mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 23:26:48 +00:00
fix: allow symlinked workspace write parents (#85818)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user