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

184 lines
10 KiB
Markdown
Raw Permalink 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-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](CB-301-Session-Manager.md) (shipped, commit `54d907c`).
**Realizes:** the config-parity requirement in [Worker Git Workflow](Worker-Git-Workflow.md).
**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")
```java
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)
```java
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
```java
// 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
```java
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
```mermaid
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.