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.
184 lines
10 KiB
Markdown
184 lines
10 KiB
Markdown
# 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.
|