Skip to content

fix(workspace): a mount that doesn't follow symlinks follows none on a path's way - #468

Merged
nissa-seru merged 32 commits into
anima-research:mainfrom
nissa-seru:langston/f145-push-through-link
Oct 18, 2026
Merged

nissa-seru merged 32 commits into
anima-research:mainfrom
nissa-seru:langston/f145-push-through-link

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

On a mount that doesn't follow symlinks, a path through a symlinked directory is now refused wherever the link leads, with a refusal that names the link and, for one inside the mount, its target. Before, a link pointing inside the mount was followed. So a materialize could delete, create or overwrite a file under another path than the one it named, and a sync could take that file's bytes in under the path's name. readBinaryFromDisk also stops reading outside its mount through a symlink, the disk readers stop waiting on a FIFO, and deleting an entry disk never agreed with leaves nothing pending.

Problem

followSymlinks defaults to false. Its doc says "Whether to follow symlinks (default: false)", and 0.12.0's changelog calls the default "refuse". 0.22.0's changelog says a materialize locates a file "never through a symlink the mount doesn't follow". The workspace held a path to that only at its last component, with O_NOFOLLOW or an lstat. A directory on the way was resolved with realpath and checked only for lying inside the mount. So a symlinked directory leading out of the mount was refused, and one leading inside it was followed.

Take a mount on main that doesn't follow symlinks, where disk has replaced a tracked directory l/ with a symlink to elsewhere/, inside the mount. At 119a86256:

  • A deletion: the workspace's deletion of l/x.txt, pushed by materialize with applyDeletions, unlinked elsewhere/x.txt. With force, it unlinked it when it held other bytes than the workspace's copy.
  • A write: a materialize of a new l/new.txt created elsewhere/new.txt, and one of an edited l/x.txt overwrote elsewhere/x.txt.
  • A sync: a sync of l/x.txt took elsewhere/x.txt's bytes in as l/x.txt.
  • A link leading out of the mount was refused in each of these, as "a parent directory resolves outside the mount".

At 9e3e407c7, the red-first run (Checks) shows the same for the unforced deletion, the write, the edit and the sync. There, readFileFromDisk and read_image read a file through such a link, and refuse one leading out of the mount as escape, Symlink escape detected: <path>.

WorkspaceModule.readBinaryFromDisk stated and read the path as written, though its doc promises "Same mount boundary and traversal guard as every other path here". So it followed a symlink at the path or on its way, wherever it led. On main it returned the bytes of a file outside the mount, through a file symlink and through a directory symlink on the way, where readFileFromDisk refused both.

Changes

  • One rule where a path's directories are found (observe.ts). On a mount that doesn't follow symlinks, a path with a symlink among its directories beneath the mount's root is unobserved, and refused wherever the link leads.
    • Where it's asked: in the places the workspace finds a path's directories:
      • locateDir, behind each push's parent directory, each listing and each scope;
      • parentPlacement, behind observePath, for each file a sync observes;
      • openContained, for each file a pass reads;
      • confirmAbsent's walk, behind each proof of absence, and createDir, for the directories a push makes.
    • How: linkRefusal takes what realpath resolved the path to. Where that is the root's canonical path joined with the path's own, none of its directories beneath the root is a link, and nothing more is asked. Otherwise linkOnTheWay asks each directory, from the root down, whether it's a symlink (readlink). Only a symlink is refused: a name that disk spells otherwise, with another case or Unicode normalization on macOS or Windows, also resolves to another string. The walk takes . and .. out of the path first, as the lookups that follow do. A directory the walk can't search ends it with nothing found, since the caller's own lookup meets the same answer. Any other readlink answer it doesn't expect refuses the path ("cannot tell whether "work/a" is a symlink (EIO)").
    • When: after the resolution and before the containment check, so a link gets its own answer wherever it leads. In openContained the check comes after the open, of the resolution the existing descriptor check holds to the file opened, and before the file's type. A link that leads nowhere, or loops, gets its answer where its resolution fails.
    • What stays as it was: a mount root that is itself a symlink is still the root. A mount that follows symlinks keeps today's rule: an inside link is followed, and one leading out is refused. A symlink at the path itself keeps its refusal.
  • The refusal (linkWords, from where linkLeads finds the link leads, for the observations through linkReason and for the disk readers through linkReadError) has the link as its subject and says where it leads, with paths spelled as the tools take them:
    • A link to a directory inside the mount: "work/l" is a symlink to "work/elsewhere", which this mount does not follow; the mount's host can set followSymlinks: true;
    • A link to a file inside the mount: the same without the setting, which following the link wouldn't answer;
    • A link into the directory of a mount nested in this one (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): "work/l" is a symlink to "work/nested/sub", which this mount does not follow; it lies in mount "inner", nested in mount "work": use "inner/sub" in place of "work/l", without the setting, which would only let this mount reach into the nested one's directory. MountView carries the nested mounts' names for it beside others;
    • A link leading outside the mount: "work/l" is a symlink leading outside the mount, which this mount does not follow;
    • A link that leads nowhere, or loops: "work/l" is a symlink, which this mount does not follow.
  • The disk readers (openContainedFile, behind readFileFromDisk and read_image):
    • The link: they refuse a path through such a link with the same refusal after the path (work/l/x.txt: "work/l" is a symlink to "work/elsewhere", …). Its code follows where the link leads: escape for a link leading outside the mount, as on main; other_mount for one into a nested mount's directory, the code 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 gives that mount's own paths; and for any other a new code, parent_symlink, apart from symlink, which stays for a file that is itself a link. They ask before they lstat, and again of the path's resolution after the open, so a directory swapped for a link in between is refused too.
    • A file that isn't regular: a FIFO, socket or device is refused at the lstat, without opening it, as not_found, Not a regular file: <path>. The open also takes O_NONBLOCK, so a FIFO reached through a symlink the mount follows, or swapped in after the lstat, doesn't wait for a writer.
    • read_image passes the reader's own words through for these, and for any escape, where it put its own in their place. For a link at the path itself, the words are its old Symlinks are not allowed: <path>, and for a followed link that resolves outside the mount, its old Symlink escape detected: <path>.
    • The caller's own check (inspect, such as a size limit) runs after the boundary's checks.
  • readBinaryFromDisk reads through openContainedFile, the reader readFileFromDisk uses, so it takes the mount's link rule and the canonical containment check. It keeps its result shape, and its texts for a missing file, a file that isn't regular and a file over maxBytes.
  • A deletion whose evidence holds no agreed disk copy, where disk last agreed it had nothing at the path, or the evidence holds nothing for the path and the workspace created the entry, puts the store back at the agreement. It pushes nothing and clears the path's whole intent. With no tombstone it needs no proof of absence, which a path beneath a link the mount doesn't follow can't give, and a conflict recorded for the path has nothing left to be about once the workspace has given up its side. Where that conflict named a disk file the workspace never took in, delete's answer carries a note saying nothing was unlinked, since deleted: true alone would read as having removed that file. A deletion doesn't look at disk, so where nothing recorded a file there, its answer has no note. A result the tool-result guard withholds keeps the note in its stub. Every other deletion keeps its tombstone: one of a copy disk agreed with, one where a push of ours may have landed, and one of an entry from before the evidence. A push still recorded as pending counts as one that may have landed, whether or not its bytes reached disk. Where they didn't, the next pass over the path finds disk holding nothing there, which settles the deletion.
  • followSymlinks's doc says what it covers, and MountView carries the mount's name for the refusal.
  • Four changelog fragments: workspace-parent-symlinks.fixed.md, workspace-parent-symlinks.breaking.md for operators whose mounts hold links inside them and for callers choosing advice by the code, read-binary-from-disk-links.fixed.md and disk-readers-fifo.fixed.md.

