Files
fleetd/docs/CB-402-OpenCode-Adapter.md
T
kevin 9daf1ec5ba CB-5xx: Stage 5 hardening — auth, authz+audit, metrics, CI, supervision
Closes out single-host before the cross-host work. Sequenced BEFORE CB-308
deliberately: federation's own gating concern is the trust model, and it
inherits whatever identity shape lands here.

The finding this stage is built around: bridged had exactly ONE security
control, the loopback bind. ConnectionIdentity resolves a worker from its
connection (unforgeable), but every caller that was not a recognised worker
pane fell through to being treated as the PRIMARY -- the most privileged role
on the bus. Latent today; load-bearing the moment a bind widens.

CB-501 auth:
- Role/Principal/CallerResolver: connection identity first, bearer token
  second, ANONYMOUS third. Inverts the old default so absence of identity
  means nothing, not everything.
- Worker identity is never token-gated, so enabling auth cannot lock the
  fleet out of bridge_reply.
- Constant-time token compare (MessageDigest.isEqual).
- validateAuthExposure(): a non-loopback bind under loopback-trust now
  REFUSES TO START. Makes the dangerous config unrepresentable rather than
  merely documented.
- TLS terminates at a reverse proxy by design (D3), not in the JVM.

CB-505 authz + audit, enforced on BOTH entry paths:
- The docs describe MCP as "a thin adapter over the REST core"; at code level
  it is not. BridgeMcp calls MessageService directly, and /mcp is a raw
  servlet on Jetty's context handler that never traverses Javalin's before
  filter. Enforcing only at REST would have left /mcp open.
- Load-bearing rule is own-session-only: a worker may reply/ask only as
  itself. Structurally true over MCP already; over REST the session id in the
  URL path had simply been trusted.
- Audit: JSON lines to a dedicated appender, additivity=false. Never records
  message content -- this bus carries source and prompts.

CB-502 metrics: zero new dependencies. A ~150-line Prometheus text renderer
instead of the specced Micrometer, because this pom already hand-pins
jackson-annotations to reconcile Jackson 2/3, imports a Jetty BOM against
skew, and carries four accepted-CVE advisories -- and CLAUDE.md's mandated
dependency CVE gate could not be run (no JetBrains MCP server connected).
Instrumented at MessageService, the single funnel both surfaces share.

CB-503 CI: .gitea/workflows/ci.yml against the already-running Gitea runner.
Needs no contract-exclusion flag -- the pom's default-excludes profile
already sets excludedGroups=contract, so plain `mvn clean install` IS the
mock-socket surface. Provisions JDK 25 explicitly (runner default-jdk is older).

CB-504 supervision: launchd agent (the real target -- this host is macOS,
there is no systemd) plus a systemd unit for the Linux gateways CB-308 adds.
Ordering directives are advisory, so the actual fix is that startup now waits
up to 30s for the herdr socket and then serves degraded, instead of crashing
into a restart loop on a boot-order race.

Also fixes drift found while surveying:
- bridged.example.yaml documented spawn_ready_timeout_ms in snake_case; config
  binds via plain Jackson with ignoreUnknown, so uncommenting it would have
  been silently dropped and the default kept. Now camelCase, with a test that
  loads the shipped example and one that pins every documented knob's
  spelling -- no test had ever loaded that file.
- Added the 6 shipped-but-undocumented knobs (worktreeRoot, parityOverlay,
  gitTokenEnv, gitHostEnv, configDir, primary:).
- README "Next" listed bridge_ask and session lifecycle as upcoming; both
  shipped long ago.
- docs/CB-301-ext and docs/CB-402 status headers said "design"/"pre-
  implementation" for work already merged.

307 unit/acceptance tests green (was 266), mvn clean install BUILD SUCCESS.
Note: CLAUDE.md's per-file ide_diagnostics gate and the pom Mend.io CVE check
could not be run -- no JetBrains/intellij-index MCP server is connected this
session. mvn clean install is the only gate that ran.
2026-07-29 22:29:26 +07:00

14 KiB
Raw Blame History

CB-402 — Second peer adapter: opencode (Stage B of the Peer Launcher SPI)

Status: ✅ implemented and merged (ded226a) — increments 1–4 of §4 all landed (HerdrPeerLauncher base, kind: discriminator, OpenCodeLauncher, CompositePeerLauncher). ⚠️ Increment 5 — the live dogfood (§5) — has NOT run. It was deferred at merge time pending a daemon restart and a resolved provider, and §7 Q1 (which provider this host has credentials for) is still open; opencode is not installed on the dev host. Gitea issue #7 stays open until the §5 checklist is executed. This is the single known-unverified item going into the cross-host stage. Depends on: CB-401 Stage A (PeerLauncher SPI, merged 3aa69a9) Stage: 4 (Pluggable peers) · Stage B Owner action: design-note → file issue → delegate → primary-verify (per CB-401/306/307)


