Skip to main content

cprg

Outbound-only sandbox workers with a Go executable and a Python control plane.

The production worker is a native executable. Build it with make -C packages/outrigger/go bundle from the repository root; see the native worker guide for distribution and compatibility tests. The Python worker remains as a reference implementation and in-process demo.

A worker lives inside a sandbox with no inbound connectivity — no open ports, no tunnels, no VPN. It dials out to the control plane over a single gRPC bidirectional stream and then serves RPCs back over that stream (reverse RPC): the control plane runs the agent loop and inference, the worker executes shell commands and file operations inside the sandbox.

┌──────────────────────┐   one gRPC bidi stream, outbound only   ┌──────────────────┐
│  sandboxed worker    │ ──────────────────────────────────────► │  control plane   │
│  (exec + fs server)  │ ◄── Envelope{REQUEST} ── Envelope{RESP} │  (agent loop +   │
│                      │                                         │   LLM inference) │
└──────────────────────┘                                         └──────────────────┘

The protocol design is distilled from a reverse-engineering of Cursor's self-hosted cloud-agent worker (agent worker start, the agent.v1.PrivateWorkerBridgeExternalService tunnel), generalized into something small, embeddable, and MIT-licensed.

Why this shape

  • Sandbox-friendly: the strictest egress-only network policy still allows the worker to reach its control plane. Nothing can reach in.
  • Framework-agnostic jobs: the control plane owns the agent loop, so any agent framework (pydantic-ai included, see outrigger.agent) can drive a sandbox it doesn't run in.
  • Secret hygiene: per-task env is delivered with the claim and lives only in the worker process for the life of the claim. The control plane never needs the sandbox's credentials, and the sandbox never sees model keys.
  • Disposable compute: workers are interchangeable. Reconnect with the same worker id and the control plane replaces the old stream; sandboxes can be spawned per-task (--once) and torn down.

Quickstart

uv run outrigger demo          # in-process control plane + worker + agent
uv run outrigger demo --model openai:gpt-5   # with a real model

Two terminals, real processes:

# side A: embed the control plane in your app (it's a library)
python - <<'PY'
import asyncio
from outrigger import ControlPlane
from outrigger.agent import agent_runner

async def main():
    control = ControlPlane(token="dev-secret")
    await control.serve(host="0.0.0.0", port=7600)
    control.set_runner(agent_runner("openai:gpt-5"))
    task = await control.submit("create a file listing this machine's OS, then show it")
    print((await control.wait_task(task.id)).result)

asyncio.run(main())
PY

# side B: a worker anywhere with egress to side A (a container, a VM, a Modal Sandbox)
outrigger worker --connect bridge.example.com:7600 --token dev-secret \
    --root /work --label runtime=modal-sandbox

See examples/modal_worker.py for spawning one ephemeral Modal Sandbox per task — outbound-only by construction.

The protocol

One gRPC service, one bidi method, one envelope type (proto/outrigger/v1/bridge.proto):

service WorkerBridge { rpc Connect(stream Envelope) returns (stream Envelope); }

message Envelope {
  string id = 1;       // correlation id
  string method = 2;   // "worker.Claim", "exec.Start", "fs.ReadFile", ...
  bytes payload = 3;   // serialized method-specific message
  Kind kind = 4;       // REQUEST | RESPONSE | ERROR
  string error = 5;
}
  • Registration is gRPC metadata on Connect: authorization: Bearer <token>, x-worker-id (stable, worker-minted, persisted across restarts), x-worker-name, x-worker-labels (JSON, used for task routing), x-worker-methods (capability advertisement).
  • Multiplexing: methods are addressed by string, not by gRPC service, so either side can add methods without a proto change; unknown methods get a clean ERROR and peers negotiate via x-worker-methods.
  • Unary methods: one REQUEST, exactly one RESPONSE or ERROR. Streaming methods (exec.Start): N RESPONSEs, terminated by an empty-payload RESPONSE or an ERROR.
  • Cancellation: a REQUEST to /internal/cancel carrying CancelRequest{request_id, reason}; the callee cancels the handler task (killing the subprocess) and answers the original request with ERROR. Client-side cancellation and timeouts propagate this way automatically.
  • Heartbeats: the worker sends heartbeat (a fire-and-forget Heartbeat message: active request count, claimed task, uptime) every 30s; the control plane sweeps workers silent for >75s.

