fix(codex): approve bundled MCP loopback tools

This commit is contained in:
Peter Steinberger
2026-04-23 21:01:29 +01:00
parent 5314042990
commit 10202f9279
9 changed files with 184 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -141,9 +141,23 @@ function applyCommonServerConfig(
}
}
function normalizeCodexServerConfig(server: BundleMcpServerConfig): Record<string, unknown> {
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<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> = {};
@@ -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),
]),
),
);

View File

@@ -79,6 +79,65 @@ type LoopbackJsonRpcResponse = {
error?: { message?: string };
};
type LoopbackToolListEntry = {
name?: string;
inputSchema?: unknown;
};
function asLoopbackSchemaRecord(schema: unknown): Record<string, unknown> | null {
return schema && typeof schema === "object" && !Array.isArray(schema)
? (schema as Record<string, unknown>)
: 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<void> {
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);

View File

@@ -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<string, unknown>):
console.error(`[gateway-cli-live] ${step}${suffix}`);
}
async function createMcpSchemaProbePlugin(tempDir: string): Promise<string> {
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) {