diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 52853883379..c9990fcf9b0 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -200,10 +200,19 @@ export class CodexRuntimeHomeService { return this.getRuntimeHomePath() } + // Why: the real-home hook installer flips this gate off when the trust-grant + // client reports the host incapable, keeping that host byte-identical to the + // managed lane instead of shipping status-blind panes. + private realHomeLaneGate: () => boolean = () => true + + setRealHomeLaneGate(gate: () => boolean): void { + this.realHomeLaneGate = gate + } + // Why: real-home routing applies only to the host system-default selection // (no managed account chosen for host) with the staged flag ON. Managed host // accounts keep the isolated runtime home for hot-swap and token persistence. - isHostSystemDefaultRealHome(): boolean { + isHostSystemDefaultRealHomeSelected(): boolean { const settings = this.store.getSettings() return ( isCodexSystemDefaultRealHomeEnabled(settings) && @@ -211,6 +220,10 @@ export class CodexRuntimeHomeService { ) } + isHostSystemDefaultRealHome(): boolean { + return this.isHostSystemDefaultRealHomeSelected() && this.realHomeLaneGate() + } + syncActiveWslSelectionsBeforeRestart(): void { if (process.platform !== 'win32') { return diff --git a/src/main/codex/codex-real-home-hook-install.test.ts b/src/main/codex/codex-real-home-hook-install.test.ts new file mode 100644 index 00000000000..f6f1f3a054d --- /dev/null +++ b/src/main/codex/codex-real-home-hook-install.test.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import type * as NodeOs from 'node:os' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { CodexManagedTrustGrantPlan } from './codex-hook-trust-grant' + +const { homedirMock, grantMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>(), + grantMock: vi.fn() +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { ...actual, homedir: homedirMock } +}) + +vi.mock('./codex-hook-trust-grant', () => ({ + grantManagedCodexHookTrust: grantMock +})) + +import { + ensureRealHomeCodexHookState, + getRealHomeCodexHookLane, + _internals +} from './codex-real-home-hook-install' +import { getCodexManagedHookInstallMaterial } from './hook-service' + +let fakeHomeDir: string +let userDataDir: string +let previousUserDataPath: string | undefined + +function getRealHooksJsonPath(): string { + return join(fakeHomeDir, '.codex', 'hooks.json') +} + +function readRealHooksJson(): { + hooks?: Record + [key: string]: unknown +} { + return JSON.parse(readFileSync(getRealHooksJsonPath(), 'utf-8')) +} + +function grantSucceeds(): void { + grantMock.mockImplementation((plan: CodexManagedTrustGrantPlan) => ({ + lane: 'rpc', + entries: plan.managedEntries.map((entry) => ({ ...entry, trustedHash: 'codex-hash' })) + })) +} + +function grantUnavailable(): void { + grantMock.mockReturnValue({ lane: 'fallback', reason: 'unsupported' }) +} + +beforeEach(() => { + grantMock.mockReset() + fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-real-home-hooks-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-real-home-hooks-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(fakeHomeDir) + mkdirSync(join(fakeHomeDir, '.codex'), { recursive: true }) + _internals.setLaneForTesting('pending') +}) + +afterEach(() => { + rmSync(fakeHomeDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +describe('ensureRealHomeCodexHookState (install)', () => { + it('creates hooks.json with the Orca entry in every managed event for a fresh home', () => { + grantSucceeds() + + const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + + expect(lane).toBe('installed') + const material = getCodexManagedHookInstallMaterial() + const config = readRealHooksJson() + for (const eventName of material.events) { + const definitions = config.hooks?.[eventName] + expect(definitions).toHaveLength(1) + expect(definitions?.[0]?.hooks?.[0]?.command).toBe(material.command) + } + // The grant plan targeted the real home with append-position trust keys. + const plan = grantMock.mock.calls[0]![0] as CodexManagedTrustGrantPlan + expect(plan.runtimeHomePath).toBe(join(fakeHomeDir, '.codex')) + expect(plan.host).toEqual({ kind: 'native' }) + expect(plan.managedEntries.every((entry) => entry.groupIndex === 0)).toBe(true) + }) + + it('appends LAST and preserves user entries, unknown fields, and trust positions', () => { + grantSucceeds() + const userConfig = { + hooks: { + Stop: [{ matcher: 'deploy-*', hooks: [{ type: 'command', command: 'my-stop-hook.sh' }] }], + PreCompact: [{ hooks: [{ type: 'command', command: 'my-compact-hook.sh' }] }] + }, + _pluginManagerMetadata: { owner: 'someone-else' } + } + writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(userConfig, null, 2)}\n`, 'utf-8') + + const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + + expect(lane).toBe('installed') + const config = readRealHooksJson() + // User's Stop entry keeps position 0; Orca's entry is appended after it. + expect(config.hooks?.Stop).toHaveLength(2) + expect(config.hooks?.Stop?.[0]).toEqual(userConfig.hooks.Stop[0]) + // Non-managed events Orca does not subscribe to stay untouched. + expect(config.hooks?.PreCompact).toEqual(userConfig.hooks.PreCompact) + // Unknown top-level metadata survives, unlike the managed-home writer. + expect(config._pluginManagerMetadata).toEqual(userConfig._pluginManagerMetadata) + // The appended entry's trust key uses its appended position. + const plan = grantMock.mock.calls[0]![0] as CodexManagedTrustGrantPlan + const stopEntry = plan.managedEntries.find((entry) => entry.eventLabel === 'stop') + expect(stopEntry?.groupIndex).toBe(1) + // Pristine pre-Orca backup lands under userData, not in ~/.codex. + expect( + readFileSync(join(userDataDir, 'codex-real-home-hooks', 'hooks.json.pre-orca'), 'utf-8') + ).toBe(`${JSON.stringify(userConfig, null, 2)}\n`) + }) + + it('rolls the file back byte-exactly when the grant lane is unavailable', () => { + grantUnavailable() + const userRaw = `${JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'mine.sh' }] }] } }, null, 2)}\n` + writeFileSync(getRealHooksJsonPath(), userRaw, 'utf-8') + + const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + + expect(lane).toBe('unavailable') + expect(getRealHomeCodexHookLane()).toBe('unavailable') + expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(userRaw) + }) + + it('removes a freshly created hooks.json when the grant lane is unavailable', () => { + grantUnavailable() + + const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + + expect(lane).toBe('unavailable') + expect(existsSync(getRealHooksJsonPath())).toBe(false) + }) + + it('leaves an unparseable hooks.json untouched and keeps the managed lane', () => { + writeFileSync(getRealHooksJsonPath(), '{not json', 'utf-8') + + const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + + expect(lane).toBe('unavailable') + expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe('{not json') + expect(grantMock).not.toHaveBeenCalled() + }) + + it('is idempotent: a second ensure keeps a single appended entry per event', () => { + grantSucceeds() + ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + const firstRaw = readFileSync(getRealHooksJsonPath(), 'utf-8') + + const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + + expect(lane).toBe('installed') + expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(firstRaw) + }) +}) + +describe('ensureRealHomeCodexHookState (opt-out sweep)', () => { + it('removes only Orca entries and reports the removed lane', () => { + grantSucceeds() + const userStop = { + matcher: 'deploy-*', + hooks: [{ type: 'command', command: 'my-stop-hook.sh' }] + } + writeFileSync( + getRealHooksJsonPath(), + `${JSON.stringify({ hooks: { Stop: [userStop] } }, null, 2)}\n`, + 'utf-8' + ) + ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + expect(readRealHooksJson().hooks?.Stop).toHaveLength(2) + + const lane = ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir }) + + expect(lane).toBe('removed') + const config = readRealHooksJson() + expect(config.hooks?.Stop).toEqual([userStop]) + const material = getCodexManagedHookInstallMaterial() + for (const eventName of material.events) { + if (eventName === 'Stop') { + continue + } + expect(config.hooks?.[eventName]).toBeUndefined() + } + }) + + it('no-ops the sweep when the real home has no hooks.json', () => { + const lane = ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir }) + + expect(lane).toBe('removed') + expect(existsSync(getRealHooksJsonPath())).toBe(false) + }) +}) diff --git a/src/main/codex/codex-real-home-hook-install.ts b/src/main/codex/codex-real-home-hook-install.ts new file mode 100644 index 00000000000..9dbec577037 --- /dev/null +++ b/src/main/codex/codex-real-home-hook-install.ts @@ -0,0 +1,275 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { join } from 'node:path' +import { + buildManagedCommandHook, + createManagedCommandMatcher, + hookDefinitionHasManagedCommand, + MANAGED_HOOK_TIMEOUT_SECONDS, + readHooksJson, + removeManagedCommands, + writeHooksJson, + writeManagedScript, + type HookDefinition, + type HooksConfig +} from '../agent-hooks/installer-utils' +import { getSystemCodexHomePath } from './codex-home-paths' +import { getCodexManagedScriptFileName } from './codex-hook-identity' +import { grantManagedCodexHookTrust } from './codex-hook-trust-grant' +import { removeCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger' +import { getCodexManagedHookInstallMaterial } from './hook-service' +import { computeTrustKey, removeHookTrustEntries, type CodexTrustEntry } from './config-toml-trust' + +/** + * Real-home Codex hook lane for the system-default selection (flag ON). + * + * - 'pending': no attempt yet this process; routing may optimistically use the + * real home (reads are hook-free and the install runs before pane spawns). + * - 'installed': entry appended LAST in ~/.codex/hooks.json and trusted by + * codex itself through the app-server grant client. + * - 'unavailable': the grant lane could not trust the entry (old binary, + * unsupported RPC, verify failure). The entry is rolled back and the host + * stays on the managed-home lane. + * - 'removed': hooks are opted out; Orca entries are swept from the real home. + */ +export type RealHomeCodexHookLane = 'pending' | 'installed' | 'unavailable' | 'removed' + +let currentLane: RealHomeCodexHookLane = 'pending' + +export function getRealHomeCodexHookLane(): RealHomeCodexHookLane { + return currentLane +} + +/** + * Routing gate consumed by CodexRuntimeHomeService: the real home is usable + * whenever hooks are opted out (nothing to install) or the grant lane has not + * proven incapable on this host. + */ +export function isRealHomeCodexHookLaneUsable(hooksEnabled: boolean): boolean { + return !hooksEnabled || currentLane !== 'unavailable' +} + +function getRealHomeHooksJsonPath(): string { + return join(getSystemCodexHomePath(), 'hooks.json') +} + +function getRealHomeConfigTomlPath(): string { + return join(getSystemCodexHomePath(), 'config.toml') +} + +/** Orca-side state dir; nothing extra is ever written into the user's ~/.codex. */ +function getRealHomeHookStateDir(userDataPath: string): string { + return join(userDataPath, 'codex-real-home-hooks') +} + +/** + * Ensures the real-home hook state matches the settings: installs and trusts + * the Orca status hook when enabled, sweeps it when opted out. Idempotent and + * synchronous (launch prep); repeat calls are cheap — an unchanged hooks.json + * write no-ops and a valid grant ledger skips the RPC session entirely. + * Never throws: any failure logs and leaves the host on the managed lane. + */ +export function ensureRealHomeCodexHookState(args: { + hooksEnabled: boolean + userDataPath: string +}): RealHomeCodexHookLane { + try { + currentLane = args.hooksEnabled + ? installRealHomeCodexHook(args.userDataPath) + : sweepRealHomeCodexHook() + } catch (error) { + console.warn('[codex-real-home-hooks] ensure failed; staying on managed lane:', error) + currentLane = args.hooksEnabled ? 'unavailable' : currentLane + } + return currentLane +} + +function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane { + const material = getCodexManagedHookInstallMaterial() + const hooksJsonPath = getRealHomeHooksJsonPath() + const config = readHooksJson(hooksJsonPath) + if (!config) { + // Why: an unparseable user file must never be clobbered; without a hook + // entry the managed lane keeps status working for this host. + console.warn('[codex-real-home-hooks] could not parse', hooksJsonPath, '- managed lane kept') + return 'unavailable' + } + + // Why: the same script the managed lane maintains; deploying here too keeps + // host-connect ordering independent of the managed installer loop. + writeManagedScript(material.scriptPath, material.script) + + const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) + const nextHooks: Record = { ...config.hooks } + const managedEntries: CodexTrustEntry[] = [] + for (const eventName of material.events) { + const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + // Why: append LAST. Codex trust keys are positional + // (source:event:group:handler); prepending would shift every user entry + // and invalidate the user's own hook trust records. + nextHooks[eventName] = [...cleaned, { hooks: [buildManagedCommandHook(material.command)] }] + managedEntries.push({ + sourcePath: hooksJsonPath, + eventLabel: material.eventLabel[eventName], + groupIndex: cleaned.length, + handlerIndex: 0, + command: material.command, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + }) + } + // Why: sweep stale Orca entries out of events the managed lane no longer + // subscribes to, mirroring the managed installer's upgrade behavior. + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if ((material.events as readonly string[]).includes(eventName) || !Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + const previousRaw = existsSync(hooksJsonPath) ? readFileSync(hooksJsonPath, 'utf-8') : null + backupRealHomeHooksJsonOnce(userDataPath, hooksJsonPath, previousRaw) + // Why: unknown top-level fields belong to the user (other managers' + // metadata); unlike the managed-home writer, preserve them verbatim. + writeHooksJson(hooksJsonPath, { ...config, hooks: nextHooks } as HooksConfig) + + const grant = grantManagedCodexHookTrust({ + runtimeHomePath: getSystemCodexHomePath(), + tomlPath: getRealHomeConfigTomlPath(), + managedCommand: material.command, + managedEntries, + host: { kind: 'native' } + }) + if (grant.lane === 'rpc') { + return 'installed' + } + + // Why: never leave an untrusted Orca entry in the user's real home — it + // would surface as "Hooks need review". Roll the file back to its prior + // bytes and keep this host on the managed-home lane; the grant client + // already logged the fallback reason. + restoreRealHomeHooksJson(hooksJsonPath, previousRaw) + console.warn( + `[codex-real-home-hooks] trust grant unavailable (${grant.reason}); entry rolled back, managed lane kept` + ) + return 'unavailable' +} + +function sweepRealHomeCodexHook(): RealHomeCodexHookLane { + const hooksJsonPath = getRealHomeHooksJsonPath() + const config = readHooksJson(hooksJsonPath) + if (!config?.hooks) { + return 'removed' + } + const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) + const material = getCodexManagedHookInstallMaterial() + const nextHooks: Record = { ...config.hooks } + const removedTrustKeys: string[] = [] + let removedAny = false + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + continue + } + definitions.forEach((definition, groupIndex) => { + if (!hookDefinitionHasManagedCommand(definition, isManagedCommand)) { + return + } + const eventLabel = material.eventLabel[eventName as (typeof material.events)[number]] + if (!eventLabel) { + return + } + removedTrustKeys.push( + computeTrustKey({ + sourcePath: hooksJsonPath, + eventLabel, + groupIndex, + handlerIndex: 0, + command: material.command, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + }) + ) + }) + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length !== definitions.length) { + removedAny = true + } + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + if (removedAny) { + writeHooksJson(hooksJsonPath, { ...config, hooks: nextHooks } as HooksConfig) + // Why: dead [hooks.state] blocks for a removed hook are Orca-owned records; + // dropping them keeps the user's config.toml from accumulating orphans. + // Their removal never shifts user trust keys because Orca appends last. + try { + removeHookTrustEntries(getRealHomeConfigTomlPath(), removedTrustKeys) + } catch (error) { + console.warn('[codex-real-home-hooks] failed to drop Orca trust entries:', error) + } + try { + removeCodexTrustGrantLedgerHome(getSystemCodexHomePath()) + } catch { + // Ledger cleanup is bookkeeping only; the next grant rebuilds it. + } + } + return 'removed' +} + +/** One-time pristine copy of the user's file, kept under Orca's userData. */ +function backupRealHomeHooksJsonOnce( + userDataPath: string, + hooksJsonPath: string, + previousRaw: string | null +): void { + if (previousRaw === null) { + return + } + try { + const backupDir = getRealHomeHookStateDir(userDataPath) + const backupPath = join(backupDir, 'hooks.json.pre-orca') + if (existsSync(backupPath)) { + return + } + mkdirSync(backupDir, { recursive: true }) + copyFileSync(hooksJsonPath, backupPath) + } catch (error) { + console.warn('[codex-real-home-hooks] failed to write pristine backup:', error) + } +} + +function restoreRealHomeHooksJson(hooksJsonPath: string, previousRaw: string | null): void { + try { + if (previousRaw === null) { + if (existsSync(hooksJsonPath)) { + unlinkSync(hooksJsonPath) + } + return + } + const tmpPath = `${hooksJsonPath}.${process.pid}.rollback.tmp` + writeFileSync(tmpPath, previousRaw, 'utf-8') + renameSync(tmpPath, hooksJsonPath) + } catch (error) { + console.warn('[codex-real-home-hooks] failed to roll back hooks.json:', error) + } +} + +export const _internals = { + setLaneForTesting(lane: RealHomeCodexHookLane): void { + currentLane = lane + } +} diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 1011d595ac3..0e183c8d653 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -142,6 +142,38 @@ function getManagedCommand(scriptPath: string): string { : 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 } @@ -539,6 +571,9 @@ function dedupeHookDefinitions(definitions: readonly HookDefinition[]): HookDefi } function cleanupLegacySystemManagedHooks(): void { + if (systemCodexHomeHookSweepSuppressed()) { + return + } const legacyConfigPath = getSystemConfigPath() const runtimeConfigPath = getConfigPath() if (legacyConfigPath === runtimeConfigPath) { diff --git a/src/main/index.ts b/src/main/index.ts index 50a6b48aed8..2e4317ce8ed 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -128,7 +128,11 @@ import { type CodexAccountSelectionTarget } from './codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' -import { codexHookService } from './codex/hook-service' +import { codexHookService, setSystemCodexHomeHookSweepSuppressed } from './codex/hook-service' +import { + ensureRealHomeCodexHookState, + isRealHomeCodexHookLaneUsable +} from './codex/codex-real-home-hook-install' import { setCodexTrustGrantTelemetry } from './codex/codex-hook-trust-grant' import { getDefaultWslDistro } from './wsl' import { ClaudeAccountService } from './claude-accounts/service' @@ -734,12 +738,20 @@ function startTerminalRuntimeStartupServices(): Promise { } function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null { + if (target?.runtime !== 'wsl' && codexRuntimeHome!.isHostSystemDefaultRealHomeSelected()) { + // Why (flag ON, system default): the hook entry must exist — appended last + // and trusted by codex's own app-server grant — in the real ~/.codex before + // the pane spawns. An incapable grant flips the lane gate so the launch + // below falls back to the managed home instead of a status-blind pane. + ensureRealHomeCodexHookState({ + hooksEnabled: isAgentStatusHooksEnabled(store?.getSettings()), + userDataPath: app.getPath('userData') + }) + } const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target) if (runtimeHomePath === null && codexRuntimeHome!.isHostSystemDefaultRealHome()) { - // Why (flag ON, system default): Codex runs on the user's real ~/.codex, so - // the managed-home hook install below would target a home Codex never reads. - // The real-home hook installer (trust granted via the app-server client) - // owns hook install for this lane and lands with the trust plumbing. + // Why: Codex runs on the user's real ~/.codex; the managed-home hook + // install below would target a home Codex never reads on this lane. return null } const hookTarget = @@ -1766,6 +1778,21 @@ app.whenReady().then(async () => { openCodeUsage = new OpenCodeUsageStore(store) rateLimits = new RateLimitService() codexRuntimeHome = new CodexRuntimeHomeService(store) + // Why: an incapable trust-grant host must fall back to the managed home for + // every consumer (PTY env, rate limits, commit messages) in one place. + codexRuntimeHome.setRealHomeLaneGate(() => + isRealHomeCodexHookLaneUsable(isAgentStatusHooksEnabled(store?.getSettings())) + ) + // Why: while the real-home lane owns ~/.codex/hooks.json, the legacy + // system-home sweep inside managed installs would delete the entry the + // real-home installer just appended. Flag OFF or hooks off re-arms the sweep, + // which is also what removes the entry again on downgrade or opt-out. + setSystemCodexHomeHookSweepSuppressed( + () => + codexRuntimeHome !== null && + codexRuntimeHome.isHostSystemDefaultRealHomeSelected() && + isAgentStatusHooksEnabled(store?.getSettings()) + ) codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome) claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) @@ -1992,6 +2019,15 @@ app.whenReady().then(async () => { removeManagedAgentHooks() } } + if (codexRuntimeHome.isHostSystemDefaultRealHomeSelected()) { + // Why: host-connect seam — install (or sweep, when opted out) the trusted + // real-home status hook before the first pane can spawn, so the very first + // launch already knows whether this host's grant lane is usable. + ensureRealHomeCodexHookState({ + hooksEnabled: isAgentStatusHooksEnabled(store.getSettings()), + userDataPath: app.getPath('userData') + }) + } app.on('child-process-gone', (_event, details) => { recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, {