diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ac140c87f6..ca5e709afe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - Plugin SDK/Pi embedded runs: add a bundled-plugin embedded extension factory seam so native plugins can extend Pi embedded runs with async runtime hooks such as `tool_result` handling instead of falling back to the older synchronous persistence path. (#69946) Thanks @vincentkoc. - Tokenjuice: add bundled native OpenClaw support for tokenjuice as an opt-in plugin that compacts noisy `exec` and `bash` tool results in Pi embedded runs. (#69946) Thanks @vincentkoc. - Codex harness/hooks: route native Codex app-server turns through `before_prompt_build` and emit `before_compaction` / `after_compaction` for native compaction items so prompt and compaction hooks stop drifting from Pi. Thanks @vincentkoc. +- Codex harness/plugins: add a bundled-plugin Codex app-server extension seam for async `tool_result` middleware, fire `after_tool_call` for Codex tool runs, and route mirrored Codex transcript writes through `before_message_write` so tool integrations stop diverging from Pi. Thanks @vincentkoc. - Providers/Tencent: add the bundled Tencent Cloud provider plugin with TokenHub and Token Plan onboarding, docs, `hy3-preview` model catalog entries, and tiered Hy3 pricing metadata. (#68460) Thanks @JuniperSling. - TUI: add local embedded mode for running terminal chats without a Gateway while keeping plugin approval gates enforced. (#66767) Thanks @fuller-stack-dev. - CLI/Claude: default `claude-cli` runs to warm stdio sessions, including custom configs that omit transport fields, and resume from the stored Claude session after Gateway restarts or idle exits. (#69679) Thanks @obviyus. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index a0a62e51ea5..81e57d732fc 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -23c12038821233958a3659371293384f5f69208353433c70196b2f27798a3316 plugin-sdk-api-baseline.json -40ca99eaf0bf6f1b52bb7c2208a105fbba3215d59c518e2edd93e22f52841b27 plugin-sdk-api-baseline.jsonl +2b7093a57992029cc70126d33544e02eed6c3076a3a6b4ffa6aef7664da0f33d plugin-sdk-api-baseline.json +ea6a2f2326565517b6c42a4d334f615163fb434dbad5e0b8d134c92767714256 plugin-sdk-api-baseline.jsonl diff --git a/docs/plugins/sdk-agent-harness.md b/docs/plugins/sdk-agent-harness.md index 6979c4d5e12..455d50ed53d 100644 --- a/docs/plugins/sdk-agent-harness.md +++ b/docs/plugins/sdk-agent-harness.md @@ -134,6 +134,15 @@ OpenClaw requires Codex app-server `0.118.0` or newer. The Codex plugin checks the app-server initialize handshake and blocks older or unversioned servers so OpenClaw only runs against the protocol surface it has been tested with. +### Codex app-server tool-result middleware + +Bundled plugins can also attach Codex app-server-specific `tool_result` +middleware through `api.registerCodexAppServerExtensionFactory(...)` when their +manifest declares `contracts.embeddedExtensionFactories: ["codex-app-server"]`. +This is the trusted-plugin seam for async tool-result transforms that need to +run inside the native Codex harness before the tool output is projected back +into the OpenClaw transcript. + ### Native Codex harness mode The bundled `codex` harness is the native Codex mode for embedded OpenClaw diff --git a/extensions/codex/src/app-server/dynamic-tools.test.ts b/extensions/codex/src/app-server/dynamic-tools.test.ts index 35e4bfa5ead..f2fde99932f 100644 --- a/extensions/codex/src/app-server/dynamic-tools.test.ts +++ b/extensions/codex/src/app-server/dynamic-tools.test.ts @@ -1,6 +1,13 @@ 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 { afterEach, describe, expect, it, vi } from "vitest"; +import { + initializeGlobalHookRunner, + resetGlobalHookRunner, +} from "../../../../src/plugins/hook-runner-global.js"; +import { createMockPluginRegistry } from "../../../../src/plugins/hooks.test-helpers.js"; +import { createEmptyPluginRegistry } from "../../../../src/plugins/registry.js"; +import { setActivePluginRegistry } from "../../../../src/plugins/runtime.js"; import { createCodexDynamicToolBridge } from "./dynamic-tools.js"; import type { JsonValue } from "./protocol.js"; @@ -58,6 +65,11 @@ async function handleMessageToolCall( }); } +afterEach(() => { + resetGlobalHookRunner(); + setActivePluginRegistry(createEmptyPluginRegistry()); +}); + describe("createCodexDynamicToolBridge", () => { it.each([ { toolName: "tts", mediaUrl: "/tmp/reply.opus", audioAsVoice: true }, @@ -152,4 +164,82 @@ describe("createCodexDynamicToolBridge", () => { messagingToolSentTargets: [], }); }); + + it("applies codex app-server tool_result extensions from the active plugin registry", async () => { + const registry = createEmptyPluginRegistry(); + const factory = async (codex: { + on: ( + event: "tool_result", + handler: (event: any) => Promise<{ result: AgentToolResult }>, + ) => void; + }) => { + codex.on("tool_result", async (event) => ({ + result: { + ...event.result, + content: [{ type: "text", text: `${event.toolName} compacted` }], + }, + })); + }; + registry.codexAppServerExtensionFactories.push({ + pluginId: "tokenjuice", + pluginName: "Tokenjuice", + rawFactory: factory, + factory, + source: "test", + }); + setActivePluginRegistry(registry); + + const bridge = createBridgeWithToolResult("exec", { + content: [{ type: "text", text: "raw output" }], + details: {}, + }); + + const result = await bridge.handleToolCall({ + threadId: "thread-1", + turnId: "turn-1", + callId: "call-1", + tool: "exec", + arguments: { command: "git status" }, + }); + + expect(result).toEqual(expectInputText("exec compacted")); + }); + + it("fires after_tool_call for successful codex tool executions", async () => { + const afterToolCall = vi.fn(); + initializeGlobalHookRunner( + createMockPluginRegistry([{ hookName: "after_tool_call", handler: afterToolCall }]), + ); + + const bridge = createBridgeWithToolResult("exec", { + content: [{ type: "text", text: "done" }], + details: {}, + }); + + await bridge.handleToolCall({ + threadId: "thread-1", + turnId: "turn-1", + callId: "call-1", + tool: "exec", + arguments: { command: "pwd" }, + }); + + await vi.waitFor(() => { + expect(afterToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: "exec", + toolCallId: "call-1", + params: { command: "pwd" }, + result: expect.objectContaining({ + content: [{ type: "text", text: "done" }], + details: {}, + }), + }), + expect.objectContaining({ + toolName: "exec", + toolCallId: "call-1", + }), + ); + }); + }); }); diff --git a/extensions/codex/src/app-server/dynamic-tools.ts b/extensions/codex/src/app-server/dynamic-tools.ts index 5a13360eff3..67a6aa23846 100644 --- a/extensions/codex/src/app-server/dynamic-tools.ts +++ b/extensions/codex/src/app-server/dynamic-tools.ts @@ -1,10 +1,12 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; import { + createCodexAppServerToolResultExtensionRunner, extractToolResultMediaArtifact, filterToolResultMediaUrls, isMessagingTool, isMessagingToolSendAction, + runAgentHarnessAfterToolCallHook, type AnyAgentTool, type MessagingToolSend, } from "openclaw/plugin-sdk/agent-harness"; @@ -33,6 +35,12 @@ export type CodexDynamicToolBridge = { export function createCodexDynamicToolBridge(params: { tools: AnyAgentTool[]; signal: AbortSignal; + hookContext?: { + agentId?: string; + sessionId?: string; + sessionKey?: string; + runId?: string; + }; }): CodexDynamicToolBridge { const toolMap = new Map(params.tools.map((tool) => [tool.name, tool])); const telemetry: CodexDynamicToolBridge["telemetry"] = { @@ -43,6 +51,7 @@ export function createCodexDynamicToolBridge(params: { toolMediaUrls: [], toolAudioAsVoice: false, }; + const extensionRunner = createCodexAppServerToolResultExtensionRunner(params.hookContext ?? {}); return { specs: params.tools.map((tool) => ({ @@ -60,9 +69,18 @@ export function createCodexDynamicToolBridge(params: { }; } const args = jsonObjectToRecord(call.arguments); + const startedAt = Date.now(); try { const preparedArgs = tool.prepareArguments ? tool.prepareArguments(args) : args; - const result = await tool.execute(call.callId, preparedArgs, params.signal); + const rawResult = await tool.execute(call.callId, preparedArgs, params.signal); + const result = await extensionRunner.applyToolResultExtensions({ + threadId: call.threadId, + turnId: call.turnId, + toolCallId: call.callId, + toolName: tool.name, + args, + result: rawResult, + }); collectToolTelemetry({ toolName: tool.name, args, @@ -70,6 +88,17 @@ export function createCodexDynamicToolBridge(params: { telemetry, isError: false, }); + void runAgentHarnessAfterToolCallHook({ + toolName: tool.name, + toolCallId: call.callId, + runId: params.hookContext?.runId, + agentId: params.hookContext?.agentId, + sessionId: params.hookContext?.sessionId, + sessionKey: params.hookContext?.sessionKey, + startArgs: args, + result, + startedAt, + }); return { contentItems: result.content.flatMap(convertToolContent), success: true, @@ -82,6 +111,17 @@ export function createCodexDynamicToolBridge(params: { telemetry, isError: true, }); + void runAgentHarnessAfterToolCallHook({ + toolName: tool.name, + toolCallId: call.callId, + runId: params.hookContext?.runId, + agentId: params.hookContext?.agentId, + sessionId: params.hookContext?.sessionId, + sessionKey: params.hookContext?.sessionKey, + startArgs: args, + error: error instanceof Error ? error.message : String(error), + startedAt, + }); return { contentItems: [ { diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 72d16beab6f..32981efd324 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -100,6 +100,12 @@ export async function runCodexAppServerAttempt( const toolBridge = createCodexDynamicToolBridge({ tools, signal: runAbortController.signal, + hookContext: { + agentId: sessionAgentId, + sessionId: params.sessionId, + sessionKey: sandboxSessionKey, + runId: params.runId, + }, }); const historyMessages = readMirroredSessionHistoryMessages(params.sessionFile); const promptBuild = await resolveAgentHarnessBeforePromptBuildResult({ @@ -279,7 +285,9 @@ export async function runCodexAppServerAttempt( const result = activeProjector.buildResult(toolBridge.telemetry, { yieldDetected }); await mirrorTranscriptBestEffort({ params, + agentId: sessionAgentId, result, + sessionKey: sandboxSessionKey, threadId: thread.threadId, turnId: activeTurnId, }); @@ -514,14 +522,17 @@ function readMirroredSessionHistoryMessages(sessionFile: string): unknown[] { async function mirrorTranscriptBestEffort(params: { params: EmbeddedRunAttemptParams; + agentId?: string; result: EmbeddedRunAttemptResult; + sessionKey?: string; threadId: string; turnId: string; }): Promise { try { await mirrorCodexAppServerTranscript({ sessionFile: params.params.sessionFile, - sessionKey: params.params.sessionKey, + agentId: params.agentId, + sessionKey: params.sessionKey, messages: params.result.messagesSnapshot, idempotencyScope: `codex-app-server:${params.threadId}:${params.turnId}`, }); diff --git a/extensions/codex/src/app-server/transcript-mirror.test.ts b/extensions/codex/src/app-server/transcript-mirror.test.ts index 5574d1eca8c..bdfb720921e 100644 --- a/extensions/codex/src/app-server/transcript-mirror.test.ts +++ b/extensions/codex/src/app-server/transcript-mirror.test.ts @@ -1,92 +1,186 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { + castAgentMessage, + makeAgentAssistantMessage, + makeAgentUserMessage, +} from "../../../../src/agents/test-helpers/agent-message-fixtures.js"; +import { + initializeGlobalHookRunner, + resetGlobalHookRunner, +} from "../../../../src/plugins/hook-runner-global.js"; +import { createMockPluginRegistry } from "../../../../src/plugins/hooks.test-helpers.js"; import { mirrorCodexAppServerTranscript } from "./transcript-mirror.js"; -let tempDir: string; +const tempDirs: string[] = []; -function assistantMessage(text: string, timestamp: number): AgentMessage { - return { - role: "assistant", - content: [{ type: "text", text }], - 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, - }; +afterEach(async () => { + resetGlobalHookRunner(); + for (const dir of tempDirs.splice(0)) { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +async function createTempSessionFile() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-transcript-")); + tempDirs.push(dir); + return path.join(dir, "session.jsonl"); } 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"); + it("mirrors user and assistant messages into the Pi transcript", async () => { + const sessionFile = await createTempSessionFile(); await mirrorCodexAppServerTranscript({ sessionFile, - sessionKey: "agent:main:session-1", + sessionKey: "session-1", messages: [ - { role: "user", content: "hello", timestamp: 1 }, - assistantMessage("Codex plan:\ninspect", 2), - assistantMessage("hi", 3), + makeAgentUserMessage({ + content: [{ type: "text", text: "hello" }], + timestamp: Date.now(), + }), + makeAgentAssistantMessage({ + content: [{ type: "text", text: "hi there" }], + timestamp: Date.now() + 1, + }), ], + idempotencyScope: "scope-1", }); - 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", - ]); + const raw = await fs.readFile(sessionFile, "utf8"); + expect(raw).toContain('"role":"user"'); + expect(raw).toContain('"content":[{"type":"text","text":"hello"}]'); + expect(raw).toContain('"role":"assistant"'); + expect(raw).toContain('"content":[{"type":"text","text":"hi there"}]'); + expect(raw).toContain('"idempotencyKey":"scope-1:user:0"'); + expect(raw).toContain('"idempotencyKey":"scope-1:assistant:1"'); }); it("deduplicates app-server turn mirrors by idempotency scope", async () => { - const sessionFile = path.join(tempDir, "session.jsonl"); + const sessionFile = await createTempSessionFile(); const messages = [ - { role: "user" as const, content: "hello", timestamp: 1 }, - assistantMessage("hi", 2), - ]; + makeAgentUserMessage({ + content: [{ type: "text", text: "hello" }], + timestamp: Date.now(), + }), + makeAgentAssistantMessage({ + content: [{ type: "text", text: "hi there" }], + timestamp: Date.now() + 1, + }), + ] as const; await mirrorCodexAppServerTranscript({ sessionFile, - messages, - idempotencyScope: "codex-app-server:thread-1:turn-1", + sessionKey: "session-1", + messages: [...messages], + idempotencyScope: "scope-1", }); await mirrorCodexAppServerTranscript({ sessionFile, - messages, - idempotencyScope: "codex-app-server:thread-1:turn-1", + sessionKey: "session-1", + messages: [...messages], + idempotencyScope: "scope-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", - ]); + .filter(Boolean) + .map((line) => JSON.parse(line) as { type?: string; message?: { role?: string } }); + expect(records.slice(1)).toHaveLength(2); + }); + + it("runs before_message_write before appending mirrored transcript messages", async () => { + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "before_message_write", + handler: (event) => ({ + message: castAgentMessage({ + ...((event as { message: unknown }).message as Record), + content: [{ type: "text", text: "hello [hooked]" }], + }), + }), + }, + ]), + ); + const sessionFile = await createTempSessionFile(); + + await mirrorCodexAppServerTranscript({ + sessionFile, + sessionKey: "session-1", + messages: [ + makeAgentAssistantMessage({ + content: [{ type: "text", text: "hello" }], + timestamp: Date.now(), + }), + ], + idempotencyScope: "scope-1", + }); + + const raw = await fs.readFile(sessionFile, "utf8"); + expect(raw).toContain('"content":[{"type":"text","text":"hello [hooked]"}]'); + expect(raw).toContain('"idempotencyKey":"scope-1:assistant:0"'); + }); + + it("preserves the computed idempotency key when hooks rewrite message keys", async () => { + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "before_message_write", + handler: (event) => ({ + message: castAgentMessage({ + ...((event as { message: unknown }).message as Record), + idempotencyKey: "hook-rewritten-key", + }), + }), + }, + ]), + ); + const sessionFile = await createTempSessionFile(); + + await mirrorCodexAppServerTranscript({ + sessionFile, + sessionKey: "session-1", + messages: [ + makeAgentAssistantMessage({ + content: [{ type: "text", text: "hello" }], + timestamp: Date.now(), + }), + ], + idempotencyScope: "scope-1", + }); + + const raw = await fs.readFile(sessionFile, "utf8"); + expect(raw).toContain('"idempotencyKey":"scope-1:assistant:0"'); + expect(raw).not.toContain("hook-rewritten-key"); + }); + + it("respects before_message_write blocking decisions", async () => { + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "before_message_write", + handler: () => ({ block: true }), + }, + ]), + ); + const sessionFile = await createTempSessionFile(); + + await mirrorCodexAppServerTranscript({ + sessionFile, + sessionKey: "session-1", + messages: [ + makeAgentAssistantMessage({ + content: [{ type: "text", text: "should not persist" }], + timestamp: Date.now(), + }), + ], + idempotencyScope: "scope-1", + }); + + await expect(fs.readFile(sessionFile, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); }); }); diff --git a/extensions/codex/src/app-server/transcript-mirror.ts b/extensions/codex/src/app-server/transcript-mirror.ts index 0f8138dd324..74932b6cc95 100644 --- a/extensions/codex/src/app-server/transcript-mirror.ts +++ b/extensions/codex/src/app-server/transcript-mirror.ts @@ -5,11 +5,13 @@ import { SessionManager } from "@mariozechner/pi-coding-agent"; import { acquireSessionWriteLock, emitSessionTranscriptUpdate, + runAgentHarnessBeforeMessageWriteHook, } from "openclaw/plugin-sdk/agent-harness"; export async function mirrorCodexAppServerTranscript(params: { sessionFile: string; sessionKey?: string; + agentId?: string; messages: AgentMessage[]; idempotencyScope?: string; }): Promise { @@ -39,7 +41,21 @@ export async function mirrorCodexAppServerTranscript(params: { ...message, ...(idempotencyKey ? { idempotencyKey } : {}), } as Parameters[0]; - sessionManager.appendMessage(transcriptMessage); + const nextMessage = runAgentHarnessBeforeMessageWriteHook({ + message: transcriptMessage, + agentId: params.agentId, + sessionKey: params.sessionKey, + }); + if (!nextMessage) { + continue; + } + const messageToAppend = (idempotencyKey + ? { + ...(nextMessage as unknown as Record), + idempotencyKey, + } + : nextMessage) as unknown as Parameters[0]; + sessionManager.appendMessage(messageToAppend); if (idempotencyKey) { existingIdempotencyKeys.add(idempotencyKey); } diff --git a/src/agents/codex-app-server.extensions.test.ts b/src/agents/codex-app-server.extensions.test.ts new file mode 100644 index 00000000000..9ab9cb15fa4 --- /dev/null +++ b/src/agents/codex-app-server.extensions.test.ts @@ -0,0 +1,263 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createCodexAppServerToolResultExtensionRunner } from "../plugin-sdk/agent-harness.js"; +import { listCodexAppServerExtensionFactories } from "../plugins/codex-app-server-extension-factory.js"; +import { clearPluginLoaderCache, loadOpenClawPlugins } from "../plugins/loader.js"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; + +const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} }; +const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; +const tempDirs: string[] = []; + +function createTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-ext-")); + tempDirs.push(dir); + return dir; +} + +function writeTempPlugin(params: { + dir: string; + id: string; + body: string; + manifest?: Record; + filename?: string; +}): string { + const pluginDir = path.join(params.dir, params.id); + fs.mkdirSync(pluginDir, { recursive: true }); + const file = path.join(pluginDir, params.filename ?? `${params.id}.mjs`); + fs.writeFileSync(file, params.body, "utf-8"); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: params.id, + ...params.manifest, + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + return file; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + clearPluginLoaderCache(); + setActivePluginRegistry(createEmptyPluginRegistry()); + if (originalBundledPluginsDir === undefined) { + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + } else { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir; + } +}); + +describe("Codex app-server extension factories", () => { + it("includes plugin-registered Codex app-server extension factories and restores them from cache", async () => { + const tmp = createTempDir(); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp; + + writeTempPlugin({ + dir: tmp, + id: "codex-ext", + filename: "index.mjs", + manifest: { + contracts: { + embeddedExtensionFactories: ["codex-app-server"], + }, + }, + body: `export default { id: "codex-ext", register(api) { + api.registerCodexAppServerExtensionFactory((codex) => { + codex.on("tool_result", async (event) => ({ + result: { ...event.result, content: [{ type: "text", text: "compacted" }] } + })); + }); +} };`, + }); + + const options = { + config: { + plugins: { + entries: { + "codex-ext": { + enabled: true, + }, + }, + }, + }, + }; + + loadOpenClawPlugins(options); + expect(listCodexAppServerExtensionFactories()).toHaveLength(1); + + setActivePluginRegistry(createEmptyPluginRegistry()); + expect(listCodexAppServerExtensionFactories()).toHaveLength(0); + + loadOpenClawPlugins(options); + const runner = createCodexAppServerToolResultExtensionRunner({}); + const result = await runner.applyToolResultExtensions({ + threadId: "thread-1", + turnId: "turn-1", + toolCallId: "call-1", + toolName: "exec", + args: { command: "git status" }, + result: { content: [{ type: "text", text: "raw" }], details: {} }, + }); + + expect(result.content).toEqual([{ type: "text", text: "compacted" }]); + }); + + it("rejects Codex app-server extension factories from non-bundled plugins even when they declare the contract", () => { + const tmp = createTempDir(); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; + + const pluginFile = writeTempPlugin({ + dir: tmp, + id: "codex-ext", + manifest: { + contracts: { + embeddedExtensionFactories: ["codex-app-server"], + }, + }, + body: `export default { id: "codex-ext", register(api) { + api.registerCodexAppServerExtensionFactory(() => undefined); +} };`, + }); + + const registry = loadOpenClawPlugins({ + workspaceDir: tmp, + config: { + plugins: { + load: { paths: [pluginFile] }, + allow: ["codex-ext"], + }, + }, + }); + + expect(registry.diagnostics).toContainEqual( + expect.objectContaining({ + level: "error", + pluginId: "codex-ext", + message: "only bundled plugins can register Codex app-server extension factories", + }), + ); + expect(listCodexAppServerExtensionFactories()).toHaveLength(0); + }); + + it("rejects bundled plugins that omit the Codex app-server extension contract", () => { + const tmp = createTempDir(); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp; + + writeTempPlugin({ + dir: tmp, + id: "codex-ext", + filename: "index.mjs", + body: `export default { id: "codex-ext", register(api) { + api.registerCodexAppServerExtensionFactory(() => undefined); +} };`, + }); + + const registry = loadOpenClawPlugins({ + config: { + plugins: { + entries: { + "codex-ext": { + enabled: true, + }, + }, + }, + }, + }); + + expect(registry.diagnostics).toContainEqual( + expect.objectContaining({ + level: "error", + pluginId: "codex-ext", + message: + 'plugin must declare contracts.embeddedExtensionFactories: ["codex-app-server"] to register Codex app-server extension factories', + }), + ); + expect(listCodexAppServerExtensionFactories()).toHaveLength(0); + }); + + it("rejects non-function Codex app-server extension factories from bundled plugins", () => { + const tmp = createTempDir(); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp; + + writeTempPlugin({ + dir: tmp, + id: "codex-ext", + filename: "index.mjs", + manifest: { + contracts: { + embeddedExtensionFactories: ["codex-app-server"], + }, + }, + body: `export default { id: "codex-ext", register(api) { + api.registerCodexAppServerExtensionFactory("not-a-function"); +} };`, + }); + + const registry = loadOpenClawPlugins({ + config: { + plugins: { + entries: { + "codex-ext": { + enabled: true, + }, + }, + }, + }, + }); + + expect(registry.diagnostics).toContainEqual( + expect.objectContaining({ + level: "error", + pluginId: "codex-ext", + message: "codex app-server extension factory must be a function", + }), + ); + expect(listCodexAppServerExtensionFactories()).toHaveLength(0); + }); + + it("initializes async Codex app-server extension factories in registration order", async () => { + const steps: string[] = []; + const runner = createCodexAppServerToolResultExtensionRunner({}, [ + async (codex) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + codex.on("tool_result", async ({ result }) => { + steps.push("first"); + return { + result: { + ...result, + content: [{ type: "text", text: `${result.content[0]?.type}:${steps.length}` }], + }, + }; + }); + }, + async (codex) => { + codex.on("tool_result", async ({ result }) => { + steps.push("second"); + return { result }; + }); + }, + ]); + + await runner.applyToolResultExtensions({ + threadId: "thread-1", + turnId: "turn-1", + toolCallId: "call-1", + toolName: "exec", + args: { command: "git status" }, + result: { content: [{ type: "text", text: "raw" }], details: {} }, + }); + + expect(steps).toEqual(["first", "second"]); + }); +}); diff --git a/src/agents/harness/codex-app-server-extensions.ts b/src/agents/harness/codex-app-server-extensions.ts new file mode 100644 index 00000000000..4aaf14a9b44 --- /dev/null +++ b/src/agents/harness/codex-app-server-extensions.ts @@ -0,0 +1,53 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { listCodexAppServerExtensionFactories } from "../../plugins/codex-app-server-extension-factory.js"; +import type { + CodexAppServerExtensionContext, + CodexAppServerExtensionFactory, + CodexAppServerExtensionRuntime, + CodexAppServerToolResultEvent, +} from "../../plugins/codex-app-server-extension-types.js"; + +const log = createSubsystemLogger("agents/harness"); + +type CodexToolResultHandler = Parameters[1]; + +export function createCodexAppServerToolResultExtensionRunner( + ctx: CodexAppServerExtensionContext, + factories: CodexAppServerExtensionFactory[] = listCodexAppServerExtensionFactories(), +) { + const handlers: CodexToolResultHandler[] = []; + const runtime: CodexAppServerExtensionRuntime = { + on(event, handler) { + if (event === "tool_result") { + handlers.push(handler); + } + }, + }; + const initPromise = (async () => { + for (const factory of factories) { + await factory(runtime); + } + })(); + + return { + async applyToolResultExtensions( + event: CodexAppServerToolResultEvent, + ): Promise> { + await initPromise; + let current = event.result; + for (const handler of handlers) { + try { + const next = await handler({ ...event, result: current }, ctx); + if (next?.result) { + current = next.result; + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + log.warn(`[codex] tool_result extension failed for ${event.toolName}: ${detail}`); + } + } + return current; + }, + }; +} diff --git a/src/agents/harness/hook-helpers.ts b/src/agents/harness/hook-helpers.ts new file mode 100644 index 00000000000..e7b084245ad --- /dev/null +++ b/src/agents/harness/hook-helpers.ts @@ -0,0 +1,74 @@ +import type { AgentMessage, AgentToolResult } from "@mariozechner/pi-agent-core"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; +import { consumeAdjustedParamsForToolCall } from "../pi-tools.before-tool-call.js"; + +const log = createSubsystemLogger("agents/harness"); + +export async function runAgentHarnessAfterToolCallHook(params: { + toolName: string; + toolCallId: string; + runId?: string; + agentId?: string; + sessionId?: string; + sessionKey?: string; + startArgs: Record; + result?: AgentToolResult; + error?: string; + startedAt?: number; +}): Promise { + const hookRunner = getGlobalHookRunner(); + if (!hookRunner?.hasHooks("after_tool_call")) { + return; + } + const adjustedArgs = consumeAdjustedParamsForToolCall(params.toolCallId, params.runId); + const eventArgs = + adjustedArgs && typeof adjustedArgs === "object" + ? (adjustedArgs as Record) + : params.startArgs; + try { + await hookRunner.runAfterToolCall( + { + toolName: params.toolName, + params: eventArgs, + ...(params.runId ? { runId: params.runId } : {}), + toolCallId: params.toolCallId, + ...(params.result ? { result: params.result } : {}), + ...(params.error ? { error: params.error } : {}), + ...(params.startedAt != null ? { durationMs: Date.now() - params.startedAt } : {}), + }, + { + toolName: params.toolName, + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(params.sessionId ? { sessionId: params.sessionId } : {}), + ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), + ...(params.runId ? { runId: params.runId } : {}), + toolCallId: params.toolCallId, + }, + ); + } catch (error) { + log.warn(`after_tool_call hook failed: tool=${params.toolName} error=${String(error)}`); + } +} + +export function runAgentHarnessBeforeMessageWriteHook(params: { + message: AgentMessage; + agentId?: string; + sessionKey?: string; +}): AgentMessage | null { + const hookRunner = getGlobalHookRunner(); + if (!hookRunner?.hasHooks("before_message_write")) { + return params.message; + } + const result = hookRunner.runBeforeMessageWrite( + { message: params.message }, + { + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), + }, + ); + if (result?.block) { + return null; + } + return result?.message ?? params.message; +} diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index bc329db52f2..ca536fe4ef6 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -89,6 +89,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ webSearchProviders: [], memoryEmbeddingProviders: [], embeddedExtensionFactories: [], + codexAppServerExtensionFactories: [], textTransforms: [], agentHarnesses: [], gatewayHandlers: {}, diff --git a/src/gateway/test-helpers.plugin-registry.ts b/src/gateway/test-helpers.plugin-registry.ts index 8acc27c8fba..ff47a1de71f 100644 --- a/src/gateway/test-helpers.plugin-registry.ts +++ b/src/gateway/test-helpers.plugin-registry.ts @@ -23,6 +23,7 @@ function createStubPluginRegistry(): PluginRegistry { webFetchProviders: [], webSearchProviders: [], embeddedExtensionFactories: [], + codexAppServerExtensionFactories: [], memoryEmbeddingProviders: [], textTransforms: [], agentHarnesses: [], diff --git a/src/plugin-sdk/agent-harness.ts b/src/plugin-sdk/agent-harness.ts index bbe4d26b775..032b811b6b1 100644 --- a/src/plugin-sdk/agent-harness.ts +++ b/src/plugin-sdk/agent-harness.ts @@ -22,6 +22,13 @@ export type { MessagingToolSend } from "../agents/pi-embedded-messaging.types.js export type { AgentApprovalEventData } from "../infra/agent-events.js"; export type { ExecApprovalDecision } from "../infra/exec-approvals.js"; export type { NormalizedUsage } from "../agents/usage.js"; +export type { + CodexAppServerExtensionContext, + CodexAppServerExtensionFactory, + CodexAppServerExtensionRuntime, + CodexAppServerToolResultEvent, + CodexAppServerToolResultHandlerResult, +} from "../plugins/codex-app-server-extension-types.js"; export { VERSION as OPENCLAW_VERSION } from "../version.js"; export { formatErrorMessage } from "../infra/errors.js"; @@ -59,3 +66,8 @@ export { runAgentHarnessAfterCompactionHook, runAgentHarnessBeforeCompactionHook, } from "../agents/harness/prompt-compaction-hook-helpers.js"; +export { createCodexAppServerToolResultExtensionRunner } from "../agents/harness/codex-app-server-extensions.js"; +export { + runAgentHarnessAfterToolCallHook, + runAgentHarnessBeforeMessageWriteHook, +} from "../agents/harness/hook-helpers.js"; diff --git a/src/plugins/api-builder.ts b/src/plugins/api-builder.ts index 4c465b31912..c03ffff7ac9 100644 --- a/src/plugins/api-builder.ts +++ b/src/plugins/api-builder.ts @@ -49,6 +49,7 @@ export type BuildPluginApiParams = { | "registerCompactionProvider" | "registerAgentHarness" | "registerEmbeddedExtensionFactory" + | "registerCodexAppServerExtensionFactory" | "registerDetachedTaskRuntime" | "registerMemoryCapability" | "registerMemoryPromptSection" @@ -102,6 +103,8 @@ const noopRegisterCompactionProvider: OpenClawPluginApi["registerCompactionProvi const noopRegisterAgentHarness: OpenClawPluginApi["registerAgentHarness"] = () => {}; const noopRegisterEmbeddedExtensionFactory: OpenClawPluginApi["registerEmbeddedExtensionFactory"] = () => {}; +const noopRegisterCodexAppServerExtensionFactory: OpenClawPluginApi["registerCodexAppServerExtensionFactory"] = + () => {}; const noopRegisterDetachedTaskRuntime: OpenClawPluginApi["registerDetachedTaskRuntime"] = () => {}; const noopRegisterMemoryCapability: OpenClawPluginApi["registerMemoryCapability"] = () => {}; const noopRegisterMemoryPromptSection: OpenClawPluginApi["registerMemoryPromptSection"] = () => {}; @@ -171,6 +174,8 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi registerAgentHarness: handlers.registerAgentHarness ?? noopRegisterAgentHarness, registerEmbeddedExtensionFactory: handlers.registerEmbeddedExtensionFactory ?? noopRegisterEmbeddedExtensionFactory, + registerCodexAppServerExtensionFactory: + handlers.registerCodexAppServerExtensionFactory ?? noopRegisterCodexAppServerExtensionFactory, registerDetachedTaskRuntime: handlers.registerDetachedTaskRuntime ?? noopRegisterDetachedTaskRuntime, registerMemoryCapability: handlers.registerMemoryCapability ?? noopRegisterMemoryCapability, diff --git a/src/plugins/captured-registration.ts b/src/plugins/captured-registration.ts index 4bfe9e43813..44e4ce7f721 100644 --- a/src/plugins/captured-registration.ts +++ b/src/plugins/captured-registration.ts @@ -1,6 +1,7 @@ import type { ExtensionFactory } from "@mariozechner/pi-coding-agent"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { buildPluginApi } from "./api-builder.js"; +import type { CodexAppServerExtensionFactory } from "./codex-app-server-extension-types.js"; import type { MemoryEmbeddingProviderAdapter } from "./memory-embedding-providers.js"; import type { PluginRuntime } from "./runtime/types.js"; import type { @@ -37,6 +38,7 @@ export type CapturedPluginRegistration = { cliBackends: CliBackendPlugin[]; textTransforms: PluginTextTransformRegistration[]; embeddedExtensionFactories: ExtensionFactory[]; + codexAppServerExtensionFactories: CodexAppServerExtensionFactory[]; speechProviders: SpeechProviderPlugin[]; realtimeTranscriptionProviders: RealtimeTranscriptionProviderPlugin[]; realtimeVoiceProviders: RealtimeVoiceProviderPlugin[]; @@ -60,6 +62,7 @@ export function createCapturedPluginRegistration(params?: { const cliBackends: CliBackendPlugin[] = []; const textTransforms: PluginTextTransformRegistration[] = []; const embeddedExtensionFactories: ExtensionFactory[] = []; + const codexAppServerExtensionFactories: CodexAppServerExtensionFactory[] = []; const speechProviders: SpeechProviderPlugin[] = []; const realtimeTranscriptionProviders: RealtimeTranscriptionProviderPlugin[] = []; const realtimeVoiceProviders: RealtimeVoiceProviderPlugin[] = []; @@ -85,6 +88,7 @@ export function createCapturedPluginRegistration(params?: { cliBackends, textTransforms, embeddedExtensionFactories, + codexAppServerExtensionFactories, speechProviders, realtimeTranscriptionProviders, realtimeVoiceProviders, @@ -138,6 +142,9 @@ export function createCapturedPluginRegistration(params?: { registerEmbeddedExtensionFactory(factory: ExtensionFactory) { embeddedExtensionFactories.push(factory); }, + registerCodexAppServerExtensionFactory(factory: CodexAppServerExtensionFactory) { + codexAppServerExtensionFactories.push(factory); + }, registerCliBackend(backend: CliBackendPlugin) { cliBackends.push(backend); }, diff --git a/src/plugins/codex-app-server-extension-factory.ts b/src/plugins/codex-app-server-extension-factory.ts new file mode 100644 index 00000000000..55e32dd233a --- /dev/null +++ b/src/plugins/codex-app-server-extension-factory.ts @@ -0,0 +1,9 @@ +import { getActivePluginRegistry } from "./runtime.js"; + +export const CODEX_APP_SERVER_EXTENSION_RUNTIME_ID = "codex-app-server"; + +export function listCodexAppServerExtensionFactories() { + return ( + getActivePluginRegistry()?.codexAppServerExtensionFactories?.map((entry) => entry.factory) ?? [] + ); +} diff --git a/src/plugins/codex-app-server-extension-types.ts b/src/plugins/codex-app-server-extension-types.ts new file mode 100644 index 00000000000..1e9b813554c --- /dev/null +++ b/src/plugins/codex-app-server-extension-types.ts @@ -0,0 +1,38 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; + +export type CodexAppServerToolResultEvent = { + threadId: string; + turnId: string; + toolCallId: string; + toolName: string; + args: Record; + result: AgentToolResult; +}; + +export type CodexAppServerExtensionContext = { + agentId?: string; + sessionId?: string; + sessionKey?: string; + runId?: string; +}; + +export type CodexAppServerToolResultHandlerResult = { + result: AgentToolResult; +}; + +export type CodexAppServerExtensionRuntime = { + on: ( + event: "tool_result", + handler: ( + event: CodexAppServerToolResultEvent, + ctx: CodexAppServerExtensionContext, + ) => + | Promise + | CodexAppServerToolResultHandlerResult + | void, + ) => void; +}; + +export type CodexAppServerExtensionFactory = ( + runtime: CodexAppServerExtensionRuntime, +) => Promise | void; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index d5c24a28ebb..f86366aa554 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -278,6 +278,7 @@ type PluginRegistrySnapshot = { webFetchProviders: PluginRegistry["webFetchProviders"]; webSearchProviders: PluginRegistry["webSearchProviders"]; embeddedExtensionFactories: PluginRegistry["embeddedExtensionFactories"]; + codexAppServerExtensionFactories: PluginRegistry["codexAppServerExtensionFactories"]; memoryEmbeddingProviders: PluginRegistry["memoryEmbeddingProviders"]; agentHarnesses: PluginRegistry["agentHarnesses"]; httpRoutes: PluginRegistry["httpRoutes"]; @@ -315,6 +316,7 @@ function snapshotPluginRegistry(registry: PluginRegistry): PluginRegistrySnapsho webFetchProviders: [...registry.webFetchProviders], webSearchProviders: [...registry.webSearchProviders], embeddedExtensionFactories: [...registry.embeddedExtensionFactories], + codexAppServerExtensionFactories: [...registry.codexAppServerExtensionFactories], memoryEmbeddingProviders: [...registry.memoryEmbeddingProviders], agentHarnesses: [...registry.agentHarnesses], httpRoutes: [...registry.httpRoutes], @@ -351,6 +353,7 @@ function restorePluginRegistry(registry: PluginRegistry, snapshot: PluginRegistr registry.webFetchProviders = snapshot.arrays.webFetchProviders; registry.webSearchProviders = snapshot.arrays.webSearchProviders; registry.embeddedExtensionFactories = snapshot.arrays.embeddedExtensionFactories; + registry.codexAppServerExtensionFactories = snapshot.arrays.codexAppServerExtensionFactories; registry.memoryEmbeddingProviders = snapshot.arrays.memoryEmbeddingProviders; registry.agentHarnesses = snapshot.arrays.agentHarnesses; registry.httpRoutes = snapshot.arrays.httpRoutes; diff --git a/src/plugins/registry-empty.ts b/src/plugins/registry-empty.ts index 3807690c279..c718743c81c 100644 --- a/src/plugins/registry-empty.ts +++ b/src/plugins/registry-empty.ts @@ -21,6 +21,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { webFetchProviders: [], webSearchProviders: [], embeddedExtensionFactories: [], + codexAppServerExtensionFactories: [], memoryEmbeddingProviders: [], agentHarnesses: [], gatewayHandlers: {}, diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index d7e51905132..0e68d1d1e58 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -4,6 +4,7 @@ import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import type { OperatorScope } from "../gateway/operator-scopes.js"; import type { GatewayRequestHandlers } from "../gateway/server-methods/types.js"; import type { HookEntry } from "../hooks/types.js"; +import type { CodexAppServerExtensionFactory } from "./codex-app-server-extension-types.js"; import type { PluginActivationSource } from "./config-state.js"; import type { PluginBundleFormat, @@ -153,6 +154,14 @@ export type PluginEmbeddedExtensionFactoryRegistration = { source: string; rootDir?: string; }; +export type PluginCodexAppServerExtensionFactoryRegistration = { + pluginId: string; + pluginName?: string; + rawFactory: CodexAppServerExtensionFactory; + factory: CodexAppServerExtensionFactory; + source: string; + rootDir?: string; +}; export type PluginAgentHarnessRegistration = { pluginId: string; pluginName?: string; @@ -291,6 +300,7 @@ export type PluginRegistry = { webFetchProviders: PluginWebFetchProviderRegistration[]; webSearchProviders: PluginWebSearchProviderRegistration[]; embeddedExtensionFactories: PluginEmbeddedExtensionFactoryRegistration[]; + codexAppServerExtensionFactories: PluginCodexAppServerExtensionFactoryRegistration[]; memoryEmbeddingProviders: PluginMemoryEmbeddingProviderRegistration[]; agentHarnesses: PluginAgentHarnessRegistration[]; gatewayHandlers: GatewayRequestHandlers; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index c3c8705747f..041d741a8c9 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -30,6 +30,8 @@ import { import { resolveUserPath } from "../utils.js"; import { buildPluginApi } from "./api-builder.js"; import { normalizeRegisteredChannelPlugin } from "./channel-validation.js"; +import { CODEX_APP_SERVER_EXTENSION_RUNTIME_ID } from "./codex-app-server-extension-factory.js"; +import type { CodexAppServerExtensionFactory } from "./codex-app-server-extension-types.js"; import { registerPluginCommand, validatePluginCommandDefinition } from "./command-registration.js"; import { clearPluginCommandsForPlugin } from "./command-registry-state.js"; import { @@ -261,6 +263,69 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); }; + const registerCodexAppServerExtensionFactory = ( + record: PluginRecord, + factory: Parameters[0], + ) => { + if (record.origin !== "bundled") { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "only bundled plugins can register Codex app-server extension factories", + }); + return; + } + if ( + !(record.contracts?.embeddedExtensionFactories ?? []).includes( + CODEX_APP_SERVER_EXTENSION_RUNTIME_ID, + ) + ) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: + 'plugin must declare contracts.embeddedExtensionFactories: ["codex-app-server"] to register Codex app-server extension factories', + }); + return; + } + if (typeof (factory as unknown) !== "function") { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "codex app-server extension factory must be a function", + }); + return; + } + if ( + registry.codexAppServerExtensionFactories.some( + (entry) => entry.pluginId === record.id && entry.rawFactory === factory, + ) + ) { + return; + } + const safeFactory: CodexAppServerExtensionFactory = async (codex) => { + try { + await factory(codex); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + registryParams.logger.warn( + `[plugins] codex app-server extension factory failed for ${record.id}: ${detail}`, + ); + } + }; + registry.codexAppServerExtensionFactories.push({ + pluginId: record.id, + pluginName: record.name, + rawFactory: factory, + factory: safeFactory, + source: record.source, + rootDir: record.rootDir, + }); + }; + const registerTool = ( record: PluginRecord, tool: AnyAgentTool | OpenClawPluginToolFactory, @@ -1339,6 +1404,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerEmbeddedExtensionFactory: (factory) => { registerPiEmbeddedExtensionFactory(record, factory); }, + registerCodexAppServerExtensionFactory: (factory) => { + registerCodexAppServerExtensionFactory(record, factory); + }, registerMemoryCapability: (capability) => { if (!hasKind(record.kind, "memory")) { pushDiagnostic({ diff --git a/src/plugins/status.test-helpers.ts b/src/plugins/status.test-helpers.ts index 18ba487ace4..342d84c4a92 100644 --- a/src/plugins/status.test-helpers.ts +++ b/src/plugins/status.test-helpers.ts @@ -129,6 +129,7 @@ export function createPluginLoadResult( webFetchProviders: [], webSearchProviders: [], embeddedExtensionFactories: [], + codexAppServerExtensionFactories: [], memoryEmbeddingProviders: [], textTransforms: [], agentHarnesses: [], diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 1e160485f68..b53802c42e3 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -75,6 +75,7 @@ import type { PluginTextReplacement, PluginTextTransforms, } from "./cli-backend.types.js"; +import type { CodexAppServerExtensionFactory } from "./codex-app-server-extension-types.js"; import type { PluginConversationBinding, PluginConversationBindingRequestParams, @@ -2009,6 +2010,8 @@ export type OpenClawPluginApi = { registerAgentHarness: (harness: AgentHarness) => void; /** Register a Pi embedded extension factory for OpenClaw embedded runs. Only bundled plugins may use this seam, and `contracts.embeddedExtensionFactories` must include `"pi"`. */ registerEmbeddedExtensionFactory: (factory: ExtensionFactory) => void; + /** Register a Codex app-server extension factory for Codex harness tool-result middleware. Only bundled plugins may use this seam, and `contracts.embeddedExtensionFactories` must include `"codex-app-server"`. */ + registerCodexAppServerExtensionFactory: (factory: CodexAppServerExtensionFactory) => void; /** Register the active detached task runtime for this plugin (exclusive slot). */ registerDetachedTaskRuntime: ( runtime: import("./runtime/runtime-tasks.types.js").DetachedTaskLifecycleRuntime, diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 68c81a0ba78..433d5f73691 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -36,6 +36,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl webFetchProviders: [], webSearchProviders: [], embeddedExtensionFactories: [], + codexAppServerExtensionFactories: [], memoryEmbeddingProviders: [], textTransforms: [], agentHarnesses: [], diff --git a/test/helpers/plugins/plugin-api.ts b/test/helpers/plugins/plugin-api.ts index 76efa199af4..8e9f2ed95e5 100644 --- a/test/helpers/plugins/plugin-api.ts +++ b/test/helpers/plugins/plugin-api.ts @@ -42,6 +42,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi registerCompactionProvider() {}, registerAgentHarness() {}, registerEmbeddedExtensionFactory() {}, + registerCodexAppServerExtensionFactory() {}, registerDetachedTaskRuntime() {}, registerMemoryCapability() {}, registerMemoryPromptSection() {},