Files
openclaw/extensions/codex/src/app-server/client.ts
2026-04-10 23:29:58 +01:00

367 lines
11 KiB
TypeScript

import { createInterface, type Interface as ReadlineInterface } from "node:readline";
import { embeddedAgentLog, OPENCLAW_VERSION } from "openclaw/plugin-sdk/agent-harness";
import { resolveCodexAppServerRuntimeOptions, type CodexAppServerStartOptions } from "./config.js";
import {
type CodexInitializeResponse,
isRpcResponse,
type CodexServerNotification,
type JsonValue,
type RpcMessage,
type RpcRequest,
type RpcResponse,
} from "./protocol.js";
import { createStdioTransport } from "./transport-stdio.js";
import { createWebSocketTransport } from "./transport-websocket.js";
import { closeCodexAppServerTransport, type CodexAppServerTransport } from "./transport.js";
export const MIN_CODEX_APP_SERVER_VERSION = "0.118.0";
type PendingRequest = {
method: string;
resolve: (value: unknown) => void;
reject: (error: Error) => void;
};
export class CodexAppServerRpcError extends Error {
readonly code?: number;
readonly data?: JsonValue;
constructor(error: { code?: number; message: string; data?: JsonValue }, method: string) {
super(error.message || `${method} failed`);
this.name = "CodexAppServerRpcError";
this.code = error.code;
this.data = error.data;
}
}
export type CodexServerRequestHandler = (
request: Required<Pick<RpcRequest, "id" | "method">> & { params?: JsonValue },
) => Promise<JsonValue | undefined> | JsonValue | undefined;
export type CodexServerNotificationHandler = (
notification: CodexServerNotification,
) => Promise<void> | void;
export class CodexAppServerClient {
private readonly child: CodexAppServerTransport;
private readonly lines: ReadlineInterface;
private readonly pending = new Map<number | string, PendingRequest>();
private readonly requestHandlers = new Set<CodexServerRequestHandler>();
private readonly notificationHandlers = new Set<CodexServerNotificationHandler>();
private readonly closeHandlers = new Set<(client: CodexAppServerClient) => void>();
private nextId = 1;
private initialized = false;
private closed = false;
private constructor(child: CodexAppServerTransport) {
this.child = child;
this.lines = createInterface({ input: child.stdout });
this.lines.on("line", (line) => this.handleLine(line));
child.stderr.on("data", (chunk: Buffer | string) => {
const text = chunk.toString("utf8").trim();
if (text) {
embeddedAgentLog.debug(`codex app-server stderr: ${text}`);
}
});
child.once("error", (error) =>
this.closeWithError(error instanceof Error ? error : new Error(String(error))),
);
child.once("exit", (code, signal) => {
this.closeWithError(
new Error(
`codex app-server exited: code=${formatExitValue(code)} signal=${formatExitValue(signal)}`,
),
);
});
}
static start(options?: Partial<CodexAppServerStartOptions>): CodexAppServerClient {
const defaults = resolveCodexAppServerRuntimeOptions().start;
const startOptions = {
...defaults,
...options,
headers: options?.headers ?? defaults.headers,
};
if (startOptions.transport === "websocket") {
return new CodexAppServerClient(createWebSocketTransport(startOptions));
}
return new CodexAppServerClient(createStdioTransport(startOptions));
}
static fromTransportForTests(child: CodexAppServerTransport): CodexAppServerClient {
return new CodexAppServerClient(child);
}
async initialize(): Promise<void> {
if (this.initialized) {
return;
}
// The handshake identifies the exact app-server process we will keep using,
// which matters when callers override the binary or app-server args.
const response = await this.request<CodexInitializeResponse>("initialize", {
clientInfo: {
name: "openclaw",
title: "OpenClaw",
version: OPENCLAW_VERSION,
},
capabilities: {
experimentalApi: true,
},
});
assertSupportedCodexAppServerVersion(response);
this.notify("initialized");
this.initialized = true;
}
request<T = JsonValue | undefined>(method: string, params?: JsonValue): Promise<T> {
if (this.closed) {
return Promise.reject(new Error("codex app-server client is closed"));
}
const id = this.nextId++;
const message: RpcRequest = { id, method, params };
return new Promise<T>((resolve, reject) => {
this.pending.set(id, {
method,
resolve: (value) => resolve(value as T),
reject,
});
this.writeMessage(message);
});
}
notify(method: string, params?: JsonValue): void {
this.writeMessage({ method, params });
}
addRequestHandler(handler: CodexServerRequestHandler): () => void {
this.requestHandlers.add(handler);
return () => this.requestHandlers.delete(handler);
}
addNotificationHandler(handler: CodexServerNotificationHandler): () => void {
this.notificationHandlers.add(handler);
return () => this.notificationHandlers.delete(handler);
}
addCloseHandler(handler: (client: CodexAppServerClient) => void): () => void {
this.closeHandlers.add(handler);
return () => this.closeHandlers.delete(handler);
}
close(): void {
if (this.closed) {
return;
}
this.closed = true;
this.lines.close();
this.rejectPendingRequests(new Error("codex app-server client is closed"));
closeCodexAppServerTransport(this.child);
}
private writeMessage(message: RpcRequest | RpcResponse): void {
this.child.stdin.write(`${JSON.stringify(message)}\n`);
}
private handleLine(line: string): void {
const trimmed = line.trim();
if (!trimmed) {
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch (error) {
embeddedAgentLog.warn("failed to parse codex app-server message", { error });
return;
}
if (!parsed || typeof parsed !== "object") {
return;
}
const message = parsed as RpcMessage;
if (isRpcResponse(message)) {
this.handleResponse(message);
return;
}
if (!("method" in message)) {
return;
}
if ("id" in message && message.id !== undefined) {
void this.handleServerRequest({
id: message.id,
method: message.method,
params: message.params,
});
return;
}
this.handleNotification({
method: message.method,
params: message.params,
});
}
private handleResponse(response: RpcResponse): void {
const pending = this.pending.get(response.id);
if (!pending) {
return;
}
this.pending.delete(response.id);
if (response.error) {
pending.reject(new CodexAppServerRpcError(response.error, pending.method));
return;
}
pending.resolve(response.result);
}
private async handleServerRequest(
request: Required<Pick<RpcRequest, "id" | "method">> & { params?: JsonValue },
): Promise<void> {
try {
for (const handler of this.requestHandlers) {
const result = await handler(request);
if (result !== undefined) {
this.writeMessage({ id: request.id, result });
return;
}
}
this.writeMessage({ id: request.id, result: defaultServerRequestResponse(request) });
} catch (error) {
this.writeMessage({
id: request.id,
error: {
message: error instanceof Error ? error.message : String(error),
},
});
}
}
private handleNotification(notification: CodexServerNotification): void {
for (const handler of this.notificationHandlers) {
Promise.resolve(handler(notification)).catch((error: unknown) => {
embeddedAgentLog.warn("codex app-server notification handler failed", { error });
});
}
}
private closeWithError(error: Error): void {
if (this.closed) {
return;
}
this.closed = true;
this.rejectPendingRequests(error);
}
private rejectPendingRequests(error: Error): void {
for (const pending of this.pending.values()) {
pending.reject(error);
}
this.pending.clear();
for (const handler of this.closeHandlers) {
handler(this);
}
}
}
export function defaultServerRequestResponse(
request: Required<Pick<RpcRequest, "id" | "method">> & { params?: JsonValue },
): JsonValue {
if (request.method === "item/tool/call") {
return {
contentItems: [
{
type: "inputText",
text: "OpenClaw did not register a handler for this app-server tool call.",
},
],
success: false,
};
}
if (
request.method === "item/commandExecution/requestApproval" ||
request.method === "item/fileChange/requestApproval"
) {
return { decision: "decline" };
}
if (request.method === "item/permissions/requestApproval") {
return { permissions: {}, scope: "turn" };
}
if (isCodexAppServerApprovalRequest(request.method)) {
return {
decision: "decline",
reason: "OpenClaw codex app-server bridge does not grant native approvals yet.",
};
}
if (request.method === "item/tool/requestUserInput") {
return {
answers: {},
};
}
if (request.method === "mcpServer/elicitation/request") {
return {
action: "decline",
};
}
return {};
}
function assertSupportedCodexAppServerVersion(response: CodexInitializeResponse): void {
const detectedVersion = readCodexVersionFromUserAgent(response.userAgent);
if (!detectedVersion) {
throw new Error(
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required, but OpenClaw could not determine the running Codex version. Upgrade Codex CLI and retry.`,
);
}
if (compareVersions(detectedVersion, MIN_CODEX_APP_SERVER_VERSION) < 0) {
throw new Error(
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required, but detected ${detectedVersion}. Upgrade Codex CLI and retry.`,
);
}
}
export function readCodexVersionFromUserAgent(userAgent: string | undefined): string | undefined {
// Codex returns `<originator>/<codex-version> ...`; the originator can be
// OpenClaw or an env override, so only the slash-delimited version is stable.
const match = userAgent?.match(/^[^/\s]+\/(\d+\.\d+\.\d+(?:[-+][^\s()]*)?)/);
return match?.[1];
}
function compareVersions(left: string, right: string): number {
const leftParts = numericVersionParts(left);
const rightParts = numericVersionParts(right);
for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
const leftPart = leftParts[index] ?? 0;
const rightPart = rightParts[index] ?? 0;
if (leftPart !== rightPart) {
return leftPart < rightPart ? -1 : 1;
}
}
return 0;
}
function numericVersionParts(version: string): number[] {
// Pre-release/build tags do not affect our minimum gate; 0.118.0-dev should
// satisfy the same protocol floor as 0.118.0.
return version
.split(/[+-]/, 1)[0]
.split(".")
.map((part) => Number.parseInt(part, 10))
.map((part) => (Number.isFinite(part) ? part : 0));
}
export function isCodexAppServerApprovalRequest(method: string): boolean {
return method.includes("requestApproval") || method.includes("Approval");
}
function formatExitValue(value: unknown): string {
if (value === null || value === undefined) {
return "null";
}
if (typeof value === "string" || typeof value === "number") {
return String(value);
}
return "unknown";
}
export const __testing = {
closeCodexAppServerTransport,
} as const;