test: keep pi fs workspace tests on fs tool factories

This commit is contained in:
Peter Steinberger
2026-04-08 17:06:02 +01:00
parent 4a51a1031d
commit 8f67f156ee
2 changed files with 77 additions and 54 deletions

View File

@@ -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<unknown>;
};
type CodingToolsInput = NonNullable<Parameters<typeof createOpenClawCodingTools>[0]>;
type UnsafeMountedSandboxHarness = Parameters<typeof withUnsafeMountedSandboxHarness>[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<CodingToolsInput, "sandbox" | "workspaceDir"> & { 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"],

View File

@@ -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();