Manage the Codex app-server binary in OpenClaw (#71808)

* Manage Codex app-server binary

* Use plugin deps for Codex app-server binary

* Stabilize media model registry test

* Exclude checkpoint transcripts from memory ingestion
This commit is contained in:
pash-openai
2026-04-25 16:51:14 -07:00
committed by GitHub
parent fc334cda13
commit edb618c6c4
21 changed files with 537 additions and 33 deletions

View File

@@ -57,10 +57,7 @@
"enum": ["stdio", "websocket"],
"default": "stdio"
},
"command": {
"type": "string",
"default": "codex"
},
"command": { "type": "string" },
"args": {
"oneOf": [
{
@@ -132,7 +129,7 @@
},
"appServer.command": {
"label": "Command",
"help": "Executable used for stdio transport.",
"help": "Executable used for stdio transport. Leave unset to use OpenClaw's managed Codex binary.",
"advanced": true
},
"appServer.args": {

View File

@@ -5,6 +5,7 @@
"type": "module",
"dependencies": {
"@mariozechner/pi-coding-agent": "0.70.2",
"@openai/codex": "0.125.0",
"ajv": "^8.18.0",
"ws": "^8.20.0",
"zod": "^4.3.6"

View File

@@ -1,6 +1,7 @@
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
import { embeddedAgentLog, OPENCLAW_VERSION } from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveCodexAppServerRuntimeOptions, type CodexAppServerStartOptions } from "./config.js";
import { MIN_CODEX_APP_SERVER_VERSION } from "./version.js";
import {
type CodexAppServerRequestMethod,
type CodexAppServerRequestParams,
@@ -22,7 +23,7 @@ import {
type CodexAppServerTransport,
} from "./transport.js";
export const MIN_CODEX_APP_SERVER_VERSION = "0.125.0";
export { MIN_CODEX_APP_SERVER_VERSION } from "./version.js";
const CODEX_APP_SERVER_PARSE_LOG_MAX = 500;
type PendingRequest = {
@@ -99,6 +100,9 @@ export class CodexAppServerClient {
...options,
headers: options?.headers ?? defaults.headers,
};
if (startOptions.transport === "stdio" && startOptions.commandSource === "managed") {
throw new Error("Managed Codex app-server start options must be resolved before spawn.");
}
if (startOptions.transport === "websocket") {
return new CodexAppServerClient(createWebSocketTransport(startOptions));
}
@@ -407,12 +411,12 @@ function assertSupportedCodexAppServerVersion(response: CodexInitializeResponse)
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.`,
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required, but OpenClaw could not determine the running Codex version. Update the configured Codex app-server binary, or remove custom command overrides to use the managed binary.`,
);
}
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.`,
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required, but detected ${detectedVersion}. Update the configured Codex app-server binary, or remove custom command overrides to use the managed binary.`,
);
}
}

View File

