diff --git a/src/agents/pi-tools.sandbox-mounted-paths.workspace-only.test.ts b/src/agents/pi-tools.sandbox-mounted-paths.workspace-only.test.ts index b76a4c8cd5f..e83013e8760 100644 --- a/src/agents/pi-tools.sandbox-mounted-paths.workspace-only.test.ts +++ b/src/agents/pi-tools.sandbox-mounted-paths.workspace-only.test.ts @@ -2,7 +2,13 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { createOpenClawCodingTools } from "./pi-tools.js"; +import { createApplyPatchTool } from "./apply-patch.js"; +import { + createSandboxedEditTool, + createSandboxedReadTool, + createSandboxedWriteTool, + wrapToolWorkspaceRootGuardWithOptions, +} from "./pi-tools.read.js"; import { expectReadWriteEditTools, expectReadWriteTools, @@ -19,36 +25,60 @@ vi.mock("../infra/shell-env.js", async () => { type ToolWithExecute = { execute: (toolCallId: string, args: unknown, signal?: AbortSignal) => Promise; }; -type CodingToolsInput = NonNullable[0]>; +type UnsafeMountedSandboxHarness = Parameters[0] extends ( + harness: infer THarness, +) => unknown + ? THarness + : never; +type UnsafeMountedSandbox = UnsafeMountedSandboxHarness["sandbox"]; const APPLY_PATCH_PAYLOAD = `*** Begin Patch *** Add File: /agent/pwned.txt +owned-by-apply-patch *** End Patch`; -function resolveApplyPatchTool( - params: Pick & { config: OpenClawConfig }, -): ToolWithExecute { - const tools = createOpenClawCodingTools({ - sandbox: params.sandbox, - workspaceDir: params.workspaceDir, - config: params.config, - modelProvider: "openai", - modelId: "gpt-5.4", - }); - const applyPatchTool = tools.find((t) => t.name === "apply_patch") as ToolWithExecute | undefined; - if (!applyPatchTool) { - throw new Error("apply_patch tool missing"); +function resolveApplyPatchTool(params: { + sandbox: UnsafeMountedSandbox; + config: OpenClawConfig; +}): ToolWithExecute { + return createApplyPatchTool({ + cwd: params.sandbox.workspaceDir, + sandbox: { root: params.sandbox.workspaceDir, bridge: params.sandbox.fsBridge! }, + workspaceOnly: params.config.tools?.exec?.applyPatch?.workspaceOnly !== false, + }) as ToolWithExecute; +} + +function createSandboxFsTools(params: { sandbox: UnsafeMountedSandbox; workspaceOnly?: boolean }) { + const tools = [ + createSandboxedReadTool({ + root: params.sandbox.workspaceDir, + bridge: params.sandbox.fsBridge!, + }), + createSandboxedWriteTool({ + root: params.sandbox.workspaceDir, + bridge: params.sandbox.fsBridge!, + }), + createSandboxedEditTool({ + root: params.sandbox.workspaceDir, + bridge: params.sandbox.fsBridge!, + }), + ]; + if (!params.workspaceOnly) { + return tools; } - return applyPatchTool; + return tools.map((tool) => + wrapToolWorkspaceRootGuardWithOptions(tool, params.sandbox.workspaceDir, { + containerWorkdir: params.sandbox.containerWorkdir, + }), + ); } describe("tools.fs.workspaceOnly", () => { it("defaults to allowing sandbox mounts outside the workspace root", async () => { - await withUnsafeMountedSandboxHarness(async ({ sandboxRoot, agentRoot, sandbox }) => { + await withUnsafeMountedSandboxHarness(async ({ agentRoot, sandbox }) => { await fs.writeFile(path.join(agentRoot, "secret.txt"), "shh", "utf8"); - const tools = createOpenClawCodingTools({ sandbox, workspaceDir: sandboxRoot }); + const tools = createSandboxFsTools({ sandbox }); const { readTool, writeTool } = expectReadWriteTools(tools); const readResult = await readTool?.execute("t1", { path: "/agent/secret.txt" }); @@ -60,11 +90,10 @@ describe("tools.fs.workspaceOnly", () => { }); it("rejects sandbox mounts outside the workspace root when enabled", async () => { - await withUnsafeMountedSandboxHarness(async ({ sandboxRoot, agentRoot, sandbox }) => { + await withUnsafeMountedSandboxHarness(async ({ agentRoot, sandbox }) => { await fs.writeFile(path.join(agentRoot, "secret.txt"), "shh", "utf8"); - const cfg = { tools: { fs: { workspaceOnly: true } } } as unknown as OpenClawConfig; - const tools = createOpenClawCodingTools({ sandbox, workspaceDir: sandboxRoot, config: cfg }); + const tools = createSandboxFsTools({ sandbox, workspaceOnly: true }); const { readTool, writeTool, editTool } = expectReadWriteEditTools(tools); await expect(readTool?.execute("t1", { path: "/agent/secret.txt" })).rejects.toThrow( @@ -86,10 +115,9 @@ describe("tools.fs.workspaceOnly", () => { }); it("enforces apply_patch workspace-only in sandbox mounts by default", async () => { - await withUnsafeMountedSandboxHarness(async ({ sandboxRoot, agentRoot, sandbox }) => { + await withUnsafeMountedSandboxHarness(async ({ agentRoot, sandbox }) => { const applyPatchTool = resolveApplyPatchTool({ sandbox, - workspaceDir: sandboxRoot, config: { tools: { allow: ["read", "write", "exec"], @@ -108,10 +136,9 @@ describe("tools.fs.workspaceOnly", () => { }); it("allows apply_patch outside workspace root when explicitly disabled", async () => { - await withUnsafeMountedSandboxHarness(async ({ sandboxRoot, agentRoot, sandbox }) => { + await withUnsafeMountedSandboxHarness(async ({ agentRoot, sandbox }) => { const applyPatchTool = resolveApplyPatchTool({ sandbox, - workspaceDir: sandboxRoot, config: { tools: { allow: ["read", "write", "exec"], diff --git a/src/agents/pi-tools.workspace-only-false.test.ts b/src/agents/pi-tools.workspace-only-false.test.ts index dcb64470646..c94284f5202 100644 --- a/src/agents/pi-tools.workspace-only-false.test.ts +++ b/src/agents/pi-tools.workspace-only-false.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { createReadTool } from "@mariozechner/pi-coding-agent"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@mariozechner/pi-ai", async () => { @@ -22,7 +23,14 @@ vi.mock("@mariozechner/pi-ai/oauth", async () => { }; }); -import { createOpenClawCodingTools } from "./pi-tools.js"; +import { + createHostWorkspaceEditTool, + createHostWorkspaceWriteTool, + createOpenClawReadTool, + wrapToolMemoryFlushAppendOnlyWrite, + wrapToolWorkspaceRootGuard, +} from "./pi-tools.read.js"; +import type { AnyAgentTool } from "./tools/common.js"; describe("FS tools with workspaceOnly=false", () => { let tmpDir: string; @@ -37,20 +45,15 @@ describe("FS tools with workspaceOnly=false", () => { return content.text?.toLowerCase().includes("error") ?? false; }); - const toolsFor = (workspaceOnly: boolean | undefined) => - createOpenClawCodingTools({ - workspaceDir, - config: - workspaceOnly === undefined - ? {} - : { - tools: { - fs: { - workspaceOnly, - }, - }, - }, - }); + const toolsFor = (workspaceOnly: boolean | undefined): AnyAgentTool[] => { + const read = createOpenClawReadTool(createReadTool(workspaceDir) as unknown as AnyAgentTool); + const write = createHostWorkspaceWriteTool(workspaceDir, { workspaceOnly }); + const edit = createHostWorkspaceEditTool(workspaceDir, { workspaceOnly }); + const tools = [read, write, edit]; + return workspaceOnly + ? tools.map((tool) => wrapToolWorkspaceRootGuard(tool, workspaceDir)) + : tools; + }; const runFsTool = async ( toolName: "write" | "edit" | "read", @@ -205,20 +208,13 @@ describe("FS tools with workspaceOnly=false", () => { await fs.mkdir(path.dirname(allowedAbsolutePath), { recursive: true }); await fs.writeFile(allowedAbsolutePath, "seed"); - const tools = createOpenClawCodingTools({ - workspaceDir, - trigger: "memory", - memoryFlushWritePath: allowedRelativePath, - config: { - tools: { - exec: { - applyPatch: {}, - }, - }, - }, - modelProvider: "openai", - modelId: "gpt-5", - }); + const tools = [ + createOpenClawReadTool(createReadTool(workspaceDir) as unknown as AnyAgentTool), + wrapToolMemoryFlushAppendOnlyWrite(createHostWorkspaceWriteTool(workspaceDir), { + root: workspaceDir, + relativePath: allowedRelativePath, + }), + ]; const writeTool = tools.find((tool) => tool.name === "write"); expect(writeTool).toBeDefined();