openclaw/src/agents/pi-tools.workspace-paths.test.ts
2026-02-24 17:23:14 +00:00

196 lines
7.6 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
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 { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js";
import { expectReadWriteEditTools, getTextContent } from "./test-helpers/pi-tools-fs-helpers.js";
import { createPiToolsSandboxContext } from "./test-helpers/pi-tools-sandbox-context.js";
vi.mock("../infra/shell-env.js", async (importOriginal) => {
const mod = await importOriginal<typeof import("../infra/shell-env.js")>();
return { ...mod, getShellPathFromLoginShell: () => null };
});
async function withTempDir<T>(prefix: string, fn: (dir: string) => Promise<T>) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
try {
return await fn(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
describe("workspace path resolution", () => {
it("resolves relative read/write/edit paths against workspaceDir even after cwd changes", async () => {
await withTempDir("openclaw-ws-", async (workspaceDir) => {
await withTempDir("openclaw-cwd-", async (otherDir) => {
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(otherDir);
try {
const tools = createOpenClawCodingTools({ workspaceDir });
const { readTool, writeTool, editTool } = expectReadWriteEditTools(tools);
const readFile = "read.txt";
await fs.writeFile(path.join(workspaceDir, readFile), "workspace read ok", "utf8");
const readResult = await readTool.execute("ws-read", { path: readFile });
expect(getTextContent(readResult)).toContain("workspace read ok");
const writeFile = "write.txt";
await writeTool.execute("ws-write", {
path: writeFile,
content: "workspace write ok",
});
expect(await fs.readFile(path.join(workspaceDir, writeFile), "utf8")).toBe(
"workspace write ok",
);
const editFile = "edit.txt";
await fs.writeFile(path.join(workspaceDir, editFile), "hello world", "utf8");
await editTool.execute("ws-edit", {
path: editFile,
oldText: "world",
newText: "openclaw",
});
expect(await fs.readFile(path.join(workspaceDir, editFile), "utf8")).toBe(
"hello openclaw",
);
} finally {
cwdSpy.mockRestore();
}
});
});
});
it("allows deletion edits with empty newText", async () => {
await withTempDir("openclaw-ws-", async (workspaceDir) => {
await withTempDir("openclaw-cwd-", async (otherDir) => {
const testFile = "delete.txt";
await fs.writeFile(path.join(workspaceDir, testFile), "hello world", "utf8");
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(otherDir);
try {
const tools = createOpenClawCodingTools({ workspaceDir });
const { editTool } = expectReadWriteEditTools(tools);
await editTool.execute("ws-edit-delete", {
path: testFile,
oldText: " world",
newText: "",
});
expect(await fs.readFile(path.join(workspaceDir, testFile), "utf8")).toBe("hello");
} finally {
cwdSpy.mockRestore();
}
});
});
});
it("defaults exec cwd to workspaceDir when workdir is omitted", async () => {
await withTempDir("openclaw-ws-", async (workspaceDir) => {
const tools = createOpenClawCodingTools({
workspaceDir,
exec: { host: "gateway", ask: "off", security: "full" },
});
const execTool = tools.find((tool) => tool.name === "exec");
expect(execTool).toBeDefined();
const result = await execTool?.execute("ws-exec", {
command: "echo ok",
});
const cwd =
result?.details && typeof result.details === "object" && "cwd" in result.details
? (result.details as { cwd?: string }).cwd
: undefined;
expect(cwd).toBeTruthy();
const [resolvedOutput, resolvedWorkspace] = await Promise.all([
fs.realpath(String(cwd)),
fs.realpath(workspaceDir),
]);
expect(resolvedOutput).toBe(resolvedWorkspace);
});
});
it("lets exec workdir override the workspace default", async () => {
await withTempDir("openclaw-ws-", async (workspaceDir) => {
await withTempDir("openclaw-override-", async (overrideDir) => {
const tools = createOpenClawCodingTools({
workspaceDir,
exec: { host: "gateway", ask: "off", security: "full" },
});
const execTool = tools.find((tool) => tool.name === "exec");
expect(execTool).toBeDefined();
const result = await execTool?.execute("ws-exec-override", {
command: "echo ok",
workdir: overrideDir,
});
const cwd =
result?.details && typeof result.details === "object" && "cwd" in result.details
? (result.details as { cwd?: string }).cwd
: undefined;
expect(cwd).toBeTruthy();
const [resolvedOutput, resolvedOverride] = await Promise.all([
fs.realpath(String(cwd)),
fs.realpath(overrideDir),
]);
expect(resolvedOutput).toBe(resolvedOverride);
});
});
});
it("rejects @-prefixed absolute paths outside workspace when workspaceOnly is enabled", async () => {
await withTempDir("openclaw-ws-", async (workspaceDir) => {
const cfg: OpenClawConfig = { tools: { fs: { workspaceOnly: true } } };
const tools = createOpenClawCodingTools({ workspaceDir, config: cfg });
const { readTool } = expectReadWriteEditTools(tools);
const outsideAbsolute = path.resolve(path.parse(workspaceDir).root, "outside-openclaw.txt");
await expect(
readTool.execute("ws-read-at-prefix", { path: `@${outsideAbsolute}` }),
).rejects.toThrow(/Path escapes sandbox root/i);
});
});
});
describe("sandboxed workspace paths", () => {
it("uses sandbox workspace for relative read/write/edit", async () => {
await withTempDir("openclaw-sandbox-", async (sandboxDir) => {
await withTempDir("openclaw-workspace-", async (workspaceDir) => {
const sandbox = createPiToolsSandboxContext({
workspaceDir: sandboxDir,
agentWorkspaceDir: workspaceDir,
workspaceAccess: "rw" as const,
fsBridge: createHostSandboxFsBridge(sandboxDir),
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 = createOpenClawCodingTools({ workspaceDir, sandbox });
const { readTool, writeTool, editTool } = expectReadWriteEditTools(tools);
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");
});
});
});
});