diff --git a/fleetd/fleetd.example.yaml b/fleetd/fleetd.example.yaml index 5f6fc07..0a3d369 100644 --- a/fleetd/fleetd.example.yaml +++ b/fleetd/fleetd.example.yaml @@ -877,3 +877,32 @@ guard: # terminal: term_65619bd6174568 # pushReminders: 5 # pushBackoffMs: 15000 + +# Central allow-list of models any profiles: entry may name. Nothing checked a profile's model: +# value before this block existed — it was a free-form string handed straight to the backend +# adapter, and a withdrawn or misspelled name failed silently instead of at config load (opencode +# falls back to a default model rather than erroring on an unknown -m). +# +# Absent, or present with an empty allow:, is OFF: no profile's model: is checked, exactly like +# before this block existed. fleetd.yaml is gitignored on every host, so an upgrade must not force +# every operator to enumerate their models before the daemon will start. +# +# The list is the authority; profiles: is checked against it, never the reverse — adding or +# editing a profiles: entry cannot, by itself, widen what is permitted here. +# +# Enforcement is at CONFIG LOAD only (a bad model: fails the daemon at startup, naming both the +# model and the profile). There is no spawn-time enforcement, no runtime on/off switch, and no +# interaction with BackendQuarantine — those are separate, later units. +# +# allow → the permitted models. Each entry is its own block (not a bare string) so a later unit +# can add an on/off state or a load limit per model without changing this shape. +# model → the model id exactly as a profiles: entry's model: field would write it. One flat, +# opaque-string namespace: a bare Claude id (claude-sonnet-5) and an opencode +# provider-prefixed id (openai/gpt-5.6-terra) both fit here unchanged — the check is a +# plain string match, never a parse of the provider prefix or a branch on kind:. +# models: +# allow: +# - model: claude-sonnet-5 +# - model: claude-opus-5 +# - model: openai/gpt-5.6-terra +# - model: amazon.nova-pro-v1:0 diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java index cc9191d..ed194a5 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java +++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java @@ -148,19 +148,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(); + // 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 aa5072a..5ee4dcd 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java +++ b/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java @@ -43,6 +43,12 @@ import java.util.function.Supplier; * running daemon keeps whatever this was at startup regardless of a later edit), * {@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()} + * (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), * {@code guard:}, {@code worktreeRoot:}, {@code worktreeGroup:} and {@code memberSkills:} * (all three of the latter baked once into the {@code GitWorktrees} built at * {@code Fleetd.java:251} and never rebuilt — fleetd #323 instance 2 found @@ -135,8 +141,9 @@ import java.util.function.Supplier; * * *

