mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 00:02:05 +00:00
365 lines
13 KiB
TypeScript
365 lines
13 KiB
TypeScript
import { spawnProcess } from '../../shared/child-process/run-process'
|
|
import type { ChildProcessHandle, ProcessSpec } from '../../shared/child-process/process-spec'
|
|
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
import { waitForProcessExitUntil } from './codex-process-exit-deadline'
|
|
import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-signal'
|
|
import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
|
|
|
|
// Why: `codex app-server` is Orca's sanctioned RPC surface into Codex-owned
|
|
// state (hook trust hashes, the sqlite thread index). This module owns the
|
|
// stdio JSONL transport — spawn, handshake, framing, deadline, reap — so every
|
|
// RPC consumer (trust grant, session index heal) shares one hardened lifecycle.
|
|
|
|
export type CodexAppServerInvocation = {
|
|
command: string
|
|
args: string[]
|
|
/**
|
|
* The resolved CLI path, used to pair the CLI with the `node` it was installed
|
|
* against — without it a CLI resolved out of a version-manager directory runs
|
|
* under whatever node leads PATH and dies on a NODE_MODULE_VERSION mismatch
|
|
* (stablyai/orca#10932).
|
|
*
|
|
* Required, and `null` only for a guest-side launcher (wsl.exe) where the host
|
|
* path means nothing. Optional would let a native builder omit it and silently
|
|
* fall back to pairing against a cmd.exe wrapper with no type error.
|
|
*/
|
|
cliPath: string | null
|
|
/** Overlay applied on top of the inherited environment (e.g. CODEX_HOME). */
|
|
env?: Record<string, string>
|
|
/** Env keys stripped from the inherited environment before spawn (e.g. an
|
|
* inherited CODEX_HOME, so a default-home grant runs against the real ~/.codex). */
|
|
envToDelete?: readonly string[]
|
|
/** Whole-session deadline. The codex child is SIGKILLed when it lapses. */
|
|
timeoutMs: number
|
|
}
|
|
|
|
/** Codex-side absence of the requested app-server RPC surface (old CLI without
|
|
* the app-server subcommand, or a server without the called methods).
|
|
* This is the ONLY error class capability caches mark 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 }
|
|
}
|
|
|
|
export type CodexAppServerRpc = {
|
|
request: (method: string, params?: Record<string, unknown>) => Promise<unknown>
|
|
notify: (method: string, params?: Record<string, unknown>) => void
|
|
}
|
|
|
|
const JSON_RPC_METHOD_NOT_FOUND = -32601
|
|
const STDERR_TAIL_MAX_BYTES = 8192
|
|
const STDOUT_LINE_MAX_BYTES = 1024 * 1024
|
|
|
|
export function killCodexAppServerProcessTree(
|
|
child: Pick<ChildProcessHandle, 'pid' | 'kill'>,
|
|
options: {
|
|
platform?: NodeJS.Platform
|
|
spawnImpl?: (program: string, args: string[], options: Record<string, unknown>) => ChildProcessHandle
|
|
} = {}
|
|
): void {
|
|
const platform = options.platform ?? process.platform
|
|
const spawnImpl =
|
|
options.spawnImpl ??
|
|
((program: string, args: string[], spawnOptions: Record<string, unknown>) =>
|
|
spawnProcess({ program, args, ...spawnOptions } as ProcessSpec))
|
|
if (platform === 'win32' && child.pid) {
|
|
try {
|
|
// Why: npm-installed Codex runs behind cmd.exe; killing only that wrapper
|
|
// leaves the app-server child alive after a timeout or failed shutdown.
|
|
const killer = spawnImpl('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
|
stdio: 'ignore',
|
|
windowsHide: true
|
|
})
|
|
let fellBack = false
|
|
const killDirectChild = (): void => {
|
|
if (!fellBack) {
|
|
fellBack = true
|
|
child.kill('SIGKILL')
|
|
}
|
|
}
|
|
killer.on('error', killDirectChild)
|
|
killer.on('exit', (code) => {
|
|
if (code !== 0) {
|
|
killDirectChild()
|
|
}
|
|
})
|
|
killer.unref()
|
|
return
|
|
} catch {
|
|
// Fall through to the direct-child best effort when taskkill cannot start.
|
|
}
|
|
}
|
|
if (child.pid) {
|
|
try {
|
|
// npm/package-manager launchers insert a shim child on POSIX. Reap its
|
|
// direct descendants before signalling the wrapper itself.
|
|
const descendants = spawnImpl('pkill', ['-KILL', '-P', String(child.pid)], {
|
|
stdio: 'ignore'
|
|
})
|
|
// A missing pkill surfaces as an async 'error' event, and an unhandled one
|
|
// takes down the main process.
|
|
descendants.on('error', () => undefined)
|
|
descendants.unref()
|
|
} catch {
|
|
// The direct kill below remains the fallback when pkill is unavailable.
|
|
}
|
|
}
|
|
child.kill('SIGKILL')
|
|
}
|
|
|
|
/** Codex answering "no such method" is the only response that proves the RPC
|
|
* surface is absent rather than temporarily failing. */
|
|
export function isCodexMethodNotFoundError(error: unknown): boolean {
|
|
if (typeof error !== 'object' || error === null) {
|
|
return false
|
|
}
|
|
const { code, message } = error as { code?: unknown; message?: unknown }
|
|
return (
|
|
code === JSON_RPC_METHOD_NOT_FOUND ||
|
|
/method not found/i.test(typeof message === 'string' ? message : '')
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Runs one short-lived `codex app-server` session over stdio JSON-RPC (JSONL):
|
|
* spawn → initialize → initialized → body(rpc) → EOF/reap. The child is reaped
|
|
* on every path; the session deadline SIGKILLs it.
|
|
*/
|
|
export async function runCodexAppServerSession<T>(
|
|
invocation: CodexAppServerInvocation,
|
|
body: (rpc: CodexAppServerRpc) => Promise<T>,
|
|
spawnImpl: (program: string, args: string[], options: Record<string, unknown>) => ChildProcessHandle =
|
|
(program, args, options) => spawnProcess({ program, args, ...options } as ProcessSpec)
|
|
): Promise<T> {
|
|
// Why: a default-home grant must run against the real ~/.codex, so strip an
|
|
// inherited CODEX_HOME (envToDelete) after applying the overlay, not before.
|
|
const childEnv: NodeJS.ProcessEnv = { ...process.env, ...invocation.env }
|
|
for (const key of invocation.envToDelete ?? []) {
|
|
delete childEnv[key]
|
|
}
|
|
const pairedEnv = invocation.cliPath
|
|
? withCliRuntimeOnPath(invocation.cliPath, childEnv)
|
|
: childEnv
|
|
const child = spawnImpl(invocation.command, invocation.args, {
|
|
env: pairedEnv,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
windowsHide: true
|
|
}) as ChildProcessWithoutNullStreams
|
|
|
|
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) {
|
|
// Why: Windows process-tree termination is asynchronous; stop buffered
|
|
// chunks from spawning another taskkill for the same oversized response.
|
|
child.stdout.destroy()
|
|
killCodexAppServerProcessTree(child)
|
|
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()
|
|
}
|
|
|
|
let rejectDeadline: (error: Error) => void = () => {}
|
|
const deadlinePromise = new Promise<never>((_resolve, reject) => {
|
|
rejectDeadline = reject
|
|
})
|
|
const deadline = setTimeout(() => {
|
|
timedOut = true
|
|
const error = new CodexAppServerTimeoutError(
|
|
`codex app-server session exceeded ${invocation.timeoutMs}ms (${invocation.command})`
|
|
)
|
|
killCodexAppServerProcessTree(child)
|
|
failPending(error)
|
|
rejectDeadline(error)
|
|
}, invocation.timeoutMs)
|
|
|
|
function sendLine(payload: Record<string, unknown>): void {
|
|
child.stdin.write(`${JSON.stringify(payload)}\n`)
|
|
}
|
|
|
|
function notify(method: string, params?: Record<string, unknown>): void {
|
|
const payload: Record<string, unknown> = { method }
|
|
if (params !== undefined) {
|
|
payload.params = params
|
|
}
|
|
try {
|
|
sendLine(payload)
|
|
} catch {
|
|
// Notifications are fire-and-forget; a dead child fails the next request.
|
|
}
|
|
}
|
|
|
|
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 (isCodexMethodNotFoundError(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 {
|
|
const session = async (): Promise<T> => {
|
|
await requestRpc('initialize', {
|
|
clientInfo: { name: 'orca_desktop', title: 'Orca', version: '0.0.0' }
|
|
})
|
|
notify('initialized')
|
|
return body({ request: requestRpc, notify })
|
|
}
|
|
// Why: the timeout owns the whole callback, including time between RPCs;
|
|
// killing the child alone cannot settle a callback awaiting unrelated work.
|
|
return await Promise.race([session(), deadlinePromise])
|
|
} 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) {
|
|
killCodexAppServerProcessTree(child)
|
|
await waitForProcessExitUntil(exitPromise, 1000)
|
|
}
|
|
}
|
|
clearTimeout(deadline)
|
|
}
|
|
}
|