fix(codex): stop blocking the main thread on trust grants (#16441) (#16594)

* fix(codex): stop blocking the main thread on trust grants (#16441)

Codex hook trust was 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 real-home path
(rebase inspect + repair + grant). Cold start and every Codex pane
launch showed "Not Responding"; the reported event-loop gap was
15,049 ms.

The subprocess only ever existed to donate an event loop to a
deliberately blocked parent — `runCodexHookTrustGrantSession` was
already the real async implementation. Make the callers async and the
fork is unnecessary, so the bridge, the forked entry and its envelope
are deleted along with their build/knip/tsconfig registrations. The CLI
`agent hooks prepare-codex` handler is already async, so it awaits the
in-process session and saves a process spawn per managed-home shell.

`resolveCodexTrustGrantHost` is async too; the WSL identity probe moves
from `execFileSync` to `runProcess`, dropping that file from the
child-process import allowlist. Status reads keep a synchronous
native-only stamp path.

Two invariants that held only because the lane blocked:

- Overlapping capability probes were impossible by construction.
  `GitCapabilityCache`'s dedupe engine is extracted to a shared
  `CapabilityProbeCache` and `CodexAppServerCapabilityCache` now
  inherits it, so concurrent launches against a cold host share one
  app-server session instead of one each.
- Two grants on one `config.toml` could not interleave capture and
  restore. A reentrant per-file lane now serializes the whole install
  sequence (managed, WSL runtime, real-home ensure, legacy sweep) and
  the grant and rebase inside it.

Cold-start work moves off the critical path: retained-home
reconciliation (N sequential sessions) is fire-and-forget behind the
daemon provider, and the startup real-home ensure chains into managed
hook reconciliation instead of blocking app init.

Every preserved semantic is unchanged: never throws, the
ORCA_DISABLE_CODEX_TRUST_RPC kill switch, ledger hits, backfill-pending
and cooldown fallbacks, config rollback on every failure path,
pre-grant self-computed trust removal, the verify-failure taxonomy,
diagnostics and telemetry.

* fix(codex): widen the trust-config lane to every config.toml writer

Review follow-ups on #16441's async trust grant:

- `markCodexProjectTrusted` now runs inside the runtime+system config.toml
  lanes, so a project-trust write can no longer land inside a hook grant's
  capture->restore window and be silently reverted. Its callers await it.
- `install`/`refreshRuntimeUserHooks`/`remove` hold the system config.toml
  lane as well as the runtime one — they promote approvals into
  ~/.codex/config.toml and mirror it back. Lock order is runtime-before-system
  everywhere.
- The real-home ensure chain resumes after a rejection instead of returning
  the same rejected promise to every later pane launch, and resolving the real
  home is now inside the module's never-throws boundary.
- `buildSpawnEnv` awaits inside a cancelable pending-spawn registration, so
  shutdown during the (now long) env build stops the PTY from launching.
  `prepareLocalPtySpawn` generalizes into `awaitCancelableLocalPtySpawn`.
- CapabilityProbeCache drops the test-only `nowMs` passthrough; its probe
  backstop comment now describes what it actually guards.
- Preflight is a plain async function; the trust dispatch in orca-runtime
  collapses into one `markWorkspaceTrustedForAgent`.

* test(codex): exercise the trust-config lane under real concurrency

The async grant makes two pane launches overlap for the first time. These
drive the real modules end to end on real files: a rollback swallowing a
sibling's grant, a markCodexProjectTrusted write landing inside a capture
-> restore window, shared capability-probe dedupe on a cold host, the
host-scoped transient cooldown, and reentrancy from inside an installer.

Each was verified to fail against a deliberately broken implementation
(lane removed, dedupe disabled, cooldown made global, reentrancy pass-
through disabled).

* test(codex): stop hook-service suites spawning the developer's real codex

The forked grant bundle never existed under vitest, so the RPC lane was
unreachable in tests on main. Running it in-process makes these suites
spawn a real `codex app-server` when one is installed: 38 spawns and two
failures in hook-service-runtime-trust-repair on a machine with codex,
green in CI where there is none. Stand in for the missing binary so both
environments exercise the same fallback lane.

* docs(codex): scope the trust-RPC kill switch comment to what it actually gates

The comment read as though the flag forces the fallback lane everywhere. It
gates the managed grant only: the real-home rebase still runs its own
inspect/repair app-server sessions when Orca's insertion shifts a user's hook
positions, and never reads the flag.

Verified by exercise, not by reading — with the flag set, both
inspect-user-hook-trust and repair-user-hook-trust still ran. Pre-existing:
main has no check there either, it just blocked the main thread while doing it.

Widening the flag to cover the rebase is a follow-up; this only stops the
comment promising something the constant does not do.
This commit is contained in:
Neil
2026-08-26 16:44:55 -07:00
committed by GitHub
parent 68d5b9206e
commit 26721bd632
63 changed files with 2464 additions and 1558 deletions
@@ -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
-1
View File
@@ -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 -2
View File
@@ -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",
-6
View File
@@ -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(
+1 -1
View File
@@ -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')
@@ -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(
@@ -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)
+36 -9
View File
@@ -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')
+13 -4
View File
@@ -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 {
@@ -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'
)
@@ -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
@@ -470,106 +466,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
)
}
})
})
+2 -2
View File
@@ -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 } : {})
})
)
}
+175 -81
View File
@@ -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',
+162 -197
View File
@@ -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
@@ -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 }] }
+43 -116
View File
@@ -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
}
}
@@ -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
}
+46 -19
View File
@@ -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()
})
})
+31 -13
View File
@@ -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)
})
+27 -11
View File
@@ -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()
@@ -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) {
+31 -31
View File
@@ -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)
+29 -29
View File
@@ -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))
)
+103 -32
View File
@@ -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,12 +1472,7 @@ 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)
}
@@ -1535,7 +1592,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 +1608,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 +1657,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 +1706,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()
}
+46 -40
View File
@@ -39,6 +39,10 @@ vi.mock('os', async (importOriginal) => {
}
})
import {
restoreCodexTrustSessionsForTests,
stubCodexTrustSessionsForTests
} from './hook-service-test-harness'
import { CodexHookService } from './hook-service'
let tmpHome: string
@@ -50,6 +54,7 @@ beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-user-data-'))
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
process.env.ORCA_USER_DATA_PATH = userDataDir
stubCodexTrustSessionsForTests()
homedirMock.mockReturnValue(tmpHome)
getPathMock.mockImplementation((name: string) => {
if (name === 'userData') {
@@ -60,6 +65,7 @@ beforeEach(() => {
})
afterEach(() => {
restoreCodexTrustSessionsForTests()
rmSync(tmpHome, { recursive: true, force: true })
rmSync(userDataDir, { recursive: true, force: true })
if (previousUserDataPath === undefined) {
@@ -130,7 +136,7 @@ function readSystemToml(): string {
}
describe('codex hook trust write-back promotion', () => {
it('mirrors default-home trust when the .codex directory is a symlink', () => {
it('mirrors default-home trust when the .codex directory is a symlink', async () => {
const targetHome = join(tmpHome, 'dotfiles-codex')
mkdirSync(targetHome)
symlinkSync(targetHome, systemCodexDir(), process.platform === 'win32' ? 'junction' : 'dir')
@@ -141,7 +147,7 @@ describe('codex hook trust write-back promotion', () => {
upsertHookTrustEntriesInContent('', [systemEntry])
)
expect(new CodexHookService().install().state).toBe('installed')
expect((await new CodexHookService().install()).state).toBe('installed')
const runtimeTrust = readHookTrustEntries(join(runtimeHomeDir(), 'config.toml'))
expect(runtimeTrust.get(computeTrustKey(runtimeUserStopEntry()))?.trustedHash).toBe(
@@ -149,10 +155,10 @@ describe('codex hook trust write-back promotion', () => {
)
})
it('keeps an in-Orca approval of a user hook across launches and promotes it to ~/.codex', () => {
it('keeps an in-Orca approval of a user hook across launches and promotes it to ~/.codex', async () => {
writeSystemUserHook()
const service = new CodexHookService()
service.install()
await service.install()
// The mirrored user hook has no system trust yet, so no runtime trust
// entry exists for it — Codex would show it as pending review.
@@ -163,7 +169,7 @@ describe('codex hook trust write-back promotion', () => {
simulateCodexApproval(runtimeUserStopEntry())
const approvedHash = computeTrustedHash(runtimeUserStopEntry())
service.install()
await service.install()
// Approval survives the relaunch instead of being wiped as stale…
expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)?.trustedHash).toBe(approvedHash)
@@ -177,15 +183,15 @@ describe('codex hook trust write-back promotion', () => {
// Steady state: another launch with no external changes rewrites nothing.
const systemTomlAfterPromotion = readSystemToml()
const runtimeTomlAfterPromotion = readFileSync(runtimeTomlPath, 'utf-8')
service.install()
await service.install()
expect(readSystemToml()).toBe(systemTomlAfterPromotion)
expect(readFileSync(runtimeTomlPath, 'utf-8')).toBe(runtimeTomlAfterPromotion)
})
it('never promotes trust for the Orca-managed status hook into ~/.codex', () => {
it('never promotes trust for the Orca-managed status hook into ~/.codex', async () => {
writeSystemUserHook()
const service = new CodexHookService()
service.install()
await service.install()
// Simulate Codex rewriting the managed Stop hook's trust entry (as an
// approval after hash drift would).
@@ -206,20 +212,20 @@ describe('codex hook trust write-back promotion', () => {
{ hash: 'sha256:codex-corrected-managed-hash' }
)
service.install()
await service.install()
expect(readSystemToml()).not.toContain(managedCommand)
expect(readSystemToml()).not.toContain('codex-corrected-managed-hash')
})
it('does not resurrect trust the user revoked in ~/.codex/config.toml', () => {
it('does not resurrect trust the user revoked in ~/.codex/config.toml', async () => {
writeSystemUserHook()
// Pre-trust the hook in the system config, as a terminal Codex session would.
const systemTomlPath = join(systemCodexDir(), 'config.toml')
writeFileSync(systemTomlPath, upsertHookTrustEntriesInContent('', [systemUserStopEntry()]))
const service = new CodexHookService()
service.install()
await service.install()
const runtimeTomlPath = join(runtimeHomeDir(), 'config.toml')
const approvalKey = computeTrustKey(runtimeUserStopEntry())
@@ -227,19 +233,19 @@ describe('codex hook trust write-back promotion', () => {
// User revokes in the system config; the runtime copy must not win.
writeFileSync(systemTomlPath, '')
service.install()
await service.install()
expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)).toBeUndefined()
expect(readSystemToml()).not.toContain('[hooks.state.')
})
it('promotes an in-Orca disable of a mirrored user hook back to the system config', () => {
it('promotes an in-Orca disable of a mirrored user hook back to the system config', async () => {
writeSystemUserHook()
const systemTomlPath = join(systemCodexDir(), 'config.toml')
writeFileSync(systemTomlPath, upsertHookTrustEntriesInContent('', [systemUserStopEntry()]))
const service = new CodexHookService()
service.install()
await service.install()
// User disables the hook via /hooks inside Orca-launched Codex.
const runtimeTomlPath = join(runtimeHomeDir(), 'config.toml')
@@ -251,7 +257,7 @@ describe('codex hook trust write-back promotion', () => {
upsertHookTrustEntriesInContent(runtimeToml, [{ ...runtimeUserStopEntry(), enabled: false }])
)
service.install()
await service.install()
const systemState = readHookTrustEntries(systemTomlPath).get(
computeTrustKey(systemUserStopEntry())
@@ -261,17 +267,17 @@ describe('codex hook trust write-back promotion', () => {
expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)?.enabled).toBe(false)
})
it('carries a Codex-written hash verbatim when it differs from the reproduced hash', () => {
it('carries a Codex-written hash verbatim when it differs from the reproduced hash', async () => {
// Simulates Codex changing its trust hash algorithm: the approval hash in
// the runtime config no longer matches computeTrustedHash's output.
writeSystemUserHook()
const service = new CodexHookService()
service.install()
await service.install()
const driftedHash = 'sha256:codex-next-gen-hash-orca-cannot-reproduce'
simulateCodexApproval(runtimeUserStopEntry(), { hash: driftedHash })
service.install()
await service.install()
const runtimeTomlPath = join(runtimeHomeDir(), 'config.toml')
const approvalKey = computeTrustKey(runtimeUserStopEntry())
@@ -283,24 +289,24 @@ describe('codex hook trust write-back promotion', () => {
).toBe(driftedHash)
// And the launch after that still keeps it.
service.install()
await service.install()
expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)?.trustedHash).toBe(driftedHash)
})
it('does not touch ~/.codex on the first launch after upgrading (no provenance yet)', () => {
it('does not touch ~/.codex on the first launch after upgrading (no provenance yet)', async () => {
// Simulates an existing install: runtime home fully materialized by a
// build without provenance snapshots, managed hooks only.
const service = new CodexHookService()
service.install()
await service.install()
rmSync(join(runtimeHomeDir(), '.orca-hook-trust-provenance.json'), { force: true })
service.install()
await service.install()
expect(readSystemToml()).toBe('')
expect(existsSync(join(systemCodexDir(), 'config.toml'))).toBe(false)
})
it('re-promoting mirrored trust without provenance is a no-op on ~/.codex', () => {
it('re-promoting mirrored trust without provenance is a no-op on ~/.codex', async () => {
// Existing install with a system-trusted user hook, upgraded to this
// build: the mirrored runtime entry has no provenance, so promotion must
// sit out this launch and leave the system config byte-identical.
@@ -308,16 +314,16 @@ describe('codex hook trust write-back promotion', () => {
const systemTomlPath = join(systemCodexDir(), 'config.toml')
writeFileSync(systemTomlPath, upsertHookTrustEntriesInContent('', [systemUserStopEntry()]))
const service = new CodexHookService()
service.install()
await service.install()
rmSync(join(runtimeHomeDir(), '.orca-hook-trust-provenance.json'), { force: true })
const systemTomlBefore = readSystemToml()
service.install()
await service.install()
expect(readSystemToml()).toBe(systemTomlBefore)
})
it('does not resurrect trust revoked in ~/.codex before the first provenance snapshot', () => {
it('does not resurrect trust revoked in ~/.codex before the first provenance snapshot', async () => {
// Old build mirrored a system-trusted hook into the runtime home; the
// user then revoked it in ~/.codex/config.toml and upgraded to this
// build. The stale runtime mirror must not be mistaken for an approval.
@@ -325,11 +331,11 @@ describe('codex hook trust write-back promotion', () => {
const systemTomlPath = join(systemCodexDir(), 'config.toml')
writeFileSync(systemTomlPath, upsertHookTrustEntriesInContent('', [systemUserStopEntry()]))
const service = new CodexHookService()
service.install()
await service.install()
rmSync(join(runtimeHomeDir(), '.orca-hook-trust-provenance.json'), { force: true })
writeFileSync(systemTomlPath, '')
service.install()
await service.install()
expect(readSystemToml()).not.toContain('[hooks.state.')
expect(
@@ -339,21 +345,21 @@ describe('codex hook trust write-back promotion', () => {
).toBeUndefined()
})
it('does not flip a hook the user disabled in ~/.codex back to enabled after upgrading', () => {
it('does not flip a hook the user disabled in ~/.codex back to enabled after upgrading', async () => {
// Old build mirrored the hook enabled=true; the user then set
// enabled = false in ~/.codex/config.toml and upgraded to this build.
writeSystemUserHook()
const systemTomlPath = join(systemCodexDir(), 'config.toml')
writeFileSync(systemTomlPath, upsertHookTrustEntriesInContent('', [systemUserStopEntry()]))
const service = new CodexHookService()
service.install()
await service.install()
rmSync(join(runtimeHomeDir(), '.orca-hook-trust-provenance.json'), { force: true })
writeFileSync(
systemTomlPath,
upsertHookTrustEntriesInContent('', [{ ...systemUserStopEntry(), enabled: false }])
)
service.install()
await service.install()
expect(
readHookTrustEntries(systemTomlPath).get(computeTrustKey(systemUserStopEntry()))?.enabled
@@ -365,13 +371,13 @@ describe('codex hook trust write-back promotion', () => {
).toBe(false)
})
it('promotes one approval to every identical system hook collapsed by deduping', () => {
it('promotes one approval to every identical system hook collapsed by deduping', async () => {
writeSystemUserHook([USER_HOOK_COMMAND, USER_HOOK_COMMAND])
const service = new CodexHookService()
service.install()
await service.install()
simulateCodexApproval(runtimeUserStopEntry())
service.install()
await service.install()
const systemTrust = readHookTrustEntries(join(systemCodexDir(), 'config.toml'))
const approvedHash = computeTrustedHash(runtimeUserStopEntry())
@@ -379,16 +385,16 @@ describe('codex hook trust write-back promotion', () => {
expect(systemTrust.get(computeTrustKey(systemUserStopEntry(1)))?.trustedHash).toBe(approvedHash)
})
it('skips promotion when the approved hook no longer exists in ~/.codex/hooks.json', () => {
it('skips promotion when the approved hook no longer exists in ~/.codex/hooks.json', async () => {
writeSystemUserHook()
const service = new CodexHookService()
service.install()
await service.install()
simulateCodexApproval(runtimeUserStopEntry())
// User deletes the hook from their system hooks.json before relaunching.
writeFileSync(join(systemCodexDir(), 'hooks.json'), JSON.stringify({ hooks: {} }))
service.install()
await service.install()
expect(readSystemToml()).not.toContain('[hooks.state.')
// The runtime copy of the deleted hook (and its approval) is cleaned up.
@@ -398,10 +404,10 @@ describe('codex hook trust write-back promotion', () => {
).toBeUndefined()
})
it('promotes approvals recorded while status hooks are disabled (refresh path)', () => {
it('promotes approvals recorded while status hooks are disabled (refresh path)', async () => {
writeSystemUserHook()
const service = new CodexHookService()
service.refreshRuntimeUserHooks()
await service.refreshRuntimeUserHooks()
// Without the managed status hook, the mirrored user hook sits at group 0.
const refreshedRuntimeEntry: CodexTrustEntry = {
@@ -411,7 +417,7 @@ describe('codex hook trust write-back promotion', () => {
simulateCodexApproval(refreshedRuntimeEntry)
const approvedHash = computeTrustedHash(refreshedRuntimeEntry)
service.refreshRuntimeUserHooks()
await service.refreshRuntimeUserHooks()
expect(
readHookTrustEntries(join(systemCodexDir(), 'config.toml')).get(
@@ -35,7 +35,7 @@ describe('managed Codex shell preflight', () => {
).toBe(home)
})
it('accepts a marker-proven account home and installs only while hooks are enabled', () => {
it('accepts a marker-proven account home and installs only while hooks are enabled', async () => {
const userDataPath = makeRoot()
const home = join(userDataPath, 'codex-accounts', 'account-1', 'home')
mkdirSync(home, { recursive: true })
@@ -50,12 +50,22 @@ describe('managed Codex shell preflight', () => {
const env = { CODEX_HOME: home, ORCA_CODEX_HOME: home }
expect(
prepareManagedCodexHomeBeforeShellLaunch({ userDataPath, hooksEnabled: true, env, install })
await prepareManagedCodexHomeBeforeShellLaunch({
userDataPath,
hooksEnabled: true,
env,
install
})
).toMatchObject({ state: 'installed' })
expect(install).toHaveBeenCalledWith(home)
expect(
prepareManagedCodexHomeBeforeShellLaunch({ userDataPath, hooksEnabled: false, env, install })
await prepareManagedCodexHomeBeforeShellLaunch({
userDataPath,
hooksEnabled: false,
env,
install
})
).toBeNull()
expect(install).toHaveBeenCalledTimes(1)
})
+11 -3
View File
@@ -77,12 +77,20 @@ export function resolveManagedCodexShellPreflightHome(
return resolveAccountManagedHome(codexHome, userDataPath)
}
export function prepareManagedCodexHomeBeforeShellLaunch(args: {
/**
* Shell-startup preflight for a managed CODEX_HOME.
*
* Async because the Codex install awaits an app-server trust-grant session
* in-process. The old lane forked that session through spawnSync purely to
* borrow an event loop; the CLI already has one, so awaiting here removes a
* whole ELECTRON_RUN_AS_NODE process from every managed-home shell launch.
*/
export async function prepareManagedCodexHomeBeforeShellLaunch(args: {
env?: ShellPreflightEnvironment
userDataPath: string
hooksEnabled: boolean
install?: (runtimeHomePath: string) => AgentHookInstallStatus
}): AgentHookInstallStatus | null {
install?: (runtimeHomePath: string) => AgentHookInstallStatus | Promise<AgentHookInstallStatus>
}): Promise<AgentHookInstallStatus | null> {
if (!args.hooksEnabled) {
return null
}
@@ -13,11 +13,11 @@ function status(state: 'installed' | 'not_installed' | 'error'): AgentHookInstal
}
describe('retained Codex hook state', () => {
it('repairs Orca hooks before a retained shell can launch Codex', () => {
it('repairs Orca hooks before a retained shell can launch Codex', async () => {
const install = vi.fn(() => status('installed'))
const refreshRuntimeUserHooks = vi.fn(() => status('not_installed'))
reconcileRetainedCodexHookHomes({
await reconcileRetainedCodexHookHomes({
hookService: { install, refreshRuntimeUserHooks },
hooksEnabled: true,
runtimeHomePaths: ['/orca/shared-home', '/orca/account-home']
@@ -29,11 +29,11 @@ describe('retained Codex hook state', () => {
expect(refreshRuntimeUserHooks).not.toHaveBeenCalled()
})
it('removes only Orca hooks from retained homes when hooks are disabled', () => {
it('removes only Orca hooks from retained homes when hooks are disabled', async () => {
const install = vi.fn(() => status('installed'))
const refreshRuntimeUserHooks = vi.fn(() => status('not_installed'))
reconcileRetainedCodexHookHomes({
await reconcileRetainedCodexHookHomes({
hookService: { install, refreshRuntimeUserHooks },
hooksEnabled: false,
runtimeHomePaths: ['/orca/shared-home']
+17 -6
View File
@@ -1,20 +1,31 @@
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
type RetainedCodexHookService = {
install: (runtimeHomePath: string) => AgentHookInstallStatus
refreshRuntimeUserHooks: (runtimeHomePath: string) => AgentHookInstallStatus
install: (runtimeHomePath: string) => AgentHookInstallStatus | Promise<AgentHookInstallStatus>
refreshRuntimeUserHooks: (
runtimeHomePath: string
) => AgentHookInstallStatus | Promise<AgentHookInstallStatus>
}
export function reconcileRetainedCodexHookHomes(args: {
/**
* Repairs the hook state of Codex homes that retained shells still point at.
*
* Why not on the startup critical path (#16441): each home can run a codex
* app-server trust-grant session, so N retained homes used to mean N sequential
* multi-second blocks before the first window could paint. Callers start this
* and move on — the repair only matters before a retained shell's next Codex
* invocation, which cannot happen until the daemon provider is already serving.
*/
export async function reconcileRetainedCodexHookHomes(args: {
hookService: RetainedCodexHookService
hooksEnabled: boolean
runtimeHomePaths: readonly string[]
}): void {
}): Promise<void> {
for (const runtimeHomePath of args.runtimeHomePaths) {
try {
const status = args.hooksEnabled
? args.hookService.install(runtimeHomePath)
: args.hookService.refreshRuntimeUserHooks(runtimeHomePath)
? await args.hookService.install(runtimeHomePath)
: await args.hookService.refreshRuntimeUserHooks(runtimeHomePath)
if (status.state === 'error') {
console.warn('[codex-hook-service] failed to reconcile retained Codex home', status.detail)
}
+53 -35
View File
@@ -1073,12 +1073,17 @@ function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServices {
if (livePtyIds) {
reconcileCodexPaneAccountsWithLivePtys(livePtyIds)
const settings = store?.getSettings()
reconcileRetainedCodexHookHomes({
// Why (#16441): each retained home can run a codex app-server grant
// session. Awaiting them here delayed the first window by N sessions;
// a retained shell cannot invoke Codex before this provider serves.
void reconcileRetainedCodexHookHomes({
hookService: codexHookService,
hooksEnabled:
isAgentStatusHooksEnabled(settings) &&
settings?.disabledTuiAgents.includes('codex') !== true,
runtimeHomePaths: codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds)
}).catch((error: unknown) => {
console.warn('[codex-hook-service] retained Codex home reconcile failed:', error)
})
}
}
@@ -1139,24 +1144,24 @@ function bindTerminalRuntimeStartupServices(
localPtyProviderStartupReady = services.then((value) => value.localPtyProviderReady)
}
function prepareCodexRuntimeHomeForLaunch(
async function prepareCodexRuntimeHomeForLaunch(
target?: CodexAccountSelectionTarget,
launchEnv?: NodeJS.ProcessEnv,
launchContext?: CodexHomeLaunchContext
): string | null {
): Promise<string | null> {
if (
target?.runtime !== 'wsl' &&
launchContext?.launchAgent === 'codex' &&
launchContext.workspacePath
) {
try {
// Why: renderer quick-launch cannot await trust IPC before its PTY mounts; launch prep runs synchronously before every recognized Codex spawn.
markCodexProjectTrusted(launchContext.workspacePath)
// Why: renderer quick-launch cannot await trust IPC before its PTY mounts; launch prep runs before every recognized Codex spawn.
await markCodexProjectTrusted(launchContext.workspacePath)
} catch (error) {
console.warn('[codex-project-trust] failed to pre-mark launch workspace:', error)
}
}
const ensureRealHomeHooksIfSelected = (): boolean => {
const ensureRealHomeHooksIfSelected = async (): Promise<boolean> => {
if (
target?.runtime === 'wsl' ||
!codexRuntimeHome!.isHostSystemDefaultRealHomeSelected(launchEnv)
@@ -1167,13 +1172,13 @@ function prepareCodexRuntimeHomeForLaunch(
// and trusted by codex's own app-server grant — in the real ~/.codex before
// the pane spawns. An incapable grant flips the lane gate so the launch
// below falls back to the managed home instead of a status-blind pane.
ensureRealHomeCodexHookState({
await ensureRealHomeCodexHookState({
hooksEnabled: isAgentStatusHooksEnabled(store?.getSettings()),
userDataPath: app.getPath('userData')
})
return true
}
let realHomeHooksPrepared = ensureRealHomeHooksIfSelected()
let realHomeHooksPrepared = await ensureRealHomeHooksIfSelected()
// Why: a ManagedCodexHomeTemporarilyUnavailableError must escape uncaught —
// the fallbacks below all key off `null`, which means "system default", so
// swallowing the refusal would launch the wrong account (#STA-4422).
@@ -1184,7 +1189,7 @@ function prepareCodexRuntimeHomeForLaunch(
// Why: launch prep can reject an untrusted managed home and clear its
// selection. Establish hook capability for that newly selected lane, then
// re-resolve if the capability gate rejects it.
realHomeHooksPrepared = ensureRealHomeHooksIfSelected()
realHomeHooksPrepared = await ensureRealHomeHooksIfSelected()
if (realHomeHooksPrepared) {
runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv, {
unavailableManagedHomePath: launchContext?.unavailableManagedHomePath
@@ -1207,12 +1212,12 @@ function prepareCodexRuntimeHomeForLaunch(
try {
// Why: honor the persisted off switch so post-startup launches can't reinstall removed hooks.
const status = hooksEnabled
? (codexHookService.installForRuntimeHome(runtimeHomePath, hookTarget) ??
? ((await codexHookService.installForRuntimeHome(runtimeHomePath, hookTarget)) ??
// Why: a managed account's launch home is its own self-contained
// CODEX_HOME, so hooks/trust must install there, not the shared mirror.
codexHookService.install(runtimeHomePath ?? undefined))
(await codexHookService.install(runtimeHomePath ?? undefined)))
: (codexHookService.refreshRuntimeUserHooksForRuntimeHome(runtimeHomePath, hookTarget) ??
codexHookService.refreshRuntimeUserHooks(runtimeHomePath ?? undefined))
(await codexHookService.refreshRuntimeUserHooks(runtimeHomePath ?? undefined)))
if (status.state === 'error') {
console.warn(
`[codex-hook-service] failed to ${
@@ -1306,7 +1311,7 @@ async function prepareCodexSessionResumeForLaunch(args: {
if (args.workspacePath) {
try {
markCodexProjectTrusted(args.workspacePath)
await markCodexProjectTrusted(args.workspacePath)
} catch (error) {
console.warn('[codex-project-trust] failed to pre-mark resumed workspace:', error)
}
@@ -1317,11 +1322,14 @@ async function prepareCodexSessionResumeForLaunch(args: {
const hooksEnabled = isAgentStatusHooksEnabled(settingsStore.getSettings())
try {
if (isSystemHome) {
ensureRealHomeCodexHookState({ hooksEnabled, userDataPath: app.getPath('userData') })
await ensureRealHomeCodexHookState({
hooksEnabled,
userDataPath: app.getPath('userData')
})
} else if (hooksEnabled) {
codexHookService.install(resumeHome)
await codexHookService.install(resumeHome)
} else {
codexHookService.refreshRuntimeUserHooks(resumeHome)
await codexHookService.refreshRuntimeUserHooks(resumeHome)
}
} catch (error) {
// Why: hook repair is best-effort; session provenance must still win over the currently selected home.
@@ -3060,30 +3068,40 @@ void app.whenReady().then(async () => {
console.warn('[worktrees] Failed to sweep leftover worktree directories:', error)
})
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
if (codexRuntimeHome.isHostSystemDefaultRealHomeSelected()) {
// Why: establish capability before managed-hook reconciliation so an
// incapable host re-arms and completes the legacy real-home sweep now.
ensureRealHomeCodexHookState({
hooksEnabled: isAgentStatusHooksEnabled(store.getSettings()),
userDataPath: app.getPath('userData')
})
}
// Why (#16441): the real-home grant runs a codex app-server session. It stays
// ordered before managed-hook reconciliation an incapable host must re-arm
// and complete the legacy real-home sweep first — but awaiting it inline
// stalled app init behind that session, so chain instead of blocking.
const realHomeCodexHookState = codexRuntimeHome.isHostSystemDefaultRealHomeSelected()
? ensureRealHomeCodexHookState({
hooksEnabled: isAgentStatusHooksEnabled(store.getSettings()),
userDataPath: app.getPath('userData')
}).catch((error: unknown) => {
console.warn('[codex-real-home-hooks] startup ensure failed:', error)
})
: Promise.resolve()
if (shouldInstallManagedHooks(is.dev)) {
// Why: check the persisted off switch before any auto-install so removed hooks don't silently reappear on launch.
if (isAgentStatusHooksEnabled(store.getSettings())) {
const managedHookStore = store
void applyAgentStatusHooksEnabled(true, managedHookStore.getSettings(), {
shouldHydrateShellPath: app.isPackaged,
onInstallError: recordManagedHookInstallFailure,
shouldContinue: (agent) => {
const settings = managedHookStore.getSettings()
return shouldContinueManagedHookStartup(isQuitting, settings, agent)
}
}).catch((error) => {
console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error)
})
void realHomeCodexHookState
.then(() =>
applyAgentStatusHooksEnabled(true, managedHookStore.getSettings(), {
shouldHydrateShellPath: app.isPackaged,
onInstallError: recordManagedHookInstallFailure,
shouldContinue: (agent) => {
const settings = managedHookStore.getSettings()
return shouldContinueManagedHookStartup(isQuitting, settings, agent)
}
})
)
.catch((error: unknown) => {
console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error)
})
} else {
removeManagedAgentHooks()
void removeManagedAgentHooks().catch((error: unknown) => {
console.warn('[agent-hooks] failed to remove managed hooks on startup:', error)
})
}
}
// Why: process-gone metrics only see survivors; retain a recent whole-app
+6 -4
View File
@@ -114,8 +114,10 @@ type ManagedCodexAuthResolutionArgs = {
getSettings: () => GlobalSettings | undefined
requiredCodexHomePath?: string
target: CodexAccountSelectionTarget
resolveCurrent: () => string | null
resolveAfterUnavailable: (unavailableManagedHomePath: string) => string | null
resolveCurrent: () => string | null | Promise<string | null>
resolveAfterUnavailable: (
unavailableManagedHomePath: string
) => string | null | Promise<string | null>
}
export function resolveCodexHomeAfterManagedAuthReadiness(
@@ -150,7 +152,7 @@ async function continueCodexHomeAfterManagedAuthWait(
if (args.requiredCodexHomePath) {
return selectedCodexHomePath
}
const currentCodexHomePath = args.resolveCurrent()
const currentCodexHomePath = await args.resolveCurrent()
if (codexHomeSelectionsEqual(selectedCodexHomePath, currentCodexHomePath)) {
return selectedCodexHomePath
}
@@ -172,7 +174,7 @@ async function continueCodexHomeAfterManagedAuthWait(
if (args.requiredCodexHomePath) {
throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE)
}
selectedCodexHomePath = args.resolveAfterUnavailable(selectedCodexHomePath!)
selectedCodexHomePath = await args.resolveAfterUnavailable(selectedCodexHomePath!)
if (attempt === 1) {
break
}
+4 -4
View File
@@ -103,14 +103,14 @@ export function resolveCodexResumeLaunch(
})
}
export function reconcileSharedRuntimeResumeHome(
export async function reconcileSharedRuntimeResumeHome(
resumeHome: Extract<CodexSessionResumePreparation, { outcome: 'resume' }>,
resolveCurrentHome: () => string | null
): string {
resolveCurrentHome: () => string | null | Promise<string | null>
): Promise<string> {
if (!resumeHome.reconcileSharedRuntimeAuth) {
return resumeHome.codexHomePath
}
const currentHome = resolveCurrentHome()
const currentHome = await resolveCurrentHome()
if (!codexHomePathsEqual(currentHome, resumeHome.codexHomePath)) {
throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE)
}
+4 -1
View File
@@ -39,11 +39,14 @@ export type CodexHomeLaunchContext = {
unavailableManagedHomePath?: string
}
// Why (#16441): Codex launch prep grants hook trust through a codex app-server
// session. It resolves asynchronously so the Electron main thread stays
// responsive; every consumer already runs inside an async spawn path.
export type GetSelectedCodexHomePath = (
target?: CodexAccountSelectionTarget,
launchEnv?: NodeJS.ProcessEnv,
launchContext?: CodexHomeLaunchContext
) => string | null
) => string | null | Promise<string | null>
export type PrepareCodexSessionResume = (args: {
providerSession: AgentProviderSessionMetadata
+14 -15
View File
@@ -51,24 +51,23 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis
// Why: declared after the strip so a local-provider spawn cannot capture the
// pre-strip env — only the daemon branch below re-derives this from baseEnv.
ctx.env = ctx.baseEnv
const selectLaunchCodexHome = async (): Promise<string | null> =>
(await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, {
workspacePath: ctx.cwd,
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined
})) ?? null
ctx.selectedCodexHomePath =
!ctx.preAdoptedStablePane && !args.connectionId
? getCompatibleSelectedCodexHomePath(
ctx.codexSelectionTarget,
codexResumeHome
? ctx.deps.reconcileSharedRuntimeResumeHome(codexResumeHome, () =>
? await ctx.deps.reconcileSharedRuntimeResumeHome(codexResumeHome, async () =>
getCompatibleSelectedCodexHomePath(
ctx.codexSelectionTarget,
ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, {
workspacePath: ctx.cwd,
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined
}) ?? null
await selectLaunchCodexHome()
)
)
: (ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, {
workspacePath: ctx.cwd,
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined
}) ?? null)
: await selectLaunchCodexHome()
)
: null
if (!ctx.preAdoptedStablePane && args.launchAgent === 'codex' && args.sessionId === undefined) {
@@ -77,22 +76,22 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis
getSettings: () => ctx.deps.getSettings?.(),
requiredCodexHomePath: codexResumeHome?.codexHomePath,
target: ctx.codexSelectionTarget,
resolveCurrent: () =>
resolveCurrent: async () =>
getCompatibleSelectedCodexHomePath(
ctx.codexSelectionTarget,
ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, {
(await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, {
workspacePath: ctx.cwd,
launchAgent: 'codex'
}) ?? null
})) ?? null
),
resolveAfterUnavailable: (unavailableManagedHomePath) =>
resolveAfterUnavailable: async (unavailableManagedHomePath) =>
getCompatibleSelectedCodexHomePath(
ctx.codexSelectionTarget,
ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, {
(await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, {
workspacePath: ctx.cwd,
launchAgent: 'codex',
unavailableManagedHomePath
}) ?? null
})) ?? null
)
})
ctx.selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution
+2 -2
View File
@@ -110,8 +110,8 @@ export type PtySpawnIpcDeps = {
) => Promise<CodexResumeLaunch>
reconcileSharedRuntimeResumeHome: (
resumeHome: Extract<CodexSessionResumePreparation, { outcome: 'resume' }>,
resolveCurrent: () => string | null
) => string
resolveCurrent: () => string | null | Promise<string | null>
) => Promise<string>
stripSequencedStartupResumeArgv: <T extends Record<string, string> | undefined>(
env: T,
launch: CodexResumeLaunch
+3 -3
View File
@@ -38,7 +38,7 @@ export function configureLocalPtyProvider(args: {
getWindowsPowerShellImplementation: () =>
getSettings ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined,
pwshAvailable: () => isPwshAvailableAsync(),
buildSpawnEnv: (id, baseEnv, ctx) => {
buildSpawnEnv: async (id, baseEnv, ctx) => {
const codexSelectionTarget: CodexAccountSelectionTarget =
ctx?.isWsl === true
? { runtime: 'wsl', wslDistro: ctx.wslDistro ?? null }
@@ -47,10 +47,10 @@ export function configureLocalPtyProvider(args: {
codexSelectionTarget,
ctx?.codexHomePathOverride
? ctx.codexHomePathOverride.value
: (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
: ((await getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
workspacePath: ctx?.cwd,
launchAgent: ctx?.launchAgent
}) ?? null)
})) ?? null)
)
const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath
const ptySettings = getSettings?.()
+2 -2
View File
@@ -40,8 +40,8 @@ export type PtyRuntimeControllerDeps = {
noCodexResumeLaunch: (command: string | undefined) => CodexResumeLaunch
reconcileSharedRuntimeResumeHome: (
resumeHome: Extract<CodexSessionResumePreparation, { outcome: 'resume' }>,
resolveCurrent: () => string | null
) => string
resolveCurrent: () => string | null | Promise<string | null>
) => Promise<string>
stripSequencedStartupResumeArgv: <T extends Record<string, string> | undefined>(
env: T,
launch: CodexResumeLaunch
+14 -15
View File
@@ -171,24 +171,23 @@ export async function prepareRuntimePtySpawn(
if (args.preAllocatedHandle) {
ctx.env = { ...ctx.env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle }
}
const selectLaunchCodexHome = async (): Promise<string | null> =>
(await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, {
workspacePath: ctx.cwd,
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined
})) ?? null
ctx.selectedCodexHomePath =
!ctx.preAdoptedStablePane && !args.connectionId
? getCompatibleSelectedCodexHomePath(
ctx.codexSelectionTarget,
codexResumeHome
? ctx.deps.reconcileSharedRuntimeResumeHome(codexResumeHome, () =>
? await ctx.deps.reconcileSharedRuntimeResumeHome(codexResumeHome, async () =>
getCompatibleSelectedCodexHomePath(
ctx.codexSelectionTarget,
ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, {
workspacePath: ctx.cwd,
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined
}) ?? null
await selectLaunchCodexHome()
)
)
: (ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, {
workspacePath: ctx.cwd,
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined
}) ?? null)
: await selectLaunchCodexHome()
)
: null
if (
@@ -201,22 +200,22 @@ export async function prepareRuntimePtySpawn(
getSettings: () => ctx.deps.getSettings?.(),
requiredCodexHomePath: codexResumeHome?.codexHomePath,
target: ctx.codexSelectionTarget,
resolveCurrent: () =>
resolveCurrent: async () =>
getCompatibleSelectedCodexHomePath(
ctx.codexSelectionTarget,
ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, {
(await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, {
workspacePath: ctx.cwd,
launchAgent: 'codex'
}) ?? null
})) ?? null
),
resolveAfterUnavailable: (unavailableManagedHomePath) =>
resolveAfterUnavailable: async (unavailableManagedHomePath) =>
getCompatibleSelectedCodexHomePath(
ctx.codexSelectionTarget,
ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, {
(await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, {
workspacePath: ctx.cwd,
launchAgent: 'codex',
unavailableManagedHomePath
}) ?? null
})) ?? null
)
})
ctx.selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution
@@ -317,6 +317,30 @@ describe('LocalPtyProvider', () => {
expect(spawnMock).toHaveBeenCalledOnce()
})
// Why (#16441): the Codex hook install and trust grant moved into
// buildSpawnEnv, so the env build is now the long await before node-pty
// exists — shutdown must be able to cancel the session id during it.
it('does not spawn after shutdown cancels a pending spawn during the env build', async () => {
spawnMock.mockClear()
let finishEnvBuild!: (env: Record<string, string>) => void
const buildSpawnEnv = vi.fn(
(_id: string, baseEnv: Record<string, string>) =>
new Promise<Record<string, string>>((resolve) => {
finishEnvBuild = () => resolve(baseEnv)
})
)
const envProvider = new LocalPtyProvider({ buildSpawnEnv })
const spawn = envProvider.spawn({ cols: 80, rows: 24, sessionId: 'env-build-session' })
const canceledSpawn = expect(spawn).rejects.toThrow('PTY spawn canceled: env-build-session')
await vi.waitFor(() => expect(buildSpawnEnv).toHaveBeenCalledOnce())
await envProvider.shutdown('env-build-session', { immediate: true })
finishEnvBuild({})
await canceledSpawn
expect(spawnMock).not.toHaveBeenCalled()
})
it('coalesces a concurrent same-session-id spawn before launching a redundant shell (F3)', async () => {
spawnMock.mockClear()
const procA = { ...mockProc, pid: 1001 }
+25 -14
View File
@@ -373,17 +373,19 @@ function allocatePtyId(sessionId: string | undefined): string {
return id
}
async function prepareLocalPtySpawn(id: string): Promise<void> {
/** Awaits pre-launch work that shutdown must be able to cancel: no node-pty
* process exists yet, so cancellation can only be observed after the await. */
async function awaitCancelableLocalPtySpawn<T>(id: string, operation: T | Promise<T>): Promise<T> {
const pendingSpawn: PendingLocalPtySpawn = { canceled: false }
const pending = pendingLocalPtySpawns.get(id) ?? new Set()
pending.add(pendingSpawn)
pendingLocalPtySpawns.set(id, pending)
try {
// Why: shutdown must be able to cancel a stable session id during the async macOS capability probe, before node-pty exists.
await prepareMacosTccLoginShell()
const result = await operation
if (pendingSpawn.canceled) {
throw new Error(`PTY spawn canceled: ${id}`)
}
return result
} finally {
pending.delete(pendingSpawn)
if (pending.size === 0) {
@@ -534,7 +536,10 @@ export type LocalPtyProviderOptions = {
isWsl?: boolean
wslDistro?: string | null
}
) => Record<string, string>
// Why (#16441): Codex launch prep grants hook trust through a codex
// app-server session. `spawn` already awaits, so returning a promise keeps
// the Electron main thread responsive instead of blocking on spawnSync.
) => Record<string, string> | Promise<Record<string, string>>
/** Whether worktree-scoped shell history is enabled; when true (or absent) with a worktreeId, HISTFILE is scoped per-worktree. */
isHistoryEnabled?: () => boolean
/** Why: COMSPEC is always cmd.exe, so this callback injects the user's persisted shell preference. Undefined when none set. */
@@ -743,16 +748,21 @@ export class LocalPtyProvider implements IPtyProvider {
const isWslShell = Boolean(wslInfo) || pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe'
const launchWslDistro = isWslShell ? (launchWslContext?.distro ?? null) : null
// Why (#16441): building the env now awaits Codex hook installs and trust
// grants, so shutdown must be able to cancel this session id here too.
const finalEnv = this.opts.buildSpawnEnv
? this.opts.buildSpawnEnv(id, spawnEnv, {
command: args.command,
launchAgent: args.launchAgent,
codexHomePathOverride: args.codexHomePathOverride,
cwd,
shellPath,
isWsl: isWslShell,
wslDistro: launchWslDistro
})
? await awaitCancelableLocalPtySpawn(
id,
this.opts.buildSpawnEnv(id, spawnEnv, {
command: args.command,
launchAgent: args.launchAgent,
codexHomePathOverride: args.codexHomePathOverride,
cwd,
shellPath,
isWsl: isWslShell,
wslDistro: launchWslDistro
})
)
: spawnEnv
// Why: app-level env hooks can re-add scrubbed vars; delete last so shims like Claude Agent Teams keep their PATH.
for (const key of args.envToDelete ?? []) {
@@ -914,7 +924,8 @@ export class LocalPtyProvider implements IPtyProvider {
primaryLaunchEnvKeys = Object.keys(shellLaunch.env)
}
await prepareLocalPtySpawn(id)
// Why: the async macOS capability probe runs before node-pty exists.
await awaitCancelableLocalPtySpawn(id, prepareMacosTccLoginShell())
if (args.signal?.aborted) {
throw new Error('client_disconnected')
}
+24 -41
View File
@@ -24483,7 +24483,20 @@ export class OrcaRuntimeService {
}
}
private markLocalWorkspaceTrustedForAgent(agent: TuiAgent, workspacePath: string): void {
private markWorkspaceTrustedForAgent(
agent: TuiAgent,
connectionId: string | null | undefined,
workspacePath: string
): Promise<void> {
return connectionId
? this.markRemoteWorkspaceTrustedForAgent(agent, connectionId, workspacePath)
: this.markLocalWorkspaceTrustedForAgent(agent, workspacePath)
}
private async markLocalWorkspaceTrustedForAgent(
agent: TuiAgent,
workspacePath: string
): Promise<void> {
const preset = TUI_AGENT_CONFIG[agent].preflightTrust
if (!preset) {
return
@@ -24494,7 +24507,9 @@ export class OrcaRuntimeService {
} else if (preset === 'copilot') {
markCopilotFolderTrusted(workspacePath)
} else if (preset === 'codex') {
markCodexProjectTrusted(workspacePath)
// Why: the Codex write queues behind any in-flight hook grant, so the
// agent must not launch until it has actually landed.
await markCodexProjectTrusted(workspacePath)
}
} catch {
// Best-effort: the user can still accept the agent trust prompt manually.
@@ -25019,7 +25034,7 @@ export class OrcaRuntimeService {
try {
const startupTrustAgent = effectiveDraftPaste?.agent ?? effectiveCreatedWithAgent
if (startupTrustAgent) {
this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktree.path)
await this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktree.path)
}
const terminal = await this.createTerminal(`id:${worktree.id}`, {
command: effectiveStartup.command,
@@ -25812,7 +25827,7 @@ export class OrcaRuntimeService {
// session later, matching `orca terminal create` background semantics.
const startupTrustAgent = effectiveDraftPaste?.agent ?? effectiveCreatedWithAgent
if (startupTrustAgent) {
this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktreePath)
await this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktreePath)
}
const terminal = await this.createTerminal(`id:${worktree.id}`, {
command: sequencedStartup.command,
@@ -28298,11 +28313,7 @@ export class OrcaRuntimeService {
return opts
}
if (workspace.connectionId) {
await this.markRemoteWorkspaceTrustedForAgent(agent, workspace.connectionId, workspace.path)
} else {
this.markLocalWorkspaceTrustedForAgent(agent, workspace.path)
}
await this.markWorkspaceTrustedForAgent(agent, workspace.connectionId, workspace.path)
return {
...opts,
@@ -28436,15 +28447,7 @@ export class OrcaRuntimeService {
if (!startup) {
throw new Error('agent_session_identity_required')
}
if (workspace.connectionId) {
await this.markRemoteWorkspaceTrustedForAgent(
request.agent,
workspace.connectionId,
workspace.path
)
} else {
this.markLocalWorkspaceTrustedForAgent(request.agent, workspace.path)
}
await this.markWorkspaceTrustedForAgent(request.agent, workspace.connectionId, workspace.path)
if (_caller.signal?.aborted) {
throw new Error('client_disconnected')
}
@@ -28605,15 +28608,7 @@ export class OrcaRuntimeService {
if (!startup) {
throw new Error('agent_session_identity_required')
}
if (workspace.connectionId) {
await this.markRemoteWorkspaceTrustedForAgent(
request.agent,
workspace.connectionId,
workspace.path
)
} else {
this.markLocalWorkspaceTrustedForAgent(request.agent, workspace.path)
}
await this.markWorkspaceTrustedForAgent(request.agent, workspace.connectionId, workspace.path)
if (caller.signal?.aborted) {
throw new Error('client_disconnected')
}
@@ -29245,11 +29240,7 @@ export class OrcaRuntimeService {
throw new Error('Repository for the selected workspace is no longer available.')
}
const startup = this.buildStartupForAgent(repo, opts.agent, opts.prompt)
if (repo.connectionId) {
await this.markRemoteWorkspaceTrustedForAgent(opts.agent, repo.connectionId, worktree.path)
} else {
this.markLocalWorkspaceTrustedForAgent(opts.agent, worktree.path)
}
await this.markWorkspaceTrustedForAgent(opts.agent, repo.connectionId, worktree.path)
return await this.createTerminal(`id:${worktree.id}`, {
command: startup.startup.command,
env: startup.startup.env,
@@ -29638,15 +29629,7 @@ export class OrcaRuntimeService {
if (opts.agentPrompt && startupPlan.followupPrompt) {
throw new Error(`Agent ${opts.agent} does not support startup prompt quick commands.`)
}
if (workspace.connectionId) {
await this.markRemoteWorkspaceTrustedForAgent(
opts.agent,
workspace.connectionId,
workspace.path
)
} else {
this.markLocalWorkspaceTrustedForAgent(opts.agent, workspace.path)
}
await this.markWorkspaceTrustedForAgent(opts.agent, workspace.connectionId, workspace.path)
return {
command: startupPlan.launchCommand,
env: startupPlan.env,
@@ -4,7 +4,9 @@ import { readShellStartupEnvVar } from '../pty/shell-startup-env'
import { parseWslUncPath } from '../../shared/wsl-paths'
export type CommitMessageAgentEnvironmentResolvers = {
prepareForCodexLaunch?: (target?: CommitMessageAgentRuntimeTarget) => string | null
prepareForCodexLaunch?: (
target?: CommitMessageAgentRuntimeTarget
) => string | null | Promise<string | null>
prepareForClaudeLaunch?: (
target?: CommitMessageAgentRuntimeTarget
) => Promise<ClaudeRuntimeAuthPreparation>
@@ -100,7 +102,7 @@ export async function prepareLocalCommitMessageAgentEnv(
try {
if (agentId === 'codex' && resolvers.prepareForCodexLaunch) {
const codexHomePath = resolvers.prepareForCodexLaunch(target)
const codexHomePath = await resolvers.prepareForCodexLaunch(target)
const wslCodexHome = codexHomePath ? parseWslUncPath(codexHomePath) : null
if (target?.runtime === 'wsl') {
const codexHomeForTarget = wslCodexHome?.linuxPath ?? null
+128
View File
@@ -0,0 +1,128 @@
/**
* Optimistic capability probing with a bounded retry window and in-flight
* probe dedupe.
*
* Extracted from GitCapabilityCache so every host-capability cache in the tree
* gets the same three behaviors: probe once, remember only a positive absence
* signal, and let a concurrent caller wait on the probe already running rather
* than starting a duplicate one.
*/
export type CapabilityProbeOutcome = 'supported' | 'unsupported' | 'unknown'
export class CapabilityProbeCache<TCapability> {
private readonly retryAfterByCapability = new Map<TCapability, number>()
private readonly probesByCapability = new Map<TCapability, Promise<CapabilityProbeOutcome>>()
private readonly supportedCapabilities = new Set<TCapability>()
constructor(private readonly retryIntervalMs: number) {}
shouldTry(capability: TCapability, nowMs = Date.now()): boolean {
const retryAfterMs = this.retryAfterByCapability.get(capability)
if (retryAfterMs === undefined) {
return true
}
if (nowMs < retryAfterMs) {
return false
}
this.retryAfterByCapability.delete(capability)
return true
}
isKnownSupported(capability: TCapability): boolean {
return this.supportedCapabilities.has(capability)
}
rememberSupported(capability: TCapability): void {
this.retryAfterByCapability.delete(capability)
this.supportedCapabilities.add(capability)
}
rememberUnsupported(capability: TCapability, nowMs = Date.now()): void {
// Why: optimistic probes preserve newer behavior, but repeating a known
// failure on every poll/search wastes subprocesses and trace space.
this.supportedCapabilities.delete(capability)
this.retryAfterByCapability.set(capability, nowMs + this.retryIntervalMs)
}
async runWithFallback<T>(
capability: TCapability,
runPreferred: () => Promise<T>,
runFallback: () => Promise<T>,
isUnsupportedError: (error: unknown) => boolean
): Promise<T> {
if (this.supportedCapabilities.has(capability)) {
// Why: supported commands are real work, not disposable probes. Let
// sibling repo/SSH calls retain their intended concurrency.
return this.runPreferredOrFallback(capability, runPreferred, runFallback, isUnsupportedError)
}
if (!this.shouldTry(capability)) {
return runFallback()
}
const inFlightProbe = this.probesByCapability.get(capability)
if (inFlightProbe) {
const outcome = await inFlightProbe
if (outcome === 'unsupported' || !this.shouldTry(capability)) {
return runFallback()
}
return this.runPreferredOrFallback(capability, runPreferred, runFallback, isUnsupportedError)
}
let settleProbe!: (outcome: CapabilityProbeOutcome) => void
const probe = new Promise<CapabilityProbeOutcome>((resolve) => {
settleProbe = resolve
})
this.probesByCapability.set(capability, probe)
try {
return await this.runPreferredOrFallback(
capability,
runPreferred,
runFallback,
isUnsupportedError,
settleProbe
)
} finally {
if (this.probesByCapability.get(capability) === probe) {
this.probesByCapability.delete(capability)
}
// Backstop: `isUnsupportedError` or `rememberUnsupported` can throw
// before the settle below them runs; waiters must not hang behind it.
settleProbe('unknown')
}
}
clear(): void {
this.retryAfterByCapability.clear()
this.probesByCapability.clear()
this.supportedCapabilities.clear()
}
private async runPreferredOrFallback<T>(
capability: TCapability,
runPreferred: () => Promise<T>,
runFallback: () => Promise<T>,
isUnsupportedError: (error: unknown) => boolean,
settleProbe?: (outcome: CapabilityProbeOutcome) => void
): Promise<T> {
try {
const result = await runPreferred()
// A preferred callback can detect a weaker positive signal (old Git's
// exit-zero option echo) and remember it as unsupported, so do not
// overwrite that stronger signal.
const outcome = this.retryAfterByCapability.has(capability) ? 'unsupported' : 'supported'
if (outcome === 'supported') {
this.supportedCapabilities.add(capability)
}
settleProbe?.(outcome)
return result
} catch (error) {
if (!isUnsupportedError(error)) {
settleProbe?.('unknown')
throw error
}
this.rememberUnsupported(capability)
settleProbe?.('unsupported')
return runFallback()
}
}
}
@@ -44,10 +44,8 @@ src/main/claude-accounts/keychain.ts
src/main/codex-accounts/runtime-home-service.ts
src/main/codex-accounts/service.ts
src/main/codex/codex-app-server-client.ts
src/main/codex/codex-app-server-grant-bridge.ts
src/main/codex/codex-app-server-session.ts
src/main/codex/codex-state-db-backfill-recovery.ts
src/main/codex/codex-trust-grant-host.ts
src/main/codex/codex-wsl-hook-install-plan.ts
src/main/computer/desktop-script-provider-bridge.ts
src/main/computer/macos-computer-use-permission-status.ts
+5 -102
View File
@@ -1,3 +1,5 @@
import { CapabilityProbeCache } from './capability-probe-cache'
// Why: suppress hot-loop failures while still detecting an in-place Git
// upgrade during a long Orca session without requiring a restart.
export const GIT_CAPABILITY_RETRY_INTERVAL_MS = 30 * 60_000
@@ -10,107 +12,8 @@ export type GitCapability =
| 'rev-parse-path-format'
| 'worktree-list-z'
type GitCapabilityProbeOutcome = 'supported' | 'unsupported' | 'unknown'
export class GitCapabilityCache {
private readonly retryAfterByCapability = new Map<GitCapability, number>()
private readonly probesByCapability = new Map<GitCapability, Promise<GitCapabilityProbeOutcome>>()
private readonly supportedCapabilities = new Set<GitCapability>()
shouldTry(capability: GitCapability, nowMs = Date.now()): boolean {
const retryAfterMs = this.retryAfterByCapability.get(capability)
if (retryAfterMs === undefined) {
return true
}
if (nowMs < retryAfterMs) {
return false
}
this.retryAfterByCapability.delete(capability)
return true
}
rememberUnsupported(capability: GitCapability, nowMs = Date.now()): void {
// Why: optimistic probes preserve newer Git behavior, but repeating a
// known failure on every poll/search wastes subprocesses and trace space.
this.supportedCapabilities.delete(capability)
this.retryAfterByCapability.set(capability, nowMs + GIT_CAPABILITY_RETRY_INTERVAL_MS)
}
async runWithFallback<T>(
capability: GitCapability,
runPreferred: () => Promise<T>,
runFallback: () => Promise<T>,
isUnsupportedError: (error: unknown) => boolean
): Promise<T> {
if (this.supportedCapabilities.has(capability)) {
// Why: supported commands are real work, not disposable probes. Let
// sibling repo/SSH calls retain their intended concurrency.
return this.runPreferredOrFallback(capability, runPreferred, runFallback, isUnsupportedError)
}
if (!this.shouldTry(capability)) {
return runFallback()
}
const inFlightProbe = this.probesByCapability.get(capability)
if (inFlightProbe) {
const outcome = await inFlightProbe
if (outcome === 'unsupported' || !this.shouldTry(capability)) {
return runFallback()
}
return this.runPreferredOrFallback(capability, runPreferred, runFallback, isUnsupportedError)
}
let settleProbe!: (outcome: GitCapabilityProbeOutcome) => void
const probe = new Promise<GitCapabilityProbeOutcome>((resolve) => {
settleProbe = resolve
})
this.probesByCapability.set(capability, probe)
try {
return await this.runPreferredOrFallback(
capability,
runPreferred,
runFallback,
isUnsupportedError,
settleProbe
)
} finally {
if (this.probesByCapability.get(capability) === probe) {
this.probesByCapability.delete(capability)
}
}
}
clear(): void {
this.retryAfterByCapability.clear()
this.probesByCapability.clear()
this.supportedCapabilities.clear()
}
private async runPreferredOrFallback<T>(
capability: GitCapability,
runPreferred: () => Promise<T>,
runFallback: () => Promise<T>,
isUnsupportedError: (error: unknown) => boolean,
settleProbe?: (outcome: GitCapabilityProbeOutcome) => void
): Promise<T> {
try {
const result = await runPreferred()
// A preferred callback can detect old Git's exit-zero option echo and
// remember it as unsupported, so do not overwrite that stronger signal.
const outcome = this.retryAfterByCapability.has(capability) ? 'unsupported' : 'supported'
if (outcome === 'supported') {
this.supportedCapabilities.add(capability)
}
settleProbe?.(outcome)
return result
} catch (error) {
if (!isUnsupportedError(error)) {
settleProbe?.('unknown')
throw error
}
this.rememberUnsupported(capability)
settleProbe?.('unsupported')
return runFallback()
}
export class GitCapabilityCache extends CapabilityProbeCache<GitCapability> {
constructor() {
super(GIT_CAPABILITY_RETRY_INTERVAL_MS)
}
}