diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java index 37fa27e..712894d 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java +++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java @@ -131,6 +131,27 @@ public final class Fleetd { } static void main(String[] args) { + main(args, ResourcePorts.system()); + } + + /** + * fleetd #625: package-private so a test can drive the literal startup sequence — not a copy + * of it — against a non-production {@link ResourcePorts} whose {@link + * ResourcePorts#environment()} is tainted, without ever touching the real process environment. + * The real process environment is exactly what a test cannot taint from inside the JVM, which + * is why nothing could pin {@link SubscriptionGuard#assertPrimaryClean} running at this call + * site before this ticket. + * + *

{@link #main(String[])} is the one production caller, passing {@link + * ResourcePorts#system()}. Every statement below, in the same order, is otherwise unchanged + * from before this ticket — in particular the guard still runs here, before {@code + * cfg.validateAll()} and before {@link FleetdAssembly#assembleAndStart} ever touches a socket, + * a broker, or HTTP, exactly as it always has. {@code ports} is reused for the guard check and + * then handed on to the assembly, rather than a second instance being constructed there, so a + * test's fake backs the whole boot path with one consistent view — see {@code + * FleetdSubscriptionGuardOrderingTest}. + */ + static void main(String[] args, ResourcePorts ports) { Path configPath = args.length > 0 ? Path.of(args[0]) : chooseDefaultConfigFile(Path.of("")); // The config file was renamed bridged.yaml -> fleetd.yaml. Name the file we actually // loaded, whichever of the two names it carries. @@ -166,7 +187,7 @@ public final class Fleetd { // The primary/host env that launched fleetd must not be tainted. SubscriptionGuard guard = new SubscriptionGuard(cfg.guard().hostSet()); - guard.assertPrimaryClean(System.getenv()); + guard.assertPrimaryClean(ports.environment()); // Every FleetConfig.validateXxx() the operator's config can fail — CB-501's auth-exposure // check, CB-531's lead-tab-prefix check, CB-542's subscription-profile check, the charter @@ -189,7 +210,9 @@ public final class Fleetd { // after validateAll() (this line) puts both back under test, in the same relative order, // before either one does any I/O — see FleetdAssembly's javadoc for the full boot-order // contract this preserves exactly. - FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg, config, guard), ResourcePorts.system()); + // fleetd #625: the same `ports` the guard check above just used, not a second + // ResourcePorts.system() instance — see this method's own javadoc. + FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg, config, guard), ports); } /** diff --git a/fleetd/src/main/java/dev/ltms/fleet/FleetdAssembly.java b/fleetd/src/main/java/dev/ltms/fleet/FleetdAssembly.java index c51c3b4..52df9ca 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/FleetdAssembly.java +++ b/fleetd/src/main/java/dev/ltms/fleet/FleetdAssembly.java @@ -194,7 +194,7 @@ final class FleetdAssembly { // CB-504: under supervision (launchd/systemd) fleetd can start before herdr's socket // exists. Wait, then degrade rather than die: serving with /healthz reporting "degraded" is // strictly more useful than exiting. - Fleetd.HerdrAwaitOutcome herdrOutcome = Fleetd.awaitHerdr(herdr, ports.nanoClock(), Fleetd::sleepHerdrPoll); + Fleetd.HerdrAwaitOutcome herdrOutcome = Fleetd.awaitHerdr(herdr, ports.nanoClock(), ports.herdrPollWait()); boolean herdrUp = Fleetd.logHerdrWaitOutcomeAndShouldReap(herdrOutcome); if (herdrUp) { // CB-117: herdr keeps worker panes alive across a daemon restart, and their ids died diff --git a/fleetd/src/main/java/dev/ltms/fleet/ResourcePorts.java b/fleetd/src/main/java/dev/ltms/fleet/ResourcePorts.java index bec9d42..5fd594a 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/ResourcePorts.java +++ b/fleetd/src/main/java/dev/ltms/fleet/ResourcePorts.java @@ -43,6 +43,19 @@ public interface ResourcePorts { /** A monotonic elapsed-time clock. Production: {@link System#nanoTime()}. */ LongSupplier nanoClock(); + /** + * fleetd #629: the per-poll wait {@code FleetdAssembly#assembleAndStart} passes to {@code + * Fleetd#awaitHerdr} while polling for herdr's socket. Production: {@link + * Fleetd#sleepHerdrPoll()} — a real {@code Thread.sleep}. {@link #nanoClock()} alone is not + * enough to make {@code awaitHerdr}'s deadline controllable: the old call site passed {@code + * Fleetd::sleepHerdrPoll} directly, hardcoded, so a test that injected a fake clock still had + * to wait out the real sleep between each poll to ever reach the deadline — the clock looked + * injected and was not actually controllable. A test supplies a no-op that advances its own + * injected {@link #nanoClock()} instead, so the deadline becomes reachable without any real + * wall-clock time passing. + */ + Runnable herdrPollWait(); + /** * A wall-clock reading, in nanoseconds. Production: {@code System.currentTimeMillis()} * converted to nanoseconds. Kept separate from {@link #nanoClock()} because {@link diff --git a/fleetd/src/main/java/dev/ltms/fleet/SystemResourcePorts.java b/fleetd/src/main/java/dev/ltms/fleet/SystemResourcePorts.java index 7ce5ddd..ed02a34 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/SystemResourcePorts.java +++ b/fleetd/src/main/java/dev/ltms/fleet/SystemResourcePorts.java @@ -44,6 +44,11 @@ final class SystemResourcePorts implements ResourcePorts { return System::nanoTime; } + @Override + public Runnable herdrPollWait() { + return Fleetd::sleepHerdrPoll; + } + @Override public LongSupplier wallClockNanos() { return () -> TimeUnit.MILLISECONDS.toNanos(System.currentTimeMillis()); diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyConnectionIdentityTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyConnectionIdentityTest.java index df5afdf..67d41f5 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyConnectionIdentityTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyConnectionIdentityTest.java @@ -117,6 +117,14 @@ class FleetdAssemblyConnectionIdentityTest { public void startHttp(Javalin app, String host, int port) { // Deliberately never bind — this test never issues a real HTTP request. } + + @Override + public Runnable herdrPollWait() { + // Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls. + return () -> { + throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy"); + }; + } } private FleetdRuntime runtime; diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyCoordinatorLifecycleTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyCoordinatorLifecycleTest.java index d5262b0..627a431 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyCoordinatorLifecycleTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyCoordinatorLifecycleTest.java @@ -140,6 +140,14 @@ class FleetdAssemblyCoordinatorLifecycleTest { public void startHttp(Javalin app, String host, int port) { // No real HTTP bind in a unit test. } + + @Override + public Runnable herdrPollWait() { + // Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls. + return () -> { + throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy"); + }; + } } private static final class SentinelReplyInbox implements ReplyInbox { diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyFleetAppTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyFleetAppTest.java index ba2b8c3..36ca40d 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyFleetAppTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyFleetAppTest.java @@ -21,6 +21,8 @@ import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.LongSupplier; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -76,6 +78,10 @@ class FleetdAssemblyFleetAppTest { final Map herdrsBySocket = new LinkedHashMap<>(); final CopyOnWriteArrayList schedulers = new CopyOnWriteArrayList<>(); + // fleetd #629: a fake, advanceable clock — NOT System::nanoTime. awaitHerdr's poll wait + // (herdrPollWait() below) advances this on every poll instead of sleeping for real, so the + // down-lead test below reaches awaitHerdr's deadline without burning real wall-clock time. + final AtomicLong nowNanos = new AtomicLong(1_000_000_000L); // arbitrary non-zero start Runnable shutdownHook; @Override @@ -107,7 +113,7 @@ class FleetdAssemblyFleetAppTest { @Override public LongSupplier nanoClock() { - return System::nanoTime; + return nowNanos::get; } @Override @@ -115,6 +121,13 @@ class FleetdAssemblyFleetAppTest { return System::nanoTime; } + @Override + public Runnable herdrPollWait() { + // fleetd #629: advance the fake clock instead of a real Thread.sleep, so awaitHerdr's + // deadline is reached in real time regardless of the configured poll interval. + return () -> nowNanos.addAndGet(TimeUnit.SECONDS.toNanos(1)); + } + @Override public ScheduledExecutorService newScheduler(String purpose) { ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyLifecycleTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyLifecycleTest.java index 8f8f488..e4c32e2 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyLifecycleTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyLifecycleTest.java @@ -126,6 +126,14 @@ class FleetdAssemblyLifecycleTest { ledger.add("startHttp"); this.startedApp = app; } + + @Override + public Runnable herdrPollWait() { + // Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls. + return () -> { + throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy"); + }; + } } /** A fake {@link ReplyInbox} that is also {@link AutoCloseable}, so the ledger can prove it closes. */ diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyRoleFallbackBoundaryTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyRoleFallbackBoundaryTest.java index b4d03c4..daf45ca 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyRoleFallbackBoundaryTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyRoleFallbackBoundaryTest.java @@ -98,6 +98,14 @@ class FleetdAssemblyRoleFallbackBoundaryTest { public void startHttp(Javalin app, String host, int port) { // No real HTTP bind in a unit test. } + + @Override + public Runnable herdrPollWait() { + // Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls. + return () -> { + throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy"); + }; + } } private static final class SentinelReplyInbox implements ReplyInbox { diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdBackendQuarantineAssemblyTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdBackendQuarantineAssemblyTest.java index 2852739..c7d90c1 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/FleetdBackendQuarantineAssemblyTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdBackendQuarantineAssemblyTest.java @@ -100,6 +100,14 @@ class FleetdBackendQuarantineAssemblyTest { @Override public void startHttp(Javalin app, String host, int port) { } + + @Override + public Runnable herdrPollWait() { + // Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls. + return () -> { + throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy"); + }; + } } private static final class SentinelReplyInbox implements ReplyInbox, AutoCloseable { diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdCompletionResolverAssemblyTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdCompletionResolverAssemblyTest.java index 664af23..df3091a 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/FleetdCompletionResolverAssemblyTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdCompletionResolverAssemblyTest.java @@ -126,6 +126,14 @@ class FleetdCompletionResolverAssemblyTest { public void startHttp(Javalin app, String host, int port) { // Deliberately never bind a real port. } + + @Override + public Runnable herdrPollWait() { + // Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls. + return () -> { + throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy"); + }; + } } private static FleetConfig writeConfig(Path dir, String profilesYaml, String extraGuardHost, diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadRolloverAssemblyTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadRolloverAssemblyTest.java index d5d0e25..70f13d5 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadRolloverAssemblyTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadRolloverAssemblyTest.java @@ -115,6 +115,14 @@ class FleetdLeadRolloverAssemblyTest { @Override public void startHttp(Javalin app, String host, int port) { } + + @Override + public Runnable herdrPollWait() { + // Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls. + return () -> { + throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy"); + }; + } } private static final class SentinelReplyInbox implements ReplyInbox, AutoCloseable { diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatAssemblyTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatAssemblyTest.java index aff5704..6457a37 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatAssemblyTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatAssemblyTest.java @@ -90,6 +90,14 @@ class FleetdLeadSeatAssemblyTest { @Override public void startHttp(Javalin app, String host, int port) { } + + @Override + public Runnable herdrPollWait() { + // Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls. + return () -> { + throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy"); + }; + } } private static final class SentinelReplyInbox implements ReplyInbox, AutoCloseable { diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdSubscriptionGuardOrderingTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdSubscriptionGuardOrderingTest.java new file mode 100644 index 0000000..2bf3003 --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdSubscriptionGuardOrderingTest.java @@ -0,0 +1,202 @@ +package dev.ltms.fleet; + +import dev.ltms.fleet.guard.GuardException; +import dev.ltms.fleet.herdr.HerdrClient; +import io.javalin.Javalin; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.LongSupplier; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * fleetd #625: pins {@link dev.ltms.fleet.guard.SubscriptionGuard#assertPrimaryClean}'s call site + * in {@link Fleetd#main(String[])} — the ONE place it runs at startup, and the check behind the + * bridge charter's invariant 1 (never let the primary carry {@code ANTHROPIC_BASE_URL}). Nothing + * pinned it before this ticket: deleting {@code guard.assertPrimaryClean(...)} from {@code main} + * left the full suite green, because the call site read the real process environment ({@code + * System.getenv()}), which a test cannot taint from inside the JVM. + * + *

{@link Fleetd#main(String[], ResourcePorts)} (added by this ticket) is the literal production + * sequence — not a copy of it — driven here with a {@link ResourcePorts} whose {@link + * ResourcePorts#environment()} is a plain {@code Map} a test controls. The guard itself was + * already pinned by {@code SubscriptionGuardTest}, directly, with a {@code Map} — that proves the + * method's behaviour, not that {@code main} still calls it at the right point. This class pins the + * call site and, separately, the ORDER: the guard must still run before {@code cfg.validateAll()} + * and before {@code FleetdAssembly.assembleAndStart} touches a socket, a broker, or HTTP — not just + * be present somewhere in {@code main}. + * + *

Presence alone is not enough (a fix that pins only presence trades an invisible deletion for + * an invisible reordering), so each test below is built so that EITHER deleting the guard call OR + * moving it later makes the same test fail — with a different exception type than the one + * asserted, not a vacuous pass. See each test's own javadoc for how. + */ +class FleetdSubscriptionGuardOrderingTest { + + private static final Map TAINTED_ENV = + Map.of("ANTHROPIC_BASE_URL", "http://tainted.example"); + private static final Map CLEAN_ENV = Map.of("PATH", "/usr/bin"); + + /** + * A {@link ResourcePorts} whose {@link #environment()} is fixed to whatever the test hands it, + * and whose every other method refuses to be called at all. That refusal is the ordering pin: + * if {@code main} ever reaches {@link FleetdAssembly#assembleAndStart} before the guard has had + * a chance to throw, the very first thing the assembly does with {@code ports} is {@link + * #connectHerdr} — so a test that expects {@link GuardException} and instead observes {@link + * UnsupportedOperationException} has just caught the guard running too late (or not at all). + */ + private static final class FixedEnvPorts implements ResourcePorts { + private final Map env; + + FixedEnvPorts(Map env) { + this.env = env; + } + + @Override + public Map environment() { + return env; + } + + @Override + public HerdrClient connectHerdr(Path socketPath) { + throw new UnsupportedOperationException("connectHerdr must not be called before the guard runs"); + } + + @Override + public Fleetd.AmqpOpener replyInboxOpener() { + throw new UnsupportedOperationException("replyInboxOpener must not be called before the guard runs"); + } + + @Override + public Fleetd.LeadMailboxOpener leadMailboxOpener() { + throw new UnsupportedOperationException("leadMailboxOpener must not be called before the guard runs"); + } + + @Override + public LongSupplier nanoClock() { + throw new UnsupportedOperationException("nanoClock must not be called before the guard runs"); + } + + @Override + public LongSupplier wallClockNanos() { + throw new UnsupportedOperationException("wallClockNanos must not be called before the guard runs"); + } + + @Override + public ScheduledExecutorService newScheduler(String purpose) { + throw new UnsupportedOperationException("newScheduler must not be called before the guard runs"); + } + + @Override + public void addShutdownHook(Runnable hook) { + throw new UnsupportedOperationException("addShutdownHook must not be called before the guard runs"); + } + + @Override + public void startHttp(Javalin app, String host, int port) { + throw new UnsupportedOperationException("startHttp must not be called before the guard runs"); + } + + @Override + public Runnable herdrPollWait() { + throw new UnsupportedOperationException("herdrPollWait must not be called before the guard runs"); + } + } + + /** + * Otherwise-invalid: {@code bind.host: 0.0.0.0} with no {@code auth.mode: token} fails {@code + * cfg.validateAll()} (CB-501's auth-exposure check — the same fixture {@code + * FleetdStartupValidationTest#mainRefusesANonLoopbackBindWithoutTokenMode} uses), with an + * {@link IllegalStateException}. That is deliberate: it is what {@code main} would throw INSTEAD + * of {@link GuardException} if the guard call were deleted, or moved to run after {@code + * validateAll()} — a different, distinguishable exception type. + */ + private static Path invalidConfig(Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, """ + bind: + host: 0.0.0.0 + port: 8765 + """); + return f; + } + + /** Passes {@code cfg.validateAll()} cleanly — nothing here trips any of its checks. */ + private static Path validConfig(Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, """ + bind: + host: 127.0.0.1 + port: 8765 + """); + return f; + } + + /** + * Pins the order against {@code cfg.validateAll()}. The environment is tainted and the config + * is otherwise invalid (see {@link #invalidConfig}). If the guard runs first (the required + * order), {@code main} throws {@link GuardException} before {@code validateAll()} is ever + * reached. If the guard were deleted, or reordered to run after {@code validateAll()}, {@code + * validateAll()} throws {@link IllegalStateException} instead and this assertion fails on the + * wrong exception type. + */ + @Test + void mainRefusesATaintedEnvironmentBeforeValidatingTheConfig(@TempDir Path dir) throws Exception { + Path config = invalidConfig(dir); + FixedEnvPorts ports = new FixedEnvPorts(TAINTED_ENV); + + GuardException ex = assertThrows(GuardException.class, + () -> Fleetd.main(new String[]{config.toString()}, ports)); + assertTrue(ex.getMessage().contains("tainted"), + "expected the primary-taint message, got: " + ex.getMessage()); + } + + /** + * Pins the order against {@code FleetdAssembly.assembleAndStart}. The environment is tainted + * and the config is otherwise VALID (see {@link #validConfig}), so {@code cfg.validateAll()} + * passes silently and the next thing that could possibly run is the assembly's first socket + * call. If the guard runs first (the required order), {@code main} throws {@link + * GuardException} before assembly starts. If the guard were deleted, or reordered to run after + * assembly begins touching {@code ports}, {@link FixedEnvPorts#connectHerdr} throws {@link + * UnsupportedOperationException} instead and this assertion fails on the wrong exception type. + */ + @Test + void mainRefusesATaintedEnvironmentBeforeAssemblyTouchesAnyPort(@TempDir Path dir) throws Exception { + Path config = validConfig(dir); + FixedEnvPorts ports = new FixedEnvPorts(TAINTED_ENV); + + GuardException ex = assertThrows(GuardException.class, + () -> Fleetd.main(new String[]{config.toString()}, ports)); + assertTrue(ex.getMessage().contains("tainted"), + "expected the primary-taint message, got: " + ex.getMessage()); + } + + /** + * The CONTROL for the two tests above. Same otherwise-valid config, same {@link FixedEnvPorts} + * whose every method but {@code environment()} refuses to be called — but a CLEAN environment. + * Without this, a guard that always threw {@link GuardException} regardless of input (the + * opposite bug — e.g. the check inverted) would make the two tests above pass for the wrong + * reason: not because they actually drove a real taint through a real guard, but because + * anything would have thrown {@code GuardException}. Here, with nothing to taint, the guard + * must let {@code main} proceed into {@code cfg.validateAll()} and on into the real assembly, + * which reaches {@code ports.connectHerdr} — and THAT throws. A loud, positive assertion: if + * the boot path never actually ran this far, there is no {@link UnsupportedOperationException} + * to catch, only a quiet, unexpected hang or an unrelated early failure. + */ + @Test + void mainProceedsPastTheGuardOnACleanEnvironment(@TempDir Path dir) throws Exception { + Path config = validConfig(dir); + FixedEnvPorts ports = new FixedEnvPorts(CLEAN_ENV); + + UnsupportedOperationException ex = assertThrows(UnsupportedOperationException.class, + () -> Fleetd.main(new String[]{config.toString()}, ports)); + assertTrue(ex.getMessage().contains("connectHerdr"), + "expected forward progress to reach the assembly's first port call, got: " + ex.getMessage()); + } +}