From 71fdc829e6be90f53e724d452e85bd96cb67e5e6 Mon Sep 17 00:00:00 2001 From: hsrvc <129702169+hsrvc@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:53:44 +0800 Subject: [PATCH 1/2] Agents: add Claude Code parameter aliasing for read/write/edit tools --- src/agents/pi-tools.test.ts | 83 ++++++++++++++++++++++ src/agents/pi-tools.ts | 135 ++++++++++++++++++++++++++++++++---- 2 files changed, 204 insertions(+), 14 deletions(-) diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts index 089b10926..76ff871e9 100644 --- a/src/agents/pi-tools.test.ts +++ b/src/agents/pi-tools.test.ts @@ -586,6 +586,89 @@ describe("createClawdbotCodingTools", () => { } }); + it("accepts Claude Code parameter aliases for read/write/edit", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-alias-")); + try { + const tools = createClawdbotCodingTools({ workspaceDir: tmpDir }); + const readTool = tools.find((tool) => tool.name === "read"); + const writeTool = tools.find((tool) => tool.name === "write"); + const editTool = tools.find((tool) => tool.name === "edit"); + expect(readTool).toBeDefined(); + expect(writeTool).toBeDefined(); + expect(editTool).toBeDefined(); + + const filePath = "alias-test.txt"; + await writeTool?.execute("tool-alias-1", { + file_path: filePath, + content: "hello world", + }); + + await editTool?.execute("tool-alias-2", { + file_path: filePath, + old_string: "world", + new_string: "universe", + }); + + const result = await readTool?.execute("tool-alias-3", { + file_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("applies sandbox path guards to file_path alias", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sbx-")); + const outsidePath = path.join(os.tmpdir(), "clawdbot-outside.txt"); + await fs.writeFile(outsidePath, "outside", "utf8"); + try { + const sandbox = { + enabled: true, + sessionKey: "sandbox:test", + workspaceDir: tmpDir, + agentWorkspaceDir: path.join(os.tmpdir(), "clawdbot-workspace"), + workspaceAccess: "ro", + containerName: "clawdbot-sbx-test", + containerWorkdir: "/workspace", + docker: { + image: "clawdbot-sandbox:bookworm-slim", + containerPrefix: "clawdbot-sbx-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: [], + network: "none", + user: "1000:1000", + capDrop: ["ALL"], + env: { LANG: "C.UTF-8" }, + }, + tools: { + allow: ["read"], + deny: [], + }, + browserAllowHostControl: false, + }; + + const tools = createClawdbotCodingTools({ sandbox }); + const readTool = tools.find((tool) => tool.name === "read"); + expect(readTool).toBeDefined(); + + await expect( + readTool?.execute("tool-sbx-1", { file_path: outsidePath }), + ).rejects.toThrow(); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(outsidePath, { force: true }); + } + }); + it("falls back to process.cwd() when workspaceDir not provided", () => { const prevCwd = process.cwd(); const tools = createClawdbotCodingTools(); diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index ed2549c32..34c3926a2 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -414,15 +414,17 @@ function wrapSandboxPathGuard(tool: AnyAgentTool, root: string): AnyAgentTool { return { ...tool, execute: async (toolCallId, args, signal, onUpdate) => { + const normalized = normalizeToolParams(args); const record = - args && typeof args === "object" + normalized ?? + (args && typeof args === "object" ? (args as Record) - : undefined; + : undefined); const filePath = record?.path; if (typeof filePath === "string" && filePath.trim()) { await assertSandboxPath({ filePath, cwd: root, root }); } - return tool.execute(toolCallId, args, signal, onUpdate); + return tool.execute(toolCallId, normalized ?? args, signal, onUpdate); }, }; } @@ -434,31 +436,134 @@ function createSandboxedReadTool(root: string) { function createSandboxedWriteTool(root: string) { const base = createWriteTool(root); - return wrapSandboxPathGuard(base as unknown as AnyAgentTool, root); + return wrapSandboxPathGuard(wrapToolParamNormalization(base), root); } function createSandboxedEditTool(root: string) { const base = createEditTool(root); - return wrapSandboxPathGuard(base as unknown as AnyAgentTool, root); + return wrapSandboxPathGuard(wrapToolParamNormalization(base), root); +} +function createWhatsAppLoginTool(): AnyAgentTool { + return { + label: "WhatsApp Login", + name: "whatsapp_login", + description: + "Generate a WhatsApp QR code for linking, or wait for the scan to complete.", + // NOTE: Using Type.Unsafe for action enum instead of Type.Union([Type.Literal(...)]) + // because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema. + parameters: Type.Object({ + action: Type.Unsafe<"start" | "wait">({ + type: "string", + enum: ["start", "wait"], + }), + timeoutMs: Type.Optional(Type.Number()), + force: Type.Optional(Type.Boolean()), + }), + execute: async (_toolCallId, args) => { + const action = (args as { action?: string })?.action ?? "start"; + if (action === "wait") { + const result = await waitForWebLogin({ + timeoutMs: + typeof (args as { timeoutMs?: unknown }).timeoutMs === "number" + ? (args as { timeoutMs?: number }).timeoutMs + : undefined, + }); + return { + content: [{ type: "text", text: result.message }], + details: { connected: result.connected }, + }; + } + + const result = await startWebLoginWithQr({ + timeoutMs: + typeof (args as { timeoutMs?: unknown }).timeoutMs === "number" + ? (args as { timeoutMs?: number }).timeoutMs + : undefined, + force: + typeof (args as { force?: unknown }).force === "boolean" + ? (args as { force?: boolean }).force + : false, + }); + + if (!result.qrDataUrl) { + return { + content: [ + { + type: "text", + text: result.message, + }, + ], + details: { qr: false }, + }; + } + + const text = [ + result.message, + "", + "Open WhatsApp → Linked Devices and scan:", + "", + `![whatsapp-qr](${result.qrDataUrl})`, + ].join("\n"); + return { + content: [{ type: "text", text }], + details: { qr: true }, + }; + }, + }; } +// Normalize tool parameters from Claude Code conventions to pi-coding-agent conventions. +// Claude Code uses file_path/old_string/new_string while pi-coding-agent uses path/oldText/newText. +// This prevents models trained on Claude Code from getting stuck in tool-call loops. +function normalizeToolParams( + params: unknown, +): Record | undefined { + if (!params || typeof params !== "object") return undefined; + const record = params as Record; + const normalized = { ...record }; + // file_path → path (read, write, edit) + if ("file_path" in normalized && !("path" in normalized)) { + normalized.path = normalized.file_path; + delete normalized.file_path; + } + // old_string → oldText (edit) + if ("old_string" in normalized && !("oldText" in normalized)) { + normalized.oldText = normalized.old_string; + delete normalized.old_string; + } + // new_string → newText (edit) + if ("new_string" in normalized && !("newText" in normalized)) { + normalized.newText = normalized.new_string; + delete normalized.new_string; + } + return normalized; +} + +// Generic wrapper to normalize parameters for any tool +function wrapToolParamNormalization(tool: AnyAgentTool): AnyAgentTool { + return { + ...tool, + execute: async (toolCallId, params, signal, onUpdate) => { + const normalized = normalizeToolParams(params); + return tool.execute(toolCallId, normalized ?? params, signal, onUpdate); + }, + }; +} function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool { return { ...base, execute: async (toolCallId, params, signal) => { + const normalized = normalizeToolParams(params); const result = (await base.execute( toolCallId, - params, + normalized ?? params, signal, )) as AgentToolResult; - const record = - params && typeof params === "object" - ? (params as Record) - : undefined; + const record = normalized ?? (params as Record); const filePath = typeof record?.path === "string" ? String(record.path) : ""; - const normalized = await normalizeReadImageResult(result, filePath); - return sanitizeToolResultImages(normalized, `read:${filePath}`); + const normalizedResult = await normalizeReadImageResult(result, filePath); + return sanitizeToolResultImages(normalizedResult, `read:${filePath}`); }, }; } @@ -581,11 +686,13 @@ export function createClawdbotCodingTools(options?: { if (tool.name === "bash" || tool.name === execToolName) return []; if (tool.name === "write") { if (sandboxRoot) return []; - return [createWriteTool(workspaceRoot)]; + // Wrap with param normalization for Claude Code compatibility + return [wrapToolParamNormalization(createWriteTool(workspaceRoot))]; } if (tool.name === "edit") { if (sandboxRoot) return []; - return [createEditTool(workspaceRoot)]; + // Wrap with param normalization for Claude Code compatibility + return [wrapToolParamNormalization(createEditTool(workspaceRoot))]; } return [tool as AnyAgentTool]; }); From 6711eaf8a5dbeb11adfdb31f1f20fb859443e9f7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 08:48:25 +0000 Subject: [PATCH 2/2] fix: finalize tool param aliasing (#768) (thanks @hsrvc) --- CHANGELOG.md | 1 + src/agents/pi-tools.ts | 69 ------------------------------------------ 2 files changed, 1 insertion(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecd69a823..eca789827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,7 @@ - Auto-reply: allow sender fallback for command authorization when `SenderId` is empty (WhatsApp self-chat). (#755) — thanks @juanpablodlc. - Heartbeat: refresh prompt text for updated defaults. - Agents/Tools: use PowerShell on Windows to capture system utility output. (#748) — thanks @myfunc. +- Agents/Tools: normalize Claude Code-style read/write/edit params (file_path/old_string/new_string) and keep sandbox guards in place. (#768) — thanks @hsrvc. - Docker: tolerate unset optional env vars in docker-setup.sh under strict mode. (#725) — thanks @petradonka. - CLI/Update: preserve base environment when passing overrides to update subprocesses. (#713) — thanks @danielz1z. - Agents: treat message tool errors as failures so fallback replies still send; require `to` + `message` for `action=send`. (#717) — thanks @theglove44. diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 34c3926a2..690566e6f 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -443,75 +443,6 @@ function createSandboxedEditTool(root: string) { const base = createEditTool(root); return wrapSandboxPathGuard(wrapToolParamNormalization(base), root); } -function createWhatsAppLoginTool(): AnyAgentTool { - return { - label: "WhatsApp Login", - name: "whatsapp_login", - description: - "Generate a WhatsApp QR code for linking, or wait for the scan to complete.", - // NOTE: Using Type.Unsafe for action enum instead of Type.Union([Type.Literal(...)]) - // because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema. - parameters: Type.Object({ - action: Type.Unsafe<"start" | "wait">({ - type: "string", - enum: ["start", "wait"], - }), - timeoutMs: Type.Optional(Type.Number()), - force: Type.Optional(Type.Boolean()), - }), - execute: async (_toolCallId, args) => { - const action = (args as { action?: string })?.action ?? "start"; - if (action === "wait") { - const result = await waitForWebLogin({ - timeoutMs: - typeof (args as { timeoutMs?: unknown }).timeoutMs === "number" - ? (args as { timeoutMs?: number }).timeoutMs - : undefined, - }); - return { - content: [{ type: "text", text: result.message }], - details: { connected: result.connected }, - }; - } - - const result = await startWebLoginWithQr({ - timeoutMs: - typeof (args as { timeoutMs?: unknown }).timeoutMs === "number" - ? (args as { timeoutMs?: number }).timeoutMs - : undefined, - force: - typeof (args as { force?: unknown }).force === "boolean" - ? (args as { force?: boolean }).force - : false, - }); - - if (!result.qrDataUrl) { - return { - content: [ - { - type: "text", - text: result.message, - }, - ], - details: { qr: false }, - }; - } - - const text = [ - result.message, - "", - "Open WhatsApp → Linked Devices and scan:", - "", - `![whatsapp-qr](${result.qrDataUrl})`, - ].join("\n"); - return { - content: [{ type: "text", text }], - details: { qr: true }, - }; - }, - }; -} - // Normalize tool parameters from Claude Code conventions to pi-coding-agent conventions. // Claude Code uses file_path/old_string/new_string while pi-coding-agent uses path/oldText/newText. // This prevents models trained on Claude Code from getting stuck in tool-call loops.