@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
234 lines • 8.24 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 { 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