import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { findUnsupportedSchemaKeywords, GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, } from "../plugin-sdk/provider-tools.js"; import { initializeGlobalHookRunner, resetGlobalHookRunner, } from "../plugins/hook-runner-global.js"; import { createMockPluginRegistry } from "../plugins/hooks.test-helpers.js"; import "./test-helpers/fast-bash-tools.js"; import "./test-helpers/fast-coding-tools.js"; import "./test-helpers/fast-openclaw-tools.js"; import { createOpenClawCodingTools } from "./agent-tools.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; import * as openClawPluginTools from "./openclaw-plugin-tools.js"; import { createOpenClawTools } from "./openclaw-tools.js"; import { expectReadWriteEditTools } from "./test-helpers/agent-tools-fs-helpers.js"; import { createAgentToolsSandboxContext } from "./test-helpers/agent-tools-sandbox-context.js"; import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js"; import { buildEmptyExplicitToolAllowlistError } from "./tool-allowlist-guard.js"; import { DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, normalizeToolName } from "./tool-policy.js"; const tinyPngBuffer = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2f7z8AAAAASUVORK5CYII=", "base64", ); const XAI_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([ "minLength", "maxLength", "minItems", "maxItems", "minContains", "maxContains", ]); function collectActionValues(schema: unknown, values: Set): void { if (!schema || typeof schema !== "object") { return; } const record = schema as Record; if (typeof record.const === "string") { values.add(record.const); } if (Array.isArray(record.enum)) { for (const value of record.enum) { if (typeof value === "string") { values.add(value); } } } if (Array.isArray(record.anyOf)) { for (const variant of record.anyOf) { collectActionValues(variant, values); } } } async function writeSessionStore( storeTemplate: string, agentId: string, entries: Record, ) { await fs.writeFile( storeTemplate.replaceAll("{agentId}", agentId), JSON.stringify(entries, null, 2), "utf-8", ); } function createToolsForStoredSession(storeTemplate: string, sessionKey: string) { return createOpenClawCodingTools({ sessionKey, config: { session: { store: storeTemplate, }, agents: { defaults: { subagents: { maxSpawnDepth: 2, }, }, }, }, }); } function expectNoSubagentControlTools(tools: ReturnType) { const names = new Set(tools.map((tool) => tool.name)); expect(names.has("sessions_spawn")).toBe(false); expect(names.has("sessions_list")).toBe(false); expect(names.has("sessions_history")).toBe(false); expect(names.has("subagents")).toBe(false); } function applyRuntimeToolsAllow(tools: T[], toolsAllow: string[]) { const allowSet = new Set(toolsAllow.map((name) => normalizeToolName(name))); return tools.filter((tool) => allowSet.has(normalizeToolName(tool.name))); } type OpenClawCodingTool = ReturnType[number]; type OpenClawToolsOptions = NonNullable[0]>; function toolNameList(tools: readonly { name: string }[]): string[] { return tools.map((tool) => tool.name); } function requireTool(tools: OpenClawCodingTool[], name: string): OpenClawCodingTool { const tool = tools.find((candidate) => candidate.name === name); if (!tool) { throw new Error(`expected ${name} tool`); } return tool; } function requireToolExecute(tool: OpenClawCodingTool): NonNullable { if (!tool.execute) { throw new Error(`expected ${tool.name} tool execute`); } return tool.execute; } function latestCreateOpenClawToolsOptions(): OpenClawToolsOptions { const calls = vi.mocked(createOpenClawTools).mock.calls; const lastCall = calls.at(-1); const options = lastCall?.[0]; if (!options) { throw new Error("expected createOpenClawTools call"); } return options; } function expectListIncludes( list: readonly string[] | undefined, expected: readonly string[], ): void { if (!list) { throw new Error("expected string list"); } for (const value of expected) { expect(list.includes(value)).toBe(true); } } describe("createOpenClawCodingTools", () => { const testConfig: OpenClawConfig = {}; afterEach(() => { resetGlobalHookRunner(); }); it("exposes gateway config and restart actions to owner sessions", () => { const tools = createOpenClawCodingTools({ config: testConfig }); const gateway = requireTool(tools, "gateway"); const parameters = gateway.parameters as { properties?: Record; }; const action = parameters.properties?.action as | { const?: unknown; enum?: unknown[] } | undefined; const values = new Set(); collectActionValues(action, values); expectListIncludes([...values], ["restart", "config.get", "config.patch", "config.apply"]); }); it("does not add Tool Search control tools from the shared factory by default", () => { const tools = createOpenClawCodingTools({ config: { tools: { toolSearch: true, }, }, }); const names = new Set(tools.map((tool) => tool.name)); expect(names.has("tool_search_code")).toBe(false); expect(names.has("tool_search")).toBe(false); expect(names.has("tool_describe")).toBe(false); expect(names.has("tool_call")).toBe(false); }); it("passes explicit hook channel ids to wrapped tool hooks", async () => { const beforeToolCall = vi.fn(); initializeGlobalHookRunner( createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]), ); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hook-channel-")); await fs.writeFile(path.join(tmpDir, "note.txt"), "hello"); const tools = createOpenClawCodingTools({ workspaceDir: tmpDir, currentChannelId: "telegram:-100123", hookChannelId: "-100123", }); const readTool = requireTool(tools, "read"); await requireToolExecute(readTool)("tool-hook-channel", { path: "note.txt" }); expect(beforeToolCall).toHaveBeenCalledTimes(1); expect(beforeToolCall.mock.calls[0]?.[1]).toEqual( expect.objectContaining({ channelId: "-100123" }), ); }); it("adds Tool Search control tools when explicitly requested", () => { const tools = createOpenClawCodingTools({ includeToolSearchControls: true, config: { tools: { toolSearch: true, }, }, }); const names = new Set(tools.map((tool) => tool.name)); expect(names.has("tool_search_code")).toBe(true); expect(names.has("tool_search")).toBe(true); expect(names.has("tool_describe")).toBe(true); expect(names.has("tool_call")).toBe(true); }); it("keeps Tool Search controls available under restrictive tool profiles", () => { const tools = createOpenClawCodingTools({ includeToolSearchControls: true, config: { tools: { profile: "coding", toolSearch: true, }, }, }); const names = new Set(tools.map((tool) => tool.name)); expect(names.has("tool_search_code")).toBe(true); expect(names.has("tool_search")).toBe(true); expect(names.has("tool_describe")).toBe(true); expect(names.has("tool_call")).toBe(true); expect(names.has("message")).toBe(false); }); it("keeps Tool Search controls available under restrictive tool allowlists", () => { const tools = createOpenClawCodingTools({ includeToolSearchControls: true, config: { tools: { allow: ["read"], toolSearch: true, }, }, }); const names = new Set(tools.map((tool) => tool.name)); expect(names.has("read")).toBe(true); expect(names.has("exec")).toBe(false); expect(names.has("tool_search_code")).toBe(true); expect(names.has("tool_search")).toBe(true); expect(names.has("tool_describe")).toBe(true); expect(names.has("tool_call")).toBe(true); }); it("lets explicit deny policies remove Tool Search controls", () => { const tools = createOpenClawCodingTools({ includeToolSearchControls: true, config: { tools: { profile: "coding", deny: ["tool_search_code"], toolSearch: true, }, }, }); const names = new Set(tools.map((tool) => tool.name)); expect(names.has("tool_search_code")).toBe(false); expect(names.has("read")).toBe(true); }); it("keeps Tool Search controls when core OpenClaw tools are not materialized", () => { const createOpenClawToolsMock = vi.mocked(createOpenClawTools); createOpenClawToolsMock.mockClear(); const tools = createOpenClawCodingTools({ includeCoreTools: false, includeToolSearchControls: true, toolConstructionPlan: { includeBaseCodingTools: false, includeShellTools: false, includeChannelTools: false, includeOpenClawTools: false, includePluginTools: true, }, config: { tools: { toolSearch: true, }, }, }); const names = new Set(tools.map((tool) => tool.name)); expect(createOpenClawToolsMock).not.toHaveBeenCalled(); expect(names.has("tool_search_code")).toBe(true); expect(names.has("tool_search")).toBe(true); expect(names.has("tool_describe")).toBe(true); expect(names.has("tool_call")).toBe(true); expect(names.has("message")).toBe(false); expect(names.has("exec")).toBe(false); }); it("exposes control-plane tools to configured sessions", () => { const tools = createOpenClawCodingTools({ config: testConfig, }); const names = new Set(tools.map((tool) => tool.name)); expect(names.has("cron")).toBe(true); expect(names.has("gateway")).toBe(true); expect(names.has("nodes")).toBe(true); }); it("resolves isolated cron runtime toolsAllow", () => { const allowed = applyRuntimeToolsAllow( createOpenClawCodingTools({ config: testConfig, }), ["cron"], ); expect(allowed.map((tool) => tool.name)).toEqual(["cron"]); expect( buildEmptyExplicitToolAllowlistError({ sources: [{ label: "runtime toolsAllow", entries: ["cron"] }], callableToolNames: allowed.map((tool) => tool.name), toolsEnabled: true, }), ).toBeNull(); }); it("uses runtime toolsAllow when materializing plugin tools", () => { const createOpenClawToolsMock = vi.mocked(createOpenClawTools); createOpenClawToolsMock.mockClear(); createOpenClawCodingTools({ config: testConfig, runtimeToolAllowlist: ["memory_search", "memory_get"], }); expect(createOpenClawToolsMock).toHaveBeenCalledTimes(1); const options = latestCreateOpenClawToolsOptions(); expectListIncludes(options.pluginToolAllowlist, ["memory_search", "memory_get"]); }); it("preserves runtime-allowed message through restrictive profiles", () => { const tools = createOpenClawCodingTools({ config: { tools: { profile: "minimal" } }, runtimeToolAllowlist: ["message"], toolConstructionPlan: { includeBaseCodingTools: false, includeShellTools: false, includeChannelTools: false, includeOpenClawTools: true, includePluginTools: false, }, }); expect(toolNameList(tools)).toContain("message"); }); it("preserves runtime allowlist groups containing message through restrictive profiles", () => { for (const runtimeToolAllowlist of [["group:messaging"], ["group:openclaw"], ["*"]]) { const tools = createOpenClawCodingTools({ config: { tools: { profile: "minimal" } }, runtimeToolAllowlist, toolConstructionPlan: { includeBaseCodingTools: false, includeShellTools: false, includeChannelTools: false, includeOpenClawTools: true, includePluginTools: false, }, }); expect(toolNameList(tools)).toContain("message"); } }); it("passes source reply delivery mode to OpenClaw tool construction", () => { const createOpenClawToolsMock = vi.mocked(createOpenClawTools); createOpenClawToolsMock.mockClear(); createOpenClawCodingTools({ config: testConfig, forceMessageTool: true, sourceReplyDeliveryMode: "message_tool_only", }); expect(createOpenClawToolsMock).toHaveBeenCalledTimes(1); expect(latestCreateOpenClawToolsOptions().sourceReplyDeliveryMode).toBe("message_tool_only"); }); it("skips unrelated tool families when construction is planned from a narrow allowlist", () => { const createOpenClawToolsMock = vi.mocked(createOpenClawTools); createOpenClawToolsMock.mockClear(); const tools = createOpenClawCodingTools({ config: testConfig, toolConstructionPlan: { includeBaseCodingTools: true, includeShellTools: false, includeChannelTools: false, includeOpenClawTools: false, includePluginTools: false, }, }); const names = new Set(tools.map((tool) => tool.name)); expect(createOpenClawToolsMock).not.toHaveBeenCalled(); expect(names.has("read")).toBe(true); expect(names.has("write")).toBe(true); expect(names.has("edit")).toBe(true); expect(names.has("exec")).toBe(false); expect(names.has("process")).toBe(false); expect(names.has("apply_patch")).toBe(false); expect(names.has("message")).toBe(false); }); it("passes plugin suppression into OpenClaw tool construction plans", () => { const createOpenClawToolsMock = vi.mocked(createOpenClawTools); createOpenClawToolsMock.mockClear(); createOpenClawCodingTools({ config: testConfig, toolConstructionPlan: { includeBaseCodingTools: false, includeShellTools: false, includeChannelTools: false, includeOpenClawTools: true, includePluginTools: false, }, }); expect(createOpenClawToolsMock).toHaveBeenCalledTimes(1); expect(latestCreateOpenClawToolsOptions().disablePluginTools).toBe(true); }); it("keeps plugin-only construction off the OpenClaw core factory", () => { const createOpenClawToolsMock = vi.mocked(createOpenClawTools); createOpenClawToolsMock.mockClear(); createOpenClawCodingTools({ config: testConfig, includeCoreTools: false, runtimeToolAllowlist: ["memory_search"], toolConstructionPlan: { includeBaseCodingTools: false, includeShellTools: false, includeChannelTools: false, includeOpenClawTools: false, includePluginTools: true, }, }); expect(createOpenClawToolsMock).not.toHaveBeenCalled(); }); it("forwards active model metadata to plugin-only tool construction", () => { const createOpenClawToolsMock = vi.mocked(createOpenClawTools); createOpenClawToolsMock.mockClear(); const resolvePluginToolsSpy = vi .spyOn(openClawPluginTools, "resolveOpenClawPluginToolsForOptions") .mockReturnValue([]); try { createOpenClawCodingTools({ config: testConfig, includeCoreTools: false, runtimeToolAllowlist: ["memory_search"], modelProvider: "openrouter", modelId: "openrouter/auto", toolConstructionPlan: { includeBaseCodingTools: false, includeShellTools: false, includeChannelTools: false, includeOpenClawTools: false, includePluginTools: true, }, }); expect(createOpenClawToolsMock).not.toHaveBeenCalled(); expect(resolvePluginToolsSpy).toHaveBeenCalledTimes(1); const pluginToolOptions = resolvePluginToolsSpy.mock.calls[0]?.[0].options; expect(pluginToolOptions?.modelProvider).toBe("openrouter"); expect(pluginToolOptions?.modelId).toBe("openrouter/auto"); } finally { resolvePluginToolsSpy.mockRestore(); } }); it("forwards auth profiles to plugin-only tool construction", () => { const createOpenClawToolsMock = vi.mocked(createOpenClawTools); createOpenClawToolsMock.mockClear(); const resolvePluginToolsSpy = vi .spyOn(openClawPluginTools, "resolveOpenClawPluginToolsForOptions") .mockReturnValue([]); const authProfileStore = { version: 1, order: { xai: ["xai-oauth"] }, profiles: { "xai-oauth": { type: "oauth", provider: "xai", access: "xai-oauth-access-token", // pragma: allowlist secret refresh: "xai-oauth-refresh-token", // pragma: allowlist secret expires: Date.now() + 60_000, }, }, } satisfies AuthProfileStore; try { createOpenClawCodingTools({ config: { auth: { order: { xai: ["xai-oauth"], }, }, }, authProfileStore, includeCoreTools: false, runtimeToolAllowlist: ["x_search"], toolConstructionPlan: { includeBaseCodingTools: false, includeShellTools: false, includeChannelTools: false, includeOpenClawTools: false, includePluginTools: true, }, }); expect(createOpenClawToolsMock).not.toHaveBeenCalled(); expect(resolvePluginToolsSpy).toHaveBeenCalledTimes(1); const pluginToolOptions = resolvePluginToolsSpy.mock.calls[0]?.[0].options; expect(pluginToolOptions?.authProfileStore).toBe(authProfileStore); } finally { resolvePluginToolsSpy.mockRestore(); } }); it("uses tools.alsoAllow for optional plugin discovery without widening to all plugins", () => { const createOpenClawToolsMock = vi.mocked(createOpenClawTools); createOpenClawToolsMock.mockClear(); createOpenClawCodingTools({ config: { tools: { alsoAllow: ["lobster"] } }, }); expect(createOpenClawToolsMock).toHaveBeenCalledTimes(1); expect(latestCreateOpenClawToolsOptions().pluginToolAllowlist).toStrictEqual([ "lobster", DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, ]); }); it("passes explicit denylist entries to OpenClaw tool factory planning", () => { const createOpenClawToolsMock = vi.mocked(createOpenClawTools); createOpenClawToolsMock.mockClear(); createOpenClawCodingTools({ config: { tools: { deny: ["pdf"] } }, }); expect(createOpenClawToolsMock).toHaveBeenCalledTimes(1); expectListIncludes(latestCreateOpenClawToolsOptions().pluginToolDenylist, ["pdf"]); }); it("passes inherited allowlist entries to OpenClaw plugin discovery", async () => { const createOpenClawToolsMock = vi.mocked(createOpenClawTools); createOpenClawToolsMock.mockClear(); const agentId = `inherited-allow-${Date.now()}-${Math.random().toString(16).slice(2)}`; const storeTemplate = path.join( os.tmpdir(), `openclaw-session-store-${agentId}-{agentId}.json`, ); await writeSessionStore(storeTemplate, agentId, { [`agent:${agentId}:subagent:limited`]: { sessionId: "limited-session", updatedAt: Date.now(), spawnDepth: 1, subagentRole: "orchestrator", subagentControlScope: "children", inheritedToolAllow: ["custom_plugin_tool", "sessions_spawn"], }, }); createOpenClawCodingTools({ sessionKey: `agent:${agentId}:subagent:limited`, config: { session: { store: storeTemplate, }, }, }); expect(createOpenClawToolsMock).toHaveBeenCalledTimes(1); expectListIncludes(latestCreateOpenClawToolsOptions().pluginToolAllowlist, [ "custom_plugin_tool", "sessions_spawn", ]); }); it("passes effective allow-list-restricted tool surface to spawned sessions", () => { const createOpenClawToolsMock = vi.mocked(createOpenClawTools); createOpenClawToolsMock.mockClear(); createOpenClawCodingTools({ config: { tools: { allow: ["read", "sessions_spawn"] } }, }); expect(createOpenClawToolsMock).toHaveBeenCalledTimes(1); const inheritedAllow = latestCreateOpenClawToolsOptions().inheritedToolAllowlist; expectListIncludes(inheritedAllow, ["read", "sessions_spawn"]); expect(inheritedAllow?.includes("exec")).toBe(false); expect(inheritedAllow?.includes("process")).toBe(false); }); it("records core tool-prep stages for hot-path diagnostics", () => { const stages: string[] = []; createOpenClawCodingTools({ config: testConfig, recordToolPrepStage: (name) => stages.push(name), }); expectListIncludes(stages, [ "tool-policy", "workspace-policy", "base-coding-tools", "shell-tools", "openclaw-tools:test-helper", "openclaw-tools", "message-provider-policy", "model-provider-policy", "authorization-policy", "schema-normalization", "tool-hooks", "abort-wrappers", "deferred-followup-descriptions", ]); expect(stages.indexOf("tool-policy")).toBeLessThan(stages.indexOf("workspace-policy")); expect(stages.indexOf("workspace-policy")).toBeLessThan(stages.indexOf("base-coding-tools")); expect(stages.indexOf("openclaw-tools:test-helper")).toBeLessThan( stages.indexOf("openclaw-tools"), ); expect(stages.indexOf("schema-normalization")).toBeLessThan(stages.indexOf("tool-hooks")); }); it("preserves action enums in normalized schemas", () => { const defaultTools = createOpenClawCodingTools({ config: testConfig }); const toolNames = ["canvas", "nodes", "cron", "gateway", "message"]; const missingNames = toolNames.filter( (name) => !defaultTools.some((candidate) => candidate.name === name), ); expect(missingNames).toStrictEqual([]); for (const name of toolNames) { const tool = defaultTools.find((candidate) => candidate.name === name); const parameters = tool?.parameters as { properties?: Record; }; const action = parameters.properties?.action as | { const?: unknown; enum?: unknown[] } | undefined; const values = new Set(); collectActionValues(action, values); const min = name === "gateway" ? 1 : 2; expect(values.size).toBeGreaterThanOrEqual(min); } }); it("enforces apply_patch availability and canonical names across model/provider constraints", () => { const defaultTools = createOpenClawCodingTools({ config: testConfig }); expect(toolNameList(defaultTools)).toContain("exec"); expect(toolNameList(defaultTools)).toContain("process"); expect(toolNameList(defaultTools)).not.toContain("apply_patch"); const openAiTools = createOpenClawCodingTools({ config: testConfig, modelProvider: "openai", modelId: "gpt-5.4", }); expect(toolNameList(openAiTools)).toContain("apply_patch"); const codexTools = createOpenClawCodingTools({ config: testConfig, modelProvider: "openai-codex", modelId: "gpt-5.4", }); expect(toolNameList(codexTools)).toContain("apply_patch"); const disabledConfig: OpenClawConfig = { tools: { exec: { applyPatch: { enabled: false }, }, }, }; const disabledOpenAiTools = createOpenClawCodingTools({ config: disabledConfig, modelProvider: "openai", modelId: "gpt-5.4", }); expect(toolNameList(disabledOpenAiTools)).not.toContain("apply_patch"); const anthropicTools = createOpenClawCodingTools({ config: disabledConfig, modelProvider: "anthropic", modelId: "claude-opus-4-6", }); expect(toolNameList(anthropicTools)).not.toContain("apply_patch"); const allowModelsConfig: OpenClawConfig = { tools: { exec: { applyPatch: { allowModels: ["gpt-5.4"] }, }, }, }; const allowed = createOpenClawCodingTools({ config: allowModelsConfig, modelProvider: "openai", modelId: "gpt-5.4", }); expect(toolNameList(allowed)).toContain("apply_patch"); const denied = createOpenClawCodingTools({ config: allowModelsConfig, modelProvider: "openai", modelId: "gpt-5.4-mini", }); expect(toolNameList(denied)).not.toContain("apply_patch"); const oauthTools = createOpenClawCodingTools({ config: testConfig, modelProvider: "anthropic", modelAuthMode: "oauth", }); const names = new Set(oauthTools.map((tool) => tool.name)); expect(names.has("exec")).toBe(true); expect(names.has("read")).toBe(true); expect(names.has("write")).toBe(true); expect(names.has("edit")).toBe(true); expect(names.has("apply_patch")).toBe(false); }); it("provides top-level object schemas for all tools", () => { const tools = createOpenClawCodingTools({ config: testConfig }); const offenders = tools .map((tool) => { const schema = tool.parameters && typeof tool.parameters === "object" ? (tool.parameters as Record) : null; return { name: tool.name, type: schema?.type, keys: schema ? Object.keys(schema).toSorted() : null, }; }) .filter((entry) => entry.type !== "object"); expect(offenders).toStrictEqual([]); }); it("does not expose provider-specific message tools", () => { const tools = createOpenClawCodingTools({ messageProvider: "discord" }); const names = new Set(tools.map((tool) => tool.name)); expect(names.has("discord")).toBe(false); expect(names.has("slack")).toBe(false); expect(names.has("telegram")).toBe(false); expect(names.has("whatsapp")).toBe(false); }); it("filters session tools for sub-agent sessions by default", () => { const tools = createOpenClawCodingTools({ sessionKey: "agent:main:subagent:test", }); const names = new Set(tools.map((tool) => tool.name)); expect(names.has("sessions_list")).toBe(false); expect(names.has("sessions_history")).toBe(false); expect(names.has("sessions_send")).toBe(false); expect(names.has("sessions_spawn")).toBe(false); expect(names.has("subagents")).toBe(false); expect(names.has("read")).toBe(true); expect(names.has("exec")).toBe(true); expect(names.has("process")).toBe(true); expect(names.has("apply_patch")).toBe(false); }); it("uses stored spawnDepth to apply leaf tool policy for flat depth-2 session keys", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-depth-policy-")); try { const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json"); await writeSessionStore(storeTemplate, "main", { "agent:main:subagent:flat": { sessionId: "session-flat-depth-2", updatedAt: Date.now(), spawnDepth: 2, }, }); const tools = createToolsForStoredSession(storeTemplate, "agent:main:subagent:flat"); expectNoSubagentControlTools(tools); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } }); it("applies subagent tool policy to ACP children spawned under a subagent envelope", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acp-subagent-policy-")); try { const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json"); await writeSessionStore(storeTemplate, "main", { "agent:main:acp:child": { sessionId: "session-acp-child", updatedAt: Date.now(), spawnedBy: "agent:main:subagent:parent", spawnDepth: 2, subagentRole: "leaf", subagentControlScope: "none", }, "agent:main:acp:plain": { sessionId: "session-acp-plain", updatedAt: Date.now(), spawnedBy: "agent:main:main", }, "agent:main:acp:parent": { sessionId: "session-acp-parent", updatedAt: Date.now(), spawnedBy: "agent:main:subagent:parent", }, }); await writeSessionStore(storeTemplate, "writer", { "agent:writer:acp:child": { sessionId: "session-acp-cross-agent-child", updatedAt: Date.now(), spawnedBy: "agent:main:acp:parent", }, }); const persistedEnvelopeTools = createToolsForStoredSession( storeTemplate, "agent:main:acp:child", ); expectNoSubagentControlTools(persistedEnvelopeTools); const restrictedTools = createToolsForStoredSession(storeTemplate, "agent:main:acp:plain"); const restrictedNames = new Set(restrictedTools.map((tool) => tool.name)); expect(restrictedNames.has("sessions_spawn")).toBe(true); expect(restrictedNames.has("subagents")).toBe(true); const ancestryTools = createToolsForStoredSession(storeTemplate, "agent:writer:acp:child"); expectNoSubagentControlTools(ancestryTools); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } }); it("applies leaf tool policy for cross-agent subagent sessions when spawnDepth is missing", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cross-agent-subagent-")); try { const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json"); await writeSessionStore(storeTemplate, "main", { "agent:main:subagent:parent": { sessionId: "session-main-parent", updatedAt: Date.now(), spawnedBy: "agent:main:main", }, }); await writeSessionStore(storeTemplate, "writer", { "agent:writer:subagent:child": { sessionId: "session-writer-child", updatedAt: Date.now(), spawnedBy: "agent:main:subagent:parent", }, }); const tools = createToolsForStoredSession(storeTemplate, "agent:writer:subagent:child"); expectNoSubagentControlTools(tools); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } }); it("supports allow-only sub-agent tool policy", () => { const tools = createOpenClawCodingTools({ sessionKey: "agent:main:subagent:test", config: { tools: { subagents: { tools: { allow: ["read"], }, }, }, }, }); expect(tools.map((tool) => tool.name)).toEqual(["read"]); }); it("applies tool profiles before allow/deny policies", () => { const tools = createOpenClawCodingTools({ config: { tools: { profile: "messaging" } }, }); const names = new Set(tools.map((tool) => tool.name)); expect(names.has("message")).toBe(true); expect(names.has("sessions_send")).toBe(true); expect(names.has("sessions_spawn")).toBe(false); expect(names.has("exec")).toBe(false); expect(names.has("browser")).toBe(false); }); it("includes browser tool with full profile when browser is configured (#76507)", () => { const tools = createOpenClawCodingTools({ config: { tools: { profile: "full" }, browser: { enabled: true }, plugins: { entries: { browser: { enabled: true } } }, } as OpenClawConfig, }); const names = new Set(tools.map((tool) => tool.name)); // full profile must not filter any tools — browser, canvas, etc. must be present. expect(names.has("browser")).toBe(true); expect(names.has("canvas")).toBe(true); expect(names.has("exec")).toBe(true); expect(names.has("message")).toBe(true); }); it("includes browser tool with full profile (#76507)", () => { const tools = createOpenClawCodingTools({ config: { tools: { profile: "full" }, browser: { enabled: true }, plugins: { entries: { browser: { enabled: true } } }, } as OpenClawConfig, }); const names = new Set(tools.map((tool) => tool.name)); expect(names.has("browser")).toBe(true); expect(names.has("canvas")).toBe(true); expect(names.has("gateway")).toBe(true); expect(names.has("cron")).toBe(true); expect(names.has("nodes")).toBe(true); }); it("includes browser tool without explicit profile (defaults to no filtering) (#76507)", () => { const tools = createOpenClawCodingTools({ config: { browser: { enabled: true }, plugins: { entries: { browser: { enabled: true } } }, } as OpenClawConfig, }); const names = new Set(tools.map((tool) => tool.name)); // No profile means no profile filtering — all tools pass. expect(names.has("browser")).toBe(true); }); it("keeps browser out of coding-profile subagents unless profile-stage alsoAllow adds it", () => { const baseConfig = { browser: { enabled: true }, plugins: { entries: { browser: { enabled: true } } }, tools: { profile: "coding" }, } as OpenClawConfig; const codingSubagent = createOpenClawCodingTools({ sessionKey: "agent:main:subagent:test", config: baseConfig, }); const codingNames = new Set(codingSubagent.map((tool) => tool.name)); expect(codingNames.has("browser")).toBe(false); const subagentAllowOnly = createOpenClawCodingTools({ sessionKey: "agent:main:subagent:test", config: { ...baseConfig, tools: { profile: "coding", subagents: { tools: { allow: ["browser"] } }, }, } as OpenClawConfig, }); expect(toolNameList(subagentAllowOnly)).not.toContain("browser"); const profileStageAlsoAllow = createOpenClawCodingTools({ sessionKey: "agent:main:subagent:test", config: { ...baseConfig, tools: { profile: "coding", alsoAllow: ["browser"] }, } as OpenClawConfig, }); expect(toolNameList(profileStageAlsoAllow)).toContain("browser"); }); it("can keep message available when a cron route needs it under the coding profile", () => { const codingTools = createOpenClawCodingTools({ config: { tools: { profile: "coding" } }, }); expect(toolNameList(codingTools)).not.toContain("message"); const cronTools = createOpenClawCodingTools({ config: { tools: { profile: "coding" } }, forceMessageTool: true, }); expect(toolNameList(cronTools)).toContain("message"); }); it("keeps message available for message-tool-only source replies under the coding profile", () => { const tools = createOpenClawCodingTools({ config: { tools: { profile: "coding" } }, sourceReplyDeliveryMode: "message_tool_only", }); expect(toolNameList(tools)).toContain("message"); }); it("keeps heartbeat response available for heartbeat runs under the coding profile", () => { const codingTools = createOpenClawCodingTools({ config: { tools: { profile: "coding" } }, trigger: "heartbeat", enableHeartbeatTool: true, forceHeartbeatTool: true, }); expect(toolNameList(codingTools)).toContain("heartbeat_respond"); }); it("enables heartbeat response when visible replies are message-tool-only", () => { const tools = createOpenClawCodingTools({ config: { messages: { visibleReplies: "message_tool" }, tools: { profile: "coding" }, } as OpenClawConfig, trigger: "heartbeat", }); expect(toolNameList(tools)).toContain("heartbeat_respond"); }); it("can keep message available when a cron route needs it under a provider coding profile", () => { const providerProfileTools = createOpenClawCodingTools({ config: { tools: { byProvider: { openai: { profile: "coding" } } } }, modelProvider: "openai", modelId: "gpt-5.4", }); expect(toolNameList(providerProfileTools)).not.toContain("message"); const cronTools = createOpenClawCodingTools({ config: { tools: { byProvider: { openai: { profile: "coding" } } } }, modelProvider: "openai", modelId: "gpt-5.4", forceMessageTool: true, }); expect(toolNameList(cronTools)).toContain("message"); }); it("expands group shorthands in global tool policy", () => { const tools = createOpenClawCodingTools({ config: { tools: { allow: ["group:fs"] } }, }); const names = new Set(tools.map((tool) => tool.name)); expect(names.has("read")).toBe(true); expect(names.has("write")).toBe(true); expect(names.has("edit")).toBe(true); expect(names.has("exec")).toBe(false); expect(names.has("browser")).toBe(false); }); it("expands group shorthands in global tool deny policy", () => { const tools = createOpenClawCodingTools({ config: { tools: { deny: ["group:fs"] } }, }); const names = new Set(tools.map((tool) => tool.name)); expect(names.has("read")).toBe(false); expect(names.has("write")).toBe(false); expect(names.has("edit")).toBe(false); expect(names.has("exec")).toBe(true); }); it("lets agent profiles override global profiles", () => { const tools = createOpenClawCodingTools({ sessionKey: "agent:work:main", config: { tools: { profile: "coding" }, agents: { list: [{ id: "work", tools: { profile: "messaging" } }], }, }, }); const names = new Set(tools.map((tool) => tool.name)); expect(names.has("message")).toBe(true); expect(names.has("exec")).toBe(false); expect(names.has("read")).toBe(false); }); it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => { const googleTools = createOpenClawCodingTools({ modelProvider: "google", }); for (const tool of googleTools) { const violations = findUnsupportedSchemaKeywords( tool.parameters, `${tool.name}.parameters`, GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, ); expect(violations).toStrictEqual([]); } }); it("applies xai model compat for direct Grok tool cleanup", () => { const xaiTools = createOpenClawCodingTools({ modelProvider: "xai", modelCompat: { toolSchemaProfile: "xai", unsupportedToolSchemaKeywords: Array.from(XAI_UNSUPPORTED_SCHEMA_KEYWORDS), nativeWebSearchTool: true, toolCallArgumentsEncoding: "html-entities", }, }); expect(toolNameList(xaiTools)).not.toContain("web_search"); for (const tool of xaiTools) { const violations = findUnsupportedSchemaKeywords( tool.parameters, `${tool.name}.parameters`, XAI_UNSUPPORTED_SCHEMA_KEYWORDS, ); expect( violations.filter((violation) => { const keyword = violation.split(".").at(-1) ?? ""; return XAI_UNSUPPORTED_SCHEMA_KEYWORDS.has(keyword); }), ).toStrictEqual([]); } }); it("returns image-aware read metadata for images and text-only blocks for text files", async () => { const defaultTools = createOpenClawCodingTools(); const readTool = requireTool(defaultTools, "read"); const readExecute = requireToolExecute(readTool); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-read-")); try { const imagePath = path.join(tmpDir, "sample.png"); await fs.writeFile(imagePath, tinyPngBuffer); const imageResult = await readExecute("tool-1", { path: imagePath, }); const imageBlocks = imageResult?.content?.filter((block) => block.type === "image") as | Array<{ mimeType?: string }> | undefined; const imageTextBlocks = imageResult?.content?.filter((block) => block.type === "text") as | Array<{ text?: string }> | undefined; const imageText = imageTextBlocks?.map((block) => block.text ?? "").join("\n") ?? ""; expect(imageText).toContain("Read image file [image/png]"); if ((imageBlocks?.length ?? 0) > 0) { expect(imageBlocks?.every((block) => block.mimeType === "image/png")).toBe(true); } else { expect(imageText).toContain("[Image omitted:"); } const textPath = path.join(tmpDir, "sample.txt"); const contents = "Hello from openclaw read tool."; await fs.writeFile(textPath, contents, "utf8"); const textResult = await readExecute("tool-2", { path: textPath, }); expect(textResult?.content).toEqual([{ type: "text", text: contents }]); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } }); it("filters tools by sandbox policy", () => { const sandboxDir = path.join(os.tmpdir(), "openclaw-sandbox"); const sandbox = createAgentToolsSandboxContext({ workspaceDir: sandboxDir, agentWorkspaceDir: path.join(os.tmpdir(), "openclaw-workspace"), workspaceAccess: "none" as const, fsBridge: createHostSandboxFsBridge(sandboxDir), tools: { allow: ["bash"], deny: ["browser"], }, }); const tools = createOpenClawCodingTools({ sandbox }); expect(toolNameList(tools)).toContain("exec"); expect(toolNameList(tools)).not.toContain("read"); expect(toolNameList(tools)).not.toContain("browser"); }); it("hard-disables write/edit when sandbox workspaceAccess is ro", () => { const sandboxDir = path.join(os.tmpdir(), "openclaw-sandbox"); const sandbox = createAgentToolsSandboxContext({ workspaceDir: sandboxDir, agentWorkspaceDir: path.join(os.tmpdir(), "openclaw-workspace"), workspaceAccess: "ro" as const, fsBridge: createHostSandboxFsBridge(sandboxDir), tools: { allow: ["read", "write", "edit"], deny: [], }, }); const tools = createOpenClawCodingTools({ sandbox }); expect(toolNameList(tools)).toContain("read"); expect(toolNameList(tools)).not.toContain("write"); expect(toolNameList(tools)).not.toContain("edit"); }); it("accepts canonical parameters for read/write/edit", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canonical-")); try { const tools = createOpenClawCodingTools({ workspaceDir: tmpDir }); const { readTool, writeTool, editTool } = expectReadWriteEditTools(tools); const filePath = "canonical-test.txt"; await writeTool?.execute("tool-canonical-1", { path: filePath, content: "hello world", }); await editTool?.execute("tool-canonical-2", { path: filePath, edits: [{ oldText: "world", newText: "universe" }], }); const result = await readTool?.execute("tool-canonical-3", { path: filePath, }); const textBlocks = result?.content?.filter((block) => block.type === "text") as | Array<{ text?: string }> | undefined; const combinedText = textBlocks?.map((block) => block.text ?? "").join("\n"); expect(combinedText).toContain("hello universe"); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } }); it("roots memory flush append-only writes in the workspace when cwd differs", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-workspace-")); const taskCwd = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-cwd-")); const memoryRelativePath = "memory/2026-03-24.md"; const workspaceMemoryFile = path.join(workspaceDir, memoryRelativePath); const taskMemoryFile = path.join(taskCwd, memoryRelativePath); try { await fs.mkdir(path.dirname(workspaceMemoryFile), { recursive: true }); await fs.writeFile(workspaceMemoryFile, "seed", "utf8"); const tools = createOpenClawCodingTools({ workspaceDir, cwd: taskCwd, trigger: "memory", memoryFlushWritePath: memoryRelativePath, }); const writeExecute = requireToolExecute(requireTool(tools, "write")); await writeExecute("tool-memory-flush-workspace", { path: memoryRelativePath, content: "new durable note", }); await expect(fs.readFile(workspaceMemoryFile, "utf8")).resolves.toBe( "seed\nnew durable note", ); await expect(fs.stat(taskMemoryFile)).rejects.toThrow(); } finally { await fs.rm(workspaceDir, { recursive: true, force: true }); await fs.rm(taskCwd, { recursive: true, force: true }); } }); it("rejects legacy alias parameters", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-legacy-alias-")); try { const tools = createOpenClawCodingTools({ workspaceDir: tmpDir }); const { readTool, writeTool, editTool } = expectReadWriteEditTools(tools); await expect( writeTool?.execute("tool-legacy-write", { file: "legacy.txt", content: "hello old value", }), ).rejects.toThrow(/Missing required parameter: path/); await expect( editTool?.execute("tool-legacy-edit", { filePath: "legacy.txt", old_text: "old", newString: "new", }), ).rejects.toThrow(/Missing required parameters: path, edits/); await expect( readTool?.execute("tool-legacy-read", { file_path: "legacy.txt", }), ).rejects.toThrow(/Missing required parameter: path/); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } }); it("rejects structured content blocks for write", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-structured-write-")); try { const tools = createOpenClawCodingTools({ workspaceDir: tmpDir }); const writeTool = requireTool(tools, "write"); const writeExecute = requireToolExecute(writeTool); await expect( writeExecute("tool-structured-write", { path: "structured-write.js", content: [ { type: "text", text: "const path = require('path');\n" }, { type: "input_text", text: "const root = path.join(process.env.HOME, 'clawd');\n" }, ], }), ).rejects.toThrow(/Missing required parameter: content/); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } }); it("rejects structured edit payloads", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-structured-edit-")); try { const filePath = path.join(tmpDir, "structured-edit.js"); await fs.writeFile(filePath, "const value = 'old';\n", "utf8"); const tools = createOpenClawCodingTools({ workspaceDir: tmpDir }); const editTool = requireTool(tools, "edit"); const editExecute = requireToolExecute(editTool); await expect( editExecute("tool-structured-edit", { path: "structured-edit.js", edits: [ { oldText: [{ type: "text", text: "old" }], newText: [{ kind: "text", value: "new" }], }, ], }), ).rejects.toThrow(/Missing required parameter: edits/); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } }); });