@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
303 lines • 13.6 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 { compareCodePoints } from "../utils/sort.js";
import { awaitsAs, callsAs, currentCaller, noteRefusal, } from "../context/session_context.js";
import { isGlob, showHead } from "../utils/hidden.js";
import { PathSpec } from "../types.js";
import { Files } from "../workspace/files.js";
import { CANNOT_EXECUTE_STATUS } from "../shell/constants.js";
/** Live workspace connection; capture creates views scoped to one execution. */
export class WorkspaceBinding {
dispatch;
resolver;
context;
files;
getSession;
mountPrefixes;
bridge;
/**
* @param dispatch a program's own file call, its gate included; null
* over a guest bridge alone (`overBridge`).
* @param resolver the routing table.
* @param context the capture for one execution; `captureBinding` when
* absent.
* @param files the workspace's `ws.vfs`, for an adapter that serves the
* mounts as files (a sandbox's SFTP); null outside a workspace.
* @param getSession its live session by id; null outside a workspace.
* @param mountPrefixes every prefix a sandbox may be told of, whatever
* the session hides; null outside a workspace.
* @param bridge the guest bridge a run with no files rides.
*/
constructor(dispatch, resolver, context, files = null, getSession = null, mountPrefixes = null, bridge = null) {
this.dispatch = dispatch;
this.resolver = resolver;
this.context = context;
this.files = files;
this.getSession = getSession;
this.mountPrefixes = mountPrefixes;
this.bridge = bridge;
}
/**
* A binding whose file calls ride a guest bridge (the Pyodide worker's,
* across `postMessage`) rather than a dispatcher.
*
* @param bridge the guest bridge.
* @param resolver the routing table.
* @param context the capture for one execution; `captureBinding` when
* absent.
*/
static overBridge(bridge, resolver, context) {
return new WorkspaceBinding(null, resolver, context, null, null, null, bridge);
}
capture() {
return this.context === undefined ? captureBinding(this) : this.context(this);
}
}
/**
* A run the session's restrictions refuse before it starts, as admission
* refuses a command: bash's `<name>: Permission denied` at 126, the reason
* on the line's refusal record and never on stderr. `name` is the runtime,
* which names the refusal. Mirrors Python's `refused_run`.
*/
export function refusedRun(name, reason) {
noteRefusal({ kind: 'deny', reason, policy: '', askId: null });
return {
stdout: new Uint8Array(),
stderr: new TextEncoder().encode(`${name}: Permission denied\n`),
exitCode: CANNOT_EXECUTE_STATUS,
};
}
/** Map restrictions to owning mounts, not ancestors whose trees contain another mount. */
export function restrictedMountsFor(session, prefixes) {
const hidden = session.visibility.paths;
const entries = [...(hidden?.paths ?? []), ...(hidden?.patterns ?? [])];
entries.push(...(session.visibility.shown?.entries ?? [])
.filter((e) => e.mode !== null && e.mode !== 'exec')
.map((e) => e.path));
for (const rule of [...(session.commands?.ask ?? []), ...(session.commands?.deny ?? [])]) {
for (const item of rule.paths?.length ? rule.paths : rule.mount ? [rule.mount] : [])
entries.push(item);
}
for (const [prefix, mode] of session.mountModes ?? [])
if (mode !== 'exec')
entries.push(prefix);
const mounts = new Map(prefixes.map((p) => [p.replace(/\/+$/, '') || '/', p]));
if (!mounts.has('/'))
mounts.set('/', '/');
const restricted = new Set();
for (const entry of entries) {
const head = isGlob(entry) && !entry.includes('/') ? '/' : showHead(entry);
const owners = [...mounts.keys()].filter((p) => p === '/' || head === p || head.startsWith(p + '/'));
owners.sort((a, b) => b.length - a.length);
restricted.add(mounts.get(owners[0] ?? '/') ?? '/');
for (const [path, prefix] of mounts)
if (head === '/' || path.startsWith(head + '/'))
restricted.add(prefix);
}
return [...restricted].sort(compareCodePoints);
}
/**
* Pin callback attribution without copying authoritative workspace state.
*
* @param binding the workspace connection.
* @param views the session's context, its restricted mounts, and the
* dispatch bound to that session (the binding's own when absent).
* @param caller who the entry points run as.
*/
export function captureBinding(binding, views = {}, caller = currentCaller()) {
const given = views.dispatch ?? binding.dispatch;
const dispatch = given === null ? null : awaitsAs(caller, given);
const vfs = dispatch === null
? undefined
: (views.ctx?.vfs?.over(dispatch) ?? new Files(dispatch, null, null, { ledger: false }));
// A read of the session reads the session it is bound to; only a write
// passes the gates, which read the caller, so only a write runs as it.
const session = views.ctx?.session;
const wrapped = session === undefined
? undefined
: {
...session,
set: awaitsAs(caller, session.set.bind(session)),
unset: awaitsAs(caller, session.unset.bind(session)),
mark: awaitsAs(caller, session.mark.bind(session)),
};
const rest = { ...views.ctx };
delete rest.vfs;
return Object.freeze({
restrictedMounts: Object.freeze(views.restrictedMounts ?? []),
ctx: Object.freeze({
stdin: null,
flags: {},
...rest,
...(vfs !== undefined ? { vfs } : {}),
cwd: views.ctx?.cwd ?? PathSpec.fromStrPath('/'),
env: Object.freeze({ ...views.ctx?.env }),
...(wrapped === undefined ? {} : { session: wrapped }),
}),
caller,
});
}
/** Ask every routing question as the captured caller, whose session
* decides which mounts it is told of. */
export class CallerResolver {
resolver;
caller;
/**
* @param resolver the routing table asked.
* @param caller who each question is asked as.
*/
constructor(resolver, caller) {
this.resolver = resolver;
this.caller = caller;
}
prefixes() {
return callsAs(this.caller, () => this.resolver.prefixes())();
}
ownerOf(path) {
return callsAs(this.caller, () => this.resolver.ownerOf(path))();
}
linkChildren(directory) {
return callsAs(this.caller, () => this.resolver.linkChildren(directory))();
}
}
/**
* The bridge a runtime's file I/O rides, over a dispatch shaped like
* `Workspace.dispatch` (name, string path, positional args, kwargs): the
* run's files for a guest (`RuntimeFiles.of`), `ws.vfs.dispatch` for
* node's patched `fs`. A read is the rendered one unless its `raw` attr asks for the
* stored bytes, and its `offset`/`size` attrs ask for a byte range,
* matching Python's `RuntimeFiles.read`.
*/
export function workspaceBridge(dispatch) {
return async (op, path, bytes, dst, attrs) => {
switch (op) {
case 'read': {
const kwargs = attrs?.raw === true ? { filetype: null } : {};
if (attrs?.offset !== undefined || attrs?.size !== undefined) {
kwargs.offset = attrs.offset ?? 0;
kwargs.size = attrs.size ?? null;
}
if (attrs?.direct === true)
kwargs.direct = true;
if (attrs?.retained !== undefined)
kwargs.retained = attrs.retained;
return (await dispatch('read', path, [], kwargs));
}
case 'write': {
if (attrs?.admitOnly === true) {
await dispatch('write', path, [], { admit_only: true });
return undefined;
}
if (bytes === undefined)
throw new Error('write op requires bytes');
const buf = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
await dispatch('write', path, [buf]);
return undefined;
}
case 'append': {
if (bytes === undefined)
throw new Error('append op requires bytes');
const buf = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
await dispatch('append', path, [buf]);
return undefined;
}
case 'pwrite': {
if (bytes === undefined)
throw new Error('pwrite op requires bytes');
await dispatch('pwrite', path, [bytes, attrs?.offset ?? 0]);
return undefined;
}
case 'stat':
// The mount's own row, nothing projected: the file adapter
// builds the one VFSStat both languages read, so the two
// tiers cannot drift into two translations of one fact.
// `nofollow` is the caller's lstat; `admitOnly` checks a guest's
// requested alias against command rules without backend I/O.
return await dispatch('stat', path, [], {
...(attrs?.nofollow === true ? { nofollow: true } : {}),
...(attrs?.admitOnly === true ? { admit_only: true } : {}),
...(attrs?.retained !== undefined ? { retained: attrs.retained } : {}),
});
case 'create':
await dispatch('create', path);
return undefined;
case 'truncate':
await dispatch('truncate', path, [attrs?.length ?? 0]);
return undefined;
case 'unlink':
await dispatch('unlink', path);
return undefined;
case 'mkdir':
// `parents` is pathlib's mkdir(parents=True), riding to the
// backend op as a kwarg the way python's dispatch carries it.
await dispatch('mkdir', path, [], attrs?.parents === true ? { parents: true } : {});
return undefined;
case 'rmdir':
await dispatch('rmdir', path);
return undefined;
case 'rename': {
if (dst === undefined)
throw new Error('rename op requires dst');
await dispatch('rename', path, [PathSpec.fromStrPath(dst, undefined, '/')]);
return undefined;
}
case 'symlink': {
// The target is not a PathSpec: a link stores what was typed,
// relative or dangling, and resolving it here would record a
// different link than the guest asked for.
if (dst === undefined)
throw new Error('symlink op requires dst');
await dispatch('symlink', path, [], { target: dst });
return undefined;
}
case 'readlink':
return (await dispatch('readlink', path));
case 'setattr': {
if (attrs === undefined)
throw new Error('setattr op requires attrs');
await dispatch('setattr', path, [], attrs);
return undefined;
}
case 'readdir':
// The names as the dispatcher merged them, nothing resolved: the
// file adapter (`RuntimeFiles.readdir`) stats each entry and
// marks the links, so a row is built in one tier and in one
// shape in both languages.
return (await dispatch('readdir', path)) ?? [];
case 'getxattr':
return await dispatch('getxattr', path, [], {
name: dst ?? '',
nofollow: attrs?.nofollow === true,
});
case 'listxattr':
return await dispatch('listxattr', path, [], { nofollow: attrs?.nofollow === true });
case 'setxattr':
await dispatch('setxattr', path, [], {
name: dst ?? '',
value: bytes ?? new Uint8Array(),
create: attrs?.create === true,
replace: attrs?.replace === true,
nofollow: attrs?.nofollow === true,
});
return undefined;
case 'removexattr':
await dispatch('removexattr', path, [], {
name: dst ?? '',
nofollow: attrs?.nofollow === true,
});
return undefined;
}
};
}
//# sourceMappingURL=binding.js.map