mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(codex): preserve MCP servers in app-server harness (#81551)
* Plumb bundle MCP config into Codex app server * fix: align codex mcp thread config with pi * fix: rotate codex mcp threads when disabled * fix: scope codex bundle mcp to bundled servers * fix(codex): resend user MCP config on resume --------- Co-authored-by: Josh Lehman <phaedrus@Mac.hsd1.ca.comcast.net>
This commit is contained in:
co-authored by
Josh Lehman
parent
4935e24c7a
commit
1ee0d51e92
@@ -651,6 +651,109 @@ describe("runCodexAppServerAttempt", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("passes MCP server config through to Codex thread/start", 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: createThreadLifecycleAppServerOptions(),
|
||||
config: {
|
||||
mcp_servers: {
|
||||
search: {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
mcpServersFingerprint: "mcp-v1",
|
||||
mcpServersFingerprintEvaluated: true,
|
||||
});
|
||||
|
||||
const startRequest = request.mock.calls.find(([method]) => method === "thread/start");
|
||||
expect((startRequest?.[1] as { config?: unknown } | undefined)?.config).toMatchObject({
|
||||
mcp_servers: {
|
||||
search: {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
},
|
||||
},
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": true,
|
||||
});
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(binding?.mcpServersFingerprint).toBe("mcp-v1");
|
||||
});
|
||||
|
||||
it("starts a new Codex thread when the MCP server fingerprint changes", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "old-thread",
|
||||
cwd: workspaceDir,
|
||||
dynamicToolsFingerprint: JSON.stringify([]),
|
||||
mcpServersFingerprint: "mcp-v1",
|
||||
});
|
||||
const request = vi.fn(async (method: string, _params: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("new-thread");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
mcpServersFingerprint: "mcp-v2",
|
||||
mcpServersFingerprintEvaluated: true,
|
||||
});
|
||||
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(binding.threadId).toBe("new-thread");
|
||||
expect(binding.mcpServersFingerprint).toBe("mcp-v2");
|
||||
});
|
||||
|
||||
it("starts a no-MCP Codex thread when MCP config is evaluated empty", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "old-thread",
|
||||
cwd: workspaceDir,
|
||||
dynamicToolsFingerprint: JSON.stringify([]),
|
||||
mcpServersFingerprint: "mcp-v1",
|
||||
});
|
||||
const request = vi.fn(async (method: string, _params: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("new-thread");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
mcpServersFingerprintEvaluated: true,
|
||||
});
|
||||
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(binding.threadId).toBe("new-thread");
|
||||
expect(binding.mcpServersFingerprint).toBeUndefined();
|
||||
expect((await readCodexAppServerBinding(sessionFile))?.mcpServersFingerprint).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not expose OpenClaw Tool Search controls through Codex dynamic tools", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
formatErrorMessage,
|
||||
isActiveHarnessContextEngine,
|
||||
isSubagentSessionKey,
|
||||
loadCodexBundleMcpThreadConfig,
|
||||
normalizeAgentRuntimeTools,
|
||||
resolveAttemptSpawnWorkspaceDir,
|
||||
resolveAgentHarnessBeforePromptBuildResult,
|
||||
@@ -87,6 +88,7 @@ import { buildCodexPluginAppCacheKey } from "./plugin-app-cache-key.js";
|
||||
import {
|
||||
buildCodexPluginThreadConfig,
|
||||
buildCodexPluginThreadConfigInputFingerprint,
|
||||
mergeCodexThreadConfigs,
|
||||
shouldBuildCodexPluginThreadConfig,
|
||||
} from "./plugin-thread-config.js";
|
||||
import {
|
||||
@@ -517,6 +519,16 @@ export async function runCodexAppServerAttempt(
|
||||
: resolveCodexAppServerEnvApiKeyCacheKey({
|
||||
startOptions: appServer.start,
|
||||
});
|
||||
const bundleMcpThreadConfig = await loadCodexBundleMcpThreadConfig({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
cfg: params.config,
|
||||
toolsEnabled: supportsModelTools(params.model),
|
||||
disableTools: params.disableTools,
|
||||
toolsAllow: params.toolsAllow,
|
||||
});
|
||||
for (const diagnostic of bundleMcpThreadConfig.diagnostics) {
|
||||
embeddedAgentLog.warn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`);
|
||||
}
|
||||
const activeContextEngine = isActiveHarnessContextEngine(params.contextEngine)
|
||||
? params.contextEngine
|
||||
: undefined;
|
||||
@@ -713,7 +725,10 @@ export async function runCodexAppServerAttempt(
|
||||
: options.nativeHookRelay?.enabled === false
|
||||
? buildCodexNativeHookRelayDisabledConfig()
|
||||
: undefined;
|
||||
const threadConfig = nativeHookRelayConfig;
|
||||
const threadConfig = mergeCodexThreadConfigs(
|
||||
nativeHookRelayConfig,
|
||||
bundleMcpThreadConfig?.configPatch as JsonObject | undefined,
|
||||
);
|
||||
const pluginThreadConfigEnabled = shouldBuildCodexPluginThreadConfig(pluginConfig);
|
||||
const pluginAppCacheKey = buildCodexPluginAppCacheKey({
|
||||
appServer,
|
||||
@@ -772,6 +787,8 @@ export async function runCodexAppServerAttempt(
|
||||
appServer: pluginAppServer,
|
||||
developerInstructions: promptBuild.developerInstructions,
|
||||
config: threadConfig,
|
||||
mcpServersFingerprint: bundleMcpThreadConfig.fingerprint,
|
||||
mcpServersFingerprintEvaluated: bundleMcpThreadConfig.evaluated,
|
||||
pluginThreadConfig: pluginThreadConfigEnabled
|
||||
? {
|
||||
enabled: true,
|
||||
|
||||
@@ -41,6 +41,7 @@ export type CodexAppServerThreadBinding = {
|
||||
serviceTier?: CodexServiceTier;
|
||||
dynamicToolsFingerprint?: string;
|
||||
userMcpServersFingerprint?: string;
|
||||
mcpServersFingerprint?: string;
|
||||
pluginAppsFingerprint?: string;
|
||||
pluginAppsInputFingerprint?: string;
|
||||
pluginAppPolicyContext?: PluginAppPolicyContext;
|
||||
@@ -104,6 +105,8 @@ export async function readCodexAppServerBinding(
|
||||
typeof parsed.userMcpServersFingerprint === "string"
|
||||
? parsed.userMcpServersFingerprint
|
||||
: undefined,
|
||||
mcpServersFingerprint:
|
||||
typeof parsed.mcpServersFingerprint === "string" ? parsed.mcpServersFingerprint : undefined,
|
||||
pluginAppsFingerprint:
|
||||
typeof parsed.pluginAppsFingerprint === "string" ? parsed.pluginAppsFingerprint : undefined,
|
||||
pluginAppsInputFingerprint:
|
||||
@@ -149,6 +152,7 @@ export async function writeCodexAppServerBinding(
|
||||
serviceTier: binding.serviceTier,
|
||||
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint: binding.userMcpServersFingerprint,
|
||||
mcpServersFingerprint: binding.mcpServersFingerprint,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
|
||||
pluginAppPolicyContext: binding.pluginAppPolicyContext,
|
||||
|
||||
@@ -73,6 +73,8 @@ export async function startOrResumeThread(params: {
|
||||
appServer: CodexAppServerRuntimeOptions;
|
||||
developerInstructions?: string;
|
||||
config?: JsonObject;
|
||||
mcpServersFingerprint?: string;
|
||||
mcpServersFingerprintEvaluated?: boolean;
|
||||
pluginThreadConfig?: CodexPluginThreadConfigProvider;
|
||||
}): Promise<CodexAppServerThreadLifecycleBinding> {
|
||||
const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools);
|
||||
@@ -112,6 +114,17 @@ export async function startOrResumeThread(params: {
|
||||
await clearCodexAppServerBinding(params.params.sessionFile);
|
||||
binding = undefined;
|
||||
}
|
||||
if (
|
||||
binding?.threadId &&
|
||||
params.mcpServersFingerprintEvaluated === true &&
|
||||
binding.mcpServersFingerprint !== params.mcpServersFingerprint
|
||||
) {
|
||||
embeddedAgentLog.debug("codex app-server 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,
|
||||
@@ -146,6 +159,17 @@ export async function startOrResumeThread(params: {
|
||||
binding = undefined;
|
||||
}
|
||||
}
|
||||
if (
|
||||
binding?.threadId &&
|
||||
params.mcpServersFingerprintEvaluated === true &&
|
||||
binding.mcpServersFingerprint !== params.mcpServersFingerprint
|
||||
) {
|
||||
embeddedAgentLog.debug("codex app-server MCP config changed; starting a new thread", {
|
||||
threadId: binding.threadId,
|
||||
});
|
||||
await clearCodexAppServerBinding(params.params.sessionFile);
|
||||
binding = undefined;
|
||||
}
|
||||
if (binding?.threadId) {
|
||||
// `/codex resume <thread>` writes a binding before the next turn can know
|
||||
// the dynamic tool catalog, so only invalidate fingerprints we actually have.
|
||||
@@ -179,6 +203,7 @@ export async function startOrResumeThread(params: {
|
||||
} else {
|
||||
try {
|
||||
const authProfileId = params.params.authProfileId ?? binding.authProfileId;
|
||||
const resumeConfig = mergeCodexThreadConfigs(params.config, userMcpServersConfigPatch);
|
||||
const response = assertCodexThreadResumeResponse(
|
||||
await params.client.request(
|
||||
"thread/resume",
|
||||
@@ -187,7 +212,7 @@ export async function startOrResumeThread(params: {
|
||||
authProfileId,
|
||||
appServer: params.appServer,
|
||||
developerInstructions: params.developerInstructions,
|
||||
config: params.config,
|
||||
config: resumeConfig,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -199,6 +224,10 @@ export async function startOrResumeThread(params: {
|
||||
agentDir: params.params.agentDir,
|
||||
config: params.params.config,
|
||||
});
|
||||
const nextMcpServersFingerprint =
|
||||
params.mcpServersFingerprintEvaluated === true
|
||||
? params.mcpServersFingerprint
|
||||
: binding.mcpServersFingerprint;
|
||||
await writeCodexAppServerBinding(
|
||||
params.params.sessionFile,
|
||||
{
|
||||
@@ -209,6 +238,7 @@ export async function startOrResumeThread(params: {
|
||||
modelProvider: response.modelProvider ?? fallbackModelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
|
||||
pluginAppPolicyContext: binding.pluginAppPolicyContext,
|
||||
@@ -230,6 +260,7 @@ export async function startOrResumeThread(params: {
|
||||
modelProvider: response.modelProvider ?? fallbackModelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
|
||||
pluginAppPolicyContext: binding.pluginAppPolicyContext,
|
||||
@@ -276,6 +307,8 @@ export async function startOrResumeThread(params: {
|
||||
config: params.params.config,
|
||||
});
|
||||
const createdAt = new Date().toISOString();
|
||||
const nextMcpServersFingerprint =
|
||||
params.mcpServersFingerprintEvaluated === true ? params.mcpServersFingerprint : undefined;
|
||||
if (!preserveExistingBinding) {
|
||||
await writeCodexAppServerBinding(
|
||||
params.params.sessionFile,
|
||||
@@ -287,6 +320,7 @@ export async function startOrResumeThread(params: {
|
||||
modelProvider: response.modelProvider ?? modelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
|
||||
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
|
||||
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
|
||||
@@ -310,6 +344,7 @@ export async function startOrResumeThread(params: {
|
||||
modelProvider: response.modelProvider ?? modelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
|
||||
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
|
||||
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
|
||||
|
||||
@@ -205,7 +205,7 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
|
||||
});
|
||||
});
|
||||
|
||||
it("resumes a thread with the matching user MCP fingerprint without resending ignored MCP config", async () => {
|
||||
it("resends user MCP config when resuming a thread with the matching fingerprint", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const config = {
|
||||
@@ -252,6 +252,8 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
|
||||
config?: { mcp_servers?: Record<string, unknown> };
|
||||
};
|
||||
expect(resumeCall).toBeDefined();
|
||||
expect(resumeParams?.config?.mcp_servers).toBeUndefined();
|
||||
expect(resumeParams?.config?.mcp_servers).toMatchObject({
|
||||
notes: { command: "node", args: ["/opt/notes-mcp/dist/index.js"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
decodeHeaderEnvPlaceholder,
|
||||
normalizeStringRecord,
|
||||
} from "./bundle-mcp-adapter-shared.js";
|
||||
import { buildCodexMcpServersConfig } from "../codex-mcp-config.js";
|
||||
import { serializeTomlInlineValue } from "./toml-inline.js";
|
||||
|
||||
// Mutable JSON shape structurally compatible with the bundled Codex
|
||||
@@ -70,14 +71,7 @@ export function injectCodexMcpConfigArgs(
|
||||
args: string[] | undefined,
|
||||
config: BundleMcpConfig,
|
||||
): string[] {
|
||||
const overrides = serializeTomlInlineValue(
|
||||
Object.fromEntries(
|
||||
Object.entries(config.mcpServers).map(([name, server]) => [
|
||||
name,
|
||||
normalizeCodexServerConfig(name, server),
|
||||
]),
|
||||
),
|
||||
);
|
||||
const overrides = serializeTomlInlineValue(buildCodexMcpServersConfig(config));
|
||||
return [...(args ?? []), "-c", `mcp_servers=${overrides}`];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildCodexMcpServersConfig, loadCodexBundleMcpThreadConfig } from "./codex-mcp-config.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
bundleMcp: {
|
||||
config: {
|
||||
mcpServers: {},
|
||||
},
|
||||
diagnostics: [],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/bundle-mcp.js", () => ({
|
||||
loadEnabledBundleMcpConfig: () => mocks.bundleMcp,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.bundleMcp = {
|
||||
config: {
|
||||
mcpServers: {},
|
||||
},
|
||||
diagnostics: [],
|
||||
};
|
||||
});
|
||||
|
||||
describe("buildCodexMcpServersConfig", () => {
|
||||
it("normalizes OpenClaw MCP servers into Codex app-server mcp_servers shape", () => {
|
||||
expect(
|
||||
buildCodexMcpServersConfig({
|
||||
mcpServers: {
|
||||
openclaw: {
|
||||
type: "http",
|
||||
url: "http://127.0.0.1:23119/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
|
||||
"x-session-key": "${OPENCLAW_MCP_SESSION_KEY}",
|
||||
"x-static": "static-value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
openclaw: {
|
||||
url: "http://127.0.0.1:23119/mcp",
|
||||
default_tools_approval_mode: "approve",
|
||||
bearer_token_env_var: "OPENCLAW_MCP_TOKEN",
|
||||
http_headers: {
|
||||
"x-static": "static-value",
|
||||
},
|
||||
env_http_headers: {
|
||||
"x-session-key": "OPENCLAW_MCP_SESSION_KEY",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadCodexBundleMcpThreadConfig", () => {
|
||||
it("loads enabled bundled MCP servers as a Codex thread config patch", () => {
|
||||
mocks.bundleMcp = {
|
||||
config: {
|
||||
mcpServers: {
|
||||
search: {
|
||||
type: "http",
|
||||
url: "https://mcp.example.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const loaded = loadCodexBundleMcpThreadConfig({
|
||||
workspaceDir: "/workspace",
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"bundle-probe": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(loaded.configPatch).toEqual({
|
||||
mcp_servers: {
|
||||
search: {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(loaded.fingerprint).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it("leaves user mcp.servers to the Codex user MCP projection path", () => {
|
||||
const loaded = loadCodexBundleMcpThreadConfig({
|
||||
workspaceDir: "/workspace",
|
||||
cfg: {
|
||||
mcp: {
|
||||
servers: {
|
||||
search: {
|
||||
transport: "streamable-http",
|
||||
url: "https://mcp.example.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
toolsEnabled: true,
|
||||
});
|
||||
|
||||
expect(loaded.configPatch).toBeUndefined();
|
||||
expect(loaded.fingerprint).toBeUndefined();
|
||||
expect(loaded.evaluated).toBe(true);
|
||||
});
|
||||
|
||||
it("returns an evaluated empty MCP config when Pi would not create a bundle MCP runtime", () => {
|
||||
const cfg = {
|
||||
mcp: {
|
||||
servers: {
|
||||
search: {
|
||||
transport: "streamable-http",
|
||||
url: "https://mcp.example.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
for (const params of [
|
||||
{ toolsEnabled: false },
|
||||
{ toolsEnabled: true, disableTools: true },
|
||||
{ toolsEnabled: true, toolsAllow: [] },
|
||||
{ toolsEnabled: true, toolsAllow: ["memory_search"] },
|
||||
]) {
|
||||
const loaded = loadCodexBundleMcpThreadConfig({
|
||||
workspaceDir: "/workspace",
|
||||
cfg,
|
||||
...params,
|
||||
});
|
||||
|
||||
expect(loaded.configPatch).toBeUndefined();
|
||||
expect(loaded.fingerprint).toBeUndefined();
|
||||
expect(loaded.evaluated).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("omits the config patch when no MCP servers are configured", () => {
|
||||
const loaded = loadCodexBundleMcpThreadConfig({
|
||||
workspaceDir: "/workspace",
|
||||
cfg: {},
|
||||
toolsEnabled: true,
|
||||
});
|
||||
|
||||
expect(loaded.configPatch).toBeUndefined();
|
||||
expect(loaded.fingerprint).toBeUndefined();
|
||||
expect(loaded.evaluated).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
loadEnabledBundleMcpConfig,
|
||||
type BundleMcpConfig,
|
||||
type BundleMcpServerConfig,
|
||||
} from "../plugins/bundle-mcp.js";
|
||||
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
|
||||
import {
|
||||
applyCommonServerConfig,
|
||||
decodeHeaderEnvPlaceholder,
|
||||
normalizeStringRecord,
|
||||
} from "./cli-runner/bundle-mcp-adapter-shared.js";
|
||||
import type {
|
||||
CodexBundleMcpThreadConfig,
|
||||
CodexMcpServersConfig,
|
||||
LoadCodexBundleMcpThreadConfigParams,
|
||||
} from "./codex-mcp-config.types.js";
|
||||
import { shouldCreateBundleMcpRuntimeForAttempt } from "./pi-embedded-runner/run/attempt-tool-construction-plan.js";
|
||||
|
||||
export type {
|
||||
CodexBundleMcpThreadConfig,
|
||||
CodexMcpServersConfig,
|
||||
LoadCodexBundleMcpThreadConfigParams,
|
||||
} from "./codex-mcp-config.types.js";
|
||||
|
||||
function isOpenClawLoopbackMcpServer(name: string, server: BundleMcpServerConfig): boolean {
|
||||
return (
|
||||
name === "openclaw" &&
|
||||
typeof server.url === "string" &&
|
||||
/^https?:\/\/(?:127\.0\.0\.1|localhost):\d+\/mcp(?:[?#].*)?$/.test(server.url)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeCodexMcpServerConfig(
|
||||
name: string,
|
||||
server: BundleMcpServerConfig,
|
||||
): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = {};
|
||||
applyCommonServerConfig(next, server);
|
||||
if (isOpenClawLoopbackMcpServer(name, server)) {
|
||||
next.default_tools_approval_mode = "approve";
|
||||
}
|
||||
const httpHeaders = normalizeStringRecord(server.headers);
|
||||
if (httpHeaders) {
|
||||
const staticHeaders: Record<string, string> = {};
|
||||
const envHeaders: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(httpHeaders)) {
|
||||
const decoded = decodeHeaderEnvPlaceholder(value);
|
||||
if (!decoded) {
|
||||
staticHeaders[name] = value;
|
||||
continue;
|
||||
}
|
||||
if (decoded.bearer && normalizeOptionalLowercaseString(name) === "authorization") {
|
||||
next.bearer_token_env_var = decoded.envVar;
|
||||
continue;
|
||||
}
|
||||
envHeaders[name] = decoded.envVar;
|
||||
}
|
||||
if (Object.keys(staticHeaders).length > 0) {
|
||||
next.http_headers = staticHeaders;
|
||||
}
|
||||
if (Object.keys(envHeaders).length > 0) {
|
||||
next.env_http_headers = envHeaders;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function buildCodexMcpServersConfig(config: BundleMcpConfig): CodexMcpServersConfig {
|
||||
return Object.fromEntries(
|
||||
Object.entries(config.mcpServers).map(([name, server]) => [
|
||||
name,
|
||||
normalizeCodexMcpServerConfig(name, server),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function stableJsonValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(stableJsonValue);
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, child]) => [key, stableJsonValue(child)]),
|
||||
);
|
||||
}
|
||||
|
||||
function fingerprintCodexMcpServersConfig(config: CodexMcpServersConfig): string {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(JSON.stringify(stableJsonValue(config)))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export function loadCodexBundleMcpThreadConfig(
|
||||
params: LoadCodexBundleMcpThreadConfigParams,
|
||||
): CodexBundleMcpThreadConfig {
|
||||
const shouldCreateRuntime = shouldCreateBundleMcpRuntimeForAttempt({
|
||||
toolsEnabled: params.toolsEnabled ?? true,
|
||||
disableTools: params.disableTools,
|
||||
toolsAllow: params.toolsAllow,
|
||||
});
|
||||
if (!shouldCreateRuntime) {
|
||||
return {
|
||||
diagnostics: [],
|
||||
evaluated: true,
|
||||
};
|
||||
}
|
||||
const bundleMcp = loadEnabledBundleMcpConfig({
|
||||
workspaceDir: params.workspaceDir,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
const mcpServers = buildCodexMcpServersConfig(bundleMcp.config);
|
||||
if (Object.keys(mcpServers).length === 0) {
|
||||
return {
|
||||
diagnostics: bundleMcp.diagnostics,
|
||||
evaluated: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
configPatch: {
|
||||
mcp_servers: mcpServers,
|
||||
},
|
||||
diagnostics: bundleMcp.diagnostics,
|
||||
evaluated: true,
|
||||
fingerprint: fingerprintCodexMcpServersConfig(mcpServers),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { BundleMcpDiagnostic } from "../plugins/bundle-mcp.js";
|
||||
|
||||
export type CodexMcpServersConfig = Record<string, Record<string, unknown>>;
|
||||
|
||||
export type CodexBundleMcpThreadConfig = {
|
||||
configPatch?: {
|
||||
mcp_servers: CodexMcpServersConfig;
|
||||
};
|
||||
diagnostics: BundleMcpDiagnostic[];
|
||||
evaluated: boolean;
|
||||
fingerprint?: string;
|
||||
};
|
||||
|
||||
export type LoadCodexBundleMcpThreadConfigParams = {
|
||||
workspaceDir: string;
|
||||
cfg?: OpenClawConfig;
|
||||
toolsEnabled?: boolean;
|
||||
disableTools?: boolean;
|
||||
toolsAllow?: string[];
|
||||
};
|
||||
@@ -2,6 +2,10 @@
|
||||
// Keep heavyweight tool construction out of this module so harness imports can
|
||||
// register quickly inside gateway startup and Docker e2e runs.
|
||||
|
||||
import type {
|
||||
CodexBundleMcpThreadConfig,
|
||||
LoadCodexBundleMcpThreadConfigParams,
|
||||
} from "../agents/codex-mcp-config.types.js";
|
||||
import type { EmbeddedRunAttemptResult } from "../agents/pi-embedded-runner/run/types.js";
|
||||
import {
|
||||
abortEmbeddedPiRun,
|
||||
@@ -134,7 +138,18 @@ export {
|
||||
logAgentRuntimeToolDiagnostics,
|
||||
normalizeAgentRuntimeTools,
|
||||
} from "../agents/runtime-plan/tools.js";
|
||||
export type {
|
||||
CodexBundleMcpThreadConfig,
|
||||
LoadCodexBundleMcpThreadConfigParams,
|
||||
} from "../agents/codex-mcp-config.types.js";
|
||||
export { normalizeProviderToolSchemas } from "../agents/pi-embedded-runner/tool-schema-runtime.js";
|
||||
|
||||
export async function loadCodexBundleMcpThreadConfig(
|
||||
params: LoadCodexBundleMcpThreadConfigParams,
|
||||
): Promise<CodexBundleMcpThreadConfig> {
|
||||
const { loadCodexBundleMcpThreadConfig: load } = await import("../agents/codex-mcp-config.js");
|
||||
return load(params);
|
||||
}
|
||||
export { resolveSandboxContext } from "../agents/sandbox.js";
|
||||
export { resolveBootstrapContextForRun } from "../agents/bootstrap-files.js";
|
||||
export type { EmbeddedContextFile } from "../agents/pi-embedded-helpers/types.js";
|
||||
|
||||
Reference in New Issue
Block a user