Compare commits

...

22 Commits

Author SHA1 Message Date
Zecheng Zhang 755260d7f6 refactor(sandbox): user-provisioned sandboxes, connect and exec only 2026-07-28 18:22:57 -07:00
Zecheng Zhang 509e28b790 refactor(sandbox): drop the lazy provider re-exports 2026-07-28 17:45:36 -07:00
Zecheng Zhang 5e4090cd08 refactor(sandbox): connect-only runtimes, one in-sandbox workspace 2026-07-28 17:34:21 -07:00
Zecheng Zhang b261fea2eb refactor(sandbox): provider-owned configs, spec-derived mounts, remote sweep 2026-07-28 16:57:55 -07:00
Zecheng Zhang 335e6c9cc5 refactor(sandbox): one general SandboxConfig, shared constants, provider packages 2026-07-28 16:20:38 -07:00
Zecheng Zhang e40bb167b6 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.
2026-07-28 15:41:55 -07:00
Zecheng Zhang b10a943a42 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.
2026-07-28 15:15:38 -07:00
Zecheng Zhang 986ed19e9c test(qdrant): follow the is_remote -> remote rename 2026-07-28 04:13:39 -07:00
Zecheng Zhang 89ec4e7132 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.
2026-07-28 03:50:28 -07:00
Zecheng Zhang 14383595e5 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.
2026-07-28 03:00:32 -07:00
Zecheng Zhang 860e81d703 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.
2026-07-28 03:00:02 -07:00
Zecheng Zhang da18759510 Merge remote-tracking branch 'origin/main' into feat/remote-sandbox
# Conflicts:
#	python/uv.lock
2026-07-28 02:27:49 -07:00
Zecheng Zhang e9d5b84df4 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.
2026-07-27 04:32:03 -07:00
Zecheng Zhang 1a856552ed 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.
2026-07-27 03:24:49 -07:00
Zecheng Zhang 8f2ccc6496 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).
2026-07-27 03:09:07 -07:00
Zecheng Zhang 9a1c1778ec Merge remote-tracking branch 'origin/main' into feat/remote-sandbox 2026-07-27 00:38:14 -07:00
Zecheng Zhang 829aaf6b1e Merge origin/main into feat/remote-sandbox 2026-07-25 16:21:04 -07:00
Zecheng Zhang 8abadfc4ff 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.
2026-07-20 21:12:17 -07:00
Zecheng Zhang 550c626eb7 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.
2026-07-20 17:54:36 -07:00
Zecheng Zhang c0df14d354 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.
2026-07-20 17:50:30 -07:00
Zecheng Zhang ef068e630a 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).
2026-07-20 16:59:28 -07:00
Zecheng Zhang 73bd24e905 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.
2026-07-20 16:59:18 -07:00
66 changed files with 4326 additions and 64 deletions
+6
View File
@@ -0,0 +1,6 @@
*
!python
python/.venv
python/**/__pycache__
python/.mypy_cache
python/.pytest_cache
+20 -1
View File
@@ -804,8 +804,27 @@ jobs:
steps:
- uses: actions/checkout@v7
# Retry choco (its community feed 503s), then fall back to the
# official GitHub release MSI, and verify the driver landed either
# way so a bad install fails here, not as "Unable to find libfuse".
- name: Install WinFsp
run: choco install winfsp -y
shell: pwsh
run: |
$ok = $false
foreach ($i in 1..3) {
choco install winfsp -y
if ($LASTEXITCODE -eq 0) { $ok = $true; break }
Write-Host "choco attempt $i failed; retrying"
Start-Sleep -Seconds 10
}
if (-not $ok) {
Write-Host "choco unavailable; installing from the GitHub release MSI"
Invoke-WebRequest -Uri "https://github.com/winfsp/winfsp/releases/download/v2.1/winfsp-2.1.25156.msi" -OutFile winfsp.msi
Start-Process msiexec.exe -ArgumentList '/i','winfsp.msi','/qn' -Wait
}
if (-not (Test-Path "C:\Program Files (x86)\WinFsp\bin\winfsp-x64.dll")) {
throw "WinFsp install failed: winfsp-x64.dll not found"
}
- name: Set up Python
uses: actions/setup-python@v7
+32
View File
@@ -0,0 +1,32 @@
# Mirage sandbox base image, built from the repo checkout so it always
# matches the code under test (no release required).
#
# docker build -f docker/sandbox/Dockerfile --target fuse \
# -t mirage-python-fuse .
#
# The fuse target installs the `sandbox` extra: every mountable backend
# plus fuse3, so one image can FUSE-mount whatever backend a workspace
# declares, not just S3. It deliberately excludes the agent frameworks
# and the sandbox-provider SDKs (daytona/e2b) that `all` pulls in: a
# sandbox is a filesystem host, it never builds agents or launches other
# sandboxes. Usable as-is for local docker (run with --cap-add SYS_ADMIN
# --device /dev/fuse), as a Daytona image/snapshot source, and as an E2B
# template base.
#
# Narrow the extras to just the backends you mount for a leaner image:
# docker build -f docker/sandbox/Dockerfile --target fuse \
# --build-arg MIRAGE_EXTRAS=s3,postgres -t mirage-fuse-lean .
# Or extend this image as a base: FROM mirage-python-fuse, then
# pip install any additional extra.
FROM python:3.12-slim AS fuse
ARG MIRAGE_EXTRAS=sandbox
RUN apt-get update \
&& apt-get install -y --no-install-recommends fuse3 libfuse3-dev \
&& rm -rf /var/lib/apt/lists/* \
&& echo user_allow_other >> /etc/fuse.conf
COPY python /src/python
RUN pip install --no-cache-dir "/src/python[${MIRAGE_EXTRAS},fuse]" && rm -rf /src
+4 -2
View File
@@ -393,7 +393,8 @@
"group": "Runtimes",
"pages": [
"python/runtime/python",
"python/runtime/javascript"
"python/runtime/javascript",
"python/runtime/sandbox"
]
}
]
@@ -569,7 +570,8 @@
"group": "Runtimes",
"pages": [
"typescript/runtime/python",
"typescript/runtime/javascript"
"typescript/runtime/javascript",
"typescript/runtime/sandbox"
]
}
]
+203
View File
@@ -0,0 +1,203 @@
---
title: Sandbox
description: Run captured lines whole inside a remote sandbox (Docker, Daytona, or e2b) you provision yourself.
icon: server
---
The `monty`, `wasi`, and `local` runtimes run `python3` in-process on your
machine. A **sandbox runtime** does the opposite: it takes a whole command
line and runs it inside a remote machine, a local Docker container or a
cloud sandbox. Reach for it when a line needs a real OS, heavy packages, or
a GPU that the in-process interpreters cannot give it.
Three sandbox runtimes ship today, all with the same surface:
| Runtime | Connects to | Config |
| --- | --- | --- |
| `docker` | A container you started (`docker run`) | `container` |
| `daytona` | A [Daytona](https://www.daytona.io) sandbox you created | `sandbox_id`, `api_key` |
| `e2b` | An [e2b](https://e2b.dev) sandbox you created | `sandbox_id`, `api_key` |
## Routing: what goes to the sandbox
A sandbox runtime does not claim every line. Each runtime **captures** a set
of commands; a captured line runs in the sandbox, everything else stays on
the local vfs. You list the runtimes in order, and `vfs` is the catch-all:
```yaml
runtimes:
- name: daytona
captures: ["python3", "pip"] # these run in the sandbox
- vfs # ls, grep, cat, ... stay local
```
So `grep`, `cat`, and `ls` run locally as always, while `python3 train.py`
runs on the cloud box. Mirage never creates, provisions, or deletes
sandboxes: **you bring your own** (a running container, a live Daytona or
E2B sandbox), the first captured line connects to it, and every captured
line execs inside it verbatim.
## The workspace inside the sandbox
For the job to see your mounts as ordinary files, the sandbox must serve
the workspace itself, and provisioning that is yours, like everything else
about the sandbox. Run Mirage inside it, with the **same mounts at the
same prefixes** as the host workspace, each FUSE-mounted at its own
prefix:
```yaml
# sandbox.yaml, inside the sandbox
mounts:
/data:
resource: s3
config:
bucket: my-bucket
aws_access_key_id: ...
aws_secret_access_key: ...
fuse: /data
```
```bash
mirage workspace create sandbox.yaml # entrypoint, or once by hand
```
A read then pulls from the backend and a write streams straight back to
it, with no upload or sync step: inside the sandbox, `/data` backed by S3
is a real directory. Because you write the sandbox-side config, it can
differ from the host's where it should: the endpoint that is
`127.0.0.1:9000` on your laptop is `host.docker.internal:9000` from inside
a container, credentials can be scoped down, and none of it ever travels
over the provider's exec API. The flip side is that keeping the prefixes
in step with the host workspace is your job; a sandbox serving different
mounts fails loud only when a path misses.
This needs an image with `fuse3` and Mirage plus your backends installed
(see [The sandbox image](#the-sandbox-image)).
## Paths inside the sandbox
Mirage rewrites nothing: the line, its cwd, and every path in it pass
through verbatim. With the sandbox serving the same prefixes, `cd /data`
then `python3 train.py` works, and so does an absolute path like
`python3 /data/train.py`, because `/data` means the same thing on both
sides.
## The sandbox image
The sandbox needs `fuse3` plus Mirage with the backends you mount. The repo
ships a Dockerfile that builds this image from the current checkout, so it
always matches your code:
```bash
docker build -f docker/sandbox/Dockerfile --target fuse -t mirage-python-fuse .
```
By default it installs the `sandbox` extra, every mountable backend plus
fuse. Narrow it to just the backends you use for a smaller image, or extend
it as a base:
```bash
docker build -f docker/sandbox/Dockerfile --target fuse \
--build-arg MIRAGE_EXTRAS=s3,postgres -t mirage-fuse-lean .
```
The one image is the shared base for all three providers: run it directly
under Docker, use it as a Daytona `image`/`snapshot` source, or as an e2b
template base.
## Docker
Start the container yourself, provision the workspace inside it, then
point `DockerRuntime` at it. Live FUSE mounts need `--cap-add SYS_ADMIN
--device /dev/fuse` (Linux hosts) and the fuse image:
```bash
docker run -d --cap-add SYS_ADMIN --device /dev/fuse \
--name my-sandbox mirage-python-fuse sleep infinity
docker cp sandbox.yaml my-sandbox:/tmp/sandbox.yaml # or bake it into the image
docker exec my-sandbox mirage workspace create /tmp/sandbox.yaml
```
```python
from mirage import MountMode, Workspace
from mirage.accessor.s3 import S3Config
from mirage.resource.s3.s3 import S3Resource
from mirage.runtime.sandbox.docker import DockerRuntime
runtime = DockerRuntime(captures=["python3"],
config={"container": "my-sandbox"})
ws = Workspace({"/data": S3Resource(S3Config(bucket="my-bucket"))},
mode=MountMode.EXEC,
runtimes=[runtime, "vfs"])
await ws.execute("python3 job.py", cwd="/data")
```
Every sandbox runtime takes a `config`: how to reach the sandbox. Each
provider's config carries exactly the fields that provider needs, and an
unknown field fails loud. The docker CLI is the transport (Docker Desktop,
colima, or a podman alias), so there is no SDK and no daemon socket
wiring; the container gets real stdin and separated stderr. Sizing, GPUs,
networks, and binds are all yours: pass them to your own `docker run`.
## Daytona and e2b
Create the sandbox with the provider's own tooling (dashboard, CLI, or
SDK), boot it from an image or snapshot carrying the fuse image, provision
the workspace inside it (the provider's exec or a terminal session), and
hand mirage the id:
```yaml
runtimes:
- name: daytona
captures: ["python3", "pip"]
config:
sandbox_id: ${DAYTONA_SANDBOX_ID}
api_key: ${DAYTONA_API_KEY}
- vfs
```
They need the matching extra:
```bash
pip install mirage-ai[daytona] # or mirage-ai[e2b]
```
Sizing, GPUs, snapshots, and lifecycle (idle-stop, auto-delete) are all
provider concerns you set when you create the sandbox; mirage never
touches them. Closing the workspace releases the SDK client and leaves the
sandbox running. A
[worked Daytona example](https://github.com/strukto-ai/mirage/tree/main/examples/python/runtimes/daytona)
covers the one-time snapshot bake, sandbox creation, in-sandbox
provisioning, and a full read/write round trip.
## Selecting in YAML
Sandbox runtimes are ordinary `runtimes` entries: a name, its captures, and
a `config` block describing the machine (mirroring a mount's `config`
block), with `vfs` as the in-process catch-all. Only the selected runtime
consumes its entry, so one file stays portable:
```yaml
runtimes:
- name: docker
captures: ["python3"]
config:
container: my-sandbox # started with your own `docker run`
- vfs
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}
```
## Resource limits
A captured line is a command like any other: the same `command_safeguards`
that guard `cat` or `grep` guard `python3`, including in the sandbox. A run
that exceeds `timeout_seconds` answers exit 124; `max_bytes` and `max_lines`
cap its output the same way. There is no sandbox-specific limit surface.
+203
View File
@@ -0,0 +1,203 @@
---
title: Sandbox
description: Run captured lines whole inside a remote sandbox (Docker, Daytona, or e2b) you provision yourself.
icon: server
---
The `pyodide` and `monty` runtimes run `python3` in-process on your machine.
A **sandbox runtime** does the opposite: it takes a whole command line and
runs it inside a remote machine, a local Docker container or a cloud
sandbox. Reach for it when a line needs a real OS, heavy packages, or a GPU
that the in-process interpreters cannot give it.
Three sandbox runtimes ship from `@struktoai/mirage-node`, all with the same
surface:
| Runtime | Connects to | Config |
| --- | --- | --- |
| `docker` | A container you started (`docker run`) | `container` |
| `daytona` | A [Daytona](https://www.daytona.io) sandbox you created | `sandboxId`, `apiKey` |
| `e2b` | An [e2b](https://e2b.dev) sandbox you created | `sandboxId`, `apiKey` |
## Routing: what goes to the sandbox
A sandbox runtime does not claim every line. Each runtime **captures** a set
of commands; a captured line runs in the sandbox, everything else stays on
the local vfs. You list the runtimes in order, and `vfs` is the catch-all:
```yaml
runtimes:
- name: daytona
captures: ["python3", "pip"] # these run in the sandbox
- vfs # ls, grep, cat, ... stay local
```
So `grep`, `cat`, and `ls` run locally as always, while `python3 train.py`
runs on the cloud box. Mirage never creates, provisions, or deletes
sandboxes: **you bring your own** (a running container, a live Daytona or
E2B sandbox), the first captured line connects to it, and every captured
line execs inside it verbatim.
## The workspace inside the sandbox
For the job to see your mounts as ordinary files, the sandbox must serve
the workspace itself, and provisioning that is yours, like everything else
about the sandbox. Run Mirage inside it, with the **same mounts at the
same prefixes** as the host workspace, each FUSE-mounted at its own
prefix:
```yaml
# sandbox.yaml, inside the sandbox
mounts:
/data:
resource: s3
config:
bucket: my-bucket
aws_access_key_id: ...
aws_secret_access_key: ...
fuse: /data
```
```bash
mirage workspace create sandbox.yaml # entrypoint, or once by hand
```
A read then pulls from the backend and a write streams straight back to
it, with no upload or sync step: inside the sandbox, `/data` backed by S3
is a real directory. Because you write the sandbox-side config, it can
differ from the host's where it should: the endpoint that is
`127.0.0.1:9000` on your laptop is `host.docker.internal:9000` from inside
a container, credentials can be scoped down, and none of it ever travels
over the provider's exec API. The flip side is that keeping the prefixes
in step with the host workspace is your job; a sandbox serving different
mounts fails loud only when a path misses.
This needs an image with `fuse3` and Mirage plus your backends installed
(see [The sandbox image](#the-sandbox-image)). The in-sandbox mount is
served by the Python Mirage build, so the image is the same for both
language SDKs.
## Paths inside the sandbox
Mirage rewrites nothing: the line, its cwd, and every path in it pass
through verbatim. With the sandbox serving the same prefixes, `cd /data`
then `python3 train.py` works, and so does an absolute path like
`python3 /data/train.py`, because `/data` means the same thing on both
sides.
## The sandbox image
The sandbox needs `fuse3` plus Mirage with the backends you mount. The repo
ships a Dockerfile that builds this image from the current checkout, so it
always matches your code:
```bash
docker build -f docker/sandbox/Dockerfile --target fuse -t mirage-python-fuse .
```
By default it installs every mountable backend plus fuse. Narrow it to just
the backends you use for a smaller image, or extend it as a base:
```bash
docker build -f docker/sandbox/Dockerfile --target fuse \
--build-arg MIRAGE_EXTRAS=s3,postgres -t mirage-fuse-lean .
```
The one image is the shared base for all three providers: run it directly
under Docker, use it as a Daytona `image`/`snapshot` source, or as an e2b
template base.
## Docker
Start the container yourself, provision the workspace inside it, then
point `DockerRuntime` at it. Live FUSE mounts need `--cap-add SYS_ADMIN
--device /dev/fuse` (Linux hosts) and the fuse image:
```bash
docker run -d --cap-add SYS_ADMIN --device /dev/fuse \
--name my-sandbox mirage-python-fuse sleep infinity
docker cp sandbox.yaml my-sandbox:/tmp/sandbox.yaml # or bake it into the image
docker exec my-sandbox mirage workspace create /tmp/sandbox.yaml
```
```ts
import { MountMode, S3Resource, Workspace } from '@struktoai/mirage-node'
import { DockerRuntime } from '@struktoai/mirage-node'
const runtime = new DockerRuntime({
captures: ['python3'],
config: { container: 'my-sandbox' },
})
const ws = new Workspace(
{ '/data': new S3Resource({ bucket: 'my-bucket' }) },
{ mode: MountMode.EXEC, runtimes: [runtime, 'vfs'] },
)
await ws.execute('python3 job.py', { cwd: '/data' })
```
Every sandbox runtime takes a `config`: how to reach the sandbox. Each
provider's config carries exactly the fields that provider needs, and an
unknown field fails loud. The docker CLI is the transport (Docker Desktop,
colima, or a podman alias), so there is no SDK and no daemon socket
wiring; the container gets real stdin and separated stderr. Sizing, GPUs,
networks, and binds are all yours: pass them to your own `docker run`.
## Daytona and e2b
Create the sandbox with the provider's own tooling (dashboard, CLI, or
SDK), boot it from an image or snapshot carrying the fuse image, provision
the workspace inside it (the provider's exec or a terminal session), and
hand mirage the id:
```yaml
runtimes:
- name: daytona
captures: ["python3", "pip"]
config:
sandboxId: ${DAYTONA_SANDBOX_ID}
apiKey: ${DAYTONA_API_KEY}
- vfs
```
They need the matching provider SDK:
```bash
pnpm add @daytonaio/sdk # or: pnpm add e2b
```
Sizing, GPUs, snapshots, and lifecycle (idle-stop, auto-delete) are all
provider concerns you set when you create the sandbox; mirage never
touches them. Closing the workspace releases the SDK client and leaves the
sandbox running.
## Selecting in YAML
Sandbox runtimes are ordinary `runtimes` entries: a name, its captures, and
a `config` block describing the machine (mirroring a mount's `config`
block), with `vfs` as the in-process catch-all. Only the selected runtime
consumes its entry, so one file stays portable:
```yaml
runtimes:
- name: docker
captures: ["python3"]
config:
container: my-sandbox # started with your own `docker run`
- vfs
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}
```
## Resource limits
A captured line is a command like any other: the same `command_safeguards`
that guard `cat` or `grep` guard `python3`, including in the sandbox. A run
that exceeds `timeoutSeconds` answers exit 124; `maxBytes` and `maxLines`
cap its output the same way. There is no sandbox-specific limit surface.
+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())
+6
View File
@@ -40,6 +40,10 @@ def execute_cmd(
"--session",
"-s",
help="Session id."),
cwd: str | None = typer.Option(
None,
"--cwd",
help="Working directory for this line (a workspace path)."),
background: bool = typer.Option(
False,
"--background",
@@ -55,6 +59,8 @@ def execute_cmd(
payload: dict[str, Any] = {"command": command, "provision": False}
if session_id:
payload["session_id"] = session_id
if cwd:
payload["cwd"] = cwd
path = f"/v1/workspaces/{workspace_id}/execute"
if background:
path += "?background=true"
+21
View File
@@ -0,0 +1,21 @@
# ========= 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. =========
from mirage.runtime.sandbox.base import RemoteSandbox
from mirage.runtime.sandbox.config import SandboxConfig
# Providers (daytona, docker, e2b) are not re-exported here: their
# modules import optional SDKs at load, and the runtime table reaches
# them by module path. Import a provider from its own package.
__all__ = ["RemoteSandbox", "SandboxConfig"]
+110
View File
@@ -0,0 +1,110 @@
# ========= 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
from collections.abc import Sequence
from typing import Any, ClassVar
from mirage.runtime.base import RunArgs, RunResult, Runtime
from mirage.runtime.route.types import RouteScript
from mirage.runtime.sandbox.config import SandboxConfig
class RemoteSandbox(Runtime):
"""A runtime that runs whole lines inside a sandbox the user runs.
Mirage never creates, provisions, or deletes sandboxes: you bring
your own (a running container, a live Daytona or E2B sandbox) and
the provider config says how to reach it. The sandbox is also
yours to provision: serve the workspace inside it yourself (run
``mirage workspace create`` in the image entrypoint or by hand)
with mounts at the same prefixes as the host workspace, so the
session cwd and every path in a line resolve unchanged. Mirage
only connects and execs lines. Subclasses adapt one provider by
implementing connect() and exec_line(); routing, captures, and
per-line scripts are inherited.
Args:
captures (Sequence[str]): commands that place a whole line
here; ("*",) claims every line.
config (SandboxConfig | dict[str, Any] | None): how to reach
the sandbox, coerced through the provider's own config
class (config_cls), so a field the provider does not have
fails loud; the dict form is a yaml entry's ``config``
block.
script (RouteScript | None): per-line admission script, the
same contract as any runtime.
"""
runs_lines = True
captures: tuple[str, ...] = ("*", )
# Each provider's config class; coerce() makes unknown fields
# fail loud, so providers need no per-field rejection code.
config_cls: ClassVar[type[SandboxConfig]] = SandboxConfig
def __init__(self,
captures: Sequence[str] = ("*", ),
config: SandboxConfig | dict[str, Any] | None = None,
script: RouteScript | None = None) -> None:
self.captures = tuple(captures)
self.config = self.config_cls.coerce(config)
self.script = script
# Connect-once latch: the first captured line connects; later
# lines just execute. A failed connect leaves it unset so the
# next line retries.
self._connected = False
self._connect_lock = asyncio.Lock()
async def run(self, args: RunArgs) -> RunResult:
raise NotImplementedError(
f"runtime {self.name!r} runs whole lines in a remote sandbox, "
f"not single interpreter stages")
async def run_line(self, line: str, stdin: bytes | None,
env: dict[str, str], cwd: str) -> RunResult:
"""Run one raw line in the sandbox, connecting once.
The line, cwd, and paths pass through verbatim: the sandbox is
expected to serve the workspace at the same prefixes as the
host, so nothing is rewritten. The session environment merges
over the config environment.
Args:
line (str): the raw typed line.
stdin (bytes | None): bytes piped into the line.
env (dict[str, str]): the session environment.
cwd (str): the session working directory.
"""
async with self._connect_lock:
if not self._connected:
await self.connect()
self._connected = True
merged = {**self.config.env, **env}
return await self.exec_line(line, stdin, merged, cwd)
async def connect(self) -> None:
"""Attach to the user's live sandbox, failing loud if absent."""
raise NotImplementedError
async def exec_line(self, line: str, stdin: bytes | None,
env: dict[str, str], cwd: str) -> RunResult:
"""Execute one shell line inside the sandbox.
Args:
line (str): the raw shell line.
stdin (bytes | None): bytes piped into the line.
env (dict[str, str]): the merged environment.
cwd (str): the working directory, passed through verbatim.
"""
raise NotImplementedError
+53
View File
@@ -0,0 +1,53 @@
# ========= 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. =========
from dataclasses import dataclass, field, fields
from typing import Any, TypeVar
T = TypeVar("T", bound="SandboxConfig")
@dataclass(frozen=True, slots=True)
class SandboxConfig:
"""How the sandbox machine is built: the fields every provider has.
This base carries only what all providers support; each provider
extends it with its own fields (DockerConfig, DaytonaConfig,
E2BConfig), so an option a provider cannot honor is simply not a
field there and fails loud at construction. In yaml this is the
runtime entry's ``config`` block, mirroring a mount's.
Args:
env (dict[str, str]): environment set in the sandbox.
"""
env: dict[str, str] = field(default_factory=dict)
@classmethod
def coerce(cls: type[T],
value: "SandboxConfig | dict[str, Any] | None") -> T:
"""A constructor's config argument as this provider's config.
Args:
value (SandboxConfig | dict | None): an instance, its
dict form (a yaml ``config`` block), or None for the
defaults. Unknown keys fail loud.
"""
if value is None:
return cls()
if isinstance(value, cls):
return value
if isinstance(value, SandboxConfig):
value = {f.name: getattr(value, f.name) for f in fields(value)}
return cls(**value)
@@ -0,0 +1,28 @@
# ========= 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. =========
# Providers whose exec API has no stdin stream (Daytona, e2b) upload
# piped bytes here and redirect them into the line.
STDIN_PATH = "/tmp/.mirage_stdin"
def sdk_install_hint(name: str) -> str:
"""The message shown when a provider SDK extra is missing.
Args:
name (str): the runtime name, which is also its pip extra
(daytona, e2b).
"""
return (f"the {name} runtime needs the {name} SDK; install with: "
f"pip install mirage-ai[{name}]")
@@ -0,0 +1,18 @@
# ========= 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. =========
from mirage.runtime.sandbox.daytona.config import DaytonaConfig
from mirage.runtime.sandbox.daytona.runtime import DaytonaRuntime
__all__ = ["DaytonaConfig", "DaytonaRuntime"]
@@ -0,0 +1,33 @@
# ========= 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. =========
from dataclasses import dataclass
from mirage.runtime.sandbox.config import SandboxConfig
@dataclass(frozen=True, slots=True, kw_only=True)
class DaytonaConfig(SandboxConfig):
"""How to reach the user's live Daytona sandbox.
Args:
sandbox_id (str): id of a sandbox you created (dashboard,
`daytona sandbox create`, or the SDK). Boot it from an
image or snapshot with fuse3 and mirage installed.
api_key (str | None): Daytona credential; None reads
DAYTONA_API_KEY.
"""
sandbox_id: str
api_key: str | None = None
@@ -0,0 +1,77 @@
# ========= 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 shlex
from typing import Any
from mirage.runtime.base import RunResult
from mirage.runtime.sandbox.base import RemoteSandbox
from mirage.runtime.sandbox.constants import STDIN_PATH, sdk_install_hint
from mirage.runtime.sandbox.daytona import sdk
from mirage.runtime.sandbox.daytona.config import DaytonaConfig
class DaytonaRuntime(RemoteSandbox):
"""A Daytona sandbox the user runs as a whole-line runtime.
You create the sandbox yourself (dashboard, `daytona sandbox
create`, or the SDK); mirage only connects by ``sandbox_id`` and
execs lines. ``api_key`` falls back to DAYTONA_API_KEY. Daytona's
exec has no stdin and reports combined output, so piped bytes are
uploaded and redirected in, and stderr comes back None. close()
releases the SDK client and never touches the sandbox.
Args:
options (Any): the RemoteSandbox constructor fields.
"""
name = "daytona"
config_cls = DaytonaConfig
config: DaytonaConfig
_client: Any = None
_sandbox: Any = None
async def connect(self) -> None:
if sdk.AsyncDaytona is None:
raise ImportError(sdk_install_hint("daytona"))
if self._client is None:
api_key = self.config.api_key
config = (sdk.DaytonaConfig(
api_key=api_key) if api_key is not None else None)
self._client = sdk.AsyncDaytona(config)
self._sandbox = await self._client.get(self.config.sandbox_id)
async def exec_line(self, line: str, stdin: bytes | None,
env: dict[str, str], cwd: str) -> RunResult:
command = line
if stdin is not None:
await self._upload(STDIN_PATH, stdin)
command = f"( {line} ) < {shlex.quote(STDIN_PATH)}"
response = await self._sandbox.process.exec(command, cwd=cwd, env=env)
return RunResult(stdout=str(response.result).encode(),
stderr=None,
exit_code=int(response.exit_code))
async def _upload(self, path: str, data: bytes) -> None:
parent = path.rsplit("/", 1)[0]
if parent:
await self._sandbox.fs.create_folder(parent, "755")
await self._sandbox.fs.upload_file(data, path)
async def close(self) -> None:
"""Release the SDK client; the sandbox itself is the user's."""
self._sandbox = None
if getattr(self, "_client", None) is not None:
await self._client.close()
self._client = None
@@ -0,0 +1,27 @@
# ========= 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. =========
from typing import Any
AsyncDaytona: Any
DaytonaConfig: Any
try:
from daytona import AsyncDaytona as _AsyncDaytona
from daytona import DaytonaConfig as _DaytonaConfig
except ImportError:
AsyncDaytona = None
DaytonaConfig = None
else:
AsyncDaytona = _AsyncDaytona
DaytonaConfig = _DaytonaConfig
@@ -0,0 +1,18 @@
# ========= 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. =========
from mirage.runtime.sandbox.docker.config import DockerConfig
from mirage.runtime.sandbox.docker.runtime import DockerRuntime
__all__ = ["DockerConfig", "DockerRuntime"]
@@ -0,0 +1,31 @@
# ========= 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. =========
from dataclasses import dataclass
from mirage.runtime.sandbox.config import SandboxConfig
@dataclass(frozen=True, slots=True, kw_only=True)
class DockerConfig(SandboxConfig):
"""How to reach the user's running container.
Args:
container (str): id or name of a running container. You start
it yourself (`docker run -d ... sleep infinity`); live
FUSE mounts need `--cap-add SYS_ADMIN --device /dev/fuse`
and an image with mirage installed.
"""
container: str
@@ -0,0 +1,16 @@
# ========= 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. =========
DOCKER_CLI_HINT = ("the docker runtime needs the docker CLI on PATH "
"(Docker Desktop, colima, or a podman alias)")
@@ -0,0 +1,77 @@
# ========= 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
from mirage.runtime.base import RunResult
from mirage.runtime.sandbox.base import RemoteSandbox
from mirage.runtime.sandbox.docker.config import DockerConfig
from mirage.runtime.sandbox.docker.constants import DOCKER_CLI_HINT
class DockerRuntime(RemoteSandbox):
"""A container the user runs as a whole-line runtime.
You start the container yourself; mirage only connects to it and
execs lines. The docker CLI is the transport (Docker Desktop,
colima, or a podman alias all work), so there is no SDK dependency
and no daemon socket wiring; each line is one `docker exec -i`
with the merged environment, the rebased cwd, real stdin, and
separated stderr.
Args:
options (Any): the RemoteSandbox constructor fields.
"""
name = "docker"
config_cls = DockerConfig
config: DockerConfig
async def _docker(self,
args: list[str],
stdin: bytes | None = None) -> tuple[bytes, bytes, int]:
"""One docker CLI invocation; the seam tests override."""
try:
process = await asyncio.create_subprocess_exec(
"docker",
*args,
stdin=(asyncio.subprocess.PIPE
if stdin is not None else asyncio.subprocess.DEVNULL),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError:
raise RuntimeError(DOCKER_CLI_HINT) from None
stdout, stderr = await process.communicate(stdin)
return stdout, stderr, process.returncode or 0
async def connect(self) -> None:
stdout, stderr, code = await self._docker([
"inspect", "--format", "{{.State.Running}}", self.config.container
])
if code != 0:
raise RuntimeError(
f"docker inspect failed: {stderr.decode().strip()}")
if stdout.decode().strip() != "true":
raise RuntimeError(
f"container {self.config.container} is not running")
async def exec_line(self, line: str, stdin: bytes | None,
env: dict[str, str], cwd: str) -> RunResult:
args = ["exec", "-i", "-w", cwd]
for key, value in env.items():
args += ["-e", f"{key}={value}"]
args += [self.config.container, "sh", "-c", line]
stdout, stderr, code = await self._docker(args, stdin=stdin)
return RunResult(stdout=stdout, stderr=stderr, exit_code=code)
@@ -0,0 +1,18 @@
# ========= 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. =========
from mirage.runtime.sandbox.e2b.config import E2BConfig
from mirage.runtime.sandbox.e2b.runtime import E2BRuntime
__all__ = ["E2BConfig", "E2BRuntime"]
@@ -0,0 +1,32 @@
# ========= 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. =========
from dataclasses import dataclass
from mirage.runtime.sandbox.config import SandboxConfig
@dataclass(frozen=True, slots=True, kw_only=True)
class E2BConfig(SandboxConfig):
"""How to reach the user's live E2B sandbox.
Args:
sandbox_id (str): id of a sandbox you created (`e2b sandbox
spawn` or the SDK), booted from a template with fuse3 and
mirage installed.
api_key (str | None): E2B credential; None reads E2B_API_KEY.
"""
sandbox_id: str
api_key: str | None = None
@@ -0,0 +1,73 @@
# ========= 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 shlex
from typing import Any
from mirage.runtime.base import RunResult
from mirage.runtime.sandbox.base import RemoteSandbox
from mirage.runtime.sandbox.constants import STDIN_PATH, sdk_install_hint
from mirage.runtime.sandbox.e2b import sdk
from mirage.runtime.sandbox.e2b.config import E2BConfig
class E2BRuntime(RemoteSandbox):
"""An E2B sandbox the user runs as a whole-line runtime.
You create the sandbox yourself (`e2b sandbox spawn` or the SDK);
mirage only connects by ``sandbox_id`` and execs lines.
``api_key`` falls back to E2B_API_KEY. E2B's exec reports stdout
and stderr separately, so both stream back real; it takes no
stdin, so piped bytes are uploaded and redirected in.
Args:
options (Any): the RemoteSandbox constructor fields.
"""
name = "e2b"
config_cls = E2BConfig
config: E2BConfig
_sandbox: Any = None
def _api_params(self) -> dict[str, Any]:
api_key = self.config.api_key
return {"api_key": api_key} if api_key is not None else {}
async def connect(self) -> None:
if sdk.AsyncSandbox is None:
raise ImportError(sdk_install_hint("e2b"))
self._sandbox = await sdk.AsyncSandbox.connect(self.config.sandbox_id,
**self._api_params())
async def exec_line(self, line: str, stdin: bytes | None,
env: dict[str, str], cwd: str) -> RunResult:
command = line
if stdin is not None:
await self._upload(STDIN_PATH, stdin)
command = f"( {line} ) < {shlex.quote(STDIN_PATH)}"
try:
result = await self._sandbox.commands.run(command,
envs=env,
cwd=cwd)
except sdk.CommandExitException as exc:
result = exc
return RunResult(stdout=str(result.stdout).encode(),
stderr=str(result.stderr).encode(),
exit_code=int(result.exit_code))
async def _upload(self, path: str, data: bytes) -> None:
parent = path.rsplit("/", 1)[0]
if parent:
await self._sandbox.files.make_dir(parent)
await self._sandbox.files.write(path, data)
+27
View File
@@ -0,0 +1,27 @@
# ========= 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. =========
from typing import Any
AsyncSandbox: Any
CommandExitException: Any
try:
from e2b import AsyncSandbox as _AsyncSandbox
from e2b import CommandExitException as _CommandExitException
except ImportError:
AsyncSandbox = None
CommandExitException = None
else:
AsyncSandbox = _AsyncSandbox
CommandExitException = _CommandExitException
+15 -1
View File
@@ -12,6 +12,7 @@
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import importlib
from collections.abc import Mapping, Sequence
from typing import Any, Callable
@@ -90,6 +91,16 @@ class VfsRuntime(Runtime):
NAMED: dict[str, type[Runtime]] = {cls.name: cls for cls in RUNTIMES}
NAMED[VfsRuntime.name] = VfsRuntime
# Sandbox runtimes resolve on first use. Their provider SDKs are heavy
# (the daytona client alone pulls in opentelemetry), and importing them
# eagerly would put that cost on every `import mirage`, so the table
# holds module paths and imports the class only when the name is built.
SANDBOX_MODULES: dict[str, str] = {
"daytona": "mirage.runtime.sandbox.daytona:DaytonaRuntime",
"docker": "mirage.runtime.sandbox.docker:DockerRuntime",
"e2b": "mirage.runtime.sandbox.e2b:E2BRuntime",
}
# The default world when no runtimes list is given: today's behavior
# exactly. Defaults build gracefully (a missing extra leaves the
# command reporting its install hint per invocation); an explicitly
@@ -120,10 +131,13 @@ def build_runtime(name: str, **options: Any) -> Runtime:
TypeScript-only names.
"""
cls = NAMED.get(name)
if cls is None and name in SANDBOX_MODULES:
module_path, attr = SANDBOX_MODULES[name].split(":")
cls = getattr(importlib.import_module(module_path), attr)
if cls is None:
if name in TS_ONLY_HINTS:
raise ValueError(TS_ONLY_HINTS[name])
known = ", ".join(repr(n) for n in NAMED)
known = ", ".join(repr(n) for n in (*NAMED, *SANDBOX_MODULES))
raise ValueError(f"unknown runtime: {name!r} "
f"(expected one of {known})")
return cls(**options)
+3
View File
@@ -31,6 +31,7 @@ class ExecuteRequest(BaseModel):
session_id: str | None = None
provision: bool = False
agent_id: str | None = None
cwd: str | None = None
class BackgroundResponse(BaseModel):
@@ -56,6 +57,8 @@ def _build_execute_kwargs(req: ExecuteRequest,
kwargs["session_id"] = req.session_id
if req.agent_id is not None:
kwargs["agent_id"] = req.agent_id
if req.cwd is not None:
kwargs["cwd"] = req.cwd
if stdin is not None:
kwargs["stdin"] = stdin
return kwargs
+18
View File
@@ -128,6 +128,24 @@ class Dispatcher:
return False
return mount.resource.caches_reads
async def invalidate_all_after_remote(self) -> None:
"""Drop the file cache and every mount index wholesale.
A whole-line runtime may have written anywhere in its view of
the workspace, so per-path invalidation cannot apply: clear
the read caches so the next local command refetches from the
backends instead of serving pre-line state.
Example: `cat /data/x` caches "old" locally; `python3 job.py`
runs in the sandbox and writes "new" straight to S3 via its own
FUSE mount, which the local dispatch never saw; without this
reset the next `cat /data/x` would serve the stale "old".
"""
if self._cache is not None:
await self._cache.clear()
for mount in self._namespace.registry.mounts():
await mount.resource.index.clear()
async def invalidate_after_write_by_path(self, path: str) -> None:
"""Drop file-cache + stale parent index after a write to `path`.
+3
View File
@@ -1324,6 +1324,9 @@ class Workspace:
result = await line_runtime.run_line(
command, data, dict(effective_session.env),
effective_session.cwd)
# The line may have written anywhere in the runtime's
# view of the workspace; local read caches are stale.
await self._dispatcher.invalidate_all_after_remote()
io = IOResult(exit_code=result.exit_code,
stdout=result.stdout,
stderr=result.stderr)
+29 -1
View File
@@ -162,7 +162,9 @@ camel = [
]
# --- sandboxes ---
daytona = ["daytona>=0.176.0"]
daytona = [
"daytona>=0.176.0",
]
# --- meta: install everything ---
all = [
@@ -192,6 +194,29 @@ all = [
"mirage-ai[agno]",
"mirage-ai[claude-agent-sdk]",
"mirage-ai[daytona]",
"mirage-ai[e2b]",
]
sandbox = [
"mirage-ai[s3]",
"mirage-ai[r2]",
"mirage-ai[gcs]",
"mirage-ai[oci]",
"mirage-ai[databricks]",
"mirage-ai[ssh]",
"mirage-ai[nextcloud]",
"mirage-ai[hf]",
"mirage-ai[fuse]",
"mirage-ai[mongodb]",
"mirage-ai[gridfs]",
"mirage-ai[postgres]",
"mirage-ai[redis]",
"mirage-ai[email]",
"mirage-ai[parquet]",
"mirage-ai[hdf5]",
"mirage-ai[pdf]",
"mirage-ai[langfuse]",
"mirage-ai[chroma]",
"mirage-ai[qdrant]",
]
lancedb = [
"lancedb>=0.33.0",
@@ -199,6 +224,9 @@ lancedb = [
mem0 = [
"mem0ai>=2.0.6",
]
e2b = [
"e2b>=2.0.0",
]
[tool.uv]
conflicts = [
@@ -0,0 +1,35 @@
# ========= 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 pytest
from mirage.runtime.sandbox.daytona import DaytonaConfig
def test_coerce_dict_covers_all_fields():
config = DaytonaConfig.coerce({
"sandbox_id": "sb-live",
"api_key": "k-123",
"env": {
"A": "1"
},
})
assert config.sandbox_id == "sb-live"
assert config.api_key == "k-123"
assert config.env == {"A": "1"}
def test_sandbox_id_is_required():
with pytest.raises(TypeError, match="sandbox_id"):
DaytonaConfig.coerce({"api_key": "k-123"})
@@ -0,0 +1,142 @@
# ========= 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 pytest
from mirage.runtime.sandbox.daytona import DaytonaRuntime, sdk
class FakeProcess:
def __init__(self) -> None:
self.calls: list[tuple[str, str | None, dict | None]] = []
async def exec(self, command, cwd=None, env=None, timeout=None):
self.calls.append((command, cwd, env))
class Response:
exit_code = 0
result = f"out:{command}"
return Response()
class FakeFs:
def __init__(self) -> None:
self.files: dict[str, bytes] = {}
self.folders: list[str] = []
async def create_folder(self, path, mode):
self.folders.append(path)
async def upload_file(self, data, path):
self.files[path] = data
class FakeSandbox:
id = "sb-77"
def __init__(self) -> None:
self.process = FakeProcess()
self.fs = FakeFs()
class FakeClient:
configs: list[object] = []
fetched: list[str] = []
closed = 0
last: "FakeSandbox | None" = None
def __init__(self, config=None) -> None:
FakeClient.configs.append(config)
async def get(self, sandbox_id):
FakeClient.fetched.append(sandbox_id)
FakeClient.last = FakeSandbox()
return FakeClient.last
async def close(self):
FakeClient.closed += 1
@pytest.fixture(autouse=True)
def fake_client(monkeypatch):
FakeClient.configs = []
FakeClient.fetched = []
FakeClient.closed = 0
FakeClient.last = None
monkeypatch.setattr(sdk, "AsyncDaytona", FakeClient)
@pytest.mark.asyncio
async def test_connect_gets_the_users_sandbox_by_id():
runtime = DaytonaRuntime(config={"sandbox_id": "sb-live"})
await runtime.connect()
assert FakeClient.fetched == ["sb-live"]
def test_sandbox_id_is_required():
with pytest.raises(TypeError, match="sandbox_id"):
DaytonaRuntime(config={})
@pytest.mark.asyncio
async def test_api_key_reaches_the_client(monkeypatch):
class FakeSdkConfig:
def __init__(self, api_key=None) -> None:
self.api_key = api_key
monkeypatch.setattr(sdk, "DaytonaConfig", FakeSdkConfig)
runtime = DaytonaRuntime(config={
"sandbox_id": "sb-live",
"api_key": "k-123",
})
await runtime.connect()
assert FakeClient.configs[0].api_key == "k-123"
@pytest.mark.asyncio
async def test_exec_line_redirects_stdin_through_a_file():
runtime = DaytonaRuntime(config={"sandbox_id": "sb-live"})
await runtime.connect()
result = await runtime.exec_line("wc -l", b"a\nb\n", {"E": "1"},
"/workspace")
assert result.exit_code == 0
assert result.stderr is None
sandbox = FakeClient.last
assert sandbox.fs.files["/tmp/.mirage_stdin"] == b"a\nb\n"
command, cwd, env = sandbox.process.calls[0]
assert command == "( wc -l ) < /tmp/.mirage_stdin"
assert cwd == "/workspace"
assert env == {"E": "1"}
@pytest.mark.asyncio
async def test_close_releases_the_client_never_the_sandbox():
runtime = DaytonaRuntime(config={"sandbox_id": "sb-live"})
await runtime.connect()
await runtime.close()
# The fake exposes no delete at all: close only drops the client.
assert FakeClient.closed == 1
@pytest.mark.asyncio
async def test_missing_sdk_fails_with_install_hint(monkeypatch):
monkeypatch.setattr(sdk, "AsyncDaytona", None)
runtime = DaytonaRuntime(config={"sandbox_id": "sb-live"})
with pytest.raises(ImportError, match="mirage-ai\\[daytona\\]"):
await runtime.connect()
@@ -0,0 +1,68 @@
# ========= 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 pytest
from mirage.runtime.sandbox.docker import DockerRuntime
class FakeDockerRuntime(DockerRuntime):
def __init__(self, running: bool = True, **options):
super().__init__(**options)
self.running = running
self.calls: list[tuple[list[str], bytes | None]] = []
async def _docker(self, args, stdin=None):
self.calls.append((list(args), stdin))
if args[0] == "inspect":
return (b"true\n" if self.running else b"false\n"), b"", 0
script = args[-1]
return f"out:{script}".encode(), b"warn", 0
@pytest.mark.asyncio
async def test_connect_checks_the_users_container_is_running():
runtime = FakeDockerRuntime(config={"container": "cid-42"})
await runtime.connect()
args, _ = runtime.calls[0]
assert args == ["inspect", "--format", "{{.State.Running}}", "cid-42"]
@pytest.mark.asyncio
async def test_connect_fails_loud_on_a_stopped_container():
runtime = FakeDockerRuntime(running=False, config={"container": "cid-42"})
with pytest.raises(RuntimeError, match="not running"):
await runtime.connect()
def test_container_is_required():
with pytest.raises(TypeError, match="container"):
DockerRuntime(config={})
@pytest.mark.asyncio
async def test_exec_line_threads_cwd_env_stdin_and_real_stderr():
runtime = FakeDockerRuntime(config={"container": "cid-42"})
result = await runtime.exec_line("wc -l", b"a\nb\n", {"E": "1"},
"/root/workspace")
assert result.exit_code == 0
assert result.stdout == b"out:wc -l"
assert result.stderr == b"warn"
args, stdin = runtime.calls[-1]
assert args == [
"exec", "-i", "-w", "/root/workspace", "-e", "E=1", "cid-42", "sh",
"-c", "wc -l"
]
assert stdin == b"a\nb\n"
@@ -0,0 +1,137 @@
# ========= 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 pytest
from e2b import CommandExitException
from mirage.runtime.sandbox.e2b import E2BRuntime, sdk
class FakeResult:
def __init__(self, stdout: str, stderr: str = "", exit_code: int = 0):
self.stdout = stdout
self.stderr = stderr
self.exit_code = exit_code
class FakeCommands:
def __init__(self) -> None:
self.calls: list[tuple[str, dict | None, str | None]] = []
async def run(self, command, envs=None, cwd=None):
self.calls.append((command, envs, cwd))
if "exit 3" in command:
raise CommandExitException(stderr="boom-err",
stdout="partial",
exit_code=3,
error=None)
return FakeResult(f"out:{command}", stderr="warn")
class FakeFiles:
def __init__(self) -> None:
self.files: dict[str, bytes] = {}
self.dirs: list[str] = []
async def make_dir(self, path):
self.dirs.append(path)
return True
async def write(self, path, data):
self.files[path] = data
class FakeSandbox:
connected: list[tuple[str, dict]] = []
last: "FakeSandbox | None" = None
def __init__(self) -> None:
self.sandbox_id = "sb-e2b"
self.commands = FakeCommands()
self.files = FakeFiles()
@classmethod
async def connect(cls, sandbox_id, **params):
cls.connected.append((sandbox_id, params))
cls.last = cls()
return cls.last
@pytest.fixture(autouse=True)
def fake_sdk(monkeypatch):
FakeSandbox.connected = []
FakeSandbox.last = None
monkeypatch.setattr(sdk, "AsyncSandbox", FakeSandbox)
@pytest.mark.asyncio
async def test_connect_attaches_by_id_with_api_key():
runtime = E2BRuntime(config={
"sandbox_id": "sb-live",
"api_key": "k-123",
})
await runtime.connect()
assert FakeSandbox.connected == [("sb-live", {"api_key": "k-123"})]
def test_sandbox_id_is_required():
with pytest.raises(TypeError, match="sandbox_id"):
E2BRuntime(config={})
@pytest.mark.asyncio
async def test_exec_line_threads_env_cwd_and_real_stderr():
runtime = E2BRuntime(config={"sandbox_id": "sb-live"})
await runtime.connect()
result = await runtime.exec_line("wc -l", None, {"E": "1"}, "/workspace")
assert result.exit_code == 0
assert result.stdout == b"out:wc -l"
assert result.stderr == b"warn"
sandbox = FakeSandbox.last
assert sandbox.commands.calls[0] == ("wc -l", {"E": "1"}, "/workspace")
@pytest.mark.asyncio
async def test_exec_line_nonzero_exit_comes_back_as_result():
runtime = E2BRuntime(config={"sandbox_id": "sb-live"})
await runtime.connect()
result = await runtime.exec_line("exit 3", None, {}, "/workspace")
assert result.exit_code == 3
assert result.stdout == b"partial"
assert result.stderr == b"boom-err"
@pytest.mark.asyncio
async def test_stdin_redirects_through_an_uploaded_file():
runtime = E2BRuntime(config={"sandbox_id": "sb-live"})
await runtime.connect()
result = await runtime.exec_line("wc -l", b"a\nb\n", {}, "/workspace")
assert result.exit_code == 0
sandbox = FakeSandbox.last
assert sandbox.files.files["/tmp/.mirage_stdin"] == b"a\nb\n"
assert sandbox.files.dirs == ["/tmp"]
command, _, cwd = sandbox.commands.calls[0]
assert command == "( wc -l ) < /tmp/.mirage_stdin"
assert cwd == "/workspace"
@pytest.mark.asyncio
async def test_missing_sdk_fails_with_install_hint(monkeypatch):
monkeypatch.setattr(sdk, "AsyncSandbox", None)
runtime = E2BRuntime(config={"sandbox_id": "sb-live"})
with pytest.raises(ImportError, match="mirage-ai\\[e2b\\]"):
await runtime.connect()
+139
View File
@@ -0,0 +1,139 @@
# ========= 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 pytest
from mirage import MountMode, RAMResource, Workspace
from mirage.cache.index.config import IndexEntry
from mirage.io.types import materialize
from mirage.runtime.base import RunArgs, RunResult
from mirage.runtime.sandbox import RemoteSandbox, SandboxConfig
class RecordingSandbox(RemoteSandbox):
name = "recbox"
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.execs: list[tuple[str, bytes | None, str]] = []
self.exec_envs: list[dict[str, str]] = []
self.connected = 0
async def connect(self) -> None:
self.connected += 1
async def exec_line(self, line: str, stdin: bytes | None,
env: dict[str, str], cwd: str) -> RunResult:
self.execs.append((line, stdin, cwd))
self.exec_envs.append(dict(env))
return RunResult(stdout=b"ran:" + line.encode(),
stderr=None,
exit_code=0)
@pytest.mark.asyncio
async def test_first_line_connects_once():
box = RecordingSandbox(captures=("python3", ))
ws = Workspace({"/data": RAMResource()},
mode=MountMode.EXEC,
runtimes=[box, "vfs"])
try:
io = await ws.execute("python3 x")
assert await materialize(io.stdout) == b"ran:python3 x"
assert box.connected == 1
await ws.execute("python3 x")
# The runtime connects on the first line, not per line.
assert box.connected == 1
finally:
await ws.close()
@pytest.mark.asyncio
async def test_failed_connect_retries_on_the_next_line():
class FlakyBox(RecordingSandbox):
async def connect(self) -> None:
await super().connect()
if self.connected == 1:
raise RuntimeError("sandbox not running")
box = FlakyBox(captures=("python3", ))
ws = Workspace({"/data": RAMResource()},
mode=MountMode.EXEC,
runtimes=[box, "vfs"])
try:
io = await ws.execute("python3 x")
assert io.exit_code != 0
assert b"not running" in await materialize(io.stderr)
io = await ws.execute("python3 x")
assert io.exit_code == 0
assert box.connected == 2
finally:
await ws.close()
@pytest.mark.asyncio
async def test_cwd_passes_through_verbatim_and_env_merges():
box = RecordingSandbox(captures=("*", ), config={"env": {"BASE": "1"}})
result = await box.run_line("nvidia-smi", None, {"LINE": "2"},
"/data/deep")
assert result.exit_code == 0
# The sandbox serves the workspace at the same prefixes as the
# host, so nothing is rewritten.
assert box.execs[-1][2] == "/data/deep"
assert box.exec_envs[-1]["BASE"] == "1"
assert box.exec_envs[-1]["LINE"] == "2"
@pytest.mark.asyncio
async def test_stdin_bytes_reach_exec_line():
box = RecordingSandbox(captures=("*", ))
ws = Workspace({"/data": RAMResource()},
mode=MountMode.EXEC,
runtimes=[box, "vfs"])
try:
await ws.execute("wc -l", stdin=b"a\nb\n")
assert box.execs[-1][1] == b"a\nb\n"
finally:
await ws.close()
@pytest.mark.asyncio
async def test_run_raises_sandboxes_take_lines():
box = RecordingSandbox()
with pytest.raises(NotImplementedError, match="whole lines"):
await box.run(RunArgs(code="x", args=[], env={}, stdin=None, flags={}))
def test_config_dict_form_coerces():
box = RecordingSandbox(config={"env": {"A": "1"}})
assert box.config == SandboxConfig(env={"A": "1"})
@pytest.mark.asyncio
async def test_remote_line_invalidates_local_read_caches():
box = RecordingSandbox(captures=("python3", ))
ws = Workspace({"/data": RAMResource()},
mode=MountMode.EXEC,
runtimes=[box, "vfs"])
try:
mount = next(m for m in ws._registry.mounts() if m.prefix == "/data/")
stale = IndexEntry(id="stale", name="stale.txt", resource_type="ram")
await mount.resource.index.put("/stale.txt", stale)
await ws.execute("python3 anything")
looked = await mount.resource.index.get("/stale.txt")
assert looked.entry is None
finally:
await ws.close()
@@ -0,0 +1,46 @@
# ========= 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 pytest
from mirage.runtime.sandbox.config import SandboxConfig
from mirage.runtime.sandbox.docker import DockerConfig
from mirage.runtime.sandbox.e2b import E2BConfig
def test_coerce_none_gives_defaults():
config = SandboxConfig.coerce(None)
assert config == SandboxConfig()
assert config.env == {}
def test_coerce_passes_an_instance_through():
config = SandboxConfig.coerce(SandboxConfig(env={"A": "1"}))
assert config.env == {"A": "1"}
def test_coerce_dict_form_is_a_yaml_config_block():
config = SandboxConfig.coerce({"env": {"A": "1"}})
assert config.env == {"A": "1"}
def test_coerce_unknown_key_fails_loud():
# A provider-only field is unknown on the base config.
with pytest.raises(TypeError, match="container"):
SandboxConfig.coerce({"container": "cid-42"})
def test_coerce_rejects_a_sibling_provider_config():
with pytest.raises(TypeError, match="container"):
E2BConfig.coerce(DockerConfig(container="cid-42"))
@@ -59,6 +59,31 @@ async def test_execute_sync_returns_io_result():
assert "X-Mirage-Job-Id" in r.headers
@pytest.mark.asyncio
async def test_execute_honors_cwd():
app = build_app(idle_grace_seconds=10.0)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport,
base_url="http://test") as client:
wid = await _create_workspace(client)
r = await client.post(
f"/v1/workspaces/{wid}/execute",
json={"command": "mkdir -p /sub && echo -n nested > /sub/f.txt"},
)
assert r.status_code == 200, r.text
r = await client.post(
f"/v1/workspaces/{wid}/execute",
json={
"command": "cat f.txt",
"cwd": "/sub"
},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["exit_code"] == 0
assert body["stdout"] == "nested"
@pytest.mark.asyncio
async def test_execute_sync_records_a_job_in_done_state():
app = build_app(idle_grace_seconds=10.0)
+187 -1
View File
File diff suppressed because one or more lines are too long
+9 -1
View File
@@ -25,11 +25,19 @@ export function registerExecuteCommand(program: Command): void {
.requiredOption('-w, --workspace <id>', 'Workspace id')
.requiredOption('-c, --command <command>', 'Shell command to execute')
.option('-s, --session <id>', 'Session id')
.option('--cwd <path>', 'Working directory for this line (a workspace path)')
.option('--bg', 'Background; return job_id immediately')
.action(
async (opts: { workspace: string; command: string; session?: string; bg?: boolean }) => {
async (opts: {
workspace: string
command: string
session?: string
cwd?: string
bg?: boolean
}) => {
const body: Record<string, unknown> = { command: opts.command, provision: false }
if (opts.session !== undefined) body.sessionId = opts.session
if (opts.cwd !== undefined) body.cwd = opts.cwd
if (opts.bg !== true && !process.stdin.isTTY) {
body.stdinBase64 = readFileSync(0).toString('base64')
}
+13 -1
View File
@@ -494,7 +494,19 @@ export {
type RouteFn,
type RouteScript,
} from './workspace/executor/route/index.ts'
export { buildRuntime, candidates, RUNTIMES } from './workspace/executor/runtime_table.ts'
export {
buildRuntime,
candidates,
registerRuntime,
RUNTIMES,
} from './workspace/executor/runtime_table.ts'
export { RemoteSandbox, type RemoteSandboxOptions } from './workspace/executor/sandbox/base.ts'
export {
coerceConfig,
type NormalizedSandboxConfig,
type SandboxConfig,
} from './workspace/executor/sandbox/config.ts'
export { STDIN_PATH } from './workspace/executor/sandbox/constants.ts'
export type { JsRuntime } from './workspace/executor/js/interface.ts'
export { applyBarrier, BarrierPolicy } from './shell/barrier.ts'
export { handleConnection, handlePipe, handleSubshell } from './workspace/executor/pipes.ts'
@@ -161,6 +161,11 @@ export class Dispatcher {
return [result, new IOResult()]
}
/** Drop the whole file cache (post-remote-line invalidation). */
async clearFileCache(): Promise<void> {
await this.cache.clear()
}
async invalidateAfterWriteByPath(rawPath: string): Promise<void> {
// Directory writes (mkdir/rmdir via tree copies) arrive with a
// trailing slash; normalize so the parent computation below does not
@@ -50,6 +50,21 @@ const OPTION_KEYS: Record<string, readonly string[]> = {
vfs: ['script', 'captures'],
}
/**
* Register a runtime class under a config name, with its allowed
* constructor option keys. Runtime packages extend the table with
* their own runtimes (e.g. `daytona` from `@struktoai/mirage-node`),
* mirroring Python's NAMED dict; existing entries are overwritten.
*/
export function registerRuntime(
name: string,
cls: new (options?: Record<string, unknown>) => Runtime,
optionKeys: readonly string[],
): void {
NAMED[name] = cls
OPTION_KEYS[name] = optionKeys
}
/**
* Construct a runtime by name, failing loud on unknown names (with a
* cross-language hint for Python-only names) and on unknown options.
@@ -0,0 +1,141 @@
// ========= 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 { describe, expect, it } from 'vitest'
import { getTestParser } from '../../fixtures/workspace_fixture.ts'
import { RAMResource } from '../../../resource/ram/ram.ts'
import { MountMode } from '../../../types.ts'
import { Workspace } from '../../workspace.ts'
import { RemoteSandbox, type RemoteSandboxOptions } from './base.ts'
import type { RunResult } from '../runtime.ts'
const ENC = new TextEncoder()
const DEC = new TextDecoder()
class RecordingSandbox extends RemoteSandbox {
readonly name = 'recbox'
readonly execs: [string, Uint8Array | null, Record<string, string>, string][] = []
connectedCount = 0
constructor(options: RemoteSandboxOptions = {}) {
super(options)
}
connect(): Promise<void> {
this.connectedCount += 1
return Promise.resolve()
}
execLine(
line: string,
stdin: Uint8Array | null,
env: Record<string, string>,
cwd: string,
): Promise<RunResult> {
this.execs.push([line, stdin, env, cwd])
return Promise.resolve({ stdout: ENC.encode(`ran:${line}`), stderr: null, exitCode: 0 })
}
}
async function sandboxWorkspace(box: RecordingSandbox): Promise<Workspace> {
const parser = await getTestParser()
return new Workspace(
{ '/data': new RAMResource() },
{
mode: MountMode.EXEC,
shellParser: parser,
runtimes: [box, 'vfs'],
},
)
}
describe('RemoteSandbox', () => {
it('connects on the first line only', async () => {
const box = new RecordingSandbox({ captures: ['python3'] })
const ws = await sandboxWorkspace(box)
try {
const io = await ws.execute('python3 x')
expect(DEC.decode(io.stdout)).toBe('ran:python3 x')
expect(box.connectedCount).toBe(1)
await ws.execute('python3 x')
// The runtime connects on the first line, not per line.
expect(box.connectedCount).toBe(1)
} finally {
await ws.close()
}
})
it('a failed connect retries on the next line', async () => {
class FlakyBox extends RecordingSandbox {
override connect(): Promise<void> {
this.connectedCount += 1
if (this.connectedCount === 1) return Promise.reject(new Error('sandbox not running'))
return Promise.resolve()
}
}
const box = new FlakyBox({ captures: ['python3'] })
const ws = await sandboxWorkspace(box)
try {
const first = await ws.execute('python3 x')
expect(first.exitCode).not.toBe(0)
expect(DEC.decode(first.stderr)).toContain('not running')
const second = await ws.execute('python3 x')
expect(second.exitCode).toBe(0)
expect(box.connectedCount).toBe(2)
} finally {
await ws.close()
}
})
it('passes cwd through verbatim and merges env', async () => {
const box = new RecordingSandbox({ captures: ['*'], config: { env: { BASE: '1' } } })
const result = await box.runLine('nvidia-smi', null, { LINE: '2' }, '/data/deep')
expect(result.exitCode).toBe(0)
const [, , env, cwd] = box.execs[box.execs.length - 1] ?? [
'',
null,
{} as Record<string, string>,
'',
]
// The sandbox serves the workspace at the same prefixes as the
// host, so nothing is rewritten.
expect(cwd).toBe('/data/deep')
expect(env.BASE).toBe('1')
expect(env.LINE).toBe('2')
})
it('passes stdin bytes through to execLine', async () => {
const box = new RecordingSandbox({ captures: ['*'] })
const ws = await sandboxWorkspace(box)
try {
await ws.execute('wc -l', { stdin: ENC.encode('a\nb\n') })
const [, stdin] = box.execs[box.execs.length - 1] ?? ['', null, {}, '']
expect(DEC.decode(stdin ?? new Uint8Array())).toBe('a\nb\n')
} finally {
await ws.close()
}
})
it('rejects run(): sandboxes take whole lines', async () => {
const box = new RecordingSandbox()
await expect(box.run({ code: 'x', args: [], env: {}, stdin: null })).rejects.toThrow(
'whole lines',
)
})
it('rejects unknown config keys', () => {
const options = { config: { snapshot: 'mirage-fuse' } } as Record<string, unknown>
expect(() => new RecordingSandbox(options)).toThrow("unknown sandbox config key 'snapshot'")
})
})
@@ -0,0 +1,121 @@
// ========= 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 { ScriptSource, type RouteScript } from '../route/types.ts'
import { scriptStringError, type RunArgs, type RunResult, type Runtime } from '../runtime.ts'
import { coerceConfig, type NormalizedSandboxConfig, type SandboxConfig } from './config.ts'
/** Constructor options for a RemoteSandbox subclass (a yaml entry's keys). */
export interface RemoteSandboxOptions<C extends SandboxConfig = SandboxConfig> {
/** Commands that place a whole line here; ["*"] claims every line. */
captures?: readonly string[]
/** How to reach the sandbox (a yaml entry's `config` block). */
config?: C
/** Per-line admission script, the same contract as any runtime. */
script?: RouteScript
}
/**
* A runtime that runs whole lines inside a sandbox the user runs.
*
* Mirage never creates, provisions, or deletes sandboxes: you bring
* your own (a running container, a live Daytona or E2B sandbox) and
* the provider config says how to reach it. The sandbox is also
* yours to provision: serve the workspace inside it yourself (run
* `mirage workspace create` in the image entrypoint or by hand) with
* mounts at the same prefixes as the host workspace, so the session
* cwd and every path in a line resolve unchanged. Mirage only
* connects and execs lines. Subclasses adapt one provider by
* implementing connect() and execLine(); routing, captures, and
* per-line scripts are inherited.
*/
export abstract class RemoteSandbox<C extends SandboxConfig = SandboxConfig> implements Runtime {
abstract readonly name: string
readonly runsLines = true
readonly captures: readonly string[]
readonly config: NormalizedSandboxConfig<C>
script?: RouteScript
// Connect-once latch: the first captured line connects; later lines
// just execute. Single-flight so concurrent first lines share one
// connect, and a failed connect clears the slot so the next line
// retries.
private connecting: Promise<void> | null = null
// Each provider passes its own config key list, so a field the
// provider does not have fails loud (mirrors Python's config_cls).
constructor(
options: RemoteSandboxOptions<C> | Record<string, unknown> = {},
configKeys?: readonly string[],
) {
const opts = options as RemoteSandboxOptions<C>
if (typeof opts.script === 'string') throw scriptStringError()
this.captures = opts.captures !== undefined ? opts.captures.slice() : ['*']
this.config = coerceConfig(opts.config, configKeys)
if (typeof opts.script === 'function' || opts.script instanceof ScriptSource) {
this.script = opts.script
}
}
attach(): void {
// the sandbox serves the workspace itself; nothing to wire
}
run(_args: RunArgs): Promise<RunResult> {
return Promise.reject(
new Error(
`runtime '${this.name}' runs whole lines in a remote sandbox, ` +
`not single interpreter stages`,
),
)
}
/**
* Run one raw line in the sandbox, connecting once.
*
* The line, cwd, and paths pass through verbatim: the sandbox is
* expected to serve the workspace at the same prefixes as the host,
* so nothing is rewritten. The session environment merges over the
* config environment.
*/
async runLine(
line: string,
stdin: Uint8Array | null,
env: Record<string, string>,
cwd: string,
): Promise<RunResult> {
this.connecting ??= this.connect().catch((err: unknown) => {
this.connecting = null
throw err
})
await this.connecting
const merged = { ...this.config.env, ...env }
return this.execLine(line, stdin, merged, cwd)
}
/** Attach to the user's live sandbox, failing loud if absent. */
abstract connect(): Promise<void>
/** Execute one shell line inside the sandbox. */
abstract execLine(
line: string,
stdin: Uint8Array | null,
env: Record<string, string>,
cwd: string,
): Promise<RunResult>
/** Release provider client resources; the sandbox itself is the user's. */
close(): Promise<void> {
return Promise.resolve()
}
}
@@ -0,0 +1,54 @@
// ========= 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. =========
/**
* How the sandbox machine is built: the fields every provider has.
*
* This base carries only what all providers support; each provider
* extends it with its own fields (DockerConfig, DaytonaConfig,
* E2BConfig) and its own key list, so an option a provider cannot
* honor fails loud at construction. In yaml this is the runtime
* entry's `config` block, mirroring a mount's.
*/
export interface SandboxConfig {
/** Environment set in the sandbox. */
env?: Record<string, string>
}
/** A provider config with the shared collection fields always present. */
export type NormalizedSandboxConfig<C extends SandboxConfig = SandboxConfig> = C &
Required<Pick<SandboxConfig, 'env'>>
const BASE_CONFIG_KEYS: readonly string[] = ['env']
/**
* A constructor's config option as the provider's normalized config,
* mirroring Python's SandboxConfig.coerce: keys outside the
* provider's list fail loud (Python gets this from the dataclass
* raising TypeError; a TS object spread would silently swallow a
* typo key without it).
*/
export function coerceConfig<C extends SandboxConfig>(
value: C | undefined,
keys: readonly string[] = BASE_CONFIG_KEYS,
): NormalizedSandboxConfig<C> {
const config = value ?? ({} as C)
for (const key of Object.keys(config)) {
if (!keys.includes(key)) {
const known = keys.map((k) => `'${k}'`).join(', ')
throw new Error(`unknown sandbox config key '${key}' (expected: ${known})`)
}
}
return { ...config, env: { ...config.env } }
}
@@ -0,0 +1,17 @@
// ========= 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. =========
// Providers whose exec API has no stdin stream (Daytona, e2b) upload
// piped bytes here and redirect them into the line.
export const STDIN_PATH = '/tmp/.mirage_stdin'
@@ -1069,6 +1069,20 @@ export class Workspace {
* for any mount that maintains an index. No-op for paths that resolve
* to no known mount.
*/
/**
* Drop the file cache and every mount index wholesale. A whole-line
* runtime may have written anywhere in its view of the workspace,
* so per-path invalidation cannot apply: clear the read caches so
* the next local command refetches from the backends instead of
* serving pre-line state.
*/
private async invalidateAllAfterRemote(): Promise<void> {
await this.dispatcher.clearFileCache()
for (const m of this.registry.allMounts()) {
await m.resource.index?.clear()
}
}
async invalidateAfterWriteByPath(path: string): Promise<void> {
await this.dispatcher.invalidateAfterWriteByPath(path)
}
@@ -1225,12 +1239,24 @@ export class Workspace {
const lineRuntime = this.wholeLineRuntimeFor(rootNode, deps.routingDecision ?? null)
if (lineRuntime?.runLine !== undefined) {
const data = stdin !== null ? await materialize(stdin) : null
const result = await lineRuntime.runLine(
command,
data,
{ ...effectiveSession.env },
effectiveSession.cwd,
)
let result: RunResult
try {
result = await lineRuntime.runLine(
command,
data,
{ ...effectiveSession.env },
effectiveSession.cwd,
)
// The line may have written anywhere in the runtime's view of
// the workspace; local read caches are stale.
await this.invalidateAllAfterRemote()
} catch (err) {
result = {
stdout: new Uint8Array(),
stderr: new TextEncoder().encode(err instanceof Error ? err.message : String(err)),
exitCode: 1,
}
}
targetSession.lastExitCode = result.exitCode
if (isLine) {
const lineIo = new IOResult({
+10
View File
@@ -60,8 +60,10 @@
"web-tree-sitter": "^0.26.8"
},
"peerDependencies": {
"@daytonaio/sdk": "^0.199.0",
"@lancedb/lancedb": "^0.30.0",
"@zkochan/fuse-native": "^0.1.0",
"e2b": "^2.0.0",
"imapflow": "^1.0.0",
"mailparser": "^3.6.0",
"mongodb": "^6.10.0",
@@ -72,12 +74,18 @@
"ssh2": "^1.16.0"
},
"peerDependenciesMeta": {
"@daytonaio/sdk": {
"optional": true
},
"@lancedb/lancedb": {
"optional": true
},
"@zkochan/fuse-native": {
"optional": true
},
"e2b": {
"optional": true
},
"imapflow": {
"optional": true
},
@@ -105,12 +113,14 @@
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.0.0",
"@daytonaio/sdk": "^0.199.0",
"@lancedb/lancedb": "^0.30.0",
"@types/mailparser": "^3.4.6",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.11.0",
"@types/ssh2": "^1.15.0",
"@zkochan/fuse-native": "^0.1.0",
"e2b": "^2.0.0",
"imapflow": "^1.3.2",
"mailparser": "^3.9.8",
"mongodb": "^6.10.0",
+6
View File
@@ -386,6 +386,12 @@ export {
export { EmailAccessor } from './accessor/email.ts'
export { EMAIL_COMMANDS } from './commands/builtin/email/index.ts'
export { EMAIL_OPS } from './ops/email/index.ts'
export { DaytonaRuntime, DAYTONA_OPTION_KEYS } from './workspace/runtime/daytona/runtime.ts'
export { DAYTONA_CONFIG_KEYS, type DaytonaConfig } from './workspace/runtime/daytona/config.ts'
export { E2BRuntime, E2B_OPTION_KEYS } from './workspace/runtime/e2b/runtime.ts'
export { E2B_CONFIG_KEYS, type E2BConfig } from './workspace/runtime/e2b/config.ts'
export { DockerRuntime, DOCKER_OPTION_KEYS } from './workspace/runtime/docker/runtime.ts'
export { DOCKER_CONFIG_KEYS, type DockerConfig } from './workspace/runtime/docker/config.ts'
export {
buildResource,
knownResources,
@@ -28,6 +28,7 @@ import {
import { FuseManager } from './workspace/fuse.ts'
import { Mount } from './workspace/mount_spec.ts'
import './compression_codecs.ts'
import './workspace/runtime/daytona/runtime.ts'
const requireCjs = createRequire(import.meta.url)
@@ -0,0 +1,29 @@
// ========= 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 type { SandboxConfig } from '@struktoai/mirage-core'
/** How to reach the user's live Daytona sandbox. */
export interface DaytonaConfig extends SandboxConfig {
/**
* Id of a sandbox you created (dashboard, `daytona sandbox create`,
* or the SDK). Boot it from an image or snapshot with fuse3 and
* mirage installed.
*/
sandboxId: string
/** Daytona credential; absent reads DAYTONA_API_KEY. */
apiKey?: string
}
export const DAYTONA_CONFIG_KEYS: readonly string[] = ['env', 'sandboxId', 'apiKey']
@@ -0,0 +1,146 @@
// ========= 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 { buildRuntime } from '@struktoai/mirage-core'
import { beforeEach, describe, expect, it } from 'vitest'
import type { RemoteSandboxOptions } from '@struktoai/mirage-core'
import type { DaytonaConfig } from './config.ts'
import { DaytonaRuntime, type DaytonaSdk } from './runtime.ts'
const DEC = new TextDecoder()
class FakeProcess {
calls: [string, string | undefined, Record<string, string> | undefined][] = []
executeCommand(command: string, cwd?: string, env?: Record<string, string>) {
this.calls.push([command, cwd, env])
return Promise.resolve({ exitCode: 0, result: `out:${command}` })
}
}
class FakeFs {
files = new Map<string, Buffer>()
folders: string[] = []
createFolder(path: string, _mode: string): Promise<void> {
this.folders.push(path)
return Promise.resolve()
}
uploadFile(data: Buffer, path: string): Promise<void> {
this.files.set(path, data)
return Promise.resolve()
}
}
class FakeSandbox {
readonly id = 'sb-77'
readonly process = new FakeProcess()
readonly fs = new FakeFs()
}
class FakeClient {
static configs: (object | undefined)[] = []
static fetched: string[] = []
static disposed = 0
static last: FakeSandbox | null = null
constructor(readonly config?: object) {
FakeClient.configs.push(config)
}
get(sandboxId: string): Promise<FakeSandbox> {
FakeClient.fetched.push(sandboxId)
FakeClient.last = new FakeSandbox()
return Promise.resolve(FakeClient.last)
}
[Symbol.asyncDispose](): Promise<void> {
FakeClient.disposed += 1
return Promise.resolve()
}
}
class FakedDaytonaRuntime extends DaytonaRuntime {
protected override loadSdk(): Promise<DaytonaSdk> {
return Promise.resolve({ Daytona: FakeClient } as unknown as DaytonaSdk)
}
}
function makeRuntime(
options: RemoteSandboxOptions<DaytonaConfig> | Record<string, unknown> = {
config: { sandboxId: 'sb-live' },
},
): FakedDaytonaRuntime {
return new FakedDaytonaRuntime(options)
}
beforeEach(() => {
FakeClient.configs = []
FakeClient.fetched = []
FakeClient.disposed = 0
FakeClient.last = null
})
describe('DaytonaRuntime', () => {
it('connect gets the user sandbox by id', async () => {
const runtime = makeRuntime()
await runtime.connect()
expect(FakeClient.fetched).toEqual(['sb-live'])
})
it('sandboxId is required', () => {
expect(() => makeRuntime({ config: {} })).toThrow('sandboxId')
})
it('the apiKey reaches the client', async () => {
const runtime = makeRuntime({ config: { sandboxId: 'sb-live', apiKey: 'k-123' } })
await runtime.connect()
expect(FakeClient.configs[0]).toEqual({ apiKey: 'k-123' })
})
it('redirects stdin through an uploaded file', async () => {
const runtime = makeRuntime()
await runtime.connect()
const result = await runtime.execLine(
'wc -l',
new TextEncoder().encode('a\nb\n'),
{ E: '1' },
'/workspace',
)
expect(result.exitCode).toBe(0)
expect(result.stderr).toBeNull()
expect(DEC.decode(result.stdout)).toBe('out:( wc -l ) < /tmp/.mirage_stdin')
const sandbox = FakeClient.last ?? new FakeSandbox()
expect(DEC.decode(sandbox.fs.files.get('/tmp/.mirage_stdin'))).toBe('a\nb\n')
const [command, cwd, env] = sandbox.process.calls[0] ?? ['', undefined, undefined]
expect(command).toBe('( wc -l ) < /tmp/.mirage_stdin')
expect(cwd).toBe('/workspace')
expect(env).toEqual({ E: '1' })
})
it('close releases the client, never the sandbox', async () => {
const runtime = makeRuntime()
await runtime.connect()
await runtime.close()
// The fake exposes no delete at all: close only drops the client.
expect(FakeClient.disposed).toBe(1)
})
it("registers under the config name 'daytona'", () => {
const runtime = buildRuntime('daytona', { config: { sandboxId: 'sb-live' } })
expect(runtime).toBeInstanceOf(DaytonaRuntime)
expect(runtime.captures).toEqual(['*'])
})
})
@@ -0,0 +1,111 @@
// ========= 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 {
loadOptionalPeer,
registerRuntime,
RemoteSandbox,
type RemoteSandboxOptions,
type RunResult,
STDIN_PATH,
} from '@struktoai/mirage-core'
import { DAYTONA_CONFIG_KEYS, type DaytonaConfig } from './config.ts'
import type { Daytona, Sandbox } from '@daytonaio/sdk'
import type * as daytonaSdk from '@daytonaio/sdk'
export type DaytonaSdk = typeof daytonaSdk
const ENC = new TextEncoder()
export const DAYTONA_OPTION_KEYS: readonly string[] = ['captures', 'config', 'script']
/**
* A Daytona sandbox the user runs as a whole-line runtime.
*
* You create the sandbox yourself (dashboard, `daytona sandbox
* create`, or the SDK); mirage only connects by `sandboxId` and execs
* lines. `apiKey` falls back to DAYTONA_API_KEY. Daytona's exec has
* no stdin and reports combined output, so piped bytes are uploaded
* and redirected in, and stderr comes back null. close() releases the
* SDK client and never touches the sandbox.
*/
export class DaytonaRuntime extends RemoteSandbox<DaytonaConfig> {
readonly name = 'daytona'
private client: Daytona | null = null
private sandbox: Sandbox | null = null
constructor(options: RemoteSandboxOptions<DaytonaConfig> | Record<string, unknown> = {}) {
super(options, DAYTONA_CONFIG_KEYS)
if (!this.config.sandboxId) {
throw new Error('daytona config needs sandboxId: the id of a live sandbox you created')
}
}
// The SDK loader as a seam: tests substitute a fake module here.
protected loadSdk(): Promise<DaytonaSdk> {
return loadOptionalPeer(() => import('@daytonaio/sdk'), {
feature: "the 'daytona' runtime",
packageName: '@daytonaio/sdk',
})
}
async connect(): Promise<void> {
if (this.client === null) {
const sdk = await this.loadSdk()
this.client = new sdk.Daytona(
this.config.apiKey !== undefined ? { apiKey: this.config.apiKey } : undefined,
)
}
this.sandbox = await this.client.get(this.config.sandboxId)
}
async execLine(
line: string,
stdin: Uint8Array | null,
env: Record<string, string>,
cwd: string,
): Promise<RunResult> {
if (this.sandbox === null) throw new Error('daytona sandbox not connected')
let command = line
if (stdin !== null) {
await this.upload(STDIN_PATH, stdin)
command = `( ${line} ) < ${STDIN_PATH}`
}
const response = await this.sandbox.process.executeCommand(command, cwd, env)
return {
stdout: ENC.encode(response.result),
stderr: null,
exitCode: response.exitCode,
}
}
private async upload(path: string, data: Uint8Array): Promise<void> {
if (this.sandbox === null) throw new Error('daytona sandbox not connected')
const slash = path.lastIndexOf('/')
const parent = slash > 0 ? path.slice(0, slash) : ''
if (parent !== '') await this.sandbox.fs.createFolder(parent, '755')
await this.sandbox.fs.uploadFile(Buffer.from(data), path)
}
/** Release the SDK client; the sandbox itself is the user's. */
override async close(): Promise<void> {
this.sandbox = null
if (this.client !== null) {
await this.client[Symbol.asyncDispose]()
this.client = null
}
}
}
registerRuntime('daytona', DaytonaRuntime, DAYTONA_OPTION_KEYS)
@@ -0,0 +1,28 @@
// ========= 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 type { SandboxConfig } from '@struktoai/mirage-core'
/** How to reach the user's running container. */
export interface DockerConfig extends SandboxConfig {
/**
* Id or name of a running container. You start it yourself
* (`docker run -d ... sleep infinity`); live FUSE mounts need
* `--cap-add SYS_ADMIN --device /dev/fuse` and an image with mirage
* installed.
*/
container: string
}
export const DOCKER_CONFIG_KEYS: readonly string[] = ['env', 'container']
@@ -0,0 +1,16 @@
// ========= 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. =========
export const DOCKER_CLI_HINT =
'the docker runtime needs the docker CLI on PATH (Docker Desktop, colima, or a podman alias)'
@@ -0,0 +1,107 @@
// ========= 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 { buildRuntime } from '@struktoai/mirage-core'
import { describe, expect, it } from 'vitest'
import type { RemoteSandboxOptions } from '@struktoai/mirage-core'
import type { DockerConfig } from './config.ts'
import { DockerRuntime } from './runtime.ts'
const DEC = new TextDecoder()
const ENC = new TextEncoder()
interface DockerResult {
stdout: Uint8Array
stderr: Uint8Array
code: number
}
class FakeDockerRuntime extends DockerRuntime {
running = true
readonly calls: [string[], Uint8Array | null][] = []
protected override docker(
args: string[],
stdin: Uint8Array | null = null,
): Promise<DockerResult> {
this.calls.push([args.slice(), stdin])
if (args[0] === 'inspect') {
return Promise.resolve({
stdout: ENC.encode(this.running ? 'true\n' : 'false\n'),
stderr: new Uint8Array(),
code: 0,
})
}
const script = args[args.length - 1] ?? ''
return Promise.resolve({
stdout: ENC.encode(`out:${script}`),
stderr: ENC.encode('warn'),
code: 0,
})
}
}
function makeRuntime(
options: RemoteSandboxOptions<DockerConfig> | Record<string, unknown> = {
config: { container: 'cid-42' },
},
): FakeDockerRuntime {
return new FakeDockerRuntime(options)
}
describe('DockerRuntime', () => {
it('connect checks the user container is running', async () => {
const runtime = makeRuntime()
await runtime.connect()
expect(runtime.calls[0]?.[0]).toEqual(['inspect', '--format', '{{.State.Running}}', 'cid-42'])
})
it('connect fails loud on a stopped container', async () => {
const runtime = makeRuntime()
runtime.running = false
await expect(runtime.connect()).rejects.toThrow('not running')
})
it('container is required', () => {
expect(() => makeRuntime({ config: {} })).toThrow('container')
})
it('threads cwd, env, stdin, and real stderr through exec', async () => {
const runtime = makeRuntime()
const result = await runtime.execLine('wc -l', ENC.encode('a\nb\n'), { E: '1' }, '/root/ws')
expect(result.exitCode).toBe(0)
expect(DEC.decode(result.stdout)).toBe('out:wc -l')
expect(DEC.decode(result.stderr ?? new Uint8Array())).toBe('warn')
const [args, stdin] = runtime.calls[runtime.calls.length - 1] ?? [[], null]
expect(args).toEqual([
'exec',
'-i',
'-w',
'/root/ws',
'-e',
'E=1',
'cid-42',
'sh',
'-c',
'wc -l',
])
expect(DEC.decode(stdin ?? new Uint8Array())).toBe('a\nb\n')
})
it("registers under the config name 'docker'", () => {
const runtime = buildRuntime('docker', { config: { container: 'cid-42' } })
expect(runtime).toBeInstanceOf(DockerRuntime)
expect(runtime.captures).toEqual(['*'])
})
})
@@ -0,0 +1,111 @@
// ========= 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 { spawn } from 'node:child_process'
import {
registerRuntime,
RemoteSandbox,
type RemoteSandboxOptions,
type RunResult,
} from '@struktoai/mirage-core'
import { DOCKER_CONFIG_KEYS, type DockerConfig } from './config.ts'
import { DOCKER_CLI_HINT } from './constants.ts'
export const DOCKER_OPTION_KEYS: readonly string[] = ['captures', 'config', 'script']
interface DockerResult {
stdout: Uint8Array
stderr: Uint8Array
code: number
}
/**
* A container the user runs as a whole-line runtime.
*
* You start the container yourself; mirage only connects to it and
* execs lines. The docker CLI is the transport (Docker Desktop,
* colima, or a podman alias all work), so there is no SDK dependency
* and no daemon socket wiring; each line is one `docker exec -i` with
* the merged environment, the rebased cwd, real stdin, and separated
* stderr.
*/
export class DockerRuntime extends RemoteSandbox<DockerConfig> {
readonly name = 'docker'
constructor(options: RemoteSandboxOptions<DockerConfig> | Record<string, unknown> = {}) {
super(options, DOCKER_CONFIG_KEYS)
if (!this.config.container) {
throw new Error('docker config needs container: the id or name of a running container')
}
}
// One docker CLI invocation; the seam tests override.
protected docker(args: string[], stdin: Uint8Array | null = null): Promise<DockerResult> {
return new Promise((resolve, reject) => {
const child = spawn('docker', args, { stdio: ['pipe', 'pipe', 'pipe'] })
const out: Buffer[] = []
const err: Buffer[] = []
child.stdout.on('data', (chunk: Buffer) => out.push(chunk))
child.stderr.on('data', (chunk: Buffer) => err.push(chunk))
child.on('error', (error: NodeJS.ErrnoException) => {
reject(error.code === 'ENOENT' ? new Error(DOCKER_CLI_HINT) : error)
})
child.on('close', (code) => {
resolve({
stdout: new Uint8Array(Buffer.concat(out)),
stderr: new Uint8Array(Buffer.concat(err)),
code: code ?? 1,
})
})
if (stdin !== null) child.stdin.write(stdin)
child.stdin.end()
})
}
async connect(): Promise<void> {
const result = await this.docker([
'inspect',
'--format',
'{{.State.Running}}',
this.config.container,
])
if (result.code !== 0) {
throw new Error(`docker inspect failed: ${decode(result.stderr).trim()}`)
}
if (decode(result.stdout).trim() !== 'true') {
throw new Error(`container ${this.config.container} is not running`)
}
}
async execLine(
line: string,
stdin: Uint8Array | null,
env: Record<string, string>,
cwd: string,
): Promise<RunResult> {
const args = ['exec', '-i', '-w', cwd]
for (const [key, value] of Object.entries(env)) args.push('-e', `${key}=${value}`)
args.push(this.config.container, 'sh', '-c', line)
const result = await this.docker(args, stdin)
return { stdout: result.stdout, stderr: result.stderr, exitCode: result.code }
}
}
const DECODER = new TextDecoder()
function decode(bytes: Uint8Array): string {
return DECODER.decode(bytes)
}
registerRuntime('docker', DockerRuntime, DOCKER_OPTION_KEYS)
@@ -0,0 +1,28 @@
// ========= 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 type { SandboxConfig } from '@struktoai/mirage-core'
/** How to reach the user's live E2B sandbox. */
export interface E2BConfig extends SandboxConfig {
/**
* Id of a sandbox you created (`e2b sandbox spawn` or the SDK),
* booted from a template with fuse3 and mirage installed.
*/
sandboxId: string
/** E2B credential; absent reads E2B_API_KEY. */
apiKey?: string
}
export const E2B_CONFIG_KEYS: readonly string[] = ['env', 'sandboxId', 'apiKey']
@@ -0,0 +1,150 @@
// ========= 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 { buildRuntime } from '@struktoai/mirage-core'
import { beforeEach, describe, expect, it } from 'vitest'
import type { RemoteSandboxOptions } from '@struktoai/mirage-core'
import type { E2BConfig } from './config.ts'
import { E2BRuntime, type E2bSdk } from './runtime.ts'
const DEC = new TextDecoder()
class FakeExitError extends Error {
constructor(
readonly exitCode: number,
readonly stdout: string,
readonly stderr: string,
) {
super(`exit ${String(exitCode)}`)
}
}
class FakeCommands {
calls: [string, Record<string, string> | undefined, string | undefined][] = []
run(command: string, opts?: { envs?: Record<string, string>; cwd?: string }) {
this.calls.push([command, opts?.envs, opts?.cwd])
if (command.includes('exit 3')) {
return Promise.reject(new FakeExitError(3, 'partial', 'boom-err'))
}
return Promise.resolve({ stdout: `out:${command}`, stderr: 'warn', exitCode: 0 })
}
}
class FakeFiles {
files = new Map<string, Uint8Array>()
dirs: string[] = []
makeDir(path: string): Promise<boolean> {
this.dirs.push(path)
return Promise.resolve(true)
}
async write(path: string, data: Blob): Promise<void> {
this.files.set(path, new Uint8Array(await data.arrayBuffer()))
}
}
class FakeSandbox {
static connected: [string, Record<string, unknown>][] = []
static last: FakeSandbox | null = null
readonly sandboxId = 'sb-e2b'
readonly commands = new FakeCommands()
readonly files = new FakeFiles()
static connect(sandboxId: string, params: Record<string, unknown>): Promise<FakeSandbox> {
FakeSandbox.connected.push([sandboxId, params])
FakeSandbox.last = new FakeSandbox()
return Promise.resolve(FakeSandbox.last)
}
}
class FakedE2BRuntime extends E2BRuntime {
protected override loadSdk(): Promise<E2bSdk> {
return Promise.resolve({
Sandbox: FakeSandbox,
CommandExitError: FakeExitError,
} as unknown as E2bSdk)
}
}
function makeRuntime(
options: RemoteSandboxOptions<E2BConfig> | Record<string, unknown> = {
config: { sandboxId: 'sb-live' },
},
): FakedE2BRuntime {
return new FakedE2BRuntime(options)
}
beforeEach(() => {
FakeSandbox.connected = []
FakeSandbox.last = null
})
describe('E2BRuntime', () => {
it('connect attaches by id with the apiKey', async () => {
const runtime = makeRuntime({ config: { sandboxId: 'sb-live', apiKey: 'k-123' } })
await runtime.connect()
expect(FakeSandbox.connected).toEqual([['sb-live', { apiKey: 'k-123' }]])
})
it('sandboxId is required', () => {
expect(() => makeRuntime({ config: {} })).toThrow('sandboxId')
})
it('threads env and cwd and reports real stderr', async () => {
const runtime = makeRuntime()
await runtime.connect()
const result = await runtime.execLine('wc -l', null, { E: '1' }, '/workspace')
expect(result.exitCode).toBe(0)
expect(DEC.decode(result.stdout)).toBe('out:wc -l')
expect(DEC.decode(result.stderr ?? new Uint8Array())).toBe('warn')
const sandbox = FakeSandbox.last ?? new FakeSandbox()
expect(sandbox.commands.calls[0]).toEqual(['wc -l', { E: '1' }, '/workspace'])
})
it('a nonzero exit comes back as a result', async () => {
const runtime = makeRuntime()
await runtime.connect()
const result = await runtime.execLine('exit 3', null, {}, '/workspace')
expect(result.exitCode).toBe(3)
expect(DEC.decode(result.stdout)).toBe('partial')
expect(DEC.decode(result.stderr ?? new Uint8Array())).toBe('boom-err')
})
it('redirects stdin through an uploaded file', async () => {
const runtime = makeRuntime()
await runtime.connect()
const result = await runtime.execLine(
'wc -l',
new TextEncoder().encode('a\nb\n'),
{},
'/workspace',
)
expect(result.exitCode).toBe(0)
const sandbox = FakeSandbox.last ?? new FakeSandbox()
expect(DEC.decode(sandbox.files.files.get('/tmp/.mirage_stdin'))).toBe('a\nb\n')
expect(sandbox.files.dirs).toEqual(['/tmp'])
const [command, , cwd] = sandbox.commands.calls[0] ?? ['', undefined, undefined]
expect(command).toBe('( wc -l ) < /tmp/.mirage_stdin')
expect(cwd).toBe('/workspace')
})
it("registers under the config name 'e2b'", () => {
const runtime = buildRuntime('e2b', { config: { sandboxId: 'sb-live' } })
expect(runtime).toBeInstanceOf(E2BRuntime)
expect(runtime.captures).toEqual(['*'])
})
})
@@ -0,0 +1,112 @@
// ========= 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 {
loadOptionalPeer,
registerRuntime,
RemoteSandbox,
type RemoteSandboxOptions,
type RunResult,
STDIN_PATH,
} from '@struktoai/mirage-core'
import { E2B_CONFIG_KEYS, type E2BConfig } from './config.ts'
import type { CommandResult, Sandbox } from 'e2b'
import type * as e2bSdk from 'e2b'
export type E2bSdk = typeof e2bSdk
const ENC = new TextEncoder()
export const E2B_OPTION_KEYS: readonly string[] = ['captures', 'config', 'script']
/**
* An E2B sandbox the user runs as a whole-line runtime.
*
* You create the sandbox yourself (`e2b sandbox spawn` or the SDK);
* mirage only connects by `sandboxId` and execs lines. `apiKey` falls
* back to E2B_API_KEY. E2B's exec reports stdout and stderr
* separately, so both stream back real; it takes no stdin, so piped
* bytes are uploaded and redirected in.
*/
export class E2BRuntime extends RemoteSandbox<E2BConfig> {
readonly name = 'e2b'
private sdk: E2bSdk | null = null
private sandbox: Sandbox | null = null
constructor(options: RemoteSandboxOptions<E2BConfig> | Record<string, unknown> = {}) {
super(options, E2B_CONFIG_KEYS)
if (!this.config.sandboxId) {
throw new Error('e2b config needs sandboxId: the id of a live sandbox you created')
}
}
// The SDK loader as a seam: tests substitute a fake module here.
protected loadSdk(): Promise<E2bSdk> {
return loadOptionalPeer(() => import('e2b'), {
feature: "the 'e2b' runtime",
packageName: 'e2b',
})
}
private async ensureSdk(): Promise<E2bSdk> {
this.sdk ??= await this.loadSdk()
return this.sdk
}
private apiParams(): Record<string, unknown> {
return this.config.apiKey !== undefined ? { apiKey: this.config.apiKey } : {}
}
async connect(): Promise<void> {
const sdk = await this.ensureSdk()
this.sandbox = await sdk.Sandbox.connect(this.config.sandboxId, this.apiParams())
}
async execLine(
line: string,
stdin: Uint8Array | null,
env: Record<string, string>,
cwd: string,
): Promise<RunResult> {
if (this.sandbox === null) throw new Error('e2b sandbox not connected')
const sdk = await this.ensureSdk()
let command = line
if (stdin !== null) {
await this.upload(STDIN_PATH, stdin)
command = `( ${line} ) < ${STDIN_PATH}`
}
let result: Pick<CommandResult, 'stdout' | 'stderr' | 'exitCode'>
try {
result = await this.sandbox.commands.run(command, { envs: env, cwd })
} catch (error) {
if (!(error instanceof sdk.CommandExitError)) throw error
result = error
}
return {
stdout: ENC.encode(result.stdout),
stderr: ENC.encode(result.stderr),
exitCode: result.exitCode,
}
}
private async upload(path: string, data: Uint8Array): Promise<void> {
if (this.sandbox === null) throw new Error('e2b sandbox not connected')
const slash = path.lastIndexOf('/')
const parent = slash > 0 ? path.slice(0, slash) : ''
if (parent !== '') await this.sandbox.files.makeDir(parent)
await this.sandbox.files.write(path, new Blob([data]))
}
}
registerRuntime('e2b', E2BRuntime, E2B_OPTION_KEYS)
@@ -105,7 +105,7 @@ describe('configToWorkspaceArgs', () => {
it('rejects an unknown runtime entry name', async () => {
const cfg = loadWorkspaceConfig({
mounts: { '/': { resource: 'ram' } },
runtimes: ['docker'],
runtimes: ['nosuchruntime'],
})
await expect(configToWorkspaceArgs(cfg)).rejects.toThrow(/unknown runtime/)
})
@@ -41,6 +41,26 @@ describe('execute router', () => {
await app.close()
})
it('honors a cwd for the line', async () => {
const app = buildApp()
await createWs(app, 'ecwd')
await app.inject({
method: 'POST',
url: '/v1/workspaces/ecwd/execute',
payload: { command: 'mkdir -p /sub && echo -n nested > /sub/f.txt' },
})
const res = await app.inject({
method: 'POST',
url: '/v1/workspaces/ecwd/execute',
payload: { command: 'cat f.txt', cwd: '/sub' },
})
expect(res.statusCode).toBe(200)
const body = res.json<{ stdout: string; exitCode: number }>()
expect(body.exitCode).toBe(0)
expect(body.stdout).toBe('nested')
await app.close()
})
it('passes base64 stdin to command execution', async () => {
const app = buildApp()
await createWs(app, 'estdin')
@@ -32,6 +32,7 @@ interface ExecuteBody {
sessionId?: string
provision?: boolean
agentId?: string
cwd?: string
stdinBase64?: string
}
@@ -54,6 +55,7 @@ export function registerExecuteRoutes(app: FastifyInstance, deps: ExecuteRoutesD
entry.runner.ws.execute(body.command, {
...(body.sessionId !== undefined ? { sessionId: body.sessionId } : {}),
...(body.agentId !== undefined ? { agentId: body.agentId } : {}),
...(body.cwd !== undefined ? { cwd: body.cwd } : {}),
...(body.provision === true ? { provision: true as const } : {}),
...(body.stdinBase64 !== undefined
? { stdin: new Uint8Array(Buffer.from(body.stdinBase64, 'base64')) }
+751 -49
View File
File diff suppressed because it is too large Load Diff