feat: unify live cli backend probes

This commit is contained in:
Peter Steinberger 2026-04-07 10:34:39 +01:00
parent dbc7710938
commit c2f9de3935
No known key found for this signature in database
28 changed files with 1209 additions and 255 deletions

View file

@ -360,7 +360,7 @@ OpenClaw ships with the piai 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.

View file

@ -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 <generated-file>`
- 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.

View file

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

View file

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

View file

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

View file

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

View file

@ -9,7 +9,7 @@ const GEMINI_MODEL_ALIASES: Record<string, string> = {
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",

View file

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

View file

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

View file

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

View file

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

View file

@ -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", () => {

View file

@ -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<string, FallbackCliBackendPolicy> = {};
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,
};
}

View file

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

View file

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

View file

@ -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<string, { url?: string; headers?: Record<string, string> }>;
};
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?.();
});
});

View file

@ -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<string, string>;
};
function resolveBundleMcpMode(mode: CliBundleMcpMode | undefined): CliBundleMcpMode {
return mode ?? "claude-config-file";
}
async function readExternalMcpConfig(configPath: string): Promise<BundleMcpConfig> {
try {
const raw = JSON.parse(await fs.readFile(configPath, "utf-8")) as unknown;
@ -28,6 +34,17 @@ async function readExternalMcpConfig(configPath: string): Promise<BundleMcpConfi
}
}
async function readJsonObject(filePath: string): Promise<Record<string, unknown>> {
try {
const raw = JSON.parse(await fs.readFile(filePath, "utf-8")) as unknown;
return raw && typeof raw === "object" && !Array.isArray(raw)
? ({ ...raw } as Record<string, unknown>)
: {};
} 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<string, unknown> {
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<string, string> | 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<string, unknown> {
const next: Record<string, unknown> = {};
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<string, string> = {};
const envHeaders: Record<string, string> = {};
for (const [name, value] of Object.entries(httpHeaders)) {
const decoded = decodeHeaderEnvPlaceholder(value);
if (!decoded) {
staticHeaders[name] = value;
continue;
}
if (decoded.bearer && 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<string, string> | 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<string, string> | undefined,
): Record<string, unknown> {
const next: Record<string, unknown> = {};
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<string, string> | undefined,
): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> }> {
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<string, unknown>;
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<string, string>;
}): Promise<PreparedCliBundleMcpConfig> {
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 });
},
};
});
}

View file

@ -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<void> }> {
const imageRoot = path.join(resolvePreferredOpenClawTmpDir(), "openclaw-cli-images");
export async function writeCliImages(params: {
backend: CliBackendConfig;
workspaceDir: string;
images: ImageContent[];
}): Promise<{ paths: string[]; cleanup: () => Promise<void> }> {
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 {

View file

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

View file

@ -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. */

View file

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

View file

@ -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<CronListCliResult["jobs"]>[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<T>(args: string[], env: NodeJS.ProcessEnv): Promise<T> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@ -394,8 +342,7 @@ export async function verifyCliBackendImageProbe(params: {
tempDir: string;
bootstrapWorkspace: BootstrapWorkspaceContext | null;
}): Promise<void> {
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<void> {
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<CronListJob | undefined> {
const cronList = await runOpenClawCliJson<CronListCliResult>(
[
"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)
);
}

View file

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

View file

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

View file

@ -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<CronListCliResult["jobs"]>[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<T>(args: string[], env: NodeJS.ProcessEnv): Promise<T> {
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<CronListJob | undefined> {
const cronList = await runOpenClawCliJson<CronListCliResult>(
[
"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 ?? "<missing>"}`);
}
if (params.job.payload?.kind !== "agentTurn") {
throw new Error(`cron payload kind mismatch: ${params.job.payload?.kind ?? "<missing>"}`);
}
if (params.job.payload?.message !== params.expectedMessage) {
throw new Error(`cron payload message mismatch: ${params.job.payload?.message ?? "<missing>"}`);
}
const expectedAgentId = params.expectedAgentId ?? "dev";
if (params.job.agentId !== expectedAgentId) {
throw new Error(`cron agentId mismatch: ${params.job.agentId ?? "<missing>"}`);
}
if (params.job.sessionKey !== params.expectedSessionKey) {
throw new Error(`cron sessionKey mismatch: ${params.job.sessionKey ?? "<missing>"}`);
}
if (params.job.sessionTarget !== `session:${params.expectedSessionKey}`) {
throw new Error(`cron sessionTarget mismatch: ${params.job.sessionTarget ?? "<missing>"}`);
}
}

View file

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

View file

@ -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<Record<string, unknown>> = [];
stageBundledPluginRuntimeDeps({
repoRoot,
installAttempts: 1,
installPluginRuntimeDepsImpl(params: { packageJson: Record<string, unknown> }) {
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();
});
});

View file

@ -2017,6 +2017,11 @@ export type OpenClawPluginService = {
stop?: (ctx: OpenClawPluginServiceContext) => void | Promise<void>;
};
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.
*