Built-in methods

Method Direction Purpose
ping cp → worker liveness
worker.Claim cp → worker bind task: {task_id, prompt, env, repo_url, ref}; rejected while claimed
worker.Release cp → worker end claim; targeted by task_id (stale releases are ignored); --once workers exit 0 afterwards
exec.Start cp → worker stream `ExecEvent{stdout
fs.ReadFile / fs.WriteFile / fs.ListDirectory cp → worker confined to the workspace root
heartbeat worker → cp liveness + status

Security model

  • Egress-only worker: no inbound ports; the sandbox's firewall can deny everything but the control plane endpoint.
  • Claim gating: exec and fs serve nothing until the worker is claimed for a specific task; a second claim while claimed is rejected (single-assignment, like Cursor's pool mode).
  • Path confinement: fs paths are resolved against the workspace root and rejected if they escape.
  • Auth: bearer token on connect. Use TLS in production (ControlPlane.serve(tls=(cert, key)), outrigger worker --tls-ca ca.pem).
  • The worker is not a security boundary against the control plane — run it in a sandbox you consider disposable, and assume the claim's env is the only secret material inside.

Roadmap / known limits

  • repo_url/ref on claims are delivered but cloning is left to the task's own commands for now.
  • File transfers are single-message (gRPC's 4MB default cap applies to reads; max_read_bytes on the worker). Chunked transfer is the obvious next method.
  • One active task per worker (pool semantics). Shared assignment (My-Machines-style) is a scheduler flag away.
  • Control-plane durability is opt-in through SQLiteTaskStore (see below). Resuming agent execution from checkpoints remains an embedding concern.
  • The claim lifecycle is formally specified in spec/ (TLA+). Four races found and fixed via the model — cancel-during-claim running the task, scheduler claim races, stale-claim wedges after disconnect, and calls on a closed mux hanging — are covered by regression tests in tests/test_races.py; the Fixed configs in spec/ verify the fixes.

Durable controller state

Pass a store to persist task inputs, status, claim attempts, worker assignment, JSON results/errors, and revoked worker credentials:

from outrigger import ControlPlane, SQLiteTaskStore

control = await ControlPlane.create(
    token=stable_token,
    store_factory=lambda: SQLiteTaskStore("/var/lib/outrigger/controller.db"),
)
control.set_runner(my_runner)
await control.serve()
# Await submit(), cancel_task(), fail_task(), and revoke_worker_token().
# They commit before returning, without blocking the controller's event loop.
# Always await control.stop() at shutdown; it drains execution and closes the store.

Without a store the controller remains in-memory. TaskStore is a protocol for embedding applications that need a different persistence implementation. The controller owns the supplied store and closes it on shutdown. Store calls are serialized on worker threads. In async applications, use ControlPlane.create(..., store_factory=...) to offload initial recovery too.

Recovery happens when constructing the controller, before it serves workers:

Stored state After restart
Queued Queued; runs when a matching worker connects and a runner is installed
Claiming or running Failed, with an explicit restart error
Succeeded, failed, or cancelled Preserved; wait_task() returns immediately
Revoked credential Still rejected when using the same signing token

Use recover_queued=False if your runner requires process-local inputs that cannot be reconstructed. This also fails queued tasks on restart. Duplicate task IDs are rejected, including IDs loaded from the store.

An interrupted task is never automatically replayed: it may already have changed files or called external services. Recoverable state does not resume Python coroutines or provide exactly-once tool execution. Live worker streams and claims are transient; workers clear claims when a stream closes and must reconnect. Supply the same signing token and reachable address for existing workers to reconnect. The store does not retain the signing token, provision replacement workers, or restore sandbox files.

SQLite uses WAL and FULL synchronous commits. A Unix file lock enforces one controller per database; this is restart durability on persistent local disk, not multi-host failover. Keep the database and its WAL together on that disk; an ephemeral container filesystem is not sufficient. Writes are synchronous to preserve the existing synchronous submission/cancellation API, so commit latency blocks the controller event loop. Histories and revocations currently have no automatic retention limit.

Records use JSON, never pickle. Runner results must be JSON serializable; unsupported results fail the task. Treat Task objects as read-only snapshots outside the controller. Prompts and claim environment values are persisted in plaintext, so the database belongs on private storage; new files are created with mode 0600.

mo integration

Mo enables this store automatically beside MO_DB_PATH (by default, mo.outrigger.db). Override it with MO_OUTRIGGER_DB_PATH. Persist both the app database and controller database. Mo uses recover_queued=False to match its run recovery policy: interrupted chat runs fail and saved output remains available, while a new run creates/reuses a sandbox through the normal manager. The manager's sandbox handles and active-run payloads remain process-local; this change does not restore remote sandbox workspaces or resume agent loops.

Development

uv sync
uv run pytest                                   # 20 tests, ~5s, fully offline
# regenerate protobuf code after editing the .proto:
uv run python -m grpc_tools.protoc -Iproto --python_out=src --pyi_out=src \
    --grpc_python_out=src proto/outrigger/v1/bridge.proto

Formal specification (spec/)

TLA+ models of the claim lifecycle (OutriggerBridge.tla) and the mux wire contract (OutriggerMux.tla). Each has an as-written config (TLC finds the known bugs) and a fixed config (TLC verifies the proposed fixes). To run:

# needs a JDK (brew install openjdk) and tla2tools.jar from
# https://github.com/tlaplus/tlaplus/releases
cd spec
java -XX:+UseParallelGC -cp /path/to/tla2tools.jar tlc2.TLC -deadlock \
    -nowarning -workers auto -metadir /tmp/outrigger-tla \
    OutriggerBridge.tla -config OutriggerBridgeFixed.cfg

Standalone native worker installation and updates are described in INSTALL.md.

PyPI distribution

Install with pip install cprg (cprg[agent] or cprg[web] for the optional integrations). Python imports, the worker CLI, and the wire protocol remain outrigger. See publishing for release setup.

Public source and native releases

modal-projects/cprg is the public Copybara mirror of this package. Changes originate in modal-projects/mo. The Python distribution is cprg; Python imports and the worker executable remain outrigger. See INSTALL.md for standalone native downloads.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

cprg-0.1.0.tar.gz (19.0 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

cprg-0.1.0-py3-none-any.whl (19.0 MB view details)

Uploaded Python 3

File details

Details for the file cprg-0.1.0.tar.gz.

File metadata

  • Download URL: cprg-0.1.0.tar.gz
  • Upload date:
  • Size: 19.0 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.17 {"installer":{"name":"uv","version":"0.11.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for cprg-0.1.0.tar.gz
Algorithm Hash digest
SHA256 5490f512d1f3a4abdaf968f97a8d883fedb48d741dbf0de5dc629f66159c64a4
MD5 3d6bd0f0251492e2de252ccc78de814c
BLAKE2b-256 6eebf922b899673f9b8267f3d09f1d0eddb9a5a2631da0c9a31b91b0191571fc

See more details on using hashes here.

File details

Details for the file cprg-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: cprg-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 19.0 MB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.17 {"installer":{"name":"uv","version":"0.11.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for cprg-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 63768962641a2e96b89bda40718c932452a7a1a16dc2d2396472cdc626a1bd7f
MD5 82d795aac32c6dd30feba070b373e755
BLAKE2b-256 fd8e795b3f92a93bbf8778149e567ab6ded373faa2eb78465aa8ac645e6b973e

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page