mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user