Skip to content

fix(agents): agent-created files get lowercased names on Windows#109823

Merged
steipete merged 12 commits into
openclaw:mainfrom
Yigtwxx:fix/windows-agent-file-case
Jul 18, 2026
Merged

fix(agents): agent-created files get lowercased names on Windows#109823
steipete merged 12 commits into
openclaw:mainfrom
Yigtwxx:fix/windows-agent-file-case

Conversation

@Yigtwxx

@Yigtwxx Yigtwxx commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

What Problem This Solves

On Windows, files and directories an agent creates get lowercased names. Ask for
src/Components/MyComponent.tsx and what lands on disk is src\components\mycomponent.tsx.

NTFS is case-insensitive but case-preserving, so nothing fails locally — the agent reads its own file
back fine. The damage shows up downstream: git records the lowercased name, and the TypeScript/React
import the agent just wrote (./Components/MyComponent) resolves on the author's Windows box but breaks
for every teammate on Linux/macOS and in CI. Any agent-authored name that is not already lowercase is
affected: README.md, Dockerfile, MyClass.ts.

For some names it is worse than a case change. "İstanbul.md".toLowerCase() is "i̇stanbul.md"
U+0130 becomes the two code points U+0069 U+0307, one longer than the original and not reversible. So a
Turkish filename does not come back lowercased, it comes back corrupted.

This is Windows-only. The POSIX branch of the same function preserves case correctly today.

Why This Change Was Made

toRelativePathUnderRoot (src/agents/path-policy.ts:76-90) ran both root and candidate through
normalizeWindowsPathForComparison and then returned the resulting relative path:

const rootForCompare = normalizeWindowsPathForComparison(rootResolved);
const targetForCompare = normalizeWindowsPathForComparison(resolvedCandidate);
const relative = path.win32.relative(rootForCompare, targetForCompare);
return validateRelativePathWithinBoundary({ relativePath: relative, ... });

That helper lowercases (@openclaw/[email protected], dist/path.js:17
normalizeLowercaseStringOrEmpty(...)). Lowercasing is right for a comparison key — which is exactly
how the other two call sites use it — but this function hands the relative path back to callers, and they
build files out of it:

  • src/agents/agent-tools.read.ts:1089-1092mkdir: path.resolve(root, relative)fs.mkdir
  • src/agents/agent-tools.read.ts:1059-1060writeWorkspaceFile.write(relative, { mkdir: true })
  • src/agents/apply-patch.ts:310,318,326apply_patch writeFile / remove / mkdirp

toCanonicalRelativeWorkspacePath (agent-tools.read.ts:1167-1179) does not rescue it: fs.realpath
restores the true case only of the already-existing ancestor, the new basename stays lowercased, and
line 1179 re-lowercases the whole thing on the way out. assertSandboxPath does not either — it is
handed the already-derived resolved path, not the original candidate.

The lowercasing was also never needed for the boundary math. path.win32.relative already matches the
root case-insensitively and returns the tail in its original case, so the pre-lowercasing bought
nothing and cost the filename. The one part of that normalization the boundary check does rely on is
extended-length prefix stripping — without it a \\?\C:\... candidate relativizes to
..\..\..\?\C:\... and gets rejected as an escape. So this adds normalizeWindowsPathPreservingCase
next to the comparison variant in src/infra/path-guards.ts. It mirrors that helper step for step —
including the trim — minus the lowercasing, and a test pins that equivalence directly so the two cannot
drift:

expect(normalizeWindowsPathPreservingCase(input).toLowerCase()).toBe(
  normalizeWindowsPathForComparison(input),
);

Sibling surfaces

All three callers of normalizeWindowsPathForComparison were checked. Only this one was misusing it:

Call site Uses result as Verdict
src/plugins/installed-plugin-index-record-reader.ts:215 comparison key (===) correct, untouched
isPathInside (fs-safe) discards relative, returns boolean correct, untouched
src/agents/path-policy.ts returns it to callers fixed here

Evidence

Live before/after on real Windows (Windows 11, NTFS, Turkish locale), driving the real
toRelativeWorkspacePath through the exact call pattern of agent-tools.read.ts, then reading the names
back with fs.readdirSync:

Before (origin/main):

--- case 1: agent asked for src\Components\MyComponent.tsx ---
  toRelativeWorkspacePath -> "src\components\mycomponent.tsx"
  ON DISK                 -> components/mycomponent.tsx

--- case 2: agent asked for İstanbul.md ---
  asked   : İstanbul.md  | length 11 | U+0130
  ON DISK : i̇stanbul.md | length 12 | U+0069 U+0307
  identical? NO  <- filename corrupted, not just lowercased

