mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
2f045a73f4
* fix(agents): preserve filename case for agent file writes on Windows toRelativePathUnderRoot passed root and candidate through normalizeWindowsPathForComparison, which lowercases, and then returned the resulting relative path. Callers build files out of that path, so an agent asking for src/Components/MyComponent.tsx got src\components\mycomponent.tsx on disk. NTFS is case-preserving, so nothing fails locally, but git records the lowercased name and the imports the agent wrote break on Linux and in CI. Lowercasing is not even case-safe for every name: "İstanbul.md" lowercases to "i̇stanbul.md" (U+0130 becomes U+0069 U+0307), one code point longer and not reversible, so the filename is corrupted rather than merely recased. The lowercasing was never needed for the boundary math: path.win32.relative already matches the root case-insensitively and returns the tail in its original case. Extended-length prefix stripping is still needed, or a \?\ candidate relativizes to ..\..\..\?\C:\... and reads as an escape, so this adds normalizeWindowsPathPreservingCase next to the comparison variant. It mirrors that helper step for step, including the trim, minus the lowercasing; a test pins the equivalence so the two cannot drift. The containment decision is unchanged: relative(lower(a), lower(b)) and relative(a, b) return the same structure, and that structure is all validateRelativePathWithinBoundary inspects. Sibling surfaces checked: the other two callers of normalizeWindowsPathForComparison use it as a comparison key and are correct as-is (installed-plugin-index-record-reader.ts:215 compares with ===, fs-safe's isPathInside discards the relative and returns a boolean). path-policy.ts was the only site returning the normalized value. * test(agents): verify Windows filename case end to end Co-authored-by: Yigtwxx <yigiterdogan023@gmail.com> * style(agents): apply oxfmt to the workspace path case test --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
90 lines
3.5 KiB
TypeScript
90 lines
3.5 KiB
TypeScript
// Verifies workspace-relative path policy across POSIX and Windows semantics.
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { withMockedWindowsPlatform } from "../test-utils/vitest-spies.js";
|
|
|
|
const resolveSandboxInputPathMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("./sandbox-paths.js", () => ({
|
|
resolveSandboxInputPath: resolveSandboxInputPathMock,
|
|
}));
|
|
|
|
import { toRelativeWorkspacePath } from "./path-policy.js";
|
|
|
|
describe("toRelativeWorkspacePath (windows semantics)", () => {
|
|
beforeEach(() => {
|
|
// Sandbox input resolution is not under test; return normalized input paths directly.
|
|
resolveSandboxInputPathMock.mockReset();
|
|
resolveSandboxInputPathMock.mockImplementation((filePath: string) => filePath);
|
|
});
|
|
|
|
it("accepts windows paths with mixed separators and case", () => {
|
|
withMockedWindowsPlatform(() => {
|
|
const root = "C:\\Users\\User\\OpenClaw";
|
|
const candidate = "c:/users/user/openclaw/memory/log.txt";
|
|
expect(toRelativeWorkspacePath(root, candidate)).toBe("memory\\log.txt");
|
|
});
|
|
});
|
|
|
|
it("preserves filename case so callers create the file the agent asked for", () => {
|
|
withMockedWindowsPlatform(() => {
|
|
const root = "C:\\Users\\User\\OpenClaw";
|
|
const candidate = "C:\\Users\\User\\OpenClaw\\src\\Components\\MyComponent.tsx";
|
|
expect(toRelativeWorkspacePath(root, candidate)).toBe("src\\Components\\MyComponent.tsx");
|
|
});
|
|
});
|
|
|
|
it("preserves candidate case when the root itself is spelled with different case", () => {
|
|
withMockedWindowsPlatform(() => {
|
|
const root = "C:\\Users\\User\\OpenClaw";
|
|
const candidate = "c:/users/user/openclaw/Memory/Log.txt";
|
|
expect(toRelativeWorkspacePath(root, candidate)).toBe("Memory\\Log.txt");
|
|
});
|
|
});
|
|
|
|
it("accepts extended-length prefixed windows paths", () => {
|
|
withMockedWindowsPlatform(() => {
|
|
const root = "C:\\Users\\User\\OpenClaw";
|
|
const candidate = "\\\\?\\C:\\Users\\User\\OpenClaw\\Memory\\Log.txt";
|
|
expect(toRelativeWorkspacePath(root, candidate)).toBe("Memory\\Log.txt");
|
|
});
|
|
});
|
|
|
|
it("rejects windows paths outside workspace root", () => {
|
|
withMockedWindowsPlatform(() => {
|
|
const root = "C:\\Users\\User\\OpenClaw";
|
|
const candidate = "C:\\Users\\User\\Other\\log.txt";
|
|
expect(() => toRelativeWorkspacePath(root, candidate)).toThrow("Path escapes workspace root");
|
|
});
|
|
});
|
|
|
|
it("rejects windows escapes that differ from the root only by case", () => {
|
|
withMockedWindowsPlatform(() => {
|
|
const root = "C:\\Users\\User\\OpenClaw";
|
|
const candidate = "c:\\users\\USER\\openclaw\\..\\Other\\log.txt";
|
|
expect(() => toRelativeWorkspacePath(root, candidate)).toThrow("Path escapes workspace root");
|
|
});
|
|
});
|
|
|
|
it("treats a differently-cased root as the root itself", () => {
|
|
withMockedWindowsPlatform(() => {
|
|
const root = "C:\\Users\\User\\OpenClaw";
|
|
const candidate = "c:\\users\\USER\\openclaw";
|
|
expect(toRelativeWorkspacePath(root, candidate, { allowRoot: true })).toBe("");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("toRelativeWorkspacePath", () => {
|
|
it("accepts dot-dot-prefixed filenames inside the workspace", () => {
|
|
expect(toRelativeWorkspacePath("/workspace/root", "/workspace/root/..file.txt")).toBe(
|
|
"..file.txt",
|
|
);
|
|
});
|
|
|
|
it("rejects parent directory traversal outside the workspace", () => {
|
|
expect(() => toRelativeWorkspacePath("/workspace/root", "/workspace/root/../file.txt")).toThrow(
|
|
"Path escapes workspace root",
|
|
);
|
|
});
|
|
});
|