fix: allow symlinked workspace write parents (#85818)

This commit is contained in:
Gio Della-Libera
2026-05-23 20:42:01 -07:00
committed by GitHub
parent af765100ff
commit 6b337ff3ea
3 changed files with 125 additions and 3 deletions

View File

@@ -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.

View File

@@ -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<string> {
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;

View File

@@ -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");