重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,55 @@
import type { AgentHarness } from "openclaw/plugin-sdk/agent-harness";
import { maybeCompactCodexAppServerSession } from "./src/app-server/compact.js";
import { listCodexAppServerModels } from "./src/app-server/models.js";
import type {
CodexAppServerListModelsOptions,
CodexAppServerModel,
CodexAppServerModelListResult,
} from "./src/app-server/models.js";
import { runCodexAppServerAttempt } from "./src/app-server/run-attempt.js";
import { clearCodexAppServerBinding } from "./src/app-server/session-binding.js";
import { clearSharedCodexAppServerClient } from "./src/app-server/shared-client.js";
const DEFAULT_CODEX_HARNESS_PROVIDER_IDS = new Set(["codex"]);
export type { CodexAppServerListModelsOptions, CodexAppServerModel, CodexAppServerModelListResult };
export { listCodexAppServerModels };
export function createCodexAppServerAgentHarness(options?: {
id?: string;
label?: string;
providerIds?: Iterable<string>;
pluginConfig?: unknown;
}): AgentHarness {
const providerIds = new Set(
[...(options?.providerIds ?? DEFAULT_CODEX_HARNESS_PROVIDER_IDS)].map((id) =>
id.trim().toLowerCase(),
),
);
return {
id: options?.id ?? "codex",
label: options?.label ?? "Codex agent harness",
supports: (ctx) => {
const provider = ctx.provider.trim().toLowerCase();
if (providerIds.has(provider)) {
return { supported: true, priority: 100 };
}
return {
supported: false,
reason: `provider is not one of: ${[...providerIds].toSorted().join(", ")}`,
};
},
runAttempt: (params) =>
runCodexAppServerAttempt(params, { pluginConfig: options?.pluginConfig }),
compact: (params) =>
maybeCompactCodexAppServerSession(params, { pluginConfig: options?.pluginConfig }),
reset: async (params) => {
if (params.sessionFile) {
await clearCodexAppServerBinding(params.sessionFile);
}
},
dispose: () => {
clearSharedCodexAppServerClient();
},
};
}

View file

@ -0,0 +1,62 @@
import fs from "node:fs";
import { describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
import { createCodexAppServerAgentHarness } from "./harness.js";
import plugin from "./index.js";
describe("codex plugin", () => {
it("is opt-in by default", () => {
const manifest = JSON.parse(
fs.readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"),
) as { enabledByDefault?: unknown };
expect(manifest.enabledByDefault).toBeUndefined();
});
it("registers the codex provider and agent harness", () => {
const registerAgentHarness = vi.fn();
const registerCommand = vi.fn();
const registerProvider = vi.fn();
plugin.register(
createTestPluginApi({
id: "codex",
name: "Codex",
source: "test",
config: {},
pluginConfig: {},
runtime: {} as never,
registerAgentHarness,
registerCommand,
registerProvider,
}),
);
expect(registerProvider.mock.calls[0]?.[0]).toMatchObject({ id: "codex", label: "Codex" });
expect(registerAgentHarness.mock.calls[0]?.[0]).toMatchObject({
id: "codex",
label: "Codex agent harness",
dispose: expect.any(Function),
});
expect(registerCommand.mock.calls[0]?.[0]).toMatchObject({
name: "codex",
description: "Inspect and control the Codex app-server harness",
});
});
it("only claims the codex provider by default", () => {
const harness = createCodexAppServerAgentHarness();
expect(
harness.supports({ provider: "codex", modelId: "gpt-5.4", requestedRuntime: "auto" })
.supported,
).toBe(true);
expect(
harness.supports({
provider: "openai-codex",
modelId: "gpt-5.4",
requestedRuntime: "auto",
}),
).toMatchObject({ supported: false });
});
});

View file

@ -0,0 +1,15 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createCodexAppServerAgentHarness } from "./harness.js";
import { buildCodexProvider } from "./provider.js";
import { createCodexCommand } from "./src/commands.js";
export default definePluginEntry({
id: "codex",
name: "Codex",
description: "Codex app-server harness and Codex-managed GPT model catalog.",
register(api) {
api.registerAgentHarness(createCodexAppServerAgentHarness({ pluginConfig: api.pluginConfig }));
api.registerProvider(buildCodexProvider({ pluginConfig: api.pluginConfig }));
api.registerCommand(createCodexCommand({ pluginConfig: api.pluginConfig }));
},
});

View file

@ -0,0 +1,161 @@
{
"id": "codex",
"name": "Codex",
"description": "Codex app-server harness and Codex-managed GPT model catalog.",
"providers": ["codex"],
"activation": {
"onAgentHarnesses": ["codex"]
},
"commandAliases": [
{
"name": "codex",
"kind": "runtime-slash",
"cliCommand": "plugins"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"discovery": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"timeoutMs": {
"type": "number",
"minimum": 1,
"default": 2500
}
}
},
"appServer": {
"type": "object",
"additionalProperties": false,
"properties": {
"transport": {
"type": "string",
"enum": ["stdio", "websocket"],
"default": "stdio"
},
"command": {
"type": "string",
"default": "codex"
},
"args": {
"oneOf": [
{
"type": "array",
"items": { "type": "string" }
},
{ "type": "string" }
]
},
"url": { "type": "string" },
"authToken": { "type": "string" },
"headers": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"requestTimeoutMs": {
"type": "number",
"minimum": 1,
"default": 60000
},
"approvalPolicy": {
"type": "string",
"enum": ["never", "on-request", "on-failure", "untrusted"],
"default": "never"
},
"sandbox": {
"type": "string",
"enum": ["read-only", "workspace-write", "danger-full-access"],
"default": "workspace-write"
},
"approvalsReviewer": {
"type": "string",
"enum": ["user", "guardian_subagent"],
"default": "user"
},
"serviceTier": { "type": "string" }
}
}
}
},
"uiHints": {
"discovery": {
"label": "Model Discovery",
"help": "Plugin-owned controls for discovering Codex app-server models."
},
"discovery.enabled": {
"label": "Enable Discovery",
"help": "When false, OpenClaw keeps the Codex harness available but uses the bundled fallback model list."
},
"discovery.timeoutMs": {
"label": "Discovery Timeout",
"help": "Maximum time to wait for Codex app-server model discovery before falling back to the bundled model list.",
"advanced": true
},
"appServer": {
"label": "App Server",
"help": "Runtime controls for connecting to Codex app-server.",
"advanced": true
},
"appServer.transport": {
"label": "Transport",
"help": "Use stdio to spawn Codex locally, or websocket to connect to an already-running app-server.",
"advanced": true
},
"appServer.command": {
"label": "Command",
"help": "Executable used for stdio transport.",
"advanced": true
},
"appServer.args": {
"label": "Arguments",
"help": "Arguments used for stdio transport. Defaults to app-server --listen stdio://.",
"advanced": true
},
"appServer.url": {
"label": "WebSocket URL",
"help": "Codex app-server WebSocket URL when transport is websocket.",
"advanced": true
},
"appServer.authToken": {
"label": "Auth Token",
"help": "Bearer token sent to the WebSocket app-server.",
"sensitive": true,
"advanced": true
},
"appServer.headers": {
"label": "Headers",
"help": "Additional headers sent to the WebSocket app-server.",
"advanced": true
},
"appServer.requestTimeoutMs": {
"label": "Request Timeout",
"help": "Maximum time to wait for Codex app-server control-plane requests.",
"advanced": true
},
"appServer.approvalPolicy": {
"label": "Approval Policy",
"help": "Codex native approval policy sent to thread start, resume, and turns.",
"advanced": true
},
"appServer.sandbox": {
"label": "Sandbox",
"help": "Codex native sandbox mode sent to thread start and resume.",
"advanced": true
},
"appServer.approvalsReviewer": {
"label": "Approvals Reviewer",
"help": "Use user approvals or the Codex guardian subagent for native app-server approvals.",
"advanced": true
},
"appServer.serviceTier": {
"label": "Service Tier",
"help": "Optional Codex service tier passed when starting or resuming threads.",
"advanced": true
}
}
}

View file

@ -0,0 +1,19 @@
{
"name": "@openclaw/codex",
"version": "2026.4.20",
"description": "OpenClaw Codex harness and model provider plugin",
"type": "module",
"dependencies": {
"@mariozechner/pi-coding-agent": "0.67.68",
"ws": "^8.20.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View file

@ -0,0 +1,179 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildCodexProvider, buildCodexProviderCatalog } from "./provider.js";
import { CodexAppServerClient } from "./src/app-server/client.js";
import {
getSharedCodexAppServerClient,
resetSharedCodexAppServerClientForTests,
} from "./src/app-server/shared-client.js";
afterEach(() => {
resetSharedCodexAppServerClientForTests();
vi.restoreAllMocks();
});
describe("codex provider", () => {
it("maps Codex app-server models to a Codex provider catalog", async () => {
const listModels = vi.fn(async () => ({
models: [
{
id: "gpt-5.4",
model: "gpt-5.4",
displayName: "gpt-5.4",
hidden: false,
inputModalities: ["text", "image"],
supportedReasoningEfforts: ["low", "medium", "high", "xhigh"],
},
{
id: "hidden-model",
model: "hidden-model",
hidden: true,
inputModalities: ["text"],
supportedReasoningEfforts: [],
},
],
}));
const result = await buildCodexProviderCatalog({
env: {},
listModels,
pluginConfig: { discovery: { timeoutMs: 1234 } },
});
expect(listModels).toHaveBeenCalledWith(
expect.objectContaining({ limit: 100, timeoutMs: 1234, sharedClient: false }),
);
expect(result.provider).toMatchObject({
auth: "token",
api: "openai-codex-responses",
models: [
{
id: "gpt-5.4",
name: "gpt-5.4",
reasoning: true,
input: ["text", "image"],
compat: { supportsReasoningEffort: true },
},
],
});
});
it("keeps a static fallback catalog when discovery is disabled", async () => {
const listModels = vi.fn();
const result = await buildCodexProviderCatalog({
env: {},
listModels,
pluginConfig: { discovery: { enabled: false } },
});
expect(listModels).not.toHaveBeenCalled();
expect(result.provider.models.map((model) => model.id)).toEqual([
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.2",
]);
});
it("keeps a static fallback catalog when live discovery is explicitly disabled by env", async () => {
const listModels = vi.fn();
const result = await buildCodexProviderCatalog({
env: { OPENCLAW_CODEX_DISCOVERY_LIVE: "0" },
listModels,
});
expect(listModels).not.toHaveBeenCalled();
expect(result.provider.models.map((model) => model.id)).toEqual([
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.2",
]);
});
it("closes the transient app-server client after live discovery", async () => {
const client = {
initialize: vi.fn(async () => undefined),
request: vi.fn(async () => ({ data: [] })),
addCloseHandler: vi.fn(() => () => undefined),
close: vi.fn(),
} as unknown as CodexAppServerClient;
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(client);
await buildCodexProviderCatalog({
env: { OPENCLAW_CODEX_DISCOVERY_LIVE: "1" },
});
expect(client.close).toHaveBeenCalledTimes(1);
});
it("does not close an active shared app-server client during live discovery", async () => {
const activeClient = {
initialize: vi.fn(async () => undefined),
request: vi.fn(async () => ({ data: [] })),
addCloseHandler: vi.fn(() => () => undefined),
close: vi.fn(),
} as unknown as CodexAppServerClient;
const discoveryClient = {
initialize: vi.fn(async () => undefined),
request: vi.fn(async () => ({ data: [] })),
addCloseHandler: vi.fn(() => () => undefined),
close: vi.fn(),
} as unknown as CodexAppServerClient;
vi.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(activeClient)
.mockReturnValueOnce(discoveryClient);
await getSharedCodexAppServerClient({ timeoutMs: 1000 });
await buildCodexProviderCatalog({
env: { OPENCLAW_CODEX_DISCOVERY_LIVE: "1" },
});
expect(activeClient.close).not.toHaveBeenCalled();
expect(discoveryClient.close).toHaveBeenCalledTimes(1);
});
it("resolves arbitrary Codex app-server model ids through the codex provider", () => {
const provider = buildCodexProvider();
const model = provider.resolveDynamicModel?.({
provider: "codex",
modelId: " custom-model ",
modelRegistry: { find: () => null },
} as never);
expect(model).toMatchObject({
id: "custom-model",
provider: "codex",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
input: ["text", "image"],
});
});
it("treats o4 ids as reasoning-capable Codex models", () => {
const provider = buildCodexProvider();
const model = provider.resolveDynamicModel?.({
provider: "codex",
modelId: "o4-mini",
modelRegistry: { find: () => null },
} as never);
expect(model).toMatchObject({
id: "o4-mini",
reasoning: true,
compat: { supportsReasoningEffort: true },
});
expect(provider.supportsXHighThinking?.({ provider: "codex", modelId: "o4-mini" })).toBe(true);
});
it("declares synthetic auth because the harness owns Codex credentials", () => {
const provider = buildCodexProvider();
expect(provider.resolveSyntheticAuth?.({ provider: "codex" })).toEqual({
apiKey: "codex-app-server",
source: "codex-app-server",
mode: "token",
});
});
});

View file

@ -0,0 +1,225 @@
import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry";
import {
normalizeModelCompat,
type ModelDefinitionConfig,
type ModelProviderConfig,
type ProviderPlugin,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
listCodexAppServerModels,
type CodexAppServerModel,
type CodexAppServerModelListResult,
} from "./harness.js";
import {
type CodexAppServerStartOptions,
readCodexPluginConfig,
resolveCodexAppServerRuntimeOptions,
} from "./src/app-server/config.js";
const PROVIDER_ID = "codex";
const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
const DEFAULT_CONTEXT_WINDOW = 272_000;
const DEFAULT_MAX_TOKENS = 128_000;
const DEFAULT_DISCOVERY_TIMEOUT_MS = 2500;
const LIVE_DISCOVERY_ENV = "OPENCLAW_CODEX_DISCOVERY_LIVE";
type CodexModelLister = (options: {
timeoutMs: number;
limit?: number;
startOptions?: CodexAppServerStartOptions;
sharedClient?: boolean;
}) => Promise<CodexAppServerModelListResult>;
type BuildCodexProviderOptions = {
pluginConfig?: unknown;
listModels?: CodexModelLister;
};
type BuildCatalogOptions = {
env?: NodeJS.ProcessEnv;
pluginConfig?: unknown;
listModels?: CodexModelLister;
};
const FALLBACK_CODEX_MODELS = [
{
id: "gpt-5.4",
model: "gpt-5.4",
displayName: "gpt-5.4",
description: "Latest frontier agentic coding model.",
isDefault: true,
inputModalities: ["text", "image"],
supportedReasoningEfforts: ["low", "medium", "high", "xhigh"],
},
{
id: "gpt-5.4-mini",
model: "gpt-5.4-mini",
displayName: "GPT-5.4-Mini",
description: "Smaller frontier agentic coding model.",
inputModalities: ["text", "image"],
supportedReasoningEfforts: ["low", "medium", "high", "xhigh"],
},
{
id: "gpt-5.2",
model: "gpt-5.2",
displayName: "gpt-5.2",
inputModalities: ["text", "image"],
supportedReasoningEfforts: ["low", "medium", "high", "xhigh"],
},
] satisfies CodexAppServerModel[];
export function buildCodexProvider(options: BuildCodexProviderOptions = {}): ProviderPlugin {
return {
id: PROVIDER_ID,
label: "Codex",
docsPath: "/providers/models",
auth: [],
catalog: {
order: "late",
run: async (ctx) =>
buildCodexProviderCatalog({
env: ctx.env,
pluginConfig: options.pluginConfig,
listModels: options.listModels,
}),
},
resolveDynamicModel: (ctx) => resolveCodexDynamicModel(ctx.modelId),
resolveSyntheticAuth: () => ({
apiKey: "codex-app-server",
source: "codex-app-server",
mode: "token",
}),
supportsXHighThinking: ({ modelId }) => isKnownXHighCodexModel(modelId),
isModernModelRef: ({ modelId }) => isModernCodexModel(modelId),
};
}
export async function buildCodexProviderCatalog(
options: BuildCatalogOptions = {},
): Promise<{ provider: ModelProviderConfig }> {
const config = readCodexPluginConfig(options.pluginConfig);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
const timeoutMs = normalizeTimeoutMs(config.discovery?.timeoutMs);
let discovered: CodexAppServerModel[] = [];
if (config.discovery?.enabled !== false && !shouldSkipLiveDiscovery(options.env)) {
discovered = await listModelsBestEffort({
listModels: options.listModels ?? listCodexAppServerModels,
timeoutMs,
startOptions: appServer.start,
});
}
const models = (discovered.length > 0 ? discovered : FALLBACK_CODEX_MODELS).map(
codexModelToDefinition,
);
return {
provider: {
baseUrl: CODEX_BASE_URL,
apiKey: "codex-app-server",
auth: "token",
api: "openai-codex-responses",
models,
},
};
}
function resolveCodexDynamicModel(modelId: string): ProviderRuntimeModel | undefined {
const id = modelId.trim();
if (!id) {
return undefined;
}
return normalizeModelCompat({
...buildModelDefinition({
id,
model: id,
inputModalities: ["text", "image"],
supportedReasoningEfforts: shouldDefaultToReasoningModel(id) ? ["medium"] : [],
}),
provider: PROVIDER_ID,
baseUrl: CODEX_BASE_URL,
} as ProviderRuntimeModel);
}
function codexModelToDefinition(model: CodexAppServerModel): ModelDefinitionConfig {
return buildModelDefinition(model);
}
function buildModelDefinition(model: {
id: string;
model: string;
displayName?: string;
inputModalities: string[];
supportedReasoningEfforts: string[];
}): ModelDefinitionConfig {
const id = model.id.trim() || model.model.trim();
return {
id,
name: model.displayName?.trim() || id,
api: "openai-codex-responses",
reasoning: model.supportedReasoningEfforts.length > 0 || shouldDefaultToReasoningModel(id),
input: model.inputModalities.includes("image") ? ["text", "image"] : ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_WINDOW,
maxTokens: DEFAULT_MAX_TOKENS,
compat: {
supportsReasoningEffort: model.supportedReasoningEfforts.length > 0,
supportsUsageInStreaming: true,
},
};
}
async function listModelsBestEffort(params: {
listModels: CodexModelLister;
timeoutMs: number;
startOptions: CodexAppServerStartOptions;
}): Promise<CodexAppServerModel[]> {
try {
const result = await params.listModels({
timeoutMs: params.timeoutMs,
limit: 100,
startOptions: params.startOptions,
sharedClient: false,
});
return result.models.filter((model) => !model.hidden);
} catch {
return [];
}
}
function normalizeTimeoutMs(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) && value > 0
? value
: DEFAULT_DISCOVERY_TIMEOUT_MS;
}
function shouldSkipLiveDiscovery(env: NodeJS.ProcessEnv = process.env): boolean {
const override = env[LIVE_DISCOVERY_ENV]?.trim().toLowerCase();
if (override === "0" || override === "false") {
return true;
}
return Boolean(env.VITEST) && override !== "1";
}
function shouldDefaultToReasoningModel(modelId: string): boolean {
const lower = modelId.toLowerCase();
return (
lower.startsWith("gpt-5") ||
lower.startsWith("o1") ||
lower.startsWith("o3") ||
lower.startsWith("o4")
);
}
function isKnownXHighCodexModel(modelId: string): boolean {
const lower = modelId.trim().toLowerCase();
return (
lower.startsWith("gpt-5") ||
lower.startsWith("o3") ||
lower.startsWith("o4") ||
lower.includes("codex")
);
}
function isModernCodexModel(modelId: string): boolean {
const lower = modelId.trim().toLowerCase();
return lower === "gpt-5.4" || lower === "gpt-5.4-mini" || lower === "gpt-5.2";
}

View file

@ -0,0 +1,138 @@
import { callGatewayTool, type EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildApprovalResponse, handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
vi.mock("openclaw/plugin-sdk/agent-harness", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness")>()),
callGatewayTool: vi.fn(),
}));
const mockCallGatewayTool = vi.mocked(callGatewayTool);
function createParams(): EmbeddedRunAttemptParams {
return {
sessionKey: "agent:main:session-1",
agentId: "main",
messageChannel: "telegram",
currentChannelId: "chat-1",
agentAccountId: "default",
currentThreadTs: "thread-ts",
onAgentEvent: vi.fn(),
} as unknown as EmbeddedRunAttemptParams;
}
describe("Codex app-server approval bridge", () => {
beforeEach(() => {
mockCallGatewayTool.mockReset();
});
it("routes command approvals through plugin approvals and accepts allowed commands", async () => {
const params = createParams();
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-1", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-1", decision: "allow-once" });
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-1",
command: "pnpm test extensions/codex/src/app-server",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
});
expect(result).toEqual({ decision: "accept" });
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
expect(mockCallGatewayTool).toHaveBeenCalledWith(
"plugin.approval.request",
expect.any(Object),
expect.objectContaining({
pluginId: "openclaw-codex-app-server",
title: "Codex app-server command approval",
twoPhase: true,
turnSourceChannel: "telegram",
turnSourceTo: "chat-1",
}),
{ expectFinal: false },
);
expect(params.onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "approval",
data: expect.objectContaining({ status: "pending", approvalId: "plugin:approval-1" }),
}),
);
expect(params.onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "approval",
data: expect.objectContaining({ status: "approved", approvalId: "plugin:approval-1" }),
}),
);
});
it("fails closed when no approval route is available", async () => {
const params = createParams();
mockCallGatewayTool.mockResolvedValueOnce({
id: "plugin:approval-2",
decision: null,
});
const result = await handleCodexAppServerApprovalRequest({
method: "item/fileChange/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "patch-1",
reason: "needs write access",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
});
expect(result).toEqual({ decision: "decline" });
expect(mockCallGatewayTool).toHaveBeenCalledTimes(1);
expect(params.onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "approval",
data: expect.objectContaining({ status: "unavailable", reason: "needs write access" }),
}),
);
});
it("maps app-server approval response families separately", () => {
expect(
buildApprovalResponse(
"item/commandExecution/requestApproval",
{ availableDecisions: ["accept"] },
"approved-session",
),
).toEqual({
decision: "accept",
});
expect(buildApprovalResponse("item/fileChange/requestApproval", undefined, "denied")).toEqual({
decision: "decline",
});
expect(
buildApprovalResponse(
"item/permissions/requestApproval",
{
permissions: {
network: { allowHosts: ["example.com"] },
fileSystem: null,
},
},
"approved-once",
),
).toEqual({
permissions: { network: { allowHosts: ["example.com"] } },
scope: "turn",
});
});
});

View file