What each part is for

  • The rule at each place a path's directories are found is what makes the setting hold for each operation. Each placement has a test that fails without it (Checks).
  • Asking realpath's answer first adds no system call per path in the four observation places in the common case, since each already resolves the path: it compares two strings. Asking each directory with readlink before every observation, read and push, as adca6af2d does, took about 1.9× main's CPU for a whole-mount sync of 2000 files beneath four directories when nothing had changed. This change's cost, if any, is within the runs' spread (Checks).
  • The walk after a different answer tells a link from another spelling of the same name. Refusing every different answer would refuse paths on macOS and Windows whose spelling differs from disk's.
  • Taking . and .. out before the walk makes it walk the path the lookups resolve. Otherwise work/nope/../l/x.txt would stop at the missing nope and read through l.
  • Refusing on an answer the walk doesn't expect keeps the rule from failing open, where a directory it can't search ends the walk because the caller's lookup fails on it the same way.
  • The check before containment gives a link one answer, whether it leads inside the mount or out of it.
  • Naming the link's target lets an agent compose the path to use: no tool shows where a link leads. One realpath and one stat of the link's target are made, and only when refusing. The stat keeps the setting to links to a directory, since following a link to a file can't make a path beneath it exist.
  • The code by where a link leads lets a caller that chooses its advice by code, such as connectome-host's uploads, keep giving its outside advice for a link leading outside the mount, as on main. parent_symlink, apart from symlink, lets it tell a link among the path's directories, whose refusal names the path through the link's target, and the setting only for a link to a directory, from a file that is itself a link, which has to be put at the path itself. With one code for both, by our reading, connectome-host's instructions alert would give the remedy for the second, "put the file itself at that path", for both. other_mount for a link into a nested mount's directory keeps a host that maps parent_symlink to the setting from offering it there, where it would let this mount reach the nested mount's files.
  • The deletion rule keeps the workspace from holding a change no tool can clear. A write through an inside link is now stored and its push refused. Without the rule, deleting that entry left a tombstone whose proof of absence the link would never give, so the deletion stayed pending until the link went. A push applies what the store changed since disk last agreed, and where the agreement holds no copy, deleting the entry leaves nothing to apply. The commit decides it, as the one place that still knows whether disk ever agreed with the entry: every deletion drops the entry's origin, so by the planner's turn a deleted draft looks like an entry from before the evidence.
  • The readers' walk before their lstat gives a link its own answer, ahead of the file's type and a caller's size check. It costs one readlink per directory on each reader call, not per file of a pass. Their check after the open closes the window between the walk and the open, where openContained's single check after its open already has.
  • The caller's inspect after the boundary keeps a size limit from answering for a file the boundary refuses, such as an outside file on a mount that follows symlinks.
  • Refusing a file that isn't regular before the open keeps readBinaryFromDisk from waiting on a FIFO, where it used to answer at once, and ends the same wait for the other two readers. O_NONBLOCK covers a FIFO the lstat lets through.
  • readBinaryFromDisk through openContainedFile gives it the boundary its doc promises, from the reader the other two already share, rather than a second copy of the checks.
  • Alternatives not taken:
    • a readlink walk before every observation, read and push, which costs a sync about 1.9× main's CPU (above);
    • a refusal saying "use the path it leads to, or set followSymlinks: true on the mount", which holds for no outside link and names no path;
    • a computed path to use, the link's target joined with the rest of the path, which runs beneath a file for a link to one, through a further link, or to the root's directory;
    • a race test that swaps a directory for a link during a sync through a hooked lstat: through the tools an earlier check answers first, so the tests ask each primitive directly.

What a caller notices

On a mount that doesn't follow symlinks, which is the default, a path through a symlinked directory inside the mount is no longer read or written through it:

  • materialize lists it under skipped, "cannot verify disk copy (…) — fix it and materialize again" with the refusal above, and "with applyDeletions" for a deletion, where it wrote, created or unlinked the file behind the link.
  • write, edit and delete on a mount with autoMaterialize change the workspace and report that the push didn't reach disk, with that refusal, where the push wrote or unlinked through the link.
  • A sync that names it skips it with that refusal, where it took in the file behind the link. A sync of the whole mount is unchanged, since a walk never descended a symlink.
  • ls and glob of the directory a link stands at give its tracked files that refusal as their note, and name it under incomplete, where they listed what lies behind the link.
  • readFileFromDisk, read_image and readBinaryFromDisk refuse it with that refusal, readFileFromDisk with the new code parent_symlink, or other_mount for a link into a nested mount's directory, where they read the file behind the link.
  • read is unchanged: it reads the workspace's copy.
  • A change stored through the link stays pending until disk can be observed there: the link goes, or, for a link to a directory inside the mount, the host sets followSymlinks: true. A file the agent created through the link clears when it deletes the entry. For a link inside the mount, the path through its target is the one to use from the start. For a link into a nested mount's directory, the refusal names that path in the nested mount, where readFileFromDisk read that mount's file through the link.

A symlinked directory leading out of the mount gets the refusal for an outside link, where it got "a parent directory resolves outside the mount". readFileFromDisk and read_image keep its code, escape, and give the outside link's refusal where they said "Symlink escape detected".

A host that keeps a mount with an inside directory link can set followSymlinks: true on it, or point the agent at the target the refusal names. The changelog says this.

On a mount that follows symlinks, links are followed as before. readBinaryFromDisk there now refuses a target outside the mount, Symlink escape detected: <path>, where it read it, and answers that before its size limit.

On any mount:

  • A FIFO, socket or device: readFileFromDisk and read_image refuse it at once, Not a regular file: <path> (readFileFromDisk as not_found), where they waited for a writer on a FIFO. readBinaryFromDisk answers "Not a file", as before.
  • A path through a file: readBinaryFromDisk answers File not found on disk: <path>, where it gave the system's message with the absolute path.
  • A file this user can't read: readBinaryFromDisk says Unable to read file: <path> whatever its size, where it gave the system's message with the absolute path, or "File too large" over maxBytes.
  • Deleting an entry the workspace created that disk never agreed with, with no push of it in doubt, or one at a path disk last agreed had nothing, leaves nothing pending, where it left a deletion pending until a materialize with applyDeletions settled it. A deletion made before this change keeps the tombstone it wrote, which settles once disk can be observed at the path. An entry from before the evidence, with no record of where it came from, keeps its tombstone too.
  • Deleting a draft written over a disk file the workspace never saw completes and leaves that file alone, for a sync to observe as it does any file disk holds. It was refused, with a sync of the path offered first and then a forced push, which would have deleted that file. Where a push had met that file and recorded the conflict, the answer is { path, deleted: true, note }, with the note saying nothing was unlinked, that disk held a file there the workspace never took in, and that a sync observes whatever disk holds there.

connectome-host reads disk through readFileFromDisk in four places this change reaches. At its main (b73d515e), none of them maps parent_symlink, or other_mount for a link into a nested mount's directory, and three of its tests pin main's answers in the first three:

  • Its web UI's /files/ route no longer serves a file reached through a linked directory inside a mount that doesn't follow symlinks. By our reading, it answers 404 with the refusal for parent_symlink and other_mount, where it answers 403 for symlink and escape.
  • Its InstructionsModule injects nothing from an instructions file reached through such a link, where it injected the file. By our reading, it raises its instructions-unavailable alert with the refusal's own words as the reason.
  • Its uploads choose their advice by the code. They now refuse an inside link with its target named, where they sent the file behind it, and by our reading give parent_symlink and other_mount their default advice, that the host must fix the read. For a link leading out of the mount, they keep their outside advice, now with the outside link's refusal where "Symlink escape detected" stood. They no longer wait on a FIFO.
  • Its activity module, by our reading, takes no origin hint from a file reached through such a link, where it read the file's head, and skips the refused read without a word.

anima-research/connectome-host#323, on its main since 8409d2d, answers both codes. Its web UI's route gives them 403 with the refusal. Its uploads give parent_symlink advice of its own, and other_mount the refusal's reason, with no advice of its own. Its instructions alert carries the refusal's own words.

Tests

New, in test/workspace-parent-link.test.ts, each pinning its refusal word for word. Unless named otherwise, each runs with the link leading inside the mount and with it leading out:

  • A workspace deletion isn't applied through the link, forced or not, and the file behind it stays.
  • No file is created through it, forced or not.
  • An edit isn't written through it, forced or not, and the unforced refusal offers no force.
  • write, edit and delete on a mount with autoMaterialize report their push refused, with no advice to pass force, and nothing behind the link changes.
  • An explicit sync doesn't read through it, and history keeps its copy.
  • readFileFromDisk and read_image refuse a path through it, readFileFromDisk with the code for where it leads: parent_symlink for a link inside the mount, also for one to a file, to a FIFO, to the root, and for one that leads nowhere or loops; other_mount for one into a nested mount's directory; and escape for one leading out. Where the workspace's own copy can't be read as an image, read_image still gives the link's refusal, not that copy's failure.
  • A listing of the directory the link stands at names it under incomplete instead of walking it.
  • Each primitive refuses it with the link's answer, whatever lies behind it: checkDir, observePath, readPath of a file and of a directory, and confirmAbsent, asked directly, since through the tools an earlier check usually answers first.
  • A .. after a name that isn't there hides no link beyond it, through readFileFromDisk, readBinaryFromDisk and observePath.
  • A link into a nested mount's directory names the path to use in that mount, and not the setting: a disk reader's refusal pins it word for word, with the code other_mount; a write through the link is refused with it and creates nothing in the nested mount's directory; and read_image gives it where the workspace's copy can't be read as an image. This test has one mount nested in the other and a link leading inside.
  • A link to a directory beside the mount whose name starts with the mount's is outside it, a link to a file inside the mount names its target without the setting, and a link to the mount's root names the mount as its target. A disk reader through a link to a FIFO names it without the setting, and through a link to the root names the mount with it.
  • A path that resolves to another spelling of itself, with no link on its way, is reached by checkDir, observePath and readPath. Linux has no such spelling, so the test respells realpath's answers with a . after the root.
  • A directory the walk can't ask (a readlink hooked to answer EIO, and EPERM) refuses the path through readFileFromDisk, read_image, checkDir and a materialize, rather than letting a link beneath it through.
  • A link that leads nowhere, or loops, is named as one by each primitive, by a materialize, forced or not, and by readFileFromDisk.
  • A directory swapped for a link after the readers' walk (a hooked lstat) is refused by readFileFromDisk after the open.
  • A symlink at the path itself gets no advice to pass force, which a forced push refuses too.
  • readBinaryFromDisk reads nothing through a link: a file symlink leading out, a directory symlink leading out, or one leading inside.
  • A mount root that is itself a symlink is the root, also where a path names it on its way back into the mount.
  • On a mount that follows symlinks, an inside link is still followed and one leading out refused, and readBinaryFromDisk reads through an inside link and refuses an outside one, with or without maxBytes.
  • A deletion whose evidence holds no agreed disk copy leaves nothing pending:
    • a write through the link is refused, and deleting the entry clears it, with the link leading inside and out;
    • a file disk last agreed it had deleted, written again and deleted beneath a link;
    • a draft written over a disk file the workspace never saw, with its push refused or with no push. The file stays, and a sync takes it in;
    • a draft disk never agreed with, on a plain mount too.
  • The deletion's note is in delete's answer where the push had recorded the collision with that file, and where a file disk agreed it had deleted was written again while disk got another (a recorded both-changed conflict). A withheld result keeps it. A deletion that cleared no conflict answers without one, and so does one that cleared a conflict naming no disk file: one branch's deleted-on-disk conflict, after another branch's pass recorded that disk had nothing there.
  • A deletion keeps its tombstone where the evidence may hold a copy of ours: a copy disk agreed with, also beneath a link, where it unlinks nothing; a pending push whose bytes are on disk, never confirmed, which stays pending through the deletion's pass; or an entry from before the evidence.
  • A deletion over a pending push whose bytes never reached disk leaves nothing pending once a sync of the mount passes over it, and the push is no longer pending.
  • Control: a regular parent directory is still read and written.

New, in test/workspace-read-file-from-disk.test.ts:

  • A FIFO is refused at once by each disk reader, with no reader opening it (a hooked open), each answer pinned and raced against a 10 s timer. It's skipped on Windows, which has no FIFOs.
  • A FIFO behind a symlink the mount follows is opened without waiting and refused.
  • A directory on the way this user can't search is reported as the lookup's own EACCES, not as a symlink. It's skipped as root.
  • readBinaryFromDisk's own answers, which had no test: a file's bytes and absolutePath, "File not found on disk" for a missing file and for a path through a file, "Not a file" for a directory, "File too large: N > M bytes" over maxBytes, with a file of exactly maxBytes read, and "Unable to read file" for a file this user can't read, with or without maxBytes.

Changed: five assertions in four tests of test/workspace-three-way.test.ts. For a directory link leading out of a mount that doesn't follow symlinks, they pinned "a parent directory resolves outside the mount", "the directory resolves outside the mount" or "outside the mount". They now pin the outside link's refusal. Where a test also runs with followSymlinks: true, it still pins the older words there. In test/workspace-read-file-from-disk.test.ts, the test of such a link keeps its escape and also pins the outside link's refusal. In test/workspace-file-tools.test.ts, "a deletion of a draft that met a disk file the workspace never saw" pinned the refused deletion and its offer of a forced push. It now finds the deletion complete, disk's file kept, and the answer's note.

Checks

On Linux, with membrane 0.5.87, context-manager 0.14.1 and chronicle 0.4.0 installed. The head is a24f10bd5, which has merged main at 9e3e407c7. The earlier removals, each test file alone, the red-first run of workspace-read-file-from-disk and the timing ran at 1c2791cb7. Since then the change has gained:

  • the words for a link into a nested mount's directory (49268ef91);

  • tests, two comments and the changelog (0e0a39dbf, aa42f6d8d);

  • a merge of main whose one conflict was an import (d41db92c9);

  • the codes parent_symlink and, for a link into a nested mount's directory, other_mount (e8d832b56, 71ffd25a3, with a comment moved in 9af0adce4);

  • the breaking fragment (565259a0a);

  • the nested mount's words saying what the path to use replaces (9453858a6);

  • a merge of main at 9e3e407c7, whose two conflicts were imports (a24f10bd5).

  • CI's steps (npm install, npx tsc --noEmit, npm run build, npm test) at the head: tsc and the build are clean, and npm test passed all 3950 tests in 259 suites under each of Node 24.22.0 and Node 20.20.2.

  • Each test file in its own process, on Node 24 and without --test-force-exit, at 1c2791cb7: 177 files and 3022 tests, none failed, the three files CI's unforced step runs among them. At the head, workspace-parent-link alone passed all 47 of its tests under each Node. Against main, by the diff, the change adds one file and 52 tests: the new workspace-parent-link (47 tests in 4 suites) and five more in workspace-read-file-from-disk (19, where main has 14). It adds or removes no other test, and changes one in workspace-file-tools and five assertions in workspace-three-way.

  • CI's pack check (node scripts/pack-installs-alone.mjs, after npm install --ignore-scripts and the build) passed at the head on Node 24.22.0 with Bun 1.4.3, where CI uses Bun 1.3.14.

  • Red-first: two of the change's test files, built on main's copies of the three workspace sources this change touches (observe.ts, index.ts and types.ts):

    • workspace-parent-link, at the head on 9e3e407c7's copies: 40 of its 47 tests fail. The seven that pass: a regular parent directory, a path that resolves to another spelling of itself, a mount root that is itself a symlink, an inside link on a mount that follows symlinks, the deletions that keep their tombstone, and the deletion over a pending push whose bytes never reached disk, which are controls; and a symlink at the path itself getting no advice to pass force, which main has given since fix(workspace): the file tools store exactly what they're given or refuse, and say when a stored change didn't reach disk #456.
    • workspace-read-file-from-disk, at 1c2791cb7 on 77066360's copies: 5 of its 19 fail. They are readBinaryFromDisk's own answers, where a path through a file gave the system's ENOTDIR message; a file it can't read, the system's EACCES message; the FIFO, with readFileFromDisk still waiting after 10 s; the FIFO behind a followed symlink, likewise; and a directory link leading out, whose words differ. The 14 that pass are main's other 13 tests and the control for a directory this user can't search.
  • Removals: at 1c2791cb7, each of 41 pieces was taken out alone, or turned round, and the build and four files (workspace-parent-link, workspace-read-file-from-disk, workspace-read-image and workspace-three-way) run. Each number below is how many of those tests then failed, all of which pass there. 39 of the 41 failed at least one. Each piece added since was taken out at the commit its line names, with workspace-parent-link run, and each failed a test that pins it:

    • Where a path's directories are found: locateDir's check 17, and for a link its realpath fails on 1; parentPlacement's 15, and 1; openContained's 2, for a looping link at its open 1, and its order before the file type 2; confirmAbsent's 3; createDir's 1.
    • The walk: refusing a different answer outright instead of walking 9; walking the path as the lookups resolve it 2; refusing when unsure 2; ending at EACCES 1; EPERM leaving it unsure 1.
    • The refusal: naming an inside link's target 15; spelling paths from the mount 33; the setting only for a link to a directory 2; escape for a link leading outside 3; at d41db92c9, naming a nested mount's path for a link into its directory 1.
    • The deletion rule: clearing the intent at all 7; for P absent 2; for a store-origin entry with no P 5; only with store origin where there's no P 3; the whole intent, conflict included 2; no push 4.
    • The deletion's note: returning it 2; answering with it 2; no note where none was made 3; a withheld result keeping it 1; at d41db92c9, its condition that the cleared conflict names a disk file, widened to any conflict, 1.
    • The disk readers: the walk before the lstat 3; the check after the open 1; a file that isn't regular refused at the lstat 1; O_NONBLOCK 1; inspect after the boundary 1; readBinaryFromDisk through openContainedFile 7, its "Not a file" 1, and its missing path through a file 1; read_image's words for a symlink 1, for an escape 1, and for a file that isn't regular 3.
    • The codes, at the head: parent_symlink for a link on the way, turned back to symlink, 6; other_mount for a link into a nested mount's directory, given parent_symlink instead, 1; and read_image's case for each, mapped to not_found, 1 and 1.
    • The one not caught at 1c2791cb7 that stays so: the outside link's words don't build without them, since the inside case would then read a target an outside link doesn't have.
  • Timing, at 1c2791cb7, whose sync path the head keeps (49268ef91 adds words to a refusal, made only when refusing): a whole-mount sync of 2000 files beneath four directories, in six rounds alternating that commit and the same built on main's copies of those three sources, at load 7 to 17.

    • Warm syncs, where fingerprints vouch: CPU median 1106 ms at 1c2791cb7 and 1079 ms on main's sources, ranging 960 to 1412 and 883 to 1509 ms over 18 syncs each.
    • Cold syncs: median 1896 and 2086 ms, ranging 1767 to 2357 and 1583 to 2275 ms over six each.
    • The six paired rounds' ratios run from 0.91 to 1.28 warm and 0.80 to 1.12 cold. The same probe at four other commits whose sync path is this change's, 9ad2677bb, bcc914955, 83242279b and 122bccebf, put the change's warm median 2.5%, 2.3% and 8.1% above main's and 7.4% below, where this run puts it 2.5% above, and its cold median 4.4%, 11% and 7.3% above and 1.6% below, where this run puts it 9.1% below. Every run's ranges overlap. So these runs show no difference beyond their spread, and can't rule out one smaller than it. Walking every path with readlink, as adca6af2d does, took about 1.9× main's warm CPU at the medians in the same probe, its fastest sync slower than main's slowest.

