mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 09:20:42 +00:00
* codex harness mcp hook parity * tighten codex hook parity floor * prove security-style mcp hook blocking * bound native hook relay key handling * clarify permission relay defers to provider * harden native hook relay approvals * fix(agents): bound native hook relay JSON work budget --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
968 lines
32 KiB
TypeScript
968 lines
32 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import type { CliBackendConfig } from "../config/types.js";
|
|
import type {
|
|
CliBackendAuthEpochMode,
|
|
CliBackendNormalizeConfigContext,
|
|
CliBundleMcpMode,
|
|
} from "../plugins/types.js";
|
|
import {
|
|
__testing as cliBackendsTesting,
|
|
resolveCliBackendConfig,
|
|
resolveCliBackendLiveTest,
|
|
} from "./cli-backends.js";
|
|
|
|
type RuntimeBackendEntry = ReturnType<
|
|
(typeof import("../plugins/cli-backends.runtime.js"))["resolveRuntimeCliBackends"]
|
|
>[number];
|
|
type SetupBackendEntry = NonNullable<
|
|
ReturnType<(typeof import("../plugins/setup-registry.js"))["resolvePluginSetupCliBackend"]>
|
|
>;
|
|
|
|
let runtimeBackendEntries: RuntimeBackendEntry[] = [];
|
|
let setupBackendEntries: SetupBackendEntry[] = [];
|
|
|
|
function createBackendEntry(params: {
|
|
pluginId: string;
|
|
id: string;
|
|
config: CliBackendConfig;
|
|
bundleMcp?: boolean;
|
|
bundleMcpMode?: CliBundleMcpMode;
|
|
defaultAuthProfileId?: string;
|
|
authEpochMode?: CliBackendAuthEpochMode;
|
|
prepareExecution?: () => Promise<null>;
|
|
normalizeConfig?: (
|
|
config: CliBackendConfig,
|
|
context?: CliBackendNormalizeConfigContext,
|
|
) => CliBackendConfig;
|
|
}) {
|
|
return {
|
|
pluginId: params.pluginId,
|
|
source: "test",
|
|
backend: {
|
|
id: params.id,
|
|
config: params.config,
|
|
...(params.bundleMcp ? { bundleMcp: params.bundleMcp } : {}),
|
|
...(params.bundleMcpMode ? { bundleMcpMode: params.bundleMcpMode } : {}),
|
|
...(params.defaultAuthProfileId ? { defaultAuthProfileId: params.defaultAuthProfileId } : {}),
|
|
...(params.authEpochMode ? { authEpochMode: params.authEpochMode } : {}),
|
|
...(params.prepareExecution ? { prepareExecution: params.prepareExecution } : {}),
|
|
...(params.normalizeConfig ? { normalizeConfig: params.normalizeConfig } : {}),
|
|
liveTest: {
|
|
defaultModelRef:
|
|
params.id === "claude-cli"
|
|
? "claude-cli/claude-sonnet-4-6"
|
|
: params.id === "codex-cli"
|
|
? "codex-cli/gpt-5.5"
|
|
: params.id === "google-gemini-cli"
|
|
? "google-gemini-cli/gemini-3-flash-preview"
|
|
: undefined,
|
|
defaultImageProbe: true,
|
|
defaultMcpProbe: true,
|
|
docker: {
|
|
npmPackage:
|
|
params.id === "claude-cli"
|
|
? "@anthropic-ai/claude-code"
|
|
: params.id === "codex-cli"
|
|
? "@openai/codex@0.125.0"
|
|
: params.id === "google-gemini-cli"
|
|
? "@google/gemini-cli"
|
|
: undefined,
|
|
binaryName:
|
|
params.id === "claude-cli"
|
|
? "claude"
|
|
: params.id === "codex-cli"
|
|
? "codex"
|
|
: params.id === "google-gemini-cli"
|
|
? "gemini"
|
|
: undefined,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createRuntimeBackendEntry(params: Parameters<typeof createBackendEntry>[0]) {
|
|
const entry = createBackendEntry(params);
|
|
return {
|
|
...entry.backend,
|
|
pluginId: entry.pluginId,
|
|
} satisfies RuntimeBackendEntry;
|
|
}
|
|
|
|
function createClaudeCliOverrideConfig(config: CliBackendConfig): OpenClawConfig {
|
|
return {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": config,
|
|
},
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
}
|
|
|
|
const NORMALIZED_CLAUDE_FALLBACK_ARGS = [
|
|
"-p",
|
|
"--output-format",
|
|
"stream-json",
|
|
"--setting-sources",
|
|
"user",
|
|
];
|
|
|
|
const NORMALIZED_CLAUDE_FALLBACK_RESUME_ARGS = [
|
|
"-p",
|
|
"--resume",
|
|
"{sessionId}",
|
|
"--setting-sources",
|
|
"user",
|
|
];
|
|
|
|
function isTestYoloConfig(context?: CliBackendNormalizeConfigContext): boolean {
|
|
const agentExec = context?.agentId
|
|
? context.config?.agents?.list?.find((agent) => agent.id === context.agentId)?.tools?.exec
|
|
: undefined;
|
|
const exec = agentExec ?? context?.config?.tools?.exec;
|
|
return (exec?.security ?? "full") === "full" && (exec?.ask ?? "off") === "off";
|
|
}
|
|
|
|
function normalizeTestPermissionMode(context?: CliBackendNormalizeConfigContext): {
|
|
mode?: string;
|
|
overrideExisting: boolean;
|
|
} {
|
|
return isTestYoloConfig(context)
|
|
? { mode: "bypassPermissions", overrideExisting: false }
|
|
: { overrideExisting: false };
|
|
}
|
|
|
|
function normalizeTestClaudeArgs(
|
|
args: string[] | undefined,
|
|
permission: { mode?: string; overrideExisting: boolean },
|
|
): string[] | undefined {
|
|
if (!args) {
|
|
return permission.mode ? ["--permission-mode", permission.mode] : args;
|
|
}
|
|
const normalized: string[] = [];
|
|
let hasSettingSources = false;
|
|
let hasPermissionMode = false;
|
|
for (let i = 0; i < args.length; i += 1) {
|
|
const arg = args[i];
|
|
if (arg === "--dangerously-skip-permissions") {
|
|
continue;
|
|
}
|
|
if (arg === "--setting-sources") {
|
|
const maybeValue = args[i + 1];
|
|
if (maybeValue && !maybeValue.startsWith("-")) {
|
|
hasSettingSources = true;
|
|
normalized.push(arg, "user");
|
|
i += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--setting-sources=")) {
|
|
hasSettingSources = true;
|
|
normalized.push("--setting-sources=user");
|
|
continue;
|
|
}
|
|
if (arg === "--permission-mode") {
|
|
const maybeValue = args[i + 1];
|
|
if (maybeValue && !maybeValue.startsWith("-")) {
|
|
hasPermissionMode = true;
|
|
if (!permission.overrideExisting) {
|
|
normalized.push(arg, maybeValue);
|
|
}
|
|
i += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--permission-mode=")) {
|
|
const maybeValue = arg.slice("--permission-mode=".length).trim();
|
|
if (maybeValue.length > 0 && !maybeValue.startsWith("-")) {
|
|
hasPermissionMode = true;
|
|
if (!permission.overrideExisting) {
|
|
normalized.push(`--permission-mode=${maybeValue}`);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
normalized.push(arg);
|
|
}
|
|
if (!hasSettingSources) {
|
|
normalized.push("--setting-sources", "user");
|
|
}
|
|
if (permission.mode && (!hasPermissionMode || permission.overrideExisting)) {
|
|
normalized.push("--permission-mode", permission.mode);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeTestClaudeBackendConfig(
|
|
config: CliBackendConfig,
|
|
context?: CliBackendNormalizeConfigContext,
|
|
): CliBackendConfig {
|
|
const permission = normalizeTestPermissionMode(context);
|
|
return {
|
|
...config,
|
|
args: normalizeTestClaudeArgs(config.args, permission),
|
|
resumeArgs: normalizeTestClaudeArgs(config.resumeArgs, permission),
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
cliBackendsTesting.resetDepsForTest();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
runtimeBackendEntries = [
|
|
createRuntimeBackendEntry({
|
|
pluginId: "anthropic",
|
|
id: "claude-cli",
|
|
bundleMcp: true,
|
|
bundleMcpMode: "claude-config-file",
|
|
config: {
|
|
command: "claude",
|
|
args: [
|
|
"stream-json",
|
|
"--include-partial-messages",
|
|
"--verbose",
|
|
"--setting-sources",
|
|
"user",
|
|
"--allowedTools",
|
|
"mcp__openclaw__*",
|
|
],
|
|
resumeArgs: [
|
|
"stream-json",
|
|
"--include-partial-messages",
|
|
"--verbose",
|
|
"--setting-sources",
|
|
"user",
|
|
"--allowedTools",
|
|
"mcp__openclaw__*",
|
|
"--resume",
|
|
"{sessionId}",
|
|
],
|
|
output: "jsonl",
|
|
input: "stdin",
|
|
imageArg: "@",
|
|
imagePathScope: "workspace",
|
|
clearEnv: [
|
|
"ANTHROPIC_API_KEY",
|
|
"ANTHROPIC_API_KEY_OLD",
|
|
"ANTHROPIC_API_TOKEN",
|
|
"ANTHROPIC_AUTH_TOKEN",
|
|
"ANTHROPIC_BASE_URL",
|
|
"ANTHROPIC_CUSTOM_HEADERS",
|
|
"ANTHROPIC_OAUTH_TOKEN",
|
|
"ANTHROPIC_UNIX_SOCKET",
|
|
"CLAUDE_CONFIG_DIR",
|
|
"CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR",
|
|
"CLAUDE_CODE_ENTRYPOINT",
|
|
"CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
|
|
"CLAUDE_CODE_OAUTH_SCOPES",
|
|
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
"CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR",
|
|
"CLAUDE_CODE_PLUGIN_CACHE_DIR",
|
|
"CLAUDE_CODE_PLUGIN_SEED_DIR",
|
|
"CLAUDE_CODE_REMOTE",
|
|
"CLAUDE_CODE_USE_COWORK_PLUGINS",
|
|
"CLAUDE_CODE_USE_BEDROCK",
|
|
"CLAUDE_CODE_USE_FOUNDRY",
|
|
"CLAUDE_CODE_USE_VERTEX",
|
|
],
|
|
},
|
|
normalizeConfig: normalizeTestClaudeBackendConfig,
|
|
}),
|
|
createRuntimeBackendEntry({
|
|
pluginId: "openai",
|
|
id: "codex-cli",
|
|
bundleMcp: true,
|
|
bundleMcpMode: "codex-config-overrides",
|
|
config: {
|
|
command: "codex",
|
|
args: [
|
|
"exec",
|
|
"--json",
|
|
"--color",
|
|
"never",
|
|
"--sandbox",
|
|
"workspace-write",
|
|
"-c",
|
|
'service_tier="fast"',
|
|
"--skip-git-repo-check",
|
|
],
|
|
resumeArgs: [
|
|
"exec",
|
|
"resume",
|
|
"{sessionId}",
|
|
"-c",
|
|
'sandbox_mode="workspace-write"',
|
|
"-c",
|
|
'service_tier="fast"',
|
|
"--skip-git-repo-check",
|
|
],
|
|
systemPromptFileConfigArg: "-c",
|
|
systemPromptFileConfigKey: "model_instructions_file",
|
|
systemPromptWhen: "first",
|
|
imagePathScope: "workspace",
|
|
reliability: {
|
|
watchdog: {
|
|
fresh: {
|
|
noOutputTimeoutRatio: 0.8,
|
|
minMs: 60_000,
|
|
maxMs: 180_000,
|
|
},
|
|
resume: {
|
|
noOutputTimeoutRatio: 0.3,
|
|
minMs: 60_000,
|
|
maxMs: 180_000,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
createRuntimeBackendEntry({
|
|
pluginId: "google",
|
|
id: "google-gemini-cli",
|
|
bundleMcp: true,
|
|
bundleMcpMode: "gemini-system-settings",
|
|
config: {
|
|
command: "gemini",
|
|
args: ["--skip-trust", "--output-format", "json", "--prompt", "{prompt}"],
|
|
resumeArgs: [
|
|
"--skip-trust",
|
|
"--resume",
|
|
"{sessionId}",
|
|
"--output-format",
|
|
"json",
|
|
"--prompt",
|
|
"{prompt}",
|
|
],
|
|
imageArg: "@",
|
|
imagePathScope: "workspace",
|
|
modelArg: "--model",
|
|
sessionMode: "existing",
|
|
sessionIdFields: ["session_id", "sessionId"],
|
|
modelAliases: { pro: "gemini-3.1-pro-preview" },
|
|
},
|
|
}),
|
|
];
|
|
const claudeBackend = runtimeBackendEntries.find((entry) => entry.id === "claude-cli");
|
|
setupBackendEntries = claudeBackend
|
|
? [
|
|
{
|
|
pluginId: claudeBackend.pluginId,
|
|
backend: {
|
|
...claudeBackend,
|
|
config: {
|
|
...claudeBackend.config,
|
|
sessionArg: "--session-id",
|
|
sessionMode: "always",
|
|
systemPromptFileArg: "--append-system-prompt-file",
|
|
systemPromptWhen: "first",
|
|
},
|
|
},
|
|
},
|
|
]
|
|
: [];
|
|
cliBackendsTesting.setDepsForTest({
|
|
resolveRuntimeCliBackends: () => runtimeBackendEntries,
|
|
resolvePluginSetupCliBackend: ({ backend }) => {
|
|
return setupBackendEntries.find((entry) => entry.backend.id === backend);
|
|
},
|
|
});
|
|
});
|
|
|
|
describe("resolveCliBackendConfig reliability merge", () => {
|
|
it("defaults codex-cli fresh sandboxing and config-pinned resume sandboxing", () => {
|
|
const resolved = resolveCliBackendConfig("codex-cli");
|
|
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved?.config.args).toEqual([
|
|
"exec",
|
|
"--json",
|
|
"--color",
|
|
"never",
|
|
"--sandbox",
|
|
"workspace-write",
|
|
"-c",
|
|
'service_tier="fast"',
|
|
"--skip-git-repo-check",
|
|
]);
|
|
expect(resolved?.config.resumeArgs).toEqual([
|
|
"exec",
|
|
"resume",
|
|
"{sessionId}",
|
|
"-c",
|
|
'sandbox_mode="workspace-write"',
|
|
"-c",
|
|
'service_tier="fast"',
|
|
"--skip-git-repo-check",
|
|
]);
|
|
});
|
|
|
|
it("deep-merges reliability watchdog overrides for codex", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"codex-cli": {
|
|
command: "codex",
|
|
reliability: {
|
|
watchdog: {
|
|
resume: {
|
|
noOutputTimeoutMs: 42_000,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
|
|
const resolved = resolveCliBackendConfig("codex-cli", cfg);
|
|
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved?.config.reliability?.watchdog?.resume?.noOutputTimeoutMs).toBe(42_000);
|
|
// Ensure defaults are retained when only one field is overridden.
|
|
expect(resolved?.config.reliability?.watchdog?.resume?.noOutputTimeoutRatio).toBe(0.3);
|
|
expect(resolved?.config.reliability?.watchdog?.resume?.minMs).toBe(60_000);
|
|
expect(resolved?.config.reliability?.watchdog?.resume?.maxMs).toBe(180_000);
|
|
expect(resolved?.config.reliability?.watchdog?.fresh?.noOutputTimeoutRatio).toBe(0.8);
|
|
});
|
|
});
|
|
|
|
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.5",
|
|
defaultImageProbe: true,
|
|
defaultMcpProbe: true,
|
|
dockerNpmPackage: "@openai/codex@0.125.0",
|
|
dockerBinaryName: "codex",
|
|
});
|
|
});
|
|
|
|
it("returns plugin-owned live smoke metadata for gemini", () => {
|
|
expect(resolveCliBackendLiveTest("google-gemini-cli")).toEqual({
|
|
defaultModelRef: "google-gemini-cli/gemini-3-flash-preview",
|
|
defaultImageProbe: true,
|
|
defaultMcpProbe: true,
|
|
dockerNpmPackage: "@google/gemini-cli",
|
|
dockerBinaryName: "gemini",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveCliBackendConfig claude-cli defaults", () => {
|
|
it("derives bypassPermissions from OpenClaw's default YOLO exec policy", () => {
|
|
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");
|
|
expect(resolved?.config.args).toContain("--verbose");
|
|
expect(resolved?.config.args).toContain("--setting-sources");
|
|
expect(resolved?.config.args).toContain("user");
|
|
expect(resolved?.config.args).toContain("--allowedTools");
|
|
expect(resolved?.config.args).toContain("mcp__openclaw__*");
|
|
expect(resolved?.config.args).toContain("--permission-mode");
|
|
expect(resolved?.config.args).toContain("bypassPermissions");
|
|
expect(resolved?.config.args).not.toContain("--dangerously-skip-permissions");
|
|
expect(resolved?.config.input).toBe("stdin");
|
|
expect(resolved?.config.imageArg).toBe("@");
|
|
expect(resolved?.config.imagePathScope).toBe("workspace");
|
|
expect(resolved?.config.resumeArgs).toContain("stream-json");
|
|
expect(resolved?.config.resumeArgs).toContain("--include-partial-messages");
|
|
expect(resolved?.config.resumeArgs).toContain("--verbose");
|
|
expect(resolved?.config.resumeArgs).toContain("--setting-sources");
|
|
expect(resolved?.config.resumeArgs).toContain("user");
|
|
expect(resolved?.config.resumeArgs).toContain("--allowedTools");
|
|
expect(resolved?.config.resumeArgs).toContain("mcp__openclaw__*");
|
|
expect(resolved?.config.resumeArgs).toContain("--permission-mode");
|
|
expect(resolved?.config.resumeArgs).toContain("bypassPermissions");
|
|
expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions");
|
|
});
|
|
|
|
it("keeps Claude permission mode unset when OpenClaw exec policy is not YOLO", () => {
|
|
const resolved = resolveCliBackendConfig("claude-cli", {
|
|
tools: { exec: { security: "allowlist", ask: "on-miss" } },
|
|
});
|
|
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved?.config.args).not.toContain("--permission-mode");
|
|
expect(resolved?.config.args).not.toContain("bypassPermissions");
|
|
expect(resolved?.config.resumeArgs).not.toContain("--permission-mode");
|
|
expect(resolved?.config.resumeArgs).not.toContain("bypassPermissions");
|
|
});
|
|
|
|
it("derives Claude permission mode from per-agent exec policy when an agent id is known", () => {
|
|
const cfg = {
|
|
tools: { exec: { security: "full", ask: "off" } },
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "reviewer",
|
|
tools: { exec: { security: "allowlist", ask: "on-miss" } },
|
|
},
|
|
{
|
|
id: "builder",
|
|
tools: { exec: { security: "full", ask: "off" } },
|
|
},
|
|
],
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
|
|
const reviewer = resolveCliBackendConfig("claude-cli", cfg, { agentId: "reviewer" });
|
|
const builder = resolveCliBackendConfig("claude-cli", cfg, { agentId: "builder" });
|
|
|
|
expect(reviewer?.config.args).not.toContain("--permission-mode");
|
|
expect(reviewer?.config.resumeArgs).not.toContain("--permission-mode");
|
|
expect(builder?.config.args).toContain("--permission-mode");
|
|
expect(builder?.config.args).toContain("bypassPermissions");
|
|
expect(builder?.config.resumeArgs).toContain("--permission-mode");
|
|
expect(builder?.config.resumeArgs).toContain("bypassPermissions");
|
|
});
|
|
|
|
it("uses existing exec policy and raw Claude args as permission overrides", () => {
|
|
const safe = resolveCliBackendConfig("claude-cli", {
|
|
tools: { exec: { security: "full", ask: "off" } },
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": {
|
|
command: "claude",
|
|
args: ["-p", "--permission-mode", "default"],
|
|
resumeArgs: ["-p", "--permission-mode=default", "--resume", "{sessionId}"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const yolo = resolveCliBackendConfig("claude-cli", {
|
|
tools: { exec: { security: "deny", ask: "always" } },
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": {
|
|
command: "claude",
|
|
args: ["-p", "--permission-mode", "bypassPermissions"],
|
|
resumeArgs: ["-p", "--permission-mode=bypassPermissions", "--resume", "{sessionId}"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(safe?.config.args).toContain("default");
|
|
expect(safe?.config.args).not.toContain("bypassPermissions");
|
|
expect(yolo?.config.args).toContain("--permission-mode");
|
|
expect(yolo?.config.args).toContain("bypassPermissions");
|
|
});
|
|
|
|
it("retains default claude safety args when only command is overridden", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": {
|
|
command: "/usr/local/bin/claude",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
|
|
const resolved = resolveCliBackendConfig("claude-cli", cfg);
|
|
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved?.config.command).toBe("/usr/local/bin/claude");
|
|
expect(resolved?.config.args).toContain("--setting-sources");
|
|
expect(resolved?.config.args).toContain("user");
|
|
expect(resolved?.config.args).toContain("--permission-mode");
|
|
expect(resolved?.config.args).toContain("bypassPermissions");
|
|
expect(resolved?.config.resumeArgs).toContain("--setting-sources");
|
|
expect(resolved?.config.resumeArgs).toContain("user");
|
|
expect(resolved?.config.resumeArgs).toContain("--permission-mode");
|
|
expect(resolved?.config.resumeArgs).toContain("bypassPermissions");
|
|
expect(resolved?.config.env).not.toHaveProperty("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST");
|
|
expect(resolved?.config.clearEnv).toContain("ANTHROPIC_API_TOKEN");
|
|
expect(resolved?.config.clearEnv).toContain("ANTHROPIC_BASE_URL");
|
|
expect(resolved?.config.clearEnv).toContain("ANTHROPIC_CUSTOM_HEADERS");
|
|
expect(resolved?.config.clearEnv).toContain("ANTHROPIC_OAUTH_TOKEN");
|
|
expect(resolved?.config.clearEnv).toContain("CLAUDE_CONFIG_DIR");
|
|
expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_OAUTH_TOKEN");
|
|
expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_PLUGIN_CACHE_DIR");
|
|
expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_PLUGIN_SEED_DIR");
|
|
expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_REMOTE");
|
|
expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_USE_COWORK_PLUGINS");
|
|
});
|
|
|
|
it("drops legacy skip-permissions overrides without inventing bypassPermissions under safe policy", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": {
|
|
command: "claude",
|
|
args: ["-p", "--dangerously-skip-permissions", "--output-format", "json"],
|
|
resumeArgs: [
|
|
"-p",
|
|
"--dangerously-skip-permissions",
|
|
"--output-format",
|
|
"json",
|
|
"--resume",
|
|
"{sessionId}",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
tools: { exec: { security: "allowlist", ask: "on-miss" } },
|
|
} satisfies OpenClawConfig;
|
|
|
|
const resolved = resolveCliBackendConfig("claude-cli", cfg);
|
|
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved?.config.args).not.toContain("--dangerously-skip-permissions");
|
|
expect(resolved?.config.args).not.toContain("--permission-mode");
|
|
expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions");
|
|
expect(resolved?.config.resumeArgs).not.toContain("--permission-mode");
|
|
});
|
|
|
|
it("keeps explicit permission-mode overrides while removing legacy skip flag", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": {
|
|
command: "claude",
|
|
args: ["-p", "--dangerously-skip-permissions", "--permission-mode", "acceptEdits"],
|
|
resumeArgs: [
|
|
"-p",
|
|
"--dangerously-skip-permissions",
|
|
"--permission-mode=acceptEdits",
|
|
"--resume",
|
|
"{sessionId}",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
|
|
const resolved = resolveCliBackendConfig("claude-cli", cfg);
|
|
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved?.config.args).not.toContain("--dangerously-skip-permissions");
|
|
expect(resolved?.config.args).toEqual([
|
|
"-p",
|
|
"--permission-mode",
|
|
"acceptEdits",
|
|
"--setting-sources",
|
|
"user",
|
|
]);
|
|
expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions");
|
|
expect(resolved?.config.resumeArgs).toEqual([
|
|
"-p",
|
|
"--permission-mode=acceptEdits",
|
|
"--resume",
|
|
"{sessionId}",
|
|
"--setting-sources",
|
|
"user",
|
|
]);
|
|
expect(resolved?.config.args).not.toContain("bypassPermissions");
|
|
expect(resolved?.config.resumeArgs).not.toContain("bypassPermissions");
|
|
});
|
|
|
|
it("forces project or local setting-source overrides back to user-only", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": {
|
|
command: "claude",
|
|
args: ["-p", "--setting-sources", "project", "--permission-mode", "acceptEdits"],
|
|
resumeArgs: [
|
|
"-p",
|
|
"--setting-sources=local,user",
|
|
"--resume",
|
|
"{sessionId}",
|
|
"--permission-mode=acceptEdits",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
|
|
const resolved = resolveCliBackendConfig("claude-cli", cfg);
|
|
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved?.config.args).toEqual([
|
|
"-p",
|
|
"--setting-sources",
|
|
"user",
|
|
"--permission-mode",
|
|
"acceptEdits",
|
|
]);
|
|
expect(resolved?.config.resumeArgs).toEqual([
|
|
"-p",
|
|
"--setting-sources=user",
|
|
"--resume",
|
|
"{sessionId}",
|
|
"--permission-mode=acceptEdits",
|
|
]);
|
|
});
|
|
|
|
it("falls back to user-only setting sources when a custom override leaves the flag without a value", () => {
|
|
const cfg = {
|
|
...createClaudeCliOverrideConfig({
|
|
command: "claude",
|
|
args: ["-p", "--setting-sources", "--output-format", "stream-json"],
|
|
resumeArgs: ["-p", "--setting-sources", "--resume", "{sessionId}"],
|
|
}),
|
|
tools: { exec: { security: "allowlist", ask: "on-miss" } },
|
|
} satisfies OpenClawConfig;
|
|
|
|
const resolved = resolveCliBackendConfig("claude-cli", cfg);
|
|
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved?.config.args).toEqual(NORMALIZED_CLAUDE_FALLBACK_ARGS);
|
|
expect(resolved?.config.resumeArgs).toEqual(NORMALIZED_CLAUDE_FALLBACK_RESUME_ARGS);
|
|
});
|
|
|
|
it("drops malformed permission-mode overrides without adding bypassPermissions under safe policy", () => {
|
|
const cfg = {
|
|
...createClaudeCliOverrideConfig({
|
|
command: "claude",
|
|
args: ["-p", "--permission-mode", "--output-format", "stream-json"],
|
|
resumeArgs: ["-p", "--permission-mode=--resume", "--resume", "{sessionId}"],
|
|
}),
|
|
tools: { exec: { security: "allowlist", ask: "on-miss" } },
|
|
} satisfies OpenClawConfig;
|
|
|
|
const resolved = resolveCliBackendConfig("claude-cli", cfg);
|
|
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved?.config.args).toEqual(NORMALIZED_CLAUDE_FALLBACK_ARGS);
|
|
expect(resolved?.config.resumeArgs).toEqual(NORMALIZED_CLAUDE_FALLBACK_RESUME_ARGS);
|
|
});
|
|
|
|
it("leaves permission-mode unset when custom args omit it under safe policy", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": {
|
|
command: "claude",
|
|
args: ["-p", "--output-format", "stream-json", "--verbose"],
|
|
resumeArgs: [
|
|
"-p",
|
|
"--output-format",
|
|
"stream-json",
|
|
"--verbose",
|
|
"--resume",
|
|
"{sessionId}",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
tools: { exec: { security: "allowlist", ask: "on-miss" } },
|
|
} satisfies OpenClawConfig;
|
|
|
|
const resolved = resolveCliBackendConfig("claude-cli", cfg);
|
|
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved?.config.args).toContain("--setting-sources");
|
|
expect(resolved?.config.args).toContain("user");
|
|
expect(resolved?.config.args).not.toContain("--permission-mode");
|
|
expect(resolved?.config.resumeArgs).toContain("--setting-sources");
|
|
expect(resolved?.config.resumeArgs).toContain("user");
|
|
expect(resolved?.config.resumeArgs).not.toContain("--permission-mode");
|
|
});
|
|
|
|
it("keeps hardened clearEnv defaults when custom claude env overrides are merged", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": {
|
|
command: "claude",
|
|
env: {
|
|
SAFE_CUSTOM: "ok",
|
|
ANTHROPIC_BASE_URL: "https://evil.example.com/v1",
|
|
},
|
|
clearEnv: ["EXTRA_CLEAR"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
|
|
const resolved = resolveCliBackendConfig("claude-cli", cfg);
|
|
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved?.config.env).toEqual({
|
|
SAFE_CUSTOM: "ok",
|
|
ANTHROPIC_BASE_URL: "https://evil.example.com/v1",
|
|
});
|
|
expect(resolved?.config.clearEnv).toContain("ANTHROPIC_BASE_URL");
|
|
expect(resolved?.config.clearEnv).toContain("ANTHROPIC_API_TOKEN");
|
|
expect(resolved?.config.clearEnv).toContain("ANTHROPIC_CUSTOM_HEADERS");
|
|
expect(resolved?.config.clearEnv).toContain("ANTHROPIC_OAUTH_TOKEN");
|
|
expect(resolved?.config.clearEnv).toContain("CLAUDE_CONFIG_DIR");
|
|
expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_OAUTH_TOKEN");
|
|
expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_PLUGIN_CACHE_DIR");
|
|
expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_PLUGIN_SEED_DIR");
|
|
expect(resolved?.config.clearEnv).toContain("EXTRA_CLEAR");
|
|
});
|
|
|
|
it("normalizes override-only claude-cli config when the plugin registry is absent", () => {
|
|
runtimeBackendEntries = [];
|
|
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": {
|
|
command: "/usr/local/bin/claude",
|
|
args: ["-p", "--output-format", "json"],
|
|
resumeArgs: ["-p", "--output-format", "json", "--resume", "{sessionId}"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
|
|
const resolved = resolveCliBackendConfig("claude-cli", cfg);
|
|
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved?.bundleMcp).toBe(true);
|
|
expect(resolved?.bundleMcpMode).toBe("claude-config-file");
|
|
expect(resolved?.config.args).toEqual([
|
|
"-p",
|
|
"--output-format",
|
|
"json",
|
|
"--setting-sources",
|
|
"user",
|
|
"--permission-mode",
|
|
"bypassPermissions",
|
|
]);
|
|
expect(resolved?.config.resumeArgs).toEqual([
|
|
"-p",
|
|
"--output-format",
|
|
"json",
|
|
"--resume",
|
|
"{sessionId}",
|
|
"--setting-sources",
|
|
"user",
|
|
"--permission-mode",
|
|
"bypassPermissions",
|
|
]);
|
|
expect(resolved?.config.systemPromptFileArg).toBe("--append-system-prompt-file");
|
|
expect(resolved?.config.systemPromptWhen).toBe("first");
|
|
expect(resolved?.config.sessionArg).toBe("--session-id");
|
|
expect(resolved?.config.sessionMode).toBe("always");
|
|
expect(resolved?.config.input).toBe("stdin");
|
|
expect(resolved?.config.output).toBe("jsonl");
|
|
});
|
|
});
|
|
|
|
describe("resolveCliBackendConfig google-gemini-cli defaults", () => {
|
|
it("uses Gemini CLI json args and existing-session resume mode", () => {
|
|
const resolved = resolveCliBackendConfig("google-gemini-cli");
|
|
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved?.bundleMcp).toBe(true);
|
|
expect(resolved?.bundleMcpMode).toBe("gemini-system-settings");
|
|
expect(resolved?.config.args).toEqual([
|
|
"--skip-trust",
|
|
"--output-format",
|
|
"json",
|
|
"--prompt",
|
|
"{prompt}",
|
|
]);
|
|
expect(resolved?.config.resumeArgs).toEqual([
|
|
"--skip-trust",
|
|
"--resume",
|
|
"{sessionId}",
|
|
"--output-format",
|
|
"json",
|
|
"--prompt",
|
|
"{prompt}",
|
|
]);
|
|
expect(resolved?.config.modelArg).toBe("--model");
|
|
expect(resolved?.config.sessionMode).toBe("existing");
|
|
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");
|
|
expect(resolved?.defaultAuthProfileId).toBeUndefined();
|
|
expect(resolved?.authEpochMode).toBeUndefined();
|
|
expect(resolved?.prepareExecution).toBeUndefined();
|
|
expect(resolved?.config.systemPromptFileConfigArg).toBe("-c");
|
|
expect(resolved?.config.systemPromptFileConfigKey).toBe("model_instructions_file");
|
|
expect(resolved?.config.systemPromptWhen).toBe("first");
|
|
expect(resolved?.config.imagePathScope).toBe("workspace");
|
|
});
|
|
});
|
|
|
|
describe("resolveCliBackendConfig alias precedence", () => {
|
|
it("prefers the canonical backend key over legacy aliases when both are configured", () => {
|
|
runtimeBackendEntries = [
|
|
createRuntimeBackendEntry({
|
|
pluginId: "moonshot",
|
|
id: "kimi",
|
|
config: {
|
|
command: "kimi",
|
|
args: ["--default"],
|
|
},
|
|
}),
|
|
];
|
|
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"kimi-coding": {
|
|
command: "kimi-legacy",
|
|
args: ["--legacy"],
|
|
},
|
|
kimi: {
|
|
command: "kimi-canonical",
|
|
args: ["--canonical"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
|
|
const resolved = resolveCliBackendConfig("kimi", cfg);
|
|
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved?.config.command).toBe("kimi-canonical");
|
|
expect(resolved?.config.args).toEqual(["--canonical"]);
|
|
});
|
|
});
|