mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
feat(codex): real-home hook installer trusted via the codex app-server grant client
With the real-home flag ON and the system-default selection, install Orca's status hook into the user's real ~/.codex before any pane spawns: - entry APPENDED LAST per managed event: codex hook trust keys are positional (source:event:group:handler), so appending keeps every user entry's position and trust record intact; user entries and unknown top-level hooks.json fields are preserved verbatim - trust is granted exclusively through the codex app-server client (hooks/list + config/batchWrite, verified by re-list); Orca never writes [hooks.state] into the user's real config.toml itself - if the grant lane is unavailable (old binary, unsupported RPC, verify failure), the appended entry is rolled back byte-exactly and the host keeps the managed-home lane end to end (PTY env, rate limits, commit messages) via a lane gate on the runtime-home service - one-time pristine backup of the user's hooks.json under Orca's userData; a rolling .bak sits next to the file (existing atomic writer) - hook opt-out sweeps Orca entries from the real home and drops Orca-owned trust records; flag-off downgrade re-arms the existing legacy system-home sweep, which removes the entry and its trust keys cleanly - the legacy system-home sweep is suppressed only while the real-home lane owns ~/.codex/hooks.json, so managed installs cannot delete the entry
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<typeof NodeOs>('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<string, { hooks?: { command?: string }[] }[]>
|
||||
[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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, HookDefinition[]> = { ...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<string, HookDefinition[]> = { ...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
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
+41
-5
@@ -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<void> {
|
||||
}
|
||||
|
||||
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, {
|
||||
|
||||
Reference in New Issue
Block a user