feat(runtime): sandbox runtimes with FUSE-mounted workspaces (#590)

* feat(resource): generic remote_mount_spec so cloud backends are sandbox-mountable

Lift the S3-only remote_mount_spec to the base class: any backend that
opts in with remotely_mountable and holds a pydantic config serializes
{resource, config} (credentials unwrapped) so a remote mirage, e.g.
inside a sandbox, can reconstruct and mount it. RAM and disk keep the
None default. Enables s3, gdrive and slack; adds base coverage.

* feat(runtime): sandbox runtimes with FUSE-mounted workspaces

Add the RemoteSandbox base and the Daytona, e2b and Docker runtimes:
whole-line execution against a remote or local container, lazy
provisioning on the first line, reattach by id, and sandbox ownership so
teardown only touches what we created.

The workspace becomes visible by running mirage inside the sandbox and
FUSE-mounting each remotable mount live: the host serializes the mounts
into the public workspace config, writes .mirage-workspace.json, and runs
one command, `mirage workspace create`. The CLI auto-spawns the in-sandbox
daemon and mounts synchronously, so the exit code is the ready signal and
stderr carries the error. Reads and writes flow both ways with no sync;
writes reach the backend on file close. Needs an image with mirage baked
in (e.g. mirage-python-fuse).

* feat(sandbox): translate virtual mount paths onto provider mountpoints

The agent speaks virtual paths (/data/a.py); mirage is the control
plane that rewrites them onto each provider's physical mountpoint
(/home/daytona/workspace/data/a.py on Daytona, /workspace/data/a.py on
Docker). Before, only cwd was rebased, so absolute virtual paths broke.

Rewrite is longest-prefix-first and only touches tokens that start
exactly at a mount (/s3 or /s3/...); siblings (/s3.txt), system paths
(/usr/bin), and relative paths are left alone. Each mount is also
exported as a MIRAGE_<PREFIX> env var for paths built at runtime. A
bare / world mount is skipped (it would capture the sandbox's own /usr).

Verified end to end on a real Daytona sandbox with an absolute
/data/in.txt round trip.

* docs(examples): README for the Daytona FUSE runtime example

Documents the working flow: bake the mirage-fuse snapshot once, then
drive an S3-backed workspace whose python3 lines run in a Daytona
sandbox that FUSE-mounts the bucket live. Explains the control-plane
path translation (virtual /data/x rewritten to the sandbox mountpoint).

Fixes the workspace yaml: drops the removed mount: fuse/copy option
(fuse is now the only sandbox mode) and points the active runtime entry
at the mirage-fuse snapshot so the example actually mounts.

* fix(sandbox): typecheck fallback for env tuple; remove RAM runtime example

The env-var test's ?? fallback widened env to {}, failing tsc
(TS2339 on MIRAGE_DATA). Type the fallback like the sibling cwd test.

Remove sandbox_runtime.py: it mounted RAM, which is not
remotely_mountable, so under the fuse-only sandbox path its first
python3 line is rejected. The Daytona example (S3-backed) is the
supported demo.

* feat(docker): bake all backends into the sandbox image, not just s3

The sandbox image install was pinned to mirage-ai[s3,fuse], so a
workspace mounting any other backend failed in-sandbox for lack of its
deps. Install mirage-ai[all,fuse] via a MIRAGE_EXTRAS build arg
(default all) so one image mounts any backend; narrow the arg or extend
FROM the image for a lean build. Verified: [all,fuse] resolves under
pip and postgres/mongodb/gcs import in the built image (1.74 GB).

* feat(docker): sandbox image installs mountable backends only, no agent deps

Add a curated 'sandbox' extra (every mountable backend + fuse) and
default the Dockerfile to it, instead of 'all'. A sandbox is a
filesystem host: it never builds agents or launches other sandboxes,
so the agent frameworks (anthropic/openai/deepagents/openhands/agno/
claude-agent-sdk/pydantic-ai) and provider SDKs (daytona/e2b) that
'all' pulls do not belong. mem0 is excluded too: mem0ai is the lone
backend that hard-requires the openai client.

Verified in the built image: no openai/anthropic/daytona/e2b/mem0,
backends (s3/postgres/mongodb/chroma) and fuse still import. Image
1.74 GB (all) -> 947 MB. Narrow further with --build-arg MIRAGE_EXTRAS
or extend FROM the image.

* docs: document sandbox runtimes (docker/daytona/e2b)

Add a Sandbox page to the Runtimes section for both the Python and
TypeScript docs, wired into docs.json nav. Covers capture-based
routing, the live FUSE-mounted workspace, control-plane virtual-path
translation, the mirage-python-fuse image + MIRAGE_EXTRAS, per-provider
setup, reattach/lifecycle, and resource limits. The existing runtime
pages document the in-process interpreters (monty/wasi/pyodide/local);
this covers the remote whole-line runtimes.

* refactor(resource): unify remote flag, rename remotely_mountable -> remote

One boolean now answers 'does this backend live remotely', replacing
both the verbose remotely_mountable and qdrant's dead is_remote/isRemote
one-off. remote=True unlocks reconstructing the resource elsewhere (FUSE
mount inside a sandbox) via the generic remote_mount_spec. Set on s3,
gdrive, slack, qdrant.

* docs: add cache-invalidation example to invalidate_all_after_remote

Concrete cat -> sandbox-write -> cat example showing why a sandbox line
forces a full local cache reset.

* feat(sandbox): reconcile mounts imperatively, drop the workspace config file

mirage is the control plane: the host workspace is the desired state,
the sandbox is the actual state, and every captured line reconciles the
two through the provider's own exec API. A new or changed mount runs
'mirage mount add <prefix> --fuse <path>' inside the sandbox with the
spec in the exec environment (never a file, never argv), a dropped
mount runs 'mirage mount remove <prefix>', unchanged mounts cost
nothing. Mounts added or removed after the sandbox booted converge on
the next line.

New in-sandbox CLI 'mirage mount add/remove/list': each prefix becomes
its own single-mount daemon workspace (deterministic id), so mounts
attach and detach independently through the existing create/delete
endpoints. The uploaded .mirage-workspace.json and one-shot
mount_workspace are gone; TS mirrors the reconciler (syncMounts,
serialized per line).

Real docker e2e green: reconciled mount add, absolute-path read, FUSE
write-through to S3, host readback.

* test(qdrant): follow the is_remote -> remote rename

* ci(integ): survive chocolatey outages in the WinFsp install

Retry choco three times, fall back to the official WinFsp GitHub
release MSI, and verify winfsp-x64.dll landed so a bad install fails
at the install step instead of as 'Unable to find libfuse' mid-test.
The advisory integ-fuse-windows job went red on a chocolatey.org 503.

* refactor(sandbox): mount once, run lines verbatim, drop path magic

Remove the line translation, the MIRAGE_<prefix> env injection, and
the per-line reconcile state. The contract is now plain: the sandbox
mounts the workspace's backends once at boot (mirage mount add per
mount, spec in the exec env), mounts appear at
<workspace_root>/<prefix>, the session cwd is rebased, and the line
runs verbatim. Path consistency beyond the rebased cwd is the
caller's job; static rewriting could never be complete (quoted code,
runtime-built paths) and half-working magic is worse than none.

