openswarm-runner
One ephemeral Linux container that executes ONE OpenSwarm workflow run and exits. One Fly Firecracker machine per run, no state kept.
Build
The build context is the repo root, not this directory (the image needs backend/
and backend/requirements.lock):
docker build --platform linux/amd64 -f openswarm-runner/Dockerfile -t openswarm-runner .
Run
The container is told everything it needs by one JSON run spec in OPENSWARM_RUN_SPEC
(or a path in OPENSWARM_RUN_SPEC_FILE). See runner/run_spec.py for the typed shape.
{
"run_id": "cr_01J...",
"workflow": { "id": "wf_1", "title": "Daily digest", "model": "opus-5",
"steps": [{ "text": "summarize my inbox" }] },
"credentials": [
{ "provider": "claude", "auth_type": "oauth",
"access_token": "<already refreshed by the control plane>",
"expires_at": "2026-07-31T20:00:00Z" }
],
"callback": { "url": "https://api.openswarm.com/api/cloud-runs/cr_01J.../report",
"token": "<two-party runner token, not a user credential>" },
"max_run_seconds": 1800
}
Exit codes: 0 ok, 1 runner crash, 2 bad spec, 3 credential expired on arrival,
4 backend never came up, 5 workflow failed, 6 wall-clock cap hit.
The credential rule
A providerConnections[] entry this runner writes never contains a refreshToken.
9Router's refresh dispatcher bails on if (!b || !b.refreshToken) return null, so
omitting the field is what makes the container incapable of rotating the user's grant.
If it ever rotated, the user's laptop would be left replaying a dead token and the
provider would revoke the whole grant family.
Two independent walls enforce it, and a third makes a leak require deleting the code that builds the entry:
ProviderCredentialforbids extra fields, so a spec carryingrefreshTokenfails validation before the backend boots.assert_no_refresh_tokenre-reads the assembled db payload just before the write.router_connectionassembles the entry from a fixed key list, never a passthrough.
All three live in runner/seed/router_credentials.py.
An access token that arrives expired fails the run (exit 3). The runner never refreshes.
Test
PYTHONPATH=.:openswarm-runner backend/.venv/bin/python3 -m pytest openswarm-runner/tests -q
Deploy
The app exists and is created onto its own isolated private network. Read fly.toml's
header before touching it; the network is fixed at create time and cannot be changed
by a redeploy.
# from the REPO ROOT, the image needs backend/ in its build context
fly deploy . --app openswarm-runner --config openswarm-runner/fly.toml \
--dockerfile openswarm-runner/Dockerfile --image-label latest --ha=false
--image-label latest is load-bearing: the control plane creates machines from the
fixed tag registry.fly.io/openswarm-runner:latest, so a redeploy without it ships an
image nothing will ever boot. Re-verify the isolation after any deploy, do not assume
it survived:
fly machine run registry.fly.io/openswarm-runner:latest -a openswarm-runner \
--entrypoint /bin/sleep --restart no --vm-memory 512 --vm-cpus 1 600
fly ssh console -a openswarm-runner --machine <id> -C "getent hosts openswarm-cloud.internal"
# must print nothing and exit 2. Then destroy the probe machine.
The deploy leaves one stopped template machine with no run spec. That is expected; it
exits 2 immediately and [[restart]] policy = 'never' stops it looping.
How a run gets here
openswarm-cloud creates one machine per due workflow through the Fly Machines API
(workflows/dispatch.ts). It never uses fly deploy for a run, so this app's env is
whatever the IMAGE carries plus OPENSWARM_RUN_SPEC_FILE; fly.toml's settings do not
reach a per-run machine. Control-plane side that means:
| env on openswarm-cloud | why |
|---|---|
FLY_API_TOKEN |
app-scoped deploy token for openswarm-runner, nothing wider |
RUN_CALLBACK_BASE_URL |
where the runner reports; no default, so a staging control plane can never point its machines at prod |
RUNNER_APP / RUNNER_IMAGE / RUNNER_REGION |
optional overrides of openswarm-runner / the :latest tag / iad |
CLOUD_RUNS_GLOBAL_CAP |
machines this whole service will run at once, all accounts together (default 50) |
CLOUD_RUNS_TICK_BUDGET |
machines one 60s tick will start (default 20); the rest keep their slot for the next tick |
A run gets three walls on its wall clock, and only the third survives a wedged VM:
the runner stops its own poll loop at max_run_seconds, an independent thread inside
it kills the process 90s later, and the control plane destroys the machine outright
5 minutes past that. Verified live: a machine with a sleeping entrypoint that never
reported was destroyed by the control plane and its run row closed as failed.