diff --git a/CHANGELOG.md b/CHANGELOG.md index c918e9b1664..93df27b8a40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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..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. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 1af5a84afbc..dd092b1697f 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/extensions/codex/src/app-server/session-binding.test.ts b/extensions/codex/src/app-server/session-binding.test.ts index 44d55e4d8cd..3b835105b2b 100644 --- a/extensions/codex/src/app-server/session-binding.test.ts +++ b/extensions/codex/src/app-server/session-binding.test.ts @@ -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); }); diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index 039f3e1f0a1..f0cfce2ea5a 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -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, diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index 2ebef8423ea..decca736cd7 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -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 { 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); diff --git a/extensions/codex/src/app-server/thread-lifecycle.user-mcp-servers.test.ts b/extensions/codex/src/app-server/thread-lifecycle.user-mcp-servers.test.ts new file mode 100644 index 00000000000..8985a5010bc --- /dev/null +++ b/extensions/codex/src/app-server/thread-lifecycle.user-mcp-servers.test.ts @@ -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 { + 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 { + 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 } }; + 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 } }; + 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 }; + }; + 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 }; + }; + expect(resumeCall).toBeDefined(); + expect(resumeParams?.config?.mcp_servers).toBeUndefined(); + }); +}); diff --git a/scripts/lib/plugin-sdk-private-local-only-subpaths.json b/scripts/lib/plugin-sdk-private-local-only-subpaths.json index 91e92dd8870..55c63a7a928 100644 --- a/scripts/lib/plugin-sdk-private-local-only-subpaths.json +++ b/scripts/lib/plugin-sdk-private-local-only-subpaths.json @@ -1,4 +1,5 @@ [ + "codex-mcp-projection", "codex-native-task-runtime", "qa-channel", "qa-channel-protocol", diff --git a/src/agents/cli-runner/bundle-mcp-codex.ts b/src/agents/cli-runner/bundle-mcp-codex.ts index c6a338cb28a..6c6b27fea9a 100644 --- a/src/agents/cli-runner/bundle-mcp-codex.ts +++ b/src/agents/cli-runner/bundle-mcp-codex.ts @@ -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 { - const next: Record = {}; +): 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 }; +} diff --git a/src/agents/cli-runner/bundle-mcp-codex.user-config.test.ts b/src/agents/cli-runner/bundle-mcp-codex.user-config.test.ts new file mode 100644 index 00000000000..1cb31519c0f --- /dev/null +++ b/src/agents/cli-runner/bundle-mcp-codex.user-config.test.ts @@ -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" }); + }); +}); diff --git a/src/plugin-sdk/codex-mcp-projection.ts b/src/plugin-sdk/codex-mcp-projection.ts new file mode 100644 index 00000000000..4b7e1c2cb11 --- /dev/null +++ b/src/plugin-sdk/codex-mcp-projection.ts @@ -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"; diff --git a/src/plugins/sdk-alias.test.ts b/src/plugins/sdk-alias.test.ts index c1e280a064f..c7536577bdf 100644 --- a/src/plugins/sdk-alias.test.ts +++ b/src/plugins/sdk-alias.test.ts @@ -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", () => { diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index 9106da45b80..7a72ac6d047 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -267,6 +267,11 @@ const cachedPluginSdkScopedAliasMaps = new PluginLruCache 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, diff --git a/tsdown.config.ts b/tsdown.config.ts index cc962ce9b67..108c8a47a0a 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -285,6 +285,8 @@ function buildUnifiedDistEntries(): Record { "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}`,