Not verified

  • macOS and Windows. CI runs macOS. On Linux, the walk's answer for a name spelled otherwise than disk spells it runs only through the test's respelled realpath, and its answer to an unexpected readlink error only through a hooked readlink. On Windows, libuv's readlink answers EINVAL for a path that isn't a reparse point (src/win/fs.c, fs__readlink), which the walk reads as "not a link", but nothing here ran there.
  • Bun, which connectome-host runs this code on, in part. At 9ad2677bb and bcc914955, before this change took in fix(workspace): the file tools store exactly what they're given or refuse, and say when a stored change didn't reach disk #456's file tools, the readers, a write and a listing through inside and outside links answered under Bun 1.4.3 as under Node 24.22.0, and readlink and realpath gave the walk the same answers. The suite runs on Node, and the walk's answers to an unexpected readlink error and to a respelled name weren't run under Bun.

Out of scope

  • A directory swapped for a link and back, between the resolution and the walk, is taken for another spelling and read where the resolution led, inside the mount. That needs a process writing inside the mount at that moment, which could make the same change to the file itself.
  • grep of a path whose last component is a symlink takes it for a single file and answers no matches and no incomplete entry, before and after this change.
  • resolveAbsolutePath stays lexical-only, as documented, and deprecated.
  • A path written with a backslash on Windows, such as work/l\x.txt: parentOf and nameOf split only on /, so to them its parent is the mount's root, work, while Windows reads it as x.txt in work/l. That's how main parses a path, and this change leaves it as it is.
  • readBinaryFromDisk's read checks maxBytes against the size its open's fstat gives and then reads the whole file, as on main, so a file that grows past maxBytes in between is read whole. fix(workspace): three reads' size limits bound the read itself #409 bounded the read itself for readFileFromDisk and read_image.

Merge notes

Against main at 9e3e407c7, this change meets three open pull requests:


Written by Langston-2479 (Claude Opus 5.5), to a design by Ada-1017 (Claude Opus 5.5), who also set the refusal's words. Phoebe-2419 (Claude Opus 5.5) found the deletion through an inside link with a probe of main. After four rounds of our internal review, Ogden-2688 (Claude Opus 5.5) reviewed its code at bcc914955, and Esther-2584 (Claude Opus 5.5) at bcc914955, its note at 4a8a8897b, its merges of main at 122bccebf, 7ef66e79a and d41db92c9, the words for a link into a nested mount, whose need she found, at 49268ef91, the fourth round's fixes at aa42f6d8d, and the codes for a link on the way, whose cases she found needed telling apart, at 71ffd25a3.

Langston-2479 and others added 20 commits October 17, 2026 13:42
…a path's way

On a mount that doesn't follow symlinks (followSymlinks unset or false), a
symlinked directory among a path's parents that pointed inside the mount was
followed: locateDir, parentPlacement, openContained and confirmAbsent resolved
a parent with realpath and checked only that it lay inside the mount. So the
workspace acted on a file under another path than the one it named. On main
119a862, with l/ replaced by a link to elsewhere/: a workspace deletion of
l/x.txt unlinked elsewhere/x.txt (with force, even holding other bytes), a
materialize created elsewhere/new.txt or overwrote elsewhere/x.txt, and an
explicit sync took elsewhere/x.txt's bytes in. A link leading out of the mount
was refused. readBinaryFromDisk stat'd and read the lexical path, so it
returned a file outside the mount through a link at the path or among its
directories.

- observe.ts: on a mount that doesn't follow symlinks, linkOnTheWay asks each
  directory beneath the mount's root, from the root down, whether it is a
  symlink (readlink). locateDir, observePath, openContained and confirmAbsent
  answer a path through one as unobserved, with linkedPathReason naming the
  link and the way out (the path it leads to, or followSymlinks: true), before
  anything resolves the path, so a link gets one answer wherever it leads. A
  root that is itself a link is still the root, and a mount that follows
  symlinks keeps today's rule.
- index.ts: openContainedFile refuses such a path as 'symlink' (a directory
  link leading out of the mount was 'escape'). readBinaryFromDisk reads
  through openContainedFile, keeping its own result and its own errors.
- reconcile.ts: a materialize's unverified refusal doesn't offer force for this
  reason, which force never gets past.
- types.ts: followSymlinks's doc says what it covers.
- Tests: test/workspace-parent-link.test.ts covers each push effect, a sync, a
  listing, readFileFromDisk and readBinaryFromDisk through an inside and an
  outside parent link, each primitive directly, and a mount that follows
  symlinks. Four assertions in workspace-three-way and one in
  workspace-read-file-from-disk turn round to the new reason; the link leading
  out of the mount is still refused there.
- changelog.d: workspace-parent-symlinks.fixed.md and
  read-binary-from-disk-links.fixed.md.

Found through Phoebe-2419's probe of a deletion through an inside link.
adca6af asked every directory on a path's way whether it is a symlink
(readlink, one call each) before each observation, read and push. A sync
observes every file it passes, so on a mount that doesn't follow symlinks a
whole-mount sync of 2000 files beneath four directories took about 1.8x the
CPU of main's when fingerprints vouched (a timing probe at adca6af,
interleaved with main six times on the VPS).

Each of those places already resolves the path with realpath. Where its
answer is the root's canonical path joined with the path's own, no directory
beneath the root resolved through a link, so nothing more is asked
(linkRefusal). Only another answer walks the directories (linkOnTheWay): a
link, or a name spelled otherwise than disk spells it, as on a file system
that ignores case or Unicode normalization, which isn't a link and isn't
refused. The check now sits after the resolution and before containment, so
a link still gets one answer wherever it leads, and in openContained it is
asked of the path's resolution after the open, which the descriptor check
holds to the file opened.

- observe.ts: linkRefusal; locateDir, parentPlacement (observePath) and
  openContained ask it in place of the walk before them. confirmAbsent's
  component loop, which already lstats each component, is unchanged, and so
  is openContainedFile's walk before it opens, a read per call rather than
  per file.
- Test: a path that resolves to another spelling of itself, with no link on
  its way, is reached by checkDir, observePath and readPath. Linux has no
  such spelling, so the test respells realpath's answers with a `.` after the
  root.
openContainedFile refuses a path through a symlinked directory on a mount
that doesn't follow symlinks with the link and the way out, and read_image
put its own "Symlinks are not allowed: <path>" in their place. It now passes
the core reader's words through for the symlink code: for a link at the
path they are that same sentence, so nothing changes there.

The parent-link test's readFileFromDisk case now also asks read_image for an
image behind the link, inside the mount and out of it, and the spelling
test's failure messages no longer stringify a BigInt.
…are refused

write, edit and delete on a mount with autoMaterialize push through the same
effects a materialize uses, so they already refuse a path through a symlinked
directory on a mount that doesn't follow symlinks. This pins what an agent
reads there: the push refused with the link and the way out, no advice to
pass force, and nothing created or changed behind the link, with the link
leading inside the mount and out of it.
…ng for a writer

openContainedFile opened its file without O_NONBLOCK, so readFileFromDisk
and read_image held their call on a FIFO in the mount until something wrote
to it. readBinaryFromDisk, which this change routes through
openContainedFile, had stat'd the path and answered "Not a file", so it
would have started waiting too.

- index.ts: openContainedFile opens with O_NONBLOCK (observe.ts's NONBLOCK,
  0 where the platform has none), as the pass's own reader does, so a FIFO
  opens at once and its regular-file check refuses it.
  readBinaryFromDisk keeps its "Not a file: <path>" for a file that isn't
  regular, which that check reports as not_found from its fstat with no
  errno.
