Merge remote-tracking branch 'origin/brennanb2025/codex-trust-rpc-grant' into brennanb2025/codex-real-home-phase1

This commit is contained in:
Brennan Benson
2026-07-15 15:54:58 -07:00
5 changed files with 88 additions and 16 deletions
+30 -1
View File
@@ -22,7 +22,19 @@ const config = JSON.parse(process.env.STUB_CONFIG)
require('node:fs').writeFileSync(config.pidFile, String(process.pid))
const trusted = new Set(config.hooks.filter(h => h.trustStatus === 'trusted').map(h => h.key))
let buffer = ''
function send(message) { process.stdout.write(JSON.stringify(message) + '\\n') }
function send(message) {
const serialized = Buffer.from(JSON.stringify(message) + '\\n')
if (config.scenario === 'split-unicode') {
const marker = Buffer.from('é')
const markerIndex = serialized.indexOf(marker)
if (markerIndex !== -1) {
process.stdout.write(serialized.subarray(0, markerIndex + 1))
setTimeout(() => process.stdout.write(serialized.subarray(markerIndex + 1)), 5)
return
}
}
process.stdout.write(serialized)
}
function listing() {
return {
data: [{
@@ -212,6 +224,23 @@ describe('runCodexHookTrustGrantSession', () => {
expect(result.outcome).toBe('verify-failed')
})
it('decodes JSONL when a non-ASCII hook path is split across stdout chunks', async () => {
const command = "/bin/sh '/tmp/rené/codex-hook.sh'"
const key = '/home/rené/.codex/hooks.json:session_start:0:0'
const { request } = createStubRequest({
scenario: 'split-unicode',
hooks: [{ ...managedHook(key), command }],
expectedTrustKeys: [key],
managedCommand: command,
timeoutMs: 2_000
})
await expect(runCodexHookTrustGrantSession(request)).resolves.toMatchObject({
outcome: 'granted',
entries: [{ key }]
})
})
it('throws the unsupported error class for unknown JSON-RPC methods', async () => {
const keys = ['/home/a/.codex/hooks.json:session_start:0:0']
const { request } = createStubRequest({
+6 -4
View File
@@ -178,8 +178,10 @@ export async function runCodexHookTrustGrantSession(
child.on('close', () => {
failPending(buildEarlyExitError())
})
child.stderr.on('data', (chunk: Buffer) => {
stderrTail = (stderrTail + chunk.toString('utf8')).slice(-STDERR_TAIL_MAX_BYTES)
// 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.
@@ -188,8 +190,8 @@ export async function runCodexHookTrustGrantSession(
})
let stdoutBuffer = ''
child.stdout.on('data', (chunk: Buffer) => {
stdoutBuffer += chunk.toString('utf8')
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'))
+9 -4
View File
@@ -82,10 +82,15 @@ export function readCurrentCodexTrustGrantLedgerHome(
host: CodexTrustGrantHost
): CodexTrustGrantLedgerHome | null {
try {
return readCodexTrustGrantLedgerHomeMatchingStamp(
runtimeHomePath,
resolveCodexTrustGrantHost(host).binaryStamp
)
const home = readCodexTrustGrantLedgerHome(runtimeHomePath)
if (!home) {
// Why: fallback-only installs have no ledger. Avoid a synchronous PATH
// and version-manager scan when there is no recorded stamp to validate.
return null
}
return binaryStampsMatch(home.binary, resolveCodexTrustGrantHost(host).binaryStamp)
? home
: null
} catch {
// Why: status is diagnostic and best-effort; unreadable ledger/binary
// paths must trigger conservative self-hash handling, not throw.
@@ -21,9 +21,10 @@ import {
import type { CodexHookTrustGrantRequest } from './codex-app-server-client'
import { getCodexHookTrustSignature } from './codex-hook-identity'
const { getPathMock, homedirMock } = vi.hoisted(() => ({
const { getPathMock, homedirMock, resolveCodexCommandMock } = vi.hoisted(() => ({
getPathMock: vi.fn<(name: string) => string>(),
homedirMock: vi.fn<() => string>()
homedirMock: vi.fn<() => string>(),
resolveCodexCommandMock: vi.fn<() => string>()
}))
vi.mock('electron', () => ({ app: { getPath: getPathMock } }))
@@ -31,6 +32,7 @@ vi.mock('os', async (importOriginal) => {
const actual = await importOriginal<typeof Os>()
return { ...actual, homedir: homedirMock }
})
vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock }))
import { CodexHookService, getCodexManagedHookInstallMaterial } from './hook-service'
@@ -44,6 +46,7 @@ beforeEach(() => {
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
process.env.ORCA_USER_DATA_PATH = userDataDir
homedirMock.mockReturnValue(tmpHome)
resolveCodexCommandMock.mockReturnValue(process.execPath)
getPathMock.mockImplementation((name: string) => {
if (name === 'userData') {
return userDataDir
@@ -136,6 +139,9 @@ describe('CodexHookService app-server trust grant lane', () => {
expect(service.install().state).toBe('installed')
expect(runner).toHaveBeenCalledTimes(1)
// Why: each launch validates the binary stamp once; getStatus reuses the
// just-verified grant instead of repeating PATH/version-manager scans.
expect(resolveCodexCommandMock).toHaveBeenCalledTimes(2)
expect(readFileSync(join(managedHome, 'config.toml'))).toEqual(firstToml)
})
@@ -243,8 +249,11 @@ describe('CodexHookService app-server trust grant lane', () => {
const runner = vi.fn()
trustGrantInternals.setGrantSessionRunnerSync(runner)
expect(new CodexHookService().install().state).toBe('installed')
const service = new CodexHookService()
expect(service.install().state).toBe('installed')
expect(service.getStatus().state).toBe('installed')
expect(runner).not.toHaveBeenCalled()
expect(resolveCodexCommandMock).not.toHaveBeenCalled()
})
it('restores exact config bytes before fallback after a mutating RPC failure', () => {
+31 -4
View File
@@ -1097,6 +1097,12 @@ export class CodexHookService {
}
getStatus(): AgentHookInstallStatus {
return this.getStatusAfterInstall(null)
}
private getStatusAfterInstall(
recentGrantEntries: readonly CodexTrustEntry[] | null
): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
const config = readHooksJson(configPath)
@@ -1130,9 +1136,21 @@ export class CodexHookService {
// Why: RPC-granted entries store Codex's own hash, which is authoritative
// even when it differs from computeTrustedHash — that difference is the
// drift bug class this lane exists to absorb, not a stale entry.
const ledgerHome = readCurrentCodexTrustGrantLedgerHome(getOrcaManagedCodexHomePath(), {
kind: 'native'
})
// Why: install() already resolved the binary and either verified Codex's
// hashes or wrote fallback hashes. Re-resolving PATH here doubles sync launch work.
const ledgerHome =
recentGrantEntries === null
? readCurrentCodexTrustGrantLedgerHome(getOrcaManagedCodexHomePath(), { kind: 'native' })
: null
const recentGrantHashes = new Map<string, { signature: string; trustedHash: string }>()
for (const entry of recentGrantEntries ?? []) {
if (entry.trustedHash) {
recentGrantHashes.set(normalizeHookTrustKeyForLookup(computeTrustKey(entry)), {
signature: getCodexHookTrustSignature(entry),
trustedHash: entry.trustedHash
})
}
}
const missing: string[] = []
const trustMissing: string[] = []
@@ -1183,6 +1201,13 @@ export class CodexHookService {
if (grantedHash) {
validHashes.add(grantedHash)
}
const recentGrant = recentGrantHashes.get(normalizeHookTrustKeyForLookup(trustKey))
if (
recentGrant?.signature === getCodexHookTrustSignature(trustInput) &&
recentGrant.trustedHash
) {
validHashes.add(recentGrant.trustedHash)
}
const actualState = trustEntries.get(trustKey)
if (!actualState?.trustedHash || !validHashes.has(actualState.trustedHash)) {
trustMissing.push(eventName)
@@ -1310,6 +1335,7 @@ export class CodexHookService {
})
}
const trustEntries: CodexTrustEntry[] = [...mirroredTrustEntries, ...managedTrustEntries]
let recentGrantEntries: readonly CodexTrustEntry[] = []
config.hooks = nextHooks
writeManagedScript(scriptPath, getManagedScript())
@@ -1334,6 +1360,7 @@ export class CodexHookService {
host: { kind: 'native' }
})
if (grant.lane === 'rpc') {
recentGrantEntries = grant.entries
upsertHookTrustEntries(tomlPath, mirroredTrustEntries)
removeStaleRuntimeHookTrustEntries(tomlPath, configPath, [
...mirroredTrustEntries,
@@ -1365,7 +1392,7 @@ export class CodexHookService {
} catch (error) {
console.warn('[codex-hook-service] failed to clean legacy Codex hooks', error)
}
return this.getStatus()
return this.getStatusAfterInstall(recentGrantEntries)
}
async installRemote(