UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

234 lines • 8.24 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 { PipeFiles } from "../../core/dev/mixin.js"; import { liveSessions } from "../../context/session_context.js"; import { enoent, eacces, einval, fsError } from "../../errors/fs.js"; import { stripSlash } from "../../utils/slash.js"; const DEV_NAMES = new Set(['null', 'zero']); // Real backing store plus a synthetic /null, /zero overlay. The synthetic // device names read as empty/zeros and swallow writes until they are // deleted (GNU: `rm /dev/null` succeeds and the path is gone). A deleted // name is tombstoned; the next write stores real bytes, which is GNU's // rm-then-redirect recreation as a regular file. // // A descriptor row holds a pipe and no bytes: reading or writing it as bytes // is EINVAL, so an op that forgets its pipe branch fails instead of using // bytes no reader sees, and removing it is EPERM (GNU: `rm /dev/fd/63`), // since only the scope that opened it releases it. Membership and listing // still see it, and iteration counts it as empty, a FIFO storing nothing. export class DevFiles extends PipeFiles { boundOwner; state; constructor(boundOwner, state = { files: new Map(), dirs: new Set(['/']), tombstones: new Set(), inputs: new Map(), nextAllocation: 0, }) { super(); this.boundOwner = boundOwner; this.state = state; } forSession(owner) { return new DevFiles(owner, this.state); } pipeOf(key) { const row = this.state.inputs.get(key); return row?.owner === this.owner() ? row.pipe : null; } owner() { if (this.boundOwner !== undefined) return this.boundOwner; const sessions = liveSessions(); const owner = sessions[0]?.sessionId; // The browser fallback cannot distinguish overlapping sessions: fail closed. return owner !== undefined && sessions.every((session) => session.sessionId === owner) ? owner : null; } holdsPipe(key) { const under = key.replace(/\/+$/, '') + '/'; return [...this.visibleInputs().keys()].some((k) => k === key || k.startsWith(under)); } visibleInputs() { const owner = this.owner(); return new Map([...this.state.inputs] .filter(([, row]) => owner !== null && row.owner === owner) .map(([key, row]) => [key, row.pipe])); } allocateInput(pipe) { const owner = this.owner(); if (owner === null) throw eacces('/dev/fd'); let fd = 63; while (this.state.inputs.has(`/fd/${String(fd)}`)) fd -= 1; const key = `/fd/${String(fd)}`; const allocation = this.state.nextAllocation++; this.state.inputs.set(key, { owner, allocation, pipe }); return [`/dev${key}`, allocation]; } releaseInput(path, allocation) { const row = this.state.inputs.get(path.slice(4)); if (row?.allocation !== allocation) return false; return this.state.inputs.delete(path.slice(4)); } syntheticActive(name) { return (DEV_NAMES.has(name) && !this.state.tombstones.has(name) && !this.state.files.has('/' + name)); } syntheticBytes(_name) { // Content is served by the device reader. Store iteration stays finite // and size-neutral so generic metadata operations never allocate zeros. return new Uint8Array(0); } deviceOf(key) { const name = stripSlash(key); return this.syntheticActive(name) ? name : null; } has(key) { if (key.startsWith('/fd/')) return this.visibleInputs().has(key); return this.state.files.has(key) || this.syntheticActive(stripSlash(key)); } get(key) { if (key.startsWith('/fd/')) { if (this.pipeOf(key) !== null) throw einval(`/dev${key}`); return undefined; } if (this.state.files.has(key)) return this.state.files.get(key); const name = stripSlash(key); if (this.syntheticActive(name)) return this.syntheticBytes(name); return undefined; } set(key, value) { if (key === '/fd' || key.startsWith('/fd/')) { if (this.pipeOf(key) === null) throw enoent(`/dev${key}`); throw einval(`/dev${key}`); } const name = stripSlash(key); if (this.syntheticActive(name)) return this; this.state.files.set(key, value); this.state.tombstones.delete(name); return this; } delete(key) { if (key.startsWith('/fd/')) { if (this.pipeOf(key) === null) return false; throw fsError(`/dev${key}`, 'EPERM'); } const name = stripSlash(key); if (this.state.files.has(key)) { this.state.files.delete(key); if (DEV_NAMES.has(name)) this.state.tombstones.add(name); return true; } if (this.syntheticActive(name)) { this.state.tombstones.add(name); return true; } return false; } clear() { /* no-op: synthetic devices cannot be cleared */ } get size() { let synthetic = 0; for (const name of ['null', 'zero']) { if (this.syntheticActive(name)) synthetic += 1; } return synthetic + this.state.files.size + this.visibleInputs().size; } *keys() { for (const [k] of this.entries()) yield k; } *values() { for (const [, v] of this.entries()) yield v; } *entries() { for (const name of ['null', 'zero']) { if (this.syntheticActive(name)) yield ['/' + name, this.syntheticBytes(name)]; } yield* this.state.files.entries(); for (const key of this.visibleInputs().keys()) yield [key, new Uint8Array()]; } [Symbol.iterator]() { return this.entries(); } forEach(callback, thisArg) { for (const [k, v] of this.entries()) { callback.call(thisArg, v, k, this); } } } class DevDirs extends Set { files; constructor(files) { super(); this.files = files; } get size() { return this.files.state.dirs.size + (this.has('/fd') ? 1 : 0); } has(key) { return key === '/fd' ? this.files.visibleInputs().size > 0 : this.files.state.dirs.has(key); } *[Symbol.iterator]() { yield* this.files.state.dirs; if (this.has('/fd')) yield '/fd'; } add(key) { if (key === '/fd' || key.startsWith('/fd/')) throw eacces(`/dev${key}`); this.files.state.dirs.add(key); return this; } delete(key) { if (key === '/fd' || key.startsWith('/fd/')) throw eacces(`/dev${key}`); return this.files.state.dirs.delete(key); } } export class DevStore { files; modified; attrs; dirs; /** Never counted: a capacity refuses `/dev`. Read as RAMStore's. */ usage = null; constructor(files = new DevFiles(), modified = new Map(), attrs = new Map()) { this.files = files; this.modified = modified; this.attrs = attrs; this.dirs = new DevDirs(files); } forSession(owner) { return new DevStore(this.files.forSession(owner), this.modified, this.attrs); } } //# sourceMappingURL=store.js.map