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