Skip to content

fix(root): report documented remove() failure codes - #84

Closed
Yigtwxx wants to merge 2 commits into
openclaw:mainfrom
Yigtwxx:fix/remove-failure-codes
Closed

fix(root): report documented remove() failure codes#84
Yigtwxx wants to merge 2 commits into
openclaw:mainfrom
Yigtwxx:fix/remove-failure-codes

Conversation

@Yigtwxx

@Yigtwxx Yigtwxx commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

What Problem This Solves

Fixes an issue where consumers calling Root.remove() would receive the boundary-violation code path-alias ("path is not under root") when the target was simply missing or the directory was simply not empty.

The affected surface is root confinement error reporting. removePathInRoot() routes every failure through normalizePinnedPathError(), which rewrites any non-FsSafeError into path-alias. Because removePathFallback() reaches the filesystem through plain fs.lstat / fs.rmdir / fs.rm, an ENOENT and an ENOTEMPTY both surface as boundary violations.

The documentation already specifies the intended behavior in six places:

  • docs/writing.md:130,135 and docs/writing.md:193,194 — non-empty directories throw not-empty, not-removable covers other unlink/rmdir failures
  • docs/errors.md:96,100 — same two codes, with not-found at docs/errors.md:98
  • docs/file-store.md:135 — "non-empty dirs throw not-empty"
  • docs/root.md:156not-found for a missing target

not-empty and not-removable are declared in the exported FsSafeErrorCode union (src/errors.ts:10,14) and listed in docs/types.md:156-157, but before this change they were constructed nowhere in src/ and asserted nowhere in test/. The documentation was already correct; the implementation was not.

Why This Change Was Made

A new normalizeRemovePathError() in src/root-errors.ts classifies the failure at the remove() call site: ENOENT/ENOTDIR to not-found, ENOTEMPTY/EEXIST to not-empty, and any other errno to not-removable. Anything that is not errno-shaped still falls back to normalizePinnedPathError(), so today's path-alias behavior is preserved for genuinely unclassifiable throws.

Three boundaries were deliberate:

  • Directory guard signals are untouched. createAsyncDirectoryGuard() and assertAsyncDirectoryGuard() already throw FsSafeError (not-file and path-mismatch), so the leading instanceof FsSafeError check returns them unchanged. TOCTOU identity drift still reports path-mismatch — that is covered by a unit assertion.
  • normalizePinnedPathError() itself is unchanged. mkdirPathInRoot() shares it and is out of scope for this PR, and test/edge-coverage.test.ts pins its current contract.
  • EEXIST maps to not-empty because POSIX permits rmdir to report EEXIST for a non-empty directory. The package already treats the pair as equivalent in src/move-path.ts:346 and src/trash.ts:12.

Compatibility: error codes are a public compatibility surface, and this is a deliberate behavior change. A consumer branching on path-alias around remove() will observe the new codes. Consumers written against the documented contract are fixed by this change; consumers written against the previous behavior were treating a routine ENOTEMPTY as a containment failure, which is the more dangerous of the two readings. No internal caller depends on the old code — src/file-store-prune.ts:89,95 is the only in-package consumer and it discards remove errors.

Non-goal: mkdirPathInRoot() shares the same normalizer and has its own error-shape questions. Left alone to keep this to one concern; happy to open a follow-up.

User Impact

Root.remove() and FileStore.remove() now report the codes their documentation already promised:

await fs.remove("missing.txt");        // not-found     (was: path-alias)
await fs.remove("snapshots/full-dir"); // not-empty     (was: path-alias)
// permissions, device busy, read-only fs -> not-removable (was: path-alias)

Directory identity drift during the remove still reports path-mismatch, unchanged. No API, option, default, or export changed; no migration is needed beyond widening a path-alias catch if one was written against the previous behavior.

Evidence

Reproduction on main before the fix, using the new regression test:

FAIL  test/fs-safe.test.ts > @openclaw/fs-safe > reports documented failure codes
      when remove cannot unlink the target
