Compare commits

..

2 Commits

Author SHA1 Message Date
Dai Ha 5da912ae8e CB-633: sshAuthSock: block must win over an SSH_AUTH_SOCK allow: entry, and report the credential gap on the non-zsh fallback path
CI / contract (pull_request) Successful in 1m14s
CI / build (pull_request) Successful in 1m39s
Fixes two defects in PR #174:

1. applyEnvironmentAllowListPolicy unioned memberCredentials.allow directly into the
   derived allow-list, so an operator who wrote both `sshAuthSock: block` and
   SSH_AUTH_SOCK on `allow:` (the live fleetd.yaml shape) got the block silently
   defeated. SSH_AUTH_SOCK is now excluded from that union and governed only by
   sshAuthSock:, with a one-time WARN when the two controls conflict.

2. The non-zsh login-shell fallback branch dropped its logCredentialGap call, so an
   allow-list spawn on a non-zsh shell produced no gap report at all — exactly the
   weakest, overlay-only path that most needs one. The call is restored, and
   logCredentialGap now picks its WARN/INFO wording from whether a scrub-derived
   allow-list set was actually computed (effectiveAllowed != null) rather than from
   the policy alone, so this path correctly gets the WARN ("inherited UNBLOCKED")
   wording instead of the allow-list INFO wording.

3. credentialGapLogged was one shared AtomicBoolean guarding both report kinds;
   split into allowListGapLogged/unprotectedGapLogged so an early INFO on one spawn
   can no longer suppress a later WARN on a live-reloaded policy.