@ -0,0 +1,377 @@
import {
callGatewayTool,
type AgentApprovalEventData,
type EmbeddedRunAttemptParams,
type ExecApprovalDecision,
} from "openclaw/plugin-sdk/agent-harness";
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
const DEFAULT_CODEX_APPROVAL_TIMEOUT_MS = 120_000;
export type AppServerApprovalOutcome =
| "approved-once"
| "approved-session"
| "denied"
| "unavailable"
| "cancelled";
type ApprovalRequestResult = {
id?: string;
status?: string;
decision?: ExecApprovalDecision | null;
};
type ApprovalWaitResult = {
id?: string;
decision?: ExecApprovalDecision | null;
};
export async function handleCodexAppServerApprovalRequest(params: {
method: string;
requestParams: JsonValue | undefined;
paramsForRun: EmbeddedRunAttemptParams;
threadId: string;
turnId: string;
signal?: AbortSignal;
}): Promise<JsonValue | undefined> {
const requestParams = isJsonObject(params.requestParams) ? params.requestParams : undefined;
if (!matchesCurrentTurn(requestParams, params.threadId, params.turnId)) {
return undefined;
}
const context = buildApprovalContext({
method: params.method,
requestParams,
paramsForRun: params.paramsForRun,
});
try {
const timeoutMs = DEFAULT_CODEX_APPROVAL_TIMEOUT_MS;
const requestResult: ApprovalRequestResult | undefined = await callGatewayTool(
"plugin.approval.request",
{ timeoutMs: timeoutMs + 10_000 },
{
pluginId: "openclaw-codex-app-server",
title: context.title,
description: context.description,
severity: context.severity,
toolName: context.kind === "exec" ? "codex_command_approval" : "codex_file_approval",
toolCallId: context.itemId,
agentId: params.paramsForRun.agentId,
sessionKey: params.paramsForRun.sessionKey,
turnSourceChannel:
params.paramsForRun.messageChannel ?? params.paramsForRun.messageProvider,
turnSourceTo: params.paramsForRun.currentChannelId,
turnSourceAccountId: params.paramsForRun.agentAccountId,
turnSourceThreadId: params.paramsForRun.currentThreadTs,
timeoutMs,
twoPhase: true,
},
{ expectFinal: false },
);
const approvalId = requestResult?.id;
if (!approvalId) {
emitApprovalEvent(params.paramsForRun, {
phase: "resolved",
kind: context.kind,
status: "unavailable",
title: context.title,
...context.eventDetails,
message: "Codex app-server approval route unavailable.",
});
return buildApprovalResponse(params.method, context.requestParams, "denied");
}
emitApprovalEvent(params.paramsForRun, {
phase: "requested",
kind: context.kind,
status: "pending",
title: context.title,
approvalId,
approvalSlug: approvalId,
...context.eventDetails,
message: "Codex app-server approval requested.",
});
const decision = Object.prototype.hasOwnProperty.call(requestResult, "decision")
? requestResult.decision
: await waitForApprovalDecision({
approvalId,
timeoutMs,
signal: params.signal,
});
const outcome = mapExecDecisionToOutcome(decision);
emitApprovalEvent(params.paramsForRun, {
phase: "resolved",
kind: context.kind,
status:
outcome === "denied"
? "denied"
: outcome === "unavailable"
? "unavailable"
: outcome === "cancelled"
? "failed"
: "approved",
title: context.title,
approvalId,
approvalSlug: approvalId,
...context.eventDetails,
message: approvalResolutionMessage(outcome),
});
return buildApprovalResponse(params.method, context.requestParams, outcome);
} catch (error) {
const cancelled = params.signal?.aborted === true;
emitApprovalEvent(params.paramsForRun, {
phase: "resolved",
kind: context.kind,
status: cancelled ? "failed" : "unavailable",
title: context.title,
...context.eventDetails,
message: cancelled
? "Codex app-server approval cancelled because the run stopped."
: `Codex app-server approval route failed: ${formatErrorMessage(error)}`,
});
return buildApprovalResponse(
params.method,
context.requestParams,
cancelled ? "cancelled" : "denied",
);
}
}
export function buildApprovalResponse(
method: string,
requestParams: JsonObject | undefined,
outcome: AppServerApprovalOutcome,
): JsonValue {
if (method === "item/commandExecution/requestApproval") {
return { decision: commandApprovalDecision(requestParams, outcome) };
}
if (method === "item/fileChange/requestApproval") {
return { decision: fileChangeApprovalDecision(outcome) };
}
if (method === "item/permissions/requestApproval") {
if (outcome === "approved-session" || outcome === "approved-once") {
return {
permissions: requestedPermissions(requestParams),
scope: outcome === "approved-session" ? "session" : "turn",
};
}
return { permissions: {}, scope: "turn" };
}
return {
decision: outcome === "approved-once" || outcome === "approved-session" ? "accept" : "decline",
};
}
function matchesCurrentTurn(
requestParams: JsonObject | undefined,
threadId: string,
turnId: string,
): boolean {
if (!requestParams) {
return true;
}
const requestThreadId =
readString(requestParams, "threadId") ?? readString(requestParams, "conversationId");
const requestTurnId = readString(requestParams, "turnId");
if (requestThreadId && requestThreadId !== threadId) {
return false;
}
if (requestTurnId && requestTurnId !== turnId) {
return false;
}
return true;
}
function buildApprovalContext(params: {
method: string;
requestParams: JsonObject | undefined;
paramsForRun: EmbeddedRunAttemptParams;
}) {
const itemId =
readString(params.requestParams, "itemId") ??
readString(params.requestParams, "callId") ??
readString(params.requestParams, "approvalId");
const command = readCommand(params.requestParams);
const reason = readString(params.requestParams, "reason");
const kind = approvalKindForMethod(params.method);
const title =
kind === "exec"
? "Codex app-server command approval"
: kind === "plugin"
? "Codex app-server file approval"
: "Codex app-server approval";
const subject = command
? `Command: ${truncate(command, 180)}`
: reason
? `Reason: ${truncate(reason, 180)}`
: `Request method: ${params.method}`;
const description = [
subject,
params.paramsForRun.sessionKey && `Session: ${params.paramsForRun.sessionKey}`,
]
.filter(Boolean)
.join("\n");
return {
kind,
title,
description,
severity: kind === "exec" ? ("warning" as const) : ("info" as const),
itemId,
requestParams: params.requestParams,
eventDetails: {
...(itemId ? { itemId } : {}),
...(command ? { command } : {}),
...(reason ? { reason } : {}),
},
};
}
async function waitForApprovalDecision(params: {
approvalId: string;
timeoutMs: number;
signal?: AbortSignal;
}): Promise<ExecApprovalDecision | null | undefined> {
const waitPromise: Promise<ApprovalWaitResult | undefined> = callGatewayTool(
"plugin.approval.waitDecision",
{ timeoutMs: params.timeoutMs + 10_000 },
{ id: params.approvalId },
);
if (!params.signal) {
return (await waitPromise)?.decision;
}
let onAbort: (() => void) | undefined;
const abortPromise = new Promise<never>((_, reject) => {
if (params.signal!.aborted) {
reject(params.signal!.reason);
return;
}
onAbort = () => reject(params.signal!.reason);
params.signal!.addEventListener("abort", onAbort, { once: true });
});
try {
return (await Promise.race([waitPromise, abortPromise]))?.decision;
} finally {
if (onAbort) {
params.signal.removeEventListener("abort", onAbort);
}
}
}
function commandApprovalDecision(
requestParams: JsonObject | undefined,
outcome: AppServerApprovalOutcome,
): JsonValue {
if (outcome === "cancelled") {
return "cancel";
}
if (outcome === "denied" || outcome === "unavailable") {
return "decline";
}
if (outcome === "approved-session" && hasAvailableDecision(requestParams, "acceptForSession")) {
return "acceptForSession";
}
return "accept";
}
function fileChangeApprovalDecision(outcome: AppServerApprovalOutcome): JsonValue {
if (outcome === "cancelled") {
return "cancel";
}
if (outcome === "denied" || outcome === "unavailable") {
return "decline";
}
return outcome === "approved-session" ? "acceptForSession" : "accept";
}
function requestedPermissions(requestParams: JsonObject | undefined): JsonObject {
const permissions = isJsonObject(requestParams?.permissions) ? requestParams.permissions : {};
const granted: JsonObject = {};
if (isJsonObject(permissions.network)) {
granted.network = permissions.network;
}
if (isJsonObject(permissions.fileSystem)) {
granted.fileSystem = permissions.fileSystem;
}
return granted;
}
function hasAvailableDecision(requestParams: JsonObject | undefined, decision: string): boolean {
const available = requestParams?.availableDecisions;
if (!Array.isArray(available)) {
return true;
}
return available.includes(decision);
}
function mapExecDecisionToOutcome(
decision: ExecApprovalDecision | null | undefined,
): AppServerApprovalOutcome {
if (decision === "allow-once") {
return "approved-once";
}
if (decision === "allow-always") {
return "approved-session";
}
if (decision === null || decision === undefined) {
return "unavailable";
}
return "denied";
}
function approvalResolutionMessage(outcome: AppServerApprovalOutcome): string {
if (outcome === "approved-session") {
return "Codex app-server approval granted for the session.";
}
if (outcome === "approved-once") {
return "Codex app-server approval granted once.";
}
if (outcome === "cancelled") {
return "Codex app-server approval cancelled.";
}
if (outcome === "unavailable") {
return "Codex app-server approval unavailable.";
}
return "Codex app-server approval denied.";
}
function approvalKindForMethod(method: string): AgentApprovalEventData["kind"] {
if (method.includes("commandExecution") || method.includes("execCommand")) {
return "exec";
}
if (method.includes("fileChange") || method.includes("Patch") || method.includes("permissions")) {
return "plugin";
}
return "unknown";
}
function emitApprovalEvent(params: EmbeddedRunAttemptParams, data: AgentApprovalEventData): void {
params.onAgentEvent?.({ stream: "approval", data: data as unknown as Record<string, unknown> });
}
function readCommand(record: JsonObject | undefined): string | undefined {
const command = record?.command;
if (typeof command === "string") {
return command;
}
if (Array.isArray(command) && command.every((part) => typeof part === "string")) {
return command.join(" ");
}
return undefined;
}
function readString(record: JsonObject | undefined, key: string): string | undefined {
const value = record?.[key];
return typeof value === "string" ? value : undefined;
}
function truncate(value: string, maxLength: number): string {
return value.length <= maxLength ? value : `${value.slice(0, Math.max(0, maxLength - 3))}...`;
}
function formatErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -0,0 +1,148 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { saveAuthProfileStore } from "openclaw/plugin-sdk/agent-runtime";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
let bridgeCodexAppServerStartOptions: typeof import("./auth-bridge.js").bridgeCodexAppServerStartOptions;
describe("bridgeCodexAppServerStartOptions", () => {
const tempDirs: string[] = [];
const resolveHashedCodexHome = (agentDir: string, profileId: string) =>
path.join(
agentDir,
"harness-auth",
"codex",
crypto.createHash("sha256").update(profileId).digest("hex").slice(0, 16),
);
beforeAll(async () => {
({ bridgeCodexAppServerStartOptions } = await import("./auth-bridge.js"));
});
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
it("bridges canonical OpenClaw oauth into an isolated CODEX_HOME", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
tempDirs.push(agentDir);
saveAuthProfileStore(
{
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
accountId: "acct-123",
idToken: "id-token",
},
},
},
agentDir,
{ filterExternalAuthProfiles: false },
);
const result = await bridgeCodexAppServerStartOptions({
startOptions: {
transport: "stdio",
command: "codex",
args: ["app-server"],
headers: { authorization: "Bearer dev-token" },
env: { EXISTING: "1" },
clearEnv: ["FOO"],
},
agentDir,
});
expect(result).toMatchObject({
env: {
EXISTING: "1",
CODEX_HOME: expect.stringContaining(path.join(agentDir, "harness-auth", "codex")),
},
clearEnv: expect.arrayContaining(["FOO", "OPENAI_API_KEY"]),
});
const authFile = JSON.parse(
await fs.readFile(path.join(result.env?.CODEX_HOME ?? "", "auth.json"), "utf8"),
);
expect(authFile).toEqual({
auth_mode: "chatgpt",
tokens: {
id_token: "id-token",
access_token: "access-token",
refresh_token: "refresh-token",
account_id: "acct-123",
},
});
if (process.platform !== "win32") {
const authStat = await fs.stat(path.join(result.env?.CODEX_HOME ?? "", "auth.json"));
expect(authStat.mode & 0o777).toBe(0o600);
}
});
it("leaves start options unchanged when canonical oauth is unavailable", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
tempDirs.push(agentDir);
const startOptions = {
transport: "stdio" as const,
command: "codex",
args: ["app-server"],
headers: { authorization: "Bearer dev-token" },
};
saveAuthProfileStore({ version: 1, profiles: {} }, agentDir, {
filterExternalAuthProfiles: false,
});
await expect(
bridgeCodexAppServerStartOptions({
startOptions,
agentDir,
authProfileId: "openai-codex:missing",
}),
).resolves.toEqual(startOptions);
});
it("refuses to overwrite a symlinked auth bridge file", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
tempDirs.push(agentDir);
saveAuthProfileStore(
{
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
},
agentDir,
{ filterExternalAuthProfiles: false },
);
const codexHome = resolveHashedCodexHome(agentDir, "openai-codex:default");
await fs.mkdir(codexHome, { recursive: true });
await fs.symlink(path.join(agentDir, "outside.txt"), path.join(codexHome, "auth.json"));
await expect(
bridgeCodexAppServerStartOptions({
startOptions: {
transport: "stdio",
command: "codex",
args: ["app-server"],
headers: {},
},
agentDir,
}),
).rejects.toThrow("must not be a symlink");
});
});

View file

@ -0,0 +1,82 @@
import crypto from "node:crypto";
import path from "node:path";
import {
ensureAuthProfileStoreForLocalUpdate,
type OAuthCredential,
} from "openclaw/plugin-sdk/provider-auth";
import { writePrivateSecretFileAtomic } from "openclaw/plugin-sdk/secret-file-runtime";
import type { CodexAppServerStartOptions } from "./config.js";
const DEFAULT_CODEX_AUTH_PROFILE_ID = "openai-codex:default";
const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
const CODEX_AUTH_ENV_CLEAR_KEYS = ["OPENAI_API_KEY"] as const;
function isCodexBridgeableOAuthCredential(value: unknown): value is OAuthCredential {
return Boolean(
value &&
typeof value === "object" &&
value !== null &&
"type" in value &&
"provider" in value &&
"access" in value &&
"refresh" in value &&
value.type === "oauth" &&
value.provider === OPENAI_CODEX_PROVIDER_ID &&
typeof value.access === "string" &&
value.access.trim().length > 0 &&
typeof value.refresh === "string" &&
value.refresh.trim().length > 0,
);
}
function resolveCodexBridgeHome(agentDir: string, profileId: string): string {
const digest = crypto.createHash("sha256").update(profileId).digest("hex").slice(0, 16);
return path.join(agentDir, "harness-auth", "codex", digest);
}
function buildCodexAuthFile(credential: OAuthCredential): string {
return `${JSON.stringify(
{
auth_mode: "chatgpt",
tokens: {
...(credential.idToken ? { id_token: credential.idToken } : {}),
access_token: credential.access,
refresh_token: credential.refresh,
...(credential.accountId ? { account_id: credential.accountId } : {}),
},
},
null,
2,
)}\n`;
}
export async function bridgeCodexAppServerStartOptions(params: {
startOptions: CodexAppServerStartOptions;
agentDir: string;
authProfileId?: string;
}): Promise<CodexAppServerStartOptions> {
const profileId = params.authProfileId?.trim() || DEFAULT_CODEX_AUTH_PROFILE_ID;
const store = ensureAuthProfileStoreForLocalUpdate(params.agentDir);
const credential = store.profiles[profileId];
if (!isCodexBridgeableOAuthCredential(credential)) {
return params.startOptions;
}
const codexHome = resolveCodexBridgeHome(params.agentDir, profileId);
await writePrivateSecretFileAtomic({
rootDir: params.agentDir,
filePath: path.join(codexHome, "auth.json"),
content: buildCodexAuthFile(credential),
});
return {
...params.startOptions,
env: {
...params.startOptions.env,
CODEX_HOME: codexHome,
},
clearEnv: Array.from(
new Set([...(params.startOptions.clearEnv ?? []), ...CODEX_AUTH_ENV_CLEAR_KEYS]),
),
};
}

View file

@ -0,0 +1,26 @@
import { CodexAppServerRpcError } from "./client.js";
export const CODEX_CONTROL_METHODS = {
account: "account/read",
compact: "thread/compact/start",
listMcpServers: "mcpServerStatus/list",
listSkills: "skills/list",
listThreads: "thread/list",
rateLimits: "account/rateLimits/read",
resumeThread: "thread/resume",
review: "review/start",
} as const;
export type CodexControlName = keyof typeof CODEX_CONTROL_METHODS;
export type CodexControlMethod = (typeof CODEX_CONTROL_METHODS)[CodexControlName];
export function describeControlFailure(error: unknown): string {
if (isUnsupportedControlError(error)) {
return "unsupported by this Codex app-server";
}
return error instanceof Error ? error.message : String(error);
}
function isUnsupportedControlError(error: unknown): error is CodexAppServerRpcError {
return error instanceof CodexAppServerRpcError && error.code === -32601;
}

View file

@ -0,0 +1,282 @@
import { EventEmitter } from "node:events";
import { PassThrough, Writable } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
__testing,
CodexAppServerClient,
CodexAppServerRpcError,
MIN_CODEX_APP_SERVER_VERSION,
readCodexVersionFromUserAgent,
} from "./client.js";
import { resetSharedCodexAppServerClientForTests } from "./shared-client.js";
function createClientHarness() {
const stdout = new PassThrough();
const stderr = new PassThrough();
const writes: string[] = [];
const stdin = new Writable({
write(chunk, _encoding, callback) {
writes.push(chunk.toString());
callback();
},
});
const process = Object.assign(new EventEmitter(), {
stdin,
stdout,
stderr,
killed: false,
kill: vi.fn(() => {
process.killed = true;
}),
});
// fromTransportForTests speaks the same newline-delimited JSON-RPC as the
// spawned app-server, but keeps the process lifecycle fully observable.
const client = CodexAppServerClient.fromTransportForTests(process);
return {
client,
process,
writes,
send(message: unknown) {
stdout.write(`${JSON.stringify(message)}\n`);
},
};
}
describe("CodexAppServerClient", () => {
const clients: CodexAppServerClient[] = [];
afterEach(() => {
resetSharedCodexAppServerClientForTests();
vi.useRealTimers();
vi.restoreAllMocks();
vi.useRealTimers();
for (const client of clients) {
client.close();
}
clients.length = 0;
});
it("routes request responses by id", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const request = harness.client.request("model/list", {});
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number; method?: string };
harness.send({ id: outbound.id, result: { models: [] } });
await expect(request).resolves.toEqual({ models: [] });
expect(outbound.method).toBe("model/list");
});
it("preserves JSON-RPC error codes", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const request = harness.client.request("future/method", {});
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({ id: outbound.id, error: { code: -32601, message: "Method not found" } });
await expect(request).rejects.toMatchObject({
name: "CodexAppServerRpcError",
code: -32601,
message: "Method not found",
} satisfies Partial<CodexAppServerRpcError>);
});
it("rejects timed-out requests and ignores late responses", async () => {
vi.useFakeTimers();
const harness = createClientHarness();
clients.push(harness.client);
const request = harness.client.request("model/list", {}, { timeoutMs: 1 });
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
const assertion = expect(request).rejects.toThrow("model/list timed out");
await vi.advanceTimersByTimeAsync(100);
await assertion;
harness.send({ id: outbound.id, result: { data: [] } });
expect(harness.writes).toHaveLength(1);
});
it("rejects aborted requests and ignores late responses", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const controller = new AbortController();
const request = harness.client.request("model/list", {}, { signal: controller.signal });
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
const assertion = expect(request).rejects.toThrow("model/list aborted");
controller.abort();
await assertion;
harness.send({ id: outbound.id, result: { data: [] } });
expect(harness.writes).toHaveLength(1);
});
it("initializes with the required client version", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const initializing = harness.client.initialize();
const outbound = JSON.parse(harness.writes[0] ?? "{}") as {
id?: number;
method?: string;
params?: { clientInfo?: { name?: string; title?: string; version?: string } };
};
harness.send({
id: outbound.id,
result: { userAgent: "openclaw/0.118.0 (macOS; test)" },
});
await expect(initializing).resolves.toBeUndefined();
expect(outbound).toMatchObject({
method: "initialize",
params: {
clientInfo: {
name: "openclaw",
title: "OpenClaw",
version: expect.any(String),
},
},
});
expect(outbound.params?.clientInfo?.version).not.toBe("");
expect(JSON.parse(harness.writes[1] ?? "{}")).toEqual({ method: "initialized" });
});
it("blocks unsupported app-server versions during initialize", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const initializing = harness.client.initialize();
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({
id: outbound.id,
result: { userAgent: "openclaw/0.117.9 (macOS; test)" },
});
await expect(initializing).rejects.toThrow(
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required, but detected 0.117.9`,
);
expect(harness.writes).toHaveLength(1);
});
it("blocks app-server initialize responses without a version", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const initializing = harness.client.initialize();
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({ id: outbound.id, result: {} });
await expect(initializing).rejects.toThrow(
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required`,
);
expect(harness.writes).toHaveLength(1);
});
it("force-stops app-server transports that ignore the graceful signal", async () => {
vi.useFakeTimers();
const process = Object.assign(new EventEmitter(), {
stdin: {
write: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
unref: vi.fn(),
},
stdout: Object.assign(new PassThrough(), { unref: vi.fn() }),
stderr: Object.assign(new PassThrough(), { unref: vi.fn() }),
exitCode: null,
signalCode: null,
kill: vi.fn(),
unref: vi.fn(),
});
__testing.closeCodexAppServerTransport(process, { forceKillDelayMs: 25 });
expect(process.kill).toHaveBeenCalledWith("SIGTERM");
await vi.advanceTimersByTimeAsync(25);
expect(process.kill).toHaveBeenCalledWith("SIGKILL");
expect(process.unref).toHaveBeenCalledTimes(1);
});
it("handles stdin write errors without crashing the process", async () => {
const harness = createClientHarness();
clients.push(harness.client);
// Start a pending request so we can verify it gets properly rejected.
const pending = harness.client.request("test/method");
// Simulate the child process closing its pipe — a write to the now-dead
// stdin emits an asynchronous EPIPE error on the stream.
harness.process.stdin.destroy(Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
// The pending request must be rejected with the pipe error rather than
// an unhandled exception tearing down the gateway.
await expect(pending).rejects.toThrow("write EPIPE");
// Subsequent requests are rejected immediately (client is closed).
await expect(harness.client.request("another/method")).rejects.toThrow(
"codex app-server client is closed",
);
});
it("does not write to stdin after the child process exits", async () => {
const harness = createClientHarness();
clients.push(harness.client);
// Simulate the child process exiting.
harness.process.emit("exit", 1, null);
// A notification after exit must not attempt a write.
harness.client.notify("late/event", { data: "ignored" });
expect(harness.writes).toHaveLength(0);
});
it("reads the Codex version from the app-server user agent", () => {
expect(readCodexVersionFromUserAgent("Codex Desktop/0.118.0")).toBe("0.118.0");
expect(readCodexVersionFromUserAgent("openclaw/0.118.0 (macOS; test)")).toBe("0.118.0");
expect(readCodexVersionFromUserAgent("codex_cli_rs/0.118.1-dev (linux; test)")).toBe(
"0.118.1-dev",
);
expect(readCodexVersionFromUserAgent("Codex Desktop/not-a-version")).toBeUndefined();
expect(readCodexVersionFromUserAgent("Codex Desktop/0.118")).toBeUndefined();
expect(readCodexVersionFromUserAgent("openclaw/0.118.0abc")).toBeUndefined();
expect(readCodexVersionFromUserAgent("missing-version")).toBeUndefined();
});
it("answers server-initiated requests with the registered handler result", async () => {
const harness = createClientHarness();
clients.push(harness.client);
harness.client.addRequestHandler((request) => {
if (request.method === "item/tool/call") {
return { contentItems: [{ type: "inputText", text: "ok" }], success: true };
}
return undefined;
});
harness.send({ id: "srv-1", method: "item/tool/call", params: { tool: "message" } });
await vi.waitFor(() => expect(harness.writes.length).toBe(1));
expect(JSON.parse(harness.writes[0] ?? "{}")).toEqual({
id: "srv-1",
result: { contentItems: [{ type: "inputText", text: "ok" }], success: true },
});
});
it("fails closed for unhandled native app-server approvals", async () => {
const harness = createClientHarness();
clients.push(harness.client);
harness.send({
id: "approval-1",
method: "item/commandExecution/requestApproval",
params: { threadId: "thread-1", turnId: "turn-1", itemId: "cmd-1", command: "pnpm test" },
});
await vi.waitFor(() => expect(harness.writes.length).toBe(1));
expect(JSON.parse(harness.writes[0] ?? "{}")).toEqual({
id: "approval-1",
result: { decision: "decline" },
});
});
});

View file

