Metadata-Version: 2.5
Name: rindo-provisioner
Version: 0.1.0
Summary: The Rindo reference provisioner: turns a runner pool's demand into one-job ephemeral runners inside E2B sandboxes
Project-URL: Homepage, https://rindo.io
Project-URL: Repository, https://github.com/rindohq/rindo
Author: Rindo
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: agent,e2b,provisioner,rindo,runner
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Software Development
Requires-Python: >=3.11
Requires-Dist: e2b<3,>=2.49.1
Requires-Dist: httpx>=0.28
Description-Content-Type: text/markdown

# rindo-provisioner — the Rindo reference provisioner (decision #90, RP3)

A controller that turns ONE pool's demand into one-job **ephemeral runners** inside
[E2B](https://e2b.dev) sandboxes. It reads the pool's demand, mints a registration
per job it can serve, creates a sandbox from the agent-host template, revokes the
sudo grant the sandbox provisioning added, starts `rindo-runner` inside it with
`executor = "process"` and the registration's credential, and kills the sandbox
when that runner exits. It never claims a job, never calls `/mcp` or a runner verb,
never reads a payload, never retries a job, never listens inbound, never calls an
LLM — the server stays dumb and the provisioner stays outside it.

Operator documentation lives in the Rindo ops runbook §4.13 ("Runner pools and
the provisioner"). This README is the package-local quickstart.

## Install (on the operator box)

```sh
uv tool install rindo-provisioner       # or: pip install rindo-provisioner
rindo-provisioner --check
# from a source checkout instead: cd agents/provisioner && uv sync && uv run rindo-provisioner --check
```

Python 3.11+. Two runtime dependencies: `httpx` (the two pool doors are plain REST)
and the `e2b` SDK (the sandbox substrate). Apache-2.0 (see LICENSE — like `rindo-runner`, not part of the proprietary Software); published
on PyPI.

## Configure

Flags > `RINDO_PROVISIONER_<KEY>` env vars > a TOML file > defaults, validated
before any network call (exit 2 on a problem). The two secrets have **no flag**:

| Setting | Flag | Env var | Config key | Default |
| --- | --- | --- | --- | --- |
| Server URL | `--server-url` | `RINDO_PROVISIONER_SERVER_URL` | `server_url` | — |
| Pool token (`rndp_…`) | *(none)* | `RINDO_PROVISIONER_TOKEN` | `token` | — |
| E2B API key (`e2b_…`) | *(none)* | `E2B_API_KEY` | `e2b_api_key` | — |
| Runtime (one per process) | `--runtime` | `RINDO_PROVISIONER_RUNTIME` | `runtime` | — |
| Template | `--template` | `RINDO_PROVISIONER_TEMPLATE` | `template` | — |
| Runner command | `--runner-command` | `RINDO_PROVISIONER_RUNNER_COMMAND` | `runner_command` | — |
| Claim-by TTL (s) | `--ttl-seconds` | `RINDO_PROVISIONER_TTL_SECONDS` | `ttl_seconds` | 300 |
| Own sandboxes at once | `--max-sandboxes` | `RINDO_PROVISIONER_MAX_SANDBOXES` | `max_sandboxes` | 2 |
| Env allowlist | *(none)* | `RINDO_PROVISIONER_SANDBOX_ENV` | `sandbox_env` | *(empty)* |
| Poll cadence (s) | `--poll-seconds` | `RINDO_PROVISIONER_POLL_SECONDS` | `poll_seconds` | 10 |
| Controller id | `--controller-id` | `RINDO_PROVISIONER_CONTROLLER_ID` | `controller_id` | `<hostname>-<runtime>` |
| Sandbox cap (s) | `--max-lifetime-seconds` | `RINDO_PROVISIONER_MAX_LIFETIME_SECONDS` | `max_lifetime_seconds` | 3600 (Hobby) |
| Create rate (/s) | `--creates-per-second` | `RINDO_PROVISIONER_CREATES_PER_SECOND` | `creates_per_second` | 1 (Hobby) |
| Log level / JSON | `--log-level` / `--log-json` | `…_LOG_LEVEL` / `…_LOG_JSON` | `log_level` / `log_json` | INFO / off |

```toml
# ~/.config/rindo-provisioner/config.toml   (chmod 600 — it holds two secrets)
server_url = "https://rindo.example.com"
token = "rndp_xxxxxxxxxxxxxxxxxxxxxxxx"      # minted on Account → Runners & pools, or POST /api/v1/account/pools/{id}/tokens
e2b_api_key = "e2b_xxxxxxxxxxxxxxxx"         # or E2B_API_KEY in the environment
runtime = "claude-code"
template = "rindo-agent-host"               # built once from the agent-host image (below)
runner_command = "env DISABLE_AUTOUPDATER=1 GH_NO_UPDATE_NOTIFIER=1 LANG=C.UTF-8 rindo-entrypoint rindo-runner --log-json --command 'claude -p --dangerously-skip-permissions {payload_file}'"
sandbox_env = ["ANTHROPIC_API_KEY"]          # names from THIS process's environment that enter a sandbox
# max_lifetime_seconds = 86400               # a Pro E2B tier
# creates_per_second = 5
```

- The **pool token** is minted on **Account → Runners & pools** (a holder's own pool —
  the reveal prints this file beside the once-shown `rndp_` with the three operator
  values marked: the runtime, the template, the E2B key) or with a PAT:
  `POST /api/v1/account/pools/{id}/tokens`, or `POST /api/v1/admin/pools/{id}/tokens`
  for an instance pool (API-only — ruling D4); runbook §4.13 / §8. It opens exactly two
  doors: the demand read and the ephemeral registration. Its revocation stops the
  provisioner at its next request (exit 3).
- The web reveal prints the same shape as this file — the TOML in one well, the `chmod`
  and the two `--config` commands in another, each with its own Copy; its `runner_command`
  and `sandbox_env` are the Claude Code example above, labelled as such — another agent
  CLI adapts both. `--check` tests neither the template, the runner command nor the
  agent's credentials (it reads the demand and lists your sandboxes).
- **`runtime`**: a provisioner serves ONE runtime; run a second process for another. Its
  spelling is the server's skills-name rule exactly — lowercase letters and digits in
  runs joined by single dashes (`claude-code`, `codex`), 1–64 chars.
- **`controller_id`** must be UNIQUE per process — it is the tag a restart adopts by,
  and two processes sharing it would adopt and kill each other's sandboxes. The
  default `<hostname>-<runtime>` covers one process per runtime on a box; a second
  process for the SAME runtime on one box sets its own.
- **`runner_command`** runs inside the sandbox as the image's `rindo` user. Go through
  `rindo-entrypoint` so the Codex MCP wiring happens (the image's ENTRYPOINT never runs
  in a sandbox). The runner reads its credential, the server URL and
  `RINDO_RUNNER_EXECUTOR=process` from the environment the provisioner constructs —
  never put them in the command.
- **`sandbox_env`** is an allowlist of names from the provisioner's OWN environment;
  nothing crosses by default. The provisioner's credentials and namespace
  (`E2B_API_KEY`, `RINDO_PROVISIONER_*`) and the three handles it constructs
  (`RINDO_RUNNER_TOKEN` / `_SERVER_URL` / `_EXECUTOR`) are refused by name.
- **`ttl_seconds`** is the registration's claim-by deadline: the sandbox must boot and
  poll inside it (M3 measured boot → the runner's first round trip at ~3 s; a failed
  create holds a `booting` slot for the whole TTL, so keep it short).
- **`max_lifetime_seconds`** / **`creates_per_second`** are the E2B tier's limits
  (Hobby 1 h and 1/s; Pro 24 h and 5/s). A sandbox lives
  `ttl + job_max_duration + tail` seconds (`tail = beat + min(beat, 30) + 60`,
  `beat = heartbeat / 2` — every term served by the demand read); when that sum
  exceeds the cap the provisioner launches NOTHING and logs the bound
  `job_max_duration_seconds ≤ cap − ttl − tail` — never a silent clamp.
- `E2B_DEBUG` in the environment is a config error: the SDK's debug mode never kills a
  sandbox.

## The template (a recipe, not a build script)

Build the agent-host image from `runner/Dockerfile` (the default `agents` target),
push it to a **private** registry (it bakes in Claude Code under Anthropic's terms —
never a public image), then build the E2B template once with the Template SDK
(`e2b template build` no longer exists):

```python
from e2b import Template, default_build_logger

template = (
    Template()
    .from_image("ghcr.io/<org>/rindo-agent-host:<tag>", username="<user>", password="<read:packages PAT>")
    .set_user("rindo")          # from_image keeps NEITHER the image's USER nor its WORKDIR (M3)
    .set_workdir("/workspace")
)
Template.build(template, "rindo-agent-host", cpu_count=2, memory_mb=2048, on_build_logs=default_build_logger())
```

No start command: the image's ENTRYPOINT never runs in a sandbox (PID 1 is the
guest's init), which is why `runner_command` goes through `rindo-entrypoint`. The
image's ENV lines do not reach a sandbox command either (`from_image` drops them and
`set_envs` is build-time only) — prefix the runner command with them:
`env DISABLE_AUTOUPDATER=1 GH_NO_UPDATE_NOTIFIER=1 LANG=C.UTF-8 rindo-entrypoint rindo-runner --log-json --command '…'`,
or list them in `sandbox_env`. The
image must carry **rindo-runner ≥ 1.2** (`executor = "process"` is a 1.2 key). The
pull credential is a recipe-time secret the operator holds — it is not a provisioner
key. 2 vCPU / 2 GiB is the measured shape; a smaller one is unmeasured.

## What happens per job

1. `GET /api/v1/pool/demand` — per runtime: `queued` (jobs this pool's runners could
   claim), `budget_waiting` (held by their project's window — never launched on),
   `booting`, `polling`, `busy`, plus the three facts of the lifetime formula.
2. Launches = `queued − booting − polling − own creates in flight`, capped by
   `max_sandboxes` and the create rate.
3. Per launch: `POST /api/v1/pool/runners` (one ephemeral registration, its `rndw_`
   shown once) → `Sandbox.create(template, timeout, metadata, on_timeout = kill)` →
   the **root bootstrap** → `rindo-runner` started as `rindo` with the credential in
   the command's own environment (never create-time envs, never metadata — E2B
   echoes those) → when the runner exits, `kill()`.
4. A failed create or bootstrap leaves its registration to die at its TTL (the
   server retires it; nothing claimed). A job is never retried by the provisioner.

**The bootstrap.** E2B's provisioning adds an account `user` with passwordless sudo
to every template. Before the runner starts — and before the credential enters the
sandbox — the provisioner runs ONE fixed root command that removes `user` from the
`sudo` / `admin` / `wheel` groups and every direct grant in `/etc/sudoers` and
`/etc/sudoers.d/*`, validates with `visudo -cq`, and proves `sudo -n true` is refused
for BOTH `user` and `rindo`. It returns 0 only when both probes are refused; any
other outcome (a non-zero exit, a timeout, a transport error) kills the sandbox and
the runner never starts. **Three consecutive bootstrap failures exit 2** — a template
fault is an operator fault. The isolation claim stays the memo's P13: the runner and
its one job share the sandbox and its uid; the `rndw_` can claim no second job and
dies with this one.

**Restarts.** The provisioner tags every sandbox (`rindo_server`, `rindo_pool`,
`rindo_runner`, `rindo_controller`). On start it adopts its own tagged sandboxes by
each one's OWN deadline (a lowered `ttl_seconds` never shortens a claimed job),
re-attaches to the runner command, and kills a sandbox whose runner is gone. It never
touches another controller's. A kill the provider refuses keeps the sandbox counted
against `max_sandboxes` (a "zombie") until the provider stops listing it or a retried
kill lands — never a silent drop. `SIGTERM` / `SIGINT` stop the loop and leave live
sandboxes to their deadlines; `SIGQUIT` drains — nothing more is launched and the
process exits once its own sandboxes have ended.

## `--check`

ONE demand read + ONE sandbox listing, never a registration: prints the pool, the
runtime's counts, the three facts, the computed lifetime and whether it fits the
cap, and the number of own sandboxes.

## Exit codes

| Code | Meaning |
| ---- | --- |
| 0    | clean stop, drained, or a successful `--check` |
| 1    | server or substrate unreachable during `--check` (the daemon backs off instead) |
| 2    | configuration error — incl. `--token` / `--e2b-api-key` on the command line (refused by name, the value never echoed), `E2B_DEBUG` set, a secret-holding config file readable by others, three consecutive bootstrap failures |
| 3    | the pool token refused (revoked, or its pool retired) |

## What to alert on

A `booting` count that stays high across polls (sandboxes that never claim — the
TTL, the template, or the server URL as seen from the sandbox); "sandbox bootstrap
failed" (the template); "exceeds max_lifetime_seconds" (the tier cap vs the
instance's `job_max_duration`); exit 3 (rotate the pool token: mint the new one,
start the provisioner on it, revoke the old — runbook §8).

## Develop

```sh
uv sync
uv run ruff check . && uv run ruff format --check .
uv run pytest -q          # offline: a FakeSubstrate + a MockTransport; the bootstrap
                          # constant runs under a real bash with stubbed tools
```
