Files
openclaw/src/plugins/tools.optional.test.ts
2026-05-03 21:06:46 -07:00

2352 lines
68 KiB
TypeScript

import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY } from "../agents/tool-policy.js";
import { resetLogger, setLoggerOverride } from "../logging/logger.js";
import { loggingState } from "../logging/state.js";
import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js";
type MockRegistryToolEntry = {
pluginId: string;
optional: boolean;
source: string;
names: string[];
declaredNames?: string[];
factory: (ctx: unknown) => unknown;
};
const loadOpenClawPluginsMock = vi.fn();
const resolveRuntimePluginRegistryMock = vi.fn();
const applyPluginAutoEnableMock = vi.fn();
vi.mock("./loader.js", () => ({
loadOpenClawPlugins: (params: unknown) => loadOpenClawPluginsMock(params),
resolveCompatibleRuntimePluginRegistry: (params: unknown) =>
resolveRuntimePluginRegistryMock(params),
resolvePluginRegistryLoadCacheKey: (params: unknown) => JSON.stringify(params),
resolveRuntimePluginRegistry: (params: unknown) => resolveRuntimePluginRegistryMock(params),
}));
vi.mock("../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: (params: unknown) => applyPluginAutoEnableMock(params),
}));
let resolvePluginTools: typeof import("./tools.js").resolvePluginTools;
let ensureStandalonePluginToolRegistryLoaded: typeof import("./tools.js").ensureStandalonePluginToolRegistryLoaded;
let buildPluginToolMetadataKey: typeof import("./tools.js").buildPluginToolMetadataKey;
let getPluginToolMeta: typeof import("./tools.js").getPluginToolMeta;
let resetPluginToolFactoryCache: typeof import("./tools.js").resetPluginToolFactoryCache;
let getActivePluginRegistry: typeof import("./runtime.js").getActivePluginRegistry;
let pinActivePluginChannelRegistry: typeof import("./runtime.js").pinActivePluginChannelRegistry;
let resetPluginRuntimeStateForTest: typeof import("./runtime.js").resetPluginRuntimeStateForTest;
let setActivePluginRegistry: typeof import("./runtime.js").setActivePluginRegistry;
let clearCurrentPluginMetadataSnapshot: typeof import("./current-plugin-metadata-snapshot.js").clearCurrentPluginMetadataSnapshot;
let setCurrentPluginMetadataSnapshot: typeof import("./current-plugin-metadata-snapshot.js").setCurrentPluginMetadataSnapshot;
function makeTool(name: string) {
return {
name,
description: `${name} tool`,
parameters: { type: "object", properties: {} },
async execute() {
return { content: [{ type: "text", text: "ok" }] };
},
};
}
function createContext() {
return {
config: {
plugins: {
enabled: true,
allow: ["optional-demo", "message", "multi"],
load: { paths: ["/tmp/plugin.js"] },
slots: { memory: "none" },
},
},
workspaceDir: "/tmp",
};
}
function createResolveToolsParams(params?: {
context?: ReturnType<typeof createContext> & Record<string, unknown>;
toolAllowlist?: readonly string[];
toolDenylist?: readonly string[];
existingToolNames?: Set<string>;
env?: NodeJS.ProcessEnv;
suppressNameConflicts?: boolean;
allowGatewaySubagentBinding?: boolean;
}) {
return {
context: (params?.context ?? createContext()) as never,
...(params?.toolAllowlist ? { toolAllowlist: [...params.toolAllowlist] } : {}),
...(params?.toolDenylist ? { toolDenylist: [...params.toolDenylist] } : {}),
...(params?.existingToolNames ? { existingToolNames: params.existingToolNames } : {}),
...(params?.env ? { env: params.env } : {}),
...(params?.suppressNameConflicts ? { suppressNameConflicts: true } : {}),
...(params?.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } : {}),
};
}
function createToolRegistry(entries: MockRegistryToolEntry[]) {
return {
plugins: entries.map((entry) => ({ id: entry.pluginId, status: "loaded" })),
tools: entries,
diagnostics: [] as Array<{
level: string;
pluginId: string;
source: string;
message: string;
}>,
};
}
function setRegistry(entries: MockRegistryToolEntry[]) {
const registry = createToolRegistry(entries);
loadOpenClawPluginsMock.mockReturnValue(registry);
setActivePluginRegistry?.(registry as never, "test-tool-registry", "gateway-bindable", "/tmp");
installToolManifestSnapshots({
config: createContext().config,
plugins: entries
.map((entry) => ({
id: entry.pluginId,
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: entry.declaredNames ?? entry.names,
},
...(entry.optional
? {
toolMetadata: Object.fromEntries(
(entry.declaredNames ?? entry.names).map((name) => [name, { optional: true }]),
),
}
: {}),
}))
.filter((plugin) => plugin.contracts.tools.length > 0),
});
return registry;
}
function setMultiToolRegistry() {
return setRegistry([
{
pluginId: "multi",
optional: false,
source: "/tmp/multi.js",
names: ["message", "other_tool"],
factory: () => [makeTool("message"), makeTool("other_tool")],
},
]);
}
function createOptionalDemoEntry(): MockRegistryToolEntry {
return {
pluginId: "optional-demo",
names: ["optional_tool"],
optional: true,
source: "/tmp/optional-demo.js",
factory: () => makeTool("optional_tool"),
};
}
function createMalformedTool(name: string) {
return {
name,
description: `${name} tool`,
inputSchema: { type: "object", properties: {} },
async execute() {
return { content: [{ type: "text", text: "bad" }] };
},
};
}
function installConsoleMethodSpy(method: "log" | "warn") {
const spy = vi.fn();
loggingState.rawConsole = {
log: method === "log" ? spy : vi.fn(),
info: vi.fn(),
warn: method === "warn" ? spy : vi.fn(),
error: vi.fn(),
};
return spy;
}
function resolveWithConflictingCoreName(options?: { suppressNameConflicts?: boolean }) {
return resolvePluginTools(
createResolveToolsParams({
existingToolNames: new Set(["message"]),
...(options?.suppressNameConflicts ? { suppressNameConflicts: true } : {}),
}),
);
}
function setOptionalDemoRegistry() {
setRegistry([createOptionalDemoEntry()]);
}
function resolveOptionalDemoTools(toolAllowlist?: readonly string[]) {
return resolvePluginTools(createResolveToolsParams({ toolAllowlist }));
}
function createAutoEnabledOptionalContext() {
const rawContext = createContext();
const autoEnabledConfig = {
...rawContext.config,
plugins: {
...rawContext.config.plugins,
entries: {
"optional-demo": { enabled: true },
},
},
};
return { rawContext, autoEnabledConfig };
}
function expectAutoEnabledOptionalLoad(autoEnabledConfig: unknown) {
expectLoaderCall({ config: autoEnabledConfig });
}
function resolveAutoEnabledOptionalDemoTools() {
setOptionalDemoRegistry();
const { rawContext, autoEnabledConfig } = createAutoEnabledOptionalContext();
installToolManifestSnapshot({
config: autoEnabledConfig,
plugin: {
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
});
applyPluginAutoEnableMock.mockReturnValue({ config: autoEnabledConfig, changes: [] });
const tools = resolvePluginTools({
context: {
...rawContext,
config: rawContext.config as never,
} as never,
toolAllowlist: ["optional_tool"],
});
return { rawContext, autoEnabledConfig, tools };
}
function createOptionalDemoActiveRegistry() {
installToolManifestSnapshot({
config: createContext().config,
plugin: {
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
});
const registry = {
plugins: [{ id: "optional-demo", status: "loaded" }],
tools: [createOptionalDemoEntry()],
diagnostics: [],
};
setActivePluginRegistry?.(registry as never, "test-tool-registry", "gateway-bindable", "/tmp");
return registry;
}
function installToolManifestSnapshot(params: {
config: ReturnType<typeof createContext>["config"];
env?: NodeJS.ProcessEnv;
plugin: Record<string, unknown>;
}) {
installToolManifestSnapshots({
config: params.config,
env: params.env,
plugins: [params.plugin],
});
}
function installToolManifestSnapshots(params: {
config: ReturnType<typeof createContext>["config"];
env?: NodeJS.ProcessEnv;
plugins: Record<string, unknown>[];
}) {
const plugins = params.plugins;
setCurrentPluginMetadataSnapshot(
{
policyHash: resolveInstalledPluginIndexPolicyHash(params.config),
workspaceDir: "/tmp",
index: {
version: 1,
hostContractVersion: "test",
compatRegistryVersion: "test",
migrationVersion: 1,
policyHash: "test",
generatedAtMs: 0,
installRecords: {},
plugins: plugins.map((plugin) => ({
pluginId: String(plugin.id),
origin: plugin.origin,
enabled: true,
enabledByDefault: plugin.enabledByDefault,
startup: {
sidecar: false,
memory: false,
deferConfiguredChannelFullLoadUntilAfterListen: false,
agentHarnesses: [],
},
compat: [],
})),
diagnostics: [],
},
registryDiagnostics: [],
manifestRegistry: { plugins, diagnostics: [] },
plugins,
diagnostics: [],
byPluginId: new Map(plugins.map((plugin) => [String(plugin.id), plugin])),
normalizePluginId: (id: string) => id,
owners: {
channels: new Map(),
channelConfigs: new Map(),
providers: new Map(),
modelCatalogProviders: new Map(),
cliBackends: new Map(),
setupProviders: new Map(),
commandAliases: new Map(),
contracts: new Map(),
},
metrics: {
registrySnapshotMs: 0,
manifestRegistryMs: 0,
ownerMapsMs: 0,
totalMs: 0,
indexPluginCount: plugins.length,
manifestPluginCount: plugins.length,
},
} as never,
{ config: params.config, env: params.env ?? process.env, workspaceDir: "/tmp" },
);
}
function createXaiToolManifest() {
return {
id: "xai",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: ["xai"],
providerAuthEnvVars: {
xai: ["XAI_API_KEY"],
},
contracts: {
tools: ["x_search"],
},
toolMetadata: {
x_search: {
authSignals: [{ provider: "xai" }],
configSignals: [
{
rootPath: "plugins.entries.xai.config",
overlayPath: "webSearch",
required: ["apiKey"],
},
],
},
},
};
}
function expectResolvedToolNames(
tools: ReturnType<typeof resolvePluginTools>,
expectedToolNames: readonly string[],
) {
expect(tools.map((tool) => tool.name)).toEqual(expectedToolNames);
}
function expectLoaderCall(overrides: Record<string, unknown>) {
void overrides;
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
}
function expectLoaderSelectedOnlyPluginIds(expectedPluginIds: readonly string[]) {
const selectedPluginIds = loadOpenClawPluginsMock.mock.calls.map(
([params]) => (params as { onlyPluginIds?: string[] }).onlyPluginIds,
);
expect(selectedPluginIds.length).toBeGreaterThan(0);
expect(selectedPluginIds).toEqual(selectedPluginIds.map(() => expectedPluginIds));
}
function expectSingleDiagnosticMessage(
diagnostics: Array<{ message: string }>,
messageFragment: string,
) {
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0]?.message).toContain(messageFragment);
}
function expectConflictingCoreNameResolution(params: {
suppressNameConflicts?: boolean;
expectedDiagnosticFragment?: string;
}) {
const registry = setMultiToolRegistry();
const tools = resolveWithConflictingCoreName({
suppressNameConflicts: params.suppressNameConflicts,
});
expectResolvedToolNames(tools, ["other_tool"]);
if (params.expectedDiagnosticFragment) {
expectSingleDiagnosticMessage(registry.diagnostics, params.expectedDiagnosticFragment);
return;
}
expect(registry.diagnostics).toHaveLength(0);
}
describe("resolvePluginTools optional tools", () => {
beforeAll(async () => {
({
buildPluginToolMetadataKey,
ensureStandalonePluginToolRegistryLoaded,
getPluginToolMeta,
resetPluginToolFactoryCache,
resolvePluginTools,
} = await import("./tools.js"));
({
getActivePluginRegistry,
pinActivePluginChannelRegistry,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} = await import("./runtime.js"));
({ clearCurrentPluginMetadataSnapshot, setCurrentPluginMetadataSnapshot } =
await import("./current-plugin-metadata-snapshot.js"));
});
beforeEach(() => {
loadOpenClawPluginsMock.mockReset();
resolveRuntimePluginRegistryMock.mockReset();
resolveRuntimePluginRegistryMock.mockImplementation((params) =>
loadOpenClawPluginsMock(params),
);
applyPluginAutoEnableMock.mockReset();
applyPluginAutoEnableMock.mockImplementation(({ config }: { config: unknown }) => ({
config,
changes: [],
}));
resetPluginRuntimeStateForTest?.();
clearCurrentPluginMetadataSnapshot?.();
resetPluginToolFactoryCache?.();
});
afterEach(() => {
resetPluginRuntimeStateForTest?.();
clearCurrentPluginMetadataSnapshot?.();
resetPluginToolFactoryCache?.();
setLoggerOverride(null);
loggingState.rawConsole = null;
resetLogger();
vi.useRealTimers();
});
it("does not load plugin-owned tools whose manifest metadata has no available signal", () => {
const config = createContext().config;
installToolManifestSnapshot({
config,
env: {},
plugin: createXaiToolManifest(),
});
const factory = vi.fn(() => makeTool("x_search"));
loadOpenClawPluginsMock.mockImplementation((params) =>
Array.isArray((params as { onlyPluginIds?: string[] }).onlyPluginIds) &&
(params as { onlyPluginIds?: string[] }).onlyPluginIds?.length === 0
? { tools: [], diagnostics: [] }
: {
tools: [
{
pluginId: "xai",
optional: false,
source: "/tmp/xai.js",
names: ["x_search"],
factory,
},
],
diagnostics: [],
},
);
const tools = resolvePluginTools({
context: {
...createContext(),
config,
} as never,
env: {},
});
expect(tools).toEqual([]);
expect(factory).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("standalone bootstrap loads configured plugin tools before resolution", () => {
const config = createContext().config;
const registry = createToolRegistry([createOptionalDemoEntry()]);
loadOpenClawPluginsMock.mockReturnValue(registry);
installToolManifestSnapshot({
config,
plugin: {
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
});
ensureStandalonePluginToolRegistryLoaded({
context: createContext() as never,
toolAllowlist: ["optional_tool"],
});
const tools = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["optional_tool"],
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expectLoaderSelectedOnlyPluginIds(["optional-demo"]);
});
it("auto-loads cold registry for path-based config-origin plugins without pre-warming (#76598)", () => {
const context = {
...createContext(),
config: {
...createContext().config,
plugins: {
...createContext().config.plugins,
entries: {
"optional-demo": { enabled: true },
},
},
},
};
const config = context.config;
const registry = createToolRegistry([createOptionalDemoEntry()]);
loadOpenClawPluginsMock.mockReturnValue(registry);
installToolManifestSnapshot({
config,
plugin: {
id: "optional-demo",
origin: "config",
enabledByDefault: undefined,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
});
// No ensureStandalonePluginToolRegistryLoaded pre-call and no pinned channel registry —
// resolvePluginTools must trigger standalone load itself when the registry is cold.
// This is the regression path from PR #76004 where path-based plugin tools disappeared.
const tools = resolvePluginTools(
createResolveToolsParams({
context,
toolAllowlist: ["optional_tool"],
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expectLoaderSelectedOnlyPluginIds(["optional-demo"]);
});
it("does not reuse a partial active registry for wildcard-selected plugin tools", () => {
const context = createContext();
const config = context.config;
const optionalEntry = createOptionalDemoEntry();
const multiEntry: MockRegistryToolEntry = {
pluginId: "multi",
optional: false,
source: "/tmp/multi.js",
names: ["other_tool"],
declaredNames: ["other_tool"],
factory: () => makeTool("other_tool"),
};
installToolManifestSnapshots({
config,
plugins: [
{
id: "multi",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["other_tool"],
},
},
{
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
toolMetadata: {
optional_tool: {
optional: true,
},
},
},
],
});
const partialRegistry = createToolRegistry([multiEntry]);
partialRegistry.plugins.push({ id: "optional-demo", status: "loaded" });
const fullRegistry = createToolRegistry([multiEntry, optionalEntry]);
setActivePluginRegistry?.(
partialRegistry as never,
"partial-test-tool-registry",
"gateway-bindable",
"/tmp",
);
resolveRuntimePluginRegistryMock.mockReturnValue(partialRegistry);
loadOpenClawPluginsMock.mockReturnValue(fullRegistry);
const tools = resolvePluginTools(
createResolveToolsParams({
context,
toolAllowlist: ["*", "optional-demo"],
}),
);
expectResolvedToolNames(tools, ["other_tool", "optional_tool"]);
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
activate: false,
cache: false,
onlyPluginIds: ["multi", "optional-demo"],
toolDiscovery: true,
}),
);
});
it("warns when cold registry load still does not provide the selected plugin tools", () => {
const context = {
...createContext(),
config: {
...createContext().config,
plugins: {
...createContext().config.plugins,
entries: {
"optional-demo": { enabled: true },
},
},
},
};
const config = context.config;
const registry = createToolRegistry([]);
loadOpenClawPluginsMock.mockReturnValue(registry);
installToolManifestSnapshot({
config,
plugin: {
id: "optional-demo",
origin: "config",
enabledByDefault: undefined,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
});
const tools = resolvePluginTools(
createResolveToolsParams({
context,
toolAllowlist: ["optional_tool"],
}),
);
expect(tools).toEqual([]);
expectSingleDiagnosticMessage(
registry.diagnostics,
"plugin tool registry did not include selected plugin tools after cold load (optional-demo)",
);
});
it("uses the fresh cold-loaded registry for diagnostics when partial active registries remain incomplete", () => {
const context = createContext();
const config = context.config;
const multiEntry: MockRegistryToolEntry = {
pluginId: "multi",
optional: false,
source: "/tmp/multi.js",
names: ["other_tool"],
declaredNames: ["other_tool"],
factory: () => makeTool("other_tool"),
};
const optionalEntry = createOptionalDemoEntry();
installToolManifestSnapshots({
config,
plugins: [
{
id: "multi",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["other_tool"],
},
},
{
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
],
});
const staleRegistry = createToolRegistry([multiEntry]);
staleRegistry.plugins.push({ id: "optional-demo", status: "loaded" });
const freshRegistry = createToolRegistry([optionalEntry]);
freshRegistry.plugins.push({ id: "multi", status: "loaded" });
setActivePluginRegistry?.(
staleRegistry as never,
"partial-test-tool-registry",
"gateway-bindable",
"/tmp",
);
resolveRuntimePluginRegistryMock.mockReturnValue(staleRegistry);
loadOpenClawPluginsMock.mockReturnValue(freshRegistry);
const tools = resolvePluginTools(
createResolveToolsParams({
context,
toolAllowlist: ["*", "optional-demo"],
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(getActivePluginRegistry?.()).toBe(staleRegistry);
expectSingleDiagnosticMessage(
freshRegistry.diagnostics,
"plugin tool registry did not include selected plugin tools after cold load (multi)",
);
expect(staleRegistry.diagnostics).toEqual([]);
});
it("does not reuse a pinned gateway registry for manifest-unavailable tools", () => {
const config = createContext().config;
installToolManifestSnapshot({
config,
env: {},
plugin: createXaiToolManifest(),
});
const factory = vi.fn(() => makeTool("x_search"));
pinActivePluginChannelRegistry({
plugins: [{ id: "xai", status: "loaded" }],
tools: [
{
pluginId: "xai",
optional: false,
source: "/tmp/xai.js",
names: ["x_search"],
factory,
},
],
diagnostics: [],
} as never);
loadOpenClawPluginsMock.mockReturnValue({ tools: [], diagnostics: [] });
const tools = resolvePluginTools({
context: {
...createContext(),
config,
} as never,
env: {},
allowGatewaySubagentBinding: true,
});
expect(tools).toEqual([]);
expect(factory).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("loads plugin-owned tools when manifest tool metadata has env auth evidence", () => {
const config = createContext().config;
installToolManifestSnapshot({
config,
env: { XAI_API_KEY: "test-key" },
plugin: createXaiToolManifest(),
});
const factory = vi.fn(() => makeTool("x_search"));
setActivePluginRegistry(
{
plugins: [{ id: "xai", status: "loaded" }],
tools: [
{
pluginId: "xai",
optional: false,
source: "/tmp/xai.js",
names: ["x_search"],
factory,
},
],
diagnostics: [],
} as never,
"test-tool-registry",
"gateway-bindable",
"/tmp",
);
const tools = resolvePluginTools({
context: {
...createContext(),
config,
} as never,
env: {
XAI_API_KEY: "test-key",
},
});
expectResolvedToolNames(tools, ["x_search"]);
expect(factory).toHaveBeenCalledTimes(1);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("loads plugin-owned tools when manifest config signals point at configured non-env SecretRefs", () => {
const base = createContext();
const config = {
...base.config,
plugins: {
...base.config.plugins,
entries: {
xai: {
config: {
webSearch: {
apiKey: {
source: "file",
provider: "vault",
id: "/xai/tool-key",
},
},
},
},
},
},
secrets: {
providers: {
vault: {
source: "file",
path: "/tmp/openclaw-secrets.json",
mode: "json",
},
},
},
} as const;
installToolManifestSnapshot({
config,
env: {},
plugin: createXaiToolManifest(),
});
const factory = vi.fn(() => makeTool("x_search"));
setActivePluginRegistry(
{
plugins: [{ id: "xai", status: "loaded" }],
tools: [
{
pluginId: "xai",
optional: false,
source: "/tmp/xai.js",
names: ["x_search"],
factory,
},
],
diagnostics: [],
} as never,
"test-tool-registry",
"gateway-bindable",
"/tmp",
);
const tools = resolvePluginTools({
context: {
...base,
config,
} as never,
env: {},
});
expectResolvedToolNames(tools, ["x_search"]);
expect(factory).toHaveBeenCalledTimes(1);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("skips optional tools without explicit allowlist", () => {
setOptionalDemoRegistry();
const tools = resolveOptionalDemoTools();
expect(tools).toHaveLength(0);
});
it("does not invoke named optional tool factories without a matching allowlist", () => {
const factory = vi.fn(() => makeTool("optional_tool"));
setRegistry([
{
pluginId: "optional-demo",
optional: true,
source: "/tmp/optional-demo.js",
names: ["optional_tool"],
factory,
},
]);
expect(resolveOptionalDemoTools()).toHaveLength(0);
expect(resolveOptionalDemoTools(["other_tool"])).toHaveLength(0);
expect(factory).not.toHaveBeenCalled();
});
it("invokes unnamed optional tool factories when a tool allowlist may match the result", () => {
const factory = vi.fn(() => makeTool("optional_tool"));
setRegistry([
{
pluginId: "optional-demo",
optional: true,
source: "/tmp/optional-demo.js",
names: [],
declaredNames: ["optional_tool"],
factory,
},
]);
const tools = resolveOptionalDemoTools(["optional_tool"]);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(factory).toHaveBeenCalledTimes(1);
});
it.each([
{
name: "allows optional tools by tool name",
toolAllowlist: ["optional_tool"],
},
{
name: "allows optional tools via plugin id",
toolAllowlist: ["optional-demo"],
},
{
name: "allows optional tools via plugin-scoped allowlist entries",
toolAllowlist: ["optional_tool", "tavily"],
},
] as const)("$name", ({ toolAllowlist }) => {
setOptionalDemoRegistry();
const tools = resolveOptionalDemoTools(toolAllowlist);
expectResolvedToolNames(tools, ["optional_tool"]);
});
it("keeps default non-optional plugin tools when alsoAllow opts into optional tools", () => {
const defaultEntry: MockRegistryToolEntry = {
pluginId: "multi",
optional: false,
source: "/tmp/multi.js",
names: ["other_tool"],
declaredNames: ["other_tool"],
factory: () => makeTool("other_tool"),
};
setRegistry([defaultEntry, createOptionalDemoEntry()]);
const tools = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: [DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, "optional_tool"],
}),
);
expectResolvedToolNames(tools, ["other_tool", "optional_tool"]);
});
it("cold-loads default plugin tools when alsoAllow opts into optional tools", () => {
const context = createContext();
const config = context.config;
const defaultEntry: MockRegistryToolEntry = {
pluginId: "multi",
optional: false,
source: "/tmp/multi.js",
names: ["other_tool"],
declaredNames: ["other_tool"],
factory: () => makeTool("other_tool"),
};
loadOpenClawPluginsMock.mockReturnValue(
createToolRegistry([defaultEntry, createOptionalDemoEntry()]),
);
installToolManifestSnapshots({
config,
plugins: [
{
id: "multi",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["other_tool"],
},
},
{
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
],
});
const tools = resolvePluginTools(
createResolveToolsParams({
context,
toolAllowlist: [DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, "optional_tool"],
}),
);
expectResolvedToolNames(tools, ["other_tool", "optional_tool"]);
expectLoaderSelectedOnlyPluginIds(["multi", "optional-demo"]);
});
it("does not cold-load unrelated manifest-optional plugins when alsoAllow opts into one optional tool", () => {
const context = createContext();
const config = context.config;
const explicitOptionalEntry = createOptionalDemoEntry();
loadOpenClawPluginsMock.mockReturnValue(createToolRegistry([explicitOptionalEntry]));
installToolManifestSnapshots({
config,
plugins: [
{
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
toolMetadata: {
optional_tool: {
optional: true,
},
},
},
{
id: "unrelated-optional",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["unrelated_optional_tool"],
},
toolMetadata: {
unrelated_optional_tool: {
optional: true,
},
},
},
],
});
const tools = resolvePluginTools(
createResolveToolsParams({
context,
toolAllowlist: [DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, "optional_tool"],
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expectLoaderSelectedOnlyPluginIds(["optional-demo"]);
});
it("does not materialize manifest-unavailable default tools from warm registries under alsoAllow", () => {
const config = createContext().config;
installToolManifestSnapshots({
config,
env: {},
plugins: [
createXaiToolManifest(),
{
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
toolMetadata: {
optional_tool: {
optional: true,
},
},
},
],
});
const unavailableFactory = vi.fn(() => makeTool("x_search"));
const optionalFactory = vi.fn(() => makeTool("optional_tool"));
setActivePluginRegistry(
createToolRegistry([
{
pluginId: "xai",
optional: false,
source: "/tmp/xai.js",
names: ["x_search"],
declaredNames: ["x_search"],
factory: unavailableFactory,
},
{
pluginId: "optional-demo",
optional: true,
source: "/tmp/optional-demo.js",
names: ["optional_tool"],
declaredNames: ["optional_tool"],
factory: optionalFactory,
},
]) as never,
"test-tool-registry",
"gateway-bindable",
"/tmp",
);
const tools = resolvePluginTools(
createResolveToolsParams({
context: {
...createContext(),
config,
},
env: {},
toolAllowlist: [DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, "optional_tool"],
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(optionalFactory).toHaveBeenCalledTimes(1);
expect(unavailableFactory).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("does not materialize manifest-unavailable optional sibling tools under alsoAllow", () => {
const config = createContext().config;
installToolManifestSnapshot({
config,
env: {},
plugin: {
id: "multi",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
providerAuthEnvVars: {
xai: ["XAI_API_KEY"],
},
contracts: {
tools: ["other_tool", "optional_tool"],
},
toolMetadata: {
optional_tool: {
optional: true,
authSignals: [{ provider: "xai" }],
},
},
},
});
const defaultFactory = vi.fn(() => makeTool("other_tool"));
const optionalFactory = vi.fn(() => makeTool("optional_tool"));
setActivePluginRegistry(
createToolRegistry([
{
pluginId: "multi",
optional: false,
source: "/tmp/multi.js",
names: ["other_tool"],
declaredNames: ["other_tool"],
factory: defaultFactory,
},
{
pluginId: "multi",
optional: true,
source: "/tmp/multi.js",
names: ["optional_tool"],
declaredNames: ["optional_tool"],
factory: optionalFactory,
},
]) as never,
"test-tool-registry",
"gateway-bindable",
"/tmp",
);
const tools = resolvePluginTools(
createResolveToolsParams({
context: {
...createContext(),
config,
},
env: {},
toolAllowlist: [DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, "optional_tool"],
}),
);
expectResolvedToolNames(tools, ["other_tool"]);
expect(defaultFactory).toHaveBeenCalledTimes(1);
expect(optionalFactory).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("does not materialize manifest-optional sibling tools from non-optional factories by default", async () => {
const config = createContext().config;
installToolManifestSnapshot({
config,
plugin: {
id: "multi",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["other_tool", "optional_tool"],
},
toolMetadata: {
optional_tool: {
optional: true,
},
},
},
});
const factory = vi.fn(() => [makeTool("other_tool"), makeTool("optional_tool")]);
setActivePluginRegistry(
createToolRegistry([
{
pluginId: "multi",
optional: false,
source: "/tmp/multi.js",
names: ["other_tool", "optional_tool"],
declaredNames: ["other_tool", "optional_tool"],
factory,
},
]) as never,
"test-tool-registry",
"gateway-bindable",
"/tmp",
);
const { loadManifestContractSnapshot } = await import("./manifest-contract-eligibility.js");
const snapshot = loadManifestContractSnapshot({ config, workspaceDir: "/tmp" });
expect(
snapshot.plugins.find((plugin) => plugin.id === "multi")?.toolMetadata?.optional_tool,
).toMatchObject({ optional: true });
const tools = resolvePluginTools(
createResolveToolsParams({
context: {
...createContext(),
config,
},
}),
);
expectResolvedToolNames(tools, ["other_tool"]);
expect(factory).toHaveBeenCalledTimes(1);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("marks allowlisted manifest-optional sibling tools from non-optional factories as optional", () => {
const config = createContext().config;
installToolManifestSnapshot({
config,
plugin: {
id: "multi",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["other_tool", "optional_tool"],
},
toolMetadata: {
optional_tool: {
optional: true,
},
},
},
});
const factory = vi.fn(() => [makeTool("other_tool"), makeTool("optional_tool")]);
setActivePluginRegistry(
createToolRegistry([
{
pluginId: "multi",
optional: false,
source: "/tmp/multi.js",
names: ["other_tool", "optional_tool"],
declaredNames: ["other_tool", "optional_tool"],
factory,
},
]) as never,
"test-tool-registry",
"gateway-bindable",
"/tmp",
);
const first = resolvePluginTools(
createResolveToolsParams({
context: {
...createContext(),
config,
},
toolAllowlist: [DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, "optional_tool"],
}),
);
const second = resolvePluginTools(
createResolveToolsParams({
context: {
...createContext(),
config,
},
toolAllowlist: [DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, "optional_tool"],
}),
);
expectResolvedToolNames(first, ["other_tool", "optional_tool"]);
expectResolvedToolNames(second, ["other_tool", "optional_tool"]);
expect(getPluginToolMeta(first[0])?.optional).toBe(false);
expect(getPluginToolMeta(first[1])?.optional).toBe(true);
expect(getPluginToolMeta(second[1])?.optional).toBe(true);
expect(factory).toHaveBeenCalledTimes(1);
});
it("rejects plugin id collisions with core tool names", () => {
const registry = setRegistry([
{
pluginId: "message",
optional: false,
source: "/tmp/message.js",
names: ["optional_tool"],
factory: () => makeTool("optional_tool"),
},
]);
const tools = resolvePluginTools(
createResolveToolsParams({
existingToolNames: new Set(["message"]),
}),
);
expect(tools).toHaveLength(0);
expectSingleDiagnosticMessage(registry.diagnostics, "plugin id conflicts with core tool name");
});
it.each([
{
name: "skips conflicting tool names but keeps other tools",
expectedDiagnosticFragment: "plugin tool name conflict",
},
{
name: "suppresses conflict diagnostics when requested",
suppressNameConflicts: true,
},
] as const)("$name", ({ suppressNameConflicts, expectedDiagnosticFragment }) => {
expectConflictingCoreNameResolution({
suppressNameConflicts,
expectedDiagnosticFragment,
});
});
it("rejects normalized plugin tool name collisions with core tools", () => {
const registry = setRegistry([
{
pluginId: "multi",
optional: false,
source: "/tmp/multi.js",
names: ["Message", "other_tool"],
declaredNames: ["Message", "other_tool"],
factory: () => [makeTool("Message"), makeTool("other_tool")],
},
]);
const tools = resolvePluginTools(
createResolveToolsParams({
existingToolNames: new Set(["message"]),
}),
);
expectResolvedToolNames(tools, ["other_tool"]);
expectSingleDiagnosticMessage(
registry.diagnostics,
"plugin tool name conflict (multi): Message",
);
});
it("rejects normalized cached plugin tool name collisions with core tools", () => {
const factory = vi.fn(() => makeTool("Message"));
setRegistry([
{
pluginId: "multi",
optional: false,
source: "/tmp/multi.js",
names: ["Message"],
declaredNames: ["Message"],
factory,
},
]);
const first = resolvePluginTools(createResolveToolsParams());
const second = resolvePluginTools(
createResolveToolsParams({
existingToolNames: new Set(["message"]),
}),
);
expectResolvedToolNames(first, ["Message"]);
expect(second).toEqual([]);
expect(factory).toHaveBeenCalled();
});
it.each([
{
name: "uses loaded plugin tools with an explicit env",
params: {
env: { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv,
toolAllowlist: ["optional_tool"],
},
expectedLoaderCall: {
env: { OPENCLAW_HOME: "/srv/openclaw-home" },
},
},
{
name: "uses loaded plugin tools with gateway subagent binding",
params: {
allowGatewaySubagentBinding: true,
toolAllowlist: ["optional_tool"],
},
expectedLoaderCall: {
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
},
},
])("$name", ({ params, expectedLoaderCall }) => {
setOptionalDemoRegistry();
if (params.env) {
installToolManifestSnapshot({
config: createContext().config,
env: params.env,
plugin: {
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
});
}
resolvePluginTools(createResolveToolsParams(params));
expectLoaderCall(expectedLoaderCall);
});
it("skips malformed plugin tools while keeping valid sibling tools", () => {
const registry = setRegistry([
{
pluginId: "schema-bug",
optional: false,
source: "/tmp/schema-bug.js",
names: ["broken_tool", "valid_tool"],
factory: () => [createMalformedTool("broken_tool"), makeTool("valid_tool")],
},
]);
const tools = resolvePluginTools(createResolveToolsParams());
expectResolvedToolNames(tools, ["valid_tool"]);
expectSingleDiagnosticMessage(
registry.diagnostics,
"plugin tool is malformed (schema-bug): broken_tool missing parameters object",
);
});
it("warns with plugin factory timing details when a factory is slow", () => {
vi.useFakeTimers({ now: 0 });
const warnSpy = installConsoleMethodSpy("warn");
setLoggerOverride({ level: "silent", consoleLevel: "warn" });
setRegistry([
{
pluginId: "optional-demo",
names: ["optional_tool"],
optional: true,
source: "/tmp/optional-demo.js",
factory: () => {
vi.advanceTimersByTime(1200);
return makeTool("optional_tool");
},
},
]);
const tools = resolveOptionalDemoTools(["optional_tool"]);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(warnSpy).toHaveBeenCalledTimes(1);
const message = String(warnSpy.mock.calls[0]?.[0] ?? "");
expect(message).toContain("[trace:plugin-tools] factory timings");
expect(message).toContain("totalMs=1200");
expect(message).toContain("optional-demo:1200ms@1200ms");
expect(message).toContain("names=[optional_tool]");
expect(message).toContain("result=single");
expect(message).toContain("count=1");
});
it("emits trace factory timings below the warn threshold when trace logging is enabled", () => {
vi.useFakeTimers({ now: 0 });
const logSpy = installConsoleMethodSpy("log");
setLoggerOverride({ level: "silent", consoleLevel: "trace" });
setRegistry([
{
pluginId: "optional-demo",
names: ["optional_tool"],
optional: true,
source: "/tmp/optional-demo.js",
factory: () => {
vi.advanceTimersByTime(5);
return makeTool("optional_tool");
},
},
]);
const tools = resolveOptionalDemoTools(["optional_tool"]);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(logSpy).toHaveBeenCalledTimes(1);
const message = String(logSpy.mock.calls[0]?.[0] ?? "");
expect(message).toContain("[trace:plugin-tools] factory timings");
expect(message).toContain("totalMs=5");
expect(message).toContain("optional-demo:5ms@5ms");
});
it("does not log plugin factory timings for fast factories without trace logging", () => {
vi.useFakeTimers({ now: 0 });
const warnSpy = installConsoleMethodSpy("warn");
setLoggerOverride({ level: "silent", consoleLevel: "warn" });
setRegistry([
{
pluginId: "optional-demo",
names: ["optional_tool"],
optional: true,
source: "/tmp/optional-demo.js",
factory: () => {
vi.advanceTimersByTime(5);
return makeTool("optional_tool");
},
},
]);
const tools = resolveOptionalDemoTools(["optional_tool"]);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(warnSpy).not.toHaveBeenCalled();
});
it("caches plugin tool descriptors and uses the runtime only on execution", async () => {
const factory = vi.fn((rawCtx: unknown) => {
const ctx = rawCtx as { sessionId?: string };
return {
...makeTool("cached_tool"),
async execute() {
return { content: [{ type: "text", text: ctx.sessionId ?? "missing" }] };
},
};
});
setRegistry([
{
pluginId: "cache-test",
optional: false,
source: "/tmp/cache-test.js",
names: ["cached_tool"],
factory,
},
]);
const first = resolvePluginTools(
createResolveToolsParams({
context: { ...createContext(), sessionId: "same" },
}),
);
const second = resolvePluginTools(
createResolveToolsParams({
context: { ...createContext(), sessionId: "same" },
}),
);
expectResolvedToolNames(first, ["cached_tool"]);
expectResolvedToolNames(second, ["cached_tool"]);
expect(factory).toHaveBeenCalledTimes(1);
expect(second[0]).not.toBe(first[0]);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
await expect(second[0]?.execute("call", {}, undefined)).resolves.toEqual({
content: [{ type: "text", text: "same" }],
});
expect(factory).toHaveBeenCalledTimes(2);
});
it("reuses cached plugin tool descriptors across session identity changes", async () => {
const factory = vi.fn((rawCtx: unknown) => {
const ctx = rawCtx as { sessionId?: string };
return {
...makeTool("cached_session_tool"),
async execute() {
return { content: [{ type: "text", text: ctx.sessionId ?? "missing" }] };
},
};
});
setRegistry([
{
pluginId: "cache-session-test",
optional: false,
source: "/tmp/cache-session-test.js",
names: ["cached_session_tool"],
factory,
},
]);
const first = resolvePluginTools(
createResolveToolsParams({
context: {
...createContext(),
sessionId: "first-session",
sessionKey: "agent:main:first-session",
},
}),
);
const second = resolvePluginTools(
createResolveToolsParams({
context: {
...createContext(),
sessionId: "second-session",
sessionKey: "agent:main:second-session",
},
}),
);
expectResolvedToolNames(first, ["cached_session_tool"]);
expectResolvedToolNames(second, ["cached_session_tool"]);
expect(factory).toHaveBeenCalledTimes(1);
await expect(second[0]?.execute("call", {}, undefined)).resolves.toEqual({
content: [{ type: "text", text: "second-session" }],
});
expect(factory).toHaveBeenCalledTimes(2);
});
it("does not reuse cached plugin tool descriptors across sandbox context changes", () => {
const factory = vi.fn((rawCtx: unknown) => {
const ctx = rawCtx as { sandboxed?: boolean };
return ctx.sandboxed ? null : makeTool("sandbox_sensitive_tool");
});
setRegistry([
{
pluginId: "sandbox-sensitive",
optional: false,
source: "/tmp/sandbox-sensitive.js",
names: ["sandbox_sensitive_tool"],
factory,
},
]);
const hostTools = resolvePluginTools(
createResolveToolsParams({
context: { ...createContext(), sandboxed: false },
}),
);
const sandboxedTools = resolvePluginTools(
createResolveToolsParams({
context: { ...createContext(), sandboxed: true },
}),
);
expectResolvedToolNames(hostTools, ["sandbox_sensitive_tool"]);
expect(sandboxedTools).toEqual([]);
expect(factory).toHaveBeenCalledTimes(2);
});
it("executes cached plugin tools registered with implicit names", async () => {
const factory = vi.fn(() => ({
...makeTool("implicit_tool"),
async execute() {
return { content: [{ type: "text", text: "implicit-ok" }] };
},
}));
setRegistry([
{
pluginId: "implicit-owner",
optional: false,
source: "/tmp/implicit-owner.js",
names: [],
declaredNames: ["implicit_tool"],
factory,
},
]);
const first = resolvePluginTools(createResolveToolsParams());
const second = resolvePluginTools(createResolveToolsParams());
expectResolvedToolNames(first, ["implicit_tool"]);
expectResolvedToolNames(second, ["implicit_tool"]);
expect(factory).toHaveBeenCalledTimes(1);
await expect(second[0]?.execute("call", {}, undefined)).resolves.toEqual({
content: [{ type: "text", text: "implicit-ok" }],
});
expect(factory).toHaveBeenCalledTimes(2);
});
it("skips factory-returned tools outside the manifest tool contract", () => {
const registry = setRegistry([
{
pluginId: "dynamic-owner",
optional: false,
source: "/tmp/dynamic-owner.js",
names: ["declared_tool"],
declaredNames: ["declared_tool"],
factory: () => [makeTool("declared_tool"), makeTool("rogue_tool")],
},
]);
const tools = resolvePluginTools(createResolveToolsParams());
expectResolvedToolNames(tools, ["declared_tool"]);
expectSingleDiagnosticMessage(registry.diagnostics, "plugin tool is undeclared");
});
it("skips allowlisted optional malformed plugin tools", () => {
const registry = setRegistry([
{
pluginId: "optional-demo",
optional: true,
source: "/tmp/optional-demo.js",
names: ["optional_tool"],
factory: () => createMalformedTool("optional_tool"),
},
]);
const tools = resolveOptionalDemoTools(["optional_tool"]);
expect(tools).toHaveLength(0);
expectSingleDiagnosticMessage(
registry.diagnostics,
"plugin tool is malformed (optional-demo): optional_tool missing parameters object",
);
});
it.each([
{
name: "loads plugin tools from the auto-enabled config snapshot",
expectedToolNames: undefined,
},
{
name: "does not reuse a cached active registry when auto-enable changes the config snapshot",
expectedToolNames: ["optional_tool"],
},
] as const)("$name", ({ expectedToolNames }) => {
const { rawContext, autoEnabledConfig, tools } = resolveAutoEnabledOptionalDemoTools();
expect(applyPluginAutoEnableMock).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: rawContext.config.plugins?.allow,
load: rawContext.config.plugins?.load,
}),
}),
env: process.env,
}),
);
if (expectedToolNames) {
expectResolvedToolNames(tools, expectedToolNames);
}
expectAutoEnabledOptionalLoad(autoEnabledConfig);
});
it("reuses a compatible active registry instead of loading again", () => {
const activeRegistry = createOptionalDemoActiveRegistry();
resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry);
const tools = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["optional_tool"],
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("reuses the gateway-bindable registry when it covers the tool runtime scope", () => {
const activeRegistry = createOptionalDemoActiveRegistry();
setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp");
resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry);
const tools = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["optional_tool"],
allowGatewaySubagentBinding: true,
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("does not widen active registry reuse to non-matching plugin tool owners", () => {
installToolManifestSnapshot({
config: createContext().config,
plugin: {
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
});
const heavyFactory = vi.fn(() => makeTool("heavy_tool"));
const activeRegistry = {
plugins: [
{ id: "optional-demo", status: "loaded" },
{ id: "heavy-startup", status: "loaded" },
],
tools: [
createOptionalDemoEntry(),
{
pluginId: "heavy-startup",
optional: false,
source: "/tmp/heavy-startup.js",
names: ["heavy_tool"],
factory: heavyFactory,
},
],
diagnostics: [],
};
setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp");
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
const tools = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["optional_tool"],
allowGatewaySubagentBinding: true,
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(heavyFactory).not.toHaveBeenCalled();
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("does not let disabled bundled tool owners poison explicit runtime allowlists", () => {
const config = {
plugins: {
enabled: true,
allow: ["memory-core", "memory-lancedb"],
load: { paths: [] },
entries: {
"memory-core": { enabled: true },
"memory-lancedb": { enabled: false },
},
slots: { memory: "memory-core" },
},
};
installToolManifestSnapshots({
config,
plugins: [
{
id: "memory-core",
origin: "bundled",
enabledByDefault: false,
channels: [],
providers: [],
contracts: {
tools: ["memory_get", "memory_search"],
},
},
{
id: "memory-lancedb",
origin: "bundled",
enabledByDefault: false,
channels: [],
providers: [],
contracts: {
tools: ["memory_recall"],
},
},
],
});
const memorySearchFactory = vi.fn(() => [makeTool("memory_search"), makeTool("memory_get")]);
const activeRegistry = {
plugins: [
{ id: "memory-core", status: "loaded" },
{ id: "memory-lancedb", status: "disabled" },
],
tools: [
{
pluginId: "memory-core",
optional: false,
source: "/tmp/memory-core.js",
names: ["memory_search", "memory_get"],
declaredNames: ["memory_search", "memory_get"],
factory: memorySearchFactory,
},
],
diagnostics: [],
};
setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp");
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
const tools = resolvePluginTools(
createResolveToolsParams({
context: { ...createContext(), config },
toolAllowlist: ["memory_recall", "memory_search", "memory_get"],
allowGatewaySubagentBinding: true,
}),
);
expectResolvedToolNames(tools, ["memory_search", "memory_get"]);
expect(memorySearchFactory).toHaveBeenCalledTimes(1);
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("falls back from a loaded channel registry without matching tool entries", () => {
const config = {
plugins: {
enabled: true,
allow: ["memory-core"],
load: { paths: [] },
entries: {
"memory-core": { enabled: true },
},
slots: { memory: "memory-core" },
},
};
installToolManifestSnapshot({
config,
plugin: {
id: "memory-core",
origin: "bundled",
enabledByDefault: false,
channels: [],
providers: [],
contracts: {
tools: ["memory_get", "memory_search"],
},
},
});
const memorySearchFactory = vi.fn(() => [makeTool("memory_search"), makeTool("memory_get")]);
const activeRegistry = {
plugins: [{ id: "memory-core", status: "loaded" }],
tools: [
{
pluginId: "memory-core",
optional: false,
source: "/tmp/memory-core.js",
names: ["memory_search", "memory_get"],
declaredNames: ["memory_search", "memory_get"],
factory: memorySearchFactory,
},
],
diagnostics: [],
};
setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp");
pinActivePluginChannelRegistry({
plugins: [{ id: "memory-core", status: "loaded" }],
tools: [],
diagnostics: [],
} as never);
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
const tools = resolvePluginTools(
createResolveToolsParams({
context: { ...createContext(), config },
toolAllowlist: ["memory_search", "memory_get"],
allowGatewaySubagentBinding: true,
}),
);
expectResolvedToolNames(tools, ["memory_search", "memory_get"]);
expect(memorySearchFactory).toHaveBeenCalledTimes(1);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("loads a standalone registry when cached runtime registries lack matching tool entries", () => {
const config = {
plugins: {
enabled: true,
allow: ["memory-core"],
load: { paths: [] },
entries: {
"memory-core": { enabled: true },
},
slots: { memory: "memory-core" },
},
};
installToolManifestSnapshot({
config,
plugin: {
id: "memory-core",
origin: "bundled",
enabledByDefault: false,
channels: [],
providers: [],
contracts: {
tools: ["memory_get", "memory_search"],
},
},
});
const memorySearchFactory = vi.fn(() => [makeTool("memory_search"), makeTool("memory_get")]);
const loadedRegistry = {
plugins: [{ id: "memory-core", status: "loaded" }],
tools: [
{
pluginId: "memory-core",
optional: false,
source: "/tmp/memory-core.js",
names: ["memory_search", "memory_get"],
declaredNames: ["memory_search", "memory_get"],
factory: memorySearchFactory,
},
],
diagnostics: [],
};
setActivePluginRegistry(
{
plugins: [{ id: "memory-core", status: "loaded" }],
tools: [],
diagnostics: [],
} as never,
"gateway-startup",
"gateway-bindable",
"/tmp",
);
pinActivePluginChannelRegistry({
plugins: [{ id: "memory-core", status: "loaded" }],
tools: [],
diagnostics: [],
} as never);
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
loadOpenClawPluginsMock.mockReturnValue(loadedRegistry);
const tools = resolvePluginTools(
createResolveToolsParams({
context: { ...createContext(), config },
toolAllowlist: ["memory_search", "memory_get"],
allowGatewaySubagentBinding: true,
}),
);
expectResolvedToolNames(tools, ["memory_search", "memory_get"]);
expect(memorySearchFactory).toHaveBeenCalledTimes(1);
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
activate: false,
onlyPluginIds: ["memory-core"],
toolDiscovery: true,
}),
);
});
it("adds enabled non-startup tool plugins to the active tool runtime scope", () => {
const activeRegistry = createOptionalDemoActiveRegistry();
const context = createContext();
const config = {
...context.config,
plugins: {
...context.config.plugins,
allow: ["tavily"],
entries: {
tavily: { enabled: true },
},
},
};
installToolManifestSnapshots({
config,
plugins: [
{
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
{
id: "tavily",
origin: "bundled",
enabledByDefault: false,
channels: [],
providers: [],
contracts: {
tools: ["tavily_search"],
},
},
],
});
setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp");
resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry);
loadOpenClawPluginsMock.mockReturnValue(createToolRegistry([]));
resolvePluginTools({
context: {
...context,
config,
} as never,
toolAllowlist: ["*", "tavily"],
allowGatewaySubagentBinding: true,
});
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: expect.arrayContaining(["tavily"]),
toolDiscovery: true,
}),
);
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: expect.arrayContaining(["tavily"]),
toolDiscovery: true,
}),
);
});
it("reuses the pinned gateway channel registry after provider runtime loads replace active registry", () => {
const gatewayRegistry = createOptionalDemoActiveRegistry();
setActivePluginRegistry(
gatewayRegistry as never,
"gateway-startup",
"gateway-bindable",
"/tmp",
);
pinActivePluginChannelRegistry(gatewayRegistry as never);
setActivePluginRegistry(
{
plugins: [],
tools: [],
diagnostics: [],
} as never,
"provider-runtime",
"default",
"/tmp",
);
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
const tools = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["optional_tool"],
allowGatewaySubagentBinding: true,
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("reuses the pinned gateway channel registry even when the caller omits gateway binding", () => {
const gatewayRegistry = createOptionalDemoActiveRegistry();
setActivePluginRegistry(
gatewayRegistry as never,
"gateway-startup",
"gateway-bindable",
"/tmp",
);
pinActivePluginChannelRegistry(gatewayRegistry as never);
setActivePluginRegistry(
{
plugins: [],
tools: [],
diagnostics: [],
} as never,
"provider-runtime",
"default",
"/tmp",
);
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
const tools = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["optional_tool"],
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("loads plugin tools when gateway-bindable tool loads have no active registry", () => {
setOptionalDemoRegistry();
const tools = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["optional_tool"],
allowGatewaySubagentBinding: true,
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expectLoaderCall({
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
});
});
it("reloads when gateway binding would otherwise reuse a default-mode active registry", () => {
setActivePluginRegistry(
{
plugins: [],
tools: [],
diagnostics: [],
} as never,
"default-registry",
"default",
);
setOptionalDemoRegistry();
resolvePluginTools({
context: createContext() as never,
allowGatewaySubagentBinding: true,
toolAllowlist: ["optional_tool"],
});
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("includes non-optional browser tool when toolAllowlist is empty (full profile)", () => {
const browserEntry: MockRegistryToolEntry = {
pluginId: "browser",
optional: false,
source: "/tmp/browser.js",
names: ["browser"],
declaredNames: ["browser"],
factory: () => makeTool("browser"),
};
setRegistry([browserEntry]);
// Empty toolAllowlist simulates tools.profile: "full" where no explicit
// allow list exists. Non-optional plugin tools must still be resolved.
const tools = resolvePluginTools(createResolveToolsParams({ toolAllowlist: [] }));
expectResolvedToolNames(tools, ["browser"]);
});
it("includes non-optional browser tool when toolAllowlist is undefined (full profile)", () => {
const browserEntry: MockRegistryToolEntry = {
pluginId: "browser",
optional: false,
source: "/tmp/browser.js",
names: ["browser"],
declaredNames: ["browser"],
factory: () => makeTool("browser"),
};
setRegistry([browserEntry]);
// Undefined toolAllowlist is the other variant of "no explicit allowlist".
const tools = resolvePluginTools(createResolveToolsParams());
expectResolvedToolNames(tools, ["browser"]);
});
it("includes non-optional browser tool when toolAllowlist has wildcard (#76507)", () => {
const browserEntry: MockRegistryToolEntry = {
pluginId: "browser",
optional: false,
source: "/tmp/browser.js",
names: ["browser"],
declaredNames: ["browser"],
factory: () => makeTool("browser"),
};
setRegistry([browserEntry]);
// Wildcard allowlist from tools.profile: "full" explicitly grants all tools.
const tools = resolvePluginTools(createResolveToolsParams({ toolAllowlist: ["*"] }));
expectResolvedToolNames(tools, ["browser"]);
});
it("does not materialize plugin tools blocked by explicit deny policy", () => {
const browserFactory = vi.fn(() => makeTool("browser"));
const browserEntry: MockRegistryToolEntry = {
pluginId: "browser",
optional: false,
source: "/tmp/browser.js",
names: ["browser"],
declaredNames: ["browser"],
factory: browserFactory,
};
setRegistry([browserEntry]);
const tools = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["*"],
toolDenylist: ["browser"],
}),
);
expectResolvedToolNames(tools, []);
expect(browserFactory).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("includes optional tools when wildcard allowlist is active (#76507)", () => {
setOptionalDemoRegistry();
// Wildcard must grant optional tools too.
const tools = resolvePluginTools(createResolveToolsParams({ toolAllowlist: ["*"] }));
expectResolvedToolNames(tools, ["optional_tool"]);
});
});
describe("buildPluginToolMetadataKey", () => {
beforeAll(async () => {
({ buildPluginToolMetadataKey } = await import("./tools.js"));
});
it("does not collide when ids or names contain separator-like characters", () => {
expect(buildPluginToolMetadataKey("plugin", "a\uE000b")).not.toBe(
buildPluginToolMetadataKey("plugin\uE000a", "b"),
);
expect(buildPluginToolMetadataKey("plugin", "a\u0000b")).not.toBe(
buildPluginToolMetadataKey("plugin\u0000a", "b"),
);
});
});