From 10202f927921ee3d813ba596d7ebce93a3efe375 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Apr 2026 21:01:29 +0100 Subject: [PATCH] fix(codex): approve bundled MCP loopback tools --- .../openclaw-live-and-e2e-checks-reusable.yml | 2 +- docs/gateway/cli-backends.md | 4 +- extensions/openai/cli-backend.ts | 6 +- scripts/test-live-cli-backend-docker.sh | 17 +++++ src/agents/cli-backends.test.ts | 12 +++- src/agents/cli-runner/bundle-mcp.test.ts | 4 +- src/agents/cli-runner/bundle-mcp.ts | 18 ++++- .../gateway-cli-backend.live-probe-helpers.ts | 66 ++++++++++++++++++- src/gateway/gateway-cli-backend.live.test.ts | 65 ++++++++++++++++++ 9 files changed, 184 insertions(+), 10 deletions(-) diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index 640a6b1a70e..7c2e7e2b880 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -861,7 +861,7 @@ jobs: fi case "${{ matrix.suite_id }}" in live-cli-backend-docker) - echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.4" >> "$GITHUB_ENV" + echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.5" >> "$GITHUB_ENV" # The CLI backend Docker lane should exercise the same staged # Codex auth path Peter uses locally so MCP cron creation and # multimodal probes stay covered in CI. Replace the staged diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index f59f84bfb68..9deb7127a8d 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -325,7 +325,9 @@ opt into a generated MCP config overlay with `bundleMcp: true`. Current bundled behavior: - `claude-cli`: generated strict MCP config file -- `codex-cli`: inline config overrides for `mcp_servers` +- `codex-cli`: inline config overrides for `mcp_servers`; the generated + OpenClaw loopback server is marked with Codex's per-server tool approval mode + so MCP calls cannot stall on local approval prompts - `google-gemini-cli`: generated Gemini system settings file When bundle MCP is enabled, OpenClaw: diff --git a/extensions/openai/cli-backend.ts b/extensions/openai/cli-backend.ts index 7c29b8f8a1c..591d30db0e4 100644 --- a/extensions/openai/cli-backend.ts +++ b/extensions/openai/cli-backend.ts @@ -16,7 +16,7 @@ export function buildOpenAICodexCliBackend(): CliBackendPlugin { defaultImageProbe: true, defaultMcpProbe: true, docker: { - npmPackage: "@openai/codex", + npmPackage: "@openai/codex@0.124.0", binaryName: "codex", }, }, @@ -34,6 +34,8 @@ export function buildOpenAICodexCliBackend(): CliBackendPlugin { "never", "--sandbox", "workspace-write", + "-c", + 'service_tier="fast"', "--skip-git-repo-check", ], resumeArgs: [ @@ -42,6 +44,8 @@ export function buildOpenAICodexCliBackend(): CliBackendPlugin { "{sessionId}", "-c", 'sandbox_mode="workspace-write"', + "-c", + 'service_tier="fast"', "--skip-git-repo-check", ], output: "jsonl", diff --git a/scripts/test-live-cli-backend-docker.sh b/scripts/test-live-cli-backend-docker.sh index d6fed82463b..9419b8e0289 100644 --- a/scripts/test-live-cli-backend-docker.sh +++ b/scripts/test-live-cli-backend-docker.sh @@ -259,14 +259,30 @@ provider="${OPENCLAW_DOCKER_CLI_BACKEND_PROVIDER:-claude-cli}" default_command="${OPENCLAW_DOCKER_CLI_BACKEND_COMMAND_DEFAULT:-}" docker_package="${OPENCLAW_DOCKER_CLI_BACKEND_NPM_PACKAGE:-}" binary_name="${OPENCLAW_DOCKER_CLI_BACKEND_BINARY_NAME:-}" +if [ "$provider" = "codex-cli" ] && [ "${OPENCLAW_LIVE_CLI_BACKEND_AUTH:-auto}" != "api-key" ]; then + unset OPENAI_API_KEY + unset OPENAI_BASE_URL +fi if [ -z "$binary_name" ] && [ -n "$default_command" ]; then binary_name="$(basename "$default_command")" fi if [ -z "${OPENCLAW_LIVE_CLI_BACKEND_COMMAND:-}" ] && [ -n "$binary_name" ]; then export OPENCLAW_LIVE_CLI_BACKEND_COMMAND="$NPM_CONFIG_PREFIX/bin/$binary_name" fi +package_has_explicit_version() { + case "$1" in + @*/*@*) return 0 ;; + *@*) + [[ "$1" != @* ]] + return + ;; + *) return 1 ;; + esac +} if [ -n "${OPENCLAW_LIVE_CLI_BACKEND_COMMAND:-}" ] && [ ! -x "${OPENCLAW_LIVE_CLI_BACKEND_COMMAND}" ] && [ -n "$docker_package" ]; then npm install -g "$docker_package" +elif [ -n "$docker_package" ] && package_has_explicit_version "$docker_package"; then + npm install -g "$docker_package" fi if [ "$provider" = "codex-cli" ] && [ "${OPENCLAW_LIVE_CLI_BACKEND_AUTH:-auto}" = "api-key" ]; then codex_login_command="${OPENCLAW_LIVE_CLI_BACKEND_COMMAND:-$NPM_CONFIG_PREFIX/bin/codex}" @@ -451,6 +467,7 @@ DOCKER_RUN_ARGS=(docker run --rm -t \ -e OPENCLAW_LIVE_CLI_BACKEND_MODEL_SWITCH_PROBE="${OPENCLAW_LIVE_CLI_BACKEND_MODEL_SWITCH_PROBE:-}" \ -e OPENCLAW_LIVE_CLI_BACKEND_IMAGE_PROBE="${OPENCLAW_LIVE_CLI_BACKEND_IMAGE_PROBE:-}" \ -e OPENCLAW_LIVE_CLI_BACKEND_MCP_PROBE="${OPENCLAW_LIVE_CLI_BACKEND_MCP_PROBE:-}" \ + -e OPENCLAW_LIVE_CLI_BACKEND_MCP_SCHEMA_PROBE="${OPENCLAW_LIVE_CLI_BACKEND_MCP_SCHEMA_PROBE:-}" \ -e OPENCLAW_LIVE_CLI_BACKEND_IMAGE_ARG="${OPENCLAW_LIVE_CLI_BACKEND_IMAGE_ARG:-}" \ -e OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE="${OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE:-}") openclaw_live_append_array DOCKER_RUN_ARGS DOCKER_HOME_MOUNT diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index 044541271d6..42008a5b65f 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -57,7 +57,7 @@ function createBackendEntry(params: { params.id === "claude-cli" ? "@anthropic-ai/claude-code" : params.id === "codex-cli" - ? "@openai/codex" + ? "@openai/codex@0.124.0" : params.id === "google-gemini-cli" ? "@google/gemini-cli" : undefined, @@ -242,6 +242,8 @@ beforeEach(() => { "never", "--sandbox", "workspace-write", + "-c", + 'service_tier="fast"', "--skip-git-repo-check", ], resumeArgs: [ @@ -250,6 +252,8 @@ beforeEach(() => { "{sessionId}", "-c", 'sandbox_mode="workspace-write"', + "-c", + 'service_tier="fast"', "--skip-git-repo-check", ], systemPromptFileConfigArg: "-c", @@ -328,6 +332,8 @@ describe("resolveCliBackendConfig reliability merge", () => { "never", "--sandbox", "workspace-write", + "-c", + 'service_tier="fast"', "--skip-git-repo-check", ]); expect(resolved?.config.resumeArgs).toEqual([ @@ -336,6 +342,8 @@ describe("resolveCliBackendConfig reliability merge", () => { "{sessionId}", "-c", 'sandbox_mode="workspace-write"', + "-c", + 'service_tier="fast"', "--skip-git-repo-check", ]); }); @@ -388,7 +396,7 @@ describe("resolveCliBackendLiveTest", () => { defaultModelRef: "codex-cli/gpt-5.5", defaultImageProbe: true, defaultMcpProbe: true, - dockerNpmPackage: "@openai/codex", + dockerNpmPackage: "@openai/codex@0.124.0", dockerBinaryName: "codex", }); }); diff --git a/src/agents/cli-runner/bundle-mcp.test.ts b/src/agents/cli-runner/bundle-mcp.test.ts index 8da07818527..e4ed16b9437 100644 --- a/src/agents/cli-runner/bundle-mcp.test.ts +++ b/src/agents/cli-runner/bundle-mcp.test.ts @@ -328,14 +328,14 @@ describe("prepareCliBundleMcpConfig", () => { "exec", "--json", "-c", - 'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY" } } }', + 'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", default_tools_approval_mode = "approve", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY" } } }', ]); expect(prepared.backend.resumeArgs).toEqual([ "exec", "resume", "{sessionId}", "-c", - 'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY" } } }', + 'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", default_tools_approval_mode = "approve", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY" } } }', ]); expect(prepared.cleanup).toBeUndefined(); }); diff --git a/src/agents/cli-runner/bundle-mcp.ts b/src/agents/cli-runner/bundle-mcp.ts index 7be248b4bdc..4d3875ee87c 100644 --- a/src/agents/cli-runner/bundle-mcp.ts +++ b/src/agents/cli-runner/bundle-mcp.ts @@ -141,9 +141,23 @@ function applyCommonServerConfig( } } -function normalizeCodexServerConfig(server: BundleMcpServerConfig): Record { +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) + ); +} + +function normalizeCodexServerConfig( + name: string, + server: BundleMcpServerConfig, +): Record { const next: Record = {}; applyCommonServerConfig(next, server); + if (isOpenClawLoopbackMcpServer(name, server)) { + next.default_tools_approval_mode = "approve"; + } const httpHeaders = normalizeStringRecord(server.headers); if (httpHeaders) { const staticHeaders: Record = {}; @@ -211,7 +225,7 @@ function injectCodexMcpConfigArgs(args: string[] | undefined, config: BundleMcpC Object.fromEntries( Object.entries(config.mcpServers).map(([name, server]) => [ name, - normalizeCodexServerConfig(server), + normalizeCodexServerConfig(name, server), ]), ), ); diff --git a/src/gateway/gateway-cli-backend.live-probe-helpers.ts b/src/gateway/gateway-cli-backend.live-probe-helpers.ts index e7ae8cdff88..fa9ec3b31e5 100644 --- a/src/gateway/gateway-cli-backend.live-probe-helpers.ts +++ b/src/gateway/gateway-cli-backend.live-probe-helpers.ts @@ -79,6 +79,65 @@ type LoopbackJsonRpcResponse = { error?: { message?: string }; }; +type LoopbackToolListEntry = { + name?: string; + inputSchema?: unknown; +}; + +function asLoopbackSchemaRecord(schema: unknown): Record | null { + return schema && typeof schema === "object" && !Array.isArray(schema) + ? (schema as Record) + : null; +} + +function assertLoopbackObjectSchemasHaveProperties(params: { + tools: LoopbackToolListEntry[]; + expectedSchemaProbeToolName?: string; +}): void { + const missingProperties = params.tools + .filter((tool) => { + const schema = asLoopbackSchemaRecord(tool.inputSchema); + if (!schema || schema.type !== "object") { + return false; + } + const properties = schema.properties; + return ( + !Object.hasOwn(schema, "properties") || + !properties || + typeof properties !== "object" || + Array.isArray(properties) + ); + }) + .map((tool) => tool.name) + .filter((name): name is string => typeof name === "string" && name.length > 0); + + if (missingProperties.length > 0) { + throw new Error( + `mcp loopback tools/list exposed object schemas without properties: ${missingProperties.join( + ", ", + )}`, + ); + } + + const expectedToolName = params.expectedSchemaProbeToolName; + if (!expectedToolName) { + return; + } + const tool = params.tools.find((candidate) => candidate.name === expectedToolName); + if (!tool) { + throw new Error(`mcp loopback tools/list did not expose ${expectedToolName}`); + } + const schema = asLoopbackSchemaRecord(tool.inputSchema); + if ( + !schema || + schema.type !== "object" || + !Object.hasOwn(schema, "properties") || + !asLoopbackSchemaRecord(schema.properties) + ) { + throw new Error(`mcp loopback schema probe ${expectedToolName} was not normalized`); + } +} + async function callLoopbackJsonRpc(params: { sessionKey: string; senderIsOwner: boolean; @@ -128,6 +187,7 @@ export async function verifyCliCronMcpLoopbackPreflight(params: { senderIsOwner: boolean; messageProvider?: string; accountId?: string; + expectedSchemaProbeToolName?: string; }): Promise { const cronProbe = createLiveCronProbeSpec(); logCliCronProbe("loopback-preflight:start", { @@ -163,8 +223,12 @@ export async function verifyCliCronMcpLoopbackPreflight(params: { body: { jsonrpc: "2.0", id: "tools-list", method: "tools/list" }, }); const tools = Array.isArray((toolsList.result as { tools?: unknown[] } | undefined)?.tools) - ? (((toolsList.result as { tools?: unknown[] }).tools ?? []) as Array<{ name?: string }>) + ? (((toolsList.result as { tools?: unknown[] }).tools ?? []) as LoopbackToolListEntry[]) : []; + assertLoopbackObjectSchemasHaveProperties({ + tools, + expectedSchemaProbeToolName: params.expectedSchemaProbeToolName, + }); const toolNames = tools .map((tool) => (typeof tool.name === "string" ? tool.name : "")) .filter(Boolean); diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index 2c0bc00464e..1b41cf7d8c7 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -42,8 +42,14 @@ const CLI_DEBUG = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_DEBUG); const CLI_CI_SAFE_CODEX_CONFIG = isTruthyEnvValue( process.env.OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG, ); +const CLI_MCP_SCHEMA_PROBE = isTruthyEnvValue( + process.env.OPENCLAW_LIVE_CLI_BACKEND_MCP_SCHEMA_PROBE, +); const describeLive = LIVE && CLI_LIVE ? describe : describe.skip; +const MCP_SCHEMA_PROBE_PLUGIN_ID = "mcp-schema-probe"; +const MCP_SCHEMA_PROBE_TOOL_NAME = "mcp_schema_probe_no_args"; + const DEFAULT_PROVIDER = "claude-cli"; const DEFAULT_MODEL = resolveCliBackendLiveTest(DEFAULT_PROVIDER)?.defaultModelRef ?? "claude-cli/claude-sonnet-4-6"; @@ -64,6 +70,44 @@ function logCliBackendLiveStep(step: string, details?: Record): console.error(`[gateway-cli-live] ${step}${suffix}`); } +async function createMcpSchemaProbePlugin(tempDir: string): Promise { + const pluginDir = path.join(tempDir, MCP_SCHEMA_PROBE_PLUGIN_ID); + await fs.mkdir(pluginDir, { recursive: true }); + const pluginFile = path.join(pluginDir, "index.cjs"); + await fs.writeFile( + path.join(pluginDir, "openclaw.plugin.json"), + `${JSON.stringify( + { + id: MCP_SCHEMA_PROBE_PLUGIN_ID, + name: "MCP Schema Probe", + description: "Live test plugin for no-argument MCP tool schemas", + configSchema: { type: "object", properties: {} }, + }, + null, + 2, + )}\n`, + ); + await fs.writeFile( + pluginFile, + `module.exports = { + id: "${MCP_SCHEMA_PROBE_PLUGIN_ID}", + name: "MCP Schema Probe", + register(api) { + api.registerTool({ + name: "${MCP_SCHEMA_PROBE_TOOL_NAME}", + description: "Live test no-argument tool for MCP schema normalization", + parameters: { type: "object" }, + async execute() { + return { content: [{ type: "text", text: "schema probe ok" }] }; + }, + }); + }, +}; +`, + ); + return pluginFile; +} + describeLive("gateway live (cli backend)", () => { it( "runs the agent pipeline against the local CLI backend", @@ -151,6 +195,9 @@ describeLive("gateway live (cli backend)", () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-live-cli-")); const stateDir = path.join(tempDir, "state"); await fs.mkdir(stateDir, { recursive: true }); + const schemaProbePluginPath = CLI_MCP_SCHEMA_PROBE + ? await createMcpSchemaProbePlugin(tempDir) + : undefined; process.env.OPENCLAW_STATE_DIR = stateDir; const bundleMcp = backendResolved?.bundleMcp === true; const bootstrapWorkspace = @@ -180,6 +227,21 @@ describeLive("gateway live (cli backend)", () => { const existingBackends = cfgWithCliBackends.agents?.defaults?.cliBackends ?? {}; const nextCfg = { ...cfg, + ...(schemaProbePluginPath + ? { + plugins: { + ...cfg.plugins, + load: { + ...cfg.plugins?.load, + paths: [...(cfg.plugins?.load?.paths ?? []), schemaProbePluginPath], + }, + entries: { + ...cfg.plugins?.entries, + [MCP_SCHEMA_PROBE_PLUGIN_ID]: { enabled: true }, + }, + }, + } + : {}), gateway: { mode: "local", ...cfg.gateway, @@ -387,6 +449,9 @@ describeLive("gateway live (cli backend)", () => { token, env: process.env, senderIsOwner: true, + expectedSchemaProbeToolName: schemaProbePluginPath + ? MCP_SCHEMA_PROBE_TOOL_NAME + : undefined, }); logCliBackendLiveStep("cron-mcp-loopback-preflight:done"); if (providerId === "codex-cli" && CLI_CI_SAFE_CODEX_CONFIG) {