mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 17:51:22 +00:00
367 lines
11 KiB
TypeScript
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;
|