diff --git a/src/tools/availability.test.ts b/src/tools/availability.test.ts new file mode 100644 index 00000000000..5a28df7be09 --- /dev/null +++ b/src/tools/availability.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "vitest"; +import { evaluateToolAvailability } from "./availability.js"; +import type { ToolDescriptor } from "./types.js"; + +const baseDescriptor: ToolDescriptor = { + name: "example", + description: "Example tool", + inputSchema: { type: "object" }, + owner: { kind: "core" }, + executor: { kind: "core", executorId: "example" }, +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +describe("evaluateToolAvailability", () => { + it("treats descriptors without signals as available", () => { + expect(evaluateToolAvailability({ descriptor: baseDescriptor })).toEqual([]); + }); + + it("evaluates auth, env, config, plugin, and context signals from data only", () => { + const descriptor: ToolDescriptor = { + ...baseDescriptor, + availability: { + allOf: [ + { kind: "auth", providerId: "openai" }, + { kind: "env", name: "OPENAI_API_KEY" }, + { kind: "config", path: ["plugins", "entries", "demo", "config"], check: "non-empty" }, + { kind: "plugin-enabled", pluginId: "demo" }, + { kind: "context", key: "channel", equals: "telegram" }, + ], + }, + }; + + expect( + evaluateToolAvailability({ + descriptor, + context: { + authProviderIds: new Set(["openai"]), + env: { OPENAI_API_KEY: "set" }, + config: { plugins: { entries: { demo: { config: { mode: "local" } } } } }, + enabledPluginIds: new Set(["demo"]), + values: { channel: "telegram" }, + }, + }), + ).toEqual([]); + }); + + it("returns deterministic diagnostics for missing signals", () => { + const descriptor: ToolDescriptor = { + ...baseDescriptor, + availability: { + allOf: [ + { kind: "auth", providerId: "openai" }, + { kind: "env", name: "OPENAI_API_KEY" }, + { kind: "config", path: ["plugins", "entries", "demo", "config"], check: "non-empty" }, + { kind: "plugin-enabled", pluginId: "demo" }, + { kind: "context", key: "channel", equals: "telegram" }, + ], + }, + }; + + expect( + evaluateToolAvailability({ + descriptor, + context: { + authProviderIds: new Set(), + env: {}, + config: { plugins: { entries: { demo: { config: {} } } } }, + enabledPluginIds: new Set(), + values: { channel: "discord" }, + }, + }).map((entry) => entry.reason), + ).toEqual([ + "auth-missing", + "env-missing", + "config-missing", + "plugin-disabled", + "context-mismatch", + ]); + }); + + it("does not treat credential config values as available without an injected resolver", () => { + const descriptor: ToolDescriptor = { + ...baseDescriptor, + availability: { + kind: "config", + path: ["models", "providers", "openai", "apiKey"], + check: "available", + }, + }; + + expect( + evaluateToolAvailability({ + descriptor, + context: { + config: { + models: { + providers: { + openai: { + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }, + }, + }, + }, + env: {}, + }, + }).map((entry) => entry.reason), + ).toEqual(["config-missing"]); + }); + + it("accepts credential config values only through an injected availability resolver", () => { + const descriptor: ToolDescriptor = { + ...baseDescriptor, + availability: { + kind: "config", + path: ["models", "providers", "openai", "apiKey"], + check: "available", + }, + }; + + expect( + evaluateToolAvailability({ + descriptor, + context: { + config: { + models: { + providers: { + openai: { + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }, + }, + }, + }, + env: { OPENAI_API_KEY: "set" }, + isConfigValueAvailable: ({ value }) => + isRecord(value) && + value.source === "env" && + value.provider === "default" && + value.id === "OPENAI_API_KEY", + }, + }), + ).toEqual([]); + }); + + it("does not infer env-template strings as configured credentials", () => { + const descriptor: ToolDescriptor = { + ...baseDescriptor, + availability: { + kind: "config", + path: ["models", "providers", "openai", "apiKey"], + check: "available", + }, + }; + + expect( + evaluateToolAvailability({ + descriptor, + context: { + config: { + models: { + providers: { + openai: { apiKey: "${OPENAI_API_KEY}" }, + }, + }, + }, + env: { OPENAI_API_KEY: "set" }, + }, + }).map((entry) => entry.reason), + ).toEqual(["config-missing"]); + }); + + it("does not infer ordinary objects with source/provider/id fields as credentials", () => { + const descriptor: ToolDescriptor = { + ...baseDescriptor, + availability: { + kind: "config", + path: ["tools", "example"], + check: "non-empty", + }, + }; + + expect( + evaluateToolAvailability({ + descriptor, + context: { + config: { + tools: { + example: { source: "manual", provider: "docs", id: "readme" }, + }, + }, + }, + }), + ).toEqual([]); + }); + + it("supports anyOf availability expressions", () => { + const descriptor: ToolDescriptor = { + ...baseDescriptor, + availability: { + anyOf: [ + { kind: "auth", providerId: "openai" }, + { kind: "env", name: "OPENAI_API_KEY" }, + { + allOf: [ + { kind: "config", path: ["plugins", "entries", "local"], check: "non-empty" }, + { kind: "plugin-enabled", pluginId: "local" }, + ], + }, + ], + }, + }; + + expect( + evaluateToolAvailability({ + descriptor, + context: { + authProviderIds: new Set(), + env: { OPENAI_API_KEY: "set" }, + enabledPluginIds: new Set(), + }, + }), + ).toEqual([]); + + expect( + evaluateToolAvailability({ + descriptor, + context: { + authProviderIds: new Set(), + env: {}, + enabledPluginIds: new Set(), + }, + }).map((entry) => entry.reason), + ).toEqual(["auth-missing", "env-missing", "config-missing", "plugin-disabled"]); + }); +}); diff --git a/src/tools/boundary.test.ts b/src/tools/boundary.test.ts new file mode 100644 index 00000000000..97a4f602963 --- /dev/null +++ b/src/tools/boundary.test.ts @@ -0,0 +1,45 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const toolsDir = new URL("./", import.meta.url); +const moduleReferencePattern = + /\b(?:import|export)\s+(?:type\s+)?(?:[^"'`]*?\s+from\s+)?["']([^"']+)["']/gu; + +function collectStaticModuleReferences( + source: string, +): readonly { line: number; specifier: string }[] { + const references: { line: number; specifier: string }[] = []; + const lines = source.split("\n"); + for (const [index, line] of lines.entries()) { + const trimmed = line.trimStart(); + if (trimmed.startsWith("//")) { + continue; + } + for (const match of line.matchAll(moduleReferencePattern)) { + const specifier = match[1]; + if (specifier) { + references.push({ line: index + 1, specifier }); + } + } + } + return references; +} + +describe("tool system boundary", () => { + it("keeps production tool modules independent from OpenClaw subsystems", () => { + const violations = readdirSync(toolsDir, { withFileTypes: true }).flatMap((entry) => { + if (!entry.isFile() || !entry.name.endsWith(".ts") || entry.name.endsWith(".test.ts")) { + return []; + } + const source = readFileSync(new URL(entry.name, toolsDir), "utf8"); + return collectStaticModuleReferences(source) + .filter( + (reference) => + !reference.specifier.startsWith("./") && !reference.specifier.startsWith("node:"), + ) + .map((reference) => `${entry.name}:${reference.line} ${reference.specifier}`); + }); + + expect(violations).toEqual([]); + }); +}); diff --git a/src/tools/planner.test.ts b/src/tools/planner.test.ts new file mode 100644 index 00000000000..6d3c26c1d46 --- /dev/null +++ b/src/tools/planner.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { ToolPlanContractError } from "./diagnostics.js"; +import { formatToolExecutorRef } from "./execution.js"; +import { buildToolPlan } from "./planner.js"; +import { toToolProtocolDescriptors } from "./protocol.js"; +import type { ToolDescriptor } from "./types.js"; + +function descriptor(name: string, overrides: Partial = {}): ToolDescriptor { + return { + name, + description: `${name} description`, + inputSchema: { type: "object" }, + owner: { kind: "core" }, + executor: { kind: "core", executorId: name }, + ...overrides, + }; +} + +describe("buildToolPlan", () => { + it("sorts visible and hidden tools deterministically", () => { + const plan = buildToolPlan({ + descriptors: [ + descriptor("zeta"), + descriptor("alpha"), + descriptor("hidden", { + sortKey: "middle", + availability: { kind: "env", name: "MISSING_ENV" }, + }), + ], + availability: { env: {} }, + }); + + expect(plan.visible.map((entry) => entry.descriptor.name)).toEqual(["alpha", "zeta"]); + expect(plan.hidden.map((entry) => entry.descriptor.name)).toEqual(["hidden"]); + expect(plan.hidden[0]?.diagnostics.map((entry) => entry.reason)).toEqual(["env-missing"]); + }); + + it("fails deterministically on duplicate tool names", () => { + let error: unknown; + try { + buildToolPlan({ + descriptors: [descriptor("read"), descriptor("read")], + }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(ToolPlanContractError); + expect(error).toMatchObject({ + code: "duplicate-tool-name", + toolName: "read", + }); + }); + + it("fails closed when a visible descriptor has no executor", () => { + let error: unknown; + try { + buildToolPlan({ + descriptors: [descriptor("read", { executor: undefined })], + }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(ToolPlanContractError); + expect(error).toMatchObject({ + code: "missing-executor", + toolName: "read", + }); + }); + + it("does not require an executor for unavailable descriptors", () => { + const plan = buildToolPlan({ + descriptors: [ + descriptor("plugin_tool", { + executor: undefined, + availability: { kind: "plugin-enabled", pluginId: "demo" }, + }), + ], + availability: { enabledPluginIds: new Set() }, + }); + + expect(plan.visible).toEqual([]); + expect(plan.hidden[0]?.descriptor.name).toBe("plugin_tool"); + expect(plan.hidden[0]?.diagnostics[0]?.reason).toBe("plugin-disabled"); + }); + + it("hides descriptors with malformed empty allOf availability", () => { + const plan = buildToolPlan({ + descriptors: [descriptor("malformed", { availability: { allOf: [] } })], + }); + + expect(plan.visible).toEqual([]); + expect(plan.hidden[0]?.descriptor.name).toBe("malformed"); + expect(plan.hidden[0]?.diagnostics).toEqual([ + { + reason: "unsupported-signal", + message: "Empty availability allOf group", + }, + ]); + }); + + it("keeps protocol conversion separate from executor refs and model normalization", () => { + const plan = buildToolPlan({ + descriptors: [ + descriptor("plugin_tool", { + owner: { kind: "plugin", pluginId: "demo" }, + executor: { kind: "plugin", pluginId: "demo", toolName: "plugin_tool" }, + }), + ], + }); + + expect(formatToolExecutorRef(plan.visible[0].executor)).toBe("plugin:demo:plugin_tool"); + expect(toToolProtocolDescriptors(plan.visible)).toEqual([ + { + name: "plugin_tool", + description: "plugin_tool description", + inputSchema: { type: "object" }, + }, + ]); + }); +});