@ -0,0 +1,435 @@
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;
cleanup: () => 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)}`,
),
);
});
// Guard against unhandled EPIPE / write-after-close errors on the stdin
// stream. When the child process terminates abruptly the pipe can break
// before the "exit" event fires, so a pending writeMessage() produces an
// asynchronous error on stdin that would otherwise crash the gateway.
child.stdin.on?.("error", (error) =>
this.closeWithError(error instanceof Error ? error : new Error(String(error))),
);
}
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,
options: { timeoutMs?: number; signal?: AbortSignal } = {},
): Promise<T> {
if (this.closed) {
return Promise.reject(new Error("codex app-server client is closed"));
}
if (options.signal?.aborted) {
return Promise.reject(new Error(`${method} aborted`));
}
const id = this.nextId++;
const message: RpcRequest = { id, method, params };
return new Promise<T>((resolve, reject) => {
let timeout: ReturnType<typeof setTimeout> | undefined;
let cleanupAbort: (() => void) | undefined;
const cleanup = () => {
if (timeout) {
clearTimeout(timeout);
timeout = undefined;
}
cleanupAbort?.();
cleanupAbort = undefined;
};
const rejectPending = (error: Error) => {
if (!this.pending.has(id)) {
return;
}
this.pending.delete(id);
cleanup();
reject(error);
};
if (options.timeoutMs && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0) {
timeout = setTimeout(
() => rejectPending(new Error(`${method} timed out`)),
Math.max(100, options.timeoutMs),
);
timeout.unref?.();
}
if (options.signal) {
const abortListener = () => rejectPending(new Error(`${method} aborted`));
options.signal.addEventListener("abort", abortListener, { once: true });
cleanupAbort = () => options.signal?.removeEventListener("abort", abortListener);
}
this.pending.set(id, {
method,
resolve: (value) => {
cleanup();
resolve(value as T);
},
reject: (error) => {
cleanup();
reject(error);
},
cleanup,
});
if (options.signal?.aborted) {
rejectPending(new Error(`${method} aborted`));
return;
}
try {
this.writeMessage(message);
} catch (error) {
rejectPending(error instanceof Error ? error : new Error(String(error)));
}
});
}
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 {
if (this.closed) {
return;
}
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.lines.close();
this.rejectPendingRequests(error);
closeCodexAppServerTransport(this.child);
}
private rejectPendingRequests(error: Error): void {
for (const pending of this.pending.values()) {
pending.cleanup();
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, Codex Desktop, or an env override, so only the slash-delimited
// version in the leading product field is stable.
const match = userAgent?.match(
/^[^/]+\/(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)(?:[\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;

View file

@ -0,0 +1,191 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClient } from "./client.js";
import { maybeCompactCodexAppServerSession, __testing } from "./compact.js";
import type { CodexServerNotification } from "./protocol.js";
import { writeCodexAppServerBinding } from "./session-binding.js";
let tempDir: string;
describe("maybeCompactCodexAppServerSession", () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-compact-"));
});
afterEach(async () => {
__testing.resetCodexAppServerClientFactoryForTests();
await fs.rm(tempDir, { recursive: true, force: true });
});
it("waits for native app-server compaction before reporting success", async () => {
const fake = createFakeCodexClient();
__testing.setCodexAppServerClientFactoryForTests(async () => fake.client);
const sessionFile = path.join(tempDir, "session.jsonl");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-1",
cwd: tempDir,
});
const pendingResult = maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
currentTokenCount: 123,
});
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
let settled = false;
void pendingResult.then(() => {
settled = true;
});
await Promise.resolve();
expect(settled).toBe(false);
fake.emit({
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
});
const result = await pendingResult;
expect(result).toMatchObject({
ok: true,
compacted: true,
result: {
tokensBefore: 123,
details: {
backend: "codex-app-server",
threadId: "thread-1",
signal: "thread/compacted",
turnId: "turn-1",
},
},
});
});
it("accepts native context-compaction item completion as success", async () => {
const fake = createFakeCodexClient();
__testing.setCodexAppServerClientFactoryForTests(async () => fake.client);
const sessionFile = path.join(tempDir, "session.jsonl");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-1",
cwd: tempDir,
});
const pendingResult = maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
});
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
fake.emit({
method: "item/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: { type: "contextCompaction", id: "compact-1" },
},
});
await expect(pendingResult).resolves.toMatchObject({
ok: true,
compacted: true,
result: {
details: {
signal: "item/completed",
itemId: "compact-1",
},
},
});
});
it("reuses the bound auth profile for native compaction", async () => {
const fake = createFakeCodexClient();
let seenAuthProfileId: string | undefined;
__testing.setCodexAppServerClientFactoryForTests(async (_startOptions, authProfileId) => {
seenAuthProfileId = authProfileId;
return fake.client;
});
const sessionFile = path.join(tempDir, "session.jsonl");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-1",
cwd: tempDir,
authProfileId: "openai-codex:work",
});
const pendingResult = maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
});
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
fake.emit({
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
});
await pendingResult;
expect(seenAuthProfileId).toBe("openai-codex:work");
});
it("fails closed when the persisted binding auth profile disagrees with the runtime request", async () => {
const fake = createFakeCodexClient();
const factory = vi.fn(async () => fake.client);
__testing.setCodexAppServerClientFactoryForTests(factory);
const sessionFile = path.join(tempDir, "session.jsonl");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-1",
cwd: tempDir,
authProfileId: "openai-codex:binding",
});
const result = await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
authProfileId: "openai-codex:runtime",
});
expect(result).toEqual({
ok: false,
compacted: false,
reason: "auth profile mismatch for session binding",
});
expect(factory).not.toHaveBeenCalled();
});
});
function createFakeCodexClient(): {
client: CodexAppServerClient;
request: ReturnType<typeof vi.fn>;
emit: (notification: CodexServerNotification) => void;
} {
const handlers = new Set<(notification: CodexServerNotification) => void>();
const request = vi.fn(async () => ({}));
return {
client: {
request,
addNotificationHandler(handler: (notification: CodexServerNotification) => void) {
handlers.add(handler);
return () => handlers.delete(handler);
},
} as unknown as CodexAppServerClient,
request,
emit(notification: CodexServerNotification): void {
for (const handler of handlers) {
handler(notification);
}
},
};
}

View file

@ -0,0 +1,230 @@
import {
embeddedAgentLog,
type CompactEmbeddedPiSessionParams,
type EmbeddedPiCompactResult,
} from "openclaw/plugin-sdk/agent-harness";
import type { CodexAppServerClient, CodexServerNotificationHandler } from "./client.js";
import { resolveCodexAppServerRuntimeOptions, type CodexAppServerStartOptions } from "./config.js";
import { isJsonObject, type CodexServerNotification, type JsonObject } from "./protocol.js";
import { readCodexAppServerBinding } from "./session-binding.js";
import { getSharedCodexAppServerClient } from "./shared-client.js";
type CodexAppServerClientFactory = (
startOptions?: CodexAppServerStartOptions,
authProfileId?: string,
) => Promise<CodexAppServerClient>;
type CodexNativeCompactionCompletion = {
signal: "thread/compacted" | "item/completed";
turnId?: string;
itemId?: string;
};
type CodexNativeCompactionWaiter = {
promise: Promise<CodexNativeCompactionCompletion>;
startTimeout: () => void;
cancel: () => void;
};
const DEFAULT_CODEX_COMPACTION_WAIT_TIMEOUT_MS = 5 * 60 * 1000;
let clientFactory: CodexAppServerClientFactory = (startOptions, authProfileId) =>
getSharedCodexAppServerClient({ startOptions, authProfileId });
export async function maybeCompactCodexAppServerSession(
params: CompactEmbeddedPiSessionParams,
options: { pluginConfig?: unknown } = {},
): Promise<EmbeddedPiCompactResult | undefined> {
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
const binding = await readCodexAppServerBinding(params.sessionFile);
if (!binding?.threadId) {
return { ok: false, compacted: false, reason: "no codex app-server thread binding" };
}
const requestedAuthProfileId = params.authProfileId?.trim() || undefined;
if (
requestedAuthProfileId &&
binding.authProfileId &&
binding.authProfileId !== requestedAuthProfileId
) {
return { ok: false, compacted: false, reason: "auth profile mismatch for session binding" };
}
const client = await clientFactory(
appServer.start,
requestedAuthProfileId ?? binding.authProfileId,
);
const waiter = createCodexNativeCompactionWaiter(client, binding.threadId);
let completion: CodexNativeCompactionCompletion;
try {
await client.request("thread/compact/start", {
threadId: binding.threadId,
});
embeddedAgentLog.info("started codex app-server compaction", {
sessionId: params.sessionId,
threadId: binding.threadId,
});
waiter.startTimeout();
completion = await waiter.promise;
} catch (error) {
waiter.cancel();
return {
ok: false,
compacted: false,
reason: formatCompactionError(error),
};
}
embeddedAgentLog.info("completed codex app-server compaction", {
sessionId: params.sessionId,
threadId: binding.threadId,
signal: completion.signal,
turnId: completion.turnId,
itemId: completion.itemId,
});
return {
ok: true,
compacted: true,
result: {
summary: "",
firstKeptEntryId: "",
tokensBefore: params.currentTokenCount ?? 0,
details: {
backend: "codex-app-server",
threadId: binding.threadId,
signal: completion.signal,
turnId: completion.turnId,
itemId: completion.itemId,
},
},
};
}
function createCodexNativeCompactionWaiter(
client: CodexAppServerClient,
threadId: string,
): CodexNativeCompactionWaiter {
let settled = false;
let removeHandler: () => void = () => {};
let timeout: ReturnType<typeof setTimeout> | undefined;
let failWaiter: (error: Error) => void = () => {};
const promise = new Promise<CodexNativeCompactionCompletion>((resolve, reject) => {
const cleanup = (): void => {
removeHandler();
if (timeout) {
clearTimeout(timeout);
}
};
const complete = (completion: CodexNativeCompactionCompletion): void => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve(completion);
};
const fail = (error: Error): void => {
if (settled) {
return;
}
settled = true;
cleanup();
reject(error);
};
failWaiter = fail;
const handler: CodexServerNotificationHandler = (notification) => {
const completion = readNativeCompactionCompletion(notification, threadId);
if (completion) {
complete(completion);
}
};
removeHandler = client.addNotificationHandler(handler);
});
return {
promise,
startTimeout(): void {
if (settled || timeout) {
return;
}
timeout = setTimeout(() => {
failWaiter(new Error(`timed out waiting for codex app-server compaction for ${threadId}`));
}, resolveCompactionWaitTimeoutMs());
timeout.unref?.();
},
cancel(): void {
if (settled) {
return;
}
settled = true;
removeHandler();
if (timeout) {
clearTimeout(timeout);
}
},
};
}
function readNativeCompactionCompletion(
notification: CodexServerNotification,
threadId: string,
): CodexNativeCompactionCompletion | undefined {
const params = notification.params;
if (!isJsonObject(params) || readString(params, "threadId", "thread_id") !== threadId) {
return undefined;
}
if (notification.method === "thread/compacted") {
return {
signal: "thread/compacted",
turnId: readString(params, "turnId", "turn_id"),
};
}
if (notification.method !== "item/completed") {
return undefined;
}
const item = isJsonObject(params.item) ? params.item : undefined;
if (readString(item, "type") !== "contextCompaction") {
return undefined;
}
return {
signal: "item/completed",
turnId: readString(params, "turnId", "turn_id"),
itemId: readString(item, "id") ?? readString(params, "itemId", "item_id", "id"),
};
}
function resolveCompactionWaitTimeoutMs(): number {
const raw = process.env.OPENCLAW_CODEX_COMPACTION_WAIT_TIMEOUT_MS?.trim();
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
return DEFAULT_CODEX_COMPACTION_WAIT_TIMEOUT_MS;
}
function readString(params: JsonObject | undefined, ...keys: string[]): string | undefined {
if (!params) {
return undefined;
}
for (const key of keys) {
const value = params[key];
if (typeof value === "string") {
return value;
}
}
return undefined;
}
function formatCompactionError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export const __testing = {
setCodexAppServerClientFactoryForTests(factory: CodexAppServerClientFactory): void {
clientFactory = factory;
},
resetCodexAppServerClientFactoryForTests(): void {
clientFactory = (startOptions, authProfileId) =>
getSharedCodexAppServerClient({ startOptions, authProfileId });
},
} as const;

View file

@ -0,0 +1,83 @@
import fs from "node:fs/promises";
import { describe, expect, it } from "vitest";
import {
CODEX_APP_SERVER_CONFIG_KEYS,
readCodexPluginConfig,
resolveCodexAppServerRuntimeOptions,
} from "./config.js";
describe("Codex app-server config", () => {
it("parses typed plugin config before falling back to environment knobs", () => {
const runtime = resolveCodexAppServerRuntimeOptions({
pluginConfig: {
appServer: {
transport: "websocket",
url: "ws://127.0.0.1:39175",
headers: { "X-Test": "yes" },
approvalPolicy: "on-request",
sandbox: "danger-full-access",
approvalsReviewer: "guardian_subagent",
serviceTier: "priority",
},
},
env: {
OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY: "never",
OPENCLAW_CODEX_APP_SERVER_SANDBOX: "read-only",
},
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "on-request",
sandbox: "danger-full-access",
approvalsReviewer: "guardian_subagent",
serviceTier: "priority",
start: expect.objectContaining({
transport: "websocket",
url: "ws://127.0.0.1:39175",
headers: { "X-Test": "yes" },
}),
}),
);
});
it("rejects malformed plugin config instead of treating freeform strings as control values", () => {
expect(
readCodexPluginConfig({
appServer: {
approvalPolicy: "always",
},
}),
).toEqual({});
});
it("requires a websocket url when websocket transport is configured", () => {
expect(() =>
resolveCodexAppServerRuntimeOptions({
pluginConfig: { appServer: { transport: "websocket" } },
env: {},
}),
).toThrow("appServer.url is required");
});
it("keeps runtime config keys aligned with manifest schema and UI hints", async () => {
const manifest = JSON.parse(
await fs.readFile(new URL("../../openclaw.plugin.json", import.meta.url), "utf8"),
) as {
configSchema: {
properties: {
appServer: { properties: Record<string, unknown> };
};
};
uiHints: Record<string, unknown>;
};
const manifestKeys = Object.keys(
manifest.configSchema.properties.appServer.properties,
).toSorted();
expect(manifestKeys).toEqual([...CODEX_APP_SERVER_CONFIG_KEYS].toSorted());
for (const key of CODEX_APP_SERVER_CONFIG_KEYS) {
expect(manifest.uiHints[`appServer.${key}`]).toBeTruthy();
}
});
});

View file

@ -0,0 +1,256 @@
import { z } from "zod";
export type CodexAppServerTransportMode = "stdio" | "websocket";
export type CodexAppServerApprovalPolicy = "never" | "on-request" | "on-failure" | "untrusted";
export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "danger-full-access";
export type CodexAppServerApprovalsReviewer = "user" | "guardian_subagent";
export type CodexAppServerStartOptions = {
transport: CodexAppServerTransportMode;
command: string;
args: string[];
url?: string;
authToken?: string;
headers: Record<string, string>;
env?: Record<string, string>;
clearEnv?: string[];
};
export type CodexAppServerRuntimeOptions = {
start: CodexAppServerStartOptions;
requestTimeoutMs: number;
approvalPolicy: CodexAppServerApprovalPolicy;
sandbox: CodexAppServerSandboxMode;
approvalsReviewer: CodexAppServerApprovalsReviewer;
serviceTier?: string;
};
export type CodexPluginConfig = {
discovery?: {
enabled?: boolean;
timeoutMs?: number;
};
appServer?: {
transport?: CodexAppServerTransportMode;
command?: string;
args?: string[] | string;
url?: string;
authToken?: string;
headers?: Record<string, string>;
requestTimeoutMs?: number;
approvalPolicy?: CodexAppServerApprovalPolicy;
sandbox?: CodexAppServerSandboxMode;
approvalsReviewer?: CodexAppServerApprovalsReviewer;
serviceTier?: string;
};
};
export const CODEX_APP_SERVER_CONFIG_KEYS = [
"transport",
"command",
"args",
"url",
"authToken",
"headers",
"requestTimeoutMs",
"approvalPolicy",
"sandbox",
"approvalsReviewer",
"serviceTier",
] as const;
const codexAppServerTransportSchema = z.enum(["stdio", "websocket"]);
const codexAppServerApprovalPolicySchema = z.enum([
"never",
"on-request",
"on-failure",
"untrusted",
]);
const codexAppServerSandboxSchema = z.enum(["read-only", "workspace-write", "danger-full-access"]);
const codexAppServerApprovalsReviewerSchema = z.enum(["user", "guardian_subagent"]);
const codexPluginConfigSchema = z
.object({
discovery: z
.object({
enabled: z.boolean().optional(),
timeoutMs: z.number().positive().optional(),
})
.strict()
.optional(),
appServer: z
.object({
transport: codexAppServerTransportSchema.optional(),
command: z.string().optional(),
args: z.union([z.array(z.string()), z.string()]).optional(),
url: z.string().optional(),
authToken: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
requestTimeoutMs: z.number().positive().optional(),
approvalPolicy: codexAppServerApprovalPolicySchema.optional(),
sandbox: codexAppServerSandboxSchema.optional(),
approvalsReviewer: codexAppServerApprovalsReviewerSchema.optional(),
serviceTier: z.string().optional(),
})
.strict()
.optional(),
})
.strict();
export function readCodexPluginConfig(value: unknown): CodexPluginConfig {
const parsed = codexPluginConfigSchema.safeParse(value);
return parsed.success ? parsed.data : {};
}
export function resolveCodexAppServerRuntimeOptions(
params: {
pluginConfig?: unknown;
env?: NodeJS.ProcessEnv;
} = {},
): CodexAppServerRuntimeOptions {
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 args = resolveArgs(config.args, env.OPENCLAW_CODEX_APP_SERVER_ARGS);
const headers = normalizeHeaders(config.headers);
const authToken = readNonEmptyString(config.authToken);
const url = readNonEmptyString(config.url);
if (transport === "websocket" && !url) {
throw new Error(
"plugins.entries.codex.config.appServer.url is required when appServer.transport is websocket",
);
}
return {
start: {
transport,
command,
args: args.length > 0 ? args : ["app-server", "--listen", "stdio://"],
...(url ? { url } : {}),
...(authToken ? { authToken } : {}),
headers,
},
requestTimeoutMs: normalizePositiveNumber(config.requestTimeoutMs, 60_000),
approvalPolicy:
resolveApprovalPolicy(config.approvalPolicy) ??
resolveApprovalPolicy(env.OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY) ??
"never",
sandbox:
resolveSandbox(config.sandbox) ??
resolveSandbox(env.OPENCLAW_CODEX_APP_SERVER_SANDBOX) ??
"workspace-write",
approvalsReviewer:
resolveApprovalsReviewer(config.approvalsReviewer) ??
(env.OPENCLAW_CODEX_APP_SERVER_GUARDIAN === "1" ? "guardian_subagent" : "user"),
...(readNonEmptyString(config.serviceTier)
? { serviceTier: readNonEmptyString(config.serviceTier) }
: {}),
};
}
export function codexAppServerStartOptionsKey(options: CodexAppServerStartOptions): string {
return JSON.stringify({
transport: options.transport,
command: options.command,
args: options.args,
url: options.url ?? null,
authToken: options.authToken ? "<set>" : null,
headers: Object.entries(options.headers).toSorted(([left], [right]) =>
left.localeCompare(right),
),
env: Object.entries(options.env ?? {}).toSorted(([left], [right]) => left.localeCompare(right)),
clearEnv: [...(options.clearEnv ?? [])].toSorted(),
});
}
function resolveTransport(value: unknown): CodexAppServerTransportMode {
return value === "websocket" ? "websocket" : "stdio";
}
function resolveApprovalPolicy(value: unknown): CodexAppServerApprovalPolicy | undefined {
return value === "on-request" ||
value === "on-failure" ||
value === "untrusted" ||
value === "never"
? value
: undefined;
}
function resolveSandbox(value: unknown): CodexAppServerSandboxMode | undefined {
return value === "read-only" || value === "workspace-write" || value === "danger-full-access"
? value
: undefined;
}
function resolveApprovalsReviewer(value: unknown): CodexAppServerApprovalsReviewer | undefined {
return value === "guardian_subagent" || value === "user" ? value : undefined;
}
function normalizePositiveNumber(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
}
function normalizeHeaders(value: unknown): Record<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return Object.fromEntries(
Object.entries(value)
.map(([key, child]) => [key.trim(), readNonEmptyString(child)] as const)
.filter((entry): entry is readonly [string, string] => Boolean(entry[0] && entry[1])),
);
}
function resolveArgs(configArgs: unknown, envArgs: string | undefined): string[] {
if (Array.isArray(configArgs)) {
return configArgs
.map((entry) => readNonEmptyString(entry))
.filter((entry): entry is string => entry !== undefined);
}
if (typeof configArgs === "string") {
return splitShellWords(configArgs);
}
return splitShellWords(envArgs ?? "");
}
function readNonEmptyString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
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;
}

View file

@ -0,0 +1,178 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { AnyAgentTool } from "openclaw/plugin-sdk/agent-harness";
import { describe, expect, it, vi } from "vitest";
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
function createTool(overrides: Partial<AnyAgentTool>): AnyAgentTool {
return {
name: "tts",
description: "Convert text to speech.",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
...overrides,
} as unknown as AnyAgentTool;
}
describe("createCodexDynamicToolBridge", () => {
it.each([
{ toolName: "tts", mediaUrl: "/tmp/reply.opus", audioAsVoice: true },
{ toolName: "image_generate", mediaUrl: "/tmp/generated.png" },
{ toolName: "video_generate", mediaUrl: "https://media.example/video.mp4" },
{ toolName: "music_generate", mediaUrl: "https://media.example/music.wav" },
])(
"preserves structured media artifacts from $toolName tool results",
async ({ toolName, mediaUrl, audioAsVoice }) => {
const toolResult = {
content: [{ type: "text", text: "Generated media reply." }],
details: {
media: {
mediaUrl,
...(audioAsVoice === true ? { audioAsVoice: true } : {}),
},
},
} satisfies AgentToolResult<unknown>;
const tool = createTool({
name: toolName,
execute: vi.fn(async () => toolResult),
});
const bridge = createCodexDynamicToolBridge({
tools: [tool],
signal: new AbortController().signal,
});
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
tool: toolName,
arguments: { prompt: "hello" },
});
expect(result).toEqual({
success: true,
contentItems: [{ type: "inputText", text: "Generated media reply." }],
});
expect(bridge.telemetry.toolMediaUrls).toEqual([mediaUrl]);
expect(bridge.telemetry.toolAudioAsVoice).toBe(audioAsVoice === true);
},
);
it("preserves audio-as-voice metadata from tts results", async () => {
const toolResult = {
content: [{ type: "text", text: "Generated audio reply." }],
details: {
media: {
mediaUrl: "/tmp/reply.opus",
audioAsVoice: true,
},
},
} satisfies AgentToolResult<unknown>;
const tool = createTool({
execute: vi.fn(async () => toolResult),
});
const bridge = createCodexDynamicToolBridge({
tools: [tool],
signal: new AbortController().signal,
});
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
tool: "tts",
arguments: { text: "hello" },
});
expect(result).toEqual({
success: true,
contentItems: [{ type: "inputText", text: "Generated audio reply." }],
});
expect(bridge.telemetry.toolMediaUrls).toEqual(["/tmp/reply.opus"]);
expect(bridge.telemetry.toolAudioAsVoice).toBe(true);
});
it("records messaging tool side effects while returning concise text to app-server", async () => {
const toolResult = {
content: [{ type: "text", text: "Sent." }],
details: { messageId: "message-1" },
} satisfies AgentToolResult<unknown>;
const tool = createTool({
name: "message",
execute: vi.fn(async () => toolResult),
});
const bridge = createCodexDynamicToolBridge({
tools: [tool],
signal: new AbortController().signal,
});
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
tool: "message",
arguments: {
action: "send",
text: "hello from Codex",
mediaUrl: "/tmp/reply.png",
provider: "telegram",
to: "chat-1",
threadId: "thread-ts-1",
},
});
expect(result).toEqual({
success: true,
contentItems: [{ type: "inputText", text: "Sent." }],
});
expect(bridge.telemetry).toMatchObject({
didSendViaMessagingTool: true,
messagingToolSentTexts: ["hello from Codex"],
messagingToolSentMediaUrls: ["/tmp/reply.png"],
messagingToolSentTargets: [
{
tool: "message",
provider: "telegram",
to: "chat-1",
threadId: "thread-ts-1",
},
],
});
});
it("does not record messaging side effects when the send fails", async () => {
const tool = createTool({
name: "message",
execute: vi.fn(async () => {
throw new Error("send failed");
}),
});
const bridge = createCodexDynamicToolBridge({
tools: [tool],
signal: new AbortController().signal,
});
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
tool: "message",
arguments: {
action: "send",
text: "not delivered",
provider: "slack",
to: "C123",
},
});
expect(result).toEqual({
success: false,
contentItems: [{ type: "inputText", text: "send failed" }],
});
expect(bridge.telemetry).toMatchObject({
didSendViaMessagingTool: false,
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
});
});
});

View file

@ -0,0 +1,220 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
import {
extractToolResultMediaArtifact,
filterToolResultMediaUrls,
isMessagingTool,
isMessagingToolSendAction,
type AnyAgentTool,
type MessagingToolSend,
} from "openclaw/plugin-sdk/agent-harness";
import {
type CodexDynamicToolCallOutputContentItem,
type CodexDynamicToolCallParams,
type CodexDynamicToolCallResponse,
type CodexDynamicToolSpec,
type JsonValue,
} from "./protocol.js";
export type CodexDynamicToolBridge = {
specs: CodexDynamicToolSpec[];
handleToolCall: (params: CodexDynamicToolCallParams) => Promise<CodexDynamicToolCallResponse>;
telemetry: {
didSendViaMessagingTool: boolean;
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
toolMediaUrls: string[];
toolAudioAsVoice: boolean;
successfulCronAdds?: number;
};
};
export function createCodexDynamicToolBridge(params: {
tools: AnyAgentTool[];
signal: AbortSignal;
}): CodexDynamicToolBridge {
const toolMap = new Map(params.tools.map((tool) => [tool.name, tool]));
const telemetry: CodexDynamicToolBridge["telemetry"] = {
didSendViaMessagingTool: false,
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
toolMediaUrls: [],
toolAudioAsVoice: false,
};
return {
specs: params.tools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: toJsonValue(tool.parameters),
})),
telemetry,
handleToolCall: async (call) => {
const tool = toolMap.get(call.tool);
if (!tool) {
return {
contentItems: [{ type: "inputText", text: `Unknown OpenClaw tool: ${call.tool}` }],
success: false,
};
}
const args = jsonObjectToRecord(call.arguments);
try {
const preparedArgs = tool.prepareArguments ? tool.prepareArguments(args) : args;
const result = await tool.execute(call.callId, preparedArgs, params.signal);
collectToolTelemetry({
toolName: tool.name,
args,
result,
telemetry,
isError: false,
});
return {
contentItems: result.content.flatMap(convertToolContent),
success: true,
};
} catch (error) {
collectToolTelemetry({
toolName: tool.name,
args,
result: undefined,
telemetry,
isError: true,
});
return {
contentItems: [
{
type: "inputText",
text: error instanceof Error ? error.message : String(error),
},
],
success: false,
};
}
},
};
}
function collectToolTelemetry(params: {
toolName: string;
args: Record<string, unknown>;
result: AgentToolResult<unknown> | undefined;
telemetry: CodexDynamicToolBridge["telemetry"];
isError: boolean;
}): void {
if (params.isError) {
return;
}
if (!params.isError && params.toolName === "cron" && isCronAddAction(params.args)) {
params.telemetry.successfulCronAdds = (params.telemetry.successfulCronAdds ?? 0) + 1;
}
if (!params.isError && params.result) {
const media = extractToolResultMediaArtifact(params.result);
if (media) {
const mediaUrls = filterToolResultMediaUrls(params.toolName, media.mediaUrls, params.result);
const seen = new Set(params.telemetry.toolMediaUrls);
for (const mediaUrl of mediaUrls) {
if (!seen.has(mediaUrl)) {
seen.add(mediaUrl);
params.telemetry.toolMediaUrls.push(mediaUrl);
}
}
if (media.audioAsVoice) {
params.telemetry.toolAudioAsVoice = true;
}
}
}
if (
!isMessagingTool(params.toolName) ||
!isMessagingToolSendAction(params.toolName, params.args)
) {
return;
}
params.telemetry.didSendViaMessagingTool = true;
const text = readFirstString(params.args, ["text", "message", "body", "content"]);
if (text) {
params.telemetry.messagingToolSentTexts.push(text);
}
params.telemetry.messagingToolSentMediaUrls.push(...collectMediaUrls(params.args));
params.telemetry.messagingToolSentTargets.push({
tool: params.toolName,
provider: readFirstString(params.args, ["provider", "channel"]) ?? params.toolName,
accountId: readFirstString(params.args, ["accountId", "account_id"]),
to: readFirstString(params.args, ["to", "target", "recipient"]),
threadId: readFirstString(params.args, ["threadId", "thread_id", "messageThreadId"]),
});
}
function convertToolContent(
content: TextContent | ImageContent,
): CodexDynamicToolCallOutputContentItem[] {
if (content.type === "text") {
return [{ type: "inputText", text: content.text }];
}
return [
{
type: "inputImage",
imageUrl: `data:${content.mimeType};base64,${content.data}`,
},
];
}
function toJsonValue(value: unknown): JsonValue {
try {
const text = JSON.stringify(value);
if (!text) {
return {};
}
return JSON.parse(text) as JsonValue;
} catch {
return {};
}
}
function jsonObjectToRecord(value: JsonValue | undefined): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
}
function readFirstString(record: Record<string, unknown>, keys: string[]): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
}
return undefined;
}
function collectMediaUrls(record: Record<string, unknown>): string[] {
const urls: string[] = [];
for (const key of ["mediaUrl", "media_url", "imageUrl", "image_url"]) {
const value = record[key];
if (typeof value === "string" && value.trim()) {
urls.push(value.trim());
}
}
for (const key of ["mediaUrls", "media_urls", "imageUrls", "image_urls"]) {
const value = record[key];
if (!Array.isArray(value)) {
continue;
}
for (const entry of value) {
if (typeof entry === "string" && entry.trim()) {
urls.push(entry.trim());
}
}
}
return urls;
}
function isCronAddAction(args: Record<string, unknown>): boolean {
const action = args.action;
return typeof action === "string" && action.trim().toLowerCase() === "add";
}

View file

@ -0,0 +1,392 @@
import type { Api, Model } from "@mariozechner/pi-ai";
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness";
import { describe, expect, it, vi } from "vitest";
import { CodexAppServerEventProjector } from "./event-projector.js";
function createParams(): EmbeddedRunAttemptParams {
return {
prompt: "hello",
sessionId: "session-1",
provider: "openai-codex",
modelId: "gpt-5.4-codex",
model: {
id: "gpt-5.4-codex",
name: "gpt-5.4-codex",
provider: "openai-codex",
api: "openai-codex-responses",
input: ["text"],
reasoning: true,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 8_000,
} as Model<Api>,
thinkLevel: "medium",
} as unknown as EmbeddedRunAttemptParams;
}
describe("CodexAppServerEventProjector", () => {
it("projects assistant deltas and usage into embedded attempt results", async () => {
const onAssistantMessageStart = vi.fn();
const onPartialReply = vi.fn();
const params = {
...createParams(),
onAssistantMessageStart,
onPartialReply,
};
const projector = new CodexAppServerEventProjector(params, "thread-1", "turn-1");
await projector.handleNotification({
method: "item/agentMessage/delta",
params: { threadId: "thread-1", turnId: "turn-1", itemId: "msg-1", delta: "hel" },
});
await projector.handleNotification({
method: "item/agentMessage/delta",
params: { threadId: "thread-1", turnId: "turn-1", itemId: "msg-1", delta: "lo" },
});
await projector.handleNotification({
method: "thread/tokenUsage/updated",
params: {
threadId: "thread-1",
turnId: "turn-1",
tokenUsage: {
total: {
totalTokens: 900_000,
inputTokens: 700_000,
cachedInputTokens: 100_000,
outputTokens: 100_000,
},
last: {
totalTokens: 14,
inputTokens: 5,
cachedInputTokens: 2,
outputTokens: 7,
},
},
},
});
await projector.handleNotification({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: {
id: "turn-1",
status: "completed",
items: [{ type: "agentMessage", id: "msg-1", text: "hello" }],
},
},
});
const result = projector.buildResult({
didSendViaMessagingTool: false,
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
});
expect(onAssistantMessageStart).toHaveBeenCalledTimes(1);
expect(onPartialReply).not.toHaveBeenCalled();
expect(result.assistantTexts).toEqual(["hello"]);
expect(result.messagesSnapshot.map((message) => message.role)).toEqual(["user", "assistant"]);
expect(result.lastAssistant?.content).toEqual([{ type: "text", text: "hello" }]);
expect(result.attemptUsage).toMatchObject({ input: 5, output: 7, cacheRead: 2, total: 14 });
expect(result.lastAssistant?.usage).toMatchObject({
input: 5,
output: 7,
cacheRead: 2,
totalTokens: 14,
});
expect(result.replayMetadata.replaySafe).toBe(true);
});
it("does not treat cumulative-only token usage as fresh context usage", async () => {
const params = createParams();
const projector = new CodexAppServerEventProjector(params, "thread-1", "turn-1");
await projector.handleNotification({
method: "item/agentMessage/delta",
params: { threadId: "thread-1", turnId: "turn-1", itemId: "msg-1", delta: "done" },
});
await projector.handleNotification({
method: "thread/tokenUsage/updated",
params: {
threadId: "thread-1",
turnId: "turn-1",
tokenUsage: {
total: {
totalTokens: 1_000_000,
inputTokens: 999_000,
cachedInputTokens: 500,
outputTokens: 500,
},
},
},
});
const result = projector.buildResult({
didSendViaMessagingTool: false,
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
});
expect(result.assistantTexts).toEqual(["done"]);
expect(result.attemptUsage).toBeUndefined();
expect(result.lastAssistant?.usage).toMatchObject({
input: 0,
output: 0,
cacheRead: 0,
totalTokens: 0,
});
});
it("normalizes snake_case current token usage fields", async () => {
const params = createParams();
const projector = new CodexAppServerEventProjector(params, "thread-1", "turn-1");
await projector.handleNotification({
method: "item/agentMessage/delta",
params: { threadId: "thread-1", turnId: "turn-1", itemId: "msg-1", delta: "done" },
});
await projector.handleNotification({
method: "thread/tokenUsage/updated",
params: {
threadId: "thread-1",
turnId: "turn-1",
tokenUsage: {
total: { total_tokens: 1_000_000 },
last_token_usage: {
total_tokens: 20,
input_tokens: 8,
cached_input_tokens: 3,
output_tokens: 9,
},
},
},
});
const result = projector.buildResult({
didSendViaMessagingTool: false,
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
});
expect(result.attemptUsage).toMatchObject({ input: 8, output: 9, cacheRead: 3, total: 20 });
expect(result.lastAssistant?.usage).toMatchObject({
input: 8,
output: 9,
cacheRead: 3,
totalTokens: 20,
});
});
it("keeps intermediate agentMessage items out of the final visible reply", async () => {
const onAssistantMessageStart = vi.fn();
const onPartialReply = vi.fn();
const params = {
...createParams(),
onAssistantMessageStart,
onPartialReply,
};
const projector = new CodexAppServerEventProjector(params, "thread-1", "turn-1");
await projector.handleNotification({
method: "item/agentMessage/delta",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "msg-commentary",
delta: "checking thread context; then post a tight progress reply here.",
},
});
await projector.handleNotification({
method: "item/agentMessage/delta",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "msg-final",
delta: "release fixes first. please drop affected PRs, failing checks, and blockers here.",
},
});
await projector.handleNotification({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: {
id: "turn-1",
status: "completed",
items: [
{
type: "agentMessage",
id: "msg-commentary",
text: "checking thread context; then post a tight progress reply here.",
},
{
type: "agentMessage",
id: "msg-final",
text: "release fixes first. please drop affected PRs, failing checks, and blockers here.",
},
],
},
},
});
const result = projector.buildResult({
didSendViaMessagingTool: false,
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
});
expect(onAssistantMessageStart).toHaveBeenCalledTimes(1);
expect(onPartialReply).not.toHaveBeenCalled();
expect(result.assistantTexts).toEqual([
"release fixes first. please drop affected PRs, failing checks, and blockers here.",
]);
expect(result.lastAssistant?.content).toEqual([
{
type: "text",
text: "release fixes first. please drop affected PRs, failing checks, and blockers here.",
},
]);
expect(JSON.stringify(result.messagesSnapshot)).not.toContain("checking thread context");
});
it("ignores notifications for other turns", async () => {
const params = createParams();
const projector = new CodexAppServerEventProjector(params, "thread-1", "turn-1");
await projector.handleNotification({
method: "item/agentMessage/delta",
params: { threadId: "thread-1", turnId: "turn-2", itemId: "msg-1", delta: "wrong" },
});
const result = projector.buildResult({
didSendViaMessagingTool: false,
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
});
expect(result.assistantTexts).toEqual([]);
});
it("preserves sessions_yield detection in attempt results", () => {
const params = createParams();
const projector = new CodexAppServerEventProjector(params, "thread-1", "turn-1");
const result = projector.buildResult(
{
didSendViaMessagingTool: false,
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
},
{ yieldDetected: true },
);
expect(result.yieldDetected).toBe(true);
});
it("projects reasoning end, plan updates, compaction state, and tool metadata", async () => {
const onReasoningStream = vi.fn();
const onReasoningEnd = vi.fn();
const onAgentEvent = vi.fn();
const params = {
...createParams(),
onReasoningStream,
onReasoningEnd,
onAgentEvent,
};
const projector = new CodexAppServerEventProjector(params, "thread-1", "turn-1");
await projector.handleNotification({
method: "item/reasoning/textDelta",
params: { threadId: "thread-1", turnId: "turn-1", itemId: "reason-1", delta: "thinking" },
});
await projector.handleNotification({
method: "item/plan/delta",
params: { threadId: "thread-1", turnId: "turn-1", itemId: "plan-1", delta: "- inspect\n" },
});
await projector.handleNotification({
method: "turn/plan/updated",
params: {
threadId: "thread-1",
turnId: "turn-1",
explanation: "next",
plan: [{ step: "patch", status: "in_progress" }],
},
});
await projector.handleNotification({
method: "item/started",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: { type: "contextCompaction", id: "compact-1" },
},
});
expect(projector.isCompacting()).toBe(true);
await projector.handleNotification({
method: "item/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: { type: "contextCompaction", id: "compact-1" },
},
});
expect(projector.isCompacting()).toBe(false);
await projector.handleNotification({
method: "item/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
type: "dynamicToolCall",
id: "tool-1",
tool: "sessions_send",
status: "completed",
},
},
});
await projector.handleNotification({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: { id: "turn-1", status: "completed", items: [] },
},
});
const result = projector.buildResult({
didSendViaMessagingTool: false,
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
});
expect(onReasoningStream).toHaveBeenCalledWith({ text: "thinking" });
expect(onReasoningEnd).toHaveBeenCalledTimes(1);
expect(onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "plan",
data: expect.objectContaining({ steps: ["patch (in_progress)"] }),
}),
);
expect(onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "compaction",
data: expect.objectContaining({ phase: "start", itemId: "compact-1" }),
}),
);
expect(result.toolMetas).toEqual([{ toolName: "sessions_send", meta: "completed" }]);
expect(result.messagesSnapshot.map((message) => message.role)).toEqual([
"user",
"assistant",
"assistant",
]);
expect(JSON.stringify(result.messagesSnapshot[1])).toContain("Codex reasoning");
expect(JSON.stringify(result.messagesSnapshot[2])).toContain("Codex plan");
expect(result.itemLifecycle).toMatchObject({ compactionCount: 1 });
});
});

View file

@ -0,0 +1,714 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Usage } from "@mariozechner/pi-ai";
import {
formatErrorMessage,
normalizeUsage,
type NormalizedUsage,
type EmbeddedRunAttemptParams,
type EmbeddedRunAttemptResult,
type MessagingToolSend,
} from "openclaw/plugin-sdk/agent-harness";
import {
isJsonObject,
type CodexServerNotification,
type CodexThreadItem,
type CodexTurn,
type JsonObject,
type JsonValue,
} from "./protocol.js";
export type CodexAppServerToolTelemetry = {
didSendViaMessagingTool: boolean;
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
toolMediaUrls?: string[];
toolAudioAsVoice?: boolean;
successfulCronAdds?: number;
};
const ZERO_USAGE: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
const CURRENT_TOKEN_USAGE_KEYS = [
"last",
"current",
"lastCall",
"lastCallUsage",
"lastTokenUsage",
"last_token_usage",
] as const;
export class CodexAppServerEventProjector {
private readonly assistantTextByItem = new Map<string, string>();
private readonly assistantItemOrder: string[] = [];
private readonly reasoningTextByItem = new Map<string, string>();
private readonly planTextByItem = new Map<string, string>();
private readonly activeItemIds = new Set<string>();
private readonly completedItemIds = new Set<string>();
private readonly activeCompactionItemIds = new Set<string>();
private readonly toolMetas = new Map<string, { toolName: string; meta?: string }>();
private assistantStarted = false;
private reasoningStarted = false;
private reasoningEnded = false;
private completedTurn: CodexTurn | undefined;
private promptError: unknown;
private promptErrorSource: EmbeddedRunAttemptResult["promptErrorSource"] = null;
private aborted = false;
private tokenUsage: NormalizedUsage | undefined;
private guardianReviewCount = 0;
private completedCompactionCount = 0;
constructor(
private readonly params: EmbeddedRunAttemptParams,
private readonly threadId: string,
private readonly turnId: string,
) {}
async handleNotification(notification: CodexServerNotification): Promise<void> {
const params = isJsonObject(notification.params) ? notification.params : undefined;
if (!params || !this.isNotificationForTurn(params)) {
return;
}
switch (notification.method) {
case "item/agentMessage/delta":
await this.handleAssistantDelta(params);
break;
case "item/reasoning/summaryTextDelta":
case "item/reasoning/textDelta":
await this.handleReasoningDelta(params);
break;
case "item/plan/delta":
this.handlePlanDelta(params);
break;
case "turn/plan/updated":
this.handleTurnPlanUpdated(params);
break;
case "item/started":
this.handleItemStarted(params);
break;
case "item/completed":
this.handleItemCompleted(params);
break;
case "item/autoApprovalReview/started":
case "item/autoApprovalReview/completed":
this.guardianReviewCount += 1;
this.params.onAgentEvent?.({
stream: "codex_app_server.guardian",
data: { method: notification.method },
});
break;
case "thread/tokenUsage/updated":
this.handleTokenUsage(params);
break;
case "turn/completed":
await this.handleTurnCompleted(params);
break;
case "error":
this.promptError = readString(params, "message") ?? "codex app-server error";
this.promptErrorSource = "prompt";
break;
default:
break;
}
}
buildResult(
toolTelemetry: CodexAppServerToolTelemetry,
options?: { yieldDetected?: boolean },
): EmbeddedRunAttemptResult {
const assistantTexts = this.collectAssistantTexts();
const reasoningText = collectTextValues(this.reasoningTextByItem).join("\n\n");
const planText = collectTextValues(this.planTextByItem).join("\n\n");
const lastAssistant =
assistantTexts.length > 0
? this.createAssistantMessage(assistantTexts.join("\n\n"))
: undefined;
const messagesSnapshot: AgentMessage[] = [
{
role: "user",
content: this.params.prompt,
timestamp: Date.now(),
},
];
// Codex owns the canonical thread. These mirror records keep enough local
// context for OpenClaw history, search, and future harness switching.
if (reasoningText) {
messagesSnapshot.push(this.createAssistantMirrorMessage("Codex reasoning", reasoningText));
}
if (planText) {
messagesSnapshot.push(this.createAssistantMirrorMessage("Codex plan", planText));
}
if (lastAssistant) {
messagesSnapshot.push(lastAssistant);
}
const turnFailed = this.completedTurn?.status === "failed";
const turnInterrupted = this.completedTurn?.status === "interrupted";
const promptError =
this.promptError ??
(turnFailed ? (this.completedTurn?.error?.message ?? "codex app-server turn failed") : null);
return {
aborted: this.aborted || turnInterrupted,
externalAbort: false,
timedOut: false,
idleTimedOut: false,
timedOutDuringCompaction: false,
promptError,
promptErrorSource: promptError ? this.promptErrorSource || "prompt" : null,
sessionIdUsed: this.params.sessionId,
bootstrapPromptWarningSignaturesSeen: this.params.bootstrapPromptWarningSignaturesSeen,
bootstrapPromptWarningSignature: this.params.bootstrapPromptWarningSignature,
messagesSnapshot,
assistantTexts,
toolMetas: [...this.toolMetas.values()],
lastAssistant,
didSendViaMessagingTool: toolTelemetry.didSendViaMessagingTool,
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,
toolMediaUrls: toolTelemetry.toolMediaUrls,
toolAudioAsVoice: toolTelemetry.toolAudioAsVoice,
successfulCronAdds: toolTelemetry.successfulCronAdds,
cloudCodeAssistFormatError: false,
attemptUsage: this.tokenUsage,
replayMetadata: {
hadPotentialSideEffects: toolTelemetry.didSendViaMessagingTool,
replaySafe: !toolTelemetry.didSendViaMessagingTool,
},
itemLifecycle: {
startedCount: this.activeItemIds.size + this.completedItemIds.size,
completedCount: this.completedItemIds.size,
activeCount: this.activeItemIds.size,
...(this.completedCompactionCount > 0
? { compactionCount: this.completedCompactionCount }
: {}),
},
yieldDetected: options?.yieldDetected || false,
didSendDeterministicApprovalPrompt: this.guardianReviewCount > 0 ? false : undefined,
};
}
markTimedOut(): void {
this.aborted = true;
this.promptError = "codex app-server attempt timed out";
this.promptErrorSource = "prompt";
}
isCompacting(): boolean {
return this.activeCompactionItemIds.size > 0;
}
private async handleAssistantDelta(params: JsonObject): Promise<void> {
const itemId = readString(params, "itemId") ?? readString(params, "id") ?? "assistant";
const delta = readString(params, "delta") ?? "";
if (!delta) {
return;
}
if (!this.assistantStarted) {
this.assistantStarted = true;
await this.params.onAssistantMessageStart?.();
}
this.rememberAssistantItem(itemId);
const text = `${this.assistantTextByItem.get(itemId) ?? ""}${delta}`;
this.assistantTextByItem.set(itemId, text);
// Codex app-server can emit multiple agentMessage items per turn, including
// intermediate coordination/progress prose. Keep those deltas internal until
// turn completion chooses the last assistant item as the user-visible reply.
}
private async handleReasoningDelta(params: JsonObject): Promise<void> {
const itemId = readString(params, "itemId") ?? readString(params, "id") ?? "reasoning";
const delta = readString(params, "delta") ?? "";
if (!delta) {
return;
}
this.reasoningStarted = true;
this.reasoningTextByItem.set(itemId, `${this.reasoningTextByItem.get(itemId) ?? ""}${delta}`);
await this.params.onReasoningStream?.({ text: delta });
}
private handlePlanDelta(params: JsonObject): void {
const itemId = readString(params, "itemId") ?? readString(params, "id") ?? "plan";
const delta = readString(params, "delta") ?? "";
if (!delta) {
return;
}
const text = `${this.planTextByItem.get(itemId) ?? ""}${delta}`;
this.planTextByItem.set(itemId, text);
this.emitPlanUpdate({ explanation: undefined, steps: splitPlanText(text) });
}
private handleTurnPlanUpdated(params: JsonObject): void {
const plan = Array.isArray(params.plan)
? params.plan.flatMap((entry) => {
if (!isJsonObject(entry)) {
return [];
}
const step = readString(entry, "step");
const status = readString(entry, "status");
if (!step) {
return [];
}
return status ? [`${step} (${status})`] : [step];
})
: undefined;
this.emitPlanUpdate({
explanation: readNullableString(params, "explanation"),
steps: plan,
});
}
private handleItemStarted(params: JsonObject): void {
const item = readItem(params.item);
const itemId = item?.id ?? readString(params, "itemId") ?? readString(params, "id");
if (itemId) {
this.activeItemIds.add(itemId);
}
if (item?.type === "contextCompaction" && itemId) {
this.activeCompactionItemIds.add(itemId);
this.params.onAgentEvent?.({
stream: "compaction",
data: {
phase: "start",
backend: "codex-app-server",
threadId: this.threadId,
turnId: this.turnId,
itemId,
},
});
}
this.emitStandardItemEvent({ phase: "start", item });
this.params.onAgentEvent?.({
stream: "codex_app_server.item",
data: { phase: "started", itemId, type: item?.type },
});
}
private handleItemCompleted(params: JsonObject): void {
const item = readItem(params.item);
const itemId = item?.id ?? readString(params, "itemId") ?? readString(params, "id");
if (itemId) {
this.activeItemIds.delete(itemId);
this.completedItemIds.add(itemId);
}
if (item?.type === "agentMessage" && typeof item.text === "string" && item.text) {
this.rememberAssistantItem(item.id);
this.assistantTextByItem.set(item.id, item.text);
}
if (item?.type === "plan" && typeof item.text === "string" && item.text) {
this.planTextByItem.set(item.id, item.text);
this.emitPlanUpdate({ explanation: undefined, steps: splitPlanText(item.text) });
}
if (item?.type === "contextCompaction" && itemId) {
this.activeCompactionItemIds.delete(itemId);
this.completedCompactionCount += 1;
this.params.onAgentEvent?.({
stream: "compaction",
data: {
phase: "end",
backend: "codex-app-server",
threadId: this.threadId,
turnId: this.turnId,
itemId,
},
});
}
this.recordToolMeta(item);
this.emitStandardItemEvent({ phase: "end", item });
this.params.onAgentEvent?.({
stream: "codex_app_server.item",
data: { phase: "completed", itemId, type: item?.type },
});
}
private handleTokenUsage(params: JsonObject): void {
const tokenUsage = isJsonObject(params.tokenUsage) ? params.tokenUsage : undefined;
const current =
(tokenUsage ? readFirstJsonObject(tokenUsage, CURRENT_TOKEN_USAGE_KEYS) : undefined) ??
readFirstJsonObject(params, CURRENT_TOKEN_USAGE_KEYS);
if (!current) {
return;
}
const usage = normalizeCodexTokenUsage(current);
if (usage) {
this.tokenUsage = usage;
}
}
private async handleTurnCompleted(params: JsonObject): Promise<void> {
const turn = readTurn(params.turn);
if (!turn || turn.id !== this.turnId) {
return;
}
this.completedTurn = turn;
if (turn.status === "interrupted") {
this.aborted = true;
}
if (turn.status === "failed") {
this.promptError = turn.error?.message ?? "codex app-server turn failed";
this.promptErrorSource = "prompt";
}
for (const item of turn.items ?? []) {
if (item.type === "agentMessage" && typeof item.text === "string" && item.text) {
this.rememberAssistantItem(item.id);
this.assistantTextByItem.set(item.id, item.text);
}
if (item.type === "plan" && typeof item.text === "string" && item.text) {
this.planTextByItem.set(item.id, item.text);
this.emitPlanUpdate({ explanation: undefined, steps: splitPlanText(item.text) });
}
this.recordToolMeta(item);
}
this.activeCompactionItemIds.clear();
await this.maybeEndReasoning();
}
private async maybeEndReasoning(): Promise<void> {
if (!this.reasoningStarted || this.reasoningEnded) {
return;
}
this.reasoningEnded = true;
await this.params.onReasoningEnd?.();
}
private emitPlanUpdate(params: { explanation?: string | null; steps?: string[] }): void {
if (!params.explanation && (!params.steps || params.steps.length === 0)) {
return;
}
this.params.onAgentEvent?.({
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "codex-app-server",
...(params.explanation ? { explanation: params.explanation } : {}),
...(params.steps && params.steps.length > 0 ? { steps: params.steps } : {}),
},
});
}
private emitStandardItemEvent(params: {
phase: "start" | "end";
item: CodexThreadItem | undefined;
}): void {
const { item } = params;
if (!item) {
return;
}
const kind = itemKind(item);
if (!kind) {
return;
}
this.params.onAgentEvent?.({
stream: "item",
data: {
itemId: item.id,
phase: params.phase,
kind,
title: itemTitle(item),
status: params.phase === "start" ? "running" : itemStatus(item),
...(itemName(item) ? { name: itemName(item) } : {}),
...(itemMeta(item) ? { meta: itemMeta(item) } : {}),
},
});
}
private recordToolMeta(item: CodexThreadItem | undefined): void {
if (!item) {
return;
}
const toolName = itemName(item);
if (!toolName) {
return;
}
this.toolMetas.set(item.id, {
toolName,
...(itemMeta(item) ? { meta: itemMeta(item) } : {}),
});
}
private collectAssistantTexts(): string[] {
const finalText = this.resolveFinalAssistantText();
return finalText ? [finalText] : [];
}
private resolveFinalAssistantText(): string | undefined {
for (let i = this.assistantItemOrder.length - 1; i >= 0; i -= 1) {
const itemId = this.assistantItemOrder[i];
if (!itemId) {
continue;
}
const text = this.assistantTextByItem.get(itemId)?.trim();
if (text) {
return text;
}
}
return undefined;
}
private rememberAssistantItem(itemId: string): void {
if (!itemId || this.assistantItemOrder.includes(itemId)) {
return;
}
this.assistantItemOrder.push(itemId);
}
private createAssistantMessage(text: string): AssistantMessage {
const usage: Usage = this.tokenUsage
? {
input: this.tokenUsage.input ?? 0,
output: this.tokenUsage.output ?? 0,
cacheRead: this.tokenUsage.cacheRead ?? 0,
cacheWrite: this.tokenUsage.cacheWrite ?? 0,
totalTokens:
this.tokenUsage.total ??
(this.tokenUsage.input ?? 0) +
(this.tokenUsage.output ?? 0) +
(this.tokenUsage.cacheRead ?? 0) +
(this.tokenUsage.cacheWrite ?? 0),
cost: ZERO_USAGE.cost,
}
: ZERO_USAGE;
return {
role: "assistant",
content: [{ type: "text", text }],
api: this.params.model.api ?? "openai-codex-responses",
provider: this.params.provider,
model: this.params.modelId,
usage,
stopReason: this.aborted ? "aborted" : this.promptError ? "error" : "stop",
errorMessage: this.promptError ? formatErrorMessage(this.promptError) : undefined,
timestamp: Date.now(),
};
}
private createAssistantMirrorMessage(title: string, text: string): AssistantMessage {
return {
role: "assistant",
content: [{ type: "text", text: `${title}:\n${text}` }],
api: this.params.model.api ?? "openai-codex-responses",
provider: this.params.provider,
model: this.params.modelId,
usage: ZERO_USAGE,
stopReason: "stop",
timestamp: Date.now(),
};
}
private isNotificationForTurn(params: JsonObject): boolean {
const threadId = readString(params, "threadId");
const turnId = readString(params, "turnId");
return (!threadId || threadId === this.threadId) && (!turnId || turnId === this.turnId);
}
}
function readString(record: JsonObject, key: string): string | undefined {
const value = record[key];
return typeof value === "string" ? value : undefined;
}
function readNullableString(record: JsonObject, key: string): string | null | undefined {
const value = record[key];
if (value === null) {
return null;
}
return typeof value === "string" ? value : undefined;
}
function readNumber(record: JsonObject, key: string): number | undefined {
const value = record[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function readFirstJsonObject(record: JsonObject, keys: readonly string[]): JsonObject | undefined {
for (const key of keys) {
const value = record[key];
if (isJsonObject(value)) {
return value;
}
}
return undefined;
}
function readNumberAlias(record: JsonObject, keys: readonly string[]): number | undefined {
for (const key of keys) {
const value = readNumber(record, key);
if (value !== undefined) {
return value;
}
}
return undefined;
}
function normalizeCodexTokenUsage(record: JsonObject): NormalizedUsage | undefined {
return normalizeUsage({
input: readNumberAlias(record, ["inputTokens", "input_tokens", "input", "promptTokens"]),
output: readNumberAlias(record, ["outputTokens", "output_tokens", "output"]),
cacheRead: readNumberAlias(record, [
"cachedInputTokens",
"cached_input_tokens",
"cacheRead",
"cache_read",
"cache_read_input_tokens",
"cached_tokens",
]),
cacheWrite: readNumberAlias(record, [
"cacheWrite",
"cache_write",
"cacheCreationInputTokens",
"cache_creation_input_tokens",
]),
total: readNumberAlias(record, ["totalTokens", "total_tokens", "total"]),
});
}
function splitPlanText(text: string): string[] {
return text
.split(/\r?\n/)
.map((line) => line.trim().replace(/^[-*]\s+/, ""))
.filter((line) => line.length > 0);
}
function collectTextValues(map: Map<string, string>): string[] {
return [...map.values()].filter((text) => text.trim().length > 0);
}
function itemKind(
item: CodexThreadItem,
): "tool" | "command" | "patch" | "search" | "analysis" | undefined {
switch (item.type) {
case "dynamicToolCall":
case "mcpToolCall":
return "tool";
case "commandExecution":
return "command";
case "fileChange":
return "patch";
case "webSearch":
return "search";
case "reasoning":
case "contextCompaction":
return "analysis";
default:
return undefined;
}
}
function itemTitle(item: CodexThreadItem): string {
switch (item.type) {
case "commandExecution":
return "Command";
case "fileChange":
return "File change";
case "mcpToolCall":
return "MCP tool";
case "dynamicToolCall":
return "Tool";
case "webSearch":
return "Web search";
case "contextCompaction":
return "Context compaction";
case "reasoning":
return "Reasoning";
default:
return item.type;
}
}
function itemStatus(item: CodexThreadItem): "completed" | "failed" | "running" {
const status = readItemString(item, "status");
if (status === "failed") {
return "failed";
}
if (status === "inProgress" || status === "running") {
return "running";
}
return "completed";
}
function itemName(item: CodexThreadItem): string | undefined {
if (item.type === "dynamicToolCall" && typeof item.tool === "string") {
return item.tool;
}
if (item.type === "mcpToolCall" && typeof item.tool === "string") {
const server = typeof item.server === "string" ? item.server : undefined;
return server ? `${server}.${item.tool}` : item.tool;
}
if (item.type === "commandExecution") {
return "bash";
}
if (item.type === "fileChange") {
return "apply_patch";
}
if (item.type === "webSearch") {
return "web_search";
}
return undefined;
}
function itemMeta(item: CodexThreadItem): string | undefined {
if (item.type === "commandExecution" && typeof item.command === "string") {
return item.command;
}
if (item.type === "webSearch" && typeof item.query === "string") {
return item.query;
}
return readItemString(item, "status");
}
function readItemString(item: CodexThreadItem, key: string): string | undefined {
const value = (item as Record<string, unknown>)[key];
return typeof value === "string" ? value : undefined;
}
function readItem(value: JsonValue | undefined): CodexThreadItem | undefined {
if (!isJsonObject(value)) {
return undefined;
}
const type = typeof value.type === "string" ? value.type : undefined;
const id = typeof value.id === "string" ? value.id : undefined;
if (!type || !id) {
return undefined;
}
return value as CodexThreadItem;
}
function readTurn(value: JsonValue | undefined): CodexTurn | undefined {
if (!isJsonObject(value)) {
return undefined;
}
const id = typeof value.id === "string" ? value.id : undefined;
const status = typeof value.status === "string" ? value.status : undefined;
if (!id || !status) {
return undefined;
}
const items = Array.isArray(value.items)
? value.items.flatMap((item) => {
const parsed = readItem(item);
return parsed ? [parsed] : [];
})
: undefined;
return {
id,
status: status as CodexTurn["status"],
error: isJsonObject(value.error)
? {
message: typeof value.error.message === "string" ? value.error.message : undefined,
}
: null,
items,
};
}

View file

@ -0,0 +1,64 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { CodexAppServerClient } from "./client.js";
import { listCodexAppServerModels } from "./models.js";
import { resetSharedCodexAppServerClientForTests } from "./shared-client.js";
import { createClientHarness } from "./test-support.js";
describe("listCodexAppServerModels", () => {
afterEach(() => {
resetSharedCodexAppServerClientForTests();
vi.restoreAllMocks();
});
it("lists app-server models through the typed helper", async () => {
const harness = createClientHarness();
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const listPromise = listCodexAppServerModels({ limit: 12, timeoutMs: 1000 });
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({
id: initialize.id,
result: { userAgent: "openclaw/0.118.0 (macOS; test)" },
});
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3));
const list = JSON.parse(harness.writes[2] ?? "{}") as { id?: number; method?: string };
expect(list.method).toBe("model/list");
harness.send({
id: list.id,
result: {
data: [
{
id: "gpt-5.4",
model: "gpt-5.4",
displayName: "gpt-5.4",
inputModalities: ["text", "image"],
supportedReasoningEfforts: [
{ reasoningEffort: "low", description: "fast" },
{ reasoningEffort: "xhigh", description: "deep" },
],
defaultReasoningEffort: "medium",
isDefault: true,
},
],
nextCursor: null,
},
});
await expect(listPromise).resolves.toEqual({
models: [
{
id: "gpt-5.4",
model: "gpt-5.4",
displayName: "gpt-5.4",
inputModalities: ["text", "image"],
supportedReasoningEfforts: ["low", "xhigh"],
defaultReasoningEffort: "medium",
isDefault: true,
},
],
});
harness.client.close();
startSpy.mockRestore();
});
});

View file

@ -0,0 +1,146 @@
import type { CodexAppServerStartOptions } from "./config.js";
import { type JsonObject, type JsonValue } from "./protocol.js";
import {
createIsolatedCodexAppServerClient,
getSharedCodexAppServerClient,
} from "./shared-client.js";
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;
startOptions?: CodexAppServerStartOptions;
authProfileId?: string;
sharedClient?: boolean;
};
export async function listCodexAppServerModels(
options: CodexAppServerListModelsOptions = {},
): Promise<CodexAppServerModelListResult> {
const timeoutMs = options.timeoutMs ?? 2500;
const useSharedClient = options.sharedClient !== false;
const client = useSharedClient
? await getSharedCodexAppServerClient({
startOptions: options.startOptions,
timeoutMs,
authProfileId: options.authProfileId,
})
: await createIsolatedCodexAppServerClient({
startOptions: options.startOptions,
timeoutMs,
authProfileId: options.authProfileId,
});
try {
const response = await client.request<JsonObject>(
"model/list",
{
limit: options.limit ?? null,
cursor: options.cursor ?? null,
includeHidden: options.includeHidden ?? null,
},
{ timeoutMs },
);
return readModelListResult(response);
} finally {
if (!useSharedClient) {
client.close();
}
}
}
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));
}

View file

@ -0,0 +1,201 @@
export type JsonPrimitive = null | boolean | number | string;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
export type JsonObject = { [key: string]: JsonValue };
export type RpcRequest = {
id?: number | string;
method: string;
params?: JsonValue;
};
export type RpcResponse = {
id: number | string;
result?: JsonValue;
error?: {
code?: number;
message: string;
data?: JsonValue;
};
};
export type RpcMessage = RpcRequest | RpcResponse;
export type CodexInitializeResponse = {
userAgent?: string;
codexHome?: string;
platformFamily?: string;
platformOs?: string;
};
export type CodexUserInput =
| {
type: "text";
text: string;
}
| {
type: "image";
url: string;
}
| {
type: "localImage";
path: string;
};
export type CodexDynamicToolSpec = {
name: string;
description: string;
inputSchema: JsonValue;
deferLoading?: boolean;
};
export type CodexThreadStartParams = {
model?: string | null;
modelProvider?: string | null;
cwd?: string | null;
approvalPolicy?: "never" | "on-request" | "on-failure" | "untrusted";
approvalsReviewer?: "user" | "guardian_subagent";
sandbox?: "read-only" | "workspace-write" | "danger-full-access";
serviceTier?: string | null;
config?: JsonObject | null;
serviceName?: string | null;
baseInstructions?: string | null;
developerInstructions?: string | null;
ephemeral?: boolean | null;
dynamicTools?: CodexDynamicToolSpec[] | null;
experimentalRawEvents: boolean;
persistExtendedHistory: boolean;
};
export type CodexThreadResumeParams = {
threadId: string;
model?: string | null;
modelProvider?: string | null;
approvalPolicy?: "never" | "on-request" | "on-failure" | "untrusted";
approvalsReviewer?: "user" | "guardian_subagent";
sandbox?: "read-only" | "workspace-write" | "danger-full-access";
serviceTier?: string | null;
persistExtendedHistory?: boolean;
};
export type CodexThreadStartResponse = {
thread: CodexThread;
model?: string | null;
modelProvider?: string | null;
};
export type CodexThreadResumeResponse = CodexThreadStartResponse;
export type CodexTurnStartParams = {
threadId: string;
input: CodexUserInput[];
cwd?: string | null;
approvalPolicy?: "never" | "on-request" | "on-failure" | "untrusted";
approvalsReviewer?: "user" | "guardian_subagent";
model?: string | null;
serviceTier?: string | null;
effort?: "minimal" | "low" | "medium" | "high" | "xhigh" | null;
};
export type CodexTurnSteerParams = {
threadId: string;
expectedTurnId: string;
input: CodexUserInput[];
};
export type CodexTurnInterruptParams = {
threadId: string;
turnId: string;
};
export type CodexTurnStartResponse = {
turn: CodexTurn;
};
export type CodexThread = {
id: string;
status?: string;
cwd?: string | null;
turns?: CodexTurn[];
};
export type CodexTurn = {
id: string;
status: "completed" | "interrupted" | "failed" | "inProgress";
error?: {
message?: string;
} | null;
items?: CodexThreadItem[];
};
export type CodexThreadItem =
| {
type: "agentMessage";
id: string;
text?: string;
}
| {
type: "reasoning";
id: string;
summary?: string[];
content?: string[];
}
| {
type: "plan";
id: string;
text?: string;
}
| {
type: "dynamicToolCall";
id: string;
tool?: string;
status?: string;
}
| {
type: string;
id: string;
status?: string;
[key: string]: JsonValue | undefined;
};
export type CodexServerNotification = {
method: string;
params?: JsonValue;
};
export type CodexDynamicToolCallParams = {
threadId: string;
turnId: string;
callId: string;
tool: string;
arguments?: JsonValue;
};
export type CodexDynamicToolCallResponse = {
contentItems: CodexDynamicToolCallOutputContentItem[];
success: boolean;
};
export type CodexDynamicToolCallOutputContentItem =
| {
type: "inputText";
text: string;
}
| {
type: "inputImage";
imageUrl: string;
};
export function isJsonObject(value: JsonValue | undefined): value is JsonObject {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
export function isRpcResponse(message: RpcMessage): message is RpcResponse {
return "id" in message && !("method" in message);
}
export function coerceJsonObject(value: unknown): JsonObject | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as JsonObject;
}

View file

@ -0,0 +1,26 @@
import type { CodexAppServerStartOptions } from "./config.js";
import type { JsonValue } from "./protocol.js";
import { getSharedCodexAppServerClient } from "./shared-client.js";
import { withTimeout } from "./timeout.js";
export async function requestCodexAppServerJson<T = JsonValue | undefined>(params: {
method: string;
requestParams?: JsonValue;
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
authProfileId?: string;
}): Promise<T> {
const timeoutMs = params.timeoutMs ?? 60_000;
return await withTimeout(
(async () => {
const client = await getSharedCodexAppServerClient({
startOptions: params.startOptions,
timeoutMs,
authProfileId: params.authProfileId,
});
return await client.request<T>(params.method, params.requestParams, { timeoutMs });
})(),
timeoutMs,
`codex app-server ${params.method} timed out`,
);
}

View file

@ -0,0 +1,611 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { Api, Model } from "@mariozechner/pi-ai";
import {
abortAgentHarnessRun,
queueAgentHarnessMessage,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexServerNotification } from "./protocol.js";
import { runCodexAppServerAttempt, __testing } from "./run-attempt.js";
import { writeCodexAppServerBinding } from "./session-binding.js";
import {
buildThreadResumeParams,
buildTurnStartParams,
startOrResumeThread,
} from "./thread-lifecycle.js";
let tempDir: string;
function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
return {
prompt: "hello",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir,
runId: "run-1",
provider: "codex",
modelId: "gpt-5.4-codex",
model: {
id: "gpt-5.4-codex",
name: "gpt-5.4-codex",
provider: "codex",
api: "openai-codex-responses",
input: ["text"],
reasoning: true,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 8_000,
} as Model<Api>,
thinkLevel: "medium",
disableTools: true,
timeoutMs: 5_000,
authStorage: {} as never,
modelRegistry: {} as never,
} as EmbeddedRunAttemptParams;
}
function createAppServerHarness(
requestImpl: (method: string, params: unknown) => Promise<unknown>,
) {
const requests: Array<{ method: string; params: unknown }> = [];
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
const request = vi.fn(async (method: string, params?: unknown) => {
requests.push({ method, params });
return requestImpl(method, params);
});
__testing.setCodexAppServerClientFactoryForTests(
async () =>
({
request,
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
}) as never,
);
return {
request,
requests,
async waitForMethod(method: string) {
await vi.waitFor(() => expect(requests.some((entry) => entry.method === method)).toBe(true));
},
async completeTurn(params: { threadId: string; turnId: string }) {
await notify({
method: "turn/completed",
params: {
threadId: params.threadId,
turnId: params.turnId,
turn: { id: params.turnId, status: "completed" },
},
});
},
};
}
function expectResumeRequest(
requests: Array<{ method: string; params: unknown }>,
params: Record<string, unknown>,
) {
expect(requests).toEqual(
expect.arrayContaining([
{
method: "thread/resume",
params,
},
]),
);
}
function createResumeHarness() {
return createAppServerHarness(async (method) => {
if (method === "thread/resume") {
return { thread: { id: "thread-existing" }, modelProvider: "openai" };
}
if (method === "turn/start") {
return { turn: { id: "turn-1", status: "inProgress" } };
}
return {};
});
}
describe("runCodexAppServerAttempt", () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-run-"));
});
afterEach(async () => {
__testing.resetCodexAppServerClientFactoryForTests();
vi.restoreAllMocks();
await fs.rm(tempDir, { recursive: true, force: true });
});
it("forwards queued user input and aborts the active app-server turn", async () => {
const { requests, waitForMethod } = createAppServerHarness(async (method, _params) => {
if (method === "thread/start") {
return { thread: { id: "thread-1" }, model: "gpt-5.4-codex", modelProvider: "openai" };
}
if (method === "turn/start") {
return { turn: { id: "turn-1", status: "inProgress" } };
}
return {};
});
const run = runCodexAppServerAttempt(
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
);
await waitForMethod("turn/start");
expect(queueAgentHarnessMessage("session-1", "more context")).toBe(true);
await vi.waitFor(() =>
expect(requests.some((entry) => entry.method === "turn/steer")).toBe(true),
);
expect(abortAgentHarnessRun("session-1")).toBe(true);
await vi.waitFor(() =>
expect(requests.some((entry) => entry.method === "turn/interrupt")).toBe(true),
);
const result = await run;
expect(result.aborted).toBe(true);
expect(requests).toEqual(
expect.arrayContaining([
{
method: "thread/start",
params: expect.objectContaining({
model: "gpt-5.4-codex",
modelProvider: "openai",
}),
},
{
method: "turn/steer",
params: {
threadId: "thread-1",
expectedTurnId: "turn-1",
input: [{ type: "text", text: "more context" }],
},
},
{
method: "turn/interrupt",
params: { threadId: "thread-1", turnId: "turn-1" },
},
]),
);
});
it("does not leak unhandled rejections when shutdown closes before interrupt", async () => {
const unhandledRejections: unknown[] = [];
const onUnhandledRejection = (reason: unknown) => {
unhandledRejections.push(reason);
};
process.on("unhandledRejection", onUnhandledRejection);
try {
const { waitForMethod } = createAppServerHarness(async (method, _params) => {
if (method === "thread/start") {
return { thread: { id: "thread-1" }, model: "gpt-5.4-codex", modelProvider: "openai" };
}
if (method === "turn/start") {
return { turn: { id: "turn-1", status: "inProgress" } };
}
if (method === "turn/interrupt") {
throw new Error("codex app-server client is closed");
}
return {};
});
const abortController = new AbortController();
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.abortSignal = abortController.signal;
const run = runCodexAppServerAttempt(params);
await waitForMethod("turn/start");
abortController.abort("shutdown");
await expect(run).resolves.toMatchObject({ aborted: true });
await new Promise((resolve) => setImmediate(resolve));
expect(unhandledRejections).toEqual([]);
} finally {
process.off("unhandledRejection", onUnhandledRejection);
}
});
it("forwards image attachments to the app-server turn input", async () => {
const { requests, waitForMethod, completeTurn } = createAppServerHarness(async (method) => {
if (method === "thread/start") {
return { thread: { id: "thread-1" }, model: "gpt-5.4-codex", modelProvider: "openai" };
}
if (method === "turn/start") {
return { turn: { id: "turn-1", status: "inProgress" } };
}
return {};
});
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.model = {
...params.model,
input: ["text", "image"],
} as Model<Api>;
params.images = [
{
type: "image",
mimeType: "image/png",
data: "aW1hZ2UtYnl0ZXM=",
},
];
const run = runCodexAppServerAttempt(params);
await waitForMethod("turn/start");
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
expect(requests).toEqual(
expect.arrayContaining([
{
method: "turn/start",
params: expect.objectContaining({
input: [
{ type: "text", text: "hello" },
{ type: "image", url: "data:image/png;base64,aW1hZ2UtYnl0ZXM=" },
],
}),
},
]),
);
});
it("does not drop turn completion notifications emitted while turn/start is in flight", async () => {
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return { thread: { id: "thread-1" }, model: "gpt-5.4-codex", modelProvider: "openai" };
}
if (method === "turn/start") {
await notify({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: { id: "turn-1", status: "completed" },
},
});
return { turn: { id: "turn-1", status: "completed" } };
}
return {};
});
__testing.setCodexAppServerClientFactoryForTests(
async () =>
({
request,
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
}) as never,
);
await expect(
runCodexAppServerAttempt(
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
),
).resolves.toMatchObject({
aborted: false,
timedOut: false,
});
});
it("times out app-server startup before thread setup can hang forever", async () => {
__testing.setCodexAppServerClientFactoryForTests(() => new Promise<never>(() => undefined));
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.timeoutMs = 1;
await expect(runCodexAppServerAttempt(params)).rejects.toThrow(
"codex app-server startup timed out",
);
expect(queueAgentHarnessMessage("session-1", "after timeout")).toBe(false);
});
it("passes the selected auth profile into app-server startup", async () => {
const seenAuthProfileIds: Array<string | undefined> = [];
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
__testing.setCodexAppServerClientFactoryForTests(async (_startOptions, authProfileId) => {
seenAuthProfileIds.push(authProfileId);
return {
request: async (method: string) => {
if (method === "thread/start") {
return {
thread: { id: "thread-1" },
model: "gpt-5.4-codex",
modelProvider: "openai",
};
}
if (method === "turn/start") {
return { turn: { id: "turn-1", status: "inProgress" } };
}
return {};
},
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
} as never;
});
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.authProfileId = "openai-codex:work";
const run = runCodexAppServerAttempt(params);
await vi.waitFor(() => expect(seenAuthProfileIds).toEqual(["openai-codex:work"]));
await notify({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: { id: "turn-1", status: "completed" },
},
});
await run;
expect(seenAuthProfileIds).toEqual(["openai-codex:work"]);
});
it("times out turn start before the active run handle is installed", async () => {
const request = vi.fn(
async (method: string, _params?: unknown, options?: { timeoutMs?: number }) => {
if (method === "thread/start") {
return { thread: { id: "thread-1" }, model: "gpt-5.4-codex", modelProvider: "openai" };
}
if (method === "turn/start") {
return await new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error("turn/start timed out")),
Math.max(100, options?.timeoutMs ?? 0),
);
});
}
return {};
},
);
__testing.setCodexAppServerClientFactoryForTests(
async () =>
({
request,
addNotificationHandler: () => () => undefined,
addRequestHandler: () => () => undefined,
}) as never,
);
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.timeoutMs = 1;
await expect(runCodexAppServerAttempt(params)).rejects.toThrow("turn/start timed out");
expect(queueAgentHarnessMessage("session-1", "after timeout")).toBe(false);
});
it("keeps extended history enabled when resuming a bound Codex thread", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
});
const { requests, waitForMethod, completeTurn } = createResumeHarness();
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
await waitForMethod("turn/start");
await completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
await run;
expectResumeRequest(requests, {
threadId: "thread-existing",
model: "gpt-5.4-codex",
modelProvider: "openai",
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: "workspace-write",
persistExtendedHistory: true,
});
});
it("passes configured app-server policy, sandbox, service tier, and model on resume", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.2",
modelProvider: "openai",
});
const { requests, waitForMethod, completeTurn } = createResumeHarness();
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
pluginConfig: {
appServer: {
approvalPolicy: "on-request",
approvalsReviewer: "guardian_subagent",
sandbox: "danger-full-access",
serviceTier: "priority",
},
},
});
await waitForMethod("turn/start");
await completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
await run;
expectResumeRequest(requests, {
threadId: "thread-existing",
model: "gpt-5.4-codex",
modelProvider: "openai",
approvalPolicy: "on-request",
approvalsReviewer: "guardian_subagent",
sandbox: "danger-full-access",
serviceTier: "priority",
persistExtendedHistory: true,
});
expect(requests).toEqual(
expect.arrayContaining([
{
method: "turn/start",
params: expect.objectContaining({
approvalPolicy: "on-request",
approvalsReviewer: "guardian_subagent",
serviceTier: "priority",
model: "gpt-5.4-codex",
}),
},
]),
);
});
it("builds resume and turn params from the currently selected OpenClaw model", () => {
const params = createParams("/tmp/session.jsonl", "/tmp/workspace");
const appServer = {
start: {
transport: "stdio" as const,
command: "codex",
args: ["app-server", "--listen", "stdio://"],
headers: {},
},
requestTimeoutMs: 60_000,
approvalPolicy: "on-request" as const,
approvalsReviewer: "guardian_subagent" as const,
sandbox: "danger-full-access" as const,
serviceTier: "priority",
};
expect(buildThreadResumeParams(params, { threadId: "thread-1", appServer })).toEqual({
threadId: "thread-1",
model: "gpt-5.4-codex",
modelProvider: "openai",
approvalPolicy: "on-request",
approvalsReviewer: "guardian_subagent",
sandbox: "danger-full-access",
serviceTier: "priority",
persistExtendedHistory: true,
});
expect(
buildTurnStartParams(params, { threadId: "thread-1", cwd: "/tmp/workspace", appServer }),
).toEqual(
expect.objectContaining({
threadId: "thread-1",
cwd: "/tmp/workspace",
model: "gpt-5.4-codex",
approvalPolicy: "on-request",
approvalsReviewer: "guardian_subagent",
serviceTier: "priority",
}),
);
});
it("preserves the bound auth profile when resume params omit authProfileId", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
authProfileId: "openai-codex:bound",
model: "gpt-5.4-codex",
modelProvider: "openai",
});
const params = createParams(sessionFile, workspaceDir);
delete params.authProfileId;
const binding = await startOrResumeThread({
client: {
request: async (method: string) => {
if (method === "thread/resume") {
return { thread: { id: "thread-existing" }, modelProvider: "openai" };
}
throw new Error(`unexpected method: ${method}`);
},
} as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer: {
start: {
transport: "stdio",
command: "codex",
args: ["app-server"],
headers: {},
},
requestTimeoutMs: 60_000,
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: "workspace-write",
},
});
expect(binding.authProfileId).toBe("openai-codex:bound");
});
it("reuses the bound auth profile for app-server startup when params omit it", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
authProfileId: "openai-codex:bound",
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
});
const seenAuthProfileIds: Array<string | undefined> = [];
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
__testing.setCodexAppServerClientFactoryForTests(async (_startOptions, authProfileId) => {
seenAuthProfileIds.push(authProfileId);
return {
request: async (method: string) => {
if (method === "thread/resume") {
return { thread: { id: "thread-existing" }, modelProvider: "openai" };
}
if (method === "turn/start") {
return { turn: { id: "turn-1", status: "inProgress" } };
}
throw new Error(`unexpected method: ${method}`);
},
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
} as never;
});
const params = createParams(sessionFile, workspaceDir);
delete params.authProfileId;
const run = runCodexAppServerAttempt(params);
await vi.waitFor(() => expect(seenAuthProfileIds).toEqual(["openai-codex:bound"]));
await notify({
method: "turn/completed",
params: {
threadId: "thread-existing",
turnId: "turn-1",
turn: { id: "turn-1", status: "completed" },
},
});
await run;
expect(seenAuthProfileIds).toEqual(["openai-codex:bound"]);
});
});

View file

@ -0,0 +1,496 @@
import fs from "node:fs/promises";
import {
buildEmbeddedAttemptToolRunContext,
clearActiveEmbeddedRun,
createOpenClawCodingTools,
embeddedAgentLog,
isSubagentSessionKey,
normalizeProviderToolSchemas,
resolveAttemptSpawnWorkspaceDir,
resolveModelAuthMode,
resolveOpenClawAgentDir,
resolveSandboxContext,
resolveSessionAgentIds,
resolveUserPath,
setActiveEmbeddedRun,
supportsModelTools,
type EmbeddedRunAttemptParams,
type EmbeddedRunAttemptResult,
} from "openclaw/plugin-sdk/agent-harness";
import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
import { isCodexAppServerApprovalRequest, type CodexAppServerClient } from "./client.js";
import { resolveCodexAppServerRuntimeOptions, type CodexAppServerStartOptions } from "./config.js";
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
import { CodexAppServerEventProjector } from "./event-projector.js";
import {
isJsonObject,
type CodexServerNotification,
type CodexDynamicToolCallParams,
type CodexTurnStartResponse,
type JsonObject,
type JsonValue,
} from "./protocol.js";
import { readCodexAppServerBinding, type CodexAppServerThreadBinding } from "./session-binding.js";
import { clearSharedCodexAppServerClient, getSharedCodexAppServerClient } from "./shared-client.js";
import { buildTurnStartParams, startOrResumeThread } from "./thread-lifecycle.js";
import { mirrorCodexAppServerTranscript } from "./transcript-mirror.js";
type CodexAppServerClientFactory = (
startOptions?: CodexAppServerStartOptions,
authProfileId?: string,
) => Promise<CodexAppServerClient>;
let clientFactory: CodexAppServerClientFactory = (startOptions, authProfileId) =>
getSharedCodexAppServerClient({ startOptions, authProfileId });
export async function runCodexAppServerAttempt(
params: EmbeddedRunAttemptParams,
options: { pluginConfig?: unknown } = {},
): Promise<EmbeddedRunAttemptResult> {
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
await fs.mkdir(resolvedWorkspace, { recursive: true });
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
const sandbox = await resolveSandboxContext({
config: params.config,
sessionKey: sandboxSessionKey,
workspaceDir: resolvedWorkspace,
});
const effectiveWorkspace = sandbox?.enabled
? sandbox.workspaceAccess === "rw"
? resolvedWorkspace
: sandbox.workspaceDir
: resolvedWorkspace;
await fs.mkdir(effectiveWorkspace, { recursive: true });
const runAbortController = new AbortController();
const abortFromUpstream = () => {
runAbortController.abort(params.abortSignal?.reason ?? "upstream_abort");
};
if (params.abortSignal?.aborted) {
abortFromUpstream();
} else {
params.abortSignal?.addEventListener("abort", abortFromUpstream, { once: true });
}
const { sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
agentId: params.agentId,
});
let yieldDetected = false;
const startupBinding = await readCodexAppServerBinding(params.sessionFile);
const startupAuthProfileId = params.authProfileId ?? startupBinding?.authProfileId;
const tools = await buildDynamicTools({
params,
resolvedWorkspace,
effectiveWorkspace,
sandboxSessionKey,
sandbox,
runAbortController,
sessionAgentId,
onYieldDetected: () => {
yieldDetected = true;
},
});
const toolBridge = createCodexDynamicToolBridge({
tools,
signal: runAbortController.signal,
});
let client: CodexAppServerClient;
let thread: CodexAppServerThreadBinding;
try {
({ client, thread } = await withCodexStartupTimeout({
timeoutMs: params.timeoutMs,
signal: runAbortController.signal,
operation: async () => {
const startupClient = await clientFactory(appServer.start, startupAuthProfileId);
const startupThread = await startOrResumeThread({
client: startupClient,
params,
cwd: effectiveWorkspace,
dynamicTools: toolBridge.specs,
appServer,
});
return { client: startupClient, thread: startupThread };
},
}));
} catch (error) {
clearSharedCodexAppServerClient();
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
throw error;
}
let projector: CodexAppServerEventProjector | undefined;
let turnId: string | undefined;
const pendingNotifications: CodexServerNotification[] = [];
let completed = false;
let timedOut = false;
let resolveCompletion: (() => void) | undefined;
const completion = new Promise<void>((resolve) => {
resolveCompletion = resolve;
});
let notificationQueue: Promise<void> = Promise.resolve();
const handleNotification = async (notification: CodexServerNotification) => {
if (!projector || !turnId) {
pendingNotifications.push(notification);
return;
}
await projector.handleNotification(notification);
if (
notification.method === "turn/completed" &&
isTurnNotification(notification.params, turnId)
) {
completed = true;
resolveCompletion?.();
}
};
const enqueueNotification = (notification: CodexServerNotification): Promise<void> => {
notificationQueue = notificationQueue.then(
() => handleNotification(notification),
() => handleNotification(notification),
);
return notificationQueue;
};
const notificationCleanup = client.addNotificationHandler(enqueueNotification);
const requestCleanup = client.addRequestHandler(async (request) => {
if (!turnId) {
return undefined;
}
if (request.method !== "item/tool/call") {
if (isCodexAppServerApprovalRequest(request.method)) {
return handleApprovalRequest({
method: request.method,
params: request.params,
paramsForRun: params,
threadId: thread.threadId,
turnId,
signal: runAbortController.signal,
});
}
return undefined;
}
const call = readDynamicToolCallParams(request.params);
if (!call || call.threadId !== thread.threadId || call.turnId !== turnId) {
return undefined;
}
return toolBridge.handleToolCall(call) as Promise<JsonValue>;
});
let turn: CodexTurnStartResponse;
try {
turn = await client.request<CodexTurnStartResponse>(
"turn/start",
buildTurnStartParams(params, {
threadId: thread.threadId,
cwd: effectiveWorkspace,
appServer,
}),
{ timeoutMs: params.timeoutMs, signal: runAbortController.signal },
);
} catch (error) {
notificationCleanup();
requestCleanup();
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
throw error;
}
turnId = turn.turn.id;
projector = new CodexAppServerEventProjector(params, thread.threadId, turnId);
for (const notification of pendingNotifications.splice(0)) {
await enqueueNotification(notification);
}
const activeTurnId = turnId;
const activeProjector = projector;
const handle = {
kind: "embedded" as const,
queueMessage: async (text: string) => {
await client.request("turn/steer", {
threadId: thread.threadId,
expectedTurnId: activeTurnId,
input: [{ type: "text", text }],
});
},
isStreaming: () => !completed,
isCompacting: () => projector?.isCompacting() ?? false,
cancel: () => runAbortController.abort("cancelled"),
abort: () => runAbortController.abort("aborted"),
};
setActiveEmbeddedRun(params.sessionId, handle, params.sessionKey);
const timeout = setTimeout(
() => {
timedOut = true;
projector?.markTimedOut();
runAbortController.abort("timeout");
},
Math.max(100, params.timeoutMs),
);
const abortListener = () => {
interruptCodexTurnBestEffort(client, {
threadId: thread.threadId,
turnId: activeTurnId,
});
resolveCompletion?.();
};
runAbortController.signal.addEventListener("abort", abortListener, { once: true });
if (runAbortController.signal.aborted) {
abortListener();
}
try {
await completion;
const result = activeProjector.buildResult(toolBridge.telemetry, { yieldDetected });
await mirrorTranscriptBestEffort({
params,
result,
threadId: thread.threadId,
turnId: activeTurnId,
});
return {
...result,
timedOut,
aborted: result.aborted || runAbortController.signal.aborted,
promptError: timedOut ? "codex app-server attempt timed out" : result.promptError,
promptErrorSource: timedOut ? "prompt" : result.promptErrorSource,
};
} finally {
clearTimeout(timeout);
notificationCleanup();
requestCleanup();
runAbortController.signal.removeEventListener("abort", abortListener);
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
clearActiveEmbeddedRun(params.sessionId, handle, params.sessionKey);
}
}
function interruptCodexTurnBestEffort(
client: CodexAppServerClient,
params: {
threadId: string;
turnId: string;
},
): void {
void Promise.resolve()
.then(() => client.request("turn/interrupt", params))
.catch((error: unknown) => {
embeddedAgentLog.debug("codex app-server turn interrupt failed during abort", { error });
});
}
type DynamicToolBuildParams = {
params: EmbeddedRunAttemptParams;
resolvedWorkspace: string;
effectiveWorkspace: string;
sandboxSessionKey: string;
sandbox: Awaited<ReturnType<typeof resolveSandboxContext>>;
runAbortController: AbortController;
sessionAgentId: string | undefined;
onYieldDetected: () => void;
};
async function buildDynamicTools(input: DynamicToolBuildParams) {
const { params } = input;
if (params.disableTools || !supportsModelTools(params.model)) {
return [];
}
const modelHasVision = params.model.input?.includes("image") ?? false;
const agentDir = params.agentDir ?? resolveOpenClawAgentDir();
const allTools = createOpenClawCodingTools({
agentId: input.sessionAgentId,
...buildEmbeddedAttemptToolRunContext(params),
exec: {
...params.execOverrides,
elevated: params.bashElevated,
},
sandbox: input.sandbox,
messageProvider: params.messageChannel ?? params.messageProvider,
agentAccountId: params.agentAccountId,
messageTo: params.messageTo,
messageThreadId: params.messageThreadId,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
senderIsOwner: params.senderIsOwner,
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
sessionKey: input.sandboxSessionKey,
sessionId: params.sessionId,
runId: params.runId,
agentDir,
workspaceDir: input.effectiveWorkspace,
spawnWorkspaceDir: resolveAttemptSpawnWorkspaceDir({
sandbox: input.sandbox,
resolvedWorkspace: input.resolvedWorkspace,
}),
config: params.config,
abortSignal: input.runAbortController.signal,
modelProvider: params.model.provider,
modelId: params.modelId,
modelCompat: params.model.compat,
modelApi: params.model.api,
modelContextWindowTokens: params.model.contextWindow,
modelAuthMode: resolveModelAuthMode(params.model.provider, params.config),
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
modelHasVision,
requireExplicitMessageTarget:
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
disableMessageTool: params.disableMessageTool,
onYield: (message) => {
input.onYieldDetected();
params.onAgentEvent?.({
stream: "codex_app_server.tool",
data: { name: "sessions_yield", message },
});
input.runAbortController.abort("sessions_yield");
},
});
const filteredTools =
params.toolsAllow && params.toolsAllow.length > 0
? allTools.filter((tool) => params.toolsAllow?.includes(tool.name))
: allTools;
return normalizeProviderToolSchemas({
tools: filteredTools,
provider: params.provider,
config: params.config,
workspaceDir: input.effectiveWorkspace,
env: process.env,
modelId: params.modelId,
modelApi: params.model.api,
model: params.model,
});
}
async function withCodexStartupTimeout<T>(params: {
timeoutMs: number;
signal: AbortSignal;
operation: () => Promise<T>;
}): Promise<T> {
if (params.signal.aborted) {
throw new Error("codex app-server startup aborted");
}
let timeout: NodeJS.Timeout | undefined;
let abortCleanup: (() => void) | undefined;
try {
return await Promise.race([
params.operation(),
new Promise<never>((_, reject) => {
const rejectOnce = (error: Error) => {
if (timeout) {
clearTimeout(timeout);
timeout = undefined;
}
reject(error);
};
const timeoutMs = Math.max(100, params.timeoutMs);
timeout = setTimeout(() => {
rejectOnce(new Error("codex app-server startup timed out"));
}, timeoutMs);
const abortListener = () => rejectOnce(new Error("codex app-server startup aborted"));
params.signal.addEventListener("abort", abortListener, { once: true });
abortCleanup = () => params.signal.removeEventListener("abort", abortListener);
}),
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
abortCleanup?.();
}
}
function readDynamicToolCallParams(
value: JsonValue | undefined,
): CodexDynamicToolCallParams | undefined {
if (!isJsonObject(value)) {
return undefined;
}
const threadId = readString(value, "threadId");
const turnId = readString(value, "turnId");
const callId = readString(value, "callId");
const tool = readString(value, "tool");
if (!threadId || !turnId || !callId || !tool) {
return undefined;
}
return {
threadId,
turnId,
callId,
tool,
arguments: value.arguments,
};
}
function isTurnNotification(value: JsonValue | undefined, turnId: string): boolean {
if (!isJsonObject(value)) {
return false;
}
const directTurnId = readString(value, "turnId");
if (directTurnId === turnId) {
return true;
}
const turn = isJsonObject(value.turn) ? value.turn : undefined;
return readString(turn ?? {}, "id") === turnId;
}
function readString(record: JsonObject, key: string): string | undefined {
const value = record[key];
return typeof value === "string" ? value : undefined;
}
async function mirrorTranscriptBestEffort(params: {
params: EmbeddedRunAttemptParams;
result: EmbeddedRunAttemptResult;
threadId: string;
turnId: string;
}): Promise<void> {
try {
await mirrorCodexAppServerTranscript({
sessionFile: params.params.sessionFile,
sessionKey: params.params.sessionKey,
messages: params.result.messagesSnapshot,
idempotencyScope: `codex-app-server:${params.threadId}:${params.turnId}`,
});
} catch (error) {
embeddedAgentLog.warn("failed to mirror codex app-server transcript", { error });
}
}
function handleApprovalRequest(params: {
method: string;
params: JsonValue | undefined;
paramsForRun: EmbeddedRunAttemptParams;
threadId: string;
turnId: string;
signal?: AbortSignal;
}): Promise<JsonValue | undefined> {
return handleCodexAppServerApprovalRequest({
method: params.method,
requestParams: params.params,
paramsForRun: params.paramsForRun,
threadId: params.threadId,
turnId: params.turnId,
signal: params.signal,
});
}
export const __testing = {
setCodexAppServerClientFactoryForTests(factory: CodexAppServerClientFactory): void {
clientFactory = factory;
},
resetCodexAppServerClientFactoryForTests(): void {
clientFactory = (startOptions, authProfileId) =>
getSharedCodexAppServerClient({ startOptions, authProfileId });
},
} as const;

View file

@ -0,0 +1,52 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
clearCodexAppServerBinding,
readCodexAppServerBinding,
resolveCodexAppServerBindingPath,
writeCodexAppServerBinding,
} from "./session-binding.js";
let tempDir: string;
describe("codex app-server session binding", () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-binding-"));
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it("round-trips the thread binding beside the PI session file", async () => {
const sessionFile = path.join(tempDir, "session.json");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-123",
cwd: tempDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "tools-v1",
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding).toMatchObject({
schemaVersion: 1,
threadId: "thread-123",
sessionFile,
cwd: tempDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "tools-v1",
});
await expect(fs.stat(resolveCodexAppServerBindingPath(sessionFile))).resolves.toBeTruthy();
});
it("clears missing bindings without throwing", async () => {
const sessionFile = path.join(tempDir, "missing.json");
await clearCodexAppServerBinding(sessionFile);
await expect(readCodexAppServerBinding(sessionFile)).resolves.toBeUndefined();
});
});

View file

@ -0,0 +1,101 @@
import fs from "node:fs/promises";
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness";
export type CodexAppServerThreadBinding = {
schemaVersion: 1;
threadId: string;
sessionFile: string;
cwd: string;
authProfileId?: string;
model?: string;
modelProvider?: string;
dynamicToolsFingerprint?: string;
createdAt: string;
updatedAt: string;
};
export function resolveCodexAppServerBindingPath(sessionFile: string): string {
return `${sessionFile}.codex-app-server.json`;
}
export async function readCodexAppServerBinding(
sessionFile: string,
): Promise<CodexAppServerThreadBinding | undefined> {
const path = resolveCodexAppServerBindingPath(sessionFile);
let raw: string;
try {
raw = await fs.readFile(path, "utf8");
} catch (error) {
if (isNotFound(error)) {
return undefined;
}
embeddedAgentLog.warn("failed to read codex app-server binding", { path, error });
return undefined;
}
try {
const parsed = JSON.parse(raw) as Partial<CodexAppServerThreadBinding>;
if (parsed.schemaVersion !== 1 || typeof parsed.threadId !== "string") {
return undefined;
}
return {
schemaVersion: 1,
threadId: parsed.threadId,
sessionFile,
cwd: typeof parsed.cwd === "string" ? parsed.cwd : "",
authProfileId: typeof parsed.authProfileId === "string" ? parsed.authProfileId : undefined,
model: typeof parsed.model === "string" ? parsed.model : undefined,
modelProvider: typeof parsed.modelProvider === "string" ? parsed.modelProvider : undefined,
dynamicToolsFingerprint:
typeof parsed.dynamicToolsFingerprint === "string"
? parsed.dynamicToolsFingerprint
: undefined,
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : new Date().toISOString(),
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date().toISOString(),
};
} catch (error) {
embeddedAgentLog.warn("failed to parse codex app-server binding", { path, error });
return undefined;
}
}
export async function writeCodexAppServerBinding(
sessionFile: string,
binding: Omit<
CodexAppServerThreadBinding,
"schemaVersion" | "sessionFile" | "createdAt" | "updatedAt"
> & {
createdAt?: string;
},
): Promise<void> {
const now = new Date().toISOString();
const payload: CodexAppServerThreadBinding = {
schemaVersion: 1,
sessionFile,
threadId: binding.threadId,
cwd: binding.cwd,
authProfileId: binding.authProfileId,
model: binding.model,
modelProvider: binding.modelProvider,
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,
createdAt: binding.createdAt ?? now,
updatedAt: now,
};
await fs.writeFile(
resolveCodexAppServerBindingPath(sessionFile),
`${JSON.stringify(payload, null, 2)}\n`,
);
}
export async function clearCodexAppServerBinding(sessionFile: string): Promise<void> {
try {
await fs.unlink(resolveCodexAppServerBindingPath(sessionFile));
} catch (error) {
if (!isNotFound(error)) {
embeddedAgentLog.warn("failed to clear codex app-server binding", { sessionFile, error });
}
}
}
function isNotFound(error: unknown): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
}

View file

@ -0,0 +1,109 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { CodexAppServerClient, MIN_CODEX_APP_SERVER_VERSION } from "./client.js";
import { createClientHarness } from "./test-support.js";
const mocks = vi.hoisted(() => ({
bridgeCodexAppServerStartOptions: vi.fn(async ({ startOptions }) => startOptions),
resolveOpenClawAgentDir: vi.fn(() => "/tmp/openclaw-agent"),
}));
vi.mock("./auth-bridge.js", () => ({
bridgeCodexAppServerStartOptions: mocks.bridgeCodexAppServerStartOptions,
}));
vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
resolveOpenClawAgentDir: mocks.resolveOpenClawAgentDir,
}));
let listCodexAppServerModels: typeof import("./models.js").listCodexAppServerModels;
let resetSharedCodexAppServerClientForTests: typeof import("./shared-client.js").resetSharedCodexAppServerClientForTests;
describe("shared Codex app-server client", () => {
beforeAll(async () => {
({ listCodexAppServerModels } = await import("./models.js"));
({ resetSharedCodexAppServerClientForTests } = await import("./shared-client.js"));
});
afterEach(() => {
resetSharedCodexAppServerClientForTests();
vi.useRealTimers();
vi.restoreAllMocks();
mocks.bridgeCodexAppServerStartOptions.mockClear();
mocks.resolveOpenClawAgentDir.mockClear();
});
it("closes the shared app-server when the version gate fails", async () => {
const harness = createClientHarness();
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
// Model discovery uses the shared-client path, which owns child teardown
// when initialize discovers an unsupported app-server.
const listPromise = listCodexAppServerModels({ timeoutMs: 1000 });
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({
id: initialize.id,
result: { userAgent: "openclaw/0.117.9 (macOS; test)" },
});
await expect(listPromise).rejects.toThrow(
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required`,
);
expect(harness.process.kill).toHaveBeenCalledTimes(1);
startSpy.mockRestore();
});
it("closes and clears a shared app-server when initialize times out", async () => {
const first = createClientHarness();
const second = createClientHarness();
const startSpy = vi
.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(first.client)
.mockReturnValueOnce(second.client);
await expect(listCodexAppServerModels({ timeoutMs: 5 })).rejects.toThrow(
"codex app-server initialize timed out",
);
expect(first.process.kill).toHaveBeenCalledTimes(1);
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
await vi.waitFor(() => expect(second.writes.length).toBeGreaterThanOrEqual(1));
const initialize = JSON.parse(second.writes[0] ?? "{}") as { id?: number };
second.send({
id: initialize.id,
result: { userAgent: "openclaw/0.118.0 (macOS; test)" },
});
await vi.waitFor(() => expect(second.writes.length).toBeGreaterThanOrEqual(3));
const modelList = JSON.parse(second.writes[2] ?? "{}") as { id?: number };
second.send({ id: modelList.id, result: { data: [] } });
await expect(secondList).resolves.toEqual({ models: [] });
expect(startSpy).toHaveBeenCalledTimes(2);
});
it("passes the selected auth profile through the bridge helper", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const listPromise = listCodexAppServerModels({
timeoutMs: 1000,
authProfileId: "openai-codex:work",
});
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({
id: initialize.id,
result: { userAgent: "openclaw/0.118.0 (macOS; test)" },
});
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3));
const modelList = JSON.parse(harness.writes[2] ?? "{}") as { id?: number };
harness.send({ id: modelList.id, result: { data: [] } });
await expect(listPromise).resolves.toEqual({ models: [] });
expect(mocks.bridgeCodexAppServerStartOptions).toHaveBeenCalledWith(
expect.objectContaining({
authProfileId: "openai-codex:work",
}),
);
});
});