The denominator, measured on 2026-09-04 (fleetd #330; recounted for fleetd #333); - * recounted again for fleetd #362, and again after {@code idleSleepGuard:} was added. - * {@code FleetConfig} has 24 top-level record components: 5 cold, 13 deferred, 3 split, 3 + * recounted again for fleetd #362, again after {@code idleSleepGuard:} was added, and again after + * {@code models:} was added. + * {@code FleetConfig} has 25 top-level record components: 5 cold, 14 deferred, 3 split, 3 * hot-excluded. Three of them are named nowhere in this file, and the reason is the same for all * three: {@code placement}, {@code memberCredentials} and {@code memberLoginShell} are * hot and correctly absent — all three are read live off {@code config.get()} @@ -219,7 +226,7 @@ public final class ConfigRef implements Supplier { static final Set DEFERRED_KEYS = Set.of( "guard", "worktreeRoot", "worktreeGroup", "memberSkills", "primary", "configReload", "leadHeartbeat", "lifecycle", "spawnReadyTimeoutMs", "spawnReadyPollMs", - "quarantineCooldownSeconds", "profiles", "idleSleepGuard"); + "quarantineCooldownSeconds", "profiles", "idleSleepGuard", "models"); private final Path path; private final AtomicReference current; @@ -322,12 +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(); + // 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); @@ -439,6 +446,15 @@ public final class ConfigRef implements Supplier { if (!Objects.equals(old.idleSleepGuard(), fresh.idleSleepGuard())) { changed.add("idleSleepGuard"); } + // fleetd ticket "central allow-list of usable models": validateModels() runs again in + // 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())) { + changed.add("models"); + } if (!Objects.equals(old.spawnReadyTimeoutMs(), fresh.spawnReadyTimeoutMs()) || !Objects.equals(old.spawnReadyPollMs(), fresh.spawnReadyPollMs())) { changed.add("spawnReady*"); 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 175cb18..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; @@ -124,6 +128,13 @@ import java.util.regex.PatternSyntaxException; * under a member's long turn. {@code null} (the block omitted) behaves the * same as an explicit {@code enabled: true}; set {@code enabled: false} to * turn it off. See {@link dev.ltms.fleet.power.IdleSleepGuard}. + * @param models central allow-list of models any {@code profiles:} entry may name. {@code + * null} or an empty {@code allow:} ⇒ off: {@link #validateModels()} checks + * nothing and every existing config keeps working exactly as it does today. + * When non-empty, a profile whose {@code model:} is not one of {@link + * Models#ids()} fails config load, naming both the model and the profile — + * see {@link #validateModels()}. This block only decides what may be + * CONFIGURED; nothing here enforces it at spawn time. See {@link Models}. */ @JsonIgnoreProperties(ignoreUnknown = true) public record FleetConfig( @@ -150,7 +161,22 @@ public record FleetConfig( String worktreeGroup, String memberLoginShell, String memberSkills, - IdleSleepGuard idleSleepGuard) { + IdleSleepGuard idleSleepGuard, + Models models) { + + /** Back-compat form before the {@code models:} block was added. */ + public FleetConfig(Bind bind, String herdrSocket, String memberHerdrSocket, Map profiles, + Guard guard, String worktreeRoot, Lifecycle lifecycle, Integer spawnReadyTimeoutMs, + Integer spawnReadyPollMs, Broker broker, Primary primary, Fleet fleet, + LeadHeartbeat leadHeartbeat, Health health, String placement, Auth auth, + ConfigReload configReload, Integer quarantineCooldownSeconds, + MemberCredentials memberCredentials, Coordinator coordinator, String worktreeGroup, + String memberLoginShell, String memberSkills, IdleSleepGuard idleSleepGuard) { + this(bind, herdrSocket, memberHerdrSocket, profiles, guard, worktreeRoot, lifecycle, spawnReadyTimeoutMs, + spawnReadyPollMs, broker, primary, fleet, leadHeartbeat, health, placement, auth, + configReload, quarantineCooldownSeconds, memberCredentials, coordinator, worktreeGroup, + memberLoginShell, memberSkills, idleSleepGuard, null); + } /** Back-compat form before the {@code idleSleepGuard:} block was added. */ public FleetConfig(Bind bind, String herdrSocket, String memberHerdrSocket, Map profiles, @@ -1318,6 +1344,72 @@ public record FleetConfig( } } + /** + * Central allow-list of models any {@code profiles:} entry may name (fleetd ticket: "a central + * allow-list of usable models"). Nothing before this block checked a profile's {@code model:} + * against anything — it was a free-form string handed straight to the backend adapter, and a + * withdrawn or misspelled name failed silently (opencode falls back to a default model rather + * than erroring on an unknown {@code -m}) rather than at config load, where a mistake is cheap. + * + *

Absent or empty {@code allow:} is "off", on purpose: this is a large deployment + * with gitignored {@code fleetd.yaml} on more than one host, and a change that forced every + * operator to enumerate their models before the daemon would start would break every one of + * them on upgrade. See {@link FleetConfig#validateModels()}, which is where the allow-list is + * actually enforced, at config load. + * + *

The list is the authority; a {@code profiles:} entry is checked against it, never the + * other way around. Adding or editing a {@code profiles:} entry cannot, by itself, widen + * the set of permitted models — only editing {@code models.allow:} itself can. This is the + * invariant the ticket asked for: the two blocks are validated in one direction only. + * + *

Out of scope here, deliberately: nothing in this block is read at spawn time — + * enforcing it against a live spawn, an on/off runtime switch, and any interaction with {@code + * BackendQuarantine} are separate units. This block is config-load validation only. + * + * @param allow the permitted models, each its own {@link ModelEntry} rather than a bare + * string — see that record's javadoc for why. {@code null}/empty ⇒ the block is + * treated as absent: {@link FleetConfig#validateModels()} checks nothing. + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record Models(List allow) { + + public Models { + allow = (allow == null) ? List.of() : List.copyOf(allow); + } + + /** + * One permitted model, named as a record rather than a bare string on purpose: a later unit + * needs to hang an on/off state and a load-limit state off each entry, and a bare {@code + * List} cannot grow those fields without changing the YAML shape underneath every + * operator who already wrote one. {@link #model()} is intentionally a single flat, + * opaque-string namespace — a bare Claude id ({@code claude-sonnet-5}) and an opencode + * provider-prefixed id ({@code openai/gpt-5.6-terra}) both fit it unchanged, because + * {@link FleetConfig#validateModels()} only ever compares a profile's {@code model:} value + * against this string for exact equality; it never parses a provider prefix or branches on + * a profile's {@code kind:}. + * + * @param model the model id exactly as a {@code profiles:} entry's {@code model:} field + * would name it + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record ModelEntry(String model) { + public ModelEntry { + model = (model == null || model.isBlank()) ? null : model.trim(); + } + } + + /** {@link #allow}'s model ids, as a set for membership checks. Blank/null entries are dropped. */ + public Set ids() { + Set ids = new java.util.LinkedHashSet<>(); + for (ModelEntry e : allow) { + if (e != null && e.model() != null) { + ids.add(e.model()); + } + } + return Collections.unmodifiableSet(ids); + } + } + /** * The terminal → lead-name map seeded from the legacy singular {@code primary:} pin (CB-530). * @@ -1569,7 +1661,7 @@ public record FleetConfig( "lifecycle", "spawnReadyTimeoutMs", "spawnReadyPollMs", "broker", "primary", "fleet", "leadHeartbeat", "health", "placement", "auth", "configReload", "quarantineCooldownSeconds", "memberCredentials", "coordinator", "worktreeGroup", "memberLoginShell", "memberSkills", - "idleSleepGuard"); + "idleSleepGuard", "models"); /** Load and validate config from {@code path}. */ public static FleetConfig load(Path path) { @@ -2254,10 +2346,15 @@ public record FleetConfig( // enabled: true — see its javadoc), so defaulting the block here would change nothing a // reader observes and would only obscure that "block omitted" and "block present and // enabled" are deliberately the same outcome. + // models is left as-is, like broker/primary/coordinator above: null/empty is "off", and an + // absent block must validate nothing (see Models's javadoc) — defaulting it here to an + // empty Models would be a no-op for validateModels() either way, since an empty allow-list + // already means "check nothing", so there is nothing to gain and one more null check to + // avoid by leaving it exactly as configured. return new FleetConfig(b, herdrSocket, memberHerdrSocket, profiles, g, worktreeRoot, l, timeout, pollMs, broker, primary, f, leadHeartbeat, health, placementOrDefault, a, configReload, quarantineCooldown, mc, coordinator, worktreeGroup, memberLoginShell, memberSkills, - idleSleepGuard); + idleSleepGuard, models); } /** @@ -2477,6 +2574,118 @@ public record FleetConfig( } } + /** + * Reject a {@code profiles:} entry whose {@code model:} is not on the configured {@link + * #models} allow-list. + * + *

Absent or empty {@code models.allow:} validates nothing — see {@link Models}'s javadoc: + * an existing config with no such block must keep working exactly as it does today. Once the + * operator declares at least one entry, every profile's {@code model:} (when set — a profile + * may legitimately leave it {@code null}, e.g. a {@code subscription: true} profile relying on + * the account's own default) must equal one of {@link Models#ids()} exactly. The comparison is + * a flat string match: a bare Claude id and an opencode {@code provider/model} id are both + * just opaque strings here, so nothing here needs to know which {@code kind:} a profile runs. + * + *

The check runs one direction only, by construction: it reads {@link #profiles} and + * {@link #models}, and only ever adds to {@code bad} when a profile's model is missing from + * the allow-list. Nothing here can be satisfied by widening a {@code profiles:} entry — only + * editing {@code models.allow:} itself changes what passes. That is the invariant the ticket + * asked for: the list is the authority, profiles are checked against it. + * + * @throws IllegalStateException when any profile names a model outside the configured + * allow-list, naming both the model and the profile that wanted it + */ + public void validateModels() { + if (models == null || models.allow().isEmpty()) { + return; + } + Set allowed = models.ids(); + List bad = new ArrayList<>(); + profiles.forEach((name, p) -> { + String model = p.model(); + if (model != null && !model.isBlank() && !allowed.contains(model)) { + bad.add("profile '" + name + "' names model '" + model + "', which is not in " + + "models.allow: (have: " + allowed + ")."); + } + }); + if (!bad.isEmpty()) { + throw new IllegalStateException("refusing to start: " + String.join(" ", bad)); + } + } + + /** + * 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/ConfigRefTopLevelReportingCoverageTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTopLevelReportingCoverageTest.java index f022306..2626027 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTopLevelReportingCoverageTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTopLevelReportingCoverageTest.java @@ -109,6 +109,8 @@ class ConfigRefTopLevelReportingCoverageTest { v.put("memberLoginShell", null); v.put("memberSkills", "/skills/a"); v.put("idleSleepGuard", new FleetConfig.IdleSleepGuard(true)); + v.put("models", new FleetConfig.Models( + List.of(new FleetConfig.Models.ModelEntry("model-a")))); assertNamesMatchComponents(v); return v; } @@ -151,6 +153,8 @@ class ConfigRefTopLevelReportingCoverageTest { v.put("memberLoginShell", null); v.put("memberSkills", "/skills/b"); v.put("idleSleepGuard", new FleetConfig.IdleSleepGuard(false)); + v.put("models", new FleetConfig.Models( + List.of(new FleetConfig.Models.ModelEntry("model-b")))); assertNamesMatchComponents(v); return v; } diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java index d8e6fc8..19baf53 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java @@ -2683,4 +2683,222 @@ class FleetConfigTest { assertTrue(cfg.idleSleepGuard().isEnabled(), "unlike ConfigReload/Health, this block defaults to ON even when present but empty"); } + + // --- models: central allow-list of usable models ----------------------------------------- + + /** + * An absent {@code models:} block is today's behaviour exactly: no profile's {@code model:} is + * checked against anything, whatever it says. This is the "existing config keeps working" + * invariant — an operator on a gitignored {@code fleetd.yaml} that predates this feature must + * not be broken by upgrading the daemon. + */ + @Test + void absentModelsBlockValidatesNothing(@TempDir Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + profiles: + gx10: + baseUrl: http://gx10.gw:8000 + model: totally-unheard-of-model-xyz + """); + + FleetConfig cfg = FleetConfig.load(f); + assertDoesNotThrow(cfg::validateModels); + } + + /** + * {@code models:} present but with an empty (or absent) {@code allow:} must behave exactly like + * the block being absent — an operator adding the block for the first time with nothing in it + * yet must not be surprised by every profile suddenly refusing to start. + */ + @Test + void modelsBlockPresentButEmptyValidatesNothing(@TempDir Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + profiles: + gx10: + baseUrl: http://gx10.gw:8000 + model: whatever-the-operator-typed + models: + allow: [] + """); + + FleetConfig cfg = FleetConfig.load(f); + assertDoesNotThrow(cfg::validateModels); + } + + /** + * The core of the ticket: a profile naming a model outside the configured allow-list fails + * config load, naming both the model and the profile that wanted it. + */ + @Test + void aProfileNamingAModelOutsideTheAllowListRefusesToStart(@TempDir Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + 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 + - model: claude-haiku-5 + """); + FleetConfig cfg = FleetConfig.load(f); + + IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateModels); + assertTrue(e.getMessage().contains("rogue"), "the refusal names the offending profile"); + assertTrue(e.getMessage().contains("claude-opus-9000"), "the refusal names the offending model"); + assertFalse(e.getMessage().contains("'sonnet'"), + "the profile whose model IS allowed must not be reported"); + } + + /** A profile whose {@code model:} is on the allow-list loads fine. */ + @Test + void aProfileNamingAModelOnTheAllowListLoadsFine(@TempDir Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + profiles: + sonnet: + baseUrl: http://gx10.gw:8000 + model: claude-sonnet-5 + models: + allow: + - model: claude-sonnet-5 + """); + + FleetConfig cfg = FleetConfig.load(f); + assertDoesNotThrow(cfg::validateModels); + } + + /** + * A profile that never sets {@code model:} (a {@code subscription: true} profile relying on the + * account's own default is the live example) must not be refused just because an allow-list is + * active — there is nothing to check it against. + */ + @Test + void aProfileWithNoModelPassesEvenWithAnActiveAllowList(@TempDir Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + profiles: + opus: + subscription: true + models: + allow: + - model: claude-sonnet-5 + """); + + FleetConfig cfg = FleetConfig.load(f); + assertDoesNotThrow(cfg::validateModels); + } + + /** + * Reproduces the live config shape this ticket measured: a mix of {@code amazon-bedrock}, + * {@code opencode} and {@code openai}-backed profiles, some naming a bare Claude id and some a + * provider-prefixed opencode id, in ONE allow-list. Both forms are just opaque strings compared + * for exact equality — proves the shape decision holds against the shape actually seen live, + * not just against a synthetic single-provider example. + */ + @Test + void aBareClaudeIdAndAnOpencodeProviderPrefixedIdBothFitOneAllowList(@TempDir Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + profiles: + sonnet: + baseUrl: http://gx10.gw:8000 + model: claude-sonnet-5 + terra: + kind: opencode + model: openai/gpt-5.6-terra + nova: + kind: opencode + model: amazon-bedrock/amazon.nova-pro-v1:0 + models: + allow: + - model: claude-sonnet-5 + - model: openai/gpt-5.6-terra + - model: amazon-bedrock/amazon.nova-pro-v1:0 + """); + + FleetConfig cfg = FleetConfig.load(f); + assertDoesNotThrow(cfg::validateModels); + } + + /** The same live shape, but one opencode profile's model is missing from the allow-list. */ + @Test + void anUnlistedOpencodeProviderPrefixedModelRefusesToStart(@TempDir Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + profiles: + sonnet: + baseUrl: http://gx10.gw:8000 + model: claude-sonnet-5 + terra: + kind: opencode + model: openai/gpt-5.6-terra-withdrawn + models: + allow: + - model: claude-sonnet-5 + - model: openai/gpt-5.6-terra + """); + FleetConfig cfg = FleetConfig.load(f); + + IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateModels); + assertTrue(e.getMessage().contains("terra"), "the refusal names the offending profile"); + assertTrue(e.getMessage().contains("openai/gpt-5.6-terra-withdrawn"), + "the refusal names the offending model, with its provider prefix intact"); + } + + /** + * Editing a {@code profiles:} entry alone must never be able to widen what is permitted — only + * editing {@code models.allow:} itself can. This is the invariant the ticket states explicitly; + * this test pins it by giving a profile a plausible-looking model that was never added to the + * allow-list and confirming it is still refused. + */ + @Test + void addingAProfileCannotWidenTheAllowListByItself(@TempDir Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + profiles: + sonnet: + baseUrl: http://gx10.gw:8000 + model: claude-sonnet-5 + brand-new: + baseUrl: http://gx12.gw:8000 + model: claude-sonnet-6-preview + models: + allow: + - model: claude-sonnet-5 + """); + FleetConfig cfg = FleetConfig.load(f); + + IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateModels); + assertTrue(e.getMessage().contains("brand-new")); + assertTrue(e.getMessage().contains("claude-sonnet-6-preview")); + } + + /** {@code models} is a brand-new top-level key and must be recognized, not WARN-ed as unknown. */ + @Test + void modelsIsAKnownTopLevelKey() { + assertTrue(FleetConfig.KNOWN_TOP_LEVEL_KEYS.contains("models")); + } } 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..50930ab --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigValidateAllTest.java @@ -0,0 +1,358 @@ +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 now fails + * a test in this module. + * + *

What is NOT pinned, measured rather than assumed. Reverting {@link + * FleetConfig#validateAll()} to a hardcoded list of today's six method calls leaves the whole + * suite green (measured at review: 1491 tests, 0 failures). Nothing ties {@code validateAll()} to + * the generic sweep — claim 1 proves {@link FleetConfig#invokeAllValidators} is generic, and claim + * 2 proves {@code validateAll()} reaches today's six, and a hardcoded list satisfies both. So the + * reflective sweep is a convenience, not the guarantee. The guarantee is {@link + * #fleetConfigDeclaresExactlyTheseSixValidatorsToday()}: it fails the moment a seventh validator + * is declared, which forces whoever adds it to look at this file. + */ +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. Do TWO things, in this " + + "order. First confirm validateAll() still delegates to " + + "invokeAllValidators(this) — a hardcoded list there passes every other " + + "test in this class, so this assertion is the only place that will ever " + + "make you check. Only then 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()); + } +} diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigWithDefaultsPreservesEveryComponentTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigWithDefaultsPreservesEveryComponentTest.java index de487fc..80cdf85 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigWithDefaultsPreservesEveryComponentTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigWithDefaultsPreservesEveryComponentTest.java @@ -97,6 +97,8 @@ class FleetConfigWithDefaultsPreservesEveryComponentTest { v.put("memberLoginShell", "/bin/zsh"); v.put("memberSkills", "/skills/guard"); v.put("idleSleepGuard", new FleetConfig.IdleSleepGuard(true)); + v.put("models", new FleetConfig.Models( + List.of(new FleetConfig.Models.ModelEntry("model-guard")))); assertNamesMatchComponents(v); return v; }