Files
orca/src/main/codex/hook-service.ts
T
Brennan BensonandOrcaWin e58de71f5e feat(codex): real-home routing + self-contained multi-account homes (#9501)
* feat(codex): backfill managed-home sessions into the real Codex home once per host

Orca-launched Codex sessions currently land only in the Orca-managed
runtime home, so the user's own `codex resume` picker and app history
never see them (#4444, #8612). Backfill the managed sessions tree into
the real ~/.codex/sessions/YYYY/MM/DD layout once per host:

- hardlink first (one physical rollout log), copy as the cross-volume
  fallback; existing target files are always skipped, nothing in either
  home is deleted or moved
- idempotent; per-file failures leave the completion marker unset so the
  next startup retries cheaply
- JSONL audit log of every link/copy/failure under
  <userData>/codex-session-backfill/
- honors the custom Codex session source home override, mirroring the
  existing system->managed bridge

WSL managed homes are distro-local and need an in-distro variant; that
is a follow-up.

* feat(codex): flag-gated system-default real-home routing scaffolding

Staged internal flag (default OFF, no settings UI): route the SYSTEM-DEFAULT
Codex account at the user's real ~/.codex instead of Orca's managed runtime
home. Flag OFF is byte-identical to today; managed (multi-account) selections
are unchanged in either state.

Routing (flag ON + host system default = no managed account):
- CodexRuntimeHomeService.prepareForCodexLaunch / prepareForRateLimitFetch
  return null so the PTY/env layer injects no managed CODEX_HOME and the
  rate-limit fetcher + auth-presence gate fall back to ~/.codex (the background
  poller stops spawning Codex against the managed home — the #5370 auth war).
- buildPtyHostEnv strips only a nested-Orca-inherited Orca-owned override
  (CODEX_HOME matching the private ORCA_CODEX_HOME marker), preserving a
  user-set CODEX_HOME. Shell-ready re-exports already no-op without the marker.
- The headless commit-message Codex path strips the same inherited override.

Hook install for the real-home lane (append-last into ~/.codex/hooks.json,
trust via the app-server client) lands with the trust plumbing; the managed
hook install is skipped for this lane meanwhile.

Credit @jellychoco (#8606) for the native-home routing direction.

Depends on the codex trust-rpc-grant plumbing for the real-home hook installer.

* fix(codex): strip the daemon-inherited Orca CODEX_HOME override for real-home routing

The daemon spawns PTYs from its own inherited environment and honors only
spawnOptions.envToDelete, so mutating the sparse env object was not enough to
strip an Orca-owned CODEX_HOME the daemon already carries. Add the strip to
envToDelete for both daemon host-spawn paths, preserving a user-set CODEX_HOME.

Verified live via CDP against a sandboxed dev instance (flag ON): an
Orca-spawned pane reports empty CODEX_HOME/ORCA_CODEX_HOME, so Codex resolves
its own ~/.codex. Adds daemon-path unit coverage (strip Orca-owned, preserve
user-owned, no-op when flag OFF).

* fix(codex): harden one-time session backfill

* test(codex): cover staged cross-volume install

* feat(codex): app-server trust-grant client, capability cache, and grant ledger

Short-lived codex app-server JSON-RPC client (hooks/list + config/batchWrite,
the same pair the Codex TUI 'Trust all' flow calls), run in a bundled
ELECTRON_RUN_AS_NODE entry so synchronous launch prep can block on it with a
hard deadline and guaranteed child reap. Capability cache modeled on
GitCapabilityCache, scoped per execution host (native vs each WSL distro),
with a narrow unknown-method/missing-subcommand unsupported predicate. The
grant ledger records verified grants so steady-state launches skip the RPC.

* fix(codex): grant managed hook trust via codex app-server RPCs in install/refresh

Host and WSL installs now grant trust for Orca's managed status hooks through
codex's own hooks/list -> config/batchWrite -> re-list verify, scoped to
exactly the managed entries; the previous computeTrustedHash lane is the
unchanged fallback for incapable/erroring CLIs. getStatus and the removal
paths recognize ledger-recorded codex hashes so drift between codex's real
algorithm and the replica no longer misreports or strands trust. SSH remote
install is untouched by design.

* test(codex): cover app-server trust grant client, cache, ledger, and lanes

* test(codex): cover commit-message real-home override strip/preserve

Adds the two cases for the headless commit-message Codex env under real-home
routing: a nested-Orca-inherited Orca-owned CODEX_HOME is stripped, and a
user-owned CODEX_HOME is preserved.

* test(codex): WSL grant-lane coverage — in-distro invocation and fallback parity

* feat(codex): real-home hook installer trusted via the codex app-server grant client

With the real-home flag ON and the system-default selection, install Orca's
status hook into the user's real ~/.codex before any pane spawns:

- entry APPENDED LAST per managed event: codex hook trust keys are positional
  (source:event:group:handler), so appending keeps every user entry's position
  and trust record intact; user entries and unknown top-level hooks.json fields
  are preserved verbatim
- trust is granted exclusively through the codex app-server client
  (hooks/list + config/batchWrite, verified by re-list); Orca never writes
  [hooks.state] into the user's real config.toml itself
- if the grant lane is unavailable (old binary, unsupported RPC, verify
  failure), the appended entry is rolled back byte-exactly and the host keeps
  the managed-home lane end to end (PTY env, rate limits, commit messages)
  via a lane gate on the runtime-home service
- one-time pristine backup of the user's hooks.json under Orca's userData;
  a rolling .bak sits next to the file (existing atomic writer)
- hook opt-out sweeps Orca entries from the real home and drops Orca-owned
  trust records; flag-off downgrade re-arms the existing legacy system-home
  sweep, which removes the entry and its trust keys cleanly
- the legacy system-home sweep is suppressed only while the real-home lane
  owns ~/.codex/hooks.json, so managed installs cannot delete the entry

* fix(codex): resolve the trust-grant entry without requiring electron

The grant bridge is reachable from plain-Node CLI entries, where the
plain-node entry guard rejects any chunk containing require("electron").
Resolve the bundled session entry from __dirname (root chunk and chunks/
layouts) with an app.asar -> app.asar.unpacked rewrite for packaged runs,
instead of electron's app path APIs.

* fix(codex): keep session backfill off main thread

Use asynchronous, sequential filesystem operations for the one-time rollout backfill, and avoid repeated target-directory probes. Treat inaccessible managed session roots as retryable failures instead of writing a false completion marker.

* fix(codex): harden app-server trust grant fallback

* fix(codex): install cross-volume session backfill copies atomically

On a real Codex home whose filesystem supports no hardlinks (exFAT/FAT,
some network mounts), the staged cross-volume copy was installed with a
non-atomic copyFile(..., COPYFILE_EXCL) straight into the final
rollout-*.jsonl name. An install interrupted mid-copy (app quit, crash,
ENOSPC during the deferred run) could strand a truncated rollout that the
next run then skips as already-present, defeating the staging design's own
guarantee that a failed copy never leaves a partial session behind.

Install the fully-staged copy with an atomic rename instead, guarded by an
existence re-check so it keeps the never-overwrite contract (and the rename
source is the same immutable managed rollout, so any clobber would be
byte-identical). Cover the no-hardlink-support target and an interrupted
install that must leave no partial in the user's sessions tree.

* fix(codex): resolve grant entry from __dirname so plain-node CLI entries stay electron-free

The build guard rejects any electron require reachable from plain-node
entries; the bridge now maps app.asar to app.asar.unpacked by string
replacement instead of consulting electron app paths. CLI typecheck project
lists the new trust-grant module graph.

* fix(codex): harden trust grant reconciliation

* fix(codex): restore trust config permissions on rollback

* fix(codex): harden real-home routing cleanup and retries

* fix(codex): preserve unicode trust RPC responses

* fix(codex): preserve remote env and complete real-home cleanup

* fix(codex): preserve real-home lane invariants

* test(terminal): isolate replacement idle reset assertion

* fix(codex): preserve real-home dotfile links

* fix(codex): preserve verified trust grants across launch prep

* fix(codex): preserve dangling config symlinks on rollback

* fix(codex): don't revoke a just-granted WSL home on a false 'missing' probe

The async wsl.exe canonical-path settlement could report the runtime home
'missing' immediately after a verified RPC grant (a false negative — codex
had just written and re-listed trust there), which drove the reconciliation
'remove' branch to delete all six granted [hooks.state] tables, leaving a bare
[hooks.state] the launching pane read as 'hooks need review'. A 'missing'
settlement now revokes only when no successful install ran this generation; a
genuinely moved home still resolves to a different path and reinstalls.

* test(codex): model codex config/batchWrite faithfully on Windows

The grant-lane stub simulated codex by calling Orca's upsertHookTrustEntries,
which writes both separator variants for a Windows key (a fallback-lane compat
shim real codex never does) — fabricating duplicate tables and whitespace the
RPC path never produces, so the byte-stable and no-duplicate assertions failed
on win32. Replace it with a single-variant, blank-line-separated writer that
matches the real 0.144.x binary's output.

* feat(codex): collapse duplicate session listings across Codex roots

Backfilled/bridged rollouts are hardlinked into both the real ~/.codex and
Orca's managed runtime home, so AI Vault listed each session once per root
(#7521). Dedup candidates by rollout file name pre-parse and parsed sessions
by session id post-parse, keeping the canonical root: host real home first
(unprefixed resume), then the managed runtime home, then other homes. Applies
to local, WSL, and SSH-remote scans.

* feat(codex): background sqlite index heal for backfilled sessions

Codex's own state-DB metadata backfill is one-shot, so rollouts hardlinked in
by Orca's session backfill never become visible to Codex's DB-driven surfaces.
Extract the app-server stdio JSONL transport into codex-app-server-session
(shared with the trust-grant client) and add a bounded, resumable background
pass that drives Codex's lazy indexing via thread/read per backfilled session:
recent-first, batched onto one short-lived server per batch with small
concurrency, ledger + marker so steady-state startups are a no-op, stop-aware
on quit, and capability-aware on CLIs without the app-server surface.

* fix(codex): preserve session identity during dedup heal

* fix(codex): preserve user trust during real-home cleanup

* fix(codex): harden real-home heal boundaries

* fix(codex): fail closed on unsafe backfill install

* fix: harden real-home hook cleanup

* fix(ai-vault): preserve execution boundaries and reap children

* fix(codex): narrow app-server unsupported detection

* fix(codex): bound user hook trust rebase retries per host

The rebase lane ran a codex app-server session on every launch prep while a
host was stuck (CLI without app-server support, or keys hooks/list cannot
match). Gate the transaction on the shared capability cache and add the same
5-minute transient cooldown the grant lane uses, so sweep and legacy-cleanup
retries cost plain fs reads instead of a codex session per pane spawn.

* fix(codex): enforce real-home resume and heal boundaries

* fix(codex): establish real-home lane before cleanup

* fix(codex): stop index heal before delayed spawn

* fix(codex): protect symlinked rolling backups

* fix(ai-vault): preserve resume env deletion through drag

* fix(codex): strip inherited Codex homes on mobile real-home resume

The mobile resume surface types a bare real-home codex resume into a
freshly created pane, but never asked for CODEX_HOME/ORCA_CODEX_HOME
deletion at pane spawn, so an agentDefaultEnv-pinned or daemon-inherited
Codex home rerouted the resume away from the user's real ~/.codex while
the same session resumed correctly on desktop. Share the deletion helper
from the AI Vault resume builders and forward it through the mobile
launch and session.tabs.createTerminal call.

* fix(codex): gate session migration on real-home lane

* fix(codex): stop session backfill after opt-out

* fix(codex): keep session heal failures retryable

* fix(codex): keep session migration state recoverable

* fix(codex): retry republished missing session heals

* fix(codex): preserve hook symlink trust path

* fix(codex): disambiguate POSIX trust paths

* fix(codex): align hook trust source paths

* fix(codex): harden trust grant lifecycle

* fix(codex): restore envToDelete on client invocation type after base reconcile

* test(codex): type child.stdout as PassThrough for oversized-output write

* Assemble RC: reconcile app-server transport API across PRs

Unify on the object RPC surface from the index-heal transport (#8921) while
preserving the default-home env strip (#8828) and the narrowed missing-app-server
capability signal (#8847): adapt the user-hook-trust-rebase consumer + tests,
port envToDelete stripping into the shared session, and route stderr
classification through the canonical capability-signal module.

* RC: enable system-default real-home routing by default (flag ON)

Flip codexSystemDefaultRealHomeEnabled to default ON for this RC's staged
rollout (a user can still opt out by setting it false, which stays byte-identical
to managed-home behavior). This is the only intended behavior difference between
the RC branch and the individual PRs. Updates the two tests that assumed the
prior OFF default.

* fix(codex): snapshot hooks.json bytes+parse in one read to close real-home clobber race

The install/sweep/legacy-cleanup paths parsed hooks.json, then did a separate
later read to capture the previous bytes for the pre-write generation guard.
A concurrent save (second Orca instance or the user editing the file) could
land between the parse and that second read and be silently overwritten.
readHooksJsonWithRaw returns the raw bytes and parse from a single read so the
guard compares against exactly what it parsed. Adds a regression test that
mutates hooks.json mid-RPC and asserts the sweep aborts without clobbering.

* fix(codex): sanitize managed account config trust

* fix(codex): guard OAuth add for custom providers

* fix(codex): persist outgoing managed tokens before real-home lane takeover (PR-C)

prepareForCodexLaunch returns null early for the real-home / system-default
lane before syncForCurrentSelection runs. If a managed account is still
recorded as synced when the selection has dropped to the system default
(nulled without a sync pass, or auto-deselect on missing managed auth), a
Codex-refreshed token stranded in the shared runtime home is never persisted
to its canonical per-account home -> token loss.

Read the outgoing managed account's refreshed token back before the real home
takes over. The real-home lane implies host === null, so running the
managed->system-default transition restores only Orca's runtime mirror from
~/.codex and never writes the real ~/.codex. It is a no-op once the selection
has already been reconciled, so the normal select path does not double-write.

* fix(codex): preserve refreshes across all default transitions

* feat(codex): show system-default/real-home account identity in switcher (PR-B)

The account switcher modeled the system-default Codex account as
activeAccountId:null with no identity fields, so the null row rendered
blank ("System default" / generic subtitle) even though its effective
login is whatever ~/.codex/auth.json currently is.

Add a CodexSystemDefaultIdentity descriptor {hasAuth, authKind, email,
providerAccountId, workspaceLabel} to CodexRateLimitAccountsState,
resolved live and READ-ONLY from ~/.codex by the accounts service and
returned from listAccounts()/getSnapshot(). The settings switcher now
renders the null (system-default) row as that real identity: the OAuth
email when signed in, "Custom provider — no usage tracked." for
env-key/custom-provider logins (auth.json with OPENAI_API_KEY, or an
OPENAI_API_KEY env with no auth.json), and the generic fallback when
signed out. Identity is host-scoped (per-distro WSL keeps the generic
label). Orca never writes ~/.codex; managed-account switches only touch
Orca-owned homes, so the system-default identity stays a stable,
displayed source of truth. Usage already routes to the real home via
getSystemCodexHomePath, so the switcher now attributes it to a real face.

Tests (sandboxed temp homes only): OAuth email/provider resolution,
api-key auth.json and env-key (no auth.json) as custom-provider,
signed-out, and select/deselect of a managed account never mutating
~/.codex/auth.json.

* fix(codex): parse multiline provider pins in OAuth guard

* fix(codex): harden managed trust sanitization

* fix(codex): harden system-default identity rendering

* feat(codex): give each managed account a self-contained CODEX_HOME; retire shared mirror (PR-E)

With the real-home flag ON, a host managed account now launches directly
against its own codex-accounts/<id>/home instead of the shared runtime
mirror + auth.json hot-swap:

- codex-home-paths: syncSystemCodexResourcesIntoManagedHome links system
  resources into any managed home (ownership-marker discipline; never
  symlinks into / mutates ~/.codex).
- runtime-home-service: prepareForCodexLaunch / prepareForRateLimitFetch /
  syncForCurrentSelection route the per-account home directly and skip the
  shared-home hot-swap + token read-back; each home keeps its own auth in
  place (fixes GAP-5 concurrent auth race). Session discovery scans every
  per-account home.
- hook-service / hook-trust-promotion: install/getStatus/refresh accept a
  runtimeHomePath so hooks + RPC-granted trust land in the per-account home.
- service: config mirror into a self-contained home uses the trust-
  preserving merge so granted hook/project trust survives account switches.
- codex-session-root-dedup: rank codex-accounts/<id>/home as canonical
  managed alongside the shared runtime home.

Flag-OFF and the system-default real-home (null) lane are unchanged; the
nested-Orca CODEX_HOME===ORCA_CODEX_HOME daemon strip (#5370) is preserved.
Sandboxed tests only; ~/.codex is never mutated.

* fix(codex): validate per-account home ownership

* fix(codex): keep managed rollouts discoverable across real-home opt-out

WI-4 lossless migration/rollback validation for pre-E shared-mirror managed
accounts. Session discovery gated the per-account home scan on the real-home
flag, so opting back out (flag OFF) hid every rollout an account accumulated
while the flag was ON — the data stayed on disk but vanished from the AI Vault
until the flag flipped back on.

Scan a managed host home whenever it holds a sessions/ tree, independent of the
flag; a never-enabled install keeps its homes credential-only so opt-out stays
byte-identical to today. Forward migration was already lossless (the shared
mirror is always scanned) and the opt-out credential read-back already refuses
to overwrite a fresher per-account token; add tests locking all three
invariants. Sandboxed tests only; ~/.codex is never touched.

* fix(codex): migrate stranded shared auth on E takeover

* test(e2e): isolate Electron from developer Codex home

* test(codex): add real-account validation harness

* fix(codex): finish C and E matcher composition

* fix(codex): bound validation harness shutdown

* test(codex): isolate hook lifecycle user data

* test(codex): cover realistic account-home migration

* fix(codex): keep standalone home tripwire active

* test(codex): fingerprint system auth in validation reports

* fix(codex): bind managed homes to account ownership

* fix(codex): normalize Windows trust source identity

* fix(codex): make Windows trust upgrade transactional

* test(codex): use TypeScript pipeline for validation scripts

* test(codex): run validation modules through native node

* test(codex): allow slow Windows tripwire startup

* fix(codex): survive lingering Windows codex login processes in add-account

On Windows, codex login can keep running (with descendants) after it has
written auth.json, holding OS handles on the per-account managed home
(log/codex-login.log). That made doAddAccount's post-login cleanup fail
with ENOTEMPTY (rmSync) and left an orphaned codex-accounts/<id>/home.

- runCodexLogin now watches for auth.json on Windows and force-kills the
  login process tree (taskkill /t) if it lingers past a short grace
  period; the forced exit is treated as a successful login. The 120s
  timeout path also kills the whole tree instead of only the direct
  child. macOS/Linux behavior is unchanged.
- safeRemoveManagedHome now removes homes with rmSync maxRetries /
  retryDelay (mirroring the local-worktree-filesystem Windows policy)
  and no longer lets a cleanup failure mask the original add error.
- run-codex-real-account-validation.mjs accepts --temp-parent /
  ORCA_CODEX_VALIDATION_TEMP_PARENT so the disposable root can live
  outside %USERPROFILE% on Windows, and fails with an actionable message
  before creating anything when the temp parent is inside the primary
  home. The real-home guard is unchanged.

* fix(codex): preserve managed-account MCP .credentials.json on per-account-home migration (#8440)

Codex file-mode MCP OAuth tokens live in $CODEX_HOME/.credentials.json,
keyed by MCP server URL with no account identity of their own. The legacy
shared-mirror -> per-account-home migration only carried auth.json, so an
existing managed account with authed MCP servers had its tokens stranded on
upgrade and silently needed re-auth.

Carry the shared mirror's .credentials.json into the same identity-proven
per-account home alongside auth.json: only into the single uniquely-matched
active account (no cross-account leak), only when the destination has none yet
(never clobber a newer file the account authed in its own home), atomic 0600,
absent-source no-op. New MCP auth already lands in the per-account home since
that home is CODEX_HOME.

* fix(codex): preserve Windows reauthentication login flow

* test(codex): build real-account validation harness cross-platform on Windows

The harness built its app with execFileSync('npx', ['electron-vite', ...]),
but npx resolves to a .cmd shim on Windows that execFileSync cannot launch
(ENOENT), so the harness could not build its own app there and required
--skip-build with a prebuilt out/main/index.js.

Extract resolveElectronViteBuildCommand(repoRoot): it runs the repository-local
electron-vite JS entry (node_modules/electron-vite/bin/electron-vite.js) with
the current Node binary (process.execPath), which resolves identically on
macOS, Linux, and Windows with no shell. It throws a clear error if the local
entry is missing (install deps or pass --skip-build). --skip-build behavior is
unchanged.

Add regression coverage asserting the build command uses process.execPath and
the repo-local JS entry (not npx), and that a missing entry fails clearly.

* fix(codex): version the MCP creds migration independently of the auth marker

The auth carry and the MCP .credentials.json carry (#8440) shared one
existence-only v1 marker, so any build that stamped the auth-only marker
first would strand the MCP store forever. The MCP carry now concludes via
its own per-account-mcp-creds-migration-v1.json marker and runs even when
the auth marker is already present; ordering is code-enforced instead of
landing-discipline-enforced.

Also isolate per-account read failures: one stale or deleted account home
no longer aborts the whole migration. The broken account stays in the
unique-identity ambiguity gate via its stored fields but is never read or
written, so the active account still migrates.

* fix(codex): fail corrupt managed auth.json without echoing credential bytes

A raw JSON.parse SyntaxError from loadOAuthCredentials could carry auth
file fragments into logs and the add/reauth error surface. Throw a
sanitized error instead; filesystem errors still propagate unchanged.

* fix(mobile): give the pairing runtime a disposable home for the E2E boot guard

The main-process guard now refuses to start with ORCA_E2E_USER_DATA_DIR
set but the real user home, and this was the one caller not updated —
the temporary pairing runtime crashed before emitting its pairing URL.

* test(codex): canonicalize harness containment guards and retry cleanup

Resolve symlinks before the disposable-root containment checks so a
symlinked temp parent cannot smuggle the throwaway home inside the
primary home, and give the final cleanup rm Windows retry/force so a
briefly lingering codex handle cannot strand the credential-bearing
root.

* test(codex): add lane-aware containment mode to the real-account harness

The Windows gate-D run proved strict zero-event whole-profile containment
is structurally unreachable with the real-home flag ON: system-default
spawn sites deliberately delete CODEX_HOME so native codex resolves the
real ~/.codex, and on Windows the binary ignores the USERPROFILE sandbox.
Its own volatile runtime churn (root sqlite/WAL/SHM, tmp/, log/) is the
shipped Phase-1 design, not a candidate defect.

--lane-aware-containment records those designed events without aborting
while every other real-home write — auth.json, config.toml,
.credentials.json, hooks.json, sessions/, anything unknown — remains a
hard violation and still aborts the run. Default behavior is unchanged
(strict); the absolute zero-event claim stays carried by macOS runs,
where HOME does sandbox native codex.

* test(codex): allow the real-account harness to pin the real-home flag off

--system-default-real-home off seeds and env-pins the flag OFF so every
codex spawn gets an explicit managed CODEX_HOME and native codex never
resolves the OS profile. This is the only Windows configuration where the
strict zero-event whole-profile tripwire is reachable, and it matches the
stable-rollout default; flag-ON runs keep lane-aware classification.

* test(codex): correct the flag-off harness comment to kill-switch rationale

The rollout ships all codex-home changes at once (no phased rollout), so
flag OFF is the emergency kill-switch lane, not the stable default.

* test(e2e): canonicalize the isolated E2E home path

The disposable HOME lives under os.tmpdir(), whose spelling is an alias
on CI (macOS /var symlink, Windows 8.3 RUNNER~1). Git canonicalizes
worktree paths, so worktrees created under the aliased home never
matched the app's listing — golden core flows and the packaged
crash-survival harness failed with 'worktree created but not found in
listing'. Resolve the home to its canonical spelling at creation in
both the e2e helper and the packaged-app driver.

* fix(codex): address CodeRabbit review on the landing PR

- carry envToDelete through the mobile agent-resume startup plan so a
  real-home Codex resume cannot inherit an ambient CODEX_HOME
- strip Orca-owned Codex overrides in the commit-message WSL fallback,
  matching the host fallback
- strip ELECTRON_RUN_AS_NODE in the computer-e2e driver like every other
  home-isolation caller
- drop the unused hooksEnabled parameter from isRealHomeCodexHookLaneUsable

* feat(codex): ship real-home routing unconditionally, remove the rollout flag

The codexSystemDefaultRealHomeEnabled setting is gone from types and
constants and the helper no longer consults settings — the system-default
real-home lane and per-account homes ship for everyone in one release.
This also un-strands profiles that rc-era builds stamped with false (the
setting had no UI, so every stored false was a seeded artifact that would
have silently kept those users on the legacy mirror forever).

The ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME env override survives strictly as
a test-rig control: the containment harness pins the legacy lane for
strict zero-event Windows runs, e2e home isolation pins lanes inside
disposable homes, and the legacy-lane test suites now route their
per-test lane selection through it.

---------

Co-authored-by: OrcaWin <alpha-eng@stably.ai>
2026-07-20 14:34:53 -07:00

1612 lines
61 KiB
TypeScript

/* eslint-disable max-lines -- Why: getStatus + install + remove all share the managed-command and trust-key derivation. Splitting would hide that the three operations must agree on group index, event label, and command bytes. */
import { existsSync, readFileSync, statSync, unlinkSync } from 'node:fs'
import { join, win32 as pathWin32 } from 'node:path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
buildManagedCommandHook,
createManagedCommandMatcher,
buildWindowsAgentHookCurlPostCommand,
getSharedManagedScriptPath,
hookDefinitionHasManagedCommand,
MANAGED_HOOK_TIMEOUT_SECONDS,
readHooksJson,
readHooksJsonWithRaw,
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsCmdHookCommand,
writeHooksJson,
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import {
readHooksJsonRemote,
readTextFileRemote,
writeHooksJsonRemote,
writeManagedScriptRemote,
writeTextFileRemoteAtomic
} from '../agent-hooks/installer-utils-remote'
import {
buildPosixHookPayloadCapture,
buildWindowsHookEnvironmentGuardLines,
buildWindowsHookStdinDrainEpilogue,
POSIX_HOOK_STDIN_DRAIN_COMMAND
} from '../agent-hooks/hook-stdin-contract'
import {
codexHookSourcePathsEqual,
computeTrustKey,
computeTrustedHash,
escapeTomlString,
getCodexExplicitHomeHookSourcePath,
normalizeCodexHookSourcePath,
normalizeCodexProjectPathForLookup,
normalizeHookTrustKeyForLookup,
parseTrustKey,
readHookTrustEntries,
removeHookTrustEntries,
upsertHookTrustEntriesInContent,
upsertHookTrustEntries,
writeConfigAtomically,
type CodexEventLabel,
type CodexHookTrustState,
type CodexTrustEntry
} from './config-toml-trust'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror'
import {
createCodexWslRuntimeHookInstallPlan,
type CodexWslRuntimeHookInstallPlan,
type CodexWslRuntimeHookTarget,
type WslCanonicalPathSettlement
} from './codex-wsl-hook-install-plan'
import {
CODEX_HOOK_EVENT_LABEL,
createCodexHookTrustEntry,
getCodexHookTrustSignature,
getCodexManagedScriptFileName
} from './codex-hook-identity'
import {
promoteCodexRuntimeHookApprovalsToSystem,
snapshotCodexRuntimeHookTrustProvenance
} from './hook-trust-promotion'
import { grantManagedCodexHookTrust } from './codex-hook-trust-grant'
import { readCurrentCodexTrustGrantLedgerHome } from './codex-trust-grant-host'
import {
getCodexLedgerTrustedHash,
readCodexTrustGrantLedgerHomeForReconciliation,
removeCodexManagedHookTrustEntries,
removeStaleWslCodexManagedHookTrustEntries
} from './codex-managed-trust-reconciliation'
import type { CodexTrustGrantLedgerHome } from './codex-trust-grant-ledger'
import { mutateRealHomeHooksPreservingUserTrust } from './codex-user-hook-trust-rebase'
// Why: Pre/PostToolUse feed the live in-flight-tool readout; PermissionRequest exits with no decision so Codex still shows its approval UI while Orca flips the pane to waiting.
const CODEX_EVENTS = [
'SessionStart',
'UserPromptSubmit',
'PreToolUse',
'PermissionRequest',
'PostToolUse',
'Stop'
] as const
function getConfigPath(runtimeHomePath: string = getOrcaManagedCodexHomePath()): string {
return join(runtimeHomePath, 'hooks.json')
}
function writeCodexHooksJson(configPath: string, hooks: Record<string, HookDefinition[]>): void {
// Why: Codex rejects unknown top-level hooks.json fields, so plugin bookkeeping like `_managed` must not survive Orca's rewrite.
writeHooksJson(configPath, { hooks })
}
function getCodexConfigTomlPath(runtimeHomePath: string = getOrcaManagedCodexHomePath()): string {
return join(runtimeHomePath, 'config.toml')
}
// Why: managed-event subset of the shared label map; full mapping lives in codex-hook-identity.ts so promotion can't drift.
const CODEX_EVENT_LABEL: Record<(typeof CODEX_EVENTS)[number], CodexEventLabel> = {
SessionStart: CODEX_HOOK_EVENT_LABEL.SessionStart!,
UserPromptSubmit: CODEX_HOOK_EVENT_LABEL.UserPromptSubmit!,
PreToolUse: CODEX_HOOK_EVENT_LABEL.PreToolUse!,
PermissionRequest: CODEX_HOOK_EVENT_LABEL.PermissionRequest!,
PostToolUse: CODEX_HOOK_EVENT_LABEL.PostToolUse!,
Stop: CODEX_HOOK_EVENT_LABEL.Stop!
}
const CODEX_MANAGED_EVENT_LABELS = new Set<CodexEventLabel>(
CODEX_EVENTS.map((eventName) => CODEX_EVENT_LABEL[eventName])
)
const CODEX_PLUGIN_ONLY_HOOK_PLACEHOLDERS = [
'${CLAUDE_PLUGIN_ROOT}',
'${CLAUDE_PLUGIN_DATA}',
'${PLUGIN_ROOT}',
'${PLUGIN_DATA}'
] as const
const LEGACY_ORCA_PROFILE_NAME = 'orca-agent-status'
const LEGACY_ORCA_PROFILE_BLOCK_START = '# BEGIN ORCA AGENT STATUS HOOKS'
const LEGACY_ORCA_PROFILE_BLOCK_END = '# END ORCA AGENT STATUS HOOKS'
type MirroredRuntimeUserHookTrustEntry = {
entry: CodexTrustEntry
enabled: boolean
}
function getManagedScriptPath(): string {
return getSharedManagedScriptPath(getCodexManagedScriptFileName())
}
function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32'
? wrapWindowsCmdHookCommand(scriptPath)
: wrapPosixHookCommand(scriptPath)
}
export type CodexManagedHookInstallMaterial = {
events: readonly (typeof CODEX_EVENTS)[number][]
eventLabel: Record<(typeof CODEX_EVENTS)[number], CodexEventLabel>
scriptPath: string
command: string
script: string
}
// Why: the real-home installer must byte-match the managed lane's events,
// command, and script, or trust signatures diverge between the two homes.
export function getCodexManagedHookInstallMaterial(): CodexManagedHookInstallMaterial {
const scriptPath = getManagedScriptPath()
return {
events: CODEX_EVENTS,
eventLabel: CODEX_EVENT_LABEL,
scriptPath,
command: getManagedCommand(scriptPath),
script: getManagedScript()
}
}
// Why: when the real-home lane owns ~/.codex/hooks.json (system-default flag ON
// with hooks enabled), the legacy system-home sweep must stand down or every
// managed install would delete the entry the real-home installer just wrote.
// Injected as a gate because this module is bundled into plain-node CLI entries
// that have no settings store; the CLI default keeps the sweep active.
let systemCodexHomeHookSweepSuppressed: () => boolean = () => false
export function setSystemCodexHomeHookSweepSuppressed(gate: () => boolean): void {
systemCodexHomeHookSweepSuppressed = gate
}
export { createCodexWslRuntimeHookInstallPlan }
export type { CodexWslRuntimeHookInstallPlan }
function wrapReadablePosixHookCommand(scriptPath: string): string {
const quoted = `'${scriptPath.replaceAll("'", "'\\''")}'`
// Why: WSL hooks are written from Windows over UNC where the exec bit is unreliable; a missing script must still own stdin.
return `if [ -f ${quoted} ] && [ -r ${quoted} ]; then /bin/sh ${quoted}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
}
function getSystemConfigPath(): string {
return join(getSystemCodexHomePath(), 'hooks.json')
}
function getSystemCodexConfigTomlPath(): string {
return join(getSystemCodexHomePath(), 'config.toml')
}
function getLegacyCodexProfileTomlPath(): string {
return join(getSystemCodexHomePath(), `${LEGACY_ORCA_PROFILE_NAME}.config.toml`)
}
function collectManagedTrustEntries(
sourcePath: string,
eventName: string,
definitions: readonly HookDefinition[],
isManagedCommand: (command: string | undefined) => boolean
): CodexTrustEntry[] {
const entries: CodexTrustEntry[] = []
definitions.forEach((definition, groupIndex) => {
const hooks = Array.isArray(definition.hooks) ? definition.hooks : []
hooks.forEach((hook, handlerIndex) => {
if (!isManagedCommand(hook.command)) {
return
}
const entry = createCodexHookTrustEntry(
sourcePath,
eventName,
groupIndex,
handlerIndex,
definition,
hook
)
if (entry) {
entries.push(entry)
}
})
})
return entries
}
function removeSelfComputedMatchingTrustEntries(
configPath: string,
entries: readonly CodexTrustEntry[]
): void {
if (entries.length === 0) {
return
}
const existingEntries = readHookTrustEntries(configPath)
const ownedKeys = entries
.map((entry) => {
const key = computeTrustKey(entry)
return existingEntries.get(key)?.trustedHash === computeTrustedHash(entry) ? key : null
})
.filter((key): key is string => key !== null)
if (ownedKeys.length > 0) {
removeHookTrustEntries(configPath, ownedKeys)
}
}
function removeStaleRuntimeHookTrustEntries(
tomlPath: string,
runtimeHooksPath: string,
expectedEntries: readonly CodexTrustEntry[]
): void {
const expectedHashes = new Map(
expectedEntries.map((entry) => [
normalizeHookTrustKeyForLookup(computeTrustKey(entry)),
entry.trustedHash ?? computeTrustedHash(entry)
])
)
const canonicalRuntimeHooksPath = getCodexExplicitHomeHookSourcePath(runtimeHooksPath)
const staleKeys: string[] = []
for (const [key, state] of readHookTrustEntries(tomlPath)) {
const parsed = parseTrustKey(key)
if (!parsed || !codexHookSourcePathsEqual(parsed.sourcePath, canonicalRuntimeHooksPath)) {
continue
}
if (expectedHashes.get(normalizeHookTrustKeyForLookup(key)) === state.trustedHash) {
continue
}
staleKeys.push(key)
}
if (staleKeys.length > 0) {
removeHookTrustEntries(tomlPath, staleKeys)
}
}
function commandUsesCodexPluginOnlyPlaceholder(command: string | undefined): boolean {
return (
typeof command === 'string' &&
CODEX_PLUGIN_ONLY_HOOK_PLACEHOLDERS.some((placeholder) => command.includes(placeholder))
)
}
function removeCodexPluginEnvironmentCommands(definitions: HookDefinition[]): HookDefinition[] {
// Why: plugin placeholders only resolve for Codex plugin hook sources; mirroring them into a plain runtime hooks.json turns them into 127s.
return removeManagedCommands(definitions, commandUsesCodexPluginOnlyPlaceholder)
}
function getRuntimeHooksWithSystemUserHooks(
runtimeHooks: Record<string, HookDefinition[]> | undefined,
isManagedCommand: (command: string | undefined) => boolean,
runtimeConfigPath: string = getConfigPath()
): {
hooks: Record<string, HookDefinition[]>
trustEntries: MirroredRuntimeUserHookTrustEntry[]
} {
const systemConfigPath = getSystemConfigPath()
if (systemConfigPath === runtimeConfigPath) {
return { hooks: { ...runtimeHooks }, trustEntries: [] }
}
const systemConfig = readHooksJson(systemConfigPath)
if (!systemConfig?.hooks) {
return { hooks: {}, trustEntries: [] }
}
const nextHooks: Record<string, HookDefinition[]> = {}
const trustedSystemHookSignatures = getTrustedSystemUserHookSignatures(
systemConfigPath,
systemConfig.hooks,
isManagedCommand
)
for (const [eventName, systemDefinitions] of Object.entries(systemConfig.hooks)) {
if (!Array.isArray(systemDefinitions)) {
continue
}
const systemUserDefinitions = removeCodexPluginEnvironmentCommands(
removeManagedCommands(systemDefinitions, isManagedCommand)
)
if (systemUserDefinitions.length === 0) {
continue
}
// Why: rebuild from system hooks; reusing old runtime copies would keep deleted/edited ~/.codex/hooks.json entries alive for new sessions.
nextHooks[eventName] = dedupeHookDefinitions(systemUserDefinitions)
}
return {
hooks: nextHooks,
trustEntries: collectMirroredRuntimeUserHookTrustEntries(
runtimeConfigPath,
nextHooks,
trustedSystemHookSignatures,
isManagedCommand
)
}
}
type TrustedSystemHookSignatureState = {
enabled: boolean
trustedHash: string
}
function getTrustedSystemUserHookSignatures(
systemConfigPath: string,
systemHooks: Record<string, HookDefinition[]>,
isManagedCommand: (command: string | undefined) => boolean
): Map<string, TrustedSystemHookSignatureState> {
const signatures = new Map<string, TrustedSystemHookSignatureState>()
let trustEntries: Map<string, CodexHookTrustState>
try {
trustEntries = readHookTrustEntries(getSystemCodexConfigTomlPath())
} catch (error) {
// Why: a hand-broken system config.toml should only disable user-hook trust mirroring, not block Orca's managed runtime hooks.
console.warn('[codex-hook-service] failed to read system hook trust entries', error)
return signatures
}
const trustedHashesByEvent = getTrustedSystemHookHashesByEvent(systemConfigPath, trustEntries)
for (const [eventName, definitions] of Object.entries(systemHooks)) {
if (!Array.isArray(definitions)) {
continue
}
definitions.forEach((definition, groupIndex) => {
const hooks = Array.isArray(definition.hooks) ? definition.hooks : []
hooks.forEach((hook, handlerIndex) => {
if (isManagedCommand(hook.command)) {
return
}
const entry = createCodexHookTrustEntry(
systemConfigPath,
eventName,
groupIndex,
handlerIndex,
definition,
hook
)
if (!entry) {
return
}
const state = resolveTrustedSystemHookState(entry, trustEntries, trustedHashesByEvent)
if (!state) {
return
}
const signature = getCodexHookTrustSignature(entry)
// Why: runtime deduping collapses identical definitions; if any duplicate stays enabled, keep the mirrored hook enabled.
if (state.enabled || !signatures.has(signature)) {
signatures.set(signature, state)
}
})
})
}
return signatures
}
function resolveTrustedSystemHookState(
entry: CodexTrustEntry,
trustEntries: ReadonlyMap<string, CodexHookTrustState>,
trustedHashesByEvent: ReadonlyMap<CodexEventLabel, Map<string, boolean>>
): TrustedSystemHookSignatureState | null {
const expectedHash = computeTrustedHash(entry)
const state = trustEntries.get(computeTrustKey(entry))
if (state?.trustedHash === expectedHash) {
return { enabled: state.enabled !== false, trustedHash: expectedHash }
}
const reorderedEnabled = trustedHashesByEvent.get(entry.eventLabel)?.get(expectedHash)
if (reorderedEnabled !== undefined) {
return { enabled: reorderedEnabled, trustedHash: expectedHash }
}
if (state?.trustedHash) {
// Why: carry a key-matched system hash verbatim — recomputing caused #7110 re-approval loops since Codex owns its hash algorithm.
return { enabled: state.enabled !== false, trustedHash: state.trustedHash }
}
return null
}
function getTrustedSystemHookHashesByEvent(
systemConfigPath: string,
trustEntries: ReadonlyMap<string, CodexHookTrustState>
): Map<CodexEventLabel, Map<string, boolean>> {
const trustedHashesByEvent = new Map<CodexEventLabel, Map<string, boolean>>()
const canonicalSystemConfigPath = normalizeCodexHookSourcePath(systemConfigPath)
for (const [key, state] of trustEntries) {
const parsed = parseTrustKey(key)
if (!parsed || !state.trustedHash) {
continue
}
if (!codexHookSourcePathsEqual(parsed.sourcePath, canonicalSystemConfigPath)) {
continue
}
let hashes = trustedHashesByEvent.get(parsed.eventLabel)
if (!hashes) {
hashes = new Map()
trustedHashesByEvent.set(parsed.eventLabel, hashes)
}
const enabled = state.enabled !== false
// Why: Codex trust keys include hook indices, but the hash still proves the same event+command identity was approved after a reorder.
if (enabled || !hashes.has(state.trustedHash)) {
hashes.set(state.trustedHash, enabled)
}
}
return trustedHashesByEvent
}
function collectMirroredRuntimeUserHookTrustEntries(
runtimeConfigPath: string,
runtimeHooks: Record<string, HookDefinition[]>,
trustedSystemHookSignatures: ReadonlyMap<string, TrustedSystemHookSignatureState>,
isManagedCommand: (command: string | undefined) => boolean
): MirroredRuntimeUserHookTrustEntry[] {
if (trustedSystemHookSignatures.size === 0) {
return []
}
const entries: MirroredRuntimeUserHookTrustEntry[] = []
const trustSourcePath = getCodexExplicitHomeHookSourcePath(runtimeConfigPath)
for (const [eventName, definitions] of Object.entries(runtimeHooks)) {
if (!Array.isArray(definitions)) {
continue
}
definitions.forEach((definition, groupIndex) => {
const hooks = Array.isArray(definition.hooks) ? definition.hooks : []
hooks.forEach((hook, handlerIndex) => {
if (isManagedCommand(hook.command)) {
return
}
const entry = createCodexHookTrustEntry(
trustSourcePath,
eventName,
groupIndex,
handlerIndex,
definition,
hook
)
if (!entry) {
return
}
const signature = getCodexHookTrustSignature(entry)
const state = trustedSystemHookSignatures.get(signature)
if (state !== undefined) {
entries.push({
entry: { ...entry, trustedHash: state.trustedHash },
enabled: state.enabled
})
}
})
})
}
return entries
}
function moveMirroredRuntimeUserTrustAfterManagedStatusHook(
entries: readonly MirroredRuntimeUserHookTrustEntry[]
): MirroredRuntimeUserHookTrustEntry[] {
return entries.map(({ entry, enabled }) => {
if (!CODEX_MANAGED_EVENT_LABELS.has(entry.eventLabel)) {
return { entry, enabled }
}
return {
entry: { ...entry, groupIndex: entry.groupIndex + 1 },
enabled
}
})
}
function escapeRegex(value: string): string {
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function buildHookTrustHeaderKeyPattern(key: string): string {
const keyVariants = [key]
const parsed = parseTrustKey(key)
if (parsed && /^[A-Za-z]:[\\/]|^\\\\/.test(parsed.sourcePath)) {
const suffix = `:${parsed.eventLabel}:${parsed.groupIndex}:${parsed.handlerIndex}`
keyVariants.push(
`${parsed.sourcePath.replace(/\\/g, '/')}${suffix}`,
`${parsed.sourcePath.replace(/\//g, '\\')}${suffix}`
)
}
const alternatives = [...new Set(keyVariants)].flatMap((variant) => {
const quoted = [`"${escapeRegex(escapeTomlString(variant))}"`]
if (!variant.includes("'")) {
// Why: tolerate raw-backslash literal keys from Codex/manual approval while repairing mirrored runtime trust across both Windows variants.
quoted.push(`'${escapeRegex(variant)}'`)
}
return quoted
})
return `(?:${alternatives.join('|')})`
}
function applyMirroredRuntimeUserHookTrustStates(
tomlPath: string,
entries: readonly MirroredRuntimeUserHookTrustEntry[]
): void {
if (entries.length === 0 || !existsSync(tomlPath)) {
return
}
const existing = readFileSync(tomlPath, 'utf-8')
let updated = existing
for (const { entry, enabled } of entries) {
const headerKeyPattern = buildHookTrustHeaderKeyPattern(computeTrustKey(entry))
const pattern = new RegExp(
`(\\[hooks\\.state\\.${headerKeyPattern}\\]\\r?\\n[ \\t]*enabled[ \\t]*=[ \\t]*)(true|false)`,
'g'
)
updated = updated.replace(pattern, `$1${enabled}`)
}
if (updated !== existing) {
writeConfigAtomically(tomlPath, updated)
}
}
function dedupeHookDefinitions(definitions: readonly HookDefinition[]): HookDefinition[] {
const seen = new Set<string>()
return definitions.filter((definition) => {
const key = JSON.stringify(definition)
if (seen.has(key)) {
return false
}
seen.add(key)
return true
})
}
function removeSystemManagedHookTrustEntries(systemHomePath: string, hooksJsonPath: string): void {
removeCodexManagedHookTrustEntries({
tomlPath: getSystemCodexConfigTomlPath(),
runtimeHomePath: systemHomePath,
sourcePath: hooksJsonPath,
command: getManagedCommand(getManagedScriptPath()),
managedEventLabels: CODEX_MANAGED_EVENT_LABELS,
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
})
}
function cleanupLegacySystemManagedHooks(): void {
if (systemCodexHomeHookSweepSuppressed()) {
return
}
const legacyConfigPath = getSystemConfigPath()
const runtimeConfigPath = getConfigPath()
if (legacyConfigPath === runtimeConfigPath) {
return
}
const systemHomePath = getSystemCodexHomePath()
const hasRecordedRealHomeGrant =
readCodexTrustGrantLedgerHomeForReconciliation(systemHomePath) !== null
// Why: the pre-write guard below compares against these bytes; a separate
// later read would let a concurrent save land between parse and snapshot.
const { raw: previousRaw, config } = readHooksJsonWithRaw(legacyConfigPath)
if (!config?.hooks || previousRaw === null) {
if (hasRecordedRealHomeGrant) {
removeSystemManagedHookTrustEntries(systemHomePath, legacyConfigPath)
}
return
}
const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName())
const nextHooks = { ...config.hooks }
const trustEntries: CodexTrustEntry[] = []
let removedManagedHook = false
for (const [eventName, definitions] of Object.entries(nextHooks)) {
if (!Array.isArray(definitions)) {
continue
}
const eventTrustEntries = collectManagedTrustEntries(
legacyConfigPath,
eventName,
definitions,
isManagedCommand
)
// Why: user hook configs can be large; avoid the argument limit from push(...entries).
for (const entry of eventTrustEntries) {
trustEntries.push(entry)
}
const cleaned = removeManagedCommands(definitions, isManagedCommand)
removedManagedHook ||= definitions.some((definition) =>
hookDefinitionHasManagedCommand(definition, isManagedCommand)
)
if (cleaned.length === 0) {
delete nextHooks[eventName]
} else {
nextHooks[eventName] = cleaned
}
}
// Why: Codex hooks moved to Orca's managed CODEX_HOME; stale ~/.codex entries would keep external Codex sessions reporting into Orca.
if (removedManagedHook) {
// Why: this is the user's system hooks file, not Orca's runtime copy.
// Remove only stale Orca hook entries and preserve other managers' metadata.
const hooksWritePath = resolveHooksJsonWritePath(legacyConfigPath)
const previousMode = statSync(hooksWritePath).mode
mutateRealHomeHooksPreservingUserTrust({
sourcePath: legacyConfigPath,
runtimeHomePath: systemHomePath,
tomlPath: getSystemCodexConfigTomlPath(),
beforeHooks: config.hooks,
afterHooks: nextHooks,
writeHooks: () => {
if (
readFileSync(legacyConfigPath, 'utf-8') !== previousRaw ||
resolveHooksJsonWritePath(legacyConfigPath) !== hooksWritePath
) {
// Why: the pre-mutation RPC may overlap a user save; downgrade must
// never replace that newer dotfiles generation with our stale parse.
throw new Error('System Codex hooks changed during trust repair')
}
writeHooksJson(hooksWritePath, { ...config, hooks: nextHooks }, { preserveMode: true })
},
restoreHooks: () => writeFileAtomically(hooksWritePath, previousRaw, { mode: previousMode })
})
// Why: stale dev/version entries can reference an older managed script
// path that is not represented by the current grant ledger.
removeSelfComputedMatchingTrustEntries(getSystemCodexConfigTomlPath(), trustEntries)
}
if (removedManagedHook || hasRecordedRealHomeGrant) {
// Why: the ledger recognizes Codex-computed hashes and remains a retry
// marker if a prior cleanup removed hooks.json but could not update TOML.
removeSystemManagedHookTrustEntries(systemHomePath, legacyConfigPath)
}
}
function stripLegacyManagedProfileBlock(content: string): string {
const start = content.indexOf(LEGACY_ORCA_PROFILE_BLOCK_START)
if (start === -1) {
return content
}
const endMarker = content.indexOf(LEGACY_ORCA_PROFILE_BLOCK_END, start)
const end = endMarker === -1 ? content.length : endMarker + LEGACY_ORCA_PROFILE_BLOCK_END.length
const before = content.slice(0, start).replace(/[ \t]*(?:\r?\n)*$/, '')
const after = content.slice(end).replace(/^(?:\r?\n)+/, '')
if (!before) {
return after
}
if (!after) {
return before.endsWith('\n') ? before : `${before}\n`
}
return `${before}\n\n${after}`
}
function cleanupLegacyCodexProfileHooks(): void {
const profilePath = getLegacyCodexProfileTomlPath()
if (!existsSync(profilePath)) {
return
}
const existing = readFileSync(profilePath, 'utf-8')
const next = stripLegacyManagedProfileBlock(existing)
if (next === existing) {
return
}
// Why: #2778 wrote Orca hooks into a Codex profile file; runtime CODEX_HOME supersedes it, so remove only Orca's marked block.
if (next.trim().length === 0) {
unlinkSync(profilePath)
} else {
writeConfigAtomically(profilePath, next)
}
}
function cleanupLegacyManagedHookRepresentations(): void {
try {
cleanupLegacySystemManagedHooks()
cleanupLegacyCodexProfileHooks()
} catch (error) {
console.warn('[codex-hook-service] failed to clean legacy Codex hooks', error)
}
}
function removeRuntimeManagedHookTrustEntries(configPath: string): void {
try {
removeCodexManagedHookTrustEntries({
tomlPath: getCodexConfigTomlPath(),
runtimeHomePath: getOrcaManagedCodexHomePath(),
sourcePath: configPath,
command: getManagedCommand(getManagedScriptPath()),
managedEventLabels: CODEX_MANAGED_EVENT_LABELS,
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS,
sourceUsesExplicitCodexHome: true
})
} catch (error) {
// Best effort — stale trust is harmless once hooks.json no longer references the hook; log so a programmer error isn't silent.
console.warn('[codex-hook-service] failed to clean trust entries', error)
}
}
function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstallPlan): void {
try {
removeCodexManagedHookTrustEntries({
tomlPath: plan.tomlPath,
runtimeHomePath: pathWin32.dirname(plan.tomlPath),
sourcePath: plan.trustConfigPath,
command: wrapReadablePosixHookCommand(plan.commandScriptPath),
managedEventLabels: CODEX_MANAGED_EVENT_LABELS,
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
})
} catch (error) {
// Why: best-effort like host cleanup; stale trust is inert once hooks.json no longer points at us.
console.warn('[codex-hook-service] failed to clean WSL trust entries', error)
}
}
function removeStaleWslRuntimeManagedHookTrustEntries(
tomlPath: string,
desiredEntries: readonly CodexTrustEntry[],
priorLedgerHomes: readonly CodexTrustGrantLedgerHome[] = []
): void {
removeStaleWslCodexManagedHookTrustEntries({
tomlPath,
runtimeHomePath: pathWin32.dirname(tomlPath),
desiredEntries,
managedEventLabels: CODEX_MANAGED_EVENT_LABELS,
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS,
buildManagedCommand: (linuxRuntimeHome) =>
wrapReadablePosixHookCommand(`${linuxRuntimeHome}/.orca/agent-hooks/codex-hook.sh`),
priorLedgerHomes
})
}
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
if (target === 'local' && process.platform === 'win32') {
return [
'@echo off',
'setlocal',
// Why: the endpoint file holds this install's live port/token; sourcing it lets a surviving PTY reach the current server (see claude/hook-service.ts).
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
...buildWindowsHookEnvironmentGuardLines(),
buildWindowsAgentHookCurlPostCommand('codex'),
'exit /b 0',
...buildWindowsHookStdinDrainEpilogue(),
''
].join('\r\n')
}
return [
'#!/bin/sh',
...buildPosixHookPayloadCapture(),
// Why: sourcing refreshes PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts).
'load_hook_endpoint() {',
' endpoint_path="$1"',
' case "$endpoint_path" in',
' *.cmd)',
// Why: Windows passes endpoint.cmd into WSL via WSLENV; parse only Orca's known assignments since cmd.exe `set` lines aren't shell syntax.
' endpoint_cr=$(printf "\\r")',
' while IFS= read -r endpoint_line || [ -n "$endpoint_line" ]; do',
' endpoint_line=${endpoint_line%"$endpoint_cr"}',
' case "$endpoint_line" in',
' "set ORCA_AGENT_HOOK_PORT="*) ORCA_AGENT_HOOK_PORT=${endpoint_line#*=} ;;',
' "set ORCA_AGENT_HOOK_TOKEN="*) ORCA_AGENT_HOOK_TOKEN=${endpoint_line#*=} ;;',
' "set ORCA_AGENT_HOOK_ENV="*) ORCA_AGENT_HOOK_ENV=${endpoint_line#*=} ;;',
' "set ORCA_AGENT_HOOK_VERSION="*) ORCA_AGENT_HOOK_VERSION=${endpoint_line#*=} ;;',
' esac',
' done < "$endpoint_path"',
' ;;',
' *)',
' . "$endpoint_path" 2>/dev/null || :',
' ;;',
' esac',
'}',
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' load_hook_endpoint "$ORCA_AGENT_HOOK_ENDPOINT"',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' exit 0',
'fi',
'post_codex_hook() {',
' curl_bin="$1"',
' connect_timeout="${2:-0.5}"',
' max_time="${3:-1.5}"',
// Why: worktreeId embeds a path, so hand-building JSON in shell is unsafe with quotes/newlines; post raw payload plus metadata as form fields instead.
// Why: pipe payload to curl's stdin (`payload@-`) not an inline arg, so tens-of-KB tool output stays off the command line (EDR false positives).
' printf \'%s\' "$payload" | "$curl_bin" -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
' --connect-timeout "$connect_timeout" --max-time "$max_time" \\',
' --noproxy "127.0.0.1" \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload@-"',
'}',
'is_wsl_runtime() {',
' [ -n "$WSL_DISTRO_NAME" ] && return 0',
' grep -qiE "microsoft|wsl" /proc/sys/kernel/osrelease /proc/version 2>/dev/null',
'}',
'if post_codex_hook curl >/dev/null 2>&1; then',
' exit 0',
'fi',
'if is_wsl_runtime; then',
' windows_curl=$(command -v curl.exe 2>/dev/null || true)',
' if [ -n "$windows_curl" ] && [ -x "$windows_curl" ]; then',
' post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true',
' fi',
'fi',
'exit 0',
''
].join('\n')
}
function installManagedHooksIntoWslRuntime(
plan: CodexWslRuntimeHookInstallPlan
): AgentHookInstallStatus {
const config = readHooksJson(plan.configPath)
if (!config) {
return {
agent: 'codex',
state: 'error',
configPath: plan.configPath,
managedHooksPresent: false,
detail: 'Could not parse Codex hooks.json'
}
}
const isManagedCommand = createManagedCommandMatcher('codex-hook.sh')
const command = wrapReadablePosixHookCommand(plan.commandScriptPath)
const nextHooks = { ...config.hooks }
const managedEvents = new Set<string>(CODEX_EVENTS)
for (const [eventName, definitions] of Object.entries(nextHooks)) {
if (managedEvents.has(eventName) || !Array.isArray(definitions)) {
continue
}
const cleaned = removeManagedCommands(definitions, isManagedCommand)
if (cleaned.length === 0) {
delete nextHooks[eventName]
} else {
nextHooks[eventName] = cleaned
}
}
const trustEntries: CodexTrustEntry[] = []
for (const eventName of CODEX_EVENTS) {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
hooks: [buildManagedCommandHook(command)]
}
nextHooks[eventName] = [definition, ...cleaned]
trustEntries.push({
sourcePath: plan.trustConfigPath,
eventLabel: CODEX_EVENT_LABEL[eventName],
groupIndex: 0,
handlerIndex: 0,
command,
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
})
}
config.hooks = nextHooks
writeManagedScript(plan.scriptPath, getManagedScript('posix'))
writeCodexHooksJson(plan.configPath, nextHooks)
try {
// Why: same grant-then-fallback split as the host install — codex runs
// inside the distro so the hash authority matches the codex the pane runs.
const runtimeHomePath = pathWin32.dirname(plan.tomlPath)
// Why: a successful re-grant replaces the ledger. Keep the previous
// records long enough to prove ownership of stale canonical-path keys.
const previousLedgerHome = readCodexTrustGrantLedgerHomeForReconciliation(runtimeHomePath)
// Why: Codex's verified RPC write must be the final config mutation. A
// host-side rewrite after verification can race or invalidate that grant.
removeStaleWslRuntimeManagedHookTrustEntries(
plan.tomlPath,
trustEntries,
previousLedgerHome ? [previousLedgerHome] : []
)
const grant = grantManagedCodexHookTrust({
runtimeHomePath,
tomlPath: plan.tomlPath,
managedCommand: command,
managedEntries: trustEntries,
host: { kind: 'wsl', distro: plan.wslDistro, linuxRuntimeHome: plan.linuxRuntimeHome }
})
if (grant.lane === 'fallback') {
// Why: WSL runtime homes may carry user hook approvals we did not rebuild
// here; only upsert Orca's entries instead of sweeping the whole source.
upsertHookTrustEntries(plan.tomlPath, trustEntries)
}
} catch (error) {
return {
agent: 'codex',
state: 'error',
configPath: plan.configPath,
managedHooksPresent: true,
detail: `Hooks installed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.`
}
}
return {
agent: 'codex',
state: 'installed',
configPath: plan.configPath,
managedHooksPresent: true,
detail: null
}
}
function refreshWslRuntimeUserHooks(plan: CodexWslRuntimeHookInstallPlan): AgentHookInstallStatus {
const config = readHooksJson(plan.configPath)
if (!config) {
return {
agent: 'codex',
state: 'error',
configPath: plan.configPath,
managedHooksPresent: false,
detail: 'Could not parse Codex hooks.json'
}
}
const isManagedCommand = createManagedCommandMatcher('codex-hook.sh')
const nextHooks = { ...config.hooks }
for (const [eventName, definitions] of Object.entries(nextHooks)) {
if (!Array.isArray(definitions)) {
continue
}
const cleaned = removeManagedCommands(definitions, isManagedCommand)
if (cleaned.length === 0) {
delete nextHooks[eventName]
} else {
nextHooks[eventName] = cleaned
}
}
writeCodexHooksJson(plan.configPath, nextHooks)
removeWslRuntimeManagedHookTrustEntries(plan)
try {
// Why: the disabled path may run after the WSL mount root changed, so cleanup can't be scoped to the plan's current source path.
removeStaleWslRuntimeManagedHookTrustEntries(plan.tomlPath, [])
} catch (error) {
console.warn('[codex-hook-service] failed to clean stale WSL trust entries', error)
}
return {
agent: 'codex',
state: 'not_installed',
configPath: plan.configPath,
managedHooksPresent: false,
detail: null
}
}
// Why: transport failures preserve last known-good identity; a successful absence probe is strong enough to revoke trust immediately.
function getWslHookReconciliationAction(args: {
settlement: WslCanonicalPathSettlement
isCurrentGeneration: boolean
installedTrustConfigPath: string | null
resolvedTrustConfigPath: string | null
/** Whether the synchronous install for this generation wrote trust. */
installSucceeded: boolean
}): 'none' | 'remove' | 'reinstall' {
if (!args.isCurrentGeneration) {
return 'none'
}
if (args.settlement.status === 'missing') {
// Why: a `missing` directory probe right after a verified install/grant is
// a false negative — the RPC (or fallback) just wrote and read trust in
// that home, so it exists. Revoking here would delete the fresh grant the
// launching pane needs, resurfacing "hooks need review". A genuinely moved
// home resolves to a different path and takes the `reinstall` branch below.
return args.installSucceeded ? 'none' : 'remove'
}
if (
args.settlement.status !== 'resolved' ||
!args.resolvedTrustConfigPath ||
args.resolvedTrustConfigPath === args.installedTrustConfigPath
) {
return 'none'
}
return 'reinstall'
}
// Why: fold only the Windows-case-insensitive portion; a full lowercase would let case-distinct WSL homes share one reconciliation slot.
function getWslReconciliationKey(runtimeHomePath: string): string {
return normalizeCodexProjectPathForLookup(runtimeHomePath)
}
export class CodexHookService {
private readonly wslReconciliationGeneration = new Map<string, number>()
private supersedeWslReconciliation(runtimeHomePath: string | null | undefined): number {
if (!runtimeHomePath) {
return 0
}
const key = getWslReconciliationKey(runtimeHomePath)
const generation = (this.wslReconciliationGeneration.get(key) ?? 0) + 1
this.wslReconciliationGeneration.set(key, generation)
return generation
}
installForRuntimeHome(
runtimeHomePath: string | null | undefined,
target?: CodexWslRuntimeHookTarget
): 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 => {
if (!runtimeHomePath) {
return
}
const key = getWslReconciliationKey(runtimeHomePath)
const resolvedPlan =
settlement.status === 'resolved'
? createCodexWslRuntimeHookInstallPlan(
runtimeHomePath,
target,
() => settlement.canonicalPath
)
: null
const action = getWslHookReconciliationAction({
settlement,
isCurrentGeneration: this.wslReconciliationGeneration.get(key) === generation,
installedTrustConfigPath,
resolvedTrustConfigPath: resolvedPlan?.trustConfigPath ?? null,
installSucceeded
})
if (action === 'none') {
return
}
if (action === 'remove') {
try {
removeStaleWslRuntimeManagedHookTrustEntries(
pathWin32.join(runtimeHomePath, 'config.toml'),
[]
)
} catch (error) {
console.warn('[codex-hook-service] failed to revoke stale WSL hook trust', error)
}
return
}
if (!resolvedPlan) {
return
}
const status = installManagedHooksIntoWslRuntime(resolvedPlan)
if (status.state === 'error') {
console.warn('[codex-hook-service] failed to reconcile WSL hook path', status.detail)
return
}
installedTrustConfigPath = resolvedPlan.trustConfigPath
installSucceeded = status.state === 'installed'
}
const wslPlan = createCodexWslRuntimeHookInstallPlan(
runtimeHomePath,
target,
undefined,
onCanonicalPathSettled
)
installedTrustConfigPath = wslPlan?.trustConfigPath ?? null
const status = wslPlan ? installManagedHooksIntoWslRuntime(wslPlan) : null
installSucceeded = status?.state === 'installed'
return status
}
refreshRuntimeUserHooksForRuntimeHome(
runtimeHomePath: string | null | undefined,
target?: CodexWslRuntimeHookTarget
): AgentHookInstallStatus | null {
this.supersedeWslReconciliation(runtimeHomePath)
const wslPlan = createCodexWslRuntimeHookInstallPlan(runtimeHomePath, target)
return wslPlan ? refreshWslRuntimeUserHooks(wslPlan) : null
}
getStatus(runtimeHomePath: string = getOrcaManagedCodexHomePath()): AgentHookInstallStatus {
return this.getStatusAfterInstall(null, runtimeHomePath)
}
private getStatusAfterInstall(
recentGrantEntries: readonly CodexTrustEntry[] | null,
runtimeHomePath: string = getOrcaManagedCodexHomePath()
): AgentHookInstallStatus {
const configPath = getConfigPath(runtimeHomePath)
const scriptPath = getManagedScriptPath()
const config = readHooksJson(configPath)
if (!config) {
return {
agent: 'codex',
state: 'error',
configPath,
managedHooksPresent: false,
detail: 'Could not parse Codex hooks.json'
}
}
// Why: Codex 0.129+ silently drops untrusted hooks, so report `partial` when managed events OR their trust entries are missing/stale.
const command = getManagedCommand(scriptPath)
const tomlPath = getCodexConfigTomlPath(runtimeHomePath)
// Why: an unreadable config.toml (EACCES/EIO) is distinct from "file
// absent" (which returns an empty Map without throwing). Hooks.json may
// still be fine, so report partial with a specific reason rather than
// collapsing to a generic error or masking it as universally-stale trust.
let trustEntries: Map<string, CodexHookTrustState>
let trustReadError: string | null = null
try {
trustEntries = readHookTrustEntries(tomlPath)
} catch (error) {
trustEntries = new Map()
trustReadError = error instanceof Error ? error.message : String(error)
}
// Why: RPC-granted entries store Codex's own hash, which is authoritative
// even when it differs from computeTrustedHash — that difference is the
// drift bug class this lane exists to absorb, not a stale entry.
// Why: install() already resolved the binary and either verified Codex's
// hashes or wrote fallback hashes. Re-resolving PATH here doubles sync launch work.
const ledgerHome =
recentGrantEntries === null
? readCurrentCodexTrustGrantLedgerHome(runtimeHomePath, { kind: 'native' })
: null
const recentGrantHashes = new Map<string, { signature: string; trustedHash: string }>()
for (const entry of recentGrantEntries ?? []) {
if (entry.trustedHash) {
recentGrantHashes.set(normalizeHookTrustKeyForLookup(computeTrustKey(entry)), {
signature: getCodexHookTrustSignature(entry),
trustedHash: entry.trustedHash
})
}
}
const missing: string[] = []
const trustMissing: string[] = []
const disabled: string[] = []
const trustSourcePath = getCodexExplicitHomeHookSourcePath(configPath)
let presentCount = 0
for (const eventName of CODEX_EVENTS) {
const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : []
// Why: older installs appended, current ones prepend; last-match keeps status repair conservative when stale duplicate definitions survive.
let foundGroupIndex = -1
let foundHandlerIndex = -1
definitions.forEach((definition, idx) => {
const hooks = definition.hooks ?? []
// Why: last-match-wins at the group level — if merged hook arrays repeat our command, the surviving runtime entry is the last one.
const handlerIdx = hooks.findLastIndex((hook) => hook.command === command)
if (handlerIdx !== -1) {
foundGroupIndex = idx
foundHandlerIndex = handlerIdx
}
})
if (foundGroupIndex === -1) {
missing.push(eventName)
continue
}
presentCount += 1
// Why: a stale hash blocks firing like a missing entry, so compare against the canonical hash we would write.
// Why: Codex's hook_key is positional, so hardcoding handlerIndex 0 misreports trust for user-merged hook arrays.
// Why: hash the same `timeout` install() writes, since Codex folds it into the trust hash or every managed hook reports stale-trust.
const trustInput: CodexTrustEntry = {
sourcePath: trustSourcePath,
eventLabel: CODEX_EVENT_LABEL[eventName],
groupIndex: foundGroupIndex,
handlerIndex: foundHandlerIndex,
command,
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
}
const trustKey = computeTrustKey(trustInput)
const validHashes = new Set([computeTrustedHash(trustInput)])
const grantedHash = getCodexLedgerTrustedHash(ledgerHome, trustKey, trustInput)
if (grantedHash) {
validHashes.add(grantedHash)
}
const recentGrant = recentGrantHashes.get(normalizeHookTrustKeyForLookup(trustKey))
if (
recentGrant?.signature === getCodexHookTrustSignature(trustInput) &&
recentGrant.trustedHash
) {
validHashes.add(recentGrant.trustedHash)
}
const actualState = trustEntries.get(trustKey)
if (!actualState?.trustedHash || !validHashes.has(actualState.trustedHash)) {
trustMissing.push(eventName)
} else if (actualState?.enabled === false) {
disabled.push(eventName)
}
}
const managedHooksPresent = presentCount > 0
let state: AgentHookInstallState
let detail: string | null
if (presentCount === 0) {
state = 'not_installed'
// Why: surface the trust read error even when not_installed, so a broken config.toml gives actionable info.
detail = trustReadError !== null ? `Trust entries unverifiable: ${trustReadError}` : null
} else if (
missing.length === 0 &&
trustMissing.length === 0 &&
disabled.length === 0 &&
trustReadError === null
) {
state = 'installed'
detail = null
} else {
state = 'partial'
const parts: string[] = []
if (missing.length > 0) {
parts.push(`Managed hook missing for events: ${missing.join(', ')}`)
}
if (trustReadError !== null) {
parts.push(`Trust entries unverifiable: ${trustReadError}`)
} else if (trustMissing.length > 0) {
parts.push(`Trust entry missing or stale for events: ${trustMissing.join(', ')}`)
}
if (disabled.length > 0) {
parts.push(`Managed hook disabled for events: ${disabled.join(', ')}`)
}
detail = parts.join('; ')
}
return { agent: 'codex', state, configPath, managedHooksPresent, detail }
}
// 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 {
const configPath = getConfigPath(runtimeHomePath)
const scriptPath = getManagedScriptPath()
// Why: must run before this install rewrites hooks.json/config.toml —
// approvals the user made inside Orca-launched Codex are keyed to the
// previous launch's runtime layout, and stale-trust cleanup below would
// delete them once the system config stops backing them.
promoteCodexRuntimeHookApprovalsToSystem(runtimeHomePath)
const config = readHooksJson(configPath)
if (!config) {
return {
agent: 'codex',
state: 'error',
configPath,
managedHooksPresent: false,
detail: 'Could not parse Codex hooks.json'
}
}
// Why: match by script filename (not exact command) so a fresh install sweeps stale entries from older builds or a different userData path.
const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName())
const command = getManagedCommand(scriptPath)
const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand, configPath)
const nextHooks = hookPlan.hooks
const managedEvents = new Set<string>(CODEX_EVENTS)
// Why: sweep managed entries from events we no longer subscribe to (e.g. a prior install's PreToolUse), else they keep firing stale hooks after upgrade.
for (const [eventName, definitions] of Object.entries(nextHooks)) {
if (managedEvents.has(eventName)) {
continue
}
if (!Array.isArray(definitions)) {
// Why: a non-array event value would make removeManagedCommands throw; skip the unparsable entry, managed events below still install.
continue
}
const cleaned = removeManagedCommands(definitions, isManagedCommand)
if (cleaned.length === 0) {
delete nextHooks[eventName]
} else {
nextHooks[eventName] = cleaned
}
}
// Why: Codex 0.129+ requires a per-hook config.toml trust entry or the hook needs manual /hooks-approve; precompute the hash to avoid that.
const mirroredUserTrustEntries = moveMirroredRuntimeUserTrustAfterManagedStatusHook(
hookPlan.trustEntries
)
const mirroredTrustEntries: CodexTrustEntry[] = mirroredUserTrustEntries.map(
({ entry }) => entry
)
const managedTrustEntries: CodexTrustEntry[] = []
const trustSourcePath = getCodexExplicitHomeHookSourcePath(configPath)
for (const eventName of CODEX_EVENTS) {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
hooks: [buildManagedCommandHook(command)]
}
nextHooks[eventName] = [definition, ...cleaned]
// Why: the status hook must run before user hooks so a slow
// PostToolUse/Stop hook cannot leave the sidebar stuck on the previous
// state while Codex visibly reports that hooks are still running.
// timeoutSec mirrors the hook's `timeout` so the trust hash matches the
// entry actually written to hooks.json.
managedTrustEntries.push({
sourcePath: trustSourcePath,
eventLabel: CODEX_EVENT_LABEL[eventName],
groupIndex: 0,
handlerIndex: 0,
command,
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
})
}
const trustEntries: CodexTrustEntry[] = [...mirroredTrustEntries, ...managedTrustEntries]
let recentGrantEntries: readonly CodexTrustEntry[] = []
config.hooks = nextHooks
writeManagedScript(scriptPath, getManagedScript())
writeCodexHooksJson(configPath, nextHooks)
// Why: trust entries write last so a half-write can't leave a hash pointing at a nonexistent hook.
// Why: surface trust-write failures — otherwise getStatus reports green for a hook Codex won't fire.
try {
const tomlPath = getCodexConfigTomlPath(runtimeHomePath)
syncSystemConfigIntoManagedCodexHome({
runtimeHomePath,
systemHomePath: getSystemCodexHomePath()
})
// Why: Codex is the only authority on its trust-hash algorithm, so the
// managed entries are granted through codex app-server RPCs (verified by
// re-list) whenever the installed CLI supports them; the granted entries
// 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({
runtimeHomePath,
tomlPath,
managedCommand: command,
managedEntries: managedTrustEntries,
host: { kind: 'native' }
})
if (grant.lane === 'rpc') {
recentGrantEntries = grant.entries
upsertHookTrustEntries(tomlPath, mirroredTrustEntries)
removeStaleRuntimeHookTrustEntries(tomlPath, configPath, [
...mirroredTrustEntries,
...grant.entries
])
} else {
// Why: system user hook approvals are mirrored into runtime CODEX_HOME.
// If the user later revokes approval in ~/.codex/config.toml, preserving
// all old runtime [hooks.state.*] blocks would keep Orca Codex trusted.
// Upsert first so duplicate repair can preserve a disabled managed copy
// before stale cleanup removes old managed hook keys.
upsertHookTrustEntries(tomlPath, trustEntries)
removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries)
}
applyMirroredRuntimeUserHookTrustStates(tomlPath, mirroredUserTrustEntries)
} catch (error) {
return {
agent: 'codex',
state: 'error',
configPath,
managedHooksPresent: true,
detail: `Hooks installed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.`
}
}
snapshotCodexRuntimeHookTrustProvenance(runtimeHomePath)
try {
cleanupLegacySystemManagedHooks()
cleanupLegacyCodexProfileHooks()
} catch (error) {
console.warn('[codex-hook-service] failed to clean legacy Codex hooks', error)
}
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
}
): Promise<AgentHookInstallStatus> {
const codexHomeBase =
options?.codexHomeDir?.replace(/\/$/, '') ?? `${remoteHome.replace(/\/$/, '')}/.codex`
const remoteConfigPath = `${codexHomeBase}/hooks.json`
const remoteTomlPath = `${codexHomeBase}/config.toml`
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh`
try {
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
if (!config) {
return {
agent: 'codex',
state: 'error',
configPath: remoteConfigPath,
managedHooksPresent: false,
detail: 'Could not parse remote Codex hooks.json'
}
}
const command = wrapPosixHookCommand(remoteScriptPath)
const nextHooks = { ...config.hooks }
const managedEvents = new Set<string>(CODEX_EVENTS)
const isManagedCommand = createManagedCommandMatcher('codex-hook.sh')
for (const [eventName, definitions] of Object.entries(nextHooks)) {
if (managedEvents.has(eventName) || !Array.isArray(definitions)) {
continue
}
const cleaned = removeManagedCommands(definitions, isManagedCommand)
if (cleaned.length === 0) {
delete nextHooks[eventName]
} else {
nextHooks[eventName] = cleaned
}
}
const trustEntries: CodexTrustEntry[] = []
for (const eventName of CODEX_EVENTS) {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
hooks: [buildManagedCommandHook(command)]
}
nextHooks[eventName] = [...cleaned, definition]
trustEntries.push({
sourcePath: remoteConfigPath,
eventLabel: CODEX_EVENT_LABEL[eventName],
groupIndex: cleaned.length,
handlerIndex: 0,
command,
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
})
}
config.hooks = nextHooks
// Why: write script/settings before trust TOML; a partial trust write leaves Codex asking approval instead of running a missing script.
// Why: SSH remotes use POSIX `.sh` paths even when Orca runs on Windows; never derive remote script syntax from local OS.
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
// Why: SSH edits the user's remote ~/.codex/hooks.json directly, so preserve non-Orca top-level metadata while replacing the hooks tree.
await writeHooksJsonRemote(sftp, remoteConfigPath, { ...config, hooks: nextHooks })
try {
const existingTomlRaw = await readTextFileRemote(sftp, remoteTomlPath)
if (existingTomlRaw === null && options?.deferTrustUntilConfigToml === true) {
return {
agent: 'codex',
state: 'installed',
configPath: remoteConfigPath,
managedHooksPresent: true,
detail: 'Trust entries deferred until config.toml is seeded by the launch path'
}
}
const existingToml = existingTomlRaw ?? ''
const updatedToml = upsertHookTrustEntriesInContent(existingToml, trustEntries)
if (updatedToml !== existingToml) {
await writeTextFileRemoteAtomic(sftp, remoteTomlPath, updatedToml)
}
} catch (error) {
return {
agent: 'codex',
state: 'error',
configPath: remoteConfigPath,
managedHooksPresent: true,
detail: `Hooks installed but trust entries could not be written: ${
error instanceof Error ? error.message : String(error)
}. Run /hooks in Codex on the remote host to approve.`
}
}
return {
agent: 'codex',
state: 'installed',
configPath: remoteConfigPath,
managedHooksPresent: true,
detail: null
}
} catch (err) {
return {
agent: 'codex',
state: 'error',
configPath: remoteConfigPath,
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
}
refreshRuntimeUserHooks(
runtimeHomePath: string = getOrcaManagedCodexHomePath()
): AgentHookInstallStatus {
const configPath = getConfigPath(runtimeHomePath)
// Why: same as install() — capture in-Orca approvals before this refresh
// rewrites the runtime files they are keyed against.
promoteCodexRuntimeHookApprovalsToSystem(runtimeHomePath)
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()
return {
agent: 'codex',
state: 'error',
configPath,
managedHooksPresent: false,
detail: 'Could not parse Codex hooks.json'
}
}
const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName())
const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand, configPath)
config.hooks = hookPlan.hooks
writeCodexHooksJson(configPath, hookPlan.hooks)
try {
const tomlPath = getCodexConfigTomlPath(runtimeHomePath)
const trustEntries = hookPlan.trustEntries.map(({ entry }) => entry)
syncSystemConfigIntoManagedCodexHome({
runtimeHomePath,
systemHomePath: getSystemCodexHomePath()
})
// Why: this path is used when Orca status hooks are disabled. The
// runtime CODEX_HOME should keep user hooks, but not Orca-managed trust.
// Write current mirrored user trust first so stale cleanup compares
// against current hashes while deleting old managed hook keys.
upsertHookTrustEntries(tomlPath, trustEntries)
removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries)
applyMirroredRuntimeUserHookTrustStates(tomlPath, hookPlan.trustEntries)
} catch (error) {
return {
agent: 'codex',
state: 'error',
configPath,
managedHooksPresent: false,
detail: `User hooks refreshed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.`
}
}
snapshotCodexRuntimeHookTrustProvenance(runtimeHomePath)
cleanupLegacyManagedHookRepresentations()
return this.getStatus(runtimeHomePath)
}
remove(): 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()
return {
agent: 'codex',
state: 'error',
configPath,
managedHooksPresent: false,
detail: 'Could not parse Codex hooks.json'
}
}
const nextHooks = { ...config.hooks }
// Why: same broad matcher as install() so stale entries from older builds get cleaned even if scriptPath moved.
const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName())
for (const [eventName, definitions] of Object.entries(nextHooks)) {
if (!Array.isArray(definitions)) {
// Why: a non-array event value would make removeManagedCommands throw; skip it.
continue
}
const cleaned = removeManagedCommands(definitions, isManagedCommand)
if (cleaned.length === 0) {
delete nextHooks[eventName]
} else {
nextHooks[eventName] = cleaned
}
}
if (configExists) {
// Why: remove() may be the only repair path for a file whose top-level plugin metadata makes Codex reject hooks.json.
writeCodexHooksJson(configPath, nextHooks)
}
// Why: drop trust entries so config.toml doesn't accumulate dead [hooks.state] blocks across install/remove cycles.
removeRuntimeManagedHookTrustEntries(configPath)
cleanupLegacyManagedHookRepresentations()
return this.getStatus()
}
}
export const codexHookService = new CodexHookService()
export const _internals = {
getManagedScript,
installManagedHooksIntoWslRuntime,
refreshWslRuntimeUserHooks,
removeStaleWslRuntimeManagedHookTrustEntries,
getWslHookReconciliationAction
}