@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
436 lines • 18.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 { strongestUnderSession } from "../../context/session_context.js";
import { withCaller } from "../files.js";
import { PolicyDenied } from "../../policy/errors.js";
import { MOUNT_MODE_RANK, MountMode } from "../../types.js";
import { isEacces, isStaleWrite } from "../../errors/fs.js";
import { TooManyMatches } from "../../shell/errors.js";
import { pathVisible } from "../../utils/hidden.js";
import { gnuDirname } from "../../utils/path.js";
import { singleQuote } from "../../utils/quote.js";
import { rstripSlash } from "../../utils/slash.js";
import { runs } from "../../policy/match/allow.js";
import { DEV_PREFIX } from "../mount/registry.js";
import { FileVersionTracker } from "./file_version.js";
import { decode, errorText, ioToStr, replaceText } from "./io_text.js";
import { mediaOf } from "./read_file.js";
/**
* Render a slice of a file the way the read tool reports it: split on
* newlines only, each line kept with its terminator. Mirrors Python's
* number_lines.
*/
function numberLines(text, offset, limit) {
const raw = text.length === 0 ? [] : text.split(/(?<=\n)/);
const lines = raw.length > 0 && raw[raw.length - 1] === '' ? raw.slice(0, -1) : raw;
const sliced = lines.slice(offset, offset + limit);
return sliced.map((line, i) => `${String(i + offset + 1).padStart(6)}\t${line}`).join('');
}
function textResult(text) {
return { content: [{ type: 'text', text }] };
}
function errorResult(text) {
return { content: [{ type: 'text', text }], isError: true };
}
function ioResult(io) {
const result = textResult(ioToStr(io));
if (io.exitCode !== 0)
result.isError = true;
return result;
}
export async function ensureParents(vfs, path) {
const parent = gnuDirname(path);
if (parent === '/' || parent === '' || parent === '.')
return;
if (await vfs.exists(parent))
return;
await ensureParents(vfs, parent);
try {
await vfs.mkdir(parent);
}
catch (err) {
if (!(await vfs.exists(parent)))
throw err;
}
}
/**
* Whether a path a read just failed on is absent, which picks the
* failure's wording. A probe the workspace refuses means the path is
* there: a hidden one answers absent, never refused. A probe that fails
* for any other filesystem reason proves nothing either way. In both
* cases the read's own error stands rather than the probe's.
*/
async function missing(vfs, path) {
try {
return !(await vfs.exists(path));
}
catch (err) {
// An errno-stamped error is the TypeScript shape of Python's OSError;
// anything else (an unknown session) is not a probe answer.
if (typeof err?.code !== 'string')
throw err;
// A policy refusal is routine (the policy that refused the read refuses
// the probe too); any other failure, a backend EACCES included, warns.
if (!(err instanceof PolicyDenied)) {
console.warn(`exists probe failed for ${path}: ${String(err)}`);
}
return false;
}
}
const DEFAULT_READ_LIMIT = 2000;
/** Every tool, in the order the entry points list them. */
export const TOOL_NAMES = ['shell', 'read', 'write', 'edit', 'ls', 'grep', 'glob'];
/**
* Whether a session may write anywhere: a mount it can see whose mode,
* narrowed by the profile or opened by a show entry below it, reaches
* write. `/dev` is left out: its null sink takes a write from anyone and
* stores nothing. Mirrors Python's `writes`.
*/
export function writes(session, mounts) {
return mounts.some((mount) => mount.prefix !== DEV_PREFIX &&
pathVisible(session.visibility, mount.prefix) &&
MOUNT_MODE_RANK[strongestUnderSession(session, mount.prefix, mount.mode)] >=
MOUNT_MODE_RANK[MountMode.WRITE]);
}
/**
* A file tool's call as the unit an op-level answer covers
* (`Decisions.withinCall`): an approval for a path runs every op the call
* makes on it, and the call's end spends it. `run` gets the session's
* files as that call, bound to the session by id (the default session's
* `vfs` is the unnamed facade), so each op carries it and a call running
* at the same time never claims this one's approval. The call is no
* line's, so its ops never read as a line's I/O, which skips the policy
* methods: without task isolation the caller in reach may be another
* task's line. Mirrors Python's `one_call`.
*/
function oneCall(session, run) {
return session.decisions.withinCall(session.sessionId, (handed) => run(withCaller(session.vfs.forSession(session.sessionId), {
call: [session.decisions, handed, session.sessionId],
refusals: null,
})));
}
/**
* The agent tools for one session, independent of any agent framework.
* `session.tools` is the session's own table. Every guarded table of a
* session shares the session's read history, so a read through one
* guards a write through another. Build one directly only to turn the
* guard off.
*/
export class MirageToolOperations {
session;
own;
/**
* @param session The session the tools act as, with its cwd,
* environment and mount grants.
* @param staleWriteProtection False lets an agent overwrite a file that
* changed since it read it.
*/
constructor(session, staleWriteProtection = true) {
this.session = session;
this.own = staleWriteProtection ? null : new FileVersionTracker(session.vfs, false);
}
/**
* The read history this call uses: the session's, which every guarded
* table of the session shares, or this table's own when the guard is
* off. A call keeps the one it started with, so a restore during the
* call cannot mix two histories.
*/
versions() {
return this.own !== null ? Promise.resolve(this.own) : this.session.reads();
}
/**
* The tools this session can use, in the order the entry points list them.
* Read off the session's profile, so no entry point offers a tool every call
* of which would be refused: `shell` needs a command the allow list
* installs, `ls` and `grep` run those commands and need them, and
* `write` and `edit` need somewhere the session may write. `read` and
* `glob` are always offered; what they cannot reach answers as the
* error it is. A session not loaded yet (a stored one before its first
* call) is offered every tool, since its profile is not known here; the
* policies still judge each call, and `offered` loads it first. Mirrors
* Python's `names`.
*/
names() {
let session;
try {
session = this.session.state;
}
catch (err) {
if (err instanceof Error && err.message.startsWith('unknown session:'))
return TOOL_NAMES;
throw err;
}
const allow = session.commands?.allow ?? null;
const writable = writes(session, this.session.mounts());
const offered = {
shell: allow === null || allow.length > 0,
read: true,
write: writable,
edit: writable,
ls: runs('ls', session.commands),
grep: runs('grep', session.commands),
glob: true,
};
return TOOL_NAMES.filter((name) => offered[name] === true);
}
/**
* The tools this session can use, its sessions loaded first, so a stored
* session answers with its own profile: what an async entry point lists and
* calls by. Mirrors Python's `offered`.
*/
async offered() {
await this.session.loaded();
return this.names();
}
lineOptions(signal) {
return signal === undefined ? {} : { signal };
}
/** Run a line in the session's shell; `signal` aborts it, as in-app. */
async shell(command, signal) {
return ioResult(await this.session.shell(command, this.lineOptions(signal)));
}
/** Read a file as line-numbered text; a relative path is taken under the session's cwd, spelled as given. */
read(given, offset = 0, limit = DEFAULT_READ_LIMIT) {
return oneCall(this.session, async (vfs) => {
const versions = (await this.versions()).through(vfs);
const path = given.startsWith('/') ? given : `${rstripSlash(this.session.state.cwd)}/${given}`;
let data;
try {
data = await versions.read(path);
}
catch (err) {
return this.readFailure(versions, path, err);
}
return this.numbered(versions, path, data, offset, limit);
});
}
/**
* Read a file for an entry point that hands media to the model, in one fetch:
* an image or a PDF comes back as media and counts as seen in full;
* anything else is the `read` answer for the same bytes. Either way
* the file is stamped for a later edit.
*/
async readMedia(given, offset = 0, limit = DEFAULT_READ_LIMIT) {
const versions = await this.versions();
const path = given.startsWith('/') ? given : `${rstripSlash(this.session.state.cwd)}/${given}`;
let data;
try {
data = await versions.read(path);
}
catch (err) {
return this.readFailure(versions, path, err);
}
const media = mediaOf(path, data);
if (media === undefined)
return this.numbered(versions, path, data, offset, limit);
versions.markSeen(path);
return media;
}
async readFailure(versions, path, err) {
if (await missing(versions.vfs, path)) {
return errorResult(`Error: file '${path}' not found`);
}
return errorResult(errorText(err));
}
numbered(versions, path, data, offset, limit) {
const text = decode(data);
const lines = (text.match(/\n/g)?.length ?? 0) + (text !== '' && !text.endsWith('\n') ? 1 : 0);
if (offset <= 0 && offset + limit >= lines)
versions.markSeen(path);
return textResult(numberLines(text, offset, limit));
}
/**
* Write a file; an existing one must have been read in full first. A
* new file is created with its missing parents. An existing one is
* overwritten only when the agent was shown all of it and it did not
* change since, so a write never clobbers text the agent has not seen. A
* relative path is taken under the session's cwd, spelled as given.
*/
write(given, content) {
return oneCall(this.session, async (vfs) => {
const versions = (await this.versions()).through(vfs);
const path = given.startsWith('/') ? given : `${rstripSlash(this.session.state.cwd)}/${given}`;
let present;
try {
present = await versions.vfs.exists(path);
}
catch (err) {
if (typeof err?.code !== 'string')
throw err;
return errorResult(errorText(err));
}
if (present && !versions.hasRead(path)) {
return errorResult(`Error: file '${path}' exists; read all of it before overwriting it`);
}
try {
await ensureParents(versions.vfs, path);
await versions.write(path, content);
}
catch (err) {
return errorResult(errorText(err));
}
return textResult(`Written: ${path}`);
});
}
/** Replace a string in an existing file; a relative path is taken under the session's cwd, spelled as given. */
edit(given, oldString, newString, replaceAll = false) {
return oneCall(this.session, async (vfs) => {
const versions = (await this.versions()).through(vfs);
const path = given.startsWith('/') ? given : `${rstripSlash(this.session.state.cwd)}/${given}`;
let content;
try {
content = decode(await versions.readForEdit(path));
}
catch (err) {
if (isStaleWrite(err))
return errorResult(errorText(err));
if (await missing(versions.vfs, path)) {
return errorResult(`Error: file '${path}' not found`);
}
return errorResult(errorText(err));
}
const [newContent, count] = replaceText(content, oldString, newString, replaceAll);
if (count === 0) {
return errorResult(`Error: string not found in file: '${oldString}'`);
}
if (count > 1 && !replaceAll) {
return errorResult(`Error: string appears ${String(count)} times. Pass replace_all=true`);
}
try {
await versions.writeEdit(path, newContent);
}
catch (err) {
return errorResult(errorText(err));
}
const occurrences = replaceAll ? count : 1;
return textResult(`Edited: ${path} (${String(occurrences)} occurrence(s))`);
});
}
async ls(path, signal) {
return ioResult(await this.session.shell(`ls ${singleQuote(path)}`, this.lineOptions(signal)));
}
/**
* Search recursively for a pattern, as `grep -rn` does. Each option is
* the GNU grep flag of the same name, and the line runs in the
* session's shell, so the search is the shell's own: the same policy,
* push-down and history as typing it. grep exits 1 when nothing
* matched, an empty answer rather than a failure, so only an exit above
* 1 (a bad regex, an unreadable path) is a tool error.
*/
async grep(pattern, path, options = {}, signal) {
const words = ['grep', '-rn'];
if (options.ignoreCase === true)
words.push('-i');
if (options.fixedStrings === true)
words.push('-F');
if (options.filesWithMatches === true)
words.push('-l');
if (options.count === true)
words.push('-c');
if (options.maxCount !== undefined)
words.push('-m', String(options.maxCount));
if (options.context !== undefined)
words.push('-C', String(options.context));
if (options.include !== undefined)
words.push(singleQuote(`--include=${options.include}`));
words.push('-e', singleQuote(pattern), singleQuote(path));
const io = await this.session.shell(words.join(' '), this.lineOptions(signal));
const result = textResult(ioToStr(io));
if (io.exitCode > 1)
result.isError = true;
return result;
}
/**
* Find files, not directories, whose path matches a pattern. The
* pattern is expanded by `Session.glob`, the shell's own resolver:
* `**` matches any number of directories, and a relative pattern is
* matched under `path`. A symlink to a file counts; a dangling one does
* not, nor does a match the workspace refuses to stat, since nothing
* says what it is. Any other failure propagates rather than pass for a
* short list.
*/
glob(pattern, path = '/') {
return oneCall(this.session, async (vfs) => {
const full = pattern.startsWith('/') || path === ''
? pattern
: path.endsWith('/')
? `${path}${pattern}`
: `${path}/${pattern}`;
let matches;
try {
matches = await this.session.glob(full);
}
catch (err) {
if (err instanceof TooManyMatches)
return errorResult(err.reason);
if (!isEacces(err))
throw err;
matches = [];
}
const files = [];
for (const match of matches) {
try {
if (await vfs.isFile(match))
files.push(match);
}
catch (err) {
if (!isEacces(err))
throw err;
}
}
return textResult(files.map((match) => `${match}\n`).join(''));
});
}
/**
* Run one tool by name with its JSON input. The one entry every entry point
* shares: MCP, the HTTP routes, the CLI and the agent adapters hand a
* tool's name and its input, as the tool's `*_INPUT` schema reads it,
* to this method, so each tool answers the same way through each of
* them. Throws for a name no tool has, or one the session's profile does
* not offer (`offered`). Mirrors Python's `call`.
*/
async call(name, args, signal) {
if (TOOL_NAMES.includes(name) &&
!(await this.offered()).includes(name)) {
throw new Error(`unknown tool: ${name}`);
}
switch (name) {
case 'shell':
return this.shell(args.command, signal);
case 'read':
return this.read(args.path, args.offset ?? 0, args.limit ?? DEFAULT_READ_LIMIT);
case 'write':
return this.write(args.path, args.content);
case 'edit':
return this.edit(args.path, args.old_string, args.new_string, args.replace_all ?? false);
case 'ls':
return this.ls(args.path, signal);
case 'grep':
return this.grep(args.pattern, args.path, {
ignoreCase: args.ignore_case,
fixedStrings: args.fixed_strings,
include: args.include,
context: args.context,
filesWithMatches: args.files_with_matches,
count: args.count,
maxCount: args.max_count,
}, signal);
case 'glob':
return this.glob(args.pattern, args.path ?? '/');
default:
throw new Error(`unknown tool: ${name}`);
}
}
}
//# sourceMappingURL=tool_operations.js.map