mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-20 21:23:23 +00:00
feat: add Codex app-server controls
This commit is contained in:
@@ -255,6 +255,106 @@ fallback catalog:
|
||||
}
|
||||
```
|
||||
|
||||
## App-server connection and policy
|
||||
|
||||
By default, the plugin starts Codex locally with:
|
||||
|
||||
```bash
|
||||
codex app-server --listen stdio://
|
||||
```
|
||||
|
||||
You can keep that default and only tune Codex native policy:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
config: {
|
||||
appServer: {
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
serviceTier: "priority",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
For an already-running app-server, use WebSocket transport:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
config: {
|
||||
appServer: {
|
||||
transport: "websocket",
|
||||
url: "ws://127.0.0.1:39175",
|
||||
authToken: "${CODEX_APP_SERVER_TOKEN}",
|
||||
requestTimeoutMs: 60000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Supported `appServer` fields:
|
||||
|
||||
| Field | Default | Meaning |
|
||||
| ------------------- | ---------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
|
||||
| `command` | `"codex"` | Executable for stdio transport. |
|
||||
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
|
||||
| `url` | unset | WebSocket app-server URL. |
|
||||
| `authToken` | unset | Bearer token for WebSocket transport. |
|
||||
| `headers` | `{}` | Extra WebSocket headers. |
|
||||
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
|
||||
| `approvalPolicy` | `"never"` | Native Codex approval policy sent to thread start/resume/turn. |
|
||||
| `sandbox` | `"workspace-write"` | Native Codex sandbox mode sent to thread start/resume. |
|
||||
| `approvalsReviewer` | `"user"` | Use `"guardian_subagent"` to let Codex guardian review native approvals. |
|
||||
| `serviceTier` | unset | Optional Codex service tier, for example `"priority"`. |
|
||||
|
||||
The older environment variables still work as fallbacks for local testing when
|
||||
the matching config field is unset:
|
||||
|
||||
- `OPENCLAW_CODEX_APP_SERVER_BIN`
|
||||
- `OPENCLAW_CODEX_APP_SERVER_ARGS`
|
||||
- `OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY`
|
||||
- `OPENCLAW_CODEX_APP_SERVER_SANDBOX`
|
||||
- `OPENCLAW_CODEX_APP_SERVER_GUARDIAN=1`
|
||||
|
||||
Config is preferred for repeatable deployments.
|
||||
|
||||
## Codex command
|
||||
|
||||
The bundled plugin registers `/codex` as an authorized slash command. It is
|
||||
generic and works on any channel that supports OpenClaw text commands.
|
||||
|
||||
Common forms:
|
||||
|
||||
- `/codex status` shows live app-server connectivity, models, account, rate limits, MCP servers, and skills.
|
||||
- `/codex models` lists live Codex app-server models.
|
||||
- `/codex threads [filter]` lists recent Codex threads.
|
||||
- `/codex resume <thread-id>` attaches the current OpenClaw session to an existing Codex thread.
|
||||
- `/codex compact` asks Codex app-server to compact the attached thread.
|
||||
- `/codex review` starts Codex native review for the attached thread.
|
||||
- `/codex account` shows account and rate-limit status.
|
||||
- `/codex mcp` lists Codex app-server MCP server status.
|
||||
- `/codex skills` lists Codex app-server skills.
|
||||
|
||||
`/codex resume` writes the same sidecar binding file that the harness uses for
|
||||
normal turns. On the next message, OpenClaw resumes that Codex thread, passes the
|
||||
currently selected OpenClaw `codex/*` model into app-server, and keeps extended
|
||||
history enabled.
|
||||
|
||||
## Tools, media, and compaction
|
||||
|
||||
The Codex harness changes the low-level embedded agent executor only.
|
||||
@@ -286,6 +386,9 @@ reports version `0.118.0` or newer.
|
||||
**Model discovery is slow:** lower `plugins.entries.codex.config.discovery.timeoutMs`
|
||||
or disable discovery.
|
||||
|
||||
**WebSocket transport fails immediately:** check `appServer.url`, `authToken`,
|
||||
and that the remote app-server speaks the same Codex app-server protocol version.
|
||||
|
||||
**A non-Codex model uses PI:** that is expected. The Codex harness only claims
|
||||
`codex/*` model refs.
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ Bundled plugins can add more slash commands. Current bundled commands in this re
|
||||
- `/phone status|arm <camera|screen|writes|all> [duration]|disarm` temporarily arms high-risk phone node commands.
|
||||
- `/voice status|list [limit]|set <voiceId|name>` manages Talk voice config. On Discord, the native command name is `/talkvoice`.
|
||||
- `/card ...` sends LINE rich card presets. See [LINE](/channels/line).
|
||||
- `/codex status|models|threads|resume|compact|review|account|mcp|skills` inspects and controls the bundled Codex app-server harness. See [Codex Harness](/plugins/codex-harness).
|
||||
- QQBot-only commands:
|
||||
- `/bot-ping`
|
||||
- `/bot-version`
|
||||
|
||||
@@ -18,6 +18,7 @@ export function createCodexAppServerAgentHarness(options?: {
|
||||
id?: string;
|
||||
label?: string;
|
||||
providerIds?: Iterable<string>;
|
||||
pluginConfig?: unknown;
|
||||
}): AgentHarness {
|
||||
const providerIds = new Set(
|
||||
[...(options?.providerIds ?? DEFAULT_CODEX_HARNESS_PROVIDER_IDS)].map((id) =>
|
||||
@@ -37,8 +38,10 @@ export function createCodexAppServerAgentHarness(options?: {
|
||||
reason: `provider is not one of: ${[...providerIds].toSorted().join(", ")}`,
|
||||
};
|
||||
},
|
||||
runAttempt: runCodexAppServerAttempt,
|
||||
compact: maybeCompactCodexAppServerSession,
|
||||
runAttempt: (params) =>
|
||||
runCodexAppServerAttempt(params, { pluginConfig: options?.pluginConfig }),
|
||||
compact: (params) =>
|
||||
maybeCompactCodexAppServerSession(params, { pluginConfig: options?.pluginConfig }),
|
||||
reset: async (params) => {
|
||||
if (params.sessionFile) {
|
||||
await clearCodexAppServerBinding(params.sessionFile);
|
||||
|
||||
@@ -14,6 +14,7 @@ describe("codex plugin", () => {
|
||||
|
||||
it("registers the codex provider and agent harness", () => {
|
||||
const registerAgentHarness = vi.fn();
|
||||
const registerCommand = vi.fn();
|
||||
const registerProvider = vi.fn();
|
||||
|
||||
plugin.register(
|
||||
@@ -25,6 +26,7 @@ describe("codex plugin", () => {
|
||||
pluginConfig: {},
|
||||
runtime: {} as never,
|
||||
registerAgentHarness,
|
||||
registerCommand,
|
||||
registerProvider,
|
||||
}),
|
||||
);
|
||||
@@ -34,5 +36,9 @@ describe("codex plugin", () => {
|
||||
id: "codex",
|
||||
label: "Codex agent harness",
|
||||
});
|
||||
expect(registerCommand.mock.calls[0]?.[0]).toMatchObject({
|
||||
name: "codex",
|
||||
description: "Inspect and control the Codex app-server harness",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createCodexAppServerAgentHarness } from "./harness.js";
|
||||
import { buildCodexProvider } from "./provider.js";
|
||||
import { createCodexCommand } from "./src/commands.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
description: "Codex app-server harness and Codex-managed GPT model catalog.",
|
||||
register(api) {
|
||||
api.registerAgentHarness(createCodexAppServerAgentHarness());
|
||||
api.registerAgentHarness(createCodexAppServerAgentHarness({ pluginConfig: api.pluginConfig }));
|
||||
api.registerProvider(buildCodexProvider({ pluginConfig: api.pluginConfig }));
|
||||
api.registerCommand(createCodexCommand({ pluginConfig: api.pluginConfig }));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
"name": "Codex",
|
||||
"description": "Codex app-server harness and Codex-managed GPT model catalog.",
|
||||
"providers": ["codex"],
|
||||
"commandAliases": [
|
||||
{
|
||||
"name": "codex",
|
||||
"kind": "runtime-slash",
|
||||
"cliCommand": "plugins"
|
||||
}
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
@@ -18,6 +25,57 @@
|
||||
"default": 2500
|
||||
}
|
||||
}
|
||||
},
|
||||
"appServer": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"transport": {
|
||||
"type": "string",
|
||||
"enum": ["stdio", "websocket"],
|
||||
"default": "stdio"
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"default": "codex"
|
||||
},
|
||||
"args": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
{ "type": "string" }
|
||||
]
|
||||
},
|
||||
"url": { "type": "string" },
|
||||
"authToken": { "type": "string" },
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
"requestTimeoutMs": {
|
||||
"type": "number",
|
||||
"minimum": 1,
|
||||
"default": 60000
|
||||
},
|
||||
"approvalPolicy": {
|
||||
"type": "string",
|
||||
"enum": ["never", "on-request", "on-failure", "untrusted"],
|
||||
"default": "never"
|
||||
},
|
||||
"sandbox": {
|
||||
"type": "string",
|
||||
"enum": ["read-only", "workspace-write", "danger-full-access"],
|
||||
"default": "workspace-write"
|
||||
},
|
||||
"approvalsReviewer": {
|
||||
"type": "string",
|
||||
"enum": ["user", "guardian_subagent"],
|
||||
"default": "user"
|
||||
},
|
||||
"serviceTier": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -34,6 +92,62 @@
|
||||
"label": "Discovery Timeout",
|
||||
"help": "Maximum time to wait for Codex app-server model discovery before falling back to the bundled model list.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer": {
|
||||
"label": "App Server",
|
||||
"help": "Runtime controls for connecting to Codex app-server.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.transport": {
|
||||
"label": "Transport",
|
||||
"help": "Use stdio to spawn Codex locally, or websocket to connect to an already-running app-server.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.command": {
|
||||
"label": "Command",
|
||||
"help": "Executable used for stdio transport.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.args": {
|
||||
"label": "Arguments",
|
||||
"help": "Arguments used for stdio transport. Defaults to app-server --listen stdio://.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.url": {
|
||||
"label": "WebSocket URL",
|
||||
"help": "Codex app-server WebSocket URL when transport is websocket.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.authToken": {
|
||||
"label": "Auth Token",
|
||||
"help": "Bearer token sent to the WebSocket app-server.",
|
||||
"sensitive": true,
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.requestTimeoutMs": {
|
||||
"label": "Request Timeout",
|
||||
"help": "Maximum time to wait for Codex app-server control-plane requests.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.approvalPolicy": {
|
||||
"label": "Approval Policy",
|
||||
"help": "Codex native approval policy sent to thread start, resume, and turns.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.sandbox": {
|
||||
"label": "Sandbox",
|
||||
"help": "Codex native sandbox mode sent to thread start and resume.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.approvalsReviewer": {
|
||||
"label": "Approvals Reviewer",
|
||||
"help": "Use user approvals or the Codex guardian subagent for native app-server approvals.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.serviceTier": {
|
||||
"label": "Service Tier",
|
||||
"help": "Optional Codex service tier passed when starting or resuming threads.",
|
||||
"advanced": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"description": "OpenClaw Codex harness and model provider plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-coding-agent": "0.65.2"
|
||||
"@mariozechner/pi-coding-agent": "0.65.2",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
|
||||
@@ -29,7 +29,9 @@ describe("codex provider", () => {
|
||||
pluginConfig: { discovery: { timeoutMs: 1234 } },
|
||||
});
|
||||
|
||||
expect(listModels).toHaveBeenCalledWith({ limit: 100, timeoutMs: 1234 });
|
||||
expect(listModels).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit: 100, timeoutMs: 1234 }),
|
||||
);
|
||||
expect(result.provider).toMatchObject({
|
||||
auth: "token",
|
||||
api: "openai-codex-responses",
|
||||
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
type CodexAppServerModel,
|
||||
type CodexAppServerModelListResult,
|
||||
} from "./harness.js";
|
||||
import {
|
||||
type CodexAppServerStartOptions,
|
||||
readCodexPluginConfig,
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
} from "./src/app-server/config.js";
|
||||
|
||||
const PROVIDER_ID = "codex";
|
||||
const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
||||
@@ -18,16 +23,10 @@ const DEFAULT_MAX_TOKENS = 128_000;
|
||||
const DEFAULT_DISCOVERY_TIMEOUT_MS = 2500;
|
||||
const LIVE_DISCOVERY_ENV = "OPENCLAW_CODEX_DISCOVERY_LIVE";
|
||||
|
||||
type CodexPluginConfig = {
|
||||
discovery?: {
|
||||
enabled?: boolean;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type CodexModelLister = (options: {
|
||||
timeoutMs: number;
|
||||
limit?: number;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
}) => Promise<CodexAppServerModelListResult>;
|
||||
|
||||
type BuildCodexProviderOptions = {
|
||||
@@ -98,6 +97,7 @@ export async function buildCodexProviderCatalog(
|
||||
options: BuildCatalogOptions = {},
|
||||
): Promise<{ provider: ModelProviderConfig }> {
|
||||
const config = readCodexPluginConfig(options.pluginConfig);
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
|
||||
const timeoutMs = normalizeTimeoutMs(config.discovery?.timeoutMs);
|
||||
const discovered =
|
||||
config.discovery?.enabled === false || shouldSkipLiveDiscovery(options.env)
|
||||
@@ -105,6 +105,7 @@ export async function buildCodexProviderCatalog(
|
||||
: await listModelsBestEffort({
|
||||
listModels: options.listModels ?? listCodexAppServerModels,
|
||||
timeoutMs,
|
||||
startOptions: appServer.start,
|
||||
});
|
||||
const models = (discovered.length > 0 ? discovered : FALLBACK_CODEX_MODELS).map(
|
||||
codexModelToDefinition,
|
||||
@@ -167,11 +168,13 @@ function buildModelDefinition(model: {
|
||||
async function listModelsBestEffort(params: {
|
||||
listModels: CodexModelLister;
|
||||
timeoutMs: number;
|
||||
startOptions: CodexAppServerStartOptions;
|
||||
}): Promise<CodexAppServerModel[]> {
|
||||
try {
|
||||
const result = await params.listModels({
|
||||
timeoutMs: params.timeoutMs,
|
||||
limit: 100,
|
||||
startOptions: params.startOptions,
|
||||
});
|
||||
return result.models.filter((model) => !model.hidden);
|
||||
} catch {
|
||||
@@ -179,13 +182,6 @@ async function listModelsBestEffort(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function readCodexPluginConfig(value: unknown): CodexPluginConfig {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value as CodexPluginConfig;
|
||||
}
|
||||
|
||||
function normalizeTimeoutMs(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? value
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough, Writable } from "node:stream";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocketServer, type RawData } from "ws";
|
||||
import {
|
||||
CodexAppServerClient,
|
||||
listCodexAppServerModels,
|
||||
@@ -242,4 +243,53 @@ describe("CodexAppServerClient", () => {
|
||||
});
|
||||
startSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("can speak JSON-RPC over websocket transport", async () => {
|
||||
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
||||
const authHeaders: Array<string | undefined> = [];
|
||||
server.on("connection", (socket, request) => {
|
||||
authHeaders.push(request.headers.authorization);
|
||||
socket.on("message", (data) => {
|
||||
const message = JSON.parse(rawDataToText(data)) as { id?: number; method?: string };
|
||||
if (message.method === "initialize") {
|
||||
socket.send(
|
||||
JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.118.0" } }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (message.method === "model/list") {
|
||||
socket.send(JSON.stringify({ id: message.id, result: { data: [] } }));
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected websocket test server port");
|
||||
}
|
||||
const client = CodexAppServerClient.start({
|
||||
transport: "websocket",
|
||||
url: `ws://127.0.0.1:${address.port}`,
|
||||
authToken: "secret",
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
await expect(client.initialize()).resolves.toBeUndefined();
|
||||
await expect(client.request("model/list", {})).resolves.toEqual({ data: [] });
|
||||
expect(authHeaders).toEqual(["Bearer secret"]);
|
||||
client.close();
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve())),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function rawDataToText(data: RawData): string {
|
||||
if (Buffer.isBuffer(data)) {
|
||||
return data.toString("utf8");
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
return Buffer.concat(data).toString("utf8");
|
||||
}
|
||||
return Buffer.from(data).toString("utf8");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
|
||||
import { PassThrough, Writable } from "node:stream";
|
||||
import { embeddedAgentLog, OPENCLAW_VERSION } from "openclaw/plugin-sdk/agent-harness";
|
||||
import WebSocket, { type RawData } from "ws";
|
||||
import {
|
||||
codexAppServerStartOptionsKey,
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
type CodexAppServerStartOptions,
|
||||
} from "./config.js";
|
||||
import {
|
||||
type CodexInitializeResponse,
|
||||
isRpcResponse,
|
||||
@@ -59,6 +67,7 @@ export type CodexAppServerListModelsOptions = {
|
||||
cursor?: string;
|
||||
includeHidden?: boolean;
|
||||
timeoutMs?: number;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
};
|
||||
|
||||
export class CodexAppServerClient {
|
||||
@@ -93,11 +102,17 @@ export class CodexAppServerClient {
|
||||
});
|
||||
}
|
||||
|
||||
static start(): CodexAppServerClient {
|
||||
const bin = process.env.OPENCLAW_CODEX_APP_SERVER_BIN?.trim() || "codex";
|
||||
const extraArgs = splitShellWords(process.env.OPENCLAW_CODEX_APP_SERVER_ARGS ?? "");
|
||||
const args = extraArgs.length > 0 ? extraArgs : ["app-server", "--listen", "stdio://"];
|
||||
const child = spawn(bin, args, {
|
||||
static start(options?: Partial<CodexAppServerStartOptions>): CodexAppServerClient {
|
||||
const defaults = resolveCodexAppServerRuntimeOptions().start;
|
||||
const startOptions = {
|
||||
...defaults,
|
||||
...options,
|
||||
headers: options?.headers ?? defaults.headers,
|
||||
};
|
||||
if (startOptions.transport === "websocket") {
|
||||
return new CodexAppServerClient(createWebSocketTransport(startOptions));
|
||||
}
|
||||
const child = spawn(startOptions.command, startOptions.args, {
|
||||
env: process.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
@@ -266,10 +281,19 @@ export class CodexAppServerClient {
|
||||
|
||||
let sharedClient: CodexAppServerClient | undefined;
|
||||
let sharedClientPromise: Promise<CodexAppServerClient> | undefined;
|
||||
let sharedClientKey: string | undefined;
|
||||
|
||||
export async function getSharedCodexAppServerClient(): Promise<CodexAppServerClient> {
|
||||
export async function getSharedCodexAppServerClient(options?: {
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
}): Promise<CodexAppServerClient> {
|
||||
const startOptions = options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
|
||||
const key = codexAppServerStartOptionsKey(startOptions);
|
||||
if (sharedClientKey && sharedClientKey !== key) {
|
||||
clearSharedCodexAppServerClient();
|
||||
}
|
||||
sharedClientKey = key;
|
||||
sharedClientPromise ??= (async () => {
|
||||
const client = CodexAppServerClient.start();
|
||||
const client = CodexAppServerClient.start(startOptions);
|
||||
sharedClient = client;
|
||||
try {
|
||||
await client.initialize();
|
||||
@@ -286,6 +310,7 @@ export async function getSharedCodexAppServerClient(): Promise<CodexAppServerCli
|
||||
} catch (error) {
|
||||
sharedClient = undefined;
|
||||
sharedClientPromise = undefined;
|
||||
sharedClientKey = undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -293,12 +318,14 @@ export async function getSharedCodexAppServerClient(): Promise<CodexAppServerCli
|
||||
export function resetSharedCodexAppServerClientForTests(): void {
|
||||
sharedClient = undefined;
|
||||
sharedClientPromise = undefined;
|
||||
sharedClientKey = undefined;
|
||||
}
|
||||
|
||||
export function clearSharedCodexAppServerClient(): void {
|
||||
const client = sharedClient;
|
||||
sharedClient = undefined;
|
||||
sharedClientPromise = undefined;
|
||||
sharedClientKey = undefined;
|
||||
client?.close();
|
||||
}
|
||||
|
||||
@@ -308,6 +335,7 @@ function clearSharedClientIfCurrent(client: CodexAppServerClient): void {
|
||||
}
|
||||
sharedClient = undefined;
|
||||
sharedClientPromise = undefined;
|
||||
sharedClientKey = undefined;
|
||||
}
|
||||
|
||||
export async function listCodexAppServerModels(
|
||||
@@ -316,7 +344,7 @@ export async function listCodexAppServerModels(
|
||||
const timeoutMs = options.timeoutMs ?? 2500;
|
||||
return await withTimeout(
|
||||
(async () => {
|
||||
const client = await getSharedCodexAppServerClient();
|
||||
const client = await getSharedCodexAppServerClient({ startOptions: options.startOptions });
|
||||
const response = await client.request<JsonObject>("model/list", {
|
||||
limit: options.limit ?? null,
|
||||
cursor: options.cursor ?? null,
|
||||
@@ -329,6 +357,23 @@ export async function listCodexAppServerModels(
|
||||
);
|
||||
}
|
||||
|
||||
export async function requestCodexAppServerJson<T = JsonValue | undefined>(params: {
|
||||
method: string;
|
||||
requestParams?: JsonValue;
|
||||
timeoutMs?: number;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
}): Promise<T> {
|
||||
const timeoutMs = params.timeoutMs ?? 60_000;
|
||||
return await withTimeout(
|
||||
(async () => {
|
||||
const client = await getSharedCodexAppServerClient({ startOptions: params.startOptions });
|
||||
return await client.request<T>(params.method, params.requestParams);
|
||||
})(),
|
||||
timeoutMs,
|
||||
`codex app-server ${params.method} timed out`,
|
||||
);
|
||||
}
|
||||
|
||||
export function defaultServerRequestResponse(
|
||||
request: Required<Pick<RpcRequest, "id" | "method">> & { params?: JsonValue },
|
||||
): JsonValue {
|
||||
@@ -521,38 +566,6 @@ export function isCodexAppServerApprovalRequest(method: string): boolean {
|
||||
return method.includes("requestApproval") || method.includes("Approval");
|
||||
}
|
||||
|
||||
function splitShellWords(value: string): string[] {
|
||||
const words: string[] = [];
|
||||
let current = "";
|
||||
let quote: '"' | "'" | null = null;
|
||||
for (const char of value) {
|
||||
if (quote) {
|
||||
if (char === quote) {
|
||||
quote = null;
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(char)) {
|
||||
if (current) {
|
||||
words.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
if (current) {
|
||||
words.push(current);
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
function formatExitValue(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return "null";
|
||||
@@ -562,3 +575,86 @@ function formatExitValue(value: unknown): string {
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function createWebSocketTransport(options: CodexAppServerStartOptions): CodexAppServerTransport {
|
||||
if (!options.url) {
|
||||
throw new Error(
|
||||
"codex app-server websocket transport requires plugins.entries.codex.config.appServer.url",
|
||||
);
|
||||
}
|
||||
const events = new EventEmitter();
|
||||
const stdout = new PassThrough();
|
||||
const stderr = new PassThrough();
|
||||
const headers = {
|
||||
...options.headers,
|
||||
...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}),
|
||||
};
|
||||
const socket = new WebSocket(options.url, { headers });
|
||||
const pendingFrames: string[] = [];
|
||||
let killed = false;
|
||||
|
||||
const sendFrame = (frame: string) => {
|
||||
const trimmed = frame.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(trimmed);
|
||||
return;
|
||||
}
|
||||
pendingFrames.push(trimmed);
|
||||
};
|
||||
|
||||
// `initialize` can be written before the WebSocket open event fires. Buffer
|
||||
// whole JSON-RPC frames so stdio and websocket transports share call timing.
|
||||
socket.once("open", () => {
|
||||
for (const frame of pendingFrames.splice(0)) {
|
||||
socket.send(frame);
|
||||
}
|
||||
});
|
||||
socket.once("error", (error) => events.emit("error", error));
|
||||
socket.once("close", (code, reason) => {
|
||||
killed = true;
|
||||
events.emit("exit", code, reason.toString("utf8"));
|
||||
});
|
||||
socket.on("message", (data) => {
|
||||
const text = websocketFrameToText(data);
|
||||
stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
||||
});
|
||||
|
||||
const stdin = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
for (const frame of chunk.toString("utf8").split("\n")) {
|
||||
sendFrame(frame);
|
||||
}
|
||||
callback();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
get killed() {
|
||||
return killed;
|
||||
},
|
||||
kill: () => {
|
||||
killed = true;
|
||||
socket.close();
|
||||
},
|
||||
once: (event, listener) => events.once(event, listener),
|
||||
};
|
||||
}
|
||||
|
||||
function websocketFrameToText(data: RawData): string {
|
||||
if (typeof data === "string") {
|
||||
return data;
|
||||
}
|
||||
if (Buffer.isBuffer(data)) {
|
||||
return data.toString("utf8");
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
return Buffer.concat(data).toString("utf8");
|
||||
}
|
||||
return Buffer.from(data).toString("utf8");
|
||||
}
|
||||
|
||||
@@ -9,10 +9,13 @@ import {
|
||||
type CodexAppServerClient,
|
||||
type CodexServerNotificationHandler,
|
||||
} from "./client.js";
|
||||
import { resolveCodexAppServerRuntimeOptions, type CodexAppServerStartOptions } from "./config.js";
|
||||
import { isJsonObject, type CodexServerNotification, type JsonObject } from "./protocol.js";
|
||||
import { readCodexAppServerBinding } from "./session-binding.js";
|
||||
|
||||
type CodexAppServerClientFactory = () => Promise<CodexAppServerClient>;
|
||||
type CodexAppServerClientFactory = (
|
||||
startOptions?: CodexAppServerStartOptions,
|
||||
) => Promise<CodexAppServerClient>;
|
||||
type CodexNativeCompactionCompletion = {
|
||||
signal: "thread/compacted" | "item/completed";
|
||||
turnId?: string;
|
||||
@@ -26,11 +29,14 @@ type CodexNativeCompactionWaiter = {
|
||||
|
||||
const DEFAULT_CODEX_COMPACTION_WAIT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
let clientFactory: CodexAppServerClientFactory = getSharedCodexAppServerClient;
|
||||
let clientFactory: CodexAppServerClientFactory = (startOptions) =>
|
||||
getSharedCodexAppServerClient({ startOptions });
|
||||
|
||||
export async function maybeCompactCodexAppServerSession(
|
||||
params: CompactEmbeddedPiSessionParams,
|
||||
options: { pluginConfig?: unknown } = {},
|
||||
): Promise<EmbeddedPiCompactResult | undefined> {
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
|
||||
const runtime = resolveEmbeddedAgentRuntime();
|
||||
const provider = params.provider?.trim().toLowerCase();
|
||||
const shouldUseCodex =
|
||||
@@ -48,7 +54,7 @@ export async function maybeCompactCodexAppServerSession(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const client = await clientFactory();
|
||||
const client = await clientFactory(appServer.start);
|
||||
const waiter = createCodexNativeCompactionWaiter(client, binding.threadId);
|
||||
let completion: CodexNativeCompactionCompletion;
|
||||
try {
|
||||
@@ -222,6 +228,6 @@ export const __testing = {
|
||||
clientFactory = factory;
|
||||
},
|
||||
resetCodexAppServerClientFactoryForTests(): void {
|
||||
clientFactory = getSharedCodexAppServerClient;
|
||||
clientFactory = (startOptions) => getSharedCodexAppServerClient({ startOptions });
|
||||
},
|
||||
} as const;
|
||||
|
||||
195
extensions/codex/src/app-server/config.ts
Normal file
195
extensions/codex/src/app-server/config.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
export type CodexAppServerTransportMode = "stdio" | "websocket";
|
||||
export type CodexAppServerApprovalPolicy = "never" | "on-request" | "on-failure" | "untrusted";
|
||||
export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "danger-full-access";
|
||||
export type CodexAppServerApprovalsReviewer = "user" | "guardian_subagent";
|
||||
|
||||
export type CodexAppServerStartOptions = {
|
||||
transport: CodexAppServerTransportMode;
|
||||
command: string;
|
||||
args: string[];
|
||||
url?: string;
|
||||
authToken?: string;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
export type CodexAppServerRuntimeOptions = {
|
||||
start: CodexAppServerStartOptions;
|
||||
requestTimeoutMs: number;
|
||||
approvalPolicy: CodexAppServerApprovalPolicy;
|
||||
sandbox: CodexAppServerSandboxMode;
|
||||
approvalsReviewer: CodexAppServerApprovalsReviewer;
|
||||
serviceTier?: string;
|
||||
};
|
||||
|
||||
export type CodexPluginConfig = {
|
||||
discovery?: {
|
||||
enabled?: boolean;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
appServer?: {
|
||||
transport?: CodexAppServerTransportMode;
|
||||
command?: string;
|
||||
args?: string[] | string;
|
||||
url?: string;
|
||||
authToken?: string;
|
||||
headers?: Record<string, string>;
|
||||
requestTimeoutMs?: number;
|
||||
approvalPolicy?: CodexAppServerApprovalPolicy;
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
approvalsReviewer?: CodexAppServerApprovalsReviewer;
|
||||
serviceTier?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function readCodexPluginConfig(value: unknown): CodexPluginConfig {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value as CodexPluginConfig;
|
||||
}
|
||||
|
||||
export function resolveCodexAppServerRuntimeOptions(
|
||||
params: {
|
||||
pluginConfig?: unknown;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
} = {},
|
||||
): CodexAppServerRuntimeOptions {
|
||||
const env = params.env ?? process.env;
|
||||
const config = readCodexPluginConfig(params.pluginConfig).appServer ?? {};
|
||||
const transport = resolveTransport(config.transport);
|
||||
const command =
|
||||
readNonEmptyString(config.command) ?? env.OPENCLAW_CODEX_APP_SERVER_BIN ?? "codex";
|
||||
const args = resolveArgs(config.args, env.OPENCLAW_CODEX_APP_SERVER_ARGS);
|
||||
const headers = normalizeHeaders(config.headers);
|
||||
const authToken = readNonEmptyString(config.authToken);
|
||||
const url = readNonEmptyString(config.url);
|
||||
|
||||
return {
|
||||
start: {
|
||||
transport,
|
||||
command,
|
||||
args: args.length > 0 ? args : ["app-server", "--listen", "stdio://"],
|
||||
...(url ? { url } : {}),
|
||||
...(authToken ? { authToken } : {}),
|
||||
headers,
|
||||
},
|
||||
requestTimeoutMs: normalizePositiveNumber(config.requestTimeoutMs, 60_000),
|
||||
approvalPolicy:
|
||||
resolveApprovalPolicy(config.approvalPolicy) ??
|
||||
resolveApprovalPolicy(env.OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY) ??
|
||||
"never",
|
||||
sandbox:
|
||||
resolveSandbox(config.sandbox) ??
|
||||
resolveSandbox(env.OPENCLAW_CODEX_APP_SERVER_SANDBOX) ??
|
||||
"workspace-write",
|
||||
approvalsReviewer:
|
||||
resolveApprovalsReviewer(config.approvalsReviewer) ??
|
||||
(env.OPENCLAW_CODEX_APP_SERVER_GUARDIAN === "1" ? "guardian_subagent" : "user"),
|
||||
...(readNonEmptyString(config.serviceTier)
|
||||
? { serviceTier: readNonEmptyString(config.serviceTier) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function codexAppServerStartOptionsKey(options: CodexAppServerStartOptions): string {
|
||||
return JSON.stringify({
|
||||
transport: options.transport,
|
||||
command: options.command,
|
||||
args: options.args,
|
||||
url: options.url ?? null,
|
||||
authToken: options.authToken ? "<set>" : null,
|
||||
headers: Object.entries(options.headers).toSorted(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function resolveTransport(value: unknown): CodexAppServerTransportMode {
|
||||
return value === "websocket" ? "websocket" : "stdio";
|
||||
}
|
||||
|
||||
function resolveApprovalPolicy(value: unknown): CodexAppServerApprovalPolicy | undefined {
|
||||
return value === "on-request" ||
|
||||
value === "on-failure" ||
|
||||
value === "untrusted" ||
|
||||
value === "never"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveSandbox(value: unknown): CodexAppServerSandboxMode | undefined {
|
||||
return value === "read-only" || value === "workspace-write" || value === "danger-full-access"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveApprovalsReviewer(value: unknown): CodexAppServerApprovalsReviewer | undefined {
|
||||
return value === "guardian_subagent" || value === "user" ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizePositiveNumber(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeHeaders(value: unknown): Record<string, string> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(([key, child]) => [key.trim(), readNonEmptyString(child)] as const)
|
||||
.filter((entry): entry is readonly [string, string] => Boolean(entry[0] && entry[1])),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveArgs(configArgs: unknown, envArgs: string | undefined): string[] {
|
||||
if (Array.isArray(configArgs)) {
|
||||
return configArgs
|
||||
.map((entry) => readNonEmptyString(entry))
|
||||
.filter((entry): entry is string => entry !== undefined);
|
||||
}
|
||||
if (typeof configArgs === "string") {
|
||||
return splitShellWords(configArgs);
|
||||
}
|
||||
return splitShellWords(envArgs ?? "");
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function splitShellWords(value: string): string[] {
|
||||
const words: string[] = [];
|
||||
let current = "";
|
||||
let quote: '"' | "'" | null = null;
|
||||
for (const char of value) {
|
||||
if (quote) {
|
||||
if (char === quote) {
|
||||
quote = null;
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(char)) {
|
||||
if (current) {
|
||||
words.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
if (current) {
|
||||
words.push(current);
|
||||
}
|
||||
return words;
|
||||
}
|
||||
@@ -55,6 +55,7 @@ export type CodexThreadStartParams = {
|
||||
approvalPolicy?: "never" | "on-request" | "on-failure" | "untrusted";
|
||||
approvalsReviewer?: "user" | "guardian_subagent";
|
||||
sandbox?: "read-only" | "workspace-write" | "danger-full-access";
|
||||
serviceTier?: string | null;
|
||||
config?: JsonObject | null;
|
||||
serviceName?: string | null;
|
||||
baseInstructions?: string | null;
|
||||
@@ -67,6 +68,13 @@ export type CodexThreadStartParams = {
|
||||
|
||||
export type CodexThreadResumeParams = {
|
||||
threadId: string;
|
||||
model?: string | null;
|
||||
modelProvider?: string | null;
|
||||
approvalPolicy?: "never" | "on-request" | "on-failure" | "untrusted";
|
||||
approvalsReviewer?: "user" | "guardian_subagent";
|
||||
sandbox?: "read-only" | "workspace-write" | "danger-full-access";
|
||||
serviceTier?: string | null;
|
||||
persistExtendedHistory?: boolean;
|
||||
};
|
||||
|
||||
export type CodexThreadStartResponse = {
|
||||
@@ -84,6 +92,7 @@ export type CodexTurnStartParams = {
|
||||
approvalPolicy?: "never" | "on-request" | "on-failure" | "untrusted";
|
||||
approvalsReviewer?: "user" | "guardian_subagent";
|
||||
model?: string | null;
|
||||
serviceTier?: string | null;
|
||||
effort?: "minimal" | "low" | "medium" | "high" | "xhigh" | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -296,10 +296,99 @@ describe("runCodexAppServerAttempt", () => {
|
||||
method: "thread/resume",
|
||||
params: {
|
||||
threadId: "thread-existing",
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: "workspace-write",
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes configured app-server policy, sandbox, service tier, and model on resume", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.2",
|
||||
modelProvider: "openai",
|
||||
});
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
const request = vi.fn(async (method: string, params?: unknown) => {
|
||||
requests.push({ method, params });
|
||||
if (method === "thread/resume") {
|
||||
return { thread: { id: "thread-existing" }, modelProvider: "openai" };
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return { turn: { id: "turn-1", status: "inProgress" } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
__testing.setCodexAppServerClientFactoryForTests(
|
||||
async () =>
|
||||
({
|
||||
request,
|
||||
addNotificationHandler: (handler: typeof notify) => {
|
||||
notify = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
addRequestHandler: () => () => undefined,
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "guardian_subagent",
|
||||
sandbox: "danger-full-access",
|
||||
serviceTier: "priority",
|
||||
},
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(requests.some((entry) => entry.method === "turn/start")).toBe(true),
|
||||
);
|
||||
await notify({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-existing",
|
||||
turnId: "turn-1",
|
||||
turn: { id: "turn-1", status: "completed" },
|
||||
},
|
||||
});
|
||||
await run;
|
||||
|
||||
expect(requests).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
method: "thread/resume",
|
||||
params: {
|
||||
threadId: "thread-existing",
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "guardian_subagent",
|
||||
sandbox: "danger-full-access",
|
||||
serviceTier: "priority",
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "turn/start",
|
||||
params: expect.objectContaining({
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "guardian_subagent",
|
||||
serviceTier: "priority",
|
||||
model: "gpt-5.4-codex",
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
isCodexAppServerApprovalRequest,
|
||||
type CodexAppServerClient,
|
||||
} from "./client.js";
|
||||
import {
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
type CodexAppServerRuntimeOptions,
|
||||
type CodexAppServerStartOptions,
|
||||
} from "./config.js";
|
||||
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
|
||||
import { CodexAppServerEventProjector } from "./event-projector.js";
|
||||
import {
|
||||
@@ -45,13 +50,18 @@ import {
|
||||
} from "./session-binding.js";
|
||||
import { mirrorCodexAppServerTranscript } from "./transcript-mirror.js";
|
||||
|
||||
type CodexAppServerClientFactory = () => Promise<CodexAppServerClient>;
|
||||
type CodexAppServerClientFactory = (
|
||||
startOptions?: CodexAppServerStartOptions,
|
||||
) => Promise<CodexAppServerClient>;
|
||||
|
||||
let clientFactory: CodexAppServerClientFactory = getSharedCodexAppServerClient;
|
||||
let clientFactory: CodexAppServerClientFactory = (startOptions) =>
|
||||
getSharedCodexAppServerClient({ startOptions });
|
||||
|
||||
export async function runCodexAppServerAttempt(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
options: { pluginConfig?: unknown } = {},
|
||||
): Promise<EmbeddedRunAttemptResult> {
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
|
||||
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
||||
await fs.mkdir(resolvedWorkspace, { recursive: true });
|
||||
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
|
||||
@@ -106,12 +116,13 @@ export async function runCodexAppServerAttempt(
|
||||
timeoutMs: params.timeoutMs,
|
||||
signal: runAbortController.signal,
|
||||
operation: async () => {
|
||||
const startupClient = await clientFactory();
|
||||
const startupClient = await clientFactory(appServer.start);
|
||||
const startupThread = await startOrResumeThread({
|
||||
client: startupClient,
|
||||
params,
|
||||
cwd: effectiveWorkspace,
|
||||
dynamicTools: toolBridge.specs,
|
||||
appServer,
|
||||
});
|
||||
return { client: startupClient, thread: startupThread };
|
||||
},
|
||||
@@ -178,9 +189,10 @@ export async function runCodexAppServerAttempt(
|
||||
threadId: thread.threadId,
|
||||
input: buildUserInput(params),
|
||||
cwd: effectiveWorkspace,
|
||||
approvalPolicy: resolveAppServerApprovalPolicy(),
|
||||
approvalsReviewer: resolveApprovalsReviewer(),
|
||||
approvalPolicy: appServer.approvalPolicy,
|
||||
approvalsReviewer: appServer.approvalsReviewer,
|
||||
model: params.modelId,
|
||||
...(appServer.serviceTier ? { serviceTier: appServer.serviceTier } : {}),
|
||||
effort: resolveReasoningEffort(params.thinkLevel),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -394,11 +406,17 @@ async function startOrResumeThread(params: {
|
||||
params: EmbeddedRunAttemptParams;
|
||||
cwd: string;
|
||||
dynamicTools: JsonValue[];
|
||||
appServer: CodexAppServerRuntimeOptions;
|
||||
}): Promise<CodexAppServerThreadBinding> {
|
||||
const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools);
|
||||
const binding = await readCodexAppServerBinding(params.params.sessionFile);
|
||||
if (binding?.threadId) {
|
||||
if (binding.dynamicToolsFingerprint !== dynamicToolsFingerprint) {
|
||||
// `/codex resume <thread>` writes a binding before the next turn can know
|
||||
// the dynamic tool catalog, so only invalidate fingerprints we actually have.
|
||||
if (
|
||||
binding.dynamicToolsFingerprint &&
|
||||
binding.dynamicToolsFingerprint !== dynamicToolsFingerprint
|
||||
) {
|
||||
embeddedAgentLog.debug(
|
||||
"codex app-server dynamic tool catalog changed; starting a new thread",
|
||||
{
|
||||
@@ -410,6 +428,12 @@ async function startOrResumeThread(params: {
|
||||
try {
|
||||
const response = await params.client.request<CodexThreadResumeResponse>("thread/resume", {
|
||||
threadId: binding.threadId,
|
||||
model: params.params.modelId,
|
||||
modelProvider: normalizeModelProvider(params.params.provider),
|
||||
approvalPolicy: params.appServer.approvalPolicy,
|
||||
approvalsReviewer: params.appServer.approvalsReviewer,
|
||||
sandbox: params.appServer.sandbox,
|
||||
...(params.appServer.serviceTier ? { serviceTier: params.appServer.serviceTier } : {}),
|
||||
persistExtendedHistory: true,
|
||||
});
|
||||
await writeCodexAppServerBinding(params.params.sessionFile, {
|
||||
@@ -441,9 +465,10 @@ async function startOrResumeThread(params: {
|
||||
model: params.params.modelId,
|
||||
modelProvider: normalizeModelProvider(params.params.provider),
|
||||
cwd: params.cwd,
|
||||
approvalPolicy: resolveAppServerApprovalPolicy(),
|
||||
approvalsReviewer: resolveApprovalsReviewer(),
|
||||
sandbox: resolveAppServerSandbox(),
|
||||
approvalPolicy: params.appServer.approvalPolicy,
|
||||
approvalsReviewer: params.appServer.approvalsReviewer,
|
||||
sandbox: params.appServer.sandbox,
|
||||
...(params.appServer.serviceTier ? { serviceTier: params.appServer.serviceTier } : {}),
|
||||
serviceName: "OpenClaw",
|
||||
developerInstructions: buildDeveloperInstructions(params.params),
|
||||
dynamicTools: params.dynamicTools,
|
||||
@@ -518,26 +543,6 @@ function normalizeModelProvider(provider: string): string {
|
||||
return provider === "codex" || provider === "openai-codex" ? "openai" : provider;
|
||||
}
|
||||
|
||||
function resolveAppServerApprovalPolicy(): "never" | "on-request" | "on-failure" | "untrusted" {
|
||||
const raw = process.env.OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY?.trim();
|
||||
if (raw === "on-request" || raw === "on-failure" || raw === "untrusted") {
|
||||
return raw;
|
||||
}
|
||||
return "never";
|
||||
}
|
||||
|
||||
function resolveAppServerSandbox(): "read-only" | "workspace-write" | "danger-full-access" {
|
||||
const raw = process.env.OPENCLAW_CODEX_APP_SERVER_SANDBOX?.trim();
|
||||
if (raw === "read-only" || raw === "danger-full-access") {
|
||||
return raw;
|
||||
}
|
||||
return "workspace-write";
|
||||
}
|
||||
|
||||
function resolveApprovalsReviewer(): "user" | "guardian_subagent" {
|
||||
return process.env.OPENCLAW_CODEX_APP_SERVER_GUARDIAN === "1" ? "guardian_subagent" : "user";
|
||||
}
|
||||
|
||||
function resolveReasoningEffort(
|
||||
thinkLevel: EmbeddedRunAttemptParams["thinkLevel"],
|
||||
): "minimal" | "low" | "medium" | "high" | "xhigh" | null {
|
||||
@@ -633,6 +638,6 @@ export const __testing = {
|
||||
clientFactory = factory;
|
||||
},
|
||||
resetCodexAppServerClientFactoryForTests(): void {
|
||||
clientFactory = getSharedCodexAppServerClient;
|
||||
clientFactory = (startOptions) => getSharedCodexAppServerClient({ startOptions });
|
||||
},
|
||||
} as const;
|
||||
|
||||
84
extensions/codex/src/commands.test.ts
Normal file
84
extensions/codex/src/commands.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resetSharedCodexAppServerClientForTests } from "./app-server/client.js";
|
||||
import { handleCodexCommand } from "./commands.js";
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
function createContext(args: string, sessionFile?: string): PluginCommandContext {
|
||||
return {
|
||||
channel: "test",
|
||||
isAuthorizedSender: true,
|
||||
args,
|
||||
commandBody: `/codex ${args}`,
|
||||
config: {},
|
||||
sessionFile,
|
||||
requestConversationBinding: async () => ({ status: "error", message: "unused" }),
|
||||
detachConversationBinding: async () => ({ removed: false }),
|
||||
getCurrentConversationBinding: async () => null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("codex command", () => {
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-command-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetSharedCodexAppServerClientForTests();
|
||||
vi.restoreAllMocks();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("attaches the current session to an existing Codex thread", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
vi.spyOn(
|
||||
await import("./app-server/client.js"),
|
||||
"requestCodexAppServerJson",
|
||||
).mockImplementation(async ({ method, requestParams }) => {
|
||||
requests.push({ method, params: requestParams });
|
||||
return {
|
||||
thread: { id: "thread-123", cwd: "/repo" },
|
||||
model: "gpt-5.4",
|
||||
modelProvider: "openai",
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleCodexCommand(createContext("resume thread-123", sessionFile)),
|
||||
).resolves.toEqual({
|
||||
text: "Attached this OpenClaw session to Codex thread thread-123.",
|
||||
});
|
||||
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
method: "thread/resume",
|
||||
params: { threadId: "thread-123", persistExtendedHistory: true },
|
||||
},
|
||||
]);
|
||||
await expect(fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8")).resolves.toContain(
|
||||
'"threadId": "thread-123"',
|
||||
);
|
||||
});
|
||||
|
||||
it("shows model ids from Codex app-server", async () => {
|
||||
vi.spyOn(await import("./app-server/client.js"), "listCodexAppServerModels").mockResolvedValue({
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
model: "gpt-5.4",
|
||||
inputModalities: ["text"],
|
||||
supportedReasoningEfforts: ["medium"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(handleCodexCommand(createContext("models"))).resolves.toEqual({
|
||||
text: "Codex models:\n- gpt-5.4",
|
||||
});
|
||||
});
|
||||
});
|
||||
299
extensions/codex/src/commands.ts
Normal file
299
extensions/codex/src/commands.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import type {
|
||||
OpenClawPluginCommandDefinition,
|
||||
PluginCommandContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { listCodexAppServerModels, requestCodexAppServerJson } from "./app-server/client.js";
|
||||
import { resolveCodexAppServerRuntimeOptions } from "./app-server/config.js";
|
||||
import { isJsonObject, type JsonObject, type JsonValue } from "./app-server/protocol.js";
|
||||
import {
|
||||
readCodexAppServerBinding,
|
||||
writeCodexAppServerBinding,
|
||||
} from "./app-server/session-binding.js";
|
||||
|
||||
export function createCodexCommand(options: {
|
||||
pluginConfig?: unknown;
|
||||
}): OpenClawPluginCommandDefinition {
|
||||
return {
|
||||
name: "codex",
|
||||
description: "Inspect and control the Codex app-server harness",
|
||||
acceptsArgs: true,
|
||||
requireAuth: true,
|
||||
handler: (ctx) => handleCodexCommand(ctx, options),
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleCodexCommand(
|
||||
ctx: PluginCommandContext,
|
||||
options: { pluginConfig?: unknown } = {},
|
||||
): Promise<{ text: string }> {
|
||||
const [subcommand = "status", ...rest] = splitArgs(ctx.args);
|
||||
const normalized = subcommand.toLowerCase();
|
||||
if (normalized === "help") {
|
||||
return { text: buildHelp() };
|
||||
}
|
||||
if (normalized === "status") {
|
||||
return { text: await buildStatus(options.pluginConfig) };
|
||||
}
|
||||
if (normalized === "models") {
|
||||
return { text: await buildModels(options.pluginConfig) };
|
||||
}
|
||||
if (normalized === "threads") {
|
||||
return { text: await buildThreads(options.pluginConfig, rest.join(" ")) };
|
||||
}
|
||||
if (normalized === "resume") {
|
||||
return { text: await resumeThread(ctx, options.pluginConfig, rest[0]) };
|
||||
}
|
||||
if (normalized === "compact") {
|
||||
return {
|
||||
text: await startThreadAction(
|
||||
ctx,
|
||||
options.pluginConfig,
|
||||
"thread/compact/start",
|
||||
"compaction",
|
||||
),
|
||||
};
|
||||
}
|
||||
if (normalized === "review") {
|
||||
return { text: await startThreadAction(ctx, options.pluginConfig, "review/start", "review") };
|
||||
}
|
||||
if (normalized === "mcp") {
|
||||
return { text: await buildList(options.pluginConfig, "mcpServerStatus/list", "MCP servers") };
|
||||
}
|
||||
if (normalized === "skills") {
|
||||
return { text: await buildList(options.pluginConfig, "skills/list", "Codex skills") };
|
||||
}
|
||||
if (normalized === "account") {
|
||||
return { text: await buildAccount(options.pluginConfig) };
|
||||
}
|
||||
return { text: `Unknown Codex command: ${subcommand}\n\n${buildHelp()}` };
|
||||
}
|
||||
|
||||
async function buildStatus(pluginConfig: unknown): Promise<string> {
|
||||
const [models, account, limits, mcps, skills] = await Promise.all([
|
||||
safeValue(() => listCodexAppServerModels(requestOptions(pluginConfig, 20))),
|
||||
safeValue(() => codexRequest(pluginConfig, "account/read", {})),
|
||||
safeValue(() => codexRequest(pluginConfig, "account/rateLimits/read", {})),
|
||||
safeValue(() => codexRequest(pluginConfig, "mcpServerStatus/list", { limit: 100 })),
|
||||
safeValue(() => codexRequest(pluginConfig, "skills/list", {})),
|
||||
]);
|
||||
|
||||
const connected = models.ok || account.ok || limits.ok || mcps.ok || skills.ok;
|
||||
const lines = [`Codex app-server: ${connected ? "connected" : "unavailable"}`];
|
||||
if (models.ok) {
|
||||
lines.push(
|
||||
`Models: ${
|
||||
models.value.models
|
||||
.map((model) => model.id)
|
||||
.slice(0, 8)
|
||||
.join(", ") || "none"
|
||||
}`,
|
||||
);
|
||||
} else {
|
||||
lines.push(`Models: ${models.error}`);
|
||||
}
|
||||
lines.push(`Account: ${account.ok ? summarizeAccount(account.value) : account.error}`);
|
||||
lines.push(`Rate limits: ${limits.ok ? summarizeArrayLike(limits.value) : limits.error}`);
|
||||
lines.push(`MCP servers: ${mcps.ok ? summarizeArrayLike(mcps.value) : mcps.error}`);
|
||||
lines.push(`Skills: ${skills.ok ? summarizeArrayLike(skills.value) : skills.error}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function buildModels(pluginConfig: unknown): Promise<string> {
|
||||
const result = await listCodexAppServerModels(requestOptions(pluginConfig, 100));
|
||||
if (result.models.length === 0) {
|
||||
return "No Codex app-server models returned.";
|
||||
}
|
||||
return [
|
||||
"Codex models:",
|
||||
...result.models.map((model) => `- ${model.id}${model.isDefault ? " (default)" : ""}`),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function buildThreads(pluginConfig: unknown, filter: string): Promise<string> {
|
||||
const response = await codexRequest(pluginConfig, "thread/list", {
|
||||
limit: 10,
|
||||
...(filter.trim() ? { filter: filter.trim() } : {}),
|
||||
});
|
||||
const threads = extractArray(response);
|
||||
if (threads.length === 0) {
|
||||
return "No Codex threads returned.";
|
||||
}
|
||||
return [
|
||||
"Codex threads:",
|
||||
...threads.slice(0, 10).map((thread) => {
|
||||
const record = isJsonObject(thread) ? thread : {};
|
||||
const id = readString(record, "threadId") ?? readString(record, "id") ?? "<unknown>";
|
||||
const title =
|
||||
readString(record, "title") ?? readString(record, "name") ?? readString(record, "summary");
|
||||
return `- ${id}${title ? ` - ${title}` : ""}`;
|
||||
}),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function buildAccount(pluginConfig: unknown): Promise<string> {
|
||||
const [account, limits] = await Promise.all([
|
||||
safeValue(() => codexRequest(pluginConfig, "account/read", {})),
|
||||
safeValue(() => codexRequest(pluginConfig, "account/rateLimits/read", {})),
|
||||
]);
|
||||
return [
|
||||
`Account: ${account.ok ? summarizeAccount(account.value) : account.error}`,
|
||||
`Rate limits: ${limits.ok ? summarizeArrayLike(limits.value) : limits.error}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function buildList(pluginConfig: unknown, method: string, label: string): Promise<string> {
|
||||
const response = await codexRequest(pluginConfig, method, { limit: 100 });
|
||||
const entries = extractArray(response);
|
||||
if (entries.length === 0) {
|
||||
return `${label}: none returned.`;
|
||||
}
|
||||
return [
|
||||
`${label}:`,
|
||||
...entries.slice(0, 25).map((entry) => {
|
||||
const record = isJsonObject(entry) ? entry : {};
|
||||
return `- ${readString(record, "name") ?? readString(record, "id") ?? JSON.stringify(entry)}`;
|
||||
}),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function resumeThread(
|
||||
ctx: PluginCommandContext,
|
||||
pluginConfig: unknown,
|
||||
threadId: string | undefined,
|
||||
): Promise<string> {
|
||||
const normalizedThreadId = threadId?.trim();
|
||||
if (!normalizedThreadId) {
|
||||
return "Usage: /codex resume <thread-id>";
|
||||
}
|
||||
if (!ctx.sessionFile) {
|
||||
return "Cannot attach a Codex thread because this command did not include an OpenClaw session file.";
|
||||
}
|
||||
const response = await codexRequest(pluginConfig, "thread/resume", {
|
||||
threadId: normalizedThreadId,
|
||||
persistExtendedHistory: true,
|
||||
});
|
||||
const thread = isJsonObject(response) && isJsonObject(response.thread) ? response.thread : {};
|
||||
const effectiveThreadId = readString(thread, "id") ?? normalizedThreadId;
|
||||
await writeCodexAppServerBinding(ctx.sessionFile, {
|
||||
threadId: effectiveThreadId,
|
||||
cwd: readString(thread, "cwd") ?? "",
|
||||
model: isJsonObject(response) ? readString(response, "model") : undefined,
|
||||
modelProvider: isJsonObject(response) ? readString(response, "modelProvider") : undefined,
|
||||
});
|
||||
return `Attached this OpenClaw session to Codex thread ${effectiveThreadId}.`;
|
||||
}
|
||||
|
||||
async function startThreadAction(
|
||||
ctx: PluginCommandContext,
|
||||
pluginConfig: unknown,
|
||||
method: string,
|
||||
label: string,
|
||||
): Promise<string> {
|
||||
if (!ctx.sessionFile) {
|
||||
return `Cannot start Codex ${label} because this command did not include an OpenClaw session file.`;
|
||||
}
|
||||
const binding = await readCodexAppServerBinding(ctx.sessionFile);
|
||||
if (!binding?.threadId) {
|
||||
return `No Codex thread is attached to this OpenClaw session yet.`;
|
||||
}
|
||||
await codexRequest(pluginConfig, method, { threadId: binding.threadId });
|
||||
return `Started Codex ${label} for thread ${binding.threadId}.`;
|
||||
}
|
||||
|
||||
async function codexRequest(
|
||||
pluginConfig: unknown,
|
||||
method: string,
|
||||
requestParams?: JsonValue,
|
||||
): Promise<JsonValue | undefined> {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
return await requestCodexAppServerJson({
|
||||
method,
|
||||
requestParams,
|
||||
timeoutMs: runtime.requestTimeoutMs,
|
||||
startOptions: runtime.start,
|
||||
});
|
||||
}
|
||||
|
||||
function requestOptions(pluginConfig: unknown, limit: number) {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
return {
|
||||
limit,
|
||||
timeoutMs: runtime.requestTimeoutMs,
|
||||
startOptions: runtime.start,
|
||||
};
|
||||
}
|
||||
|
||||
async function safeValue<T>(
|
||||
read: () => Promise<T>,
|
||||
): Promise<{ ok: true; value: T } | { ok: false; error: string }> {
|
||||
try {
|
||||
return { ok: true, value: await read() };
|
||||
} catch (error) {
|
||||
return { ok: false, error: formatError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeAccount(value: JsonValue | undefined): string {
|
||||
if (!isJsonObject(value)) {
|
||||
return "unavailable";
|
||||
}
|
||||
return (
|
||||
readString(value, "email") ??
|
||||
readString(value, "accountEmail") ??
|
||||
readString(value, "planType") ??
|
||||
readString(value, "id") ??
|
||||
"available"
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeArrayLike(value: JsonValue | undefined): string {
|
||||
const entries = extractArray(value);
|
||||
if (entries.length === 0) {
|
||||
return "none returned";
|
||||
}
|
||||
return `${entries.length}`;
|
||||
}
|
||||
|
||||
function extractArray(value: JsonValue | undefined): JsonValue[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
if (!isJsonObject(value)) {
|
||||
return [];
|
||||
}
|
||||
for (const key of ["data", "items", "threads", "models", "skills", "servers", "rateLimits"]) {
|
||||
const child = value[key];
|
||||
if (Array.isArray(child)) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function readString(record: JsonObject, key: string): string | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function splitArgs(value: string | undefined): string[] {
|
||||
return (value ?? "").trim().split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function buildHelp(): string {
|
||||
return [
|
||||
"Codex commands:",
|
||||
"- /codex status",
|
||||
"- /codex models",
|
||||
"- /codex threads [filter]",
|
||||
"- /codex resume <thread-id>",
|
||||
"- /codex compact",
|
||||
"- /codex review",
|
||||
"- /codex account",
|
||||
"- /codex mcp",
|
||||
"- /codex skills",
|
||||
].join("\n");
|
||||
}
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -423,6 +423,9 @@ importers:
|
||||
'@mariozechner/pi-coding-agent':
|
||||
specifier: 0.65.2
|
||||
version: 0.65.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||
ws:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
devDependencies:
|
||||
'@openclaw/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
|
||||
@@ -41,6 +41,7 @@ export const handlePluginCommand: CommandHandler = async (
|
||||
gatewayClientScopes: params.ctx.GatewayClientScopes,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionEntry?.sessionId,
|
||||
sessionFile: params.sessionEntry?.sessionFile,
|
||||
commandBody: command.commandBodyNormalized,
|
||||
config: cfg,
|
||||
from: command.from,
|
||||
|
||||
@@ -162,6 +162,7 @@ export async function executePluginCommand(params: {
|
||||
gatewayClientScopes?: PluginCommandContext["gatewayClientScopes"];
|
||||
sessionKey?: PluginCommandContext["sessionKey"];
|
||||
sessionId?: PluginCommandContext["sessionId"];
|
||||
sessionFile?: PluginCommandContext["sessionFile"];
|
||||
commandBody: string;
|
||||
config: OpenClawConfig;
|
||||
from?: PluginCommandContext["from"];
|
||||
@@ -202,6 +203,7 @@ export async function executePluginCommand(params: {
|
||||
gatewayClientScopes: params.gatewayClientScopes,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
sessionFile: params.sessionFile,
|
||||
args: sanitizedArgs,
|
||||
commandBody,
|
||||
config,
|
||||
|
||||
@@ -1822,6 +1822,8 @@ export type PluginCommandContext = {
|
||||
sessionKey?: string;
|
||||
/** Ephemeral host session id for the active conversation when available. */
|
||||
sessionId?: string;
|
||||
/** Transcript file for the active OpenClaw session when available. */
|
||||
sessionFile?: string;
|
||||
/** Raw command arguments after the command name */
|
||||
args?: string;
|
||||
/** The full normalized command body */
|
||||
|
||||
Reference in New Issue
Block a user