After (this branch):

--- case 1: agent asked for src\Components\MyComponent.tsx ---
  toRelativeWorkspacePath -> "src\Components\MyComponent.tsx"
  ON DISK                 -> Components/MyComponent.tsx

--- case 2: agent asked for İstanbul.md ---
  asked   : İstanbul.md | length 11 | U+0130
  ON DISK : İstanbul.md | length 11 | U+0130 U+0073
  identical? YES

Why the existing test did not catch it: path-policy.test.ts:23 used an all-lowercase candidate
(c:/users/user/openclaw/memory/log.txt), which passes either way. The new cases use mixed case.

Tests — every added mixed-case assertion fails on origin/main and passes here:

path-policy.test.ts + path-guards.test.ts        41/41 passed
+ agent-tools.workspace-paths, apply-patch,
  installed-plugin-index-record-reader            111 passed | 12 skipped, 0 failed
max-lines ratchet                                 OK
dead-export ratchet (knip)                        0 entries
oxlint / oxfmt --check                            clean
tsgo -p tsconfig.core.json                        no errors in touched files

Merge Risk

Low, but this touches a sandbox boundary, so here is the containment argument explicitly.

The boundary decision is unchanged. path.win32.relative compares case-insensitively, so
relative(lower(a), lower(b)) and relative(a, b) return the same structure — same number of ..
segments, same segment count, same absoluteness. Only the case of the returned characters differs.
validateRelativePathWithinBoundary looks at exactly that structure (""/".", "..", "../",
"..\\", isAbsolute), so every accept/reject verdict is identical. Verified empirically:

root: C:\Users\User\OpenClaw
escape, no lowercasing        -> "..\Other\log.txt"     still rejected
root itself, differing case   -> ""                     allowRoot still honored
drive-letter case c: vs C:    -> "Memory\Log.txt"       still matched
\\?\ prefix NOT stripped      -> "..\..\..\?\C:\..."    why stripping is kept

Two of the added tests pin this directly — an escape that differs from the root only by case is still
rejected, and a differently-cased root still resolves to "" under allowRoot. Both pass before and
after the change, which is the point.

Scope is one file's Windows branch plus one new helper with a single caller. POSIX behavior is untouched.

AI-assisted.

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.
@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: S labels Jul 17, 2026
@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. P1 High-priority user-facing bug, regression, or broken workflow. merge-risk: 🚨 security-boundary 🚨 May affect sandboxing, authorization, credentials, or sensitive data. labels Jul 17, 2026
@clawsweeper

clawsweeper Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed July 18, 2026, 5:44 PM ET / 21:44 UTC.

Summary
The branch replaces lowercasing Windows workspace-path normalization with a case-preserving variant and adds mixed-case, Unicode, extended-prefix, containment, and end-to-end workspace-write tests.

PR surface: Source +24, Tests +96. Total +120 across 5 files.

Reproducibility: yes. from source and provided live Windows output: the current path helper returns a relative path derived from a lowercasing comparison key, and the branch demonstrates the exact write path retaining mixed-case and Unicode names after the change.

Review metrics: none identified.

Merge readiness
Overall: 🦐 gold shrimp
Proof: 🦞 diamond lobster
Patch quality: 🦐 gold shrimp
Result: needs maintainer review before merge.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Rank-up moves:

  • Get an explicit decision on whether the case-preserving normalizer belongs in fs-safe or in OpenClaw's infra facade.
  • If core ownership is accepted, document the dependency-contract tracking expectation in the implementation review.

Risk before merge

  • [P1] normalizeWindowsPathPreservingCase mirrors dependency-owned normalization logic in core; a future fs-safe change to extended-prefix or whitespace handling could make agent boundary behavior drift from the shared filesystem-security contract.

Maintainer options:

  1. Move normalization ownership to fs-safe (recommended)
    Expose a tested case-preserving normalizer from the dependency and keep OpenClaw as a thin consumer so security normalization cannot drift.
  2. Accept the core adapter explicitly
    Merge the core helper only after an owner accepts the duplicated normalization contract and its ongoing dependency-tracking responsibility.

Next step before merge

  • [P2] The branch has no discrete mechanical defect to dispatch; a human must choose the long-term owner of the case-preserving Windows normalization contract before merge.

Maintainer decision needed

  • Question: Should OpenClaw keep a core-owned copy of fs-safe's Windows path-normalization steps for this case-preserving return-path use case, or should fs-safe expose and own that contract?
  • Rationale: The patch is narrowly scoped and has strong Windows proof, but it creates a parallel normalization implementation on an agent workspace security boundary; this is an ownership and long-term contract choice rather than a mechanical correctness fix.
  • Likely owner: steipete — They authored the current end-to-end coverage, are assigned to the PR, and are best positioned to decide the agent-runtime versus fs-safe ownership boundary.
  • Options:
    • Expose the dependency contract (recommended): Add a case-preserving Windows normalization primitive to @openclaw/fs-safe and have OpenClaw consume it through its existing infra facade.
    • Accept a core-owned adapter: Keep the helper in src/infra/path-guards.ts only with an explicit reviewed ownership decision and tests that track all relevant dependency normalization cases.

Security
Needs attention: No exploit is identified, but this security-boundary patch duplicates fs-safe path-normalization behavior and needs an explicit contract owner before merge.

Review details

Best possible solution:

Choose one owned case-preserving Windows normalization contract—preferably expose it from @openclaw/fs-safe and consume it through src/infra/path-guards.ts—then retain the mixed-case and escape-regression coverage before merge.

Do we have a high-confidence way to reproduce the issue?

Yes, from source and provided live Windows output: the current path helper returns a relative path derived from a lowercasing comparison key, and the branch demonstrates the exact write path retaining mixed-case and Unicode names after the change.

Is this the best way to solve the issue?

Unclear. Preserving the returned filename case is the right behavioral repair, but duplicating fs-safe normalization internals in core is not clearly the best durable ownership model for this security-sensitive path contract.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against fa9ae68c3423.

Label changes

Label changes:

  • add rating: 🦐 gold shrimp: Overall readiness is 🦐 gold shrimp; proof is 🦞 diamond lobster and patch quality is 🦐 gold shrimp.
  • add status: ⏳ waiting on author: ClawSweeper has contributor-facing work open and is waiting for author action. Sufficient (live_output): The PR body contains a concrete real-Windows before/after run through the workspace write path, including on-disk directory listings for mixed-case and Turkish-Unicode names; no additional contributor proof is required.
  • remove rating: 🐚 platinum hermit: Current PR rating is rating: 🦐 gold shrimp, so this older rating label is no longer current.
  • remove status: 👀 ready for maintainer look: Current PR status label is status: ⏳ waiting on author.

Label justifications:

  • P1: A Windows agent file write can preserve incorrect casing into Git and break case-sensitive Linux, macOS, or CI consumers.
  • merge-risk: 🚨 security-boundary: The patch changes Windows path normalization immediately before the workspace containment decision and introduces a parallel implementation of dependency behavior.
  • rating: 🦐 gold shrimp: Overall readiness is 🦐 gold shrimp; proof is 🦞 diamond lobster and patch quality is 🦐 gold shrimp.
  • status: ⏳ waiting on author: ClawSweeper has contributor-facing work open and is waiting for author action. Sufficient (live_output): The PR body contains a concrete real-Windows before/after run through the workspace write path, including on-disk directory listings for mixed-case and Turkish-Unicode names; no additional contributor proof is required.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body contains a concrete real-Windows before/after run through the workspace write path, including on-disk directory listings for mixed-case and Turkish-Unicode names; no additional contributor proof is required.
Evidence reviewed

PR surface:

Source +24, Tests +96. Total +120 across 5 files.

View PR surface stats
Area Files Added Removed Net
Source 2 29 5 +24
Tests 3 97 1 +96
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 5 126 6 +120

Security concerns:

  • [medium] Choose one owner for Windows path normalization — src/infra/path-guards.ts:12
    normalizeWindowsPathPreservingCase copies the dependency's prefix-stripping and trimming behavior in core. If fs-safe changes its normalization contract, containment behavior can diverge between OpenClaw and the shared filesystem boundary.
    Confidence: 0.86

What I checked:

  • Changed boundary path: The branch changes toRelativePathUnderRoot to derive its returned Windows relative path from a case-preserving normalizer, while retaining the existing relative-path boundary validation; the returned value feeds agent file operations. (src/agents/path-policy.ts:76, b59a85f0d8f7)
  • Concrete regression coverage: The branch adds Windows-only end-to-end coverage that writes Source/İstanbul/Widget.ts and verifies the directory entries retain their original case and Unicode spelling. (src/agents/agent-tools.workspace-paths.test.ts:121, b59a85f0d8f7)
  • Security-boundary ownership: The new core helper reproduces the dependency normalizer's extended-prefix and trimming behavior without lowercasing. fs-safe documents its root-bounded path primitives as the shared security boundary, so the copied normalization contract needs an explicit owner before merge. (fs-safe.io) (src/infra/path-guards.ts:12, b59a85f0d8f7)
  • Recent feature-history signal: The PR history includes test(agents): verify Windows filename case end to end, authored by steipete and co-authored by the contributor, making steipete the strongest current routing candidate for this agent-path behavior. (src/agents/agent-tools.workspace-paths.test.ts:121, 614b1f9cdd26)
  • Review continuity: Nine prior ClawSweeper cycles reported no line-level correctness findings; the remaining stated action is a maintainer-owned security-boundary seam decision, not a newly discovered patch defect. (83abf1b5456d)

