From 2f045a73f42c895c796446aaedb9b63311c666d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fit=20ERDO=C4=9EAN?= Date: Sun, 19 Jul 2026 00:58:59 +0300 Subject: [PATCH] fix(agents): agent-created files get lowercased names on Windows (#109823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * style(agents): apply oxfmt to the workspace path case test --------- Co-authored-by: Peter Steinberger --- .../agent-tools.workspace-paths.test.ts | 22 ++++++++++ src/agents/path-policy.test.ts | 40 +++++++++++++++++++ src/agents/path-policy.ts | 12 +++--- src/infra/path-guards.test.ts | 36 ++++++++++++++++- src/infra/path-guards.ts | 22 ++++++++++ 5 files changed, 126 insertions(+), 6 deletions(-) diff --git a/src/agents/agent-tools.workspace-paths.test.ts b/src/agents/agent-tools.workspace-paths.test.ts index b77b65e4755a..5dace3fc56eb 100644 --- a/src/agents/agent-tools.workspace-paths.test.ts +++ b/src/agents/agent-tools.workspace-paths.test.ts @@ -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) => { diff --git a/src/agents/path-policy.test.ts b/src/agents/path-policy.test.ts index 982ab63cb897..c07b1e24c1d0 100644 --- a/src/agents/path-policy.test.ts +++ b/src/agents/path-policy.test.ts @@ -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", () => { diff --git a/src/agents/path-policy.ts b/src/agents/path-policy.ts index 5819f9bd1b56..e0253938d272 100644 --- a/src/agents/path-policy.ts +++ b/src/agents/path-policy.ts @@ -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, diff --git a/src/infra/path-guards.test.ts b/src/infra/path-guards.test.ts index 945d8b12e62a..581ab99b69b7 100644 --- a/src/infra/path-guards.test.ts +++ b/src/infra/path-guards.test.ts @@ -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], diff --git a/src/infra/path-guards.ts b/src/infra/path-guards.ts index 2221b196744e..e96245af4e3c 100644 --- a/src/infra/path-guards.ts +++ b/src/infra/path-guards.ts @@ -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; +}