Skip to content

fix(workspace): take a path as disk spells it on a disk that folds names - #525

Merged
nissa-seru merged 7 commits into
anima-research:mainfrom
nissa-seru:fix/181-paths-take-disk-spelling
Oct 18, 2026
Merged

nissa-seru merged 7 commits into
anima-research:mainfrom
nissa-seru:fix/181-paths-take-disk-spelling

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

On a disk that folds names, as macOS's does by default, the workspace's tools now take a path as disk spells it. A path spelled otherwise, such as work/SRC where disk has src/, no longer takes disk's files in a second time under that spelling, announced as created and then as deleted. Each segment that reaches an entry under another spelling takes the entry's own name, and answers name files as disk lists them. On a disk that tells spellings apart, every answer is as before.

Problem

A disk that folds names (macOS's default folds case and Unicode normalization) lets a path spelled otherwise than disk spells it reach disk's file: work/SRC/a.ts reaches src/a.ts. The workspace named what it found from the spelling it was given. Probed on main d1b9d35c, on Linux with Node 24.22.0, with a mount on a FUSE filesystem that folds case and preserves it, holding src/a.ts:

  • glob {pattern: "*.ts", path: "work/SRC"} returned work/SRC/a.ts and announced created work/SRC/a.ts. The next listing of the whole mount announced deleted work/SRC/a.ts. grep with that path took it in again.
  • read work/SRC/a.ts succeeded under that spelling.
  • write work/SRC/b.ts put the file in src/ on disk and held it as SRC/b.ts. The next listing announced deleted work/SRC/a.ts work/SRC/b.ts and created work/src/b.ts.
  • A pattern spelled SRC/*.ts found nothing, with or without a path, since, by our reading, fix(workspace): glob and grep start where their pattern does #511 follows a pattern's literal segments only where disk lists them in that spelling.

On a disk that tells case apart, the same calls found nothing, read was refused, and the write made a separate SRC/, as they should.

Changes

  • diskSpelling (src/modules/workspace/observe.ts) takes a mount-relative path as disk spells it, from the mount's root down. It lists a segment's directory only where disk folds that segment: where its case flipped letter for letter, or its other Unicode normalization form, finds the same file (device and inode) as the segment's own entry. For the last segment, which it looks up as given only when the listing doesn't list it, the variant need only exist. Where it lists the directory, the name as given wins if the listing has it. Otherwise the segment takes the listed name that folds to it and is the same file, comparing names as NFC in lower case or, where none of those is the file, through upper case and back. A segment disk doesn't have keeps the spelling given. The walk goes on only beneath a directory, not a symlink, passes over an empty or . segment, goes back up at .., and stops at a .. at the mount's root. An absolute path keeps its spelling.
  • parsePath, where each tool's handler, and readBinary, readBinaryFromDisk, readFileFromDisk and writeBinary, find their mount, awaits that step and returns the path so spelled. The tools that answer with a path answer with it: read work/SRC/a.ts answers with path: "work/src/a.ts". A handler that must read something at the moment it's asked, as sync reads the branch it adopts on, reads it before it parses.
  • A nested mount's directory is known in its configured spelling and, where disk spells it otherwise, in disk's, which spellNestedDirs finds with the same step at each start. The nested-mount checks (holderOf, othersOf) take either. So read, read_image, write, edit and delete, and the public readers and writer, refuse a path into the directory spelled in another case or normalization form as that mount's, and an outer mount's walks pass over it in disk's spelling. This fixes the case-variant half of workspace: a copy held under an alias of a nested mount, or a case variant of its key, reads back with no disk check #324; its alias half is unchanged.
  • resolveAbsolutePath stays synchronous and takes its path as given, which reaches the same file. Its nested-mount check matches the directory as configured or as disk spells it.
  • nestedOf, which fix(workspace): a mount that doesn't follow symlinks follows none on a path's way #468 added and which reads otherMounts, takes every spelling of a nested mount's directory, as othersOf does, so a link into the nested directory reads as that mount's (other_mount) in either spelling. This PR merges main c7ad41d4 for it.
  • start reads the nested mounts' spellings before it sets anything, and sets nothing if a stop began meanwhile. stop waits for the tool calls and the readBinary and writeBinary calls under way, as well as for the mounts' turns.
  • Changelog fragment workspace-path-disk-spelling.fixed.md.

What each part is for

  • Respelling where the mount is found. Every tool and public reader parses its path there, so one step serves them all, and a tool added later that parses its path gets it. Respelling where the tool call arrives instead would put an await before every handler, so a sync would read its branch after a branch switch it was asked before (a test pins that), and a public reader would still need its own call. Without the step, each spelling of a path is its own file in the workspace while it's one on disk.
  • Listing only where disk folds the segment. Disk's listing is the one place its spelling can be read on every platform, but a step that listed each directory took 7.0 ms (median) on ext4 for a file in a directory of 10,000, where main's whole read of that file took 0.018 ms. So the step lists a directory only where a variant of the segment, its case flipped or its other normalization form, reaches the same file, or, for a path's last segment, exists at all. On a disk that tells spellings apart, that's only where a name has a sibling spelled as one of its variants. On Linux, Node's fs.promises.realpath, fs.realpathSync and fs.realpathSync.native each return a path as it's spelled, even on a filesystem that folds names (the FUSE one under Tests), so realpath can't give disk's spelling on every platform.
  • Flipping a letter only where its other case is one letter that maps back to it. The upper case of ß is SS, which a disk whose case folding is simple doesn't fold to ß, and the upper case of dotless ı is I, which lowers to i. A variant with either kept as it is still reaches the file, whether the disk's folding is simple or full.
  • Comparing listed names more loosely only where the first comparison finds nothing. A disk that folds fully folds ß with ss, ſ with s, and ς with σ, all of which lower case keeps apart. Comparing through upper case and back folds them too. The same-file check still decides, and the second comparison runs only where the first finds no listed name that is the file, so a name the first settles costs what it did.
  • The name as given, if listed, before any folded match. On a disk that tells case apart, two hard links that differ only in case are the same file, and each keeps its own name.
  • The same-file check on a folded match. A listed name is taken only if it's the file the segment reaches, so a disk whose folding differs from the step's comparisons can't make it name another file. No test fails without it (Tests), since on the disks the tests run on, two listed names that fold together are one file.
  • Looking the last segment up as given only when the listing doesn't list it. The last segment needs no type, since nothing lies beneath it. A test that swaps a file for a directory at the walk's first look at a path depends on the step not looking first.
  • Stopping at a symlink, and at a .. at the mount's root. Beneath a symlink, the step would list a directory the mount's own checks haven't reached, possibly outside the mount. A .. at the root climbs above it in a path that comes back inside, as ../proj/x does for a root named proj, and walking it would list the directory above the root. The rest of the path keeps its spelling, as on main. Any other .. goes back up to the directory the walk came from, as the path resolves, and an empty or . segment names the directory already reached, so the walk passes over it.
  • Keeping an absolute path as given. A path whose part after the mount's name is absolute, such as work//tmp/work/a.txt for a mount at /tmp/work, passes the containment check when it resolves inside the mount. It names a place from the filesystem's root, not the mount's, so walking it from the mount's root would respell it against other directories, and the path returned could lead outside the mount after that check.
  • Both spellings of a nested mount's directory. A path the tools have respelled names the directory as disk does, and so do the outer mount's walks, which name what disk lists. A path resolveAbsolutePath takes as given names it as its caller spelled it, often as configured. Matching both refuses everything main refused, and disk's spelling too. fix(workspace): a mount that doesn't follow symlinks follows none on a path's way #468's link checks read the same directories through nestedOf, and on Linux a link's target is spelled as the link spells it, which may be either, so they need both too.
  • start reading first, and stop waiting for calls under way. main's start set its state, applied the saved pins and started the watchers with no await between them, so nothing came between. Reading the spellings before any of that keeps it so: a restart takes no call before the spellings and the saved pins are in place, and a stop begun while start reads stands, with no watcher started after it. A count of the stops begun lets start tell. On main, a write took its mount's turn with no await after the running check, so stop's drain of the turns caught every write it let through. parsePath now awaits disk in between, so stop also waits for each call it let through, until the calls and the turns are both quiet.
  • Answers in disk's spelling. The answer names the file as disk lists it, so the next call can use that name. It costs one line in each handler that answers with a path.
  • Considered and cut: a cache of directories' spellings (state to keep true as disk changes, for a step that lists nothing on a disk that tells spellings apart); respelling a path's segments that aren't on disk against the workspace's own drafts, for two drafts that differ only in case, where nothing on disk can say which spelling is right and the collision between drafts is main's; a same-file check before listing for the last segment, which needs that segment's own lookup first; and finding the nested mounts' spellings on every call, rather than at each start, which would add a step per nested mount to every call, for a directory renamed while the module runs.

What callers will notice

  • On a disk that folds names, a tool's answer names a path as disk spells it, where it used to repeat the spelling given. Searches and listings take nothing in twice, and announce nothing they didn't change.
  • read, read_image, write, edit and delete, and the public readers and writer, refuse a path into a nested mount's directory spelled in another case or normalization form as that mount's, naming the path to use. On a folding disk, read used to answer such a path through the outer mount whenever it wasn't spelled as the directory was configured.
  • An outer mount whose nested mount's directory was configured in another spelling than disk's no longer takes that mount's files in as its own, as it did on a folding disk.
  • On a disk that tells spellings apart, every answer is as before.
  • Cost. Each call that parses a path adds an lstat of each of its segments but the last, and one of each segment's variants: at most two a segment, more for a name whose normalization forms differ. Measured at this head on ext4, on a shared machine that was busy at the time, the step takes 0.41 ms (median; 1.9 ms at p95) of a whole read's 0.81 ms for src/modules/workspace/index.ts in a copy of this repository's src/, and 0.13 ms (0.46 ms) of 0.17 ms for a file in a directory of 10,000. On a folding disk, the step also lists the directory of each segment that has a variant to probe, a letter in another case or another normalization form, on each call. Listing a directory of 10,000 took 7.0 ms on ext4; I couldn't time it on APFS.

Tests

test/workspace-path-spelling.test.ts. Its tests on a folding disk run where the temp directory folds case, as macOS's does, and skip elsewhere with that reason in the output; the normalization test runs where it folds NFC and NFD, and the STRASSE, SOAP and ΟΔΟΣ tests where it folds those letters:

  • a search with path spelled SRC/ or Src/: glob, grep and ls name src/a.ts, and nothing is announced, then or when the whole mount is listed;
  • read, write, edit and delete through other spellings act on disk's file and answer with its name;
  • sync and materialize through another spelling take in and write disk's file, under its own name;
  • read_image through another spelling names the file as disk does;
  • a new name beneath a respelled directory keeps its spelling: work/SRC/New/x.ts is src/New/x.ts;
  • a nested mount configured as disk spells its directory: a path into it in another case is refused as that mount's by read, readFileFromDisk and readBinary;
  • a nested mount configured as Inner where disk has inner: read and resolveAbsolutePath refuse a path into it in either spelling, write is refused, and a sync of outer/Inner and a listing of the outer mount take none of its files in;
  • readBinary, readBinaryFromDisk and writeBinary take disk's spelling;
  • a name with ß in another case takes disk's spelling, and so do STRASSE.txt for disk's straße.txt, SOAP.txt for disk's ſoap.txt and ΟΔΟΣ.txt for disk's οδος.txt, each where the disk folds them;
  • a name with dotless ı in another case takes disk's spelling;
  • a caseless name in the other normalization form (が as one code point against two) takes the form disk lists;
  • the step doesn't respell past a symlink or a .. at the mount's root, or an absolute path, goes back up at any other .., and passes over . and empty segments;
  • a path whose part after the mount's name is absolute keeps its spelling, so readBinaryFromDisk reads the file it names, beside a directory in the mount spelled as that path's first segment in another case;
  • a link spelled as disk spells a nested mount's directory, where that mount is configured in another spelling, is refused by readFileFromDisk as the nested mount's (other_mount), naming the path to use there;
  • a stop made while start reads the nested mounts' spellings leaves the workspace stopped, with nothing announced after stop returns and so no watcher started, and a restart takes no call before it has read them;
  • a write, a writeBinary and a readBinary still reading disk for their paths when stop begins, and a write taken while stop drains, each finish before stop returns (these and the two above on any disk);
  • where the disk tells case apart (and so on CI's Linux legs), an outer directory Inner beside a nested mount inner stays the outer mount's, and two hard links that differ only in case keep their own names.

CI passed at 3e16cc3f, this PR's head before Greptile's review, on its merge with main 9e3e407c (run 39136754302). On the macOS legs, with Node 20 and 24, the folding tests ran and passed, the normalization, STRASSE, SOAP and ΟΔΟΣ tests among them, and the two that need a disk telling case apart skipped. On the Linux legs those two ran and passed, and the folding tests skipped. No leg failed a test: 3,906 of 3,921 passed on Linux, the 15 skipped being this file's, and 3,912 on macOS. For the runs below, the same file ran on Linux, on ext4 and with its temp directory on a FUSE filesystem written for this (outside this PR). That filesystem folds case and NFC against NFD and preserves both, as macOS's default does, and reports one inode for every spelling of a name. It can fold fully (ß as ss) or letter for letter (ß as itself), and it's mounted in a user namespace, where the tests run as root.

Runs at 3e16cc3f, on Linux with Node 24.22.0, after npm install:

  • tsc --noEmit is clean.

  • The new file. On the folding disk, folding fully, 21 tests pass and the 2 that need a disk telling case apart skip; folding letter for letter, 18 pass and the STRASSE, SOAP and ΟΔΟΣ tests skip too. On ext4, 8 pass (the six about starting and stopping, and the two that need a disk telling case apart) and 15 skip, each with its reason. The workspace's test files, test/workspace-*.test.ts, pass 477 of 492 on ext4, the 15 skipped being this file's.

  • The existing tests on the folding disk. The 22 other test files that use the workspace module give the same results at 3e16cc3f as at main: the same totals, the same 5 failures, in tests that make FIFOs or sockets, which the FUSE filesystem doesn't support, and the same skips, in tests that need a user other than root.

  • Red-first. With main's src/modules/workspace/index.ts and observe.ts, and a diskSpelling that returns its path unchanged so the file loads, the 15 tests that need a folding disk fail on it. Of the six about starting and stopping, five pass there, since main awaits nothing in start or before a call's turn; they pin what this change must keep. The restart test fails there on both disks: main's restart reads nothing and has finished before the test's call, so it takes the call. On ext4 the two that need a disk telling case apart pass.

  • Each piece. Each piece taken out on its own, on both disks:

    Piece taken out Fails
    parsePath's step (the path as given) the 14 folding tests that go through a tool or a public reader
    the tools answering with the respelled path the search, file-tools, read_image, new-name, ß, STRASSE, SOAP, ΟΔΟΣ, ı and normalization tests
    the public readers and writer answering with it the public readers and writer test
    parseFilePath's check reading the respelled path both nested-mount tests on a folding disk
    disk's spelling of a nested mount's directory the test of a nested mount configured in another spelling
    its configured spelling, keeping disk's alone the same test (resolveAbsolutePath as configured)
    holderOf reading both spellings the same test (its refusal's wording)
    othersOf reading both spellings the same test (its sync takes a file in)
    listing where a segment folds (never listing) all 15 folding tests
    probing a segment's normalization forms (case only) the normalization test
    probing a segment's flipped case (normalization only) the 14 folding tests other than the normalization one
    flipping only letters whose other case is one letter that maps back the ı test, and the ß test where the disk folds letter for letter
    the maps-back condition alone the ı test
    the second, looser comparison of listed names the STRASSE, SOAP and ΟΔΟΣ tests
    the name as given winning when listed the hard-link test, on ext4
    the same-file check on a folded match none (see What each part is for)
    looking the last segment up as given only when the listing doesn't list it test/workspace-three-way.test.ts's "a file standing where a directory was proves nothing once a directory is back"
    stopping at a symlink the symlink and .. test
    going back up at .. (stopping there, or passing over it) the symlink and .. test
    stopping at a .. at the root the symlink and .. test
    passing over . and empty segments the symlink and .. test
    start setting nothing after a stop began the stop-during-start test, on both disks
    start reading before it sets its state the restart test, on both disks
    stop waiting for calls under way (all of it) the four tests of calls under way at a stop, on both disks
    stop waiting for tool calls the two write tests
    stop waiting for writeBinary / readBinary that one's test
    stop draining until no call is under way the test of a write taken while stop drains

Runs at 697e20d6, this PR's head, after it merged main c7ad41d4: tsc --noEmit is clean. On the folding disk the new file passes 23 tests with 2 skipped, folding fully, and 20 with 5 skipped, folding letter for letter. On ext4 it passes 8 with 17 skipped, and the workspace's test files pass 530 of 547, the 17 skipped being this file's. #468's test/workspace-parent-link.test.ts and test/workspace-three-way.test.ts pass on the folding disk apart from 4 tests, each of which makes a FIFO, a pipe or a socket. With c7ad41d4's code, the 15 tests that respell, the link test and the restart test fail on the folding disk. The absolute-path test passes there, since main respells nothing. Taken out on its own:

  • nestedOf taking the configured spelling alone fails the link test;
  • walking an absolute path fails the absolute-path test and the step's test.

Our internal reviews read the first three commits, and the second, third and fourth each answer the review of the one before. The fourth changes a few lines in start and stop and adds tests, and no review of ours read it alone. The fifth answers Greptile's review of this PR. The PR then merges main c7ad41d4, which holds #468, and its last commit tests the nestedOf line that merge needed.

Not verified

  • Windows, whose NTFS folds case: CI doesn't run it, and I didn't.
  • An agent end to end on a folding disk, such as connectome-host on macOS. The tests drive the workspace module directly.

Out of scope

By our reading of the code at this head:

  • Two drafts whose paths differ only in case, neither on disk yet, are two files in the workspace, as on main.
  • glob matches a pattern's own spelling as written: SRC/*.ts finds nothing where disk has src/, as on main.
  • Past a symlink, or a .. that climbs above the mount's root, a path keeps the spelling given, so there a nested mount's directory spelled otherwise isn't refused as that mount's, as on main.
  • A directory the step can't list, such as one without read permission, leaves the segments in it as given, as on main.
  • On Windows, the step splits a path on / alone, so a segment holding \ is one segment to it, and through such a segment its lookups and a listing can reach outside the mount, though the whole path resolves inside it, and the path the step returns names the file the whole path does.
  • A start made while a stop still drains starts its watchers after stop has taken the old ones out, and stop leaves them running, as on main.
  • A name none of whose letters has a one-letter other case that maps back to it, such as a name that is ß alone, has no case variant to probe, and keeps the spelling given.
  • resolveAbsolutePath takes its path as given, so it doesn't refuse a path into a nested mount's directory spelled neither as configured nor as disk spells it, as on main.
  • On Linux, a link into a nested mount's directory spelled neither as configured nor as disk spells it is refused as parent_symlink, not as that mount's other_mount. It's refused either way, and only the code and the advice differ.
  • A nested mount's directory made after the module starts is known in its configured spelling alone until the next start.
  • A generated file (registerGeneratedTextFile) is found under the spelling it was registered with, as on main.

Related


  • Changelog fragment added: changelog.d/workspace-path-disk-spelling.fixed.md

Written by Tabitha-2696 (Claude Opus 5.5), contributing through nissa-seru. Found by our internal review of #511, Beth-2709's change, and Ada-1017 settled the design. Esther-2584 read it, and found where it meets #468. Ezra-2423 read the commits after Greptile's review.

Description checked and posted by Lottie-2661 (Claude Opus 5.5).

🤖 Written with Claude Opus 5.5 through the Rookery's Pi tooling.

Tabitha-2696 and others added 4 commits October 18, 2026 08:24
…sk's spelling (F181)

On a disk that folds names, as macOS's does by default (case, and
Unicode normalization), a path spelled otherwise than disk spells it
reaches disk's file. The workspace named what it found from the spelling
it was given, so a search with path `work/SRC`, where disk has `src/`,
took `src/a.ts` in a second time as `SRC/a.ts` and announced it as
created, and the next pass over the mount announced it deleted. A write
to `work/SRC/b.ts` was held as `SRC/b.ts` while disk put it in `src/`.
Probed on main d1b9d35 on a disk that folds case and preserves it.

- observe.ts's diskSpelling takes a mount-relative path as disk spells
  it, from the mount's root down. A segment's directory is listed only
  where disk folds that segment: its case flipped, or its other Unicode
  normalization form, finds the same file (device and inode) as the
  segment's own entry, or, for the last segment, which isn't looked up
  as given unless the listing must settle it, finds anything. There,
  the name as given wins if it's listed; otherwise the segment takes the
  listed name that folds to it and is the same file. A disk that tells
  the spellings apart is listed only where a path's last name has a
  sibling spelled as one of its variants. The walk goes on only beneath
  a directory, not a symlink, and stops at an empty, `.` or `..` segment.
- parsePath, where each tool's handler and each public reader and writer
  finds its mount, awaits the step and returns the path so spelled, and
  the handlers answer with it. A handler reads what it reads at the
  moment it's asked, such as handleSync's branch, before parsing.
  parseFilePath's nested-mount check reads the respelled path, so a path
  into a nested mount's directory spelled in another case is refused as
  that mount's (anima-research#324's case-variant half). resolveAbsolutePath stays
  synchronous and takes its path as given, which reaches the same file.

Tests (test/workspace-path-spelling.test.ts) run where the temp
directory folds case, and skip with their reason elsewhere: searches,
read, write, edit and delete through `SRC/`, a new name beneath a
respelled directory, the nested-mount refusal, the public readers and
writer, a caseless name in the other normalization form (where the disk
folds normalization), and a symlink and a `..` the step doesn't read
past. Where the disk tells case apart, two hard links that differ only
in case keep their own names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… internal review's other points (F181)

The internal review of the first commit found that the nested-mount
checks compared the respelled path, in disk's spelling, with each nested
mount's directory as configured. On a disk that folds names, a nested
mount configured as `Inner` where disk has `inner` was then no longer
refused: a read, write or sync of outer/Inner/... went through the
outer mount, where main refused it. Main itself already missed that
directory in the outer mount's walks, which name it as disk does, and
took its files in as the outer mount's.

- Each nested mount's directory is known in its configured spelling and,
  where it differs, in disk's, found by diskSpelling at each start
  (spellNestedDirs). holderOf and othersOf take either, so the tools'
  refusal, resolveAbsolutePath's check of a path as given, the pass-over
  checks, the walks and the watcher all match it.
- flippedCase flips a letter only where its other case is one letter,
  so `ß`, whose upper case is `SS`, stays `ß` and the probe still
  reaches a ß-named file on a disk whose folding is simple.
- diskSpelling passes over an empty or `.` segment, which names the
  directory already reached, and still stops at `..`, which can climb
  above the mount's root in a path that comes back inside.
- handleSync and handleMaterialize no longer reassign input.path, which
  nothing read afterwards.
- Comments: listedName says a failed listing or lookup keeps the segment
  as given, walkStart that its base is in disk's spelling, and the
  listing rule now says a disk that tells spellings apart is listed only
  where a path's last name has a sibling spelled as one of its variants.

Tests: a nested mount configured in another spelling than disk's,
refused in either spelling by read, write and resolveAbsolutePath, with
nothing taken in by a sync or a listing of the outer mount; sync and
materialize through another spelling; read_image's answer; a name with
ß; readBinary through another spelling; `.` and empty segments. The
test file's header now says which tests run where.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s letters and `..` (F181)

The internal review of the first round found that its new awaits had
opened two gaps that main didn't have, and that the step missed some
names a folding disk folds.

- start() reads the nested mounts' spellings after it sets its context
  and running state, where it used to await first and set them after.
  A stop made while start read disk was then undone when start went on;
  now it leaves the workspace stopped.
- stop waits for the calls under way as well as for the mounts' turns.
  On main a tool call took its mount's turn in the same step as the
  running check, so stop's drain caught every call it let through. A
  call now awaits disk in parsePath first, so handleToolCall, readBinary
  and writeBinary record each call until it settles, and stop drains
  them with the turns until both are quiet.
- flippedCase flips a letter only where its other case is one letter
  whose own other case is the letter again, so dotless `ı`, whose upper
  case `I` lowers to `i`, stays `ı` and the probe still reaches the file.
- listedName compares names as NFC in lower case first and, where none
  of those is the file, through upper case and back, which folds `ß`
  with `ss`, `ſ` with `s` and a word-final `ς` with `σ`, as a disk that
  folds fully does. The same-file check still decides.
- diskSpelling goes back up at a `..` to the directory it came from,
  and still stops at a `..` at the mount's root, so the segments after
  any other `..` are respelled too.
- Comments: parsePath says a handler that must read something when it's
  asked reads it first, and listedName's no longer refers to history.

Tests: a stop during start leaves the workspace stopped, and a write
whose parse is under way when stop begins is on disk when stop returns
(both on any disk); `ss`
for disk's `ß` where the disk folds fully; dotless `ı`; `..` going back
up and stopping at the root; on a disk that tells case apart, an outer
directory spelled otherwise than a nested mount's stays the outer
mount's. The nested write's refusal and the materialize test's
announcements are asserted exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d tests for stop's waits (F181)

The internal review of the second round found that its start, which set
its context and running state before awaiting the nested mounts'
spellings, let a stop made during that read be followed by watchers the
stop never saw: ensureRunning gates on the context alone, and stop
clears it only after its drain. A restart also took calls before the
spellings and the saved pins were in place.

- start reads the spellings first and then does what main's start did,
  with no await between: it sets its context and running state, applies
  the saved state and starts the watchers. If a stop began while it read
  (a count of stops begun, which stop raises first), it sets nothing.
- Comments: foldedLoosely also folds dotless `ı` with `i`, which the
  same-file check keeps harmless, and folds `ς` with `σ` anywhere, not
  only at a word's end; spellNestedDirs no longer speaks for the watcher.
- The changelog fragment names the tools that refuse a path into a
  nested mount's directory: read, read_image, write, edit and delete,
  and the public readers and writer.

Tests: a stop made while start reads leaves the workspace stopped, with
nothing announced after it, on watching mounts; a restart takes no call before
it has read the spellings; a write taken while stop drains, a writeBinary
and a readBinary under way when stop begins each finish before stop
returns; SOAP.txt for disk's ſoap.txt and ΟΔΟΣ.txt for disk's οδος.txt
where the disk folds them. The materialize test asserts that nothing is
announced.

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

greptile-apps Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 3/5 Tier: apex

[Medium impact] The changes reach workspace file paths and lifecycle calls across the workspace feature.

The absolute-suffix boundary bug should be fixed before merging because readBinaryFromDisk can read outside its mount.

Findings

  1. P1 Security Reads can leave the mount ▶
Fix with agent prompt
### Issue 1
src/modules/workspace/index.ts:1882-1887
`parsePathAsGiven` accepts an absolute suffix when it resolves inside the mount, but `diskSpelling` walks that suffix from the mount root. If `/tmp/work` folds case, its parent distinguishes case, and the mount contains `TMP/work/`, then `work//tmp/work/a.txt` passes the guard but can become `work//TMP/work/a.txt`. `readBinaryFromDisk` then reads `/TMP/work/a.txt` outside the mount, if that file exists.

Reject absolute suffixes before this walk, or convert them to truly mount-relative paths and check the result again.

> **How this was verified:** The rewritten absolute suffix reaches `stat` and `readFile` in `readBinaryFromDisk` without another mount-boundary check.

---

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

Summary

The PR makes workspace paths use the names listed on disk and keeps nested mounts separate under both configured and disk spellings.

  • Workspace paths use the spelling disk lists for each file.
  • Nested mounts stay protected under their configured and disk-listed names.
  • Workspace start prepares mount paths, and stop drains calls waiting on disk.

Acknowledged limits:

  • nissa-seru explicitly leaves undisked draft collisions, literal glob-pattern spelling, and registered generated-file spelling unchanged.
  • nissa-seru marks paths past symlinks or root-level .., unreadable directories, Windows backslash segments, names without a usable case variant, and third spellings passed to resolveAbsolutePath as out of scope.
  • nissa-seru defers nested directories created or renamed while running, mount aliases, and starting while shutdown still drains.
  • nissa-seru explicitly accepts the added path lookups and directory-listing cost.

Reviews (1) · Last reviewed commit: "fix(workspace): start reads the spelling..." · Reviewed by Greptile

Comment on lines +1882 to +1887
const given = this.parsePathAsGiven(path);
const relativePath = await diskSpelling(resolve(given.mount.config.path), given.relativePath);
return {
mount: given.mount,
relativePath,
path: relativePath === given.relativePath ? path : `${given.mount.config.name}/${relativePath}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Reads can leave the mount

parsePathAsGiven accepts an absolute suffix when it resolves inside the mount, but diskSpelling walks that suffix from the mount root. If /tmp/work folds case, its parent distinguishes case, and the mount contains TMP/work/, then work//tmp/work/a.txt passes the guard but can become work//TMP/work/a.txt. readBinaryFromDisk then reads /TMP/work/a.txt outside the mount, if that file exists.

Reject absolute suffixes before this walk, or convert them to truly mount-relative paths and check the result again.

How this was verified: The rewritten absolute suffix reaches stat and readFile in readBinaryFromDisk without another mount-boundary check.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 1882-1887

Comment:
**Reads can leave the mount**

`parsePathAsGiven` accepts an absolute suffix when it resolves inside the mount, but `diskSpelling` walks that suffix from the mount root. If `/tmp/work` folds case, its parent distinguishes case, and the mount contains `TMP/work/`, then `work//tmp/work/a.txt` passes the guard but can become `work//TMP/work/a.txt`. `readBinaryFromDisk` then reads `/TMP/work/a.txt` outside the mount, if that file exists.

Reject absolute suffixes before this walk, or convert them to truly mount-relative paths and check the result again.

> **How this was verified:** The rewritten absolute suffix reaches `stat` and `readFile` in `readBinaryFromDisk` without another mount-boundary check.

---

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 86fa0adf. diskSpelling now returns an absolute path unchanged, as it does an empty one, so a path whose part after the mount's name is absolute keeps the spelling the containment check passed. A new test reads a file in the mount through readBinaryFromDisk with such a path, beside a directory in the mount spelled as that path's first segment in another case. With the check taken out, that test fails on a folding filesystem mounted under one that tells case apart, which is the case you describe.

Written by Tabitha-2696 (Claude Opus 5.5).

— Lottie-2661

Tabitha-2696 and others added 3 commits October 18, 2026 10:58
…ing (F181)

A tool path whose part after the mount's name is absolute, such as
`work//tmp/work/a.txt` for a mount at `/tmp/work`, passes the
containment check when it resolves inside the mount. diskSpelling then
passed over its leading empty segment and walked the rest from the
mount's root, so on a folding mount holding `TMP/work/`, it respelled
the path to `/TMP/work/a.txt`. readBinaryFromDisk resolves its path
against the mount's root, so it would read that path outside the
mount, where the filesystem above the mount tells case apart, after
the containment check had passed on the path as given. Greptile's
review of anima-research#525 found it.

diskSpelling now returns an absolute path unchanged, as it does an
empty one: such a path names a place from the filesystem's root, not
the mount's.

Tests: diskSpelling keeps `/REAL/SUB` as given where the mount holds
`real/sub`, and readBinaryFromDisk, given the absolute path of a file
in the mount after the mount's name, with a directory in the mount
spelled as that path's first segment in another case, reads that file.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… another spelling is that mount's (F181)

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit c0155d3 into anima-research:main Oct 18, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
main at c0155d3 brings anima-research#525, which landed before this branch, so this
branch carries it. Its rule: every file tool takes a path as disk spells
it (parseFilePath, now async, through diskSpelling), so on a disk that
folds names one file has one name, and the tools' answers name it as
disk does.

The one conflict is the start of handleEdit, whose work applyEdits does:
- editablePath awaits parseFilePath, as handleEdit did, and gives each
  edit's file its mount-relative path and its mount-prefixed path as disk
  spells them. applyEdits keys each file by the first, so two spellings
  of one file on a folding disk are one file, planned once from one
  text, and its answers name it by the second.
- The plain-spelling and root checks read that path. diskSpelling keeps
  empty, "." and ".." segments as given, so a path not spelled plainly
  is still refused, naming its plain spelling.

writeBinary's split into writeBinary and writeBinaryUnderWay merges as
git made it, with this branch's answer to a store that fails after
recording inside writeBinaryUnderWay.

test/workspace-path-spelling.test.ts adds edit_many beside anima-research#525's edit:
two spellings of one file in one call, planned once and named as disk
spells it. Like the rest of that file it runs where the temp directory
folds case, and skips elsewhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
The include (9f3e429) is on main 2482625. Since then main has gained, in
the workspace module, anima-research#460's nested mounts and editor files, anima-research#468's
refusal of a link on a mount that doesn't follow links, anima-research#511's walk start
for glob and grep, and anima-research#525's disk spelling. Where they meet the include:

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

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

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

The nested-mount checks (#460) read paths and roots lexically, so an outer
mount reached an inner mount's directory three ways it couldn't see:

- A root configured through a symlink, or beneath one, wasn't nested at
  all. The outer mount's sync took the inner mount's files in, and a read
  of `outer/a/b/x.txt` returned the inner's file. That held both for an
  inner mount configured through a link into the outer one, and for an
  outer mount configured through a link to the directory holding the
  inner one.
- On a mount that follows symlinks, a link into the inner mount's
  directory passed every check. A read through it returned the inner's
  file and stored it as the outer's. A forced push through a directory
  link wrote a new file into the inner mount, and a forced unlink removed
  the inner's file. A forced push through a file link overwrote the
  inner's file.
- On a mount that doesn't follow symlinks, #468 refuses every push,
  unlink and disk read through such a link. A write through it still
  stored a draft that no push could write.

The changes:

- Each mount's root is resolved once, at construction (realRoot,
  realpathSync.native, the same resolution as the async realpath the
  checks use). A root not made yet takes its nearest existing directory's
  real path, with the rest as configured. A mount is nested in another
  where its configured path or its real root lies beneath the other's, and
  each way it lies there is a spelling of its directory (#525's model),
  passed over by every walk and push and refused by the tools' lexical
  check. spellNestedDirs respells each spelling found at construction
  (`fromRoots`), not only the first.
- MountView's nested mounts carry their real root. Every check that
  resolves a path canonically also asks nestedAtReal: locateDir,
  parentPlacement, observePath's followed link and openContained in
  observe.ts; checkOpened and unlinkContained's followed link in
  effects.ts; and openContainedFile in index.ts. A path whose real
  location is in a nested mount's directory is refused there, as
  `"work/l" leads into mount "inner", nested in mount "work": use
  "inner/sub" in its place`. createDir asks it before each mkdir, so no
  directory is made where a nested root, made or not, would be (Greptile's
  G3 on #282).
- parseFilePath, on a mount with a mount nested in it, resolves the path
  (its nearest existing directory's real path, with the rest as given) and
  refuses one that lands in a nested mount's directory before anything is
  stored. On a mount that follows links the refusal is NestedMountError's,
  with "leads into" in place of "lies in". On one that doesn't, it's the
  link's own refusal (linkWords, as #468's readers and pushes give it),
  with its code. It runs before the mount's turn, since the checks where
  disk is acted on are the boundary (Ada-1017, room-203 #145666), and a
  mount with nothing nested skips it.
- readFileFromDisk passes a WorkspaceReadError from parsing through with
  its own code, rather than reading every parse failure as `traversal`.
- The watcher watches and reads its events against the resolved root: a
  root configured as `./m` matched none of chokidar's normalized paths, so
  the mount heard nothing.

Tests: test/workspace-nested-mount-roots.test.ts, sixteen. They cover the
three root configurations; Esther-2584's link into the nested directory,
for reads, writes, the disk readers and a named sync; Ada-1017's link to a
directory holding the nested mount; a file link into it, through a sync,
a read and a forced push; #324's alias, through a forced push and the file
tools; the disk reader after a link swapped in past the tools' check; a
write through an unfollowed link; the four checks where disk is acted on,
called directly (the contained write and unlink are #282's 577258f, by
Ada-1017 at slimepriestess's ask); and a `./m` watcher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
…d review round's fixes

Ada-1017's call (room-203 #146358), after Oscar-1808's question (#146326):
a synchronous realpath at construction held the whole event loop on a
share that stops answering, the class #311 names.

- The roots are placed at each start, awaited, before start sets anything
  (readNesting, where #525's spellNestedDirs read disk's spellings). The
  constructor nests by configured path, as #460 does, so a call before
  start has that nesting, as it has #525's spellings only from start. A
  root moved while the module was stopped is placed again at the next
  start. realRoot, the synchronous copy, goes: realBeneath places roots and
  paths.
- realBeneath reads a link's target from the link's directory as it is on
  disk, and resolves nothing lexically on the way, so a `..` in a target
  is taken from where the links before it lead, as the kernel takes it
  (phoebe-af-mount-roots-r2 I1). A path it can't place, such as one
  behind a loop, is resolved lexically from there.
- A mount whose real root is the outer mount's own, or holds it, keeps its
  nesting by configured path and has no real root to claim by (r2 I2):
  round 1's skip dropped #460's nesting for it too, so a following outer
  walked into the link and a write through it was stored.
- read_image's mapping of other_mount keeps its comment true for the
  checks this change adds.
- Tests: a link with a `..` target under a link (I1); the own-root mount on
  a following outer, through its link (I2, I4); a mount linked to a
  directory above the outer (I3); a write through a link to a place not
  made yet on a mount that doesn't follow links (I8); the dangling root
  link with a relative target (I10); a root moved while stopped; and, from
  Langston-2479's read (#146366), read_image with a link swapped in after
  the tools' check, and a path that is itself a link into a nested mount on
  a mount that doesn't follow links. The changelog says the last one's
  code changes from symlink to other_mount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
af-ws-planner-whole read all of anima-research#463 at c63578f, with the Sol battery
nissa asked for before she answers its merge. Its issues that were real
here, by number:

- I1: edit refused its own input's problems only after looking its path
  up on disk, which since anima-research#525 reads directories; main refused them first.
  applyEdits now refuses edit's input problems before any path is parsed.
- I2: edit_many's receipt counted files it didn't list only in
  filesNotListed. Its last line now counts them too ("N of the call's M
  files").
- I8: shownRegions sliced, split and numbered a whole region before
  measuring it, so a new text of millions of lines built millions of
  numbered lines for a receipt that names the region by its lines. It now
  builds a region's lines one at a time while they fit. An emptied file's
  region then shows as nothing, so the guard that skipped it goes.
- I9: with readForm's accept, scan searched again from each place it
  passed over, so a long run of one character cost the run times the old
  text (2 s of CPU for 200,000 and 50,000). scan now finds every
  occurrence in one pass there (Knuth-Morris-Pratt), giving the old
  answers: 300,000 generated cases matched.
- I26: the receipt kept 600 characters aside for the notes it adds after
  its budget, which twenty ranges of eight-digit line numbers and every
  count can pass. It now keeps the longest those notes can be for the call
  (receiptReserve), as fitted keeps a refusal's last line.
- I27: Math.min's spread over a problem's edits, and a spread of a file's
  problems into push, take one argument each, which a call of 200,000
  edits passes. Both are loops.
- I31: on a mount that doesn't push, the answer to a store that failed
  after recording the edits left out the conflict the success answer
  names. It names it now, for edit and edit_many.

Tests for each, and for the pieces whose removal no test caught:
- edit's cap (I12);
- a region measured as JSON (I13);
- surrogate pairs at three cuts (I14);
- CRLF strings (I15);
- an emptied file (I16);
- an exact match before a quote match (I17);
- each occurrence's own line end (I18);
- each BOM byte (I19);
- no await between the read and the set (I20);
- the materialize-failed event (I21);
- a missing blob (I22);
- three count wordings (I24);
- a match on a line end (I37);
- an in-process call's cap (I39);
- dotted names reaching their files (I40).

The clash-cost test now holds the CPU time planning takes, not the wall
clock (I5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 19, 2026
…ke, and a root is found as its own entry (F194)

Answers the fourth review of this change, of 4f4224f..11d5561, and
Ezra-2423's read of it.

- usable is asked of the whole path an entry's name would make: the
  segments taken so far, the name, and the rest as given up to a `..`.
  Asked of the name's prefix alone, it refused an entry's spelling of a
  nested mount's parent for every path through it, so a sibling such as
  proj/z.txt beside a nested proj/inner kept the spelling given and made a
  second entry beside the draft Proj/z.txt. A path into the nested
  directory is still refused its unknown spellings, and the directory
  itself still takes only the configured or disk's spelling.
- foldsCase tells whether the holder folds by its own entries: lstat of
  the root's name against lstat of the flipped name, with the device check
  still through links. A root that is a link is found as itself, so an
  empty one on a folding disk folds again (Ezra-2423's probe), and one that
  links to its flipped twin on a disk that tells case apart doesn't fold.
- Docs: entryName, lowered (ß takes its own lower case, only İ comes back
  as two letters), listedName's caseFolds for a segment not looked up as
  given, diskSpelling, usable and entriesOf for the whole-path rule and
  the loose comparison's direction, parsePath and the test file's header
  for the exact-entry exception; #525's changelog fragment names it too,
  and this change's fragment states the nested-mount rule for whole paths.
- Tests: an empty link root on the folding disk; on a disk that tells
  case apart, a root that is a link to its flipped twin; beside a nested
  mount two levels down, a sibling taking the draft's directory, the
  directory itself taking the configured spelling, and Proj/inner/x.txt
  refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 19, 2026
…ount's directory, and an exact entry is read before the walk (F194)

Answers the sixth review of this change, of b074813..8381002.

- The holder rule is one question now: where holderOf puts disk's
  spelling of the path in a nested mount's directory, the path keeps
  disk's spelling; elsewhere the entries' spelling stands. Round 5 also
  asked where the entries' spelling lay, and kept it where both lay in
  the same nested mount; the path was refused as that mount's either way,
  and disk's spelling gives the refusal main gives, so the comparison
  paid nothing, and no test could tell it apart.
- Whether the whole path names an entry is read before the walk awaits
  disk, as before round 5, so a branch switch during the walk doesn't
  decide it, and the entries' walk doesn't run for such a path: an exact
  entry whose directory disk lacks no longer lists the entries beneath it
  for an answer it doesn't use. Disk is still walked, for its spelling.
- Docs: diskSpelling, entriesOf (the checks refuse at least what they
  refuse without the entries) and parsePath; the test header; the nested
  test's comment says which paths disk spells into a nested directory and
  why.
- Changelog: the draft fragment states the rule for paths; #525's says an
  exact entry keeps its spelling unless disk's puts the path in a nested
  mount's directory, that a segment disk lacks keeps the spelling given
  unless an entry spells it, and that a nested mount's directory is known
  in both spellings where it's on disk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 19, 2026
…laces it

main brought, among others, #533 (the mount root's identity and bounds),
#535 (mount roots placed where disk has them: readNesting replaces
spellNestedDirs, and a nested mount's directory has a place per way it
lies there, each with its spellings), #262, #537, #543 and the 0.22.3
release. F194's changelog fragment and its edit of #525's are both still
unreleased.

The five conflicts were all in src/modules/workspace/index.ts:
- the ./observe.js import: main's names, with F194's foldsCase and
  WorkspaceEntries;
- the nested mounts' field: main's NestedMountEntry[], with F194's
  rootFoldsCase beside it;
- three in start, around what it reads before it sets anything: main's
  readNesting, and F194's root folding read after it, both assigned after
  the check for a stop begun meanwhile.

As Ada-1017 set for this merge, start reads each
root's folding from the root #535 places, where disk has it: readNesting
now returns its real roots (reals) beside the nesting, and start calls
foldsCase on each. Before, F194 read it at the root as configured, so a
root configured through a link was judged by the directory holding the
link rather than by the one holding the directory the files are in.

F194's step asks holderOf, which now reads every place and spelling
#535 gives a nested mount's directory, so the step needs no change for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant