Repository navigation
feat(workspace): a mount's include names the files it holds, and is its boundary - #536
Conversation
…ts boundary A mount versioned everything under its path that ignore didn't cover, and ignore shaped only the walk: materializeMount, a sync naming a path, the watcher's passes and the agent's own reads and writes didn't consult it. So a host couldn't keep the editor files saved beside its config out of a mount's history, or stop an undo or a branch switch from writing them back (anima-research/connectome-host#278). - MountConfig.include takes patterns in ignore's forms, read for files: a name or an `*.ext` glob covers a file by its own name at any depth, and a `dir/**` glob covers what lies beneath that directory. A bad include is refused when the module is created, naming the mount and the pattern. - It's the mount's boundary. A file or link it doesn't cover is outside the mount: a walk passes over it, no pass decides it, materialize and materializeMount never write it back, and the file operations (read, read_image, write, edit, delete, readBinary, writeBinary, readBinaryFromDisk, readFileFromDisk) refuse it, readFileFromDisk with the code outside_include; resolveAbsolutePath returns null for it. A sync, materialize, grep, glob or ls naming an uncovered path takes it as a scope exactly where a listing names a directory, so no tool says more about what lies outside the include than a listing does. status and every listing show only what's inside. - Every tool reads a path after its mount's name in one spelling, made where it's parsed: `/` between segments, `\` too on Windows, no empty or `.` segment and no trailing `/`. On a mount with an include a `..` is refused before the path is looked up; on any other, a `..` takes back the segment before it, and one that climbs above the root is refused. A key no spelling names, which a store can hold from before, is outside the mount. - The framework keeps its own files (tool-results/, refs/, background-scripts/) in the first read-write mount without an include. - Text the host registers for a path is served there whatever the include says. A mount path given with a trailing separator holds its own root. - Changelog fragments say so. Tests: test/workspace-include.test.ts, and the files whose expectations the one spelling changes. 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>
… one spelling and refuse a climb
On main, glob and grep took a path with a `.` or `..` segment as written,
so the store refused the key each made from it ("Invalid tree path").
The include reads every tool's path in one spelling on every mount; this
pins it for the two scope tools on a mount without an include:
work/src/../src and work/./src find work/src/a.ts, and work/../work/src is
refused as climbing above the root. Tabitha-2696's probe found the case on
main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…swers only under its exact spelling A spelling of the catalogue's path in another case isn't compared with it, so on a disk that folds names none of the catalogue's protections applies to it: a write under it makes an ordinary file, which any agent can read under that spelling. This leaves the lookup as it is and says so where the catalogue is described. Tabitha-2696's run on a disk that folds names showed it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… to it is that mount's - holderOf takes a path that spells a nested mount's directory in any way disk folds to it as that mount's, where disk folds names there. spellNestedDirs notes that at start (foldsNames: a case-flipped or renormalized spelling reaches the same directory), and containedFolded compares as listedName does. So resolveAbsolutePath, which judges a path as given, returns null for such a path, where it returned a path into the nested mount's directory, to a file that mount's include may hide. On a disk that tells case apart, a directory spelled otherwise stays the outer mount's own. - parsePath's doc says what its path is: disk's spelling where that differs from the one spelling, and otherwise the path as the caller gave it, which a refusal then shows. - Texts: on a mount with an include, the `..` refusal comes before a nested mount's answer; a change the watcher reports outside the include is dropped; resolveAbsolutePath's two answers on a disk that folds names; registered text answers only to its own path there; when a link into a nested mount's directory is other_mount; and the catalogue line says a write under another spelling writes the file at that name. - Tests: resolveAbsolutePath with a nested mount's directory spelled otherwise, on a disk that folds names; the `..` refusal into a nested mount's directory. The three-way test that called pathSegments and inOtherMount with `\` passed in is gone: it reached no parsing, and the include's splitter test covers the splitter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…each level compares names foldsNames tested the nested directory's whole path with every segment flipped at once, so where a disk that folds names is mounted beneath one that tells them apart, the variant reached nothing and resolveAbsolutePath still took a spelling such as fold/M as the outer mount's. Now: - foldsAtEach records, for each segment of the directory, how the directory holding it compares names, case and Unicode normalization apart: a variant of that segment alone reaches the same entry, as listedName tests one. A level it can't tell, because the segment has no such variant or isn't on disk at start, is taken to fold, so the comparison errs toward the nested mount. - beginsWithDir compares a path's segments with the directory's level by level, as each level compares names, and holderOf takes the rest of the path past the directory's segment count. - Only a path judged as given (resolveAbsolutePath) is compared folded. The tools judge a path as disk spells it, so they compare it with the directory's spellings exactly, and a loose case fold (ß with ss, ı with i) can't take a distinct directory of theirs for the nested mount's. - spellNestedDirs sets a directory's spellings and its levels together. - The walk's first include test now has an editor's swap file, counted as one rather than as outside the include, which pins the order the merge chose. Tests: the level-by-level comparison on any disk; resolveAbsolutePath with a nested directory in its other normalization form, and with one not on disk at start; and, where disk folds case but keeps ß apart from ss, the tools reading an outer directory spelled with ss beside a nested straße while resolveAbsolutePath gives null. Texts say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aken to fold The constructor filled each nested mount's levels with nothing known, which beginsWithDir compares exactly, so until the first start's spellNestedDirs ran, resolveAbsolutePath took a nested directory spelled in another case as the outer mount's. Every level is now taken to fold until a start tells it, as for a directory not on disk at start. A test has resolveAbsolutePath give null for such a spelling before any start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a start that finds the directory - A test calls foldsAtEach directly, on the disk the suite runs on: each level as the directory holding it compares names, with an ASCII name's normalization, a name with no letter to flip, and every level from one not on disk taken to fold. - The test of a nested directory not on disk at start now starts the workspace again once the directory is there: where disk tells case apart, LATER is then the outer mount's own. - Texts: resolveAbsolutePath's comment, types.ts and the fragment say that where it can't tell, it errs toward the nested mount, the loose case comparison included, which also takes a spelling a disk may keep apart, such as strasse for a nested straße. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main gained anima-research#526 (modules read, edit and remove the writes the framework holds for them), anima-research#531 (fetch_reference's failures say what happened and what calling again can do, and the refs save hook logs why a payload wasn't stored) and anima-research#532 (start() and addModule() are refused while a stop() is still running). Where they meet the include: - reference-fetcher.ts conflicted where both sides changed what the fetch answers when the save hook returns null. It takes main's failure and its log, and its comment says the null covers no workspace, no writable mount without an include, and a failed write. - The refs save hook's log for a missing mount said "no workspace mount is writable". firstWritableMountName also answers null where the only writable mount has an include, so the log says "no writable workspace mount without an include", and main's test of the hook's log lines says so too. - The rest merged on its own: docs/tool-result-guard.md's two sides change different paragraphs, and anima-research#526's and anima-research#532's framework.ts changes touch no mount or workspace path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a test of the levels before start - Two changelog fragments from main named a missing writable mount where the framework now looks for one without an include: the refs save hook's log (fetch-reference-words) and the image stubs past a spill's cut (403-truncation-image-slots). - The background-script journal's comment names the first read-write mount without an include, which is the one firstWritableMountName picks. - resolveAbsolutePath's doc lists every null: an unknown mount, a path the mount doesn't hold, which leaves its directory, lies in a nested mount's directory or falls outside its include, and a path that can't name a file of it, with a separator right after the mount's name or a lone UTF-16 surrogate. - spellNestedDirs' comment says only the levels from a directory's first segment not on disk are taken to fold. - The include's doc and fragment say a level is taken to fold until a start tells how it compares names, and that the loose case comparison applies at every level that folds case. The fragment says a covered name whose file disk spells otherwise is refused in a way that shows a file is there, and that connectome-host's _config can name its files with an include. - A test has resolveAbsolutePath, before any start, take a nested directory two levels deep as that mount's in another case at the first level and another normalization form at the second, while the outer mount's own path resolves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The changes affect workspace file boundaries and path handling across agent tools. Fix full syncs leaving deleted covered files visible before merging. FindingsFix with agent prompt### Issue 1
src/modules/workspace/observe.ts:1577-1579
With `include: ['gate.json']`, sync `x/gate.json`, then replace directory `x` on disk with an ordinary file. A full `sync` puts `x` in `walk.others`, so `provenAbsent` cannot prove that `x/gate.json` is gone. The old entry stays `unverified`, and `ls` and `grep` keep returning it after repeated full syncs. Without `include`, the same scan removes it.
Keep enough file-type information to prove covered children absent without reading or tracking the excluded parent file.
### Issue 2
src/modules/workspace/index.ts:798-802
On Windows, the constructor accepts `include: ['sub\\gate.json']` because `name` only rejects `/`. But `pathSegments` changes backslashes to `/`, while `coveredByInclude` compares the unchanged pattern against the file's last name. This accepted pattern can never match, so a mistaken configuration silently excludes the intended file instead of failing at construction.
Reject backslashes in patterns on Windows, while preserving literal backslashes on POSIX.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryAdds
Reviews (1) · Last reviewed commit: "docs(workspace): texts the include makes..." · Reviewed by Greptile |
| if (!entry.isDirectory() && !insideMount(childRel, view.include)) { | ||
| others.push([childRel, OUTSIDE_INCLUDE]); | ||
| regions.push({ path: childRel, reason: OUTSIDE_INCLUDE, kind: 'ignored' }); |
There was a problem hiding this comment.
With include: ['gate.json'], sync x/gate.json, then replace directory x on disk with an ordinary file. A full sync puts x in walk.others, so provenAbsent cannot prove that x/gate.json is gone. The old entry stays unverified, and ls and grep keep returning it after repeated full syncs. Without include, the same scan removes it.
Keep enough file-type information to prove covered children absent without reading or tracking the excluded parent file.
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: 1577-1579
Comment:
**Deleted files stay visible**
With `include: ['gate.json']`, sync `x/gate.json`, then replace directory `x` on disk with an ordinary file. A full `sync` puts `x` in `walk.others`, so `provenAbsent` cannot prove that `x/gate.json` is gone. The old entry stays `unverified`, and `ls` and `grep` keep returning it after repeated full syncs. Without `include`, the same scan removes it.
Keep enough file-type information to prove covered children absent without reading or tracking the excluded parent file.
**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.
Addressed in e0cea10e, with more tests in a8414822.
A run at 7e1ee976 showed it: with include: ["gate.json"], once x/ was replaced by a file named x, two full syncs in a row kept x/gate.json in the workspace's history, ls listed it as unverified and grep matched it. Without an include, the same sync took x in and dropped x/gate.json. Now the walk notes a regular file the include doesn't cover as one (Walk.otherFiles), and provenAbsent takes a path beneath it as absent, so the sync records x/gate.json as gone, as it does when x/ goes altogether. a8414822 also drops a name from that record when the walk takes back the listing it came from. The test "a covered file whose directory disk has replaced with a regular file the include doesn't cover is gone after a full sync" in test/workspace-include.test.ts covers it at the mount's top and in a subdirectory. A link the walk passes over is left as main leaves any link: a walk doesn't go through one, so a covered file beneath it stays in history, and a test pins that too.
Written by Nell-1783.
— Lottie-2661
| const segment = (s: string): boolean => s.length > 0 && s !== '.' && s !== '..'; | ||
| const name = (s: string): boolean => segment(s) && !s.includes('/') && | ||
| (!s.includes('*') || (s.startsWith('*.') && s.length > 2 && !s.slice(1).includes('*'))); | ||
| const readable = (p: unknown): boolean => typeof p === 'string' && | ||
| (p.endsWith('/**') ? p.length > 3 && p.slice(0, -3).split('/').every((s) => segment(s) && !s.includes('*')) : name(p)); |
There was a problem hiding this comment.
Accepted patterns match nothing
On Windows, the constructor accepts include: ['sub\\gate.json'] because name only rejects /. But pathSegments changes backslashes to /, while coveredByInclude compares the unchanged pattern against the file's last name. This accepted pattern can never match, so a mistaken configuration silently excludes the intended file instead of failing at construction.
Reject backslashes in patterns on Windows, while preserving literal backslashes on POSIX.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 798-802
Comment:
**Accepted patterns match nothing**
On Windows, the constructor accepts `include: ['sub\\gate.json']` because `name` only rejects `/`. But `pathSegments` changes backslashes to `/`, while `coveredByInclude` compares the unchanged pattern against the file's last name. This accepted pattern can never match, so a mistaken configuration silently excludes the intended file instead of failing at construction.
Reject backslashes in patterns on Windows, while preserving literal backslashes on POSIX.
---
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.
Addressed in e0cea10e.
Each pattern is now checked by includePatternReadable in observe.ts. Where \ is the platform's separator, it refuses a pattern holding one. Where / is the separator, \ stays part of a name. Its test passes each separator in, since CI runs no Windows, and a8414822 adds an assertion that the module's own check takes a pattern holding \ where / is the separator.
Written by Nell-1783.
— Lottie-2661
…neath it; refuse a pattern holding the platform's `\` Both from Greptile's review of this pull request. - A walk passes over a regular file the mount's include doesn't cover, and provenAbsent read every passed-over name as present and unproven, even for a path beneath it. So a covered file whose directory disk replaced with an uncovered file stayed in history, unverified, through every full sync, and ls and grep kept showing it. The walk now notes such a file as a regular file (Walk.otherFiles), and provenAbsent takes a path beneath it as absent, so the sync records the covered file as gone, as it does where the directory went altogether. A path beneath a name passed over by design is never decided, so only the files the include passes over are noted. - Where `\` is the platform's separator, as on Windows, every path is read with it as one, so a pattern holding `\` could name no file and would take its files out of the mount unsaid. The check the module makes on each pattern is now includePatternReadable, which refuses it there and reads `\` as part of a name elsewhere. Tests: a covered file's directory replaced with an uncovered file, and the pattern check with each separator. The include test file's header says connectome-host's _config can name its files with an include. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts others; pin the rules beside the fix for removed directories
From the internal review of the previous commit.
- When a listing is taken back, the walk's record of the regular files the
include doesn't cover (otherFiles) is taken back with the others that
hold them, so it stays a record of names in others.
- Tests:
- a covered file whose directory disk has replaced with a regular file
the include doesn't cover is gone after a full sync, at the mount's top
and in a subdirectory;
- one whose directory disk has replaced with a link the include doesn't
cover stays in history, as a file beneath any link does, since a walk
doesn't go through a link and on a mount that follows links the file
may be there through it;
- provenAbsent takes a path beneath an uncovered regular file as absent,
and not that file itself;
- where "/" is the separator, the module takes an include pattern
holding a backslash as part of a name.
- The changelog fragment and the include test file's header say
connectome-host can give its _config mount an include naming its files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… they hold with anima-research/agent-framework#536 - The climb test asks agent-framework's own read for its refusal of a path that climbs out of its mount, checks that it's a traversal refusal naming the path, and checks that the upload passes it on. agent-framework 0.22.2 says such a path "resolves outside mount", and anima-research/agent-framework#536 says it "climbs above the root" of the mount. - The saveAs test compares the host's verdict with the workspace's write for every form without a "." or ".." part after its mount's name. For each form with one, it checks that the host refuses it before sending, with its advice to name the plain path, and it no longer writes that form through the workspace, since there's no verdict to compare. 0.22.2's write refuses such a path, and #536's reads one that stays in the mount as the plain path it names, so with #536 the host still refuses those before sending, though the write would take them. Both hold with 0.22.2 and with #536, so this can merge before #536 and needs no change when the host takes a release carrying it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…m-framework test(identity): ask agent-framework for the answers two tests pin, so they hold with anima-research/agent-framework#536
main brings anima-research#535, which places each nested mount at each start where its real root lies as well as where it's configured (readNesting), and refuses a path that reaches a nested mount's directory through a link the mount follows (nestedAtReal). Resolved: - index.ts: anima-research#535's readNesting takes the place of the include's spellNestedDirs. Each spelling it places a nested mount's directory by (as configured, through a root, by the real root, and as disk lists each) now carries its own levels, from foldsAtEach on disk's spelling, read from the mount's root as anima-research#535's spelling is. The include kept one list of levels, for the configured directory. holderOf reads each spelling's levels, and the constructor's entries take every level to fold, as before. nestedOf gives each nested mount's real root and its include. parseFilePath refuses what the include's refuseNotHeld refuses, then what anima-research#535's leadsIntoNested does, and resolveAbsolutePath keeps refuseNotHeld, its comment naming readNesting. The imports take both sides' names. - observe.ts: one NestedMount type, with both sides' fields, `real` and `include`. Each side had added the type in its own place, and git kept both. MountView's `nested` doc says what both sides read it for. Merged without a conflict: effects.ts and watcher.ts, which only main changes, and test/workspace-parent-link.test.ts and test/workspace-three-way.test.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ted mount only where that mount's include covers it Merged with main, the include met anima-research#535's refusals of a path that reaches a nested mount's directory through a link. Those name the place the path reaches beneath the nested mount, and the disk readers give it the code other_mount. Where the nested mount's include leaves that place out, the refusal named a file no tool of that mount names, and its advice was a path that mount would refuse. - namesNestedPlace holds the rule the include already applies to a link a mount doesn't follow (namesHolder): a refusal names a place in a nested mount's directory, as that mount's, only where it's a directory or a file the nested mount's include covers. Anywhere else the place is no mount's, so a path that reaches it through a link the mount follows is refused as one that leaves the mount, in the words each refusal of that already has: "Symlink escape detected: <path>" with the code escape from the disk readers and the file tools, and "a symlink that leaves the mount" from a pass and a push. - It's asked at each site that names such a place: observePath and openContained, the push's check of an opened file and its unlink of a followed link, openContainedFile, and leadsIntoNested, which the file tools and grep's check of the copies it holds use. nestedAtReal gives the place beneath the nested mount's root and that mount's include for it. A directory a push makes on its way names only a nested mount's root, which is that mount's. - Tests: through a link a mount follows, a file the nested mount's include leaves out, there or missing, reached as the link itself or beneath a linked directory, is refused as leaving the mount by the disk readers and read, naming neither, and grep and a sync say the same of a link to one that's there; a covered file and a directory are named, as that mount's, the directory read as a file too; a mount that doesn't follow the link still refuses it as a link it doesn't follow. On a disk that folds names, resolveAbsolutePath takes a nested mount's directory where the real roots place it as that mount's in another case, as it does the configured one, which pins the merge's reading of each spelling's levels. - The include's fragment says what a mount that follows the link answers, and anima-research#535's fragment says its refusal names neither where the nested mount's include leaves the place out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
…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>
main has taken anima-research#234, anima-research#528, anima-research#529, anima-research#524, anima-research#538, the 0.22.3 merge-back (anima-research#539), anima-research#372, anima-research#210 and anima-research#541 since 8d7694f. - changelog.d/403-truncation-image-slots.fixed.md: main deleted it when 0.22.3 took it into the changelog, where this branch had changed one clause of it, to say a spill file goes only to a writable mount without an include. Taken as deleted: 0.22.3's entry stays as released, and this change's own fragment (mount-include.added.md) already says where the framework keeps its files and that a setup whose only writable mount has an include is treated as having none. git merged the rest on its own. Against main, every file the merge changes differs exactly as this branch did against 8d7694f, so no change of main's is undone. Of the files both sides changed, src/framework.ts, src/types/framework.ts and src/types/trace.ts take this branch's spill texts beside main's changes elsewhere in them, and test/workspace-three-way.test.ts takes the nested-directory tests beside anima-research#528's re-attach tests. No use of firstWritableMountName or text about a writable workspace was added on main since 8d7694f. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…old, at every site and depth A further review of the walk, and runs of every tool through a link, found sites that told an errno, a place outside the mount or what a file is before asking where the path went, and layouts the walk misread: - landedFor asks landing where a path of a mount that follows symlinks has a real place other than its spelling beneath the root, or where the kernel couldn't resolve it. parentPlacement and locateDir ask it before containment and before a failed lookup's errno; the located read before an errno or what the file is; checkOpened before what the file is; the disk read at each failed lookup, open, fstat and realpath, and before containment; and the effects' failed opens, as before. So a link of the nested mount's own leading out of the outer mount, a loop there, or a FIFO there tells nothing a file there doesn't. - realBeneath reports, through an out-parameter, a realpath that failed otherwise than ENOENT or ENOTDIR, which it returns as written, or a link it read itself on the way to a place not made yet, whose spelling can come out as the path's own, and the file tools' gate asks the walk there. The gate compares against each mount's root where disk has it, read with the nesting at each start, so a root configured through a link, as one under macOS's /var is, doesn't make every path walk. realBeneath's doc no longer says nothing is taken lexically after a name that isn't there. - landing keeps a stack of the nested directories it is in, so a `..` at the root of a mount nested in a nested one climbs back into the one around it. A nested mount without an include shows every place in its directory, so the walk goes through it as it does outside, its links followed, and judges again in any mount with an include it reaches. A place where the walk stops is shown only where nothing at all follows it, not even a `/` or a `.`. An absolute link target is walked from its root, whose own parts, such as a Windows drive or share, aren't names. Each nested root is read once, however many spellings list it. - A walk passes over a directory it descends into when it is a nested mount's root by identity, read once per walk, so a link spelling a directory above it otherwise than a disk that folds names does lists none of that mount's files. - grep asks the file tools' gate of its path before asking disk whether the path names a file, so a file the nested include leaves out and a name that isn't there are skipped alike, in read's words. - checkOpened, at a file that isn't regular, asks the walk before telling its kind, and only the walk, so where no nested mount with an include is reached, the kind is told first, as before. - Error objects for these refusals are made only where they're thrown. Texts: the fragment, landing's doc and the description say that names in a nested mount's directory are judged as the path and its links spell them, as resolveAbsolutePath judges a path as given, and that a link inside a nested mount with an include stops the walk even where that mount follows links. They also say resolveAbsolutePath follows no link, and name two cases outside a nested mount's boundary, since its root is placed at each start: a mount configured through a link spelled otherwise than a disk that folds names spells it isn't found nested, and a root a mount accepts while running stays placed where it was until the next start. A test comment no longer says a file's `..` reaches the root on disk. Tests: a `..` after a directory, a `/` after a covered file, the nested mount's own looping link, and links that come back to themselves through a withheld file or a missing name, refused by write's parse and the first three by readFileFromDisk; a mount without an include nested in one with an include; a link inside a nested mount with an include that follows links, and one whose name the include covers; a sync beneath the nested mount's own link; a nested mount without an include on the way; links of the nested mount's own leading out and in; grep of a withheld path and a missing one; a looping link outside any nested mount; chmod's failed open, called directly; a `..` out of a mount nested in a nested one; and on a disk that folds names, a walk through a link spelling an ancestor in another case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3b41199 to
49f3505
Compare
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R1 at 49f3505 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.
Reviewed at 49f3505 against main 00eb87a
No earlier review of ours on this PR, so there are no earlier findings to give a disposition. Greptile's two findings (P1 deleted files staying visible, P2 Windows \ patterns) are fixed in e0cea10e and a8414822, and we checked both.
Verdict: approve, with three minors and a nit. None of them blocks.
The include does what the PR says for content. We traced every workspace path, and none of them reads, stores, serves, writes back or names a file the include doesn't cover. CI is green, and so is the change merged onto today's main (67aabff2). The guarantee that answers are the same whether a file is there is weaker than the description says (R1-1). That matters for scope. The include itself is small: the matcher, insideMount, the filters in gather and pushUnlocked, the walk's pass-over and refuseNotHeld. Most of the 1,169 added src lines go elsewhere: making answers indistinguishable through followed links into nested mounts and on case-folding disks (landing/landedFor at about 20 call sites, foldsAtEach/beginsWithDir), and the one-spelling change that applies to every mount. Since existence can still be inferred by simpler routes, the maintainers should decide whether that machinery is worth keeping as a standing invariant. Each of the last three merges with main needed a fix commit to carry it to new sites (17f2de03, c80bddf4, 49f3505f). If it stays, a single "ask landedFor before telling what the kernel said" helper would make the next site harder to miss.
The two questions this review was asked to settle
Is include now a hard boundary on every read/write/materialize/sync path? It is for content, everywhere in the workspace module:
- File operations:
read,read_image,write,edit,delete,readBinary,writeBinary,readBinaryFromDiskandreadFileFromDiskall go throughparseFilePath→refuseNotHeld(index.ts:2279). - Path-taking listings and passes:
ls,glob,grep,syncandmaterializego throughparsePathandrefusesAsOutside(index.ts:2325). resolveAbsolutePathgoes throughrefuseNotHeldwithasGiven.- Pushes: every push goes through
pushUnlocked(index.ts:1122), the only caller ofpushPaths. That coversmaterialize,materializeMount, the write tools' pushes and autoMaterialize. - Passes: every pass goes through
gather(reconcile.ts:~865–873), which filters tree, agreement, intent and watcher candidates byinsideMount. That covers the walk, the watcher batch, the path-scoped sync,ensureSyncedbefore reads, and the after-batch scan. The walk counts what it passes over underpassedOver; it never lists those paths. - Events:
emitFsEventsonly sees ops the pass decided, so an uncovered change never produces an event or a wake.
The deliberate exceptions are text registered with registerGeneratedTextFile, which is read-only, and a non-recursive ls naming directories that exist on disk. It is not a disk sandbox. Anything that acts on the mount directory by absolute path (an operator, a host module, a script) is untouched; uncovered files are just never versioned, served or written back. It is not an existence boundary either (R1-1).
Upgrade effect on existing mounts with include patterns. The premise needs one correction: no mount can carry an include before this PR. main's MountConfig has no such field. connectome-host refuses unknown mount keys (#275), and its MOUNT_KEY_CHECKS table is typed by MountConfig, so it won't compile against this release until include gets a check there. That is by design (see Downstream). The real upgrade effects are these:
- Adding an include to a mount that already has history. We probed this against the built head:
statuscounts only the covered entry.readandwriteof old files are refused, includingtool-results/spill files that earlier spill notes point to.- History keeps every entry as it was.
- A disk deletion of an uncovered file while the include is on is not adopted.
materializeMountwrites only covered paths.- Removing the include again resumes the three-way logic correctly: the deletion made while the file was hidden is adopted on the next sync.
- Every mount, with or without an include: one spelling.
- Old keys such as
a//b.txtorc/become inert, and a draft stored under one that never reached disk is stranded until someone recovers it by hand (see R1-3). - The refusal text for a climb changes.
work//xis now refused.- A
..resolves lexically on a mount without an include. - A walk through a link to a directory above a nested mount no longer lists that mount's files.
- Old keys such as
- Framework spill files (
tool-results/,refs/,background-scripts/) go to the first read-write mount without an include. In connectome-host_configis appended last, so default recipes keep spilling intoproducts. A recipe whose only writable mount is_configloses spill files once #278 gives it an include, and falls back to plain truncation.
Blockers
None.
Major
None.
Minor
R1-1. Non-disclosure is narrower than the description says. (Found by both reviewers, through different channels.) The description says "no tool says more about what lies outside the include than a listing does, with two exceptions". There are at least three more channels:
- (a) A whole-mount
syncreturnspassedOver: [{ reason: "outside the mount's include", count: N }]. This is by design and pinned bytest/workspace-include.test.ts:119, which expects[OUTSIDE, 6]. A listing gives no such count. - (b) On a disk that folds names,
parsePathrespells to disk's spelling (index.ts:2357) beforerefuseNotHeldchecks the include. Soread gate.jsonanswers "outside the mount's include" when an uncoveredGATE.JSONexists, and not-found when nothing does. The changelog documents this one. - (c) This one is undocumented. A
writeof a covered path beneath an uncovered name, followed bymaterialize, tells what sits at that name. We ran it withinclude: ["*.json"], writingw/secret/a.json:- With nothing at
secret, it materializes. - With an uncovered regular file at
secret, it is skipped with "disk has a file or a symlink where this path needs a directory…" (reconcile.ts:490). - With an uncovered link,
ls,grep,materializeandreadFileFromDiskname the link and say it leads outside the mount. - The
read,ls,grepandreadFileFromDiskanswers for the regular-file case are identical to the nothing case, so onlymaterializegives it away.
- With nothing at
None of these leaks content. Each shows whether a name is taken, which an agent on most hosts can learn by other means anyway.
Fix: state the guarantee as what holds. No workspace path reads, stores, serves, writes back or names an uncovered file. Whether a name is taken can still be inferred through (a), (b) and (c). Put that in the description and in changelog.d/mount-include.added.md. Then decide, with that in mind, which of the indistinguishability machinery in the framing above earns its keep.
R1-2. The watcher still runs a full mount-turn pass for every change the include excludes, and its include half is only tested through a private method. (Found by Sol; Claude verified.)
MountWatcher's chokidar filter consults onlyignore, editor files and nested mounts (watcher.ts:121–132).handleFsChanges(index.ts:4327–4333) takes the mount's turn and runspassUnlockedfor any batch, and only then doesgatherdrop the uncovered paths.- On a narrow include over a busy directory (for example
include: ["*.md"]beside a log that keeps growing), every debounced batch holds the mount's turn against the agent's own tool calls and changes nothing. - The include test for this (
test/workspace-include.test.ts:1243–1258) useswatch: 'never'and calls the privatehandleFsChangesdirectly. The watcher suite's #278 test (test/workspace-watcher.test.ts:1136) predates this PR and has no include case.
Fix: in handleFsChanges, filter paths with insideMount(p, mount.config.include) and return when nothing is left. That is safe, because a covered path's own change still arrives as its own event, and gather would drop the rest anyway. Then add one watch: 'always' test with a real watcher asserting that an uncovered write produces no pass and no event.
R1-3. The one-spelling change is filed as fixed, but it asks operators to act. (Found by Claude.) changelog.d/workspace-path-spellings.fixed.md contains three things that make it breaking by CONTRIBUTING.md's own definition (lines 122–133: who must act, migration, unchanged):
- a manual migration for drafts stranded under old keys (
treeGetonworkspace/<mount>/tree, thengetBlob, then write again); - a changed refusal text that a host test pinned (connectome-host #331 had to change it);
work//…paths thatreadFileFromDiskused to serve and now refuses.
The release is already 0.23 because other breaking fragments are pending, so this is about where operators read it, not about the version bump.
Fix: move the operator-facing part to a workspace-path-spellings.breaking.md. Open it with "Operators whose workspace history holds paths written with //, ./ or a trailing /:" and "Hosts matching the traversal refusal's text:", and keep the bug-fix part in .fixed.md.
Nit. The agent can't see an include before it trips on one. ls without a path lists { name, path, mode } (index.ts:3529). The refusal already prints the patterns, so listing include there costs nothing and saves a failed write.
Downstream follow-ups (connectome-host, for the #278 change, not this PR's defects)
MOUNT_KEY_CHECKS(src/recipe.ts:1708) needs anincludecheck that matchesincludePatternReadable. Until then the host fails to typecheck on this release, as its own comment intends. Land the check together with the dependency bump.- The web UI's file route (
src/modules/web-ui-module.ts:1523) has nooutside_includein its forbidden-code list, so an include refusal comes back as 404 rather than 403. - The upload path in
src/modules/identity-module.ts:1069treatsoutside_includeas a disk failure and offers the disk remedy. cannotKeep(src/modules/identity-module.ts:1014) reports aresolveAbsolutePathnull for an uncoveredsaveAsas "leads out of the mount". It also lists the include mount among the writable ones in its remedy, becausegetMounts()reports it asread-write.
Verified fine
- Builds and tests:
- Head builds clean with
tsc. - Workspace and other touched test files: 767 pass, 24 skipped (folding disk), 0 fail.
- Full suite at head: 4,658 pass, 24 skipped, 0 fail.
- Head merged onto
main67aabff2(merges clean): builds, and the full suite gives 4,681 pass, 24 skipped, 0 fail.
- Head builds clean with
- CI is green on all six checks at head. The macOS legs do run the folding-disk tests: their 26 skips are watcher root-hold tests, while Linux's 24 skips are the folding tests.
- Include matcher: a
dir/**pattern is anchored at the root, asignore's is (observe.tsisIgnoredvscoveredByInclude:1555).includePatternReadable(observe.ts:1537) refuses an empty list,*,**,/**,a//**,./xanda/b, and on Windows refuses\. - The
..refusal on mounts with an include comes before any lookup (index.ts:2422). The climb refusal and the leading-separator refusal follow it. - Framework spill selection: every caller (
framework.tsreferences, background-script logs, tool-result spill, MCP payload spill) goes throughfirstWritableMountName, which skips mounts with an include.main's newerframework.tshunks don't touch it. - In-flight state: pending deletions, conflicts and interrupted-push agreement records outside the include stay inert.
agreement.forgetonly runs on paths a pass decided, so removing the include later picks them up intact. - Old keys: non-canonical keys are inert, not deleted (
pushUnlockeddrops them before splitting covered from uncovered, andgatherfilters them). - Upstream claims: connectome-host #331 is merged on its
main, and the claim that_configis appended last holds (src/workspace-mounts.ts:62–70).
Method. Two reviewers, each claim re-verified before adoption.
- Sol (Codex
gpt-5.6-sol, reasoningxhigh) read a read-only checkout of the head withgit diffagainst the merge-base, plus connectome-host'smain. It ran for about 15 minutes and roughly 90 shell commands, using 9.76M input tokens (9.28M cached) and 34.2k output tokens (17.8k reasoning). It had no network and nonode_modules, so it ran no tests and saw no CI. - Claude (Opus 5.5) built the head, ran the full suite at the head and merged onto current
main, and wrote two probe scripts against the built module: adding and then removing an include on a mount with history, and covered paths beneath an uncovered name. It also read the macOS and Linux CI logs and traced connectome-host'smain. - Who caught what. R1-1 was found by both: Sol found channels (a) and (b), Claude found (c). R1-2 was found by Sol only; Claude had seen that chokidar still sees uncovered files but filed it as harmless. R1-3, the nit and the answers to the two questions are Claude's. The downstream items came from both: Sol found the key table and the error-code mapping, Claude found
cannotKeepand the spill fallback. - Rejected or re-rated. Sol rated the connectome-host typecheck "major". We moved it to Downstream, because the host's comment (
recipe.ts:1701–1706) says that lockstep is intended. Sol rated (a) "major" as a disclosure. We folded it into R1-1 as minor, because a count names nothing and the PR documents it. - Not verified. Neither reviewer could check Windows or a case-folding disk mounted beneath one that tells names apart. For case folding we relied on CI's macOS legs.
…ler resolves the root first This change had made isContainedPath resolve the root it was given, so a mount path written with a trailing separator would hold its own root. Every caller already passes a resolved root, or one realpath gave, so that never ran. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a nested mount's already is On a mount that follows links, the include was judged only where a path is named, so a link the include covers read and wrote whatever file it led to: with `include: ["*.ts"]` and `cov.ts -> secret.txt`, a read served secret.txt, a sync stored it, grep matched it, and a materialize of a write to cov.ts wrote into secret.txt. The include is the operator's statement of which files' content the workspace holds, and a link is a path, not a grant of content, so a file the include leaves out is no file of the mount's, however a path reaches it. - coversLanding judges the mount's own include at the canonical place a path lands, as namesNestedPlace judges a nested mount's place, beside the nested check at each site that asks nestedAtReal where a followed path lands to a file: the file tools' parse, so read, write, edit and delete refuse such a path before anything is stored; observePath, for a link at the path and, through the parent's real path parentPlacement now returns, for one on its way, since a pass takes a copy whose fingerprint its stat vouches for without opening it; openContained; the push's location check; unlinkContained, before the hash, so the file isn't read for one; and readFileFromDisk. A file a write would create is judged where it lands before anything is made, and a walk of a directory a link put elsewhere than its path judges each file where it lies. - The place is judged as disk spells it, as a path given directly is: a path that meets no link lands where its parse named it, but through a link, realpath keeps the spelling the link's target gives, which on a disk that folds names need not be disk's. So only a path that went through a link reads its directories' spelling, and a walk reads it once for each directory it lists elsewhere. - Such a path is refused as one that leaves the mount, in the words a place a nested mount's include leaves out gets (escape). A mount without an include judges nothing more. - Texts: the include's and followSymlinks' docs and the fragment say so, with a link to a directory under a `dir/**` include as the example. They also give the full reach of a nested case outside the guarantee: on a disk that folds names, through a link that spells a nested mount's directory otherwise than disk does, the outer mount reads, lists, searches and writes there as its own, creating files too. Tests: the test that a covered link reads through its own name turns around. A covered link to a covered file reads through, while a covered path that reaches a file the include leaves out, through a link at it or on its way, is refused by read, write, edit, delete and readFileFromDisk, isn't stored, matched or listed from where the link leads, and where disk puts the link after a change was taken, no materialize, forced or not, writes through it, creates a file where it leads, or removes it. A copy history took in before the include, at such a path, is neither vouched for nor searched. The checks at the act, called directly, refuse it too. On a disk that folds names, the landing is judged as disk spells it, both ways, and in a listing from where a link leads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main brought the change that gives a draft one spelling on a disk that folds case: the parse respells a path with the workspace's entries, so a path that names an entry keeps the entry's spelling. Where both changed the same code: - parsePath respells with the workspace's entries, as main does, then keeps the rest of a path as given past a nested mount's directory, as this change does. With `.`, `..` and empty segments gone before the respelling, each segment keeps its place, so the two compose. Its doc states both rules. nestedDepth and entriesOf both stand. - The observe import takes both sides' names, and this change no longer uses linkLeads there. - workspace-path-spelling.test.ts takes main's counts in its header, with this change's test of an absolute path run on any disk, and both sides' tests. - readNesting returns main's real roots alone, since this change no longer keeps its own. The include's check of where a followed path lands respells without the workspace's entries, so it judges disk's spelling of the place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… whatever spelling realpath gives The include's check of where a followed path lands took a landing spelled otherwise than its path for a link it had followed. That holds where realpath keeps each name as given unless it's a link, as on Linux. On macOS and Windows, realpath gives disk's stored names, and the parse can now keep a workspace entry's spelling that differs from disk's, so a path with no link would have been judged as disk spells it: refused where the include covers the entry's spelling and not disk's, in words that name a link that isn't there, and judged differently by platform. - judgedAt gives the path the include judges: the path as named where the two spellings agree, or where no link lies on the way (linkOnTheWay's walk, asked only where they differ), and otherwise the landing as disk spells it. coversLanding asks it, and a walk of a directory a link put elsewhere asks it once for the listing, so the rule sits in one place. Tests: with a landing spelled otherwise and no link on the way, the path is judged as named; through a link, where it lands; and without an include, every file is covered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…judged where the include says The include still met a file it leaves out, or named one, in these places. - An unlink judges where a regular file reached through a link among its directories lands, before it hashes or removes it. The check sat only in the branch for a link at the path, so a forced deletion through a followed directory link removed a file the include leaves out. - A pass judges history in a nested mount's directory by that mount's include, so what the outer mount took in there before the mount was nested, and that mount's include leaves out, is inert history that no listing, sync or grep names. - A link's target is judged and named as disk spells it: realpath keeps the spelling the target gives, so on a disk that folds names a covered link's refusal could name a file disk spells outside the include. - What an answer shows keeps the caller's spelling from the first segment on a path's way that disk has as something other than a directory and that the include doesn't cover. A link refusal, a pass's note on a file tool's path, and what a sync of a path says it skipped name such a segment the same way, so no answer names it as disk spells it. Every check still judges the path as disk spells it, and directories keep disk's spelling, as a listing shows them. - A create judges where the file would land, a nested mount's place included, before anything is made: one function, which the opened file's check shares, judges a landing for both. - judgedAt takes a path as named only where its landing is spelled otherwise only as folding makes it and no link lies on the way, so a link swapped for a directory between the realpath and the walk can only make the landing judged, and a link to a directory spelled otherwise only in case, on a disk that tells case apart, is judged where it lands. - The entries the spelling step reads leave out keys no spelling names, which are outside the mount. - Where a nested mount's holder has no include, a place there is named whatever its kind, so the stat that would tell its kind isn't made. - A refusal shows a configured bigint as JavaScript writes it, where JSON.stringify threw. - Texts: the include's doc and fragment give the spelling rules on a disk that folds names and four nested cases outside the guarantee, a place reached through a link being judged in its target's spelling among them; the follow advice holds where the include covers what a link leads to; the codes' doc, the payload saver's and the trace's comments, and the parent-link test's header say what holds. Tests: an unlink through a followed directory link, forced or expecting other bytes, and a write expecting other bytes, are refused before any hash; a create leaves nothing at a nested mount's absent root; history in a nested directory that mount's include leaves out isn't listed, synced or matched; a covered directory through a link is a directory to the file tools; a landing spelled beyond folding with no link left on the way is judged where it lands, and so is a link to a directory spelled otherwise only in case where disk tells case apart; the watcher's test waits for its batch's handling and counts the mount's turns. On a disk that folds names: a covered name whose file disk spells otherwise is outside the include; an uncovered file or link on a path's way keeps the caller's spelling in a read's, a delete's and a sync's answer and a disk read's refusal, while the include still judges disk's spelling; a link's target is named as disk spells it, both ways; two nested levels keep the path as given past the innermost; and a key no spelling names spells no write. The validator refuses `*.js*` and shows a bigint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to this review. Each finding is answered at R1-1. Addressed in Thank you for asking what that machinery buys. The include exists to keep files out of a mount's content, meaning what it syncs, stores, serves and writes back. Whether a name is taken was never what it protects, and the design never said who that would be hidden from. So this round narrows the guarantee to content. No workspace path reads, stores, serves or writes back a file the include leaves out, including one in a nested mount, and no tool lists, matches or names one. A refusal may show that something occupies a path the caller named. On a mount that follows links, it may also say whether that path leads out of the mount or into a nested mount that covers it, with the path to use there. A link the include covers is described as on any mount, its target included unless that's a file the include leaves out. Otherwise, a refusal names nothing the include leaves out beyond the path the caller gave, and on a mount that doesn't follow links, it tells nothing of where a link the include leaves out leads. The code that served only to hide whether a name is taken is gone, and the description and the changelog fragment state the guarantee as it now holds, with the four cases outside it that come from how R1-2. Addressed in R1-3. Addressed in Nit. Addressed in Downstream. anima-research/connectome-host#335, merged, keeps the host's mount-key checks typechecking against this change's Thank you for the review. Written by Nell-1783 (Claude Opus 5.5). — Lottie-2661 |
…an hold An earlier version could store an entry under a key with an empty segment or a trailing `/`, such as `a//b.txt` or `c/`, but never one with a `.` or `..` segment: the store refuses those keys, which is why a write to `work/./a.txt` failed with "Invalid tree path". So no operator's history holds a `./` key for this change to leave outside the mount, and the note for operators names `//` and a trailing `/` alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s of the mount The test counted every turn of the mount taken after the uncovered write, so a turn that any other batch took in that window failed it as well. On macOS one run failed with one turn, and the test couldn't say whether the uncovered file's batch or another took it. The handler asks for its turn, if it takes one, before its first await, so the turns counted across its call are its batch's own. The test now asserts no turn for a batch of nothing but the uncovered file, and no pass naming it whichever batch hands it over, and a failure names each batch with the turns it took. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test counts each batch's turns of the mount across the handler's call, which holds because the handler asks for its turn before its first await. Nothing checked that. A handler that awaited first would leave every batch's count at 0, and the assertion that the uncovered file's batch takes no turn would pass whatever the handler did. The covered change's control now asserts that its own batch's count sees its turn, so such a handler fails the test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…path A disk read that meets a link the include doesn't cover names the link in the caller's spelling, taking the shown path's segments up to the link's. Those segments were the caller's text as given, so for a path with an empty, `.` or `..` segment before the link they didn't line up with the parsed path's: `work/./link/a.json` named "work/." as the symlink, and `work/sub/../link/a.json` named "work/sub", which isn't there. Each caller now passes the shown path as the parse reads it, as a pass's note already reads it. What's refused, and its code, stay as they were. keptAsGiven's comment said that no answer names an uncovered segment otherwise than as given. A sync's synced, conflicts and ignored lists, and ls, name entries as the workspace stores them, so the comment now says which answers show the path as given. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The walk judged a link by its own name, as a file, even where the link leads to a directory. With links followed and `include: ["*.json"]`, a read of `work/link/a.json` through `link -> real/` was served, since its path and where it lands are both covered, but a listing or a sync then called it outside the mount's include, and gave the link that reason too. A name or an extension glob can cover what lies beneath such a link, as beneath a directory, so the walk now gives the link the reason a mount without an include gives one, "a symlink, not walked", and history beneath it is unverified for that reason. Its own name is no covered file's, so the walk passes it over by design, as it does such a file, rather than taking it, and a sync counts it under passedOver with that reason where nothing tracked lies beneath it. A link to a file, or one that leads nowhere, is still judged by its name. Telling them apart costs a stat of each link whose name the include leaves out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st it The walk told a link to a directory from a link to a file by a stat that follows the link, on every mount with an include. On a mount that doesn't follow links, that resolved a link the operator chose not to follow, and a sync's passedOver then told whether it led to a directory, one outside the mount included, which none of that mount's refusals tell. Nothing reads through a link there, so every link the include leaves out is passed over as "a symlink, not walked", which is true of each, with no stat. A link to a file or to nothing is counted that way there too, while a read of it is refused as outside the include. On a mount that follows links, a link is told apart by that stat as before. Sync's description lists the reasons passedOver counts, and now names that one, as it reads on each kind of mount. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecks judge where a path lands as the others do On a disk that folds names, the parse kept the caller's spelling for a segment on a path's way that the include doesn't cover and that disk has as a file or a link, and each answer that quotes that path was made to show it so: a refusal, a pass's note, a sync's skipped entries. Review of the change found that rule leaking at answer after answer: a push's refusals, a scope's incomplete regions and notes, the last segment, a failed lookup, a name holding a quote, a disk read's absolute path. It also found the projection disagreeing with itself about directories. What it hid was how disk spells a name the caller had already given, in case or Unicode form. That's no content of the file, and the include's guarantee is about content, so the rule goes. Every answer names a path in the workspace's one spelling, disk's, and only the include's own refusal shows the path as given, since it answers the path asked about. keptAsGiven, asShownPath, asShownIn and shownReport go, and a link's refusal names the link as the parse spells it. Three checks judged less than their siblings: - judgedAt took a path as named where its spelling and the landing's fold alike and no link is on the way. On a disk that tells case apart, a link swapped for a directory between the realpath and that walk passed both, and the read served the file the link had led to. It now also requires that the path as named is the landing's own file. - The unlink of a regular file reached through a link among its directories judged only the mount's own include where the file lands. It now asks what the create and the opened file's check ask (refusedLanding), so a file in a nested mount's directory isn't removed either. - A listing that isn't recursive named a directory implied only by history in a nested mount's directory that mount's include leaves out, history no pass decides. The listing and the pass now judge history by one helper (withinIncludes). The texts follow: MountConfig.include's boundary says file, as the guarantee does; the folding-disk paragraph says which spelling the parse reads and where a landing is judged; two comments name the nested-include exception; and the walk's comment says it enters no link. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Of what main brings, two meet this branch: the 0.23.0 release, and the workspace's edit planner with edit_many. 0.23.0 released four fragments this branch had edited, and main deletes them. The branch's sentences about the include in them are already in mount-include.added.md, apart from two it now gains: the over-cap notice, the background-script log's text and the host's log line for a fetched reference it can't store say there is no writable mount without an include; and on a mount that doesn't follow links, only a refusal that names a nested mount has the code other_mount, so a link at the path to a file that mount's include leaves out keeps symlink, which a test now pins. One edit the branch made in those fragments wasn't about the include. 0.23.0's breaking note says a file that is itself a link keeps the code symlink, where one that leads into a nested mount's directory gets other_mount, as the same release's note on nested mounts says. A fixed fragment corrects the note; behaviour doesn't change. The edit planner refused a path not spelled plainly in its mount (an empty, . or .. segment, a trailing /, or on Windows a backslash), since the workspace kept an entry for each spelling of a path, and two spellings in one edit_many call would be two plans of one file. This branch has every tool read a path in one spelling, so that reason is gone: the parse hands the planner the one spelling, and two spellings in one call are one file, planned once. The check goes, with its words in the edit planner's and edit_many's fragments, and the refusal of a path that names the mount's root stays. The planner's test now pins the one spelling: four spellings edit one file, two in one edit_many call are planned once, a .. that climbs above the root and a separator right after the mount's name are refused by the parse, and the root's spellings are refused as naming no file. edit_many reaches the include through the same parse as edit, so the include's fragment names it among the tools that refuse a path the include doesn't cover, and a test pins it: an edit_many naming an uncovered path beside a covered one is refused in the include's words, with none of its edits stored. main's tool-call-result-max-chars test pins the over-cap notice's words for a framework with no writable workspace, which this branch changes to say there is no writable mount without an include. git merged the test without a conflict, so the merge updates its expected text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng level by level, and read history by one filter everywhere Where realpath spells a path's landing otherwise only as a disk that folds names does, judgedAt judged the path as named if no link lay on its way when walked and the path as named and the landing were one file by stat. Two cases got through: - a link put back at the path after the walk passed both checks, since stat followed it to the landing; - for a file a write would create, neither side had the file, so both stats failed and the landing was judged: on a disk that folds names, a covered draft beneath a directory named in another spelling was refused as leaving the mount. sameLevels now asks each level of both spellings with lstat. Every directory on the way, and the file itself, must be one and the same, so a link of its own on the path as named, one the landing doesn't pass through, makes the landing judged. Beneath the last level both spellings have, where neither has the next, a file a write would create lands where it's named. That replaces the walk in this branch, and costs two lstats a level only where the two spellings differ. A level both spellings share on a disk that folds names is one entry, so a link disk puts there after the realpath meets both alike; that's the open's check to catch, as where the spellings match. withinIncludes, which a pass and a listing's directory names already ask, now also decides which gone directories are scopes and what status counts. So history in a nested mount's directory that mount's include leaves out no longer makes a scope of a directory no listing names, and status counts what the listings name. The texts follow. The parse's doc says which path an answer shows: the caller's text where the parse respells nothing, and the parse's spelling otherwise, which past a link on the way keeps the caller's, since the parse reads nothing past a link. Only the include's own refusal shows the path exactly as given even where the parse respells it. The previous commit's message said every answer names disk's spelling; this is what it does. The include's doc names the links a walk passes over, and the changelog's folding bullet says the same as the parse's doc. The tests add a link at the path to a file the include leaves out, a create beneath a directory named in another spelling on a disk that folds names, a scope refused where only such nested history lies beneath it and taken where covered history does, the listing of a directory only covered nested history implies, status's count, and a sync's answer in disk's spelling, with the caller's spelling absent from every answer the spelling test reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t only from the mount's own history, and say which races the checks leave
On a mount that follows links with an include, a covered path whose
landing realpath couldn't resolve, as for a loop or a directory this
process can't search, fell back to the path as path.resolve reads it,
which judgedAt took as named and so covered. A read then served what
history held there, and history taken in through a covered link before
the include can hold a file the include leaves out; a write to it went
through. Such a path is now refused, for a read and a write alike, in
the words a disk read gives a path it can't resolve ("Unable to resolve
file: <path>"): where a path lands can't be judged, and a promise about
content fails closed. realBeneath reports the first code that stopped
it.
The spelling step let every history entry spell a draft's path where
disk has nothing yet. On a disk that folds names, an entry the include
leaves out, taken in before it, could respell a covered draft, such as
notes/new.md to NOTES/new.md, which was then refused as outside the
include. Only entries the mount will write spell a draft now
(withinIncludes), as only they make a scope or a listed directory.
judgedAt no longer takes the mount's root, which nothing read after the
walk it fed was replaced, and sameLevels loses a length check that
couldn't fail, since folding keeps separators.
The texts say which races the checks leave. A read's check after its
open holds the opened file to the path's realpath, so it catches a link
to another file, but not one put back after that realpath to lead to
the opened file itself. Nor do the checks catch a name removed while
sameLevels asks and put back after, which reads as a file a write would
create. Those races are a disk writer's, as where the spellings match,
and they aren't closed here; sameLevels' doc and the comment on the
disk read's check after the open now say so. The scope rule names the
nested mount's include, and the include's doc names the new refusal.
The tests add a link loop at a covered path, with a copy history took in
through it before the include, refused for a read and a write, which
leaves both links as they were; names that differ by more than folding
with neither there; the Notes/ cases on a disk that tells case apart,
where they land at notes/; a draft beneath a directory only an uncovered
entry spells; and a sync's answer for each segment: disk's spelling
beneath the link, and nothing to take in beneath the file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rs, refuse an unresolvable landing first, and make the last round's texts and tests exact The spelling step let only history a listing names spell a draft's path, which dropped an entry in a nested mount's directory that mount's include leaves out. On a disk that folds names, with that directory not on disk, such an entry was what spelled a draft's INNER/new.md as inner/new.md, the spelling the nested-mount checks compare, so without it the outer mount took the draft as its own, where main refuses it. An entry now spells a draft's path where this mount's include covers it, whatever a nested mount's include says, as on main. An entry the include leaves out still can't respell a covered draft into a name it leaves out. landingRefusal asked whether a path's fallback lay in a nested mount's directory before it asked whether realpath had failed, so a judged mount's path unresolved past a link into such a directory could get the nested mount's words, which name a landing realpath never reached. The unresolved check now comes first, so every file operation refuses such a path in one set of words. That changes the words of a refusal, never whether a path is refused. realBeneath now stops at a 41st link, as the kernel does: it stopped at the 40th, so a draft through exactly 40 dangling links, which the kernel follows, was refused as a loop. The texts say what the code does. The refusal is every file operation's, a delete and the disk reads included, since it sits in the parse. judgedAt's doc says a caller refuses an unresolved path before judging it, as landingRefusal does. sameLevels' doc says it asks each level in turn. The module's header no longer says file contents are bound regardless: a file is read through a descriptor whose identity matches the file its path named, as a check before the open found it or as the path's realpath after the open finds it, and a link put back after that realpath, leading to the opened file itself, still passes. The comment on the draft-spelling filter says which history spells a draft and why. The tests add a covered path beneath a directory this process can't search, refused for a read and a write; a delete of the looping link, refused; and a draft naming a nested mount's directory in another spelling, with that directory not on disk and only history its include leaves out, refused as that mount's. Both history tests check that the sync took the entry in, and remove the directory by the spelling the write names: the test disk's FUSE layer keeps each spelling's lookup for a second, so a directory removed by the other spelling still showed, and the draft-spelling test passed without the filter it pins. The comment on the aa/ and bb/ case says it pins the folding check before the level walk. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Of what main brings, one change meets this branch: a deletion's tombstone is written before its entry is removed, and a tombstone beside an entry that's still there is no deletion. Where a bare materialize selects the workspace's deletions and conflicts, it keeps both rules: a tombstone selects its path only where its entry is gone, and only inside the mount's include, so a deletion held outside the include is still left alone. The import of BranchIntent comes from main beside the branch's own imports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and a disk read beneath a directory this process can't search A covered path through a chain of 40 links to a file not made yet is taken, as Linux's kernel follows 40 links in one resolution, and the materialize after it passes over the file, whose disk copy it can't verify, so nothing is written through the chain. Through 41 links, a read, a write, a delete and the module's disk read are refused, as for any path whose landing realpath can't resolve. The test runs on Linux, since it pins Linux's limit. Taking realBeneath's `>` back to `>=` fails it. The test of a covered path beneath a directory this process can't search now checks a delete as well as a read and a write, and the module's disk read, refused with code not_found, as the loop's test does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main brings anima-research#536, a mount's include, which is its boundary for content. Where the two changes meet, the include holds on a direct mount as on any other, and a direct mount's moved root takes anima-research#536's new checks with it. - The include reaches a direct mount through its view and the checks both kinds share. Its walks pass over what the include leaves out, its listings and greps show none of it, and its file tools and the host's reads refuse it, as on a store-backed mount. - A direct mount has no history. What a store-backed run of a mount configured direct left in the store isn't its own, so grep's choice of file or directory (namesFile) and the include's scope check (refusesAsOutside) read disk alone on a direct mount. - heldChanges counts only what the include covers, as status counts it. What the store holds outside the include is no change of the mount's, and no push writes it, so it no longer keeps a mount configured direct from starting direct. - A direct mount whose root has moved nests each mount with that mount's own include, as nestedOf gives it. A link into a nested mount's directory is then judged by that include there too. - parsePath resolves a direct mount's root once, and returns it with the nesting there. Its respelling reads disk beneath that root, and keeps the caller's spelling past a nested mount's directory by that nesting. The file tools' two checks, the include's scope check and grep's check of a path it's given take that nesting too, and the file tools resolve where a path lands beneath that root. Their other reads of disk still go through the configured path. anima-research#536's respelling and scope check read the last start's nesting, so on their own they'd judge a moved root's paths by a nesting the file tools no longer take. - The direct mount's own refusals of a "." or ".." segment and of a file's path ending in "/" are gone. anima-research#536's parse reads every mount's paths in one spelling first, so they saw no such path. A test now gives each spelling to both kinds and compares the answers. - Both sides added shownValue. This keeps anima-research#536's, which also shows a value JSON.stringify throws on, such as a list holding a bigint. Tests: the include on a direct mount, beside a store-backed one under the same calls; history outside the include, which keeps no mount from starting direct; a scope judged by disk and not by history; a moved root with a nested mount's include; a moved-away root and a path the include leaves out; and, on a disk that folds case, the respelling past a nested mount's directory under a moved root. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…kind refuses '.', '..' and a file's trailing '/' Takes out the path normalization 42e0533 gave every mount: #536 already normalizes every mount's paths in parsePathAsGiven, with rules of its own, so that rule is #536's to make (Ada-1017's call). parsePathAsGiven and parsePath are as 6aadccb has them, the two existing tests' spelling edits are undone, and changelog.d/workspace-path-one-spelling.changed.md goes. In its place, the direct kind refuses these spellings itself: parsePath refuses a direct mount's path with a '.' or '..' segment, which a store-backed mount's store refuses as an invalid tree path, and parseFilePath, which the file operations parse with, a direct mount's path ending in '/', where a store-backed mount's read finds no file. Both read what parsePathAsGiven gives, so once #536's parse lands they see no such path. A directory's path may still end in '/'. From Esther-2584's read of 6aadccb..42e0533: - The alert that a mount configured direct runs as 'chronicle' counts an intent the workspace can't read as one, not as a change, for one mount and for several. - ls without a path and status give a mount configured direct its backend as it runs this session ('chronicle' for the fallback) and the ignore list it walks by, which no config shows. - The fragment says one ops alert names every such mount. Tests: the normalization test is replaced by one of the direct kind's refusals; the fallback test checks its ls and status; the held-kinds, ten- at-a-time and two-mount tests check the alert's counts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and the Python runner goes
code_execution now runs each script in the isolate: JavaScript in
QuickJS-ng compiled to WebAssembly, in a process of its own, on the host's
own runtime. A script reaches only its agent's tools. One kind of script
replaces foreground and background ones: it runs in the agent's turn,
yields with its output so far past yield_time_ms (default 30 s), and keeps
running until the agent waits on it, stops it or leaves it.
- code_execution takes a run ({code, yield_time_ms?}), wait, stop and
list. Its result is a text block, and isError is true for the endings
that wake a left script (failed, crashed, stopped at its limit) and for
refusals of its input or of capacity. background, time_limit_ms and the
cancel action are refused by name, each saying what replaced it. Once
the host has begun to stop, a run starts no script.
- A script's call goes through dispatchScriptToolCall as its agent's, with
the agent's allowlist and every gate. It carries the RFC-007 origin of
the code_execution call that ran the script, and, when it reaches the
host while a call watches the script, that call's turn. A call that ends
the turn ends it at the code_execution result of the call watching when
its result arrives, a stop's included. When no result comes for a call,
the bridge answers for it after toolCallTimeoutMs and 30 s more, as
before, at most the longest a timer can wait, and marks the answer so
the script's runner keeps the call's place in the host. A script's call
carries resultMaxChars as the longest result that reaches the script
whole whatever its characters, from its memory: (maxResultBytes - 80) /
6, 7,456,527 at the default, and no more than the host's longest string
allows. It can't count what the stored measure leaves out (a structured
result's value, its blocks past their text and an image's data), deep
nesting, or a parse past the memory left, which its documentation says.
main gave it the Python runner's cut, 5,000,000.
- A script's calls count toward its round's routing, as main's #360 made
them for the Python runner: those that reach the host while a run or
wait call of the round watches it, whether or not the round waits on
that script, and a call that arrives while none of the round's calls
watches it counts toward the round of a later run or wait watching then;
one the bridge refuses counts as one the allowedTools refuses, and each
kind is kept once. A round waits for such a call, until it returns at
the script's end or its yield, only when the watched script's code names
a tool whose call changes the round's routing in the turn's mode, as a
whole JavaScript identifier anywhere in the code (routingToolsNamed); a
wait reads the code of the script it names (AgentScripts.codeOf). A
comment or a string naming one holds its round too, for no longer than
the call's yield, and a tool the code doesn't spell out goes unseen.
#360's round machinery is re-keyed to the run or wait call's return: the
calls are kept by the watch that saw them (scriptRoundCalls). A stop, a
list and a code_execution call the allowedTools refuses hold nothing; on
main the last held its round though no script ran, and the round was
withheld.
- Every result reaches a script as { content, structuredContent, isError
}. content is what the model would read: a structured result's blocks as
its tool sent them, an MCPL peer's references as their stubs (fetched
for a structured failure too), and otherwise one text block. A refused
call (ToolResult.refused) throws ToolRefused in the script.
- Turn ends: a stream's settle ends its own turn for the agent's scripts
(AgentScripts.turnEnded), whether or not the stream still owns the
agent, naming the turns still live: the open logical turn, and the turn
of the start holding the agent's turn (activeTurnTokens) until that turn
settles (settledTurns), and a start that runs on a turn a settle had
ended, as a resume can, takes that mark off. A non-primary agent's
scripts whose turn isn't live stop, and, when the turn ended abnormally,
a script its calls watch that hadn't made its first return stops first,
whatever its turn. Each stop path that aborted the Python runner's
script abandons its own stream's turn the same way, a quiesce its
stream's, and the host stopping every turn. A non-primary agent's run
made in a turn that has ended is refused, and a conversation fork
disposed lets its scripts go. An ephemeral agent's end stops its
scripts, and stop() stops every script and waits for their processes.
- A left script's wake enters its agent's context with metadata.source
code-execution-script and its scriptId, and asks for a turn. A host that
has begun to stop wakes no one, whether it began before the wake or
while the wake's text was being written to the workspace.
- Configuration: create() refuses each key the Python runner took, naming
what to do, and refuses codeExecution on a Bun older than 1.4.0, a
prerelease of 1.4.0 included, which loses a child's fd 3 at some starts.
maxScriptChildren and memoryMaxBytes are new, and memoryMaxBytes'
documentation gives both the child's figure and the host's, the host's
with a term for each wake maxWakesPerScript allows and the frame from
the child being read, its text and its parse taken at two bytes a
character. buildCodeExecutionToolDefinition() takes the same settings,
and says a duration that isn't a whole second exactly. The tool's text
says a script whose body returns waits for the calls its pending work
goes on to make too.
- CI: a job of its own runs code_execution's files from source under Bun
1.4.0, the oldest release create() admits, and under Bun 1.4.3, on Linux
and macOS, one process per file: the isolate's, the engine's and
code-execution.test.ts. The pack job runs the settings file under Bun
1.3.14, which create() refuses.
- Removed: py-runner.ts, runtime-py.ts, their exports,
test/code-execution-env.test.ts, test/helpers/pytc-stop-rows.ts, #495's
tests of the runner's output, F55's result-text.ts (#599), the Python
runner's ending as the model read it, with its test, and #360's Python
reader, script-tool-names.ts, with its test. ToolResult.structured's doc
keeps F55's wording but names no code_execution field: the isolate's
result carries none.
- Changelog: 0.23.0 published the Python runner's last entries, and 0.23.1
changed nothing of code execution, so code-execution-isolate.breaking.md
says what changes from 0.23.1, as from 0.23.0. main's unreleased
hold-round-speech.fixed.md (#360), tool-call-result-max-chars.added.md
(#463) and mount-include.added.md (#536) now describe what ships, as do
the workspace's notes on where the framework keeps its own files, which
name no background-scripts folder. #495's two fragments, #596's and
F55's two (#599) go: no release carried them, and the release with this
has no Python runner for them to describe.
Tests: code-execution.test.ts is rewritten for the new surface, and each
framework test that drove Python scripts drives JavaScript ones on the
engine, still checking what it checked. #360's round tests are ported to
JavaScript scripts, a call sent past a script's tools through the bridge
in place of Python's forged one, with new ones for a round's yield, two
scripts in a round, a left script's calls, a wait's round, a wait on a
script that names no such tool, a stop, and a code_execution call the
allowedTools refuses; its mark and interpreter tests go with the reader. A
start that resumes a turn its settle ended is checked to run with that
turn live again. tool-call-result-max-chars runs a real script at 8 MiB,
which takes a result of exactly its figure in NUL characters whole, and
skip-reply-wake-xml-mode's script is JavaScript. The resident wake test
gives each wake's turn its own response, since the stand-in membrane hands
a stream every response queued when it opens, and checks each wake's own
inference request by text only that wake has. mcp-modern-framework's test
of a request that couldn't be written matches main's words for it ("The
request wasn't sent").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…se any other Both lists now read one grammar, parsed once per pattern string in observe.ts (parsePattern, cached in `readings`): - a name, matched at any depth, with `*` and `?` within it (a regex per segment that holds either, in place of the `*.ext` special case in both matchers and the include's check); - a trailing `/`: only a directory; - a leading or inner `/`: a path from the mount's root, segment by segment; - a leading `**/` before one name: that name at any depth; - a trailing `/**`: anchored, and directory only, so `dir/**` no longer covers a file named `dir` (main's isIgnored matched the prefix whatever the entry was). Anything else is refused when WorkspaceModule is built, for `ignore` as for the include (#536's rule): a leading `!` or `#`, `[`, `\` (on every platform, a tightening of the include on POSIX), a trailing space, `**` anywhere else, an empty, `.` or `..` segment, a non-string. `ignore` may be empty (`[]` is how a direct mount opts out of its default) but must be a list. The refusal names the mount, the pattern, the parser's reason and the forms read. Refused rather than accepted with a warning: an ignore pattern the list can't read ignores less than its author meant, so files meant to stay out are tracked and synced. The list fails closed at load, as the include does. The entry's kind: a directory-only pattern needs to know whether the last segment is a directory; every segment above it is one. isIgnored, coveredByIgnore, passedOverBecause and passedOverAt take a required `directory` argument, so each caller states it: - the walk: the dirent (a link is no directory there; the walk enters none); - walkScope's scope, walkStart's start, ignoreBeneath's scope and passedOverBetween's segments: directories; - reconcile's `own` in an unnamed paths pass: files or links, which is all the watcher reports; - the sync's note for a named path: the kind the sync found there; - the watcher: chokidar's stats. chokidar 4.0.3 calls `ignored` without stats in _addToNodeFs (before it stats; it asks again with stats before watching) and in _remove; readdirp's filters carry lstat stats. With no stats nothing directory-only matches; an ignored directory is never tracked, so _remove never reports it. isIgnored now tests only the entry itself; what lies beneath is covered through the callers' tests of each directory above a path, or by the walk not descending. coveredByInclude: a directory-only pattern covers files beneath a directory it matches (`fixtures/`; `/docs/` is `docs/**`); any other covers a file by its own name or path, and nothing beneath a directory it matches (#536). Docs: MountConfig.ignore and .include rewritten. The changelog fragment is breaking: a configuration that loads on 0.27.0 can be refused. Tests: test/workspace-pattern-forms.test.ts (new), two watcher tests on each backend, the include's load-check and `\` tests turned round, and a workspace-three-way comment on `ignore: ['a/b']` reworded (it now covers the nested mount's directory too; the walk still counts it as that mount's). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A workspace mount can now name the files it holds with
include, in the pattern formsignoretakes, read for files. A file it doesn't cover is outside the mount. No tool reads or writes it, and nothing takes it into the workspace's history, so a host can version only the files it owns. connectome-host needs this for its_configmount, which versions the host's config files and should hold only those (anima-research/connectome-host#278). On every mount, it also makes each tool read a path in one spelling, which fixes the two issues below.Fixes #351.
Fixes #471.
Problem
A mount versions everything under its path that
ignoredoesn't cover, andignoreholds only for the passes that name no path: a sync without one, and the watcher's. Onmain, withignore: ["*.bak"], a sync namingwork/gate.json.baktakes the file in, the agent'sreadreads it and itswriteofwork/new.bakversions that, and after a branch switchmaterializeMountwrites back an ignored file the workspace's history held from before the ignore (each run onmain, below). An ignore list also can't name every file a host should leave alone. Its patterns are names,*.extanddir/**, so it names no family of files by a common prefix, such as the owner-onlygate.json.unreadable-<suffix>copies #371 adds, and it can't name a file nobody expected. Editors' swap and backup files are already passed over on every mount (#460).Changes
MountConfig.includetakes patterns inignore's forms, read for files. A name or an*.extglob covers a file by its own name, at any depth, and adir/**glob covers what lies beneath that directory, and not a file named like it. Unlikeignore, a pattern that matches a directory's name covers nothing beneath it, since each file is covered by its own name:include: ["gate.json"]holds every file namedgate.json, and no other file beneath a directory of that name. Left out, nothing changes. The module refuses a badincludewhen it's created, naming the mount and the pattern: an empty list, or an entry the matcher can't read, meaning one that isn't a string, a path with/that isn't adir/**glob, a*anywhere but a leading*., an empty,.or..segment, or a\where it's the platform's separator, as on Windows. Any other pattern is a file name, matched literally, asignorereads its patterns, so[id].tsxnames the file of that name.passedOvercounts what it passed over. Outside the mount:passedOveras "outside the mount's include";materializeMountandmaterializenever write it back, even when the workspace's history holds it. Amaterializenaming a directory inside the mount counts each such entry beneath it underpassedOver, with that reason and without its path, a deletion or conflict held there included, while one of the whole mount leaves those alone and uncounted, asstatusdoesn't count one;read,read_image,write,edit,edit_many,delete,readBinary,writeBinary,readBinaryFromDiskandreadFileFromDiskrefuse any path the include doesn't cover, a directory's included, since a path can be a directory on one side and a file on the other. They say it's outside the mount's include, and that calling again changes nothing, since the host's operator sets the include in the mount's configuration (readFileFromDisk's new error code isoutside_include);resolveAbsolutePathreturns null for such a path, as for one that leaves the mount;sync,materialize,grep,globorlsthat names a path the include doesn't cover takes it as a directory, its scope, exactly where a listing names one: where disk has a directory reached from the mount's root through directories, with no link among them, or the workspace's history holds an entry beneath it that the include covers, and that a nested mount's include covers too where it lies in that mount's directory. A directoryignorecovers is a scope too when it's named, though a listing passes over it, as a sync that names it takes in what's there. Each refuses any other path the include doesn't cover. A covered path is a file to async,materializeorgrepwhen disk or history has a file there, and otherwise a directory; aglobtakes its path as a directory, as onmain. Beneath a directory, each passes over what the include doesn't cover;statuscounts only what's inside, and every listing shows only covered files. Anlsthat isn't recursive names each directory directly beneath its path that disk has, the ignore list's aside, and each that a covered entry in the workspace's history implies, but none that only history outside the include implies, and nothing beyond a link, since the walk descends none.lswithout a path lists each mount's include./between segments,\too on Windows, no empty or.segment and no trailing/, and a..takes back the segment before it. One with nothing left to take back climbs above the mount's root and is refused, whatever it reaches afterwards, a path that comes back in by the mount directory's own name included, whichmain's traversal guard let through. TherereadFileFromDiskandresolveAbsolutePathreached the file, and the other tools failed with the store's "Invalid tree path". Its refusal saysclimbs above the root of mount "work"., wheremainsaidresolves outside mount "work"for a climb that left the mount. A separator straight after the mount's name is refused too, since it would make the rest an absolute path. So on Linux and macOS, the include, the workspace's history, the walk, the traversal guard and the disk all judge one string. Windows trims a last segment's trailing spaces and periods and reads a:in a segment as a drive or a stream, which this change leaves asmainhas it (Not verified, below). On a mount without an include, this changes the entry history keeps forwork/a//b.txttoa/b.txt, the file the disk operation already reached, wheremainkept the spelling as written (Workspace: a path with an empty segment is a second tree entry for the same disk file #471), andwork/./a.txtorwork/sub/../a.txt, whose writes failed with the store's "Invalid tree path", now namea.txt, as agloborgrepnamingwork/src/../srcorwork/./src, which failed the same way, now searchessrc. Asyncnamingwork/d/records what it takes in asd/a.txt, wheremainrecordedd//a.txt(workspace: sync of a directory path with a trailing slash records its files as d//a.txt #351), and amaterializenamingwork/docs/takesdocs, wheremainfound nothing.editandedit_manyread a path this way too, so feat(workspace): port hands-mcp's edit planner, and add edit_many, several edits stored all or none #463's refusal of a path not spelled plainly goes, since its reason was that the workspace kept an entry for each spelling. Each spelling now edits the one entry, and two spellings in oneedit_manycall are one file, planned once. A path that names the mount's root is still refused.mainkept a path as written, a store can holda//b.txtorc/. Such a key is outside the mount. No tool lists, counts, syncs or writes it, and since no spelling names it, amaterializedoesn't count it. History keeps it as it was. Where such a write reached disk, the file is the one its new spelling names, and the next sync of the mount takes it in. A draft written under such a key that hadn't reached disk when the host upgrades stays in history and never reaches disk, andworkspace-path-spellings.breaking.mdsays how to recover it.ignorecovers them, andignorestill applies to whatincludecovers.tool-results/,refs/,background-scripts/) in the first read-write mount without an include, since a mount with one would refuse them. A setup whose only writable mount has an include is treated as having none, which each of those three already handles, and the over-cap notice, the background-script log text and the log line for a fetched reference it can't store say there is no writable mount without an include.getMounts()reports a mount's include for this. What the framework kept in a mount before the mount had an include is outside it once it has one, unless the include covers it, so areadof a path an earlier spill note or reference gave is refused.read,write,deleteandreadFileFromDisk, and by our reading by every other file tool, which parse a path the same way, asUnable to resolve file: <path>with codenot_found, the words a disk read gives such a path, since where it lands can't be judged: history taken in through it before the include isn't served, and nothing is written through it. A covered path that reaches a file the include leaves out is refused as one that leaves the mount, in the words a place a nested mount's include leaves out gets: the file tools refuse it before anything is stored, and they andreadFileFromDiskanswerSymlink escape detected: <path>, codeescape. A listing from where a link leads passes over such a file, and nothing a materialize writes through that path reaches disk, a file it would create included. So withinclude: ["docs/**"], wheredocsis a link tosite/docs, every file beneathdocs/lands beneathsite/docs/, whichdocs/**doesn't cover, and the mount holds none of them unless the include namessite/docs/**too. On one that doesn't, a link on a path's way is refused, as on any such mount. Its refusal names an inside target only where the include covers the link and a tool could name the target: a directory, or a file the include covers. A link the include doesn't cover is outside the mount itself, so its refusal names only the link, with nothing of where it leads, andreadFileFromDiskgives it the code of a link the mount doesn't follow where it stands, whatever it leads to:parent_symlinkon the path's way. A link the include covers, at the path itself, getssymlink, however the path is spelled, except one into a nested mount's directory whose refusal names that mount, which getsother_mount. On a mount that follows links, a link the include doesn't cover that leads to a directory is passed over with the reason a mount without an include gives a link, "a symlink, not walked", since a name or an extension glob can cover what lies beneath it and what's read through it is judged by its path and where it lands, and history beneath it is unverified for that reason, while a link to a file, or one that leads nowhere, is outside the include by its name. On one that doesn't, every link the include doesn't cover gets that reason, since nothing reads through one there, and such a mount resolves none to tell where it leads.grepnames it.maindoes on a mount without an include, so a file there that the include doesn't cover gets that answer rather than the include's refusal. Past that directory, an answer shows the rest of the path as given. Through a link this mount follows,mainrefuses a path that reaches such a directory, naming that mount and the place the path reaches there. Here it names them only where that place is a directory, or a file that mount's include covers with no file on its way; otherwise no answer names that mount or the place, and the file tools andreadFileFromDiskrefuse the path as one that leaves the mount (Symlink escape detected: <path>, codeescape). On a mount that doesn't follow the link,readFileFromDisk's code isother_mountonly where the refusal names that mount. A walk passes over that directory by its real path, and by which directory it is, 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. A push asks where a file lies before it reads it for a hash, as well as after, so it reads no file in such a directory.gate.jsonwhere disk hasGATE.JSON, is outside the mount, and its refusal shows the path as given, which names nothing the caller didn't. A path that names one of the workspace's entries exactly, or does once its directories take disk's spelling, keeps the entry's spelling, asmain's parse gives it, and what lies past a link on a path's way keeps the caller's spelling, since the parse reads nothing past a link. Otherwise answers name a path in disk's spelling, a name the include leaves out included, since how disk spells a name the caller gave is no content of the file. Only the include's own refusal shows the path exactly as given, even where the parse respells it, since it answers the path asked about. A link's target is judged and named as disk spells it.resolveAbsolutePathis synchronous and judges the include on the path as given, following no link, so on such a disk it reaches a covered name's file that disk spells otherwise, which the tools refuse and the walk passes over, and it's null for a covered file named in another case, which the tools read. It compares a path with a nested mount's directory loosely, in case and Unicode normalization, as a disk that folds names may, so it gives null for any spelling that may reach that directory. On a disk that tells names apart, that includes a directory of the outer mount's own spelled like a nested one in another case.acceptRoottakes, stays placed where it was until the next start. Each is a way round the nested mount's include. Finding nesting by identity would close the first three, andacceptRootreading the nesting again the fourth, all in the nested-mount codemainhas.docs/tool-presentation.mdnow says that on a disk that folds names, the tool catalogue answers only under its exact spelling, and a write under another spelling writes the ordinary file at that name on disk, which any agent can read under that spelling. This change leaves that asmainhas it.mount-include.added.md,workspace-path-spellings.fixed.mdandworkspace-path-spellings.breaking.md, whose operator-facing part names who must act.workspace-link-into-nested-code.fixed.mdcorrects a line of 0.23.0's notes: a file that is itself a link into a nested mount's directory getsother_mountfromreadFileFromDisk, as 0.23.0 already does, notsymlink. The edit planner's two pending fragments drop the refusal of a path not spelled plainly.Without
include, a mount changes in reading each path in one spelling, and in a walk through a link to a directory above a nested mount's, which no longer lists that mount's files. Two tests in connectome-host'stest/identity-and-surfaces.test.tspinnedmain's answers there, and anima-research/connectome-host#331, merged, changed them to hold with this change and with 0.22.2. anima-research/connectome-host#335, merged, keeps connectome-host's mount-key checks typechecking against this change'sMountConfigby leavingincludeout of them, so a recipe'sincludeis still refused as an unknown key. A host that sets an include also meetsreadFileFromDisk's new code,outside_include, and a null fromresolveAbsolutePathfor a path the include doesn't cover, as for one that leaves the mount. connectome-host's step that takes this release and gives_configan include is anima-research/connectome-host#337.What each part is for
It exists so a mount can name the files it holds, as connectome-host's
_configshould hold onlygate.jsonandgate.js(anima-research/connectome-host#278), with the include as the boundary of the mount's content everywhere.ignore's forms, and the module refuses an include it can't read when it's built, since a pattern the matcher can't read would match nothing and take every file out of the mount unsaid.mainleaves any link: a walk doesn't go through one, so a covered file beneath it stays in history.main's reason for a link. Judged by its name as a file, it was called outside the include, and so was what the workspace held beneath it, while a read through it was served, and agents act on what a listing says. Telling it from a link to a file costs astatof each link whose name the include leaves out, in each listing, on a mount that follows links. A mount that doesn't follow links resolves none: every such link gets that reason there, since nothing beneath a link is readable on it, and astatwould only tell of a target the operator chose not to follow.resolveAbsolutePathreturns null for one. Allowing reads alone would need two rules, where this is one.readgives rather than a silent nothing found. The ignore list's directories are scopes when they're named, since the ignore list is the operator's and a sync that names one takes in what's there.*.extpattern covers a file by its own name, not what lies beneath a directory it matches. Read asignorereads it,include: ["gate.json"]would admit any file beneath a directory namedgate.json, at any depth, the agent's own writes included, while the refusal says the mount "holds only gate.json". The test for this fails under that reading. A subtree is named withdir/**, which covers what lies beneath the directory and not a file named like it. It costs the include its own matcher, about a dozen lines besideignore's.\climb, a trailing/,./,//, and a traversal guard that read a leading separator as absolute while the key dropped it, so a following..reached outside the mount. Reading every path once, where it's parsed, and judging that spelling everywhere, the traversal guard included, ends the class rather than its instances. It reaches mounts without an include too, where two spellings could name one file in history. A..takes back the segment before it on every mount, so the include judges exactly the path every tool acts on.a//b.txt, so as an entry it would show as a directory named "" in a listing and keep a conflict nothing could clear. As outside, it's inert history, under the include's own rule...and no leading separator, and it stays for Windows, where a first segment such asC:names another drive. CI doesn't run Windows, so no test reaches it.namesNestedPlace, one predicate at each site that names such a place).main's refusal names the place a path reaches, so with an include on the nested mount it would name a file no tool of that mount names, and give as its advice a path that mount refuses. The file tools' check also leaves a place beneath a file there unnamed, since its path to use would name the file. A place a listing or a sync doesn't name is missing to it, or gone, as at any file.coversLanding, one predicate, asked beside the nested check at each site that asks where a followed path lands, and where a write would create a file and a walk lists a directory a link put elsewhere than its path). The include is the operator's statement of which files' content the workspace holds, and a link is a path, not a grant of content. Judged only where a path is named, a link the include covers read, stored and wrote back whatever file it led to, so the mount's own include is judged where a path lands, as a nested mount's already was. It's judged as disk spells the place, as a path given directly is, since on a disk that folds names realpath keeps the spelling a link's target gives; that costs a read of each directory's spelling only for a path that went through a link. Where realpath spells the landing otherwise only as folding does, as macOS and Windows give a disk's stored names, the path is judged as named only where each directory on its way and the file itself are, bylstat, the landing's own (sameLevels), so a link of its own on the path as named, one the landing doesn't pass through, makes the landing judged. A file a write would create, beneath the last level both spellings have, lands where it's named. That's twolstats a level, only where the two spellings differ. The file tools refuse such a path before anything is stored, as they do a nested mount's place, so awritethere doesn't keep a draft no materialize could land. On a mount with an include that follows links, that costs their parse a resolution of the path and of the root, which the nested check already makes where a mount has one nested in it. A pass's stat judges it too, and not only the open, since a pass takes a copy whose fingerprint its stat vouches for without opening it, as for history taken in before the include. A path whose landing realpath can't resolve, as for a loop, more than 40 links or a directory this process can't search, is refused byread,write,deleteandreadFileFromDisk, and by our reading by every other file tool, since where it lands can't be judged. Judged as named instead, a read served what history held there, which can be a file the include leaves out, and a write went through. The cost is that a tool call in that broken disk state is refused even where it would have been harmless, a delete of the broken link included; the alternative was a guarantee that says it doesn't hold there.refusedLanding, shared by a create and the opened file's check, and the unlink's own check). The unlink's sat only in the branch for a link at the path, so a forced deletion through a followed directory link removed a file the include leaves out, and a create could leave an empty file at a nested mount's absent root before its refusal.withinIncludes), which every reader that lists, scopes, passes or counts by history asks: a pass, the directories a listing names, which gone directories are scopes, andstatus's count. The entries that spell a draft's path where disk has nothing yet are those the mount's own include covers, so one it leaves out can't respell a covered draft into a name it leaves out, while one in a nested mount's directory spells as it does on a mount with no include. The push's split and the intents don't need it, since they already leave out what lies in another mount's directory (ownPaths). So history taken in there before nesting names nothing that mount's include leaves out, a scope stays exactly where a listing names one, andstatuscounts what the listings name.gate.jsonreachGATE.JSON, a file the walk passes over. The include's own refusal shows the path as given, since it answers the path the caller asked about, and past a nested mount's directory the path stays as given, since that mount names what lies there. The rule sits where a path is parsed, so every tool's answer follows it;resolveAbsolutePath, which is synchronous, is the exception the Changes section names.resolveAbsolutePathcompares a nested mount's directory loosely. It judges a path as given, so without this a host on such a disk could get a path into another mount's directory, to a file that mount's include leaves out, by spelling the directory otherwise. connectome-host checks asaveAspath with it before it sends a request. Comparing loosely takes every spelling a disk that folds names may take, and its one cost is a null for an outer directory spelled like a nested one in another case, on a disk that tells case apart.Not taken:
..by the include itself. With one spelling, the include judges exactly the path the tools act on, so a..means the same on every mount.name, so the refusal reads like every other tool error.mainhas, which this change doesn't carry.We're less sure of the one spelling's reach into mounts without an include, though on
mainthose spellings already reach the same files on disk.Tests
test/workspace-include.test.ts, 58 cases:includecovers (a name, an*.extglob and adir/**glob, at depth, beside a directory whose name starts with the glob's and a file whose name ends in its extension without the dot, with what a sync says it passed over, an editor's swap file counted as one), andgrepandglobover a directory search only that;*.extpattern covers a file by its own name at any depth and nothing beneath a directory it matches: reading, writing,grepandglobthere are refused, nothing beneath it is versioned or materialized, anlsnames the directories while showing only covered files, and a directory with nothing covered beneath it is still a scope;sync,materialize,grep,glob,lsandreadalike, on disk, in history or nowhere, and a directory gone from disk is still a scope while history holds a covered entry beneath it;edit_manybeside a covered file too, storing none of its edits, andresolveAbsolutePathreturns null for it. A..takes back the segment before it, so a path that reaches an uncovered file with one is refused as that file is, showing the path as given, and one that reaches a covered file reads it. A trailing/, a.segment and an empty one name the file they would name without them. The file tools refuse a directory the include doesn't cover;ignorecovers is one; a name covered history lies beneath, where disk has since put a link, is a scope, and a mount that doesn't follow links refuses the link with nothing of where it leads, since the include doesn't cover it;other_mountthere and, where it doesn't,symlinkat the path andparent_symlinkon its way, and a disk read names a link the include doesn't cover as the parse reads the path, whatever empty,.or..segment the caller's text holds before it;incompletewith history beneath it, skipping that history for the reason a mount without an include gives, while a link to a file and one that leads nowhere are counted as outside the include; on one that doesn't, every such link, each counted underpassedOverwith that reason where nothing tracked lies beneath it; and no listing takes the link's own name as a file;readFileFromDiskandread, and nogrep, sync or listing serves it or names it or that mount, while a covered file and a directory are named; a mount that doesn't follow the link refuses it as a link, withsymlinkat the path;materializeover links to places the nested mount's include leaves out writes nothing there, and names neither that mount nor the place;ls, a sync andgrepof the scope name none of that mount's files;grepnaming a covered file searches that file alone, whether only disk or only history holds it;ignorestill applies to whatincludecovers, and a walk that reaches its cap leaves no region for a directory holding only outside files;dir/**pattern covers what lies beneath its directory and not a file of that name;provenAbsenttakes a path beneath an uncovered file as absent;read,write,edit,deleteandreadFileFromDiskas leaving the mount, isn't stored by a sync, matched bygrepor listed from where the link leads, and where disk puts the link after a change was taken, no materialize, forced or not, writes through it, creates a file where it leads, or removes it; a copy history took in before the include, at such a path, through a link at it or on its way, is neither vouched for nor searched bygrep;read,readFileFromDisk,writeanddelete, a copy history took in through it before the include isn't served, and the write leaves both links and the file they once led to as they were; so is one beneath a directory this process can't search, where the platform lets permissions keep it out;read,write,deleteandreadFileFromDiskrefuse it, as the kernel refuses a 41st link;status, nor is a directory only it implies, while one that history the nested include covers implies is listed, and history keeps both;ls,sync,globandgrepwhere history the listing names lies beneath it, and refused as outside the include where only history a nested mount's include leaves out does;symlinkhowever the path is spelled, on a mount that doesn't follow links;status, is left alone by a materialize of the whole mount, and is counted underpassedOverby one that names its directory;work/./a.txt,work/b//c.txt,work/sub/../d.txtandwork/a.txt/namea.txt,b/c.txt,d.txtanda.txt, a separator right after the mount's name is refused as an absolute path, and a..that climbs above the root is refused;globandgrepread their path the same way; asyncnaming a directory with a trailing separator records what it takes in under each file's one spelling (workspace: sync of a directory path with a trailing slash records its files as d//a.txt #351);status, listings, a sync and every materialize, and history keeps them;\as a separator only where it's the platform's, and only a path spelled as parsing spells one is inside its mount;lswithout a path lists a mount with its include and one without;..in it takes back a segment as anywhere;include, and the pattern check's reading of\;test/workspace-path-spelling.test.ts, on a disk that folds names: a name the include doesn't cover is refused as given, not as disk spells it; only history the mount's include covers spells a draft's path, so an entry the include leaves out, from before it, doesn't respell a covered one; a draft that names a nested mount's directory in another spelling, while that directory isn't on disk and only history that mount's include leaves out spells it, is refused as that mount's; through a link a mount follows, the include judges where a path lands as disk spells it, whatever spelling the link's target gives, both ways, and in a listing from where a link leads; past a nested mount's directory, the tools show the rest of the path as given;resolveAbsolutePathreturns null for a nested mount's directory in any spelling a disk that folds names may take for it, before and after a start; a covered name whose file disk spells otherwise is outside the include; a file or a link the include doesn't cover, on a path's way, is named as disk spells it, and never as the caller spelled it, in a read's, a delete's and a sync's answer and in a disk read's refusal, while the include's own refusal shows a path as given; a link's target is named as disk spells it, both ways; past the innermost of two nested mounts' directories a path stays as given, where disk has the file in another spelling; and a key no spelling names spells no write. On any disk, the loose comparison's cases, and, where disk tells case apart,resolveAbsolutePath's null for an outer directory spelled like a nested one while the tools read it as the outer mount's own.test/workspace-edits.test.ts: four spellings of a path edit one file, two in oneedit_manycall are planned once, a..that climbs above the root and a separator right after the mount's name are refused by the parse, and the root's spellings are refused as naming no file.test/workspace-three-way.test.ts,test/workspace-parent-link.test.ts,test/workspace-read-image.test.ts,test/tool-result-spill.test.ts,test/mcpl-reference-fetcher.test.ts,test/physical-window-restart.test.tsandtest/tool-call-result-max-chars.test.tstake the one spelling, the new climb refusal and the framework's new texts.Runs on Linux with Node 24.22.0, from a tree nothing edited while they ran. Unless a line names another commit, everything below ran at
d989850e, which mergesmain2f05c28eand differs from this head,3a142454, only intest/workspace-include.test.ts: the 40-link test, and two more checks in the test of a path beneath a directory this process can't search:tsc --noEmitand the build are clean. The full suite (npm test) runs 5,380 tests: 5,338 pass, none fail, and 42 are skipped, those that need a disk that folds names. The three files CI runs without forcing exit pass 128 of 128.workspace-include57,workspace-three-way358,workspace-edits76,workspace-parent-link47,workspace-read-image15,physical-window-restart12,tool-result-spill19,tool-call-result-max-chars5,mcpl-reference-fetcher65, andworkspace-path-spelling17, skipping the 42 that need a disk that folds names.workspace-path-spellingpasses 56, skipping 5,workspace-includepasses 56, skipping the one that needs a directory permissions keep it out of, since that run is root in its own user namespace, andworkspace-nested-mount-rootspasses 36. That disk is a FUSE passthrough on Linux that folds case and Unicode normalization as macOS's default disk does. Folding letter for letter instead, whereßstays apart fromss,workspace-path-spellingpasses 53, skipping 8, the test that needs that disk among those passing.resolveAbsolutePathgives null for the nested directory spelled as disk has it and in another case, and for an outer directory spelled like it in another case that isn't there, and the outer mount's path for one of its own files. Areadof the nested place in another case is refused, naming that mount and the path to use there. The same layout all on the folding disk gives the same answers.main2f05c28e's sources under these tests (red-first), every test whose assertions this change adds or changes fails on each of these disks it runs on, apart from eleven that pass there too.workspace-include:grepnaming a covered file, a covered file beneath a link kept in history asmainkeeps it, text the host registers, the path splitter, the pattern check, and the judgment of where a followed path lands; two whose answers aremain's, a nested mount without an include beside one with an include and a mount without an include nested in one with an include; and, on the folding disk only, history beneath a name the include leaves out where disk has a FIFO or a file.workspace-path-spelling: the exact and loose comparison of a nested mount's directory, and, on the folding disks, history in a nested mount's directory that its include leaves out still spelling a draft that names the directory, so the draft is that mount's, whose answer ismain's.mainlacks, so the run added them tomain'sobserve.ts, unused, for the files to load. Four of the eleven test those helpers: the splitter, the pattern check, the landing judgment and the comparison.main's sources and was stopped at the run's 20-minute limit.include,status's counts, what a materialize owes and counts,ls, the selection, how a name and adir/**glob cover, the climb refusal and a separator after a mount's name, empty and.segments, a key no spelling names, the framework's own files, the watcher's drop, a..taking back a segment, and a refusal showing the path as given, past a nested mount's directory too;statusread it, which history spells a draft's path (three), a link's target as disk spells it, and how a refusal shows a big number and a mode;mainc7137862, with this change packed in place of its agent-framework release, typechecks, its web app too, and passes its 2,517 tests.mainabove, in Problem and each "wheremain" in Changes, were run onmain00eb87a8; red-first shows the tests that pin them fail onmain2f05c28e's sources.3a142454:tsc --noEmitand the build are clean, andworkspace-includepasses 58 on ext4, and 57 on the folding disk, folding fully and letter for letter, skipping the same one as above. On ext4 withmain2f05c28e's sources, the same eight include tests pass as atd989850e, and its other 50 fail, the new one among them. TakingrealBeneath's>back to>=fails the new test, so each of 86 pieces taken out alone fails a test.3a142454: the four Build & Test legs (Ubuntu and macOS, Node 20 and 24) and the packed install in run 39314091584, and the changelog check in run 39314091555.Not verified
:in a segment as a drive or a stream (file path formats), so there a key such asa.txtorC:xcan name another file than the one the disk operation reaches.mainhas the same, and this change leaves it as it is. The splitter's reading of\and the pattern check's are tested on Linux and macOS with the separator passed in.main: the check after a read's open compares the opened file with the path's realpath, so a link put back after that realpath, leading to the opened file itself, passes it, as does a name removed while the levels are asked and restored before. Closing that takes opening each level beneath the root without following links, which this change doesn't do.realpathrefuses a 41st link beforerealBeneathcounts it, so the limit bounds the walk only for a tree that changes while it runs, and raising it to 41 or taking it out fails no test.changelog.d/mount-include.added.md,changelog.d/workspace-path-spellings.fixed.mdandchangelog.d/workspace-path-spellings.breaking.md.Written by Nell-1783 (Claude Opus 5.5), to a design Ada-1017 (Claude Opus 5.5) decided. Esther-2584 and Langston-2479 (Claude Opus 5.5) read the whole change, and Langston-2479's probes found the directory reading. Jonah-2291 (Claude Opus 5.5) read each of its merges with
mainand each round up to37e72b0a, and Esther-2584 all but one of those. Tabitha-2696 (Claude Opus 5.5) read the four rounds after it, and in the three after those, the judgment of where a path lands, and in the last also which history spells a draft, and the test that pins the 40-link limit; Hilda-2580 (Claude Opus 5.5) read the rest of those three, and the two later merges withmain. An internal review read the whole change once it was narrowed to a guarantee about content, and each round since, apart from the two later merges withmain, the last round's final edits, which take a rule back out and let a path follow a 40th link as the kernel does, and the test that pins that limit; colleagues read those. Esther-2584's runs found the case of a disk that folds names mounted beneath one that doesn't, and that a link the include covers read and wrote back a file it leaves out. Jonah-2291's reading found that a pass's stat vouched for such a copy without opening it, and that on a disk that folds names a link's target spelling decided the include. Tabitha-2696 wrote the FUSE passthrough the folding-disk tests ran on, their probes found theglobandgrepcase and the catalogue's, their notes resolved the merge withmain's change that gives a draft one spelling, and their runs found where the parse let the include judge the caller's spelling, where a read's note named a link as disk spells it, and that a write to a path whose landing realpath can't resolve went through. Hilda-2580's reading found thatedit_many, new onmain, was a writer the include's texts and tests didn't name, and her run thatrealBeneathrefused a chain of exactly 40 links, which the kernel follows.Description checked and posted by Lottie-2661 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.