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:
Peter Steinberger
2026-05-13 20:06:38 +01:00
parent d4484158d9
commit 8a406528b4
13 changed files with 477 additions and 8 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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);
});

View File

@@ -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,

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -1,4 +1,5 @@
[
"codex-mcp-projection",
"codex-native-task-runtime",
"qa-channel",
"qa-channel-protocol",

View File

@@ -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 };
}

View 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" });
});
});

View 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";

View File

@@ -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", () => {

View File

@@ -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,

View File

@@ -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}`,