UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

425 lines • 18.6 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 { 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