@@ -96,6 +96,36 @@ describe("Codex app-server config", () => {
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
start: expect.objectContaining({
command: "codex",
commandSource: "managed",
}),
}),
);
});
it("treats configured and environment commands as explicit overrides", () => {
expect(
resolveCodexAppServerRuntimeOptions({
pluginConfig: { appServer: { command: "/opt/codex/bin/codex" } },
env: { OPENCLAW_CODEX_APP_SERVER_BIN: "/usr/local/bin/codex" },
}).start,
).toEqual(
expect.objectContaining({
command: "/opt/codex/bin/codex",
commandSource: "config",
}),
);
expect(
resolveCodexAppServerRuntimeOptions({
pluginConfig: {},
env: { OPENCLAW_CODEX_APP_SERVER_BIN: "/usr/local/bin/codex" },
}).start,
).toEqual(
expect.objectContaining({
command: "/usr/local/bin/codex",
commandSource: "env",
}),
);
});
@@ -244,6 +274,7 @@ describe("Codex app-server config", () => {
};
const appServerProperties = manifest.configSchema.properties.appServer.properties;
expect(appServerProperties.command?.default).toBeUndefined();
expect(appServerProperties.approvalPolicy?.default).toBeUndefined();
expect(appServerProperties.sandbox?.default).toBeUndefined();
expect(appServerProperties.approvalsReviewer?.default).toBeUndefined();

View File

@@ -7,10 +7,12 @@ export type CodexAppServerPolicyMode = "yolo" | "guardian";
export type CodexAppServerApprovalPolicy = "never" | "on-request" | "on-failure" | "untrusted";
export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "danger-full-access";
export type CodexAppServerApprovalsReviewer = "user" | "auto_review" | "guardian_subagent";
export type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "env";
export type CodexAppServerStartOptions = {
transport: CodexAppServerTransportMode;
command: string;
commandSource?: CodexAppServerCommandSource;
args: string[];
url?: string;
authToken?: string;
@@ -125,8 +127,14 @@ export function resolveCodexAppServerRuntimeOptions(
const env = params.env ?? process.env;
const config = readCodexPluginConfig(params.pluginConfig).appServer ?? {};
const transport = resolveTransport(config.transport);
const command =
readNonEmptyString(config.command) ?? env.OPENCLAW_CODEX_APP_SERVER_BIN ?? "codex";
const configCommand = readNonEmptyString(config.command);
const envCommand = readNonEmptyString(env.OPENCLAW_CODEX_APP_SERVER_BIN);
const command = configCommand ?? envCommand ?? "codex";
const commandSource: CodexAppServerCommandSource = configCommand
? "config"
: envCommand
? "env"
: "managed";
const args = resolveArgs(config.args, env.OPENCLAW_CODEX_APP_SERVER_ARGS);
const headers = normalizeHeaders(config.headers);
const authToken = readNonEmptyString(config.authToken);
@@ -146,6 +154,7 @@ export function resolveCodexAppServerRuntimeOptions(
start: {
transport,
command,
commandSource,
args: args.length > 0 ? args : ["app-server", "--listen", "stdio://"],
...(url ? { url } : {}),
...(authToken ? { authToken } : {}),
@@ -174,6 +183,7 @@ export function codexAppServerStartOptionsKey(
return JSON.stringify({
transport: options.transport,
command: options.command,
commandSource: options.commandSource ?? null,
args: options.args,
url: options.url ?? null,
authToken: hashSecretForKey(options.authToken),

View File

@@ -0,0 +1,95 @@
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { CodexAppServerStartOptions } from "./config.js";
import {
resolveManagedCodexAppServerPaths,
resolveManagedCodexAppServerStartOptions,
} from "./managed-binary.js";
function startOptions(
commandSource: CodexAppServerStartOptions["commandSource"],
): CodexAppServerStartOptions {
return {
transport: "stdio",
command: "codex",
commandSource,
args: ["app-server", "--listen", "stdio://"],
headers: {},
};
}
function managedCommandPath(root: string, platform: NodeJS.Platform): string {
return path.join(root, "node_modules", ".bin", platform === "win32" ? "codex.cmd" : "codex");
}
describe("managed Codex app-server binary", () => {
it("leaves explicit command overrides unchanged", async () => {
const explicitOptions = startOptions("config");
const pathExists = vi.fn(async () => false);
await expect(
resolveManagedCodexAppServerStartOptions(explicitOptions, {
platform: "darwin",
pathExists,
}),
).resolves.toBe(explicitOptions);
expect(pathExists).not.toHaveBeenCalled();
});
it("resolves the plugin-local bundled Codex binary", async () => {
const pluginRoot = path.join("/tmp", "openclaw", "extensions", "codex");
const paths = resolveManagedCodexAppServerPaths({ platform: "darwin", pluginRoot });
const pathExists = vi.fn(async (filePath: string) => filePath === paths.commandPath);
await expect(
resolveManagedCodexAppServerStartOptions(startOptions("managed"), {
platform: "darwin",
pluginRoot,
pathExists,
}),
).resolves.toEqual({
...startOptions("managed"),
command: paths.commandPath,
commandSource: "resolved-managed",
});
expect(paths.commandPath).toBe(managedCommandPath(pluginRoot, "darwin"));
});
it("resolves Windows Codex command shims", () => {
const pluginRoot = path.win32.join("C:\\", "OpenClaw", "dist", "extensions", "codex");
const paths = resolveManagedCodexAppServerPaths({ platform: "win32", pluginRoot });
expect(paths.commandPath.endsWith(path.win32.join("node_modules", ".bin", "codex.cmd"))).toBe(
true,
);
});
it("finds Codex in the external runtime-deps install root used by packaged plugins", async () => {
const installRoot = path.join("/tmp", "openclaw-runtime-deps", "codex");
const pluginRoot = path.join(installRoot, "dist", "extensions", "codex");
const installedCommand = managedCommandPath(installRoot, "linux");
const pathExists = vi.fn(async (filePath: string) => filePath === installedCommand);
await expect(
resolveManagedCodexAppServerStartOptions(startOptions("managed"), {
platform: "linux",
pluginRoot,
pathExists,
}),
).resolves.toEqual({
...startOptions("managed"),
command: installedCommand,
commandSource: "resolved-managed",
});
});
it("fails clearly when bundled runtime deps did not stage Codex", async () => {
await expect(
resolveManagedCodexAppServerStartOptions(startOptions("managed"), {
platform: "darwin",
pluginRoot: path.join("/tmp", "openclaw", "extensions", "codex"),
pathExists: vi.fn(async () => false),
}),
).rejects.toThrow("Managed Codex app-server binary was not found");
});
});

View File

@@ -0,0 +1,121 @@
import { constants as fsConstants } from "node:fs";
import { access } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { CodexAppServerStartOptions } from "./config.js";
import { MANAGED_CODEX_APP_SERVER_PACKAGE } from "./version.js";
const CODEX_PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
type ManagedCodexAppServerPaths = {
commandPath: string;
candidateCommandPaths: string[];
};
export type ResolveManagedCodexAppServerOptions = {
platform?: NodeJS.Platform;
pluginRoot?: string;
pathExists?: (filePath: string, platform: NodeJS.Platform) => Promise<boolean>;
};
export async function resolveManagedCodexAppServerStartOptions(
startOptions: CodexAppServerStartOptions,
options: ResolveManagedCodexAppServerOptions = {},
): Promise<CodexAppServerStartOptions> {
if (startOptions.transport !== "stdio" || startOptions.commandSource !== "managed") {
return startOptions;
}
const platform = options.platform ?? process.platform;
const paths = resolveManagedCodexAppServerPaths({
platform,
pluginRoot: options.pluginRoot,
});
const pathExists = options.pathExists ?? commandPathExists;
const commandPath = await findManagedCodexAppServerCommandPath({
candidateCommandPaths: paths.candidateCommandPaths,
pathExists,
platform,
});
return {
...startOptions,
command: commandPath,
commandSource: "resolved-managed",
};
}
export function resolveManagedCodexAppServerPaths(params: {
platform?: NodeJS.Platform;
pluginRoot?: string;
}): ManagedCodexAppServerPaths {
const platform = params.platform ?? process.platform;
const candidateCommandPaths = resolveManagedCodexAppServerCommandCandidates(
params.pluginRoot ?? CODEX_PLUGIN_ROOT,
platform,
);
return {
commandPath: candidateCommandPaths[0] ?? "",
candidateCommandPaths,
};
}
function resolveManagedCodexAppServerCommandCandidates(
pluginRoot: string,
platform: NodeJS.Platform,
): string[] {
const pathApi = pathForPlatform(platform);
const commandName = platform === "win32" ? "codex.cmd" : "codex";
const roots = [
pluginRoot,
pathApi.dirname(pluginRoot),
pathApi.dirname(pathApi.dirname(pluginRoot)),
isDistExtensionRoot(pluginRoot, platform)
? pathApi.dirname(pathApi.dirname(pathApi.dirname(pluginRoot)))
: null,
].filter((root): root is string => Boolean(root));
return [...new Set(roots.map((root) => pathApi.join(root, "node_modules", ".bin", commandName)))];
}
function isDistExtensionRoot(pluginRoot: string, platform: NodeJS.Platform): boolean {
const pathApi = pathForPlatform(platform);
const extensionsDir = pathApi.dirname(pluginRoot);
const distDir = pathApi.dirname(extensionsDir);
return (
pathApi.basename(extensionsDir) === "extensions" &&
(pathApi.basename(distDir) === "dist" || pathApi.basename(distDir) === "dist-runtime")
);
}
function pathForPlatform(platform: NodeJS.Platform): typeof path {
return platform === "win32" ? path.win32 : path.posix;
}
async function findManagedCodexAppServerCommandPath(params: {
candidateCommandPaths: readonly string[];
pathExists: (filePath: string, platform: NodeJS.Platform) => Promise<boolean>;
platform: NodeJS.Platform;
}): Promise<string> {
for (const commandPath of params.candidateCommandPaths) {
if (await params.pathExists(commandPath, params.platform)) {
return commandPath;
}
}
throw new Error(
[
`Managed Codex app-server binary was not found for ${MANAGED_CODEX_APP_SERVER_PACKAGE}.`,
"Run OpenClaw with bundled plugin runtime dependencies enabled, or run pnpm install in a source checkout.",
"Set plugins.entries.codex.config.appServer.command or OPENCLAW_CODEX_APP_SERVER_BIN to use a custom Codex binary.",
].join(" "),
);
}
async function commandPathExists(filePath: string, platform: NodeJS.Platform): Promise<boolean> {
try {
await access(filePath, platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK);
return true;
} catch {
return false;
}
}

View File

@@ -7,10 +7,13 @@ const mocks = vi.hoisted(() => {
applyAuthProfile: vi.fn(async () => undefined),
startOptions: vi.fn(async ({ startOptions }) => startOptions),
};
const managedBinary = {
startOptions: vi.fn(async (startOptions) => startOptions),
};
const providerAuth = {
agentDir: vi.fn(() => "/tmp/openclaw-agent"),
};
return { authBridge, providerAuth };
return { authBridge, managedBinary, providerAuth };
});
vi.mock("./auth-bridge.js", () => ({
@@ -18,6 +21,10 @@ vi.mock("./auth-bridge.js", () => ({
bridgeCodexAppServerStartOptions: mocks.authBridge.startOptions,
}));
vi.mock("./managed-binary.js", () => ({
resolveManagedCodexAppServerStartOptions: mocks.managedBinary.startOptions,
}));
vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
resolveOpenClawAgentDir: mocks.providerAuth.agentDir,
}));
@@ -38,6 +45,8 @@ describe("listCodexAppServerModels", () => {
vi.restoreAllMocks();
mocks.authBridge.applyAuthProfile.mockClear();
mocks.authBridge.startOptions.mockClear();
mocks.managedBinary.startOptions.mockClear();
mocks.managedBinary.startOptions.mockImplementation(async (startOptions) => startOptions);
mocks.providerAuth.agentDir.mockClear();
});

View File

@@ -6,6 +6,8 @@ import { createClientHarness } from "./test-support.js";
const mocks = vi.hoisted(() => ({
bridgeCodexAppServerStartOptions: vi.fn(async ({ startOptions }) => startOptions),
applyCodexAppServerAuthProfile: vi.fn(async () => undefined),
resolveManagedCodexAppServerStartOptions: vi.fn(async (startOptions) => startOptions),
embeddedAgentLog: { debug: vi.fn(), warn: vi.fn() },
resolveOpenClawAgentDir: vi.fn(() => "/tmp/openclaw-agent"),
}));
@@ -14,6 +16,15 @@ vi.mock("./auth-bridge.js", () => ({
bridgeCodexAppServerStartOptions: mocks.bridgeCodexAppServerStartOptions,
}));
vi.mock("./managed-binary.js", () => ({
resolveManagedCodexAppServerStartOptions: mocks.resolveManagedCodexAppServerStartOptions,
}));
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", () => ({
embeddedAgentLog: mocks.embeddedAgentLog,
OPENCLAW_VERSION: "test",
}));
vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
resolveOpenClawAgentDir: mocks.resolveOpenClawAgentDir,
}));
@@ -54,6 +65,12 @@ describe("shared Codex app-server client", () => {
vi.restoreAllMocks();
mocks.bridgeCodexAppServerStartOptions.mockClear();
mocks.applyCodexAppServerAuthProfile.mockClear();
mocks.resolveManagedCodexAppServerStartOptions.mockClear();
mocks.resolveManagedCodexAppServerStartOptions.mockImplementation(
async (startOptions) => startOptions,
);
mocks.embeddedAgentLog.debug.mockClear();
mocks.embeddedAgentLog.warn.mockClear();
mocks.resolveOpenClawAgentDir.mockClear();
});
@@ -128,6 +145,42 @@ describe("shared Codex app-server client", () => {
);
});
it("resolves the managed binary before bridging and spawning the shared client", async () => {
const harness = createClientHarness();
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
mocks.resolveManagedCodexAppServerStartOptions.mockImplementationOnce(async (startOptions) => ({
...startOptions,
command: "/cache/openclaw/codex",
commandSource: "resolved-managed",
}));
const listPromise = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(harness);
await expect(listPromise).resolves.toEqual({ models: [] });
expect(mocks.resolveManagedCodexAppServerStartOptions).toHaveBeenCalledWith(
expect.objectContaining({
command: "codex",
commandSource: "managed",
}),
);
expect(mocks.bridgeCodexAppServerStartOptions).toHaveBeenCalledWith(
expect.objectContaining({
startOptions: expect.objectContaining({
command: "/cache/openclaw/codex",
commandSource: "resolved-managed",
}),
}),
);
expect(startSpy).toHaveBeenCalledWith(
expect.objectContaining({
command: "/cache/openclaw/codex",
commandSource: "resolved-managed",
}),
);
});
it("restarts the shared client when the bridged auth token changes", async () => {
const first = createClientHarness();
const second = createClientHarness();

View File

@@ -6,6 +6,7 @@ import {
resolveCodexAppServerRuntimeOptions,
type CodexAppServerStartOptions,
} from "./config.js";
import { resolveManagedCodexAppServerStartOptions } from "./managed-binary.js";
import { withTimeout } from "./timeout.js";
type SharedCodexAppServerClientState = {
@@ -30,9 +31,13 @@ export async function getSharedCodexAppServerClient(options?: {
authProfileId?: string;
}): Promise<CodexAppServerClient> {
const state = getSharedCodexAppServerClientState();
const agentDir = resolveOpenClawAgentDir();
const requestedStartOptions =
options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
const managedStartOptions = await resolveManagedCodexAppServerStartOptions(requestedStartOptions);
const startOptions = await bridgeCodexAppServerStartOptions({
startOptions: options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start,
agentDir: resolveOpenClawAgentDir(),
startOptions: managedStartOptions,
agentDir,
authProfileId: options?.authProfileId,
});
const key = codexAppServerStartOptionsKey(startOptions, {
@@ -52,7 +57,7 @@ export async function getSharedCodexAppServerClient(options?: {
await client.initialize();
await applyCodexAppServerAuthProfile({
client,
agentDir: resolveOpenClawAgentDir(),
agentDir,
authProfileId: options?.authProfileId,
});
return client;
@@ -82,9 +87,13 @@ export async function createIsolatedCodexAppServerClient(options?: {
timeoutMs?: number;
authProfileId?: string;
}): Promise<CodexAppServerClient> {
const agentDir = resolveOpenClawAgentDir();
const requestedStartOptions =
options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
const managedStartOptions = await resolveManagedCodexAppServerStartOptions(requestedStartOptions);
const startOptions = await bridgeCodexAppServerStartOptions({
startOptions: options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start,
agentDir: resolveOpenClawAgentDir(),
startOptions: managedStartOptions,
agentDir,
authProfileId: options?.authProfileId,
});
const client = CodexAppServerClient.start(startOptions);
@@ -93,7 +102,7 @@ export async function createIsolatedCodexAppServerClient(options?: {
await withTimeout(initialize, options?.timeoutMs ?? 0, "codex app-server initialize timed out");
await applyCodexAppServerAuthProfile({
client,
agentDir: resolveOpenClawAgentDir(),
agentDir,
authProfileId: options?.authProfileId,
});
return client;

View File

@@ -44,6 +44,22 @@ describe("resolveCodexAppServerSpawnInvocation", () => {
});
});
it("requires managed Codex commands to be resolved before spawn", () => {
expect(() =>
resolveCodexAppServerSpawnInvocation(
{
...startOptions("codex"),
commandSource: "managed",
},
{
platform: "darwin",
env: {},
execPath: "/usr/local/bin/node",
},
),
).toThrow("must be resolved before spawn");
});
it("resolves Windows npm .cmd Codex shims through Node instead of raw spawn", async () => {
const binDir = await createTempDir();
const entryPath = path.join(binDir, "node_modules", "@openai", "codex", "bin", "codex.js");

View File

@@ -22,6 +22,9 @@ export function resolveCodexAppServerSpawnInvocation(
options: CodexAppServerStartOptions,
runtime: CodexAppServerSpawnRuntime = DEFAULT_SPAWN_RUNTIME,
): { command: string; args: string[]; shell?: boolean; windowsHide?: boolean } {
if (options.commandSource === "managed") {
throw new Error("Managed Codex app-server start options must be resolved before spawn.");
}
const program = resolveWindowsSpawnProgram({
command: options.command,
platform: runtime.platform,

View File

@@ -0,0 +1,3 @@
export const MIN_CODEX_APP_SERVER_VERSION = "0.125.0";
export const MANAGED_CODEX_APP_SERVER_PACKAGE = "@openai/codex";
export const MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION = MIN_CODEX_APP_SERVER_VERSION;

View File

@@ -1,5 +1,6 @@
import fs from "node:fs";
import { describe, expect, it } from "vitest";
import { MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION } from "./app-server/version.js";
type CodexPackageManifest = {
dependencies?: Record<string, string>;
@@ -17,6 +18,9 @@ describe("codex package manifest", () => {
) as CodexPackageManifest;
expect(packageJson.dependencies?.["@mariozechner/pi-coding-agent"]).toBeDefined();
expect(packageJson.dependencies?.["@openai/codex"]).toBe(
MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION,
);
expect(packageJson.openclaw?.bundle?.stageRuntimeDependencies).toBe(true);
});
});