mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
fix(codex): approve bundled MCP loopback tools
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user