diff --git a/changelog.d/nested-mount-ignore.fixed.md b/changelog.d/nested-mount-ignore.fixed.md new file mode 100644 index 000000000..bbc18c274 --- /dev/null +++ b/changelog.d/nested-mount-ignore.fixed.md @@ -0,0 +1,107 @@ +- 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. + - 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. 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, 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 + 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 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 + 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. + 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 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 + `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 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 + 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. `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, 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 + 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. 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 + `path.resolve` reads it, by nesting and by every operation alike. Before, + 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. + - 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) diff --git a/src/modules/workspace/effects.ts b/src/modules/workspace/effects.ts index 5e280fe5c..4b5fc7246 100644 --- a/src/modules/workspace/effects.ts +++ b/src/modules/workspace/effects.ts @@ -53,6 +53,7 @@ import { errno, checkDir, checkRoot, + insideNested, nameOf, parentOf, } from './observe.js'; @@ -223,6 +224,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 (!view.followSymlinks) { @@ -289,6 +292,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); file = through; diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index ab7c5692e..81f30f583 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, lstatSync, readlinkSync, 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, 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'; @@ -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 { coveredByIgnore, filesystemTrustsCtime, locateRoot } 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, @@ -111,7 +111,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' @@ -207,6 +208,102 @@ function matchesPartialPrefix(buffer: Buffer, prefix: Buffer): boolean { return buffer.length > 0 && prefix.subarray(0, buffer.length).equals(buffer); } +/** + * 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. + * + * 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 resolveWorkspacePath(path: string): ResolvedPath { + const absolute = resolve(path); + try { + 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) { + const part = queue.shift()!; + 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. + } + let target: string | null = null; + let missingPart = false; + if (links < 40) { + try { + if (lstatSync(next).isSymbolicLink()) target = readlinkSync(next); + } catch (error) { + missingPart = errnoOf(error) === 'ENOENT' || errnoOf(error) === 'ENOTDIR'; + } + } + if (target === null) { + if (missingPart && missingFrom === undefined) missingFrom = pathComponents(current).length; + 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; + missingFrom = undefined; + queue.unshift(...parts(target.slice(targetRoot.length))); + } else { + queue.unshift(...parts(target)); + } + } + return { real: current, ...(missingFrom !== undefined ? { missingFrom } : {}) }; +} + +/** 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 { if (candidate === root) return true; const normalizedRoot = root.endsWith(sep) ? root : root + sep; @@ -632,6 +729,8 @@ export class WorkspaceModule implements Module { private stopped = false; 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; @@ -651,32 +750,140 @@ 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. + // Nesting checks keep each mount's disk operations out of nested mounts + // (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) { if (outer === inner) continue; - 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 - outer.ignore = outer.ignore ?? []; - const pattern = rel + '/**'; - if (!outer.ignore.includes(pattern) && !outer.ignore.includes(rel)) { - outer.ignore.push(rel); - console.warn( - `[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` + - `(${rel}/) — auto-ignoring to prevent overlap`, - ); - } + 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); + // 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 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, 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 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) => 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 }; + } + + /** + * 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 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. + */ + 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); + 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. 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 }; + } + return 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) => nestedSuffix(n, { real }) !== null); + if (!inner) return null; + 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}"`; + } + + /** + * 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 }> { + return this.nestedSnapshot(mount).ats; + } + + /** The mount nested in `mount` that a mount-relative path lies in, if any. */ + private nestedMountAt( + mount: MountState, + relativePath: string, + ats: Array<{ name: string; at: string }> = this.nestedAtsOf(mount), + ): { name: string; at: string } | undefined { + return ats.filter((n) => relativePath === n.at || relativePath.startsWith(n.at + '/')) + .sort((a, b) => b.at.length - a.at.length)[0]; + } + /** * Inject the Chronicle store. Must be called after framework creation. * @@ -765,6 +972,10 @@ 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, 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: { @@ -773,6 +984,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, @@ -1124,9 +1336,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, 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: { type: 'object' as const, properties: { @@ -1197,7 +1410,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: { @@ -1222,7 +1436,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 ' @@ -1260,8 +1475,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; } @@ -1448,6 +1666,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 }; } @@ -1458,6 +1678,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 }; // Checked inside the mount's turn, after anything the write waited on: // commitToolChange sets the tree entry before it first yields. if (options.branch !== undefined && store.currentBranch().name !== options.branch) { @@ -1495,7 +1719,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; @@ -1506,12 +1730,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}` }; + 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 }; + return { data: blob, ...diskCheckView(report) }; }); } @@ -1527,8 +1751,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, @@ -1542,27 +1768,44 @@ 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. + let tooLargeSize: number | undefined; + 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) { + tooLargeSize = fileStat.size; + throw new Error('too large'); + } + }, { follow: true }); + } catch (error) { + 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}` }; + } + 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(); } } /** * 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) { @@ -1573,10 +1816,26 @@ 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); - if (!isContainedPath(mount.config.path, resolved)) { + const root = resolve(mount.config.path); + const resolved = resolve(root, written); + 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 + // 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). 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}/: ` + + `use "${rest ? `${inner.name}/${rest}` : inner.name}"`, + ); + } return { mount, relativePath }; } @@ -1640,29 +1899,28 @@ 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 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, 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>; @@ -1675,20 +1933,38 @@ 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'); } 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)); } + 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)); @@ -1716,14 +1992,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'); - } - inspect?.(fileStat); - if (!follow && !useNoFollow) { let postOpenInfo: Awaited>; try { @@ -1751,8 +2019,12 @@ 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'); } let pathStat: Stats; @@ -1768,6 +2040,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) { @@ -1853,7 +2126,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': @@ -2001,14 +2275,34 @@ 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. 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, 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. */ - 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)) 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 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; } /** Why the workspace holds no entry at a path, when disk explains it. */ @@ -2050,19 +2344,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); @@ -2083,6 +2379,7 @@ export class WorkspaceModule implements Module { offsetChars: start, nextOffsetChars: end < content.length ? end : null, content: content.slice(start, end), + ...checked, }, }; } @@ -2115,6 +2412,7 @@ export class WorkspaceModule implements Module { ? { note: `Truncated at ${effectiveLimit} lines (file has ${lines.length}). Use offset/limit to read more.` } : {}), content: formatted, + ...checked, }, }; } @@ -2136,32 +2434,49 @@ export class WorkspaceModule implements Module { } const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE; - let treeError: WorkspaceImageReadError | null = null; - - try { - const image = this.tryReadImageFromTree(mount, relativePath, input.path, maxSize); - if (image) { - return { - success: true, - data: [ - { - type: 'text', - text: `Path: ${input.path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`, - }, - { - type: 'image', - data: image.bytes.toString('base64'), - mimeType: image.mimeType, - }, - ], - }; - } - } catch (err) { - if (err instanceof WorkspaceImageReadError) { + // Set by the tree read below, inside a closure, so not narrowed to null. + let treeError = null as WorkspaceImageReadError | null; + + // 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 = {}; + 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 conflict = check?.conflict; + const detail = check?.note + ?? (conflict ? `${conflict.kind}${conflict.diskChangedSinceRecorded ? '; disk changed since' : ''}` : undefined); + 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 { @@ -2193,6 +2508,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 }; } @@ -2204,11 +2521,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, @@ -2229,14 +2553,20 @@ 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 }; } 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. + 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) { @@ -2290,21 +2620,109 @@ export class WorkspaceModule implements Module { data: { path: input.path, size: newBuffer.byteLength, + ...diskCheckView(report), }, }; }); } - private async handleDelete(input: DeleteInput): Promise { - const { mount, relativePath } = this.parsePath(input.path); - if (mount.config.mode === 'read-only') { - return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true }; - } + /** 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), 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.'; + } + + /** 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, 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 { + // 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) => 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 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 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, + 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' }); + // 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. + 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 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 + // have come to lie in a nested mount while the delete waited. + 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 }; @@ -2315,7 +2733,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, }; } @@ -2696,21 +3114,45 @@ 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 }> => { + 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, 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 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 = 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, pushed: await this.pushUnlocked(mount, paths, { force: input.force, named: explicit !== null, applyDeletions: input.applyDeletions, branchId, }), }; }); + 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); @@ -3136,6 +3578,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/src/modules/workspace/observe.ts b/src/modules/workspace/observe.ts index 95776d90d..e06189061 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, join, parse, relative, sep } from 'node:path'; import { sameRootIdentity, type Fingerprint, type RootIdentity } from './disk-agreement.js'; export interface MountView { @@ -56,6 +56,12 @@ export interface MountView { /** Text above this size is not ingested. */ maxFileSize: number; ignore: string[]; + /** + * 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; } export type StatFingerprint = Omit; @@ -102,6 +108,51 @@ 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; +} + +/** 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: '' for the root + * itself, null when the candidate isn't inside it (outside it, or on another + * 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); + 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++) { + 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) => 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'; export const ROOT_REPLACED = 'the mount root is not the directory disk last agreed with (an unmounted drive, or a replaced directory): ' + @@ -235,6 +286,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) { @@ -449,10 +503,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'; @@ -468,7 +524,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; @@ -494,6 +550,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' }; } @@ -545,6 +603,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' }; @@ -695,7 +755,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); @@ -705,15 +766,26 @@ 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 (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; @@ -796,7 +868,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/src/modules/workspace/types.ts b/src/modules/workspace/types.ts index 3517a8b96..ca65c7285 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/src/modules/workspace/watcher.ts b/src/modules/workspace/watcher.ts index f853b0365..0fff5b329 100644 --- a/src/modules/workspace/watcher.ts +++ b/src/modules/workspace/watcher.ts @@ -7,8 +7,9 @@ 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'; export type FsOp = 'created' | 'modified' | 'deleted'; @@ -60,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; @@ -69,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; @@ -95,16 +99,26 @@ 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))); } } - const ignored = this.config.ignore ?? []; - - this.watcher = watch(this.config.path, { - ignored: ignored.length > 0 ? ignored : undefined, + // 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.root, { + 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, @@ -146,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 }); } }); @@ -343,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 }; } @@ -366,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('/'); } @@ -376,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 new file mode 100644 index 000000000..819706778 --- /dev/null +++ b/test/workspace-nested-mount-ignore.test.ts @@ -0,0 +1,1738 @@ +/** + * 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 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. + * + * #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. + */ + +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +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'; +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 { 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'; + +/** 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'); + 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 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/**']); + 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 }); + mkdirSync(join(outerDir, 'a', 'c')); + const outer: MountConfig = { + name: 'outer', path: outerDir, mode: 'read-write', watch: 'always', ignore: ['node_modules', '*.log', 'vendor/', 'build/cache'], + 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', '*.log', 'vendor/', 'build/cache', 'a/b/**']); + + const seen: FsChange[] = []; + 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), + }); + // 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 }); + }); + // 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)); + // 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 + // directory for the name pattern, and the same depth for the two ignored + // 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. + 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', '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', '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)}`); + 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', () => { + 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'); + // 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) => { + 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 }; +} + +/** 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[], 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); + 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, 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(); + + 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), /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); + 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)); + // 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); +}); + +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/); + // 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.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"); +}); + +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') { + 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"); + } + 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'); + 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), /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. + 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. + 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, !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]) { + 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: 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"); + }); +} + +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 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'); + 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"); + // 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/); + 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) => { + 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"); +}); + +// 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'); + }, + }, +]; + +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'); + // 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/); + + // 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-'); + const outerDir = join(root, 'outer'); + mkdirSync(join(outerDir, 'deep'), { recursive: true }); + mkdirSync(join(root, 'base')); + symlinkSync(join(outerDir, 'deep'), join(root, 'base', 'hop')); + writeFileSync(join(outerDir, 'top.txt'), 'top'); + 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: spell(root), mode: 'read-write', watch: 'never' }, + ]); + 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)); + 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']); + }); +} + +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' }, + ]); + 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"); + + // 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'); + // 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"); + // 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']); + 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']); +}); + +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$/); + }); +} + +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 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(); } + 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(); + } +}); + +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(`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(`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('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('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('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('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('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; + 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('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('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('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('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('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 + // 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'); +}); + +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'); + 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("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' }); + 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('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('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'); + // 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 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' }); + 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); + 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); } +}); + +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); + // 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')); + const native = fs.realpathSync.native; + 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); + 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(); + 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 { + Object.assign(fs, { lstatSync, readlinkSync, realpathSync }); + fs.realpathSync.native = native; + 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) => { + 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 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'); + const edited = await call('edit', { path: 'outer/node_modules/pkg.js', oldString: 'v3', newString: 'v4' }); + 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 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' }); + 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); + // 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' }] }); + 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.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'); + 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) => { + 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); + 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)); + 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("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'); + 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')); + 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"'); +});