@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
312 lines • 13.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 { BUILTIN_SPECS } from "../../commands/spec/builtins.js";
import { errorSummary } from "../../secrets/summary.js";
import { snakeToCamel } from "../../utils/normalize.js";
import { JOB_BUILTINS, KEYWORDS, NAMESPACE_COMMANDS, SHELL_NAMES } from "../lookup/constants.js";
import { z } from 'zod';
import { ABORT_JOIN_MS, abortable } from "../../utils/abort.js";
import { Activity } from "../../utils/activity.js";
import { SystemClock } from "../../utils/clock.js";
import { compareCodePoints } from "../../utils/sort.js";
/**
* Installed CLIs, keyed by head word.
*
* Fully separate from the mount registry: a CLI exists because it was
* installed (YAML `clis:` section or registerCli), never because
* storage was mounted. Install is fail-loud: a bad name, a colliding
* name, or a config the spec's configModel rejects throws at install
* time, so a workspace that loads has only valid entries.
*
* The lifecycle is host-side only, and must stay that way: install and
* uninstall are called by the program embedding mirage, never by a line
* the agent types, so an agent cannot take away the tools it was given.
* Do not add an `install`/`uninstall` shell builtin. What an agent can
* do is shadow a head word with a shell function, which is bash's own
* rule, reversible with `unset -f`, bypassable with `command <name>`,
* and visible through `type -a`. Pinning a head word against that
* belongs in the policy layer's `preCommand`, since it is a
* per-deployment call rather than a property of the registry.
*/
export class CLIRegistry {
peers;
clock;
installs = new Map();
retiring = new Map();
shutdown = new AbortController();
clocksBound = false;
/**
* @param peers the mounts' accessors, which an install may share an account with.
* @param clock the workspace's clock, which each install's accessor reads
* time and waits on.
*/
constructor(peers = () => [], clock = new SystemClock()) {
this.peers = peers;
this.clock = clock;
}
/**
* Refuse a head word no CLI may be installed under: not a single word,
* or one that collides with a shell builtin, a keyword or a general
* command. Mirrors Python's `CLIRegistry.check_name`.
*/
checkName(name) {
if (name === '' || /\s/.test(name)) {
throw new Error(`CLI name '${name}' must be a single word`);
}
if (SHELL_NAMES.has(name) || JOB_BUILTINS.has(name)) {
throw new Error(`CLI name '${name}' collides with a shell builtin`);
}
// A reserved word never reaches dispatch (the parser consumes it), so
// an install under one would be unreachable rather than wrong.
if (KEYWORDS.has(name)) {
throw new Error(`CLI name '${name}' is a shell keyword`);
}
if (NAMESPACE_COMMANDS.has(name) || name in BUILTIN_SPECS) {
throw new Error(`CLI name '${name}' collides with a general command`);
}
}
/**
* Install a CLI under a head word. The name must be a single word and
* must not collide with another installed CLI, a shell builtin, or a
* general command (a runtime capture of the same name is fine: the
* policy steers per line).
*/
install(name, spec, config = null) {
if (this.installs.has(name)) {
throw new Error(`CLI name '${name}' is already installed`);
}
this.checkName(name);
const validated = this.validateConfig(name, spec, config);
const accessor = spec.accessor(validated);
accessor.join?.([...this.peers(), ...[...this.installs.values()].map((i) => i.accessor)]);
const install = {
name,
cli: spec,
config: validated,
accessor,
activity: new Activity(),
};
if (this.clocksBound)
accessor.bindClock?.(this.clock, install);
this.installs.set(name, install);
return install;
}
validateConfig(name, spec, config) {
// A config that is not an object is refused by type, rather than having
// its entries read as unknown keys. One the schema already parsed is
// still an object with the schema's own keys, so it parses again to
// the same config, as the python arm takes an instance of its model. A
// mount config on the same account (a gdocs config handed to gws) is
// an object too: a mount-only key it carries is refused like any
// unknown key, as python refuses a subclass field the caller set.
if (config !== null && (typeof config !== 'object' || Array.isArray(config))) {
const kind = Array.isArray(config) ? 'array' : typeof config;
throw new Error(`CLI '${name}': config must be an object, got ${kind}`);
}
if (spec.script !== null) {
// A script spec has no configModel: the mapping passes through
// as-is for the program to consume.
return config !== null && Object.keys(config).length > 0 ? { ...config } : null;
}
if (spec.configModel === null) {
if (config !== null && Object.keys(config).length > 0) {
throw new Error(`CLI '${name}': config given but '${spec.spec.name}' declares no configModel`);
}
return null;
}
const model = spec.configModel;
if (model instanceof z.ZodObject) {
// The same snake_case YAML config block serves Python and TS alike
// (the VFS registries already promise this), and the pydantic
// arm is snake_case-native, so a key that camelizes onto a declared
// field is delivered there. Keys that resolve to no field keep the
// caller's spelling: an unknown key is then reported as typed, and
// a loose schema's passthrough data stays verbatim like pydantic
// extra="allow". hasOwn, not `in`: the shape is a plain object, so
// `in` would read 'constructor'/'toString' off Object.prototype as
// declared fields, and fromEntries keeps a '__proto__' key an own
// property instead of silently reassigning the prototype.
const pairs = [];
const taken = new Set();
for (const [key, value] of Object.entries(config ?? {})) {
const camel = snakeToCamel(key);
const target = Object.hasOwn(model.shape, camel) ? camel : key;
// Both spellings of one field is ambiguous, and pydantic already
// rejects it (the camelCase one is an unknown key there), so the
// zod arm must not silently keep whichever came last.
if (taken.has(target)) {
throw new Error(`CLI '${name}': config key '${key}' duplicates field '${target}'`);
}
taken.add(target);
pairs.push([target, value]);
}
const norm = Object.fromEntries(pairs);
// Unknown keys fail loud (a typo'd YAML key must not be silently
// ignored), mirroring the Python pydantic arm; a schema that
// declares its own extra-key policy (strict/passthrough/catchall)
// enforces it through parse instead, like pydantic extra="allow".
if (model.def.catchall === undefined) {
const unknown = [...taken].filter((k) => !Object.hasOwn(model.shape, k));
if (unknown.length > 0) {
throw new Error(`CLI '${name}': unknown config keys: ${unknown.sort(compareCodePoints).join(', ')}`);
}
}
try {
return model.parse(norm);
}
catch (err) {
// An account CLI's config is where a fetched credential lands, the
// same as a mount's: field and code only, never zod's rendering of
// the refused value. Mirrors the python registry's `error_summary`.
if (err instanceof z.ZodError)
throw new Error(`CLI '${name}': ${errorSummary(err)}`);
throw err;
}
}
const result = model(config ?? {});
// The snapshot stores the normalized config and replays it through
// the normalizer, so the output must be a JSON-shaped mapping (or
// null), matching the Python side's model-instance contract.
if (result !== null && (typeof result !== 'object' || Array.isArray(result))) {
throw new Error(`CLI '${name}': configModel must return an object or null`);
}
return result;
}
/**
* Hand every install's accessor the workspace clock, once the workspace is
* built; an install made later takes it at once. Each install holds on its
* own, so removing one leaves another that shares its client holding.
* Mirrors Python's `CLIRegistry.bind_clocks`.
*
* @throws Error when another workspace holds a client on another clock;
* nothing stays bound.
*/
bindClocks() {
try {
for (const install of this.installs.values()) {
install.accessor.bindClock?.(this.clock, install);
}
}
catch (err) {
this.releaseClocks();
throw err;
}
this.clocksBound = true;
}
/**
* Let go of the workspace clock on every install's accessor, as the
* workspace closes or fails to come to be. Mirrors Python's
* `CLIRegistry.release_clocks`.
*/
releaseClocks() {
this.clocksBound = false;
for (const install of this.installs.values())
install.accessor.releaseClock?.(install);
}
/**
* Remove an installed CLI now and close its accessor once the lines running
* on it and their output streams are done; its head word stops resolving
* (127) at once.
*/
async uninstall(name) {
const install = this.installs.get(name);
if (install === undefined) {
throw new Error(`CLI name '${name}' is not installed`);
}
this.installs.delete(name);
await this.track(install, retire(install, this.shutdown.signal));
}
/**
* Close every install's accessor, including the ones still being removed,
* as the workspace closes; one failed close does not skip the rest.
*
* The workspace aborts its lines before this runs, so a leaf still on an
* install is unwinding (or ignoring its abort): it gets `ABORT_JOIN_MS` to
* settle before its accessor goes, rather than meeting a closed client
* mid-request, and no longer, so one that never settles cannot hold the
* workspace open. Each close is tracked, as a removal is, so a second close
* waits for the clients the first is still closing.
*/
async close() {
const shutdown = this.shutdown;
this.shutdown = new AbortController();
shutdown.abort();
const installs = [...this.installs.values()];
this.installs.clear();
for (const install of installs)
void this.track(install, closeAfterGrace(install));
const results = await Promise.allSettled([...this.retiring.values()]);
const failures = results.flatMap((r) => (r.status === 'rejected' ? [r.reason] : []));
if (failures.length > 0)
throw failures.length === 1 ? failures[0] : new AggregateError(failures, 'CLI close failed');
}
/** Keep `closing` in `retiring` until it settles, so `close` waits for it. */
track(install, closing) {
const tracked = closing.finally(() => {
this.retiring.delete(install);
});
this.retiring.set(install, tracked);
return tracked;
}
/** Look up an installation by head word. */
get(name) {
return this.installs.get(name) ?? null;
}
/** Snapshot of the installed CLIs keyed by head word. */
items() {
return new Map(this.installs);
}
/** The installed head words. */
names() {
return new Set(this.installs.keys());
}
}
async function retire(install, shutdown) {
try {
try {
await abortable(install.activity.wait(), shutdown);
}
catch (error) {
if (!shutdown.aborted)
throw error;
await closeAfterGrace(install);
return;
}
await install.accessor.close?.();
}
finally {
install.accessor.releaseClock?.(install);
}
}
async function closeAfterGrace(install) {
let timer;
try {
await Promise.race([
install.activity.wait(),
new Promise((resolve) => {
timer = setTimeout(resolve, ABORT_JOIN_MS);
}),
]);
}
finally {
clearTimeout(timer);
}
try {
await install.accessor.close?.();
}
finally {
install.accessor.releaseClock?.(install);
}
}
//# sourceMappingURL=registry.js.map