Repository navigation
fix(workspace): a mount that doesn't follow symlinks follows none on a path's way - #468
Conversation
…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>
[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. FindingsFix 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.SummaryThe PR extends
Reviews (1) · Last reviewed commit: "Merge main (b96e918b6) into the parent-l..." · Reviewed by Greptile |
| const resolvedAt = through === 'path' ? rel : parentOf(rel); | ||
| if (resolvedAt === '' || real === join(rootReal, resolvedAt)) return null; | ||
| return linkOnTheWay(view, rel, through); |
There was a problem hiding this 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
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.There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
| // 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 }); |
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
, 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>
… 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>
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>
…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>
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>
… 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>
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>
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>
…, 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>
…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>
…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>
…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>
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.
readBinaryFromDiskalso 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
followSymlinksdefaults 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, withO_NOFOLLOWor anlstat. A directory on the way was resolved withrealpathand 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
mainthat doesn't follow symlinks, where disk has replaced a tracked directoryl/with a symlink toelsewhere/, inside the mount. At119a86256:l/x.txt, pushed bymaterializewithapplyDeletions, unlinkedelsewhere/x.txt. Withforce, it unlinked it when it held other bytes than the workspace's copy.materializeof a newl/new.txtcreatedelsewhere/new.txt, and one of an editedl/x.txtoverwroteelsewhere/x.txt.syncofl/x.txttookelsewhere/x.txt's bytes in asl/x.txt.At
9e3e407c7, the red-first run (Checks) shows the same for the unforced deletion, the write, the edit and the sync. There,readFileFromDiskandread_imageread a file through such a link, and refuse one leading out of the mount asescape,Symlink escape detected: <path>.WorkspaceModule.readBinaryFromDiskstated 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. Onmainit returned the bytes of a file outside the mount, through a file symlink and through a directory symlink on the way, wherereadFileFromDiskrefused both.Changes
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.locateDir, behind each push's parent directory, each listing and each scope;parentPlacement, behindobservePath, for each file a sync observes;openContained, for each file a pass reads;confirmAbsent's walk, behind each proof of absence, andcreateDir, for the directories a push makes.linkRefusaltakes whatrealpathresolved 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. OtherwiselinkOnTheWayasks 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 otherreadlinkanswer it doesn't expect refuses the path ("cannot tell whether "work/a" is a symlink (EIO)").openContainedthe 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.linkWords, from wherelinkLeadsfinds the link leads, for the observations throughlinkReasonand for the disk readers throughlinkReadError) has the link as its subject and says where it leads, with paths spelled as the tools take them:"work/l" is a symlink to "work/elsewhere", which this mount does not follow; the mount's host can set followSymlinks: true;"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.MountViewcarries the nested mounts' names for it besideothers;"work/l" is a symlink leading outside the mount, which this mount does not follow;"work/l" is a symlink, which this mount does not follow.openContainedFile, behindreadFileFromDiskandread_image):work/l/x.txt: "work/l" is a symlink to "work/elsewhere", …). Its code follows where the link leads:escapefor a link leading outside the mount, as onmain;other_mountfor 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 fromsymlink, which stays for a file that is itself a link. They ask before theylstat, and again of the path's resolution after the open, so a directory swapped for a link in between is refused too.lstat, without opening it, asnot_found,Not a regular file: <path>. The open also takesO_NONBLOCK, so a FIFO reached through a symlink the mount follows, or swapped in after thelstat, doesn't wait for a writer.read_imagepasses the reader's own words through for these, and for anyescape, where it put its own in their place. For a link at the path itself, the words are its oldSymlinks are not allowed: <path>, and for a followed link that resolves outside the mount, its oldSymlink escape detected: <path>.inspect, such as a size limit) runs after the boundary's checks.readBinaryFromDiskreads throughopenContainedFile, the readerreadFileFromDiskuses, 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 overmaxBytes.delete's answer carries anotesaying nothing was unlinked, sincedeleted: truealone 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, andMountViewcarries the mount's name for the refusal.workspace-parent-symlinks.fixed.md,workspace-parent-symlinks.breaking.mdfor operators whose mounts hold links inside them and for callers choosing advice by the code,read-binary-from-disk-links.fixed.mdanddisk-readers-fifo.fixed.md.What each part is for
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 withreadlinkbefore every observation, read and push, asadca6af2ddoes, 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)..and..out before the walk makes it walk the path the lookups resolve. Otherwisework/nope/../l/x.txtwould stop at the missingnopeand read throughl.realpathand onestatof the link's target are made, and only when refusing. Thestatkeeps the setting to links to a directory, since following a link to a file can't make a path beneath it exist.main.parent_symlink, apart fromsymlink, 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_mountfor a link into a nested mount's directory keeps a host that mapsparent_symlinkto the setting from offering it there, where it would let this mount reach the nested mount's files.lstatgives a link its own answer, ahead of the file's type and a caller's size check. It costs onereadlinkper 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, whereopenContained's single check after its open already has.inspectafter 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.readBinaryFromDiskfrom waiting on a FIFO, where it used to answer at once, and ends the same wait for the other two readers.O_NONBLOCKcovers a FIFO thelstatlets through.readBinaryFromDiskthroughopenContainedFilegives it the boundary its doc promises, from the reader the other two already share, rather than a second copy of the checks.readlinkwalk before every observation, read and push, which costs a sync about 1.9×main's CPU (above);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:
materializelists it underskipped, "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,editanddeleteon a mount withautoMaterializechange the workspace and report that the push didn't reach disk, with that refusal, where the push wrote or unlinked through the link.syncthat 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.lsandglobof the directory a link stands at give its tracked files that refusal as their note, and name it underincomplete, where they listed what lies behind the link.readFileFromDisk,read_imageandreadBinaryFromDiskrefuse it with that refusal,readFileFromDiskwith the new codeparent_symlink, orother_mountfor a link into a nested mount's directory, where they read the file behind the link.readis unchanged: it reads the workspace's copy.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, wherereadFileFromDiskread 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".
readFileFromDiskandread_imagekeep 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: trueon 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.
readBinaryFromDiskthere 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:
readFileFromDiskandread_imagerefuse it at once,Not a regular file: <path>(readFileFromDiskasnot_found), where they waited for a writer on a FIFO.readBinaryFromDiskanswers "Not a file", as before.readBinaryFromDiskanswersFile not found on disk: <path>, where it gave the system's message with the absolute path.readBinaryFromDisksaysUnable to read file: <path>whatever its size, where it gave the system's message with the absolute path, or "File too large" overmaxBytes.applyDeletionssettled 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.{ path, deleted: true, note }, with thenotesaying 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
readFileFromDiskin four places this change reaches. At itsmain(b73d515e), none of them mapsparent_symlink, orother_mountfor a link into a nested mount's directory, and three of its tests pinmain's answers in the first three:/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 forparent_symlinkandother_mount, where it answers 403 forsymlinkandescape.InstructionsModuleinjects nothing from an instructions file reached through such a link, where it injected the file. By our reading, it raises itsinstructions-unavailablealert with the refusal's own words as the reason.parent_symlinkandother_mounttheir 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.anima-research/connectome-host#323, on its
mainsince8409d2d, answers both codes. Its web UI's route gives them 403 with the refusal. Its uploads giveparent_symlinkadvice of its own, andother_mountthe 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:force.write,editanddeleteon a mount withautoMaterializereport their push refused, with no advice to passforce, and nothing behind the link changes.syncdoesn't read through it, and history keeps its copy.readFileFromDiskandread_imagerefuse a path through it,readFileFromDiskwith the code for where it leads:parent_symlinkfor 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_mountfor one into a nested mount's directory; andescapefor one leading out. Where the workspace's own copy can't be read as an image,read_imagestill gives the link's refusal, not that copy's failure.incompleteinstead of walking it.checkDir,observePath,readPathof a file and of a directory, andconfirmAbsent, asked directly, since through the tools an earlier check usually answers first...after a name that isn't there hides no link beyond it, throughreadFileFromDisk,readBinaryFromDiskandobservePath.other_mount; a write through the link is refused with it and creates nothing in the nested mount's directory; andread_imagegives 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.checkDir,observePathandreadPath. Linux has no such spelling, so the test respellsrealpath's answers with a.after the root.readlinkhooked to answerEIO, andEPERM) refuses the path throughreadFileFromDisk,read_image,checkDirand a materialize, rather than letting a link beneath it through.readFileFromDisk.lstat) is refused byreadFileFromDiskafter the open.force, which a forced push refuses too.readBinaryFromDiskreads nothing through a link: a file symlink leading out, a directory symlink leading out, or one leading inside.readBinaryFromDiskreads through an inside link and refuses an outside one, with or withoutmaxBytes.noteis indelete'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.New, in
test/workspace-read-file-from-disk.test.ts:open), each answer pinned and raced against a 10 s timer. It's skipped on Windows, which has no FIFOs.EACCES, not as a symlink. It's skipped as root.readBinaryFromDisk's own answers, which had no test: a file's bytes andabsolutePath, "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" overmaxBytes, with a file of exactlymaxBytesread, and "Unable to read file" for a file this user can't read, with or withoutmaxBytes.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 withfollowSymlinks: true, it still pins the older words there. Intest/workspace-read-file-from-disk.test.ts, the test of such a link keeps itsescapeand also pins the outside link's refusal. Intest/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'snote.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 mergedmainat9e3e407c7. The earlier removals, each test file alone, the red-first run ofworkspace-read-file-from-diskand the timing ran at1c2791cb7. 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
mainwhose one conflict was an import (d41db92c9);the codes
parent_symlinkand, for a link into a nested mount's directory,other_mount(e8d832b56,71ffd25a3, with a comment moved in9af0adce4);the breaking fragment (
565259a0a);the nested mount's words saying what the path to use replaces (
9453858a6);a merge of
mainat9e3e407c7, whose two conflicts were imports (a24f10bd5).CI's steps (
npm install,npx tsc --noEmit,npm run build,npm test) at the head:tscand the build are clean, andnpm testpassed 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, at1c2791cb7: 177 files and 3022 tests, none failed, the three files CI's unforced step runs among them. At the head,workspace-parent-linkalone passed all 47 of its tests under each Node. Againstmain, by the diff, the change adds one file and 52 tests: the newworkspace-parent-link(47 tests in 4 suites) and five more inworkspace-read-file-from-disk(19, wheremainhas 14). It adds or removes no other test, and changes one inworkspace-file-toolsand five assertions inworkspace-three-way.CI's pack check (
node scripts/pack-installs-alone.mjs, afternpm install --ignore-scriptsand 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.tsandtypes.ts):workspace-parent-link, at the head on9e3e407c7'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 passforce, whichmainhas 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, at1c2791cb7on77066360's copies: 5 of its 19 fail. They arereadBinaryFromDisk's own answers, where a path through a file gave the system'sENOTDIRmessage; a file it can't read, the system'sEACCESmessage; the FIFO, withreadFileFromDiskstill waiting after 10 s; the FIFO behind a followed symlink, likewise; and a directory link leading out, whose words differ. The 14 that pass aremain'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-imageandworkspace-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, withworkspace-parent-linkrun, and each failed a test that pins it:locateDir's check 17, and for a link itsrealpathfails 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.EACCES1;EPERMleaving it unsure 1.escapefor a link leading outside 3; atd41db92c9, naming a nested mount's path for a link into its directory 1.note: returning it 2; answering with it 2; nonotewhere none was made 3; a withheld result keeping it 1; atd41db92c9, its condition that the cleared conflict names a disk file, widened to any conflict, 1.lstat3; the check after the open 1; a file that isn't regular refused at thelstat1;O_NONBLOCK1;inspectafter the boundary 1;readBinaryFromDiskthroughopenContainedFile7, its "Not a file" 1, and its missing path through a file 1;read_image's words for a symlink 1, for anescape1, and for a file that isn't regular 3.parent_symlinkfor a link on the way, turned back tosymlink, 6;other_mountfor a link into a nested mount's directory, givenparent_symlinkinstead, 1; andread_image's case for each, mapped tonot_found, 1 and 1.1c2791cb7that 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 (49268ef91adds 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 onmain's copies of those three sources, at load 7 to 17.1c2791cb7and 1079 ms onmain's sources, ranging 960 to 1412 and 883 to 1509 ms over 18 syncs each.9ad2677bb,bcc914955,83242279band122bccebf, put the change's warm median 2.5%, 2.3% and 8.1% abovemain'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 withreadlink, asadca6af2ddoes, took about 1.9×main's warm CPU at the medians in the same probe, its fastest sync slower thanmain's slowest.Not verified
realpath, and its answer to an unexpectedreadlinkerror only through a hookedreadlink. On Windows, libuv'sreadlinkanswersEINVALfor 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.9ad2677bbandbcc914955, 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, andreadlinkandrealpathgave the walk the same answers. The suite runs on Node, and the walk's answers to an unexpectedreadlinkerror and to a respelled name weren't run under Bun.Out of scope
grepof a path whose last component is a symlink takes it for a single file and answers no matches and noincompleteentry, before and after this change.resolveAbsolutePathstays lexical-only, as documented, and deprecated.work/l\x.txt:parentOfandnameOfsplit only on/, so to them its parent is the mount's root,work, while Windows reads it asx.txtinwork/l. That's howmainparses a path, and this change leaves it as it is.readBinaryFromDisk's read checksmaxBytesagainst the size its open'sfstatgives and then reads the whole file, as onmain, so a file that grows pastmaxBytesin between is read whole. fix(workspace): three reads' size limits bound the read itself #409 bounded the read itself forreadFileFromDiskandread_image.Merge notes
Against
mainat9e3e407c7, this change meets three open pull requests:commitToolChangeintocommitToolChanges, which commits several paths at once, and this change's deletion rule andnoteare incommitToolChange's deletion branch. The resolution carries the rule intocommitToolChangesfor each path: a deletion whose evidence holds no agreed disk copy clears that path's whole intent and pushes nothing, and one whose cleared conflict names a disk file gives the deletion itsnote.commitToolChanges, so it meets this change in the same place, with the same resolution.{ name, spellings: [{ dir, root }] }. This change'snestedOfreads each entry as{ dir, name }. Merged, the two conflict inindex.ts's import from./observe.js, which takes both sides' names, andnestedOfno longer typechecks. The resolution hasnestedOftake every spelling, as fix(workspace): take a path as disk spells it on a disk that folds names #525'sothersOfdoes:(this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ dir: s.dir, name: o.name }))).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 atbcc914955, and Esther-2584 (Claude Opus 5.5) atbcc914955, itsnoteat4a8a8897b, its merges ofmainat122bccebf,7ef66e79aandd41db92c9, the words for a link into a nested mount, whose need she found, at49268ef91, the fourth round's fixes ataa42f6d8d, and the codes for a link on the way, whose cases she found needed telling apart, at71ffd25a3.