|
|
|
@@ -1,13 +1,7 @@
|
|
|
|
|
import { spawn } from 'node:child_process'
|
|
|
|
|
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
|
|
|
import { normalizeHookTrustKeyForLookup } from './config-toml-trust'
|
|
|
|
|
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'
|
|
|
|
|
import { waitForProcessExitUntil } from './codex-process-exit-deadline'
|
|
|
|
|
import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-signal'
|
|
|
|
|
|
|
|
|
|
// Why: Codex gates hooks on a `trusted_hash` it computes from a private
|
|
|
|
|
// canonical-JSON identity. Orca used to replicate that algorithm
|
|
|
|
@@ -19,6 +13,15 @@ export type { CodexAppServerInvocation } from './codex-app-server-session'
|
|
|
|
|
// 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
|
|
|
|
@@ -50,15 +53,49 @@ export type CodexHookTrustGrantSessionResult =
|
|
|
|
|
}
|
|
|
|
|
| { outcome: 'verify-failed'; reason: string }
|
|
|
|
|
|
|
|
|
|
export type CodexHookListing = {
|
|
|
|
|
/** 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 = {
|
|
|
|
|
key: string
|
|
|
|
|
command: string | null
|
|
|
|
|
currentHash: string
|
|
|
|
|
trustStatus: string
|
|
|
|
|
enabled: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function collectCodexHookListings(result: unknown): CodexHookListing[] {
|
|
|
|
|
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 ?? '')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectHookListings(result: unknown): CodexHookListing[] {
|
|
|
|
|
const data =
|
|
|
|
|
result && typeof result === 'object' && Array.isArray((result as { data?: unknown }).data)
|
|
|
|
|
? ((result as { data: unknown[] }).data as { hooks?: unknown }[])
|
|
|
|
@@ -85,8 +122,7 @@ export function collectCodexHookListings(result: unknown): CodexHookListing[] {
|
|
|
|
|
key: hook.key,
|
|
|
|
|
command: typeof hook.command === 'string' ? hook.command : null,
|
|
|
|
|
currentHash: hook.currentHash,
|
|
|
|
|
trustStatus: hook.trustStatus,
|
|
|
|
|
enabled: typeof hook.enabled === 'boolean' ? hook.enabled : true
|
|
|
|
|
trustStatus: hook.trustStatus
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -103,59 +139,232 @@ export async function runCodexHookTrustGrantSession(
|
|
|
|
|
request: CodexHookTrustGrantRequest,
|
|
|
|
|
spawnImpl: typeof spawn = spawn
|
|
|
|
|
): Promise<CodexHookTrustGrantSessionResult> {
|
|
|
|
|
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))
|
|
|
|
|
const { invocation } = request
|
|
|
|
|
const child = spawnImpl(invocation.command, invocation.args, {
|
|
|
|
|
env: { ...process.env, ...invocation.env },
|
|
|
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
|
|
|
windowsHide: true
|
|
|
|
|
}) as ChildProcessWithoutNullStreams
|
|
|
|
|
|
|
|
|
|
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 stderrTail = ''
|
|
|
|
|
let exited = false
|
|
|
|
|
let nextRequestId = 1
|
|
|
|
|
let timedOut = false
|
|
|
|
|
const pending = new Map<
|
|
|
|
|
number,
|
|
|
|
|
{ resolve: (r: JsonRpcResponse) => void; reject: (e: Error) => void }
|
|
|
|
|
>()
|
|
|
|
|
|
|
|
|
|
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 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)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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`
|
|
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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: 'granted',
|
|
|
|
|
wroteTrust: needingTrust.length > 0,
|
|
|
|
|
entries: verifiedListings.map((listing) => ({
|
|
|
|
|
key: listing.key,
|
|
|
|
|
normalizedKey: normalizeHookTrustKeyForLookup(listing.key),
|
|
|
|
|
trustedHash: listing.currentHash
|
|
|
|
|
}))
|
|
|
|
|
outcome: 'verify-failed',
|
|
|
|
|
reason: `hooks/list reported ${managedListings.length} of ${expectedKeys.size} expected managed entries`
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
spawnImpl
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|