cprg 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cprg-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,276 @@
1
+ Metadata-Version: 2.3
2
+ Name: cprg
3
+ Version: 0.1.0
4
+ Summary: Outbound-only sandbox worker bridge: workers dial out and serve reverse RPCs to a central agent control plane
5
+ Requires-Dist: grpcio>=1.81.1
6
+ Requires-Dist: protobuf>=6.33.5
7
+ Requires-Dist: pydantic-ai>=2.0 ; extra == 'agent'
8
+ Requires-Dist: playwright>=1.58,<2 ; extra == 'web'
9
+ Requires-Dist: aiohttp>=3.13,<4 ; extra == 'web'
10
+ Requires-Python: >=3.11
11
+ Provides-Extra: agent
12
+ Provides-Extra: web
13
+ Description-Content-Type: text/markdown
14
+
15
+ # cprg
16
+
17
+ Outbound-only sandbox workers with a Go executable and a Python control plane.
18
+
19
+ The production worker is a native executable. Build it with
20
+ `make -C packages/outrigger/go bundle` from the repository root; see the
21
+ [native worker guide](go/README.md) for distribution and compatibility tests.
22
+ The Python worker remains as a reference implementation and in-process demo.
23
+
24
+ A worker lives inside a sandbox with **no inbound connectivity** — no open
25
+ ports, no tunnels, no VPN. It dials **out** to the control plane over a single
26
+ gRPC bidirectional stream and then *serves RPCs back over that stream*
27
+ (reverse RPC): the control plane runs the agent loop and inference, the worker
28
+ executes shell commands and file operations inside the sandbox.
29
+
30
+ ```
31
+ ┌──────────────────────┐ one gRPC bidi stream, outbound only ┌──────────────────┐
32
+ │ sandboxed worker │ ──────────────────────────────────────► │ control plane │
33
+ │ (exec + fs server) │ ◄── Envelope{REQUEST} ── Envelope{RESP} │ (agent loop + │
34
+ │ │ │ LLM inference) │
35
+ └──────────────────────┘ └──────────────────┘
36
+ ```
37
+
38
+ The protocol design is distilled from a reverse-engineering of Cursor's
39
+ self-hosted cloud-agent worker (`agent worker start`, the
40
+ `agent.v1.PrivateWorkerBridgeExternalService` tunnel), generalized into
41
+ something small, embeddable, and MIT-licensed.
42
+
43
+ ## Why this shape
44
+
45
+ - **Sandbox-friendly**: the strictest egress-only network policy still allows
46
+ the worker to reach its control plane. Nothing can reach in.
47
+ - **Framework-agnostic jobs**: the control plane owns the agent loop, so any
48
+ agent framework (pydantic-ai included, see `outrigger.agent`) can drive a
49
+ sandbox it doesn't run in.
50
+ - **Secret hygiene**: per-task env is delivered with the claim and lives only
51
+ in the worker process for the life of the claim. The control plane never
52
+ needs the sandbox's credentials, and the sandbox never sees model keys.
53
+ - **Disposable compute**: workers are interchangeable. Reconnect with the same
54
+ worker id and the control plane replaces the old stream; sandboxes can be
55
+ spawned per-task (`--once`) and torn down.
56
+
57
+ ## Quickstart
58
+
59
+ ```bash
60
+ uv run outrigger demo # in-process control plane + worker + agent
61
+ uv run outrigger demo --model openai:gpt-5 # with a real model
62
+ ```
63
+
64
+ Two terminals, real processes:
65
+
66
+ ```bash
67
+ # side A: embed the control plane in your app (it's a library)
68
+ python - <<'PY'
69
+ import asyncio
70
+ from outrigger import ControlPlane
71
+ from outrigger.agent import agent_runner
72
+
73
+ async def main():
74
+ control = ControlPlane(token="dev-secret")
75
+ await control.serve(host="0.0.0.0", port=7600)
76
+ control.set_runner(agent_runner("openai:gpt-5"))
77
+ task = await control.submit("create a file listing this machine's OS, then show it")
78
+ print((await control.wait_task(task.id)).result)
79
+
80
+ asyncio.run(main())
81
+ PY
82
+
83
+ # side B: a worker anywhere with egress to side A (a container, a VM, a Modal Sandbox)
84
+ outrigger worker --connect bridge.example.com:7600 --token dev-secret \
85
+ --root /work --label runtime=modal-sandbox
86
+ ```
87
+
88
+ See `examples/modal_worker.py` for spawning one ephemeral Modal Sandbox per
89
+ task — outbound-only by construction.
90
+
91
+ ## The protocol
92
+
93
+ One gRPC service, one bidi method, one envelope type
94
+ (`proto/outrigger/v1/bridge.proto`):
95
+
96
+ ```proto
97
+ service WorkerBridge { rpc Connect(stream Envelope) returns (stream Envelope); }
98
+
99
+ message Envelope {
100
+ string id = 1; // correlation id
101
+ string method = 2; // "worker.Claim", "exec.Start", "fs.ReadFile", ...
102
+ bytes payload = 3; // serialized method-specific message
103
+ Kind kind = 4; // REQUEST | RESPONSE | ERROR
104
+ string error = 5;
105
+ }
106
+ ```
107
+
108
+ - **Registration** is gRPC metadata on `Connect`: `authorization: Bearer
109
+ <token>`, `x-worker-id` (stable, worker-minted, persisted across restarts),
110
+ `x-worker-name`, `x-worker-labels` (JSON, used for task routing),
111
+ `x-worker-methods` (capability advertisement).
112
+ - **Multiplexing**: methods are addressed by string, not by gRPC service, so
113
+ either side can add methods without a proto change; unknown methods get a
114
+ clean `ERROR` and peers negotiate via `x-worker-methods`.
115
+ - **Unary** methods: one `REQUEST`, exactly one `RESPONSE` or `ERROR`.
116
+ **Streaming** methods (`exec.Start`): N `RESPONSE`s, terminated by an
117
+ empty-payload `RESPONSE` or an `ERROR`.
118
+ - **Cancellation**: a `REQUEST` to `/internal/cancel` carrying
119
+ `CancelRequest{request_id, reason}`; the callee cancels the handler task
120
+ (killing the subprocess) and answers the original request with `ERROR`.
121
+ Client-side cancellation and timeouts propagate this way automatically.
122
+ - **Heartbeats**: the worker sends `heartbeat` (a fire-and-forget `Heartbeat`
123
+ message: active request count, claimed task, uptime) every 30s; the control
124
+ plane sweeps workers silent for >75s.
125
+
126
+ ### Built-in methods
127
+
128
+ | Method | Direction | Purpose |
129
+ |---|---|---|
130
+ | `ping` | cp → worker | liveness |
131
+ | `worker.Claim` | cp → worker | bind task: `{task_id, prompt, env, repo_url, ref}`; rejected while claimed |
132
+ | `worker.Release` | cp → worker | end claim; targeted by `task_id` (stale releases are ignored); `--once` workers exit 0 afterwards |
133
+ | `exec.Start` | cp → worker | stream `ExecEvent{stdout|stderr|exit{code,timed_out}}`; spawn failures are exit 127 |
134
+ | `fs.ReadFile` / `fs.WriteFile` / `fs.ListDirectory` | cp → worker | confined to the workspace root |
135
+ | `heartbeat` | worker → cp | liveness + status |
136
+
137
+ ## Security model
138
+
139
+ - **Egress-only worker**: no inbound ports; the sandbox's firewall can deny
140
+ everything but the control plane endpoint.
141
+ - **Claim gating**: exec and fs serve nothing until the worker is claimed for
142
+ a specific task; a second claim while claimed is rejected
143
+ (single-assignment, like Cursor's pool mode).
144
+ - **Path confinement**: fs paths are resolved against the workspace root and
145
+ rejected if they escape.
146
+ - **Auth**: bearer token on connect. Use TLS in production
147
+ (`ControlPlane.serve(tls=(cert, key))`, `outrigger worker --tls-ca ca.pem`).
148
+ - The worker is *not* a security boundary against the control plane — run it
149
+ in a sandbox you consider disposable, and assume the claim's env is the only
150
+ secret material inside.
151
+
152
+ ## Roadmap / known limits
153
+
154
+ - `repo_url`/`ref` on claims are delivered but cloning is left to the task's
155
+ own commands for now.
156
+ - File transfers are single-message (gRPC's 4MB default cap applies to reads;
157
+ `max_read_bytes` on the worker). Chunked transfer is the obvious next method.
158
+ - One active task per worker (pool semantics). Shared assignment
159
+ (My-Machines-style) is a scheduler flag away.
160
+ - Control-plane durability is opt-in through `SQLiteTaskStore` (see below).
161
+ Resuming agent execution from checkpoints remains an embedding concern.
162
+ - The claim lifecycle is formally specified in `spec/` (TLA+). Four races
163
+ found and fixed via the model — cancel-during-claim running the task,
164
+ scheduler claim races, stale-claim wedges after disconnect, and calls on
165
+ a closed mux hanging — are covered by regression tests in
166
+ `tests/test_races.py`; the `Fixed` configs in `spec/` verify the fixes.
167
+
168
+ ## Durable controller state
169
+
170
+ Pass a store to persist task inputs, status, claim attempts, worker assignment,
171
+ JSON results/errors, and revoked worker credentials:
172
+
173
+ ```python
174
+ from outrigger import ControlPlane, SQLiteTaskStore
175
+
176
+ control = await ControlPlane.create(
177
+ token=stable_token,
178
+ store_factory=lambda: SQLiteTaskStore("/var/lib/outrigger/controller.db"),
179
+ )
180
+ control.set_runner(my_runner)
181
+ await control.serve()
182
+ # Await submit(), cancel_task(), fail_task(), and revoke_worker_token().
183
+ # They commit before returning, without blocking the controller's event loop.
184
+ # Always await control.stop() at shutdown; it drains execution and closes the store.
185
+ ```
186
+
187
+ Without a store the controller remains in-memory. `TaskStore` is a protocol
188
+ for embedding applications that need a different persistence implementation.
189
+ The controller owns the supplied store and closes it on shutdown.
190
+ Store calls are serialized on worker threads. In async applications, use
191
+ `ControlPlane.create(..., store_factory=...)` to offload initial recovery too.
192
+
193
+ Recovery happens when constructing the controller, before it serves workers:
194
+
195
+ | Stored state | After restart |
196
+ | --- | --- |
197
+ | Queued | Queued; runs when a matching worker connects and a runner is installed |
198
+ | Claiming or running | Failed, with an explicit restart error |
199
+ | Succeeded, failed, or cancelled | Preserved; `wait_task()` returns immediately |
200
+ | Revoked credential | Still rejected when using the same signing token |
201
+
202
+ Use `recover_queued=False` if your runner requires process-local inputs that
203
+ cannot be reconstructed. This also fails queued tasks on restart. Duplicate
204
+ task IDs are rejected, including IDs loaded from the store.
205
+
206
+ An interrupted task is **never automatically replayed**: it may already have
207
+ changed files or called external services. Recoverable state does not resume
208
+ Python coroutines or provide exactly-once tool execution. Live worker streams
209
+ and claims are transient; workers clear claims when a stream closes and must
210
+ reconnect. Supply the same signing token and reachable address for existing
211
+ workers to reconnect. The store does not retain the signing token, provision
212
+ replacement workers, or restore sandbox files.
213
+
214
+ SQLite uses WAL and FULL synchronous commits. A Unix file lock enforces one
215
+ controller per database; this is restart durability on persistent local disk,
216
+ not multi-host failover. Keep the database and its WAL together on that disk;
217
+ an ephemeral container filesystem is not sufficient. Writes are synchronous
218
+ to preserve the existing synchronous submission/cancellation API, so commit
219
+ latency blocks the controller event loop. Histories and revocations currently
220
+ have no automatic retention limit.
221
+
222
+ Records use JSON, never pickle. Runner results must be JSON serializable;
223
+ unsupported results fail the task. Treat `Task` objects as read-only snapshots
224
+ outside the controller. Prompts and claim environment values are persisted
225
+ in plaintext, so the database belongs on private storage; new files are
226
+ created with mode 0600.
227
+
228
+ ### mo integration
229
+
230
+ Mo enables this store automatically beside `MO_DB_PATH` (by default,
231
+ `mo.outrigger.db`). Override it with `MO_OUTRIGGER_DB_PATH`. Persist both the
232
+ app database and controller database. Mo uses `recover_queued=False` to match
233
+ its run recovery policy: interrupted chat runs fail and saved output remains
234
+ available, while a new run creates/reuses a sandbox through the normal manager.
235
+ The manager's sandbox handles and active-run payloads remain process-local;
236
+ this change does not restore remote sandbox workspaces or resume agent loops.
237
+
238
+ ## Development
239
+
240
+ ```bash
241
+ uv sync
242
+ uv run pytest # 20 tests, ~5s, fully offline
243
+ # regenerate protobuf code after editing the .proto:
244
+ uv run python -m grpc_tools.protoc -Iproto --python_out=src --pyi_out=src \
245
+ --grpc_python_out=src proto/outrigger/v1/bridge.proto
246
+ ```
247
+
248
+ ### Formal specification (`spec/`)
249
+
250
+ TLA+ models of the claim lifecycle (`OutriggerBridge.tla`) and the mux wire
251
+ contract (`OutriggerMux.tla`). Each has an as-written config (TLC finds the
252
+ known bugs) and a fixed config (TLC verifies the proposed fixes). To run:
253
+
254
+ ```bash
255
+ # needs a JDK (brew install openjdk) and tla2tools.jar from
256
+ # https://github.com/tlaplus/tlaplus/releases
257
+ cd spec
258
+ java -XX:+UseParallelGC -cp /path/to/tla2tools.jar tlc2.TLC -deadlock \
259
+ -nowarning -workers auto -metadir /tmp/outrigger-tla \
260
+ OutriggerBridge.tla -config OutriggerBridgeFixed.cfg
261
+ ```
262
+
263
+ Standalone native worker installation and updates are described in [INSTALL.md](INSTALL.md).
264
+
265
+ ## PyPI distribution
266
+
267
+ Install with `pip install cprg` (`cprg[agent]` or `cprg[web]` for the optional
268
+ integrations). Python imports, the worker CLI, and the wire protocol remain
269
+ `outrigger`. See [publishing](../../docs/publishing-cprg.md) for release setup.
270
+
271
+ ## Public source and native releases
272
+
273
+ [modal-projects/cprg](https://github.com/modal-projects/cprg) is the public
274
+ Copybara mirror of this package. Changes originate in `modal-projects/mo`.
275
+ The Python distribution is `cprg`; Python imports and the worker executable
276
+ remain `outrigger`. See [INSTALL.md](INSTALL.md) for standalone native downloads.
cprg-0.1.0/README.md ADDED
@@ -0,0 +1,262 @@
1
+ # cprg
2
+
3
+ Outbound-only sandbox workers with a Go executable and a Python control plane.
4
+
5
+ The production worker is a native executable. Build it with
6
+ `make -C packages/outrigger/go bundle` from the repository root; see the
7
+ [native worker guide](go/README.md) for distribution and compatibility tests.
8
+ The Python worker remains as a reference implementation and in-process demo.
9
+
10
+ A worker lives inside a sandbox with **no inbound connectivity** — no open
11
+ ports, no tunnels, no VPN. It dials **out** to the control plane over a single
12
+ gRPC bidirectional stream and then *serves RPCs back over that stream*
13
+ (reverse RPC): the control plane runs the agent loop and inference, the worker
14
+ executes shell commands and file operations inside the sandbox.
15
+
16
+ ```
17
+ ┌──────────────────────┐ one gRPC bidi stream, outbound only ┌──────────────────┐
18
+ │ sandboxed worker │ ──────────────────────────────────────► │ control plane │
19
+ │ (exec + fs server) │ ◄── Envelope{REQUEST} ── Envelope{RESP} │ (agent loop + │
20
+ │ │ │ LLM inference) │
21
+ └──────────────────────┘ └──────────────────┘
22
+ ```
23
+
24
+ The protocol design is distilled from a reverse-engineering of Cursor's
25
+ self-hosted cloud-agent worker (`agent worker start`, the
26
+ `agent.v1.PrivateWorkerBridgeExternalService` tunnel), generalized into
27
+ something small, embeddable, and MIT-licensed.
28
+
29
+ ## Why this shape
30
+
31
+ - **Sandbox-friendly**: the strictest egress-only network policy still allows
32
+ the worker to reach its control plane. Nothing can reach in.
33
+ - **Framework-agnostic jobs**: the control plane owns the agent loop, so any
34
+ agent framework (pydantic-ai included, see `outrigger.agent`) can drive a
35
+ sandbox it doesn't run in.
36
+ - **Secret hygiene**: per-task env is delivered with the claim and lives only
37
+ in the worker process for the life of the claim. The control plane never
38
+ needs the sandbox's credentials, and the sandbox never sees model keys.
39
+ - **Disposable compute**: workers are interchangeable. Reconnect with the same
40
+ worker id and the control plane replaces the old stream; sandboxes can be
41
+ spawned per-task (`--once`) and torn down.
42
+
43
+ ## Quickstart
44
+
45
+ ```bash
46
+ uv run outrigger demo # in-process control plane + worker + agent
47
+ uv run outrigger demo --model openai:gpt-5 # with a real model
48
+ ```
49
+
50
+ Two terminals, real processes:
51
+
52
+ ```bash
53
+ # side A: embed the control plane in your app (it's a library)
54
+ python - <<'PY'
55
+ import asyncio
56
+ from outrigger import ControlPlane
57
+ from outrigger.agent import agent_runner
58
+
59
+ async def main():
60
+ control = ControlPlane(token="dev-secret")
61
+ await control.serve(host="0.0.0.0", port=7600)
62
+ control.set_runner(agent_runner("openai:gpt-5"))
63
+ task = await control.submit("create a file listing this machine's OS, then show it")
64
+ print((await control.wait_task(task.id)).result)
65
+
66
+ asyncio.run(main())
67
+ PY
68
+
69
+ # side B: a worker anywhere with egress to side A (a container, a VM, a Modal Sandbox)
70
+ outrigger worker --connect bridge.example.com:7600 --token dev-secret \
71
+ --root /work --label runtime=modal-sandbox
72
+ ```
73
+
74
+ See `examples/modal_worker.py` for spawning one ephemeral Modal Sandbox per
75
+ task — outbound-only by construction.
76
+
77
+ ## The protocol
78
+
79
+ One gRPC service, one bidi method, one envelope type
80
+ (`proto/outrigger/v1/bridge.proto`):
81
+
82
+ ```proto
83
+ service WorkerBridge { rpc Connect(stream Envelope) returns (stream Envelope); }
84
+
85
+ message Envelope {
86
+ string id = 1; // correlation id
87
+ string method = 2; // "worker.Claim", "exec.Start", "fs.ReadFile", ...
88
+ bytes payload = 3; // serialized method-specific message
89
+ Kind kind = 4; // REQUEST | RESPONSE | ERROR
90
+ string error = 5;
91
+ }
92
+ ```
93
+
94
+ - **Registration** is gRPC metadata on `Connect`: `authorization: Bearer
95
+ <token>`, `x-worker-id` (stable, worker-minted, persisted across restarts),
96
+ `x-worker-name`, `x-worker-labels` (JSON, used for task routing),
97
+ `x-worker-methods` (capability advertisement).
98
+ - **Multiplexing**: methods are addressed by string, not by gRPC service, so
99
+ either side can add methods without a proto change; unknown methods get a
100
+ clean `ERROR` and peers negotiate via `x-worker-methods`.
101
+ - **Unary** methods: one `REQUEST`, exactly one `RESPONSE` or `ERROR`.
102
+ **Streaming** methods (`exec.Start`): N `RESPONSE`s, terminated by an
103
+ empty-payload `RESPONSE` or an `ERROR`.
104
+ - **Cancellation**: a `REQUEST` to `/internal/cancel` carrying
105
+ `CancelRequest{request_id, reason}`; the callee cancels the handler task
106
+ (killing the subprocess) and answers the original request with `ERROR`.
107
+ Client-side cancellation and timeouts propagate this way automatically.
108
+ - **Heartbeats**: the worker sends `heartbeat` (a fire-and-forget `Heartbeat`
109
+ message: active request count, claimed task, uptime) every 30s; the control
110
+ plane sweeps workers silent for >75s.
111
+
112
+ ### Built-in methods
113
+
114
+ | Method | Direction | Purpose |
115
+ |---|---|---|
116
+ | `ping` | cp → worker | liveness |
117
+ | `worker.Claim` | cp → worker | bind task: `{task_id, prompt, env, repo_url, ref}`; rejected while claimed |
118
+ | `worker.Release` | cp → worker | end claim; targeted by `task_id` (stale releases are ignored); `--once` workers exit 0 afterwards |
119
+ | `exec.Start` | cp → worker | stream `ExecEvent{stdout|stderr|exit{code,timed_out}}`; spawn failures are exit 127 |
120
+ | `fs.ReadFile` / `fs.WriteFile` / `fs.ListDirectory` | cp → worker | confined to the workspace root |
121
+ | `heartbeat` | worker → cp | liveness + status |
122
+
123
+ ## Security model
124
+
125
+ - **Egress-only worker**: no inbound ports; the sandbox's firewall can deny
126
+ everything but the control plane endpoint.
127
+ - **Claim gating**: exec and fs serve nothing until the worker is claimed for
128
+ a specific task; a second claim while claimed is rejected
129
+ (single-assignment, like Cursor's pool mode).
130
+ - **Path confinement**: fs paths are resolved against the workspace root and
131
+ rejected if they escape.
132
+ - **Auth**: bearer token on connect. Use TLS in production
133
+ (`ControlPlane.serve(tls=(cert, key))`, `outrigger worker --tls-ca ca.pem`).
134
+ - The worker is *not* a security boundary against the control plane — run it
135
+ in a sandbox you consider disposable, and assume the claim's env is the only
136
+ secret material inside.
137
+
138
+ ## Roadmap / known limits
139
+
140
+ - `repo_url`/`ref` on claims are delivered but cloning is left to the task's
141
+ own commands for now.
142
+ - File transfers are single-message (gRPC's 4MB default cap applies to reads;
143
+ `max_read_bytes` on the worker). Chunked transfer is the obvious next method.
144
+ - One active task per worker (pool semantics). Shared assignment
145
+ (My-Machines-style) is a scheduler flag away.
146
+ - Control-plane durability is opt-in through `SQLiteTaskStore` (see below).
147
+ Resuming agent execution from checkpoints remains an embedding concern.
148
+ - The claim lifecycle is formally specified in `spec/` (TLA+). Four races
149
+ found and fixed via the model — cancel-during-claim running the task,
150
+ scheduler claim races, stale-claim wedges after disconnect, and calls on
151
+ a closed mux hanging — are covered by regression tests in
152
+ `tests/test_races.py`; the `Fixed` configs in `spec/` verify the fixes.
153
+
154
+ ## Durable controller state
155
+
156
+ Pass a store to persist task inputs, status, claim attempts, worker assignment,
157
+ JSON results/errors, and revoked worker credentials:
158
+
159
+ ```python
160
+ from outrigger import ControlPlane, SQLiteTaskStore
161
+
162
+ control = await ControlPlane.create(
163
+ token=stable_token,
164
+ store_factory=lambda: SQLiteTaskStore("/var/lib/outrigger/controller.db"),
165
+ )
166
+ control.set_runner(my_runner)
167
+ await control.serve()
168
+ # Await submit(), cancel_task(), fail_task(), and revoke_worker_token().
169
+ # They commit before returning, without blocking the controller's event loop.
170
+ # Always await control.stop() at shutdown; it drains execution and closes the store.
171
+ ```
172
+
173
+ Without a store the controller remains in-memory. `TaskStore` is a protocol
174
+ for embedding applications that need a different persistence implementation.
175
+ The controller owns the supplied store and closes it on shutdown.
176
+ Store calls are serialized on worker threads. In async applications, use
177
+ `ControlPlane.create(..., store_factory=...)` to offload initial recovery too.
178
+
179
+ Recovery happens when constructing the controller, before it serves workers:
180
+
181
+ | Stored state | After restart |
182
+ | --- | --- |
183
+ | Queued | Queued; runs when a matching worker connects and a runner is installed |
184
+ | Claiming or running | Failed, with an explicit restart error |
185
+ | Succeeded, failed, or cancelled | Preserved; `wait_task()` returns immediately |
186
+ | Revoked credential | Still rejected when using the same signing token |
187
+
188
+ Use `recover_queued=False` if your runner requires process-local inputs that
189
+ cannot be reconstructed. This also fails queued tasks on restart. Duplicate
190
+ task IDs are rejected, including IDs loaded from the store.
191
+
192
+ An interrupted task is **never automatically replayed**: it may already have
193
+ changed files or called external services. Recoverable state does not resume
194
+ Python coroutines or provide exactly-once tool execution. Live worker streams
195
+ and claims are transient; workers clear claims when a stream closes and must
196
+ reconnect. Supply the same signing token and reachable address for existing
197
+ workers to reconnect. The store does not retain the signing token, provision
198
+ replacement workers, or restore sandbox files.
199
+
200
+ SQLite uses WAL and FULL synchronous commits. A Unix file lock enforces one
201
+ controller per database; this is restart durability on persistent local disk,
202
+ not multi-host failover. Keep the database and its WAL together on that disk;
203
+ an ephemeral container filesystem is not sufficient. Writes are synchronous
204
+ to preserve the existing synchronous submission/cancellation API, so commit
205
+ latency blocks the controller event loop. Histories and revocations currently
206
+ have no automatic retention limit.
207
+
208
+ Records use JSON, never pickle. Runner results must be JSON serializable;
209
+ unsupported results fail the task. Treat `Task` objects as read-only snapshots
210
+ outside the controller. Prompts and claim environment values are persisted
211
+ in plaintext, so the database belongs on private storage; new files are
212
+ created with mode 0600.
213
+
214
+ ### mo integration
215
+
216
+ Mo enables this store automatically beside `MO_DB_PATH` (by default,
217
+ `mo.outrigger.db`). Override it with `MO_OUTRIGGER_DB_PATH`. Persist both the
218
+ app database and controller database. Mo uses `recover_queued=False` to match
219
+ its run recovery policy: interrupted chat runs fail and saved output remains
220
+ available, while a new run creates/reuses a sandbox through the normal manager.
221
+ The manager's sandbox handles and active-run payloads remain process-local;
222
+ this change does not restore remote sandbox workspaces or resume agent loops.
223
+
224
+ ## Development
225
+
226
+ ```bash
227
+ uv sync
228
+ uv run pytest # 20 tests, ~5s, fully offline
229
+ # regenerate protobuf code after editing the .proto:
230
+ uv run python -m grpc_tools.protoc -Iproto --python_out=src --pyi_out=src \
231
+ --grpc_python_out=src proto/outrigger/v1/bridge.proto
232
+ ```
233
+
234
+ ### Formal specification (`spec/`)
235
+
236
+ TLA+ models of the claim lifecycle (`OutriggerBridge.tla`) and the mux wire
237
+ contract (`OutriggerMux.tla`). Each has an as-written config (TLC finds the
238
+ known bugs) and a fixed config (TLC verifies the proposed fixes). To run:
239
+
240
+ ```bash
241
+ # needs a JDK (brew install openjdk) and tla2tools.jar from
242
+ # https://github.com/tlaplus/tlaplus/releases
243
+ cd spec
244
+ java -XX:+UseParallelGC -cp /path/to/tla2tools.jar tlc2.TLC -deadlock \
245
+ -nowarning -workers auto -metadir /tmp/outrigger-tla \
246
+ OutriggerBridge.tla -config OutriggerBridgeFixed.cfg
247
+ ```
248
+
249
+ Standalone native worker installation and updates are described in [INSTALL.md](INSTALL.md).
250
+
251
+ ## PyPI distribution
252
+
253
+ Install with `pip install cprg` (`cprg[agent]` or `cprg[web]` for the optional
254
+ integrations). Python imports, the worker CLI, and the wire protocol remain
255
+ `outrigger`. See [publishing](../../docs/publishing-cprg.md) for release setup.
256
+
257
+ ## Public source and native releases
258
+
259
+ [modal-projects/cprg](https://github.com/modal-projects/cprg) is the public
260
+ Copybara mirror of this package. Changes originate in `modal-projects/mo`.
261
+ The Python distribution is `cprg`; Python imports and the worker executable
262
+ remain `outrigger`. See [INSTALL.md](INSTALL.md) for standalone native downloads.
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "cprg"
3
+ version = "0.1.0"
4
+ description = "Outbound-only sandbox worker bridge: workers dial out and serve reverse RPCs to a central agent control plane"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "grpcio>=1.81.1",
9
+ "protobuf>=6.33.5",
10
+ ]
11
+
12
+ [project.optional-dependencies]
13
+ agent = ["pydantic-ai>=2.0"]
14
+ web = ["playwright>=1.58,<2", "aiohttp>=3.13,<4"]
15
+
16
+ [project.scripts]
17
+ outrigger = "outrigger.cli:main"
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "grpcio-tools>=1.60",
22
+ "pydantic-ai>=2.0",
23
+ "pytest>=9.0",
24
+ "pytest-asyncio>=1.4",
25
+ ]
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.11,<0.12"]
29
+ build-backend = "uv_build"
30
+
31
+ [tool.pytest.ini_options]
32
+ asyncio_mode = "auto"
33
+ testpaths = ["tests"]
34
+
35
+ [tool.ruff]
36
+ line-length = 110
37
+ # generated protobuf/grpc code
38
+ extend-exclude = ["src/outrigger/v1"]
39
+
40
+ [tool.ruff.lint]
41
+ select = ["E", "F", "I", "UP", "B", "SIM", "ASYNC", "RUF"]
42
+ ignore = [
43
+ "BLE001", # blind excepts are deliberate at trust boundaries (mux, agent tools)
44
+ "ASYNC109", # parametrized timeouts are the design
45
+ ]
46
+
47
+ [tool.uv.build-backend]
48
+ module-name = "outrigger"
@@ -0,0 +1,26 @@
1
+ """outrigger: outbound-only sandbox workers for agent control planes.
2
+
3
+ Workers live inside sandboxes with no inbound connectivity; they dial out to
4
+ a control plane over one gRPC bidi stream and serve reverse RPCs (shell +
5
+ filesystem) back over it. The control plane runs agent loops and inference;
6
+ the sandbox only executes.
7
+ """
8
+
9
+ from outrigger.control import ControlPlane, ExecResult, Task, TaskContext, WorkerHandle
10
+ from outrigger.protocol import ConnectionClosedError, Mux, RemoteError
11
+ from outrigger.store import SQLiteTaskStore, TaskStore
12
+ from outrigger.worker import Worker
13
+
14
+ __all__ = [
15
+ "ConnectionClosedError",
16
+ "ControlPlane",
17
+ "ExecResult",
18
+ "Mux",
19
+ "RemoteError",
20
+ "SQLiteTaskStore",
21
+ "Task",
22
+ "TaskContext",
23
+ "TaskStore",
24
+ "Worker",
25
+ "WorkerHandle",
26
+ ]