View file

@ -0,0 +1,115 @@
import { resolveOpenClawAgentDir } from "openclaw/plugin-sdk/provider-auth";
import { bridgeCodexAppServerStartOptions } from "./auth-bridge.js";
import { CodexAppServerClient } from "./client.js";
import {
codexAppServerStartOptionsKey,
resolveCodexAppServerRuntimeOptions,
type CodexAppServerStartOptions,
} from "./config.js";
import { withTimeout } from "./timeout.js";
type SharedCodexAppServerClientState = {
client?: CodexAppServerClient;
promise?: Promise<CodexAppServerClient>;
key?: string;
};
const SHARED_CODEX_APP_SERVER_CLIENT_STATE = Symbol.for("openclaw.codexAppServerClientState");
function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
const globalState = globalThis as typeof globalThis & {
[SHARED_CODEX_APP_SERVER_CLIENT_STATE]?: SharedCodexAppServerClientState;
};
globalState[SHARED_CODEX_APP_SERVER_CLIENT_STATE] ??= {};
return globalState[SHARED_CODEX_APP_SERVER_CLIENT_STATE];
}
export async function getSharedCodexAppServerClient(options?: {
startOptions?: CodexAppServerStartOptions;
timeoutMs?: number;
authProfileId?: string;
}): Promise<CodexAppServerClient> {
const state = getSharedCodexAppServerClientState();
const startOptions = await bridgeCodexAppServerStartOptions({
startOptions: options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start,
agentDir: resolveOpenClawAgentDir(),
authProfileId: options?.authProfileId,
});
const key = codexAppServerStartOptionsKey(startOptions);
if (state.key && state.key !== key) {
clearSharedCodexAppServerClient();
}
state.key = key;
state.promise ??= (async () => {
const client = CodexAppServerClient.start(startOptions);
state.client = client;
client.addCloseHandler(clearSharedClientIfCurrent);
try {
await client.initialize();
return client;
} catch (error) {
// Startup failures happen before callers own the shared client, so close
// the child here instead of leaving a rejected daemon attached to stdio.
client.close();
throw error;
}
})();
try {
return await withTimeout(
state.promise,
options?.timeoutMs ?? 0,
"codex app-server initialize timed out",
);
} catch (error) {
clearSharedCodexAppServerClient();
throw error;
}
}
export async function createIsolatedCodexAppServerClient(options?: {
startOptions?: CodexAppServerStartOptions;
timeoutMs?: number;
authProfileId?: string;
}): Promise<CodexAppServerClient> {
const startOptions = await bridgeCodexAppServerStartOptions({
startOptions: options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start,
agentDir: resolveOpenClawAgentDir(),
authProfileId: options?.authProfileId,
});
const client = CodexAppServerClient.start(startOptions);
const initialize = client.initialize();
try {
await withTimeout(initialize, options?.timeoutMs ?? 0, "codex app-server initialize timed out");
return client;
} catch (error) {
client.close();
await initialize.catch(() => undefined);
throw error;
}
}
export function resetSharedCodexAppServerClientForTests(): void {
const state = getSharedCodexAppServerClientState();
state.client = undefined;
state.promise = undefined;
state.key = undefined;
}
export function clearSharedCodexAppServerClient(): void {
const state = getSharedCodexAppServerClientState();
const client = state.client;
state.client = undefined;
state.promise = undefined;
state.key = undefined;
client?.close();
}
function clearSharedClientIfCurrent(client: CodexAppServerClient): void {
const state = getSharedCodexAppServerClientState();
if (state.client !== client) {
return;
}
state.client = undefined;
state.promise = undefined;
state.key = undefined;
}

