mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 20:20:45 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
55
openclaw/extensions/codex/harness.ts
Normal file
55
openclaw/extensions/codex/harness.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
62
openclaw/extensions/codex/index.test.ts
Normal file
62
openclaw/extensions/codex/index.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
15
openclaw/extensions/codex/index.ts
Normal file
15
openclaw/extensions/codex/index.ts
Normal 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 }));
|
||||
},
|
||||
});
|
||||
161
openclaw/extensions/codex/openclaw.plugin.json
Normal file
161
openclaw/extensions/codex/openclaw.plugin.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
19
openclaw/extensions/codex/package.json
Normal file
19
openclaw/extensions/codex/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
179
openclaw/extensions/codex/provider.test.ts
Normal file
179
openclaw/extensions/codex/provider.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
225
openclaw/extensions/codex/provider.ts
Normal file
225
openclaw/extensions/codex/provider.ts
Normal 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";
|
||||
}
|
||||
138
openclaw/extensions/codex/src/app-server/approval-bridge.test.ts
Normal file
138
openclaw/extensions/codex/src/app-server/approval-bridge.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
377
openclaw/extensions/codex/src/app-server/approval-bridge.ts
Normal file
377
openclaw/extensions/codex/src/app-server/approval-bridge.ts
Normal 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);
|
||||
}
|
||||
148
openclaw/extensions/codex/src/app-server/auth-bridge.test.ts
Normal file
148
openclaw/extensions/codex/src/app-server/auth-bridge.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
82
openclaw/extensions/codex/src/app-server/auth-bridge.ts
Normal file
82
openclaw/extensions/codex/src/app-server/auth-bridge.ts
Normal 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]),
|
||||
),
|
||||
};
|
||||
}
|
||||
26
openclaw/extensions/codex/src/app-server/capabilities.ts
Normal file
26
openclaw/extensions/codex/src/app-server/capabilities.ts
Normal 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;
|
||||
}
|
||||
282
openclaw/extensions/codex/src/app-server/client.test.ts
Normal file
282
openclaw/extensions/codex/src/app-server/client.test.ts
Normal 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
435
openclaw/extensions/codex/src/app-server/client.ts
Normal file
435
openclaw/extensions/codex/src/app-server/client.ts
Normal 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;
|
||||
191
openclaw/extensions/codex/src/app-server/compact.test.ts
Normal file
191
openclaw/extensions/codex/src/app-server/compact.test.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
230
openclaw/extensions/codex/src/app-server/compact.ts
Normal file
230
openclaw/extensions/codex/src/app-server/compact.ts
Normal 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;
|
||||
83
openclaw/extensions/codex/src/app-server/config.test.ts
Normal file
83
openclaw/extensions/codex/src/app-server/config.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
256
openclaw/extensions/codex/src/app-server/config.ts
Normal file
256
openclaw/extensions/codex/src/app-server/config.ts
Normal 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;
|
||||
}
|
||||
178
openclaw/extensions/codex/src/app-server/dynamic-tools.test.ts
Normal file
178
openclaw/extensions/codex/src/app-server/dynamic-tools.test.ts
Normal 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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
220
openclaw/extensions/codex/src/app-server/dynamic-tools.ts
Normal file
220
openclaw/extensions/codex/src/app-server/dynamic-tools.ts
Normal 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";
|
||||
}
|
||||
392
openclaw/extensions/codex/src/app-server/event-projector.test.ts
Normal file
392
openclaw/extensions/codex/src/app-server/event-projector.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
714
openclaw/extensions/codex/src/app-server/event-projector.ts
Normal file
714
openclaw/extensions/codex/src/app-server/event-projector.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
64
openclaw/extensions/codex/src/app-server/models.test.ts
Normal file
64
openclaw/extensions/codex/src/app-server/models.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
146
openclaw/extensions/codex/src/app-server/models.ts
Normal file
146
openclaw/extensions/codex/src/app-server/models.ts
Normal 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));
|
||||
}
|
||||
201
openclaw/extensions/codex/src/app-server/protocol.ts
Normal file
201
openclaw/extensions/codex/src/app-server/protocol.ts
Normal 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;
|
||||
}
|
||||
26
openclaw/extensions/codex/src/app-server/request.ts
Normal file
26
openclaw/extensions/codex/src/app-server/request.ts
Normal 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`,
|
||||
);
|
||||
}
|
||||
611
openclaw/extensions/codex/src/app-server/run-attempt.test.ts
Normal file
611
openclaw/extensions/codex/src/app-server/run-attempt.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
496
openclaw/extensions/codex/src/app-server/run-attempt.ts
Normal file
496
openclaw/extensions/codex/src/app-server/run-attempt.ts
Normal 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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
101
openclaw/extensions/codex/src/app-server/session-binding.ts
Normal file
101
openclaw/extensions/codex/src/app-server/session-binding.ts
Normal 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");
|
||||
}
|
||||
109
openclaw/extensions/codex/src/app-server/shared-client.test.ts
Normal file
109
openclaw/extensions/codex/src/app-server/shared-client.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
115
openclaw/extensions/codex/src/app-server/shared-client.ts
Normal file
115
openclaw/extensions/codex/src/app-server/shared-client.ts
Normal 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;
|
||||
}
|
||||
33
openclaw/extensions/codex/src/app-server/test-support.ts
Normal file
33
openclaw/extensions/codex/src/app-server/test-support.ts
Normal 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`);
|
||||
},
|
||||
};
|
||||
}
|
||||
217
openclaw/extensions/codex/src/app-server/thread-lifecycle.ts
Normal file
217
openclaw/extensions/codex/src/app-server/thread-lifecycle.ts
Normal 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;
|
||||
}
|
||||
22
openclaw/extensions/codex/src/app-server/timeout.ts
Normal file
22
openclaw/extensions/codex/src/app-server/timeout.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
18
openclaw/extensions/codex/src/app-server/transport-stdio.ts
Normal file
18
openclaw/extensions/codex/src/app-server/transport-stdio.ts
Normal 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"],
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
73
openclaw/extensions/codex/src/app-server/transport.ts
Normal file
73
openclaw/extensions/codex/src/app-server/transport.ts
Normal 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);
|
||||
}
|
||||
157
openclaw/extensions/codex/src/command-formatters.ts
Normal file
157
openclaw/extensions/codex/src/command-formatters.ts
Normal 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;
|
||||
}
|
||||
185
openclaw/extensions/codex/src/command-handlers.ts
Normal file
185
openclaw/extensions/codex/src/command-handlers.ts
Normal 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);
|
||||
}
|
||||
16
openclaw/extensions/codex/src/command-rpc.test.ts
Normal file
16
openclaw/extensions/codex/src/command-rpc.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
70
openclaw/extensions/codex/src/command-rpc.ts
Normal file
70
openclaw/extensions/codex/src/command-rpc.ts
Normal 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) };
|
||||
}
|
||||
}
|
||||
185
openclaw/extensions/codex/src/commands.test.ts
Normal file
185
openclaw/extensions/codex/src/commands.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
25
openclaw/extensions/codex/src/commands.ts
Normal file
25
openclaw/extensions/codex/src/commands.ts
Normal 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);
|
||||
}
|
||||
16
openclaw/extensions/codex/tsconfig.json
Normal file
16
openclaw/extensions/codex/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue