mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 12:34:47 +00:00
fix(codex): project user MCP servers into app-server threads
Fixes #80814. Co-authored-by: kinjitakabe <273844887+kinjitakabe@users.noreply.github.com>
This commit is contained in:
@@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai
|
||||
- slack: enforce reaction notification policy [AI]. (#80907) Thanks @pgondhi987.
|
||||
- Enforce gateway command scopes by caller context [AI]. (#80891) Thanks @pgondhi987.
|
||||
- Telegram/groups: in single-account setups, treat an explicit empty `accounts.<id>.groups: {}` map the same as undefined so the root `channels.telegram.groups` allowlist still applies, instead of silently dropping every group update under the default `groupPolicy: "allowlist"`. Multi-account semantics are unchanged so per-account explicit-empty groups still scope-disable a single account without affecting siblings; the explicit way to block all groups for any account remains `groupPolicy: "disabled"`. Fixes #79427. (#81030) Thanks @kinjitakabe.
|
||||
- Codex (app-server): project user-configured `mcp.servers` into new Codex thread configs, matching the codex-cli runtime's existing `-c mcp_servers=...` behavior so app-server-runtime agents see the same user MCP servers the CLI runtime already exposes. Plugin-curated apps remain attached via the separate `apps` config patch. Fixes #80814. Thanks @kinjitakabe.
|
||||
- Enforce Slack plugin approval button authorization [AI]. (#80899) Thanks @pgondhi987.
|
||||
- Recognize PowerShell -ec inline commands [AI]. (#80893) Thanks @pgondhi987.
|
||||
- fix(qqbot): authorize approval button callbacks [AI]. (#80892) Thanks @pgondhi987.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
54198a37be7bbd7949aef79fb7b1b95550967e5a947cd34fc659f3cb648ffa0a plugin-sdk-api-baseline.json
|
||||
345e1f0786b83a3454de82b4434bf4aacaf755db9550366f445fa9a6ac98bf15 plugin-sdk-api-baseline.jsonl
|
||||
3468877af0d3fe749812abc6d4852194b07f3468533fd0fee2772dd26c4e62fe plugin-sdk-api-baseline.json
|
||||
2b880b2509bd9a02566b003a4cded1c556245f3625aa13fb3013fa16114ab75a plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -59,6 +59,7 @@ describe("codex app-server session binding", () => {
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "tools-v1",
|
||||
userMcpServersFingerprint: "user-mcp-v1",
|
||||
});
|
||||
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
@@ -70,6 +71,7 @@ describe("codex app-server session binding", () => {
|
||||
expect(binding?.model).toBe("gpt-5.4-codex");
|
||||
expect(binding?.modelProvider).toBe("openai");
|
||||
expect(binding?.dynamicToolsFingerprint).toBe("tools-v1");
|
||||
expect(binding?.userMcpServersFingerprint).toBe("user-mcp-v1");
|
||||
const bindingStat = await fs.stat(resolveCodexAppServerBindingPath(sessionFile));
|
||||
expect(bindingStat.isFile()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ export type CodexAppServerThreadBinding = {
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
serviceTier?: CodexServiceTier;
|
||||
dynamicToolsFingerprint?: string;
|
||||
userMcpServersFingerprint?: string;
|
||||
pluginAppsFingerprint?: string;
|
||||
pluginAppsInputFingerprint?: string;
|
||||
pluginAppPolicyContext?: PluginAppPolicyContext;
|
||||
@@ -99,6 +100,10 @@ export async function readCodexAppServerBinding(
|
||||
typeof parsed.dynamicToolsFingerprint === "string"
|
||||
? parsed.dynamicToolsFingerprint
|
||||
: undefined,
|
||||
userMcpServersFingerprint:
|
||||
typeof parsed.userMcpServersFingerprint === "string"
|
||||
? parsed.userMcpServersFingerprint
|
||||
: undefined,
|
||||
pluginAppsFingerprint:
|
||||
typeof parsed.pluginAppsFingerprint === "string" ? parsed.pluginAppsFingerprint : undefined,
|
||||
pluginAppsInputFingerprint:
|
||||
@@ -143,6 +148,7 @@ export async function writeCodexAppServerBinding(
|
||||
sandbox: binding.sandbox,
|
||||
serviceTier: binding.serviceTier,
|
||||
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint: binding.userMcpServersFingerprint,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
|
||||
pluginAppPolicyContext: binding.pluginAppPolicyContext,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
isActiveHarnessContextEngine,
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { buildCodexUserMcpServersThreadConfigPatch } from "openclaw/plugin-sdk/codex-mcp-projection";
|
||||
import {
|
||||
CODEX_GPT5_HEARTBEAT_PROMPT_OVERLAY,
|
||||
renderCodexPromptOverlay,
|
||||
@@ -76,6 +77,8 @@ export async function startOrResumeThread(params: {
|
||||
}): Promise<CodexAppServerThreadLifecycleBinding> {
|
||||
const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools);
|
||||
const contextEngineBinding = buildContextEngineBinding(params.params);
|
||||
const userMcpServersConfigPatch = buildCodexUserMcpServersThreadConfigPatch(params.params.config);
|
||||
const userMcpServersFingerprint = fingerprintUserMcpServersConfigPatch(userMcpServersConfigPatch);
|
||||
let binding = await readCodexAppServerBinding(params.params.sessionFile, {
|
||||
authProfileStore: params.params.authProfileStore,
|
||||
agentDir: params.params.agentDir,
|
||||
@@ -102,6 +105,13 @@ export async function startOrResumeThread(params: {
|
||||
rotatedContextEngineBinding = true;
|
||||
}
|
||||
}
|
||||
if (binding?.threadId && binding.userMcpServersFingerprint !== userMcpServersFingerprint) {
|
||||
embeddedAgentLog.debug("codex app-server user MCP config changed; starting a new thread", {
|
||||
threadId: binding.threadId,
|
||||
});
|
||||
await clearCodexAppServerBinding(params.params.sessionFile);
|
||||
binding = undefined;
|
||||
}
|
||||
if (binding?.threadId) {
|
||||
let pluginBindingStale = isCodexPluginThreadBindingStale({
|
||||
codexPluginsEnabled: params.pluginThreadConfig?.enabled ?? false,
|
||||
@@ -198,6 +208,7 @@ export async function startOrResumeThread(params: {
|
||||
model: params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? fallbackModelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
|
||||
pluginAppPolicyContext: binding.pluginAppPolicyContext,
|
||||
@@ -218,6 +229,7 @@ export async function startOrResumeThread(params: {
|
||||
model: params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? fallbackModelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
|
||||
pluginAppPolicyContext: binding.pluginAppPolicyContext,
|
||||
@@ -239,7 +251,11 @@ export async function startOrResumeThread(params: {
|
||||
const pluginThreadConfig = params.pluginThreadConfig?.enabled
|
||||
? (prebuiltPluginThreadConfig ?? (await params.pluginThreadConfig.build()))
|
||||
: undefined;
|
||||
const config = mergeCodexThreadConfigs(params.config, pluginThreadConfig?.configPatch);
|
||||
const config = mergeCodexThreadConfigs(
|
||||
params.config,
|
||||
userMcpServersConfigPatch,
|
||||
pluginThreadConfig?.configPatch,
|
||||
);
|
||||
const response = assertCodexThreadStartResponse(
|
||||
await params.client.request(
|
||||
"thread/start",
|
||||
@@ -270,6 +286,7 @@ export async function startOrResumeThread(params: {
|
||||
model: response.model ?? params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? modelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
|
||||
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
|
||||
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
|
||||
@@ -292,6 +309,7 @@ export async function startOrResumeThread(params: {
|
||||
model: response.model ?? params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? modelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
|
||||
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
|
||||
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
|
||||
@@ -530,6 +548,12 @@ function fingerprintDynamicTools(dynamicTools: CodexDynamicToolSpec[]): string {
|
||||
);
|
||||
}
|
||||
|
||||
function fingerprintUserMcpServersConfigPatch(
|
||||
configPatch: JsonObject | undefined,
|
||||
): string | undefined {
|
||||
return configPatch ? JSON.stringify(stabilizeJsonValue(configPatch)) : undefined;
|
||||
}
|
||||
|
||||
function fingerprintDynamicToolSpec(tool: JsonValue): JsonValue {
|
||||
if (!isJsonObject(tool)) {
|
||||
return stabilizeJsonValue(tool);
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerRuntimeOptions } from "./config.js";
|
||||
import { writeCodexAppServerBinding } from "./session-binding.js";
|
||||
import { startOrResumeThread } from "./thread-lifecycle.js";
|
||||
|
||||
function threadStartResult(threadId = "thread-1"): Record<string, unknown> {
|
||||
return {
|
||||
thread: {
|
||||
id: threadId,
|
||||
sessionId: "session-1",
|
||||
forkedFromId: null,
|
||||
preview: "",
|
||||
ephemeral: false,
|
||||
modelProvider: "openai",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: "/tmp",
|
||||
cliVersion: "0.125.0",
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
gitInfo: null,
|
||||
name: null,
|
||||
turns: [],
|
||||
},
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
serviceTier: null,
|
||||
cwd: "/tmp",
|
||||
instructionSources: [],
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: { type: "dangerFullAccess" },
|
||||
permissionProfile: null,
|
||||
reasoningEffort: null,
|
||||
};
|
||||
}
|
||||
|
||||
function threadResumeResult(threadId = "thread-existing"): Record<string, unknown> {
|
||||
return threadStartResult(threadId);
|
||||
}
|
||||
|
||||
function createAppServerOptions(): CodexAppServerRuntimeOptions {
|
||||
return {
|
||||
start: {
|
||||
transport: "stdio",
|
||||
command: "codex",
|
||||
args: ["app-server"],
|
||||
headers: {},
|
||||
},
|
||||
requestTimeoutMs: 60_000,
|
||||
turnCompletionIdleTimeoutMs: 60_000,
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: "workspace-write",
|
||||
} as unknown as CodexAppServerRuntimeOptions;
|
||||
}
|
||||
|
||||
function createParams(
|
||||
sessionFile: string,
|
||||
workspaceDir: string,
|
||||
configOverrides?: EmbeddedRunAttemptParams["config"],
|
||||
): EmbeddedRunAttemptParams {
|
||||
return {
|
||||
prompt: "hello",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
runId: "run-1",
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4-codex",
|
||||
thinkLevel: "medium",
|
||||
disableTools: true,
|
||||
timeoutMs: 5_000,
|
||||
authStorage: {} as never,
|
||||
authProfileStore: { version: 1, profiles: {} },
|
||||
modelRegistry: {} as never,
|
||||
config: configOverrides,
|
||||
} as unknown as EmbeddedRunAttemptParams;
|
||||
}
|
||||
|
||||
describe("startOrResumeThread — user mcp.servers projection (regression: #80814)", () => {
|
||||
let tempDir = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-80814-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("projects cfg.mcp.servers into the thread/start config patch under mcp_servers", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const request = vi.fn(async (method: string, _params: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult();
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir, {
|
||||
mcp: {
|
||||
servers: {
|
||||
outlook: {
|
||||
transport: "stdio",
|
||||
command: "node",
|
||||
args: ["/opt/outlook-mcp/dist/index.js"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as EmbeddedRunAttemptParams["config"]),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createAppServerOptions(),
|
||||
});
|
||||
|
||||
const startCall = request.mock.calls.find(([method]) => method === "thread/start");
|
||||
const startParams = startCall?.[1] as { config?: { mcp_servers?: Record<string, unknown> } };
|
||||
expect(startParams?.config?.mcp_servers).toBeDefined();
|
||||
expect(startParams.config!.mcp_servers).toMatchObject({
|
||||
outlook: { command: "node", args: ["/opt/outlook-mcp/dist/index.js"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("omits mcp_servers from the start config when cfg has no user MCP servers", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const request = vi.fn(async (method: string, _params: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult();
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createAppServerOptions(),
|
||||
});
|
||||
|
||||
const startCall = request.mock.calls.find(([method]) => method === "thread/start");
|
||||
const startParams = startCall?.[1] as { config?: { mcp_servers?: Record<string, unknown> } };
|
||||
expect(startParams?.config?.mcp_servers).toBeUndefined();
|
||||
});
|
||||
|
||||
it("starts a new thread when an existing binding lacks the matching user MCP fingerprint", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
});
|
||||
|
||||
const request = vi.fn(async (method: string, _params: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-restarted");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir, {
|
||||
mcp: {
|
||||
servers: {
|
||||
notes: {
|
||||
transport: "stdio",
|
||||
command: "node",
|
||||
args: ["/opt/notes-mcp/dist/index.js"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as EmbeddedRunAttemptParams["config"]),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createAppServerOptions(),
|
||||
});
|
||||
|
||||
expect(request.mock.calls.some(([method]) => method === "thread/resume")).toBe(false);
|
||||
const startCall = request.mock.calls.find(([method]) => method === "thread/start");
|
||||
const startParams = startCall?.[1] as {
|
||||
config?: { mcp_servers?: Record<string, unknown> };
|
||||
};
|
||||
expect(startParams?.config?.mcp_servers).toBeDefined();
|
||||
expect(startParams.config!.mcp_servers).toMatchObject({
|
||||
notes: { command: "node", args: ["/opt/notes-mcp/dist/index.js"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("resumes a thread with the matching user MCP fingerprint without resending ignored MCP config", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const config = {
|
||||
mcp: {
|
||||
servers: {
|
||||
notes: {
|
||||
transport: "stdio",
|
||||
command: "node",
|
||||
args: ["/opt/notes-mcp/dist/index.js"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as EmbeddedRunAttemptParams["config"];
|
||||
const request = vi.fn(async (method: string, _params: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-with-user-mcp");
|
||||
}
|
||||
if (method === "thread/resume") {
|
||||
return threadResumeResult("thread-with-user-mcp");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir, config),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createAppServerOptions(),
|
||||
});
|
||||
|
||||
request.mockClear();
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir, config),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createAppServerOptions(),
|
||||
});
|
||||
|
||||
const resumeCall = request.mock.calls.find(([method]) => method === "thread/resume");
|
||||
const resumeParams = resumeCall?.[1] as {
|
||||
config?: { mcp_servers?: Record<string, unknown> };
|
||||
};
|
||||
expect(resumeCall).toBeDefined();
|
||||
expect(resumeParams?.config?.mcp_servers).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
[
|
||||
"codex-mcp-projection",
|
||||
"codex-native-task-runtime",
|
||||
"qa-channel",
|
||||
"qa-channel-protocol",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { normalizeConfiguredMcpServers } from "../../config/mcp-config-normalize.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { BundleMcpConfig, BundleMcpServerConfig } from "../../plugins/bundle-mcp.js";
|
||||
import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js";
|
||||
import {
|
||||
@@ -7,6 +9,20 @@ import {
|
||||
} from "./bundle-mcp-adapter-shared.js";
|
||||
import { serializeTomlInlineValue } from "./toml-inline.js";
|
||||
|
||||
// Mutable JSON shape structurally compatible with the bundled Codex
|
||||
// app-server thread-config JsonObject (see the protocol module in the codex
|
||||
// plugin). Defined locally so this projection result stays assignable to
|
||||
// mergeCodexThreadConfigs without pulling plugin-local types across the
|
||||
// extensions boundary.
|
||||
type CodexThreadConfigValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| CodexThreadConfigValue[]
|
||||
| { [key: string]: CodexThreadConfigValue };
|
||||
type CodexThreadConfigObject = { [key: string]: CodexThreadConfigValue };
|
||||
|
||||
function isOpenClawLoopbackMcpServer(name: string, server: BundleMcpServerConfig): boolean {
|
||||
return (
|
||||
name === "openclaw" &&
|
||||
@@ -18,8 +34,8 @@ function isOpenClawLoopbackMcpServer(name: string, server: BundleMcpServerConfig
|
||||
function normalizeCodexServerConfig(
|
||||
name: string,
|
||||
server: BundleMcpServerConfig,
|
||||
): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = {};
|
||||
): CodexThreadConfigObject {
|
||||
const next: CodexThreadConfigObject = {};
|
||||
applyCommonServerConfig(next, server);
|
||||
if (isOpenClawLoopbackMcpServer(name, server)) {
|
||||
next.default_tools_approval_mode = "approve";
|
||||
@@ -64,3 +80,30 @@ export function injectCodexMcpConfigArgs(
|
||||
);
|
||||
return [...(args ?? []), "-c", `mcp_servers=${overrides}`];
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex app-server runtime (extensions/codex) receives its thread config as a
|
||||
* JSON object through JSON-RPC `thread/start`/`thread/resume`, not as `-c` CLI
|
||||
* args. This returns a thread-config patch projecting user-configured
|
||||
* `cfg.mcp.servers` entries into Codex's `mcp_servers` table using the same
|
||||
* per-server normalization the CLI path uses, so app-server agents see the
|
||||
* same user MCP servers the CLI runtime exposes via `injectCodexMcpConfigArgs`.
|
||||
*
|
||||
* Only user-configured servers (`cfg.mcp.servers`) are projected. Plugin-
|
||||
* curated app-server apps are already attached separately through the codex
|
||||
* plugin thread-config `apps` patch, so they must not be re-projected here.
|
||||
*/
|
||||
export function buildCodexUserMcpServersThreadConfigPatch(
|
||||
cfg: OpenClawConfig | undefined,
|
||||
): { mcp_servers: CodexThreadConfigObject } | undefined {
|
||||
const userServers = normalizeConfiguredMcpServers(cfg?.mcp?.servers);
|
||||
const entries = Object.entries(userServers);
|
||||
if (entries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const mcp_servers: CodexThreadConfigObject = {};
|
||||
for (const [name, server] of entries) {
|
||||
mcp_servers[name] = normalizeCodexServerConfig(name, server as BundleMcpServerConfig);
|
||||
}
|
||||
return { mcp_servers };
|
||||
}
|
||||
|
||||
81
src/agents/cli-runner/bundle-mcp-codex.user-config.test.ts
Normal file
81
src/agents/cli-runner/bundle-mcp-codex.user-config.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { buildCodexUserMcpServersThreadConfigPatch } from "./bundle-mcp-codex.js";
|
||||
|
||||
describe("buildCodexUserMcpServersThreadConfigPatch", () => {
|
||||
it("returns undefined when cfg has no mcp.servers (regression: #80814)", () => {
|
||||
expect(buildCodexUserMcpServersThreadConfigPatch(undefined)).toBeUndefined();
|
||||
expect(buildCodexUserMcpServersThreadConfigPatch({} as OpenClawConfig)).toBeUndefined();
|
||||
expect(
|
||||
buildCodexUserMcpServersThreadConfigPatch({ mcp: {} } as OpenClawConfig),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
buildCodexUserMcpServersThreadConfigPatch({ mcp: { servers: {} } } as OpenClawConfig),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("projects a stdio user MCP server entry into mcp_servers (regression: #80814)", () => {
|
||||
const patch = buildCodexUserMcpServersThreadConfigPatch({
|
||||
mcp: {
|
||||
servers: {
|
||||
outlook: {
|
||||
transport: "stdio",
|
||||
command: "node",
|
||||
args: ["/opt/outlook-mcp/dist/index.js"],
|
||||
env: { OUTLOOK_USER: "alice@example.org" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig);
|
||||
expect(patch).toStrictEqual({
|
||||
mcp_servers: {
|
||||
outlook: {
|
||||
command: "node",
|
||||
args: ["/opt/outlook-mcp/dist/index.js"],
|
||||
env: { OUTLOOK_USER: "alice@example.org" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("projects a streamable-http user MCP server with bearer auth into mcp_servers", () => {
|
||||
const patch = buildCodexUserMcpServersThreadConfigPatch({
|
||||
mcp: {
|
||||
servers: {
|
||||
notes: {
|
||||
transport: "streamable-http",
|
||||
url: "https://notes.example.org/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer ${NOTES_TOKEN}",
|
||||
"x-tenant": "${NOTES_TENANT}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig);
|
||||
expect(patch).toStrictEqual({
|
||||
mcp_servers: {
|
||||
notes: {
|
||||
url: "https://notes.example.org/mcp",
|
||||
bearer_token_env_var: "NOTES_TOKEN",
|
||||
env_http_headers: { "x-tenant": "NOTES_TENANT" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves multiple user MCP servers as independent mcp_servers entries", () => {
|
||||
const patch = buildCodexUserMcpServersThreadConfigPatch({
|
||||
mcp: {
|
||||
servers: {
|
||||
one: { transport: "stdio", command: "one" },
|
||||
two: { transport: "stdio", command: "two" },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig);
|
||||
expect(patch?.mcp_servers).toBeDefined();
|
||||
expect(Object.keys(patch!.mcp_servers).toSorted()).toEqual(["one", "two"]);
|
||||
expect(patch!.mcp_servers.one).toMatchObject({ command: "one" });
|
||||
expect(patch!.mcp_servers.two).toMatchObject({ command: "two" });
|
||||
});
|
||||
});
|
||||
6
src/plugin-sdk/codex-mcp-projection.ts
Normal file
6
src/plugin-sdk/codex-mcp-projection.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Private helper surface for the bundled Codex plugin. Mirrors the Codex CLI
|
||||
// runtime's user-mcp-server projection so the bundled Codex app-server harness
|
||||
// can attach the same user `mcp.servers` entries to its thread config without
|
||||
// deep-importing core helpers.
|
||||
|
||||
export { buildCodexUserMcpServersThreadConfigPatch } from "../agents/cli-runner/bundle-mcp-codex.js";
|
||||
@@ -668,6 +668,11 @@ describe("plugin sdk alias helpers", () => {
|
||||
"export const codexNativeTaskRuntime = true;\n",
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(fixture.root, "src", "plugin-sdk", "codex-mcp-projection.ts"),
|
||||
"export const codexMcpProjection = true;\n",
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(fixture.root, "src", "plugin-sdk", "qa-runtime.ts"),
|
||||
"export const qaRuntime = true;\n",
|
||||
@@ -736,8 +741,12 @@ describe("plugin sdk alias helpers", () => {
|
||||
),
|
||||
);
|
||||
|
||||
expect(codexSubpaths).toEqual(["codex-native-task-runtime", "core"]);
|
||||
expect(installedCodexSubpaths).toEqual(["codex-native-task-runtime", "core"]);
|
||||
expect(codexSubpaths).toEqual(["codex-mcp-projection", "codex-native-task-runtime", "core"]);
|
||||
expect(installedCodexSubpaths).toEqual([
|
||||
"codex-mcp-projection",
|
||||
"codex-native-task-runtime",
|
||||
"core",
|
||||
]);
|
||||
expect(otherSubpaths).toEqual(["core"]);
|
||||
expect(installedOtherSubpaths).toEqual(["core"]);
|
||||
expect(shadowCodexSubpaths).toEqual(["core"]);
|
||||
@@ -924,6 +933,12 @@ describe("plugin sdk alias helpers", () => {
|
||||
"plugin-sdk",
|
||||
"codex-native-task-runtime.ts",
|
||||
);
|
||||
const sourceCodexMcpProjectionPath = path.join(
|
||||
fixture.root,
|
||||
"src",
|
||||
"plugin-sdk",
|
||||
"codex-mcp-projection.ts",
|
||||
);
|
||||
const distRootAlias = path.join(fixture.root, "dist", "plugin-sdk", "root-alias.cjs");
|
||||
const distCodexNativeTaskRuntimePath = path.join(
|
||||
fixture.root,
|
||||
@@ -931,6 +946,12 @@ describe("plugin sdk alias helpers", () => {
|
||||
"plugin-sdk",
|
||||
"codex-native-task-runtime.js",
|
||||
);
|
||||
const distCodexMcpProjectionPath = path.join(
|
||||
fixture.root,
|
||||
"dist",
|
||||
"plugin-sdk",
|
||||
"codex-mcp-projection.js",
|
||||
);
|
||||
const sourceQaRuntimePath = path.join(fixture.root, "src", "plugin-sdk", "qa-runtime.ts");
|
||||
fs.writeFileSync(sourceRootAlias, "module.exports = {};\n", "utf-8");
|
||||
fs.writeFileSync(distRootAlias, "module.exports = {};\n", "utf-8");
|
||||
@@ -943,11 +964,21 @@ describe("plugin sdk alias helpers", () => {
|
||||
"export const codexNativeTaskRuntime = true;\n",
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
sourceCodexMcpProjectionPath,
|
||||
"export const codexMcpProjection = true;\n",
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
distCodexNativeTaskRuntimePath,
|
||||
"export const codexNativeTaskRuntime = true;\n",
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
distCodexMcpProjectionPath,
|
||||
"export const codexMcpProjection = true;\n",
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(sourceQaRuntimePath, "export const qaRuntime = true;\n", "utf-8");
|
||||
const sourcePluginEntry = writePluginEntry(
|
||||
fixture.root,
|
||||
@@ -1022,13 +1053,22 @@ describe("plugin sdk alias helpers", () => {
|
||||
expect(fs.realpathSync(aliases["openclaw/plugin-sdk/codex-native-task-runtime"] ?? "")).toBe(
|
||||
fs.realpathSync(sourceCodexNativeTaskRuntimePath),
|
||||
);
|
||||
expect(fs.realpathSync(aliases["openclaw/plugin-sdk/codex-mcp-projection"] ?? "")).toBe(
|
||||
fs.realpathSync(sourceCodexMcpProjectionPath),
|
||||
);
|
||||
expect(
|
||||
fs.realpathSync(installedAliases["openclaw/plugin-sdk/codex-native-task-runtime"] ?? ""),
|
||||
).toBe(fs.realpathSync(distCodexNativeTaskRuntimePath));
|
||||
expect(
|
||||
fs.realpathSync(installedAliases["openclaw/plugin-sdk/codex-mcp-projection"] ?? ""),
|
||||
).toBe(fs.realpathSync(distCodexMcpProjectionPath));
|
||||
expect(aliases["openclaw/plugin-sdk/qa-runtime"]).toBeUndefined();
|
||||
expect(otherAliases["openclaw/plugin-sdk/codex-native-task-runtime"]).toBeUndefined();
|
||||
expect(otherAliases["openclaw/plugin-sdk/codex-mcp-projection"]).toBeUndefined();
|
||||
expect(installedOtherAliases["openclaw/plugin-sdk/codex-native-task-runtime"]).toBeUndefined();
|
||||
expect(installedOtherAliases["openclaw/plugin-sdk/codex-mcp-projection"]).toBeUndefined();
|
||||
expect(shadowCodexAliases["openclaw/plugin-sdk/codex-native-task-runtime"]).toBeUndefined();
|
||||
expect(shadowCodexAliases["openclaw/plugin-sdk/codex-mcp-projection"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies explicit dist resolution to plugin-sdk subpath aliases too", () => {
|
||||
|
||||
@@ -267,6 +267,11 @@ const cachedPluginSdkScopedAliasMaps = new PluginLruCache<Record<string, string>
|
||||
const PLUGIN_SDK_PACKAGE_NAMES = ["openclaw/plugin-sdk", "@openclaw/plugin-sdk"] as const;
|
||||
const OFFICIAL_CODEX_PLUGIN_PACKAGE_NAME = "@openclaw/codex";
|
||||
const CODEX_NATIVE_TASK_RUNTIME_PLUGIN_SDK_SUBPATH = "codex-native-task-runtime";
|
||||
const CODEX_MCP_PROJECTION_PLUGIN_SDK_SUBPATH = "codex-mcp-projection";
|
||||
const BUNDLED_CODEX_PRIVATE_PLUGIN_SDK_SUBPATHS = new Set([
|
||||
CODEX_NATIVE_TASK_RUNTIME_PLUGIN_SDK_SUBPATH,
|
||||
CODEX_MCP_PROJECTION_PLUGIN_SDK_SUBPATH,
|
||||
]);
|
||||
const PLUGIN_SDK_SOURCE_CANDIDATE_EXTENSIONS = [
|
||||
".ts",
|
||||
".mts",
|
||||
@@ -313,6 +318,7 @@ function readPrivateLocalOnlyPluginSdkSubpaths(packageRoot: string): string[] {
|
||||
return [
|
||||
...new Set([
|
||||
CODEX_NATIVE_TASK_RUNTIME_PLUGIN_SDK_SUBPATH,
|
||||
CODEX_MCP_PROJECTION_PLUGIN_SDK_SUBPATH,
|
||||
...(Array.isArray(parsed)
|
||||
? parsed.filter((subpath): subpath is string => isSafePluginSdkSubpathSegment(subpath))
|
||||
: []),
|
||||
@@ -513,7 +519,7 @@ function shouldIncludePrivateLocalOnlyPluginSdkSubpath(params: {
|
||||
}) {
|
||||
return (
|
||||
shouldIncludePrivateLocalOnlyPluginSdkSubpaths() ||
|
||||
(params.subpath === CODEX_NATIVE_TASK_RUNTIME_PLUGIN_SDK_SUBPATH &&
|
||||
(BUNDLED_CODEX_PRIVATE_PLUGIN_SDK_SUBPATHS.has(params.subpath) &&
|
||||
isTrustedCodexPluginModulePath({
|
||||
packageRoot: params.packageRoot,
|
||||
modulePath: params.modulePath,
|
||||
|
||||
@@ -285,6 +285,8 @@ function buildUnifiedDistEntries(): Record<string, string> {
|
||||
"plugin-sdk/compat": "src/plugin-sdk/compat.ts",
|
||||
// Private bundled Codex helper for app-server native subagent task mirroring.
|
||||
"plugin-sdk/codex-native-task-runtime": "src/plugin-sdk/codex-native-task-runtime.ts",
|
||||
// Private bundled Codex helper for app-server user MCP config projection.
|
||||
"plugin-sdk/codex-mcp-projection": "src/plugin-sdk/codex-mcp-projection.ts",
|
||||
...Object.fromEntries(
|
||||
Object.entries(buildPluginSdkEntrySources()).map(([entry, source]) => [
|
||||
`plugin-sdk/${entry}`,
|
||||
|
||||
Reference in New Issue
Block a user