mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-28 06:31:11 +00:00
feat: unify live cli backend probes
This commit is contained in:
parent
dbc7710938
commit
c2f9de3935
28 changed files with 1209 additions and 255 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-"),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
58
src/gateway/live-agent-probes.test.ts
Normal file
58
src/gateway/live-agent-probes.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
190
src/gateway/live-agent-probes.ts
Normal file
190
src/gateway/live-agent-probes.ts
Normal 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>"}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue