Skip to content

fix(run): resolve a run's cwd to one path spelling - #273

Merged
slowdini merged 1 commit into
devfrom
fix/canonical-workspace-root
Aug 15, 2026
Merged

fix(run): resolve a run's cwd to one path spelling#273
slowdini merged 1 commit into
devfrom
fix/canonical-workspace-root

Conversation

@slowdini

Copy link
Copy Markdown
Owner

The problem

POSIX gives this away for free: getcwd resolves symlinks, so a Unix process and everything it spawns already agree on how the working directory is spelled. Windows makes no such promise — it hands back whatever spelling the cwd was set with — and one directory there has several valid names: an 8.3 short name (RUNNER~1), a junction, a subst drive, a redirected profile.

Nothing forced the two sides of the write guard's comparison onto one of them.

Spawning each tool the way a harness is spawned (CreateProcess with an explicit short-form cwd — a shell in between contaminates the result, since Set-Location expands):

preserves the alias resolves it
node process.cwd(), node fs.realpathSync, cmd /c cd git — every path it prints, Rust fs::canonicalize, PowerShell Set-Location

Both spellings are reachable from inside a single task env, and is_under compares strings. Driving the real hook (eval-magic guard <marker>) with a short-form allowed root:

tool call verdict
write relative to a cwd spelled as the harness reports it allow ✔
write relative to a cwd spelled as git reports it deny
absolute target under the env, git's spelling deny
genuine escape outside the env deny ✔

The guard never got weaker — only falsely strict, and only on legitimate in-sandbox writes. Every task env is a git repo, so git rev-parse --show-toplevel is a one-step route to the refused spelling.

The fix

Resolve once, where a run's roots are derived, rather than at either end of the comparison. Canonicalising only the guard marker inverts the failure instead of removing it: with a resolved root and the alias the harness actually reports as its cwd, the same legitimate write is denied.

detect_run_context is that seam — every root descends from its cwd. absolutize routes through the same helper, so --workspace-dir can't reintroduce the split as a second entrance. One spelling becomes a property of the whole RunContext rather than of the one field someone remembered.

core::fs::real_path resolves the deepest existing ancestor and re-attaches the rest, because a run names directories before it creates them and the alias always lives in an ancestor, never in the leaf.

Test fixture change worth a look

Several tests/cli fixtures built their roots with a bare fs::canonicalize, which on Windows returns a \?\ verbatim path — a spelling no agent ever produces. They passed only because the old lexical code handed the prefix through untouched on both sides of the comparison. They now use the resolved helper that strips it.

Verification

cargo fmt --all -- --check                                   # exit 0
cargo clippy --all-targets --all-features -- -D warnings     # exit 0
cargo test --lib / --test run / --test cli

Run twice on Windows — once normally, once with %TEMP% pointed at an 8.3 short name, which reproduces the GitHub runner's C:\Users\RUNNER~1\...:

target normal %TEMP% 8.3 %TEMP%
--test run 127 / 0 127 / 0 (was 126 / 1)
--test cli 175 / 0 175 / 0
--lib 836 / 3 835 / 4

The --lib failures are pre-existing on dev and unrelated: clean dev under the identical 8.3 %TEMP% gives the same four (829 / 4 — the delta is the new tests here). They are the Windows jq CRLF bug and the long-path measurement, both already fixed on ci/windows-job. The pre-push hook was skipped for that reason; it should pass again once both branches land.

Notes

  • No schema, CLI, or documentation surface changes.
  • Unblocks the failing Test suite (windows-latest) check on ci: run the suite on windows-latest #272, whose sole failure was this. That branch needs a rebase once this merges.
  • The new tests use a junction on Windows rather than a symlink, so they need neither Developer Mode nor elevation — the platform this bug lives on stays covered on a stock dev box instead of silently skipping.

🤖 Generated with Claude Code

POSIX gives this away for free: `getcwd` resolves symlinks, so a Unix
process and everything it spawns already agree on how the working
directory is spelled. Windows makes no such promise — it hands back
whatever spelling the cwd was set with — and one directory there has
several valid names: an 8.3 short name, a junction, a `subst` drive, a
redirected profile.

Nothing forced the two sides of the write guard's comparison onto one of
them. Measured, spawning each tool the way a harness is spawned: node's
`process.cwd()`, node's `realpathSync`, and `cmd`'s `cd` all echo the
alias back, while git prints the resolved name for every path it emits.
Both spellings are therefore reachable from inside a single task env, and
`is_under` compares strings. Driving the hook with a short-form root, a
write relative to a long-form cwd is denied, as is an absolute long-form
target under the env — legitimate writes, refused. A genuine escape is
still denied, so the guard never got weaker, only falsely strict. Every
task env is a git repo, which makes `rev-parse --show-toplevel` a
one-step route to the refused spelling.

Resolve once, where the run's roots are derived, rather than at either
end of the comparison: canonicalising only the marker inverts the failure
instead of removing it, denying the same write when the cwd is the alias.
Every path in a `RunContext` now shares one spelling, which makes it a
property of the struct rather than of the one field someone remembered.

Resolution walks up to the deepest ancestor that exists and re-attaches
the rest, because a run names directories before it creates them and the
alias always lives in an ancestor, never in the leaf.

The cli fixtures that built roots with a bare `fs::canonicalize` now use
the helper that strips the verbatim prefix. They had been comparing
against `\?\` paths, a spelling no agent ever produces and one the CLI
no longer emits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@slowdini
slowdini merged commit 3fe5eb1 into dev Aug 15, 2026
7 checks passed
@slowdini
slowdini deleted the fix/canonical-workspace-root branch August 15, 2026 20:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant