mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 17:51:22 +00:00
511 lines
14 KiB
TypeScript
511 lines
14 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
|
|
import { embeddedAgentLog, OPENCLAW_VERSION } from "openclaw/plugin-sdk/agent-harness";
|
|
import {
|
|
isRpcResponse,
|
|
type CodexServerNotification,
|
|
type JsonObject,
|
|
type JsonValue,
|
|
type RpcMessage,
|
|
type RpcRequest,
|
|
type RpcResponse,
|
|
} from "./protocol.js";
|
|
|
|
type PendingRequest = {
|
|
method: string;
|
|
resolve: (value: unknown) => void;
|
|
reject: (error: Error) => void;
|
|
};
|
|
|
|
type CodexAppServerTransport = {
|
|
stdin: { write: (data: string) => unknown };
|
|
stdout: NodeJS.ReadableStream;
|
|
stderr: NodeJS.ReadableStream;
|
|
killed?: boolean;
|
|
kill?: () => unknown;
|
|
once: (event: string, listener: (...args: unknown[]) => void) => unknown;
|
|
};
|
|
|
|
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 type CodexAppServerModel = {
|
|
id: string;
|
|
model: string;
|
|
displayName?: string;
|
|
description?: string;
|
|
hidden?: boolean;
|
|
isDefault?: boolean;
|
|
inputModalities: string[];
|
|
supportedReasoningEfforts: string[];
|
|
defaultReasoningEffort?: string;
|
|
};
|
|
|
|
export type CodexAppServerModelListResult = {
|
|
models: CodexAppServerModel[];
|
|
nextCursor?: string;
|
|
};
|
|
|
|
export type CodexAppServerListModelsOptions = {
|
|
limit?: number;
|
|
cursor?: string;
|
|
includeHidden?: boolean;
|
|
timeoutMs?: number;
|
|
};
|
|
|
|
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 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(): CodexAppServerClient {
|
|
const bin = process.env.OPENCLAW_CODEX_APP_SERVER_BIN?.trim() || "codex";
|
|
const extraArgs = splitShellWords(process.env.OPENCLAW_CODEX_APP_SERVER_ARGS ?? "");
|
|
const args = extraArgs.length > 0 ? extraArgs : ["app-server", "--listen", "stdio://"];
|
|
const child = spawn(bin, args, {
|
|
env: process.env,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
return new CodexAppServerClient(child);
|
|
}
|
|
|
|
static fromTransportForTests(child: CodexAppServerTransport): CodexAppServerClient {
|
|
return new CodexAppServerClient(child);
|
|
}
|
|
|
|
async initialize(): Promise<void> {
|
|
if (this.initialized) {
|
|
return;
|
|
}
|
|
await this.request("initialize", {
|
|
clientInfo: {
|
|
name: "openclaw",
|
|
title: "OpenClaw",
|
|
version: OPENCLAW_VERSION,
|
|
},
|
|
capabilities: {
|
|
experimentalApi: true,
|
|
},
|
|
});
|
|
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);
|
|
}
|
|
|
|
close(): void {
|
|
this.closed = true;
|
|
this.lines.close();
|
|
if (!this.child.killed) {
|
|
this.child.kill?.();
|
|
}
|
|
}
|
|
|
|
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 Error(response.error.message || `${pending.method} failed`));
|
|
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;
|
|
for (const pending of this.pending.values()) {
|
|
pending.reject(error);
|
|
}
|
|
this.pending.clear();
|
|
clearSharedClientIfCurrent(this);
|
|
}
|
|
}
|
|
|
|
let sharedClient: CodexAppServerClient | undefined;
|
|
let sharedClientPromise: Promise<CodexAppServerClient> | undefined;
|
|
|
|
export async function getSharedCodexAppServerClient(): Promise<CodexAppServerClient> {
|
|
sharedClientPromise ??= (async () => {
|
|
const client = CodexAppServerClient.start();
|
|
sharedClient = client;
|
|
await client.initialize();
|
|
return client;
|
|
})();
|
|
try {
|
|
return await sharedClientPromise;
|
|
} catch (error) {
|
|
sharedClient = undefined;
|
|
sharedClientPromise = undefined;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function resetSharedCodexAppServerClientForTests(): void {
|
|
sharedClient = undefined;
|
|
sharedClientPromise = undefined;
|
|
}
|
|
|
|
export function clearSharedCodexAppServerClient(): void {
|
|
const client = sharedClient;
|
|
sharedClient = undefined;
|
|
sharedClientPromise = undefined;
|
|
client?.close();
|
|
}
|
|
|
|
function clearSharedClientIfCurrent(client: CodexAppServerClient): void {
|
|
if (sharedClient !== client) {
|
|
return;
|
|
}
|
|
sharedClient = undefined;
|
|
sharedClientPromise = undefined;
|
|
}
|
|
|
|
export async function listCodexAppServerModels(
|
|
options: CodexAppServerListModelsOptions = {},
|
|
): Promise<CodexAppServerModelListResult> {
|
|
const timeoutMs = options.timeoutMs ?? 2500;
|
|
return await withTimeout(
|
|
(async () => {
|
|
const client = await getSharedCodexAppServerClient();
|
|
const response = await client.request<JsonObject>("model/list", {
|
|
limit: options.limit ?? null,
|
|
cursor: options.cursor ?? null,
|
|
includeHidden: options.includeHidden ?? null,
|
|
});
|
|
return readModelListResult(response);
|
|
})(),
|
|
timeoutMs,
|
|
"codex app-server model/list timed out",
|
|
);
|
|
}
|
|
|
|
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 === "execCommandApproval" || request.method === "applyPatchApproval") {
|
|
return { decision: "denied" };
|
|
}
|
|
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 readModelListResult(value: JsonValue | undefined): CodexAppServerModelListResult {
|
|
if (!isJsonObjectValue(value) || !Array.isArray(value.data)) {
|
|
return { models: [] };
|
|
}
|
|
const models = value.data
|
|
.map((entry) => readCodexModel(entry))
|
|
.filter((entry): entry is CodexAppServerModel => entry !== undefined);
|
|
const nextCursor = typeof value.nextCursor === "string" ? value.nextCursor : undefined;
|
|
return { models, ...(nextCursor ? { nextCursor } : {}) };
|
|
}
|
|
|
|
function readCodexModel(value: unknown): CodexAppServerModel | undefined {
|
|
if (!isJsonObjectValue(value)) {
|
|
return undefined;
|
|
}
|
|
const id = readNonEmptyString(value.id);
|
|
const model = readNonEmptyString(value.model) ?? id;
|
|
if (!id || !model) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
id,
|
|
model,
|
|
...(readNonEmptyString(value.displayName)
|
|
? { displayName: readNonEmptyString(value.displayName) }
|
|
: {}),
|
|
...(readNonEmptyString(value.description)
|
|
? { description: readNonEmptyString(value.description) }
|
|
: {}),
|
|
...(typeof value.hidden === "boolean" ? { hidden: value.hidden } : {}),
|
|
...(typeof value.isDefault === "boolean" ? { isDefault: value.isDefault } : {}),
|
|
inputModalities: readStringArray(value.inputModalities),
|
|
supportedReasoningEfforts: readReasoningEfforts(value.supportedReasoningEfforts),
|
|
...(readNonEmptyString(value.defaultReasoningEffort)
|
|
? { defaultReasoningEffort: readNonEmptyString(value.defaultReasoningEffort) }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
function readReasoningEfforts(value: unknown): string[] {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
const efforts = value
|
|
.map((entry) => {
|
|
if (!isJsonObjectValue(entry)) {
|
|
return undefined;
|
|
}
|
|
return readNonEmptyString(entry.reasoningEffort);
|
|
})
|
|
.filter((entry): entry is string => entry !== undefined);
|
|
return [...new Set(efforts)];
|
|
}
|
|
|
|
function readStringArray(value: unknown): string[] {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
return [
|
|
...new Set(
|
|
value
|
|
.map((entry) => readNonEmptyString(entry))
|
|
.filter((entry): entry is string => entry !== undefined),
|
|
),
|
|
];
|
|
}
|
|
|
|
function readNonEmptyString(value: unknown): string | undefined {
|
|
if (typeof value !== "string") {
|
|
return undefined;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed || undefined;
|
|
}
|
|
|
|
function isJsonObjectValue(value: unknown): value is JsonObject {
|
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
}
|
|
|
|
async function withTimeout<T>(
|
|
promise: Promise<T>,
|
|
timeoutMs: number,
|
|
timeoutMessage: string,
|
|
): Promise<T> {
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
return await promise;
|
|
}
|
|
let timeout: NodeJS.Timeout | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
promise,
|
|
new Promise<never>((_, reject) => {
|
|
timeout = setTimeout(() => reject(new Error(timeoutMessage)), Math.max(1, timeoutMs));
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function isCodexAppServerApprovalRequest(method: string): boolean {
|
|
return method.includes("requestApproval") || method.includes("Approval");
|
|
}
|
|
|
|
function splitShellWords(value: string): string[] {
|
|
const words: string[] = [];
|
|
let current = "";
|
|
let quote: '"' | "'" | null = null;
|
|
for (const char of value) {
|
|
if (quote) {
|
|
if (char === quote) {
|
|
quote = null;
|
|
} else {
|
|
current += char;
|
|
}
|
|
continue;
|
|
}
|
|
if (char === '"' || char === "'") {
|
|
quote = char;
|
|
continue;
|
|
}
|
|
if (/\s/.test(char)) {
|
|
if (current) {
|
|
words.push(current);
|
|
current = "";
|
|
}
|
|
continue;
|
|
}
|
|
current += char;
|
|
}
|
|
if (current) {
|
|
words.push(current);
|
|
}
|
|
return words;
|
|
}
|
|
|
|
function formatExitValue(value: unknown): string {
|
|
if (value === null || value === undefined) {
|
|
return "null";
|
|
}
|
|
if (typeof value === "string" || typeof value === "number") {
|
|
return String(value);
|
|
}
|
|
return "unknown";
|
|
}
|