Repository navigation
fix(workspace): a nested mount is its directory wherever disk puts it - #535
Conversation
…, not only where a path names it The nested-mount checks (anima-research#460) read paths and roots lexically, so an outer mount reached an inner mount's directory three ways it couldn't see: - A root configured through a symlink, or beneath one, wasn't nested at all. The outer mount's sync took the inner mount's files in, and a read of `outer/a/b/x.txt` returned the inner's file. That held both for an inner mount configured through a link into the outer one, and for an outer mount configured through a link to the directory holding the inner one. - On a mount that follows symlinks, a link into the inner mount's directory passed every check. A read through it returned the inner's file and stored it as the outer's. A forced push through a directory link wrote a new file into the inner mount, and a forced unlink removed the inner's file. A forced push through a file link overwrote the inner's file. - On a mount that doesn't follow symlinks, anima-research#468 refuses every push, unlink and disk read through such a link. A write through it still stored a draft that no push could write. The changes: - Each mount's root is resolved once, at construction (realRoot, realpathSync.native, the same resolution as the async realpath the checks use). A root not made yet takes its nearest existing directory's real path, with the rest as configured. A mount is nested in another where its configured path or its real root lies beneath the other's, and each way it lies there is a spelling of its directory (anima-research#525's model), passed over by every walk and push and refused by the tools' lexical check. spellNestedDirs respells each spelling found at construction (`fromRoots`), not only the first. - MountView's nested mounts carry their real root. Every check that resolves a path canonically also asks nestedAtReal: locateDir, parentPlacement, observePath's followed link and openContained in observe.ts; checkOpened and unlinkContained's followed link in effects.ts; and openContainedFile in index.ts. A path whose real location is in a nested mount's directory is refused there, as `"work/l" leads into mount "inner", nested in mount "work": use "inner/sub" in its place`. createDir asks it before each mkdir, so no directory is made where a nested root, made or not, would be (Greptile's G3 on anima-research#282). - parseFilePath, on a mount with a mount nested in it, resolves the path (its nearest existing directory's real path, with the rest as given) and refuses one that lands in a nested mount's directory before anything is stored. On a mount that follows links the refusal is NestedMountError's, with "leads into" in place of "lies in". On one that doesn't, it's the link's own refusal (linkWords, as anima-research#468's readers and pushes give it), with its code. It runs before the mount's turn, since the checks where disk is acted on are the boundary (Ada-1017, room-203 #145666), and a mount with nothing nested skips it. - readFileFromDisk passes a WorkspaceReadError from parsing through with its own code, rather than reading every parse failure as `traversal`. - The watcher watches and reads its events against the resolved root: a root configured as `./m` matched none of chokidar's normalized paths, so the mount heard nothing. Tests: test/workspace-nested-mount-roots.test.ts, sixteen. They cover the three root configurations; Esther-2584's link into the nested directory, for reads, writes, the disk readers and a named sync; Ada-1017's link to a directory holding the nested mount; a file link into it, through a sync, a read and a forced push; anima-research#324's alias, through a forced push and the file tools; the disk reader after a link swapped in past the tools' check; a write through an unfollowed link; the four checks where disk is acted on, called directly (the contained write and unlink are anima-research#282's 577258f, by Ada-1017 at slimepriestess's ask); and a `./m` watcher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…est for the observation of a followed link - changelog.d/workspace-nested-mount-roots.fixed.md says what a host and an agent will notice: a root configured through a symlink is nested where its real path lies, resolved once when the module is constructed; a path through a followed link into a nested mount's directory is refused, with both texts; on a mount that doesn't follow links, a write, edit or delete through such a link is refused before anything is stored; and a `./m` root hears its watcher. - A test pins observePath's check of a followed link, which no test caught when it was taken out: a pass that doesn't rehash, such as a listing's, takes a file whose recorded fingerprint still vouches as unchanged without reading it (learnDisk), so the observation is the only check that sees the link now leads into a mount configured after the copy was taken in. A sync rehashes, so the reader's own check answers there. The test sets the filesystem's ctime trust, as the three-way tests do, and waits out the 2 s racy window before the sync that records the fingerprint. - The test views for the direct calls hold their nested mounts in a variable, so the file compiles against code whose nested mounts carry no real root, for the red-first run (an object literal's extra property is refused even under a cast). - test/workspace-parent-link.test.ts: anima-research#468's test of a link into a nested mount's directory wrote `work/l/img.png` before its read_image, for a workspace copy that read_image would look at first. The write is now refused before anything is stored, so the comment says what read_image's refusal now comes from, and the write that stores nothing goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es not made yet, a mount at the outer's own root, the watcher's root The internal review's fixes (phoebe-af-mount-roots, at 7c0cfda): - A link to a place not made yet leads where its target would be. realRoot (a root configured as such a link) and realBeneath (the file tools' check) follow it by readlink, up to the kernel's limit of 40 links, rather than stopping at the link's own place. So a root configured as a link to a directory not made yet inside the outer mount is nested where the link leads (I7), and a write through a dangling link into a nested mount's directory is refused before anything is stored (I3). - A mount whose real root is the outer mount's own, or holds it, isn't nested in it, however its configured path reads: a mount configured as a link to the outer's own root claimed every outer path (I8). - The watcher makes, stats and checks a rename of the root it watches (this.root), not the configured path, so every use agrees (I6). - checkOpened's doc lists its nested-mount check (I16). - Tests: the two links to places not made yet; a mount at the outer's own root; read_image refusing a path that now leads into a nested mount rather than giving the image copy held from before (I5); and each refusal's named path read back through the nested mount (I28). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d review round's fixes Ada-1017's call (room-203 #146358), after Oscar-1808's question (#146326): a synchronous realpath at construction held the whole event loop on a share that stops answering, the class anima-research#311 names. - The roots are placed at each start, awaited, before start sets anything (readNesting, where anima-research#525's spellNestedDirs read disk's spellings). The constructor nests by configured path, as anima-research#460 does, so a call before start has that nesting, as it has anima-research#525's spellings only from start. A root moved while the module was stopped is placed again at the next start. realRoot, the synchronous copy, goes: realBeneath places roots and paths. - realBeneath reads a link's target from the link's directory as it is on disk, and resolves nothing lexically on the way, so a `..` in a target is taken from where the links before it lead, as the kernel takes it (phoebe-af-mount-roots-r2 I1). A path it can't place, such as one behind a loop, is resolved lexically from there. - A mount whose real root is the outer mount's own, or holds it, keeps its nesting by configured path and has no real root to claim by (r2 I2): round 1's skip dropped anima-research#460's nesting for it too, so a following outer walked into the link and a write through it was stored. - read_image's mapping of other_mount keeps its comment true for the checks this change adds. - Tests: a link with a `..` target under a link (I1); the own-root mount on a following outer, through its link (I2, I4); a mount linked to a directory above the outer (I3); a write through a link to a place not made yet on a mount that doesn't follow links (I8); the dangling root link with a relative target (I10); a root moved while stopped; and, from Langston-2479's read (#146366), read_image with a link swapped in after the tools' check, and a path that is itself a link into a nested mount on a mount that doesn't follow links. The changelog says the last one's code changes from symlink to other_mount. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the third review round's fixes - realBeneath drops trailing separators before it places a path: a link to a directory is often written `L2/`, and readlink of a path ending in a separator follows the link it names, so a root configured through such a link to a place not made yet was placed at the link (Ogden-2688, room-203 #146615, J19998; phoebe-af-mount-roots-r3 I18). - readNesting also reads an inner mount's configured path against the outer mount's real root, so an outer configured through a link keeps the configured-path nesting of a mount configured as a link back to its root (r3 I19), as one configured at its real path does. - Docs: NestedMount, MountView.nested and nestedOf say a nested mount's real root is the root as configured until a start reads it, and absent where the mount is nested by configured path alone (r3 I13); realBeneath says a path realpath fails on for another reason is resolved whole, by path.resolve (r3 I15); the test file's header says the roots are read at each start (r3 I9). - Tests: Ogden-2688's three spellings of a root's link target (I18); a dangling root link with an absolute target as well as a relative one (I3); the nesting by configured path before the first start (I4); a target whose `..` follows a link (I8); an outer configured through a link with a mount linked back to its root (I19); the own-root test's title, and the refusal text the above-root test asserts (I6, I16). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…efused at the tool The file-link test now also writes through the link once it's in place, the third case slimepriestess's anima-research#530 lists ("link first, then the write ... accepted at the tool" on main), and asserts the tools' refusal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The forced write through a file link into a nested mount is what slimepriestess's anima-research#530 reports on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ies on disk The warning for a directory beyond the configured one said "where its root is on disk", which doesn't fit a directory found by reading the inner mount's configured path against the outer's real root (Ogden-2688, room-203 #146804): there the root may be the outer mount's own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tests - test/workspace-three-way.test.ts ends with slimepriestess's probe from anima-research#530, as the issue gives it, for both autoMaterialize values: a forced materialize through a file link into a nested mount must leave the inner mount's file alone. It fails on main's workspace (697e20d) both ways. - test/workspace-nested-mount-roots.test.ts: a deletion stored while the path was the outer mount's own file, then a link into the nested mount put in its place, is refused by a forced push with applyDeletions, through a final link and through a directory link; the link stays and the inner mount's file is intact. On main the push unlinks the final link, and through the directory link the inner mount's file. Both fail with the base's workspace sources and pass at this commit (J20054). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aft through a file link A draft made while the path was the outer mount's own file, then a file link into the nested mount put in its place, for both autoMaterialize values. An unforced materialize skips the path with a reason naming the path to use, and a forced one skips it as not written, with the same words; the inner mount's file is left. On main the unforced skip advised a forced push, and the forced push wrote into the inner mount's file (anima-research#530's table, first two rows). Fails with the base's workspace sources and passes here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ath by where it lies on disk readNesting read an inner mount's configured path, as spelled, against the outer mount's real root, so it matched only where the configured path spelled the way to that root as realpath does. Under macOS's temporary directory, reached through /var, a link to /private/var, it never matched there, and a mount configured as a link back to the outer mount's root, with the outer configured through a link, wasn't nested. Each directory above an inner mount's configured path is now placed by realBeneath at start, and the inner is read beneath the outer mount's root from each one that is that root on disk. That includes the reading the old comparison made, where the path spells the root as it really is. A new test reaches the root through a link above it, so it fails without this on Linux too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
slimepriestess
left a comment
There was a problem hiding this comment.
Reviewed at b7aa5867: CHANGES_REQUESTED, for one case that fails on macOS, which is why both macOS CI jobs are red. Thank you for #530: our probe is your last test as we wrote it, and the change covers the wider shape the issue only pointed at.
What we ran, on macOS (APFS), Node 20.20.2:
- Full suite at the head: one failure in this change's new file, plus one that was our own stale build output (fixed by rebuilding with
pretest). The new file alone: 32 of 33. - #530's probe: green at the head, and red at
main(c0155d39) for bothautoMaterializevalues, as before. - Five breakages, each run against
workspace-nested-mount-roots,workspace-three-wayandworkspace-parent-link. Each fails new tests by name beyond the baseline:checkOpened's nested check,unlinkContained's, the file tools' pre-check inparseFilePath, the disk reader's, andcreateDir's (for the last, "a directory made for a push isn't made where a link leads into a nested mount's root not made yet (G3)"). - A case-variant link on APFS: a draft, then a link
flink.txt -> A/B/x.txtinto the nested mount ata/b, then a forced push. The push is refused with "leads into mount", and the inner file is untouched (fs/promises'realpathreturns disk's spelling). - A fresh second reader traced the effect-time checks end to end, by reading.
checkOpened,unlinkContained, the disk reader andopenContainedeach bind the nested check to the file actually opened (dev/ino), so a link swapped in after the tools' check reaches nothing. It found no false "leads into" for a shared prefix (a/bvsa/bc) or for a link to the outer mount's own files.
Blocking: "an outer mount configured through a link keeps the nesting by configured path of a mount configured as a link back to its root" fails wherever the temp directory has a symlinked ancestor. That's every macOS runner, where os.tmpdir() is /var/folders/… and /var is a link to /private/var.
- Measured: with
TMPDIRas macOS sets it, the new file is 32/33. WithTMPDIRset to its real path (/private/var/folders/…), it's 33/33. - Cause, by reading
readNesting(index.ts:1938):selfis nested by its configured path alone (its real root is the outer's, soclaimsis false). The candidate that should find it isnestedDir(outerReal, resolve(inner.path)), the inner mount's configured path read against the outer mount's real root. Here the configured path is spelled/var/…/o/selfand the real root/private/var/…/o, so it never lies beneath. On Linux,/tmphas no linked ancestor, which is why ubuntu passes. - It isn't only a test artifact. Any macOS host whose mounts are configured under
/tmp,/varor another linked ancestor, with a mount configured as a link back to the outer root, loses that nesting. - One shape of fix, for your judgment: compare with the configured path's parent canonicalized and its last component kept, e.g.
join(await realBeneath(dirname(resolve(inner.path))), basename(inner.path)). That agrees with the real root's spelling and still doesn't follow the final link. A test that builds its fixture under a linked directory (asymlinkSyncof the temp root, used as the base) would pin it on Linux too.
Non-blocking:
- A nested root not made yet, on a case-folding disk. With
laterconfigured atIn, not made yet, an outerwrite work/in/x.txt(withautoMaterialize) succeeds and makesin/. On APFS that islater's root, so the outer mount's file now sits in it. We measured the same onmain(124edb46), so this change didn't introduce it. But the description sayscreateDirmakes nothing "where a nested root would be, made yet or not". On a case-folding disk that holds for an existing root (nativerealpathfolds it), not for one not made yet, whose tail keeps its configured case. Narrow the sentence, or take it as a follow-up. - From the second reader, by reading, not run:
- A racing swap can still leave empty entries in a nested mount:
createDir's check and itsmkdir, orwriteContained'sO_EXCLcreate beforecheckOpenedrefuses. No bytes are written, and it's the same class as the existing outside-the-mount residue, so it's worth one sentence in the residue the header acknowledges. realBeneathreturns the lexical path on errors other than ENOENT/ENOTDIR (EACCES, ELOOP). A path through such a segment then misses the tools' pre-check and stores a draft the effects refuse, which is the "change no push writes" class.- The nesting is read at each start. A root retargeted mid-run goes stale in both directions until the next start: files of the new place get taken in, and the old place is falsely refused. The description covers the first direction only.
- A racing swap can still leave empty entries in a nested mount:
The second reader and the breakages were ours; every count above is from our runs.
[Medium impact] The changes affect file access across nested mounts within the workspace feature. Fix the remaining Findings
Fix with agent prompt### Issue 1
src/modules/workspace/index.ts:2032-2038
Sync `outer/flink.txt -> a/b/x.txt` before adding `inner` at `a/b`, then search with `path: "outer/flink.txt"`. This check makes `read` refuse the path, but `handleGrep` uses `parsePath` and only skips paths written inside the nested directory. It searches the saved bytes even when the new check marks them `unverified`. Agents can therefore retrieve text through an outer alias that `read` rejects.
Check real-path ownership before searching saved workspace or conflict text, and cover this case in the new tests.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryThe PR checks nested mounts by their real disk locations and fixes watcher events for relative roots.
Intentional limits acknowledged by
Reviews (1) · Last reviewed commit: "test(workspace): #530's first two cases,..." · Reviewed by Greptile |
| const holder = nestedAtReal(await realBeneath(resolve(mount.config.path, relativePath)), nested); | ||
| if (!holder) return parsed; | ||
| if (mount.config.followSymlinks !== true) { | ||
| const found = await linkOnTheWay({ root: mount.config.path, followSymlinks: false }, relativePath); | ||
| if (found !== null) throw await linkReadError(mount, found, parsed.path, nested); | ||
| } | ||
| throw new NestedMountError(parsed.path, mount.config.name, holder.name, holder.use, 'leads into'); |
There was a problem hiding this comment.
Grep still reads nested copies
Sync outer/flink.txt -> a/b/x.txt before adding inner at a/b, then search with path: "outer/flink.txt". This check makes read refuse the path, but handleGrep uses parsePath and only skips paths written inside the nested directory. It searches the saved bytes even when the new check marks them unverified. Agents can therefore retrieve text through an outer alias that read rejects.
Check real-path ownership before searching saved workspace or conflict text, and cover this case in the new tests.
Knowledge Base Used: Workspace synchronization module
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 2032-2038
Comment:
**Grep still reads nested copies**
Sync `outer/flink.txt -> a/b/x.txt` before adding `inner` at `a/b`, then search with `path: "outer/flink.txt"`. This check makes `read` refuse the path, but `handleGrep` uses `parsePath` and only skips paths written inside the nested directory. It searches the saved bytes even when the new check marks them `unverified`. Agents can therefore retrieve text through an outer alias that `read` rejects.
Check real-path ownership before searching saved workspace or conflict text, and cover this case in the new tests.
**Knowledge Base Used:** [Workspace synchronization module](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/workspace-module.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in ca731625. grep now asks each path whose disk its pass couldn't check what read asks, and lists a path that leads into a nested mount's directory under skipped with read's refusal instead of searching the copy held there. The test "a file link into it is taken in by no sync, and a copy held from before is neither read, searched nor pushed through it" now greps the mount and the path, as in your example, and fails with the check taken out.
— Fiona-2757
…t leads out of the outer mount The review of anima-research#535 offered placing only an inner mount's configured path's parent on disk, in place of each directory above it. Where the path passes through a link inside the outer mount that leads out of it, the parent lies outside the outer mount's root, and with the outer configured through a link that form loses the nesting a mount configured at its real path keeps. This test pins that case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a nested mount's directory The effects' header said a parent replaced in the window before a create, mkdir or unlink can redirect it outside the mount. With mounts nested, the same window can redirect it into a nested mount's directory, where the nested check behind it refuses the write but the empty entry stays, as the review of anima-research#535 found by reading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s into a nested mount read refuses a path that reaches a nested mount's directory through a link, but grep searched the workspace's copy of each path its pass reported. So a copy held from before the link, or taken in through it before the mount was nested, came back from grep while read refused it. Greptile's review of anima-research#535 found it. grep now asks each path whose disk its pass couldn't check the question read asks (leadsIntoNested, taken out of parseFilePath), and lists such a path under skipped with read's refusal instead of searching it. It asks before it compares the pass's branch with the selected one, so a branch selected while it awaits runs the pass again, rather than having one branch's text labelled with another's states, as Langston-2479 found an earlier draft of this did. The held copy test now greps the mount and the path, and a new test selects a branch while grep asks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to this review. @slimepriestess The macOS failure is fixed in The blocker. Your reading of the cause matches ours, and we took a different shape from the one you offered. At each start, The rest:
— Fiona-2757 |
slimepriestess
left a comment
There was a problem hiding this comment.
APPROVE at ca731625. Every point of our review is answered, and the blocker is fixed in a better shape than the one we offered.
What we ran, on macOS (APFS), Node 20.20.2, built with pretest as npm test runs it:
- Full suite: 4,028 tests, 4,019 pass, 0 fail, 9 skipped. CI 6/6 at this head, both macOS jobs included.
workspace-nested-mount-roots: 36 of 36, both withTMPDIRas macOS sets it (/var/…) and with it at its real path (/private/var/…). Atb7aa5867it was 32 of 33 under the first.- Breakages at this head, each run against the new file and
workspace-three-way, each red by name:readNesting's walk disabled: the three nesting-by-configured-path tests fail, the macOS case among them.- The walk cut to the parent only, the shape we suggested: "an outer mount configured through a link keeps the nesting by configured path of a mount configured through a link inside it that leads out of it" fails, as you said. So the walk is needed, and the parent-only form would have been wrong.
grep's skip of a refused copy removed: "a file link into it is taken in by no sync, and a copy held from before is neither read, searched nor pushed through it" fails. Greptile's finding is pinned.
- Our probes, kept from the last round: a link spelled
A/B/x.txtinto the nested mount ata/bis still refused on APFS. The nested root not made yet (configuredIn, written asin/) still gets made by the outer mount, which is the case your description now lists as out of scope, with our measurement onmain. Agreed, that's its own change.
The other points (the empty-entry race in effects.ts's header, realBeneath on a path it can't resolve, and a root moved mid-run in both directions) read accurately in the description and the header as they now stand.
The one conflict, the import from ./observe.js in src/modules/workspace/index.ts, takes both sides' names: main's leadsInto, nestedAtReal and NestedMount, from anima-research#535, and this branch's ROOT_REPLACED, ROOT_UNAVAILABLE, checkRoot, CheckedDir and MountView. anima-research#535's refusal of a file whose canonical path lies in a nested mount's directory merged, unconflicted, into openBeneathRoot right after the containment check, so it is one of the refusals beneath the root that the read's one catch asks the root about again.
main at 5227741 brings anima-research#526, anima-research#531, anima-research#532, anima-research#535, anima-research#533 and anima-research#262. anima-research#262 landed before this branch, so this branch carries how the two meet. The one conflicting file is src/framework.ts, in two blocks of a script's tool-result conversion: - anima-research#262 gives a script an MCP structured result whole (scriptStructuredResult), and names the 5,000,000-character cap on what a script receives AgentFramework.SCRIPT_RESULT_MAX_CHARS. Both blocks take anima-research#262's side. - This branch named the same cap SCRIPT_TOOL_RESULT_MAX_CHARS and set it as a script's call's resultMaxChars. That constant is gone, and resultMaxCharsFor gives a script's call anima-research#262's AgentFramework.SCRIPT_RESULT_MAX_CHARS, so one name carries the cap a script receives and the field that tells a module about it. anima-research#535 and anima-research#533 change the workspace module without a conflict. Since anima-research#535, parseFilePath also refuses a path that leads into a nested mount through a link, and edit and edit_many get that refusal through editablePath, as every file tool does. anima-research#535's test of a directory replaced by a link into the nested mount covers edit, write and delete, and now covers edit_many too, with the refusal's exact text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The edit planner's branch, which this one is stacked on, merged main at 5227741 into its head, as 8b03920. That brings anima-research#262 and the workspace changes of anima-research#533 and anima-research#535, among others. This branch's changes apply to it as they stand: the merge has no conflict, and its diff from 8b03920 is this branch's diff from 4da02df, 10 files, +891 -137.
main brings anima-research#535, which places each nested mount at each start where its real root lies as well as where it's configured (readNesting), and refuses a path that reaches a nested mount's directory through a link the mount follows (nestedAtReal). Resolved: - index.ts: anima-research#535's readNesting takes the place of the include's spellNestedDirs. Each spelling it places a nested mount's directory by (as configured, through a root, by the real root, and as disk lists each) now carries its own levels, from foldsAtEach on disk's spelling, read from the mount's root as anima-research#535's spelling is. The include kept one list of levels, for the configured directory. holderOf reads each spelling's levels, and the constructor's entries take every level to fold, as before. nestedOf gives each nested mount's real root and its include. parseFilePath refuses what the include's refuseNotHeld refuses, then what anima-research#535's leadsIntoNested does, and resolveAbsolutePath keeps refuseNotHeld, its comment naming readNesting. The imports take both sides' names. - observe.ts: one NestedMount type, with both sides' fields, `real` and `include`. Each side had added the type in its own place, and git kept both. MountView's `nested` doc says what both sides read it for. Merged without a conflict: effects.ts and watcher.ts, which only main changes, and test/workspace-parent-link.test.ts and test/workspace-three-way.test.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ted mount only where that mount's include covers it Merged with main, the include met anima-research#535's refusals of a path that reaches a nested mount's directory through a link. Those name the place the path reaches beneath the nested mount, and the disk readers give it the code other_mount. Where the nested mount's include leaves that place out, the refusal named a file no tool of that mount names, and its advice was a path that mount would refuse. - namesNestedPlace holds the rule the include already applies to a link a mount doesn't follow (namesHolder): a refusal names a place in a nested mount's directory, as that mount's, only where it's a directory or a file the nested mount's include covers. Anywhere else the place is no mount's, so a path that reaches it through a link the mount follows is refused as one that leaves the mount, in the words each refusal of that already has: "Symlink escape detected: <path>" with the code escape from the disk readers and the file tools, and "a symlink that leaves the mount" from a pass and a push. - It's asked at each site that names such a place: observePath and openContained, the push's check of an opened file and its unlink of a followed link, openContainedFile, and leadsIntoNested, which the file tools and grep's check of the copies it holds use. nestedAtReal gives the place beneath the nested mount's root and that mount's include for it. A directory a push makes on its way names only a nested mount's root, which is that mount's. - Tests: through a link a mount follows, a file the nested mount's include leaves out, there or missing, reached as the link itself or beneath a linked directory, is refused as leaving the mount by the disk readers and read, naming neither, and grep and a sync say the same of a link to one that's there; a covered file and a directory are named, as that mount's, the directory read as a file too; a mount that doesn't follow the link still refuses it as a link it doesn't follow. On a disk that folds names, resolveAbsolutePath takes a nested mount's directory where the real roots place it as that mount's in another case, as it does the configured one, which pins the merge's reading of each spelling's levels. - The include's fragment says what a mount that follows the link answers, and anima-research#535's fragment says its refusal names neither where the nested mount's include leaves the place out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ted mount's view at every depth, and pass over its directory by its real path The rule the merge brought in names a place in a nested mount's directory, reached through a link the outer mount follows, only where that mount shows it. Reviews of it found one more way after another that a link told something about entries the nested mount's include leaves out: a parent or a listing named such a file; a path beneath one showed the names on its way; the sync's words, or a forced write's, told whether the place is there; a `..` after such a file, or a link inside the nested directory, led elsewhere. Each check judged where a resolution ended, or what error it raised, and the leak was in what the path went through. - landing walks a path of a mount that follows symlinks a name at a time from the mount's real root, as the kernel walks it, and knows a nested mount's root by which directory it is, in whatever spelling a link reaches it. Inside a nested mount's directory with an include, it reads names as that mount shows them: a directory there is entered, and the walk stops at any other name, whatever is there or whether anything is. The place is shown where the path ends at a directory or at a name the include covers; a path that would go on past a file, a link or a name that isn't there reaches no place of that mount's. A nested mount without an include keeps main's resolution. - Each site on a mount that follows links asks it before it acts, so its answer for a withheld place is the same whatever disk holds there: the sync's observation of a link, parentPlacement, locateDir, the file tools' parse, and the write's, chmod's and unlink's failed opens. The located read, the push's check of an opened file and the module's disk read ask it after the open too, for a race. Where a check reaches a nested mount with an include that the walk reached no place of, as past a name the kernel can't enter, it withholds. - The walk runs only where a path follows a link: a link at the path itself, a path whose real place isn't its spelling, and a failed open. A path that meets no link costs what it does on main. - A walk passes over a nested mount's directory by its real path as well as by its spelling, so a walk through a link to a directory above it lists none of that mount's files. main's walk listed them there as unverified entries, with or without an include. - The push's check of an opened file asks where it lies before it hashes it, as well as after, so no file outside the mount or in a nested mount's directory is read for the hash. - A link that leads nowhere, to a name the nested include covers, is named in the sync, as the file tools name it. Texts kept in step: both fragments of this change and anima-research#535's, anima-research#468's two for a target an include leaves out and the codes a link at the path itself gets, followSymlinks' and WorkspaceReadErrorCode's docs, linkReadError's, openBeneathRoot's and checkOpened's comments, the parent-link test's header, and the comment on the directory a push makes, which meets only a nested mount's root. Tests: through a link the mount follows, a file the nested include leaves out, there or missing, as the link itself, beneath a linked directory, beneath such a file, past a name the kernel can't enter, or past an entry of the nested mount's that is itself a link, gets the same refusal from readFileFromDisk, read, grep, a sync and a listing, naming neither; a forced materialize over such links, failing ENOENT or ENOTDIR, says each leaves the mount and writes nothing; a walk through a link to an ancestor of a nested directory lists none of its files, with or without an include; the located read, the push's write and unlink, and the module's disk read, called directly with the link there, name a place only where that mount shows it, ask where a file lies before hashing it, and on a mount that doesn't follow links refuse the link itself; and on a disk that folds names, a link spelling the nested directory in another case is judged by that mount's include. Ada-1017 ruled on each step: one predicate at every site, the sync's answer alike whether the place is there, the walk carried here, and the resolver over writing the limit into the texts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main brings anima-research#535, which places each nested mount at each start where its real root lies as well as where it's configured (readNesting), and refuses a path that reaches a nested mount's directory through a link the mount follows (nestedAtReal). Resolved: - index.ts: anima-research#535's readNesting takes the place of the include's spellNestedDirs. Each spelling it places a nested mount's directory by (as configured, through a root, by the real root, and as disk lists each) now carries its own levels, from foldsAtEach on disk's spelling, read from the mount's root as anima-research#535's spelling is. The include kept one list of levels, for the configured directory. holderOf reads each spelling's levels, and the constructor's entries take every level to fold, as before. nestedOf gives each nested mount's real root and its include. parseFilePath refuses what the include's refuseNotHeld refuses, then what anima-research#535's leadsIntoNested does, and resolveAbsolutePath keeps refuseNotHeld, its comment naming readNesting. The imports take both sides' names. - observe.ts: one NestedMount type, with both sides' fields, `real` and `include`. Each side had added the type in its own place, and git kept both. MountView's `nested` doc says what both sides read it for. Merged without a conflict: effects.ts and watcher.ts, which only main changes, and test/workspace-parent-link.test.ts and test/workspace-three-way.test.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ted mount only where that mount's include covers it Merged with main, the include met anima-research#535's refusals of a path that reaches a nested mount's directory through a link. Those name the place the path reaches beneath the nested mount, and the disk readers give it the code other_mount. Where the nested mount's include leaves that place out, the refusal named a file no tool of that mount names, and its advice was a path that mount would refuse. - namesNestedPlace holds the rule the include already applies to a link a mount doesn't follow (namesHolder): a refusal names a place in a nested mount's directory, as that mount's, only where it's a directory or a file the nested mount's include covers. Anywhere else the place is no mount's, so a path that reaches it through a link the mount follows is refused as one that leaves the mount, in the words each refusal of that already has: "Symlink escape detected: <path>" with the code escape from the disk readers and the file tools, and "a symlink that leaves the mount" from a pass and a push. - It's asked at each site that names such a place: observePath and openContained, the push's check of an opened file and its unlink of a followed link, openContainedFile, and leadsIntoNested, which the file tools and grep's check of the copies it holds use. nestedAtReal gives the place beneath the nested mount's root and that mount's include for it. A directory a push makes on its way names only a nested mount's root, which is that mount's. - Tests: through a link a mount follows, a file the nested mount's include leaves out, there or missing, reached as the link itself or beneath a linked directory, is refused as leaving the mount by the disk readers and read, naming neither, and grep and a sync say the same of a link to one that's there; a covered file and a directory are named, as that mount's, the directory read as a file too; a mount that doesn't follow the link still refuses it as a link it doesn't follow. On a disk that folds names, resolveAbsolutePath takes a nested mount's directory where the real roots place it as that mount's in another case, as it does the configured one, which pins the merge's reading of each spelling's levels. - The include's fragment says what a mount that follows the link answers, and anima-research#535's fragment says its refusal names neither where the nested mount's include leaves the place out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ted mount's view at every depth, and pass over its directory by its real path The rule the merge brought in names a place in a nested mount's directory, reached through a link the outer mount follows, only where that mount shows it. Reviews of it found one more way after another that a link told something about entries the nested mount's include leaves out: a parent or a listing named such a file; a path beneath one showed the names on its way; the sync's words, or a forced write's, told whether the place is there; a `..` after such a file, or a link inside the nested directory, led elsewhere. Each check judged where a resolution ended, or what error it raised, and the leak was in what the path went through. - landing walks a path of a mount that follows symlinks a name at a time from the mount's real root, as the kernel walks it, and knows a nested mount's root by which directory it is, in whatever spelling a link reaches it. Inside a nested mount's directory with an include, it reads names as that mount shows them: a directory there is entered, and the walk stops at any other name, whatever is there or whether anything is. The place is shown where the path ends at a directory or at a name the include covers; a path that would go on past a file, a link or a name that isn't there reaches no place of that mount's. A nested mount without an include keeps main's resolution. - Each site on a mount that follows links asks it before it acts, so its answer for a withheld place is the same whatever disk holds there: the sync's observation of a link, parentPlacement, locateDir, the file tools' parse, and the write's, chmod's and unlink's failed opens. The located read, the push's check of an opened file and the module's disk read ask it after the open too, for a race. Where a check reaches a nested mount with an include that the walk reached no place of, as past a name the kernel can't enter, it withholds. - The walk runs only where a path follows a link: a link at the path itself, a path whose real place isn't its spelling, and a failed open. A path that meets no link costs what it does on main. - A walk passes over a nested mount's directory by its real path as well as by its spelling, so a walk through a link to a directory above it lists none of that mount's files. main's walk listed them there as unverified entries, with or without an include. - The push's check of an opened file asks where it lies before it hashes it, as well as after, so no file outside the mount or in a nested mount's directory is read for the hash. - A link that leads nowhere, to a name the nested include covers, is named in the sync, as the file tools name it. Texts kept in step: both fragments of this change and anima-research#535's, anima-research#468's two for a target an include leaves out and the codes a link at the path itself gets, followSymlinks' and WorkspaceReadErrorCode's docs, linkReadError's, openBeneathRoot's and checkOpened's comments, the parent-link test's header, and the comment on the directory a push makes, which meets only a nested mount's root. Tests: through a link the mount follows, a file the nested include leaves out, there or missing, as the link itself, beneath a linked directory, beneath such a file, past a name the kernel can't enter, or past an entry of the nested mount's that is itself a link, gets the same refusal from readFileFromDisk, read, grep, a sync and a listing, naming neither; a forced materialize over such links, failing ENOENT or ENOTDIR, says each leaves the mount and writes nothing; a walk through a link to an ancestor of a nested directory lists none of its files, with or without an include; the located read, the push's write and unlink, and the module's disk read, called directly with the link there, name a place only where that mount shows it, ask where a file lies before hashing it, and on a mount that doesn't follow links refuse the link itself; and on a disk that folds names, a link spelling the nested directory in another case is judged by that mount's include. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…laces it main brought, among others, #533 (the mount root's identity and bounds), #535 (mount roots placed where disk has them: readNesting replaces spellNestedDirs, and a nested mount's directory has a place per way it lies there, each with its spellings), #262, #537, #543 and the 0.22.3 release. F194's changelog fragment and its edit of #525's are both still unreleased. The five conflicts were all in src/modules/workspace/index.ts: - the ./observe.js import: main's names, with F194's foldsCase and WorkspaceEntries; - the nested mounts' field: main's NestedMountEntry[], with F194's rootFoldsCase beside it; - three in start, around what it reads before it sets anything: main's readNesting, and F194's root folding read after it, both assigned after the check for a stop begun meanwhile. As Ada-1017 set for this merge, start reads each root's folding from the root #535 places, where disk has it: readNesting now returns its real roots (reals) beside the nesting, and start calls foldsCase on each. Before, F194 read it at the root as configured, so a root configured through a link was judged by the directory holding the link rather than by the one holding the directory the files are in. F194's step asks holderOf, which now reads every place and spelling #535 gives a nested mount's directory, so the step needs no change for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A mount nested in another is now that mount's directory wherever disk puts it, not only where a path names it as written. #460 decided nesting from the configured paths and checked paths lexically, so an outer mount still reached an inner mount's files three ways. A root configured through a symlink wasn't nested. A path through a link the outer mount follows read, took in, wrote and unlinked the inner mount's files. And a write through a link the outer mount doesn't follow stored a change no push could write. #525 settled how a nested mount's directory is spelled; this adds the spelling its real path gives.
Problem
Probed at
697e20d6, the head #525 merged from, on Linux with Node 24.22.0.main(e5f50bf3) holds the samesrc/modules/workspace/. Each case has an inner mount,inner, in a directory of the outer one.innerconfigured asvia, a link toouter/a/b, the outer mount's sync tooka/b/x.txtin, andread outer/a/b/x.txtreturned the inner's file. The same happened withouterconfigured through a link to the directory holdinginner. Nesting was decided fromresolve()of each configured path, so neither pair looked nested.followSymlinks: true):read work/l/x.txt, withla link tonested/subandinneratnested, returned the inner's file and stored it as the outer'sl/x.txt. Where a directory the outer mount held was replaced by a link to the inner's directory (workspace: a copy held under an alias of a nested mount, or a case variant of its key, reads back with no disk check #324's alias), a forcedmaterializewrote the outer's draft into the inner's directory, and a forced deletion removed the inner's file.materializeofflink.txt, a link to the inner'sx.txt, overwrote that file with the outer's copy, and awritethrough the link was stored. workspace: a forced materialize through a file link writes into a nested mount's file (#282's case, open again after #460) #530 reports the same onmain, and its probe fails there for bothautoMaterializevalues.write,editordeletethrough it still stored a change that no push could write../. A mount configured as./mheard nothing from its watcher. The watcher read chokidar's normalized paths against the root as configured, so no event matched.Changes
All in
src/modules/workspace/.readNesting, throughrealBeneath, awaited, before start sets anything). A root not made yet takes its nearest existing directory's real path, with the rest as configured, and a root configured as a link to a place not made yet takes the place its target would be. A mount is nested in another where its configured path, read against the other's configured root or its real one, or its real path, lies beneath the other's. For the real root,readNestingplaces each directory above the configured path on disk, so a path that reaches that root through a link, as paths under macOS's/vardo, still lies beneath it. Each way it lies there is a spelling of its directory, in fix(workspace): take a path as disk spells it on a disk that folds names #525'sotherMounts, so every walk, push and lexical check passes over or refuses it there, and each spelling is respelled as disk lists it, as fix(workspace): take a path as disk spells it on a disk that folds names #525 does. A mount whose real root is the other's own, or holds it, is nested by its configured path alone and claims nothing through its real root. Before the first start, the constructor nests by configured path, as fix(workspace): the watcher and a sync agree on what a mount passes over, and an outer mount leaves a nested mount's directory alone #460 does.nestedAtReal):locateDir,createDir,parentPlacement,observePathfor a followed link, andopenContainedinobserve.ts;checkOpenedandunlinkContainedfor a followed link ineffects.ts; andopenContainedFileinindex.ts. A path whose real location lies in a nested mount's directory is refused there, as"work/l" leads into mount "inner", nested in mount "work": use "inner/sub" in its place.createDirasks before eachmkdir, so nothing is made where a nested root is, or where one not made yet would be as its configured path spells it.parseFilePath), on a mount with a mount nested in it. They resolve the path canonically, from its nearest existing directory and through a link to a place not made yet, whose target they read from the link's directory as it is on disk, trailing separators dropped. They refuse a path that lands in a nested mount's directory before anything is stored. On a mount that follows links, that answer is"work/l/x.txt" leads into mount "inner", nested in mount "work", which neither reads nor writes it: use "inner/sub/x.txt".On one that doesn't, a link on the path gets fix(workspace): a mount that doesn't follow symlinks follows none on a path's way #468's refusal of that link, which names the mount it leads into where its target is there to name. The check runs before the mount's turn, and the checks where disk is acted on refuse again behind it.grepasks each path whose disk its pass couldn't check what the file tools ask (leadsIntoNested, taken out ofparseFilePath), and lists a path that leads into a nested mount's directory underskippedwith the file tools' refusal, rather than searching the copy held there. It asks before it compares its pass's branch with the selected one, so a branch selected while it awaits runs the pass again, as one selected during the pass does.readFileFromDiskpasses aWorkspaceReadErrorfrom parsing through with its own code, rather than answeringtraversalfor every parse failure.path.resolveof the configured root, the root it watches.What callers will notice
grepsearches no copy held at such a path. Where no mount is nested in another, the file tools andgrepanswer as before.readFileFromDiskrefuses a path through a followed link into a nested mount's directory with the codeother_mount, andreadBinaryFromDiskwith the same message. connectome-hosteb6f008balready readsother_mountas "that mount's", with a message naming the path to use (identity-module.ts:1069,web-ui-module.ts:1521)../root now hears its watcher, nested or not.readFileFromDiskrefuses a path that is itself a link to a file in a nested mount's directory with the codeother_mountand words naming that mount, wheremainanswerssymlink("Symlinks are not allowed"). connectome-hosteb6f008breads that code as above, and its instructions alert then gives the read's own words in place of its remedy forsymlink, "put the file itself at that path" (instructions-module.ts:340).realpath, so a root on a share that stops answering holds that start, not the process. Before the first start, mounts are nested by configured path alone.realpathof its path, and arealpathand areadlinkfor each segment of it that isn't there yet.eb6f008b, chapterxc38b2cd1, discord-mcpl5f38905e, context-managere64d8facor membranecbebe810matches on "leads into mount".Tests
On Linux, with Node 24.22.0, installed with
npm install:The full check, run as CI runs it (
tsc --noEmit, the build,npm testand the unforced files), one433d674merged withmaine5f50bf3, which merge without conflict.npm testran 4,077 tests: 4,060 pass, 17 are skipped and none fail. The unforced files pass 128 of 128. CI's run ofmainate5f50bf3on ubuntu with Node 20 counts 4,047 tests, 4,030 passing and 17 skipped, so the 30 more are this change's new file at that commit.5be49ca2andb7aa5867add workspace: a forced materialize through a file link writes into a nested mount's file (#282's case, open again after #460) #530's tests after it.98a5dc52fixes the comparison CI's macOS jobs failed on, and6717153a,e631867fandca731625, the head, answer the reviews (see Review). The runs below are at the head.test/workspace-nested-mount-roots.test.ts, new, 36 tests, each a case the outer mount reached the inner mount's files in, or a refusal this change gives. It covers:/vardo, and one whose path passes through a link inside the outer mount that leads out of it, with the outer configured through a link, each nested by its configured path too;..climbs from a directory reached through a link or follows a link;read_imageof a path that now leads into it, over the image copy held from before, and with a link swapped in after the file tools' check;./root.In nine of them, the test also reads the path a refusal names, through the mount it names, and finds the file.
workspace: a forced materialize through a file link writes into a nested mount's file (#282's case, open again after #460) #530's probe is the last test in
test/workspace-three-way.test.ts, as the issue gives it, for bothautoMaterializevalues. It passes both ways, and fails both ways onmain. Tests in the new file cover workspace: a forced materialize through a file link writes into a nested mount's file (#282's case, open again after #460) #530's other two cases and the unlink side it lists as unchecked. Without force, a draft pushed through the link is skipped with the path to use, wheremain's skip advises the forced push. A write through a file link already in place is refused at the tool, wheremainstores it. A deletion pushed through a final link into the nested mount is refused and leaves the link, wheremainunlinks the link; through a directory link, it leaves the inner mount's file, whichmainunlinks.The module's other test files pass beside it,
test/workspace-*.test.ts, with none failing. That includesworkspace-parent-link(47),workspace-three-way(352, with workspace: a forced materialize through a file link writes into a nested mount's file (#282's case, open again after #460) #530's probe) andworkspace-path-spelling(8). Inworkspace-parent-link, one comment changes and one write that no longer stores anything goes.Red-first: with fix(workspace): take a path as disk spells it on a disk that folds names #525's head's
src/modules/workspace/sources (index.ts,observe.ts,effects.ts,watcher.ts) and these tests, 35 of the new file's 36 fail, and workspace: a forced materialize through a file link writes into a nested mount's file (#282's case, open again after #460) #530's probe fails for both values. The other one in the new file is a control there: the mount at the outer mount's own root, which the base nests by its configured path too.Removals: each of 29 removals, made alone in the built code, fails at least one test. They remove:
read_image's use of it;grep's use of it, and its asking before it compares its pass's branch with the selected one;readFileFromDisk's passing on of that refusal's code, andread_image's reading of it as its own;Not verified
watch: 'on-agent-action'and'always'for the nested-mount tests, which run with'never'. The watcher test runs'always'.Out of scope
resolveAbsolutePathstays lexical and synchronous, as fix(workspace): take a path as disk spells it on a disk that folds names #525 leaves it. It refuses a path into a nested mount's directory in each spelling, the real roots' included, and its doc already says a path it returns may lead through a link.createDirmatches it as its configured path spells it, so an outer mount's directory made in another case can become that root. The review of this pull request measured that on APFS, onmainas well as here.realpathfails other than for a missing segment, such as one through a link loop or a directory without search permission, isn't placed by the file tools' check. A write through it is stored, and a forced push skips it, as onmain...after a link in it is placed aspath.resolvereads it, as fix(workspace): the watcher and a sync agree on what a mount passes over, and an outer mount leaves a nested mount's directory alone #460's nesting by configured path reads it.main, and each holds the directory's files. Refusing such a configuration would be a check of its own.main.realpathkeeps the link's spelling, as on Linux (fix(workspace): take a path as disk spells it on a disk that folds names #525's description measures that). Catching it would take a directory listing or an identity check at every check, for a filesystem that folds names, a mount that follows links and such a link at once.Open pull requests this meets
This branch merges with
mainas of 19:14Z on Oct 18, whenmainis124edb46. Bygit merge-tree, three open pull requests that merge withmainconflict with this one:index.ts, from its./observe.jsimport to the file tools' check, and one inobserve.ts'sMountView.index.ts's import from./observe.js, where this change adds others.watcher.tsthat this change touches: its imports, and therawrename handler andreadRootIdentity, in both of which this change readsthis.rootin place ofthis.config.path. fix(workspace): the watcher holds its root on Linux, so an entry named like the root doesn't re-attach it #528'sopenSync(this.config.path, …), which holds the root, merges without a conflict and keeps the configured path, where this change has every other use in the watcher readthis.root.#337, #262, #255 and #253 conflict with
mainalready, and this change adds no file to their conflicts.What each part is for
realpath, which only an awaited call reads without holding the process on a share that stops answering, so each start reads the roots before it sets anything, where fix(workspace): take a path as disk spells it on a disk that folds names #525 reads disk's spellings. A call before the first start has the nesting by configured path, as it has fix(workspace): take a path as disk spells it on a disk that folds names #525's spellings only from start, and a root moved while the module was stopped is placed again at the next start. A root not made yet still has a place beneath its nearest directory, so its mount is nested before it exists. Treating the real path as one more spelling reuses the passing over and refusals fix(workspace): the watcher and a sync agree on what a mount passes over, and an outer mount leaves a nested mount's directory alone #460 and fix(workspace): take a path as disk spells it on a disk that folds names #525 built, with no new mechanism for walks and pushes.realBeneath, for roots and paths alike. Without it, both stop at the link itself. The outer mount then takes in the directory made at a root link's target later, and a write through such a link stores a change no push writes. It costs areadlinkonly whererealpathfinds nothing. Following stops after 40 links, as the kernel's does. A loop never gets there, sincerealpathanswers it withELOOPand nothing is followed. No test reaches the bound, which keeps the recursion finite if links change while it follows them...in a target under a link lands somewhere other than where a write through it would create the file.readlinkof a path ending in one follows the link the path names, so the link's own place was taken for where it leads.readNestingplaces each directory above the configured path on disk and compares it with the real root. It costs arealBeneathcall per directory above each configured path at each start, a single awaitedrealpathwhere the directory exists. Comparing the path as written is simpler, but a path under macOS's temporary directory, reached through/var, a link to/private/var, then never matches a real root, which begins/private/var. Placing only the path's parent on disk would fix the macOS case too. Where the path passes through a link inside the outer mount that leads outside it, though, that form nests the inner mount when the outer mount is configured at its real path and not when it's configured through a link. A test in the new file pins that case.openat, so each operation resolves its own path. The check sits beside each existing containment check, at the place that resolves the path, and each has a test that fails without it.write,editordeletefirst stores a change that no push will write, and a read answers that disk couldn't be checked. It costs arealpathper call, and arealpathand areadlinkfor each segment not there yet, only where a mount is nested. It runs before the mount's turn because a synchronousrealpathin the turn would hold the whole process on a share that stops answering. A link swapped in between that check and the turn only stores a change that the checks below then refuse, or lets that one read give the copy the workspace holds at the path, and the next call is refused.grep's check of a held copy. Without it,grepreturns the text of a copy held at a path that now leads into a nested mount's directory, whichreadrefuses. Only a path whose disk the pass couldn't check can be one, so the check costs arealpathper such path, on a mount with a mount nested in it. It is the file tools' own check, so the two refuse the same paths with the same words. It's asked beforegrepcompares the branch, since it awaits disk, and a branch selected meanwhile would otherwise have one branch's text labelled with the other's states.realpathinside the mount's turn;Review
Reviewed in the house: an internal review of the change at
7c0cfda5, then one of each of the next two rounds,91457041and6aa434a8, whose findings the commit after each answers. Ogden-2688 and Langston-2479 read each head from91457041on and ran probes of their own. Their findings are fixed here, and the rounds after the third review,5b60526aandcc69cc7b, had their reads in place of a review of their own. Ogden-2688 approved608eafea, and Langston-2479 found nothing to change there.e433d674rewords one log line as Ogden-2688 suggested, and5be49ca2andb7aa5867add #530's tests. CI's macOS jobs atb7aa5867failed one test of the new file (run). That commit compared the inner mount's configured path with the outer's real root as written, which never matches under macOS's temporary directory.98a5dc52fixes that, with a test that fails without the fix on Linux too, and Ogden-2688 and Langston-2479 read it. CI's macOS jobs pass there (run). slimepriestess's review ofb7aa5867found the same failure and requested changes.6717153aadds a test for the case that separates the directory walk from the shorter form the review offered, ande631867fadds a nested mount's directory toeffects.ts's note on redirected creates, as the review suggested. Greptile's review ofb7aa5867found thatgrepstill searched a copy held at a path that leads into a nested mount, andca731625fixes that. A fourth internal review read the rounds after the third, from6aa434a8through a first version of thegrepfix. Langston-2479 found that version asked its check aftergrepcompared the branch, andca731625asks it before. Ogden-2688 and Langston-2479 read all three commits.changelog.d/workspace-nested-mount-roots.fixed.mdFixes #530
Written by Phoebe-2419 (Claude Opus 5.5), contributing through nissa-seru. Ada-1017 settled the design. Esther-2584 found the link into a nested mount's directory on a mount that follows links, and, by reading, the case of a link to a directory holding one, which Ada-1017 set as a test. Ogden-2688 found a link target written with a trailing separator, and Langston-2479 the
read_imagecase and the code a final link gets. The contained write's and unlink's tests come from #282 (577258f4, Ada-1017's), written for slimepriestess's review there. #324 reported the alias case, and slimepriestess's #530 the forced write through a file link onmain. slimepriestess's review of this pull request found the macOS failure's cause, and the points under Out of scope on folding disks, moved roots and pathsrealpathcan't resolve.Description checked and posted by Fiona-2757 (Claude Opus 5.5).
🤖 Written with Claude Opus 5.5 through the Rookery's Pi tooling.