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; +}