test: keep openclaw tools registration policy pure

This commit is contained in:
Peter Steinberger
2026-04-08 15:30:52 +01:00
parent 1b9a6959b8
commit 47db29076e
10 changed files with 78 additions and 241 deletions

View File

@@ -1,57 +1,29 @@
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { createOpenClawTools } from "./openclaw-tools.js";
import {
fastOpenClawToolFactoryMocks,
stubOpenClawCoreTool,
} from "./test-helpers/fast-openclaw-tools-shell.js";
import { describe, expect, it } from "vitest";
import { collectPresentOpenClawTools } from "./openclaw-tools.registration.js";
import { textResult, type AnyAgentTool } from "./tools/common.js";
function asConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
function stubAgentTool(name: string): AnyAgentTool {
return {
label: name,
name,
description: `${name} stub`,
parameters: { type: "object", properties: {} },
async execute() {
return textResult("ok", {});
},
};
}
describe("openclaw tools image generation registration", () => {
afterEach(() => {
fastOpenClawToolFactoryMocks.createImageGenerateTool.mockReset();
fastOpenClawToolFactoryMocks.createVideoGenerateTool.mockReset();
it("registers image_generate when an image-generation tool is present", () => {
const imageGenerateTool = stubAgentTool("image_generate");
expect(collectPresentOpenClawTools([imageGenerateTool])).toEqual([imageGenerateTool]);
});
it("registers image_generate when the image tool factory returns a tool", () => {
const imageGenerateTool = stubOpenClawCoreTool("image_generate");
fastOpenClawToolFactoryMocks.createImageGenerateTool.mockReturnValue(imageGenerateTool);
const config = asConfig({
agents: {
defaults: {
imageGenerationModel: {
primary: "openai/gpt-image-1",
},
},
},
});
const tools = createOpenClawTools({
config,
agentDir: "/tmp/openclaw-agent-main",
});
expect(tools).toContain(imageGenerateTool);
expect(fastOpenClawToolFactoryMocks.createImageGenerateTool).toHaveBeenCalledWith({
config,
agentDir: "/tmp/openclaw-agent-main",
workspaceDir: expect.any(String),
sandbox: undefined,
fsPolicy: undefined,
});
});
it("omits image_generate when the image tool factory returns null", () => {
fastOpenClawToolFactoryMocks.createImageGenerateTool.mockReturnValue(null);
const tools = createOpenClawTools({
config: asConfig({}),
agentDir: "/tmp/openclaw-agent-main",
});
expect(tools.map((tool) => tool.name)).not.toContain("image_generate");
it("omits image_generate when the image-generation tool is absent", () => {
expect(collectPresentOpenClawTools([null]).map((tool) => tool.name)).not.toContain(
"image_generate",
);
});
});

View File

@@ -1,22 +1,18 @@
import { describe, expect, it } from "vitest";
import "./test-helpers/fast-openclaw-tools-shell.js";
import { createOpenClawTools } from "./openclaw-tools.js";
function readToolByName() {
return new Map(createOpenClawTools().map((tool) => [tool.name, tool]));
}
import {
isOpenClawOwnerOnlyCoreToolName,
OPENCLAW_OWNER_ONLY_CORE_TOOL_NAMES,
} from "./tools/owner-only-tools.js";
describe("createOpenClawTools owner authorization", () => {
it("marks owner-only core tools in raw registration", () => {
const tools = readToolByName();
expect(tools.get("cron")?.ownerOnly).toBe(true);
expect(tools.get("gateway")?.ownerOnly).toBe(true);
expect(tools.get("nodes")?.ownerOnly).toBe(true);
it("marks owner-only core tool names", () => {
expect(OPENCLAW_OWNER_ONLY_CORE_TOOL_NAMES).toEqual(["cron", "gateway", "nodes"]);
expect(isOpenClawOwnerOnlyCoreToolName("cron")).toBe(true);
expect(isOpenClawOwnerOnlyCoreToolName("gateway")).toBe(true);
expect(isOpenClawOwnerOnlyCoreToolName("nodes")).toBe(true);
});
it("keeps canvas non-owner-only in raw registration", () => {
const tools = readToolByName();
expect(tools.get("canvas")).toBeDefined();
expect(tools.get("canvas")?.ownerOnly).not.toBe(true);
it("keeps canvas non-owner-only", () => {
expect(isOpenClawOwnerOnlyCoreToolName("canvas")).toBe(false);
});
});

View File

@@ -0,0 +1,7 @@
import type { AnyAgentTool } from "./tools/common.js";
export function collectPresentOpenClawTools(
candidates: readonly (AnyAgentTool | null | undefined)[],
): AnyAgentTool[] {
return candidates.filter((tool): tool is AnyAgentTool => tool !== null && tool !== undefined);
}

View File

@@ -6,6 +6,7 @@ import { normalizeDeliveryContext } from "../utils/delivery-context.js";
import type { GatewayMessageChannel } from "../utils/message-channel.js";
import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js";
import { resolveOpenClawPluginToolsForOptions } from "./openclaw-plugin-tools.js";
import { collectPresentOpenClawTools } from "./openclaw-tools.registration.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
import type { SpawnedToolContext } from "./spawned-context.js";
import type { ToolFsPolicy } from "./tool-fs-policy.js";
@@ -235,9 +236,7 @@ export function createOpenClawTools(
agentChannel: options?.agentChannel,
config: options?.config,
}),
...(imageGenerateTool ? [imageGenerateTool] : []),
...(musicGenerateTool ? [musicGenerateTool] : []),
...(videoGenerateTool ? [videoGenerateTool] : []),
...collectPresentOpenClawTools([imageGenerateTool, musicGenerateTool, videoGenerateTool]),
createGatewayTool({
agentSessionKey: options?.agentSessionKey,
config: options?.config,
@@ -293,10 +292,7 @@ export function createOpenClawTools(
config: resolvedConfig,
sandboxed: options?.sandboxed,
}),
...(webSearchTool ? [webSearchTool] : []),
...(webFetchTool ? [webFetchTool] : []),
...(imageTool ? [imageTool] : []),
...(pdfTool ? [pdfTool] : []),
...collectPresentOpenClawTools([webSearchTool, webFetchTool, imageTool, pdfTool]),
];
if (options?.disablePluginTools) {

View File

@@ -1,60 +1,29 @@
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { createOpenClawTools } from "./openclaw-tools.js";
import {
fastOpenClawToolFactoryMocks,
stubOpenClawCoreTool,
} from "./test-helpers/fast-openclaw-tools-shell.js";
import { describe, expect, it } from "vitest";
import { collectPresentOpenClawTools } from "./openclaw-tools.registration.js";
import { textResult, type AnyAgentTool } from "./tools/common.js";
function asConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
function stubAgentTool(name: string): AnyAgentTool {
return {
label: name,
name,
description: `${name} stub`,
parameters: { type: "object", properties: {} },
async execute() {
return textResult("ok", {});
},
};
}
describe("openclaw tools video generation registration", () => {
afterEach(() => {
fastOpenClawToolFactoryMocks.createImageGenerateTool.mockReset();
fastOpenClawToolFactoryMocks.createVideoGenerateTool.mockReset();
it("registers video_generate when a video-generation tool is present", () => {
const videoGenerateTool = stubAgentTool("video_generate");
expect(collectPresentOpenClawTools([videoGenerateTool])).toEqual([videoGenerateTool]);
});
it("registers video_generate when the video tool factory returns a tool", () => {
const videoGenerateTool = stubOpenClawCoreTool("video_generate");
fastOpenClawToolFactoryMocks.createVideoGenerateTool.mockReturnValue(videoGenerateTool);
const config = asConfig({
agents: {
defaults: {
videoGenerationModel: {
primary: "qwen/wan2.6-t2v",
},
},
},
});
const tools = createOpenClawTools({
config,
agentDir: "/tmp/openclaw-agent-main",
agentSessionKey: "agent:main:main",
});
expect(tools).toContain(videoGenerateTool);
expect(fastOpenClawToolFactoryMocks.createVideoGenerateTool).toHaveBeenCalledWith({
config,
agentDir: "/tmp/openclaw-agent-main",
agentSessionKey: "agent:main:main",
requesterOrigin: undefined,
workspaceDir: expect.any(String),
sandbox: undefined,
fsPolicy: undefined,
});
});
it("omits video_generate when the video tool factory returns null", () => {
fastOpenClawToolFactoryMocks.createVideoGenerateTool.mockReturnValue(null);
const tools = createOpenClawTools({
config: asConfig({}),
agentDir: "/tmp/openclaw-agent-main",
});
expect(tools.map((tool) => tool.name)).not.toContain("video_generate");
it("omits video_generate when the video-generation tool is absent", () => {
expect(collectPresentOpenClawTools([null]).map((tool) => tool.name)).not.toContain(
"video_generate",
);
});
});

View File

@@ -1,115 +0,0 @@
import { vi } from "vitest";
export type StubbedCoreTool = {
name: string;
description: string;
parameters: { type: "object"; properties: Record<string, unknown> };
ownerOnly?: boolean;
execute: (...args: unknown[]) => unknown;
};
export function stubOpenClawCoreTool(name: string, options?: { ownerOnly?: boolean }) {
return {
name,
description: `${name} stub`,
parameters: { type: "object", properties: {} },
execute: vi.fn() as unknown as (...args: unknown[]) => unknown,
...(options?.ownerOnly ? { ownerOnly: true } : {}),
} satisfies StubbedCoreTool;
}
export const fastOpenClawToolFactoryMocks = {
createImageGenerateTool: vi.fn<(...args: unknown[]) => StubbedCoreTool | null>(() => null),
createVideoGenerateTool: vi.fn<(...args: unknown[]) => StubbedCoreTool | null>(() => null),
};
vi.mock("../tools/agents-list-tool.js", () => ({
createAgentsListTool: () => stubOpenClawCoreTool("agents_list"),
}));
vi.mock("../tools/canvas-tool.js", () => ({
createCanvasTool: () => stubOpenClawCoreTool("canvas"),
}));
vi.mock("../tools/cron-tool.js", () => ({
createCronTool: () => stubOpenClawCoreTool("cron", { ownerOnly: true }),
}));
vi.mock("../tools/gateway-tool.js", () => ({
createGatewayTool: () => stubOpenClawCoreTool("gateway", { ownerOnly: true }),
}));
vi.mock("../tools/image-generate-tool.js", () => ({
createImageGenerateTool: (...args: unknown[]) =>
fastOpenClawToolFactoryMocks.createImageGenerateTool(...args),
}));
vi.mock("../tools/image-tool.js", () => ({
createImageTool: () => null,
}));
vi.mock("../tools/message-tool.js", () => ({
createMessageTool: () => stubOpenClawCoreTool("message"),
}));
vi.mock("../tools/music-generate-tool.js", () => ({
createMusicGenerateTool: () => null,
}));
vi.mock("../tools/nodes-tool.js", () => ({
createNodesTool: () => stubOpenClawCoreTool("nodes", { ownerOnly: true }),
}));
vi.mock("../tools/pdf-tool.js", () => ({
createPdfTool: () => null,
}));
vi.mock("../tools/session-status-tool.js", () => ({
createSessionStatusTool: () => stubOpenClawCoreTool("session_status"),
}));
vi.mock("../tools/sessions-history-tool.js", () => ({
createSessionsHistoryTool: () => stubOpenClawCoreTool("sessions_history"),
}));
vi.mock("../tools/sessions-list-tool.js", () => ({
createSessionsListTool: () => stubOpenClawCoreTool("sessions_list"),
}));
vi.mock("../tools/sessions-send-tool.js", () => ({
createSessionsSendTool: () => stubOpenClawCoreTool("sessions_send"),
}));
vi.mock("../tools/sessions-spawn-tool.js", () => ({
createSessionsSpawnTool: () => stubOpenClawCoreTool("sessions_spawn"),
}));
vi.mock("../tools/sessions-yield-tool.js", () => ({
createSessionsYieldTool: () => stubOpenClawCoreTool("sessions_yield"),
}));
vi.mock("../tools/subagents-tool.js", () => ({
createSubagentsTool: () => stubOpenClawCoreTool("subagents"),
}));
vi.mock("../tools/tts-tool.js", () => ({
createTtsTool: () => stubOpenClawCoreTool("tts"),
}));
vi.mock("../tools/update-plan-tool.js", () => ({
createUpdatePlanTool: () => stubOpenClawCoreTool("update_plan"),
}));
vi.mock("../tools/video-generate-tool.js", () => ({
createVideoGenerateTool: (...args: unknown[]) =>
fastOpenClawToolFactoryMocks.createVideoGenerateTool(...args),
}));
vi.mock("../tools/web-tools.js", () => ({
createWebSearchTool: () => null,
createWebFetchTool: () => null,
}));
vi.mock("../openclaw-plugin-tools.js", () => ({
resolveOpenClawPluginToolsForOptions: () => [],
}));

View File

@@ -15,6 +15,7 @@ import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import { CRON_TOOL_DISPLAY_SUMMARY } from "../tool-description-presets.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, readGatewayCallOptions, type GatewayCallOptions } from "./gateway.js";
import { isOpenClawOwnerOnlyCoreToolName } from "./owner-only-tools.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
// We spell out job/patch properties so that LLMs know what fields to send.
@@ -387,7 +388,7 @@ export function createCronTool(opts?: CronToolOptions, deps?: CronToolDeps): Any
return {
label: "Cron",
name: "cron",
ownerOnly: true,
ownerOnly: isOpenClawOwnerOnlyCoreToolName("cron"),
displaySummary: CRON_TOOL_DISPLAY_SUMMARY,
description: `Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use this for reminders, "check back later" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.

View File

@@ -16,6 +16,7 @@ import { normalizeOptionalString, readStringValue } from "../../shared/string-co
import { stringEnum } from "../schema/typebox.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, readGatewayCallOptions } from "./gateway.js";
import { isOpenClawOwnerOnlyCoreToolName } from "./owner-only-tools.js";
const log = createSubsystemLogger("gateway-tool");
@@ -163,7 +164,7 @@ export function createGatewayTool(opts?: {
return {
label: "Gateway",
name: "gateway",
ownerOnly: true,
ownerOnly: isOpenClawOwnerOnlyCoreToolName("gateway"),
description:
"Restart, inspect a specific config schema path, apply config, or update the gateway in-place (SIGUSR1). Use config.schema.lookup with a targeted dot path before config edits. Use config.patch for safe partial config updates (merges with existing). Use config.apply only when replacing entire config. Config writes hot-reload when possible and restart when required. Always pass a human-readable completion message via the `note` parameter so the system can deliver it to the user after restart.",
parameters: GatewayToolSchema,

View File

@@ -14,6 +14,7 @@ import { callGatewayTool, readGatewayCallOptions } from "./gateway.js";
import { executeNodeCommandAction, type NodeCommandAction } from "./nodes-tool-commands.js";
import { executeNodeMediaAction, MEDIA_INVOKE_ACTIONS } from "./nodes-tool-media.js";
import { resolveNodeId } from "./nodes-utils.js";
import { isOpenClawOwnerOnlyCoreToolName } from "./owner-only-tools.js";
const NODES_TOOL_ACTIONS = [
"status",
@@ -149,7 +150,7 @@ export function createNodesTool(options?: {
return {
label: "Nodes",
name: "nodes",
ownerOnly: true,
ownerOnly: isOpenClawOwnerOnlyCoreToolName("nodes"),
description:
"Discover and control paired nodes (status/describe/pairing/notify/camera/photos/screen/location/notifications/invoke).",
parameters: NodesToolSchema,

View File

@@ -0,0 +1,9 @@
export const OPENCLAW_OWNER_ONLY_CORE_TOOL_NAMES = ["cron", "gateway", "nodes"] as const;
const OPENCLAW_OWNER_ONLY_CORE_TOOL_NAME_SET: ReadonlySet<string> = new Set(
OPENCLAW_OWNER_ONLY_CORE_TOOL_NAMES,
);
export function isOpenClawOwnerOnlyCoreToolName(toolName: string): boolean {
return OPENCLAW_OWNER_ONLY_CORE_TOOL_NAME_SET.has(toolName);
}