Metadata-Version: 2.3
Name: cprg
Version: 0.1.0
Summary: Outbound-only sandbox worker bridge: workers dial out and serve reverse RPCs to a central agent control plane
Requires-Dist: grpcio>=1.81.1
Requires-Dist: protobuf>=6.33.5
Requires-Dist: pydantic-ai>=2.0 ; extra == 'agent'
Requires-Dist: playwright>=1.58,<2 ; extra == 'web'
Requires-Dist: aiohttp>=3.13,<4 ; extra == 'web'
Requires-Python: >=3.11
Provides-Extra: agent
Provides-Extra: web
Description-Content-Type: text/markdown

# 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](go/README.md) 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

```bash
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:

```bash
# 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`):

```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 `RESPONSE`s, 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|stderr|exit{code,timed_out}}`; spawn failures are exit 127 |
| `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:

```python
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

```bash
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:

```bash
# 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](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](../../docs/publishing-cprg.md) for release setup.

## Public source and native releases

[modal-projects/cprg](https://github.com/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](INSTALL.md) for standalone native downloads.
