mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:50:43 +00:00
test: dedupe pi tools schema coverage
This commit is contained in:
@@ -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<string>): void => {
|
||||
if (!schema || typeof schema !== "object") {
|
||||
return;
|
||||
}
|
||||
const record = schema as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
};
|
||||
const action = parameters.properties?.action as
|
||||
| { const?: unknown; enum?: unknown[] }
|
||||
| undefined;
|
||||
const values = new Set<string>();
|
||||
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<string, unknown>)
|
||||
: null;
|
||||
return {
|
||||
name: tool.name,
|
||||
type: schema?.type,
|
||||
keys: schema ? Object.keys(schema).toSorted() : null,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.type !== "object");
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, unknown>;
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
538
src/agents/pi-tools.create-openclaw-coding-tools.test.ts
Normal file
538
src/agents/pi-tools.create-openclaw-coding-tools.test.ts
Normal file
@@ -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<string>): void {
|
||||
if (!schema || typeof schema !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = schema as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
};
|
||||
const action = parameters.properties?.action as
|
||||
| { const?: unknown; enum?: unknown[] }
|
||||
| undefined;
|
||||
const values = new Set<string>();
|
||||
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<string, unknown>)
|
||||
: 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, unknown>;
|
||||
};
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user