mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 08:02:12 +00:00
Merge remote-tracking branch 'origin/main' into tmp/native-chat-recovery-main-integration
# Conflicts: # src/main/runtime/orca-runtime.ts # src/main/startup/desktop-startup-ordering.test.ts # src/shared/child-process/__fixtures__/child-process-import-allowlist.txt
This commit is contained in:
@@ -425,6 +425,7 @@ jobs:
|
||||
src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
|
||||
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
|
||||
src/shared/pty-reply-echo-shapes.node-pty.test.ts \
|
||||
src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
src/shared/posix-command-path-lookup.test.ts
|
||||
|
||||
@@ -470,6 +471,7 @@ jobs:
|
||||
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
|
||||
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
|
||||
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
|
||||
@@ -24,8 +24,7 @@ const PLAIN_NODE_ENTRY_NAMES = [
|
||||
'parcel-watcher-process-entry',
|
||||
'computer-sidecar',
|
||||
'wsl-transcript-fs-process-entry',
|
||||
'agent-hooks/managed-agent-hook-controls',
|
||||
'codex/codex-app-server-grant-entry'
|
||||
'agent-hooks/managed-agent-hook-controls'
|
||||
] as const
|
||||
|
||||
// Entries executed as worker threads of the main process. Electron's module is
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"src/main/ports/port-scan-command-worker-entry.ts",
|
||||
"src/main/ipc/parcel-watcher-process-entry.ts",
|
||||
"src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts",
|
||||
"src/main/codex/codex-app-server-grant-entry.ts",
|
||||
"src/main/agent-hooks/managed-agent-hook-controls.ts",
|
||||
"src/main/claude-accounts/keychain.ts",
|
||||
"src/renderer/src/main.tsx",
|
||||
|
||||
@@ -2,13 +2,22 @@ diff --git a/binding.gyp b/binding.gyp
|
||||
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..5f15551cb1520af996f216500a83ac68b57f5104 100644
|
||||
--- a/binding.gyp
|
||||
+++ b/binding.gyp
|
||||
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"target_name": "windows_process_tree",
|
||||
"dependencies": [
|
||||
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
|
||||
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
|
||||
],
|
||||
"conditions": [
|
||||
['OS=="win"', {
|
||||
@@ -16,9 +16,6 @@
|
||||
],
|
||||
"include_dirs": [],
|
||||
"libraries": [ 'psapi.lib' ],
|
||||
- "msvs_configuration_attributes": {
|
||||
- "SpectreMitigation": "Spectre"
|
||||
- },
|
||||
- "msvs_configuration_attributes": {
|
||||
- "SpectreMitigation": "Spectre"
|
||||
- },
|
||||
"msvs_settings": {
|
||||
"VCCLCompilerTool": {
|
||||
"AdditionalOptions": [
|
||||
@@ -20,7 +29,7 @@ index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5
|
||||
process_info.push_back(std::move(pinfo));
|
||||
process_count++;
|
||||
}
|
||||
- } while (process_count < 1024 && Process32Next(snapshot_handle, &process_entry));
|
||||
- } while (process_count < 1024 && Process32Next(snapshot_handle, &process_entry));
|
||||
+ } while (Process32Next(snapshot_handle, &process_entry));
|
||||
}
|
||||
|
||||
|
||||
@@ -2094,15 +2094,16 @@
|
||||
"providers": ["local", "daemon", "ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "daemon", "ssh"],
|
||||
"coverageNotes": "Deterministic unit coverage exercises activation gating, single-instance ownership, quit policy, local/remote CLI status, headless binding persistence, local daemon identity, SSH identity transfer, the promoted renderer's agent-resume accounting, and the macOS serve update handoff from staged installer through atomic bundle replacement and target-version readiness. A macOS Electron journey covers headless promotion and persistent PTY identity. A disposable locally signed Electron canary exercised real ShipIt and a temporary LaunchAgent with the compiled production supervisor; full packaged Orca and Linux/Windows serve updates remain uncollected.",
|
||||
"coverageNotes": "Deterministic unit coverage exercises activation gating, single-instance ownership, quit policy, local/remote CLI status, headless binding persistence, local daemon identity, SSH identity transfer, the promoted renderer's agent-resume accounting, and the macOS serve update handoff from staged installer through atomic bundle replacement and target-version readiness. Supervisor settlement coverage prevents a late handoff-completion failure from rearming termination after the replacement child exits. A macOS Electron journey covers headless promotion and persistent PTY identity. A disposable locally signed Electron canary exercised real ShipIt and a temporary LaunchAgent with the compiled production supervisor; full packaged Orca and Linux/Windows serve updates remain uncollected.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/8457",
|
||||
"https://github.com/stablyai/orca/issues/9563"
|
||||
],
|
||||
"invariant": "A safely promotable headless serve process is the single app owner. Desktop activation preserves its daemon-backed sessions. On macOS, a CLI-supervised serve update keeps the node-mode parent alive across ShipIt's atomic bundle swap, restarts with the original serve arguments only after the target bundle is present, and clears handoff state only after that target version reports runtime readiness. Unsupported or failed handoffs leave the current serving owner intact or recover it once without an install retry loop.",
|
||||
"oracle": "Unit tests coalesce early activation, preserve daemon and SSH identity, and reproduce the update race with a staged target, old serving child, persistent CLI parent, atomic .app replacement, and replacement readiness message. They assert the parent does not exit for launchd to respawn the old app, the native updater does not launch an interactive GUI, the replacement version is verified before handoff completion, mismatches become durable failures without retries, and unsupported/preflight-failed installs do not invoke native quit or PTY cleanup. A joined lock-owner/activation/hydration contract asserts that a forced relaunch opens exactly one window and that the renderer promoted inside the serve process launches zero agent resumes, creates no replacement tab or startup command, and leaves every surviving session record untouched. The Electron journey independently verifies headless promotion retains owner/runtime/daemon/PTY identity and terminal I/O.",
|
||||
"invariant": "A safely promotable headless serve process is the single app owner. Desktop activation preserves its daemon-backed sessions. On macOS, a CLI-supervised serve update keeps the node-mode parent alive across ShipIt's atomic bundle swap, restarts with the original serve arguments only after the target bundle is present, and clears handoff state only after that target version reports runtime readiness. Once a supervised child exits or fails to start, no late handoff completion may signal it or arm force-kill escalation. Unsupported or failed handoffs leave the current serving owner intact or recover it once without an install retry loop.",
|
||||
"oracle": "Unit tests coalesce early activation, preserve daemon and SSH identity, and reproduce the update race with a staged target, old serving child, persistent CLI parent, atomic .app replacement, and replacement readiness message. They assert the parent does not exit for launchd to respawn the old app, the native updater does not launch an interactive GUI, the replacement version is verified before handoff completion, mismatches become durable failures without retries, and unsupported/preflight-failed installs do not invoke native quit or PTY cleanup. Force handoff completion to fail after the replacement child exits and require zero later child signals and zero escalation timers. A joined lock-owner/activation/hydration contract asserts that a forced relaunch opens exactly one window and that the renderer promoted inside the serve process launches zero agent resumes, creates no replacement tab or startup command, and leaves every surviving session record untouched. The Electron journey independently verifies headless promotion retains owner/runtime/daemon/PTY identity and terminal I/O.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/cli/runtime/launch.test.ts src/main/serve-update-handoff.test.ts src/main/updater.headless-serve-install.test.ts src/main/updater.test.ts src/main/updater.mac-install.test.ts src/main/window/attach-main-window-services.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/cli/runtime/serve-signal-exit-diagnostic.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/startup/serve-desktop-activation.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts src/main/startup/single-instance-lock.test.ts src/main/startup/window-all-closed-quit-policy.test.ts src/cli/runtime-client.test.ts src/cli/runtime/websocket-transport.test.ts src/main/runtime/orca-runtime.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/serve-desktop-promotion-session-continuity.test.ts",
|
||||
"pnpm exec electron-vite build --mode e2e",
|
||||
@@ -2112,6 +2113,7 @@
|
||||
"src/main/updater.headless-serve-install.test.ts",
|
||||
"src/main/serve-update-handoff.test.ts",
|
||||
"src/cli/runtime/launch.test.ts",
|
||||
"src/cli/runtime/serve-signal-exit-diagnostic.test.ts",
|
||||
"src/main/startup/serve-desktop-activation.test.ts",
|
||||
"src/main/startup/serve-desktop-activation-wiring.test.ts",
|
||||
"src/main/startup/single-instance-lock.test.ts",
|
||||
@@ -2144,6 +2146,12 @@
|
||||
"a replacement version mismatch or readiness timeout is persisted and exits without an in-process retry loop"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/cli/runtime/serve-signal-exit-diagnostic.test.ts",
|
||||
"assertions": [
|
||||
"late update handoff failure cannot rearm termination after child exit"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/serve-update-handoff.test.ts",
|
||||
"assertions": [
|
||||
@@ -13116,7 +13124,7 @@
|
||||
"invariant": "The desktop PTY input queue retains at most 64 explicitly sourced pending terminal query replies and 4096 UTF-16 code units. Every retained reply reaches the provider as one atomic write, and the host writes each reply the moment it accepts it, so replies reach the PTY in the order they were produced with no queue that could reorder them. A reply's own echo is contained on the output side by projecting its known echo shapes; the ESC-initial verbatim shape is matched only when complete, never held as a partial, so a query torn at its own ESC is still answered. Overflow removes only the oldest query replies, never ordinary input except the documented modified-F3/CPR byte collision, and drain failures cannot clear a newer queue generation.",
|
||||
"oracle": "Synchronously enqueue separate 10,000-entry OSC and DA1 reply floods before the scheduled drain and assert that only the initial immediate reply and newest 64 pending replies are written, each as one provider write, before a trailing keystroke. At the host boundary, defer an OSC reply and assert that separate or legacy-coalesced DA1/CPR replies flush after it in observed query order. At the remote-runtime boundary, preserve separate writes around pending ordinary input, async validation, and viewport-claim buffering. Repeat behind 10,000 ordinary inputs and exercise the text ceiling, real xterm generation, provider-write failure, rejected yield, and clear/reuse generation fencing.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts src/shared/pty-startup-reply-echo-shapes.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts",
|
||||
"pnpm exec playwright test tests/e2e/terminal-osc-color-queries.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"pnpm exec playwright test tests/e2e/terminal-typing-latency.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
@@ -13130,6 +13138,7 @@
|
||||
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts",
|
||||
"src/shared/terminal-query-reply.test.ts",
|
||||
"src/shared/pty-startup-ingress-live-query-reply.test.ts",
|
||||
"src/shared/pty-startup-reply-echo-shapes.test.ts",
|
||||
"tests/e2e/terminal-osc-color-queries.spec.ts",
|
||||
"tests/e2e/terminal-typing-latency.spec.ts",
|
||||
"tests/e2e/pty-input-write-queue-ssh.spec.ts"
|
||||
@@ -13208,13 +13217,13 @@
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-09",
|
||||
"date": "2026-08-25",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts src/shared/pty-startup-reply-echo-shapes.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 2.35,
|
||||
"summary": "Four files and 138 tests passed, including explicit IPC reply-source routing, the 10,000-reply count ceiling, text ceiling, 10,000-entry ordinary backlog preservation, real xterm OSC query flood, single-shot drain-failure recovery, one-reply-per-write echo containment, and clear/reuse generation fencing."
|
||||
"durationSeconds": 1.05,
|
||||
"summary": "Five files and 92 tests passed, including the live-pty echo-shape transcript, including explicit IPC reply-source routing, the 10,000-reply count ceiling, text ceiling, 10,000-entry ordinary backlog preservation, real xterm OSC query flood, single-shot drain-failure recovery, one-reply-per-write echo containment, and clear/reuse generation fencing."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-09",
|
||||
@@ -16221,6 +16230,130 @@
|
||||
],
|
||||
"demotionRule": "Demote if a live parked PTY loses its exact graph leaf, a retired PTY remains published, multi-pane identity drifts, or the routed client round trip flakes without a diagnosed cause."
|
||||
},
|
||||
{
|
||||
"id": "agent-browser.owner-boundary-cleanup",
|
||||
"title": "Headless browser helpers retire with their owning page and runtime",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "browser-runtime",
|
||||
"layer": "electron-main-headless-browser-lifecycle",
|
||||
"surfaces": [
|
||||
"headless offscreen browser page close",
|
||||
"offscreen renderer destruction",
|
||||
"app quit",
|
||||
"headless serve SIGINT/SIGTERM"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "remote-runtime", "ssh", "wsl"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "The owner-boundary contract injects an isolated BrowserManager, offscreen WebContents, and AgentBrowserBridge. It proves explicit close removes the page from command routing before awaiting exact named-session retirement, runs retirement on unexpected renderer destruction, preserves unrelated pages, joins pending creation/retirement during shutdown, rejects command admission after terminal cleanup begins, retries Electron quit after a vetoed signal, preserves Windows shared-console graceful Ctrl-C, reserves the full renderer-acknowledgement and committed-teardown budgets before supervisor force-kill, bounds process-swap retirement to five seconds, and caps cleanup concurrency at four. A built-CLI macOS headless serve run used an isolated profile, folder workspace, socket directory, and port: exact PPID-1 helper/session identity disappeared within five seconds after page close and after Ctrl-C runtime shutdown, while an unrelated page survived close and reconnect. Linux cgroup and paired/SSH journeys remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-5400",
|
||||
"https://github.com/stablyai/orca/issues/16367"
|
||||
],
|
||||
"invariant": "For each Orca-owned page identity, after its owner closes or is destroyed and a five-second third-party shutdown grace expires, no live helper session named orca-tab-<pageId> may remain; unrelated live page identities must survive, and runtime quit must join the bounded cleanup attempt. The serve supervisor may force-kill only after the renderer-acknowledgement deadline, committed teardown deadline, and bounded scheduling margin have all elapsed.",
|
||||
"oracle": "Create two isolated offscreen pages and register their WebContents. Close one, block onPageClosed for its stable page ID, and require unregisterGuest to have already removed that page from command routing while the unrelated page remains live. Emit destroyed and require the same exact retirement. Race shutdown with creation, pending retirement, and process-swap destruction; require no replacement session or command after terminal cleanup starts, require shutdown to remain pending until retirement settles, require the swap close command to use the five-second cleanup timeout, and require at most four concurrent close commands. Deliver repeated signals and require every attempt to reach Electron quit; under Windows shared-console semantics require the child to handle Ctrl-C without an immediate child.kill. Advance the supervisor clock through the renderer-acknowledgement and committed teardown deadlines and require no SIGKILL until the bounded scheduling margin elapses. In direct built-CLI serve, inspect exact session/PID/socket identity plus RSS/fd inventory before close and after a five-second grace; reconnect between commands and stop via Ctrl-C. No process-name kill or global sweep is permitted.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/cli/runtime/serve-signal-exit-diagnostic.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/agent-browser-bridge-session-lifecycle.test.ts src/main/browser/agent-browser-bridge-tab-routing.test.ts src/main/startup/serve-signal-handlers.test.ts src/main/startup/desktop-startup-ordering.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/browser"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/browser/offscreen-browser-backend-lifecycle.test.ts",
|
||||
"src/main/browser/agent-browser-bridge-session-lifecycle.test.ts",
|
||||
"src/main/browser/agent-browser-bridge-tab-routing.test.ts",
|
||||
"src/main/startup/serve-signal-handlers.test.ts",
|
||||
"src/main/startup/desktop-startup-ordering.test.ts",
|
||||
"src/cli/runtime/serve-signal-exit-diagnostic.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/browser/offscreen-browser-backend-lifecycle.test.ts",
|
||||
"assertions": [
|
||||
"explicit close unregisters the page before awaiting exact owner cleanup while unrelated and same-ID replacement pages survive",
|
||||
"unexpected offscreen renderer destruction invokes and joins exact helper-owner cleanup",
|
||||
"backend shutdown joins every pending owner cleanup with concurrency capped at four"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/agent-browser-bridge-session-lifecycle.test.ts",
|
||||
"assertions": [
|
||||
"owner cleanup uses a five-second close-command timeout",
|
||||
"process-swap retirement uses the same five-second cleanup timeout",
|
||||
"runtime shutdown includes sessions whose creation is still pending",
|
||||
"runtime shutdown rejects late command and replacement-session admission",
|
||||
"runtime-wide helper cleanup concurrency is capped at four"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/agent-browser-bridge-tab-routing.test.ts",
|
||||
"assertions": [
|
||||
"closing a tab retires the exact named agent-browser session"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/startup/serve-signal-handlers.test.ts",
|
||||
"assertions": [
|
||||
"every repeated serve signal retries Electron quit after a possible renderer veto",
|
||||
"SIGINT and SIGTERM listeners remain installed during quit draining"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/startup/desktop-startup-ordering.test.ts",
|
||||
"assertions": [
|
||||
"agent-browser cleanup is joined by the committed quit teardown barrier",
|
||||
"repeatable serve signal handling is registered before readiness"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/cli/runtime/serve-signal-exit-diagnostic.test.ts",
|
||||
"assertions": [
|
||||
"serve supervisor force-kill grace covers renderer acknowledgement, committed teardown, and scheduling margin",
|
||||
"Windows shared-console Ctrl-C is not forwarded as an immediate child termination"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-26",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/cli/runtime/serve-signal-exit-diagnostic.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/agent-browser-bridge-session-lifecycle.test.ts src/main/browser/agent-browser-bridge-tab-routing.test.ts src/main/startup/serve-signal-handlers.test.ts src/main/startup/desktop-startup-ordering.test.ts",
|
||||
"durationSeconds": 0.44,
|
||||
"summary": "Candidate passed 63 lifecycle, bridge, routing, signal, and quit-order tests plus 1,469 browser tests. The production-reverted control was red; disabling the final swap-timeout/admission controls failed 3 of 15 bridge lifecycle tests; removing the pending-retirement join, page-routing fence, repeat-signal behavior, or Windows shared-console guard failed its exact focused oracle. Setting the supervisor scheduling margin to zero reproduced SIGKILL at the combined 30-second renderer-acknowledgement and teardown boundary. Direct built-CLI serve observed exact PPID-1 helpers at 10-11 MiB RSS and 16-18 fd rows. Closing one page removed only its helper/session within five seconds while the other page survived reconnect. With the signal fix disabled, Ctrl-C left exact helpers alive after the listener exited; the final candidate emptied session inventory and removed app/helper PIDs and port within five seconds."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 2,
|
||||
"scope": "bounded offscreen lifecycle contracts and bridge cleanup ordering"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "Fresh deterministic coverage has local candidate evidence only."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The pristine current-main control failed explicit close, unexpected destruction, backend shutdown, pending creation, bounded concurrency, joined quit, and repeated-signal assertions; the candidate passed the byte-identical oracle. Disabling only process-swap cleanup timeout and terminal command admission failed 3 of 15 bridge lifecycle tests. Live candidate-with-signal-fix-disabled also retained exact helpers after Ctrl-C, while the final candidate removed them."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Cleanup is event-driven and exact-page scoped: no polling, process-name scan, idle timer, or new wire field. Owner and residual session drains cap close subprocess concurrency at four, sequence headless ownership cleanup before the residual bridge sweep, and reuse the existing bounded app teardown deadline."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run the same oracle on Linux headless serve with bundled agent-browser PID/session and RSS/fd inventory.",
|
||||
"Exercise runtime restart, transport loss, reconnect, concurrent tabs, and paired/SSH host ownership without cross-page cleanup.",
|
||||
"Collect repeated open/close/reconnect evidence showing bounded helper count and no stale PID-1-owned Orca identities."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No 64 GiB Linux cgroup stress was manufactured; the reported production incident remains un-soaked.",
|
||||
"The real Electron headless serve oracle ran on macOS; physical Linux/SSH/WSL and paired-client journeys remain unvalidated.",
|
||||
"A close-command timeout or error is treated as best-effort and does not escalate to a process kill; helper disappearance on that failure path remains unverifiable without exact PID ownership.",
|
||||
"Four-way close batches remain bounded but can exceed the 20-second app quit barrier above 16 worst-case five-second retirements.",
|
||||
"The patch does not add startup stale-process recovery or an idle timeout; those require an exact persisted ownership ledger and are intentionally out of scope."
|
||||
],
|
||||
"demotionRule": "Demote if a closed or destroyed page leaves its exact named helper session, if quit can exit before helper retirement settles, if unrelated live pages are retired, or if cleanup regresses to a global process-name kill or timer-only sweep."
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.remote-pane-layout-retry",
|
||||
"title": "Remote pane layouts retry identical state after reconnect",
|
||||
|
||||
@@ -56,10 +56,11 @@ function parseArgs(argv) {
|
||||
/**
|
||||
* Refuse to build unpatched source.
|
||||
*
|
||||
* Both hunks are load-bearing and fail in opposite directions: with Spectre the
|
||||
* build dies outright, and with the cap it succeeds and lies. Checking the
|
||||
* source rather than trusting the install is what stops a silently unpatched
|
||||
* tree from being shipped as if it were patched.
|
||||
* Each hunk fails differently: Spectre dies outright, the 1024-process cap
|
||||
* succeeds and lies, and `.targets` is cwd-relative so pnpm's nested layout
|
||||
* makes node-gyp miss node_addon_api.gyp on Windows. Checking the source
|
||||
* rather than trusting the install is what stops a silently unpatched tree
|
||||
* from being shipped as if it were patched.
|
||||
*/
|
||||
function assertPatchApplied() {
|
||||
const bindingGyp = readFileSync(join(PACKAGE_DIR, 'binding.gyp'), 'utf8')
|
||||
@@ -69,6 +70,13 @@ function assertPatchApplied() {
|
||||
'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
|
||||
)
|
||||
}
|
||||
if (!bindingGyp.includes("require.resolve('node-addon-api/node_addon_api.gyp')")) {
|
||||
throw new Error(
|
||||
'binding.gyp still uses require("node-addon-api").targets. That path is ' +
|
||||
'cwd-relative and misses node_addon_api.gyp under pnpm on Windows. ' +
|
||||
'pnpm did not apply config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
|
||||
)
|
||||
}
|
||||
const processCc = readFileSync(join(PACKAGE_DIR, 'src', 'process.cc'), 'utf8')
|
||||
if (processCc.includes('process_count < 1024')) {
|
||||
throw new Error(
|
||||
|
||||
@@ -190,8 +190,10 @@ function summarizeMemory(snapshot: MemorySnapshot): {
|
||||
app: MemorySnapshot['app']
|
||||
host: MemorySnapshot['host']
|
||||
processMemoryMetric: MemorySnapshot['processMemoryMetric']
|
||||
processCommitMetric: MemorySnapshot['processCommitMetric']
|
||||
totalCpu: number
|
||||
totalMemory: number
|
||||
totalPrivateMemory: MemorySnapshot['totalPrivateMemory']
|
||||
worktreeCount: number
|
||||
sessionCount: number
|
||||
worktreeMemory: number
|
||||
@@ -208,8 +210,10 @@ function summarizeMemory(snapshot: MemorySnapshot): {
|
||||
app: snapshot.app,
|
||||
host: snapshot.host,
|
||||
processMemoryMetric: snapshot.processMemoryMetric,
|
||||
processCommitMetric: snapshot.processCommitMetric,
|
||||
totalCpu: snapshot.totalCpu,
|
||||
totalMemory: snapshot.totalMemory,
|
||||
totalPrivateMemory: snapshot.totalPrivateMemory,
|
||||
worktreeCount: snapshot.worktrees.length,
|
||||
sessionCount: snapshot.worktrees.reduce(
|
||||
(total, worktree) => total + worktree.sessions.length,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const PATCH = readFileSync(
|
||||
join(projectDir, 'config/patches/@vscode__windows-process-tree@0.8.0.patch'),
|
||||
'utf8'
|
||||
)
|
||||
const PACKAGE_DIR = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
|
||||
const ABSOLUTE_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
|
||||
|
||||
describe('windows-process-tree node-addon-api gyp path', () => {
|
||||
it('keeps the gyp project path absolute so pnpm Windows source builds find it', () => {
|
||||
expect(PATCH).toContain(
|
||||
`+ "<!(node -p \\"require.resolve('node-addon-api/node_addon_api.gyp')\\"):node_addon_api_except"`
|
||||
)
|
||||
const bindingGyp = readFileSync(join(PACKAGE_DIR, 'binding.gyp'), 'utf8')
|
||||
expect(bindingGyp).toContain(ABSOLUTE_GYP)
|
||||
expect(bindingGyp).not.toContain("require('node-addon-api').targets")
|
||||
expect(
|
||||
readFileSync(
|
||||
join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
).toContain(ABSOLUTE_GYP)
|
||||
})
|
||||
|
||||
it('resolves node_addon_api.gyp to a real file from the package directory', () => {
|
||||
const resolved = execFileSync(process.execPath, ['-p', ABSOLUTE_GYP], {
|
||||
cwd: PACKAGE_DIR,
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
expect(isAbsolute(resolved)).toBe(true)
|
||||
expect(existsSync(resolved)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -30,8 +30,6 @@
|
||||
"../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-grant-bridge.ts",
|
||||
"../src/main/codex/codex-app-server-grant-envelope.ts",
|
||||
"../src/main/codex/codex-app-server-session.ts",
|
||||
"../src/main/codex/codex-config-mirror.ts",
|
||||
"../src/main/codex/codex-config-path-reference-rewrite.ts",
|
||||
@@ -40,6 +38,7 @@
|
||||
"../src/main/codex/codex-config-settings-upsert.ts",
|
||||
"../src/main/codex/codex-home-paths.ts",
|
||||
"../src/main/codex/codex-managed-home-resource-copy-marker.ts",
|
||||
"../src/main/codex/codex-managed-trust-grant-plan.ts",
|
||||
"../src/main/codex/codex-path-observation.ts",
|
||||
"../src/main/codex/codex-hook-identity.ts",
|
||||
"../src/main/codex/codex-hook-trust-grant.ts",
|
||||
@@ -48,6 +47,7 @@
|
||||
"../src/main/codex/codex-state-db.ts",
|
||||
"../src/main/codex/codex-trust-identity.ts",
|
||||
"../src/main/codex/codex-trust-config-rollback.ts",
|
||||
"../src/main/codex/codex-trust-config-mutation-queue.ts",
|
||||
"../src/main/codex/codex-trust-grant-telemetry.ts",
|
||||
"../src/main/codex/codex-trust-grant-host.ts",
|
||||
"../src/main/codex/codex-trust-grant-ledger.ts",
|
||||
|
||||
@@ -116,11 +116,11 @@ it as an optional relay artifact.
|
||||
|
||||
`config/scripts/build-windows-process-tree-relay-addon.mjs` builds it from the
|
||||
source pnpm has already patched, on a Windows runner, and refuses to run if
|
||||
either patch hunk is missing — the Spectre hunk fails loudly, but the
|
||||
1024-process hunk fails *silently*, so the source is checked rather than the
|
||||
install trusted. It also reads the PE machine field of the output, because a
|
||||
cross-build that quietly emitted host arch would ship a binary the target cannot
|
||||
load.
|
||||
any patch hunk is missing — the Spectre hunk fails loudly, the 1024-process
|
||||
hunk fails *silently*, and the relative gyp path dies at configure on Windows.
|
||||
The source is checked rather than the install trusted. It also reads the PE
|
||||
machine field of the output, because a cross-build that quietly emitted host
|
||||
arch would ship a binary the target cannot load.
|
||||
|
||||
Windows arm64 cross-compiles from the x64 runner — verified on real hardware,
|
||||
producing `IMAGE_FILE_MACHINE_ARM64` (0xaa64) against x64's 0x8664. It needs the
|
||||
@@ -143,7 +143,7 @@ on any other OS keeps using the scan.
|
||||
|
||||
## Why the package is patched
|
||||
|
||||
`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries two hunks.
|
||||
`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries three hunks.
|
||||
|
||||
1. **Spectre mitigation.** The upstream `binding.gyp` requires Spectre-mitigated
|
||||
libraries, which Orca's Windows build agents do not install. `node-pty` is
|
||||
@@ -153,6 +153,11 @@ on any other OS keeps using the scan.
|
||||
1024 and the querying process was itself among the 27 missing. A truncated
|
||||
snapshot silently hides the descendants a teardown is trying to reap — the
|
||||
exact failure the native path exists to remove.
|
||||
3. **Absolute `node-addon-api` gyp path.** `require('node-addon-api').targets`
|
||||
is cwd-relative. node-gyp on Windows evaluates it from the pnpm store
|
||||
realpath, then loads the relative path from the `node_modules` symlink, so
|
||||
`node_addon_api.gyp` resolves outside the repo and hourly Windows builds
|
||||
die at configure. `node-pty` is patched the same way for the same reason.
|
||||
|
||||
The typings claim `commandLine` is truncated at 512 characters. Measured, it is
|
||||
not: the longest observed on a real host was 26,059.
|
||||
@@ -177,6 +182,13 @@ time to prove a PID has not been recycled — daemon identity, managed-hook
|
||||
ownership, and CPU accounting in the memory collector — still reads it through
|
||||
its own query. Those callers are not migrated.
|
||||
|
||||
Committed private bytes have no equivalent either, and the one memory value the
|
||||
snapshot does carry is unusable for the sizes Orca now sees: `process.cc` stores
|
||||
`pmc.WorkingSetSize` into a `DWORD`, so anything above 4 GB wraps. That is the
|
||||
second reason `windows-process-resource-collector.ts` still runs its own
|
||||
`Get-CimInstance` sweep — it needs `PageFileUsage` (commit) and the CPU-time
|
||||
counters in the same pass. Migrating it to the native table would cost both.
|
||||
|
||||
Start time is a proxy for identity, not identity. The durable answer for the
|
||||
process trees Orca itself spawns is an inherited handle: a job object names the
|
||||
tree Orca created, so no start-time comparison is needed. Those readers should
|
||||
|
||||
@@ -239,12 +239,6 @@ export const electronViteConfig: UserConfig = {
|
||||
'main-thread-hang-watchdog-entry': resolve(
|
||||
'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts'
|
||||
),
|
||||
// Why: run under ELECTRON_RUN_AS_NODE while the caller blocks on
|
||||
// spawnSync — codex app-server trust grants need a live event loop
|
||||
// but must finish before a Codex pane launch proceeds.
|
||||
'codex/codex-app-server-grant-entry': resolve(
|
||||
'src/main/codex/codex-app-server-grant-entry.ts'
|
||||
),
|
||||
// Why: electron-vite cleans out/main in dev. The dev CLI imports
|
||||
// this path for `orca agent hooks ...`, so it must survive rebuilds.
|
||||
'agent-hooks/managed-agent-hook-controls': resolve(
|
||||
|
||||
Generated
+3
-3
@@ -9,7 +9,7 @@ overrides:
|
||||
|
||||
patchedDependencies:
|
||||
'@vscode/windows-process-tree@0.8.0':
|
||||
hash: 7c08bebce9b36829be6035218db2383f1a889c21ec907ea7e85c36fc54795a05
|
||||
hash: 73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0
|
||||
path: config/patches/@vscode__windows-process-tree@0.8.0.patch
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287':
|
||||
hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
|
||||
@@ -413,7 +413,7 @@ importers:
|
||||
optionalDependencies:
|
||||
'@vscode/windows-process-tree':
|
||||
specifier: 0.8.0
|
||||
version: 0.8.0(patch_hash=7c08bebce9b36829be6035218db2383f1a889c21ec907ea7e85c36fc54795a05)
|
||||
version: 0.8.0(patch_hash=73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0)
|
||||
sherpa-onnx-darwin-arm64:
|
||||
specifier: 1.12.37
|
||||
version: 1.12.37
|
||||
@@ -9617,7 +9617,7 @@ snapshots:
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vscode/windows-process-tree@0.8.0(patch_hash=7c08bebce9b36829be6035218db2383f1a889c21ec907ea7e85c36fc54795a05)':
|
||||
'@vscode/windows-process-tree@0.8.0(patch_hash=73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0)':
|
||||
dependencies:
|
||||
node-addon-api: 7.1.0
|
||||
optional: true
|
||||
|
||||
@@ -182,7 +182,7 @@ A dispatched worker normally cannot dispatch sub-workers. Attempting it fails wi
|
||||
`nested_worker_depth_exceeded` and a message telling the worker to complete the task
|
||||
itself. Do that — do not try to route around it.
|
||||
|
||||
The limit is a number, not an on/off switch. `Settings -> Agents -> Nested worker depth`
|
||||
The limit is a number, not an on/off switch. `Settings -> Orchestration -> Nested worker depth`
|
||||
sets how many generations are allowed:
|
||||
|
||||
- `1` (default): a coordinator dispatches workers; those workers do not dispatch.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -208,7 +208,7 @@ async function setAgentHooksEnabled(
|
||||
export const AGENT_HOOK_HANDLERS: Record<string, CommandHandler> = {
|
||||
'agent hooks prepare-codex': async ({ client }) => {
|
||||
const settings = await readHookSettings(client)
|
||||
prepareManagedCodexHomeBeforeShellLaunch({
|
||||
await prepareManagedCodexHomeBeforeShellLaunch({
|
||||
userDataPath: getDefaultUserDataPath(),
|
||||
hooksEnabled:
|
||||
settings.agentStatusHooksEnabled && !settings.disabledTuiAgents.includes('codex')
|
||||
|
||||
@@ -113,5 +113,112 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(output).toContain('hostAvailable: 2.0 MB (free-memory)')
|
||||
expect(output).toContain('app: 1.0 MB')
|
||||
expect(output).toContain('- feature 1.0 MB 2.5% 1 session')
|
||||
// Back-compat: a host that never heard of committed bytes prints none.
|
||||
expect(output).not.toContain('totalPrivateMemory')
|
||||
expect(output).not.toContain('processCommitMetric')
|
||||
})
|
||||
|
||||
it('reports committed private bytes alongside the resident figure', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_memory', {
|
||||
app: {
|
||||
cpu: 1.25,
|
||||
memory: 1024 * 1024,
|
||||
privateMemory: 2 * 1024 * 1024,
|
||||
main: { cpu: 0.5, memory: 512 * 1024, privateMemory: 1024 * 1024 },
|
||||
renderer: { cpu: 0.5, memory: 384 * 1024, privateMemory: 768 * 1024 },
|
||||
other: { cpu: 0.25, memory: 128 * 1024, privateMemory: 256 * 1024 },
|
||||
history: [1024 * 1024]
|
||||
},
|
||||
worktrees: [
|
||||
{
|
||||
worktreeId: 'repo::/tmp/repo/feature',
|
||||
worktreeName: 'feature',
|
||||
repoId: 'repo',
|
||||
repoName: 'Orca',
|
||||
cpu: 2.5,
|
||||
memory: 1024 * 1024,
|
||||
privateMemory: 14 * 1024 * 1024,
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'pty-1',
|
||||
paneKey: null,
|
||||
pid: 123,
|
||||
cpu: 2.5,
|
||||
memory: 1024 * 1024,
|
||||
privateMemory: 14 * 1024 * 1024
|
||||
}
|
||||
],
|
||||
history: [1024 * 1024]
|
||||
}
|
||||
],
|
||||
host: {
|
||||
totalMemory: 8 * 1024 * 1024,
|
||||
freeMemory: 2 * 1024 * 1024,
|
||||
availableMemory: 2 * 1024 * 1024,
|
||||
availableMemorySource: 'free-memory',
|
||||
usedMemory: 6 * 1024 * 1024,
|
||||
memoryUsagePercent: 75,
|
||||
cpuCoreCount: 8,
|
||||
loadAverage1m: 1.25
|
||||
},
|
||||
processMemoryMetric: 'working-set',
|
||||
processCommitMetric: 'private-bytes',
|
||||
totalCpu: 3.75,
|
||||
totalMemory: 2 * 1024 * 1024,
|
||||
totalPrivateMemory: 16 * 1024 * 1024,
|
||||
collectedAt: 1000
|
||||
})
|
||||
)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['diagnostics', 'memory'], '/tmp/repo')
|
||||
|
||||
const output = logSpy.mock.calls.flat().join('\n')
|
||||
expect(output).toContain('totalMemory: 2.0 MB')
|
||||
expect(output).toContain('processMemoryMetric: summed working set; shared pages may repeat')
|
||||
expect(output).toContain('totalPrivateMemory: 16 MB')
|
||||
expect(output).toContain(
|
||||
'processCommitMetric: summed private bytes; committed memory, counted whether resident or paged out'
|
||||
)
|
||||
expect(output).toContain('- feature 1.0 MB 14 MB committed 2.5% 1 session')
|
||||
})
|
||||
|
||||
it('passes committed bytes through --json untouched', async () => {
|
||||
const snapshot = {
|
||||
app: {
|
||||
cpu: 0,
|
||||
memory: 1024,
|
||||
privateMemory: 4096,
|
||||
main: { cpu: 0, memory: 1024, privateMemory: 4096 },
|
||||
renderer: { cpu: 0, memory: 0, privateMemory: 0 },
|
||||
other: { cpu: 0, memory: 0, privateMemory: 0 },
|
||||
history: []
|
||||
},
|
||||
worktrees: [],
|
||||
host: {
|
||||
totalMemory: 16 * 1024,
|
||||
freeMemory: 1024,
|
||||
availableMemory: 1024,
|
||||
availableMemorySource: 'free-memory',
|
||||
usedMemory: 15 * 1024,
|
||||
memoryUsagePercent: 93.75,
|
||||
cpuCoreCount: 8,
|
||||
loadAverage1m: 0
|
||||
},
|
||||
processMemoryMetric: 'working-set',
|
||||
processCommitMetric: 'private-bytes',
|
||||
totalCpu: 0,
|
||||
totalMemory: 1024,
|
||||
totalPrivateMemory: 4096,
|
||||
collectedAt: 1000
|
||||
}
|
||||
queueFixtures(callMock, okFixture('req_memory', snapshot))
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['diagnostics', 'memory', '--json'], '/tmp/repo')
|
||||
|
||||
expect(JSON.parse(logSpy.mock.calls.flat().join('\n')).result).toEqual(snapshot)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { serveSignalExitError } from './serve-signal-exit-diagnostic'
|
||||
import { superviseForegroundServe } from './serve-update-supervisor'
|
||||
import {
|
||||
SERVE_CHILD_FORCE_KILL_GRACE_MS,
|
||||
SERVE_CHILD_FORCE_KILL_SCHEDULING_MARGIN_MS,
|
||||
superviseForegroundServe
|
||||
} from './serve-update-supervisor'
|
||||
import { RuntimeClientError } from './types'
|
||||
import {
|
||||
QUIT_RENDERER_ACK_TIMEOUT_MS,
|
||||
WILL_QUIT_TEARDOWN_DEADLINE_MS
|
||||
} from '../../shared/quit-teardown-deadline'
|
||||
|
||||
class FakeChildProcess extends EventEmitter {
|
||||
kill = vi.fn()
|
||||
@@ -17,7 +28,13 @@ function setPlatform(platform: NodeJS.Platform): void {
|
||||
|
||||
function superviseUntilExit(code: number | null, signal: NodeJS.Signals | null): Promise<number> {
|
||||
const child = new FakeChildProcess()
|
||||
const supervised = superviseForegroundServe({
|
||||
const supervised = superviseChild(child)
|
||||
child.emit('exit', code, signal)
|
||||
return supervised
|
||||
}
|
||||
|
||||
function superviseChild(child: FakeChildProcess): Promise<number> {
|
||||
return superviseForegroundServe({
|
||||
executable: '/Applications/Orca.app/Contents/MacOS/Orca',
|
||||
childArgs: ['--serve'],
|
||||
spawnOptions: {},
|
||||
@@ -26,12 +43,12 @@ function superviseUntilExit(code: number | null, signal: NodeJS.Signals | null):
|
||||
child: child as never,
|
||||
expectedHandoff: null
|
||||
})
|
||||
child.emit('exit', code, signal)
|
||||
return supervised
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('serveSignalExitError', () => {
|
||||
@@ -74,6 +91,83 @@ describe('serveSignalExitError', () => {
|
||||
})
|
||||
|
||||
describe('superviseForegroundServe signal exits', () => {
|
||||
it('lets pre-commit and committed Electron quit deadlines finish before force-killing serve', async () => {
|
||||
setPlatform('linux')
|
||||
vi.useFakeTimers()
|
||||
const child = new FakeChildProcess()
|
||||
const supervised = superviseChild(child)
|
||||
|
||||
expect(SERVE_CHILD_FORCE_KILL_GRACE_MS).toBe(
|
||||
QUIT_RENDERER_ACK_TIMEOUT_MS +
|
||||
WILL_QUIT_TEARDOWN_DEADLINE_MS +
|
||||
SERVE_CHILD_FORCE_KILL_SCHEDULING_MARGIN_MS
|
||||
)
|
||||
expect(SERVE_CHILD_FORCE_KILL_GRACE_MS).toBeLessThanOrEqual(35_000)
|
||||
|
||||
process.emit('SIGTERM', 'SIGTERM')
|
||||
expect(child.kill).toHaveBeenCalledOnce()
|
||||
expect(child.kill).toHaveBeenLastCalledWith('SIGTERM')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(QUIT_RENDERER_ACK_TIMEOUT_MS + WILL_QUIT_TEARDOWN_DEADLINE_MS)
|
||||
expect(child.kill).toHaveBeenCalledOnce()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(SERVE_CHILD_FORCE_KILL_SCHEDULING_MARGIN_MS)
|
||||
expect(child.kill).toHaveBeenLastCalledWith('SIGKILL')
|
||||
expect(child.kill).toHaveBeenCalledTimes(2)
|
||||
|
||||
child.emit('exit', null, 'SIGKILL')
|
||||
await expect(supervised).rejects.toThrow('Orca serve exited via SIGKILL.')
|
||||
})
|
||||
|
||||
it('lets a shared-console Windows child handle Ctrl-C gracefully', async () => {
|
||||
setPlatform('win32')
|
||||
vi.useFakeTimers()
|
||||
const child = new FakeChildProcess()
|
||||
const supervised = superviseChild(child)
|
||||
|
||||
process.emit('SIGINT', 'SIGINT')
|
||||
expect(child.kill).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
child.emit('exit', 0, null)
|
||||
await expect(supervised).resolves.toBe(0)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not terminate an exited child when update handoff completion fails late', async () => {
|
||||
vi.useFakeTimers()
|
||||
const missingParent = await mkdtemp(join(tmpdir(), 'orca-serve-missing-handoff-'))
|
||||
await rm(missingParent, { recursive: true })
|
||||
const child = new FakeChildProcess()
|
||||
const supervised = superviseForegroundServe({
|
||||
executable: '/Applications/Orca.app/Contents/MacOS/Orca',
|
||||
childArgs: ['--serve'],
|
||||
spawnOptions: {},
|
||||
spawnChild: vi.fn() as never,
|
||||
handoffPath: join(missingParent, 'handoff.json'),
|
||||
child: child as never,
|
||||
expectedHandoff: {
|
||||
schemaVersion: 1,
|
||||
phase: 'install-requested',
|
||||
fromVersion: '1.0.51',
|
||||
targetVersion: '1.0.61',
|
||||
servingPid: child.pid
|
||||
}
|
||||
})
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
child.emit('message', {
|
||||
type: 'orca:serve-ready',
|
||||
version: '1.0.61',
|
||||
runtimeId: 'runtime-new'
|
||||
})
|
||||
child.emit('exit', 0, null)
|
||||
|
||||
await expect(supervised).resolves.toBe(1)
|
||||
expect(child.kill).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('throws the macOS diagnostic when the child aborts on darwin', async () => {
|
||||
setPlatform('darwin')
|
||||
|
||||
|
||||
@@ -6,10 +6,19 @@ import {
|
||||
parseServeUpdateHandoffState,
|
||||
type ServeUpdateHandoffState
|
||||
} from '../../shared/serve-update-handoff'
|
||||
import {
|
||||
QUIT_RENDERER_ACK_TIMEOUT_MS,
|
||||
WILL_QUIT_TEARDOWN_DEADLINE_MS
|
||||
} from '../../shared/quit-teardown-deadline'
|
||||
import { serveSignalExitError } from './serve-signal-exit-diagnostic'
|
||||
import { waitForMacBundleVersion } from './mac-app-update-bundle'
|
||||
|
||||
export const SERVE_REPLACEMENT_READY_TIMEOUT_MS = 60_000
|
||||
export const SERVE_CHILD_FORCE_KILL_SCHEDULING_MARGIN_MS = 5_000
|
||||
export const SERVE_CHILD_FORCE_KILL_GRACE_MS =
|
||||
QUIT_RENDERER_ACK_TIMEOUT_MS +
|
||||
WILL_QUIT_TEARDOWN_DEADLINE_MS +
|
||||
SERVE_CHILD_FORCE_KILL_SCHEDULING_MARGIN_MS
|
||||
|
||||
type InstallRequestedHandoff = Extract<ServeUpdateHandoffState, { phase: 'install-requested' }>
|
||||
type ServeReadiness = 'not-expected' | 'pending' | 'verified' | 'failed'
|
||||
@@ -111,9 +120,13 @@ function waitForForegroundChild(
|
||||
let readyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let readiness: ServeReadiness = expected ? 'pending' : 'not-expected'
|
||||
let stateWrite = Promise.resolve()
|
||||
let childSettled = false
|
||||
const terminateChild = (): void => {
|
||||
if (childSettled) {
|
||||
return
|
||||
}
|
||||
child.kill('SIGTERM')
|
||||
forceKillTimer ??= setTimeout(() => child.kill('SIGKILL'), 5000)
|
||||
forceKillTimer ??= setTimeout(() => child.kill('SIGKILL'), SERVE_CHILD_FORCE_KILL_GRACE_MS)
|
||||
}
|
||||
const recordReplacementFailure = (reason: string): boolean => {
|
||||
if (!expected || readiness !== 'pending') {
|
||||
@@ -140,8 +153,11 @@ function waitForForegroundChild(
|
||||
terminateChild()
|
||||
}
|
||||
const forwardSignal = (signal: NodeJS.Signals): void => {
|
||||
child.kill(signal)
|
||||
forceKillTimer ??= setTimeout(() => child.kill('SIGKILL'), 5000)
|
||||
// A Windows console delivers Ctrl-C to parent and child; child.kill would terminate the child mid-teardown.
|
||||
if (process.platform !== 'win32') {
|
||||
child.kill(signal)
|
||||
}
|
||||
forceKillTimer ??= setTimeout(() => child.kill('SIGKILL'), SERVE_CHILD_FORCE_KILL_GRACE_MS)
|
||||
}
|
||||
const handleMessage = (value: unknown): void => {
|
||||
const message = parseServeSupervisorMessage(value)
|
||||
@@ -195,10 +211,12 @@ function waitForForegroundChild(
|
||||
}, SERVE_REPLACEMENT_READY_TIMEOUT_MS)
|
||||
}
|
||||
const handleExit = (code: number | null, signal: NodeJS.Signals | null): void => {
|
||||
childSettled = true
|
||||
cleanup()
|
||||
void stateWrite.then(() => resolveWait({ code, signal, readiness }))
|
||||
}
|
||||
child.once('error', (error) => {
|
||||
childSettled = true
|
||||
recordReplacementFailure(`Could not start the replacement process: ${String(error)}`)
|
||||
cleanup()
|
||||
child.off('exit', handleExit)
|
||||
|
||||
@@ -16,6 +16,7 @@ export function formatMemorySnapshot(snapshot: MemorySnapshot): string {
|
||||
`collectedAt: ${new Date(snapshot.collectedAt).toISOString()}`,
|
||||
`totalMemory: ${formatByteCount(snapshot.totalMemory)}`,
|
||||
`processMemoryMetric: ${formatProcessMemoryMetric(snapshot.processMemoryMetric)}`,
|
||||
...formatCommitLines(snapshot),
|
||||
`totalCpu: ${formatCpu(snapshot.totalCpu)}`,
|
||||
[
|
||||
`hostUsed: ${formatByteCount(snapshot.host.usedMemory)}`,
|
||||
@@ -51,11 +52,32 @@ function formatWorktreeMemoryLine(worktree: WorktreeMemory): string {
|
||||
return [
|
||||
`- ${worktree.worktreeName}`,
|
||||
`${formatByteCount(worktree.memory)}`,
|
||||
...(worktree.privateMemory === undefined
|
||||
? []
|
||||
: [`${formatByteCount(worktree.privateMemory)} committed`]),
|
||||
`${formatCpu(worktree.cpu)}`,
|
||||
`${worktree.sessions.length} session${worktree.sessions.length === 1 ? '' : 's'}`
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
// Why omitted rather than zeroed: a host that predates the field, or cannot read
|
||||
// commit at all, must not be printed as agents committing nothing.
|
||||
function formatCommitLines(snapshot: MemorySnapshot): string[] {
|
||||
if (typeof snapshot.totalPrivateMemory !== 'number') {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
`totalPrivateMemory: ${formatByteCount(snapshot.totalPrivateMemory)}`,
|
||||
`processCommitMetric: ${formatProcessCommitMetric(snapshot.processCommitMetric)}`
|
||||
]
|
||||
}
|
||||
|
||||
function formatProcessCommitMetric(metric: MemorySnapshot['processCommitMetric']): string {
|
||||
return metric === 'private-bytes'
|
||||
? 'summed private bytes; committed memory, counted whether resident or paged out'
|
||||
: `summed ${metric ?? 'unknown'}`
|
||||
}
|
||||
|
||||
function formatCpu(cpu: number): string {
|
||||
return `${cpu.toFixed(1)}%`
|
||||
}
|
||||
|
||||
@@ -86,14 +86,14 @@ function selectedInstallers(options: InstallOptions): readonly ManagedAgentHookI
|
||||
return MANAGED_AGENT_HOOK_INSTALLERS.filter(([agent]) => allowed.has(agent))
|
||||
}
|
||||
|
||||
function runInstaller(
|
||||
async function runInstaller(
|
||||
entry: ManagedAgentHookInstaller,
|
||||
onInstallError: InstallOptions['onInstallError'],
|
||||
userInitiated?: boolean
|
||||
): AgentHookInstallStatus {
|
||||
): Promise<AgentHookInstallStatus> {
|
||||
const [agent, install] = entry
|
||||
try {
|
||||
return install({ userInitiated })
|
||||
return await install({ userInitiated })
|
||||
} catch (error) {
|
||||
console.error(`[agent-hooks] Failed to install ${agent} managed hooks:`, error)
|
||||
try {
|
||||
@@ -177,22 +177,27 @@ export async function installManagedAgentHooks(
|
||||
)
|
||||
continue
|
||||
}
|
||||
results.push(runInstaller(entry, options.onInstallError, options.userInitiated))
|
||||
results.push(await runInstaller(entry, options.onInstallError, options.userInitiated))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export function removeManagedAgentHooks(options: RemoveOptions = {}): AgentHookInstallStatus[] {
|
||||
export async function removeManagedAgentHooks(
|
||||
options: RemoveOptions = {}
|
||||
): Promise<AgentHookInstallStatus[]> {
|
||||
const allowed = options.agents ? new Set(options.agents) : null
|
||||
return MANAGED_AGENT_HOOK_REMOVERS.filter(
|
||||
([agent]) => allowed === null || allowed.has(agent)
|
||||
).map(([agent, remove]) => {
|
||||
try {
|
||||
return remove()
|
||||
} catch (error) {
|
||||
return errorStatus(agent, error)
|
||||
const results: AgentHookInstallStatus[] = []
|
||||
for (const [agent, remove] of MANAGED_AGENT_HOOK_REMOVERS) {
|
||||
if (allowed !== null && !allowed.has(agent)) {
|
||||
continue
|
||||
}
|
||||
})
|
||||
try {
|
||||
results.push(await remove())
|
||||
} catch (error) {
|
||||
results.push(errorStatus(agent, error))
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export async function removeManagedAgentHooksAsync(
|
||||
@@ -228,7 +233,7 @@ export async function applyAgentStatusHooksEnabled(
|
||||
options: InstallOptions = {}
|
||||
): Promise<AgentHookInstallStatus[]> {
|
||||
if (!enabled) {
|
||||
return removeManagedAgentHooks()
|
||||
return await removeManagedAgentHooks()
|
||||
}
|
||||
const disabled = normalizeDisabledTuiAgents(settings?.disabledTuiAgents).filter(
|
||||
isManagedAgentHookTarget
|
||||
@@ -241,7 +246,10 @@ export async function applyAgentStatusHooksEnabled(
|
||||
return installed
|
||||
}
|
||||
const removed = new Map(
|
||||
removeManagedAgentHooks({ agents: disabledToRemove }).map((status) => [status.agent, status])
|
||||
(await removeManagedAgentHooks({ agents: disabledToRemove })).map((status) => [
|
||||
status.agent,
|
||||
status
|
||||
])
|
||||
)
|
||||
return installed.map((status) => removed.get(status.agent) ?? status)
|
||||
}
|
||||
|
||||
@@ -15,13 +15,21 @@ import { hermesHookService } from '../hermes/hook-service'
|
||||
import { kimiHookService } from '../kimi/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
|
||||
// Why (#16441): Codex's installer awaits a codex app-server trust-grant session
|
||||
// instead of blocking the main thread on spawnSync. Widening the tuple keeps the
|
||||
// other thirteen agent services synchronous — the shared loop already awaits.
|
||||
export type ManagedAgentHookInstallOptions = { userInitiated?: boolean }
|
||||
export type ManagedAgentHookInstaller = readonly [
|
||||
HookInstallAgent,
|
||||
(options?: ManagedAgentHookInstallOptions) => AgentHookInstallStatus
|
||||
(
|
||||
options?: ManagedAgentHookInstallOptions
|
||||
) => AgentHookInstallStatus | Promise<AgentHookInstallStatus>
|
||||
]
|
||||
export type ManagedAgentHookScriptRefresher = readonly [HookInstallAgent, () => Promise<void>]
|
||||
export type ManagedAgentHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus]
|
||||
export type ManagedAgentHookRemover = readonly [
|
||||
HookInstallAgent,
|
||||
() => AgentHookInstallStatus | Promise<AgentHookInstallStatus>
|
||||
]
|
||||
export type ManagedAgentHookAsyncRemover = readonly [
|
||||
HookInstallAgent,
|
||||
() => Promise<AgentHookInstallStatus>
|
||||
|
||||
@@ -209,11 +209,14 @@ async function generatePosixScripts(): Promise<Map<string, string>> {
|
||||
return scripts
|
||||
}
|
||||
|
||||
function withPlatform<T>(platform: NodeJS.Platform, run: () => T): T {
|
||||
// Why: the Codex installer awaits an app-server trust-grant session, so the
|
||||
// override has to stay pinned across the await instead of being restored by a
|
||||
// synchronous `finally` while the install is still running.
|
||||
async function withPlatform<T>(platform: NodeJS.Platform, run: () => T | Promise<T>): Promise<T> {
|
||||
const original = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
try {
|
||||
return run()
|
||||
return await run()
|
||||
} finally {
|
||||
if (original) {
|
||||
Object.defineProperty(process, 'platform', original)
|
||||
@@ -222,7 +225,7 @@ function withPlatform<T>(platform: NodeJS.Platform, run: () => T): T {
|
||||
}
|
||||
|
||||
describe('Windows managed hook stdin structure', () => {
|
||||
it('exits immediately when Orca env is missing and keeps drain for other failures', () => {
|
||||
it('exits immediately when Orca env is missing and keeps drain for other failures', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'orca-hook-stdin-windows-'))
|
||||
homedirMock.mockReturnValue(home)
|
||||
const previousGrokHome = process.env.GROK_HOME
|
||||
@@ -230,9 +233,9 @@ describe('Windows managed hook stdin structure', () => {
|
||||
delete process.env.GROK_HOME
|
||||
delete process.env.KIMI_CODE_HOME
|
||||
try {
|
||||
withPlatform('win32', () => {
|
||||
await withPlatform('win32', async () => {
|
||||
for (const entry of LOCAL_INSTALLERS) {
|
||||
expect(entry.install().state, `${entry.agent} install status`).toBe('installed')
|
||||
expect((await entry.install()).state, `${entry.agent} install status`).toBe('installed')
|
||||
}
|
||||
})
|
||||
const hooksDir = join(home, '.orca', 'agent-hooks')
|
||||
@@ -317,7 +320,7 @@ describe('Windows managed hook stdin structure', () => {
|
||||
try {
|
||||
const gitBash = findGitBash()
|
||||
for (const entry of LOCAL_INSTALLERS) {
|
||||
expect(entry.install().state, `${entry.agent} install status`).toBe('installed')
|
||||
expect((await entry.install()).state, `${entry.agent} install status`).toBe('installed')
|
||||
}
|
||||
const hooksDir = join(home, '.orca', 'agent-hooks')
|
||||
const mainScripts = readdirSync(hooksDir).filter(
|
||||
|
||||
@@ -241,7 +241,20 @@ describe('remote hook service installers', () => {
|
||||
expect(toml).toContain('trusted_hash = "sha256:')
|
||||
})
|
||||
|
||||
it('installs Codex hooks into an explicit redirected CODEX_HOME (WSL managed runtime home)', async () => {
|
||||
it('reports Codex trust-write failures without rolling back installed hooks', async () => {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
fs.failRenameTo.add('/home/dev/.codex/config.toml')
|
||||
|
||||
const status = await new CodexHookService().installRemote(sftp, '/home/dev')
|
||||
|
||||
expect(status.state).toBe('error')
|
||||
expect(status.managedHooksPresent).toBe(true)
|
||||
expect(status.detail).toContain('trust entries could not be written')
|
||||
expect(fs.files.get('/home/dev/.codex/hooks.json')).toContain('codex-hook.sh')
|
||||
expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh')
|
||||
})
|
||||
|
||||
it('installs Codex hooks into an explicit redirected CODEX_HOME', async () => {
|
||||
const runtimeHome = '/home/dev/.local/share/orca/codex-runtime-home/home'
|
||||
const { sftp, fs } = createFakeSftp({
|
||||
[`${runtimeHome}/config.toml`]: 'model = "gpt-5.2-codex"\n'
|
||||
@@ -261,12 +274,12 @@ describe('remote hook service installers', () => {
|
||||
expect(hooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toContain(
|
||||
'/home/dev/.orca/agent-hooks/codex-hook.sh'
|
||||
)
|
||||
const toml = fs.files.get(`${runtimeHome}/config.toml`)
|
||||
expect(toml).toContain('model = "gpt-5.2-codex"')
|
||||
expect(toml).toContain(`${runtimeHome}/hooks.json:stop:0:0`)
|
||||
expect(fs.files.get(`${runtimeHome}/config.toml`)).toContain(
|
||||
`${runtimeHome}/hooks.json:stop:0:0`
|
||||
)
|
||||
})
|
||||
|
||||
it('defers Codex trust writes until the redirected config.toml exists (launch-path seed race)', async () => {
|
||||
it('defers redirected Codex trust writes until config.toml exists', async () => {
|
||||
const runtimeHome = '/home/dev/.local/share/orca/codex-runtime-home/home'
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
|
||||
@@ -278,24 +291,9 @@ describe('remote hook service installers', () => {
|
||||
expect(status.state).toBe('installed')
|
||||
expect(status.detail).toContain('deferred')
|
||||
expect(fs.files.get(`${runtimeHome}/hooks.json`)).toContain('codex-hook.sh')
|
||||
// Why: creating config.toml here would make the launch path's
|
||||
// only-if-absent seed skip the user's real config.
|
||||
expect(fs.files.has(`${runtimeHome}/config.toml`)).toBe(false)
|
||||
})
|
||||
|
||||
it('reports Codex trust-write failures without rolling back installed hooks', async () => {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
fs.failRenameTo.add('/home/dev/.codex/config.toml')
|
||||
|
||||
const status = await new CodexHookService().installRemote(sftp, '/home/dev')
|
||||
|
||||
expect(status.state).toBe('error')
|
||||
expect(status.managedHooksPresent).toBe(true)
|
||||
expect(status.detail).toContain('trust entries could not be written')
|
||||
expect(fs.files.get('/home/dev/.codex/hooks.json')).toContain('codex-hook.sh')
|
||||
expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh')
|
||||
})
|
||||
|
||||
it('installs remote Gemini, Antigravity, Cursor, Command Code, Grok, and Devin configs using their CLI-specific schemas', async () => {
|
||||
const gemini = createFakeSftp()
|
||||
const antigravity = createFakeSftp()
|
||||
|
||||
@@ -16,11 +16,10 @@ import { kimiHookService } from '../kimi/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
|
||||
export type RemoteManagedHookInstallOptions = {
|
||||
/** Explicit CODEX_HOME dir for redirected runtimes (WSL managed runtime
|
||||
* home). Codex-only: it is the one agent whose home Orca redirects. Also
|
||||
* defers the config.toml trust write until that file exists, so the
|
||||
* launch path's only-if-absent seed is never pre-empted. */
|
||||
/** Explicit CODEX_HOME dir for redirected runtimes (for example WSL's managed runtime home). */
|
||||
codexHomeDir?: string
|
||||
/** Skip the trust write when a redirected runtime config is seeded by the launch path. */
|
||||
deferTrustUntilConfigToml?: boolean
|
||||
/** Explicit GROK_HOME for remote runtimes that redirect Grok's config. */
|
||||
grokHomeDir?: string
|
||||
/** Stops before starting the next installer when the owning relay request
|
||||
@@ -46,13 +45,10 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [
|
||||
[
|
||||
'codex',
|
||||
(sftp, remoteHome, options) =>
|
||||
codexHookService.installRemote(
|
||||
sftp,
|
||||
remoteHome,
|
||||
options?.codexHomeDir
|
||||
? { codexHomeDir: options.codexHomeDir, deferTrustUntilConfigToml: true }
|
||||
: undefined
|
||||
)
|
||||
codexHookService.installRemote(sftp, remoteHome, {
|
||||
codexHomeDir: options?.codexHomeDir,
|
||||
deferTrustUntilConfigToml: options?.deferTrustUntilConfigToml
|
||||
})
|
||||
],
|
||||
['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)],
|
||||
['antigravity', (sftp, remoteHome) => antigravityHookService.installRemote(sftp, remoteHome)],
|
||||
|
||||
@@ -56,11 +56,14 @@ const BATCH_SCRIPT_INSTALLERS = [
|
||||
{ agent: 'grok', install: () => new GrokHookService().install() }
|
||||
] as const
|
||||
|
||||
function withPlatform<T>(platform: NodeJS.Platform, run: () => T): T {
|
||||
// Why: the Codex installer awaits an app-server trust-grant session, so the
|
||||
// override has to stay pinned across the await instead of being restored by a
|
||||
// synchronous `finally` while the install is still running.
|
||||
async function withPlatform<T>(platform: NodeJS.Platform, run: () => T | Promise<T>): Promise<T> {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
try {
|
||||
return run()
|
||||
return await run()
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
@@ -93,10 +96,10 @@ describe('Windows managed hook post interpreter', () => {
|
||||
home = ''
|
||||
})
|
||||
|
||||
it('posts through curl.exe from every managed batch script, spawning no interpreter', () => {
|
||||
const scripts = withPlatform('win32', () => {
|
||||
it('posts through curl.exe from every managed batch script, spawning no interpreter', async () => {
|
||||
const scripts = await withPlatform('win32', async () => {
|
||||
for (const entry of BATCH_SCRIPT_INSTALLERS) {
|
||||
expect(entry.install().state, `${entry.agent} install status`).toBe('installed')
|
||||
expect((await entry.install()).state, `${entry.agent} install status`).toBe('installed')
|
||||
}
|
||||
const hooksDir = join(home, '.orca', 'agent-hooks')
|
||||
return readdirSync(hooksDir)
|
||||
|
||||
@@ -15,9 +15,7 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env'
|
||||
import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../../shared/wsl-hook-relay-contract'
|
||||
|
||||
/** Run the shared remote hook installers against a WSL guest over the relay's
|
||||
* fs bridge. Codex is the one agent whose home Orca redirects for WSL
|
||||
* sessions, so its hooks go to the managed runtime home. */
|
||||
/** Run the shared remote hook installers against a WSL guest over the relay's fs bridge. */
|
||||
export async function installWslGuestHooks(options: {
|
||||
mux: SshChannelMultiplexer
|
||||
guestHome: string
|
||||
@@ -45,8 +43,11 @@ export async function installWslGuestHooks(options: {
|
||||
return
|
||||
}
|
||||
const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, {
|
||||
agents,
|
||||
// WSL Codex launches use Orca's managed runtime CODEX_HOME, not ~/.codex.
|
||||
// Keep relay hooks in that active home so status callbacks are received.
|
||||
codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome),
|
||||
agents
|
||||
deferTrustUntilConfigToml: true
|
||||
})
|
||||
const failed = results.filter((r) => r.state === 'error').length
|
||||
if (failed > 0) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AgentHookServer } from './server'
|
||||
import { WslHookRelayManager } from './wsl-hook-relay-manager'
|
||||
import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env'
|
||||
|
||||
const BUNDLE_DIR = join(process.cwd(), 'out', 'relay', 'wsl')
|
||||
const BUNDLE_JS = join(BUNDLE_DIR, 'wsl-agent-hook-relay.js')
|
||||
@@ -113,18 +114,10 @@ describe.skipIf(process.platform === 'win32')(
|
||||
|
||||
manager.ensureForDistro('LiveDistro')
|
||||
|
||||
// Codex hooks land in the redirected managed runtime home. Waiting on
|
||||
// this artifact (not Claude's, which is written first) keeps the
|
||||
// Waiting on Codex's artifact (not Claude's, which is written first) keeps the
|
||||
// assertions behind the still-running 14-agent installer loop.
|
||||
const codexRuntimeHome = join(
|
||||
fakeHome,
|
||||
'.local',
|
||||
'share',
|
||||
'orca',
|
||||
'codex-runtime-home',
|
||||
'home'
|
||||
)
|
||||
await vi.waitFor(() => expect(existsSync(join(codexRuntimeHome, 'hooks.json'))).toBe(true), {
|
||||
const codexHome = wslCodexRuntimeHomeForGuestHome(fakeHome)
|
||||
await vi.waitFor(() => expect(existsSync(join(codexHome, 'hooks.json'))).toBe(true), {
|
||||
timeout: 15_000
|
||||
})
|
||||
expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true)
|
||||
@@ -135,7 +128,7 @@ describe.skipIf(process.platform === 'win32')(
|
||||
expect(claudeScript).toContain('/hook/claude')
|
||||
|
||||
// Trust TOML is deferred so the launch-path seed is never pre-empted.
|
||||
expect(existsSync(join(codexRuntimeHome, 'config.toml'))).toBe(false)
|
||||
expect(existsSync(join(codexHome, 'config.toml'))).toBe(false)
|
||||
expect(existsSync(join(fakeHome, '.codex', 'hooks.json'))).toBe(false)
|
||||
|
||||
// Re-coordinate exactly like a hook script: read the relay-written
|
||||
|
||||
@@ -244,10 +244,10 @@ describe('WslHookRelayManager', () => {
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
|
||||
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
|
||||
// Codex is the one agent whose home Orca redirects for WSL sessions.
|
||||
expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, {
|
||||
agents: ['codex'],
|
||||
codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`,
|
||||
agents: ['codex']
|
||||
deferTrustUntilConfigToml: true
|
||||
})
|
||||
|
||||
expect(manager.getGuestEndpointFilePath('Ubuntu')).toBe(
|
||||
|
||||
@@ -40,6 +40,8 @@ vi.mock('node:os', async () => {
|
||||
|
||||
const { markCodexProjectTrusted, markCopilotFolderTrusted, markCursorWorkspaceTrusted } =
|
||||
await import('./agent-trust-presets')
|
||||
const { runExclusivelyForCodexTrustConfig } =
|
||||
await import('./codex/codex-trust-config-mutation-queue')
|
||||
|
||||
beforeEach(() => {
|
||||
testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-'))
|
||||
@@ -137,7 +139,32 @@ describe('markCopilotFolderTrusted', () => {
|
||||
})
|
||||
|
||||
describe('markCodexProjectTrusted', () => {
|
||||
it('trusts the main repository root for a linked worktree without reading commondir', () => {
|
||||
// Why (#16441): a hook install/grant holds this file across an awaited
|
||||
// app-server session; an unqueued write here lands inside its
|
||||
// capture->restore window and is silently reverted.
|
||||
it('queues behind an in-flight Codex trust-config mutation', async () => {
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-'))
|
||||
const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
|
||||
let releaseGrant!: () => void
|
||||
const grantHoldingTheFile = new Promise<void>((resolve) => {
|
||||
releaseGrant = resolve
|
||||
})
|
||||
try {
|
||||
const held = runExclusivelyForCodexTrustConfig(configPath, () => grantHoldingTheFile)
|
||||
const marked = markCodexProjectTrusted(workspace)
|
||||
await Promise.resolve()
|
||||
expect(existsSync(configPath)).toBe(false)
|
||||
|
||||
releaseGrant()
|
||||
await held
|
||||
await marked
|
||||
expect(readFileSync(configPath, 'utf-8')).toContain('trust_level = "trusted"')
|
||||
} finally {
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('trusts the main repository root for a linked worktree without reading commondir', async () => {
|
||||
const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-linked-ws-'))
|
||||
const repository = join(fixtureRoot, 'repo')
|
||||
const workspace = join(fixtureRoot, 'worktrees', 'feature')
|
||||
@@ -148,7 +175,7 @@ describe('markCodexProjectTrusted', () => {
|
||||
writeFileSync(join(workspace, '.git'), `gitdir: ${worktreeGitDir}\n`, 'utf-8')
|
||||
writeFileSync(join(worktreeGitDir, 'gitdir'), join(workspace, '.git'), 'utf-8')
|
||||
|
||||
markCodexProjectTrusted(workspace)
|
||||
await markCodexProjectTrusted(workspace)
|
||||
|
||||
const repositoryRoot = realpathSync.native(repository)
|
||||
const workspaceRoot = realpathSync.native(workspace)
|
||||
@@ -171,7 +198,7 @@ describe('markCodexProjectTrusted', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not broaden trust through arbitrary or adversarial Git metadata', () => {
|
||||
it('does not broaden trust through arbitrary or adversarial Git metadata', async () => {
|
||||
const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-untrusted-gitdir-'))
|
||||
const workspace = join(fixtureRoot, 'workspace')
|
||||
const arbitraryGitDir = join(fixtureRoot, 'metadata', 'feature')
|
||||
@@ -183,12 +210,12 @@ describe('markCodexProjectTrusted', () => {
|
||||
writeFileSync(join(workspace, '.git'), `gitdir: ${arbitraryGitDir}\n`, 'utf-8')
|
||||
writeFileSync(join(arbitraryGitDir, 'commondir'), join(unrelatedRoot, '.git'), 'utf-8')
|
||||
|
||||
markCodexProjectTrusted(workspace)
|
||||
await markCodexProjectTrusted(workspace)
|
||||
const structuredGitDir = join(unrelatedRoot, '.git', 'worktrees', 'feature')
|
||||
mkdirSync(structuredGitDir, { recursive: true })
|
||||
writeFileSync(join(workspace, '.git'), `gitdir: ${structuredGitDir}\n`, 'utf-8')
|
||||
writeFileSync(join(structuredGitDir, 'gitdir'), join(unrelatedRoot, '.git'), 'utf-8')
|
||||
markCodexProjectTrusted(workspace)
|
||||
await markCodexProjectTrusted(workspace)
|
||||
|
||||
const written = readFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), 'utf-8')
|
||||
expect(written).toContain(
|
||||
@@ -202,11 +229,11 @@ describe('markCodexProjectTrusted', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('writes ~/.codex/config.toml with the project marked trusted', () => {
|
||||
it('writes ~/.codex/config.toml with the project marked trusted', async () => {
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-'))
|
||||
try {
|
||||
const realpath = realpathSync.native(workspace)
|
||||
markCodexProjectTrusted(workspace)
|
||||
await markCodexProjectTrusted(workspace)
|
||||
const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
|
||||
const runtimeConfigPath = join(
|
||||
testState.userDataDir,
|
||||
@@ -227,7 +254,7 @@ describe('markCodexProjectTrusted', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves existing config keys and updates an existing project block', () => {
|
||||
it('preserves existing config keys and updates an existing project block', async () => {
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-'))
|
||||
const realpath = realpathSync.native(workspace)
|
||||
try {
|
||||
@@ -260,7 +287,7 @@ describe('markCodexProjectTrusted', () => {
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
markCodexProjectTrusted(workspace)
|
||||
await markCodexProjectTrusted(workspace)
|
||||
|
||||
const written = readFileSync(join(codexDir, 'config.toml'), 'utf-8')
|
||||
const runtimeWritten = readFileSync(join(runtimeCodexDir, 'config.toml'), 'utf-8')
|
||||
|
||||
@@ -4,6 +4,7 @@ import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { writeFileAtomically } from './codex-accounts/fs-utils'
|
||||
import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths'
|
||||
import { upsertProjectTrustLevel } from './codex/config-toml-trust'
|
||||
import { runExclusivelyForCodexTrustConfig } from './codex/codex-trust-config-mutation-queue'
|
||||
|
||||
export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex'
|
||||
|
||||
@@ -108,13 +109,21 @@ export function markCopilotFolderTrusted(workspacePath: string): void {
|
||||
* Verified against codex-rs/tui/src/onboarding/trust_directory.rs and
|
||||
* codex-rs/core/src/config/config_tests.rs in the Codex CLI source.
|
||||
*/
|
||||
export function markCodexProjectTrusted(workspacePath: string): void {
|
||||
export function markCodexProjectTrusted(workspacePath: string): Promise<void> {
|
||||
const absPath = resolveCodexProjectTrustRoot(workspacePath)
|
||||
const configPath = join(homedir(), '.codex', 'config.toml')
|
||||
upsertProjectTrustLevel(configPath, absPath, 'trusted')
|
||||
const systemTomlPath = join(homedir(), '.codex', 'config.toml')
|
||||
// Why: Orca-launched Codex runs with an Orca-owned CODEX_HOME, so the trust
|
||||
// preset must also update the runtime config Codex will actually read.
|
||||
upsertProjectTrustLevel(join(getOrcaManagedCodexHomePath(), 'config.toml'), absPath, 'trusted')
|
||||
const runtimeTomlPath = join(getOrcaManagedCodexHomePath(), 'config.toml')
|
||||
// Why (#16441): hook installs now await a codex app-server grant, so an
|
||||
// unqueued write here can land inside their capture->restore window and be
|
||||
// reverted. Same runtime-before-system lock order the installer takes.
|
||||
return runExclusivelyForCodexTrustConfig(runtimeTomlPath, () =>
|
||||
runExclusivelyForCodexTrustConfig(systemTomlPath, async () => {
|
||||
upsertProjectTrustLevel(systemTomlPath, absPath, 'trusted')
|
||||
upsertProjectTrustLevel(runtimeTomlPath, absPath, 'trusted')
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function resolveCodexProjectTrustRoot(workspacePath: string): string {
|
||||
|
||||
@@ -51,6 +51,7 @@ vi.mock('./cdp-bridge', () => ({
|
||||
}))
|
||||
|
||||
import { AgentBrowserBridge } from './agent-browser-bridge'
|
||||
import { AGENT_BROWSER_IDLE_TIMEOUT_MS } from './agent-browser-process-environment'
|
||||
import {
|
||||
createSucceedWith,
|
||||
mockBrowserManager,
|
||||
@@ -338,7 +339,10 @@ describe('AgentBrowserBridge', () => {
|
||||
expect(args).toContain('wait')
|
||||
expect(args).toContain('#ready')
|
||||
expect(options.timeout).toBe(2200)
|
||||
expect(options.env).toBe(process.env)
|
||||
// Why not toBe(process.env): the bridge hands the daemon an idle-lifetime bound (#16367).
|
||||
const env = options.env as NodeJS.ProcessEnv
|
||||
expect(env.PATH).toBe(process.env.PATH)
|
||||
expect(env.AGENT_BROWSER_IDLE_TIMEOUT_MS).toBe(String(AGENT_BROWSER_IDLE_TIMEOUT_MS))
|
||||
})
|
||||
|
||||
it('returns browser_timeout for timed conditional waits without recycling the session', async () => {
|
||||
|
||||
@@ -64,6 +64,12 @@ overrideBridgeWebContentsLookup(AgentBrowserBridge.prototype, webContentsFromIdM
|
||||
|
||||
const succeedWith = createSucceedWith(execFileMock, stdinWrites)
|
||||
|
||||
function closeCallCount(): number {
|
||||
return execFileMock.mock.calls.filter((call: unknown[]) =>
|
||||
(call[1] as string[]).includes('close')
|
||||
).length
|
||||
}
|
||||
|
||||
describe('AgentBrowserBridge', () => {
|
||||
let bridge: AgentBrowserBridge
|
||||
|
||||
@@ -140,6 +146,43 @@ describe('AgentBrowserBridge', () => {
|
||||
expect(lastArgs).toContain('--cdp')
|
||||
})
|
||||
|
||||
it('bounds owner cleanup independently from command execution timeouts', async () => {
|
||||
succeedWith({ snapshot: 'initial' })
|
||||
await bridge.snapshot()
|
||||
execFileMock.mockClear()
|
||||
|
||||
succeedWith(null)
|
||||
await bridge.onPageClosed('tab-1')
|
||||
|
||||
const closeCall = execFileMock.mock.calls.find((call: unknown[]) =>
|
||||
(call[1] as string[]).includes('close')
|
||||
)
|
||||
expect(closeCall?.[2]).toMatchObject({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
it('uses the cleanup timeout when a target swap retires its session', async () => {
|
||||
succeedWith({ snapshot: 'initial' })
|
||||
await bridge.snapshot()
|
||||
execFileMock.mockClear()
|
||||
|
||||
succeedWith(null)
|
||||
await (
|
||||
bridge as unknown as {
|
||||
restartSessionForTarget: (
|
||||
sessionName: string,
|
||||
browserPageId: string,
|
||||
webContentsId: number,
|
||||
options: { recreate: boolean }
|
||||
) => Promise<void>
|
||||
}
|
||||
).restartSessionForTarget('orca-tab-tab-1', 'tab-1', 100, { recreate: false })
|
||||
|
||||
const closeCall = execFileMock.mock.calls.find((call: unknown[]) =>
|
||||
(call[1] as string[]).includes('close')
|
||||
)
|
||||
expect(closeCall?.[2]).toMatchObject({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
it('waits for pending session destruction before recreating the same session', async () => {
|
||||
succeedWith({ snapshot: 'initial' })
|
||||
await bridge.snapshot()
|
||||
@@ -416,24 +459,231 @@ describe('AgentBrowserBridge', () => {
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
// Why: the daemon's own idle timer retires it between commands; a replacement still serves the
|
||||
// page but has none of the session's network routes, so leaving them dropped is a silent wrong
|
||||
// answer for the next request the caller expected to be stubbed (#16367).
|
||||
it('replays intercept routes after the daemon idles out', async () => {
|
||||
succeedWith({ ok: true })
|
||||
await bridge.interceptEnable(['https://api.example/**'])
|
||||
|
||||
const sessions = (bridge as unknown as { sessions: Map<string, { lastCommandAt: number }> })
|
||||
.sessions
|
||||
const session = sessions.get('orca-tab-tab-1')!
|
||||
session.lastCommandAt = Date.now() - 11 * 60 * 1000
|
||||
|
||||
const commandCalls: string[][] = []
|
||||
execFileMock.mockImplementation(
|
||||
(_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => {
|
||||
commandCalls.push(args)
|
||||
cb(null, JSON.stringify({ success: true, data: { snapshot: 'tree' } }), '')
|
||||
}
|
||||
)
|
||||
await bridge.snapshot()
|
||||
|
||||
const routeCalls = commandCalls.filter(
|
||||
(args) => args.includes('network') && args.includes('route')
|
||||
)
|
||||
expect(routeCalls).toHaveLength(1)
|
||||
expect(routeCalls[0]).toContain('https://api.example/**')
|
||||
})
|
||||
|
||||
it('leaves a session alone while the daemon is still within its idle bound', async () => {
|
||||
succeedWith({ ok: true })
|
||||
await bridge.interceptEnable(['https://api.example/**'])
|
||||
|
||||
const commandCalls: string[][] = []
|
||||
execFileMock.mockImplementation(
|
||||
(_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => {
|
||||
commandCalls.push(args)
|
||||
cb(null, JSON.stringify({ success: true, data: { snapshot: 'tree' } }), '')
|
||||
}
|
||||
)
|
||||
await bridge.snapshot()
|
||||
|
||||
expect(
|
||||
commandCalls.filter((args) => args.includes('network') && args.includes('route'))
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
// ── destroyAllSessions ──
|
||||
|
||||
it('destroys all active sessions', async () => {
|
||||
it('makes runtime-wide session destruction terminal', async () => {
|
||||
succeedWith({ snapshot: 'tree' })
|
||||
await bridge.snapshot()
|
||||
|
||||
// Should have one session now
|
||||
succeedWith(null) // for the 'close' call
|
||||
succeedWith(null)
|
||||
await bridge.destroyAllSessions()
|
||||
execFileMock.mockClear()
|
||||
|
||||
// Next command should re-create session with --cdp
|
||||
succeedWith({ snapshot: 'fresh' })
|
||||
await expect(bridge.snapshot()).rejects.toMatchObject({
|
||||
code: 'browser_owner_unavailable',
|
||||
message: 'Browser runtime is shutting down'
|
||||
})
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds concurrent helper retirements during runtime shutdown', async () => {
|
||||
const sessions = (bridge as unknown as { sessions: Map<string, unknown> }).sessions
|
||||
for (let index = 0; index < 6; index++) {
|
||||
sessions.set(`orca-tab-tab-${index}`, {
|
||||
proxy: { stop: vi.fn(async () => {}) },
|
||||
cdpEndpoint: `ws://127.0.0.1:${9200 + index}`,
|
||||
initialized: true,
|
||||
consecutiveTimeouts: 0,
|
||||
activeInterceptPatterns: [],
|
||||
activeCapture: false,
|
||||
webContentsId: 100 + index,
|
||||
activeProcess: null
|
||||
})
|
||||
}
|
||||
|
||||
let activeRetirements = 0
|
||||
let peakRetirements = 0
|
||||
const releases: (() => void)[] = []
|
||||
execFileMock.mockImplementation(
|
||||
(_bin: string, _args: string[], _opts: unknown, cb: ExecFileCallback) => {
|
||||
activeRetirements++
|
||||
peakRetirements = Math.max(peakRetirements, activeRetirements)
|
||||
releases.push(() => {
|
||||
activeRetirements--
|
||||
cb(null, JSON.stringify({ success: true, data: null }), '')
|
||||
})
|
||||
return { kill: vi.fn() }
|
||||
}
|
||||
)
|
||||
|
||||
const shutdown = bridge.destroyAllSessions()
|
||||
await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledTimes(4))
|
||||
releases.splice(0).forEach((release) => release())
|
||||
await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledTimes(6))
|
||||
releases.splice(0).forEach((release) => release())
|
||||
await shutdown
|
||||
|
||||
expect(peakRetirements).toBe(4)
|
||||
})
|
||||
|
||||
it('destroys a session that finishes creating during runtime shutdown', async () => {
|
||||
const commandCalls: string[][] = []
|
||||
let releaseStaleClose: (() => void) | null = null
|
||||
execFileMock.mockImplementation(
|
||||
(_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => {
|
||||
commandCalls.push(args)
|
||||
if (args.includes('close') && !releaseStaleClose) {
|
||||
releaseStaleClose = () => {
|
||||
cb(null, JSON.stringify({ success: true, data: null }), '')
|
||||
}
|
||||
return { kill: vi.fn() }
|
||||
}
|
||||
cb(null, JSON.stringify({ success: true, data: null }), '')
|
||||
return { kill: vi.fn() }
|
||||
}
|
||||
)
|
||||
|
||||
const ensurePromise = (
|
||||
bridge as unknown as {
|
||||
ensureSession: (
|
||||
sessionName: string,
|
||||
browserPageId: string,
|
||||
webContentsId: number
|
||||
) => Promise<void>
|
||||
}
|
||||
).ensureSession('orca-tab-tab-1', 'tab-1', 100)
|
||||
await vi.waitFor(() => expect(releaseStaleClose).not.toBeNull())
|
||||
|
||||
const destroyAllPromise = bridge.destroyAllSessions()
|
||||
releaseStaleClose!()
|
||||
await ensurePromise
|
||||
await destroyAllPromise
|
||||
|
||||
const sessions = (bridge as unknown as { sessions: Map<string, unknown> }).sessions
|
||||
const proxy = CdpWsProxyMock.instances[0] as { stop: ReturnType<typeof vi.fn> }
|
||||
expect(commandCalls.filter((args) => args.includes('close'))).toHaveLength(2)
|
||||
expect(sessions.size).toBe(0)
|
||||
expect(proxy.stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not recreate a session after shutdown observes its pending retirement', async () => {
|
||||
succeedWith({ snapshot: 'initial' })
|
||||
await bridge.snapshot()
|
||||
execFileMock.mockClear()
|
||||
|
||||
let releaseClose: (() => void) | null = null
|
||||
execFileMock.mockImplementation(
|
||||
(_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => {
|
||||
if (!args.includes('close')) {
|
||||
throw new Error(`unexpected agent-browser args ${args.join(' ')}`)
|
||||
}
|
||||
releaseClose = () => cb(null, JSON.stringify({ success: true, data: null }), '')
|
||||
return { kill: vi.fn() }
|
||||
}
|
||||
)
|
||||
|
||||
const restart = (
|
||||
bridge as unknown as {
|
||||
restartSessionForTarget: (
|
||||
sessionName: string,
|
||||
browserPageId: string,
|
||||
webContentsId: number
|
||||
) => Promise<void>
|
||||
}
|
||||
).restartSessionForTarget('orca-tab-tab-1', 'tab-1', 100)
|
||||
await vi.waitFor(() => expect(releaseClose).not.toBeNull())
|
||||
|
||||
const shutdown = bridge.destroyAllSessions()
|
||||
releaseClose!()
|
||||
|
||||
await expect(restart).rejects.toMatchObject({
|
||||
code: 'browser_owner_unavailable',
|
||||
message: 'Browser runtime is shutting down'
|
||||
})
|
||||
await shutdown
|
||||
|
||||
const sessions = (bridge as unknown as { sessions: Map<string, unknown> }).sessions
|
||||
expect(sessions.size).toBe(0)
|
||||
expect(CdpWsProxyMock.instances).toHaveLength(1)
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Why: quit awaits destroyAllSessions inside a 20s barrier, so an unbounded close can hold the
|
||||
// window up for the whole deadline when the daemon is wedged (#16367).
|
||||
it('bounds every teardown close well inside the quit barrier', async () => {
|
||||
succeedWith({ snapshot: 'tree' })
|
||||
await bridge.snapshot()
|
||||
|
||||
const snapshotCalls = execFileMock.mock.calls.filter((c: unknown[]) =>
|
||||
(c[1] as string[]).includes('snapshot')
|
||||
succeedWith(null)
|
||||
await bridge.destroyAllSessions()
|
||||
|
||||
const closeCall = execFileMock.mock.calls.findLast((c: unknown[]) =>
|
||||
(c[1] as string[]).includes('close')
|
||||
)
|
||||
const lastSnapshotArgs = snapshotCalls.at(-1)![1] as string[]
|
||||
expect(lastSnapshotArgs).toContain('--cdp')
|
||||
expect((closeCall![2] as { timeout: number }).timeout).toBeLessThanOrEqual(5_000)
|
||||
})
|
||||
|
||||
// Why: the daemon is already spawned by the time the name reaches pendingSessionCreation, so a
|
||||
// quit that only walks `sessions` leaves exactly the orphan the barrier was added to prevent.
|
||||
it('closes a session still being created when everything is torn down', async () => {
|
||||
let releaseProxyStart: (() => void) | undefined
|
||||
CdpWsProxyMock.mockImplementationOnce(function (this: Record<string, unknown>) {
|
||||
this.start = vi.fn(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
releaseProxyStart = () => resolve('ws://127.0.0.1:9222')
|
||||
})
|
||||
)
|
||||
this.stop = vi.fn(async () => {})
|
||||
this.getPort = vi.fn(() => 9222)
|
||||
})
|
||||
|
||||
succeedWith({ snapshot: 'tree' })
|
||||
const inFlight = bridge.snapshot()
|
||||
await vi.waitFor(() => expect(releaseProxyStart).toBeDefined())
|
||||
|
||||
// Why the baseline: session creation already spawned a stale-session `close` of its own.
|
||||
const closesBeforeTeardown = closeCallCount()
|
||||
const teardown = bridge.destroyAllSessions()
|
||||
releaseProxyStart!()
|
||||
await Promise.allSettled([inFlight, teardown])
|
||||
|
||||
expect(closeCallCount()).toBeGreaterThan(closesBeforeTeardown)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,14 +49,22 @@ import type {
|
||||
} from '../../shared/runtime-types'
|
||||
import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text'
|
||||
import { normalizeBrowserNavigationUrl } from '../../shared/browser-url'
|
||||
import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency'
|
||||
import { iterateBrowserTextInsertionChunks } from './browser-text-insertion'
|
||||
import { createAgentBrowserProcessEnvironment } from './agent-browser-process-environment'
|
||||
import {
|
||||
ORCA_TAB_SESSION_PREFIX,
|
||||
sweepOrphanedAgentBrowserSessions
|
||||
} from './agent-browser-orphan-sweep'
|
||||
|
||||
// Why: must exceed agent-browser's internal timeouts (goto 30s, wait 60s) so the bridge never kills a command before its own timeout fires.
|
||||
const EXEC_TIMEOUT_MS = 90_000
|
||||
const CONSECUTIVE_TIMEOUT_LIMIT = 3
|
||||
const WAIT_PROCESS_TIMEOUT_GRACE_MS = 1_000
|
||||
const STALE_SESSION_CLOSE_TIMEOUT_MS = 3_000
|
||||
// Why separate from EXEC_TIMEOUT_MS: a close is a member of the 20s will-quit barrier and must finish well inside it.
|
||||
const AGENT_BROWSER_CLEANUP_TIMEOUT_MS = 5_000
|
||||
const AGENT_BROWSER_CLEANUP_CONCURRENCY = 4
|
||||
const EMBEDDED_NAVIGATION_TIMEOUT_MS = 30_000
|
||||
export const AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES = 8 * 1024
|
||||
export const AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES = AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES
|
||||
@@ -69,6 +77,8 @@ type SessionState = {
|
||||
// Why: track active interception patterns so they can be re-enabled after session restart
|
||||
activeInterceptPatterns: string[]
|
||||
activeCapture: boolean
|
||||
// Why: the daemon retires itself once idle; the gap since the last command is how the bridge notices.
|
||||
lastCommandAt: number
|
||||
// Why: verify the tab is alive at execution time, not just enqueue time — queue delay can destroy it in between.
|
||||
webContentsId: number
|
||||
activeProcess: ChildProcess | null
|
||||
@@ -85,6 +95,10 @@ type ResolvedBrowserCommandTarget = {
|
||||
webContentsId: number
|
||||
}
|
||||
|
||||
type AgentBrowserCleanupOptions = {
|
||||
closeTimeoutMs?: number
|
||||
}
|
||||
|
||||
export type BrowserMouseModifier = 'cmd' | 'ctrl' | 'alt' | 'shift'
|
||||
|
||||
function focusedValueSetExpression(
|
||||
@@ -341,7 +355,7 @@ function isTabClosedTransportError(message: string): boolean {
|
||||
}
|
||||
|
||||
function pageUnavailableMessageForSession(sessionName: string): string {
|
||||
const prefix = 'orca-tab-'
|
||||
const prefix = ORCA_TAB_SESSION_PREFIX
|
||||
const browserPageId = sessionName.startsWith(prefix) ? sessionName.slice(prefix.length) : null
|
||||
return browserPageId
|
||||
? `Browser page ${browserPageId} is no longer available`
|
||||
@@ -580,6 +594,9 @@ export class AgentBrowserBridge {
|
||||
private screenshotTurn: Promise<void> = Promise.resolve()
|
||||
private readonly agentBrowserBin: string
|
||||
private readonly agentBrowserEnv: NodeJS.ProcessEnv
|
||||
private readonly ownsAgentBrowserSocketDirectory: boolean
|
||||
// Why: null when nothing bounds the daemon, so the bridge never guesses that one was replaced.
|
||||
private readonly agentBrowserIdleTimeoutMs: number | null
|
||||
// Why: stash intercept patterns from a swap-destroyed session, keyed by name, so the next session restores them.
|
||||
private readonly pendingInterceptRestore = new Map<string, string[]>()
|
||||
// Why: promise-lock so two concurrent ensureSession calls don't both create the session entry.
|
||||
@@ -587,17 +604,22 @@ export class AgentBrowserBridge {
|
||||
// Why: `agent-browser close` is async, keyed by session name — recreating before it finishes lets the old teardown close the new session.
|
||||
private readonly pendingSessionDestruction = new Map<string, Promise<void>>()
|
||||
private readonly cancelledProcesses = new WeakSet<ChildProcess>()
|
||||
private shutdownStarted = false
|
||||
|
||||
constructor(
|
||||
private readonly browserManager: BrowserManager,
|
||||
private readonly options: AgentBrowserBridgeOptions = {}
|
||||
) {
|
||||
this.agentBrowserBin = resolveAgentBrowserBinary()
|
||||
this.agentBrowserEnv = createAgentBrowserProcessEnvironment({
|
||||
const processEnvironment = createAgentBrowserProcessEnvironment({
|
||||
inheritedEnv: process.env,
|
||||
platform: process.platform,
|
||||
userDataPath: app.getPath('userData')
|
||||
})
|
||||
this.agentBrowserEnv = processEnvironment.env
|
||||
this.ownsAgentBrowserSocketDirectory = processEnvironment.ownsSocketDirectory
|
||||
const idleTimeoutMs = Number(this.agentBrowserEnv.AGENT_BROWSER_IDLE_TIMEOUT_MS)
|
||||
this.agentBrowserIdleTimeoutMs = idleTimeoutMs > 0 ? idleTimeoutMs : null
|
||||
}
|
||||
|
||||
// ── Tab tracking ──
|
||||
@@ -678,20 +700,31 @@ export class AgentBrowserBridge {
|
||||
this.activeWebContentsId = nextWorktreeActiveWebContentsId
|
||||
}
|
||||
if (browserPageId) {
|
||||
const sessionName = `orca-tab-${browserPageId}`
|
||||
await this.destroySession(sessionName)
|
||||
this.pendingInterceptRestore.delete(sessionName)
|
||||
await this.onPageClosed(browserPageId)
|
||||
}
|
||||
this.options.onTabsChanged?.(owningWorktreeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire a page's daemon by page id.
|
||||
*
|
||||
* The headless offscreen backend owns pages by id and unregisters the guest
|
||||
* itself, so `onTabClosed`'s webContentsId lookup can never resolve one — it
|
||||
* has to say which page closed (#16367).
|
||||
*/
|
||||
async onPageClosed(browserPageId: string): Promise<void> {
|
||||
const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}`
|
||||
await this.destroySession(sessionName)
|
||||
this.pendingInterceptRestore.delete(sessionName)
|
||||
}
|
||||
|
||||
async onProcessSwap(
|
||||
browserPageId: string,
|
||||
newWebContentsId: number,
|
||||
previousWebContentsId?: number
|
||||
): Promise<void> {
|
||||
// Why: an Electron process swap keeps browserPageId but gives a new webContentsId — destroy the session so the next command recreates it.
|
||||
const sessionName = `orca-tab-${browserPageId}`
|
||||
const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}`
|
||||
const session = this.sessions.get(sessionName)
|
||||
const oldWebContentsId = previousWebContentsId ?? session?.webContentsId
|
||||
const owningWorktreeId = this.browserManager.getWorktreeIdForTab(browserPageId)
|
||||
@@ -885,7 +918,9 @@ export class AgentBrowserBridge {
|
||||
navigationTimeout = null
|
||||
}
|
||||
if (!this.getWebContents(target.webContentsId)) {
|
||||
throw this.createPageUnavailableError(`orca-tab-${target.browserPageId}`)
|
||||
throw this.createPageUnavailableError(
|
||||
`${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}`
|
||||
)
|
||||
}
|
||||
// Why: ERR_ABORTED also covers a page vetoing unload; that navigation did not succeed.
|
||||
if (
|
||||
@@ -1606,7 +1641,9 @@ export class AgentBrowserBridge {
|
||||
throw error
|
||||
}
|
||||
if (!this.getWebContents(target.webContentsId)) {
|
||||
throw this.createPageUnavailableError(`orca-tab-${target.browserPageId}`)
|
||||
throw this.createPageUnavailableError(
|
||||
`${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}`
|
||||
)
|
||||
}
|
||||
throw new BrowserError(
|
||||
'browser_error',
|
||||
@@ -2044,12 +2081,32 @@ export class AgentBrowserBridge {
|
||||
|
||||
// ── Session lifecycle ──
|
||||
|
||||
async destroyAllSessions(): Promise<void> {
|
||||
const promises: Promise<void>[] = []
|
||||
for (const sessionName of this.sessions.keys()) {
|
||||
promises.push(this.destroySession(sessionName))
|
||||
}
|
||||
await Promise.allSettled(promises)
|
||||
// Why: a previous run that crashed or was SIGKILL'd left one daemon per open tab with
|
||||
// nobody holding its name — closeStaleAgentBrowserSession only resets a name being reused.
|
||||
async sweepOrphanedSessions(): Promise<string[]> {
|
||||
return sweepOrphanedAgentBrowserSessions({
|
||||
binaryPath: this.agentBrowserBin,
|
||||
env: this.agentBrowserEnv,
|
||||
ownsSocketDirectory: this.ownsAgentBrowserSocketDirectory,
|
||||
isSessionLive: (sessionName) =>
|
||||
this.sessions.has(sessionName) || this.pendingSessionCreation.has(sessionName)
|
||||
})
|
||||
}
|
||||
|
||||
async destroyAllSessions(options?: AgentBrowserCleanupOptions): Promise<void> {
|
||||
this.shutdownStarted = true
|
||||
// Why the union: a session still being created has already spawned its daemon but is not in
|
||||
// `sessions` yet, so closing only `sessions` lets that daemon outlive the quit (#16367).
|
||||
const sessionNames = new Set([
|
||||
...this.sessions.keys(),
|
||||
...this.pendingSessionCreation.keys(),
|
||||
...this.pendingSessionDestruction.keys()
|
||||
])
|
||||
await mapSettledWithConcurrency(
|
||||
[...sessionNames],
|
||||
AGENT_BROWSER_CLEANUP_CONCURRENCY,
|
||||
(sessionName) => this.destroySession(sessionName, options)
|
||||
)
|
||||
this.pendingInterceptRestore.clear()
|
||||
}
|
||||
|
||||
@@ -2073,12 +2130,14 @@ export class AgentBrowserBridge {
|
||||
execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise<T>,
|
||||
options: EnqueueTargetedCommandOptions = {}
|
||||
): Promise<T> {
|
||||
this.assertCommandAdmission()
|
||||
const target = this.resolveCommandTarget(worktreeId, browserPageId, options.requireScopedTarget)
|
||||
const sessionName = `orca-tab-${target.browserPageId}`
|
||||
const sessionName = `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}`
|
||||
|
||||
if (options.ensureSession !== false) {
|
||||
await this.ensureSession(sessionName, target.browserPageId, target.webContentsId)
|
||||
}
|
||||
this.assertCommandAdmission()
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let queue = this.commandQueues.get(sessionName)
|
||||
@@ -2302,6 +2361,7 @@ export class AgentBrowserBridge {
|
||||
if (pendingDestruction) {
|
||||
await pendingDestruction
|
||||
}
|
||||
this.assertCommandAdmission()
|
||||
|
||||
if (this.sessions.has(sessionName)) {
|
||||
return
|
||||
@@ -2311,6 +2371,7 @@ export class AgentBrowserBridge {
|
||||
const pending = this.pendingSessionCreation.get(sessionName)
|
||||
if (pending) {
|
||||
await pending
|
||||
this.assertCommandAdmission()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2337,6 +2398,7 @@ export class AgentBrowserBridge {
|
||||
consecutiveTimeouts: 0,
|
||||
activeInterceptPatterns: [],
|
||||
activeCapture: false,
|
||||
lastCommandAt: Date.now(),
|
||||
webContentsId,
|
||||
activeProcess: null
|
||||
})
|
||||
@@ -2381,7 +2443,9 @@ export class AgentBrowserBridge {
|
||||
|
||||
const destroy = (async (): Promise<void> => {
|
||||
try {
|
||||
await this.runAgentBrowserRaw(sessionName, ['--session', sessionName, 'close'])
|
||||
await this.runAgentBrowserRaw(sessionName, ['--session', sessionName, 'close'], {
|
||||
timeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS
|
||||
})
|
||||
} catch {
|
||||
// Session may already be dead.
|
||||
}
|
||||
@@ -2400,7 +2464,10 @@ export class AgentBrowserBridge {
|
||||
}
|
||||
}
|
||||
|
||||
private async destroySession(sessionName: string): Promise<void> {
|
||||
private async destroySession(
|
||||
sessionName: string,
|
||||
options: AgentBrowserCleanupOptions = { closeTimeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS }
|
||||
): Promise<void> {
|
||||
const pendingDestruction = this.pendingSessionDestruction.get(sessionName)
|
||||
if (pendingDestruction) {
|
||||
await pendingDestruction
|
||||
@@ -2443,7 +2510,12 @@ export class AgentBrowserBridge {
|
||||
const destroy = (async (): Promise<void> => {
|
||||
try {
|
||||
// Why: each tab has its own named session — close without --session leaves this tab's daemon running.
|
||||
await this.runAgentBrowserRaw(sessionName, ['--session', sessionName, 'close'])
|
||||
// Why bounded: this runs inside the 20s will-quit barrier, so it cannot inherit the 90s exec timeout.
|
||||
await this.runAgentBrowserRaw(
|
||||
sessionName,
|
||||
['--session', sessionName, 'close'],
|
||||
options.closeTimeoutMs === undefined ? undefined : { timeoutMs: options.closeTimeoutMs }
|
||||
)
|
||||
} catch {
|
||||
// Session may already be dead
|
||||
}
|
||||
@@ -2474,6 +2546,32 @@ export class AgentBrowserBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notice that the daemon retired itself between two commands.
|
||||
*
|
||||
* A replacement daemon still serves the page (every call reasserts `--cdp`)
|
||||
* but carries none of the session's network routes, so without this the
|
||||
* interception the caller configured is silently gone (#16367).
|
||||
*/
|
||||
private reinitializeIfDaemonIdledOut(sessionName: string, session: SessionState): void {
|
||||
if (
|
||||
this.agentBrowserIdleTimeoutMs === null ||
|
||||
Date.now() - session.lastCommandAt < this.agentBrowserIdleTimeoutMs
|
||||
) {
|
||||
return
|
||||
}
|
||||
session.initialized = false
|
||||
if (session.activeInterceptPatterns.length > 0) {
|
||||
this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns])
|
||||
}
|
||||
}
|
||||
|
||||
private assertCommandAdmission(): void {
|
||||
if (this.shutdownStarted) {
|
||||
throw new BrowserError('browser_owner_unavailable', 'Browser runtime is shutting down')
|
||||
}
|
||||
}
|
||||
|
||||
private async execAgentBrowser(
|
||||
sessionName: string,
|
||||
commandArgs: string[],
|
||||
@@ -2491,6 +2589,9 @@ export class AgentBrowserBridge {
|
||||
throw this.createPageUnavailableError(sessionName)
|
||||
}
|
||||
|
||||
this.reinitializeIfDaemonIdledOut(sessionName, session)
|
||||
session.lastCommandAt = Date.now()
|
||||
|
||||
const args = ['--session', sessionName]
|
||||
const managesInterceptRoutes =
|
||||
commandArgs[0] === 'network' && (commandArgs[1] === 'route' || commandArgs[1] === 'unroute')
|
||||
@@ -2772,7 +2873,7 @@ export class AgentBrowserBridge {
|
||||
private requireTargetWebContents(target: ResolvedBrowserCommandTarget): WebContents {
|
||||
const wc = this.getWebContents(target.webContentsId)
|
||||
if (!wc || wc.isDestroyed()) {
|
||||
throw this.createPageUnavailableError(`orca-tab-${target.browserPageId}`)
|
||||
throw this.createPageUnavailableError(`${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}`)
|
||||
}
|
||||
return wc
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const runProcessMock = vi.fn()
|
||||
vi.mock('../../shared/child-process/run-process', () => ({
|
||||
runProcess: (spec: unknown) => runProcessMock(spec)
|
||||
}))
|
||||
|
||||
import { sweepOrphanedAgentBrowserSessions } from './agent-browser-orphan-sweep'
|
||||
|
||||
type Spec = { args?: readonly string[] }
|
||||
|
||||
const BIN = '/opt/orca/agent-browser'
|
||||
const SCOPED = {
|
||||
env: { AGENT_BROWSER_SOCKET_DIR: '/tmp/orca-ab-0123456789abcdef' },
|
||||
ownsSocketDirectory: true
|
||||
}
|
||||
|
||||
function respond(sessions: string[]): void {
|
||||
runProcessMock.mockImplementation((spec: Spec) => {
|
||||
if (spec.args?.[0] === 'session') {
|
||||
return Promise.resolve({
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout: JSON.stringify({ success: true, data: { sessions } }),
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
}
|
||||
return Promise.resolve({ code: 0, signal: null, stdout: '', stderr: '', timedOut: false })
|
||||
})
|
||||
}
|
||||
|
||||
function closedArgs(): string[][] {
|
||||
return runProcessMock.mock.calls
|
||||
.map((call) => [...((call[0] as Spec).args ?? [])])
|
||||
.filter((args) => args.includes('close'))
|
||||
}
|
||||
|
||||
describe('agent-browser orphan sweep', () => {
|
||||
beforeEach(() => {
|
||||
runProcessMock.mockReset()
|
||||
})
|
||||
|
||||
it('closes tab daemons left by a previous run', async () => {
|
||||
respond(['orca-tab-aaa', 'orca-tab-bbb'])
|
||||
|
||||
const closed = await sweepOrphanedAgentBrowserSessions({ binaryPath: BIN, ...SCOPED })
|
||||
|
||||
expect(closed).toEqual(['orca-tab-aaa', 'orca-tab-bbb'])
|
||||
expect(closedArgs()).toEqual([
|
||||
['--session', 'orca-tab-aaa', 'close'],
|
||||
['--session', 'orca-tab-bbb', 'close']
|
||||
])
|
||||
})
|
||||
|
||||
it('never closes a daemon outside Orca tab naming', async () => {
|
||||
respond(['default', 'agent1', 'orca-orcad-deadbeef', 'orca-tab-aaa'])
|
||||
|
||||
await sweepOrphanedAgentBrowserSessions({ binaryPath: BIN, ...SCOPED })
|
||||
|
||||
expect(closedArgs()).toEqual([['--session', 'orca-tab-aaa', 'close']])
|
||||
})
|
||||
|
||||
it('leaves sessions this run already owns alone', async () => {
|
||||
respond(['orca-tab-live', 'orca-tab-orphan'])
|
||||
|
||||
await sweepOrphanedAgentBrowserSessions({
|
||||
binaryPath: BIN,
|
||||
...SCOPED,
|
||||
isSessionLive: (name) => name === 'orca-tab-live'
|
||||
})
|
||||
|
||||
expect(closedArgs()).toEqual([['--session', 'orca-tab-orphan', 'close']])
|
||||
})
|
||||
|
||||
// Why: without a socket dir Orca derived itself, `session list` can reach daemons another Orca
|
||||
// profile owns (Windows named pipes, or an inherited AGENT_BROWSER_SOCKET_DIR). Idle timeout bounds those.
|
||||
it.each([
|
||||
['no socket directory at all', { PATH: 'C:\\Windows' }],
|
||||
['a socket directory Orca inherited', { AGENT_BROWSER_SOCKET_DIR: '/tmp/shared-ab' }]
|
||||
])('does not enumerate with %s', async (_label, env) => {
|
||||
respond(['orca-tab-aaa'])
|
||||
|
||||
const closed = await sweepOrphanedAgentBrowserSessions({
|
||||
binaryPath: BIN,
|
||||
env,
|
||||
ownsSocketDirectory: false
|
||||
})
|
||||
|
||||
expect(closed).toEqual([])
|
||||
expect(runProcessMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes nothing when the listing is unusable', async () => {
|
||||
runProcessMock.mockResolvedValue({
|
||||
code: 1,
|
||||
signal: null,
|
||||
stdout: 'not json',
|
||||
stderr: 'boom',
|
||||
timedOut: false
|
||||
})
|
||||
|
||||
await expect(
|
||||
sweepOrphanedAgentBrowserSessions({ binaryPath: BIN, ...SCOPED })
|
||||
).resolves.toEqual([])
|
||||
expect(closedArgs()).toEqual([])
|
||||
})
|
||||
|
||||
it('survives a listing that never returns', async () => {
|
||||
runProcessMock.mockRejectedValue(new Error('ENOENT'))
|
||||
|
||||
await expect(
|
||||
sweepOrphanedAgentBrowserSessions({ binaryPath: BIN, ...SCOPED })
|
||||
).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('keeps sweeping after one close fails', async () => {
|
||||
runProcessMock.mockImplementation((spec: Spec) => {
|
||||
if (spec.args?.[0] === 'session') {
|
||||
return Promise.resolve({
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout: JSON.stringify({ data: { sessions: ['orca-tab-aaa', 'orca-tab-bbb'] } }),
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
}
|
||||
if (spec.args?.[1] === 'orca-tab-aaa') {
|
||||
return Promise.reject(new Error('spawn failed'))
|
||||
}
|
||||
return Promise.resolve({ code: 0, signal: null, stdout: '', stderr: '', timedOut: false })
|
||||
})
|
||||
|
||||
const closed = await sweepOrphanedAgentBrowserSessions({ binaryPath: BIN, ...SCOPED })
|
||||
|
||||
expect(closed).toEqual(['orca-tab-bbb'])
|
||||
})
|
||||
|
||||
it('bounds every child it starts', async () => {
|
||||
respond(['orca-tab-aaa'])
|
||||
|
||||
await sweepOrphanedAgentBrowserSessions({ binaryPath: BIN, ...SCOPED })
|
||||
|
||||
for (const call of runProcessMock.mock.calls) {
|
||||
expect((call[0] as { timeoutMs?: number | null }).timeoutMs).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('sweep kill switch', () => {
|
||||
const previous = process.env.ORCA_DISABLE_AGENT_BROWSER_SWEEP
|
||||
|
||||
afterEach(() => {
|
||||
if (previous === undefined) {
|
||||
delete process.env.ORCA_DISABLE_AGENT_BROWSER_SWEEP
|
||||
} else {
|
||||
process.env.ORCA_DISABLE_AGENT_BROWSER_SWEEP = previous
|
||||
}
|
||||
})
|
||||
|
||||
// Why: the idle bound is an env passthrough an operator can raise and the quit close is
|
||||
// self-bounded, so the sweep is the only new behaviour whose failure would need a revert.
|
||||
it('enumerates nothing when disabled, even when Orca owns the socket directory', async () => {
|
||||
process.env.ORCA_DISABLE_AGENT_BROWSER_SWEEP = '1'
|
||||
runProcessMock.mockClear()
|
||||
|
||||
const closed = await sweepOrphanedAgentBrowserSessions({
|
||||
binaryPath: BIN,
|
||||
env: {},
|
||||
ownsSocketDirectory: true
|
||||
})
|
||||
|
||||
expect(closed).toEqual([])
|
||||
expect(runProcessMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still sweeps when the flag holds any other value', async () => {
|
||||
process.env.ORCA_DISABLE_AGENT_BROWSER_SWEEP = '0'
|
||||
runProcessMock.mockClear()
|
||||
runProcessMock.mockResolvedValue({ code: 0, stdout: '{"data":{"sessions":[]}}', stderr: '' })
|
||||
|
||||
await sweepOrphanedAgentBrowserSessions({
|
||||
binaryPath: BIN,
|
||||
env: {},
|
||||
ownsSocketDirectory: true
|
||||
})
|
||||
|
||||
expect(runProcessMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
|
||||
/** Session-name namespace Orca gives one daemon per browser tab. */
|
||||
export const ORCA_TAB_SESSION_PREFIX = 'orca-tab-'
|
||||
|
||||
const SWEEP_TIMEOUT_MS = 5_000
|
||||
const SWEEP_MAX_OUTPUT_BYTES = 256 * 1024
|
||||
|
||||
type SessionListEnvelope = {
|
||||
data?: { sessions?: unknown }
|
||||
}
|
||||
|
||||
function parseSessionNames(stdout: string): string[] {
|
||||
let envelope: SessionListEnvelope
|
||||
try {
|
||||
envelope = JSON.parse(stdout) as SessionListEnvelope
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const sessions = envelope?.data?.sessions
|
||||
if (!Array.isArray(sessions)) {
|
||||
return []
|
||||
}
|
||||
return sessions.filter((name): name is string => typeof name === 'string' && name.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close agent-browser daemons left behind by a previous Orca run.
|
||||
*
|
||||
* A crash (or SIGKILL) leaves one daemon per open tab with nobody holding its
|
||||
* name; `closeStaleAgentBrowserSession` only resets the single name a new tab
|
||||
* is about to reuse, so the rest persist. This closes them through
|
||||
* agent-browser's own CLI rather than by walking pids.
|
||||
*
|
||||
* Scoping — this only runs when Orca derived the socket directory itself
|
||||
* (`ownsSocketDirectory`), because that private per-profile directory is what
|
||||
* proves the enumeration can only see this Orca profile's daemons. An inherited
|
||||
* `AGENT_BROWSER_SOCKET_DIR` can be shared with a second Orca profile, and
|
||||
* Windows gets none at all (named pipes make the directory moot); both cases
|
||||
* skip the sweep rather than run a `session list` that could close a daemon Orca
|
||||
* does not own, and stay bounded by `AGENT_BROWSER_IDLE_TIMEOUT_MS` instead.
|
||||
*
|
||||
* `ORCA_DISABLE_AGENT_BROWSER_SWEEP=1` turns it off in the field. The other two
|
||||
* behaviours this PR adds are already recoverable without a build — the idle bound
|
||||
* is an env passthrough an operator can raise, and the quit close is bounded by its
|
||||
* own timeout — but a sweep that closes the wrong daemon, or spawns one process per
|
||||
* stale name on a profile with hundreds, would otherwise need a revert.
|
||||
*/
|
||||
export async function sweepOrphanedAgentBrowserSessions(options: {
|
||||
binaryPath: string
|
||||
env: NodeJS.ProcessEnv
|
||||
ownsSocketDirectory: boolean
|
||||
isSessionLive?: (sessionName: string) => boolean
|
||||
}): Promise<string[]> {
|
||||
if (!options.ownsSocketDirectory || process.env.ORCA_DISABLE_AGENT_BROWSER_SWEEP === '1') {
|
||||
return []
|
||||
}
|
||||
let listed: string[]
|
||||
try {
|
||||
const result = await runProcess({
|
||||
program: options.binaryPath,
|
||||
args: ['session', 'list', '--json'],
|
||||
env: options.env,
|
||||
timeoutMs: SWEEP_TIMEOUT_MS,
|
||||
maxOutputBytes: SWEEP_MAX_OUTPUT_BYTES
|
||||
})
|
||||
listed = result.timedOut ? [] : parseSessionNames(result.stdout)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const closed: string[] = []
|
||||
for (const sessionName of listed) {
|
||||
if (!sessionName.startsWith(ORCA_TAB_SESSION_PREFIX) || options.isSessionLive?.(sessionName)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await runProcess({
|
||||
program: options.binaryPath,
|
||||
args: ['--session', sessionName, 'close'],
|
||||
env: options.env,
|
||||
timeoutMs: SWEEP_TIMEOUT_MS,
|
||||
maxOutputBytes: SWEEP_MAX_OUTPUT_BYTES
|
||||
})
|
||||
closed.push(sessionName)
|
||||
} catch {
|
||||
// A daemon that died mid-sweep needs no closing.
|
||||
}
|
||||
}
|
||||
return closed
|
||||
}
|
||||
@@ -1,40 +1,86 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createAgentBrowserProcessEnvironment } from './agent-browser-process-environment'
|
||||
import {
|
||||
AGENT_BROWSER_IDLE_TIMEOUT_MS,
|
||||
createAgentBrowserProcessEnvironment
|
||||
} from './agent-browser-process-environment'
|
||||
|
||||
vi.mock('node:fs', () => ({ mkdirSync: vi.fn(), chmodSync: vi.fn() }))
|
||||
|
||||
describe('agent-browser process environment', () => {
|
||||
it('bounds Unix socket paths independently of a long profile path', () => {
|
||||
const env = createAgentBrowserProcessEnvironment({
|
||||
const { env, ownsSocketDirectory } = createAgentBrowserProcessEnvironment({
|
||||
inheritedEnv: { PATH: '/bin' },
|
||||
platform: 'darwin',
|
||||
userDataPath: `/private/var/folders/${'long-profile-segment/'.repeat(12)}`
|
||||
})
|
||||
const socketDirectory = env.AGENT_BROWSER_SOCKET_DIR
|
||||
|
||||
expect(ownsSocketDirectory).toBe(true)
|
||||
expect(socketDirectory).toMatch(/^\/tmp\/orca-ab-[0-9a-f]{16}$/)
|
||||
expect(
|
||||
`${socketDirectory}/orca-tab-00000000-0000-4000-8000-000000000000.sock`.length
|
||||
).toBeLessThan(104)
|
||||
})
|
||||
|
||||
it('preserves explicit overrides and leaves Windows unchanged', () => {
|
||||
const configured = { AGENT_BROWSER_SOCKET_DIR: '/custom/socket-dir' }
|
||||
expect(
|
||||
createAgentBrowserProcessEnvironment({
|
||||
inheritedEnv: configured,
|
||||
platform: 'linux',
|
||||
// Why ownsSocketDirectory is false for both: a directory Orca did not derive can be shared with
|
||||
// another Orca profile, so `session list` under it is no proof of ownership.
|
||||
it('preserves explicit overrides and leaves Windows socket routing unchanged', () => {
|
||||
const configured = createAgentBrowserProcessEnvironment({
|
||||
inheritedEnv: { AGENT_BROWSER_SOCKET_DIR: '/custom/socket-dir' },
|
||||
platform: 'linux',
|
||||
userDataPath: '/profile'
|
||||
})
|
||||
expect(configured.env.AGENT_BROWSER_SOCKET_DIR).toBe('/custom/socket-dir')
|
||||
expect(configured.ownsSocketDirectory).toBe(false)
|
||||
|
||||
const windows = createAgentBrowserProcessEnvironment({
|
||||
inheritedEnv: { PATH: 'C:\\Windows' },
|
||||
platform: 'win32',
|
||||
userDataPath: 'C:\\Users\\Orca'
|
||||
})
|
||||
expect(windows.env.AGENT_BROWSER_SOCKET_DIR).toBeUndefined()
|
||||
expect(windows.env.PATH).toBe('C:\\Windows')
|
||||
expect(windows.ownsSocketDirectory).toBe(false)
|
||||
})
|
||||
|
||||
// Why: the only daemon bound that survives a SIGKILL'd Orca, so it must be set on every platform.
|
||||
it.each<NodeJS.Platform>(['darwin', 'linux', 'win32'])(
|
||||
'bounds daemon idle lifetime on %s',
|
||||
(platform) => {
|
||||
const { env } = createAgentBrowserProcessEnvironment({
|
||||
inheritedEnv: { PATH: '/bin' },
|
||||
platform,
|
||||
userDataPath: '/profile'
|
||||
})
|
||||
).toBe(configured)
|
||||
expect(env.AGENT_BROWSER_IDLE_TIMEOUT_MS).toBe(String(AGENT_BROWSER_IDLE_TIMEOUT_MS))
|
||||
}
|
||||
)
|
||||
|
||||
const windows = { PATH: 'C:\\Windows' }
|
||||
expect(
|
||||
createAgentBrowserProcessEnvironment({
|
||||
inheritedEnv: windows,
|
||||
platform: 'win32',
|
||||
userDataPath: 'C:\\Users\\Orca'
|
||||
})
|
||||
).toBe(windows)
|
||||
it('never cuts a command short: idle timeout exceeds the bridge exec timeout', () => {
|
||||
expect(AGENT_BROWSER_IDLE_TIMEOUT_MS).toBeGreaterThan(90_000)
|
||||
})
|
||||
|
||||
it('honors an explicit idle timeout from the environment', () => {
|
||||
const { env } = createAgentBrowserProcessEnvironment({
|
||||
inheritedEnv: { AGENT_BROWSER_IDLE_TIMEOUT_MS: '5000' },
|
||||
platform: 'darwin',
|
||||
userDataPath: '/profile'
|
||||
})
|
||||
expect(env.AGENT_BROWSER_IDLE_TIMEOUT_MS).toBe('5000')
|
||||
})
|
||||
|
||||
it('still bounds the daemon when the socket directory cannot be created', async () => {
|
||||
const fs = await import('node:fs')
|
||||
vi.mocked(fs.mkdirSync).mockImplementationOnce(() => {
|
||||
throw new Error('EACCES')
|
||||
})
|
||||
const { env, ownsSocketDirectory } = createAgentBrowserProcessEnvironment({
|
||||
inheritedEnv: { PATH: '/bin' },
|
||||
platform: 'linux',
|
||||
userDataPath: '/profile'
|
||||
})
|
||||
expect(env.AGENT_BROWSER_SOCKET_DIR).toBeUndefined()
|
||||
expect(ownsSocketDirectory).toBe(false)
|
||||
expect(env.AGENT_BROWSER_IDLE_TIMEOUT_MS).toBe(String(AGENT_BROWSER_IDLE_TIMEOUT_MS))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,13 +4,45 @@ import { join } from 'node:path'
|
||||
|
||||
const AGENT_BROWSER_SOCKET_DIRECTORY_PREFIX = 'orca-ab-'
|
||||
|
||||
/**
|
||||
* Lifetime bound for the agent-browser daemon.
|
||||
*
|
||||
* `agent-browser` is a client/daemon CLI: Orca only ever spawns the short-lived
|
||||
* client, which forks a daemon Orca holds no handle on and that reparents to
|
||||
* pid 1 immediately. Nothing in Orca can reap it — not teardown, not a pid walk
|
||||
* (see `windows-pty-job.ts` for why walking your own orphans is guesswork) —
|
||||
* and a SIGKILL'd Orca never runs teardown at all. The daemon's own idle timer
|
||||
* is the only bound that survives every way Orca can die (#16367).
|
||||
*
|
||||
* 10 minutes: >6x `EXEC_TIMEOUT_MS` (90s) so no command, retry chain, or normal
|
||||
* gap between two user commands can be cut short by it, while capping an
|
||||
* abandoned daemon at minutes instead of days. Only per-tab helper daemons get
|
||||
* this: see `externalChromiumAgentBrowserEnvironment` for why the daemon that
|
||||
* owns a whole Chromium tree must not be idled out.
|
||||
*/
|
||||
export const AGENT_BROWSER_IDLE_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
export type AgentBrowserProcessEnvironment = {
|
||||
env: NodeJS.ProcessEnv
|
||||
/**
|
||||
* True only when Orca derived the socket directory itself. An inherited
|
||||
* `AGENT_BROWSER_SOCKET_DIR` can be shared with another Orca profile, so it is
|
||||
* no proof that `session list` under it sees only this profile's daemons.
|
||||
*/
|
||||
ownsSocketDirectory: boolean
|
||||
}
|
||||
|
||||
export function createAgentBrowserProcessEnvironment(options: {
|
||||
inheritedEnv: NodeJS.ProcessEnv
|
||||
platform: NodeJS.Platform
|
||||
userDataPath: string
|
||||
}): NodeJS.ProcessEnv {
|
||||
if (options.platform === 'win32' || options.inheritedEnv.AGENT_BROWSER_SOCKET_DIR?.trim()) {
|
||||
return options.inheritedEnv
|
||||
}): AgentBrowserProcessEnvironment {
|
||||
const env = { ...options.inheritedEnv }
|
||||
if (!env.AGENT_BROWSER_IDLE_TIMEOUT_MS?.trim()) {
|
||||
env.AGENT_BROWSER_IDLE_TIMEOUT_MS = String(AGENT_BROWSER_IDLE_TIMEOUT_MS)
|
||||
}
|
||||
if (options.platform === 'win32' || env.AGENT_BROWSER_SOCKET_DIR?.trim()) {
|
||||
return { env, ownsSocketDirectory: false }
|
||||
}
|
||||
const profileKey = createHash('sha256').update(options.userDataPath).digest('hex').slice(0, 16)
|
||||
const socketDirectory = join('/tmp', `${AGENT_BROWSER_SOCKET_DIRECTORY_PREFIX}${profileKey}`)
|
||||
@@ -18,7 +50,8 @@ export function createAgentBrowserProcessEnvironment(options: {
|
||||
mkdirSync(socketDirectory, { recursive: true, mode: 0o700 })
|
||||
chmodSync(socketDirectory, 0o700)
|
||||
} catch {
|
||||
return options.inheritedEnv
|
||||
return { env, ownsSocketDirectory: false }
|
||||
}
|
||||
return { ...options.inheritedEnv, AGENT_BROWSER_SOCKET_DIR: socketDirectory }
|
||||
env.AGENT_BROWSER_SOCKET_DIR = socketDirectory
|
||||
return { env, ownsSocketDirectory: true }
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@ export type BrowserBackend = {
|
||||
closeTab(browserPageId: string): Promise<void>
|
||||
/** Tear down every page this backend owns (process shutdown). Optional —
|
||||
* renderer-hosted backends are torn down with their window. */
|
||||
destroyAll?(): void
|
||||
destroyAll?(): void | Promise<void>
|
||||
}
|
||||
|
||||
@@ -93,4 +93,208 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('unregisters a closing page before awaiting owner retirement', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
let releaseOwnerRetirement!: () => void
|
||||
const ownerRetirementBlocked = new Promise<void>((resolve) => {
|
||||
releaseOwnerRetirement = resolve
|
||||
})
|
||||
const onPageClosed = vi.fn(() => ownerRetirementBlocked)
|
||||
const backend = new OffscreenBrowserBackend(browserManager as never, {
|
||||
getAgentBrowserBridge: () => ({ onPageClosed })
|
||||
})
|
||||
|
||||
await backend.createTab({ browserPageId: 'page-1', url: 'about:blank', worktreeId: 'wt' })
|
||||
await backend.createTab({ browserPageId: 'page-2', url: 'about:blank', worktreeId: 'wt' })
|
||||
const close = backend.closeTab('page-1')
|
||||
await vi.waitFor(() => expect(onPageClosed).toHaveBeenCalledWith('page-1'))
|
||||
const pageWasUnregisteredBeforeRetirement = browserManager.unregisterGuest.mock.calls.some(
|
||||
([pageId]) => pageId === 'page-1'
|
||||
)
|
||||
releaseOwnerRetirement()
|
||||
await close
|
||||
|
||||
expect(onPageClosed).toHaveBeenCalledOnce()
|
||||
expect(onPageClosed).not.toHaveBeenCalledWith('page-2')
|
||||
expect(backend.getWebContentsId('page-2')).toBe(2)
|
||||
expect(pageWasUnregisteredBeforeRetirement).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves a replacement page when the old window finishes closing', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
let releaseOwnerRetirement!: () => void
|
||||
const ownerRetirementBlocked = new Promise<void>((resolve) => {
|
||||
releaseOwnerRetirement = resolve
|
||||
})
|
||||
const onPageClosed = vi.fn(() => ownerRetirementBlocked)
|
||||
const backend = new OffscreenBrowserBackend(browserManager as never, {
|
||||
getAgentBrowserBridge: () => ({ onPageClosed })
|
||||
})
|
||||
|
||||
await backend.createTab({ browserPageId: 'page-1', url: 'about:blank', worktreeId: 'wt' })
|
||||
const close = backend.closeTab('page-1')
|
||||
await vi.waitFor(() => expect(onPageClosed).toHaveBeenCalledWith('page-1'))
|
||||
await backend.createTab({ browserPageId: 'page-1', url: 'about:blank', worktreeId: 'wt' })
|
||||
|
||||
releaseOwnerRetirement()
|
||||
await close
|
||||
|
||||
expect(backend.getWebContentsId('page-1')).toBe(2)
|
||||
expect(browserManager.unregisterGuest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retires the helper when an offscreen renderer is destroyed unexpectedly', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
const onPageClosed = vi.fn(async () => {})
|
||||
const backend = new OffscreenBrowserBackend(browserManager as never, {
|
||||
getAgentBrowserBridge: () => ({ onPageClosed })
|
||||
})
|
||||
|
||||
await backend.createTab({ browserPageId: 'page-1', url: 'about:blank', worktreeId: 'wt' })
|
||||
mocks.windows[0].webContents.emit('destroyed')
|
||||
await vi.waitFor(() => expect(onPageClosed).toHaveBeenCalledWith('page-1'))
|
||||
})
|
||||
|
||||
it('cleans every helper owner during backend shutdown', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
const onPageClosed = vi.fn(async () => {})
|
||||
const backend = new OffscreenBrowserBackend(browserManager as never, {
|
||||
getAgentBrowserBridge: () => ({ onPageClosed })
|
||||
})
|
||||
|
||||
await backend.createTab({ browserPageId: 'page-1', url: 'about:blank', worktreeId: 'wt' })
|
||||
await backend.createTab({ browserPageId: 'page-2', url: 'about:blank', worktreeId: 'wt' })
|
||||
await backend.destroyAll()
|
||||
|
||||
expect(onPageClosed).toHaveBeenCalledTimes(2)
|
||||
expect(browserManager.unregisterGuest).toHaveBeenCalledWith('page-1')
|
||||
expect(browserManager.unregisterGuest).toHaveBeenCalledWith('page-2')
|
||||
})
|
||||
|
||||
it('rejects a concurrent create while shutdown is draining owned pages', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
let releaseOwnerRetirement!: () => void
|
||||
const ownerRetirementBlocked = new Promise<void>((resolve) => {
|
||||
releaseOwnerRetirement = resolve
|
||||
})
|
||||
const onPageClosed = vi.fn(() => ownerRetirementBlocked)
|
||||
const backend = new OffscreenBrowserBackend(browserManager as never, {
|
||||
getAgentBrowserBridge: () => ({ onPageClosed })
|
||||
})
|
||||
|
||||
await backend.createTab({ browserPageId: 'page-1', url: 'about:blank', worktreeId: 'wt' })
|
||||
const shutdown = backend.destroyAll()
|
||||
await vi.waitFor(() => expect(onPageClosed).toHaveBeenCalledWith('page-1'))
|
||||
|
||||
await expect(
|
||||
backend.createTab({ browserPageId: 'page-2', url: 'about:blank', worktreeId: 'wt' })
|
||||
).rejects.toThrow('Offscreen browser backend is shutting down')
|
||||
|
||||
releaseOwnerRetirement()
|
||||
await shutdown
|
||||
expect(mocks.windows).toHaveLength(1)
|
||||
expect(browserManager.unregisterGuest).toHaveBeenCalledWith('page-1')
|
||||
expect(browserManager.unregisterGuest).not.toHaveBeenCalledWith('page-2')
|
||||
})
|
||||
|
||||
it('joins owner retirement started by an unexpected renderer destroy', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
let releaseOwnerRetirement!: () => void
|
||||
const ownerRetirementBlocked = new Promise<void>((resolve) => {
|
||||
releaseOwnerRetirement = resolve
|
||||
})
|
||||
const onPageClosed = vi.fn(() => ownerRetirementBlocked)
|
||||
const backend = new OffscreenBrowserBackend(browserManager as never, {
|
||||
getAgentBrowserBridge: () => ({ onPageClosed })
|
||||
})
|
||||
|
||||
await backend.createTab({ browserPageId: 'page-1', url: 'about:blank', worktreeId: 'wt' })
|
||||
mocks.windows[0].webContents.emit('destroyed')
|
||||
await vi.waitFor(() => expect(onPageClosed).toHaveBeenCalledWith('page-1'))
|
||||
|
||||
const shutdown = backend.destroyAll()
|
||||
const outcome = await Promise.race([
|
||||
shutdown.then(() => 'settled'),
|
||||
new Promise<string>((resolve) => setImmediate(() => resolve('pending')))
|
||||
])
|
||||
expect(outcome).toBe('pending')
|
||||
|
||||
releaseOwnerRetirement()
|
||||
await shutdown
|
||||
})
|
||||
|
||||
it('bounds concurrent helper retirements during shutdown', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
let activeRetirements = 0
|
||||
let peakRetirements = 0
|
||||
const releases: (() => void)[] = []
|
||||
const onPageClosed = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
activeRetirements++
|
||||
peakRetirements = Math.max(peakRetirements, activeRetirements)
|
||||
releases.push(() => {
|
||||
activeRetirements--
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
)
|
||||
const backend = new OffscreenBrowserBackend(browserManager as never, {
|
||||
getAgentBrowserBridge: () => ({ onPageClosed })
|
||||
})
|
||||
|
||||
for (let index = 0; index < 6; index++) {
|
||||
await backend.createTab({
|
||||
browserPageId: `page-${index}`,
|
||||
url: 'about:blank',
|
||||
worktreeId: 'wt'
|
||||
})
|
||||
}
|
||||
|
||||
const shutdown = backend.destroyAll()
|
||||
await vi.waitFor(() => expect(onPageClosed).toHaveBeenCalledTimes(4))
|
||||
releases.splice(0).forEach((release) => release())
|
||||
await vi.waitFor(() => expect(onPageClosed).toHaveBeenCalledTimes(6))
|
||||
releases.splice(0).forEach((release) => release())
|
||||
await shutdown
|
||||
|
||||
expect(peakRetirements).toBe(4)
|
||||
})
|
||||
|
||||
it('closes the page even when daemon retirement throws', async () => {
|
||||
const browserManager = { registerOffscreenGuest: vi.fn(), unregisterGuest: vi.fn() }
|
||||
const backend = new OffscreenBrowserBackend(browserManager as never, {
|
||||
getAgentBrowserBridge: () => ({
|
||||
onPageClosed: vi.fn(async () => {
|
||||
throw new Error('daemon gone')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
await backend.createTab({ browserPageId: 'page-1', url: 'about:blank', worktreeId: 'wt' })
|
||||
await expect(backend.closeTab('page-1')).resolves.toBeUndefined()
|
||||
expect(browserManager.unregisterGuest).toHaveBeenCalledWith('page-1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,10 @@ import { randomUUID } from 'node:crypto'
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
|
||||
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences'
|
||||
import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency'
|
||||
import type { BrowserBackend, BrowserBackendCreateTab } from './browser-backend'
|
||||
import type { BrowserManager } from './browser-manager'
|
||||
import type { AgentBrowserBridge } from './agent-browser-bridge'
|
||||
import { browserSessionRegistry } from './browser-session-registry'
|
||||
|
||||
// Why: headless orca serve has no renderer window to host a <webview>, so each
|
||||
@@ -16,13 +18,26 @@ import { browserSessionRegistry } from './browser-session-registry'
|
||||
const DEFAULT_VIEWPORT_WIDTH = 1280
|
||||
const DEFAULT_VIEWPORT_HEIGHT = 800
|
||||
const LOAD_TIMEOUT_MS = 30_000
|
||||
const OWNER_RETIREMENT_CONCURRENCY = 4
|
||||
|
||||
export class OffscreenBrowserBackend implements BrowserBackend {
|
||||
private readonly windowsByPageId = new Map<string, BrowserWindow>()
|
||||
// Shutdown is terminal for this backend; rejecting creates closes the race
|
||||
// where destroyAll snapshots ownership and a new page appears afterward.
|
||||
private shutdownStarted = false
|
||||
private readonly pendingOwnerRetirements = new Set<Promise<void>>()
|
||||
|
||||
constructor(private readonly browserManager: BrowserManager) {}
|
||||
constructor(
|
||||
private readonly browserManager: BrowserManager,
|
||||
private readonly options: {
|
||||
getAgentBrowserBridge?: () => Pick<AgentBrowserBridge, 'onPageClosed'> | null
|
||||
} = {}
|
||||
) {}
|
||||
|
||||
async createTab(params: BrowserBackendCreateTab): Promise<{ browserPageId: string }> {
|
||||
if (this.shutdownStarted) {
|
||||
throw new Error('Offscreen browser backend is shutting down')
|
||||
}
|
||||
const browserPageId = params.browserPageId ?? randomUUID()
|
||||
if (this.windowsByPageId.has(browserPageId)) {
|
||||
throw new Error(`Browser page ${browserPageId} already exists`)
|
||||
@@ -55,6 +70,12 @@ export class OffscreenBrowserBackend implements BrowserBackend {
|
||||
// teardown), drop the registry entry so commands fail cleanly instead of
|
||||
// resolving a dead WebContents.
|
||||
win.webContents.once('destroyed', () => {
|
||||
// Explicit close removes the page first and performs awaited cleanup;
|
||||
// only an unexpected destruction still owns the bridge retirement here.
|
||||
if (this.windowsByPageId.get(browserPageId) !== win) {
|
||||
return
|
||||
}
|
||||
void this.retirePageOwner(browserPageId)
|
||||
this.windowsByPageId.delete(browserPageId)
|
||||
this.browserManager.unregisterGuest(browserPageId)
|
||||
})
|
||||
@@ -88,8 +109,14 @@ export class OffscreenBrowserBackend implements BrowserBackend {
|
||||
const win = this.windowsByPageId.get(browserPageId)
|
||||
this.windowsByPageId.delete(browserPageId)
|
||||
this.browserManager.unregisterGuest(browserPageId)
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.destroy()
|
||||
try {
|
||||
if (win) {
|
||||
await this.retirePageOwner(browserPageId)
|
||||
}
|
||||
} finally {
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,14 +125,24 @@ export class OffscreenBrowserBackend implements BrowserBackend {
|
||||
return win && !win.isDestroyed() ? win.webContents.id : null
|
||||
}
|
||||
|
||||
destroyAll(): void {
|
||||
for (const [pageId, win] of this.windowsByPageId) {
|
||||
this.browserManager.unregisterGuest(pageId)
|
||||
if (!win.isDestroyed()) {
|
||||
win.destroy()
|
||||
}
|
||||
async destroyAll(): Promise<void> {
|
||||
this.shutdownStarted = true
|
||||
const pageIds = [...this.windowsByPageId.keys()]
|
||||
await mapSettledWithConcurrency(pageIds, OWNER_RETIREMENT_CONCURRENCY, (pageId) =>
|
||||
this.closeTab(pageId)
|
||||
)
|
||||
await Promise.all(this.pendingOwnerRetirements)
|
||||
}
|
||||
|
||||
private retirePageOwner(browserPageId: string): Promise<void> {
|
||||
const bridge = this.options.getAgentBrowserBridge?.()
|
||||
if (!bridge) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
this.windowsByPageId.clear()
|
||||
const retirement = bridge.onPageClosed(browserPageId).catch(() => {})
|
||||
this.pendingOwnerRetirements.add(retirement)
|
||||
void retirement.finally(() => this.pendingOwnerRetirements.delete(retirement))
|
||||
return retirement
|
||||
}
|
||||
|
||||
private async loadUrl(win: BrowserWindow, url: string): Promise<void> {
|
||||
|
||||
@@ -305,6 +305,35 @@ describe('CodexRuntimeHomeService', () => {
|
||||
expect(discovery).toContain(home1)
|
||||
})
|
||||
|
||||
it('includes WSL account homes in session discovery', async () => {
|
||||
const wslHome =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\me\\.local\\share\\orca\\codex-accounts\\account-1\\home'
|
||||
const store = createStore(
|
||||
createSettings({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
email: 'wsl@example.com',
|
||||
managedHomePath: wslHome,
|
||||
managedHomeRuntime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxHomePath: '/home/me/.local/share/orca/codex-accounts/account-1/home',
|
||||
providerAccountId: null,
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
expect(service.getHostCodexHomePathsForSessionDiscovery()).toContain(wslHome)
|
||||
})
|
||||
|
||||
it('surfaces per-account rollouts for session discovery on the mirror lane', async () => {
|
||||
// A Windows host keeps the shared system-default mirror, but its managed
|
||||
// accounts still launch from their own homes and accumulate rollouts there.
|
||||
|
||||
@@ -18,6 +18,18 @@ import {
|
||||
testState,
|
||||
writePaneRegistry
|
||||
} from './runtime-home-service-test-harness'
|
||||
import { hasCompletedCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker'
|
||||
import { getCodexSessionBackfillDate } from '../codex/codex-session-backfill-scan-dates'
|
||||
|
||||
function expectBaselineKeptWithLaunchDatePending(markerPath: string): void {
|
||||
const marker = JSON.parse(readFileSync(markerPath, 'utf-8')) as {
|
||||
pendingScanDates?: unknown
|
||||
}
|
||||
expect(marker.pendingScanDates).toEqual([getCodexSessionBackfillDate()])
|
||||
expect(
|
||||
hasCompletedCodexSessionBackfillMarker(markerPath, join(getSystemCodexHomePath(), 'sessions'))
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
@@ -54,7 +66,9 @@ describe('CodexRuntimeHomeService', () => {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
expect(
|
||||
hasCompletedCodexSessionBackfillMarker(markerPath, join(getSystemCodexHomePath(), 'sessions'))
|
||||
).toBe(false)
|
||||
service.finishHostSystemDefaultSessionMigrationPass()
|
||||
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
|
||||
true
|
||||
@@ -82,7 +96,7 @@ describe('CodexRuntimeHomeService', () => {
|
||||
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
|
||||
false
|
||||
)
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
expectBaselineKeptWithLaunchDatePending(markerPath)
|
||||
service.prepareForCodexLaunch()
|
||||
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
|
||||
false
|
||||
@@ -101,7 +115,7 @@ describe('CodexRuntimeHomeService', () => {
|
||||
expect(service.beginHostSystemDefaultSessionMigrationLaunch(null, { reattached: true })).toBe(
|
||||
false
|
||||
)
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
expectBaselineKeptWithLaunchDatePending(markerPath)
|
||||
store.updateSettings({
|
||||
codexSessionSourceHome: { host: join(testState.fakeHomeDir, 'moved-history'), wsl: {} }
|
||||
})
|
||||
@@ -140,7 +154,9 @@ describe('CodexRuntimeHomeService', () => {
|
||||
mkdirSync(join(testState.userDataDir, 'codex-session-backfill'), { recursive: true })
|
||||
writeFileSync(markerPath, '{}\n', 'utf-8')
|
||||
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
expect(
|
||||
hasCompletedCodexSessionBackfillMarker(markerPath, join(getSystemCodexHomePath(), 'sessions'))
|
||||
).toBe(false)
|
||||
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
@@ -95,7 +95,7 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => {
|
||||
const config = readFileSync(join(account.managedHomePath, 'config.toml'), 'utf8')
|
||||
expect(config).toContain('model = "fixture-model"')
|
||||
expect(config).not.toContain('[hooks.state')
|
||||
expect(hookService.install(account.managedHomePath).state).toBe('installed')
|
||||
expect((await hookService.install(account.managedHomePath)).state).toBe('installed')
|
||||
expect(readFileSync(join(account.managedHomePath, 'hooks.json'), 'utf8')).toContain(
|
||||
process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh'
|
||||
)
|
||||
|
||||
@@ -59,7 +59,7 @@ import {
|
||||
prepareSystemConfigForFreshRuntimeMirror,
|
||||
syncSystemConfigIntoManagedCodexHome
|
||||
} from '../codex/codex-config-mirror'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths'
|
||||
import {
|
||||
getWslSelectionKey,
|
||||
getSelectedCodexAccountIdForTarget,
|
||||
@@ -71,8 +71,10 @@ import { getDefaultWslDistro, getWslHome } from '../wsl'
|
||||
import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path'
|
||||
import {
|
||||
hasCompletedCodexSessionBackfillMarker,
|
||||
invalidateCodexSessionBackfillMarker
|
||||
markCodexSessionBackfillMarkerPending
|
||||
} from '../codex/codex-session-backfill-marker'
|
||||
import { getCodexSessionBackfillDate } from '../codex/codex-session-backfill-scan-dates'
|
||||
import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types'
|
||||
import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill'
|
||||
import {
|
||||
ManagedCodexHomeTemporarilyUnavailableError,
|
||||
@@ -305,18 +307,30 @@ export class CodexRuntimeHomeService {
|
||||
)
|
||||
}
|
||||
|
||||
prepareHostSystemDefaultSessionMigrationPass(): boolean {
|
||||
prepareHostSystemDefaultSessionMigrationPass(
|
||||
scanDates: readonly CodexSessionBackfillDate[] = []
|
||||
): boolean {
|
||||
const paths = resolveCodexSessionBackfillPaths(
|
||||
resolveHostCodexSessionSourceHome(this.store.getSettings())
|
||||
)
|
||||
const target = normalizeRuntimePathForComparison(paths.systemSessionsRoot)
|
||||
if (
|
||||
this.hostSystemDefaultSessionMigrationPending &&
|
||||
this.pendingHostSystemDefaultSessionMigrationTarget !== paths.systemSessionsRoot
|
||||
this.pendingHostSystemDefaultSessionMigrationTarget !== target
|
||||
) {
|
||||
this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = true
|
||||
this.pendingHostSystemDefaultSessionMigrationTarget = paths.systemSessionsRoot
|
||||
this.pendingHostSystemDefaultSessionMigrationTarget = target
|
||||
}
|
||||
invalidateCodexSessionBackfillMarker(paths.markerPath)
|
||||
// Why: the launch creates rollouts for these dates; record them durably so a
|
||||
// force-quit recovers a bounded window instead of re-walking all history.
|
||||
const markerOwesFullScan = markCodexSessionBackfillMarkerPending(
|
||||
paths.markerPath,
|
||||
paths.systemSessionsRoot,
|
||||
scanDates.length > 0 ? scanDates : [getCodexSessionBackfillDate()]
|
||||
)
|
||||
// Why: the marker is the only place an overflowed pending window survives a
|
||||
// restart, so its demand has to reach this pass rather than die in the file.
|
||||
this.pendingHostSystemDefaultSessionMigrationNeedsFullScan ||= markerOwesFullScan
|
||||
return this.pendingHostSystemDefaultSessionMigrationNeedsFullScan
|
||||
}
|
||||
|
||||
@@ -342,14 +356,14 @@ export class CodexRuntimeHomeService {
|
||||
return account
|
||||
}
|
||||
|
||||
// Why: session discovery must surface a managed account's own rollouts wherever
|
||||
// they physically live. Every host managed home is a live CODEX_HOME, so scan
|
||||
// them all.
|
||||
private getManagedHostAccountHomesForSessionDiscovery(): string[] {
|
||||
// Why: session discovery must surface every account's own rollouts wherever they live.
|
||||
private getManagedAccountHomesForSessionDiscovery(): string[] {
|
||||
const settings = this.store.getSettings()
|
||||
const homes: string[] = []
|
||||
for (const account of settings.codexManagedAccounts) {
|
||||
if (this.getWslManagedHomePath(account)) {
|
||||
const wslHome = this.getWslManagedHomePath(account)
|
||||
if (wslHome) {
|
||||
homes.push(wslHome)
|
||||
continue
|
||||
}
|
||||
const trustedHome = this.getTrustedSelfContainedManagedHomePath(account)
|
||||
@@ -360,6 +374,12 @@ export class CodexRuntimeHomeService {
|
||||
return homes
|
||||
}
|
||||
|
||||
private getManagedHostAccountHomesForSessionDiscovery(): string[] {
|
||||
return this.getManagedAccountHomesForSessionDiscovery().filter(
|
||||
(home) => parseWslUncPath(home) === null
|
||||
)
|
||||
}
|
||||
|
||||
private prepareSelfContainedManagedHomeForLaunch(
|
||||
account: CodexManagedAccount,
|
||||
unavailableManagedHomePath?: string
|
||||
@@ -526,7 +546,9 @@ export class CodexRuntimeHomeService {
|
||||
)
|
||||
this.pendingHostSystemDefaultSessionMigrationNeedsFullScan =
|
||||
!hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot)
|
||||
this.pendingHostSystemDefaultSessionMigrationTarget = paths.systemSessionsRoot
|
||||
this.pendingHostSystemDefaultSessionMigrationTarget = normalizeRuntimePathForComparison(
|
||||
paths.systemSessionsRoot
|
||||
)
|
||||
this.hostSystemDefaultSessionMigrationPending = true
|
||||
}
|
||||
return this.prepareHostSystemDefaultSessionMigrationPass()
|
||||
@@ -567,10 +589,8 @@ export class CodexRuntimeHomeService {
|
||||
// mirror, so include the real root for both directly-routed host lanes.
|
||||
homes.push(getSystemCodexHomePath())
|
||||
}
|
||||
// Why: each managed host account runs in its own self-contained home, so
|
||||
// its rollouts live there rather than in the shared mirror. Scan every such
|
||||
// home so account-scoped sessions still surface in the AI Vault.
|
||||
for (const perAccountHome of this.getManagedHostAccountHomesForSessionDiscovery()) {
|
||||
// Why: account-scoped rollouts live in each account's own home, including WSL.
|
||||
for (const perAccountHome of this.getManagedAccountHomesForSessionDiscovery()) {
|
||||
homes.push(perAccountHome)
|
||||
}
|
||||
return homes.filter((home, index) => homes.indexOf(home) === index)
|
||||
@@ -757,7 +777,11 @@ export class CodexRuntimeHomeService {
|
||||
systemHomePath,
|
||||
managedHomePath: runtimeHomePath
|
||||
})
|
||||
syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
syncSystemConfigIntoManagedCodexHome({
|
||||
runtimeHomePath,
|
||||
systemHomePath,
|
||||
systemConfigDir: toLinuxPath(systemHomePath)
|
||||
})
|
||||
}
|
||||
|
||||
// Why: `null` is a real value here — it means "use the system-default lane".
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import type * as NodeOs from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createSettings } from './runtime-home-settings-test-fixtures'
|
||||
import {
|
||||
createStore,
|
||||
getRuntimeCodexHomePath,
|
||||
setupRuntimeHomeTest,
|
||||
teardownRuntimeHomeTest,
|
||||
testState
|
||||
} from './runtime-home-service-test-harness'
|
||||
import { getCodexSessionBackfillDate } from '../codex/codex-session-backfill-scan-dates'
|
||||
import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => testState.userDataDir
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node:os', async () => {
|
||||
const actual = await vi.importActual<typeof NodeOs>('node:os')
|
||||
return {
|
||||
...actual,
|
||||
homedir: () => testState.fakeHomeDir
|
||||
}
|
||||
})
|
||||
|
||||
const CUSTOM_HISTORY_HOME = 'C:\\Users\\Me\\.codex'
|
||||
|
||||
function getMarkerPath(): string {
|
||||
return join(testState.userDataDir, 'codex-session-backfill', 'backfill-complete.json')
|
||||
}
|
||||
|
||||
function writeBaselineMarker(systemCodexHomePath: string, needsFullScan = false): void {
|
||||
mkdirSync(join(testState.userDataDir, 'codex-session-backfill'), { recursive: true })
|
||||
writeFileSync(
|
||||
getMarkerPath(),
|
||||
`${JSON.stringify({
|
||||
version: 4,
|
||||
systemSessionsRoot: join(systemCodexHomePath, 'sessions'),
|
||||
coverage: 'full',
|
||||
baselineScannedFiles: 5,
|
||||
pendingScanDates: [],
|
||||
needsFullScan,
|
||||
summary: { scannedFiles: 5 }
|
||||
})}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
}
|
||||
|
||||
function readPendingScanDates(): unknown {
|
||||
return (JSON.parse(readFileSync(getMarkerPath(), 'utf-8')) as { pendingScanDates?: unknown })
|
||||
.pendingScanDates
|
||||
}
|
||||
|
||||
describe('host system default session migration pass preparation', () => {
|
||||
beforeEach(() => {
|
||||
setupRuntimeHomeTest()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
teardownRuntimeHomeTest()
|
||||
})
|
||||
|
||||
it('records the launch date and keeps the baseline instead of deleting it', async () => {
|
||||
writeBaselineMarker(CUSTOM_HISTORY_HOME)
|
||||
const store = createStore(
|
||||
createSettings({ codexSessionSourceHome: { host: CUSTOM_HISTORY_HOME, wsl: {} } })
|
||||
)
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
expect(service.prepareHostSystemDefaultSessionMigrationPass()).toBe(false)
|
||||
|
||||
expect(readPendingScanDates()).toEqual([getCodexSessionBackfillDate()])
|
||||
expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ coverage: 'full' })
|
||||
})
|
||||
|
||||
it('persists every date a scheduled pass reports so a force-quit stays bounded', async () => {
|
||||
writeBaselineMarker(CUSTOM_HISTORY_HOME)
|
||||
const store = createStore(
|
||||
createSettings({ codexSessionSourceHome: { host: CUSTOM_HISTORY_HOME, wsl: {} } })
|
||||
)
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
const spannedDates: CodexSessionBackfillDate[] = [
|
||||
['2026', '08', '05'],
|
||||
['2026', '08', '06']
|
||||
]
|
||||
|
||||
service.prepareHostSystemDefaultSessionMigrationPass(spannedDates)
|
||||
|
||||
expect(readPendingScanDates()).toEqual(spannedDates)
|
||||
})
|
||||
|
||||
it('does not demand a full scan when the same history home is spelled differently', async () => {
|
||||
writeBaselineMarker(CUSTOM_HISTORY_HOME)
|
||||
const store = createStore(
|
||||
createSettings({ codexSessionSourceHome: { host: CUSTOM_HISTORY_HOME, wsl: {} } })
|
||||
)
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
store.updateSettings({
|
||||
codexSessionSourceHome: { host: 'c:/users/me/.codex', wsl: {} }
|
||||
})
|
||||
|
||||
expect(service.prepareHostSystemDefaultSessionMigrationPass()).toBe(false)
|
||||
})
|
||||
|
||||
it('carries a full-scan demand persisted by an earlier launch into this pass', async () => {
|
||||
writeBaselineMarker(CUSTOM_HISTORY_HOME, true)
|
||||
const store = createStore(
|
||||
createSettings({ codexSessionSourceHome: { host: CUSTOM_HISTORY_HOME, wsl: {} } })
|
||||
)
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
expect(service.prepareHostSystemDefaultSessionMigrationPass()).toBe(true)
|
||||
|
||||
// Recording this launch must not erase the demand the marker still carries.
|
||||
expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({
|
||||
needsFullScan: true
|
||||
})
|
||||
})
|
||||
|
||||
it('still demands a full scan when the history home really moves', async () => {
|
||||
writeBaselineMarker(CUSTOM_HISTORY_HOME)
|
||||
const store = createStore(
|
||||
createSettings({ codexSessionSourceHome: { host: CUSTOM_HISTORY_HOME, wsl: {} } })
|
||||
)
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
store.updateSettings({
|
||||
codexSessionSourceHome: { host: 'C:\\Users\\Me\\moved-codex', wsl: {} }
|
||||
})
|
||||
|
||||
expect(service.prepareHostSystemDefaultSessionMigrationPass()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type * as WslPaths from '../../shared/wsl-paths'
|
||||
import { createSettings } from './runtime-home-settings-test-fixtures'
|
||||
import {
|
||||
createManagedAuth,
|
||||
@@ -281,15 +282,19 @@ describe('CodexRuntimeHomeService', () => {
|
||||
getDefaultWslDistro: () => null,
|
||||
getWslHome: (distro: string) => (distro === 'Debian' ? wslHome : null)
|
||||
}))
|
||||
vi.doMock('../../shared/wsl-paths', () => ({
|
||||
parseWslUncPath: (candidate: string) =>
|
||||
candidate === wslRuntimeHomePath
|
||||
? {
|
||||
distro: 'Debian',
|
||||
linuxPath: '/home/alice/.local/share/orca/codex-runtime-home/home'
|
||||
}
|
||||
: null
|
||||
}))
|
||||
vi.doMock('../../shared/wsl-paths', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof WslPaths>()
|
||||
return {
|
||||
...actual,
|
||||
parseWslUncPath: (candidate: string) =>
|
||||
candidate === wslRuntimeHomePath
|
||||
? {
|
||||
distro: 'Debian',
|
||||
linuxPath: '/home/alice/.local/share/orca/codex-runtime-home/home'
|
||||
}
|
||||
: null
|
||||
}
|
||||
})
|
||||
const managedHomePath = createManagedAuth(
|
||||
testState.userDataDir,
|
||||
'debian-account',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type * as CodexConfigMirror from '../codex/codex-config-mirror'
|
||||
import { createSettings } from './runtime-home-settings-test-fixtures'
|
||||
import {
|
||||
createCodexAuthJson,
|
||||
@@ -327,4 +328,47 @@ describe('CodexRuntimeHomeService', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('passes the Linux source config directory for mounted-drive WSL homes', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
vi.doMock('../wsl', () => ({
|
||||
getDefaultWslDistro: () => 'Ubuntu',
|
||||
getWslHome: () => 'C:\\Users\\alice'
|
||||
}))
|
||||
const syncConfig = vi.fn()
|
||||
vi.doMock('../codex/codex-config-mirror', async () => ({
|
||||
...(await vi.importActual<typeof CodexConfigMirror>('../codex/codex-config-mirror')),
|
||||
syncSystemConfigIntoManagedCodexHome: syncConfig
|
||||
}))
|
||||
|
||||
try {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(createStore(createSettings()) as never)
|
||||
const syncWslConfig = (
|
||||
service as unknown as {
|
||||
syncWslConfigAndGlobalInstructionsForLaunch: (
|
||||
target: { runtime: 'wsl'; wslDistro?: string | null },
|
||||
runtimeHomePath: string | null
|
||||
) => void
|
||||
}
|
||||
).syncWslConfigAndGlobalInstructionsForLaunch
|
||||
|
||||
syncWslConfig.call(
|
||||
service,
|
||||
{ runtime: 'wsl', wslDistro: 'Ubuntu' },
|
||||
join(testState.userDataDir, 'runtime-home')
|
||||
)
|
||||
|
||||
expect(syncConfig).toHaveBeenCalledWith({
|
||||
runtimeHomePath: join(testState.userDataDir, 'runtime-home'),
|
||||
systemHomePath: 'C:\\Users\\alice/.codex',
|
||||
systemConfigDir: '/mnt/c/Users/alice/.codex'
|
||||
})
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,6 +45,148 @@ function wslFailed(code: number, stderr = ''): WslResult {
|
||||
describe('CodexAccountService config sync', () => {
|
||||
registerCodexAccountsTestHomes()
|
||||
|
||||
it('preserves WSL account-home project trust while refreshing canonical settings', async () => {
|
||||
const wslManagedHomePath = join(testState.userDataDir, 'wsl-account', 'home')
|
||||
const wslCanonicalHomePath = join(testState.userDataDir, 'wsl-home', '.codex')
|
||||
const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-1/home'
|
||||
const wslLinuxCanonicalHomePath = '/home/alice/.codex'
|
||||
mkdirSync(wslManagedHomePath, { recursive: true })
|
||||
mkdirSync(wslCanonicalHomePath, { recursive: true })
|
||||
writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-1\n', 'utf-8')
|
||||
writeFileSync(
|
||||
join(wslManagedHomePath, 'config.toml'),
|
||||
'approval_policy = "untrusted"\n[projects."/workspace"]\ntrust_level = "trusted"\n',
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
join(wslCanonicalHomePath, 'config.toml'),
|
||||
'sandbox_mode = "danger-full-access"\n',
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
vi.doMock('../../shared/wsl-paths', () => ({
|
||||
parseWslUncPath: (path: string) => {
|
||||
if (path === wslManagedHomePath) {
|
||||
return { distro: 'Ubuntu', linuxPath: wslLinuxHomePath }
|
||||
}
|
||||
if (path === wslCanonicalHomePath) {
|
||||
return { distro: 'Ubuntu', linuxPath: wslLinuxCanonicalHomePath }
|
||||
}
|
||||
return null
|
||||
}
|
||||
}))
|
||||
vi.doMock('../wsl', () => ({
|
||||
toWindowsWslPath: (linuxPath: string) =>
|
||||
linuxPath === wslLinuxCanonicalHomePath ||
|
||||
linuxPath === `${wslLinuxCanonicalHomePath}/config.toml`
|
||||
? linuxPath.endsWith('/config.toml')
|
||||
? join(wslCanonicalHomePath, 'config.toml')
|
||||
: wslCanonicalHomePath
|
||||
: wslManagedHomePath
|
||||
}))
|
||||
|
||||
const settings = createSettings({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
email: 'wsl@example.com',
|
||||
managedHomePath: wslManagedHomePath,
|
||||
managedHomeRuntime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxHomePath,
|
||||
providerAccountId: null,
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const { CodexAccountService } = await import('./service')
|
||||
new CodexAccountService(
|
||||
createStore(settings) as never,
|
||||
createRateLimits() as never,
|
||||
createRuntimeHome() as never
|
||||
)
|
||||
|
||||
expect(readFileSync(join(wslManagedHomePath, 'config.toml'), 'utf-8')).toBe(
|
||||
'sandbox_mode = "danger-full-access"\n\n' +
|
||||
'[projects."/workspace"]\ntrust_level = "trusted"\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps Linux-relative config paths when a WSL home is under a mounted drive', async () => {
|
||||
vi.resetModules()
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
const wslManagedHomePath = join(testState.userDataDir, 'wsl-account', 'home')
|
||||
const wslCanonicalHomePath = join(testState.userDataDir, 'wsl-home', '.codex')
|
||||
const wslCanonicalConfigPath = join(wslCanonicalHomePath, 'config.toml')
|
||||
const wslLinuxHomePath = '/mnt/c/Users/alice/.local/share/orca/codex-accounts/account-1/home'
|
||||
const wslLinuxCanonicalHomePath = '/mnt/c/Users/alice/.codex'
|
||||
mkdirSync(wslManagedHomePath, { recursive: true })
|
||||
mkdirSync(wslCanonicalHomePath, { recursive: true })
|
||||
writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-1\n', 'utf-8')
|
||||
writeFileSync(wslCanonicalConfigPath, 'model_instructions_file = "instructions.md"\n', 'utf-8')
|
||||
|
||||
vi.doMock('node:child_process', () => ({
|
||||
execFileSync: vi.fn(() => `${wslLinuxHomePath}\n`),
|
||||
spawn: vi.fn()
|
||||
}))
|
||||
vi.doMock('../../shared/wsl-paths', () => ({
|
||||
parseWslUncPath: (path: string) =>
|
||||
path === wslManagedHomePath ? { distro: 'Ubuntu', linuxPath: wslLinuxHomePath } : null
|
||||
}))
|
||||
vi.doMock('../wsl', () => ({
|
||||
toWindowsWslPath: (linuxPath: string) =>
|
||||
linuxPath.endsWith('/config.toml')
|
||||
? wslCanonicalConfigPath
|
||||
: linuxPath === wslLinuxCanonicalHomePath
|
||||
? wslCanonicalHomePath
|
||||
: wslManagedHomePath
|
||||
}))
|
||||
|
||||
const settings = createSettings({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
email: 'wsl@example.com',
|
||||
managedHomePath: wslManagedHomePath,
|
||||
managedHomeRuntime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxHomePath,
|
||||
providerAccountId: null,
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
try {
|
||||
const { CodexAccountService } = await import('./service')
|
||||
new CodexAccountService(
|
||||
createStore(settings) as never,
|
||||
createRateLimits() as never,
|
||||
createRuntimeHome() as never
|
||||
)
|
||||
|
||||
expect(readFileSync(join(wslManagedHomePath, 'config.toml'), 'utf-8')).toContain(
|
||||
"model_instructions_file = '/mnt/c/Users/alice/.codex/instructions.md'"
|
||||
)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('adds a managed Codex account inside WSL when the account context is WSL', async () => {
|
||||
vi.resetModules()
|
||||
const originalPlatform = process.platform
|
||||
@@ -54,8 +196,10 @@ describe('CodexAccountService config sync', () => {
|
||||
})
|
||||
|
||||
const wslManagedHomePath = join(testState.userDataDir, 'wsl-managed-home')
|
||||
const wslConfigPath = join(testState.userDataDir, 'wsl-config.toml')
|
||||
const wslConfigHomePath = join(testState.userDataDir, 'wsl-config-home')
|
||||
const wslConfigPath = join(wslConfigHomePath, 'config.toml')
|
||||
const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-id-for-test/home'
|
||||
mkdirSync(wslConfigHomePath, { recursive: true })
|
||||
writeFileSync(
|
||||
wslConfigPath,
|
||||
'sandbox_mode = "danger-full-access"\nmodel_instructions_file = "instructions.md"\n',
|
||||
@@ -130,11 +274,19 @@ describe('CodexAccountService config sync', () => {
|
||||
vi.doMock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock }))
|
||||
vi.doMock('../../shared/wsl-paths', () => ({
|
||||
parseWslUncPath: (path: string) =>
|
||||
path === wslManagedHomePath ? { distro: 'Debian', linuxPath: wslLinuxHomePath } : null
|
||||
path === wslManagedHomePath
|
||||
? { distro: 'Debian', linuxPath: wslLinuxHomePath }
|
||||
: path === wslConfigHomePath
|
||||
? { distro: 'Debian', linuxPath: '/home/alice/.codex' }
|
||||
: null
|
||||
}))
|
||||
vi.doMock('../wsl', () => ({
|
||||
toWindowsWslPath: (linuxPath: string) =>
|
||||
linuxPath.endsWith('/.codex/config.toml') ? wslConfigPath : wslManagedHomePath
|
||||
linuxPath.endsWith('/.codex/config.toml')
|
||||
? wslConfigPath
|
||||
: linuxPath.endsWith('/.codex')
|
||||
? wslConfigHomePath
|
||||
: wslManagedHomePath
|
||||
}))
|
||||
|
||||
const settings = createSettings()
|
||||
|
||||
@@ -32,12 +32,8 @@ import type {
|
||||
} from '../../shared/codex-reset-credit-attempt-ledger'
|
||||
import type { CodexRuntimeHomeService } from './runtime-home-service'
|
||||
import { writeFileAtomically } from './fs-utils'
|
||||
import { rewriteRelativePathConfigValues } from '../codex/codex-config-path-reference-rewrite'
|
||||
import { stripCodexManagedHookTrustEntriesFromConfig } from '../codex/codex-managed-trust-reconciliation'
|
||||
import { getCodexManagedHookInstallMaterial } from '../codex/hook-service'
|
||||
import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror'
|
||||
import { getSystemCodexHomePath } from '../codex/codex-home-paths'
|
||||
import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils'
|
||||
import { readCodexTopLevelModelProvider } from '../codex/codex-model-provider-config'
|
||||
import { resolveCodexCommand } from '../codex-cli/command'
|
||||
import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
|
||||
@@ -93,9 +89,10 @@ type ResolvedCodexIdentity = {
|
||||
|
||||
type CanonicalCodexConfig = {
|
||||
contents: string
|
||||
/** Home the config was read from, in the path style Codex sees (Linux-side for WSL); relative settings resolve against it. */
|
||||
/** Host-readable source home; the mirror resolves WSL UNC paths to their Linux spelling. */
|
||||
sourceHomePath: string
|
||||
sourceHooksPath: string
|
||||
/** Preserve Linux path semantics when WSL $HOME is under /mnt/<drive>. */
|
||||
sourceConfigDir?: string
|
||||
}
|
||||
|
||||
export type CodexAccountAddTarget = {
|
||||
@@ -1303,12 +1300,6 @@ export class CodexAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
private isSelfContainedHostManagedHome(managedHomePath: string): boolean {
|
||||
// Why: each host account home is its own launch CODEX_HOME. WSL homes keep
|
||||
// their distro-local seed lane.
|
||||
return !parseWslUncPath(managedHomePath)
|
||||
}
|
||||
|
||||
private syncCanonicalConfigIntoManagedHome(
|
||||
managedHomePath: string,
|
||||
canonicalConfig = this.readCanonicalConfigForManagedHome(managedHomePath),
|
||||
@@ -1319,36 +1310,13 @@ export class CodexAccountService {
|
||||
}
|
||||
|
||||
const trustedManagedHomePath = this.assertManagedHomePath(managedHomePath, expectedAccountId)
|
||||
if (this.isSelfContainedHostManagedHome(trustedManagedHomePath)) {
|
||||
// Why: this home is codex's live CODEX_HOME, so mirror config with the
|
||||
// trust-preserving merge — the plain overwrite below would wipe the
|
||||
// hook/project trust codex granted in this home, forcing a re-approval and
|
||||
// an app-server re-grant on every account switch.
|
||||
syncSystemConfigIntoManagedCodexHome({
|
||||
runtimeHomePath: trustedManagedHomePath,
|
||||
systemHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
return
|
||||
}
|
||||
// Why: Orca account switching is meant to swap Codex credentials and quota
|
||||
// identity, not silently fork the user's sandbox/config defaults. Syncing
|
||||
// one canonical config into every managed home keeps auth isolated per
|
||||
// account while preserving consistent Codex behavior. Managed homes are
|
||||
// real CODEX_HOMEs for `codex login`, so relative path-valued settings
|
||||
// must keep resolving against the home the config was read from.
|
||||
const material = getCodexManagedHookInstallMaterial()
|
||||
// Why: source-home Orca trust is foreign to each managed home's hooks.json.
|
||||
const sanitizedConfig = stripCodexManagedHookTrustEntriesFromConfig(canonicalConfig.contents, {
|
||||
runtimeHomePath: canonicalConfig.sourceHomePath,
|
||||
sourcePath: canonicalConfig.sourceHooksPath,
|
||||
command: material.command,
|
||||
managedEventLabels: new Set(Object.values(material.eventLabel)),
|
||||
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
|
||||
// Why: every account home is Codex's own CODEX_HOME. Preserve trust Codex
|
||||
// granted there while refreshing ordinary settings from the lane's source.
|
||||
syncSystemConfigIntoManagedCodexHome({
|
||||
runtimeHomePath: trustedManagedHomePath,
|
||||
systemHomePath: canonicalConfig.sourceHomePath,
|
||||
systemConfigDir: canonicalConfig.sourceConfigDir
|
||||
})
|
||||
this.writeManagedConfig(
|
||||
trustedManagedHomePath,
|
||||
rewriteRelativePathConfigValues(sanitizedConfig, canonicalConfig.sourceHomePath)
|
||||
)
|
||||
}
|
||||
|
||||
private readCanonicalConfig(): CanonicalCodexConfig | null {
|
||||
@@ -1361,8 +1329,7 @@ export class CodexAccountService {
|
||||
try {
|
||||
return {
|
||||
contents: readFileSync(primaryConfigPath, 'utf-8'),
|
||||
sourceHomePath,
|
||||
sourceHooksPath: join(sourceHomePath, 'hooks.json')
|
||||
sourceHomePath
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[codex-accounts] Failed to read canonical config:', error)
|
||||
@@ -1392,8 +1359,8 @@ export class CodexAccountService {
|
||||
// path rewrites must anchor to the Linux-side ~/.codex, not the UNC path.
|
||||
return {
|
||||
contents: readFileSync(configPath, 'utf-8'),
|
||||
sourceHomePath: `${wslHome}/.codex`,
|
||||
sourceHooksPath: `${wslHome}/.codex/hooks.json`
|
||||
sourceHomePath: toWindowsWslPath(`${wslHome}/.codex`, wslInfo.distro),
|
||||
sourceConfigDir: `${wslHome}/.codex`
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[codex-accounts] Failed to read WSL canonical config:', error)
|
||||
@@ -1416,18 +1383,6 @@ export class CodexAccountService {
|
||||
)
|
||||
}
|
||||
|
||||
private writeManagedConfig(managedHomePath: string, contents: string): void {
|
||||
const configPath = join(managedHomePath, 'config.toml')
|
||||
try {
|
||||
if (existsSync(configPath) && readFileSync(configPath, 'utf-8') === contents) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Why: a read error must not make a stale config look current; atomic write owns ACL repair and error surfacing.
|
||||
}
|
||||
writeFileAtomically(configPath, contents)
|
||||
}
|
||||
|
||||
private getManagedAccountsRoot(): string {
|
||||
const root = join(app.getPath('userData'), 'codex-accounts')
|
||||
mkdirSync(root, { recursive: true })
|
||||
|
||||
@@ -21,30 +21,40 @@ describe('CodexAppServerCapabilityCache', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back on the first unsupported probe and skips the probe on later calls', () => {
|
||||
it('falls back on the first unsupported probe and skips the probe on later calls', async () => {
|
||||
const cache = new CodexAppServerCapabilityCache()
|
||||
const firstPreferred = vi.fn(() => {
|
||||
throw unsupportedError
|
||||
})
|
||||
expect(
|
||||
cache.runWithFallbackSync('native', firstPreferred, () => 'first-fallback', isUnsupported, 5)
|
||||
).toBe('first-fallback')
|
||||
const firstPreferred = vi.fn(() => Promise.reject(unsupportedError))
|
||||
await expect(
|
||||
cache.runWithFallback(
|
||||
'native',
|
||||
firstPreferred,
|
||||
() => Promise.resolve('first-fallback'),
|
||||
isUnsupported
|
||||
)
|
||||
).resolves.toBe('first-fallback')
|
||||
expect(firstPreferred).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Why: probes are synchronous on the main thread, so they can never
|
||||
// overlap — back-to-back calls inside the retry window are the
|
||||
// "concurrent probe" equivalent and must share the first probe's result.
|
||||
const laterPreferred = vi.fn(() => 'unexpected-preferred')
|
||||
expect(
|
||||
cache.runWithFallbackSync('native', laterPreferred, () => 'cached-fallback', isUnsupported, 6)
|
||||
).toBe('cached-fallback')
|
||||
expect(
|
||||
cache.runWithFallbackSync('native', laterPreferred, () => 'cached-fallback', isUnsupported, 7)
|
||||
).toBe('cached-fallback')
|
||||
const laterPreferred = vi.fn(() => Promise.resolve('unexpected-preferred'))
|
||||
await expect(
|
||||
cache.runWithFallback(
|
||||
'native',
|
||||
laterPreferred,
|
||||
() => Promise.resolve('cached-fallback'),
|
||||
isUnsupported
|
||||
)
|
||||
).resolves.toBe('cached-fallback')
|
||||
await expect(
|
||||
cache.runWithFallback(
|
||||
'native',
|
||||
laterPreferred,
|
||||
() => Promise.resolve('cached-fallback'),
|
||||
isUnsupported
|
||||
)
|
||||
).resolves.toBe('cached-fallback')
|
||||
expect(laterPreferred).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('isolates capability state per execution host', () => {
|
||||
it('isolates capability state per execution host', async () => {
|
||||
const cache = new CodexAppServerCapabilityCache()
|
||||
cache.rememberUnsupported('wsl:Ubuntu', 1_000)
|
||||
|
||||
@@ -52,63 +62,148 @@ describe('CodexAppServerCapabilityCache', () => {
|
||||
expect(cache.shouldTry('native', 1_001)).toBe(true)
|
||||
expect(cache.shouldTry('wsl:Debian', 1_001)).toBe(true)
|
||||
|
||||
const nativePreferred = vi.fn(() => 'native-result')
|
||||
expect(
|
||||
cache.runWithFallbackSync('native', nativePreferred, () => 'unexpected', isUnsupported, 1_001)
|
||||
).toBe('native-result')
|
||||
const nativePreferred = vi.fn(() => Promise.resolve('native-result'))
|
||||
await expect(
|
||||
cache.runWithFallback(
|
||||
'native',
|
||||
nativePreferred,
|
||||
() => Promise.resolve('unexpected'),
|
||||
isUnsupported
|
||||
)
|
||||
).resolves.toBe('native-result')
|
||||
expect(nativePreferred).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('drops known support when a later call reports the capability unsupported', () => {
|
||||
it('drops known support when a later call reports the capability unsupported', async () => {
|
||||
const cache = new CodexAppServerCapabilityCache()
|
||||
expect(
|
||||
cache.runWithFallbackSync(
|
||||
await expect(
|
||||
cache.runWithFallback(
|
||||
'native',
|
||||
() => 'supported',
|
||||
() => 'unexpected',
|
||||
isUnsupported,
|
||||
1
|
||||
() => Promise.resolve('supported'),
|
||||
() => Promise.resolve('unexpected'),
|
||||
isUnsupported
|
||||
)
|
||||
).toBe('supported')
|
||||
).resolves.toBe('supported')
|
||||
expect(cache.isKnownSupported('native')).toBe(true)
|
||||
|
||||
expect(
|
||||
cache.runWithFallbackSync(
|
||||
await expect(
|
||||
cache.runWithFallback(
|
||||
'native',
|
||||
() => {
|
||||
throw unsupportedError
|
||||
},
|
||||
() => 'fallback',
|
||||
isUnsupported,
|
||||
2
|
||||
() => Promise.reject(unsupportedError),
|
||||
() => Promise.resolve('fallback'),
|
||||
isUnsupported
|
||||
)
|
||||
).toBe('fallback')
|
||||
).resolves.toBe('fallback')
|
||||
expect(cache.isKnownSupported('native')).toBe(false)
|
||||
|
||||
const laterPreferred = vi.fn(() => 'unexpected-preferred')
|
||||
expect(
|
||||
cache.runWithFallbackSync('native', laterPreferred, () => 'cached-fallback', isUnsupported, 3)
|
||||
).toBe('cached-fallback')
|
||||
const laterPreferred = vi.fn(() => Promise.resolve('unexpected-preferred'))
|
||||
await expect(
|
||||
cache.runWithFallback(
|
||||
'native',
|
||||
laterPreferred,
|
||||
() => Promise.resolve('cached-fallback'),
|
||||
isUnsupported
|
||||
)
|
||||
).resolves.toBe('cached-fallback')
|
||||
expect(laterPreferred).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rethrows transient errors without marking the host unsupported', () => {
|
||||
it('rethrows transient errors without marking the host unsupported', async () => {
|
||||
const cache = new CodexAppServerCapabilityCache()
|
||||
const transient = new Error('spawn ETIMEDOUT')
|
||||
expect(() =>
|
||||
cache.runWithFallbackSync(
|
||||
await expect(
|
||||
cache.runWithFallback(
|
||||
'native',
|
||||
() => {
|
||||
throw transient
|
||||
},
|
||||
() => 'unexpected-fallback',
|
||||
isUnsupported,
|
||||
1
|
||||
() => Promise.reject(transient),
|
||||
() => Promise.resolve('unexpected-fallback'),
|
||||
isUnsupported
|
||||
)
|
||||
).toThrow(transient)
|
||||
).rejects.toBe(transient)
|
||||
expect(cache.shouldTry('native', 2)).toBe(true)
|
||||
})
|
||||
|
||||
// Why (#16441): grants no longer block the main thread, so two pane launches
|
||||
// can reach a cold host at once. Without dedupe each one pays its own
|
||||
// app-server session against a codex that has no such RPC surface.
|
||||
it('dedupes concurrent probes on one host to a single app-server session', async () => {
|
||||
const cache = new CodexAppServerCapabilityCache()
|
||||
let releaseProbe!: (error: unknown) => void
|
||||
const preferred = vi.fn(
|
||||
() =>
|
||||
new Promise<string>((_resolve, reject) => {
|
||||
releaseProbe = reject
|
||||
})
|
||||
)
|
||||
const first = cache.runWithFallback(
|
||||
'native',
|
||||
preferred,
|
||||
() => Promise.resolve('fallback'),
|
||||
isUnsupported
|
||||
)
|
||||
const second = cache.runWithFallback(
|
||||
'native',
|
||||
preferred,
|
||||
() => Promise.resolve('fallback'),
|
||||
isUnsupported
|
||||
)
|
||||
await Promise.resolve()
|
||||
releaseProbe(unsupportedError)
|
||||
|
||||
await expect(first).resolves.toBe('fallback')
|
||||
await expect(second).resolves.toBe('fallback')
|
||||
expect(preferred).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('lets a waiter run its own work once the in-flight probe reports support', async () => {
|
||||
const cache = new CodexAppServerCapabilityCache()
|
||||
let releaseProbe!: (value: string) => void
|
||||
const firstPreferred = vi.fn(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
releaseProbe = resolve
|
||||
})
|
||||
)
|
||||
const secondPreferred = vi.fn(() => Promise.resolve('second'))
|
||||
const first = cache.runWithFallback(
|
||||
'native',
|
||||
firstPreferred,
|
||||
() => Promise.resolve('fallback'),
|
||||
isUnsupported
|
||||
)
|
||||
const second = cache.runWithFallback(
|
||||
'native',
|
||||
secondPreferred,
|
||||
() => Promise.resolve('fallback'),
|
||||
isUnsupported
|
||||
)
|
||||
await Promise.resolve()
|
||||
releaseProbe('first')
|
||||
|
||||
await expect(first).resolves.toBe('first')
|
||||
await expect(second).resolves.toBe('second')
|
||||
expect(secondPreferred).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('isolates in-flight probes per host so a cold WSL distro never waits on native', async () => {
|
||||
const cache = new CodexAppServerCapabilityCache()
|
||||
const nativePreferred = vi.fn(() => new Promise<string>(() => {}))
|
||||
void cache.runWithFallback(
|
||||
'native',
|
||||
nativePreferred,
|
||||
() => Promise.resolve('fallback'),
|
||||
isUnsupported
|
||||
)
|
||||
const wslPreferred = vi.fn(() => Promise.resolve('wsl-result'))
|
||||
await expect(
|
||||
cache.runWithFallback(
|
||||
'wsl:Ubuntu',
|
||||
wslPreferred,
|
||||
() => Promise.resolve('fallback'),
|
||||
isUnsupported
|
||||
)
|
||||
).resolves.toBe('wsl-result')
|
||||
})
|
||||
|
||||
it('builds host keys that keep WSL distros apart', () => {
|
||||
expect(getCodexAppServerHostKey({ kind: 'native' })).toBe('native')
|
||||
expect(getCodexAppServerHostKey({ kind: 'wsl', distro: 'Ubuntu' })).toBe('wsl:Ubuntu')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CapabilityProbeCache } from '../../shared/capability-probe-cache'
|
||||
|
||||
// Why: suppress a known-missing RPC surface without pinning it forever — an
|
||||
// in-place codex upgrade during a long Orca session self-heals after the
|
||||
// interval, mirroring GitCapabilityCache's rationale.
|
||||
@@ -14,70 +16,14 @@ export function getCodexAppServerHostKey(
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability cache for the codex app-server trust-grant RPC pair, modeled on
|
||||
* GitCapabilityCache but with a synchronous runner: the grant client blocks
|
||||
* the main thread by design (launch prep), so probes cannot overlap — the
|
||||
* unsupported mark alone is what keeps later installs off the dead probe.
|
||||
* Capability cache for the codex app-server trust-grant RPC pair. The grant
|
||||
* client runs off the main thread's critical path, so two pane launches can
|
||||
* probe the same host at once; the shared probe dedupe is what keeps a cold
|
||||
* host to one app-server session instead of one per concurrent launch.
|
||||
*/
|
||||
export class CodexAppServerCapabilityCache {
|
||||
private readonly retryAfterByHost = new Map<CodexAppServerHostKey, number>()
|
||||
private readonly supportedHosts = new Set<CodexAppServerHostKey>()
|
||||
|
||||
shouldTry(hostKey: CodexAppServerHostKey, nowMs = Date.now()): boolean {
|
||||
const retryAfterMs = this.retryAfterByHost.get(hostKey)
|
||||
if (retryAfterMs === undefined) {
|
||||
return true
|
||||
}
|
||||
if (nowMs < retryAfterMs) {
|
||||
return false
|
||||
}
|
||||
this.retryAfterByHost.delete(hostKey)
|
||||
return true
|
||||
}
|
||||
|
||||
isKnownSupported(hostKey: CodexAppServerHostKey): boolean {
|
||||
return this.supportedHosts.has(hostKey)
|
||||
}
|
||||
|
||||
rememberUnsupported(hostKey: CodexAppServerHostKey, nowMs = Date.now()): void {
|
||||
this.supportedHosts.delete(hostKey)
|
||||
this.retryAfterByHost.set(hostKey, nowMs + CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS)
|
||||
}
|
||||
|
||||
rememberSupported(hostKey: CodexAppServerHostKey): void {
|
||||
this.retryAfterByHost.delete(hostKey)
|
||||
this.supportedHosts.add(hostKey)
|
||||
}
|
||||
|
||||
runWithFallbackSync<T>(
|
||||
hostKey: CodexAppServerHostKey,
|
||||
runPreferred: () => T,
|
||||
runFallback: () => T,
|
||||
isUnsupportedError: (error: unknown) => boolean,
|
||||
nowMs = Date.now()
|
||||
): T {
|
||||
if (!this.supportedHosts.has(hostKey) && !this.shouldTry(hostKey, nowMs)) {
|
||||
return runFallback()
|
||||
}
|
||||
try {
|
||||
const result = runPreferred()
|
||||
this.rememberSupported(hostKey)
|
||||
return result
|
||||
} catch (error) {
|
||||
// Why: only a positive absence signal (unknown method / missing
|
||||
// subcommand) marks unsupported. Transient spawn failures, timeouts,
|
||||
// and RPC errors fall back once without poisoning the capability.
|
||||
if (!isUnsupportedError(error)) {
|
||||
throw error
|
||||
}
|
||||
this.rememberUnsupported(hostKey, nowMs)
|
||||
return runFallback()
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.retryAfterByHost.clear()
|
||||
this.supportedHosts.clear()
|
||||
export class CodexAppServerCapabilityCache extends CapabilityProbeCache<CodexAppServerHostKey> {
|
||||
constructor() {
|
||||
super(CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,6 @@ import {
|
||||
type CodexHookTrustGrantRequest
|
||||
} from './codex-app-server-client'
|
||||
import { killCodexAppServerProcessTree, runCodexAppServerSession } from './codex-app-server-session'
|
||||
import {
|
||||
resolveCodexGrantEntryPath,
|
||||
runCodexHookTrustGrantSessionSync
|
||||
} from './codex-app-server-grant-bridge'
|
||||
|
||||
// Stub codex app-server speaking the same JSONL protocol: initialize →
|
||||
// initialized → hooks/list → config/batchWrite → hooks/list. Scenario-driven
|
||||
@@ -475,106 +471,3 @@ describe('runCodexHookTrustGrantSession', () => {
|
||||
expect(isCodexAppServerUnsupportedError(error)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runCodexHookTrustGrantSessionSync', () => {
|
||||
function writeEntryFixture(source: string): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-codex-entry-'))
|
||||
tempRoots.push(root)
|
||||
const entryPath = join(root, 'grant-entry.cjs')
|
||||
writeFileSync(entryPath, source)
|
||||
return entryPath
|
||||
}
|
||||
|
||||
const baseRequest: CodexHookTrustGrantRequest = {
|
||||
invocation: { command: 'codex', cliPath: null, args: ['app-server'], timeoutMs: 1_000 },
|
||||
hooksListCwd: '/tmp',
|
||||
expectedTrustKeys: ['k'],
|
||||
managedCommand: MANAGED_COMMAND
|
||||
}
|
||||
|
||||
it('returns the entry envelope result and passes the request over stdin', () => {
|
||||
const entryPath = writeEntryFixture(`
|
||||
let input = ''
|
||||
process.stdin.setEncoding('utf8')
|
||||
process.stdin.on('data', (chunk) => { input += chunk })
|
||||
process.stdin.on('end', () => {
|
||||
const request = JSON.parse(input)
|
||||
process.stdout.write(JSON.stringify({
|
||||
ok: true,
|
||||
result: {
|
||||
outcome: 'granted',
|
||||
wroteTrust: true,
|
||||
entries: [{ key: request.expectedTrustKeys[0], normalizedKey: request.expectedTrustKeys[0], trustedHash: 'sha256:x' }]
|
||||
}
|
||||
}) + '\\n')
|
||||
})
|
||||
`)
|
||||
const result = runCodexHookTrustGrantSessionSync(baseRequest, { entryPath })
|
||||
expect(result).toMatchObject({ outcome: 'granted', wroteTrust: true })
|
||||
})
|
||||
|
||||
it('rethrows unsupported envelopes as the unsupported error class', () => {
|
||||
const entryPath = writeEntryFixture(`
|
||||
process.stdin.resume()
|
||||
process.stdin.on('end', () => {
|
||||
process.stdout.write(JSON.stringify({ ok: false, errorName: 'CodexAppServerUnsupportedError', message: 'no app-server', unsupported: true }) + '\\n')
|
||||
})
|
||||
`)
|
||||
expect(() => runCodexHookTrustGrantSessionSync(baseRequest, { entryPath })).toThrow(
|
||||
CodexAppServerUnsupportedError
|
||||
)
|
||||
})
|
||||
|
||||
it('fails with a clear error when the entry produces no result', () => {
|
||||
const entryPath = writeEntryFixture(
|
||||
`process.stdin.resume(); process.stdin.on('end', () => process.exit(7))`
|
||||
)
|
||||
expect(() => runCodexHookTrustGrantSessionSync(baseRequest, { entryPath })).toThrow(
|
||||
/produced no result \(exit 7\)/
|
||||
)
|
||||
})
|
||||
|
||||
it('classifies the spawnSync deadline as a typed timeout', () => {
|
||||
const entryPath = writeEntryFixture(`setInterval(() => {}, 1000)`)
|
||||
const request = {
|
||||
...baseRequest,
|
||||
invocation: { ...baseRequest.invocation, timeoutMs: 20 }
|
||||
}
|
||||
expect(() =>
|
||||
runCodexHookTrustGrantSessionSync(request, { entryPath, timeoutMarginMs: 20 })
|
||||
).toThrow(CodexAppServerTimeoutError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveCodexGrantEntryPath', () => {
|
||||
const entryName = 'codex-app-server-grant-entry.js'
|
||||
|
||||
it('finds the sibling entry from emitted main and chunk directories', () => {
|
||||
const mainDir = join('/opt', 'orca', 'out', 'main')
|
||||
expect(
|
||||
resolveCodexGrantEntryPath(
|
||||
(candidate) => candidate === join(mainDir, 'codex', entryName),
|
||||
mainDir
|
||||
)
|
||||
).toBe(join(mainDir, 'codex', entryName))
|
||||
|
||||
const chunkDir = join(mainDir, 'chunks')
|
||||
expect(
|
||||
resolveCodexGrantEntryPath(
|
||||
(candidate) => candidate === join(mainDir, 'codex', entryName),
|
||||
chunkDir
|
||||
)
|
||||
).toBe(join(mainDir, 'codex', entryName))
|
||||
})
|
||||
|
||||
it('redirects app.asar to unpacked without double-unpacking an existing path', () => {
|
||||
const resourcesDir = join('/Applications', 'Orca.app', 'Contents', 'Resources')
|
||||
const expected = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'codex', entryName)
|
||||
for (const archiveDir of ['app.asar', 'app.asar.unpacked']) {
|
||||
const moduleDir = join(resourcesDir, archiveDir, 'out', 'main', 'chunks')
|
||||
expect(resolveCodexGrantEntryPath((candidate) => candidate === expected, moduleDir)).toBe(
|
||||
expected
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,8 +41,8 @@ export type CodexGrantedHookTrust = {
|
||||
trustedHash: string
|
||||
}
|
||||
|
||||
/** Closed verify-failure taxonomy — crosses the grant-bridge JSON envelope, so
|
||||
* telemetry never has to parse the free-form `reason` diagnostics string. */
|
||||
/** Closed verify-failure taxonomy, so telemetry never has to parse the
|
||||
* free-form `reason` diagnostics string. */
|
||||
export type CodexTrustGrantSessionVerifyClass =
|
||||
| 'list-mismatch'
|
||||
| 'post-grant-untrusted'
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
CodexAppServerTimeoutError,
|
||||
CodexAppServerUnsupportedError,
|
||||
type CodexHookTrustGrantRequest,
|
||||
type CodexHookTrustGrantSessionResult
|
||||
} from './codex-app-server-client'
|
||||
import type {
|
||||
CodexAppServerEntryRequest,
|
||||
CodexAppServerEntryResult,
|
||||
GrantEntryEnvelope
|
||||
} from './codex-app-server-grant-envelope'
|
||||
import type {
|
||||
CodexUserHookTrustRebaseRequest,
|
||||
CodexUserHookTrustRebaseResult
|
||||
} from './codex-user-hook-trust-rebase-client'
|
||||
|
||||
// Why: hook install/refresh is synchronous launch prep — a Codex pane must
|
||||
// not start before its trust is settled — but a stdio JSON-RPC session needs
|
||||
// a live event loop. This bridge blocks the caller on spawnSync of a bundled
|
||||
// ELECTRON_RUN_AS_NODE entry (same pattern as the daemon and parcel-watcher
|
||||
// entries) that runs the session and reports one JSON envelope on stdout.
|
||||
|
||||
const GRANT_ENTRY_FILE_NAME = 'codex-app-server-grant-entry.js'
|
||||
// Why: spawnSync must outlive the session deadline so the entry's own timeout
|
||||
// (and its result envelope) win the race; the margin only reaps a hung entry.
|
||||
const GRANT_ENTRY_TIMEOUT_MARGIN_MS = 5_000
|
||||
const GRANT_ENTRY_MAX_BUFFER_BYTES = 16 * 1024 * 1024
|
||||
|
||||
export function resolveCodexGrantEntryPath(
|
||||
pathExists: (candidate: string) => boolean = existsSync,
|
||||
moduleDir = __dirname
|
||||
): string | null {
|
||||
// Why: resolved from __dirname (not electron's app paths) so this module
|
||||
// stays loadable in plain-node CLI entries — the build guard rejects any
|
||||
// electron require reachable from them. The emitted bridge chunk sits in
|
||||
// out/main or out/main/chunks, so the entry is one or two levels up.
|
||||
// ELECTRON_RUN_AS_NODE bypasses asar integration, so packaged builds must
|
||||
// run the copy under app.asar.unpacked (out/main/codex/** is asarUnpacked).
|
||||
const toUnpackedDir = (dir: string): string =>
|
||||
dir.replace(/([\\/])app\.asar(?=([\\/]|$))/, '$1app.asar.unpacked')
|
||||
const baseDirs = [moduleDir, join(moduleDir, '..')].map(toUnpackedDir)
|
||||
for (const baseDir of baseDirs) {
|
||||
const candidate = join(baseDir, 'codex', GRANT_ENTRY_FILE_NAME)
|
||||
if (pathExists(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export type RunGrantSessionSyncOptions = {
|
||||
entryPath?: string
|
||||
nodeCommand?: string
|
||||
/** Test-only override; production keeps enough margin for child cleanup. */
|
||||
timeoutMarginMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking wrapper for the grant session. Hook install/refresh is synchronous
|
||||
* launch prep (pane launch must not proceed until trust is settled), and a
|
||||
* stdio JSON-RPC session needs a live event loop — so the session runs in a
|
||||
* short-lived ELECTRON_RUN_AS_NODE child (same pattern as the daemon and
|
||||
* parcel-watcher entries) while the caller blocks on spawnSync. spawnSync
|
||||
* always reaps the entry; a killed entry closes the codex child's stdin,
|
||||
* which makes codex app-server exit on EOF.
|
||||
*/
|
||||
export function runCodexHookTrustGrantSessionSync(
|
||||
request: CodexHookTrustGrantRequest,
|
||||
options: RunGrantSessionSyncOptions = {}
|
||||
): CodexHookTrustGrantSessionResult {
|
||||
return runCodexAppServerEntrySync(request, options) as CodexHookTrustGrantSessionResult
|
||||
}
|
||||
|
||||
export function runCodexUserHookTrustRebaseSessionSync(
|
||||
request: CodexUserHookTrustRebaseRequest,
|
||||
options: RunGrantSessionSyncOptions = {}
|
||||
): CodexUserHookTrustRebaseResult {
|
||||
return runCodexAppServerEntrySync(request, options) as CodexUserHookTrustRebaseResult
|
||||
}
|
||||
|
||||
function runCodexAppServerEntrySync(
|
||||
request: CodexAppServerEntryRequest,
|
||||
options: RunGrantSessionSyncOptions
|
||||
): CodexAppServerEntryResult {
|
||||
const entryPath = options.entryPath ?? resolveCodexGrantEntryPath()
|
||||
if (!entryPath) {
|
||||
throw new Error('codex trust-grant entry bundle not found')
|
||||
}
|
||||
const spawned = spawnSync(options.nodeCommand ?? process.execPath, [entryPath], {
|
||||
input: JSON.stringify(request),
|
||||
encoding: 'utf8',
|
||||
timeout:
|
||||
request.invocation.timeoutMs + (options.timeoutMarginMs ?? GRANT_ENTRY_TIMEOUT_MARGIN_MS),
|
||||
killSignal: 'SIGKILL',
|
||||
maxBuffer: GRANT_ENTRY_MAX_BUFFER_BYTES,
|
||||
windowsHide: true,
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
||||
})
|
||||
if ((spawned.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT') {
|
||||
// Why: spawnSync reports its own deadline through error.code before the
|
||||
// signal field; preserve the typed timeout so cooldown diagnostics work.
|
||||
throw new CodexAppServerTimeoutError(
|
||||
`codex trust-grant entry exceeded ${request.invocation.timeoutMs}ms session deadline`
|
||||
)
|
||||
}
|
||||
if (spawned.error) {
|
||||
throw spawned.error
|
||||
}
|
||||
if (spawned.signal) {
|
||||
throw new CodexAppServerTimeoutError(
|
||||
`codex trust-grant entry killed by ${spawned.signal} after ${request.invocation.timeoutMs}ms deadline`
|
||||
)
|
||||
}
|
||||
const lines = (spawned.stdout ?? '').split('\n').filter((line) => line.trim().length > 0)
|
||||
const lastLine = lines.at(-1)
|
||||
let envelope: GrantEntryEnvelope | null = null
|
||||
if (lastLine) {
|
||||
try {
|
||||
envelope = JSON.parse(lastLine) as GrantEntryEnvelope
|
||||
} catch {
|
||||
envelope = null
|
||||
}
|
||||
}
|
||||
if (!envelope) {
|
||||
throw new Error(
|
||||
`codex trust-grant entry produced no result (exit ${spawned.status ?? 'unknown'})${
|
||||
spawned.stderr ? `: ${spawned.stderr.trim().slice(0, 400)}` : ''
|
||||
}`
|
||||
)
|
||||
}
|
||||
if (!envelope.ok) {
|
||||
if (envelope.unsupported) {
|
||||
throw new CodexAppServerUnsupportedError(envelope.message)
|
||||
}
|
||||
if (envelope.errorName === 'CodexAppServerTimeoutError') {
|
||||
throw new CodexAppServerTimeoutError(envelope.message)
|
||||
}
|
||||
throw new Error(envelope.message)
|
||||
}
|
||||
return envelope.result
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
// Forked (ELECTRON_RUN_AS_NODE) child that runs one codex app-server
|
||||
// trust-grant session. The parent blocks on spawnSync because hook
|
||||
// install/refresh must finish before a Codex pane launch proceeds, while the
|
||||
// JSONL RPC session itself needs a live event loop. Reads the request JSON
|
||||
// from stdin, writes a single result-envelope JSON line to stdout, and never
|
||||
// imports electron (see PLAIN_NODE_ENTRY_NAMES in the build guard).
|
||||
import {
|
||||
buildGrantEntryEnvelope,
|
||||
type CodexAppServerEntryRequest
|
||||
} from './codex-app-server-grant-envelope'
|
||||
import { writeSync } from 'node:fs'
|
||||
import { runCodexHookTrustGrantSession } from './codex-app-server-client'
|
||||
import { runCodexUserHookTrustRebaseSession } from './codex-user-hook-trust-rebase-client'
|
||||
|
||||
const HARD_EXIT_MARGIN_MS = 2_000
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk as Buffer)
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8')
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const raw = await readStdin()
|
||||
let request: CodexAppServerEntryRequest
|
||||
try {
|
||||
request = JSON.parse(raw) as CodexAppServerEntryRequest
|
||||
} catch (error) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
ok: false,
|
||||
errorName: 'Error',
|
||||
message: `invalid trust-grant request JSON: ${error instanceof Error ? error.message : String(error)}`
|
||||
})}\n`
|
||||
)
|
||||
return
|
||||
}
|
||||
// Why: backstop for a session whose own deadline failed to fire (clock
|
||||
// suspend mid-session); exiting closes the codex child's stdio so it
|
||||
// exits on EOF instead of orphaning.
|
||||
const hardExit = setTimeout(() => {
|
||||
// Why: process.exit() does not flush asynchronous stdout pipes; write the
|
||||
// timeout envelope synchronously so the parent can classify the fallback.
|
||||
writeSync(
|
||||
process.stdout.fd,
|
||||
`${JSON.stringify({
|
||||
ok: false,
|
||||
errorName: 'CodexAppServerTimeoutError',
|
||||
message: `trust-grant entry hard deadline (${request.invocation.timeoutMs + HARD_EXIT_MARGIN_MS}ms) elapsed`
|
||||
})}\n`
|
||||
)
|
||||
process.exit(3)
|
||||
}, request.invocation.timeoutMs + HARD_EXIT_MARGIN_MS)
|
||||
const run =
|
||||
'operation' in request
|
||||
? runCodexUserHookTrustRebaseSession(request)
|
||||
: runCodexHookTrustGrantSession(request)
|
||||
const envelope = await buildGrantEntryEnvelope(run)
|
||||
clearTimeout(hardExit)
|
||||
process.stdout.write(`${JSON.stringify(envelope)}\n`)
|
||||
}
|
||||
|
||||
void main().then(
|
||||
() => {
|
||||
process.exitCode = 0
|
||||
},
|
||||
(error: unknown) => {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
ok: false,
|
||||
errorName: error instanceof Error ? error.name : 'Error',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
})}\n`
|
||||
)
|
||||
process.exitCode = 0
|
||||
}
|
||||
)
|
||||
@@ -1,35 +0,0 @@
|
||||
import {
|
||||
isCodexAppServerUnsupportedError,
|
||||
type CodexHookTrustGrantRequest,
|
||||
type CodexHookTrustGrantSessionResult
|
||||
} from './codex-app-server-client'
|
||||
import type {
|
||||
CodexUserHookTrustRebaseRequest,
|
||||
CodexUserHookTrustRebaseResult
|
||||
} from './codex-user-hook-trust-rebase-client'
|
||||
|
||||
export type CodexAppServerEntryRequest =
|
||||
| CodexHookTrustGrantRequest
|
||||
| CodexUserHookTrustRebaseRequest
|
||||
|
||||
export type CodexAppServerEntryResult =
|
||||
| CodexHookTrustGrantSessionResult
|
||||
| CodexUserHookTrustRebaseResult
|
||||
|
||||
export type GrantEntryEnvelope =
|
||||
| { ok: true; result: CodexAppServerEntryResult }
|
||||
| { ok: false; errorName: string; message: string; unsupported?: boolean }
|
||||
|
||||
export function buildGrantEntryEnvelope(
|
||||
run: Promise<CodexAppServerEntryResult>
|
||||
): Promise<GrantEntryEnvelope> {
|
||||
return run.then(
|
||||
(result) => ({ ok: true as const, result }),
|
||||
(error: unknown) => ({
|
||||
ok: false as const,
|
||||
errorName: error instanceof Error ? error.name : 'Error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
...(isCodexAppServerUnsupportedError(error) ? { unsupported: true as const } : {})
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -768,6 +768,15 @@ describe('syncSystemConfigIntoLegacySharedCodexHome', () => {
|
||||
})
|
||||
|
||||
describe('prepareSystemConfigForFreshRuntimeMirror', () => {
|
||||
it('allows WSL callers to retain Linux semantics for mounted-drive homes', () => {
|
||||
expect(
|
||||
resolveCodexConfigMirrorSourceDirectory(
|
||||
'C:\\Users\\alice\\.codex',
|
||||
'/mnt/c/Users/alice/.codex'
|
||||
)
|
||||
).toBe('/mnt/c/Users/alice/.codex')
|
||||
})
|
||||
|
||||
it('uses the Linux-side directory for WSL UNC source homes', () => {
|
||||
const sourceDir = resolveCodexConfigMirrorSourceDirectory(
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex'
|
||||
|
||||
@@ -155,7 +155,7 @@ type CodexConfigMirrorResult =
|
||||
| { status: 'mirrored'; preservedConflictKeys: ReadonlySet<string> }
|
||||
|
||||
function syncSystemConfigIntoManagedCodexHomeUnsafe(
|
||||
{ runtimeHomePath, systemHomePath }: CodexSettingsPromotionHomes,
|
||||
{ runtimeHomePath, systemHomePath, systemConfigDir }: CodexSettingsPromotionHomes,
|
||||
promotionPlan: CodexSettingsPromotionPlan
|
||||
): CodexConfigMirrorResult {
|
||||
const systemConfigPath = join(systemHomePath, 'config.toml')
|
||||
@@ -184,7 +184,7 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(
|
||||
: { status: 'mirrored', preservedConflictKeys: new Set() }
|
||||
}
|
||||
|
||||
const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(systemHomePath)
|
||||
const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(systemHomePath, systemConfigDir)
|
||||
if (!runtimeConfigExists) {
|
||||
writeFileAtomically(
|
||||
runtimeConfigPath,
|
||||
@@ -207,8 +207,15 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(
|
||||
return { status: 'mirrored', preservedConflictKeys: preserved.keys }
|
||||
}
|
||||
|
||||
export function resolveCodexConfigMirrorSourceDirectory(systemHomePath: string): string {
|
||||
return parseWslUncPath(systemHomePath)?.linuxPath ?? dirname(join(systemHomePath, 'config.toml'))
|
||||
export function resolveCodexConfigMirrorSourceDirectory(
|
||||
systemHomePath: string,
|
||||
systemConfigDir?: string
|
||||
): string {
|
||||
return (
|
||||
systemConfigDir ??
|
||||
parseWslUncPath(systemHomePath)?.linuxPath ??
|
||||
dirname(join(systemHomePath, 'config.toml'))
|
||||
)
|
||||
}
|
||||
|
||||
function prepareSystemConfigForRuntimeMirror(config: string, systemConfigDir: string): string {
|
||||
|
||||
@@ -45,7 +45,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
_internals.setGrantSessionRunnerSync(null)
|
||||
_internals.setGrantSessionRunner(null)
|
||||
setCodexTrustGrantTelemetry(() => {})
|
||||
codexAppServerCapabilityCache.clear()
|
||||
if (previousUserDataPath === undefined) {
|
||||
@@ -97,28 +97,30 @@ function grantedSessionResult(entries: CodexTrustEntry[], hashPrefix = 'sha256:c
|
||||
}
|
||||
|
||||
describe('grantManagedCodexHookTrust', () => {
|
||||
it('does not let a short trust RPC claim an incomplete session index', () => {
|
||||
it('does not let a short trust RPC claim an incomplete session index', async () => {
|
||||
const sessions = join(runtimeHomeDir, 'sessions')
|
||||
mkdirSync(sessions, { recursive: true })
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
writeFileSync(join(sessions, `${index}.jsonl`), '{}\n')
|
||||
}
|
||||
const runner = vi.fn()
|
||||
_internals.setGrantSessionRunnerSync(runner)
|
||||
_internals.setGrantSessionRunner(runner)
|
||||
|
||||
expect(grantManagedCodexHookTrust(buildPlan([managedEntry('stop')]))).toMatchObject({
|
||||
expect(await grantManagedCodexHookTrust(buildPlan([managedEntry('stop')]))).toMatchObject({
|
||||
lane: 'fallback',
|
||||
reason: 'retry-cached'
|
||||
})
|
||||
expect(runner).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns granted entries with codex-verbatim hashes and records the ledger', () => {
|
||||
it('returns granted entries with codex-verbatim hashes and records the ledger', async () => {
|
||||
const entries = [managedEntry('session_start'), managedEntry('stop')]
|
||||
const runner = vi.fn((_request: CodexHookTrustGrantRequest) => grantedSessionResult(entries))
|
||||
_internals.setGrantSessionRunnerSync(runner)
|
||||
const runner = vi.fn(async (_request: CodexHookTrustGrantRequest) =>
|
||||
grantedSessionResult(entries)
|
||||
)
|
||||
_internals.setGrantSessionRunner(runner)
|
||||
|
||||
const outcome = grantManagedCodexHookTrust(buildPlan(entries))
|
||||
const outcome = await grantManagedCodexHookTrust(buildPlan(entries))
|
||||
expect(outcome.lane).toBe('rpc')
|
||||
if (outcome.lane !== 'rpc') {
|
||||
return
|
||||
@@ -139,20 +141,22 @@ describe('grantManagedCodexHookTrust', () => {
|
||||
expect(getCodexTrustGrantDiagnostics()).toMatchObject({ granted: 1, fellBack: 0 })
|
||||
})
|
||||
|
||||
it('builds a default-home grant invocation without an inherited CODEX_HOME', () => {
|
||||
it('builds a default-home grant invocation without an inherited CODEX_HOME', async () => {
|
||||
const entries = [managedEntry('stop')]
|
||||
const runner = vi.fn((_request: CodexHookTrustGrantRequest) => grantedSessionResult(entries))
|
||||
_internals.setGrantSessionRunnerSync(runner)
|
||||
const runner = vi.fn(async (_request: CodexHookTrustGrantRequest) =>
|
||||
grantedSessionResult(entries)
|
||||
)
|
||||
_internals.setGrantSessionRunner(runner)
|
||||
|
||||
expect(
|
||||
grantManagedCodexHookTrust({ ...buildPlan(entries), useDefaultCodexHome: true })
|
||||
await grantManagedCodexHookTrust({ ...buildPlan(entries), useDefaultCodexHome: true })
|
||||
).toMatchObject({ lane: 'rpc' })
|
||||
const invocation = runner.mock.calls[0]![0]!.invocation
|
||||
expect(invocation.env?.CODEX_HOME).toBeUndefined()
|
||||
expect(invocation.envToDelete).toContain('CODEX_HOME')
|
||||
})
|
||||
|
||||
it('removes equivalent Windows fallback keys before the RPC writes canonical trust', () => {
|
||||
it('removes equivalent Windows fallback keys before the RPC writes canonical trust', async () => {
|
||||
const entry: CodexTrustEntry = {
|
||||
...managedEntry('stop'),
|
||||
sourcePath: String.raw`C:\Users\Alice\.codex\hooks.json`
|
||||
@@ -162,23 +166,23 @@ describe('grantManagedCodexHookTrust', () => {
|
||||
expect(readHookTrustEntries(plan.tomlPath).get(computeTrustKey(entry))?.trustedHash).toBe(
|
||||
computeTrustedHash(entry)
|
||||
)
|
||||
const runner = vi.fn(() => {
|
||||
const runner = vi.fn(async () => {
|
||||
expect(readHookTrustEntries(plan.tomlPath).has(computeTrustKey(entry))).toBe(false)
|
||||
return grantedSessionResult([entry])
|
||||
})
|
||||
_internals.setGrantSessionRunnerSync(runner)
|
||||
_internals.setGrantSessionRunner(runner)
|
||||
|
||||
expect(grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'rpc' })
|
||||
expect(await grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'rpc' })
|
||||
expect(runner).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips the RPC session while the ledger grant still holds, and re-grants on config drift', () => {
|
||||
it('skips the RPC session while the ledger grant still holds, and re-grants on config drift', async () => {
|
||||
const entries = [managedEntry('session_start')]
|
||||
const runner = vi.fn(() => grantedSessionResult(entries))
|
||||
_internals.setGrantSessionRunnerSync(runner)
|
||||
const runner = vi.fn(async () => grantedSessionResult(entries))
|
||||
_internals.setGrantSessionRunner(runner)
|
||||
const plan = buildPlan(entries)
|
||||
|
||||
const first = grantManagedCodexHookTrust(plan)
|
||||
const first = await grantManagedCodexHookTrust(plan)
|
||||
expect(first.lane).toBe('rpc')
|
||||
expect(runner).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -187,92 +191,95 @@ describe('grantManagedCodexHookTrust', () => {
|
||||
upsertHookTrustEntries(plan.tomlPath, [
|
||||
{ ...entries[0], trustedHash: 'sha256:codex-session_start' }
|
||||
])
|
||||
const second = grantManagedCodexHookTrust(plan)
|
||||
const second = await grantManagedCodexHookTrust(plan)
|
||||
expect(second.lane).toBe('rpc')
|
||||
expect(runner).toHaveBeenCalledTimes(1)
|
||||
expect(getCodexTrustGrantDiagnostics()).toMatchObject({ granted: 1, ledgerHits: 1 })
|
||||
|
||||
// Config drift (user wiped the trust entry) must re-run the session.
|
||||
upsertHookTrustEntries(plan.tomlPath, [{ ...entries[0], trustedHash: 'sha256:wiped' }])
|
||||
const third = grantManagedCodexHookTrust(plan)
|
||||
const third = await grantManagedCodexHookTrust(plan)
|
||||
expect(third.lane).toBe('rpc')
|
||||
expect(runner).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('re-grants when the managed hook identity changes', () => {
|
||||
it('re-grants when the managed hook identity changes', async () => {
|
||||
const entries = [managedEntry('session_start')]
|
||||
const runner = vi.fn(() => grantedSessionResult(entries))
|
||||
_internals.setGrantSessionRunnerSync(runner)
|
||||
const runner = vi.fn(async () => grantedSessionResult(entries))
|
||||
_internals.setGrantSessionRunner(runner)
|
||||
const plan = buildPlan(entries)
|
||||
grantManagedCodexHookTrust(plan)
|
||||
await grantManagedCodexHookTrust(plan)
|
||||
upsertHookTrustEntries(plan.tomlPath, [
|
||||
{ ...entries[0], trustedHash: 'sha256:codex-session_start' }
|
||||
])
|
||||
|
||||
const changedEntries = [{ ...entries[0], timeoutSec: 99 }]
|
||||
const changedRunner = vi.fn(() => grantedSessionResult(changedEntries))
|
||||
_internals.setGrantSessionRunnerSync(changedRunner)
|
||||
const outcome = grantManagedCodexHookTrust(buildPlan(changedEntries))
|
||||
const changedRunner = vi.fn(async () => grantedSessionResult(changedEntries))
|
||||
_internals.setGrantSessionRunner(changedRunner)
|
||||
const outcome = await grantManagedCodexHookTrust(buildPlan(changedEntries))
|
||||
expect(outcome.lane).toBe('rpc')
|
||||
expect(changedRunner).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('marks the host unsupported only for the unsupported error class', () => {
|
||||
it('marks the host unsupported only for the unsupported error class', async () => {
|
||||
const entries = [managedEntry('session_start')]
|
||||
const runner = vi.fn((): CodexHookTrustGrantSessionResult => {
|
||||
const runner = vi.fn((): Promise<CodexHookTrustGrantSessionResult> => {
|
||||
throw new CodexAppServerUnsupportedError('no such method')
|
||||
})
|
||||
_internals.setGrantSessionRunnerSync(runner)
|
||||
_internals.setGrantSessionRunner(runner)
|
||||
const plan = buildPlan(entries)
|
||||
|
||||
expect(grantManagedCodexHookTrust(plan)).toMatchObject({
|
||||
expect(await grantManagedCodexHookTrust(plan)).toMatchObject({
|
||||
lane: 'fallback',
|
||||
reason: 'unsupported'
|
||||
})
|
||||
expect(runner).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Cached: the second install skips the probe entirely.
|
||||
expect(grantManagedCodexHookTrust(plan)).toMatchObject({
|
||||
expect(await grantManagedCodexHookTrust(plan)).toMatchObject({
|
||||
lane: 'fallback',
|
||||
reason: 'unsupported-cached'
|
||||
})
|
||||
expect(runner).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('backs off transient failures without poisoning the capability', () => {
|
||||
it('backs off transient failures without poisoning the capability', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
const entries = [managedEntry('session_start')]
|
||||
const runner = vi.fn((): CodexHookTrustGrantSessionResult => {
|
||||
const runner = vi.fn((): Promise<CodexHookTrustGrantSessionResult> => {
|
||||
throw new Error('spawn ETIMEDOUT')
|
||||
})
|
||||
_internals.setGrantSessionRunnerSync(runner)
|
||||
_internals.setGrantSessionRunner(runner)
|
||||
const plan = buildPlan(entries)
|
||||
|
||||
expect(grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'fallback', reason: 'error' })
|
||||
expect(grantManagedCodexHookTrust(plan)).toMatchObject({
|
||||
expect(await grantManagedCodexHookTrust(plan)).toMatchObject({
|
||||
lane: 'fallback',
|
||||
reason: 'error'
|
||||
})
|
||||
expect(await grantManagedCodexHookTrust(plan)).toMatchObject({
|
||||
lane: 'fallback',
|
||||
reason: 'retry-cached'
|
||||
})
|
||||
expect(runner).toHaveBeenCalledTimes(1)
|
||||
expect(codexAppServerCapabilityCache.shouldTry('native')).toBe(true)
|
||||
|
||||
runner.mockImplementation(() => grantedSessionResult(entries))
|
||||
runner.mockImplementation(async () => grantedSessionResult(entries))
|
||||
vi.setSystemTime(1_000 + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS)
|
||||
expect(grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'rpc' })
|
||||
expect(await grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'rpc' })
|
||||
expect(runner).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('falls back on verify-failed without marking unsupported', () => {
|
||||
it('falls back on verify-failed without marking unsupported', async () => {
|
||||
const entries = [managedEntry('session_start')]
|
||||
const runner = vi.fn(() => ({
|
||||
const runner = vi.fn(async () => ({
|
||||
outcome: 'verify-failed' as const,
|
||||
reason: 'missing entries',
|
||||
reasonClass: 'list-mismatch' as const
|
||||
}))
|
||||
_internals.setGrantSessionRunnerSync(runner)
|
||||
_internals.setGrantSessionRunner(runner)
|
||||
|
||||
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
|
||||
expect(await grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
|
||||
lane: 'fallback',
|
||||
reason: 'verify-failed'
|
||||
})
|
||||
@@ -280,53 +287,56 @@ describe('grantManagedCodexHookTrust', () => {
|
||||
expect(getCodexTrustGrantDiagnostics()).toMatchObject({ verifyFailed: 1 })
|
||||
})
|
||||
|
||||
it('rejects duplicate granted keys instead of treating another key as covered', () => {
|
||||
it('rejects duplicate granted keys instead of treating another key as covered', async () => {
|
||||
const entries = [managedEntry('session_start'), managedEntry('stop')]
|
||||
const duplicated = grantedSessionResult([entries[0]!, entries[0]!])
|
||||
_internals.setGrantSessionRunnerSync(() => duplicated)
|
||||
_internals.setGrantSessionRunner(async () => duplicated)
|
||||
|
||||
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
|
||||
expect(await grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
|
||||
lane: 'fallback',
|
||||
reason: 'verify-failed'
|
||||
})
|
||||
expect(readCodexTrustGrantLedgerHome(runtimeHomeDir)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps grant and fallback outcomes stable when telemetry throws', () => {
|
||||
it('keeps grant and fallback outcomes stable when telemetry throws', async () => {
|
||||
const entries = [managedEntry('session_start')]
|
||||
setCodexTrustGrantTelemetry(() => {
|
||||
throw new Error('telemetry unavailable')
|
||||
})
|
||||
_internals.setGrantSessionRunnerSync(() => grantedSessionResult(entries))
|
||||
_internals.setGrantSessionRunner(async () => grantedSessionResult(entries))
|
||||
|
||||
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'rpc' })
|
||||
expect(await grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'rpc' })
|
||||
process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1'
|
||||
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
|
||||
expect(await grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
|
||||
lane: 'fallback',
|
||||
reason: 'disabled'
|
||||
})
|
||||
})
|
||||
|
||||
it('restores exact config bytes before fallback after a mutating RPC error', () => {
|
||||
it('restores exact config bytes before fallback after a mutating RPC error', async () => {
|
||||
const entries = [managedEntry('session_start')]
|
||||
const plan = buildPlan(entries)
|
||||
const original = '# user formatting\r\n[hooks]\r\n'
|
||||
mkdirSync(runtimeHomeDir, { recursive: true })
|
||||
writeFileSync(plan.tomlPath, original)
|
||||
_internals.setGrantSessionRunnerSync(() => {
|
||||
_internals.setGrantSessionRunner(async () => {
|
||||
writeFileSync(plan.tomlPath, '[hooks.state."rpc-partial"]\ntrusted_hash = "changed"\n')
|
||||
throw new Error('post-write transport failure')
|
||||
})
|
||||
|
||||
expect(grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'fallback', reason: 'error' })
|
||||
expect(await grantManagedCodexHookTrust(plan)).toMatchObject({
|
||||
lane: 'fallback',
|
||||
reason: 'error'
|
||||
})
|
||||
expect(readFileSync(plan.tomlPath, 'utf8')).toBe(original)
|
||||
})
|
||||
|
||||
it('removes an RPC-created config before fallback when none existed', () => {
|
||||
it('removes an RPC-created config before fallback when none existed', async () => {
|
||||
const entries = [managedEntry('session_start')]
|
||||
const plan = buildPlan(entries)
|
||||
mkdirSync(runtimeHomeDir, { recursive: true })
|
||||
_internals.setGrantSessionRunnerSync(() => {
|
||||
_internals.setGrantSessionRunner(async () => {
|
||||
writeFileSync(plan.tomlPath, '[hooks.state."rpc-partial"]\ntrusted_hash = "changed"\n')
|
||||
return {
|
||||
outcome: 'verify-failed',
|
||||
@@ -335,32 +345,116 @@ describe('grantManagedCodexHookTrust', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(grantManagedCodexHookTrust(plan)).toMatchObject({
|
||||
expect(await grantManagedCodexHookTrust(plan)).toMatchObject({
|
||||
lane: 'fallback',
|
||||
reason: 'verify-failed'
|
||||
})
|
||||
expect(existsSync(plan.tomlPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('honors the ops kill switch env flag', () => {
|
||||
it('honors the ops kill switch env flag', async () => {
|
||||
process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1'
|
||||
const entries = [managedEntry('session_start')]
|
||||
const runner = vi.fn(() => grantedSessionResult(entries))
|
||||
_internals.setGrantSessionRunnerSync(runner)
|
||||
const runner = vi.fn(async () => grantedSessionResult(entries))
|
||||
_internals.setGrantSessionRunner(runner)
|
||||
|
||||
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
|
||||
expect(await grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
|
||||
lane: 'fallback',
|
||||
reason: 'disabled'
|
||||
})
|
||||
expect(runner).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('builds a WSL invocation that runs codex inside the distro', () => {
|
||||
// Why (#16441): the grant used to run through spawnSync, so two grants on one
|
||||
// config.toml were impossible by construction. Now they must queue — an
|
||||
// interleaved capture/restore pair resurrects trust the other run removed.
|
||||
it('serializes concurrent grants that share one config.toml', async () => {
|
||||
const entries = [managedEntry('session_start')]
|
||||
const runner = vi.fn((_request: CodexHookTrustGrantRequest) => grantedSessionResult(entries))
|
||||
_internals.setGrantSessionRunnerSync(runner)
|
||||
const plan = buildPlan(entries)
|
||||
let inFlight = 0
|
||||
let maxInFlight = 0
|
||||
const releases: (() => void)[] = []
|
||||
_internals.setGrantSessionRunner(async () => {
|
||||
inFlight += 1
|
||||
maxInFlight = Math.max(maxInFlight, inFlight)
|
||||
await new Promise<void>((resolve) => releases.push(resolve))
|
||||
inFlight -= 1
|
||||
return grantedSessionResult(entries)
|
||||
})
|
||||
|
||||
const outcome = grantManagedCodexHookTrust({
|
||||
const first = grantManagedCodexHookTrust(plan)
|
||||
const second = grantManagedCodexHookTrust(plan)
|
||||
await vi.waitFor(() => expect(releases).toHaveLength(1))
|
||||
releases[0]!()
|
||||
await first
|
||||
await vi.waitFor(() => expect(releases).toHaveLength(2))
|
||||
releases[1]!()
|
||||
await second
|
||||
|
||||
expect(maxInFlight).toBe(1)
|
||||
})
|
||||
|
||||
it('lets grants on different config.toml paths overlap', async () => {
|
||||
const entries = [managedEntry('session_start')]
|
||||
const otherHome = join(userDataDir, 'codex-accounts', 'other', 'home')
|
||||
mkdirSync(otherHome, { recursive: true })
|
||||
// Why: the probe dedupe only holds the first session on an unproven host.
|
||||
// A known-supported host must keep its intended launch concurrency.
|
||||
codexAppServerCapabilityCache.rememberSupported('native')
|
||||
let inFlight = 0
|
||||
let maxInFlight = 0
|
||||
const releases: (() => void)[] = []
|
||||
_internals.setGrantSessionRunner(async () => {
|
||||
inFlight += 1
|
||||
maxInFlight = Math.max(maxInFlight, inFlight)
|
||||
await new Promise<void>((resolve) => releases.push(resolve))
|
||||
inFlight -= 1
|
||||
return grantedSessionResult(entries)
|
||||
})
|
||||
|
||||
const first = grantManagedCodexHookTrust(buildPlan(entries))
|
||||
const second = grantManagedCodexHookTrust({
|
||||
...buildPlan(entries),
|
||||
runtimeHomePath: otherHome,
|
||||
tomlPath: join(otherHome, 'config.toml')
|
||||
})
|
||||
await vi.waitFor(() => expect(releases).toHaveLength(2))
|
||||
releases.forEach((release) => release())
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(maxInFlight).toBe(2)
|
||||
})
|
||||
|
||||
it('dedupes the capability probe when concurrent grants hit an unsupported host', async () => {
|
||||
const entries = [managedEntry('session_start')]
|
||||
const otherHome = join(userDataDir, 'codex-accounts', 'other', 'home')
|
||||
mkdirSync(otherHome, { recursive: true })
|
||||
const releases: ((error: unknown) => void)[] = []
|
||||
const runner = vi.fn(() => new Promise<never>((_resolve, reject) => releases.push(reject)))
|
||||
_internals.setGrantSessionRunner(runner)
|
||||
|
||||
const first = grantManagedCodexHookTrust(buildPlan(entries))
|
||||
const second = grantManagedCodexHookTrust({
|
||||
...buildPlan(entries),
|
||||
runtimeHomePath: otherHome,
|
||||
tomlPath: join(otherHome, 'config.toml')
|
||||
})
|
||||
await vi.waitFor(() => expect(releases).toHaveLength(1))
|
||||
releases[0]!(new CodexAppServerUnsupportedError('no such method'))
|
||||
|
||||
expect(await first).toMatchObject({ lane: 'fallback', reason: 'unsupported' })
|
||||
expect(await second).toMatchObject({ lane: 'fallback', reason: 'unsupported-cached' })
|
||||
expect(runner).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('builds a WSL invocation that runs codex inside the distro', async () => {
|
||||
const entries = [managedEntry('session_start')]
|
||||
const runner = vi.fn(async (_request: CodexHookTrustGrantRequest) =>
|
||||
grantedSessionResult(entries)
|
||||
)
|
||||
_internals.setGrantSessionRunner(runner)
|
||||
|
||||
const outcome = await grantManagedCodexHookTrust({
|
||||
...buildPlan(entries),
|
||||
host: { kind: 'wsl', distro: 'Ubuntu', linuxRuntimeHome: '/home/alice/.codex-runtime' }
|
||||
})
|
||||
@@ -384,32 +478,32 @@ describe('trust-grant telemetry detail', () => {
|
||||
return events
|
||||
}
|
||||
|
||||
it('attributes the plan lane on granted events', () => {
|
||||
it('attributes the plan lane on granted events', async () => {
|
||||
const events = captureTelemetry()
|
||||
const entries = [managedEntry('session_start')]
|
||||
_internals.setGrantSessionRunnerSync(() => grantedSessionResult(entries))
|
||||
_internals.setGrantSessionRunner(async () => grantedSessionResult(entries))
|
||||
|
||||
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'rpc' })
|
||||
expect(await grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'rpc' })
|
||||
expect(events).toEqual([{ outcome: 'granted', hostKind: 'native', lane: 'real-home' }])
|
||||
})
|
||||
|
||||
it('reports the managed lane independently of host kind', () => {
|
||||
it('reports the managed lane independently of host kind', async () => {
|
||||
const events = captureTelemetry()
|
||||
const entries = [managedEntry('session_start')]
|
||||
_internals.setGrantSessionRunnerSync(() => grantedSessionResult(entries))
|
||||
_internals.setGrantSessionRunner(async () => grantedSessionResult(entries))
|
||||
|
||||
grantManagedCodexHookTrust({ ...buildPlan(entries), telemetryLane: 'managed' })
|
||||
await grantManagedCodexHookTrust({ ...buildPlan(entries), telemetryLane: 'managed' })
|
||||
expect(events).toEqual([{ outcome: 'granted', hostKind: 'native', lane: 'managed' }])
|
||||
})
|
||||
|
||||
it('classifies error fallbacks on the wire', () => {
|
||||
it('classifies error fallbacks on the wire', async () => {
|
||||
const events = captureTelemetry()
|
||||
const entries = [managedEntry('session_start')]
|
||||
_internals.setGrantSessionRunnerSync(() => {
|
||||
_internals.setGrantSessionRunner(async () => {
|
||||
throw new Error('spawn codex ENOENT')
|
||||
})
|
||||
|
||||
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
|
||||
expect(await grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
|
||||
lane: 'fallback',
|
||||
reason: 'error'
|
||||
})
|
||||
@@ -424,16 +518,16 @@ describe('trust-grant telemetry detail', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('carries the session verify class through the fallback event', () => {
|
||||
it('carries the session verify class through the fallback event', async () => {
|
||||
const events = captureTelemetry()
|
||||
const entries = [managedEntry('session_start')]
|
||||
_internals.setGrantSessionRunnerSync(() => ({
|
||||
_internals.setGrantSessionRunner(async () => ({
|
||||
outcome: 'verify-failed' as const,
|
||||
reason: 'post-grant verify left 1 entries untrusted',
|
||||
reasonClass: 'post-grant-untrusted' as const
|
||||
}))
|
||||
|
||||
grantManagedCodexHookTrust(buildPlan(entries))
|
||||
await grantManagedCodexHookTrust(buildPlan(entries))
|
||||
expect(events).toEqual([
|
||||
{
|
||||
outcome: 'verify_failed',
|
||||
@@ -445,12 +539,12 @@ describe('trust-grant telemetry detail', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('classifies module-detected verify failures', () => {
|
||||
it('classifies module-detected verify failures', async () => {
|
||||
const events = captureTelemetry()
|
||||
const entries = [managedEntry('session_start'), managedEntry('stop')]
|
||||
_internals.setGrantSessionRunnerSync(() => grantedSessionResult([entries[0]!, entries[0]!]))
|
||||
_internals.setGrantSessionRunner(async () => grantedSessionResult([entries[0]!, entries[0]!]))
|
||||
|
||||
grantManagedCodexHookTrust(buildPlan(entries))
|
||||
await grantManagedCodexHookTrust(buildPlan(entries))
|
||||
expect(events).toEqual([
|
||||
{
|
||||
outcome: 'verify_failed',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
isCodexAppServerUnsupportedError,
|
||||
runCodexHookTrustGrantSession,
|
||||
type CodexHookTrustGrantRequest,
|
||||
type CodexHookTrustGrantSessionResult
|
||||
} from './codex-app-server-client'
|
||||
@@ -10,55 +11,50 @@ import {
|
||||
type CodexTrustGrantTelemetryLane,
|
||||
type CodexTrustGrantVerifyClass
|
||||
} from './codex-trust-grant-telemetry'
|
||||
import { runCodexHookTrustGrantSessionSync } from './codex-app-server-grant-bridge'
|
||||
import {
|
||||
codexAppServerCapabilityCache,
|
||||
getCodexAppServerHostKey
|
||||
getCodexAppServerHostKey,
|
||||
type CodexAppServerHostKey
|
||||
} from './codex-app-server-capability-cache'
|
||||
import {
|
||||
writeCodexTrustGrantLedgerHome,
|
||||
type CodexTrustGrantBinaryStamp,
|
||||
type CodexTrustGrantLedgerEntry
|
||||
} from './codex-trust-grant-ledger'
|
||||
import {
|
||||
computeTrustKey,
|
||||
computeTrustedHash,
|
||||
normalizeHookTrustKeyForLookup,
|
||||
readHookTrustEntries,
|
||||
removeHookTrustEntries,
|
||||
type CodexTrustEntry
|
||||
} from './config-toml-trust'
|
||||
import { getCodexHookTrustSignature } from './codex-hook-identity'
|
||||
import type { CodexTrustEntry } from './config-toml-trust'
|
||||
import { captureCodexTrustConfig, restoreCodexTrustConfig } from './codex-trust-config-rollback'
|
||||
import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue'
|
||||
import {
|
||||
readCodexTrustGrantLedgerHomeMatchingStamp,
|
||||
resolveCodexTrustGrantHost,
|
||||
type CodexTrustGrantHost
|
||||
type ResolvedCodexTrustGrantHost
|
||||
} from './codex-trust-grant-host'
|
||||
import {
|
||||
buildExpectedEntries,
|
||||
findLedgerGrant,
|
||||
removeSelfComputedTrustBeforeGrant,
|
||||
type CodexManagedTrustGrantPlan,
|
||||
type ExpectedManagedEntry
|
||||
} from './codex-managed-trust-grant-plan'
|
||||
import { isCodexStateDbBackfillPending } from './codex-state-db'
|
||||
|
||||
// Why: a transiently hung app-server must not block launch prep on every pane.
|
||||
// The legacy lane remains available while a short, host-scoped cooldown runs.
|
||||
export const CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS = 5 * 60_000
|
||||
|
||||
/** Ops escape hatch (not a setting): forces the unchanged fallback lane. */
|
||||
/**
|
||||
* Ops escape hatch (not a setting): forces the unchanged fallback lane for the
|
||||
* *managed* grant only.
|
||||
*
|
||||
* Scope, because the name reads broader than it is: the real-home rebase
|
||||
* (`mutateRealHomeHooksPreservingUserTrust`) still runs its own inspect/repair
|
||||
* app-server sessions when Orca's insertion shifts a user's hook positions, and
|
||||
* does not read this flag. That is unchanged from before the grant went async —
|
||||
* those sessions simply used to block the main thread instead. Widening the flag
|
||||
* to cover the rebase is a follow-up, not something this constant already does.
|
||||
*/
|
||||
const DISABLE_ENV_FLAG = 'ORCA_DISABLE_CODEX_TRUST_RPC'
|
||||
|
||||
export type CodexManagedTrustGrantPlan = {
|
||||
/** Host-visible runtime home path (UNC for WSL) — ledger key + config reads. */
|
||||
runtimeHomePath: string
|
||||
/** Host-visible config.toml path holding the trust entries. */
|
||||
tomlPath: string
|
||||
/** Exact command string written to the managed hooks.json entries. */
|
||||
managedCommand: string
|
||||
/** Managed trust identities Orca just wrote (no trustedHash). */
|
||||
managedEntries: readonly CodexTrustEntry[]
|
||||
host: CodexTrustGrantHost
|
||||
telemetryLane: CodexTrustGrantTelemetryLane
|
||||
/** Match a pane where CODEX_HOME is absent instead of an explicit managed home. */
|
||||
useDefaultCodexHome?: boolean
|
||||
}
|
||||
|
||||
export type { CodexManagedTrustGrantPlan }
|
||||
export type { CodexTrustGrantFallbackReason, CodexTrustGrantTelemetryLane }
|
||||
|
||||
export type CodexManagedTrustGrantOutcome =
|
||||
@@ -77,11 +73,14 @@ const transientRetryAfterByHost = new Map<string, number>()
|
||||
|
||||
export const getCodexTrustGrantDiagnostics = (): CodexTrustGrantDiagnostics => ({ ...diagnostics })
|
||||
|
||||
type GrantSessionRunnerSync = (
|
||||
type GrantSessionRunner = (
|
||||
request: CodexHookTrustGrantRequest
|
||||
) => CodexHookTrustGrantSessionResult
|
||||
) => Promise<CodexHookTrustGrantSessionResult>
|
||||
|
||||
let runSessionSync: GrantSessionRunnerSync = runCodexHookTrustGrantSessionSync
|
||||
// Why (#16441): the session runs in-process on the main thread's event loop.
|
||||
// It used to be forked through spawnSync purely to donate an event loop to a
|
||||
// deliberately-blocked parent, which froze the window for the whole deadline.
|
||||
let runSession: GrantSessionRunner = runCodexHookTrustGrantSession
|
||||
|
||||
function fallback(
|
||||
plan: CodexManagedTrustGrantPlan,
|
||||
@@ -109,60 +108,141 @@ function fallback(
|
||||
return { lane: 'fallback', reason }
|
||||
}
|
||||
|
||||
type ExpectedManagedEntry = {
|
||||
entry: CodexTrustEntry
|
||||
normalizedKey: string
|
||||
signature: string
|
||||
function startTransientCooldown(hostKey: CodexAppServerHostKey): void {
|
||||
transientRetryAfterByHost.set(hostKey, Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function buildExpectedEntries(plan: CodexManagedTrustGrantPlan): ExpectedManagedEntry[] {
|
||||
return plan.managedEntries.map((entry) => ({
|
||||
entry,
|
||||
normalizedKey: normalizeHookTrustKeyForLookup(computeTrustKey(entry)),
|
||||
signature: getCodexHookTrustSignature(entry)
|
||||
}))
|
||||
type GrantAttempt = {
|
||||
plan: CodexManagedTrustGrantPlan
|
||||
expected: ExpectedManagedEntry[]
|
||||
hostKey: CodexAppServerHostKey
|
||||
currentStamp: CodexTrustGrantBinaryStamp | null
|
||||
configSnapshot: ReturnType<typeof captureCodexTrustConfig>
|
||||
startedAtMs: number
|
||||
}
|
||||
|
||||
function removeSelfComputedTrustBeforeGrant(plan: CodexManagedTrustGrantPlan): void {
|
||||
const trustStates = readHookTrustEntries(plan.tomlPath)
|
||||
const ownedKeys = plan.managedEntries
|
||||
.map((entry) => {
|
||||
const key = computeTrustKey(entry)
|
||||
return trustStates.get(key)?.trustedHash === computeTrustedHash(entry) ? key : null
|
||||
})
|
||||
.filter((key): key is string => key !== null)
|
||||
if (ownedKeys.length > 0) {
|
||||
removeHookTrustEntries(plan.tomlPath, ownedKeys)
|
||||
/** Post-session verification, ledger persistence and telemetry. Never throws for
|
||||
* a verify failure — every rejection is a rolled-back fallback. */
|
||||
function completeGrant(
|
||||
attempt: GrantAttempt,
|
||||
result: CodexHookTrustGrantSessionResult
|
||||
): CodexManagedTrustGrantOutcome {
|
||||
const { plan, expected, hostKey, configSnapshot } = attempt
|
||||
const rejectGrant = (
|
||||
detail: unknown,
|
||||
verifyClass: CodexTrustGrantVerifyClass
|
||||
): CodexManagedTrustGrantOutcome => {
|
||||
restoreCodexTrustConfig(plan.tomlPath, configSnapshot)
|
||||
startTransientCooldown(hostKey)
|
||||
return fallback(plan, 'verify-failed', detail, verifyClass)
|
||||
}
|
||||
if (result.outcome === 'verify-failed') {
|
||||
return rejectGrant(result.reason, result.reasonClass)
|
||||
}
|
||||
|
||||
const byNormalizedKey = new Map(expected.map((item) => [item.normalizedKey, item]))
|
||||
const seenNormalizedKeys = new Set<string>()
|
||||
const grantedEntries: CodexTrustEntry[] = []
|
||||
const ledgerRecord: Record<string, CodexTrustGrantLedgerEntry> = {}
|
||||
for (const granted of result.entries) {
|
||||
const match = byNormalizedKey.get(granted.normalizedKey)
|
||||
if (!match) {
|
||||
return rejectGrant(`unexpected granted key ${granted.key}`, 'unexpected-key')
|
||||
}
|
||||
if (seenNormalizedKeys.has(granted.normalizedKey)) {
|
||||
return rejectGrant(`duplicate granted key ${granted.key}`, 'duplicate-key')
|
||||
}
|
||||
seenNormalizedKeys.add(granted.normalizedKey)
|
||||
grantedEntries.push({ ...match.entry, trustedHash: granted.trustedHash })
|
||||
ledgerRecord[granted.normalizedKey] = {
|
||||
signature: match.signature,
|
||||
trustedHash: granted.trustedHash
|
||||
}
|
||||
}
|
||||
if (seenNormalizedKeys.size !== expected.length) {
|
||||
return rejectGrant('granted entry set did not cover expected entries', 'coverage')
|
||||
}
|
||||
transientRetryAfterByHost.delete(hostKey)
|
||||
try {
|
||||
writeCodexTrustGrantLedgerHome(plan.runtimeHomePath, {
|
||||
binary: attempt.currentStamp,
|
||||
entries: ledgerRecord
|
||||
})
|
||||
} catch (error) {
|
||||
// Why: a ledger write failure only costs an extra session next launch.
|
||||
console.warn('[codex-trust-grant] failed to persist grant ledger', error)
|
||||
}
|
||||
diagnostics.granted += 1
|
||||
console.log(
|
||||
`[codex-trust-grant] granted ${grantedEntries.length} managed hook entries via codex app-server ` +
|
||||
`(host=${plan.host.kind}, wrote=${result.wroteTrust}, ${Date.now() - attempt.startedAtMs}ms)`
|
||||
)
|
||||
emitCodexTrustGrantTelemetry({
|
||||
outcome: 'granted',
|
||||
hostKind: plan.host.kind,
|
||||
lane: plan.telemetryLane
|
||||
})
|
||||
return { lane: 'rpc', entries: grantedEntries }
|
||||
}
|
||||
|
||||
function findLedgerGrant(
|
||||
async function runGrantAttempt(
|
||||
plan: CodexManagedTrustGrantPlan,
|
||||
expected: ExpectedManagedEntry[],
|
||||
currentStamp: CodexTrustGrantBinaryStamp | null
|
||||
): CodexTrustEntry[] | null {
|
||||
const home = readCodexTrustGrantLedgerHomeMatchingStamp(plan.runtimeHomePath, currentStamp)
|
||||
if (!home) {
|
||||
return null
|
||||
resolvedHost: ResolvedCodexTrustGrantHost,
|
||||
hostKey: CodexAppServerHostKey
|
||||
): Promise<CodexManagedTrustGrantOutcome> {
|
||||
// Why: the RPC may rewrite config.toml before a later RPC fails. Restore its
|
||||
// exact pre-session bytes before the legacy lane runs so every fallback has
|
||||
// the same input and output as the pre-RPC implementation.
|
||||
const attempt: GrantAttempt = {
|
||||
plan,
|
||||
expected,
|
||||
hostKey,
|
||||
currentStamp: resolvedHost.binaryStamp,
|
||||
configSnapshot: captureCodexTrustConfig(plan.tomlPath),
|
||||
startedAtMs: Date.now()
|
||||
}
|
||||
let trustStates: ReturnType<typeof readHookTrustEntries>
|
||||
let unsupportedError: unknown
|
||||
try {
|
||||
trustStates = readHookTrustEntries(plan.tomlPath)
|
||||
} catch {
|
||||
return null
|
||||
return await codexAppServerCapabilityCache.runWithFallback(
|
||||
hostKey,
|
||||
async () => {
|
||||
removeSelfComputedTrustBeforeGrant(plan)
|
||||
return completeGrant(
|
||||
attempt,
|
||||
await runSession(
|
||||
resolvedHost.buildRequest({
|
||||
runtimeHomePath: plan.runtimeHomePath,
|
||||
managedCommand: plan.managedCommand,
|
||||
expectedTrustKeys: expected.map(({ normalizedKey }) => normalizedKey),
|
||||
useDefaultCodexHome: plan.useDefaultCodexHome
|
||||
})
|
||||
)
|
||||
)
|
||||
},
|
||||
async () => {
|
||||
if (unsupportedError === undefined) {
|
||||
// Why: a concurrent launch's probe proved the surface missing while
|
||||
// this one waited behind it; nothing was mutated, so nothing to undo.
|
||||
return fallback(plan, 'unsupported-cached')
|
||||
}
|
||||
restoreCodexTrustConfig(plan.tomlPath, attempt.configSnapshot)
|
||||
transientRetryAfterByHost.delete(hostKey)
|
||||
return fallback(plan, 'unsupported', unsupportedError)
|
||||
},
|
||||
(error) => {
|
||||
if (!isCodexAppServerUnsupportedError(error)) {
|
||||
return false
|
||||
}
|
||||
unsupportedError = error
|
||||
return true
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
restoreCodexTrustConfig(plan.tomlPath, attempt.configSnapshot)
|
||||
startTransientCooldown(hostKey)
|
||||
return fallback(plan, 'error', error)
|
||||
}
|
||||
const entries: CodexTrustEntry[] = []
|
||||
for (const { entry, normalizedKey, signature } of expected) {
|
||||
const recorded = home.entries[normalizedKey]
|
||||
if (!recorded || recorded.signature !== signature) {
|
||||
return null
|
||||
}
|
||||
if (trustStates.get(normalizedKey)?.trustedHash !== recorded.trustedHash) {
|
||||
return null
|
||||
}
|
||||
entries.push({ ...entry, trustedHash: recorded.trustedHash })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,9 +253,9 @@ function findLedgerGrant(
|
||||
* throws: any unexpected failure is a fallback, because hook install is
|
||||
* best-effort launch prep.
|
||||
*/
|
||||
export function grantManagedCodexHookTrust(
|
||||
export async function grantManagedCodexHookTrust(
|
||||
plan: CodexManagedTrustGrantPlan
|
||||
): CodexManagedTrustGrantOutcome {
|
||||
): Promise<CodexManagedTrustGrantOutcome> {
|
||||
try {
|
||||
if (process.env[DISABLE_ENV_FLAG] === '1') {
|
||||
return fallback(plan, 'disabled')
|
||||
@@ -184,9 +264,8 @@ export function grantManagedCodexHookTrust(
|
||||
return fallback(plan, 'no-managed-entries')
|
||||
}
|
||||
const expected = buildExpectedEntries(plan)
|
||||
const resolvedHost = resolveCodexTrustGrantHost(plan.host)
|
||||
const currentStamp = resolvedHost.binaryStamp
|
||||
const ledgerEntries = findLedgerGrant(plan, expected, currentStamp)
|
||||
const resolvedHost = await resolveCodexTrustGrantHost(plan.host)
|
||||
const ledgerEntries = findLedgerGrant(plan, expected, resolvedHost.binaryStamp)
|
||||
if (ledgerEntries !== null) {
|
||||
diagnostics.ledgerHits += 1
|
||||
return { lane: 'rpc', entries: ledgerEntries }
|
||||
@@ -207,131 +286,17 @@ export function grantManagedCodexHookTrust(
|
||||
}
|
||||
transientRetryAfterByHost.delete(hostKey)
|
||||
}
|
||||
|
||||
const startedAtMs = Date.now()
|
||||
// Why: the RPC may rewrite config.toml before a later RPC fails. Restore
|
||||
// its exact pre-session bytes before the legacy lane runs so every fallback
|
||||
// has the same input and output as the pre-RPC implementation.
|
||||
const configSnapshot = captureCodexTrustConfig(plan.tomlPath)
|
||||
let result: CodexHookTrustGrantSessionResult
|
||||
try {
|
||||
// Why: Windows fallback writes equivalent separator variants that Codex's
|
||||
// canonical RPC key may not overwrite, leaving conflicting logical trust.
|
||||
removeSelfComputedTrustBeforeGrant(plan)
|
||||
result = runSessionSync(
|
||||
resolvedHost.buildRequest({
|
||||
runtimeHomePath: plan.runtimeHomePath,
|
||||
managedCommand: plan.managedCommand,
|
||||
expectedTrustKeys: expected.map(({ normalizedKey }) => normalizedKey),
|
||||
useDefaultCodexHome: plan.useDefaultCodexHome
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
restoreCodexTrustConfig(plan.tomlPath, configSnapshot)
|
||||
if (isCodexAppServerUnsupportedError(error)) {
|
||||
transientRetryAfterByHost.delete(hostKey)
|
||||
codexAppServerCapabilityCache.rememberUnsupported(hostKey)
|
||||
return fallback(plan, 'unsupported', error)
|
||||
}
|
||||
transientRetryAfterByHost.set(
|
||||
hostKey,
|
||||
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
|
||||
)
|
||||
return fallback(plan, 'error', error)
|
||||
}
|
||||
// Why: the RPC surface answered, even if our entries were not verifiable —
|
||||
// remember support so a later drift event retries the preferred lane.
|
||||
codexAppServerCapabilityCache.rememberSupported(hostKey)
|
||||
if (result.outcome === 'verify-failed') {
|
||||
restoreCodexTrustConfig(plan.tomlPath, configSnapshot)
|
||||
transientRetryAfterByHost.set(
|
||||
hostKey,
|
||||
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
|
||||
)
|
||||
return fallback(plan, 'verify-failed', result.reason, result.reasonClass)
|
||||
}
|
||||
|
||||
const byNormalizedKey = new Map(expected.map((item) => [item.normalizedKey, item]))
|
||||
const seenNormalizedKeys = new Set<string>()
|
||||
const grantedEntries: CodexTrustEntry[] = []
|
||||
const ledgerRecord: Record<string, CodexTrustGrantLedgerEntry> = {}
|
||||
for (const granted of result.entries) {
|
||||
const match = byNormalizedKey.get(granted.normalizedKey)
|
||||
if (!match) {
|
||||
restoreCodexTrustConfig(plan.tomlPath, configSnapshot)
|
||||
transientRetryAfterByHost.set(
|
||||
hostKey,
|
||||
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
|
||||
)
|
||||
return fallback(
|
||||
plan,
|
||||
'verify-failed',
|
||||
`unexpected granted key ${granted.key}`,
|
||||
'unexpected-key'
|
||||
)
|
||||
}
|
||||
if (seenNormalizedKeys.has(granted.normalizedKey)) {
|
||||
restoreCodexTrustConfig(plan.tomlPath, configSnapshot)
|
||||
transientRetryAfterByHost.set(
|
||||
hostKey,
|
||||
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
|
||||
)
|
||||
return fallback(
|
||||
plan,
|
||||
'verify-failed',
|
||||
`duplicate granted key ${granted.key}`,
|
||||
'duplicate-key'
|
||||
)
|
||||
}
|
||||
seenNormalizedKeys.add(granted.normalizedKey)
|
||||
grantedEntries.push({ ...match.entry, trustedHash: granted.trustedHash })
|
||||
ledgerRecord[granted.normalizedKey] = {
|
||||
signature: match.signature,
|
||||
trustedHash: granted.trustedHash
|
||||
}
|
||||
}
|
||||
if (seenNormalizedKeys.size !== expected.length) {
|
||||
restoreCodexTrustConfig(plan.tomlPath, configSnapshot)
|
||||
transientRetryAfterByHost.set(
|
||||
hostKey,
|
||||
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
|
||||
)
|
||||
return fallback(
|
||||
plan,
|
||||
'verify-failed',
|
||||
'granted entry set did not cover expected entries',
|
||||
'coverage'
|
||||
)
|
||||
}
|
||||
transientRetryAfterByHost.delete(hostKey)
|
||||
try {
|
||||
writeCodexTrustGrantLedgerHome(plan.runtimeHomePath, {
|
||||
binary: currentStamp,
|
||||
entries: ledgerRecord
|
||||
})
|
||||
} catch (error) {
|
||||
// Why: a ledger write failure only costs an extra session next launch.
|
||||
console.warn('[codex-trust-grant] failed to persist grant ledger', error)
|
||||
}
|
||||
diagnostics.granted += 1
|
||||
console.log(
|
||||
`[codex-trust-grant] granted ${grantedEntries.length} managed hook entries via codex app-server ` +
|
||||
`(host=${plan.host.kind}, wrote=${result.wroteTrust}, ${Date.now() - startedAtMs}ms)`
|
||||
return await runExclusivelyForCodexTrustConfig(plan.tomlPath, () =>
|
||||
runGrantAttempt(plan, expected, resolvedHost, hostKey)
|
||||
)
|
||||
emitCodexTrustGrantTelemetry({
|
||||
outcome: 'granted',
|
||||
hostKind: plan.host.kind,
|
||||
lane: plan.telemetryLane
|
||||
})
|
||||
return { lane: 'rpc', entries: grantedEntries }
|
||||
} catch (error) {
|
||||
return fallback(plan, 'error', error)
|
||||
}
|
||||
}
|
||||
|
||||
export const _internals = {
|
||||
setGrantSessionRunnerSync(runner: GrantSessionRunnerSync | null): void {
|
||||
runSessionSync = runner ?? runCodexHookTrustGrantSessionSync
|
||||
setGrantSessionRunner(runner: GrantSessionRunner | null): void {
|
||||
runSession = runner ?? runCodexHookTrustGrantSession
|
||||
},
|
||||
resetDiagnostics(): void {
|
||||
diagnostics.granted = 0
|
||||
|
||||
@@ -325,6 +325,27 @@ describe('per-account resume repin', () => {
|
||||
expect(result).toEqual({ useRealCodexHome: false })
|
||||
})
|
||||
|
||||
it('does not consult the host selection while resuming a WSL account session', async () => {
|
||||
const wslHome =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\me\\.local\\share\\orca\\codex-accounts\\account-1\\home'
|
||||
const result = await prepareLegacySharedCodexSessionResume(
|
||||
{
|
||||
agent: 'codex',
|
||||
filePath: `${wslHome}\\sessions\\2026\\07\\20\\rollout-session.jsonl`,
|
||||
codexHome: wslHome,
|
||||
executionHostId: 'local'
|
||||
},
|
||||
{
|
||||
...repinOptions(),
|
||||
getSelectedHostAccountCodexHomePath: () => {
|
||||
throw new Error('host lane must not be consulted')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toEqual({ useRealCodexHome: false })
|
||||
})
|
||||
|
||||
it('declines a transcript outside the dated rollout layout', async () => {
|
||||
const straySessionPath = join(peerHome, 'sessions', 'stray.jsonl')
|
||||
writeFileSync(straySessionPath, '{"type":"session_meta"}\n', 'utf-8')
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import { isPerAccountManagedCodexHome } from '../../shared/ai-vault-resume-preparation'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import {
|
||||
appendCodexSessionHealAuditRecord,
|
||||
createCodexSessionBackfillAuditWriter
|
||||
@@ -104,6 +105,7 @@ async function resolveSelectedAccountCodexHomeForResume(
|
||||
args.agent !== 'codex' ||
|
||||
args.executionHostId !== LOCAL_EXECUTION_HOST_ID ||
|
||||
!args.codexHome ||
|
||||
parseWslUncPath(args.codexHome) !== null ||
|
||||
!isPerAccountManagedCodexHome(args.codexHome)
|
||||
) {
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { CodexTrustGrantTelemetryLane } from './codex-trust-grant-telemetry'
|
||||
import {
|
||||
readCodexTrustGrantLedgerHomeMatchingStamp,
|
||||
type CodexTrustGrantHost
|
||||
} from './codex-trust-grant-host'
|
||||
import type { CodexTrustGrantBinaryStamp } from './codex-trust-grant-ledger'
|
||||
import { getCodexHookTrustSignature } from './codex-hook-identity'
|
||||
import {
|
||||
computeTrustKey,
|
||||
computeTrustedHash,
|
||||
normalizeHookTrustKeyForLookup,
|
||||
readHookTrustEntries,
|
||||
removeHookTrustEntries,
|
||||
type CodexTrustEntry
|
||||
} from './config-toml-trust'
|
||||
|
||||
export type CodexManagedTrustGrantPlan = {
|
||||
/** Host-visible runtime home path (UNC for WSL) — ledger key + config reads. */
|
||||
runtimeHomePath: string
|
||||
/** Host-visible config.toml path holding the trust entries. */
|
||||
tomlPath: string
|
||||
/** Exact command string written to the managed hooks.json entries. */
|
||||
managedCommand: string
|
||||
/** Managed trust identities Orca just wrote (no trustedHash). */
|
||||
managedEntries: readonly CodexTrustEntry[]
|
||||
host: CodexTrustGrantHost
|
||||
telemetryLane: CodexTrustGrantTelemetryLane
|
||||
/** Match a pane where CODEX_HOME is absent instead of an explicit managed home. */
|
||||
useDefaultCodexHome?: boolean
|
||||
}
|
||||
|
||||
export type ExpectedManagedEntry = {
|
||||
entry: CodexTrustEntry
|
||||
normalizedKey: string
|
||||
signature: string
|
||||
}
|
||||
|
||||
export function buildExpectedEntries(plan: CodexManagedTrustGrantPlan): ExpectedManagedEntry[] {
|
||||
return plan.managedEntries.map((entry) => ({
|
||||
entry,
|
||||
normalizedKey: normalizeHookTrustKeyForLookup(computeTrustKey(entry)),
|
||||
signature: getCodexHookTrustSignature(entry)
|
||||
}))
|
||||
}
|
||||
|
||||
/** Windows fallback writes equivalent separator variants that Codex's canonical
|
||||
* RPC key may not overwrite, leaving conflicting logical trust behind. */
|
||||
export function removeSelfComputedTrustBeforeGrant(plan: CodexManagedTrustGrantPlan): void {
|
||||
const trustStates = readHookTrustEntries(plan.tomlPath)
|
||||
const ownedKeys = plan.managedEntries
|
||||
.map((entry) => {
|
||||
const key = computeTrustKey(entry)
|
||||
return trustStates.get(key)?.trustedHash === computeTrustedHash(entry) ? key : null
|
||||
})
|
||||
.filter((key): key is string => key !== null)
|
||||
if (ownedKeys.length > 0) {
|
||||
removeHookTrustEntries(plan.tomlPath, ownedKeys)
|
||||
}
|
||||
}
|
||||
|
||||
/** Entries a prior grant already recorded for this exact binary and config
|
||||
* state, or null when the RPC session has to run again. */
|
||||
export function findLedgerGrant(
|
||||
plan: CodexManagedTrustGrantPlan,
|
||||
expected: ExpectedManagedEntry[],
|
||||
currentStamp: CodexTrustGrantBinaryStamp | null
|
||||
): CodexTrustEntry[] | null {
|
||||
const home = readCodexTrustGrantLedgerHomeMatchingStamp(plan.runtimeHomePath, currentStamp)
|
||||
if (!home) {
|
||||
return null
|
||||
}
|
||||
let trustStates: ReturnType<typeof readHookTrustEntries>
|
||||
try {
|
||||
trustStates = readHookTrustEntries(plan.tomlPath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const entries: CodexTrustEntry[] = []
|
||||
for (const { entry, normalizedKey, signature } of expected) {
|
||||
const recorded = home.entries[normalizedKey]
|
||||
if (!recorded || recorded.signature !== signature) {
|
||||
return null
|
||||
}
|
||||
if (trustStates.get(normalizedKey)?.trustedHash !== recorded.trustedHash) {
|
||||
return null
|
||||
}
|
||||
entries.push({ ...entry, trustedHash: recorded.trustedHash })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
@@ -87,7 +87,7 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rebaseInternals.setSessionRunnerSync(null)
|
||||
rebaseInternals.setSessionRunner(null)
|
||||
rebaseInternals.resetRetryState()
|
||||
rmSync(fakeHomeDir, { recursive: true, force: true })
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
@@ -100,10 +100,30 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('ensureRealHomeCodexHookState (install)', () => {
|
||||
it('creates hooks.json with the Orca entry in every managed event for a fresh home', () => {
|
||||
// Why (#16441): the ensure chain is process-wide; a rejection that escapes it
|
||||
// would return the same rejected promise to every later pane launch, with no
|
||||
// retry and no cooldown recovery.
|
||||
it('recovers from a home-resolution failure instead of poisoning later ensures', async () => {
|
||||
grantSucceeds()
|
||||
homedirMock.mockImplementationOnce(() => {
|
||||
throw new Error('home unavailable')
|
||||
})
|
||||
|
||||
await expect(
|
||||
ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
).resolves.toBe('unavailable')
|
||||
await expect(
|
||||
ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })
|
||||
).resolves.toBe('removed')
|
||||
})
|
||||
|
||||
it('creates hooks.json with the Orca entry in every managed event for a fresh home', async () => {
|
||||
grantSucceeds()
|
||||
|
||||
const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
const lane = await ensureRealHomeCodexHookState({
|
||||
hooksEnabled: true,
|
||||
userDataPath: userDataDir
|
||||
})
|
||||
|
||||
expect(lane).toBe('installed')
|
||||
const material = getCodexManagedHookInstallMaterial()
|
||||
@@ -121,7 +141,7 @@ describe('ensureRealHomeCodexHookState (install)', () => {
|
||||
expect(plan.managedEntries.every((entry) => entry.groupIndex === 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a symlinked default home logical in the keys sent to Codex', () => {
|
||||
it('keeps a symlinked default home logical in the keys sent to Codex', async () => {
|
||||
grantSucceeds()
|
||||
const logicalHome = join(fakeHomeDir, '.codex')
|
||||
const targetHome = join(fakeHomeDir, 'dotfiles-codex')
|
||||
@@ -129,9 +149,9 @@ describe('ensureRealHomeCodexHookState (install)', () => {
|
||||
mkdirSync(targetHome)
|
||||
symlinkSync(targetHome, logicalHome, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
|
||||
expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe(
|
||||
'installed'
|
||||
)
|
||||
expect(
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
).toBe('installed')
|
||||
|
||||
const plan = grantMock.mock.calls[0]![0] as CodexManagedTrustGrantPlan
|
||||
expect(
|
||||
@@ -139,7 +159,7 @@ describe('ensureRealHomeCodexHookState (install)', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the managed lane for unknown top-level fields Codex cannot load', () => {
|
||||
it('keeps the managed lane for unknown top-level fields Codex cannot load', async () => {
|
||||
grantSucceeds()
|
||||
const userConfig = {
|
||||
hooks: {
|
||||
@@ -151,7 +171,10 @@ describe('ensureRealHomeCodexHookState (install)', () => {
|
||||
const original = `${JSON.stringify(userConfig, null, 2)}\n`
|
||||
writeFileSync(getRealHooksJsonPath(), original, 'utf-8')
|
||||
|
||||
const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
const lane = await ensureRealHomeCodexHookState({
|
||||
hooksEnabled: true,
|
||||
userDataPath: userDataDir
|
||||
})
|
||||
|
||||
expect(lane).toBe('unavailable')
|
||||
expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(original)
|
||||
@@ -161,7 +184,7 @@ describe('ensureRealHomeCodexHookState (install)', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('appends LAST and preserves user entries and trust positions', () => {
|
||||
it('appends LAST and preserves user entries and trust positions', async () => {
|
||||
grantSucceeds()
|
||||
const userConfig = {
|
||||
hooks: {
|
||||
@@ -172,9 +195,9 @@ describe('ensureRealHomeCodexHookState (install)', () => {
|
||||
const original = `${JSON.stringify(userConfig, null, 2)}\n`
|
||||
writeFileSync(getRealHooksJsonPath(), original, 'utf-8')
|
||||
|
||||
expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe(
|
||||
'installed'
|
||||
)
|
||||
expect(
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
).toBe('installed')
|
||||
|
||||
const config = readRealHooksJson()
|
||||
expect(config.hooks?.Stop).toHaveLength(2)
|
||||
@@ -190,7 +213,7 @@ describe('ensureRealHomeCodexHookState (install)', () => {
|
||||
// Why: ordinary Windows CI tokens cannot create file symlinks without Developer Mode.
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'updates a symlinked hooks.json target without replacing the symlink',
|
||||
() => {
|
||||
async () => {
|
||||
grantSucceeds()
|
||||
const dotfilesDir = join(fakeHomeDir, 'dotfiles')
|
||||
const targetPath = join(dotfilesDir, 'hooks.json')
|
||||
@@ -202,78 +225,87 @@ describe('ensureRealHomeCodexHookState (install)', () => {
|
||||
)
|
||||
symlinkSync(targetPath, getRealHooksJsonPath())
|
||||
|
||||
expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe(
|
||||
'installed'
|
||||
)
|
||||
expect(
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
).toBe('installed')
|
||||
|
||||
expect(lstatSync(getRealHooksJsonPath()).isSymbolicLink()).toBe(true)
|
||||
expect(JSON.parse(readFileSync(targetPath, 'utf-8')).hooks.Stop).toHaveLength(2)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the managed lane and original bytes when the pristine backup cannot be created', () => {
|
||||
it('keeps the managed lane and original bytes when the pristine backup cannot be created', async () => {
|
||||
grantSucceeds()
|
||||
const original = `${JSON.stringify({ hooks: { Stop: [] } }, null, 2)}\n`
|
||||
writeFileSync(getRealHooksJsonPath(), original, 'utf-8')
|
||||
writeFileSync(join(userDataDir, 'codex-real-home-hooks'), 'blocks backup directory', 'utf-8')
|
||||
|
||||
expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe(
|
||||
'unavailable'
|
||||
)
|
||||
expect(
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
).toBe('unavailable')
|
||||
|
||||
expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(original)
|
||||
expect(grantMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('preserves restrictive hooks.json permissions', () => {
|
||||
grantSucceeds()
|
||||
writeFileSync(getRealHooksJsonPath(), '{ "hooks": {} }\n', 'utf-8')
|
||||
chmodSync(getRealHooksJsonPath(), 0o600)
|
||||
|
||||
expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe(
|
||||
'installed'
|
||||
)
|
||||
|
||||
expect(statSync(getRealHooksJsonPath()).mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'restores restrictive hooks.json permissions after grant fallback',
|
||||
() => {
|
||||
grantUnavailable()
|
||||
'preserves restrictive hooks.json permissions',
|
||||
async () => {
|
||||
grantSucceeds()
|
||||
writeFileSync(getRealHooksJsonPath(), '{ "hooks": {} }\n', 'utf-8')
|
||||
chmodSync(getRealHooksJsonPath(), 0o600)
|
||||
|
||||
expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe(
|
||||
'unavailable'
|
||||
)
|
||||
expect(
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
).toBe('installed')
|
||||
|
||||
expect(statSync(getRealHooksJsonPath()).mode & 0o777).toBe(0o600)
|
||||
}
|
||||
)
|
||||
|
||||
it('rolls the file back byte-exactly when the grant lane is unavailable', () => {
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'restores restrictive hooks.json permissions after grant fallback',
|
||||
async () => {
|
||||
grantUnavailable()
|
||||
writeFileSync(getRealHooksJsonPath(), '{ "hooks": {} }\n', 'utf-8')
|
||||
chmodSync(getRealHooksJsonPath(), 0o600)
|
||||
|
||||
expect(
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
).toBe('unavailable')
|
||||
|
||||
expect(statSync(getRealHooksJsonPath()).mode & 0o777).toBe(0o600)
|
||||
}
|
||||
)
|
||||
|
||||
it('rolls the file back byte-exactly when the grant lane is unavailable', async () => {
|
||||
grantUnavailable()
|
||||
const userRaw = `${JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'mine.sh' }] }] } }, null, 2)}\n`
|
||||
writeFileSync(getRealHooksJsonPath(), userRaw, 'utf-8')
|
||||
|
||||
const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
const lane = await ensureRealHomeCodexHookState({
|
||||
hooksEnabled: true,
|
||||
userDataPath: userDataDir
|
||||
})
|
||||
|
||||
expect(lane).toBe('unavailable')
|
||||
expect(getRealHomeCodexHookLane()).toBe('unavailable')
|
||||
expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(userRaw)
|
||||
})
|
||||
|
||||
it('removes a freshly created hooks.json when the grant lane is unavailable', () => {
|
||||
it('removes a freshly created hooks.json when the grant lane is unavailable', async () => {
|
||||
grantUnavailable()
|
||||
|
||||
const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
const lane = await ensureRealHomeCodexHookState({
|
||||
hooksEnabled: true,
|
||||
userDataPath: userDataDir
|
||||
})
|
||||
|
||||
expect(lane).toBe('unavailable')
|
||||
expect(existsSync(getRealHooksJsonPath())).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces rollback failures to the retry boundary', () => {
|
||||
it('surfaces rollback failures to the retry boundary', async () => {
|
||||
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
grantMock.mockImplementation(() => {
|
||||
rmSync(getRealHooksJsonPath())
|
||||
@@ -281,9 +313,9 @@ describe('ensureRealHomeCodexHookState (install)', () => {
|
||||
return { lane: 'fallback', reason: 'unsupported' }
|
||||
})
|
||||
|
||||
expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe(
|
||||
'unavailable'
|
||||
)
|
||||
expect(
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
).toBe('unavailable')
|
||||
|
||||
expect(warning).toHaveBeenCalledWith(
|
||||
'[codex-real-home-hooks] ensure failed; staying on managed lane:',
|
||||
@@ -291,43 +323,49 @@ describe('ensureRealHomeCodexHookState (install)', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does no hook-file or grant work on repeated unsupported launches', () => {
|
||||
it('does no hook-file or grant work on repeated unsupported launches', async () => {
|
||||
grantUnavailable()
|
||||
expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe(
|
||||
'unavailable'
|
||||
)
|
||||
expect(
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
).toBe('unavailable')
|
||||
expect(existsSync(getRealHooksJsonPath())).toBe(false)
|
||||
|
||||
expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe(
|
||||
'unavailable'
|
||||
)
|
||||
expect(
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
).toBe('unavailable')
|
||||
|
||||
expect(grantMock).toHaveBeenCalledTimes(1)
|
||||
expect(existsSync(getRealHooksJsonPath())).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves an unparseable hooks.json untouched and keeps the managed lane', () => {
|
||||
it('leaves an unparseable hooks.json untouched and keeps the managed lane', async () => {
|
||||
writeFileSync(getRealHooksJsonPath(), '{not json', 'utf-8')
|
||||
|
||||
const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
const lane = await ensureRealHomeCodexHookState({
|
||||
hooksEnabled: true,
|
||||
userDataPath: userDataDir
|
||||
})
|
||||
|
||||
expect(lane).toBe('unavailable')
|
||||
expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe('{not json')
|
||||
expect(grantMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is idempotent: a second ensure keeps a single appended entry per event', () => {
|
||||
it('is idempotent: a second ensure keeps a single appended entry per event', async () => {
|
||||
grantSucceeds()
|
||||
ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
const firstRaw = readFileSync(getRealHooksJsonPath(), 'utf-8')
|
||||
|
||||
const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
const lane = await ensureRealHomeCodexHookState({
|
||||
hooksEnabled: true,
|
||||
userDataPath: userDataDir
|
||||
})
|
||||
|
||||
expect(lane).toBe('installed')
|
||||
expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(firstRaw)
|
||||
})
|
||||
|
||||
it('keeps later user hook trust positions stable when reconciling an existing install', () => {
|
||||
it('keeps later user hook trust positions stable when reconciling an existing install', async () => {
|
||||
grantSucceeds()
|
||||
const userBefore = { hooks: [{ type: 'command', command: 'before.sh' }] }
|
||||
writeFileSync(
|
||||
@@ -335,15 +373,15 @@ describe('ensureRealHomeCodexHookState (install)', () => {
|
||||
`${JSON.stringify({ hooks: { Stop: [userBefore] } }, null, 2)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
const installed = readRealHooksJson()
|
||||
const userAfter = { hooks: [{ type: 'command', command: 'after.sh' }] }
|
||||
installed.hooks!.Stop!.push(userAfter)
|
||||
writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`, 'utf-8')
|
||||
|
||||
expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe(
|
||||
'installed'
|
||||
)
|
||||
expect(
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
).toBe('installed')
|
||||
|
||||
const reconciled = readRealHooksJson().hooks?.Stop
|
||||
expect(reconciled?.[0]).toEqual(userBefore)
|
||||
@@ -352,17 +390,17 @@ describe('ensureRealHomeCodexHookState (install)', () => {
|
||||
expect(plan.managedEntries.find((entry) => entry.eventLabel === 'stop')?.groupIndex).toBe(1)
|
||||
})
|
||||
|
||||
it("keeps later user handler trust positions stable inside Orca's hook group", () => {
|
||||
it("keeps later user handler trust positions stable inside Orca's hook group", async () => {
|
||||
grantSucceeds()
|
||||
ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
const installed = readRealHooksJson()
|
||||
const userAfter = { type: 'command', command: 'after.sh' }
|
||||
installed.hooks!.Stop![0]!.hooks!.push(userAfter)
|
||||
writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`, 'utf-8')
|
||||
|
||||
expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe(
|
||||
'installed'
|
||||
)
|
||||
expect(
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
).toBe('installed')
|
||||
|
||||
expect(readRealHooksJson().hooks?.Stop?.[0]?.hooks?.[1]).toEqual(userAfter)
|
||||
const plan = grantMock.mock.calls.at(-1)![0] as CodexManagedTrustGrantPlan
|
||||
@@ -372,37 +410,37 @@ describe('ensureRealHomeCodexHookState (install)', () => {
|
||||
})
|
||||
|
||||
describe('ensureRealHomeCodexHookState (opt-out sweep)', () => {
|
||||
it('keeps the managed lane when hooks.json cannot be read', () => {
|
||||
it('keeps the managed lane when hooks.json cannot be read', async () => {
|
||||
mkdirSync(getRealHooksJsonPath())
|
||||
|
||||
expect(ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })).toBe(
|
||||
'unavailable'
|
||||
)
|
||||
expect(
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })
|
||||
).toBe('unavailable')
|
||||
})
|
||||
|
||||
it('keeps the managed lane when hooks.json is malformed', () => {
|
||||
it('keeps the managed lane when hooks.json is malformed', async () => {
|
||||
writeFileSync(getRealHooksJsonPath(), '{ not json', 'utf-8')
|
||||
|
||||
expect(ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })).toBe(
|
||||
'unavailable'
|
||||
)
|
||||
expect(
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })
|
||||
).toBe('unavailable')
|
||||
expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe('{ not json')
|
||||
})
|
||||
|
||||
it('rebases trust when a user appended hooks after Orca installed', () => {
|
||||
it('rebases trust when a user appended hooks after Orca installed', async () => {
|
||||
grantSucceeds()
|
||||
const before = { type: 'command', command: 'before.sh' }
|
||||
writeFileSync(
|
||||
getRealHooksJsonPath(),
|
||||
`${JSON.stringify({ hooks: { Stop: [{ hooks: [before] }] } }, null, 2)}\n`
|
||||
)
|
||||
ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
const installed = readRealHooksJson()
|
||||
const after = { type: 'command', command: 'after.sh' }
|
||||
installed.hooks!.Stop!.push({ hooks: [after] })
|
||||
writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`)
|
||||
const operations: string[] = []
|
||||
rebaseInternals.setSessionRunnerSync((request) => {
|
||||
rebaseInternals.setSessionRunner(async (request) => {
|
||||
operations.push(request.operation)
|
||||
if (request.operation === 'inspect-user-hook-trust') {
|
||||
expect(readRealHooksJson().hooks?.Stop?.[2]?.hooks?.[0]?.command).toBe('after.sh')
|
||||
@@ -420,21 +458,21 @@ describe('ensureRealHomeCodexHookState (opt-out sweep)', () => {
|
||||
return { outcome: 'repaired', repaired: 1 }
|
||||
})
|
||||
|
||||
expect(ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })).toBe(
|
||||
'removed'
|
||||
)
|
||||
expect(
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })
|
||||
).toBe('removed')
|
||||
expect(operations).toEqual(['inspect-user-hook-trust', 'repair-user-hook-trust'])
|
||||
expect(readRealHooksJson().hooks?.Stop).toEqual([{ hooks: [before] }, { hooks: [after] }])
|
||||
})
|
||||
|
||||
it('aborts without writing when hooks.json changes during the trust inspection', () => {
|
||||
it('aborts without writing when hooks.json changes during the trust inspection', async () => {
|
||||
grantSucceeds()
|
||||
const before = { type: 'command', command: 'before.sh' }
|
||||
writeFileSync(
|
||||
getRealHooksJsonPath(),
|
||||
`${JSON.stringify({ hooks: { Stop: [{ hooks: [before] }] } }, null, 2)}\n`
|
||||
)
|
||||
ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
const installed = readRealHooksJson()
|
||||
const after = { type: 'command', command: 'after.sh' }
|
||||
installed.hooks!.Stop!.push({ hooks: [after] })
|
||||
@@ -443,7 +481,7 @@ describe('ensureRealHomeCodexHookState (opt-out sweep)', () => {
|
||||
writeFileSync(getRealConfigTomlPath(), userTrustToml, 'utf-8')
|
||||
const concurrentSave = `${JSON.stringify({ hooks: { Stop: [{ hooks: [before] }] } }, null, 2)}\n`
|
||||
const operations: string[] = []
|
||||
rebaseInternals.setSessionRunnerSync((request) => {
|
||||
rebaseInternals.setSessionRunner(async (request) => {
|
||||
operations.push(request.operation)
|
||||
// A user save (or a second Orca instance) lands while the RPC runs.
|
||||
writeFileSync(getRealHooksJsonPath(), concurrentSave, 'utf-8')
|
||||
@@ -458,16 +496,16 @@ describe('ensureRealHomeCodexHookState (opt-out sweep)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })).toBe(
|
||||
'unavailable'
|
||||
)
|
||||
expect(
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })
|
||||
).toBe('unavailable')
|
||||
|
||||
expect(operations).toEqual(['inspect-user-hook-trust'])
|
||||
expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(concurrentSave)
|
||||
expect(readFileSync(getRealConfigTomlPath(), 'utf-8')).toBe(userTrustToml)
|
||||
})
|
||||
|
||||
it('removes only Orca entries and reports the removed lane', () => {
|
||||
it('removes only Orca entries and reports the removed lane', async () => {
|
||||
grantSucceeds()
|
||||
const userStop = {
|
||||
matcher: 'deploy-*',
|
||||
@@ -478,10 +516,13 @@ describe('ensureRealHomeCodexHookState (opt-out sweep)', () => {
|
||||
`${JSON.stringify({ hooks: { Stop: [userStop] } }, null, 2)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
expect(readRealHooksJson().hooks?.Stop).toHaveLength(2)
|
||||
|
||||
const lane = ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })
|
||||
const lane = await ensureRealHomeCodexHookState({
|
||||
hooksEnabled: false,
|
||||
userDataPath: userDataDir
|
||||
})
|
||||
|
||||
expect(lane).toBe('removed')
|
||||
const config = readRealHooksJson()
|
||||
@@ -495,14 +536,17 @@ describe('ensureRealHomeCodexHookState (opt-out sweep)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('no-ops the sweep when the real home has no hooks.json', () => {
|
||||
const lane = ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })
|
||||
it('no-ops the sweep when the real home has no hooks.json', async () => {
|
||||
const lane = await ensureRealHomeCodexHookState({
|
||||
hooksEnabled: false,
|
||||
userDataPath: userDataDir
|
||||
})
|
||||
|
||||
expect(lane).toBe('removed')
|
||||
expect(existsSync(getRealHooksJsonPath())).toBe(false)
|
||||
})
|
||||
|
||||
it('removes only hash-proven Orca trust from a mixed hook group', () => {
|
||||
it('removes only hash-proven Orca trust from a mixed hook group', async () => {
|
||||
const material = getCodexManagedHookInstallMaterial()
|
||||
const userCommand = 'my-user-hook.sh'
|
||||
writeFileSync(
|
||||
@@ -544,9 +588,9 @@ describe('ensureRealHomeCodexHookState (opt-out sweep)', () => {
|
||||
]
|
||||
writeFileSync(getRealConfigTomlPath(), upsertHookTrustEntriesInContent('', entries), 'utf-8')
|
||||
|
||||
expect(ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })).toBe(
|
||||
'removed'
|
||||
)
|
||||
expect(
|
||||
await ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })
|
||||
).toBe('removed')
|
||||
|
||||
expect(readRealHooksJson().hooks?.Stop).toEqual([
|
||||
{ hooks: [{ type: 'command', command: userCommand }] }
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { writeFileAtomically } from '../codex-accounts/fs-utils'
|
||||
import { statSync } from 'node:fs'
|
||||
import {
|
||||
buildManagedCommandHook,
|
||||
createManagedCommandMatcher,
|
||||
MANAGED_HOOK_TIMEOUT_SECONDS,
|
||||
readHooksJsonWithRaw,
|
||||
@@ -13,6 +10,14 @@ import {
|
||||
type HooksConfig
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path'
|
||||
import {
|
||||
assertHooksJsonGeneration,
|
||||
backupRealHomeHooksJsonOnce,
|
||||
getRealHomeConfigTomlPath,
|
||||
getRealHomeHooksJsonPath,
|
||||
reconcileManagedHookDefinition,
|
||||
restoreRealHomeHooksJson
|
||||
} from './codex-real-home-hooks-json'
|
||||
import { getCodexManagedScriptFileName } from './codex-hook-identity'
|
||||
import {
|
||||
CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS,
|
||||
@@ -25,6 +30,7 @@ import { getSystemCodexHomePath } from './codex-home-paths'
|
||||
import type { CodexTrustEntry } from './config-toml-trust'
|
||||
import { restoreCodexTrustConfig } from './codex-trust-config-rollback'
|
||||
import { mutateRealHomeHooksPreservingUserTrust } from './codex-user-hook-trust-rebase'
|
||||
import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue'
|
||||
|
||||
/**
|
||||
* Real-home Codex hook lane for the system-default selection (flag ON).
|
||||
@@ -42,6 +48,7 @@ export type RealHomeCodexHookLane = 'pending' | 'installed' | 'unavailable' | 'r
|
||||
|
||||
let currentLane: RealHomeCodexHookLane = 'pending'
|
||||
let installRetryAfterMs = 0
|
||||
let ensureInFlight: Promise<RealHomeCodexHookLane> = Promise.resolve(currentLane)
|
||||
|
||||
export function getRealHomeCodexHookLane(): RealHomeCodexHookLane {
|
||||
return currentLane
|
||||
@@ -56,52 +63,43 @@ export function isRealHomeCodexHookLaneUsable(): boolean {
|
||||
return currentLane !== 'unavailable'
|
||||
}
|
||||
|
||||
function getRealHomeHooksJsonPath(): string {
|
||||
return join(getSystemCodexHomePath(), 'hooks.json')
|
||||
}
|
||||
|
||||
function getRealHomeConfigTomlPath(): string {
|
||||
return join(getSystemCodexHomePath(), 'config.toml')
|
||||
}
|
||||
|
||||
/** Orca-side state dir; nothing extra is ever written into the user's ~/.codex. */
|
||||
function getRealHomeHookStateDir(userDataPath: string): string {
|
||||
return join(userDataPath, 'codex-real-home-hooks')
|
||||
}
|
||||
|
||||
function assertHooksJsonGeneration(
|
||||
hooksJsonPath: string,
|
||||
hooksWritePath: string,
|
||||
expectedRaw: string | null
|
||||
): void {
|
||||
const currentRaw = existsSync(hooksJsonPath) ? readFileSync(hooksJsonPath, 'utf-8') : null
|
||||
if (currentRaw !== expectedRaw || resolveHooksJsonWritePath(hooksJsonPath) !== hooksWritePath) {
|
||||
// Why: the pre-mutation RPC can overlap a user's editor save. Abort rather
|
||||
// than atomically replacing a newer file with the stale parsed snapshot.
|
||||
throw new Error('Codex hooks.json changed while Orca prepared its trust repair')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the real-home hook state matches the settings: installs and trusts
|
||||
* the Orca status hook when enabled, sweeps it when opted out. Idempotent and
|
||||
* synchronous (launch prep); repeat calls are cheap — an unchanged hooks.json
|
||||
* write no-ops and a valid grant ledger skips the RPC session entirely.
|
||||
* the Orca status hook when enabled, sweeps it when opted out. Idempotent;
|
||||
* repeat calls are cheap — an unchanged hooks.json write no-ops and a valid
|
||||
* grant ledger skips the RPC session entirely.
|
||||
* Never throws: any failure logs and leaves the host on the managed lane.
|
||||
*/
|
||||
export function ensureRealHomeCodexHookState(args: {
|
||||
hooksEnabled: boolean
|
||||
userDataPath: string
|
||||
}): RealHomeCodexHookLane {
|
||||
}): Promise<RealHomeCodexHookLane> {
|
||||
// Why: the grant client caches failed probes, but mutating and rolling back
|
||||
// hooks.json before consulting it still adds synchronous work to every pane.
|
||||
// hooks.json before consulting it still adds work to every pane launch.
|
||||
if (args.hooksEnabled && currentLane === 'unavailable' && Date.now() < installRetryAfterMs) {
|
||||
return currentLane
|
||||
return Promise.resolve(currentLane)
|
||||
}
|
||||
// Why: this mutates the user's real ~/.codex and the module's lane state.
|
||||
// Concurrent pane launches must not interleave two of them, and the shared
|
||||
// config.toml lane keeps the rebase + grant pair atomic against the managed
|
||||
// installer's legacy sweep of the same file.
|
||||
const run = (): Promise<RealHomeCodexHookLane> => runRealHomeCodexHookEnsure(args)
|
||||
// Why both handlers: a rejected predecessor must not poison every later
|
||||
// ensure for the process' lifetime.
|
||||
ensureInFlight = ensureInFlight.then(run, run)
|
||||
return ensureInFlight
|
||||
}
|
||||
|
||||
async function runRealHomeCodexHookEnsure(args: {
|
||||
hooksEnabled: boolean
|
||||
userDataPath: string
|
||||
}): Promise<RealHomeCodexHookLane> {
|
||||
try {
|
||||
currentLane = args.hooksEnabled
|
||||
? installRealHomeCodexHook(args.userDataPath)
|
||||
: sweepRealHomeCodexHook()
|
||||
// Why inside the try: resolving the real home can throw too, and this
|
||||
// function is the module's "never throws" boundary.
|
||||
currentLane = await runExclusivelyForCodexTrustConfig(getRealHomeConfigTomlPath(), () =>
|
||||
args.hooksEnabled ? installRealHomeCodexHook(args.userDataPath) : sweepRealHomeCodexHook()
|
||||
)
|
||||
if (!args.hooksEnabled || currentLane === 'installed') {
|
||||
installRetryAfterMs = 0
|
||||
}
|
||||
@@ -115,7 +113,7 @@ export function ensureRealHomeCodexHookState(args: {
|
||||
return currentLane
|
||||
}
|
||||
|
||||
function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane {
|
||||
async function installRealHomeCodexHook(userDataPath: string): Promise<RealHomeCodexHookLane> {
|
||||
const material = getCodexManagedHookInstallMaterial()
|
||||
const hooksJsonPath = getRealHomeHooksJsonPath()
|
||||
const hooksWritePath = resolveHooksJsonWritePath(hooksJsonPath)
|
||||
@@ -175,7 +173,7 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane {
|
||||
backupRealHomeHooksJsonOnce(userDataPath, previousRaw)
|
||||
// Why: unknown top-level fields belong to the user (other managers'
|
||||
// metadata); unlike the managed-home writer, preserve them verbatim.
|
||||
const trustConfigSnapshot = mutateRealHomeHooksPreservingUserTrust({
|
||||
const trustConfigSnapshot = await mutateRealHomeHooksPreservingUserTrust({
|
||||
sourcePath: hooksJsonPath,
|
||||
runtimeHomePath: getSystemCodexHomePath(),
|
||||
tomlPath: getRealHomeConfigTomlPath(),
|
||||
@@ -190,7 +188,7 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane {
|
||||
restoreHooks: () => restoreRealHomeHooksJson(hooksWritePath, previousRaw, previousMode)
|
||||
})
|
||||
|
||||
const grant = grantManagedCodexHookTrust({
|
||||
const grant = await grantManagedCodexHookTrust({
|
||||
runtimeHomePath: getSystemCodexHomePath(),
|
||||
tomlPath: getRealHomeConfigTomlPath(),
|
||||
managedCommand: material.command,
|
||||
@@ -223,53 +221,13 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane {
|
||||
return 'unavailable'
|
||||
}
|
||||
|
||||
function reconcileManagedHookDefinition(
|
||||
current: HookDefinition[],
|
||||
isManagedCommand: (command: string | undefined) => boolean,
|
||||
command: string
|
||||
): { definitions: HookDefinition[]; groupIndex: number; handlerIndex: number } {
|
||||
const directCommandKeys = ['command', 'bash', 'powershell'] as const
|
||||
const hasManagedDirectCommand = current.some((definition) =>
|
||||
directCommandKeys.some((key) => isManagedCommand(definition[key]))
|
||||
)
|
||||
const nestedLocations = current.flatMap((definition, groupIndex) =>
|
||||
Array.isArray(definition.hooks)
|
||||
? definition.hooks.flatMap((hook, handlerIndex) =>
|
||||
isManagedCommand(hook.command) ? [{ groupIndex, handlerIndex }] : []
|
||||
)
|
||||
: []
|
||||
)
|
||||
if (!hasManagedDirectCommand && nestedLocations.length === 1) {
|
||||
const { groupIndex, handlerIndex } = nestedLocations[0]!
|
||||
const definition = current[groupIndex]!
|
||||
const hasDirectCommand = directCommandKeys.some((key) => typeof definition[key] === 'string')
|
||||
if (definition.matcher === undefined && !hasDirectCommand) {
|
||||
const definitions = [...current]
|
||||
// Why: users can append groups or handlers after Orca's first install.
|
||||
// Reusing the exact slot preserves all later positional trust keys.
|
||||
const hooks = [...definition.hooks!]
|
||||
hooks[handlerIndex] = buildManagedCommandHook(command)
|
||||
definitions[groupIndex] = { ...definition, hooks }
|
||||
return { definitions, groupIndex, handlerIndex }
|
||||
}
|
||||
}
|
||||
|
||||
const cleaned = removeManagedCommands(current, isManagedCommand)
|
||||
// Why: first install appends LAST so no existing user trust position shifts.
|
||||
return {
|
||||
definitions: [...cleaned, { hooks: [buildManagedCommandHook(command)] }],
|
||||
groupIndex: cleaned.length,
|
||||
handlerIndex: 0
|
||||
}
|
||||
}
|
||||
|
||||
function getInstallRetryAfterMs(reason: CodexTrustGrantFallbackReason): number {
|
||||
return reason === 'unsupported' || reason === 'unsupported-cached' || reason === 'disabled'
|
||||
? Number.POSITIVE_INFINITY
|
||||
: Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
|
||||
}
|
||||
|
||||
function sweepRealHomeCodexHook(): RealHomeCodexHookLane {
|
||||
async function sweepRealHomeCodexHook(): Promise<RealHomeCodexHookLane> {
|
||||
const hooksJsonPath = getRealHomeHooksJsonPath()
|
||||
// Why: single read — the pre-write generation guard must compare against
|
||||
// the exact bytes this sweep's parse came from.
|
||||
@@ -306,7 +264,7 @@ function sweepRealHomeCodexHook(): RealHomeCodexHookLane {
|
||||
if (removedAny) {
|
||||
const hooksWritePath = resolveHooksJsonWritePath(hooksJsonPath)
|
||||
const previousMode = statSync(hooksWritePath).mode
|
||||
mutateRealHomeHooksPreservingUserTrust({
|
||||
await mutateRealHomeHooksPreservingUserTrust({
|
||||
sourcePath: hooksJsonPath,
|
||||
runtimeHomePath: getSystemCodexHomePath(),
|
||||
tomlPath: getRealHomeConfigTomlPath(),
|
||||
@@ -345,41 +303,10 @@ function sweepRealHomeCodexHook(): RealHomeCodexHookLane {
|
||||
return 'removed'
|
||||
}
|
||||
|
||||
/** One-time pristine copy of the user's file, kept under Orca's userData. */
|
||||
function backupRealHomeHooksJsonOnce(userDataPath: string, previousRaw: string | null): void {
|
||||
if (previousRaw === null) {
|
||||
return
|
||||
}
|
||||
const backupDir = getRealHomeHookStateDir(userDataPath)
|
||||
const backupPath = join(backupDir, 'hooks.json.pre-orca')
|
||||
if (existsSync(backupPath)) {
|
||||
return
|
||||
}
|
||||
// Why: this lane mutates the user's real Codex home. If the required
|
||||
// pristine recovery copy cannot be created, keep the managed lane intact.
|
||||
mkdirSync(backupDir, { recursive: true })
|
||||
writeFileAtomically(backupPath, previousRaw, { mode: 0o600 })
|
||||
}
|
||||
|
||||
function restoreRealHomeHooksJson(
|
||||
hooksJsonPath: string,
|
||||
previousRaw: string | null,
|
||||
previousMode?: number
|
||||
): void {
|
||||
if (previousRaw === null) {
|
||||
if (existsSync(hooksJsonPath)) {
|
||||
unlinkSync(hooksJsonPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Why: rollback is part of the safety boundary. Use the shared atomic
|
||||
// writer so Windows file-lock retries and failed-temp cleanup are covered.
|
||||
writeFileAtomically(hooksJsonPath, previousRaw, { mode: previousMode })
|
||||
}
|
||||
|
||||
export const _internals = {
|
||||
setLaneForTesting(lane: RealHomeCodexHookLane): void {
|
||||
currentLane = lane
|
||||
installRetryAfterMs = 0
|
||||
ensureInFlight = Promise.resolve(lane)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { writeFileAtomically } from '../codex-accounts/fs-utils'
|
||||
import {
|
||||
buildManagedCommandHook,
|
||||
removeManagedCommands,
|
||||
type HookDefinition
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path'
|
||||
import { getSystemCodexHomePath } from './codex-home-paths'
|
||||
|
||||
/** The user's real `~/.codex` hook files, plus the guards and rollback the
|
||||
* real-home lane needs before it is allowed to mutate them. */
|
||||
export function getRealHomeHooksJsonPath(): string {
|
||||
return join(getSystemCodexHomePath(), 'hooks.json')
|
||||
}
|
||||
|
||||
export function getRealHomeConfigTomlPath(): string {
|
||||
return join(getSystemCodexHomePath(), 'config.toml')
|
||||
}
|
||||
|
||||
/** Orca-side state dir; nothing extra is ever written into the user's ~/.codex. */
|
||||
function getRealHomeHookStateDir(userDataPath: string): string {
|
||||
return join(userDataPath, 'codex-real-home-hooks')
|
||||
}
|
||||
|
||||
export function assertHooksJsonGeneration(
|
||||
hooksJsonPath: string,
|
||||
hooksWritePath: string,
|
||||
expectedRaw: string | null
|
||||
): void {
|
||||
const currentRaw = existsSync(hooksJsonPath) ? readFileSync(hooksJsonPath, 'utf-8') : null
|
||||
if (currentRaw !== expectedRaw || resolveHooksJsonWritePath(hooksJsonPath) !== hooksWritePath) {
|
||||
// Why: the pre-mutation RPC can overlap a user's editor save. Abort rather
|
||||
// than atomically replacing a newer file with the stale parsed snapshot.
|
||||
throw new Error('Codex hooks.json changed while Orca prepared its trust repair')
|
||||
}
|
||||
}
|
||||
|
||||
/** One-time pristine copy of the user's file, kept under Orca's userData. */
|
||||
export function backupRealHomeHooksJsonOnce(
|
||||
userDataPath: string,
|
||||
previousRaw: string | null
|
||||
): void {
|
||||
if (previousRaw === null) {
|
||||
return
|
||||
}
|
||||
const backupDir = getRealHomeHookStateDir(userDataPath)
|
||||
const backupPath = join(backupDir, 'hooks.json.pre-orca')
|
||||
if (existsSync(backupPath)) {
|
||||
return
|
||||
}
|
||||
// Why: this lane mutates the user's real Codex home. If the required
|
||||
// pristine recovery copy cannot be created, keep the managed lane intact.
|
||||
mkdirSync(backupDir, { recursive: true })
|
||||
writeFileAtomically(backupPath, previousRaw, { mode: 0o600 })
|
||||
}
|
||||
|
||||
export function restoreRealHomeHooksJson(
|
||||
hooksJsonPath: string,
|
||||
previousRaw: string | null,
|
||||
previousMode?: number
|
||||
): void {
|
||||
if (previousRaw === null) {
|
||||
if (existsSync(hooksJsonPath)) {
|
||||
unlinkSync(hooksJsonPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Why: rollback is part of the safety boundary. Use the shared atomic
|
||||
// writer so Windows file-lock retries and failed-temp cleanup are covered.
|
||||
writeFileAtomically(hooksJsonPath, previousRaw, { mode: previousMode })
|
||||
}
|
||||
|
||||
/** Places Orca's managed hook in `definitions`, reusing its existing slot when
|
||||
* one is unambiguous so no later user trust position shifts. */
|
||||
export function reconcileManagedHookDefinition(
|
||||
current: HookDefinition[],
|
||||
isManagedCommand: (command: string | undefined) => boolean,
|
||||
command: string
|
||||
): { definitions: HookDefinition[]; groupIndex: number; handlerIndex: number } {
|
||||
const directCommandKeys = ['command', 'bash', 'powershell'] as const
|
||||
const hasManagedDirectCommand = current.some((definition) =>
|
||||
directCommandKeys.some((key) => isManagedCommand(definition[key]))
|
||||
)
|
||||
const nestedLocations = current.flatMap((definition, groupIndex) =>
|
||||
Array.isArray(definition.hooks)
|
||||
? definition.hooks.flatMap((hook, handlerIndex) =>
|
||||
isManagedCommand(hook.command) ? [{ groupIndex, handlerIndex }] : []
|
||||
)
|
||||
: []
|
||||
)
|
||||
if (!hasManagedDirectCommand && nestedLocations.length === 1) {
|
||||
const { groupIndex, handlerIndex } = nestedLocations[0]!
|
||||
const definition = current[groupIndex]!
|
||||
const hasDirectCommand = directCommandKeys.some((key) => typeof definition[key] === 'string')
|
||||
if (definition.matcher === undefined && !hasDirectCommand) {
|
||||
const definitions = [...current]
|
||||
// Why: users can append groups or handlers after Orca's first install.
|
||||
// Reusing the exact slot preserves all later positional trust keys.
|
||||
const hooks = [...definition.hooks!]
|
||||
hooks[handlerIndex] = buildManagedCommandHook(command)
|
||||
definitions[groupIndex] = { ...definition, hooks }
|
||||
return { definitions, groupIndex, handlerIndex }
|
||||
}
|
||||
}
|
||||
|
||||
const cleaned = removeManagedCommands(current, isManagedCommand)
|
||||
// Why: first install appends LAST so no existing user trust position shifts.
|
||||
return {
|
||||
definitions: [...cleaned, { hooks: [buildManagedCommandHook(command)] }],
|
||||
groupIndex: cleaned.length,
|
||||
handlerIndex: 0
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { createReadStream, type Stats } from 'node:fs'
|
||||
import type { Stats } from 'node:fs'
|
||||
import { appendFile, mkdir } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import { streamCodexSessionLedgerRecords } from './codex-session-ledger-stream'
|
||||
import type { CodexSessionBackfillSummary } from './codex-session-backfill-types'
|
||||
|
||||
export type CodexSessionBackfillAuditWriter = (record: Record<string, unknown>) => Promise<boolean>
|
||||
@@ -68,38 +68,23 @@ export async function readCodexSessionBackfillAuditCoverage(
|
||||
diagnosticEventIds: new Set<string>(),
|
||||
hasRunSummary: false
|
||||
}
|
||||
const input = createReadStream(auditLogPath, { encoding: 'utf-8' })
|
||||
const lines = createInterface({ input, crlfDelay: Infinity })
|
||||
try {
|
||||
for await (const raw of lines) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
continue
|
||||
}
|
||||
const record = parsed as Record<string, unknown>
|
||||
coverage.hasRunSummary ||= record.action === 'run-summary'
|
||||
if (
|
||||
typeof record.action === 'string' &&
|
||||
HEAL_AUDIT_ACTIONS.has(record.action) &&
|
||||
typeof record.fileEventId === 'string'
|
||||
) {
|
||||
coverage.fileEventIds.add(record.fileEventId)
|
||||
}
|
||||
if (
|
||||
typeof record.action === 'string' &&
|
||||
DIAGNOSTIC_AUDIT_ACTIONS.has(record.action) &&
|
||||
typeof record.diagnosticEventId === 'string'
|
||||
) {
|
||||
coverage.diagnosticEventIds.add(record.diagnosticEventId)
|
||||
}
|
||||
} catch {
|
||||
// Torn audit tails are quarantined by the writer's leading newline.
|
||||
}
|
||||
for await (const record of streamCodexSessionLedgerRecords(auditLogPath, {
|
||||
throwOnReadFailure: true
|
||||
})) {
|
||||
coverage.hasRunSummary ||= record.action === 'run-summary'
|
||||
if (
|
||||
typeof record.action === 'string' &&
|
||||
HEAL_AUDIT_ACTIONS.has(record.action) &&
|
||||
typeof record.fileEventId === 'string'
|
||||
) {
|
||||
coverage.fileEventIds.add(record.fileEventId)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isNotFoundError(error)) {
|
||||
throw error
|
||||
if (
|
||||
typeof record.action === 'string' &&
|
||||
DIAGNOSTIC_AUDIT_ACTIONS.has(record.action) &&
|
||||
typeof record.diagnosticEventId === 'string'
|
||||
) {
|
||||
coverage.diagnosticEventIds.add(record.diagnosticEventId)
|
||||
}
|
||||
}
|
||||
return coverage
|
||||
@@ -192,7 +177,3 @@ export async function recordExistingCodexSessionForHeal(
|
||||
...(fileEventId ? { fileEventId } : {})
|
||||
})
|
||||
}
|
||||
|
||||
function isNotFoundError(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
||||
}
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import { isCodexSessionBackfillDate } from './codex-session-backfill-scan-dates'
|
||||
import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing'
|
||||
import type {
|
||||
CodexSessionBackfillDate,
|
||||
CodexSessionBackfillOptions
|
||||
} from './codex-session-backfill-types'
|
||||
|
||||
export function getCodexSessionBackfillDate(date = new Date()): CodexSessionBackfillDate {
|
||||
return [
|
||||
String(date.getUTCFullYear()).padStart(4, '0'),
|
||||
String(date.getUTCMonth() + 1).padStart(2, '0'),
|
||||
String(date.getUTCDate()).padStart(2, '0')
|
||||
]
|
||||
}
|
||||
|
||||
export function isCodexSessionRolloutPath(sessionsRoot: string, filePath: string): boolean {
|
||||
const pathParts = relative(sessionsRoot, filePath).split(sep)
|
||||
if (pathParts.length !== 4) {
|
||||
return false
|
||||
}
|
||||
const [year, month, day, fileName] = pathParts
|
||||
return (
|
||||
/^\d{4}$/.test(year) &&
|
||||
/^\d{2}$/.test(month) &&
|
||||
/^\d{2}$/.test(day) &&
|
||||
/^rollout-.+\.jsonl$/.test(fileName)
|
||||
)
|
||||
return isCodexSessionBackfillDate([year, month, day]) && /^rollout-.+\.jsonl$/.test(fileName)
|
||||
}
|
||||
|
||||
export async function* listCodexSessionBackfillFilesForDates(
|
||||
@@ -54,9 +42,7 @@ function resolveCodexSessionBackfillDateRoots(
|
||||
return [sessionsRoot]
|
||||
}
|
||||
return scanDates
|
||||
.filter(
|
||||
([year, month, day]) => /^\d{4}$/.test(year) && /^\d{2}$/.test(month) && /^\d{2}$/.test(day)
|
||||
)
|
||||
.filter(isCodexSessionBackfillDate)
|
||||
.map(([year, month, day]) => join(sessionsRoot, year, month, day))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import type * as NodeFs from 'node:fs'
|
||||
import type * as NodeFsPromises from 'node:fs/promises'
|
||||
|
||||
// Fault-injection doubles for the backfill tests. Kept out of the spec files so
|
||||
// several suites can drive the same failure modes from one switchboard.
|
||||
|
||||
export const fsMockState = {
|
||||
failLink: false,
|
||||
failLinkTransiently: false,
|
||||
failLinkPermission: false,
|
||||
raceTargetIntoExistence: false,
|
||||
failMarkerRm: false,
|
||||
failMarkerReplacement: false,
|
||||
failAuditMkdirOnce: false,
|
||||
failAuditWrites: false,
|
||||
failMkdirPath: null as string | null,
|
||||
failDirectoryPath: null as string | null,
|
||||
failLstatPath: null as string | null
|
||||
}
|
||||
|
||||
export function resetCodexSessionBackfillFsMocks(): void {
|
||||
fsMockState.failLink = false
|
||||
fsMockState.failLinkTransiently = false
|
||||
fsMockState.failLinkPermission = false
|
||||
fsMockState.raceTargetIntoExistence = false
|
||||
fsMockState.failMarkerRm = false
|
||||
fsMockState.failMarkerReplacement = false
|
||||
fsMockState.failAuditMkdirOnce = false
|
||||
fsMockState.failAuditWrites = false
|
||||
fsMockState.failMkdirPath = null
|
||||
fsMockState.failDirectoryPath = null
|
||||
fsMockState.failLstatPath = null
|
||||
}
|
||||
|
||||
function errnoError(message: string, code: string): NodeJS.ErrnoException {
|
||||
const error = new Error(message) as NodeJS.ErrnoException
|
||||
error.code = code
|
||||
return error
|
||||
}
|
||||
|
||||
function isMarkerPath(value: unknown): boolean {
|
||||
return (
|
||||
String(value).includes('codex-session-backfill') &&
|
||||
String(value).endsWith('backfill-complete.json')
|
||||
)
|
||||
}
|
||||
|
||||
export function createNodeFsMock(actual: typeof NodeFs): typeof NodeFs {
|
||||
return {
|
||||
...actual,
|
||||
existsSync: (...args: Parameters<typeof actual.existsSync>) =>
|
||||
args[0] === fsMockState.failLstatPath ? false : actual.existsSync(...args),
|
||||
rmSync: (...args: Parameters<typeof actual.rmSync>) => {
|
||||
if (fsMockState.failMarkerRm && isMarkerPath(args[0])) {
|
||||
throw errnoError('EACCES: marker removal failed', 'EACCES')
|
||||
}
|
||||
return actual.rmSync(...args)
|
||||
},
|
||||
renameSync: (...args: Parameters<typeof actual.renameSync>) => {
|
||||
if (fsMockState.failMarkerReplacement && isMarkerPath(args[1])) {
|
||||
throw errnoError('EACCES: marker replacement failed', 'EACCES')
|
||||
}
|
||||
return actual.renameSync(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createNodeFsPromisesMock(actual: typeof NodeFsPromises): typeof NodeFsPromises {
|
||||
return {
|
||||
...actual,
|
||||
mkdir: (...args: Parameters<typeof actual.mkdir>) => {
|
||||
if (args[0] === fsMockState.failMkdirPath) {
|
||||
throw errnoError('EACCES: target directory inaccessible', 'EACCES')
|
||||
}
|
||||
if (fsMockState.failAuditMkdirOnce && String(args[0]).includes('codex-session-backfill')) {
|
||||
fsMockState.failAuditMkdirOnce = false
|
||||
throw errnoError('EACCES: transient audit directory failure', 'EACCES')
|
||||
}
|
||||
return actual.mkdir(...args)
|
||||
},
|
||||
appendFile: (...args: Parameters<typeof actual.appendFile>) => {
|
||||
if (fsMockState.failAuditWrites && String(args[0]).includes('codex-session-backfill')) {
|
||||
throw errnoError('ENOSPC: audit write failed', 'ENOSPC')
|
||||
}
|
||||
return actual.appendFile(...args)
|
||||
},
|
||||
lstat: (...args: Parameters<typeof actual.lstat>) => {
|
||||
if (args[0] === fsMockState.failLstatPath) {
|
||||
throw errnoError('EACCES: path inaccessible', 'EACCES')
|
||||
}
|
||||
return actual.lstat(...args)
|
||||
},
|
||||
link: async (...args: Parameters<typeof actual.link>) => {
|
||||
if (fsMockState.raceTargetIntoExistence && String(args[0]).includes('codex-runtime-home')) {
|
||||
fsMockState.raceTargetIntoExistence = false
|
||||
await actual.writeFile(args[1], 'concurrent target\n', 'utf-8')
|
||||
throw errnoError('EEXIST: concurrent target', 'EEXIST')
|
||||
}
|
||||
if (fsMockState.failLink && String(args[0]).includes('codex-runtime-home')) {
|
||||
throw errnoError('EXDEV: cross-device link', 'EXDEV')
|
||||
}
|
||||
if (fsMockState.failLinkTransiently && String(args[0]).includes('codex-runtime-home')) {
|
||||
throw errnoError('EIO: transient hardlink failure', 'EIO')
|
||||
}
|
||||
if (fsMockState.failLinkPermission && String(args[0]).includes('codex-runtime-home')) {
|
||||
throw errnoError('EACCES: hardlink permission denied', 'EACCES')
|
||||
}
|
||||
return actual.link(...args)
|
||||
},
|
||||
opendir: (...args: Parameters<typeof actual.opendir>) => {
|
||||
if (args[0] === fsMockState.failDirectoryPath) {
|
||||
throw errnoError('EACCES: directory unreadable', 'EACCES')
|
||||
}
|
||||
return actual.opendir(...args)
|
||||
}
|
||||
} as typeof NodeFsPromises
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
captureCodexSessionBackfillMarkerGeneration,
|
||||
hasCompletedCodexSessionBackfillMarker,
|
||||
markCodexSessionBackfillMarkerPending,
|
||||
readCodexSessionBackfillBaseline,
|
||||
writeCodexSessionBackfillMarker
|
||||
} from './codex-session-backfill-marker'
|
||||
import {
|
||||
getCodexSessionBackfillDate,
|
||||
getCodexSessionBackfillDatesBetween
|
||||
} from './codex-session-backfill-scan-dates'
|
||||
import type {
|
||||
CodexSessionBackfillDate,
|
||||
CodexSessionBackfillSummary
|
||||
} from './codex-session-backfill-types'
|
||||
|
||||
const WINDOWS_ROOT = 'C:\\Users\\Me\\.codex\\sessions'
|
||||
const TODAY = getCodexSessionBackfillDate()
|
||||
const LAUNCH_DATE: CodexSessionBackfillDate = ['2026', '08', '05']
|
||||
|
||||
let stateDir: string
|
||||
let markerPath: string
|
||||
|
||||
function createSummary(scannedFiles = 3): CodexSessionBackfillSummary {
|
||||
return {
|
||||
stopped: false,
|
||||
scannedFiles,
|
||||
linkedFiles: scannedFiles,
|
||||
copiedFiles: 0,
|
||||
skippedExistingFiles: 0,
|
||||
skippedUnexpectedFiles: 0,
|
||||
skippedSymlinkFiles: 0,
|
||||
skippedUnsupportedFilesystemFiles: 0,
|
||||
failedDirectories: 0,
|
||||
failedFiles: 0,
|
||||
failedHealAuditRecords: 0
|
||||
}
|
||||
}
|
||||
|
||||
function writeFullBaseline(
|
||||
root: string,
|
||||
options: { coveredScanDates?: readonly CodexSessionBackfillDate[]; retain?: boolean } = {}
|
||||
): void {
|
||||
writeCodexSessionBackfillMarker(
|
||||
markerPath,
|
||||
root,
|
||||
createSummary(),
|
||||
captureCodexSessionBackfillMarkerGeneration(),
|
||||
{
|
||||
coverage: 'full',
|
||||
coveredScanDates: options.coveredScanDates ?? [],
|
||||
retainPendingScanDates: options.retain
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** More dates than MAX_PENDING_SCAN_DATES, so the marker gives up on bounding. */
|
||||
function overflowingDates(): CodexSessionBackfillDate[] {
|
||||
return getCodexSessionBackfillDatesBetween(
|
||||
new Date(Date.UTC(2026, 6, 1)),
|
||||
new Date(Date.UTC(2026, 7, 9))
|
||||
)
|
||||
}
|
||||
|
||||
function readMarker(): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(markerPath, 'utf-8')) as Record<string, unknown>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stateDir = mkdtempSync(join(tmpdir(), 'orca-codex-marker-'))
|
||||
markerPath = join(stateDir, 'backfill-complete.json')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(stateDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('codex session backfill marker', () => {
|
||||
it('treats Windows spellings of one directory as the same target', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
|
||||
for (const alias of ['C:/Users/Me/.codex/sessions', 'c:\\users\\me\\.codex\\sessions']) {
|
||||
expect(hasCompletedCodexSessionBackfillMarker(markerPath, alias)).toBe(true)
|
||||
}
|
||||
expect(
|
||||
hasCompletedCodexSessionBackfillMarker(markerPath, 'C:\\Users\\Me\\other-codex\\sessions')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('reads a legacy v3 marker as a certified baseline with nothing pending', () => {
|
||||
writeFileSync(
|
||||
markerPath,
|
||||
`${JSON.stringify({
|
||||
version: 3,
|
||||
systemSessionsRoot: WINDOWS_ROOT,
|
||||
completedAt: Date.now(),
|
||||
summary: { scannedFiles: 12 }
|
||||
})}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect(readCodexSessionBackfillBaseline(markerPath, 'c:/users/me/.codex/sessions')).toEqual({
|
||||
pendingScanDates: []
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps honoring the v3 empty-source guard', () => {
|
||||
writeFileSync(
|
||||
markerPath,
|
||||
`${JSON.stringify({
|
||||
version: 3,
|
||||
systemSessionsRoot: WINDOWS_ROOT,
|
||||
summary: { scannedFiles: 0 }
|
||||
})}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect(readCodexSessionBackfillBaseline(markerPath, WINDOWS_ROOT)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not treat a bounded pass that scanned nothing as an empty source', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
writeCodexSessionBackfillMarker(
|
||||
markerPath,
|
||||
WINDOWS_ROOT,
|
||||
createSummary(0),
|
||||
captureCodexSessionBackfillMarkerGeneration(),
|
||||
{ coverage: 'bounded', coveredScanDates: [LAUNCH_DATE] }
|
||||
)
|
||||
|
||||
expect(readMarker()).toMatchObject({ baselineScannedFiles: 3 })
|
||||
expect(hasCompletedCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT)).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses to let a bounded pass invent a baseline it never verified', () => {
|
||||
writeCodexSessionBackfillMarker(
|
||||
markerPath,
|
||||
WINDOWS_ROOT,
|
||||
createSummary(),
|
||||
captureCodexSessionBackfillMarkerGeneration(),
|
||||
{ coverage: 'bounded', coveredScanDates: [LAUNCH_DATE] }
|
||||
)
|
||||
|
||||
expect(hasCompletedCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT)).toBe(false)
|
||||
})
|
||||
|
||||
it('records a launch date without destroying the historical baseline', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
|
||||
markCodexSessionBackfillMarkerPending(markerPath, 'C:/Users/Me/.codex/sessions', [LAUNCH_DATE])
|
||||
|
||||
expect(readMarker()).toMatchObject({ coverage: 'full', pendingScanDates: [LAUNCH_DATE] })
|
||||
expect(readCodexSessionBackfillBaseline(markerPath, WINDOWS_ROOT)).toEqual({
|
||||
pendingScanDates: [LAUNCH_DATE]
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a marker for a different history untouched', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
|
||||
markCodexSessionBackfillMarkerPending(markerPath, 'D:\\other\\.codex\\sessions', [LAUNCH_DATE])
|
||||
|
||||
expect(readMarker()).toMatchObject({ systemSessionsRoot: WINDOWS_ROOT, pendingScanDates: [] })
|
||||
})
|
||||
|
||||
it('keeps a racing launch date pending when an older pass publishes', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
const staleGeneration = captureCodexSessionBackfillMarkerGeneration()
|
||||
markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, [LAUNCH_DATE])
|
||||
|
||||
writeCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT, createSummary(), staleGeneration, {
|
||||
coverage: 'bounded',
|
||||
coveredScanDates: [LAUNCH_DATE]
|
||||
})
|
||||
|
||||
expect(readMarker()).toMatchObject({ pendingScanDates: [LAUNCH_DATE] })
|
||||
})
|
||||
|
||||
it('keeps the pane date pending while the pane is still live', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT, { coveredScanDates: [LAUNCH_DATE], retain: true })
|
||||
|
||||
expect(readMarker()).toMatchObject({ launchActive: true, pendingScanDates: [LAUNCH_DATE] })
|
||||
})
|
||||
|
||||
it('settles every pending date once a full walk covers them', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, [LAUNCH_DATE])
|
||||
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
|
||||
// The walk looked at every date, so nothing is left to revisit.
|
||||
expect(readMarker()).toMatchObject({ pendingScanDates: [] })
|
||||
})
|
||||
|
||||
it('demands a full walk once the pending window outgrows its bound', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
|
||||
expect(
|
||||
markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, overflowingDates())
|
||||
).toBe(true)
|
||||
|
||||
expect(readMarker()).toMatchObject({ needsFullScan: true, pendingScanDates: [] })
|
||||
expect(hasCompletedCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps owing that full walk when the next launch records its own date', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, overflowingDates())
|
||||
|
||||
expect(markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, [TODAY])).toBe(true)
|
||||
|
||||
expect(readMarker()).toMatchObject({ needsFullScan: true, pendingScanDates: [] })
|
||||
expect(hasCompletedCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the full-walk demand when a later launch overtook the walk', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
const staleGeneration = captureCodexSessionBackfillMarkerGeneration()
|
||||
markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, overflowingDates())
|
||||
|
||||
writeCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT, createSummary(), staleGeneration, {
|
||||
coverage: 'full',
|
||||
coveredScanDates: []
|
||||
})
|
||||
|
||||
// The walk may have passed those dates before their rollouts existed.
|
||||
expect(readMarker()).toMatchObject({ needsFullScan: true })
|
||||
})
|
||||
|
||||
it('clears the full-walk demand only once a full walk certifies the tree', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, overflowingDates())
|
||||
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
|
||||
expect(readMarker()).toMatchObject({ needsFullScan: false, pendingScanDates: [] })
|
||||
expect(hasCompletedCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT)).toBe(true)
|
||||
})
|
||||
|
||||
it('persists a launch date even before any baseline exists', () => {
|
||||
markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, [TODAY])
|
||||
|
||||
expect(readMarker()).toMatchObject({ coverage: 'bounded', pendingScanDates: [TODAY] })
|
||||
expect(hasCompletedCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,91 +1,264 @@
|
||||
import { mkdirSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import { writeFileAtomically } from '../codex-accounts/fs-utils'
|
||||
import type { CodexSessionBackfillSummary } from './codex-session-backfill-types'
|
||||
import {
|
||||
expandCodexSessionBackfillDatesThroughToday,
|
||||
getCodexSessionBackfillDate,
|
||||
mergeCodexSessionBackfillDates,
|
||||
parseCodexSessionBackfillDates,
|
||||
subtractCodexSessionBackfillDates
|
||||
} from './codex-session-backfill-scan-dates'
|
||||
import type {
|
||||
CodexSessionBackfillDate,
|
||||
CodexSessionBackfillSummary
|
||||
} from './codex-session-backfill-types'
|
||||
|
||||
// Why: bump to re-run the backfill for every host after a layout or semantics
|
||||
// change; the run itself stays skip-existing so re-runs never overwrite.
|
||||
const CODEX_SESSION_BACKFILL_MARKER_VERSION = 3
|
||||
const CODEX_SESSION_BACKFILL_MARKER_VERSION = 4
|
||||
// Why: v3 was only ever written after a certified full-tree walk, so it reads
|
||||
// as a v4 baseline and existing installs never pay one more full scan.
|
||||
const MARKER_BASELINE_VERSIONS: ReadonlySet<number> = new Set([3, 4])
|
||||
// Why: bounds abnormal-exit recovery; past this many dates a full walk is the
|
||||
// cheaper certainty.
|
||||
const MAX_PENDING_SCAN_DATES = 31
|
||||
|
||||
let markerInvalidationGeneration = 0
|
||||
|
||||
export type CodexSessionBackfillBaseline = {
|
||||
/** Dates whose managed rollouts may not be published yet, widened through today. */
|
||||
pendingScanDates: readonly CodexSessionBackfillDate[]
|
||||
}
|
||||
|
||||
export function captureCodexSessionBackfillMarkerGeneration(): number {
|
||||
return markerInvalidationGeneration
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the certified full-history baseline for this target, if one exists.
|
||||
*
|
||||
* Null means no usable baseline, which is the only state that justifies a
|
||||
* full-tree walk. A baseline with pending dates still needs a bounded pass.
|
||||
*/
|
||||
export function readCodexSessionBackfillBaseline(
|
||||
markerPath: string,
|
||||
systemSessionsRoot: string,
|
||||
today: CodexSessionBackfillDate = getCodexSessionBackfillDate()
|
||||
): CodexSessionBackfillBaseline | null {
|
||||
const record = readMarkerRecord(markerPath)
|
||||
if (!record?.hasBaseline || !matchesTargetRoot(record, systemSessionsRoot)) {
|
||||
return null
|
||||
}
|
||||
// Why: an empty source can become populated after an early migration run;
|
||||
// let the incremental async walk verify it without blocking the main thread.
|
||||
if (record.baselineScannedFiles === 0 || record.needsFullScan) {
|
||||
return null
|
||||
}
|
||||
// Why: only a pane that was still running when the pass ended can have written
|
||||
// dates nobody recorded — a pane held open across midnight, then force-quit.
|
||||
const pendingScanDates = record.launchActive
|
||||
? expandCodexSessionBackfillDatesThroughToday(
|
||||
record.pendingScanDates,
|
||||
today,
|
||||
MAX_PENDING_SCAN_DATES
|
||||
)
|
||||
: record.pendingScanDates
|
||||
return pendingScanDates ? { pendingScanDates } : null
|
||||
}
|
||||
|
||||
export function hasCompletedCodexSessionBackfillMarker(
|
||||
markerPath: string,
|
||||
systemSessionsRoot: string
|
||||
): boolean {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(markerPath, 'utf-8'))
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return false
|
||||
}
|
||||
const marker = parsed as {
|
||||
version?: unknown
|
||||
systemSessionsRoot?: unknown
|
||||
summary?: { scannedFiles?: unknown }
|
||||
}
|
||||
// Why: changing the configured real Codex home must backfill the new
|
||||
// target instead of honoring a marker written for a different history.
|
||||
const markerMatchesTarget =
|
||||
marker.version === CODEX_SESSION_BACKFILL_MARKER_VERSION &&
|
||||
marker.systemSessionsRoot === systemSessionsRoot
|
||||
if (!markerMatchesTarget) {
|
||||
return false
|
||||
}
|
||||
// Why: an empty source can become populated after an early migration run;
|
||||
// let the incremental async walk verify it without blocking the main thread.
|
||||
return marker.summary?.scannedFiles !== 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return readCodexSessionBackfillBaseline(markerPath, systemSessionsRoot) !== null
|
||||
}
|
||||
|
||||
export function writeCodexSessionBackfillMarker(
|
||||
markerPath: string,
|
||||
systemSessionsRoot: string,
|
||||
summary: CodexSessionBackfillSummary,
|
||||
expectedGeneration: number
|
||||
expectedGeneration: number,
|
||||
options: {
|
||||
/** A full pass certifies the whole tree; a bounded one may only extend that. */
|
||||
coverage: 'full' | 'bounded'
|
||||
coveredScanDates: readonly CodexSessionBackfillDate[]
|
||||
/** A live Codex pane keeps writing into its own date, so it stays pending. */
|
||||
retainPendingScanDates?: boolean
|
||||
}
|
||||
): void {
|
||||
// Why: a launch can invalidate this pass before its delayed replacement begins.
|
||||
if (expectedGeneration !== markerInvalidationGeneration) {
|
||||
const record = readMarkerRecord(markerPath)
|
||||
const current = record && matchesTargetRoot(record, systemSessionsRoot) ? record : null
|
||||
// Why: a date-limited pass cannot certify the dates it never looked at, so
|
||||
// without an existing baseline it must publish nothing at all.
|
||||
if (options.coverage !== 'full' && !current?.hasBaseline) {
|
||||
return
|
||||
}
|
||||
mkdirSync(dirname(markerPath), { recursive: true })
|
||||
writeFileAtomically(
|
||||
markerPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: CODEX_SESSION_BACKFILL_MARKER_VERSION,
|
||||
systemSessionsRoot,
|
||||
completedAt: Date.now(),
|
||||
summary
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
// Why: a launch that began during this pass can have written rollouts the
|
||||
// walk had already gone past, so its dates must survive as pending.
|
||||
const generationCurrent = expectedGeneration === markerInvalidationGeneration
|
||||
const clearCovered = generationCurrent && options.retainPendingScanDates !== true
|
||||
const currentPendingScanDates = current?.pendingScanDates ?? []
|
||||
// Why: a full walk speaks for every date, so it settles the whole pending set
|
||||
// rather than only the dates a bounded pass was asked to look at.
|
||||
const coveredScanDates =
|
||||
options.coverage === 'full' ? currentPendingScanDates : options.coveredScanDates
|
||||
const pendingScanDates = clearCovered
|
||||
? subtractCodexSessionBackfillDates(currentPendingScanDates, coveredScanDates)
|
||||
: mergeCodexSessionBackfillDates(currentPendingScanDates, options.coveredScanDates)
|
||||
writeMarkerRecord(markerPath, {
|
||||
...current?.raw,
|
||||
version: CODEX_SESSION_BACKFILL_MARKER_VERSION,
|
||||
systemSessionsRoot,
|
||||
// Why: only reached with a baseline in hand, so this records that a baseline
|
||||
// exists, not the scope of this particular pass.
|
||||
coverage: 'full',
|
||||
completedAt: Date.now(),
|
||||
launchActive: options.retainPendingScanDates === true,
|
||||
// Why: only a full walk can speak for the whole tree, so a bounded pass
|
||||
// that legitimately scanned nothing must not read back as an empty source.
|
||||
baselineScannedFiles:
|
||||
options.coverage === 'full' ? summary.scannedFiles : current?.baselineScannedFiles,
|
||||
summary,
|
||||
// Why: only a full walk that no later launch overtook can retire the demand.
|
||||
...describePendingScanDates(
|
||||
pendingScanDates,
|
||||
current?.needsFullScan === true && !(generationCurrent && options.coverage === 'full')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function invalidateCodexSessionBackfillMarker(markerPath: string): void {
|
||||
/**
|
||||
* Records dates a launch may still be writing into, keeping the baseline.
|
||||
*
|
||||
* Why not delete: the marker is the only record that the full history was ever
|
||||
* published. Dropping it makes every launch re-walk the whole sessions tree.
|
||||
*
|
||||
* Returns whether a full walk is still owed for this target, so the caller can
|
||||
* fold that demand into its own in-memory state instead of losing it.
|
||||
*/
|
||||
export function markCodexSessionBackfillMarkerPending(
|
||||
markerPath: string,
|
||||
systemSessionsRoot: string,
|
||||
scanDates: readonly CodexSessionBackfillDate[]
|
||||
): boolean {
|
||||
// Why: an older in-flight pass must not clear dates recorded after it started.
|
||||
markerInvalidationGeneration += 1
|
||||
const record = readMarkerRecord(markerPath)
|
||||
// Why: a marker for a different history says nothing about this target, so
|
||||
// only a full walk can certify it.
|
||||
if (record && !matchesTargetRoot(record, systemSessionsRoot)) {
|
||||
return true
|
||||
}
|
||||
const pendingScanDates = mergeCodexSessionBackfillDates(record?.pendingScanDates, scanDates)
|
||||
const pending = describePendingScanDates(pendingScanDates, record?.needsFullScan === true)
|
||||
if (pendingScanDates.length === record?.pendingScanDates.length) {
|
||||
return record.needsFullScan
|
||||
}
|
||||
try {
|
||||
// Why: a managed-lane system-default launch can create new source
|
||||
// rollouts, so a prior one-time marker must not suppress the next opt-in.
|
||||
rmSync(markerPath, { force: true })
|
||||
writeMarkerRecord(markerPath, {
|
||||
version: CODEX_SESSION_BACKFILL_MARKER_VERSION,
|
||||
systemSessionsRoot,
|
||||
coverage: 'bounded',
|
||||
// Why: defaults only — an existing record keeps its own version and
|
||||
// coverage, which is what preserves a v3 marker's implicit baseline.
|
||||
...record?.raw,
|
||||
...pending
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[codex-session-backfill] Failed to invalidate completion marker:', error)
|
||||
console.warn('[codex-session-backfill] Failed to record pending scan dates:', error)
|
||||
try {
|
||||
writeFileAtomically(
|
||||
markerPath,
|
||||
`${JSON.stringify({ version: 0, invalidatedAt: Date.now() })}\n`
|
||||
)
|
||||
// Why: fail closed — a full rescan costs one slow pass, while a silently
|
||||
// unrecorded launch date hides its rollouts forever.
|
||||
rmSync(markerPath, { force: true })
|
||||
} catch (fallbackError) {
|
||||
throw new AggregateError(
|
||||
[error, fallbackError],
|
||||
'Failed to invalidate Codex session backfill marker'
|
||||
'Failed to record pending Codex session backfill scan dates'
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return pending.needsFullScan
|
||||
}
|
||||
|
||||
type CodexSessionBackfillMarkerRecord = {
|
||||
raw: Record<string, unknown>
|
||||
systemSessionsRoot: string
|
||||
hasBaseline: boolean
|
||||
pendingScanDates: CodexSessionBackfillDate[]
|
||||
needsFullScan: boolean
|
||||
launchActive: boolean
|
||||
/** Files the last full-tree walk saw; a bounded pass must not overwrite it. */
|
||||
baselineScannedFiles: number | undefined
|
||||
}
|
||||
|
||||
function readMarkerRecord(markerPath: string): CodexSessionBackfillMarkerRecord | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(markerPath, 'utf-8'))
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
const marker = parsed as {
|
||||
version?: unknown
|
||||
systemSessionsRoot?: unknown
|
||||
coverage?: unknown
|
||||
pendingScanDates?: unknown
|
||||
needsFullScan?: unknown
|
||||
launchActive?: unknown
|
||||
baselineScannedFiles?: unknown
|
||||
summary?: { scannedFiles?: unknown }
|
||||
}
|
||||
if (
|
||||
typeof marker.version !== 'number' ||
|
||||
!MARKER_BASELINE_VERSIONS.has(marker.version) ||
|
||||
typeof marker.systemSessionsRoot !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const baselineScannedFiles = marker.baselineScannedFiles ?? marker.summary?.scannedFiles
|
||||
return {
|
||||
raw: parsed as Record<string, unknown>,
|
||||
systemSessionsRoot: marker.systemSessionsRoot,
|
||||
// v3 predates `coverage` and was only written after a full-tree walk.
|
||||
hasBaseline: marker.version === 3 || marker.coverage === 'full',
|
||||
pendingScanDates: parseCodexSessionBackfillDates(marker.pendingScanDates),
|
||||
needsFullScan: marker.needsFullScan === true,
|
||||
launchActive: marker.launchActive === true,
|
||||
// v3 wrote only one summary, and it was always a full-tree one.
|
||||
baselineScannedFiles:
|
||||
typeof baselineScannedFiles === 'number' ? baselineScannedFiles : undefined
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Why: changing the configured real Codex home must backfill the new target
|
||||
* instead of honoring a marker written for a different history — and Windows
|
||||
* spells one directory several ways, so compare normalized. */
|
||||
function matchesTargetRoot(
|
||||
record: CodexSessionBackfillMarkerRecord,
|
||||
systemSessionsRoot: string
|
||||
): boolean {
|
||||
return (
|
||||
normalizeRuntimePathForComparison(record.systemSessionsRoot) ===
|
||||
normalizeRuntimePathForComparison(systemSessionsRoot)
|
||||
)
|
||||
}
|
||||
|
||||
/** Why: an unmet full-scan demand outlives the launch that raised it — only a
|
||||
* full walk may clear it, or the overflowed dates are never revisited. */
|
||||
function describePendingScanDates(
|
||||
pendingScanDates: readonly CodexSessionBackfillDate[],
|
||||
stillOwesFullScan: boolean
|
||||
): { pendingScanDates: readonly CodexSessionBackfillDate[]; needsFullScan: boolean } {
|
||||
return pendingScanDates.length > MAX_PENDING_SCAN_DATES || stillOwesFullScan
|
||||
? { pendingScanDates: [], needsFullScan: true }
|
||||
: { pendingScanDates, needsFullScan: false }
|
||||
}
|
||||
|
||||
function writeMarkerRecord(markerPath: string, record: Record<string, unknown>): void {
|
||||
mkdirSync(dirname(markerPath), { recursive: true })
|
||||
writeFileAtomically(markerPath, `${JSON.stringify(record, null, 2)}\n`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
compareCodexSessionBackfillDates,
|
||||
expandCodexSessionBackfillDatesThroughToday,
|
||||
getCodexSessionBackfillDate,
|
||||
getCodexSessionBackfillDatesBetween,
|
||||
isCodexSessionBackfillDate,
|
||||
mergeCodexSessionBackfillDates,
|
||||
parseCodexSessionBackfillDates,
|
||||
subtractCodexSessionBackfillDates
|
||||
} from './codex-session-backfill-scan-dates'
|
||||
|
||||
describe('codex session backfill scan dates', () => {
|
||||
it('reads UTC parts so a local evening never lands on the wrong directory', () => {
|
||||
expect(getCodexSessionBackfillDate(new Date('2026-08-05T23:59:59Z'))).toEqual([
|
||||
'2026',
|
||||
'08',
|
||||
'05'
|
||||
])
|
||||
expect(getCodexSessionBackfillDate(new Date('2026-01-02T00:00:00Z'))).toEqual([
|
||||
'2026',
|
||||
'01',
|
||||
'02'
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects anything that is not a zero-padded YYYY/MM/DD triple', () => {
|
||||
expect(isCodexSessionBackfillDate(['2026', '08', '05'])).toBe(true)
|
||||
expect(isCodexSessionBackfillDate(['2026', '8', '05'])).toBe(false)
|
||||
expect(isCodexSessionBackfillDate(['2026', '08'])).toBe(false)
|
||||
expect(isCodexSessionBackfillDate('2026-08-05')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects dates the calendar never produced', () => {
|
||||
expect(isCodexSessionBackfillDate(['2026', '99', '99'])).toBe(false)
|
||||
expect(isCodexSessionBackfillDate(['2026', '02', '30'])).toBe(false)
|
||||
expect(isCodexSessionBackfillDate(['2025', '02', '29'])).toBe(false)
|
||||
expect(isCodexSessionBackfillDate(['2026', '00', '10'])).toBe(false)
|
||||
expect(isCodexSessionBackfillDate(['2024', '02', '29'])).toBe(true)
|
||||
expect(isCodexSessionBackfillDate(['2026', '12', '31'])).toBe(true)
|
||||
})
|
||||
|
||||
it('merges and subtracts date sets by identity, not by reference', () => {
|
||||
const merged = mergeCodexSessionBackfillDates(
|
||||
[
|
||||
['2026', '08', '06'],
|
||||
['2026', '08', '05']
|
||||
],
|
||||
[['2026', '08', '06']],
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(merged).toEqual([
|
||||
['2026', '08', '05'],
|
||||
['2026', '08', '06']
|
||||
])
|
||||
expect(subtractCodexSessionBackfillDates(merged, [['2026', '08', '05']])).toEqual([
|
||||
['2026', '08', '06']
|
||||
])
|
||||
expect(compareCodexSessionBackfillDates(merged[0], merged[1])).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('discards unparseable persisted dates instead of scanning bogus roots', () => {
|
||||
expect(parseCodexSessionBackfillDates([['2026', '08', '05'], 'nope', ['2026'], null])).toEqual([
|
||||
['2026', '08', '05']
|
||||
])
|
||||
expect(parseCodexSessionBackfillDates('not an array')).toEqual([])
|
||||
})
|
||||
|
||||
it('walks every date a launch could have spanned, including across a month end', () => {
|
||||
expect(
|
||||
getCodexSessionBackfillDatesBetween(
|
||||
new Date('2026-07-31T23:00:00Z'),
|
||||
new Date('2026-08-02T01:00:00Z')
|
||||
)
|
||||
).toEqual([
|
||||
['2026', '07', '31'],
|
||||
['2026', '08', '01'],
|
||||
['2026', '08', '02']
|
||||
])
|
||||
})
|
||||
|
||||
it('widens a pending set into the contiguous window that ends today', () => {
|
||||
expect(
|
||||
expandCodexSessionBackfillDatesThroughToday([['2026', '08', '05']], ['2026', '08', '07'], 31)
|
||||
).toEqual([
|
||||
['2026', '08', '05'],
|
||||
['2026', '08', '06'],
|
||||
['2026', '08', '07']
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves an empty pending set empty rather than inventing today', () => {
|
||||
expect(expandCodexSessionBackfillDatesThroughToday([], ['2026', '08', '07'], 31)).toEqual([])
|
||||
})
|
||||
|
||||
it('gives up on a window wider than the bound so a full walk can recertify', () => {
|
||||
expect(
|
||||
expandCodexSessionBackfillDatesThroughToday([['2026', '01', '01']], ['2026', '08', '07'], 31)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { CodexSessionBackfillDate } from './codex-session-backfill-types'
|
||||
|
||||
// Set algebra over the UTC date directories that make up a bounded backfill
|
||||
// pass. Kept apart from the walk itself so the durable marker, the scheduler,
|
||||
// and the pass all agree on identity, ordering, and range expansion.
|
||||
|
||||
export function getCodexSessionBackfillDate(date = new Date()): CodexSessionBackfillDate {
|
||||
return [
|
||||
String(date.getUTCFullYear()).padStart(4, '0'),
|
||||
String(date.getUTCMonth() + 1).padStart(2, '0'),
|
||||
String(date.getUTCDate()).padStart(2, '0')
|
||||
]
|
||||
}
|
||||
|
||||
export function isCodexSessionBackfillDate(value: unknown): value is CodexSessionBackfillDate {
|
||||
if (!Array.isArray(value) || value.length !== 3) {
|
||||
return false
|
||||
}
|
||||
const key = value.join('-')
|
||||
// Why: shape alone accepts directories the calendar never produces (2026/99/99
|
||||
// from a corrupted marker, 2025/02/29); a UTC round-trip rejects them for free.
|
||||
// Deliberately no age or future bound — this also gates which managed rollouts
|
||||
// get published, and a clock-skewed future directory holds real sessions.
|
||||
return (
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(key) &&
|
||||
toCodexSessionBackfillDateKey(getCodexSessionBackfillDate(toUtcDate(value))) === key
|
||||
)
|
||||
}
|
||||
|
||||
export function toCodexSessionBackfillDateKey(date: CodexSessionBackfillDate): string {
|
||||
return date.join('-')
|
||||
}
|
||||
|
||||
export function compareCodexSessionBackfillDates(
|
||||
left: CodexSessionBackfillDate,
|
||||
right: CodexSessionBackfillDate
|
||||
): number {
|
||||
return toCodexSessionBackfillDateKey(left).localeCompare(toCodexSessionBackfillDateKey(right))
|
||||
}
|
||||
|
||||
/** Deduplicated ascending union; invalid entries are dropped. */
|
||||
export function mergeCodexSessionBackfillDates(
|
||||
...groups: readonly (readonly CodexSessionBackfillDate[] | undefined)[]
|
||||
): CodexSessionBackfillDate[] {
|
||||
const merged = new Map<string, CodexSessionBackfillDate>()
|
||||
for (const group of groups) {
|
||||
for (const date of group ?? []) {
|
||||
if (isCodexSessionBackfillDate(date)) {
|
||||
merged.set(toCodexSessionBackfillDateKey(date), date)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...merged.values()].sort(compareCodexSessionBackfillDates)
|
||||
}
|
||||
|
||||
export function subtractCodexSessionBackfillDates(
|
||||
dates: readonly CodexSessionBackfillDate[],
|
||||
removed: readonly CodexSessionBackfillDate[]
|
||||
): CodexSessionBackfillDate[] {
|
||||
const removedKeys = new Set(removed.map(toCodexSessionBackfillDateKey))
|
||||
return dates.filter((date) => !removedKeys.has(toCodexSessionBackfillDateKey(date)))
|
||||
}
|
||||
|
||||
/** Reads persisted marker dates; anything unrecognized is discarded. */
|
||||
export function parseCodexSessionBackfillDates(value: unknown): CodexSessionBackfillDate[] {
|
||||
return Array.isArray(value)
|
||||
? mergeCodexSessionBackfillDates(value.filter(isCodexSessionBackfillDate))
|
||||
: []
|
||||
}
|
||||
|
||||
export function getCodexSessionBackfillDatesBetween(
|
||||
startedAt: Date,
|
||||
finishedAt: Date
|
||||
): CodexSessionBackfillDate[] {
|
||||
const dates: CodexSessionBackfillDate[] = []
|
||||
const cursor = toUtcMidnight(startedAt)
|
||||
const last = toUtcMidnight(finishedAt)
|
||||
while (cursor <= last) {
|
||||
dates.push(getCodexSessionBackfillDate(cursor))
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1)
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
/**
|
||||
* Widens a pending set into the contiguous range that ends at `today`.
|
||||
*
|
||||
* Why: an abnormal exit or a pane held open across midnight leaves activity on
|
||||
* dates nobody got to record. The gap between the oldest pending date and today
|
||||
* is the smallest window that provably contains them. Returns null once that
|
||||
* window outgrows `maxDates`, where a full walk is the cheaper certainty.
|
||||
*/
|
||||
export function expandCodexSessionBackfillDatesThroughToday(
|
||||
dates: readonly CodexSessionBackfillDate[],
|
||||
today: CodexSessionBackfillDate,
|
||||
maxDates: number
|
||||
): CodexSessionBackfillDate[] | null {
|
||||
if (dates.length === 0) {
|
||||
return []
|
||||
}
|
||||
const bounds = mergeCodexSessionBackfillDates(dates, [today])
|
||||
const range = getCodexSessionBackfillDatesBetween(toUtcDate(bounds[0]), toUtcDate(bounds.at(-1)!))
|
||||
return range.length > maxDates ? null : range
|
||||
}
|
||||
|
||||
function toUtcDate([year, month, day]: readonly string[]): Date {
|
||||
return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
|
||||
}
|
||||
|
||||
function toUtcMidnight(date: Date): Date {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
|
||||
}
|
||||
@@ -26,12 +26,12 @@ export type CodexSessionBackfillOptions = CodexSessionBridgeIncrementalOptions &
|
||||
shouldStop?: () => boolean
|
||||
/** Limits a launch-triggered pass to the date directories that can contain its rollouts. */
|
||||
scanDates?: readonly CodexSessionBackfillDate[]
|
||||
/** A scheduled launch pass must not be suppressed by a marker it just invalidated. */
|
||||
/** Forces recertification of the whole tree, ignoring any existing baseline. */
|
||||
fullScanRequired?: boolean
|
||||
/** A scheduled launch pass runs even when the baseline reports nothing pending. */
|
||||
ignoreCompletionMarker?: boolean
|
||||
/** Active launch passes defer global completion until their final exit scan. */
|
||||
writeCompletionMarker?: boolean
|
||||
/** Final launch scans can extend a previously certified full-tree baseline. */
|
||||
writeBoundedCompletionMarker?: boolean
|
||||
/** A live Codex pane keeps writing into its own date, so it stays pending. */
|
||||
retainPendingScanDates?: boolean
|
||||
/** Rechecks launch scheduling state immediately before marker publication. */
|
||||
canWriteCompletionMarker?: () => boolean
|
||||
}
|
||||
|
||||
@@ -21,129 +21,16 @@ const { homedirMock } = vi.hoisted(() => ({
|
||||
homedirMock: vi.fn<() => string>()
|
||||
}))
|
||||
|
||||
const { fsMockState } = vi.hoisted(() => ({
|
||||
fsMockState: {
|
||||
failLink: false,
|
||||
failLinkTransiently: false,
|
||||
failLinkPermission: false,
|
||||
raceTargetIntoExistence: false,
|
||||
failMarkerRm: false,
|
||||
failMarkerReplacement: false,
|
||||
failAuditMkdirOnce: false,
|
||||
failAuditWrites: false,
|
||||
failMkdirPath: null as string | null,
|
||||
failDirectoryPath: null as string | null,
|
||||
failLstatPath: null as string | null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = await vi.importActual<typeof NodeFs>('node:fs')
|
||||
return {
|
||||
...actual,
|
||||
existsSync: (...args: Parameters<typeof actual.existsSync>) => {
|
||||
if (args[0] === fsMockState.failLstatPath) {
|
||||
return false
|
||||
}
|
||||
return actual.existsSync(...args)
|
||||
},
|
||||
rmSync: (...args: Parameters<typeof actual.rmSync>) => {
|
||||
if (
|
||||
fsMockState.failMarkerRm &&
|
||||
String(args[0]).includes('codex-session-backfill') &&
|
||||
String(args[0]).endsWith('backfill-complete.json')
|
||||
) {
|
||||
const error = new Error('EACCES: marker removal failed') as NodeJS.ErrnoException
|
||||
error.code = 'EACCES'
|
||||
throw error
|
||||
}
|
||||
return actual.rmSync(...args)
|
||||
},
|
||||
renameSync: (...args: Parameters<typeof actual.renameSync>) => {
|
||||
if (
|
||||
fsMockState.failMarkerReplacement &&
|
||||
String(args[1]).includes('codex-session-backfill') &&
|
||||
String(args[1]).endsWith('backfill-complete.json')
|
||||
) {
|
||||
const error = new Error('EACCES: marker replacement failed') as NodeJS.ErrnoException
|
||||
error.code = 'EACCES'
|
||||
throw error
|
||||
}
|
||||
return actual.renameSync(...args)
|
||||
}
|
||||
}
|
||||
const mocks = await import('./codex-session-backfill-fs-mocks')
|
||||
return mocks.createNodeFsMock(await vi.importActual<typeof NodeFs>('node:fs'))
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof NodeFsPromises>('node:fs/promises')
|
||||
return {
|
||||
...actual,
|
||||
mkdir: (...args: Parameters<typeof actual.mkdir>) => {
|
||||
if (args[0] === fsMockState.failMkdirPath) {
|
||||
const error = new Error('EACCES: target directory inaccessible') as NodeJS.ErrnoException
|
||||
error.code = 'EACCES'
|
||||
throw error
|
||||
}
|
||||
if (fsMockState.failAuditMkdirOnce && String(args[0]).includes('codex-session-backfill')) {
|
||||
fsMockState.failAuditMkdirOnce = false
|
||||
const error = new Error(
|
||||
'EACCES: transient audit directory failure'
|
||||
) as NodeJS.ErrnoException
|
||||
error.code = 'EACCES'
|
||||
throw error
|
||||
}
|
||||
return actual.mkdir(...args)
|
||||
},
|
||||
appendFile: (...args: Parameters<typeof actual.appendFile>) => {
|
||||
if (fsMockState.failAuditWrites && String(args[0]).includes('codex-session-backfill')) {
|
||||
const error = new Error('ENOSPC: audit write failed') as NodeJS.ErrnoException
|
||||
error.code = 'ENOSPC'
|
||||
throw error
|
||||
}
|
||||
return actual.appendFile(...args)
|
||||
},
|
||||
lstat: (...args: Parameters<typeof actual.lstat>) => {
|
||||
if (args[0] === fsMockState.failLstatPath) {
|
||||
const error = new Error('EACCES: path inaccessible') as NodeJS.ErrnoException
|
||||
error.code = 'EACCES'
|
||||
throw error
|
||||
}
|
||||
return actual.lstat(...args)
|
||||
},
|
||||
link: async (...args: Parameters<typeof actual.link>) => {
|
||||
if (fsMockState.raceTargetIntoExistence && String(args[0]).includes('codex-runtime-home')) {
|
||||
fsMockState.raceTargetIntoExistence = false
|
||||
await actual.writeFile(args[1], 'concurrent target\n', 'utf-8')
|
||||
const error = new Error('EEXIST: concurrent target') as NodeJS.ErrnoException
|
||||
error.code = 'EEXIST'
|
||||
throw error
|
||||
}
|
||||
if (fsMockState.failLink && String(args[0]).includes('codex-runtime-home')) {
|
||||
const error = new Error('EXDEV: cross-device link') as NodeJS.ErrnoException
|
||||
error.code = 'EXDEV'
|
||||
throw error
|
||||
}
|
||||
if (fsMockState.failLinkTransiently && String(args[0]).includes('codex-runtime-home')) {
|
||||
const error = new Error('EIO: transient hardlink failure') as NodeJS.ErrnoException
|
||||
error.code = 'EIO'
|
||||
throw error
|
||||
}
|
||||
if (fsMockState.failLinkPermission && String(args[0]).includes('codex-runtime-home')) {
|
||||
const error = new Error('EACCES: hardlink permission denied') as NodeJS.ErrnoException
|
||||
error.code = 'EACCES'
|
||||
throw error
|
||||
}
|
||||
return actual.link(...args)
|
||||
},
|
||||
opendir: (...args: Parameters<typeof actual.opendir>) => {
|
||||
if (args[0] === fsMockState.failDirectoryPath) {
|
||||
const error = new Error('EACCES: directory unreadable') as NodeJS.ErrnoException
|
||||
error.code = 'EACCES'
|
||||
throw error
|
||||
}
|
||||
return actual.opendir(...args)
|
||||
}
|
||||
}
|
||||
const mocks = await import('./codex-session-backfill-fs-mocks')
|
||||
return mocks.createNodeFsPromisesMock(
|
||||
await vi.importActual<typeof NodeFsPromises>('node:fs/promises')
|
||||
)
|
||||
})
|
||||
|
||||
vi.mock('node:os', async () => {
|
||||
@@ -159,7 +46,15 @@ import {
|
||||
resolveCodexSessionBackfillPaths,
|
||||
startCodexSessionBackfillInBackground
|
||||
} from './codex-session-backfill'
|
||||
import { invalidateCodexSessionBackfillMarker } from './codex-session-backfill-marker'
|
||||
import {
|
||||
markCodexSessionBackfillMarkerPending,
|
||||
readCodexSessionBackfillBaseline
|
||||
} from './codex-session-backfill-marker'
|
||||
import { fsMockState, resetCodexSessionBackfillFsMocks } from './codex-session-backfill-fs-mocks'
|
||||
import { getCodexSessionBackfillDate } from './codex-session-backfill-scan-dates'
|
||||
import type { CodexSessionBackfillDate } from './codex-session-backfill-types'
|
||||
|
||||
const FIXTURE_LAUNCH_DATE: CodexSessionBackfillDate = ['2026', '05', '26']
|
||||
|
||||
let fakeHomeDir: string
|
||||
let userDataDir: string
|
||||
@@ -212,18 +107,21 @@ function readAuditActions(): string[] {
|
||||
return readBackfillAuditRecords().map((record) => record.action)
|
||||
}
|
||||
|
||||
/** Stands in for a Codex pane launch: records its date without dropping the baseline. */
|
||||
function markLaunchPending(...scanDates: CodexSessionBackfillDate[]): void {
|
||||
markCodexSessionBackfillMarkerPending(
|
||||
getMarkerPath(),
|
||||
getSystemSessionsRoot(),
|
||||
scanDates.length > 0 ? scanDates : [FIXTURE_LAUNCH_DATE]
|
||||
)
|
||||
}
|
||||
|
||||
function readMarker(): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(getMarkerPath(), 'utf-8')) as Record<string, unknown>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fsMockState.failLink = false
|
||||
fsMockState.failLinkTransiently = false
|
||||
fsMockState.failLinkPermission = false
|
||||
fsMockState.raceTargetIntoExistence = false
|
||||
fsMockState.failMarkerRm = false
|
||||
fsMockState.failMarkerReplacement = false
|
||||
fsMockState.failAuditMkdirOnce = false
|
||||
fsMockState.failAuditWrites = false
|
||||
fsMockState.failMkdirPath = null
|
||||
fsMockState.failDirectoryPath = null
|
||||
fsMockState.failLstatPath = null
|
||||
resetCodexSessionBackfillFsMocks()
|
||||
fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-home-'))
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-user-data-'))
|
||||
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
@@ -557,18 +455,48 @@ describe('startCodexSessionBackfillInBackground', () => {
|
||||
expect(existsSync(getMarkerPath())).toBe(false)
|
||||
})
|
||||
|
||||
it('defers completion while a launch lease is active', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
it('certifies the historical baseline while a launch lease is still active', async () => {
|
||||
const today = getCodexSessionBackfillDate()
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-history.jsonl'), 'history\n')
|
||||
writeManagedSession(join(...today, 'rollout-live.jsonl'), 'live\n')
|
||||
markLaunchPending(today)
|
||||
|
||||
const active = await startCodexSessionBackfillInBackground({
|
||||
writeCompletionMarker: false
|
||||
ignoreCompletionMarker: true,
|
||||
retainPendingScanDates: true
|
||||
})
|
||||
|
||||
expect(active).toMatchObject({ scannedFiles: 2, linkedFiles: 2 })
|
||||
// The baseline is certified despite the open pane; only its date stays pending.
|
||||
expect(readMarker()).toMatchObject({
|
||||
version: 4,
|
||||
coverage: 'full',
|
||||
launchActive: true,
|
||||
pendingScanDates: [today]
|
||||
})
|
||||
expect(readCodexSessionBackfillBaseline(getMarkerPath(), getSystemSessionsRoot())).toEqual({
|
||||
pendingScanDates: [today]
|
||||
})
|
||||
expect(active).toMatchObject({ linkedFiles: 1 })
|
||||
expect(existsSync(getMarkerPath())).toBe(false)
|
||||
|
||||
const completed = await startCodexSessionBackfillInBackground()
|
||||
expect(completed).toMatchObject({ skippedExistingFiles: 1 })
|
||||
expect(existsSync(getMarkerPath())).toBe(true)
|
||||
expect(completed).toMatchObject({ scannedFiles: 1, skippedExistingFiles: 1 })
|
||||
expect(readMarker()).toMatchObject({ pendingScanDates: [], launchActive: false })
|
||||
expect(await startCodexSessionBackfillInBackground()).toBeNull()
|
||||
})
|
||||
|
||||
it('scans only the current date once a baseline exists', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
// A second date directory: a full walk would report two scanned files.
|
||||
writeManagedSession(join('2026', '06', '02', 'rollout-other.jsonl'), '{"id":"other"}\n')
|
||||
markLaunchPending()
|
||||
|
||||
const scanned = await startCodexSessionBackfillInBackground({
|
||||
ignoreCompletionMarker: true
|
||||
})
|
||||
|
||||
// No scanDates were requested, so only the recorded pending date is walked.
|
||||
expect(scanned).toMatchObject({ scannedFiles: 1, skippedExistingFiles: 1 })
|
||||
})
|
||||
|
||||
it('rechecks launch state before publishing completion', async () => {
|
||||
@@ -582,54 +510,93 @@ describe('startCodexSessionBackfillInBackground', () => {
|
||||
expect(existsSync(getMarkerPath())).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps an invalidated active pass from recreating the completion marker', async () => {
|
||||
it('keeps a racing launch date pending without discarding the baseline', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
let invalidated = false
|
||||
let raceStarted = false
|
||||
|
||||
const raced = await startCodexSessionBackfillInBackground({
|
||||
shouldStop: () => {
|
||||
if (!invalidated) {
|
||||
invalidated = true
|
||||
invalidateCodexSessionBackfillMarker(getMarkerPath())
|
||||
if (!raceStarted) {
|
||||
raceStarted = true
|
||||
markLaunchPending(['2026', '08', '05'])
|
||||
}
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
expect(raced).toMatchObject({ linkedFiles: 1, stopped: false })
|
||||
expect(existsSync(getMarkerPath())).toBe(false)
|
||||
// The full walk still certifies history, but the racing launch's date stays pending.
|
||||
expect(readMarker()).toMatchObject({
|
||||
version: 4,
|
||||
coverage: 'full',
|
||||
pendingScanDates: [['2026', '08', '05']]
|
||||
})
|
||||
const racedAudit = readFileSync(getAuditLogPath(), 'utf-8')
|
||||
|
||||
writeManagedSession(join('2026', '08', '05', 'rollout-raced.jsonl'), '{"id":"raced"}\n')
|
||||
const recovered = await startCodexSessionBackfillInBackground()
|
||||
|
||||
expect(recovered).toMatchObject({ skippedExistingFiles: 1, failedHealAuditRecords: 0 })
|
||||
expect(existsSync(getMarkerPath())).toBe(true)
|
||||
expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(racedAudit)
|
||||
expect(recovered).toMatchObject({ scannedFiles: 1, linkedFiles: 1 })
|
||||
expect(readMarker()).toMatchObject({ pendingScanDates: [] })
|
||||
expect(readFileSync(getAuditLogPath(), 'utf-8')).not.toBe(racedAudit)
|
||||
})
|
||||
|
||||
it('replaces a stale marker when direct removal fails', async () => {
|
||||
it('falls back to a full rescan when the pending rewrite fails', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
fsMockState.failMarkerRm = true
|
||||
fsMockState.failMarkerReplacement = true
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
invalidateCodexSessionBackfillMarker(getMarkerPath())
|
||||
markLaunchPending(['2026', '08', '05'])
|
||||
|
||||
expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 0 })
|
||||
// Fail closed: no marker at all beats a marker missing the launch's date.
|
||||
expect(existsSync(getMarkerPath())).toBe(false)
|
||||
fsMockState.failMarkerReplacement = false
|
||||
const recovered = await startCodexSessionBackfillInBackground()
|
||||
expect(recovered).toMatchObject({ skippedExistingFiles: 1 })
|
||||
expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 })
|
||||
expect(readMarker()).toMatchObject({ version: 4, coverage: 'full' })
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('fails launch preparation when a stale marker cannot be invalidated', async () => {
|
||||
it('fails launch preparation when the baseline can neither be updated nor cleared', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
fsMockState.failMarkerRm = true
|
||||
fsMockState.failMarkerReplacement = true
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
expect(() => invalidateCodexSessionBackfillMarker(getMarkerPath())).toThrow(
|
||||
'Failed to invalidate Codex session backfill marker'
|
||||
expect(() => markLaunchPending(['2026', '08', '05'])).toThrow(
|
||||
'Failed to record pending Codex session backfill scan dates'
|
||||
)
|
||||
expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 })
|
||||
expect(readMarker()).toMatchObject({ version: 4 })
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('reads a legacy v3 marker as a certified baseline', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
mkdirSync(dirname(getMarkerPath()), { recursive: true })
|
||||
writeFileSync(
|
||||
getMarkerPath(),
|
||||
`${JSON.stringify({
|
||||
version: 3,
|
||||
systemSessionsRoot: getSystemSessionsRoot(),
|
||||
completedAt: Date.now(),
|
||||
summary: { scannedFiles: 1 }
|
||||
})}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
// No full walk on upgrade: the v3 baseline is honored as-is.
|
||||
expect(await startCodexSessionBackfillInBackground()).toBeNull()
|
||||
expect(existsSync(join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl'))).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
markLaunchPending()
|
||||
const bounded = await startCodexSessionBackfillInBackground()
|
||||
expect(bounded).toMatchObject({ scannedFiles: 1, linkedFiles: 1 })
|
||||
expect(readMarker()).toMatchObject({ version: 4, coverage: 'full' })
|
||||
})
|
||||
|
||||
it('writes a completion marker and skips the walk on later runs', async () => {
|
||||
@@ -638,7 +605,7 @@ describe('startCodexSessionBackfillInBackground', () => {
|
||||
const first = await startCodexSessionBackfillInBackground()
|
||||
expect(first).toMatchObject({ linkedFiles: 1, failedFiles: 0 })
|
||||
expect(existsSync(getMarkerPath())).toBe(true)
|
||||
expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 })
|
||||
expect(readMarker()).toMatchObject({ version: 4, coverage: 'full' })
|
||||
|
||||
// An ordinary call remains a no-op; only a launch-scheduled pass bypasses the marker.
|
||||
writeManagedSession(join('2026', '07', '01', 'rollout-later.jsonl'), '{"id":"later"}\n')
|
||||
@@ -655,11 +622,7 @@ describe('startCodexSessionBackfillInBackground', () => {
|
||||
expect(scheduled).toMatchObject({ scannedFiles: 1, linkedFiles: 1 })
|
||||
})
|
||||
|
||||
it('does not let a bounded pass certify older unscanned history', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-baseline.jsonl'), 'baseline\n')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
|
||||
invalidateCodexSessionBackfillMarker(getMarkerPath())
|
||||
it('does not let a bounded pass certify history no baseline ever covered', async () => {
|
||||
const missedRelativePath = join('2026', '06', '01', 'rollout-missed.jsonl')
|
||||
writeManagedSession(missedRelativePath, 'missed\n')
|
||||
writeManagedSession(join('2026', '08', '05', 'rollout-launch.jsonl'), 'launch\n')
|
||||
@@ -672,26 +635,61 @@ describe('startCodexSessionBackfillInBackground', () => {
|
||||
expect(existsSync(getMarkerPath())).toBe(false)
|
||||
|
||||
const recovered = await startCodexSessionBackfillInBackground()
|
||||
expect(recovered).toMatchObject({ scannedFiles: 3, linkedFiles: 1 })
|
||||
expect(recovered).toMatchObject({ scannedFiles: 2, linkedFiles: 1 })
|
||||
expect(existsSync(join(getSystemSessionsRoot(), missedRelativePath))).toBe(true)
|
||||
expect(existsSync(getMarkerPath())).toBe(true)
|
||||
expect(readMarker()).toMatchObject({ version: 4, coverage: 'full' })
|
||||
})
|
||||
|
||||
it('lets an explicit bounded final pass restore a certified baseline', async () => {
|
||||
it('lets a bounded launch pass extend a certified baseline', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-baseline.jsonl'), 'baseline\n')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
|
||||
invalidateCodexSessionBackfillMarker(getMarkerPath())
|
||||
markLaunchPending(['2026', '08', '05'])
|
||||
writeManagedSession(join('2026', '08', '05', 'rollout-launch.jsonl'), 'launch\n')
|
||||
|
||||
const bounded = await startCodexSessionBackfillInBackground({
|
||||
ignoreCompletionMarker: true,
|
||||
scanDates: [['2026', '08', '05']],
|
||||
writeBoundedCompletionMarker: true
|
||||
scanDates: [['2026', '08', '05']]
|
||||
})
|
||||
|
||||
expect(bounded).toMatchObject({ scannedFiles: 1, linkedFiles: 1 })
|
||||
expect(existsSync(getMarkerPath())).toBe(true)
|
||||
expect(readMarker()).toMatchObject({ coverage: 'full', pendingScanDates: [] })
|
||||
expect(await startCodexSessionBackfillInBackground()).toBeNull()
|
||||
})
|
||||
|
||||
it('recovers a bounded window after an abnormal exit instead of a full walk', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-baseline.jsonl'), 'baseline\n')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
|
||||
// A launch records its date, then the app dies before any pass runs.
|
||||
markLaunchPending(['2026', '08', '05'])
|
||||
writeManagedSession(join('2026', '06', '01', 'rollout-untouched.jsonl'), 'untouched\n')
|
||||
writeManagedSession(join('2026', '08', '05', 'rollout-launch.jsonl'), 'launch\n')
|
||||
|
||||
const recovered = await startCodexSessionBackfillInBackground()
|
||||
|
||||
expect(recovered).toMatchObject({ scannedFiles: 1, linkedFiles: 1 })
|
||||
expect(
|
||||
existsSync(join(getSystemSessionsRoot(), '2026', '08', '05', 'rollout-launch.jsonl'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('widens recovery across midnight when a pane was still live', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-baseline.jsonl'), 'baseline\n')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
const launchDate = getCodexSessionBackfillDate(new Date(Date.now() - 2 * 24 * 60 * 60 * 1000))
|
||||
await startCodexSessionBackfillInBackground({
|
||||
scanDates: [launchDate],
|
||||
ignoreCompletionMarker: true,
|
||||
retainPendingScanDates: true
|
||||
})
|
||||
|
||||
const baseline = readCodexSessionBackfillBaseline(getMarkerPath(), getSystemSessionsRoot())
|
||||
|
||||
// The pane could have written on every date from its launch through today.
|
||||
expect(baseline?.pendingScanDates).toHaveLength(3)
|
||||
expect(baseline?.pendingScanDates.at(0)).toEqual(launchDate)
|
||||
expect(baseline?.pendingScanDates.at(-1)).toEqual(getCodexSessionBackfillDate())
|
||||
})
|
||||
|
||||
it('records a new heal event when a linked rollout grows in place', async () => {
|
||||
@@ -700,7 +698,7 @@ describe('startCodexSessionBackfillInBackground', () => {
|
||||
await startCodexSessionBackfillInBackground()
|
||||
|
||||
const firstRecord = readBackfillAuditRecords().find((record) => record.action === 'hardlink')
|
||||
invalidateCodexSessionBackfillMarker(getMarkerPath())
|
||||
markLaunchPending()
|
||||
appendFileSync(managedPath, '{"event":"later"}\n', 'utf-8')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
|
||||
@@ -720,7 +718,7 @@ describe('startCodexSessionBackfillInBackground', () => {
|
||||
const firstAudit = readFileSync(getAuditLogPath(), 'utf-8')
|
||||
|
||||
for (let pass = 0; pass < 2; pass += 1) {
|
||||
invalidateCodexSessionBackfillMarker(getMarkerPath())
|
||||
markLaunchPending()
|
||||
const repeated = await startCodexSessionBackfillInBackground()
|
||||
expect(repeated).toMatchObject({
|
||||
linkedFiles: 0,
|
||||
@@ -745,14 +743,15 @@ describe('startCodexSessionBackfillInBackground', () => {
|
||||
writeManagedSession(firstRelativePath, '{"id":"a"}\n')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
|
||||
invalidateCodexSessionBackfillMarker(getMarkerPath())
|
||||
markLaunchPending()
|
||||
writeManagedSession(secondRelativePath, '{"id":"b"}\n')
|
||||
fsMockState.failAuditWrites = true
|
||||
|
||||
const interrupted = await startCodexSessionBackfillInBackground()
|
||||
|
||||
expect(interrupted).toMatchObject({ linkedFiles: 1, failedHealAuditRecords: 1 })
|
||||
expect(existsSync(getMarkerPath())).toBe(false)
|
||||
// The failed pass certifies nothing, so its date stays queued for the retry.
|
||||
expect(readMarker()).toMatchObject({ pendingScanDates: [FIXTURE_LAUNCH_DATE] })
|
||||
expect(
|
||||
readBackfillAuditRecords().filter((record) =>
|
||||
['hardlink', 'copy', 'existing'].includes(record.action)
|
||||
@@ -810,7 +809,7 @@ describe('startCodexSessionBackfillInBackground', () => {
|
||||
rmSync(getMarkerPath(), { recursive: true })
|
||||
const resumed = await startCodexSessionBackfillInBackground()
|
||||
expect(resumed).toMatchObject({ skippedExistingFiles: 1, failedHealAuditRecords: 0 })
|
||||
expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 })
|
||||
expect(readMarker()).toMatchObject({ version: 4 })
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
@@ -841,7 +840,7 @@ describe('startCodexSessionBackfillInBackground', () => {
|
||||
expect(existsSync(getMarkerPath())).toBe(true)
|
||||
|
||||
const firstAudit = readFileSync(getAuditLogPath(), 'utf-8')
|
||||
invalidateCodexSessionBackfillMarker(getMarkerPath())
|
||||
markLaunchPending()
|
||||
const repeated = await startCodexSessionBackfillInBackground()
|
||||
|
||||
expect(repeated).toMatchObject({ skippedUnsupportedFilesystemFiles: 1, failedFiles: 0 })
|
||||
|
||||
@@ -17,10 +17,16 @@ import {
|
||||
} from './codex-session-backfill-date'
|
||||
import {
|
||||
captureCodexSessionBackfillMarkerGeneration,
|
||||
hasCompletedCodexSessionBackfillMarker,
|
||||
writeCodexSessionBackfillMarker as writeBackfillMarker
|
||||
readCodexSessionBackfillBaseline,
|
||||
writeCodexSessionBackfillMarker as writeBackfillMarker,
|
||||
type CodexSessionBackfillBaseline
|
||||
} from './codex-session-backfill-marker'
|
||||
import {
|
||||
getCodexSessionBackfillDate,
|
||||
mergeCodexSessionBackfillDates
|
||||
} from './codex-session-backfill-scan-dates'
|
||||
import type {
|
||||
CodexSessionBackfillDate,
|
||||
CodexSessionBackfillOptions,
|
||||
CodexSessionBackfillPaths,
|
||||
CodexSessionBackfillSummary
|
||||
@@ -88,30 +94,61 @@ async function runCodexSessionBackfillOncePerHost(
|
||||
): Promise<CodexSessionBackfillSummary | null> {
|
||||
const paths = resolveCodexSessionBackfillPaths(systemCodexHomePathOverride)
|
||||
const markerGeneration = captureCodexSessionBackfillMarkerGeneration()
|
||||
if (
|
||||
!options.ignoreCompletionMarker &&
|
||||
hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot)
|
||||
) {
|
||||
const baseline = readCodexSessionBackfillBaseline(paths.markerPath, paths.systemSessionsRoot)
|
||||
const scanPlan = resolveCodexSessionBackfillScanPlan(baseline, options)
|
||||
if (!scanPlan) {
|
||||
return null
|
||||
}
|
||||
const summary = await backfillManagedCodexSessionsIntoSystemHome(paths, options)
|
||||
// Why: file or heal-queue failures leave the marker unset so the next
|
||||
const summary = await backfillManagedCodexSessionsIntoSystemHome(paths, {
|
||||
...options,
|
||||
scanDates: scanPlan.scanDates
|
||||
})
|
||||
// Why: file or heal-queue failures leave the pass uncertified so the next
|
||||
// startup retries; skip-existing keeps those retries cheap.
|
||||
if (
|
||||
!summary.stopped &&
|
||||
options.shouldStop?.() !== true &&
|
||||
options.writeCompletionMarker !== false &&
|
||||
options.canWriteCompletionMarker?.() !== false &&
|
||||
(options.scanDates === undefined || options.writeBoundedCompletionMarker === true) &&
|
||||
summary.failedFiles === 0 &&
|
||||
summary.failedDirectories === 0 &&
|
||||
summary.failedHealAuditRecords === 0
|
||||
) {
|
||||
writeBackfillMarker(paths.markerPath, paths.systemSessionsRoot, summary, markerGeneration)
|
||||
writeBackfillMarker(paths.markerPath, paths.systemSessionsRoot, summary, markerGeneration, {
|
||||
coverage: scanPlan.scanDates ? 'bounded' : 'full',
|
||||
coveredScanDates: scanPlan.scanDates ?? [],
|
||||
retainPendingScanDates: options.retainPendingScanDates === true
|
||||
})
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides how much of the sessions tree this pass must walk.
|
||||
*
|
||||
* Null means the baseline already covers everything and there is nothing
|
||||
* pending; an absent `scanDates` means a full walk, which is only ever needed
|
||||
* when no certified baseline exists (or the caller demands recertification).
|
||||
*/
|
||||
function resolveCodexSessionBackfillScanPlan(
|
||||
baseline: CodexSessionBackfillBaseline | null,
|
||||
options: CodexSessionBackfillOptions
|
||||
): { scanDates?: readonly CodexSessionBackfillDate[] } | null {
|
||||
const requestedScanDates = options.scanDates?.length ? options.scanDates : undefined
|
||||
if (options.fullScanRequired) {
|
||||
return {}
|
||||
}
|
||||
if (!baseline) {
|
||||
return { scanDates: requestedScanDates }
|
||||
}
|
||||
const scanDates = mergeCodexSessionBackfillDates(baseline.pendingScanDates, requestedScanDates)
|
||||
if (scanDates.length > 0) {
|
||||
return { scanDates }
|
||||
}
|
||||
// Why: a launch-scheduled pass exists to publish rollouts the running pane is
|
||||
// creating right now, so with a baseline in hand the current date is enough.
|
||||
return options.ignoreCompletionMarker ? { scanDates: [getCodexSessionBackfillDate()] } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfills managed-home session rollout files into the real Codex home.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { appendFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
CODEX_SESSION_INDEX_HEAL_VERSION,
|
||||
appendHealLedgerRecord,
|
||||
collectPendingHealThreads,
|
||||
isHealMarkerCurrent,
|
||||
writeHealMarker,
|
||||
type CodexSessionIndexHealPaths
|
||||
} from './codex-session-index-heal-state'
|
||||
|
||||
const WINDOWS_SESSIONS_ROOT = 'C:\\Users\\Me\\.codex\\sessions'
|
||||
const THREAD_ID = '019f0000-1111-7222-8333-000000000001'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of tempRoots) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
function createPaths(systemSessionsRoot = WINDOWS_SESSIONS_ROOT): CodexSessionIndexHealPaths {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), 'orca-codex-heal-state-'))
|
||||
tempRoots.push(stateDir)
|
||||
return {
|
||||
auditLogPath: join(stateDir, 'audit.jsonl'),
|
||||
systemSessionsRoot,
|
||||
healLedgerPath: join(stateDir, 'index-heal-ledger.jsonl'),
|
||||
healMarkerPath: join(stateDir, 'index-heal-complete.json')
|
||||
}
|
||||
}
|
||||
|
||||
function appendAuditRecord(paths: CodexSessionIndexHealPaths, target: string, recordId: string) {
|
||||
// Mirrors the real writer's leading newline, which quarantines a torn tail.
|
||||
appendFileSync(
|
||||
paths.auditLogPath,
|
||||
`\n${JSON.stringify({ action: 'hardlink', source: '/managed/x.jsonl', target, recordId })}\n`
|
||||
)
|
||||
}
|
||||
|
||||
function windowsRolloutTarget(root: string): string {
|
||||
return `${root}\\2026\\07\\01\\rollout-2026-07-01T10-00-00-${THREAD_ID}.jsonl`
|
||||
}
|
||||
|
||||
describe('codex session index heal state', () => {
|
||||
it('keeps the heal marker current across Windows spellings of one target', () => {
|
||||
const paths = createPaths()
|
||||
writeHealMarker(paths, 42, { healedThreads: 1, missingThreads: 0, failedThreads: 0 })
|
||||
|
||||
for (const alias of ['C:/Users/Me/.codex/sessions', 'c:\\users\\me\\.codex\\sessions']) {
|
||||
expect(isHealMarkerCurrent({ ...paths, systemSessionsRoot: alias }, 42)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('still re-heals when the real target path actually changes', () => {
|
||||
const paths = createPaths()
|
||||
writeHealMarker(paths, 42, { healedThreads: 1, missingThreads: 0, failedThreads: 0 })
|
||||
|
||||
expect(
|
||||
isHealMarkerCurrent(
|
||||
{ ...paths, systemSessionsRoot: 'C:\\Users\\Me\\moved-codex\\sessions' },
|
||||
42
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a heal record as processed regardless of how its root was spelled', async () => {
|
||||
const paths = createPaths()
|
||||
appendAuditRecord(paths, windowsRolloutTarget(WINDOWS_SESSIONS_ROOT), 'audit-1')
|
||||
appendHealLedgerRecord(
|
||||
{ ...paths, systemSessionsRoot: 'c:/users/me/.codex/sessions' },
|
||||
THREAD_ID,
|
||||
'healed',
|
||||
'audit-1'
|
||||
)
|
||||
|
||||
expect(await collectPendingHealThreads(paths)).toEqual([])
|
||||
})
|
||||
|
||||
it('re-heals a thread whose record belongs to a different real home', async () => {
|
||||
const paths = createPaths()
|
||||
appendAuditRecord(paths, windowsRolloutTarget(WINDOWS_SESSIONS_ROOT), 'audit-1')
|
||||
appendHealLedgerRecord(
|
||||
{ ...paths, systemSessionsRoot: 'C:\\Users\\Me\\moved-codex\\sessions' },
|
||||
THREAD_ID,
|
||||
'healed',
|
||||
'audit-1'
|
||||
)
|
||||
|
||||
expect(await collectPendingHealThreads(paths)).toEqual([
|
||||
expect.objectContaining({ threadId: THREAD_ID, auditRecordId: 'audit-1' })
|
||||
])
|
||||
})
|
||||
|
||||
it('re-queues a thread when a later publication event supersedes a healed one', async () => {
|
||||
const paths = createPaths()
|
||||
appendAuditRecord(paths, windowsRolloutTarget(WINDOWS_SESSIONS_ROOT), 'audit-1')
|
||||
appendHealLedgerRecord(paths, THREAD_ID, 'healed', 'audit-1')
|
||||
appendAuditRecord(paths, windowsRolloutTarget(WINDOWS_SESSIONS_ROOT), 'audit-2')
|
||||
|
||||
expect(await collectPendingHealThreads(paths)).toEqual([
|
||||
expect.objectContaining({ threadId: THREAD_ID, auditRecordId: 'audit-2' })
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves the main thread free while walking a large audit ledger', async () => {
|
||||
const paths = createPaths()
|
||||
for (let index = 0; index < 20_000; index += 1) {
|
||||
appendAuditRecord(
|
||||
paths,
|
||||
`${WINDOWS_SESSIONS_ROOT}\\2026\\07\\01\\rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-${String(index).padStart(12, '0')}.jsonl`,
|
||||
`audit-${index}`
|
||||
)
|
||||
}
|
||||
let ticks = 0
|
||||
const ticker = setInterval(() => {
|
||||
ticks += 1
|
||||
}, 1)
|
||||
|
||||
const pending = await collectPendingHealThreads(paths)
|
||||
clearInterval(ticker)
|
||||
|
||||
expect(pending).toHaveLength(20_000)
|
||||
// A blocking readFileSync + whole-file JSON.parse would starve every timer.
|
||||
expect(ticks).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('refuses to treat an unreadable audit ledger as an empty work queue', async () => {
|
||||
const paths = createPaths()
|
||||
// A directory in the audit's place surfaces EISDIR rather than ENOENT.
|
||||
mkdirSync(paths.auditLogPath, { recursive: true })
|
||||
|
||||
await expect(collectPendingHealThreads(paths)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('treats a missing audit ledger as no pending work', async () => {
|
||||
await expect(collectPendingHealThreads(createPaths())).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('skips torn ledger lines instead of failing the pass', async () => {
|
||||
const paths = createPaths()
|
||||
appendFileSync(paths.auditLogPath, '{"action":"hardlink","target":"/x/rollout')
|
||||
appendAuditRecord(paths, windowsRolloutTarget(WINDOWS_SESSIONS_ROOT), 'audit-1')
|
||||
|
||||
expect(await collectPendingHealThreads(paths)).toEqual([
|
||||
expect.objectContaining({ threadId: THREAD_ID })
|
||||
])
|
||||
expect(CODEX_SESSION_INDEX_HEAL_VERSION).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
normalizeRuntimePathForComparison
|
||||
} from '../../shared/cross-platform-path'
|
||||
import { writeFileAtomically } from '../codex-accounts/fs-utils'
|
||||
import { streamCodexSessionLedgerRecords } from './codex-session-ledger-stream'
|
||||
|
||||
// State files for the session index heal: which backfilled rollouts exist
|
||||
// (the backfill audit ledger), which thread ids this pass already processed
|
||||
@@ -50,10 +51,14 @@ export type HealMarkerSummary = {
|
||||
* Diffs the backfill audit ledger against the heal ledger: every hardlinked or
|
||||
* copied rollout whose thread id has not been processed yet, most recent first.
|
||||
*/
|
||||
export function collectPendingHealThreads(paths: CodexSessionIndexHealPaths): PendingHealThread[] {
|
||||
const processed = readProcessedHealThreads(paths)
|
||||
export async function collectPendingHealThreads(
|
||||
paths: CodexSessionIndexHealPaths
|
||||
): Promise<PendingHealThread[]> {
|
||||
const processed = await readProcessedHealThreads(paths)
|
||||
const pendingByThreadId = new Map<string, PendingHealThread>()
|
||||
for (const line of readJsonlLines(paths.auditLogPath, true)) {
|
||||
for await (const line of streamCodexSessionLedgerRecords(paths.auditLogPath, {
|
||||
throwOnReadFailure: true
|
||||
})) {
|
||||
if (line.action !== 'hardlink' && line.action !== 'copy' && line.action !== 'existing') {
|
||||
continue
|
||||
}
|
||||
@@ -94,18 +99,18 @@ function lastPathSegment(filePath: string): string {
|
||||
return filePath.split(/[\\/]/).at(-1) ?? ''
|
||||
}
|
||||
|
||||
function readProcessedHealThreads(paths: CodexSessionIndexHealPaths): {
|
||||
async function readProcessedHealThreads(paths: CodexSessionIndexHealPaths): Promise<{
|
||||
healedAuditRecords: Set<string>
|
||||
legacyHealedThreadIds: Set<string>
|
||||
missingAuditRecords: Set<string>
|
||||
legacyMissingThreadIds: Set<string>
|
||||
} {
|
||||
}> {
|
||||
const healedAuditRecords = new Set<string>()
|
||||
const legacyHealedThreadIds = new Set<string>()
|
||||
const missingAuditRecords = new Set<string>()
|
||||
const legacyMissingThreadIds = new Set<string>()
|
||||
const expectedRoot = normalizeRuntimePathForComparison(paths.systemSessionsRoot)
|
||||
for (const line of readJsonlLines(paths.healLedgerPath)) {
|
||||
for await (const line of streamCodexSessionLedgerRecords(paths.healLedgerPath)) {
|
||||
if (
|
||||
line.v === CODEX_SESSION_INDEX_HEAL_VERSION &&
|
||||
typeof line.threadId === 'string' &&
|
||||
@@ -165,35 +170,6 @@ export function appendHealLedgerRecord(
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonlLines(filePath: string, throwOnReadFailure = false): Record<string, unknown>[] {
|
||||
let contents: string
|
||||
try {
|
||||
contents = readFileSync(filePath, 'utf-8')
|
||||
} catch (error) {
|
||||
if (throwOnReadFailure && !isNotFoundError(error)) {
|
||||
// Why: the audit is the heal work queue. Treating EACCES/EIO as empty
|
||||
// would write a completion marker that permanently skips every session.
|
||||
throw error
|
||||
}
|
||||
return []
|
||||
}
|
||||
const lines: Record<string, unknown>[] = []
|
||||
for (const raw of contents.split('\n')) {
|
||||
if (!raw.trim()) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
lines.push(parsed as Record<string, unknown>)
|
||||
}
|
||||
} catch {
|
||||
// Skip torn/corrupt lines; both ledgers are append-only diagnostics.
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
export function readAuditLogSize(auditLogPath: string): number {
|
||||
try {
|
||||
return statSync(auditLogPath).size
|
||||
@@ -225,9 +201,13 @@ export function isHealMarkerCurrent(
|
||||
unsupportedAt?: unknown
|
||||
retryableFailureAt?: unknown
|
||||
}
|
||||
// Why: one Windows directory has several spellings (drive case, separators),
|
||||
// so a raw compare re-drives the whole heal for what is the same target.
|
||||
if (
|
||||
marker.version !== CODEX_SESSION_INDEX_HEAL_VERSION ||
|
||||
marker.systemSessionsRoot !== paths.systemSessionsRoot
|
||||
typeof marker.systemSessionsRoot !== 'string' ||
|
||||
normalizeRuntimePathForComparison(marker.systemSessionsRoot) !==
|
||||
normalizeRuntimePathForComparison(paths.systemSessionsRoot)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export async function runCodexSessionIndexHeal(
|
||||
}
|
||||
}
|
||||
|
||||
const pending = collectPendingHealThreads(paths)
|
||||
const pending = await collectPendingHealThreads(paths)
|
||||
const summary: CodexSessionIndexHealSummary = {
|
||||
outcome: 'completed',
|
||||
pendingThreads: pending.length,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { createInterface } from 'node:readline'
|
||||
|
||||
/**
|
||||
* Streams an append-only JSONL ledger one record at a time.
|
||||
*
|
||||
* Why: the backfill audit holds one line per published session file and neither
|
||||
* ledger is ever compacted, so a large Codex history makes a `readFileSync` plus
|
||||
* whole-file `JSON.parse` a multi-megabyte block of the Electron main thread.
|
||||
* Reading chunk by chunk keeps the window responsive while the pass runs.
|
||||
*/
|
||||
export async function* streamCodexSessionLedgerRecords(
|
||||
filePath: string,
|
||||
options: { throwOnReadFailure?: boolean } = {}
|
||||
): AsyncGenerator<Record<string, unknown>> {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(filePath, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
try {
|
||||
for await (const raw of lines) {
|
||||
const record = parseLedgerRecord(raw)
|
||||
if (record) {
|
||||
yield record
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (options.throwOnReadFailure && !isNotFoundError(error)) {
|
||||
// Why: the audit is the heal work queue. Treating EACCES/EIO as empty
|
||||
// would write a completion marker that permanently skips every session.
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
lines.close()
|
||||
}
|
||||
}
|
||||
|
||||
function parseLedgerRecord(raw: string): Record<string, unknown> | null {
|
||||
if (!raw.trim()) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
// Torn tails are quarantined by the writer's leading newline; skip them.
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isNotFoundError(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
||||
}
|
||||
@@ -94,6 +94,57 @@ describe('createCodexSessionMigrationScheduler', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('publishes the baseline while a Codex pane is still open', async () => {
|
||||
vi.setSystemTime(new Date('2026-08-05T10:00:00Z'))
|
||||
const prepareScheduledRun = vi.fn()
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
prepareScheduledRun,
|
||||
startBackfill,
|
||||
startIndexHeal: vi.fn().mockResolvedValue(null),
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.beginLaunch('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledOnce())
|
||||
|
||||
const options = startBackfill.mock.calls[0]?.[0]
|
||||
// The open pane only holds its own date pending; publication is not blocked.
|
||||
expect(options?.retainPendingScanDates).toBe(true)
|
||||
expect(options?.canWriteCompletionMarker?.()).toBe(true)
|
||||
// Preparation is handed the dates so it can persist them before the walk.
|
||||
expect(prepareScheduledRun).toHaveBeenCalledWith([['2026', '08', '05']])
|
||||
})
|
||||
|
||||
it('hands preparation every date a cross-midnight launch spanned', async () => {
|
||||
vi.setSystemTime(new Date('2026-08-05T23:59:59Z'))
|
||||
const prepareScheduledRun = vi.fn()
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
prepareScheduledRun,
|
||||
startBackfill: vi.fn().mockResolvedValue({ stopped: false }),
|
||||
startIndexHeal: vi.fn().mockResolvedValue(null),
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.beginLaunch('pty-1')
|
||||
vi.setSystemTime(new Date('2026-08-07T01:00:00Z'))
|
||||
scheduler.finishLaunch('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(prepareScheduledRun).toHaveBeenLastCalledWith([
|
||||
['2026', '08', '05'],
|
||||
['2026', '08', '06'],
|
||||
['2026', '08', '07']
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps launch passes full when no completed baseline can cover older failures', async () => {
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
@@ -375,7 +426,7 @@ describe('createCodexSessionMigrationScheduler', () => {
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledOnce())
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ writeCompletionMarker: false }),
|
||||
expect.objectContaining({ retainPendingScanDates: true }),
|
||||
undefined
|
||||
)
|
||||
expect(finishScheduledRun).not.toHaveBeenCalled()
|
||||
@@ -392,8 +443,8 @@ describe('createCodexSessionMigrationScheduler', () => {
|
||||
['2026', '08', '07']
|
||||
],
|
||||
ignoreCompletionMarker: true,
|
||||
writeCompletionMarker: true,
|
||||
writeBoundedCompletionMarker: true
|
||||
retainPendingScanDates: false,
|
||||
fullScanRequired: false
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
@@ -421,7 +472,7 @@ describe('createCodexSessionMigrationScheduler', () => {
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledOnce())
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ scanDates: undefined, writeBoundedCompletionMarker: false }),
|
||||
expect.objectContaining({ scanDates: undefined, fullScanRequired: true }),
|
||||
undefined
|
||||
)
|
||||
|
||||
@@ -429,7 +480,7 @@ describe('createCodexSessionMigrationScheduler', () => {
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2))
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ scanDates: undefined, writeBoundedCompletionMarker: false }),
|
||||
expect.objectContaining({ scanDates: undefined, fullScanRequired: true }),
|
||||
undefined
|
||||
)
|
||||
await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce())
|
||||
@@ -485,7 +536,7 @@ describe('createCodexSessionMigrationScheduler', () => {
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(startBackfill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ scanDates: undefined, writeBoundedCompletionMarker: false }),
|
||||
expect.objectContaining({ scanDates: undefined, fullScanRequired: true }),
|
||||
undefined
|
||||
)
|
||||
await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce())
|
||||
@@ -510,8 +561,8 @@ describe('createCodexSessionMigrationScheduler', () => {
|
||||
expect(startBackfill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scanDates: expect.any(Array),
|
||||
writeCompletionMarker: false,
|
||||
writeBoundedCompletionMarker: false
|
||||
retainPendingScanDates: true,
|
||||
fullScanRequired: false
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
@@ -536,8 +587,8 @@ describe('createCodexSessionMigrationScheduler', () => {
|
||||
expect(startBackfill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scanDates: expect.any(Array),
|
||||
writeCompletionMarker: false,
|
||||
writeBoundedCompletionMarker: false
|
||||
retainPendingScanDates: true,
|
||||
fullScanRequired: false
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
@@ -560,14 +611,14 @@ describe('createCodexSessionMigrationScheduler', () => {
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(startBackfill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ writeCompletionMarker: false }),
|
||||
expect.objectContaining({ retainPendingScanDates: true }),
|
||||
undefined
|
||||
)
|
||||
|
||||
scheduler.finishLaunch('stable-pty', 4)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ writeCompletionMarker: true }),
|
||||
expect.objectContaining({ retainPendingScanDates: false }),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
@@ -588,14 +639,14 @@ describe('createCodexSessionMigrationScheduler', () => {
|
||||
scheduler.beginLaunch('stable-pty', false, new Date(), 2)
|
||||
await vi.advanceTimersByTimeAsync(61_000)
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ writeCompletionMarker: false }),
|
||||
expect.objectContaining({ retainPendingScanDates: true }),
|
||||
undefined
|
||||
)
|
||||
|
||||
scheduler.finishLaunch('stable-pty', 3)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ writeCompletionMarker: true }),
|
||||
expect.objectContaining({ retainPendingScanDates: false }),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
@@ -623,7 +674,7 @@ describe('createCodexSessionMigrationScheduler', () => {
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(startBackfill).toHaveBeenCalledTimes(2)
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ writeCompletionMarker: true }),
|
||||
expect.objectContaining({ retainPendingScanDates: false }),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
@@ -646,7 +697,7 @@ describe('createCodexSessionMigrationScheduler', () => {
|
||||
scheduler.beginLaunch('stable-pty', false, new Date(), 5)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ writeCompletionMarker: false }),
|
||||
expect.objectContaining({ retainPendingScanDates: true }),
|
||||
undefined
|
||||
)
|
||||
|
||||
@@ -654,7 +705,7 @@ describe('createCodexSessionMigrationScheduler', () => {
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(startBackfill).toHaveBeenCalledTimes(2)
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ writeCompletionMarker: true }),
|
||||
expect.objectContaining({ retainPendingScanDates: false }),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
@@ -675,7 +726,7 @@ describe('createCodexSessionMigrationScheduler', () => {
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(startBackfill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ writeCompletionMarker: false }),
|
||||
expect.objectContaining({ retainPendingScanDates: true }),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { getCodexSessionBackfillDate } from './codex-session-backfill-date'
|
||||
import {
|
||||
compareCodexSessionBackfillDates,
|
||||
getCodexSessionBackfillDate,
|
||||
getCodexSessionBackfillDatesBetween,
|
||||
toCodexSessionBackfillDateKey
|
||||
} from './codex-session-backfill-scan-dates'
|
||||
import { CodexSessionMigrationIgnoredLaunches } from './codex-session-migration-ignored-launches'
|
||||
import { CodexSessionMigrationRecentExits } from './codex-session-migration-recent-exits'
|
||||
import type {
|
||||
@@ -29,7 +34,7 @@ export function createCodexSessionMigrationScheduler(args: {
|
||||
isEligible: () => boolean
|
||||
isQuitting: () => boolean
|
||||
resolveSystemCodexHomePathOverride: () => string | undefined
|
||||
prepareScheduledRun?: () => boolean | void
|
||||
prepareScheduledRun?: (scanDates: readonly CodexSessionBackfillDate[]) => boolean | void
|
||||
finishScheduledRun?: () => void
|
||||
startBackfill: MigrationRun
|
||||
startIndexHeal: MigrationRun
|
||||
@@ -63,7 +68,7 @@ export function createCodexSessionMigrationScheduler(args: {
|
||||
pendingScheduledRunGeneration ?? requestedGeneration
|
||||
)
|
||||
for (const scanDate of requestedScanDates) {
|
||||
pendingScanDates.set(scanDate.join('-'), scanDate)
|
||||
pendingScanDates.set(toCodexSessionBackfillDateKey(scanDate), scanDate)
|
||||
}
|
||||
pendingFullScan ||= requestedFullScan
|
||||
}
|
||||
@@ -77,17 +82,16 @@ export function createCodexSessionMigrationScheduler(args: {
|
||||
}
|
||||
const isScheduledRun = pendingScheduledRunGeneration !== null
|
||||
const activeScheduledRunGeneration = pendingScheduledRunGeneration
|
||||
const runScanDates = [...pendingScanDates.values()].sort(compareCodexSessionBackfillDates)
|
||||
let preparationNeedsFullScan = false
|
||||
if (isScheduledRun) {
|
||||
pendingScheduledRunGeneration = null
|
||||
// Why: an older active pass can rewrite the marker after launch invalidates it.
|
||||
preparationNeedsFullScan = args.prepareScheduledRun?.() === true
|
||||
// Why: preparation persists these dates so an abnormal exit still yields a
|
||||
// bounded recovery window instead of another full-tree walk.
|
||||
preparationNeedsFullScan = args.prepareScheduledRun?.(runScanDates) === true
|
||||
}
|
||||
const fullScanRequired = pendingFullScan || preparationNeedsFullScan
|
||||
const scanDates =
|
||||
!fullScanRequired && pendingScanDates.size > 0
|
||||
? [...pendingScanDates.values()].sort(compareBackfillDates)
|
||||
: undefined
|
||||
const scanDates = !fullScanRequired && runScanDates.length > 0 ? runScanDates : undefined
|
||||
pendingScanDates.clear()
|
||||
pendingFullScan = false
|
||||
activeRunStopObserved = false
|
||||
@@ -105,12 +109,12 @@ export function createCodexSessionMigrationScheduler(args: {
|
||||
{
|
||||
shouldStop,
|
||||
scanDates,
|
||||
fullScanRequired,
|
||||
ignoreCompletionMarker: isScheduledRun,
|
||||
writeCompletionMarker: activeLaunches.size === 0,
|
||||
writeBoundedCompletionMarker:
|
||||
isScheduledRun && activeLaunches.size === 0 && !fullScanRequired,
|
||||
// Why: a live pane keeps appending to its own date directory, so that
|
||||
// date stays pending — but the historical baseline is still certified.
|
||||
retainPendingScanDates: activeLaunches.size > 0,
|
||||
canWriteCompletionMarker: () =>
|
||||
activeLaunches.size === 0 &&
|
||||
scheduledTimer === null &&
|
||||
pendingScheduledRunGeneration === null &&
|
||||
(!isScheduledRun || activeScheduledRunGeneration === scheduledRunGeneration)
|
||||
@@ -144,7 +148,7 @@ export function createCodexSessionMigrationScheduler(args: {
|
||||
)
|
||||
pendingFullScan ||= fullScanRequired
|
||||
for (const scanDate of scanDates ?? []) {
|
||||
pendingScanDates.set(scanDate.join('-'), scanDate)
|
||||
pendingScanDates.set(toCodexSessionBackfillDateKey(scanDate), scanDate)
|
||||
}
|
||||
}
|
||||
if (
|
||||
@@ -168,9 +172,9 @@ export function createCodexSessionMigrationScheduler(args: {
|
||||
scheduledTimer = null
|
||||
if (generation !== undefined) {
|
||||
const currentDate = getCodexSessionBackfillDate()
|
||||
scheduledScanDates.set(currentDate.join('-'), currentDate)
|
||||
scheduledScanDates.set(toCodexSessionBackfillDateKey(currentDate), currentDate)
|
||||
}
|
||||
const scanDates = [...scheduledScanDates.values()].sort(compareBackfillDates)
|
||||
const scanDates = [...scheduledScanDates.values()].sort(compareCodexSessionBackfillDates)
|
||||
scheduledScanDates.clear()
|
||||
const fullScanRequired = scheduledFullScan
|
||||
scheduledFullScan = false
|
||||
@@ -186,7 +190,7 @@ export function createCodexSessionMigrationScheduler(args: {
|
||||
scheduledRunGeneration += 1
|
||||
scheduledFullScan ||= fullScanRequired
|
||||
const launchDate = getCodexSessionBackfillDate()
|
||||
scheduledScanDates.set(launchDate.join('-'), launchDate)
|
||||
scheduledScanDates.set(toCodexSessionBackfillDateKey(launchDate), launchDate)
|
||||
armScheduledRun(scheduledRunGeneration)
|
||||
}
|
||||
|
||||
@@ -235,7 +239,7 @@ export function createCodexSessionMigrationScheduler(args: {
|
||||
return
|
||||
}
|
||||
for (const scanDate of getCodexSessionBackfillDatesBetween(startedAt, new Date())) {
|
||||
scheduledScanDates.set(scanDate.join('-'), scanDate)
|
||||
scheduledScanDates.set(toCodexSessionBackfillDateKey(scanDate), scanDate)
|
||||
}
|
||||
scheduleRun()
|
||||
},
|
||||
@@ -249,31 +253,6 @@ export function createCodexSessionMigrationScheduler(args: {
|
||||
}
|
||||
}
|
||||
|
||||
function getCodexSessionBackfillDatesBetween(
|
||||
startedAt: Date,
|
||||
finishedAt: Date
|
||||
): CodexSessionBackfillDate[] {
|
||||
const dates: CodexSessionBackfillDate[] = []
|
||||
const cursor = new Date(
|
||||
Date.UTC(startedAt.getUTCFullYear(), startedAt.getUTCMonth(), startedAt.getUTCDate())
|
||||
)
|
||||
const last = new Date(
|
||||
Date.UTC(finishedAt.getUTCFullYear(), finishedAt.getUTCMonth(), finishedAt.getUTCDate())
|
||||
)
|
||||
while (cursor <= last) {
|
||||
dates.push(getCodexSessionBackfillDate(cursor))
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1)
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
function compareBackfillDates(
|
||||
left: CodexSessionBackfillDate,
|
||||
right: CodexSessionBackfillDate
|
||||
): number {
|
||||
return left.join('-').localeCompare(right.join('-'))
|
||||
}
|
||||
|
||||
function isStoppedMigrationResult(result: unknown): boolean {
|
||||
return Boolean(result && typeof result === 'object' && 'stopped' in result && result.stopped)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
CodexHookTrustGrantRequest,
|
||||
CodexHookTrustGrantSessionResult
|
||||
} from './codex-app-server-client'
|
||||
import type { CodexManagedTrustGrantPlan } from './codex-hook-trust-grant'
|
||||
import type { CodexTrustEntry } from './config-toml-trust'
|
||||
|
||||
const testState = {
|
||||
fakeHomeDir: '',
|
||||
userDataDir: '',
|
||||
previousUserDataPath: undefined as string | undefined
|
||||
}
|
||||
|
||||
vi.mock('node:os', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
|
||||
const actual = await vi.importActual<typeof import('node:os')>('node:os')
|
||||
return { ...actual, homedir: () => testState.fakeHomeDir }
|
||||
})
|
||||
|
||||
const { CodexAppServerUnsupportedError } = await import('./codex-app-server-client')
|
||||
const { codexAppServerCapabilityCache } = await import('./codex-app-server-capability-cache')
|
||||
const { _internals, grantManagedCodexHookTrust } = await import('./codex-hook-trust-grant')
|
||||
const { markCodexProjectTrusted } = await import('../agent-trust-presets')
|
||||
const { setCodexTrustGrantTelemetry } = await import('./codex-trust-grant-telemetry')
|
||||
const {
|
||||
computeTrustKey,
|
||||
computeTrustedHash,
|
||||
normalizeHookTrustKeyForLookup,
|
||||
readHookTrustEntries,
|
||||
upsertHookTrustEntries
|
||||
} = await import('./config-toml-trust')
|
||||
|
||||
let runtimeHomeDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-concurrent-home-'))
|
||||
testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-concurrent-userdata-'))
|
||||
testState.previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
process.env.ORCA_USER_DATA_PATH = testState.userDataDir
|
||||
runtimeHomeDir = join(testState.userDataDir, 'codex-runtime-home', 'home')
|
||||
mkdirSync(runtimeHomeDir, { recursive: true })
|
||||
writeFileSync(join(runtimeHomeDir, 'hooks.json'), '{"hooks":{}}\n', 'utf-8')
|
||||
mkdirSync(join(testState.fakeHomeDir, '.codex'), { recursive: true })
|
||||
codexAppServerCapabilityCache.clear()
|
||||
_internals.resetDiagnostics()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_internals.setGrantSessionRunner(null)
|
||||
setCodexTrustGrantTelemetry(() => {})
|
||||
codexAppServerCapabilityCache.clear()
|
||||
if (testState.previousUserDataPath === undefined) {
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
} else {
|
||||
process.env.ORCA_USER_DATA_PATH = testState.previousUserDataPath
|
||||
}
|
||||
delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC
|
||||
rmSync(testState.fakeHomeDir, { recursive: true, force: true })
|
||||
rmSync(testState.userDataDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const MANAGED_COMMAND = "/bin/sh '/tmp/orca/codex-hook.sh'"
|
||||
|
||||
function managedEntry(eventLabel: CodexTrustEntry['eventLabel']): CodexTrustEntry {
|
||||
return {
|
||||
sourcePath: join(runtimeHomeDir, 'hooks.json'),
|
||||
eventLabel,
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: MANAGED_COMMAND,
|
||||
timeoutSec: 10
|
||||
}
|
||||
}
|
||||
|
||||
function buildPlan(
|
||||
entries: CodexTrustEntry[],
|
||||
overrides: Partial<CodexManagedTrustGrantPlan> = {}
|
||||
): CodexManagedTrustGrantPlan {
|
||||
return {
|
||||
runtimeHomePath: runtimeHomeDir,
|
||||
tomlPath: join(runtimeHomeDir, 'config.toml'),
|
||||
managedCommand: MANAGED_COMMAND,
|
||||
managedEntries: entries,
|
||||
host: { kind: 'native' },
|
||||
telemetryLane: 'real-home',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const tick = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
/** Stands in for codex app-server: really writes the trust entries into
|
||||
* config.toml across an await, like the RPC does. */
|
||||
function writingSessionRunner(args: {
|
||||
tomlPath: string
|
||||
entries: CodexTrustEntry[]
|
||||
hashPrefix: string
|
||||
gate?: Promise<void>
|
||||
outcome?: 'granted' | 'verify-failed'
|
||||
}) {
|
||||
return async (
|
||||
_request: CodexHookTrustGrantRequest
|
||||
): Promise<CodexHookTrustGrantSessionResult> => {
|
||||
const granted = args.entries.map((entry) => {
|
||||
const key = computeTrustKey(entry)
|
||||
return {
|
||||
key,
|
||||
normalizedKey: normalizeHookTrustKeyForLookup(key),
|
||||
trustedHash: `${args.hashPrefix}${entry.eventLabel}`
|
||||
}
|
||||
})
|
||||
await tick()
|
||||
upsertHookTrustEntries(
|
||||
args.tomlPath,
|
||||
args.entries.map((entry, index) => ({ ...entry, trustedHash: granted[index].trustedHash }))
|
||||
)
|
||||
if (args.gate) {
|
||||
await args.gate
|
||||
}
|
||||
if (args.outcome === 'verify-failed') {
|
||||
return {
|
||||
outcome: 'verify-failed',
|
||||
reason: 'listed hash mismatch',
|
||||
reasonClass: 'post-grant-mismatch'
|
||||
}
|
||||
}
|
||||
return { outcome: 'granted', wroteTrust: true, entries: granted }
|
||||
}
|
||||
}
|
||||
|
||||
describe('two Codex pane launches against one config.toml', () => {
|
||||
it('does not let a failing launch roll back a concurrent launch that already succeeded', async () => {
|
||||
// Why warm: on a cold host the shared capability probe incidentally
|
||||
// serializes the two launches. Once the host is known-supported that
|
||||
// dedupe is bypassed and the per-file lane is the only thing left.
|
||||
codexAppServerCapabilityCache.rememberSupported('native')
|
||||
const tomlPath = join(runtimeHomeDir, 'config.toml')
|
||||
const entries = [managedEntry('session_start')]
|
||||
let sessionsInFlight = 0
|
||||
let maxSessionsInFlight = 0
|
||||
let call = 0
|
||||
let releaseFirst!: () => void
|
||||
const firstGate = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
|
||||
_internals.setGrantSessionRunner(async (request) => {
|
||||
sessionsInFlight += 1
|
||||
maxSessionsInFlight = Math.max(maxSessionsInFlight, sessionsInFlight)
|
||||
call += 1
|
||||
const isFirst = call === 1
|
||||
try {
|
||||
return await writingSessionRunner({
|
||||
tomlPath,
|
||||
entries,
|
||||
hashPrefix: isFirst ? 'sha256:doomed-' : 'sha256:survivor-',
|
||||
gate: isFirst ? firstGate : undefined,
|
||||
outcome: isFirst ? 'verify-failed' : 'granted'
|
||||
})(request)
|
||||
} finally {
|
||||
sessionsInFlight -= 1
|
||||
}
|
||||
})
|
||||
|
||||
const doomed = grantManagedCodexHookTrust(buildPlan(entries))
|
||||
const survivor = grantManagedCodexHookTrust(buildPlan(entries))
|
||||
await tick()
|
||||
await tick()
|
||||
releaseFirst()
|
||||
|
||||
expect(await doomed).toMatchObject({ lane: 'fallback', reason: 'verify-failed' })
|
||||
expect(await survivor).toMatchObject({ lane: 'rpc' })
|
||||
// The doomed run's rollback must not resurrect the pre-grant file over
|
||||
// the entries the survivor legitimately wrote.
|
||||
const trust = readHookTrustEntries(tomlPath)
|
||||
const key = normalizeHookTrustKeyForLookup(computeTrustKey(entries[0]))
|
||||
expect(trust.get(key)?.trustedHash).toBe('sha256:survivor-session_start')
|
||||
expect(maxSessionsInFlight).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps a concurrent markCodexProjectTrusted write out of a grant rollback window', async () => {
|
||||
codexAppServerCapabilityCache.rememberSupported('native')
|
||||
const tomlPath = join(runtimeHomeDir, 'config.toml')
|
||||
const entries = [managedEntry('session_start')]
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'orca-concurrent-ws-'))
|
||||
let releaseSession!: () => void
|
||||
const sessionGate = new Promise<void>((resolve) => {
|
||||
releaseSession = resolve
|
||||
})
|
||||
|
||||
_internals.setGrantSessionRunner(
|
||||
writingSessionRunner({
|
||||
tomlPath,
|
||||
entries,
|
||||
hashPrefix: 'sha256:doomed-',
|
||||
gate: sessionGate,
|
||||
outcome: 'verify-failed'
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const grant = grantManagedCodexHookTrust(buildPlan(entries))
|
||||
// Let the grant capture config.toml and start its session.
|
||||
await tick()
|
||||
await tick()
|
||||
const marked = markCodexProjectTrusted(workspace)
|
||||
await tick()
|
||||
// The lane must hold the preset write back until rollback has run.
|
||||
expect(readFileSync(tomlPath, 'utf-8')).not.toContain('trust_level')
|
||||
|
||||
releaseSession()
|
||||
expect(await grant).toMatchObject({ lane: 'fallback', reason: 'verify-failed' })
|
||||
await marked
|
||||
|
||||
expect(readFileSync(tomlPath, 'utf-8')).toContain('trust_level = "trusted"')
|
||||
} finally {
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('concurrent capability probes against a cold host', () => {
|
||||
it('shares one app-server session between two launches on different config files', async () => {
|
||||
const secondHome = join(testState.userDataDir, 'second-runtime-home')
|
||||
mkdirSync(secondHome, { recursive: true })
|
||||
writeFileSync(join(secondHome, 'hooks.json'), '{"hooks":{}}\n', 'utf-8')
|
||||
const entries = [managedEntry('session_start')]
|
||||
let sessions = 0
|
||||
let releaseProbe!: () => void
|
||||
const probeGate = new Promise<void>((resolve) => {
|
||||
releaseProbe = resolve
|
||||
})
|
||||
_internals.setGrantSessionRunner(async () => {
|
||||
sessions += 1
|
||||
await probeGate
|
||||
throw new CodexAppServerUnsupportedError('hooks/grantTrust: method not found')
|
||||
})
|
||||
|
||||
const first = grantManagedCodexHookTrust(buildPlan(entries))
|
||||
const second = grantManagedCodexHookTrust(
|
||||
buildPlan([{ ...entries[0], sourcePath: join(secondHome, 'hooks.json') }], {
|
||||
runtimeHomePath: secondHome,
|
||||
tomlPath: join(secondHome, 'config.toml')
|
||||
})
|
||||
)
|
||||
await tick()
|
||||
await tick()
|
||||
expect(sessions).toBe(1)
|
||||
releaseProbe()
|
||||
|
||||
expect(await first).toMatchObject({ lane: 'fallback', reason: 'unsupported' })
|
||||
expect(await second).toMatchObject({ lane: 'fallback', reason: 'unsupported-cached' })
|
||||
expect(sessions).toBe(1)
|
||||
})
|
||||
|
||||
it('leaves the waiter config.toml untouched when the shared probe reports unsupported', async () => {
|
||||
const secondHome = join(testState.userDataDir, 'second-runtime-home')
|
||||
mkdirSync(secondHome, { recursive: true })
|
||||
writeFileSync(join(secondHome, 'hooks.json'), '{"hooks":{}}\n', 'utf-8')
|
||||
const waiterToml = join(secondHome, 'config.toml')
|
||||
const waiterEntry = {
|
||||
...managedEntry('session_start'),
|
||||
sourcePath: join(secondHome, 'hooks.json')
|
||||
}
|
||||
// Self-computed trust the fallback lane already wrote for this pane.
|
||||
upsertHookTrustEntries(waiterToml, [
|
||||
{ ...waiterEntry, trustedHash: computeTrustedHash(waiterEntry) }
|
||||
])
|
||||
const before = readFileSync(waiterToml, 'utf-8')
|
||||
|
||||
let releaseProbe!: () => void
|
||||
const probeGate = new Promise<void>((resolve) => {
|
||||
releaseProbe = resolve
|
||||
})
|
||||
_internals.setGrantSessionRunner(async () => {
|
||||
await probeGate
|
||||
throw new CodexAppServerUnsupportedError('hooks/grantTrust: method not found')
|
||||
})
|
||||
|
||||
const first = grantManagedCodexHookTrust(buildPlan([managedEntry('session_start')]))
|
||||
const waiter = grantManagedCodexHookTrust(
|
||||
buildPlan([waiterEntry], { runtimeHomePath: secondHome, tomlPath: waiterToml })
|
||||
)
|
||||
await tick()
|
||||
releaseProbe()
|
||||
await first
|
||||
expect(await waiter).toMatchObject({ lane: 'fallback', reason: 'unsupported-cached' })
|
||||
expect(readFileSync(waiterToml, 'utf-8')).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('host-scoped transient cooldown', () => {
|
||||
// Why: the cooldown lives outside the per-file lane, so a failure on one
|
||||
// pane's config.toml has to suppress every other pane on that host and
|
||||
// nothing on a different one.
|
||||
it('suppresses a second config.toml on the same host but not another host', async () => {
|
||||
const secondHome = join(testState.userDataDir, 'second-runtime-home')
|
||||
mkdirSync(secondHome, { recursive: true })
|
||||
writeFileSync(join(secondHome, 'hooks.json'), '{"hooks":{}}\n', 'utf-8')
|
||||
const entries = [managedEntry('session_start')]
|
||||
let calls = 0
|
||||
_internals.setGrantSessionRunner(() => {
|
||||
calls += 1
|
||||
throw new Error('spawn ETIMEDOUT')
|
||||
})
|
||||
|
||||
expect(await grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
|
||||
lane: 'fallback',
|
||||
reason: 'error'
|
||||
})
|
||||
expect(
|
||||
await grantManagedCodexHookTrust(
|
||||
buildPlan([{ ...entries[0], sourcePath: join(secondHome, 'hooks.json') }], {
|
||||
runtimeHomePath: secondHome,
|
||||
tomlPath: join(secondHome, 'config.toml')
|
||||
})
|
||||
)
|
||||
).toMatchObject({ lane: 'fallback', reason: 'retry-cached' })
|
||||
expect(calls).toBe(1)
|
||||
|
||||
// A WSL distro runs its own codex binary; the native cooldown must not reach it.
|
||||
expect(
|
||||
await grantManagedCodexHookTrust(
|
||||
buildPlan(entries, {
|
||||
host: { kind: 'wsl', distro: 'Ubuntu', linuxRuntimeHome: '/home/u/.codex' }
|
||||
})
|
||||
)
|
||||
).toMatchObject({ lane: 'fallback', reason: 'error' })
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
// Why: the cooldown check runs before the lane, so a launch already admitted
|
||||
// can succeed after a sibling failed. That proof of health must clear the
|
||||
// sibling's cooldown instead of suppressing the host for five more minutes.
|
||||
it('lets a concurrent success clear a cooldown a sibling failure just set', async () => {
|
||||
codexAppServerCapabilityCache.rememberSupported('native')
|
||||
const secondHome = join(testState.userDataDir, 'second-runtime-home')
|
||||
mkdirSync(secondHome, { recursive: true })
|
||||
writeFileSync(join(secondHome, 'hooks.json'), '{"hooks":{}}\n', 'utf-8')
|
||||
const entries = [managedEntry('session_start')]
|
||||
const okEntry = { ...entries[0], sourcePath: join(secondHome, 'hooks.json') }
|
||||
const okToml = join(secondHome, 'config.toml')
|
||||
const okPlan = buildPlan([okEntry], { runtimeHomePath: secondHome, tomlPath: okToml })
|
||||
|
||||
let releaseFailure!: () => void
|
||||
const failureGate = new Promise<void>((resolve) => {
|
||||
releaseFailure = resolve
|
||||
})
|
||||
let sessions = 0
|
||||
_internals.setGrantSessionRunner(async (request) => {
|
||||
sessions += 1
|
||||
if (request.hooksListCwd === runtimeHomeDir) {
|
||||
await failureGate
|
||||
throw new Error('spawn ETIMEDOUT')
|
||||
}
|
||||
return writingSessionRunner({
|
||||
tomlPath: okToml,
|
||||
entries: [okEntry],
|
||||
hashPrefix: 'sha256:ok-'
|
||||
})(request)
|
||||
})
|
||||
|
||||
const failing = grantManagedCodexHookTrust(buildPlan(entries))
|
||||
const succeeding = grantManagedCodexHookTrust(okPlan)
|
||||
releaseFailure()
|
||||
expect(await failing).toMatchObject({ lane: 'fallback', reason: 'error' })
|
||||
expect(await succeeding).toMatchObject({ lane: 'rpc' })
|
||||
|
||||
// A later launch on the same host must reach the RPC, not the cooldown.
|
||||
// The ledger is shared across runtime homes, so clear it to force a session.
|
||||
rmSync(join(testState.userDataDir, 'codex-runtime-home', 'trust-grant-ledger.json'), {
|
||||
force: true
|
||||
})
|
||||
expect(sessions).toBe(2)
|
||||
expect(await grantManagedCodexHookTrust(okPlan)).toMatchObject({ lane: 'rpc' })
|
||||
expect(sessions).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reentrancy under concurrency', () => {
|
||||
it('completes a grant nested inside an installer that already holds both lanes', async () => {
|
||||
const { runExclusivelyForCodexTrustConfig } =
|
||||
await import('./codex-trust-config-mutation-queue')
|
||||
const entries = [managedEntry('session_start')]
|
||||
const tomlPath = join(runtimeHomeDir, 'config.toml')
|
||||
const systemToml = join(testState.fakeHomeDir, '.codex', 'config.toml')
|
||||
_internals.setGrantSessionRunner(
|
||||
writingSessionRunner({ tomlPath, entries, hashPrefix: 'sha256:nested-' })
|
||||
)
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'orca-nested-ws-'))
|
||||
try {
|
||||
// Installer lock order: runtime then system, with a grant and a preset
|
||||
// write nested inside both.
|
||||
const outcome = await runExclusivelyForCodexTrustConfig(tomlPath, () =>
|
||||
runExclusivelyForCodexTrustConfig(systemToml, async () => {
|
||||
await markCodexProjectTrusted(workspace)
|
||||
return grantManagedCodexHookTrust(buildPlan(entries))
|
||||
})
|
||||
)
|
||||
expect(outcome).toMatchObject({ lane: 'rpc' })
|
||||
expect(readFileSync(tomlPath, 'utf-8')).toContain('trust_level = "trusted"')
|
||||
} finally {
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
}
|
||||
}, 5000)
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue'
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void; reject: (e: unknown) => void } {
|
||||
let resolve!: () => void
|
||||
let reject!: (e: unknown) => void
|
||||
const promise = new Promise<void>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('runExclusivelyForCodexTrustConfig', () => {
|
||||
// Why: the grant lane runs inside the installer that already owns the file;
|
||||
// a non-reentrant lane would queue it behind itself and never settle.
|
||||
it('passes through a nested acquire of a lane the caller already holds', async () => {
|
||||
const nested = await runExclusivelyForCodexTrustConfig('/a/config.toml', () =>
|
||||
runExclusivelyForCodexTrustConfig('/a/config.toml', () => Promise.resolve('inner'))
|
||||
)
|
||||
expect(nested).toBe('inner')
|
||||
})
|
||||
|
||||
it('still queues an unrelated lane acquired from inside another lane', async () => {
|
||||
const gate = deferred()
|
||||
let innerRan = false
|
||||
const blocking = runExclusivelyForCodexTrustConfig('/b/config.toml', () => gate.promise)
|
||||
const nested = runExclusivelyForCodexTrustConfig('/a/config.toml', () =>
|
||||
runExclusivelyForCodexTrustConfig('/b/config.toml', () => {
|
||||
innerRan = true
|
||||
return Promise.resolve()
|
||||
})
|
||||
)
|
||||
await Promise.resolve()
|
||||
expect(innerRan).toBe(false)
|
||||
gate.resolve()
|
||||
await blocking
|
||||
await nested
|
||||
expect(innerRan).toBe(true)
|
||||
})
|
||||
|
||||
it('runs one mutation at a time per config.toml', async () => {
|
||||
const order: string[] = []
|
||||
const first = deferred()
|
||||
const second = deferred()
|
||||
|
||||
const a = runExclusivelyForCodexTrustConfig('/home/.codex/config.toml', async () => {
|
||||
order.push('a:start')
|
||||
await first.promise
|
||||
order.push('a:end')
|
||||
return 'a'
|
||||
})
|
||||
const b = runExclusivelyForCodexTrustConfig('/home/.codex/config.toml', async () => {
|
||||
order.push('b:start')
|
||||
await second.promise
|
||||
order.push('b:end')
|
||||
return 'b'
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(order).toEqual(['a:start'])
|
||||
first.resolve()
|
||||
await a
|
||||
second.resolve()
|
||||
await b
|
||||
expect(order).toEqual(['a:start', 'a:end', 'b:start', 'b:end'])
|
||||
})
|
||||
|
||||
it('keeps distinct config.toml paths independent', async () => {
|
||||
const gate = deferred()
|
||||
let secondRan = false
|
||||
const blocked = runExclusivelyForCodexTrustConfig('/a/config.toml', () => gate.promise)
|
||||
await runExclusivelyForCodexTrustConfig('/b/config.toml', async () => {
|
||||
secondRan = true
|
||||
})
|
||||
expect(secondRan).toBe(true)
|
||||
gate.resolve()
|
||||
await blocked
|
||||
})
|
||||
|
||||
it('keeps the queue alive after a rejected mutation', async () => {
|
||||
const failing = runExclusivelyForCodexTrustConfig('/a/config.toml', () =>
|
||||
Promise.reject(new Error('grant blew up'))
|
||||
)
|
||||
await expect(failing).rejects.toThrow('grant blew up')
|
||||
await expect(
|
||||
runExclusivelyForCodexTrustConfig('/a/config.toml', () => Promise.resolve('next'))
|
||||
).resolves.toBe('next')
|
||||
})
|
||||
|
||||
// Why: normalized keys, so a Windows caller passing the other separator or
|
||||
// case must still land in the same lane as the run it has to wait for.
|
||||
it('serializes equivalent paths that differ only in normalization', async () => {
|
||||
const gate = deferred()
|
||||
let secondStarted = false
|
||||
const blocked = runExclusivelyForCodexTrustConfig(
|
||||
String.raw`C:\Users\Alice\.codex\config.toml`,
|
||||
() => gate.promise
|
||||
)
|
||||
const queued = runExclusivelyForCodexTrustConfig('C:/Users/Alice/.codex/config.toml', () => {
|
||||
secondStarted = true
|
||||
return Promise.resolve()
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(secondStarted).toBe(false)
|
||||
gate.resolve()
|
||||
await blocked
|
||||
await queued
|
||||
expect(secondStarted).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
|
||||
const tailByTomlPath = new Map<string, Promise<void>>()
|
||||
// Why: the grant lane runs inside the installer that already owns the file.
|
||||
// AsyncLocalStorage survives awaits, so the inner acquire can see the outer
|
||||
// one and pass through instead of queueing behind itself forever.
|
||||
const heldKeys = new AsyncLocalStorage<ReadonlySet<string>>()
|
||||
|
||||
/**
|
||||
* Serializes everything that mutates one Codex `config.toml` — hook installs,
|
||||
* trust grants, and user-hook rebases — as a single lane per file.
|
||||
*
|
||||
* Why (#16441): these used to block the main thread, so two of them could
|
||||
* never be in flight at once. Now that they await, a second run could write
|
||||
* the file between another run's capture and its restore-on-failure, undoing
|
||||
* a mutation that run never made and resurrecting trust it deliberately
|
||||
* removed.
|
||||
*/
|
||||
export function runExclusivelyForCodexTrustConfig<T>(
|
||||
tomlPath: string,
|
||||
run: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const key = normalizeRuntimePathForComparison(tomlPath)
|
||||
const held = heldKeys.getStore()
|
||||
if (held?.has(key)) {
|
||||
return run()
|
||||
}
|
||||
const owned = new Set(held ?? [])
|
||||
owned.add(key)
|
||||
const enter = (): Promise<T> => heldKeys.run(owned, run)
|
||||
const previous = tailByTomlPath.get(key) ?? Promise.resolve()
|
||||
// Why both handlers: a rejected predecessor must not cancel the queue.
|
||||
const result = previous.then(enter, enter)
|
||||
const tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
tailByTomlPath.set(key, tail)
|
||||
void tail.then(() => {
|
||||
if (tailByTomlPath.get(key) === tail) {
|
||||
tailByTomlPath.delete(key)
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { execFileSyncMock, resolveCodexCommandMock } = vi.hoisted(() => ({
|
||||
execFileSyncMock: vi.fn(),
|
||||
const { runProcessMock, resolveCodexCommandMock } = vi.hoisted(() => ({
|
||||
runProcessMock: vi.fn(),
|
||||
resolveCodexCommandMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock }))
|
||||
vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock }))
|
||||
|
||||
vi.mock('../codex-cli/command', () => ({
|
||||
resolveCodexCommand: resolveCodexCommandMock
|
||||
@@ -14,23 +14,28 @@ vi.mock('../codex-cli/command', () => ({
|
||||
import { resolveCodexTrustGrantHost } from './codex-trust-grant-host'
|
||||
|
||||
beforeEach(() => {
|
||||
execFileSyncMock.mockReset()
|
||||
runProcessMock.mockReset()
|
||||
// Stand in for the guest shell: rc banner first, then the payload inside the
|
||||
// command's own fence. The identity script execs, so no closing fence is written.
|
||||
execFileSyncMock.mockImplementation((_command: string, args: string[]) => {
|
||||
const nonce = /__ORCA_WSL_CAPTURE_BEGIN_([^_]+)__/.exec(String(args.at(-1)))?.[1] ?? ''
|
||||
return (
|
||||
'To run a command as administrator (user "root"), use "sudo <command>".\n\n' +
|
||||
`__ORCA_WSL_CAPTURE_BEGIN_${nonce}__/home/alice/.local/bin/codex\ncodex-cli 1.2.3\n`
|
||||
)
|
||||
runProcessMock.mockImplementation((spec: { args: string[] }) => {
|
||||
const nonce = /__ORCA_WSL_CAPTURE_BEGIN_([^_]+)__/.exec(String(spec.args.at(-1)))?.[1] ?? ''
|
||||
return Promise.resolve({
|
||||
code: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stderr: '',
|
||||
stdout:
|
||||
'To run a command as administrator (user "root"), use "sudo <command>".\n\n' +
|
||||
`__ORCA_WSL_CAPTURE_BEGIN_${nonce}__/home/alice/.local/bin/codex\ncodex-cli 1.2.3\n`
|
||||
})
|
||||
})
|
||||
resolveCodexCommandMock.mockReset()
|
||||
resolveCodexCommandMock.mockReturnValue(process.execPath)
|
||||
})
|
||||
|
||||
describe('resolveCodexTrustGrantHost', () => {
|
||||
it('resolves the native command once for both the binary stamp and request', () => {
|
||||
const host = resolveCodexTrustGrantHost({ kind: 'native' })
|
||||
it('resolves the native command once for both the binary stamp and request', async () => {
|
||||
const host = await resolveCodexTrustGrantHost({ kind: 'native' })
|
||||
const input = {
|
||||
runtimeHomePath: '/tmp/codex-home',
|
||||
managedCommand: '/bin/sh codex-hook.sh',
|
||||
@@ -43,11 +48,11 @@ describe('resolveCodexTrustGrantHost', () => {
|
||||
// Why: PATH/version-manager scans are synchronous launch-path I/O. Reusing
|
||||
// the resolved command keeps one grant at one scan regardless of consumers.
|
||||
expect(resolveCodexCommandMock).toHaveBeenCalledTimes(1)
|
||||
expect(execFileSyncMock).not.toHaveBeenCalled()
|
||||
expect(runProcessMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('builds WSL requests without scanning the native PATH', () => {
|
||||
const host = resolveCodexTrustGrantHost({
|
||||
it('builds WSL requests without scanning the native PATH', async () => {
|
||||
const host = await resolveCodexTrustGrantHost({
|
||||
kind: 'wsl',
|
||||
distro: 'Ubuntu',
|
||||
linuxRuntimeHome: '/home/alice/.codex-runtime'
|
||||
@@ -65,11 +70,33 @@ describe('resolveCodexTrustGrantHost', () => {
|
||||
version: 'codex-cli 1.2.3'
|
||||
})
|
||||
expect(request.invocation.command).toBe('wsl.exe')
|
||||
expect(execFileSyncMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
expect.arrayContaining(['-d', 'Ubuntu', '--exec', 'sh', '-c']),
|
||||
expect.objectContaining({ encoding: 'utf-8', timeout: 5_000, windowsHide: true })
|
||||
// Why (#16441): the identity probe runs through the shared async runner —
|
||||
// an execFileSync here froze the Electron main thread for its full timeout.
|
||||
expect(runProcessMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
program: 'wsl.exe',
|
||||
args: expect.arrayContaining(['-d', 'Ubuntu', '--exec', 'sh', '-c']),
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
)
|
||||
expect(resolveCodexCommandMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops the stamp when the guest probe fails instead of trusting partial stdout', async () => {
|
||||
runProcessMock.mockResolvedValue({
|
||||
code: 127,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: '',
|
||||
stderr: 'codex not found'
|
||||
})
|
||||
|
||||
const host = await resolveCodexTrustGrantHost({
|
||||
kind: 'wsl',
|
||||
distro: 'Ubuntu',
|
||||
linuxRuntimeHome: '/home/alice/.codex-runtime'
|
||||
})
|
||||
|
||||
expect(host.binaryStamp).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import { resolveCodexCommand } from '../codex-cli/command'
|
||||
import { getSpawnArgsForWindows } from '../win32-utils'
|
||||
import {
|
||||
@@ -36,10 +36,18 @@ export type ResolvedCodexTrustGrantHost = {
|
||||
buildRequest: (input: CodexTrustGrantRequestInput) => CodexHookTrustGrantRequest
|
||||
}
|
||||
|
||||
export function resolveCodexTrustGrantHost(host: CodexTrustGrantHost): ResolvedCodexTrustGrantHost {
|
||||
/**
|
||||
* Resolves the host that runs the codex binary for a grant session.
|
||||
*
|
||||
* Async because the WSL identity probe shells into the distro; #16441 measured
|
||||
* a 15s main-thread stall when launch prep did this work synchronously.
|
||||
*/
|
||||
export async function resolveCodexTrustGrantHost(
|
||||
host: CodexTrustGrantHost
|
||||
): Promise<ResolvedCodexTrustGrantHost> {
|
||||
if (host.kind === 'wsl') {
|
||||
return {
|
||||
binaryStamp: buildWslCodexBinaryStamp(host.distro),
|
||||
binaryStamp: await buildWslCodexBinaryStamp(host.distro),
|
||||
buildRequest: (input) => ({
|
||||
invocation: {
|
||||
command: 'wsl.exe',
|
||||
@@ -55,6 +63,10 @@ export function resolveCodexTrustGrantHost(host: CodexTrustGrantHost): ResolvedC
|
||||
}
|
||||
}
|
||||
|
||||
return resolveNativeCodexTrustGrantHost()
|
||||
}
|
||||
|
||||
export function resolveNativeCodexTrustGrantHost(): ResolvedCodexTrustGrantHost {
|
||||
// Why: command resolution scans PATH/version-manager directories. Resolve
|
||||
// once per grant and reuse it for both the binary stamp and invocation.
|
||||
const command = resolveCodexCommand()
|
||||
@@ -81,19 +93,24 @@ export function resolveCodexTrustGrantHost(host: CodexTrustGrantHost): ResolvedC
|
||||
}
|
||||
}
|
||||
|
||||
function buildWslCodexBinaryStamp(distro: string): CodexTrustGrantBinaryStamp | null {
|
||||
async function buildWslCodexBinaryStamp(
|
||||
distro: string
|
||||
): Promise<CodexTrustGrantBinaryStamp | null> {
|
||||
try {
|
||||
// Why: WSL PATH resolution happens inside the distro's login shell. The
|
||||
// resolved path plus CLI version detects upgrades without assuming UNC access.
|
||||
const probe = buildWslCodexIdentityProbe(distro)
|
||||
const stdout = execFileSync('wsl.exe', probe.args, {
|
||||
encoding: 'utf-8',
|
||||
timeout: WSL_CODEX_AVAILABILITY_TIMEOUT_MS,
|
||||
windowsHide: true
|
||||
const result = await runProcess({
|
||||
program: 'wsl.exe',
|
||||
args: probe.args,
|
||||
timeoutMs: WSL_CODEX_AVAILABILITY_TIMEOUT_MS
|
||||
})
|
||||
if (result.code !== 0 || result.timedOut) {
|
||||
return null
|
||||
}
|
||||
// Why: the split below is positional, so login-shell rc output ahead of the
|
||||
// payload would silently become the "path" and destabilize the stamp.
|
||||
const output = probe.readStdout(stdout)
|
||||
const output = probe.readStdout(result.stdout)
|
||||
if (output === null) {
|
||||
return null
|
||||
}
|
||||
@@ -114,9 +131,10 @@ export function readCodexTrustGrantLedgerHomeMatchingStamp(
|
||||
return home && binaryStampsMatch(home.binary, currentStamp) ? home : null
|
||||
}
|
||||
|
||||
export function readCurrentCodexTrustGrantLedgerHome(
|
||||
runtimeHomePath: string,
|
||||
host: CodexTrustGrantHost
|
||||
/** Native-only: the WSL stamp needs a subprocess, and status reads must stay
|
||||
* synchronous for the hook-status readers that never target a distro. */
|
||||
export function readCurrentNativeCodexTrustGrantLedgerHome(
|
||||
runtimeHomePath: string
|
||||
): CodexTrustGrantLedgerHome | null {
|
||||
try {
|
||||
const home = readCodexTrustGrantLedgerHome(runtimeHomePath)
|
||||
@@ -125,7 +143,7 @@ export function readCurrentCodexTrustGrantLedgerHome(
|
||||
// and version-manager scan when there is no recorded stamp to validate.
|
||||
return null
|
||||
}
|
||||
return binaryStampsMatch(home.binary, resolveCodexTrustGrantHost(host).binaryStamp)
|
||||
return binaryStampsMatch(home.binary, resolveNativeCodexTrustGrantHost().binaryStamp)
|
||||
? home
|
||||
: null
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Ratchet for stablyai/orca#16441.
|
||||
*
|
||||
* Codex hook trust used to be granted by blocking the Electron main thread on
|
||||
* `spawnSync` of a bundled ELECTRON_RUN_AS_NODE entry, for the whole
|
||||
* app-server deadline: 15s native, 35s WSL, ~45s on the three-session real-home
|
||||
* path. The window showed "Not Responding" during cold start and pane launch.
|
||||
*
|
||||
* The subprocess only ever existed to donate an event loop to a deliberately
|
||||
* blocked parent, so this guards the shape of the fix rather than one call
|
||||
* site: nothing on the trust-grant lane may start a child process
|
||||
* synchronously, and the forked entry must stay gone.
|
||||
*/
|
||||
const CODEX_DIR = __dirname
|
||||
|
||||
const SYNC_SPAWN_PATTERN = /\b(?:spawnSync|execSync|execFileSync|runProcessSync)\s*[(<]/
|
||||
|
||||
/** Drop comments so the prose explaining the old idiom is not an offender. */
|
||||
function codeText(contents: string): string {
|
||||
return contents
|
||||
.split('\n')
|
||||
.filter((line) => !/^\s*(?:\/\/|\/\*|\*)/.test(line))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function listCodexSourceFiles(): string[] {
|
||||
return readdirSync(CODEX_DIR).filter((name) => name.endsWith('.ts') && !name.endsWith('.test.ts'))
|
||||
}
|
||||
|
||||
describe('codex trust grant main-thread boundary', () => {
|
||||
it('starts no child process synchronously anywhere in the codex module', () => {
|
||||
const offenders = listCodexSourceFiles().filter((name) =>
|
||||
SYNC_SPAWN_PATTERN.test(codeText(readFileSync(join(CODEX_DIR, name), 'utf8')))
|
||||
)
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the forked grant entry and its blocking bridge deleted', () => {
|
||||
for (const name of [
|
||||
'codex-app-server-grant-bridge.ts',
|
||||
'codex-app-server-grant-entry.ts',
|
||||
'codex-app-server-grant-envelope.ts'
|
||||
]) {
|
||||
expect(existsSync(join(CODEX_DIR, name))).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the trust-grant lane on async entry points', () => {
|
||||
const grant = readFileSync(join(CODEX_DIR, 'codex-hook-trust-grant.ts'), 'utf8')
|
||||
expect(grant).toContain('export async function grantManagedCodexHookTrust(')
|
||||
const host = readFileSync(join(CODEX_DIR, 'codex-trust-grant-host.ts'), 'utf8')
|
||||
expect(host).toContain('export async function resolveCodexTrustGrantHost(')
|
||||
const realHome = readFileSync(join(CODEX_DIR, 'codex-real-home-hook-install.ts'), 'utf8')
|
||||
expect(realHome).toContain('}): Promise<RealHomeCodexHookLane> {')
|
||||
})
|
||||
})
|
||||
@@ -16,10 +16,9 @@ export type CodexTrustGrantFallbackReason =
|
||||
| 'retry-cached'
|
||||
| 'error'
|
||||
|
||||
/** Closed classification of `reason: 'error'` fallbacks. Errors cross the
|
||||
* grant-bridge envelope as message text (only timeout/unsupported keep their
|
||||
* name), so classes are matched on the bounded message shapes each layer
|
||||
* produces — never forwarded raw. */
|
||||
/** Closed classification of `reason: 'error'` fallbacks. Only timeout and
|
||||
* unsupported carry a stable error name, so the rest are matched on the
|
||||
* bounded message shapes each layer produces — never forwarded raw. */
|
||||
export type CodexTrustGrantErrorClass =
|
||||
| 'binary-missing'
|
||||
| 'timeout'
|
||||
|
||||
@@ -27,7 +27,7 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_internals.setSessionRunnerSync(null)
|
||||
_internals.setSessionRunner(null)
|
||||
_internals.resetRetryState()
|
||||
codexAppServerCapabilityCache.clear()
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
@@ -38,18 +38,18 @@ function command(command: string): HookCommandConfig {
|
||||
}
|
||||
|
||||
describe('real-home user hook trust rebasing', () => {
|
||||
it('writes directly without reading config or spawning Codex when user positions stay stable', () => {
|
||||
it('writes directly without reading config or spawning Codex when user positions stay stable', async () => {
|
||||
const user = command('user-hook')
|
||||
const orca = command('orca-hook')
|
||||
const before = { Stop: [{ hooks: [user] }] }
|
||||
const after = { Stop: [{ hooks: [user] }, { hooks: [orca] }] }
|
||||
let wroteHooks = false
|
||||
_internals.setSessionRunnerSync(() => {
|
||||
_internals.setSessionRunner(() => {
|
||||
throw new Error('stable positions must not open an app-server session')
|
||||
})
|
||||
|
||||
expect(
|
||||
mutateRealHomeHooksPreservingUserTrust({
|
||||
await mutateRealHomeHooksPreservingUserTrust({
|
||||
sourcePath: hooksPath,
|
||||
runtimeHomePath: root,
|
||||
tomlPath: configPath,
|
||||
@@ -67,7 +67,7 @@ describe('real-home user hook trust rebasing', () => {
|
||||
expect(existsSync(configPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('finds multiple shifted user hooks, including a handler from a mixed group', () => {
|
||||
it('finds multiple shifted user hooks, including a handler from a mixed group', async () => {
|
||||
const orca = command('orca-hook')
|
||||
const first = command('first-user')
|
||||
const second = command('second-user')
|
||||
@@ -98,7 +98,7 @@ describe('real-home user hook trust rebasing', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('carries only previously trusted states into the repair request', () => {
|
||||
it('carries only previously trusted states into the repair request', async () => {
|
||||
const orca = command('orca-hook')
|
||||
const trusted = command('trusted-user')
|
||||
const untrusted = command('untrusted-user')
|
||||
@@ -107,7 +107,7 @@ describe('real-home user hook trust rebasing', () => {
|
||||
writeFileSync(hooksPath, `${JSON.stringify({ hooks: before }, null, 2)}\n`)
|
||||
writeFileSync(configPath, '# original config\n')
|
||||
const requests: CodexUserHookTrustRebaseRequest[] = []
|
||||
_internals.setSessionRunnerSync((request) => {
|
||||
_internals.setSessionRunner(async (request) => {
|
||||
requests.push(request)
|
||||
if (request.operation === 'inspect-user-hook-trust') {
|
||||
return {
|
||||
@@ -123,7 +123,7 @@ describe('real-home user hook trust rebasing', () => {
|
||||
return { outcome: 'repaired', repaired: 1 }
|
||||
})
|
||||
|
||||
mutateRealHomeHooksPreservingUserTrust({
|
||||
await mutateRealHomeHooksPreservingUserTrust({
|
||||
sourcePath: hooksPath,
|
||||
runtimeHomePath: root,
|
||||
tomlPath: configPath,
|
||||
@@ -147,14 +147,14 @@ describe('real-home user hook trust rebasing', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('marks the host unsupported and skips further codex sessions', () => {
|
||||
it('marks the host unsupported and skips further codex sessions', async () => {
|
||||
const orca = command('orca-hook')
|
||||
const user = command('user-hook')
|
||||
const before = { Stop: [{ hooks: [orca] }, { hooks: [user] }] }
|
||||
const after = { Stop: [{ hooks: [user] }] }
|
||||
writeFileSync(configPath, '# config\n')
|
||||
let sessions = 0
|
||||
_internals.setSessionRunnerSync(() => {
|
||||
_internals.setSessionRunner(async () => {
|
||||
sessions += 1
|
||||
throw new CodexAppServerUnsupportedError('unrecognized subcommand app-server')
|
||||
})
|
||||
@@ -172,20 +172,22 @@ describe('real-home user hook trust rebasing', () => {
|
||||
}
|
||||
}
|
||||
|
||||
expect(() => mutateRealHomeHooksPreservingUserTrust(args)).toThrow('unrecognized subcommand')
|
||||
expect(() => mutateRealHomeHooksPreservingUserTrust(args)).toThrow('marked unsupported')
|
||||
await expect(mutateRealHomeHooksPreservingUserTrust(args)).rejects.toThrow(
|
||||
'unrecognized subcommand'
|
||||
)
|
||||
await expect(mutateRealHomeHooksPreservingUserTrust(args)).rejects.toThrow('marked unsupported')
|
||||
expect(sessions).toBe(1)
|
||||
expect(codexAppServerCapabilityCache.shouldTry('native')).toBe(false)
|
||||
})
|
||||
|
||||
it('cools down after a transient session failure instead of retrying every launch prep', () => {
|
||||
it('cools down after a transient session failure instead of retrying every launch prep', async () => {
|
||||
const orca = command('orca-hook')
|
||||
const user = command('user-hook')
|
||||
const before = { Stop: [{ hooks: [orca] }, { hooks: [user] }] }
|
||||
const after = { Stop: [{ hooks: [user] }] }
|
||||
writeFileSync(configPath, '# config\n')
|
||||
let sessions = 0
|
||||
_internals.setSessionRunnerSync(() => {
|
||||
_internals.setSessionRunner(async () => {
|
||||
sessions += 1
|
||||
throw new Error('pre-mutation hooks/list reported 0 of 1 moved user hooks')
|
||||
})
|
||||
@@ -203,14 +205,16 @@ describe('real-home user hook trust rebasing', () => {
|
||||
}
|
||||
}
|
||||
|
||||
expect(() => mutateRealHomeHooksPreservingUserTrust(args)).toThrow('0 of 1 moved user hooks')
|
||||
expect(() => mutateRealHomeHooksPreservingUserTrust(args)).toThrow('cooling down')
|
||||
await expect(mutateRealHomeHooksPreservingUserTrust(args)).rejects.toThrow(
|
||||
'0 of 1 moved user hooks'
|
||||
)
|
||||
await expect(mutateRealHomeHooksPreservingUserTrust(args)).rejects.toThrow('cooling down')
|
||||
expect(sessions).toBe(1)
|
||||
// Why: a transient failure must not poison the shared capability signal.
|
||||
expect(codexAppServerCapabilityCache.shouldTry('native')).toBe(true)
|
||||
})
|
||||
|
||||
it('restores both files byte-exactly when post-mutation repair fails', () => {
|
||||
it('restores both files byte-exactly when post-mutation repair fails', async () => {
|
||||
const orca = command('orca-hook')
|
||||
const user = command('user-hook')
|
||||
const before = { Stop: [{ hooks: [orca] }, { hooks: [user] }] }
|
||||
@@ -220,7 +224,7 @@ describe('real-home user hook trust rebasing', () => {
|
||||
const originalConfig = '# user formatting\r\nmodel = "x"\r\n'
|
||||
writeFileSync(hooksPath, originalHooks)
|
||||
writeFileSync(configPath, originalConfig)
|
||||
_internals.setSessionRunnerSync((request) => {
|
||||
_internals.setSessionRunner(async (request) => {
|
||||
if (request.operation === 'inspect-user-hook-trust') {
|
||||
return {
|
||||
outcome: 'inspected',
|
||||
@@ -236,7 +240,7 @@ describe('real-home user hook trust rebasing', () => {
|
||||
throw new Error('repair transport failed')
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
await expect(
|
||||
mutateRealHomeHooksPreservingUserTrust({
|
||||
sourcePath: hooksPath,
|
||||
runtimeHomePath: root,
|
||||
@@ -246,7 +250,7 @@ describe('real-home user hook trust rebasing', () => {
|
||||
writeHooks: () => writeFileSync(hooksPath, `${JSON.stringify({ hooks: after })}\n`),
|
||||
restoreHooks: () => writeFileSync(hooksPath, originalHooks)
|
||||
})
|
||||
).toThrow('repair transport failed')
|
||||
).rejects.toThrow('repair transport failed')
|
||||
expect(readFileSync(hooksPath, 'utf-8')).toBe(originalHooks)
|
||||
expect(readFileSync(configPath, 'utf-8')).toBe(originalConfig)
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
getCodexAppServerHostKey,
|
||||
type CodexAppServerHostKey
|
||||
} from './codex-app-server-capability-cache'
|
||||
import { runCodexUserHookTrustRebaseSessionSync } from './codex-app-server-grant-bridge'
|
||||
import { runCodexUserHookTrustRebaseSession } from './codex-user-hook-trust-rebase-client'
|
||||
import { isCodexAppServerUnsupportedError } from './codex-app-server-session'
|
||||
import { CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS } from './codex-hook-trust-grant'
|
||||
import { createCodexHookTrustEntry } from './codex-hook-identity'
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
restoreCodexTrustConfig,
|
||||
type CodexTrustConfigSnapshot
|
||||
} from './codex-trust-config-rollback'
|
||||
import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue'
|
||||
import { computeTrustKey, type CodexTrustEntry } from './config-toml-trust'
|
||||
import type {
|
||||
CodexUserHookTrustRebaseRequest,
|
||||
@@ -23,11 +24,13 @@ import type {
|
||||
|
||||
type HooksByEvent = Record<string, HookDefinition[]>
|
||||
|
||||
type RebaseSessionRunnerSync = (
|
||||
type RebaseSessionRunner = (
|
||||
request: CodexUserHookTrustRebaseRequest
|
||||
) => CodexUserHookTrustRebaseResult
|
||||
) => Promise<CodexUserHookTrustRebaseResult>
|
||||
|
||||
let runSessionSync: RebaseSessionRunnerSync = runCodexUserHookTrustRebaseSessionSync
|
||||
// Why (#16441): the session runs in-process; forking it through spawnSync
|
||||
// froze the main thread for the whole app-server deadline on every install.
|
||||
let runSession: RebaseSessionRunner = runCodexUserHookTrustRebaseSession
|
||||
|
||||
// Why: launch prep re-runs the callers on every pane spawn. A host stuck
|
||||
// without a usable rebase lane (old CLI, unmatched keys) must not pay a codex
|
||||
@@ -129,12 +132,25 @@ export function mutateRealHomeHooksPreservingUserTrust(args: {
|
||||
afterHooks: HooksByEvent
|
||||
writeHooks: () => void
|
||||
restoreHooks: () => void
|
||||
}): CodexTrustConfigSnapshot | null {
|
||||
}): Promise<CodexTrustConfigSnapshot | null> {
|
||||
const moves = getMovedCodexUserHookTrust(args.sourcePath, args.beforeHooks, args.afterHooks)
|
||||
if (moves.length === 0) {
|
||||
args.writeHooks()
|
||||
return null
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
// Why: capture/mutate/restore on one config.toml is not reentrant.
|
||||
return runExclusivelyForCodexTrustConfig(args.tomlPath, () => rebaseMovedUserTrust(args, moves))
|
||||
}
|
||||
|
||||
async function rebaseMovedUserTrust(
|
||||
args: {
|
||||
runtimeHomePath: string
|
||||
tomlPath: string
|
||||
writeHooks: () => void
|
||||
restoreHooks: () => void
|
||||
},
|
||||
moves: CodexUserHookTrustMove[]
|
||||
): Promise<CodexTrustConfigSnapshot | null> {
|
||||
const hostKey = getCodexAppServerHostKey({ kind: 'native' })
|
||||
if (!codexAppServerCapabilityCache.shouldTry(hostKey)) {
|
||||
throw new Error('codex app-server is marked unsupported on this host; trust rebase skipped')
|
||||
@@ -148,7 +164,7 @@ export function mutateRealHomeHooksPreservingUserTrust(args: {
|
||||
}
|
||||
const snapshot = captureCodexTrustConfig(args.tomlPath)
|
||||
|
||||
const baseRequest = resolveCodexTrustGrantHost({ kind: 'native' }).buildRequest({
|
||||
const baseRequest = (await resolveCodexTrustGrantHost({ kind: 'native' })).buildRequest({
|
||||
runtimeHomePath: args.runtimeHomePath,
|
||||
managedCommand: '',
|
||||
expectedTrustKeys: [],
|
||||
@@ -158,7 +174,7 @@ export function mutateRealHomeHooksPreservingUserTrust(args: {
|
||||
// without shifting a user's positional trust key.
|
||||
let inspected: CodexUserHookTrustRebaseResult
|
||||
try {
|
||||
inspected = runSessionSync({
|
||||
inspected = await runSession({
|
||||
operation: 'inspect-user-hook-trust',
|
||||
invocation: baseRequest.invocation,
|
||||
hooksListCwd: baseRequest.hooksListCwd,
|
||||
@@ -177,7 +193,7 @@ export function mutateRealHomeHooksPreservingUserTrust(args: {
|
||||
try {
|
||||
args.writeHooks()
|
||||
hooksWritten = true
|
||||
const repaired = runSessionSync({
|
||||
const repaired = await runSession({
|
||||
operation: 'repair-user-hook-trust',
|
||||
invocation: baseRequest.invocation,
|
||||
hooksListCwd: baseRequest.hooksListCwd,
|
||||
@@ -197,8 +213,8 @@ export function mutateRealHomeHooksPreservingUserTrust(args: {
|
||||
}
|
||||
|
||||
export const _internals = {
|
||||
setSessionRunnerSync(runner: RebaseSessionRunnerSync | null): void {
|
||||
runSessionSync = runner ?? runCodexUserHookTrustRebaseSessionSync
|
||||
setSessionRunner(runner: RebaseSessionRunner | null): void {
|
||||
runSession = runner ?? runCodexUserHookTrustRebaseSession
|
||||
},
|
||||
resetRetryState(): void {
|
||||
rebaseRetryAfterByHost.clear()
|
||||
|
||||
@@ -184,6 +184,8 @@ export function snapshotCodexRuntimeSettingsBaseline(
|
||||
export type CodexSettingsPromotionHomes = {
|
||||
runtimeHomePath: string
|
||||
systemHomePath: string
|
||||
/** Linux spelling of the source config directory when its host path is a drvfs drive. */
|
||||
systemConfigDir?: string
|
||||
}
|
||||
|
||||
export type CodexSettingsPromotionPlan = {
|
||||
|
||||
@@ -54,7 +54,7 @@ function legacyManagedHookCommand(): string {
|
||||
}
|
||||
|
||||
describe('CodexHookService', () => {
|
||||
it('removes legacy Orca-managed hooks from system ~/.codex during install', () => {
|
||||
it('removes legacy Orca-managed hooks from system ~/.codex during install', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
const legacyCommand = legacyManagedHookCommand()
|
||||
@@ -102,7 +102,7 @@ describe('CodexHookService', () => {
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect(new CodexHookService().install().state).toBe('installed')
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
const systemHooks = JSON.parse(readFileSync(systemHooksPath, 'utf-8')) as {
|
||||
hooks: Record<string, { hooks?: { command?: string }[] }[]>
|
||||
@@ -117,7 +117,7 @@ describe('CodexHookService', () => {
|
||||
expect(systemToml).not.toContain(':session_start:0:0')
|
||||
})
|
||||
|
||||
it('removes very large legacy Orca-managed hook lists from system ~/.codex', () => {
|
||||
it('removes very large legacy Orca-managed hook lists from system ~/.codex', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
const legacyCommand = legacyManagedHookCommand()
|
||||
@@ -136,7 +136,7 @@ describe('CodexHookService', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
expect(new CodexHookService().install().state).toBe('installed')
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalledWith(
|
||||
'[codex-hook-service] failed to clean legacy Codex hooks',
|
||||
@@ -151,18 +151,18 @@ describe('CodexHookService', () => {
|
||||
expect(systemHooks.hooks.Stop).toBeUndefined()
|
||||
}, 30_000)
|
||||
|
||||
it('removes the legacy Orca Codex profile file when it only contains managed hooks', () => {
|
||||
it('removes the legacy Orca Codex profile file when it only contains managed hooks', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const profilePath = join(systemCodexHome, 'orca-agent-status.config.toml')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(profilePath, LEGACY_ORCA_PROFILE_LINES.join('\n'), 'utf-8')
|
||||
|
||||
expect(new CodexHookService().install().state).toBe('installed')
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
expect(existsSync(profilePath)).toBe(false)
|
||||
})
|
||||
|
||||
it('removes only the legacy Orca block from a user-edited Codex profile file', () => {
|
||||
it('removes only the legacy Orca block from a user-edited Codex profile file', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const profilePath = join(systemCodexHome, 'orca-agent-status.config.toml')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
@@ -172,7 +172,7 @@ describe('CodexHookService', () => {
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect(new CodexHookService().install().state).toBe('installed')
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
const profileConfig = readFileSync(profilePath, 'utf-8')
|
||||
expect(profileConfig).toContain('model = "gpt-5.5"')
|
||||
@@ -180,7 +180,7 @@ describe('CodexHookService', () => {
|
||||
expect(profileConfig).not.toContain('codex-hook')
|
||||
})
|
||||
|
||||
it('cleans legacy system and profile hooks when runtime hooks.json is malformed during remove', () => {
|
||||
it('cleans legacy system and profile hooks when runtime hooks.json is malformed during remove', async () => {
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
mkdirSync(managedCodexHome, { recursive: true })
|
||||
writeFileSync(join(managedCodexHome, 'hooks.json'), '{not json', 'utf-8')
|
||||
@@ -209,7 +209,7 @@ describe('CodexHookService', () => {
|
||||
)
|
||||
writeFileSync(profilePath, LEGACY_ORCA_PROFILE_LINES.join('\n'), 'utf-8')
|
||||
|
||||
const status = new CodexHookService().remove()
|
||||
const status = await new CodexHookService().remove()
|
||||
|
||||
expect(status.state).toBe('error')
|
||||
expect(status.detail).toBe('Could not parse Codex hooks.json')
|
||||
@@ -221,7 +221,7 @@ describe('CodexHookService', () => {
|
||||
expect(existsSync(profilePath)).toBe(false)
|
||||
})
|
||||
|
||||
it('sanitizes runtime hooks.json metadata during remove even without managed hooks', () => {
|
||||
it('sanitizes runtime hooks.json metadata during remove even without managed hooks', async () => {
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
mkdirSync(managedCodexHome, { recursive: true })
|
||||
@@ -244,7 +244,7 @@ describe('CodexHookService', () => {
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
const status = new CodexHookService().remove()
|
||||
const status = await new CodexHookService().remove()
|
||||
|
||||
expect(status.state).toBe('not_installed')
|
||||
const hooksConfig = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as {
|
||||
@@ -256,7 +256,7 @@ describe('CodexHookService', () => {
|
||||
expect(hooksConfig.hooks.Stop).toEqual([{ hooks: [{ type: 'command', command: 'user-hook' }] }])
|
||||
})
|
||||
|
||||
it('cleans duplicate Codex hook representations while keeping status hooks in runtime CODEX_HOME', () => {
|
||||
it('cleans duplicate Codex hook representations while keeping status hooks in runtime CODEX_HOME', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
const systemTomlPath = join(systemCodexHome, 'config.toml')
|
||||
@@ -307,7 +307,7 @@ describe('CodexHookService', () => {
|
||||
writeFileSync(legacyProfilePath, LEGACY_ORCA_PROFILE_LINES.join('\n'), 'utf-8')
|
||||
|
||||
const service = new CodexHookService()
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock('os', async (importOriginal) => {
|
||||
})
|
||||
|
||||
import { CodexHookService } from './hook-service'
|
||||
import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue'
|
||||
|
||||
const WINDOWS_POWERSHELL_LAUNCHER =
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/
|
||||
@@ -48,7 +49,58 @@ function localManagedCodexEvents(): string[] {
|
||||
}
|
||||
|
||||
describe('CodexHookService', () => {
|
||||
it('installs PermissionRequest with trust so Codex approval prompts reach Orca', () => {
|
||||
// Why (#16441): install promotes in-Orca approvals into ~/.codex/config.toml
|
||||
// and mirrors that file into the managed home, so holding only the runtime
|
||||
// lane still lets it land inside a real-home grant's capture->restore window.
|
||||
it('waits for an in-flight mutation of the system config.toml', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(join(systemCodexHome, 'config.toml'), 'approval_policy = "on-request"\n', 'utf-8')
|
||||
const managedHooksJsonPath = join(homes.userDataDir, 'codex-runtime-home', 'home', 'hooks.json')
|
||||
let releaseGrant!: () => void
|
||||
const grantHoldingSystemConfig = new Promise<void>((resolve) => {
|
||||
releaseGrant = resolve
|
||||
})
|
||||
const held = runExclusivelyForCodexTrustConfig(
|
||||
join(systemCodexHome, 'config.toml'),
|
||||
() => grantHoldingSystemConfig
|
||||
)
|
||||
|
||||
const install = new CodexHookService().install()
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(existsSync(managedHooksJsonPath)).toBe(false)
|
||||
|
||||
releaseGrant()
|
||||
await held
|
||||
await expect(install).resolves.toMatchObject({ state: 'installed' })
|
||||
expect(existsSync(managedHooksJsonPath)).toBe(true)
|
||||
})
|
||||
|
||||
it('makes the user-hook refresh wait for the system config.toml too', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(join(systemCodexHome, 'config.toml'), 'approval_policy = "on-request"\n', 'utf-8')
|
||||
const managedHooksJsonPath = join(homes.userDataDir, 'codex-runtime-home', 'home', 'hooks.json')
|
||||
let releaseGrant!: () => void
|
||||
const held = runExclusivelyForCodexTrustConfig(
|
||||
join(systemCodexHome, 'config.toml'),
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseGrant = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const refresh = new CodexHookService().refreshRuntimeUserHooks()
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(existsSync(managedHooksJsonPath)).toBe(false)
|
||||
|
||||
releaseGrant()
|
||||
await held
|
||||
await refresh
|
||||
expect(existsSync(managedHooksJsonPath)).toBe(true)
|
||||
})
|
||||
|
||||
it('installs PermissionRequest with trust so Codex approval prompts reach Orca', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(
|
||||
@@ -57,7 +109,7 @@ describe('CodexHookService', () => {
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
const status = new CodexHookService().install()
|
||||
const status = await new CodexHookService().install()
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
|
||||
@@ -77,7 +129,7 @@ describe('CodexHookService', () => {
|
||||
expect(trustConfig).toContain(':permission_request:0:0')
|
||||
})
|
||||
|
||||
it('installs managed hooks + trust into a per-account self-contained home, not the shared mirror', () => {
|
||||
it('installs managed hooks + trust into a per-account self-contained home, not the shared mirror', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(join(systemCodexHome, 'config.toml'), 'approval_policy = "on-request"\n', 'utf-8')
|
||||
@@ -86,7 +138,7 @@ describe('CodexHookService', () => {
|
||||
mkdirSync(perAccountHome, { recursive: true })
|
||||
writeFileSync(join(perAccountHome, '.orca-managed-home'), 'account-1\n', 'utf-8')
|
||||
|
||||
const status = new CodexHookService().install(perAccountHome)
|
||||
const status = await new CodexHookService().install(perAccountHome)
|
||||
expect(status.state).toBe('installed')
|
||||
|
||||
// Hooks + trust land in THIS account's home.
|
||||
@@ -106,7 +158,7 @@ describe('CodexHookService', () => {
|
||||
expect(existsSync(join(systemCodexHome, 'hooks.json'))).toBe(false)
|
||||
})
|
||||
|
||||
it('drops plugin manager metadata from runtime hooks.json during install', () => {
|
||||
it('drops plugin manager metadata from runtime hooks.json during install', async () => {
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
mkdirSync(managedCodexHome, { recursive: true })
|
||||
writeFileSync(
|
||||
@@ -122,7 +174,7 @@ describe('CodexHookService', () => {
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect(new CodexHookService().install().state).toBe('installed')
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
const hooksConfig = JSON.parse(readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')) as {
|
||||
hooks: Record<string, unknown>
|
||||
@@ -138,7 +190,7 @@ describe('CodexHookService', () => {
|
||||
// `cmd.exe /C` never sees the raw script path.
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'wraps the managed hook command when the profile path contains a space (#6078)',
|
||||
() => {
|
||||
async () => {
|
||||
const spaceHome = join(tmpdir(), 'orca home with spaces')
|
||||
mkdirSync(spaceHome, { recursive: true })
|
||||
homedirMock.mockReturnValue(spaceHome)
|
||||
@@ -146,7 +198,7 @@ describe('CodexHookService', () => {
|
||||
const systemCodexHome = join(spaceHome, '.codex')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
|
||||
const status = new CodexHookService().install()
|
||||
const status = await new CodexHookService().install()
|
||||
expect(status.state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
@@ -168,7 +220,7 @@ describe('CodexHookService', () => {
|
||||
// plausible paths. Keep those rare cases on the encoded launcher from #6078.
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'keeps the encoded launcher when the profile path contains cmd metacharacters',
|
||||
() => {
|
||||
async () => {
|
||||
const metacharHome = join(tmpdir(), 'orca %ORCA_TEST% ^ home')
|
||||
mkdirSync(metacharHome, { recursive: true })
|
||||
homedirMock.mockReturnValue(metacharHome)
|
||||
@@ -176,7 +228,7 @@ describe('CodexHookService', () => {
|
||||
const systemCodexHome = join(metacharHome, '.codex')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
|
||||
const status = new CodexHookService().install()
|
||||
const status = await new CodexHookService().install()
|
||||
expect(status.state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
@@ -199,8 +251,8 @@ describe('CodexHookService', () => {
|
||||
// speed that Codex 0.140's synchronous "Running <event> hook" rows expose.
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'launches the managed .cmd directly when the profile path is cmd-safe',
|
||||
() => {
|
||||
const status = new CodexHookService().install()
|
||||
async () => {
|
||||
const status = await new CodexHookService().install()
|
||||
expect(status.state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
@@ -227,7 +279,7 @@ describe('CodexHookService', () => {
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'posts hook payloads via the curl-based managed script preserving UTF-8 and spaced metadata',
|
||||
async () => {
|
||||
new CodexHookService().install()
|
||||
await new CodexHookService().install()
|
||||
const scriptPath = join(homedir(), '.orca', 'agent-hooks', 'codex-hook.cmd')
|
||||
expect(existsSync(scriptPath)).toBe(true)
|
||||
|
||||
@@ -297,7 +349,7 @@ describe('CodexHookService', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps hooks isolated by Orca userData instead of mutating system ~/.codex', () => {
|
||||
it('keeps hooks isolated by Orca userData instead of mutating system ~/.codex', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
const existingSystemHooks = '{"hooks":{"Stop":[{"hooks":[{"command":"user-hook"}]}]}}\n'
|
||||
@@ -314,7 +366,7 @@ describe('CodexHookService', () => {
|
||||
throw new Error(`unexpected app.getPath(${name})`)
|
||||
})
|
||||
process.env.ORCA_USER_DATA_PATH = devUserDataDir
|
||||
expect(new CodexHookService().install().state).toBe('installed')
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
getPathMock.mockImplementation((name: string) => {
|
||||
if (name === 'userData') {
|
||||
@@ -323,7 +375,7 @@ describe('CodexHookService', () => {
|
||||
throw new Error(`unexpected app.getPath(${name})`)
|
||||
})
|
||||
process.env.ORCA_USER_DATA_PATH = prodUserDataDir
|
||||
expect(new CodexHookService().install().state).toBe('installed')
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
const devHooksPath = join(devUserDataDir, 'codex-runtime-home', 'home', 'hooks.json')
|
||||
const prodHooksPath = join(prodUserDataDir, 'codex-runtime-home', 'home', 'hooks.json')
|
||||
|
||||
@@ -33,7 +33,7 @@ import { CodexHookService } from './hook-service'
|
||||
const homes = setupCodexHookHomes(homedirMock, getPathMock)
|
||||
|
||||
describe('CodexHookService', () => {
|
||||
it('removes managed trust entries when userData resolves through a symlink', () => {
|
||||
it('removes managed trust entries when userData resolves through a symlink', async () => {
|
||||
const linkedUserDataDir = join(homes.tmpHome, 'linked-user-data')
|
||||
symlinkSync(
|
||||
homes.userDataDir,
|
||||
@@ -43,14 +43,14 @@ describe('CodexHookService', () => {
|
||||
process.env.ORCA_USER_DATA_PATH = linkedUserDataDir
|
||||
|
||||
const service = new CodexHookService()
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
|
||||
const linkedManagedCodexHome = join(linkedUserDataDir, 'codex-runtime-home', 'home')
|
||||
const linkedHooksPath = join(linkedManagedCodexHome, 'hooks.json')
|
||||
let runtimeToml = readFileSync(join(linkedManagedCodexHome, 'config.toml'), 'utf-8')
|
||||
expect(runtimeToml).toContain(hookTrustHeader(`${linkedHooksPath}:permission_request:0:0`))
|
||||
|
||||
const status = service.remove()
|
||||
const status = await service.remove()
|
||||
|
||||
expect(status.state).toBe('not_installed')
|
||||
runtimeToml = readFileSync(join(linkedManagedCodexHome, 'config.toml'), 'utf-8')
|
||||
@@ -58,9 +58,9 @@ describe('CodexHookService', () => {
|
||||
expect(runtimeToml).not.toContain(':stop:0:0')
|
||||
})
|
||||
|
||||
it('removes legacy managed trust entries hashed before hook timeouts existed', () => {
|
||||
it('removes legacy managed trust entries hashed before hook timeouts existed', async () => {
|
||||
const service = new CodexHookService()
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
@@ -88,13 +88,13 @@ describe('CodexHookService', () => {
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect(service.remove().state).toBe('not_installed')
|
||||
expect((await service.remove()).state).toBe('not_installed')
|
||||
|
||||
const runtimeToml = readFileSync(runtimeTomlPath, 'utf-8')
|
||||
expect(runtimeToml).not.toContain(':permission_request:0:0')
|
||||
})
|
||||
|
||||
it('mirrors system Codex config while preserving runtime hook trust on hook install', () => {
|
||||
it('mirrors system Codex config while preserving runtime hook trust on hook install', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8')
|
||||
@@ -114,7 +114,7 @@ describe('CodexHookService', () => {
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
const status = new CodexHookService().install()
|
||||
const status = await new CodexHookService().install()
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
|
||||
@@ -128,9 +128,9 @@ describe('CodexHookService', () => {
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'treats legacy forward-slash runtime trust keys as installed before canonicalizing on reinstall',
|
||||
() => {
|
||||
async () => {
|
||||
const service = new CodexHookService()
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
@@ -154,7 +154,7 @@ describe('CodexHookService', () => {
|
||||
expect(legacyToml).toContain(legacyPermissionHeader)
|
||||
expect(service.getStatus().state).toBe('installed')
|
||||
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
|
||||
const repairedToml = readFileSync(runtimeTomlPath, 'utf-8')
|
||||
expect(repairedToml).not.toContain(legacyPermissionHeader)
|
||||
@@ -163,13 +163,13 @@ describe('CodexHookService', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('repairs duplicate managed PermissionRequest trust tables on restart install', () => {
|
||||
it('repairs duplicate managed PermissionRequest trust tables on restart install', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8')
|
||||
|
||||
const service = new CodexHookService()
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
@@ -209,7 +209,7 @@ describe('CodexHookService', () => {
|
||||
|
||||
// Why: preserving `enabled = false` is the repair contract; status can be
|
||||
// partial because the user-disabled managed hook remains disabled.
|
||||
expect(['installed', 'partial']).toContain(service.install().state)
|
||||
expect(['installed', 'partial']).toContain((await service.install()).state)
|
||||
|
||||
const repairedToml = readFileSync(runtimeTomlPath, 'utf-8')
|
||||
expect(repairedToml.split(permissionRequestHeader)).toHaveLength(2)
|
||||
@@ -219,7 +219,7 @@ describe('CodexHookService', () => {
|
||||
expect(repairedToml).toContain('model = "system-model"')
|
||||
})
|
||||
|
||||
it('preserves runtime-only project trust while honoring system project untrust', () => {
|
||||
it('preserves runtime-only project trust while honoring system project untrust', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(
|
||||
@@ -247,7 +247,7 @@ describe('CodexHookService', () => {
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
const status = new CodexHookService().install()
|
||||
const status = await new CodexHookService().install()
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
|
||||
|
||||
@@ -7,6 +7,17 @@ import {
|
||||
getCodexExplicitHomeHookSourcePath,
|
||||
normalizeCodexHookSourcePath
|
||||
} from './config-toml-trust'
|
||||
import { _internals as grantInternals } from './codex-hook-trust-grant'
|
||||
import { _internals as rebaseInternals } from './codex-user-hook-trust-rebase'
|
||||
|
||||
// Why (#16441): the grant/rebase sessions now run in-process instead of in a
|
||||
// forked bundle that never existed under vitest. Without this stub these
|
||||
// suites spawn the developer's real `codex app-server`, so they pass in CI
|
||||
// (no codex installed) and fail on any machine that has one. Stand in for the
|
||||
// missing binary so the fallback lane is exercised either way.
|
||||
function stubMissingCodexBinary(): never {
|
||||
throw Object.assign(new Error('spawn codex ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
export type CodexHookHomes = {
|
||||
tmpHome: string
|
||||
@@ -14,6 +25,19 @@ export type CodexHookHomes = {
|
||||
}
|
||||
|
||||
/** Mutable holder: fields are re-pointed at fresh temp dirs by the registered beforeEach. */
|
||||
/** Applies the stub above; for suites that build their own temp homes. */
|
||||
export function stubCodexTrustSessionsForTests(): void {
|
||||
grantInternals.setGrantSessionRunner(stubMissingCodexBinary)
|
||||
rebaseInternals.setSessionRunner(stubMissingCodexBinary)
|
||||
}
|
||||
|
||||
export function restoreCodexTrustSessionsForTests(): void {
|
||||
grantInternals.setGrantSessionRunner(null)
|
||||
grantInternals.resetDiagnostics()
|
||||
rebaseInternals.setSessionRunner(null)
|
||||
rebaseInternals.resetRetryState()
|
||||
}
|
||||
|
||||
export function setupCodexHookHomes(
|
||||
homedirMock: Mock<() => string>,
|
||||
getPathMock: Mock<(name: string) => string>
|
||||
@@ -27,6 +51,7 @@ export function setupCodexHookHomes(
|
||||
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
process.env.ORCA_USER_DATA_PATH = homes.userDataDir
|
||||
homedirMock.mockReturnValue(homes.tmpHome)
|
||||
stubCodexTrustSessionsForTests()
|
||||
getPathMock.mockImplementation((name: string) => {
|
||||
if (name === 'userData') {
|
||||
return homes.userDataDir
|
||||
@@ -36,6 +61,7 @@ export function setupCodexHookHomes(
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreCodexTrustSessionsForTests()
|
||||
rmSync(homes.tmpHome, { recursive: true, force: true })
|
||||
rmSync(homes.userDataDir, { recursive: true, force: true })
|
||||
if (previousUserDataPath === undefined) {
|
||||
|
||||
@@ -75,8 +75,8 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rebaseInternals.setSessionRunnerSync(null)
|
||||
trustGrantInternals.setGrantSessionRunnerSync(null)
|
||||
rebaseInternals.setSessionRunner(null)
|
||||
trustGrantInternals.setGrantSessionRunner(null)
|
||||
trustGrantInternals.resetDiagnostics()
|
||||
codexAppServerCapabilityCache.clear()
|
||||
if (previousDisableTrustRpc === undefined) {
|
||||
@@ -123,7 +123,7 @@ function writeCodexLikeTrust(configPath: string, entries: CodexTrustEntry[]): vo
|
||||
function installCodexLikeGrantRunner(): ReturnType<typeof vi.fn> {
|
||||
const codexHash = (key: string): string =>
|
||||
`sha256:codex-${parseTrustKey(key)?.eventLabel ?? 'unknown'}`
|
||||
const runner = vi.fn((request: CodexHookTrustGrantRequest) => {
|
||||
const runner = vi.fn(async (request: CodexHookTrustGrantRequest) => {
|
||||
const codexHome = request.invocation.env?.CODEX_HOME
|
||||
expect(codexHome).toBeTruthy()
|
||||
const entries: CodexTrustEntry[] = request.expectedTrustKeys.map((key) => {
|
||||
@@ -145,7 +145,7 @@ function installCodexLikeGrantRunner(): ReturnType<typeof vi.fn> {
|
||||
}))
|
||||
}
|
||||
})
|
||||
trustGrantInternals.setGrantSessionRunnerSync(runner)
|
||||
trustGrantInternals.setGrantSessionRunner(runner)
|
||||
return runner
|
||||
}
|
||||
|
||||
@@ -154,11 +154,11 @@ function prepareSystemHome(): void {
|
||||
}
|
||||
|
||||
describe('CodexHookService app-server trust grant lane', () => {
|
||||
it('treats Codex hashes as authoritative and records the verified grant', () => {
|
||||
it('treats Codex hashes as authoritative and records the verified grant', async () => {
|
||||
prepareSystemHome()
|
||||
const runner = installCodexLikeGrantRunner()
|
||||
|
||||
const status = new CodexHookService().install()
|
||||
const status = await new CodexHookService().install()
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(runner).toHaveBeenCalledTimes(1)
|
||||
@@ -177,15 +177,15 @@ describe('CodexHookService app-server trust grant lane', () => {
|
||||
expect(Object.keys(readCodexTrustGrantLedgerHome(managedHome)!.entries)).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('keeps config byte-stable and skips the session on a repeat ledger hit', () => {
|
||||
it('keeps config byte-stable and skips the session on a repeat ledger hit', async () => {
|
||||
prepareSystemHome()
|
||||
const runner = installCodexLikeGrantRunner()
|
||||
const service = new CodexHookService()
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
const managedHome = join(userDataDir, 'codex-runtime-home', 'home')
|
||||
const firstToml = readFileSync(join(managedHome, 'config.toml'))
|
||||
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
expect(runner).toHaveBeenCalledTimes(1)
|
||||
// Why: each launch validates the binary stamp once; getStatus reuses the
|
||||
// just-verified grant instead of repeating PATH/version-manager scans.
|
||||
@@ -193,7 +193,7 @@ describe('CodexHookService app-server trust grant lane', () => {
|
||||
expect(readFileSync(join(managedHome, 'config.toml'))).toEqual(firstToml)
|
||||
})
|
||||
|
||||
it('retries ledger-proven real-home trust cleanup after the hook is already gone', () => {
|
||||
it('retries ledger-proven real-home trust cleanup after the hook is already gone', async () => {
|
||||
prepareSystemHome()
|
||||
const systemHome = join(tmpHome, '.codex')
|
||||
const hooksPath = join(systemHome, 'hooks.json')
|
||||
@@ -223,7 +223,7 @@ describe('CodexHookService app-server trust grant lane', () => {
|
||||
})
|
||||
installCodexLikeGrantRunner()
|
||||
|
||||
expect(new CodexHookService().install().state).toBe('installed')
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
expect(readHookTrustEntries(configPath).has(trustKey)).toBe(false)
|
||||
expect(readCodexTrustGrantLedgerHome(systemHome)).toBeNull()
|
||||
@@ -232,7 +232,7 @@ describe('CodexHookService app-server trust grant lane', () => {
|
||||
// Why: ordinary Windows CI tokens cannot create file symlinks without Developer Mode.
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'keeps a real-home symlink and rebases later user trust during flag-off cleanup',
|
||||
() => {
|
||||
async () => {
|
||||
prepareSystemHome()
|
||||
const systemHome = join(tmpHome, '.codex')
|
||||
const hooksPath = join(systemHome, 'hooks.json')
|
||||
@@ -256,7 +256,7 @@ describe('CodexHookService app-server trust grant lane', () => {
|
||||
)
|
||||
symlinkSync(targetPath, hooksPath)
|
||||
const operations: string[] = []
|
||||
rebaseInternals.setSessionRunnerSync((request) => {
|
||||
rebaseInternals.setSessionRunner(async (request) => {
|
||||
operations.push(request.operation)
|
||||
if (request.operation === 'inspect-user-hook-trust') {
|
||||
return {
|
||||
@@ -273,7 +273,7 @@ describe('CodexHookService app-server trust grant lane', () => {
|
||||
})
|
||||
installCodexLikeGrantRunner()
|
||||
|
||||
expect(new CodexHookService().install().state).toBe('installed')
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
expect(lstatSync(hooksPath).isSymbolicLink()).toBe(true)
|
||||
expect(JSON.parse(readFileSync(targetPath, 'utf-8')).hooks.Stop).toEqual([
|
||||
@@ -285,7 +285,7 @@ describe('CodexHookService app-server trust grant lane', () => {
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'preserves restrictive real-home hooks permissions during flag-off cleanup',
|
||||
() => {
|
||||
async () => {
|
||||
prepareSystemHome()
|
||||
const hooksPath = join(tmpHome, '.codex', 'hooks.json')
|
||||
const material = getCodexManagedHookInstallMaterial()
|
||||
@@ -296,17 +296,17 @@ describe('CodexHookService app-server trust grant lane', () => {
|
||||
chmodSync(hooksPath, 0o600)
|
||||
installCodexLikeGrantRunner()
|
||||
|
||||
expect(new CodexHookService().install().state).toBe('installed')
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
expect(statSync(hooksPath).mode & 0o777).toBe(0o600)
|
||||
}
|
||||
)
|
||||
|
||||
it('does not accept a ledger hash after the recorded Codex binary stamp changes', () => {
|
||||
it('does not accept a ledger hash after the recorded Codex binary stamp changes', async () => {
|
||||
prepareSystemHome()
|
||||
installCodexLikeGrantRunner()
|
||||
const service = new CodexHookService()
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
const managedHome = join(userDataDir, 'codex-runtime-home', 'home')
|
||||
const ledger = readCodexTrustGrantLedgerHome(managedHome)!
|
||||
writeCodexTrustGrantLedgerHome(managedHome, {
|
||||
@@ -320,16 +320,16 @@ describe('CodexHookService app-server trust grant lane', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('upgrades self-computed trust in place without duplicate logical entries', () => {
|
||||
it('upgrades self-computed trust in place without duplicate logical entries', async () => {
|
||||
prepareSystemHome()
|
||||
const service = new CodexHookService()
|
||||
process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1'
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
const managedHome = join(userDataDir, 'codex-runtime-home', 'home')
|
||||
delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC
|
||||
installCodexLikeGrantRunner()
|
||||
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
const upgraded = readFileSync(join(managedHome, 'config.toml'), 'utf-8')
|
||||
// Why: the legacy Windows fallback intentionally writes slash variants;
|
||||
// duplicate detection is about the normalized trust identity.
|
||||
@@ -350,7 +350,7 @@ describe('CodexHookService app-server trust grant lane', () => {
|
||||
expect(upgraded).toContain('sha256:codex-session_start')
|
||||
})
|
||||
|
||||
it('leaves user trust byte-untouched while granting managed entries', () => {
|
||||
it('leaves user trust byte-untouched while granting managed entries', async () => {
|
||||
prepareSystemHome()
|
||||
const managedHome = join(userDataDir, 'codex-runtime-home', 'home')
|
||||
mkdirSync(managedHome, { recursive: true })
|
||||
@@ -362,35 +362,35 @@ describe('CodexHookService app-server trust grant lane', () => {
|
||||
writeFileSync(join(managedHome, 'config.toml'), `${userBlock}\n`)
|
||||
installCodexLikeGrantRunner()
|
||||
|
||||
expect(new CodexHookService().install().state).toBe('installed')
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
expect(readFileSync(join(managedHome, 'config.toml'), 'utf-8')).toContain(userBlock)
|
||||
})
|
||||
|
||||
it('keeps the forced fallback on self-computed writes', () => {
|
||||
it('keeps the forced fallback on self-computed writes', async () => {
|
||||
prepareSystemHome()
|
||||
process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1'
|
||||
const runner = vi.fn()
|
||||
trustGrantInternals.setGrantSessionRunnerSync(runner)
|
||||
trustGrantInternals.setGrantSessionRunner(runner)
|
||||
|
||||
const service = new CodexHookService()
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
expect(service.getStatus().state).toBe('installed')
|
||||
expect(runner).not.toHaveBeenCalled()
|
||||
expect(resolveCodexCommandMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores exact config bytes before fallback after a mutating RPC failure', () => {
|
||||
it('restores exact config bytes before fallback after a mutating RPC failure', async () => {
|
||||
prepareSystemHome()
|
||||
const service = new CodexHookService()
|
||||
process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1'
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
const managedHome = join(userDataDir, 'codex-runtime-home', 'home')
|
||||
const baseline = readFileSync(join(managedHome, 'config.toml'))
|
||||
|
||||
delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC
|
||||
rmSync(managedHome, { recursive: true, force: true })
|
||||
trustGrantInternals.resetDiagnostics()
|
||||
const runner = vi.fn((request: CodexHookTrustGrantRequest) => {
|
||||
const runner = vi.fn(async (request: CodexHookTrustGrantRequest) => {
|
||||
const codexHome = request.invocation.env?.CODEX_HOME
|
||||
writeFileSync(
|
||||
join(codexHome!, 'config.toml'),
|
||||
@@ -398,9 +398,9 @@ describe('CodexHookService app-server trust grant lane', () => {
|
||||
)
|
||||
throw new Error('transport failed after config/batchWrite')
|
||||
})
|
||||
trustGrantInternals.setGrantSessionRunnerSync(runner)
|
||||
trustGrantInternals.setGrantSessionRunner(runner)
|
||||
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
expect(runner).toHaveBeenCalledTimes(1)
|
||||
expect(readFileSync(join(managedHome, 'config.toml'))).toEqual(baseline)
|
||||
})
|
||||
|
||||
@@ -73,10 +73,10 @@ function markHookTrustDisabled(toml: string, header: string): string {
|
||||
}
|
||||
|
||||
describe('CodexHookService', () => {
|
||||
it('preserves mirrored user hooks when the system hooks file cannot be read', () => {
|
||||
it('preserves mirrored user hooks when the system hooks file cannot be read', async () => {
|
||||
const service = new CodexHookService()
|
||||
const { systemHooksPath, managedHooksPath } = seedSystemUserHook('user-hook')
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
const systemBefore = readFileSync(systemHooksPath, 'utf-8')
|
||||
const before = readFileSync(managedHooksPath, 'utf-8')
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('CodexHookService', () => {
|
||||
mkdirSync(systemHooksPath)
|
||||
|
||||
for (const retry of [() => service.install(), () => service.refreshRuntimeUserHooks()]) {
|
||||
expect(retry()).toMatchObject({
|
||||
expect(await retry()).toMatchObject({
|
||||
state: 'error',
|
||||
detail: 'Could not read system Codex hooks.json'
|
||||
})
|
||||
@@ -93,16 +93,16 @@ describe('CodexHookService', () => {
|
||||
|
||||
rmSync(systemHooksPath, { recursive: true })
|
||||
writeFileSync(systemHooksPath, systemBefore, 'utf-8')
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
expect(readRuntimeHookCommands(managedHooksPath)).toContain('user-hook')
|
||||
})
|
||||
|
||||
it.each(['absent', 'malformed'] as const)(
|
||||
'rebuilds mirrored user hooks when the system source is %s',
|
||||
(sourceState) => {
|
||||
async (sourceState) => {
|
||||
const service = new CodexHookService()
|
||||
const { systemHooksPath, managedHooksPath } = seedSystemUserHook('stale-user-hook')
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
|
||||
if (sourceState === 'absent') {
|
||||
rmSync(systemHooksPath)
|
||||
@@ -110,12 +110,12 @@ describe('CodexHookService', () => {
|
||||
writeFileSync(systemHooksPath, '{ not json', 'utf-8')
|
||||
}
|
||||
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
expect(readRuntimeHookCommands(managedHooksPath)).not.toContain('stale-user-hook')
|
||||
}
|
||||
)
|
||||
|
||||
it('mirrors trusted system user hook approvals into the runtime CODEX_HOME', () => {
|
||||
it('mirrors trusted system user hook approvals into the runtime CODEX_HOME', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
@@ -163,7 +163,7 @@ describe('CodexHookService', () => {
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect(new CodexHookService().install().state).toBe('installed')
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
@@ -183,7 +183,7 @@ describe('CodexHookService', () => {
|
||||
expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:0:0`, true))
|
||||
})
|
||||
|
||||
it('runs managed PostToolUse status before mirrored user hooks', () => {
|
||||
it('runs managed PostToolUse status before mirrored user hooks', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
@@ -210,7 +210,7 @@ describe('CodexHookService', () => {
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect(new CodexHookService().install().state).toBe('installed')
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
@@ -231,7 +231,7 @@ describe('CodexHookService', () => {
|
||||
expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:post_tool_use:0:0`, true))
|
||||
})
|
||||
|
||||
it('mirrors system user hook approvals when the system trust indices are stale', () => {
|
||||
it('mirrors system user hook approvals when the system trust indices are stale', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
@@ -272,7 +272,7 @@ describe('CodexHookService', () => {
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect(new CodexHookService().install().state).toBe('installed')
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
@@ -284,7 +284,7 @@ describe('CodexHookService', () => {
|
||||
expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:1:0`, true))
|
||||
})
|
||||
|
||||
it('skips plugin-placeholder system hooks when mirroring into runtime CODEX_HOME', () => {
|
||||
it('skips plugin-placeholder system hooks when mirroring into runtime CODEX_HOME', async () => {
|
||||
const pluginCommands = [
|
||||
'node "${CLAUDE_PLUGIN_ROOT}/scripts/on-stop.mjs"',
|
||||
'node "${CLAUDE_PLUGIN_DATA}/scripts/on-stop.mjs"',
|
||||
@@ -340,7 +340,7 @@ describe('CodexHookService', () => {
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect(new CodexHookService().install().state).toBe('installed')
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
@@ -368,7 +368,7 @@ describe('CodexHookService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('mirrors compact-event user hook approvals and disabled trust entries', () => {
|
||||
it('mirrors compact-event user hook approvals and disabled trust entries', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
@@ -409,7 +409,7 @@ describe('CodexHookService', () => {
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect(new CodexHookService().install().state).toBe('installed')
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
@@ -430,7 +430,7 @@ describe('CodexHookService', () => {
|
||||
expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:post_compact:0:0`, true))
|
||||
})
|
||||
|
||||
it('removes runtime user hook trust after system approval is revoked', () => {
|
||||
it('removes runtime user hook trust after system approval is revoked', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
@@ -456,7 +456,7 @@ describe('CodexHookService', () => {
|
||||
)
|
||||
const service = new CodexHookService()
|
||||
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
@@ -466,14 +466,14 @@ describe('CodexHookService', () => {
|
||||
)
|
||||
|
||||
writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8')
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
|
||||
const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
|
||||
expect(runtimeToml).not.toContain(runtimeUserTrustHeader)
|
||||
expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`))
|
||||
})
|
||||
|
||||
it('refreshes mirrored system user hooks when the system hooks file changes', () => {
|
||||
it('refreshes mirrored system user hooks when the system hooks file changes', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
@@ -486,7 +486,7 @@ describe('CodexHookService', () => {
|
||||
)
|
||||
|
||||
const service = new CodexHookService()
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
|
||||
writeFileSync(
|
||||
systemHooksPath,
|
||||
@@ -495,7 +495,7 @@ describe('CodexHookService', () => {
|
||||
})}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
|
||||
const managedHooksPath = join(homes.userDataDir, 'codex-runtime-home', 'home', 'hooks.json')
|
||||
const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as {
|
||||
@@ -509,7 +509,7 @@ describe('CodexHookService', () => {
|
||||
expect(stopCommands).not.toContain('user-hook-old')
|
||||
})
|
||||
|
||||
it('refreshes runtime user hooks without installing Orca-managed hooks', () => {
|
||||
it('refreshes runtime user hooks without installing Orca-managed hooks', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
@@ -537,7 +537,7 @@ describe('CodexHookService', () => {
|
||||
)
|
||||
|
||||
const service = new CodexHookService()
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
const runtimeTomlPath = join(managedCodexHome, 'config.toml')
|
||||
@@ -559,7 +559,7 @@ describe('CodexHookService', () => {
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
const status = service.refreshRuntimeUserHooks()
|
||||
const status = await service.refreshRuntimeUserHooks()
|
||||
|
||||
expect(status.state).toBe('not_installed')
|
||||
expect(status.managedHooksPresent).toBe(false)
|
||||
|
||||
@@ -82,7 +82,7 @@ function expectedManagedCommand(scriptPath: string): string {
|
||||
}
|
||||
|
||||
describe('Codex WSL runtime hook install', () => {
|
||||
it('plans WSL hook files with Linux command and trust paths', () => {
|
||||
it('plans WSL hook files with Linux command and trust paths', async () => {
|
||||
const runtimeHome =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('Codex WSL runtime hook install', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('plans WSL hooks when the distro home is mounted on a Windows drive', () => {
|
||||
it('plans WSL hooks when the distro home is mounted on a Windows drive', async () => {
|
||||
const runtimeHome = 'D:\\wsl-home\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
|
||||
expect(
|
||||
@@ -121,7 +121,7 @@ describe('Codex WSL runtime hook install', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses WSL-canonical paths for hook commands and trust keys', () => {
|
||||
it('uses WSL-canonical paths for hook commands and trust keys', async () => {
|
||||
const runtimeHome =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alias\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
const canonicalHome = '/home/alice/.local/share/orca/codex-runtime-home/home'
|
||||
@@ -141,7 +141,7 @@ describe('Codex WSL runtime hook install', () => {
|
||||
expect(plan?.configPath).toBe(pathWin32.join(runtimeHome, 'hooks.json'))
|
||||
})
|
||||
|
||||
it('removes managed trust when the WSL canonical path changes', () => {
|
||||
it('removes managed trust when the WSL canonical path changes', async () => {
|
||||
const plan = createTestPlan()
|
||||
writeFileSync(plan.configPath, '{"hooks":{}}\n', 'utf-8')
|
||||
writeFileSync(plan.tomlPath, '', 'utf-8')
|
||||
@@ -151,7 +151,7 @@ describe('Codex WSL runtime hook install', () => {
|
||||
commandScriptPath: '/old/home/.orca/agent-hooks/codex-hook.sh',
|
||||
trustConfigPath: '/old/home/hooks.json'
|
||||
}
|
||||
expect(_internals.installManagedHooksIntoWslRuntime(oldPlan).state).toBe('installed')
|
||||
expect((await _internals.installManagedHooksIntoWslRuntime(oldPlan)).state).toBe('installed')
|
||||
const oldCommand = expectedManagedCommand(oldPlan.commandScriptPath)
|
||||
const oldKey = computeTrustKey(getManagedTrustEntry(oldPlan, oldCommand))
|
||||
|
||||
@@ -160,7 +160,7 @@ describe('Codex WSL runtime hook install', () => {
|
||||
commandScriptPath: '/new/home/.orca/agent-hooks/codex-hook.sh',
|
||||
trustConfigPath: '/new/home/hooks.json'
|
||||
}
|
||||
expect(_internals.installManagedHooksIntoWslRuntime(newPlan).state).toBe('installed')
|
||||
expect((await _internals.installManagedHooksIntoWslRuntime(newPlan)).state).toBe('installed')
|
||||
const newCommand = expectedManagedCommand(newPlan.commandScriptPath)
|
||||
const newKey = computeTrustKey(getManagedTrustEntry(newPlan, newCommand))
|
||||
const trustEntries = readHookTrustEntries(plan.tomlPath)
|
||||
@@ -171,7 +171,7 @@ describe('Codex WSL runtime hook install', () => {
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'drains stdin when the WSL runtime script is missing',
|
||||
() => {
|
||||
async () => {
|
||||
const basePlan = createTestPlan()
|
||||
const plan = {
|
||||
...basePlan,
|
||||
@@ -180,7 +180,7 @@ describe('Codex WSL runtime hook install', () => {
|
||||
writeFileSync(plan.configPath, '{"hooks":{}}\n', 'utf-8')
|
||||
writeFileSync(plan.tomlPath, '', 'utf-8')
|
||||
|
||||
expect(_internals.installManagedHooksIntoWslRuntime(plan).state).toBe('installed')
|
||||
expect((await _internals.installManagedHooksIntoWslRuntime(plan)).state).toBe('installed')
|
||||
const installed = JSON.parse(readFileSync(plan.configPath, 'utf-8')) as HooksConfig
|
||||
const command = installed.hooks.UserPromptSubmit[0]?.hooks?.[0]?.command
|
||||
expect(command).toBe(expectedManagedCommand(plan.commandScriptPath))
|
||||
@@ -193,20 +193,20 @@ describe('Codex WSL runtime hook install', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('sweeps all managed WSL trust for disable or confirmed absence', () => {
|
||||
it('sweeps all managed WSL trust for disable or confirmed absence', async () => {
|
||||
// Why: disable and confirmed absence intentionally pass []. Transient
|
||||
// unavailability must NOT use this path — last known-good trust remains.
|
||||
const plan = createTestPlan()
|
||||
writeFileSync(plan.configPath, '{"hooks":{}}\n', 'utf-8')
|
||||
writeFileSync(plan.tomlPath, '', 'utf-8')
|
||||
expect(_internals.installManagedHooksIntoWslRuntime(plan).state).toBe('installed')
|
||||
expect((await _internals.installManagedHooksIntoWslRuntime(plan)).state).toBe('installed')
|
||||
|
||||
_internals.removeStaleWslRuntimeManagedHookTrustEntries(plan.tomlPath, [])
|
||||
|
||||
expect(readHookTrustEntries(plan.tomlPath).size).toBe(0)
|
||||
})
|
||||
|
||||
it('reconciles only current, conclusive WSL path settlements', () => {
|
||||
it('reconciles only current, conclusive WSL path settlements', async () => {
|
||||
expect(
|
||||
_internals.getWslHookReconciliationAction({
|
||||
settlement: { status: 'unavailable' },
|
||||
@@ -272,7 +272,7 @@ describe('Codex WSL runtime hook install', () => {
|
||||
).toBe('reinstall')
|
||||
})
|
||||
|
||||
it('generates a POSIX hook that bridges WSL loopback failures through Windows curl', () => {
|
||||
it('generates a POSIX hook that bridges WSL loopback failures through Windows curl', async () => {
|
||||
const script = _internals.getManagedScript('posix')
|
||||
expect(script).toContain('load_hook_endpoint()')
|
||||
expect(script).toContain('"set ORCA_AGENT_HOOK_TOKEN="*)')
|
||||
@@ -287,7 +287,7 @@ describe('Codex WSL runtime hook install', () => {
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'refreshes stale hook coordinates from a Windows endpoint file',
|
||||
() => {
|
||||
async () => {
|
||||
const plan = createTestPlan()
|
||||
const root = dirname(plan.configPath)
|
||||
const endpointPath = join(root, 'endpoint.cmd')
|
||||
@@ -339,7 +339,7 @@ describe('Codex WSL runtime hook install', () => {
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'uses the Windows curl discovered from the WSL PATH after loopback fails',
|
||||
() => {
|
||||
async () => {
|
||||
const plan = createTestPlan()
|
||||
const root = dirname(plan.configPath)
|
||||
const binDir = join(root, 'bin')
|
||||
@@ -378,7 +378,7 @@ describe('Codex WSL runtime hook install', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('installs trusted WSL hooks and removes only Orca entries when disabled', () => {
|
||||
it('installs trusted WSL hooks and removes only Orca entries when disabled', async () => {
|
||||
const plan = createTestPlan()
|
||||
const userCommand = '/bin/sh /home/alice/user-hook.sh'
|
||||
writeFileSync(
|
||||
@@ -408,7 +408,7 @@ describe('Codex WSL runtime hook install', () => {
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect(_internals.installManagedHooksIntoWslRuntime(plan).state).toBe('installed')
|
||||
expect((await _internals.installManagedHooksIntoWslRuntime(plan)).state).toBe('installed')
|
||||
|
||||
const installed = JSON.parse(readFileSync(plan.configPath, 'utf-8')) as HooksConfig
|
||||
expect(Object.keys(installed.hooks).sort()).toEqual([...managedEvents].sort())
|
||||
@@ -457,7 +457,7 @@ describe('Codex WSL runtime hook install app-server grant lane', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
trustGrantInternals.setGrantSessionRunnerSync(null)
|
||||
trustGrantInternals.setGrantSessionRunner(null)
|
||||
trustGrantInternals.resetDiagnostics()
|
||||
codexAppServerCapabilityCache.clear()
|
||||
if (previousUserDataPath === undefined) {
|
||||
@@ -467,12 +467,12 @@ describe('Codex WSL runtime hook install app-server grant lane', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('grants WSL managed trust through codex inside the distro instead of self-computed writes', () => {
|
||||
it('grants WSL managed trust through codex inside the distro instead of self-computed writes', async () => {
|
||||
const plan = createTestPlan()
|
||||
writeFileSync(plan.configPath, '{"hooks":{}}\n', 'utf-8')
|
||||
writeFileSync(plan.tomlPath, '', 'utf-8')
|
||||
|
||||
const runner = vi.fn((request: CodexHookTrustGrantRequest) => {
|
||||
const runner = vi.fn(async (request: CodexHookTrustGrantRequest) => {
|
||||
// Simulate codex's side: write trusted_hash blocks the way its config
|
||||
// writer would, then report the entries trusted.
|
||||
upsertHookTrustEntries(
|
||||
@@ -496,9 +496,9 @@ describe('Codex WSL runtime hook install app-server grant lane', () => {
|
||||
}))
|
||||
}
|
||||
})
|
||||
trustGrantInternals.setGrantSessionRunnerSync(runner)
|
||||
trustGrantInternals.setGrantSessionRunner(runner)
|
||||
|
||||
expect(_internals.installManagedHooksIntoWslRuntime(plan).state).toBe('installed')
|
||||
expect((await _internals.installManagedHooksIntoWslRuntime(plan)).state).toBe('installed')
|
||||
|
||||
expect(runner).toHaveBeenCalledTimes(1)
|
||||
const request = runner.mock.calls[0]![0]!
|
||||
@@ -519,7 +519,7 @@ describe('Codex WSL runtime hook install app-server grant lane', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the unchanged self-computed lane when the WSL grant falls back', () => {
|
||||
it('keeps the unchanged self-computed lane when the WSL grant falls back', async () => {
|
||||
const plan = createTestPlan()
|
||||
writeFileSync(plan.configPath, '{"hooks":{}}\n', 'utf-8')
|
||||
writeFileSync(plan.tomlPath, '', 'utf-8')
|
||||
@@ -527,9 +527,9 @@ describe('Codex WSL runtime hook install app-server grant lane', () => {
|
||||
const runner = vi.fn(() => {
|
||||
throw new Error('wsl.exe not reachable')
|
||||
})
|
||||
trustGrantInternals.setGrantSessionRunnerSync(runner)
|
||||
trustGrantInternals.setGrantSessionRunner(runner)
|
||||
|
||||
expect(_internals.installManagedHooksIntoWslRuntime(plan).state).toBe('installed')
|
||||
expect((await _internals.installManagedHooksIntoWslRuntime(plan)).state).toBe('installed')
|
||||
|
||||
expect(runner).toHaveBeenCalledTimes(1)
|
||||
const command = expectedManagedCommand(plan.commandScriptPath)
|
||||
@@ -540,12 +540,12 @@ describe('Codex WSL runtime hook install app-server grant lane', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the previous ledger to remove stale Codex hashes after a canonical path change', () => {
|
||||
it('uses the previous ledger to remove stale Codex hashes after a canonical path change', async () => {
|
||||
const basePlan = createTestPlan()
|
||||
writeFileSync(basePlan.configPath, '{"hooks":{}}\n', 'utf-8')
|
||||
writeFileSync(basePlan.tomlPath, '', 'utf-8')
|
||||
let staleKeyExpectedRemoved: string | null = null
|
||||
const runner = vi.fn((request: CodexHookTrustGrantRequest) => {
|
||||
const runner = vi.fn(async (request: CodexHookTrustGrantRequest) => {
|
||||
if (staleKeyExpectedRemoved) {
|
||||
expect(readHookTrustEntries(basePlan.tomlPath).has(staleKeyExpectedRemoved)).toBe(false)
|
||||
}
|
||||
@@ -571,7 +571,7 @@ describe('Codex WSL runtime hook install app-server grant lane', () => {
|
||||
}))
|
||||
}
|
||||
})
|
||||
trustGrantInternals.setGrantSessionRunnerSync(runner)
|
||||
trustGrantInternals.setGrantSessionRunner(runner)
|
||||
|
||||
const oldPlan = {
|
||||
...basePlan,
|
||||
@@ -579,7 +579,7 @@ describe('Codex WSL runtime hook install app-server grant lane', () => {
|
||||
trustConfigPath: '/old/home/hooks.json',
|
||||
linuxRuntimeHome: '/old/home'
|
||||
}
|
||||
expect(_internals.installManagedHooksIntoWslRuntime(oldPlan).state).toBe('installed')
|
||||
expect((await _internals.installManagedHooksIntoWslRuntime(oldPlan)).state).toBe('installed')
|
||||
const oldKey = computeTrustKey(
|
||||
getManagedTrustEntry(oldPlan, expectedManagedCommand(oldPlan.commandScriptPath))
|
||||
)
|
||||
@@ -591,7 +591,7 @@ describe('Codex WSL runtime hook install app-server grant lane', () => {
|
||||
trustConfigPath: '/new/home/hooks.json',
|
||||
linuxRuntimeHome: '/new/home'
|
||||
}
|
||||
expect(_internals.installManagedHooksIntoWslRuntime(newPlan).state).toBe('installed')
|
||||
expect((await _internals.installManagedHooksIntoWslRuntime(newPlan)).state).toBe('installed')
|
||||
const newKey = computeTrustKey(
|
||||
getManagedTrustEntry(newPlan, expectedManagedCommand(newPlan.commandScriptPath))
|
||||
)
|
||||
|
||||
+104
-38
@@ -73,7 +73,8 @@ import {
|
||||
snapshotCodexRuntimeHookTrustProvenance
|
||||
} from './hook-trust-promotion'
|
||||
import { grantManagedCodexHookTrust } from './codex-hook-trust-grant'
|
||||
import { readCurrentCodexTrustGrantLedgerHome } from './codex-trust-grant-host'
|
||||
import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue'
|
||||
import { readCurrentNativeCodexTrustGrantLedgerHome } from './codex-trust-grant-host'
|
||||
import {
|
||||
getCodexLedgerTrustedHash,
|
||||
readCodexTrustGrantLedgerHomeForReconciliation,
|
||||
@@ -585,7 +586,30 @@ function removeSystemManagedHookTrustEntries(systemHomePath: string, hooksJsonPa
|
||||
})
|
||||
}
|
||||
|
||||
function cleanupLegacySystemManagedHooks(): void {
|
||||
// Why (#16441): these sequences mutate the runtime config.toml *and* the
|
||||
// system one — approval promotion, the system-config sync and the legacy sweep
|
||||
// all touch ~/.codex/config.toml — so holding only the runtime lane still lets
|
||||
// a real-home grant's capture->restore window swallow their writes. Lock order
|
||||
// is always runtime-before-system; every other holder acquires it that way too.
|
||||
function runExclusivelyForRuntimeAndSystemTrustConfig<T>(
|
||||
runtimeHomePath: string,
|
||||
run: () => Promise<T>
|
||||
): Promise<T> {
|
||||
return runExclusivelyForCodexTrustConfig(getCodexConfigTomlPath(runtimeHomePath), () =>
|
||||
runExclusivelyForCodexTrustConfig(getSystemCodexConfigTomlPath(), run)
|
||||
)
|
||||
}
|
||||
|
||||
function cleanupLegacySystemManagedHooks(): Promise<void> {
|
||||
// Why: shares the real-home lane with ensureRealHomeCodexHookState — both
|
||||
// capture, mutate and roll back the user's ~/.codex/config.toml.
|
||||
return runExclusivelyForCodexTrustConfig(
|
||||
getSystemCodexConfigTomlPath(),
|
||||
sweepLegacySystemManagedHooks
|
||||
)
|
||||
}
|
||||
|
||||
async function sweepLegacySystemManagedHooks(): Promise<void> {
|
||||
if (systemCodexHomeHookSweepSuppressed()) {
|
||||
return
|
||||
}
|
||||
@@ -650,7 +674,7 @@ function cleanupLegacySystemManagedHooks(): void {
|
||||
// Remove only stale Orca hook entries and preserve other managers' metadata.
|
||||
const hooksWritePath = resolveHooksJsonWritePath(legacyConfigPath)
|
||||
const previousMode = statSync(hooksWritePath).mode
|
||||
mutateRealHomeHooksPreservingUserTrust({
|
||||
await mutateRealHomeHooksPreservingUserTrust({
|
||||
sourcePath: legacyConfigPath,
|
||||
runtimeHomePath: systemHomePath,
|
||||
tomlPath: getSystemCodexConfigTomlPath(),
|
||||
@@ -717,9 +741,9 @@ function cleanupLegacyCodexProfileHooks(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupLegacyManagedHookRepresentations(): void {
|
||||
async function cleanupLegacyManagedHookRepresentations(): Promise<void> {
|
||||
try {
|
||||
cleanupLegacySystemManagedHooks()
|
||||
await cleanupLegacySystemManagedHooks()
|
||||
cleanupLegacyCodexProfileHooks()
|
||||
} catch (error) {
|
||||
console.warn('[codex-hook-service] failed to clean legacy Codex hooks', error)
|
||||
@@ -859,9 +883,20 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
// Why (#16441): the grant inside awaits a codex app-server session, so a
|
||||
// concurrent pane launch could write this config.toml between this run's
|
||||
// capture and its restore. One lane per file keeps the sequence atomic.
|
||||
function installManagedHooksIntoWslRuntime(
|
||||
plan: CodexWslRuntimeHookInstallPlan
|
||||
): AgentHookInstallStatus {
|
||||
): Promise<AgentHookInstallStatus> {
|
||||
return runExclusivelyForCodexTrustConfig(plan.tomlPath, () =>
|
||||
installManagedHooksIntoWslRuntimeExclusively(plan)
|
||||
)
|
||||
}
|
||||
|
||||
async function installManagedHooksIntoWslRuntimeExclusively(
|
||||
plan: CodexWslRuntimeHookInstallPlan
|
||||
): Promise<AgentHookInstallStatus> {
|
||||
const config = readHooksJson(plan.configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
@@ -924,7 +959,7 @@ function installManagedHooksIntoWslRuntime(
|
||||
trustEntries,
|
||||
previousLedgerHome ? [previousLedgerHome] : []
|
||||
)
|
||||
const grant = grantManagedCodexHookTrust({
|
||||
const grant = await grantManagedCodexHookTrust({
|
||||
runtimeHomePath,
|
||||
tomlPath: plan.tomlPath,
|
||||
managedCommand: command,
|
||||
@@ -1050,17 +1085,24 @@ export class CodexHookService {
|
||||
return generation
|
||||
}
|
||||
|
||||
installForRuntimeHome(
|
||||
async installForRuntimeHome(
|
||||
runtimeHomePath: string | null | undefined,
|
||||
target?: CodexWslRuntimeHookTarget
|
||||
): AgentHookInstallStatus | null {
|
||||
): Promise<AgentHookInstallStatus | null> {
|
||||
const generation = this.supersedeWslReconciliation(runtimeHomePath)
|
||||
let installedTrustConfigPath: string | null = null
|
||||
// Why: JS is single-threaded, so the synchronous install below finishes
|
||||
// before any async `wsl.exe` settlement callback runs — this flag is
|
||||
// always set by the time the callback reads it.
|
||||
let installSucceeded = false
|
||||
const onCanonicalPathSettled = (settlement: WslCanonicalPathSettlement): void => {
|
||||
// Why: the install below now awaits a codex app-server session, so a
|
||||
// settlement callback can land mid-install. This gate keeps reconciliation
|
||||
// reading the finished install's flags, as it did when the install was
|
||||
// synchronous and no callback could interleave with it.
|
||||
let markPrimaryInstallSettled!: () => void
|
||||
let reconciliationChain = new Promise<void>((resolve) => {
|
||||
markPrimaryInstallSettled = resolve
|
||||
})
|
||||
const reconcileSettledWslCanonicalPath = async (
|
||||
settlement: WslCanonicalPathSettlement
|
||||
): Promise<void> => {
|
||||
if (!runtimeHomePath) {
|
||||
return
|
||||
}
|
||||
@@ -1097,7 +1139,7 @@ export class CodexHookService {
|
||||
if (!resolvedPlan) {
|
||||
return
|
||||
}
|
||||
const status = installManagedHooksIntoWslRuntime(resolvedPlan)
|
||||
const status = await installManagedHooksIntoWslRuntime(resolvedPlan)
|
||||
if (status.state === 'error') {
|
||||
console.warn('[codex-hook-service] failed to reconcile WSL hook path', status.detail)
|
||||
return
|
||||
@@ -1105,6 +1147,13 @@ export class CodexHookService {
|
||||
installedTrustConfigPath = resolvedPlan.trustConfigPath
|
||||
installSucceeded = status.state === 'installed'
|
||||
}
|
||||
const onCanonicalPathSettled = (settlement: WslCanonicalPathSettlement): void => {
|
||||
const run = (): Promise<void> => reconcileSettledWslCanonicalPath(settlement)
|
||||
reconciliationChain = reconciliationChain.then(run, run)
|
||||
void reconciliationChain.catch((error: unknown) => {
|
||||
console.warn('[codex-hook-service] failed to reconcile WSL hook path', error)
|
||||
})
|
||||
}
|
||||
const wslPlan = createCodexWslRuntimeHookInstallPlan(
|
||||
runtimeHomePath,
|
||||
target,
|
||||
@@ -1112,9 +1161,13 @@ export class CodexHookService {
|
||||
onCanonicalPathSettled
|
||||
)
|
||||
installedTrustConfigPath = wslPlan?.trustConfigPath ?? null
|
||||
const status = wslPlan ? installManagedHooksIntoWslRuntime(wslPlan) : null
|
||||
installSucceeded = status?.state === 'installed'
|
||||
return status
|
||||
try {
|
||||
const status = wslPlan ? await installManagedHooksIntoWslRuntime(wslPlan) : null
|
||||
installSucceeded = status?.state === 'installed'
|
||||
return status
|
||||
} finally {
|
||||
markPrimaryInstallSettled()
|
||||
}
|
||||
}
|
||||
|
||||
refreshRuntimeUserHooksForRuntimeHome(
|
||||
@@ -1169,7 +1222,7 @@ export class CodexHookService {
|
||||
// hashes or wrote fallback hashes. Re-resolving PATH here doubles sync launch work.
|
||||
const ledgerHome =
|
||||
recentGrantEntries === null
|
||||
? readCurrentCodexTrustGrantLedgerHome(runtimeHomePath, { kind: 'native' })
|
||||
? readCurrentNativeCodexTrustGrantLedgerHome(runtimeHomePath)
|
||||
: null
|
||||
const recentGrantHashes = new Map<string, { signature: string; trustedHash: string }>()
|
||||
for (const entry of recentGrantEntries ?? []) {
|
||||
@@ -1273,7 +1326,16 @@ export class CodexHookService {
|
||||
// Why: runtimeHomePath defaults to the shared managed mirror, but a managed
|
||||
// account launching against its own self-contained CODEX_HOME passes that
|
||||
// per-account home so hooks.json/config.toml/trust land where codex reads.
|
||||
install(runtimeHomePath: string = getOrcaManagedCodexHomePath()): AgentHookInstallStatus {
|
||||
install(
|
||||
runtimeHomePath: string = getOrcaManagedCodexHomePath()
|
||||
): Promise<AgentHookInstallStatus> {
|
||||
// Why: same lane as the grant it performs — see installManagedHooksIntoWslRuntime.
|
||||
return runExclusivelyForRuntimeAndSystemTrustConfig(runtimeHomePath, () =>
|
||||
this.installExclusively(runtimeHomePath)
|
||||
)
|
||||
}
|
||||
|
||||
private async installExclusively(runtimeHomePath: string): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath(runtimeHomePath)
|
||||
const scriptPath = getManagedScriptPath()
|
||||
// Why: must run before this install rewrites hooks.json/config.toml —
|
||||
@@ -1375,7 +1437,7 @@ export class CodexHookService {
|
||||
// then carry Codex's verbatim hashes into stale cleanup so it cannot
|
||||
// delete what Codex just wrote. Mirrored user trust keeps its existing
|
||||
// verbatim-carry lane either way.
|
||||
const grant = grantManagedCodexHookTrust({
|
||||
const grant = await grantManagedCodexHookTrust({
|
||||
runtimeHomePath,
|
||||
tomlPath,
|
||||
managedCommand: command,
|
||||
@@ -1410,24 +1472,14 @@ export class CodexHookService {
|
||||
}
|
||||
}
|
||||
snapshotCodexRuntimeHookTrustProvenance(runtimeHomePath)
|
||||
try {
|
||||
cleanupLegacySystemManagedHooks()
|
||||
cleanupLegacyCodexProfileHooks()
|
||||
} catch (error) {
|
||||
console.warn('[codex-hook-service] failed to clean legacy Codex hooks', error)
|
||||
}
|
||||
await cleanupLegacyManagedHookRepresentations()
|
||||
return this.getStatusAfterInstall(recentGrantEntries, runtimeHomePath)
|
||||
}
|
||||
|
||||
async installRemote(
|
||||
sftp: SFTPWrapper,
|
||||
remoteHome: string,
|
||||
options?: {
|
||||
/** Explicit CODEX_HOME dir (flat layout). WSL sessions read Orca's managed runtime home, not ~/.codex, so the default location leaves them hookless. */
|
||||
codexHomeDir?: string
|
||||
/** Skip the trust write when config.toml is absent — the WSL launch path seeds it only-if-absent, so creating it here would cancel that seed. */
|
||||
deferTrustUntilConfigToml?: boolean
|
||||
}
|
||||
options?: { codexHomeDir?: string; deferTrustUntilConfigToml?: boolean }
|
||||
): Promise<AgentHookInstallStatus> {
|
||||
const codexHomeBase =
|
||||
options?.codexHomeDir?.replace(/\/$/, '') ?? `${remoteHome.replace(/\/$/, '')}/.codex`
|
||||
@@ -1535,7 +1587,15 @@ export class CodexHookService {
|
||||
|
||||
refreshRuntimeUserHooks(
|
||||
runtimeHomePath: string = getOrcaManagedCodexHomePath()
|
||||
): AgentHookInstallStatus {
|
||||
): Promise<AgentHookInstallStatus> {
|
||||
return runExclusivelyForRuntimeAndSystemTrustConfig(runtimeHomePath, () =>
|
||||
this.refreshRuntimeUserHooksExclusively(runtimeHomePath)
|
||||
)
|
||||
}
|
||||
|
||||
private async refreshRuntimeUserHooksExclusively(
|
||||
runtimeHomePath: string
|
||||
): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath(runtimeHomePath)
|
||||
// Why: same as install() — capture in-Orca approvals before this refresh
|
||||
// rewrites the runtime files they are keyed against.
|
||||
@@ -1543,7 +1603,7 @@ export class CodexHookService {
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
// Why: disabled launch prep once called remove(); preserve that legacy cleanup even when runtime hooks.json is malformed.
|
||||
cleanupLegacyManagedHookRepresentations()
|
||||
await cleanupLegacyManagedHookRepresentations()
|
||||
return {
|
||||
agent: 'codex',
|
||||
state: 'error',
|
||||
@@ -1592,17 +1652,23 @@ export class CodexHookService {
|
||||
}
|
||||
snapshotCodexRuntimeHookTrustProvenance(runtimeHomePath)
|
||||
|
||||
cleanupLegacyManagedHookRepresentations()
|
||||
await cleanupLegacyManagedHookRepresentations()
|
||||
return this.getStatus(runtimeHomePath)
|
||||
}
|
||||
|
||||
remove(): AgentHookInstallStatus {
|
||||
remove(): Promise<AgentHookInstallStatus> {
|
||||
return runExclusivelyForRuntimeAndSystemTrustConfig(getOrcaManagedCodexHomePath(), () =>
|
||||
this.removeExclusively()
|
||||
)
|
||||
}
|
||||
|
||||
private async removeExclusively(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath()
|
||||
const configExists = existsSync(configPath)
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
// Why: a malformed hooks.json shouldn't strand old hooks in ~/.codex or the legacy profile after disabling.
|
||||
cleanupLegacyManagedHookRepresentations()
|
||||
await cleanupLegacyManagedHookRepresentations()
|
||||
return {
|
||||
agent: 'codex',
|
||||
state: 'error',
|
||||
@@ -1635,7 +1701,7 @@ export class CodexHookService {
|
||||
// Why: drop trust entries so config.toml doesn't accumulate dead [hooks.state] blocks across install/remove cycles.
|
||||
removeRuntimeManagedHookTrustEntries(configPath)
|
||||
|
||||
cleanupLegacyManagedHookRepresentations()
|
||||
await cleanupLegacyManagedHookRepresentations()
|
||||
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user