mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
fix(codex): preserve user trust during real-home cleanup
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
"../src/cli/**/*",
|
||||
"../src/shared/**/*",
|
||||
"../src/main/agent-hooks/hook-stdin-contract.ts",
|
||||
"../src/main/agent-hooks/hook-config-write-path.ts",
|
||||
"../src/main/agent-hooks/installer-utils.ts",
|
||||
"../src/main/agent-hooks/installer-utils-remote.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
|
||||
@@ -15,6 +16,7 @@
|
||||
"../src/main/codex/codex-app-server-client.ts",
|
||||
"../src/main/codex/codex-app-server-grant-bridge.ts",
|
||||
"../src/main/codex/codex-app-server-grant-envelope.ts",
|
||||
"../src/main/codex/codex-app-server-session.ts",
|
||||
"../src/main/codex/codex-config-mirror.ts",
|
||||
"../src/main/codex/codex-config-path-reference-rewrite.ts",
|
||||
"../src/main/codex/codex-home-paths.ts",
|
||||
@@ -25,6 +27,8 @@
|
||||
"../src/main/codex/codex-trust-config-rollback.ts",
|
||||
"../src/main/codex/codex-trust-grant-host.ts",
|
||||
"../src/main/codex/codex-trust-grant-ledger.ts",
|
||||
"../src/main/codex/codex-user-hook-trust-rebase-client.ts",
|
||||
"../src/main/codex/codex-user-hook-trust-rebase.ts",
|
||||
"../src/main/codex/codex-wsl-hook-install-plan.ts",
|
||||
"../src/main/codex/config-settings-promotion.ts",
|
||||
"../src/main/codex/config-toml-line-scan.ts",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { lstatSync, realpathSync } from 'node:fs'
|
||||
|
||||
export function resolveHooksJsonWritePath(configPath: string): string {
|
||||
let isSymlink = false
|
||||
try {
|
||||
isSymlink = lstatSync(configPath).isSymbolicLink()
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
return configPath
|
||||
}
|
||||
if (isSymlink) {
|
||||
// Why: atomic rename on the link path disconnects dotfiles-managed hook
|
||||
// configs. A dangling link must fail closed rather than be replaced.
|
||||
return realpathSync.native(configPath)
|
||||
}
|
||||
return configPath
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
chmodSync
|
||||
} from 'node:fs'
|
||||
@@ -42,6 +44,27 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('writeHooksJson', () => {
|
||||
it('updates a symlink target without replacing the hook config link', () => {
|
||||
const targetPath = join(tmpDir, 'dotfiles-hooks.json')
|
||||
writeFileSync(targetPath, '{"hooks":{}}\n')
|
||||
symlinkSync(targetPath, configPath)
|
||||
|
||||
writeHooksJson(configPath, { hooks: { Stop: [] } })
|
||||
|
||||
expect(lstatSync(configPath).isSymbolicLink()).toBe(true)
|
||||
expect(JSON.parse(readFileSync(targetPath, 'utf-8'))).toEqual({ hooks: { Stop: [] } })
|
||||
})
|
||||
|
||||
it('does not replace a dangling hook config symlink', () => {
|
||||
const targetPath = join(tmpDir, 'missing-dotfiles-hooks.json')
|
||||
symlinkSync(targetPath, configPath)
|
||||
|
||||
expect(() => writeHooksJson(configPath, { hooks: { Stop: [] } })).toThrow()
|
||||
|
||||
expect(lstatSync(configPath).isSymbolicLink()).toBe(true)
|
||||
expect(existsSync(targetPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('writes the config as formatted JSON', () => {
|
||||
const config: HooksConfig = {
|
||||
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'foo' }] }] }
|
||||
|
||||
@@ -15,6 +15,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import type { AgentHookSource } from '../../shared/agent-hook-relay'
|
||||
import { grantDirAcl, isPermissionError } from '../win32-utils'
|
||||
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
|
||||
import { resolveHooksJsonWritePath } from './hook-config-write-path'
|
||||
|
||||
export type HookCommandConfig = {
|
||||
type: 'command'
|
||||
@@ -359,7 +360,8 @@ export function writeHooksJson(
|
||||
config: HooksConfig,
|
||||
options?: { preserveMode?: boolean }
|
||||
): void {
|
||||
const dir = dirname(configPath)
|
||||
const writePath = resolveHooksJsonWritePath(configPath)
|
||||
const dir = dirname(writePath)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
|
||||
// Why: write to a temp file then rename so a crash or disk-full mid-write
|
||||
@@ -374,15 +376,15 @@ export function writeHooksJson(
|
||||
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
|
||||
const serialized = `${JSON.stringify(config, null, 2)}\n`
|
||||
const existingMode =
|
||||
options?.preserveMode === true && existsSync(configPath) ? statSync(configPath).mode : undefined
|
||||
options?.preserveMode === true && existsSync(writePath) ? statSync(writePath).mode : undefined
|
||||
|
||||
// Why: skip the write (and therefore the .bak rotation) when the on-disk
|
||||
// content is already identical. Without this, every install() rewrites the
|
||||
// file and rolls the backup forward, which can silently destroy the last
|
||||
// recoverable copy if install() is called repeatedly (e.g. on app start).
|
||||
if (existsSync(configPath)) {
|
||||
if (existsSync(writePath)) {
|
||||
try {
|
||||
if (readFileSync(configPath, 'utf-8') === serialized) {
|
||||
if (readFileSync(writePath, 'utf-8') === serialized) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
@@ -397,10 +399,10 @@ export function writeHooksJson(
|
||||
// Why: single rolling backup — one file, no accumulation in ~/.claude.
|
||||
// Protects against a merge-logic bug producing bad JSON; the original is
|
||||
// always recoverable from <configPath>.bak until the next write.
|
||||
if (existsSync(configPath)) {
|
||||
copyFileSync(configPath, `${configPath}.bak`)
|
||||
if (existsSync(writePath)) {
|
||||
copyFileSync(writePath, `${writePath}.bak`)
|
||||
}
|
||||
renameSync(tmpPath, configPath)
|
||||
renameSync(tmpPath, writePath)
|
||||
} finally {
|
||||
// Clean up temp file if rename failed.
|
||||
if (existsSync(tmpPath)) {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { normalizeHookTrustKeyForLookup } from './config-toml-trust'
|
||||
import { waitForProcessExitUntil } from './codex-process-exit-deadline'
|
||||
import { runCodexAppServerSession, type CodexAppServerInvocation } from './codex-app-server-session'
|
||||
|
||||
export {
|
||||
CodexAppServerTimeoutError,
|
||||
CodexAppServerUnsupportedError,
|
||||
isCodexAppServerUnsupportedError
|
||||
} from './codex-app-server-session'
|
||||
export type { CodexAppServerInvocation } from './codex-app-server-session'
|
||||
|
||||
// Why: Codex gates hooks on a `trusted_hash` it computes from a private
|
||||
// canonical-JSON identity. Orca used to replicate that algorithm
|
||||
@@ -12,15 +19,6 @@ import { waitForProcessExitUntil } from './codex-process-exit-deadline'
|
||||
// Codex as the only hash authority. See upstream codex-rs/tui/src/hooks_rpc.rs
|
||||
// and codex-rs/tui/src/startup_hooks_review.rs.
|
||||
|
||||
export type CodexAppServerInvocation = {
|
||||
command: string
|
||||
args: string[]
|
||||
/** Overlay applied on top of the inherited environment (e.g. CODEX_HOME). */
|
||||
env?: Record<string, string>
|
||||
/** Whole-session deadline. The codex child is SIGKILLed when it lapses. */
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
export type CodexHookTrustGrantRequest = {
|
||||
invocation: CodexAppServerInvocation
|
||||
/** cwd passed to hooks/list. Discovery of the managed CODEX_HOME's
|
||||
@@ -52,55 +50,15 @@ export type CodexHookTrustGrantSessionResult =
|
||||
}
|
||||
| { outcome: 'verify-failed'; reason: string }
|
||||
|
||||
/** Codex-side absence of the trust-grant RPC surface (old CLI without the
|
||||
* app-server subcommand, or a server without hooks/list / config/batchWrite).
|
||||
* This is the ONLY error class the capability cache marks unsupported. */
|
||||
export class CodexAppServerUnsupportedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'CodexAppServerUnsupportedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class CodexAppServerTimeoutError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'CodexAppServerTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isCodexAppServerUnsupportedError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'CodexAppServerUnsupportedError'
|
||||
}
|
||||
|
||||
type JsonRpcResponse = {
|
||||
id?: number
|
||||
result?: unknown
|
||||
error?: { code?: number; message?: string }
|
||||
}
|
||||
|
||||
type CodexHookListing = {
|
||||
export type CodexHookListing = {
|
||||
key: string
|
||||
command: string | null
|
||||
currentHash: string
|
||||
trustStatus: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const JSON_RPC_METHOD_NOT_FOUND = -32601
|
||||
const STDERR_TAIL_MAX_BYTES = 8192
|
||||
const STDOUT_LINE_MAX_BYTES = 1024 * 1024
|
||||
|
||||
function isMethodNotFoundError(error: { code?: number; message?: string }): boolean {
|
||||
return error.code === JSON_RPC_METHOD_NOT_FOUND || /method not found/i.test(error.message ?? '')
|
||||
}
|
||||
|
||||
// Why: a CLI predating the app-server subcommand fails argv parsing before
|
||||
// speaking any JSON-RPC; that shape is a capability signal, not a transient.
|
||||
function stderrIndicatesMissingAppServer(stderrTail: string): boolean {
|
||||
return /unrecognized subcommand|unexpected argument|invalid subcommand/i.test(stderrTail)
|
||||
}
|
||||
|
||||
function collectHookListings(result: unknown): CodexHookListing[] {
|
||||
export function collectCodexHookListings(result: unknown): CodexHookListing[] {
|
||||
const data =
|
||||
result && typeof result === 'object' && Array.isArray((result as { data?: unknown }).data)
|
||||
? ((result as { data: unknown[] }).data as { hooks?: unknown }[])
|
||||
@@ -127,7 +85,8 @@ function collectHookListings(result: unknown): CodexHookListing[] {
|
||||
key: hook.key,
|
||||
command: typeof hook.command === 'string' ? hook.command : null,
|
||||
currentHash: hook.currentHash,
|
||||
trustStatus: hook.trustStatus
|
||||
trustStatus: hook.trustStatus,
|
||||
enabled: typeof hook.enabled === 'boolean' ? hook.enabled : true
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -144,232 +103,59 @@ export async function runCodexHookTrustGrantSession(
|
||||
request: CodexHookTrustGrantRequest,
|
||||
spawnImpl: typeof spawn = spawn
|
||||
): Promise<CodexHookTrustGrantSessionResult> {
|
||||
const { invocation } = request
|
||||
const child = spawnImpl(invocation.command, invocation.args, {
|
||||
env: { ...process.env, ...invocation.env },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
}) as ChildProcessWithoutNullStreams
|
||||
return runCodexAppServerSession(
|
||||
request.invocation,
|
||||
async (requestRpc) => {
|
||||
const expectedKeys = new Set(request.expectedTrustKeys)
|
||||
const matchManaged = (listing: CodexHookListing): boolean =>
|
||||
listing.command === request.managedCommand &&
|
||||
expectedKeys.has(normalizeHookTrustKeyForLookup(listing.key))
|
||||
|
||||
let stderrTail = ''
|
||||
let exited = false
|
||||
let nextRequestId = 1
|
||||
let timedOut = false
|
||||
const pending = new Map<
|
||||
number,
|
||||
{ resolve: (r: JsonRpcResponse) => void; reject: (e: Error) => void }
|
||||
>()
|
||||
|
||||
const exitPromise = new Promise<void>((resolve) => {
|
||||
child.on('exit', () => {
|
||||
exited = true
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
// Why: 'error' fires instead of 'exit' when the spawn itself fails
|
||||
// (ENOENT); surface it to every in-flight request or they wait forever.
|
||||
let spawnError: Error | null = null
|
||||
child.on('error', (error) => {
|
||||
spawnError = error
|
||||
exited = true
|
||||
failPending(error)
|
||||
})
|
||||
// Why: 'close' (not 'exit') guarantees the stderr tail is complete, so an
|
||||
// early death classifies correctly as missing-subcommand vs transient.
|
||||
child.on('close', () => {
|
||||
failPending(buildEarlyExitError())
|
||||
})
|
||||
// Why: JSONL can contain non-ASCII hook paths. Stream decoding must retain a
|
||||
// multibyte character split across pipe chunks or the response becomes invalid JSON.
|
||||
child.stderr.setEncoding('utf8').on('data', (chunk: string) => {
|
||||
stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_BYTES)
|
||||
})
|
||||
// Why: a child can exit between the liveness check and stdin.write(); an
|
||||
// EPIPE must reject the RPC instead of becoming an unhandled stream error.
|
||||
child.stdin.on('error', (error) => {
|
||||
failPending(error)
|
||||
})
|
||||
|
||||
let stdoutBuffer = ''
|
||||
child.stdout.setEncoding('utf8').on('data', (chunk: string) => {
|
||||
stdoutBuffer += chunk
|
||||
if (Buffer.byteLength(stdoutBuffer) > STDOUT_LINE_MAX_BYTES) {
|
||||
child.kill('SIGKILL')
|
||||
failPending(new Error('codex app-server emitted an oversized JSONL response'))
|
||||
return
|
||||
}
|
||||
let newlineIndex
|
||||
while ((newlineIndex = stdoutBuffer.indexOf('\n')) !== -1) {
|
||||
const line = stdoutBuffer.slice(0, newlineIndex).trim()
|
||||
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1)
|
||||
if (!line) {
|
||||
continue
|
||||
const listResult = await requestRpc('hooks/list', { cwds: [request.hooksListCwd] })
|
||||
const managedListings = collectCodexHookListings(listResult).filter(matchManaged)
|
||||
if (managedListings.length !== expectedKeys.size) {
|
||||
return {
|
||||
outcome: 'verify-failed',
|
||||
reason: `hooks/list reported ${managedListings.length} of ${expectedKeys.size} expected managed entries`
|
||||
}
|
||||
}
|
||||
let message: JsonRpcResponse
|
||||
try {
|
||||
message = JSON.parse(line) as JsonRpcResponse
|
||||
} catch {
|
||||
continue
|
||||
|
||||
const needingTrust = managedListings.filter((listing) => listing.trustStatus !== 'trusted')
|
||||
if (needingTrust.length > 0) {
|
||||
// Why: same wire shape as the Codex TUI "Trust all" flow — one upsert
|
||||
// edit under hooks.state with each key's Codex-computed current hash.
|
||||
const value: Record<string, { trusted_hash: string }> = {}
|
||||
for (const listing of needingTrust) {
|
||||
value[listing.key] = { trusted_hash: listing.currentHash }
|
||||
}
|
||||
await requestRpc('config/batchWrite', {
|
||||
edits: [{ keyPath: 'hooks.state', value, mergeStrategy: 'upsert' }],
|
||||
reloadUserConfig: true
|
||||
})
|
||||
}
|
||||
if (typeof message.id === 'number' && pending.has(message.id)) {
|
||||
const waiter = pending.get(message.id)!
|
||||
pending.delete(message.id)
|
||||
waiter.resolve(message)
|
||||
|
||||
const verifyResult = await requestRpc('hooks/list', { cwds: [request.hooksListCwd] })
|
||||
const verifiedListings = collectCodexHookListings(verifyResult).filter(matchManaged)
|
||||
const untrusted = verifiedListings.filter((listing) => listing.trustStatus !== 'trusted')
|
||||
if (verifiedListings.length !== expectedKeys.size || untrusted.length > 0) {
|
||||
return {
|
||||
outcome: 'verify-failed',
|
||||
reason:
|
||||
untrusted.length > 0
|
||||
? `post-grant verify left ${untrusted.length} entries ${untrusted[0].trustStatus}`
|
||||
: `post-grant verify reported ${verifiedListings.length} of ${expectedKeys.size} entries`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function failPending(error: Error): void {
|
||||
for (const waiter of pending.values()) {
|
||||
waiter.reject(error)
|
||||
}
|
||||
pending.clear()
|
||||
}
|
||||
|
||||
const deadline = setTimeout(() => {
|
||||
timedOut = true
|
||||
child.kill('SIGKILL')
|
||||
failPending(
|
||||
new CodexAppServerTimeoutError(
|
||||
`codex app-server session exceeded ${invocation.timeoutMs}ms (${invocation.command})`
|
||||
)
|
||||
)
|
||||
}, invocation.timeoutMs)
|
||||
|
||||
function sendLine(payload: Record<string, unknown>): void {
|
||||
child.stdin.write(`${JSON.stringify(payload)}\n`)
|
||||
}
|
||||
|
||||
async function requestRpc(method: string, params?: Record<string, unknown>): Promise<unknown> {
|
||||
if (spawnError) {
|
||||
throw spawnError
|
||||
}
|
||||
if (timedOut) {
|
||||
throw new CodexAppServerTimeoutError('codex app-server session already timed out')
|
||||
}
|
||||
if (exited) {
|
||||
throw buildEarlyExitError()
|
||||
}
|
||||
const id = nextRequestId++
|
||||
const response = await new Promise<JsonRpcResponse>((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject })
|
||||
const payload: Record<string, unknown> = { method, id }
|
||||
if (params !== undefined) {
|
||||
payload.params = params
|
||||
}
|
||||
try {
|
||||
sendLine(payload)
|
||||
} catch (error) {
|
||||
pending.delete(id)
|
||||
reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
if (response.error) {
|
||||
if (isMethodNotFoundError(response.error)) {
|
||||
throw new CodexAppServerUnsupportedError(
|
||||
`codex app-server does not support ${method}: ${response.error.message ?? 'method not found'}`
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`codex app-server ${method} failed: ${response.error.message ?? 'unknown error'}`
|
||||
)
|
||||
}
|
||||
return response.result
|
||||
}
|
||||
|
||||
function buildEarlyExitError(): Error {
|
||||
if (stderrIndicatesMissingAppServer(stderrTail)) {
|
||||
return new CodexAppServerUnsupportedError(
|
||||
`codex CLI does not support the app-server subcommand: ${stderrTail.trim().slice(0, 400)}`
|
||||
)
|
||||
}
|
||||
return new Error(
|
||||
`codex app-server exited before completing the session${stderrTail ? `: ${stderrTail.trim().slice(0, 400)}` : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await requestRpc('initialize', {
|
||||
clientInfo: { name: 'orca_desktop', title: 'Orca', version: '0.0.0' }
|
||||
})
|
||||
sendLine({ method: 'initialized' })
|
||||
|
||||
const expectedKeys = new Set(request.expectedTrustKeys)
|
||||
const matchManaged = (listing: CodexHookListing): boolean =>
|
||||
listing.command === request.managedCommand &&
|
||||
expectedKeys.has(normalizeHookTrustKeyForLookup(listing.key))
|
||||
|
||||
const listResult = await requestRpc('hooks/list', { cwds: [request.hooksListCwd] })
|
||||
const managedListings = collectHookListings(listResult).filter(matchManaged)
|
||||
if (managedListings.length !== expectedKeys.size) {
|
||||
return {
|
||||
outcome: 'verify-failed',
|
||||
reason: `hooks/list reported ${managedListings.length} of ${expectedKeys.size} expected managed entries`
|
||||
outcome: 'granted',
|
||||
wroteTrust: needingTrust.length > 0,
|
||||
entries: verifiedListings.map((listing) => ({
|
||||
key: listing.key,
|
||||
normalizedKey: normalizeHookTrustKeyForLookup(listing.key),
|
||||
trustedHash: listing.currentHash
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const needingTrust = managedListings.filter((listing) => listing.trustStatus !== 'trusted')
|
||||
if (needingTrust.length > 0) {
|
||||
// Why: same wire shape as the Codex TUI "Trust all" flow — one upsert
|
||||
// edit under hooks.state with each key's Codex-computed current hash.
|
||||
const value: Record<string, { trusted_hash: string }> = {}
|
||||
for (const listing of needingTrust) {
|
||||
value[listing.key] = { trusted_hash: listing.currentHash }
|
||||
}
|
||||
await requestRpc('config/batchWrite', {
|
||||
edits: [{ keyPath: 'hooks.state', value, mergeStrategy: 'upsert' }],
|
||||
reloadUserConfig: true
|
||||
})
|
||||
}
|
||||
|
||||
const verifyResult = await requestRpc('hooks/list', { cwds: [request.hooksListCwd] })
|
||||
const verifiedListings = collectHookListings(verifyResult).filter(matchManaged)
|
||||
const untrusted = verifiedListings.filter((listing) => listing.trustStatus !== 'trusted')
|
||||
if (verifiedListings.length !== expectedKeys.size || untrusted.length > 0) {
|
||||
return {
|
||||
outcome: 'verify-failed',
|
||||
reason:
|
||||
untrusted.length > 0
|
||||
? `post-grant verify left ${untrusted.length} entries ${untrusted[0].trustStatus}`
|
||||
: `post-grant verify reported ${verifiedListings.length} of ${expectedKeys.size} entries`
|
||||
}
|
||||
}
|
||||
return {
|
||||
outcome: 'granted',
|
||||
wroteTrust: needingTrust.length > 0,
|
||||
entries: verifiedListings.map((listing) => ({
|
||||
key: listing.key,
|
||||
normalizedKey: normalizeHookTrustKeyForLookup(listing.key),
|
||||
trustedHash: listing.currentHash
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
!(error instanceof CodexAppServerUnsupportedError) &&
|
||||
!(error instanceof CodexAppServerTimeoutError) &&
|
||||
stderrIndicatesMissingAppServer(stderrTail)
|
||||
) {
|
||||
throw new CodexAppServerUnsupportedError(
|
||||
`codex CLI does not support the app-server subcommand: ${stderrTail.trim().slice(0, 400)}`
|
||||
)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
try {
|
||||
child.stdin.end()
|
||||
} catch {
|
||||
// stdin may already be destroyed after a kill; reaping below still runs.
|
||||
}
|
||||
if (!exited) {
|
||||
// Why: the server exits promptly on stdin EOF; the grace period only
|
||||
// bounds a wedged child before the guaranteed SIGKILL reap.
|
||||
await waitForProcessExitUntil(exitPromise, 1500)
|
||||
if (!exited) {
|
||||
child.kill('SIGKILL')
|
||||
await waitForProcessExitUntil(exitPromise, 1000)
|
||||
}
|
||||
}
|
||||
clearTimeout(deadline)
|
||||
}
|
||||
},
|
||||
spawnImpl
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,15 @@ import {
|
||||
type CodexHookTrustGrantRequest,
|
||||
type CodexHookTrustGrantSessionResult
|
||||
} from './codex-app-server-client'
|
||||
import type { GrantEntryEnvelope } from './codex-app-server-grant-envelope'
|
||||
import type {
|
||||
CodexAppServerEntryRequest,
|
||||
CodexAppServerEntryResult,
|
||||
GrantEntryEnvelope
|
||||
} from './codex-app-server-grant-envelope'
|
||||
import type {
|
||||
CodexUserHookTrustRebaseRequest,
|
||||
CodexUserHookTrustRebaseResult
|
||||
} from './codex-user-hook-trust-rebase-client'
|
||||
|
||||
// Why: hook install/refresh is synchronous launch prep — a Codex pane must
|
||||
// not start before its trust is settled — but a stdio JSON-RPC session needs
|
||||
@@ -63,6 +71,20 @@ export function runCodexHookTrustGrantSessionSync(
|
||||
request: CodexHookTrustGrantRequest,
|
||||
options: RunGrantSessionSyncOptions = {}
|
||||
): CodexHookTrustGrantSessionResult {
|
||||
return runCodexAppServerEntrySync(request, options) as CodexHookTrustGrantSessionResult
|
||||
}
|
||||
|
||||
export function runCodexUserHookTrustRebaseSessionSync(
|
||||
request: CodexUserHookTrustRebaseRequest,
|
||||
options: RunGrantSessionSyncOptions = {}
|
||||
): CodexUserHookTrustRebaseResult {
|
||||
return runCodexAppServerEntrySync(request, options) as CodexUserHookTrustRebaseResult
|
||||
}
|
||||
|
||||
function runCodexAppServerEntrySync(
|
||||
request: CodexAppServerEntryRequest,
|
||||
options: RunGrantSessionSyncOptions
|
||||
): CodexAppServerEntryResult {
|
||||
const entryPath = options.entryPath ?? resolveCodexGrantEntryPath()
|
||||
if (!entryPath) {
|
||||
throw new Error('codex trust-grant entry bundle not found')
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
// JSONL RPC session itself needs a live event loop. Reads the request JSON
|
||||
// from stdin, writes a single result-envelope JSON line to stdout, and never
|
||||
// imports electron (see PLAIN_NODE_ENTRY_NAMES in the build guard).
|
||||
import { buildGrantEntryEnvelope } from './codex-app-server-grant-envelope'
|
||||
import { writeSync } from 'node:fs'
|
||||
import {
|
||||
runCodexHookTrustGrantSession,
|
||||
type CodexHookTrustGrantRequest
|
||||
} from './codex-app-server-client'
|
||||
buildGrantEntryEnvelope,
|
||||
type CodexAppServerEntryRequest
|
||||
} from './codex-app-server-grant-envelope'
|
||||
import { writeSync } from 'node:fs'
|
||||
import { runCodexHookTrustGrantSession } from './codex-app-server-client'
|
||||
import { runCodexUserHookTrustRebaseSession } from './codex-user-hook-trust-rebase-client'
|
||||
|
||||
const HARD_EXIT_MARGIN_MS = 2_000
|
||||
|
||||
@@ -23,9 +24,9 @@ async function readStdin(): Promise<string> {
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const raw = await readStdin()
|
||||
let request: CodexHookTrustGrantRequest
|
||||
let request: CodexAppServerEntryRequest
|
||||
try {
|
||||
request = JSON.parse(raw) as CodexHookTrustGrantRequest
|
||||
request = JSON.parse(raw) as CodexAppServerEntryRequest
|
||||
} catch (error) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
@@ -52,7 +53,11 @@ async function main(): Promise<void> {
|
||||
)
|
||||
process.exit(3)
|
||||
}, request.invocation.timeoutMs + HARD_EXIT_MARGIN_MS)
|
||||
const envelope = await buildGrantEntryEnvelope(runCodexHookTrustGrantSession(request))
|
||||
const run =
|
||||
'operation' in request
|
||||
? runCodexUserHookTrustRebaseSession(request)
|
||||
: runCodexHookTrustGrantSession(request)
|
||||
const envelope = await buildGrantEntryEnvelope(run)
|
||||
clearTimeout(hardExit)
|
||||
process.stdout.write(`${JSON.stringify(envelope)}\n`)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import {
|
||||
isCodexAppServerUnsupportedError,
|
||||
type CodexHookTrustGrantRequest,
|
||||
type CodexHookTrustGrantSessionResult
|
||||
} from './codex-app-server-client'
|
||||
import type {
|
||||
CodexUserHookTrustRebaseRequest,
|
||||
CodexUserHookTrustRebaseResult
|
||||
} from './codex-user-hook-trust-rebase-client'
|
||||
|
||||
export type CodexAppServerEntryRequest =
|
||||
| CodexHookTrustGrantRequest
|
||||
| CodexUserHookTrustRebaseRequest
|
||||
|
||||
export type CodexAppServerEntryResult =
|
||||
| CodexHookTrustGrantSessionResult
|
||||
| CodexUserHookTrustRebaseResult
|
||||
|
||||
export type GrantEntryEnvelope =
|
||||
| { ok: true; result: CodexHookTrustGrantSessionResult }
|
||||
| { ok: true; result: CodexAppServerEntryResult }
|
||||
| { ok: false; errorName: string; message: string; unsupported?: boolean }
|
||||
|
||||
export function buildGrantEntryEnvelope(
|
||||
run: Promise<CodexHookTrustGrantSessionResult>
|
||||
run: Promise<CodexAppServerEntryResult>
|
||||
): Promise<GrantEntryEnvelope> {
|
||||
return run.then(
|
||||
(result) => ({ ok: true as const, result }),
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { waitForProcessExitUntil } from './codex-process-exit-deadline'
|
||||
|
||||
export type CodexAppServerInvocation = {
|
||||
command: string
|
||||
args: string[]
|
||||
/** Overlay applied on top of the inherited environment (for example, CODEX_HOME). */
|
||||
env?: Record<string, string>
|
||||
/** Whole-session deadline; the Codex child is killed when it lapses. */
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
export class CodexAppServerUnsupportedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'CodexAppServerUnsupportedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class CodexAppServerTimeoutError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'CodexAppServerTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isCodexAppServerUnsupportedError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'CodexAppServerUnsupportedError'
|
||||
}
|
||||
|
||||
type JsonRpcResponse = {
|
||||
id?: number
|
||||
result?: unknown
|
||||
error?: { code?: number; message?: string }
|
||||
}
|
||||
|
||||
export type CodexAppServerRequestRpc = (
|
||||
method: string,
|
||||
params?: Record<string, unknown>
|
||||
) => Promise<unknown>
|
||||
|
||||
const JSON_RPC_METHOD_NOT_FOUND = -32601
|
||||
const STDERR_TAIL_MAX_BYTES = 8192
|
||||
const STDOUT_LINE_MAX_BYTES = 1024 * 1024
|
||||
|
||||
function stderrIndicatesMissingAppServer(stderrTail: string): boolean {
|
||||
return /unrecognized subcommand|unexpected argument|invalid subcommand/i.test(stderrTail)
|
||||
}
|
||||
|
||||
export async function runCodexAppServerSession<T>(
|
||||
invocation: CodexAppServerInvocation,
|
||||
run: (requestRpc: CodexAppServerRequestRpc) => Promise<T>,
|
||||
spawnImpl: typeof spawn = spawn
|
||||
): Promise<T> {
|
||||
const child = spawnImpl(invocation.command, invocation.args, {
|
||||
env: { ...process.env, ...invocation.env },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
}) as ChildProcessWithoutNullStreams
|
||||
|
||||
let stderrTail = ''
|
||||
let stdoutBuffer = ''
|
||||
let exited = false
|
||||
let nextRequestId = 1
|
||||
let timedOut = false
|
||||
let spawnError: Error | null = null
|
||||
const pending = new Map<
|
||||
number,
|
||||
{ resolve: (response: JsonRpcResponse) => void; reject: (error: Error) => void }
|
||||
>()
|
||||
|
||||
function buildEarlyExitError(): Error {
|
||||
if (stderrIndicatesMissingAppServer(stderrTail)) {
|
||||
return new CodexAppServerUnsupportedError(
|
||||
`codex CLI does not support the app-server subcommand: ${stderrTail.trim().slice(0, 400)}`
|
||||
)
|
||||
}
|
||||
return new Error(
|
||||
`codex app-server exited before completing the session${stderrTail ? `: ${stderrTail.trim().slice(0, 400)}` : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
function failPending(error: Error): void {
|
||||
for (const waiter of pending.values()) {
|
||||
waiter.reject(error)
|
||||
}
|
||||
pending.clear()
|
||||
}
|
||||
|
||||
const exitPromise = new Promise<void>((resolve) => {
|
||||
child.on('exit', () => {
|
||||
exited = true
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
// Why: a spawn failure emits `error` instead of `exit`; reject active RPCs
|
||||
// immediately so a missing executable cannot wait for the deadline.
|
||||
child.on('error', (error) => {
|
||||
spawnError = error
|
||||
exited = true
|
||||
failPending(error)
|
||||
})
|
||||
// Why: close guarantees the stderr tail is complete before classifying an
|
||||
// old CLI as unsupported rather than as a transient early exit.
|
||||
child.on('close', () => failPending(buildEarlyExitError()))
|
||||
// Why: JSONL may contain non-ASCII paths; stream decoding must retain a
|
||||
// multibyte character split across pipe chunks.
|
||||
child.stderr.setEncoding('utf8').on('data', (chunk: string) => {
|
||||
stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_BYTES)
|
||||
})
|
||||
// Why: the child can exit between the liveness check and stdin.write; an
|
||||
// EPIPE must reject the RPC instead of becoming an unhandled stream error.
|
||||
child.stdin.on('error', (error) => failPending(error))
|
||||
child.stdout.setEncoding('utf8').on('data', (chunk: string) => {
|
||||
stdoutBuffer += chunk
|
||||
if (Buffer.byteLength(stdoutBuffer) > STDOUT_LINE_MAX_BYTES) {
|
||||
child.kill('SIGKILL')
|
||||
failPending(new Error('codex app-server emitted an oversized JSONL response'))
|
||||
return
|
||||
}
|
||||
let newlineIndex
|
||||
while ((newlineIndex = stdoutBuffer.indexOf('\n')) !== -1) {
|
||||
const line = stdoutBuffer.slice(0, newlineIndex).trim()
|
||||
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1)
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
let message: JsonRpcResponse
|
||||
try {
|
||||
message = JSON.parse(line) as JsonRpcResponse
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (typeof message.id === 'number' && pending.has(message.id)) {
|
||||
const waiter = pending.get(message.id)!
|
||||
pending.delete(message.id)
|
||||
waiter.resolve(message)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const deadline = setTimeout(() => {
|
||||
timedOut = true
|
||||
child.kill('SIGKILL')
|
||||
failPending(
|
||||
new CodexAppServerTimeoutError(
|
||||
`codex app-server session exceeded ${invocation.timeoutMs}ms (${invocation.command})`
|
||||
)
|
||||
)
|
||||
}, invocation.timeoutMs)
|
||||
|
||||
const requestRpc: CodexAppServerRequestRpc = async (method, params) => {
|
||||
if (spawnError) {
|
||||
throw spawnError
|
||||
}
|
||||
if (timedOut) {
|
||||
throw new CodexAppServerTimeoutError('codex app-server session already timed out')
|
||||
}
|
||||
if (exited) {
|
||||
throw buildEarlyExitError()
|
||||
}
|
||||
const id = nextRequestId++
|
||||
const response = await new Promise<JsonRpcResponse>((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject })
|
||||
try {
|
||||
child.stdin.write(`${JSON.stringify({ method, id, ...(params ? { params } : {}) })}\n`)
|
||||
} catch (error) {
|
||||
pending.delete(id)
|
||||
reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
if (response.error) {
|
||||
if (
|
||||
response.error.code === JSON_RPC_METHOD_NOT_FOUND ||
|
||||
/method not found/i.test(response.error.message ?? '')
|
||||
) {
|
||||
throw new CodexAppServerUnsupportedError(
|
||||
`codex app-server does not support ${method}: ${response.error.message ?? 'method not found'}`
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`codex app-server ${method} failed: ${response.error.message ?? 'unknown error'}`
|
||||
)
|
||||
}
|
||||
return response.result
|
||||
}
|
||||
|
||||
try {
|
||||
await requestRpc('initialize', {
|
||||
clientInfo: { name: 'orca_desktop', title: 'Orca', version: '0.0.0' }
|
||||
})
|
||||
child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`)
|
||||
return await run(requestRpc)
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
!(error instanceof CodexAppServerUnsupportedError) &&
|
||||
!(error instanceof CodexAppServerTimeoutError) &&
|
||||
stderrIndicatesMissingAppServer(stderrTail)
|
||||
) {
|
||||
throw new CodexAppServerUnsupportedError(
|
||||
`codex CLI does not support the app-server subcommand: ${stderrTail.trim().slice(0, 400)}`
|
||||
)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
try {
|
||||
child.stdin.end()
|
||||
} catch {
|
||||
// stdin may already be destroyed after a kill; reaping below still runs.
|
||||
}
|
||||
if (!exited) {
|
||||
await waitForProcessExitUntil(exitPromise, 1500)
|
||||
if (!exited) {
|
||||
child.kill('SIGKILL')
|
||||
await waitForProcessExitUntil(exitPromise, 1000)
|
||||
}
|
||||
}
|
||||
clearTimeout(deadline)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
_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
|
||||
@@ -86,6 +87,7 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rebaseInternals.setSessionRunnerSync(null)
|
||||
rmSync(fakeHomeDir, { recursive: true, force: true })
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
if (previousUserDataPath === undefined) {
|
||||
@@ -312,6 +314,44 @@ describe('ensureRealHomeCodexHookState (install)', () => {
|
||||
})
|
||||
|
||||
describe('ensureRealHomeCodexHookState (opt-out sweep)', () => {
|
||||
it('rebases trust when a user appended hooks after Orca installed', () => {
|
||||
grantSucceeds()
|
||||
const before = { type: 'command', command: 'before.sh' }
|
||||
writeFileSync(
|
||||
getRealHooksJsonPath(),
|
||||
`${JSON.stringify({ hooks: { Stop: [{ hooks: [before] }] } }, null, 2)}\n`
|
||||
)
|
||||
ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })
|
||||
const installed = readRealHooksJson()
|
||||
const after = { type: 'command', command: 'after.sh' }
|
||||
installed.hooks!.Stop!.push({ hooks: [after] })
|
||||
writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`)
|
||||
const operations: string[] = []
|
||||
rebaseInternals.setSessionRunnerSync((request) => {
|
||||
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(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('removes only Orca entries and reports the removed lane', () => {
|
||||
grantSucceeds()
|
||||
const userStop = {
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
statSync,
|
||||
unlinkSync
|
||||
} from 'node:fs'
|
||||
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { writeFileAtomically } from '../codex-accounts/fs-utils'
|
||||
import {
|
||||
@@ -20,6 +12,7 @@ import {
|
||||
type HookDefinition,
|
||||
type HooksConfig
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path'
|
||||
import { getCodexManagedScriptFileName } from './codex-hook-identity'
|
||||
import {
|
||||
CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS,
|
||||
@@ -30,6 +23,8 @@ import { removeCodexManagedHookTrustEntries } from './codex-managed-trust-reconc
|
||||
import { getCodexManagedHookInstallMaterial } from './hook-service'
|
||||
import { getSystemCodexHomePath } from './codex-home-paths'
|
||||
import type { CodexTrustEntry } from './config-toml-trust'
|
||||
import { restoreCodexTrustConfig } from './codex-trust-config-rollback'
|
||||
import { mutateRealHomeHooksPreservingUserTrust } from './codex-user-hook-trust-rebase'
|
||||
|
||||
/**
|
||||
* Real-home Codex hook lane for the system-default selection (flag ON).
|
||||
@@ -53,12 +48,12 @@ export function getRealHomeCodexHookLane(): RealHomeCodexHookLane {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Routing gate consumed by CodexRuntimeHomeService. Both a failed install and
|
||||
* a failed opt-out cleanup use the managed lane so no half-mutated hook state
|
||||
* can diverge from PTY, rate-limit, or commit-message routing.
|
||||
*/
|
||||
export function isRealHomeCodexHookLaneUsable(hooksEnabled: boolean): boolean {
|
||||
return !hooksEnabled || currentLane !== 'unavailable'
|
||||
export function isRealHomeCodexHookLaneUsable(_hooksEnabled: boolean): boolean {
|
||||
return currentLane !== 'unavailable'
|
||||
}
|
||||
|
||||
function getRealHomeHooksJsonPath(): string {
|
||||
@@ -69,35 +64,24 @@ function getRealHomeConfigTomlPath(): string {
|
||||
return join(getSystemCodexHomePath(), 'config.toml')
|
||||
}
|
||||
|
||||
function resolveRealHomeHooksWritePath(hooksJsonPath: string): string {
|
||||
let isSymlink = false
|
||||
try {
|
||||
isSymlink = lstatSync(hooksJsonPath).isSymbolicLink()
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return hooksJsonPath
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (!isSymlink) {
|
||||
return hooksJsonPath
|
||||
}
|
||||
try {
|
||||
// Why: replacing the link itself would silently disconnect a user's
|
||||
// dotfiles-managed hooks.json. Atomic writes belong at its real target.
|
||||
return realpathSync.native(hooksJsonPath)
|
||||
} catch (error) {
|
||||
throw new Error(`Could not resolve symlinked Codex hooks file ${hooksJsonPath}`, {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Orca-side state dir; nothing extra is ever written into the user's ~/.codex. */
|
||||
function getRealHomeHookStateDir(userDataPath: string): string {
|
||||
return join(userDataPath, 'codex-real-home-hooks')
|
||||
}
|
||||
|
||||
function assertHooksJsonGeneration(
|
||||
hooksJsonPath: string,
|
||||
hooksWritePath: string,
|
||||
expectedRaw: string | null
|
||||
): void {
|
||||
const currentRaw = existsSync(hooksJsonPath) ? readFileSync(hooksJsonPath, 'utf-8') : null
|
||||
if (currentRaw !== expectedRaw || resolveHooksJsonWritePath(hooksJsonPath) !== hooksWritePath) {
|
||||
// Why: the pre-mutation RPC can overlap a user's editor save. Abort rather
|
||||
// than atomically replacing a newer file with the stale parsed snapshot.
|
||||
throw new Error('Codex hooks.json changed while Orca prepared its trust repair')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the real-home hook state matches the settings: installs and trusts
|
||||
* the Orca status hook when enabled, sweeps it when opted out. Idempotent and
|
||||
@@ -123,7 +107,7 @@ export function ensureRealHomeCodexHookState(args: {
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[codex-real-home-hooks] ensure failed; staying on managed lane:', error)
|
||||
currentLane = args.hooksEnabled ? 'unavailable' : currentLane
|
||||
currentLane = 'unavailable'
|
||||
if (args.hooksEnabled) {
|
||||
installRetryAfterMs = Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
|
||||
}
|
||||
@@ -134,7 +118,7 @@ export function ensureRealHomeCodexHookState(args: {
|
||||
function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane {
|
||||
const material = getCodexManagedHookInstallMaterial()
|
||||
const hooksJsonPath = getRealHomeHooksJsonPath()
|
||||
const hooksWritePath = resolveRealHomeHooksWritePath(hooksJsonPath)
|
||||
const hooksWritePath = resolveHooksJsonWritePath(hooksJsonPath)
|
||||
const config = readHooksJson(hooksJsonPath)
|
||||
if (!config) {
|
||||
// Why: an unparseable user file must never be clobbered; without a hook
|
||||
@@ -183,8 +167,19 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane {
|
||||
backupRealHomeHooksJsonOnce(userDataPath, previousRaw)
|
||||
// Why: unknown top-level fields belong to the user (other managers'
|
||||
// metadata); unlike the managed-home writer, preserve them verbatim.
|
||||
writeHooksJson(hooksWritePath, { ...config, hooks: nextHooks } as HooksConfig, {
|
||||
preserveMode: true
|
||||
const trustConfigSnapshot = mutateRealHomeHooksPreservingUserTrust({
|
||||
sourcePath: hooksJsonPath,
|
||||
runtimeHomePath: getSystemCodexHomePath(),
|
||||
tomlPath: getRealHomeConfigTomlPath(),
|
||||
beforeHooks: config.hooks ?? {},
|
||||
afterHooks: nextHooks,
|
||||
writeHooks: () => {
|
||||
assertHooksJsonGeneration(hooksJsonPath, hooksWritePath, previousRaw)
|
||||
writeHooksJson(hooksWritePath, { ...config, hooks: nextHooks } as HooksConfig, {
|
||||
preserveMode: true
|
||||
})
|
||||
},
|
||||
restoreHooks: () => restoreRealHomeHooksJson(hooksWritePath, previousRaw, previousMode)
|
||||
})
|
||||
|
||||
const grant = grantManagedCodexHookTrust({
|
||||
@@ -202,7 +197,15 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane {
|
||||
// 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(hooksWritePath, previousRaw, previousMode)
|
||||
try {
|
||||
restoreRealHomeHooksJson(hooksWritePath, previousRaw, previousMode)
|
||||
} finally {
|
||||
// Why: a user-trust rebase may have succeeded before the managed grant
|
||||
// failed. Roll both files back to the same pre-mutation generation.
|
||||
if (trustConfigSnapshot) {
|
||||
restoreCodexTrustConfig(getRealHomeConfigTomlPath(), trustConfigSnapshot)
|
||||
}
|
||||
}
|
||||
installRetryAfterMs = getInstallRetryAfterMs(grant.reason)
|
||||
console.warn(
|
||||
`[codex-real-home-hooks] trust grant unavailable (${grant.reason}); entry rolled back, managed lane kept`
|
||||
@@ -284,14 +287,28 @@ function sweepRealHomeCodexHook(): RealHomeCodexHookLane {
|
||||
}
|
||||
}
|
||||
if (removedAny) {
|
||||
writeHooksJson(
|
||||
resolveRealHomeHooksWritePath(hooksJsonPath),
|
||||
{
|
||||
...config,
|
||||
hooks: nextHooks
|
||||
} as HooksConfig,
|
||||
{ preserveMode: true }
|
||||
)
|
||||
const hooksWritePath = resolveHooksJsonWritePath(hooksJsonPath)
|
||||
const previousRaw = readFileSync(hooksJsonPath, 'utf-8')
|
||||
const previousMode = statSync(hooksWritePath).mode
|
||||
mutateRealHomeHooksPreservingUserTrust({
|
||||
sourcePath: hooksJsonPath,
|
||||
runtimeHomePath: getSystemCodexHomePath(),
|
||||
tomlPath: getRealHomeConfigTomlPath(),
|
||||
beforeHooks: config.hooks,
|
||||
afterHooks: nextHooks,
|
||||
writeHooks: () => {
|
||||
assertHooksJsonGeneration(hooksJsonPath, hooksWritePath, previousRaw)
|
||||
writeHooksJson(
|
||||
hooksWritePath,
|
||||
{
|
||||
...config,
|
||||
hooks: nextHooks
|
||||
} as HooksConfig,
|
||||
{ preserveMode: true }
|
||||
)
|
||||
},
|
||||
restoreHooks: () => restoreRealHomeHooksJson(hooksWritePath, previousRaw, previousMode)
|
||||
})
|
||||
// Why: dead [hooks.state] blocks for a removed hook are Orca-owned records;
|
||||
// dropping them keeps the user's config.toml from accumulating orphans.
|
||||
// Verify ownership by the expected hash or grant ledger: stale/mixed hook
|
||||
@@ -333,19 +350,15 @@ function restoreRealHomeHooksJson(
|
||||
previousRaw: string | null,
|
||||
previousMode?: number
|
||||
): void {
|
||||
try {
|
||||
if (previousRaw === null) {
|
||||
if (existsSync(hooksJsonPath)) {
|
||||
unlinkSync(hooksJsonPath)
|
||||
}
|
||||
return
|
||||
if (previousRaw === null) {
|
||||
if (existsSync(hooksJsonPath)) {
|
||||
unlinkSync(hooksJsonPath)
|
||||
}
|
||||
// Why: rollback is part of the safety boundary. Use the shared atomic
|
||||
// writer so Windows file-lock retries and failed-temp cleanup are covered.
|
||||
writeFileAtomically(hooksJsonPath, previousRaw, { mode: previousMode })
|
||||
} catch (error) {
|
||||
console.warn('[codex-real-home-hooks] failed to roll back hooks.json:', error)
|
||||
return
|
||||
}
|
||||
// Why: rollback is part of the safety boundary. Use the shared atomic
|
||||
// writer so Windows file-lock retries and failed-temp cleanup are covered.
|
||||
writeFileAtomically(hooksJsonPath, previousRaw, { mode: previousMode })
|
||||
}
|
||||
|
||||
export const _internals = {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as CodexAppServerSession from './codex-app-server-session'
|
||||
import type { CodexAppServerRequestRpc } from './codex-app-server-session'
|
||||
|
||||
const requestRpcMock = vi.hoisted(() => vi.fn<CodexAppServerRequestRpc>())
|
||||
vi.mock('./codex-app-server-session', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof CodexAppServerSession>()
|
||||
return {
|
||||
...actual,
|
||||
runCodexAppServerSession: vi.fn((_invocation, run) => run(requestRpcMock))
|
||||
}
|
||||
})
|
||||
|
||||
import { runCodexUserHookTrustRebaseSession } from './codex-user-hook-trust-rebase-client'
|
||||
|
||||
const invocation = { command: 'codex', args: ['app-server'], timeoutMs: 1000 }
|
||||
const oldTrusted = '/home/a/.codex/hooks.json:stop:1:0'
|
||||
const oldUntrusted = '/home/a/.codex/hooks.json:stop:2:0'
|
||||
const newTrusted = '/home/a/.codex/hooks.json:stop:0:0'
|
||||
const newUntrusted = '/home/a/.codex/hooks.json:stop:1:0'
|
||||
|
||||
function listing(key: string, command: string, trustStatus: string, enabled = true) {
|
||||
return { key, command, currentHash: `sha256:${key}`, trustStatus, enabled }
|
||||
}
|
||||
|
||||
function listResult(hooks: ReturnType<typeof listing>[]) {
|
||||
return { data: [{ cwd: '/tmp', hooks }] }
|
||||
}
|
||||
|
||||
beforeEach(() => requestRpcMock.mockReset())
|
||||
|
||||
describe('Codex user hook trust rebase RPCs', () => {
|
||||
it('captures trusted, untrusted, and disabled states without writing config', async () => {
|
||||
requestRpcMock.mockResolvedValueOnce(
|
||||
listResult([
|
||||
listing(oldTrusted, 'trusted-user', 'trusted'),
|
||||
listing(oldUntrusted, 'untrusted-user', 'untrusted', false)
|
||||
])
|
||||
)
|
||||
const result = await runCodexUserHookTrustRebaseSession({
|
||||
operation: 'inspect-user-hook-trust',
|
||||
invocation,
|
||||
hooksListCwd: '/tmp',
|
||||
moves: [
|
||||
{ oldKey: oldTrusted, newKey: newTrusted, command: 'trusted-user' },
|
||||
{ oldKey: oldUntrusted, newKey: newUntrusted, command: 'untrusted-user' }
|
||||
]
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: 'inspected',
|
||||
moves: [
|
||||
expect.objectContaining({ command: 'trusted-user', wasTrusted: true, enabled: true }),
|
||||
expect.objectContaining({ command: 'untrusted-user', wasTrusted: false, enabled: false })
|
||||
]
|
||||
})
|
||||
expect(requestRpcMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clears shifted states, re-grants only prior trust, and verifies by re-list', async () => {
|
||||
const postMutation = listResult([
|
||||
listing(newTrusted, 'trusted-user', 'untrusted'),
|
||||
listing(newUntrusted, 'untrusted-user', 'trusted')
|
||||
])
|
||||
const verified = listResult([
|
||||
listing(newTrusted, 'trusted-user', 'trusted'),
|
||||
listing(newUntrusted, 'untrusted-user', 'untrusted', false)
|
||||
])
|
||||
requestRpcMock
|
||||
.mockResolvedValueOnce(postMutation)
|
||||
.mockResolvedValueOnce({ status: 'ok' })
|
||||
.mockResolvedValueOnce(verified)
|
||||
|
||||
await expect(
|
||||
runCodexUserHookTrustRebaseSession({
|
||||
operation: 'repair-user-hook-trust',
|
||||
invocation,
|
||||
hooksListCwd: '/tmp',
|
||||
moves: [
|
||||
{
|
||||
oldKey: oldTrusted,
|
||||
newKey: newTrusted,
|
||||
command: 'trusted-user',
|
||||
reportedOldKey: oldTrusted,
|
||||
wasTrusted: true,
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
oldKey: oldUntrusted,
|
||||
newKey: newUntrusted,
|
||||
command: 'untrusted-user',
|
||||
reportedOldKey: oldUntrusted,
|
||||
wasTrusted: false,
|
||||
enabled: false
|
||||
}
|
||||
]
|
||||
})
|
||||
).resolves.toEqual({ outcome: 'repaired', repaired: 1 })
|
||||
|
||||
const batch = requestRpcMock.mock.calls[1]
|
||||
expect(batch?.[0]).toBe('config/batchWrite')
|
||||
expect(batch).toBeDefined()
|
||||
const edits = (batch![1] as { edits: { value: unknown }[] }).edits
|
||||
expect(edits.some((edit) => edit.value === null)).toBe(true)
|
||||
expect(edits).toContainEqual(
|
||||
expect.objectContaining({
|
||||
value: expect.objectContaining({ trusted_hash: `sha256:${newTrusted}` })
|
||||
})
|
||||
)
|
||||
expect(edits).toContainEqual(expect.objectContaining({ value: { enabled: false } }))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
import { collectCodexHookListings, type CodexHookListing } from './codex-app-server-client'
|
||||
import { runCodexAppServerSession, type CodexAppServerInvocation } from './codex-app-server-session'
|
||||
import { normalizeHookTrustKeyForLookup } from './config-toml-trust'
|
||||
|
||||
export type CodexUserHookTrustMove = {
|
||||
oldKey: string
|
||||
newKey: string
|
||||
command: string
|
||||
}
|
||||
|
||||
export type CodexCapturedUserHookTrustMove = CodexUserHookTrustMove & {
|
||||
reportedOldKey: string
|
||||
wasTrusted: boolean
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
type CodexUserHookTrustRebaseRequestBase = {
|
||||
invocation: CodexAppServerInvocation
|
||||
hooksListCwd: string
|
||||
}
|
||||
|
||||
export type CodexUserHookTrustInspectRequest = CodexUserHookTrustRebaseRequestBase & {
|
||||
operation: 'inspect-user-hook-trust'
|
||||
moves: CodexUserHookTrustMove[]
|
||||
}
|
||||
|
||||
export type CodexUserHookTrustRepairRequest = CodexUserHookTrustRebaseRequestBase & {
|
||||
operation: 'repair-user-hook-trust'
|
||||
moves: CodexCapturedUserHookTrustMove[]
|
||||
}
|
||||
|
||||
export type CodexUserHookTrustRebaseRequest =
|
||||
| CodexUserHookTrustInspectRequest
|
||||
| CodexUserHookTrustRepairRequest
|
||||
|
||||
export type CodexUserHookTrustRebaseResult =
|
||||
| { outcome: 'inspected'; moves: CodexCapturedUserHookTrustMove[] }
|
||||
| { outcome: 'repaired'; repaired: number }
|
||||
|
||||
function matchingListings(
|
||||
listings: readonly CodexHookListing[],
|
||||
moves: readonly CodexUserHookTrustMove[],
|
||||
key: 'oldKey' | 'newKey'
|
||||
): Map<string, CodexHookListing> {
|
||||
const expected = new Map(
|
||||
moves.map((move) => [normalizeHookTrustKeyForLookup(move[key]), move.command])
|
||||
)
|
||||
return new Map(
|
||||
listings
|
||||
.filter(
|
||||
(listing) => expected.get(normalizeHookTrustKeyForLookup(listing.key)) === listing.command
|
||||
)
|
||||
.map((listing) => [normalizeHookTrustKeyForLookup(listing.key), listing])
|
||||
)
|
||||
}
|
||||
|
||||
function quotedKeyPath(key: string): string {
|
||||
const escaped = key.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
|
||||
return `hooks.state."${escaped}"`
|
||||
}
|
||||
|
||||
async function inspectUserHookTrust(
|
||||
request: CodexUserHookTrustInspectRequest
|
||||
): Promise<CodexUserHookTrustRebaseResult> {
|
||||
return runCodexAppServerSession(request.invocation, async (requestRpc) => {
|
||||
const result = await requestRpc('hooks/list', { cwds: [request.hooksListCwd] })
|
||||
const byOldKey = matchingListings(collectCodexHookListings(result), request.moves, 'oldKey')
|
||||
if (byOldKey.size !== request.moves.length) {
|
||||
throw new Error(
|
||||
`pre-mutation hooks/list reported ${byOldKey.size} of ${request.moves.length} moved user hooks`
|
||||
)
|
||||
}
|
||||
return {
|
||||
outcome: 'inspected',
|
||||
moves: request.moves.map((move) => {
|
||||
const listing = byOldKey.get(normalizeHookTrustKeyForLookup(move.oldKey))!
|
||||
return {
|
||||
...move,
|
||||
reportedOldKey: listing.key,
|
||||
wasTrusted: listing.trustStatus === 'trusted',
|
||||
enabled: listing.enabled
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function repairUserHookTrust(
|
||||
request: CodexUserHookTrustRepairRequest
|
||||
): Promise<CodexUserHookTrustRebaseResult> {
|
||||
return runCodexAppServerSession(request.invocation, async (requestRpc) => {
|
||||
const result = await requestRpc('hooks/list', { cwds: [request.hooksListCwd] })
|
||||
const byNewKey = matchingListings(collectCodexHookListings(result), request.moves, 'newKey')
|
||||
if (byNewKey.size !== request.moves.length) {
|
||||
throw new Error(
|
||||
`post-mutation hooks/list reported ${byNewKey.size} of ${request.moves.length} moved user hooks`
|
||||
)
|
||||
}
|
||||
|
||||
const keysToClear = new Set([
|
||||
...request.moves.map((move) => move.reportedOldKey),
|
||||
...Array.from(byNewKey.values(), (listing) => listing.key)
|
||||
])
|
||||
const edits: { keyPath: string; value: unknown; mergeStrategy: 'replace' }[] = Array.from(
|
||||
keysToClear,
|
||||
(key) => ({
|
||||
keyPath: quotedKeyPath(key),
|
||||
value: null,
|
||||
mergeStrategy: 'replace' as const
|
||||
})
|
||||
)
|
||||
for (const move of request.moves) {
|
||||
const listing = byNewKey.get(normalizeHookTrustKeyForLookup(move.newKey))!
|
||||
if (move.wasTrusted) {
|
||||
edits.push({
|
||||
keyPath: quotedKeyPath(listing.key),
|
||||
value: {
|
||||
trusted_hash: listing.currentHash,
|
||||
...(move.enabled ? {} : { enabled: false })
|
||||
},
|
||||
mergeStrategy: 'replace'
|
||||
})
|
||||
} else if (!move.enabled) {
|
||||
edits.push({
|
||||
keyPath: quotedKeyPath(listing.key),
|
||||
value: { enabled: false },
|
||||
mergeStrategy: 'replace'
|
||||
})
|
||||
}
|
||||
}
|
||||
await requestRpc('config/batchWrite', { edits, reloadUserConfig: true })
|
||||
|
||||
const verified = await requestRpc('hooks/list', { cwds: [request.hooksListCwd] })
|
||||
const verifiedByKey = matchingListings(
|
||||
collectCodexHookListings(verified),
|
||||
request.moves,
|
||||
'newKey'
|
||||
)
|
||||
const invalid = request.moves.find((move) => {
|
||||
const listing = verifiedByKey.get(normalizeHookTrustKeyForLookup(move.newKey))
|
||||
return (
|
||||
!listing ||
|
||||
(listing.trustStatus === 'trusted') !== move.wasTrusted ||
|
||||
listing.enabled !== move.enabled
|
||||
)
|
||||
})
|
||||
if (invalid) {
|
||||
throw new Error(`post-rebase verify failed for moved user hook ${invalid.newKey}`)
|
||||
}
|
||||
return {
|
||||
outcome: 'repaired',
|
||||
repaired: request.moves.filter((move) => move.wasTrusted).length
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function runCodexUserHookTrustRebaseSession(
|
||||
request: CodexUserHookTrustRebaseRequest
|
||||
): Promise<CodexUserHookTrustRebaseResult> {
|
||||
return request.operation === 'inspect-user-hook-trust'
|
||||
? inspectUserHookTrust(request)
|
||||
: repairUserHookTrust(request)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { HookCommandConfig, HookDefinition } from '../agent-hooks/installer-utils'
|
||||
import type { CodexUserHookTrustRebaseRequest } from './codex-user-hook-trust-rebase-client'
|
||||
|
||||
const resolveCodexCommandMock = vi.hoisted(() => vi.fn(() => process.execPath))
|
||||
vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock }))
|
||||
|
||||
import {
|
||||
_internals,
|
||||
getMovedCodexUserHookTrust,
|
||||
mutateRealHomeHooksPreservingUserTrust
|
||||
} from './codex-user-hook-trust-rebase'
|
||||
|
||||
let root: string
|
||||
let hooksPath: string
|
||||
let configPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'orca-user-hook-rebase-'))
|
||||
hooksPath = join(root, 'hooks.json')
|
||||
configPath = join(root, 'config.toml')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_internals.setSessionRunnerSync(null)
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function command(command: string): HookCommandConfig {
|
||||
return { type: 'command', command }
|
||||
}
|
||||
|
||||
describe('real-home user hook trust rebasing', () => {
|
||||
it('writes directly without reading config or spawning Codex when user positions stay stable', () => {
|
||||
const user = command('user-hook')
|
||||
const orca = command('orca-hook')
|
||||
const before = { Stop: [{ hooks: [user] }] }
|
||||
const after = { Stop: [{ hooks: [user] }, { hooks: [orca] }] }
|
||||
let wroteHooks = false
|
||||
_internals.setSessionRunnerSync(() => {
|
||||
throw new Error('stable positions must not open an app-server session')
|
||||
})
|
||||
|
||||
expect(
|
||||
mutateRealHomeHooksPreservingUserTrust({
|
||||
sourcePath: hooksPath,
|
||||
runtimeHomePath: root,
|
||||
tomlPath: configPath,
|
||||
beforeHooks: before,
|
||||
afterHooks: after,
|
||||
writeHooks: () => {
|
||||
wroteHooks = true
|
||||
},
|
||||
restoreHooks: () => {
|
||||
throw new Error('restore must not run')
|
||||
}
|
||||
})
|
||||
).toBeNull()
|
||||
expect(wroteHooks).toBe(true)
|
||||
expect(existsSync(configPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('finds multiple shifted user hooks, including a handler from a mixed group', () => {
|
||||
const orca = command('orca-hook')
|
||||
const first = command('first-user')
|
||||
const second = command('second-user')
|
||||
const mixed = command('mixed-user')
|
||||
const before: Record<string, HookDefinition[]> = {
|
||||
Stop: [{ hooks: [orca] }, { hooks: [first] }, { hooks: [second] }, { hooks: [orca, mixed] }]
|
||||
}
|
||||
const after: Record<string, HookDefinition[]> = {
|
||||
Stop: [{ hooks: [first] }, { hooks: [second] }, { hooks: [mixed] }]
|
||||
}
|
||||
|
||||
expect(getMovedCodexUserHookTrust(hooksPath, before, after)).toEqual([
|
||||
expect.objectContaining({
|
||||
command: 'first-user',
|
||||
oldKey: expect.stringContaining(':1:0'),
|
||||
newKey: expect.stringContaining(':0:0')
|
||||
}),
|
||||
expect.objectContaining({
|
||||
command: 'second-user',
|
||||
oldKey: expect.stringContaining(':2:0'),
|
||||
newKey: expect.stringContaining(':1:0')
|
||||
}),
|
||||
expect.objectContaining({
|
||||
command: 'mixed-user',
|
||||
oldKey: expect.stringContaining(':3:1'),
|
||||
newKey: expect.stringContaining(':2:0')
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('carries only previously trusted states into the repair request', () => {
|
||||
const orca = command('orca-hook')
|
||||
const trusted = command('trusted-user')
|
||||
const untrusted = command('untrusted-user')
|
||||
const before = { Stop: [{ hooks: [orca] }, { hooks: [trusted] }, { hooks: [untrusted] }] }
|
||||
const after = { Stop: [{ hooks: [trusted] }, { hooks: [untrusted] }] }
|
||||
writeFileSync(hooksPath, `${JSON.stringify({ hooks: before }, null, 2)}\n`)
|
||||
writeFileSync(configPath, '# original config\n')
|
||||
const requests: CodexUserHookTrustRebaseRequest[] = []
|
||||
_internals.setSessionRunnerSync((request) => {
|
||||
requests.push(request)
|
||||
if (request.operation === 'inspect-user-hook-trust') {
|
||||
return {
|
||||
outcome: 'inspected',
|
||||
moves: request.moves.map((move) => ({
|
||||
...move,
|
||||
reportedOldKey: move.oldKey,
|
||||
wasTrusted: move.command === 'trusted-user',
|
||||
enabled: move.command !== 'untrusted-user'
|
||||
}))
|
||||
}
|
||||
}
|
||||
return { outcome: 'repaired', repaired: 1 }
|
||||
})
|
||||
|
||||
mutateRealHomeHooksPreservingUserTrust({
|
||||
sourcePath: hooksPath,
|
||||
runtimeHomePath: root,
|
||||
tomlPath: configPath,
|
||||
beforeHooks: before,
|
||||
afterHooks: after,
|
||||
writeHooks: () => writeFileSync(hooksPath, `${JSON.stringify({ hooks: after }, null, 2)}\n`),
|
||||
restoreHooks: () => {
|
||||
throw new Error('restore must not run')
|
||||
}
|
||||
})
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
const repair = requests[1]
|
||||
expect(repair?.operation).toBe('repair-user-hook-trust')
|
||||
if (repair?.operation === 'repair-user-hook-trust') {
|
||||
expect(repair.moves).toEqual([
|
||||
expect.objectContaining({ command: 'trusted-user', wasTrusted: true, enabled: true }),
|
||||
expect.objectContaining({ command: 'untrusted-user', wasTrusted: false, enabled: false })
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it('restores both files byte-exactly when post-mutation repair fails', () => {
|
||||
const orca = command('orca-hook')
|
||||
const user = command('user-hook')
|
||||
const before = { Stop: [{ hooks: [orca] }, { hooks: [user] }] }
|
||||
const after = { Stop: [{ hooks: [user] }] }
|
||||
const originalHooks =
|
||||
'{ "hooks": { "Stop": [{"hooks":[{"type":"command","command":"orca-hook"}]},{"hooks":[{"type":"command","command":"user-hook"}]}] } }\r\n'
|
||||
const originalConfig = '# user formatting\r\nmodel = "x"\r\n'
|
||||
writeFileSync(hooksPath, originalHooks)
|
||||
writeFileSync(configPath, originalConfig)
|
||||
_internals.setSessionRunnerSync((request) => {
|
||||
if (request.operation === 'inspect-user-hook-trust') {
|
||||
return {
|
||||
outcome: 'inspected',
|
||||
moves: request.moves.map((move) => ({
|
||||
...move,
|
||||
reportedOldKey: move.oldKey,
|
||||
wasTrusted: true,
|
||||
enabled: true
|
||||
}))
|
||||
}
|
||||
}
|
||||
writeFileSync(configPath, '[hooks.state."partial"]\ntrusted_hash = "bad"\n')
|
||||
throw new Error('repair transport failed')
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
mutateRealHomeHooksPreservingUserTrust({
|
||||
sourcePath: hooksPath,
|
||||
runtimeHomePath: root,
|
||||
tomlPath: configPath,
|
||||
beforeHooks: before,
|
||||
afterHooks: after,
|
||||
writeHooks: () => writeFileSync(hooksPath, `${JSON.stringify({ hooks: after })}\n`),
|
||||
restoreHooks: () => writeFileSync(hooksPath, originalHooks)
|
||||
})
|
||||
).toThrow('repair transport failed')
|
||||
expect(readFileSync(hooksPath, 'utf-8')).toBe(originalHooks)
|
||||
expect(readFileSync(configPath, 'utf-8')).toBe(originalConfig)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { HookCommandConfig, HookDefinition } from '../agent-hooks/installer-utils'
|
||||
import { runCodexUserHookTrustRebaseSessionSync } from './codex-app-server-grant-bridge'
|
||||
import { createCodexHookTrustEntry } from './codex-hook-identity'
|
||||
import { resolveCodexTrustGrantHost } from './codex-trust-grant-host'
|
||||
import {
|
||||
captureCodexTrustConfig,
|
||||
restoreCodexTrustConfig,
|
||||
type CodexTrustConfigSnapshot
|
||||
} from './codex-trust-config-rollback'
|
||||
import { computeTrustKey, type CodexTrustEntry } from './config-toml-trust'
|
||||
import type {
|
||||
CodexUserHookTrustRebaseRequest,
|
||||
CodexUserHookTrustRebaseResult,
|
||||
CodexUserHookTrustMove
|
||||
} from './codex-user-hook-trust-rebase-client'
|
||||
|
||||
type HooksByEvent = Record<string, HookDefinition[]>
|
||||
|
||||
type RebaseSessionRunnerSync = (
|
||||
request: CodexUserHookTrustRebaseRequest
|
||||
) => CodexUserHookTrustRebaseResult
|
||||
|
||||
let runSessionSync: RebaseSessionRunnerSync = runCodexUserHookTrustRebaseSessionSync
|
||||
|
||||
function entriesByHookObject(
|
||||
sourcePath: string,
|
||||
hooksByEvent: HooksByEvent
|
||||
): Map<HookCommandConfig, CodexTrustEntry> {
|
||||
const result = new Map<HookCommandConfig, CodexTrustEntry>()
|
||||
for (const [eventName, definitions] of Object.entries(hooksByEvent)) {
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
definitions.forEach((definition, groupIndex) => {
|
||||
if (!Array.isArray(definition.hooks)) {
|
||||
return
|
||||
}
|
||||
definition.hooks.forEach((hook, handlerIndex) => {
|
||||
const entry = createCodexHookTrustEntry(
|
||||
sourcePath,
|
||||
eventName,
|
||||
groupIndex,
|
||||
handlerIndex,
|
||||
definition,
|
||||
hook
|
||||
)
|
||||
if (entry) {
|
||||
result.set(hook, entry)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function getMovedCodexUserHookTrust(
|
||||
sourcePath: string,
|
||||
beforeHooks: HooksByEvent,
|
||||
afterHooks: HooksByEvent
|
||||
): CodexUserHookTrustMove[] {
|
||||
const before = entriesByHookObject(sourcePath, beforeHooks)
|
||||
const after = entriesByHookObject(sourcePath, afterHooks)
|
||||
const moves: CodexUserHookTrustMove[] = []
|
||||
for (const [hook, oldEntry] of before) {
|
||||
const newEntry = after.get(hook)
|
||||
if (!newEntry) {
|
||||
continue
|
||||
}
|
||||
const oldKey = computeTrustKey(oldEntry)
|
||||
const newKey = computeTrustKey(newEntry)
|
||||
if (oldKey !== newKey) {
|
||||
moves.push({ oldKey, newKey, command: oldEntry.command })
|
||||
}
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
function rollbackMutation(
|
||||
restoreHooks: () => void,
|
||||
tomlPath: string,
|
||||
snapshot: CodexTrustConfigSnapshot,
|
||||
originalError: unknown
|
||||
): never {
|
||||
const rollbackErrors: unknown[] = []
|
||||
try {
|
||||
restoreHooks()
|
||||
} catch (error) {
|
||||
rollbackErrors.push(error)
|
||||
}
|
||||
try {
|
||||
restoreCodexTrustConfig(tomlPath, snapshot)
|
||||
} catch (error) {
|
||||
rollbackErrors.push(error)
|
||||
}
|
||||
if (rollbackErrors.length > 0) {
|
||||
throw new AggregateError(
|
||||
[originalError, ...rollbackErrors],
|
||||
'Failed to rebase moved user hook trust and restore the original files'
|
||||
)
|
||||
}
|
||||
throw originalError
|
||||
}
|
||||
|
||||
export function mutateRealHomeHooksPreservingUserTrust(args: {
|
||||
sourcePath: string
|
||||
runtimeHomePath: string
|
||||
tomlPath: string
|
||||
beforeHooks: HooksByEvent
|
||||
afterHooks: HooksByEvent
|
||||
writeHooks: () => void
|
||||
restoreHooks: () => void
|
||||
}): CodexTrustConfigSnapshot | null {
|
||||
const moves = getMovedCodexUserHookTrust(args.sourcePath, args.beforeHooks, args.afterHooks)
|
||||
if (moves.length === 0) {
|
||||
args.writeHooks()
|
||||
return null
|
||||
}
|
||||
const snapshot = captureCodexTrustConfig(args.tomlPath)
|
||||
|
||||
const baseRequest = resolveCodexTrustGrantHost({ kind: 'native' }).buildRequest({
|
||||
runtimeHomePath: args.runtimeHomePath,
|
||||
managedCommand: '',
|
||||
expectedTrustKeys: []
|
||||
})
|
||||
// Why: inspection happens before the write, so an unavailable RPC aborts
|
||||
// without shifting a user's positional trust key.
|
||||
const inspected: CodexUserHookTrustRebaseResult = runSessionSync({
|
||||
operation: 'inspect-user-hook-trust',
|
||||
invocation: baseRequest.invocation,
|
||||
hooksListCwd: baseRequest.hooksListCwd,
|
||||
moves
|
||||
})
|
||||
if (inspected.outcome !== 'inspected') {
|
||||
throw new Error('Unexpected Codex user hook trust inspection result')
|
||||
}
|
||||
|
||||
let hooksWritten = false
|
||||
try {
|
||||
args.writeHooks()
|
||||
hooksWritten = true
|
||||
const repaired = runSessionSync({
|
||||
operation: 'repair-user-hook-trust',
|
||||
invocation: baseRequest.invocation,
|
||||
hooksListCwd: baseRequest.hooksListCwd,
|
||||
moves: inspected.moves
|
||||
})
|
||||
if (repaired.outcome !== 'repaired') {
|
||||
throw new Error('Unexpected Codex user hook trust repair result')
|
||||
}
|
||||
return snapshot
|
||||
} catch (error) {
|
||||
if (hooksWritten) {
|
||||
return rollbackMutation(args.restoreHooks, args.tomlPath, snapshot, error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const _internals = {
|
||||
setSessionRunnerSync(runner: RebaseSessionRunnerSync | null): void {
|
||||
runSessionSync = runner ?? runCodexUserHookTrustRebaseSessionSync
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -1300,6 +1302,44 @@ describe('normalizeCodexProjectPathForRevocationLookup', () => {
|
||||
})
|
||||
|
||||
describe('removeHookTrustEntries', () => {
|
||||
it('updates a symlink target without replacing config.toml', () => {
|
||||
const targetPath = join(tmpDir, 'dotfiles-config.toml')
|
||||
const entry: CodexTrustEntry = {
|
||||
sourcePath: '/x/hooks.json',
|
||||
eventLabel: 'stop',
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: 'echo trusted'
|
||||
}
|
||||
upsertHookTrustEntries(targetPath, [entry])
|
||||
symlinkSync(targetPath, configPath)
|
||||
|
||||
removeHookTrustEntries(configPath, [computeTrustKey(entry)])
|
||||
|
||||
expect(lstatSync(configPath).isSymbolicLink()).toBe(true)
|
||||
expect(readHookTrustEntries(targetPath).has(computeTrustKey(entry))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not replace a dangling config.toml symlink', () => {
|
||||
const targetPath = join(tmpDir, 'missing-dotfiles-config.toml')
|
||||
symlinkSync(targetPath, configPath)
|
||||
|
||||
expect(() =>
|
||||
upsertHookTrustEntries(configPath, [
|
||||
{
|
||||
sourcePath: '/x/hooks.json',
|
||||
eventLabel: 'stop',
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: 'echo trusted'
|
||||
}
|
||||
])
|
||||
).toThrow()
|
||||
|
||||
expect(lstatSync(configPath).isSymbolicLink()).toBe(true)
|
||||
expect(existsSync(targetPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('is a no-op (creates no file) when the config does not exist', () => {
|
||||
removeHookTrustEntries(configPath, ['/x/hooks.json:pre_tool_use:0:0'])
|
||||
expect(existsSync(configPath)).toBe(false)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable max-lines -- Why: Codex hook trust parsing, hashing, and byte-preserving TOML edits share one fragile file-format contract; splitting would make the compatibility shim harder to audit. */
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
@@ -780,16 +781,30 @@ function isCompleteTableHeader(line: string): boolean {
|
||||
// tmp and rename. Random-suffix tmp name avoids cross-process races on
|
||||
// rapid reinstalls.
|
||||
export function writeConfigAtomically(configPath: string, contents: string): void {
|
||||
const dir = dirname(configPath)
|
||||
let writePath = configPath
|
||||
let isSymlink = false
|
||||
try {
|
||||
isSymlink = lstatSync(configPath).isSymbolicLink()
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (isSymlink) {
|
||||
// Why: atomic rename at the lexical path replaces the user's dotfiles
|
||||
// link. A dangling link must fail closed rather than be replaced.
|
||||
writePath = realpathSync.native(configPath)
|
||||
}
|
||||
const dir = dirname(writePath)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
|
||||
let renamed = false
|
||||
try {
|
||||
writeFileSync(tmpPath, contents, 'utf-8')
|
||||
if (existsSync(configPath)) {
|
||||
copyFileWithWindowsRetry(configPath, `${configPath}.bak`)
|
||||
if (existsSync(writePath)) {
|
||||
copyFileWithWindowsRetry(writePath, `${writePath}.bak`)
|
||||
}
|
||||
renameFileWithWindowsRetry(tmpPath, configPath)
|
||||
renameFileWithWindowsRetry(tmpPath, writePath)
|
||||
renamed = true
|
||||
} finally {
|
||||
if (!renamed && existsSync(tmpPath)) {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as Os from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -22,6 +30,7 @@ import {
|
||||
} from './codex-trust-grant-ledger'
|
||||
import type { CodexHookTrustGrantRequest } from './codex-app-server-client'
|
||||
import { getCodexHookTrustSignature } from './codex-hook-identity'
|
||||
import { _internals as rebaseInternals } from './codex-user-hook-trust-rebase'
|
||||
|
||||
const { getPathMock, homedirMock, resolveCodexCommandMock } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn<(name: string) => string>(),
|
||||
@@ -60,6 +69,7 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rebaseInternals.setSessionRunnerSync(null)
|
||||
trustGrantInternals.setGrantSessionRunnerSync(null)
|
||||
trustGrantInternals.resetDiagnostics()
|
||||
codexAppServerCapabilityCache.clear()
|
||||
@@ -209,6 +219,56 @@ describe('CodexHookService app-server trust grant lane', () => {
|
||||
expect(readCodexTrustGrantLedgerHome(systemHome)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a real-home symlink and rebases later user trust during flag-off cleanup', () => {
|
||||
prepareSystemHome()
|
||||
const systemHome = join(tmpHome, '.codex')
|
||||
const hooksPath = join(systemHome, 'hooks.json')
|
||||
const targetPath = join(tmpHome, 'dotfiles-hooks.json')
|
||||
const material = getCodexManagedHookInstallMaterial()
|
||||
const userHook = { type: 'command' as const, command: 'after-orca.sh' }
|
||||
writeFileSync(
|
||||
targetPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
hooks: {
|
||||
Stop: [
|
||||
{ hooks: [{ type: 'command', command: material.command }] },
|
||||
{ hooks: [userHook] }
|
||||
]
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
symlinkSync(targetPath, hooksPath)
|
||||
const operations: string[] = []
|
||||
rebaseInternals.setSessionRunnerSync((request) => {
|
||||
operations.push(request.operation)
|
||||
if (request.operation === 'inspect-user-hook-trust') {
|
||||
return {
|
||||
outcome: 'inspected',
|
||||
moves: request.moves.map((move) => ({
|
||||
...move,
|
||||
reportedOldKey: move.oldKey,
|
||||
wasTrusted: true,
|
||||
enabled: true
|
||||
}))
|
||||
}
|
||||
}
|
||||
return { outcome: 'repaired', repaired: 1 }
|
||||
})
|
||||
installCodexLikeGrantRunner()
|
||||
|
||||
expect(new CodexHookService().install().state).toBe('installed')
|
||||
|
||||
expect(lstatSync(hooksPath).isSymbolicLink()).toBe(true)
|
||||
expect(JSON.parse(readFileSync(targetPath, 'utf-8')).hooks.Stop).toEqual([
|
||||
{ hooks: [userHook] }
|
||||
])
|
||||
expect(operations).toEqual(['inspect-user-hook-trust', 'repair-user-hook-trust'])
|
||||
})
|
||||
|
||||
it('does not accept a ledger hash after the recorded Codex binary stamp changes', () => {
|
||||
prepareSystemHome()
|
||||
installCodexLikeGrantRunner()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* 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, unlinkSync } from 'node:fs'
|
||||
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'
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path'
|
||||
import { writeFileAtomically } from '../codex-accounts/fs-utils'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
readTextFileRemote,
|
||||
@@ -75,6 +77,7 @@ import {
|
||||
removeStaleWslCodexManagedHookTrustEntries
|
||||
} from './codex-managed-trust-reconciliation'
|
||||
import type { CodexTrustGrantLedgerHome } from './codex-trust-grant-ledger'
|
||||
import { mutateRealHomeHooksPreservingUserTrust } from './codex-user-hook-trust-rebase'
|
||||
|
||||
// Why: PreToolUse/PostToolUse give the dashboard a live readout of the
|
||||
// in-flight tool (name + input preview) between UserPromptSubmit and Stop.
|
||||
@@ -642,7 +645,28 @@ function cleanupLegacySystemManagedHooks(): void {
|
||||
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.
|
||||
writeHooksJson(legacyConfigPath, { ...config, hooks: nextHooks })
|
||||
const hooksWritePath = resolveHooksJsonWritePath(legacyConfigPath)
|
||||
const previousRaw = readFileSync(legacyConfigPath, 'utf-8')
|
||||
const previousMode = statSync(hooksWritePath).mode
|
||||
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 })
|
||||
},
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user