mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-08 15:12:55 +00:00
Merged via squash.
Prepared head SHA: f47b073830
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
490 lines
19 KiB
TypeScript
490 lines
19 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 "./test-helpers/fast-coding-tools.js";
|
|
import "./test-helpers/fast-openclaw-tools.js";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import { createOpenClawCodingTools } from "./agent-tools.js";
|
|
import { createCanonicalFixtureSkill } from "./skills.test-helpers.js";
|
|
import { expectReadWriteEditTools, getTextContent } from "./test-helpers/agent-tools-fs-helpers.js";
|
|
import { createAgentToolsSandboxContext } from "./test-helpers/agent-tools-sandbox-context.js";
|
|
import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js";
|
|
|
|
vi.mock("../infra/shell-env.js", async () => {
|
|
const mod =
|
|
await vi.importActual<typeof import("../infra/shell-env.js")>("../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 });
|
|
}
|
|
}
|
|
|
|
function createExecTool(workspaceDir: string) {
|
|
const tools = createOpenClawCodingTools({
|
|
workspaceDir,
|
|
exec: { host: "gateway", ask: "off", security: "full" },
|
|
});
|
|
const execTool = tools.find((tool) => tool.name === "exec");
|
|
if (!execTool) {
|
|
throw new Error("expected exec tool");
|
|
}
|
|
return execTool;
|
|
}
|
|
|
|
async function expectExecCwdResolvesTo(
|
|
execTool: ReturnType<typeof createExecTool>,
|
|
callId: string,
|
|
params: { command: string; workdir?: string },
|
|
expectedDir: string,
|
|
) {
|
|
const result = await execTool?.execute(callId, params);
|
|
const cwd =
|
|
result?.details && typeof result.details === "object" && "cwd" in result.details
|
|
? (result.details as { cwd?: string }).cwd
|
|
: undefined;
|
|
if (typeof cwd !== "string" || cwd.length === 0) {
|
|
throw new Error("expected exec result cwd");
|
|
}
|
|
const [resolvedOutput, resolvedExpected] = await Promise.all([
|
|
fs.realpath(cwd),
|
|
fs.realpath(expectedDir),
|
|
]);
|
|
expect(resolvedOutput).toBe(resolvedExpected);
|
|
}
|
|
|
|
describe("workspace path resolution", () => {
|
|
it("uses cwd for coding filesystem tools while workspaceDir remains the agent workspace", async () => {
|
|
await withTempDir("openclaw-agent-ws-", async (workspaceDir) => {
|
|
await withTempDir("openclaw-task-cwd-", async (cwd) => {
|
|
const tools = createOpenClawCodingTools({ workspaceDir, cwd });
|
|
const { readTool, writeTool } = expectReadWriteEditTools(tools);
|
|
|
|
await fs.writeFile(path.join(cwd, "task.txt"), "task cwd read ok", "utf8");
|
|
const readResult = await readTool.execute("cwd-read", { path: "task.txt" });
|
|
expect(getTextContent(readResult)).toContain("task cwd read ok");
|
|
|
|
await writeTool.execute("cwd-write", { path: "created.txt", content: "task cwd write ok" });
|
|
expect(await fs.readFile(path.join(cwd, "created.txt"), "utf8")).toBe("task cwd write ok");
|
|
await expect(fs.access(path.join(workspaceDir, "created.txt"))).rejects.toThrow();
|
|
});
|
|
});
|
|
});
|
|
|
|
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,
|
|
edits: [{ 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,
|
|
edits: [{ oldText: " world", newText: "" }],
|
|
});
|
|
|
|
expect(await fs.readFile(path.join(workspaceDir, testFile), "utf8")).toBe("hello");
|
|
} finally {
|
|
cwdSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
it("supports multi-edit edits[] payloads", async () => {
|
|
await withTempDir("openclaw-ws-", async (workspaceDir) => {
|
|
await withTempDir("openclaw-cwd-", async (otherDir) => {
|
|
const testFile = "batch.txt";
|
|
await fs.writeFile(path.join(workspaceDir, testFile), "alpha beta gamma delta", "utf8");
|
|
|
|
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(otherDir);
|
|
try {
|
|
const tools = createOpenClawCodingTools({ workspaceDir });
|
|
const { editTool } = expectReadWriteEditTools(tools);
|
|
|
|
await editTool.execute("ws-edit-batch", {
|
|
path: testFile,
|
|
edits: [
|
|
{ oldText: "alpha", newText: "ALPHA" },
|
|
{ oldText: "delta", newText: "DELTA" },
|
|
],
|
|
});
|
|
|
|
expect(await fs.readFile(path.join(workspaceDir, testFile), "utf8")).toBe(
|
|
"ALPHA beta gamma DELTA",
|
|
);
|
|
} finally {
|
|
cwdSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
it("defaults exec cwd to workspaceDir when workdir is omitted", async () => {
|
|
await withTempDir("openclaw-ws-", async (workspaceDir) => {
|
|
const execTool = createExecTool(workspaceDir);
|
|
await expectExecCwdResolvesTo(execTool, "ws-exec", { command: "echo ok" }, workspaceDir);
|
|
});
|
|
});
|
|
|
|
it("lets exec workdir override the workspace default", async () => {
|
|
await withTempDir("openclaw-ws-", async (workspaceDir) => {
|
|
await withTempDir("openclaw-override-", async (overrideDir) => {
|
|
const execTool = createExecTool(workspaceDir);
|
|
await expectExecCwdResolvesTo(
|
|
execTool,
|
|
"ws-exec-override",
|
|
{ command: "echo ok", workdir: overrideDir },
|
|
overrideDir,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
it("rejects hardlinked file aliases when workspaceOnly is enabled", async () => {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
await withTempDir("openclaw-ws-", async (workspaceDir) => {
|
|
const cfg: OpenClawConfig = { tools: { fs: { workspaceOnly: true } } };
|
|
const tools = createOpenClawCodingTools({ workspaceDir, config: cfg });
|
|
const { readTool, writeTool } = expectReadWriteEditTools(tools);
|
|
const outsidePath = path.join(
|
|
path.dirname(workspaceDir),
|
|
`outside-hardlink-${process.pid}-${Date.now()}.txt`,
|
|
);
|
|
const hardlinkPath = path.join(workspaceDir, "linked.txt");
|
|
await fs.writeFile(outsidePath, "top-secret", "utf8");
|
|
try {
|
|
try {
|
|
await fs.link(outsidePath, hardlinkPath);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
await expect(readTool.execute("ws-read-hardlink", { path: "linked.txt" })).rejects.toThrow(
|
|
/hardlink|sandbox/i,
|
|
);
|
|
await expect(
|
|
writeTool.execute("ws-write-hardlink", {
|
|
path: "linked.txt",
|
|
content: "pwned",
|
|
}),
|
|
).rejects.toThrow(/hardlink|sandbox/i);
|
|
expect(await fs.readFile(outsidePath, "utf8")).toBe("top-secret");
|
|
} finally {
|
|
await fs.rm(hardlinkPath, { force: true });
|
|
await fs.rm(outsidePath, { force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"writes through in-workspace symlink parents when workspaceOnly is enabled",
|
|
async () => {
|
|
await withTempDir("openclaw-ws-symlink-write-", async (workspaceDir) => {
|
|
const realDir = path.join(workspaceDir, "oc_system", "memory");
|
|
const aliasDir = path.join(workspaceDir, "memory");
|
|
await fs.mkdir(realDir, { recursive: true });
|
|
await fs.symlink(realDir, aliasDir);
|
|
|
|
const cfg: OpenClawConfig = { tools: { fs: { workspaceOnly: true } } };
|
|
const tools = createOpenClawCodingTools({ workspaceDir, config: cfg });
|
|
const { writeTool } = expectReadWriteEditTools(tools);
|
|
|
|
await writeTool.execute("ws-write-symlink-parent", {
|
|
path: "memory/2026-05-20.md",
|
|
content: "remember this\n",
|
|
});
|
|
|
|
await expect(fs.readFile(path.join(realDir, "2026-05-20.md"), "utf8")).resolves.toBe(
|
|
"remember this\n",
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"edits through in-workspace symlink parents when workspaceOnly is enabled",
|
|
async () => {
|
|
await withTempDir("openclaw-ws-symlink-edit-", async (workspaceDir) => {
|
|
const realDir = path.join(workspaceDir, "oc_system", "memory");
|
|
const aliasDir = path.join(workspaceDir, "memory");
|
|
const targetPath = path.join(realDir, "2026-05-20.md");
|
|
await fs.mkdir(realDir, { recursive: true });
|
|
await fs.symlink(realDir, aliasDir);
|
|
await fs.writeFile(targetPath, "old memory\n", "utf8");
|
|
|
|
const cfg: OpenClawConfig = { tools: { fs: { workspaceOnly: true } } };
|
|
const tools = createOpenClawCodingTools({ workspaceDir, config: cfg });
|
|
const { editTool } = expectReadWriteEditTools(tools);
|
|
|
|
await editTool.execute("ws-edit-symlink-parent", {
|
|
path: "memory/2026-05-20.md",
|
|
edits: [{ oldText: "old", newText: "new" }],
|
|
});
|
|
|
|
await expect(fs.readFile(targetPath, "utf8")).resolves.toBe("new memory\n");
|
|
});
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"rejects writes through symlink parents that resolve outside the workspace",
|
|
async () => {
|
|
await withTempDir("openclaw-ws-symlink-escape-", async (rootDir) => {
|
|
const workspaceDir = path.join(rootDir, "workspace");
|
|
const outsideDir = path.join(rootDir, "outside");
|
|
const aliasDir = path.join(workspaceDir, "memory");
|
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
await fs.mkdir(outsideDir, { recursive: true });
|
|
await fs.symlink(outsideDir, aliasDir);
|
|
|
|
const cfg: OpenClawConfig = { tools: { fs: { workspaceOnly: true } } };
|
|
const tools = createOpenClawCodingTools({ workspaceDir, config: cfg });
|
|
const { writeTool } = expectReadWriteEditTools(tools);
|
|
|
|
await expect(
|
|
writeTool.execute("ws-write-symlink-escape", {
|
|
path: "memory/secret.md",
|
|
content: "pwned\n",
|
|
}),
|
|
).rejects.toThrow(/Path escapes workspace root|outside-workspace|sandbox/i);
|
|
await expect(fs.stat(path.join(outsideDir, "secret.md"))).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
});
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"rejects writes to final symlinks when workspaceOnly is enabled",
|
|
async () => {
|
|
await withTempDir("openclaw-ws-symlink-leaf-", async (workspaceDir) => {
|
|
const targetPath = path.join(workspaceDir, "target.md");
|
|
const linkPath = path.join(workspaceDir, "memory.md");
|
|
await fs.writeFile(targetPath, "original\n", "utf8");
|
|
await fs.symlink(targetPath, linkPath);
|
|
|
|
const cfg: OpenClawConfig = { tools: { fs: { workspaceOnly: true } } };
|
|
const tools = createOpenClawCodingTools({ workspaceDir, config: cfg });
|
|
const { writeTool } = expectReadWriteEditTools(tools);
|
|
|
|
await expect(
|
|
writeTool.execute("ws-write-final-symlink", {
|
|
path: "memory.md",
|
|
content: "pwned\n",
|
|
}),
|
|
).rejects.toThrow(/symlink|not-file|directory component/i);
|
|
await expect(fs.readFile(targetPath, "utf8")).resolves.toBe("original\n");
|
|
});
|
|
},
|
|
);
|
|
|
|
it("allows workspaceOnly reads for resolved skill roots without allowing other filesystem access", async () => {
|
|
await withTempDir("openclaw-skill-read-", async (rootDir) => {
|
|
const workspaceDir = path.join(rootDir, "workspace");
|
|
const skillDir = path.join(rootDir, "global-skills", "demo");
|
|
const siblingDir = path.join(rootDir, "global-skills", "other");
|
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
await fs.mkdir(skillDir, { recursive: true });
|
|
await fs.mkdir(siblingDir, { recursive: true });
|
|
const skillFile = path.join(skillDir, "SKILL.md");
|
|
const guideFile = path.join(skillDir, "guide.md");
|
|
const siblingFile = path.join(siblingDir, "SKILL.md");
|
|
const outsideFile = path.join(rootDir, "outside.txt");
|
|
await fs.writeFile(skillFile, "# Demo skill\noriginal skill\n", "utf8");
|
|
await fs.writeFile(guideFile, "skill guide", "utf8");
|
|
await fs.writeFile(siblingFile, "sibling skill", "utf8");
|
|
await fs.writeFile(outsideFile, "outside secret", "utf8");
|
|
|
|
const cfg: OpenClawConfig = { tools: { fs: { workspaceOnly: true } } };
|
|
const tools = createOpenClawCodingTools({
|
|
workspaceDir,
|
|
config: cfg,
|
|
skillsSnapshot: {
|
|
prompt: "",
|
|
skills: [{ name: "demo" }],
|
|
resolvedSkills: [
|
|
createCanonicalFixtureSkill({
|
|
name: "demo",
|
|
description: "Demo skill",
|
|
filePath: skillFile,
|
|
baseDir: skillDir,
|
|
source: "test",
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
const { readTool, writeTool, editTool } = expectReadWriteEditTools(tools);
|
|
|
|
expect(getTextContent(await readTool.execute("read-skill", { path: skillFile }))).toContain(
|
|
"original skill",
|
|
);
|
|
expect(
|
|
getTextContent(await readTool.execute("read-skill-guide", { path: guideFile })),
|
|
).toContain("skill guide");
|
|
await expect(readTool.execute("read-sibling", { path: siblingFile })).rejects.toThrow(
|
|
/Path escapes sandbox root/i,
|
|
);
|
|
await expect(readTool.execute("read-outside", { path: outsideFile })).rejects.toThrow(
|
|
/Path escapes sandbox root/i,
|
|
);
|
|
await expect(
|
|
writeTool.execute("write-skill", { path: skillFile, content: "overwritten" }),
|
|
).rejects.toThrow(/Path escapes sandbox root|outside-workspace/i);
|
|
await expect(
|
|
editTool.execute("edit-skill", {
|
|
path: skillFile,
|
|
edits: [{ oldText: "original", newText: "edited" }],
|
|
}),
|
|
).rejects.toThrow(/Path escapes sandbox root|outside-workspace/i);
|
|
expect(await fs.readFile(skillFile, "utf8")).toContain("original skill");
|
|
});
|
|
});
|
|
|
|
it("rejects symlink escapes inside resolved skill roots", async () => {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
await withTempDir("openclaw-skill-read-symlink-", async (rootDir) => {
|
|
const workspaceDir = path.join(rootDir, "workspace");
|
|
const skillDir = path.join(rootDir, "global-skills", "demo");
|
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
await fs.mkdir(skillDir, { recursive: true });
|
|
const skillFile = path.join(skillDir, "SKILL.md");
|
|
const outsideFile = path.join(rootDir, "outside.txt");
|
|
const linkPath = path.join(skillDir, "outside-link.txt");
|
|
await fs.writeFile(skillFile, "# Demo skill\n", "utf8");
|
|
await fs.writeFile(outsideFile, "outside secret", "utf8");
|
|
await fs.symlink(outsideFile, linkPath);
|
|
|
|
const cfg: OpenClawConfig = { tools: { fs: { workspaceOnly: true } } };
|
|
const tools = createOpenClawCodingTools({
|
|
workspaceDir,
|
|
config: cfg,
|
|
skillsSnapshot: {
|
|
prompt: "",
|
|
skills: [{ name: "demo" }],
|
|
resolvedSkills: [
|
|
createCanonicalFixtureSkill({
|
|
name: "demo",
|
|
description: "Demo skill",
|
|
filePath: skillFile,
|
|
baseDir: skillDir,
|
|
source: "test",
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
const { readTool } = expectReadWriteEditTools(tools);
|
|
|
|
await expect(readTool.execute("read-skill-symlink", { path: linkPath })).rejects.toThrow(
|
|
/symlink|sandbox|outside|escape/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 = createAgentToolsSandboxContext({
|
|
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",
|
|
edits: [{ oldText: "write", newText: "edit" }],
|
|
});
|
|
const edited = await fs.readFile(path.join(sandboxDir, "new.txt"), "utf8");
|
|
expect(edited).toBe("sandbox edit");
|
|
});
|
|
});
|
|
});
|
|
});
|