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 b7e0829..2685848 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java +++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java @@ -157,6 +157,10 @@ public final class Fleetd { // 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(); 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..fbf2f37 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,11 @@ 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()}, 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 +140,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 +225,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; @@ -328,6 +334,10 @@ public final class ConfigRef implements Supplier { 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(); } 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 +449,14 @@ 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, 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..5cc70f0 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java +++ b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java @@ -124,6 +124,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 +157,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 +1340,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 +1657,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 +2342,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 +2570,45 @@ 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)); + } + } + /** 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/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/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; }