mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 21:00:44 +00:00
feat(codex): add app-server protocol bridge
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
d3b5638e205a94e40d07aa1830c8d57135df18ff9388fb7d72ee84c791ac293f config-baseline.json
|
||||
f0421335bfd388b7ebe1b8d478036ece4bf5eb8fd7b1de81b8cdc4ec6522ce20 config-baseline.json
|
||||
bf00f7910d8f0d8e12592e8a1c6bd0397f8e62fef2c11eb0cbd3b3a3e2a78ffe config-baseline.core.json
|
||||
22d7cd6d8279146b2d79c9531a55b80b52a2c99c81338c508104729154fdd02d config-baseline.channel.json
|
||||
a91304e3566ecc8906f199b88a2e38eaee86130aad799bf4d62921e2f0ddc1b5 config-baseline.plugin.json
|
||||
c6f99aed28b98e5914585956ec303b615a8ef975abf5cec186a61781c20b9106 config-baseline.plugin.json
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Use ACP runtime sessions for Codex, Claude Code, Cursor, Gemini CLI, OpenClaw ACP, and other harness agents"
|
||||
summary: "Use ACP runtime sessions for Claude Code, Cursor, Gemini CLI, explicit Codex ACP fallback, OpenClaw ACP, and other harness agents"
|
||||
read_when:
|
||||
- Running coding harnesses through ACP
|
||||
- Setting up conversation-bound ACP sessions on messaging channels
|
||||
@@ -10,9 +10,11 @@ read_when:
|
||||
title: "ACP agents"
|
||||
---
|
||||
|
||||
[Agent Client Protocol (ACP)](https://agentclientprotocol.com/) sessions let OpenClaw run external coding harnesses (for example Pi, Claude Code, Codex, Cursor, Copilot, OpenClaw ACP, OpenCode, Gemini CLI, and other supported ACPX harnesses) through an ACP backend plugin.
|
||||
[Agent Client Protocol (ACP)](https://agentclientprotocol.com/) sessions let OpenClaw run external coding harnesses (for example Pi, Claude Code, Cursor, Copilot, OpenClaw ACP, OpenCode, Gemini CLI, and other supported ACPX harnesses) through an ACP backend plugin.
|
||||
|
||||
If you ask OpenClaw in plain language to "run this in Codex" or "start Claude Code in a thread", OpenClaw should route that request to the ACP runtime (not the native sub-agent runtime). Each ACP session spawn is tracked as a [background task](/automation/tasks).
|
||||
If you ask OpenClaw in plain language to bind or control Codex in the current conversation, OpenClaw should use the native Codex app-server plugin (`/codex bind`, `/codex threads`, `/codex resume`). If you ask for `/acp`, ACP, acpx, or a Codex background child session, OpenClaw can still route Codex through ACP. Each ACP session spawn is tracked as a [background task](/automation/tasks).
|
||||
|
||||
If you ask OpenClaw in plain language to "start Claude Code in a thread" or use another external harness, OpenClaw should route that request to the ACP runtime (not the native sub-agent runtime).
|
||||
|
||||
If you want Codex or Claude Code to connect as an external MCP client directly
|
||||
to existing OpenClaw channel conversations, use [`openclaw mcp serve`](/cli/mcp)
|
||||
@@ -22,11 +24,12 @@ instead of ACP.
|
||||
|
||||
There are three nearby surfaces that are easy to confuse:
|
||||
|
||||
| You want to... | Use this | Notes |
|
||||
| ---------------------------------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| Run Codex, Claude Code, Gemini CLI, or another external harness _through_ OpenClaw | This page: ACP agents | Chat-bound sessions, `/acp spawn`, `sessions_spawn({ runtime: "acp" })`, background tasks, runtime controls |
|
||||
| Expose an OpenClaw Gateway session _as_ an ACP server for an editor or client | [`openclaw acp`](/cli/acp) | Bridge mode. IDE/client talks ACP to OpenClaw over stdio/WebSocket |
|
||||
| Reuse a local AI CLI as a text-only fallback model | [CLI Backends](/gateway/cli-backends) | Not ACP. No OpenClaw tools, no ACP controls, no harness runtime |
|
||||
| You want to... | Use this | Notes |
|
||||
| ----------------------------------------------------------------------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Bind or control Codex in the current conversation | `/codex bind`, `/codex threads` | Native Codex app-server path; includes bound chat replies, image forwarding, model/fast/permissions, stop, and steer controls. ACP is an explicit fallback |
|
||||
| Run Claude Code, Gemini CLI, explicit Codex ACP, or another external harness _through_ OpenClaw | This page: ACP agents | Chat-bound sessions, `/acp spawn`, `sessions_spawn({ runtime: "acp" })`, background tasks, runtime controls |
|
||||
| Expose an OpenClaw Gateway session _as_ an ACP server for an editor or client | [`openclaw acp`](/cli/acp) | Bridge mode. IDE/client talks ACP to OpenClaw over stdio/WebSocket |
|
||||
| Reuse a local AI CLI as a text-only fallback model | [CLI Backends](/gateway/cli-backends) | Not ACP. No OpenClaw tools, no ACP controls, no harness runtime |
|
||||
|
||||
## Does this work out of the box?
|
||||
|
||||
@@ -42,25 +45,32 @@ First-run gotchas:
|
||||
|
||||
Quick `/acp` flow from chat:
|
||||
|
||||
1. **Spawn** — `/acp spawn codex --bind here` or `/acp spawn codex --mode persistent --thread auto`
|
||||
1. **Spawn** — `/acp spawn claude --bind here`, `/acp spawn gemini --mode persistent --thread auto`, or explicit `/acp spawn codex --bind here`
|
||||
2. **Work** in the bound conversation or thread (or target the session key explicitly).
|
||||
3. **Check state** — `/acp status`
|
||||
4. **Tune** — `/acp model <provider/model>`, `/acp permissions <profile>`, `/acp timeout <seconds>`
|
||||
5. **Steer** without replacing context — `/acp steer tighten logging and continue`
|
||||
6. **Stop** — `/acp cancel` (current turn) or `/acp close` (session + bindings)
|
||||
|
||||
Natural-language triggers that should route to the ACP runtime:
|
||||
Natural-language triggers that should route to the native Codex plugin:
|
||||
|
||||
- "Bind this Discord channel to Codex."
|
||||
- "Start a persistent Codex session in a thread here."
|
||||
- "Attach this chat to Codex thread `<id>`."
|
||||
- "Show Codex threads, then bind this one."
|
||||
|
||||
Native Codex conversation binding is the default chat-control path, but it is intentionally conservative for interactive Codex approval/tool flows: OpenClaw dynamic tools and approval prompts are not exposed through this bound-chat path yet, so those requests are declined with a clear explanation. Use the Codex harness path or explicit ACP fallback when the workflow depends on OpenClaw dynamic tools or long-running interactive approvals.
|
||||
|
||||
Natural-language triggers that should route to the ACP runtime:
|
||||
|
||||
- "Run this as a one-shot Claude Code ACP session and summarize the result."
|
||||
- "Use Gemini CLI for this task in a thread, then keep follow-ups in that same thread."
|
||||
- "Run Codex through ACP in a background thread."
|
||||
|
||||
OpenClaw picks `runtime: "acp"`, resolves the harness `agentId`, binds to the current conversation or thread when supported, and routes follow-ups to that session until close/expiry.
|
||||
OpenClaw picks `runtime: "acp"`, resolves the harness `agentId`, binds to the current conversation or thread when supported, and routes follow-ups to that session until close/expiry. Codex only follows this path when ACP is explicit or the requested background runtime still needs ACP.
|
||||
|
||||
## ACP versus sub-agents
|
||||
|
||||
Use ACP when you want an external harness runtime. Use sub-agents when you want OpenClaw-native delegated runs.
|
||||
Use ACP when you want an external harness runtime. Use native Codex app-server for Codex conversation binding/control. Use sub-agents when you want OpenClaw-native delegated runs.
|
||||
|
||||
| Area | ACP session | Sub-agent run |
|
||||
| ------------- | ------------------------------------- | ---------------------------------- |
|
||||
@@ -105,7 +115,10 @@ Mental model:
|
||||
|
||||
Examples:
|
||||
|
||||
- `/acp spawn codex --bind here` — keep this chat, spawn or attach Codex, route future messages here.
|
||||
- `/codex bind` — keep this chat, spawn or attach native Codex app-server, route future messages here.
|
||||
- `/codex model gpt-5.4`, `/codex fast on`, `/codex permissions yolo` — tune the bound native Codex thread from chat.
|
||||
- `/codex stop` or `/codex steer focus on the failing tests first` — control the active native Codex turn.
|
||||
- `/acp spawn codex --bind here` — explicit ACP fallback for Codex.
|
||||
- `/acp spawn codex --thread auto` — OpenClaw may create a child thread/topic and bind there.
|
||||
- `/acp spawn codex --bind here --cwd /workspace/repo` — same chat binding, Codex runs in `/workspace/repo`.
|
||||
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
---
|
||||
name: acp-router
|
||||
description: Route plain-language requests for Pi, Claude Code, Codex, Cursor, Copilot, OpenClaw ACP, OpenCode, Gemini CLI, Qwen, Kiro, Kimi, iFlow, Factory Droid, Kilocode, or ACP harness work into either OpenClaw ACP runtime sessions or direct acpx-driven sessions ("telephone game" flow). For coding-agent thread requests, read this skill first, then use only `sessions_spawn` for thread creation.
|
||||
description: Route plain-language requests for Pi, Claude Code, Cursor, Copilot, OpenClaw ACP, OpenCode, Gemini CLI, Qwen, Kiro, Kimi, iFlow, Factory Droid, Kilocode, or explicit ACP harness work into either OpenClaw ACP runtime sessions or direct acpx-driven sessions ("telephone game" flow). For coding-agent thread requests, read this skill first, then use only `sessions_spawn` for thread creation. Codex chat binding defaults to the native Codex app-server plugin unless ACP is explicit or background spawn needs ACP.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# ACP Harness Router
|
||||
|
||||
When user intent is "run this in Pi/Claude Code/Codex/Cursor/Copilot/OpenClaw/OpenCode/Gemini/Qwen/Kiro/Kimi/iFlow/Droid/Kilocode (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows.
|
||||
When user intent is "run this in Pi/Claude Code/Cursor/Copilot/OpenClaw/OpenCode/Gemini/Qwen/Kiro/Kimi/iFlow/Droid/Kilocode (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows.
|
||||
|
||||
Codex is special: plain chat/conversation binding and control should use the native Codex app-server plugin (`/codex bind`, `/codex threads`, `/codex resume`) instead of the default ACP path. Use ACP for Codex only when the user explicitly names ACP/`/acp`/acpx, or when spawning background child sessions through `sessions_spawn` where a native Codex runtime spawn is not available yet.
|
||||
|
||||
## Intent detection
|
||||
|
||||
Trigger this skill when the user asks OpenClaw to:
|
||||
|
||||
- run something in Pi / Claude Code / Codex / Cursor / Copilot / OpenClaw / OpenCode / Gemini / Qwen / Kiro / Kimi / iFlow / Droid / Kilocode
|
||||
- run something in Pi / Claude Code / Cursor / Copilot / OpenClaw / OpenCode / Gemini / Qwen / Kiro / Kimi / iFlow / Droid / Kilocode
|
||||
- run Codex explicitly through ACP, `/acp`, or acpx
|
||||
- continue existing harness work
|
||||
- relay instructions to an external coding harness
|
||||
- keep an external harness conversation in a thread-like conversation
|
||||
@@ -48,7 +51,7 @@ Use these defaults when user names a harness directly:
|
||||
- "pi" -> `agentId: "pi"`
|
||||
- "openclaw" -> `agentId: "openclaw"`
|
||||
- "claude" or "claude code" -> `agentId: "claude"`
|
||||
- "codex" -> `agentId: "codex"`
|
||||
- "codex" -> `agentId: "codex"` only for explicit ACP/acpx requests or background ACP runtime spawn
|
||||
- "copilot" or "github copilot" -> `agentId: "copilot"`
|
||||
- "cursor" or "cursor cli" -> `agentId: "cursor"`
|
||||
- "droid" or "factory droid" -> `agentId: "droid"`
|
||||
@@ -80,7 +83,7 @@ Required behavior:
|
||||
|
||||
Example:
|
||||
|
||||
User: "spawn a test codex session in thread and tell it to say hi"
|
||||
User: "spawn a test codex ACP session in thread and tell it to say hi"
|
||||
|
||||
Call:
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ describe("codex plugin", () => {
|
||||
const registerCommand = vi.fn();
|
||||
const registerMediaUnderstandingProvider = vi.fn();
|
||||
const registerProvider = vi.fn();
|
||||
const on = vi.fn();
|
||||
const onConversationBindingResolved = vi.fn();
|
||||
|
||||
plugin.register(
|
||||
createTestPluginApi({
|
||||
@@ -31,6 +33,8 @@ describe("codex plugin", () => {
|
||||
registerCommand,
|
||||
registerMediaUnderstandingProvider,
|
||||
registerProvider,
|
||||
on,
|
||||
onConversationBindingResolved,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -51,6 +55,8 @@ describe("codex plugin", () => {
|
||||
name: "codex",
|
||||
description: "Inspect and control the Codex app-server harness",
|
||||
});
|
||||
expect(on).toHaveBeenCalledWith("inbound_claim", expect.any(Function));
|
||||
expect(onConversationBindingResolved).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
|
||||
it("only claims the codex provider by default", () => {
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createCodexAppServerAgentHarness } from "./harness.js";
|
||||
import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
import { buildCodexProvider } from "./provider.js";
|
||||
import { createCodexCommand } from "./src/commands.js";
|
||||
import {
|
||||
handleCodexConversationBindingResolved,
|
||||
handleCodexConversationInboundClaim,
|
||||
} from "./src/conversation-binding.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
description: "Codex app-server harness and Codex-managed GPT model catalog.",
|
||||
register(api) {
|
||||
const resolveCurrentPluginConfig = () =>
|
||||
resolveLivePluginConfigObject(
|
||||
api.runtime.config?.loadConfig,
|
||||
"codex",
|
||||
api.pluginConfig as Record<string, unknown>,
|
||||
) ?? api.pluginConfig;
|
||||
api.registerAgentHarness(createCodexAppServerAgentHarness({ pluginConfig: api.pluginConfig }));
|
||||
api.registerProvider(buildCodexProvider({ pluginConfig: api.pluginConfig }));
|
||||
api.registerMediaUnderstandingProvider(
|
||||
buildCodexMediaUnderstandingProvider({ pluginConfig: api.pluginConfig }),
|
||||
);
|
||||
api.registerCommand(createCodexCommand({ pluginConfig: api.pluginConfig }));
|
||||
api.on("inbound_claim", (event, ctx) =>
|
||||
handleCodexConversationInboundClaim(event, ctx, {
|
||||
pluginConfig: resolveCurrentPluginConfig(),
|
||||
}),
|
||||
);
|
||||
api.onConversationBindingResolved(handleCodexConversationBindingResolved);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -91,9 +91,12 @@
|
||||
},
|
||||
"approvalsReviewer": {
|
||||
"type": "string",
|
||||
"enum": ["user", "guardian_subagent"]
|
||||
"enum": ["user", "auto_review", "guardian_subagent"]
|
||||
},
|
||||
"serviceTier": { "type": ["string", "null"], "enum": ["fast", "flex", null] }
|
||||
"serviceTier": { "type": ["string", "null"], "enum": ["fast", "flex", null] },
|
||||
"defaultWorkspaceDir": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,13 +173,18 @@
|
||||
},
|
||||
"appServer.approvalsReviewer": {
|
||||
"label": "Approvals Reviewer",
|
||||
"help": "Use user approvals or the Codex guardian subagent for native app-server approvals.",
|
||||
"help": "Use user approvals or Codex auto_review for native app-server approvals. guardian_subagent remains accepted for compatibility.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.serviceTier": {
|
||||
"label": "Service Tier",
|
||||
"help": "Optional Codex app-server service tier. Use fast, flex, or null.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.defaultWorkspaceDir": {
|
||||
"label": "Default Workspace",
|
||||
"help": "Workspace used by /codex bind when --cwd is omitted.",
|
||||
"advanced": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import { createInterface, type Interface as ReadlineInterface } from "node:readl
|
||||
import { embeddedAgentLog, OPENCLAW_VERSION } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resolveCodexAppServerRuntimeOptions, type CodexAppServerStartOptions } from "./config.js";
|
||||
import {
|
||||
type CodexAppServerRequestMethod,
|
||||
type CodexAppServerRequestParams,
|
||||
type CodexAppServerRequestResult,
|
||||
type CodexInitializeParams,
|
||||
type CodexInitializeResponse,
|
||||
isRpcResponse,
|
||||
type CodexServerNotification,
|
||||
@@ -107,7 +111,7 @@ export class CodexAppServerClient {
|
||||
}
|
||||
// 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", {
|
||||
const response = await this.request("initialize", {
|
||||
clientInfo: {
|
||||
name: "openclaw",
|
||||
title: "OpenClaw",
|
||||
@@ -116,17 +120,28 @@ export class CodexAppServerClient {
|
||||
capabilities: {
|
||||
experimentalApi: true,
|
||||
},
|
||||
});
|
||||
} satisfies CodexInitializeParams);
|
||||
assertSupportedCodexAppServerVersion(response);
|
||||
this.notify("initialized");
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
request<M extends CodexAppServerRequestMethod>(
|
||||
method: M,
|
||||
params: CodexAppServerRequestParams<M>,
|
||||
options?: { timeoutMs?: number; signal?: AbortSignal },
|
||||
): Promise<CodexAppServerRequestResult<M>>;
|
||||
request<T = JsonValue | undefined>(
|
||||
method: string,
|
||||
params?: JsonValue,
|
||||
options: { timeoutMs?: number; signal?: AbortSignal } = {},
|
||||
params?: unknown,
|
||||
options?: { timeoutMs?: number; signal?: AbortSignal },
|
||||
): Promise<T>;
|
||||
request<T = JsonValue | undefined>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: { timeoutMs?: number; signal?: AbortSignal },
|
||||
): Promise<T> {
|
||||
options ??= {};
|
||||
if (this.closed) {
|
||||
return Promise.reject(new Error("codex app-server client is closed"));
|
||||
}
|
||||
@@ -134,7 +149,7 @@ export class CodexAppServerClient {
|
||||
return Promise.reject(new Error(`${method} aborted`));
|
||||
}
|
||||
const id = this.nextId++;
|
||||
const message: RpcRequest = { id, method, params };
|
||||
const message: RpcRequest = { id, method, params: params as JsonValue | undefined };
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let cleanupAbort: (() => void) | undefined;
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("Codex app-server config", () => {
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "read-only",
|
||||
approvalsReviewer: "guardian_subagent",
|
||||
approvalsReviewer: "auto_review",
|
||||
}),
|
||||
);
|
||||
expect(runtime).not.toHaveProperty("serviceTier");
|
||||
@@ -114,7 +114,7 @@ describe("Codex app-server config", () => {
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "guardian_subagent",
|
||||
approvalsReviewer: "auto_review",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -129,11 +129,26 @@ describe("Codex app-server config", () => {
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "guardian_subagent",
|
||||
approvalsReviewer: "auto_review",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts the latest auto_review reviewer and legacy guardian_subagent alias", () => {
|
||||
expect(
|
||||
resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: { appServer: { approvalsReviewer: "auto_review" } },
|
||||
env: {},
|
||||
}).approvalsReviewer,
|
||||
).toBe("auto_review");
|
||||
expect(
|
||||
resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: { appServer: { approvalsReviewer: "guardian_subagent" } },
|
||||
env: {},
|
||||
}).approvalsReviewer,
|
||||
).toBe("guardian_subagent");
|
||||
});
|
||||
|
||||
it("ignores removed OPENCLAW_CODEX_APP_SERVER_GUARDIAN fallback", () => {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: {},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type { CodexServiceTier } from "./protocol.js";
|
||||
import type { CodexSandboxPolicy, CodexServiceTier } from "./protocol.js";
|
||||
|
||||
export type CodexAppServerTransportMode = "stdio" | "websocket";
|
||||
export type CodexAppServerPolicyMode = "yolo" | "guardian";
|
||||
export type CodexAppServerApprovalPolicy = "never" | "on-request" | "on-failure" | "untrusted";
|
||||
export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "danger-full-access";
|
||||
export type CodexAppServerApprovalsReviewer = "user" | "guardian_subagent";
|
||||
export type CodexAppServerApprovalsReviewer = "user" | "auto_review" | "guardian_subagent";
|
||||
|
||||
export type CodexAppServerStartOptions = {
|
||||
transport: CodexAppServerTransportMode;
|
||||
@@ -46,6 +46,7 @@ export type CodexPluginConfig = {
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
approvalsReviewer?: CodexAppServerApprovalsReviewer;
|
||||
serviceTier?: CodexServiceTier | null;
|
||||
defaultWorkspaceDir?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,6 +63,7 @@ export const CODEX_APP_SERVER_CONFIG_KEYS = [
|
||||
"sandbox",
|
||||
"approvalsReviewer",
|
||||
"serviceTier",
|
||||
"defaultWorkspaceDir",
|
||||
] as const;
|
||||
|
||||
const codexAppServerTransportSchema = z.enum(["stdio", "websocket"]);
|
||||
@@ -73,7 +75,7 @@ const codexAppServerApprovalPolicySchema = z.enum([
|
||||
"untrusted",
|
||||
]);
|
||||
const codexAppServerSandboxSchema = z.enum(["read-only", "workspace-write", "danger-full-access"]);
|
||||
const codexAppServerApprovalsReviewerSchema = z.enum(["user", "guardian_subagent"]);
|
||||
const codexAppServerApprovalsReviewerSchema = z.enum(["user", "auto_review", "guardian_subagent"]);
|
||||
const codexAppServerServiceTierSchema = z.preprocess(
|
||||
(value) => (value === null ? null : resolveServiceTier(value)),
|
||||
z.enum(["fast", "flex"]).nullable().optional(),
|
||||
@@ -102,6 +104,7 @@ const codexPluginConfigSchema = z
|
||||
sandbox: codexAppServerSandboxSchema.optional(),
|
||||
approvalsReviewer: codexAppServerApprovalsReviewerSchema.optional(),
|
||||
serviceTier: codexAppServerServiceTierSchema,
|
||||
defaultWorkspaceDir: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
@@ -159,7 +162,7 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
(policyMode === "guardian" ? "workspace-write" : "danger-full-access"),
|
||||
approvalsReviewer:
|
||||
resolveApprovalsReviewer(config.approvalsReviewer) ??
|
||||
(policyMode === "guardian" ? "guardian_subagent" : "user"),
|
||||
(policyMode === "guardian" ? "auto_review" : "user"),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
};
|
||||
}
|
||||
@@ -179,6 +182,26 @@ export function codexAppServerStartOptionsKey(options: CodexAppServerStartOption
|
||||
});
|
||||
}
|
||||
|
||||
export function codexSandboxPolicyForTurn(
|
||||
mode: CodexAppServerSandboxMode,
|
||||
cwd: string,
|
||||
): CodexSandboxPolicy {
|
||||
if (mode === "danger-full-access") {
|
||||
return { type: "dangerFullAccess" };
|
||||
}
|
||||
if (mode === "read-only") {
|
||||
return { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false };
|
||||
}
|
||||
return {
|
||||
type: "workspaceWrite",
|
||||
writableRoots: [cwd],
|
||||
readOnlyAccess: { type: "fullAccess" },
|
||||
networkAccess: false,
|
||||
excludeTmpdirEnvVar: false,
|
||||
excludeSlashTmp: false,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTransport(value: unknown): CodexAppServerTransportMode {
|
||||
return value === "websocket" ? "websocket" : "stdio";
|
||||
}
|
||||
@@ -203,7 +226,9 @@ function resolveSandbox(value: unknown): CodexAppServerSandboxMode | undefined {
|
||||
}
|
||||
|
||||
function resolveApprovalsReviewer(value: unknown): CodexAppServerApprovalsReviewer | undefined {
|
||||
return value === "guardian_subagent" || value === "user" ? value : undefined;
|
||||
return value === "auto_review" || value === "guardian_subagent" || value === "user"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveServiceTier(value: unknown): CodexServiceTier | undefined {
|
||||
|
||||
@@ -48,7 +48,7 @@ export async function listCodexAppServerModels(
|
||||
authProfileId: options.authProfileId,
|
||||
});
|
||||
try {
|
||||
const response = await client.request<unknown>(
|
||||
const response = await client.request(
|
||||
"model/list",
|
||||
{
|
||||
limit: options.limit ?? null,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"Account": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"type": {
|
||||
"enum": ["apiKey"],
|
||||
"title": "ApiKeyAccountType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"title": "ApiKeyAccount",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"planType": {
|
||||
"$ref": "#/definitions/PlanType"
|
||||
},
|
||||
"type": {
|
||||
"enum": ["chatgpt"],
|
||||
"title": "ChatgptAccountType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["email", "planType", "type"],
|
||||
"title": "ChatgptAccount",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"type": {
|
||||
"enum": ["amazonBedrock"],
|
||||
"title": "AmazonBedrockAccountType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"title": "AmazonBedrockAccount",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"PlanType": {
|
||||
"enum": [
|
||||
"free",
|
||||
"go",
|
||||
"plus",
|
||||
"pro",
|
||||
"prolite",
|
||||
"team",
|
||||
"self_serve_business_usage_based",
|
||||
"business",
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"unknown"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"account": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Account"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requiresOpenaiAuth": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["requiresOpenaiAuth"],
|
||||
"title": "GetAccountResponse",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -3,4 +3,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { PlanType } from "../PlanType.js";
|
||||
|
||||
export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string, planType: PlanType, };
|
||||
export type Account =
|
||||
| { type: "apiKey" }
|
||||
| { type: "chatgpt"; email: string; planType: PlanType }
|
||||
| { type: "amazonBedrock" };
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type {
|
||||
ClientRequest as GeneratedClientRequest,
|
||||
InitializeParams as GeneratedInitializeParams,
|
||||
InitializeResponse as GeneratedInitializeResponse,
|
||||
ServerNotification as GeneratedServerNotification,
|
||||
ServerRequest as GeneratedServerRequest,
|
||||
ServiceTier as GeneratedServiceTier,
|
||||
v2,
|
||||
} from "./protocol-generated/typescript/index.js";
|
||||
import type { JsonValue as GeneratedJsonValue } from "./protocol-generated/typescript/serde_json/JsonValue.js";
|
||||
@@ -7,7 +12,18 @@ import type { JsonValue as GeneratedJsonValue } from "./protocol-generated/types
|
||||
export type JsonPrimitive = null | boolean | number | string;
|
||||
export type JsonValue = GeneratedJsonValue;
|
||||
export type JsonObject = { [key: string]: JsonValue };
|
||||
export type CodexServiceTier = "fast" | "flex";
|
||||
export type CodexServiceTier = GeneratedServiceTier;
|
||||
|
||||
export type CodexAppServerRequestMethod = GeneratedClientRequest["method"];
|
||||
export type CodexAppServerRequestParams<M extends CodexAppServerRequestMethod> =
|
||||
M extends keyof CodexAppServerRequestParamsOverride
|
||||
? CodexAppServerRequestParamsOverride[M]
|
||||
: Extract<GeneratedClientRequest, { method: M }>["params"];
|
||||
|
||||
export type CodexAppServerRequestResult<M extends CodexAppServerRequestMethod> =
|
||||
M extends keyof CodexAppServerRequestResultMap
|
||||
? CodexAppServerRequestResultMap[M]
|
||||
: JsonValue | undefined;
|
||||
|
||||
export type RpcRequest = {
|
||||
id?: number | string;
|
||||
@@ -27,12 +43,9 @@ export type RpcResponse = {
|
||||
|
||||
export type RpcMessage = RpcRequest | RpcResponse;
|
||||
|
||||
export type CodexInitializeResponse = {
|
||||
userAgent?: string;
|
||||
codexHome?: string;
|
||||
platformFamily?: string;
|
||||
platformOs?: string;
|
||||
};
|
||||
export type CodexInitializeParams = GeneratedInitializeParams;
|
||||
|
||||
export type CodexInitializeResponse = GeneratedInitializeResponse;
|
||||
|
||||
export type CodexUserInput = v2.UserInput;
|
||||
|
||||
@@ -50,6 +63,8 @@ export type CodexThreadResumeResponse = v2.ThreadResumeResponse;
|
||||
|
||||
export type CodexTurnStartParams = v2.TurnStartParams;
|
||||
|
||||
export type CodexSandboxPolicy = v2.SandboxPolicy;
|
||||
|
||||
export type CodexTurnSteerParams = v2.TurnSteerParams;
|
||||
|
||||
export type CodexTurnInterruptParams = {
|
||||
@@ -71,12 +86,35 @@ export type CodexServerNotification = {
|
||||
params?: JsonValue;
|
||||
};
|
||||
|
||||
export type CodexKnownServerRequest = GeneratedServerRequest;
|
||||
|
||||
export type CodexDynamicToolCallParams = v2.DynamicToolCallParams;
|
||||
|
||||
export type CodexDynamicToolCallResponse = v2.DynamicToolCallResponse;
|
||||
|
||||
export type CodexDynamicToolCallOutputContentItem = v2.DynamicToolCallOutputContentItem;
|
||||
|
||||
type CodexAppServerRequestParamsOverride = {
|
||||
"thread/start": CodexThreadStartParams;
|
||||
};
|
||||
|
||||
type CodexAppServerRequestResultMap = {
|
||||
initialize: CodexInitializeResponse;
|
||||
"account/rateLimits/read": v2.GetAccountRateLimitsResponse;
|
||||
"account/read": v2.GetAccountResponse;
|
||||
"mcpServerStatus/list": v2.ListMcpServerStatusResponse;
|
||||
"model/list": v2.ModelListResponse;
|
||||
"review/start": v2.ReviewStartResponse;
|
||||
"skills/list": v2.SkillsListResponse;
|
||||
"thread/compact/start": v2.ThreadCompactStartResponse;
|
||||
"thread/list": v2.ThreadListResponse;
|
||||
"thread/resume": CodexThreadResumeResponse;
|
||||
"thread/start": CodexThreadStartResponse;
|
||||
"turn/interrupt": v2.TurnInterruptResponse;
|
||||
"turn/start": CodexTurnStartResponse;
|
||||
"turn/steer": v2.TurnSteerResponse;
|
||||
};
|
||||
|
||||
export function isJsonObject(value: JsonValue | undefined): value is JsonObject {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import type { CodexAppServerStartOptions } from "./config.js";
|
||||
import type { JsonValue } from "./protocol.js";
|
||||
import type {
|
||||
CodexAppServerRequestMethod,
|
||||
CodexAppServerRequestParams,
|
||||
CodexAppServerRequestResult,
|
||||
JsonValue,
|
||||
} from "./protocol.js";
|
||||
import { getSharedCodexAppServerClient } from "./shared-client.js";
|
||||
import { withTimeout } from "./timeout.js";
|
||||
|
||||
export async function requestCodexAppServerJson<M extends CodexAppServerRequestMethod>(params: {
|
||||
method: M;
|
||||
requestParams: CodexAppServerRequestParams<M>;
|
||||
timeoutMs?: number;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
authProfileId?: string;
|
||||
}): Promise<CodexAppServerRequestResult<M>>;
|
||||
export async function requestCodexAppServerJson<T = JsonValue | undefined>(params: {
|
||||
method: string;
|
||||
requestParams?: JsonValue;
|
||||
requestParams?: unknown;
|
||||
timeoutMs?: number;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
authProfileId?: string;
|
||||
}): Promise<T>;
|
||||
export async function requestCodexAppServerJson<T = JsonValue | undefined>(params: {
|
||||
method: string;
|
||||
requestParams?: unknown;
|
||||
timeoutMs?: number;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
authProfileId?: string;
|
||||
|
||||
@@ -550,7 +550,6 @@ describe("runCodexAppServerAttempt", () => {
|
||||
method: "thread/start",
|
||||
params: expect.objectContaining({
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "user",
|
||||
@@ -1034,7 +1033,6 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expectResumeRequest(requests, {
|
||||
threadId: "thread-existing",
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: "danger-full-access",
|
||||
@@ -1136,7 +1134,6 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expectResumeRequest(requests, {
|
||||
threadId: "thread-existing",
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "guardian_subagent",
|
||||
sandbox: "danger-full-access",
|
||||
@@ -1151,6 +1148,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
params: expect.objectContaining({
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "guardian_subagent",
|
||||
sandboxPolicy: { type: "dangerFullAccess" },
|
||||
serviceTier: "fast",
|
||||
model: "gpt-5.4-codex",
|
||||
}),
|
||||
@@ -1207,7 +1205,6 @@ describe("runCodexAppServerAttempt", () => {
|
||||
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",
|
||||
@@ -1224,6 +1221,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
model: "gpt-5.4-codex",
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "guardian_subagent",
|
||||
sandboxPolicy: { type: "dangerFullAccess" },
|
||||
serviceTier: "flex",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -62,6 +62,18 @@ import { filterToolsForVisionInputs } from "./vision-tools.js";
|
||||
|
||||
let clientFactory = defaultCodexAppServerClientFactory;
|
||||
|
||||
function emitCodexAppServerEvent(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
event: Parameters<NonNullable<EmbeddedRunAttemptParams["onAgentEvent"]>>[0],
|
||||
): void {
|
||||
try {
|
||||
params.onAgentEvent?.(event);
|
||||
} catch {
|
||||
// Event consumers are observational; they must not abort or strand the
|
||||
// canonical app-server turn lifecycle.
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCodexAppServerAttempt(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
options: { pluginConfig?: unknown; startupTimeoutFloorMs?: number } = {},
|
||||
@@ -151,6 +163,10 @@ export async function runCodexAppServerAttempt(
|
||||
let thread: CodexAppServerThreadBinding;
|
||||
let trajectoryEndRecorded = false;
|
||||
try {
|
||||
emitCodexAppServerEvent(params, {
|
||||
stream: "codex_app_server.lifecycle",
|
||||
data: { phase: "startup" },
|
||||
});
|
||||
({ client, thread } = await withCodexStartupTimeout({
|
||||
timeoutMs: params.timeoutMs,
|
||||
timeoutFloorMs: options.startupTimeoutFloorMs,
|
||||
@@ -168,6 +184,10 @@ export async function runCodexAppServerAttempt(
|
||||
return { client: startupClient, thread: startupThread };
|
||||
},
|
||||
}));
|
||||
emitCodexAppServerEvent(params, {
|
||||
stream: "codex_app_server.lifecycle",
|
||||
data: { phase: "thread_ready", threadId: thread.threadId },
|
||||
});
|
||||
} catch (error) {
|
||||
clearSharedCodexAppServerClient();
|
||||
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
|
||||
@@ -306,8 +326,12 @@ export async function runCodexAppServerAttempt(
|
||||
event: llmInputEvent,
|
||||
ctx: hookContext,
|
||||
});
|
||||
emitCodexAppServerEvent(params, {
|
||||
stream: "codex_app_server.lifecycle",
|
||||
data: { phase: "turn_starting", threadId: thread.threadId },
|
||||
});
|
||||
turn = assertCodexTurnStartResponse(
|
||||
await client.request<unknown>(
|
||||
await client.request(
|
||||
"turn/start",
|
||||
buildTurnStartParams(params, {
|
||||
threadId: thread.threadId,
|
||||
@@ -319,6 +343,10 @@ export async function runCodexAppServerAttempt(
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
emitCodexAppServerEvent(params, {
|
||||
stream: "codex_app_server.lifecycle",
|
||||
data: { phase: "turn_start_failed", error: formatErrorMessage(error) },
|
||||
});
|
||||
trajectoryRecorder?.recordEvent("session.ended", {
|
||||
status: "error",
|
||||
threadId: thread.threadId,
|
||||
@@ -575,7 +603,7 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
disableMessageTool: params.disableMessageTool,
|
||||
onYield: (message) => {
|
||||
input.onYieldDetected();
|
||||
params.onAgentEvent?.({
|
||||
emitCodexAppServerEvent(params, {
|
||||
stream: "codex_app_server.tool",
|
||||
data: { name: "sessions_yield", message },
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { CodexAppServerApprovalPolicy, CodexAppServerSandboxMode } from "./config.js";
|
||||
import type { CodexServiceTier } from "./protocol.js";
|
||||
|
||||
export type CodexAppServerThreadBinding = {
|
||||
schemaVersion: 1;
|
||||
@@ -9,6 +11,9 @@ export type CodexAppServerThreadBinding = {
|
||||
authProfileId?: string;
|
||||
model?: string;
|
||||
modelProvider?: string;
|
||||
approvalPolicy?: CodexAppServerApprovalPolicy;
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
serviceTier?: CodexServiceTier;
|
||||
dynamicToolsFingerprint?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -45,6 +50,9 @@ export async function readCodexAppServerBinding(
|
||||
authProfileId: typeof parsed.authProfileId === "string" ? parsed.authProfileId : undefined,
|
||||
model: typeof parsed.model === "string" ? parsed.model : undefined,
|
||||
modelProvider: typeof parsed.modelProvider === "string" ? parsed.modelProvider : undefined,
|
||||
approvalPolicy: readApprovalPolicy(parsed.approvalPolicy),
|
||||
sandbox: readSandboxMode(parsed.sandbox),
|
||||
serviceTier: readServiceTier(parsed.serviceTier),
|
||||
dynamicToolsFingerprint:
|
||||
typeof parsed.dynamicToolsFingerprint === "string"
|
||||
? parsed.dynamicToolsFingerprint
|
||||
@@ -76,6 +84,9 @@ export async function writeCodexAppServerBinding(
|
||||
authProfileId: binding.authProfileId,
|
||||
model: binding.model,
|
||||
modelProvider: binding.modelProvider,
|
||||
approvalPolicy: binding.approvalPolicy,
|
||||
sandbox: binding.sandbox,
|
||||
serviceTier: binding.serviceTier,
|
||||
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,
|
||||
createdAt: binding.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
@@ -99,3 +110,22 @@ export async function clearCodexAppServerBinding(sessionFile: string): Promise<v
|
||||
function isNotFound(error: unknown): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
||||
}
|
||||
|
||||
function readApprovalPolicy(value: unknown): CodexAppServerApprovalPolicy | undefined {
|
||||
return value === "never" ||
|
||||
value === "on-request" ||
|
||||
value === "on-failure" ||
|
||||
value === "untrusted"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readSandboxMode(value: unknown): CodexAppServerSandboxMode | undefined {
|
||||
return value === "read-only" || value === "workspace-write" || value === "danger-full-access"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readServiceTier(value: unknown): CodexServiceTier | undefined {
|
||||
return value === "fast" || value === "flex" ? value : undefined;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { renderCodexPromptOverlay } from "../../prompt-overlay.js";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import type { CodexAppServerRuntimeOptions } from "./config.js";
|
||||
import { codexSandboxPolicyForTurn, type CodexAppServerRuntimeOptions } from "./config.js";
|
||||
import {
|
||||
assertCodexThreadResumeResponse,
|
||||
assertCodexThreadStartResponse,
|
||||
} from "./protocol-validators.js";
|
||||
import {
|
||||
isJsonObject,
|
||||
type CodexDynamicToolSpec,
|
||||
@@ -15,10 +19,6 @@ import {
|
||||
type JsonObject,
|
||||
type JsonValue,
|
||||
} from "./protocol.js";
|
||||
import {
|
||||
assertCodexThreadResumeResponse,
|
||||
assertCodexThreadStartResponse,
|
||||
} from "./protocol-validators.js";
|
||||
import {
|
||||
clearCodexAppServerBinding,
|
||||
readCodexAppServerBinding,
|
||||
@@ -53,22 +53,23 @@ export async function startOrResumeThread(params: {
|
||||
} else {
|
||||
try {
|
||||
const response = assertCodexThreadResumeResponse(
|
||||
await params.client.request<unknown>(
|
||||
await params.client.request(
|
||||
"thread/resume",
|
||||
buildThreadResumeParams(params.params, {
|
||||
threadId: binding.threadId,
|
||||
appServer: params.appServer,
|
||||
developerInstructions: params.developerInstructions,
|
||||
}) as unknown as JsonValue,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const boundAuthProfileId = params.params.authProfileId ?? binding.authProfileId;
|
||||
const fallbackModelProvider = resolveCodexAppServerModelProvider(params.params.provider);
|
||||
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),
|
||||
modelProvider: response.modelProvider ?? fallbackModelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
createdAt: binding.createdAt,
|
||||
});
|
||||
@@ -78,7 +79,7 @@ export async function startOrResumeThread(params: {
|
||||
cwd: params.cwd,
|
||||
authProfileId: boundAuthProfileId,
|
||||
model: params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider),
|
||||
modelProvider: response.modelProvider ?? fallbackModelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -90,25 +91,23 @@ export async function startOrResumeThread(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const modelProvider = resolveCodexAppServerModelProvider(params.params.provider);
|
||||
const response = assertCodexThreadStartResponse(
|
||||
await params.client.request<unknown>(
|
||||
"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:
|
||||
params.developerInstructions ?? buildDeveloperInstructions(params.params),
|
||||
dynamicTools: params.dynamicTools,
|
||||
experimentalRawEvents: true,
|
||||
persistExtendedHistory: true,
|
||||
} satisfies CodexThreadStartParams) as unknown as JsonValue,
|
||||
),
|
||||
await params.client.request("thread/start", {
|
||||
model: params.params.modelId,
|
||||
...(modelProvider ? { modelProvider } : {}),
|
||||
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:
|
||||
params.developerInstructions ?? buildDeveloperInstructions(params.params),
|
||||
dynamicTools: params.dynamicTools,
|
||||
experimentalRawEvents: true,
|
||||
persistExtendedHistory: true,
|
||||
} satisfies CodexThreadStartParams),
|
||||
);
|
||||
const createdAt = new Date().toISOString();
|
||||
await writeCodexAppServerBinding(params.params.sessionFile, {
|
||||
@@ -116,7 +115,7 @@ export async function startOrResumeThread(params: {
|
||||
cwd: params.cwd,
|
||||
authProfileId: params.params.authProfileId,
|
||||
model: response.model ?? params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider),
|
||||
modelProvider: response.modelProvider ?? modelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
createdAt,
|
||||
});
|
||||
@@ -127,7 +126,7 @@ export async function startOrResumeThread(params: {
|
||||
cwd: params.cwd,
|
||||
authProfileId: params.params.authProfileId,
|
||||
model: response.model ?? params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider),
|
||||
modelProvider: response.modelProvider ?? modelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
@@ -142,10 +141,11 @@ export function buildThreadResumeParams(
|
||||
developerInstructions?: string;
|
||||
},
|
||||
): CodexThreadResumeParams {
|
||||
const modelProvider = resolveCodexAppServerModelProvider(params.provider);
|
||||
return {
|
||||
threadId: options.threadId,
|
||||
model: params.modelId,
|
||||
modelProvider: normalizeModelProvider(params.provider),
|
||||
...(modelProvider ? { modelProvider } : {}),
|
||||
approvalPolicy: options.appServer.approvalPolicy,
|
||||
approvalsReviewer: options.appServer.approvalsReviewer,
|
||||
sandbox: options.appServer.sandbox,
|
||||
@@ -170,6 +170,7 @@ export function buildTurnStartParams(
|
||||
cwd: options.cwd,
|
||||
approvalPolicy: options.appServer.approvalPolicy,
|
||||
approvalsReviewer: options.appServer.approvalsReviewer,
|
||||
sandboxPolicy: codexSandboxPolicyForTurn(options.appServer.sandbox, options.cwd),
|
||||
model: params.modelId,
|
||||
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
|
||||
effort: resolveReasoningEffort(params.thinkLevel),
|
||||
@@ -238,8 +239,14 @@ function buildUserInput(
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeModelProvider(provider: string): string {
|
||||
return provider === "codex" || provider === "openai-codex" ? "openai" : provider;
|
||||
function resolveCodexAppServerModelProvider(provider: string): string | undefined {
|
||||
const normalized = provider.trim();
|
||||
if (!normalized || normalized === "codex") {
|
||||
// `codex` is OpenClaw's virtual provider; let Codex app-server keep its
|
||||
// native provider/auth selection instead of forcing the legacy OpenAI path.
|
||||
return undefined;
|
||||
}
|
||||
return normalized === "openai-codex" ? "openai" : normalized;
|
||||
}
|
||||
|
||||
function resolveReasoningEffort(
|
||||
|
||||
@@ -106,6 +106,14 @@ export function buildHelp(): string {
|
||||
"- /codex models",
|
||||
"- /codex threads [filter]",
|
||||
"- /codex resume <thread-id>",
|
||||
"- /codex bind [thread-id] [--cwd <path>] [--model <model>] [--provider <provider>]",
|
||||
"- /codex binding",
|
||||
"- /codex stop",
|
||||
"- /codex steer <message>",
|
||||
"- /codex model [model]",
|
||||
"- /codex fast [on|off|status]",
|
||||
"- /codex permissions [default|yolo|status]",
|
||||
"- /codex detach",
|
||||
"- /codex compact",
|
||||
"- /codex review",
|
||||
"- /codex account",
|
||||
@@ -118,11 +126,16 @@ function summarizeAccount(value: JsonValue | undefined): string {
|
||||
if (!isJsonObject(value)) {
|
||||
return "unavailable";
|
||||
}
|
||||
const account = isJsonObject(value.account) ? value.account : value;
|
||||
const accountType = readString(account, "type");
|
||||
if (accountType === "amazonBedrock") {
|
||||
return "Amazon Bedrock";
|
||||
}
|
||||
return (
|
||||
readString(value, "email") ??
|
||||
readString(value, "accountEmail") ??
|
||||
readString(value, "planType") ??
|
||||
readString(value, "id") ??
|
||||
readString(account, "email") ??
|
||||
readString(account, "accountEmail") ??
|
||||
readString(account, "planType") ??
|
||||
readString(account, "id") ??
|
||||
"available"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
|
||||
import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { CODEX_CONTROL_METHODS, type CodexControlMethod } from "./app-server/capabilities.js";
|
||||
import { listCodexAppServerModels } from "./app-server/models.js";
|
||||
import { isJsonObject } from "./app-server/protocol.js";
|
||||
import { isJsonObject, type JsonValue } from "./app-server/protocol.js";
|
||||
import {
|
||||
clearCodexAppServerBinding,
|
||||
readCodexAppServerBinding,
|
||||
writeCodexAppServerBinding,
|
||||
} from "./app-server/session-binding.js";
|
||||
@@ -20,18 +21,56 @@ import {
|
||||
readCodexStatusProbes,
|
||||
requestOptions,
|
||||
safeCodexControlRequest,
|
||||
type SafeValue,
|
||||
} from "./command-rpc.js";
|
||||
import {
|
||||
readCodexConversationBindingData,
|
||||
resolveCodexDefaultWorkspaceDir,
|
||||
startCodexConversationThread,
|
||||
} from "./conversation-binding.js";
|
||||
import {
|
||||
formatPermissionsMode,
|
||||
parseCodexFastModeArg,
|
||||
parseCodexPermissionsModeArg,
|
||||
readCodexConversationActiveTurn,
|
||||
setCodexConversationFastMode,
|
||||
setCodexConversationModel,
|
||||
setCodexConversationPermissions,
|
||||
steerCodexConversationTurn,
|
||||
stopCodexConversationTurn,
|
||||
} from "./conversation-control.js";
|
||||
|
||||
export type CodexCommandDeps = {
|
||||
codexControlRequest: typeof codexControlRequest;
|
||||
codexControlRequest: CodexControlRequestFn;
|
||||
listCodexAppServerModels: typeof listCodexAppServerModels;
|
||||
readCodexStatusProbes: typeof readCodexStatusProbes;
|
||||
readCodexAppServerBinding: typeof readCodexAppServerBinding;
|
||||
requestOptions: typeof requestOptions;
|
||||
safeCodexControlRequest: typeof safeCodexControlRequest;
|
||||
safeCodexControlRequest: SafeCodexControlRequestFn;
|
||||
writeCodexAppServerBinding: typeof writeCodexAppServerBinding;
|
||||
clearCodexAppServerBinding: typeof clearCodexAppServerBinding;
|
||||
resolveCodexDefaultWorkspaceDir: typeof resolveCodexDefaultWorkspaceDir;
|
||||
startCodexConversationThread: typeof startCodexConversationThread;
|
||||
readCodexConversationActiveTurn: typeof readCodexConversationActiveTurn;
|
||||
setCodexConversationFastMode: typeof setCodexConversationFastMode;
|
||||
setCodexConversationModel: typeof setCodexConversationModel;
|
||||
setCodexConversationPermissions: typeof setCodexConversationPermissions;
|
||||
steerCodexConversationTurn: typeof steerCodexConversationTurn;
|
||||
stopCodexConversationTurn: typeof stopCodexConversationTurn;
|
||||
};
|
||||
|
||||
type CodexControlRequestFn = (
|
||||
pluginConfig: unknown,
|
||||
method: CodexControlMethod,
|
||||
requestParams: JsonValue | undefined,
|
||||
) => Promise<JsonValue | undefined>;
|
||||
|
||||
type SafeCodexControlRequestFn = (
|
||||
pluginConfig: unknown,
|
||||
method: CodexControlMethod,
|
||||
requestParams: JsonValue | undefined,
|
||||
) => Promise<SafeValue<JsonValue | undefined>>;
|
||||
|
||||
const defaultCodexCommandDeps: CodexCommandDeps = {
|
||||
codexControlRequest,
|
||||
listCodexAppServerModels,
|
||||
@@ -40,12 +79,29 @@ const defaultCodexCommandDeps: CodexCommandDeps = {
|
||||
requestOptions,
|
||||
safeCodexControlRequest,
|
||||
writeCodexAppServerBinding,
|
||||
clearCodexAppServerBinding,
|
||||
resolveCodexDefaultWorkspaceDir,
|
||||
startCodexConversationThread,
|
||||
readCodexConversationActiveTurn,
|
||||
setCodexConversationFastMode,
|
||||
setCodexConversationModel,
|
||||
setCodexConversationPermissions,
|
||||
steerCodexConversationTurn,
|
||||
stopCodexConversationTurn,
|
||||
};
|
||||
|
||||
type ParsedBindArgs = {
|
||||
threadId?: string;
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
help?: boolean;
|
||||
};
|
||||
|
||||
export async function handleCodexSubcommand(
|
||||
ctx: PluginCommandContext,
|
||||
options: { pluginConfig?: unknown; deps?: Partial<CodexCommandDeps> },
|
||||
): Promise<{ text: string }> {
|
||||
): Promise<PluginCommandResult> {
|
||||
const deps: CodexCommandDeps = { ...defaultCodexCommandDeps, ...options.deps };
|
||||
const [subcommand = "status", ...rest] = splitArgs(ctx.args);
|
||||
const normalized = subcommand.toLowerCase();
|
||||
@@ -68,6 +124,30 @@ export async function handleCodexSubcommand(
|
||||
if (normalized === "resume") {
|
||||
return { text: await resumeThread(deps, ctx, options.pluginConfig, rest[0]) };
|
||||
}
|
||||
if (normalized === "bind") {
|
||||
return await bindConversation(deps, ctx, options.pluginConfig, rest);
|
||||
}
|
||||
if (normalized === "detach" || normalized === "unbind") {
|
||||
return { text: await detachConversation(deps, ctx) };
|
||||
}
|
||||
if (normalized === "binding") {
|
||||
return { text: await describeConversationBinding(deps, ctx) };
|
||||
}
|
||||
if (normalized === "stop") {
|
||||
return { text: await stopConversationTurn(deps, ctx, options.pluginConfig) };
|
||||
}
|
||||
if (normalized === "steer") {
|
||||
return { text: await steerConversationTurn(deps, ctx, options.pluginConfig, rest.join(" ")) };
|
||||
}
|
||||
if (normalized === "model") {
|
||||
return { text: await setConversationModel(deps, ctx, options.pluginConfig, rest.join(" ")) };
|
||||
}
|
||||
if (normalized === "fast") {
|
||||
return { text: await setConversationFastMode(deps, ctx, options.pluginConfig, rest[0]) };
|
||||
}
|
||||
if (normalized === "permissions") {
|
||||
return { text: await setConversationPermissions(deps, ctx, options.pluginConfig, rest[0]) };
|
||||
}
|
||||
if (normalized === "compact") {
|
||||
return {
|
||||
text: await startThreadAction(
|
||||
@@ -110,14 +190,110 @@ export async function handleCodexSubcommand(
|
||||
}
|
||||
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, {}),
|
||||
deps.safeCodexControlRequest(options.pluginConfig, CODEX_CONTROL_METHODS.account, {
|
||||
refreshToken: false,
|
||||
}),
|
||||
deps.safeCodexControlRequest(
|
||||
options.pluginConfig,
|
||||
CODEX_CONTROL_METHODS.rateLimits,
|
||||
undefined,
|
||||
),
|
||||
]);
|
||||
return { text: formatAccount(account, limits) };
|
||||
}
|
||||
return { text: `Unknown Codex command: ${subcommand}\n\n${buildHelp()}` };
|
||||
}
|
||||
|
||||
async function bindConversation(
|
||||
deps: CodexCommandDeps,
|
||||
ctx: PluginCommandContext,
|
||||
pluginConfig: unknown,
|
||||
args: string[],
|
||||
): Promise<PluginCommandResult> {
|
||||
if (!ctx.sessionFile) {
|
||||
return {
|
||||
text: "Cannot bind Codex because this command did not include an OpenClaw session file.",
|
||||
};
|
||||
}
|
||||
const parsed = parseBindArgs(args);
|
||||
if (parsed.help) {
|
||||
return {
|
||||
text: "Usage: /codex bind [thread-id] [--cwd <path>] [--model <model>] [--provider <provider>]",
|
||||
};
|
||||
}
|
||||
const workspaceDir = parsed.cwd ?? deps.resolveCodexDefaultWorkspaceDir(pluginConfig);
|
||||
const data = await deps.startCodexConversationThread({
|
||||
pluginConfig,
|
||||
sessionFile: ctx.sessionFile,
|
||||
workspaceDir,
|
||||
threadId: parsed.threadId,
|
||||
model: parsed.model,
|
||||
modelProvider: parsed.provider,
|
||||
});
|
||||
const binding = await deps.readCodexAppServerBinding(ctx.sessionFile);
|
||||
const threadId = binding?.threadId ?? parsed.threadId ?? "new thread";
|
||||
const summary = `Codex app-server thread ${threadId} in ${workspaceDir}`;
|
||||
let request: Awaited<ReturnType<PluginCommandContext["requestConversationBinding"]>>;
|
||||
try {
|
||||
request = await ctx.requestConversationBinding({
|
||||
summary,
|
||||
detachHint: "/codex detach",
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
await deps.clearCodexAppServerBinding(ctx.sessionFile);
|
||||
throw error;
|
||||
}
|
||||
if (request.status === "bound") {
|
||||
return { text: `Bound this conversation to Codex thread ${threadId} in ${workspaceDir}.` };
|
||||
}
|
||||
if (request.status === "pending") {
|
||||
return request.reply;
|
||||
}
|
||||
await deps.clearCodexAppServerBinding(ctx.sessionFile);
|
||||
return { text: request.message };
|
||||
}
|
||||
|
||||
async function detachConversation(
|
||||
deps: CodexCommandDeps,
|
||||
ctx: PluginCommandContext,
|
||||
): Promise<string> {
|
||||
const current = await ctx.getCurrentConversationBinding();
|
||||
const data = readCodexConversationBindingData(current);
|
||||
const detached = await ctx.detachConversationBinding();
|
||||
if (data) {
|
||||
await deps.clearCodexAppServerBinding(data.sessionFile);
|
||||
} else if (ctx.sessionFile) {
|
||||
await deps.clearCodexAppServerBinding(ctx.sessionFile);
|
||||
}
|
||||
return detached.removed
|
||||
? "Detached this conversation from Codex."
|
||||
: "No Codex conversation binding was attached.";
|
||||
}
|
||||
|
||||
async function describeConversationBinding(
|
||||
deps: CodexCommandDeps,
|
||||
ctx: PluginCommandContext,
|
||||
): Promise<string> {
|
||||
const current = await ctx.getCurrentConversationBinding();
|
||||
const data = readCodexConversationBindingData(current);
|
||||
if (!current || !data) {
|
||||
return "No Codex conversation binding is attached.";
|
||||
}
|
||||
const threadBinding = await deps.readCodexAppServerBinding(data.sessionFile);
|
||||
const active = deps.readCodexConversationActiveTurn(data.sessionFile);
|
||||
return [
|
||||
"Codex conversation binding:",
|
||||
`- Thread: ${threadBinding?.threadId ?? "unknown"}`,
|
||||
`- Workspace: ${data.workspaceDir}`,
|
||||
`- Model: ${threadBinding?.model ?? "default"}`,
|
||||
`- Fast: ${threadBinding?.serviceTier === "fast" ? "on" : "off"}`,
|
||||
`- Permissions: ${threadBinding ? formatPermissionsMode(threadBinding) : "default"}`,
|
||||
`- Active run: ${active ? active.turnId : "none"}`,
|
||||
`- Session: ${data.sessionFile}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function buildThreads(
|
||||
deps: CodexCommandDeps,
|
||||
pluginConfig: unknown,
|
||||
@@ -125,7 +301,7 @@ async function buildThreads(
|
||||
): Promise<string> {
|
||||
const response = await deps.codexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.listThreads, {
|
||||
limit: 10,
|
||||
...(filter.trim() ? { filter: filter.trim() } : {}),
|
||||
...(filter.trim() ? { searchTerm: filter.trim() } : {}),
|
||||
});
|
||||
return formatThreads(response);
|
||||
}
|
||||
@@ -162,6 +338,106 @@ async function resumeThread(
|
||||
return `Attached this OpenClaw session to Codex thread ${effectiveThreadId}.`;
|
||||
}
|
||||
|
||||
async function stopConversationTurn(
|
||||
deps: CodexCommandDeps,
|
||||
ctx: PluginCommandContext,
|
||||
pluginConfig: unknown,
|
||||
): Promise<string> {
|
||||
const sessionFile = await resolveControlSessionFile(ctx);
|
||||
if (!sessionFile) {
|
||||
return "Cannot stop Codex because this command did not include an OpenClaw session file.";
|
||||
}
|
||||
return (await deps.stopCodexConversationTurn({ sessionFile, pluginConfig })).message;
|
||||
}
|
||||
|
||||
async function steerConversationTurn(
|
||||
deps: CodexCommandDeps,
|
||||
ctx: PluginCommandContext,
|
||||
pluginConfig: unknown,
|
||||
message: string,
|
||||
): Promise<string> {
|
||||
const sessionFile = await resolveControlSessionFile(ctx);
|
||||
if (!sessionFile) {
|
||||
return "Cannot steer Codex because this command did not include an OpenClaw session file.";
|
||||
}
|
||||
return (
|
||||
await deps.steerCodexConversationTurn({
|
||||
sessionFile,
|
||||
pluginConfig,
|
||||
message,
|
||||
})
|
||||
).message;
|
||||
}
|
||||
|
||||
async function setConversationModel(
|
||||
deps: CodexCommandDeps,
|
||||
ctx: PluginCommandContext,
|
||||
pluginConfig: unknown,
|
||||
model: string,
|
||||
): Promise<string> {
|
||||
const sessionFile = await resolveControlSessionFile(ctx);
|
||||
if (!sessionFile) {
|
||||
return "Cannot set Codex model because this command did not include an OpenClaw session file.";
|
||||
}
|
||||
const normalized = model.trim();
|
||||
if (!normalized) {
|
||||
const binding = await deps.readCodexAppServerBinding(sessionFile);
|
||||
return binding?.model ? `Codex model: ${binding.model}` : "Usage: /codex model <model>";
|
||||
}
|
||||
return await deps.setCodexConversationModel({
|
||||
sessionFile,
|
||||
pluginConfig,
|
||||
model: normalized,
|
||||
});
|
||||
}
|
||||
|
||||
async function setConversationFastMode(
|
||||
deps: CodexCommandDeps,
|
||||
ctx: PluginCommandContext,
|
||||
pluginConfig: unknown,
|
||||
value: string | undefined,
|
||||
): Promise<string> {
|
||||
const sessionFile = await resolveControlSessionFile(ctx);
|
||||
if (!sessionFile) {
|
||||
return "Cannot set Codex fast mode because this command did not include an OpenClaw session file.";
|
||||
}
|
||||
const parsed = parseCodexFastModeArg(value);
|
||||
if (value && parsed == null && value.trim().toLowerCase() !== "status") {
|
||||
return "Usage: /codex fast [on|off|status]";
|
||||
}
|
||||
return await deps.setCodexConversationFastMode({
|
||||
sessionFile,
|
||||
pluginConfig,
|
||||
enabled: parsed,
|
||||
});
|
||||
}
|
||||
|
||||
async function setConversationPermissions(
|
||||
deps: CodexCommandDeps,
|
||||
ctx: PluginCommandContext,
|
||||
pluginConfig: unknown,
|
||||
value: string | undefined,
|
||||
): Promise<string> {
|
||||
const sessionFile = await resolveControlSessionFile(ctx);
|
||||
if (!sessionFile) {
|
||||
return "Cannot set Codex permissions because this command did not include an OpenClaw session file.";
|
||||
}
|
||||
const parsed = parseCodexPermissionsModeArg(value);
|
||||
if (value && !parsed && value.trim().toLowerCase() !== "status") {
|
||||
return "Usage: /codex permissions [default|yolo|status]";
|
||||
}
|
||||
return await deps.setCodexConversationPermissions({
|
||||
sessionFile,
|
||||
pluginConfig,
|
||||
mode: parsed,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveControlSessionFile(ctx: PluginCommandContext): Promise<string | undefined> {
|
||||
const binding = await ctx.getCurrentConversationBinding();
|
||||
return readCodexConversationBindingData(binding)?.sessionFile ?? ctx.sessionFile;
|
||||
}
|
||||
|
||||
async function startThreadAction(
|
||||
deps: CodexCommandDeps,
|
||||
ctx: PluginCommandContext,
|
||||
@@ -169,17 +445,66 @@ async function startThreadAction(
|
||||
method: typeof CODEX_CONTROL_METHODS.compact | typeof CODEX_CONTROL_METHODS.review,
|
||||
label: string,
|
||||
): Promise<string> {
|
||||
if (!ctx.sessionFile) {
|
||||
const sessionFile = await resolveControlSessionFile(ctx);
|
||||
if (!sessionFile) {
|
||||
return `Cannot start Codex ${label} because this command did not include an OpenClaw session file.`;
|
||||
}
|
||||
const binding = await deps.readCodexAppServerBinding(ctx.sessionFile);
|
||||
const binding = await deps.readCodexAppServerBinding(sessionFile);
|
||||
if (!binding?.threadId) {
|
||||
return `No Codex thread is attached to this OpenClaw session yet.`;
|
||||
}
|
||||
await deps.codexControlRequest(pluginConfig, method, { threadId: binding.threadId });
|
||||
if (method === CODEX_CONTROL_METHODS.review) {
|
||||
await deps.codexControlRequest(pluginConfig, method, {
|
||||
threadId: binding.threadId,
|
||||
target: { type: "uncommittedChanges" },
|
||||
});
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
|
||||
function parseBindArgs(args: string[]): ParsedBindArgs {
|
||||
const parsed: ParsedBindArgs = {};
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
parsed.help = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--cwd") {
|
||||
parsed.cwd = args[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--model") {
|
||||
parsed.model = args[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--provider" || arg === "--model-provider") {
|
||||
parsed.provider = args[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (!arg.startsWith("-") && !parsed.threadId) {
|
||||
parsed.threadId = arg;
|
||||
continue;
|
||||
}
|
||||
parsed.help = true;
|
||||
}
|
||||
parsed.threadId = normalizeOptionalString(parsed.threadId);
|
||||
parsed.cwd = normalizeOptionalString(parsed.cwd);
|
||||
parsed.model = normalizeOptionalString(parsed.model);
|
||||
parsed.provider = normalizeOptionalString(parsed.provider);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ import {
|
||||
} 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 type {
|
||||
CodexAppServerRequestMethod,
|
||||
CodexAppServerRequestParams,
|
||||
CodexAppServerRequestResult,
|
||||
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 };
|
||||
@@ -19,11 +24,23 @@ export function requestOptions(pluginConfig: unknown, limit: number) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function codexControlRequest(
|
||||
type CodexControlRequestMethod = CodexControlMethod & CodexAppServerRequestMethod;
|
||||
|
||||
export function codexControlRequest<M extends CodexControlRequestMethod>(
|
||||
pluginConfig: unknown,
|
||||
method: M,
|
||||
requestParams: CodexAppServerRequestParams<M>,
|
||||
): Promise<CodexAppServerRequestResult<M>>;
|
||||
export function codexControlRequest(
|
||||
pluginConfig: unknown,
|
||||
method: CodexControlMethod,
|
||||
requestParams?: JsonValue,
|
||||
): Promise<JsonValue | undefined> {
|
||||
): Promise<JsonValue | undefined>;
|
||||
export async function codexControlRequest(
|
||||
pluginConfig: unknown,
|
||||
method: CodexControlMethod,
|
||||
requestParams?: unknown,
|
||||
) {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
return await requestCodexAppServerJson({
|
||||
method,
|
||||
@@ -33,13 +50,23 @@ export async function codexControlRequest(
|
||||
});
|
||||
}
|
||||
|
||||
export async function safeCodexControlRequest(
|
||||
export function safeCodexControlRequest<M extends CodexControlRequestMethod>(
|
||||
pluginConfig: unknown,
|
||||
method: M,
|
||||
requestParams: CodexAppServerRequestParams<M>,
|
||||
): Promise<SafeValue<CodexAppServerRequestResult<M>>>;
|
||||
export function safeCodexControlRequest(
|
||||
pluginConfig: unknown,
|
||||
method: CodexControlMethod,
|
||||
requestParams?: JsonValue,
|
||||
): Promise<SafeValue<JsonValue | undefined>> {
|
||||
): Promise<SafeValue<JsonValue | undefined>>;
|
||||
export async function safeCodexControlRequest(
|
||||
pluginConfig: unknown,
|
||||
method: CodexControlMethod,
|
||||
requestParams?: unknown,
|
||||
) {
|
||||
return await safeValue(
|
||||
async () => await codexControlRequest(pluginConfig, method, requestParams),
|
||||
async () => await codexControlRequest(pluginConfig, method, requestParams as JsonValue),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,8 +79,8 @@ export async function safeCodexModelList(pluginConfig: unknown, limit: number) {
|
||||
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.account, { refreshToken: false }),
|
||||
safeCodexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.rateLimits, undefined),
|
||||
safeCodexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.listMcpServers, { limit: 100 }),
|
||||
safeCodexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.listSkills, {}),
|
||||
]);
|
||||
|
||||
@@ -11,7 +11,11 @@ import { handleCodexCommand } from "./commands.js";
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
function createContext(args: string, sessionFile?: string): PluginCommandContext {
|
||||
function createContext(
|
||||
args: string,
|
||||
sessionFile?: string,
|
||||
overrides: Partial<PluginCommandContext> = {},
|
||||
): PluginCommandContext {
|
||||
return {
|
||||
channel: "test",
|
||||
isAuthorizedSender: true,
|
||||
@@ -22,6 +26,7 @@ function createContext(args: string, sessionFile?: string): PluginCommandContext
|
||||
requestConversationBinding: async () => ({ status: "error", message: "unused" }),
|
||||
detachConversationBinding: async () => ({ removed: false }),
|
||||
getCurrentConversationBinding: async () => null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,6 +136,48 @@ describe("codex command", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("formats generated account/read responses", async () => {
|
||||
const safeCodexControlRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
value: {
|
||||
account: { type: "chatgpt", email: "codex@example.com", planType: "pro" },
|
||||
requiresOpenaiAuth: false,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, value: { data: [{ name: "primary" }] } });
|
||||
|
||||
await expect(
|
||||
handleCodexCommand(createContext("account"), {
|
||||
deps: createDeps({ safeCodexControlRequest }),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
text: ["Account: codex@example.com", "Rate limits: 1"].join("\n"),
|
||||
});
|
||||
expect(safeCodexControlRequest).toHaveBeenCalledWith(undefined, CODEX_CONTROL_METHODS.account, {
|
||||
refreshToken: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("formats generated Amazon Bedrock account responses", async () => {
|
||||
const safeCodexControlRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
value: { account: { type: "amazonBedrock" }, requiresOpenaiAuth: false },
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, value: [] });
|
||||
|
||||
await expect(
|
||||
handleCodexCommand(createContext("account"), {
|
||||
deps: createDeps({ safeCodexControlRequest }),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
text: ["Account: Amazon Bedrock", "Rate limits: none returned"].join("\n"),
|
||||
});
|
||||
});
|
||||
|
||||
it("starts compaction for the attached Codex thread", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
@@ -152,6 +199,27 @@ describe("codex command", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("starts review with the generated app-server target shape", 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 () => ({}));
|
||||
|
||||
await expect(
|
||||
handleCodexCommand(createContext("review", sessionFile), {
|
||||
deps: createDeps({ codexControlRequest }),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
text: "Started Codex review for thread thread-123.",
|
||||
});
|
||||
expect(codexControlRequest).toHaveBeenCalledWith(undefined, CODEX_CONTROL_METHODS.review, {
|
||||
threadId: "thread-123",
|
||||
target: { type: "uncommittedChanges" },
|
||||
});
|
||||
});
|
||||
|
||||
it("explains compaction when no Codex thread is attached", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
|
||||
@@ -179,7 +247,335 @@ describe("codex command", () => {
|
||||
});
|
||||
expect(codexControlRequest).toHaveBeenCalledWith(undefined, CODEX_CONTROL_METHODS.listThreads, {
|
||||
limit: 10,
|
||||
filter: "fix",
|
||||
searchTerm: "fix",
|
||||
});
|
||||
});
|
||||
|
||||
it("binds the current conversation to a Codex app-server 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 startCodexConversationThread = vi.fn(async () => ({
|
||||
kind: "codex-app-server-session" as const,
|
||||
version: 1 as const,
|
||||
sessionFile,
|
||||
workspaceDir: "/repo",
|
||||
}));
|
||||
const requestConversationBinding = vi.fn(async () => ({
|
||||
status: "bound" as const,
|
||||
binding: {
|
||||
bindingId: "binding-1",
|
||||
pluginId: "codex",
|
||||
pluginRoot: "/plugin",
|
||||
channel: "test",
|
||||
accountId: "default",
|
||||
conversationId: "conversation",
|
||||
boundAt: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
await expect(
|
||||
handleCodexCommand(
|
||||
createContext(
|
||||
"bind thread-123 --cwd /repo --model gpt-5.4 --provider openai",
|
||||
sessionFile,
|
||||
{
|
||||
requestConversationBinding,
|
||||
},
|
||||
),
|
||||
{
|
||||
deps: createDeps({
|
||||
startCodexConversationThread,
|
||||
resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"),
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({
|
||||
text: "Bound this conversation to Codex thread thread-123 in /repo.",
|
||||
});
|
||||
expect(startCodexConversationThread).toHaveBeenCalledWith({
|
||||
pluginConfig: undefined,
|
||||
sessionFile,
|
||||
workspaceDir: "/repo",
|
||||
threadId: "thread-123",
|
||||
model: "gpt-5.4",
|
||||
modelProvider: "openai",
|
||||
});
|
||||
expect(requestConversationBinding).toHaveBeenCalledWith({
|
||||
summary: "Codex app-server thread thread-123 in /repo",
|
||||
detachHint: "/codex detach",
|
||||
data: {
|
||||
kind: "codex-app-server-session",
|
||||
version: 1,
|
||||
sessionFile,
|
||||
workspaceDir: "/repo",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the binding approval reply when conversation bind needs approval", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const reply = { text: "Approve this?" };
|
||||
await expect(
|
||||
handleCodexCommand(
|
||||
createContext("bind", sessionFile, {
|
||||
requestConversationBinding: async () => ({
|
||||
status: "pending",
|
||||
approvalId: "approval-1",
|
||||
reply,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
deps: createDeps({
|
||||
startCodexConversationThread: vi.fn(async () => ({
|
||||
kind: "codex-app-server-session" as const,
|
||||
version: 1 as const,
|
||||
sessionFile,
|
||||
workspaceDir: "/default",
|
||||
})),
|
||||
resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"),
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual(reply);
|
||||
});
|
||||
|
||||
it("clears the Codex app-server thread binding when conversation bind fails", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const clearCodexAppServerBinding = vi.fn(async () => {});
|
||||
|
||||
await expect(
|
||||
handleCodexCommand(
|
||||
createContext("bind", sessionFile, {
|
||||
requestConversationBinding: async () => ({
|
||||
status: "error",
|
||||
message: "binding unsupported",
|
||||
}),
|
||||
}),
|
||||
{
|
||||
deps: createDeps({
|
||||
clearCodexAppServerBinding,
|
||||
startCodexConversationThread: vi.fn(async () => ({
|
||||
kind: "codex-app-server-session" as const,
|
||||
version: 1 as const,
|
||||
sessionFile,
|
||||
workspaceDir: "/default",
|
||||
})),
|
||||
resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"),
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({ text: "binding unsupported" });
|
||||
expect(clearCodexAppServerBinding).toHaveBeenCalledWith(sessionFile);
|
||||
});
|
||||
|
||||
it("detaches the current conversation and clears the Codex app-server thread binding", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const clearCodexAppServerBinding = vi.fn(async () => {});
|
||||
const detachConversationBinding = vi.fn(async () => ({ removed: true }));
|
||||
|
||||
await expect(
|
||||
handleCodexCommand(
|
||||
createContext("detach", sessionFile, {
|
||||
detachConversationBinding,
|
||||
getCurrentConversationBinding: async () => ({
|
||||
bindingId: "binding-1",
|
||||
pluginId: "codex",
|
||||
pluginRoot: "/plugin",
|
||||
channel: "test",
|
||||
accountId: "default",
|
||||
conversationId: "conversation",
|
||||
boundAt: 1,
|
||||
data: {
|
||||
kind: "codex-app-server-session",
|
||||
version: 1,
|
||||
sessionFile,
|
||||
workspaceDir: "/repo",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{ deps: createDeps({ clearCodexAppServerBinding }) },
|
||||
),
|
||||
).resolves.toEqual({
|
||||
text: "Detached this conversation from Codex.",
|
||||
});
|
||||
expect(detachConversationBinding).toHaveBeenCalled();
|
||||
expect(clearCodexAppServerBinding).toHaveBeenCalledWith(sessionFile);
|
||||
});
|
||||
|
||||
it("stops the active bound Codex turn", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const stopCodexConversationTurn = vi.fn(async () => ({
|
||||
stopped: true,
|
||||
message: "Codex stop requested.",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
handleCodexCommand(createContext("stop", sessionFile), {
|
||||
deps: createDeps({ stopCodexConversationTurn }),
|
||||
}),
|
||||
).resolves.toEqual({ text: "Codex stop requested." });
|
||||
expect(stopCodexConversationTurn).toHaveBeenCalledWith({
|
||||
sessionFile,
|
||||
pluginConfig: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("steers the active bound Codex turn", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const steerCodexConversationTurn = vi.fn(async () => ({
|
||||
steered: true,
|
||||
message: "Sent steer message to Codex.",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
handleCodexCommand(createContext("steer focus tests first", sessionFile), {
|
||||
deps: createDeps({ steerCodexConversationTurn }),
|
||||
}),
|
||||
).resolves.toEqual({ text: "Sent steer message to Codex." });
|
||||
expect(steerCodexConversationTurn).toHaveBeenCalledWith({
|
||||
sessionFile,
|
||||
pluginConfig: undefined,
|
||||
message: "focus tests first",
|
||||
});
|
||||
});
|
||||
|
||||
it("sets per-binding model, fast mode, and permissions", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const setCodexConversationModel = vi.fn(async () => "Codex model set to gpt-5.4.");
|
||||
const setCodexConversationFastMode = vi.fn(async () => "Codex fast mode enabled.");
|
||||
const setCodexConversationPermissions = vi.fn(
|
||||
async () => "Codex permissions set to full access.",
|
||||
);
|
||||
const deps = createDeps({
|
||||
setCodexConversationModel,
|
||||
setCodexConversationFastMode,
|
||||
setCodexConversationPermissions,
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleCodexCommand(createContext("model gpt-5.4", sessionFile), { deps }),
|
||||
).resolves.toEqual({ text: "Codex model set to gpt-5.4." });
|
||||
await expect(
|
||||
handleCodexCommand(createContext("fast on", sessionFile), { deps }),
|
||||
).resolves.toEqual({ text: "Codex fast mode enabled." });
|
||||
await expect(
|
||||
handleCodexCommand(createContext("permissions yolo", sessionFile), { deps }),
|
||||
).resolves.toEqual({ text: "Codex permissions set to full access." });
|
||||
|
||||
expect(setCodexConversationModel).toHaveBeenCalledWith({
|
||||
sessionFile,
|
||||
pluginConfig: undefined,
|
||||
model: "gpt-5.4",
|
||||
});
|
||||
expect(setCodexConversationFastMode).toHaveBeenCalledWith({
|
||||
sessionFile,
|
||||
pluginConfig: undefined,
|
||||
enabled: true,
|
||||
});
|
||||
expect(setCodexConversationPermissions).toHaveBeenCalledWith({
|
||||
sessionFile,
|
||||
pluginConfig: undefined,
|
||||
mode: "yolo",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses current plugin binding data for follow-up control commands", async () => {
|
||||
const hostSessionFile = path.join(tempDir, "host-session.jsonl");
|
||||
const pluginSessionFile = path.join(tempDir, "plugin-session.jsonl");
|
||||
const setCodexConversationFastMode = vi.fn(async () => "Codex fast mode enabled.");
|
||||
|
||||
await expect(
|
||||
handleCodexCommand(
|
||||
createContext("fast on", pluginSessionFile, {
|
||||
getCurrentConversationBinding: async () => ({
|
||||
bindingId: "binding-1",
|
||||
pluginId: "codex",
|
||||
pluginRoot: "/plugin",
|
||||
channel: "slack",
|
||||
accountId: "default",
|
||||
conversationId: "user:U123",
|
||||
boundAt: 1,
|
||||
data: {
|
||||
kind: "codex-app-server-session",
|
||||
version: 1,
|
||||
sessionFile: hostSessionFile,
|
||||
workspaceDir: tempDir,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{
|
||||
deps: createDeps({
|
||||
setCodexConversationFastMode,
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({ text: "Codex fast mode enabled." });
|
||||
|
||||
expect(setCodexConversationFastMode).toHaveBeenCalledWith({
|
||||
sessionFile: hostSessionFile,
|
||||
pluginConfig: undefined,
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("describes active binding preferences", 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",
|
||||
model: "gpt-5.4",
|
||||
serviceTier: "fast",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
handleCodexCommand(
|
||||
createContext("binding", sessionFile, {
|
||||
getCurrentConversationBinding: async () => ({
|
||||
bindingId: "binding-1",
|
||||
pluginId: "codex",
|
||||
pluginRoot: "/plugin",
|
||||
channel: "test",
|
||||
accountId: "default",
|
||||
conversationId: "conversation",
|
||||
boundAt: 1,
|
||||
data: {
|
||||
kind: "codex-app-server-session",
|
||||
version: 1,
|
||||
sessionFile,
|
||||
workspaceDir: "/repo",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{
|
||||
deps: createDeps({
|
||||
readCodexConversationActiveTurn: vi.fn(() => ({
|
||||
sessionFile,
|
||||
threadId: "thread-123",
|
||||
turnId: "turn-1",
|
||||
})),
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({
|
||||
text: [
|
||||
"Codex conversation binding:",
|
||||
"- Thread: thread-123",
|
||||
"- Workspace: /repo",
|
||||
"- Model: gpt-5.4",
|
||||
"- Fast: on",
|
||||
"- Permissions: full access",
|
||||
"- Active run: turn-1",
|
||||
`- Session: ${sessionFile}`,
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
OpenClawPluginCommandDefinition,
|
||||
PluginCommandContext,
|
||||
PluginCommandResult,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import type { CodexCommandDeps } from "./command-handlers.js";
|
||||
|
||||
@@ -20,7 +21,7 @@ export function createCodexCommand(options: {
|
||||
export async function handleCodexCommand(
|
||||
ctx: PluginCommandContext,
|
||||
options: { pluginConfig?: unknown; deps?: Partial<CodexCommandDeps> } = {},
|
||||
): Promise<{ text: string }> {
|
||||
): Promise<PluginCommandResult> {
|
||||
const { handleCodexSubcommand } = await import("./command-handlers.js");
|
||||
return await handleCodexSubcommand(ctx, options);
|
||||
}
|
||||
|
||||
72
extensions/codex/src/conversation-binding-data.ts
Normal file
72
extensions/codex/src/conversation-binding-data.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import process from "node:process";
|
||||
import type { PluginConversationBinding } from "openclaw/plugin-sdk/plugin-entry";
|
||||
|
||||
const BINDING_DATA_VERSION = 1;
|
||||
|
||||
export type CodexConversationBindingData = {
|
||||
kind: "codex-app-server-session";
|
||||
version: 1;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
};
|
||||
|
||||
export function createCodexConversationBindingData(params: {
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
}): CodexConversationBindingData {
|
||||
return {
|
||||
kind: "codex-app-server-session",
|
||||
version: BINDING_DATA_VERSION,
|
||||
sessionFile: params.sessionFile,
|
||||
workspaceDir: params.workspaceDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function readCodexConversationBindingData(
|
||||
binding: PluginConversationBinding | null | undefined,
|
||||
): CodexConversationBindingData | undefined {
|
||||
const data = binding?.data;
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||||
return undefined;
|
||||
}
|
||||
return readCodexConversationBindingDataRecord(data);
|
||||
}
|
||||
|
||||
export function readCodexConversationBindingDataRecord(
|
||||
data: Record<string, unknown>,
|
||||
): CodexConversationBindingData | undefined {
|
||||
if (
|
||||
data.kind !== "codex-app-server-session" ||
|
||||
data.version !== BINDING_DATA_VERSION ||
|
||||
typeof data.sessionFile !== "string" ||
|
||||
!data.sessionFile.trim()
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
kind: "codex-app-server-session",
|
||||
version: BINDING_DATA_VERSION,
|
||||
sessionFile: data.sessionFile,
|
||||
workspaceDir:
|
||||
typeof data.workspaceDir === "string" && data.workspaceDir.trim()
|
||||
? data.workspaceDir
|
||||
: process.cwd(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCodexDefaultWorkspaceDir(pluginConfig: unknown): string {
|
||||
const appServer = readRecord(readRecord(pluginConfig)?.appServer);
|
||||
const configured = readString(appServer, "defaultWorkspaceDir");
|
||||
return configured ?? process.cwd();
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown> | undefined, key: string) {
|
||||
const value = record?.[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
43
extensions/codex/src/conversation-binding.test.ts
Normal file
43
extensions/codex/src/conversation-binding.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
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 { handleCodexConversationBindingResolved } from "./conversation-binding.js";
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
describe("codex conversation 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("clears the Codex app-server sidecar when a pending bind is denied", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const sidecar = `${sessionFile}.codex-app-server.json`;
|
||||
await fs.writeFile(sidecar, JSON.stringify({ schemaVersion: 1, threadId: "thread-1" }));
|
||||
|
||||
await handleCodexConversationBindingResolved({
|
||||
status: "denied",
|
||||
decision: "deny",
|
||||
request: {
|
||||
data: {
|
||||
kind: "codex-app-server-session",
|
||||
version: 1,
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
},
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "channel:1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(fs.stat(sidecar)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
});
|
||||
357
extensions/codex/src/conversation-binding.ts
Normal file
357
extensions/codex/src/conversation-binding.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type {
|
||||
PluginConversationBindingResolvedEvent,
|
||||
PluginHookInboundClaimContext,
|
||||
PluginHookInboundClaimEvent,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
|
||||
import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
|
||||
import {
|
||||
codexSandboxPolicyForTurn,
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
} from "./app-server/config.js";
|
||||
import {
|
||||
type CodexThreadResumeResponse,
|
||||
type CodexThreadStartResponse,
|
||||
type CodexTurnStartResponse,
|
||||
type JsonValue,
|
||||
} from "./app-server/protocol.js";
|
||||
import {
|
||||
clearCodexAppServerBinding,
|
||||
readCodexAppServerBinding,
|
||||
writeCodexAppServerBinding,
|
||||
} from "./app-server/session-binding.js";
|
||||
import { getSharedCodexAppServerClient } from "./app-server/shared-client.js";
|
||||
import {
|
||||
createCodexConversationBindingData,
|
||||
readCodexConversationBindingData,
|
||||
readCodexConversationBindingDataRecord,
|
||||
resolveCodexDefaultWorkspaceDir,
|
||||
type CodexConversationBindingData,
|
||||
} from "./conversation-binding-data.js";
|
||||
import { trackCodexConversationActiveTurn } from "./conversation-control.js";
|
||||
import { createCodexConversationTurnCollector } from "./conversation-turn-collector.js";
|
||||
import { buildCodexConversationTurnInput } from "./conversation-turn-input.js";
|
||||
|
||||
const DEFAULT_BOUND_TURN_TIMEOUT_MS = 20 * 60_000;
|
||||
|
||||
export {
|
||||
createCodexConversationBindingData,
|
||||
readCodexConversationBindingData,
|
||||
readCodexConversationBindingDataRecord,
|
||||
resolveCodexDefaultWorkspaceDir,
|
||||
type CodexConversationBindingData,
|
||||
} from "./conversation-binding-data.js";
|
||||
|
||||
type CodexConversationRunOptions = {
|
||||
pluginConfig?: unknown;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type CodexConversationStartParams = {
|
||||
pluginConfig?: unknown;
|
||||
sessionFile: string;
|
||||
workspaceDir?: string;
|
||||
threadId?: string;
|
||||
model?: string;
|
||||
modelProvider?: string;
|
||||
};
|
||||
|
||||
type BoundTurnResult = {
|
||||
reply: ReplyPayload;
|
||||
};
|
||||
|
||||
type CodexConversationGlobalState = {
|
||||
queues: Map<string, Promise<void>>;
|
||||
};
|
||||
|
||||
const CODEX_CONVERSATION_GLOBAL_STATE = Symbol.for("openclaw.codex.conversationBinding");
|
||||
|
||||
function getGlobalState(): CodexConversationGlobalState {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
[CODEX_CONVERSATION_GLOBAL_STATE]?: CodexConversationGlobalState;
|
||||
};
|
||||
globalState[CODEX_CONVERSATION_GLOBAL_STATE] ??= { queues: new Map() };
|
||||
return globalState[CODEX_CONVERSATION_GLOBAL_STATE];
|
||||
}
|
||||
|
||||
export async function startCodexConversationThread(
|
||||
params: CodexConversationStartParams,
|
||||
): Promise<CodexConversationBindingData> {
|
||||
const workspaceDir =
|
||||
params.workspaceDir?.trim() || resolveCodexDefaultWorkspaceDir(params.pluginConfig);
|
||||
if (params.threadId?.trim()) {
|
||||
await attachExistingThread({
|
||||
pluginConfig: params.pluginConfig,
|
||||
sessionFile: params.sessionFile,
|
||||
threadId: params.threadId.trim(),
|
||||
workspaceDir,
|
||||
model: params.model,
|
||||
modelProvider: params.modelProvider,
|
||||
});
|
||||
} else {
|
||||
await createThread({
|
||||
pluginConfig: params.pluginConfig,
|
||||
sessionFile: params.sessionFile,
|
||||
workspaceDir,
|
||||
model: params.model,
|
||||
modelProvider: params.modelProvider,
|
||||
});
|
||||
}
|
||||
return createCodexConversationBindingData({
|
||||
sessionFile: params.sessionFile,
|
||||
workspaceDir,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleCodexConversationInboundClaim(
|
||||
event: PluginHookInboundClaimEvent,
|
||||
ctx: PluginHookInboundClaimContext,
|
||||
options: CodexConversationRunOptions = {},
|
||||
): Promise<{ handled: boolean; reply?: ReplyPayload } | undefined> {
|
||||
const data = readCodexConversationBindingData(ctx.pluginBinding);
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
const prompt = (event.bodyForAgent ?? event.content ?? "").trim();
|
||||
if (!prompt) {
|
||||
return { handled: true };
|
||||
}
|
||||
try {
|
||||
const result = await enqueueBoundTurn(data.sessionFile, () =>
|
||||
runBoundTurn({
|
||||
data,
|
||||
prompt,
|
||||
event,
|
||||
pluginConfig: options.pluginConfig,
|
||||
timeoutMs: options.timeoutMs,
|
||||
}),
|
||||
);
|
||||
return { handled: true, reply: result.reply };
|
||||
} catch (error) {
|
||||
return {
|
||||
handled: true,
|
||||
reply: {
|
||||
text: `Codex app-server turn failed: ${formatErrorMessage(error)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleCodexConversationBindingResolved(
|
||||
event: PluginConversationBindingResolvedEvent,
|
||||
): Promise<void> {
|
||||
if (event.status !== "denied") {
|
||||
return;
|
||||
}
|
||||
const data = readCodexConversationBindingDataRecord(event.request.data ?? {});
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
await clearCodexAppServerBinding(data.sessionFile);
|
||||
}
|
||||
|
||||
async function attachExistingThread(params: {
|
||||
pluginConfig?: unknown;
|
||||
sessionFile: string;
|
||||
threadId: string;
|
||||
workspaceDir: string;
|
||||
model?: string;
|
||||
modelProvider?: string;
|
||||
}): Promise<void> {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
|
||||
const client = await getSharedCodexAppServerClient({
|
||||
startOptions: runtime.start,
|
||||
timeoutMs: runtime.requestTimeoutMs,
|
||||
});
|
||||
const response: CodexThreadResumeResponse = await client.request(
|
||||
CODEX_CONTROL_METHODS.resumeThread,
|
||||
{
|
||||
threadId: params.threadId,
|
||||
...(params.model ? { model: params.model } : {}),
|
||||
...(params.modelProvider ? { modelProvider: params.modelProvider } : {}),
|
||||
approvalPolicy: runtime.approvalPolicy,
|
||||
approvalsReviewer: runtime.approvalsReviewer,
|
||||
sandbox: runtime.sandbox,
|
||||
...(runtime.serviceTier ? { serviceTier: runtime.serviceTier } : {}),
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
{ timeoutMs: runtime.requestTimeoutMs },
|
||||
);
|
||||
const thread = response.thread;
|
||||
await writeCodexAppServerBinding(params.sessionFile, {
|
||||
threadId: thread.id,
|
||||
cwd: thread.cwd ?? params.workspaceDir,
|
||||
model: response.model ?? params.model,
|
||||
modelProvider: response.modelProvider ?? params.modelProvider,
|
||||
approvalPolicy: runtime.approvalPolicy,
|
||||
sandbox: runtime.sandbox,
|
||||
serviceTier: runtime.serviceTier,
|
||||
});
|
||||
}
|
||||
|
||||
async function createThread(params: {
|
||||
pluginConfig?: unknown;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
model?: string;
|
||||
modelProvider?: string;
|
||||
}): Promise<void> {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
|
||||
const client = await getSharedCodexAppServerClient({
|
||||
startOptions: runtime.start,
|
||||
timeoutMs: runtime.requestTimeoutMs,
|
||||
});
|
||||
const response: CodexThreadStartResponse = await client.request(
|
||||
"thread/start",
|
||||
{
|
||||
cwd: params.workspaceDir,
|
||||
...(params.model ? { model: params.model } : {}),
|
||||
...(params.modelProvider ? { modelProvider: params.modelProvider } : {}),
|
||||
approvalPolicy: runtime.approvalPolicy,
|
||||
approvalsReviewer: runtime.approvalsReviewer,
|
||||
sandbox: runtime.sandbox,
|
||||
...(runtime.serviceTier ? { serviceTier: runtime.serviceTier } : {}),
|
||||
developerInstructions:
|
||||
"This Codex thread is bound to an OpenClaw conversation. Answer normally; OpenClaw will deliver your final response back to the conversation.",
|
||||
experimentalRawEvents: true,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
{ timeoutMs: runtime.requestTimeoutMs },
|
||||
);
|
||||
await writeCodexAppServerBinding(params.sessionFile, {
|
||||
threadId: response.thread.id,
|
||||
cwd: response.thread.cwd ?? params.workspaceDir,
|
||||
model: response.model ?? params.model,
|
||||
modelProvider: response.modelProvider ?? params.modelProvider,
|
||||
approvalPolicy: runtime.approvalPolicy,
|
||||
sandbox: runtime.sandbox,
|
||||
serviceTier: runtime.serviceTier,
|
||||
});
|
||||
}
|
||||
|
||||
async function runBoundTurn(params: {
|
||||
data: CodexConversationBindingData;
|
||||
prompt: string;
|
||||
event: PluginHookInboundClaimEvent;
|
||||
pluginConfig?: unknown;
|
||||
timeoutMs?: number;
|
||||
}): Promise<BoundTurnResult> {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
|
||||
const binding = await readCodexAppServerBinding(params.data.sessionFile);
|
||||
const threadId = binding?.threadId;
|
||||
if (!threadId) {
|
||||
throw new Error("bound Codex conversation has no thread binding");
|
||||
}
|
||||
|
||||
const client = await getSharedCodexAppServerClient({
|
||||
startOptions: runtime.start,
|
||||
timeoutMs: runtime.requestTimeoutMs,
|
||||
authProfileId: binding.authProfileId,
|
||||
});
|
||||
const collector = createCodexConversationTurnCollector(threadId);
|
||||
const notificationCleanup = client.addNotificationHandler((notification) =>
|
||||
collector.handleNotification(notification),
|
||||
);
|
||||
const requestCleanup = client.addRequestHandler(
|
||||
async (request): Promise<JsonValue | undefined> => {
|
||||
if (request.method === "item/tool/call") {
|
||||
return {
|
||||
contentItems: [
|
||||
{
|
||||
type: "inputText",
|
||||
text: "OpenClaw native Codex conversation binding does not expose dynamic OpenClaw tools yet.",
|
||||
},
|
||||
],
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
request.method === "item/commandExecution/requestApproval" ||
|
||||
request.method === "item/fileChange/requestApproval"
|
||||
) {
|
||||
return {
|
||||
decision: "decline",
|
||||
reason:
|
||||
"OpenClaw native Codex conversation binding cannot route interactive approvals yet; use the Codex harness or explicit /acp spawn codex for that workflow.",
|
||||
};
|
||||
}
|
||||
if (request.method === "item/permissions/requestApproval") {
|
||||
return { permissions: {}, scope: "turn" };
|
||||
}
|
||||
if (request.method.includes("requestApproval")) {
|
||||
return {
|
||||
decision: "decline",
|
||||
reason:
|
||||
"OpenClaw native Codex conversation binding cannot route interactive approvals yet; use the Codex harness or explicit /acp spawn codex for that workflow.",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
try {
|
||||
const response: CodexTurnStartResponse = await client.request(
|
||||
"turn/start",
|
||||
{
|
||||
threadId,
|
||||
input: buildCodexConversationTurnInput({ prompt: params.prompt, event: params.event }),
|
||||
cwd: binding.cwd || params.data.workspaceDir,
|
||||
approvalPolicy: binding.approvalPolicy ?? runtime.approvalPolicy,
|
||||
approvalsReviewer: runtime.approvalsReviewer,
|
||||
sandboxPolicy: codexSandboxPolicyForTurn(
|
||||
binding.sandbox ?? runtime.sandbox,
|
||||
binding.cwd || params.data.workspaceDir,
|
||||
),
|
||||
...(binding.model ? { model: binding.model } : {}),
|
||||
...((binding.serviceTier ?? runtime.serviceTier)
|
||||
? { serviceTier: binding.serviceTier ?? runtime.serviceTier }
|
||||
: {}),
|
||||
},
|
||||
{ timeoutMs: runtime.requestTimeoutMs },
|
||||
);
|
||||
const turnId = response.turn.id;
|
||||
const activeCleanup = trackCodexConversationActiveTurn({
|
||||
sessionFile: params.data.sessionFile,
|
||||
threadId,
|
||||
turnId,
|
||||
});
|
||||
collector.setTurnId(turnId);
|
||||
const completion = await collector
|
||||
.wait({
|
||||
timeoutMs: params.timeoutMs ?? DEFAULT_BOUND_TURN_TIMEOUT_MS,
|
||||
})
|
||||
.finally(activeCleanup);
|
||||
const replyText = completion.replyText.trim();
|
||||
return {
|
||||
reply: {
|
||||
text: replyText || "Codex completed without a text reply.",
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
notificationCleanup();
|
||||
requestCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueBoundTurn<T>(key: string, run: () => Promise<T>): Promise<T> {
|
||||
const state = getGlobalState();
|
||||
const previous = state.queues.get(key) ?? Promise.resolve();
|
||||
const next = previous.then(run, run);
|
||||
const queued = next.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
state.queues.set(key, queued);
|
||||
void next.finally(() => {
|
||||
if (state.queues.get(key) === queued) {
|
||||
state.queues.delete(key);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resetQueues() {
|
||||
getGlobalState().queues.clear();
|
||||
},
|
||||
};
|
||||
50
extensions/codex/src/conversation-control.test.ts
Normal file
50
extensions/codex/src/conversation-control.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
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 {
|
||||
readCodexAppServerBinding,
|
||||
writeCodexAppServerBinding,
|
||||
} from "./app-server/session-binding.js";
|
||||
import {
|
||||
setCodexConversationFastMode,
|
||||
setCodexConversationPermissions,
|
||||
} from "./conversation-control.js";
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
describe("codex conversation controls", () => {
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-control-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("persists fast mode and permissions for later bound turns", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-1",
|
||||
cwd: tempDir,
|
||||
model: "gpt-5.4",
|
||||
modelProvider: "openai",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
});
|
||||
|
||||
await expect(setCodexConversationFastMode({ sessionFile, enabled: true })).resolves.toBe(
|
||||
"Codex fast mode enabled.",
|
||||
);
|
||||
await expect(setCodexConversationPermissions({ sessionFile, mode: "default" })).resolves.toBe(
|
||||
"Codex permissions set to default.",
|
||||
);
|
||||
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-1",
|
||||
serviceTier: "fast",
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
});
|
||||
});
|
||||
});
|
||||
261
extensions/codex/src/conversation-control.ts
Normal file
261
extensions/codex/src/conversation-control.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
|
||||
import {
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
type CodexAppServerApprovalPolicy,
|
||||
type CodexAppServerSandboxMode,
|
||||
} from "./app-server/config.js";
|
||||
import type { CodexServiceTier, CodexThreadResumeResponse } from "./app-server/protocol.js";
|
||||
import {
|
||||
readCodexAppServerBinding,
|
||||
writeCodexAppServerBinding,
|
||||
} from "./app-server/session-binding.js";
|
||||
import { getSharedCodexAppServerClient } from "./app-server/shared-client.js";
|
||||
|
||||
type ActiveTurn = {
|
||||
sessionFile: string;
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
};
|
||||
|
||||
type PermissionsMode = "default" | "yolo";
|
||||
|
||||
const CODEX_CONVERSATION_CONTROL_STATE = Symbol.for("openclaw.codex.conversationControl");
|
||||
|
||||
function getActiveTurns(): Map<string, ActiveTurn> {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
[CODEX_CONVERSATION_CONTROL_STATE]?: Map<string, ActiveTurn>;
|
||||
};
|
||||
globalState[CODEX_CONVERSATION_CONTROL_STATE] ??= new Map();
|
||||
return globalState[CODEX_CONVERSATION_CONTROL_STATE];
|
||||
}
|
||||
|
||||
export function trackCodexConversationActiveTurn(active: ActiveTurn): () => void {
|
||||
const activeTurns = getActiveTurns();
|
||||
activeTurns.set(active.sessionFile, active);
|
||||
return () => {
|
||||
const current = activeTurns.get(active.sessionFile);
|
||||
if (current?.turnId === active.turnId) {
|
||||
activeTurns.delete(active.sessionFile);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function readCodexConversationActiveTurn(sessionFile: string): ActiveTurn | undefined {
|
||||
return getActiveTurns().get(sessionFile);
|
||||
}
|
||||
|
||||
export async function stopCodexConversationTurn(params: {
|
||||
sessionFile: string;
|
||||
pluginConfig?: unknown;
|
||||
}): Promise<{ stopped: boolean; message: string }> {
|
||||
const active = readCodexConversationActiveTurn(params.sessionFile);
|
||||
if (!active) {
|
||||
return { stopped: false, message: "No active Codex run to stop." };
|
||||
}
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
|
||||
const binding = await readCodexAppServerBinding(params.sessionFile);
|
||||
const client = await getSharedCodexAppServerClient({
|
||||
startOptions: runtime.start,
|
||||
timeoutMs: runtime.requestTimeoutMs,
|
||||
authProfileId: binding?.authProfileId,
|
||||
});
|
||||
await client.request(
|
||||
"turn/interrupt",
|
||||
{
|
||||
threadId: active.threadId,
|
||||
turnId: active.turnId,
|
||||
},
|
||||
{ timeoutMs: runtime.requestTimeoutMs },
|
||||
);
|
||||
return { stopped: true, message: "Codex stop requested." };
|
||||
}
|
||||
|
||||
export async function steerCodexConversationTurn(params: {
|
||||
sessionFile: string;
|
||||
message: string;
|
||||
pluginConfig?: unknown;
|
||||
}): Promise<{ steered: boolean; message: string }> {
|
||||
const active = readCodexConversationActiveTurn(params.sessionFile);
|
||||
const text = params.message.trim();
|
||||
if (!text) {
|
||||
return { steered: false, message: "Usage: /codex steer <message>" };
|
||||
}
|
||||
if (!active) {
|
||||
return { steered: false, message: "No active Codex run to steer." };
|
||||
}
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
|
||||
const binding = await readCodexAppServerBinding(params.sessionFile);
|
||||
const client = await getSharedCodexAppServerClient({
|
||||
startOptions: runtime.start,
|
||||
timeoutMs: runtime.requestTimeoutMs,
|
||||
authProfileId: binding?.authProfileId,
|
||||
});
|
||||
await client.request(
|
||||
"turn/steer",
|
||||
{
|
||||
threadId: active.threadId,
|
||||
expectedTurnId: active.turnId,
|
||||
input: [{ type: "text", text, text_elements: [] }],
|
||||
},
|
||||
{ timeoutMs: runtime.requestTimeoutMs },
|
||||
);
|
||||
return { steered: true, message: "Sent steer message to Codex." };
|
||||
}
|
||||
|
||||
export async function setCodexConversationModel(params: {
|
||||
sessionFile: string;
|
||||
model: string;
|
||||
pluginConfig?: unknown;
|
||||
}): Promise<string> {
|
||||
const model = params.model.trim();
|
||||
if (!model) {
|
||||
return "Usage: /codex model <model>";
|
||||
}
|
||||
const binding = await requireThreadBinding(params.sessionFile);
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
|
||||
const response = await resumeThreadWithOverrides({
|
||||
pluginConfig: params.pluginConfig,
|
||||
threadId: binding.threadId,
|
||||
authProfileId: binding.authProfileId,
|
||||
model,
|
||||
});
|
||||
await writeCodexAppServerBinding(params.sessionFile, {
|
||||
...binding,
|
||||
cwd: response.thread.cwd ?? binding.cwd,
|
||||
model: response.model ?? model,
|
||||
modelProvider: response.modelProvider ?? binding.modelProvider,
|
||||
approvalPolicy: binding.approvalPolicy,
|
||||
sandbox: binding.sandbox,
|
||||
serviceTier: binding.serviceTier ?? runtime.serviceTier,
|
||||
});
|
||||
return `Codex model set to ${response.model ?? model}.`;
|
||||
}
|
||||
|
||||
export async function setCodexConversationFastMode(params: {
|
||||
sessionFile: string;
|
||||
enabled?: boolean;
|
||||
pluginConfig?: unknown;
|
||||
}): Promise<string> {
|
||||
const binding = await requireThreadBinding(params.sessionFile);
|
||||
if (params.enabled == null) {
|
||||
return `Codex fast mode: ${binding.serviceTier === "fast" ? "on" : "off"}.`;
|
||||
}
|
||||
const serviceTier: CodexServiceTier = params.enabled ? "fast" : "flex";
|
||||
// Fast mode is sent on each later turn; do not require Codex to accept an
|
||||
// immediate thread/resume control request just to persist the preference.
|
||||
await writeCodexAppServerBinding(params.sessionFile, {
|
||||
...binding,
|
||||
serviceTier,
|
||||
});
|
||||
return `Codex fast mode ${params.enabled ? "enabled" : "disabled"}.`;
|
||||
}
|
||||
|
||||
export async function setCodexConversationPermissions(params: {
|
||||
sessionFile: string;
|
||||
mode?: PermissionsMode;
|
||||
pluginConfig?: unknown;
|
||||
}): Promise<string> {
|
||||
const binding = await requireThreadBinding(params.sessionFile);
|
||||
if (!params.mode) {
|
||||
return `Codex permissions: ${formatPermissionsMode(binding)}.`;
|
||||
}
|
||||
const policy = permissionsForMode(params.mode);
|
||||
// Native bound turns pass these settings at turn/start time, so this command
|
||||
// can update the local binding even when app-server resume overrides fail.
|
||||
await writeCodexAppServerBinding(params.sessionFile, {
|
||||
...binding,
|
||||
approvalPolicy: policy.approvalPolicy,
|
||||
sandbox: policy.sandbox,
|
||||
});
|
||||
return `Codex permissions set to ${params.mode === "yolo" ? "full access" : "default"}.`;
|
||||
}
|
||||
|
||||
export function parseCodexFastModeArg(arg: string | undefined): boolean | undefined {
|
||||
const normalized = arg?.trim().toLowerCase();
|
||||
if (!normalized || normalized === "status") {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized === "on" || normalized === "true" || normalized === "fast") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "off" || normalized === "false" || normalized === "flex") {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseCodexPermissionsModeArg(arg: string | undefined): PermissionsMode | undefined {
|
||||
const normalized = arg?.trim().toLowerCase();
|
||||
if (!normalized || normalized === "status") {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized === "yolo" || normalized === "full" || normalized === "full-access") {
|
||||
return "yolo";
|
||||
}
|
||||
if (normalized === "default" || normalized === "guardian") {
|
||||
return "default";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function formatPermissionsMode(binding: {
|
||||
approvalPolicy?: CodexAppServerApprovalPolicy;
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
}): string {
|
||||
return binding.approvalPolicy === "never" && binding.sandbox === "danger-full-access"
|
||||
? "full access"
|
||||
: "default";
|
||||
}
|
||||
|
||||
async function requireThreadBinding(sessionFile: string) {
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
if (!binding?.threadId) {
|
||||
throw new Error("No Codex thread is attached to this OpenClaw session yet.");
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
async function resumeThreadWithOverrides(params: {
|
||||
pluginConfig?: unknown;
|
||||
threadId: string;
|
||||
authProfileId?: string;
|
||||
model?: string;
|
||||
approvalPolicy?: CodexAppServerApprovalPolicy;
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
serviceTier?: CodexServiceTier;
|
||||
}): Promise<CodexThreadResumeResponse> {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
|
||||
const client = await getSharedCodexAppServerClient({
|
||||
startOptions: runtime.start,
|
||||
timeoutMs: runtime.requestTimeoutMs,
|
||||
authProfileId: params.authProfileId,
|
||||
});
|
||||
return await client.request(
|
||||
CODEX_CONTROL_METHODS.resumeThread,
|
||||
{
|
||||
threadId: params.threadId,
|
||||
...(params.model ? { model: params.model } : {}),
|
||||
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
|
||||
sandbox: params.sandbox ?? runtime.sandbox,
|
||||
approvalsReviewer: runtime.approvalsReviewer,
|
||||
...(params.serviceTier ? { serviceTier: params.serviceTier } : {}),
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
{ timeoutMs: runtime.requestTimeoutMs },
|
||||
);
|
||||
}
|
||||
|
||||
function permissionsForMode(mode: PermissionsMode): {
|
||||
approvalPolicy: CodexAppServerApprovalPolicy;
|
||||
sandbox: CodexAppServerSandboxMode;
|
||||
} {
|
||||
return mode === "yolo"
|
||||
? { approvalPolicy: "never", sandbox: "danger-full-access" }
|
||||
: { approvalPolicy: "on-request", sandbox: "workspace-write" };
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resetActiveTurns() {
|
||||
getActiveTurns().clear();
|
||||
},
|
||||
};
|
||||
103
extensions/codex/src/conversation-turn-collector.test.ts
Normal file
103
extensions/codex/src/conversation-turn-collector.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createCodexConversationTurnCollector } from "./conversation-turn-collector.js";
|
||||
|
||||
describe("codex conversation turn collector", () => {
|
||||
it("collects streamed assistant deltas for the active turn", async () => {
|
||||
const collector = createCodexConversationTurnCollector("thread-1");
|
||||
collector.setTurnId("turn-1");
|
||||
const completion = collector.wait({ timeoutMs: 1_000 });
|
||||
|
||||
collector.handleNotification({
|
||||
method: "item/agentMessage/delta",
|
||||
params: { threadId: "thread-1", turnId: "turn-1", itemId: "item-1", delta: "hello " },
|
||||
});
|
||||
collector.handleNotification({
|
||||
method: "item/agentMessage/delta",
|
||||
params: { threadId: "thread-1", turnId: "turn-1", itemId: "item-1", delta: "world" },
|
||||
});
|
||||
collector.handleNotification({
|
||||
method: "turn/completed",
|
||||
params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", items: [] } },
|
||||
});
|
||||
|
||||
await expect(completion).resolves.toEqual({ replyText: "hello world" });
|
||||
});
|
||||
|
||||
it("uses completed agent message items when deltas are absent", async () => {
|
||||
const collector = createCodexConversationTurnCollector("thread-1");
|
||||
collector.setTurnId("turn-1");
|
||||
const completion = collector.wait({ timeoutMs: 1_000 });
|
||||
|
||||
collector.handleNotification({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item: { type: "agentMessage", id: "item-1", text: "final answer" },
|
||||
},
|
||||
});
|
||||
collector.handleNotification({
|
||||
method: "turn/completed",
|
||||
params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", items: [] } },
|
||||
});
|
||||
|
||||
await expect(completion).resolves.toEqual({ replyText: "final answer" });
|
||||
});
|
||||
|
||||
it("ignores notifications for other threads or turns", async () => {
|
||||
const collector = createCodexConversationTurnCollector("thread-1");
|
||||
collector.setTurnId("turn-1");
|
||||
const completion = collector.wait({ timeoutMs: 1_000 });
|
||||
|
||||
collector.handleNotification({
|
||||
method: "item/agentMessage/delta",
|
||||
params: { threadId: "thread-2", turnId: "turn-1", itemId: "wrong", delta: "wrong" },
|
||||
});
|
||||
collector.handleNotification({
|
||||
method: "item/agentMessage/delta",
|
||||
params: { threadId: "thread-1", turnId: "turn-2", itemId: "wrong", delta: "wrong" },
|
||||
});
|
||||
collector.handleNotification({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turn: {
|
||||
id: "turn-1",
|
||||
status: "completed",
|
||||
items: [{ type: "agentMessage", id: "item-1", text: "right" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(completion).resolves.toEqual({ replyText: "right" });
|
||||
});
|
||||
|
||||
it("rejects failed turns with the app-server error message", async () => {
|
||||
const collector = createCodexConversationTurnCollector("thread-1");
|
||||
collector.setTurnId("turn-1");
|
||||
const completion = collector.wait({ timeoutMs: 1_000 });
|
||||
|
||||
collector.handleNotification({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turn: { id: "turn-1", status: "failed", error: { message: "model exploded" }, items: [] },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(completion).rejects.toThrow("model exploded");
|
||||
});
|
||||
|
||||
it("times out when the app-server never completes the turn", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const collector = createCodexConversationTurnCollector("thread-1");
|
||||
const completion = collector.wait({ timeoutMs: 100 });
|
||||
const assertion = expect(completion).rejects.toThrow("codex app-server bound turn timed out");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await assertion;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
162
extensions/codex/src/conversation-turn-collector.ts
Normal file
162
extensions/codex/src/conversation-turn-collector.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
isJsonObject,
|
||||
type CodexServerNotification,
|
||||
type JsonObject,
|
||||
} from "./app-server/protocol.js";
|
||||
|
||||
export type CodexConversationTurnCollector = ReturnType<
|
||||
typeof createCodexConversationTurnCollector
|
||||
>;
|
||||
|
||||
export function createCodexConversationTurnCollector(threadId: string) {
|
||||
let turnId: string | undefined;
|
||||
let completed = false;
|
||||
let failedError: string | undefined;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const assistantTextByItem = new Map<string, string>();
|
||||
const assistantOrder: string[] = [];
|
||||
let resolveCompletion: ((value: { replyText: string }) => void) | undefined;
|
||||
let rejectCompletion: ((error: Error) => void) | undefined;
|
||||
|
||||
const rememberItem = (itemId: string) => {
|
||||
if (!assistantOrder.includes(itemId)) {
|
||||
assistantOrder.push(itemId);
|
||||
}
|
||||
};
|
||||
const collectReplyText = (): string => {
|
||||
const texts = assistantOrder
|
||||
.map((itemId) => assistantTextByItem.get(itemId)?.trim())
|
||||
.filter((text): text is string => Boolean(text));
|
||||
return texts.at(-1) ?? "";
|
||||
};
|
||||
const clearWaitState = () => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
}
|
||||
resolveCompletion = undefined;
|
||||
rejectCompletion = undefined;
|
||||
};
|
||||
const finish = () => {
|
||||
if (completed) {
|
||||
return;
|
||||
}
|
||||
completed = true;
|
||||
if (failedError) {
|
||||
rejectCompletion?.(new Error(failedError));
|
||||
} else {
|
||||
resolveCompletion?.({ replyText: collectReplyText() });
|
||||
}
|
||||
clearWaitState();
|
||||
};
|
||||
|
||||
return {
|
||||
setTurnId(nextTurnId: string) {
|
||||
turnId = nextTurnId;
|
||||
},
|
||||
handleNotification(notification: CodexServerNotification) {
|
||||
const params = isJsonObject(notification.params) ? notification.params : undefined;
|
||||
if (!params || !isNotificationForTurn(params, threadId, turnId)) {
|
||||
return;
|
||||
}
|
||||
if (notification.method === "item/agentMessage/delta") {
|
||||
const itemId = readString(params, "itemId") ?? readString(params, "id") ?? "assistant";
|
||||
const delta = readTextString(params, "delta");
|
||||
if (!delta) {
|
||||
return;
|
||||
}
|
||||
rememberItem(itemId);
|
||||
assistantTextByItem.set(itemId, `${assistantTextByItem.get(itemId) ?? ""}${delta}`);
|
||||
return;
|
||||
}
|
||||
if (notification.method === "item/completed") {
|
||||
const item = isJsonObject(params.item) ? params.item : undefined;
|
||||
if (item?.type === "agentMessage") {
|
||||
const itemId = readString(item, "id") ?? readString(params, "itemId") ?? "assistant";
|
||||
const text = readTextString(item, "text");
|
||||
if (text) {
|
||||
rememberItem(itemId);
|
||||
assistantTextByItem.set(itemId, text);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (notification.method === "turn/completed") {
|
||||
const turn = isJsonObject(params.turn) ? params.turn : undefined;
|
||||
const status = readString(turn, "status");
|
||||
if (status === "failed") {
|
||||
failedError =
|
||||
readString(readRecord(turn?.error), "message") ?? "codex app-server turn failed";
|
||||
}
|
||||
const items = Array.isArray(turn?.items) ? turn.items : [];
|
||||
for (const item of items) {
|
||||
if (!isJsonObject(item) || item.type !== "agentMessage") {
|
||||
continue;
|
||||
}
|
||||
const itemId = readString(item, "id") ?? `assistant-${assistantOrder.length + 1}`;
|
||||
const text = readTextString(item, "text");
|
||||
if (text) {
|
||||
rememberItem(itemId);
|
||||
assistantTextByItem.set(itemId, text);
|
||||
}
|
||||
}
|
||||
finish();
|
||||
}
|
||||
},
|
||||
wait(params: { timeoutMs: number }): Promise<{ replyText: string }> {
|
||||
if (completed) {
|
||||
return failedError
|
||||
? Promise.reject(new Error(failedError))
|
||||
: Promise.resolve({ replyText: collectReplyText() });
|
||||
}
|
||||
return new Promise<{ replyText: string }>((resolve, reject) => {
|
||||
resolveCompletion = resolve;
|
||||
rejectCompletion = reject;
|
||||
timeout = setTimeout(
|
||||
() => {
|
||||
completed = true;
|
||||
reject(new Error("codex app-server bound turn timed out"));
|
||||
clearWaitState();
|
||||
},
|
||||
Math.max(100, params.timeoutMs),
|
||||
);
|
||||
timeout.unref?.();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isNotificationForTurn(
|
||||
params: JsonObject,
|
||||
threadId: string,
|
||||
turnId: string | undefined,
|
||||
): boolean {
|
||||
if (readString(params, "threadId") !== threadId) {
|
||||
return false;
|
||||
}
|
||||
if (!turnId) {
|
||||
return true;
|
||||
}
|
||||
const directTurnId = readString(params, "turnId");
|
||||
if (directTurnId) {
|
||||
return directTurnId === turnId;
|
||||
}
|
||||
const turn = isJsonObject(params.turn) ? params.turn : undefined;
|
||||
return !turn || readString(turn, "id") === turnId;
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown> | JsonObject | undefined, key: string) {
|
||||
const value = record?.[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readTextString(record: Record<string, unknown> | JsonObject | undefined, key: string) {
|
||||
const value = record?.[key];
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
44
extensions/codex/src/conversation-turn-input.test.ts
Normal file
44
extensions/codex/src/conversation-turn-input.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCodexConversationTurnInput } from "./conversation-turn-input.js";
|
||||
|
||||
describe("codex conversation turn input", () => {
|
||||
it("forwards inbound image attachments to Codex app-server", () => {
|
||||
expect(
|
||||
buildCodexConversationTurnInput({
|
||||
prompt: "what is this?",
|
||||
event: {
|
||||
content: "what is this?",
|
||||
channel: "telegram",
|
||||
isGroup: false,
|
||||
metadata: {
|
||||
mediaPaths: ["/tmp/photo.png", "/tmp/readme.txt"],
|
||||
mediaUrls: ["https://example.test/photo.png"],
|
||||
mediaTypes: ["image/png", "text/plain"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{ type: "text", text: "what is this?", text_elements: [] },
|
||||
{ type: "localImage", path: "/tmp/photo.png" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses remote image urls when no local path is available", () => {
|
||||
expect(
|
||||
buildCodexConversationTurnInput({
|
||||
prompt: "look",
|
||||
event: {
|
||||
content: "look",
|
||||
channel: "webchat",
|
||||
isGroup: false,
|
||||
metadata: {
|
||||
mediaUrl: "https://example.test/photo.webp?sig=1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{ type: "text", text: "look", text_elements: [] },
|
||||
{ type: "image", url: "https://example.test/photo.webp?sig=1" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
80
extensions/codex/src/conversation-turn-input.ts
Normal file
80
extensions/codex/src/conversation-turn-input.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import path from "node:path";
|
||||
import type { PluginHookInboundClaimEvent } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import type { CodexUserInput } from "./app-server/protocol.js";
|
||||
|
||||
type InboundMedia = {
|
||||
path?: string;
|
||||
url?: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([".avif", ".gif", ".jpeg", ".jpg", ".png", ".webp"]);
|
||||
|
||||
export function buildCodexConversationTurnInput(params: {
|
||||
prompt: string;
|
||||
event: PluginHookInboundClaimEvent;
|
||||
}): CodexUserInput[] {
|
||||
return [
|
||||
{ type: "text", text: params.prompt, text_elements: [] },
|
||||
...extractInboundMedia(params.event)
|
||||
.map(toCodexImageInput)
|
||||
.filter((item): item is CodexUserInput => item !== undefined),
|
||||
];
|
||||
}
|
||||
|
||||
function extractInboundMedia(event: PluginHookInboundClaimEvent): InboundMedia[] {
|
||||
const metadata = event.metadata ?? {};
|
||||
// OpenClaw channels expose either local staged files or remote URLs. Keep
|
||||
// them separate so Codex can receive the cheaper localImage input when a file
|
||||
// is already present, while still supporting remote-only transports.
|
||||
const paths = readStringArray(metadata.mediaPaths).concat(readStringArray(metadata.mediaPath));
|
||||
const urls = readStringArray(metadata.mediaUrls).concat(readStringArray(metadata.mediaUrl));
|
||||
const mimeTypes = readStringArray(metadata.mediaTypes).concat(
|
||||
readStringArray(metadata.mediaType),
|
||||
);
|
||||
const count = Math.max(paths.length, urls.length, mimeTypes.length);
|
||||
const media: InboundMedia[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
media.push({
|
||||
path: paths[index],
|
||||
url: urls[index],
|
||||
mimeType: mimeTypes[index] ?? mimeTypes[0],
|
||||
});
|
||||
}
|
||||
return media;
|
||||
}
|
||||
|
||||
function toCodexImageInput(media: InboundMedia): CodexUserInput | undefined {
|
||||
if (!isImageMedia(media)) {
|
||||
return undefined;
|
||||
}
|
||||
if (media.path) {
|
||||
return { type: "localImage", path: normalizeFileUrl(media.path) };
|
||||
}
|
||||
return media.url ? { type: "image", url: media.url } : undefined;
|
||||
}
|
||||
|
||||
function isImageMedia(media: InboundMedia): boolean {
|
||||
if (media.mimeType?.toLowerCase().startsWith("image/")) {
|
||||
return true;
|
||||
}
|
||||
const candidate = media.path ?? media.url;
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
return IMAGE_EXTENSIONS.has(path.extname(candidate.split(/[?#]/, 1)[0] ?? "").toLowerCase());
|
||||
}
|
||||
|
||||
function normalizeFileUrl(value: string): string {
|
||||
return value.startsWith("file://") ? new URL(value).pathname : value;
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return [value.trim()];
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
|
||||
}
|
||||
@@ -1452,6 +1452,7 @@
|
||||
"test:docker:live-cli-backend:claude-subscription": "OPENCLAW_LIVE_CLI_BACKEND_AUTH=subscription OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6 OPENCLAW_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG=1 OPENCLAW_LIVE_CLI_BACKEND_MODEL_SWITCH_PROBE=0 OPENCLAW_LIVE_CLI_BACKEND_RESUME_PROBE=1 OPENCLAW_LIVE_CLI_BACKEND_IMAGE_PROBE=0 OPENCLAW_LIVE_CLI_BACKEND_MCP_PROBE=0 bash scripts/test-live-cli-backend-docker.sh",
|
||||
"test:docker:live-cli-backend:codex": "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.5 bash scripts/test-live-cli-backend-docker.sh",
|
||||
"test:docker:live-cli-backend:gemini": "OPENCLAW_LIVE_CLI_BACKEND_MODEL=google-gemini-cli/gemini-3-flash-preview bash scripts/test-live-cli-backend-docker.sh",
|
||||
"test:docker:live-codex-bind": "OPENCLAW_LIVE_CODEX_BIND=1 OPENCLAW_LIVE_CODEX_TEST_FILES=src/gateway/gateway-codex-bind.live.test.ts bash scripts/test-live-codex-harness-docker.sh",
|
||||
"test:docker:live-codex-harness": "bash scripts/test-live-codex-harness-docker.sh",
|
||||
"test:docker:live-gateway": "bash scripts/test-live-gateway-models-docker.sh",
|
||||
"test:docker:live-gateway:claude": "OPENCLAW_LIVE_GATEWAY_PROVIDERS=claude-cli OPENCLAW_LIVE_GATEWAY_MODELS=claude-cli/claude-sonnet-4-6 bash scripts/test-live-gateway-models-docker.sh",
|
||||
|
||||
@@ -5,6 +5,22 @@ const codexRepo = process.env.OPENCLAW_CODEX_REPO
|
||||
? path.resolve(process.env.OPENCLAW_CODEX_REPO)
|
||||
: path.resolve(process.cwd(), "../codex");
|
||||
const schemaRoot = path.join(codexRepo, "codex-rs/app-server-protocol/schema/typescript");
|
||||
const sourceSchemaRoot = path.join(codexRepo, "codex-rs/app-server-protocol/schema");
|
||||
const generatedRoot = path.resolve(
|
||||
process.cwd(),
|
||||
"extensions/codex/src/app-server/protocol-generated",
|
||||
);
|
||||
|
||||
const selectedJsonSchemas = [
|
||||
"DynamicToolCallParams.json",
|
||||
"v2/ErrorNotification.json",
|
||||
"v2/GetAccountResponse.json",
|
||||
"v2/ModelListResponse.json",
|
||||
"v2/ThreadResumeResponse.json",
|
||||
"v2/ThreadStartResponse.json",
|
||||
"v2/TurnCompletedNotification.json",
|
||||
"v2/TurnStartResponse.json",
|
||||
] as const;
|
||||
|
||||
const checks: Array<{ file: string; snippets: string[] }> = [
|
||||
{
|
||||
@@ -33,6 +49,22 @@ const checks: Array<{ file: string; snippets: string[] }> = [
|
||||
file: "v2/CommandExecutionApprovalDecision.ts",
|
||||
snippets: ['"accept"', '"acceptForSession"', '"decline"', '"cancel"'],
|
||||
},
|
||||
{
|
||||
file: "v2/Account.ts",
|
||||
snippets: ['"type": "apiKey"', '"type": "chatgpt"', '"type": "amazonBedrock"'],
|
||||
},
|
||||
{
|
||||
file: "v2/ThreadStartParams.ts",
|
||||
snippets: [
|
||||
"permissionProfile?: PermissionProfile | null",
|
||||
"experimentalRawEvents: boolean",
|
||||
"persistExtendedHistory: boolean",
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "v2/TurnStartParams.ts",
|
||||
snippets: ["permissionProfile?: PermissionProfile | null", "serviceTier?: ServiceTier | null"],
|
||||
},
|
||||
{
|
||||
file: "ReviewDecision.ts",
|
||||
snippets: ['"approved"', '"approved_for_session"', '"denied"', '"abort"'],
|
||||
@@ -49,6 +81,8 @@ const checks: Array<{ file: string; snippets: string[] }> = [
|
||||
|
||||
const failures: string[] = [];
|
||||
|
||||
await compareGeneratedProtocolMirror();
|
||||
|
||||
for (const check of checks) {
|
||||
const filePath = path.join(schemaRoot, check.file);
|
||||
let text: string;
|
||||
@@ -70,9 +104,88 @@ if (failures.length > 0) {
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
console.error("Run `pnpm codex-app-server:protocol:sync` after refreshing ../codex.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Codex app-server generated protocol matches OpenClaw bridge assumptions: ${schemaRoot}`,
|
||||
);
|
||||
|
||||
async function compareGeneratedProtocolMirror(): Promise<void> {
|
||||
const sourceTsRoot = path.join(sourceSchemaRoot, "typescript");
|
||||
const targetTsRoot = path.join(generatedRoot, "typescript");
|
||||
const sourceFiles = await listFiles(sourceTsRoot, ".ts");
|
||||
const targetFiles = await listFiles(targetTsRoot, ".ts");
|
||||
const sourceSet = new Set(sourceFiles);
|
||||
const targetSet = new Set(targetFiles);
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
if (!targetSet.has(file)) {
|
||||
failures.push(`protocol-generated/typescript/${file}: missing local mirror`);
|
||||
continue;
|
||||
}
|
||||
const source = normalizeGeneratedTypeScript(
|
||||
await fs.readFile(path.join(sourceTsRoot, file), "utf8"),
|
||||
);
|
||||
const target = await fs.readFile(path.join(targetTsRoot, file), "utf8");
|
||||
if (source !== target) {
|
||||
failures.push(
|
||||
`protocol-generated/typescript/${file}: differs from normalized ../codex schema`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const file of targetFiles) {
|
||||
if (!sourceSet.has(file)) {
|
||||
failures.push(`protocol-generated/typescript/${file}: no longer present in ../codex schema`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const schema of selectedJsonSchemas) {
|
||||
const sourcePath = path.join(sourceSchemaRoot, "json", schema);
|
||||
const targetPath = path.join(generatedRoot, "json", schema);
|
||||
let source: string;
|
||||
let target: string;
|
||||
try {
|
||||
source = await fs.readFile(sourcePath, "utf8");
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
`protocol-generated/json/${schema}: missing upstream schema (${String(error)})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
target = await fs.readFile(targetPath, "utf8");
|
||||
} catch (error) {
|
||||
failures.push(`protocol-generated/json/${schema}: missing local schema (${String(error)})`);
|
||||
continue;
|
||||
}
|
||||
if (source !== target) {
|
||||
failures.push(`protocol-generated/json/${schema}: differs from ../codex schema`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listFiles(root: string, suffix: string): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
async function visit(dir: string): Promise<void> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await visit(fullPath);
|
||||
} else if (entry.isFile() && entry.name.endsWith(suffix)) {
|
||||
files.push(path.relative(root, fullPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
await visit(root);
|
||||
return files.toSorted();
|
||||
}
|
||||
|
||||
function normalizeGeneratedTypeScript(text: string): string {
|
||||
return text
|
||||
.replace(/(from\s+["'])(\.{1,2}\/[^"']+?)(\.js)?(["'])/g, "$1$2.js$4")
|
||||
.replace('export * as v2 from "./v2.js";', 'export * as v2 from "./v2/index.js";')
|
||||
.replaceAll("| null | null", "| null");
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const targetRoot = path.resolve(
|
||||
const selectedJsonSchemas = [
|
||||
"DynamicToolCallParams.json",
|
||||
"v2/ErrorNotification.json",
|
||||
"v2/GetAccountResponse.json",
|
||||
"v2/ModelListResponse.json",
|
||||
"v2/ThreadResumeResponse.json",
|
||||
"v2/ThreadStartResponse.json",
|
||||
|
||||
Reference in New Issue
Block a user