@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
849 lines • 37.8 kB
JavaScript
// ========= 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