Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 755260d7f6 | |||
| 509e28b790 | |||
| 5e4090cd08 | |||
| b261fea2eb | |||
| 335e6c9cc5 | |||
| e40bb167b6 | |||
| b10a943a42 | |||
| 986ed19e9c | |||
| 89ec4e7132 | |||
| 14383595e5 | |||
| 860e81d703 | |||
| da18759510 | |||
| e9d5b84df4 | |||
| 1a856552ed | |||
| 8f2ccc6496 | |||
| 9a1c1778ec | |||
| 829aaf6b1e | |||
| 8abadfc4ff | |||
| 550c626eb7 | |||
| c0df14d354 | |||
| ef068e630a | |||
| 73bd24e905 |
@@ -0,0 +1,6 @@
|
||||
*
|
||||
!python
|
||||
python/.venv
|
||||
python/**/__pycache__
|
||||
python/.mypy_cache
|
||||
python/.pytest_cache
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
Generated
+187
-1
File diff suppressed because one or more lines are too long
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')) }
|
||||
|
||||
Generated
+751
-49
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user