# Daytona runtime

`delegate_task` is arbe's coding tool. It runs a coding agent in the Daytona box bound to
an environment.

This doc is the daytona implementation. It runs pi in a Daytona sandbox, mirrors pi's
session into one durable arbe thread, and resumes a run from that thread. The code is in
`packages/sandbox/src/daytona/`. Each layer has a runnable proof at `proofs/prove-*.ts`
(`bunx varlock run -- bun proofs/<file>` from that dir; see `proofs/.env.schema`).

## The shape

The thread is the only durable handle, and the source of truth. A thread is a durable
stream of entries. Everything else is a cache you can lose — pi's session files on disk,
the in-process sandbox handle, any real-time watcher — and every cache writes to the
thread or reads from it.

The parts:

- `thread` — the durable stream and source of truth. Entry types: `chat`, `pi.*`,
  `signal.*`.
- finished-signal — the `signal.thread.*` entry that ends a run. Status is one of
  `status_changed:completed`, `pi_failed`, or `pi_session_orphaned`.
- pi — the coding-agent process.
- `CodingAgent` — the harness descriptor (binary, file extension, how a run ends) that
  selects pi, codex, or claude-code. Not arbe's `Agent`, which is the persona (model plus
  instructions) composed onto a `CodingAgent`.
- `arbe-pi-runner` — starts pi inside the sandbox, owns pi's exit code, and posts the
  finished-signal when pi exits. One per harness; lives in `src/runner.ts`.
- the mirror — a pi extension that posts `pi.*` entries plus a heartbeat every ~5s. One
  extension, posting directly; no relay process.
- `arbe-proxy` — a Supabase Edge Function the sandbox calls arbe through, because it can't
  reach the CF worker. Carries all of `/api/*`. Covered under egress below.

A run, end to end:

```text
  launchCodingAgent(pi, sandbox, task, { thread? })   spawns a session (or uses thread),
        │  provision + fire DETACHED                       returns the thread id at once
        ▼
  ╭──────────────────── Daytona sandbox ────────────────────╮
  │   arbe-pi-runner ─▶ pi ─▶ tool calls                     │
  │     owns exit code  ╰─ pi extension (the mirror)         │
  │     posts terminal on exit                               │
  ╰─────────────────────────────┬───────────────────────────╯
                                │  pi.* + heartbeat, via scoped JWT
                                ▼
                      edge fn (arbe-proxy)
                                │
                                ▼
        ╭───────────────────── thread ──────────────────────╮
        │  source of truth · entries: chat | pi.* | signal.* │
        │  a finished-signal ends a run                      │
        ╰────────────────────────────────────────────────────╯
```

A run is never silently terminal. Whoever is still alive when the run ends posts the
finished-signal:

```
  the mirror       soft error (pi emits stopReason:"error")   inside pi
  arbe-pi-runner   pi crashed, sandbox alive (has exit code)  → pi_failed
  pull-confirm     the sandbox itself died (no exit code)     → pi_session_orphaned
```

The heartbeat makes "dead" decidable from "still thinking" using the thread alone, so no
watcher is needed.

The thread's Postgres row is a lazy cache of the stream. Dispatch claims the child
`idle → running` when it fires the runner (`launchOnDaytona` posts the `status_changed`
readiness milestone), and that claim arms the read-path reconcile (`reconcileStuckThread`,
run on every thread GET): it adopts the stream's finished-signal onto the row, or — when the
stream has gone silent past the orphan threshold — flips the row to `failed` with
`pi_session_orphaned`. Without the claim the row stays `idle` and reconcile does nothing, so
a run that dies in bootstrap, before pi ever mirrors, would hang dark forever. For a stuck
`running` thread, reconcile also asks Daytona whether the box still exists, so a dead box is
settled at once instead of waiting out the threshold — see [box lifecycle](#box-lifecycle).

When the reconciled thread is a delegated child (a `delegate_task` run,
`parent.kind:'thread'`), that same reconcile notifies the parent: it posts a
`signal.thread.child_finished` plus a brain-authored `chat` carrying the child's result onto
the parent thread, with no dispatch re-fire. See [dispatch](../chat/dispatch.md).

Resume rebuilds the same handles from the thread, not from disk. pi's disk `--continue` is
a cache that goes stale once the thread is continued on another device. The pi session
format and what reconstruction can and can't recover are worked out in
[resume notes](../../thinking/pi-resume.md).

```
  same sandbox   { resume }           pi --continue <disk cache>     L5
  fresh sandbox  { resume, hydrate }  rebuild session from thread,   L7
                                      upload, pi --session <file>    (no disk --continue)
```

pi's session is one portable JSONL file, but the mirror posts a lossy projection of it:
user turns become `chat`, assistant turns become `pi.assistant`, and tree ids, labels, and
model entries are dropped. So a fresh-sandbox resume can't replay the thread directly. It
rebuilds a loadable linear session with `pi.reconstructSession` and loads it with
`--session` (not `--continue`, which scans a directory filtered by cwd and raced by mtime).
The prompt is canonical on the thread: `run()` posts it as a `chat` entry before driving
pi, so a rebuild reads it from the thread.

## Library (`src/`)

- `thread.ts` — the write side. `openThread()` mints a scoped grant and returns the stream
  endpoint; create, post, and read delegate to `@arbe/core/entries`.
- `sandbox.ts` — the compute side. `createSandbox()` returns a fresh Daytona sandbox with
  `.exec` and `.provision(agent)`.
- `coding-agent.ts` — the pi `CodingAgent` descriptor.
- `decide-pi-outcome.ts` — `decidePiOutcome` maps pi's stopReason and exit code to an
  outcome plus the `signal.thread.*` entries to post.
- `run.ts` — `run()`, the synchronous host driver: provision, drive pi, read the thread
  back; the host posts the terminal entry. Used by the proofs and `cli.ts`.
- `launch-coding-agent.ts` — `launchCodingAgent()`, the daytona body of `delegate_task`:
  spawn a session (or use a given thread), fire `arbe-pi-runner` detached, return the
  thread id at once. The CF worker can't block for minutes, so dispatch needs the detached
  shape. Its default thread is an `openThread()` grant and stream with no DB row; the
  parented child row with `environmentId` is core `createThread`'s job.
- `runner.ts` — `arbe-pi-runner`, running in the sandbox: owns pi's exit code, reads the
  thread back, runs `decidePiOutcome`, and posts the terminal entry on exit.

`run` and `launchCodingAgent` are two callers of the same handles. `run` blocks on `exec`
and the host posts the terminal; `launchCodingAgent` returns at once and the in-sandbox
runner posts the terminal. Both feed `decidePiOutcome` the same two inputs, the thread and
the exit code. A second harness (codex, claude-code) is a second `CodingAgent` with its own
`decideMessageType`; the orchestrator does not change.

## Egress and env

Daytona egress is whitelist-only and matched by domain. The sandbox can reach `npm`,
`github*`, `cloudflare.com`, and our Supabase host `*.supabase.co` (plus the other
[default-allowlisted services](https://www.daytona.io/docs/en/network-limits.md): package
managers, git hosts, container registries, LLM APIs). It cannot reach the CF worker
`arbe.0sk.ar`, `workers.dev`, or any other host, and a tunnel doesn't help because the
block is by domain. At our org tier the restriction cannot be overridden per sandbox;
lifting it means Daytona tier 3 (~400 EUR/mo), which we've decided against. So: the open
web is reachable only via a proxy on an allowlisted host, if a workload ever truly needs
it — none does today, since the harness itself only needs the allowlist (arbe-5783).
The signature of a blocked host: HTTPS gets a TLS reset (curl exit 35, `000` status),
plain HTTP gets a proxy 403.

Don't take any of that on faith — measure it:

```
bunx varlock run -- bun run scripts/sandbox-egress-probe.ts <daytona-sandbox-id> [--url <extra>]
```

It runs curl inside a real box and prints REACHED/BLOCKED per host. Run it before building
anything that calls out from a sandbox, and to re-confirm the claims here rather than
trusting a doc that has had time to go stale.

That is why sandbox→arbe traffic runs through a shim. `*.supabase.co` is reachable, so the
proxy is deployed as a Supabase Edge Function at `supabase/functions/arbe-proxy`. It
forwards `/api/*` verbatim — method, path, query, body (binary-safe), and the caller's own
`Authorization`/`Content-Type` — to `arbe.0sk.ar`, and refuses anything outside that prefix.
One secret: `ARBE_APP_URL=https://arbe.0sk.ar`. Deploy with
`bunx supabase functions deploy arbe-proxy` (config pins `verify_jwt=false`).

The shim is **not** a trust boundary and must never become one. Every route behind it is
already on the public internet under the same route-level auth; the proxy restores
reachability from one room and nothing else. It verifies nothing, mints nothing, rewrites
nothing — so the route stays the single place trust is decided. A proxy that starts making
decisions is a second, weaker auth surface.

Everything a sandbox calls hangs off one base, `API_URL` in
`packages/sandbox/src/daytona/config.ts` (override: `ARBE_API_URL`), and `STREAM_URL` is
derived from it rather than hard-coding a second host.

Three more sandbox facts: every create explicitly follows Daytona's managed `daytona-small`
snapshot and requests `language: 'typescript'`; pi installs in-sandbox with
`npm i -g @earendil-works/pi-coding-agent@0.78.0`; `gh` is not in the image, so a box the
house's GitHub connection reaches installs a pinned release (`gh.ts`) before its first command;
and the sandbox runs as a non-root user, so upload artifacts to a HOME-relative path, not
`/root`.

The mirror's write path:

```
mint:   mintStreamWriteJwt(houseId, threadId, DURABLE_STREAMS_SECRET) -> { jwt, expiresAt }  (2h TTL)
write:  POST <url>/api/stream/arbe-thread-<threadId>   Authorization: Bearer <jwt>
        body = { id, ts, authorId?, payload }          (payload: chat | pi.* | signal.*)
read:   GET  <url>/api/stream/arbe-thread-<threadId>?offset=0   Authorization: Bearer <jwt>  (NDJSON)
```

A scoped token can only touch its own thread; a cross-thread write returns 403.

Alongside the plumbing below, a box also gets the house secrets its environment
binds — `run_command` exports them onto the command it execs, `delegate_task` into the
environment pi runs under. Bound names only, and never arbe's own operator keys; the
boundary and its two rules are in [secrets](../access/secrets.md). Binding is the opt-in, so a
`GITHUB_TOKEN` the house holds but no environment binds reaches nothing.

The pi extension reads its config from these environment variables (via
`readPiThreadMirrorEnv` in `packages/sandbox/src/pi-extension/thread-mirror.ts`). The
sandbox must export the same canonical names dispatch uses:

| Env var | Required | Meaning |
|---------|----------|---------|
| `ARBE_THREAD_ID` | yes | target thread |
| `ARBE_STREAM_URL` | yes | stream-write base URL — `arbe-proxy` from the sandbox; the CF worker only off-sandbox |
| `ARBE_STREAM_TOKEN` | yes | scoped `stream:write` JWT (the `jwt` from `mintStreamWriteJwt`) |
| `ARBE_AUTHOR_ID` | no | informational author hint; the stream proxy stamps from the token's `agent` claim |
| `ARBE_PI_MIRROR_NEXT_INDEX` | no | resume offset |

## Publishing to the house files

A delegated coding agent can write an artifact into the house's shared file tree. The
capability rides the parent stream token — dispatch mints it with `caps: ['volume:write']`
and the acting agent id (`PARENT_GRANT`), so the box supplies bytes and a path and never an
identity:

```sh
curl -sS -X PUT "$ARBE_API_URL/api/houses/$ARBE_HOUSE_ID/files/<path>" \
  -H "Authorization: Bearer $ARBE_FILES_TOKEN" \
  -H "Content-Type: text/markdown" --data-binary @<file>
```

A capability-token write is **always** the raw file, so `Content-Type` means the file's own
media type and is stored
as such — `text/plain` publishes a .txt, `application/json` publishes a .json. The JSON
write envelope (`{ content, mime?, message? }`) is the member/UI door only. `text/plain`
is an HTML form enctype and was refused by CSRF before the route ran; bearer requests are
now exempt (`enforceCsrf` in `apps/www/src/hooks.server.ts`, with SvelteKit's builtin
`csrf.checkOrigin` off in `svelte.config.js` because it runs ahead of every hook).

| Env var | Required | Meaning |
|---------|----------|---------|
| `ARBE_API_URL` | no | arbe API base through the egress shim (`API_URL`) |
| `ARBE_HOUSE_ID` | no | house the volume write targets |
| `ARBE_FILES_TOKEN` | no | the parent JWT, when it carries `volume:write` |

All three are set together or not at all, and `launchCodingAgent` decodes the token's own
claims to decide (`carriesVolumeWrite`) rather than trusting a flag — a prompt describing a
curl that would 403 is worse than saying nothing. When they are set, the harness appends the
publish instructions to pi's system prompt; the persona's `instructionsSource` still
describes the persona, not this note.

The route is the only place that decides anything: it verifies the signature, takes house,
author and thread from the claims (never the URL or an env var — anything in a sandbox can
forge `ARBE_AUTHOR_ID`), and refuses DELETE to a capability token. The stream proxy likewise
stamps every entry from the token's `agent` claim and refuses authorless append tokens. Child
tokens name the delegating bot; parent handover tokens name the house system agent. Raw writes preserve the
file bytes and declared media type, including binary artifacts. `scripts/volume-publish-proof.ts`
drives the single-file PUT door with real curl, including every refusal. The changeset POST door
still needs an equivalent live proof.

### Sandbox files stay in the sandbox

Finishing a run publishes nothing. `arbe-pi-runner` never reads the working tree after pi
exits and never sends a volume request; files the coding agent wrote stay on the box, reachable
through the normal sandbox workflow (`arbe sandbox`, `run_command`, a resumed session). Putting a
file in the house is the agent's own explicit act during the run, through the curl door above.
`delegate_task` accepts no file list: sandbox work and house-file publication are separate
actions.

## Box lifecycle

A sandbox is a machine, with a lifecycle independent of any run. pi is one process inside
it; many threads, commands, and runs can share a box, and the box outlives any single run.
A pi terminal ends a process, never the machine — box teardown is
never keyed on a run's terminal, and never on a wall-clock timeout against the run.

There is no run timeout. A coding agent runs until it finishes; `ARBE_PI_TIMEOUT` is a
~3-day runaway guard for a hung process, not a run length.

Reaping is arbe-owned and idle-based. A box stays up while any thread on it
(`threads.sandbox_id`) is `running`; once none is, the reconcile/prune sweep
(`reconcileStuckThread` / `pruneStuckThreads`) deletes it — the same seam that clears stale
threads. When a run finishes there, `reapSandboxIfIdle` deletes the box (via an injected
`reapBox`, so `@arbe/core` stays provider-free) and tombstones its row. Resume re-resolves
to the environment's live box, or makes a fresh one, so idle means delete; a stopped box
buys nothing. The discriminator is `sandboxes.ephemeral`: a replacement box created only to
resume an old child is reapable; a box attached to the parent for later delegated work and an
environment's shared `run_command` box are retained.

Death is pull-confirmed, never webhook-driven — one org-wide webhook is a forged-event risk
against other houses' boxes. `confirmSandboxLiveness(sandboxId, probeBox)` asks Daytona
about a house-scoped `provider_ref` through that house's own runtime: a missing box (404) or
terminal-fault state (`error`/`build_failed`/`removing`) tombstones the `sandboxes` row to
`dead` and orphans the threads on it; a `live`/`unknown` verdict is a no-op, so a network
blip never tombstones a real box. It rides the reaper's two seams: the read-path/prune
reconcile for the current thread and `GET /api/sandboxes?house_id=<id>&reconcile=1` for a
cold-row sweep; there is no cron. `probeBox` is injected by the worker, mirroring `reapBox`.

Daytona's own auto-stop/delete is the dumb backstop. `createSandbox` sets `autoStopInterval`
from the runaway guard plus a buffer (Daytona counts only API calls as activity, and a
detached run makes none, so the clock runs from launch — the value must clear the guard or
it would stop a live run) and `autoDeleteInterval: 0`. Before a detached run reuses an existing
box, `connectSandbox` raises that box's interval to the same guard-plus-buffer value; otherwise
a shared box's default 15-minute stop could kill a healthy agent. This catches an abandoned
box (worker dead, runner crashed) after ~3 days; it is not the primary reaper.

Spawned boxes are labelled `arbe.house` / `arbe.thread` / `arbe.environment` on create, so
`arbe sandbox list` shows which environment or run owns a box.

See [dispatch](../chat/dispatch.md),
[environments](environments.md), [secrets](../access/secrets.md), [runtime](runtime.md).