View file

@ -0,0 +1,33 @@
import { EventEmitter } from "node:events";
import { PassThrough, Writable } from "node:stream";
import { vi } from "vitest";
import { CodexAppServerClient } from "./client.js";
export function createClientHarness() {
const stdout = new PassThrough();
const writes: string[] = [];
const stdin = new Writable({
write(chunk, _encoding, callback) {
writes.push(chunk.toString());
callback();
},
});
const process = Object.assign(new EventEmitter(), {
stdin,
stdout,
stderr: new PassThrough(),
killed: false,
kill: vi.fn(() => {
process.killed = true;
}),
});
const client = CodexAppServerClient.fromTransportForTests(process);
return {
client,
process,
writes,
send(message: unknown) {
stdout.write(`${JSON.stringify(message)}\n`);
},
};
}

View file

@ -0,0 +1,217 @@
import { embeddedAgentLog, type EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness";
import type { CodexAppServerClient } from "./client.js";
import type { CodexAppServerRuntimeOptions } from "./config.js";
import {
isJsonObject,
type CodexThreadResumeParams,
type CodexThreadResumeResponse,
type CodexThreadStartResponse,
type CodexTurnStartParams,
type CodexUserInput,
type JsonObject,
type JsonValue,
} from "./protocol.js";
import {
clearCodexAppServerBinding,
readCodexAppServerBinding,
writeCodexAppServerBinding,
type CodexAppServerThreadBinding,
} from "./session-binding.js";
export async function startOrResumeThread(params: {
client: CodexAppServerClient;
params: EmbeddedRunAttemptParams;
cwd: string;
dynamicTools: JsonValue[];
appServer: CodexAppServerRuntimeOptions;
}): Promise<CodexAppServerThreadBinding> {
const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools);
const binding = await readCodexAppServerBinding(params.params.sessionFile);
if (binding?.threadId) {
// `/codex resume <thread>` writes a binding before the next turn can know
// the dynamic tool catalog, so only invalidate fingerprints we actually have.
if (
binding.dynamicToolsFingerprint &&
binding.dynamicToolsFingerprint !== dynamicToolsFingerprint
) {
embeddedAgentLog.debug(
"codex app-server dynamic tool catalog changed; starting a new thread",
{
threadId: binding.threadId,
},
);
await clearCodexAppServerBinding(params.params.sessionFile);
} else {
try {
const response = await params.client.request<CodexThreadResumeResponse>(
"thread/resume",
buildThreadResumeParams(params.params, {
threadId: binding.threadId,
appServer: params.appServer,
}),
);
const boundAuthProfileId = params.params.authProfileId ?? binding.authProfileId;
await writeCodexAppServerBinding(params.params.sessionFile, {
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: boundAuthProfileId,
model: params.params.modelId,
modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider),
dynamicToolsFingerprint,
createdAt: binding.createdAt,
});
return {
...binding,
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: boundAuthProfileId,
model: params.params.modelId,
modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider),
dynamicToolsFingerprint,
};
} catch (error) {
embeddedAgentLog.warn("codex app-server thread resume failed; starting a new thread", {
error,
});
await clearCodexAppServerBinding(params.params.sessionFile);
}
}
}
const response = await params.client.request<CodexThreadStartResponse>("thread/start", {
model: params.params.modelId,
modelProvider: normalizeModelProvider(params.params.provider),
cwd: params.cwd,
approvalPolicy: params.appServer.approvalPolicy,
approvalsReviewer: params.appServer.approvalsReviewer,
sandbox: params.appServer.sandbox,
...(params.appServer.serviceTier ? { serviceTier: params.appServer.serviceTier } : {}),
serviceName: "OpenClaw",
developerInstructions: buildDeveloperInstructions(params.params),
dynamicTools: params.dynamicTools,
experimentalRawEvents: true,
persistExtendedHistory: true,
});
const createdAt = new Date().toISOString();
await writeCodexAppServerBinding(params.params.sessionFile, {
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: params.params.authProfileId,
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider),
dynamicToolsFingerprint,
createdAt,
});
return {
schemaVersion: 1,
threadId: response.thread.id,
sessionFile: params.params.sessionFile,
cwd: params.cwd,
authProfileId: params.params.authProfileId,
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider),
dynamicToolsFingerprint,
createdAt,
updatedAt: createdAt,
};
}
export function buildThreadResumeParams(
params: EmbeddedRunAttemptParams,
options: {
threadId: string;
appServer: CodexAppServerRuntimeOptions;
},
): CodexThreadResumeParams {
return {
threadId: options.threadId,
model: params.modelId,
modelProvider: normalizeModelProvider(params.provider),
approvalPolicy: options.appServer.approvalPolicy,
approvalsReviewer: options.appServer.approvalsReviewer,
sandbox: options.appServer.sandbox,
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
persistExtendedHistory: true,
};
}
export function buildTurnStartParams(
params: EmbeddedRunAttemptParams,
options: {
threadId: string;
cwd: string;
appServer: CodexAppServerRuntimeOptions;
},
): CodexTurnStartParams {
return {
threadId: options.threadId,
input: buildUserInput(params),
cwd: options.cwd,
approvalPolicy: options.appServer.approvalPolicy,
approvalsReviewer: options.appServer.approvalsReviewer,
model: params.modelId,
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
effort: resolveReasoningEffort(params.thinkLevel),
};
}
function fingerprintDynamicTools(dynamicTools: JsonValue[]): string {
return JSON.stringify(dynamicTools.map(stabilizeJsonValue));
}
function stabilizeJsonValue(value: JsonValue): JsonValue {
if (Array.isArray(value)) {
return value.map(stabilizeJsonValue);
}
if (!isJsonObject(value)) {
return value;
}
const stable: JsonObject = {};
for (const [key, child] of Object.entries(value).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
stable[key] = stabilizeJsonValue(child);
}
return stable;
}
function buildDeveloperInstructions(params: EmbeddedRunAttemptParams): string {
const sections = [
"You are running inside OpenClaw. Use OpenClaw dynamic tools for messaging, cron, sessions, and host actions when available.",
"Preserve the user's existing channel/session context. If sending a channel reply, use the OpenClaw messaging tool instead of describing that you would reply.",
params.extraSystemPrompt,
params.skillsSnapshot?.prompt,
];
return sections.filter((section) => typeof section === "string" && section.trim()).join("\n\n");
}
function buildUserInput(params: EmbeddedRunAttemptParams): CodexUserInput[] {
return [
{ type: "text", text: params.prompt },
...(params.images ?? []).map(
(image): CodexUserInput => ({
type: "image",
url: `data:${image.mimeType};base64,${image.data}`,
}),
),
];
}
function normalizeModelProvider(provider: string): string {
return provider === "codex" || provider === "openai-codex" ? "openai" : provider;
}
function resolveReasoningEffort(
thinkLevel: EmbeddedRunAttemptParams["thinkLevel"],
): "minimal" | "low" | "medium" | "high" | "xhigh" | null {
if (
thinkLevel === "minimal" ||
thinkLevel === "low" ||
thinkLevel === "medium" ||
thinkLevel === "high" ||
thinkLevel === "xhigh"
) {
return thinkLevel;
}
return null;
}

