docs/sandbox-model.md
Rama main · réplica de lectura del repositorio original.
# Sandbox Model
A **sandbox** is the execution environment an agent uses to run shell commands and host its workspace files. Each agent has zero or more sandboxes, each persisted as a row in the `sandboxes` table; the runtime constructs an `ExecBackend` from each row when the agent starts.
Four backend types are supported:
- `docker` — runs commands inside a per-agent Docker container managed by the gateway.
- `e2b` — runs commands inside an [E2B](https://e2b.dev) cloud sandbox.
- `daytona` — runs commands inside a [Daytona](https://www.daytona.io) cloud sandbox.
- `tenki` — runs commands inside a [Tenki](https://tenki.cloud) cloud sandbox.
## Sandbox Row
Each sandbox row carries:
| Field | Description |
|---|---|
| `id` | UUID |
| `agent_id` | Owning agent |
| `alias` | Per-agent unique name. The default alias is `default`. |
| `type` | `docker` / `e2b` / `daytona` / `tenki` |
| `config` | Type-specific config blob (see below) |
| `status` | Lifecycle state: `pending` → `provisioned` → `deleted` (soft delete) |
| `external_id` | Backend-assigned identifier (container name, e2b sandbox id, etc.) |
| `runtime_state` | Backend-private JSONB used to reconnect after a gateway restart (e.g. queued skill syncs) |
| `last_seen_at` | Last successful `ensure()` |
A row starts at `pending` and flips to `provisioned` the first time the backend's `ensure()` succeeds. The `runtime_state` lets backends like `e2b` and `daytona` reconnect to a long-lived remote sandbox across gateway restarts. Live runtime state (running vs stopped vs paused) is intentionally **not** stored — the backend itself is the source of truth.
`alias` is unique per agent under a partial index that excludes `deleted` rows, so the same alias can be re-created after a soft delete.
## Backend Configs
> **Note:** The `host` backend (run commands directly on the gateway machine, as
> the gateway process user, with no isolation) has been **removed**. It granted
> unsandboxed shell access to the production host and was abused to read gateway
> secrets. Agents must use an isolated backend (`docker` / `e2b` / `daytona` /
> `tenki`); there is no implicit fallback — an agent with no configured sandbox
> has no exec environment.
### `docker`
```json
{
"type": "docker",
"config": {
"image": "ubuntu:24.04",
"username": "root",
"agent_home": "/root"
}
}
```
`username` and `agent_home` default to `root` / `/root` for ubuntu images. The agent's workspace dir is mounted at `agent_home`. `external_id` records the container name. Optional `memory_limit`, `cpu_shares`, and a per-backend `lifecycle` block override the agent-level lifecycle.
### `e2b`
```json
{
"type": "e2b",
"config": {
"template": "base",
"username": "user",
"agent_home": "/home/user",
"sandbox_timeout_ms": 3600000
}
}
```
Requires `E2B_API_KEY` in the gateway environment. `template` is the E2B template id. `external_id` records the e2b sandbox id; `runtime_state` queues skill-sync operations so they replay deterministically when the sandbox is reconnected after a gateway restart.
### `daytona`
```json
{
"type": "daytona",
"config": {
"image": "ubuntu:24.04",
"username": "daytona",
"agent_home": "/home/daytona"
}
}
```
Requires `DAYTONA_API_KEY`. Pass `snapshot` (snapshot id) or `image` (mutually exclusive). Daytona auto-stops idle sandboxes after `auto_stop_interval_minutes` (Daytona default 15) and auto-archives after 7 days; `ensure()` calls `start()` which transparently recovers archived sandboxes — no explicit recover step is needed.
### `tenki`
```json
{
"type": "tenki",
"config": { "timeout_ms": 300000 }
}
```
Requires `TENKI_API_KEY` (or `TENKI_AUTH_TOKEN`). The backend uses Tenki SDK 1.4 or newer and works in `/home/tenki` by default. Set `workspace_id` when selecting a workspace explicitly.
`timeout_ms` is the guest-enforced per-command budget, defaulting to five minutes. Timed-out commands return exit code 137 and a timeout diagnostic while preserving partial stdout and stderr. A stalled transport is aborted after an additional five-second grace period; when no result arrives, partial output is unavailable. `shutdown()` pauses the sandbox; `ensure()` reconnects and resumes it using persisted runtime state.
## Sandbox Presets
Presets live in `gateway.json` and let operators define a small registry of named sandbox templates that users can pick from at agent-create time.
```json
{
"sandboxPresets": {
"docker-ubuntu": {
"type": "docker",
"config": { "image": "ubuntu:24.04", "username": "root", "agent_home": "/root" }
},
"e2b-default": { "type": "e2b", "config": { "template": "base" } },
"daytona-default": { "type": "daytona", "config": {} }
},
"autoProvisionSandbox": "docker-ubuntu"
}
```
`autoProvisionSandbox` references a preset by name (or `null` to disable auto-provisioning). It is the default when an agent is created without an explicit `sandbox` field.
`POST /api/agents` accepts a `sandbox` field:
- omitted → use `autoProvisionSandbox`
- string → use that preset
- `null` → skip provisioning entirely (the agent has no sandbox until one is added)
The web UI's create-agent dialog renders a dropdown populated from `GET /api/sandbox-presets`.
## Per-Agent Sandbox API
| Method | Path | Notes |
|---|---|---|
| `GET` | `/api/agents/:id/sandboxes` | List active sandboxes for the agent |
| `POST` | `/api/agents/:id/sandboxes` | Add a sandbox: `{ type, alias?, config? }`. Defaults `alias: "default"`. |
| `DELETE` | `/api/agents/:id/sandboxes/:alias` | Soft-delete (status flips to `deleted`) |
CLI mirror:
```bash
hermit sandbox list --agent <id>
hermit sandbox add --agent <id> --type docker --config '{"image":"ubuntu:24.04"}'
hermit sandbox add --agent <id> --type e2b --config '{"template":"base"}'
hermit sandbox remove --agent <id> default
```
The admin UI's **Sandboxes** tab lists every sandbox across all agents, with the lifecycle column from the DB and a runtime column that overlays live `docker ps` info for docker rows (or `—` when the container isn't on this host).
## Lifecycle (per-backend behavior)
The agent config's `exec.lifecycle` block controls when a backend is brought up and torn down:
```json
{
"exec": {
"lifecycle": {
"start": "ondemand",
"stop": "idle",
"idle_timeout_minutes": 5
}
}
}
```
Start policy:
- `ondemand` — `ensure()` runs the first time a tool call needs the backend
- `session` — `ensure()` runs when a session opens
Stop policy:
- `idle` — `shutdown()` runs after `idle_timeout_minutes` of inactivity
- `session` — `shutdown()` runs when the session ends
Soft-deleting a sandbox row (via DELETE) does **not** automatically tear down the remote resource for `e2b` / `daytona`; clean those up explicitly through the respective provider if you want to reclaim the cloud sandbox immediately.
## Security Policy
The security policy (in the `agents.security_json` column, managed via `PUT /api/agents/:id/security` or `hermit config security`) is a separate concern from the sandbox: the sandbox decides **where** commands run; the security policy decides **who can talk to the agent** and **when commands need approval**.
```json
{
"access": "private",
"autonomy_level": "supervised",
"require_approval_for": ["exec"],
"access_token": "..."
}
```
`access` ∈ `public` / `protected` / `private`:
- `public` — any sender can open a session; unknown channel identities auto-claim a `guest` membership
- `protected` — sender must present `access_token` (via `/members` self-join) to become a member
- `private` — only owner / admin can add members; everyone else gets a 404 on session open
The runtime enforces `access` at session-open time: a sender with no membership row on a non-public agent is rejected before any message is processed, regardless of whether they have a global user record from another agent.