AssertionError: expected FsSafeError: path is not under root { …(3) } to match
object { code: 'not-found' }
- { "code": "not-found",
+ FsSafeError { "code": "path-alias", }
 ❯ test/fs-safe.test.ts:507:45

Regression coverage added:

  • test/fs-safe.test.ts — real-disk mkdtemp root exercising not-found for a missing target, not-found for a missing parent, and not-empty for a populated directory. Not platform-skipped: removePathInRoot() has no platform branch, so it runs on all six CI matrix legs.
  • test/edge-coverage.test.ts — unit assertions over the six errno mappings, the FsSafeError pass-through for path-mismatch, and the non-errno fallback to path-alias.

not-removable is covered at the unit level rather than end to end on purpose: forcing EACCES needs a chmod that is a no-op for root in container CI and is ignored on Windows, and forcing EBUSY only works on Windows. A platform-skipped end-to-end case would assert less than the unit test does on every platform.

Validation on Windows 11, Node v22.20.0, pnpm 10.34.5:

pnpm check
  lint:file-size    pass
  lint:fs-boundary  pass
  build             pass
  test              Test Files  54 passed | 6 skipped (60)
                    Tests  489 passed | 183 skipped (672)
  check-pack.mjs    exit 0

pnpm test:security  Test Files  5 passed (5)
                    Tests  46 passed | 17 skipped (63)

targeted           Tests  21 passed | 13 skipped (34)
                   (test/fs-safe.test.ts, test/edge-coverage.test.ts;
                    19 passed on main before the two new tests)
  • Tests added or updated when behavior changed
  • Security and compatibility impact considered
  • CHANGELOG.md updated when release-relevant
  • No credentials, private paths, private hosts, or sensitive contents included

Update — e96eb9c

The errno mapping wrapped the whole fallback, and the parent-directory guard runs inside it, so a raw guard error such as ELOOP became not-removable although no deletion was attempted.

The guard stage now sits in prepareRemoveGuard() with its own normalizer, and the errno mapping wraps only the lstat/rmdir/rm calls on the target. removePathInRoot() is back to normalizePinnedPathError(), unchanged from main.

normalizeRemoveGuardError() keeps FsSafeError unchanged, maps ENOENT/ENOTDIR to not-found — the parent does not exist, so the target is definitionally absent and nothing mutated — and fails closed with path-alias for every other raw error.

Pre-fix, on head 44cc206 with the new regression:

FAIL  test/edge-coverage.test.ts > root error helpers >
      does not map a guard-stage filesystem failure to a removal code
AssertionError: expected FsSafeError: path could not be removed { …(3) } to match
object { code: 'path-alias' }
- Expected
+ Received
- {
-   "code": "path-alias",
+ FsSafeError {
+   "code": "not-removable",

Coverage added: an end-to-end case driving an errno-shaped guard failure through the existing beforeRootFallbackMutation hook, so it is deterministic on all six CI legs, plus unit assertions over every branch of the new normalizer.

Validation on Windows 11, Node v22.20.0:

lint:file-size    pass
lint:fs-boundary  pass
tsc --noEmit      pass
vitest run        Test Files  54 passed | 6 skipped (60)
                  Tests  491 passed | 183 skipped (674)
test:security     Test Files  5 passed (5)
                  Tests  46 passed | 17 skipped (63)

Root.remove() routed every failure through normalizePinnedPathError, which
rewrites any non-FsSafeError into path-alias "path is not under root". A
missing target and a non-empty directory were both reported as boundary
violations, and the documented not-empty and not-removable codes were never
constructed anywhere in the package.

Map the syscall failures at the remove call site instead: ENOENT/ENOTDIR to
not-found, ENOTEMPTY/EEXIST to not-empty, and any other errno to
not-removable. Directory guard failures are already FsSafeError instances,
so path-mismatch and not-file pass through untouched.
@Yigtwxx
Yigtwxx requested a review from a team as a code owner August 2, 2026 07:29
@clawsweeper clawsweeper Bot added proof: sufficient Contributor real behavior proof is sufficient. 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. P2 Normal priority bug or improvement with limited blast radius. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. labels Aug 2, 2026
@clawsweeper

clawsweeper Bot commented Aug 2, 2026

Copy link
Copy Markdown

Codex review: needs maintainer review before merge. Reviewed August 2, 2026, 2:20 PM ET / 18:20 UTC.

ClawSweeper review

What this changes

The branch maps Root.remove() target deletion errors to not-found, not-empty, or not-removable, while keeping parent-directory guard failures as existing boundary errors.

Merge readiness

⚠️ Ready for maintainer review - 4 items remain

This PR is a focused correction to the documented remove() error contract and its revised guard boundary preserves fail-closed handling. The remaining merge question is intentional compatibility acceptance: callers that caught the previous path-alias result for routine deletion failures will now receive the documented operational codes.

Priority: P2
Reviewed head: e96eb9c4b267f391c79855f0fec68b56517bfe45
Owner decision: Required. See Decision needed.

Review scores

Measure Result What it means
Overall readiness 🦞 diamond lobster (5/6) Focused source changes, load-bearing regression coverage, and after-fix real-run evidence support a strong patch; public compatibility acceptance remains a maintainer decision.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (terminal): The PR provides before/after terminal evidence from a Windows setup, including a real-disk regression against current main and after-fix pnpm check and security-test results.
Patch quality 🦞 diamond lobster (5/6) No actionable review findings were identified.

Verification

Check Result Evidence
Real behavior Verified Sufficient (terminal): The PR provides before/after terminal evidence from a Windows setup, including a real-disk regression against current main and after-fix pnpm check and security-test results.
Evidence reviewed 5 items Current-main defect: On current main, removePathFallback() performs lstat and rmdir/rm directly, while removePathInRoot() catches every raw fallback error and passes it to normalizePinnedPathError(), which converts non-FsSafeError failures to path-alias. Thus ordinary ENOENT and ENOTEMPTY outcomes cannot surface as their documented removal codes.
Documented public contract: Current documentation explicitly defines not-empty for remove() on a populated directory, not-found for missing targets or parents, and not-removable for other unlink/rmdir failures; the exported union contains all three codes.
Guard safety preserved: The proposed prepareRemoveGuard() isolates guard creation and revalidation, normalizes only missing-parent errors to not-found, and leaves other raw guard errors on the pre-existing fail-closed path-alias path. The target-operation normalizer wraps only lstat and rmdir/rm; the new tests cover both boundaries.
Findings None None.
Security None None.

How this fits together

Root.remove() deletes a path through a capability-style root, first resolving it under the root and pinning the parent directory’s identity. The parent guard protects confinement; only after it passes does the fallback inspect and delete the requested target, returning a public error code to the caller.

flowchart TD
  A[Caller requests removal] --> B[Resolve target under root]
  B --> C[Pin parent directory identity]
  C --> D{Parent guard succeeds?}
  D -->|No| E[Fail-closed boundary error]
  D -->|Yes| F[Inspect and remove target]
  F --> G[Map target syscall failure]
  G --> H[Public remove result]
Loading

Decision needed

Question Recommendation
Should the package treat the documented remove() error codes as the authoritative public contract even though callers written against the prior implementation may be catching path-alias? Accept the documented error contract: Merge the focused correction and describe that ordinary deletion failures now return the documented operational codes rather than path-alias.

Why: The patch is mechanically narrow and preserves the guard fail-closed boundary, but selecting which externally observable error contract takes precedence is a maintainer compatibility decision.

Before merge

  • Resolve merge risk (P1) - Merging deliberately changes an existing public runtime outcome: consumers that catch path-alias around remove() for missing or non-empty targets will instead receive not-found or not-empty.
  • Resolve merge risk (P1) - This code sits on a filesystem-confinement boundary; any future widening of deletion errno mapping beyond target syscalls could misreport an identity or guard failure as an ordinary removal outcome.
  • Complete next step (P2) - No mechanical blocker remains; a root-confinement owner should explicitly accept the public error-code compatibility change before merge.
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Changed surface 5 files affected; 129 added, 5 removed The patch is constrained to removal error handling, two focused regression suites, and the unreleased changelog.
Regression coverage 2 test files changed; 4 new behavioral scenarios Coverage exercises missing target, missing parent, non-empty directory, and a deterministic guard-stage raw errno failure.

Merge-risk options

Maintainer options:

  1. Accept the documented contract correction (recommended)
    Merge after explicitly accepting that remove() callers may need to handle not-found, not-empty, and not-removable instead of the legacy path-alias result.
  2. Retain legacy behavior
    Pause this PR if maintainers consider the established runtime result more important than the documented error-code contract.

Technical review

Best possible solution:

Adopt the documented removal-error contract with the guard/deletion separation in this branch, and call out the legacy path-alias catch behavior in release review so downstream consumers can adjust deliberately.

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

Yes. Current main routes raw fallback ENOENT and ENOTEMPTY errors through normalizePinnedPathError(), which produces path-alias; the PR’s real-disk regression scenarios directly exercise those paths.

Is this the best way to solve the issue?

Yes, subject to the compatibility decision. Separating parent-directory guard errors from target deletion syscall errors is the narrowest maintainable repair and retains fail-closed behavior for ambiguous guard failures.

AGENTS.md: found and applied where relevant.

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

Labels

Label changes:

  • add rating: 🦞 diamond lobster: Overall readiness is 🦞 diamond lobster; proof is 🦞 diamond lobster and patch quality is 🦞 diamond lobster. Replaced prior rating: 🐚 platinum hermit.
  • remove rating: 🐚 platinum hermit: Current PR rating is rating: 🦞 diamond lobster, so this older rating label is no longer current.

Label justifications:

  • P2: This is a bounded public error-contract bug affecting callers of Root.remove() and FileStore.remove().
  • merge-risk: 🚨 compatibility: Existing consumers that branch on the accidental path-alias result will observe documented operational error codes after upgrade.
  • merge-risk: 🚨 security-boundary: The affected code translates failures adjacent to parent-directory identity checks that enforce root confinement.
  • rating: 🦞 diamond lobster: Overall readiness is 🦞 diamond lobster; proof is 🦞 diamond lobster and patch quality is 🦞 diamond lobster. Replaced prior rating: 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (terminal): The PR provides before/after terminal evidence from a Windows setup, including a real-disk regression against current main and after-fix pnpm check and security-test results.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR provides before/after terminal evidence from a Windows setup, including a real-disk regression against current main and after-fix pnpm check and security-test results.

Evidence

What I checked:

  • Current-main defect: On current main, removePathFallback() performs lstat and rmdir/rm directly, while removePathInRoot() catches every raw fallback error and passes it to normalizePinnedPathError(), which converts non-FsSafeError failures to path-alias. Thus ordinary ENOENT and ENOTEMPTY outcomes cannot surface as their documented removal codes. (src/root-impl.ts:978, 2477f5681f68)
  • Documented public contract: Current documentation explicitly defines not-empty for remove() on a populated directory, not-found for missing targets or parents, and not-removable for other unlink/rmdir failures; the exported union contains all three codes. (docs/errors.md:96, 2477f5681f68)
  • Guard safety preserved: The proposed prepareRemoveGuard() isolates guard creation and revalidation, normalizes only missing-parent errors to not-found, and leaves other raw guard errors on the pre-existing fail-closed path-alias path. The target-operation normalizer wraps only lstat and rmdir/rm; the new tests cover both boundaries. (src/root-impl.ts:1326, e96eb9c4b267)
  • Security-boundary provenance: The current fallback guard and normalizer are attributed on current main to the v0.5.1 release commit, while earlier repository history identifies Peter Steinberger’s guarded-parent and pinned-path boundary work as the surrounding design provenance. (src/root-impl.ts:1327, 16e1bd489ae8)
  • Release and main provenance: The candidate fix commit is not contained by any checked-out release tag or current main; it remains an open PR against main. The latest release v0.5.1 is tagged at the pre-fix release commit, so this work requires normal merge and release review rather than an implemented-on-main close. (CHANGELOG.md:1, e96eb9c4b267)

Likely related people:

  • steipete: Current-main blame attributes the relevant fallback and error normalizer to the v0.5.1 release commit, and earlier commits by Peter Steinberger established guarded parent mutations and pinned-path boundary behavior. (role: root-confinement and guarded-fallback history owner; confidence: high; commits: 16e1bd489ae8, ee0eb18a6dc9, 36dd0888a38a; files: src/root-impl.ts, src/root-errors.ts, src/directory-guard.ts)
  • Yuval Dinodia: Commit 0112fa7 is recent history for writable-parent guard behavior, which is adjacent to the parent-directory identity boundary this PR separates from deletion error handling. (role: adjacent root-guard contributor; confidence: medium; commits: 0112fa729449; files: src/root-impl.ts, src/directory-guard.ts)

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
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.

Workflow

  • 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.

History

Review history (8 earlier review cycles)
  • reviewed 2026-08-02T07:33:50.689Z sha 44cc206 :: needs maintainer review before merge. :: none
  • reviewed 2026-08-02T09:40:31.078Z sha 44cc206 :: needs maintainer review before merge. :: none
  • reviewed 2026-08-02T10:56:39.986Z sha 44cc206 :: needs maintainer review before merge. :: none
  • reviewed 2026-08-02T12:46:58.364Z sha 44cc206 :: needs maintainer review before merge. :: none
  • reviewed 2026-08-02T14:31:03.418Z sha 44cc206 :: needs changes before merge. :: [P2] Keep directory-guard failures out of removal errno mapping
  • reviewed 2026-08-02T16:40:38.463Z sha 44cc206 :: needs changes before merge. :: [P2] Preserve directory-guard failures before error remapping
  • reviewed 2026-08-02T17:36:25.764Z sha 44cc206 :: needs changes before merge. :: [P2] Keep directory-guard failures out of remove errno mapping
  • reviewed 2026-08-02T18:00:51.228Z sha e96eb9c :: needs maintainer review before merge. :: none

@Yigtwxx

Yigtwxx commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

The two Windows check legs are red. I looked into it before assuming it was mine, and it is a pre-existing flake rather than a regression from this branch.

What failed here:

FAIL test/new-primitives.test.ts > file locks
     > supports manager lifecycle and top-level withFileLock

That exact test also fails on main. It went red in the release: 0.5.1 (#80) run, alongside file-lock-reentrancy.test.ts > continues to arbitrate with a separate process.

test/fs-safe.test.ts, which carries this PR's regression test, passed on the same Windows leg (26 tests | 12 skipped).

ci.yml history on main, most recent first:

run result failing test
#83 feat(filename) failure json.test.ts > recovers readJson from a concurrent real atomic rewrite (ubuntu)
#82 chore(release) failure new-primitives.test.ts > secure file reads > reads from a validated Windows ACL and owner
#80 release: 0.5.1 failure new-primitives.test.ts > file locks > supports manager lifecycle and top-level withFileLock — the same test failing here — plus file-lock-reentrancy.test.ts > continues to arbitrate with a separate process
#79 chore(deps) failure file-lock-reentrancy.test.ts > queues a absent owner so both read-modify-write operations land
#78 success last green run on main

A different test fails almost every run, across both Windows and ubuntu, and all of them are concurrency or file-handle timing cases. On my Windows machine file-lock-reentrancy.test.ts failed once with EPERM on a .lock file and then passed 4/4 in isolation, which matches the same pattern.

This change cannot reach that code: normalizeRemovePathError() is only called from removePathInRoot(), and src/sidecar-lock.ts / src/file-lock.ts do not import anything from src/root-errors.ts. The green legs on this PR (ubuntu 22/24, macOS 22/24, all native checks, CodeQL, package smoke) exercise the new code paths.

I cannot re-run the job myself — that needs repository admin rights — so if you would like a clean run before reviewing, a maintainer re-run or a rebase once main is green would both work; happy to do the rebase.

If the flakes are worth chasing separately, I am glad to open an issue with the run links collected above rather than expanding this PR.

@clawsweeper clawsweeper Bot added rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. 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. merge-risk: 🚨 security-boundary 🚨 Merging this PR could weaken sandboxing, authorization, credentials, or sensitive data. and removed rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. 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. labels Aug 2, 2026
removePathFallback() creates and asserts the parent-directory guard before
it touches the target, so wrapping the whole call in the remove normalizer
turned a raw guard error such as ELOOP into not-removable even though no
deletion was attempted.

Scope the errno mapping to the deletion syscalls and give the guard stage
its own normalizer: FsSafeError passes through, ENOENT/ENOTDIR still report
not-found because the target is definitionally absent and nothing mutated,
and every other raw error keeps the fail-closed path-alias contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1kwRo32A6sGU1tbMFEFk
@Yigtwxx

Yigtwxx commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Pushed e96eb9c for the guard-boundary finding. It is real: the errno mapping was applied to the whole fallback, and the parent-directory guard runs inside it.

What was wrong

removePathFallback() creates the guard, fires the test hook, and asserts the guard before it touches the target:

createAsyncDirectoryGuard(dirname)   <- lstat + realpath
assertAsyncDirectoryGuard(guard)     <- lstat + realpath
lstat(target) -> rmdir | rm          <- the only deletion

createAsyncDirectoryGuard() and assertAsyncDirectoryGuard() only convert known unsafe states into FsSafeError (not-file, path-mismatch). Any other raw errno from those lstat/realpath calls escaped, and with one outer normalizeRemovePathError() it became not-removable — reporting a failed deletion when no deletion was attempted, and losing the fail-closed reading of an unresolved identity condition.

What changed

The guard stage moved into prepareRemoveGuard() with its own normalizer, and the errno mapping now wraps only the deletion calls. removePathInRoot() is back to normalizePinnedPathError(), unchanged from main; everything below it already throws FsSafeError, so it is a pass-through.

normalizeRemoveGuardError():

From the guard stage Code Why
FsSafeError unchanged not-file, path-mismatch keep their meaning
ENOENT, ENOTDIR not-found the parent does not exist, so the target is definitionally absent and nothing mutated — this is docs/root.md:156
any other errno, non-errno path-alias fail closed, exactly as on main

The ENOENT/ENOTDIR carve-out is the one place the guard stage still maps to a documented code. It cannot mask an identity condition: identity drift and non-directory components already arrive as FsSafeError and return unchanged above that branch. It is also what keeps remove("missing-dir/missing.txt") on the documented not-found rather than reporting a boundary violation for an ordinary missing parent.

Evidence

The new end-to-end regression throws an errno-shaped ELOOP from the guard stage. On the previous head:

FAIL  test/edge-coverage.test.ts > root error helpers >
      does not map a guard-stage filesystem failure to a removal code
AssertionError: expected FsSafeError: path could not be removed { …(3) } to match
object { code: 'path-alias' }
- Expected
+ Received
- {
-   "code": "path-alias",
+ FsSafeError {
+   "code": "not-removable",

That is the finding reproduced: a guard-originated raw error surfacing as not-removable. After the fix it is path-alias, and the test also asserts the target file is still on disk.

Coverage added to test/edge-coverage.test.ts:

  • the end-to-end case above, driven through the existing beforeRootFallbackMutation hook so it is deterministic on all six CI legs rather than needing a real ELOOP/EACCES, which is unforgeable as root in container CI and on Windows;
  • unit assertions over normalizeRemoveGuardError()FsSafeError pass-through, ENOENT/ENOTDIR to not-found, and ELOOP/EACCES/EBUSY/ENOTEMPTY/EEXIST plus a non-errno throw all to path-alias. ENOTEMPTY and EEXIST are in that list on purpose: they are removal outcomes, so from the guard stage they are nonsense and must fail closed rather than be reported as not-empty.

CHANGELOG.md now states the boundary alongside the code change.

Validation on Windows 11, Node v22.20.0:

lint:file-size    pass
lint:fs-boundary  pass
tsc --noEmit      pass
vitest run        Test Files  54 passed | 6 skipped (60)
                  Tests  491 passed | 183 skipped (674)
test:security     Test Files  5 passed (5)
                  Tests  46 passed | 17 skipped (63)

The pre-existing Windows lock flakes noted earlier did not reproduce in this run.

@clawsweeper clawsweeper Bot added 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. and removed 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. labels Aug 2, 2026
@Yigtwxx Yigtwxx closed this Aug 2, 2026
@Yigtwxx Yigtwxx reopened this Aug 2, 2026
@Yigtwxx

Yigtwxx commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

The red Windows leg here isn't from this diff — it's the flake family that's been on main since #79, and a re-run moved it.

I closed/reopened this PR to retrigger CI (I can't gh run rerun without admin). On identical code, the failing test changed:

Run Failing leg Failing test
First Windows, Node 22 move-path-regression.test.ts > publishes a fresh inode when hardlink rejection is enabled
Re-run Windows, Node 24 file-lock-reentrancy.test.ts > queues a absent owner so both read-modify-write operations land

Each time the other Windows Node version passed. A real defect fails the same leg every run; this one rotates.

It's also outside this PR's call graph. The diff touches src/root-errors.ts and src/root-impl.ts (remove() errno mapping only). movePathWithCopyFallback lives in src/move-path.ts, and file-lock-reentrancy doesn't reach either changed file.

For context on the first failure specifically — I did chase it before the re-run, in case it was a genuine identity bug. It isn't: with sourceHardlinks: "reject" the copy path stages .fs-safe-move-*.tmp while the source is still alive, so the staged file can't be handed the source's file ID, and copyEntryWithManifest bottoms out in copyRegularFilePinned with no link/clone path. I also ran the exact scenario 200× on a Windows box: 0 collisions. So the ino assertion looks sound and the failure is timing/environment, not semantics.

The dispatch job failure is unrelated infra — the ClawSweeper bot installation hit its own GitHub API rate limit (installation ID 122230863, HTTP 403), triggered by the pull_request_target hook on my reopen.

Happy to rebase once main is green if you'd prefer a clean run before merging.

@Yigtwxx

Yigtwxx commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Correction to my note above: I called the lock failures timing/environment. I chased them properly afterwards and they are a real, fixable defect — #87 has the root cause and the fix.

Windows denies access to a lock file while a just-unlinked directory entry is still being torn down, so a contended acquire() gets EPERM (errno -4048) on a name that is already gone. Instrumented at the moment of failure:

EPERM-DIAG {"attempt":2,"lstat":"ENOENT","siblings":["state.json"],"retryAfterMs":0}

lstat says ENOENT and a zero-delay retry opens the file. acquire() only treated EEXIST as contention, so the EPERM escaped from the exclusive create and from readSidecarLockSnapshot(), which maps only ENOENT to a vanished lock.

Two things I had wrong:

Measured on Windows 11 / Node 22 over file-lock-reentrancy + new-primitives: 8 failures in 85 runs before, 0 in 110 after.

That covers the file-lock-reentrancy and new-primitives > file locks legs. The move-path-regression > publishes a fresh inode failure is a different symptom and still open — my analysis of that one in the comment above still stands as far as I took it.

This PR's own diff is unrelated to any of it, so #87 is independent of #84.

steipete added a commit that referenced this pull request Aug 2, 2026
removePathInRoot() routed every non-FsSafeError through
normalizePinnedPathError(), so an ordinary ENOENT or ENOTEMPTY surfaced as the
boundary-violation code path-alias. The documented codes not-found, not-empty
and not-removable were declared in the exported union and documented in six
places but constructed nowhere.

The guard stage is separated from the errno mapping so a raw guard failure such
as ELOOP is not reported as a removal outcome, and the three codes are now
classified as operational rather than policy, so FsSafeError.category stops
describing routine filesystem outcomes as safety-policy rejections.

Supersedes #84.

Co-authored-by: Yigtwxx <yigiterdogan023@gmail.com>
@steipete

steipete commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Landed as 6af7f93 via #93 — thank you, and your commits are credited on the squash.

Your diagnosis and your second push were both right. removePathInRoot() collapsed every non-FsSafeError into path-alias, so the three documented codes were declared in the exported union, documented in six places, and constructed nowhere. Separating the guard stage in prepareRemoveGuard() so a raw ELOOP is not reported as a removal outcome was a genuinely good catch — that is the kind of thing that usually only turns up after the fix ships.

I added one thing before landing. src/errors.ts defines OPERATIONAL_CODES, and not-found, not-empty and not-removable were absent from it, so categorizeFsSafeError() was still giving all three category: "policy" — the category consumers read as "a safety policy rejected this". Since the whole point of the change is that a routine ENOTEMPTY is not a boundary violation, fixing the code without the category would only have got you half the way there. All three are now operational, and docs/types.md states the rule.

Worth knowing for the downstream bump: this breaks src/infra/fs-safe-remove.test.ts:66-74 in openclaw/openclaw, which asserts /ENOTEMPTY|EEXIST|EPERM/; it makes findPathAliasFilesystemCause() dead code, since that helper existed purely to unwrap the errno from the bogus path-alias; and it quietly fixes three latent bugs there where not-found and not-removable checks were unreachable.

The red Windows legs on this PR were never yours — that was the sidecar-lock teardown race, fixed separately in #92.

@steipete steipete closed this Aug 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. merge-risk: 🚨 security-boundary 🚨 Merging this PR could weaken sandboxing, authorization, credentials, or sensitive data. P2 Normal priority bug or improvement with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants