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>
This commit is contained in:
Yiğit ERDOĞAN
2026-07-19 00:58:59 +03:00
committed by GitHub
parent b88646cf78
commit 2f045a73f4
5 changed files with 126 additions and 6 deletions
@@ -118,6 +118,28 @@ describe("workspace path resolution", () => {
});
});
it.runIf(process.platform === "win32")(
"preserves mixed-case and Unicode names for workspace-only writes on Windows",
async () => {
await withTempDir("openclaw-windows-case-", async (workspaceDir) => {
const cfg: OpenClawConfig = { tools: { fs: { workspaceOnly: true } } };
const tools = createOpenClawCodingTools({ workspaceDir, config: cfg });
const { writeTool } = expectReadWriteEditTools(tools);
await writeTool.execute("windows-case-write", {
path: "Source/İstanbul/Widget.ts",
content: "export const Widget = true;",
});
await expect(fs.readdir(workspaceDir)).resolves.toEqual(["Source"]);
await expect(fs.readdir(path.join(workspaceDir, "Source"))).resolves.toEqual(["İstanbul"]);
await expect(fs.readdir(path.join(workspaceDir, "Source", "İstanbul"))).resolves.toEqual([
"Widget.ts",
]);
});
},
);
it("allows deletion edits with empty newText", async () => {
await withTempDir("openclaw-ws-", async (workspaceDir) => {
await withTempDir("openclaw-cwd-", async (otherDir) => {
+40
View File
@@ -25,6 +25,30 @@ describe("toRelativeWorkspacePath (windows semantics)", () => {
});
});
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";
@@ -32,6 +56,22 @@ describe("toRelativeWorkspacePath (windows semantics)", () => {
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", () => {
+7 -5
View File
@@ -4,7 +4,7 @@
* Converts validated absolute or relative inputs into root-relative paths without allowing boundary escapes.
*/
import path from "node:path";
import { normalizeWindowsPathForComparison } from "../infra/path-guards.js";
import { normalizeWindowsPathPreservingCase } from "../infra/path-guards.js";
import { resolveSandboxInputPath } from "./sandbox-paths.js";
// Shared path boundary helpers for workspace and sandbox-facing agent inputs.
@@ -74,12 +74,14 @@ function toRelativePathUnderRoot(params: {
);
if (process.platform === "win32") {
// Windows comparisons need normalized separators and drive casing before
// path.relative; otherwise the same root can look outside the boundary.
// path.win32.relative already matches the root case-insensitively, so normalization
// here only strips extended-length prefixes that would otherwise read as an escape.
// It must not lowercase: this relative path is what callers create files from, and
// Windows is case-insensitive but case-preserving.
const rootResolved = path.win32.resolve(params.root);
const resolvedCandidate = path.win32.resolve(resolvedInput);
const rootForCompare = normalizeWindowsPathForComparison(rootResolved);
const targetForCompare = normalizeWindowsPathForComparison(resolvedCandidate);
const rootForCompare = normalizeWindowsPathPreservingCase(rootResolved);
const targetForCompare = normalizeWindowsPathPreservingCase(resolvedCandidate);
const relative = path.win32.relative(rootForCompare, targetForCompare);
return validateRelativePathWithinBoundary({
relativePath: relative,
+35 -1
View File
@@ -1,7 +1,11 @@
// Covers path guard helpers for platform and symlink errors.
import { afterEach, describe, expect, it, vi } from "vitest";
import { mockProcessPlatform } from "../test-utils/vitest-spies.js";
import { isPathInside, normalizeWindowsPathForComparison } from "./path-guards.js";
import {
isPathInside,
normalizeWindowsPathForComparison,
normalizeWindowsPathPreservingCase,
} from "./path-guards.js";
function setPlatform(platform: NodeJS.Platform): void {
mockProcessPlatform(platform);
@@ -21,6 +25,36 @@ describe("normalizeWindowsPathForComparison", () => {
});
});
describe("normalizeWindowsPathPreservingCase", () => {
// Callers create files from paths derived off this, so case must survive. The
// equivalence case below pins that case is the *only* thing that differs from the
// comparison variant; these rows pin the concrete shapes.
it.each([
["\\\\?\\C:\\Users\\Peter/Repo", "C:\\Users\\Peter\\Repo"],
["\\\\?\\UNC\\Server\\Share\\Folder", "\\\\Server\\Share\\Folder"],
["\\\\?\\unc\\Server\\Share\\Folder", "\\\\Server\\Share\\Folder"],
["C:\\Users\\User\\OpenClaw\\src/Components", "C:\\Users\\User\\OpenClaw\\src\\Components"],
["C:\\Users\\User\\OpenClaw ", "C:\\Users\\User\\OpenClaw"],
])("normalizes windows path %s without lowercasing", (input, expected) => {
expect(normalizeWindowsPathPreservingCase(input)).toBe(expected);
});
it("matches the comparison variant except for case", () => {
for (const input of [
"\\\\?\\C:\\Users\\Peter/Repo",
"\\\\?\\UNC\\Server\\Share\\Folder",
"\\\\?\\unc\\Server\\Share\\Folder",
"C:\\Users\\User\\OpenClaw\\src/Components",
"C:\\Users\\User\\OpenClaw ",
" C:\\Users\\User\\OpenClaw ",
]) {
expect(normalizeWindowsPathPreservingCase(input).toLowerCase()).toBe(
normalizeWindowsPathForComparison(input),
);
}
});
});
describe("isPathInside", () => {
it.each([
["/workspace/root", "/workspace/root", true],
+22
View File
@@ -1,4 +1,5 @@
// Exposes generic path guard helpers with fs-safe defaults.
import path from "node:path";
import "./fs-safe-defaults.js";
// Generic path guard facade for containment checks and safe relative paths.
@@ -8,3 +9,24 @@ export {
normalizeWindowsPathForComparison,
safeStatSync,
} from "@openclaw/fs-safe/path";
/**
* Normalize a Windows path for boundary math whose result is handed back to callers.
*
* Unlike `normalizeWindowsPathForComparison`, this preserves case: `path.win32.relative`
* already matches roots case-insensitively, so lowercasing only corrupts the returned
* relative path — and callers create files from it on a case-preserving filesystem.
* Extended-length prefix stripping stays, or `\\?\`-prefixed inputs read as boundary escapes.
*/
export function normalizeWindowsPathPreservingCase(input: string): string {
// Mirrors normalizeWindowsPathForComparison step for step, minus the lowercasing,
// so the only behavior that shifts is the case of the characters handed back.
const normalized = path.win32.normalize(input).trim();
if (!normalized.startsWith("\\\\?\\")) {
return normalized;
}
const withoutPrefix = normalized.slice(4);
return withoutPrefix.toUpperCase().startsWith("UNC\\")
? `\\\\${withoutPrefix.slice(4)}`
: withoutPrefix;
}