9daf1ec5ba
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.
268 lines
14 KiB
Markdown
268 lines
14 KiB
Markdown
# 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?
|