mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(cron): enforce caps across CLI backends
This commit is contained in:
@@ -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__<tool>` 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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Record<string, unknown>> {
|
||||
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<string, unknown>,
|
||||
availability: CliBackendToolAvailability,
|
||||
): Record<string, unknown> {
|
||||
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<CliBackendPreparedExecution> {
|
||||
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<CliBackendPreparedExecution | null> {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, string> }).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,
|
||||
);
|
||||
|
||||
@@ -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<string, Record<string, unknown>>;
|
||||
experimental?: { enableAgents?: boolean };
|
||||
agents?: { overrides?: Record<string, Record<string, unknown>> };
|
||||
hooksConfig?: Record<string, unknown>;
|
||||
skills?: Record<string, unknown>;
|
||||
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-"));
|
||||
|
||||
@@ -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<unknown>
|
||||
>()
|
||||
.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<unknown>
|
||||
>()
|
||||
.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<
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PreparedCliRunContext["params"]["cliToolAvailability"]> = {
|
||||
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);
|
||||
|
||||
@@ -120,6 +120,7 @@ export type PreparedCliRunContextOverrides = {
|
||||
backend?: Partial<PreparedCliRunContext["preparedBackend"]["backend"]>;
|
||||
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: {
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<void>) | 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<NonNullable<typeof backendResolved.prepareExecution>>[0];
|
||||
} satisfies Parameters<NonNullable<typeof backendResolved.prepareExecution>>[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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<string>();
|
||||
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[],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -82,12 +82,14 @@ describe("resolveMcpLoopbackScopedTools", () => {
|
||||
]);
|
||||
const call = resolveGatewayScopedTools.mock.calls[0]?.[0] as {
|
||||
excludeToolNames?: Set<string>;
|
||||
mediatedToolNames?: Set<string>;
|
||||
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"]));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof makeTool>[] => [],
|
||||
),
|
||||
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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+108
-22
@@ -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> | void;
|
||||
@@ -81,6 +86,8 @@ export function resolveGatewayScopedTools(params: {
|
||||
allowMediaInvokeCommands?: boolean;
|
||||
surface?: GatewayScopedToolSurface;
|
||||
excludeToolNames?: Iterable<string>;
|
||||
/** Server-minted coding tools that must be mediated through the loopback surface. */
|
||||
mediatedToolNames?: Iterable<string>;
|
||||
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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<Record<string, string>>;
|
||||
};
|
||||
|
||||
export type CliBackendPreparedExecution = {
|
||||
@@ -124,6 +128,8 @@ export type CliBackendPreparedExecution = {
|
||||
*/
|
||||
beforeExecution?: () => Promise<void>;
|
||||
cleanup?: () => Promise<void>;
|
||||
/** 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;
|
||||
/**
|
||||
|
||||
@@ -17,11 +17,9 @@ export type {
|
||||
CliBackendPrepareExecutionContext,
|
||||
CliBackendResolveExecutionArgs,
|
||||
CliBackendResolveExecutionArgsContext,
|
||||
CliBackendResolveRuntimeToolAvailability,
|
||||
CliBackendResolveRuntimeToolAvailabilityContext,
|
||||
CliBackendRuntimeToolAvailability,
|
||||
CliBackendSideQuestionToolMode,
|
||||
CliBackendToolAvailability,
|
||||
CliBackendToolAvailabilityEnforcement,
|
||||
CliBackendThinkingLevel,
|
||||
CliBundleMcpMode,
|
||||
PluginTextTransforms,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user