diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index f90f1e5da4c..97ec18d00b6 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -360,7 +360,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - or `npm install -g @google/gemini-cli` - Enable: `openclaw plugins enable google` - Login: `openclaw models auth login --provider google-gemini-cli --set-default` - - Default model: `google-gemini-cli/gemini-3.1-pro-preview` + - Default model: `google-gemini-cli/gemini-3-flash-preview` - Note: you do **not** paste a client id or secret into `openclaw.json`. The CLI login flow stores tokens in auth profiles on the gateway host. - If requests fail after login, set `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` on the gateway host. diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 44e42e71241..b9c24930cb2 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -214,8 +214,10 @@ The bundled OpenAI plugin also registers a default for `codex-cli`: The bundled Google plugin also registers a default for `google-gemini-cli`: - `command: "gemini"` -- `args: ["--prompt", "--output-format", "json"]` -- `resumeArgs: ["--resume", "{sessionId}", "--prompt", "--output-format", "json"]` +- `args: ["--output-format", "json", "--prompt", "{prompt}"]` +- `resumeArgs: ["--resume", "{sessionId}", "--output-format", "json", "--prompt", "{prompt}"]` +- `imageArg: "@"` +- `imagePathScope: "workspace"` - `modelArg: "--model"` - `sessionMode: "existing"` - `sessionIdFields: ["session_id", "sessionId"]` @@ -251,8 +253,9 @@ opt into a generated MCP config overlay with `bundleMcp: true`. Current bundled behavior: -- `codex-cli`: no bundle MCP overlay -- `google-gemini-cli`: no bundle MCP overlay +- `claude-cli`: generated strict MCP config file +- `codex-cli`: inline config overrides for `mcp_servers` +- `google-gemini-cli`: generated Gemini system settings file When bundle MCP is enabled, OpenClaw: @@ -260,8 +263,8 @@ When bundle MCP is enabled, OpenClaw: - authenticates the bridge with a per-session token (`OPENCLAW_MCP_TOKEN`) - scopes tool access to the current session, account, and channel context - loads enabled bundle-MCP servers for the current workspace -- merges them with any existing backend `--mcp-config` -- rewrites the CLI args to pass `--strict-mcp-config --mcp-config ` +- merges them with any existing backend MCP config/settings shape +- rewrites the launch config using the backend-owned integration mode from the owning extension If no MCP servers are enabled, OpenClaw still injects a strict config when a backend opts into bundle MCP so background runs stay isolated. diff --git a/docs/help/faq.md b/docs/help/faq.md index 7113291a4aa..bccd4859b55 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -701,7 +701,7 @@ for usage/billing and raise limits as needed. - npm: `npm install -g @google/gemini-cli` 2. Enable the plugin: `openclaw plugins enable google` 3. Login: `openclaw models auth login --provider google-gemini-cli --set-default` - 4. Default model after login: `google-gemini-cli/gemini-3.1-pro-preview` + 4. Default model after login: `google-gemini-cli/gemini-3-flash-preview` 5. If requests fail, set `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` on the gateway host This stores OAuth tokens in auth profiles on the gateway host. Details: [Model providers](/concepts/model-providers). diff --git a/docs/providers/google.md b/docs/providers/google.md index 4d3c604557b..5befa75d50d 100644 --- a/docs/providers/google.md +++ b/docs/providers/google.md @@ -52,7 +52,7 @@ An alternative provider `google-gemini-cli` uses PKCE OAuth instead of an API key. This is an unofficial integration; some users report account restrictions. Use at your own risk. -- Default model: `google-gemini-cli/gemini-3.1-pro-preview` +- Default model: `google-gemini-cli/gemini-3-flash-preview` - Alias: `gemini-cli` - Install prerequisite: local Gemini CLI available as `gemini` - Homebrew: `brew install gemini-cli` diff --git a/docs/providers/models.md b/docs/providers/models.md index cffa65003f4..65fa9467e95 100644 --- a/docs/providers/models.md +++ b/docs/providers/models.md @@ -54,7 +54,7 @@ model as `provider/model`. - `anthropic-vertex` - implicit Anthropic on Google Vertex support when Vertex credentials are available; no separate onboarding auth choice - `copilot-proxy` - local VS Code Copilot Proxy bridge; use `openclaw onboard --auth-choice copilot-proxy` -- `google-gemini-cli` - unofficial Gemini CLI OAuth flow; requires a local `gemini` install (`brew install gemini-cli` or `npm install -g @google/gemini-cli`); default model `google-gemini-cli/gemini-3.1-pro-preview`; use `openclaw onboard --auth-choice google-gemini-cli` or `openclaw models auth login --provider google-gemini-cli --set-default` +- `google-gemini-cli` - unofficial Gemini CLI OAuth flow; requires a local `gemini` install (`brew install gemini-cli` or `npm install -g @google/gemini-cli`); default model `google-gemini-cli/gemini-3-flash-preview`; use `openclaw onboard --auth-choice google-gemini-cli` or `openclaw models auth login --provider google-gemini-cli --set-default` For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration, see [Model providers](/concepts/model-providers). diff --git a/extensions/anthropic/cli-backend.ts b/extensions/anthropic/cli-backend.ts index d2150bace11..6e66fccb66f 100644 --- a/extensions/anthropic/cli-backend.ts +++ b/extensions/anthropic/cli-backend.ts @@ -19,12 +19,14 @@ export function buildAnthropicCliBackend(): CliBackendPlugin { liveTest: { defaultModelRef: CLAUDE_CLI_DEFAULT_MODEL_REF, defaultImageProbe: true, + defaultMcpProbe: true, docker: { npmPackage: "@anthropic-ai/claude-code", binaryName: "claude", }, }, bundleMcp: true, + bundleMcpMode: "claude-config-file", config: { command: "claude", args: [ diff --git a/extensions/google/cli-backend.ts b/extensions/google/cli-backend.ts index b051cc06a0b..71d800311bf 100644 --- a/extensions/google/cli-backend.ts +++ b/extensions/google/cli-backend.ts @@ -9,7 +9,7 @@ const GEMINI_MODEL_ALIASES: Record = { flash: "gemini-3.1-flash-preview", "flash-lite": "gemini-3.1-flash-lite-preview", }; -const GEMINI_CLI_DEFAULT_MODEL_REF = "google-gemini-cli/gemini-3.1-pro-preview"; +const GEMINI_CLI_DEFAULT_MODEL_REF = "google-gemini-cli/gemini-3-flash-preview"; export function buildGoogleGeminiCliBackend(): CliBackendPlugin { return { @@ -17,17 +17,22 @@ export function buildGoogleGeminiCliBackend(): CliBackendPlugin { liveTest: { defaultModelRef: GEMINI_CLI_DEFAULT_MODEL_REF, defaultImageProbe: true, + defaultMcpProbe: true, docker: { npmPackage: "@google/gemini-cli", binaryName: "gemini", }, }, + bundleMcp: true, + bundleMcpMode: "gemini-system-settings", config: { command: "gemini", args: ["--output-format", "json", "--prompt", "{prompt}"], resumeArgs: ["--resume", "{sessionId}", "--output-format", "json", "--prompt", "{prompt}"], output: "json", input: "arg", + imageArg: "@", + imagePathScope: "workspace", modelArg: "--model", modelAliases: GEMINI_MODEL_ALIASES, sessionMode: "existing", diff --git a/extensions/openai/cli-backend.ts b/extensions/openai/cli-backend.ts index 2a86a51121f..30874ccfe77 100644 --- a/extensions/openai/cli-backend.ts +++ b/extensions/openai/cli-backend.ts @@ -12,11 +12,14 @@ export function buildOpenAICodexCliBackend(): CliBackendPlugin { liveTest: { defaultModelRef: CODEX_CLI_DEFAULT_MODEL_REF, defaultImageProbe: true, + defaultMcpProbe: true, docker: { npmPackage: "@openai/codex", binaryName: "codex", }, }, + bundleMcp: true, + bundleMcpMode: "codex-config-overrides", config: { command: "codex", args: [ diff --git a/package.json b/package.json index 671e589e0b4..aba3c3fdb5e 100644 --- a/package.json +++ b/package.json @@ -1191,7 +1191,7 @@ "test:docker:live-cli-backend": "bash scripts/test-live-cli-backend-docker.sh", "test:docker:live-cli-backend:claude": "OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6 bash scripts/test-live-cli-backend-docker.sh", "test:docker:live-cli-backend:codex": "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.4 bash scripts/test-live-cli-backend-docker.sh", - "test:docker:live-cli-backend:gemini": "OPENCLAW_LIVE_CLI_BACKEND_MODEL=google-gemini-cli/gemini-3.1-pro-preview bash scripts/test-live-cli-backend-docker.sh", + "test:docker:live-cli-backend:gemini": "OPENCLAW_LIVE_CLI_BACKEND_MODEL=google-gemini-cli/gemini-3-flash-preview bash scripts/test-live-cli-backend-docker.sh", "test:docker:live-gateway": "bash scripts/test-live-gateway-models-docker.sh", "test:docker:live-gateway:claude": "OPENCLAW_LIVE_GATEWAY_PROVIDERS=claude-cli OPENCLAW_LIVE_GATEWAY_MODELS=claude-cli/claude-sonnet-4-6 bash scripts/test-live-gateway-models-docker.sh", "test:docker:live-gateway:codex": "OPENCLAW_LIVE_GATEWAY_PROVIDERS=codex-cli OPENCLAW_LIVE_GATEWAY_MODELS=codex-cli/gpt-5.4 bash scripts/test-live-gateway-models-docker.sh", diff --git a/scripts/print-cli-backend-live-metadata.ts b/scripts/print-cli-backend-live-metadata.ts index e8327ceb010..02a7dca3d73 100644 --- a/scripts/print-cli-backend-live-metadata.ts +++ b/scripts/print-cli-backend-live-metadata.ts @@ -37,6 +37,7 @@ const backendLiveTest = ? { defaultModelRef: fallbackBackend.liveTest?.defaultModelRef, defaultImageProbe: fallbackBackend.liveTest?.defaultImageProbe === true, + defaultMcpProbe: fallbackBackend.liveTest?.defaultMcpProbe === true, dockerNpmPackage: fallbackBackend.liveTest?.docker?.npmPackage, dockerBinaryName: fallbackBackend.liveTest?.docker?.binaryName, } @@ -53,8 +54,10 @@ process.stdout.write( imageMode: backendConfig?.imageMode, systemPromptWhen: backendConfig?.systemPromptWhen ?? "never", bundleMcp: resolved?.bundleMcp === true || fallbackBackend?.bundleMcp === true, + bundleMcpMode: resolved?.bundleMcpMode ?? fallbackBackend?.bundleMcpMode, defaultModelRef: backendLiveTest?.defaultModelRef, defaultImageProbe: backendLiveTest?.defaultImageProbe === true, + defaultMcpProbe: backendLiveTest?.defaultMcpProbe === true, dockerNpmPackage: backendLiveTest?.dockerNpmPackage, dockerBinaryName: backendLiveTest?.dockerBinaryName, }, diff --git a/scripts/stage-bundled-plugin-runtime-deps.mjs b/scripts/stage-bundled-plugin-runtime-deps.mjs index f88708cec07..30817f6896c 100644 --- a/scripts/stage-bundled-plugin-runtime-deps.mjs +++ b/scripts/stage-bundled-plugin-runtime-deps.mjs @@ -143,60 +143,18 @@ function shouldStageRuntimeDeps(packageJson) { return packageJson.openclaw?.bundle?.stageRuntimeDependencies === true; } -function removeSanitizedDependencyEntries(entries, shouldRemove) { - if (!entries || typeof entries !== "object") { - return { changed: false, nextEntries: entries }; - } - - const nextEntries = Object.fromEntries( - Object.entries(entries).filter(([depName, spec]) => !shouldRemove(depName, spec)), - ); - const changed = Object.keys(nextEntries).length !== Object.keys(entries).length; - return { - changed, - nextEntries: changed - ? Object.keys(nextEntries).length > 0 - ? nextEntries - : undefined - : entries, - }; -} - -function shouldRemoveLocalWorkspaceDependency(depName, spec) { - return depName === "openclaw" || (typeof spec === "string" && spec.startsWith("workspace:")); -} - function sanitizeBundledManifestForRuntimeInstall(pluginDir) { const manifestPath = path.join(pluginDir, "package.json"); const packageJson = readJson(manifestPath); let changed = false; - const { changed: peerDependenciesChanged, nextEntries: nextPeerDependencies } = - removeSanitizedDependencyEntries( - packageJson.peerDependencies, - shouldRemoveLocalWorkspaceDependency, - ); - if (peerDependenciesChanged) { - if (nextPeerDependencies) { - packageJson.peerDependencies = nextPeerDependencies; - } else { - delete packageJson.peerDependencies; - } + if (packageJson.peerDependencies) { + delete packageJson.peerDependencies; changed = true; } - if (peerDependenciesChanged && packageJson.peerDependenciesMeta) { - const allowedPeerNames = new Set(Object.keys(packageJson.peerDependencies ?? {})); - const nextPeerDependenciesMeta = Object.fromEntries( - Object.entries(packageJson.peerDependenciesMeta).filter(([depName]) => - allowedPeerNames.has(depName), - ), - ); - if (Object.keys(nextPeerDependenciesMeta).length === 0) { - delete packageJson.peerDependenciesMeta; - } else { - packageJson.peerDependenciesMeta = nextPeerDependenciesMeta; - } + if (packageJson.peerDependenciesMeta) { + delete packageJson.peerDependenciesMeta; changed = true; } diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index c42b3835da7..a812cb5be8b 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { CliBackendConfig } from "../config/types.js"; +import type { CliBundleMcpMode } from "../plugins/types.js"; let createEmptyPluginRegistry: typeof import("../plugins/registry.js").createEmptyPluginRegistry; let setActivePluginRegistry: typeof import("../plugins/runtime.js").setActivePluginRegistry; @@ -13,6 +14,7 @@ function createBackendEntry(params: { id: string; config: CliBackendConfig; bundleMcp?: boolean; + bundleMcpMode?: CliBundleMcpMode; normalizeConfig?: (config: CliBackendConfig) => CliBackendConfig; }) { return { @@ -22,6 +24,7 @@ function createBackendEntry(params: { id: params.id, config: params.config, ...(params.bundleMcp ? { bundleMcp: params.bundleMcp } : {}), + ...(params.bundleMcpMode ? { bundleMcpMode: params.bundleMcpMode } : {}), ...(params.normalizeConfig ? { normalizeConfig: params.normalizeConfig } : {}), liveTest: { defaultModelRef: @@ -33,6 +36,7 @@ function createBackendEntry(params: { ? "google-gemini-cli/gemini-3.1-pro-preview" : undefined, defaultImageProbe: true, + defaultMcpProbe: true, docker: { npmPackage: params.id === "claude-cli" @@ -69,6 +73,8 @@ beforeEach(async () => { createBackendEntry({ pluginId: "anthropic", id: "claude-cli", + bundleMcp: true, + bundleMcpMode: "claude-config-file", config: { command: "claude", args: [ @@ -123,6 +129,8 @@ beforeEach(async () => { createBackendEntry({ pluginId: "openai", id: "codex-cli", + bundleMcp: true, + bundleMcpMode: "codex-config-overrides", config: { command: "codex", args: [ @@ -154,11 +162,14 @@ beforeEach(async () => { createBackendEntry({ pluginId: "google", id: "google-gemini-cli", - bundleMcp: false, + bundleMcp: true, + bundleMcpMode: "gemini-system-settings", config: { command: "gemini", args: ["--output-format", "json", "--prompt", "{prompt}"], resumeArgs: ["--resume", "{sessionId}", "--output-format", "json", "--prompt", "{prompt}"], + imageArg: "@", + imagePathScope: "workspace", modelArg: "--model", sessionMode: "existing", sessionIdFields: ["session_id", "sessionId"], @@ -224,10 +235,21 @@ describe("resolveCliBackendConfig reliability merge", () => { }); describe("resolveCliBackendLiveTest", () => { + it("returns plugin-owned live smoke metadata for claude", () => { + expect(resolveCliBackendLiveTest("claude-cli")).toEqual({ + defaultModelRef: "claude-cli/claude-sonnet-4-6", + defaultImageProbe: true, + defaultMcpProbe: true, + dockerNpmPackage: "@anthropic-ai/claude-code", + dockerBinaryName: "claude", + }); + }); + it("returns plugin-owned live smoke metadata for codex", () => { expect(resolveCliBackendLiveTest("codex-cli")).toEqual({ defaultModelRef: "codex-cli/gpt-5.4", defaultImageProbe: true, + defaultMcpProbe: true, dockerNpmPackage: "@openai/codex", dockerBinaryName: "codex", }); @@ -235,8 +257,9 @@ describe("resolveCliBackendLiveTest", () => { it("returns plugin-owned live smoke metadata for gemini", () => { expect(resolveCliBackendLiveTest("google-gemini-cli")).toEqual({ - defaultModelRef: "google-gemini-cli/gemini-3.1-pro-preview", + defaultModelRef: "google-gemini-cli/gemini-3-flash-preview", defaultImageProbe: true, + defaultMcpProbe: true, dockerNpmPackage: "@google/gemini-cli", dockerBinaryName: "gemini", }); @@ -248,6 +271,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { const resolved = resolveCliBackendConfig("claude-cli"); expect(resolved).not.toBeNull(); + expect(resolved?.bundleMcp).toBe(true); + expect(resolved?.bundleMcpMode).toBe("claude-config-file"); expect(resolved?.config.output).toBe("jsonl"); expect(resolved?.config.args).toContain("stream-json"); expect(resolved?.config.args).toContain("--include-partial-messages"); @@ -588,6 +613,7 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { expect(resolved).not.toBeNull(); expect(resolved?.bundleMcp).toBe(true); + expect(resolved?.bundleMcpMode).toBe("claude-config-file"); expect(resolved?.config.args).toEqual([ "-p", "--output-format", @@ -622,7 +648,8 @@ describe("resolveCliBackendConfig google-gemini-cli defaults", () => { const resolved = resolveCliBackendConfig("google-gemini-cli"); expect(resolved).not.toBeNull(); - expect(resolved?.bundleMcp).toBe(false); + expect(resolved?.bundleMcp).toBe(true); + expect(resolved?.bundleMcpMode).toBe("gemini-system-settings"); expect(resolved?.config.args).toEqual(["--output-format", "json", "--prompt", "{prompt}"]); expect(resolved?.config.resumeArgs).toEqual([ "--resume", @@ -637,6 +664,14 @@ describe("resolveCliBackendConfig google-gemini-cli defaults", () => { expect(resolved?.config.sessionIdFields).toEqual(["session_id", "sessionId"]); expect(resolved?.config.modelAliases?.pro).toBe("gemini-3.1-pro-preview"); }); + + it("uses Codex CLI bundle MCP config overrides", () => { + const resolved = resolveCliBackendConfig("codex-cli"); + + expect(resolved).not.toBeNull(); + expect(resolved?.bundleMcp).toBe(true); + expect(resolved?.bundleMcpMode).toBe("codex-config-overrides"); + }); }); describe("resolveCliBackendConfig alias precedence", () => { diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index 71931658dc1..ea67cceef0b 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -2,18 +2,21 @@ import type { OpenClawConfig } from "../config/config.js"; import type { CliBackendConfig } from "../config/types.js"; import { resolveRuntimeCliBackends } from "../plugins/cli-backends.runtime.js"; import { resolvePluginSetupCliBackend } from "../plugins/setup-registry.js"; +import type { CliBundleMcpMode } from "../plugins/types.js"; import { normalizeProviderId } from "./model-selection.js"; export type ResolvedCliBackend = { id: string; config: CliBackendConfig; bundleMcp: boolean; + bundleMcpMode?: CliBundleMcpMode; pluginId?: string; }; export type ResolvedCliBackendLiveTest = { defaultModelRef?: string; defaultImageProbe: boolean; + defaultMcpProbe: boolean; dockerNpmPackage?: string; dockerBinaryName?: string; }; @@ -25,12 +28,23 @@ export function normalizeClaudeBackendConfig(config: CliBackendConfig): CliBacke type FallbackCliBackendPolicy = { bundleMcp: boolean; + bundleMcpMode?: CliBundleMcpMode; baseConfig?: CliBackendConfig; normalizeConfig?: (config: CliBackendConfig) => CliBackendConfig; }; const FALLBACK_CLI_BACKEND_POLICIES: Record = {}; +function normalizeBundleMcpMode( + mode: CliBundleMcpMode | undefined, + enabled: boolean, +): CliBundleMcpMode | undefined { + if (!enabled) { + return undefined; + } + return mode ?? "claude-config-file"; +} + function resolveSetupCliBackendPolicy(provider: string): FallbackCliBackendPolicy | undefined { const entry = resolvePluginSetupCliBackend({ backend: provider, @@ -42,6 +56,10 @@ function resolveSetupCliBackendPolicy(provider: string): FallbackCliBackendPolic // Setup-registered backends keep narrow CLI paths generic even when the // runtime plugin registry has not booted yet. bundleMcp: entry.backend.bundleMcp === true, + bundleMcpMode: normalizeBundleMcpMode( + entry.backend.bundleMcpMode, + entry.backend.bundleMcp === true, + ), baseConfig: entry.backend.config, normalizeConfig: entry.backend.normalizeConfig, }; @@ -137,6 +155,7 @@ export function resolveCliBackendLiveTest(provider: string): ResolvedCliBackendL return { defaultModelRef: backend.liveTest?.defaultModelRef, defaultImageProbe: backend.liveTest?.defaultImageProbe === true, + defaultMcpProbe: backend.liveTest?.defaultMcpProbe === true, dockerNpmPackage: backend.liveTest?.docker?.npmPackage, dockerBinaryName: backend.liveTest?.docker?.binaryName, }; @@ -162,6 +181,10 @@ export function resolveCliBackendConfig( id: normalized, config: { ...config, command }, bundleMcp: registered.bundleMcp === true, + bundleMcpMode: normalizeBundleMcpMode( + registered.bundleMcpMode, + registered.bundleMcp === true, + ), pluginId: registered.pluginId, }; } @@ -181,6 +204,7 @@ export function resolveCliBackendConfig( id: normalized, config: { ...baseConfig, command }, bundleMcp: fallbackPolicy.bundleMcp, + bundleMcpMode: fallbackPolicy.bundleMcpMode, }; } const mergedFallback = fallbackPolicy?.baseConfig @@ -197,5 +221,6 @@ export function resolveCliBackendConfig( id: normalized, config: { ...config, command }, bundleMcp: fallbackPolicy?.bundleMcp === true, + bundleMcpMode: fallbackPolicy?.bundleMcpMode, }; } diff --git a/src/agents/cli-runner.helpers.test.ts b/src/agents/cli-runner.helpers.test.ts index 9b553d0d971..5e921d8cd0b 100644 --- a/src/agents/cli-runner.helpers.test.ts +++ b/src/agents/cli-runner.helpers.test.ts @@ -168,14 +168,25 @@ describe("buildCliArgs", () => { describe("writeCliImages", () => { it("uses stable hashed file paths so repeated image hydration reuses the same path", async () => { + const workspaceDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-cli-write-images-"), + ); const image: ImageContent = { type: "image", data: "c29tZS1pbWFnZQ==", mimeType: "image/png", }; - const first = await writeCliImages([image]); - const second = await writeCliImages([image]); + const first = await writeCliImages({ + backend: { command: "codex" }, + workspaceDir, + images: [image], + }); + const second = await writeCliImages({ + backend: { command: "codex" }, + workspaceDir, + images: [image], + }); try { expect(first.paths).toHaveLength(1); @@ -185,22 +196,31 @@ describe("writeCliImages", () => { await expect(fs.readFile(first.paths[0])).resolves.toEqual(Buffer.from(image.data, "base64")); } finally { await fs.rm(first.paths[0], { force: true }); + await fs.rm(workspaceDir, { recursive: true, force: true }); } }); it("uses the shared media extension map for image formats beyond the tiny builtin list", async () => { + const workspaceDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-cli-write-heic-"), + ); const image: ImageContent = { type: "image", data: "aGVpYy1pbWFnZQ==", mimeType: "image/heic", }; - const written = await writeCliImages([image]); + const written = await writeCliImages({ + backend: { command: "codex" }, + workspaceDir, + images: [image], + }); try { expect(written.paths[0]).toMatch(/\.heic$/); } finally { await fs.rm(written.paths[0], { force: true }); + await fs.rm(workspaceDir, { recursive: true, force: true }); } }); @@ -286,6 +306,57 @@ describe("writeCliImages", () => { } }); + it("appends Gemini prompt refs with @-prefixed image paths", async () => { + const tempDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-cli-prompt-image-gemini-"), + ); + const explicitImage: ImageContent = { + type: "image", + data: "c29tZS1leHBsaWNpdC1pbWFnZQ==", + mimeType: "image/png", + }; + + try { + const prepared = await prepareCliPromptImagePayload({ + backend: { + command: "gemini", + imageArg: "@", + imagePathScope: "workspace", + input: "arg", + }, + prompt: "What is in this image?", + workspaceDir: tempDir, + images: [explicitImage], + }); + + expect(prepared.prompt).toContain("\n\n@"); + expect(prepared.prompt).toContain(prepared.imagePaths?.[0] ?? ""); + expect(prepared.prompt.trimEnd().endsWith(`@${prepared.imagePaths?.[0] ?? ""}`)).toBe(true); + expect(prepared.imagePaths?.[0]?.startsWith(path.join(tempDir, ".openclaw-cli-images"))).toBe( + true, + ); + + const argv = buildCliArgs({ + backend: { + command: "gemini", + imageArg: "@", + imagePathScope: "workspace", + }, + baseArgs: ["--output-format", "json", "--prompt", "{prompt}"], + modelId: "gemini-3.1-pro-preview", + promptArg: prepared.prompt, + imagePaths: prepared.imagePaths, + useResume: false, + }); + + expect(argv).toEqual(["--output-format", "json", "--prompt", prepared.prompt]); + + await prepared.cleanupImages?.(); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it("prefers explicit images over prompt refs through the helper seams", async () => { const tempDir = await fs.mkdtemp( path.join(resolvePreferredOpenClawTmpDir(), "openclaw-cli-explicit-images-"), diff --git a/src/agents/cli-runner.test-support.ts b/src/agents/cli-runner.test-support.ts index a95f88d1f95..193ac6772a1 100644 --- a/src/agents/cli-runner.test-support.ts +++ b/src/agents/cli-runner.test-support.ts @@ -106,6 +106,8 @@ type ManagedRunMock = { function buildOpenAICodexCliBackendFixture(): CliBackendPlugin { return { id: "codex-cli", + bundleMcp: true, + bundleMcpMode: "codex-config-overrides", config: { command: "codex", args: [ @@ -187,6 +189,7 @@ function buildAnthropicCliBackendFixture(): CliBackendPlugin { return { id: "claude-cli", bundleMcp: true, + bundleMcpMode: "claude-config-file", config: { command: "claude", args: [ @@ -248,12 +251,16 @@ function buildAnthropicCliBackendFixture(): CliBackendPlugin { function buildGoogleGeminiCliBackendFixture(): CliBackendPlugin { return { id: "google-gemini-cli", + bundleMcp: true, + bundleMcpMode: "gemini-system-settings", config: { command: "gemini", args: ["--output-format", "json", "--prompt", "{prompt}"], resumeArgs: ["--resume", "{sessionId}", "--output-format", "json", "--prompt", "{prompt}"], output: "json", input: "arg", + imageArg: "@", + imagePathScope: "workspace", modelArg: "--model", modelAliases: { pro: "gemini-3.1-pro-preview", diff --git a/src/agents/cli-runner/bundle-mcp.test.ts b/src/agents/cli-runner/bundle-mcp.test.ts index 98a9f71297b..9a9af654920 100644 --- a/src/agents/cli-runner/bundle-mcp.test.ts +++ b/src/agents/cli-runner/bundle-mcp.test.ts @@ -22,6 +22,7 @@ describe("prepareCliBundleMcpConfig", () => { const prepared = await prepareCliBundleMcpConfig({ enabled: true, + mode: "claude-config-file", backend: { command: "node", args: ["./fake-claude.mjs"], @@ -62,6 +63,7 @@ describe("prepareCliBundleMcpConfig", () => { const prepared = await prepareCliBundleMcpConfig({ enabled: true, + mode: "claude-config-file", backend: { command: "node", args: ["./fake-claude.mjs"], @@ -117,6 +119,7 @@ describe("prepareCliBundleMcpConfig", () => { const prepared = await prepareCliBundleMcpConfig({ enabled: true, + mode: "claude-config-file", backend: { command: "node", args: ["./fake-claude.mjs"], @@ -160,6 +163,7 @@ describe("prepareCliBundleMcpConfig", () => { const prepared = await prepareCliBundleMcpConfig({ enabled: true, + mode: "claude-config-file", backend: { command: "node", args: ["./fake-claude.mjs"], @@ -199,6 +203,7 @@ describe("prepareCliBundleMcpConfig", () => { const prepared = await prepareCliBundleMcpConfig({ enabled: true, + mode: "claude-config-file", backend: { command: "node", args: ["./fake-claude.mjs"], @@ -232,4 +237,85 @@ describe("prepareCliBundleMcpConfig", () => { expect(prepared.backend.args).toEqual(["./fake-cli.mjs"]); expect(prepared.cleanup).toBeUndefined(); }); + + it("injects codex MCP config overrides with env-backed loopback headers", async () => { + const prepared = await prepareCliBundleMcpConfig({ + enabled: true, + mode: "codex-config-overrides", + backend: { + command: "codex", + args: ["exec", "--json"], + resumeArgs: ["exec", "resume", "{sessionId}"], + }, + workspaceDir: "/tmp/openclaw-bundle-mcp-codex", + additionalConfig: { + 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}", + }, + }, + }, + }, + }); + + expect(prepared.backend.args).toEqual([ + "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" } } }', + ]); + 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" } } }', + ]); + expect(prepared.cleanup).toBeUndefined(); + }); + + it("writes Gemini system settings for bundle MCP servers", async () => { + const prepared = await prepareCliBundleMcpConfig({ + enabled: true, + mode: "gemini-system-settings", + backend: { + command: "gemini", + args: ["--prompt", "{prompt}"], + }, + workspaceDir: "/tmp/openclaw-bundle-mcp-gemini", + additionalConfig: { + mcpServers: { + openclaw: { + type: "http", + url: "http://127.0.0.1:23119/mcp", + headers: { + Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", + }, + }, + }, + }, + env: { + OPENCLAW_MCP_TOKEN: "loopback-token-123", + }, + }); + + expect(prepared.backend.args).toEqual(["--prompt", "{prompt}"]); + expect(prepared.env?.OPENCLAW_MCP_TOKEN).toBe("loopback-token-123"); + expect(typeof prepared.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH).toBe("string"); + const raw = JSON.parse( + await fs.readFile(prepared.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH as string, "utf-8"), + ) as { + mcp?: { allowed?: string[] }; + mcpServers?: Record }>; + }; + expect(raw.mcp?.allowed).toEqual(["openclaw"]); + expect(raw.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp"); + expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer loopback-token-123"); + + await prepared.cleanup?.(); + }); }); diff --git a/src/agents/cli-runner/bundle-mcp.ts b/src/agents/cli-runner/bundle-mcp.ts index 50136a77a03..96a0688efe1 100644 --- a/src/agents/cli-runner/bundle-mcp.ts +++ b/src/agents/cli-runner/bundle-mcp.ts @@ -9,7 +9,9 @@ import { extractMcpServerMap, loadEnabledBundleMcpConfig, type BundleMcpConfig, + type BundleMcpServerConfig, } from "../../plugins/bundle-mcp.js"; +import type { CliBundleMcpMode } from "../../plugins/types.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; type PreparedCliBundleMcpConfig = { @@ -19,6 +21,10 @@ type PreparedCliBundleMcpConfig = { env?: Record; }; +function resolveBundleMcpMode(mode: CliBundleMcpMode | undefined): CliBundleMcpMode { + return mode ?? "claude-config-file"; +} + async function readExternalMcpConfig(configPath: string): Promise { try { const raw = JSON.parse(await fs.readFile(configPath, "utf-8")) as unknown; @@ -28,6 +34,17 @@ async function readExternalMcpConfig(configPath: string): Promise> { + try { + const raw = JSON.parse(await fs.readFile(filePath, "utf-8")) as unknown; + return raw && typeof raw === "object" && !Array.isArray(raw) + ? ({ ...raw } as Record) + : {}; + } catch { + return {}; + } +} + function findMcpConfigPath(args?: string[]): string | undefined { if (!args?.length) { return undefined; @@ -44,7 +61,7 @@ function findMcpConfigPath(args?: string[]): string | undefined { return undefined; } -function injectMcpConfigArgs(args: string[] | undefined, mcpConfigPath: string): string[] { +function injectClaudeMcpConfigArgs(args: string[] | undefined, mcpConfigPath: string): string[] { const next: string[] = []; for (let i = 0; i < (args?.length ?? 0); i += 1) { const arg = args?.[i] ?? ""; @@ -64,8 +81,271 @@ function injectMcpConfigArgs(args: string[] | undefined, mcpConfigPath: string): return next; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeStringArray(value: unknown): string[] | undefined { + return Array.isArray(value) && value.every((entry) => typeof entry === "string") + ? [...value] + : undefined; +} + +function normalizeStringRecord(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const entries = Object.entries(value).filter((entry): entry is [string, string] => { + return typeof entry[1] === "string"; + }); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +function decodeHeaderEnvPlaceholder(value: string): { envVar: string; bearer: boolean } | null { + const bearerMatch = /^Bearer \${([A-Z0-9_]+)}$/.exec(value); + if (bearerMatch) { + return { envVar: bearerMatch[1], bearer: true }; + } + const envMatch = /^\${([A-Z0-9_]+)}$/.exec(value); + if (envMatch) { + return { envVar: envMatch[1], bearer: false }; + } + return null; +} + +function normalizeCodexServerConfig(server: BundleMcpServerConfig): Record { + const next: Record = {}; + if (typeof server.command === "string") { + next.command = server.command; + } + const args = normalizeStringArray(server.args); + if (args) { + next.args = args; + } + const env = normalizeStringRecord(server.env); + if (env) { + next.env = env; + } + if (typeof server.cwd === "string") { + next.cwd = server.cwd; + } + if (typeof server.url === "string") { + next.url = server.url; + } + const httpHeaders = normalizeStringRecord(server.headers); + if (httpHeaders) { + const staticHeaders: Record = {}; + const envHeaders: Record = {}; + for (const [name, value] of Object.entries(httpHeaders)) { + const decoded = decodeHeaderEnvPlaceholder(value); + if (!decoded) { + staticHeaders[name] = value; + continue; + } + if (decoded.bearer && name.toLowerCase() === "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; +} + +function resolveEnvPlaceholder( + value: string, + inheritedEnv: Record | undefined, +): string { + const decoded = decodeHeaderEnvPlaceholder(value); + if (!decoded) { + return value; + } + const resolved = inheritedEnv?.[decoded.envVar] ?? process.env[decoded.envVar] ?? ""; + return decoded.bearer ? `Bearer ${resolved}` : resolved; +} + +function normalizeGeminiServerConfig( + server: BundleMcpServerConfig, + inheritedEnv: Record | undefined, +): Record { + const next: Record = {}; + if (typeof server.command === "string") { + next.command = server.command; + } + const args = normalizeStringArray(server.args); + if (args) { + next.args = args; + } + const env = normalizeStringRecord(server.env); + if (env) { + next.env = env; + } + if (typeof server.cwd === "string") { + next.cwd = server.cwd; + } + if (typeof server.url === "string") { + next.url = server.url; + } + if (typeof server.type === "string") { + next.type = server.type; + } + const headers = normalizeStringRecord(server.headers); + if (headers) { + next.headers = Object.fromEntries( + Object.entries(headers).map(([name, value]) => [ + name, + resolveEnvPlaceholder(value, inheritedEnv), + ]), + ); + } + if (typeof server.trust === "boolean") { + next.trust = server.trust; + } + return next; +} + +function escapeTomlString(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +} + +function formatTomlKey(key: string): string { + return /^[A-Za-z0-9_-]+$/.test(key) ? key : `"${escapeTomlString(key)}"`; +} + +function serializeTomlInlineValue(value: unknown): string { + if (typeof value === "string") { + return `"${escapeTomlString(value)}"`; + } + if (typeof value === "number" || typeof value === "bigint") { + return String(value); + } + if (typeof value === "boolean") { + return value ? "true" : "false"; + } + if (Array.isArray(value)) { + return `[${value.map((entry) => serializeTomlInlineValue(entry)).join(", ")}]`; + } + if (isRecord(value)) { + return `{ ${Object.entries(value) + .map(([key, entry]) => `${formatTomlKey(key)} = ${serializeTomlInlineValue(entry)}`) + .join(", ")} }`; + } + throw new Error(`Unsupported TOML value for Codex MCP config: ${String(value)}`); +} + +function injectCodexMcpConfigArgs(args: string[] | undefined, config: BundleMcpConfig): string[] { + const overrides = serializeTomlInlineValue( + Object.fromEntries( + Object.entries(config.mcpServers).map(([name, server]) => [ + name, + normalizeCodexServerConfig(server), + ]), + ), + ); + return [...(args ?? []), "-c", `mcp_servers=${overrides}`]; +} + +async function writeGeminiSystemSettings( + mergedConfig: BundleMcpConfig, + inheritedEnv: Record | undefined, +): Promise<{ env: Record; cleanup: () => Promise }> { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gemini-mcp-")); + const settingsPath = path.join(tempDir, "settings.json"); + const existingSettingsPath = + inheritedEnv?.GEMINI_CLI_SYSTEM_SETTINGS_PATH ?? process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH; + const base = + typeof existingSettingsPath === "string" && existingSettingsPath.trim() + ? await readJsonObject(existingSettingsPath) + : {}; + const normalizedConfig: BundleMcpConfig = { + mcpServers: Object.fromEntries( + Object.entries(mergedConfig.mcpServers).map(([name, server]) => [ + name, + normalizeGeminiServerConfig(server, inheritedEnv), + ]), + ) as BundleMcpConfig["mcpServers"], + }; + const settings = applyMergePatch(base, { + mcp: { + allowed: Object.keys(normalizedConfig.mcpServers), + }, + mcpServers: normalizedConfig.mcpServers, + }) as Record; + await fs.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8"); + return { + env: { + ...inheritedEnv, + GEMINI_CLI_SYSTEM_SETTINGS_PATH: settingsPath, + }, + cleanup: async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }, + }; +} + +async function prepareModeSpecificBundleMcpConfig(params: { + mode: CliBundleMcpMode; + backend: CliBackendConfig; + mergedConfig: BundleMcpConfig; + env?: Record; +}): Promise { + const serializedConfig = `${JSON.stringify(params.mergedConfig, null, 2)}\n`; + const mcpConfigHash = crypto.createHash("sha256").update(serializedConfig).digest("hex"); + + if (params.mode === "codex-config-overrides") { + return { + backend: { + ...params.backend, + args: injectCodexMcpConfigArgs(params.backend.args, params.mergedConfig), + resumeArgs: injectCodexMcpConfigArgs( + params.backend.resumeArgs ?? params.backend.args ?? [], + params.mergedConfig, + ), + }, + mcpConfigHash, + env: params.env, + }; + } + + if (params.mode === "gemini-system-settings") { + const settings = await writeGeminiSystemSettings(params.mergedConfig, params.env); + return { + backend: params.backend, + mcpConfigHash, + env: settings.env, + cleanup: settings.cleanup, + }; + } + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-mcp-")); + const mcpConfigPath = path.join(tempDir, "mcp.json"); + await fs.writeFile(mcpConfigPath, serializedConfig, "utf-8"); + return { + backend: { + ...params.backend, + args: injectClaudeMcpConfigArgs(params.backend.args, mcpConfigPath), + resumeArgs: injectClaudeMcpConfigArgs( + params.backend.resumeArgs ?? params.backend.args ?? [], + mcpConfigPath, + ), + }, + mcpConfigHash, + env: params.env, + cleanup: async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }, + }; +} + export async function prepareCliBundleMcpConfig(params: { enabled: boolean; + mode?: CliBundleMcpMode; backend: CliBackendConfig; workspaceDir: string; config?: OpenClawConfig; @@ -77,8 +357,11 @@ export async function prepareCliBundleMcpConfig(params: { return { backend: params.backend, env: params.env }; } + const mode = resolveBundleMcpMode(params.mode); const existingMcpConfigPath = - findMcpConfigPath(params.backend.resumeArgs) ?? findMcpConfigPath(params.backend.args); + mode === "claude-config-file" + ? (findMcpConfigPath(params.backend.resumeArgs) ?? findMcpConfigPath(params.backend.args)) + : undefined; let mergedConfig: BundleMcpConfig = { mcpServers: {} }; if (existingMcpConfigPath) { @@ -103,27 +386,10 @@ export async function prepareCliBundleMcpConfig(params: { mergedConfig = applyMergePatch(mergedConfig, params.additionalConfig) as BundleMcpConfig; } - // Always pass an explicit strict MCP config for background claude-cli runs. - // Otherwise Claude may inherit ambient user/global MCP servers (for example - // Playwright) and spawn unexpected background processes. - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-mcp-")); - const mcpConfigPath = path.join(tempDir, "mcp.json"); - const serializedConfig = `${JSON.stringify(mergedConfig, null, 2)}\n`; - await fs.writeFile(mcpConfigPath, serializedConfig, "utf-8"); - - return { - backend: { - ...params.backend, - args: injectMcpConfigArgs(params.backend.args, mcpConfigPath), - resumeArgs: injectMcpConfigArgs( - params.backend.resumeArgs ?? params.backend.args ?? [], - mcpConfigPath, - ), - }, - mcpConfigHash: crypto.createHash("sha256").update(serializedConfig).digest("hex"), + return await prepareModeSpecificBundleMcpConfig({ + mode, + backend: params.backend, + mergedConfig, env: params.env, - cleanup: async () => { - await fs.rm(tempDir, { recursive: true, force: true }); - }, - }; + }); } diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index 2aeee66c372..6a08312c6e3 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -198,13 +198,20 @@ function resolveCliImagePath(image: ImageContent): string { return path.join(resolvePreferredOpenClawTmpDir(), "openclaw-cli-images", `${digest}${ext}`); } -export function appendImagePathsToPrompt(prompt: string, paths: string[]): string { +function resolveCliImageRoot(params: { backend: CliBackendConfig; workspaceDir: string }): string { + if (params.backend.imagePathScope === "workspace") { + return path.join(params.workspaceDir, ".openclaw-cli-images"); + } + return path.join(resolvePreferredOpenClawTmpDir(), "openclaw-cli-images"); +} + +export function appendImagePathsToPrompt(prompt: string, paths: string[], prefix = ""): string { if (!paths.length) { return prompt; } const trimmed = prompt.trimEnd(); const separator = trimmed ? "\n\n" : ""; - return `${trimmed}${separator}${paths.join("\n")}`; + return `${trimmed}${separator}${paths.map((entry) => `${prefix}${entry}`).join("\n")}`; } export async function loadPromptRefImages(params: { @@ -244,15 +251,21 @@ export async function loadPromptRefImages(params: { return sanitizedImages; } -export async function writeCliImages( - images: ImageContent[], -): Promise<{ paths: string[]; cleanup: () => Promise }> { - const imageRoot = path.join(resolvePreferredOpenClawTmpDir(), "openclaw-cli-images"); +export async function writeCliImages(params: { + backend: CliBackendConfig; + workspaceDir: string; + images: ImageContent[]; +}): Promise<{ paths: string[]; cleanup: () => Promise }> { + const imageRoot = resolveCliImageRoot({ + backend: params.backend, + workspaceDir: params.workspaceDir, + }); await fs.mkdir(imageRoot, { recursive: true, mode: 0o700 }); const paths: string[] = []; - for (let i = 0; i < images.length; i += 1) { - const image = images[i]; - const filePath = resolveCliImagePath(image); + for (let i = 0; i < params.images.length; i += 1) { + const image = params.images[i]; + const fileName = path.basename(resolveCliImagePath(image)); + const filePath = path.join(imageRoot, fileName); const buffer = Buffer.from(image.data, "base64"); await fs.writeFile(filePath, buffer, { mode: 0o600 }); paths.push(filePath); @@ -281,10 +294,22 @@ export async function prepareCliPromptImagePayload(params: { if (resolvedImages.length === 0) { return { prompt }; } - const imagePayload = await writeCliImages(resolvedImages); + const imagePayload = await writeCliImages({ + backend: params.backend, + workspaceDir: params.workspaceDir, + images: resolvedImages, + }); const imagePaths = imagePayload.paths; - if (!params.backend.imageArg || params.backend.input === "stdin") { - prompt = appendImagePathsToPrompt(prompt, imagePaths); + if ( + !params.backend.imageArg || + params.backend.input === "stdin" || + params.backend.imageArg === "@" + ) { + prompt = appendImagePathsToPrompt( + prompt, + imagePaths, + params.backend.imageArg === "@" ? "@" : "", + ); } return { prompt, @@ -322,7 +347,7 @@ export function buildCliArgs(params: { if (params.imagePaths && params.imagePaths.length > 0) { const mode = params.backend.imageMode ?? "repeat"; const imageArg = params.backend.imageArg; - if (imageArg) { + if (imageArg && imageArg !== "@") { if (mode === "list") { args.push(imageArg, params.imagePaths.join(",")); } else { diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 2be4501f414..c1c8051a5f8 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -112,10 +112,12 @@ export async function prepareCliRunContext( config: params.config, agentId: params.agentId, }); - const mcpLoopbackRuntime = - backendResolved.id === "claude-cli" ? prepareDeps.getActiveMcpLoopbackRuntime() : undefined; + const mcpLoopbackRuntime = backendResolved.bundleMcp + ? prepareDeps.getActiveMcpLoopbackRuntime() + : undefined; const preparedBackend = await prepareCliBundleMcpConfig({ enabled: backendResolved.bundleMcp, + mode: backendResolved.bundleMcpMode, backend: backendResolved.config, workspaceDir, config: params.config, diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 3a506584e59..96457d5e245 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -87,6 +87,8 @@ export type CliBackendConfig = { imageArg?: string; /** How to pass multiple images. */ imageMode?: "repeat" | "list"; + /** Where staged image files should live before handing them to the CLI. */ + imagePathScope?: "temp" | "workspace"; /** Serialize runs for this CLI. */ serialize?: boolean; /** Runtime reliability tuning for this backend's process lifecycle. */ diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index c1cf4d6daf9..a2bef61d96e 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -539,6 +539,7 @@ export const CliBackendSchema = z .optional(), imageArg: z.string().optional(), imageMode: z.union([z.literal("repeat"), z.literal("list")]).optional(), + imagePathScope: z.union([z.literal("temp"), z.literal("workspace")]).optional(), serialize: z.boolean().optional(), reliability: z .object({ diff --git a/src/gateway/gateway-cli-backend.live-helpers.ts b/src/gateway/gateway-cli-backend.live-helpers.ts index 563a647010e..bfc39992c14 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.ts @@ -1,9 +1,6 @@ -import { execFile } from "node:child_process"; -import { randomBytes, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; -import { promisify } from "node:util"; -import { expect } from "vitest"; import { resolveCliBackendLiveTest } from "../agents/cli-backends.js"; import { loadOrCreateDeviceIdentity, @@ -19,10 +16,18 @@ import { isTruthyEnvValue } from "../infra/env.js"; import { getFreePortBlockWithPermissionFallback } from "../test-utils/ports.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GatewayClient } from "./client.js"; -import { renderCatNoncePngBase64 } from "./live-image-probe.js"; +import { + assertCronJobMatches, + assertCronJobVisibleViaCli, + assertLiveImageProbeReply, + buildLiveCronProbeMessage, + createLiveCronProbeSpec, + runOpenClawCliJson, + type CronListJob, +} from "./live-agent-probes.js"; +import { renderCatFacePngBase64 } from "./live-image-probe.js"; import { extractPayloadText } from "./test-helpers.agent-results.js"; -const execFileAsync = promisify(execFile); const CLI_GATEWAY_CONNECT_TIMEOUT_MS = 30_000; export type BootstrapWorkspaceContext = { @@ -35,19 +40,6 @@ export type SystemPromptReport = { injectedWorkspaceFiles?: Array<{ name?: string }>; }; -export type CronListCliResult = { - jobs?: Array<{ - id?: string; - name?: string; - sessionTarget?: string; - agentId?: string | null; - sessionKey?: string | null; - payload?: { kind?: string; text?: string; message?: string }; - }>; -}; - -type CronListJob = NonNullable[number]; - export type CliBackendLiveEnvSnapshot = { configPath?: string; stateDir?: string; @@ -64,18 +56,6 @@ export type CliBackendLiveEnvSnapshot = { anthropicApiKeyOld?: string; }; -export function randomImageProbeCode(len = 6): string { - // Chosen to avoid common OCR confusions in our 5x7 bitmap font. - // Notably: 0↔8, B↔8, 6↔9, 3↔B, D↔0. - const alphabet = "24567ACEF"; - const bytes = randomBytes(len); - let out = ""; - for (let i = 0; i < len; i += 1) { - out += alphabet[bytes[i] % alphabet.length]; - } - return out; -} - export function parseJsonStringArray(name: string, raw?: string): string[] | undefined { const trimmed = raw?.trim(); if (!trimmed) { @@ -107,13 +87,21 @@ export function shouldRunCliImageProbe(providerId: string): boolean { return resolveCliBackendLiveTest(providerId)?.defaultImageProbe === true; } +export function shouldRunCliMcpProbe(providerId: string): boolean { + const raw = process.env.OPENCLAW_LIVE_CLI_BACKEND_MCP_PROBE?.trim(); + if (raw) { + return isTruthyEnvValue(raw); + } + return resolveCliBackendLiveTest(providerId)?.defaultMcpProbe === true; +} + export function matchesCliBackendReply(text: string, expected: string): boolean { const normalized = text.trim(); const target = expected.trim(); return normalized === target || normalized === target.slice(0, -1); } -export function withMcpConfigOverrides(args: string[], mcpConfigPath: string): string[] { +export function withClaudeMcpConfigOverrides(args: string[], mcpConfigPath: string): string[] { const next = [...args]; if (!next.includes("--strict-mcp-config")) { next.push("--strict-mcp-config"); @@ -153,46 +141,6 @@ export async function createBootstrapWorkspace( return { expectedInjectedFiles, workspaceDir, workspaceRootDir }; } -export async function runOpenClawCliJson(args: string[], env: NodeJS.ProcessEnv): Promise { - const childEnv = { ...env }; - delete childEnv.VITEST; - delete childEnv.VITEST_MODE; - delete childEnv.VITEST_POOL_ID; - delete childEnv.VITEST_WORKER_ID; - const { stdout, stderr } = await execFileAsync(process.execPath, ["openclaw.mjs", ...args], { - cwd: process.cwd(), - env: childEnv, - timeout: 30_000, - maxBuffer: 1024 * 1024, - }); - const trimmed = stdout.trim(); - if (!trimmed) { - throw new Error( - [ - `openclaw ${args.join(" ")} produced no JSON stdout`, - stderr.trim() ? `stderr: ${stderr.trim()}` : undefined, - ] - .filter(Boolean) - .join("\n"), - ); - } - try { - return JSON.parse(trimmed) as T; - } catch (error) { - throw new Error( - [ - `openclaw ${args.join(" ")} returned invalid JSON`, - `stdout: ${trimmed}`, - stderr.trim() ? `stderr: ${stderr.trim()}` : undefined, - error instanceof Error ? `cause: ${error.message}` : undefined, - ] - .filter(Boolean) - .join("\n"), - { cause: error }, - ); - } -} - function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -394,8 +342,7 @@ export async function verifyCliBackendImageProbe(params: { tempDir: string; bootstrapWorkspace: BootstrapWorkspaceContext | null; }): Promise { - const imageCode = randomImageProbeCode(); - const imageBase64 = renderCatNoncePngBase64(imageCode); + const imageBase64 = renderCatFacePngBase64(); const runIdImage = randomUUID(); const imageFilePath = path.join( params.bootstrapWorkspace?.workspaceDir ?? params.tempDir, @@ -435,84 +382,66 @@ export async function verifyCliBackendImageProbe(params: { if (imageProbe?.status !== "ok") { throw new Error(`image probe failed: status=${String(imageProbe?.status)}`); } - const imageText = extractPayloadText(imageProbe?.result).trim().toLowerCase(); - if (imageText !== "cat") { - throw new Error(`image probe expected 'cat', got: ${imageText}`); - } + assertLiveImageProbeReply(extractPayloadText(imageProbe?.result)); } -export async function verifyClaudeCliCronMcpProbe(params: { +export async function verifyCliCronMcpProbe(params: { client: GatewayClient; + providerId: string; sessionKey: string; port: number; token: string; env: NodeJS.ProcessEnv; }): Promise { - const cronProbeNonce = randomBytes(3).toString("hex").toUpperCase(); - const cronProbeName = `live-mcp-${cronProbeNonce.toLowerCase()}`; - const cronProbeMessage = `probe-${cronProbeNonce.toLowerCase()}`; - const cronProbeAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); - const cronArgsJson = JSON.stringify({ - action: "add", - job: { - name: cronProbeName, - schedule: { kind: "at", at: cronProbeAt }, - payload: { kind: "agentTurn", message: cronProbeMessage }, - sessionTarget: "current", - enabled: true, - }, - }); + const cronProbe = createLiveCronProbeSpec(); let createdJob: CronListJob | undefined; let lastCronText = ""; for (let attempt = 0; attempt < 2 && !createdJob; attempt += 1) { const runIdMcp = randomUUID(); - const cronProbe = await params.client.request( + const cronResult = await params.client.request( "agent", { sessionKey: params.sessionKey, idempotencyKey: `idem-${runIdMcp}-mcp-${attempt}`, - message: - attempt === 0 - ? "Use the OpenClaw MCP tool named cron. " + - `Call it with JSON arguments ${cronArgsJson}. ` + - "Do the actual tool call; I will verify externally with the OpenClaw cron CLI. " + - `After the cron job is created, reply exactly: ${cronProbeName}` - : "Return only a tool call for the OpenClaw MCP tool `cron`. " + - `Use these exact JSON arguments: ${cronArgsJson}. ` + - "No prose. I will verify externally with the OpenClaw cron CLI.", + message: buildLiveCronProbeMessage({ + agent: params.providerId, + argsJson: cronProbe.argsJson, + attempt, + exactReply: cronProbe.name, + }), deliver: false, }, { expectFinal: true }, ); - if (cronProbe?.status !== "ok") { - throw new Error(`cron mcp probe failed: status=${String(cronProbe?.status)}`); + if (cronResult?.status !== "ok") { + throw new Error(`cron mcp probe failed: status=${String(cronResult?.status)}`); } - lastCronText = extractPayloadText(cronProbe?.result).trim(); + lastCronText = extractPayloadText(cronResult?.result).trim(); createdJob = await assertCronJobVisibleViaCli({ port: params.port, token: params.token, env: params.env, - expectedName: cronProbeName, - expectedMessage: cronProbeMessage, + expectedName: cronProbe.name, + expectedMessage: cronProbe.message, }); if (!createdJob && attempt === 1) { throw new Error( - `cron cli verify could not find job ${cronProbeName}: reply=${JSON.stringify(lastCronText)}`, + `cron cli verify could not find job ${cronProbe.name}: reply=${JSON.stringify(lastCronText)}`, ); } } if (!createdJob) { - throw new Error(`cron cli verify did not create job ${cronProbeName}`); + throw new Error(`cron cli verify did not create job ${cronProbe.name}`); } - expect(createdJob.name).toBe(cronProbeName); - expect(createdJob?.payload?.kind).toBe("agentTurn"); - expect(createdJob?.payload?.message).toBe(cronProbeMessage); - expect(createdJob?.agentId).toBe("dev"); - expect(createdJob?.sessionKey).toBe(params.sessionKey); - expect(createdJob?.sessionTarget).toBe(`session:${params.sessionKey}`); + assertCronJobMatches({ + job: createdJob, + expectedName: cronProbe.name, + expectedMessage: cronProbe.message, + expectedSessionKey: params.sessionKey, + }); if (createdJob?.id) { await runOpenClawCliJson( [ @@ -529,29 +458,3 @@ export async function verifyClaudeCliCronMcpProbe(params: { ); } } - -export async function assertCronJobVisibleViaCli(params: { - port: number; - token: string; - env: NodeJS.ProcessEnv; - expectedName: string; - expectedMessage: string; -}): Promise { - const cronList = await runOpenClawCliJson( - [ - "cron", - "list", - "--all", - "--json", - "--url", - `ws://127.0.0.1:${params.port}`, - "--token", - params.token, - ], - params.env, - ); - return ( - cronList.jobs?.find((job) => job.name === params.expectedName) ?? - cronList.jobs?.find((job) => job.payload?.message === params.expectedMessage) - ); -} diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index 20fe0afac0b..cce304282f5 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -18,11 +18,12 @@ import { parseJsonStringArray, restoreCliBackendLiveEnv, shouldRunCliImageProbe, + shouldRunCliMcpProbe, snapshotCliBackendLiveEnv, type SystemPromptReport, - verifyClaudeCliCronMcpProbe, + verifyCliCronMcpProbe, verifyCliBackendImageProbe, - withMcpConfigOverrides, + withClaudeMcpConfigOverrides, connectTestGatewayClient, } from "./gateway-cli-backend.live-helpers.js"; import { startGatewayServer } from "./server.js"; @@ -79,7 +80,13 @@ describeLive("gateway live (cli backend)", () => { const modelKey = `${providerId}/${parsed.model}`; const backendResolved = resolveCliBackendConfig(providerId); const enableCliImageProbe = shouldRunCliImageProbe(providerId); - logCliBackendLiveStep("model-selected", { providerId, modelKey, enableCliImageProbe }); + const enableCliMcpProbe = shouldRunCliMcpProbe(providerId); + logCliBackendLiveStep("model-selected", { + providerId, + modelKey, + enableCliImageProbe, + enableCliMcpProbe, + }); const providerDefaults = backendResolved?.config; const cliCommand = process.env.OPENCLAW_LIVE_CLI_BACKEND_COMMAND ?? providerDefaults?.command; @@ -127,13 +134,20 @@ describeLive("gateway live (cli backend)", () => { await fs.mkdir(stateDir, { recursive: true }); process.env.OPENCLAW_STATE_DIR = stateDir; const bundleMcp = backendResolved?.bundleMcp === true; - const bootstrapWorkspace = bundleMcp ? await createBootstrapWorkspace(tempDir) : null; + const bootstrapWorkspace = + backendResolved?.bundleMcpMode === "claude-config-file" + ? await createBootstrapWorkspace(tempDir) + : null; const disableMcpConfig = process.env.OPENCLAW_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG !== "0"; let cliArgs = baseCliArgs; - if (bundleMcp && disableMcpConfig) { + if ( + bundleMcp && + disableMcpConfig && + backendResolved?.bundleMcpMode === "claude-config-file" + ) { const mcpConfigPath = path.join(tempDir, "claude-mcp.json"); await fs.writeFile(mcpConfigPath, `${JSON.stringify({ mcpServers: {} }, null, 2)}\n`); - cliArgs = withMcpConfigOverrides(baseCliArgs, mcpConfigPath); + cliArgs = withClaudeMcpConfigOverrides(baseCliArgs, mcpConfigPath); } const cfg: OpenClawConfig = {}; @@ -278,10 +292,11 @@ describeLive("gateway live (cli backend)", () => { logCliBackendLiveStep("image-probe:done"); } - if (providerId === "claude-cli") { + if (enableCliMcpProbe) { logCliBackendLiveStep("cron-mcp-probe:start", { sessionKey }); - await verifyClaudeCliCronMcpProbe({ + await verifyCliCronMcpProbe({ client, + providerId, sessionKey, port, token, diff --git a/src/gateway/live-agent-probes.test.ts b/src/gateway/live-agent-probes.test.ts new file mode 100644 index 00000000000..90bd4c77b56 --- /dev/null +++ b/src/gateway/live-agent-probes.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + assertCronJobMatches, + assertLiveImageProbeReply, + buildLiveCronProbeMessage, + createLiveCronProbeSpec, + normalizeLiveAgentFamily, +} from "./live-agent-probes.js"; + +describe("live-agent-probes", () => { + it("normalizes cli backend ids into live agent families", () => { + expect(normalizeLiveAgentFamily("claude-cli")).toBe("claude"); + expect(normalizeLiveAgentFamily("codex")).toBe("codex"); + expect(normalizeLiveAgentFamily("google-gemini-cli")).toBe("gemini"); + }); + + it("accepts only cat for the shared image probe reply", () => { + expect(() => assertLiveImageProbeReply("cat")).not.toThrow(); + expect(() => assertLiveImageProbeReply("horse")).toThrow("image probe expected 'cat'"); + }); + + it("builds a retryable cron prompt with provider-specific fallback wording", () => { + const spec = createLiveCronProbeSpec(); + expect( + buildLiveCronProbeMessage({ + agent: "claude-cli", + argsJson: spec.argsJson, + attempt: 1, + exactReply: spec.name, + }), + ).toContain("Return only a tool call"); + expect( + buildLiveCronProbeMessage({ + agent: "codex", + argsJson: spec.argsJson, + attempt: 1, + exactReply: spec.name, + }), + ).toContain("No prose before the tool call"); + }); + + it("validates cron cli job shape for the shared live probe", () => { + expect(() => + assertCronJobMatches({ + job: { + name: "live-mcp-abc", + sessionTarget: "session:agent:dev:test", + agentId: "dev", + sessionKey: "agent:dev:test", + payload: { kind: "agentTurn", message: "probe-abc" }, + }, + expectedName: "live-mcp-abc", + expectedMessage: "probe-abc", + expectedSessionKey: "agent:dev:test", + }), + ).not.toThrow(); + }); +}); diff --git a/src/gateway/live-agent-probes.ts b/src/gateway/live-agent-probes.ts new file mode 100644 index 00000000000..df5b5104832 --- /dev/null +++ b/src/gateway/live-agent-probes.ts @@ -0,0 +1,190 @@ +import { execFile } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export type LiveAgentFamily = "claude" | "codex" | "gemini"; + +export type CronListCliResult = { + jobs?: Array<{ + id?: string; + name?: string; + sessionTarget?: string; + agentId?: string | null; + sessionKey?: string | null; + payload?: { kind?: string; text?: string; message?: string }; + }>; +}; + +export type CronListJob = NonNullable[number]; + +export type LiveCronProbeSpec = { + nonce: string; + name: string; + message: string; + at: string; + argsJson: string; +}; + +export function normalizeLiveAgentFamily(raw: string): LiveAgentFamily { + const normalized = raw.trim().toLowerCase(); + if (normalized === "claude" || normalized === "claude-cli") { + return "claude"; + } + if (normalized === "codex" || normalized === "codex-cli") { + return "codex"; + } + if (normalized === "gemini" || normalized === "google-gemini-cli") { + return "gemini"; + } + throw new Error(`unsupported live agent family: ${raw}`); +} + +export function assertLiveImageProbeReply(text: string): void { + const normalized = text.trim().toLowerCase(); + if (normalized !== "cat") { + throw new Error(`image probe expected 'cat', got: ${normalized}`); + } +} + +export function createLiveCronProbeSpec(): LiveCronProbeSpec { + const nonce = randomBytes(3).toString("hex").toUpperCase(); + const name = `live-mcp-${nonce.toLowerCase()}`; + const message = `probe-${nonce.toLowerCase()}`; + const at = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + const argsJson = JSON.stringify({ + action: "add", + job: { + name, + schedule: { kind: "at", at }, + payload: { kind: "agentTurn", message }, + sessionTarget: "current", + enabled: true, + }, + }); + return { nonce, name, message, at, argsJson }; +} + +export function buildLiveCronProbeMessage(params: { + agent: string; + argsJson: string; + attempt: number; + exactReply: string; +}): string { + const family = normalizeLiveAgentFamily(params.agent); + if (params.attempt === 0) { + return ( + "Use the OpenClaw MCP tool named cron. " + + `Call it with JSON arguments ${params.argsJson}. ` + + "Do the actual tool call; I will verify externally with the OpenClaw cron CLI. " + + `After the cron job is created, reply exactly: ${params.exactReply}` + ); + } + if (family === "claude") { + return ( + "Return only a tool call for the OpenClaw MCP tool `cron`. " + + `Use these exact JSON arguments: ${params.argsJson}. ` + + "No prose. I will verify externally with the OpenClaw cron CLI." + ); + } + return ( + "Use the OpenClaw MCP tool named cron. " + + `Use these exact JSON arguments: ${params.argsJson}. ` + + "No prose before the tool call. I will verify externally with the OpenClaw cron CLI." + ); +} + +export async function runOpenClawCliJson(args: string[], env: NodeJS.ProcessEnv): Promise { + const childEnv = { ...env }; + delete childEnv.VITEST; + delete childEnv.VITEST_MODE; + delete childEnv.VITEST_POOL_ID; + delete childEnv.VITEST_WORKER_ID; + const { stdout, stderr } = await execFileAsync(process.execPath, ["openclaw.mjs", ...args], { + cwd: process.cwd(), + env: childEnv, + timeout: 30_000, + maxBuffer: 1024 * 1024, + }); + const trimmed = stdout.trim(); + if (!trimmed) { + throw new Error( + [ + `openclaw ${args.join(" ")} produced no JSON stdout`, + stderr.trim() ? `stderr: ${stderr.trim()}` : undefined, + ] + .filter(Boolean) + .join("\n"), + ); + } + try { + return JSON.parse(trimmed) as T; + } catch (error) { + throw new Error( + [ + `openclaw ${args.join(" ")} returned invalid JSON`, + `stdout: ${trimmed}`, + stderr.trim() ? `stderr: ${stderr.trim()}` : undefined, + error instanceof Error ? `cause: ${error.message}` : undefined, + ] + .filter(Boolean) + .join("\n"), + { cause: error }, + ); + } +} + +export async function assertCronJobVisibleViaCli(params: { + port: number; + token: string; + env: NodeJS.ProcessEnv; + expectedName: string; + expectedMessage: string; +}): Promise { + const cronList = await runOpenClawCliJson( + [ + "cron", + "list", + "--all", + "--json", + "--url", + `ws://127.0.0.1:${params.port}`, + "--token", + params.token, + ], + params.env, + ); + return ( + cronList.jobs?.find((job) => job.name === params.expectedName) ?? + cronList.jobs?.find((job) => job.payload?.message === params.expectedMessage) + ); +} + +export function assertCronJobMatches(params: { + job: CronListJob; + expectedName: string; + expectedMessage: string; + expectedSessionKey: string; + expectedAgentId?: string; +}) { + if (params.job.name !== params.expectedName) { + throw new Error(`cron job name mismatch: ${params.job.name ?? ""}`); + } + if (params.job.payload?.kind !== "agentTurn") { + throw new Error(`cron payload kind mismatch: ${params.job.payload?.kind ?? ""}`); + } + if (params.job.payload?.message !== params.expectedMessage) { + throw new Error(`cron payload message mismatch: ${params.job.payload?.message ?? ""}`); + } + const expectedAgentId = params.expectedAgentId ?? "dev"; + if (params.job.agentId !== expectedAgentId) { + throw new Error(`cron agentId mismatch: ${params.job.agentId ?? ""}`); + } + if (params.job.sessionKey !== params.expectedSessionKey) { + throw new Error(`cron sessionKey mismatch: ${params.job.sessionKey ?? ""}`); + } + if (params.job.sessionTarget !== `session:${params.expectedSessionKey}`) { + throw new Error(`cron sessionTarget mismatch: ${params.job.sessionTarget ?? ""}`); + } +} diff --git a/src/gateway/live-image-probe.ts b/src/gateway/live-image-probe.ts index eefeecdaf0e..964f0ab1d71 100644 --- a/src/gateway/live-image-probe.ts +++ b/src/gateway/live-image-probe.ts @@ -89,6 +89,117 @@ function measureTextWidthPx(text: string, scale: number) { return text.length * 6 * scale - scale; // 5px glyph + 1px space } +function fillRect(params: { + buf: Buffer; + width: number; + height: number; + x: number; + y: number; + w: number; + h: number; + color: { r: number; g: number; b: number; a?: number }; +}) { + const startX = Math.max(0, params.x); + const startY = Math.max(0, params.y); + const endX = Math.min(params.width, params.x + params.w); + const endY = Math.min(params.height, params.y + params.h); + for (let y = startY; y < endY; y += 1) { + for (let x = startX; x < endX; x += 1) { + fillPixel( + params.buf, + x, + y, + params.width, + params.color.r, + params.color.g, + params.color.b, + params.color.a ?? 255, + ); + } + } +} + +function fillEllipse(params: { + buf: Buffer; + width: number; + height: number; + cx: number; + cy: number; + rx: number; + ry: number; + color: { r: number; g: number; b: number; a?: number }; +}) { + for ( + let y = Math.max(0, params.cy - params.ry); + y <= Math.min(params.height - 1, params.cy + params.ry); + y += 1 + ) { + for ( + let x = Math.max(0, params.cx - params.rx); + x <= Math.min(params.width - 1, params.cx + params.rx); + x += 1 + ) { + const dx = (x - params.cx) / params.rx; + const dy = (y - params.cy) / params.ry; + if (dx * dx + dy * dy <= 1) { + fillPixel( + params.buf, + x, + y, + params.width, + params.color.r, + params.color.g, + params.color.b, + params.color.a ?? 255, + ); + } + } + } +} + +function fillTriangle(params: { + buf: Buffer; + width: number; + height: number; + a: { x: number; y: number }; + b: { x: number; y: number }; + c: { x: number; y: number }; + color: { r: number; g: number; b: number; a?: number }; +}) { + const minX = Math.max(0, Math.min(params.a.x, params.b.x, params.c.x)); + const maxX = Math.min(params.width - 1, Math.max(params.a.x, params.b.x, params.c.x)); + const minY = Math.max(0, Math.min(params.a.y, params.b.y, params.c.y)); + const maxY = Math.min(params.height - 1, Math.max(params.a.y, params.b.y, params.c.y)); + const area = + (params.b.x - params.a.x) * (params.c.y - params.a.y) - + (params.b.y - params.a.y) * (params.c.x - params.a.x); + if (area === 0) { + return; + } + for (let y = minY; y <= maxY; y += 1) { + for (let x = minX; x <= maxX; x += 1) { + const w0 = + (params.b.x - params.a.x) * (y - params.a.y) - (params.b.y - params.a.y) * (x - params.a.x); + const w1 = + (params.c.x - params.b.x) * (y - params.b.y) - (params.c.y - params.b.y) * (x - params.b.x); + const w2 = + (params.a.x - params.c.x) * (y - params.c.y) - (params.a.y - params.c.y) * (x - params.c.x); + if ((w0 <= 0 && w1 <= 0 && w2 <= 0) || (w0 >= 0 && w1 >= 0 && w2 >= 0)) { + fillPixel( + params.buf, + x, + y, + params.width, + params.color.r, + params.color.g, + params.color.b, + params.color.a ?? 255, + ); + } + } + } +} + export function renderCatNoncePngBase64(nonce: string): string { const top = "CAT"; const bottom = nonce.toUpperCase(); @@ -128,3 +239,118 @@ export function renderCatNoncePngBase64(nonce: string): string { const png = encodePngRgba(buf, width, height); return png.toString("base64"); } + +export function renderCatFacePngBase64(): string { + const width = 256; + const height = 256; + const buf = Buffer.alloc(width * height * 4, 255); + const outline = { r: 40, g: 40, b: 40 }; + const innerEar = { r: 245, g: 182, b: 193 }; + const nose = { r: 222, g: 102, b: 138 }; + const whisker = { r: 30, g: 30, b: 30 }; + + fillTriangle({ + buf, + width, + height, + a: { x: 62, y: 86 }, + b: { x: 106, y: 18 }, + c: { x: 136, y: 104 }, + color: outline, + }); + fillTriangle({ + buf, + width, + height, + a: { x: 194, y: 86 }, + b: { x: 150, y: 18 }, + c: { x: 120, y: 104 }, + color: outline, + }); + fillTriangle({ + buf, + width, + height, + a: { x: 78, y: 82 }, + b: { x: 106, y: 38 }, + c: { x: 122, y: 92 }, + color: innerEar, + }); + fillTriangle({ + buf, + width, + height, + a: { x: 178, y: 82 }, + b: { x: 150, y: 38 }, + c: { x: 134, y: 92 }, + color: innerEar, + }); + fillEllipse({ + buf, + width, + height, + cx: 128, + cy: 142, + rx: 82, + ry: 78, + color: outline, + }); + fillEllipse({ + buf, + width, + height, + cx: 98, + cy: 126, + rx: 9, + ry: 12, + color: { r: 255, g: 255, b: 255 }, + }); + fillEllipse({ + buf, + width, + height, + cx: 158, + cy: 126, + rx: 9, + ry: 12, + color: { r: 255, g: 255, b: 255 }, + }); + fillEllipse({ + buf, + width, + height, + cx: 128, + cy: 158, + rx: 22, + ry: 18, + color: { r: 255, g: 255, b: 255 }, + }); + fillTriangle({ + buf, + width, + height, + a: { x: 128, y: 150 }, + b: { x: 118, y: 164 }, + c: { x: 138, y: 164 }, + color: nose, + }); + fillRect({ buf, width, height, x: 127, y: 164, w: 2, h: 16, color: whisker }); + fillRect({ buf, width, height, x: 74, y: 161, w: 42, h: 2, color: whisker }); + fillRect({ buf, width, height, x: 140, y: 161, w: 42, h: 2, color: whisker }); + fillRect({ buf, width, height, x: 76, y: 173, w: 38, h: 2, color: whisker }); + fillRect({ buf, width, height, x: 142, y: 173, w: 38, h: 2, color: whisker }); + fillRect({ buf, width, height, x: 85, y: 185, w: 30, h: 2, color: whisker }); + fillRect({ buf, width, height, x: 141, y: 185, w: 30, h: 2, color: whisker }); + drawText({ + buf, + width, + x: Math.floor((width - measureTextWidthPx("CAT", 10)) / 2), + y: 212, + text: "CAT", + scale: 10, + color: outline, + }); + + const png = encodePngRgba(buf, width, height); + return png.toString("base64"); +} diff --git a/src/plugins/stage-bundled-plugin-runtime-deps.test.ts b/src/plugins/stage-bundled-plugin-runtime-deps.test.ts index 7f87bb3df18..68d602c5ce0 100644 --- a/src/plugins/stage-bundled-plugin-runtime-deps.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime-deps.test.ts @@ -102,4 +102,57 @@ describe("stageBundledPluginRuntimeDeps", () => { expect(fs.existsSync(path.join(stagedRoot, "types"))).toBe(false); }); }); + + it("strips non-runtime dependency sections before temp npm staging", async () => { + const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-manifest-"); + writeRepoFile( + repoRoot, + "dist/extensions/amazon-bedrock/package.json", + JSON.stringify( + { + name: "@openclaw/amazon-bedrock-provider", + version: "2026.4.6", + dependencies: { + "@aws-sdk/client-bedrock": "3.1024.0", + }, + devDependencies: { + "@openclaw/plugin-sdk": "workspace:*", + }, + peerDependencies: { + openclaw: "^0.0.0", + }, + peerDependenciesMeta: { + openclaw: { + optional: true, + }, + }, + openclaw: { + bundle: { + stageRuntimeDependencies: true, + }, + }, + }, + null, + 2, + ), + ); + + const stageBundledPluginRuntimeDeps = await loadStageBundledPluginRuntimeDeps(); + const installs: Array> = []; + stageBundledPluginRuntimeDeps({ + repoRoot, + installAttempts: 1, + installPluginRuntimeDepsImpl(params: { packageJson: Record }) { + installs.push(params.packageJson); + }, + }); + + expect(installs).toHaveLength(1); + expect(installs[0]?.dependencies).toEqual({ + "@aws-sdk/client-bedrock": "3.1024.0", + }); + expect(installs[0]?.devDependencies).toBeUndefined(); + expect(installs[0]?.peerDependencies).toBeUndefined(); + expect(installs[0]?.peerDependenciesMeta).toBeUndefined(); + }); }); diff --git a/src/plugins/types.ts b/src/plugins/types.ts index e6237eea686..bf23cbfca6a 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -2017,6 +2017,11 @@ export type OpenClawPluginService = { stop?: (ctx: OpenClawPluginServiceContext) => void | Promise; }; +export type CliBundleMcpMode = + | "claude-config-file" + | "codex-config-overrides" + | "gemini-system-settings"; + /** Plugin-owned CLI backend defaults used by the text-only CLI runner. */ export type CliBackendPlugin = { /** Provider id used in model refs, for example `claude-cli/opus`. */ @@ -2032,6 +2037,7 @@ export type CliBackendPlugin = { liveTest?: { defaultModelRef?: string; defaultImageProbe?: boolean; + defaultMcpProbe?: boolean; docker?: { npmPackage?: string; binaryName?: string; @@ -2040,10 +2046,19 @@ export type CliBackendPlugin = { /** * Whether OpenClaw should inject bundle MCP config for this backend. * - * Keep this opt-in. Only backends that explicitly consume an MCP config file - * should enable it. + * Keep this opt-in. Only backends that explicitly consume OpenClaw's bundle + * MCP bridge should enable it. */ bundleMcp?: boolean; + /** + * Provider-owned bundle MCP integration strategy. + * + * Different CLIs wire MCP through different surfaces: + * - Claude: `--strict-mcp-config --mcp-config` + * - Codex: `-c mcp_servers=...` + * - Gemini: system-level `settings.json` + */ + bundleMcpMode?: CliBundleMcpMode; /** * Optional config normalizer applied after user overrides merge. *