1. Goal

Prove the PeerLauncher SPI actually holds for a non-Claude coding agent by shipping a second, first-class in-tree adapter: opencode (opencode 1.1.31, a provider-agnostic terminal coding agent).

The product direction is heterogeneous coding agents, Claude Code first-class — not a human/mock peer. opencode is the right proof precisely because it differs from Claude Code on every seam the SPI is meant to hide:

Seam Claude Code opencode ⇒ SPI proof
Subscription boundary ANTHROPIC_BASE_URL + SubscriptionGuard.assertWorker() before any herdr call none — provider-agnostic, off-subscription by nature the guard is Claude-private, not core
MCP mount inline --mcp-config '{…}' launch flag opencode mcp add / config file (OPENCODE_CONFIG) — no inline flag "mount the bridge MCP" is adapter-private
Instruction injection --append-system-prompt "<REPLY_CHARTER>" config instructions / AGENTS.md / --agent — no append flag the reply-charter mount is adapter-private
Model selection ANTHROPIC_MODEL env -m provider/model flag env-vs-flag is adapter-private
Name / reap scheme claude-<profile>-<nonce>-<seq> opencode-<profile>-<nonce>-<seq> each adapter reaps only its own kind

Everything else — herdr tab/pane placement, the CB-306 spawn-readiness gate, CB-301-ext worktree provisioning, CB-117 orphan reap, teardown, list(), cwd resolution — is transport machinery that is identical for both. That split is the whole design.

Out of scope (deferred to Stage C / later): dynamic external plugin loading behind a trust/capability model, capability enforcement at the verb layer (Stage A only declares caps), and a human/mock peer.


2. Current state — one launcher, two concerns mixed

ClaudeCodeLauncher (584 LOC) is the sole PeerLauncher. It interleaves two concerns:

flowchart TB
    subgraph CCL["ClaudeCodeLauncher (584 LOC) — today"]
        direction TB
        T["herdr transport (GENERIC / reusable)<br/>tab-pane placement · spawn-ready gate · worktree<br/>orphan reap · teardown · list · cwd resolution · unique naming"]
        C["Claude-specific (per-agent)<br/>ANTHROPIC_BASE_URL + SubscriptionGuard · ANTHROPIC_MODEL<br/>argv --mcp-config · --append-system-prompt REPLY_CHARTER · 'claude-' name prefix"]
    end
    classDef generic fill:#2f855a,stroke:#22543d,color:#ffffff;
    classDef specific fill:#b7791f,stroke:#7b341e,color:#ffffff;
    class T generic
    class C specific

Figure 1 — the two concerns tangled inside today's single launcher; CB-402 splits them.

There is also a Stage-A deferral to finish: Bridged.main still casts (ClaudeCodeLauncher) workers at the BridgeMcp and BridgedApp constructors. Those two callers only invoke profiles(), defaultProfile(), and list() — all already on the PeerLauncher interface. The cast survives for one reason only: PeerLauncher.list() returns List<?> (element type erased) while the callers use Agent element methods in their roster join. Finishing the migration is therefore small and contained (§4.D).


3. Target design

Template-Method base + two thin adapters + a routing composite that keeps the Stage-A seam (one PeerLauncher reference held by SessionManager / BridgeMcp / BridgedApp) intact.

flowchart TB
    IFACE["«interface»<br/>PeerLauncher"]
    COMP["CompositePeerLauncher<br/>routes by profile kind; fans out list/reap/caps"]
    BASE["«abstract»<br/>HerdrPeerLauncher<br/>transport: placement · ready-gate · reap · stop · cwd · naming"]
    CCL2["ClaudeCodeLauncher<br/>hooks: guard+ANTHROPIC_* env · --mcp-config · charter flag · prefix 'claude'"]
    OCL["OpenCodeLauncher<br/>hooks: provider env · OPENCODE_CONFIG file · AGENTS charter · prefix 'opencode'"]

    IFACE -.implemented by.-> COMP
    IFACE -.implemented by.-> BASE
    BASE --> CCL2
    BASE --> OCL
    COMP -->|"kind=claude-code"| CCL2
    COMP -->|"kind=opencode"| OCL

    classDef iface fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
    classDef base fill:#2f855a,stroke:#22543d,color:#ffffff;
    classDef leaf fill:#6b46c1,stroke:#44337a,color:#ffffff;
    class IFACE,COMP iface
    class BASE base
    class CCL2,OCL leaf

Figure 2 — extracted base, two adapters, and a routing composite behind the unchanged SPI.

A. Extract HerdrPeerLauncher (abstract base)

Move all transport machinery down from ClaudeCodeLauncher. What stays generic:

  • fields agents, spaces, profiles, defaultProfile, env, nameSeq, the CB-306 gate knobs (spawnReadyTimeoutMs/spawnReadyPollMs/nowMillis/sleeper), and nameNonce;
  • profiles(), defaultProfile(), parityOverlay(), effectiveCwd(…), resolveCwd;
  • the spawn(SpawnRequest) skeleton: resolve profile → cfg → hook → placement → gate → WorkerHandle;
  • spawnInTab / spawnAsPane / tidy / startUniquelyNamed (name built from a hook prefix);
  • list(), reapOrphanWorkers() / isForeignWorker / workerNonce (pattern built from the prefix hook), stop() / usesTabPlacement / isAlreadyGone;
  • waitUntilInjectableOrThrow, the WorkerHandle record, putIfPresent, resolveEnv, sleepUninterruptibly.

Two adapter hooks (abstract):

/** Label prefix for this peer kind; drives unique naming AND the orphan-reap pattern. */
protected abstract String namePrefix();               // "claude" | "opencode"

/** Build the peer-specific launch: env map + argv. Runs any pre-spawn guard here. */
protected abstract Launch buildLaunch(BridgedConfig.Worker cfg, SpawnRequest req);
record Launch(Map<String,String> env, List<String> argv) {}

capabilities() stays abstract/per-adapter (it already is). The subscription guard is not a base field — it is a constructor dependency of ClaudeCodeLauncher alone.

Reap isolation: the reap pattern becomes Pattern.compile(namePrefix() + "-.*-([0-9a-f]{6})-\\d+"), so the opencode adapter never reaps a claude-* pane and vice-versa. The composite sums both.

B. kind: config discriminator

Add one field to BridgedConfig.Worker:

String kind          // "claude-code" (default) | "opencode"
  • Compact-ctor default: kind = blank ? "claude-code" : kind.toLowerCase().
  • argv default is currently List.of("claude"); when kind=opencode and the operator left argv unset, default it to List.of("opencode"). (Handle in normalization, keyed off kind, so the record stays declarative.)
  • Keep the existing back-compat constructors; kind is additive and optional.

bridged.example.yaml documents a two-kind workers: block.

C. OpenCodeLauncher — the adapter hooks for opencode

namePrefix() → "opencode". buildLaunch(cfg, req):

  • Env: no ANTHROPIC_BASE_URL, no SubscriptionGuard call. Pass through provider credentials the operator names (reuse the existing tokenEnv indirection; opencode reads provider keys from env / opencode auth). CB-302 git-token injection is reused unchanged (it is peer-neutral: GITEA_TOKEN/GITEA_HOST).
  • MCP mount (non-invasive): opencode has no inline --mcp-config. The adapter writes a throwaway config file and points OPENCODE_CONFIG=<tempfile> in the worker env, containing the bridge MCP server block (opencode HTTP MCP schema, type: "remote") — the opencode analog of Claude Code's inline flag. Nothing is written into the worker's real project or profile.
  • Reply-charter: carry REPLY_CHARTER as an instructions entry in that same generated config (or an AGENTS.md written into the per-worker worktree, which is already a throwaway isolated checkout under CB-301-ext). Recommend the config-file route to keep the "touch nothing the user owns" invariant.
  • argv: opencode <project-or-cwd> (interactive TUI, the mode a herdr pane drives), plus -m <provider/model> when the profile sets a model.

REPLY_CHARTER is peer-neutral text — hoist it to a shared constant (base or a small PeerCharter), consumed by each adapter through its own injection mechanism.

D. CompositePeerLauncher + finish the Stage-A migration

  • Bridged.main groups configured profiles by kind, instantiates one launcher per kind present, and wraps them in CompositePeerLauncher implements PeerLauncher.
  • Routing methods (spawn(req), effectiveCwd(req), parityOverlay(name)) dispatch by the profile's kind. Fan-out methods (list(), reapOrphanWorkers(), capabilities(), profiles(), defaultProfile()) merge across sub-launchers. stop(id) tries each (teardown only knows the pane id) — already best-effort/idempotent.
  • Migrate BridgeMcp + BridgedApp to the PeerLauncher interface, dropping both (ClaudeCodeLauncher) casts. Only friction is list()'s List<?>; resolve by giving the SPI a typed roster element (small neutral PeerAgent view exposing id()/name()/status) that the CB-304 roster join consumes — or, minimally, narrow at the callsite. Prefer the typed view.
