openclaw/src/agents/pi-tools.workspace-paths.test.ts

236 lines
9.0 KiB
TypeScript
Raw Normal View History

2026-01-10 17:26:29 +01:00
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";
2026-01-30 03:15:10 +01:00
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 };
});
2026-01-10 17:26:29 +01:00
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 () => {
2026-01-30 03:15:10 +01:00
await withTempDir("openclaw-ws-", async (workspaceDir) => {
await withTempDir("openclaw-cwd-", async (otherDir) => {
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(otherDir);
2026-01-10 17:26:29 +01:00
try {
2026-01-30 03:15:10 +01:00
const tools = createOpenClawCodingTools({ workspaceDir });
const { readTool, writeTool, editTool } = expectReadWriteEditTools(tools);
2026-01-10 17:26:29 +01:00
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");
2026-01-10 17:26:29 +01:00
const writeFile = "write.txt";
await writeTool.execute("ws-write", {
path: writeFile,
content: "workspace write ok",
2026-01-10 17:26:29 +01:00
});
expect(await fs.readFile(path.join(workspaceDir, writeFile), "utf8")).toBe(
"workspace write ok",
);
2026-01-10 17:26:29 +01:00
const editFile = "edit.txt";
await fs.writeFile(path.join(workspaceDir, editFile), "hello world", "utf8");
await editTool.execute("ws-edit", {
path: editFile,
2026-01-10 17:26:29 +01:00
oldText: "world",
2026-01-30 03:15:10 +01:00
newText: "openclaw",
2026-01-10 17:26:29 +01:00
});
expect(await fs.readFile(path.join(workspaceDir, editFile), "utf8")).toBe(
"hello openclaw",
);
2026-01-10 17:26:29 +01:00
} finally {
cwdSpy.mockRestore();
2026-01-10 17:26:29 +01:00
}
});
});
});
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 () => {
2026-01-30 03:15:10 +01:00
await withTempDir("openclaw-ws-", async (workspaceDir) => {
fix: ensure exec approval is registered before returning (#2402) (#3357) * feat(gateway): add register and awaitDecision methods to ExecApprovalManager Separates registration (synchronous) from waiting (async) to allow callers to confirm registration before the decision is made. Adds grace period for resolved entries to prevent race conditions. * feat(gateway): add two-phase response and waitDecision handler for exec approvals Send immediate 'accepted' response after registration so callers can confirm the approval ID is valid. Add exec.approval.waitDecision endpoint to wait for decision on already-registered approvals. * fix(exec): await approval registration before returning approval-pending Ensures the approval ID is registered in the gateway before the tool returns. Uses exec.approval.request with expectFinal:false for registration, then fire-and-forget exec.approval.waitDecision for the decision phase. Fixes #2402 * test(gateway): update exec-approval test for two-phase response Add assertion for immediate 'accepted' response before final decision. * test(exec): update approval-id test mocks for new two-phase flow Mock both exec.approval.request (registration) and exec.approval.waitDecision (decision) calls to match the new internal implementation. * fix(lint): add cause to errors, use generics instead of type assertions * fix(exec-approval): guard register() against duplicate IDs * fix: remove unused timeoutMs param, guard register() against duplicates * fix(exec-approval): throw on duplicate ID, capture entry in closure * fix: return error on timeout, remove stale test mock branch * fix: wrap register() in try/catch, make timeout handling consistent * fix: update snapshot on timeout, make two-phase response opt-in * fix: extend grace period to 15s, return 'expired' status * fix: prevent double-resolve after timeout * fix: make register() idempotent, capture snapshot before await * fix(gateway): complete two-phase exec approval wiring * fix: finalize exec approval race fix (openclaw#3357) thanks @ramin-shirali * fix(protocol): regenerate exec approval request models (openclaw#3357) thanks @ramin-shirali * fix(test): remove unused callCount in discord threading test --------- Co-authored-by: rshirali <rshirali@rshirali-haga.local> Co-authored-by: rshirali <rshirali@rshirali-haga-1.home> Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-13 19:57:02 +01:00
const tools = createOpenClawCodingTools({
workspaceDir,
exec: { host: "gateway", ask: "off", security: "full" },
});
const execTool = tools.find((tool) => tool.name === "exec");
expect(execTool).toBeDefined();
2026-01-10 17:26:29 +01:00
const result = await execTool?.execute("ws-exec", {
command: "echo ok",
2026-01-10 17:26:29 +01:00
});
const cwd =
result?.details && typeof result.details === "object" && "cwd" in result.details
? (result.details as { cwd?: string }).cwd
: undefined;
expect(cwd).toBeTruthy();
2026-01-10 17:26:29 +01:00
const [resolvedOutput, resolvedWorkspace] = await Promise.all([
fs.realpath(String(cwd)),
2026-01-10 17:26:29 +01:00
fs.realpath(workspaceDir),
]);
expect(resolvedOutput).toBe(resolvedWorkspace);
});
});
it("lets exec workdir override the workspace default", async () => {
2026-01-30 03:15:10 +01:00
await withTempDir("openclaw-ws-", async (workspaceDir) => {
await withTempDir("openclaw-override-", async (overrideDir) => {
fix: ensure exec approval is registered before returning (#2402) (#3357) * feat(gateway): add register and awaitDecision methods to ExecApprovalManager Separates registration (synchronous) from waiting (async) to allow callers to confirm registration before the decision is made. Adds grace period for resolved entries to prevent race conditions. * feat(gateway): add two-phase response and waitDecision handler for exec approvals Send immediate 'accepted' response after registration so callers can confirm the approval ID is valid. Add exec.approval.waitDecision endpoint to wait for decision on already-registered approvals. * fix(exec): await approval registration before returning approval-pending Ensures the approval ID is registered in the gateway before the tool returns. Uses exec.approval.request with expectFinal:false for registration, then fire-and-forget exec.approval.waitDecision for the decision phase. Fixes #2402 * test(gateway): update exec-approval test for two-phase response Add assertion for immediate 'accepted' response before final decision. * test(exec): update approval-id test mocks for new two-phase flow Mock both exec.approval.request (registration) and exec.approval.waitDecision (decision) calls to match the new internal implementation. * fix(lint): add cause to errors, use generics instead of type assertions * fix(exec-approval): guard register() against duplicate IDs * fix: remove unused timeoutMs param, guard register() against duplicates * fix(exec-approval): throw on duplicate ID, capture entry in closure * fix: return error on timeout, remove stale test mock branch * fix: wrap register() in try/catch, make timeout handling consistent * fix: update snapshot on timeout, make two-phase response opt-in * fix: extend grace period to 15s, return 'expired' status * fix: prevent double-resolve after timeout * fix: make register() idempotent, capture snapshot before await * fix(gateway): complete two-phase exec approval wiring * fix: finalize exec approval race fix (openclaw#3357) thanks @ramin-shirali * fix(protocol): regenerate exec approval request models (openclaw#3357) thanks @ramin-shirali * fix(test): remove unused callCount in discord threading test --------- Co-authored-by: rshirali <rshirali@rshirali-haga.local> Co-authored-by: rshirali <rshirali@rshirali-haga-1.home> Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-13 19:57:02 +01:00
const tools = createOpenClawCodingTools({
workspaceDir,
exec: { host: "gateway", ask: "off", security: "full" },
});
const execTool = tools.find((tool) => tool.name === "exec");
expect(execTool).toBeDefined();
2026-01-10 17:26:29 +01:00
const result = await execTool?.execute("ws-exec-override", {
command: "echo ok",
2026-01-10 17:26:29 +01:00
workdir: overrideDir,
});
const cwd =
result?.details && typeof result.details === "object" && "cwd" in result.details
? (result.details as { cwd?: string }).cwd
: undefined;
expect(cwd).toBeTruthy();
2026-01-10 17:26:29 +01:00
const [resolvedOutput, resolvedOverride] = await Promise.all([
fs.realpath(String(cwd)),
2026-01-10 17:26:29 +01:00
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);
});
});
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 });
}
});
});
2026-01-10 17:26:29 +01:00
});
describe("sandboxed workspace paths", () => {
it("uses sandbox workspace for relative read/write/edit", async () => {
2026-01-30 03:15:10 +01:00
await withTempDir("openclaw-sandbox-", async (sandboxDir) => {
await withTempDir("openclaw-workspace-", async (workspaceDir) => {
const sandbox = createPiToolsSandboxContext({
2026-01-10 17:26:29 +01:00
workspaceDir: sandboxDir,
agentWorkspaceDir: workspaceDir,
2026-02-17 15:48:44 +09:00
workspaceAccess: "rw" as const,
fsBridge: createHostSandboxFsBridge(sandboxDir),
2026-01-10 17:26:29 +01:00
tools: { allow: [], deny: [] },
});
2026-01-10 17:26:29 +01:00
const testFile = "sandbox.txt";
await fs.writeFile(path.join(sandboxDir, testFile), "sandbox read", "utf8");
await fs.writeFile(path.join(workspaceDir, testFile), "workspace read", "utf8");
2026-01-10 17:26:29 +01:00
2026-01-30 03:15:10 +01:00
const tools = createOpenClawCodingTools({ workspaceDir, sandbox });
const { readTool, writeTool, editTool } = expectReadWriteEditTools(tools);
2026-01-10 17:26:29 +01:00
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");
2026-01-10 17:26:29 +01:00
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");
2026-01-10 17:26:29 +01:00
expect(edited).toBe("sandbox edit");
});
});
});
});