From 8287e2144571eb0d7233a89fa1982618bfccdc82 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:02:51 -0500 Subject: [PATCH] fix(cron): enforce caps across CLI backends --- docs/plugins/cli-backend-plugins.md | 39 ++-- docs/plugins/sdk-overview.md | 16 +- extensions/anthropic/cli-backend.ts | 3 +- extensions/anthropic/cli-shared.test.ts | 36 +--- extensions/anthropic/cli-shared.ts | 18 +- extensions/google/cli-backend-auth.runtime.ts | 124 ++++++++++-- extensions/google/cli-backend.ts | 11 +- extensions/google/setup-api.test.ts | 129 +++++++++++- extensions/qa-lab/src/cron-run-wait.test.ts | 56 ++++++ extensions/qa-lab/src/cron-run-wait.ts | 12 +- .../cron-explicit-authority-execution.yaml | 54 ++++-- src/agents/agent-tools.ts | 17 +- src/agents/cli-auth-epoch.ts | 1 + src/agents/cli-backends.test.ts | 2 + src/agents/cli-backends.ts | 11 +- src/agents/cli-runner.spawn.test.ts | 29 ++- src/agents/cli-runner.test-helpers.ts | 4 + src/agents/cli-runner/execute.ts | 8 +- src/agents/cli-runner/mcp-grant-context.ts | 2 + src/agents/cli-runner/prepare.test.ts | 183 ++++++++++-------- src/agents/cli-runner/prepare.ts | 67 +++---- src/agents/cli-runner/tool-policy.test.ts | 45 +---- src/agents/cli-runner/tool-policy.ts | 32 --- src/agents/cli-runner/types.ts | 4 +- .../cli-backend-dispatch.test.ts | 6 +- .../cli-backend-dispatch.ts | 4 +- src/gateway/mcp-grant-store.ts | 3 + src/gateway/mcp-http.runtime.test.ts | 2 + src/gateway/mcp-http.runtime.ts | 7 + src/gateway/mcp-http.ts | 3 + src/gateway/tool-resolution.exclude.test.ts | 68 +++++++ src/gateway/tool-resolution.test.ts | 29 +++ src/gateway/tool-resolution.ts | 130 ++++++++++--- src/plugin-sdk/cli-backend.ts | 4 +- src/plugins/cli-backend.types.ts | 45 ++--- src/plugins/types.ts | 4 +- src/system-agent/agent-turn.test.ts | 3 +- src/system-agent/agent-turn.ts | 13 +- src/system-agent/system-agent.test-helpers.ts | 1 + 39 files changed, 836 insertions(+), 389 deletions(-) diff --git a/docs/plugins/cli-backend-plugins.md b/docs/plugins/cli-backend-plugins.md index c82a70e8ce44..0ef6ab98fe06 100644 --- a/docs/plugins/cli-backend-plugins.md +++ b/docs/plugins/cli-backend-plugins.md @@ -234,6 +234,7 @@ only for behavior that really belongs to the backend. | `defaultAuthProfileId` | Prefer a specific OpenClaw auth profile | | `authEpochMode` | Decide how auth changes invalidate stored CLI sessions | | `nativeToolMode` | Declare whether native tools are absent, always on, or host-selectable | +| `toolAvailabilityEnforcement` | Declare whether exact tool caps are enforced in argv or execution staging | | `sideQuestionToolMode` | Declare disabled native tools for `/btw` side questions | | `bundleMcp` / `bundleMcpMode` | Opt into OpenClaw's loopback MCP tool bridge | | `ownsNativeCompaction` | Backend owns its own compaction - OpenClaw defers | @@ -271,25 +272,27 @@ side-question argv reliably disables those tools, also set `sideQuestionToolMode: "disabled"`; otherwise OpenClaw fails closed when BTW requires a no-tools CLI run. -Set `nativeToolMode: "selectable"` only when `resolveExecutionArgs` can disable -every backend-native tool for an individual run. For those restricted runs, -`ctx.toolAvailability.native` is the exact backend-native tool list and -`ctx.toolAvailability.mcp` is the exact host-isolated MCP allowlist. The hook -must replace conflicting tool flags, disable backend customization surfaces -that can execute outside those tools, and return argv that enforces both -values. OpenClaw calls it once with the final fresh or resume argv and fails -closed when the backend cannot enforce the restriction. MCP names in this -context are safe to auto-approve only because the host has already limited the -generated MCP configuration to those servers and tools. +Set `nativeToolMode: "selectable"` only when the backend can disable every +backend-native tool for an individual run. Restricted runs receive a canonical +contract: `ctx.toolAvailability.native` is the exact backend-native list and +`ctx.toolAvailability.openClaw` is the exact list of OpenClaw tool names. The +host independently limits the generated MCP configuration and grant to that +OpenClaw list; plugins must not translate it in core or add transport prefixes. -To support OpenClaw runtime caps such as cron `toolsAllow`, also implement -`resolveRuntimeToolAvailability(ctx)`. OpenClaw passes a normalized, -group-expanded allowlist and always disables backend-native tools. Return only -host-isolated MCP names selected from that allowlist. Returning `null` or -`undefined` keeps the generic runner fail-closed. A backend may omit an allowed -tool it cannot represent, but must never add authority absent from the -allowlist. Before minting a grant, the host rejects any returned entry that is -not the exact `mcp__openclaw__` name for one of the allowed tools. +Declare how the backend enforces that contract: + +- `toolAvailabilityEnforcement: "execution-args"` requires + `resolveExecutionArgs`. The hook must replace conflicting tool flags, disable + customization surfaces that can execute outside the selected tools, and + return enforcing argv for both fresh and resumed runs. +- `toolAvailabilityEnforcement: "prepare-execution"` requires + `prepareExecution`. The hook must stage an exact per-run policy and return + `toolAvailabilityEnforced: true`; missing acknowledgement fails closed and + OpenClaw cleans up the staged resources before launch. + +Runtime caps such as cron `toolsAllow` are normalized and group-expanded by +OpenClaw before this contract is built. Native tools are disabled, and a +backend without a complete declared enforcement path fails before execution. ### `ownsNativeCompaction`: opting out of OpenClaw compaction diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index 153657279670..cb58a7123dc1 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -588,15 +588,17 @@ AI CLI backend such as `claude-cli` or `my-cli`. - Use `prepareExecution` for backend-owned launch environment or temporary auth/config bridges. Its `ctx.contextTokenBudget` is the effective token limit selected for the run, so native-compaction backends can align their - own threshold without provider-specific core branches. + own threshold without provider-specific core branches. It also receives the + core-prepared `ctx.env` when backend staging must extend bundled MCP settings. - Backends that can disable all native tools for a specific run may declare `nativeToolMode: "selectable"`. Restricted calls pass an exact - `ctx.toolAvailability.native` list plus an exact host-isolated MCP allowlist; - `resolveExecutionArgs` must enforce both on the final fresh or resume argv. - To accept runtime caps such as cron `toolsAllow`, the backend must also - implement `resolveRuntimeToolAvailability`; OpenClaw disables all native - tools and fails closed if the backend cannot translate or enforce the MCP - cap. + `ctx.toolAvailability.native` list plus canonical + `ctx.toolAvailability.openClaw` names. Declare + `toolAvailabilityEnforcement: "execution-args"` and enforce the contract in + final fresh/resume argv, or declare `"prepare-execution"`, enforce it in + staged policy, and return `toolAvailabilityEnforced: true`. OpenClaw disables + native tools for runtime caps such as cron `toolsAllow` and fails closed when + the declared enforcement path is incomplete. For an end-to-end authoring guide, see [CLI backend plugins](/plugins/cli-backend-plugins). diff --git a/extensions/anthropic/cli-backend.ts b/extensions/anthropic/cli-backend.ts index ebe94aaa11fc..aaacc32f7766 100644 --- a/extensions/anthropic/cli-backend.ts +++ b/extensions/anthropic/cli-backend.ts @@ -20,7 +20,6 @@ import { normalizeClaudeBackendConfig, resolveClaudeCliAutoCompactEnv, resolveClaudeCliExecutionArgs, - resolveClaudeCliRuntimeToolAvailability, } from "./cli-shared.js"; type ClaudeCliAuthCredential = @@ -128,6 +127,7 @@ export function buildAnthropicCliBackend(): CliBackendPlugin { bundleMcp: true, bundleMcpMode: "claude-config-file", nativeToolMode: "selectable", + toolAvailabilityEnforcement: "execution-args", sideQuestionToolMode: "disabled", ownsNativeCompaction: true, // Anthropic routes direct anthropic-messages calls on subscription OAuth @@ -210,6 +210,5 @@ export function buildAnthropicCliBackend(): CliBackendPlugin { : undefined; }, resolveExecutionArgs: resolveClaudeCliExecutionArgs, - resolveRuntimeToolAvailability: resolveClaudeCliRuntimeToolAvailability, }; } diff --git a/extensions/anthropic/cli-shared.test.ts b/extensions/anthropic/cli-shared.test.ts index 6ba7be2cabcd..dd92ac038fc2 100644 --- a/extensions/anthropic/cli-shared.test.ts +++ b/extensions/anthropic/cli-shared.test.ts @@ -6,7 +6,6 @@ import { normalizeClaudeBackendConfig, resolveClaudeCliAutoCompactEnv, resolveClaudeCliExecutionArgs, - resolveClaudeCliRuntimeToolAvailability, } from "./cli-shared.js"; type ClaudePreparedExecutionWithSecret = { @@ -80,33 +79,6 @@ describe("resolveClaudeCliAutoCompactEnv", () => { }); }); -describe("resolveClaudeCliRuntimeToolAvailability", () => { - it("routes every restricted tool through the OpenClaw MCP policy boundary", () => { - expect( - resolveClaudeCliRuntimeToolAvailability({ - toolsAllow: ["read", "write", "edit", "apply_patch", "exec", "process", "browser", "image"], - }), - ).toEqual({ - mcp: [ - "mcp__openclaw__read", - "mcp__openclaw__write", - "mcp__openclaw__edit", - "mcp__openclaw__apply_patch", - "mcp__openclaw__exec", - "mcp__openclaw__process", - "mcp__openclaw__browser", - "mcp__openclaw__image", - ], - }); - }); - - it("keeps process-only authority on the exact OpenClaw MCP tool", () => { - expect(resolveClaudeCliRuntimeToolAvailability({ toolsAllow: ["process"] })).toEqual({ - mcp: ["mcp__openclaw__process"], - }); - }); -}); - function expectDefaultDisallowedTools(args: readonly string[] | undefined) { const disallowedIndex = args?.indexOf("--disallowedTools") ?? -1; expect(disallowedIndex).toBeGreaterThanOrEqual(0); @@ -280,7 +252,7 @@ describe("resolveClaudeCliExecutionArgs", () => { ], toolAvailability: { native: [], - mcp: ["mcp__openclaw__openclaw"], + openClaw: ["openclaw"], }, }), ).toEqual([ @@ -355,7 +327,7 @@ describe("resolveClaudeCliExecutionArgs", () => { ], toolAvailability: { native: [], - mcp: ["mcp__openclaw__message"], + openClaw: ["message"], }, }), ).toEqual([ @@ -419,7 +391,7 @@ describe("resolveClaudeCliExecutionArgs", () => { "--disallowedTools", "mcp__other__*", ], - toolAvailability: { native: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, }), ).toEqual([ "-p", @@ -695,7 +667,7 @@ describe("normalizeClaudeBackendConfig", () => { expect(normalized?.resumeArgs).toContain("bypassPermissions"); expect(normalized?.liveSession).toBe("claude-stdio"); expect(backend.resolveExecutionArgs).toBe(resolveClaudeCliExecutionArgs); - expect(backend.resolveRuntimeToolAvailability).toBe(resolveClaudeCliRuntimeToolAvailability); + expect(backend.toolAvailabilityEnforcement).toBe("execution-args"); }); it("opts bundled Claude CLI into bounded raw transcript reseed without disabling native resume", () => { diff --git a/extensions/anthropic/cli-shared.ts b/extensions/anthropic/cli-shared.ts index 28cae09ada86..712b9c65841c 100644 --- a/extensions/anthropic/cli-shared.ts +++ b/extensions/anthropic/cli-shared.ts @@ -5,8 +5,6 @@ import type { CliBackendConfig, CliBackendNormalizeConfigContext, CliBackendResolveExecutionArgsContext, - CliBackendResolveRuntimeToolAvailabilityContext, - CliBackendRuntimeToolAvailability, } from "openclaw/plugin-sdk/cli-backend"; import { resolveExecModePolicy } from "openclaw/plugin-sdk/exec-approvals-runtime"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -450,8 +448,11 @@ function resolveClaudeCliRestrictedExecutionArgs( CLAUDE_TOOLS_ARG, availability.native.join(","), ); - if (availability.mcp.length > 0) { - normalized.push(CLAUDE_ALLOWED_TOOLS_ARG, availability.mcp.join(",")); + if (availability.openClaw.length > 0) { + normalized.push( + CLAUDE_ALLOWED_TOOLS_ARG, + availability.openClaw.map((toolName) => `${OPENCLAW_MCP_TOOL_PREFIX}${toolName}`).join(","), + ); } else { normalized.push(CLAUDE_DISALLOWED_TOOLS_ARG, CLAUDE_DENY_MCP_TOOLS_VALUE); } @@ -484,15 +485,6 @@ export function resolveClaudeCliExecutionArgs( return resolveClaudeCliRestrictedExecutionArgs(executionArgs, context.toolAvailability); } -/** Route restricted runs entirely through OpenClaw's grant-scoped MCP policy boundary. */ -export function resolveClaudeCliRuntimeToolAvailability( - context: CliBackendResolveRuntimeToolAvailabilityContext, -): CliBackendRuntimeToolAvailability { - return { - mcp: context.toolsAllow.map((toolName) => `${OPENCLAW_MCP_TOOL_PREFIX}${toolName}`), - }; -} - /** Normalize Claude CLI backend config before registration or execution. */ export function normalizeClaudeBackendConfig( config: CliBackendConfig, diff --git a/extensions/google/cli-backend-auth.runtime.ts b/extensions/google/cli-backend-auth.runtime.ts index e3a9b6ec771d..5d1be69d74be 100644 --- a/extensions/google/cli-backend-auth.runtime.ts +++ b/extensions/google/cli-backend-auth.runtime.ts @@ -1,7 +1,10 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; -import type { CliBackendPreparedExecution } from "openclaw/plugin-sdk/cli-backend"; +import type { + CliBackendPreparedExecution, + CliBackendToolAvailability, +} from "openclaw/plugin-sdk/cli-backend"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { @@ -65,6 +68,7 @@ type GeminiCliAuthHomeContext = { agentDir?: string; authProfileId?: string; systemSettingsPath?: string; + toolAvailability?: CliBackendToolAvailability; }; type GeminiCliAuthSelectedType = "oauth-personal" | "gemini-api-key"; @@ -231,23 +235,91 @@ function buildGeminiCliAuthSettings( async function buildGeminiCliSystemSettings( ctx: GeminiCliAuthHomeContext, - selectedType: GeminiCliAuthSelectedType, + selectedType?: GeminiCliAuthSelectedType, ): Promise> { const base = await readGeminiCliJsonObject(ctx.systemSettingsPath); - const security = isRecord(base.security) ? { ...base.security } : {}; - const auth = isRecord(security.auth) ? { ...security.auth } : {}; - const enforcedType = normalizeString( - typeof auth.enforcedType === "string" ? auth.enforcedType : undefined, - ); - if (enforcedType && enforcedType !== selectedType) { - throw new Error( - `Gemini CLI system settings enforce ${enforcedType} auth, but the selected OpenClaw profile requires ${selectedType}.`, + let settings = base; + if (selectedType) { + const security = isRecord(base.security) ? { ...base.security } : {}; + const auth = isRecord(security.auth) ? { ...security.auth } : {}; + const enforcedType = normalizeString( + typeof auth.enforcedType === "string" ? auth.enforcedType : undefined, ); + if (enforcedType && enforcedType !== selectedType) { + throw new Error( + `Gemini CLI system settings enforce ${enforcedType} auth, but the selected OpenClaw profile requires ${selectedType}.`, + ); + } + security.auth = { ...auth, selectedType }; + settings = { ...base, security }; } - security.auth = { ...auth, selectedType }; + return ctx.toolAvailability + ? applyGeminiCliToolAvailability(settings, ctx.toolAvailability) + : settings; +} + +function applyGeminiCliToolAvailability( + base: Record, + availability: CliBackendToolAvailability, +): Record { + if (availability.native.length > 0) { + throw new Error("Gemini CLI cannot expose backend-native tools in an exact restricted run."); + } + const mcpServers = isRecord(base.mcpServers) ? { ...base.mcpServers } : {}; + if (!isRecord(mcpServers.openclaw)) { + throw new Error("Gemini CLI exact tool availability requires the OpenClaw MCP server."); + } + const tools = isRecord(base.tools) ? { ...base.tools } : {}; + // `tools.allowed` has higher policy priority than the `tools.core` default + // deny. Drop it so inherited system settings cannot widen this exact run. + const { + allowed: _allowedTools, + core: _coreTools, + discoveryCommand: _discoveryCommand, + callCommand: _callCommand, + ...nonAuthorityToolSettings + } = tools; + const mcp = isRecord(base.mcp) ? { ...base.mcp } : {}; + const { serverCommand: _serverCommand, ...nonAuthorityMcpSettings } = mcp; + const experimental = isRecord(base.experimental) ? { ...base.experimental } : {}; + const agents = isRecord(base.agents) ? { ...base.agents } : {}; + const agentOverrides = isRecord(agents.overrides) ? { ...agents.overrides } : {}; + const hooksConfig = isRecord(base.hooksConfig) ? { ...base.hooksConfig } : {}; + const skills = isRecord(base.skills) ? { ...base.skills } : {}; return { ...base, - security, + tools: { + ...nonAuthorityToolSettings, + core: ["mcp_openclaw_*"], + discoveryCommand: "", + callCommand: "", + }, + mcp: { ...nonAuthorityMcpSettings, allowed: ["openclaw"], serverCommand: "" }, + mcpServers: { + openclaw: { + ...mcpServers.openclaw, + includeTools: [...availability.openClaw], + }, + }, + experimental: { ...experimental, enableAgents: false }, + agents: { + ...agents, + overrides: { + ...agentOverrides, + codebase_investigator: { + ...(isRecord(agentOverrides.codebase_investigator) + ? agentOverrides.codebase_investigator + : {}), + enabled: false, + }, + cli_help: { + ...(isRecord(agentOverrides.cli_help) ? agentOverrides.cli_help : {}), + enabled: false, + }, + }, + }, + hooksConfig: { ...hooksConfig, enabled: false }, + skills: { ...skills, enabled: false }, }; } @@ -388,7 +460,29 @@ async function prepareGeminiCliApiKeyHome( }; } -export async function prepareGeminiCliAuthHome( +async function prepareGeminiCliRestrictedSystemSettings( + ctx: GeminiCliAuthHomeContext, +): Promise { + const settings = await buildGeminiCliSystemSettings(ctx); + const systemSettingsDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-gemini-cli-policy-"), + ); + await fs.chmod(systemSettingsDir, 0o700); + const systemSettingsPath = path.join(systemSettingsDir, "settings.json"); + return { + env: { GEMINI_CLI_SYSTEM_SETTINGS_PATH: systemSettingsPath }, + clearEnv: [...GEMINI_CLI_PROFILE_SETTINGS_ENV], + beforeExecution: async () => { + await writeGeminiCliJson(systemSettingsPath, settings); + }, + cleanup: async () => { + await fs.rm(systemSettingsDir, { recursive: true, force: true }); + }, + toolAvailabilityEnforced: true, + }; +} + +export async function prepareGeminiCliExecution( ctx: GeminiCliAuthHomeContext, credential: unknown, ): Promise { @@ -397,10 +491,10 @@ export async function prepareGeminiCliAuthHome( (await prepareGeminiCliOAuthHome(ctx, authCredential)) ?? (await prepareGeminiCliApiKeyHome(ctx, authCredential)); if (prepared) { - return prepared; + return ctx.toolAvailability ? { ...prepared, toolAvailabilityEnforced: true } : prepared; } if (normalizeString(ctx.authProfileId)) { throwUnstageableSelectedGeminiProfile(ctx, authCredential); } - return null; + return ctx.toolAvailability ? await prepareGeminiCliRestrictedSystemSettings(ctx) : null; } diff --git a/extensions/google/cli-backend.ts b/extensions/google/cli-backend.ts index c7ae65f27e00..12692536446d 100644 --- a/extensions/google/cli-backend.ts +++ b/extensions/google/cli-backend.ts @@ -77,18 +77,19 @@ export function buildGoogleGeminiCliBackend(): CliBackendPlugin { }, bundleMcp: true, bundleMcpMode: "gemini-system-settings", - nativeToolMode: "always-on", + nativeToolMode: "selectable", + toolAvailabilityEnforcement: "prepare-execution", authEpochMode: "profile-only", normalizeConfig: normalizeGeminiCliBackendConfig, prepareExecution: async (ctx) => { - const { prepareGeminiCliAuthHome } = await import("./cli-backend-auth.runtime.js"); - return await prepareGeminiCliAuthHome( + const { prepareGeminiCliExecution } = await import("./cli-backend-auth.runtime.js"); + return await prepareGeminiCliExecution( { agentDir: ctx.agentDir, authProfileId: ctx.authProfileId, systemSettingsPath: - (ctx as typeof ctx & { env?: Record }).env - ?.GEMINI_CLI_SYSTEM_SETTINGS_PATH ?? process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH, + ctx.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH ?? process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH, + toolAvailability: ctx.toolAvailability, }, (ctx as typeof ctx & { authCredential?: unknown }).authCredential, ); diff --git a/extensions/google/setup-api.test.ts b/extensions/google/setup-api.test.ts index d8e46a068fb8..19f1f2e21dbc 100644 --- a/extensions/google/setup-api.test.ts +++ b/extensions/google/setup-api.test.ts @@ -142,11 +142,14 @@ describe("google gemini cli backend config", () => { }); it("declares its bundled package implementation boundary", () => { - expect(buildGoogleGeminiCliBackend().runtimeArtifact).toEqual({ + const backend = buildGoogleGeminiCliBackend(); + expect(backend.runtimeArtifact).toEqual({ kind: "bundled-package-tree", packageName: "@google/gemini-cli", entrypoint: "command", }); + expect(backend.nativeToolMode).toBe("selectable"); + expect(backend.toolAvailabilityEnforcement).toBe("prepare-execution"); }); it("keeps legacy json output overrides on the json parser", () => { @@ -191,6 +194,130 @@ describe("google gemini cli backend config", () => { }); describe("google gemini cli backend auth bridge", () => { + it.each([ + { auth: "ambient", allowed: ["memory_search"] }, + { auth: "ambient", allowed: [] }, + { auth: "oauth", allowed: ["memory_search"] }, + { auth: "oauth", allowed: [] }, + { auth: "api-key", allowed: ["memory_search"] }, + { auth: "api-key", allowed: [] }, + ] as const)( + "enforces exact system policy for $auth auth with $allowed", + async ({ auth, allowed }) => { + await withTempDir("openclaw-test-workspace-", async (workspaceDir) => { + const backend = buildGoogleGeminiCliBackend(); + const inheritedSettingsPath = path.join(workspaceDir, "generated-mcp-settings.json"); + await fs.writeFile( + inheritedSettingsPath, + `${JSON.stringify({ + tools: { + core: ["run_shell_command"], + allowed: ["*"], + discoveryCommand: "hostile-discovery", + callCommand: "hostile-call", + }, + mcp: { allowed: ["openclaw", "hostile"], serverCommand: "hostile-mcp" }, + mcpServers: { + openclaw: { + url: "http://127.0.0.1:23119/mcp", + headers: { authorization: "Bearer loopback-token" }, + }, + hostile: { command: "hostile-server" }, + }, + experimental: { enableAgents: true }, + agents: { + overrides: { + codebase_investigator: { enabled: true, custom: "preserved" }, + cli_help: { enabled: true }, + }, + }, + hooksConfig: { enabled: true, marker: "preserved" }, + skills: { enabled: true, marker: "preserved" }, + })}\n`, + "utf8", + ); + const context: GeminiPrepareContext = + auth === "oauth" + ? buildGeminiOAuthPrepareContext(workspaceDir) + : auth === "api-key" + ? buildGeminiApiKeyPrepareContext(workspaceDir) + : { + workspaceDir, + provider: "google-gemini-cli", + modelId: "gemini-3.1-pro-preview", + }; + context.env = { GEMINI_CLI_SYSTEM_SETTINGS_PATH: inheritedSettingsPath }; + context.toolAvailability = { native: [], openClaw: [...allowed] }; + const prepared = await backend.prepareExecution?.(context); + try { + expect(prepared?.toolAvailabilityEnforced).toBe(true); + await stageGeminiPreparedExecution(prepared); + const systemSettingsPath = prepared?.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH; + expect(systemSettingsPath).toBeTruthy(); + const settings = JSON.parse(await fs.readFile(systemSettingsPath ?? "", "utf8")) as { + tools?: { + core?: string[]; + discoveryCommand?: string; + callCommand?: string; + }; + mcp?: { allowed?: string[]; serverCommand?: string }; + mcpServers?: Record>; + experimental?: { enableAgents?: boolean }; + agents?: { overrides?: Record> }; + hooksConfig?: Record; + skills?: Record; + security?: { auth?: { selectedType?: string } }; + }; + expect(settings.tools?.core).toEqual(["mcp_openclaw_*"]); + expect(settings.tools).not.toHaveProperty("allowed"); + expect(settings.tools?.discoveryCommand).toBe(""); + expect(settings.tools?.callCommand).toBe(""); + expect(settings.mcp?.allowed).toEqual(["openclaw"]); + expect(settings.mcp?.serverCommand).toBe(""); + expect(settings.mcpServers?.openclaw).toMatchObject({ + url: "http://127.0.0.1:23119/mcp", + headers: { authorization: "Bearer loopback-token" }, + includeTools: [...allowed], + }); + expect(settings.mcpServers?.hostile).toBeUndefined(); + expect(settings.experimental?.enableAgents).toBe(false); + expect(settings.agents?.overrides?.codebase_investigator).toEqual({ + enabled: false, + custom: "preserved", + }); + expect(settings.agents?.overrides?.cli_help?.enabled).toBe(false); + expect(settings.hooksConfig).toEqual({ enabled: false, marker: "preserved" }); + expect(settings.skills).toEqual({ enabled: false, marker: "preserved" }); + expect(settings.security?.auth?.selectedType).toBe( + auth === "oauth" ? "oauth-personal" : auth === "api-key" ? "gemini-api-key" : undefined, + ); + } finally { + await prepared?.cleanup?.(); + } + }); + }, + ); + + it("rejects native tools because Gemini exact policy only exposes OpenClaw MCP", async () => { + await withTempDir("openclaw-test-workspace-", async (workspaceDir) => { + const inheritedSettingsPath = path.join(workspaceDir, "generated-mcp-settings.json"); + await fs.writeFile( + inheritedSettingsPath, + JSON.stringify({ mcpServers: { openclaw: { url: "http://127.0.0.1/mcp" } } }), + "utf8", + ); + await expect( + buildGoogleGeminiCliBackend().prepareExecution?.({ + workspaceDir, + provider: "google-gemini-cli", + modelId: "gemini-3.1-pro-preview", + env: { GEMINI_CLI_SYSTEM_SETTINGS_PATH: inheritedSettingsPath }, + toolAvailability: { native: ["run_shell_command"], openClaw: [] }, + }), + ).rejects.toThrow("cannot expose backend-native tools"); + }); + }); + it("materializes selected OpenClaw OAuth credentials into a persistent profile-scoped Gemini CLI home", async () => { const backend = buildGoogleGeminiCliBackend(); const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-workspace-")); diff --git a/extensions/qa-lab/src/cron-run-wait.test.ts b/extensions/qa-lab/src/cron-run-wait.test.ts index 610edc565b80..e97f52417028 100644 --- a/extensions/qa-lab/src/cron-run-wait.test.ts +++ b/extensions/qa-lab/src/cron-run-wait.test.ts @@ -52,6 +52,62 @@ describe("waitForCronRunCompletion", () => { ).rejects.toThrow(/timed out waiting for cron run completion/); }); + it("allows live CLI scenarios to extend the gateway call deadline", async () => { + const callGateway = vi + .fn< + (method: string, rpcParams?: unknown, opts?: { timeoutMs?: number }) => Promise + >() + .mockResolvedValue({ entries: [{ ts: 180, status: "ok" }] }); + + await waitForCronRunCompletion({ + callGateway, + jobId: "slow-cli-job", + afterTs: 150, + timeoutMs: 120_000, + gatewayCallTimeoutMs: 90_000, + }); + + expect(callGateway).toHaveBeenCalledWith( + "cron.runs", + { id: "slow-cli-job", limit: 20, sortDir: "desc" }, + { timeoutMs: 90_000 }, + ); + }); + + it("caps each gateway call at the remaining overall deadline", async () => { + let now = 1_000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + const callGateway = vi + .fn< + (method: string, rpcParams?: unknown, opts?: { timeoutMs?: number }) => Promise + >() + .mockImplementationOnce(async () => { + now = 1_080; + return { entries: [{ ts: 100, status: "ok", summary: "older run" }] }; + }) + .mockResolvedValueOnce({ entries: [{ ts: 180, status: "ok" }] }); + + try { + await waitForCronRunCompletion({ + callGateway, + jobId: "bounded-call-job", + afterTs: 150, + timeoutMs: 100, + intervalMs: 0, + gatewayCallTimeoutMs: 90, + }); + + expect(callGateway).toHaveBeenNthCalledWith( + 2, + "cron.runs", + { id: "bounded-call-job", limit: 20, sortDir: "desc" }, + { timeoutMs: 20 }, + ); + } finally { + nowSpy.mockRestore(); + } + }); + it("keeps oversized poll intervals within the overall timeout", async () => { const callGateway = vi .fn< diff --git a/extensions/qa-lab/src/cron-run-wait.ts b/extensions/qa-lab/src/cron-run-wait.ts index 3494b1e7de27..2fc38ce98c32 100644 --- a/extensions/qa-lab/src/cron-run-wait.ts +++ b/extensions/qa-lab/src/cron-run-wait.ts @@ -29,12 +29,22 @@ export async function waitForCronRunCompletion(params: { afterTs: number; timeoutMs?: number; intervalMs?: number; + gatewayCallTimeoutMs?: number; }) { const timeoutMs = params.timeoutMs ?? 90_000; const intervalMs = resolveCronRunPollIntervalMs(params.intervalMs); + const gatewayCallTimeoutMs = resolveTimerTimeoutMs( + params.gatewayCallTimeoutMs ?? 30_000, + 30_000, + 1, + ); const startedAt = Date.now(); let lastEntries: QaCronRunLogEntry[] = []; while (Date.now() - startedAt < timeoutMs) { + const remainingCallMs = timeoutMs - (Date.now() - startedAt); + if (remainingCallMs <= 0) { + break; + } const page = (await params.callGateway( "cron.runs", { @@ -42,7 +52,7 @@ export async function waitForCronRunCompletion(params: { limit: 20, sortDir: "desc", }, - { timeoutMs: Math.min(timeoutMs, 30_000) }, + { timeoutMs: Math.min(remainingCallMs, gatewayCallTimeoutMs) }, )) as QaCronRunsPage; const entries = Array.isArray(page.entries) ? page.entries : []; lastEntries = entries; diff --git a/qa/scenarios/scheduling/cron-explicit-authority-execution.yaml b/qa/scenarios/scheduling/cron-explicit-authority-execution.yaml index 6db0b37ede18..c05a39a83454 100644 --- a/qa/scenarios/scheduling/cron-explicit-authority-execution.yaml +++ b/qa/scenarios/scheduling/cron-explicit-authority-execution.yaml @@ -44,7 +44,7 @@ scenario: channel: qa-channel retryCount: 0 timeoutMs: 420000 - summary: Create one group-owned job, add a sender wildcard restriction, then prove its scheduled write authority survives requester-policy resolution. + summary: Create one group-owned job through the operator API, add a sender wildcard restriction, then prove its scheduled write authority survives requester-policy resolution. config: artifactFile: cron-explicit-authority-proof.txt artifactText: CRON_EXPLICIT_AUTHORITY_OK @@ -77,27 +77,33 @@ flow: - set: jobName value: expr: "`qa-cron-explicit-authority-${randomUUID()}`" - - sendInbound: - conversation: - id: - ref: config.conversationId - kind: group - title: Cron execution authority QA - senderId: qa-cron-authority-operator - senderName: Cron Authority Operator - text: - expr: |- - `Use the cron tool once to create one enabled isolated agent-turn job. Do not run it yourself. - Name it '${jobName}', schedule it at '${scheduledFor}', use payload message 'Use the write tool to create ${config.artifactFile} in the workspace with exactly ${config.artifactText}. Then reply exactly ${config.artifactText}.', set toolsAllow to ["write"], and set delivery to none. - After the cron add call succeeds, reply exactly CRON-EXECUTION-JOB-CREATED.` - - call: waitForCondition + - call: env.gateway.call saveAs: added args: - - lambda: - async: true - expr: "env.gateway.call('cron.list', { includeDisabled: true }, { timeoutMs: 30000 }).then((page) => page.jobs.find((job) => job.name === jobName))" - - expr: liveTurnTimeoutMs(env, 240000) - - 250 + - cron.add + - agentId: qa + owner: + agentId: qa + sessionKey: + ref: config.ownerSessionKey + name: + ref: jobName + enabled: true + schedule: + kind: at + at: + ref: scheduledFor + sessionTarget: isolated + wakeMode: now + payload: + kind: agentTurn + message: + expr: "`Use the write tool to create ${config.artifactFile} in the workspace with exactly ${config.artifactText}. Then reply exactly ${config.artifactText}.`" + toolsAllow: + - write + delivery: + mode: none + - timeoutMs: 30000 - set: jobId value: expr: added.id @@ -142,7 +148,7 @@ flow: message: expr: "`expected cron.run to enqueue one run, got ${JSON.stringify(runResponse)}`" - - name: scheduled authority writes the workspace proof + - name: records the scheduled run as successful actions: - call: waitForCronRunCompletion saveAs: completedRun @@ -155,10 +161,16 @@ flow: ref: runStartedAt timeoutMs: expr: liveTurnTimeoutMs(env, 120000) + gatewayCallTimeoutMs: + expr: liveTurnTimeoutMs(env, 120000) - assert: expr: "completedRun?.status === 'ok'" message: expr: "`expected cron run ok, got ${JSON.stringify(completedRun)}`" + detailsExpr: "`job=${jobId} status=${completedRun.status}`" + + - name: scheduled authority writes the workspace proof + actions: - call: waitForCondition saveAs: artifact args: diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index 07e32e74e738..f688f19fc947 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -339,6 +339,8 @@ type OpenClawCodingToolsOptions = { abortSignal?: AbortSignal; /** Disable hook-owned diagnostics when an outer runtime owns tool diagnostics. */ emitBeforeToolCallDiagnostics?: boolean; + /** Skip hook wrapping when an outer tool-call boundary owns hook execution. */ + wrapBeforeToolCallHook?: boolean; /** * Provider of the currently selected model (used for provider-specific tool quirks). * Example: "anthropic", "openai", "google", "openai". @@ -1216,11 +1218,16 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) emitDiagnostics: options?.emitBeforeToolCallDiagnostics, ...(options?.swarmCollector ? { approvalMode: "deny" as const } : {}), }; - const withHooks = normalized.map((tool) => - isToolWrappedWithBeforeToolCallHook(tool) - ? rewrapToolWithBeforeToolCallHook(tool, hookContext, hookOptions) - : wrapToolWithBeforeToolCallHook(tool, hookContext, hookOptions), - ); + // MCP and other outer dispatchers execute hooks at their shared call boundary. + // Rewrapping here would run plugin authorization and mutation hooks twice. + const withHooks = + options?.wrapBeforeToolCallHook === false + ? normalized + : normalized.map((tool) => + isToolWrappedWithBeforeToolCallHook(tool) + ? rewrapToolWithBeforeToolCallHook(tool, hookContext, hookOptions) + : wrapToolWithBeforeToolCallHook(tool, hookContext, hookOptions), + ); options?.recordToolPrepStage?.("tool-hooks"); const withAbort = options?.abortSignal ? withHooks.map((tool) => wrapToolWithAbortSignal(tool, options.abortSignal)) diff --git a/src/agents/cli-auth-epoch.ts b/src/agents/cli-auth-epoch.ts index 1c37649bc349..56e6787371a6 100644 --- a/src/agents/cli-auth-epoch.ts +++ b/src/agents/cli-auth-epoch.ts @@ -478,6 +478,7 @@ export async function resolveCliRuntimeOwnerFingerprint(params: { bundleMcpMode: backend.bundleMcpMode, authEpochMode: backend.authEpochMode, nativeToolMode: backend.nativeToolMode, + toolAvailabilityEnforcement: backend.toolAvailabilityEnforcement, sideQuestionToolMode: backend.sideQuestionToolMode, }, ...(authProfileId ? { authProfileId } : {}), diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index 17eee9ad3f71..662906ff3c2e 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -202,6 +202,7 @@ describe("resolveCliBackendConfig", () => { resolveExecutionArgs: resolveExecutionArgs as never, ownsNativeCompaction: true, nativeToolMode: "selectable", + toolAvailabilityEnforcement: "execution-args", sideQuestionToolMode: "disabled", }), ], @@ -214,6 +215,7 @@ describe("resolveCliBackendConfig", () => { expect(resolved.resolveExecutionArgs).toBe(resolveExecutionArgs); expect(resolved.ownsNativeCompaction).toBe(true); expect(resolved.nativeToolMode).toBe("selectable"); + expect(resolved.toolAvailabilityEnforcement).toBe("execution-args"); expect(resolved.sideQuestionToolMode).toBe("disabled"); }); }); diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index e9fbd22cd3f6..ed4caef9ef09 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -21,6 +21,7 @@ import type { CliBackendPlugin, CliBackendNativeToolMode, CliBackendSideQuestionToolMode, + CliBackendToolAvailabilityEnforcement, PluginTextTransforms, } from "../plugins/types.js"; import { mergePluginTextTransforms } from "./plugin-text-transforms.js"; @@ -56,7 +57,7 @@ export type ResolvedCliBackend = { ownsNativeCompaction?: boolean; prepareExecution?: CliBackendPlugin["prepareExecution"]; resolveExecutionArgs?: CliBackendPlugin["resolveExecutionArgs"]; - resolveRuntimeToolAvailability?: CliBackendPlugin["resolveRuntimeToolAvailability"]; + toolAvailabilityEnforcement?: CliBackendToolAvailabilityEnforcement; nativeToolMode?: CliBackendNativeToolMode; sideQuestionToolMode?: CliBackendSideQuestionToolMode; runtimeArtifact?: CliBackendRuntimeArtifactPolicy; @@ -95,7 +96,7 @@ type FallbackCliBackendPolicy = { ownsNativeCompaction?: boolean; prepareExecution?: CliBackendPlugin["prepareExecution"]; resolveExecutionArgs?: CliBackendPlugin["resolveExecutionArgs"]; - resolveRuntimeToolAvailability?: CliBackendPlugin["resolveRuntimeToolAvailability"]; + toolAvailabilityEnforcement?: CliBackendToolAvailabilityEnforcement; nativeToolMode?: CliBackendNativeToolMode; sideQuestionToolMode?: CliBackendSideQuestionToolMode; runtimeArtifact?: CliBackendRuntimeArtifactPolicy; @@ -140,7 +141,7 @@ function resolveSetupCliBackendPolicy(provider: string): FallbackCliBackendPolic ownsNativeCompaction: entry.backend.ownsNativeCompaction, prepareExecution: entry.backend.prepareExecution, resolveExecutionArgs: entry.backend.resolveExecutionArgs, - resolveRuntimeToolAvailability: entry.backend.resolveRuntimeToolAvailability, + toolAvailabilityEnforcement: entry.backend.toolAvailabilityEnforcement, nativeToolMode: entry.backend.nativeToolMode, sideQuestionToolMode: entry.backend.sideQuestionToolMode, runtimeArtifact: entry.backend.runtimeArtifact, @@ -381,7 +382,7 @@ export function resolveCliBackendConfig( ownsNativeCompaction: registered.ownsNativeCompaction, prepareExecution: registered.prepareExecution, resolveExecutionArgs: registered.resolveExecutionArgs, - resolveRuntimeToolAvailability: registered.resolveRuntimeToolAvailability, + toolAvailabilityEnforcement: registered.toolAvailabilityEnforcement, nativeToolMode: registered.nativeToolMode, sideQuestionToolMode: registered.sideQuestionToolMode, runtimeArtifact: registered.runtimeArtifact, @@ -414,7 +415,7 @@ export function resolveCliBackendConfig( ownsNativeCompaction: fallbackPolicy.ownsNativeCompaction, prepareExecution: fallbackPolicy.prepareExecution, resolveExecutionArgs: fallbackPolicy.resolveExecutionArgs, - resolveRuntimeToolAvailability: fallbackPolicy.resolveRuntimeToolAvailability, + toolAvailabilityEnforcement: fallbackPolicy.toolAvailabilityEnforcement, nativeToolMode: fallbackPolicy.nativeToolMode, sideQuestionToolMode: fallbackPolicy.sideQuestionToolMode, runtimeArtifact: fallbackPolicy.runtimeArtifact, diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index 6de6c10343a2..711acaf0051d 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -159,6 +159,10 @@ afterEach(() => { }); const CLAUDE_OK_JSONL = `${JSON.stringify({ type: "result", result: "ok" })}\n`; +const GEMINI_OK_JSONL = `${[ + JSON.stringify({ type: "message", role: "assistant", content: "ok", delta: true }), + JSON.stringify({ type: "result", status: "success" }), +].join("\n")}\n`; describe("runCliAgent spawn path", () => { it("formats output digests without logging response content", () => { @@ -280,7 +284,7 @@ describe("runCliAgent spawn path", () => { toolAvailability = execution.toolAvailability; return [...execution.baseArgs]; }, - cliToolAvailability: { native: [], mcp: ["mcp__openclaw__message"] }, + cliToolAvailability: { native: [], openClaw: ["message"] }, }); context.preparedBackend.secretInput = { fd: 3, @@ -297,8 +301,8 @@ describe("runCliAgent spawn path", () => { expect(output).toMatchObject({ text: "node answer", sessionId: "forked-node-session" }); // Node runs keep the gateway's native tool policy; loopback MCP tools do - // not exist on the node so the mcp list is projected empty. - expect(toolAvailability).toEqual({ native: [], mcp: [] }); + // not exist on the node so the OpenClaw list is projected empty. + expect(toolAvailability).toEqual({ native: [], openClaw: [] }); expect(writeSystemPrompt).not.toHaveBeenCalled(); expect(supervisorSpawnMock).not.toHaveBeenCalled(); expect(invokeNode).toHaveBeenCalledWith( @@ -1180,7 +1184,7 @@ describe("runCliAgent spawn path", () => { mockSuccessfulCliRun(CLAUDE_OK_JSONL); const toolAvailability: NonNullable = { native: [], - mcp: ["mcp__openclaw__openclaw"], + openClaw: ["openclaw"], }; const resolveExecutionArgs = vi.fn(({ baseArgs }) => baseArgs); @@ -1205,7 +1209,7 @@ describe("runCliAgent spawn path", () => { buildPreparedCliRunContext({ cliToolAvailability: { native: [], - mcp: ["mcp__openclaw__openclaw"], + openClaw: ["openclaw"], }, resolveExecutionArgs, }), @@ -1214,6 +1218,21 @@ describe("runCliAgent spawn path", () => { expect(supervisorSpawnMock).not.toHaveBeenCalled(); }); + it("does not require an argv rewrite after prepared-execution enforcement", async () => { + mockSuccessfulCliRun(GEMINI_OK_JSONL); + + await executePreparedCliRun( + buildPreparedCliRunContext({ + provider: "google-gemini-cli", + model: "gemini-3.1-pro-preview", + cliToolAvailability: { native: [], openClaw: ["openclaw"] }, + toolAvailabilityEnforcement: "prepare-execution", + }), + ); + + expect(supervisorSpawnMock).toHaveBeenCalledOnce(); + }); + it("maps Ultra to the strongest generic CLI backend level", async () => { mockSuccessfulCliRun(CLAUDE_OK_JSONL); const resolveExecutionArgs = vi.fn(({ baseArgs }) => baseArgs); diff --git a/src/agents/cli-runner.test-helpers.ts b/src/agents/cli-runner.test-helpers.ts index fc6372a1af29..8e700bc64bd7 100644 --- a/src/agents/cli-runner.test-helpers.ts +++ b/src/agents/cli-runner.test-helpers.ts @@ -120,6 +120,7 @@ export type PreparedCliRunContextOverrides = { backend?: Partial; preparedEnv?: PreparedCliRunContext["preparedBackend"]["env"]; resolveExecutionArgs?: PreparedCliRunContext["backendResolved"]["resolveExecutionArgs"]; + toolAvailabilityEnforcement?: PreparedCliRunContext["backendResolved"]["toolAvailabilityEnforcement"]; config?: PreparedCliRunContext["params"]["config"]; mcpConfigHash?: string; mcpDeliveryCapture?: boolean; @@ -221,6 +222,9 @@ export function buildPreparedCliRunContext( ? "google" : "openai", resolveExecutionArgs: overrides.resolveExecutionArgs, + toolAvailabilityEnforcement: + overrides.toolAvailabilityEnforcement ?? + (provider === "google-gemini-cli" ? "prepare-execution" : "execution-args"), runtimeArtifact: overrides.runtimeArtifact, }, preparedBackend: { diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index 6c468c5bcb04..6a657fea53ae 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -523,12 +523,16 @@ export async function executePreparedCliRun( // session run with the node's full native toolset. toolAvailability: nodePlacement && params.cliToolAvailability - ? { native: params.cliToolAvailability.native, mcp: [] } + ? { native: params.cliToolAvailability.native, openClaw: [] } : params.cliToolAvailability, useResume, baseArgs: baseArgsWithSkills, }); - if (params.cliToolAvailability && !resolvedExecutionArgs) { + if ( + params.cliToolAvailability && + context.backendResolved.toolAvailabilityEnforcement === "execution-args" && + !resolvedExecutionArgs + ) { throw new Error( `CLI backend ${context.backendResolved.id} did not enforce exact per-run tool availability`, ); diff --git a/src/agents/cli-runner/mcp-grant-context.ts b/src/agents/cli-runner/mcp-grant-context.ts index 4ece9b6f5a83..556b0ff8cf46 100644 --- a/src/agents/cli-runner/mcp-grant-context.ts +++ b/src/agents/cli-runner/mcp-grant-context.ts @@ -120,6 +120,8 @@ export function buildCliMcpGrantContext(params: { agentId: params.agentId, sessionId: normalizeOptionalMcpContextValue(params.run.sessionId), runId: normalizeOptionalMcpContextValue(params.run.runId), + workspaceDir: params.run.workspaceDir, + ...(normalizeOptionalMcpContextValue(params.run.cwd) ? { cwd: params.run.cwd?.trim() } : {}), // Restricted runs get their allowlist stamped into the grant; the // loopback server enforces it on tools/list and tools/call. ...(params.toolsAllow ? { toolsAllow: params.toolsAllow } : {}), diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 124541c43f76..d6f4a26c676e 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -2660,6 +2660,7 @@ describe("prepareCliRunContext", () => { agentId: "worker", sessionId: "session-test", runId: "run-test-room-event-tools", + workspaceDir: context.workspaceDir, modelProvider: "anthropic", modelId: "test-model", messageProvider: "discord", @@ -2802,7 +2803,7 @@ describe("prepareCliRunContext", () => { }); }); - it("fails closed when a runtime toolsAllow is requested for CLI backends", async () => { + it("fails closed when a backend cannot enforce a runtime toolsAllow", async () => { const getActiveMcpLoopbackRuntime = vi.fn(() => ({ port: 31783, ownerToken: "loopback-owner-token", @@ -2817,28 +2818,107 @@ describe("prepareCliRunContext", () => { config: createCliBackendConfig({ bundleMcp: true }), toolsAllow: ["read", "web_search"], }), - ).rejects.toThrow( - "CLI backend test-cli cannot enforce runtime toolsAllow; use an embedded runtime for restricted tool policy", - ); + ).rejects.toThrow("CLI backend test-cli cannot enforce exact per-run tool availability"); expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled(); }); - it("translates runtime toolsAllow through a selectable backend and bounds its MCP grant", async () => { + it("requires prepared-execution backends to acknowledge exact enforcement and cleans up", async () => { + const cleanup = vi.fn(async () => {}); + const prepareExecution = vi.fn(async () => ({ cleanup })); + setRawCliBackendForPrepareTest({ + id: "settings-cli", + pluginId: "settings-plugin", + bundleMcp: false, + nativeToolMode: "selectable", + toolAvailabilityEnforcement: "prepare-execution", + prepareExecution, + config: { + command: "settings-cli", + args: ["--print"], + output: "jsonl", + input: "stdin", + sessionMode: "existing", + }, + }); + + await expect( + fixture.prepare({ + provider: "settings-cli", + cliToolAvailability: { native: [], openClaw: [] }, + }), + ).rejects.toThrow( + "did not enforce exact per-run tool availability during execution preparation", + ); + expect(prepareExecution).toHaveBeenCalledWith( + expect.objectContaining({ toolAvailability: { native: [], openClaw: [] } }), + ); + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it("accepts a positive prepared-execution enforcement acknowledgement", async () => { + const prepareExecution = vi.fn(async () => ({ toolAvailabilityEnforced: true as const })); + setRawCliBackendForPrepareTest({ + id: "settings-cli", + pluginId: "settings-plugin", + bundleMcp: false, + nativeToolMode: "selectable", + toolAvailabilityEnforcement: "prepare-execution", + prepareExecution, + config: { + command: "settings-cli", + args: ["--print"], + output: "jsonl", + input: "stdin", + sessionMode: "existing", + }, + }); + + const context = await fixture.prepare({ + provider: "settings-cli", + cliToolAvailability: { native: [], openClaw: [] }, + }); + expect(context.params.cliToolAvailability).toEqual({ native: [], openClaw: [] }); + await context.preparedBackend.cleanup?.(); + }); + + it("projects node-placed Claude availability before prepared-execution enforcement", async () => { + const prepareExecution = vi.fn(async () => ({ toolAvailabilityEnforced: true as const })); + setRawCliBackendForPrepareTest({ + id: "claude-cli", + pluginId: "anthropic", + bundleMcp: false, + nativeToolMode: "selectable", + toolAvailabilityEnforcement: "prepare-execution", + prepareExecution, + config: { + command: "claude", + args: ["--print"], + output: "jsonl", + input: "stdin", + sessionMode: "existing", + }, + }); + + const context = await fixture.prepare({ + provider: "claude-cli", + sessionEntry: { execHost: "node", execNode: "node-a" } as never, + cliToolAvailability: { native: ["Read"], openClaw: ["message"] }, + }); + + expect(prepareExecution).toHaveBeenCalledWith( + expect.objectContaining({ + toolAvailability: { native: ["Read"], openClaw: [] }, + }), + ); + expect(context.params.cliToolAvailability).toEqual({ native: ["Read"], openClaw: [] }); + await context.preparedBackend.cleanup?.(); + }); + + it("keeps runtime toolsAllow canonical and bounds the backend-independent MCP grant", async () => { const resolveExecutionArgs = vi.fn((context: { baseArgs: readonly string[] }) => [ ...context.baseArgs, ]); - const resolveRuntimeToolAvailability = vi.fn(() => ({ - mcp: [ - "mcp__openclaw__read", - "mcp__openclaw__write", - "mcp__openclaw__edit", - "mcp__openclaw__apply_patch", - "mcp__openclaw__exec", - "mcp__openclaw__browser", - "mcp__openclaw__image", - ], - })); const mintMcpLoopbackClientGrant = vi.fn(createTestMcpLoopbackClientGrant); setRawCliBackendForPrepareTest({ id: "claude-cli", @@ -2846,8 +2926,8 @@ describe("prepareCliRunContext", () => { bundleMcp: true, bundleMcpMode: "claude-config-file", nativeToolMode: "selectable", + toolAvailabilityEnforcement: "execution-args", resolveExecutionArgs, - resolveRuntimeToolAvailability, config: { command: "claude", args: ["--print"], @@ -2879,21 +2959,10 @@ describe("prepareCliRunContext", () => { }); cleanup = context.preparedBackend.cleanup; - expect(resolveRuntimeToolAvailability).toHaveBeenCalledWith({ - toolsAllow: ["read", "write", "edit", "apply_patch", "exec", "browser", "image"], - }); expect(context.params.toolsAllow).toBeUndefined(); expect(context.params.cliToolAvailability).toEqual({ native: [], - mcp: [ - "mcp__openclaw__read", - "mcp__openclaw__write", - "mcp__openclaw__edit", - "mcp__openclaw__apply_patch", - "mcp__openclaw__exec", - "mcp__openclaw__browser", - "mcp__openclaw__image", - ], + openClaw: ["read", "write", "edit", "apply_patch", "exec", "browser", "image"], }); expect(mintMcpLoopbackClientGrant.mock.calls[0]?.[0]?.context.toolsAllow).toEqual([ "read", @@ -2912,47 +2981,6 @@ describe("prepareCliRunContext", () => { } }); - it("rejects a backend that expands runtime toolsAllow beyond the requested grant", async () => { - const getActiveMcpLoopbackRuntime = vi.fn(() => ({ - port: 31783, - ownerToken: "loopback-owner-token", - nonOwnerToken: "loopback-non-owner-token", - })); - setRawCliBackendForPrepareTest({ - id: "claude-cli", - pluginId: "anthropic", - bundleMcp: true, - bundleMcpMode: "claude-config-file", - nativeToolMode: "selectable", - resolveExecutionArgs: ({ baseArgs }) => [...baseArgs], - resolveRuntimeToolAvailability: () => ({ - mcp: ["mcp__openclaw__read", "mcp__openclaw__exec"], - }), - config: { - command: "claude", - args: ["--print"], - output: "jsonl", - jsonlDialect: "claude-stream-json", - input: "stdin", - sessionMode: "existing", - }, - }); - setCliRunnerPrepareTestDeps({ - getActiveMcpLoopbackRuntime, - }); - - await expect( - fixture.prepare({ - sessionKey: "agent:main:main", - provider: "claude-cli", - toolsAllow: ["read"], - }), - ).rejects.toThrow( - "CLI backend claude-cli expanded runtime toolsAllow outside the requested OpenClaw MCP grant: mcp__openclaw__exec", - ); - expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled(); - }); - it("bounds the loopback grant to the selectable MCP tool allowlist", async () => { const resolveExecutionArgs = vi.fn((context: { baseArgs: readonly string[] }) => [ ...context.baseArgs, @@ -2964,6 +2992,7 @@ describe("prepareCliRunContext", () => { bundleMcp: true, bundleMcpMode: "claude-config-file", nativeToolMode: "selectable", + toolAvailabilityEnforcement: "execution-args", resolveExecutionArgs, config: { command: "claude", @@ -3001,13 +3030,12 @@ describe("prepareCliRunContext", () => { }, cliToolAvailability: { native: [], - mcp: ["mcp__openclaw__memory_search", "mcp__openclaw__memory_get", "mcp__other__thing"], + openClaw: ["memory_search", "memory_get"], }, }); cleanup = context.preparedBackend.cleanup; - // Foreign-server entries are not loopback-governed; the grant carries - // only the gateway tool names the run may reach. + // The grant carries exactly the canonical gateway tool names. const grantContext = mintMcpLoopbackClientGrant.mock.calls[0]?.[0]?.context; expect(grantContext?.toolsAllow).toEqual(["memory_search", "memory_get"]); @@ -3030,13 +3058,13 @@ describe("prepareCliRunContext", () => { const resolveExecutionArgs = vi.fn( (context: { baseArgs: readonly string[]; - toolAvailability?: { native: readonly string[]; mcp: readonly string[] }; + toolAvailability?: { native: readonly string[]; openClaw: readonly string[] }; }) => [ ...context.baseArgs, "--tools", context.toolAvailability?.native.join(",") ?? "default", "--allowedTools", - context.toolAvailability?.mcp.join(",") ?? "", + context.toolAvailability?.openClaw.join(",") ?? "", ], ); setCliRunnerPrepareTestDeps({ getActiveMcpLoopbackRuntime }); @@ -3046,6 +3074,7 @@ describe("prepareCliRunContext", () => { bundleMcp: true, bundleMcpMode: "claude-config-file", nativeToolMode: "selectable", + toolAvailabilityEnforcement: "execution-args", resolveExecutionArgs, config: { command: "claude", @@ -3071,7 +3100,7 @@ describe("prepareCliRunContext", () => { systemAgentTool: { surface: "cli" }, cliToolAvailability: { native: [], - mcp: ["mcp__openclaw__openclaw"], + openClaw: ["openclaw"], }, }; const context = await prepareCliRunContext(params); @@ -3089,7 +3118,7 @@ describe("prepareCliRunContext", () => { expect(resolveExecutionArgs).not.toHaveBeenCalled(); expect(context.params.cliToolAvailability).toEqual({ native: [], - mcp: ["mcp__openclaw__openclaw"], + openClaw: ["openclaw"], }); const mcpConfigPath = expectDefined( args[args.indexOf("--mcp-config") + 1], diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index ecc8661b517b..e2fbea811a8e 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -92,7 +92,7 @@ import { collectRuntimeChannelCapabilities } from "../runtime-capabilities.js"; import { ensureSandboxWorkspaceForSession } from "../sandbox.js"; import { buildSystemPromptReport } from "../system-prompt-report.js"; import { appendModelIdentitySystemPrompt, buildModelIdentityPromptLine } from "../system-prompt.js"; -import { expandToolGroups } from "../tool-policy.js"; +import { expandToolGroups, normalizeToolName } from "../tool-policy.js"; import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js"; import { DEFAULT_BOOTSTRAP_FILENAME, @@ -121,10 +121,6 @@ import { loadCliSessionReseedMessages, resolveAutoCliSessionReseedHistoryChars, } from "./session-history.js"; -import { - OPENCLAW_MCP_TOOL_PREFIX, - resolveLoopbackToolsAllowFromMcpPermissions, -} from "./tool-policy.js"; import type { CliReusableSession, CliSecretInput, @@ -372,34 +368,15 @@ export async function prepareCliRunContext( if (normalizedToolsAllow.includes("*")) { params = { ...params, toolsAllow: undefined }; } else { - const resolvedAvailability = backendResolved.resolveRuntimeToolAvailability?.({ - toolsAllow: normalizedToolsAllow, - }); - if (!resolvedAvailability) { - throw new Error( - `CLI backend ${backendResolved.id} cannot enforce runtime toolsAllow; use an embedded runtime for restricted tool policy`, - ); - } - const resolvedMcpPermissions = uniqueStrings( - resolvedAvailability.mcp.map((permission) => permission.trim()).filter(Boolean), + const canonicalToolsAllow = uniqueStrings( + normalizedToolsAllow.map((toolName) => normalizeToolName(toolName)).filter(Boolean), ); - const allowedMcpPermissions = new Set( - normalizedToolsAllow.map((toolName) => `${OPENCLAW_MCP_TOOL_PREFIX}${toolName}`), - ); - const expandedMcpPermissions = resolvedMcpPermissions.filter( - (permission) => !allowedMcpPermissions.has(permission), - ); - if (expandedMcpPermissions.length > 0) { - throw new Error( - `CLI backend ${backendResolved.id} expanded runtime toolsAllow outside the requested OpenClaw MCP grant: ${expandedMcpPermissions.join(", ")}`, - ); - } params = { ...params, toolsAllow: undefined, cliToolAvailability: { native: [], - mcp: resolvedMcpPermissions, + openClaw: canonicalToolsAllow, }, }; } @@ -410,9 +387,25 @@ export async function prepareCliRunContext( execHost: params.sessionEntry?.execHost, execNode: params.sessionEntry?.execNode, }); + if (nodeClaudePlacement && params.cliToolAvailability) { + // Gateway-loopback MCP tools do not exist on the node. Project the policy + // before either backend enforcement phase so staged settings and argv agree. + params = { + ...params, + cliToolAvailability: { + native: params.cliToolAvailability.native, + openClaw: [], + }, + }; + } if ( params.cliToolAvailability !== undefined && - (backendResolved.nativeToolMode !== "selectable" || !backendResolved.resolveExecutionArgs) + (backendResolved.nativeToolMode !== "selectable" || + !backendResolved.toolAvailabilityEnforcement || + (backendResolved.toolAvailabilityEnforcement === "execution-args" && + !backendResolved.resolveExecutionArgs) || + (backendResolved.toolAvailabilityEnforcement === "prepare-execution" && + !backendResolved.prepareExecution)) ) { throw new Error( `CLI backend ${backendResolved.id} cannot enforce exact per-run tool availability`, @@ -521,7 +514,7 @@ export async function prepareCliRunContext( JSON.stringify([ baseExtraSystemPromptHash ?? null, params.cliToolAvailability.native.toSorted(), - params.cliToolAvailability.mcp.toSorted(), + params.cliToolAvailability.openClaw.toSorted(), ]), ) : baseExtraSystemPromptHash; @@ -726,9 +719,7 @@ export async function prepareCliRunContext( // user/plugin MCP servers must not be merged into the run's config at all. // The loopback server (scoped by the grant allowlist) becomes the complete // tool universe for the run. - const restrictedLoopbackToolsAllow = resolveLoopbackToolsAllowFromMcpPermissions( - params.cliToolAvailability?.mcp, - ); + const restrictedLoopbackToolsAllow = params.cliToolAvailability?.openClaw; let cleanupPreparedResources: (() => Promise) | undefined; let preparedExecution: PrivateCliBackendPreparedExecution | undefined; try { @@ -826,8 +817,9 @@ export async function prepareCliRunContext( contextTokenBudget: contextWindowInfo.tokens, authProfileId: effectiveAuthProfileId, executionMode, + toolAvailability: params.cliToolAvailability, env: preparedBackend.env, - } as Parameters>[0]; + } satisfies Parameters>[0]; preparedExecution = (await backendResolved.prepareExecution?.( (backendResolved.id === "google-gemini-cli" || backendResolved.id === "claude-cli" @@ -853,6 +845,15 @@ export async function prepareCliRunContext( } : undefined; cleanupPreparedResources = preparedBackendCleanup; + if ( + params.cliToolAvailability && + backendResolved.toolAvailabilityEnforcement === "prepare-execution" && + preparedExecution?.toolAvailabilityEnforced !== true + ) { + throw new Error( + `CLI backend ${backendResolved.id} did not enforce exact per-run tool availability during execution preparation`, + ); + } const skipLocalCredentialEpoch = shouldSkipLocalCliCredentialEpoch({ authEpochMode: backendResolved.authEpochMode, authProfileId: effectiveAuthProfileId, diff --git a/src/agents/cli-runner/tool-policy.test.ts b/src/agents/cli-runner/tool-policy.test.ts index 116495e619df..14de71354d95 100644 --- a/src/agents/cli-runner/tool-policy.test.ts +++ b/src/agents/cli-runner/tool-policy.test.ts @@ -1,48 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - resolveCliRuntimeToolsAllow, - resolveLoopbackToolsAllowFromMcpPermissions, - stripOpenClawMcpToolPrefix, -} from "./tool-policy.js"; - -describe("resolveLoopbackToolsAllowFromMcpPermissions", () => { - it("returns undefined when no MCP permission list is set", () => { - expect(resolveLoopbackToolsAllowFromMcpPermissions(undefined)).toBeUndefined(); - }); - - it("maps prefixed loopback names to gateway tool names", () => { - expect( - resolveLoopbackToolsAllowFromMcpPermissions([ - "mcp__openclaw__memory_search", - "mcp__openclaw__memory_get", - ]), - ).toEqual(["memory_search", "memory_get"]); - }); - - it("keeps the full surface on wildcard entries", () => { - expect(resolveLoopbackToolsAllowFromMcpPermissions(["mcp__openclaw__*"])).toBeUndefined(); - expect( - resolveLoopbackToolsAllowFromMcpPermissions(["mcp__openclaw__memory_search", "*"]), - ).toBeUndefined(); - }); - - it("drops tools owned by other MCP servers and fails closed when none remain", () => { - expect( - resolveLoopbackToolsAllowFromMcpPermissions([ - "mcp__other__thing", - "mcp__openclaw__memory_search", - ]), - ).toEqual(["memory_search"]); - // Only foreign-server entries: the loopback surface exposes nothing. - expect(resolveLoopbackToolsAllowFromMcpPermissions(["mcp__other__thing"])).toEqual([]); - }); - - it("normalizes and dedupes unprefixed entries", () => { - expect( - resolveLoopbackToolsAllowFromMcpPermissions([" Memory_Search ", "memory_search"]), - ).toEqual(["memory_search"]); - }); -}); +import { resolveCliRuntimeToolsAllow, stripOpenClawMcpToolPrefix } from "./tool-policy.js"; describe("stripOpenClawMcpToolPrefix", () => { it("strips only the loopback transport prefix", () => { diff --git a/src/agents/cli-runner/tool-policy.ts b/src/agents/cli-runner/tool-policy.ts index 7d235df49807..4d1843c53c23 100644 --- a/src/agents/cli-runner/tool-policy.ts +++ b/src/agents/cli-runner/tool-policy.ts @@ -10,38 +10,6 @@ export function stripOpenClawMcpToolPrefix(toolName: string): string { : toolName; } -/** - * Derives the loopback MCP grant allowlist from a selectable-backend MCP - * permission list. Wildcards keep the full session-scoped surface; entries for - * other MCP servers are not loopback-governed and drop out. A non-wildcard - * list that leaves no loopback names fails closed (empty allowlist). - */ -export function resolveLoopbackToolsAllowFromMcpPermissions( - mcp: readonly string[] | undefined, -): string[] | undefined { - if (!mcp) { - return undefined; - } - const names = new Set(); - for (const entry of mcp) { - const trimmed = entry.trim(); - if (!trimmed) { - continue; - } - if (trimmed === "*" || trimmed === `${OPENCLAW_MCP_TOOL_PREFIX}*`) { - return undefined; - } - if (trimmed.startsWith("mcp__") && !trimmed.startsWith(OPENCLAW_MCP_TOOL_PREFIX)) { - continue; - } - const name = normalizeToolName(stripOpenClawMcpToolPrefix(trimmed)); - if (name) { - names.add(name); - } - } - return [...names]; -} - /** Keeps only explicit runtime caps for backend-owned exact translation. */ export function resolveCliRuntimeToolsAllow( toolsAllow?: string[], diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index 2dbd2ca91d8f..02371acf291a 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -188,10 +188,10 @@ export type RunCliAgentParams = { toolsAllow?: string[]; /** Trusted server-stamped authority for an explicitly capped scheduled run. */ scheduledToolPolicy?: ScheduledToolPolicyContext; - /** Exact native surface plus host-isolated MCP permissions for a selectable CLI backend. */ + /** Exact native plus canonical OpenClaw surface for a selectable CLI backend. */ cliToolAvailability?: { native: string[]; - mcp: string[]; + openClaw: string[]; }; disableTools?: boolean; abortSignal?: AbortSignal; diff --git a/src/agents/embedded-agent-runner/cli-backend-dispatch.test.ts b/src/agents/embedded-agent-runner/cli-backend-dispatch.test.ts index 92533dd8c1f7..7645b9959b69 100644 --- a/src/agents/embedded-agent-runner/cli-backend-dispatch.test.ts +++ b/src/agents/embedded-agent-runner/cli-backend-dispatch.test.ts @@ -343,11 +343,7 @@ describe("runEmbeddedAgentViaCliBackendIfEligible execution", () => { requireExplicitMessageTarget: true, cliToolAvailability: { native: [], - mcp: [ - "mcp__openclaw__memory_search", - "mcp__openclaw__memory_get", - "mcp__openclaw__notes_retrieve_context", - ], + openClaw: ["memory_search", "memory_get", "notes_retrieve_context"], }, }); // Embedded toolsAllow must never reach the CLI runner: it fails closed. diff --git a/src/agents/embedded-agent-runner/cli-backend-dispatch.ts b/src/agents/embedded-agent-runner/cli-backend-dispatch.ts index 1bc8a10c0672..24f7d076caa4 100644 --- a/src/agents/embedded-agent-runner/cli-backend-dispatch.ts +++ b/src/agents/embedded-agent-runner/cli-backend-dispatch.ts @@ -14,7 +14,7 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { onAgentEvent } from "../../infra/agent-events.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { OPENCLAW_MCP_TOOL_PREFIX, stripOpenClawMcpToolPrefix } from "../cli-runner/tool-policy.js"; +import { stripOpenClawMcpToolPrefix } from "../cli-runner/tool-policy.js"; import { normalizeToolName } from "../tool-policy.js"; import { isToolResultError } from "../tool-result-error.js"; import { resolveEmbeddedCliBackendDispatchEligibility } from "./cli-backend-dispatch-eligibility.js"; @@ -99,7 +99,7 @@ async function runEmbeddedAgentViaCliBackend( // unreachable, matching disableMessageTool intent. const cliToolAvailability = { native: [] as [], - mcp: dispatch.toolsAllow.map((name) => `${OPENCLAW_MCP_TOOL_PREFIX}${name}`), + openClaw: dispatch.toolsAllow, }; const onAgentToolResult = params.onAgentToolResult; // The CLI backend writes no OpenClaw session records; mirror the run into diff --git a/src/gateway/mcp-grant-store.ts b/src/gateway/mcp-grant-store.ts index 67913b80004c..c68bdbecf95d 100644 --- a/src/gateway/mcp-grant-store.ts +++ b/src/gateway/mcp-grant-store.ts @@ -15,6 +15,9 @@ export type McpLoopbackRequestContext = { agentId?: string; sessionId?: string; runId?: string; + /** Server-selected roots for mediated coding tools in this CLI run. */ + workspaceDir?: string; + cwd?: string; modelProvider?: string; modelId?: string; messageProvider?: string; diff --git a/src/gateway/mcp-http.runtime.test.ts b/src/gateway/mcp-http.runtime.test.ts index 203d303e78d4..eee3e438f520 100644 --- a/src/gateway/mcp-http.runtime.test.ts +++ b/src/gateway/mcp-http.runtime.test.ts @@ -82,12 +82,14 @@ describe("resolveMcpLoopbackScopedTools", () => { ]); const call = resolveGatewayScopedTools.mock.calls[0]?.[0] as { excludeToolNames?: Set; + mediatedToolNames?: Set; includeNodeExecTool?: boolean; }; expect(call.includeNodeExecTool).toBe(false); expect(call.excludeToolNames?.has("read")).toBe(false); expect(call.excludeToolNames?.has("exec")).toBe(false); expect(call.excludeToolNames?.has("write")).toBe(true); + expect(call.mediatedToolNames).toEqual(new Set(["read", "exec"])); }); }); diff --git a/src/gateway/mcp-http.runtime.ts b/src/gateway/mcp-http.runtime.ts index c176cbdb2ae8..7f6cc7e6c500 100644 --- a/src/gateway/mcp-http.runtime.ts +++ b/src/gateway/mcp-http.runtime.ts @@ -40,6 +40,9 @@ type McpLoopbackScopeParams = { runtimePolicySessionKey?: string; agentId?: string; sessionId?: string; + runId?: string; + workspaceDir?: string; + cwd?: string; modelProvider?: string; modelId?: string; yieldContextCacheKey?: string; @@ -100,6 +103,7 @@ export function resolveMcpLoopbackScopedTools(params: McpLoopbackScopeParams): { conversationReadOrigin: "delegated", surface: "loopback", excludeToolNames, + mediatedToolNames: mediatedNativeTools, includeNodeExecTool, }); return { @@ -140,6 +144,9 @@ export class McpLoopbackToolCache { params.runtimePolicySessionKey ?? "", params.agentId ?? "", params.sessionId ?? "", + params.runId ?? "", + params.workspaceDir ?? "", + params.cwd ?? "", params.modelProvider ?? "", params.modelId ?? "", params.yieldContextCacheKey ?? "", diff --git a/src/gateway/mcp-http.ts b/src/gateway/mcp-http.ts index a08e918dccfa..621d4699fafb 100644 --- a/src/gateway/mcp-http.ts +++ b/src/gateway/mcp-http.ts @@ -294,6 +294,9 @@ async function startMcpLoopbackServer(port = 0): Promise<{ runtimePolicySessionKey: requestContext.runtimePolicySessionKey, agentId: requestContext.agentId, sessionId: requestContext.sessionId, + runId: requestContext.runId, + workspaceDir: requestContext.workspaceDir, + cwd: requestContext.cwd, modelProvider: requestContext.modelProvider, modelId: requestContext.modelId, yieldContextCacheKey: yieldContext?.cacheKey, diff --git a/src/gateway/tool-resolution.exclude.test.ts b/src/gateway/tool-resolution.exclude.test.ts index 843a78771e3c..9f02ffe5e0d3 100644 --- a/src/gateway/tool-resolution.exclude.test.ts +++ b/src/gateway/tool-resolution.exclude.test.ts @@ -19,6 +19,16 @@ type CreateOpenClawToolsArg = { requesterAgentIdOverride?: string; }; +type CreateOpenClawCodingToolsArg = { + runtimeToolAllowlist?: string[]; + sessionKey?: string; + runSessionKey?: string; + workspaceDir?: string; + cwd?: string; + wrapBeforeToolCallHook?: boolean; + scheduledToolPolicy?: { ownerSessionKey: string }; +}; + type LazyExecToolDefaults = { host?: string; allowBackground?: boolean; @@ -57,6 +67,9 @@ const hoisted = vi.hoisted(() => { makeTool, createLazyExecToolMock, getLoadedChannelPluginMock: vi.fn(), + createOpenClawCodingToolsMock: vi.fn( + (_args: CreateOpenClawCodingToolsArg): ReturnType[] => [], + ), createOpenClawToolsMock: vi.fn((_args: CreateOpenClawToolsArg) => [ makeTool("read"), makeTool("sessions_spawn"), @@ -71,6 +84,11 @@ vi.mock("../agents/openclaw-tools.js", () => ({ createOpenClawTools: (args: CreateOpenClawToolsArg) => hoisted.createOpenClawToolsMock(args), })); +vi.mock("../agents/agent-tools.js", () => ({ + createOpenClawCodingTools: (args: CreateOpenClawCodingToolsArg) => + hoisted.createOpenClawCodingToolsMock(args), +})); + vi.mock("../channels/plugins/index.js", () => ({ getLoadedChannelPlugin: (channel: string) => hoisted.getLoadedChannelPluginMock(channel), })); @@ -87,6 +105,8 @@ describe("resolveGatewayScopedTools excludeToolNames", () => { beforeEach(() => { hoisted.createOpenClawToolsMock.mockClear(); hoisted.createLazyExecToolMock.mockClear(); + hoisted.createOpenClawCodingToolsMock.mockReset(); + hoisted.createOpenClawCodingToolsMock.mockReturnValue([]); hoisted.getLoadedChannelPluginMock.mockReset(); }); @@ -128,6 +148,54 @@ describe("resolveGatewayScopedTools excludeToolNames", () => { expect(args.inheritedToolDenylist).toEqual([]); }); + it("constructs exact coding tools for a server-minted mediated grant", () => { + hoisted.createOpenClawCodingToolsMock.mockReturnValueOnce([hoisted.makeTool("write")]); + + const result = resolveGatewayScopedTools({ + cfg: { tools: { exec: { host: "node" } } } as OpenClawConfig, + sessionKey: "agent:main:cron:run-1", + runtimePolicySessionKey: "agent:main:qa-channel:group:ops", + runId: "run-1", + workspaceDir: "/workspace", + cwd: "/workspace/task", + surface: "loopback", + excludeToolNames: ["read", "edit", "apply_patch", "exec", "process"], + mediatedToolNames: ["write"], + scheduledToolPolicy: { ownerSessionKey: "agent:main:qa-channel:group:ops" }, + }); + + expect(result.tools.map((tool) => tool.name)).toContain("write"); + expect(hoisted.createOpenClawCodingToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ + runtimeToolAllowlist: ["write"], + sessionKey: "agent:main:qa-channel:group:ops", + runSessionKey: "agent:main:cron:run-1", + workspaceDir: "/workspace", + cwd: "/workspace/task", + wrapBeforeToolCallHook: false, + scheduledToolPolicy: { ownerSessionKey: "agent:main:qa-channel:group:ops" }, + }), + ); + expect(hoisted.createLazyExecToolMock).not.toHaveBeenCalled(); + }); + + it("does not fall back when policy removes a mediated coding tool", () => { + hoisted.createOpenClawToolsMock.mockReturnValueOnce([ + hoisted.makeTool("write"), + hoisted.makeTool("cron"), + ]); + + const result = resolveGatewayScopedTools({ + cfg: {} as OpenClawConfig, + sessionKey: "agent:main:cron:run-1", + surface: "loopback", + mediatedToolNames: ["write"], + excludeToolNames: ["read", "edit", "apply_patch", "exec", "process"], + }); + + expect(result.tools.map((tool) => tool.name)).toEqual(["cron"]); + }); + it("keeps owner-only core tools visible only for owner loopback callers", () => { const ownerResult = resolveGatewayScopedTools({ cfg: { diff --git a/src/gateway/tool-resolution.test.ts b/src/gateway/tool-resolution.test.ts index 92bafccfd83a..7fd42179c963 100644 --- a/src/gateway/tool-resolution.test.ts +++ b/src/gateway/tool-resolution.test.ts @@ -1,6 +1,9 @@ /** * Gateway tool-resolution tests. */ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveGatewayScopedTools } from "./tool-resolution.js"; @@ -67,6 +70,32 @@ describe("resolveGatewayScopedTools", () => { expect(result.tools.some((tool) => tool.name === "message")).toBe(false); }); + it("materializes an executable write tool on the mediated CLI surface", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mediated-write-")); + try { + const result = resolveGatewayScopedTools({ + cfg: {} as OpenClawConfig, + sessionKey: "agent:main:cron:mediated-write", + surface: "loopback", + workspaceDir, + mediatedToolNames: ["write"], + excludeToolNames: ["read", "edit", "apply_patch", "exec", "process"], + }); + + const writeTool = result.tools.find((tool) => tool.name === "write"); + expect(writeTool).toBeDefined(); + await writeTool?.execute?.("mediated-write-call", { + path: "proof.txt", + content: "mediated write ok", + }); + await expect(fs.readFile(path.join(workspaceDir, "proof.txt"), "utf8")).resolves.toBe( + "mediated write ok", + ); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + }); + it("applies sandbox tool denies to sandboxed loopback turns", () => { const result = resolveGatewayScopedTools({ cfg: { diff --git a/src/gateway/tool-resolution.ts b/src/gateway/tool-resolution.ts index b0b1a2c10aba..115f0ea903da 100644 --- a/src/gateway/tool-resolution.ts +++ b/src/gateway/tool-resolution.ts @@ -1,5 +1,6 @@ // Gateway-scoped tool resolution for HTTP and loopback tool surfaces. import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { createOpenClawCodingTools } from "../agents/agent-tools.js"; import { filterToolsByMessageProvider } from "../agents/agent-tools.message-provider-policy.js"; import { resolveEffectiveToolPolicy } from "../agents/agent-tools.policy.js"; import type { ExecElevatedDefaults } from "../agents/bash-tools.exec-types.js"; @@ -24,6 +25,7 @@ import { collectExplicitDenylist, hasRestrictiveAllowPolicy, mergeAlsoAllowPolicy, + normalizeToolName, replaceWithEffectiveToolAllowlist, resolveToolProfilePolicy, } from "../agents/tool-policy.js"; @@ -59,6 +61,9 @@ export function resolveGatewayScopedTools(params: { runtimePolicySessionKey?: string; agentId?: string; sessionId?: string; + runId?: string; + workspaceDir?: string; + cwd?: string; modelProvider?: string; modelId?: string; onYield?: (message: string) => Promise | void; @@ -81,6 +86,8 @@ export function resolveGatewayScopedTools(params: { allowMediaInvokeCommands?: boolean; surface?: GatewayScopedToolSurface; excludeToolNames?: Iterable; + /** Server-minted coding tools that must be mediated through the loopback surface. */ + mediatedToolNames?: Iterable; disablePluginTools?: boolean; gatewayRequestedTools?: string[]; /** Add the CLI-only, node-forced exec tool before applying the shared policy pipeline. */ @@ -179,6 +186,9 @@ export function resolveGatewayScopedTools(params: { }); const sandboxPolicy = sandboxRuntime.sandboxed ? sandboxRuntime.toolPolicy : undefined; const excludedToolNames = params.excludeToolNames ? Array.from(params.excludeToolNames) : []; + const mediatedToolNames = new Set( + Array.from(params.mediatedToolNames ?? [], (name) => normalizeToolName(name)).filter(Boolean), + ); const gatewayToolsCfg = params.cfg.gateway?.tools; const defaultGatewayDeny = surface === "http" @@ -189,10 +199,9 @@ export function resolveGatewayScopedTools(params: { ? [...GATEWAY_OWNER_ONLY_CORE_TOOLS] : []; // HTTP callers start with additional surface denies because they cross auth only. - const workspaceDir = resolveAgentWorkspaceDir( - params.cfg, - agentId ?? resolveDefaultAgentId(params.cfg), - ); + const workspaceDir = + params.workspaceDir?.trim() || + resolveAgentWorkspaceDir(params.cfg, agentId ?? resolveDefaultAgentId(params.cfg)); const explicitDenylist = collectExplicitDenylist([ profilePolicy, providerProfilePolicy, @@ -275,36 +284,113 @@ export function resolveGatewayScopedTools(params: { inheritedToolAllowlist, inheritedToolDenylist, }); - const nodeExecCandidate = nodeExecSurface - ? resolveExecDefaults({ - cfg: params.cfg, - sessionEntry: params.execSession, - execOverrides: params.execOverrides, - agentId, - sessionKey: runtimePolicySessionKey, - sandboxAvailable: sandboxRuntime.sandboxed, - }) - : undefined; - const includeNodeExecTool = nodeExecCandidate?.canRequestNode === true; + const execDefaults = + nodeExecSurface || mediatedToolNames.size > 0 + ? resolveExecDefaults({ + cfg: params.cfg, + sessionEntry: params.execSession, + execOverrides: params.execOverrides, + agentId, + sessionKey: runtimePolicySessionKey, + sandboxAvailable: sandboxRuntime.sandboxed, + }) + : undefined; + const nodeExecDefaults = + nodeExecSurface && execDefaults?.canRequestNode === true ? execDefaults : undefined; + const includeNodeExecTool = nodeExecDefaults !== undefined; const execConfig = includeNodeExecTool ? resolveExecToolConfig({ cfg: params.cfg, agentId }) : undefined; + const includeMediatedBaseCodingTools = ["read", "write", "edit"].some((name) => + mediatedToolNames.has(name), + ); + const includeMediatedShellTools = ["apply_patch", "exec", "process"].some((name) => + mediatedToolNames.has(name), + ); + const mediatedCodingTools = + surface === "loopback" && (includeMediatedBaseCodingTools || includeMediatedShellTools) + ? createOpenClawCodingTools({ + config: params.cfg, + agentId, + sessionKey: runtimePolicySessionKey, + runSessionKey: params.sessionKey, + sessionId: params.sessionId, + runId: params.runId, + workspaceDir, + cwd: params.cwd?.trim() || workspaceDir, + modelProvider: params.modelProvider, + modelId: params.modelId, + messageProvider: params.messageProvider, + messageChannel: params.messageProvider, + clientCaps: params.clientCaps, + agentAccountId: params.accountId, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + currentInboundAudio: params.currentInboundAudio, + channelContext: params.channelContext, + groupId: params.groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, + spawnedBy: params.spawnedBy, + senderId: params.channelContext?.sender?.id, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + senderIsOwner: params.senderIsOwner, + trigger: params.trigger, + approvalReviewerDeviceId: params.approvalReviewerDeviceId, + sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: params.taskSuggestionDeliveryMode, + inboundEventKind: params.inboundEventKind, + requireExplicitMessageTarget: params.requireExplicitMessageTarget, + runtimeToolAllowlist: [...mediatedToolNames], + exec: execDefaults + ? { + host: execDefaults.host, + mode: execDefaults.mode, + security: execDefaults.security, + ask: execDefaults.ask, + node: execDefaults.node, + elevated: params.bashElevated, + } + : undefined, + scheduledToolPolicy: params.scheduledToolPolicy, + toolConstructionPlan: { + includeBaseCodingTools: includeMediatedBaseCodingTools, + includeShellTools: includeMediatedShellTools, + includeChannelTools: false, + includeOpenClawTools: false, + includePluginTools: false, + }, + // The MCP dispatcher is the shared hook and abort boundary for these tools. + wrapBeforeToolCallHook: false, + toolPolicyAuditLogLevel: "debug", + }) + : []; // CLI backends already own their local shell. This extra surface is deliberately // fixed to node so it cannot become a second path to Gateway-local execution. const baseTools = nodeExecSurface ? openClawTools.filter((tool) => tool.name.trim().toLowerCase() !== "exec") : openClawTools; - const allTools = includeNodeExecTool + const toolsWithMediatedCoding = [ + // Once a name is server-minted as mediated, only the canonical coding + // factory may supply it. A policy-filtered tool must not fall back to a + // coincidentally named Gateway/plugin implementation. + ...baseTools.filter((tool) => !mediatedToolNames.has(normalizeToolName(tool.name))), + ...mediatedCodingTools, + ]; + const allTools = nodeExecDefaults ? [ - ...baseTools, + ...toolsWithMediatedCoding, createLazyExecTool( { host: "node", - mode: nodeExecCandidate.mode, - security: nodeExecCandidate.security, - ask: nodeExecCandidate.ask, + mode: nodeExecDefaults.mode, + security: nodeExecDefaults.security, + ask: nodeExecDefaults.ask, trigger: params.trigger, - node: nodeExecCandidate.node, + node: nodeExecDefaults.node, pathPrepend: execConfig?.pathPrepend, safeBins: execConfig?.safeBins, strictInlineEval: execConfig?.strictInlineEval, @@ -349,7 +435,7 @@ export function resolveGatewayScopedTools(params: { }, ), ] - : baseTools; + : toolsWithMediatedCoding; const toolsForMessageProvider = filterToolsByMessageProvider(allTools, params.messageProvider); const policyFiltered = applyToolPolicyPipeline({ diff --git a/src/plugin-sdk/cli-backend.ts b/src/plugin-sdk/cli-backend.ts index c27f51c04978..9d075a40e65c 100644 --- a/src/plugin-sdk/cli-backend.ts +++ b/src/plugin-sdk/cli-backend.ts @@ -12,11 +12,9 @@ export type { CliBackendPrepareExecutionContext, CliBackendResolveExecutionArgs, CliBackendResolveExecutionArgsContext, - CliBackendResolveRuntimeToolAvailability, - CliBackendResolveRuntimeToolAvailabilityContext, - CliBackendRuntimeToolAvailability, CliBackendSideQuestionToolMode, CliBackendToolAvailability, + CliBackendToolAvailabilityEnforcement, CliBackendThinkingLevel, } from "../plugins/types.js"; export type { CliBackendRuntimeArtifactPolicy } from "../plugins/cli-backend.types.js"; diff --git a/src/plugins/cli-backend.types.ts b/src/plugins/cli-backend.types.ts index 889586b2fcdd..8d3e8ae6ab5c 100644 --- a/src/plugins/cli-backend.types.ts +++ b/src/plugins/cli-backend.types.ts @@ -113,6 +113,10 @@ export type CliBackendPrepareExecutionContext = { contextTokenBudget?: number; authProfileId?: string; executionMode?: CliBackendExecutionMode; + /** Exact runtime tool surface the backend must enforce for this run. */ + toolAvailability?: CliBackendToolAvailability; + /** Core-prepared environment, including any bundled MCP settings path. */ + env?: Readonly>; }; export type CliBackendPreparedExecution = { @@ -124,6 +128,8 @@ export type CliBackendPreparedExecution = { */ beforeExecution?: () => Promise; cleanup?: () => Promise; + /** Positive acknowledgement for `prepare-execution` tool enforcement. */ + toolAvailabilityEnforced?: true; }; export type CliBackendThinkingLevel = @@ -138,27 +144,13 @@ export type CliBackendThinkingLevel = export type CliBackendExecutionMode = "agent" | "side-question"; -/** Exact backend-native surface plus host-isolated MCP permissions for one CLI run. */ +/** Exact backend-native plus canonical OpenClaw tool surface for one CLI run. */ export type CliBackendToolAvailability = { native: readonly string[]; - /** MCP tools already isolated by the host transport that may be auto-approved. */ - mcp: readonly string[]; + /** Canonical OpenClaw tool names served through the host-isolated transport. */ + openClaw: readonly string[]; }; -export type CliBackendResolveRuntimeToolAvailabilityContext = { - /** Normalized, group-expanded OpenClaw runtime allowlist. */ - toolsAllow: readonly string[]; -}; - -export type CliBackendRuntimeToolAvailability = { - /** Host-isolated MCP tools selected from the OpenClaw runtime allowlist. */ - mcp: readonly string[]; -}; - -export type CliBackendResolveRuntimeToolAvailability = ( - ctx: CliBackendResolveRuntimeToolAvailabilityContext, -) => CliBackendRuntimeToolAvailability | null | undefined; - export type CliBackendResolveExecutionArgsContext = { config?: OpenClawConfig; workspaceDir: string; @@ -180,6 +172,9 @@ export type CliBackendAuthEpochMode = "combined" | "profile-only"; export type CliBackendNativeToolMode = "none" | "always-on" | "selectable"; +/** Backend-owned mechanism that enforces exact per-run tool availability. */ +export type CliBackendToolAvailabilityEnforcement = "execution-args" | "prepare-execution"; + export type CliBackendSideQuestionToolMode = "disabled"; export type CliBackendNormalizeConfigContext = { @@ -333,20 +328,12 @@ export type CliBackendPlugin = { * native effort flag. */ resolveExecutionArgs?: CliBackendResolveExecutionArgs; - /** - * Translate an OpenClaw runtime allowlist into the host-isolated MCP tools - * this backend can expose for one run. OpenClaw disables all native tools. - * - * Return null/undefined when the backend cannot enforce the requested cap. - * Omitting an allowed tool is safe; adding MCP authority absent from the - * allowlist is not. - */ - resolveRuntimeToolAvailability?: CliBackendResolveRuntimeToolAvailability; + /** How this backend enforces an exact per-run `toolAvailability` contract. */ + toolAvailabilityEnforcement?: CliBackendToolAvailabilityEnforcement; /** * Whether this CLI backend can expose native tools outside OpenClaw's tool - * catalog. `selectable` backends must enforce `toolAvailability` through - * `resolveExecutionArgs`; `always-on` backends fail closed for restricted - * callers. + * catalog. Exact restricted runs require `selectable` plus a declared + * `toolAvailabilityEnforcement`; `always-on` backends fail closed. */ nativeToolMode?: CliBackendNativeToolMode; /** diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 3e5951550a3d..54f9f02c9215 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -17,11 +17,9 @@ export type { CliBackendPrepareExecutionContext, CliBackendResolveExecutionArgs, CliBackendResolveExecutionArgsContext, - CliBackendResolveRuntimeToolAvailability, - CliBackendResolveRuntimeToolAvailabilityContext, - CliBackendRuntimeToolAvailability, CliBackendSideQuestionToolMode, CliBackendToolAvailability, + CliBackendToolAvailabilityEnforcement, CliBackendThinkingLevel, CliBundleMcpMode, PluginTextTransforms, diff --git a/src/system-agent/agent-turn.test.ts b/src/system-agent/agent-turn.test.ts index 8481668447f5..54f395e2008a 100644 --- a/src/system-agent/agent-turn.test.ts +++ b/src/system-agent/agent-turn.test.ts @@ -114,6 +114,7 @@ beforeEach(() => { ], }), nativeToolMode: "selectable", + toolAvailabilityEnforcement: "execution-args", sideQuestionToolMode: "disabled", resolveExecutionArgs: (context) => context.baseArgs, }, @@ -337,7 +338,7 @@ describe("runSystemAgentTurn", () => { expect(call.cleanupCliLiveSessionOnRunEnd).toBe(true); expect(call.cliToolAvailability).toEqual({ native: [], - mcp: ["mcp__openclaw__openclaw"], + openClaw: ["openclaw"], }); expect(call.toolsAllow).toBeUndefined(); expect(requireValue(call.systemAgentTool, "missing CLI OpenClaw tool").proposalRef).toBe( diff --git a/src/system-agent/agent-turn.ts b/src/system-agent/agent-turn.ts index 7721de13a7c4..6d3babf8a012 100644 --- a/src/system-agent/agent-turn.ts +++ b/src/system-agent/agent-turn.ts @@ -33,7 +33,7 @@ import { // calls, so even metered external routes need the full window, and 120s // already covers local startup + generation (planner evidence). const AGENT_TURN_TIMEOUT_MS = 120_000; -const SYSTEM_AGENT_MCP_TOOL_NAME = "mcp__openclaw__openclaw"; +const SYSTEM_AGENT_TOOL_NAME = "openclaw"; export type SystemAgentTurnDirective = import("../agents/tools/system-agent-tool.js").SystemAgentToolDirective; @@ -186,6 +186,7 @@ function cliRouteKey( bundleMcpMode: backend.bundleMcpMode, authEpochMode: backend.authEpochMode, nativeToolMode: backend.nativeToolMode, + toolAvailabilityEnforcement: backend.toolAvailabilityEnforcement, sideQuestionToolMode: backend.sideQuestionToolMode, } : null, @@ -211,12 +212,16 @@ function resolveSystemAgentCliBackend( function resolveSystemAgentCliToolAvailability( backend: ResolvedCliBackend | null, -): { native: []; mcp: string[] } | undefined { +): { native: []; openClaw: string[] } | undefined { if (backend?.nativeToolMode === "none") { return undefined; } - if (backend?.nativeToolMode === "selectable" && backend.resolveExecutionArgs) { - return { native: [], mcp: [SYSTEM_AGENT_MCP_TOOL_NAME] }; + if ( + backend?.nativeToolMode === "selectable" && + ((backend.toolAvailabilityEnforcement === "execution-args" && backend.resolveExecutionArgs) || + (backend.toolAvailabilityEnforcement === "prepare-execution" && backend.prepareExecution)) + ) { + return { native: [], openClaw: [SYSTEM_AGENT_TOOL_NAME] }; } const backendId = backend?.id ?? "unknown"; throw new Error(`CLI backend ${backendId} cannot enforce OpenClaw's exact tool availability`); diff --git a/src/system-agent/system-agent.test-helpers.ts b/src/system-agent/system-agent.test-helpers.ts index 9c1d27bb5fc7..1a90bc6cb48c 100644 --- a/src/system-agent/system-agent.test-helpers.ts +++ b/src/system-agent/system-agent.test-helpers.ts @@ -136,6 +136,7 @@ export async function createSystemAgentVerifiedInferenceTestFixture( bundleMcpMode: backend.bundleMcpMode, authEpochMode: backend.authEpochMode, nativeToolMode: backend.nativeToolMode, + toolAvailabilityEnforcement: backend.toolAvailabilityEnforcement, sideQuestionToolMode: backend.sideQuestionToolMode, }, ...(profileId ? { authProfileId: profileId } : {}),