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

268 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`](../bridged/src/main/java/dev/ltms/bridged/peer/PeerLauncher.java) 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:
```mermaid
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.
```mermaid
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):
```java
/** 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`:
```java
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.
```mermaid
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?