- test/workspace-read-file-from-disk.test.ts: a FIFO is refused at once by
  readFileFromDisk (not_found), read_image and readBinaryFromDisk ("Not a
  file"), each raced against a 2 s timer; skipped on Windows, which has no
  FIFOs.
…ontainedFile

readBinaryFromDisk had no test. Through openContainedFile it must still
answer as it did: the bytes and absolutePath of a file, "File not found on
disk" for a missing one, "Not a file" for a directory, and "File too large:
N > M bytes" over maxBytes, with a file of exactly maxBytes read. A control:
main answers the same.
… the walk closed

Answers the internal review af-f145-parent-links (wf-2376) of 377a5f0.

The refusal (I6, I7, I12, I15, I17), as the module's owner set it: the link
is the subject, the refusal says where it leads, and it offers a way out only
where one works, from one function for every reader (linkReason).
- Inside: `"l" is a symlink to "elsewhere", which this mount does not follow:
  use "elsewhere/x.txt", or the mount's host can set followSymlinks: true`.
- Outside: `"l" is a symlink leading outside the mount, which this mount does
  not follow`.
- Dangling, or a loop: `"l" is a symlink, which this mount does not follow`.
The disk readers give the same sentence spelled from the mount's name, after
the path. One realpath of the link, made only when refusing.

The walk (observe.ts):
- I1: linkOnTheWay fails closed: a readlink answer other than EINVAL,
  ENOENT or ENOTDIR leaves it unsure, and the path is refused, "cannot tell
  whether "a" is a symlink (EIO)", where it ended the walk as no link.
- I4, I28: it walks the path as the lookups resolve it, `.` and `..` taken
  out, so `work/nope/../l/x.txt` no longer reads through l, and a root that
  is itself a link, named on the way back into the mount, isn't one.
- `through: 'parents'` walks a path's directories and reports the whole
  path, so the way out names the file, not its directory.
- I3: a link on the way that leads nowhere or loops gets the link's reason
  in locateDir's and parentPlacement's realpath failures and in createDir's
  EEXIST, where it got "a parent path is not a directory" or "cannot resolve
  the directory (ELOOP)".
- I8: openContained asks about the link before the file type, so a link to
  a directory or a FIFO gets the link's answer.

The disk readers (index.ts):
- I11: openContainedFile asks again of the path's resolution after the open,
  so a directory swapped for a link since the walk is refused.
- I5: its caller's inspect runs after the boundary's checks, so a size limit
  never answers for a file the boundary refuses (as anima-research#282 orders it).
- I2, I19: a file that isn't regular is refused at the lstat, unopened, as
  "Not a regular file", and the fstat check says the same, where it said
  "File not found".
- I16: readBinaryFromDisk answers "File not found on disk" for a path
  through a file too, and "Not a file" for any file that isn't regular.
- I21: the walk's refusals carry stage 'readlink'.
- I14: a materialize's unforced refusal offers no force for any symlink the
  mount doesn't follow, at the path too (isSymlinkRefusal), since a forced
  push refuses it as well.

Texts: openContained's and openContainedFile's docs (I13), followSymlinks's
doc (I20, I23, I24), and the three fragments (I22, the new wording).

Tests: workspace-parent-link pins every reason exactly and adds the `..`
spelling, the unsure walk (a hooked readlink), dangling and looping links
forced and not, the swap after the walk (a hooked lstat), a symlink at the
path itself, a symlinked root, and readBinaryFromDisk on a mount that follows
symlinks, an outside link under maxBytes included (I10). The FIFO test pins
each reader's words and waits up to 10 s (I26); readBinaryFromDisk's
contract test adds a path through a file. The three-way assertions turn to
the outside link's words.
…able, whatever its size

Through openContainedFile the open comes before the size check, so a file
this user can't read answers "Unable to read file" with or without maxBytes,
where main's stat answered "File too large" over the limit. Skipped as root,
whom file modes don't keep out, and on Windows.
…get, not a path to use

Answers the internal review af-f145-parent-links-r2 (wf-2400) of cbedc4b.

The refusal (I1, I17, I21), as the module's owner set it (room-203
#133449): it names the link's target and drops the computed path to use,
which ran beneath a file for a link to one, through a further link, or to
the root's directory, and was spelled mount-relative where the tools take
`work/...`. Every path in it is now spelled as the tools take them:
- inside: `"work/l" is a symlink to "work/elsewhere", which this mount does
  not follow; the mount's host can set followSymlinks: true`;
- outside: `"work/l" is a symlink leading outside the mount, which this
  mount does not follow`;
- dangling, or a loop: `"work/l" is a symlink, which this mount does not
  follow`.
MountView carries the mount's name for it. LinkFound loses `path`, which
only the computed way out read.

- I16: a readlink answer of EACCES or EPERM ends the walk with nothing
  found, as ENOENT does: the caller's own lookup meets the same answer, so
  it reports the permission and no link is named that isn't there.
- I2: read_image passes the core reader's words through for a file that
  isn't regular and for a directory the walk couldn't ask, where it said
  "Unable to read image file".
- I6: a materialize's refusal of a symlink keeps "fix it and materialize
  again", and drops only the offer of force.
- I25: isSymlinkRefusal matches the refusals' shapes (NOT_FOLLOWED, a quoted
  link "is a symlink", the unsure prefix), beside the function that builds
  them, and the tests pin the advice for each.
- Texts: linkOnTheWay's doc says how the walk reads each readlink answer
  (I24); the fragments and followSymlinks's doc say what the refusal names
  (I3), what changed for readBinaryFromDisk over maxBytes (I7, I29), and
  for a FIFO, socket or device, read_image's included (I2, I20).

Tests: the refusals pinned word for word on the materialize, autoMaterialize
and sync paths too (I14); every primitive on a link that leads nowhere or
loops (I15); a link to a directory beside the mount sharing its name's
prefix (I18); a link to the mount's root; the unsure walk through a
materialize, with its advice; and the FIFO test asserts no reader opens it
(I10).
…user can't search

Two readers' answers this change sets and no test pinned:
- A FIFO behind a symlink the mount follows passes the lstat (it's a link)
  and is opened: O_NONBLOCK keeps that open from waiting for a writer, and
  the fstat check refuses it as "Not a regular file".
- A directory on the way this user can't search ends the walk with nothing
  found (EACCES), so the reader's own lstat reports it, as main did, and no
  symlink is named. Skipped as root, whom file modes don't keep out.
…MountView too

So the red-first run can build these tests on main's sources, where
MountView has no name.
Main moved 119a862..3af13bf (nissa's merges of 19 pull requests, among
them anima-research#409's bounded read and anima-research#439's glob braces). Two conflicts, both import
lines:
- src/modules/workspace/index.ts's ./observe.js import takes both lists
  (anima-research#409's readPrefix beside this branch's NONBLOCK and link helpers), as the
  description's merge note says.
- test/workspace-read-file-from-disk.test.ts's node:fs import takes both
  lists, keeping this branch's child_process and node:module imports.
…d copy clears, codes by where a link leads

Answers Esther-2584's and Ogden-2688's reads of 9ad2677, on Ada-1017's
calls (room-203 #134588, #134600, #134611).

- A deletion whose evidence holds no agreed disk copy puts the store back
  at the agreement: P absent, or no P with the entry's origin 'store'. The
  commit (commitToolChange) clears the path's intent instead of writing a
  tombstone, and pushes nothing, so it needs no proof of absence, which a
  path beneath a link the mount doesn't follow would never give. Before,
  a write through an inside link, stored and its push refused, then
  deleted, left a tombstone no tool could clear while the link stood.
  Every other deletion keeps its tombstone: P content, P pending or
  interrupted (a push of ours may have landed), and a legacy entry with
  no P and no origin. The planner is unchanged: by its turn, a deletion's
  tombstone has replaced the origin that tells a draft disk never agreed
  with from a legacy entry.
- A disk reader's code for a link on a path's way follows where it leads:
  escape for a link leading outside the mount, as main gave it, so a
  caller choosing advice by code (connectome-host's uploads) keeps its
  outside advice; symlink for an inside link or one that leads nowhere.
  linkLeads says where a link leads; linkWords builds the refusal from it;
  linkReason and linkReadError use both.
- linkWords offers followSymlinks: true only for a link to a directory, by
  one stat of the target on the refusal path: following a link to a file
  can't make a path beneath it exist.
- read_image passes the core reader's words through for escape too, as it
  does for symlink: "Symlink escape detected: <path>" for a followed link
  that resolves outside, the outside link's refusal for one on the way.
- Texts: the fragment says what stays pending through a link and how it
  clears, and has a line for the deletion rule; followSymlinks's doc says
  an inside link's target is named, a file's included.

Tests: the deletion rule (a write through the link refused, its deletion
clearing, nothing pending, inside and out; a file disk agreed it had
deleted, written again and deleted beneath a link; a copy disk agreed with
keeping its tombstone; a draft on a plain mount), a link to a file inside
the mount, and the readers' codes by where the link leads.
… whole intent, a recorded conflict included

On Ada-1017's call (room-203 #134676). Once the workspace has given up its
side and the agreement holds nothing, a conflict recorded for the path has
nothing left to be about: disk's file is a sync's to take in. So the
deletion's commit clears the whole intent, where it kept the conflict.

What it changes for a caller: a draft written over a disk file the
workspace never saw (its push recorded store-origin-collision) is now
deleted without touching that file. On main the deletion was refused, with
a sync offered first and then a forced push that would have deleted the
file. The fragment and the description say so.

Test: a draft written over a disk file the workspace never saw, then
deleted, leaves that file alone and nothing pending (no conflict, no
deletion), and a sync takes the file in.
… the walk unsure, tests for every guarded deletion

Answers the internal review af-f145-parent-links-r3 (wf-2450) of a0388b6.
Its I2 (a cleared deletion kept a recorded conflict) was answered by
cfba871 before the sheet came back.

- I3: only EACCES ends the walk with nothing found, besides ENOENT and
  ENOTDIR: a directory this user can't search refuses the caller's own
  lookup the same way. EPERM, which no platform here was shown to give
  consistently, now leaves the walk unsure, and the path is refused.
- Tests:
  - I1, I5 (q1): a deletion keeps its tombstone where the evidence may hold
    a copy of ours: a store-origin draft whose P is content, one whose push
    is pending, and an entry from before the evidence (no P, no origin).
  - I1: a draft written over a disk file the workspace never saw, with no
    push, is deleted without touching that file, and a sync takes it in.
  - I3, I6: the unsure walk for EIO and EPERM, through readFileFromDisk,
    read_image (which passes the walk's words through), checkDir and a
    materialize.
  - I12: a disk reader through a link to a FIFO names it without the
    setting, and through a link to the root names the mount with it.
- Texts: the fragment says what clears a change stored through a link,
  and for which links the setting or the target's path helps (I4, I8, I24);
  commitToolChange's doc names the cleared deletion (I9); the comments at
  linkWords (I19, I22), linkReadError (I21), read_image's symlink case
  (I18), openContainedFile's step 0 (I25) and linkRefusal (I26) say what the
  code does.
…says nothing was unlinked

Esther-2584's optional point on F145 (room-203 #135215), on Ada-1017's call
(#135222, #135223). Where the intent a deletion with no agreed copy clears
held a conflict with a disk counterpart (a draft's store-origin-collision
with a file the workspace never saw, or both-changed where disk last agreed
it had nothing), `deleted: true` alone reads as gone from disk, which an
agent on an autoMaterialize mount would act on. delete's answer now carries
a note: nothing was unlinked, disk held a file there when last observed
that the workspace never took in, and a sync takes in whatever disk holds.

commitToolChange answers { refused, note }, as F48's { refused, modeKept }
does (nell/workspace-file-modes); its callers take refused as before, and
delete adds the note to its data and to what a withheld result keeps.

Tests: the collision case's answer pinned word for word; a both-changed
case where P is absent; and a draft over an unseen file with no push,
which records no conflict, answers without a note.
…lds, which holds for every file

Esther-2584's wording point (room-203 #135263): a sync takes in a new disk
file only where the workspace can hold it (read, and not binary); any other
stays disk-only. 'Takes in' promised a workspace copy no sync makes for an
image or an unreadable file, so the note and the fragment say 'observes'.
The collision test reads delete's whole result, so it pins that the note
is among what a result the guard withholds keeps (keepWhenWithheld), as
well as the answer's words. Without it, removing 'note' from that list
fails no test.
's file tools

anima-research#456 landed with train fix-c. Two files conflicted:

- reconcile.ts, the refusal of a disk copy that couldn't be verified:
  anima-research#456's side, inTheWay and then forcedWay. forcedWay names force only for
  a file this user can't read and, for a deletion, one that isn't regular,
  so a symlink the mount doesn't follow, at the path or on its way, gets no
  force without this change's isSymlinkRefusal, which goes with its import.
- index.ts, commitToolChange and its callers: anima-research#456's structure kept
  (onCommitted after the tree change, pushesOnCommit, storedButUnfinished
  for a refused or thrown push), with this change's answer shape
  { refused, note }: the doc names both and the pass an edit makes; the
  deletion calls onCommitted, then chooses between clearing the intent and
  a tombstone; write, edit and delete destructure refused, and delete adds
  its note to its answer and to what a withheld result keeps.

Two tests turn where the two changes meet. anima-research#456's "a deletion of a draft
that met a disk file the workspace never saw" now finds the deletion
complete, the file left on disk, and the answer's note. This change's
parent-link deletion test takes anima-research#456's wording for a deletion's advice,
"materialize again with applyDeletions".

The workspace test files and tool-result-guard pass on their own (Node
24.22.0, Linux).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 3/5 Tier: apex

[Medium impact] The changes affect filesystem behavior across workspace mounts and their disk readers.

Fix the Windows parent-path bypass and the failing test fixture before merging.

Findings

  1. P1 Windows paths still follow links ▶
  2. P1 New deletion test fails ▶
Fix with agent prompt
### Issue 1
src/modules/workspace/observe.ts:212-214
On Windows, `work/l\x.txt` passes `parsePath`, but `parentOf` sees no parent because it splits only on `/`. This early return skips the new link check. A forced `materialize` also uses the slash-only `parentOf` and `nameOf` helpers when it writes, so a directory link at `l` can still redirect the write to `elsewhere/x.txt` despite `followSymlinks: false`.

Normalize or reject native separators before both observation and disk writes.

### Issue 2
test/workspace-parent-link.test.ts:637-639
This fixture never puts `pushed.txt` on disk. Before deleting it, `settleBeforeMutation` observes that absence and forgets the pending write because its prior value is unknown. The new deletion branch then clears the store-created draft without a tombstone. The final assertion therefore receives only `agreed.txt` and `legacy.txt`, so this test fails.

Put the expected bytes on disk before deleting to test a write whose outcome still needs confirmation.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR extends followSymlinks: false to parent directories, moves readBinaryFromDisk onto the protected reader, prevents FIFO waits, and clears canceled drafts.

  • Mounts that refuse symlinks now stop paths through linked directories.
  • Disk readers now use the mount's link rules and refuse files that can stall a read.
  • Deleting a draft clears its intent when disk never agreed with it.

Reviews (1) · Last reviewed commit: "Merge main (b96e918b6) into the parent-l..." · Reviewed by Greptile

Comment on lines +212 to +214
const resolvedAt = through === 'path' ? rel : parentOf(rel);
if (resolvedAt === '' || real === join(rootReal, resolvedAt)) return null;
return linkOnTheWay(view, rel, through);

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 Windows paths still follow links

On Windows, work/l\x.txt passes parsePath, but parentOf sees no parent because it splits only on /. This early return skips the new link check. A forced materialize also uses the slash-only parentOf and nameOf helpers when it writes, so a directory link at l can still redirect the write to elsewhere/x.txt despite followSymlinks: false.

Normalize or reject native separators before both observation and disk writes.

Knowledge Base Used: Workspace synchronization module

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/observe.ts
Line: 212-214

Comment:
**Windows paths still follow links**

On Windows, `work/l\x.txt` passes `parsePath`, but `parentOf` sees no parent because it splits only on `/`. This early return skips the new link check. A forced `materialize` also uses the slash-only `parentOf` and `nameOf` helpers when it writes, so a directory link at `l` can still redirect the write to `elsewhere/x.txt` despite `followSymlinks: false`.

Normalize or reject native separators before both observation and disk writes.

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

This is main's path syntax, and this PR doesn't change it: parsePath, parentOf and nameOf are the same at this head as on main. l\x.txt is one name to the store, since parentOf and nameOf split only on /, and two components to Windows, which accepts both separators. The description now names this under Out of scope.

Written by Langston-2479.

— Lottie-2661

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

You're right. parsePath, parentOf, and nameOf retain the existing slash-only path syntax, and the resulting backslash ambiguity on Windows is explicitly documented as out of scope for this PR. Since this change neither introduces nor worsens that behavior, this is not an actionable finding here; I'll withdraw the comment.

Comment thread test/workspace-parent-link.test.ts Outdated
Comment on lines +637 to +639
// A store-origin draft whose push is pending: it may have reached disk.
await call(m, 'write', { path: 'work/pushed.txt', content: 'v1\n' });
agreement.intend('work', 'pushed.txt', { effect: 'disk', prior: null, expect: { kind: 'content', hash: env.store.treeGet(TREE, 'pushed.txt')!.blobHash }, branchId: env.store.currentBranch().id });

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 New deletion test fails

This fixture never puts pushed.txt on disk. Before deleting it, settleBeforeMutation observes that absence and forgets the pending write because its prior value is unknown. The new deletion branch then clears the store-created draft without a tombstone. The final assertion therefore receives only agreed.txt and legacy.txt, so this test fails.

Put the expected bytes on disk before deleting to test a write whose outcome still needs confirmation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: test/workspace-parent-link.test.ts
Line: 637-639

Comment:
**New deletion test fails**

This fixture never puts `pushed.txt` on disk. Before deleting it, `settleBeforeMutation` observes that absence and forgets the pending write because its prior value is unknown. The new deletion branch then clears the store-created draft without a tombstone. The final assertion therefore receives only `agreed.txt` and `legacy.txt`, so this test fails.

Put the expected bytes on disk before deleting to test a write whose outcome still needs confirmation.

---

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.

This test passes at this head, 122bccebf, in each of CI's four Build & Test jobs: macOS, Node 20, macOS, Node 24, Ubuntu, Node 20 and Ubuntu, Node 24. When the deletion commits, pushed.txt's write is still pending, so the deletion keeps its tombstone.

Written by Langston-2479.

— Lottie-2661

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

You’re right — my comment was incorrect. I conflated settleBeforeMutation’s observation behavior with the explicit pending disk intent created by agreement.intend(...). Here, pushed.txt has an outstanding write intent when delete commits, which is evidence that the write may have reached disk, so commitToolChange correctly keeps the tombstone even though the test does not materialize v1\n into the filesystem. The test is valid as written, and I withdraw the requested fixture change.

Langston-2479 and others added 2 commits October 18, 2026 00:05
, anima-research#462 and anima-research#466

Conflicts in src/modules/workspace/index.ts (11 hunks) and observe.ts
(one), each resolved as the change on both sides:
- The imports are unions, less readFile, which the merged code no
  longer calls (F145's readBinaryFromDisk reads through
  openContainedFile), and coveredByIgnore, whose use anima-research#460 replaced.
- commitToolChange: anima-research#462 (F48) answers { refused, modeKept } and F145
  { refused, note }. The merged answer carries all three, every return
  gives each, and the doc comment names both.
- writeBinary, write and edit take anima-research#462's side, which reads modeKept;
  delete takes F145's, which reads note.

anima-research#460 makes MountView's others required, so F145's test view gives
others: [], as the anima-research#460 trial (1e3fbb6e9) did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…has its bytes on disk

In "a deletion keeps its tombstone where the evidence may hold a copy of
ours", the pending push's file now holds the pushed bytes. With nothing
on disk, the case leaned on reconcile's no-evidence verdict dropping
resolvePending's forget (a write whose prior was unknown left no file),
so P stayed pending only because that forget goes unrecorded. With our
own bytes on disk, resolvePending sees the effect unconfirmed and records
nothing, so P stays pending as a push that may have landed: the case the
test names. Greptile suggested the fixture on anima-research#468.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Langston-2479 and others added 3 commits October 18, 2026 01:15
… to use there, and not the setting

On a mount that doesn't follow symlinks, a link whose target lies in
another mount's directory nested in this one (anima-research#460) was refused like any
inside link, offering the host's followSymlinks: true. That setting
isn't a way out there: on a following mount, the link would let this
mount read and write in the nested mount's directory, which anima-research#460 refuses
at the target's own path. Esther-2584 found it running the merged head.

linkWords now takes the mount's nested mounts, by directory and name,
and for a target in one of them names that mount and the path to use
there, with no setting: '"work/l" is a symlink to "work/nested/sub",
which this mount does not follow; it lies in mount "inner", nested in
mount "work": use "inner/sub"'. MountView carries them as `nested`,
filled from the module's nested mounts beside `others`, and the disk
readers' refusal takes them too. A test from Esther's probe checks the
reader's refusal and a write's, and that nothing is created in the
nested mount's directory. Ada-1017 decided the words (room-203 #138359).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing pushes a deletion keeps a tombstone for

The fixes from round 4 of our internal review (af-f145-parent-links-r4,
at 1c2791c):

- A deletion that clears a conflict naming no disk file answers without
  a note. In the new test, one branch records a deleted-on-disk conflict,
  and another branch's pass then records that disk has nothing there, so
  the first branch's deletion clears its intent, conflict and all. The
  note's condition, widened to any conflict, fails it. The description
  had said no conflict without a disk file could reach that branch;
  another branch's pass can bring one there.
- The test of a deletion over a pending push whose bytes are on disk now
  also checks that the push stays pending through the deletion's pass,
  so it can't pass on a copy disk agreed with.
- A deletion over a pending push whose bytes never reached disk leaves
  nothing pending once a sync observes the path. While the push is
  recorded as pending, the deletion keeps its tombstone, as for any push
  that may have landed: the settle pass's forget for such a push of a
  draft goes unrecorded (decide()'s no-evidence return), and the next
  pass settles both. The changelog now says "a draft the workspace
  created with no push of it in doubt".
- linkReadError's doc says the walk's failure is coded not_found with the
  readlink's errno, as every failed lookup is, and that escape is the
  code a path resolving outside the mount gets too.
- The note's comment says the deletion doesn't look at disk, so a file
  nothing recorded gets no note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he mount, and assert only that the push is settled

The test added in 0e0a39d synced the path itself. An explicit path sync
takes disk's state whatever the evidence (adoptDisk), so it recorded disk's
absence rather than passing over the deletion as a pass does, and the
test's "the push is forgotten" failed on that record. It now syncs the
mount, and asserts that nothing is pending, no conflict, no file on disk,
and that the push is no longer pending, which also holds if the forget a
draft's pass drops is recorded one day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Langston-2479 and others added 6 commits October 18, 2026 01:40
main's anima-research#424 (state registrations) imports ensureStateRegistered from the
module registry in workspace/index.ts, beside this change's longer import
from observe.js. The one conflict is that import pair, and the resolution
takes both sides' names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts own, parent_symlink

A disk reader refused a link among a path's directories that doesn't
lead out of the mount with the code `symlink`, which it also gives a
file that is itself a link. The two need different advice: for the
first, the file is already at that path and the mount's host can follow
the link; for the second, the file itself has to be put at the path.
Train H's cross-package check found connectome-host's instructions read
refused through a linked directory (room-558 #139643), and by reading
its InstructionsModule.reasonFor, its alert, which chooses its remedy by
the code, gave the second's ("put the file itself at that path") for
both. Ada-1017 decided the code (room-203 #139727).

WorkspaceReadErrorCode gains `parent_symlink`, for a symlink among the
path's directories on a mount that doesn't follow symlinks, wherever it
leads but out of the mount: to a directory or a file inside it, into a
nested mount's directory, or nowhere. linkReadError gives it where it
gave `symlink`; `escape` stays for a link leading out of the mount, and
`symlink` for a file that is itself a link. read_image maps it to an
image error of the same name, passing the reader's words through as
before. The tests that pinned `symlink` for a link on the way pin
`parent_symlink`, and the test of a link that leads nowhere or loops
now pins it too. The changelog says which code is which.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hold links inside them, and for callers of the new code

anima-research#468 goes in 0.23.0 (Ada-1017, room-203 #139825): a host stops a working
setup where a file is reached through a symlinked directory inside a mount
that doesn't follow symlinks, such as an agent's instructions file. The
breaking fragment names who acts (operators whose mounts hold links inside
them), what they do (set followSymlinks: true on that mount, or move the
file so no directory on its path is a link), and what's unchanged (a link
leading out and a file that is itself a link, refused as before), and
names the new code for callers choosing advice by it. The fixed fragment
names the code in passing and leaves which code is which to the breaking
one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er_mount, and read_image keeps both link codes' refusals

On Esther-2584's point (room-203 #139845) and Ada-1017's decision
(#139889):

- A symlink among a path's directories whose target lies in a nested
  mount's directory now refuses a disk read with `other_mount`, the code
  anima-research#460 gives that mount's own paths, rather than `parent_symlink`. Its
  message already names the mount and the path to use there. A host that
  maps `parent_symlink` to followSymlinks: true, which here would let
  this mount reach the nested mount's files, then can't offer it.
  linkWords and linkReadError ask the question through one helper,
  nestedHolder.
- `parent_symlink` then means a link inside this mount, to a directory,
  a file, a FIFO or nowhere, whose words say what helps in each case.
- read_image maps `other_mount` too. Without that case, or
  `parent_symlink`'s, the refusal comes back as not_found, and where the
  workspace's own copy failed to read as an image, read_image answers
  with that copy's failure instead. J18144 showed `parent_symlink`'s case
  untested: its removal passed every test. New tests pin both, through
  an inside and an outside link where the workspace's copy isn't an
  image, and in the nested-mount test.
- The code's doc, linkReadError's, and openContainedFile's step 0, which
  still said `symlink` for a link on the way, name the three codes.
  `parent_symlink`'s message names the target only where the link leads
  somewhere.
- The breaking fragment takes Esther's wording for operators: the path
  through the link's target, or a nested mount's own path; the setting
  only for a link to a directory; or moving the file. It names
  `other_mount` for callers, and the fixed fragment does too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ch comment sits on its function

71ffd25 put nestedHolder between linkWords' doc comment and linkWords,
leaving linkWords' comment above nestedHolder's and linkWords with none
(Esther-2584, room-203 #139974). This moves nestedHolder, with its comment,
above linkWords' comment. No other change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…path to use replaces

The refusal for a symlink among a path's directories whose target lies in
a nested mount's directory ended 'use "inner/sub"': the path to put in
place of the link. anima-research#460's own refusal for a path in that directory uses
'use' for the file's own path, so an agent taking it that way would try
the directory (Agnes-1009, room-203 #140076). It now ends 'use
"inner/sub" in place of "work/l"', which keeps one message per link,
as linkWords writes for every place it's used, the listing notes and
checkDir's included. Ada-1017 chose the wording (#140093).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/connectome-host that referenced this pull request Oct 18, 2026
…ories

From the release with anima-research/agent-framework#468, a workspace
mount that doesn't follow symlinks refuses a path through a symlinked
directory, wherever the link leads, where earlier releases follow one
that stays inside the mount. A link leading out keeps the code `escape`,
one into a nested mount's directory gets `other_mount`, and any other
gets a code of its own, `parent_symlink`, whose message names the link
and, where it leads somewhere, its target, adding for a linked
directory that the mount's host can set followSymlinks.

- The web UI's /files/ route answers `parent_symlink` with 403 and the
  read's message, as it does a file that is itself a link. Its list of
  forbidden codes didn't hold it, so it would have answered 404.
- An identity upload's refusal of one tells the agent that if the link
  leads to a directory, which the reason shows by saying the mount's
  host can set followSymlinks, the file can be named by its path there,
  and otherwise to ask the operator, giving them the reason. Its advice
  for `escape` now says the file lies outside this mount, since that
  check is per mount, and that one in another mount can be named by its
  path there.
- The instructions module already gives any code but `symlink` and
  `escape` in the read's own words; a comment says what those words
  carry for `parent_symlink` and `other_mount`.
- The recipe's doc for followSymlinks says it covers a link among a
  path's directories from that release.
- The deployment doc's row for the route lists the two refusals, and
  says a mount doesn't follow links unless the recipe sets
  followSymlinks, where it said a recipe's mounts don't follow links.
- The comments and docs that say what such a mount refuses (the
  instructions and activity modules', the README's, and an upload
  test's) add what the release with anima-research#468 refuses too.

`other_mount` is the code anima-research/agent-framework#460, already
on agent-framework's main, gives a path in another mount's directory
nested in the one named; its message names the mount that holds it and
the path to use there. The route answers it 403 too, where it would
answer 404, and an upload's refusal of one gives the read's reason
alone, as for a path no mount holds: the file is the nested mount's,
so the outer mount's workspace isn't offered in its place.

The unreleased fragment for the change that stopped such mounts reading
a file that is itself a link said a recipe's mounts don't follow links
because RecipeWorkspaceMount leaves followSymlinks out. A recipe has
passed followSymlinks since mounts began passing every key through, so
it now says a mount doesn't follow links unless the recipe sets it, and
names setting it as a way out; and its sentence that a file reached
through a linked directory is read as before now says it holds on
agent-framework releases before anima-research#468.

The tests that pinned a framework read's answer for a path through a
linked directory now assert what the mount's own read gives, so they
hold on agent-framework 0.22.2, which this host's lockfile installs, and
on a release with anima-research#468: the route answers as the read does, the
instructions module injects the file or alerts with the read's words,
and the upload's refusal of a link leading out carries the read's own
words, which differ between the two, with the outside advice. New cases
pin `parent_symlink` and `other_mount` at each of the three, the
nested-mount upload with a copy of the path in the outer workspace, a
mount that follows links reading through a linked directory, and the
route serving one; on anima-research#468 the route's real read carries the words the
upload's advice keys on. An upload of a path through a linked directory
that leads nowhere sends the workspace's copy where the read finds no
file, as 0.22.2's does, and is refused where it gives parent_symlink, as
anima-research#468's does.

Neither anima-research#468 nor anima-research#460 is in a release yet. A release in this host's
range (^0.22.2) that carries anima-research#460 brings `other_mount` to it, so a
fragment says what the route and the upload do with it. This change
carries no changelog lines for anima-research#468's effect on operators.

Checked on Linux x64 (Bun 1.4.3, Node 24.22) at this tree:
- tsc --noEmit is clean at the root and in web/.
- The three changed test files pass 112 of 112, three runs. With
  main's three modules in place of this change's, 4 of the 112 fail.
- Each of six one-line changes fails a test: parent_symlink or
  other_mount dropped from the route's list; the upload's
  parent_symlink advice dropped, or other_mount dropped from its early
  return; the instructions module giving its own words for
  parent_symlink or for other_mount.
- bun test passes 2404 of 2404 across 180 files.
- With agent-framework at anima-research#468's head (9453858a6) built from source and
  installed in 0.22.2's place: root tsc is clean; the three files pass
  112 of 112, three runs, with the route's, the instructions module's
  and the upload's reads through a linked directory refused as
  parent_symlink; bun test passes 2404 of 2404.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0.22.3 is cut (Ada-1017, room-203 #143802), so anima-research#468, approved for 0.23.0
(nissa, personal:oscar-1808 #141438), merges main before its train, as Ada
set (#141457). main brings 54 merges since e5a54ae, three of them in the
workspace: anima-research#496 (forced removal unlinks only a regular file or a link to
one, and a pipe, socket, device or directory at a deleted path isn't left
for applyDeletions), anima-research#500 (a mount's catch-up pass runs once its watcher
is ready), and anima-research#511 (glob and grep start where their pattern does, through
walkStart).

The conflicts were two import lines in workspace/index.ts, and the
resolution takes both sides' names: node:fs/promises gains main's readdir
and keeps this change's dropping of readFile, which nothing calls; the
observe.js import gains main's checkDir. git merged the rest on its own.

One place main's new code meets this change without a conflict: anima-research#511's
walkStart asks checkDir of the start it has
reached before going a segment deeper, and checkDir now refuses a
directory whose path goes through a link on a mount that doesn't follow
symlinks. So walkStart goes no deeper beneath a path given through such a
link. A link among the pattern's own directories it already stopped
before, as on main, since it goes on only through entries readdir lists
as directories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit c7ad41d 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
main now holds anima-research#468, which reads otherMounts in the shape this change
replaces. Two import lines conflicted, ./observe.js's in index.ts and
node:path's in observe.ts, and the merge takes both sides' names in
each. anima-research#468's nestedOf now takes every spelling of a nested mount's
directory, as othersOf does, so the merge typechecks and a link into a
nested mount's directory reads as that mount's in either spelling.

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
… another spelling is that mount's (F181)

anima-research#468's link checks name a link into a nested mount's directory as that
mount's (other_mount) by comparing the link's target with nestedOf's
directories. The merge of main made nestedOf take every spelling of a
nested mount's directory. This test pins that: on a folding disk, a
link spelled as disk spells the directory, where the nested mount is
configured in another spelling, is refused by readFileFromDisk as the
nested mount's, naming the path to use there.

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
main at c7ad41d brings anima-research#468 (F145), which landed before this branch,
so this branch carries its deletion rule, as both pull requests' merge
notes set. Every conflict is in src/modules/workspace/index.ts, between
the commitToolChange of anima-research#468 and this branch's commitToolChanges, which
commitToolChange now calls with one path:

- commitToolChanges' per-path intent update takes anima-research#468's rule: a
  deletion whose evidence holds no agreed disk copy (disk last agreed it
  had nothing there, or never agreed with an entry the workspace
  created) clears the path's whole intent and pushes nothing, and one
  that clears a conflict naming a disk file the workspace never took in
  notes that nothing was unlinked. Any other deletion keeps its
  tombstone, as before.
- Such deletions are left out of the push, and commitToolChanges answers
  their notes by path beside refused and modeKept. commitToolChange
  answers { refused, modeKept, note } for its path, as anima-research#468's did, and
  handleDelete gives the note in its answer, as anima-research#468 made it.
- StoredThenFailed carries the notes, and its pushes is false when no
  change of the call would have been pushed, so a store that fails after
  recording such a deletion answers that it's stored and unlinked
  nothing, without saying a push didn't run.

The rest of anima-research#468 (the refusals for a path through a link, its read and
write paths, observe.ts, types.ts and its tests) merges as git made it.
test/workspace-file-tools.test.ts adds the store failure after such a
deletion.

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 include (9f3e429) is on main 2482625. Since then main has gained, in
the workspace module, anima-research#460's nested mounts and editor files, anima-research#468's
refusal of a link on a mount that doesn't follow links, anima-research#511's walk start
for glob and grep, and anima-research#525's disk spelling. Where they meet the include:

- Nested mounts (anima-research#460): a path in a nested mount's directory is that
  mount's, whatever the outer mount's include covers. For every tool the
  nested mount's refusal comes first and the include's after:
  refuseNotHeld, which parseFilePath and resolveAbsolutePath share, and
  refusesAsOutside, which defers to holderOf. A nested mount's own include
  decides inside its directory. Status and entriesByEvidence leave out a
  path outside the include beside one in another mount's directory
  (ownPaths).
- Editor files (anima-research#460): main passes over editors' swap and backup files on
  every mount. The walk checks those reasons first and the include after,
  so a file both editor-named and uncovered counts under the editor
  reason, and passedOverByDesign counts OUTSIDE_INCLUDE with them. The
  include's walk, history and watcher tests now use names anima-research#460 doesn't
  pass over.
- Links (anima-research#468): a link's code says no more than its message: other_mount
  only where the refusal names the nested mount, parent_symlink otherwise.
  namesHolder (observe.ts) is the one test linkWords and linkReadError
  use, and nestedHolder takes any { dir, name }, so linkWords keeps the
  nested mount's include.
- glob and grep (anima-research#511): the include refuses an uncovered path before
  walkStart, and the walk judges each file by the include wherever it
  starts.
- Disk spelling (anima-research#525): parsePathAsGiven is the include's reader, and a
  tool's path reaches diskSpelling only after it, so the step never sees
  an empty, `.` or `..` segment, and a path whose part after the mount's
  name is absolute is refused before it. The include judges a path as disk
  spells it, and its refusal shows the path as given, since disk's
  spelling of a name the include doesn't cover would tell whether a file
  is there. Past a nested mount's directory, an answer shows the rest of
  the path as given, set once in parsePath (nestedDepth).
  resolveAbsolutePath stays synchronous and judges the path as given.
  diskSpelling keeps its branches for '', '.', '..' and an absolute rest,
  which no tool's path reaches now: they keep it total, and its own test
  pins them.

Texts: MountConfig.include's doc and the two fragments say how links,
nested mounts and disks that fold names meet the include, and the
spellings fragment names a draft stored under a key the one spelling no
longer names, such as `a//b.txt`, with how to recover it from the store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
…, not only where a path names it

The nested-mount checks (#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, #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 (#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 #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 #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; #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 #282's 577258f, by
Ada-1017 at slimepriestess's ask); and a `./m` watcher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
…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: #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>
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
…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>
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.

1 participant