mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 08:02:28 +00:00
* fix(codex): stop blocking the main thread on trust grants (#16441) Codex hook trust was granted by blocking the Electron main thread on `spawnSync` of a bundled ELECTRON_RUN_AS_NODE entry for the whole app-server deadline: 15s native, 35s WSL, ~45s on the real-home path (rebase inspect + repair + grant). Cold start and every Codex pane launch showed "Not Responding"; the reported event-loop gap was 15,049 ms. The subprocess only ever existed to donate an event loop to a deliberately blocked parent — `runCodexHookTrustGrantSession` was already the real async implementation. Make the callers async and the fork is unnecessary, so the bridge, the forked entry and its envelope are deleted along with their build/knip/tsconfig registrations. The CLI `agent hooks prepare-codex` handler is already async, so it awaits the in-process session and saves a process spawn per managed-home shell. `resolveCodexTrustGrantHost` is async too; the WSL identity probe moves from `execFileSync` to `runProcess`, dropping that file from the child-process import allowlist. Status reads keep a synchronous native-only stamp path. Two invariants that held only because the lane blocked: - Overlapping capability probes were impossible by construction. `GitCapabilityCache`'s dedupe engine is extracted to a shared `CapabilityProbeCache` and `CodexAppServerCapabilityCache` now inherits it, so concurrent launches against a cold host share one app-server session instead of one each. - Two grants on one `config.toml` could not interleave capture and restore. A reentrant per-file lane now serializes the whole install sequence (managed, WSL runtime, real-home ensure, legacy sweep) and the grant and rebase inside it. Cold-start work moves off the critical path: retained-home reconciliation (N sequential sessions) is fire-and-forget behind the daemon provider, and the startup real-home ensure chains into managed hook reconciliation instead of blocking app init. Every preserved semantic is unchanged: never throws, the ORCA_DISABLE_CODEX_TRUST_RPC kill switch, ledger hits, backfill-pending and cooldown fallbacks, config rollback on every failure path, pre-grant self-computed trust removal, the verify-failure taxonomy, diagnostics and telemetry. * fix(codex): widen the trust-config lane to every config.toml writer Review follow-ups on #16441's async trust grant: - `markCodexProjectTrusted` now runs inside the runtime+system config.toml lanes, so a project-trust write can no longer land inside a hook grant's capture->restore window and be silently reverted. Its callers await it. - `install`/`refreshRuntimeUserHooks`/`remove` hold the system config.toml lane as well as the runtime one — they promote approvals into ~/.codex/config.toml and mirror it back. Lock order is runtime-before-system everywhere. - The real-home ensure chain resumes after a rejection instead of returning the same rejected promise to every later pane launch, and resolving the real home is now inside the module's never-throws boundary. - `buildSpawnEnv` awaits inside a cancelable pending-spawn registration, so shutdown during the (now long) env build stops the PTY from launching. `prepareLocalPtySpawn` generalizes into `awaitCancelableLocalPtySpawn`. - CapabilityProbeCache drops the test-only `nowMs` passthrough; its probe backstop comment now describes what it actually guards. - Preflight is a plain async function; the trust dispatch in orca-runtime collapses into one `markWorkspaceTrustedForAgent`. * test(codex): exercise the trust-config lane under real concurrency The async grant makes two pane launches overlap for the first time. These drive the real modules end to end on real files: a rollback swallowing a sibling's grant, a markCodexProjectTrusted write landing inside a capture -> restore window, shared capability-probe dedupe on a cold host, the host-scoped transient cooldown, and reentrancy from inside an installer. Each was verified to fail against a deliberately broken implementation (lane removed, dedupe disabled, cooldown made global, reentrancy pass- through disabled). * test(codex): stop hook-service suites spawning the developer's real codex The forked grant bundle never existed under vitest, so the RPC lane was unreachable in tests on main. Running it in-process makes these suites spawn a real `codex app-server` when one is installed: 38 spawns and two failures in hook-service-runtime-trust-repair on a machine with codex, green in CI where there is none. Stand in for the missing binary so both environments exercise the same fallback lane. * docs(codex): scope the trust-RPC kill switch comment to what it actually gates The comment read as though the flag forces the fallback lane everywhere. It gates the managed grant only: the real-home rebase still runs its own inspect/repair app-server sessions when Orca's insertion shifts a user's hook positions, and never reads the flag. Verified by exercise, not by reading — with the flag set, both inspect-user-hook-trust and repair-user-hook-trust still ran. Pre-existing: main has no check there either, it just blocked the main thread while doing it. Widening the flag to cover the rebase is a follow-up; this only stops the comment promising something the constant does not do.
603 lines
22 KiB
TypeScript
603 lines
22 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
statSync,
|
|
symlinkSync,
|
|
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'
|
|
import {
|
|
computeTrustKey,
|
|
readHookTrustEntries,
|
|
upsertHookTrustEntriesInContent,
|
|
type CodexTrustEntry
|
|
} from './config-toml-trust'
|
|
|
|
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', () => ({
|
|
CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS: 300_000,
|
|
grantManagedCodexHookTrust: grantMock
|
|
}))
|
|
|
|
import {
|
|
ensureRealHomeCodexHookState,
|
|
getRealHomeCodexHookLane,
|
|
_internals
|
|
} from './codex-real-home-hook-install'
|
|
import { getCodexManagedHookInstallMaterial } from './hook-service'
|
|
import { _internals as rebaseInternals } from './codex-user-hook-trust-rebase'
|
|
|
|
let fakeHomeDir: string
|
|
let userDataDir: string
|
|
let previousUserDataPath: string | undefined
|
|
|
|
function getRealHooksJsonPath(): string {
|
|
return join(fakeHomeDir, '.codex', 'hooks.json')
|
|
}
|
|
|
|
function getRealConfigTomlPath(): string {
|
|
return join(fakeHomeDir, '.codex', 'config.toml')
|
|
}
|
|
|
|
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(() => {
|
|
rebaseInternals.setSessionRunner(null)
|
|
rebaseInternals.resetRetryState()
|
|
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)', () => {
|
|
// Why (#16441): the ensure chain is process-wide; a rejection that escapes it
|
|
// would return the same rejected promise to every later pane launch, with no
|
|
// retry and no cooldown recovery.
|
|
it('recovers from a home-resolution failure instead of poisoning later ensures', async () => {
|
|
grantSucceeds()
|
|
homedirMock.mockImplementationOnce(() => {
|
|
throw new Error('home unavailable')
|
|
})
|
|
|
|
await expect(
|
|
ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
|
).resolves.toBe('unavailable')
|
|
await expect(
|
|
ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })
|
|
).resolves.toBe('removed')
|
|
})
|
|
|
|
it('creates hooks.json with the Orca entry in every managed event for a fresh home', async () => {
|
|
grantSucceeds()
|
|
|
|
const lane = await 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.useDefaultCodexHome).toBe(true)
|
|
expect(plan.managedEntries.every((entry) => entry.groupIndex === 0)).toBe(true)
|
|
})
|
|
|
|
it('keeps a symlinked default home logical in the keys sent to Codex', async () => {
|
|
grantSucceeds()
|
|
const logicalHome = join(fakeHomeDir, '.codex')
|
|
const targetHome = join(fakeHomeDir, 'dotfiles-codex')
|
|
rmSync(logicalHome, { recursive: true })
|
|
mkdirSync(targetHome)
|
|
symlinkSync(targetHome, logicalHome, process.platform === 'win32' ? 'junction' : 'dir')
|
|
|
|
expect(
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
|
).toBe('installed')
|
|
|
|
const plan = grantMock.mock.calls[0]![0] as CodexManagedTrustGrantPlan
|
|
expect(
|
|
plan.managedEntries.map(computeTrustKey).every((key) => key.startsWith(logicalHome))
|
|
).toBe(true)
|
|
})
|
|
|
|
it('keeps the managed lane for unknown top-level fields Codex cannot load', async () => {
|
|
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' }
|
|
}
|
|
const original = `${JSON.stringify(userConfig, null, 2)}\n`
|
|
writeFileSync(getRealHooksJsonPath(), original, 'utf-8')
|
|
|
|
const lane = await ensureRealHomeCodexHookState({
|
|
hooksEnabled: true,
|
|
userDataPath: userDataDir
|
|
})
|
|
|
|
expect(lane).toBe('unavailable')
|
|
expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(original)
|
|
expect(grantMock).not.toHaveBeenCalled()
|
|
expect(existsSync(join(userDataDir, 'codex-real-home-hooks', 'hooks.json.pre-orca'))).toBe(
|
|
false
|
|
)
|
|
})
|
|
|
|
it('appends LAST and preserves user entries and trust positions', async () => {
|
|
grantSucceeds()
|
|
const userConfig = {
|
|
hooks: {
|
|
Stop: [{ matcher: 'deploy-*', hooks: [{ type: 'command', command: 'my-stop-hook.sh' }] }],
|
|
PreCompact: [{ hooks: [{ type: 'command', command: 'my-compact-hook.sh' }] }]
|
|
}
|
|
}
|
|
const original = `${JSON.stringify(userConfig, null, 2)}\n`
|
|
writeFileSync(getRealHooksJsonPath(), original, 'utf-8')
|
|
|
|
expect(
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
|
).toBe('installed')
|
|
|
|
const config = readRealHooksJson()
|
|
expect(config.hooks?.Stop).toHaveLength(2)
|
|
expect(config.hooks?.Stop?.[0]).toEqual(userConfig.hooks.Stop[0])
|
|
expect(config.hooks?.PreCompact).toEqual(userConfig.hooks.PreCompact)
|
|
const plan = grantMock.mock.calls[0]![0] as CodexManagedTrustGrantPlan
|
|
expect(plan.managedEntries.find((entry) => entry.eventLabel === 'stop')?.groupIndex).toBe(1)
|
|
expect(
|
|
readFileSync(join(userDataDir, 'codex-real-home-hooks', 'hooks.json.pre-orca'), 'utf-8')
|
|
).toBe(original)
|
|
})
|
|
|
|
// Why: ordinary Windows CI tokens cannot create file symlinks without Developer Mode.
|
|
it.skipIf(process.platform === 'win32')(
|
|
'updates a symlinked hooks.json target without replacing the symlink',
|
|
async () => {
|
|
grantSucceeds()
|
|
const dotfilesDir = join(fakeHomeDir, 'dotfiles')
|
|
const targetPath = join(dotfilesDir, 'hooks.json')
|
|
mkdirSync(dotfilesDir, { recursive: true })
|
|
writeFileSync(
|
|
targetPath,
|
|
`${JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'mine.sh' }] }] } }, null, 2)}\n`,
|
|
'utf-8'
|
|
)
|
|
symlinkSync(targetPath, getRealHooksJsonPath())
|
|
|
|
expect(
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
|
).toBe('installed')
|
|
|
|
expect(lstatSync(getRealHooksJsonPath()).isSymbolicLink()).toBe(true)
|
|
expect(JSON.parse(readFileSync(targetPath, 'utf-8')).hooks.Stop).toHaveLength(2)
|
|
}
|
|
)
|
|
|
|
it('keeps the managed lane and original bytes when the pristine backup cannot be created', async () => {
|
|
grantSucceeds()
|
|
const original = `${JSON.stringify({ hooks: { Stop: [] } }, null, 2)}\n`
|
|
writeFileSync(getRealHooksJsonPath(), original, 'utf-8')
|
|
writeFileSync(join(userDataDir, 'codex-real-home-hooks'), 'blocks backup directory', 'utf-8')
|
|
|
|
expect(
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
|
).toBe('unavailable')
|
|
|
|
expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(original)
|
|
expect(grantMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it.skipIf(process.platform === 'win32')(
|
|
'preserves restrictive hooks.json permissions',
|
|
async () => {
|
|
grantSucceeds()
|
|
writeFileSync(getRealHooksJsonPath(), '{ "hooks": {} }\n', 'utf-8')
|
|
chmodSync(getRealHooksJsonPath(), 0o600)
|
|
|
|
expect(
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
|
).toBe('installed')
|
|
|
|
expect(statSync(getRealHooksJsonPath()).mode & 0o777).toBe(0o600)
|
|
}
|
|
)
|
|
|
|
it.skipIf(process.platform === 'win32')(
|
|
'restores restrictive hooks.json permissions after grant fallback',
|
|
async () => {
|
|
grantUnavailable()
|
|
writeFileSync(getRealHooksJsonPath(), '{ "hooks": {} }\n', 'utf-8')
|
|
chmodSync(getRealHooksJsonPath(), 0o600)
|
|
|
|
expect(
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
|
).toBe('unavailable')
|
|
|
|
expect(statSync(getRealHooksJsonPath()).mode & 0o777).toBe(0o600)
|
|
}
|
|
)
|
|
|
|
it('rolls the file back byte-exactly when the grant lane is unavailable', async () => {
|
|
grantUnavailable()
|
|
const userRaw = `${JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'mine.sh' }] }] } }, null, 2)}\n`
|
|
writeFileSync(getRealHooksJsonPath(), userRaw, 'utf-8')
|
|
|
|
const lane = await ensureRealHomeCodexHookState({
|
|
hooksEnabled: true,
|
|
userDataPath: userDataDir
|
|
})
|
|
|
|
expect(lane).toBe('unavailable')
|
|
expect(getRealHomeCodexHookLane()).toBe('unavailable')
|
|
expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(userRaw)
|
|
})
|
|
|
|
it('removes a freshly created hooks.json when the grant lane is unavailable', async () => {
|
|
grantUnavailable()
|
|
|
|
const lane = await ensureRealHomeCodexHookState({
|
|
hooksEnabled: true,
|
|
userDataPath: userDataDir
|
|
})
|
|
|
|
expect(lane).toBe('unavailable')
|
|
expect(existsSync(getRealHooksJsonPath())).toBe(false)
|
|
})
|
|
|
|
it('surfaces rollback failures to the retry boundary', async () => {
|
|
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
grantMock.mockImplementation(() => {
|
|
rmSync(getRealHooksJsonPath())
|
|
mkdirSync(getRealHooksJsonPath())
|
|
return { lane: 'fallback', reason: 'unsupported' }
|
|
})
|
|
|
|
expect(
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
|
).toBe('unavailable')
|
|
|
|
expect(warning).toHaveBeenCalledWith(
|
|
'[codex-real-home-hooks] ensure failed; staying on managed lane:',
|
|
expect.any(Error)
|
|
)
|
|
})
|
|
|
|
it('does no hook-file or grant work on repeated unsupported launches', async () => {
|
|
grantUnavailable()
|
|
expect(
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
|
).toBe('unavailable')
|
|
expect(existsSync(getRealHooksJsonPath())).toBe(false)
|
|
|
|
expect(
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
|
).toBe('unavailable')
|
|
|
|
expect(grantMock).toHaveBeenCalledTimes(1)
|
|
expect(existsSync(getRealHooksJsonPath())).toBe(false)
|
|
})
|
|
|
|
it('leaves an unparseable hooks.json untouched and keeps the managed lane', async () => {
|
|
writeFileSync(getRealHooksJsonPath(), '{not json', 'utf-8')
|
|
|
|
const lane = await ensureRealHomeCodexHookState({
|
|
hooksEnabled: true,
|
|
userDataPath: userDataDir
|
|
})
|
|
|
|
expect(lane).toBe('unavailable')
|
|
expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe('{not json')
|
|
expect(grantMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('is idempotent: a second ensure keeps a single appended entry per event', async () => {
|
|
grantSucceeds()
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
|
const firstRaw = readFileSync(getRealHooksJsonPath(), 'utf-8')
|
|
|
|
const lane = await ensureRealHomeCodexHookState({
|
|
hooksEnabled: true,
|
|
userDataPath: userDataDir
|
|
})
|
|
|
|
expect(lane).toBe('installed')
|
|
expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(firstRaw)
|
|
})
|
|
|
|
it('keeps later user hook trust positions stable when reconciling an existing install', async () => {
|
|
grantSucceeds()
|
|
const userBefore = { hooks: [{ type: 'command', command: 'before.sh' }] }
|
|
writeFileSync(
|
|
getRealHooksJsonPath(),
|
|
`${JSON.stringify({ hooks: { Stop: [userBefore] } }, null, 2)}\n`,
|
|
'utf-8'
|
|
)
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
|
const installed = readRealHooksJson()
|
|
const userAfter = { hooks: [{ type: 'command', command: 'after.sh' }] }
|
|
installed.hooks!.Stop!.push(userAfter)
|
|
writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`, 'utf-8')
|
|
|
|
expect(
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
|
).toBe('installed')
|
|
|
|
const reconciled = readRealHooksJson().hooks?.Stop
|
|
expect(reconciled?.[0]).toEqual(userBefore)
|
|
expect(reconciled?.[2]).toEqual(userAfter)
|
|
const plan = grantMock.mock.calls.at(-1)![0] as CodexManagedTrustGrantPlan
|
|
expect(plan.managedEntries.find((entry) => entry.eventLabel === 'stop')?.groupIndex).toBe(1)
|
|
})
|
|
|
|
it("keeps later user handler trust positions stable inside Orca's hook group", async () => {
|
|
grantSucceeds()
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
|
const installed = readRealHooksJson()
|
|
const userAfter = { type: 'command', command: 'after.sh' }
|
|
installed.hooks!.Stop![0]!.hooks!.push(userAfter)
|
|
writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`, 'utf-8')
|
|
|
|
expect(
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
|
).toBe('installed')
|
|
|
|
expect(readRealHooksJson().hooks?.Stop?.[0]?.hooks?.[1]).toEqual(userAfter)
|
|
const plan = grantMock.mock.calls.at(-1)![0] as CodexManagedTrustGrantPlan
|
|
const stopEntry = plan.managedEntries.find((entry) => entry.eventLabel === 'stop')
|
|
expect(stopEntry).toMatchObject({ groupIndex: 0, handlerIndex: 0 })
|
|
})
|
|
})
|
|
|
|
describe('ensureRealHomeCodexHookState (opt-out sweep)', () => {
|
|
it('keeps the managed lane when hooks.json cannot be read', async () => {
|
|
mkdirSync(getRealHooksJsonPath())
|
|
|
|
expect(
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })
|
|
).toBe('unavailable')
|
|
})
|
|
|
|
it('keeps the managed lane when hooks.json is malformed', async () => {
|
|
writeFileSync(getRealHooksJsonPath(), '{ not json', 'utf-8')
|
|
|
|
expect(
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })
|
|
).toBe('unavailable')
|
|
expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe('{ not json')
|
|
})
|
|
|
|
it('rebases trust when a user appended hooks after Orca installed', async () => {
|
|
grantSucceeds()
|
|
const before = { type: 'command', command: 'before.sh' }
|
|
writeFileSync(
|
|
getRealHooksJsonPath(),
|
|
`${JSON.stringify({ hooks: { Stop: [{ hooks: [before] }] } }, null, 2)}\n`
|
|
)
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
|
const installed = readRealHooksJson()
|
|
const after = { type: 'command', command: 'after.sh' }
|
|
installed.hooks!.Stop!.push({ hooks: [after] })
|
|
writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`)
|
|
const operations: string[] = []
|
|
rebaseInternals.setSessionRunner(async (request) => {
|
|
operations.push(request.operation)
|
|
if (request.operation === 'inspect-user-hook-trust') {
|
|
expect(readRealHooksJson().hooks?.Stop?.[2]?.hooks?.[0]?.command).toBe('after.sh')
|
|
return {
|
|
outcome: 'inspected',
|
|
moves: request.moves.map((move) => ({
|
|
...move,
|
|
reportedOldKey: move.oldKey,
|
|
wasTrusted: true,
|
|
enabled: true
|
|
}))
|
|
}
|
|
}
|
|
expect(readRealHooksJson().hooks?.Stop?.[1]?.hooks?.[0]?.command).toBe('after.sh')
|
|
return { outcome: 'repaired', repaired: 1 }
|
|
})
|
|
|
|
expect(
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })
|
|
).toBe('removed')
|
|
expect(operations).toEqual(['inspect-user-hook-trust', 'repair-user-hook-trust'])
|
|
expect(readRealHooksJson().hooks?.Stop).toEqual([{ hooks: [before] }, { hooks: [after] }])
|
|
})
|
|
|
|
it('aborts without writing when hooks.json changes during the trust inspection', async () => {
|
|
grantSucceeds()
|
|
const before = { type: 'command', command: 'before.sh' }
|
|
writeFileSync(
|
|
getRealHooksJsonPath(),
|
|
`${JSON.stringify({ hooks: { Stop: [{ hooks: [before] }] } }, null, 2)}\n`
|
|
)
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
|
const installed = readRealHooksJson()
|
|
const after = { type: 'command', command: 'after.sh' }
|
|
installed.hooks!.Stop!.push({ hooks: [after] })
|
|
writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`)
|
|
const userTrustToml = '[hooks.state."x:stop:0:0"]\ntrusted_hash = "user"\n'
|
|
writeFileSync(getRealConfigTomlPath(), userTrustToml, 'utf-8')
|
|
const concurrentSave = `${JSON.stringify({ hooks: { Stop: [{ hooks: [before] }] } }, null, 2)}\n`
|
|
const operations: string[] = []
|
|
rebaseInternals.setSessionRunner(async (request) => {
|
|
operations.push(request.operation)
|
|
// A user save (or a second Orca instance) lands while the RPC runs.
|
|
writeFileSync(getRealHooksJsonPath(), concurrentSave, 'utf-8')
|
|
return {
|
|
outcome: 'inspected',
|
|
moves: request.moves.map((move) => ({
|
|
...move,
|
|
reportedOldKey: move.oldKey,
|
|
wasTrusted: true,
|
|
enabled: true
|
|
}))
|
|
}
|
|
})
|
|
|
|
expect(
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })
|
|
).toBe('unavailable')
|
|
|
|
expect(operations).toEqual(['inspect-user-hook-trust'])
|
|
expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(concurrentSave)
|
|
expect(readFileSync(getRealConfigTomlPath(), 'utf-8')).toBe(userTrustToml)
|
|
})
|
|
|
|
it('removes only Orca entries and reports the removed lane', async () => {
|
|
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'
|
|
)
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
|
expect(readRealHooksJson().hooks?.Stop).toHaveLength(2)
|
|
|
|
const lane = await 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', async () => {
|
|
const lane = await ensureRealHomeCodexHookState({
|
|
hooksEnabled: false,
|
|
userDataPath: userDataDir
|
|
})
|
|
|
|
expect(lane).toBe('removed')
|
|
expect(existsSync(getRealHooksJsonPath())).toBe(false)
|
|
})
|
|
|
|
it('removes only hash-proven Orca trust from a mixed hook group', async () => {
|
|
const material = getCodexManagedHookInstallMaterial()
|
|
const userCommand = 'my-user-hook.sh'
|
|
writeFileSync(
|
|
getRealHooksJsonPath(),
|
|
`${JSON.stringify(
|
|
{
|
|
hooks: {
|
|
Stop: [
|
|
{
|
|
hooks: [
|
|
{ type: 'command', command: userCommand },
|
|
{ type: 'command', command: material.command, timeout: 10 }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
null,
|
|
2
|
|
)}\n`,
|
|
'utf-8'
|
|
)
|
|
const entries: CodexTrustEntry[] = [
|
|
{
|
|
sourcePath: getRealHooksJsonPath(),
|
|
eventLabel: 'stop',
|
|
groupIndex: 0,
|
|
handlerIndex: 0,
|
|
command: userCommand
|
|
},
|
|
{
|
|
sourcePath: getRealHooksJsonPath(),
|
|
eventLabel: 'stop',
|
|
groupIndex: 0,
|
|
handlerIndex: 1,
|
|
command: material.command,
|
|
timeoutSec: 10
|
|
}
|
|
]
|
|
writeFileSync(getRealConfigTomlPath(), upsertHookTrustEntriesInContent('', entries), 'utf-8')
|
|
|
|
expect(
|
|
await ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })
|
|
).toBe('removed')
|
|
|
|
expect(readRealHooksJson().hooks?.Stop).toEqual([
|
|
{ hooks: [{ type: 'command', command: userCommand }] }
|
|
])
|
|
const trust = readHookTrustEntries(getRealConfigTomlPath())
|
|
expect(trust.has(computeTrustKey(entries[0]!))).toBe(true)
|
|
expect(trust.has(computeTrustKey(entries[1]!))).toBe(false)
|
|
})
|
|
})
|