Likely related people:

  • steipete: Authored the branch's end-to-end Windows filename-case coverage and is assigned to this PR, connecting them to the current behavior and the remaining boundary decision. (role: recent area contributor and assigned reviewer; confidence: high; commits: 614b1f9cdd26; files: src/agents/agent-tools.workspace-paths.test.ts, src/agents/path-policy.ts)
  • eleqtrizit: Release history credits this person for the earlier OpenClaw migration of agent workspace-file operations to shared fs-safe helpers, making them relevant to the dependency-boundary question. (role: adjacent fs-safe integration contributor; confidence: medium; files: src/infra/path-guards.ts)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

How this review workflow works
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.
Review history (9 earlier review cycles; latest 8 shown)
  • reviewed 2026-07-17T08:59:11.733Z sha 95bb741 :: needs maintainer review before merge. :: none
  • reviewed 2026-07-17T09:12:02.379Z sha b927ca9 :: needs maintainer review before merge. :: none
  • reviewed 2026-07-17T09:36:34.901Z sha 6659576 :: needs maintainer review before merge. :: none
  • reviewed 2026-07-17T18:32:55.320Z sha 2e8b73d :: needs maintainer review before merge. :: none
  • reviewed 2026-07-18T11:00:11.292Z sha dc8286f :: needs maintainer review before merge. :: none
  • reviewed 2026-07-18T20:13:48.899Z sha 1b21827 :: needs maintainer review before merge. :: none
  • reviewed 2026-07-18T21:13:08.155Z sha 614b1f9 :: needs maintainer review before merge. :: none
  • reviewed 2026-07-18T21:29:40.728Z sha 83abf1b :: needs maintainer review before merge. :: none

@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. and removed rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. labels Jul 17, 2026
@steipete steipete self-assigned this Jul 18, 2026
@clawsweeper clawsweeper Bot added rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Jul 18, 2026
@Yigtwxx

Yigtwxx commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

On the P1 drift risk — the ownership question is genuinely yours to decide, but one input for it: the
drift this describes cannot happen silently today, because the contract is already pinned by a test that
calls the dependency directly.

path-guards.ts re-exports normalizeWindowsPathForComparison straight from @openclaw/fs-safe/path,
and path-guards.test.ts:42 asserts the two normalizers agree modulo case:

expect(normalizeWindowsPathPreservingCase(input).toLowerCase()).toBe(
  normalizeWindowsPathForComparison(input),
);

Since the right-hand side is fs-safe's implementation, an fs-safe change to extended-prefix or
whitespace handling breaks this assertion rather than quietly diverging. Verified by mutating the
installed dependency — disabling the \\?\ prefix strip in
node_modules/@openclaw/fs-safe/dist/path.js:

× matches the comparison variant except for case
    AssertionError: expected 'c:\users\peter\repo' to be '\\?\c:\users\peter\repo'

  (plus 3 more: the tests that pin fs-safe's own prefix/UNC behavior)

So the failure mode is a red CI on the next dependency bump, not a silent security-boundary drift.

The honest limit: that guard is only as wide as its six inputs (extended-length, UNC upper/lower, mixed
separators, trailing and leading whitespace). If fs-safe grows a new normalization step that none of
those inputs exercise, the assertion would still pass. So it protects against changes to the steps being
mirrored, not against additions.

That does not settle the ownership question — if you'd rather @openclaw/fs-safe expose a case-preserving
primitive and keep OpenClaw a thin consumer, that is strictly better long-term and I'm happy to prepare
that instead, either here or as a dependency-side change first. I'd just rather not guess which side you
want owning it.

@steipete
steipete merged commit 2f045a7 into openclaw:main Jul 18, 2026
136 of 142 checks passed
@steipete

Copy link
Copy Markdown
Contributor

Merged via squash.

@Yigtwxx
Yigtwxx deleted the fix/windows-agent-file-case branch July 18, 2026 22:07
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request Jul 19, 2026
…nclaw#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 <[email protected]>

* style(agents): apply oxfmt to the workspace path case test

---------

Co-authored-by: Peter Steinberger <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling merge-risk: 🚨 security-boundary 🚨 May affect sandboxing, authorization, credentials, or sensitive data. P1 High-priority user-facing bug, regression, or broken workflow. proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. size: S status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants