diff --git a/CHANGELOG.md b/CHANGELOG.md index ca5c9a48bef3..c7c23503488c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,7 @@ Docs: https://docs.openclaw.ai - Scripts/Windows: route remaining QA, release, profile, and live-media `pnpm` launches through the managed runner so native Windows avoids brittle `.cmd` execution and shell-argv warnings. - Release: align generated config/API baselines and the meeting-notes plugin version so release preflight stays green on native Windows. - Checks/Windows: route full `pnpm check` stage commands through the managed child runner so Windows avoids Node shell-argv deprecation warnings there too. +- Agents/fs: allow workspace-only host write/edit tools to write through in-workspace symlink directory parents while preserving outside-workspace symlink rejection. Fixes #84696. Thanks @garbagenetwork. - Checks/Windows: run managed child commands through explicit `cmd.exe` wrapping instead of Node shell mode with argv, avoiding Node 24 subprocess deprecation warnings during changed checks. - Gateway: omit internal stream-error placeholder entries from agent prompt history so failed assistant turns are not replayed as model-authored text. (#85652) Thanks @anyech. - Sessions: enforce the session write-lock max-hold policy during lock acquisition so long-held locks can be reclaimed before the stale-lock window. (#85764) Thanks @njuboy11. diff --git a/src/agents/pi-tools.read.ts b/src/agents/pi-tools.read.ts index 8f9352dd5fe0..73a7d9101db4 100644 --- a/src/agents/pi-tools.read.ts +++ b/src/agents/pi-tools.read.ts @@ -4,7 +4,11 @@ import { URL } from "node:url"; import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { createEditTool, createReadTool, createWriteTool } from "@earendil-works/pi-coding-agent"; import { isWindowsDrivePath } from "../infra/archive-path.js"; -import { root as fsRoot, FsSafeError } from "../infra/fs-safe.js"; +import { + canonicalPathFromExistingAncestor, + root as fsRoot, + FsSafeError, +} from "../infra/fs-safe.js"; import { expandHomePrefix, resolveOsHomeDir } from "../infra/home-dir.js"; import { hasEncodedFileUrlSeparator, trySafeFileURLToPath } from "../infra/local-file-access.js"; import { detectMime } from "../media/mime.js"; @@ -884,7 +888,7 @@ function createHostWriteOperations(root: string, options?: { workspaceOnly?: boo await fs.mkdir(resolved, { recursive: true }); }, writeFile: async (absolutePath: string, content: string) => { - const relative = toRelativeWorkspacePath(root, absolutePath); + const relative = await toCanonicalRelativeWorkspacePath(root, absolutePath); await (await rootPromise).write(relative, content, { mkdir: true }); }, } as const; @@ -917,7 +921,7 @@ function createHostEditOperations(root: string, options?: { workspaceOnly?: bool return safeRead.buffer; }, writeFile: async (absolutePath: string, content: string) => { - const relative = toRelativeWorkspacePath(root, absolutePath); + const relative = await toCanonicalRelativeWorkspacePath(root, absolutePath); await (await rootPromise).write(relative, content, { mkdir: true }); }, access: async (absolutePath: string) => { @@ -950,6 +954,21 @@ function createHostEditOperations(root: string, options?: { workspaceOnly?: bool } as const; } +async function toCanonicalRelativeWorkspacePath( + root: string, + absolutePath: string, +): Promise { + const lexicalRelative = toRelativeWorkspacePath(root, absolutePath); + const lexicalPath = path.resolve(root, lexicalRelative); + const parentPath = path.dirname(lexicalPath); + const [rootReal, canonicalParentPath] = await Promise.all([ + fs.realpath(root), + canonicalPathFromExistingAncestor(parentPath), + ]); + const canonicalPath = path.join(canonicalParentPath, path.basename(lexicalPath)); + return toRelativeWorkspacePath(rootReal, canonicalPath); +} + function createFsAccessError(code: string, filePath: string): NodeJS.ErrnoException { const error = new Error(`Sandbox FS error (${code}): ${filePath}`) as NodeJS.ErrnoException; error.code = code; diff --git a/src/agents/pi-tools.workspace-paths.test.ts b/src/agents/pi-tools.workspace-paths.test.ts index 84d24aa05a7e..7ac34270272a 100644 --- a/src/agents/pi-tools.workspace-paths.test.ts +++ b/src/agents/pi-tools.workspace-paths.test.ts @@ -224,6 +224,108 @@ describe("workspace path resolution", () => { }); }); + 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");