Commit 6e29469
* feat(client): backend runtime: host|container + SpawnAdapter (#249 PR B)
Adds the bridge-client side of the external-runtime profile that
PR A (#251) seeded the image for. claude / codex backends gain a
`runtime: 'host' | 'container'` option (config + CLI flag). In
container mode the agent CLI runs inside a long-lived vicoop-runtime
container the bridge client orchestrates via `docker exec`, instead
of being spawned as a host child process.
Key pieces:
- RuntimeContainer (runtime-container.ts) owns per-backend container
lifecycle: docker daemon ping (no-fallback per Decision §6),
image pull, named volume provisioning for agents / creds / sessions
(Decisions §4 + §5), container create with --restart unless-stopped
+ NET_ADMIN/NET_RAW, reuse on bridge-client restart, explicit stop
on shutdown.
- SpawnAdapter (spawn-adapter.ts) wraps both host (child_process.spawn)
and docker-exec implementations in a SpawnFn signature that
structurally satisfies the existing ClaudeSpawnFn / AppServerSpawnFn,
so backend internals stay unaware of mode.
- pickBackend() is now async and routes claude / codex through
resolveRuntime() which boots a RuntimeContainer when runtime ===
'container' and injects a docker-exec spawn into the factory.
Backend.stop() is wrapped to also stop the runtime container.
- dockerode (+ @types/dockerode) become runtime deps (#249 Decision §1).
Tests: 9 new (runtime-container 6, spawn-adapter 3). Full client
suite at 479 → 488, no regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(client/runtime): resolve docker socket from current docker context
dockerode's zero-arg constructor only honors \$DOCKER_HOST and falls
back to /var/run/docker.sock. That misses the common macOS reality
where colima / orbstack / rancher-desktop publish their socket under
\$HOME and wire the `docker` CLI via `docker context`. dockerode
doesn't read docker contexts itself, so without this resolver every
container-mode bridge client on those setups failed with
`ENOENT /var/run/docker.sock` despite `docker ps` working fine.
resolveDockerOptions() honors \$DOCKER_HOST first (returns undefined
so dockerode parses it), otherwise reads ~/.docker/config.json's
currentContext, derives the meta-file path from sha256(context name),
and pulls Endpoints.docker.Host out. unix:// becomes socketPath;
tcp:// becomes { host, port, protocol }. Unrecognized values fall
through to dockerode's default.
Verified end-to-end against the published vicoop-runtime image on a
colima host: bridge client starts, runtime container boots, named
volumes provisioned, WS hello + agent register succeed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(runtime-image): add bubblewrap + socat for claude sandbox
claude 2.1.x's per-tool-call sandbox runtime requires bwrap and
socat; without them the CLI exits 1 with "sandbox.failIfUnavailable
is set — refusing to start without a working sandbox" on the first
task. Surfaced during PR #252 e2e (#249 PR B) against the claude
spawn path. ~5MB combined, mirrors what anthropic's reference
devcontainer ships.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(client/runtime): await runtime container stop on SIGINT/SIGTERM
Before this, withRuntimeStop() folded runtime.stop() into the
backend's sync Backend.stop() as fire-and-forget. Result: the docker
stop API call was in-flight when process.exit ran, so the container
stayed Up after the bridge client exited. The next start cycle
silently reused it (good for the unless-stopped recovery path), but
an explicit shutdown observably leaving the container alive is
misleading and complicates operator mental models.
pickBackend now returns { backend, shutdown? } separately. Backend's
sync stop() stays untouched (its contract — kill a child, close a
socket, return immediately — still fits). The async shutdown hook
lives outside the Backend interface and runs from the signal
handler:
SIGINT/SIGTERM
→ client.stop() (sync, best-effort, as before)
→ await runWithShutdownTimeout(shutdown)
(caps at 15s so a wedged docker
socket cannot pin the daemon)
→ process.exit(0)
A second signal short-circuits to exit(130) so an impatient operator
ctrl-c'ing twice always gets out.
Verified locally: tsx packages/client/src/cli.ts --codex-runtime
container; SIGTERM; container goes Exited (143) within docker's
grace window. host-mode backends are unaffected (no shutdown hook
to await).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(runtime): drop bwrap/socat, disable claude sandbox in container mode
The previous commit added bubblewrap + socat to the runtime image so
claude's per-tool-call sandbox could initialize. That worked, but
the outer container itself already isolates the agent (per-backend
named volumes, init-firewall.sh outbound allowlist, NET_ADMIN/NET_RAW
held by the entrypoint only). Stacking bwrap inside that container
double-isolates without a real security gain and balloons the image
for no operational benefit.
Cleaner shape:
- container/runtime/Dockerfile drops bubblewrap + socat. The runtime
image stays minimal and the comment block documents the
intentional omission so the next person reading the apt list
doesn't add them back.
- packages/client/src/cli.ts threads container-mode-aware settings
into the claude factory: `disableClaudeSandboxGuard()` merges
`sandbox.failIfUnavailable: false` over the operator's settings
only when runtime === 'container'. The backend (claude.ts) stays
unaware of mode — pickBackend keeps that boundary clean. Host-mode
claude is unaffected; its sandbox keeps working as before.
Verified end-to-end against the rebuilt image: claude path completes
a task in ~6s, SIGTERM gracefully stops the container.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(client/runtime): default codex sandbox to danger-full-access in container mode
Mirrors the same reasoning as claude's sandbox guard removal: the
outer container's isolation already caps what the agent can reach
(named-volume creds, init-firewall.sh egress allowlist, no NET_ADMIN
held by the agent process), so codex's own host-process sandbox is
redundant. In host mode codex's default 'read-only' is the right
safety floor; in container mode that default actively blocks the
agent from writing /workspace or running bash, which is exactly
what an operator running an agent inside a sandbox would expect to
work.
pickBackend's codex case now lifts the default to 'danger-full-access'
only when runtime === 'container' AND the operator did not explicitly
pick a sandbox via --codex-sandbox / config. Explicit values still
win, so belt-and-suspenders setups keep working.
Verified end-to-end against the runtime container: codex writes
/tmp/sandbox-test.txt and reads it back ("hello from container"),
SIGTERM cleanly stops the container.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cd0634a commit 6e29469
11 files changed
Lines changed: 1541 additions & 30 deletions
File tree
- .changeset
- container/runtime
- packages/client
- src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
40 | 50 | | |
41 | 51 | | |
42 | 52 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
24 | 24 | | |
25 | 25 | | |
26 | 26 | | |
| 27 | + | |
27 | 28 | | |
28 | 29 | | |
29 | 30 | | |
30 | 31 | | |
| 32 | + | |
31 | 33 | | |
32 | 34 | | |
33 | 35 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
13 | | - | |
| 13 | + | |
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
| |||
31 | 31 | | |
32 | 32 | | |
33 | 33 | | |
| 34 | + | |
| 35 | + | |
34 | 36 | | |
35 | 37 | | |
36 | 38 | | |
| |||
77 | 79 | | |
78 | 80 | | |
79 | 81 | | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
80 | 85 | | |
81 | 86 | | |
82 | 87 | | |
| |||
85 | 90 | | |
86 | 91 | | |
87 | 92 | | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
88 | 96 | | |
89 | 97 | | |
90 | 98 | | |
| |||
123 | 131 | | |
124 | 132 | | |
125 | 133 | | |
| 134 | + | |
126 | 135 | | |
127 | 136 | | |
| 137 | + | |
128 | 138 | | |
129 | 139 | | |
130 | 140 | | |
| |||
200 | 210 | | |
201 | 211 | | |
202 | 212 | | |
| 213 | + | |
203 | 214 | | |
204 | 215 | | |
205 | 216 | | |
| 217 | + | |
206 | 218 | | |
207 | 219 | | |
208 | 220 | | |
| |||
0 commit comments