diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-b.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-b.test.ts deleted file mode 100644 index 6fe376aff77..00000000000 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-b.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.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 "./pi-tools.js"; - -describe("createOpenClawCodingTools", () => { - const testConfig: OpenClawConfig = {}; - - it("preserves action enums in normalized schemas", () => { - const defaultTools = createOpenClawCodingTools({ config: testConfig, senderIsOwner: true }); - const toolNames = ["canvas", "nodes", "cron", "gateway", "message"]; - const missingNames = toolNames.filter( - (name) => !defaultTools.some((candidate) => candidate.name === name), - ); - expect(missingNames).toEqual([]); - - const 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); - } - } - }; - - 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 - : // Most tools expose multiple actions; keep this signal so schemas stay useful to models. - 2; - expect(values.size).toBeGreaterThanOrEqual(min); - } - }); - it("enforces apply_patch availability and canonical names across model/provider constraints", () => { - const defaultTools = createOpenClawCodingTools({ config: testConfig, senderIsOwner: true }); - expect(defaultTools.some((tool) => tool.name === "exec")).toBe(true); - expect(defaultTools.some((tool) => tool.name === "process")).toBe(true); - expect(defaultTools.some((tool) => tool.name === "apply_patch")).toBe(false); - - const openAiTools = createOpenClawCodingTools({ - config: testConfig, - modelProvider: "openai", - modelId: "gpt-5.4", - }); - expect(openAiTools.some((tool) => tool.name === "apply_patch")).toBe(true); - - const codexTools = createOpenClawCodingTools({ - config: testConfig, - modelProvider: "openai-codex", - modelId: "gpt-5.4", - }); - expect(codexTools.some((tool) => tool.name === "apply_patch")).toBe(true); - - const disabledConfig: OpenClawConfig = { - tools: { - exec: { - applyPatch: { enabled: false }, - }, - }, - }; - const disabledOpenAiTools = createOpenClawCodingTools({ - config: disabledConfig, - modelProvider: "openai", - modelId: "gpt-5.4", - }); - expect(disabledOpenAiTools.some((tool) => tool.name === "apply_patch")).toBe(false); - - const anthropicTools = createOpenClawCodingTools({ - config: disabledConfig, - modelProvider: "anthropic", - modelId: "claude-opus-4-6", - }); - expect(anthropicTools.some((tool) => tool.name === "apply_patch")).toBe(false); - - const allowModelsConfig: OpenClawConfig = { - tools: { - exec: { - applyPatch: { allowModels: ["gpt-5.4"] }, - }, - }, - }; - const allowed = createOpenClawCodingTools({ - config: allowModelsConfig, - modelProvider: "openai", - modelId: "gpt-5.4", - }); - expect(allowed.some((tool) => tool.name === "apply_patch")).toBe(true); - - const denied = createOpenClawCodingTools({ - config: allowModelsConfig, - modelProvider: "openai", - modelId: "gpt-5.4-mini", - }); - expect(denied.some((tool) => tool.name === "apply_patch")).toBe(false); - - 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).toEqual([]); - }); -}); diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-c.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-c.test.ts deleted file mode 100644 index d864da8c9cf..00000000000 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-c.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { - applyXaiModelCompat, - findUnsupportedSchemaKeywords, - GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, - XAI_UNSUPPORTED_SCHEMA_KEYWORDS, -} from "../plugin-sdk/provider-tools.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 "./pi-tools.js"; - -describe("createOpenClawCodingTools", () => { - 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-")); - const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json"); - const storePath = storeTemplate.replaceAll("{agentId}", "main"); - await fs.writeFile( - storePath, - JSON.stringify( - { - "agent:main:subagent:flat": { - sessionId: "session-flat-depth-2", - updatedAt: Date.now(), - spawnDepth: 2, - }, - }, - null, - 2, - ), - "utf-8", - ); - - const tools = createOpenClawCodingTools({ - sessionKey: "agent:main:subagent:flat", - config: { - session: { - store: storeTemplate, - }, - agents: { - defaults: { - subagents: { - maxSpawnDepth: 2, - }, - }, - }, - }, - }); - 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); - }); - - 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("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", - senderIsOwner: true, - }); - for (const tool of googleTools) { - const violations = findUnsupportedSchemaKeywords( - tool.parameters, - `${tool.name}.parameters`, - GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, - ); - expect(violations).toEqual([]); - } - }); - - it("applies xai model compat for direct Grok tool cleanup", () => { - const xaiTools = createOpenClawCodingTools({ - modelProvider: "xai", - modelCompat: applyXaiModelCompat({ compat: {} }).compat, - senderIsOwner: true, - }); - - expect(xaiTools.some((tool) => tool.name === "web_search")).toBe(false); - 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); - }), - ).toEqual([]); - } - }); -}); diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts deleted file mode 100644 index 09e3a73acd4..00000000000 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import "./test-helpers/fast-bash-tools.js"; -import "./test-helpers/fast-coding-tools.js"; -import "./test-helpers/fast-openclaw-tools.js"; -import { createOpenClawCodingTools } from "./pi-tools.js"; -import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js"; -import { createPiToolsSandboxContext } from "./test-helpers/pi-tools-sandbox-context.js"; - -const tinyPngBuffer = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2f7z8AAAAASUVORK5CYII=", - "base64", -); - -describe("createOpenClawCodingTools", () => { - it("returns image-aware read metadata for images and text-only blocks for text files", async () => { - const defaultTools = createOpenClawCodingTools(); - const readTool = defaultTools.find((tool) => tool.name === "read"); - expect(readTool).toBeDefined(); - - 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 readTool?.execute("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 readTool?.execute("tool-2", { - path: textPath, - }); - - expect(textResult?.content?.some((block) => block.type === "image")).toBe(false); - const textBlocks = textResult?.content?.filter((block) => block.type === "text") as - | Array<{ text?: string }> - | undefined; - expect(textBlocks?.length ?? 0).toBeGreaterThan(0); - const combinedText = textBlocks?.map((block) => block.text ?? "").join("\n"); - expect(combinedText).toContain(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 = createPiToolsSandboxContext({ - 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(tools.some((tool) => tool.name === "exec")).toBe(true); - expect(tools.some((tool) => tool.name === "read")).toBe(false); - expect(tools.some((tool) => tool.name === "browser")).toBe(false); - }); - it("hard-disables write/edit when sandbox workspaceAccess is ro", () => { - const sandboxDir = path.join(os.tmpdir(), "openclaw-sandbox"); - const sandbox = createPiToolsSandboxContext({ - 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(tools.some((tool) => tool.name === "read")).toBe(true); - expect(tools.some((tool) => tool.name === "write")).toBe(false); - expect(tools.some((tool) => tool.name === "edit")).toBe(false); - }); -}); diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-e.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-e.test.ts deleted file mode 100644 index bbb13569520..00000000000 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-e.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { AgentTool } from "@mariozechner/pi-agent-core"; -import { Type } from "@sinclair/typebox"; -import { describe, expect, it, vi } from "vitest"; -import { REQUIRED_PARAM_GROUPS, wrapToolParamValidation } from "./pi-tools.params.js"; -import { cleanToolSchemaForGemini } from "./pi-tools.schema.js"; - -describe("createOpenClawCodingTools", () => { - describe("Gemini cleanup and strict param validation", () => { - it("enforces canonical path/content at runtime", async () => { - const execute = vi.fn(async (_id, args) => args); - const tool: AgentTool = { - name: "write", - label: "write", - description: "test", - parameters: Type.Object({ - path: Type.String(), - content: Type.String(), - }), - execute, - }; - - const wrapped = wrapToolParamValidation(tool, REQUIRED_PARAM_GROUPS.write); - - await wrapped.execute("tool-1", { path: "foo.txt", content: "x" }); - expect(execute).toHaveBeenCalledWith( - "tool-1", - { path: "foo.txt", content: "x" }, - undefined, - undefined, - ); - - await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow( - /Missing required parameter/, - ); - await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow( - /Supply correct parameters before retrying\./, - ); - await expect(wrapped.execute("tool-3", { path: " ", content: "x" })).rejects.toThrow( - /Missing required parameter/, - ); - await expect(wrapped.execute("tool-3", { path: " ", content: "x" })).rejects.toThrow( - /Supply correct parameters before retrying\./, - ); - await expect(wrapped.execute("tool-4", {})).rejects.toThrow( - /Missing required parameters: path, content/, - ); - await expect(wrapped.execute("tool-4", {})).rejects.toThrow( - /Supply correct parameters before retrying\./, - ); - }); - }); - - it("inlines local $ref before removing unsupported keywords", () => { - const cleaned = cleanToolSchemaForGemini({ - type: "object", - properties: { - foo: { $ref: "#/$defs/Foo" }, - }, - $defs: { - Foo: { type: "string", enum: ["a", "b"] }, - }, - }) as { - $defs?: unknown; - properties?: Record; - }; - - expect(cleaned.$defs).toBeUndefined(); - expect(cleaned.properties).toBeDefined(); - expect(cleaned.properties?.foo).toMatchObject({ - type: "string", - enum: ["a", "b"], - }); - }); - - it("cleans tuple items schemas", () => { - const cleaned = cleanToolSchemaForGemini({ - type: "object", - properties: { - tuples: { - type: "array", - items: [ - { type: "string", format: "uuid" }, - { type: "number", minimum: 1 }, - ], - }, - }, - }) as { - properties?: Record; - }; - - const tuples = cleaned.properties?.tuples as { items?: unknown } | undefined; - const items = Array.isArray(tuples?.items) ? tuples?.items : []; - const first = items[0] as { format?: unknown } | undefined; - const second = items[1] as { minimum?: unknown } | undefined; - - expect(first?.format).toBeUndefined(); - expect(second?.minimum).toBeUndefined(); - }); - - it("drops null-only union variants without flattening other unions", () => { - const cleaned = cleanToolSchemaForGemini({ - type: "object", - properties: { - parentId: { anyOf: [{ type: "string" }, { type: "null" }] }, - count: { oneOf: [{ type: "string" }, { type: "number" }] }, - }, - }) as { - properties?: Record; - }; - - const parentId = cleaned.properties?.parentId as - | { type?: unknown; anyOf?: unknown; oneOf?: unknown } - | undefined; - const count = cleaned.properties?.count as - | { type?: unknown; anyOf?: unknown; oneOf?: unknown } - | undefined; - - expect(parentId?.type).toBe("string"); - expect(parentId?.anyOf).toBeUndefined(); - expect(count?.oneOf).toBeUndefined(); - }); -}); diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts deleted file mode 100644 index 9e49a2b1e6e..00000000000 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import "./test-helpers/fast-bash-tools.js"; -import "./test-helpers/fast-coding-tools.js"; -import "./test-helpers/fast-openclaw-tools.js"; -import { createOpenClawCodingTools } from "./pi-tools.js"; -import { expectReadWriteEditTools } from "./test-helpers/pi-tools-fs-helpers.js"; - -describe("createOpenClawCodingTools", () => { - 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("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 = tools.find((tool) => tool.name === "write"); - expect(writeTool).toBeDefined(); - - await expect( - writeTool?.execute("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 = tools.find((tool) => tool.name === "edit"); - expect(editTool).toBeDefined(); - - await expect( - editTool?.execute("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 }); - } - }); -}); diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts new file mode 100644 index 00000000000..c9349f95f17 --- /dev/null +++ b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts @@ -0,0 +1,538 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + applyXaiModelCompat, + findUnsupportedSchemaKeywords, + GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, + XAI_UNSUPPORTED_SCHEMA_KEYWORDS, +} from "../plugin-sdk/provider-tools.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 "./pi-tools.js"; +import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js"; +import { expectReadWriteEditTools } from "./test-helpers/pi-tools-fs-helpers.js"; +import { createPiToolsSandboxContext } from "./test-helpers/pi-tools-sandbox-context.js"; + +const tinyPngBuffer = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2f7z8AAAAASUVORK5CYII=", + "base64", +); + +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); + } + } +} + +describe("createOpenClawCodingTools", () => { + const testConfig: OpenClawConfig = {}; + + it("preserves action enums in normalized schemas", () => { + const defaultTools = createOpenClawCodingTools({ config: testConfig, senderIsOwner: true }); + const toolNames = ["canvas", "nodes", "cron", "gateway", "message"]; + const missingNames = toolNames.filter( + (name) => !defaultTools.some((candidate) => candidate.name === name), + ); + expect(missingNames).toEqual([]); + + 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, senderIsOwner: true }); + expect(defaultTools.some((tool) => tool.name === "exec")).toBe(true); + expect(defaultTools.some((tool) => tool.name === "process")).toBe(true); + expect(defaultTools.some((tool) => tool.name === "apply_patch")).toBe(false); + + const openAiTools = createOpenClawCodingTools({ + config: testConfig, + modelProvider: "openai", + modelId: "gpt-5.4", + }); + expect(openAiTools.some((tool) => tool.name === "apply_patch")).toBe(true); + + const codexTools = createOpenClawCodingTools({ + config: testConfig, + modelProvider: "openai-codex", + modelId: "gpt-5.4", + }); + expect(codexTools.some((tool) => tool.name === "apply_patch")).toBe(true); + + const disabledConfig: OpenClawConfig = { + tools: { + exec: { + applyPatch: { enabled: false }, + }, + }, + }; + const disabledOpenAiTools = createOpenClawCodingTools({ + config: disabledConfig, + modelProvider: "openai", + modelId: "gpt-5.4", + }); + expect(disabledOpenAiTools.some((tool) => tool.name === "apply_patch")).toBe(false); + + const anthropicTools = createOpenClawCodingTools({ + config: disabledConfig, + modelProvider: "anthropic", + modelId: "claude-opus-4-6", + }); + expect(anthropicTools.some((tool) => tool.name === "apply_patch")).toBe(false); + + const allowModelsConfig: OpenClawConfig = { + tools: { + exec: { + applyPatch: { allowModels: ["gpt-5.4"] }, + }, + }, + }; + const allowed = createOpenClawCodingTools({ + config: allowModelsConfig, + modelProvider: "openai", + modelId: "gpt-5.4", + }); + expect(allowed.some((tool) => tool.name === "apply_patch")).toBe(true); + + const denied = createOpenClawCodingTools({ + config: allowModelsConfig, + modelProvider: "openai", + modelId: "gpt-5.4-mini", + }); + expect(denied.some((tool) => tool.name === "apply_patch")).toBe(false); + + 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).toEqual([]); + }); + + 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"); + const storePath = storeTemplate.replaceAll("{agentId}", "main"); + await fs.writeFile( + storePath, + JSON.stringify( + { + "agent:main:subagent:flat": { + sessionId: "session-flat-depth-2", + updatedAt: Date.now(), + spawnDepth: 2, + }, + }, + null, + 2, + ), + "utf-8", + ); + + const tools = createOpenClawCodingTools({ + sessionKey: "agent:main:subagent:flat", + config: { + session: { + store: storeTemplate, + }, + agents: { + defaults: { + subagents: { + maxSpawnDepth: 2, + }, + }, + }, + }, + }); + 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); + } 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("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", + senderIsOwner: true, + }); + for (const tool of googleTools) { + const violations = findUnsupportedSchemaKeywords( + tool.parameters, + `${tool.name}.parameters`, + GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, + ); + expect(violations).toEqual([]); + } + }); + + it("applies xai model compat for direct Grok tool cleanup", () => { + const xaiTools = createOpenClawCodingTools({ + modelProvider: "xai", + modelCompat: applyXaiModelCompat({ compat: {} }).compat, + senderIsOwner: true, + }); + + expect(xaiTools.some((tool) => tool.name === "web_search")).toBe(false); + 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); + }), + ).toEqual([]); + } + }); + + it("returns image-aware read metadata for images and text-only blocks for text files", async () => { + const defaultTools = createOpenClawCodingTools(); + const readTool = defaultTools.find((tool) => tool.name === "read"); + expect(readTool).toBeDefined(); + + 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 readTool?.execute("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 readTool?.execute("tool-2", { + path: textPath, + }); + + expect(textResult?.content?.some((block) => block.type === "image")).toBe(false); + const textBlocks = textResult?.content?.filter((block) => block.type === "text") as + | Array<{ text?: string }> + | undefined; + expect(textBlocks?.length ?? 0).toBeGreaterThan(0); + const combinedText = textBlocks?.map((block) => block.text ?? "").join("\n"); + expect(combinedText).toContain(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 = createPiToolsSandboxContext({ + 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(tools.some((tool) => tool.name === "exec")).toBe(true); + expect(tools.some((tool) => tool.name === "read")).toBe(false); + expect(tools.some((tool) => tool.name === "browser")).toBe(false); + }); + + it("hard-disables write/edit when sandbox workspaceAccess is ro", () => { + const sandboxDir = path.join(os.tmpdir(), "openclaw-sandbox"); + const sandbox = createPiToolsSandboxContext({ + 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(tools.some((tool) => tool.name === "read")).toBe(true); + expect(tools.some((tool) => tool.name === "write")).toBe(false); + expect(tools.some((tool) => tool.name === "edit")).toBe(false); + }); + + 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("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 = tools.find((tool) => tool.name === "write"); + expect(writeTool).toBeDefined(); + + await expect( + writeTool?.execute("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 = tools.find((tool) => tool.name === "edit"); + expect(editTool).toBeDefined(); + + await expect( + editTool?.execute("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 }); + } + }); +}); diff --git a/src/agents/pi-tools.params.test.ts b/src/agents/pi-tools.params.test.ts index caa7fdc2a9c..66799bb562d 100644 --- a/src/agents/pi-tools.params.test.ts +++ b/src/agents/pi-tools.params.test.ts @@ -41,6 +41,47 @@ describe("assertRequiredParams", () => { ).rejects.toThrow(/\(received: file_path\)/); }); + it("enforces canonical path/content at runtime", async () => { + const execute = vi.fn(async (_id, args) => args); + const tool = wrapToolParamValidation( + { + name: "write", + label: "write", + description: "test", + parameters: {}, + execute, + }, + REQUIRED_PARAM_GROUPS.write, + ); + + await tool.execute("tool-1", { path: "foo.txt", content: "x" }); + expect(execute).toHaveBeenCalledWith( + "tool-1", + { path: "foo.txt", content: "x" }, + undefined, + undefined, + ); + + await expect(tool.execute("tool-2", { content: "x" })).rejects.toThrow( + /Missing required parameter/, + ); + await expect(tool.execute("tool-2", { content: "x" })).rejects.toThrow( + /Supply correct parameters before retrying\./, + ); + await expect(tool.execute("tool-3", { path: " ", content: "x" })).rejects.toThrow( + /Missing required parameter/, + ); + await expect(tool.execute("tool-3", { path: " ", content: "x" })).rejects.toThrow( + /Supply correct parameters before retrying\./, + ); + await expect(tool.execute("tool-4", {})).rejects.toThrow( + /Missing required parameters: path, content/, + ); + await expect(tool.execute("tool-4", {})).rejects.toThrow( + /Supply correct parameters before retrying\./, + ); + }); + it("excludes null and undefined values from received hint", () => { expect(() => assertRequiredParams( diff --git a/src/agents/pi-tools.schema.test.ts b/src/agents/pi-tools.schema.test.ts index 7069c4ccd5b..eeb84acb692 100644 --- a/src/agents/pi-tools.schema.test.ts +++ b/src/agents/pi-tools.schema.test.ts @@ -1,6 +1,10 @@ import { Type } from "@sinclair/typebox"; import { describe, expect, it, vi } from "vitest"; -import { normalizeToolParameterSchema, normalizeToolParameters } from "./pi-tools.schema.js"; +import { + cleanToolSchemaForGemini, + normalizeToolParameterSchema, + normalizeToolParameters, +} from "./pi-tools.schema.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; describe("normalizeToolParameterSchema", () => { @@ -31,6 +35,76 @@ describe("normalizeToolParameterSchema", () => { required: ["q"], }); }); + + it("inlines local $ref before removing unsupported keywords", () => { + const cleaned = cleanToolSchemaForGemini({ + type: "object", + properties: { + foo: { $ref: "#/$defs/Foo" }, + }, + $defs: { + Foo: { type: "string", enum: ["a", "b"] }, + }, + }) as { + $defs?: unknown; + properties?: Record; + }; + + expect(cleaned.$defs).toBeUndefined(); + expect(cleaned.properties).toBeDefined(); + expect(cleaned.properties?.foo).toMatchObject({ + type: "string", + enum: ["a", "b"], + }); + }); + + it("cleans tuple items schemas", () => { + const cleaned = cleanToolSchemaForGemini({ + type: "object", + properties: { + tuples: { + type: "array", + items: [ + { type: "string", format: "uuid" }, + { type: "number", minimum: 1 }, + ], + }, + }, + }) as { + properties?: Record; + }; + + const tuples = cleaned.properties?.tuples as { items?: unknown } | undefined; + const items = Array.isArray(tuples?.items) ? tuples?.items : []; + const first = items[0] as { format?: unknown } | undefined; + const second = items[1] as { minimum?: unknown } | undefined; + + expect(first?.format).toBeUndefined(); + expect(second?.minimum).toBeUndefined(); + }); + + it("drops null-only union variants without flattening other unions", () => { + const cleaned = cleanToolSchemaForGemini({ + type: "object", + properties: { + parentId: { anyOf: [{ type: "string" }, { type: "null" }] }, + count: { oneOf: [{ type: "string" }, { type: "number" }] }, + }, + }) as { + properties?: Record; + }; + + const parentId = cleaned.properties?.parentId as + | { type?: unknown; anyOf?: unknown; oneOf?: unknown } + | undefined; + const count = cleaned.properties?.count as + | { type?: unknown; anyOf?: unknown; oneOf?: unknown } + | undefined; + + expect(parentId?.type).toBe("string"); + expect(parentId?.anyOf).toBeUndefined(); + expect(count?.oneOf).toBeUndefined(); + }); }); function makeTool(parameters: unknown): AnyAgentTool {