View file

@ -0,0 +1,22 @@
export 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);
}
}
}

View file

@ -0,0 +1,119 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mirrorCodexAppServerTranscript } from "./transcript-mirror.js";
let tempDir: string;
describe("mirrorCodexAppServerTranscript", () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-transcript-"));
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it("mirrors user and assistant messages into the PI transcript", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "agent:main:session-1",
messages: [
{ role: "user", content: "hello", timestamp: 1 },
{
role: "assistant",
content: [{ type: "text", text: "Codex plan:\ninspect" }],
api: "openai-codex-responses",
provider: "openai-codex",
model: "gpt-5.4-codex",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 2,
},
{
role: "assistant",
content: [{ type: "text", text: "hi" }],
api: "openai-codex-responses",
provider: "openai-codex",
model: "gpt-5.4-codex",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 3,
},
],
});
const records = (await fs.readFile(sessionFile, "utf8"))
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { type?: string; message?: { role?: string } });
expect(records[0]?.type).toBe("session");
expect(records.slice(1).map((record) => record.message?.role)).toEqual([
"user",
"assistant",
"assistant",
]);
});
it("deduplicates app-server turn mirrors by idempotency scope", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const messages = [
{ role: "user" as const, content: "hello", timestamp: 1 },
{
role: "assistant" as const,
content: [{ type: "text" as const, text: "hi" }],
api: "openai-codex-responses",
provider: "openai-codex",
model: "gpt-5.4-codex",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop" as const,
timestamp: 2,
},
];
await mirrorCodexAppServerTranscript({
sessionFile,
messages,
idempotencyScope: "codex-app-server:thread-1:turn-1",
});
await mirrorCodexAppServerTranscript({
sessionFile,
messages,
idempotencyScope: "codex-app-server:thread-1:turn-1",
});
const records = (await fs.readFile(sessionFile, "utf8"))
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { message?: { role?: string; idempotencyKey?: string } });
expect(records.slice(1).map((record) => record.message?.role)).toEqual(["user", "assistant"]);
expect(records.slice(1).map((record) => record.message?.idempotencyKey)).toEqual([
"codex-app-server:thread-1:turn-1:user:0",
"codex-app-server:thread-1:turn-1:assistant:1",
]);
});
});

