Files
fleetd/docs/CB-301-ext-Worktree-Provisioning.md
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

10 KiB
Raw Permalink Blame History

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)

  1. Opt-in, not default. A worktree is provisioned only when the caller requests one. Absent a request, acquire behaves exactly as it does today (shared primary tree) — auditors, smoke tests, and conversational workers are unaffected. Backward compatibility is a hard requirement.
  2. 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 marked git 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 (shared info/exclude), so no marking is needed.
  3. Branch persists; worktree is disposable. release runs git 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.
  4. Git behind a seam. SessionManager depends on a Worktrees interface (production impl shells git via ProcessBuilder; tests use a fake). No live git in unit tests — mirrors the WorkerService/FakeHerdr seam.

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 — ProcessBuilder per path, redirectErrorStream(true), non-zero exit → a WorktreeException. Worktree location = <worktreeRoot>/<nonce> where worktreeRoot is a daemon setting (default: sibling ../.bridged-worktrees of the repo root — outside the repo, never nested).
  • overlayParity per file: skip if absent in repoRoot; else copy into the worktree; if git -C <wt> ls-files --error-unmatch <path> succeeds (tracked), run git -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:

  1. repoRoot = worktrees.repoRoot(firstNonBlank(requestedCwd, callerCwd)).
  2. branch = "worker/" + slug(wt.ticketSlug()) + "-" + nonce.
  3. path = worktrees.add(repoRoot, branch, wt.baseRef()).
  4. worktrees.overlayParity(repoRoot, path, cfg.parityOverlay()).
  5. spawn(profile, path, callerCwd) — the worktree path becomes the worker's cwd (highest precedence in WorkerService.resolveCwd).
  6. register the session with worktree=path, branch=branch.
  7. On any failure in 1–5, unwind: if the worktree was added, remove it; 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> parityOverlay to the Worker record (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). Update withProfile.
  • 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_spawn gains an optional worktree arg: true, or a ticket slug string. Truthy ⇒ build a WorktreeRequest(slug, null) and call the 5-arg acquire.
  • POST /workers gains worktree (+ optional ticket) in the body/query, same mapping.
  • workerView/view(WorkerSession) include worktree and branch when 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)

  1. Backward-compat: acquire with no WorktreeRequest makes zero Worktrees calls, spawns with cwd = callerCwd, and records worktree == null / branch == null. (Every CB-301 test still passes.)
  2. Provision: acquire(..., new WorktreeRequest("cb-999", null)) calls add(repoRoot, "worker/cb-999-<nonce>", null), then spawn receives the returned worktree path as requestedCwd; the session records that path + branch.
  3. Overlay: overlayParity is invoked with the profile's parityOverlay (default list when unset); the fake asserts tracked paths were --skip-worktree'd and missing paths skipped.
  4. 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 no Worktrees calls.
  5. Failure unwind: a fake add that throws ⇒ acquire throws, the session is not registered, and no worker is left running (spawn not reached / torn down).
  6. 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.json and wiki/ never enter a worker commit. .mcp.json is 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 surface worktree/branch for the fleet view.