diff --git a/CHANGELOG.md b/CHANGELOG.md index 640f7aaa288..6c909066bb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/FS workspace default: honor documented host file-tool default `tools.fs.workspaceOnly=false` when unset so host `write`/`edit` calls are not incorrectly workspace-restricted unless explicitly enabled. Landed from contributor PR #31128 by @SaucePackets. Thanks @SaucePackets. - Gateway/CLI session recovery: handle expired CLI session IDs gracefully by clearing stale session state and retrying without crashing gateway runs. Landed from contributor PR #31090 by @frankekn. Thanks @frankekn. - Slack/Subagent completion delivery: stop forcing bound conversation IDs into `threadId` so Slack completion announces do not send invalid `thread_ts` for DMs/top-level channels. Landed from contributor PR #31105 by @stakeswky. Thanks @stakeswky. - Signal/Loop protection: evaluate own-account detection before sync-message filtering (including UUID-only `accountUuid` configs) so `sentTranscript` sync events cannot bypass loop protection and self-reply loops. Landed from contributor PR #31093 by @kevinWangSheng. Thanks @kevinWangSheng. diff --git a/src/agents/pi-tools.read.ts b/src/agents/pi-tools.read.ts index de3fcd1666a..0b5ff58478d 100644 --- a/src/agents/pi-tools.read.ts +++ b/src/agents/pi-tools.read.ts @@ -763,7 +763,7 @@ function createSandboxEditOperations(params: SandboxToolParams) { } function createHostWriteOperations(root: string, options?: { workspaceOnly?: boolean }) { - const workspaceOnly = options?.workspaceOnly !== false; + const workspaceOnly = options?.workspaceOnly ?? false; if (!workspaceOnly) { // When workspaceOnly is false, allow writes anywhere on the host @@ -781,7 +781,7 @@ function createHostWriteOperations(root: string, options?: { workspaceOnly?: boo } as const; } - // When workspaceOnly is true (default), enforce workspace boundary + // When workspaceOnly is true, enforce workspace boundary return { mkdir: async (dir: string) => { const relative = toRelativePathInRoot(root, dir, { allowRoot: true }); @@ -802,7 +802,7 @@ function createHostWriteOperations(root: string, options?: { workspaceOnly?: boo } function createHostEditOperations(root: string, options?: { workspaceOnly?: boolean }) { - const workspaceOnly = options?.workspaceOnly !== false; + const workspaceOnly = options?.workspaceOnly ?? false; if (!workspaceOnly) { // When workspaceOnly is false, allow edits anywhere on the host @@ -824,7 +824,7 @@ function createHostEditOperations(root: string, options?: { workspaceOnly?: bool } as const; } - // When workspaceOnly is true (default), enforce workspace boundary + // When workspaceOnly is true, enforce workspace boundary return { readFile: async (absolutePath: string) => { const relative = toRelativePathInRoot(root, absolutePath); diff --git a/src/agents/pi-tools.workspace-only-false.test.ts b/src/agents/pi-tools.workspace-only-false.test.ts index 41237a593cf..da08f2a808c 100644 --- a/src/agents/pi-tools.workspace-only-false.test.ts +++ b/src/agents/pi-tools.workspace-only-false.test.ts @@ -173,6 +173,54 @@ describe("FS tools with workspaceOnly=false", () => { expect(hasError).toBe(false); }); + it("should allow write outside workspace when workspaceOnly is unset", async () => { + const outsideUnsetFile = path.join(tmpDir, "outside-unset-write.txt"); + const tools = createOpenClawCodingTools({ + workspaceDir, + config: {}, + }); + + const writeTool = tools.find((t) => t.name === "write"); + expect(writeTool).toBeDefined(); + + const result = await writeTool!.execute("test-call-3a", { + path: outsideUnsetFile, + content: "unset write content", + }); + + const hasError = result.content.some( + (c) => c.type === "text" && c.text.toLowerCase().includes("error"), + ); + expect(hasError).toBe(false); + const content = await fs.readFile(outsideUnsetFile, "utf-8"); + expect(content).toBe("unset write content"); + }); + + it("should allow edit outside workspace when workspaceOnly is unset", async () => { + const outsideUnsetFile = path.join(tmpDir, "outside-unset-edit.txt"); + await fs.writeFile(outsideUnsetFile, "before"); + const tools = createOpenClawCodingTools({ + workspaceDir, + config: {}, + }); + + const editTool = tools.find((t) => t.name === "edit"); + expect(editTool).toBeDefined(); + + const result = await editTool!.execute("test-call-3b", { + path: outsideUnsetFile, + oldText: "before", + newText: "after", + }); + + const hasError = result.content.some( + (c) => c.type === "text" && c.text.toLowerCase().includes("error"), + ); + expect(hasError).toBe(false); + const content = await fs.readFile(outsideUnsetFile, "utf-8"); + expect(content).toBe("after"); + }); + it("should block write outside workspace when workspaceOnly=true", async () => { const tools = createOpenClawCodingTools({ workspaceDir,