Repository navigation
fix(workspace): disk reads check the mount root; two opens skip FIFOs - #533
Merged
nissa-seru merged 2 commits intoOct 18, 2026
Merged
Conversation
openContainedFile, behind readFileFromDisk, readBinaryFromDisk and read_image, checked containment against the mount root's realpath taken during the read, so it checked that a file was inside whatever directory the root's path named then. A root renamed away and replaced by a link to another directory, or by another directory, was read as the mount. A root that was gone, or a file, was reported as a missing file, since the file was looked up before the root was resolved. The read now checks the root first (readRoot), through observe.ts's checkRoot, as a pass checks it. Once the workspace has recorded the root's identity, a root that is another directory is refused as mount_unavailable with no errno, at a new stage 'root', in ROOT_REPLACED's words, which ls and sync give it too. A gone root is mount_unavailable with realpath's ENOENT, and a file there with ENOTDIR, from a stat of the root (rootAbsence), since checkRoot gives only a reason. The read takes the root's identity from memory (rootIdentityForRead) in every state of the evidence's journal: the last identity whose append succeeded, since write() appends before it applies, which no reconcile can change, since root entries are written without a sync. So while the evidence awaits reopening after a failed append, when passes and tool calls fail, reads go on, checked; refusing them would stop every host read until a restart. Memory answers only once it is whole: a rebuild fills a new map and swaps it in at its end, and until the open's rebuild has finished, as when it couldn't read the journal back, the evidence refuses everything, reads included, as mount_unavailable with that reason and the root's errno where no directory is there. While the read runs, the checked root is asked again (stillHere): once the file is open and its checks pass, and, through one catch around the steps beneath the root (openBeneathRoot), before any refusal there is reported. A root that has moved gets the code and errno its state gets before the read (rootMoved); the bytes are then read through the checked file's descriptor. read_image passes a mount_unavailable refusal's message on. viewOf() builds a mount's view for a pass and for this check alike. readBinaryFromDisk checked maxBytes against the open's fstat, then read with handle.readFile(), which reads the file as it is by then, so a file grown past maxBytes came back whole. It now reads with readPrefix, as the other two readers do, and refuses such a file as "File too large", with a size from an fstat after the read, at least the bytes it found plus one. CheckedDir.sync() and ToolPresentation.read opened with 'r', so a FIFO put at a checked directory's path held the sync and the mount's turn, and a FIFO at the presentation file held the whole process, the read being synchronous. Both now open with O_RDONLY | O_NONBLOCK, and their existing checks refuse what isn't a directory or a regular file. Tests: a root that is a link elsewhere or another directory is refused by all three readers until a sync with acceptRoot; a root replaced at the open; a gone root and a file root, with their errno; each of removed, replaced by a file, by another directory, and by one holding the file, at three moments of the read; a mount configured through a link that is re-pointed to another directory at each of those moments; a mount with no recorded identity moved mid-read; a read while the evidence awaits reopening, of the mount's own file and through another directory; a sync whose acceptRoot append fails; an open that can't read the evidence back, or fails to record a changed mount; a rebuild whose append fails, reached directly, leaving memory as it was; readBinaryFromDisk's bound, its size after a shrink, and growth within maxBytes read whole; a FIFO at a checked directory's path during its sync; and a FIFO at the presentation file, read in a bounded child process. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The PR appears safe to merge; no actionable issues were found. SummaryDisk reads now check that a mount still names the expected directory.
Acknowledged scope from nissa-seru: roots without a recorded identity still use the directory present when a read starts. Root swaps restored between checks remain undetected. Reads after a failed journal append deliberately use the last successfully recorded identity; a failed initial rebuild refuses reads until reopening. Other operator-configured opens are intentionally unchanged. Review was based on source and test inspection. Tests were not run. Reviews (1) · Last reviewed commit: "fix(workspace): disk reads check the mou..." · Reviewed by Greptile |
This was referenced Oct 18, 2026
nissa-seru
pushed a commit
to anima-research/connectome-host
that referenced
this pull request
Oct 18, 2026
…read does test/web-ui-files-route.test.ts's test for a mount whose directory has gone expected 404 and "File not found: gone/x.txt", which agent-framework 0.22.2's read gives, since it looks for the file before it resolves the mount's directory. anima-research/agent-framework#533 has the read check the mount's root, and a root that has gone is then mount_unavailable, which the route answers with 503 and the read's words. The test now asks the read for the file first and expects the route to answer as the read does: 503 with "Mount unavailable: gone", the read's words, when the read says mount_unavailable, and otherwise 404 with "File not found: gone/x.txt" after a not_found. The comment above the route's status mapping and the docs' row for the route say which a gone mount gets on each, and name the 503 by the read's refusal, a mount the read refuses as unavailable, where they said a mount the read can't resolve. An unreleased changelog fragment does the same and drops a gone mount from its 404 examples, and the stand-in unavailable-mount test's name drops "after finding the file", which is 0.22.2's order. Checked on Linux with bun 1.4.3 and Node 24.22.0: - With agent-framework 0.22.2, as the lockfile installs it: root tsc 0 errors; test/web-ui-files-route.test.ts 7 pass, 0 fail, in each of three runs, with the read answering not_found; bun test 2428 pass, 0 fail, across 182 files. - With #533's head (62893f2b) built and installed in its place: root tsc 0 errors; the file 7 pass, 0 fail, in each of three runs, with the read answering mount_unavailable, "Mount unavailable: gone"; bun test 2428 pass, 0 fail, across 182 files. - main's version of the file fails there, at this test: 404 expected, 503 received. - With the route answering mount_unavailable with 404, this test and the stand-in unavailable-mount test fail, on #533. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one conflict, the import from ./observe.js in src/modules/workspace/index.ts, takes both sides' names: main's leadsInto, nestedAtReal and NestedMount, from anima-research#535, and this branch's ROOT_REPLACED, ROOT_UNAVAILABLE, checkRoot, CheckedDir and MountView. anima-research#535's refusal of a file whose canonical path lies in a nested mount's directory merged, unconflicted, into openBeneathRoot right after the containment check, so it is one of the refusals beneath the root that the read's one catch asks the root about again.
1 task done
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
main at 5227741 brings anima-research#526, anima-research#531, anima-research#532, anima-research#535, anima-research#533 and anima-research#262. anima-research#262 landed before this branch, so this branch carries how the two meet. The one conflicting file is src/framework.ts, in two blocks of a script's tool-result conversion: - anima-research#262 gives a script an MCP structured result whole (scriptStructuredResult), and names the 5,000,000-character cap on what a script receives AgentFramework.SCRIPT_RESULT_MAX_CHARS. Both blocks take anima-research#262's side. - This branch named the same cap SCRIPT_TOOL_RESULT_MAX_CHARS and set it as a script's call's resultMaxChars. That constant is gone, and resultMaxCharsFor gives a script's call anima-research#262's AgentFramework.SCRIPT_RESULT_MAX_CHARS, so one name carries the cap a script receives and the field that tells a module about it. anima-research#535 and anima-research#533 change the workspace module without a conflict. Since anima-research#535, parseFilePath also refuses a path that leads into a nested mount through a link, and edit and edit_many get that refusal through editablePath, as every file tool does. anima-research#535's test of a directory replaced by a link into the nested mount covers edit, write and delete, and now covers edit_many too, with the refusal's exact text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
The edit planner's branch, which this one is stacked on, merged main at 5227741 into its head, as 8b03920. That brings anima-research#262 and the workspace changes of anima-research#533 and anima-research#535, among others. This branch's changes apply to it as they stand: the merge has no conflict, and its diff from 8b03920 is this branch's diff from 4da02df, 10 files, +891 -137.
1 task done
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 19, 2026
main brings anima-research#533, whose disk reads check the mount's root and read a file beneath it through the root they checked (viewOf, openBeneathRoot), anima-research#262's MCP client, and anima-research#537. Resolved, in index.ts only: - The imports take both sides' names. - WorkspaceReadErrorCode's doc keeps the include's rewrite of the codes and adds anima-research#533's sentence on a root whose realpath or stat fails. - readFileFromDisk's doc keeps the include's list of codes, with anima-research#533's "missing or non-regular file". - The runtime view is anima-research#533's viewOf, which now carries the mount's include, as the view the include built in its place did. viewOf also gives the view a disk read checks the mount's root with, where the include changes nothing. Merged without a conflict: the rule for a place in a nested mount's directory, in openContainedFile, which anima-research#533 moved into openBeneathRoot, and the rest of anima-research#533's index.ts, observe.ts and disk-agreement.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 19, 2026
main brings anima-research#533, whose disk reads check the mount's root and read a file beneath it through the root they checked (viewOf, openBeneathRoot), anima-research#262's MCP client, and anima-research#537. Resolved, in index.ts only: - The imports take both sides' names. - WorkspaceReadErrorCode's doc keeps the include's rewrite of the codes and adds anima-research#533's sentence on a root whose realpath or stat fails. - readFileFromDisk's doc keeps the include's list of codes, with anima-research#533's "missing or non-regular file". - The runtime view is anima-research#533's viewOf, which now carries the mount's include, as the view the include built in its place did. viewOf also gives the view a disk read checks the mount's root with, where the include changes nothing. Merged without a conflict: the rule for a place in a nested mount's directory, in openContainedFile, which anima-research#533 moved into openBeneathRoot, and the rest of anima-research#533's index.ts, observe.ts and disk-agreement.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merged
1 task done
nissa-seru
pushed a commit
that referenced
this pull request
Oct 19, 2026
…laces it main brought, among others, #533 (the mount root's identity and bounds), #535 (mount roots placed where disk has them: readNesting replaces spellNestedDirs, and a nested mount's directory has a place per way it lies there, each with its spellings), #262, #537, #543 and the 0.22.3 release. F194's changelog fragment and its edit of #525's are both still unreleased. The five conflicts were all in src/modules/workspace/index.ts: - the ./observe.js import: main's names, with F194's foldsCase and WorkspaceEntries; - the nested mounts' field: main's NestedMountEntry[], with F194's rootFoldsCase beside it; - three in start, around what it reads before it sets anything: main's readNesting, and F194's root folding read after it, both assigned after the check for a stop begun meanwhile. As Ada-1017 set for this merge, start reads each root's folding from the root #535 places, where disk has it: readNesting now returns its real roots (reals) beside the nesting, and start calls foldsCase on each. Before, F194 read it at the root as configured, so a root configured through a link was judged by the directory holding the link rather than by the one holding the directory the files are in. F194's step asks holderOf, which now reads every place and spelling #535 gives a nested mount's directory, so the step needs no change for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The workspace's disk reads no longer read through a mount root that isn't the mount's directory,
readBinaryFromDisk'smaxBytesbounds the read itself, and two more opens no longer wait on a FIFO.Problem
openContainedFileis the contained read behindreadFileFromDisk,readBinaryFromDiskandread_image. It checks containment against the mount root's realpath, taken during the read. So it checks that the file is inside whatever directory the root's path names at that moment, and nothing about which directory that is.main, with the root's identity recorded, I renamed the mount's directory away and put a link to another directory at its path. All three readers then returned that directory's files, whilelson the same mount reported its root as "not the directory disk last agreed with". The same happened with another directory put at the root's path, as an unmounted drive's mountpoint would be.Since #468,
readBinaryFromDiskreads throughopenContainedFile, with itsmaxByteschecked against the open'sfstat. It then reads withhandle.readFile(), which reads the file as it is by then. So a file that grew pastmaxBytesafter that check came back whole. Onmain, withmaxBytes1024, a 100-byte file grown by 4000 bytes after the check came back as 4100 bytes, all of them loaded.Two opens still waited on a FIFO, as the contained read did before #468:
CheckedDir.sync()) opened the directory with'r'. A FIFO put at its path once it was checked held that open, and with it the mount's turn, until something opened the FIFO for writing.ToolPresentation.read) opened the operator's path withopenSync(path, 'r'), before its regular-file check. The read is synchronous, so a FIFO there held the whole process.These cases take access beyond the workspace's own tools, such as a shell in the mount, write access to the mount's parent directory, or the operator's own configuration.
Changes
openContainedFilechecks the mount root first (readRoot), before any of its steps beneath it, as a pass checks it, throughobserve.ts'scheckRoot.mount_unavailablewith noerrno, at a new stage,'root', in the wordslsandsyncgive it. Those say to reconnect it, or to sync withacceptRootif it was replaced on purpose.mount_unavailablewith ENOENT, at stagerealpath_root. One that is a file ismount_unavailablewith ENOTDIR, at stageroot.checkRootgives only a reason, so a refusal for anything but another directory takes itserrnofrom astatof the root (rootAbsence).DiskAgreement.rootIdentityForRead), in every state of the evidence's journal: the last identity whose append succeeded. So while the workspace's disk evidence awaits reopening, which is after an append to it failed and until the store is reopened, the read still checks the root, though passes and tool calls fail, as before. A mount whose first identity record is the append that failed is read as one with no recorded identity, as before that record.mount_unavailablewith that reason, and with the root'serrnowhere no directory is there.parseFilePath), which each reader does beforeopenContainedFile, also looks at disk. It takes disk's spelling of the path's names, and in a mount with nested mounts, it refuses a path that reaches one of them through a link (fix(workspace): a nested mount is its directory wherever disk puts it #535). So, by our reading, under a root replaced by a directory holding such a link, a path through the link is refused by that check rather than as replaced.stillHere(), as an effect asks it ineffects.ts'smoved()). That happens once the file is open and its own checks pass, and before any refusal beneath the root, a missing file included, is reported. A root that is no longer the directory the read checked gets the code anderrnoits state would get before the read (rootMoved). The bytes are then read through the descriptor of the file that was checked, so a root that moves after that can't change what is read.read_imagepasses on amount_unavailablerefusal's message, as it does anescape's, where it said only "Mount unavailable: ".readBinaryFromDiskreads withreadPrefixwhen givenmaxBytes, asreadFileFromDiskandread_imagedo. A file it finds pastmaxBytesis refused as "File too large". The size it gives comes from anfstatafter the read, and is at least one more than the bytes it kept, so it's always pastmaxBytes.CheckedDir.sync()andToolPresentation.readopen withO_RDONLY | O_NONBLOCK. A FIFO opens at once, and their existing checks refuse it: the sync's as a directory that changed since it was checked, and the presentation read's as a file that isn't regular, with a diagnostic and none of its settings applied.viewOf()builds a mount's view once, for a pass's runtime and for the read's root check.What each part is for
readRootbefore the lookups puts the root's answer ahead of anythingopenContainedFilesays about a path beneath it. Checking only after the open would still report a missing file under a root that isn't the mount's.checkRootwith the recorded identity is the one check the passes and effects already make, so the read and the evidence agree on which directory the mount is. Checking only that the root is a directory would read another directory as the mount.rootAbsencegives a host theerrnothat says no directory is there. By our reading, connectome-host's upload readsmount_unavailablewith ENOENT or ENOTDIR as no file on disk.stillHere()after the open, and the one catch around the steps beneath the root, cover a root that changes during the read. Containment compares paths, so it can't see that by itself. One catch, rather than a check at each refusal, covers every refusal beneath the root, now and as steps are added.rootMovedclassifies a root that moved mid-read asrootAbsenceclassifies one before the read, so one state gets one code anderrnowhatever the timing.rootIdentityForReadkeeps each read checked, and host reads working, while the evidence awaits reopening. The evidence layer stops reading then so that a decision from stale evidence can't record what reopening contradicts, and a read records nothing. Refusing the read instead would turn a rare journal failure into an outage of every host read, a host's instructions file included, and reading without the check would follow a replaced root then. It needs no reconcile, since root entries are written without a sync, so a reconcile can't change the identity.rebuild()itself, rather than in howrebuild()happens to be called.WorkspaceReadErrors, as its callers expect, when the evidence was never read back whole, and gives the root'serrnowhere no directory is there.read_image's reason tells the agent why the mount is unavailable, which is what makesacceptRootreachable.readPrefixinreadBinaryFromDiskis the bounded read the other two readers share. Thefstatafter a truncated read, floored at the bytes found plus one, keeps the refusal's size truthful when the file changes again.O_NONBLOCKat the two opens turns a wait on a writer into the refusal each already makes.viewOf()keeps the view the read checks against identical to a pass's.Nothing was cut.
What a host notices
mount_unavailable, noerrno, stageroot, "…not the directory disk last agreed with…"not_found, ENOENTmount_unavailable, ENOENT, stagerealpath_rootnot_found, ENOTDIRmount_unavailable, ENOTDIR, stagerootA root that goes, becomes a file or becomes another directory during the read, until the file has been opened and checked, gets the table's code and
errnofor that state, in any mount, recorded or not. Its stage and words name the check that found it.readBinaryFromDiskanswers amount_unavailablerefusal with the refusal's message as itserror. So for a gone mount directory, or a file in its place, it now says "Mount unavailable: …", where it said "File not found on disk: ". WithmaxBytes, a file that grows past it during the read is refused as "File too large", where it was returned whole.By our reading, connectome-host's upload (
diskHasNoFilein itssrc/modules/identity-module.ts) readsmount_unavailablewith ENOENT or ENOTDIR as no file on disk, as it readsnot_foundwith ENOENT or ENOTDIR. So its outcomes for a gone directory and for a file in its place stay as they are. For another directory at the root, it now refuses the upload, where it uploaded that directory's file.By our reading, connectome-host's web UI answers a
mount_unavailablerefusal with 503 and the others it doesn't forbid with 404 (src/modules/web-ui-module.ts). So for a gone mount directory, or a file in its place, its file route goes from 404 to 503, and for another directory at a recorded root, from 200, with that directory's file, to 503.When a sync with
acceptRootfails at its append to the evidence, that sync fails, and reads go on checking against the identity from before it. So they refuse the root it was to accept, as before that sync, at least until the store is reopened, as at the next start.Tests
At this head, which merges
main19fc2468into this change's commit, on Linux, Node 24.22.0, with membrane 0.5.87, context-manager 0.14.1 and chronicle 0.4.0 installed (the repository commits no lockfile). The pull request's CI runs the whole suite on Node 20 and 24, on Ubuntu and macOS.test/workspace-read-file-from-disk.test.ts46 of 46,test/workspace-three-way.test.ts354 of 354 andtest/tool-presentation.test.ts14 of 14.test/workspace-read-file-from-disk.test.ts:syncwithacceptRoot, the read goes through.mount_unavailablewith ENOENT and ENOTDIR, andreadBinaryFromDiskanswers each with the refusal's message.errno, though the directory the read checked is untouched.errno.acceptRootwhose append fails, by injection, leaves reads refusing the root it was to accept, against the identity from before.mount_unavailable, with ENOTDIR once a file is at the root.readBinaryFromDiskwithmaxBytes100 refuses a file grown past it after the open, from 10 bytes and from empty, with the size the read found, having loaded at most 101 bytes. It refuses one that grew and then shrank before the size was taken as "101 > 100". It reads one that grows but stays withinmaxByteswhole.errnotoo, ENOENT.test/workspace-three-way.test.ts:test/tool-presentation.test.ts: a FIFO at the presentation file is refused with a diagnostic, and the default description stays. The read runs in a child process stopped at 10 s, since a read that waits holds the process it runs in.main'ssrc/modules/workspace/index.ts,observe.tsanddisk-agreement.ts, and itssrc/tool-presentation.ts, under these tests, 29 of the 30 new tests fail, and no other test. The one that passes is the growth withinmaxBytes, whichmain's whole read also returns whole.mainhas it alone, fails at least one new test, and no other test in the file that holds its tests. They are the root's check coming first, its recorded identity, the check after the open, the one catch,rootAbsence's ENOTDIR and itserrnofor a failedstat,rootMoved's classification, the read's use ofrootIdentityForReadrather thanrootIdentity, that accessor's answer while the evidence awaits reopening and its refusal while memory isn't whole, the evidence's refusal until the open has read it back in full, a rebuild's map swapped in at its end, the catch around the identity's read and that catch'serrno,read_image's reason, the two non-blocking opens, andreadBinaryFromDisk's bound, thefstatafter its read, and that size's floor. Each ran at this head. Puttingmain'swrite()in the other order, applying before appending, fails theacceptRoottest.Not verified
Out of scope
observe.ts's header says of every check by path.readFileSync), the operator log (appendFileSync) and the host-mode recovery directory's sync (openSync(directory, 'r')). Each opens whatever is at its path, so a FIFO there waits until its other end is opened. Refusing one would be a new refusal, and for some, such as a config fed through process substitution or a log another process reads, a pipe can be what the operator means. This change fixes only the opens that already refuse anything but a regular file or a directory, where the wait served nothing.Companion PRs
/files/test for a mount whose directory has gone expected 0.22.2's 404, and it failed with this change. It now asks agent-framework's read first and expects the route to answer as the read does, so it passes on 0.22.2's read and on this change's (its Tests). With it in, this change can land on its own.changelog.d/disk-read-root-and-fifos.fixed.mdWritten by Rupert-2634 (Claude Opus 5.5), to a design Ada-1017 (Claude Opus 5.5) approved. Hilda-2580 (Claude Opus 5.5) found the replaced-root read and the FIFO hangs this work began with, and the unbounded
maxBytesread whose last site this bounds. After a round of our internal review, Desmond-2579 and Duncan-2410 (Claude Opus 5.5) reviewed it. A rebase ontomainchanged only an import line, a second round of our internal review read every line since the first, and Desmond-2579 and Duncan-2410 approved its fixes. Duncan-2410 also read its merge ofmainafter #535.Description checked and posted by Fiona-2757 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's Pi tooling, and posted from the shared
nissa-seruaccount.