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
ERRORand peers negotiate viax-worker-methods. - Unary methods: one
REQUEST, exactly oneRESPONSEorERROR. Streaming methods (exec.Start): NRESPONSEs, terminated by an empty-payloadRESPONSEor anERROR. - Cancellation: a
REQUESTto/internal/cancelcarryingCancelRequest{request_id, reason}; the callee cancels the handler task (killing the subprocess) and answers the original request withERROR. Client-side cancellation and timeouts propagate this way automatically. - Heartbeats: the worker sends
heartbeat(a fire-and-forgetHeartbeatmessage: 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/refon 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_byteson 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 intests/test_races.py; theFixedconfigs inspec/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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5490f512d1f3a4abdaf968f97a8d883fedb48d741dbf0de5dc629f66159c64a4
|
|
| MD5 |
3d6bd0f0251492e2de252ccc78de814c
|
|
| BLAKE2b-256 |
6eebf922b899673f9b8267f3d09f1d0eddb9a5a2631da0c9a31b91b0191571fc
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
63768962641a2e96b89bda40718c932452a7a1a16dc2d2396472cdc626a1bd7f
|
|
| MD5 |
82d795aac32c6dd30feba070b373e755
|
|
| BLAKE2b-256 |
fd8e795b3f92a93bbf8778149e567ab6ded373faa2eb78465aa8ac645e6b973e
|