diff --git a/CHANGELOG.md b/CHANGELOG.md index 711fdd12e..9ff1f21e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - Models/Auth: allow MiniMax API configs without `models.providers.minimax.apiKey` (auth profiles / `MINIMAX_API_KEY`). (#656) — thanks @mneves75. - Agents: avoid duplicate replies when the message tool sends. (#659) — thanks @mickahouan. - Agents/Tools: resolve workspace-relative Read/Write/Edit paths; align bash default cwd. (#642) — thanks @mukhtharcm. +- Tests/Agents: add regression coverage for workspace tool path resolution and bash cwd defaults. - iOS/Android: enable stricter concurrency/lint checks; fix Swift 6 strict concurrency issues + Android lint errors (ExifInterface, obsolete SDK check). (#662) — thanks @KristijanJovanovski. - iOS/macOS: share `AsyncTimeout`, require explicit `bridgeStableID` on connect, and harden tool display defaults (avoids missing-resource label fallbacks). - Docs: showcase entries for ParentPay, R2 Upload, iOS TestFlight, and Oura Health. (#650) — thanks @henrino3. diff --git a/src/agents/pi-tools.workspace-paths.test.ts b/src/agents/pi-tools.workspace-paths.test.ts new file mode 100644 index 000000000..98b37d484 --- /dev/null +++ b/src/agents/pi-tools.workspace-paths.test.ts @@ -0,0 +1,229 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; +import { createClawdbotCodingTools } from "./pi-tools.js"; + +const normalizeText = (value?: string) => + (value ?? "").replace(/\r\n/g, "\n").trim(); + +async function withTempDir(prefix: string, fn: (dir: string) => Promise) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + try { + return await fn(dir); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +function getTextContent(result?: { + content?: Array<{ type: string; text?: string }>; +}) { + const textBlock = result?.content?.find((block) => block.type === "text"); + return textBlock?.text ?? ""; +} + +describe("workspace path resolution", () => { + it("reads relative paths against workspaceDir even after cwd changes", async () => { + await withTempDir("clawdbot-ws-", async (workspaceDir) => { + await withTempDir("clawdbot-cwd-", async (otherDir) => { + const prevCwd = process.cwd(); + const testFile = "read.txt"; + const contents = "workspace read ok"; + await fs.writeFile(path.join(workspaceDir, testFile), contents, "utf8"); + + process.chdir(otherDir); + try { + const tools = createClawdbotCodingTools({ workspaceDir }); + const readTool = tools.find((tool) => tool.name === "read"); + expect(readTool).toBeDefined(); + + const result = await readTool?.execute("ws-read", { path: testFile }); + expect(getTextContent(result)).toContain(contents); + } finally { + process.chdir(prevCwd); + } + }); + }); + }); + + it("writes relative paths against workspaceDir even after cwd changes", async () => { + await withTempDir("clawdbot-ws-", async (workspaceDir) => { + await withTempDir("clawdbot-cwd-", async (otherDir) => { + const prevCwd = process.cwd(); + const testFile = "write.txt"; + const contents = "workspace write ok"; + + process.chdir(otherDir); + try { + const tools = createClawdbotCodingTools({ workspaceDir }); + const writeTool = tools.find((tool) => tool.name === "write"); + expect(writeTool).toBeDefined(); + + await writeTool?.execute("ws-write", { + path: testFile, + content: contents, + }); + + const written = await fs.readFile( + path.join(workspaceDir, testFile), + "utf8", + ); + expect(written).toBe(contents); + } finally { + process.chdir(prevCwd); + } + }); + }); + }); + + it("edits relative paths against workspaceDir even after cwd changes", async () => { + await withTempDir("clawdbot-ws-", async (workspaceDir) => { + await withTempDir("clawdbot-cwd-", async (otherDir) => { + const prevCwd = process.cwd(); + const testFile = "edit.txt"; + await fs.writeFile( + path.join(workspaceDir, testFile), + "hello world", + "utf8", + ); + + process.chdir(otherDir); + try { + const tools = createClawdbotCodingTools({ workspaceDir }); + const editTool = tools.find((tool) => tool.name === "edit"); + expect(editTool).toBeDefined(); + + await editTool?.execute("ws-edit", { + path: testFile, + oldText: "world", + newText: "clawdbot", + }); + + const updated = await fs.readFile( + path.join(workspaceDir, testFile), + "utf8", + ); + expect(updated).toBe("hello clawdbot"); + } finally { + process.chdir(prevCwd); + } + }); + }); + }); + + it("defaults bash cwd to workspaceDir when workdir is omitted", async () => { + await withTempDir("clawdbot-ws-", async (workspaceDir) => { + const tools = createClawdbotCodingTools({ workspaceDir }); + const bashTool = tools.find((tool) => tool.name === "bash"); + expect(bashTool).toBeDefined(); + + const result = await bashTool?.execute("ws-bash", { + command: 'node -e "console.log(process.cwd())"', + }); + const output = normalizeText(getTextContent(result)); + const [resolvedOutput, resolvedWorkspace] = await Promise.all([ + fs.realpath(output), + fs.realpath(workspaceDir), + ]); + expect(resolvedOutput).toBe(resolvedWorkspace); + }); + }); + + it("lets bash workdir override the workspace default", async () => { + await withTempDir("clawdbot-ws-", async (workspaceDir) => { + await withTempDir("clawdbot-override-", async (overrideDir) => { + const tools = createClawdbotCodingTools({ workspaceDir }); + const bashTool = tools.find((tool) => tool.name === "bash"); + expect(bashTool).toBeDefined(); + + const result = await bashTool?.execute("ws-bash-override", { + command: 'node -e "console.log(process.cwd())"', + workdir: overrideDir, + }); + const output = normalizeText(getTextContent(result)); + const [resolvedOutput, resolvedOverride] = await Promise.all([ + fs.realpath(output), + fs.realpath(overrideDir), + ]); + expect(resolvedOutput).toBe(resolvedOverride); + }); + }); + }); +}); + +describe("sandboxed workspace paths", () => { + it("uses sandbox workspace for relative read/write/edit", async () => { + await withTempDir("clawdbot-sandbox-", async (sandboxDir) => { + await withTempDir("clawdbot-workspace-", async (workspaceDir) => { + const sandbox = { + enabled: true, + sessionKey: "sandbox:test", + workspaceDir: sandboxDir, + agentWorkspaceDir: workspaceDir, + workspaceAccess: "rw", + 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: [], deny: [] }, + }; + + const testFile = "sandbox.txt"; + await fs.writeFile( + path.join(sandboxDir, testFile), + "sandbox read", + "utf8", + ); + await fs.writeFile( + path.join(workspaceDir, testFile), + "workspace read", + "utf8", + ); + + const tools = createClawdbotCodingTools({ workspaceDir, sandbox }); + 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 result = await readTool?.execute("sbx-read", { path: testFile }); + expect(getTextContent(result)).toContain("sandbox read"); + + await writeTool?.execute("sbx-write", { + path: "new.txt", + content: "sandbox write", + }); + const written = await fs.readFile( + path.join(sandboxDir, "new.txt"), + "utf8", + ); + expect(written).toBe("sandbox write"); + + await editTool?.execute("sbx-edit", { + path: "new.txt", + oldText: "write", + newText: "edit", + }); + const edited = await fs.readFile( + path.join(sandboxDir, "new.txt"), + "utf8", + ); + expect(edited).toBe("sandbox edit"); + }); + }); + }); +});