Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
23
extensions/codex-supervisor/src/api.ts
Normal file
23
extensions/codex-supervisor/src/api.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Public Codex Supervisor API barrel for plugin tools, MCP serving, config, and
|
||||
* session types.
|
||||
*/
|
||||
export {
|
||||
CodexSupervisorPluginConfigSchema,
|
||||
loadCodexSupervisorEndpoints,
|
||||
resolveCodexSupervisorPluginConfig,
|
||||
} from "./config.js";
|
||||
export { CodexSupervisor } from "./supervisor.js";
|
||||
export { createCodexSupervisorTools } from "./plugin-tools.js";
|
||||
export { createCodexSupervisorMcpServer, serveCodexSupervisorMcp } from "./mcp-server.js";
|
||||
export type { CodexSupervisorPluginConfig, ResolvedCodexSupervisorPluginConfig } from "./config.js";
|
||||
export type {
|
||||
CodexJsonRpcConnection,
|
||||
CodexSupervisorEndpoint,
|
||||
CodexSupervisorEndpointHealth,
|
||||
CodexSupervisorSendResult,
|
||||
CodexSupervisorSession,
|
||||
CodexSupervisorSessionListResult,
|
||||
CodexSupervisorThreadStatus,
|
||||
CodexSupervisorTurnMode,
|
||||
} from "./types.js";
|
||||
213
extensions/codex-supervisor/src/config.ts
Normal file
213
extensions/codex-supervisor/src/config.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
/**
|
||||
* Config parsing for Codex Supervisor endpoints and safety gates.
|
||||
*/
|
||||
import { Type, type Static } from "typebox";
|
||||
import type { CodexSupervisorEndpoint } from "./types.js";
|
||||
|
||||
const ENDPOINTS_ENV = "OPENCLAW_CODEX_SUPERVISOR_ENDPOINTS";
|
||||
|
||||
const StdioEndpointSchema = Type.Object(
|
||||
{
|
||||
id: Type.Optional(Type.String()),
|
||||
label: Type.Optional(Type.String()),
|
||||
transport: Type.Optional(Type.Literal("stdio-proxy")),
|
||||
command: Type.Optional(Type.String()),
|
||||
args: Type.Optional(Type.Array(Type.String())),
|
||||
cwd: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const WebSocketEndpointSchema = Type.Object(
|
||||
{
|
||||
id: Type.Optional(Type.String()),
|
||||
label: Type.Optional(Type.String()),
|
||||
transport: Type.Literal("websocket"),
|
||||
url: Type.String(),
|
||||
authTokenEnv: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* Plugin config schema accepted by the bundled plugin manifest.
|
||||
*/
|
||||
export const CodexSupervisorPluginConfigSchema = Type.Object(
|
||||
{
|
||||
endpoints: Type.Optional(
|
||||
Type.Array(Type.Union([StdioEndpointSchema, WebSocketEndpointSchema])),
|
||||
),
|
||||
allowRawTranscripts: Type.Optional(Type.Boolean({ default: false })),
|
||||
allowWriteControls: Type.Optional(Type.Boolean({ default: false })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** Raw plugin config shape accepted from OpenClaw config. */
|
||||
export type CodexSupervisorPluginConfig = Static<typeof CodexSupervisorPluginConfigSchema>;
|
||||
|
||||
/** Normalized config consumed by plugin registration and MCP serving. */
|
||||
export type ResolvedCodexSupervisorPluginConfig = {
|
||||
endpoints: CodexSupervisorEndpoint[];
|
||||
allowRawTranscripts: boolean;
|
||||
allowWriteControls: boolean;
|
||||
};
|
||||
|
||||
function normalizeEndpointId(value: string, index: number): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
return trimmed.replace(/[^a-zA-Z0-9_.:-]/g, "-");
|
||||
}
|
||||
return `endpoint-${index + 1}`;
|
||||
}
|
||||
|
||||
function parseEndpointRecord(value: unknown, index: number): CodexSupervisorEndpoint | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const transport = typeof value.transport === "string" ? value.transport : undefined;
|
||||
const id =
|
||||
typeof value.id === "string"
|
||||
? normalizeEndpointId(value.id, index)
|
||||
: normalizeEndpointId(typeof value.label === "string" ? value.label : "", index);
|
||||
const label = typeof value.label === "string" ? value.label : undefined;
|
||||
if (transport === "websocket" && typeof value.url === "string") {
|
||||
return {
|
||||
id,
|
||||
transport,
|
||||
url: value.url,
|
||||
...(label ? { label } : {}),
|
||||
...(typeof value.authTokenEnv === "string" ? { authTokenEnv: value.authTokenEnv } : {}),
|
||||
};
|
||||
}
|
||||
if (transport === "stdio-proxy" || transport === undefined) {
|
||||
const args = Array.isArray(value.args)
|
||||
? value.args.filter((entry): entry is string => typeof entry === "string")
|
||||
: undefined;
|
||||
return {
|
||||
id,
|
||||
transport: "stdio-proxy",
|
||||
...(label ? { label } : {}),
|
||||
...(typeof value.command === "string" ? { command: value.command } : {}),
|
||||
...(args && args.length > 0 ? { args } : {}),
|
||||
...(typeof value.cwd === "string" ? { cwd: value.cwd } : {}),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function requireUniqueEndpointIds(endpoints: CodexSupervisorEndpoint[]): CodexSupervisorEndpoint[] {
|
||||
const seen = new Set<string>();
|
||||
for (const endpoint of endpoints) {
|
||||
if (seen.has(endpoint.id)) {
|
||||
throw new Error(`duplicate Codex supervisor endpoint id: ${endpoint.id}`);
|
||||
}
|
||||
seen.add(endpoint.id);
|
||||
}
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
function endpointFromToken(token: string, index: number): CodexSupervisorEndpoint | undefined {
|
||||
const trimmed = token.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
trimmed.startsWith("ws://") ||
|
||||
trimmed.startsWith("wss://") ||
|
||||
trimmed.startsWith("unix://")
|
||||
) {
|
||||
return {
|
||||
id: normalizeEndpointId("", index),
|
||||
transport: "websocket",
|
||||
url: trimmed,
|
||||
};
|
||||
}
|
||||
if (trimmed === "local" || trimmed === "proxy" || trimmed === "stdio") {
|
||||
return {
|
||||
id: "local",
|
||||
label: "local Codex app-server daemon",
|
||||
transport: "websocket",
|
||||
url: "unix://",
|
||||
};
|
||||
}
|
||||
const separatorIndex = trimmed.indexOf("=");
|
||||
const id = separatorIndex >= 0 ? trimmed.slice(0, separatorIndex) : trimmed;
|
||||
const url = separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : undefined;
|
||||
if (url?.startsWith("ws://") || url?.startsWith("wss://") || url?.startsWith("unix://")) {
|
||||
return {
|
||||
id: normalizeEndpointId(id ?? "", index),
|
||||
transport: "websocket",
|
||||
url,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads endpoint definitions from environment, defaulting to the local Codex
|
||||
* app-server unix socket.
|
||||
*/
|
||||
export function loadCodexSupervisorEndpoints(
|
||||
env: Pick<NodeJS.ProcessEnv, string> = process.env,
|
||||
): CodexSupervisorEndpoint[] {
|
||||
const raw = env[ENDPOINTS_ENV]?.trim();
|
||||
if (!raw) {
|
||||
return requireUniqueEndpointIds([
|
||||
{
|
||||
id: "local",
|
||||
label: "local Codex app-server daemon",
|
||||
transport: "websocket",
|
||||
url: "unix://",
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (raw.startsWith("[")) {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`${ENDPOINTS_ENV} must be a JSON array`);
|
||||
}
|
||||
return requireUniqueEndpointIds(
|
||||
parsed
|
||||
.map((entry, index) => parseEndpointRecord(entry, index))
|
||||
.filter((entry): entry is CodexSupervisorEndpoint => Boolean(entry)),
|
||||
);
|
||||
}
|
||||
return requireUniqueEndpointIds(
|
||||
raw
|
||||
.split(",")
|
||||
.map(endpointFromToken)
|
||||
.filter((entry): entry is CodexSupervisorEndpoint => Boolean(entry)),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeConfiguredEndpoints(
|
||||
endpoints: CodexSupervisorPluginConfig["endpoints"],
|
||||
): CodexSupervisorEndpoint[] | undefined {
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = endpoints
|
||||
.map((entry, index) => parseEndpointRecord(entry, index))
|
||||
.filter((entry): entry is CodexSupervisorEndpoint => Boolean(entry));
|
||||
return normalized.length > 0 ? requireUniqueEndpointIds(normalized) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves raw plugin config and env endpoints into validated runtime config.
|
||||
*/
|
||||
export function resolveCodexSupervisorPluginConfig(
|
||||
rawConfig: unknown,
|
||||
env: Pick<NodeJS.ProcessEnv, string> = process.env,
|
||||
): ResolvedCodexSupervisorPluginConfig {
|
||||
const config =
|
||||
rawConfig && typeof rawConfig === "object" && !Array.isArray(rawConfig)
|
||||
? (rawConfig as CodexSupervisorPluginConfig)
|
||||
: {};
|
||||
return {
|
||||
endpoints: normalizeConfiguredEndpoints(config.endpoints) ?? loadCodexSupervisorEndpoints(env),
|
||||
allowRawTranscripts: config.allowRawTranscripts === true,
|
||||
allowWriteControls: config.allowWriteControls === true,
|
||||
};
|
||||
}
|
||||
370
extensions/codex-supervisor/src/json-rpc-client.ts
Normal file
370
extensions/codex-supervisor/src/json-rpc-client.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* JSON-RPC transports for Codex app-server connections over stdio proxies or
|
||||
* websocket/unix-socket endpoints.
|
||||
*/
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as net from "node:net";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import WebSocket from "ws";
|
||||
import type { CodexJsonRpcConnection, CodexSupervisorEndpoint } from "./types.js";
|
||||
|
||||
type PendingRequest = {
|
||||
reject: (error: Error) => void;
|
||||
resolve: (value: unknown) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
function formatJsonRpcError(message: Record<string, unknown>): Error {
|
||||
const error = isRecord(message.error) ? message.error : {};
|
||||
const detail =
|
||||
typeof error.message === "string" ? error.message : "Codex app-server request failed";
|
||||
return new Error(detail);
|
||||
}
|
||||
|
||||
function formatMalformedMessageError(error: unknown): Error {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return new Error(`Malformed Codex app-server message: ${detail}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces denial responses for app-server approval requests the supervisor
|
||||
* deliberately cannot grant.
|
||||
*/
|
||||
export function resolveSafeApprovalResult(method: string): Record<string, unknown> | undefined {
|
||||
if (method === "item/tool/call") {
|
||||
return {
|
||||
contentItems: [
|
||||
{
|
||||
type: "inputText",
|
||||
text: "OpenClaw Codex supervisor did not register a handler for this app-server tool call.",
|
||||
},
|
||||
],
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
if (method === "item/commandExecution/requestApproval") {
|
||||
return { decision: "decline" };
|
||||
}
|
||||
if (method === "item/fileChange/requestApproval") {
|
||||
return { decision: "decline" };
|
||||
}
|
||||
if (method === "item/permissions/requestApproval") {
|
||||
return { permissions: {}, scope: "turn" };
|
||||
}
|
||||
if (method.endsWith("/requestApproval")) {
|
||||
return {
|
||||
decision: "decline",
|
||||
reason: "OpenClaw Codex supervisor does not grant native approvals.",
|
||||
};
|
||||
}
|
||||
if (method === "item/tool/requestUserInput") {
|
||||
return { answers: {} };
|
||||
}
|
||||
if (method === "mcpServer/elicitation/request") {
|
||||
return { action: "decline" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
abstract class BaseCodexJsonRpcConnection implements CodexJsonRpcConnection {
|
||||
private readonly pending = new Map<string, PendingRequest>();
|
||||
private closedError: Error | undefined;
|
||||
|
||||
abstract close(): Promise<void>;
|
||||
protected abstract sendRaw(line: string): void;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.request("initialize", {
|
||||
clientInfo: {
|
||||
name: "openclaw-codex-supervisor",
|
||||
title: "OpenClaw Codex Supervisor",
|
||||
version: "0.1.0",
|
||||
},
|
||||
capabilities: {
|
||||
experimentalApi: true,
|
||||
},
|
||||
});
|
||||
this.notify("initialized");
|
||||
}
|
||||
|
||||
request(method: string, params?: Record<string, unknown>): Promise<unknown> {
|
||||
if (this.closedError) {
|
||||
return Promise.reject(this.closedError);
|
||||
}
|
||||
const id = randomUUID();
|
||||
const payload: Record<string, unknown> = { id, method, params: params ?? {} };
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Codex app-server request timed out: ${method}`));
|
||||
}, 60_000);
|
||||
this.pending.set(id, { resolve, reject, timeout });
|
||||
try {
|
||||
this.sendRaw(JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
this.pending.delete(id);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
notify(method: string, params?: Record<string, unknown>): void {
|
||||
const payload: Record<string, unknown> = { method, params: params ?? null };
|
||||
this.sendRaw(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
protected handleMessage(message: unknown): void {
|
||||
if (!isRecord(message)) {
|
||||
return;
|
||||
}
|
||||
const id =
|
||||
typeof message.id === "string" || typeof message.id === "number" ? message.id : undefined;
|
||||
const method = typeof message.method === "string" ? message.method : undefined;
|
||||
if (id !== undefined && method) {
|
||||
const result = resolveSafeApprovalResult(method);
|
||||
// The supervisor is read/steer tooling, not a native approval delegate;
|
||||
// unknown app-server requests fail closed with either a denial or -32601.
|
||||
this.sendRaw(
|
||||
JSON.stringify(
|
||||
result === undefined
|
||||
? {
|
||||
id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: `OpenClaw Codex supervisor cannot handle app-server request: ${method}`,
|
||||
},
|
||||
}
|
||||
: { id, result },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (id !== undefined) {
|
||||
const pending = this.pending.get(String(id));
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timeout);
|
||||
this.pending.delete(String(id));
|
||||
if ("error" in message) {
|
||||
pending.reject(formatJsonRpcError(message));
|
||||
return;
|
||||
}
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
}
|
||||
|
||||
protected rejectAll(error: Error): void {
|
||||
for (const [id, pending] of this.pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
this.pending.delete(id);
|
||||
pending.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
protected fail(error: Error): void {
|
||||
this.closedError ??= error;
|
||||
this.rejectAll(this.closedError);
|
||||
}
|
||||
}
|
||||
|
||||
class StdioCodexJsonRpcConnection extends BaseCodexJsonRpcConnection {
|
||||
private buffer = "";
|
||||
private readonly proc: ChildProcessWithoutNullStreams;
|
||||
private readonly stderrTail: string[] = [];
|
||||
|
||||
constructor(endpoint: Extract<CodexSupervisorEndpoint, { transport: "stdio-proxy" }>) {
|
||||
super();
|
||||
this.proc = spawn(
|
||||
endpoint.command ?? "codex",
|
||||
endpoint.args ?? ["app-server", "--listen", "stdio://"],
|
||||
{
|
||||
cwd: endpoint.cwd,
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
this.proc.stdout.setEncoding("utf8");
|
||||
this.proc.stderr.setEncoding("utf8");
|
||||
this.proc.stdout.on("data", (chunk: string) => this.handleStdout(chunk));
|
||||
this.proc.stderr.on("data", (chunk: string) => {
|
||||
this.stderrTail.push(...chunk.split(/\r?\n/).filter(Boolean));
|
||||
this.stderrTail.splice(0, Math.max(0, this.stderrTail.length - 40));
|
||||
});
|
||||
this.proc.stdin.once("error", (error) => this.fail(error));
|
||||
this.proc.once("error", (error) => this.fail(error));
|
||||
this.proc.once("close", () =>
|
||||
this.fail(
|
||||
new Error(
|
||||
`Codex app-server stdio transport closed. stderr_tail=${this.stderrTail.join("\n").slice(0, 1200)}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
protected sendRaw(line: string): void {
|
||||
this.proc.stdin.write(`${line}\n`, (error) => {
|
||||
if (error) {
|
||||
this.fail(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.proc.stdin.end();
|
||||
this.proc.kill("SIGTERM");
|
||||
}
|
||||
|
||||
private handleStdout(chunk: string): void {
|
||||
this.buffer += chunk;
|
||||
for (;;) {
|
||||
const index = this.buffer.indexOf("\n");
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
const line = this.buffer.slice(0, index).trim();
|
||||
this.buffer = this.buffer.slice(index + 1);
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
this.handleMessage(JSON.parse(line) as unknown);
|
||||
} catch (error) {
|
||||
this.fail(formatMalformedMessageError(error));
|
||||
void this.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function defaultCodexControlSocketPath(): string {
|
||||
const codexHome = process.env.CODEX_HOME?.trim() || path.join(os.homedir(), ".codex");
|
||||
return path.join(codexHome, "app-server-control", "app-server-control.sock");
|
||||
}
|
||||
|
||||
function resolveUnixWebSocketPath(url: string): string {
|
||||
const suffix = url.slice("unix://".length);
|
||||
return suffix || defaultCodexControlSocketPath();
|
||||
}
|
||||
|
||||
function connectCodexSupervisorUnixSocket(url: string): net.Socket {
|
||||
return net.createConnection(resolveUnixWebSocketPath(url));
|
||||
}
|
||||
|
||||
function websocketMessageToString(data: WebSocket.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");
|
||||
}
|
||||
|
||||
class WebSocketCodexJsonRpcConnection extends BaseCodexJsonRpcConnection {
|
||||
private readonly ws: WebSocket;
|
||||
private readonly openPromise: Promise<void>;
|
||||
private closing = false;
|
||||
|
||||
constructor(endpoint: Extract<CodexSupervisorEndpoint, { transport: "websocket" }>) {
|
||||
super();
|
||||
const headers: Record<string, string> = {};
|
||||
if (endpoint.authTokenEnv) {
|
||||
const token = process.env[endpoint.authTokenEnv];
|
||||
if (token) {
|
||||
headers.authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
this.ws = endpoint.url.startsWith("unix://")
|
||||
? new WebSocket("ws://localhost/", {
|
||||
headers,
|
||||
createConnection: () => connectCodexSupervisorUnixSocket(endpoint.url),
|
||||
})
|
||||
: new WebSocket(endpoint.url, { headers });
|
||||
this.openPromise = new Promise((resolve, reject) => {
|
||||
this.ws.once("open", resolve);
|
||||
this.ws.once("error", reject);
|
||||
});
|
||||
this.ws.on("message", (data) => {
|
||||
const text = websocketMessageToString(data);
|
||||
try {
|
||||
this.handleMessage(JSON.parse(text) as unknown);
|
||||
} catch (error) {
|
||||
this.fail(formatMalformedMessageError(error));
|
||||
void this.close();
|
||||
}
|
||||
});
|
||||
this.ws.once("error", (error) => this.fail(error));
|
||||
this.ws.once("close", () => {
|
||||
if (!this.closing) {
|
||||
this.fail(new Error("Codex app-server websocket closed"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async ready(): Promise<void> {
|
||||
await this.openPromise;
|
||||
}
|
||||
|
||||
protected sendRaw(line: string): void {
|
||||
this.ws.send(line, (error) => {
|
||||
if (error) {
|
||||
this.fail(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closing = true;
|
||||
this.fail(new Error("Codex app-server websocket closed"));
|
||||
if (this.ws.readyState === WebSocket.CLOSED) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.ws.terminate();
|
||||
resolve();
|
||||
}, 1000);
|
||||
this.ws.once("close", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
if (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.close();
|
||||
} else {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens, initializes, and returns a JSON-RPC connection for one supervisor
|
||||
* endpoint.
|
||||
*/
|
||||
export async function connectCodexAppServerEndpoint(
|
||||
endpoint: CodexSupervisorEndpoint,
|
||||
): Promise<CodexJsonRpcConnection> {
|
||||
const connection =
|
||||
endpoint.transport === "websocket"
|
||||
? new WebSocketCodexJsonRpcConnection(endpoint)
|
||||
: new StdioCodexJsonRpcConnection(endpoint);
|
||||
try {
|
||||
if ("ready" in connection && typeof connection.ready === "function") {
|
||||
await connection.ready();
|
||||
}
|
||||
await connection.initialize();
|
||||
return connection;
|
||||
} catch (error) {
|
||||
await connection.close().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
18
extensions/codex-supervisor/src/mcp-serve.ts
Normal file
18
extensions/codex-supervisor/src/mcp-serve.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Standalone MCP server for OpenClaw Codex supervision.
|
||||
*
|
||||
* Run via: node --import tsx extensions/codex-supervisor/src/mcp-serve.ts
|
||||
*/
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { serveCodexSupervisorMcp } from "./mcp-server.js";
|
||||
|
||||
function formatErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
serveCodexSupervisorMcp().catch((err: unknown) => {
|
||||
process.stderr.write(`codex-supervisor-serve: ${formatErrorMessage(err)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
97
extensions/codex-supervisor/src/mcp-server.ts
Normal file
97
extensions/codex-supervisor/src/mcp-server.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Standalone MCP stdio server for exposing Codex Supervisor tools to trusted
|
||||
* MCP clients.
|
||||
*/
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { loadCodexSupervisorEndpoints } from "./config.js";
|
||||
import {
|
||||
registerCodexSupervisorMcpTools,
|
||||
type CodexSupervisorMcpToolOptions,
|
||||
} from "./mcp-tools.js";
|
||||
import { CodexSupervisor } from "./supervisor.js";
|
||||
|
||||
const VERSION = "0.1.0";
|
||||
|
||||
function routeLogsToStderr(): void {
|
||||
const methods = ["log", "info", "warn", "error", "debug"] as const;
|
||||
for (const method of methods) {
|
||||
console[method] = (...args: unknown[]) => {
|
||||
process.stderr.write(`${args.map(String).join(" ")}\n`);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Options for creating or serving a Codex Supervisor MCP server. */
|
||||
export type CodexSupervisorMcpServeOptions = {
|
||||
supervisor?: CodexSupervisor;
|
||||
toolOptions?: CodexSupervisorMcpToolOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an MCP server and owns the supervisor instance unless one is supplied.
|
||||
*/
|
||||
export function createCodexSupervisorMcpServer(opts: CodexSupervisorMcpServeOptions = {}): {
|
||||
server: McpServer;
|
||||
supervisor: CodexSupervisor;
|
||||
close: () => Promise<void>;
|
||||
} {
|
||||
const supervisor = opts.supervisor ?? new CodexSupervisor(loadCodexSupervisorEndpoints());
|
||||
const server = new McpServer({ name: "openclaw-codex-supervisor", version: VERSION });
|
||||
registerCodexSupervisorMcpTools(server, supervisor, opts.toolOptions);
|
||||
return {
|
||||
server,
|
||||
supervisor,
|
||||
close: async () => {
|
||||
await supervisor.close();
|
||||
await server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves Codex Supervisor tools over MCP stdio until transport or process
|
||||
* shutdown.
|
||||
*/
|
||||
export async function serveCodexSupervisorMcp(
|
||||
opts: CodexSupervisorMcpServeOptions = {},
|
||||
): Promise<void> {
|
||||
routeLogsToStderr();
|
||||
const { server, close } = createCodexSupervisorMcpServer(opts);
|
||||
const transport = new StdioServerTransport();
|
||||
|
||||
let shuttingDown = false;
|
||||
let resolveClosed!: () => void;
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
resolveClosed = resolve;
|
||||
});
|
||||
|
||||
const shutdown = () => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
process.stdin.off("end", shutdown);
|
||||
process.stdin.off("close", shutdown);
|
||||
process.off("SIGINT", shutdown);
|
||||
process.off("SIGTERM", shutdown);
|
||||
// The SDK exposes this callback slot but not a stable setter; clear it so
|
||||
// close() cannot recursively re-enter shutdown.
|
||||
transport["onclose"] = undefined;
|
||||
close().then(resolveClosed, resolveClosed);
|
||||
};
|
||||
|
||||
transport["onclose"] = shutdown;
|
||||
process.stdin.once("end", shutdown);
|
||||
process.stdin.once("close", shutdown);
|
||||
process.once("SIGINT", shutdown);
|
||||
process.once("SIGTERM", shutdown);
|
||||
|
||||
try {
|
||||
await server.connect(transport);
|
||||
await closed;
|
||||
} finally {
|
||||
shutdown();
|
||||
await closed;
|
||||
}
|
||||
}
|
||||
105
extensions/codex-supervisor/src/mcp-tools.test.ts
Normal file
105
extensions/codex-supervisor/src/mcp-tools.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// Codex Supervisor tests cover mcp tools plugin behavior.
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
redactCodexSupervisorEndpoint,
|
||||
redactCodexSupervisorValue,
|
||||
registerCodexSupervisorMcpTools,
|
||||
sanitizeCodexSupervisorSessionListResult,
|
||||
} from "./mcp-tools.js";
|
||||
import type { CodexSupervisor } from "./supervisor.js";
|
||||
|
||||
describe("redactCodexSupervisorValue", () => {
|
||||
it("redacts sensitive keys and common bearer-like secrets", () => {
|
||||
expect(
|
||||
redactCodexSupervisorValue({
|
||||
authorization: "Bearer abcdefghijklmnopqrstuvwxyz012345",
|
||||
nested: {
|
||||
apiKey: "sk-abcdefghijklmnopqrstuvwxyz012345",
|
||||
text: "token ghp_abcdefghijklmnopqrstuvwxyz012345 remains hidden",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
authorization: "[redacted]",
|
||||
nested: {
|
||||
apiKey: "[redacted]",
|
||||
text: "token [redacted] remains hidden",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactCodexSupervisorEndpoint", () => {
|
||||
it("removes websocket credentials and query values", () => {
|
||||
expect(
|
||||
redactCodexSupervisorEndpoint({
|
||||
id: "prod",
|
||||
transport: "websocket",
|
||||
url: "wss://user:secret@example.invalid/control?token=a=b",
|
||||
}),
|
||||
).toEqual({
|
||||
id: "prod",
|
||||
transport: "websocket",
|
||||
url: "wss://example.invalid/control?[redacted]",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeCodexSupervisorSessionListResult", () => {
|
||||
it("omits transcript-derived fields unless explicitly trusted", () => {
|
||||
const result = {
|
||||
sessions: [
|
||||
{
|
||||
endpointId: "local",
|
||||
threadId: "thread-1",
|
||||
status: "idle",
|
||||
preview: "first prompt",
|
||||
name: "thread title",
|
||||
},
|
||||
],
|
||||
errors: [{ endpointId: "down", ok: false, detail: "stderr secret" }],
|
||||
};
|
||||
|
||||
expect(sanitizeCodexSupervisorSessionListResult(result, false)).toEqual({
|
||||
sessions: [{ endpointId: "local", threadId: "thread-1", status: "idle" }],
|
||||
errors: [{ endpointId: "down", ok: false }],
|
||||
});
|
||||
expect(sanitizeCodexSupervisorSessionListResult(result, true)).toEqual(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerCodexSupervisorMcpTools", () => {
|
||||
it("uses per-server transcript policy when listing sessions", async () => {
|
||||
const handlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const server = {
|
||||
tool(name: string, _description: string, _schema: unknown, handler: unknown) {
|
||||
handlers.set(name, handler as (params: Record<string, unknown>) => Promise<unknown>);
|
||||
},
|
||||
} as unknown as McpServer;
|
||||
const supervisor = {
|
||||
listSessionSnapshot: async () => ({
|
||||
sessions: [
|
||||
{
|
||||
endpointId: "local",
|
||||
threadId: "thread-1",
|
||||
status: "idle",
|
||||
preview: "first prompt",
|
||||
name: "thread title",
|
||||
},
|
||||
],
|
||||
errors: [{ endpointId: "down", ok: false, detail: "stderr secret" }],
|
||||
}),
|
||||
} as unknown as CodexSupervisor;
|
||||
|
||||
registerCodexSupervisorMcpTools(server, supervisor, {
|
||||
rawTranscriptReadsAllowed: () => false,
|
||||
});
|
||||
|
||||
await expect(handlers.get("codex_sessions_list")?.({})).resolves.toMatchObject({
|
||||
structuredContent: {
|
||||
sessions: [{ endpointId: "local", threadId: "thread-1", status: "idle" }],
|
||||
errors: [{ endpointId: "down", ok: false }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
281
extensions/codex-supervisor/src/mcp-tools.ts
Normal file
281
extensions/codex-supervisor/src/mcp-tools.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* MCP tool registration plus redaction helpers for Codex Supervisor sessions
|
||||
* and endpoint metadata.
|
||||
*/
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import type { CodexSupervisor } from "./supervisor.js";
|
||||
import type {
|
||||
CodexSupervisorEndpoint,
|
||||
CodexSupervisorSession,
|
||||
CodexSupervisorSessionListResult,
|
||||
} from "./types.js";
|
||||
|
||||
/** Env gate for exposing transcript-derived fields through standalone MCP. */
|
||||
export const RAW_TRANSCRIPTS_ENV = "OPENCLAW_CODEX_SUPERVISOR_ALLOW_RAW_TRANSCRIPTS";
|
||||
/** Env gate for mutating/steering Codex sessions through standalone MCP. */
|
||||
export const WRITE_CONTROLS_ENV = "OPENCLAW_CODEX_SUPERVISOR_ALLOW_WRITE_CONTROLS";
|
||||
|
||||
/** Optional policy callbacks for standalone MCP tool exposure. */
|
||||
export type CodexSupervisorMcpToolOptions = {
|
||||
rawTranscriptReadsAllowed?: () => boolean;
|
||||
writeControlsAllowed?: () => boolean;
|
||||
};
|
||||
|
||||
function textResult(text: string, structuredContent?: Record<string, unknown>) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text }],
|
||||
...(structuredContent ? { structuredContent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function errorResult(message: string) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
function redactString(value: string): string {
|
||||
return value
|
||||
.replace(/\b(?:sk|glpat|xox[baprs])-[-_a-zA-Z0-9]{12,}\b/g, "[redacted]")
|
||||
.replace(/\b(?:ghp|gho|ghu|ghs)_[-_a-zA-Z0-9]{12,}\b/g, "[redacted]")
|
||||
.replace(/\bBearer\s+[-._~+/a-zA-Z0-9]+=*/g, "Bearer [redacted]");
|
||||
}
|
||||
|
||||
/**
|
||||
* Redacts common secret-bearing fields and token-like substrings before tool
|
||||
* results leave the supervisor.
|
||||
*/
|
||||
export function redactCodexSupervisorValue(value: unknown, key = ""): unknown {
|
||||
if (typeof value === "string") {
|
||||
if (/authorization|password|secret|token|api[-_]?key/i.test(key)) {
|
||||
return "[redacted]";
|
||||
}
|
||||
return redactString(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => redactCodexSupervisorValue(entry));
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([entryKey, entryValue]) => [
|
||||
entryKey,
|
||||
redactCodexSupervisorValue(entryValue, entryKey),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function redactEndpointUrl(value: string): string {
|
||||
if (value.startsWith("unix://")) {
|
||||
return "unix://";
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
if (url.search) {
|
||||
url.search = "?[redacted]";
|
||||
}
|
||||
return url.toString();
|
||||
} catch {
|
||||
return "[redacted]";
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns endpoint metadata safe for tool results. */
|
||||
export function redactCodexSupervisorEndpoint(
|
||||
endpoint: CodexSupervisorEndpoint,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
id: endpoint.id,
|
||||
transport: endpoint.transport,
|
||||
...(endpoint.label ? { label: endpoint.label } : {}),
|
||||
...(endpoint.transport === "websocket" ? { url: redactEndpointUrl(endpoint.url) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function rawTranscriptReadsAllowed(): boolean {
|
||||
return process.env[RAW_TRANSCRIPTS_ENV] === "1";
|
||||
}
|
||||
|
||||
function writeControlsAllowed(): boolean {
|
||||
return process.env[WRITE_CONTROLS_ENV] === "1";
|
||||
}
|
||||
|
||||
function rawTranscriptReadsAllowedFor(opts: CodexSupervisorMcpToolOptions): boolean {
|
||||
return opts.rawTranscriptReadsAllowed
|
||||
? opts.rawTranscriptReadsAllowed()
|
||||
: rawTranscriptReadsAllowed();
|
||||
}
|
||||
|
||||
function writeControlsAllowedFor(opts: CodexSupervisorMcpToolOptions): boolean {
|
||||
return opts.writeControlsAllowed ? opts.writeControlsAllowed() : writeControlsAllowed();
|
||||
}
|
||||
|
||||
function sanitizeSessionForMcp(
|
||||
session: CodexSupervisorSession,
|
||||
includeTranscriptDerivedFields: boolean,
|
||||
): Record<string, unknown> {
|
||||
const sanitized = redactCodexSupervisorValue(session) as Record<string, unknown>;
|
||||
if (!includeTranscriptDerivedFields) {
|
||||
delete sanitized.preview;
|
||||
delete sanitized.name;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes session-list output, optionally including transcript-derived
|
||||
* preview/name fields only when the caller has opted in.
|
||||
*/
|
||||
export function sanitizeCodexSupervisorSessionListResult(
|
||||
result: CodexSupervisorSessionListResult,
|
||||
includeTranscriptDerivedFields = rawTranscriptReadsAllowed(),
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
sessions: result.sessions.map((session) =>
|
||||
sanitizeSessionForMcp(session, includeTranscriptDerivedFields),
|
||||
),
|
||||
errors: includeTranscriptDerivedFields
|
||||
? redactCodexSupervisorValue(result.errors)
|
||||
: result.errors.map(({ endpointId, ok }) => ({ endpointId, ok })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers MCP tools for endpoint probing, session listing, reads, sends, and
|
||||
* interrupts.
|
||||
*/
|
||||
export function registerCodexSupervisorMcpTools(
|
||||
server: McpServer,
|
||||
supervisor: CodexSupervisor,
|
||||
opts: CodexSupervisorMcpToolOptions = {},
|
||||
): void {
|
||||
server.tool(
|
||||
"codex_endpoint_probe",
|
||||
"Check configured Codex app-server endpoints.",
|
||||
{},
|
||||
async () => {
|
||||
const endpoints = supervisor.listEndpoints().map(redactCodexSupervisorEndpoint);
|
||||
const health = (await supervisor.probeEndpoints()).map(({ endpointId, ok }) => ({
|
||||
endpointId,
|
||||
ok,
|
||||
}));
|
||||
return textResult(
|
||||
`codex endpoints: ${health.filter((entry) => entry.ok).length}/${health.length} ok`,
|
||||
{
|
||||
endpoints,
|
||||
health,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"codex_sessions_list",
|
||||
"List Codex sessions visible to the OpenClaw supervisor.",
|
||||
{
|
||||
include_stored: z.boolean().optional(),
|
||||
max_stored_sessions: z.number().int().min(1).max(1000).optional(),
|
||||
},
|
||||
async ({ include_stored, max_stored_sessions }) => {
|
||||
const result = await supervisor.listSessionSnapshot({
|
||||
includeStored: include_stored ?? false,
|
||||
maxStoredSessions: max_stored_sessions,
|
||||
});
|
||||
return textResult(
|
||||
`codex sessions: ${result.sessions.length}`,
|
||||
sanitizeCodexSupervisorSessionListResult(result, rawTranscriptReadsAllowedFor(opts)),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"codex_session_read",
|
||||
"Read one Codex session transcript from app-server.",
|
||||
{
|
||||
endpoint_id: z.string().optional(),
|
||||
thread_id: z.string().min(1),
|
||||
include_turns: z.boolean().optional(),
|
||||
},
|
||||
async ({ endpoint_id, thread_id, include_turns }) => {
|
||||
if (!rawTranscriptReadsAllowedFor(opts)) {
|
||||
return errorResult(
|
||||
`Codex session reads are disabled; set ${RAW_TRANSCRIPTS_ENV}=1 for a trusted supervisor-only MCP`,
|
||||
);
|
||||
}
|
||||
const includeTurns = include_turns ?? false;
|
||||
try {
|
||||
const response = await supervisor.readSession({
|
||||
endpointId: endpoint_id,
|
||||
threadId: thread_id,
|
||||
includeTurns,
|
||||
});
|
||||
return textResult(`codex session: ${thread_id}`, {
|
||||
response: redactCodexSupervisorValue(response),
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResult(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"codex_session_send",
|
||||
"Send text to a Codex session. Idle sessions start a turn; active sessions are steered.",
|
||||
{
|
||||
endpoint_id: z.string().optional(),
|
||||
thread_id: z.string().min(1),
|
||||
text: z.string().min(1),
|
||||
mode: z.enum(["auto", "start", "steer"]).optional(),
|
||||
},
|
||||
async ({ endpoint_id, thread_id, text, mode }) => {
|
||||
if (!writeControlsAllowedFor(opts)) {
|
||||
return errorResult(
|
||||
`Codex write controls are disabled; set ${WRITE_CONTROLS_ENV}=1 for a trusted supervisor-only MCP`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = await supervisor.sendToSession({
|
||||
endpointId: endpoint_id,
|
||||
threadId: thread_id,
|
||||
text,
|
||||
mode,
|
||||
});
|
||||
return textResult(`codex ${result.mode}: ${result.turnId ?? thread_id}`, { result });
|
||||
} catch (error) {
|
||||
return errorResult(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"codex_session_interrupt",
|
||||
"Interrupt an active Codex turn.",
|
||||
{
|
||||
endpoint_id: z.string().optional(),
|
||||
thread_id: z.string().min(1),
|
||||
turn_id: z.string().optional(),
|
||||
},
|
||||
async ({ endpoint_id, thread_id, turn_id }) => {
|
||||
if (!writeControlsAllowedFor(opts)) {
|
||||
return errorResult(
|
||||
`Codex write controls are disabled; set ${WRITE_CONTROLS_ENV}=1 for a trusted supervisor-only MCP`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = await supervisor.interruptSession({
|
||||
endpointId: endpoint_id,
|
||||
threadId: thread_id,
|
||||
turnId: turn_id,
|
||||
});
|
||||
return textResult(`codex interrupted: ${result.turnId}`, { result });
|
||||
} catch (error) {
|
||||
return errorResult(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
174
extensions/codex-supervisor/src/plugin-tools.test.ts
Normal file
174
extensions/codex-supervisor/src/plugin-tools.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
// Codex Supervisor tests cover plugin tools plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCodexSupervisorTools } from "./plugin-tools.js";
|
||||
import type { CodexSupervisor } from "./supervisor.js";
|
||||
|
||||
function createSupervisorStub() {
|
||||
const calls: string[] = [];
|
||||
const supervisor = {
|
||||
listEndpoints: () => [
|
||||
{
|
||||
id: "prod",
|
||||
transport: "websocket",
|
||||
url: "wss://user:secret@example.invalid/control?token=hidden",
|
||||
},
|
||||
],
|
||||
probeEndpoints: async () => [{ endpointId: "prod", ok: true }],
|
||||
listSessionSnapshot: async () => ({
|
||||
sessions: [
|
||||
{
|
||||
endpointId: "prod",
|
||||
threadId: "thread-1",
|
||||
status: "idle",
|
||||
preview: "secret prompt",
|
||||
name: "secret title",
|
||||
},
|
||||
],
|
||||
errors: [{ endpointId: "down", ok: false, detail: "secret stderr" }],
|
||||
}),
|
||||
readSession: async () => ({
|
||||
thread: {
|
||||
id: "thread-1",
|
||||
authorization: "Bearer abcdefghijklmnopqrstuvwxyz012345",
|
||||
},
|
||||
}),
|
||||
sendToSession: async (params: { mode?: string }) => {
|
||||
calls.push(`send:${params.mode ?? "auto"}`);
|
||||
return {
|
||||
endpointId: "prod",
|
||||
threadId: "thread-1",
|
||||
mode: "start" as const,
|
||||
turnId: "turn-1",
|
||||
};
|
||||
},
|
||||
interruptSession: async () => {
|
||||
calls.push("interrupt");
|
||||
return {
|
||||
endpointId: "prod",
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
};
|
||||
},
|
||||
} satisfies Pick<
|
||||
CodexSupervisor,
|
||||
| "interruptSession"
|
||||
| "listEndpoints"
|
||||
| "listSessionSnapshot"
|
||||
| "probeEndpoints"
|
||||
| "readSession"
|
||||
| "sendToSession"
|
||||
>;
|
||||
return { calls, supervisor: supervisor as unknown as CodexSupervisor };
|
||||
}
|
||||
|
||||
function toolByName(tools: ReturnType<typeof createCodexSupervisorTools>, name: string) {
|
||||
const tool = tools.find((entry) => entry.name === name);
|
||||
if (!tool) {
|
||||
throw new Error(`missing tool: ${name}`);
|
||||
}
|
||||
return tool;
|
||||
}
|
||||
|
||||
describe("createCodexSupervisorTools", () => {
|
||||
it("registers redacted read-only supervisor tools by default", async () => {
|
||||
const { supervisor } = createSupervisorStub();
|
||||
const tools = createCodexSupervisorTools({
|
||||
supervisor,
|
||||
policy: { allowRawTranscripts: false, allowWriteControls: false },
|
||||
});
|
||||
|
||||
const probe = await toolByName(tools, "codex_endpoint_probe").execute("call-1", {});
|
||||
expect(probe.details).toMatchObject({
|
||||
summary: "codex endpoints: 1/1 ok",
|
||||
endpoints: [
|
||||
{ id: "prod", transport: "websocket", url: "wss://example.invalid/control?[redacted]" },
|
||||
],
|
||||
});
|
||||
|
||||
const list = await toolByName(tools, "codex_sessions_list").execute("call-2", {});
|
||||
expect(list.details).toEqual({
|
||||
summary: "codex sessions: 1",
|
||||
sessions: [{ endpointId: "prod", threadId: "thread-1", status: "idle" }],
|
||||
errors: [{ endpointId: "down", ok: false }],
|
||||
});
|
||||
});
|
||||
|
||||
it("gates transcript reads and write controls", async () => {
|
||||
const { supervisor } = createSupervisorStub();
|
||||
const tools = createCodexSupervisorTools({
|
||||
supervisor,
|
||||
policy: { allowRawTranscripts: false, allowWriteControls: false },
|
||||
});
|
||||
|
||||
await expect(
|
||||
toolByName(tools, "codex_session_read").execute("call-1", { thread_id: "thread-1" }),
|
||||
).rejects.toThrow("Codex session reads are disabled");
|
||||
await expect(
|
||||
toolByName(tools, "codex_session_send").execute("call-2", {
|
||||
thread_id: "thread-1",
|
||||
text: "continue",
|
||||
}),
|
||||
).rejects.toThrow("Codex write controls are disabled");
|
||||
});
|
||||
|
||||
it("rejects stored session limits outside the runtime bounds", async () => {
|
||||
const { supervisor } = createSupervisorStub();
|
||||
const tools = createCodexSupervisorTools({
|
||||
supervisor,
|
||||
policy: { allowRawTranscripts: false, allowWriteControls: false },
|
||||
});
|
||||
|
||||
await expect(
|
||||
toolByName(tools, "codex_sessions_list").execute("call-1", {
|
||||
include_stored: true,
|
||||
max_stored_sessions: "2",
|
||||
}),
|
||||
).rejects.toThrow("max_stored_sessions must be an integer");
|
||||
|
||||
await expect(
|
||||
toolByName(tools, "codex_sessions_list").execute("call-2", {
|
||||
include_stored: true,
|
||||
max_stored_sessions: 1001,
|
||||
}),
|
||||
).rejects.toThrow("max_stored_sessions must be between 1 and 1000");
|
||||
await expect(
|
||||
toolByName(tools, "codex_sessions_list").execute("call-2", {
|
||||
include_stored: true,
|
||||
max_stored_sessions: null,
|
||||
}),
|
||||
).rejects.toThrow("max_stored_sessions must be an integer");
|
||||
await expect(
|
||||
toolByName(tools, "codex_sessions_list").execute("call-3", {
|
||||
include_stored: true,
|
||||
max_stored_sessions: Number.MAX_SAFE_INTEGER + 1,
|
||||
}),
|
||||
).rejects.toThrow("max_stored_sessions must be between 1 and 1000");
|
||||
});
|
||||
|
||||
it("allows trusted read and write tools when policy enables them", async () => {
|
||||
const { calls, supervisor } = createSupervisorStub();
|
||||
const tools = createCodexSupervisorTools({
|
||||
supervisor,
|
||||
policy: { allowRawTranscripts: true, allowWriteControls: true },
|
||||
});
|
||||
|
||||
const read = await toolByName(tools, "codex_session_read").execute("call-1", {
|
||||
thread_id: "thread-1",
|
||||
});
|
||||
expect(read.details).toEqual({
|
||||
summary: "codex session: thread-1",
|
||||
response: { thread: { id: "thread-1", authorization: "[redacted]" } },
|
||||
});
|
||||
|
||||
const sent = await toolByName(tools, "codex_session_send").execute("call-2", {
|
||||
thread_id: "thread-1",
|
||||
text: "continue",
|
||||
mode: "start",
|
||||
});
|
||||
expect(sent.details).toMatchObject({
|
||||
summary: "codex start: turn-1",
|
||||
result: { turnId: "turn-1" },
|
||||
});
|
||||
expect(calls).toEqual(["send:start"]);
|
||||
});
|
||||
});
|
||||
223
extensions/codex-supervisor/src/plugin-tools.ts
Normal file
223
extensions/codex-supervisor/src/plugin-tools.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* OpenClaw agent-tool definitions for Codex Supervisor endpoint and session
|
||||
* controls.
|
||||
*/
|
||||
import { jsonResult, readStringParam, type AnyAgentTool } from "openclaw/plugin-sdk/core";
|
||||
import { Type } from "typebox";
|
||||
import {
|
||||
redactCodexSupervisorEndpoint,
|
||||
redactCodexSupervisorValue,
|
||||
sanitizeCodexSupervisorSessionListResult,
|
||||
} from "./mcp-tools.js";
|
||||
import type { CodexSupervisor } from "./supervisor.js";
|
||||
import type { CodexSupervisorTurnMode } from "./types.js";
|
||||
|
||||
const EmptyParamsSchema = Type.Object({}, { additionalProperties: false });
|
||||
|
||||
const SessionsListParamsSchema = Type.Object(
|
||||
{
|
||||
include_stored: Type.Optional(Type.Boolean()),
|
||||
max_stored_sessions: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const SessionReadParamsSchema = Type.Object(
|
||||
{
|
||||
endpoint_id: Type.Optional(Type.String()),
|
||||
thread_id: Type.String(),
|
||||
include_turns: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const SessionSendParamsSchema = Type.Object(
|
||||
{
|
||||
endpoint_id: Type.Optional(Type.String()),
|
||||
thread_id: Type.String(),
|
||||
text: Type.String(),
|
||||
mode: Type.Optional(
|
||||
Type.Union([Type.Literal("auto"), Type.Literal("start"), Type.Literal("steer")]),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const SessionInterruptParamsSchema = Type.Object(
|
||||
{
|
||||
endpoint_id: Type.Optional(Type.String()),
|
||||
thread_id: Type.String(),
|
||||
turn_id: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** Policy flags controlling transcript reads and write operations. */
|
||||
export type CodexSupervisorToolPolicy = {
|
||||
allowRawTranscripts: boolean;
|
||||
allowWriteControls: boolean;
|
||||
};
|
||||
|
||||
/** Dependencies needed to build OpenClaw agent tools. */
|
||||
export type CodexSupervisorToolOptions = {
|
||||
supervisor: CodexSupervisor;
|
||||
policy: CodexSupervisorToolPolicy;
|
||||
};
|
||||
|
||||
function asRecord(params: unknown): Record<string, unknown> {
|
||||
return params && typeof params === "object" && !Array.isArray(params)
|
||||
? (params as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function readBooleanParam(params: Record<string, unknown>, key: string): boolean {
|
||||
return params[key] === true;
|
||||
}
|
||||
|
||||
function readIntegerParam(params: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = params[key];
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== "number" || !Number.isInteger(value)) {
|
||||
throw new Error(`${key} must be an integer`);
|
||||
}
|
||||
if (value < 1 || value > 1000) {
|
||||
throw new Error(`${key} must be between 1 and 1000`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readModeParam(params: Record<string, unknown>): CodexSupervisorTurnMode | undefined {
|
||||
const mode = readStringParam(params, "mode");
|
||||
if (!mode) {
|
||||
return undefined;
|
||||
}
|
||||
if (mode === "auto" || mode === "start" || mode === "steer") {
|
||||
return mode;
|
||||
}
|
||||
throw new Error("mode must be auto, start, or steer");
|
||||
}
|
||||
|
||||
function requireRawTranscriptAccess(policy: CodexSupervisorToolPolicy): void {
|
||||
if (!policy.allowRawTranscripts) {
|
||||
throw new Error("Codex session reads are disabled for this codex-supervisor plugin config.");
|
||||
}
|
||||
}
|
||||
|
||||
function requireWriteAccess(policy: CodexSupervisorToolPolicy): void {
|
||||
if (!policy.allowWriteControls) {
|
||||
throw new Error("Codex write controls are disabled for this codex-supervisor plugin config.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the OpenClaw tools that expose Codex endpoint health and session
|
||||
* controls.
|
||||
*/
|
||||
export function createCodexSupervisorTools({
|
||||
supervisor,
|
||||
policy,
|
||||
}: CodexSupervisorToolOptions): AnyAgentTool[] {
|
||||
return [
|
||||
{
|
||||
name: "codex_endpoint_probe",
|
||||
label: "Codex Endpoint Probe",
|
||||
description: "Check configured Codex app-server endpoints.",
|
||||
parameters: EmptyParamsSchema,
|
||||
execute: async () => {
|
||||
const endpoints = supervisor.listEndpoints().map(redactCodexSupervisorEndpoint);
|
||||
const health = (await supervisor.probeEndpoints()).map(({ endpointId, ok }) => ({
|
||||
endpointId,
|
||||
ok,
|
||||
}));
|
||||
return jsonResult({
|
||||
summary: `codex endpoints: ${health.filter((entry) => entry.ok).length}/${health.length} ok`,
|
||||
endpoints,
|
||||
health,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "codex_sessions_list",
|
||||
label: "Codex Sessions List",
|
||||
description: "List Codex sessions visible to the OpenClaw supervisor.",
|
||||
parameters: SessionsListParamsSchema,
|
||||
execute: async (_toolCallId, rawParams) => {
|
||||
const params = asRecord(rawParams);
|
||||
const result = await supervisor.listSessionSnapshot({
|
||||
includeStored: readBooleanParam(params, "include_stored"),
|
||||
maxStoredSessions: readIntegerParam(params, "max_stored_sessions"),
|
||||
});
|
||||
return jsonResult({
|
||||
summary: `codex sessions: ${result.sessions.length}`,
|
||||
...sanitizeCodexSupervisorSessionListResult(result, policy.allowRawTranscripts),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "codex_session_read",
|
||||
label: "Codex Session Read",
|
||||
description: "Read one Codex session transcript from app-server.",
|
||||
parameters: SessionReadParamsSchema,
|
||||
execute: async (_toolCallId, rawParams) => {
|
||||
// Raw transcript access is opt-in because app-server sessions can hold
|
||||
// secrets, private files, and user-authenticated browser context.
|
||||
requireRawTranscriptAccess(policy);
|
||||
const params = asRecord(rawParams);
|
||||
const threadId = readStringParam(params, "thread_id", { required: true });
|
||||
const response = await supervisor.readSession({
|
||||
endpointId: readStringParam(params, "endpoint_id"),
|
||||
threadId,
|
||||
includeTurns: readBooleanParam(params, "include_turns"),
|
||||
});
|
||||
return jsonResult({
|
||||
summary: `codex session: ${threadId}`,
|
||||
response: redactCodexSupervisorValue(response),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "codex_session_send",
|
||||
label: "Codex Session Send",
|
||||
description:
|
||||
"Send text to a Codex session. Idle sessions start a turn; active sessions are steered.",
|
||||
parameters: SessionSendParamsSchema,
|
||||
execute: async (_toolCallId, rawParams) => {
|
||||
// Session write controls can steer or interrupt a human-visible Codex
|
||||
// turn, so they remain behind an explicit plugin policy gate.
|
||||
requireWriteAccess(policy);
|
||||
const params = asRecord(rawParams);
|
||||
const result = await supervisor.sendToSession({
|
||||
endpointId: readStringParam(params, "endpoint_id"),
|
||||
threadId: readStringParam(params, "thread_id", { required: true }),
|
||||
text: readStringParam(params, "text", { required: true, allowEmpty: false }),
|
||||
mode: readModeParam(params),
|
||||
});
|
||||
return jsonResult({
|
||||
summary: `codex ${result.mode}: ${result.turnId ?? result.threadId}`,
|
||||
result,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "codex_session_interrupt",
|
||||
label: "Codex Session Interrupt",
|
||||
description: "Interrupt an active Codex turn.",
|
||||
parameters: SessionInterruptParamsSchema,
|
||||
execute: async (_toolCallId, rawParams) => {
|
||||
requireWriteAccess(policy);
|
||||
const params = asRecord(rawParams);
|
||||
const result = await supervisor.interruptSession({
|
||||
endpointId: readStringParam(params, "endpoint_id"),
|
||||
threadId: readStringParam(params, "thread_id", { required: true }),
|
||||
turnId: readStringParam(params, "turn_id"),
|
||||
});
|
||||
return jsonResult({
|
||||
summary: `codex interrupted: ${result.turnId}`,
|
||||
result,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
951
extensions/codex-supervisor/src/supervisor.test.ts
Normal file
951
extensions/codex-supervisor/src/supervisor.test.ts
Normal file
@@ -0,0 +1,951 @@
|
||||
// Codex Supervisor tests cover supervisor plugin behavior.
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { loadCodexSupervisorEndpoints, resolveCodexSupervisorPluginConfig } from "./config.js";
|
||||
import { connectCodexAppServerEndpoint, resolveSafeApprovalResult } from "./json-rpc-client.js";
|
||||
import { CodexSupervisor } from "./supervisor.js";
|
||||
import type { CodexJsonRpcConnection, CodexSupervisorEndpoint } from "./types.js";
|
||||
|
||||
class FakeCodexConnection implements CodexJsonRpcConnection {
|
||||
readonly calls: Array<{ method: string; params?: Record<string, unknown> }> = [];
|
||||
closeCount = 0;
|
||||
|
||||
constructor(
|
||||
private thread: Record<string, unknown>,
|
||||
private readonly failIncludeTurnsUntilMaterialized = false,
|
||||
) {}
|
||||
|
||||
async request(method: string, params?: Record<string, unknown>): Promise<unknown> {
|
||||
this.calls.push({ method, params });
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: [this.thread.id].filter((id) => typeof id === "string"), nextCursor: null };
|
||||
}
|
||||
if (method === "thread/list") {
|
||||
return { threads: [this.thread] };
|
||||
}
|
||||
if (method === "thread/read") {
|
||||
if (this.failIncludeTurnsUntilMaterialized && params?.includeTurns === true) {
|
||||
throw new Error(
|
||||
"thread is not materialized yet; includeTurns is unavailable before first user message",
|
||||
);
|
||||
}
|
||||
return { thread: this.thread };
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return { turn: { id: "turn-started", status: "inProgress" } };
|
||||
}
|
||||
if (method === "turn/steer") {
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/interrupt") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}
|
||||
|
||||
notify(): void {}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const endpoint: CodexSupervisorEndpoint = {
|
||||
id: "local",
|
||||
transport: "stdio-proxy",
|
||||
};
|
||||
|
||||
describe("loadCodexSupervisorEndpoints", () => {
|
||||
it("defaults to the local app-server Unix websocket", () => {
|
||||
expect(loadCodexSupervisorEndpoints({})).toEqual([
|
||||
{
|
||||
id: "local",
|
||||
label: "local Codex app-server daemon",
|
||||
transport: "websocket",
|
||||
url: "unix://",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses websocket shorthand endpoints", () => {
|
||||
expect(
|
||||
loadCodexSupervisorEndpoints({
|
||||
OPENCLAW_CODEX_SUPERVISOR_ENDPOINTS: "crab=ws://127.0.0.1:18080,local",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "crab",
|
||||
transport: "websocket",
|
||||
url: "ws://127.0.0.1:18080",
|
||||
},
|
||||
{
|
||||
id: "local",
|
||||
label: "local Codex app-server daemon",
|
||||
transport: "websocket",
|
||||
url: "unix://",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps equals signs inside endpoint URLs", () => {
|
||||
expect(
|
||||
loadCodexSupervisorEndpoints({
|
||||
OPENCLAW_CODEX_SUPERVISOR_ENDPOINTS: "prod=wss://example.invalid/control?token=a=b&next=c",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "prod",
|
||||
transport: "websocket",
|
||||
url: "wss://example.invalid/control?token=a=b&next=c",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not derive generated endpoint ids from secret-bearing URLs", () => {
|
||||
expect(
|
||||
loadCodexSupervisorEndpoints({
|
||||
OPENCLAW_CODEX_SUPERVISOR_ENDPOINTS: "wss://user:secret@example.invalid/control?token=a=b",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "endpoint-1",
|
||||
transport: "websocket",
|
||||
url: "wss://user:secret@example.invalid/control?token=a=b",
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
loadCodexSupervisorEndpoints({
|
||||
OPENCLAW_CODEX_SUPERVISOR_ENDPOINTS: JSON.stringify([
|
||||
{
|
||||
transport: "websocket",
|
||||
url: "wss://example.invalid/control?token=secret",
|
||||
},
|
||||
]),
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "endpoint-1",
|
||||
transport: "websocket",
|
||||
url: "wss://example.invalid/control?token=secret",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects duplicate normalized endpoint ids", () => {
|
||||
expect(() =>
|
||||
loadCodexSupervisorEndpoints({
|
||||
OPENCLAW_CODEX_SUPERVISOR_ENDPOINTS: "fleet/a=ws://one.invalid,fleet-a=ws://two.invalid",
|
||||
}),
|
||||
).toThrow("duplicate Codex supervisor endpoint id: fleet-a");
|
||||
expect(() =>
|
||||
resolveCodexSupervisorPluginConfig({
|
||||
endpoints: [
|
||||
{ id: "fleet/a", transport: "websocket", url: "ws://one.invalid" },
|
||||
{ id: "fleet-a", transport: "websocket", url: "ws://two.invalid" },
|
||||
],
|
||||
}),
|
||||
).toThrow("duplicate Codex supervisor endpoint id: fleet-a");
|
||||
});
|
||||
|
||||
it("prefers plugin-configured endpoints over environment defaults", () => {
|
||||
expect(
|
||||
resolveCodexSupervisorPluginConfig(
|
||||
{
|
||||
endpoints: [
|
||||
{
|
||||
id: "fleet",
|
||||
transport: "websocket",
|
||||
url: "wss://fleet.example.invalid/codex",
|
||||
},
|
||||
],
|
||||
allowRawTranscripts: true,
|
||||
allowWriteControls: true,
|
||||
},
|
||||
{
|
||||
OPENCLAW_CODEX_SUPERVISOR_ENDPOINTS: "local",
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
endpoints: [
|
||||
{
|
||||
id: "fleet",
|
||||
transport: "websocket",
|
||||
url: "wss://fleet.example.invalid/codex",
|
||||
},
|
||||
],
|
||||
allowRawTranscripts: true,
|
||||
allowWriteControls: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CodexSupervisor", () => {
|
||||
it("does not permanently cache failed endpoint connections", async () => {
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-1",
|
||||
status: { type: "idle" },
|
||||
turns: [],
|
||||
});
|
||||
let attempts = 0;
|
||||
const supervisor = new CodexSupervisor([endpoint], async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
throw new Error("daemon unavailable");
|
||||
}
|
||||
return fake;
|
||||
});
|
||||
|
||||
await expect(supervisor.probeEndpoints()).resolves.toEqual([
|
||||
{ endpointId: "local", ok: false, detail: "daemon unavailable" },
|
||||
]);
|
||||
await expect(supervisor.probeEndpoints()).resolves.toEqual([{ endpointId: "local", ok: true }]);
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
|
||||
it("lists loaded sessions", async () => {
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-1",
|
||||
cwd: "/workspace",
|
||||
preview: "work",
|
||||
sessionId: "session-1",
|
||||
source: "vscode",
|
||||
status: { type: "idle" },
|
||||
updatedAt: 10,
|
||||
turns: [],
|
||||
});
|
||||
const supervisor = new CodexSupervisor([endpoint], async () => fake);
|
||||
|
||||
await expect(supervisor.listSessions()).resolves.toEqual([
|
||||
{
|
||||
endpointId: "local",
|
||||
threadId: "thread-1",
|
||||
cwd: "/workspace",
|
||||
preview: "work",
|
||||
sessionId: "session-1",
|
||||
source: "vscode",
|
||||
status: "idle",
|
||||
updatedAt: 10,
|
||||
humanAttached: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists loaded sessions from real app-server data responses", async () => {
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-1",
|
||||
cwd: "/workspace",
|
||||
status: { type: "idle" },
|
||||
turns: [],
|
||||
});
|
||||
fake.request = async (method, params) => {
|
||||
fake.calls.push({ method, params });
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: ["thread-1"], nextCursor: null };
|
||||
}
|
||||
if (method === "thread/read") {
|
||||
return {
|
||||
thread: { id: "thread-1", cwd: "/workspace", status: { type: "idle" }, turns: [] },
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
};
|
||||
const supervisor = new CodexSupervisor([endpoint], async () => fake);
|
||||
|
||||
await expect(supervisor.listSessions()).resolves.toEqual([
|
||||
{
|
||||
endpointId: "local",
|
||||
threadId: "thread-1",
|
||||
cwd: "/workspace",
|
||||
status: "idle",
|
||||
humanAttached: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates loaded-only sessions without stored history", async () => {
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-live",
|
||||
cwd: "/workspace",
|
||||
status: { type: "active", activeFlags: [] },
|
||||
turns: [],
|
||||
});
|
||||
fake.request = async (method, params) => {
|
||||
fake.calls.push({ method, params });
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: ["thread-live"], nextCursor: null };
|
||||
}
|
||||
if (method === "thread/read") {
|
||||
return {
|
||||
thread: {
|
||||
id: "thread-live",
|
||||
cwd: "/workspace",
|
||||
status: { type: "active", activeFlags: [] },
|
||||
turns: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
};
|
||||
const supervisor = new CodexSupervisor([endpoint], async () => fake);
|
||||
|
||||
await expect(supervisor.listSessions()).resolves.toEqual([
|
||||
{
|
||||
endpointId: "local",
|
||||
threadId: "thread-live",
|
||||
cwd: "/workspace",
|
||||
status: "active",
|
||||
humanAttached: true,
|
||||
},
|
||||
]);
|
||||
expect(fake.calls.map((call) => call.method)).toEqual(["thread/loaded/list", "thread/read"]);
|
||||
});
|
||||
|
||||
it("does not enumerate stored sessions unless requested", async () => {
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-1",
|
||||
status: { type: "notLoaded" },
|
||||
turns: [],
|
||||
});
|
||||
fake.request = async (method, params) => {
|
||||
fake.calls.push({ method, params });
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: [], nextCursor: null };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
};
|
||||
const supervisor = new CodexSupervisor([endpoint], async () => fake);
|
||||
|
||||
await expect(supervisor.listSessions()).resolves.toEqual([]);
|
||||
expect(fake.calls.map((call) => call.method)).toEqual(["thread/loaded/list"]);
|
||||
});
|
||||
|
||||
it("reads stored sessions from real app-server data responses", async () => {
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-1",
|
||||
status: { type: "idle" },
|
||||
turns: [],
|
||||
});
|
||||
fake.request = async (method, params) => {
|
||||
fake.calls.push({ method, params });
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: [], nextCursor: null };
|
||||
}
|
||||
if (method === "thread/list") {
|
||||
return {
|
||||
data: [{ id: "thread-1", status: { type: "notLoaded" }, turns: [] }],
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
};
|
||||
const supervisor = new CodexSupervisor([endpoint], async () => fake);
|
||||
|
||||
await expect(supervisor.listSessions({ includeStored: true })).resolves.toEqual([
|
||||
{
|
||||
endpointId: "local",
|
||||
threadId: "thread-1",
|
||||
status: "notLoaded",
|
||||
},
|
||||
]);
|
||||
expect(fake.calls.find((call) => call.method === "thread/list")?.params).toMatchObject({
|
||||
sourceKinds: ["cli", "vscode", "exec", "appServer", "unknown"],
|
||||
useStateDbOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads every stored session page", async () => {
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-1",
|
||||
status: { type: "idle" },
|
||||
turns: [],
|
||||
});
|
||||
fake.request = async (method, params) => {
|
||||
fake.calls.push({ method, params });
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: [], nextCursor: null };
|
||||
}
|
||||
if (method === "thread/list") {
|
||||
if (params?.cursor === "page-2") {
|
||||
return {
|
||||
data: [{ id: "thread-2", status: { type: "notLoaded" }, turns: [] }],
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: [{ id: "thread-1", status: { type: "notLoaded" }, turns: [] }],
|
||||
nextCursor: "page-2",
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
};
|
||||
const supervisor = new CodexSupervisor([endpoint], async () => fake);
|
||||
|
||||
await expect(supervisor.listSessions({ includeStored: true })).resolves.toEqual([
|
||||
{
|
||||
endpointId: "local",
|
||||
threadId: "thread-1",
|
||||
status: "notLoaded",
|
||||
},
|
||||
{
|
||||
endpointId: "local",
|
||||
threadId: "thread-2",
|
||||
status: "notLoaded",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("bounds stored session pagination for large real Codex homes", async () => {
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-1",
|
||||
status: { type: "idle" },
|
||||
turns: [],
|
||||
});
|
||||
fake.request = async (method, params) => {
|
||||
fake.calls.push({ method, params });
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: [], nextCursor: null };
|
||||
}
|
||||
if (method === "thread/list") {
|
||||
return {
|
||||
data: [
|
||||
{ id: "thread-1", status: { type: "notLoaded" }, turns: [] },
|
||||
{ id: "thread-2", status: { type: "notLoaded" }, turns: [] },
|
||||
],
|
||||
nextCursor: "page-2",
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
};
|
||||
const supervisor = new CodexSupervisor([endpoint], async () => fake);
|
||||
|
||||
await expect(
|
||||
supervisor.listSessions({ includeStored: true, maxStoredSessions: 1 }),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
endpointId: "local",
|
||||
threadId: "thread-1",
|
||||
status: "notLoaded",
|
||||
},
|
||||
]);
|
||||
expect(fake.calls.filter((call) => call.method === "thread/list")).toEqual([
|
||||
{
|
||||
method: "thread/list",
|
||||
params: {
|
||||
limit: 1,
|
||||
sourceKinds: ["cli", "vscode", "exec", "appServer", "unknown"],
|
||||
useStateDbOnly: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("closes settled connections when evicting them", async () => {
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-1",
|
||||
status: { type: "idle" },
|
||||
turns: [],
|
||||
});
|
||||
fake.request = async (method, params) => {
|
||||
fake.calls.push({ method, params });
|
||||
if (method === "thread/read") {
|
||||
throw new Error("transport closed");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
};
|
||||
const supervisor = new CodexSupervisor([endpoint], async () => fake);
|
||||
|
||||
await expect(
|
||||
supervisor.readSession({ endpointId: "local", threadId: "thread-1" }),
|
||||
).rejects.toThrow("transport closed");
|
||||
await Promise.resolve();
|
||||
expect(fake.closeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps listing healthy endpoints when one endpoint is down", async () => {
|
||||
const downEndpoint: CodexSupervisorEndpoint = {
|
||||
id: "down",
|
||||
transport: "stdio-proxy",
|
||||
};
|
||||
const upEndpoint: CodexSupervisorEndpoint = {
|
||||
id: "up",
|
||||
transport: "stdio-proxy",
|
||||
};
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-1",
|
||||
status: { type: "idle" },
|
||||
turns: [],
|
||||
});
|
||||
const supervisor = new CodexSupervisor([downEndpoint, upEndpoint], async (target) => {
|
||||
if (target.id === "down") {
|
||||
throw new Error("host offline");
|
||||
}
|
||||
return fake;
|
||||
});
|
||||
|
||||
await expect(supervisor.listSessionSnapshot()).resolves.toEqual({
|
||||
sessions: [
|
||||
{
|
||||
endpointId: "up",
|
||||
threadId: "thread-1",
|
||||
status: "idle",
|
||||
humanAttached: true,
|
||||
},
|
||||
],
|
||||
errors: [{ endpointId: "down", ok: false, detail: "host offline" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("starts a new turn for idle sessions", async () => {
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-1",
|
||||
status: { type: "idle" },
|
||||
turns: [],
|
||||
});
|
||||
const supervisor = new CodexSupervisor([endpoint], async () => fake);
|
||||
|
||||
await expect(
|
||||
supervisor.sendToSession({ endpointId: "local", threadId: "thread-1", text: "continue" }),
|
||||
).resolves.toMatchObject({
|
||||
endpointId: "local",
|
||||
threadId: "thread-1",
|
||||
mode: "start",
|
||||
turnId: "turn-started",
|
||||
});
|
||||
expect(fake.calls.at(-1)).toEqual({
|
||||
method: "turn/start",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
input: [{ type: "text", text: "continue", text_elements: [] }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves omitted endpoint ids from loaded-only sessions", async () => {
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-1",
|
||||
status: { type: "idle" },
|
||||
turns: [],
|
||||
});
|
||||
fake.request = async (method, params) => {
|
||||
fake.calls.push({ method, params });
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: ["thread-1"], nextCursor: null };
|
||||
}
|
||||
if (method === "thread/read") {
|
||||
return { thread: { id: "thread-1", status: { type: "idle" }, turns: [] } };
|
||||
}
|
||||
if (method === "thread/list") {
|
||||
return { data: [], nextCursor: null };
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return { turn: { id: "turn-started", status: "inProgress" } };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
};
|
||||
const supervisor = new CodexSupervisor([endpoint], async () => fake);
|
||||
|
||||
await expect(
|
||||
supervisor.sendToSession({ threadId: "thread-1", text: "continue" }),
|
||||
).resolves.toMatchObject({
|
||||
endpointId: "local",
|
||||
threadId: "thread-1",
|
||||
mode: "start",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a unique loaded endpoint match even when another endpoint is down", async () => {
|
||||
const upEndpoint: CodexSupervisorEndpoint = { id: "up", transport: "stdio-proxy" };
|
||||
const downEndpoint: CodexSupervisorEndpoint = { id: "down", transport: "stdio-proxy" };
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-1",
|
||||
status: { type: "idle" },
|
||||
turns: [],
|
||||
});
|
||||
const supervisor = new CodexSupervisor([upEndpoint, downEndpoint], async (target) => {
|
||||
if (target.id === "down") {
|
||||
throw new Error("host offline");
|
||||
}
|
||||
return fake;
|
||||
});
|
||||
|
||||
await expect(
|
||||
supervisor.sendToSession({ threadId: "thread-1", text: "continue" }),
|
||||
).resolves.toMatchObject({
|
||||
endpointId: "up",
|
||||
threadId: "thread-1",
|
||||
mode: "start",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves omitted endpoint ids by exact thread read without scanning stored pages", async () => {
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-old",
|
||||
status: { type: "notLoaded" },
|
||||
turns: [],
|
||||
});
|
||||
fake.request = async (method, params) => {
|
||||
fake.calls.push({ method, params });
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: [], nextCursor: null };
|
||||
}
|
||||
if (method === "thread/read" && params?.threadId === "thread-old") {
|
||||
return { thread: { id: "thread-old", status: { type: "notLoaded" }, turns: [] } };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
};
|
||||
const supervisor = new CodexSupervisor([endpoint], async () => fake);
|
||||
|
||||
await expect(supervisor.readSession({ threadId: "thread-old" })).resolves.toEqual({
|
||||
thread: { id: "thread-old", status: { type: "notLoaded" }, turns: [] },
|
||||
});
|
||||
expect(fake.calls.map((call) => call.method)).toEqual([
|
||||
"thread/loaded/list",
|
||||
"thread/read",
|
||||
"thread/read",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves stored threads on healthy endpoints when another endpoint is down", async () => {
|
||||
const downEndpoint: CodexSupervisorEndpoint = { id: "down", transport: "stdio-proxy" };
|
||||
const upEndpoint: CodexSupervisorEndpoint = { id: "up", transport: "stdio-proxy" };
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-old",
|
||||
status: { type: "notLoaded" },
|
||||
turns: [],
|
||||
});
|
||||
fake.request = async (method, params) => {
|
||||
fake.calls.push({ method, params });
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: [], nextCursor: null };
|
||||
}
|
||||
if (method === "thread/read" && params?.threadId === "thread-old") {
|
||||
return { thread: { id: "thread-old", status: { type: "notLoaded" }, turns: [] } };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
};
|
||||
const supervisor = new CodexSupervisor([downEndpoint, upEndpoint], async (target) => {
|
||||
if (target.id === "down") {
|
||||
throw new Error("host offline");
|
||||
}
|
||||
return fake;
|
||||
});
|
||||
|
||||
await expect(supervisor.readSession({ threadId: "thread-old" })).resolves.toEqual({
|
||||
thread: { id: "thread-old", status: { type: "notLoaded" }, turns: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it("steers active sessions when the in-progress turn is readable", async () => {
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-1",
|
||||
status: { type: "active", activeFlags: [] },
|
||||
turns: [
|
||||
{ id: "turn-old", status: "completed", items: [] },
|
||||
{ id: "turn-active", status: "inProgress", items: [] },
|
||||
],
|
||||
});
|
||||
const supervisor = new CodexSupervisor([endpoint], async () => fake);
|
||||
|
||||
await expect(
|
||||
supervisor.sendToSession({ endpointId: "local", threadId: "thread-1", text: "heads up" }),
|
||||
).resolves.toEqual({
|
||||
endpointId: "local",
|
||||
threadId: "thread-1",
|
||||
mode: "steer",
|
||||
turnId: "turn-active",
|
||||
status: "active",
|
||||
});
|
||||
expect(fake.calls.at(-1)).toEqual({
|
||||
method: "turn/steer",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
expectedTurnId: "turn-active",
|
||||
input: [{ type: "text", text: "heads up", text_elements: [] }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("steers active sessions through the live turns list fallback", async () => {
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-1",
|
||||
status: { type: "active", activeFlags: [] },
|
||||
turns: [],
|
||||
});
|
||||
fake.request = async (method, params) => {
|
||||
fake.calls.push({ method, params });
|
||||
if (method === "thread/list") {
|
||||
return {
|
||||
data: [{ id: "thread-1", status: { type: "active", activeFlags: [] }, turns: [] }],
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
if (method === "thread/read") {
|
||||
return {
|
||||
thread: {
|
||||
id: "thread-1",
|
||||
status: { type: "active", activeFlags: [] },
|
||||
turns: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "thread/turns/list") {
|
||||
return {
|
||||
data: [{ id: "turn-active", status: "inProgress", items: [] }],
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
if (method === "turn/steer") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
};
|
||||
const supervisor = new CodexSupervisor([endpoint], async () => fake);
|
||||
|
||||
await expect(
|
||||
supervisor.sendToSession({ endpointId: "local", threadId: "thread-1", text: "heads up" }),
|
||||
).resolves.toEqual({
|
||||
endpointId: "local",
|
||||
threadId: "thread-1",
|
||||
mode: "steer",
|
||||
turnId: "turn-active",
|
||||
status: "active",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when active turn id is not readable", async () => {
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-1",
|
||||
status: { type: "active", activeFlags: [] },
|
||||
turns: [],
|
||||
});
|
||||
const supervisor = new CodexSupervisor([endpoint], async () => fake);
|
||||
|
||||
await expect(
|
||||
supervisor.sendToSession({ endpointId: "local", threadId: "thread-1", text: "heads up" }),
|
||||
).rejects.toThrow("active but no in-progress turn is readable");
|
||||
});
|
||||
|
||||
it("falls back to reading empty unmaterialized threads without turns", async () => {
|
||||
const fake = new FakeCodexConnection(
|
||||
{
|
||||
id: "thread-1",
|
||||
status: { type: "idle" },
|
||||
turns: [],
|
||||
},
|
||||
true,
|
||||
);
|
||||
const supervisor = new CodexSupervisor([endpoint], async () => fake);
|
||||
|
||||
await expect(
|
||||
supervisor.readSession({ endpointId: "local", threadId: "thread-1", includeTurns: true }),
|
||||
).resolves.toEqual({
|
||||
thread: {
|
||||
id: "thread-1",
|
||||
status: { type: "idle" },
|
||||
turns: [],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
fake.calls.filter((call) => call.method === "thread/read").map((call) => call.params),
|
||||
).toEqual([
|
||||
{ threadId: "thread-1", includeTurns: true },
|
||||
{ threadId: "thread-1", includeTurns: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSafeApprovalResult", () => {
|
||||
it("returns a valid fail-closed permissions response", () => {
|
||||
expect(resolveSafeApprovalResult("item/permissions/requestApproval")).toEqual({
|
||||
permissions: {},
|
||||
scope: "turn",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns valid fail-closed responses for non-approval server requests", () => {
|
||||
expect(resolveSafeApprovalResult("item/tool/call")).toEqual({
|
||||
contentItems: [
|
||||
{
|
||||
type: "inputText",
|
||||
text: "OpenClaw Codex supervisor did not register a handler for this app-server tool call.",
|
||||
},
|
||||
],
|
||||
success: false,
|
||||
});
|
||||
expect(resolveSafeApprovalResult("item/tool/requestUserInput")).toEqual({ answers: {} });
|
||||
expect(resolveSafeApprovalResult("mcpServer/elicitation/request")).toEqual({
|
||||
action: "decline",
|
||||
});
|
||||
expect(resolveSafeApprovalResult("unknown/request")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForFile(filePath: string): Promise<string> {
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
try {
|
||||
return await fs.readFile(filePath, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
}
|
||||
}
|
||||
throw new Error(`timed out waiting for ${filePath}`);
|
||||
}
|
||||
|
||||
describe("connectCodexAppServerEndpoint", () => {
|
||||
it("rejects pending websocket requests when the supervisor closes intentionally", async () => {
|
||||
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
||||
const port = await new Promise<number>((resolve) => {
|
||||
server.once("listening", () => {
|
||||
const address = server.address();
|
||||
resolve(typeof address === "object" && address ? address.port : 0);
|
||||
});
|
||||
});
|
||||
const sawProbeRequest = new Promise<void>((resolve) => {
|
||||
server.once("connection", (socket) => {
|
||||
socket.on("message", (data) => {
|
||||
const messageText =
|
||||
typeof data === "string"
|
||||
? data
|
||||
: Array.isArray(data)
|
||||
? Buffer.concat(data).toString("utf8")
|
||||
: data instanceof ArrayBuffer
|
||||
? Buffer.from(new Uint8Array(data)).toString("utf8")
|
||||
: Buffer.from(data).toString("utf8");
|
||||
const request = JSON.parse(messageText) as Record<string, unknown>;
|
||||
if (request.method === "initialize") {
|
||||
socket.send(JSON.stringify({ id: request.id, result: {} }));
|
||||
}
|
||||
if (request.method === "thread/loaded/list") {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
const supervisor = new CodexSupervisor(
|
||||
[{ id: "ws", transport: "websocket", url: `ws://127.0.0.1:${port}` }],
|
||||
connectCodexAppServerEndpoint,
|
||||
);
|
||||
|
||||
const probe = supervisor.probeEndpoints();
|
||||
await sawProbeRequest;
|
||||
await supervisor.close();
|
||||
|
||||
await expect(
|
||||
Promise.race([
|
||||
probe,
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error("probe timed out")), 500);
|
||||
}),
|
||||
]),
|
||||
).resolves.toMatchObject([{ endpointId: "ws", ok: false }]);
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed stdio frames instead of throwing out of band", async () => {
|
||||
const markerDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-supervisor-malformed-"));
|
||||
const marker = path.join(markerDir, "closed");
|
||||
const script = `
|
||||
const fs = require("node:fs");
|
||||
const readline = require("node:readline");
|
||||
process.on("SIGTERM", () => {
|
||||
fs.writeFileSync(${JSON.stringify(marker)}, "closed");
|
||||
process.exit(0);
|
||||
});
|
||||
readline.createInterface({ input: process.stdin }).on("line", () => {
|
||||
process.stdout.write("not-json\\n");
|
||||
});
|
||||
setTimeout(() => {}, 10_000);
|
||||
`;
|
||||
|
||||
await expect(
|
||||
connectCodexAppServerEndpoint({
|
||||
id: "bad",
|
||||
transport: "stdio-proxy",
|
||||
command: process.execPath,
|
||||
args: ["-e", script],
|
||||
}),
|
||||
).rejects.toThrow("Malformed Codex app-server message");
|
||||
await expect(waitForFile(marker)).resolves.toBe("closed");
|
||||
});
|
||||
|
||||
it("closes stdio connections when initialization fails", async () => {
|
||||
const markerDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-supervisor-init-"));
|
||||
const marker = path.join(markerDir, "closed");
|
||||
const script = `
|
||||
const fs = require("node:fs");
|
||||
const readline = require("node:readline");
|
||||
process.on("SIGTERM", () => {
|
||||
fs.writeFileSync(${JSON.stringify(marker)}, "closed");
|
||||
process.exit(0);
|
||||
});
|
||||
readline.createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
const request = JSON.parse(line);
|
||||
process.stdout.write(JSON.stringify({
|
||||
id: request.id,
|
||||
error: { code: -32000, message: "init failed" }
|
||||
}) + "\\n");
|
||||
});
|
||||
setTimeout(() => {}, 10_000);
|
||||
`;
|
||||
|
||||
await expect(
|
||||
connectCodexAppServerEndpoint({
|
||||
id: "bad",
|
||||
transport: "stdio-proxy",
|
||||
command: process.execPath,
|
||||
args: ["-e", script],
|
||||
}),
|
||||
).rejects.toThrow("init failed");
|
||||
await expect(waitForFile(marker)).resolves.toBe("closed");
|
||||
});
|
||||
|
||||
it("fails a cached stdio connection cleanly after the child exits", async () => {
|
||||
const script = `
|
||||
const readline = require("node:readline");
|
||||
readline.createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
const request = JSON.parse(line);
|
||||
if (request.method === "initialize") {
|
||||
process.stdout.write(JSON.stringify({ id: request.id, result: {} }) + "\\n");
|
||||
return;
|
||||
}
|
||||
if (request.method === "thread/loaded/list") {
|
||||
process.stdout.write(JSON.stringify({ id: request.id, result: { threads: [] } }) + "\\n");
|
||||
setTimeout(() => process.exit(0), 0);
|
||||
}
|
||||
});
|
||||
`;
|
||||
const supervisor = new CodexSupervisor(
|
||||
[
|
||||
{
|
||||
id: "exits",
|
||||
transport: "stdio-proxy",
|
||||
command: process.execPath,
|
||||
args: ["-e", script],
|
||||
},
|
||||
],
|
||||
connectCodexAppServerEndpoint,
|
||||
);
|
||||
|
||||
await expect(supervisor.probeEndpoints()).resolves.toEqual([{ endpointId: "exits", ok: true }]);
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
await expect(supervisor.probeEndpoints()).resolves.toMatchObject([
|
||||
{
|
||||
endpointId: "exits",
|
||||
ok: false,
|
||||
},
|
||||
]);
|
||||
await supervisor.close();
|
||||
});
|
||||
});
|
||||
536
extensions/codex-supervisor/src/supervisor.ts
Normal file
536
extensions/codex-supervisor/src/supervisor.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
/**
|
||||
* Codex app-server supervisor that lists sessions, reads transcripts, and
|
||||
* starts/steers/interrupts turns across configured endpoints.
|
||||
*/
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { connectCodexAppServerEndpoint } from "./json-rpc-client.js";
|
||||
import type {
|
||||
CodexJsonRpcConnection,
|
||||
CodexSupervisorEndpoint,
|
||||
CodexSupervisorEndpointHealth,
|
||||
CodexSupervisorSendResult,
|
||||
CodexSupervisorSession,
|
||||
CodexSupervisorSessionListResult,
|
||||
CodexSupervisorThreadStatus,
|
||||
CodexSupervisorTurnMode,
|
||||
} from "./types.js";
|
||||
|
||||
type EndpointConnector = (endpoint: CodexSupervisorEndpoint) => Promise<CodexJsonRpcConnection>;
|
||||
|
||||
const ALL_CODEX_THREAD_SOURCE_KINDS = ["cli", "vscode", "exec", "appServer", "unknown"];
|
||||
const DEFAULT_MAX_STORED_SESSIONS = 200;
|
||||
|
||||
function asRecordArray(value: unknown): Record<string, unknown>[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter(isRecord);
|
||||
}
|
||||
|
||||
function extractThread(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
if (isRecord(value.thread)) {
|
||||
return value.thread;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractThreadList(value: unknown): Record<string, unknown>[] {
|
||||
if (!isRecord(value)) {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(value.data)) {
|
||||
return asRecordArray(value.data);
|
||||
}
|
||||
if (Array.isArray(value.threads)) {
|
||||
return asRecordArray(value.threads);
|
||||
}
|
||||
if (Array.isArray(value.loadedThreads)) {
|
||||
return asRecordArray(value.loadedThreads);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractStringList(value: unknown): string[] {
|
||||
if (!isRecord(value) || !Array.isArray(value.data)) {
|
||||
return [];
|
||||
}
|
||||
return value.data.filter((entry) => typeof entry === "string");
|
||||
}
|
||||
|
||||
function getStatusType(thread: Record<string, unknown>): CodexSupervisorThreadStatus {
|
||||
const status = thread.status;
|
||||
if (isRecord(status) && typeof status.type === "string") {
|
||||
return status.type;
|
||||
}
|
||||
if (typeof status === "string") {
|
||||
return status;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function toSession(
|
||||
endpointId: string,
|
||||
thread: Record<string, unknown>,
|
||||
humanAttached?: boolean,
|
||||
): CodexSupervisorSession | undefined {
|
||||
if (typeof thread.id !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
endpointId,
|
||||
threadId: thread.id,
|
||||
status: getStatusType(thread),
|
||||
...(typeof thread.sessionId === "string" ? { sessionId: thread.sessionId } : {}),
|
||||
...(typeof thread.cwd === "string" ? { cwd: thread.cwd } : {}),
|
||||
...(typeof thread.preview === "string" ? { preview: thread.preview } : {}),
|
||||
...("name" in thread && (typeof thread.name === "string" || thread.name === null)
|
||||
? { name: thread.name }
|
||||
: {}),
|
||||
...(typeof thread.source === "string" ? { source: thread.source } : {}),
|
||||
...(typeof thread.updatedAt === "number" ? { updatedAt: thread.updatedAt } : {}),
|
||||
...(humanAttached !== undefined ? { humanAttached } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function findInProgressTurnId(thread: Record<string, unknown>): string | undefined {
|
||||
const turns = asRecordArray(thread.turns);
|
||||
for (const turn of turns.toReversed()) {
|
||||
if (turn.status === "inProgress" && typeof turn.id === "string") {
|
||||
return turn.id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isLoadedThreadReadMiss(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.includes("thread not found") || message.includes("thread not loaded");
|
||||
}
|
||||
|
||||
/** High-level supervisor facade used by OpenClaw tools and MCP tools. */
|
||||
export class CodexSupervisor {
|
||||
private readonly connections = new Map<string, Promise<CodexJsonRpcConnection>>();
|
||||
|
||||
constructor(
|
||||
private readonly endpoints: CodexSupervisorEndpoint[],
|
||||
private readonly connector: EndpointConnector = connectCodexAppServerEndpoint,
|
||||
) {}
|
||||
|
||||
/** Returns configured endpoint definitions without opening connections. */
|
||||
listEndpoints(): CodexSupervisorEndpoint[] {
|
||||
return this.endpoints;
|
||||
}
|
||||
|
||||
/** Closes all open app-server connections owned by this supervisor. */
|
||||
async close(): Promise<void> {
|
||||
const settled = await Promise.allSettled(this.connections.values());
|
||||
this.connections.clear();
|
||||
await Promise.all(
|
||||
settled.map(async (entry) => {
|
||||
if (entry.status === "fulfilled") {
|
||||
await entry.value.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Checks whether each endpoint can service a lightweight thread list call. */
|
||||
async probeEndpoints(): Promise<CodexSupervisorEndpointHealth[]> {
|
||||
return await Promise.all(
|
||||
this.endpoints.map(async (endpoint) => {
|
||||
try {
|
||||
const connection = await this.connectionFor(endpoint.id);
|
||||
await connection.request("thread/loaded/list", { limit: 1 });
|
||||
return { endpointId: endpoint.id, ok: true };
|
||||
} catch (error) {
|
||||
this.forgetEndpoint(endpoint.id);
|
||||
return {
|
||||
endpointId: endpoint.id,
|
||||
ok: false,
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Lists sessions, returning only the session array for agent-tool callers. */
|
||||
async listSessions(
|
||||
params: { includeStored?: boolean; maxStoredSessions?: number } = {},
|
||||
): Promise<CodexSupervisorSession[]> {
|
||||
return (await this.listSessionSnapshot(params)).sessions;
|
||||
}
|
||||
|
||||
/** Lists sessions plus endpoint errors for structured tool output. */
|
||||
async listSessionSnapshot(
|
||||
params: { includeStored?: boolean; maxStoredSessions?: number } = {},
|
||||
): Promise<CodexSupervisorSessionListResult> {
|
||||
const sessions: CodexSupervisorSession[] = [];
|
||||
const errors: CodexSupervisorEndpointHealth[] = [];
|
||||
for (const endpoint of this.endpoints) {
|
||||
try {
|
||||
sessions.push(...(await this.listEndpointSessions(endpoint, params)));
|
||||
} catch (error) {
|
||||
this.forgetEndpoint(endpoint.id);
|
||||
errors.push({
|
||||
endpointId: endpoint.id,
|
||||
ok: false,
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { sessions, errors };
|
||||
}
|
||||
|
||||
/** Reads a single Codex session transcript from the resolved endpoint. */
|
||||
async readSession(params: {
|
||||
endpointId?: string;
|
||||
threadId: string;
|
||||
includeTurns?: boolean;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const endpointId = await this.resolveEndpointId(params);
|
||||
const connection = await this.connectionFor(endpointId);
|
||||
try {
|
||||
const result = await this.readThread(
|
||||
connection,
|
||||
params.threadId,
|
||||
params.includeTurns === true,
|
||||
);
|
||||
if (!isRecord(result)) {
|
||||
throw new Error("Codex thread/read returned a non-object response");
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.forgetEndpoint(endpointId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts a new turn or steers an active turn depending on requested mode. */
|
||||
async sendToSession(params: {
|
||||
endpointId?: string;
|
||||
threadId: string;
|
||||
text: string;
|
||||
mode?: CodexSupervisorTurnMode;
|
||||
}): Promise<CodexSupervisorSendResult> {
|
||||
const endpointId = await this.resolveEndpointId(params);
|
||||
const connection = await this.connectionFor(endpointId);
|
||||
try {
|
||||
const mode = params.mode ?? "auto";
|
||||
if (mode === "start") {
|
||||
return await this.startTurn(connection, endpointId, params.threadId, params.text);
|
||||
}
|
||||
|
||||
const read = await this.readThread(connection, params.threadId, false);
|
||||
const thread = extractThread(read);
|
||||
if (!thread) {
|
||||
throw new Error(`Codex thread not found: ${params.threadId}`);
|
||||
}
|
||||
const status = getStatusType(thread);
|
||||
if (mode === "steer" || status === "active") {
|
||||
const detailed = await this.readThread(connection, params.threadId, true);
|
||||
const detailedThread = extractThread(detailed);
|
||||
// Active-turn ids may appear in full thread turns or the summary API;
|
||||
// try both before failing so steering handles materialized and lazy turns.
|
||||
const turnId =
|
||||
(detailedThread ? findInProgressTurnId(detailedThread) : undefined) ??
|
||||
findInProgressTurnId(thread) ??
|
||||
(await this.readActiveTurnId(connection, params.threadId));
|
||||
if (!turnId) {
|
||||
throw new Error(
|
||||
`Codex thread ${params.threadId} is active but no in-progress turn is readable`,
|
||||
);
|
||||
}
|
||||
await connection.request("turn/steer", {
|
||||
threadId: params.threadId,
|
||||
expectedTurnId: turnId,
|
||||
input: [{ type: "text", text: params.text, text_elements: [] }],
|
||||
});
|
||||
return { endpointId, threadId: params.threadId, mode: "steer", turnId, status };
|
||||
}
|
||||
return await this.startTurn(connection, endpointId, params.threadId, params.text);
|
||||
} catch (error) {
|
||||
this.forgetEndpoint(endpointId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Interrupts an active Codex turn, resolving the turn id when omitted. */
|
||||
async interruptSession(params: {
|
||||
endpointId?: string;
|
||||
threadId: string;
|
||||
turnId?: string;
|
||||
}): Promise<{ endpointId: string; threadId: string; turnId: string }> {
|
||||
const endpointId = await this.resolveEndpointId(params);
|
||||
const connection = await this.connectionFor(endpointId);
|
||||
try {
|
||||
let turnId = params.turnId;
|
||||
if (!turnId) {
|
||||
const read = await this.readThread(connection, params.threadId, true);
|
||||
const thread = extractThread(read);
|
||||
turnId =
|
||||
(thread ? findInProgressTurnId(thread) : undefined) ??
|
||||
(await this.readActiveTurnId(connection, params.threadId));
|
||||
}
|
||||
if (!turnId) {
|
||||
throw new Error(`Codex thread ${params.threadId} has no readable in-progress turn`);
|
||||
}
|
||||
await connection.request("turn/interrupt", { threadId: params.threadId, turnId });
|
||||
return { endpointId, threadId: params.threadId, turnId };
|
||||
} catch (error) {
|
||||
this.forgetEndpoint(endpointId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async listEndpointSessions(
|
||||
endpoint: CodexSupervisorEndpoint,
|
||||
params: { includeStored?: boolean; maxStoredSessions?: number },
|
||||
): Promise<CodexSupervisorSession[]> {
|
||||
if (params.includeStored === true) {
|
||||
const loaded = await this.listLoadedThreadSessions(endpoint);
|
||||
const sessions = [...loaded];
|
||||
for (const stored of await this.listStoredThreadSessions(
|
||||
endpoint,
|
||||
params.maxStoredSessions,
|
||||
)) {
|
||||
// Loaded sessions are authoritative for attachment/status; append stored
|
||||
// history only for threads that are not already live.
|
||||
if (!sessions.some((session) => session.threadId === stored.threadId)) {
|
||||
sessions.push(stored);
|
||||
}
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
return await this.listLoadedThreadSessions(endpoint);
|
||||
}
|
||||
|
||||
private async listLoadedThreadSessions(
|
||||
endpoint: CodexSupervisorEndpoint,
|
||||
): Promise<CodexSupervisorSession[]> {
|
||||
const sessions: CodexSupervisorSession[] = [];
|
||||
const connection = await this.connectionFor(endpoint.id);
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const listed = await connection.request("thread/loaded/list", {
|
||||
limit: 100,
|
||||
...(cursor ? { cursor } : {}),
|
||||
});
|
||||
for (const threadId of extractStringList(listed)) {
|
||||
if (sessions.some((entry) => entry.threadId === threadId)) {
|
||||
continue;
|
||||
}
|
||||
const read = await this.readOptionalLoadedThread(connection, threadId);
|
||||
const thread = extractThread(read);
|
||||
const session = thread ? toSession(endpoint.id, thread, true) : undefined;
|
||||
if (session) {
|
||||
sessions.push(session);
|
||||
}
|
||||
}
|
||||
cursor =
|
||||
isRecord(listed) && typeof listed.nextCursor === "string" ? listed.nextCursor : undefined;
|
||||
} while (cursor);
|
||||
return sessions;
|
||||
}
|
||||
|
||||
private async listStoredThreadSessions(
|
||||
endpoint: CodexSupervisorEndpoint,
|
||||
maxStoredSessions = DEFAULT_MAX_STORED_SESSIONS,
|
||||
): Promise<CodexSupervisorSession[]> {
|
||||
const sessionLimit = Number.isFinite(maxStoredSessions)
|
||||
? Math.min(1000, Math.max(1, Math.floor(maxStoredSessions)))
|
||||
: DEFAULT_MAX_STORED_SESSIONS;
|
||||
const sessions: CodexSupervisorSession[] = [];
|
||||
const connection = await this.connectionFor(endpoint.id);
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const remaining = sessionLimit - sessions.length;
|
||||
if (remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
const listed = await connection.request("thread/list", {
|
||||
limit: Math.min(100, remaining),
|
||||
sourceKinds: ALL_CODEX_THREAD_SOURCE_KINDS,
|
||||
useStateDbOnly: true,
|
||||
...(cursor ? { cursor } : {}),
|
||||
});
|
||||
for (const thread of extractThreadList(listed)) {
|
||||
if (typeof thread.id !== "string") {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
sessions.some((entry) => entry.endpointId === endpoint.id && entry.threadId === thread.id)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const session = toSession(endpoint.id, thread);
|
||||
if (session) {
|
||||
sessions.push(session);
|
||||
if (sessions.length >= sessionLimit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor =
|
||||
isRecord(listed) && typeof listed.nextCursor === "string" ? listed.nextCursor : undefined;
|
||||
} while (cursor);
|
||||
return sessions;
|
||||
}
|
||||
|
||||
private async readOptionalLoadedThread(
|
||||
connection: CodexJsonRpcConnection,
|
||||
threadId: string,
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
return await this.readLoadedThread(connection, threadId, false);
|
||||
} catch (error) {
|
||||
if (isLoadedThreadReadMiss(error)) {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async readLoadedThread(
|
||||
connection: CodexJsonRpcConnection,
|
||||
threadId: string,
|
||||
includeTurns: boolean,
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
return await connection.request("thread/read", { threadId, includeTurns });
|
||||
} catch (error) {
|
||||
if (!includeTurns) {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes("not materialized yet")) {
|
||||
throw error;
|
||||
}
|
||||
return await connection.request("thread/read", { threadId, includeTurns: false });
|
||||
}
|
||||
}
|
||||
|
||||
private async startTurn(
|
||||
connection: CodexJsonRpcConnection,
|
||||
endpointId: string,
|
||||
threadId: string,
|
||||
text: string,
|
||||
): Promise<CodexSupervisorSendResult> {
|
||||
const result = await connection.request("turn/start", {
|
||||
threadId,
|
||||
input: [{ type: "text", text, text_elements: [] }],
|
||||
});
|
||||
const turn = isRecord(result) && isRecord(result.turn) ? result.turn : undefined;
|
||||
return {
|
||||
endpointId,
|
||||
threadId,
|
||||
mode: "start",
|
||||
...(typeof turn?.id === "string" ? { turnId: turn.id } : {}),
|
||||
...(typeof turn?.status === "string" ? { status: turn.status } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async readThread(
|
||||
connection: CodexJsonRpcConnection,
|
||||
threadId: string,
|
||||
includeTurns: boolean,
|
||||
): Promise<unknown> {
|
||||
return await this.readLoadedThread(connection, threadId, includeTurns);
|
||||
}
|
||||
|
||||
private async readActiveTurnId(
|
||||
connection: CodexJsonRpcConnection,
|
||||
threadId: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await connection.request("thread/turns/list", {
|
||||
threadId,
|
||||
limit: 10,
|
||||
sortDirection: "desc",
|
||||
itemsView: "summary",
|
||||
});
|
||||
return extractThreadList(response).find(
|
||||
(turn) => turn.status === "inProgress" && typeof turn.id === "string",
|
||||
)?.id as string | undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveEndpointId(params: {
|
||||
endpointId?: string;
|
||||
threadId: string;
|
||||
}): Promise<string> {
|
||||
if (params.endpointId) {
|
||||
return params.endpointId;
|
||||
}
|
||||
const sessions = await this.listSessions();
|
||||
const matches = sessions.filter((session) => session.threadId === params.threadId);
|
||||
if (matches.length === 1) {
|
||||
return matches[0].endpointId;
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`Codex thread id is ambiguous across endpoints: ${params.threadId}`);
|
||||
}
|
||||
const endpointIds = new Set(matches.map((match) => match.endpointId));
|
||||
for (const endpoint of this.endpoints) {
|
||||
if (endpointIds.has(endpoint.id)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const connection = await this.connectionFor(endpoint.id);
|
||||
const read = await this.readThread(connection, params.threadId, false);
|
||||
const thread = extractThread(read);
|
||||
if (thread?.id === params.threadId) {
|
||||
endpointIds.add(endpoint.id);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isLoadedThreadReadMiss(error)) {
|
||||
continue;
|
||||
}
|
||||
this.forgetEndpoint(endpoint.id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (endpointIds.size === 1) {
|
||||
for (const endpointId of endpointIds) {
|
||||
return endpointId;
|
||||
}
|
||||
}
|
||||
if (endpointIds.size > 1) {
|
||||
throw new Error(`Codex thread id is ambiguous across endpoints: ${params.threadId}`);
|
||||
}
|
||||
throw new Error(`Codex thread not found: ${params.threadId}`);
|
||||
}
|
||||
|
||||
private async connectionFor(endpointId: string): Promise<CodexJsonRpcConnection> {
|
||||
const endpoint = this.endpoints.find((entry) => entry.id === endpointId);
|
||||
if (!endpoint) {
|
||||
throw new Error(`Unknown Codex supervisor endpoint: ${endpointId}`);
|
||||
}
|
||||
const existing = this.connections.get(endpoint.id);
|
||||
if (existing) {
|
||||
return await existing;
|
||||
}
|
||||
const created = this.connector(endpoint);
|
||||
this.connections.set(endpoint.id, created);
|
||||
void created.catch(() => {
|
||||
if (this.connections.get(endpoint.id) === created) {
|
||||
this.connections.delete(endpoint.id);
|
||||
}
|
||||
});
|
||||
return await created;
|
||||
}
|
||||
|
||||
private forgetEndpoint(endpointId: string): void {
|
||||
const existing = this.connections.get(endpointId);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
this.connections.delete(endpointId);
|
||||
void existing.then((connection) => connection.close()).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
69
extensions/codex-supervisor/src/types.ts
Normal file
69
extensions/codex-supervisor/src/types.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Public Codex Supervisor endpoint, session, and JSON-RPC connection types.
|
||||
*/
|
||||
/** Configured transport target for a Codex app-server endpoint. */
|
||||
export type CodexSupervisorEndpoint =
|
||||
| {
|
||||
id: string;
|
||||
label?: string;
|
||||
transport: "stdio-proxy";
|
||||
command?: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
label?: string;
|
||||
transport: "websocket";
|
||||
url: string;
|
||||
authTokenEnv?: string;
|
||||
};
|
||||
|
||||
/** Send behavior requested by supervisor write tools. */
|
||||
export type CodexSupervisorTurnMode = "auto" | "start" | "steer";
|
||||
|
||||
/** App-server thread status string, preserved for forward compatibility. */
|
||||
export type CodexSupervisorThreadStatus = string;
|
||||
|
||||
/** Normalized session summary returned by supervisor list operations. */
|
||||
export type CodexSupervisorSession = {
|
||||
endpointId: string;
|
||||
threadId: string;
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
preview?: string;
|
||||
name?: string | null;
|
||||
source?: string;
|
||||
status: CodexSupervisorThreadStatus;
|
||||
updatedAt?: number;
|
||||
humanAttached?: boolean;
|
||||
};
|
||||
|
||||
/** Result returned after starting or steering a Codex turn. */
|
||||
export type CodexSupervisorSendResult = {
|
||||
endpointId: string;
|
||||
threadId: string;
|
||||
mode: "start" | "steer";
|
||||
turnId?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
/** Minimal JSON-RPC connection contract used by the supervisor. */
|
||||
export type CodexJsonRpcConnection = {
|
||||
request(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
||||
notify(method: string, params?: Record<string, unknown>): void;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
/** Health result for one configured supervisor endpoint. */
|
||||
export type CodexSupervisorEndpointHealth = {
|
||||
endpointId: string;
|
||||
ok: boolean;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
/** Session list plus endpoint errors for tool-friendly structured output. */
|
||||
export type CodexSupervisorSessionListResult = {
|
||||
sessions: CodexSupervisorSession[];
|
||||
errors: CodexSupervisorEndpointHealth[];
|
||||
};
|
||||
Reference in New Issue
Block a user