diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java index 2685848..6bd8153 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java +++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java @@ -144,23 +144,16 @@ public final class Fleetd { SubscriptionGuard guard = new SubscriptionGuard(cfg.guard().hostSet()); guard.assertPrimaryClean(System.getenv()); - // CB-501: refuse to start if the bind is wider than the auth mode can defend. Under - // loopback-trust, "not a known worker" means "the primary" — sound only because the OS - // refuses remote connections to a loopback socket. This throws rather than warns so the - // dangerous configuration cannot be reached by ignoring a log line. - cfg.validateAuthExposure(); - cfg.validateLeadTabPrefixes(); - // CB-542: a subscription:true profile whose env: reseats ANTHROPIC_BASE_URL/AUTH_TOKEN would - // reach an unguarded endpoint (the launcher skips SubscriptionGuard for it). Refuse at load. - cfg.validateSubscriptionProfiles(); - cfg.validateCharters(); - // CB-548: every architect slot must name a configured workers: profile — the strong-model - // backend the future spawn lifecycle would read. A stale reference dies here, not later. - cfg.validateMembers(); - // fleetd ticket "central allow-list of usable models": an absent/empty models.allow: keeps - // today's behaviour (no model was ever checked); once configured, a profile naming a model - // outside it dies here, at load, rather than reaching a backend adapter as a free-form string. - cfg.validateModels(); + // 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 + // and member-slot checks, and the "central allow-list of usable models" check — must run + // here, at load, before anything below opens a socket or spawns a member. fleetd ticket + // "central allow-list of usable models" follow-up: mutation testing found six individual + // calls here with nothing proving any of them still ran (deleting one left the full suite + // green). validateAll() replaces them with the one call that FleetConfigValidateAllTest + // and the Fleetd-startup tests actually pin — see FleetConfig#validateAll's javadoc for + // why a name-by-name list here would have the same defect it replaces. + cfg.validateAll(); Path socket = cfg.herdrSocket() != null && !cfg.herdrSocket().isBlank() ? Path.of(cfg.herdrSocket()) diff --git a/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java b/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java index fbf2f37..5ee4dcd 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java +++ b/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java @@ -44,7 +44,8 @@ import java.util.function.Supplier; * {@code spawnReadyTimeoutMs} / {@code spawnReadyPollMs}, {@code quarantineCooldownSeconds} * (CB-578 stage B — baked once into the {@code BackendQuarantine} built at startup), * {@code models:} (fleetd ticket "central allow-list of usable models" — {@link - * FleetConfig#validateModels()} re-runs against the fresh config in {@link #reload()}, so a + * FleetConfig#validateModels()} re-runs against the fresh config in {@link #reload()} + * (via {@link FleetConfig#validateAll()}), so a * models.allow: edit that would refuse to boot still refuses the reload; a change that * passes has nothing built at startup to rebuild, so it is reported deferred rather than * silently accepted with no report at all), @@ -328,16 +329,12 @@ public final class ConfigRef implements Supplier { fresh = FleetConfig.load(path); // The same gate startup runs. A config that would have refused to boot must not be able // to slip in through a reload — that is how a daemon ends up in a state it could never - // have started in, which is the hardest kind to debug. - fresh.validateAuthExposure(); - fresh.validateLeadTabPrefixes(); - fresh.validateSubscriptionProfiles(); - fresh.validateCharters(); - fresh.validateMembers(); - // fleetd ticket "central allow-list of usable models": same reason as validateMembers - // above — a models.allow: that would have refused to boot must not slip in through a - // reload either. - fresh.validateModels(); + // have started in, which is the hardest kind to debug. fleetd ticket "central allow-list + // of usable models" follow-up: this used to be six individual validateXxx() calls, and + // mutation testing found two of the six unpinned here even though startup pinned nothing + // at all — see FleetConfig#validateAll's javadoc for why the fix is one reflective call, + // not a longer hand-maintained list. + fresh.validateAll(); } catch (RuntimeException e) { String msg = e.getMessage() == null ? e.toString() : e.getMessage(); log.warn("config reload from {} refused, keeping the running config: {}", path, msg); @@ -450,8 +447,9 @@ public final class ConfigRef implements Supplier { changed.add("idleSleepGuard"); } // fleetd ticket "central allow-list of usable models": validateModels() runs again in - // reload() above, so a bad edit is already refused as cold-adjacent (the whole reload is - // refused via the catch block, never partially applied). A GOOD edit to the allow-list + // reload() above (via validateAll()), so a bad edit is already refused as cold-adjacent + // (the whole reload is refused via the catch block, never partially applied). A GOOD edit + // to the allow-list // itself has nothing built at startup to rebuild — it only ever mattered to the validation // call that already ran — so report it deferred rather than silently swallowing the change. if (!Objects.equals(old.models(), fresh.models())) { diff --git a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java index 5cc70f0..9ec3452 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java +++ b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java @@ -16,11 +16,15 @@ import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.UncheckedIOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.Comparator; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; @@ -2609,6 +2613,79 @@ public record FleetConfig( } } + /** + * Runs every validator this class declares — found by reflection, not by name. + * + *

fleetd ticket "central allow-list of usable models", follow-up: mutation testing found + * that although each of the six validators above was well pinned on its own, nothing proved + * either real caller ({@code Fleetd.main} and {@link ConfigRef#reload()}) still + * invoked it — deleting a call site left the full suite green. The fix is not a seventh test + * per caller; a hand-maintained list of six names here would have the exact same defect its + * own javadoc would warn against: the seventh validator someone adds next month has no reason + * to be added to it. So this method does not name any validator. It sweeps {@link + * #getClass()}'s own public, no-argument, {@code void} methods whose name starts with {@code + * "validate"} (excluding itself) and invokes every one it finds, via {@link + * #invokeAllValidators}. A new {@code validateXxx()} method is therefore wired into both + * callers the moment it is written — there is no second step to forget, and so no state in + * which it silently never runs. + * + *

{@code Fleetd.main} and {@link ConfigRef#reload()} each call this one method instead of + * the six individually — see the comments at those two call sites for why + * each must run it. + * + *

Methods run in a fixed (alphabetical) order, so a config with more than one violation + * always names the same one first, on every run. + * + * @throws IllegalStateException (or whatever unchecked exception a validator itself throws), + * propagated unchanged from the first validator, in that order, + * that finds a problem + */ + public void validateAll() { + invokeAllValidators(this); + } + + /** + * The reflective sweep behind {@link #validateAll()}, kept as its own method — taking any + * {@code target}, not just {@code this} — so a test can prove the MECHANISM is generic (it + * would sweep a seventh {@code validateXxx()} method added to any class, not just something + * special-cased to today's six on {@link FleetConfig}) without needing to add a real, unwanted + * seventh validator to this class just to exercise that claim. See {@code + * FleetConfigValidateAllTest} for that proof. + * + * @param target an object whose public, no-argument, {@code void} methods named {@code + * validateXxx} (any name starting with {@code "validate"}, excluding {@code + * validateAll} itself) should all run, in alphabetical-by-name order + */ + static void invokeAllValidators(Object target) { + List methods = new ArrayList<>(); + for (Method m : target.getClass().getMethods()) { + if (Modifier.isPublic(m.getModifiers()) + && m.getParameterCount() == 0 + && m.getReturnType() == void.class + && m.getName().startsWith("validate") + && !m.getName().equals("validateAll")) { + methods.add(m); + } + } + methods.sort(Comparator.comparing(Method::getName)); + for (Method m : methods) { + try { + m.invoke(target); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException re) { + throw re; + } + if (cause instanceof Error err) { + throw err; + } + throw new IllegalStateException("validator " + m.getName() + " failed", cause); + } catch (IllegalAccessException e) { + throw new IllegalStateException("cannot invoke validator " + m.getName(), e); + } + } + } + /** True for the loopback addresses and the unspecified-but-local forms we treat as same-host. */ private static boolean isLoopbackBind(String host) { if (host == null || host.isBlank()) { diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdStartupValidationTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdStartupValidationTest.java new file mode 100644 index 0000000..6eac36c --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdStartupValidationTest.java @@ -0,0 +1,132 @@ +package dev.ltms.fleet; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Proves the one thing no test proved before this ticket's follow-up: that {@code Fleetd.main} + * ITSELF — not a copy of its logic, not the validator called directly — still refuses to start on + * a bad config. Mutation testing found that deleting {@code cfg.validateAll();} (née six + * individual {@code cfg.validateXxx();} calls) from {@code Fleetd.main} left the full 1478-test + * suite green; every existing test called a validator directly and none exercised {@code + * Fleetd.main} as the caller. See {@code FleetConfigValidateAllTest} for why the fix collapses + * those six calls into one reflective {@link FleetConfig#validateAll()}, and {@code + * ConfigRefTest} for the equivalent proof on the {@link + * dev.ltms.fleet.config.ConfigRef#reload()} path. + * + *

This is deliberately the real {@code static void main(String[] args)} — package-private, so + * only a test in this package can call it, which is exactly what makes this proof strong: it is + * not a helper extracted for testability, it is the literal method {@code java -jar fleetd.jar} + * invokes. Every fixture below is otherwise-valid and fails exactly one validator, and — because + * {@link FleetConfig#validateAll()} runs immediately after {@code SubscriptionGuard. + * assertPrimaryClean}, before {@code main} opens the herdr socket, binds Javalin, or touches + * anything else with a real side effect — calling {@code Fleetd.main} with one of these configs is + * safe: it is guaranteed to throw before reaching any of that, precisely because the config is + * deliberately invalid. + */ +class FleetdStartupValidationTest { + + private static void assertMainRefuses(Path dir, String fileName, String yaml, + String mustContain) throws Exception { + Path f = dir.resolve(fileName); + Files.writeString(f, yaml); + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> Fleetd.main(new String[]{f.toString()}), + fileName + ": Fleetd.main must refuse this config before doing anything else"); + assertTrue(e.getMessage().contains(mustContain), + fileName + ": expected message to contain \"" + mustContain + "\" but was: " + + e.getMessage()); + } + + @Test + void mainRefusesANonLoopbackBindWithoutTokenMode(@TempDir Path dir) throws Exception { + assertMainRefuses(dir, "auth-exposure.yaml", """ + bind: + host: 0.0.0.0 + port: 8765 + """, "auth.mode: token"); + } + + @Test + void mainRefusesALeadTabPrefixCollision(@TempDir Path dir) throws Exception { + assertMainRefuses(dir, "lead-tab-prefixes.yaml", """ + bind: + host: 127.0.0.1 + port: 8765 + fleet: + tabLabel: "lead: {role} {profile}" + leaders: + opus: + tab: "lead: opus" + """, "fleet.tabLabel"); + } + + @Test + void mainRefusesASubscriptionProfileThatReseatsAnthropicBaseUrl(@TempDir Path dir) + throws Exception { + assertMainRefuses(dir, "subscription-profiles.yaml", """ + bind: + host: 127.0.0.1 + port: 8765 + profiles: + sonnet: + subscription: true + argv: ["ccs", "sonnet"] + env: + ANTHROPIC_BASE_URL: http://anything-not-on-the-allowlist + """, "ANTHROPIC_BASE_URL"); + } + + @Test + void mainRefusesAnUnknownCharterKey(@TempDir Path dir) throws Exception { + assertMainRefuses(dir, "charters.yaml", """ + bind: + host: 127.0.0.1 + port: 8765 + fleet: + charters: + architetc: text + """, "architetc"); + } + + @Test + void mainRefusesAnArchitectSlotNamingAnUnconfiguredProfile(@TempDir Path dir) throws Exception { + assertMainRefuses(dir, "members.yaml", """ + bind: + host: 127.0.0.1 + port: 8765 + profiles: + gx10: + baseUrl: http://gx10.gw:8000 + fleet: + architects: + lead-designer: + profile: sonnet + """, "lead-designer"); + } + + @Test + void mainRefusesAProfileNamingAModelOutsideTheAllowList(@TempDir Path dir) throws Exception { + assertMainRefuses(dir, "models.yaml", """ + bind: + host: 127.0.0.1 + port: 8765 + profiles: + sonnet: + baseUrl: http://gx10.gw:8000 + model: claude-sonnet-5 + rogue: + baseUrl: http://gx11.gw:8000 + model: claude-opus-9000 + models: + allow: + - model: claude-sonnet-5 + """, "rogue"); + } +} diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigValidateAllTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigValidateAllTest.java new file mode 100644 index 0000000..0d68c92 --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigValidateAllTest.java @@ -0,0 +1,348 @@ +package dev.ltms.fleet.config; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The gap this class exists to close: mutation testing on the fleetd ticket "central allow-list + * of usable models" found that although {@link FleetConfig#validateModels()}'s own logic was well + * pinned, nothing proved either real caller ({@code Fleetd.main} and {@link ConfigRef#reload()}) + * still invoked it — deleting the call site left the full suite green (1478/0/0/0). A follow-up + * measurement (same technique — remove one call site, run the suite, not read the code) found the + * SAME gap for all five of {@link FleetConfig}'s other validators at startup, and for four of the + * six inside {@link ConfigRef#reload()}. This is a class of gap, not one line's mistake: every one + * of those thirteen tests called the validator itself directly, never the real caller that was + * supposed to. + * + *

The fix replaces the six individual {@code cfg.validateXxx()} calls at each of the two real + * call sites with one {@link FleetConfig#validateAll()}, which reaches every validator by + * reflection rather than by a hand-maintained list of names. A hand-maintained list of six names + * would have exactly the defect it replaces: the seventh validator someone adds next month has no + * reason to be added to it, and nothing would say so. This class proves TWO separate claims, and + * keeps them separate on purpose: + * + *

    + *
  1. {@link #theSweepMechanismIsGenericNotHardcodedToFleetConfigsSixNames()} and its neighbours + * prove the reflective sweep itself ({@link FleetConfig#invokeAllValidators}) is a general + * mechanism — it runs whatever public, no-arg, void {@code validateXxx()} methods a class + * happens to declare today, including a class with more of them than {@link FleetConfig} + * has right now. This is the proof that a future, real seventh validator on {@link + * FleetConfig} would be swept automatically, without needing to add a real (unwanted) + * seventh validator just to exercise the claim.
  2. + *
  3. {@link #validateAllReachesEveryOneOfTodaysSixValidators()} proves {@link + * FleetConfig#validateAll()} itself is wired to that same generic mechanism and genuinely + * reaches each of today's six real validators — reusing the exact minimal failing + * configurations {@code FleetConfigTest} already established for each one directly, so a + * single call to {@code validateAll()} is shown to reproduce every one of those six + * failures.
  4. + *
+ * + *

Together with the direct-{@code Fleetd.main}-invocation tests in {@code + * FleetdStartupValidationTest} (which prove the real startup call site still calls {@code + * validateAll()}) and the {@code ConfigRefTest} reload tests (which prove the same for {@link + * ConfigRef#reload()}), removing {@code cfg.validateAll();} from either real call site, or + * reverting {@link FleetConfig#validateAll()} to a hardcoded list of method calls, now fails a + * test in this module. + */ +class FleetConfigValidateAllTest { + + // ── Claim 1: the reflective sweep is a general mechanism, not six names in disguise ────────── + + /** + * A throwaway fixture class, unrelated to {@link FleetConfig} in every way except shape: three + * public, no-arg, void methods named {@code validateXxx}. Proves the sweep works on ANY class + * with this shape, not on something special-cased to {@link FleetConfig}. + */ + static class ThreeValidators { + final List ran = new ArrayList<>(); + + public void validateAlpha() { + ran.add("validateAlpha"); + } + + public void validateBeta() { + ran.add("validateBeta"); + } + + public void validateGamma() { + ran.add("validateGamma"); + } + } + + @Test + void theSweepMechanismIsGenericNotHardcodedToFleetConfigsSixNames() { + ThreeValidators target = new ThreeValidators(); + FleetConfig.invokeAllValidators(target); + assertEquals(List.of("validateAlpha", "validateBeta", "validateGamma"), target.ran, + "every validateXxx() method on this unrelated class must run, in alphabetical " + + "order — the sweep reads the class's own shape, not a name FleetConfig " + + "happens to know about"); + } + + /** + * The core of the "self-maintaining" requirement: the exact same class shape as {@link + * ThreeValidators}, plus one more method — standing in for "a developer adds a validator next + * month". Nothing about the sweep changes to pick it up; the new method is invoked purely + * because it exists and matches the shape. This is what makes adding a seventh real validator + * to {@link FleetConfig} safe without touching {@link FleetConfig#validateAll()} or either + * call site — there is no "wire it in" step left to forget. + */ + static class FourValidators { + final List ran = new ArrayList<>(); + + public void validateAlpha() { + ran.add("validateAlpha"); + } + + public void validateBeta() { + ran.add("validateBeta"); + } + + public void validateGamma() { + ran.add("validateGamma"); + } + + public void validateDelta() { + ran.add("validateDelta"); + } + } + + @Test + void addingAFourthValidatorMethodGetsSweptWithNoOtherChange() { + FourValidators target = new FourValidators(); + FleetConfig.invokeAllValidators(target); + assertEquals(List.of("validateAlpha", "validateBeta", "validateDelta", "validateGamma"), + sorted(target.ran), + "the fourth method must be reached automatically — proving a class can grow the " + + "set of things it validates with no change to the sweep itself"); + } + + private static List sorted(List in) { + List copy = new ArrayList<>(in); + copy.sort(String::compareTo); + return copy; + } + + @Test + void aFailingValidatorStopsTheSweepAndPropagatesUnchanged() { + class OneFails { + @SuppressWarnings("unused") + public void validateOk() { + // passes + } + + public void validateBoom() { + throw new IllegalStateException("refusing to start: boom"); + } + } + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> FleetConfig.invokeAllValidators(new OneFails())); + assertEquals("refusing to start: boom", e.getMessage(), + "the real exception must propagate unchanged, not be wrapped or swallowed"); + } + + /** + * Every rule the sweep's filter applies, proven independently: only public, no-arg, void + * methods whose name starts with {@code "validate"} run, {@code validateAll} itself is + * excluded (so a class that declares one of its own — as {@link FleetConfig} does — cannot + * recurse into itself), and a same-shaped-but-wrongly-named or wrongly-shaped method never + * runs. A reader who "simplifies" the filter in {@link FleetConfig#invokeAllValidators} in a + * way that widens or narrows it breaks one of these. + */ + static class FilterEdgeCases { + final List ran = new ArrayList<>(); + + public void validateReal() { + ran.add("validateReal"); + } + + /** Wrong name — must not run. */ + public void checkSomething() { + ran.add("checkSomething"); + } + + /** Wrong shape — takes an argument. */ + public void validateWithArg(String ignored) { + ran.add("validateWithArg"); + } + + /** Wrong shape — returns something. */ + public boolean validateReturnsBoolean() { + ran.add("validateReturnsBoolean"); + return true; + } + + /** Excluded by name on purpose, so the sweep cannot call itself. */ + public void validateAll() { + ran.add("validateAll"); + } + } + + @Test + void onlyPublicNoArgVoidValidateNamedMethodsRun() { + FilterEdgeCases target = new FilterEdgeCases(); + FleetConfig.invokeAllValidators(target); + assertEquals(List.of("validateReal"), target.ran, + "checkSomething (wrong name), validateWithArg (wrong shape), " + + "validateReturnsBoolean (wrong shape), and validateAll (excluded by " + + "name) must all be skipped"); + } + + // ── Claim 2: FleetConfig.validateAll() is wired to that mechanism and reaches all six today ── + + /** + * Reflectively enumerates {@link FleetConfig}'s own public, no-arg, void {@code validateXxx()} + * methods (excluding {@code validateAll} itself) — the exact same filter {@link + * FleetConfig#invokeAllValidators} applies. This is not the mechanism proof (that is claim 1, + * above, on an unrelated class) — it is a visible denominator: today there are six, named + * here, so a reader adding a seventh sees this assertion name the new count rather than a + * silent pass at the old one. + */ + @Test + void fleetConfigDeclaresExactlyTheseSixValidatorsToday() { + Set names = new TreeSet<>(); + for (Method m : FleetConfig.class.getMethods()) { + if (java.lang.reflect.Modifier.isPublic(m.getModifiers()) + && m.getParameterCount() == 0 + && m.getReturnType() == void.class + && m.getName().startsWith("validate") + && !m.getName().equals("validateAll")) { + names.add(m.getName()); + } + } + assertEquals(new TreeSet<>(Set.of("validateAuthExposure", "validateLeadTabPrefixes", + "validateSubscriptionProfiles", "validateCharters", "validateMembers", + "validateModels")), names, + "FleetConfig's public validate*() methods changed — this is not a failure by " + + "itself (validateAll() sweeps whatever is here, by construction), it is " + + "this test's own denominator; update the expected set to match"); + } + + /** A minimal, otherwise-valid file — same shape FleetConfigTest and ConfigRefTest use. */ + private static String minimalValidYaml() { + return """ + bind: + host: 127.0.0.1 + port: 8765 + profiles: + sonnet: + baseUrl: http://gx00.gw:8000 + model: sonnet + """; + } + + @Test + void aFullyValidConfigPassesValidateAll(@TempDir Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, minimalValidYaml()); + assertDoesNotThrow(() -> FleetConfig.load(f).validateAll()); + } + + /** + * The heart of claim 2: for each of today's six real validators, a minimal file that fails + * ONLY that one — the exact fixtures {@code FleetConfigTest} uses to test each validator + * directly — must also fail through {@link FleetConfig#validateAll()}. If a future edit to + * {@code validateAll()} silently dropped one validator from the sweep (e.g. a typo'd name + * filter), exactly one of these six would start passing when it must not. + */ + @Test + void validateAllReachesEveryOneOfTodaysSixValidators(@TempDir Path dir) throws Exception { + // validateAuthExposure: a non-loopback bind without token mode. + assertValidateAllRefuses(dir, "auth-exposure.yaml", """ + bind: + host: 0.0.0.0 + port: 8765 + """, "auth.mode: token"); + + // validateLeadTabPrefixes: a fleet-wide tabLabel that starts with a lead's own tabPrefix. + assertValidateAllRefuses(dir, "lead-tab-prefixes.yaml", """ + bind: + host: 127.0.0.1 + port: 8765 + fleet: + tabLabel: "lead: {role} {profile}" + leaders: + opus: + tab: "lead: opus" + """, "fleet.tabLabel"); + + // validateSubscriptionProfiles: subscription: true with env: reseating ANTHROPIC_BASE_URL. + assertValidateAllRefuses(dir, "subscription-profiles.yaml", """ + bind: + host: 127.0.0.1 + port: 8765 + profiles: + sonnet: + subscription: true + argv: ["ccs", "sonnet"] + env: + ANTHROPIC_BASE_URL: http://anything-not-on-the-allowlist + """, "ANTHROPIC_BASE_URL"); + + // validateCharters: a charter key that is not a role wire name. + assertValidateAllRefuses(dir, "charters.yaml", """ + bind: + host: 127.0.0.1 + port: 8765 + fleet: + charters: + architetc: text + """, "architetc"); + + // validateMembers: an architect slot naming an unconfigured profile. + assertValidateAllRefuses(dir, "members.yaml", """ + bind: + host: 127.0.0.1 + port: 8765 + profiles: + gx10: + baseUrl: http://gx10.gw:8000 + fleet: + architects: + lead-designer: + profile: sonnet + """, "lead-designer"); + + // validateModels: a profile naming a model outside the configured allow-list. + assertValidateAllRefuses(dir, "models.yaml", """ + bind: + host: 127.0.0.1 + port: 8765 + profiles: + sonnet: + baseUrl: http://gx10.gw:8000 + model: claude-sonnet-5 + rogue: + baseUrl: http://gx11.gw:8000 + model: claude-opus-9000 + models: + allow: + - model: claude-sonnet-5 + """, "rogue"); + } + + private static void assertValidateAllRefuses(Path dir, String fileName, String yaml, + String mustContain) throws Exception { + Path f = dir.resolve(fileName); + Files.writeString(f, yaml); + FleetConfig cfg = FleetConfig.load(f); + IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateAll, + fileName + ": validateAll() must refuse this config"); + assertTrue(e.getMessage().contains(mustContain), + fileName + ": expected message to contain \"" + mustContain + "\" but was: " + + e.getMessage()); + } +}