Files
openclaw/src/agents/path-policy.test.ts
T
Yiğit ERDOĞAN 2f045a73f4 fix(agents): agent-created files get lowercased names on Windows (#109823)
* 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>
2026-07-18 22:58:59 +01:00

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",
);
});
});