@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
425 lines • 18.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 { currentCaller, getCurrentSession, runWithCaller, } from "../../context/session_context.js";
import { rstripSlash } from "../../utils/slash.js";
import { singleQuote } from "../../utils/quote.js";
import { compareCodePoints } from "../../utils/sort.js";
import { runDetached } from "../../utils/async_context.js";
import { refusedRun, restrictedMountsFor } from "../binding.js";
import { MOUNT_POLL_SECONDS, MOUNT_WAIT_SECONDS, PIPE_SERVER_HINT, SSHFS_HINT, SSHFS_LOG_PREFIX, SSHFS_OPTIONS, UNMOUNT_WAIT_SECONDS, } from "./constants.js";
let pipeServer = null;
/**
* Where node puts the SFTP server a served prefix's pipe runs: as
* `session`, each request run by `run`. Core has none of its own:
* MountCore and ssh2 are node's.
*/
export function registerPipeServer(make) {
pipeServer = make;
}
const DECODER = new TextDecoder();
const OCTAL_ESCAPE = /\\([0-7]{3})/g;
function octalChar(_match, digits) {
return String.fromCharCode(parseInt(digits, 8));
}
/**
* Each mountpoint a `/proc/mounts` lists, with its type. The kernel
* escapes a space in a path as `\040`; that is undone. A point mounted
* twice keeps the later (top) entry's type.
*/
export function mountTable(text) {
const table = new Map();
for (const line of text.split('\n')) {
const fields = line.trim().split(/\s+/);
const [, point, kind] = fields;
if (point !== undefined && kind !== undefined) {
table.set(point.replace(OCTAL_ESCAPE, octalChar), kind);
}
}
return table;
}
/** The mountpoints a sandbox could hold of `prefixes`. */
function mountPaths(prefixes) {
const paths = new Set();
for (const prefix of prefixes)
paths.add(rstripSlash(prefix));
paths.delete('');
paths.delete('/dev');
return paths;
}
/**
* The prefixes a sandbox mounts: each one under no other. A prefix under
* another is served through it. The root and the workspace's devices are
* left out: a sandbox's `/` and `/dev` are its own, and nothing can be
* mounted over them.
*/
export function topPrefixes(prefixes) {
const tops = [];
for (const prefix of [...mountPaths(prefixes)].sort(compareCodePoints)) {
if (!tops.some((top) => prefix.startsWith(top + '/')))
tops.push(prefix);
}
return tops;
}
/** The sandbox command that lazily unmounts `prefix`. */
function unmountLine(prefix) {
const path = singleQuote(prefix);
return `fusermount3 -uz ${path} || fusermount -uz ${path}`;
}
function said(bytes) {
return bytes === null ? '' : DECODER.decode(bytes).trim();
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function within(task, ms) {
let timer;
try {
await Promise.race([
task,
new Promise((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`still running after ${String(ms)} ms`));
}, ms);
}),
]);
}
finally {
clearTimeout(timer);
}
}
/**
* The workspace prefixes one sandbox sees, by its `mountType`.
*
* `ssh`: every prefix the session sees is served from the host over SFTP,
* and the sandbox mounts each with `sshfs -o passive` on a pipe the
* provider holds open, so every file op in the sandbox is an op of the
* session, held to its hides, modes and policies. `fuse`: a mirage in the
* sandbox serves its prefixes itself and the host serves none. `both`: a
* prefix the sandbox already mounts is its own, and the host serves the
* rest. A prefix the sandbox serves itself is out of the session's rules'
* reach, so a line is refused while a hide, rule or mode cap covers
* anything under one, hidden mounts included.
*
* Each line first brings the mounts in step with the workspace: a mount
* added or shown since is served, one hidden is dropped, and a server
* that ended is started again. While a rule reaches under a prefix, the
* sandbox's own mounts are read again for each line, so one it mounted
* since is refused too. A server runs outside the line that started it,
* so it answers as the session alone and never with a grant that line was
* given. What the host serves shows one session's view, and a program a line starts may outlive it, so under `ssh` and
* `both` the sandbox belongs to the first session it serves or runs a line
* for, until it is closed: a line of another session is refused rather
* than shown, or showing, the first one's view, and so is a session made
* again under the same id, which a program the first one started could
* otherwise read through. Under `fuse` the host serves nothing, and any
* session may run.
*/
export class SandboxMounts {
runtime;
mountType;
run;
openPipe;
session = null;
// Each running line's write admission, and the served requests running on it.
lines = [];
served = new Map();
seen = null;
lock = Promise.resolve();
/**
* @param runtime the runtime's name, for messages.
* @param mountType ssh, fuse or both.
* @param run runs one setup command in the sandbox.
* @param openPipe starts a command in the sandbox with its stdin and
* stdout held open.
*/
constructor(runtime, mountType, run, openPipe) {
this.runtime = runtime;
this.mountType = mountType;
this.run = run;
this.openPipe = openPipe;
}
get fileEffectsDispatched() {
return this.mountType === 'ssh';
}
/**
* Bring the mounts in step, then refuse a line that may not run: one the
* session's restrictions reach past the dispatcher is refused as a policy
* refuses it (126), one the sandbox cannot serve because another session
* holds it fails (1). Null when the line may run.
*/
async admit(binding, signal) {
const files = binding?.files ?? null;
const getSession = binding?.getSession ?? null;
const mountPrefixes = binding?.mountPrefixes ?? null;
if (binding === null || files === null || getSession === null || mountPrefixes === null) {
throw new Error(`${this.runtime}: mountType needs the runtime in a workspace`);
}
const current = getCurrentSession();
const rules = current?.commands;
if (rules != null &&
[...rules.ask, ...rules.deny].some((rule) => (rule.commands?.length ?? 0) > 0 && ((rule.paths?.length ?? 0) > 0 || rule.mount)))
return refusedRun(this.runtime, 'this file transport cannot enforce program-specific path rules');
if (current === null) {
throw new Error(`${this.runtime}: mountType serves a session's view, and this line runs as none`);
}
const { stop, writeHeld } = currentCaller();
this.lines.push({ stop, writeHeld, lent: new Set() });
return this.locked(async () => {
const served = this.session;
const session = getSession(current.sessionId);
if (served !== null && served !== session) {
const earlier = served.sessionId === current.sessionId ? 'an earlier ' : '';
return {
stdout: new Uint8Array(),
stderr: new TextEncoder().encode(`${this.runtime}: this sandbox shows ${earlier}session ${served.sessionId}'s view ` +
`of the workspace; give session ${current.sessionId} a sandbox of its own\n`),
exitCode: 1,
};
}
const every = mountPaths(mountPrefixes());
const ruled = new Set(restrictedMountsFor(current, mountPrefixes()).map((prefix) => rstripSlash(prefix) || '/'));
await this.reconcile(binding, files, session, every, ruled, signal);
const unserved = this.unserved(ruled);
if (this.mountType !== 'fuse' && (!unserved || this.served.size > 0)) {
this.session = session;
}
return unserved
? refusedRun(this.runtime, 'a mount this session restricts is not served through the dispatcher')
: null;
});
}
/** Unmount every served prefix and end its SFTP server. */
close() {
return this.locked(async () => {
await this.unmount();
this.session = null;
});
}
/**
* Land every write a served prefix's server has acknowledged.
*
* sshfs answers close(2) once the file's writes are acknowledged and
* sends the SFTP close that lands them afterwards, so a line can end
* before they land. Its programs have closed their files by then, so
* this lands every one, outside the line as that close would, and
* `fileEffectsDispatched` holds. Each landing keeps the line's write
* admission alone: a capture waiting for the line lets it through rather
* than waiting on it. Then the line lends its admission to no more
* requests, and waits out the ones it lent it to, so a capture never
* reads past one still running.
*/
async sync() {
const { stop, writeHeld } = currentCaller();
try {
for (const { server } of [...this.served.values()]) {
await runDetached(() => runWithCaller({ ...currentCaller(), stop, writeHeld }, () => server.sync()));
}
}
finally {
const index = this.lines.findIndex((held) => held.stop === stop);
const [line] = index >= 0 ? this.lines.splice(index, 1) : [];
if (line !== undefined)
await Promise.all(line.lent);
}
}
/**
* Run one served request with the write admission of the latest line
* running in the sandbox, from its `admit` to its `sync`, and none
* between lines. A program's file op then counts as its line's, so a
* capture waiting for that line lets it land rather than holding it while
* the line waits on it.
*/
async lend(op) {
const line = this.lines.at(-1);
if (line === undefined)
return op();
let done = () => undefined;
const running = new Promise((resolve) => {
done = resolve;
});
line.lent.add(running);
try {
await runWithCaller({ ...currentCaller(), stop: line.stop, writeHeld: line.writeHeld }, op);
}
finally {
line.lent.delete(running);
done();
}
}
unserved(ruled) {
return [...ruled].some((prefix) => ![...this.served.keys()].some((p) => prefix === p || prefix.startsWith(p.replace(/\/$/, '') + '/')));
}
locked(task) {
const next = this.lock.then(task);
this.lock = next.catch(() => undefined);
return next;
}
async reconcile(binding, files, session, every, ruled, signal) {
const visible = this.mountType === 'fuse' ? [] : topPrefixes(binding.resolver.prefixes());
const seen = JSON.stringify([[...every].sort(compareCodePoints), visible]);
const ended = [...this.served].filter(([, entry]) => entry.ended).map(([prefix]) => prefix);
if (this.seen === seen &&
ended.length === 0 &&
(this.mountType === 'ssh' || ruled.size === 0)) {
return;
}
const present = await this.table(signal);
const own = [...every]
.filter((p) => this.mountType !== 'ssh' && present.has(p) && !this.served.has(p))
.sort(compareCodePoints);
const wanted = visible.filter((p) => !own.includes(p));
for (const prefix of wanted) {
const under = own.find((o) => o.startsWith(prefix + '/'));
if (under !== undefined) {
throw new Error(`${this.runtime}: the sandbox mounts ${under} itself, and serving ${prefix} would cover it`);
}
}
for (const prefix of [...this.served.keys()]) {
if (!wanted.includes(prefix) || ended.includes(prefix))
await this.drop(prefix);
}
const fresh = wanted.filter((p) => !this.served.has(p));
if (fresh.length > 0) {
const make = pipeServer;
if (make === null)
throw new Error(PIPE_SERVER_HINT);
if ((await this.run('command -v sshfs', signal)).exitCode !== 0) {
throw new Error(`${this.runtime}: ${SSHFS_HINT}`);
}
try {
for (const prefix of fresh) {
await this.serve(make(files, session, (op) => this.lend(op)), prefix, present, signal);
}
}
catch (err) {
await this.unmount();
throw err;
}
}
this.seen = seen;
}
async serve(server, prefix, present, signal) {
if (present.get(prefix) === 'fuse.sshfs')
await this.run(unmountLine(prefix), signal);
const path = singleQuote(prefix);
const made = await this.run(`mkdir -p ${path} 2>/dev/null || { sudo -n mkdir -p ${path} && ` +
`sudo -n chown "$(id -u):$(id -g)" ${path}; }`, signal);
if (made.exitCode !== 0) {
throw new Error(`${this.runtime}: cannot make ${prefix} in the sandbox: ${said(made.stderr)}`);
}
const log = singleQuote(`${SSHFS_LOG_PREFIX}_${crypto.randomUUID().replaceAll('-', '')}.log`);
const source = singleQuote(`mirage:${prefix}`);
const pipe = await this.openPipe(`exec sshfs -f -o ${SSHFS_OPTIONS} ${source} ${path} 2>${log}`);
const entry = { pipe, server, serving: Promise.resolve(), ended: false, failure: null };
entry.serving = Promise.resolve(runDetached(() => server.serve(pipe.reader, pipe.writer))).then(() => {
entry.ended = true;
}, (err) => {
entry.ended = true;
entry.failure = err instanceof Error ? err : new Error(String(err));
});
this.served.set(prefix, entry);
try {
const deadline = Date.now() + MOUNT_WAIT_SECONDS * 1000;
for (;;) {
try {
if ((await this.table(signal)).get(prefix) === 'fuse.sshfs')
return;
}
catch (error) {
signal?.throwIfAborted();
const diagnostic = (await pipe.diagnostic?.()) ?? '';
if (diagnostic) {
const said = error instanceof Error ? error.message : String(error);
throw new Error(`${this.runtime}: sshfs did not mount ${prefix}: ${said}; helper said: ${diagnostic}`, { cause: error });
}
throw error;
}
if (entry.ended || Date.now() > deadline) {
const diagnostic = (await pipe.diagnostic?.()) ?? '';
let reason = diagnostic;
try {
const written = await this.run(`cat ${log}`, signal);
const logText = said(written.stdout);
if (logText)
reason = logText + (diagnostic ? `; helper said: ${diagnostic}` : '');
}
catch (error) {
signal?.throwIfAborted();
console.debug(`${this.runtime}: reading the sshfs log failed`, error);
if (!reason)
reason = `cannot read the sshfs log: ${String(error)}`;
}
reason ||= 'no word from sshfs';
if (entry.failure !== null)
reason += `; the SFTP server failed: ${entry.failure.message}`;
throw new Error(`${this.runtime}: sshfs did not mount ${prefix}: ${reason}`);
}
signal?.throwIfAborted();
await sleep(MOUNT_POLL_SECONDS * 1000);
}
}
finally {
if (signal?.aborted !== true)
await this.dropLog(log);
}
}
/** Remove the helper's setup log; keeping it only costs a file. */
async dropLog(log) {
try {
const removed = await this.run(`rm -f -- ${log}`);
if (removed.exitCode !== 0)
console.debug('could not remove sshfs log:', said(removed.stderr));
}
catch (err) {
console.debug('could not remove sshfs log:', err);
}
}
async table(signal) {
const listed = await this.run('cat /proc/mounts', signal);
if (listed.exitCode !== 0) {
throw new Error(`${this.runtime}: cannot read the sandbox's mount table: ${said(listed.stderr)}`);
}
return mountTable(DECODER.decode(listed.stdout));
}
async drop(prefix) {
const entry = this.served.get(prefix);
if (entry === undefined)
return;
this.served.delete(prefix);
try {
const done = await this.run(unmountLine(prefix));
if (done.exitCode !== 0) {
console.warn(`${this.runtime}: unmounting ${prefix} in the sandbox failed: ${said(done.stderr)}`);
}
}
catch (err) {
console.warn(`${this.runtime}: unmounting ${prefix} in the sandbox failed:`, err);
}
try {
await entry.pipe.stop();
await within(entry.serving, UNMOUNT_WAIT_SECONDS * 1000);
if (entry.failure !== null)
throw entry.failure;
}
catch (err) {
console.warn(`${this.runtime}: ending the SFTP server for ${prefix} failed:`, err);
}
}
async unmount() {
for (const prefix of [...this.served.keys()])
await this.drop(prefix);
this.seen = null;
}
}
//# sourceMappingURL=mounts.js.map