UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

436 lines • 18.8 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 { 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