bcbd1bdb22
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
482 lines
18 KiB
Python
482 lines
18 KiB
Python
# ========= 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 asyncio
|
|
import errno
|
|
import os
|
|
import stat
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from dataclasses import asdict, dataclass, field
|
|
|
|
try:
|
|
import mfusepy as fuse
|
|
except ImportError:
|
|
fuse = None
|
|
|
|
from mirage.bridge.sync import run_async_from_sync
|
|
from mirage.fuse.platform.macos import is_macos_metadata
|
|
from mirage.ops import Ops
|
|
from mirage.types import FileType
|
|
|
|
_ENV_AGENT_ID = "MIRAGE_AGENT_ID"
|
|
_MIRAGE_DIR = "/.mirage"
|
|
_MIRAGE_WHOAMI = "/.mirage/whoami"
|
|
# "attribute not found" errno: ENOATTR on macOS, ENODATA on Linux.
|
|
_NO_XATTR = getattr(errno, "ENOATTR", None) or errno.ENODATA
|
|
# Base class only when mfusepy is installed; otherwise the module still imports
|
|
# (FUSE is the optional [fuse] extra) but instantiating MirageFS raises.
|
|
_FUSE_OPERATIONS = fuse.Operations if fuse is not None else object
|
|
# How long prefetched bytes for size-unknown files outlive their handle, so a
|
|
# release-then-stat burst (ls right after cat) neither refetches nor reports
|
|
# an unknown size. Mirrors the TS PREFETCH_TTL_MS.
|
|
PREFETCH_TTL = 30.0
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Handle:
|
|
path: str
|
|
data: bytes | None = None
|
|
write_buf: list[tuple[int, bytes]] = field(default_factory=list)
|
|
|
|
|
|
class MirageFS(_FUSE_OPERATIONS):
|
|
|
|
use_ns = True
|
|
|
|
def __init__(self,
|
|
ops: Ops,
|
|
agent_id: str | None = None,
|
|
root_prefix: str = "") -> None:
|
|
if fuse is None:
|
|
raise RuntimeError("FUSE support requires the 'fuse' extra: "
|
|
'install "mirage-ai[fuse]"')
|
|
self._ops = ops
|
|
self.agent_id = (agent_id or os.environ.get(_ENV_AGENT_ID)
|
|
or f"agent-{uuid.uuid4().hex[:8]}")
|
|
self._now = time.time_ns()
|
|
self._root = root_prefix.rstrip("/")
|
|
# When scoped to a single mount, the FUSE root maps onto that mount and
|
|
# there are no virtual intermediate directories to synthesize.
|
|
if self._root:
|
|
self._prefixes = []
|
|
else:
|
|
self._prefixes = self._ops.mount_prefixes()
|
|
self._handles: dict[int, Handle] = {}
|
|
# Prefetched content for size-unknown files: path -> (data, expiry).
|
|
self._prefetch: dict[str, tuple[bytes, float]] = {}
|
|
# In-memory extended attributes, keyed by FUSE path. Backends have no
|
|
# POSIX xattrs, so these are advisory, not persisted (see setxattr).
|
|
self._xattrs: dict[str, dict[str, bytes]] = {}
|
|
self._next_fh = 1
|
|
self._loop = asyncio.new_event_loop()
|
|
self._loop_thread = threading.Thread(target=self._loop.run_forever,
|
|
daemon=True)
|
|
self._loop_thread.start()
|
|
|
|
def _run(self, coro):
|
|
return run_async_from_sync(coro, self._loop)
|
|
|
|
def _resolve(self, path: str) -> str:
|
|
"""Map a FUSE path onto the workspace, honoring the mount root."""
|
|
if not self._root:
|
|
return path
|
|
if path == "/":
|
|
return self._root
|
|
return self._root + path
|
|
|
|
def _whoami_content(self) -> bytes:
|
|
mounts = self._ops.mount_prefixes()
|
|
lines = [
|
|
f"agent: {self.agent_id}",
|
|
"cwd: /",
|
|
f"mounts: {', '.join(mounts)}",
|
|
]
|
|
return ("\n".join(lines) + "\n").encode()
|
|
|
|
def _dir_stat(self) -> dict:
|
|
return {
|
|
"st_mode": stat.S_IFDIR | 0o755,
|
|
"st_nlink": 2,
|
|
"st_uid": os.getuid(),
|
|
"st_gid": os.getgid(),
|
|
"st_size": 0,
|
|
"st_atime": self._now,
|
|
"st_mtime": self._now,
|
|
"st_ctime": self._now,
|
|
}
|
|
|
|
def _file_stat(self, size: int) -> dict:
|
|
return {
|
|
"st_mode": stat.S_IFREG | 0o644,
|
|
"st_nlink": 1,
|
|
"st_uid": os.getuid(),
|
|
"st_gid": os.getgid(),
|
|
"st_size": size,
|
|
"st_atime": self._now,
|
|
"st_mtime": self._now,
|
|
"st_ctime": self._now,
|
|
}
|
|
|
|
def _is_virtual_dir(self, path: str) -> bool:
|
|
normalized = path.rstrip("/") + "/"
|
|
for p in self._prefixes:
|
|
if p.startswith(normalized) or p.rstrip("/") == path.rstrip("/"):
|
|
return True
|
|
return False
|
|
|
|
def _virtual_children(self, path: str) -> list[str]:
|
|
normalized = path.rstrip("/") + "/" if path != "/" else "/"
|
|
children = set()
|
|
for p in self._prefixes:
|
|
if p.startswith(normalized) and p != normalized:
|
|
rest = p[len(normalized):]
|
|
child = rest.split("/")[0]
|
|
if child:
|
|
children.add(child)
|
|
return sorted(children)
|
|
|
|
def drain_ops(self) -> list[dict]:
|
|
records = [asdict(r) for r in self._ops.records]
|
|
self._ops.records.clear()
|
|
return records
|
|
|
|
def _cached_data(self, path: str) -> bytes | None:
|
|
"""Return prefetched bytes from open handles or the TTL cache.
|
|
|
|
Args:
|
|
path (str): FUSE path to look up.
|
|
|
|
Returns:
|
|
bytes | None: cached content, or None when nothing fresh is held.
|
|
"""
|
|
for ctx in self._handles.values():
|
|
if ctx.path == path and ctx.data is not None:
|
|
return ctx.data
|
|
entry = self._prefetch.get(path)
|
|
if entry is None:
|
|
return None
|
|
data, expires = entry
|
|
if time.monotonic() >= expires:
|
|
del self._prefetch[path]
|
|
return None
|
|
return data
|
|
|
|
def _cached_size(self, path: str) -> int | None:
|
|
"""Return the real size of prefetched data, if any is cached.
|
|
|
|
Args:
|
|
path (str): FUSE path to look up.
|
|
|
|
Returns:
|
|
int | None: byte length of cached content, or None.
|
|
"""
|
|
data = self._cached_data(path)
|
|
return len(data) if data is not None else None
|
|
|
|
def _prefetch_read(self, path: str) -> bytes | None:
|
|
"""Fetch and cache the bytes of a size-unknown file.
|
|
|
|
Args:
|
|
path (str): FUSE path being opened.
|
|
|
|
Returns:
|
|
bytes | None: file content, or None when the backend read fails
|
|
(open() stays permissive; the subsequent read() surfaces the
|
|
error to the caller).
|
|
"""
|
|
data = self._cached_data(path)
|
|
if data is not None:
|
|
return data
|
|
try:
|
|
data = self._run(self._ops.read(self._resolve(path)))
|
|
except (FileNotFoundError, ValueError):
|
|
return None
|
|
# No inflight dedup: the mount runs nothreads=True, so FUSE callbacks
|
|
# are serialized and two opens cannot race (TS needs the dedup map).
|
|
self._prefetch[path] = (data, time.monotonic() + PREFETCH_TTL)
|
|
return data
|
|
|
|
def getattr(self, path: str, fh=None) -> dict:
|
|
# fstat(fd) after open: answer with the hydrated handle's real byte
|
|
# length. attr_timeout=0 on the mount makes the kernel actually ask
|
|
# here instead of trusting the cached pre-open size, which is what
|
|
# keeps wc -c, BSD cp, and tail -c correct for size-unknown files.
|
|
if fh is not None:
|
|
ctx = self._handles.get(fh)
|
|
if ctx is not None and ctx.path == path and ctx.data is not None:
|
|
return self._file_stat(len(ctx.data))
|
|
if path == "/":
|
|
return self._dir_stat()
|
|
if path == _MIRAGE_DIR:
|
|
return self._dir_stat()
|
|
if path == _MIRAGE_WHOAMI:
|
|
return self._file_stat(len(self._whoami_content()))
|
|
# macOS Finder/Spotlight probes .DS_Store, ._*, .Spotlight-V100, etc.
|
|
# Reject early to avoid hitting the ops layer.
|
|
name = path.rsplit("/", 1)[-1]
|
|
if is_macos_metadata(name):
|
|
raise fuse.FuseOSError(errno.ENOENT)
|
|
if self._is_virtual_dir(path):
|
|
return self._dir_stat()
|
|
try:
|
|
s = self._run(self._ops.stat(self._resolve(path)))
|
|
if s.type == FileType.DIRECTORY:
|
|
return self._dir_stat()
|
|
size = s.size
|
|
if size is None:
|
|
size = self._cached_size(path)
|
|
if size is None:
|
|
# Unopened size-unknown files stat as 0, matching mirage's own
|
|
# find semantics. Reads stay correct anyway: direct_io makes
|
|
# the kernel ignore st_size, and the fh branch above serves
|
|
# the real size to fstat-based tools after open. Never report
|
|
# a fake size and never fetch content here: getattr runs once
|
|
# per entry on every ls -l.
|
|
size = 0
|
|
return self._file_stat(size)
|
|
except (FileNotFoundError, ValueError):
|
|
pass
|
|
raise fuse.FuseOSError(errno.ENOENT)
|
|
|
|
def readdir(self, path: str, fh) -> list:
|
|
if path == _MIRAGE_DIR:
|
|
return [".", "..", "whoami"]
|
|
names = set(self._virtual_children(path))
|
|
if path == "/":
|
|
names.add(".mirage")
|
|
try:
|
|
entries = self._run(self._ops.readdir(self._resolve(path)))
|
|
for e in entries:
|
|
part = e.rstrip("/").rsplit("/", 1)[-1]
|
|
if part and not is_macos_metadata(part):
|
|
names.add(part)
|
|
except (FileNotFoundError, ValueError):
|
|
if not names:
|
|
raise fuse.FuseOSError(errno.ENOENT)
|
|
return [".", ".."] + sorted(names)
|
|
|
|
def read(self, path: str, size: int, offset: int, fh) -> bytes:
|
|
if path == _MIRAGE_WHOAMI:
|
|
data = self._whoami_content()
|
|
return data[offset:offset + size]
|
|
ctx = self._handles.get(fh)
|
|
if ctx is not None and ctx.data is not None:
|
|
return ctx.data[offset:offset + size]
|
|
try:
|
|
data = self._cached_data(path)
|
|
if data is None:
|
|
data = self._run(self._ops.read(self._resolve(path)))
|
|
if ctx is not None:
|
|
ctx.data = data
|
|
return data[offset:offset + size]
|
|
except (FileNotFoundError, ValueError):
|
|
raise fuse.FuseOSError(errno.ENOENT)
|
|
|
|
def write(self, path: str, data: bytes, offset: int, fh) -> int:
|
|
ctx = self._handles.get(fh)
|
|
if ctx is not None:
|
|
ctx.write_buf.append((offset, data))
|
|
return len(data)
|
|
try:
|
|
existing = b""
|
|
try:
|
|
existing = self._run(self._ops.read(self._resolve(path)))
|
|
except FileNotFoundError:
|
|
pass
|
|
if offset > len(existing):
|
|
existing = existing + b"\0" * (offset - len(existing))
|
|
new_data = existing[:offset] + data + existing[offset + len(data):]
|
|
self._run(self._ops.write(self._resolve(path), new_data))
|
|
self._prefetch.pop(path, None)
|
|
return len(data)
|
|
except PermissionError:
|
|
raise fuse.FuseOSError(errno.EACCES)
|
|
except ValueError:
|
|
raise fuse.FuseOSError(errno.ENOENT)
|
|
|
|
def create(self, path: str, mode, fi=None) -> int:
|
|
try:
|
|
self._run(self._ops.create(self._resolve(path)))
|
|
except PermissionError:
|
|
raise fuse.FuseOSError(errno.EACCES)
|
|
except ValueError:
|
|
raise fuse.FuseOSError(errno.ENOENT)
|
|
self._prefetch.pop(path, None)
|
|
fh = self._next_fh
|
|
self._next_fh += 1
|
|
self._handles[fh] = Handle(path=path)
|
|
return fh
|
|
|
|
def mkdir(self, path: str, mode) -> None:
|
|
try:
|
|
self._run(self._ops.mkdir(self._resolve(path)))
|
|
except PermissionError:
|
|
raise fuse.FuseOSError(errno.EACCES)
|
|
except ValueError:
|
|
raise fuse.FuseOSError(errno.ENOENT)
|
|
|
|
def unlink(self, path: str) -> None:
|
|
try:
|
|
self._run(self._ops.unlink(self._resolve(path)))
|
|
except PermissionError:
|
|
raise fuse.FuseOSError(errno.EACCES)
|
|
except FileNotFoundError:
|
|
raise fuse.FuseOSError(errno.ENOENT)
|
|
self._xattrs.pop(path, None)
|
|
self._prefetch.pop(path, None)
|
|
|
|
def rename(self, old: str, new: str, flags: int = 0) -> None:
|
|
try:
|
|
self._run(self._ops.rename(self._resolve(old), self._resolve(new)))
|
|
except PermissionError:
|
|
raise fuse.FuseOSError(errno.EACCES)
|
|
except (FileNotFoundError, ValueError):
|
|
raise fuse.FuseOSError(errno.ENOENT)
|
|
moved = self._xattrs.pop(old, None)
|
|
if moved is not None:
|
|
self._xattrs[new] = moved
|
|
self._prefetch.pop(old, None)
|
|
self._prefetch.pop(new, None)
|
|
|
|
def rmdir(self, path: str) -> None:
|
|
try:
|
|
self._run(self._ops.rmdir(self._resolve(path)))
|
|
except PermissionError:
|
|
raise fuse.FuseOSError(errno.EACCES)
|
|
except OSError:
|
|
raise fuse.FuseOSError(errno.ENOTEMPTY)
|
|
except (FileNotFoundError, ValueError):
|
|
raise fuse.FuseOSError(errno.ENOENT)
|
|
self._xattrs.pop(path, None)
|
|
|
|
def statfs(self, path: str) -> dict:
|
|
return {
|
|
"f_bsize": 4096,
|
|
"f_frsize": 4096,
|
|
"f_blocks": 1024 * 1024,
|
|
"f_bfree": 1024 * 1024,
|
|
"f_bavail": 1024 * 1024,
|
|
"f_files": 1000000,
|
|
"f_ffree": 1000000,
|
|
"f_favail": 1000000,
|
|
"f_namemax": 255,
|
|
}
|
|
|
|
def chmod(self, path: str, mode) -> None:
|
|
self.getattr(path)
|
|
|
|
def chown(self, path: str, uid: int, gid: int) -> None:
|
|
self.getattr(path)
|
|
|
|
def utimens(self, path: str, times=None) -> None:
|
|
self.getattr(path)
|
|
|
|
def access(self, path: str, amode: int) -> None:
|
|
self.getattr(path)
|
|
|
|
def setxattr(self,
|
|
path: str,
|
|
name: str,
|
|
value: bytes,
|
|
options: int,
|
|
position: int = 0) -> int:
|
|
# Mirage backends (S3, etc.) have no POSIX extended attributes, so
|
|
# there is nothing to persist xattrs to. We keep them in memory per
|
|
# mount so tools that probe or set xattrs (sandbox runtimes, rsync
|
|
# -aX, tar --xattrs, cp -p, macOS Finder writing com.apple.*) succeed
|
|
# instead of failing with ENOTSUP. The values live only for the
|
|
# mount's lifetime and are intentionally not written to the backend.
|
|
self.getattr(path)
|
|
self._xattrs.setdefault(path, {})[name] = bytes(value)
|
|
return 0
|
|
|
|
def getxattr(self, path: str, name: str, position: int = 0) -> bytes:
|
|
self.getattr(path)
|
|
attrs = self._xattrs.get(path)
|
|
if attrs is None or name not in attrs:
|
|
raise fuse.FuseOSError(_NO_XATTR)
|
|
return attrs[name]
|
|
|
|
def listxattr(self, path: str) -> list[str]:
|
|
self.getattr(path)
|
|
return list(self._xattrs.get(path, {}).keys())
|
|
|
|
def removexattr(self, path: str, name: str) -> int:
|
|
self.getattr(path)
|
|
self._xattrs.get(path, {}).pop(name, None)
|
|
return 0
|
|
|
|
def flush(self, path: str, fh) -> None:
|
|
ctx = self._handles.get(fh)
|
|
if ctx is not None:
|
|
if not ctx.write_buf:
|
|
return
|
|
try:
|
|
existing = b""
|
|
try:
|
|
existing = self._run(self._ops.read(self._resolve(path)))
|
|
except FileNotFoundError:
|
|
pass
|
|
merged = bytearray(existing)
|
|
for off, chunk in ctx.write_buf:
|
|
end = off + len(chunk)
|
|
if end > len(merged):
|
|
merged.extend(b"\0" * (end - len(merged)))
|
|
merged[off:off + len(chunk)] = chunk
|
|
self._run(self._ops.write(self._resolve(path), bytes(merged)))
|
|
ctx.write_buf = []
|
|
self._prefetch.pop(path, None)
|
|
except PermissionError:
|
|
raise fuse.FuseOSError(errno.EACCES)
|
|
return
|
|
|
|
def fsync(self, path: str, datasync: int, fh) -> None:
|
|
self.flush(path, fh)
|
|
|
|
def open(self, path: str, flags) -> int:
|
|
if path == _MIRAGE_WHOAMI:
|
|
fh = self._next_fh
|
|
self._next_fh += 1
|
|
self._handles[fh] = Handle(path=path)
|
|
return fh
|
|
try:
|
|
s = self._run(self._ops.stat(self._resolve(path)))
|
|
except (FileNotFoundError, ValueError):
|
|
raise fuse.FuseOSError(errno.ENOENT)
|
|
ctx = Handle(path=path)
|
|
if s.size is None and s.type != FileType.DIRECTORY:
|
|
# API resources cannot size a file without fetching it, so hydrate
|
|
# now: getattr(fh) and read() then serve real bytes, and the TTL
|
|
# cache keeps release-then-stat bursts from refetching.
|
|
ctx.data = self._prefetch_read(path)
|
|
fh = self._next_fh
|
|
self._next_fh += 1
|
|
self._handles[fh] = ctx
|
|
return fh
|
|
|
|
def release(self, path: str, fh) -> int:
|
|
self._handles.pop(fh, None)
|
|
return 0
|
|
|
|
def truncate(self, path: str, length: int, fh=None) -> None:
|
|
try:
|
|
self._run(self._ops.truncate(self._resolve(path), length))
|
|
except PermissionError:
|
|
raise fuse.FuseOSError(errno.EACCES)
|
|
except ValueError:
|
|
raise fuse.FuseOSError(errno.ENOENT)
|
|
self._prefetch.pop(path, None)
|