Docs and the daytona example teach the relative-path contract. Real
docker e2e green: relative read and write through the mounted bucket
with rebased cwd.

* refactor(sandbox): one general SandboxConfig, shared constants, provider packages

* refactor(sandbox): provider-owned configs, spec-derived mounts, remote sweep

* refactor(sandbox): connect-only runtimes, one in-sandbox workspace

* refactor(sandbox): drop the lazy provider re-exports

* refactor(sandbox): user-provisioned sandboxes, connect and exec only

* fix(sandbox): safeguard whole lines, per-invocation stdin paths, trim sandbox extra

* refactor(safeguard): one resolve_safeguard entry point, shared guard_output boundary
This commit is contained in:
Zecheng Zhang
2026-07-29 15:25:22 -07:00
committed by GitHub
parent bf076359ed
commit d5d08eb541
79 changed files with 4697 additions and 122 deletions
+123
View File
@@ -0,0 +1,123 @@
# Daytona runtime + Mirage FUSE
Run whole `python3` lines inside a [Daytona](https://www.daytona.io) cloud
sandbox, with an S3 bucket mounted live inside the sandbox. Mirage runs **in the
sandbox** and FUSE-mounts S3 there, so the job reads and writes `/data/...` as
local files and every write streams straight back to the bucket, no sync step.
Unlike the [`microsandbox`](../microsandbox/README.md) and
[`wasmer`](../wasmer/README.md) examples (which share a **host** FUSE mount into
a guest), here the guest has its own `/dev/fuse` and runs Mirage itself.
## How it works
```
your machine (control plane) Daytona sandbox (yours)
Workspace: /data -> S3Resource provisioned by you:
captures ["python3"] -> DaytonaRuntime --> mirage workspace create sandbox.yaml
vfs runs every other line locally -> FUSE-mounts S3 at /data
cd /data; python3 train.py ------------> cwd passes through; train.py reads /data
```
1. The workspace declares `/data` as an `S3Resource`. A `DaytonaRuntime` captures
`python3` lines; everything else stays on the local vfs.
1. Mirage never creates, provisions, or deletes sandboxes: you create one
(below), provision the workspace inside it (`create_sandbox.py` does both:
it uploads a sandbox-side config with the same mount at the same prefix and
runs `mirage workspace create` in the sandbox), and hand the runtime its
`sandbox_id`. Mirage only connects and execs lines.
1. The sandbox serves the same prefixes as the host, so the line, its cwd, and
every path pass through verbatim: `/data` means the same thing on both
sides, relative or absolute.
## Prerequisites
- **Daytona SDK**: installed with `mirage-ai[daytona]` (`uv sync` in `python/`
already pulls it via the example extras).
- **`DAYTONA_API_KEY`** in `.env.development` at the repo root. Building the
snapshot (below) also needs snapshot-write scope on the key.
- **AWS credentials** in `.env.development`: `AWS_S3_BUCKET`,
`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and optionally
`AWS_DEFAULT_REGION`. The bucket is reachable from Daytona's cloud.
- A stock Daytona sandbox already ships `/dev/fuse` and `fusermount3`, so no
privileged flags are needed. It does **not** ship Mirage, so the sandbox needs
an image or snapshot with `fuse3` and `mirage-ai[s3,fuse]` baked in.
## One-time: bake the FUSE snapshot
Building the image inline would sit in the first line's path for minutes. Bake it
once into a named snapshot (`mirage-fuse`); creating a sandbox from it then takes
seconds. Re-run after a Mirage release to refresh the baked package.
```bash
./python/.venv/bin/python examples/python/runtimes/daytona/prebake_snapshot.py
```
## Run: the workspace runtime (CLI)
Create and provision a sandbox from the snapshot (prints its id; provisioning
status goes to stderr), then wire the workspace to it. From the repo root:
```bash
set -a; source .env.development; set +a
export DAYTONA_SANDBOX_ID=$(./python/.venv/bin/python \
examples/python/runtimes/daytona/create_sandbox.py)
mirage workspace create examples/python/runtimes/daytona/daytona_workspace.yaml --id daytona-demo
printf 'print("hello from the sandbox")\n' \
| mirage execute -w daytona-demo -c 'cat > /data/hello.py'
# Same prefix on both sides, so the path passes through verbatim.
mirage execute -w daytona-demo -c 'cd /data && python3 hello.py'
mirage workspace delete daytona-demo # the sandbox stays yours
```
The sandbox is yours to keep or delete (`daytona sandbox delete`, the
dashboard, or just let the idle-stop/auto-delete timers set by
`create_sandbox.py` clean it up).
The `cat > /data/hello.py` write runs on the local vfs and lands in S3; the
`python3` line then reads the same file inside the sandbox through the live
mount.
## Run: standalone SDK demos (no snapshot needed)
Two self-contained scripts drive the Daytona SDK directly and build their image
inline (slower first boot, but nothing to prebake):
```bash
# Sandbox runs Mirage and FUSE-mounts S3, then reads /s3 natively.
./python/.venv/bin/python examples/python/runtimes/daytona/daytona_fuse.py
# Same, but Mirage reads S3 through its vfs API with no FUSE mount.
./python/.venv/bin/python examples/python/runtimes/daytona/daytona_vfs.py
```
`daytona_fuse.py` expects the bucket to contain `data/example.jsonl`. Expected
tail:
```
=== remote output ===
FUSE mountpoint: /home/daytona/.../s3
--- native os.listdir() against FUSE path ---
data
--- native open() reads through FUSE ---
size: NNNN bytes
head:
...
```
## GPU sandboxes
Sizing, GPUs, and lifecycle are Daytona settings you pick when you create the
sandbox, not mirage options. For a GPU box, create it from an image sized with
`Resources(gpu=...)` (Daytona requires an image for per-sandbox resources) with
`fuse3` + `mirage-ai[s3,fuse]` baked in, then hand mirage its id as usual.
## Notes
- Not run in CI. It needs a Daytona account, a baked snapshot, and live AWS
credentials.
- Lifecycle safety net: `create_sandbox.py` sets idle-stop after 10 minutes and
auto-delete 30 minutes later, so a forgotten demo box cleans itself up.
@@ -0,0 +1,87 @@
# ========= 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. =========
# Create a sandbox from the mirage-fuse snapshot, provision the
# in-sandbox workspace (S3 FUSE-mounted at /data, the same prefix the
# host workspace uses), and print the sandbox id. The sandbox is
# yours: mirage only connects to it, so delete it when done
# (`daytona sandbox delete <id>` or the dashboard). The lifecycle
# knobs below are the safety net for a forgotten demo box.
import asyncio
import os
import sys
from typing import Any
from daytona import AsyncDaytona, CreateSandboxFromSnapshotParams
from dotenv import load_dotenv
load_dotenv(".env.development")
SNAPSHOT_NAME = "mirage-fuse"
# The sandbox-side workspace config: same mount, same prefix as
# daytona_workspace.yaml, FUSE-mounted at its own prefix so host and
# sandbox paths mean the same thing.
WORKSPACE_YAML = """\
mounts:
/data:
resource: s3
config:
bucket: {bucket}
region: {region}
aws_access_key_id: {key_id}
aws_secret_access_key: {secret}
key_prefix: mirage-daytona-cli-demo
fuse: /data
"""
async def provision(sandbox: Any) -> None:
config = WORKSPACE_YAML.format(bucket=os.environ["AWS_S3_BUCKET"],
region=os.environ.get(
"AWS_DEFAULT_REGION", "us-east-1"),
key_id=os.environ["AWS_ACCESS_KEY_ID"],
secret=os.environ["AWS_SECRET_ACCESS_KEY"])
await sandbox.fs.upload_file(config.encode(), "/tmp/sandbox.yaml")
commands = (
"sudo mkdir -p /data && sudo chown daytona /data",
"mirage workspace create /tmp/sandbox.yaml",
)
for command in commands:
response = await sandbox.process.exec(command)
if int(response.exit_code) != 0:
raise RuntimeError(
f"provisioning failed ({command}): {response.result}")
print("provisioned: S3 FUSE-mounted at /data in the sandbox",
file=sys.stderr)
async def main() -> None:
client = AsyncDaytona()
try:
sandbox = await client.create(
CreateSandboxFromSnapshotParams(
snapshot=SNAPSHOT_NAME,
auto_stop_interval=10,
auto_delete_interval=30,
))
await provision(sandbox)
print(sandbox.id)
finally:
await client.close()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,45 @@
# S3-centered workspace with a Daytona whole-line runtime.
#
# Mirage never creates, provisions, or deletes sandboxes: create one
# yourself from the mirage-fuse snapshot (prebake_snapshot.py builds
# it once; create_sandbox.py creates the sandbox AND provisions the
# in-sandbox workspace), then point the runtime at its id.
#
# set -a; source .env.development; set +a
# export DAYTONA_SANDBOX_ID=$(./python/.venv/bin/python \
# examples/python/runtimes/daytona/create_sandbox.py)
# mirage workspace create daytona_workspace.yaml --id daytona-demo
# printf 'print("hello from the sandbox")\n' \
# | mirage execute -w daytona-demo -c 'cat > /data/hello.py'
# mirage execute -w daytona-demo -c 'cd /data && python3 hello.py'
# mirage workspace delete daytona-demo # the sandbox stays yours
#
# Captured lines (python3, pip) run whole inside the Daytona sandbox;
# every other command stays on the local vfs. The sandbox serves the
# same mount at the same prefix (create_sandbox.py FUSE-mounts S3 at
# /data inside it), so the line, its cwd, and every path pass through
# verbatim: /data means the same thing on both sides.
#
# Sizing, GPUs, and lifecycle (idle-stop, auto-delete) are Daytona
# settings you pick when you create the sandbox; mirage never touches
# them.
mode: EXEC
mounts:
/data:
resource: s3
config:
bucket: ${AWS_S3_BUCKET}
region: ${AWS_DEFAULT_REGION}
aws_access_key_id: ${AWS_ACCESS_KEY_ID}
aws_secret_access_key: ${AWS_SECRET_ACCESS_KEY}
key_prefix: mirage-daytona-cli-demo
runtimes:
- name: daytona
captures: ["python3", "pip"]
config:
sandbox_id: ${DAYTONA_SANDBOX_ID} # a sandbox you created and provisioned
api_key: ${DAYTONA_API_KEY}
- vfs
@@ -0,0 +1,55 @@
# ========= 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. =========
# Build the "mirage-fuse" Daytona snapshot once. Sandbox creation from
# a snapshot takes seconds; building this image inline would sit in
# the first captured line's path for many minutes. Run again after a
# mirage release to refresh the baked package.
import asyncio
from daytona import AsyncDaytona, CreateSnapshotParams, Image, Resources
from dotenv import load_dotenv
load_dotenv(".env.development")
SNAPSHOT_NAME = "mirage-fuse"
MIRAGE_GIT_SPEC = (
"mirage-ai[s3,fuse] @ "
"git+https://github.com/strukto-ai/mirage.git#subdirectory=python")
async def main() -> None:
client = AsyncDaytona()
image = (Image.debian_slim("3.12").run_commands(
"apt-get update "
"&& apt-get install -y --no-install-recommends "
" git fuse3 libfuse3-dev "
"&& sed -i 's/^#user_allow_other/user_allow_other/' /etc/fuse.conf "
"&& rm -rf /var/lib/apt/lists/*").pip_install(MIRAGE_GIT_SPEC))
try:
snapshot = await client.snapshot.create(
CreateSnapshotParams(name=SNAPSHOT_NAME,
image=image,
resources=Resources(cpu=1, memory=1, disk=3)),
on_logs=lambda line: print(f" build: {line}"),
timeout=0)
print(f"snapshot ready: {snapshot.name}")
finally:
await client.close()
if __name__ == "__main__":
asyncio.run(main())