Skip to content

fix(workspace): a nested mount is its directory wherever disk puts it - #535

Merged
nissa-seru merged 14 commits into
anima-research:mainfrom
nissa-seru:fix/workspace-mount-roots
Oct 18, 2026
Merged

nissa-seru merged 14 commits into
anima-research:mainfrom
nissa-seru:fix/workspace-mount-roots

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

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 same src/modules/workspace/. Each case has an inner mount, inner, in a directory of the outer one.

  • A root configured through a symlink. With inner configured as via, a link to outer/a/b, the outer mount's sync took a/b/x.txt in, and read outer/a/b/x.txt returned the inner's file. The same happened with outer configured through a link to the directory holding inner. Nesting was decided from resolve() of each configured path, so neither pair looked nested.
  • A link the outer mount follows (followSymlinks: true):
  • A link the outer mount doesn't follow. fix(workspace): a mount that doesn't follow symlinks follows none on a path's way #468 refuses every push, unlink and disk read through it, but a write, edit or delete through it still stored a change that no push could write.
  • A root written with ./. A mount configured as ./m heard 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/.

  • Each root's real path, at each start (readNesting, through realBeneath, 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, readNesting places each directory above the configured path on disk, so a path that reaches that root through a link, as paths under macOS's /var do, 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's otherMounts, 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.
  • Every check that resolves a path canonically asks the nested mounts' real roots (nestedAtReal): locateDir, createDir, parentPlacement, observePath for a followed link, and openContained in observe.ts; checkOpened and unlinkContained for a followed link in effects.ts; and openContainedFile in index.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. createDir asks before each mkdir, so nothing is made where a nested root is, or where one not made yet would be as its configured path spells it.
  • The file tools ask first (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.
  • grep asks each path whose disk its pass couldn't check what the file tools ask (leadsIntoNested, taken out of parseFilePath), and lists a path that leads into a nested mount's directory under skipped with 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.
  • readFileFromDisk passes a WorkspaceReadError from parsing through with its own code, rather than answering traversal for every parse failure.
  • The watcher reads its events against path.resolve of the configured root, the root it watches.

What callers will notice

  • Agents get the refusals above for a path that reaches a nested mount's directory through a link or a root configured through one, and grep searches no copy held at such a path. Where no mount is nested in another, the file tools and grep answer as before.
  • Hosts:
    • A root configured through a symlink into another mount's directory is that mount's: the outer mount takes none of its files in, and copies it already holds stay, 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 leaves copies in a nested directory.
    • readFileFromDisk refuses a path through a followed link into a nested mount's directory with the code other_mount, and readBinaryFromDisk with the same message. connectome-host eb6f008b already reads other_mount as "that mount's", with a message naming the path to use (identity-module.ts:1069, web-ui-module.ts:1521).
    • A mount configured with a ./ root now hears its watcher, nested or not.
    • On a mount that doesn't follow links, readFileFromDisk refuses a path that is itself a link to a file in a nested mount's directory with the code other_mount and words naming that mount, where main answers symlink ("Symlinks are not allowed"). connectome-host eb6f008b reads that code as above, and its instructions alert then gives the read's own words in place of its remedy for symlink, "put the file itself at that path" (instructions-module.ts:340).
    • Each start reads the roots with an awaited 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.
    • On a mount with a mount nested in it, each file-tool call adds a realpath of its path, and a realpath and a readlink for each segment of it that isn't there yet.
  • Scripts: the texts are new. The refusal for a path written into a nested mount's directory keeps its words. Nothing in connectome-host eb6f008b, chapterx c38b2cd1, discord-mcpl 5f38905e, context-manager e64d8fac or membrane cbebe810 matches 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 test and the unforced files), on e433d674 merged with main e5f50bf3, which merge without conflict. npm test ran 4,077 tests: 4,060 pass, 17 are skipped and none fail. The unforced files pass 128 of 128. CI's run of main at e5f50bf3 on 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. 5be49ca2 and b7aa5867 add 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. 98a5dc52 fixes the comparison CI's macOS jobs failed on, and 6717153a, e631867f and ca731625, 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:

    • the root configurations: an inner mount through a link into the outer one, through a link inside the outer one (passed over at both directories), and through a link, with a relative or an absolute target, to a directory not made yet; one through a link to a link not made yet, its target written plain, with a trailing separator, and with a doubled one; an outer mount through a link; and a root not made yet beneath a link;
    • a mount configured as a link to the outer mount's own root, with the outer configured at its real path and through a link, and one configured as a link inside the outer mount to a directory above it, each nested by its configured path alone;
    • a mount configured as a link to the outer mount's own root by a path that reaches that root through a link above it, as paths under macOS's /var do, 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;
    • the nesting by configured path before the first start, and a root moved while the module was stopped, placed again at the next start;
    • a link into the nested directory on a mount that follows links, for reads, writes, the disk readers and a named sync, and a link to a directory holding the nested mount;
    • a link to a place not made yet in the nested directory, for a write, and targets whose .. climbs from a directory reached through a link or follows a link;
    • a file link into it, for a sync, a read, a grep of the mount and of the path, a write and a forced push, and for a listing that would take a copy's recorded fingerprint as unchanged without reading it;
    • 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, for a forced push and the file tools;
    • a draft made while the path was the outer mount's own file, pushed with and without force after a file link into the nested mount took its place;
    • a deletion stored while the path was the outer mount's own file, pushed after a link into the nested mount took its place, through a final link and through a directory link;
    • read_image of 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;
    • the disk reader with a link swapped in after the file tools' check;
    • on a mount that doesn't follow links: a write through a link into it, a write through a link to a place not made yet in it, and a path that is itself a link into it, for the tools and the disk reader;
    • the contained write, unlink, located read and directory creation, called directly;
    • a watcher of a ./ 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 both autoMaterialize values. It passes both ways, and fails both ways on main. 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, where main's skip advises the forced push. A write through a file link already in place is refused at the tool, where main stores it. A deletion pushed through a final link into the nested mount is refused and leaves the link, where main unlinks the link; through a directory link, it leaves the inner mount's file, which main unlinks.

  • The module's other test files pass beside it, test/workspace-*.test.ts, with none failing. That includes workspace-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) and workspace-path-spelling (8). In workspace-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:

    • the real roots from nesting, start's reading of the nesting, and the constructor's nesting by configured path;
    • the nearest existing directory, for a root or a path not made yet;
    • following a link to a place not made yet, reading its target from the link's directory as it is on disk, and dropping trailing separators;
    • the exception for a mount whose real root is the outer's own or holds it, reading an inner mount's configured path against the outer's real root, and placing each directory above that path on disk to do it, rather than its parent alone;
    • every spelling but the first at start;
    • each of the eight checks where disk is acted on;
    • the file tools' check, and 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;
    • its use of the link's own refusal on a mount that doesn't follow links;
    • readFileFromDisk's passing on of that refusal's code, and read_image's reading of it as its own;
    • "leads into";
    • the watcher's resolved root.

Not verified

  • Windows. On macOS, only CI's run of the suite.
  • watch: 'on-agent-action' and 'always' for the nested-mount tests, which run with 'never'. The watcher test runs 'always'.
  • A root on a share that stops answering, at start.

Out of scope

  • resolveAbsolutePath stays 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.
  • A nested mount's root moved while the module runs keeps its old place until the module starts again. Until then, the outer mount refuses paths at the old place and reads what lies at the new one. Reading nesting again on each pass would keep a snapshot that can go stale.
  • A nested root not made yet, on a disk that folds names. createDir matches 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, on main as well as here.
  • A path whose realpath fails 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 on main.
  • A configured root with .. after a link in it is placed as path.resolve reads 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.
  • Two mounts over one directory, configured alike or one through a link to the other's root, aren't nested either way, as on main, and each holds the directory's files. Refusing such a configuration would be a check of its own.
  • Copies an outer mount already holds at a path that now leads into a nested mount stay held. No push writes them and no tool reads them.
  • A write through a link the mount doesn't follow, leading anywhere else, still stores a change that no push writes, as on main.
  • A nested mount's directory spelled in another case is fix(workspace): take a path as disk spells it on a disk that folds names #525's. One case is left: on a filesystem that folds names, a followed link whose target spells a nested mount's directory in another case isn't matched where realpath keeps 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 main as of 19:14Z on Oct 18, when main is 124edb46. By git merge-tree, three open pull requests that merge with main conflict with this one:

#337, #262, #255 and #253 conflict with main already, and this change adds no file to their conflicts.

What each part is for

  • Real roots at each start. A root's real path takes a 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.
  • A link to a place not made yet leads there, in 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 a readlink only where realpath finds nothing. Following stops after 40 links, as the kernel's does. A loop never gets there, since realpath answers it with ELOOP and nothing is followed. No test reaches the bound, which keeps the recursion finite if links change while it follows them.
  • A link's target read from the link's directory as it is on disk. Read from the path as written, a .. in a target under a link lands somewhere other than where a write through it would create the file.
  • Trailing separators dropped first. A link to a directory is often written with one, and readlink of a path ending in one follows the link the path names, so the link's own place was taken for where it leads.
  • No real root where a mount's real root is the other's own, or holds it. Without it, a mount configured as a link to the outer mount's own root claims every path of the outer mount through its real root. Its nesting by configured path stays, 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 makes it, so the outer mount still passes over the link. The inner mount's configured path is read against the outer's real root as well as its configured one, so that holds for an outer mount configured through a link too. For that reading, readNesting places each directory above the configured path on disk and compares it with the real root. It costs a realBeneath call per directory above each configured path at each start, a single awaited realpath where 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.
  • The checks where disk is acted on. Node has no 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.
  • The file tools' check. Without it, the checks below still refuse the read, the push and the unlink, but a write, edit or delete first stores a change that no push will write, and a read answers that disk couldn't be checked. It costs a realpath per call, and a realpath and a readlink for each segment not there yet, only where a mount is nested. It runs before the mount's turn because a synchronous realpath in 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, grep returns the text of a copy held at a path that now leads into a nested mount's directory, which read refuses. Only a path whose disk the pass couldn't check can be one, so the check costs a realpath per 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 before grep compares the branch, since it awaits disk, and a branch selected meanwhile would otherwise have one branch's text labelled with the other's states.
  • fix(workspace): a mount that doesn't follow symlinks follows none on a path's way #468's refusal on a mount that doesn't follow links. The file tools refuse a link on the path with fix(workspace): a mount that doesn't follow symlinks follows none on a path's way #468's words for that link, rather than words of their own.
  • "leads into" says why a path that isn't written into the nested mount's directory is refused.
  • Considered and cut:

Review

Reviewed in the house: an internal review of the change at 7c0cfda5, then one of each of the next two rounds, 91457041 and 6aa434a8, whose findings the commit after each answers. Ogden-2688 and Langston-2479 read each head from 91457041 on and ran probes of their own. Their findings are fixed here, and the rounds after the third review, 5b60526a and cc69cc7b, had their reads in place of a review of their own. Ogden-2688 approved 608eafea, and Langston-2479 found nothing to change there. e433d674 rewords one log line as Ogden-2688 suggested, and 5be49ca2 and b7aa5867 add #530's tests. CI's macOS jobs at b7aa5867 failed 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. 98a5dc52 fixes 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 of b7aa5867 found the same failure and requested changes. 6717153a adds a test for the case that separates the directory walk from the shorter form the review offered, and e631867f adds a nested mount's directory to effects.ts's note on redirected creates, as the review suggested. Greptile's review of b7aa5867 found that grep still searched a copy held at a path that leads into a nested mount, and ca731625 fixes that. A fourth internal review read the rounds after the third, from 6aa434a8 through a first version of the grep fix. Langston-2479 found that version asked its check after grep compared the branch, and ca731625 asks it before. Ogden-2688 and Langston-2479 read all three commits.


  • Changelog fragment added: changelog.d/workspace-nested-mount-roots.fixed.md

Fixes #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_image case 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 on main. 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 paths realpath can'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.

Phoebe-2419 and others added 10 commits October 18, 2026 11:30
…, 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 slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 both autoMaterialize values, as before.
  • Five breakages, each run against workspace-nested-mount-roots, workspace-three-way and workspace-parent-link. Each fails new tests by name beyond the baseline: checkOpened's nested check, unlinkContained's, the file tools' pre-check in parseFilePath, the disk reader's, and createDir'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.txt into the nested mount at a/b, then a forced push. The push is refused with "leads into mount", and the inner file is untouched (fs/promises' realpath returns disk's spelling).
  • A fresh second reader traced the effect-time checks end to end, by reading. checkOpened, unlinkContained, the disk reader and openContained each 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/b vs a/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 TMPDIR as macOS sets it, the new file is 32/33. With TMPDIR set to its real path (/private/var/folders/…), it's 33/33.
  • Cause, by reading readNesting (index.ts:1938): self is nested by its configured path alone (its real root is the outer's, so claims is false). The candidate that should find it is nestedDir(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/self and the real root /private/var/…/o, so it never lies beneath. On Linux, /tmp has no linked ancestor, which is why ubuntu passes.
  • It isn't only a test artifact. Any macOS host whose mounts are configured under /tmp, /var or 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 (a symlinkSync of 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 later configured at In, not made yet, an outer write work/in/x.txt (with autoMaterialize) succeeds and makes in/. On APFS that is later's root, so the outer mount's file now sits in it. We measured the same on main (124edb46), so this change didn't introduce it. But the description says createDir makes nothing "where a nested root would be, made yet or not". On a case-folding disk that holds for an existing root (native realpath folds 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 its mkdir, or writeContained's O_EXCL create before checkOpened refuses. 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.
    • realBeneath returns 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.

The second reader and the breakages were ours; every count above is from our runs.

@greptile-apps

greptile-apps Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[Medium impact] The changes affect file access across nested mounts within the workspace feature.

Fix the remaining grep path before merging so saved outer copies follow the same nested-mount boundary as read.

Findings

  1. P1 Grep still reads nested copies ▶
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.

Summary

The PR checks nested mounts by their real disk locations and fixes watcher events for relative roots.

  • Outer mounts leave nested mounts' files alone, even when a link leads there.
  • Watchers now match events for roots written with ./.

Intentional limits acknowledged by nissa-seru:

  • resolveAbsolutePath stays lexical and synchronous; its existing contract allows returned paths to pass through links.
  • Nesting changes while running wait until restart; rereading roots on every pass was deliberately excluded.
  • Configured roots with .. after a link follow path.resolve, matching the existing configured-path behavior.
  • Two mounts over the same real directory remain shared; rejecting that setup needs a separate configuration check.
  • Old outer copies remain stored; clearing them would not change disk. Returning their text through grep is the finding above, not an acknowledged exception.
  • Writes through non-followed links elsewhere remain unchanged; this fix targets links into nested mounts.
  • Alternate-case link targets on case-folding filesystems remain deferred because catching them needs extra directory or file-identity checks.
  • Root reads may hold startup while a share does not answer; awaited reads avoid blocking the whole process. Before the first start, nesting stays configured-path-only.
  • A link swapped after the file-tool check can still leave a draft that a later push refuses; synchronous checks inside the mount’s turn were deliberately avoided.

Reviews (1) · Last reviewed commit: "test(workspace): #530's first two cases,..." · Reviewed by Greptile

Comment thread src/modules/workspace/index.ts Outdated
Comment on lines +2032 to +2038
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');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Phoebe-2419 and others added 3 commits October 18, 2026 14:35
…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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@slimepriestess The macOS failure is fixed in 98a5dc52, and CI's macOS jobs pass there. 6717153a and e631867f answer the rest of your review, and the description now says what each point changed. Your changes-requested review stands until you review again, so we're asking you to re-review the head, ca731625, before this merges. That head also fixes a finding of Greptile's: grep searched a copy held at a path that leads into a nested mount, which read refuses.

The blocker. Your reading of the cause matches ours, and we took a different shape from the one you offered. At each start, readNesting places every directory above an inner mount's configured path with realBeneath, and reads the inner mount beneath the outer's root from each one whose place on disk is the outer's real root. Placing only the parent fixes the macOS case too. But where the configured path passes through a link inside the outer mount that leads out of it, the parent-only form nests the mount when the outer is configured at its real path and not when the outer is configured through a link. 6717153a adds a test for that case, "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", and it fails with the parent-only form in place of the walk. 98a5dc52 also adds the kind of test you suggested: "a mount configured as a link back to the outer mount's root keeps the nesting by configured path when its path reaches that root through a link above it" spells the inner mount's path through a link, alias to real, so it fails on Linux without the fix. At ca731625, on Linux with Node 24.22.0, the new file passes 36 of 36, with the temporary directory as it is and reached through a link.

The rest:

  • A nested root not made yet, on a disk that folds names. The description now says createDir makes nothing where a nested root is, or where one not made yet would be as its configured path spells it. Out of scope lists the case, with your measurement on main.
  • Empty entries from a racing swap. e631867f adds a nested mount's directory to the note in effects.ts's header on redirected creates, mkdirs and unlinks. That follows your second reader's reading, and we haven't run the race.
  • realBeneath on a path it can't resolve. We've left the file tools' check as it is, and Out of scope now names the case. With a link loop or a directory without search permission on the path, a write is stored and a forced push skips it, with cannot resolve the directory (ELOOP) or cannot open the file (EACCES). That's the same at the head and on main (124edb46), on Linux with Node 24.22.0. The check refuses what it can place in a nested mount, and the checks where disk is acted on stay the boundary.
  • A root moved while the module runs. Out of scope now names both directions. Until the next start, the outer mount refuses paths at the old place and reads what lies at the new one. A run at the head shows both, and shows a restart placing the root again.

— Fiona-2757

@slimepriestess slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 with TMPDIR as macOS sets it (/var/…) and with it at its real path (/private/var/…). At b7aa5867 it 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.txt into the nested mount at a/b is still refused on APFS. The nested root not made yet (configured In, written as in/) still gets made by the outer mount, which is the case your description now lists as out of scope, with our measurement on main. 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.

@nissa-seru
nissa-seru merged commit 19fc246 into anima-research:main Oct 18, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
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.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
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.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit that referenced this pull request Oct 19, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

workspace: a forced materialize through a file link writes into a nested mount's file (#282's case, open again after #460)

2 participants