sequenceDiagram
    autonumber
    participant P as Primary
    participant M as BridgeMcp / REST
    participant C as CompositePeerLauncher
    participant O as OpenCodeLauncher
    participant B as HerdrPeerLauncher (base)
    participant H as herdr
    P->>M: bridge_spawn(profile="oc-impl")
    M->>C: spawn(SpawnRequest)
    C->>C: kind(profile)=="opencode"
    C->>O: spawn(req)
    O->>O: buildLaunch → provider env + OPENCODE_CONFIG file + argv
    O->>B: placement + startUniquelyNamed("opencode-…")
    B->>H: agent.start(name, argv, env, tab, cwd)
    B->>H: poll status until injectable (CB-306 gate)
    B-->>O: Agent
    O-->>C: PeerHandle(paneId, terminalId)
    C-->>M: PeerHandle
    M-->>P: session id

Figure 3 — an opencode spawn: composite routes by kind, adapter builds the peer-specific launch, shared base drives herdr + the readiness gate.


4. Increment plan (delegate-then-verify friendly)

  1. Extract base, no behaviour change. Introduce HerdrPeerLauncher; make ClaudeCodeLauncher extend it with namePrefix()="claude" and buildLaunch() wrapping today's guard+env+argv logic. Green build, identical tests — pure refactor. (IDE refactor where possible; the primary re-runs the gate workers can't.)
  2. kind: discriminator. Add the field + normalization + bridged.example.yaml. Default path unchanged (kind=claude-code).
  3. OpenCodeLauncher. Implement the three hooks; unit-test buildLaunch (env has no ANTHROPIC_BASE_URL; OPENCODE_CONFIG points at a file carrying the bridge MCP block + charter; argv shape).
  4. CompositePeerLauncher + wiring + finish Stage-A migration (drop the two casts).
  5. Live dogfood (§5) + wiki as-built (primary-gated submodule commit).

Each increment is independently buildable/mergeable; the feature branch stays unmerged until it is "major" (Stage-B whole), per the CB-401 bar.


5. Risks & validation (live dogfood, not assumed)

  • opencode TUI ⇄ herdr injection. herdr drives a pane by typing into a TUI. Must confirm opencode's TUI accepts injected keystrokes/submit the way claude does, and reaches an injectable status the CB-306 gate recognizes. Validation: spawn one opencode worker, watch the readiness gate pass, bridge_send a trivial task.
  • Bridge MCP visibility in opencode. Confirm OPENCODE_CONFIG (or opencode mcp add) actually surfaces the bridge_* tools inside the opencode session, and that bridge_reply is callable — the reply-charter is worthless if the tool isn't mounted. Validation: the worker completes a task by calling bridge_reply; the reply lands via the CB-307 path.
  • opencode MCP/config schema drift. opencode is fast-moving (1.1.31 today). Pin the config schema we generate against the installed version; treat the exact keys (type: "remote" vs "http", instructions shape) as a dogfood-verified fact, not an assumption.
  • Provider credentials. opencode needs a configured provider (env key or opencode auth). The dogfood profile must name a provider the host actually has, distinct from the primary's subscription.

6. Test plan

  • Unit (hermetic): base-extraction regression (existing ClaudeCodeLauncher tests pass unchanged); OpenCodeLauncher.buildLaunch env/argv/config assertions; kind normalization in BridgedConfigTest; CompositePeerLauncher routing + fan-out (merge of profiles(), summed reapOrphanWorkers(), per-kind reap isolation) with fake sub-launchers.
  • Live (dogfood, manual): the §5 checklist on the running daemon.
  • Gate (primary): IDE diagnostics 0/0 on every changed file, mvn clean install green with the surefire summary captured (not | tail), manual diff review — the authoritative checks a worker cannot self-run.

7. Open questions for the lead

  1. Provider for the opencode dogfood profile — which provider/model does this host have credentials for that is distinct from the primary's subscription?
  2. Charter carrier — generated OPENCODE_CONFIG instructions (recommended) vs an AGENTS.md in the worktree?
  3. Merge cadence — hold the whole Stage B on a feature branch to merge as one "major" unit (per CB-401), or land the behaviour-preserving base-extraction (increment 1) to main first to shrink the branch?