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 +276 -0
- cprg-0.1.0/README.md +262 -0
- cprg-0.1.0/pyproject.toml +48 -0
- cprg-0.1.0/src/outrigger/__init__.py +26 -0
- cprg-0.1.0/src/outrigger/agent.py +97 -0
- cprg-0.1.0/src/outrigger/bin/.gitignore +2 -0
- cprg-0.1.0/src/outrigger/bin/outrigger-darwin-amd64 +0 -0
- cprg-0.1.0/src/outrigger/bin/outrigger-darwin-arm64 +0 -0
- cprg-0.1.0/src/outrigger/bin/outrigger-linux-amd64 +0 -0
- cprg-0.1.0/src/outrigger/bin/outrigger-linux-arm64 +0 -0
- cprg-0.1.0/src/outrigger/binary.py +34 -0
- cprg-0.1.0/src/outrigger/browser_service.py +102 -0
- cprg-0.1.0/src/outrigger/cli.py +132 -0
- cprg-0.1.0/src/outrigger/cloudflare.py +43 -0
- cprg-0.1.0/src/outrigger/control.py +798 -0
- cprg-0.1.0/src/outrigger/external.py +186 -0
- cprg-0.1.0/src/outrigger/native.py +36 -0
- cprg-0.1.0/src/outrigger/protocol.py +268 -0
- cprg-0.1.0/src/outrigger/py.typed +0 -0
- cprg-0.1.0/src/outrigger/store.py +120 -0
- cprg-0.1.0/src/outrigger/v1/__init__.py +0 -0
- cprg-0.1.0/src/outrigger/v1/bridge_pb2.py +88 -0
- cprg-0.1.0/src/outrigger/v1/bridge_pb2.pyi +205 -0
- cprg-0.1.0/src/outrigger/v1/bridge_pb2_grpc.py +124 -0
- cprg-0.1.0/src/outrigger/web.py +209 -0
- cprg-0.1.0/src/outrigger/worker.py +392 -0
- cprg-0.1.0/src/outrigger/workspaces.py +114 -0
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
|
+
]
|