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.
10 KiB
CB-301-ext — Worktree provisioning + config-parity overlay
Status: ✅ shipped — implemented at commit 97ecc71 (per-worker git worktree + config-parity
overlay). As-built: session/GitWorktrees.java behind the Worktrees port, wired in
Bridged.main and configurable via worktreeRoot / per-profile parityOverlay
(see bridged.example.yaml). Branch/worktree surface in bridge_list landed with CB-304
(9fe04bf); the worker-opened-PR checkpoint landed as CB-302 (64e70ef).
Extends: CB-301 Session Manager (shipped, commit 54d907c).
Realizes: the config-parity requirement in Worker Git Workflow.
Grounded in: SessionManager, WorkerService.spawn/effectiveCwd, BridgedConfig.Worker,
inject/…LsofPeerPidLookup (the ProcessBuilder exec pattern).
Problem
CB-301 gives each worker a session record but every worker still runs in the primary's own
working tree (cwd = callerCwd). One worker at a time is safe; two parallel implementers would
stomp each other. We need each implementer session to get an isolated git worktree on its own
branch — without degrading the worker: a bare worktree checks out tracked files only, so it
silently drops the untracked/local config (.claude/settings.local.json, the locally-modified
.mcp.json, .env) that makes a session a full peer of the primary. CB-301-ext provisions the
worktree AND hydrates it to config parity, so a worker differs from the primary only in the LLM
provider.
Decisions (locked)
- Opt-in, not default. A worktree is provisioned only when the caller requests one. Absent a
request,
acquirebehaves exactly as it does today (shared primary tree) — auditors, smoke tests, and conversational workers are unaffected. Backward compatibility is a hard requirement. - Copy-overlay +
--skip-worktree, not symlink. Each parity file is copied primary→worktree (isolation-friendly, no symlink type-change noise on tracked files). For a tracked overlay file (.mcp.json) the worktree copy is then markedgit update-index --skip-worktree, so the worker's commits can never include the parity overlay. Ignored files (settings.local.json) stay ignored in the worktree (sharedinfo/exclude), so no marking is needed. - Branch persists; worktree is disposable.
releaserunsgit worktree remove --force(the working dir is throwaway) but never deletes the branch — the branch holds the worker's commits and its PR (CB-302). Teardown of the checkout ≠ teardown of the work. - Git behind a seam. SessionManager depends on a
Worktreesinterface (production impl shellsgitviaProcessBuilder; tests use a fake). No livegitin unit tests — mirrors theWorkerService/FakeHerdrseam.
Design
WorktreeRequest (new, nullable = "no worktree")
package dev.ltms.bridged.session;
/** Ask acquire() to provision an isolated worktree. null ⇒ run in the shared primary tree. */
public record WorktreeRequest(String ticketSlug, String baseRef) {
// ticketSlug seeds the branch name; baseRef null/blank ⇒ current HEAD of the repo.
}
WorkerSession — two nullable fields added
| Field | Notes |
|---|---|
worktree |
absolute path of the provisioned worktree; null ⇒ shared tree |
branch |
the worker's branch (worker/<slug>-<nonce>); null ⇒ shared tree |
Add to the record + withState. A null worktree keeps every existing test and the shared-tree path
untouched.
Worktrees seam (new)
package dev.ltms.bridged.session;
public interface Worktrees {
/** git -C <repoRoot> worktree add <path> -b <branch> <baseRef|HEAD>. Returns the worktree path. */
String add(String repoRoot, String branch, String baseRef);
/** git -C <repoRoot> worktree remove --force <path>. Idempotent (already-gone tolerated). */
void remove(String repoRoot, String worktreePath);
/** Copy each existing overlay path repoRoot→worktree; mark tracked ones --skip-worktree. */
void overlayParity(String repoRoot, String worktreePath, List<String> overlay);
/** git -C <cwd> rev-parse --show-toplevel — the repo root that owns cwd. */
String repoRoot(String cwd);
}
- Production impl
GitWorktrees implements Worktrees—ProcessBuilderper path,redirectErrorStream(true), non-zero exit → aWorktreeException. Worktree location =<worktreeRoot>/<nonce>whereworktreeRootis a daemon setting (default: sibling../.bridged-worktreesof the repo root — outside the repo, never nested). overlayParityper file: skip if absent inrepoRoot; else copy into the worktree; ifgit -C <wt> ls-files --error-unmatch <path>succeeds (tracked), rungit -C <wt> update-index --skip-worktree <path>.
acquire — extended, old signature preserved
// existing (unchanged): shared tree
WorkerSession acquire(String profile, String requestedCwd, String callerCwd, String ownerTerminal);
// new overload: provision a worktree when wt != null
WorkerSession acquire(String profile, String requestedCwd, String callerCwd, String ownerTerminal,
WorktreeRequest wt);
When wt != null:
repoRoot = worktrees.repoRoot(firstNonBlank(requestedCwd, callerCwd)).branch = "worker/" + slug(wt.ticketSlug()) + "-" + nonce.path = worktrees.add(repoRoot, branch, wt.baseRef()).worktrees.overlayParity(repoRoot, path, cfg.parityOverlay()).spawn(profile, path, callerCwd)— the worktree path becomes the worker's cwd (highest precedence inWorkerService.resolveCwd).- register the session with
worktree=path, branch=branch. - On any failure in 1–5, unwind: if the worktree was added,
removeit; do not leave a dangling registry entry. (Guard/spawn already throw before herdr on a bad base_url — unchanged.)
release — remove the worktree, keep the branch
public void release(String paneId) {
WorkerSession s = registry.remove(paneId);
workerService.stop(paneId); // existing
if (s != null && s.worktree() != null) {
worktrees.remove(worktrees.repoRoot(s.cwd()), s.worktree()); // branch is NOT deleted
}
}
Config — BridgedConfig.Worker.parityOverlay + a worktreeRoot
- Add
List<String> parityOverlayto theWorkerrecord (12th field). Compact-constructor default when null/empty:[".mcp.json", ".claude/settings.local.json", ".env", ".envrc"](missing paths are silently skipped, so the default is safe across repos). UpdatewithProfile. - Add a top-level daemon setting
worktreeRoot(String, nullable →<repoParent>/.bridged-worktrees). @JsonIgnoreProperties(ignoreUnknown = true)already set → additive, no parser breakage.
Surface: MCP + REST
bridge_spawngains an optionalworktreearg:true, or a ticket slug string. Truthy ⇒ build aWorktreeRequest(slug, null)and call the 5-argacquire.POST /workersgainsworktree(+ optionalticket) in the body/query, same mapping.workerView/view(WorkerSession)includeworktreeandbranchwhen non-null (omit for shared-tree sessions, so existing response assertions for shared-tree spawns are unchanged).
Flow
flowchart TD
A["acquire(profile, ..., WorktreeRequest?)"] --> B{"worktree<br/>requested?"}
B -->|"no (default)"| C["spawn(cwd = callerCwd)<br/>— shared tree, unchanged"]
B -->|yes| D["repoRoot = rev-parse --show-toplevel"]
D --> E["git worktree add path -b branch base"]
E --> F["overlayParity: copy local config in;<br/>--skip-worktree the tracked ones"]
F --> G["spawn(cwd = worktree path)"]
G --> H["register worktree + branch on the session"]
E -.->|"add/overlay/spawn fails"| X["unwind: remove worktree,<br/>no dangling registry entry"]:::warn
C --> R["worker is a full peer of the primary"]:::goal
H --> R
classDef warn fill:#b7791f,stroke:#7b341e,color:#ffffff;
classDef goal fill:#2b6cb0,stroke:#2a4365,color:#ffffff;
Green = the invariant: whether shared-tree (parity for free) or worktree (parity via overlay), the worker matches the primary. Amber = the failure-unwind path.
Acceptance (fake Worktrees, no live git)
- Backward-compat:
acquirewith noWorktreeRequestmakes zeroWorktreescalls, spawns withcwd = callerCwd, and recordsworktree == null/branch == null. (Every CB-301 test still passes.) - Provision:
acquire(..., new WorktreeRequest("cb-999", null))callsadd(repoRoot, "worker/cb-999-<nonce>", null), thenspawnreceives the returned worktree path asrequestedCwd; the session records that path + branch. - Overlay:
overlayParityis invoked with the profile'sparityOverlay(default list when unset); the fake asserts tracked paths were--skip-worktree'd and missing paths skipped. - Release removes worktree, keeps branch: releasing a worktree session calls
Worktrees.remove(repoRoot, path)and performs no branch-delete; a shared-tree session's release makes noWorktreescalls. - Failure unwind: a fake
addthat throws ⇒acquirethrows, the session is not registered, and no worker is left running (spawn not reached / torn down). - Distinct worktrees: two worktree acquires yield distinct branches and paths (no collision).
Constraints & exclusions (standing, non-negotiable)
- Only a worker sets
ANTHROPIC_BASE_URL. Worktrees touch cwd + files only; env path is unchanged — the guard still runs before any herdr call. - Worker cwd stays inside the primary repo (the worktree is a checkout of it) — never
$HOME. .mcp.jsonandwiki/never enter a worker commit..mcp.jsonis overlaid for reference but--skip-worktree'd so it can't be staged;wiki/is a submodule the worker must not touch. The CB-302 commit step (and the implementer skill) exclude both.- The overlay list stays explicit + minimal (trust: local secrets flow to an off-subscription worker). No blanket tree copy.
Seams left for later
- CB-302 — the worker commit → push → PR checkpoint runs inside the worktree on its branch.
- CB-304 —
roster()rows surfaceworktree/branchfor the fleet view.