UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

849 lines • 37.8 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { mutationLock } from "../../cache/file/io.js"; import { buildIndex } from "../../cache/index/factory.js"; import { CacheManager } from "../../cache/manager.js"; import { GENERAL_COMMANDS } from "../../commands/builtin/general/index.js"; import { GENERIC_COMMANDS } from "../../commands/builtin/generic_bind/factory.js"; import { walk } from "../../commands/cli/walk.js"; import { parseCommand } from "../../commands/spec/parser.js"; import { effectivePathMode, sessionVisibility, strongestModeUnder, } from "../../context/session_context.js"; import { noMount } from "../../errors/fs.js"; import { IOConfig } from "../../io/config.js"; import { narrowed, networkLayer, networkRoutes, } from "../../io/network.js"; import { ALL_COMMANDS } from "../../policy/builtin/output_cap.js"; import { MountRootPolicy, Policies } from "../../policy/index.js"; import { DEFAULT_READ_SPEC, Limit, MountMode, PathSpec, VFSName, WritePolicy, } from "../../types.js"; import { SystemClock } from "../../utils/clock.js"; import { pathVisible } from "../../utils/hidden.js"; import { SystemIdSource } from "../../utils/ids.js"; import { mountKey } from "../../utils/key_prefix.js"; import { normDir, ownerPrefix, rstripSlash, stripSlash } from "../../utils/slash.js"; import { compareCodePoints } from "../../utils/sort.js"; import { BinViewVFS } from "../../vfs/bin/bin.js"; import { DevIndex, DevVFS } from "../../vfs/dev/dev.js"; import { DocumentVFS } from "../../vfs/document/document.js"; import { HistoryViewVFS } from "../../vfs/history/history.js"; import { RAMVFS } from "../../vfs/ram/ram.js"; import { CLIRegistry } from "../cli/registry.js"; import { lineFacts } from "../lookup/constants.js"; import { backendCommands } from "./backends.js"; import { MountCommandAmbiguous, SharedStorageError } from "./errors.js"; import { MountEntry } from "./mount.js"; export const DEV_PREFIX = '/dev/'; export const logger = {}; // Raised when a path-bound command is unsupported by its backend. // Rendered in the GNU shape `<cmd>: <operand>: <reason>` with the // EOPNOTSUPP strerror, naming the offending path like coreutils does; // the backend name stays on the error for programmatic use (#394). export class MountCommandUnsupported extends Error { cmdName; backend; operand; constructor(cmdName, backend, operand) { super(`${cmdName}: ${operand}: Operation not supported`); this.name = 'MountCommandUnsupported'; this.cmdName = cmdName; this.backend = backend; this.operand = operand; } } export class MountRegistry { io; clock; ids; diff; indexConfig; workspaceId; processView; // Fetches the managed names a tree a mount serves reads, once the executor // has picked that mount; the workspace sets it. fillEnv; observer = null; mountList; // `mountRows`, built once per change to the mount list. rowCache = null; retiringMounts = new Map(); retiredMounts = new WeakSet(); rootRef = null; defaultRead = DEFAULT_READ_SPEC; defaultWrite = WritePolicy.UNCONDITIONAL; cacheStore = null; reconciler = null; dispatchFn = null; dispatchAsFn = null; // Workspace-level command -> runtime bindings (first listed capturer // wins), written by Runtimes beside runtimeEntries. runtimeBindings = Object.create(null); // The ordered runtime world, set by Runtimes at construction (the // live array, so add() keeps it fresh). The CLI script arm selects // an interpreter from it (a runtime: pin or the script's language), // which the bindings map cannot answer: an entry behind another // capturer never binds a command. runtimeEntries = []; commandLimits = {}; // The workspace's max_memory, over the bytes its RAM mounts hold; null // for no cap. memory = null; // The rule the workspace's connections are judged by; null for no limit. network = null; // The workspace's system_time on its clock, which what the agent sees // reads; null for the host's time. systemClock = null; // The rule each profile's `network` narrows it to, by the layer it adds, // with the rule it narrowed. narrowed = new Map(); // What starts judging the process's connections, which the host sets: a // profile can narrow a workspace that limits nothing itself. watch = null; // Command admission policies. Policies itself is a bare mechanism; // the registry seeds the POSIX mount-root rule (mount-root semantics // are mount semantics) and the document's deny rules, then user // policies (Workspace policies option), follow it. Registry-hosted // like runtimeBindings so the executor reaches them without new // threading. policies = new Policies([new MountRootPolicy(this)]); /** The approval ledger an Ask is taken to, which the policies hold * (`Policies.decisions`) so a session write reaches it too. */ get decisions() { return this.policies.decisions; } // Installed CLIs. Not mount state: CLIs are fully separate from // mounts (a CLI exists because it was installed, never because // storage was mounted). The registry object is just the vehicle that // already reaches every dispatch site, same as the runtime fields. clis; // Whether mounts take the workspace clock as they are placed: false while // the workspace is being built, which binds them all at the end. clocksBound = false; /** * Drop what a write past the dispatcher may have made stale. * * Native code (an external program, a remote runtime line) or an account * CLI changed its service with no path the dispatcher saw, so per-path * invalidation has nothing to aim at: after `gws sheets spreadsheets * create` the new file has no cache entry to expire, which is exactly the * case that matters. Every mount the write may have reached drops what it * keeps (`MountEntry.dropCaches`). A write verb is rare next to reads, and * the cost is one cold listing on a mount's next read, never a wrong * answer. Mirrors Python's `MountRegistry.drop_caches`. * * @param by the client that wrote, whose `reaches` names the mounts it may * have changed; null for every mount. */ async dropCaches(by = null) { const mounts = this.allMounts(); const reached = mounts.filter((m) => by === null || (by.reaches?.(m.vfs.accessor) ?? true)); const cache = this.cacheStore; // Every mount: one clear of the file cache, not a scan per mount. const every = cache !== null && reached.length === mounts.length; if (every) { // Under the lock a fill checks its read generation with, so a read begun // before the write keeps nothing on any mount, as each mount's own drop // retires and evicts in one step. await mutationLock(cache, async () => { for (const mount of reached) mount.cacheManager?.retire(); await cache.clear(); }); } for (const mount of reached) await mount.dropCaches(!every); } /** * Land a runtime's writes when its line ends, however it ended, and drop * the caches when they went past the dispatcher. * * A runtime serving the workspace over SFTP sent each write through the * dispatcher, which invalidated that path as it went * (`fileEffectsDispatched`). Any other may have written anywhere in its * view of the workspace, so every mount drops its caches and the next * command refetches instead of serving pre-line state: `cat /data/x` * caches "old", `python3 job.py` writes "new" straight to S3 through the * sandbox's own FUSE mount, and without the drop the next `cat /data/x` * would serve the stale "old". Mirrors Python's `MountRegistry.settle`. */ async settle(runtime) { await runtime.settle(); if (!runtime.fileEffectsDispatched) await this.dropCaches(); } /** * The rule a session's lines are held to: the workspace's, narrowed by the * `network` its profile states, its routes over the workspace's, built once * per setting. Mirrors Python's `MountRegistry.network_of`. */ networkOf(setting) { const layer = setting === null ? null : networkLayer(setting); const routes = setting === null ? {} : networkRoutes(setting); if (layer === null && Object.keys(routes).length === 0) return this.network; const key = JSON.stringify([layer, routes]); let held = this.narrowed.get(key); if (held?.[0] !== this.network) { held = [this.network, narrowed(this.network, setting ?? true)]; this.narrowed.set(key, held); this.watch?.(); } return held[1]; } /** Set what starts judging the process's connections, once a rule needs it. */ setNetworkWatch(watch) { this.watch = watch; } /** The rules `networkOf` built, whose connections close with the workspace. */ narrowedNetworks() { return [...this.narrowed.values()].flatMap(([base, rule]) => rule === null || rule === base ? [] : [rule]); } setReconciler(reconciler) { this.reconciler = reconciler; } /** * The workspace's dispatcher, which a shell glob lists and stats through; * null until the workspace wires it in, and for a bare registry with no * workspace behind it. Mirrors Python's `MountRegistry.dispatch`. */ get dispatch() { return this.dispatchFn; } /** * The workspace's dispatcher bound to `owner`, the ambient one without. On * the browser's fallback storage the ambient session is whichever line ran * last, so a glob lists as its own line's session only through a dispatch * bound to it. Python's task context keeps each line's session, so it has no * twin. */ dispatchFor(owner) { if (owner === undefined || this.dispatchAsFn === null) return this.dispatchFn; return this.dispatchAsFn(owner); } /** * Wire in the workspace's dispatcher, and how to bind it to a session. * Mirrors Python's `set_dispatch`. */ setDispatch(dispatch, dispatchAs) { this.dispatchFn = dispatch; this.dispatchAsFn = dispatchAs ?? null; } /** * Attach the workspace file cache and build per-mount CacheManagers. * Called once by Workspace after the cache store exists; mounts * added later get their manager in `mount()`. The cache is a hidden * store, never a mount. */ attachFileCache(cache) { this.cacheStore = cache; for (const m of this.mountList) this.attachManager(m); } /** * Run the shared listing verdict for one mount's cached listing. * * The reconciler is read at call time because `attachFileCache` runs * before `setReconciler`, and a manager with none trusts its listings. A * retiring mount answers false without asking. Python also answers false * for EBUSY from a mount that began retiring mid-check; this side's probe * never enters `mount.use()`, so the `retiring` check is the whole guard. */ async mayServeListing(m, folder, version) { const reconciler = this.reconciler; if (reconciler === null) return true; if (m.retiring) return false; return reconciler.mayServeListing(m, folder, version); } attachManager(m) { m.cacheManager = new CacheManager(this.cacheStore, m.indexStore, m.prefix, m.vfs.cachesReads, (path) => !m.retiring && this.tryMountFor(path) === m, m.read.ttl, // Read at call time, as the listing gate is; a retiring mount's // leftovers go with its teardown instead. async (gone) => { const reconciler = this.reconciler; if (reconciler !== null && !m.retiring) await reconciler.onGone(gone, this.descendantMounts(m.prefix).map((entry) => entry.prefix.replace(/\/$/, ''))); }, (folder, version) => this.mayServeListing(m, folder, version), () => this.descendantMounts(m.prefix).map((entry) => entry.prefix.replace(/\/$/, '')), m.write === WritePolicy.CONDITIONAL); } constructor(mounts, defaultMode, modeOverrides = {}, defaultRead = DEFAULT_READ_SPEC, readOverrides = {}, placements = {}, io = new IOConfig(), clock = new SystemClock(), ids = new SystemIdSource(clock)) { this.io = io; this.clock = clock; this.ids = ids; this.clis = new CLIRegistry(() => this.mountList.map((m) => m.vfs.accessor), clock); this.indexConfig = placements.index; this.workspaceId = placements.workspaceId; const list = []; const seen = new Set(); const overrides = {}; for (const [k, v] of Object.entries(modeOverrides)) { overrides[normDir(k)] = v; } const readByPrefix = {}; for (const [k, v] of Object.entries(readOverrides)) { readByPrefix[normDir(k)] = v; } this.defaultRead = defaultRead; this.defaultWrite = placements.defaultWrite ?? WritePolicy.UNCONDITIONAL; // Explicit at the construction site: /dev does not cache reads, so // its policy can only ever be bounded, and it keeps no index, since a // path-only index would publish one session's descriptors to another. list.push(MountRegistry.place({ prefix: DEV_PREFIX, vfs: new DevVFS(), mode: MountMode.WRITE, read: DEFAULT_READ_SPEC, write: WritePolicy.UNCONDITIONAL, index: new DevIndex(), ids: this.ids, })); seen.add(DEV_PREFIX); for (const [rawPrefix, vfs] of Object.entries(mounts)) { const prefix = normDir(rawPrefix); if (seen.has(prefix)) { throw new Error(`duplicate mount prefix: ${prefix}`); } if (vfs.isClosed) throw new Error('VFS is closed; create a new VFS instance'); this.checkStorageUnshared(prefix, vfs, list); seen.add(prefix); const mode = overrides[prefix] ?? defaultMode; const read = readByPrefix[prefix] ?? defaultRead; const index = this.indexFor(vfs, placements.indexes?.[rawPrefix]); const vfsRef = placements.refs?.[rawPrefix] ?? null; list.push(MountRegistry.place({ prefix, vfs, mode, read, write: placements.writes?.[rawPrefix] ?? this.defaultWrite, index, vfsRef, indexConfig: placements.indexes?.[rawPrefix] ?? this.indexConfig, inheritsIndex: placements.indexes?.[rawPrefix] === undefined, ids: this.ids, })); } list.sort((a, b) => b.prefix.length - a.prefix.length); this.mountList = list; this.rootRef = list.find((m) => m.prefix === '/') ?? null; } /** * Build one mount with everything the tree needs to run its driver: * the driver's command and op tables registered on the entry, the * general commands beside them. The constructor and `mount()` * both place through here, so a mount is the same whichever entry point * built it. Mirrors the Python registry, whose constructor mounts * through `mount()` as well. */ static place(init) { const m = new MountEntry(init); for (const cmd of [...GENERIC_COMMANDS, ...GENERAL_COMMANDS]) { m.registerGeneral(cmd); } // Filtered as `registerCommands` filters, as python's `registry.mount` // does, so a family table that fans out over sibling VFS names registers // only this mount's entries instead of letting the last sibling win on a // shared key. After the shared set, so a command the VFS replaces keeps // its own spec, not the generic one of that name. The backend's table is // mirage's own; the commands the VFS was handed are the user's. m.registerBuiltinCommands(backendCommands(init.vfs)); m.registerCommands(init.vfs.commands()); return m; } /** * The store a driver runs under: a new one from the mount's or the * workspace's index config, else a RAM store at the driver's own TTL. */ indexFor(vfs, config) { // A view renders on every read, and bin lists each session's own // programs, so none of them keeps a listing. if (vfs instanceof DevVFS || vfs instanceof DocumentVFS || vfs instanceof BinViewVFS || vfs instanceof HistoryViewVFS) { return new DevIndex(); } return buildIndex(config ?? this.indexConfig, vfs.indexTtl, this.clock, this.workspaceId, this.ids); } /** * Refuse a mount that would place one VFS object at a second prefix, or a * disk on a folder a mounted disk already has. * Mirrors Python's `check_storage_unshared`. * * @throws SharedStorageError naming the mount already there. */ checkStorageUnshared(prefix, vfs, mounts) { const location = vfs.name === VFSName.DISK ? vfs.storageLocation() : null; for (const other of mounts) { if (other.vfs === vfs || (location !== null && other.vfs.storageLocation() === location)) { // shortcut: realpath only; a bind mount, macOS case spelling or `..` after a link passes, upgrade to (dev, ino). throw new SharedStorageError(prefix, other.prefix, other.vfs === vfs ? null : location); } } } /** A removed VFS instance cannot start a second lifecycle. */ checkVfsAvailable(vfs) { if (this.retiringMounts.has(vfs) || this.mountList.some((m) => m.vfs === vfs && m.retiring)) { throw new Error('VFS is being unmounted'); } if (vfs.isClosed || this.retiredMounts.has(vfs)) { throw new Error('VFS is closed; create a new VFS instance'); } } /** * Add a mount dynamically. Mirrors Python's `registry.mount(...)`. * Registers the VFS's commands and ops on the new mount and * re-sorts mounts by prefix length (longest first). Once the workspace is * built, the VFS takes the workspace clock before anything is placed. * * @throws Error when the prefix is taken, or another workspace holds the * VFS on another clock. */ mount(prefix, vfs, mode = MountMode.READ, read, placement = {}) { this.checkVfsAvailable(vfs); const norm = normDir(prefix); for (const existing of this.mountList) { if (existing.prefix === norm) { throw new Error(`duplicate mount prefix: ${norm}`); } } this.checkStorageUnshared(norm, vfs, this.mountList); if (this.clocksBound) vfs.accessor.bindClock?.(this.clock, this); try { const index = this.indexFor(vfs, placement.index); const vfsRef = placement.vfsRef ?? null; const m = MountRegistry.place({ prefix: norm, vfs, mode, read: read ?? this.defaultRead, write: placement.write ?? this.defaultWrite, index, vfsRef, indexConfig: placement.index ?? this.indexConfig, inheritsIndex: placement.index === undefined, ids: this.ids, }); this.settleWalkLimit(m); this.attachManager(m); this.mountList.push(m); this.mountList.sort((a, b) => b.prefix.length - a.prefix.length); if (norm === '/') this.rootRef = m; return m; } catch (err) { if (this.clocksBound) vfs.accessor.releaseClock?.(this); throw err; } } /** * Hand every mount's and CLI install's accessor the workspace clock, once * the workspace is built; a mount placed later takes it as it is placed. Mirrors Python's * `MountRegistry.bind_clocks`. * * @throws Error when another workspace holds one of the VFSes on another * clock; nothing stays bound. */ bindClocks() { try { for (const m of this.mountList) m.vfs.accessor.bindClock?.(this.clock, this); this.clis.bindClocks(); } catch (err) { this.releaseClocks(); throw err; } this.clocksBound = true; } /** * Let go of the workspace clock on `vfs`, as an unmounted mount finishes its * last call. Mirrors Python's `MountRegistry.release_clock`. */ releaseClock(vfs) { vfs.accessor.releaseClock?.(this); } /** * Let go of the workspace clock on every mount and CLI install, as the * workspace closes or fails to come to be, so another workspace may mount * its VFSes on its own. Mirrors Python's `MountRegistry.release_clocks`. */ releaseClocks() { this.clocksBound = false; for (const m of this.mountList) m.vfs.accessor.releaseClock?.(this); this.clis.releaseClocks(); } /** * Remove a mount by exact prefix. Mirrors Python's `registry.unmount(...)`. * Per-mount commands and ops live on the Mount instance and die with it. * The /dev/ mount is reserved and cannot be removed. */ unmount(prefix) { const norm = normDir(prefix); if (norm === DEV_PREFIX) { throw new Error(`cannot unmount reserved prefix: ${norm}`); } const idx = this.mountList.findIndex((m) => m.prefix === norm); if (idx === -1) { throw new Error(`no mount at prefix: ${norm}`); } const [removed] = this.mountList.splice(idx, 1); this.rowCache = null; if (removed === undefined) { throw new Error(`no mount at prefix: ${norm}`); } if (removed === this.rootRef) this.rootRef = null; return removed; } /** * The mount at exactly this prefix; throws noMount for none. Callers * that expect the miss branch on `tryMountForPrefix` returning null. */ mountForPrefix(prefix) { const m = this.tryMountForPrefix(prefix); if (m === null) throw noMount(prefix); return m; } /** The mount at exactly this prefix, or null when none matches. */ tryMountForPrefix(prefix, includeHidden = false) { const norm = normDir(prefix); for (const m of includeHidden ? this.mountList : this.visibleMounts()) { if (m.prefix === norm) return m; } return null; } /** * One mount's configured cap for a command or op name. The lookup a * command's output guard resolves with; tolerant of a prefix that matches * no mount (unmounted between stamp and boundary) by answering null. */ /** * Set the mount's `walkLimit`, which decides how its backend searches (its * own search API under a cap, every file without): the mount's `@all` entry, * else the workspace's, else the backend's default, a null one lifting the * cap. A command's own `walkLimit` changes only how far that command walks. * Mirrors Python's `settle_walk_limit`. */ settleWalkLimit(entry) { const found = Limit.layered([ entry.commandLimits.get(ALL_COMMANDS) ?? null, Object.hasOwn(this.commandLimits, ALL_COMMANDS) ? (this.commandLimits[ALL_COMMANDS] ?? null) : null, new Limit({ walkLimit: entry.backendWalkLimit }), ]); entry.vfs.walkLimit = found === null ? entry.backendWalkLimit : (found.walkLimit ?? -1); entry.calls = entry.answeredCalls(); this.rowCache = null; } /** * The mount a command's output guard resolves a stamped prefix to; null * for a prefix that matches no mount (unmounted between stamp and * boundary). Mirrors Python's `limit_mount`. */ limitMount(prefix) { for (const m of this.mountList) { if (m.prefix === prefix || rstripSlash(m.prefix) === prefix) return m; } return null; } isMountRoot(path) { return this.tryMountForPrefix(path) !== null; } descendantMounts(path) { const norm = normDir(path); const out = []; for (const m of this.visibleMounts()) { if (m.prefix === norm) continue; if (!m.prefix.startsWith(norm)) continue; out.push(m); } return out.sort((a, b) => compareCodePoints(a.prefix, b.prefix)); } mountPrefixes() { return this.visibleMounts().map((m) => m.prefix); } get rootMount() { return this.rootRef; } get fileCache() { return this.cacheStore; } resolve(path) { const m = this.mountFor(path); const hadTrailing = path.endsWith('/'); const norm = `/${stripSlash(path)}`; const mountPrefix = rstripSlash(m.prefix); return [ m.vfs, PathSpec.fromStrPath(hadTrailing ? `${norm}/` : norm, mountKey(hadTrailing ? `${norm}/` : norm, mountPrefix)), m.mode, ]; } /** * The mount that handles this path; throws noMount for none. * * The lookup contract pair: `mountFor` is for callers whose path must * be mounted (a miss is a broken invariant and propagates as a typed * noMount error), `tryMountFor` is for callers with a real fallback * for the miss. Never catch around this method — call the try variant * instead. */ mountFor(path) { const m = this.tryMountFor(path); if (m === null) throw noMount(path); return m; } /** The mount that handles this path, or null when none does. */ tryMountFor(path) { const owner = ownerPrefix(this.visibleMounts().map((m) => m.prefix), path); if (owner === null) return null; return this.visibleMounts().find((m) => m.prefix === owner) ?? null; } /** * One row per registered mount, for the namespace reader the dispatcher * hands out; built once per change to the mount list. The functions a VFS * answers are the ones its mount read when it was mounted * (`MountEntry.calls`); who is told of a mount is read off the mount when * asked, since a session's documents mount is narrowed after it is made. * Mirrors Python's `mount_rows`. */ mountRows() { this.rowCache ??= this.mountList.map((mount) => { const vfs = mount.vfs; return { prefix: mount.prefix, kind: vfs.name, get mode() { return mount.mode; }, visible: (sessionId) => mount.visible === null || mount.visible(sessionId), calls: mount.calls, searchable: vfs.searchable, walkLimit: vfs.walkLimit, commandLimits: mount.commandLimits, workspaceLimits: this.commandLimits, }; }); return [...this.rowCache]; } /** The mounts the session a call through a facade of `sessionId` runs as * (null: the default facade) is told of at all, hides aside. */ visibleMounts(sessionId = null) { return this.mountList.filter((m) => m.visible === null || m.visible(sessionId)); } allMounts() { return this.mountList; } /** * The bytes the workspace's RAM mounts hold, each store counted once, as a * remount's retiring entry shares its store. Mirrors Python's * `held_in_memory`. */ heldInMemory() { const stores = new Set(); for (const m of this.mountList) if (m.vfs instanceof RAMVFS) stores.add(m.vfs); let held = 0; for (const store of stores) held += store.held; return held; } isExecAllowed(access) { for (const m of this.mountList) { if (m.prefix === DEV_PREFIX) continue; // strongestModeUnder, not effectiveMode: a session whose only x // grant is a show entry still counts as having one. if (strongestModeUnder(m.prefix, m.mode, access) === MountMode.EXEC) return true; } return false; } /** * Whether code may be loaded from this path: the per-script form of * `isExecAllowed`, read by an interpreter running a file operand * (`python3 path.py`, `js app.js`, or a `./script` whose shebang names * one). Shell scripts (`bash script.sh`, `source`, a `./script` with no * shebang or an sh one) never ask: they run in the shell, which checks * each of their commands like a typed one. */ execAllowedAt = (virtual, access) => { const m = this.tryMountFor(virtual); if (m === null) return false; return effectivePathMode(virtual, m.prefix, m.mode, access) === MountMode.EXEC; }; fallbackCandidates(cmdName, access) { const visible = this.visibleMounts(access?.sessionId); const ordered = this.rootRef === null ? visible : [this.rootRef, ...visible.filter((mount) => mount !== this.rootRef)]; const visibility = sessionVisibility(access); return ordered.filter((mount) => visible.includes(mount) && mount.prefix !== DEV_PREFIX && pathVisible(visibility, mount.prefix) && mount.resolveCommand(cmdName) !== null); } mountForCommand(cmdName, access) { return this.fallbackCandidates(cmdName, access)[0] ?? null; } /** * How many leading words form a registered command name. Command names * may span several words ("gws docs documents get"), git-style. A nested * name resolves from anywhere its owning mount is reachable, so this * scans every mount (mirroring mountForCommand) and returns the longest * prefix any mount recognises, or 1 (bare first token) when none does. */ matchCommandPrefix(words) { if (words.length === 0) return 0; // An installed CLI head wins over any multiword mount command // under the same first word (`himalaya message send`): dispatch is // by name and the subcommand words belong to the tree walk, so the // head alone is the command name. if (words[0] !== undefined && this.clis.get(words[0]) !== null) return 1; let best = 1; const candidates = [...this.mountList]; if (this.rootRef !== null) candidates.push(this.rootRef); for (const mount of candidates) { best = Math.max(best, mount.longestCommandMatch(words)); } return best; } /** * Candidates for discovery and admission, without readying a backend. * A path or working directory that owns the command selects it; fallback * retains root-first, then longest-prefix registration order. A bound * mount stays authoritative so the dispatcher owns visibility refusals. */ commandCandidates(cmdName, pathScopes, cwd, access) { const first = pathScopes[0]; const visible = this.visibleMounts(access?.sessionId); const owner = ownerPrefix(visible.map((mount) => mount.prefix), first?.virtual ?? cwd); const mount = visible.find((candidate) => candidate.prefix === owner); if (mount !== undefined && mount.resolveCommand(cmdName) !== null) { return [mount]; } if (mount !== undefined && first !== undefined) return []; return this.fallbackCandidates(cmdName, access); } /** The first discovery candidate; selecting execution may refuse ambiguity. */ commandMount(cmdName, pathScopes, cwd, access) { return this.commandCandidates(cmdName, pathScopes, cwd, access)[0] ?? null; } /** The ordered execution candidates and grammar for the same invocation. */ invocationChoices(cmdName, pathScopes, cwd, argv = [], access) { const candidates = this.commandCandidates(cmdName, pathScopes, cwd, access); const first = candidates[0] ?? null; if (candidates.length < 2 || first === null) return [candidates, first]; const builtins = lineFacts(cmdName, [cmdName, ...argv], cwd).writes; const readers = []; const writable = []; const subcommands = new Set(); for (const mount of candidates) { const tree = mount.cliFor(cmdName); let writes; if (tree === null) { writes = mount.mayWrite(cmdName, null, builtins); } else { const result = walk(cmdName, tree.spec, argv, cwd, undefined, true); if (result.leaf === null) continue; if (result.path.length > 0) subcommands.add(mount); writes = tree.handlers[result.path.join(' ')]?.write === true; } if (!writes) readers.push(mount); else if (strongestModeUnder(mount.prefix, mount.mode, access) !== MountMode.READ) writable.push(mount); } const reader = readers[0]; const selected = writable.length > 0 ? writable : [reader ?? first]; const grammar = selected.find((mount) => subcommands.has(mount)) ?? selected[0] ?? first; // A selected tree's named verb owns its words. A root-only leaf has no // verb to distinguish it from a flat command's explicit path operands. if (pathScopes.length === 0 && first.cliFor(cmdName) === null && !subcommands.has(grammar)) { const spec = (grammar.cliFor(cmdName) === null ? grammar : first).specFor(cmdName); if (spec !== null) { const parsed = parseCommand(spec, [...argv], cwd, cmdName); const operands = parsed.args .filter(([, kind], index) => kind === 'path' && parsed.rawOperands[index]?.[0] !== '-') .map(([path]) => path); const inferred = [...operands, ...parsed.pathFlagValues] .filter((path) => path !== '-') .map((path) => PathSpec.fromStrPath(path, undefined, cwd)); if (inferred.length > 0) { const bound = this.commandCandidates(cmdName, inferred, cwd, access); if (bound.length < 2) return [bound, bound[0] ?? null]; } } } return [selected, grammar]; } /** The targets this invocation can run, before fetching its environment. */ invocationCandidates(cmdName, pathScopes, cwd, argv = [], access) { return this.invocationChoices(cmdName, pathScopes, cwd, argv, access)[0]; } /** The grammar used to classify this invocation, without refusing ambiguity. */ invocationMount(cmdName, pathScopes, cwd, argv = [], access) { return this.invocationChoices(cmdName, pathScopes, cwd, argv, access)[1]; } /** Select an unambiguous write target without starting a backend. */ executionMount(cmdName, pathScopes, cwd, argv = [], access) { const candidates = this.invocationCandidates(cmdName, pathScopes, cwd, argv, access); if (candidates.length > 1) { throw new MountCommandAmbiguous(cmdName, candidates.map((mount) => mount.prefix)); } const selected = candidates[0] ?? null; const original = this.commandCandidates(cmdName, pathScopes, cwd, access); if (selected !== null && original.length > 1) { logger.debug?.(`${cmdName}: multiple mounts ${original.map((mount) => mount.prefix).join(', ')}; using ${selected.prefix}`); } return selected; } async resolveMount(cmdName, pathScopes, cwd, argv = [], access) { const mount = this.executionMount(cmdName, pathScopes, cwd, argv, access); const first = pathScopes[0]; if (mount === null && first !== undefined) { const owner = this.tryMountFor(first.virtual); if (owner !== null) throw new MountCommandUnsupported(cmdName, owner.vfs.name, first.rawPath); } if (mount === null) return null; await mount.ensureReady(); return mount; } } //# sourceMappingURL=registry.js.map