View file

@ -0,0 +1,83 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import {
acquireSessionWriteLock,
emitSessionTranscriptUpdate,
} from "openclaw/plugin-sdk/agent-harness";
export async function mirrorCodexAppServerTranscript(params: {
sessionFile: string;
sessionKey?: string;
messages: AgentMessage[];
idempotencyScope?: string;
}): Promise<void> {
const messages = params.messages.filter(
(message) => message.role === "user" || message.role === "assistant",
);
if (messages.length === 0) {
return;
}
await fs.mkdir(path.dirname(params.sessionFile), { recursive: true });
const lock = await acquireSessionWriteLock({
sessionFile: params.sessionFile,
timeoutMs: 10_000,
});
try {
const existingIdempotencyKeys = await readTranscriptIdempotencyKeys(params.sessionFile);
const sessionManager = SessionManager.open(params.sessionFile);
for (const [index, message] of messages.entries()) {
const idempotencyKey = params.idempotencyScope
? `${params.idempotencyScope}:${message.role}:${index}`
: undefined;
if (idempotencyKey && existingIdempotencyKeys.has(idempotencyKey)) {
continue;
}
const transcriptMessage = {
...message,
...(idempotencyKey ? { idempotencyKey } : {}),
} as Parameters<SessionManager["appendMessage"]>[0];
sessionManager.appendMessage(transcriptMessage);
if (idempotencyKey) {
existingIdempotencyKeys.add(idempotencyKey);
}
}
} finally {
await lock.release();
}
if (params.sessionKey) {
emitSessionTranscriptUpdate({ sessionFile: params.sessionFile, sessionKey: params.sessionKey });
} else {
emitSessionTranscriptUpdate(params.sessionFile);
}
}
async function readTranscriptIdempotencyKeys(sessionFile: string): Promise<Set<string>> {
const keys = new Set<string>();
let raw: string;
try {
raw = await fs.readFile(sessionFile, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
return keys;
}
for (const line of raw.split(/\r?\n/)) {
if (!line.trim()) {
continue;
}
try {
const parsed = JSON.parse(line) as { message?: { idempotencyKey?: unknown } };
if (typeof parsed.message?.idempotencyKey === "string") {
keys.add(parsed.message.idempotencyKey);
}
} catch {
continue;
}
}
return keys;
}

View file

@ -0,0 +1,18 @@
import { spawn } from "node:child_process";
import type { CodexAppServerStartOptions } from "./config.js";
import type { CodexAppServerTransport } from "./transport.js";
export function createStdioTransport(options: CodexAppServerStartOptions): CodexAppServerTransport {
const env = {
...process.env,
...options.env,
};
for (const key of options.clearEnv ?? []) {
delete env[key];
}
return spawn(options.command, options.args, {
env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
});
}

View file

@ -0,0 +1,71 @@
import { afterEach, describe, expect, it } from "vitest";
import { WebSocketServer, type RawData } from "ws";
import { CodexAppServerClient } from "./client.js";
describe("Codex app-server websocket transport", () => {
const clients: CodexAppServerClient[] = [];
const servers: WebSocketServer[] = [];
afterEach(async () => {
for (const client of clients) {
client.close();
}
clients.length = 0;
await Promise.all(
servers
.splice(0)
.map(
(server) =>
new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
),
),
);
});
it("can speak JSON-RPC over websocket transport", async () => {
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
servers.push(server);
const authHeaders: Array<string | undefined> = [];
server.on("connection", (socket, request) => {
authHeaders.push(request.headers.authorization);
socket.on("message", (data) => {
const message = JSON.parse(rawDataToText(data)) as { id?: number; method?: string };
if (message.method === "initialize") {
socket.send(
JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.118.0" } }),
);
return;
}
if (message.method === "model/list") {
socket.send(JSON.stringify({ id: message.id, result: { data: [] } }));
}
});
});
await new Promise<void>((resolve) => server.once("listening", resolve));
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("expected websocket test server port");
}
const client = CodexAppServerClient.start({
transport: "websocket",
url: `ws://127.0.0.1:${address.port}`,
authToken: "secret",
});
clients.push(client);
await expect(client.initialize()).resolves.toBeUndefined();
await expect(client.request("model/list", {})).resolves.toEqual({ data: [] });
expect(authHeaders).toEqual(["Bearer secret"]);
});
});
function rawDataToText(data: RawData): string {
if (Buffer.isBuffer(data)) {
return data.toString("utf8");
}
if (Array.isArray(data)) {
return Buffer.concat(data).toString("utf8");
}
return Buffer.from(data).toString("utf8");
}