Each fix is proven by reverting it and observing the corresponding test fail, then
restoring it.
2026-08-31 08:44:30 +07:00
Dai Ha f6c150e99a CB-633: honor explicit member credential keeps
CI / build (pull_request) Successful in 1m0s
CI / contract (pull_request) Successful in 1m19s
2026-08-28 05:25:46 +07:00
19 changed files with 411 additions and 1152 deletions
+2 -13
View File
@@ -113,19 +113,8 @@ PY
**What this tier cannot see:** it proves facts only about the Mac daemon at `127.0.0.1:8765`.
It cannot show the fleet01 daemon, broker queue depth, or broker consumers. The fleet01 REST service
at `10.10.20.13:8765` is not reachable from the Mac. Say this in the report rather than omitting
fleet01.
**But fleet01 IS reachable over SSH — checked 2026-08-28.** An older version of this line said SSH
was denied. That is true only for the user `dai.ha`. The host alias `fleet01` maps to user `ltms`,
and `ssh fleet01` works with key auth:
```bash
ssh -o BatchMode=yes -o ConnectTimeout=6 fleet01 'echo $(id -un)@$(hostname)'
```
So fleet01's daemon PID, uptime, jar and `/healthz` **can** be reported — over SSH, not over REST.
Do that rather than writing `not reachable`. `ltms` also has passwordless sudo there.
at `10.10.20.13:8765` is not reachable from the Mac, and SSH as `dai.ha@10.10.20.13` is denied.
Say this in the report rather than omitting fleet01.
## 3. Tier 2 — the shared broker (run when management access exists)
@@ -380,8 +380,7 @@ public final class Fleetd {
sessions.onTurnFailed(target);
}
};
Predicate<String> deliverable = deliverableTo(presence, leads);
Injector injector = new Injector(agents, turnListener, deliverable,
Injector injector = new Injector(agents, turnListener, deliverableTo(presence, leads),
presence::forget);
StatusPoller poller = new StatusPoller(agents, injector, Injector.POLL_INTERVAL_MILLIS);
poller.start();
@@ -593,7 +592,7 @@ public final class Fleetd {
}));
Javalin app = new FleetApp(herdr, workers, sessions, messages, presence, mcp.servlet(),
callers, metrics, deliverable).build();
callers, metrics).build();
app.start(cfg.bind().host(), cfg.bind().port());
log.info("fleetd listening on {}:{}, herdr socket {}",
cfg.bind().host(), cfg.bind().port(), socket);
@@ -38,11 +38,6 @@ public final class AgentControl {
this.herdr = herdr;
}
/** The herdr daemon this control object sends its agent calls to. */
public HerdrClient herdr() {
return herdr;
}
/** One agent-targeted call, translating a terminal id to its pane id (retrying once fresh). */
private JsonNode agentCall(String method, String target, Map<String, Object> extra) {
String resolved = resolveTarget(target);
@@ -6,14 +6,12 @@ import dev.ltms.fleet.msg.TurnToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.LongSupplier;
import java.util.regex.Pattern;
/**
@@ -63,17 +61,6 @@ public final class CompletionResolver implements TurnListener {
/** Cap the scraped tail so a long transcript can't return an unbounded blob. */
static final int MAX_SCRAPE_CHARS = 4000;
/**
* fleetd#164: the floor below which a {@code BUSY -> DONE} transition cannot be real work. A
* backend that rejects a turn outright (e.g. an HTTP 400 from the model, before the worker read
* a single file or produced a token) drives the exact same confirmed {@code working -> idle}
* transition a genuine completion does — just in about a second instead of the many seconds a
* real turn costs. {@link #onTurnComplete} cannot tell those two cases apart from the transition
* alone, so a turn that settles inside this floor is treated as a crash signature and resolved
* as a failure, never as a (possibly empty) success.
*/
public static final long MIN_TURN_NANOS = Duration.ofSeconds(2).toNanos();
private static final String CLIPPED_PANE_TAIL_MARKER =
"[Pane tail clipped: member did not call fleet_reply.]";
@@ -81,7 +68,6 @@ public final class CompletionResolver implements TurnListener {
private final Rendezvous rendezvous;
private final ExhaustedPatternLookup exhaustedPatterns;
private final ExhaustionSink exhaustionSink;
private final LongSupplier nowNanos;
/**
* Per-target record of the turn currently in flight: the exact {@link Rendezvous} waiter its
@@ -93,21 +79,8 @@ public final class CompletionResolver implements TurnListener {
* reference: a completion scrape equal to it means the worker produced no new output (the previous
* turn's wind-down sampled as this boundary), so it is suppressed. Overwritten on each delivery;
* cleared when the turn resolves. Package-private so tests can capture and replay a specific turn.
*
* <p>{@code deliveredAtNanos} (fleetd#164) is the {@link #nowNanos} reading taken at delivery —
* the other half of the {@link #MIN_TURN_NANOS} floor check, compared against a fresh reading at
* resolution time.
*/
record InFlight(CompletableFuture<Rendezvous.Resolution> waiter, String baseline, long deliveredAtNanos) {
/**
* Convenience for tests exercising scrape/suppression logic that don't care about turn
* timing: back-dates the delivery far enough that {@link #MIN_TURN_NANOS} can never fire.
* Not used by production code — {@link #captureBaseline} always records a real reading.
*/
InFlight(CompletableFuture<Rendezvous.Resolution> waiter, String baseline) {
this(waiter, baseline, Long.MIN_VALUE / 2);
}
record InFlight(CompletableFuture<Rendezvous.Resolution> waiter, String baseline) {
}
private final ConcurrentHashMap<String, InFlight> inFlight = new ConcurrentHashMap<>();
@@ -124,25 +97,10 @@ public final class CompletionResolver implements TurnListener {
*/
public CompletionResolver(AgentControl agents, Rendezvous rendezvous, ExhaustedPatternLookup exhaustedPatterns,
ExhaustionSink exhaustionSink) {
this(agents, rendezvous, exhaustedPatterns, exhaustionSink, System::nanoTime);
}
/**
* Test constructor with an injectable clock (fleetd#164), matching the {@code LongSupplier}
* pattern {@link dev.ltms.fleet.session.SessionManager} and {@link dev.ltms.fleet.msg.MessageService}
* already use: lets a test place a turn's delivery and its resolution at an exact, controllable
* distance apart around the {@link #MIN_TURN_NANOS} floor, without a real sleep. Public (rather
* than package-private like those two) because callers that wire a full {@code MessageService}
* fixture — e.g. {@code MessageServiceTest} — construct this resolver directly from another
* package.
*/
public CompletionResolver(AgentControl agents, Rendezvous rendezvous, ExhaustedPatternLookup exhaustedPatterns,
ExhaustionSink exhaustionSink, LongSupplier nowNanos) {
this.agents = agents;
this.rendezvous = rendezvous;
this.exhaustedPatterns = Objects.requireNonNull(exhaustedPatterns, "exhaustedPatterns");
this.exhaustionSink = Objects.requireNonNull(exhaustionSink, "exhaustionSink");
this.nowNanos = Objects.requireNonNull(nowNanos, "nowNanos");
}
@Override
@@ -172,7 +130,7 @@ public final class CompletionResolver implements TurnListener {
baseline = null; // fail open: no baseline ⇒ no suppression
log.debug("delivery baseline for {} failed: {}", target, e.getMessage());
}
inFlight.put(target, new InFlight(waiter, baseline, nowNanos.getAsLong()));
inFlight.put(target, new InFlight(waiter, baseline));
}
/** The turn currently baselined for {@code target}, or {@code null} — a test hook for the captureBaseline path. */
@@ -218,15 +176,6 @@ public final class CompletionResolver implements TurnListener {
inFlight.remove(target, turn);
return;
}
// fleetd#164: a BUSY -> DONE transition inside the floor cannot be real work — it's a crash
// signature (e.g. a backend HTTP 400 before the worker did anything), not a fast answer. Fail
// it before spending a scrape on the ordinary path; the reason still carries whatever is on
// screen, since that is usually the backend's own error.
long elapsedNanos = nowNanos.getAsLong() - turn.deliveredAtNanos();
if (elapsedNanos < MIN_TURN_NANOS) {
fail(target, turn, tooFastReason(target, elapsedNanos));
return;
}
String tail;
String assistantBlock = null;
int originalLength = 0;
@@ -238,25 +187,20 @@ public final class CompletionResolver implements TurnListener {
clipped = originalLength > MAX_SCRAPE_CHARS;
tail = clip(assistantBlock);
} catch (RuntimeException e) {
log.warn("completion scrape for {} failed: {}", target, e.getMessage());
// The worker finished but we couldn't read its screen — still resolve the send so the
// caller unblocks; an empty tail beats hanging until the caller's timeout.
log.warn("completion scrape for {} failed; resolving with an empty tail: {}",
target, e.getMessage());
tail = "";
scrapeFailed = true;
}
// fleetd#164: a scrape nobody could read, and a scrape that read cleanly but produced nothing,
// both used to resolve the send as a SUCCESS carrying "" — indistinguishable from a worker that
// genuinely finished with nothing to say. That is the defect: fail loudly instead, naming the
// member, so a caller (including a lead deciding whether to delegate again) can tell a lost
// turn from a real empty answer.
if (scrapeFailed || tail.isEmpty()) {
fail(target, turn, emptyScrapeReason(target, scrapeFailed));
return;
}
// Misattribution guard (CB-115): if the scrape is byte-identical to the pane content at
// delivery, this turn produced no new output — the boundary belongs to the previous turn's
// wind-down (common on rapid back-to-back sends). Suppress rather than resolve the send with
// a stale answer; the real fleet_reply (or a later genuine completion) resolves it instead.
// A scrape that failed to read is exempt — an empty tail there is "couldn't see", not "no change".
String baseline = turn.baseline();
if (baseline != null && baseline.equals(tail)) {
if (!scrapeFailed && baseline != null && baseline.equals(tail)) {
log.debug("suppressing misattributed completion for {} (no output change since delivery)",
target);
return; // keep the in-flight record: a later genuine completion still needs it
@@ -264,19 +208,21 @@ public final class CompletionResolver implements TurnListener {
// CB-578 stage A: a turn that ended with no fleet_reply AND whose scrape matches the
// backend's configured usage-limit pattern is a refusal, not an answer. Classify it as
// BACKEND_EXHAUSTED rather than handing the caller a scrape that reads like a real reply.
Pattern exhausted = exhaustedPatterns.patternFor(target);
String matchedLine = exhausted == null ? null : firstMatchingLine(assistantBlock, exhausted);
if (matchedLine != null) {
String reason = "backend exhausted (usage limit): " + matchedLine;
if (rendezvous.resolveExhausted(waiter, reason)) {
inFlight.remove(target, turn);
log.warn("completion for {} classified BACKEND_EXHAUSTED (no fleet_reply; scrape "
+ "matched the profile's exhausted pattern): {}", target, reason);
// CB-578 stage B: only on the resolution that actually won the race — a late
// duplicate must never quarantine a credential twice for one refusal.
exhaustionSink.onExhausted(target, reason);
if (!scrapeFailed) {
Pattern exhausted = exhaustedPatterns.patternFor(target);
String matchedLine = exhausted == null ? null : firstMatchingLine(assistantBlock, exhausted);
if (matchedLine != null) {
String reason = "backend exhausted (usage limit): " + matchedLine;
if (rendezvous.resolveExhausted(waiter, reason)) {
inFlight.remove(target, turn);
log.warn("completion for {} classified BACKEND_EXHAUSTED (no fleet_reply; scrape "
+ "matched the profile's exhausted pattern): {}", target, reason);
// CB-578 stage B: only on the resolution that actually won the race — a late
// duplicate must never quarantine a credential twice for one refusal.
exhaustionSink.onExhausted(target, reason);
}
return;
}
return;
}
String completion = clipped ? tail + "\n" + CLIPPED_PANE_TAIL_MARKER : tail;
if (rendezvous.resolveCompletion(waiter, completion)) {
@@ -326,36 +272,6 @@ public final class CompletionResolver implements TurnListener {
}
}
/**
* fleetd#164: the failure reason for a turn that settled inside {@link #MIN_TURN_NANOS} — names
* the member and both timings, and appends whatever the pane shows (usually the backend's own
* error) so the caller sees the cause, not just "it failed".
*/
private String tooFastReason(String target, long elapsedNanos) {
String scrape;
try {
scrape = clip(agents.read(target, SCRAPE_SOURCE));
} catch (RuntimeException e) {
scrape = "";
}
String reason = String.format(
"member %s went BUSY -> DONE in %dms (floor %dms) — too fast to be real work, most "
+ "likely a backend error before any work started",
target, elapsedNanos / 1_000_000, MIN_TURN_NANOS / 1_000_000);
return scrape.isBlank() ? reason : reason + ": " + scrape;
}
/**
* fleetd#164: the failure reason for a scrape that produced zero characters — names the member
* and says plainly that the turn produced nothing, so a caller (a lead deciding whether to
* delegate again included) never mistakes a lost turn for a genuinely empty reply.
*/
private static String emptyScrapeReason(String target, boolean scrapeFailed) {
return "member " + target + " turn completed with an empty scrape (0 chars) — "
+ (scrapeFailed ? "its pane could not be read; " : "")
+ "treating as a lost turn, not a real answer";
}
/**
* The first line of {@code text} matching {@code pattern}, stripped — the CB-578 stage A
* evidence carried in a {@code BACKEND_EXHAUSTED} reason so the operator sees the real refusal
@@ -2,7 +2,6 @@ package dev.ltms.fleet.member;
import dev.ltms.fleet.config.FleetConfig;
import dev.ltms.fleet.herdr.Agent;
import dev.ltms.fleet.herdr.HerdrClient;
import dev.ltms.fleet.peer.Capability;
import dev.ltms.fleet.peer.MemberRole;
import dev.ltms.fleet.peer.PeerHandle;
@@ -22,7 +21,6 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -46,13 +44,12 @@ import java.util.stream.Collectors;
* the single adapter that declares it. Profiles partition cleanly across adapters: the
* constructor rejects a name claimed by two.</li>
* <li><strong>By pane id</strong> — {@link #stop} routes to the adapter that spawned that pane
* (recorded at spawn time). A pane the composite never spawned can use the fallback route
* in a one-daemon fleet. With more than one herdr daemon, its owner is unknown, so stop refuses
* the ambiguous id rather than closing a pane on an arbitrary herdr daemon.</li>
* (recorded at spawn time). A pane the composite never spawned (only real for a caller that
* hand-rolls an id) falls back to the first delegate; teardown is pane-id addressed and
* tab cleanup is single-occupant guarded, so it is safe either way.</li>
* <li><strong>Fleet-wide</strong> — {@link #reapOrphanWorkers} and {@link #capabilities} fan out
* and combine. {@link #list} is deduplicated by (owning daemon, pane id): delegates that share
* one herdr connection report the same global agent set, but two daemons can each hold a pane
* called {@code w1:p1}, so the daemon has to be part of the key.</li>
* and combine. {@link #list} is deduplicated by pane id because every herdr-backed delegate
* shares one herdr connection and so reports the same global agent set.</li>
* </ul>
*
* <p>CB-518: an unqualified spawn is routed through a {@link PlacementPolicy}. The default
@@ -438,32 +435,12 @@ public final class CompositePeerLauncher implements PeerLauncher {
@Override
public void stop(String id) {
HerdrPeerLauncher d = spawnedBy.get(id);
HerdrPeerLauncher d = spawnedBy.remove(id);
if (d == null) {
if (herdrDaemonCount() != 1) {
throw new IllegalArgumentException("ambiguous paneId '" + id
+ "': no owning herdr daemon was recorded");
}
log.debug("stop({}) — no recorded owner in a single-daemon fleet", id);
log.debug("stop({}) — no recorded owner, routing to the first adapter (pane-addressed)", id);
d = delegates.getFirst();
}
// Drop the owner record only after the delegate accepted the stop. Removing it first meant a
// delegate that threw left the pane alive with its owner forgotten, so the retry fell into
// the ambiguous branch above and refused the id for good.
d.stop(id);
spawnedBy.remove(id);
}
/**
* Count actual herdr daemons, not peer adapter kinds. Identity is intentional: separate client
* objects may represent different daemons even if a client later implements value equality.
*/
private int herdrDaemonCount() {
Set<HerdrClient> daemons = Collections.newSetFromMap(new IdentityHashMap<>());
for (HerdrPeerLauncher delegate : delegates) {
daemons.add(delegate.herdr());
}
return daemons.size();
}
@Override
@@ -498,24 +475,14 @@ public final class CompositePeerLauncher implements PeerLauncher {
return route(profileName).capabilities();
}
/**
* Every herdr agent, deduplicated by (owning daemon, pane id).
*
* <p>Delegates that share one {@link HerdrClient} see the same global agent set, so listing them
* both would report every agent twice — that is what the dedupe is for. But pane ids are
* per-daemon counters, so two daemons really can both hold {@code w1:p1} on different panes.
* Keying on the pane id alone would silently drop one of them from {@code fleet_list} and from
* every status view built on it. The daemon is part of the key for exactly that reason.
*/
/** Every herdr agent, deduplicated by pane id (all delegates share one herdr and list globally). */
@Override
public List<Agent> list() {
Map<HerdrClient, Integer> daemonIndex = new IdentityHashMap<>();
Map<String, Agent> byPane = new LinkedHashMap<>();
for (HerdrPeerLauncher d : delegates) {
int daemon = daemonIndex.computeIfAbsent(d.herdr(), _ -> daemonIndex.size());
for (Agent a : d.list()) {
if (a.paneId() != null) {
byPane.putIfAbsent(daemon + "\u0000" + a.paneId(), a);
byPane.putIfAbsent(a.paneId(), a);
}
}
}
@@ -3,7 +3,6 @@ package dev.ltms.fleet.member;
import dev.ltms.fleet.config.FleetConfig;
import dev.ltms.fleet.herdr.Agent;
import dev.ltms.fleet.herdr.AgentControl;
import dev.ltms.fleet.herdr.HerdrClient;
import dev.ltms.fleet.herdr.HerdrException;
import dev.ltms.fleet.herdr.Tab;
import dev.ltms.fleet.herdr.Workspace;
@@ -521,11 +520,6 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
req.sessionName(), spawned.agentSessionId(), spawned.receipt());
}
/** The herdr daemon that owns this launcher's pane coordinates. */
public HerdrClient herdr() {
return agents.herdr();
}
@Override
public String effectiveCwd(SpawnRequest req) {
return effectiveCwd(req.profileName(), req.requestedCwd(), req.callerCwd());
@@ -1006,8 +1000,8 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
*
* <p>CB-633: under {@code policy: allow-list} this overlay is NOT the control anymore — it is
* applied before the login shell runs and a sourced file can (and did) undo it. The control is
* the ZDOTDIR scrub ({@link #applyEnvironmentAllowListPolicy}); {@code known}/{@code allow}
* remain as reporting only via {@link #logCredentialGap}.
* the ZDOTDIR scrub ({@link #applyEnvironmentAllowListPolicy}); {@code allow} adds explicit
* keeps to its derived base, while {@code known} remains reporting metadata.
*/
private void applyMemberCredentialPolicy(Map<String, String> workerEnv) {
FleetConfig.MemberCredentials creds = memberCredentials == null ? null : memberCredentials.get();
@@ -1016,8 +1010,8 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
}
if (!creds.isAllowList()) {
overlayBlockedCredentials(workerEnv, creds);
logCredentialGap(creds, null);
}
logCredentialGap(creds);
}
/** Put {@link #BLOCKED_CREDENTIAL_SENTINEL} over every blocked name in the pane-creation env map. */
@@ -1039,84 +1033,79 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
* pass it through {@code tab.create}/{@code pane.split}. Returns the directory for teardown
* registration, or {@code null} when the policy does not apply.
*
* <p>The allow-list handed to the generator is the derived profile set UNIONed with the
* operator's own {@code memberCredentials.allow:} names ({@link MemberEnvAllowList#derive(
* Collection, Set)} — CB-633 follow-up) and with the exact keys of THIS launch's env map —
* names the daemon itself injects must survive its own control. {@code SSH_AUTH_SOCK} is added
* ONLY when the config explicitly allows it, EVEN IF the operator also listed it under
* {@code allow:}; by default it is absent, so the scrub blanks it like any other non-derived
* name. It stays a one-off decision because it is a live handle to the operator's ssh-agent, not
* a value — a member holding it can sign with every key the agent holds, so letting it ride in
* on the generic {@code allow:} list would hand that out for an unrelated reason.
* <p>The allow-list handed to the generator is the derived profile set ({@link
* MemberEnvAllowList#derive}) UNIONed with {@code memberCredentials.allow} and the exact keys of
* THIS launch's env map. This lets the operator keep inherited variables by name without putting
* their values in config, and names the daemon itself injects survive its own control. {@code
* SSH_AUTH_SOCK} is EXCLUDED from that union and added ONLY when {@code
* memberCredentials.sshAuthSock: allow} — {@code sshAuthSock} is the sole control for that one
* name, so it cannot be widened back in by naming it on {@code allow:} too; a conflicting entry
* there gets a WARN ({@link #warnSshAuthSockConflict}) and loses. By default {@code
* SSH_AUTH_SOCK} is absent, so the scrub blanks it like any other non-derived name.
*/
private Path applyEnvironmentAllowListPolicy(FleetConfig.Profile cfg, Launch launch) {
FleetConfig.MemberCredentials creds = memberCredentials == null ? null : memberCredentials.get();
if (creds == null || !creds.isAllowList()) {
return null;
}
Set<String> allowed = derivedAllowedNames(creds, launch);
String loginShell = resolveEnv("SHELL");
boolean zsh = loginShell != null && (loginShell.endsWith("/zsh") || loginShell.equals("zsh"));
if (!zsh) {
// A non-zsh login shell ignores ZDOTDIR entirely: NO scrub would run, so pretending
// otherwise would be worse than saying so. Warn loudly and fall back to the CB-596
// sentinel overlay over the enumerated known: names — weaker (a sourced file can undo
// it), but strictly better than nothing. Deliberately no "allowed N of M" line here: the
// scrub this count describes does not run on this path, so printing it would tell an
// operator that a fraction of names were blocked when the real number blocked is zero.
// logCredentialGap's WARN (below) is the only signal for this path.
// it), but strictly better than nothing.
warnNonZsh(loginShell);
overlayBlockedCredentials(launch.env(), creds);
logCredentialGap(creds);
// Nothing is scrubbed on this path — it is the CB-596 overlay only, which a sourced
// startup file can undo. Report the gap with the WARN wording (an inherited-unblocked
// exposure), not the allow-list INFO wording, which would falsely claim a scrub blanks it.
logCredentialGap(creds, null);
return null;
}
// Only reached when the scrub is actually about to run — the count below describes that
// scrub, so it must not be logged before this gate (see the non-zsh branch above).
logAllowListCoverage(allowed);
Set<String> allowed = new java.util.TreeSet<>(MemberEnvAllowList.derive(profiles.values()));
for (String name : creds.allow()) {
// SSH_AUTH_SOCK is governed ONLY by sshAuthSock: below, never by allow: — an operator
// who writes both `sshAuthSock: block` and `SSH_AUTH_SOCK` on `allow:` means the block
// to win, not to be silently widened away by the second control.
if (!SSH_AUTH_SOCK.equals(name)) {
allowed.add(name);
}
}
if (creds.sshAuthSockAllowed()) {
allowed.add(SSH_AUTH_SOCK);
} else if (creds.allow().contains(SSH_AUTH_SOCK)) {
warnSshAuthSockConflict();
} // blocked by default: absent from the set ⇒ blanked by the scrub like any other name
allowed.addAll(launch.env().keySet());
logCredentialGap(creds, allowed);
Path dir = EnvAllowListScrub.generate(Path.of(System.getProperty("java.io.tmpdir")), allowed);
launch.env().put("ZDOTDIR", dir.toAbsolutePath().toString());
log.info("memberCredentials policy=allow-list: profile={} generated ZDOTDIR {} — derived "
log.info("memberCredentials policy=allow-list: profile={} generated ZDOTDIR {} — effective "
+ "allow-list holds {} name(s); the pane reports allowed N of M at release",
cfg.profile(), dir.getFileName(), allowed.size());
return dir;
}
/**
* The full kept-name set for this spawn: the profile-derived names, unioned with {@code
* memberCredentials.allow:} (CB-633 follow-up — previously ignored by this whole policy), the
* ssh-agent handle when explicitly allowed, and the exact keys of THIS launch's own env map.
*/
private Set<String> derivedAllowedNames(FleetConfig.MemberCredentials creds, Launch launch) {
Set<String> allowed = new java.util.TreeSet<>(
MemberEnvAllowList.derive(profiles.values(), creds.allowSet()));
if (creds.sshAuthSockAllowed()) {
allowed.add(SSH_AUTH_SOCK);
} // blocked by default: absent from the set ⇒ blanked by the scrub like any other name
allowed.addAll(launch.env().keySet());
return allowed;
}
/**
* CB-633 follow-up: one INFO line per allow-list spawn WHOSE SCRUB ACTUALLY RUNS, so an operator
* can read a single log line and know the scrub ran and how much of the visible environment it
* will keep. Callable ONLY from the zsh branch of {@link #applyEnvironmentAllowListPolicy}, after
* the shell gate — logging it before that gate (or on the non-zsh fallback, where nothing is
* scrubbed) would tell an operator a fraction of names were blocked when the real number blocked
* is zero, which is worse than not logging at all. {@code M} is {@link #hostEnvNames}' size (the
* daemon's own environment — see that field's javadoc for why it stands in for the pane's, which
* the daemon has no channel to inspect at spawn time) and {@code N} is how many of those names
* survive {@code allowed} (including the {@code LC_*} prefix rule). Neither number is a constant:
* both come from the actual derived set and the actual environment this spawn sees. Never logs a
* variable NAME or VALUE — only the counts.
*/
private void logAllowListCoverage(Set<String> allowed) {
Set<String> hostNames = hostEnvNames.get();
long kept = hostNames.stream().filter(name -> MemberEnvAllowList.keeps(allowed, name)).count();
log.info("member credentials: allowed {} of {}", kept, hostNames.size());
}
/** The operator ssh-agent handle — kept ONLY by explicit config decision, never by default. */
private static final String SSH_AUTH_SOCK = MemberEnvAllowList.SSH_AUTH_SOCK;
private static final String SSH_AUTH_SOCK = "SSH_AUTH_SOCK";
/** Guards {@link #warnSshAuthSockConflict} to one WARN per launcher instance, not one per spawn. */
private final AtomicBoolean sshAuthSockConflictWarned = new AtomicBoolean();
/**
* Defect fix: {@code SSH_AUTH_SOCK} named on {@code memberCredentials.allow} while
* {@code sshAuthSock} is not {@code allow} is a conflict between the two controls — say which
* one wins, once per launcher instance, without printing any value.
*/
private void warnSshAuthSockConflict() {
if (sshAuthSockConflictWarned.compareAndSet(false, true)) {
log.warn("memberCredentials policy=allow-list: SSH_AUTH_SOCK is named on allow: but "
+ "sshAuthSock is not 'allow' — sshAuthSock: block wins and SSH_AUTH_SOCK "
+ "stays blocked. Remove it from allow: or set sshAuthSock: allow if the "
+ "member should keep it.");
}
}
/**
* CB-633: a non-zsh login shell means the allow-list control CANNOT run — say so once per
@@ -1177,29 +1166,59 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
private static final Pattern CREDENTIAL_SHAPED_NAME =
Pattern.compile("(?i).*(TOKEN|SECRET|_KEY|APIKEY|PASSWORD|CREDENTIAL|AUTH).*");
/** Guards {@link #logCredentialGap} to one WARN per launcher instance, not one per spawn. */
private final AtomicBoolean credentialGapLogged = new AtomicBoolean();
/**
* Guards {@link #logCredentialGap}'s two report kinds separately — one flag per wording, not
* one shared flag — so an early INFO ("will be blanked by the scrub") on one spawn can never
* suppress a later, more serious WARN ("inherited UNBLOCKED") on another. {@code
* memberCredentials} is live-reloadable, so the policy really can change between spawns on the
* same launcher instance.
*/
private final AtomicBoolean allowListGapLogged = new AtomicBoolean();
/** See {@link #allowListGapLogged} — the WARN-wording counterpart. */
private final AtomicBoolean unprotectedGapLogged = new AtomicBoolean();
/**
* CB-596 criterion 4: a credential-shaped host env var name on neither {@code known} nor
* {@code allow} is not silently allowed — it is reported. {@link #hostEnvNames} enumerates the
* CB-596 criterion 4: report credential-shaped host env var names that the active policy does
* not classify. Callers that pass a non-null {@code effectiveAllowed} are reporting against a
* kept-name set a scrub will actually enforce — an absent name gets an INFO stating that the
* scrub will blank it, because that is the control working rather than an exposure. Every
* other caller — deny-by-default, and an allow-list spawn whose scrub could not run (non-zsh
* login shell, falls back to the overlay) — passes {@code null} and gets the original WARN,
* because in both cases a gap name is inherited unblocked. {@link #hostEnvNames} enumerates the
* daemon's own environment (see that field's javadoc for why the daemon's env is read rather
* than the spawned pane's, which the daemon has no channel to inspect at spawn time); this logs
* every such NAME, at WARN, at most once per launcher instance — never a value, a prefix of a
* value, or a hash of a value, so the log itself cannot leak anything.
* than the spawned pane's). This logs names only, at most once per launcher instance per report
* kind — never a value, a prefix of a value, or a hash of a value, so the log itself cannot
* leak anything.
*/
private void logCredentialGap(FleetConfig.MemberCredentials creds) {
Set<String> covered = new HashSet<>(creds.known());
covered.addAll(creds.allow());
private void logCredentialGap(FleetConfig.MemberCredentials creds, Set<String> effectiveAllowed) {
Set<String> covered = effectiveAllowed == null
? new HashSet<>(creds.known()) : effectiveAllowed;
if (effectiveAllowed == null) {
covered.addAll(creds.allow());
}
List<String> gap = hostEnvNames.get().stream()
.filter(name -> CREDENTIAL_SHAPED_NAME.matcher(name).matches())
.filter(name -> !covered.contains(name))
.filter(name -> effectiveAllowed == null
? !covered.contains(name) : !MemberEnvAllowList.keeps(covered, name))
.sorted()
.toList();
if (gap.isEmpty()) {
return;
}
if (credentialGapLogged.compareAndSet(false, true)) {
// effectiveAllowed != null means a derived allow-list set was actually computed (the
// scrub will run against it) — that is the INFO case. A null effectiveAllowed means no
// scrub protects this spawn (deny-by-default, or an allow-list spawn that fell back to
// the overlay-only path because the login shell is not zsh) — that is the WARN case.
// See #allowListGapLogged for why the two kinds use separate guards.
if (effectiveAllowed != null) {
if (allowListGapLogged.compareAndSet(false, true)) {
log.info("memberCredentials allow-list: {} credential-shaped host env var name(s) "
+ "are not kept and will be blanked by the scrub — {}. Add any name "
+ "a member legitimately needs to memberCredentials.allow.",
gap.size(), gap);
}
} else if (unprotectedGapLogged.compareAndSet(false, true)) {
log.warn("memberCredentials gap: {} credential-shaped env var name(s) are on neither "
+ "known: nor allow: — every member pane inherits them UNBLOCKED — {}. "
+ "Add each to memberCredentials.known (blocked by default) or .allow "
@@ -7,14 +7,16 @@ import java.util.Set;
import java.util.TreeSet;
/**
* CB-633: the set of environment variable NAMES a spawned member is allowed to keep under
* {@code memberCredentials.policy: allow-list} — DERIVED from what the launcher itself injects,
* never hand-typed.
* CB-633: the base set of environment variable NAMES a spawned member is allowed to keep under
* {@code memberCredentials.policy: allow-list}. This class derives the base from what the launcher
* itself injects. The launcher then adds the operator's explicitly named {@code
* memberCredentials.allow} keeps and the exact keys of this launch's env map.
*
* <p>A hand-typed allow-list is the defect this class exists to prevent: a name an operator forgets
* to type is a credential that passes through to every member, and a profile added to config later
* would silently break spawns whose scrub did not know its names. Derivation closes both ends. The
* kept-name set is the union of:
* <p>A hand-typed list that <em>replaces</em> derivation is the defect this class exists to prevent:
* a profile added to config later would silently break spawns whose scrub did not know its names.
* An explicit list that only adds keeps is safe because adding a name can only widen the set; it
* cannot make another spawn lose a name that derivation already kept. The derived base is the union
* of:
*
* <ul>
* <li>every configured {@link FleetConfig.Profile profile}'s {@code gitTokenEnv},
@@ -26,36 +28,18 @@ import java.util.TreeSet;
* or the agent binary genuinely needs to function.</li>
* </ul>
*
* <p>Because the union spans EVERY profile (not just the one spawning), adding a new profile can
* only ever widen the list — it cannot break another spawn's scrub. And because the launcher also
* unions in the exact keys of each spawn's own env map at generation time (see {@code
* HerdrPeerLauncher}), anything the daemon deliberately injects for THIS spawn survives its own
* control.
* <p>Because the base spans EVERY profile (not just the one spawning), adding a new profile can only
* ever widen the list — it cannot break another spawn's scrub. The launcher then adds the operator's
* explicit keeps and the exact keys of each spawn's own env map at generation time (see {@code
* HerdrPeerLauncher}), so member binaries can keep named inherited variables without putting their
* secret values in config, and anything the daemon injects for THIS spawn survives its own control.
*
* <p>{@code SSH_AUTH_SOCK} is deliberately NOT here. It is a handle to the operator's ssh-agent — a
* member holding it can sign with the operator's keys — so keeping it is a config decision
* ({@code memberCredentials.sshAuthSock: allow}), not a derivation default.
*
* <p><b>CB-633 follow-up:</b> the union also includes {@code memberCredentials.allow:} — the
* operator's own explicit list. Before this, {@code policy: allow-list} silently ignored every name
* an operator wrote under {@code allow:} unless a profile happened to carry it too, which meant
* turning the policy on could blank credentials working members already depended on. {@code
* SSH_AUTH_SOCK} is the one exception: even when the operator lists it under {@code allow:}, it is
* excluded here and added back ONLY by the caller when {@code sshAuthSock: allow} is explicitly set
* (see {@link #SSH_AUTH_SOCK}'s javadoc) — it is a live handle to the operator's own ssh-agent, not
* a value, so treating it like any other allow-listed name would hand a member every key the
* operator's agent holds the moment they typed the name under {@code allow:} for an unrelated
* reason.
*/
public final class MemberEnvAllowList {
/**
* The operator's ssh-agent socket path. Deliberately excluded from {@link #derive}'s union of
* {@code memberCredentials.allow:} — see the class javadoc's CB-633 follow-up note. Governed
* ONLY by {@code memberCredentials.sshAuthSock}, never by appearing in {@code allow:}.
*/
public static final String SSH_AUTH_SOCK = "SSH_AUTH_SOCK";
/**
* Names that are not credentials and that a login shell or agent binary genuinely needs.
*
@@ -91,21 +75,9 @@ public final class MemberEnvAllowList {
/**
* Derive the allowed NAME set from the given profiles plus {@link #INFRASTRUCTURE_PASSTHROUGH}.
* Equivalent to {@link #derive(Collection, Set)} with no operator-configured names — kept for
* callers (and existing tests) that only care about the profile-derived half.
* Deterministic (sorted) so generated scrub files are diffable run-to-run.
*/
public static Set<String> derive(Collection<FleetConfig.Profile> profiles) {
return derive(profiles, Set.of());
}
/**
* Derive the allowed NAME set: the profile-derived union above, PLUS {@code configuredAllow} —
* the operator's own {@code memberCredentials.allow:} list (CB-633 follow-up). {@code
* SSH_AUTH_SOCK} is dropped from {@code configuredAllow} even if the operator listed it there;
* see the class javadoc for why. Deterministic (sorted) so generated scrub files are diffable
* run-to-run.
*/
public static Set<String> derive(Collection<FleetConfig.Profile> profiles, Set<String> configuredAllow) {
Set<String> derived = new TreeSet<>(INFRASTRUCTURE_PASSTHROUGH);
if (profiles != null) {
for (FleetConfig.Profile p : profiles) {
@@ -117,13 +89,6 @@ public final class MemberEnvAllowList {
}
}
}
if (configuredAllow != null) {
for (String name : configuredAllow) {
if (name != null && !name.isBlank() && !SSH_AUTH_SOCK.equals(name)) {
derived.add(name);
}
}
}
return Set.copyOf(derived);
}
@@ -29,9 +29,8 @@ import java.util.concurrent.TimeoutException;
*
* <p><strong>Mapping — consume-and-hold with deferred manual ack.</strong> Each target has a durable
* queue {@code agent.<target>.inbox}. The gateway that owns the target starts a manual-ack consumer
* ({@link #own}) that pulls persistent messages, up to its prefetch window, off that queue into an
* in-memory <em>held</em> map (keyed by {@code msgId}) but does <em>not</em> ack them.
* {@link #peek} returns that snapshot;
* ({@link #own}) that pulls persistent messages off that queue into an in-memory <em>held</em> map
* (keyed by {@code msgId}) but does <em>not</em> ack them. {@link #peek} returns that snapshot;
* {@link #ack} acks the broker delivery-tag and drops the entry. Because messages stay unacked until
* the owning gateway actually drains them, a crash (or a {@code java -jar} bounce) before caller-ack
* leaves them on the broker — it redelivers on reconnect. That is the durability the in-memory
@@ -31,7 +31,6 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
/**
@@ -59,7 +58,7 @@ public final class FleetApp {
private final PeerLauncher workers;
private final SessionManager sessions; // CB-301: authoritative session registry
private final MessageService messages;
private final Predicate<String> deliverable;
private final MemberPresence presence; // CB-113: which workers are MCP-connected (available)
private final HttpServlet mcpServlet; // MCP Streamable-HTTP endpoint, mounted at /mcp (nullable)
private final CallerResolver auth; // CB-501: null → authz not enforced (legacy behaviour)
private final Metrics metrics; // CB-502: null → /metrics not exposed
@@ -71,9 +70,9 @@ public final class FleetApp {
* behaviour without each needing an auth fixture.
*/
public FleetApp(HerdrClient herdr, PeerLauncher workers, SessionManager sessions,
MessageService messages, MemberPresence presence,
HttpServlet mcpServlet) {
this(herdr, workers, sessions, messages, presence, mcpServlet, null, null, presence::isPresent);
MessageService messages, MemberPresence presence,
HttpServlet mcpServlet) {
this(herdr, workers, sessions, messages, presence, mcpServlet, null, null);
}
/**
@@ -83,23 +82,13 @@ public final class FleetApp {
* the endpoint
*/
public FleetApp(HerdrClient herdr, PeerLauncher workers, SessionManager sessions,
MessageService messages, MemberPresence presence,
HttpServlet mcpServlet, CallerResolver auth, Metrics metrics) {
this(herdr, workers, sessions, messages, presence, mcpServlet, auth, metrics, presence::isPresent);
}
/**
* @param deliverable the injector's readiness gate, shared so status reports its real result
*/
public FleetApp(HerdrClient herdr, PeerLauncher workers, SessionManager sessions,
MessageService messages, MemberPresence presence,
HttpServlet mcpServlet, CallerResolver auth, Metrics metrics,
Predicate<String> deliverable) {
MessageService messages, MemberPresence presence,
HttpServlet mcpServlet, CallerResolver auth, Metrics metrics) {
this.herdr = herdr;
this.workers = workers;
this.sessions = sessions;
this.messages = messages;
this.deliverable = deliverable;
this.presence = presence;
this.mcpServlet = mcpServlet;
this.auth = auth;
this.metrics = metrics;
@@ -518,9 +507,9 @@ public final class FleetApp {
/**
* Live lifecycle status of a worker (MCP `fleet_status` wraps this in CB-105), plus its
* <em>readiness</em>: {@code ready} is true when the injector can deliver to the target. A
* spawned member must connect the bridge MCP first, while a known lead is ready without member
* presence. This differs from bare {@code idle}, which is also true during member boot.
* <em>readiness</em> (CB-113): {@code ready} is true once the worker's Claude has connected the
* bridge MCP — the reliable "available to receive a task" signal, unlike bare {@code idle}, which
* is also true during boot.
*/
private void sessionStatus(Context ctx) {
String id = ctx.pathParam("id");
@@ -531,7 +520,7 @@ public final class FleetApp {
Map<String, Object> body = new LinkedHashMap<>();
body.put("sessionId", id);
body.put("status", messages.status(id).name().toLowerCase());
body.put("ready", deliverable.test(id));
body.put("ready", presence.isPresent(id));
// CB-582: a worker paused mid-turn in an async fleet_ask is otherwise invisible to a
// status poll — surface the open question and how to answer it, same as fleet_poll's
// Phase.ASKING view.
@@ -7,8 +7,6 @@ import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -22,7 +20,6 @@ import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
@@ -67,10 +64,6 @@ public final class GitWorktrees implements Worktrees {
/** What {@link #isolateToolSurface} writes for {@code .autoenv}: a valid, empty env file. */
private static final String NEUTRAL_AUTOENV_CONFIG = "";
/** A credential helper command which reads only an environment variable at Git call time. */
private static final String ENVIRONMENT_CREDENTIAL_HELPER = "!f() { if [ \"$1\" = get ]; then "
+ "printf 'username=%s\\npassword=%s\\n\\n' git \"$WORKER_GITEA_TOKEN\"; fi; }; f";
/**
* A tracked project config that is hostile in a provisioned worktree, and what to replace it
* with. {@link #file} is the repo-relative path; {@link #stub} is a neutral but VALID payload for
@@ -88,7 +81,6 @@ public final class GitWorktrees implements Worktrees {
);
private final String configuredRoot;
private final Consumer<String> afterWorktreeAdded;
private final SecureRandom random = new SecureRandom();
private final AtomicLong seq = new AtomicLong();
@@ -99,13 +91,7 @@ public final class GitWorktrees implements Worktrees {
/** @param configuredRoot nullable absolute or relative path; null/blank derives a sibling of the repo root. */
public GitWorktrees(String configuredRoot) {
this(configuredRoot, _ -> {});
}
/** Test seam for changing a real worktree between its creation and its security check. */
GitWorktrees(String configuredRoot, Consumer<String> afterWorktreeAdded) {
this.configuredRoot = configuredRoot;
this.afterWorktreeAdded = afterWorktreeAdded == null ? _ -> {} : afterWorktreeAdded;
}
@Override
@@ -121,148 +107,11 @@ public final class GitWorktrees implements Worktrees {
}
String wt = path.toAbsolutePath().toString();
log.info("adding worktree branch={} path={} base={}", branch, wt, base);
removeUserInfoFromHttpsOrigin(repoRoot);
exec("git", "-C", repoRoot, "worktree", "add", wt, "-b", branch, base);
afterWorktreeAdded.accept(wt);
requireCredentialFreeHttpsOrigin(wt);
configureEnvironmentCredentialHelper(repoRoot, wt);
configureHttpsUrlRewriteForSshOrigin(repoRoot, wt);
isolateToolSurface(wt);
return wt;
}
/**
* A linked worktree shares its primary checkout's git config. Remove HTTPS user info before
* adding one, so a credential accidentally embedded in that config cannot reach the member.
*/
private void removeUserInfoFromHttpsOrigin(String repoRoot) {
if (exitCode("git", "-C", repoRoot, "config", "--get", "remote.origin.url") != 0) {
return;
}
String origin = exec("git", "-C", repoRoot, "config", "--get", "remote.origin.url").trim();
URI uri;
try {
uri = new URI(origin);
} catch (URISyntaxException e) {
throw new WorktreeException("origin URL is invalid; cannot provision a safe worktree", e);
}
if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getUserInfo() == null) {
return;
}
int schemeEnd = origin.indexOf("://") + 3;
int userInfoEnd = origin.indexOf('@', schemeEnd);
if (userInfoEnd < schemeEnd) {
throw new WorktreeException("origin URL has invalid HTTPS user info; cannot provision safely");
}
String cleanOrigin = origin.substring(0, schemeEnd) + origin.substring(userInfoEnd + 1);
exec("git", "-C", repoRoot, "remote", "set-url", "origin", cleanOrigin);
log.info("removed HTTPS user info from forge origin before provisioning worktree");
}
/** Refuse the worktree if Git still resolves any HTTPS origin URL with embedded credentials. */
private void requireCredentialFreeHttpsOrigin(String worktreePath) {
if (exitCode("git", "-C", worktreePath, "remote", "get-url", "--all", "origin") != 0) {
return;
}
String origins = exec("git", "-C", worktreePath, "remote", "get-url", "--all", "origin");
for (String origin : origins.split("\\R")) {
try {
URI uri = new URI(origin);
if ("https".equalsIgnoreCase(uri.getScheme()) && uri.getUserInfo() != null) {
throw new WorktreeException("worktree origin contains HTTPS user info; refusing provision");
}
} catch (URISyntaxException e) {
throw new WorktreeException("worktree origin URL is invalid; refusing provision", e);
}
}
}
/** Configure a per-worktree helper that supplies a token from the member environment at call time. */
private void configureEnvironmentCredentialHelper(String repoRoot, String worktreePath) {
exec("git", "-C", repoRoot, "config", "extensions.worktreeConfig", "true");
// An empty helper resets values inherited from the system or global config. Without it Git
// asks the next helper after this one, which can expose an operator-level credential.
exec("git", "-C", worktreePath, "config", "--worktree", "--replace-all", "credential.helper", "");
exec("git", "-C", worktreePath, "config", "--worktree", "--add", "credential.helper",
ENVIRONMENT_CREDENTIAL_HELPER);
}
/**
* {@link #configureEnvironmentCredentialHelper} only ever fires for an HTTPS origin — Git never
* consults a {@code credential.helper} for an SSH transport. This repo's own origin is
* {@code ssh://git@git.ltms.dev:2224/fleet/fleetd.git}, so a member sitting on that origin never
* reaches the helper and the repo-scoped {@code WORKER_GITEA_TOKEN} is simply not used.
*
* <p>An earlier version of this javadoc justified the rewrite by claiming a member <em>cannot</em>
* push once {@code memberCredentials.policy: allow-list} blocks {@code SSH_AUTH_SOCK}, because
* "there is no private key file on this host, only an ssh-agent socket". That premise is false
* (fleetd #184): {@code ssh -G} resolves a readable, passphrase-free {@code IdentityFile} outside
* {@code ~/.ssh}, and a member — same OS user — pushes over SSH with the socket blanked. The
* rewrite is still worth having, but for the reason below rather than that one: it routes the
* member through its own scoped token instead of the operator's ssh identity, which is what makes
* a member's pushes attributable and revocable.
*
* <p>The fix is a <em>worktree-scoped</em> URL rewrite: {@code url.<https-base>.insteadOf
* <ssh-base>}, set with {@code --worktree} so it lands only in
* {@code <worktree>/.git/worktrees/<name>/config.worktree} (enabled by
* {@code extensions.worktreeConfig}, already turned on above) and never touches the shared
* repo-level config the primary checkout also reads. {@code insteadOf} — not
* {@code pushInsteadOf} — because a member may also need to fetch or rebase, and both should go
* through the member's own token for the same reason.
*
* <p>The host (and, for the rewrite's SSH-side match, the port) come from parsing the origin
* itself — never a hardcoded forge host, which is exactly what #177 removed. An origin that is
* already {@code https://} is left alone; the credential helper already covers it. An origin
* that is neither {@code ssh://} nor {@code https://} — including the scp-like shorthand
* ({@code git@host:path}, no scheme) — is left untouched deliberately: that shorthand's
* {@code host:path} split is defined by the user's ssh_config aliases, not by URI syntax, so
* guessing at it risks rewriting to the wrong place. A repo provisioned from that form keeps
* today's (broken, if the policy blocks the agent) SSH-only behaviour rather than a wrong rewrite.
*/
/**
* Blank the user-info of a remote URL before it reaches a log. A remote URL is not obviously a
* credential channel, which is exactly why one has leaked here three times ({@code git remote -v}
* printing a token inline, and fleetd #157 / #182). An {@code ssh://} authority normally carries
* only {@code git@}, so this usually changes nothing — it is here so that the one origin that
* does carry a secret cannot print it. Matches every {@code ://…@} pair, not just the first.
*/
private static String redactUserInfo(String url) {
return url == null ? null : url.replaceAll("://[^@/]*@", "://<redacted>@");
}
private void configureHttpsUrlRewriteForSshOrigin(String repoRoot, String worktreePath) {
if (exitCode("git", "-C", repoRoot, "config", "--get", "remote.origin.url") != 0) {
return;
}
String origin = exec("git", "-C", repoRoot, "config", "--get", "remote.origin.url").trim();
URI uri;
try {
uri = new URI(origin);
} catch (URISyntaxException e) {
log.warn("origin URL {} is not a valid URI; skipping worktree HTTPS rewrite", redactUserInfo(origin));
return;
}
String scheme = uri.getScheme();
if (!"ssh".equalsIgnoreCase(scheme)) {
// Already https:// (the credential helper covers it), or a scheme-less/scp-like origin
// left alone on purpose — see the javadoc above.
log.debug("origin scheme is not ssh ({}) — no worktree HTTPS rewrite needed", redactUserInfo(origin));
return;
}
String host = uri.getHost();
String authority = uri.getRawAuthority();
if (host == null || host.isBlank() || authority == null || authority.isBlank()) {
log.warn("ssh origin {} has no resolvable host; skipping worktree HTTPS rewrite", redactUserInfo(origin));
return;
}
String sshBase = "ssh://" + authority + "/";
String httpsBase = "https://" + host + "/";
exec("git", "-C", worktreePath, "config", "--worktree", "--replace-all",
"url." + httpsBase + ".insteadOf", sshBase);
log.info("worktree {} rewrites {} to {} (worktree-scoped; parent checkout untouched)",
worktreePath, redactUserInfo(sshBase), httpsBase);
}
/**
* Neutralize the worktree's worktree-hostile project configs so a worker inherits only the tools
* and environment its launcher mounts (the bridge via {@code --mcp-config}, the opencode config
@@ -197,15 +197,10 @@ class CompletionResolverTest {
void resolvesSynchronouslyBeforePostTurnContextClearing() {
FakeHerdr herdr = new FakeHerdr().readText("⏺ previous answer\n❯ ");
Rendezvous rendezvous = new Rendezvous();
// fleetd#164: an injectable clock, so this real (non-crash) turn lands outside MIN_TURN_NANOS
// — captureBaseline and resolveBeforePostAction below run back-to-back with no real delay.
long[] clock = {1_000_000_000L};
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous,
ExhaustedPatternLookup.none(), ExhaustionSink.none(), () -> clock[0]);
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
var waiter = rendezvous.open("term_a");
resolver.captureBaseline("term_a", new TurnToken("term_a", waiter));
herdr.readText("⏺ answer that /clear would erase\n❯ ");
clock[0] += CompletionResolver.MIN_TURN_NANOS + 1; // this turn took longer than the floor
resolver.resolveBeforePostAction("term_a");
@@ -224,11 +219,7 @@ class CompletionResolverTest {
String longBlock = "⏺ " + "x".repeat(CompletionResolver.MAX_SCRAPE_CHARS + 500) + "\n❯ ";
FakeHerdr herdr = new FakeHerdr().readText(longBlock);
Rendezvous rendezvous = new Rendezvous();
// fleetd#164: an injectable clock so captureBaseline and resolve (back-to-back, no real
// delay) don't trip the too-fast-turn floor — this test is about the suppression guard, not timing.
long[] clock = {1_000_000_000L};
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous,
ExhaustedPatternLookup.none(), ExhaustionSink.none(), () -> clock[0]);
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
var waiter = rendezvous.open("term_a"); // a send is blocked on this turn
resolver.captureBaseline("term_a", new TurnToken("term_a", waiter)); // baseline is the clipped >cap block
@@ -236,7 +227,6 @@ class CompletionResolverTest {
assertEquals(CompletionResolver.MAX_SCRAPE_CHARS, turn.baseline().length(),
"the delivery baseline is clipped to the same cap resolve() applies to the tail");
clock[0] += CompletionResolver.MIN_TURN_NANOS + 1; // outside the floor
resolver.resolve("term_a", turn); // scrape unchanged → clipped tail == baseline → suppress
assertFalse(waiter.isDone(),
@@ -259,13 +249,12 @@ class CompletionResolverTest {
}
@Test
void aFailedScrapeResolvesAsAFailureEvenWithABaselinePresent() {
// fleetd#164: this test used to assert that a failed read resolved the send as a SUCCESS
// carrying an empty string ("an empty tail beats hanging until the caller's timeout") — that
// was the bug this ticket fixes: a lost turn and a genuine empty answer looked identical to
// every caller. This test encoded the bug and is changed here: a failed read must fail the
// send instead, naming the member, whether or not a baseline was captured (the baseline here
// is "", an empty pane at delivery — proof this isn't the CB-115 misattribution path either).
void resolvesWhenTheScrapeItselfFailsEvenWithABaselinePresent() {
// The most important branch of the CB-115 guard: a failed read means the resolver could not
// SEE the screen — "couldn't see", not "no change". It must still resolve the send (an empty
// tail beats hanging until the caller's timeout), even though a baseline was captured. The
// baseline here is "" (an empty pane at delivery), so without the !scrapeFailed clause the
// byte-identical guard would wrongly match the empty tail and suppress.
FakeHerdr herdr = new FakeHerdr().healthy(false); // agent.read throws HerdrException
Rendezvous rendezvous = new Rendezvous();
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
@@ -276,82 +265,8 @@ class CompletionResolverTest {
assertTrue(waiter.isDone(),
"a failed scrape must still resolve the send, not hang until the caller's timeout");
assertEquals(Rendezvous.Kind.FAILED, waiter.getNow(null).kind(),
"a failed read is a lost turn, not a successful empty reply");
assertTrue(waiter.getNow(null).text().contains("term_a"),
"the failure names the member: " + waiter.getNow(null).text());
assertTrue(waiter.getNow(null).text().contains("could not be read"),
"the failure explains the scrape could not be read: " + waiter.getNow(null).text());
}
// --- fleetd#164: an empty (but readable) scrape must never resolve as a success --------------
@Test
void anEmptyScrapeResolvesAsAFailureNamingTheMember() {
// The core defect: a scrape that read CLEANLY but produced zero characters used to resolve
// the send as a SUCCESS carrying "" — indistinguishable, to every caller, from a worker that
// genuinely finished with nothing to say. A lost turn must never look like a real empty reply.
FakeHerdr herdr = new FakeHerdr().readText("");
Rendezvous rendezvous = new Rendezvous();
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
var waiter = rendezvous.open("term_a");
resolver.resolve("term_a", new CompletionResolver.InFlight(waiter, null));
assertTrue(waiter.isDone(), "an empty scrape must still resolve the send, not hang");
assertEquals(Rendezvous.Kind.FAILED, waiter.getNow(null).kind(),
"an empty scrape is a lost turn, not a successful empty reply");
assertTrue(waiter.getNow(null).text().contains("term_a"),
"the failure names the member: " + waiter.getNow(null).text());
assertTrue(waiter.getNow(null).text().toLowerCase().contains("empty"),
"the failure says the scrape was empty: " + waiter.getNow(null).text());
}
// --- fleetd#164: a BUSY -> DONE transition inside the floor is a crash, not a fast answer ------
@Test
void aBusyToDoneTransitionInsideTheFloorResolvesAsAFailure() {
// The exact fleetd#164 scenario: the backend returned an HTTP 400 before the worker did
// anything, and the member went BUSY -> DONE in ~1s. That transition alone is indistinguishable
// from a genuine (if unusually fast) completion, so the resolver leans on the floor to catch it.
FakeHerdr herdr = new FakeHerdr().readText("⏺ HTTP 400: invalid request\n❯ ");
Rendezvous rendezvous = new Rendezvous();
long[] clock = {10_000_000_000L};
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous,
ExhaustedPatternLookup.none(), ExhaustionSink.none(), () -> clock[0]);
var waiter = rendezvous.open("term_a");
var turn = new CompletionResolver.InFlight(waiter, null, clock[0]); // delivered "now"
clock[0] += CompletionResolver.MIN_TURN_NANOS - 1; // 1ns inside the floor
resolver.resolve("term_a", turn);
assertTrue(waiter.isDone(), "a suspiciously fast turn must still resolve (as a failure)");
assertEquals(Rendezvous.Kind.FAILED, waiter.getNow(null).kind());
assertTrue(waiter.getNow(null).text().contains("term_a"),
"the failure names the member: " + waiter.getNow(null).text());
assertTrue(waiter.getNow(null).text().contains("HTTP 400"),
"the failure carries whatever was on screen: " + waiter.getNow(null).text());
}
@Test
void aBusyToDoneTransitionJustOutsideTheFloorResolvesNormally() {
FakeHerdr herdr = new FakeHerdr().readText("⏺ a real, if quick, answer\n❯ ");
Rendezvous rendezvous = new Rendezvous();
long[] clock = {10_000_000_000L};
CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous,
ExhaustedPatternLookup.none(), ExhaustionSink.none(), () -> clock[0]);
var waiter = rendezvous.open("term_a");
var turn = new CompletionResolver.InFlight(waiter, null, clock[0]); // delivered "now"
clock[0] += CompletionResolver.MIN_TURN_NANOS + 1; // 1ns outside the floor
resolver.resolve("term_a", turn);
assertTrue(waiter.isDone());
assertEquals(Rendezvous.Kind.COMPLETION, waiter.getNow(null).kind(),
"a turn that took longer than the floor resolves normally");
assertEquals("a real, if quick, answer", waiter.getNow(null).text());
assertEquals(Rendezvous.Kind.COMPLETION, waiter.getNow(null).kind());
assertEquals("", waiter.getNow(null).text(), "the tail is empty because the screen was unreadable");
}
// --- CB-115/CB-116 fail guard: an already-done or absent waiter is left alone ---------
@@ -1025,7 +1025,7 @@ class ClaudeCodeLauncherTest {
* {@code System.getenv()} so the test is deterministic.
*/
@Test
void aCredentialShapedNameOnNeitherListIsLoggedAsAGap() {
void denyByDefaultKeepsTheExactCredentialGapWarn() {
FakeHerdr herdr = new FakeHerdr();
FleetConfig.Profile cfg = new FleetConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN",
@@ -1045,16 +1045,104 @@ class ClaudeCodeLauncherTest {
logger.detachAppender(appender);
}
String expected = "memberCredentials gap: 1 credential-shaped env var name(s) are on neither "
+ "known: nor allow: — every member pane inherits them UNBLOCKED — "
+ "[A_BRAND_NEW_SECRET_TOKEN]. Add each to memberCredentials.known (blocked by default) "
+ "or .allow (if a member legitimately needs it).";
assertTrue(appender.list.stream().anyMatch(e ->
e.getFormattedMessage().contains("memberCredentials gap")
&& e.getFormattedMessage().contains("A_BRAND_NEW_SECRET_TOKEN")),
"the gap must name the unrecognized credential-shaped var, never a value");
e.getLevel() == ch.qos.logback.classic.Level.WARN
&& e.getFormattedMessage().equals(expected)),
"deny-by-default must keep the exact existing WARN text");
assertNotNull(startEnv(herdr).get("GITEA_ACCESS_TOKEN"),
"deny-by-default must still overlay a known blocked credential");
assertFalse(appender.list.stream().anyMatch(e -> e.getFormattedMessage().contains("PATH")),
"PATH/HOME are not credential-shaped and must not be reported as a gap");
assertFalse(appender.list.stream().anyMatch(e -> e.getFormattedMessage().contains("AI_GATEWAY_TOKEN")),
"a name already on allow: is covered, not a gap");
}
@Test
void allowListReportsAnUnkeptCredentialAsBlankedWithoutSayingUnblocked() {
FakeHerdr herdr = new FakeHerdr();
FleetConfig.Profile cfg = new FleetConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN",
List.of("claude"), "tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null);
FleetConfig.MemberCredentials creds = new FleetConfig.MemberCredentials(
FleetConfig.MemberCredentials.POLICY_ALLOW_LIST, List.of(), List.of(), null);
ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
name -> "SHELL".equals(name) ? "/bin/zsh" : null,
0, System::currentTimeMillis, () -> {}, null, () -> creds,
() -> Set.of("PATH", "HOME", "A_BRAND_NEW_SECRET_TOKEN"));
Logger logger = (Logger) LoggerFactory.getLogger(HerdrPeerLauncher.class);
ch.qos.logback.classic.Level original = logger.getLevel();
logger.setLevel(ch.qos.logback.classic.Level.INFO);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
try {
svc.spawn();
} finally {
logger.detachAppender(appender);
logger.setLevel(original);
}
ILoggingEvent gap = appender.list.stream()
.filter(e -> e.getFormattedMessage().contains("A_BRAND_NEW_SECRET_TOKEN"))
.findFirst().orElseThrow(() -> new AssertionError("allow-list must report the blanked name"));
assertEquals(ch.qos.logback.classic.Level.INFO, gap.getLevel(),
"a scrubbed credential name is the allow-list control working, not a WARN");
assertTrue(gap.getFormattedMessage().contains("will be blanked by the scrub"));
assertFalse(gap.getFormattedMessage().contains("UNBLOCKED"),
"the allow-list report must not claim that a blanked name is exposed");
}
/**
* Defect fix: {@code memberCredentials} is live-reloadable, so the policy really can change
* between spawns on the SAME launcher instance. An earlier INFO gap report (allow-list) must
* not permanently suppress a later, more serious WARN gap report (deny-by-default) — the two
* report kinds need separate guards, not one shared flag.
*/
@Test
void anEarlierInfoGapDoesNotSuppressALaterWarnGapOnTheSameLauncher() {
FakeHerdr herdr = new FakeHerdr();
FleetConfig.Profile cfg = new FleetConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN",
List.of("claude"), "tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null);
AtomicReference<FleetConfig.MemberCredentials> creds = new AtomicReference<>(
new FleetConfig.MemberCredentials(
FleetConfig.MemberCredentials.POLICY_ALLOW_LIST, List.of(), List.of(), null));
ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
name -> "SHELL".equals(name) ? "/bin/zsh" : null,
0, System::currentTimeMillis, () -> {}, null, creds::get,
() -> Set.of("PATH", "HOME", "A_BRAND_NEW_SECRET_TOKEN"));
Logger logger = (Logger) LoggerFactory.getLogger(HerdrPeerLauncher.class);
ch.qos.logback.classic.Level original = logger.getLevel();
logger.setLevel(ch.qos.logback.classic.Level.INFO);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
try {
svc.spawn(); // allow-list: logs the INFO gap report first
creds.set(new FleetConfig.MemberCredentials(
FleetConfig.MemberCredentials.POLICY_DENY_BY_DEFAULT, List.of(), List.of(), null));
svc.spawn(); // deny-by-default: must still log its own WARN gap report
} finally {
logger.detachAppender(appender);
logger.setLevel(original);
}
assertTrue(appender.list.stream().anyMatch(e ->
e.getLevel() == ch.qos.logback.classic.Level.WARN
&& e.getFormattedMessage().contains("A_BRAND_NEW_SECRET_TOKEN")
&& e.getFormattedMessage().contains("UNBLOCKED")),
"a prior INFO gap report must not suppress a later WARN gap report on the same "
+ "launcher instance");
}
/**
* The half of CB-592 that can actually survive the pane's login shell. BRIDGED_MEMBER is a name
* secrets.sh never exports, so nothing overwrites it — measured: GITEA_TOKEN is injected the
@@ -8,7 +8,6 @@ import dev.ltms.fleet.guard.SubscriptionGuard;
import dev.ltms.fleet.herdr.Agent;
import dev.ltms.fleet.herdr.AgentControl;
import dev.ltms.fleet.herdr.FakeHerdr;
import dev.ltms.fleet.herdr.HerdrException;
import dev.ltms.fleet.herdr.WorkspaceControl;
import dev.ltms.fleet.peer.Capability;
import dev.ltms.fleet.peer.CharterReceipt;
@@ -249,112 +248,6 @@ class CompositePeerLauncherTest {
"stop routes to the spawning adapter and closes exactly that worker's pane");
}
@Test
void stopKeepsSameHerdrPaneIdSeparateByOwningAdapter() {
// Separate herdr daemons can both issue w9:pRoot_1. The opaque handles identify their
// spawning adapters, so each stop reaches only its recorded owner.
FakeHerdr first = new FakeHerdr();
FakeHerdr second = new FakeHerdr();
PeerLauncher composite = new CompositePeerLauncher(
List.of(claudeAdapter(first), opencodeAdapter(second)), "claude");
PeerHandle claude = composite.spawn(new SpawnRequest("claude", null, null));
PeerHandle opencode = composite.spawn(new SpawnRequest("gemini", null, null));
assertNotEquals(claude.id(), opencode.id(), "each public paneId keeps its adapter owner");
composite.stop(claude.id());
assertTrue(first.calls.stream().anyMatch(c -> c.method().equals("pane.close")
&& "w9:pRoot_1".equals(((Map<?, ?>) c.params()).get("pane_id"))),
"the first daemon closes its own pane");
assertFalse(second.called("pane.close"), "the matching pane on the second daemon stays live");
composite.stop(opencode.id());
assertTrue(second.calls.stream().anyMatch(c -> c.method().equals("pane.close")
&& "w9:pRoot_1".equals(((Map<?, ?>) c.params()).get("pane_id"))),
"the second daemon then closes its own pane");
}
@Test
void stopAllowsALegacyBarePaneIdWithOneDaemon() {
FakeHerdr herdr = new FakeHerdr();
PeerLauncher composite = new CompositePeerLauncher(List.of(claudeAdapter(herdr)), "claude");
composite.stop("w9:pRoot_1");
assertTrue(herdr.calls.stream().anyMatch(c -> c.method().equals("pane.close")
&& "w9:pRoot_1".equals(((Map<?, ?>) c.params()).get("pane_id"))),
"one daemon keeps the legacy bare-pane routing behaviour");
}
@Test
void stopAllowsAnUnownedPaneIdWithTwoAdaptersSharingOneDaemon() {
FakeHerdr herdr = new FakeHerdr();
PeerLauncher composite = composite(herdr);
composite.stop("w9:pRoot_1");
assertTrue(herdr.calls.stream().anyMatch(c -> c.method().equals("pane.close")
&& "w9:pRoot_1".equals(((Map<?, ?>) c.params()).get("pane_id"))),
"two adapter kinds sharing one daemon keep the fallback route");
}
@Test
void listKeepsBothPanesWhenTwoDaemonsShareAPaneId() {
// herdr pane ids are per-daemon counters, so two daemons really can both hold w1:p1 on
// different panes. Deduplicating on the pane id alone dropped one of the two real agents.
FakeHerdr first = new FakeHerdr().withAgent("x", "term_x", "w1:p1", "w1:t1");
FakeHerdr second = new FakeHerdr().withAgent("y", "term_y", "w1:p1", "w1:t1");
CompositePeerLauncher composite = new CompositePeerLauncher(
List.of(claudeAdapter(first), opencodeAdapter(second)), "claude");
List<Agent> agents = composite.list();
assertEquals(2, agents.stream().filter(a -> "w1:p1".equals(a.paneId())).count(),
"one w1:p1 per daemon survives — the pane id alone is not a unique key");
assertTrue(agents.stream().anyMatch(a -> "term_x".equals(a.terminalId())));
assertTrue(agents.stream().anyMatch(a -> "term_y".equals(a.terminalId())));
}
@Test
void listStillDeduplicatesTwoAdaptersSharingOneDaemon() {
// Both adapters ask the SAME daemon, so both see the same agent set. Without the dedupe this
// would report every agent twice; the daemon key must not break that.
FakeHerdr herdr = new FakeHerdr().withAgent("x", "term_x", "w1:p1", "w1:t1");
CompositePeerLauncher composite = composite(herdr);
List<Agent> agents = composite.list();
assertEquals(1, agents.stream().filter(a -> "w1:p1".equals(a.paneId())).count(),
"one daemon still reports each of its agents once");
}
@Test
void stopKeepsTheOwnerRecordWhenTheDelegateRefusesTheStop() {
// Removing the record before the delegate accepted the stop lost the owner on failure: the
// pane was still alive, but the retry landed in the ambiguous branch and refused it for good.
FakeHerdr first = new FakeHerdr().paneCloseFailsWith("pane_busy");
CompositePeerLauncher composite = new CompositePeerLauncher(
List.of(claudeAdapter(first), opencodeAdapter(new FakeHerdr())), "claude");
PeerHandle claude = composite.spawn(new SpawnRequest("claude", null, null));
assertThrows(HerdrException.class, () -> composite.stop(claude.id()));
// The retry must still know its owner — a HerdrException, never "ambiguous paneId".
assertThrows(HerdrException.class, () -> composite.stop(claude.id()),
"the owner record survives a failed stop, so the retry is not ambiguous");
}
@Test
void stopRejectsAnUnownedPaneIdWhenMultipleDaemonsCouldOwnIt() {
PeerLauncher composite = new CompositePeerLauncher(
List.of(claudeAdapter(new FakeHerdr()), opencodeAdapter(new FakeHerdr())), "claude");
IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
() -> composite.stop("w1:p1"));
assertEquals("ambiguous paneId 'w1:p1': no owning herdr daemon was recorded", error.getMessage());
}
@Test
void opencodeContextResetIsANoOpAndWarnsOnlyOnce() {
FakeHerdr herdr = new FakeHerdr();
@@ -1,6 +1,5 @@
package dev.ltms.fleet.member;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
@@ -12,8 +11,10 @@ import dev.ltms.fleet.peer.Capability;
import dev.ltms.fleet.peer.MemberRole;
import dev.ltms.fleet.peer.SpawnRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
@@ -26,6 +27,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/**
* CB-633: proves the allow-list scrub is actually WIRED INTO the spawn path — not merely that its
@@ -48,6 +50,8 @@ class HerdrPeerLauncherAllowListWiringTest {
/** A name the daemon itself injects — it must survive its own scrub, so it must be allowed. */
private static final String INJECTED = "ANTHROPIC_BASE_URL";
private static final String OPERATOR_KEEP = "CONTEXT7_TOKEN";
private static final String UNLISTED_CREDENTIAL = "A_BRAND_NEW_SECRET_TOKEN";
@Test
void spawningUnderAllowListPolicyGivesThePaneAGeneratedZdotdir() {
@@ -79,6 +83,43 @@ class HerdrPeerLauncherAllowListWiringTest {
+ "), or the daemon's own configuration is blanked by its own control");
}
/** Explicit operator keeps widen the derived base, but all other exported names stay blocked. */
@Test
void operatorKeepSurvivesWhileAnUnlistedCredentialIsBlanked(@TempDir Path home) throws Exception {
Path zsh = Path.of("/bin/zsh");
assumeTrue(Files.isExecutable(zsh), "/bin/zsh not present — nothing to prove here");
FakeHerdr herdr = new FakeHerdr();
WiringLauncher launcher = new WiringLauncher(herdr, allowList(List.of(OPERATOR_KEEP)));
var spawned = launcher.spawn(new SpawnRequest("test", null, null, null, null, MemberRole.DEV));
try {
Path zdotdir = Path.of(launcher.env.get("ZDOTDIR"));
ProcessBuilder probe = new ProcessBuilder(zsh.toString(), "-i", "-c",
"[[ -n \"$" + OPERATOR_KEEP + "\" ]] && print -r -- kept || print -r -- missing; "
+ "[[ -z \"$" + UNLISTED_CREDENTIAL
+ "\" ]] && print -r -- blanked || print -r -- leaked");
probe.environment().clear();
probe.environment().putAll(Map.of(
"HOME", home.toString(),
"PATH", "/usr/bin:/bin",
"SHELL", zsh.toString(),
"ZDOTDIR", zdotdir.toString(),
OPERATOR_KEEP, "needed-by-member-binary",
UNLISTED_CREDENTIAL, "must-not-survive"));
probe.redirectError(ProcessBuilder.Redirect.DISCARD);
Process process = probe.start();
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
assertTrue(process.waitFor(60, java.util.concurrent.TimeUnit.SECONDS),
"the zsh scrub probe did not exit within 60 seconds");
assertEquals(0, process.exitValue(), "the zsh scrub probe must exit cleanly");
assertEquals("kept\nblanked\n", output,
"memberCredentials.allow must add a keep, while an unlisted credential stays blanked");
} finally {
launcher.stop(spawned.id());
}
}
/** The default policy must not generate anything — an upgrade changes nothing until asked. */
@Test
void spawningUnderTheDefaultPolicyGeneratesNoZdotdir() {
@@ -108,127 +149,118 @@ class HerdrPeerLauncherAllowListWiringTest {
"bash ignores ZDOTDIR; setting it would be protection theatre");
}
private static Supplier<FleetConfig.MemberCredentials> allowList() {
return () -> new FleetConfig.MemberCredentials(
FleetConfig.MemberCredentials.POLICY_ALLOW_LIST, List.of(), List.of(), null);
/**
* Defect fix: under {@code policy: allow-list} with a non-zsh login shell, nothing is
* scrubbed — the overlay-only fallback is the whole protection. The gap must still be
* reported, and with the deny-by-default WARN wording ("inherited UNBLOCKED"), never the
* allow-list INFO wording ("will be blanked by the scrub"), because nothing is scrubbed here.
*/
@Test
void nonZshAllowListFallbackStillReportsTheGapAsAWarn() {
FakeHerdr herdr = new FakeHerdr();
WiringLauncher launcher = new WiringLauncher(herdr, allowList(), "/bin/bash",
() -> Set.of("PATH", "HOME", UNLISTED_CREDENTIAL));
Logger logger = (Logger) LoggerFactory.getLogger(HerdrPeerLauncher.class);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
try {
launcher.spawn(new SpawnRequest("test", null, null, null, null, MemberRole.DEV));
} finally {
logger.detachAppender(appender);
}
ILoggingEvent gap = appender.list.stream()
.filter(e -> e.getFormattedMessage().contains(UNLISTED_CREDENTIAL))
.findFirst().orElseThrow(() -> new AssertionError(
"the non-zsh allow-list fallback must still report the credential gap"));
assertEquals(ch.qos.logback.classic.Level.WARN, gap.getLevel(),
"nothing is scrubbed on this path, so the report must use the WARN wording, not "
+ "the allow-list INFO wording");
assertTrue(gap.getFormattedMessage().contains("UNBLOCKED"),
"the fallback path scrubs nothing, so the wording must say so");
assertFalse(gap.getFormattedMessage().contains("will be blanked by the scrub"),
"nothing is scrubbed on this path — that wording would be false here");
}
/** Same as {@link #allowList()} but with an operator-configured {@code allow:} list. */
private static Supplier<FleetConfig.MemberCredentials> allowListWithAllow(List<String> allow) {
/**
* The live operator config has {@code SSH_AUTH_SOCK} on BOTH {@code allow:} AND
* {@code sshAuthSock: block}. Defect fix: {@code sshAuthSock: block} must win — the allow:
* entry must not silently widen the derived allow-list to include it.
*/
@Test
void sshAuthSockBlockWinsOverAnAllowListEntry(@TempDir Path home) throws Exception {
Path zsh = Path.of("/bin/zsh");
assumeTrue(Files.isExecutable(zsh), "/bin/zsh not present");
FakeHerdr herdr = new FakeHerdr();
Supplier<FleetConfig.MemberCredentials> live = () -> new FleetConfig.MemberCredentials(
FleetConfig.MemberCredentials.POLICY_ALLOW_LIST,
List.of("AI_GATEWAY_TOKEN", "WORKER_GITEA_TOKEN", "CONTEXT7_TOKEN", "GITEA_HOST",
"OPENCODE_AUTOMODE_MODEL", "SSH_AUTH_SOCK", "CLAUDE_CODE_MESSAGING_TOKEN"),
List.of(), "block");
WiringLauncher launcher = new WiringLauncher(herdr, live);
var spawned = launcher.spawn(new SpawnRequest("test", null, null, null, null, MemberRole.DEV));
try {
Path zdotdir = Path.of(launcher.env.get("ZDOTDIR"));
ProcessBuilder probe = new ProcessBuilder(zsh.toString(), "-i", "-c",
"[[ -n \"$SSH_AUTH_SOCK\" ]] && print -r -- LEAKED || print -r -- blanked");
probe.environment().clear();
probe.environment().putAll(Map.of(
"HOME", home.toString(), "PATH", "/usr/bin:/bin", "SHELL", zsh.toString(),
"ZDOTDIR", zdotdir.toString(), "SSH_AUTH_SOCK", "/tmp/agent.sock"));
probe.redirectError(ProcessBuilder.Redirect.DISCARD);
Process pr = probe.start();
String out = new String(pr.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
pr.waitFor(60, java.util.concurrent.TimeUnit.SECONDS);
assertEquals("blanked\n", out, "sshAuthSock: block must win over allow:");
} finally {
launcher.stop(spawned.id());
}
}
/**
* Mirror of {@link #sshAuthSockBlockWinsOverAnAllowListEntry}: with {@code sshAuthSock: allow}
* (no conflict with {@code allow:}), {@code SSH_AUTH_SOCK} IS kept. Without this case, a fix
* that simply deletes {@code SSH_AUTH_SOCK} everywhere would also pass the block test.
*/
@Test
void sshAuthSockAllowKeepsTheVariable(@TempDir Path home) throws Exception {
Path zsh = Path.of("/bin/zsh");
assumeTrue(Files.isExecutable(zsh), "/bin/zsh not present");
FakeHerdr herdr = new FakeHerdr();
Supplier<FleetConfig.MemberCredentials> creds = () -> new FleetConfig.MemberCredentials(
FleetConfig.MemberCredentials.POLICY_ALLOW_LIST,
List.of("SSH_AUTH_SOCK"), List.of(), "allow");
WiringLauncher launcher = new WiringLauncher(herdr, creds);
var spawned = launcher.spawn(new SpawnRequest("test", null, null, null, null, MemberRole.DEV));
try {
Path zdotdir = Path.of(launcher.env.get("ZDOTDIR"));
ProcessBuilder probe = new ProcessBuilder(zsh.toString(), "-i", "-c",
"[[ -n \"$SSH_AUTH_SOCK\" ]] && print -r -- kept || print -r -- blanked");
probe.environment().clear();
probe.environment().putAll(Map.of(
"HOME", home.toString(), "PATH", "/usr/bin:/bin", "SHELL", zsh.toString(),
"ZDOTDIR", zdotdir.toString(), "SSH_AUTH_SOCK", "/tmp/agent.sock"));
probe.redirectError(ProcessBuilder.Redirect.DISCARD);
Process pr = probe.start();
String out = new String(pr.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
pr.waitFor(60, java.util.concurrent.TimeUnit.SECONDS);
assertEquals("kept\n", out, "sshAuthSock: allow must keep SSH_AUTH_SOCK");
} finally {
launcher.stop(spawned.id());
}
}
private static Supplier<FleetConfig.MemberCredentials> allowList() {
return allowList(List.of());
}
private static Supplier<FleetConfig.MemberCredentials> allowList(List<String> allow) {
return () -> new FleetConfig.MemberCredentials(
FleetConfig.MemberCredentials.POLICY_ALLOW_LIST, allow, List.of(), null);
}
/**
* CB-633 follow-up: a name that lives ONLY in {@code memberCredentials.allow:} — no profile
* mentions it — must survive the scrub the real spawn path generates. Calling {@code
* MemberEnvAllowList.derive} directly (as {@link MemberEnvAllowListTest} does) would pass even
* if {@code HerdrPeerLauncher} never threaded {@code allow:} into the derivation at all; this
* test goes through {@link HerdrPeerLauncher#spawn}, the method the daemon actually calls at
* spawn time, so it proves the union is wired in, not just correct in isolation.
*/
@Test
void spawningUnderAllowListPolicyIncludesAnOperatorConfiguredAllowName() {
FakeHerdr herdr = new FakeHerdr();
WiringLauncher launcher = new WiringLauncher(herdr,
allowListWithAllow(List.of("OPERATOR_ONLY_NAME")));
launcher.spawn(new SpawnRequest("test", null, null, null, null, MemberRole.DEV));
Path dir = Path.of(launcher.env.get("ZDOTDIR"));
assertTrue(readAll(dir.resolve(EnvAllowListScrub.SCRUB_FILE)).contains("OPERATOR_ONLY_NAME"),
"a name only in memberCredentials.allow: must reach the generated scrub through the "
+ "real launcher spawn path");
}
/**
* {@code SSH_AUTH_SOCK} is a live ssh-agent handle, not a value — it must stay blocked under
* {@code allow-list} even when the operator lists it under {@code allow:}, because {@code
* sshAuthSock} defaults to blocked. Governed ONLY by {@code memberCredentials.sshAuthSock}.
*/
@Test
void sshAuthSockStaysBlockedEvenWhenListedInMemberCredentialsAllow() {
FakeHerdr herdr = new FakeHerdr();
WiringLauncher launcher = new WiringLauncher(herdr,
allowListWithAllow(List.of("SSH_AUTH_SOCK")));
launcher.spawn(new SpawnRequest("test", null, null, null, null, MemberRole.DEV));
Path dir = Path.of(launcher.env.get("ZDOTDIR"));
String scrub = readAll(dir.resolve(EnvAllowListScrub.SCRUB_FILE));
assertFalse(scrub.contains("'SSH_AUTH_SOCK'"),
"SSH_AUTH_SOCK must not be on the derived allow-list just because the operator put "
+ "it under allow: — sshAuthSock is unset here, so it defaults to block");
}
/**
* CB-633 follow-up criterion 3: on every allow-list spawn the daemon logs one INFO line, shaped
* "member credentials: allowed N of M", with real counts — not constants. Real path: the count
* is asserted after a real {@link HerdrPeerLauncher#spawn} call, reading the log the production
* code actually emits.
*/
@Test
void logsAnAllowedCountLineAgainstTheHostEnvironmentOnEverySpawn() {
FakeHerdr herdr = new FakeHerdr();
// INJECTED is a key of this launch's own env map, so it always survives; the other two are
// neither derived from the profile nor configured anywhere, so they are blanked. Real
// N=1 (INJECTED), real M=3 (all three names) — neither number is hardcoded in the assertion
// by coincidence, they follow directly from this fixture.
Set<String> hostEnvNames = Set.of(INJECTED, "SOME_UNRELATED_NAME", "ANOTHER_UNRELATED_NAME");
WiringLauncher launcher = new WiringLauncher(herdr, allowList(), "/bin/zsh", () -> hostEnvNames);
Logger logger = (Logger) LoggerFactory.getLogger(HerdrPeerLauncher.class);
Level original = logger.getLevel();
logger.setLevel(Level.INFO);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
try {
launcher.spawn(new SpawnRequest("test", null, null, null, null, MemberRole.DEV));
} finally {
logger.detachAppender(appender);
logger.setLevel(original);
}
assertTrue(appender.list.stream()
.anyMatch(e -> "member credentials: allowed 1 of 3".equals(e.getFormattedMessage())),
"expected 'member credentials: allowed 1 of 3', got: "
+ appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
}
/**
* Lead-review fix: on a NON-zsh shell no scrub ever runs (bash ignores {@code ZDOTDIR}), so the
* "allowed N of M" line — which describes what the scrub does — must not be printed there either.
* Before this fix the line was logged BEFORE the zsh gate, so a non-zsh host printed e.g.
* "allowed 1 of 3" while blocking nothing at all, telling an operator a control ran when it did
* not. Real path: goes through {@link HerdrPeerLauncher#spawn}, same as the sibling test above,
* with the shell fixed to bash so the fallback branch is the one exercised.
*/
@Test
void noAllowedCountLineIsEmittedOnTheNonZshFallbackPath() {
FakeHerdr herdr = new FakeHerdr();
Set<String> hostEnvNames = Set.of(INJECTED, "SOME_UNRELATED_NAME", "ANOTHER_UNRELATED_NAME");
WiringLauncher launcher = new WiringLauncher(herdr, allowList(), "/bin/bash", () -> hostEnvNames);
Logger logger = (Logger) LoggerFactory.getLogger(HerdrPeerLauncher.class);
Level original = logger.getLevel();
logger.setLevel(Level.INFO);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
try {
launcher.spawn(new SpawnRequest("test", null, null, null, null, MemberRole.DEV));
} finally {
logger.detachAppender(appender);
logger.setLevel(original);
}
assertFalse(appender.list.stream()
.anyMatch(e -> e.getFormattedMessage().startsWith("member credentials: allowed ")),
"no scrub runs on a non-zsh shell, so no 'allowed N of M' count may be printed — got: "
+ appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
}
private static String readAll(Path p) {
try {
return Files.readString(p);
@@ -258,9 +290,8 @@ class HerdrPeerLauncherAllowListWiringTest {
this(herdr, creds, shell, null);
}
/** Plus an injectable {@code hostEnvNames} source, for the "allowed N of M" log line test. */
WiringLauncher(FakeHerdr herdr, Supplier<FleetConfig.MemberCredentials> creds, String shell,
Supplier<Set<String>> hostEnvNames) {
Supplier<Set<String>> hostEnvNames) {
super("test", new AgentControl(herdr), new WorkspaceControl(herdr),
Map.of("test", profile()), "test",
name -> "SHELL".equals(name) ? shell : null,
@@ -88,39 +88,6 @@ class MemberEnvAllowListTest {
assertTrue(after.containsAll(Set.of("TOKEN_SECOND", "GIT_TOK", "SECOND_KEY")));
}
/**
* CB-633 follow-up: a name that appears ONLY in {@code memberCredentials.allow:} — no profile
* mentions it at all — must still survive the derivation. Before this fix {@code derive} never
* saw {@code allow:}, so setting {@code policy: allow-list} silently blanked exactly this name.
*/
@Test
void aNameOnlyInMemberCredentialsAllowSurvivesDerivation() {
FleetConfig.Profile p = profile("p", "TOKEN_A", null, null, Map.of("KEY_A", "v"));
Set<String> derived = MemberEnvAllowList.derive(List.of(p), Set.of("OPERATOR_ONLY_NAME"));
assertTrue(derived.contains("OPERATOR_ONLY_NAME"),
"memberCredentials.allow: must be unioned in, not ignored");
// and the profile-derived half must still be present — this is a union, not a replacement.
assertTrue(derived.contains("KEY_A"));
assertTrue(derived.contains("TOKEN_A"));
}
/**
* {@code SSH_AUTH_SOCK} is a live handle to the operator's ssh-agent, never a value — so it must
* stay excluded from the derived set even when the operator lists it under {@code allow:} for an
* unrelated reason. It is governed ONLY by {@code memberCredentials.sshAuthSock}, applied
* separately by the caller ({@code HerdrPeerLauncher}).
*/
@Test
void sshAuthSockInMemberCredentialsAllowIsStillExcluded() {
Set<String> derived = MemberEnvAllowList.derive(List.of(), Set.of("SSH_AUTH_SOCK", "OTHER_NAME"));
assertFalse(derived.contains("SSH_AUTH_SOCK"),
"SSH_AUTH_SOCK must never ride in on the generic allow: list");
assertTrue(derived.contains("OTHER_NAME"), "other allow: names are unaffected");
}
/** {@code LC_*} categories are infrastructure by prefix; everything else needs an exact match. */
@Test
void keepsMatchesExactlyPlusTheLocalePrefixRule() {
@@ -1,149 +0,0 @@
package dev.ltms.fleet.msg;
import com.rabbitmq.client.AMQP;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.DeliverCallback;
import com.rabbitmq.client.Delivery;
import com.rabbitmq.client.Envelope;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/**
* Pins the manual-ack prefetch behaviour without a broker. The fake channel models a broker that
* sends no more than its QoS window of unacked deliveries. If {@link AmqpReplyInbox} starts acking
* messages while it adds them to {@code held}, this test drains the whole fake queue instead.
*/
class AmqpReplyInboxPrefetchTest {
@Test
void unackedDeliveriesKeepTheHeldBacklogAtThePrefetchWindow() {
int prefetch = 3;
int published = 8;
PrefetchBroker broker = new PrefetchBroker();
for (int i = 0; i < published; i++) {
broker.publish("m" + i, "payload " + i);
}
try (AmqpReplyInbox inbox = new AmqpReplyInbox(connectionFor(broker.channel()), prefetch)) {
inbox.own("worker");
assertEquals(prefetch, inbox.peek("worker").size(),
"held messages must stop at the unacked prefetch window");
assertEquals(published - prefetch, broker.queuedCount(),
"messages beyond the window must remain on the broker");
assertEquals(0, broker.ackCount(), "receipt must not ack a held message");
inbox.ack("worker", "m0");
assertEquals(prefetch, inbox.peek("worker").size(),
"one caller ack frees exactly one slot for the broker");
assertEquals(published - prefetch - 1, broker.queuedCount(),
"only one queued message may enter after one caller ack");
assertEquals(1, broker.ackCount(), "only the caller ack may reach the broker");
}
}
private static Connection connectionFor(Channel consumeChannel) {
Channel publishChannel = (Channel) Proxy.newProxyInstance(
AmqpReplyInboxPrefetchTest.class.getClassLoader(), new Class<?>[] {Channel.class},
(proxy, method, args) -> defaultValue(method.getReturnType()));
AtomicInteger channelCalls = new AtomicInteger();
InvocationHandler handler = (proxy, method, args) -> {
if (method.getName().equals("createChannel") && (args == null || args.length == 0)) {
return channelCalls.getAndIncrement() == 0 ? consumeChannel : publishChannel;
}
return defaultValue(method.getReturnType());
};
return (Connection) Proxy.newProxyInstance(AmqpReplyInboxPrefetchTest.class.getClassLoader(),
new Class<?>[] {Connection.class}, handler);
}
private static final class PrefetchBroker implements InvocationHandler {
private final ArrayDeque<Delivery> queued = new ArrayDeque<>();
private final Map<Long, Delivery> unacked = new LinkedHashMap<>();
private DeliverCallback consumer;
private int prefetch;
private int acks;
private long nextTag = 1;
Channel channel() {
return (Channel) Proxy.newProxyInstance(AmqpReplyInboxPrefetchTest.class.getClassLoader(),
new Class<?>[] {Channel.class}, this);
}
void publish(String msgId, String content) {
queued.add(new Delivery(new Envelope(nextTag++, false, "", ""),
new AMQP.BasicProperties.Builder().messageId(msgId).build(),
content.getBytes(StandardCharsets.UTF_8)));
}
int queuedCount() {
return queued.size();
}
int ackCount() {
return acks;
}
@Override
public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) throws IOException {
switch (method.getName()) {
case "basicQos" -> {
prefetch = (int) args[0];
return null;
}
case "basicConsume" -> {
assertFalse((boolean) args[1], "the inbox consumer must use manual acknowledgements");
consumer = (DeliverCallback) args[2];
deliverAvailable();
return "consumer";
}
case "basicAck" -> {
unacked.remove((long) args[0]);
acks++;
deliverAvailable();
return null;
}
default -> {
return defaultValue(method.getReturnType());
}
}
}
private void deliverAvailable() throws IOException {
while (consumer != null && unacked.size() < prefetch && !queued.isEmpty()) {
Delivery delivery = queued.removeFirst();
unacked.put(delivery.getEnvelope().getDeliveryTag(), delivery);
consumer.handle("consumer", delivery);
}
}
}
private static Object defaultValue(Class<?> type) {
if (!type.isPrimitive() || type == void.class) {
return null;
}
if (type == boolean.class) {
return false;
}
if (type == long.class) {
return 0L;
}
if (type == int.class) {
return 0;
}
return 0;
}
}
@@ -33,17 +33,8 @@ class MessageServiceTest {
private final FakeHerdr herdr = new FakeHerdr().readText("BUILD GREEN: 391 files");
private final AgentControl agents = new AgentControl(herdr);
private final Rendezvous rendezvous = new Rendezvous();
/**
* fleetd#164: this fixture drives a delivery and its completion back-to-back with no real time
* between them, so the real clock would trip {@link CompletionResolver#MIN_TURN_NANOS} on every
* completion-fallback test here. An ever-advancing fake clock stands in for the model-latency and
* herdr round-trips a real turn would spend, so each delivery-then-resolve pair still lands
* outside the floor.
*/
private final java.util.concurrent.atomic.AtomicLong resolverClock = new java.util.concurrent.atomic.AtomicLong();
private final CompletionResolver completion =
new CompletionResolver(agents, rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none(),
() -> resolverClock.addAndGet(CompletionResolver.MIN_TURN_NANOS + 1));
new CompletionResolver(agents, rendezvous, ExhaustedPatternLookup.none(), ExhaustionSink.none());
private final Injector injector = new Injector(agents, completion);
private final InMemoryReplyInbox inbox = new InMemoryReplyInbox();
private final MessageService messages = new MessageService(agents, injector, rendezvous, inbox);
@@ -32,7 +32,6 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.Predicate;
import static org.junit.jupiter.api.Assertions.*;
@@ -65,11 +64,6 @@ class FleetAppTest {
}
private int start(FakeHerdr herdr, String workerBaseUrl, Set<String> allow, String placement, Worktrees worktrees) {
return start(herdr, workerBaseUrl, allow, placement, worktrees, ignored -> false);
}
private int start(FakeHerdr herdr, String workerBaseUrl, Set<String> allow, String placement,
Worktrees worktrees, Predicate<String> deliverable) {
FleetConfig.Profile wcfg = new FleetConfig.Profile(
"ltms-local", workerBaseUrl, "coder", null, "FLEETD_WORKER_TOKEN", null,
placement, "fleet", "worker: {profile} #{n}", null, null, null);
@@ -90,8 +84,7 @@ class FleetAppTest {
// it directly so the inbox contract holds for those endpoints.
inbox.own("term_a");
MessageService messages = new MessageService(agents, injector, rendezvous, inbox);
app = new FleetApp(herdr, workers, sessions, messages, this.presence, null,
null, null, id -> this.presence.isPresent(id) || deliverable.test(id))
app = new FleetApp(herdr, workers, sessions, messages, this.presence, null)
.build().start("127.0.0.1", 0);
return app.port();
}
@@ -491,16 +484,6 @@ class FleetAppTest {
assertTrue(mapper.readTree(req(port, "GET", "/sessions/term_a/status").body()).get("ready").asBoolean());
}
@Test
void sessionStatusReportsRegisteredLeadAsReady() throws Exception {
Map<String, String> leads = Map.of("term_lead", "terra");
int port = start(new FakeHerdr(), "http://gx00.gw:8000", Set.of("gx00.gw"), "tab",
new GitWorktrees(), leads::containsKey);
JsonNode body = mapper.readTree(req(port, "GET", "/sessions/term_lead/status").body());
assertTrue(body.get("ready").asBoolean());
}
/**
* CB-582: a lead polling {@code GET /sessions/{id}/status} on its normal cadence — not the
* ticket-scoped {@code /tasks/{ticket}} — must also see a worker's open async {@code fleet_ask}
@@ -55,21 +55,12 @@ class GitWorktreesTest {
}
private static void git(Path cwd, String... args) throws Exception {
gitOutput(cwd, args);
}
private static String gitOutput(Path cwd, String... args) throws Exception {
List<String> cmd = new java.util.ArrayList<>(List.of("git"));
cmd.addAll(List.of(args));
ProcessBuilder pb = new ProcessBuilder(cmd).directory(cwd.toFile()).redirectErrorStream(true);
pb.environment().put("GIT_CONFIG_GLOBAL", "/dev/null");
pb.environment().put("GIT_CONFIG_SYSTEM", "/dev/null");
pb.environment().put("GIT_TERMINAL_PROMPT", "0");
Process p = pb.start();
Process p = new ProcessBuilder(cmd).directory(cwd.toFile()).redirectErrorStream(true).start();
String out = new String(p.getInputStream().readAllBytes());
assertTrue(p.waitFor(30, TimeUnit.SECONDS), "git timed out: " + String.join(" ", cmd));
assertEquals(0, p.exitValue(), "git " + String.join(" ", args) + " failed:\n" + out);
return out;
}
/** Pending changes to {@code file} in {@code cwd}, empty when git considers it unmodified. */
@@ -214,144 +205,6 @@ class GitWorktreesTest {
"expected an explicitly empty server map, got:\n" + body);
}
/**
* The worktree command shares the primary checkout's config, so this checks the URL git actually
* reads after {@link GitWorktrees#add}, rather than checking only a URL formatting helper.
*/
@Test
void aProvisionedWorktreeUsesACleanHttpsOrigin(@TempDir Path tmp) throws Exception {
Path repo = initRepo(tmp.resolve("repo"));
git(repo, "remote", "add", "origin", "https://synthetic-test-token@git.ltms.dev/akb/kb.git");
String wt = new GitWorktrees(tmp.resolve("wts").toString())
.add(repo.toString(), "fleetd-157-safe-origin", "HEAD");
Path worktree = Path.of(wt);
String origin = gitOutput(worktree, "config", "--get", "remote.origin.url").trim();
assertEquals("https://git.ltms.dev/akb/kb.git", origin);
assertFalse(origin.contains("synthetic-test-token"), "provisioned worktree kept user info");
assertFalse(gitOutput(worktree, "remote", "-v").contains("synthetic-test-token"),
"git remote -v exposed user info");
assertFalse(gitOutput(worktree, "config", "--list").contains("synthetic-test-token"),
"git config --list exposed user info");
String helper = gitOutput(worktree, "config", "--worktree", "--get", "credential.helper");
assertTrue(helper.contains("WORKER_GITEA_TOKEN"), "credential helper does not read the member environment");
assertFalse(helper.contains("synthetic-test-token"), "credential helper stored user info");
}
@Test
void worktreeCredentialHelperCompletesWithoutUsingAnInheritedHelper(@TempDir Path tmp) throws Exception {
Path repo = initRepo(tmp.resolve("repo"));
git(repo, "remote", "add", "origin", "https://git.ltms.dev/akb/kb.git");
String wt = new GitWorktrees(tmp.resolve("wts").toString())
.add(repo.toString(), "fleetd-157-helper", "HEAD");
Path globalConfig = tmp.resolve("global.gitconfig");
Files.writeString(globalConfig, """
[credential]
helper = !f() { printf 'username=%s\\npassword=%s\\n\\n' operator operator-secret; }; f
""");
ProcessBuilder pb = new ProcessBuilder("git", "credential", "fill")
.directory(Path.of(wt).toFile()).redirectErrorStream(true);
pb.environment().put("GIT_CONFIG_GLOBAL", globalConfig.toString());
pb.environment().put("GIT_CONFIG_SYSTEM", "/dev/null");
pb.environment().put("GIT_TERMINAL_PROMPT", "0");
pb.environment().put("WORKER_GITEA_TOKEN", "synthetic-worker-value");
Process p = pb.start();
p.getOutputStream().write("protocol=https\nhost=git.ltms.dev\n\n".getBytes(StandardCharsets.UTF_8));
p.getOutputStream().close();
String credential = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
assertTrue(p.waitFor(30, TimeUnit.SECONDS), "git credential fill timed out");
assertEquals(0, p.exitValue(), "git credential fill failed");
assertTrue(credential.contains("username=git"), "helper did not return its fixed username");
assertTrue(credential.contains("password=synthetic-worker-value"),
"helper did not return the worker token as the password");
assertFalse(credential.contains("operator-secret"), "Git used the inherited global helper");
}
/**
* fleetd #157 follow-up. An SSH origin never consults {@code credential.helper} — the environment
* credential helper set by {@link GitWorktrees#add} is therefore useless when a member's origin is
* SSH, which is exactly this repo's shape. The worktree must instead get a worktree-scoped
* {@code url.<https>.insteadOf <ssh>} rewrite so both fetch and push resolve to HTTPS, while the
* parent checkout — sharing the same repo-level origin config — must resolve the original SSH URL
* completely unchanged. The host/port here (a synthetic {@code forge.example.test:2222}, not
* {@code git.ltms.dev}) proves the rewrite is derived from the origin, not a hardcoded constant.
*/
@Test
void aProvisionedWorktreeRewritesAnSshOriginToHttpsWorktreeScoped(@TempDir Path tmp) throws Exception {
Path repo = initRepo(tmp.resolve("repo"));
git(repo, "remote", "add", "origin", "ssh://git@forge.example.test:2222/acme/proj.git");
String wt = new GitWorktrees(tmp.resolve("wts").toString())
.add(repo.toString(), "fleetd-157-ssh-rewrite", "HEAD");
Path worktree = Path.of(wt);
assertEquals("https://forge.example.test/acme/proj.git",
gitOutput(worktree, "remote", "get-url", "origin").trim(),
"worktree fetch URL was not rewritten to HTTPS");
assertEquals("https://forge.example.test/acme/proj.git",
gitOutput(worktree, "remote", "get-url", "--push", "origin").trim(),
"worktree push URL was not rewritten to HTTPS");
// The raw config value is unchanged — only the resolved URL is rewritten, via insteadOf.
assertEquals("ssh://git@forge.example.test:2222/acme/proj.git",
gitOutput(worktree, "config", "--get", "remote.origin.url").trim());
assertEquals("ssh://git@forge.example.test:2222/acme/proj.git",
gitOutput(repo, "remote", "get-url", "origin").trim(),
"the parent checkout's fetch URL must be untouched");
assertEquals("ssh://git@forge.example.test:2222/acme/proj.git",
gitOutput(repo, "remote", "get-url", "--push", "origin").trim(),
"the parent checkout's push URL must be untouched");
}
/** An origin already on HTTPS is left alone — the environment credential helper already covers it. */
@Test
void aProvisionedWorktreeLeavesAnHttpsOriginAlone(@TempDir Path tmp) throws Exception {
Path repo = initRepo(tmp.resolve("repo"));
git(repo, "remote", "add", "origin", "https://git.ltms.dev/akb/kb.git");
String wt = new GitWorktrees(tmp.resolve("wts").toString())
.add(repo.toString(), "fleetd-157-https-noop", "HEAD");
Path worktree = Path.of(wt);
assertEquals("https://git.ltms.dev/akb/kb.git",
gitOutput(worktree, "remote", "get-url", "origin").trim());
assertEquals(1, exitCode("git", "-C", wt, "config", "--worktree", "--get-regexp", "^url\\."),
"no url.*.insteadOf rewrite should be added for an already-HTTPS origin");
}
/** Test-local exit-code probe, mirroring {@link GitWorktrees#exitCode} for an assertion the
* production class does not expose. */
private static int exitCode(String... command) throws Exception {
ProcessBuilder pb = new ProcessBuilder(command).redirectErrorStream(true);
pb.environment().put("GIT_CONFIG_GLOBAL", "/dev/null");
pb.environment().put("GIT_CONFIG_SYSTEM", "/dev/null");
pb.environment().put("GIT_TERMINAL_PROMPT", "0");
Process p = pb.start();
p.getInputStream().readAllBytes();
assertTrue(p.waitFor(30, TimeUnit.SECONDS), "command timed out: " + String.join(" ", command));
return p.exitValue();
}
@Test
void provisioningRefusesAWorktreeWhoseOriginStillHasHttpsUserInfo(@TempDir Path tmp) throws Exception {
Path repo = initRepo(tmp.resolve("repo"));
git(repo, "remote", "add", "origin", "https://git.ltms.dev/akb/kb.git");
GitWorktrees worktrees = new GitWorktrees(tmp.resolve("wts").toString(), worktreePath -> {
try {
git(Path.of(worktreePath), "remote", "set-url", "origin",
"https://synthetic-test-token@git.ltms.dev/akb/kb.git");
} catch (Exception e) {
throw new RuntimeException(e);
}
});
WorktreeException error = assertThrows(WorktreeException.class,
() -> worktrees.add(repo.toString(), "fleetd-157-refuse-origin", "HEAD"));
assertEquals("worktree origin contains HTTPS user info; refusing provision", error.getMessage());
}
/** Neutralizing must not look like work in progress, or a worker would commit it into its PR. */
@Test
void theNeutralizedConfigIsNotAPendingLocalModification(@TempDir Path tmp) throws Exception {