Skip to content

Commit 6e29469

Browse files
longfinclaude
andauthored
feat(client): backend runtime: container mode (#249 PR B) (#252)
* 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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
'@vicoop-bridge/client': minor
3+
---
4+
5+
Add `runtime: 'host' | 'container'` to the claude and codex backends
6+
(#249 PR B). This is the bridge-client side of the external-runtime
7+
profile landed in PR A (#251) — when an operator opts in, the agent
8+
CLI runs inside a long-lived `vicoop-runtime` container the bridge
9+
client orchestrates via `docker exec`, instead of being spawned as a
10+
host child process.
11+
12+
New surface:
13+
14+
- Config: `backends.claude.runtime` / `backends.codex.runtime` accept
15+
`'host'` (default) or `'container'`.
16+
- CLI flags: `--claude-runtime host|container`, `--codex-runtime host|container`.
17+
Standard precedence (flag > config) applies.
18+
- A `RuntimeContainer` module (`src/runtime-container.ts`) owns the
19+
per-backend lifecycle: docker daemon ping, image pull, named-volume
20+
provisioning (`vicoop-agents-<kind>`, `vicoop-creds-<kind>`,
21+
`vicoop-sessions-<kind>`), container create with
22+
`--restart unless-stopped` + `NET_ADMIN/NET_RAW`, reuse of an
23+
existing container on bridge-client restart, and an explicit stop
24+
on shutdown.
25+
- A `SpawnAdapter` module (`src/spawn-adapter.ts`) presents the
26+
existing `ClaudeSpawnFn` / `AppServerSpawnFn` shape regardless of
27+
mode. The host implementation is the same `node:child_process.spawn`
28+
the backends use today; the container implementation runs the
29+
command via `docker exec` inside the runtime container and bridges
30+
stdin/stdout/stderr to PassThrough streams so backends see a normal
31+
child-handle.
32+
- `dockerode` is a new runtime dependency (#249 Decision §1).
33+
34+
Decisions reflected (#249 §Decisions):
35+
36+
- §1 dockerode (programmatic API, not shell-out).
37+
- §2 `--restart unless-stopped` + bridge-client-side reuse on restart.
38+
- §3 Per-backend long-lived only; no per-context runtime.
39+
- §4 Creds in a container-only named volume — the host's `~/.claude`
40+
never enters the container.
41+
- §5 Sessions volume mounted into the container so claude/codex
42+
session resume survives container re-creation.
43+
- §6 No docker daemon → explicit error from `RuntimeContainer.start()`
44+
with a "switch to runtime: 'host' or start docker" hint; no
45+
fallback.
46+
- §8 The two backend kinds keep their identity; runtime mode is the
47+
`runtime` field, and backend internals stay unaware of it.
48+
49+
Out of scope (separate work):
50+
51+
- Per-context workspace branching (today the host bind-mount is whole).
52+
- `vicoop-client backend init` operator-UX subcommand (PR C of #249).
53+
- Bundled-direct image publishing (still off per #250).

container/runtime/Dockerfile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ ENV VICOOP_RUNTIME_IMAGE=$VICOOP_RUNTIME_IMAGE
3737
# - tini: PID-1 reaper. agent CLI subprocesses spawn other children
3838
# (e.g. claude code -> bash); without tini we leak zombies.
3939
# - iptables, ipset, dnsutils, iproute2: init-firewall.sh
40+
#
41+
# Intentionally NOT installed: bubblewrap / socat. claude code uses
42+
# them to sandbox each tool call from the host. In the external-
43+
# runtime profile the outer container already provides isolation
44+
# (per-backend named volumes + init-firewall.sh egress allowlist),
45+
# so layering bwrap inside that container would double-isolate at
46+
# noticeable image cost without a security benefit. The bridge-client
47+
# side disables claude's sandbox guard via `sandbox.failIfUnavailable
48+
# = false` only when runtime === 'container'; host-mode claude keeps
49+
# its sandbox unchanged.
4050
RUN apt-get update \
4151
&& apt-get install -y --no-install-recommends \
4252
ca-certificates curl git jq openssh-client tini \

packages/client/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@
2424
"@optique/core": "1.0.2",
2525
"@optique/run": "1.0.2",
2626
"@vicoop-bridge/protocol": "workspace:*",
27+
"dockerode": "^5.0.0",
2728
"ws": "^8.18.0",
2829
"zod": "^3.23.8"
2930
},
3031
"devDependencies": {
32+
"@types/dockerode": "^4.0.1",
3133
"@types/ws": "^8.5.13"
3234
}
3335
}

packages/client/src/cli-args.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { choice, integer, string } from '@optique/core/valueparser';
1010
import { parse } from '@optique/core/parser';
1111
import { formatMessage } from '@optique/core/message';
1212
import type { InferValue } from '@optique/core/parser';
13-
import type { ClientConfig, BackendConfigs } from './config.js';
13+
import type { ClientConfig, BackendConfigs, BackendRuntime } from './config.js';
1414

1515
// Public bridge URL baked in so a fresh install on the public deployment
1616
// needs zero flags. Self-hosters override via --server / config.json. The
@@ -31,6 +31,8 @@ export type CodexSandboxMode = (typeof SANDBOX_MODES)[number];
3131
const BACKEND_KINDS = ['echo', 'openclaw', 'claude', 'codex', 'vicoop-codex'] as const;
3232
export type BackendKind = (typeof BACKEND_KINDS)[number];
3333

34+
const BACKEND_RUNTIMES = ['host', 'container'] as const;
35+
3436
// Optique daemon-mode grammar. Every operator-tunable knob is a flag here,
3537
// including the ones that used to be env-only (CLAUDE_CWD, CODEX_SANDBOX_MODE,
3638
// OPENCLAW_*) — see issue #189 §1. `optional()` everywhere because the
@@ -77,6 +79,9 @@ export const daemonFlagsFields = {
7779
claudeSettingsFile: optional(option('--claude-settings-file', string({ metavar: 'PATH' }), {
7880
description: message`Path to a JSON file used as Claude \`--settings\`.`,
7981
})),
82+
claudeRuntime: optional(option('--claude-runtime', choice([...BACKEND_RUNTIMES]), {
83+
description: message`Where to run \`claude\`. \`host\` (default) spawns on the bridge-client host; \`container\` runs inside a vicoop-runtime container the bridge client orchestrates (#249).`,
84+
})),
8085

8186
// Backend-specific (Codex)
8287
codexCwd: optional(option('--codex-cwd', string({ metavar: 'PATH' }), {
@@ -85,6 +90,9 @@ export const daemonFlagsFields = {
8590
codexSandbox: optional(option('--codex-sandbox', choice([...SANDBOX_MODES]), {
8691
description: message`Codex sandbox mode.`,
8792
})),
93+
codexRuntime: optional(option('--codex-runtime', choice([...BACKEND_RUNTIMES]), {
94+
description: message`Where to run \`codex\`. \`host\` (default) spawns on the bridge-client host; \`container\` runs inside a vicoop-runtime container the bridge client orchestrates (#249).`,
95+
})),
8896

8997
// Backend-specific (OpenClaw)
9098
openclawGateway: optional(option('--openclaw-gateway', string({ metavar: 'WS_URL' }), {
@@ -123,8 +131,10 @@ export interface DaemonArgs {
123131
// env when set; merge logic in cli.ts threads them into backend factories.
124132
claudeCwd?: string;
125133
claudeSettingsFile?: string;
134+
claudeRuntime?: BackendRuntime;
126135
codexCwd?: string;
127136
codexSandbox?: CodexSandboxMode;
137+
codexRuntime?: BackendRuntime;
128138
openclawGateway?: string;
129139
openclawGatewayToken?: string;
130140
openclawAgent?: string;
@@ -200,9 +210,11 @@ export function mergeClientArgs(
200210
backends: config.backends,
201211
claudeCwd: pick(flags.claudeCwd) || backends.claude?.cwd || undefined,
202212
claudeSettingsFile: pick(flags.claudeSettingsFile) || undefined,
213+
claudeRuntime: flags.claudeRuntime ?? backends.claude?.runtime,
203214
codexCwd: pick(flags.codexCwd) || backends.codex?.cwd || undefined,
204215
codexSandbox:
205216
flags.codexSandbox ?? pickSandbox(backends.codex?.sandbox_mode),
217+
codexRuntime: flags.codexRuntime ?? backends.codex?.runtime,
206218
openclawGateway:
207219
pick(flags.openclawGateway) || backends.openclaw?.gateway_url || undefined,
208220
openclawGatewayToken:

0 commit comments

Comments
 (0)