View file

@ -0,0 +1,90 @@
import { EventEmitter } from "node:events";
import { PassThrough, Writable } from "node:stream";
import WebSocket, { type RawData } from "ws";
import type { CodexAppServerStartOptions } from "./config.js";
import type { CodexAppServerTransport } from "./transport.js";
export function createWebSocketTransport(
options: CodexAppServerStartOptions,
): CodexAppServerTransport {
if (!options.url) {
throw new Error(
"codex app-server websocket transport requires plugins.entries.codex.config.appServer.url",
);
}
const events = new EventEmitter();
const stdout = new PassThrough();
const stderr = new PassThrough();
const headers = {
...options.headers,
...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}),
};
const socket = new WebSocket(options.url, { headers });
const pendingFrames: string[] = [];
let killed = false;
const sendFrame = (frame: string) => {
const trimmed = frame.trim();
if (!trimmed) {
return;
}
if (socket.readyState === WebSocket.OPEN) {
socket.send(trimmed);
return;
}
pendingFrames.push(trimmed);
};
// `initialize` can be written before the WebSocket open event fires. Buffer
// whole JSON-RPC frames so stdio and websocket transports share call timing.
socket.once("open", () => {
for (const frame of pendingFrames.splice(0)) {
socket.send(frame);
}
});
socket.once("error", (error) => events.emit("error", error));
socket.once("close", (code, reason) => {
killed = true;
events.emit("exit", code, reason.toString("utf8"));
});
socket.on("message", (data) => {
const text = websocketFrameToText(data);
stdout.write(text.endsWith("\n") ? text : `${text}\n`);
});
const stdin = new Writable({
write(chunk, _encoding, callback) {
for (const frame of chunk.toString("utf8").split("\n")) {
sendFrame(frame);
}
callback();
},
});
return {
stdin,
stdout,
stderr,
get killed() {
return killed;
},
kill: () => {
killed = true;
socket.close();
},
once: (event, listener) => events.once(event, listener),
};
}
function websocketFrameToText(data: RawData): string {
if (typeof data === "string") {
return data;
}
if (Buffer.isBuffer(data)) {
return data.toString("utf8");
}
if (Array.isArray(data)) {
return Buffer.concat(data).toString("utf8");
}
return Buffer.from(data).toString("utf8");
}

View file

@ -0,0 +1,73 @@
export type CodexAppServerTransport = {
stdin: {
write: (data: string) => unknown;
end?: () => unknown;
destroy?: () => unknown;
unref?: () => unknown;
on?: (event: "error", listener: (error: Error) => void) => unknown;
};
stdout: NodeJS.ReadableStream & {
destroy?: () => unknown;
unref?: () => unknown;
};
stderr: NodeJS.ReadableStream & {
destroy?: () => unknown;
unref?: () => unknown;
};
pid?: number;
exitCode?: number | null;
signalCode?: string | null;
killed?: boolean;
kill?: (signal?: NodeJS.Signals) => unknown;
unref?: () => unknown;
once: (event: string, listener: (...args: unknown[]) => void) => unknown;
};
export function closeCodexAppServerTransport(
child: CodexAppServerTransport,
options: { forceKillDelayMs?: number } = {},
): void {
child.stdout.destroy?.();
child.stderr.destroy?.();
child.stdin.end?.();
child.stdin.destroy?.();
signalCodexAppServerTransport(child, "SIGTERM");
const forceKillDelayMs = options.forceKillDelayMs ?? 1_000;
const forceKill = setTimeout(
() => {
if (hasCodexAppServerTransportExited(child)) {
return;
}
signalCodexAppServerTransport(child, "SIGKILL");
},
Math.max(1, forceKillDelayMs),
);
forceKill.unref?.();
child.once("exit", () => clearTimeout(forceKill));
child.unref?.();
child.stdout.unref?.();
child.stderr.unref?.();
child.stdin.unref?.();
}
function hasCodexAppServerTransportExited(child: CodexAppServerTransport): boolean {
return child.exitCode !== null && child.exitCode !== undefined
? true
: child.signalCode !== null && child.signalCode !== undefined;
}
function signalCodexAppServerTransport(
child: CodexAppServerTransport,
signal: NodeJS.Signals,
): void {
if (child.pid && process.platform !== "win32") {
try {
process.kill(-child.pid, signal);
return;
} catch {
// Fall back to the child handle. The process may already be gone or not
// be a process-group leader on older call sites.
}
}
child.kill?.(signal);
}

View file

@ -0,0 +1,157 @@
import type { CodexAppServerModelListResult } from "./app-server/models.js";
import { isJsonObject, type JsonObject, type JsonValue } from "./app-server/protocol.js";
import type { SafeValue } from "./command-rpc.js";
export type CodexStatusProbes = {
models: SafeValue<CodexAppServerModelListResult>;
account: SafeValue<JsonValue | undefined>;
limits: SafeValue<JsonValue | undefined>;
mcps: SafeValue<JsonValue | undefined>;
skills: SafeValue<JsonValue | undefined>;
};
export function formatCodexStatus(probes: CodexStatusProbes): string {
const connected =
probes.models.ok || probes.account.ok || probes.limits.ok || probes.mcps.ok || probes.skills.ok;
const lines = [`Codex app-server: ${connected ? "connected" : "unavailable"}`];
if (probes.models.ok) {
lines.push(
`Models: ${
probes.models.value.models
.map((model) => model.id)
.slice(0, 8)
.join(", ") || "none"
}`,
);
} else {
lines.push(`Models: ${probes.models.error}`);
}
lines.push(
`Account: ${probes.account.ok ? summarizeAccount(probes.account.value) : probes.account.error}`,
);
lines.push(
`Rate limits: ${probes.limits.ok ? summarizeArrayLike(probes.limits.value) : probes.limits.error}`,
);
lines.push(
`MCP servers: ${probes.mcps.ok ? summarizeArrayLike(probes.mcps.value) : probes.mcps.error}`,
);
lines.push(
`Skills: ${probes.skills.ok ? summarizeArrayLike(probes.skills.value) : probes.skills.error}`,
);
return lines.join("\n");
}
export function formatModels(result: CodexAppServerModelListResult): string {
if (result.models.length === 0) {
return "No Codex app-server models returned.";
}
return [
"Codex models:",
...result.models.map((model) => `- ${model.id}${model.isDefault ? " (default)" : ""}`),
].join("\n");
}
export function formatThreads(response: JsonValue | undefined): string {
const threads = extractArray(response);
if (threads.length === 0) {
return "No Codex threads returned.";
}
return [
"Codex threads:",
...threads.slice(0, 10).map((thread) => {
const record = isJsonObject(thread) ? thread : {};
const id = readString(record, "threadId") ?? readString(record, "id") ?? "<unknown>";
const title =
readString(record, "title") ?? readString(record, "name") ?? readString(record, "summary");
const details = [
readString(record, "model"),
readString(record, "cwd"),
readString(record, "updatedAt") ?? readString(record, "lastUpdatedAt"),
].filter(Boolean);
return `- ${id}${title ? ` - ${title}` : ""}${
details.length > 0 ? ` (${details.join(", ")})` : ""
}\n Resume: /codex resume ${id}`;
}),
].join("\n");
}
export function formatAccount(
account: SafeValue<JsonValue | undefined>,
limits: SafeValue<JsonValue | undefined>,
): string {
return [
`Account: ${account.ok ? summarizeAccount(account.value) : account.error}`,
`Rate limits: ${limits.ok ? summarizeArrayLike(limits.value) : limits.error}`,
].join("\n");
}
export function formatList(response: JsonValue | undefined, label: string): string {
const entries = extractArray(response);
if (entries.length === 0) {
return `${label}: none returned.`;
}
return [
`${label}:`,
...entries.slice(0, 25).map((entry) => {
const record = isJsonObject(entry) ? entry : {};
return `- ${readString(record, "name") ?? readString(record, "id") ?? JSON.stringify(entry)}`;
}),
].join("\n");
}
export function buildHelp(): string {
return [
"Codex commands:",
"- /codex status",
"- /codex models",
"- /codex threads [filter]",
"- /codex resume <thread-id>",
"- /codex compact",
"- /codex review",
"- /codex account",
"- /codex mcp",
"- /codex skills",
].join("\n");
}
function summarizeAccount(value: JsonValue | undefined): string {
if (!isJsonObject(value)) {
return "unavailable";
}
return (
readString(value, "email") ??
readString(value, "accountEmail") ??
readString(value, "planType") ??
readString(value, "id") ??
"available"
);
}
function summarizeArrayLike(value: JsonValue | undefined): string {
const entries = extractArray(value);
if (entries.length === 0) {
return "none returned";
}
return `${entries.length}`;
}
function extractArray(value: JsonValue | undefined): JsonValue[] {
if (Array.isArray(value)) {
return value;
}
if (!isJsonObject(value)) {
return [];
}
for (const key of ["data", "items", "threads", "models", "skills", "servers", "rateLimits"]) {
const child = value[key];
if (Array.isArray(child)) {
return child;
}
}
return [];
}
export function readString(record: JsonObject, key: string): string | undefined {
const value = record[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}

View file

@ -0,0 +1,185 @@
import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
import { listCodexAppServerModels } from "./app-server/models.js";
import { isJsonObject } from "./app-server/protocol.js";
import {
readCodexAppServerBinding,
writeCodexAppServerBinding,
} from "./app-server/session-binding.js";
import {
buildHelp,
formatAccount,
formatCodexStatus,
formatList,
formatModels,
formatThreads,
readString,
} from "./command-formatters.js";
import {
codexControlRequest,
readCodexStatusProbes,
requestOptions,
safeCodexControlRequest,
} from "./command-rpc.js";
export type CodexCommandDeps = {
codexControlRequest: typeof codexControlRequest;
listCodexAppServerModels: typeof listCodexAppServerModels;
readCodexStatusProbes: typeof readCodexStatusProbes;
readCodexAppServerBinding: typeof readCodexAppServerBinding;
requestOptions: typeof requestOptions;
safeCodexControlRequest: typeof safeCodexControlRequest;
writeCodexAppServerBinding: typeof writeCodexAppServerBinding;
};
const defaultCodexCommandDeps: CodexCommandDeps = {
codexControlRequest,
listCodexAppServerModels,
readCodexStatusProbes,
readCodexAppServerBinding,
requestOptions,
safeCodexControlRequest,
writeCodexAppServerBinding,
};
export async function handleCodexSubcommand(
ctx: PluginCommandContext,
options: { pluginConfig?: unknown; deps?: Partial<CodexCommandDeps> },
): Promise<{ text: string }> {
const deps: CodexCommandDeps = { ...defaultCodexCommandDeps, ...options.deps };
const [subcommand = "status", ...rest] = splitArgs(ctx.args);
const normalized = subcommand.toLowerCase();
if (normalized === "help") {
return { text: buildHelp() };
}
if (normalized === "status") {
return { text: formatCodexStatus(await deps.readCodexStatusProbes(options.pluginConfig)) };
}
if (normalized === "models") {
return {
text: formatModels(
await deps.listCodexAppServerModels(deps.requestOptions(options.pluginConfig, 100)),
),
};
}
if (normalized === "threads") {
return { text: await buildThreads(deps, options.pluginConfig, rest.join(" ")) };
}
if (normalized === "resume") {
return { text: await resumeThread(deps, ctx, options.pluginConfig, rest[0]) };
}
if (normalized === "compact") {
return {
text: await startThreadAction(
deps,
ctx,
options.pluginConfig,
CODEX_CONTROL_METHODS.compact,
"compaction",
),
};
}
if (normalized === "review") {
return {
text: await startThreadAction(
deps,
ctx,
options.pluginConfig,
CODEX_CONTROL_METHODS.review,
"review",
),
};
}
if (normalized === "mcp") {
return {
text: formatList(
await deps.codexControlRequest(options.pluginConfig, CODEX_CONTROL_METHODS.listMcpServers, {
limit: 100,
}),
"MCP servers",
),
};
}
if (normalized === "skills") {
return {
text: formatList(
await deps.codexControlRequest(options.pluginConfig, CODEX_CONTROL_METHODS.listSkills, {}),
"Codex skills",
),
};
}
if (normalized === "account") {
const [account, limits] = await Promise.all([
deps.safeCodexControlRequest(options.pluginConfig, CODEX_CONTROL_METHODS.account, {}),
deps.safeCodexControlRequest(options.pluginConfig, CODEX_CONTROL_METHODS.rateLimits, {}),
]);
return { text: formatAccount(account, limits) };
}
return { text: `Unknown Codex command: ${subcommand}\n\n${buildHelp()}` };
}
async function buildThreads(
deps: CodexCommandDeps,
pluginConfig: unknown,
filter: string,
): Promise<string> {
const response = await deps.codexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.listThreads, {
limit: 10,
...(filter.trim() ? { filter: filter.trim() } : {}),
});
return formatThreads(response);
}
async function resumeThread(
deps: CodexCommandDeps,
ctx: PluginCommandContext,
pluginConfig: unknown,
threadId: string | undefined,
): Promise<string> {
const normalizedThreadId = threadId?.trim();
if (!normalizedThreadId) {
return "Usage: /codex resume <thread-id>";
}
if (!ctx.sessionFile) {
return "Cannot attach a Codex thread because this command did not include an OpenClaw session file.";
}
const response = await deps.codexControlRequest(
pluginConfig,
CODEX_CONTROL_METHODS.resumeThread,
{
threadId: normalizedThreadId,
persistExtendedHistory: true,
},
);
const thread = isJsonObject(response) && isJsonObject(response.thread) ? response.thread : {};
const effectiveThreadId = readString(thread, "id") ?? normalizedThreadId;
await deps.writeCodexAppServerBinding(ctx.sessionFile, {
threadId: effectiveThreadId,
cwd: readString(thread, "cwd") ?? "",
model: isJsonObject(response) ? readString(response, "model") : undefined,
modelProvider: isJsonObject(response) ? readString(response, "modelProvider") : undefined,
});
return `Attached this OpenClaw session to Codex thread ${effectiveThreadId}.`;
}
async function startThreadAction(
deps: CodexCommandDeps,
ctx: PluginCommandContext,
pluginConfig: unknown,
method: typeof CODEX_CONTROL_METHODS.compact | typeof CODEX_CONTROL_METHODS.review,
label: string,
): Promise<string> {
if (!ctx.sessionFile) {
return `Cannot start Codex ${label} because this command did not include an OpenClaw session file.`;
}
const binding = await deps.readCodexAppServerBinding(ctx.sessionFile);
if (!binding?.threadId) {
return `No Codex thread is attached to this OpenClaw session yet.`;
}
await deps.codexControlRequest(pluginConfig, method, { threadId: binding.threadId });
return `Started Codex ${label} for thread ${binding.threadId}.`;
}
function splitArgs(value: string | undefined): string[] {
return (value ?? "").trim().split(/\s+/).filter(Boolean);
}

View file

@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { CodexAppServerRpcError } from "./app-server/client.js";
import { safeValue } from "./command-rpc.js";
describe("Codex command RPC helpers", () => {
it("formats unsupported control methods from JSON-RPC error codes", async () => {
await expect(
safeValue(async () => {
throw new CodexAppServerRpcError({ code: -32601, message: "Method not found" }, "x/y");
}),
).resolves.toEqual({
ok: false,
error: "unsupported by this Codex app-server",
});
});
});

View file

@ -0,0 +1,70 @@
import {
CODEX_CONTROL_METHODS,
describeControlFailure,
type CodexControlMethod,
} from "./app-server/capabilities.js";
import { resolveCodexAppServerRuntimeOptions } from "./app-server/config.js";
import { listCodexAppServerModels } from "./app-server/models.js";
import type { JsonValue } from "./app-server/protocol.js";
import { requestCodexAppServerJson } from "./app-server/request.js";
export type SafeValue<T> = { ok: true; value: T } | { ok: false; error: string };
export function requestOptions(pluginConfig: unknown, limit: number) {
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig });
return {
limit,
timeoutMs: runtime.requestTimeoutMs,
startOptions: runtime.start,
};
}
export async function codexControlRequest(
pluginConfig: unknown,
method: CodexControlMethod,
requestParams?: JsonValue,
): Promise<JsonValue | undefined> {
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig });
return await requestCodexAppServerJson({
method,
requestParams,
timeoutMs: runtime.requestTimeoutMs,
startOptions: runtime.start,
});
}
export async function safeCodexControlRequest(
pluginConfig: unknown,
method: CodexControlMethod,
requestParams?: JsonValue,
): Promise<SafeValue<JsonValue | undefined>> {
return await safeValue(
async () => await codexControlRequest(pluginConfig, method, requestParams),
);
}
export async function safeCodexModelList(pluginConfig: unknown, limit: number) {
return await safeValue(
async () => await listCodexAppServerModels(requestOptions(pluginConfig, limit)),
);
}
export async function readCodexStatusProbes(pluginConfig: unknown) {
const [models, account, limits, mcps, skills] = await Promise.all([
safeCodexModelList(pluginConfig, 20),
safeCodexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.account, {}),
safeCodexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.rateLimits, {}),
safeCodexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.listMcpServers, { limit: 100 }),
safeCodexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.listSkills, {}),
]);
return { models, account, limits, mcps, skills };
}
export async function safeValue<T>(read: () => Promise<T>): Promise<SafeValue<T>> {
try {
return { ok: true, value: await read() };
} catch (error) {
return { ok: false, error: describeControlFailure(error) };
}
}

View file

@ -0,0 +1,185 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
import type { CodexAppServerStartOptions } from "./app-server/config.js";
import { resetSharedCodexAppServerClientForTests } from "./app-server/shared-client.js";
import type { CodexCommandDeps } from "./command-handlers.js";
import { handleCodexCommand } from "./commands.js";
let tempDir: string;
function createContext(args: string, sessionFile?: string): PluginCommandContext {
return {
channel: "test",
isAuthorizedSender: true,
args,
commandBody: `/codex ${args}`,
config: {},
sessionFile,
requestConversationBinding: async () => ({ status: "error", message: "unused" }),
detachConversationBinding: async () => ({ removed: false }),
getCurrentConversationBinding: async () => null,
};
}
function createDeps(overrides: Partial<CodexCommandDeps> = {}): Partial<CodexCommandDeps> {
return {
codexControlRequest: vi.fn(),
listCodexAppServerModels: vi.fn(),
readCodexStatusProbes: vi.fn(),
requestOptions: vi.fn((_pluginConfig: unknown, limit: number) => ({
limit,
timeoutMs: 1000,
startOptions: {
transport: "stdio",
command: "codex",
args: ["app-server", "--listen", "stdio://"],
headers: {},
} satisfies CodexAppServerStartOptions,
})),
safeCodexControlRequest: vi.fn(),
...overrides,
};
}
describe("codex command", () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-command-"));
});
afterEach(async () => {
resetSharedCodexAppServerClientForTests();
await fs.rm(tempDir, { recursive: true, force: true });
});
it("attaches the current session to an existing Codex thread", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const requests: Array<{ method: string; params: unknown }> = [];
const deps = createDeps({
codexControlRequest: vi.fn(
async (_pluginConfig: unknown, method: string, requestParams: unknown) => {
requests.push({ method, params: requestParams });
return {
thread: { id: "thread-123", cwd: "/repo" },
model: "gpt-5.4",
modelProvider: "openai",
};
},
),
});
await expect(
handleCodexCommand(createContext("resume thread-123", sessionFile), { deps }),
).resolves.toEqual({
text: "Attached this OpenClaw session to Codex thread thread-123.",
});
expect(requests).toEqual([
{
method: "thread/resume",
params: { threadId: "thread-123", persistExtendedHistory: true },
},
]);
await expect(fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8")).resolves.toContain(
'"threadId": "thread-123"',
);
});
it("shows model ids from Codex app-server", async () => {
const deps = createDeps({
listCodexAppServerModels: vi.fn(async () => ({
models: [
{
id: "gpt-5.4",
model: "gpt-5.4",
inputModalities: ["text"],
supportedReasoningEfforts: ["medium"],
},
],
})),
});
await expect(handleCodexCommand(createContext("models"), { deps })).resolves.toEqual({
text: "Codex models:\n- gpt-5.4",
});
});
it("reports status unavailable when every Codex probe fails", async () => {
const offline = { ok: false as const, error: "offline" };
const deps = createDeps({
readCodexStatusProbes: vi.fn(async () => ({
models: offline,
account: offline,
limits: offline,
mcps: offline,
skills: offline,
})),
});
await expect(handleCodexCommand(createContext("status"), { deps })).resolves.toEqual({
text: [
"Codex app-server: unavailable",
"Models: offline",
"Account: offline",
"Rate limits: offline",
"MCP servers: offline",
"Skills: offline",
].join("\n"),
});
});
it("starts compaction for the attached Codex thread", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
await fs.writeFile(
`${sessionFile}.codex-app-server.json`,
JSON.stringify({ schemaVersion: 1, threadId: "thread-123", cwd: "/repo" }),
);
const codexControlRequest = vi.fn(async () => ({}));
const deps = createDeps({
codexControlRequest,
});
await expect(
handleCodexCommand(createContext("compact", sessionFile), { deps }),
).resolves.toEqual({
text: "Started Codex compaction for thread thread-123.",
});
expect(codexControlRequest).toHaveBeenCalledWith(undefined, CODEX_CONTROL_METHODS.compact, {
threadId: "thread-123",
});
});
it("explains compaction when no Codex thread is attached", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
await expect(
handleCodexCommand(createContext("compact", sessionFile), { deps: createDeps() }),
).resolves.toEqual({
text: "No Codex thread is attached to this OpenClaw session yet.",
});
});
it("passes filters to Codex thread listing", async () => {
const codexControlRequest = vi.fn(async () => ({
data: [{ id: "thread-123", title: "Fix the thing", model: "gpt-5.4", cwd: "/repo" }],
}));
const deps = createDeps({
codexControlRequest,
});
await expect(handleCodexCommand(createContext("threads fix"), { deps })).resolves.toEqual({
text: [
"Codex threads:",
"- thread-123 - Fix the thing (gpt-5.4, /repo)",
" Resume: /codex resume thread-123",
].join("\n"),
});
expect(codexControlRequest).toHaveBeenCalledWith(undefined, CODEX_CONTROL_METHODS.listThreads, {
limit: 10,
filter: "fix",
});
});
});

View file

@ -0,0 +1,25 @@
import type {
OpenClawPluginCommandDefinition,
PluginCommandContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { handleCodexSubcommand, type CodexCommandDeps } from "./command-handlers.js";
export function createCodexCommand(options: {
pluginConfig?: unknown;
deps?: Partial<CodexCommandDeps>;
}): OpenClawPluginCommandDefinition {
return {
name: "codex",
description: "Inspect and control the Codex app-server harness",
acceptsArgs: true,
requireAuth: true,
handler: (ctx) => handleCodexCommand(ctx, options),
};
}
export async function handleCodexCommand(
ctx: PluginCommandContext,
options: { pluginConfig?: unknown; deps?: Partial<CodexCommandDeps> } = {},
): Promise<{ text: string }> {
return await handleCodexSubcommand(ctx, options);
}

View file

@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}