From 15f00582583a4471375d0399b48dc2ff6cbc3e4a Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Sat, 10 Oct 2026 15:26:21 -0400 Subject: [PATCH 01/26] fix(workspace): ignore patterns reach the watcher, and a nested mount is ignored at any depth (#280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent-framework #280 (slimepriestess, from #278's review): the constructor's overlap guard built `rel + '/**'` as `pattern` and then pushed `rel`. The walk's matcher (observe.ts isIgnored) reads a pattern without `/**` only as an entry name, so a mount nested two or more levels deep was never ignored, and a mount nested one level deep (`sub`) hid every directory named `sub` anywhere in the outer mount. On main, an outer mount over an inner one at `a/b` synced `a/b/x.txt` too, while the guard warned "auto-ignoring to prevent overlap" (probed through the sync tool). Basil-1007's review found the rule had a second reader that applied none of it: watcher.ts handed the ignore list to chokidar's `ignored` as strings, and chokidar 4 takes a string as one exact path (createPattern is `matcher === string`, normalizeIgnored only joins it to an unset cwd). With explicit paths, the watch handler's sync skips the walk, so nothing filtered them there either. On a watched mount (`watch` defaults to 'always'), every change under an ignored path, every `node_modules` included, and under a nested mount, was synced into the outer mount. That is older than #280 and wider, and it is the same rule at another reader. Changes: - The guard pushes the `/**` form it built, '/'-joined (node's relative() emits backslashes on Windows, and the walk compares '/'-joined paths). It skips adding only when the list already holds that form: a bare `a/b` a user wrote can never match, and used to suppress it. - Its nesting test is exact (Basil-1007's asides): `..` itself or `../…` climbs out, so an inner mount in `..cache` is nested; and an absolute `rel` (relative() across Windows drives) is not, which `startsWith('/')` missed. - The watcher gives chokidar a function: the event path, made mount-relative by toRelative, is passed over when coveredByIgnore says so, which is the walk's own test on the path and each directory above it. Chokidar then also doesn't descend into an ignored directory. - coveredByIgnore moves from index.ts (where #278 added it for the passed-over count) to observe.ts beside isIgnored, so the walk's count and the watcher read one helper. What an upgraded deployment sees (Basil-1007's probe at this head): files the old watcher took in from under an ignored path stay tracked and stop updating; each bare sync lists them under `skipped` ("disk could not be checked: ignored by the mount") and their directory under `incomplete`, which #278 already reports for any tracked ignored file. The listing lasts as long as disk can't show the file gone (Basil-1007's probes): for a file the pattern matches itself (`app.log` under `*.log`), until it is removed; for a file inside an ignored directory (`node_modules/pkg/index.js`), until that directory is removed, since the walk doesn't look inside it. Deleting such a file from the workspace on a mount without autoMaterialize doesn't end it either: the next two bare syncs still listed it, since the workspace-deleted intent keeps it a candidate and disk still reads as ignored. materialize with applyDeletions would end it by deleting the disk copy. There is no way yet to stop tracking a file and keep it on disk; that is out of scope here, and the fragment says so. Stacked on #278 (which is on #248): it uses #248's isIgnored and #278's coveredByIgnore. Tests (test/workspace-nested-mount-ignore.test.ts, 5, through the sync tool and a real MountWatcher over tmpdir mounts): - an inner mount at `a/b`: the outer's ignore list is `['a/b/**']`; its sync takes `a/other.txt` and `top.txt`, not `a/b/x.txt`; - at `sub`: `['sub/**']`; the outer's sync takes `deep/sub/y.txt` and `top.txt`, not `sub/z.txt`; - an outer list with a bare `a/b` still gets `a/b/**`; one with `a/b/**` doesn't get a second copy; - an inner mount at `..cache` is nested: `['..cache/**']`, and the outer's sync takes only `top.txt`; - a watched outer mount (ignore `node_modules`, inner at `a/b`, the config object the module's constructor filled in): after writes to `node_modules/pkg/index.js`, `a/b/x.txt`, `a/other.txt` and `later.txt`, once `later.txt` is seen, `a/other.txt` is seen and nothing under `node_modules` or `a/b` is. Red-first: on #278's head (0354132) all five fail. Removals: pushing `rel` fails the first three; the old skip on a bare `rel` fails the third; the old `startsWith('..')` fails the `..cache` test; the watcher's strings fail the watcher test. Checking only the path itself in the watcher, not the directories above it, changes nothing there: chokidar asks about each directory before descending into it. The shared helper reads ancestors for #278's count. The '/'-join and `isAbsolute` matter only on Windows, which CI doesn't run. Neighbours: workspace-watcher 10/10, workspace-three-way 122/122. Reviewed by Basil-1007 (Claude Opus 5.5), who found the watcher defect. Co-Authored-By: Claude Opus 5.5 --- changelog.d/nested-mount-ignore.fixed.md | 25 ++++ src/modules/workspace/index.ts | 18 ++- src/modules/workspace/observe.ts | 5 +- src/modules/workspace/watcher.ts | 15 ++- test/workspace-nested-mount-ignore.test.ts | 137 +++++++++++++++++++++ 5 files changed, 190 insertions(+), 10 deletions(-) create mode 100644 changelog.d/nested-mount-ignore.fixed.md create mode 100644 test/workspace-nested-mount-ignore.test.ts diff --git a/changelog.d/nested-mount-ignore.fixed.md b/changelog.d/nested-mount-ignore.fixed.md new file mode 100644 index 000000000..051c8720b --- /dev/null +++ b/changelog.d/nested-mount-ignore.fixed.md @@ -0,0 +1,25 @@ +- A mount's ignore patterns now apply to its watcher, and a mount that + contains another mount leaves it out at any depth. + - The watcher handed the patterns to chokidar as strings, which chokidar + takes as exact paths, so on a watched mount (`watch` defaults to + `'always'`) every change under an ignored path, `node_modules` included, + was synced. The watcher now applies the walk's own rule: a path is passed + over when it or a directory above it matches. Chokidar also no longer + descends into ignored directories. Files the old watcher already took in + from under an ignored path stay tracked and stop updating. Each bare sync + lists them under `skipped` ("disk could not be checked: ignored by the + mount"), with their directory under `incomplete`, for as long as disk + can't show them gone. For a file the pattern matches itself, such as + `app.log` under `*.log`, that's until the file is removed. For a file + inside an ignored directory, such as `node_modules/`, it's until that + directory is removed, since the walk doesn't look inside it. Deleting + such a file from the workspace doesn't end the listing either, and there + is no way yet to stop tracking a file and keep it on disk. + - The overlap guard said it was "auto-ignoring" the inner mount's directory, + but it added the bare relative path, which the walk reads only as an + entry name: a mount nested two or more levels deep (`a/b`) was still + synced by the outer mount, and a mount nested one level deep (`sub`) also + hid every other directory named `sub`. It now adds `a/b/**`, which + matches that path and what is under it, and nothing else. An inner mount + in a directory whose name starts with `..` (such as `..cache`) is now + recognized as nested (agent-framework #280). diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 45e3b5e65..b7c85fe1d 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -8,7 +8,7 @@ import { constants as fsConstants } from 'node:fs'; import type { Stats } from 'node:fs'; import { open, readFile, stat, lstat, realpath } from 'node:fs/promises'; -import { join, resolve, relative, sep } from 'node:path'; +import { isAbsolute, join, resolve, relative, sep } from 'node:path'; import type { JsStore } from '@animalabs/chronicle'; import type { Module, ModuleContext, ProcessState, EventResponse } from '../../types/module.js'; import type { ProcessEvent, ToolDefinition, ToolCall, ToolResult } from '../../types/events.js'; @@ -655,12 +655,18 @@ export class WorkspaceModule implements Module { const outerPath = resolve(outer.path); const innerPath = resolve(inner.path); const rel = relative(outerPath, innerPath); - if (rel && !rel.startsWith('..') && !rel.startsWith('/')) { - // inner is nested under outer — add ignore rule + // Nested: a relative path that doesn't climb out (`..` exactly, or + // `../…`; a name like `..cache` is a child) and isn't another root + // (`relative()` returns `D:\…` across Windows drives). + if (rel && rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel)) { + // inner is nested under outer — add ignore rule. Only the `/**` + // form matches a path at any depth, and only that path: a bare + // `a/b` never matches, and a bare `sub` matches every directory + // named sub (agent-framework #280). outer.ignore = outer.ignore ?? []; - const pattern = rel + '/**'; - if (!outer.ignore.includes(pattern) && !outer.ignore.includes(rel)) { - outer.ignore.push(rel); + const pattern = rel.split(sep).join('/') + '/**'; + if (!outer.ignore.includes(pattern)) { + outer.ignore.push(pattern); console.warn( `[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` + `(${rel}/) — auto-ignoring to prevent overlap`, diff --git a/src/modules/workspace/observe.ts b/src/modules/workspace/observe.ts index 6aabafb2a..5cfccd8a8 100644 --- a/src/modules/workspace/observe.ts +++ b/src/modules/workspace/observe.ts @@ -668,7 +668,8 @@ export interface Walk { /** * Whether a mount's ignore list covers a mount-relative path: the path itself * or any directory above it, as the walk's own test reads each entry by its - * path and name. + * path and name on the way down. The watcher reads it the same way + * (agent-framework #280). */ export function coveredByIgnore(relativePath: string, patterns: string[]): boolean { const segments = relativePath.split('/').filter(Boolean); @@ -678,7 +679,7 @@ export function coveredByIgnore(relativePath: string, patterns: string[]): boole return false; } -/** Simple ignore patterns, as the watcher and the old walk read them. */ +/** Simple ignore patterns, as the walk and the watcher read them. */ export function isIgnored(relativePath: string, name: string, patterns: string[]): boolean { for (const pattern of patterns) { if (pattern === name) return true; diff --git a/src/modules/workspace/watcher.ts b/src/modules/workspace/watcher.ts index f853b0365..84b7f9056 100644 --- a/src/modules/workspace/watcher.ts +++ b/src/modules/workspace/watcher.ts @@ -9,6 +9,7 @@ import { watch, type FSWatcher } from 'chokidar'; import { mkdirSync, statSync } from 'node:fs'; import { basename, sep } from 'node:path'; import { type MountConfig } from './types.js'; +import { coveredByIgnore } from './observe.js'; export type FsOp = 'created' | 'modified' | 'deleted'; @@ -101,10 +102,20 @@ export class MountWatcher { } } - const ignored = this.config.ignore ?? []; + // Chokidar reads a string as one exact path, so the mount's patterns + // never matched here and every ignored path was synced on change. They + // go in as the walk's own rule instead: the path, or a directory above + // it, matches (agent-framework #280). Chokidar then also doesn't descend + // into an ignored directory. + const patterns = this.config.ignore ?? []; this.watcher = watch(this.config.path, { - ignored: ignored.length > 0 ? ignored : undefined, + ignored: patterns.length > 0 + ? (path: string) => { + const relative = this.toRelative(path); + return relative !== null && coveredByIgnore(relative, patterns); + } + : undefined, persistent: true, ignoreInitial: true, followSymlinks: this.config.followSymlinks ?? false, diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts new file mode 100644 index 000000000..a8165dd41 --- /dev/null +++ b/test/workspace-nested-mount-ignore.test.ts @@ -0,0 +1,137 @@ +/** + * A mount that contains another mount ignores it (agent-framework #280). + * + * The constructor's overlap guard adds an ignore rule to the outer mount for + * the inner mount's directory, and says so ("auto-ignoring to prevent + * overlap"). It pushed the bare relative path, which the walk's matcher reads + * only as an entry name: a mount nested two levels deep (`a/b`) was never + * ignored, so the outer mount synced the inner mount's files too, and a + * mount nested one level deep (`sub`) also hid every other directory named + * `sub`. The rule is now `/**`, which matches that path and what is + * under it, at any depth, and nothing else. + * + * The watcher read none of a mount's patterns: chokidar takes a string as one + * exact path. It now reads them as the walk does. + * + * Driven through the public sync tool, and a real watcher, over tmpdir mounts. + */ + +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { JsStore } from '@animalabs/chronicle'; +import { WorkspaceModule } from '../src/modules/workspace/index.js'; +import type { MountConfig } from '../src/modules/workspace/types.js'; +import { MountWatcher, type FsChange } from '../src/modules/workspace/watcher.js'; + +function workspaceWith(t: TestContext, files: string[], innerRel: string, outerIgnore?: string[]) { + const root = mkdtempSync(join(tmpdir(), 'af-nested-mount-')); + const outerDir = join(root, 'outer'); + for (const file of files) { + mkdirSync(dirname(join(outerDir, file)), { recursive: true }); + writeFileSync(join(outerDir, file), file); + } + mkdirSync(join(outerDir, innerRel), { recursive: true }); + const store = JsStore.openOrCreate({ path: join(root, 'workspace.chronicle') }); + const outer: MountConfig = { + name: 'outer', path: outerDir, mode: 'read-write', watch: 'never', + ...(outerIgnore ? { ignore: [...outerIgnore] } : {}), + }; + const warn = console.warn; + console.warn = () => {}; + let workspace: WorkspaceModule; + try { + workspace = new WorkspaceModule({ mounts: [ + outer, + { name: 'inner', path: join(outerDir, innerRel), mode: 'read-write', watch: 'never' }, + ] }); + } finally { console.warn = warn; } + workspace.initStore(store); + t.after(() => { + store.close(); + rmSync(root, { recursive: true, force: true }); + }); + return { workspace, outer }; +} + +async function syncedByOuter(workspace: WorkspaceModule): Promise { + const result = await workspace.handleToolCall({ id: 'sync-outer', name: 'sync', input: { mount: 'outer' } }); + assert.equal(result.success, true, `sync should succeed: ${result.error ?? ''}`); + const data = result.data as { results: Array<{ mount: string; synced: string[] }> }; + return [...data.results.find((r) => r.mount === 'outer')!.synced].sort(); +} + +test('a mount nested two levels deep is left out of the outer mount', async (t) => { + const { workspace, outer } = workspaceWith(t, ['top.txt', 'a/b/x.txt', 'a/other.txt'], 'a/b'); + assert.deepEqual(outer.ignore, ['a/b/**']); + assert.deepEqual(await syncedByOuter(workspace), ['a/other.txt', 'top.txt'], + "the inner mount's file is the inner mount's alone; its sibling is still the outer's"); +}); + +test("a mount nested one level deep hides only itself, not every directory with its name", async (t) => { + const { workspace, outer } = workspaceWith(t, ['sub/z.txt', 'deep/sub/y.txt', 'top.txt'], 'sub'); + assert.deepEqual(outer.ignore, ['sub/**']); + assert.deepEqual(await syncedByOuter(workspace), ['deep/sub/y.txt', 'top.txt']); +}); + +test("an outer mount's own bare path doesn't stop the rule that works; its own /** form isn't repeated", async (t) => { + const bare = workspaceWith(t, ['top.txt', 'a/b/x.txt'], 'a/b', ['a/b']); + assert.deepEqual(bare.outer.ignore, ['a/b', 'a/b/**']); + assert.deepEqual(await syncedByOuter(bare.workspace), ['top.txt']); + + const glob = workspaceWith(t, ['top.txt', 'a/b/x.txt'], 'a/b', ['a/b/**']); + assert.deepEqual(glob.outer.ignore, ['a/b/**']); + assert.deepEqual(await syncedByOuter(glob.workspace), ['top.txt']); +}); + +test("a mount nested in a directory whose name starts with '..' is still nested", async (t) => { + const { workspace, outer } = workspaceWith(t, ['top.txt', '..cache/x.txt'], '..cache'); + assert.deepEqual(outer.ignore, ['..cache/**']); + assert.deepEqual(await syncedByOuter(workspace), ['top.txt']); +}); + +test("a watched mount's changes pass over what it ignores, a nested mount included", async (t) => { + const root = mkdtempSync(join(tmpdir(), 'af-nested-mount-watch-')); + const outerDir = join(root, 'outer'); + mkdirSync(join(outerDir, 'a', 'b'), { recursive: true }); + const outer: MountConfig = { + name: 'outer', path: outerDir, mode: 'read-write', watch: 'always', ignore: ['node_modules'], + watchDebounceMs: 50, watchRootPollMs: 50, + }; + const warn = console.warn; + console.warn = () => {}; + try { + new WorkspaceModule({ mounts: [ + outer, + { name: 'inner', path: join(outerDir, 'a', 'b'), mode: 'read-write', watch: 'never' }, + ] }); + } finally { console.warn = warn; } + assert.deepEqual(outer.ignore, ['node_modules', 'a/b/**']); + + const seen: FsChange[] = []; + let ready!: () => void; + const isReady = new Promise((resolve) => { ready = resolve; }); + const watcher = new MountWatcher(outer, (changes) => { seen.push(...changes); }, { onReady: () => ready() }); + watcher.start(); + t.after(async () => { + await watcher.stop(); + rmSync(root, { recursive: true, force: true }); + }); + await isReady; + + mkdirSync(join(outerDir, 'node_modules', 'pkg'), { recursive: true }); + writeFileSync(join(outerDir, 'node_modules', 'pkg', 'index.js'), 'ignored'); + writeFileSync(join(outerDir, 'a', 'b', 'x.txt'), 'the inner mount\'s'); + writeFileSync(join(outerDir, 'a', 'other.txt'), 'the outer mount\'s'); + writeFileSync(join(outerDir, 'later.txt'), 'last'); + for (let i = 0; i < 200 && !seen.some((c) => c.path === 'later.txt'); i++) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + const paths = seen.map((c) => c.path).sort(); + assert.ok(paths.includes('later.txt'), `the last write was seen: ${JSON.stringify(paths)}`); + assert.ok(paths.includes('a/other.txt'), 'a sibling of the nested mount is still watched'); + assert.deepEqual(paths.filter((p) => p.startsWith('a/b') || p.startsWith('node_modules')), [], + 'nothing the mount ignores was reported'); +}); From f16f532c9f00af8e0bce6847a54d1d34139803cf Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Sat, 10 Oct 2026 16:07:05 -0400 Subject: [PATCH 02/26] test(workspace): the watcher test waits for each write the mount reports, and each ignored write has a twin (#280) Greptile (P2, review of 74148c7): the watched-mount test stopped waiting once later.txt was seen, but a/other.txt is in another directory. Chokidar 4 gives each directory its own fs.watch and reads a directory to find a new file (handler.js _handleDir -> _watchWithNodeFs, _handleRead), so the two can arrive in either order, and the a/other.txt assertion could fail on a slow runner with the watcher working. The loop now waits for every path the mount reports, with the same 5 s outer bound. Basil-1007 pointed out the other side of that ordering: under a regression, the ignored writes are found after the paths the loop waited for (chokidar finds a/b in the read of a/ that emits a/other.txt, then reads a/b), so on a slow runner "nothing the mount ignores was reported" could pass with the bug back. Each ignored write now has a reported twin, written after it and reported by the same machinery: - a/x.log (ignored by a new '*.log' pattern) and a/other.txt: one directory; - a/b/x.txt and a/c/y.txt: a/c made beside a/b before the watcher starts; - node_modules/pkg/index.js and pkgs/pkg/index.js: made the same way, after the watcher starts. The loop waits for all four reported paths. This narrows the timing the negative assertion relies on rather than proving an order, in one directory as across them: MountWatcher's awaitWriteFinish (stabilityThreshold 100, pollInterval 50) holds each file's add on its own timer until its size is stable (Basil's correction to his own first reading). Test-only. At 7c3ae0a (this text aside) the file passes 5/5, five runs of five, and sixteen runs of sixteen in parallel. With the watcher's ignore function removed (`Date.now() < 0 &&` before it), the test fails at "nothing the mount ignores was reported" with a/b/x.txt, a/x.log and node_modules/pkg/index.js reported: three runs of three, and sixteen of sixteen in parallel. --- test/workspace-nested-mount-ignore.test.ts | 26 ++++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index a8165dd41..fc530ed09 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -96,8 +96,9 @@ test("a watched mount's changes pass over what it ignores, a nested mount includ const root = mkdtempSync(join(tmpdir(), 'af-nested-mount-watch-')); const outerDir = join(root, 'outer'); mkdirSync(join(outerDir, 'a', 'b'), { recursive: true }); + mkdirSync(join(outerDir, 'a', 'c')); const outer: MountConfig = { - name: 'outer', path: outerDir, mode: 'read-write', watch: 'always', ignore: ['node_modules'], + name: 'outer', path: outerDir, mode: 'read-write', watch: 'always', ignore: ['node_modules', '*.log'], watchDebounceMs: 50, watchRootPollMs: 50, }; const warn = console.warn; @@ -108,7 +109,7 @@ test("a watched mount's changes pass over what it ignores, a nested mount includ { name: 'inner', path: join(outerDir, 'a', 'b'), mode: 'read-write', watch: 'never' }, ] }); } finally { console.warn = warn; } - assert.deepEqual(outer.ignore, ['node_modules', 'a/b/**']); + assert.deepEqual(outer.ignore, ['node_modules', '*.log', 'a/b/**']); const seen: FsChange[] = []; let ready!: () => void; @@ -121,17 +122,28 @@ test("a watched mount's changes pass over what it ignores, a nested mount includ }); await isReady; + // Each ignored write has a twin the mount reports, written after it and + // reported by the same machinery, though not strictly after it: the same + // directory for the name pattern, and the same depth for the two ignored + // directories. Each directory has its own watch, and each file's report + // waits for its size to settle, so reported writes can arrive in any order; + // waiting for every twin gives each ignored write the time it would take to + // be reported. mkdirSync(join(outerDir, 'node_modules', 'pkg'), { recursive: true }); writeFileSync(join(outerDir, 'node_modules', 'pkg', 'index.js'), 'ignored'); + mkdirSync(join(outerDir, 'pkgs', 'pkg'), { recursive: true }); + writeFileSync(join(outerDir, 'pkgs', 'pkg', 'index.js'), 'its twin'); writeFileSync(join(outerDir, 'a', 'b', 'x.txt'), 'the inner mount\'s'); - writeFileSync(join(outerDir, 'a', 'other.txt'), 'the outer mount\'s'); + writeFileSync(join(outerDir, 'a', 'c', 'y.txt'), 'its twin'); + writeFileSync(join(outerDir, 'a', 'x.log'), 'ignored by its name'); + writeFileSync(join(outerDir, 'a', 'other.txt'), 'its twin'); writeFileSync(join(outerDir, 'later.txt'), 'last'); - for (let i = 0; i < 200 && !seen.some((c) => c.path === 'later.txt'); i++) { + const reported = ['pkgs/pkg/index.js', 'a/c/y.txt', 'a/other.txt', 'later.txt']; + for (let i = 0; i < 200 && !reported.every((path) => seen.some((c) => c.path === path)); i++) { await new Promise((resolve) => setTimeout(resolve, 25)); } const paths = seen.map((c) => c.path).sort(); - assert.ok(paths.includes('later.txt'), `the last write was seen: ${JSON.stringify(paths)}`); - assert.ok(paths.includes('a/other.txt'), 'a sibling of the nested mount is still watched'); - assert.deepEqual(paths.filter((p) => p.startsWith('a/b') || p.startsWith('node_modules')), [], + for (const path of reported) assert.ok(paths.includes(path), `${path} was seen: ${JSON.stringify(paths)}`); + assert.deepEqual(paths.filter((p) => p.startsWith('a/b') || p.startsWith('node_modules') || p.endsWith('.log')), [], 'nothing the mount ignores was reported'); }); From b1b9fa582b5edac792651f53d6be0aebd86d6ebc Mon Sep 17 00:00:00 2001 From: Olive-1011 Date: Sun, 11 Oct 2026 15:30:31 -0400 Subject: [PATCH 03/26] fix(workspace): a mount never takes in or writes out what lies in a mount nested in it, whatever path reaches it (sw-af282) The review round for #282 (sw-af282, the haiku review of 0354132..e21a41d; Olive-1011's verdicts in the run's ledger: 18 fix-now, 2 not-worth), built for Ada-1017 on her restack onto #278's e9eacdd (2897072). Ada agreed the design in room-203 #72169/#72176: the invariant #280 asks for is that a mount never takes in or writes out what lies in a mount nested in it. After db6a457 the walk and the watcher kept it, and four routes still broke it: 1. Another spelling of the same directory (I3, I9): the constructor's overlap guard compared lexical paths, so a mount configured through a symlink, or in another case on a case-insensitive filesystem, wasn't seen as nested and got no rule. Nesting is now read from canonical roots (realpathSync.native, falling back to the configured path for a root that doesn't exist yet), and the rule is spelled as disk spells it. The decision itself is observe.ts nestedAt(outerRoot, innerRoot, path), pure and given the platform's path module, so path.win32 tests the separator, `..` and other-drive branches POSIX CI can't reach (I15, I10's second half). 2. The outer mount's tools (your #278 interaction, and I14's writes): since #278's round a sync that names a covered directory walks it, so `sync outer/a/b` took the inner mount's files into the outer one, and `write outer/a/b/x.txt` wrote into the inner's directory. parsePath, which every path tool goes through, now refuses a path inside a nested mount, naming the mount to use ("is inside mount "inner", which is nested in "outer" at a/b/: use "inner/x.txt""): read, write, edit, ls, glob, grep (Ada's call: refused like the others), and a sync or materialize that names it. The one exception is delete of a file the outer mount holds there (a copy the old walk took in): it stops tracking it (tree entry, branch intent, disk agreement) and never touches disk. 3. Materialize without a path (I14): an owed outer entry inside a nested mount is passed over before the push and listed under skipped, "inside mount "inner", which is nested in this one; delete it here to stop tracking it". Without this, the push reached the containment refusal of point 4 and advised `force`, which wouldn't help. 4. An alias (I2): with followSymlinks, a symlink inside the outer mount into the inner's directory reached its files under the alias's path. Each mount's view now carries its nested mounts' canonical roots (MountView.nested, computed per pass), and every containment check refuses a canonical path inside one (observe.ts insideNested at locateDir, parentPlacement, observePath's followed symlink and openContained; effects.ts writeContained and unlinkContained), as it refuses one outside the mount. Also: - I7: a trailing-slash pattern (`node_modules/`) or one with a slash (`build/cache`) matched nothing in the walk or the watcher, so #280's symptom stayed for those spellings. isIgnored now drops a trailing slash, so `node_modules/` reads as `node_modules` (gitignore would match only a directory so named; here a file so named is passed over too, as Ada-1017 pointed out), and anchors a slash path at the mount root (with everything under it, since an ignored directory is passed over whole). types.ts lists the forms. - I11: parsePath reads every path in one spelling (`a/./b`, `a//b`, `a/b/` are `a/b`), so the explicit-path ignore note, the store and the evidence key it alike. - I20: the overlap warning prints the '/'-joined path. I12: comments and the test header no longer say `/**` matches at any depth; it matches that path from the outer mount's root. - The fragment: I6 (incomplete lists the ignored entry itself, the file or the ignored directory above it), I8 (only without autoMaterialize does a workspace delete leave the listing), I16 (a named sync still takes disk's copy), the patterns, the four routes, the one spelling, and #278's owed sentence: a sync without a path counts the nested mount's directory under passedOver (Ada's known verdicts I6/I10 on sw-af278). Tests (test/workspace-nested-mount-ignore.test.ts, 13, was 5): - the watched-mount test: a 10 s bound on `ready` and the watcher's errors failing it (I5); a nested bare name below the root, a trailing-slash name and a slash path, each with a reported twin (I19, I7); a 600 ms settle window after the twins, longer than a report's own delays, so an ignored write reported after its twin is still seen (I1); the negative filter matching each ignored path or what is under it, not a prefix (I18); - trailing-slash and slash-path patterns in a sync (I7); - a parent and a sibling mount not nested, the parent holding both (I4); - nestedAt under path.win32 and POSIX (I15); - an inner mount configured through a symlink, under an outer one configured through another (I3, I10); - passedOver counting the nested directory (#278's sentence); - the outer mount's tools refusing nine calls into the nested mount, naming it; a bare materialize passing over an old owed draft there with the inner's file untouched; delete stopping tracking it and leaving disk; a second delete refused; a later materialize no longer listing it, which pins that the delete removes the intent rather than tombstoning it (Ada-1017's ask); the inner mount reading its own file (points 2, 3); - an alias under a followSymlinks, autoMaterialize outer mount: a named sync through it takes nothing in, and a write through it is refused, with nothing written in the inner's directory (point 4, I2); - a path with '.' segments and a doubled slash read as its one spelling, and noted under ignored (I11). I9's case can't be tested on CI's case-sensitive filesystems: it rests on realpathSync.native returning disk's spelling there. Runs, on a clean worktree (node_modules from Ada's af-deps, the dependencies #282's package.json names): at 34965174, this commit's first version, whose code this one keeps (only a fragment line, two comments and a test assertion changed since): tsc --noEmit clean, the build clean; node --test --test-force-exit dist/test/*.test.js passes 1480/1480 on Node 24.19.0 and 1484/1484 on Node 20.20.2 (each runner's own count). At this commit: tsc and the build clean, and test/workspace-nested-mount-ignore.test.ts passes 13/13 on Node 24.19.0 and on Node 20.20.2. Red checks (~/.local/state/olive-1011/af282-redchecks.py), each undoing one fix and each failing the nested-mount suite, 11 of 11 (plus, by hand, a delete that tombstones instead of forgetting, failing the new materialize assertion): no containment refusal inside a nested mount; nesting from configured paths; the parent counted as nested; another drive counted as nested; a trailing slash read literally; a slash path not anchored; paths kept as written; the tools reaching into a nested mount; an old copy deleted like any file; materialize pushing old copies; the watcher ignoring nothing. Not done, by verdict: I13 and I17 not-worth (2897072's and db6a457's commit texts, which a review round can't change without moving reviewed hashes; the code's comments say what reads coveredByIgnore, and this commit's runs are its own). --- changelog.d/nested-mount-ignore.fixed.md | 75 +++++-- src/modules/workspace/effects.ts | 5 + src/modules/workspace/index.ts | 147 ++++++++++--- src/modules/workspace/observe.ts | 65 +++++- src/modules/workspace/types.ts | 10 +- test/workspace-nested-mount-ignore.test.ts | 229 +++++++++++++++++++-- 6 files changed, 456 insertions(+), 75 deletions(-) diff --git a/changelog.d/nested-mount-ignore.fixed.md b/changelog.d/nested-mount-ignore.fixed.md index 051c8720b..9411d31ad 100644 --- a/changelog.d/nested-mount-ignore.fixed.md +++ b/changelog.d/nested-mount-ignore.fixed.md @@ -1,25 +1,60 @@ -- A mount's ignore patterns now apply to its watcher, and a mount that - contains another mount leaves it out at any depth. +- A mount never takes in or writes out what lies in a mount nested in it, and + a mount's ignore patterns now apply to its watcher. - The watcher handed the patterns to chokidar as strings, which chokidar takes as exact paths, so on a watched mount (`watch` defaults to `'always'`) every change under an ignored path, `node_modules` included, was synced. The watcher now applies the walk's own rule: a path is passed over when it or a directory above it matches. Chokidar also no longer - descends into ignored directories. Files the old watcher already took in - from under an ignored path stay tracked and stop updating. Each bare sync - lists them under `skipped` ("disk could not be checked: ignored by the - mount"), with their directory under `incomplete`, for as long as disk - can't show them gone. For a file the pattern matches itself, such as - `app.log` under `*.log`, that's until the file is removed. For a file - inside an ignored directory, such as `node_modules/`, it's until that - directory is removed, since the walk doesn't look inside it. Deleting - such a file from the workspace doesn't end the listing either, and there - is no way yet to stop tracking a file and keep it on disk. - - The overlap guard said it was "auto-ignoring" the inner mount's directory, - but it added the bare relative path, which the walk reads only as an - entry name: a mount nested two or more levels deep (`a/b`) was still - synced by the outer mount, and a mount nested one level deep (`sub`) also - hid every other directory named `sub`. It now adds `a/b/**`, which - matches that path and what is under it, and nothing else. An inner mount - in a directory whose name starts with `..` (such as `..cache`) is now - recognized as nested (agent-framework #280). + descends into ignored directories. + - Files the old watcher already took in from under an ignored path stay + tracked, and the watcher and a sync without a path no longer update them. + A sync that names such a path still takes disk's copy, and says the + mount ignores it. Each sync without a path lists them under `skipped` + ("disk could not be checked: ignored by the mount"). It also lists, under + `incomplete`, the ignored entry that hides them: the file itself for a + pattern it matches, such as `app.log` under `*.log`, or the ignored + directory above it, such as `node_modules`. That lasts as long as disk + can't show them gone. For a file the pattern matches itself, that's until + the file is removed. For a file inside an ignored directory, it's until + that directory is removed, since the walk doesn't look inside it. On a + mount without `autoMaterialize`, deleting such a file from the workspace + doesn't end the listing either. On one with it, the delete removes the + disk copy, which ends it. There is no way yet to stop tracking a file and + keep it on disk, except inside a nested mount (below). + - A pattern with a trailing slash (`node_modules/`, `build/cache/`) or a + slash inside it (`build/cache`) matched nothing. Now a trailing slash is + dropped, so `node_modules/` reads as `node_modules` (gitignore would + match only a directory so named; here a file so named is passed over + too). A pattern with a slash is a path from the mount root, with + everything under it. + - The overlap guard said it was "auto-ignoring" the inner mount's + directory, but it added the bare relative path, which the walk reads only + as an entry name. A mount nested two or more levels deep (`a/b`) was + still synced by the outer mount, and a mount nested one level deep + (`sub`) also hid every other directory named `sub`. It now adds `a/b/**`, + which matches that path from the outer mount's root and what is under it, + and nothing else. A sync without a path counts the inner mount's + directory under `passedOver`, as it counts any ignored directory. + - Nesting is read from the mounts' canonical roots. A mount configured + through a symlink, or spelled in another case on a case-insensitive + filesystem, is still nested, and the rule is spelled as disk spells it. + An inner mount in a directory whose name starts with `..` (such as + `..cache`) is now recognized as nested. + - The outer mount's tools refuse a path inside a nested mount, naming the + mount to use: `outer/a/b/x.txt` is "inside mount "inner", which is nested + in "outer" at a/b/: use "inner/x.txt"". That covers `read`, `write`, + `edit`, `ls`, `glob`, `grep`, and a `sync` or `materialize` that names the + path. Before, a named sync took the inner mount's files into the outer + one. + - The one exception is `delete` of a file the outer mount still holds + there, taken in before this fix. It stops tracking the file in the + outer mount and leaves disk alone, where the file is the inner mount's. + - A `materialize` without a path never writes or deletes such a file. It + lists the file under `skipped`, saying to delete it to stop tracking it. + - Every check that a canonical path lies inside the mount, when reading, + walking or writing, also refuses one inside a nested mount. So a symlink + that a `followSymlinks` mount follows into a nested mount reaches nothing + there, and a write through it is refused. + - Every workspace tool reads a path in one spelling: `a/./b`, `a//b` and + `a/b/` are `a/b`. + - (agent-framework #280) diff --git a/src/modules/workspace/effects.ts b/src/modules/workspace/effects.ts index ea2cd6022..c0934a5d5 100644 --- a/src/modules/workspace/effects.ts +++ b/src/modules/workspace/effects.ts @@ -44,6 +44,7 @@ import { contained, errno, checkDir, + insideNested, locateRoot, nameOf, parentOf, @@ -181,6 +182,8 @@ export async function writeContained(view: MountView, rootReal: string, rel: str const real = await realpath(target).catch(() => null); if (real === null) throw new EffectFailed(CHANGED, touched); if (!contained(rootReal, real)) throw new EffectFailed(view.followSymlinks ? 'a symlink that leaves the mount' : OUTSIDE_PARENT, touched); + const nested = insideNested(view, real); + if (nested) throw new EffectFailed(nested, touched); const named = await stat(real, { bigint: true }).catch(() => null); if (named === null || named.dev !== info.dev || named.ino !== info.ino) throw new EffectFailed(CHANGED, touched); if (info.nlink > 1n) throw new EffectFailed(HARD_LINKED, touched); @@ -234,6 +237,8 @@ export async function unlinkContained(view: MountView, rootReal: string, rel: st if (!view.followSymlinks) throw new EffectFailed(NOT_FOLLOWED, false); const real = await realpath(target).catch(() => null); if (real === null || !contained(rootReal, real)) throw new EffectFailed('a symlink that leaves the mount', false); + const nested = insideNested(view, real); + if (nested) throw new EffectFailed(nested, false); const through = await stat(real, { bigint: true }).catch(() => null); if (through === null) throw new EffectFailed(CHANGED, false); id = { dev: through.dev, ino: through.ino }; diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index b7c85fe1d..330971b0d 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -5,10 +5,10 @@ * auto-sync between real filesystem and Chronicle, and manual materialization. */ -import { constants as fsConstants } from 'node:fs'; +import { constants as fsConstants, realpathSync } from 'node:fs'; import type { Stats } from 'node:fs'; import { open, readFile, stat, lstat, realpath } from 'node:fs/promises'; -import { isAbsolute, join, resolve, relative, sep } from 'node:path'; +import { join, resolve, relative, sep } from 'node:path'; import type { JsStore } from '@animalabs/chronicle'; import type { Module, ModuleContext, ProcessState, EventResponse } from '../../types/module.js'; import type { ProcessEvent, ToolDefinition, ToolCall, ToolResult } from '../../types/events.js'; @@ -38,7 +38,7 @@ import { MountWatcher, type FsChange } from './watcher.js'; import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js'; import { DiskAgreement, sameRootIdentity } from './disk-agreement.js'; import { BranchIntents } from './branch-intent.js'; -import { coveredByIgnore, filesystemTrustsCtime, locateRoot } from './observe.js'; +import { contained, coveredByIgnore, filesystemTrustsCtime, locateRoot, nestedAt } from './observe.js'; import { TOOL_BATCH_HOOK_TIMEOUT_MS } from '../../module-registry.js'; import { type MountRuntime, @@ -204,6 +204,16 @@ function matchesPartialPrefix(buffer: Buffer, prefix: Buffer): boolean { return buffer.length > 0 && prefix.subarray(0, buffer.length).equals(buffer); } +/** A mount root as disk spells it: its canonical path, or as configured while it doesn't exist. */ +function canonicalRoot(path: string): string { + const configured = resolve(path); + try { + return realpathSync.native(configured); + } catch { + return configured; + } +} + function isContainedPath(root: string, candidate: string): boolean { if (candidate === root) return true; const normalizedRoot = root.endsWith(sep) ? root : root + sep; @@ -627,6 +637,8 @@ export class WorkspaceModule implements Module { private store: JsStore | null = null; private config: WorkspaceConfig; private mounts = new Map(); + /** Per mount, the mounts nested in it and where, '/'-joined (agent-framework #280). */ + private nestedIn = new Map>(); private watchers = new Map(); /** What disk last agreed with (global records); created with the store. */ private agreement: DiskAgreement | null = null; @@ -646,38 +658,44 @@ export class WorkspaceModule implements Module { private savedState: WorkspaceModuleState | null = null; constructor(config: WorkspaceConfig) { - // Detect overlapping mount paths: if mount A contains mount B, - // auto-add an ignore rule on A for B's path to prevent syncing - // the sub-mount's directory through the super-mount. + // A mount never takes in or writes out what lies in a mount nested in it + // (agent-framework #280). Nesting is read from canonical roots, so a + // symlinked or differently cased spelling of the same directory still + // nests, and the rule is spelled as disk spells the path. The outer + // mount's walk and watcher pass over the inner's directory by an ignore + // rule; its tools refuse paths there (parsePath), and every containment + // check refuses what reaches the inner's directory another way + // (MountView.nested). + const roots = new Map(config.mounts.map((m) => [m, canonicalRoot(m.path)])); for (const outer of config.mounts) { for (const inner of config.mounts) { if (outer === inner) continue; - const outerPath = resolve(outer.path); - const innerPath = resolve(inner.path); - const rel = relative(outerPath, innerPath); - // Nested: a relative path that doesn't climb out (`..` exactly, or - // `../…`; a name like `..cache` is a child) and isn't another root - // (`relative()` returns `D:\…` across Windows drives). - if (rel && rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel)) { - // inner is nested under outer — add ignore rule. Only the `/**` - // form matches a path at any depth, and only that path: a bare - // `a/b` never matches, and a bare `sub` matches every directory - // named sub (agent-framework #280). - outer.ignore = outer.ignore ?? []; - const pattern = rel.split(sep).join('/') + '/**'; - if (!outer.ignore.includes(pattern)) { - outer.ignore.push(pattern); - console.warn( - `[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` + - `(${rel}/) — auto-ignoring to prevent overlap`, - ); - } + const at = nestedAt(roots.get(outer)!, roots.get(inner)!); + if (at === null) continue; + const nested = this.nestedIn.get(outer.name) ?? []; + nested.push({ name: inner.name, at }); + this.nestedIn.set(outer.name, nested); + // The `/**` form matches that path and what is under it, and nothing + // else: a bare `sub` would match every entry named sub. + outer.ignore = outer.ignore ?? []; + const pattern = `${at}/**`; + if (!outer.ignore.includes(pattern)) { + outer.ignore.push(pattern); + console.warn( + `[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` + + `(${at}/) — auto-ignoring to prevent overlap`, + ); } } } this.config = config; } + /** The mount nested in `mount` that a mount-relative path lies in, if any. */ + private nestedMountAt(mount: MountState, relativePath: string): { name: string; at: string } | undefined { + return this.nestedIn.get(mount.config.name)?.find((n) => relativePath === n.at || relativePath.startsWith(n.at + '/')); + } + /** * Inject the Chronicle store. Must be called after framework creation. * @@ -766,6 +784,16 @@ export class WorkspaceModule implements Module { trustedDev = dev !== undefined && (await filesystemTrustsCtime(rootReal)) ? dev.toString() : null; this.ctimeTrust.set(rootReal, trustedDev); } + // Canonical now, whatever they were when the module was built: the + // containment checks compare canonical paths. + const nested: Array<{ name: string; real: string }> = []; + for (const n of this.nestedIn.get(mount.config.name) ?? []) { + const inner = this.mounts.get(n.name); + if (!inner) continue; + const innerRoot = resolve(inner.config.path); + const real = await realpath(innerRoot).catch(() => innerRoot); + if (real !== rootReal && contained(rootReal, real)) nested.push({ name: n.name, real }); + } return { name: mount.config.name, view: { @@ -774,6 +802,7 @@ export class WorkspaceModule implements Module { followSymlinks: mount.config.followSymlinks === true, maxFileSize: mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE, ignore: mount.config.ignore ?? [], + nested, }, rootReal, ctimeTrustedDev: trustedDev, @@ -1519,10 +1548,10 @@ export class WorkspaceModule implements Module { /** * Parse a mount-prefixed path into (mountName, relativePath). */ - private parsePath(path: string): { mount: MountState; relativePath: string } { + private parsePath(path: string, opts: { nested?: 'allow' } = {}): { mount: MountState; relativePath: string } { const slashIdx = path.indexOf('/'); const mountName = slashIdx >= 0 ? path.slice(0, slashIdx) : path; - const relativePath = slashIdx >= 0 ? path.slice(slashIdx + 1) : ''; + const written = slashIdx >= 0 ? path.slice(slashIdx + 1) : ''; const mount = this.mounts.get(mountName); if (!mount) { @@ -1533,10 +1562,25 @@ export class WorkspaceModule implements Module { // Containment must compare with the PLATFORM separator (isContainedPath): // resolve() emits backslashes on Windows, so a '/'-suffixed root never // prefix-matches there and every in-mount path was rejected as traversal. - const resolved = resolve(mount.config.path, relativePath); + const root = resolve(mount.config.path); + const resolved = resolve(root, written); if (!isContainedPath(mount.config.path, resolved)) { throw new Error(`Path traversal detected: "${path}" resolves outside mount "${mountName}"`); } + // One spelling per path: `a/./b`, `a//b` and `a/b/` are `a/b`, as the + // store, the evidence and the ignore list key it (#282's round). + const relativePath = relative(root, resolved).split(sep).join('/'); + + // What lies in a mount nested in this one is that mount's: its tools + // answer for it there (agent-framework #280). + const inner = this.nestedMountAt(mount, relativePath); + if (inner && opts.nested !== 'allow') { + const rest = relativePath.slice(inner.at.length + 1); + throw new Error( + `"${path}" is inside mount "${inner.name}", which is nested in "${mountName}" at ${inner.at}/: ` + + `use "${rest ? `${inner.name}/${rest}` : inner.name}"`, + ); + } return { mount, relativePath }; } @@ -2238,12 +2282,41 @@ export class WorkspaceModule implements Module { } private async handleDelete(input: DeleteInput): Promise { - const { mount, relativePath } = this.parsePath(input.path); + const { mount, relativePath } = this.parsePath(input.path, { nested: 'allow' }); if (mount.config.mode === 'read-only') { return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true }; } const store = this.getStore(); + const inner = this.nestedMountAt(mount, relativePath); + if (inner) { + // A copy the outer mount took in from a nested mount's directory before + // #280's fix: deleting it stops tracking it here, and never touches + // disk, where the file is the inner mount's. Anything else at such a + // path is refused, as every other tool refuses it. + return this.withMount(mount, async (): Promise => { + if (!store.treeGet(mount.treeStateId, relativePath)) { + const rest = relativePath.slice(inner.at.length + 1); + return { + success: false, + error: `"${input.path}" is inside mount "${inner.name}", which is nested in "${mount.config.name}" at ${inner.at}/, ` + + `and "${mount.config.name}" doesn't hold it: use "${rest ? `${inner.name}/${rest}` : inner.name}"`, + isError: true, + }; + } + store.treeRemove(mount.treeStateId, relativePath); + this.intents.get(mount.config.name)!.put(relativePath, null); + this.agreementOrThrow().forget(mount.config.name, relativePath); + return { + success: true, + data: { + path: input.path, + deleted: true, + note: `inside mount "${inner.name}": no longer tracked by "${mount.config.name}"; the file on disk is "${inner.name}"'s and was left as it is`, + }, + }; + }); + } return this.withMount(mount, async (): Promise => { const report = await this.ensureSynced(mount, relativePath); await this.settleBeforeMutation(mount, relativePath); @@ -2606,16 +2679,22 @@ export class WorkspaceModule implements Module { for (const { name, mount } of mountsToMaterialize) { if (mount.config.mode === 'read-only') continue; - const turn = await this.withMount(mount, async (): Promise<{ blocked: string } | { pushed: PushResult; inPlace: boolean }> => { + const turn = await this.withMount(mount, async (): Promise<{ blocked: string } | { pushed: PushResult; inPlace: boolean; nested: string[] }> => { const branchId = store.currentBranch().id; const reason = this.mountMaterializeBlockReason(store, mount); // Force passes a divergence. The selection follows the evidence, which // is per path and kept across branches, so nothing needs resetting: // the push re-pins to this branch. if (reason && !input.force) return { blocked: reason }; - const { paths, inPlace } = this.materializeSelection(mount, explicit?.relativePath ?? ''); + const { paths: selected, inPlace } = this.materializeSelection(mount, explicit?.relativePath ?? ''); + // What the outer mount still holds in a nested mount's directory (taken + // in before #280's fix) is never written there, nor deleted there: + // the files are the inner mount's (agent-framework #280). + const nested = selected.filter((path) => this.nestedMountAt(mount, path)); + const paths = nested.length > 0 ? selected.filter((path) => !this.nestedMountAt(mount, path)) : selected; return { inPlace, + nested, pushed: await this.pushUnlocked(mount, paths, { force: input.force, named: explicit !== null, applyDeletions: input.applyDeletions, branchId, }), @@ -2637,6 +2716,10 @@ export class WorkspaceModule implements Module { for (const s of pushed.skipped) { blocked.push({ mount: name, reason: `${s.path}: ${s.reason}` }); } + for (const path of turn.nested) { + const inner = this.nestedMountAt(mount, path)!; + blocked.push({ mount: name, reason: `${path}: inside mount "${inner.name}", which is nested in this one; delete it here to stop tracking it` }); + } // What disk still owes is kept by its evidence, so the watermark and // the pin only feed the branch guard. Both are the push's own: what it diff --git a/src/modules/workspace/observe.ts b/src/modules/workspace/observe.ts index 5cfccd8a8..ccb9469ea 100644 --- a/src/modules/workspace/observe.ts +++ b/src/modules/workspace/observe.ts @@ -41,7 +41,7 @@ import { constants as fsConstants } from 'node:fs'; import type { BigIntStats } from 'node:fs'; import { lstat, mkdir, open, readdir, realpath, stat, statfs } from 'node:fs/promises'; import type { FileHandle } from 'node:fs/promises'; -import { dirname, join, sep } from 'node:path'; +import { dirname, isAbsolute, join, relative, sep } from 'node:path'; import { sameRootIdentity, type Fingerprint, type RootIdentity } from './disk-agreement.js'; export interface MountView { @@ -56,6 +56,13 @@ export interface MountView { /** Text above this size is not ingested. */ maxFileSize: number; ignore: string[]; + /** + * The canonical roots of the mounts nested in this one. A mount never + * reads or writes what lies in a mount nested in it, whatever path reaches + * it (agent-framework #280): every containment check refuses a canonical + * path inside one of these, as it refuses one outside the mount. + */ + nested?: ReadonlyArray<{ name: string; real: string }>; } export type StatFingerprint = Omit; @@ -102,6 +109,12 @@ export const RACY_WINDOW_MS = 2000; export const OUTSIDE_PARENT = 'a parent directory resolves outside the mount'; export const NOT_FOLLOWED = 'a symlink, which this mount does not follow'; + +/** Why a canonical path inside the mount is still not the mount's: it lies in a mount nested in it. */ +export function insideNested(view: MountView, real: string): string | null { + const inner = view.nested?.find((n) => contained(n.real, real)); + return inner ? `inside mount "${inner.name}", which is nested in this one` : null; +} export const ROOT_UNAVAILABLE = 'the mount root is unavailable'; export const ROOT_REPLACED = 'the mount root is not the directory disk last agreed with (an unmounted drive, or a replaced directory): ' + @@ -235,6 +248,9 @@ async function locateDir(view: MountView, rootReal: string, rel: string): Promis if (real !== rootReal) return { kind: 'unobserved', reason: ROOT_UNAVAILABLE }; } else if (!contained(rootReal, real)) { return { kind: 'unobserved', reason: OUTSIDE_PARENT, outside: true }; + } else { + const nested = insideNested(view, real); + if (nested) return { kind: 'unobserved', reason: nested }; } const checked = await checkedAt(view, rel, real); if (checked) { @@ -432,10 +448,12 @@ export async function filesystemTrustsCtime(root: string): Promise { * (absence is then proven separately), or somewhere a no-follow open would * still reach through — O_NOFOLLOW guards only the final component. */ -async function parentPlacement(rootReal: string, lexical: string): Promise<'inside' | 'gone' | { reason: string }> { +async function parentPlacement(view: MountView, rootReal: string, lexical: string): Promise<'inside' | 'gone' | { reason: string }> { try { const parent = await realpath(dirname(lexical)); - return contained(rootReal, parent) ? 'inside' : { reason: OUTSIDE_PARENT }; + if (!contained(rootReal, parent)) return { reason: OUTSIDE_PARENT }; + const nested = insideNested(view, parent); + return nested ? { reason: nested } : 'inside'; } catch (err) { const code = errno(err); if (code === 'ENOENT' || code === 'ENOTDIR') return 'gone'; @@ -451,7 +469,7 @@ async function parentPlacement(rootReal: string, lexical: string): Promise<'insi */ export async function observePath(view: MountView, rootReal: string, relativePath: string): Promise { const lexical = join(view.root, relativePath); - const placement = await parentPlacement(rootReal, lexical); + const placement = await parentPlacement(view, rootReal, lexical); if (placement === 'gone') return absence(view, rootReal, relativePath); if (placement !== 'inside') return { kind: 'unobserved', reason: placement.reason }; let info; @@ -477,6 +495,8 @@ export async function observePath(view: MountView, rootReal: string, relativePat return { kind: 'unobserved', reason: `a symlink that cannot be resolved (${errno(err) ?? String(err)})` }; } if (!contained(rootReal, real)) return { kind: 'unobserved', reason: 'a symlink that leaves the mount' }; + const nested = insideNested(view, real); + if (nested) return { kind: 'unobserved', reason: nested }; if (!target.isFile()) { return { kind: 'unobserved', reason: target.isDirectory() ? 'a symlink to a directory, not walked' : 'not a regular file' }; } @@ -528,6 +548,8 @@ async function openContained( if (!contained(rootReal, real)) { return { kind: 'unobserved', reason: view.followSymlinks ? 'a symlink that leaves the mount' : OUTSIDE_PARENT }; } + const nested = insideNested(view, real); + if (nested) return { kind: 'unobserved', reason: nested }; const named = await stat(real, { bigint: true }).catch(() => null); if (named === null || named.dev !== info.dev || named.ino !== info.ino) { return { kind: 'unobserved', reason: 'the file changed while it was read' }; @@ -679,15 +701,44 @@ export function coveredByIgnore(relativePath: string, patterns: string[]): boole return false; } -/** Simple ignore patterns, as the walk and the watcher read them. */ +/** + * Where `innerRoot` lies in `outerRoot`, '/'-joined, or null when it isn't + * nested in it: the same directory, outside it (`..`, or `../…`; a name like + * `..cache` is inside), or another root (`relative()` across Windows drives + * gives an absolute path). Both roots should be canonical, so a symlinked or + * differently cased spelling of the same directory still nests (#282's + * round). `path` is the platform's; tests pass path.win32. + */ +export function nestedAt( + outerRoot: string, + innerRoot: string, + path: Pick = { relative, isAbsolute, sep }, +): string | null { + const rel = path.relative(outerRoot, innerRoot); + if (!rel || rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) return null; + return rel.split(path.sep).join('/'); +} + +/** + * Simple ignore patterns, as the walk and the watcher read them (types.ts + * `MountConfig.ignore` lists the forms). A name matches an entry so named at + * any depth; a path (`a/b`, or `a/b/**`) matches that path from the mount + * root. A trailing slash is dropped, so `node_modules/` reads as + * `node_modules` (gitignore would match only a directory so named; here a + * file so named is passed over too). Before #282's round, a trailing-slash or + * bare slash-path pattern matched nothing. + */ export function isIgnored(relativePath: string, name: string, patterns: string[]): boolean { - for (const pattern of patterns) { + for (const written of patterns) { + const pattern = written.length > 1 && written.endsWith('/') ? written.slice(0, -1) : written; if (pattern === name) return true; if (pattern.endsWith('/**')) { const prefix = pattern.slice(0, -3); if (relativePath === prefix || relativePath.startsWith(prefix + '/')) return true; + } else if (relativePath === pattern) { + // A name at the mount root, or a path, which is anchored there. + return true; } - if (!pattern.includes('/') && relativePath === pattern) return true; if (pattern.startsWith('*.') && name.endsWith(pattern.slice(1))) return true; } return false; diff --git a/src/modules/workspace/types.ts b/src/modules/workspace/types.ts index bdae98a63..7a2b73128 100644 --- a/src/modules/workspace/types.ts +++ b/src/modules/workspace/types.ts @@ -33,9 +33,13 @@ export interface MountConfig { watchRootPollMs?: number; /** * Simple ignore patterns (NOT full gitignore syntax). Supported forms: - * - Exact name match: ".git", "node_modules" - * - Directory glob: "node_modules/**" (anything under node_modules) - * - Extension glob: "*.pyc" (any file ending in .pyc) + * - A name: ".git", "node_modules" (an entry so named, at any depth) + * - A path from the mount root: "build/cache", or "build/cache/**" (that + * path and anything under it) + * - An extension glob: "*.pyc" (any file ending in .pyc) + * A trailing slash is dropped: "node_modules/" reads as "node_modules", so + * a file so named is passed over too (gitignore would match only a + * directory). An ignored directory is passed over whole. */ ignore?: string[]; /** Whether to follow symlinks (default: false) */ diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index fc530ed09..1d0ae9bed 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -7,23 +7,31 @@ * only as an entry name: a mount nested two levels deep (`a/b`) was never * ignored, so the outer mount synced the inner mount's files too, and a * mount nested one level deep (`sub`) also hid every other directory named - * `sub`. The rule is now `/**`, which matches that path and what is - * under it, at any depth, and nothing else. + * `sub`. The rule is now `/**`, which matches that path from the + * outer mount's root and what is under it, and nothing else. * * The watcher read none of a mount's patterns: chokidar takes a string as one * exact path. It now reads them as the walk does. * - * Driven through the public sync tool, and a real watcher, over tmpdir mounts. + * #282's round makes the invariant whole: a mount never takes in or writes + * out what lies in a mount nested in it. Nesting is read from canonical + * roots; the outer mount's tools refuse paths in the inner's directory, + * naming the inner; and every containment check refuses a canonical path + * there, however it was reached. + * + * Driven through the public tools, and a real watcher, over tmpdir mounts. */ import test, { type TestContext } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname, join, win32 } from 'node:path'; import { JsStore } from '@animalabs/chronicle'; import { WorkspaceModule } from '../src/modules/workspace/index.js'; +import { nestedAt } from '../src/modules/workspace/observe.js'; import type { MountConfig } from '../src/modules/workspace/types.js'; +import type { ModuleContext } from '../src/types/module.js'; import { MountWatcher, type FsChange } from '../src/modules/workspace/watcher.js'; function workspaceWith(t: TestContext, files: string[], innerRel: string, outerIgnore?: string[]) { @@ -70,6 +78,13 @@ test('a mount nested two levels deep is left out of the outer mount', async (t) "the inner mount's file is the inner mount's alone; its sibling is still the outer's"); }); +test("a sync without a path counts the nested mount's directory under passedOver", async (t) => { + const { workspace } = workspaceWith(t, ['top.txt', 'a/b/x.txt'], 'a/b'); + const result = await workspace.handleToolCall({ id: 'sync-outer', name: 'sync', input: { mount: 'outer' } }); + const data = result.data as { passedOver?: Array<{ mount: string; reason: string; count: number }> }; + assert.deepEqual(data.passedOver?.filter((p) => p.mount === 'outer'), [{ mount: 'outer', reason: 'ignored by the mount', count: 1 }]); +}); + test("a mount nested one level deep hides only itself, not every directory with its name", async (t) => { const { workspace, outer } = workspaceWith(t, ['sub/z.txt', 'deep/sub/y.txt', 'top.txt'], 'sub'); assert.deepEqual(outer.ignore, ['sub/**']); @@ -98,7 +113,7 @@ test("a watched mount's changes pass over what it ignores, a nested mount includ mkdirSync(join(outerDir, 'a', 'b'), { recursive: true }); mkdirSync(join(outerDir, 'a', 'c')); const outer: MountConfig = { - name: 'outer', path: outerDir, mode: 'read-write', watch: 'always', ignore: ['node_modules', '*.log'], + name: 'outer', path: outerDir, mode: 'read-write', watch: 'always', ignore: ['node_modules', '*.log', 'vendor/', 'build/cache'], watchDebounceMs: 50, watchRootPollMs: 50, }; const warn = console.warn; @@ -109,18 +124,24 @@ test("a watched mount's changes pass over what it ignores, a nested mount includ { name: 'inner', path: join(outerDir, 'a', 'b'), mode: 'read-write', watch: 'never' }, ] }); } finally { console.warn = warn; } - assert.deepEqual(outer.ignore, ['node_modules', '*.log', 'a/b/**']); + assert.deepEqual(outer.ignore, ['node_modules', '*.log', 'vendor/', 'build/cache', 'a/b/**']); const seen: FsChange[] = []; - let ready!: () => void; - const isReady = new Promise((resolve) => { ready = resolve; }); - const watcher = new MountWatcher(outer, (changes) => { seen.push(...changes); }, { onReady: () => ready() }); + let ready!: () => void, failed!: (err: Error) => void; + const isReady = new Promise((resolve, reject) => { ready = resolve; failed = reject; }); + const watcher = new MountWatcher(outer, (changes) => { seen.push(...changes); }, { + onReady: () => ready(), + onError: (err: Error) => failed(err), + }); watcher.start(); t.after(async () => { await watcher.stop(); rmSync(root, { recursive: true, force: true }); }); - await isReady; + // A watcher that never starts, or fails, fails the test rather than hanging it. + let bound: ReturnType | undefined; + await Promise.race([isReady, new Promise((_, reject) => { bound = setTimeout(() => reject(new Error('the watcher was not ready within 10 s')), 10_000); })]) + .finally(() => clearTimeout(bound)); // Each ignored write has a twin the mount reports, written after it and // reported by the same machinery, though not strictly after it: the same @@ -137,13 +158,195 @@ test("a watched mount's changes pass over what it ignores, a nested mount includ writeFileSync(join(outerDir, 'a', 'c', 'y.txt'), 'its twin'); writeFileSync(join(outerDir, 'a', 'x.log'), 'ignored by its name'); writeFileSync(join(outerDir, 'a', 'other.txt'), 'its twin'); + // A name below the root (I19), a trailing-slash name and a slash path (I7). + mkdirSync(join(outerDir, 'deep', 'node_modules'), { recursive: true }); + writeFileSync(join(outerDir, 'deep', 'node_modules', 'dep.js'), 'ignored below the root'); + mkdirSync(join(outerDir, 'deep', 'modules')); + writeFileSync(join(outerDir, 'deep', 'modules', 'dep.js'), 'its twin'); + mkdirSync(join(outerDir, 'vendor')); + writeFileSync(join(outerDir, 'vendor', 'lib.js'), "ignored as 'vendor/'"); + mkdirSync(join(outerDir, 'vendored')); + writeFileSync(join(outerDir, 'vendored', 'lib.js'), 'its twin'); + mkdirSync(join(outerDir, 'build', 'cache'), { recursive: true }); + writeFileSync(join(outerDir, 'build', 'cache', 'blob'), "ignored as 'build/cache'"); + mkdirSync(join(outerDir, 'build', 'out')); + writeFileSync(join(outerDir, 'build', 'out', 'blob'), 'its twin'); writeFileSync(join(outerDir, 'later.txt'), 'last'); - const reported = ['pkgs/pkg/index.js', 'a/c/y.txt', 'a/other.txt', 'later.txt']; + const reported = ['pkgs/pkg/index.js', 'a/c/y.txt', 'a/other.txt', 'deep/modules/dep.js', 'vendored/lib.js', 'build/out/blob', 'later.txt']; for (let i = 0; i < 200 && !reported.every((path) => seen.some((c) => c.path === path)); i++) { await new Promise((resolve) => setTimeout(resolve, 25)); } + // Then a settle window longer than a report's own delays (debounce 50 ms, + // a file's size settling over 100 ms), so an ignored write reported after + // its twin is still seen (I1). + await new Promise((resolve) => setTimeout(resolve, 600)); const paths = seen.map((c) => c.path).sort(); for (const path of reported) assert.ok(paths.includes(path), `${path} was seen: ${JSON.stringify(paths)}`); - assert.deepEqual(paths.filter((p) => p.startsWith('a/b') || p.startsWith('node_modules') || p.endsWith('.log')), [], + const under = (p: string, dir: string) => p === dir || p.startsWith(dir + '/'); + assert.deepEqual(paths.filter((p) => under(p, 'a/b') || under(p, 'node_modules') || under(p, 'deep/node_modules') + || under(p, 'vendor') || under(p, 'build/cache') || p.endsWith('.log')), [], 'nothing the mount ignores was reported'); }); + +test("trailing-slash and slash-path patterns leave out what they name in a sync, and nothing else", async (t) => { + const { workspace } = workspaceWith(t, + ['vendor/lib.js', 'vendored/lib.js', 'build/cache/blob', 'build/out/blob', 'deep/node_modules/dep.js', 'top.txt'], + 'inner', ['vendor/', 'build/cache', 'node_modules/']); + assert.deepEqual(await syncedByOuter(workspace), ['build/out/blob', 'top.txt', 'vendored/lib.js']); +}); + +test("a parent or a sibling mount isn't nested in a mount; the parent holds its child", (t) => { + const root = mkdtempSync(join(tmpdir(), 'af-nested-mount-family-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(join(root, 'child')); + mkdirSync(join(root, 'side')); + const child: MountConfig = { name: 'child', path: join(root, 'child'), mode: 'read-write', watch: 'never' }; + const parent: MountConfig = { name: 'parent', path: root, mode: 'read-write', watch: 'never' }; + const side: MountConfig = { name: 'side', path: join(root, 'side'), mode: 'read-write', watch: 'never' }; + const warn = console.warn; + console.warn = () => {}; + try { new WorkspaceModule({ mounts: [child, parent, side] }); } finally { console.warn = warn; } + assert.equal(child.ignore, undefined, "its parent (`..`) isn't nested in the child"); + assert.equal(side.ignore, undefined, "its sibling (`../child`) isn't nested in it"); + assert.deepEqual(parent.ignore, ['child/**', 'side/**']); +}); + +test('nesting by the platform rules: separators, `..`, and another Windows drive', () => { + assert.equal(nestedAt('C:\\w\\outer', 'C:\\w\\outer\\a\\b', win32), 'a/b'); + assert.equal(nestedAt('C:\\w\\outer', 'D:\\w\\outer\\a', win32), null, 'another drive is another root'); + assert.equal(nestedAt('C:\\w\\outer', 'C:\\w', win32), null); + assert.equal(nestedAt('C:\\w\\outer', 'C:\\w\\side', win32), null); + assert.equal(nestedAt('C:\\w\\outer', 'C:\\w\\outer', win32), null); + assert.equal(nestedAt('C:\\w\\outer', 'C:\\w\\outer\\..cache', win32), '..cache'); + assert.equal(nestedAt('/w/outer', '/w/outer/..cache/x'), '..cache/x'); +}); + +test('a nested mount configured through a symlink, or an outer one, is still nested', async (t) => { + const root = mkdtempSync(join(tmpdir(), 'af-nested-mount-spelling-')); + const outerDir = join(root, 'outer'); + mkdirSync(join(outerDir, 'a', 'b'), { recursive: true }); + writeFileSync(join(outerDir, 'a', 'b', 'x.txt'), "the inner mount's"); + writeFileSync(join(outerDir, 'top.txt'), 'top'); + symlinkSync(join(outerDir, 'a', 'b'), join(root, 'inner-link')); + symlinkSync(outerDir, join(root, 'outer-link')); + const store = JsStore.openOrCreate({ path: join(root, 'workspace.chronicle') }); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const outer: MountConfig = { name: 'outer', path: join(root, 'outer-link'), mode: 'read-write', watch: 'never' }; + const warn = console.warn; + console.warn = () => {}; + let workspace: WorkspaceModule; + try { + workspace = new WorkspaceModule({ mounts: [outer, { name: 'inner', path: join(root, 'inner-link'), mode: 'read-write', watch: 'never' }] }); + } finally { console.warn = warn; } + workspace.initStore(store); + assert.deepEqual(outer.ignore, ['a/b/**']); + assert.deepEqual(await syncedByOuter(workspace), ['top.txt']); +}); + +/** A scratch root, removed after every workspace started on it has stopped. */ +function scratch(t: TestContext, prefix: string) { + const root = mkdtempSync(join(tmpdir(), prefix)); + const stops: Array<() => Promise> = []; + t.after(async () => { + for (const stop of stops.reverse()) await stop(); + rmSync(root, { recursive: true, force: true }); + }); + return { root, stops }; +} + +/** A started workspace on a store at `storePath`; `stops` stops it at the end, if nothing has. */ +async function started(stops: Array<() => Promise>, storePath: string, mounts: MountConfig[]) { + const store = JsStore.openOrCreate({ path: storePath }); + const warn = console.warn; + console.warn = () => {}; + let workspace: WorkspaceModule; + try { workspace = new WorkspaceModule({ mounts }); } finally { console.warn = warn; } + workspace.initStore(store); + await workspace.start({ isRestart: false, getState: () => null, setState: () => {}, pushEvent: () => {} } as unknown as ModuleContext); + let stopped = false; + const stop = async () => { + if (stopped) return; + stopped = true; + await workspace.stop(); + if (!store.isClosed()) store.close(); + }; + stops.push(stop); + return { workspace, stop }; +} + +test("the outer mount's tools refuse a path in a nested mount, naming it; deleting an old copy only stops tracking it", async (t) => { + const { root, stops } = scratch(t, 'af-nested-mount-tools-'); + const outerDir = join(root, 'outer'); + mkdirSync(join(outerDir, 'a', 'b'), { recursive: true }); + writeFileSync(join(outerDir, 'a', 'b', 'x.txt'), "the inner mount's"); + const storePath = join(root, 'workspace.chronicle'); + const outer = (): MountConfig => ({ name: 'outer', path: outerDir, mode: 'read-write', watch: 'never' }); + const inner = (): MountConfig => ({ name: 'inner', path: join(outerDir, 'a', 'b'), mode: 'read-write', watch: 'never' }); + + // Before the inner mount existed, the outer one took the file in and drafted over it. + const before = await started(stops, storePath, [outer()]); + assert.equal((await before.workspace.handleToolCall({ id: 'w', name: 'write', input: { path: 'outer/a/b/x.txt', content: 'an outer draft' } })).success, true); + await before.stop(); + + const { workspace } = await started(stops, storePath, [outer(), inner()]); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + for (const [name, input] of [ + ['read', { path: 'outer/a/b/x.txt' }], ['write', { path: 'outer/a/b/y.txt', content: 'no' }], + ['edit', { path: 'outer/a/b/x.txt', oldString: 'an', newString: 'no' }], ['ls', { path: 'outer/a/b' }], + ['glob', { pattern: '**', path: 'outer/a/b' }], ['grep', { pattern: 'draft', path: 'outer/a/b' }], + ['sync', { path: 'outer/a/b' }], ['sync', { path: 'outer/a/./b/x.txt' }], ['materialize', { path: 'outer/a/b/x.txt' }], + ] as const) { + const result = await call(name, input); + assert.equal(result.success, false, `${name} ${JSON.stringify(input)} is refused`); + assert.match(String(result.error), /is inside mount "inner", which is nested in "outer" at a\/b\/: use "inner(\/[^"]*)?"/, `${name}: ${result.error}`); + } + + // A materialize without a path leaves the old draft unwritten, and says why. + const pushed = await call('materialize', {}); + assert.match(JSON.stringify(pushed), /a\/b\/x\.txt: inside mount \\"inner\\", which is nested in this one; delete it here to stop tracking it/, JSON.stringify(pushed)); + assert.equal(readFileSync(join(outerDir, 'a', 'b', 'x.txt'), 'utf8'), "the inner mount's", "the inner mount's file is untouched"); + + // Deleting the old copy stops tracking it here, and leaves disk alone. + const dropped = await call('delete', { path: 'outer/a/b/x.txt' }); + assert.equal(dropped.success, true, String(dropped.error)); + assert.match(JSON.stringify(dropped.data), /no longer tracked by \\"outer\\"/); + assert.equal(readFileSync(join(outerDir, 'a', 'b', 'x.txt'), 'utf8'), "the inner mount's"); + const again = await call('delete', { path: 'outer/a/b/x.txt' }); + assert.equal(again.success, false); + assert.match(String(again.error), /doesn't hold it: use "inner\/x\.txt"/); + // Nothing of it is owed any more: a later materialize doesn't list it. + const after = await call('materialize', {}); + assert.doesNotMatch(JSON.stringify(after), /a\/b\/x\.txt/, JSON.stringify(after)); + // The inner mount answers for its own file. + assert.equal((await call('read', { path: 'inner/x.txt' })).success, true); +}); + +test('an alias inside a following outer mount reaches nothing of a nested mount', async (t) => { + const { root, stops } = scratch(t, 'af-nested-mount-alias-'); + const outerDir = join(root, 'outer'); + mkdirSync(join(outerDir, 'a', 'b'), { recursive: true }); + writeFileSync(join(outerDir, 'a', 'b', 'x.txt'), "the inner mount's"); + symlinkSync(join(outerDir, 'a', 'b'), join(outerDir, 'alias')); + const { workspace } = await started(stops, join(root, 'workspace.chronicle'), [ + { name: 'outer', path: outerDir, mode: 'read-write', watch: 'never', followSymlinks: true, autoMaterialize: true }, + { name: 'inner', path: join(outerDir, 'a', 'b'), mode: 'read-write', watch: 'never' }, + ]); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + const synced = await call('sync', { path: 'outer/alias/x.txt' }); + assert.equal(synced.success, true, String(synced.error)); + const data = synced.data as { totalSynced: number; skipped?: Array<{ path: string; reason: string }> }; + assert.equal(data.totalSynced, 0, 'nothing taken in through the alias'); + assert.match(JSON.stringify(data.skipped), /inside mount \\"inner\\", which is nested in this one/); + const written = await call('write', { path: 'outer/alias/y.txt', content: 'through the alias' }); + assert.match(JSON.stringify(written), /inside mount \\"inner\\", which is nested in this one/, JSON.stringify(written)); + assert.throws(() => readFileSync(join(outerDir, 'a', 'b', 'y.txt')), "nothing was written into the inner mount's directory"); +}); + +test("a path named with '.' segments is read as its one spelling, and the ignore note says so", async (t) => { + const { workspace } = workspaceWith(t, ['node_modules/pkg.js', 'top.txt'], 'inner', ['node_modules']); + const result = await workspace.handleToolCall({ id: 'sync-dot', name: 'sync', input: { path: 'outer/./node_modules//pkg.js' } }); + assert.equal(result.success, true, String(result.error)); + const data = result.data as { results: Array<{ synced: string[] }>; ignored?: Array<{ path: string }> }; + assert.deepEqual(data.results.flatMap((r) => r.synced), ['node_modules/pkg.js']); + assert.deepEqual(data.ignored?.map((i) => i.path), ['node_modules/pkg.js']); +}); From f26a478dfd213a84fe88903869dfea5fcb35bab4 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Mon, 12 Oct 2026 00:44:27 -0400 Subject: [PATCH 04/26] test(workspace): the nested-mount watch test polls, as the watcher tests do outside their Linux-only native cases On macOS in CI, the native watcher reported none of the test's writes into `a/` and `a/c/`, directories that existed before the watch began, while it reported those in directories made after it; the test failed waiting for `a/c/y.txt` (both Nodes). The watcher tests already run chokidar's polling backend except in their native cases, which are skipped outside Linux. The ignore rule this test pins is the walk's on either backend, so the test now polls: passing the mount's patterns as plain strings, the bug it was written for, still fails it. --- test/workspace-nested-mount-ignore.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index 1d0ae9bed..14eea3bc1 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -133,7 +133,21 @@ test("a watched mount's changes pass over what it ignores, a nested mount includ onReady: () => ready(), onError: (err: Error) => failed(err), }); - watcher.start(); + // Chokidar's polling backend, as the watcher tests use outside their + // Linux-only native cases: the ignore rule is the walk's on either backend. + // On macOS in CI, the native stream reported none of the writes into `a/` + // and `a/c/`, directories that existed before the watch began. + const backend = { usePolling: process.env.CHOKIDAR_USEPOLLING, interval: process.env.CHOKIDAR_INTERVAL }; + process.env.CHOKIDAR_USEPOLLING = 'true'; + process.env.CHOKIDAR_INTERVAL = '25'; + try { + watcher.start(); + } finally { + if (backend.usePolling === undefined) delete process.env.CHOKIDAR_USEPOLLING; + else process.env.CHOKIDAR_USEPOLLING = backend.usePolling; + if (backend.interval === undefined) delete process.env.CHOKIDAR_INTERVAL; + else process.env.CHOKIDAR_INTERVAL = backend.interval; + } t.after(async () => { await watcher.stop(); rmSync(root, { recursive: true, force: true }); @@ -146,7 +160,7 @@ test("a watched mount's changes pass over what it ignores, a nested mount includ // Each ignored write has a twin the mount reports, written after it and // reported by the same machinery, though not strictly after it: the same // directory for the name pattern, and the same depth for the two ignored - // directories. Each directory has its own watch, and each file's report + // directories. Each directory is polled on its own, and each file's report // waits for its size to settle, so reported writes can arrive in any order; // waiting for every twin gives each ignored write the time it would take to // be reported. From 0c8d7a0224c5d00c6518e8f9f19328490575e32b Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Mon, 12 Oct 2026 14:42:33 -0400 Subject: [PATCH 05/26] fix(workspace): nesting reads through a root's nearest existing directory and afresh each pass, and the disk readers refuse a nested mount's files whatever path reaches them Two gaps in "a mount never takes in or writes out what lies in a mount nested in it, whatever path reaches it", found in review: - An inner root that doesn't exist yet, configured through a symlinked parent (`via/child`, where `via` links into the outer mount), was never nested. canonicalRoot fell back to the configured path when realpath failed, so the constructor saw `via/child` outside the outer mount, and each pass rechecked only the pairs found at construction. Once `child` was made, the outer mount's bare sync took its files and an auto-materializing write overwrote them. canonicalRoot now resolves the nearest existing ancestor and appends the rest, so the pair is found at construction (with its ignore rule and the tools' refusal), and each pass's runtime reads nesting afresh against every other mount, so a mount that comes to lie inside another later (its root made, a symlinked parent created) is refused by the canonical checks too. - The disk readers didn't check nested mounts. openContainedFile, behind readFileFromDisk and read_image, refused a canonical target outside the mount but not one inside a nested mount, so an alias inside the outer mount (`alias -> a/b`, with a mount at `a/b`) read the inner mount's files. readBinaryFromDisk had only the lexical guard: it read through any symlink, including one leading outside the mount, though its doc promises the boundary the other readers keep. - openContainedFile refuses a canonical path inside a nested mount, as `escape`, naming the mount and the path to use there. - read_image passes that message through, rather than rewriting every `escape` as "Symlink escape detected". - readBinaryFromDisk now reads through openContainedFile, so its checks bind to the file it opens (the descriptor and the canonical path are the same inode). It follows symlinks within the mount whatever the mount's followSymlinks, as before, and refuses one that leads outside it. Its error texts for a missing file, a directory and an oversized file are unchanged. Tests: a mount configured through a symlinked parent before its root exists is nested from the start (ignore rule, bare sync, refused write, disk untouched); a mount that comes to lie in another after the module is built is refused by sync, write, readFileFromDisk and readBinaryFromDisk; readFileFromDisk, readBinaryFromDisk and read_image refuse an alias into a nested mount, naming it; readBinaryFromDisk follows an in-mount symlink and refuses one leading outside, as readFileFromDisk does. Removing each of the five fixes fails its test. The binding of readBinaryFromDisk's check to the opened file is openContainedFile's inode comparison, reasoned rather than raced in a test. The workspace and reader test files pass 245/245. --- changelog.d/nested-mount-ignore.fixed.md | 12 +++ src/modules/workspace/index.ts | 116 +++++++++++++++------ test/workspace-nested-mount-ignore.test.ts | 99 ++++++++++++++++++ 3 files changed, 198 insertions(+), 29 deletions(-) diff --git a/changelog.d/nested-mount-ignore.fixed.md b/changelog.d/nested-mount-ignore.fixed.md index 9411d31ad..d2ed11e6b 100644 --- a/changelog.d/nested-mount-ignore.fixed.md +++ b/changelog.d/nested-mount-ignore.fixed.md @@ -40,6 +40,12 @@ filesystem, is still nested, and the rule is spelled as disk spells it. An inner mount in a directory whose name starts with `..` (such as `..cache`) is now recognized as nested. + A root that doesn't exist yet is read through its nearest existing + directory, so a mount configured at `via/child`, where `via` links into + another mount, is nested there before `child` is made. Each pass also + reads nesting afresh, so a mount that comes to lie inside another after + startup (its root made later, or a symlinked parent created) is refused + there too. - The outer mount's tools refuse a path inside a nested mount, naming the mount to use: `outer/a/b/x.txt` is "inside mount "inner", which is nested in "outer" at a/b/: use "inner/x.txt"". That covers `read`, `write`, @@ -55,6 +61,12 @@ walking or writing, also refuses one inside a nested mount. So a symlink that a `followSymlinks` mount follows into a nested mount reaches nothing there, and a write through it is refused. + `readFileFromDisk` and `read_image` refuse a file there, naming the + mount to use, and so does `readBinaryFromDisk`. That reader now keeps the + boundary the other readers keep, checked against the file it opens: it + still follows a symlink that stays within the mount, and it now refuses + one that leads outside the mount ("Symlink escape detected"), which it + used to read. - Every workspace tool reads a path in one spelling: `a/./b`, `a//b` and `a/b/` are `a/b`. - (agent-framework #280) diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 330971b0d..e3fe8635d 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -7,8 +7,8 @@ import { constants as fsConstants, realpathSync } from 'node:fs'; import type { Stats } from 'node:fs'; -import { open, readFile, stat, lstat, realpath } from 'node:fs/promises'; -import { join, resolve, relative, sep } from 'node:path'; +import { open, stat, lstat, realpath } from 'node:fs/promises'; +import { basename, dirname, join, resolve, relative, sep } from 'node:path'; import type { JsStore } from '@animalabs/chronicle'; import type { Module, ModuleContext, ProcessState, EventResponse } from '../../types/module.js'; import type { ProcessEvent, ToolDefinition, ToolCall, ToolResult } from '../../types/events.js'; @@ -108,7 +108,8 @@ class WorkspaceImageReadError extends Error { * (`mount_unavailable`), an ordinary missing file (`not_found`), a directory * (`directory`), a symlink refused by the mount's `followSymlinks` policy * (`symlink`), a symlink whose canonical target lands outside the canonical - * mount root (`escape`), and a file swapped underneath the read (`changed`). + * mount root or inside a mount nested in it (`escape`), and a file swapped + * underneath the read (`changed`). */ export type WorkspaceReadErrorCode = | 'unknown_mount' @@ -204,13 +205,25 @@ function matchesPartialPrefix(buffer: Buffer, prefix: Buffer): boolean { return buffer.length > 0 && prefix.subarray(0, buffer.length).equals(buffer); } -/** A mount root as disk spells it: its canonical path, or as configured while it doesn't exist. */ +/** + * A mount root as disk spells it: the canonical path of its nearest existing + * ancestor, joined with the rest as configured. A root that doesn't exist yet + * still reads through a symlinked parent, so a mount configured at + * `via/child`, where `via` links into another mount, is nested in it before + * `child` is made. + */ function canonicalRoot(path: string): string { - const configured = resolve(path); - try { - return realpathSync.native(configured); - } catch { - return configured; + let existing = resolve(path); + const rest: string[] = []; + for (;;) { + try { + return join(realpathSync.native(existing), ...rest); + } catch { + const parent = dirname(existing); + if (parent === existing) return resolve(path); + rest.unshift(basename(existing)); + existing = parent; + } } } @@ -691,6 +704,31 @@ export class WorkspaceModule implements Module { this.config = config; } + /** + * The mounts nested in `mount` as disk spells their roots now: every other + * mount whose canonical root lies inside `rootReal`. Read afresh each time, + * so a nesting that came about after the module was built (a root made + * later, a symlinked parent created) is refused like one found then. + */ + private nestedRootsOf(mount: MountState, rootReal: string): Array<{ name: string; real: string }> { + const nested: Array<{ name: string; real: string }> = []; + for (const inner of this.mounts.values()) { + if (inner === mount) continue; + const real = canonicalRoot(inner.config.path); + if (real !== rootReal && contained(rootReal, real)) nested.push({ name: inner.config.name, real }); + } + return nested; + } + + /** Refuse a canonical path that lies in a mount nested in `mount`, naming where it belongs. */ + private nestedRefusal(mount: MountState, rootReal: string, real: string, mountPrefixedPath: string): string | null { + const inner = this.nestedRootsOf(mount, rootReal).find((n) => contained(n.real, real)); + if (!inner) return null; + const rest = relative(inner.real, real).split(sep).join('/'); + return `"${mountPrefixedPath}" is inside mount "${inner.name}", which is nested in "${mount.config.name}": ` + + `use "${rest ? `${inner.name}/${rest}` : inner.name}"`; + } + /** The mount nested in `mount` that a mount-relative path lies in, if any. */ private nestedMountAt(mount: MountState, relativePath: string): { name: string; at: string } | undefined { return this.nestedIn.get(mount.config.name)?.find((n) => relativePath === n.at || relativePath.startsWith(n.at + '/')); @@ -785,15 +823,9 @@ export class WorkspaceModule implements Module { this.ctimeTrust.set(rootReal, trustedDev); } // Canonical now, whatever they were when the module was built: the - // containment checks compare canonical paths. - const nested: Array<{ name: string; real: string }> = []; - for (const n of this.nestedIn.get(mount.config.name) ?? []) { - const inner = this.mounts.get(n.name); - if (!inner) continue; - const innerRoot = resolve(inner.config.path); - const real = await realpath(innerRoot).catch(() => innerRoot); - if (real !== rootReal && contained(rootReal, real)) nested.push({ name: n.name, real }); - } + // containment checks compare canonical paths, and a mount nested since + // then is refused like one found at construction. + const nested = this.nestedRootsOf(mount, rootReal); return { name: mount.config.name, view: { @@ -1516,8 +1548,10 @@ export class WorkspaceModule implements Module { * permanent cost to send something once is the wrong trade. * * Same mount boundary and traversal guard as every other path here: only - * declared mounts, nothing above their root. Deliberately does NOT sync, - * register, or hash anything — it reads and returns. + * declared mounts, nothing whose canonical path lies outside the mount's + * root, and nothing in a mount nested in it. Symlinks inside the mount are + * followed. Deliberately does NOT sync, register, or hash anything — it + * reads and returns. */ async readBinaryFromDisk( mountPrefixedPath: string, @@ -1531,17 +1565,35 @@ export class WorkspaceModule implements Module { return { error: error instanceof Error ? error.message : String(error) }; } const absolutePath = resolve(mount.config.path, relativePath); + // The other readers' boundary, checked against the file actually opened: + // a symlink may lead only within this mount, never into a mount nested in + // it. Symlinks within the mount are followed, whatever its followSymlinks. + const tooLarge = new Map<'size', number>(); + let opened: Awaited>; try { - const info = await stat(absolutePath); - if (!info.isFile()) return { error: `Not a file: ${mountPrefixedPath}` }; - if (opts.maxBytes !== undefined && info.size > opts.maxBytes) { - return { error: `File too large: ${info.size} > ${opts.maxBytes} bytes` }; + opened = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => { + if (opts.maxBytes !== undefined && fileStat.size > opts.maxBytes) { + tooLarge.set('size', fileStat.size); + throw new Error('too large'); + } + }, { follow: true }); + } catch (error) { + const size = tooLarge.get('size'); + if (size !== undefined) return { error: `File too large: ${size} > ${opts.maxBytes} bytes` }; + if (error instanceof WorkspaceReadError) { + if (error.code === 'not_found' && (error.errno === 'ENOENT' || error.errno === 'ENOTDIR')) { + return { error: `File not found on disk: ${mountPrefixedPath}` }; + } + if (error.code === 'directory') return { error: `Not a file: ${mountPrefixedPath}` }; } - return { data: await readFile(absolutePath), absolutePath }; + return { error: error instanceof Error ? error.message : String(error) }; + } + try { + return { data: await opened.handle.readFile(), absolutePath }; } catch (error) { - const code = (error as { code?: string }).code; - if (code === 'ENOENT') return { error: `File not found on disk: ${mountPrefixedPath}` }; return { error: error instanceof Error ? error.message : String(error) }; + } finally { + await opened.handle.close(); } } @@ -1652,9 +1704,10 @@ export class WorkspaceModule implements Module { relativePath: string, mountPrefixedPath: string, inspect?: (fileStat: Stats) => void, + opts: { follow?: boolean } = {}, ): Promise<{ handle: Awaited>; fileStat: Stats; realFilePath: string }> { const lexicalPath = resolve(mount.config.path, relativePath); - const follow = mount.config.followSymlinks === true; + const follow = opts.follow ?? mount.config.followSymlinks === true; const useNoFollow = !follow && typeof fsConstants.O_NOFOLLOW === 'number'; let fileInfo: Awaited>; @@ -1746,6 +1799,10 @@ export class WorkspaceModule implements Module { if (!isContainedPath(realMountRoot, realFilePath)) { throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath'); } + // A file in a mount nested in this one is that mount's, whatever path + // reaches it: an alias inside this mount reads none of it. + const nested = this.nestedRefusal(mount, realMountRoot, realFilePath, mountPrefixedPath); + if (nested) throw new WorkspaceReadError('escape', nested, 'realpath'); let pathStat: Stats; try { @@ -1845,7 +1902,8 @@ export class WorkspaceModule implements Module { case 'symlink': return new WorkspaceImageReadError('symlink', `Symlinks are not allowed: ${p}`); case 'escape': - return new WorkspaceImageReadError('escape', `Symlink escape detected: ${p}`); + // Outside the mount, or inside a mount nested in it (which the message names). + return new WorkspaceImageReadError('escape', err.message); case 'changed': return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`); case 'not_found': diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index 14eea3bc1..f3e8a1fa8 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -34,6 +34,9 @@ import type { MountConfig } from '../src/modules/workspace/types.js'; import type { ModuleContext } from '../src/types/module.js'; import { MountWatcher, type FsChange } from '../src/modules/workspace/watcher.js'; +/** A valid 1x1 PNG, for read_image. */ +const ONE_PIXEL_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==', 'base64'); + function workspaceWith(t: TestContext, files: string[], innerRel: string, outerIgnore?: string[]) { const root = mkdtempSync(join(tmpdir(), 'af-nested-mount-')); const outerDir = join(root, 'outer'); @@ -356,6 +359,102 @@ test('an alias inside a following outer mount reaches nothing of a nested mount' assert.throws(() => readFileSync(join(outerDir, 'a', 'b', 'y.txt')), "nothing was written into the inner mount's directory"); }); +test("the disk readers reach nothing of a nested mount through an alias, and the raw reader keeps the mount's boundary", async (t) => { + const { root, stops } = scratch(t, 'af-nested-mount-readers-'); + const outerDir = join(root, 'outer'); + const innerDir = join(outerDir, 'a', 'b'); + mkdirSync(innerDir, { recursive: true }); + writeFileSync(join(innerDir, 'x.txt'), "the inner mount's"); + writeFileSync(join(innerDir, 'i.png'), ONE_PIXEL_PNG); + writeFileSync(join(outerDir, 'own.txt'), "the outer mount's"); + writeFileSync(join(root, 'outside.txt'), 'outside every mount'); + symlinkSync(innerDir, join(outerDir, 'alias')); + symlinkSync(join(outerDir, 'own.txt'), join(outerDir, 'own-link.txt')); + symlinkSync(join(root, 'outside.txt'), join(outerDir, 'out-link.txt')); + const { workspace } = await started(stops, join(root, 'workspace.chronicle'), [ + { name: 'outer', path: outerDir, mode: 'read-write', watch: 'never', followSymlinks: true }, + { name: 'inner', path: innerDir, mode: 'read-write', watch: 'never' }, + ]); + const naming = /"outer\/alias\/(x\.txt|i\.png)" is inside mount "inner", which is nested in "outer": use "inner\/(x\.txt|i\.png)"/; + + await assert.rejects(workspace.readFileFromDisk('outer/alias/x.txt'), (err: Error & { code?: string }) => { + assert.equal(err.code, 'escape'); + assert.match(err.message, naming); + return true; + }); + const raw = await workspace.readBinaryFromDisk('outer/alias/x.txt'); + assert.ok('error' in raw, 'the raw reader refuses it'); + assert.match(raw.error, naming); + const image = await workspace.handleToolCall({ id: 'img', name: 'read_image', input: { path: 'outer/alias/i.png' } }); + assert.equal(image.success, false, 'read_image refuses it'); + assert.match(String(image.error), naming); + // The inner mount answers for its own files. + assert.equal((await workspace.readFileFromDisk('inner/x.txt')).bytes.toString(), "the inner mount's"); + + // The raw reader follows a symlink within the mount, and refuses one that leads outside it, as the others do. + const own = await workspace.readBinaryFromDisk('outer/own-link.txt'); + assert.ok('data' in own, 'error' in own ? own.error : ''); + assert.equal(own.data.toString(), "the outer mount's"); + const out = await workspace.readBinaryFromDisk('outer/out-link.txt'); + assert.ok('error' in out); + assert.match(out.error, /Symlink escape detected: outer\/out-link\.txt/); + await assert.rejects(workspace.readFileFromDisk('outer/out-link.txt'), /Symlink escape detected/); +}); + +test("a nested mount whose root doesn't exist yet, configured through a symlinked parent, is nested from the start", async (t) => { + const { root, stops } = scratch(t, 'af-nested-mount-absent-'); + const outerDir = join(root, 'outer'); + mkdirSync(outerDir); + writeFileSync(join(outerDir, 'top.txt'), 'top'); + symlinkSync(outerDir, join(root, 'via')); + const outer: MountConfig = { name: 'outer', path: outerDir, mode: 'read-write', watch: 'never', autoMaterialize: true }; + const inner: MountConfig = { name: 'inner', path: join(root, 'via', 'child'), mode: 'read-write', watch: 'never' }; + const store = JsStore.openOrCreate({ path: join(root, 'workspace.chronicle') }); + const warn = console.warn; + console.warn = () => {}; + let workspace: WorkspaceModule; + try { workspace = new WorkspaceModule({ mounts: [outer, inner] }); } finally { console.warn = warn; } + stops.push(async () => { await workspace.stop(); if (!store.isClosed()) store.close(); }); + assert.deepEqual(outer.ignore, ['child/**'], 'the inner root reads through the symlink before it exists'); + // The inner root is made, and a file in it, after the module was built. + mkdirSync(join(outerDir, 'child')); + writeFileSync(join(outerDir, 'child', 'x.txt'), "the inner mount's"); + workspace.initStore(store); + await workspace.start({ isRestart: false, getState: () => null, setState: () => {}, pushEvent: () => {} } as unknown as ModuleContext); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + assert.deepEqual(await syncedByOuter(workspace), ['top.txt']); + const written = await call('write', { path: 'outer/child/x.txt', content: 'an outer overwrite' }); + assert.equal(written.success, false); + assert.match(String(written.error), /is inside mount "inner", which is nested in "outer" at child\/: use "inner\/x\.txt"/); + assert.equal(readFileSync(join(outerDir, 'child', 'x.txt'), 'utf8'), "the inner mount's"); +}); + +test('a mount that comes to lie in another after the module is built is refused there, whatever path reaches it', async (t) => { + const { root, stops } = scratch(t, 'af-nested-mount-later-'); + const outerDir = join(root, 'outer'); + mkdirSync(outerDir); + writeFileSync(join(outerDir, 'top.txt'), 'top'); + // `via` doesn't exist when the module is built, so the inner root isn't inside the outer one then. + const { workspace } = await started(stops, join(root, 'workspace.chronicle'), [ + { name: 'outer', path: outerDir, mode: 'read-write', watch: 'never', autoMaterialize: true }, + { name: 'inner', path: join(root, 'via', 'child'), mode: 'read-write', watch: 'never' }, + ]); + symlinkSync(outerDir, join(root, 'via')); + mkdirSync(join(outerDir, 'child')); + writeFileSync(join(outerDir, 'child', 'x.txt'), "the inner mount's"); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + + const synced = await call('sync', { mount: 'outer' }); + assert.equal(synced.success, true, String(synced.error)); + assert.doesNotMatch(JSON.stringify((synced.data as { results: unknown[] }).results), /"synced":\[[^\]]*child\/x\.txt/, 'not taken in'); + const written = await call('write', { path: 'outer/child/y.txt', content: 'an outer write' }); + assert.match(JSON.stringify(written), /inside mount \\"inner\\", which is nested in this one/, JSON.stringify(written)); + assert.throws(() => readFileSync(join(outerDir, 'child', 'y.txt')), "nothing was written into the inner mount's directory"); + await assert.rejects(workspace.readFileFromDisk('outer/child/x.txt'), /inside mount "inner", which is nested in "outer": use "inner\/x\.txt"/); + const raw = await workspace.readBinaryFromDisk('outer/child/x.txt'); + assert.ok('error' in raw && /inside mount "inner"/.test(raw.error), JSON.stringify(raw)); +}); + test("a path named with '.' segments is read as its one spelling, and the ignore note says so", async (t) => { const { workspace } = workspaceWith(t, ['node_modules/pkg.js', 'top.txt'], 'inner', ['node_modules']); const result = await workspace.handleToolCall({ id: 'sync-dot', name: 'sync', input: { path: 'outer/./node_modules//pkg.js' } }); From 84985e4de2cc4dc2cd99fc962f9553ad3a0b2729 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Mon, 12 Oct 2026 18:40:55 -0400 Subject: [PATCH 06/26] fix(workspace): the outer mount's tools refuse a nesting that came about after startup; tests cover the agreement a delete forgets, and the watch test waits for each directory's poller - nestedAtsOf: the outer mount's tools read nesting from the list found at construction and from every other mount's canonical root now. A path in a mount that came to lie in this one later (its root made later, a symlinked parent created) is refused before it reaches the outer mount's tree, naming the inner mount, as one found at construction is. Before, only the lower-level check stopped its disk effects, so a write with autoMaterialize off left a draft in the outer tree that no materialize could write. - materialize reads the nested list once per pass, and carries each nested path's mount name to the skipped list. - test: the late-root test runs with autoMaterialize on and off. The write is refused with the tools' text, nothing is owed for it afterwards, and the disk readers refuse the inner file by its name and through an alias the outer mount follows. - test: the legacy-delete fixture first syncs the outer mount, so disk agreed with it about the file. After the delete, a sync without a path reports nothing of the file (the agreement is forgotten) and passes the inner directory over as ignored. - test: the watch test waits until each directory that existed before the watch has reported a write, before its checks. Chokidar 4 starts a directory's poller after reading the directory, and a write made before the poller's first reading went unreported: a/c/y.txt was missed in 1 of 48 runs on each of Node 20 and 24. - changelog: the tools refuse a later nesting too. --- changelog.d/nested-mount-ignore.fixed.md | 6 +- src/modules/workspace/index.ts | 40 ++++++++-- test/workspace-nested-mount-ignore.test.ts | 92 ++++++++++++++++------ 3 files changed, 102 insertions(+), 36 deletions(-) diff --git a/changelog.d/nested-mount-ignore.fixed.md b/changelog.d/nested-mount-ignore.fixed.md index d2ed11e6b..f895c0756 100644 --- a/changelog.d/nested-mount-ignore.fixed.md +++ b/changelog.d/nested-mount-ignore.fixed.md @@ -44,8 +44,10 @@ directory, so a mount configured at `via/child`, where `via` links into another mount, is nested there before `child` is made. Each pass also reads nesting afresh, so a mount that comes to lie inside another after - startup (its root made later, or a symlinked parent created) is refused - there too. + startup (its root made later, or a symlinked parent created) is treated + like one found at startup: the outer mount's tools refuse its paths + before anything reaches the outer mount's workspace, and the checks + below refuse it however it is reached. - The outer mount's tools refuse a path inside a nested mount, naming the mount to use: `outer/a/b/x.txt` is "inside mount "inner", which is nested in "outer" at a/b/: use "inner/x.txt"". That covers `read`, `write`, diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index e3fe8635d..d1e893040 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -729,9 +729,30 @@ export class WorkspaceModule implements Module { `use "${rest ? `${inner.name}/${rest}` : inner.name}"`; } + /** + * The mounts nested in `mount`, each at its mount-relative directory: those + * found when the module was built, and any whose root has come to lie in + * this one since, read from disk now. The outer mount's tools refuse a path + * in a nesting that came about later as they refuse one found then, before + * anything reaches its tree. + */ + private nestedAtsOf(mount: MountState): Array<{ name: string; at: string }> { + const ats = [...(this.nestedIn.get(mount.config.name) ?? [])]; + const rootReal = canonicalRoot(mount.config.path); + for (const inner of this.nestedRootsOf(mount, rootReal)) { + const at = relative(rootReal, inner.real).split(sep).join('/'); + if (!ats.some((n) => n.name === inner.name && n.at === at)) ats.push({ name: inner.name, at }); + } + return ats; + } + /** The mount nested in `mount` that a mount-relative path lies in, if any. */ - private nestedMountAt(mount: MountState, relativePath: string): { name: string; at: string } | undefined { - return this.nestedIn.get(mount.config.name)?.find((n) => relativePath === n.at || relativePath.startsWith(n.at + '/')); + private nestedMountAt( + mount: MountState, + relativePath: string, + ats: Array<{ name: string; at: string }> = this.nestedAtsOf(mount), + ): { name: string; at: string } | undefined { + return ats.find((n) => relativePath === n.at || relativePath.startsWith(n.at + '/')); } /** @@ -2737,7 +2758,7 @@ export class WorkspaceModule implements Module { for (const { name, mount } of mountsToMaterialize) { if (mount.config.mode === 'read-only') continue; - const turn = await this.withMount(mount, async (): Promise<{ blocked: string } | { pushed: PushResult; inPlace: boolean; nested: string[] }> => { + const turn = await this.withMount(mount, async (): Promise<{ blocked: string } | { pushed: PushResult; inPlace: boolean; nested: Array<{ path: string; inner: string }> }> => { const branchId = store.currentBranch().id; const reason = this.mountMaterializeBlockReason(store, mount); // Force passes a divergence. The selection follows the evidence, which @@ -2748,8 +2769,12 @@ export class WorkspaceModule implements Module { // What the outer mount still holds in a nested mount's directory (taken // in before #280's fix) is never written there, nor deleted there: // the files are the inner mount's (agent-framework #280). - const nested = selected.filter((path) => this.nestedMountAt(mount, path)); - const paths = nested.length > 0 ? selected.filter((path) => !this.nestedMountAt(mount, path)) : selected; + const ats = this.nestedAtsOf(mount); + const nested = selected.flatMap((path) => { + const inner = this.nestedMountAt(mount, path, ats); + return inner ? [{ path, inner: inner.name }] : []; + }); + const paths = nested.length > 0 ? selected.filter((path) => !this.nestedMountAt(mount, path, ats)) : selected; return { inPlace, nested, @@ -2774,9 +2799,8 @@ export class WorkspaceModule implements Module { for (const s of pushed.skipped) { blocked.push({ mount: name, reason: `${s.path}: ${s.reason}` }); } - for (const path of turn.nested) { - const inner = this.nestedMountAt(mount, path)!; - blocked.push({ mount: name, reason: `${path}: inside mount "${inner.name}", which is nested in this one; delete it here to stop tracking it` }); + for (const { path, inner } of turn.nested) { + blocked.push({ mount: name, reason: `${path}: inside mount "${inner}", which is nested in this one; delete it here to stop tracking it` }); } // What disk still owes is kept by its evidence, so the watermark and diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index f3e8a1fa8..eea150650 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -159,6 +159,20 @@ test("a watched mount's changes pass over what it ignores, a nested mount includ let bound: ReturnType | undefined; await Promise.race([isReady, new Promise((_, reject) => { bound = setTimeout(() => reject(new Error('the watcher was not ready within 10 s')), 10_000); })]) .finally(() => clearTimeout(bound)); + // Chokidar 4 reads a directory before it starts that directory's poller, + // and the poller takes its first reading later, so `ready` can come before + // a directory that existed before the watch has one. A write made then can + // fall inside that first reading and never be reported: about 1 run in 48 + // on the VPS missed `a/c/y.txt`, on Node 20 and 24 alike. So each such + // directory gets a new file each round until one is reported there, which + // shows its poller is reading, before the checks begin. + const primedDirs = ['', 'a', 'a/c']; + const primedIn = (dir: string) => seen.some((c) => c.path.startsWith(dir ? `${dir}/primed-` : 'primed-')); + for (let round = 0; round < 25 && !primedDirs.every(primedIn); round++) { + for (const dir of primedDirs.filter((d) => !primedIn(d))) writeFileSync(join(outerDir, dir, `primed-${round}.txt`), 'primed'); + await new Promise((resolve) => setTimeout(resolve, 200)); + } + assert.ok(primedDirs.every(primedIn), `each directory that existed before the watch reported a write: ${JSON.stringify(seen.map((c) => c.path))}`); // Each ignored write has a twin the mount reports, written after it and // reported by the same machinery, though not strictly after it: the same @@ -300,8 +314,12 @@ test("the outer mount's tools refuse a path in a nested mount, naming it; deleti const outer = (): MountConfig => ({ name: 'outer', path: outerDir, mode: 'read-write', watch: 'never' }); const inner = (): MountConfig => ({ name: 'inner', path: join(outerDir, 'a', 'b'), mode: 'read-write', watch: 'never' }); - // Before the inner mount existed, the outer one took the file in and drafted over it. + // Before the inner mount existed, the outer one took the file in, so disk + // agreed with it about that path, and then drafted over it. const before = await started(stops, storePath, [outer()]); + const tookIn = await before.workspace.handleToolCall({ id: 'took-in', name: 'sync', input: { mount: 'outer' } }); + assert.equal(tookIn.success, true, String(tookIn.error)); + assert.equal((tookIn.data as { totalSynced: number }).totalSynced, 1, 'the outer mount took in a/b/x.txt'); assert.equal((await before.workspace.handleToolCall({ id: 'w', name: 'write', input: { path: 'outer/a/b/x.txt', content: 'an outer draft' } })).success, true); await before.stop(); @@ -334,6 +352,15 @@ test("the outer mount's tools refuse a path in a nested mount, naming it; deleti // Nothing of it is owed any more: a later materialize doesn't list it. const after = await call('materialize', {}); assert.doesNotMatch(JSON.stringify(after), /a\/b\/x\.txt/, JSON.stringify(after)); + // Nor is it remembered: what disk agreed with about it is gone too, so a + // sync without a path passes over the inner mount's directory as ignored, + // and has nothing of it to report. + const later = await call('sync', { mount: 'outer' }); + assert.equal(later.success, true, String(later.error)); + const laterData = later.data as { skipped?: unknown; incomplete?: unknown; passedOver?: unknown }; + assert.equal(laterData.skipped, undefined, JSON.stringify(laterData)); + assert.equal(laterData.incomplete, undefined, JSON.stringify(laterData)); + assert.deepEqual(laterData.passedOver, [{ mount: 'outer', reason: 'ignored by the mount', count: 1 }]); // The inner mount answers for its own file. assert.equal((await call('read', { path: 'inner/x.txt' })).success, true); }); @@ -429,31 +456,44 @@ test("a nested mount whose root doesn't exist yet, configured through a symlinke assert.equal(readFileSync(join(outerDir, 'child', 'x.txt'), 'utf8'), "the inner mount's"); }); -test('a mount that comes to lie in another after the module is built is refused there, whatever path reaches it', async (t) => { - const { root, stops } = scratch(t, 'af-nested-mount-later-'); - const outerDir = join(root, 'outer'); - mkdirSync(outerDir); - writeFileSync(join(outerDir, 'top.txt'), 'top'); - // `via` doesn't exist when the module is built, so the inner root isn't inside the outer one then. - const { workspace } = await started(stops, join(root, 'workspace.chronicle'), [ - { name: 'outer', path: outerDir, mode: 'read-write', watch: 'never', autoMaterialize: true }, - { name: 'inner', path: join(root, 'via', 'child'), mode: 'read-write', watch: 'never' }, - ]); - symlinkSync(outerDir, join(root, 'via')); - mkdirSync(join(outerDir, 'child')); - writeFileSync(join(outerDir, 'child', 'x.txt'), "the inner mount's"); - const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); - - const synced = await call('sync', { mount: 'outer' }); - assert.equal(synced.success, true, String(synced.error)); - assert.doesNotMatch(JSON.stringify((synced.data as { results: unknown[] }).results), /"synced":\[[^\]]*child\/x\.txt/, 'not taken in'); - const written = await call('write', { path: 'outer/child/y.txt', content: 'an outer write' }); - assert.match(JSON.stringify(written), /inside mount \\"inner\\", which is nested in this one/, JSON.stringify(written)); - assert.throws(() => readFileSync(join(outerDir, 'child', 'y.txt')), "nothing was written into the inner mount's directory"); - await assert.rejects(workspace.readFileFromDisk('outer/child/x.txt'), /inside mount "inner", which is nested in "outer": use "inner\/x\.txt"/); - const raw = await workspace.readBinaryFromDisk('outer/child/x.txt'); - assert.ok('error' in raw && /inside mount "inner"/.test(raw.error), JSON.stringify(raw)); -}); +for (const autoMaterialize of [true, false]) { + test(`a mount that comes to lie in another after the module is built is refused there, whatever path reaches it (autoMaterialize ${autoMaterialize})`, async (t) => { + const { root, stops } = scratch(t, 'af-nested-mount-later-'); + const outerDir = join(root, 'outer'); + mkdirSync(outerDir); + writeFileSync(join(outerDir, 'top.txt'), 'top'); + // `via` doesn't exist when the module is built, so the inner root isn't inside the outer one then. + const { workspace } = await started(stops, join(root, 'workspace.chronicle'), [ + { name: 'outer', path: outerDir, mode: 'read-write', watch: 'never', followSymlinks: true, autoMaterialize }, + { name: 'inner', path: join(root, 'via', 'child'), mode: 'read-write', watch: 'never' }, + ]); + symlinkSync(outerDir, join(root, 'via')); + mkdirSync(join(outerDir, 'child')); + writeFileSync(join(outerDir, 'child', 'x.txt'), "the inner mount's"); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + + const synced = await call('sync', { mount: 'outer' }); + assert.equal(synced.success, true, String(synced.error)); + assert.doesNotMatch(JSON.stringify((synced.data as { results: unknown[] }).results), /"synced":\[[^\]]*child\/x\.txt/, 'not taken in'); + // The outer mount's tools refuse the path, naming the inner mount, as they + // do for a nesting found when the module was built. Without a materialize + // after the write, only that refusal can keep the write out of the outer + // mount's tree. + const written = await call('write', { path: 'outer/child/y.txt', content: 'an outer write' }); + assert.equal(written.success, false, JSON.stringify(written)); + assert.match(String(written.error), /"outer\/child\/y\.txt" is inside mount "inner", which is nested in "outer" at child\/: use "inner\/y\.txt"/); + const owed = await call('materialize', {}); + assert.doesNotMatch(JSON.stringify(owed), /child\/y\.txt/, 'the outer mount holds nothing at that path'); + assert.throws(() => readFileSync(join(outerDir, 'child', 'y.txt')), "nothing was written into the inner mount's directory"); + // The disk readers refuse it by its name, and through an alias, which + // only the check of the path disk opens can see. + symlinkSync(join(outerDir, 'child'), join(outerDir, 'alias')); + await assert.rejects(workspace.readFileFromDisk('outer/child/x.txt'), /"outer\/child\/x\.txt" is inside mount "inner", which is nested in "outer" at child\/: use "inner\/x\.txt"/); + await assert.rejects(workspace.readFileFromDisk('outer/alias/x.txt'), /"outer\/alias\/x\.txt" is inside mount "inner", which is nested in "outer": use "inner\/x\.txt"/); + const raw = await workspace.readBinaryFromDisk('outer/alias/x.txt'); + assert.ok('error' in raw && /"outer\/alias\/x\.txt" is inside mount "inner"/.test(raw.error), JSON.stringify(raw)); + }); +} test("a path named with '.' segments is read as its one spelling, and the ignore note says so", async (t) => { const { workspace } = workspaceWith(t, ['node_modules/pkg.js', 'top.txt'], 'inner', ['node_modules']); From 6276e6af077cdabc110ff6a787e911367ec8f4ae Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Mon, 12 Oct 2026 18:48:41 -0400 Subject: [PATCH 07/26] fix(workspace): a nested root configured as a link to a directory not made yet reads as the link's target canonicalRoot resolved the nearest existing ancestor and appended the rest. For a root configured as a symlink whose target doesn't exist yet (`inner-link` -> `outer/child`, before `child` is made), realpath fails on the link itself, so it walked up to the link's own directory and appended `inner-link`: the root read as a path outside the outer mount. Nesting was missed at construction and by every fresh reading, so the outer mount's tools took a write to `outer/child/y.txt` into its tree, and with autoMaterialize the write made `child/` on disk through the outer mount, inside what becomes the inner mount's directory. Now a component that realpath can't resolve but lstat shows to be a symlink is read as its target: a relative target from the link's directory as disk spells it, then canonicalized the same way. Links followed this way are bounded at 40, as the platform bounds a path's. - test: a nested mount configured as a link to a directory not made yet, with autoMaterialize on and off: the ignore rule is in place at construction, a write is refused with the tools' text before the root exists, nothing is made on disk or owed, and once the root is made, the outer mount's sync passes over it. Without the link reading, both fail. - changelog: a link on the way that leads to nothing yet reads as what it names. --- changelog.d/nested-mount-ignore.fixed.md | 16 +++++++------ src/modules/workspace/index.ts | 25 +++++++++++++------ test/workspace-nested-mount-ignore.test.ts | 28 +++++++++++++++++++++- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/changelog.d/nested-mount-ignore.fixed.md b/changelog.d/nested-mount-ignore.fixed.md index f895c0756..bd04da557 100644 --- a/changelog.d/nested-mount-ignore.fixed.md +++ b/changelog.d/nested-mount-ignore.fixed.md @@ -41,13 +41,15 @@ An inner mount in a directory whose name starts with `..` (such as `..cache`) is now recognized as nested. A root that doesn't exist yet is read through its nearest existing - directory, so a mount configured at `via/child`, where `via` links into - another mount, is nested there before `child` is made. Each pass also - reads nesting afresh, so a mount that comes to lie inside another after - startup (its root made later, or a symlinked parent created) is treated - like one found at startup: the outer mount's tools refuse its paths - before anything reaches the outer mount's workspace, and the checks - below refuse it however it is reached. + directory, and a link on the way that leads to nothing yet is read as + what it names. So a mount configured at `via/child`, where `via` links + into another mount, or at a link to that mount's `child`, is nested + there before `child` is made. Each pass also reads nesting afresh, so a + mount that comes to lie inside another after startup (its root made + later, or a symlinked parent created) is treated like one found at + startup: the outer mount's tools refuse its paths before anything + reaches the outer mount's workspace, and the checks below refuse it + however it is reached. - The outer mount's tools refuse a path inside a nested mount, naming the mount to use: `outer/a/b/x.txt` is "inside mount "inner", which is nested in "outer" at a/b/: use "inner/x.txt"". That covers `read`, `write`, diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index d1e893040..4b40b05c0 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -5,7 +5,7 @@ * auto-sync between real filesystem and Chronicle, and manual materialization. */ -import { constants as fsConstants, realpathSync } from 'node:fs'; +import { constants as fsConstants, lstatSync, readlinkSync, realpathSync } from 'node:fs'; import type { Stats } from 'node:fs'; import { open, stat, lstat, realpath } from 'node:fs/promises'; import { basename, dirname, join, resolve, relative, sep } from 'node:path'; @@ -206,19 +206,30 @@ function matchesPartialPrefix(buffer: Buffer, prefix: Buffer): boolean { } /** - * A mount root as disk spells it: the canonical path of its nearest existing - * ancestor, joined with the rest as configured. A root that doesn't exist yet - * still reads through a symlinked parent, so a mount configured at - * `via/child`, where `via` links into another mount, is nested in it before - * `child` is made. + * A mount root as disk will spell it: the canonical path of its nearest + * existing ancestor, joined with the rest as configured, where a symlink on + * the way that leads to nothing yet is read as what it names. So a root that + * doesn't exist yet still reads through a symlinked parent, or as a dangling + * link's target: a mount configured at `via/child`, where `via` links into + * another mount, or at a link to that mount's `child`, is nested in it before + * `child` is made. `hops` bounds the links followed, as the platform bounds a + * path's. */ -function canonicalRoot(path: string): string { +function canonicalRoot(path: string, hops = 0): string { let existing = resolve(path); const rest: string[] = []; for (;;) { try { return join(realpathSync.native(existing), ...rest); } catch { + if (hops < 40) { + let target: string | null = null; + try { + // A relative target is read from the link's directory as disk spells it. + if (lstatSync(existing).isSymbolicLink()) target = resolve(canonicalRoot(dirname(existing), hops + 1), readlinkSync(existing)); + } catch { /* not there, or unreadable: read its parent instead */ } + if (target !== null) return join(canonicalRoot(target, hops + 1), ...rest); + } const parent = dirname(existing); if (parent === existing) return resolve(path); rest.unshift(basename(existing)); diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index eea150650..a6c464aab 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -24,7 +24,7 @@ import test, { type TestContext } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, win32 } from 'node:path'; import { JsStore } from '@animalabs/chronicle'; @@ -457,6 +457,32 @@ test("a nested mount whose root doesn't exist yet, configured through a symlinke }); for (const autoMaterialize of [true, false]) { + test(`a nested mount configured as a link to a directory not made yet is nested from the start (autoMaterialize ${autoMaterialize})`, async (t) => { + const { root, stops } = scratch(t, 'af-nested-mount-dangling-'); + const outerDir = join(root, 'outer'); + mkdirSync(outerDir); + writeFileSync(join(outerDir, 'top.txt'), 'top'); + // `inner-link` names the outer mount's `child`, which doesn't exist yet. + symlinkSync(join(outerDir, 'child'), join(root, 'inner-link')); + const outer: MountConfig = { name: 'outer', path: outerDir, mode: 'read-write', watch: 'never', autoMaterialize }; + const { workspace } = await started(stops, join(root, 'workspace.chronicle'), [ + outer, + { name: 'inner', path: join(root, 'inner-link'), mode: 'read-write', watch: 'never' }, + ]); + assert.deepEqual(outer.ignore, ['child/**'], 'the inner root reads as the link names it, before it exists'); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + const written = await call('write', { path: 'outer/child/y.txt', content: 'an outer write' }); + assert.equal(written.success, false, JSON.stringify(written)); + assert.match(String(written.error), /"outer\/child\/y\.txt" is inside mount "inner", which is nested in "outer" at child\/: use "inner\/y\.txt"/); + assert.equal(existsSync(join(outerDir, 'child')), false, "nothing made the inner mount's directory"); + const owed = await call('materialize', {}); + assert.doesNotMatch(JSON.stringify(owed), /child\/y\.txt/, 'the outer mount holds nothing at that path'); + // Once the root is made, the outer mount's sync passes over it. + mkdirSync(join(outerDir, 'child')); + writeFileSync(join(outerDir, 'child', 'x.txt'), "the inner mount's"); + assert.deepEqual(await syncedByOuter(workspace), ['top.txt']); + }); + test(`a mount that comes to lie in another after the module is built is refused there, whatever path reaches it (autoMaterialize ${autoMaterialize})`, async (t) => { const { root, stops } = scratch(t, 'af-nested-mount-later-'); const outerDir = join(root, 'outer'); From a50b67b16a5d919acfe21283966a2689a2b4d91b Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Mon, 12 Oct 2026 18:50:01 -0400 Subject: [PATCH 08/26] refactor(workspace): the raw reader keeps its too-large size as a number, and a test holds its size limit readBinaryFromDisk kept the size its check found too large in a one-key Map; a number says the same thing directly. Its size limit had no test since the reader moved onto openContainedFile, so the disk-readers test now reads an in-mount link with maxBytes below its size and expects the reader's old text, "File too large: > bytes". Dropping that answer fails it. --- src/modules/workspace/index.ts | 7 +++---- test/workspace-nested-mount-ignore.test.ts | 3 +++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 4b40b05c0..2cf2a7f28 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -1600,18 +1600,17 @@ export class WorkspaceModule implements Module { // The other readers' boundary, checked against the file actually opened: // a symlink may lead only within this mount, never into a mount nested in // it. Symlinks within the mount are followed, whatever its followSymlinks. - const tooLarge = new Map<'size', number>(); + let tooLargeSize: number | undefined; let opened: Awaited>; try { opened = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => { if (opts.maxBytes !== undefined && fileStat.size > opts.maxBytes) { - tooLarge.set('size', fileStat.size); + tooLargeSize = fileStat.size; throw new Error('too large'); } }, { follow: true }); } catch (error) { - const size = tooLarge.get('size'); - if (size !== undefined) return { error: `File too large: ${size} > ${opts.maxBytes} bytes` }; + if (tooLargeSize !== undefined) return { error: `File too large: ${tooLargeSize} > ${opts.maxBytes} bytes` }; if (error instanceof WorkspaceReadError) { if (error.code === 'not_found' && (error.errno === 'ENOENT' || error.errno === 'ENOTDIR')) { return { error: `File not found on disk: ${mountPrefixedPath}` }; diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index a6c464aab..eb436560c 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -422,6 +422,9 @@ test("the disk readers reach nothing of a nested mount through an alias, and the const own = await workspace.readBinaryFromDisk('outer/own-link.txt'); assert.ok('data' in own, 'error' in own ? own.error : ''); assert.equal(own.data.toString(), "the outer mount's"); + // Its size limit is checked on the file it opens, with its old text. + const capped = await workspace.readBinaryFromDisk('outer/own-link.txt', { maxBytes: 3 }); + assert.deepEqual(capped, { error: `File too large: ${Buffer.byteLength("the outer mount's")} > 3 bytes` }); const out = await workspace.readBinaryFromDisk('outer/out-link.txt'); assert.ok('error' in out); assert.match(out.error, /Symlink escape detected: outer\/out-link\.txt/); From 93e723b2460a29bc44366e471730be60e51653bf Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Mon, 12 Oct 2026 18:58:30 -0400 Subject: [PATCH 09/26] fix(workspace): a root not made yet is read in the filesystem's order, so a `..` comes after the link before it 6276e6a read a dangling link's target by resolving it against the link's directory with path.resolve, which drops `..` lexically before any link before it is followed. The filesystem follows the link first: with `base/hop -> outer/deep`, `inner-link -> base/hop/../child` names `outer/child`, but the fallback read `base/child`, so the inner mount wasn't nested and the outer mount's tools took a write to `outer/child/y.txt` (with autoMaterialize, onto disk too). The configured path itself had the same flaw: canonicalRoot began with path.resolve. canonicalRoot now returns realpath's answer when the root exists, and otherwise walks the path component by component from its root: an existing prefix is read with realpath, a dangling link is replaced by its target's components (a relative one read from the directory reached so far), `..` takes the parent of the directory reached so far, and only what doesn't exist is joined as written. A relative configured path is joined to the working directory without normalizing. At most 40 dangling links are followed. - test: the unmade-root test covers three spellings, each with autoMaterialize on and off: a link to the outer mount's `child`; a link whose target is `base/hop/../child`; and the configured path `/base/hop/../child`. Reading a link's target lexically fails the second, normalizing the configured path first fails the third, and following no dangling link fails the first two. - changelog: says how a root not made yet is read. --- changelog.d/nested-mount-ignore.fixed.md | 11 +-- src/modules/workspace/index.ts | 81 +++++++++++++++------- test/workspace-nested-mount-ignore.test.ts | 44 +++++++++--- 3 files changed, 97 insertions(+), 39 deletions(-) diff --git a/changelog.d/nested-mount-ignore.fixed.md b/changelog.d/nested-mount-ignore.fixed.md index bd04da557..413f702fb 100644 --- a/changelog.d/nested-mount-ignore.fixed.md +++ b/changelog.d/nested-mount-ignore.fixed.md @@ -40,11 +40,12 @@ filesystem, is still nested, and the rule is spelled as disk spells it. An inner mount in a directory whose name starts with `..` (such as `..cache`) is now recognized as nested. - A root that doesn't exist yet is read through its nearest existing - directory, and a link on the way that leads to nothing yet is read as - what it names. So a mount configured at `via/child`, where `via` links - into another mount, or at a link to that mount's `child`, is nested - there before `child` is made. Each pass also reads nesting afresh, so a + A root that doesn't exist yet is read as the filesystem will read it: + through the directories that exist, a link on the way that leads to + nothing yet as what it names, and a `..` after any link before it is + followed. So a mount configured at `via/child`, where `via` links into + another mount, or at a link to that mount's `child`, is nested there + before `child` is made. Each pass also reads nesting afresh, so a mount that comes to lie inside another after startup (its root made later, or a symlinked parent created) is treated like one found at startup: the outer mount's tools refuse its paths before anything diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 2cf2a7f28..d6c250c59 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -8,7 +8,7 @@ import { constants as fsConstants, lstatSync, readlinkSync, realpathSync } from 'node:fs'; import type { Stats } from 'node:fs'; import { open, stat, lstat, realpath } from 'node:fs/promises'; -import { basename, dirname, join, resolve, relative, sep } from 'node:path'; +import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path'; import type { JsStore } from '@animalabs/chronicle'; import type { Module, ModuleContext, ProcessState, EventResponse } from '../../types/module.js'; import type { ProcessEvent, ToolDefinition, ToolCall, ToolResult } from '../../types/events.js'; @@ -206,36 +206,67 @@ function matchesPartialPrefix(buffer: Buffer, prefix: Buffer): boolean { } /** - * A mount root as disk will spell it: the canonical path of its nearest - * existing ancestor, joined with the rest as configured, where a symlink on - * the way that leads to nothing yet is read as what it names. So a root that - * doesn't exist yet still reads through a symlinked parent, or as a dangling - * link's target: a mount configured at `via/child`, where `via` links into - * another mount, or at a link to that mount's `child`, is nested in it before - * `child` is made. `hops` bounds the links followed, as the platform bounds a - * path's. + * A mount root as disk will spell it once it exists. A root that exists is + * its canonical path. One that doesn't yet is read the way the filesystem + * will read it: component by component from the start, each existing prefix + * as disk spells it, a link that leads to nothing yet replaced by what it + * names, and `..` taken from the directory reached so far. Only what doesn't + * exist is joined as written. So a mount configured at `via/child`, where + * `via` links into another mount, or at a link to that mount's `child` + * (`base/hop/../child` included, where `hop` is a link), is nested in it + * before `child` is made. At most 40 dangling links are followed, as the + * platform bounds a path's. */ -function canonicalRoot(path: string, hops = 0): string { - let existing = resolve(path); - const rest: string[] = []; - for (;;) { +function canonicalRoot(path: string): string { + // Nothing here normalizes `..` before a link on the way is followed. + const absolute = isAbsolute(path) ? path : `${process.cwd()}${sep}${path}`; + try { + return realpathSync.native(absolute); + } catch { + // Some part of it doesn't exist yet: walk it as the filesystem will. + } + const parts = (p: string): string[] => p.split(sep === '\\' ? /[\\/]+/ : '/').filter((c) => c.length > 0); + const root = parse(absolute).root; + let current = root; + const queue = parts(absolute.slice(root.length)); + let links = 0; + while (queue.length > 0) { + const part = queue.shift()!; + if (part === '.') continue; + if (part === '..') { + current = dirname(current); + continue; + } + const next = join(current, part); try { - return join(realpathSync.native(existing), ...rest); + current = realpathSync.native(next); + continue; } catch { - if (hops < 40) { - let target: string | null = null; - try { - // A relative target is read from the link's directory as disk spells it. - if (lstatSync(existing).isSymbolicLink()) target = resolve(canonicalRoot(dirname(existing), hops + 1), readlinkSync(existing)); - } catch { /* not there, or unreadable: read its parent instead */ } - if (target !== null) return join(canonicalRoot(target, hops + 1), ...rest); + // Missing, or a link that leads to nothing yet. + } + let target: string | null = null; + if (links < 40) { + try { + if (lstatSync(next).isSymbolicLink()) target = readlinkSync(next); + } catch { + // Not there. } - const parent = dirname(existing); - if (parent === existing) return resolve(path); - rest.unshift(basename(existing)); - existing = parent; + } + if (target === null) { + current = next; + continue; + } + links++; + // A relative target is read from the link's directory, `current`. + if (isAbsolute(target)) { + const targetRoot = parse(target).root; + current = targetRoot; + queue.unshift(...parts(target.slice(targetRoot.length))); + } else { + queue.unshift(...parts(target)); } } + return current; } function isContainedPath(root: string, candidate: string): boolean { diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index eb436560c..cccac7a27 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -26,7 +26,7 @@ import test, { type TestContext } from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { dirname, join, win32 } from 'node:path'; +import { dirname, join, sep, win32 } from 'node:path'; import { JsStore } from '@animalabs/chronicle'; import { WorkspaceModule } from '../src/modules/workspace/index.js'; import { nestedAt } from '../src/modules/workspace/observe.js'; @@ -459,20 +459,44 @@ test("a nested mount whose root doesn't exist yet, configured through a symlinke assert.equal(readFileSync(join(outerDir, 'child', 'x.txt'), 'utf8'), "the inner mount's"); }); -for (const autoMaterialize of [true, false]) { - test(`a nested mount configured as a link to a directory not made yet is nested from the start (autoMaterialize ${autoMaterialize})`, async (t) => { - const { root, stops } = scratch(t, 'af-nested-mount-dangling-'); +// Spellings of a root that will be the outer mount's `child`, made before it +// exists: `base/hop` links to the outer mount's `deep`, so `base/hop/..` is +// the outer mount, as the filesystem reads it (`..` after following `hop`). +const unmadeRootSpellings: Array<{ name: string; spell: (root: string) => string }> = [ + { + name: 'a link to it', + spell: (root) => { + symlinkSync(join(root, 'outer', 'child'), join(root, 'inner-link')); + return join(root, 'inner-link'); + }, + }, + { + name: "a link whose target climbs out of a linked directory with '..'", + spell: (root) => { + symlinkSync(['base', 'hop', '..', 'child'].join(sep), join(root, 'inner-link')); + return join(root, 'inner-link'); + }, + }, + { + name: "a path that climbs out of a linked directory with '..'", + spell: (root) => [root, 'base', 'hop', '..', 'child'].join(sep), + }, +]; + +for (const autoMaterialize of [true, false]) for (const { name: spelling, spell } of unmadeRootSpellings) { + test(`a nested mount configured as ${spelling}, before its directory is made, is nested from the start (autoMaterialize ${autoMaterialize})`, async (t) => { + const { root, stops } = scratch(t, 'af-nested-mount-unmade-'); const outerDir = join(root, 'outer'); - mkdirSync(outerDir); + mkdirSync(join(outerDir, 'deep'), { recursive: true }); + mkdirSync(join(root, 'base')); + symlinkSync(join(outerDir, 'deep'), join(root, 'base', 'hop')); writeFileSync(join(outerDir, 'top.txt'), 'top'); - // `inner-link` names the outer mount's `child`, which doesn't exist yet. - symlinkSync(join(outerDir, 'child'), join(root, 'inner-link')); const outer: MountConfig = { name: 'outer', path: outerDir, mode: 'read-write', watch: 'never', autoMaterialize }; const { workspace } = await started(stops, join(root, 'workspace.chronicle'), [ outer, - { name: 'inner', path: join(root, 'inner-link'), mode: 'read-write', watch: 'never' }, + { name: 'inner', path: spell(root), mode: 'read-write', watch: 'never' }, ]); - assert.deepEqual(outer.ignore, ['child/**'], 'the inner root reads as the link names it, before it exists'); + assert.deepEqual(outer.ignore, ['child/**'], 'the inner root reads as the filesystem will read it, before it exists'); const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); const written = await call('write', { path: 'outer/child/y.txt', content: 'an outer write' }); assert.equal(written.success, false, JSON.stringify(written)); @@ -485,7 +509,9 @@ for (const autoMaterialize of [true, false]) { writeFileSync(join(outerDir, 'child', 'x.txt'), "the inner mount's"); assert.deepEqual(await syncedByOuter(workspace), ['top.txt']); }); +} +for (const autoMaterialize of [true, false]) { test(`a mount that comes to lie in another after the module is built is refused there, whatever path reaches it (autoMaterialize ${autoMaterialize})`, async (t) => { const { root, stops } = scratch(t, 'af-nested-mount-later-'); const outerDir = join(root, 'outer'); From 507072a3e3b7525f8d2377154fdd335da397f00d Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Mon, 12 Oct 2026 19:14:12 -0400 Subject: [PATCH 10/26] fix(workspace): a mount's configured path has one meaning, path.resolve's, for nesting and for every operation 93e723b read a configured root in the filesystem's order (a `..` after the link before it), while the mount's own operations read the same string other ways: its sync through resolve(), the tools' containment check against the raw string, the disk readers through realpath of the raw string, and the watcher's prefix arithmetic on the raw string. With `/base/hop/../child` (hop -> outer/deep) and both base/child and outer/child present, nesting saw outer/child, the inner mount's sync took base/child, and its read was refused as path traversal. Every one of them now reads the configured path as resolve() does, the reading the module's sync, path keys and disk-agreement records already use: - canonicalRoot starts from resolve(path). A dangling link's target is still read in the filesystem's order, since that is how the OS will open a path through it. - parsePath's containment compares against the resolved root. - openContainedFile takes realpath of the resolved root. - MountWatcher keeps resolve(config.path) as its root, for its mkdir, watch, identity and prefix arithmetic. - test: the third unmade-root spelling (a configured path with `..`) becomes its own test with both directories present and different contents. The inner mount is not nested in the outer one; its tool read and readFileFromDisk read base/child's file; the outer mount reads outer/child's; and a watcher configured with that path (and a trailing separator) reports a change in base/child relative to its root. Each of the four sites, reverted alone, fails it: the tools' raw comparison (traversal), the readers' raw realpath (symlink escape), the watcher's raw root (no change seen), and nesting in the filesystem's order (nested, ignore rule added). - changelog: says so. --- changelog.d/nested-mount-ignore.fixed.md | 5 ++ src/modules/workspace/index.ts | 30 +++++----- src/modules/workspace/watcher.ts | 21 ++++--- test/workspace-nested-mount-ignore.test.ts | 65 ++++++++++++++++++++-- 4 files changed, 92 insertions(+), 29 deletions(-) diff --git a/changelog.d/nested-mount-ignore.fixed.md b/changelog.d/nested-mount-ignore.fixed.md index 413f702fb..bd32bcd86 100644 --- a/changelog.d/nested-mount-ignore.fixed.md +++ b/changelog.d/nested-mount-ignore.fixed.md @@ -74,4 +74,9 @@ used to read. - Every workspace tool reads a path in one spelling: `a/./b`, `a//b` and `a/b/` are `a/b`. + - A mount whose configured path has `.` or `..` segments is read the way + `path.resolve` reads it, by nesting and by every operation alike. Before, + its own sync did that, while the tools refused its files as path + traversal, the disk readers followed the path through links first, and + its watcher reported none of its changes. - (agent-framework #280) diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index d6c250c59..d2db863b1 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -206,20 +206,22 @@ function matchesPartialPrefix(buffer: Buffer, prefix: Buffer): boolean { } /** - * A mount root as disk will spell it once it exists. A root that exists is - * its canonical path. One that doesn't yet is read the way the filesystem - * will read it: component by component from the start, each existing prefix - * as disk spells it, a link that leads to nothing yet replaced by what it - * names, and `..` taken from the directory reached so far. Only what doesn't - * exist is joined as written. So a mount configured at `via/child`, where - * `via` links into another mount, or at a link to that mount's `child` - * (`base/hop/../child` included, where `hop` is a link), is nested in it - * before `child` is made. At most 40 dangling links are followed, as the - * platform bounds a path's. + * A mount root as disk will spell it once it exists. The configured path is + * read as every workspace operation reads it, `resolve(path)`, so the root + * this names is the one the mount's own sync, tools and readers use. From + * there, a root that exists is its canonical path. One that doesn't yet is + * read the way the filesystem will read it: component by component, each + * existing prefix as disk spells it, a link that leads to nothing yet + * replaced by what it names, and a `..` in that target taken from the + * directory reached so far, after any link before it. Only what doesn't exist + * is joined as written. So a mount configured at `via/child`, where `via` + * links into another mount, or at a link to that mount's `child` (a link + * whose target is `base/hop/../child` included, where `hop` is a link), is + * nested in it before `child` is made. At most 40 dangling links are + * followed, as the platform bounds a path's. */ function canonicalRoot(path: string): string { - // Nothing here normalizes `..` before a link on the way is followed. - const absolute = isAbsolute(path) ? path : `${process.cwd()}${sep}${path}`; + const absolute = resolve(path); try { return realpathSync.native(absolute); } catch { @@ -1678,7 +1680,7 @@ export class WorkspaceModule implements Module { // prefix-matches there and every in-mount path was rejected as traversal. const root = resolve(mount.config.path); const resolved = resolve(root, written); - if (!isContainedPath(mount.config.path, resolved)) { + if (!isContainedPath(root, resolved)) { throw new Error(`Path traversal detected: "${path}" resolves outside mount "${mountName}"`); } // One spelling per path: `a/./b`, `a//b` and `a/b/` are `a/b`, as the @@ -1791,7 +1793,7 @@ export class WorkspaceModule implements Module { let realMountRoot: string; try { - realMountRoot = await realpath(mount.config.path); + realMountRoot = await realpath(resolve(mount.config.path)); } catch (err) { throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`, 'realpath_root', errnoOf(err)); } diff --git a/src/modules/workspace/watcher.ts b/src/modules/workspace/watcher.ts index 84b7f9056..0fff5b329 100644 --- a/src/modules/workspace/watcher.ts +++ b/src/modules/workspace/watcher.ts @@ -7,7 +7,7 @@ import { watch, type FSWatcher } from 'chokidar'; import { mkdirSync, statSync } from 'node:fs'; -import { basename, sep } from 'node:path'; +import { basename, resolve, sep } from 'node:path'; import { type MountConfig } from './types.js'; import { coveredByIgnore } from './observe.js'; @@ -61,6 +61,8 @@ export class MountWatcher { private stopped = true; private readonly debounceMs: number; private readonly config: MountConfig; + /** The mount's root as every workspace operation reads its configured path. */ + private readonly root: string; private readonly onChangeCallback: (changes: FsChange[]) => void; private readonly lifecycle: WatcherLifecycle; @@ -70,6 +72,7 @@ export class MountWatcher { lifecycle: WatcherLifecycle = {}, ) { this.config = config; + this.root = resolve(config.path); this.debounceMs = config.watchDebounceMs ?? 300; this.onChangeCallback = onChange; this.lifecycle = lifecycle; @@ -96,7 +99,7 @@ export class MountWatcher { // directories a user asked us only to read would be surprising. if (this.config.mode === 'read-write') { try { - mkdirSync(this.config.path, { recursive: true }); + mkdirSync(this.root, { recursive: true }); } catch (err) { this.lifecycle.onError?.(err instanceof Error ? err : new Error(String(err))); } @@ -109,7 +112,7 @@ export class MountWatcher { // into an ignored directory. const patterns = this.config.ignore ?? []; - this.watcher = watch(this.config.path, { + this.watcher = watch(this.root, { ignored: patterns.length > 0 ? (path: string) => { const relative = this.toRelative(path); @@ -157,7 +160,7 @@ export class MountWatcher { if (event !== 'rename') return; // A rename on the watched root's path/basename is the one signal that // survives inode reuse on Linux, where unlinkDir/error never fire. - if (this.isRootPath(rawPath) || basename(rawPath) === basename(this.config.path)) { + if (this.isRootPath(rawPath) || basename(rawPath) === basename(this.root)) { void this.checkRootLiveness({ force: true }); } }); @@ -354,7 +357,7 @@ export class MountWatcher { } private readRootIdentity(): RootIdentity { - const stat = statSync(this.config.path); + const stat = statSync(this.root); return { dev: stat.dev, ino: stat.ino, birthtimeMs: stat.birthtimeMs }; } @@ -377,9 +380,7 @@ export class MountWatcher { // Chokidar hands back platform-separated absolute paths; compare with // the platform separator and normalize the result to the '/'-separated // logical form the rest of the workspace uses. No-op on POSIX. - const base = this.config.path.endsWith(sep) - ? this.config.path - : this.config.path + sep; + const base = this.root.endsWith(sep) ? this.root : this.root + sep; if (absolutePath.startsWith(base)) { return absolutePath.slice(base.length).split(sep).join('/'); } @@ -387,9 +388,7 @@ export class MountWatcher { } private isRootPath(absolutePath: string): boolean { - const root = this.config.path.endsWith(sep) - ? this.config.path.slice(0, -1) - : this.config.path; + const root = this.root.endsWith(sep) ? this.root.slice(0, -1) : this.root; const candidate = absolutePath.endsWith(sep) ? absolutePath.slice(0, -1) : absolutePath; diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index cccac7a27..56e6c835d 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -477,12 +477,69 @@ const unmadeRootSpellings: Array<{ name: string; spell: (root: string) => string return join(root, 'inner-link'); }, }, - { - name: "a path that climbs out of a linked directory with '..'", - spell: (root) => [root, 'base', 'hop', '..', 'child'].join(sep), - }, ]; +test("a mount configured with '..' after a link reads that path as every workspace operation does, so nesting and its own files agree", async (t) => { + const { root, stops } = scratch(t, 'af-nested-mount-written-'); + const outerDir = join(root, 'outer'); + mkdirSync(join(outerDir, 'deep'), { recursive: true }); + mkdirSync(join(outerDir, 'child')); + writeFileSync(join(outerDir, 'child', 'x.txt'), 'through the link'); + mkdirSync(join(root, 'base', 'child'), { recursive: true }); + writeFileSync(join(root, 'base', 'child', 'x.txt'), 'as written'); + symlinkSync(join(outerDir, 'deep'), join(root, 'base', 'hop')); + const outer: MountConfig = { name: 'outer', path: outerDir, mode: 'read-write', watch: 'never' }; + // Through the link, `base/hop/..` is the outer mount; as written, it is + // `base`. The workspace reads a configured path as written, so this inner + // mount is `base/child` for its own operations and for nesting alike. + const { workspace } = await started(stops, join(root, 'workspace.chronicle'), [ + outer, + { name: 'inner', path: [root, 'base', 'hop', '..', 'child'].join(sep), mode: 'read-write', watch: 'never' }, + ]); + assert.equal(outer.ignore, undefined, 'not nested in the outer mount'); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + const synced = await call('sync', { mount: 'inner' }); + assert.equal(synced.success, true, String(synced.error)); + const own = await call('read', { path: 'inner/x.txt' }); + assert.equal(own.success, true, String(own.error)); + assert.match((own.data as { content: string }).content, /as written/); + assert.equal((await workspace.readFileFromDisk('inner/x.txt')).bytes.toString(), 'as written'); + const outers = await call('read', { path: 'outer/child/x.txt' }); + assert.equal(outers.success, true, String(outers.error)); + assert.match((outers.data as { content: string }).content, /through the link/); + + // Its watcher watches the same root, and reports a change there relative to it. + const configured = [root, 'base', 'hop', '..', 'child', ''].join(sep); + const seen: FsChange[] = []; + let ready!: () => void; + const isReady = new Promise((resolveReady) => { ready = resolveReady; }); + const watcher = new MountWatcher( + { name: 'inner', path: configured, mode: 'read-write', watch: 'always', watchDebounceMs: 50, watchRootPollMs: 50 }, + (changes) => { seen.push(...changes); }, + { onReady: () => ready() }, + ); + const backend = { usePolling: process.env.CHOKIDAR_USEPOLLING, interval: process.env.CHOKIDAR_INTERVAL }; + process.env.CHOKIDAR_USEPOLLING = 'true'; + process.env.CHOKIDAR_INTERVAL = '25'; + try { + watcher.start(); + } finally { + if (backend.usePolling === undefined) delete process.env.CHOKIDAR_USEPOLLING; + else process.env.CHOKIDAR_USEPOLLING = backend.usePolling; + if (backend.interval === undefined) delete process.env.CHOKIDAR_INTERVAL; + else process.env.CHOKIDAR_INTERVAL = backend.interval; + } + stops.push(() => watcher.stop()); + await isReady; + // A new file in each round, until one is reported: the warm-up the + // nested-mount watch test explains. + for (let round = 0; round < 25 && !seen.some((c) => c.path.startsWith('new-')); round++) { + writeFileSync(join(root, 'base', 'child', `new-${round}.txt`), 'new'); + await new Promise((resolveWait) => setTimeout(resolveWait, 200)); + } + assert.ok(seen.some((c) => /^new-\d+\.txt$/.test(c.path)), `a change under base/child was reported relative to the root: ${JSON.stringify(seen)}`); +}); + for (const autoMaterialize of [true, false]) for (const { name: spelling, spell } of unmadeRootSpellings) { test(`a nested mount configured as ${spelling}, before its directory is made, is nested from the start (autoMaterialize ${autoMaterialize})`, async (t) => { const { root, stops } = scratch(t, 'af-nested-mount-unmade-'); From 11b44536568e1e0d98c4b4586c2f5cd90f1a8606 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Mon, 12 Oct 2026 19:16:45 -0400 Subject: [PATCH 11/26] docs(workspace): the fragment says a configured root is resolved first, and only a link's target is read in the filesystem's order The sentence about a root that doesn't exist yet read as if the configured path's own `..` were counted after links, which 507072a chose against: the configured path is resolved as path.resolve resolves it, and only a dangling link's target is read in the filesystem's order. --- changelog.d/nested-mount-ignore.fixed.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/changelog.d/nested-mount-ignore.fixed.md b/changelog.d/nested-mount-ignore.fixed.md index bd32bcd86..e16b10533 100644 --- a/changelog.d/nested-mount-ignore.fixed.md +++ b/changelog.d/nested-mount-ignore.fixed.md @@ -40,10 +40,12 @@ filesystem, is still nested, and the rule is spelled as disk spells it. An inner mount in a directory whose name starts with `..` (such as `..cache`) is now recognized as nested. - A root that doesn't exist yet is read as the filesystem will read it: - through the directories that exist, a link on the way that leads to - nothing yet as what it names, and a `..` after any link before it is - followed. So a mount configured at `via/child`, where `via` links into + A root that doesn't exist yet is its configured path, resolved as + `path.resolve` resolves it, then read as the filesystem will read it: + through the directories that exist, with a link on the way that leads + to nothing yet read as what it names. A `..` in such a link's target + counts after any link before it in that target, as the filesystem + counts it. So a mount configured at `via/child`, where `via` links into another mount, or at a link to that mount's `child`, is nested there before `child` is made. Each pass also reads nesting afresh, so a mount that comes to lie inside another after startup (its root made From 03b65fd7af17f2ee5f63cdc625c50ea0e73325f0 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Mon, 12 Oct 2026 19:32:50 -0400 Subject: [PATCH 12/26] fix(workspace): registering a generated file reads the configured root as path.resolve does, as the tools do 507072a gave a mount's configured path one meaning, path.resolve's, at four sites. It missed a fifth. generatedFileKey resolved the target from the root but checked containment against the raw configured string. With a root configured as `/base/hop/../child`, registering `inner/live.txt` threw "Path traversal detected", while a direct or a linked spelling of a root registered and read the file. The check now compares against resolve(mount.path), as parsePath does. The key it returns is unchanged, since resolve(resolve(p), rel) is resolve(p, rel). - test: the configured-root test registers a generated file in the inner mount and reads it back as its owner. Comparing against the raw string again fails it with the traversal error. - changelog: it names the registration among what refused such a mount's files. It also says the disk readers refused them only where a link came before a `..`, because they checked against the root reached through that link. The module's other uses of a mount's configured path only report it (the mounted and watcher-error events, status, the mounts listing) or join it with path.join, which normalizes `.` and `..` as resolve does. --- changelog.d/nested-mount-ignore.fixed.md | 9 ++++++--- src/modules/workspace/index.ts | 7 +++++-- test/workspace-nested-mount-ignore.test.ts | 5 +++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/changelog.d/nested-mount-ignore.fixed.md b/changelog.d/nested-mount-ignore.fixed.md index e16b10533..9474991aa 100644 --- a/changelog.d/nested-mount-ignore.fixed.md +++ b/changelog.d/nested-mount-ignore.fixed.md @@ -78,7 +78,10 @@ `a/b/` are `a/b`. - A mount whose configured path has `.` or `..` segments is read the way `path.resolve` reads it, by nesting and by every operation alike. Before, - its own sync did that, while the tools refused its files as path - traversal, the disk readers followed the path through links first, and - its watcher reported none of its changes. + its own sync did that, while its tools refused its files as path + traversal, and so did registering a generated file in it, such as an + agent's tool catalogue. Where a link came before a `..`, the disk readers + checked its files against a different root, the one reached by following + that link first, and refused them. Its watcher reported none of its + changes. - (agent-framework #280) diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index d2db863b1..01279af5c 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -1379,8 +1379,11 @@ export class WorkspaceModule implements Module { const mountName = slash < 0 ? path : path.slice(0, slash); const mount = this.config.mounts.find(m => m.name === mountName); if (!mount) throw new Error(`Unknown mount: "${mountName}"`); - const resolved = resolve(mount.path, slash < 0 ? '' : path.slice(slash + 1)); - if (!isContainedPath(mount.path, resolved)) throw new Error(`Path traversal detected: "${path}"`); + // The root as parsePath reads it, so a configured path with `.` or `..` + // segments contains its own files here too. + const root = resolve(mount.path); + const resolved = resolve(root, slash < 0 ? '' : path.slice(slash + 1)); + if (!isContainedPath(root, resolved)) throw new Error(`Path traversal detected: "${path}"`); return resolved; } diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index 56e6c835d..feffb2205 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -504,6 +504,11 @@ test("a mount configured with '..' after a link reads that path as every workspa assert.equal(own.success, true, String(own.error)); assert.match((own.data as { content: string }).content, /as written/); assert.equal((await workspace.readFileFromDisk('inner/x.txt')).bytes.toString(), 'as written'); + // A generated file registers there and reads back, as in a mount configured plainly. + workspace.registerGeneratedTextFile('inner/live.txt', () => 'generated content', 'owner'); + const generated = await workspace.handleToolCall({ id: 'generated', name: 'read', callerAgentName: 'owner', input: { path: 'inner/live.txt' } }); + assert.equal(generated.success, true, String(generated.error)); + assert.match((generated.data as { content: string }).content, /generated content/); const outers = await call('read', { path: 'outer/child/x.txt' }); assert.equal(outers.success, true, String(outers.error)); assert.match((outers.data as { content: string }).content, /through the link/); From fc7c5b929e23c692599f8430bf7e61fc10d0986b Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 01:41:33 -0400 Subject: [PATCH 13/26] fix(workspace): a path into a nested mount by how disk spells it is refused at the tool; the size check waits for the boundary From a review of the change: - A file link into a nested mount: when the outer mount holds a copy of a path that has since become a link into a nested mount, only the write to disk, which follows the link, can refuse it. A test now pins that check (writeContained's nested check): a copy is written, the path is replaced on disk by a link into the nested mount, and a forced materialize is refused there, leaving the inner mount's file as it was. Removing the check fails it. - A path that reaches a nested mount by how disk spells it: a case variant on a case-insensitive disk (A/B for a/b), or a link inside the mount, passed the tools' lexical check, so a write landed in the outer mount's store. write, edit, writeBinary and delete now read the path as canonicalRoot reads a root (what exists as disk spells it, a dangling link replaced by what it names, the rest joined as written) and treat a path inside a nested mount as they treat one written that way: write, edit and writeBinary refuse it before anything reaches the store, and delete stops tracking an old copy without touching disk, or refuses. The case-variant test runs where the disk ignores case and is skipped elsewhere; links, dangling and not, exercise the same read everywhere. - A size limit no longer answers for a file the boundary refuses: openContainedFile runs its caller's inspect after the canonical checks, so readBinaryFromDisk and read_image refuse a link out of the mount, or into a nested one, as such, without reporting its size. - The located read's nested check (openContained) is kept: a pass observes a path before reading it, and the read checks the file it actually opened, as the escape check beside it does, so a link swapped in between the two reaches nothing. A test calls readPath directly on such a link and fails without the check. - The later-nesting test now makes a call before the nesting exists, and its first call after is a write, so a nesting read once and kept would fail it. The refusal tests also check that the outer store holds nothing at the refused path. Mount roots are still read synchronously on each call, as before this round. Reading them without blocking the event loop, where a root sits on a share whose server has gone away, is a change of its own across the workspace module, the watcher's root polls included. The changelog fragment says the same. Every workspace test file passes, one test skipped where the disk tells case apart. --- changelog.d/nested-mount-ignore.fixed.md | 16 +- src/modules/workspace/index.ts | 64 +++++-- test/workspace-nested-mount-ignore.test.ts | 205 +++++++++++++++++++-- 3 files changed, 257 insertions(+), 28 deletions(-) diff --git a/changelog.d/nested-mount-ignore.fixed.md b/changelog.d/nested-mount-ignore.fixed.md index 9474991aa..1a67c641d 100644 --- a/changelog.d/nested-mount-ignore.fixed.md +++ b/changelog.d/nested-mount-ignore.fixed.md @@ -47,10 +47,10 @@ counts after any link before it in that target, as the filesystem counts it. So a mount configured at `via/child`, where `via` links into another mount, or at a link to that mount's `child`, is nested there - before `child` is made. Each pass also reads nesting afresh, so a - mount that comes to lie inside another after startup (its root made - later, or a symlinked parent created) is treated like one found at - startup: the outer mount's tools refuse its paths before anything + before `child` is made. Each pass and each tool call also reads + nesting afresh, so a mount that comes to lie inside another after + startup (its root made later, or a symlinked parent created) is treated + like one found at startup: the outer mount's tools refuse its paths before anything reaches the outer mount's workspace, and the checks below refuse it however it is reached. - The outer mount's tools refuse a path inside a nested mount, naming the @@ -58,7 +58,10 @@ in "outer" at a/b/: use "inner/x.txt"". That covers `read`, `write`, `edit`, `ls`, `glob`, `grep`, and a `sync` or `materialize` that names the path. Before, a named sync took the inner mount's files into the outer - one. + one. `write`, `edit`, `writeBinary` and `delete` also read where the + path leads on disk before anything reaches the outer mount's workspace, + so a case variant on a case-insensitive disk (`A/B` for `a/b`), or a link + inside the mount, dangling or not, is treated the same way. - The one exception is `delete` of a file the outer mount still holds there, taken in before this fix. It stops tracking the file in the outer mount and leaves disk alone, where the file is the inner mount's. @@ -73,7 +76,8 @@ boundary the other readers keep, checked against the file it opens: it still follows a symlink that stays within the mount, and it now refuses one that leads outside the mount ("Symlink escape detected"), which it - used to read. + used to read. Under a size limit, a file the boundary refuses is refused + as such, and its size isn't reported. - Every workspace tool reads a path in one spelling: `a/./b`, `a//b` and `a/b/` are `a/b`. - A mount whose configured path has `.` or `..` segments is read the way diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 581ad178b..f43b5cca2 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -209,10 +209,11 @@ function matchesPartialPrefix(buffer: Buffer, prefix: Buffer): boolean { } /** - * A mount root as disk will spell it once it exists. The configured path is - * read as every workspace operation reads it, `resolve(path)`, so the root - * this names is the one the mount's own sync, tools and readers use. From - * there, a root that exists is its canonical path. One that doesn't yet is + * A path as disk will spell it once it exists: a mount's root, or a path + * inside one. The configured path is read as every workspace operation reads + * it, `resolve(path)`, so the root this names is the one the mount's own + * sync, tools and readers use. From there, a path that exists is its + * canonical path. One that doesn't yet is * read the way the filesystem will read it: component by component, each * existing prefix as disk spells it, a link that leads to nothing yet * replaced by what it names, and a `..` in that target taken from the @@ -769,6 +770,32 @@ export class WorkspaceModule implements Module { return nested; } + /** + * The mount nested in `mount` that a mount-relative path lies in by how + * disk spells it, where the path as written may not show it: a case variant + * on a case-insensitive disk (`A/B` for `a/b`), or a link inside the mount, + * dangling or not. The path is read as canonicalRoot reads a root: what + * exists as disk spells it, a link that leads to nothing yet replaced by + * what it names, and only what doesn't exist joined as written. The tools + * that change the store (write, edit, writeBinary, delete) ask before + * anything reaches it, so the outer mount never takes a draft at a path + * that is the inner mount's. + */ + private nestedByDisk(mount: MountState, relativePath: string): { name: string; rest: string } | null { + const rootReal = canonicalRoot(mount.config.path); + const real = canonicalRoot(resolve(mount.config.path, relativePath)); + const inner = this.nestedRootsOf(mount, rootReal).find((n) => contained(n.real, real)); + return inner ? { name: inner.name, rest: relative(inner.real, real).split(sep).join('/') } : null; + } + + /** {@link nestedByDisk}'s refusal, naming where the path belongs. */ + private canonicalNestedRefusal(mount: MountState, relativePath: string, mountPrefixedPath: string): string | null { + const inner = this.nestedByDisk(mount, relativePath); + if (!inner) return null; + return `"${mountPrefixedPath}" is inside mount "${inner.name}", which is nested in "${mount.config.name}": ` + + `use "${inner.rest ? `${inner.name}/${inner.rest}` : inner.name}"`; + } + /** Refuse a canonical path that lies in a mount nested in `mount`, naming where it belongs. */ private nestedRefusal(mount: MountState, rootReal: string, real: string, mountPrefixedPath: string): string | null { const inner = this.nestedRootsOf(mount, rootReal).find((n) => contained(n.real, real)); @@ -1567,6 +1594,8 @@ export class WorkspaceModule implements Module { isError: true, }; } + const nestedHere = this.canonicalNestedRefusal(mount, relativePath, mountPrefixedPath); + if (nestedHere) return { success: false, error: nestedHere, isError: true }; if (mount.config.mode === 'read-only') { return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true }; } @@ -1790,9 +1819,10 @@ export class WorkspaceModule implements Module { * outside, on every platform (junctions included). * 4. Confirm the descriptor and the canonical path are the same inode. * - * `inspect` runs right after the descriptor is stat'd and before the - * containment work, so callers can impose size policy in the same order the - * image reader always has. The returned handle is the caller's to close. + * `inspect` runs once the file is known to be this mount's (after step 4), + * so a caller's size policy never answers for a file the boundary refuses: + * a link out of the mount, or into a mount nested in it, is refused as such, + * and its size isn't reported. The returned handle is the caller's to close. */ private async openContainedFile( mount: MountState, @@ -1862,7 +1892,6 @@ export class WorkspaceModule implements Module { if (!fileStat.isFile()) { throw new WorkspaceReadError('not_found', `File not found: ${mountPrefixedPath}`, 'fstat'); } - inspect?.(fileStat); if (!follow && !useNoFollow) { let postOpenInfo: Awaited>; @@ -1912,6 +1941,7 @@ export class WorkspaceModule implements Module { if (pathStat.dev !== fileStat.dev || pathStat.ino !== fileStat.ino) { throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'stat'); } + inspect?.(fileStat); return { handle, fileStat, realFilePath }; } catch (err) { @@ -2332,6 +2362,8 @@ export class WorkspaceModule implements Module { private async handleWrite(input: WriteInput): Promise { const { mount, relativePath } = this.parsePath(input.path); + const nestedHere = this.canonicalNestedRefusal(mount, relativePath, input.path); + if (nestedHere) return { success: false, error: nestedHere, isError: true }; if (mount.config.mode === 'read-only') { return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true }; } @@ -2368,6 +2400,8 @@ export class WorkspaceModule implements Module { private async handleEdit(input: EditInput): Promise { const { mount, relativePath } = this.parsePath(input.path); + const nestedHere = this.canonicalNestedRefusal(mount, relativePath, input.path); + if (nestedHere) return { success: false, error: nestedHere, isError: true }; if (mount.config.mode === 'read-only') { return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true }; } @@ -2441,7 +2475,15 @@ export class WorkspaceModule implements Module { } const store = this.getStore(); - const inner = this.nestedMountAt(mount, relativePath); + // Nested as written, or by how disk spells the path (a case variant, a + // link inside the mount). + const lexical = this.nestedMountAt(mount, relativePath); + const inner = lexical + ? { name: lexical.name, rest: relativePath.slice(lexical.at.length + 1), where: ` at ${lexical.at}/` } + : (() => { + const byDisk = this.nestedByDisk(mount, relativePath); + return byDisk ? { ...byDisk, where: '' } : null; + })(); if (inner) { // A copy the outer mount took in from a nested mount's directory before // #280's fix: deleting it stops tracking it here, and never touches @@ -2449,10 +2491,10 @@ export class WorkspaceModule implements Module { // path is refused, as every other tool refuses it. return this.withMount(mount, async (): Promise => { if (!store.treeGet(mount.treeStateId, relativePath)) { - const rest = relativePath.slice(inner.at.length + 1); + const rest = inner.rest; return { success: false, - error: `"${input.path}" is inside mount "${inner.name}", which is nested in "${mount.config.name}" at ${inner.at}/, ` + + error: `"${input.path}" is inside mount "${inner.name}", which is nested in "${mount.config.name}"${inner.where}, ` + `and "${mount.config.name}" doesn't hold it: use "${rest ? `${inner.name}/${rest}` : inner.name}"`, isError: true, }; diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index feffb2205..28452192e 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -24,12 +24,12 @@ import test, { type TestContext } from 'node:test'; import assert from 'node:assert/strict'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, sep, win32 } from 'node:path'; import { JsStore } from '@animalabs/chronicle'; import { WorkspaceModule } from '../src/modules/workspace/index.js'; -import { nestedAt } from '../src/modules/workspace/observe.js'; +import { nestedAt, readPath } from '../src/modules/workspace/observe.js'; import type { MountConfig } from '../src/modules/workspace/types.js'; import type { ModuleContext } from '../src/types/module.js'; import { MountWatcher, type FsChange } from '../src/modules/workspace/watcher.js'; @@ -285,6 +285,12 @@ function scratch(t: TestContext, prefix: string) { return { root, stops }; } +/** Whether a mount's store holds an entry at a mount-relative path. */ +function storeHolds(workspace: WorkspaceModule, mount: string, rel: string): boolean { + const internals = workspace as unknown as { mounts: Map; getStore(): { treeGet(id: string, rel: string): unknown } }; + return internals.getStore().treeGet(internals.mounts.get(mount)!.treeStateId, rel) != null; +} + /** A started workspace on a store at `storePath`; `stops` stops it at the end, if nothing has. */ async function started(stops: Array<() => Promise>, storePath: string, mounts: MountConfig[]) { const store = JsStore.openOrCreate({ path: storePath }); @@ -381,11 +387,176 @@ test('an alias inside a following outer mount reaches nothing of a nested mount' const data = synced.data as { totalSynced: number; skipped?: Array<{ path: string; reason: string }> }; assert.equal(data.totalSynced, 0, 'nothing taken in through the alias'); assert.match(JSON.stringify(data.skipped), /inside mount \\"inner\\", which is nested in this one/); + // The write is refused at the tool, as disk spells the path, so the outer + // mount's store never holds a draft there. const written = await call('write', { path: 'outer/alias/y.txt', content: 'through the alias' }); - assert.match(JSON.stringify(written), /inside mount \\"inner\\", which is nested in this one/, JSON.stringify(written)); + assert.equal(written.success, false); + assert.match(String(written.error), /"outer\/alias\/y\.txt" is inside mount "inner", which is nested in "outer": use "inner\/y\.txt"/); assert.throws(() => readFileSync(join(outerDir, 'a', 'b', 'y.txt')), "nothing was written into the inner mount's directory"); }); +test("a write or an edit through an alias into a nested mount is refused at the tool, with nothing to materialize", async (t) => { + // autoMaterialize off: nothing would reach disk to refuse it later, so the + // tool itself reads where the path leads. + const { root, stops } = scratch(t, 'af-nested-mount-alias-draft-'); + const outerDir = join(root, 'outer'); + mkdirSync(join(outerDir, 'a', 'b'), { recursive: true }); + writeFileSync(join(outerDir, 'a', 'b', 'x.txt'), "the inner mount's"); + symlinkSync(join(outerDir, 'a', 'b'), join(outerDir, 'alias')); + const { workspace } = await started(stops, join(root, 'workspace.chronicle'), [ + { name: 'outer', path: outerDir, mode: 'read-write', watch: 'never', followSymlinks: true, autoMaterialize: false }, + { name: 'inner', path: join(outerDir, 'a', 'b'), mode: 'read-write', watch: 'never' }, + ]); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + const written = await call('write', { path: 'outer/alias/y.txt', content: 'through the alias' }); + assert.equal(written.success, false); + assert.match(String(written.error), /"outer\/alias\/y\.txt" is inside mount "inner", which is nested in "outer": use "inner\/y\.txt"/); + const edited = await call('edit', { path: 'outer/alias/x.txt', oldString: 'inner', newString: 'outer' }); + assert.equal(edited.success, false); + assert.match(String(edited.error), /"outer\/alias\/x\.txt" is inside mount "inner", which is nested in "outer": use "inner\/x\.txt"/); + // Several parts that don't exist yet are joined in order. + const deep = await call('write', { path: 'outer/alias/new/deep/f.txt', content: 'deep' }); + assert.match(String(deep.error), /"outer\/alias\/new\/deep\/f\.txt" is inside mount "inner", which is nested in "outer": use "inner\/new\/deep\/f\.txt"/); + const binary = await workspace.writeBinary('outer/alias/z.bin', Buffer.from([1, 2, 3]), 'application/octet-stream'); + assert.equal(binary.success, false); + assert.match(String(binary.error), /"outer\/alias\/z\.bin" is inside mount "inner", which is nested in "outer": use "inner\/z\.bin"/); + for (const rel of ['alias/y.txt', 'alias/x.txt', 'alias/new/deep/f.txt', 'alias/z.bin']) { + assert.equal(storeHolds(workspace, 'outer', rel), false, `the outer mount's store holds nothing at ${rel}`); + } + assert.equal(readFileSync(join(outerDir, 'a', 'b', 'x.txt'), 'utf8'), "the inner mount's"); +}); + +test("a write through a dangling link into a nested mount is refused at the tool, for a file link and a directory link", async (t) => { + const { root, stops } = scratch(t, 'af-nested-mount-dangling-'); + const outerDir = join(root, 'outer'); + mkdirSync(join(outerDir, 'a', 'b'), { recursive: true }); + // Neither target exists yet: the links lead to nothing so far. + symlinkSync(join(outerDir, 'a', 'b', 'later.txt'), join(outerDir, 'flink')); + symlinkSync(join(outerDir, 'a', 'b', 'newdir'), join(outerDir, 'dlink')); + const { workspace } = await started(stops, join(root, 'workspace.chronicle'), [ + { name: 'outer', path: outerDir, mode: 'read-write', watch: 'never', followSymlinks: true, autoMaterialize: false }, + { name: 'inner', path: join(outerDir, 'a', 'b'), mode: 'read-write', watch: 'never' }, + ]); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + const file = await call('write', { path: 'outer/flink', content: 'through a dangling link' }); + assert.match(String(file.error), /"outer\/flink" is inside mount "inner", which is nested in "outer": use "inner\/later\.txt"/); + const dir = await call('write', { path: 'outer/dlink/f.txt', content: 'through a dangling directory link' }); + assert.match(String(dir.error), /"outer\/dlink\/f\.txt" is inside mount "inner", which is nested in "outer": use "inner\/newdir\/f\.txt"/); + assert.equal(storeHolds(workspace, 'outer', 'flink'), false); + assert.equal(storeHolds(workspace, 'outer', 'dlink/f.txt'), false); +}); + +test("a delete through a link into a nested mount stops tracking an old copy and touches nothing on disk, and is refused otherwise", async (t) => { + const { root, stops } = scratch(t, 'af-nested-mount-delete-alias-'); + const outerDir = join(root, 'outer'); + mkdirSync(join(outerDir, 'a', 'b'), { recursive: true }); + mkdirSync(join(outerDir, 'moved')); + writeFileSync(join(outerDir, 'a', 'b', 'z.txt'), "the inner mount's"); + const { workspace } = await started(stops, join(root, 'workspace.chronicle'), [ + { name: 'outer', path: outerDir, mode: 'read-write', watch: 'never', followSymlinks: true, autoMaterialize: false }, + { name: 'inner', path: join(outerDir, 'a', 'b'), mode: 'read-write', watch: 'never' }, + ]); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + // An old copy: written while `moved` was the outer mount's own directory, + // which then became a link into the nested mount. + const drafted = await call('write', { path: 'outer/moved/z.txt', content: 'an old copy' }); + assert.equal(drafted.success, true, String(drafted.error)); + rmSync(join(outerDir, 'moved'), { recursive: true }); + symlinkSync(join(outerDir, 'a', 'b'), join(outerDir, 'moved')); + const forgotten = await call('delete', { path: 'outer/moved/z.txt' }); + assert.equal(forgotten.success, true, String(forgotten.error)); + assert.match(JSON.stringify(forgotten.data), /no longer tracked by \\"outer\\"/); + assert.equal(storeHolds(workspace, 'outer', 'moved/z.txt'), false); + assert.equal(readFileSync(join(outerDir, 'a', 'b', 'z.txt'), 'utf8'), "the inner mount's", 'disk is untouched'); + // With no old copy, the delete is refused, naming the inner mount. + const refused = await call('delete', { path: 'outer/moved/z.txt' }); + assert.equal(refused.success, false); + assert.match(String(refused.error), /"outer\/moved\/z\.txt" is inside mount "inner", which is nested in "outer", and "outer" doesn't hold it: use "inner\/z\.txt"/); + assert.equal(readFileSync(join(outerDir, 'a', 'b', 'z.txt'), 'utf8'), "the inner mount's"); +}); + +test("read_image under the mount's size limit refuses a link out of the mount, or into a nested one, as such", async (t) => { + const { root, stops } = scratch(t, 'af-nested-mount-image-limit-'); + const outerDir = join(root, 'outer'); + mkdirSync(join(outerDir, 'a', 'b'), { recursive: true }); + writeFileSync(join(outerDir, 'a', 'b', 'i.png'), ONE_PIXEL_PNG); + writeFileSync(join(root, 'outside.png'), ONE_PIXEL_PNG); + symlinkSync(join(outerDir, 'a', 'b'), join(outerDir, 'alias')); + symlinkSync(join(root, 'outside.png'), join(outerDir, 'out.png')); + const { workspace } = await started(stops, join(root, 'workspace.chronicle'), [ + { name: 'outer', path: outerDir, mode: 'read-write', watch: 'never', followSymlinks: true, maxFileSize: 10 }, + { name: 'inner', path: join(outerDir, 'a', 'b'), mode: 'read-write', watch: 'never' }, + ]); + const image = (path: string) => workspace.handleToolCall({ id: path, name: 'read_image', input: { path } }); + const nested = await image('outer/alias/i.png'); + assert.match(String(nested.error), /"outer\/alias\/i\.png" is inside mount "inner", which is nested in "outer": use "inner\/i\.png"/); + const out = await image('outer/out.png'); + assert.match(String(out.error), /Symlink escape detected: outer\/out\.png/); +}); + +test("a case variant of a nested path is refused at the tool where the disk ignores case", async (t) => { + const { root, stops } = scratch(t, 'af-nested-mount-case-'); + const outerDir = join(root, 'outer'); + mkdirSync(join(outerDir, 'a', 'b'), { recursive: true }); + // Where the disk tells case apart, `A/B` would be a directory of the outer + // mount's own, so there's nothing to refuse. + if (!existsSync(join(outerDir, 'A', 'B'))) { + t.skip('this disk tells case apart'); + return; + } + const { workspace } = await started(stops, join(root, 'workspace.chronicle'), [ + { name: 'outer', path: outerDir, mode: 'read-write', watch: 'never', autoMaterialize: false }, + { name: 'inner', path: join(outerDir, 'a', 'b'), mode: 'read-write', watch: 'never' }, + ]); + const written = await workspace.handleToolCall({ id: 'write', name: 'write', input: { path: 'outer/A/B/y.txt', content: 'a case variant' } }); + assert.equal(written.success, false); + assert.match(String(written.error), /"outer\/A\/B\/y\.txt" is inside mount "inner", which is nested in "outer": use "inner\/y\.txt"/); + assert.equal(storeHolds(workspace, 'outer', 'A/B/y.txt'), false); +}); + +for (const autoMaterialize of [false, true]) { + test(`a write through a file link into a nested mount is refused when it would reach disk, and the inner mount's file is untouched (autoMaterialize ${autoMaterialize})`, async (t) => { + // The outer mount holds a copy of flink.txt from before the path became a + // link into the nested mount: a draft with autoMaterialize off, and with + // it on, the file the write made on disk, which the link then replaces. + // Nothing refuses the copy at the tool: only the write to disk, which + // follows the link, finds where it now leads. + const { root, stops } = scratch(t, 'af-nested-mount-file-link-'); + const outerDir = join(root, 'outer'); + mkdirSync(join(outerDir, 'a', 'b'), { recursive: true }); + writeFileSync(join(outerDir, 'a', 'b', 'x.txt'), "the inner mount's"); + const { workspace } = await started(stops, join(root, 'workspace.chronicle'), [ + { name: 'outer', path: outerDir, mode: 'read-write', watch: 'never', followSymlinks: true, autoMaterialize }, + { name: 'inner', path: join(outerDir, 'a', 'b'), mode: 'read-write', watch: 'never' }, + ]); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + const drafted = await call('write', { path: 'outer/flink.txt', content: 'outer wrote' }); + assert.equal(drafted.success, true, String(drafted.error)); + if (existsSync(join(outerDir, 'flink.txt'))) rmSync(join(outerDir, 'flink.txt')); + symlinkSync(join(outerDir, 'a', 'b', 'x.txt'), join(outerDir, 'flink.txt')); + const forced = await call('materialize', { path: 'outer/flink.txt', force: true }); + assert.match(JSON.stringify(forced), /flink\.txt: not written: inside mount \\"inner\\", which is nested in this one/, JSON.stringify(forced)); + assert.equal(readFileSync(join(outerDir, 'a', 'b', 'x.txt'), 'utf8'), "the inner mount's", "the inner mount's file is as it was"); + }); +} + +test("the located read refuses a file it opens through a link into a nested mount, as the walk's observation does", async (t) => { + // A pass observes a path before it reads it, and the observation refuses a + // link into a nested mount. The read checks the file it actually opened + // again, as it checks that the file is in the mount at all, so a link + // swapped in between the two reaches nothing either. Called directly here, + // as a pass calls it after its observation. + const { root } = scratch(t, 'af-nested-mount-located-read-'); + const outerDir = join(root, 'outer'); + const innerDir = join(outerDir, 'a', 'b'); + mkdirSync(innerDir, { recursive: true }); + writeFileSync(join(innerDir, 'x.txt'), "the inner mount's"); + symlinkSync(join(innerDir, 'x.txt'), join(outerDir, 'flink.txt')); + const view = { root: outerDir, followSymlinks: true, maxFileSize: 1 << 20, ignore: [], nested: [{ name: 'inner', real: realpathSync(innerDir) }] }; + assert.deepEqual(await readPath(view, realpathSync(outerDir), 'flink.txt', 1 << 20), + { kind: 'unobserved', reason: 'inside mount "inner", which is nested in this one' }); +}); + test("the disk readers reach nothing of a nested mount through an alias, and the raw reader keeps the mount's boundary", async (t) => { const { root, stops } = scratch(t, 'af-nested-mount-readers-'); const outerDir = join(root, 'outer'); @@ -429,6 +600,13 @@ test("the disk readers reach nothing of a nested mount through an alias, and the assert.ok('error' in out); assert.match(out.error, /Symlink escape detected: outer\/out-link\.txt/); await assert.rejects(workspace.readFileFromDisk('outer/out-link.txt'), /Symlink escape detected/); + // Under a size limit, a file the boundary refuses is refused as such, and + // its size isn't told. + assert.deepEqual(await workspace.readBinaryFromDisk('outer/out-link.txt', { maxBytes: 3 }), + { error: 'Symlink escape detected: outer/out-link.txt' }); + const nestedCapped = await workspace.readBinaryFromDisk('outer/alias/x.txt', { maxBytes: 3 }); + assert.ok('error' in nestedCapped); + assert.match(nestedCapped.error, naming); }); test("a nested mount whose root doesn't exist yet, configured through a symlinked parent, is nested from the start", async (t) => { @@ -584,21 +762,26 @@ for (const autoMaterialize of [true, false]) { { name: 'outer', path: outerDir, mode: 'read-write', watch: 'never', followSymlinks: true, autoMaterialize }, { name: 'inner', path: join(root, 'via', 'child'), mode: 'read-write', watch: 'never' }, ]); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + // A first call reads the roots before the nesting exists, so the calls + // after it must read them again. + const before = await call('sync', { mount: 'outer' }); + assert.equal(before.success, true, String(before.error)); symlinkSync(outerDir, join(root, 'via')); mkdirSync(join(outerDir, 'child')); writeFileSync(join(outerDir, 'child', 'x.txt'), "the inner mount's"); - const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); - const synced = await call('sync', { mount: 'outer' }); - assert.equal(synced.success, true, String(synced.error)); - assert.doesNotMatch(JSON.stringify((synced.data as { results: unknown[] }).results), /"synced":\[[^\]]*child\/x\.txt/, 'not taken in'); - // The outer mount's tools refuse the path, naming the inner mount, as they - // do for a nesting found when the module was built. Without a materialize - // after the write, only that refusal can keep the write out of the outer - // mount's tree. + // The first call after the nesting appears is a write: the outer mount's + // tools refuse the path, naming the inner mount, as they do for a nesting + // found when the module was built. Without a materialize after the write, + // only that refusal can keep the write out of the outer mount's tree. const written = await call('write', { path: 'outer/child/y.txt', content: 'an outer write' }); assert.equal(written.success, false, JSON.stringify(written)); assert.match(String(written.error), /"outer\/child\/y\.txt" is inside mount "inner", which is nested in "outer" at child\/: use "inner\/y\.txt"/); + assert.equal(storeHolds(workspace, 'outer', 'child/y.txt'), false); + const synced = await call('sync', { mount: 'outer' }); + assert.equal(synced.success, true, String(synced.error)); + assert.doesNotMatch(JSON.stringify((synced.data as { results: unknown[] }).results), /"synced":\[[^\]]*child\/x\.txt/, 'not taken in'); const owed = await call('materialize', {}); assert.doesNotMatch(JSON.stringify(owed), /child\/y\.txt/, 'the outer mount holds nothing at that path'); assert.throws(() => readFileSync(join(outerDir, 'child', 'y.txt')), "nothing was written into the inner mount's directory"); From 538076f95c76cb103254be466306863bec031563 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 01:51:53 -0400 Subject: [PATCH 14/26] fix(workspace): a store-changing tool reads nesting in the mount's turn, beside its store change write, edit and writeBinary read whether a path lies in a nested mount before waiting for the mount's turn, and delete chose between forgetting an old copy, refusing and an ordinary delete there too. A call queued behind the turn (a pass, another write) then changed the store on a stale answer: if the path came to lie in a nested mount meanwhile, the write, edit or writeBinary stored a draft there, and the delete took the ordinary path. Each now reads it again in the turn, after its last await, in the same synchronous stretch as the store change, so nothing in the process changes nesting between the check and the change. write, edit and writeBinary keep their read before the turn as the quick answer, so a read-only outer mount still names the inner mount for them. A link swapped in on disk after that read still leaves a draft the store takes; materialize refuses it at the write to disk. Test, for each of write, edit, writeBinary and delete: the outer mount's turn is held, the call is made while the path is the outer mount's own directory, the directory is replaced by a link into the nested mount, and the turn is released. Each is refused, naming the inner mount, and the outer store is unchanged. Removing the read in the turn fails that test for each of the four; delete has no other read, so removing it also fails the test of a delete that forgets an old copy. --- src/modules/workspace/index.ts | 87 +++++++++++++--------- test/workspace-nested-mount-ignore.test.ts | 50 +++++++++++++ 2 files changed, 101 insertions(+), 36 deletions(-) diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index f43b5cca2..484a19044 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -1606,6 +1606,10 @@ export class WorkspaceModule implements Module { const store = this.getStore(); return this.withMount(mount, async () => { await this.settleBeforeMutation(mount, relativePath); + // Read again in the mount's turn, after the last await: the path may have + // come to lie in a nested mount while the write waited. + const nestedNow = this.canonicalNestedRefusal(mount, relativePath, mountPrefixedPath); + if (nestedNow) return { success: false, error: nestedNow, isError: true }; const blobHash = store.storeBlob(data, mimeType); const materializeError = await this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: data.byteLength }); if (materializeError) { @@ -2375,11 +2379,18 @@ export class WorkspaceModule implements Module { } const buffer = Buffer.from(input.content, 'utf-8'); - const materializeError = await this.withMount(mount, async () => { + const outcome = await this.withMount(mount, async (): Promise<{ refused: string } | { materializeError: string | null }> => { await this.settleBeforeMutation(mount, relativePath); + // Read again in the mount's turn, after the last await, so the store + // changes on what the path is now: it may have come to lie in a nested + // mount while the write waited. + const nestedNow = this.canonicalNestedRefusal(mount, relativePath, input.path); + if (nestedNow) return { refused: nestedNow }; const blobHash = store.storeBlob(buffer, 'text/plain'); - return this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: buffer.byteLength }); + return { materializeError: await this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: buffer.byteLength }) }; }); + if ('refused' in outcome) return { success: false, error: outcome.refused, isError: true }; + const materializeError = outcome.materializeError; if (materializeError) { return { success: false, @@ -2410,6 +2421,10 @@ export class WorkspaceModule implements Module { return this.withMount(mount, async (): Promise => { const report = await this.ensureSynced(mount, relativePath); await this.settleBeforeMutation(mount, relativePath); // before the edit reads the entry + // Read again in the mount's turn, after the last await: the path may have + // come to lie in a nested mount while the edit waited. + const nestedNow = this.canonicalNestedRefusal(mount, relativePath, input.path); + if (nestedNow) return { success: false, error: nestedNow, isError: true }; const entry = store.treeGet(mount.treeStateId, relativePath); if (!entry) { @@ -2476,45 +2491,45 @@ export class WorkspaceModule implements Module { const store = this.getStore(); // Nested as written, or by how disk spells the path (a case variant, a - // link inside the mount). - const lexical = this.nestedMountAt(mount, relativePath); - const inner = lexical - ? { name: lexical.name, rest: relativePath.slice(lexical.at.length + 1), where: ` at ${lexical.at}/` } - : (() => { - const byDisk = this.nestedByDisk(mount, relativePath); - return byDisk ? { ...byDisk, where: '' } : null; - })(); - if (inner) { - // A copy the outer mount took in from a nested mount's directory before - // #280's fix: deleting it stops tracking it here, and never touches - // disk, where the file is the inner mount's. Anything else at such a - // path is refused, as every other tool refuses it. - return this.withMount(mount, async (): Promise => { - if (!store.treeGet(mount.treeStateId, relativePath)) { - const rest = inner.rest; - return { - success: false, - error: `"${input.path}" is inside mount "${inner.name}", which is nested in "${mount.config.name}"${inner.where}, ` + - `and "${mount.config.name}" doesn't hold it: use "${rest ? `${inner.name}/${rest}` : inner.name}"`, - isError: true, - }; - } - store.treeRemove(mount.treeStateId, relativePath); - this.intents.get(mount.config.name)!.put(relativePath, null); - this.agreementOrThrow().forget(mount.config.name, relativePath); + // link inside the mount), decided in the mount's turn. + const nestedAt = (): { name: string; rest: string; where: string } | null => { + const lexical = this.nestedMountAt(mount, relativePath); + if (lexical) return { name: lexical.name, rest: relativePath.slice(lexical.at.length + 1), where: ` at ${lexical.at}/` }; + const byDisk = this.nestedByDisk(mount, relativePath); + return byDisk ? { ...byDisk, where: '' } : null; + }; + // A copy the outer mount took in from a nested mount's directory before + // #280's fix: deleting it stops tracking it here, and never touches disk, + // where the file is the inner mount's. Anything else at such a path is + // refused, as every other tool refuses it. + const nestedDelete = (inner: { name: string; rest: string; where: string }): ToolResult => { + if (!store.treeGet(mount.treeStateId, relativePath)) { return { - success: true, - data: { - path: input.path, - deleted: true, - note: `inside mount "${inner.name}": no longer tracked by "${mount.config.name}"; the file on disk is "${inner.name}"'s and was left as it is`, - }, + success: false, + error: `"${input.path}" is inside mount "${inner.name}", which is nested in "${mount.config.name}"${inner.where}, ` + + `and "${mount.config.name}" doesn't hold it: use "${inner.rest ? `${inner.name}/${inner.rest}` : inner.name}"`, + isError: true, }; - }); - } + } + store.treeRemove(mount.treeStateId, relativePath); + this.intents.get(mount.config.name)!.put(relativePath, null); + this.agreementOrThrow().forget(mount.config.name, relativePath); + return { + success: true, + data: { + path: input.path, + deleted: true, + note: `inside mount "${inner.name}": no longer tracked by "${mount.config.name}"; the file on disk is "${inner.name}"'s and was left as it is`, + }, + }; + }; return this.withMount(mount, async (): Promise => { const report = await this.ensureSynced(mount, relativePath); await this.settleBeforeMutation(mount, relativePath); + // Decided after the last await, beside the store change: the path may + // have come to lie in a nested mount while the delete waited. + const inner = nestedAt(); + if (inner) return nestedDelete(inner); const entry = store.treeGet(mount.treeStateId, relativePath); if (!entry) { return { success: false, error: this.notInWorkspace(input.path, report), isError: true }; diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index 28452192e..b625832e4 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -426,6 +426,56 @@ test("a write or an edit through an alias into a nested mount is refused at the assert.equal(readFileSync(join(outerDir, 'a', 'b', 'x.txt'), 'utf8'), "the inner mount's"); }); +for (const op of ['write', 'edit', 'writeBinary', 'delete'] as const) { + test(`${op === 'edit' ? 'an' : 'a'} ${op} that waits for the mount's turn is judged on the path as it is when its turn comes: a nesting made meanwhile is refused`, async (t) => { + // The check that counts runs in the mount's turn, after the last await, + // beside the store change, so a path that came to lie in a nested mount + // while the call waited changes nothing in the outer mount's store. + const { root, stops } = scratch(t, `af-nested-mount-queued-${op}-`); + const outerDir = join(root, 'outer'); + const innerDir = join(outerDir, 'inner'); + mkdirSync(innerDir, { recursive: true }); + mkdirSync(join(outerDir, 'alias')); + writeFileSync(join(innerDir, 'x.txt'), 'inner original'); + const { workspace } = await started(stops, join(root, 'workspace.chronicle'), [ + { name: 'outer', path: outerDir, mode: 'read-write', watch: 'never', followSymlinks: true, autoMaterialize: false }, + { name: 'inner', path: innerDir, mode: 'read-write', watch: 'never' }, + ]); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + if (op === 'edit') { + const drafted = await call('write', { path: 'outer/alias/x.txt', content: 'old draft' }); + assert.equal(drafted.success, true, String(drafted.error)); + } + const internals = workspace as unknown as { mounts: Map; withMount(m: unknown, f: () => Promise): Promise }; + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let entered!: () => void; + const inTurn = new Promise((resolve) => { entered = resolve; }); + const held = internals.withMount(internals.mounts.get('outer'), async () => { entered(); await gate; }); + await inTurn; + const pending = op === 'writeBinary' + ? workspace.writeBinary('outer/alias/x.txt', Buffer.from('binary draft'), 'application/octet-stream') + : op === 'write' + ? call('write', { path: 'outer/alias/x.txt', content: 'new draft' }) + : op === 'edit' + ? call('edit', { path: 'outer/alias/x.txt', oldString: 'old', newString: 'new' }) + : call('delete', { path: 'outer/alias/y.txt' }); + rmSync(join(outerDir, 'alias'), { recursive: true }); + symlinkSync(innerDir, join(outerDir, 'alias')); + release(); + await held; + const result = await pending; + assert.equal(result.success, false, JSON.stringify(result)); + assert.match(String(result.error), /is inside mount "inner", which is nested in "outer"/); + if (op === 'edit') { + assert.equal(storeHolds(workspace, 'outer', 'alias/x.txt'), true, 'the old copy is as it was'); + } else { + assert.equal(storeHolds(workspace, 'outer', op === 'delete' ? 'alias/y.txt' : 'alias/x.txt'), false, "nothing reaches the outer mount's store"); + } + assert.equal(readFileSync(join(innerDir, 'x.txt'), 'utf8'), 'inner original'); + }); +} + test("a write through a dangling link into a nested mount is refused at the tool, for a file link and a directory link", async (t) => { const { root, stops } = scratch(t, 'af-nested-mount-dangling-'); const outerDir = join(root, 'outer'); From fd6953a22aff41e928a37667da500d25fea3d40b Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 01:55:22 -0400 Subject: [PATCH 15/26] test(workspace): the queued edit's refusal leaves the old copy's bytes as they were --- test/workspace-nested-mount-ignore.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index b625832e4..ec332e5d3 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -468,7 +468,9 @@ for (const op of ['write', 'edit', 'writeBinary', 'delete'] as const) { assert.equal(result.success, false, JSON.stringify(result)); assert.match(String(result.error), /is inside mount "inner", which is nested in "outer"/); if (op === 'edit') { - assert.equal(storeHolds(workspace, 'outer', 'alias/x.txt'), true, 'the old copy is as it was'); + const internals2 = workspace as unknown as { mounts: Map; getStore(): { treeGet(id: string, rel: string): { blobHash: string } | null; getBlob(hash: string): Buffer | null } }; + const kept = internals2.getStore().treeGet(internals2.mounts.get('outer')!.treeStateId, 'alias/x.txt'); + assert.equal(kept && internals2.getStore().getBlob(kept.blobHash)?.toString(), 'old draft', 'the old copy is as it was'); } else { assert.equal(storeHolds(workspace, 'outer', op === 'delete' ? 'alias/y.txt' : 'alias/x.txt'), false, "nothing reaches the outer mount's store"); } From 764cd974f1f2d65c006cd4575ee49117642c43e0 Mon Sep 17 00:00:00 2001 From: Sebastian-2380 Date: Tue, 13 Oct 2026 03:10:32 -0400 Subject: [PATCH 16/26] fix(workspace): distinguish nested writes from directory-entry deletes Choose the innermost nested mount for lexical and disk-spelled refusals. Classify path-less materialize selections through the same disk-spelling rule: writes follow their full path, while pending unlinks resolve only the parent and retain the last entry. A removed outer link therefore reaches the existing disk-unlink refusal instead of receiving a repeated suggestion to delete its already-removed tree copy. For delete of an old nested copy, prefer the supplied store key, then the directory-entry spelling on disk. This preserves old alias keys while finding copies recorded under a walk's canonical spelling. Case-variant entry names are matched only after lstat confirms their identity. A failed lookup reports its I/O error rather than claiming the copy is absent. The lower-level refusal to unlink a final link into another mount remains unchanged, and the error no longer promises that a later materialize will remove it. Check read boundaries before opening the path or answering its kind, then check the opened file's path and identity as before. Directory links now receive the boundary refusal even on platforms that reject opening directories. Existing components retain realpath's spelling. For components that do not exist, lowercase on darwin and win32 and preserve spelling elsewhere. This uses platform defaults without creating a probe in a user's mount; it is not per-filesystem case detection. The changelog names non-default-filesystem limitations, the synchronous root and per-path reads, both refusal forms, and the remaining interval in which a disk change can leave a draft that materialize later refuses. Focused Linux checks: all workspace files passed 272 tests with one case-insensitive-filesystem test skipped. The nested-mount file subsequently passed 44 with that one skip after the operation-specific unlink correction. The initial new regressions failed seven behavioral cases against the reviewed source; the root-refusal control passed, and native case-variant deletion skipped on this case-sensitive directory. The Darwin-default test selects that policy on a real local directory; it does not emulate a case-insensitive filesystem. Co-authored-by: GPT-6 --- changelog.d/nested-mount-ignore.fixed.md | 14 +- src/modules/workspace/index.ts | 152 ++++++++------ test/workspace-nested-mount-ignore.test.ts | 225 +++++++++++++++++++-- 3 files changed, 315 insertions(+), 76 deletions(-) diff --git a/changelog.d/nested-mount-ignore.fixed.md b/changelog.d/nested-mount-ignore.fixed.md index 1a67c641d..842398c03 100644 --- a/changelog.d/nested-mount-ignore.fixed.md +++ b/changelog.d/nested-mount-ignore.fixed.md @@ -1,5 +1,4 @@ -- A mount never takes in or writes out what lies in a mount nested in it, and - a mount's ignore patterns now apply to its watcher. +- Workspace tools check nested-mount boundaries before and inside each mount's turn, and disk operations check again before their effects. A mount's ignore patterns now apply to its watcher. - The watcher handed the patterns to chokidar as strings, which chokidar takes as exact paths, so on a watched mount (`watch` defaults to `'always'`) every change under an ignored path, `node_modules` included, @@ -59,9 +58,7 @@ `edit`, `ls`, `glob`, `grep`, and a `sync` or `materialize` that names the path. Before, a named sync took the inner mount's files into the outer one. `write`, `edit`, `writeBinary` and `delete` also read where the - path leads on disk before anything reaches the outer mount's workspace, - so a case variant on a case-insensitive disk (`A/B` for `a/b`), or a link - inside the mount, dangling or not, is treated the same way. + path leads on disk at their checks, so existing case variants and links inside the mount, dangling or not, are checked too. A link changed after the store check can leave an outer draft at a now-nested path; materialize refuses the later disk write. - The one exception is `delete` of a file the outer mount still holds there, taken in before this fix. It stops tracking the file in the outer mount and leaves disk alone, where the file is the inner mount's. @@ -89,3 +86,10 @@ that link first, and refused them. Its watcher reported none of its changes. - (agent-framework #280) + +- Nested refusals name the innermost mount, even when several mounts contain the path. A lexical refusal includes its directory, such as `"outer/a/b/x.txt" is inside mount "inner", which is nested in "outer" at a/b/: use "inner/x.txt"`. A path reached through disk spelling omits that location clause: `"outer/via/x.txt" is inside mount "inner", which is nested in "outer": use "inner/x.txt"`. The public tool wrapper prefixes thrown lexical errors with `Error:`. +- An unmade path component is lowercased on macOS and Windows and preserved elsewhere. Existing components still use disk's spelling. This is a platform default, not a filesystem probe: case-sensitive macOS/Windows volumes can receive conservative refusals for unmade variants, and case-insensitive filesystems on other platforms are outside this missing-component rule. The workspace writes no case-probe entry into a user's mount. +- A path-less materialize lists copies reached through links or case variants as nested skips, with the same stop-tracking note as lexical copies. Deleting an old nested copy first tries its supplied store key, then its directory-entry spelling on disk. This lets a differently spelled path remove the copy stored by an earlier walk. +- Delete resolves the parent but keeps the final entry rather than following its link. An outer link into a nested mount therefore takes the ordinary delete path instead of claiming to remove an inner-owned file. The disk unlink's existing boundary refusal remains; the error reports that refusal without promising a later materialize can remove the link. +- Disk readers check containment and nested boundaries before answering a target's kind, including a directory reached through a link. They also check the opened file's path and identity. +- These checks make synchronous filesystem reads on the event loop for mount roots and target path components, including realpath, lstat, readlink, and the directory-entry lookup for an old copy's spelling. They can block while mounted storage does not answer. This is the existing synchronous-I/O limitation tracked in #311, now explicitly including per-path reads. diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 484a19044..72ca54927 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -5,7 +5,7 @@ * auto-sync between real filesystem and Chronicle, and manual materialization. */ -import { constants as fsConstants, lstatSync, readlinkSync, realpathSync } from 'node:fs'; +import { constants as fsConstants, lstatSync, readdirSync, readlinkSync, realpathSync } from 'node:fs'; import type { Stats } from 'node:fs'; import { open, stat, lstat, realpath } from 'node:fs/promises'; import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path'; @@ -209,20 +209,9 @@ function matchesPartialPrefix(buffer: Buffer, prefix: Buffer): boolean { } /** - * A path as disk will spell it once it exists: a mount's root, or a path - * inside one. The configured path is read as every workspace operation reads - * it, `resolve(path)`, so the root this names is the one the mount's own - * sync, tools and readers use. From there, a path that exists is its - * canonical path. One that doesn't yet is - * read the way the filesystem will read it: component by component, each - * existing prefix as disk spells it, a link that leads to nothing yet - * replaced by what it names, and a `..` in that target taken from the - * directory reached so far, after any link before it. Only what doesn't exist - * is joined as written. So a mount configured at `via/child`, where `via` - * links into another mount, or at a link to that mount's `child` (a link - * whose target is `base/hop/../child` included, where `hop` is a link), is - * nested in it before `child` is made. At most 40 dangling links are - * followed, as the platform bounds a path's. + * Resolve existing path components to disk's spelling, following up to 40 links. A dangling link contributes its target; a relative target is read from the directory reached so far, including any preceding link before a '..'. The starting path uses resolve(), like the workspace's tools, sync, and watcher. + * + * A component that does not exist has no disk spelling yet. Lowercase it on darwin and win32, and preserve it elsewhere. This uses platform defaults without writing a probe into the mount: case-sensitive macOS/Windows directories can receive conservative refusals, while case-insensitive filesystems on other platforms are outside this missing-component rule. Existing components retain their observed spelling. */ function canonicalRoot(path: string): string { const absolute = resolve(path); @@ -251,15 +240,21 @@ function canonicalRoot(path: string): string { // Missing, or a link that leads to nothing yet. } let target: string | null = null; + let missingPart = false; if (links < 40) { try { if (lstatSync(next).isSymbolicLink()) target = readlinkSync(next); - } catch { - // Not there. + } catch (error) { + missingPart = errnoOf(error) === 'ENOENT' || errnoOf(error) === 'ENOTDIR'; } } if (target === null) { - current = next; + // Existing components above retain disk's spelling. For an unmade + // component there is no spelling to observe: use the platform default, + // without creating a probe file in the user's mount. + const fold = missingPart && (process.platform === 'darwin' || process.platform === 'win32'); + const missing = fold ? part.toLowerCase() : part; + current = join(current, missing); continue; } links++; @@ -721,10 +716,9 @@ export class WorkspaceModule implements Module { private savedState: WorkspaceModuleState | null = null; constructor(config: WorkspaceConfig) { - // A mount never takes in or writes out what lies in a mount nested in it - // (agent-framework #280). Nesting is read from canonical roots, so a - // symlinked or differently cased spelling of the same directory still - // nests, and the rule is spelled as disk spells the path. The outer + // Nesting checks keep each mount's disk operations out of nested mounts + // (agent-framework #280). Roots use canonicalRoot: existing components + // use disk spelling; unmade components use its documented platform rule. The outer // mount's walk and watcher pass over the inner's directory by an ignore // rule; its tools refuse paths there (parsePath), and every containment // check refuses what reaches the inner's directory another way @@ -767,7 +761,7 @@ export class WorkspaceModule implements Module { const real = canonicalRoot(inner.config.path); if (real !== rootReal && contained(rootReal, real)) nested.push({ name: inner.config.name, real }); } - return nested; + return nested.sort((a, b) => b.real.length - a.real.length); } /** @@ -776,14 +770,16 @@ export class WorkspaceModule implements Module { * on a case-insensitive disk (`A/B` for `a/b`), or a link inside the mount, * dangling or not. The path is read as canonicalRoot reads a root: what * exists as disk spells it, a link that leads to nothing yet replaced by - * what it names, and only what doesn't exist joined as written. The tools - * that change the store (write, edit, writeBinary, delete) ask before - * anything reaches it, so the outer mount never takes a draft at a path - * that is the inner mount's. + * what it names, and unmade components using the platform-default rule. + * Store-changing tools check before the turn and at their mutation point. + * A later disk change can make an accepted draft's path nested; the disk + * effect checks that boundary again when materialize attempts the write. */ - private nestedByDisk(mount: MountState, relativePath: string): { name: string; rest: string } | null { + private nestedByDisk(mount: MountState, relativePath: string, followFinal = true): { name: string; rest: string } | null { const rootReal = canonicalRoot(mount.config.path); - const real = canonicalRoot(resolve(mount.config.path, relativePath)); + const path = resolve(mount.config.path, relativePath); + // Unlink addresses the directory entry, not the last link's target. + const real = followFinal ? canonicalRoot(path) : join(canonicalRoot(dirname(path)), basename(path)); const inner = this.nestedRootsOf(mount, rootReal).find((n) => contained(n.real, real)); return inner ? { name: inner.name, rest: relative(inner.real, real).split(sep).join('/') } : null; } @@ -828,7 +824,8 @@ export class WorkspaceModule implements Module { relativePath: string, ats: Array<{ name: string; at: string }> = this.nestedAtsOf(mount), ): { name: string; at: string } | undefined { - return ats.find((n) => relativePath === n.at || relativePath.startsWith(n.at + '/')); + return ats.filter((n) => relativePath === n.at || relativePath.startsWith(n.at + '/')) + .sort((a, b) => b.at.length - a.at.length)[0]; } /** @@ -1849,9 +1846,6 @@ export class WorkspaceModule implements Module { } throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'lstat', code); } - if (fileInfo.isDirectory()) { - throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'lstat'); - } if (fileInfo.isSymbolicLink() && !follow) { throw new WorkspaceReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`, 'lstat'); } @@ -1863,6 +1857,27 @@ export class WorkspaceModule implements Module { throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`, 'realpath_root', errnoOf(err)); } + const checkBoundary = (real: string): void => { + if (!isContainedPath(realMountRoot, real)) { + throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath'); + } + const nested = this.nestedRefusal(mount, realMountRoot, real, mountPrefixedPath); + if (nested) throw new WorkspaceReadError('escape', nested, 'realpath'); + }; + let beforeOpen: string; + try { + beforeOpen = await realpath(lexicalPath); + } catch (err) { + const code = errnoOf(err); + throw new WorkspaceReadError('not_found', + code === 'ENOENT' || code === 'ENOTDIR' ? `File not found: ${mountPrefixedPath}` : `Unable to resolve file: ${mountPrefixedPath}`, + 'realpath', code); + } + checkBoundary(beforeOpen); + if (fileInfo.isDirectory()) { + throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'lstat'); + } + let handle: Awaited>; try { handle = await open(lexicalPath, fsConstants.O_RDONLY | (useNoFollow ? fsConstants.O_NOFOLLOW : 0)); @@ -1890,13 +1905,6 @@ export class WorkspaceModule implements Module { code, ); } - if (fileStat.isDirectory()) { - throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat'); - } - if (!fileStat.isFile()) { - throw new WorkspaceReadError('not_found', `File not found: ${mountPrefixedPath}`, 'fstat'); - } - if (!follow && !useNoFollow) { let postOpenInfo: Awaited>; try { @@ -1924,13 +1932,13 @@ export class WorkspaceModule implements Module { throw new WorkspaceReadError('not_found', `Unable to resolve file: ${mountPrefixedPath}`, 'realpath', code); } - if (!isContainedPath(realMountRoot, realFilePath)) { - throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath'); + checkBoundary(realFilePath); + if (fileStat.isDirectory()) { + throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat'); + } + if (!fileStat.isFile()) { + throw new WorkspaceReadError('not_found', `File not found: ${mountPrefixedPath}`, 'fstat'); } - // A file in a mount nested in this one is that mount's, whatever path - // reaches it: an alias inside this mount reads none of it. - const nested = this.nestedRefusal(mount, realMountRoot, realFilePath, mountPrefixedPath); - if (nested) throw new WorkspaceReadError('escape', nested, 'realpath'); let pathStat: Stats; try { @@ -2495,7 +2503,7 @@ export class WorkspaceModule implements Module { const nestedAt = (): { name: string; rest: string; where: string } | null => { const lexical = this.nestedMountAt(mount, relativePath); if (lexical) return { name: lexical.name, rest: relativePath.slice(lexical.at.length + 1), where: ` at ${lexical.at}/` }; - const byDisk = this.nestedByDisk(mount, relativePath); + const byDisk = this.nestedByDisk(mount, relativePath, false); return byDisk ? { ...byDisk, where: '' } : null; }; // A copy the outer mount took in from a nested mount's directory before @@ -2503,7 +2511,34 @@ export class WorkspaceModule implements Module { // where the file is the inner mount's. Anything else at such a path is // refused, as every other tool refuses it. const nestedDelete = (inner: { name: string; rest: string; where: string }): ToolResult => { - if (!store.treeGet(mount.treeStateId, relativePath)) { + // Older scans stored disk's spelling. Prefer a copy at the supplied + // path (which may be an old alias), then resolve its directory entry. + let storedPath = relativePath; + if (!store.treeGet(mount.treeStateId, storedPath)) { + const absolute = resolve(mount.config.path, relativePath); + const parent = canonicalRoot(dirname(absolute)); + let name = basename(absolute); + try { + const entries = readdirSync(parent); + if (!entries.includes(name)) { + const named = lstatSync(join(parent, name)); + const actual = entries.find((entry) => { + if (entry.toLowerCase() !== name.toLowerCase()) return false; + const candidate = lstatSync(join(parent, entry)); + return candidate.dev === named.dev && candidate.ino === named.ino; + }); + if (actual !== undefined) name = actual; + } + storedPath = relative(canonicalRoot(mount.config.path), join(parent, name)).split(sep).join('/'); + } catch (error) { + if (errnoOf(error) !== 'ENOENT' && errnoOf(error) !== 'ENOTDIR') { + return { success: false, isError: true, + error: `Cannot identify the stored spelling of "${input.path}": ${error instanceof Error ? error.message : String(error)}` }; + } + // An absent disk entry supplies no alternate spelling. + } + } + if (!store.treeGet(mount.treeStateId, storedPath)) { return { success: false, error: `"${input.path}" is inside mount "${inner.name}", which is nested in "${mount.config.name}"${inner.where}, ` + @@ -2511,9 +2546,9 @@ export class WorkspaceModule implements Module { isError: true, }; } - store.treeRemove(mount.treeStateId, relativePath); - this.intents.get(mount.config.name)!.put(relativePath, null); - this.agreementOrThrow().forget(mount.config.name, relativePath); + store.treeRemove(mount.treeStateId, storedPath); + this.intents.get(mount.config.name)!.put(storedPath, null); + this.agreementOrThrow().forget(mount.config.name, storedPath); return { success: true, data: { @@ -2540,7 +2575,7 @@ export class WorkspaceModule implements Module { return { success: false, error: `Removed from Chronicle but did not unlink "${input.path}" from disk: ${materializeError}. ` + - 'The disk copy stays (ls shows its state) until materialize with applyDeletions removes it.', + 'The disk copy remains (ls shows its state). Resolve the reported refusal before retrying materialize with applyDeletions.', isError: true, }; } @@ -2931,11 +2966,14 @@ export class WorkspaceModule implements Module { // in before #280's fix) is never written there, nor deleted there: // the files are the inner mount's (agent-framework #280). const ats = this.nestedAtsOf(mount); - const nested = selected.flatMap((path) => { - const inner = this.nestedMountAt(mount, path, ats); - return inner ? [{ path, inner: inner.name }] : []; - }); - const paths = nested.length > 0 ? selected.filter((path) => !this.nestedMountAt(mount, path, ats)) : selected; + const nested: Array<{ path: string; inner: string }> = []; + const paths: string[] = []; + for (const path of selected) { + const writing = store.treeGet(mount.treeStateId, path) !== null; + const inner = this.nestedMountAt(mount, path, ats) ?? this.nestedByDisk(mount, path, writing); + if (inner) nested.push({ path, inner: inner.name }); + else paths.push(path); + } return { inPlace, nested, diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index ec332e5d3..64872c5eb 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -13,11 +13,10 @@ * The watcher read none of a mount's patterns: chokidar takes a string as one * exact path. It now reads them as the walk does. * - * #282's round makes the invariant whole: a mount never takes in or writes - * out what lies in a mount nested in it. Nesting is read from canonical - * roots; the outer mount's tools refuse paths in the inner's directory, - * naming the inner; and every containment check refuses a canonical path - * there, however it was reached. + * #282 checks nesting at tool admission, at the store mutation, and at + * disk effects. An intervening disk change can leave an outer draft whose + * later materialize is refused. Existing components use disk spelling; + * unmade components follow the documented platform-default case rule. * * Driven through the public tools, and a real watcher, over tmpdir mounts. */ @@ -26,6 +25,9 @@ import test, { type TestContext } from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; +import fsPromises from 'node:fs/promises'; +import fs from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; import { dirname, join, sep, win32 } from 'node:path'; import { JsStore } from '@animalabs/chronicle'; import { WorkspaceModule } from '../src/modules/workspace/index.js'; @@ -354,7 +356,7 @@ test("the outer mount's tools refuse a path in a nested mount, naming it; deleti assert.equal(readFileSync(join(outerDir, 'a', 'b', 'x.txt'), 'utf8'), "the inner mount's"); const again = await call('delete', { path: 'outer/a/b/x.txt' }); assert.equal(again.success, false); - assert.match(String(again.error), /doesn't hold it: use "inner\/x\.txt"/); + assert.equal(again.error, '"outer/a/b/x.txt" is inside mount "inner", which is nested in "outer" at a/b/, and "outer" doesn\'t hold it: use "inner/x.txt"'); // Nothing of it is owed any more: a later materialize doesn't list it. const after = await call('materialize', {}); assert.doesNotMatch(JSON.stringify(after), /a\/b\/x\.txt/, JSON.stringify(after)); @@ -552,18 +554,16 @@ test("a case variant of a nested path is refused at the tool where the disk igno mkdirSync(join(outerDir, 'a', 'b'), { recursive: true }); // Where the disk tells case apart, `A/B` would be a directory of the outer // mount's own, so there's nothing to refuse. - if (!existsSync(join(outerDir, 'A', 'B'))) { - t.skip('this disk tells case apart'); - return; - } + const ignoresCase = existsSync(join(outerDir, 'A', 'B')); + if (!ignoresCase) mkdirSync(join(outerDir, 'A', 'B'), { recursive: true }); const { workspace } = await started(stops, join(root, 'workspace.chronicle'), [ { name: 'outer', path: outerDir, mode: 'read-write', watch: 'never', autoMaterialize: false }, { name: 'inner', path: join(outerDir, 'a', 'b'), mode: 'read-write', watch: 'never' }, ]); const written = await workspace.handleToolCall({ id: 'write', name: 'write', input: { path: 'outer/A/B/y.txt', content: 'a case variant' } }); - assert.equal(written.success, false); - assert.match(String(written.error), /"outer\/A\/B\/y\.txt" is inside mount "inner", which is nested in "outer": use "inner\/y\.txt"/); - assert.equal(storeHolds(workspace, 'outer', 'A/B/y.txt'), false); + assert.equal(written.success, !ignoresCase, JSON.stringify(written)); + if (ignoresCase) assert.match(String(written.error), /"outer\/A\/B\/y\.txt" is inside mount "inner", which is nested in "outer": use "inner\/y\.txt"/); + assert.equal(storeHolds(workspace, 'outer', 'A/B/y.txt'), !ignoresCase); }); for (const autoMaterialize of [false, true]) { @@ -587,7 +587,7 @@ for (const autoMaterialize of [false, true]) { if (existsSync(join(outerDir, 'flink.txt'))) rmSync(join(outerDir, 'flink.txt')); symlinkSync(join(outerDir, 'a', 'b', 'x.txt'), join(outerDir, 'flink.txt')); const forced = await call('materialize', { path: 'outer/flink.txt', force: true }); - assert.match(JSON.stringify(forced), /flink\.txt: not written: inside mount \\"inner\\", which is nested in this one/, JSON.stringify(forced)); + assert.match(JSON.stringify(forced), /flink\.txt: inside mount \\"inner\\", which is nested in this one; delete it here to stop tracking it/, JSON.stringify(forced)); assert.equal(readFileSync(join(outerDir, 'a', 'b', 'x.txt'), 'utf8'), "the inner mount's", "the inner mount's file is as it was"); }); } @@ -855,3 +855,200 @@ test("a path named with '.' segments is read as its one spelling, and the ignore assert.deepEqual(data.results.flatMap((r) => r.synced), ['node_modules/pkg.js']); assert.deepEqual(data.ignored?.map((i) => i.path), ['node_modules/pkg.js']); }); + +test('#282 round: path-less materialize reports an aliased nested copy as skipped', async (t) => { + const { root, stops } = scratch(t, 'af-nested-round-materialize-'); + const outer = join(root, 'outer'); + mkdirSync(join(outer, 'inner'), { recursive: true }); + mkdirSync(join(outer, 'alias')); + writeFileSync(join(outer, 'inner', 'x.txt'), 'inner bytes'); + const { workspace } = await started(stops, join(root, 'store'), [ + { name: 'outer', path: outer, mode: 'read-write', watch: 'never', autoMaterialize: false, followSymlinks: true }, + { name: 'inner', path: join(outer, 'inner'), mode: 'read-write', watch: 'never' }, + ]); + const write = await workspace.handleToolCall({ id: 'write', name: 'write', input: { path: 'outer/alias/x.txt', content: 'old draft' } }); + assert.equal(write.success, true); + rmSync(join(outer, 'alias'), { recursive: true }); + symlinkSync(join(outer, 'inner'), join(outer, 'alias')); + const result = await workspace.handleToolCall({ id: 'push', name: 'materialize', input: { mount: 'outer' } }); + assert.match(JSON.stringify(result.data), /alias\/x.txt: inside mount \\"inner\\", which is nested in this one; delete it here to stop tracking it/); + assert.equal(readFileSync(join(outer, 'inner', 'x.txt'), 'utf8'), 'inner bytes'); +}); + +for (const destination of ['outside', 'inner'] as const) { + test(`#282 round: a directory link to ${destination} is refused by its boundary, not its kind`, async (t) => { + const { root, stops } = scratch(t, 'af-nested-round-directory-'); + const outer = join(root, 'outer'); + mkdirSync(join(outer, 'inner'), { recursive: true }); + mkdirSync(join(root, 'outside')); + symlinkSync(destination === 'inner' ? join(outer, 'inner') : join(root, 'outside'), join(outer, 'link')); + const { workspace } = await started(stops, join(root, 'store'), [ + { name: 'outer', path: outer, mode: 'read-write', watch: 'never', followSymlinks: true }, + { name: 'inner', path: join(outer, 'inner'), mode: 'read-write', watch: 'never' }, + ]); + await assert.rejects(workspace.readFileFromDisk('outer/link'), + destination === 'inner' + ? /"outer\/link" is inside mount "inner", which is nested in "outer": use "inner"$/ + : /Symlink escape detected: outer\/link$/); + }); +} + +for (const autoMaterialize of [false, true]) { + test(`#282 round: deleting an outer final link follows ordinary delete semantics (autoMaterialize ${autoMaterialize})`, async (t) => { + const { root, stops } = scratch(t, 'af-nested-round-unlink-'); + const outer = join(root, 'outer'); + mkdirSync(join(outer, 'inner'), { recursive: true }); + writeFileSync(join(outer, 'inner', 'x.txt'), 'inner bytes'); + const { workspace } = await started(stops, join(root, 'store'), [ + { name: 'outer', path: outer, mode: 'read-write', watch: 'never', autoMaterialize, followSymlinks: true }, + { name: 'inner', path: join(outer, 'inner'), mode: 'read-write', watch: 'never' }, + ]); + assert.equal((await workspace.handleToolCall({ id: 'w', name: 'write', input: { path: 'outer/link', content: 'old draft' } })).success, true); + rmSync(join(outer, 'link'), { force: true }); + symlinkSync(join(outer, 'inner', 'x.txt'), join(outer, 'link')); + const result = await workspace.handleToolCall({ id: 'd', name: 'delete', input: { path: 'outer/link' } }); + assert.equal(result.success, !autoMaterialize); + if (autoMaterialize) { + assert.match(String(result.error), /Removed from Chronicle but did not unlink/); + assert.match(String(result.error), /inside mount "inner"/); + assert.doesNotMatch(String(result.error), /until materialize with applyDeletions removes it/); + } else { + assert.deepEqual(result.data, { path: 'outer/link', deleted: true }); + } + assert.equal(storeHolds(workspace, 'outer', 'link'), false); + const later = await workspace.handleToolCall({ id: 'later', name: 'materialize', input: { mount: 'outer', applyDeletions: true, force: true } }); + assert.match(JSON.stringify(later), /inside mount/); + assert.doesNotMatch(JSON.stringify(later), /delete it here to stop tracking it/, + 'the copy is already deleted; report the disk refusal rather than suggesting the same delete again'); + assert.equal(readFileSync(join(outer, 'inner', 'x.txt'), 'utf8'), 'inner bytes'); + }); +} + +test('#282 round: every nested lookup names the innermost mount', async (t) => { + const { root, stops } = scratch(t, 'af-nested-round-deep-'); + const outer = join(root, 'outer'); + mkdirSync(join(outer, 'mid', 'deep'), { recursive: true }); + writeFileSync(join(outer, 'mid', 'deep', 'x.txt'), 'inner bytes'); + symlinkSync(join(outer, 'mid', 'deep'), join(outer, 'alias')); + const { workspace } = await started(stops, join(root, 'store'), [ + { name: 'outer', path: outer, mode: 'read-write', watch: 'never', followSymlinks: true }, + { name: 'mid', path: join(outer, 'mid'), mode: 'read-write', watch: 'never' }, + { name: 'inner', path: join(outer, 'mid', 'deep'), mode: 'read-write', watch: 'never' }, + ]); + const lexical = await workspace.handleToolCall({ id: 'l', name: 'write', input: { path: 'outer/mid/deep/new.txt', content: 'draft' } }); + assert.equal(lexical.error, 'Error: "outer/mid/deep/new.txt" is inside mount "inner", which is nested in "outer" at mid/deep/: use "inner/new.txt"'); + const alias = await workspace.handleToolCall({ id: 'a', name: 'write', input: { path: 'outer/alias/new.txt', content: 'draft' } }); + assert.equal(alias.error, '"outer/alias/new.txt" is inside mount "inner", which is nested in "outer": use "inner/new.txt"'); + await assert.rejects(workspace.readFileFromDisk('outer/alias/x.txt'), + /"outer\/alias\/x.txt" is inside mount "inner", which is nested in "outer": use "inner\/x.txt"$/); +}); + +test('#282 round: root-level disk-spelled write and delete refusals name the mount without a slash', async (t) => { + const { root, stops } = scratch(t, 'af-nested-round-root-'); + const outer = join(root, 'outer'); + mkdirSync(join(outer, 'a', 'b'), { recursive: true }); + symlinkSync(join(outer, 'a'), join(outer, 'via')); + const { workspace } = await started(stops, join(root, 'store'), [ + { name: 'outer', path: outer, mode: 'read-write', watch: 'never', followSymlinks: true }, + { name: 'inner', path: join(outer, 'a', 'b'), mode: 'read-write', watch: 'never' }, + ]); + const write = await workspace.handleToolCall({ id: 'w', name: 'write', input: { path: 'outer/via/b', content: 'draft' } }); + assert.equal(write.error, '"outer/via/b" is inside mount "inner", which is nested in "outer": use "inner"'); + const deleted = await workspace.handleToolCall({ id: 'd', name: 'delete', input: { path: 'outer/via/b' } }); + assert.equal(deleted.error, '"outer/via/b" is inside mount "inner", which is nested in "outer", and "outer" doesn\'t hold it: use "inner"'); +}); + +test('#282 round: the insensitive default covers an unmade nested root without changing existing spelling', async (t) => { + const { root, stops } = scratch(t, 'af-nested-round-default-case-'); + const outer = join(root, 'outer'); + mkdirSync(outer); + const { workspace } = await started(stops, join(root, 'store'), [ + { name: 'outer', path: outer, mode: 'read-write', watch: 'never', autoMaterialize: false }, + { name: 'inner', path: join(outer, 'Child'), mode: 'read-write', watch: 'never' }, + ]); + // This exercises the chosen platform policy on a real local directory. + // It is not a stand-in for a case-insensitive filesystem integration run. + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + try { + Object.defineProperty(process, 'platform', { ...platform, value: 'darwin' }); + const result = await workspace.handleToolCall({ id: 'w', name: 'write', input: { path: 'outer/child/x.txt', content: 'draft' } }); + assert.equal(result.success, false); + assert.match(String(result.error), /use "inner\/x.txt"/); + assert.equal(storeHolds(workspace, 'outer', 'child/x.txt'), false); + assert.equal(existsSync(join(outer, 'Child')), false); + } finally { Object.defineProperty(process, 'platform', platform); } +}); + +test('#282 round: a case-variant nested delete finds the stored disk spelling', async (t) => { + const { root, stops } = scratch(t, 'af-nested-round-delete-case-'); + const outer = join(root, 'outer'); + mkdirSync(join(outer, 'a', 'b'), { recursive: true }); + if (!existsSync(join(outer, 'A', 'B'))) { t.skip('requires a case-insensitive directory'); return; } + writeFileSync(join(outer, 'a', 'b', 'File.txt'), 'inner bytes'); + const { workspace } = await started(stops, join(root, 'store'), [ + { name: 'outer', path: outer, mode: 'read-write', watch: 'never' }, + { name: 'inner', path: join(outer, 'a', 'b'), mode: 'read-write', watch: 'never' }, + ]); + const store = (workspace as unknown as { getStore(): JsStore }).getStore(); + const blobHash = store.storeBlob(Buffer.from('old copy'), 'text/plain'); + store.treeSet('workspace/outer/tree', 'a/b/File.txt', { blobHash, size: 8, mode: 0o644 }); + const result = await workspace.handleToolCall({ id: 'd', name: 'delete', input: { path: 'outer/A/B/file.txt' } }); + assert.equal(result.success, true, JSON.stringify(result)); + assert.equal(storeHolds(workspace, 'outer', 'a/b/File.txt'), false); + assert.equal(readFileSync(join(outer, 'a', 'b', 'File.txt'), 'utf8'), 'inner bytes'); +}); + +test('#282 round: a nested delete reached by alias finds a copy stored under its disk spelling', async (t) => { + const { root, stops } = scratch(t, 'af-nested-round-stored-spelling-'); + const outer = join(root, 'outer'); + mkdirSync(join(outer, 'a', 'b'), { recursive: true }); + writeFileSync(join(outer, 'a', 'b', 'x.txt'), 'inner bytes'); + symlinkSync(join(outer, 'a', 'b'), join(outer, 'via')); + const { workspace } = await started(stops, join(root, 'store'), [ + { name: 'outer', path: outer, mode: 'read-write', watch: 'never', followSymlinks: true }, + { name: 'inner', path: join(outer, 'a', 'b'), mode: 'read-write', watch: 'never' }, + ]); + const store = (workspace as unknown as { getStore(): JsStore }).getStore(); + const blobHash = store.storeBlob(Buffer.from('old copy'), 'text/plain'); + store.treeSet('workspace/outer/tree', 'a/b/x.txt', { blobHash, size: 8, mode: 0o644 }); + const readDir = fs.readdirSync; + try { + fs.readdirSync = (() => { throw Object.assign(new Error('directory unavailable'), { code: 'EIO' }); }) as typeof readDir; + syncBuiltinESMExports(); + const refused = await workspace.handleToolCall({ id: 'unavailable', name: 'delete', input: { path: 'outer/via/x.txt' } }); + assert.match(String(refused.error), /Cannot identify the stored spelling.*directory unavailable/); + assert.equal(storeHolds(workspace, 'outer', 'a/b/x.txt'), true); + } finally { fs.readdirSync = readDir; syncBuiltinESMExports(); } + const result = await workspace.handleToolCall({ id: 'd', name: 'delete', input: { path: 'outer/via/x.txt' } }); + assert.equal(result.success, true, JSON.stringify(result)); + assert.equal(storeHolds(workspace, 'outer', 'a/b/x.txt'), false); + assert.equal(readFileSync(join(outer, 'a', 'b', 'x.txt'), 'utf8'), 'inner bytes'); +}); + +test('#282 round: boundary refusal precedes opening a directory', async (t) => { + const { root, stops } = scratch(t, 'af-nested-round-open-boundary-'); + const outer = join(root, 'outer'); + mkdirSync(outer); + mkdirSync(join(root, 'outside')); + writeFileSync(join(outer, 'own.txt'), 'owned'); + symlinkSync(join(root, 'outside'), join(outer, 'link')); + const { workspace } = await started(stops, join(root, 'store'), [ + { name: 'outer', path: outer, mode: 'read-write', watch: 'never', followSymlinks: true }, + ]); + const original = fsPromises.open; + let opens = 0; + try { + fsPromises.open = (async (...args: Parameters) => { + opens++; + return original(...args); + }) as typeof original; + syncBuiltinESMExports(); + assert.equal((await workspace.readFileFromDisk('outer/own.txt')).bytes.toString(), 'owned'); + assert.equal(opens, 1, 'the counter observes this reader'); + await assert.rejects(workspace.readFileFromDisk('outer/link'), /Symlink escape detected/); + assert.equal(opens, 1, 'a platform that refuses opening directories still receives the boundary answer'); + } finally { + fsPromises.open = original; + syncBuiltinESMExports(); + } +}); From e4f6b0adeccdf39905339466cb2891d5f94dca75 Mon Sep 17 00:00:00 2001 From: Sebastian-2380 Date: Tue, 13 Oct 2026 04:32:40 -0400 Subject: [PATCH 17/26] fix(workspace): finish nested untracking without guessing stored keys Keep resolved spelling separate from case comparison. Paths and generated ignore rules retain their spelling; an ephemeral missing-suffix marker lets the common nesting comparison ignore case on darwin and win32 only when one side is unmade. Known case-distinct components remain distinct. The same comparison reaches the disk-effect view, and materialize shares one root snapshot across its selection instead of resolving every configured root for each selected path. End nested deletions as untracking, not as disk actions that can never finish. Delete of an outer link into a nested mount forgets the exact tracked path and reports that any disk entry was left untouched. Materialize also clears legacy pending deletions at nested paths or through such links, independently of force and applyDeletions. Cleanup notes survive a branch block on the remaining physical work, and the next materialize has no pending deletion left to report. Forget disk agreement before removing the tree copy and intent so a partial failure leaves evidence unknown rather than falsely agreed. Decide nested deletion before observation, inside the mount's turn, and retain the post-await check for a path that begins ordinary. Otherwise observation can import and announce an alias solely for the delete to forget it. Remove the post-classification directory listing and stat lookup. The outer store supplies the keys. Only the supplied key or an exactly contained derived key can be removed. A case-folded match is an actionable hint naming the exact tracked spelling, never permission to remove that key. This prevents an inner path that points back out from forgetting an outer-owned entry. Existing whole-path classification and its component fallback remain; this is not a change to their traversal contract. Update the tool descriptions, changelog, and reader-order comment to match those contracts. Retire the two tests for the superseded ordinary-unlink policy. Add 24 contract cases, including the flag matrix, cleanup of intent and agreement, constructor ignore spelling, known-case preservation under the Darwin policy, store-key bounds, no post-classification directory listing, and the per-selection root lookup count. Linux checks before commit: build and typecheck; workspace, image-save, and guard files passed 354 tests with one native case-insensitive-filesystem test skipped. The first red run on the reviewed source produced 20 failures and two passes. Correcting the derived-key fixture to include a real disk file exposed the pre-decision observation above; its red probe recorded a workspace:created event for the nested alias before successful untracking. The corrected implementation also covers a disk file too large to be imported, which reaches the old unsafe derived-key lookup. Final reviewed-source and removal checks follow on the committed tree. Native case-insensitive filesystems and Windows remain unverified. Co-authored-by: GPT-6 --- changelog.d/nested-mount-ignore.fixed.md | 19 +- src/modules/workspace/index.ts | 302 +++++++++++++-------- src/modules/workspace/observe.ts | 44 ++- test/workspace-nested-mount-ignore.test.ts | 280 ++++++++++++++++--- 4 files changed, 466 insertions(+), 179 deletions(-) diff --git a/changelog.d/nested-mount-ignore.fixed.md b/changelog.d/nested-mount-ignore.fixed.md index 842398c03..ace439970 100644 --- a/changelog.d/nested-mount-ignore.fixed.md +++ b/changelog.d/nested-mount-ignore.fixed.md @@ -19,7 +19,7 @@ mount without `autoMaterialize`, deleting such a file from the workspace doesn't end the listing either. On one with it, the delete removes the disk copy, which ends it. There is no way yet to stop tracking a file and - keep it on disk, except inside a nested mount (below). + keep it on disk, except for nested paths and outer links into nested mounts (below). - A pattern with a trailing slash (`node_modules/`, `build/cache/`) or a slash inside it (`build/cache`) matched nothing. Now a trailing slash is dropped, so `node_modules/` reads as `node_modules` (gitignore would @@ -57,13 +57,10 @@ in "outer" at a/b/: use "inner/x.txt"". That covers `read`, `write`, `edit`, `ls`, `glob`, `grep`, and a `sync` or `materialize` that names the path. Before, a named sync took the inner mount's files into the outer - one. `write`, `edit`, `writeBinary` and `delete` also read where the + one. `write`, `edit` and `writeBinary` also read where the path leads on disk at their checks, so existing case variants and links inside the mount, dangling or not, are checked too. A link changed after the store check can leave an outer draft at a now-nested path; materialize refuses the later disk write. - - The one exception is `delete` of a file the outer mount still holds - there, taken in before this fix. It stops tracking the file in the - outer mount and leaves disk alone, where the file is the inner mount's. - - A `materialize` without a path never writes or deletes such a file. It - lists the file under `skipped`, saying to delete it to stop tracking it. + - `delete` can stop tracking an old outer copy at a nested path. It removes the exact stored key and its intent and agreement; the disk entry, if any, stays untouched. + - A `materialize` without a path skips writes of such copies, with a note saying that disk was left untouched and the copy can be deleted here to stop tracking it. An already-pending deletion at a nested path is untracked once instead of receiving that advice again. - Every check that a canonical path lies inside the mount, when reading, walking or writing, also refuses one inside a nested mount. So a symlink that a `followSymlinks` mount follows into a nested mount reaches nothing @@ -88,8 +85,8 @@ - (agent-framework #280) - Nested refusals name the innermost mount, even when several mounts contain the path. A lexical refusal includes its directory, such as `"outer/a/b/x.txt" is inside mount "inner", which is nested in "outer" at a/b/: use "inner/x.txt"`. A path reached through disk spelling omits that location clause: `"outer/via/x.txt" is inside mount "inner", which is nested in "outer": use "inner/x.txt"`. The public tool wrapper prefixes thrown lexical errors with `Error:`. -- An unmade path component is lowercased on macOS and Windows and preserved elsewhere. Existing components still use disk's spelling. This is a platform default, not a filesystem probe: case-sensitive macOS/Windows volumes can receive conservative refusals for unmade variants, and case-insensitive filesystems on other platforms are outside this missing-component rule. The workspace writes no case-probe entry into a user's mount. -- A path-less materialize lists copies reached through links or case variants as nested skips, with the same stop-tracking note as lexical copies. Deleting an old nested copy first tries its supplied store key, then its directory-entry spelling on disk. This lets a differently spelled path remove the copy stored by an earlier walk. -- Delete resolves the parent but keeps the final entry rather than following its link. An outer link into a nested mount therefore takes the ordinary delete path instead of claiming to remove an inner-owned file. The disk unlink's existing boundary refusal remains; the error reports that refusal without promising a later materialize can remove the link. +- Path spelling and comparison are separate. Existing components use disk's spelling, and unmade components keep their written spelling, including in generated ignore rules. On macOS and Windows, a nesting comparison ignores case only when one of the compared components was unmade. This uses platform defaults without a filesystem probe: non-default case-sensitive volumes can receive conservative refusals, while case-insensitive filesystems elsewhere are outside the missing-component rule. +- After classification, nested deletion uses the outer store rather than listing or statting the inner directory. It removes only an exact supplied key or an exact derived key contained in the identified nested mount. A differently cased tracked key is named in a refusal so the caller can request it exactly; case folding never chooses a key to remove. This also prevents a path through an inner link back out from forgetting an outer-owned entry. +- An outer link into a nested mount is untracked rather than assigned a disk deletion the outer mount cannot perform. Its stored copy, intent, and disk agreement are removed, and the result says the file or link on disk, if any, was left untouched. The disk unlink boundary itself is unchanged. Path-less materialize also clears legacy pending deletions at nested paths or for such links, independently of `force` and `applyDeletions`. It reports cleanup once, including if remaining physical writes are blocked by branch divergence, and leaves no pending action to report on the next call. - Disk readers check containment and nested boundaries before answering a target's kind, including a directory reached through a link. They also check the opened file's path and identity. -- These checks make synchronous filesystem reads on the event loop for mount roots and target path components, including realpath, lstat, readlink, and the directory-entry lookup for an old copy's spelling. They can block while mounted storage does not answer. This is the existing synchronous-I/O limitation tracked in #311, now explicitly including per-path reads. +- Classification still makes synchronous realpath, lstat, and readlink calls on the event loop for roots and target paths; untracking adds no post-classification directory lookup. Materialize resolves the nested roots once for its selection and shares that snapshot across selected paths. Disk effects obtain their own fresh view. A mounted filesystem that does not answer can still block the synchronous reads, as tracked in #311. diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 72ca54927..b99646175 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -5,7 +5,7 @@ * auto-sync between real filesystem and Chronicle, and manual materialization. */ -import { constants as fsConstants, lstatSync, readdirSync, readlinkSync, realpathSync } from 'node:fs'; +import { constants as fsConstants, lstatSync, readlinkSync, realpathSync } from 'node:fs'; import type { Stats } from 'node:fs'; import { open, stat, lstat, realpath } from 'node:fs/promises'; import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path'; @@ -39,7 +39,7 @@ import { MountWatcher, type FsChange } from './watcher.js'; import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js'; import { DiskAgreement, sameRootIdentity } from './disk-agreement.js'; import { BranchIntents } from './branch-intent.js'; -import { contained, coveredByIgnore, filesystemTrustsCtime, locateRoot, nestedAt } from './observe.js'; +import { contained, coveredByIgnore, filesystemTrustsCtime, locateRoot, nestedSuffix, pathComponents, type ResolvedPath } from './observe.js'; import { TOOL_BATCH_HOOK_TIMEOUT_MS } from '../../module-registry.js'; import { type MountRuntime, @@ -211,18 +211,19 @@ function matchesPartialPrefix(buffer: Buffer, prefix: Buffer): boolean { /** * Resolve existing path components to disk's spelling, following up to 40 links. A dangling link contributes its target; a relative target is read from the directory reached so far, including any preceding link before a '..'. The starting path uses resolve(), like the workspace's tools, sync, and watcher. * - * A component that does not exist has no disk spelling yet. Lowercase it on darwin and win32, and preserve it elsewhere. This uses platform defaults without writing a probe into the mount: case-sensitive macOS/Windows directories can receive conservative refusals, while case-insensitive filesystems on other platforms are outside this missing-component rule. Existing components retain their observed spelling. + * Components that do not exist keep their written spelling, and missingFrom records where that suffix starts. Only nestedSuffix's comparison folds case on darwin/win32 when either component is unmade. Paths, ignore patterns, and store keys retain their spelling. This is a platform default rather than a per-filesystem probe. */ -function canonicalRoot(path: string): string { +function resolveWorkspacePath(path: string): ResolvedPath { const absolute = resolve(path); try { - return realpathSync.native(absolute); + return { real: realpathSync.native(absolute) }; } catch { // Some part of it doesn't exist yet: walk it as the filesystem will. } const parts = (p: string): string[] => p.split(sep === '\\' ? /[\\/]+/ : '/').filter((c) => c.length > 0); const root = parse(absolute).root; let current = root; + let missingFrom: number | undefined; const queue = parts(absolute.slice(root.length)); let links = 0; while (queue.length > 0) { @@ -230,11 +231,13 @@ function canonicalRoot(path: string): string { if (part === '.') continue; if (part === '..') { current = dirname(current); + if (missingFrom !== undefined && missingFrom >= pathComponents(current).length) missingFrom = undefined; continue; } const next = join(current, part); try { current = realpathSync.native(next); + missingFrom = undefined; continue; } catch { // Missing, or a link that leads to nothing yet. @@ -249,12 +252,8 @@ function canonicalRoot(path: string): string { } } if (target === null) { - // Existing components above retain disk's spelling. For an unmade - // component there is no spelling to observe: use the platform default, - // without creating a probe file in the user's mount. - const fold = missingPart && (process.platform === 'darwin' || process.platform === 'win32'); - const missing = fold ? part.toLowerCase() : part; - current = join(current, missing); + if (missingPart && missingFrom === undefined) missingFrom = pathComponents(current).length; + current = next; continue; } links++; @@ -262,12 +261,50 @@ function canonicalRoot(path: string): string { if (isAbsolute(target)) { const targetRoot = parse(target).root; current = targetRoot; + missingFrom = undefined; queue.unshift(...parts(target.slice(targetRoot.length))); } else { queue.unshift(...parts(target)); } } - return current; + return { real: current, ...(missingFrom !== undefined ? { missingFrom } : {}) }; +} + +/** Consumers that need a path, rather than a comparison, keep its spelling. */ +function canonicalRoot(path: string): string { return resolveWorkspacePath(path).real; } + +/** The last directory entry is not followed when classifying an unlink. */ +function resolveWorkspaceEntry(path: string): ResolvedPath { + let missing = false; + try { + if (!lstatSync(path).isSymbolicLink()) return resolveWorkspacePath(path); + } catch (error) { + missing = errnoOf(error) === 'ENOENT' || errnoOf(error) === 'ENOTDIR'; + } + const parent = resolveWorkspacePath(dirname(path)); + return { + real: join(parent.real, basename(path)), + ...(parent.missingFrom !== undefined ? { missingFrom: parent.missingFrom } + : missing ? { missingFrom: pathComponents(parent.real).length } : {}), + }; +} + +interface NestedRoot extends ResolvedPath { name: string; at: string } +interface NestedSnapshot { + root: ResolvedPath; + roots: NestedRoot[]; + ats: Array<{ name: string; at: string }>; +} +interface NestedLocation { + name: string; + at: string; + rest: string; + /** An exact disk-derived key, never one chosen by case folding. */ + derivedKey?: string; +} +interface NestedDeletion extends NestedLocation { + where: string; + kind: 'entry' | 'link'; } function isContainedPath(root: string, candidate: string): boolean { @@ -723,12 +760,12 @@ export class WorkspaceModule implements Module { // rule; its tools refuse paths there (parsePath), and every containment // check refuses what reaches the inner's directory another way // (MountView.nested). - const roots = new Map(config.mounts.map((m) => [m, canonicalRoot(m.path)])); + const roots = new Map(config.mounts.map((m) => [m, resolveWorkspacePath(m.path)])); for (const outer of config.mounts) { for (const inner of config.mounts) { if (outer === inner) continue; - const at = nestedAt(roots.get(outer)!, roots.get(inner)!); - if (at === null) continue; + const at = nestedSuffix(roots.get(outer)!, roots.get(inner)!); + if (at === null || at === '') continue; const nested = this.nestedIn.get(outer.name) ?? []; nested.push({ name: inner.name, at }); this.nestedIn.set(outer.name, nested); @@ -754,14 +791,27 @@ export class WorkspaceModule implements Module { * so a nesting that came about after the module was built (a root made * later, a symlinked parent created) is refused like one found then. */ - private nestedRootsOf(mount: MountState, rootReal: string): Array<{ name: string; real: string }> { - const nested: Array<{ name: string; real: string }> = []; + private nestedRootsOf(mount: MountState, outer: string | ResolvedPath): NestedRoot[] { + const root = typeof outer === 'string' ? { real: outer } : outer; + const nested: NestedRoot[] = []; for (const inner of this.mounts.values()) { if (inner === mount) continue; - const real = canonicalRoot(inner.config.path); - if (real !== rootReal && contained(rootReal, real)) nested.push({ name: inner.config.name, real }); + const resolved = resolveWorkspacePath(inner.config.path); + const at = nestedSuffix(root, resolved); + if (at !== null && at !== '') nested.push({ name: inner.config.name, ...resolved, at }); } - return nested.sort((a, b) => b.real.length - a.real.length); + return nested.sort((a, b) => pathComponents(b.real).length - pathComponents(a.real).length); + } + + /** One root reading shared by all paths in a materialize selection. */ + private nestedSnapshot(mount: MountState): NestedSnapshot { + const root = resolveWorkspacePath(mount.config.path); + const roots = this.nestedRootsOf(mount, root); + const ats = [...(this.nestedIn.get(mount.config.name) ?? [])]; + for (const { name, at } of roots) { + if (!ats.some((n) => n.name === name && n.at === at)) ats.push({ name, at }); + } + return { root, roots, ats }; } /** @@ -775,13 +825,23 @@ export class WorkspaceModule implements Module { * A later disk change can make an accepted draft's path nested; the disk * effect checks that boundary again when materialize attempts the write. */ - private nestedByDisk(mount: MountState, relativePath: string, followFinal = true): { name: string; rest: string } | null { - const rootReal = canonicalRoot(mount.config.path); + private nestedByDisk( + mount: MountState, relativePath: string, followFinal = true, snapshot?: NestedSnapshot, + ): NestedLocation | null { + const view = snapshot ?? this.nestedSnapshot(mount); + if (view.roots.length === 0) return null; const path = resolve(mount.config.path, relativePath); - // Unlink addresses the directory entry, not the last link's target. - const real = followFinal ? canonicalRoot(path) : join(canonicalRoot(dirname(path)), basename(path)); - const inner = this.nestedRootsOf(mount, rootReal).find((n) => contained(n.real, real)); - return inner ? { name: inner.name, rest: relative(inner.real, real).split(sep).join('/') } : null; + const candidate = followFinal ? resolveWorkspacePath(path) : resolveWorkspaceEntry(path); + for (const inner of view.roots) { + const rest = nestedSuffix(inner, candidate); + if (rest === null) continue; + // Policy folding can justify a refusal, never the choice of another + // stored key to remove. A derived key needs exact containment. + const derivedKey = contained(inner.real, candidate.real) && contained(view.root.real, candidate.real) + ? relative(view.root.real, candidate.real).split(sep).join('/') : undefined; + return { name: inner.name, at: inner.at, rest, derivedKey }; + } + return null; } /** {@link nestedByDisk}'s refusal, naming where the path belongs. */ @@ -794,9 +854,9 @@ export class WorkspaceModule implements Module { /** Refuse a canonical path that lies in a mount nested in `mount`, naming where it belongs. */ private nestedRefusal(mount: MountState, rootReal: string, real: string, mountPrefixedPath: string): string | null { - const inner = this.nestedRootsOf(mount, rootReal).find((n) => contained(n.real, real)); + const inner = this.nestedRootsOf(mount, rootReal).find((n) => nestedSuffix(n, { real }) !== null); if (!inner) return null; - const rest = relative(inner.real, real).split(sep).join('/'); + const rest = nestedSuffix(inner, { real })!; return `"${mountPrefixedPath}" is inside mount "${inner.name}", which is nested in "${mount.config.name}": ` + `use "${rest ? `${inner.name}/${rest}` : inner.name}"`; } @@ -809,13 +869,7 @@ export class WorkspaceModule implements Module { * anything reaches its tree. */ private nestedAtsOf(mount: MountState): Array<{ name: string; at: string }> { - const ats = [...(this.nestedIn.get(mount.config.name) ?? [])]; - const rootReal = canonicalRoot(mount.config.path); - for (const inner of this.nestedRootsOf(mount, rootReal)) { - const at = relative(rootReal, inner.real).split(sep).join('/'); - if (!ats.some((n) => n.name === inner.name && n.at === at)) ats.push({ name: inner.name, at }); - } - return ats; + return this.nestedSnapshot(mount).ats; } /** The mount nested in `mount` that a mount-relative path lies in, if any. */ @@ -1280,9 +1334,10 @@ export class WorkspaceModule implements Module { }, { name: 'delete', - description: 'Delete a file from the workspace. On a mount without autoMaterialize the disk copy stays, ' - + 'listed as workspace-deleted (no scan brings it back), until materialize with applyDeletions removes it ' - + 'or a sync of the path restores it.', + description: 'Delete a file from the workspace. For a tracked nested path or an outer link into one, ' + + 'the workspace stops tracking the path and leaves any disk entry untouched. Other deletions on mounts ' + + 'without autoMaterialize remain pending on disk, shown as workspace-deleted. Materialize with ' + + 'applyDeletions attempts those deletions, or sync the path to restore it.', inputSchema: { type: 'object' as const, properties: { @@ -1353,7 +1408,8 @@ export class WorkspaceModule implements Module { { name: 'materialize', description: 'Write workspace files to the real filesystem. Use after writing/editing to push changes to disk. ' - + 'Never deletes disk files unless applyDeletions is given; lists workspace deletions it left on disk.', + + 'Never deletes disk files unless applyDeletions is given; lists ordinary workspace deletions it left on disk. ' + + 'Pending deletions blocked by nested-mount ownership stop being tracked, with a one-time note that any disk entry was left untouched.', inputSchema: { type: 'object' as const, properties: { @@ -1809,15 +1865,12 @@ export class WorkspaceModule implements Module { * Open a mount-relative file for reading with the mount boundary enforced on * the *filesystem*, not just the path string: * - * 1. `lstat` the lexical path — refuse directories; refuse a final-component - * symlink when the mount does not `followSymlinks`. - * 2. Open with `O_NOFOLLOW` where the platform has it (POSIX) so the refusal - * holds against a symlink swapped in between lstat and open; where it - * doesn't (Windows), re-`lstat` after opening instead. - * 3. `realpath` both the mount root and the opened path and require canonical - * containment — this is what catches an *intermediate* symlinked - * directory escaping the mount, and a followed symlink whose target lands - * outside, on every platform (junctions included). + * 1. `lstat` the lexical path; refuse a final-component symlink when the + * mount does not `followSymlinks`. + * 2. Resolve the root and path, check containment and nesting, then answer + * a directory's kind. Boundary refusal precedes open. + * 3. Open with `O_NOFOLLOW` where available, else re-`lstat` after open. + * Recheck the opened path's boundary before answering its kind. * 4. Confirm the descriptor and the canonical path are the same inode. * * `inspect` runs once the file is known to be this mount's (after step 4), @@ -2491,6 +2544,63 @@ export class WorkspaceModule implements Module { }); } + /** Classify the entry first, then a final link's target. Both are + * untracked rather than tombstoned when the outer mount cannot unlink them. */ + private nestedDeletion(mount: MountState, path: string, snapshot: NestedSnapshot): NestedDeletion | null { + const lexical = this.nestedMountAt(mount, path, snapshot.ats); + if (lexical) return { ...lexical, rest: path.slice(lexical.at.length + 1), + derivedKey: path, where: ' at ' + lexical.at + '/', kind: 'entry' }; + const entry = this.nestedByDisk(mount, path, false, snapshot); + if (entry) return { ...entry, where: '', kind: 'entry' }; + const target = this.nestedByDisk(mount, path, true, snapshot); + return target ? { ...target, derivedKey: undefined, where: '', kind: 'link' } : null; + } + + /** Forget disk evidence first: a partial failure leaves it unknown rather + * than claiming agreement with a tree entry that has been removed. */ + private untrackPath(mount: MountState, path: string): void { + this.agreementOrThrow().forget(mount.config.name, path); + const store = this.getStore(); + if (store.treeGet(mount.treeStateId, path)) store.treeRemove(mount.treeStateId, path); + this.intents.get(mount.config.name)!.put(path, null); + } + + private untrackNote(mount: MountState, path: string): string { + return 'Stopped tracking "' + mount.config.name + '/' + path + + '". The file or link on disk, if any, was left untouched.'; + } + + /** This decision uses only the outer store and the classified path. + * A different case is a hint, not authority to remove a different key. */ + private deleteNestedCopy( + mount: MountState, relativePath: string, inputPath: string, inner: NestedDeletion, + ): ToolResult { + const store = this.getStore(); + const intents = this.intents.get(mount.config.name)!; + const inside = (path: string): boolean => + path === inner.at || path.startsWith(inner.at + '/'); + const keys = [relativePath]; + if (inner.kind === 'entry' && inner.derivedKey !== undefined && inside(inner.derivedKey)) keys.push(inner.derivedKey); + const storedPath = keys.find((path) => store.treeGet(mount.treeStateId, path) || + intents.get(path) || this.agreementOrThrow().get(mount.config.name, path)); + if (storedPath !== undefined) { + this.untrackPath(mount, storedPath); + return { success: true, data: { path: inputPath, deleted: true, note: this.untrackNote(mount, storedPath) } }; + } + + if (process.platform === 'darwin' || process.platform === 'win32') { + const hintKey = inner.kind === 'link' ? relativePath : inner.at + (inner.rest ? '/' + inner.rest : ''); + const hint = store.treeList(mount.treeStateId).find((entry) => + (inner.kind === 'link' || inside(entry.path)) && entry.path.toLowerCase() === hintKey.toLowerCase()); + if (hint) return { success: false, isError: true, + error: 'Tracked spelling: "' + mount.config.name + '/' + hint.path + '"; use that exact path to stop tracking this copy.' }; + } + return { success: false, isError: true, + error: '"' + inputPath + '" is inside mount "' + inner.name + '", which is nested in "' + + mount.config.name + '"' + inner.where + ', and "' + mount.config.name + + '" doesn\'t hold it: use "' + (inner.rest ? inner.name + '/' + inner.rest : inner.name) + '"' }; + } + private async handleDelete(input: DeleteInput): Promise { const { mount, relativePath } = this.parsePath(input.path, { nested: 'allow' }); if (mount.config.mode === 'read-only') { @@ -2498,73 +2608,17 @@ export class WorkspaceModule implements Module { } const store = this.getStore(); - // Nested as written, or by how disk spells the path (a case variant, a - // link inside the mount), decided in the mount's turn. - const nestedAt = (): { name: string; rest: string; where: string } | null => { - const lexical = this.nestedMountAt(mount, relativePath); - if (lexical) return { name: lexical.name, rest: relativePath.slice(lexical.at.length + 1), where: ` at ${lexical.at}/` }; - const byDisk = this.nestedByDisk(mount, relativePath, false); - return byDisk ? { ...byDisk, where: '' } : null; - }; - // A copy the outer mount took in from a nested mount's directory before - // #280's fix: deleting it stops tracking it here, and never touches disk, - // where the file is the inner mount's. Anything else at such a path is - // refused, as every other tool refuses it. - const nestedDelete = (inner: { name: string; rest: string; where: string }): ToolResult => { - // Older scans stored disk's spelling. Prefer a copy at the supplied - // path (which may be an old alias), then resolve its directory entry. - let storedPath = relativePath; - if (!store.treeGet(mount.treeStateId, storedPath)) { - const absolute = resolve(mount.config.path, relativePath); - const parent = canonicalRoot(dirname(absolute)); - let name = basename(absolute); - try { - const entries = readdirSync(parent); - if (!entries.includes(name)) { - const named = lstatSync(join(parent, name)); - const actual = entries.find((entry) => { - if (entry.toLowerCase() !== name.toLowerCase()) return false; - const candidate = lstatSync(join(parent, entry)); - return candidate.dev === named.dev && candidate.ino === named.ino; - }); - if (actual !== undefined) name = actual; - } - storedPath = relative(canonicalRoot(mount.config.path), join(parent, name)).split(sep).join('/'); - } catch (error) { - if (errnoOf(error) !== 'ENOENT' && errnoOf(error) !== 'ENOTDIR') { - return { success: false, isError: true, - error: `Cannot identify the stored spelling of "${input.path}": ${error instanceof Error ? error.message : String(error)}` }; - } - // An absent disk entry supplies no alternate spelling. - } - } - if (!store.treeGet(mount.treeStateId, storedPath)) { - return { - success: false, - error: `"${input.path}" is inside mount "${inner.name}", which is nested in "${mount.config.name}"${inner.where}, ` + - `and "${mount.config.name}" doesn't hold it: use "${inner.rest ? `${inner.name}/${inner.rest}` : inner.name}"`, - isError: true, - }; - } - store.treeRemove(mount.treeStateId, storedPath); - this.intents.get(mount.config.name)!.put(storedPath, null); - this.agreementOrThrow().forget(mount.config.name, storedPath); - return { - success: true, - data: { - path: input.path, - deleted: true, - note: `inside mount "${inner.name}": no longer tracked by "${mount.config.name}"; the file on disk is "${inner.name}"'s and was left as it is`, - }, - }; - }; return this.withMount(mount, async (): Promise => { + // Decide from the copy already held. Observing first could import a + // nested alias merely to forget it and announce that transient copy. + const beforeRead = this.nestedDeletion(mount, relativePath, this.nestedSnapshot(mount)); + if (beforeRead) return this.deleteNestedCopy(mount, relativePath, input.path, beforeRead); const report = await this.ensureSynced(mount, relativePath); await this.settleBeforeMutation(mount, relativePath); // Decided after the last await, beside the store change: the path may // have come to lie in a nested mount while the delete waited. - const inner = nestedAt(); - if (inner) return nestedDelete(inner); + const inner = this.nestedDeletion(mount, relativePath, this.nestedSnapshot(mount)); + if (inner) return this.deleteNestedCopy(mount, relativePath, input.path, inner); const entry = store.treeGet(mount.treeStateId, relativePath); if (!entry) { return { success: false, error: this.notInWorkspace(input.path, report), isError: true }; @@ -2954,26 +3008,33 @@ export class WorkspaceModule implements Module { blocked.push({ mount: name, reason: 'a read-only mount: materialize writes nothing to it' }); continue; } - const turn = await this.withMount(mount, async (): Promise<{ blocked: string } | { pushed: PushResult; inPlace: boolean; nested: Array<{ path: string; inner: string }> }> => { + const turn = await this.withMount(mount, async (): Promise< + { blocked: string; nested: Array<{ path: string; inner: string; stopped: boolean }> } | + { pushed: PushResult; inPlace: boolean; nested: Array<{ path: string; inner: string; stopped: boolean }> } + > => { const branchId = store.currentBranch().id; const reason = this.mountMaterializeBlockReason(store, mount); // Force passes a divergence. The selection follows the evidence, which // is per path and kept across branches, so nothing needs resetting: // the push re-pins to this branch. - if (reason && !input.force) return { blocked: reason }; const { paths: selected, inPlace } = this.materializeSelection(mount, explicit?.relativePath ?? ''); // What the outer mount still holds in a nested mount's directory (taken // in before #280's fix) is never written there, nor deleted there: // the files are the inner mount's (agent-framework #280). - const ats = this.nestedAtsOf(mount); - const nested: Array<{ path: string; inner: string }> = []; + const snapshot = this.nestedSnapshot(mount); + const nested: Array<{ path: string; inner: string; stopped: boolean }> = []; const paths: string[] = []; for (const path of selected) { const writing = store.treeGet(mount.treeStateId, path) !== null; - const inner = this.nestedMountAt(mount, path, ats) ?? this.nestedByDisk(mount, path, writing); - if (inner) nested.push({ path, inner: inner.name }); - else paths.push(path); + const inner = writing + ? this.nestedMountAt(mount, path, snapshot.ats) ?? this.nestedByDisk(mount, path, true, snapshot) + : this.nestedDeletion(mount, path, snapshot); + if (inner) { + if (!writing) this.untrackPath(mount, path); + nested.push({ path, inner: inner.name, stopped: !writing }); + } else paths.push(path); } + if (reason && !input.force) return { blocked: reason, nested }; return { inPlace, nested, @@ -2982,6 +3043,10 @@ export class WorkspaceModule implements Module { }), }; }); + for (const { path, inner, stopped } of turn.nested) { + blocked.push({ mount: name, reason: stopped ? this.untrackNote(mount, path) + : path + ': inside mount "' + inner + '", which is nested in this one; delete it here to stop tracking it; the disk entry, if any, was left untouched' }); + } if ('blocked' in turn) { blocked.push({ mount: name, reason: turn.blocked }); guarded.push(name); @@ -2998,9 +3063,6 @@ export class WorkspaceModule implements Module { for (const s of pushed.skipped) { blocked.push({ mount: name, reason: `${s.path}: ${s.reason}` }); } - for (const { path, inner } of turn.nested) { - blocked.push({ mount: name, reason: `${path}: inside mount "${inner}", which is nested in this one; delete it here to stop tracking it` }); - } // What disk still owes is kept by its evidence, so the watermark and // the pin only feed the branch guard. Both are the push's own: what it diff --git a/src/modules/workspace/observe.ts b/src/modules/workspace/observe.ts index 6a16c59bf..aeff44a4f 100644 --- a/src/modules/workspace/observe.ts +++ b/src/modules/workspace/observe.ts @@ -41,7 +41,7 @@ import { constants as fsConstants } from 'node:fs'; import type { BigIntStats } from 'node:fs'; import { lstat, mkdir, open, readdir, realpath, stat, statfs } from 'node:fs/promises'; import type { FileHandle } from 'node:fs/promises'; -import { dirname, isAbsolute, join, relative, sep } from 'node:path'; +import { dirname, isAbsolute, join, parse, relative, sep } from 'node:path'; import { sameRootIdentity, type Fingerprint, type RootIdentity } from './disk-agreement.js'; export interface MountView { @@ -57,12 +57,11 @@ export interface MountView { maxFileSize: number; ignore: string[]; /** - * The canonical roots of the mounts nested in this one. A mount never - * reads or writes what lies in a mount nested in it, whatever path reaches - * it (agent-framework #280): every containment check refuses a canonical - * path inside one of these, as it refuses one outside the mount. + * Roots nested in this mount, preserving spelling and unmade-component + * metadata. Disk-content reads and effects check these boundaries, as + * they check containment in the outer mount (agent-framework #280). */ - nested?: ReadonlyArray<{ name: string; real: string }>; + nested?: ReadonlyArray; } export type StatFingerprint = Omit; @@ -110,9 +109,40 @@ export const RACY_WINDOW_MS = 2000; export const OUTSIDE_PARENT = 'a parent directory resolves outside the mount'; export const NOT_FOLLOWED = 'a symlink, which this mount does not follow'; +/** + * A resolved spelling, with the first component that was absent when it was + * read. Missing components keep their spelling; the marker is used only by + * the nesting comparison. Components are counted after the filesystem root. + */ +export interface ResolvedPath { + real: string; + missingFrom?: number; +} + +export function pathComponents(path: string): string[] { + return path.slice(parse(path).root.length).split(sep).filter(Boolean); +} + +/** Mount-relative suffix, preserving candidate spelling. Known components + * compare exactly. On darwin/win32, a comparison involving an unmade + * component uses the platform's default case folding. */ +export function nestedSuffix(root: ResolvedPath, candidate: ResolvedPath): string | null { + if (relative(parse(root.real).root, parse(candidate.real).root) !== '') return null; + const a = pathComponents(root.real); + const b = pathComponents(candidate.real); + if (b.length < a.length) return null; + const fold = process.platform === 'darwin' || process.platform === 'win32'; + for (let i = 0; i < a.length; i++) { + if (a[i] === b[i]) continue; + const unmade = i >= (root.missingFrom ?? Infinity) || i >= (candidate.missingFrom ?? Infinity); + if (!fold || !unmade || a[i].toLowerCase() !== b[i].toLowerCase()) return null; + } + return b.slice(a.length).join('/'); +} + /** Why a canonical path inside the mount is still not the mount's: it lies in a mount nested in it. */ export function insideNested(view: MountView, real: string): string | null { - const inner = view.nested?.find((n) => contained(n.real, real)); + const inner = view.nested?.find((n) => nestedSuffix(n, { real }) !== null); return inner ? `inside mount "${inner.name}", which is nested in this one` : null; } export const ROOT_UNAVAILABLE = 'the mount root is unavailable'; diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index 64872c5eb..5da47e930 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -294,14 +294,14 @@ function storeHolds(workspace: WorkspaceModule, mount: string, rel: string): boo } /** A started workspace on a store at `storePath`; `stops` stops it at the end, if nothing has. */ -async function started(stops: Array<() => Promise>, storePath: string, mounts: MountConfig[]) { +async function started(stops: Array<() => Promise>, storePath: string, mounts: MountConfig[], pushEvent: (event: unknown) => void = () => {}) { const store = JsStore.openOrCreate({ path: storePath }); const warn = console.warn; console.warn = () => {}; let workspace: WorkspaceModule; try { workspace = new WorkspaceModule({ mounts }); } finally { console.warn = warn; } workspace.initStore(store); - await workspace.start({ isRestart: false, getState: () => null, setState: () => {}, pushEvent: () => {} } as unknown as ModuleContext); + await workspace.start({ isRestart: false, getState: () => null, setState: () => {}, pushEvent } as unknown as ModuleContext); let stopped = false; const stop = async () => { if (stopped) return; @@ -352,7 +352,7 @@ test("the outer mount's tools refuse a path in a nested mount, naming it; deleti // Deleting the old copy stops tracking it here, and leaves disk alone. const dropped = await call('delete', { path: 'outer/a/b/x.txt' }); assert.equal(dropped.success, true, String(dropped.error)); - assert.match(JSON.stringify(dropped.data), /no longer tracked by \\"outer\\"/); + assert.match(JSON.stringify(dropped.data), /stopped tracking/i); assert.equal(readFileSync(join(outerDir, 'a', 'b', 'x.txt'), 'utf8'), "the inner mount's"); const again = await call('delete', { path: 'outer/a/b/x.txt' }); assert.equal(again.success, false); @@ -519,7 +519,7 @@ test("a delete through a link into a nested mount stops tracking an old copy and symlinkSync(join(outerDir, 'a', 'b'), join(outerDir, 'moved')); const forgotten = await call('delete', { path: 'outer/moved/z.txt' }); assert.equal(forgotten.success, true, String(forgotten.error)); - assert.match(JSON.stringify(forgotten.data), /no longer tracked by \\"outer\\"/); + assert.match(JSON.stringify(forgotten.data), /stopped tracking/i); assert.equal(storeHolds(workspace, 'outer', 'moved/z.txt'), false); assert.equal(readFileSync(join(outerDir, 'a', 'b', 'z.txt'), 'utf8'), "the inner mount's", 'disk is untouched'); // With no old copy, the delete is refused, naming the inner mount. @@ -893,37 +893,6 @@ for (const destination of ['outside', 'inner'] as const) { }); } -for (const autoMaterialize of [false, true]) { - test(`#282 round: deleting an outer final link follows ordinary delete semantics (autoMaterialize ${autoMaterialize})`, async (t) => { - const { root, stops } = scratch(t, 'af-nested-round-unlink-'); - const outer = join(root, 'outer'); - mkdirSync(join(outer, 'inner'), { recursive: true }); - writeFileSync(join(outer, 'inner', 'x.txt'), 'inner bytes'); - const { workspace } = await started(stops, join(root, 'store'), [ - { name: 'outer', path: outer, mode: 'read-write', watch: 'never', autoMaterialize, followSymlinks: true }, - { name: 'inner', path: join(outer, 'inner'), mode: 'read-write', watch: 'never' }, - ]); - assert.equal((await workspace.handleToolCall({ id: 'w', name: 'write', input: { path: 'outer/link', content: 'old draft' } })).success, true); - rmSync(join(outer, 'link'), { force: true }); - symlinkSync(join(outer, 'inner', 'x.txt'), join(outer, 'link')); - const result = await workspace.handleToolCall({ id: 'd', name: 'delete', input: { path: 'outer/link' } }); - assert.equal(result.success, !autoMaterialize); - if (autoMaterialize) { - assert.match(String(result.error), /Removed from Chronicle but did not unlink/); - assert.match(String(result.error), /inside mount "inner"/); - assert.doesNotMatch(String(result.error), /until materialize with applyDeletions removes it/); - } else { - assert.deepEqual(result.data, { path: 'outer/link', deleted: true }); - } - assert.equal(storeHolds(workspace, 'outer', 'link'), false); - const later = await workspace.handleToolCall({ id: 'later', name: 'materialize', input: { mount: 'outer', applyDeletions: true, force: true } }); - assert.match(JSON.stringify(later), /inside mount/); - assert.doesNotMatch(JSON.stringify(later), /delete it here to stop tracking it/, - 'the copy is already deleted; report the disk refusal rather than suggesting the same delete again'); - assert.equal(readFileSync(join(outer, 'inner', 'x.txt'), 'utf8'), 'inner bytes'); - }); -} - test('#282 round: every nested lookup names the innermost mount', async (t) => { const { root, stops } = scratch(t, 'af-nested-round-deep-'); const outer = join(root, 'outer'); @@ -1015,13 +984,12 @@ test('#282 round: a nested delete reached by alias finds a copy stored under its try { fs.readdirSync = (() => { throw Object.assign(new Error('directory unavailable'), { code: 'EIO' }); }) as typeof readDir; syncBuiltinESMExports(); - const refused = await workspace.handleToolCall({ id: 'unavailable', name: 'delete', input: { path: 'outer/via/x.txt' } }); - assert.match(String(refused.error), /Cannot identify the stored spelling.*directory unavailable/); - assert.equal(storeHolds(workspace, 'outer', 'a/b/x.txt'), true); + const result = await workspace.handleToolCall({ id: 'unavailable', name: 'delete', input: { path: 'outer/via/x.txt' } }); + assert.equal(result.success, true, JSON.stringify(result)); + assert.equal(storeHolds(workspace, 'outer', 'a/b/x.txt'), false); + assert.equal((result.data as { note: string }).note, + 'Stopped tracking "outer/a/b/x.txt". The file or link on disk, if any, was left untouched.'); } finally { fs.readdirSync = readDir; syncBuiltinESMExports(); } - const result = await workspace.handleToolCall({ id: 'd', name: 'delete', input: { path: 'outer/via/x.txt' } }); - assert.equal(result.success, true, JSON.stringify(result)); - assert.equal(storeHolds(workspace, 'outer', 'a/b/x.txt'), false); assert.equal(readFileSync(join(outer, 'a', 'b', 'x.txt'), 'utf8'), 'inner bytes'); }); @@ -1052,3 +1020,233 @@ test('#282 round: boundary refusal precedes opening a directory', async (t) => { syncBuiltinESMExports(); } }); + +async function secondPassRig(t: TestContext, autoMaterialize = false, maxFileSize?: number) { + const { root, stops } = scratch(t, 'af-nested-pass2-'); + const outer = join(root, 'outer'); + const inner = join(outer, 'inner'); + mkdirSync(inner, { recursive: true }); + writeFileSync(join(inner, 'x.txt'), 'inner bytes'); + const events: unknown[] = []; + const { workspace } = await started(stops, join(root, 'store'), [ + { name: 'outer', path: outer, mode: 'read-write', watch: 'never', followSymlinks: true, autoMaterialize, maxFileSize }, + { name: 'inner', path: inner, mode: 'read-write', watch: 'never' }, + ], (event) => { events.push(event); }); + const state = workspace as unknown as { + getStore(): JsStore; + intents: Map; + agreementOrThrow(): import('../src/modules/workspace/disk-agreement.js').DiskAgreement; + }; + const store = state.getStore(); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + const seedCopy = (path: string) => { + const blobHash = store.storeBlob(Buffer.from('old copy'), 'text/plain'); + store.treeSet('workspace/outer/tree', path, { blobHash, size: 8, mode: 0o644 }); + return blobHash; + }; + return { root, stops, outer, inner, workspace, state, store, call, seedCopy, events }; +} + +for (const kind of ['lexical', 'parent-alias', 'final-link'] as const) { + for (const force of [false, true]) for (const applyDeletions of [false, true]) { + test(`#282 pass2: a legacy ${kind} pending deletion stops tracking once (force ${force}, applyDeletions ${applyDeletions})`, async (t) => { + const r = await secondPassRig(t); + const path = kind === 'lexical' ? 'inner/x.txt' : kind === 'parent-alias' ? 'via/x.txt' : 'link'; + if (kind === 'parent-alias') symlinkSync(r.inner, join(r.outer, 'via')); + if (kind === 'final-link') symlinkSync(join(r.inner, 'x.txt'), join(r.outer, 'link')); + // A deletion recorded before this path became nested, or by an older + // version. Its tree entry is gone but intent and agreement remain. + const hash = r.store.storeBlob(Buffer.from('old copy'), 'text/plain'); + r.state.intents.get('outer')!.put(path, { tombstone: { hash } }); + r.state.agreementOrThrow().set('outer', path, { kind: 'content', hash, size: 8 }); + const result = await r.call('materialize', { mount: 'outer', force, applyDeletions }); + assert.equal(result.success, true, JSON.stringify(result)); + assert.match(JSON.stringify(result.data), /stopped tracking.*disk.*untouched/i); + assert.doesNotMatch(JSON.stringify(result.data), /delete it here|applyDeletions to remove/); + assert.equal(r.state.intents.get('outer')!.get(path), null); + assert.equal(r.state.agreementOrThrow().get('outer', path), undefined); + assert.equal(readFileSync(join(r.inner, 'x.txt'), 'utf8'), 'inner bytes'); + const again = await r.call('materialize', { mount: 'outer', force, applyDeletions }); + assert.doesNotMatch(JSON.stringify(again), /stopped tracking|delete it here|workspaceDeletionsLeftOnDisk/i); + }); + } +} + +for (const autoMaterialize of [false, true]) { + test(`#282 pass2: delete of an outer link stops tracking instead of leaving an impossible unlink (autoMaterialize ${autoMaterialize})`, async (t) => { + const r = await secondPassRig(t, autoMaterialize); + assert.equal((await r.call('write', { path: 'outer/link', content: 'old copy' })).success, true); + rmSync(join(r.outer, 'link'), { force: true }); + symlinkSync(join(r.inner, 'x.txt'), join(r.outer, 'link')); + const result = await r.call('delete', { path: 'outer/link' }); + assert.equal(result.success, true, JSON.stringify(result)); + assert.match(JSON.stringify(result.data), /stopped tracking.*link.*disk.*untouched/i); + assert.equal(storeHolds(r.workspace, 'outer', 'link'), false); + assert.equal(r.state.intents.get('outer')!.get('link'), null); + assert.equal(r.state.agreementOrThrow().get('outer', 'link'), undefined); + assert.equal(fs.lstatSync(join(r.outer, 'link')).isSymbolicLink(), true); + assert.equal(readFileSync(join(r.inner, 'x.txt'), 'utf8'), 'inner bytes'); + for (const force of [false, true]) for (const applyDeletions of [false, true]) { + const later = await r.call('materialize', { mount: 'outer', force, applyDeletions }); + assert.doesNotMatch(JSON.stringify(later), /link|workspaceDeletionsLeftOnDisk/); + } + }); +} + +test('#282 pass2: a Darwin-policy constructor preserves an unmade root in its ignore rule', async (t) => { + const { root, stops } = scratch(t, 'af-nested-pass2-ignore-'); + const outer = join(root, 'outer'); + mkdirSync(outer); + writeFileSync(join(outer, 'top.txt'), 'top'); + const store = JsStore.openOrCreate({ path: join(root, 'store') }); + let workspace: WorkspaceModule | undefined; + stops.push(async () => { await workspace?.stop(); if (!store.isClosed()) store.close(); }); + const config: MountConfig = { name: 'outer', path: outer, mode: 'read-write', watch: 'never' }; + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + try { + Object.defineProperty(process, 'platform', { ...platform, value: 'darwin' }); + workspace = new WorkspaceModule({ mounts: [config, + { name: 'inner', path: join(outer, 'CamelChild'), mode: 'read-write', watch: 'never' }] }); + workspace.initStore(store); + await workspace.start({ isRestart: false, getState: () => null, setState: () => {}, pushEvent: () => {} } as unknown as ModuleContext); + assert.deepEqual(config.ignore, ['CamelChild/**']); + const denied = await workspace.handleToolCall({ id: 'w', name: 'write', input: { path: 'outer/camelchild/new.txt', content: 'draft' } }); + assert.equal(denied.success, false); + mkdirSync(join(outer, 'CamelChild')); + writeFileSync(join(outer, 'CamelChild', 'x.txt'), 'inner'); + assert.deepEqual(await syncedByOuter(workspace), ['top.txt']); + const synced = await workspace.handleToolCall({ id: 's', name: 'sync', input: { mount: 'outer' } }); + assert.deepEqual((synced.data as { passedOver: unknown }).passedOver, [{ mount: 'outer', reason: 'ignored by the mount', count: 1 }]); + } finally { Object.defineProperty(process, 'platform', platform); } +}); + +test('#282 pass2: existing case-distinct paths retain their spelling under the Darwin policy', async (t) => { + const r = await secondPassRig(t); + if (existsSync(join(r.outer, 'INNER'))) { t.skip('requires a case-sensitive directory for the control'); return; } + mkdirSync(join(r.outer, 'INNER')); + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + try { + Object.defineProperty(process, 'platform', { ...platform, value: 'darwin' }); + const result = await r.call('write', { path: 'outer/INNER/KeepCase.txt', content: 'owned' }); + assert.equal(result.success, true, JSON.stringify(result)); + assert.equal(storeHolds(r.workspace, 'outer', 'INNER/KeepCase.txt'), true); + assert.equal(storeHolds(r.workspace, 'outer', 'inner/keepcase.txt'), false); + } finally { Object.defineProperty(process, 'platform', platform); } +}); + +test('#282 pass2: delete recognises a case variant of the nested root itself', async (t) => { + const r = await secondPassRig(t); + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + try { + Object.defineProperty(process, 'platform', { ...platform, value: 'darwin' }); + const result = await r.call('delete', { path: 'outer/INNER' }); + assert.equal(result.success, false); + assert.match(String(result.error), /is inside mount "inner".*use "inner"$/); + } finally { Object.defineProperty(process, 'platform', platform); } +}); + +test('#282 pass2: a nested delete refuses before importing or announcing an alias', async (t) => { + const r = await secondPassRig(t); + assert.equal((await r.call('write', { path: 'outer/owned.txt', content: 'outer bytes' })).success, true); + // The old disk-spelling lookup reaches its derived-key assignment only + // when the entry exists on disk as well as in the outer store. + writeFileSync(join(r.outer, 'owned.txt'), 'outer bytes'); + symlinkSync(r.outer, join(r.inner, 'back')); + const result = await r.call('delete', { path: 'outer/inner/back/owned.txt' }); + assert.equal(result.success, false, JSON.stringify({ result, events: r.events })); + assert.equal(storeHolds(r.workspace, 'outer', 'owned.txt'), true); + assert.doesNotMatch(JSON.stringify(r.events), /inner\/back\/owned.txt/, + 'a refused nested delete neither imports nor announces a temporary copy'); +}); + +test('#282 pass2: nestedDelete neither lists the inner directory nor returns its OS errors', async (t) => { + const r = await secondPassRig(t); + const readdir = fs.readdirSync; + let listed = 0; + try { + fs.readdirSync = (() => { + listed++; + throw Object.assign(new Error('EACCES /private/inner-directory'), { code: 'EACCES' }); + }) as typeof readdir; + syncBuiltinESMExports(); + const result = await r.call('delete', { path: 'outer/inner/x.txt' }); + assert.equal(result.success, false); + assert.match(String(result.error), /is inside mount "inner".*doesn't hold it: use "inner\/x.txt"/); + assert.doesNotMatch(String(result.error), /EACCES|private|Cannot identify/); + assert.equal(listed, 0); + } finally { fs.readdirSync = readdir; syncBuiltinESMExports(); } +}); + +test('#282 pass2: a case-folded store match is a hint, not permission to forget that key', async (t) => { + const r = await secondPassRig(t); + r.seedCopy('inner/File.txt'); + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + try { + Object.defineProperty(process, 'platform', { ...platform, value: 'darwin' }); + const result = await r.call('delete', { path: 'outer/INNER/file.txt' }); + assert.equal(result.success, false); + assert.match(String(result.error), /tracked spelling.*outer\/inner\/File.txt/i); + assert.equal(storeHolds(r.workspace, 'outer', 'inner/File.txt'), true); + const exact = await r.call('delete', { path: 'outer/inner/File.txt' }); + assert.equal(exact.success, true, JSON.stringify(exact)); + assert.equal(storeHolds(r.workspace, 'outer', 'inner/File.txt'), false); + } finally { Object.defineProperty(process, 'platform', platform); } +}); + +test('#282 pass2: untracking a stale nested copy makes no claim that its disk file exists', async (t) => { + const r = await secondPassRig(t); + assert.match(r.workspace.getTools().find((tool) => tool.name === 'delete')!.description, /stops tracking.*any disk entry untouched/); + assert.match(r.workspace.getTools().find((tool) => tool.name === 'materialize')!.description, /Pending deletions blocked by nested-mount ownership stop being tracked/); + r.seedCopy('inner/missing.txt'); + const result = await r.call('delete', { path: 'outer/inner/missing.txt' }); + assert.equal(result.success, true); + assert.match(JSON.stringify(result.data), /if any.*untouched/); + assert.equal(existsSync(join(r.inner, 'missing.txt')), false); +}); + +test('#282 pass2: materialize reads nested roots once for its selection, not per path', async (t) => { + const r = await secondPassRig(t); + for (let i = 0; i < 4; i++) assert.equal((await r.call('write', { path: `outer/own-${i}.txt`, content: 'owned' })).success, true); + const native = fs.realpathSync.native; + let innerReads = 0; + try { + fs.realpathSync.native = ((path: Parameters[0], options?: Parameters[1]) => { + if (String(path) === r.inner) innerReads++; + return native(path, options as never); + }) as typeof native; + const result = await r.call('materialize', { mount: 'outer' }); + assert.equal(result.success, true, JSON.stringify(result)); + // One snapshot for selection and one for the subsequent disk push. + assert.equal(innerReads, 2); + } finally { fs.realpathSync.native = native; } +}); + +test('#282 pass2: a blocked physical push still reports its completed nested-deletion cleanup', async (t) => { + const r = await secondPassRig(t); + const path = 'inner/x.txt'; + const hash = r.store.storeBlob(Buffer.from('old copy'), 'text/plain'); + r.state.intents.get('outer')!.put(path, { tombstone: { hash } }); + r.state.agreementOrThrow().set('outer', path, { kind: 'content', hash, size: 8 }); + (r.workspace as unknown as { mountMaterializeBlockReason(): string }).mountMaterializeBlockReason = () => 'test branch barrier'; + const result = await r.call('materialize', { mount: 'outer' }); + assert.equal(result.success, false); + assert.match(String(result.error), /Stopped tracking.*test branch barrier/); + assert.equal(r.state.intents.get('outer')!.get(path), null); + assert.equal(r.state.agreementOrThrow().get('outer', path), undefined); + const again = await r.call('materialize', { mount: 'outer' }); + assert.doesNotMatch(String(again.error), /Stopped tracking/); + assert.equal(readFileSync(join(r.inner, 'x.txt'), 'utf8'), 'inner bytes'); +}); + +test('#282 pass2: a non-ingested nested alias cannot derive an outer-owned key', async (t) => { + const r = await secondPassRig(t, false, 4); + assert.equal((await r.call('write', { path: 'outer/owned.txt', content: 'old' })).success, true); + // Too large for observation to import under the alias. The old fallback + // therefore reached its derived key and removed the outer-owned copy. + writeFileSync(join(r.outer, 'owned.txt'), 'outer bytes above the four-byte cap'); + symlinkSync(r.outer, join(r.inner, 'back')); + const result = await r.call('delete', { path: 'outer/inner/back/owned.txt' }); + assert.equal(storeHolds(r.workspace, 'outer', 'owned.txt'), true, JSON.stringify(result)); + assert.equal(result.success, false, JSON.stringify(result)); + assert.equal(readFileSync(join(r.outer, 'owned.txt'), 'utf8'), 'outer bytes above the four-byte cap'); +}); From c6f4fcf917b8a1cded7cbe07888fadedfc769187 Mon Sep 17 00:00:00 2001 From: Sebastian-2380 Date: Tue, 13 Oct 2026 05:04:06 -0400 Subject: [PATCH 18/26] test(workspace): pin commit-side and disk-view nesting checks An early nested-path refusal must not replace the check after awaited preparation. Wrap the real settlement step so an ordinary directory becomes a link into the nested mount before the await returns. The delete must then forget the stored copy and its intent, leaving the inner file untouched instead of recording an unappliable unlink. These assertions pin state rather than this round's note wording, so the case also controls the reviewed source's existing post-await check. Exercise the other boundaries the new representation serves: the disk-effect view retains the missing-root comparison marker after a directory is created under another case; a failed agreement-forget leaves the tracked copy available for retry; and policy-only nesting cannot authorize removal of a different derived key. The latter uses a real case-sensitive directory with the Darwin policy selected, not a native case-insensitive filesystem. All 28 second-pass contract cases pass on Node 24. The final-head checks run both declared CI runtime majors. These tests add no production changes. Co-authored-by: GPT-6 --- test/workspace-nested-mount-ignore.test.ts | 86 +++++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index 5da47e930..00828f133 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -28,10 +28,10 @@ import { tmpdir } from 'node:os'; import fsPromises from 'node:fs/promises'; import fs from 'node:fs'; import { syncBuiltinESMExports } from 'node:module'; -import { dirname, join, sep, win32 } from 'node:path'; +import { dirname, join, parse, sep, win32 } from 'node:path'; import { JsStore } from '@animalabs/chronicle'; import { WorkspaceModule } from '../src/modules/workspace/index.js'; -import { nestedAt, readPath } from '../src/modules/workspace/observe.js'; +import { insideNested, nestedAt, readPath } from '../src/modules/workspace/observe.js'; import type { MountConfig } from '../src/modules/workspace/types.js'; import type { ModuleContext } from '../src/types/module.js'; import { MountWatcher, type FsChange } from '../src/modules/workspace/watcher.js'; @@ -1250,3 +1250,85 @@ test('#282 pass2: a non-ingested nested alias cannot derive an outer-owned key', assert.equal(result.success, false, JSON.stringify(result)); assert.equal(readFileSync(join(r.outer, 'owned.txt'), 'utf8'), 'outer bytes above the four-byte cap'); }); + +test('#282 pass2: an ordinary delete rechecks nesting after awaited settlement', async (t) => { + const r = await secondPassRig(t); + mkdirSync(join(r.outer, 'moved')); + writeFileSync(join(r.outer, 'moved', 'x.txt'), 'old'); + assert.equal((await r.call('write', { path: 'outer/moved/x.txt', content: 'old' })).success, true); + const internals = r.workspace as unknown as { settleBeforeMutation(...args: unknown[]): Promise }; + const settle = internals.settleBeforeMutation.bind(r.workspace); + internals.settleBeforeMutation = async (...args) => { + await settle(...args); + rmSync(join(r.outer, 'moved'), { recursive: true }); + symlinkSync(r.inner, join(r.outer, 'moved')); + }; + const result = await r.call('delete', { path: 'outer/moved/x.txt' }); + assert.equal(result.success, true, JSON.stringify(result)); + assert.equal(r.state.intents.get('outer')!.get('moved/x.txt'), null); + assert.equal(storeHolds(r.workspace, 'outer', 'moved/x.txt'), false); + assert.equal(readFileSync(join(r.inner, 'x.txt'), 'utf8'), 'inner bytes'); +}); + +test('#282 pass2: the disk-effect view retains the missing-root comparison policy', async (t) => { + const { root } = scratch(t, 'af-nested-pass2-effect-view-'); + const realRoot = realpathSync.native(root); + const inner = join(realRoot, 'CamelChild'); + const missingFrom = inner.slice(parse(inner).root.length).split(sep).filter(Boolean).length - 1; + const view = { + root: realRoot, followSymlinks: true, maxFileSize: 100, ignore: [], + nested: [{ name: 'inner', real: inner, missingFrom }], + } as unknown as Parameters[0]; + // The view was taken before this directory existed. The effect's check + // must retain that fact when the directory is made under another case. + mkdirSync(join(realRoot, 'camelchild')); + writeFileSync(join(realRoot, 'camelchild', 'x.txt'), 'inner'); + const candidate = realpathSync.native(join(realRoot, 'camelchild', 'x.txt')); + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + try { + Object.defineProperty(process, 'platform', { ...platform, value: 'darwin' }); + assert.equal(insideNested(view, candidate), 'inside mount "inner", which is nested in this one'); + } finally { Object.defineProperty(process, 'platform', platform); } +}); + +test('#282 pass2: failed agreement removal leaves the tracked copy available for retry', async (t) => { + const r = await secondPassRig(t); + const hash = r.seedCopy('inner/x.txt'); + const agreement = r.state.agreementOrThrow(); + agreement.set('outer', 'inner/x.txt', { kind: 'content', hash, size: 8 }); + const forget = agreement.forget; + try { + agreement.forget = () => { throw new Error('injected agreement failure'); }; + const failed = await r.call('delete', { path: 'outer/inner/x.txt' }); + assert.equal(failed.success, false); + assert.equal(storeHolds(r.workspace, 'outer', 'inner/x.txt'), true); + assert.equal(agreement.get('outer', 'inner/x.txt')?.kind, 'content'); + } finally { agreement.forget = forget; } + assert.equal((await r.call('delete', { path: 'outer/inner/x.txt' })).success, true); + assert.equal(storeHolds(r.workspace, 'outer', 'inner/x.txt'), false); + assert.equal(agreement.get('outer', 'inner/x.txt'), undefined); +}); + +test('#282 pass2: policy-only nesting cannot authorize a derived-key removal', async (t) => { + const { root, stops } = scratch(t, 'af-nested-pass2-derived-policy-'); + const outer = join(root, 'outer'); + mkdirSync(join(outer, 'camel'), { recursive: true }); + if (existsSync(join(outer, 'Camel'))) { t.skip('requires distinct case spellings for this control'); return; } + writeFileSync(join(outer, 'camel', 'x.txt'), 'outer bytes'); + symlinkSync(join(outer, 'camel'), join(outer, 'via')); + const { workspace } = await started(stops, join(root, 'store'), [ + { name: 'outer', path: outer, mode: 'read-write', watch: 'never', followSymlinks: true, autoMaterialize: false }, + { name: 'inner', path: join(outer, 'Camel'), mode: 'read-write', watch: 'never' }, + ]); + const store = (workspace as unknown as { getStore(): JsStore }).getStore(); + const blobHash = store.storeBlob(Buffer.from('outer copy'), 'text/plain'); + store.treeSet('workspace/outer/tree', 'camel/x.txt', { blobHash, size: 10, mode: 0o644 }); + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + try { + Object.defineProperty(process, 'platform', { ...platform, value: 'darwin' }); + const result = await workspace.handleToolCall({ id: 'd', name: 'delete', input: { path: 'outer/via/x.txt' } }); + assert.equal(result.success, false, JSON.stringify(result)); + assert.equal(storeHolds(workspace, 'outer', 'camel/x.txt'), true, + 'folding can refuse the path but cannot select that different exact key to remove'); + } finally { Object.defineProperty(process, 'platform', platform); } +}); From adb04560af5fc30d9f27e7ba206d022c4ec81a0c Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 05:56:48 -0400 Subject: [PATCH 19/26] fix(workspace): a nested copy's other spelling is named only where its own delete would untrack it; the walk passes over nested directories its rule misses Answers a review of the nested-untracking pass (764cd974..c6f4fcf9). Tracked-spelling hint. A refused nested delete on darwin/win32 named a differently cased stored key only when its nested prefix matched the nested mount's `at` exactly, so a key such as `Inner/a.txt`, stored when the inner root is `inner`, was never named and the refusal said the outer mount did not hold it. The prefix test stood in for a safety rule: on a case-sensitive volume a key under `Inner` can be the outer mount's own directory, and naming it would steer the caller to delete an ordinary file. The hint now takes the rule itself: a stored key matching the path, or its location in the nested mount, without regard to case is named only when classifying that key as a delete would (nestedDeletion with the same snapshot), so a delete by that name would stop tracking it too and touch nothing on disk. A case variant that is a real directory of the outer mount's compares exactly and is never named. Read-only mounts. Delete refused a read-only mount before the nested decision, so an old copy a read-only outer mount took in from a nested mount's directory before the nesting was honoured could never be untracked, and every path-less sync listed it. Untracking writes nothing to disk; the nested decision now comes first and the read-only refusal covers every other delete. The tool description says so. The walk. A nested mount's directory was passed over only by the ignore rule the constructor generates. One the rule doesn't name (made in another case than configured, or nested only after startup) was walked: every file listed, counted toward the walk cap, and refused one by one. The walk now also passes over a directory the pass's view finds nested, as 'ignored by the mount', so passedOver counts it as it counts a rule's. The watcher still passes over only what the rule names; each change it reports from such a directory is refused like any nested path. Smaller: - The refusal for an untracked outer link into a nested mount said the link "is inside" that mount; a link is the outer mount's own entry, and it now "leads into" it. - The lexical branch no longer sets a derived key equal to the supplied one. - A derived key needs exact containment in the nested root and the outer root (nestedByDisk). The store-prefix check after it was implied by that and is gone, so the containment clause is the one guard its test pins. - nestedSuffix and pathComponents take the platform path functions, and the platform-rules test (separators, `..cache`, another Windows drive) calls nestedSuffix with path.win32. nestedAt, which only that test still called, and canonicalRoot, which nothing called, are gone, with the comments that named canonicalRoot. - Changelog: delete reads disk at its checks; the tracked-spelling hint is macOS and Windows only and names only a key its own delete would untrack; read-only mounts; the walk's pass-over and the watcher's limit. Tests: the hand-built view check is replaced by a sync through the module's own view under the Darwin policy (an unmade `CamelChild`, a `camelchild` directory made later, passed over, and a link into it refused); a nesting made after startup is passed over by the walk; a tracked spelling whose nested directory differs in case is named and then untracks; a case variant that is the outer mount's own directory is never named; a read-only mount untracks a nested copy and refuses every other delete; the link refusal's wording; the policy-only refusal now asserts its text. Titles lose the review-pass prefix. --- changelog.d/nested-mount-ignore.fixed.md | 11 +- src/modules/workspace/index.ts | 70 ++++----- src/modules/workspace/observe.ts | 50 +++---- test/workspace-nested-mount-ignore.test.ts | 160 ++++++++++++++++----- 4 files changed, 189 insertions(+), 102 deletions(-) diff --git a/changelog.d/nested-mount-ignore.fixed.md b/changelog.d/nested-mount-ignore.fixed.md index ace439970..75df83377 100644 --- a/changelog.d/nested-mount-ignore.fixed.md +++ b/changelog.d/nested-mount-ignore.fixed.md @@ -34,6 +34,11 @@ which matches that path from the outer mount's root and what is under it, and nothing else. A sync without a path counts the inner mount's directory under `passedOver`, as it counts any ignored directory. + The walk passes over a nested mount's directory that the rule doesn't + name in the same way: one made in another case than configured, or one + that came to lie in the outer mount after startup. The watcher passes + over only what the rule names; a change it reports from such a + directory is refused like any path in a nested mount. - Nesting is read from the mounts' canonical roots. A mount configured through a symlink, or spelled in another case on a case-insensitive filesystem, is still nested, and the rule is spelled as disk spells it. @@ -57,9 +62,9 @@ in "outer" at a/b/: use "inner/x.txt"". That covers `read`, `write`, `edit`, `ls`, `glob`, `grep`, and a `sync` or `materialize` that names the path. Before, a named sync took the inner mount's files into the outer - one. `write`, `edit` and `writeBinary` also read where the + one. `write`, `edit`, `writeBinary` and `delete` also read where the path leads on disk at their checks, so existing case variants and links inside the mount, dangling or not, are checked too. A link changed after the store check can leave an outer draft at a now-nested path; materialize refuses the later disk write. - - `delete` can stop tracking an old outer copy at a nested path. It removes the exact stored key and its intent and agreement; the disk entry, if any, stays untouched. + - `delete` can stop tracking an old outer copy at a nested path, on a read-only mount too. It removes the exact stored key and its intent and agreement; the disk entry, if any, stays untouched. - A `materialize` without a path skips writes of such copies, with a note saying that disk was left untouched and the copy can be deleted here to stop tracking it. An already-pending deletion at a nested path is untracked once instead of receiving that advice again. - Every check that a canonical path lies inside the mount, when reading, walking or writing, also refuses one inside a nested mount. So a symlink @@ -86,7 +91,7 @@ - Nested refusals name the innermost mount, even when several mounts contain the path. A lexical refusal includes its directory, such as `"outer/a/b/x.txt" is inside mount "inner", which is nested in "outer" at a/b/: use "inner/x.txt"`. A path reached through disk spelling omits that location clause: `"outer/via/x.txt" is inside mount "inner", which is nested in "outer": use "inner/x.txt"`. The public tool wrapper prefixes thrown lexical errors with `Error:`. - Path spelling and comparison are separate. Existing components use disk's spelling, and unmade components keep their written spelling, including in generated ignore rules. On macOS and Windows, a nesting comparison ignores case only when one of the compared components was unmade. This uses platform defaults without a filesystem probe: non-default case-sensitive volumes can receive conservative refusals, while case-insensitive filesystems elsewhere are outside the missing-component rule. -- After classification, nested deletion uses the outer store rather than listing or statting the inner directory. It removes only an exact supplied key or an exact derived key contained in the identified nested mount. A differently cased tracked key is named in a refusal so the caller can request it exactly; case folding never chooses a key to remove. This also prevents a path through an inner link back out from forgetting an outer-owned entry. +- After classification, nested deletion uses the outer store rather than listing or statting the inner directory. It removes only an exact supplied key or an exact derived key contained in the identified nested mount. On macOS and Windows, a tracked key that differs only in case is named in the refusal so the caller can request it exactly, when a delete by that key would stop tracking it as well; a case variant that is the outer mount's own directory is never named. Case folding never chooses a key to remove. This also prevents a path through an inner link back out from forgetting an outer-owned entry. - An outer link into a nested mount is untracked rather than assigned a disk deletion the outer mount cannot perform. Its stored copy, intent, and disk agreement are removed, and the result says the file or link on disk, if any, was left untouched. The disk unlink boundary itself is unchanged. Path-less materialize also clears legacy pending deletions at nested paths or for such links, independently of `force` and `applyDeletions`. It reports cleanup once, including if remaining physical writes are blocked by branch divergence, and leaves no pending action to report on the next call. - Disk readers check containment and nested boundaries before answering a target's kind, including a directory reached through a link. They also check the opened file's path and identity. - Classification still makes synchronous realpath, lstat, and readlink calls on the event loop for roots and target paths; untracking adds no post-classification directory lookup. Materialize resolves the nested roots once for its selection and shares that snapshot across selected paths. Disk effects obtain their own fresh view. A mounted filesystem that does not answer can still block the synchronous reads, as tracked in #311. diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index b99646175..d4834b2a8 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -270,9 +270,6 @@ function resolveWorkspacePath(path: string): ResolvedPath { return { real: current, ...(missingFrom !== undefined ? { missingFrom } : {}) }; } -/** Consumers that need a path, rather than a comparison, keep its spelling. */ -function canonicalRoot(path: string): string { return resolveWorkspacePath(path).real; } - /** The last directory entry is not followed when classifying an unlink. */ function resolveWorkspaceEntry(path: string): ResolvedPath { let missing = false; @@ -754,12 +751,13 @@ export class WorkspaceModule implements Module { constructor(config: WorkspaceConfig) { // Nesting checks keep each mount's disk operations out of nested mounts - // (agent-framework #280). Roots use canonicalRoot: existing components - // use disk spelling; unmade components use its documented platform rule. The outer - // mount's walk and watcher pass over the inner's directory by an ignore - // rule; its tools refuse paths there (parsePath), and every containment - // check refuses what reaches the inner's directory another way - // (MountView.nested). + // (agent-framework #280). Roots are read by resolveWorkspacePath: existing + // components in disk's spelling, unmade ones as written, compared by + // nestedSuffix's platform rule. The outer mount's walk and watcher pass + // over the inner's directory by an ignore rule, and the walk also over one + // the rule doesn't name (MountView.nested); its tools refuse paths there + // (parsePath), and every containment check refuses what reaches the + // inner's directory another way (MountView.nested). const roots = new Map(config.mounts.map((m) => [m, resolveWorkspacePath(m.path)])); for (const outer of config.mounts) { for (const inner of config.mounts) { @@ -818,9 +816,10 @@ export class WorkspaceModule implements Module { * The mount nested in `mount` that a mount-relative path lies in by how * disk spells it, where the path as written may not show it: a case variant * on a case-insensitive disk (`A/B` for `a/b`), or a link inside the mount, - * dangling or not. The path is read as canonicalRoot reads a root: what - * exists as disk spells it, a link that leads to nothing yet replaced by - * what it names, and unmade components using the platform-default rule. + * dangling or not. The path is read as resolveWorkspacePath reads a root: + * what exists as disk spells it, a link that leads to nothing yet replaced + * by what it names, and unmade components as written, which nestedSuffix + * compares by the platform-default rule. * Store-changing tools check before the turn and at their mutation point. * A later disk change can make an accepted draft's path nested; the disk * effect checks that boundary again when materialize attempts the write. @@ -1335,7 +1334,7 @@ export class WorkspaceModule implements Module { { name: 'delete', description: 'Delete a file from the workspace. For a tracked nested path or an outer link into one, ' - + 'the workspace stops tracking the path and leaves any disk entry untouched. Other deletions on mounts ' + + 'the workspace stops tracking the path and leaves any disk entry untouched, on a read-only mount too. Other deletions on mounts ' + 'without autoMaterialize remain pending on disk, shown as workspace-deleted. Materialize with ' + 'applyDeletions attempts those deletions, or sync the path to restore it.', inputSchema: { @@ -2548,8 +2547,7 @@ export class WorkspaceModule implements Module { * untracked rather than tombstoned when the outer mount cannot unlink them. */ private nestedDeletion(mount: MountState, path: string, snapshot: NestedSnapshot): NestedDeletion | null { const lexical = this.nestedMountAt(mount, path, snapshot.ats); - if (lexical) return { ...lexical, rest: path.slice(lexical.at.length + 1), - derivedKey: path, where: ' at ' + lexical.at + '/', kind: 'entry' }; + if (lexical) return { ...lexical, rest: path.slice(lexical.at.length + 1), where: ' at ' + lexical.at + '/', kind: 'entry' }; const entry = this.nestedByDisk(mount, path, false, snapshot); if (entry) return { ...entry, where: '', kind: 'entry' }; const target = this.nestedByDisk(mount, path, true, snapshot); @@ -2573,14 +2571,13 @@ export class WorkspaceModule implements Module { /** This decision uses only the outer store and the classified path. * A different case is a hint, not authority to remove a different key. */ private deleteNestedCopy( - mount: MountState, relativePath: string, inputPath: string, inner: NestedDeletion, + mount: MountState, relativePath: string, inputPath: string, inner: NestedDeletion, snapshot: NestedSnapshot, ): ToolResult { const store = this.getStore(); const intents = this.intents.get(mount.config.name)!; - const inside = (path: string): boolean => - path === inner.at || path.startsWith(inner.at + '/'); - const keys = [relativePath]; - if (inner.kind === 'entry' && inner.derivedKey !== undefined && inside(inner.derivedKey)) keys.push(inner.derivedKey); + // The key as given, else the one disk names for the entry, which + // nestedByDisk derives only by exact containment, never by folding case. + const keys = inner.derivedKey !== undefined && inner.derivedKey !== relativePath ? [relativePath, inner.derivedKey] : [relativePath]; const storedPath = keys.find((path) => store.treeGet(mount.treeStateId, path) || intents.get(path) || this.agreementOrThrow().get(mount.config.name, path)); if (storedPath !== undefined) { @@ -2589,36 +2586,45 @@ export class WorkspaceModule implements Module { } if (process.platform === 'darwin' || process.platform === 'win32') { - const hintKey = inner.kind === 'link' ? relativePath : inner.at + (inner.rest ? '/' + inner.rest : ''); + // A copy stored under another case of this path is named only where a + // delete by its own name would stop tracking it too. Where that name + // reaches a different directory (a case-sensitive volume), the copy is + // the outer mount's own, and naming it would steer its deletion. + const wanted = new Set([relativePath, inner.kind === 'link' ? relativePath : inner.at + (inner.rest ? '/' + inner.rest : '')] + .map((path) => path.toLowerCase())); const hint = store.treeList(mount.treeStateId).find((entry) => - (inner.kind === 'link' || inside(entry.path)) && entry.path.toLowerCase() === hintKey.toLowerCase()); + wanted.has(entry.path.toLowerCase()) && this.nestedDeletion(mount, entry.path, snapshot) !== null); if (hint) return { success: false, isError: true, error: 'Tracked spelling: "' + mount.config.name + '/' + hint.path + '"; use that exact path to stop tracking this copy.' }; } + // A link is the outer mount's own entry; what lies in the nested mount is its target. return { success: false, isError: true, - error: '"' + inputPath + '" is inside mount "' + inner.name + '", which is nested in "' + - mount.config.name + '"' + inner.where + ', and "' + mount.config.name + + error: '"' + inputPath + '" ' + (inner.kind === 'link' ? 'leads into' : 'is inside') + ' mount "' + inner.name + + '", which is nested in "' + mount.config.name + '"' + inner.where + ', and "' + mount.config.name + '" doesn\'t hold it: use "' + (inner.rest ? inner.name + '/' + inner.rest : inner.name) + '"' }; } private async handleDelete(input: DeleteInput): Promise { const { mount, relativePath } = this.parsePath(input.path, { nested: 'allow' }); - if (mount.config.mode === 'read-only') { - return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true }; - } - const store = this.getStore(); return this.withMount(mount, async (): Promise => { // Decide from the copy already held. Observing first could import a // nested alias merely to forget it and announce that transient copy. - const beforeRead = this.nestedDeletion(mount, relativePath, this.nestedSnapshot(mount)); - if (beforeRead) return this.deleteNestedCopy(mount, relativePath, input.path, beforeRead); + // Stopping tracking writes nothing to disk, so a read-only mount can drop + // an old nested copy too; it refuses every other delete. + const before = this.nestedSnapshot(mount); + const beforeRead = this.nestedDeletion(mount, relativePath, before); + if (beforeRead) return this.deleteNestedCopy(mount, relativePath, input.path, beforeRead, before); + if (mount.config.mode === 'read-only') { + return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true }; + } const report = await this.ensureSynced(mount, relativePath); await this.settleBeforeMutation(mount, relativePath); // Decided after the last await, beside the store change: the path may // have come to lie in a nested mount while the delete waited. - const inner = this.nestedDeletion(mount, relativePath, this.nestedSnapshot(mount)); - if (inner) return this.deleteNestedCopy(mount, relativePath, input.path, inner); + const after = this.nestedSnapshot(mount); + const inner = this.nestedDeletion(mount, relativePath, after); + if (inner) return this.deleteNestedCopy(mount, relativePath, input.path, inner, after); const entry = store.treeGet(mount.treeStateId, relativePath); if (!entry) { return { success: false, error: this.notInWorkspace(input.path, report), isError: true }; diff --git a/src/modules/workspace/observe.ts b/src/modules/workspace/observe.ts index aeff44a4f..486e97fda 100644 --- a/src/modules/workspace/observe.ts +++ b/src/modules/workspace/observe.ts @@ -41,7 +41,7 @@ import { constants as fsConstants } from 'node:fs'; import type { BigIntStats } from 'node:fs'; import { lstat, mkdir, open, readdir, realpath, stat, statfs } from 'node:fs/promises'; import type { FileHandle } from 'node:fs/promises'; -import { dirname, isAbsolute, join, parse, relative, sep } from 'node:path'; +import { dirname, join, parse, relative, sep } from 'node:path'; import { sameRootIdentity, type Fingerprint, type RootIdentity } from './disk-agreement.js'; export interface MountView { @@ -119,17 +119,23 @@ export interface ResolvedPath { missingFrom?: number; } -export function pathComponents(path: string): string[] { - return path.slice(parse(path).root.length).split(sep).filter(Boolean); +/** The platform's path functions the nesting comparison uses; tests pass path.win32. */ +export type PathApi = Pick; +const PLATFORM_PATH: PathApi = { parse, relative, sep }; + +export function pathComponents(p: string, path: PathApi = PLATFORM_PATH): string[] { + return p.slice(path.parse(p).root.length).split(path.sep).filter(Boolean); } -/** Mount-relative suffix, preserving candidate spelling. Known components - * compare exactly. On darwin/win32, a comparison involving an unmade - * component uses the platform's default case folding. */ -export function nestedSuffix(root: ResolvedPath, candidate: ResolvedPath): string | null { - if (relative(parse(root.real).root, parse(candidate.real).root) !== '') return null; - const a = pathComponents(root.real); - const b = pathComponents(candidate.real); +/** Mount-relative suffix, preserving candidate spelling: '' for the root + * itself, null when the candidate isn't inside it (outside it, or on another + * Windows drive). Known components compare exactly. On darwin/win32, a + * comparison involving an unmade component uses the platform's default case + * folding. */ +export function nestedSuffix(root: ResolvedPath, candidate: ResolvedPath, path: PathApi = PLATFORM_PATH): string | null { + if (path.relative(path.parse(root.real).root, path.parse(candidate.real).root) !== '') return null; + const a = pathComponents(root.real, path); + const b = pathComponents(candidate.real, path); if (b.length < a.length) return null; const fold = process.platform === 'darwin' || process.platform === 'win32'; for (let i = 0; i < a.length; i++) { @@ -758,24 +764,6 @@ export function coveredByIgnore(relativePath: string, patterns: string[]): boole return false; } -/** - * Where `innerRoot` lies in `outerRoot`, '/'-joined, or null when it isn't - * nested in it: the same directory, outside it (`..`, or `../…`; a name like - * `..cache` is inside), or another root (`relative()` across Windows drives - * gives an absolute path). Both roots should be canonical, so a symlinked or - * differently cased spelling of the same directory still nests (#282's - * round). `path` is the platform's; tests pass path.win32. - */ -export function nestedAt( - outerRoot: string, - innerRoot: string, - path: Pick = { relative, isAbsolute, sep }, -): string | null { - const rel = path.relative(outerRoot, innerRoot); - if (!rel || rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) return null; - return rel.split(path.sep).join('/'); -} - /** * Simple ignore patterns, as the walk and the watcher read them (types.ts * `MountConfig.ignore` lists the forms). A name matches an entry so named at @@ -878,7 +866,11 @@ export async function walkScope( let reachedCap = false; for (const entry of entries) { const childRel = rel ? `${rel}/${entry.name}` : entry.name; - if (isIgnored(childRel, entry.name, ignore)) { + // A nested mount's directory is passed over as its generated rule passes + // it over, also where the rule doesn't name it: made under another case + // than configured, or nested only after the module was built. Walking it + // would list files the checks refuse one by one, counting toward the cap. + if (isIgnored(childRel, entry.name, ignore) || (entry.isDirectory() && insideNested(view, dir.at(entry.name)) !== null)) { others.push([childRel, 'ignored by the mount']); regions.push({ path: childRel, reason: 'ignored by the mount', kind: 'ignored' }); continue; diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index 00828f133..acc521081 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -31,7 +31,7 @@ import { syncBuiltinESMExports } from 'node:module'; import { dirname, join, parse, sep, win32 } from 'node:path'; import { JsStore } from '@animalabs/chronicle'; import { WorkspaceModule } from '../src/modules/workspace/index.js'; -import { insideNested, nestedAt, readPath } from '../src/modules/workspace/observe.js'; +import { nestedSuffix, readPath } from '../src/modules/workspace/observe.js'; import type { MountConfig } from '../src/modules/workspace/types.js'; import type { ModuleContext } from '../src/types/module.js'; import { MountWatcher, type FsChange } from '../src/modules/workspace/watcher.js'; @@ -245,13 +245,14 @@ test("a parent or a sibling mount isn't nested in a mount; the parent holds its }); test('nesting by the platform rules: separators, `..`, and another Windows drive', () => { - assert.equal(nestedAt('C:\\w\\outer', 'C:\\w\\outer\\a\\b', win32), 'a/b'); - assert.equal(nestedAt('C:\\w\\outer', 'D:\\w\\outer\\a', win32), null, 'another drive is another root'); - assert.equal(nestedAt('C:\\w\\outer', 'C:\\w', win32), null); - assert.equal(nestedAt('C:\\w\\outer', 'C:\\w\\side', win32), null); - assert.equal(nestedAt('C:\\w\\outer', 'C:\\w\\outer', win32), null); - assert.equal(nestedAt('C:\\w\\outer', 'C:\\w\\outer\\..cache', win32), '..cache'); - assert.equal(nestedAt('/w/outer', '/w/outer/..cache/x'), '..cache/x'); + const at = (outer: string, inner: string) => nestedSuffix({ real: outer }, { real: inner }, win32); + assert.equal(at('C:\\w\\outer', 'C:\\w\\outer\\a\\b'), 'a/b'); + assert.equal(at('C:\\w\\outer', 'D:\\w\\outer\\a'), null, 'another drive is another root'); + assert.equal(at('C:\\w\\outer', 'C:\\w'), null); + assert.equal(at('C:\\w\\outer', 'C:\\w\\side'), null); + assert.equal(at('C:\\w\\outer', 'C:\\w\\outer'), '', 'the root itself: inside, but nothing nests there'); + assert.equal(at('C:\\w\\outer', 'C:\\w\\outer\\..cache'), '..cache'); + assert.equal(nestedSuffix({ real: '/w/outer' }, { real: '/w/outer/..cache/x' }), '..cache/x'); }); test('a nested mount configured through a symlink, or an outer one, is still nested', async (t) => { @@ -834,6 +835,10 @@ for (const autoMaterialize of [true, false]) { const synced = await call('sync', { mount: 'outer' }); assert.equal(synced.success, true, String(synced.error)); assert.doesNotMatch(JSON.stringify((synced.data as { results: unknown[] }).results), /"synced":\[[^\]]*child\/x\.txt/, 'not taken in'); + // No ignore rule names it, since the nesting came about after the module + // was built; the walk passes over it all the same, rather than listing + // files it would refuse one by one. + assert.deepEqual((synced.data as { passedOver?: unknown }).passedOver, [{ mount: 'outer', reason: 'ignored by the mount', count: 1 }]); const owed = await call('materialize', {}); assert.doesNotMatch(JSON.stringify(owed), /child\/y\.txt/, 'the outer mount holds nothing at that path'); assert.throws(() => readFileSync(join(outerDir, 'child', 'y.txt')), "nothing was written into the inner mount's directory"); @@ -1049,7 +1054,7 @@ async function secondPassRig(t: TestContext, autoMaterialize = false, maxFileSiz for (const kind of ['lexical', 'parent-alias', 'final-link'] as const) { for (const force of [false, true]) for (const applyDeletions of [false, true]) { - test(`#282 pass2: a legacy ${kind} pending deletion stops tracking once (force ${force}, applyDeletions ${applyDeletions})`, async (t) => { + test(`a legacy ${kind} pending deletion stops tracking once (force ${force}, applyDeletions ${applyDeletions})`, async (t) => { const r = await secondPassRig(t); const path = kind === 'lexical' ? 'inner/x.txt' : kind === 'parent-alias' ? 'via/x.txt' : 'link'; if (kind === 'parent-alias') symlinkSync(r.inner, join(r.outer, 'via')); @@ -1073,7 +1078,7 @@ for (const kind of ['lexical', 'parent-alias', 'final-link'] as const) { } for (const autoMaterialize of [false, true]) { - test(`#282 pass2: delete of an outer link stops tracking instead of leaving an impossible unlink (autoMaterialize ${autoMaterialize})`, async (t) => { + test(`delete of an outer link stops tracking instead of leaving an impossible unlink (autoMaterialize ${autoMaterialize})`, async (t) => { const r = await secondPassRig(t, autoMaterialize); assert.equal((await r.call('write', { path: 'outer/link', content: 'old copy' })).success, true); rmSync(join(r.outer, 'link'), { force: true }); @@ -1093,7 +1098,7 @@ for (const autoMaterialize of [false, true]) { }); } -test('#282 pass2: a Darwin-policy constructor preserves an unmade root in its ignore rule', async (t) => { +test('a Darwin-policy constructor preserves an unmade root in its ignore rule', async (t) => { const { root, stops } = scratch(t, 'af-nested-pass2-ignore-'); const outer = join(root, 'outer'); mkdirSync(outer); @@ -1120,7 +1125,7 @@ test('#282 pass2: a Darwin-policy constructor preserves an unmade root in its ig } finally { Object.defineProperty(process, 'platform', platform); } }); -test('#282 pass2: existing case-distinct paths retain their spelling under the Darwin policy', async (t) => { +test('existing case-distinct paths retain their spelling under the Darwin policy', async (t) => { const r = await secondPassRig(t); if (existsSync(join(r.outer, 'INNER'))) { t.skip('requires a case-sensitive directory for the control'); return; } mkdirSync(join(r.outer, 'INNER')); @@ -1134,7 +1139,7 @@ test('#282 pass2: existing case-distinct paths retain their spelling under the D } finally { Object.defineProperty(process, 'platform', platform); } }); -test('#282 pass2: delete recognises a case variant of the nested root itself', async (t) => { +test('delete recognises a case variant of the nested root itself', async (t) => { const r = await secondPassRig(t); const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; try { @@ -1145,7 +1150,7 @@ test('#282 pass2: delete recognises a case variant of the nested root itself', a } finally { Object.defineProperty(process, 'platform', platform); } }); -test('#282 pass2: a nested delete refuses before importing or announcing an alias', async (t) => { +test('a nested delete refuses before importing or announcing an alias', async (t) => { const r = await secondPassRig(t); assert.equal((await r.call('write', { path: 'outer/owned.txt', content: 'outer bytes' })).success, true); // The old disk-spelling lookup reaches its derived-key assignment only @@ -1159,7 +1164,7 @@ test('#282 pass2: a nested delete refuses before importing or announcing an alia 'a refused nested delete neither imports nor announces a temporary copy'); }); -test('#282 pass2: nestedDelete neither lists the inner directory nor returns its OS errors', async (t) => { +test('a nested delete neither lists the inner directory nor returns its OS errors', async (t) => { const r = await secondPassRig(t); const readdir = fs.readdirSync; let listed = 0; @@ -1177,7 +1182,7 @@ test('#282 pass2: nestedDelete neither lists the inner directory nor returns its } finally { fs.readdirSync = readdir; syncBuiltinESMExports(); } }); -test('#282 pass2: a case-folded store match is a hint, not permission to forget that key', async (t) => { +test('a case-folded store match is a hint, not permission to forget that key', async (t) => { const r = await secondPassRig(t); r.seedCopy('inner/File.txt'); const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; @@ -1193,7 +1198,7 @@ test('#282 pass2: a case-folded store match is a hint, not permission to forget } finally { Object.defineProperty(process, 'platform', platform); } }); -test('#282 pass2: untracking a stale nested copy makes no claim that its disk file exists', async (t) => { +test('untracking a stale nested copy makes no claim that its disk file exists', async (t) => { const r = await secondPassRig(t); assert.match(r.workspace.getTools().find((tool) => tool.name === 'delete')!.description, /stops tracking.*any disk entry untouched/); assert.match(r.workspace.getTools().find((tool) => tool.name === 'materialize')!.description, /Pending deletions blocked by nested-mount ownership stop being tracked/); @@ -1204,7 +1209,7 @@ test('#282 pass2: untracking a stale nested copy makes no claim that its disk fi assert.equal(existsSync(join(r.inner, 'missing.txt')), false); }); -test('#282 pass2: materialize reads nested roots once for its selection, not per path', async (t) => { +test('materialize reads nested roots once for its selection, not per path', async (t) => { const r = await secondPassRig(t); for (let i = 0; i < 4; i++) assert.equal((await r.call('write', { path: `outer/own-${i}.txt`, content: 'owned' })).success, true); const native = fs.realpathSync.native; @@ -1221,7 +1226,7 @@ test('#282 pass2: materialize reads nested roots once for its selection, not per } finally { fs.realpathSync.native = native; } }); -test('#282 pass2: a blocked physical push still reports its completed nested-deletion cleanup', async (t) => { +test('a blocked physical push still reports its completed nested-deletion cleanup', async (t) => { const r = await secondPassRig(t); const path = 'inner/x.txt'; const hash = r.store.storeBlob(Buffer.from('old copy'), 'text/plain'); @@ -1238,7 +1243,7 @@ test('#282 pass2: a blocked physical push still reports its completed nested-del assert.equal(readFileSync(join(r.inner, 'x.txt'), 'utf8'), 'inner bytes'); }); -test('#282 pass2: a non-ingested nested alias cannot derive an outer-owned key', async (t) => { +test('a non-ingested nested alias cannot derive an outer-owned key', async (t) => { const r = await secondPassRig(t, false, 4); assert.equal((await r.call('write', { path: 'outer/owned.txt', content: 'old' })).success, true); // Too large for observation to import under the alias. The old fallback @@ -1251,7 +1256,7 @@ test('#282 pass2: a non-ingested nested alias cannot derive an outer-owned key', assert.equal(readFileSync(join(r.outer, 'owned.txt'), 'utf8'), 'outer bytes above the four-byte cap'); }); -test('#282 pass2: an ordinary delete rechecks nesting after awaited settlement', async (t) => { +test('an ordinary delete rechecks nesting after awaited settlement', async (t) => { const r = await secondPassRig(t); mkdirSync(join(r.outer, 'moved')); writeFileSync(join(r.outer, 'moved', 'x.txt'), 'old'); @@ -1270,28 +1275,36 @@ test('#282 pass2: an ordinary delete rechecks nesting after awaited settlement', assert.equal(readFileSync(join(r.inner, 'x.txt'), 'utf8'), 'inner bytes'); }); -test('#282 pass2: the disk-effect view retains the missing-root comparison policy', async (t) => { - const { root } = scratch(t, 'af-nested-pass2-effect-view-'); - const realRoot = realpathSync.native(root); - const inner = join(realRoot, 'CamelChild'); - const missingFrom = inner.slice(parse(inner).root.length).split(sep).filter(Boolean).length - 1; - const view = { - root: realRoot, followSymlinks: true, maxFileSize: 100, ignore: [], - nested: [{ name: 'inner', real: inner, missingFrom }], - } as unknown as Parameters[0]; - // The view was taken before this directory existed. The effect's check - // must retain that fact when the directory is made under another case. - mkdirSync(join(realRoot, 'camelchild')); - writeFileSync(join(realRoot, 'camelchild', 'x.txt'), 'inner'); - const candidate = realpathSync.native(join(realRoot, 'camelchild', 'x.txt')); +test("a pass's view keeps an unmade nested root's marker: a directory made under another case is passed over, its file refused", async (t) => { + const { root, stops } = scratch(t, 'af-nested-pass2-view-'); + const outer = join(root, 'outer'); + mkdirSync(outer); + writeFileSync(join(outer, 'top.txt'), 'top'); const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; try { Object.defineProperty(process, 'platform', { ...platform, value: 'darwin' }); - assert.equal(insideNested(view, candidate), 'inside mount "inner", which is nested in this one'); + const config: MountConfig = { name: 'outer', path: outer, mode: 'read-write', watch: 'never', followSymlinks: true }; + const { workspace } = await started(stops, join(root, 'store'), [config, + { name: 'inner', path: join(outer, 'CamelChild'), mode: 'read-write', watch: 'never' }]); + assert.deepEqual(config.ignore, ['CamelChild/**']); + // Made under another case than configured, so the rule doesn't name it. + // On a case-insensitive disk it would be the inner root; here, a + // case-sensitive one, the inner root stays unmade and the platform rule + // still reads this directory as it, from the marker in the pass's view. + mkdirSync(join(outer, 'camelchild')); + writeFileSync(join(outer, 'camelchild', 'x.txt'), 'inner'); + symlinkSync(join(outer, 'camelchild', 'x.txt'), join(outer, 'alias.txt')); + const synced = await workspace.handleToolCall({ id: 's', name: 'sync', input: { mount: 'outer' } }); + assert.equal(synced.success, true, JSON.stringify(synced)); + const data = synced.data as { results: Array<{ synced: string[] }>; passedOver?: unknown }; + assert.deepEqual([...data.results[0]!.synced].sort(), ['top.txt'], JSON.stringify(synced.data)); + assert.deepEqual(data.passedOver, [{ mount: 'outer', reason: 'ignored by the mount', count: 1 }]); + // A link the walk lists is read through, and its target is refused. + assert.match(JSON.stringify(synced.data), /alias\.txt[^}]*inside mount \\"inner\\"/); } finally { Object.defineProperty(process, 'platform', platform); } }); -test('#282 pass2: failed agreement removal leaves the tracked copy available for retry', async (t) => { +test('failed agreement removal leaves the tracked copy available for retry', async (t) => { const r = await secondPassRig(t); const hash = r.seedCopy('inner/x.txt'); const agreement = r.state.agreementOrThrow(); @@ -1309,7 +1322,7 @@ test('#282 pass2: failed agreement removal leaves the tracked copy available for assert.equal(agreement.get('outer', 'inner/x.txt'), undefined); }); -test('#282 pass2: policy-only nesting cannot authorize a derived-key removal', async (t) => { +test('policy-only nesting cannot authorize a derived-key removal', async (t) => { const { root, stops } = scratch(t, 'af-nested-pass2-derived-policy-'); const outer = join(root, 'outer'); mkdirSync(join(outer, 'camel'), { recursive: true }); @@ -1330,5 +1343,76 @@ test('#282 pass2: policy-only nesting cannot authorize a derived-key removal', a assert.equal(result.success, false, JSON.stringify(result)); assert.equal(storeHolds(workspace, 'outer', 'camel/x.txt'), true, 'folding can refuse the path but cannot select that different exact key to remove'); + // The policy reads `camel` as the unmade `Camel`, so a delete by that key's + // own name would stop tracking it: the refusal names it, and removes nothing. + assert.match(String(result.error), /^Tracked spelling: "outer\/camel\/x\.txt"; use that exact path to stop tracking this copy\.$/); + } finally { Object.defineProperty(process, 'platform', platform); } +}); + +test('a tracked spelling whose nested directory differs in case is named, where a delete by it would stop tracking it', async (t) => { + const r = await secondPassRig(t); + r.seedCopy('Inner/a.txt'); + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + try { + Object.defineProperty(process, 'platform', { ...platform, value: 'darwin' }); + const result = await r.call('delete', { path: 'outer/inner/a.txt' }); + assert.equal(result.success, false); + assert.match(String(result.error), /^Tracked spelling: "outer\/Inner\/a\.txt"; use that exact path/); + assert.equal(storeHolds(r.workspace, 'outer', 'Inner/a.txt'), true, 'a hint removes nothing'); + const exact = await r.call('delete', { path: 'outer/Inner/a.txt' }); + assert.equal(exact.success, true, JSON.stringify(exact)); + assert.equal(storeHolds(r.workspace, 'outer', 'Inner/a.txt'), false); + assert.equal(readFileSync(join(r.inner, 'x.txt'), 'utf8'), 'inner bytes'); + } finally { Object.defineProperty(process, 'platform', platform); } +}); + +test("a case variant that is the outer mount's own directory is never named as a nested copy's spelling", async (t) => { + const r = await secondPassRig(t); + if (existsSync(join(r.outer, 'Inner'))) { t.skip('requires distinct case spellings for this control'); return; } + mkdirSync(join(r.outer, 'Inner')); + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + try { + Object.defineProperty(process, 'platform', { ...platform, value: 'darwin' }); + // A real directory of its own: known components compare exactly, so it isn't nested. + assert.equal((await r.call('write', { path: 'outer/Inner/a.txt', content: 'owned' })).success, true); + const result = await r.call('delete', { path: 'outer/inner/a.txt' }); + assert.equal(result.success, false); + assert.match(String(result.error), /^"outer\/inner\/a\.txt" is inside mount "inner", which is nested in "outer" at inner\/, and "outer" doesn't hold it: use "inner\/a\.txt"$/); + assert.equal(storeHolds(r.workspace, 'outer', 'Inner/a.txt'), true); } finally { Object.defineProperty(process, 'platform', platform); } }); + +test('a read-only mount can stop tracking an old nested copy, and refuses every other delete', async (t) => { + const { root, stops } = scratch(t, 'af-nested-pass2-read-only-'); + const outer = join(root, 'outer'); + mkdirSync(join(outer, 'inner'), { recursive: true }); + writeFileSync(join(outer, 'inner', 'x.txt'), 'inner bytes'); + writeFileSync(join(outer, 'top.txt'), 'top'); + const { workspace } = await started(stops, join(root, 'store'), [ + { name: 'outer', path: outer, mode: 'read-only', watch: 'never' }, + { name: 'inner', path: join(outer, 'inner'), mode: 'read-write', watch: 'never' }, + ]); + const store = (workspace as unknown as { getStore(): JsStore }).getStore(); + // As a scan before the nesting was honoured took it in. + store.treeSet('workspace/outer/tree', 'inner/x.txt', { blobHash: store.storeBlob(Buffer.from('old copy'), 'text/plain'), size: 8, mode: 0o644 }); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + const stopped = await call('delete', { path: 'outer/inner/x.txt' }); + assert.equal(stopped.success, true, JSON.stringify(stopped)); + assert.match(JSON.stringify(stopped.data), /Stopped tracking \\"outer\/inner\/x\.txt\\"/); + assert.equal(storeHolds(workspace, 'outer', 'inner/x.txt'), false); + assert.equal(readFileSync(join(outer, 'inner', 'x.txt'), 'utf8'), 'inner bytes'); + assert.equal((await call('sync', { mount: 'outer' })).success, true); + assert.equal(storeHolds(workspace, 'outer', 'top.txt'), true); + const refused = await call('delete', { path: 'outer/top.txt' }); + assert.equal(refused.success, false); + assert.equal(refused.error, 'Mount "outer" is read-only'); + assert.equal(storeHolds(workspace, 'outer', 'top.txt'), true); +}); + +test("an untracked outer link into a nested mount is refused as leading into it, not as lying inside it", async (t) => { + const r = await secondPassRig(t); + symlinkSync(join(r.inner, 'x.txt'), join(r.outer, 'link')); + const result = await r.call('delete', { path: 'outer/link' }); + assert.equal(result.success, false); + assert.equal(result.error, '"outer/link" leads into mount "inner", which is nested in "outer", and "outer" doesn\'t hold it: use "inner/x.txt"'); +}); From 371d2d7efe1a394bf2a7e57a6b6a1206eb876a8a Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 06:13:52 -0400 Subject: [PATCH 20/26] fix(workspace): a read-only mount refuses a key it doesn't hold at once; the spelling hint reads every record the untrack decision does Answers a review of adb04560. Read-only mounts. Delete made its nested decision before the read-only refusal, inside the mount's turn, so every delete on a read-only mount waited for the turn and read disk (a root on a share that doesn't answer blocks there), and a path in a nested mount that the mount didn't hold got the nested refusal instead of "Mount ... is read-only". A read-only mount now refuses at once, before the turn and before any disk read, every key it doesn't hold: a tree copy, a branch intent or disk agreement (holdsKey, which deleteNestedCopy now uses too). Only a key it holds goes on to the nested decision, and is untracked when it lies in a nested mount. Tracked-spelling hint. The untrack decision counts a key as held when the tree, the intents or the agreement holds it; the hint looked at the tree alone, so a pending deletion held under another case was never named. The hint now draws its candidates from all three. Texts: - The sync tool's description says passedOver counts nested mounts' directories as well as what the ignore list covers. - nestedSuffix's doc says both sides must be resolved, since it compares spellings, and that the fold follows the platform whatever the volume's own setting. - deleteNestedCopy's comment no longer says it reads nothing but the store: its hint classifies candidates, reading disk as a delete would. - Changelog, for operators: the bullets that sat after the #280 reference are folded into it and rewritten for what a host sees; the generated rule is spelled as disk spells the root, or as configured for one not made yet; on a case-sensitive macOS or Windows volume, a directory whose name differs only in case from a nested root not made yet is treated as that root's (refused, passed over by the walk, untracked by delete); a read-only mount takes the exact key. Tests: the read-only test refuses an unheld nested path and an untracked outer link into the nested mount with the read-only text; a tracked spelling held only as a pending deletion is named, and a delete by it untracks it; the platform-rules test pins Windows' fold for an unmade component, and exact comparison when both are made. --- changelog.d/nested-mount-ignore.fixed.md | 18 +++---- src/modules/workspace/index.ts | 55 ++++++++++++++-------- src/modules/workspace/observe.ts | 8 ++-- test/workspace-nested-mount-ignore.test.ts | 32 +++++++++++++ 4 files changed, 81 insertions(+), 32 deletions(-) diff --git a/changelog.d/nested-mount-ignore.fixed.md b/changelog.d/nested-mount-ignore.fixed.md index 75df83377..f3bda8e65 100644 --- a/changelog.d/nested-mount-ignore.fixed.md +++ b/changelog.d/nested-mount-ignore.fixed.md @@ -41,7 +41,8 @@ directory is refused like any path in a nested mount. - Nesting is read from the mounts' canonical roots. A mount configured through a symlink, or spelled in another case on a case-insensitive - filesystem, is still nested, and the rule is spelled as disk spells it. + filesystem, is still nested, and the rule is spelled as disk spells the + root, or as configured for a root not made yet. An inner mount in a directory whose name starts with `..` (such as `..cache`) is now recognized as nested. A root that doesn't exist yet is its configured path, resolved as @@ -64,7 +65,7 @@ path. Before, a named sync took the inner mount's files into the outer one. `write`, `edit`, `writeBinary` and `delete` also read where the path leads on disk at their checks, so existing case variants and links inside the mount, dangling or not, are checked too. A link changed after the store check can leave an outer draft at a now-nested path; materialize refuses the later disk write. - - `delete` can stop tracking an old outer copy at a nested path, on a read-only mount too. It removes the exact stored key and its intent and agreement; the disk entry, if any, stays untouched. + - `delete` can stop tracking an old outer copy at a nested path, on a read-only mount too, where it takes the exact key. It removes that key's copy, intent and agreement; the disk entry, if any, stays untouched. A read-only mount still refuses every other delete. - A `materialize` without a path skips writes of such copies, with a note saying that disk was left untouched and the copy can be deleted here to stop tracking it. An already-pending deletion at a nested path is untracked once instead of receiving that advice again. - Every check that a canonical path lies inside the mount, when reading, walking or writing, also refuses one inside a nested mount. So a symlink @@ -87,11 +88,10 @@ checked its files against a different root, the one reached by following that link first, and refused them. Its watcher reported none of its changes. + - Refusals name the innermost mount when several contain the path. A refusal of a path as written includes its directory, such as `"outer/a/b/x.txt" is inside mount "inner", which is nested in "outer" at a/b/: use "inner/x.txt"`, and the tool's answer prefixes it with `Error:`. One of a path reached by how disk spells it leaves the directory out: `"outer/via/x.txt" is inside mount "inner", which is nested in "outer": use "inner/x.txt"`. + - On macOS and Windows, a nesting comparison ignores case only where a directory isn't made yet; directories that exist compare exactly, and a generated rule keeps an unmade root's configured spelling. That's the platforms' default, not a probe of the volume. On a case-sensitive volume there, a directory of the outer mount's whose name differs only in case from a nested root not made yet is treated as that root's: refused, passed over by the walk, and untracked by delete. A case-insensitive filesystem on another platform isn't covered. + - A delete at a nested path removes only the exact key given, or the key disk names for the entry, never one chosen by ignoring case. So a path through a link back out of the nested mount can't make the outer mount forget its own file. On macOS and Windows, its refusal names a tracked key that differs only in case, when a delete by that key would stop tracking it as well. + - Deleting an outer link into a nested mount stops tracking the link, rather than recording a deletion on disk that the outer mount can't make; the result says the file or link on disk, if any, was left untouched. A `materialize` without a path clears older pending deletions at nested paths, or of such links, the same way, whatever `force` and `applyDeletions` say. It notes each once, also when branch divergence blocks the rest of the push. + - The disk readers refuse a path outside the mount, or inside a nested one, before saying what kind of entry it is, a directory reached through a link included. + - The nesting checks read disk synchronously, so a mount whose storage stops answering can block the process (#311). - (agent-framework #280) - -- Nested refusals name the innermost mount, even when several mounts contain the path. A lexical refusal includes its directory, such as `"outer/a/b/x.txt" is inside mount "inner", which is nested in "outer" at a/b/: use "inner/x.txt"`. A path reached through disk spelling omits that location clause: `"outer/via/x.txt" is inside mount "inner", which is nested in "outer": use "inner/x.txt"`. The public tool wrapper prefixes thrown lexical errors with `Error:`. -- Path spelling and comparison are separate. Existing components use disk's spelling, and unmade components keep their written spelling, including in generated ignore rules. On macOS and Windows, a nesting comparison ignores case only when one of the compared components was unmade. This uses platform defaults without a filesystem probe: non-default case-sensitive volumes can receive conservative refusals, while case-insensitive filesystems elsewhere are outside the missing-component rule. -- After classification, nested deletion uses the outer store rather than listing or statting the inner directory. It removes only an exact supplied key or an exact derived key contained in the identified nested mount. On macOS and Windows, a tracked key that differs only in case is named in the refusal so the caller can request it exactly, when a delete by that key would stop tracking it as well; a case variant that is the outer mount's own directory is never named. Case folding never chooses a key to remove. This also prevents a path through an inner link back out from forgetting an outer-owned entry. -- An outer link into a nested mount is untracked rather than assigned a disk deletion the outer mount cannot perform. Its stored copy, intent, and disk agreement are removed, and the result says the file or link on disk, if any, was left untouched. The disk unlink boundary itself is unchanged. Path-less materialize also clears legacy pending deletions at nested paths or for such links, independently of `force` and `applyDeletions`. It reports cleanup once, including if remaining physical writes are blocked by branch divergence, and leaves no pending action to report on the next call. -- Disk readers check containment and nested boundaries before answering a target's kind, including a directory reached through a link. They also check the opened file's path and identity. -- Classification still makes synchronous realpath, lstat, and readlink calls on the event loop for roots and target paths; untracking adds no post-classification directory lookup. Materialize resolves the nested roots once for its selection and shares that snapshot across selected paths. Disk effects obtain their own fresh view. A mounted filesystem that does not answer can still block the synchronous reads, as tracked in #311. diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index d4834b2a8..6f10b964a 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -1433,7 +1433,8 @@ export class WorkspaceModule implements Module { + 'directory replaced on purpose, or a drive back under a new device number, never for one still ' + 'unplugged, whose empty mountpoint would become the root. What the scan passed over by design and ' + 'reported nowhere else is counted under `passedOver`, by mount and reason: entries the mount\'s ' - + 'ignore list covers that hold nothing the workspace tracks (an ignored directory counts once). So a ' + + 'ignore list covers, and nested mounts\' directories, that hold nothing the workspace tracks (a ' + + 'directory counts once). So a ' + 'total of 0, with no conflicts and nothing under `passedOver`, `incomplete` or `skipped`, means the ' + 'sync looked everywhere and nothing had changed. A path the mount ignores is synced when you name ' + 'it, and listed under `ignored`: a sync without a path passes over it. Naming an ignored directory ' @@ -2568,34 +2569,46 @@ export class WorkspaceModule implements Module { '". The file or link on disk, if any, was left untouched.'; } - /** This decision uses only the outer store and the classified path. - * A different case is a hint, not authority to remove a different key. */ + /** Whether the outer store holds anything at this exact key: a tree copy, + * a branch intent or disk agreement, each of which untrackPath removes. */ + private holdsKey(mount: MountState, path: string): boolean { + return this.getStore().treeGet(mount.treeStateId, path) !== null + || this.intents.get(mount.config.name)!.get(path) !== null + || this.agreementOrThrow().get(mount.config.name, path) !== undefined; + } + + /** The keys this may remove come from the outer store and the classified + * path alone; nothing inside the nested mount is read. A different case is + * only a hint, never authority to remove a different key. */ private deleteNestedCopy( mount: MountState, relativePath: string, inputPath: string, inner: NestedDeletion, snapshot: NestedSnapshot, ): ToolResult { - const store = this.getStore(); - const intents = this.intents.get(mount.config.name)!; // The key as given, else the one disk names for the entry, which // nestedByDisk derives only by exact containment, never by folding case. const keys = inner.derivedKey !== undefined && inner.derivedKey !== relativePath ? [relativePath, inner.derivedKey] : [relativePath]; - const storedPath = keys.find((path) => store.treeGet(mount.treeStateId, path) || - intents.get(path) || this.agreementOrThrow().get(mount.config.name, path)); + const storedPath = keys.find((path) => this.holdsKey(mount, path)); if (storedPath !== undefined) { this.untrackPath(mount, storedPath); return { success: true, data: { path: inputPath, deleted: true, note: this.untrackNote(mount, storedPath) } }; } if (process.platform === 'darwin' || process.platform === 'win32') { - // A copy stored under another case of this path is named only where a - // delete by its own name would stop tracking it too. Where that name - // reaches a different directory (a case-sensitive volume), the copy is - // the outer mount's own, and naming it would steer its deletion. + // A key held under another case of this path (for a link, its own path; + // for an entry, also its place in the nested mount) is named only where + // a delete by that key would stop tracking it too, which classifying it + // tells, reading disk as a delete would. Where that name reaches a + // different directory of the outer mount's own, naming it would steer + // the caller to delete an ordinary file. const wanted = new Set([relativePath, inner.kind === 'link' ? relativePath : inner.at + (inner.rest ? '/' + inner.rest : '')] .map((path) => path.toLowerCase())); - const hint = store.treeList(mount.treeStateId).find((entry) => - wanted.has(entry.path.toLowerCase()) && this.nestedDeletion(mount, entry.path, snapshot) !== null); - if (hint) return { success: false, isError: true, - error: 'Tracked spelling: "' + mount.config.name + '/' + hint.path + '"; use that exact path to stop tracking this copy.' }; + const held = new Set([ + ...this.getStore().treeList(mount.treeStateId).map((entry) => entry.path), + ...this.intents.get(mount.config.name)!.list().map(([path]) => path), + ...this.agreementOrThrow().paths(mount.config.name).map(([path]) => path), + ]); + const hint = [...held].find((path) => wanted.has(path.toLowerCase()) && this.nestedDeletion(mount, path, snapshot) !== null); + if (hint !== undefined) return { success: false, isError: true, + error: 'Tracked spelling: "' + mount.config.name + '/' + hint + '"; use that exact path to stop tracking this copy.' }; } // A link is the outer mount's own entry; what lies in the nested mount is its target. return { success: false, isError: true, @@ -2606,18 +2619,20 @@ export class WorkspaceModule implements Module { private async handleDelete(input: DeleteInput): Promise { const { mount, relativePath } = this.parsePath(input.path, { nested: 'allow' }); + // A read-only mount takes one delete: stopping tracking an old copy it + // holds at a nested path, by its exact key, which writes nothing to disk. + // A key it doesn't hold is refused at once, as every delete there was, + // without waiting for the mount's turn or reading disk. + const readOnly = (): ToolResult => ({ success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true }); + if (mount.config.mode === 'read-only' && !this.holdsKey(mount, relativePath)) return readOnly(); const store = this.getStore(); return this.withMount(mount, async (): Promise => { // Decide from the copy already held. Observing first could import a // nested alias merely to forget it and announce that transient copy. - // Stopping tracking writes nothing to disk, so a read-only mount can drop - // an old nested copy too; it refuses every other delete. const before = this.nestedSnapshot(mount); const beforeRead = this.nestedDeletion(mount, relativePath, before); if (beforeRead) return this.deleteNestedCopy(mount, relativePath, input.path, beforeRead, before); - if (mount.config.mode === 'read-only') { - return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true }; - } + if (mount.config.mode === 'read-only') return readOnly(); const report = await this.ensureSynced(mount, relativePath); await this.settleBeforeMutation(mount, relativePath); // Decided after the last await, beside the store change: the path may diff --git a/src/modules/workspace/observe.ts b/src/modules/workspace/observe.ts index 486e97fda..e06189061 100644 --- a/src/modules/workspace/observe.ts +++ b/src/modules/workspace/observe.ts @@ -129,9 +129,11 @@ export function pathComponents(p: string, path: PathApi = PLATFORM_PATH): string /** Mount-relative suffix, preserving candidate spelling: '' for the root * itself, null when the candidate isn't inside it (outside it, or on another - * Windows drive). Known components compare exactly. On darwin/win32, a - * comparison involving an unmade component uses the platform's default case - * folding. */ + * Windows drive). It compares spellings, so both must be resolved + * (resolveWorkspacePath, realpath): an unresolved link or case variant of the + * same directory doesn't nest. Known components compare exactly. On + * darwin/win32, a comparison involving an unmade component uses the + * platform's default case folding, whatever the volume's own setting. */ export function nestedSuffix(root: ResolvedPath, candidate: ResolvedPath, path: PathApi = PLATFORM_PATH): string | null { if (path.relative(path.parse(root.real).root, path.parse(candidate.real).root) !== '') return null; const a = pathComponents(root.real, path); diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index acc521081..223de6032 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -253,6 +253,14 @@ test('nesting by the platform rules: separators, `..`, and another Windows drive assert.equal(at('C:\\w\\outer', 'C:\\w\\outer'), '', 'the root itself: inside, but nothing nests there'); assert.equal(at('C:\\w\\outer', 'C:\\w\\outer\\..cache'), '..cache'); assert.equal(nestedSuffix({ real: '/w/outer' }, { real: '/w/outer/..cache/x' }), '..cache/x'); + // Windows' default: case is ignored only where a component isn't made yet + // (`missingFrom` counts components after the drive root). + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + try { + Object.defineProperty(process, 'platform', { ...platform, value: 'win32' }); + assert.equal(nestedSuffix({ real: 'C:\\w\\Inner', missingFrom: 1 }, { real: 'C:\\w\\inner\\x' }, win32), 'x'); + assert.equal(nestedSuffix({ real: 'C:\\w\\Inner' }, { real: 'C:\\w\\inner\\x' }, win32), null, 'both made: compared exactly'); + } finally { Object.defineProperty(process, 'platform', platform); } }); test('a nested mount configured through a symlink, or an outer one, is still nested', async (t) => { @@ -1366,6 +1374,22 @@ test('a tracked spelling whose nested directory differs in case is named, where } finally { Object.defineProperty(process, 'platform', platform); } }); +test('a tracked spelling held only as a pending deletion is named too, as the delete it names would find it', async (t) => { + const r = await secondPassRig(t); + const hash = r.store.storeBlob(Buffer.from('old copy'), 'text/plain'); + r.state.intents.get('outer')!.put('Inner/a.txt', { tombstone: { hash } }); + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + try { + Object.defineProperty(process, 'platform', { ...platform, value: 'darwin' }); + const result = await r.call('delete', { path: 'outer/inner/a.txt' }); + assert.equal(result.success, false); + assert.match(String(result.error), /^Tracked spelling: "outer\/Inner\/a\.txt"; use that exact path/); + const exact = await r.call('delete', { path: 'outer/Inner/a.txt' }); + assert.equal(exact.success, true, JSON.stringify(exact)); + assert.equal(r.state.intents.get('outer')!.get('Inner/a.txt'), null); + } finally { Object.defineProperty(process, 'platform', platform); } +}); + test("a case variant that is the outer mount's own directory is never named as a nested copy's spelling", async (t) => { const r = await secondPassRig(t); if (existsSync(join(r.outer, 'Inner'))) { t.skip('requires distinct case spellings for this control'); return; } @@ -1407,6 +1431,14 @@ test('a read-only mount can stop tracking an old nested copy, and refuses every assert.equal(refused.success, false); assert.equal(refused.error, 'Mount "outer" is read-only'); assert.equal(storeHolds(workspace, 'outer', 'top.txt'), true); + // A key it doesn't hold is refused as read-only at once, nested or not: + // a path in the nested mount, and an outer link into it. + symlinkSync(join(outer, 'inner', 'x.txt'), join(outer, 'link')); + for (const path of ['outer/inner/x.txt', 'outer/inner/other.txt', 'outer/link']) { + const unheld = await call('delete', { path }); + assert.equal(unheld.error, 'Mount "outer" is read-only', path); + } + assert.equal(readFileSync(join(outer, 'inner', 'x.txt'), 'utf8'), 'inner bytes'); }); test("an untracked outer link into a nested mount is refused as leading into it, not as lying inside it", async (t) => { From 038d73b850553b8b4a3979c672dd9a2ef1269593 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 06:24:32 -0400 Subject: [PATCH 21/26] fix(workspace): delete reads no root before a read-only refusal; a read or edit checks a tracked file the mount ignores against disk parsePath looked up the nested mount a path lies in (nestedAtsOf, which reads every mount's root from disk) before it looked at `nested: 'allow'`. Delete, the one caller that allows a nested path, classifies the path itself in the mount's turn, so for it the lookup only read roots: two synchronous realpath calls ahead of the read-only refusal 371d2d7e meant to come before any disk read, and a duplicate of the snapshot every delete takes. The lookup now runs only when it can refuse. A test counts the realpath calls on the read-only route for an unheld nested path, an unheld one and an untracked outer link: none. A file the workspace tracks under a path the mount ignores, such as one the old watcher took in from node_modules, is kept current by nothing: the watcher and a sync without a path pass over it. read, edit and readBinary (behind read_image) took the workspace copy as it stood (ensureSynced observes only a path the workspace doesn't hold), so after a disk change an agent read old content, and nothing said so. ensureSynced now also observes a held path the mount ignores, by its own path, under the three-way rule, as a sync that names it does. Listings and grep already mark such a file unverified. A test takes a file in by name, changes it on disk, and reads and edits it: the read sees the new content and the edit finds its text; without the change the read returns the old copy. Changelog: the watcher bullet opens with what an operator sees (a watched mount leaves changes under its ignored paths, node_modules included, out of the workspace), and the bullet on files the old watcher took in says a read or edit checks one against disk first. --- changelog.d/nested-mount-ignore.fixed.md | 17 +++++---- src/modules/workspace/index.ts | 15 +++++--- test/workspace-nested-mount-ignore.test.ts | 43 +++++++++++++++++++--- 3 files changed, 56 insertions(+), 19 deletions(-) diff --git a/changelog.d/nested-mount-ignore.fixed.md b/changelog.d/nested-mount-ignore.fixed.md index f3bda8e65..684512e14 100644 --- a/changelog.d/nested-mount-ignore.fixed.md +++ b/changelog.d/nested-mount-ignore.fixed.md @@ -1,14 +1,17 @@ - Workspace tools check nested-mount boundaries before and inside each mount's turn, and disk operations check again before their effects. A mount's ignore patterns now apply to its watcher. - - The watcher handed the patterns to chokidar as strings, which chokidar - takes as exact paths, so on a watched mount (`watch` defaults to - `'always'`) every change under an ignored path, `node_modules` included, - was synced. The watcher now applies the walk's own rule: a path is passed - over when it or a directory above it matches. Chokidar also no longer - descends into ignored directories. + - A watched mount (`watch` defaults to `'always'`) now leaves changes + under its ignored paths, `node_modules` included, out of the workspace. + The watcher handed the patterns to chokidar as strings, which chokidar + takes as exact paths, so every such change was synced. The watcher now + applies the walk's own rule: a path is passed over when it or a + directory above it matches. Chokidar also no longer descends into + ignored directories. - Files the old watcher already took in from under an ignored path stay tracked, and the watcher and a sync without a path no longer update them. A sync that names such a path still takes disk's copy, and says the - mount ignores it. Each sync without a path lists them under `skipped` + mount ignores it. A `read` or `edit` of one checks it against disk first, + by its own path, so neither works from a copy disk has moved on from. + Each sync without a path lists them under `skipped` ("disk could not be checked: ignored by the mount"). It also lists, under `incomplete`, the ignored entry that hides them: the file itself for a pattern it matches, such as `app.log` under `*.log`, or the ignored diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 6f10b964a..cae5a181c 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -1793,9 +1793,10 @@ export class WorkspaceModule implements Module { const relativePath = relative(root, resolved).split(sep).join('/'); // What lies in a mount nested in this one is that mount's: its tools - // answer for it there (agent-framework #280). - const inner = this.nestedMountAt(mount, relativePath); - if (inner && opts.nested !== 'allow') { + // answer for it there (agent-framework #280). Delete classifies the path + // itself, in the mount's turn, so it reads no roots here. + const inner = opts.nested === 'allow' ? undefined : this.nestedMountAt(mount, relativePath); + if (inner) { const rest = relativePath.slice(inner.at.length + 1); throw new Error( `"${path}" is inside mount "${inner.name}", which is nested in "${mountName}" at ${inner.at}/: ` + @@ -2235,12 +2236,14 @@ export class WorkspaceModule implements Module { /** * Before a read: a path the workspace doesn't hold is observed under the * three-way rule, which ingests a new disk file but never brings back a - * workspace deletion or a file absent from this branch. The caller holds - * the mount's turn. + * workspace deletion or a file absent from this branch. So is a path it + * holds that the mount ignores: neither the watcher nor a sync without a + * path keeps such a copy current, so it's checked by its own path, as a + * sync that names it would. The caller holds the mount's turn. */ private async ensureSynced(mount: MountState, relativePath: string): Promise { const store = this.getStore(); - if (store.treeGet(mount.treeStateId, relativePath)) return undefined; + if (store.treeGet(mount.treeStateId, relativePath) && !coveredByIgnore(relativePath, mount.config.ignore ?? [])) return undefined; const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }); return pass.reports.get(relativePath); } diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index 223de6032..90cbba43b 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -1431,16 +1431,47 @@ test('a read-only mount can stop tracking an old nested copy, and refuses every assert.equal(refused.success, false); assert.equal(refused.error, 'Mount "outer" is read-only'); assert.equal(storeHolds(workspace, 'outer', 'top.txt'), true); - // A key it doesn't hold is refused as read-only at once, nested or not: - // a path in the nested mount, and an outer link into it. + // A key it doesn't hold is refused as read-only at once, nested or not (a + // path in the nested mount, and an outer link into it), reading no root. symlinkSync(join(outer, 'inner', 'x.txt'), join(outer, 'link')); - for (const path of ['outer/inner/x.txt', 'outer/inner/other.txt', 'outer/link']) { - const unheld = await call('delete', { path }); - assert.equal(unheld.error, 'Mount "outer" is read-only', path); - } + const native = fs.realpathSync.native; + let reads = 0; + try { + fs.realpathSync.native = ((path: Parameters[0], options?: Parameters[1]) => { + reads++; + return native(path, options as never); + }) as typeof native; + for (const path of ['outer/inner/x.txt', 'outer/inner/other.txt', 'outer/link']) { + const unheld = await call('delete', { path }); + assert.equal(unheld.error, 'Mount "outer" is read-only', path); + } + } finally { fs.realpathSync.native = native; } + assert.equal(reads, 0, 'the refusal reads no root from disk'); assert.equal(readFileSync(join(outer, 'inner', 'x.txt'), 'utf8'), 'inner bytes'); }); +test('a read or an edit of a tracked file the mount ignores checks it against disk first, as nothing else keeps it current', async (t) => { + const { root, stops } = scratch(t, 'af-nested-pass2-ignored-read-'); + const outer = join(root, 'outer'); + mkdirSync(join(outer, 'node_modules'), { recursive: true }); + writeFileSync(join(outer, 'node_modules', 'pkg.js'), 'v1'); + const { workspace } = await started(stops, join(root, 'store'), [ + { name: 'outer', path: outer, mode: 'read-write', watch: 'never', ignore: ['node_modules'] }, + ]); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + // Taken in by name, as the old watcher took such files in unasked. + assert.equal((await call('sync', { path: 'outer/node_modules/pkg.js' })).success, true); + assert.equal(storeHolds(workspace, 'outer', 'node_modules/pkg.js'), true); + writeFileSync(join(outer, 'node_modules', 'pkg.js'), 'v2'); + // A sync without a path passes over it, so the workspace copy is still v1. + assert.equal((await call('sync', { mount: 'outer' })).success, true); + const read = await call('read', { path: 'outer/node_modules/pkg.js' }); + assert.match(JSON.stringify(read.data), /\\tv2/, JSON.stringify(read)); + writeFileSync(join(outer, 'node_modules', 'pkg.js'), 'v3'); + const edited = await call('edit', { path: 'outer/node_modules/pkg.js', oldString: 'v3', newString: 'v4' }); + assert.equal(edited.success, true, JSON.stringify(edited)); +}); + test("an untracked outer link into a nested mount is refused as leading into it, not as lying inside it", async (t) => { const r = await secondPassRig(t); symlinkSync(join(r.inner, 'x.txt'), join(r.outer, 'link')); From 81275a9d861602523250265d4fdf5f48b6ad4ce1 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 06:32:40 -0400 Subject: [PATCH 22/26] fix(workspace): only reads check an ignored tracked file against disk, and say when they couldn't Two findings on 038d73b8's ensureSynced change. Delete. ensureSynced has four callers, and 038d73b8 made all four observe a held path the mount ignores. For a delete that's wrong: it matches no content, so taking in a disk change first (an npm install rewriting node_modules/pkg.js, say) made the unlink find disk agreeing and remove a change the workspace never saw, where before it refused ("disk changed since it last agreed with the workspace"). The check is now an option, checkIgnored, which read, edit and readBinary pass and delete doesn't. A test takes a file in by name, changes it on disk, and deletes it on an auto-materializing mount: the unlink is refused and the change stays. A check that couldn't confirm the copy. When disk can't be observed at that path (a link the mount doesn't follow, which package managers put into node_modules), the pass reports the path unverified and the read returned the workspace copy with nothing said. read, edit and readBinary now keep returning the workspace copy and add diskCheck: the state as listings show it, with its note or conflict, whenever the check didn't confirm the copy (anything but synced or the workspace's own unwritten change). A test replaces the file with such a link: read, readBinary and edit each say unverified with the note, and a read of a file that agrees with disk adds nothing. Changelog: a read or edit of such a file works from the workspace copy where disk can't be checked and says so under diskCheck; a delete doesn't take disk's copy first, so it still refuses to remove a change the workspace hasn't seen. --- changelog.d/nested-mount-ignore.fixed.md | 6 ++- src/modules/workspace/index.ts | 58 +++++++++++++++++----- test/workspace-nested-mount-ignore.test.ts | 47 ++++++++++++++++++ 3 files changed, 97 insertions(+), 14 deletions(-) diff --git a/changelog.d/nested-mount-ignore.fixed.md b/changelog.d/nested-mount-ignore.fixed.md index 684512e14..8572981f9 100644 --- a/changelog.d/nested-mount-ignore.fixed.md +++ b/changelog.d/nested-mount-ignore.fixed.md @@ -10,7 +10,11 @@ tracked, and the watcher and a sync without a path no longer update them. A sync that names such a path still takes disk's copy, and says the mount ignores it. A `read` or `edit` of one checks it against disk first, - by its own path, so neither works from a copy disk has moved on from. + by its own path. Where disk can't be checked, as through a link the + mount doesn't follow, it works from the workspace copy and says so under + `diskCheck`, as listings mark it unverified. A `delete` doesn't take + disk's copy first, so it still refuses to remove a change on disk the + workspace hasn't seen. Each sync without a path lists them under `skipped` ("disk could not be checked: ignored by the mount"). It also lists, under `incomplete`, the ignored entry that hides them: the file itself for a diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index cae5a181c..54c348cd8 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -1686,7 +1686,7 @@ export class WorkspaceModule implements Module { */ async readBinary( mountPrefixedPath: string, - ): Promise<{ data: Buffer } | { error: string }> { + ): Promise<({ data: Buffer } & DiskCheck) | { error: string }> { if (!this.running()) return { error: NOT_RUNNING }; let mount: MountState; let relativePath: string; @@ -1697,12 +1697,12 @@ export class WorkspaceModule implements Module { } const store = this.getStore(); return this.withMount(mount, async () => { - await this.ensureSynced(mount, relativePath); + const report = await this.ensureSynced(mount, relativePath, { checkIgnored: true }); const entry = store.treeGet(mount.treeStateId, relativePath); if (!entry) return { error: `File not found: ${mountPrefixedPath}` }; const blob = store.getBlob(entry.blobHash); if (!blob) return { error: `Blob not found for: ${mountPrefixedPath}` }; - return { data: blob }; + return { data: blob, ...diskCheckView(report) }; }); } @@ -2236,14 +2236,21 @@ export class WorkspaceModule implements Module { /** * Before a read: a path the workspace doesn't hold is observed under the * three-way rule, which ingests a new disk file but never brings back a - * workspace deletion or a file absent from this branch. So is a path it - * holds that the mount ignores: neither the watcher nor a sync without a - * path keeps such a copy current, so it's checked by its own path, as a - * sync that names it would. The caller holds the mount's turn. + * workspace deletion or a file absent from this branch. With + * `checkIgnored`, so is a path it holds that the mount ignores: neither the + * watcher nor a sync without a path keeps such a copy current, so a caller + * that hands its content on (read, edit, readBinary) checks it by its own + * path, as a sync that names it would, and says so when the check couldn't + * confirm it (diskCheckView). A delete doesn't: it matches no content, so + * taking in a disk change first would let its unlink remove a change the + * workspace never saw. The caller holds the mount's turn. */ - private async ensureSynced(mount: MountState, relativePath: string): Promise { + private async ensureSynced( + mount: MountState, relativePath: string, opts: { checkIgnored?: boolean } = {}, + ): Promise { const store = this.getStore(); - if (store.treeGet(mount.treeStateId, relativePath) && !coveredByIgnore(relativePath, mount.config.ignore ?? [])) return undefined; + if (store.treeGet(mount.treeStateId, relativePath) + && !(opts.checkIgnored && coveredByIgnore(relativePath, mount.config.ignore ?? []))) return undefined; const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }); return pass.reports.get(relativePath); } @@ -2287,19 +2294,21 @@ export class WorkspaceModule implements Module { } } let content = generatedContent; + let checked: DiskCheck = {}; if (content === undefined) { const { mount, relativePath } = this.parsePath(input.path); const store = this.getStore(); - const found = await this.withMount(mount, async (): Promise<{ text: string } | { error: string }> => { - const report = await this.ensureSynced(mount, relativePath); + const found = await this.withMount(mount, async (): Promise<{ text: string; report?: PathReport } | { error: string }> => { + const report = await this.ensureSynced(mount, relativePath, { checkIgnored: true }); const entry = store.treeGet(mount.treeStateId, relativePath); if (!entry) return { error: this.notInWorkspace(input.path, report) }; const blob = store.getBlob(entry.blobHash); if (!blob) return { error: `Blob not found for: ${input.path}` }; - return { text: blob.toString('utf-8') }; + return { text: blob.toString('utf-8'), report }; }); if ('error' in found) return { success: false, error: found.error, isError: true }; content = found.text; + checked = diskCheckView(found.report); } if (characterPaging) { const start = Math.min(offsetChars, content.length); @@ -2320,6 +2329,7 @@ export class WorkspaceModule implements Module { offsetChars: start, nextOffsetChars: end < content.length ? end : null, content: content.slice(start, end), + ...checked, }, }; } @@ -2352,6 +2362,7 @@ export class WorkspaceModule implements Module { ? { note: `Truncated at ${effectiveLimit} lines (file has ${lines.length}). Use offset/limit to read more.` } : {}), content: formatted, + ...checked, }, }; } @@ -2483,7 +2494,7 @@ export class WorkspaceModule implements Module { const store = this.getStore(); return this.withMount(mount, async (): Promise => { - const report = await this.ensureSynced(mount, relativePath); + const report = await this.ensureSynced(mount, relativePath, { checkIgnored: true }); await this.settleBeforeMutation(mount, relativePath); // before the edit reads the entry // Read again in the mount's turn, after the last await: the path may have // come to lie in a nested mount while the edit waited. @@ -2542,6 +2553,7 @@ export class WorkspaceModule implements Module { data: { path: input.path, size: newBuffer.byteLength, + ...diskCheckView(report), }, }; }); @@ -3485,6 +3497,26 @@ export class WorkspaceModule implements Module { } +/** What checking a held copy against disk found, when it didn't confirm it. */ +type DiskCheck = { diskCheck?: Pick }; + +/** + * What a read of a held copy says when checking it against disk didn't + * confirm it (ensureSynced with `checkIgnored`): the state, as listings show + * it, beside the workspace copy the read still returns. Nothing when the copy + * agrees with disk or holds the workspace's own unwritten change. + */ +function diskCheckView(report: PathReport | undefined): DiskCheck { + if (!report || report.state === 'synced' || report.state === 'workspace-draft') return {}; + return { + diskCheck: { + state: report.state, + ...(report.note ? { note: report.note } : {}), + ...(report.conflict ? { conflict: report.conflict } : {}), + }, + }; +} + /** What a listing shows of a path beyond its name. */ function stateView(report: PathReport): Record { return { diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index 90cbba43b..65a33f6ef 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -1472,6 +1472,53 @@ test('a read or an edit of a tracked file the mount ignores checks it against di assert.equal(edited.success, true, JSON.stringify(edited)); }); +test("a read of a tracked file the mount ignores says when disk couldn't be checked, and still returns the workspace copy", async (t) => { + const { root, stops } = scratch(t, 'af-nested-pass2-ignored-unchecked-'); + const outer = join(root, 'outer'); + mkdirSync(join(outer, 'node_modules'), { recursive: true }); + writeFileSync(join(outer, 'node_modules', 'pkg.js'), 'v1'); + writeFileSync(join(outer, 'other.js'), 'elsewhere'); + const { workspace } = await started(stops, join(root, 'store'), [ + { name: 'outer', path: outer, mode: 'read-write', watch: 'never', ignore: ['node_modules'] }, + ]); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + assert.equal((await call('sync', { path: 'outer/node_modules/pkg.js' })).success, true); + // A package manager puts a link there, which this mount doesn't follow. + rmSync(join(outer, 'node_modules', 'pkg.js')); + symlinkSync(join(outer, 'other.js'), join(outer, 'node_modules', 'pkg.js')); + const read = await call('read', { path: 'outer/node_modules/pkg.js' }); + assert.equal(read.success, true, JSON.stringify(read)); + const data = read.data as { content: string; diskCheck?: { state: string; note?: string } }; + assert.match(data.content, /\tv1/); + assert.deepEqual(data.diskCheck, { state: 'unverified', note: 'a symlink, which this mount does not follow' }); + const bytes = await workspace.readBinary('outer/node_modules/pkg.js'); + assert.ok('data' in bytes && bytes.data.toString() === 'v1', JSON.stringify(bytes)); + assert.deepEqual('data' in bytes ? bytes.diskCheck : null, { state: 'unverified', note: 'a symlink, which this mount does not follow' }); + const edited = await call('edit', { path: 'outer/node_modules/pkg.js', oldString: 'v1', newString: 'v2' }); + assert.equal(edited.success, true, JSON.stringify(edited)); + assert.equal((edited.data as { diskCheck?: { state: string } }).diskCheck?.state, 'unverified'); + // A copy that agrees with disk says nothing more. + const plain = await call('read', { path: 'outer/other.js' }); + assert.equal((plain.data as { diskCheck?: unknown }).diskCheck, undefined); +}); + +test("a delete of a tracked file the mount ignores doesn't take in a disk change first, so it never removes one the workspace hasn't seen", async (t) => { + const { root, stops } = scratch(t, 'af-nested-pass2-ignored-delete-'); + const outer = join(root, 'outer'); + mkdirSync(join(outer, 'node_modules'), { recursive: true }); + writeFileSync(join(outer, 'node_modules', 'pkg.js'), 'v1'); + const { workspace } = await started(stops, join(root, 'store'), [ + { name: 'outer', path: outer, mode: 'read-write', watch: 'never', autoMaterialize: true, ignore: ['node_modules'] }, + ]); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + assert.equal((await call('sync', { path: 'outer/node_modules/pkg.js' })).success, true); + writeFileSync(join(outer, 'node_modules', 'pkg.js'), 'v2'); + const result = await call('delete', { path: 'outer/node_modules/pkg.js' }); + assert.equal(result.success, false, JSON.stringify(result)); + assert.match(String(result.error), /did not unlink.*disk changed since it last agreed/); + assert.equal(readFileSync(join(outer, 'node_modules', 'pkg.js'), 'utf8'), 'v2', 'the change the workspace never saw is still on disk'); +}); + test("an untracked outer link into a nested mount is refused as leading into it, not as lying inside it", async (t) => { const r = await secondPassRig(t); symlinkSync(join(r.inner, 'x.txt'), join(r.outer, 'link')); From 44c0d1a810d942a411720525b62618a1a1216d1d Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 06:38:39 -0400 Subject: [PATCH 23/26] fix(workspace): read_image checks a held image the mount ignores; the read-only refusal's test counts every disk read Answers a review of 038d73b8 (with 81275a9d's fixes) and two readers. read_image. The tool a model calls for an image is the workspace's own handleReadImage, which read a held image from the tree with no check: 81275a9d's readBinary change reached only the framework's legacy fallback for workspace modules that don't offer read_image (038d73b8's message called readBinary the one "behind read_image"; it's behind that fallback). handleReadImage now checks a held image under an ignored path against disk first, in the mount's turn, and adds a "Disk check" line to its header when the check didn't confirm the copy. A pass never stores an image (a binary isn't ingestible), so such an image is one an agent wrote. A test writes one with writeBinary, puts a link the mount doesn't follow in its place, and reads it through handleToolCall: the header ends "Disk check: unverified (a symlink, which this mount does not follow)". readBinary's missing entry now explains itself as read does (notInWorkspace): a held ignored file that turned binary on disk is dropped by the check, and the answer says it's on disk but not stored, not "File not found". A test pins it. Texts and tests: - ensureSynced's doc says the check is the three-way rule, which never gives up a workspace change; a sync that names the path takes disk's copy outright. - deleteNestedCopy's doc says the keys it may remove come from the store without reading inside the nested mount, while naming a case variant classifies it, which reads disk. - The read-only test counts realpath (sync and async), lstat (sync and async) and readlink on the refused route, now also for an unheld path outside any nested mount: none. - A tracked spelling held only as disk agreement is named, as one held only as a pending deletion is. - The ignored-read test asserts the workspace copy is still v1 after the sync without a path, so the read's own check is what refreshes it. - Changelog: read_image is among the tools that check such a file, with its "Disk check" line; a read-only mount can stop tracking an outer link into a nested mount too, by its exact key alone. --- changelog.d/nested-mount-ignore.fixed.md | 15 ++--- src/modules/workspace/index.ts | 24 ++++++-- test/workspace-nested-mount-ignore.test.ts | 67 +++++++++++++++++++--- 3 files changed, 84 insertions(+), 22 deletions(-) diff --git a/changelog.d/nested-mount-ignore.fixed.md b/changelog.d/nested-mount-ignore.fixed.md index 8572981f9..b8926f9cb 100644 --- a/changelog.d/nested-mount-ignore.fixed.md +++ b/changelog.d/nested-mount-ignore.fixed.md @@ -9,12 +9,13 @@ - Files the old watcher already took in from under an ignored path stay tracked, and the watcher and a sync without a path no longer update them. A sync that names such a path still takes disk's copy, and says the - mount ignores it. A `read` or `edit` of one checks it against disk first, - by its own path. Where disk can't be checked, as through a link the - mount doesn't follow, it works from the workspace copy and says so under - `diskCheck`, as listings mark it unverified. A `delete` doesn't take - disk's copy first, so it still refuses to remove a change on disk the - workspace hasn't seen. + mount ignores it. A `read`, `edit` or `read_image` of one checks it + against disk first, by its own path. Where disk can't be checked, as + through a link the mount doesn't follow, it works from the workspace copy + and says so (`diskCheck`; for `read_image`, a "Disk check" line), as + listings mark it unverified. A `delete` doesn't take disk's copy first, + so it still refuses to remove a change on disk the workspace hasn't + seen. Each sync without a path lists them under `skipped` ("disk could not be checked: ignored by the mount"). It also lists, under `incomplete`, the ignored entry that hides them: the file itself for a @@ -72,7 +73,7 @@ path. Before, a named sync took the inner mount's files into the outer one. `write`, `edit`, `writeBinary` and `delete` also read where the path leads on disk at their checks, so existing case variants and links inside the mount, dangling or not, are checked too. A link changed after the store check can leave an outer draft at a now-nested path; materialize refuses the later disk write. - - `delete` can stop tracking an old outer copy at a nested path, on a read-only mount too, where it takes the exact key. It removes that key's copy, intent and agreement; the disk entry, if any, stays untouched. A read-only mount still refuses every other delete. + - `delete` can stop tracking an old outer copy at a nested path, or an outer link into a nested mount, on a read-only mount too, where it takes the exact key alone. It removes that key's copy, intent and agreement; the disk entry, if any, stays untouched. A read-only mount still refuses every other delete. - A `materialize` without a path skips writes of such copies, with a note saying that disk was left untouched and the copy can be deleted here to stop tracking it. An already-pending deletion at a nested path is untracked once instead of receiving that advice again. - Every check that a canonical path lies inside the mount, when reading, walking or writing, also refuses one inside a nested mount. So a symlink diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 54c348cd8..1ff54dbff 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -1699,7 +1699,7 @@ export class WorkspaceModule implements Module { return this.withMount(mount, async () => { const report = await this.ensureSynced(mount, relativePath, { checkIgnored: true }); const entry = store.treeGet(mount.treeStateId, relativePath); - if (!entry) return { error: `File not found: ${mountPrefixedPath}` }; + if (!entry) return { error: this.notInWorkspace(mountPrefixedPath, report) }; const blob = store.getBlob(entry.blobHash); if (!blob) return { error: `Blob not found for: ${mountPrefixedPath}` }; return { data: blob, ...diskCheckView(report) }; @@ -2240,8 +2240,10 @@ export class WorkspaceModule implements Module { * `checkIgnored`, so is a path it holds that the mount ignores: neither the * watcher nor a sync without a path keeps such a copy current, so a caller * that hands its content on (read, edit, readBinary) checks it by its own - * path, as a sync that names it would, and says so when the check couldn't - * confirm it (diskCheckView). A delete doesn't: it matches no content, so + * path under the three-way rule, which never gives up a workspace change + * (a sync that names the path takes disk's copy outright), and says so + * when the check couldn't confirm it (diskCheckView). A delete doesn't: it + * matches no content, so * taking in a disk change first would let its unlink remove a change the * workspace never saw. The caller holds the mount's turn. */ @@ -2386,15 +2388,24 @@ export class WorkspaceModule implements Module { const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE; let treeError: WorkspaceImageReadError | null = null; + // A held image the mount ignores is checked against disk first, as read + // checks a held file there; a sync never stores an image, so it's one an + // agent wrote. Any other image is read from the tree, or from disk. + let checked: DiskCheck = {}; + if (this.getStore().treeGet(mount.treeStateId, relativePath) && coveredByIgnore(relativePath, mount.config.ignore ?? [])) { + checked = diskCheckView(await this.withMount(mount, () => this.ensureSynced(mount, relativePath, { checkIgnored: true }))); + } try { const image = this.tryReadImageFromTree(mount, relativePath, input.path, maxSize); if (image) { + const check = checked.diskCheck; return { success: true, data: [ { type: 'text', - text: `Path: ${input.path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`, + text: `Path: ${input.path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}` + + (check ? `\nDisk check: ${check.state}${check.note ? ` (${check.note})` : ''}` : ''), }, { type: 'image', @@ -2593,8 +2604,9 @@ export class WorkspaceModule implements Module { } /** The keys this may remove come from the outer store and the classified - * path alone; nothing inside the nested mount is read. A different case is - * only a hint, never authority to remove a different key. */ + * path alone, without reading inside the nested mount. A different case is + * only a hint, never authority to remove a different key; naming one + * classifies it as a delete would, which reads disk. */ private deleteNestedCopy( mount: MountState, relativePath: string, inputPath: string, inner: NestedDeletion, snapshot: NestedSnapshot, ): ToolResult { diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index 65a33f6ef..d832d88b7 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -1374,10 +1374,11 @@ test('a tracked spelling whose nested directory differs in case is named, where } finally { Object.defineProperty(process, 'platform', platform); } }); -test('a tracked spelling held only as a pending deletion is named too, as the delete it names would find it', async (t) => { +test('a tracked spelling held only as a pending deletion, or only as disk agreement, is named too, as the delete it names would find it', async (t) => { const r = await secondPassRig(t); const hash = r.store.storeBlob(Buffer.from('old copy'), 'text/plain'); r.state.intents.get('outer')!.put('Inner/a.txt', { tombstone: { hash } }); + r.state.agreementOrThrow().set('outer', 'Inner/b.txt', { kind: 'content', hash, size: 8 }); const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; try { Object.defineProperty(process, 'platform', { ...platform, value: 'darwin' }); @@ -1387,6 +1388,10 @@ test('a tracked spelling held only as a pending deletion is named too, as the de const exact = await r.call('delete', { path: 'outer/Inner/a.txt' }); assert.equal(exact.success, true, JSON.stringify(exact)); assert.equal(r.state.intents.get('outer')!.get('Inner/a.txt'), null); + const agreed = await r.call('delete', { path: 'outer/inner/b.txt' }); + assert.match(String(agreed.error), /^Tracked spelling: "outer\/Inner\/b\.txt"; use that exact path/); + assert.equal((await r.call('delete', { path: 'outer/Inner/b.txt' })).success, true); + assert.equal(r.state.agreementOrThrow().get('outer', 'Inner/b.txt'), undefined); } finally { Object.defineProperty(process, 'platform', platform); } }); @@ -1435,18 +1440,27 @@ test('a read-only mount can stop tracking an old nested copy, and refuses every // path in the nested mount, and an outer link into it), reading no root. symlinkSync(join(outer, 'inner', 'x.txt'), join(outer, 'link')); const native = fs.realpathSync.native; - let reads = 0; + const { lstatSync, readlinkSync } = fs; + const { realpath, lstat } = fsPromises; + const reads: string[] = []; + const counted = unknown>(name: string, f: F): F => + ((...args: Parameters) => { reads.push(name); return f(...args); }) as F; try { - fs.realpathSync.native = ((path: Parameters[0], options?: Parameters[1]) => { - reads++; - return native(path, options as never); - }) as typeof native; - for (const path of ['outer/inner/x.txt', 'outer/inner/other.txt', 'outer/link']) { + fs.realpathSync.native = counted('realpathSync.native', native); + Object.assign(fs, { lstatSync: counted('lstatSync', lstatSync), readlinkSync: counted('readlinkSync', readlinkSync) }); + Object.assign(fsPromises, { realpath: counted('realpath', realpath), lstat: counted('lstat', lstat) }); + syncBuiltinESMExports(); + for (const path of ['outer/inner/x.txt', 'outer/inner/other.txt', 'outer/link', 'outer/nothing.txt']) { const unheld = await call('delete', { path }); assert.equal(unheld.error, 'Mount "outer" is read-only', path); } - } finally { fs.realpathSync.native = native; } - assert.equal(reads, 0, 'the refusal reads no root from disk'); + } finally { + fs.realpathSync.native = native; + Object.assign(fs, { lstatSync, readlinkSync }); + Object.assign(fsPromises, { realpath, lstat }); + syncBuiltinESMExports(); + } + assert.deepEqual(reads, [], 'the refusal reads nothing from disk'); assert.equal(readFileSync(join(outer, 'inner', 'x.txt'), 'utf8'), 'inner bytes'); }); @@ -1465,6 +1479,9 @@ test('a read or an edit of a tracked file the mount ignores checks it against di writeFileSync(join(outer, 'node_modules', 'pkg.js'), 'v2'); // A sync without a path passes over it, so the workspace copy is still v1. assert.equal((await call('sync', { mount: 'outer' })).success, true); + const store = (workspace as unknown as { getStore(): JsStore }).getStore(); + const held = store.treeGet('workspace/outer/tree', 'node_modules/pkg.js'); + assert.equal(held && store.getBlob(held.blobHash)?.toString(), 'v1'); const read = await call('read', { path: 'outer/node_modules/pkg.js' }); assert.match(JSON.stringify(read.data), /\\tv2/, JSON.stringify(read)); writeFileSync(join(outer, 'node_modules', 'pkg.js'), 'v3'); @@ -1502,6 +1519,38 @@ test("a read of a tracked file the mount ignores says when disk couldn't be chec assert.equal((plain.data as { diskCheck?: unknown }).diskCheck, undefined); }); +test("readBinary of a tracked file the mount ignores, gone binary on disk, says why the workspace no longer holds it", async (t) => { + const { root, stops } = scratch(t, 'af-nested-pass2-ignored-binary-'); + const outer = join(root, 'outer'); + mkdirSync(join(outer, 'node_modules'), { recursive: true }); + writeFileSync(join(outer, 'node_modules', 'pkg.js'), 'v1'); + const { workspace } = await started(stops, join(root, 'store'), [ + { name: 'outer', path: outer, mode: 'read-write', watch: 'never', ignore: ['node_modules'] }, + ]); + assert.equal((await workspace.handleToolCall({ id: 's', name: 'sync', input: { path: 'outer/node_modules/pkg.js' } })).success, true); + writeFileSync(join(outer, 'node_modules', 'pkg.js'), ONE_PIXEL_PNG); + const bytes = await workspace.readBinary('outer/node_modules/pkg.js'); + assert.ok('error' in bytes, JSON.stringify(bytes)); + assert.match(bytes.error, /on disk but not stored in the workspace \(binary or over the size limit\)/); +}); + +test("read_image of a held image the mount ignores says when disk couldn't be checked", async (t) => { + const { root, stops } = scratch(t, 'af-nested-pass2-ignored-image-'); + const outer = join(root, 'outer'); + mkdirSync(join(outer, 'assets'), { recursive: true }); + writeFileSync(join(outer, 'other.png'), ONE_PIXEL_PNG); + const { workspace } = await started(stops, join(root, 'store'), [ + { name: 'outer', path: outer, mode: 'read-write', watch: 'never', ignore: ['assets'] }, + ]); + // A sync never stores an image, so a held one is an agent's own write. + assert.equal((await workspace.writeBinary('outer/assets/a.png', ONE_PIXEL_PNG, 'image/png')).success, true); + symlinkSync(join(outer, 'other.png'), join(outer, 'assets', 'a.png')); + const image = await workspace.handleToolCall({ id: 'img', name: 'read_image', input: { path: 'outer/assets/a.png' } }); + assert.equal(image.success, true, JSON.stringify(image)); + const text = (image.data as Array<{ type: string; text?: string }>).find((block) => block.type === 'text')!.text!; + assert.match(text, /\nDisk check: unverified \(a symlink, which this mount does not follow\)$/); +}); + test("a delete of a tracked file the mount ignores doesn't take in a disk change first, so it never removes one the workspace hasn't seen", async (t) => { const { root, stops } = scratch(t, 'af-nested-pass2-ignored-delete-'); const outer = join(root, 'outer'); From e5081686380c6fcc020f9b27aa94069b70a19c27 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 06:53:19 -0400 Subject: [PATCH 24/26] fix(workspace): a check that decides nothing says so; read_image checks and reads in one turn and names a conflict Answers a review of 038d73b8..44c0d1a8. - A check that decided nothing. When a pass gives up because the selected branch kept changing, it leaves no report, so ensureSynced returned nothing and read, edit, readBinary and read_image presented the workspace copy as if disk had confirmed it. For a held copy, ensureSynced now reports it unverified with the pass's reason. A test stands in such a pass and reads: diskCheck says unverified, "the selected branch kept changing while the scan ran". - read_image. For a held copy under an ignored path, the check and the tree read now happen in the same turn of the mount, so the image returned is the one the check looked at; any other path reads as before. The "Disk check" line names a conflict's kind where the check has no note. The tree read's refusal is kept through a closure, as before. - Tests: - a read keeps the workspace's own unwritten change with nothing added while disk agrees, and names a conflict, with its detail, once disk changes too; - a paged read (limitChars) carries diskCheck; - read_image of a held image that disk agrees with adds no line; - a read-only mount stops tracking a held outer link into the nested mount, by its exact key, and leaves the link on disk; - the read-only refusal also reads nothing for a case variant of the nested root on macOS, and the counter also covers plain realpathSync, async readlink and stat. - Texts: ensureSynced's doc lists read_image among the readers that check and says a delete of a held copy doesn't. The changelog says a check that decides nothing is reported too, that the readBinary API checks the same way and explains a dropped copy, and that it is a delete of such a file whose unlink still refuses an unseen disk change. --- changelog.d/nested-mount-ignore.fixed.md | 12 +-- src/modules/workspace/index.ts | 91 ++++++++++++---------- test/workspace-nested-mount-ignore.test.ts | 72 ++++++++++++++--- 3 files changed, 121 insertions(+), 54 deletions(-) diff --git a/changelog.d/nested-mount-ignore.fixed.md b/changelog.d/nested-mount-ignore.fixed.md index b8926f9cb..bbc18c274 100644 --- a/changelog.d/nested-mount-ignore.fixed.md +++ b/changelog.d/nested-mount-ignore.fixed.md @@ -11,11 +11,13 @@ A sync that names such a path still takes disk's copy, and says the mount ignores it. A `read`, `edit` or `read_image` of one checks it against disk first, by its own path. Where disk can't be checked, as - through a link the mount doesn't follow, it works from the workspace copy - and says so (`diskCheck`; for `read_image`, a "Disk check" line), as - listings mark it unverified. A `delete` doesn't take disk's copy first, - so it still refuses to remove a change on disk the workspace hasn't - seen. + through a link the mount doesn't follow, or the check decides nothing, + it works from the workspace copy and says so (`diskCheck`; for + `read_image`, a "Disk check" line), as listings mark it unverified. The + `readBinary` API checks the same way, returns the same `diskCheck`, and + explains a copy the check drops as `read` does. A `delete` of one + doesn't take disk's copy first, so its unlink still refuses to remove a + change on disk the workspace hasn't seen. Each sync without a path lists them under `skipped` ("disk could not be checked: ignored by the mount"). It also lists, under `incomplete`, the ignored entry that hides them: the file itself for a diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 1ff54dbff..83ef05cca 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -2239,11 +2239,12 @@ export class WorkspaceModule implements Module { * workspace deletion or a file absent from this branch. With * `checkIgnored`, so is a path it holds that the mount ignores: neither the * watcher nor a sync without a path keeps such a copy current, so a caller - * that hands its content on (read, edit, readBinary) checks it by its own - * path under the three-way rule, which never gives up a workspace change - * (a sync that names the path takes disk's copy outright), and says so - * when the check couldn't confirm it (diskCheckView). A delete doesn't: it - * matches no content, so + * that hands its content on (read, edit, readBinary, read_image) checks it + * by its own path under the three-way rule, which never gives up a + * workspace change (a sync that names the path takes disk's copy + * outright), and says so when the check couldn't confirm it + * (diskCheckView), a check that decided nothing included. A delete of a + * held copy doesn't: it matches no content, so * taking in a disk change first would let its unlink remove a change the * workspace never saw. The caller holds the mount's turn. */ @@ -2251,10 +2252,16 @@ export class WorkspaceModule implements Module { mount: MountState, relativePath: string, opts: { checkIgnored?: boolean } = {}, ): Promise { const store = this.getStore(); - if (store.treeGet(mount.treeStateId, relativePath) - && !(opts.checkIgnored && coveredByIgnore(relativePath, mount.config.ignore ?? []))) return undefined; + const held = store.treeGet(mount.treeStateId, relativePath) !== null; + if (held && !(opts.checkIgnored && coveredByIgnore(relativePath, mount.config.ignore ?? []))) return undefined; const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }); - return pass.reports.get(relativePath); + const report = pass.reports.get(relativePath); + // A pass that decided nothing (the selected branch kept changing) left a + // held copy unchecked, which the caller must not present as confirmed. + if (report === undefined && held) { + return { path: relativePath, state: 'unverified', note: pass.incomplete[0]?.reason ?? 'disk was not checked' }; + } + return report; } /** Why the workspace holds no entry at a path, when disk explains it. */ @@ -2386,41 +2393,47 @@ export class WorkspaceModule implements Module { } const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE; - let treeError: WorkspaceImageReadError | null = null; + // Set by the tree read below, inside a closure, so not narrowed to null. + let treeError = null as WorkspaceImageReadError | null; - // A held image the mount ignores is checked against disk first, as read - // checks a held file there; a sync never stores an image, so it's one an - // agent wrote. Any other image is read from the tree, or from disk. + // A held copy the mount ignores is checked against disk first, as read + // checks one, and the tree is read in the same turn (a sync never stores + // an image, so a held image there is one an agent wrote). Any other path + // is read from the tree, or from disk, as before. let checked: DiskCheck = {}; - if (this.getStore().treeGet(mount.treeStateId, relativePath) && coveredByIgnore(relativePath, mount.config.ignore ?? [])) { - checked = diskCheckView(await this.withMount(mount, () => this.ensureSynced(mount, relativePath, { checkIgnored: true }))); - } - try { - const image = this.tryReadImageFromTree(mount, relativePath, input.path, maxSize); - if (image) { - const check = checked.diskCheck; - return { - success: true, - data: [ - { - type: 'text', - text: `Path: ${input.path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}` + - (check ? `\nDisk check: ${check.state}${check.note ? ` (${check.note})` : ''}` : ''), - }, - { - type: 'image', - data: image.bytes.toString('base64'), - mimeType: image.mimeType, - }, - ], - }; - } - } catch (err) { - if (err instanceof WorkspaceImageReadError) { + const fromTree = (): ReturnType => { + try { + return this.tryReadImageFromTree(mount, relativePath, input.path, maxSize); + } catch (err) { + if (!(err instanceof WorkspaceImageReadError)) throw err; treeError = err; - } else { - throw err; + return null; } + }; + const image = this.getStore().treeGet(mount.treeStateId, relativePath) && coveredByIgnore(relativePath, mount.config.ignore ?? []) + ? await this.withMount(mount, async () => { + checked = diskCheckView(await this.ensureSynced(mount, relativePath, { checkIgnored: true })); + return fromTree(); + }) + : fromTree(); + if (image) { + const check = checked.diskCheck; + const detail = check?.note ?? check?.conflict?.kind; + return { + success: true, + data: [ + { + type: 'text', + text: `Path: ${input.path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}` + + (check ? `\nDisk check: ${check.state}${detail ? ` (${detail})` : ''}` : ''), + }, + { + type: 'image', + data: image.bytes.toString('base64'), + mimeType: image.mimeType, + }, + ], + }; } try { diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index d832d88b7..4de7b85af 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -1440,28 +1440,42 @@ test('a read-only mount can stop tracking an old nested copy, and refuses every // path in the nested mount, and an outer link into it), reading no root. symlinkSync(join(outer, 'inner', 'x.txt'), join(outer, 'link')); const native = fs.realpathSync.native; - const { lstatSync, readlinkSync } = fs; - const { realpath, lstat } = fsPromises; + const { lstatSync, readlinkSync, realpathSync } = fs; + const { realpath, lstat, readlink, stat } = fsPromises; const reads: string[] = []; const counted = unknown>(name: string, f: F): F => ((...args: Parameters) => { reads.push(name); return f(...args); }) as F; try { fs.realpathSync.native = counted('realpathSync.native', native); - Object.assign(fs, { lstatSync: counted('lstatSync', lstatSync), readlinkSync: counted('readlinkSync', readlinkSync) }); - Object.assign(fsPromises, { realpath: counted('realpath', realpath), lstat: counted('lstat', lstat) }); + const plain = counted('realpathSync', realpathSync); + Object.assign(plain, { native: fs.realpathSync.native }); + Object.assign(fs, { lstatSync: counted('lstatSync', lstatSync), readlinkSync: counted('readlinkSync', readlinkSync), realpathSync: plain }); + Object.assign(fsPromises, { realpath: counted('realpath', realpath), lstat: counted('lstat', lstat), readlink: counted('readlink', readlink), stat: counted('stat', stat) }); syncBuiltinESMExports(); - for (const path of ['outer/inner/x.txt', 'outer/inner/other.txt', 'outer/link', 'outer/nothing.txt']) { - const unheld = await call('delete', { path }); - assert.equal(unheld.error, 'Mount "outer" is read-only', path); - } + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + try { + // A case variant of the nested root, where classifying it would read disk. + Object.defineProperty(process, 'platform', { ...platform, value: 'darwin' }); + for (const path of ['outer/inner/x.txt', 'outer/inner/other.txt', 'outer/link', 'outer/nothing.txt', 'outer/INNER/x.txt']) { + const unheld = await call('delete', { path }); + assert.equal(unheld.error, 'Mount "outer" is read-only', path); + } + } finally { Object.defineProperty(process, 'platform', platform); } } finally { fs.realpathSync.native = native; - Object.assign(fs, { lstatSync, readlinkSync }); - Object.assign(fsPromises, { realpath, lstat }); + Object.assign(fs, { lstatSync, readlinkSync, realpathSync }); + Object.assign(fsPromises, { realpath, lstat, readlink, stat }); syncBuiltinESMExports(); } assert.deepEqual(reads, [], 'the refusal reads nothing from disk'); assert.equal(readFileSync(join(outer, 'inner', 'x.txt'), 'utf8'), 'inner bytes'); + // A held outer link into the nested mount is an old nested copy by alias. + symlinkSync(join(outer, 'inner', 'x.txt'), join(outer, 'held-link')); + store.treeSet('workspace/outer/tree', 'held-link', { blobHash: store.storeBlob(Buffer.from('inner bytes'), 'text/plain'), size: 11, mode: 0o644 }); + const link = await call('delete', { path: 'outer/held-link' }); + assert.equal(link.success, true, JSON.stringify(link)); + assert.equal(storeHolds(workspace, 'outer', 'held-link'), false); + assert.equal(fs.lstatSync(join(outer, 'held-link')).isSymbolicLink(), true); }); test('a read or an edit of a tracked file the mount ignores checks it against disk first, as nothing else keeps it current', async (t) => { @@ -1508,6 +1522,8 @@ test("a read of a tracked file the mount ignores says when disk couldn't be chec const data = read.data as { content: string; diskCheck?: { state: string; note?: string } }; assert.match(data.content, /\tv1/); assert.deepEqual(data.diskCheck, { state: 'unverified', note: 'a symlink, which this mount does not follow' }); + const paged = await call('read', { path: 'outer/node_modules/pkg.js', limitChars: 1 }); + assert.deepEqual((paged.data as { diskCheck?: unknown }).diskCheck, { state: 'unverified', note: 'a symlink, which this mount does not follow' }); const bytes = await workspace.readBinary('outer/node_modules/pkg.js'); assert.ok('data' in bytes && bytes.data.toString() === 'v1', JSON.stringify(bytes)); assert.deepEqual('data' in bytes ? bytes.diskCheck : null, { state: 'unverified', note: 'a symlink, which this mount does not follow' }); @@ -1517,6 +1533,37 @@ test("a read of a tracked file the mount ignores says when disk couldn't be chec // A copy that agrees with disk says nothing more. const plain = await call('read', { path: 'outer/other.js' }); assert.equal((plain.data as { diskCheck?: unknown }).diskCheck, undefined); + // A check that decided nothing (the selected branch kept changing) says so. + const internals = workspace as unknown as { passUnlocked: (...args: unknown[]) => Promise }; + const pass = internals.passUnlocked; + internals.passUnlocked = async () => ({ ops: [], discarded: [], newConflicts: [], reports: new Map(), passedOver: new Map(), dirs: [], + incomplete: [{ path: '', reason: 'the selected branch kept changing while the scan ran' }] }); + try { + const undecided = await call('read', { path: 'outer/node_modules/pkg.js' }); + assert.deepEqual((undecided.data as { diskCheck?: unknown }).diskCheck, { state: 'unverified', note: 'the selected branch kept changing while the scan ran' }); + } finally { internals.passUnlocked = pass; } +}); + +test("a read of an ignored copy keeps the workspace's own unwritten change, says nothing while disk agrees, and names a conflict", async (t) => { + const { root, stops } = scratch(t, 'af-nested-pass2-ignored-draft-'); + const outer = join(root, 'outer'); + mkdirSync(join(outer, 'node_modules'), { recursive: true }); + writeFileSync(join(outer, 'node_modules', 'pkg.js'), 'v1'); + const { workspace } = await started(stops, join(root, 'store'), [ + { name: 'outer', path: outer, mode: 'read-write', watch: 'never', ignore: ['node_modules'] }, + ]); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + assert.equal((await call('sync', { path: 'outer/node_modules/pkg.js' })).success, true); + assert.equal((await call('edit', { path: 'outer/node_modules/pkg.js', oldString: 'v1', newString: 'mine' })).success, true); + const draft = await call('read', { path: 'outer/node_modules/pkg.js' }); + assert.match((draft.data as { content: string }).content, /\tmine/); + assert.equal((draft.data as { diskCheck?: unknown }).diskCheck, undefined, "the workspace's own change isn't a disk question"); + writeFileSync(join(outer, 'node_modules', 'pkg.js'), 'v2'); + const both = await call('read', { path: 'outer/node_modules/pkg.js' }); + assert.match((both.data as { content: string }).content, /\tmine/, 'the workspace change is kept'); + const check = (both.data as { diskCheck?: { state: string; conflict?: unknown } }).diskCheck; + assert.equal(check?.state, 'conflict', JSON.stringify(both.data)); + assert.ok(check?.conflict, 'with what the conflict is'); }); test("readBinary of a tracked file the mount ignores, gone binary on disk, says why the workspace no longer holds it", async (t) => { @@ -1544,6 +1591,11 @@ test("read_image of a held image the mount ignores says when disk couldn't be ch ]); // A sync never stores an image, so a held one is an agent's own write. assert.equal((await workspace.writeBinary('outer/assets/a.png', ONE_PIXEL_PNG, 'image/png')).success, true); + assert.equal((await workspace.handleToolCall({ id: 'm', name: 'materialize', input: { mount: 'outer' } })).success, true); + const agreed = await workspace.handleToolCall({ id: 'img0', name: 'read_image', input: { path: 'outer/assets/a.png' } }); + const agreedText = (agreed.data as Array<{ type: string; text?: string }>).find((block) => block.type === 'text')!.text!; + assert.doesNotMatch(agreedText, /Disk check/, 'a copy disk agrees with says nothing more'); + rmSync(join(outer, 'assets', 'a.png')); symlinkSync(join(outer, 'other.png'), join(outer, 'assets', 'a.png')); const image = await workspace.handleToolCall({ id: 'img', name: 'read_image', input: { path: 'outer/assets/a.png' } }); assert.equal(image.success, true, JSON.stringify(image)); From 1ee8dc54d0b5ebbb744f68f83099f220a066f7ff Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 07:09:04 -0400 Subject: [PATCH 25/26] fix(workspace): only a pass that gave up reads as unchecked; the conflict line says when disk moved on Answers a review of e5081686. - e5081686 read "the pass left no report for a held copy" as "the pass gave up". But a pass that decided leaves no report exactly where it agreed with an absence: a held copy disk no longer has, which the workspace never changed, is removed, and a read should then find it gone. e5081686 told the reader disk "could not be checked" instead. ensureSynced now reports unverified only when the pass gave up, which it says in its incomplete list (the selected branch kept changing). That applies to a path the workspace doesn't hold too, so a read there no longer turns an unchecked disk into "File not found". The fallback note that could never be reached is gone. - read_image's "Disk check" line adds "disk changed since" to a conflict's kind when disk moved on after the conflict was recorded, as the check reports. - Tests: - a held ignored copy disk deleted, unchanged in the workspace: the read answers "File not found" and the copy is gone; - a check that gave up, through readBinary as well as read, returns the workspace copy with diskCheck unverified; for a path the workspace doesn't hold, the read says disk could not be checked, with the reason; - read_image names a conflict's kind, adds "disk changed since" once disk moves on, names a check that gave up, and when the tree refuses a held copy as an image and disk has none, answers with the tree's refusal; - the read-only test restores the original realpathSync before its .native, so the counting wrapper no longer stays on it for later tests. --- src/modules/workspace/index.ts | 14 ++-- test/workspace-nested-mount-ignore.test.ts | 75 +++++++++++++++++++++- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 83ef05cca..35b15e1e0 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -2256,10 +2256,12 @@ export class WorkspaceModule implements Module { if (held && !(opts.checkIgnored && coveredByIgnore(relativePath, mount.config.ignore ?? []))) return undefined; const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }); const report = pass.reports.get(relativePath); - // A pass that decided nothing (the selected branch kept changing) left a - // held copy unchecked, which the caller must not present as confirmed. - if (report === undefined && held) { - return { path: relativePath, state: 'unverified', note: pass.incomplete[0]?.reason ?? 'disk was not checked' }; + // A pass that gave up (the selected branch kept changing) decided nothing + // and says why; one that decided leaves no report only where it agreed + // with an absence (a copy disk no longer has, removed). The caller must + // not present the first as a confirmed copy or a confirmed absence. + if (report === undefined && pass.incomplete.length > 0) { + return { path: relativePath, state: 'unverified', note: pass.incomplete[0].reason }; } return report; } @@ -2418,7 +2420,9 @@ export class WorkspaceModule implements Module { : fromTree(); if (image) { const check = checked.diskCheck; - const detail = check?.note ?? check?.conflict?.kind; + const conflict = check?.conflict; + const detail = check?.note + ?? (conflict ? `${conflict.kind}${conflict.diskChangedSinceRecorded ? '; disk changed since' : ''}` : undefined); return { success: true, data: [ diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index 4de7b85af..b4172d21c 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -1462,8 +1462,8 @@ test('a read-only mount can stop tracking an old nested copy, and refuses every } } finally { Object.defineProperty(process, 'platform', platform); } } finally { - fs.realpathSync.native = native; Object.assign(fs, { lstatSync, readlinkSync, realpathSync }); + fs.realpathSync.native = native; Object.assign(fsPromises, { realpath, lstat, readlink, stat }); syncBuiltinESMExports(); } @@ -1538,12 +1538,38 @@ test("a read of a tracked file the mount ignores says when disk couldn't be chec const pass = internals.passUnlocked; internals.passUnlocked = async () => ({ ops: [], discarded: [], newConflicts: [], reports: new Map(), passedOver: new Map(), dirs: [], incomplete: [{ path: '', reason: 'the selected branch kept changing while the scan ran' }] }); + const gaveUp = { state: 'unverified', note: 'the selected branch kept changing while the scan ran' }; try { const undecided = await call('read', { path: 'outer/node_modules/pkg.js' }); - assert.deepEqual((undecided.data as { diskCheck?: unknown }).diskCheck, { state: 'unverified', note: 'the selected branch kept changing while the scan ran' }); + assert.match((undecided.data as { content: string }).content, /\tv2/, 'the workspace copy is returned'); + assert.deepEqual((undecided.data as { diskCheck?: unknown }).diskCheck, gaveUp); + const undecidedBytes = await workspace.readBinary('outer/node_modules/pkg.js'); + assert.ok('data' in undecidedBytes && undecidedBytes.data.toString() === 'v2', JSON.stringify(undecidedBytes)); + assert.deepEqual('data' in undecidedBytes ? undecidedBytes.diskCheck : null, gaveUp); + // A path the workspace doesn't hold isn't reported absent when disk went unchecked. + const unheld = await call('read', { path: 'outer/node_modules/other.js' }); + assert.equal(unheld.success, false); + assert.match(String(unheld.error), /not in the workspace, and disk could not be checked \(the selected branch kept changing while the scan ran\)/); } finally { internals.passUnlocked = pass; } }); +test('a read of an ignored copy that disk no longer has, and the workspace never changed, finds it gone', async (t) => { + const { root, stops } = scratch(t, 'af-nested-pass2-ignored-gone-'); + const outer = join(root, 'outer'); + mkdirSync(join(outer, 'node_modules'), { recursive: true }); + writeFileSync(join(outer, 'node_modules', 'pkg.js'), 'v1'); + const { workspace } = await started(stops, join(root, 'store'), [ + { name: 'outer', path: outer, mode: 'read-write', watch: 'never', ignore: ['node_modules'] }, + ]); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + assert.equal((await call('sync', { path: 'outer/node_modules/pkg.js' })).success, true); + rmSync(join(outer, 'node_modules', 'pkg.js')); + const read = await call('read', { path: 'outer/node_modules/pkg.js' }); + assert.equal(read.success, false); + assert.equal(read.error, 'File not found: outer/node_modules/pkg.js', 'a decided absence, not an unchecked one'); + assert.equal(storeHolds(workspace, 'outer', 'node_modules/pkg.js'), false); +}); + test("a read of an ignored copy keeps the workspace's own unwritten change, says nothing while disk agrees, and names a conflict", async (t) => { const { root, stops } = scratch(t, 'af-nested-pass2-ignored-draft-'); const outer = join(root, 'outer'); @@ -1603,6 +1629,51 @@ test("read_image of a held image the mount ignores says when disk couldn't be ch assert.match(text, /\nDisk check: unverified \(a symlink, which this mount does not follow\)$/); }); +test("read_image names a conflict's kind and a check that decided nothing, and keeps the tree's refusal when disk has no copy", async (t) => { + const { root, stops } = scratch(t, 'af-nested-pass2-ignored-image-states-'); + const outer = join(root, 'outer'); + mkdirSync(join(outer, 'assets'), { recursive: true }); + const { workspace } = await started(stops, join(root, 'store'), [ + { name: 'outer', path: outer, mode: 'read-write', watch: 'never', ignore: ['assets'] }, + ]); + const call = (name: string, input: Record) => workspace.handleToolCall({ id: name, name, input }); + const header = (result: { data?: unknown }) => (result.data as Array<{ type: string; text?: string }>).find((block) => block.type === 'text')!.text!; + // The workspace changes an image disk agreed with, and disk changes it too. + const gif = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64'); + assert.equal((await workspace.writeBinary('outer/assets/a.png', ONE_PIXEL_PNG, 'image/png')).success, true); + assert.equal((await call('materialize', { mount: 'outer' })).success, true); + assert.equal((await workspace.writeBinary('outer/assets/a.png', gif, 'image/gif')).success, true); + writeFileSync(join(outer, 'assets', 'a.png'), Buffer.concat([ONE_PIXEL_PNG, Buffer.from('changed')])); + const conflicted = await call('read_image', { path: 'outer/assets/a.png' }); + assert.equal(conflicted.success, true, JSON.stringify(conflicted)); + const bytes = await workspace.readBinary('outer/assets/a.png'); + const kind = 'data' in bytes ? bytes.diskCheck?.conflict?.kind : undefined; + assert.ok(kind, JSON.stringify(bytes)); + assert.match(header(conflicted), new RegExp(`\\nDisk check: conflict \\(${kind}`)); + // Disk moves on again after the conflict was recorded: the line says so. + writeFileSync(join(outer, 'assets', 'a.png'), Buffer.concat([ONE_PIXEL_PNG, Buffer.from('changed again')])); + const movedOn = await call('read_image', { path: 'outer/assets/a.png' }); + assert.match(header(movedOn), new RegExp(`\\nDisk check: conflict \\(${kind}; disk changed since\\)$`), header(movedOn)); + // A check that decided nothing says so. + const internals = workspace as unknown as { passUnlocked: (...args: unknown[]) => Promise }; + const pass = internals.passUnlocked; + internals.passUnlocked = async () => ({ ops: [], discarded: [], newConflicts: [], reports: new Map(), passedOver: new Map(), dirs: [], + incomplete: [{ path: '', reason: 'the selected branch kept changing while the scan ran' }] }); + try { + const undecided = await call('read_image', { path: 'outer/assets/a.png' }); + assert.match(header(undecided), /\nDisk check: unverified \(the selected branch kept changing while the scan ran\)$/); + } finally { internals.passUnlocked = pass; } + // A held copy the tree refuses as an image, which disk no longer has: the tree's refusal is the answer. + writeFileSync(join(outer, 'assets', 't.png'), 'text'); + assert.equal((await call('sync', { path: 'outer/assets/t.png' })).success, true); + assert.equal((await call('edit', { path: 'outer/assets/t.png', oldString: 'text', newString: 'texts' })).success, true); + rmSync(join(outer, 'assets', 't.png')); + const refused = await call('read_image', { path: 'outer/assets/t.png' }); + assert.equal(refused.success, false); + assert.doesNotMatch(String(refused.error), /not found/i, String(refused.error)); + assert.match(String(refused.error), /outer\/assets\/t\.png/); +}); + test("a delete of a tracked file the mount ignores doesn't take in a disk change first, so it never removes one the workspace hasn't seen", async (t) => { const { root, stops } = scratch(t, 'af-nested-pass2-ignored-delete-'); const outer = join(root, 'outer'); From 577258f4164eae91c266e71690232765619921fd Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 23:22:29 -0400 Subject: [PATCH 26/26] test(workspace): pin the contained write's and unlink's nested checks; say what derivedKey's second half guards The maintainer's re-review of 1ee8dc54 found writeContained's nested check (effects.ts, insideNested on the file the write actually opened) pinned by no test: with it removed the suite stays green, since materialize now refuses a write through a file link into a nested mount before anything is pushed. The new test calls writeContained directly, as the located-read test calls readPath: a file link outer/flink.txt -> outer/a/b/x.txt with the inner mount in the view's nested list is refused with the nested-mount reason, and the inner file keeps its bytes. A twin pins unlinkContained's nested check for a final link: refused, the link and the inner file left as they were. derivedKey's `contained(view.root.real, candidate.real)` is implied today, since nestedRootsOf admits only a mount whose resolved root lies under this one; the comment says so and why it stays (no key climbing out of the mount can be derived if nesting is ever decided differently). The test that was named for it is refused by the written-path check first and never reaches the line, so it is renamed for what it pins and says so. Co-Authored-By: Claude Opus 5.5 --- src/modules/workspace/index.ts | 5 ++- test/workspace-nested-mount-ignore.test.ts | 42 ++++++++++++++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 35b15e1e0..92cd3c6eb 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -835,7 +835,10 @@ export class WorkspaceModule implements Module { const rest = nestedSuffix(inner, candidate); if (rest === null) continue; // Policy folding can justify a refusal, never the choice of another - // stored key to remove. A derived key needs exact containment. + // stored key to remove. A derived key needs exact containment. The + // second half is implied today, since nestedRootsOf admits only a mount + // whose resolved root lies under this one; it stays so that no key that + // climbs out of the mount (`../…`) can be derived if that ever changes. const derivedKey = contained(inner.real, candidate.real) && contained(view.root.real, candidate.real) ? relative(view.root.real, candidate.real).split(sep).join('/') : undefined; return { name: inner.name, at: inner.at, rest, derivedKey }; diff --git a/test/workspace-nested-mount-ignore.test.ts b/test/workspace-nested-mount-ignore.test.ts index b4172d21c..819706778 100644 --- a/test/workspace-nested-mount-ignore.test.ts +++ b/test/workspace-nested-mount-ignore.test.ts @@ -23,7 +23,7 @@ import test, { type TestContext } from 'node:test'; import assert from 'node:assert/strict'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import fsPromises from 'node:fs/promises'; import fs from 'node:fs'; @@ -32,6 +32,7 @@ import { dirname, join, parse, sep, win32 } from 'node:path'; import { JsStore } from '@animalabs/chronicle'; import { WorkspaceModule } from '../src/modules/workspace/index.js'; import { nestedSuffix, readPath } from '../src/modules/workspace/observe.js'; +import { EffectFailed, unlinkContained, writeContained } from '../src/modules/workspace/effects.js'; import type { MountConfig } from '../src/modules/workspace/types.js'; import type { ModuleContext } from '../src/types/module.js'; import { MountWatcher, type FsChange } from '../src/modules/workspace/watcher.js'; @@ -618,6 +619,41 @@ test("the located read refuses a file it opens through a link into a nested moun { kind: 'unobserved', reason: 'inside mount "inner", which is nested in this one' }); }); +test("the contained write refuses a file it opens through a link into a nested mount, as materialize's check does", async (t) => { + // materialize refuses a write through a file link into a nested mount + // before anything is pushed. The write checks the file it actually opened + // again, so a link that appears between that check and the write reaches + // nothing either. Called directly here, as a push calls it. + const { root } = scratch(t, 'af-nested-mount-contained-write-'); + const outerDir = join(root, 'outer'); + const innerDir = join(outerDir, 'a', 'b'); + mkdirSync(innerDir, { recursive: true }); + writeFileSync(join(innerDir, 'x.txt'), "the inner mount's"); + symlinkSync(join(innerDir, 'x.txt'), join(outerDir, 'flink.txt')); + const view = { root: outerDir, followSymlinks: true, maxFileSize: 1 << 20, ignore: [], nested: [{ name: 'inner', real: realpathSync(innerDir) }] }; + const err = await writeContained(view, realpathSync(outerDir), 'flink.txt', Buffer.from('outer wrote'), { kind: 'any' }).then(() => null, (e) => e); + assert.ok(err instanceof EffectFailed, String(err)); + assert.equal(err.message, 'inside mount "inner", which is nested in this one'); + assert.equal(readFileSync(join(innerDir, 'x.txt'), 'utf8'), "the inner mount's", "the inner mount's file is as it was"); +}); + +test("the contained unlink refuses a final link into a nested mount, and leaves the link and the file", async (t) => { + // A delete routed to disk removes a link itself, not what it names, and + // only a link whose target lies in this mount and in no mount nested in it. + const { root } = scratch(t, 'af-nested-mount-contained-unlink-'); + const outerDir = join(root, 'outer'); + const innerDir = join(outerDir, 'a', 'b'); + mkdirSync(innerDir, { recursive: true }); + writeFileSync(join(innerDir, 'x.txt'), "the inner mount's"); + symlinkSync(join(innerDir, 'x.txt'), join(outerDir, 'flink.txt')); + const view = { root: outerDir, followSymlinks: true, maxFileSize: 1 << 20, ignore: [], nested: [{ name: 'inner', real: realpathSync(innerDir) }] }; + const err = await unlinkContained(view, realpathSync(outerDir), 'flink.txt', { kind: 'any' }).then(() => null, (e) => e); + assert.ok(err instanceof EffectFailed, String(err)); + assert.equal(err.message, 'inside mount "inner", which is nested in this one'); + assert.ok(lstatSync(join(outerDir, 'flink.txt')).isSymbolicLink(), 'the link is still there'); + assert.equal(readFileSync(join(innerDir, 'x.txt'), 'utf8'), "the inner mount's", "the inner mount's file is as it was"); +}); + test("the disk readers reach nothing of a nested mount through an alias, and the raw reader keeps the mount's boundary", async (t) => { const { root, stops } = scratch(t, 'af-nested-mount-readers-'); const outerDir = join(root, 'outer'); @@ -1251,7 +1287,9 @@ test('a blocked physical push still reports its completed nested-deletion cleanu assert.equal(readFileSync(join(r.inner, 'x.txt'), 'utf8'), 'inner bytes'); }); -test('a non-ingested nested alias cannot derive an outer-owned key', async (t) => { +test('a delete through a nested mount\'s link back into the outer mount keeps the outer-owned copy', async (t) => { + // The written-path check (nestedMountAt) refuses this first, so it never + // reaches the derived key's containment, which nesting implies today. const r = await secondPassRig(t, false, 4); assert.equal((await r.call('write', { path: 'outer/owned.txt', content: 'old' })).success, true); // Too large for observation to import under the alias. The old fallback