diff --git a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java index 0b258fd..c41d6f0 100644 --- a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java +++ b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java @@ -986,6 +986,7 @@ public record BridgedConfig( warnUnknownTopLevelKeys(yaml, path); rejectDuplicateMemberSlots(yaml); rejectNegativeMaxLoad(yaml); + rejectUnknownKind(yaml); BridgedConfig cfg = YAML.readValue(yaml, BridgedConfig.class); return cfg.withDefaults(); } catch (IOException e) { @@ -1280,6 +1281,53 @@ public record BridgedConfig( } } + /** The peer kinds this build has an adapter for — {@link Profile#kind()}'s only valid values. */ + private static final Set KNOWN_KINDS = Set.of(Profile.KIND_CLAUDE_CODE, Profile.KIND_OPENCODE); + + /** + * Reject a profile whose {@code kind:} is not one of {@link #KNOWN_KINDS} (CB-604), naming the + * profile, the value it set, and the accepted set. + * + *

{@link Profile}'s compact constructor only lower-cases {@code kind} and compares it against + * {@code KIND_OPENCODE} — anything else, including a typo like {@code opencod}, silently falls + * into the claude-code bucket ({@link dev.ltms.bridged.member.CompositePeerLauncher} routes by + * exact adapter claim, not by membership in a known set). With {@code argv:} also unset, the argv + * default special-cases only the exact string {@code "claude-code"}, so the launch command falls + * back to {@code List.of(kind)} — the daemon then tries to run a program literally named after the + * typo. {@code CompositePeerLauncher}'s constructor already treats a profile claimed by two + * adapters as fatal (CB-402); an unrecognized kind is the same class of adapter-routing mistake + * and gets the same treatment here, at config load, rather than surfacing later as a failed spawn. + * + * @param yaml the raw config text + * @throws IllegalStateException when any profile's {@code kind} is a non-blank value not in + * {@link #KNOWN_KINDS} (case-insensitive) + */ + static void rejectUnknownKind(String yaml) { + Map raw; + try { + raw = YAML.readValue(yaml, Map.class); + } catch (IOException | IllegalArgumentException e) { + return; // a malformed file is reported by the real parse, not here + } + if (raw == null || !(raw.get("profiles") instanceof Map profiles)) { + return; + } + List bad = profiles.entrySet().stream() + .filter(e -> e.getValue() instanceof Map p + && p.get("kind") instanceof String k && !k.isBlank() + && !KNOWN_KINDS.contains(k.toLowerCase())) + .map(e -> String.valueOf(e.getKey()) + "=" + ((Map) e.getValue()).get("kind")) + .sorted() + .toList(); + if (!bad.isEmpty()) { + throw new IllegalStateException("refusing to start: profile(s) [" + String.join(", ", bad) + + "] set an unrecognized kind — accepted values are " + + String.join(", ", KNOWN_KINDS.stream().sorted().toList()) + + " (case-insensitive); an unrecognized kind would otherwise fall back to the" + + " claude-code adapter and try to launch a program named after the typo."); + } + } + static List unknownTopLevelKeys(String yaml) { Map raw; try { diff --git a/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java index dc91af1..8e2efa6 100644 --- a/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java @@ -1040,6 +1040,44 @@ class BridgedConfigTest { "an opencode worker with no argv defaults to the opencode binary, never claude"); } + /** + * CB-604: an unrecognized {@code kind:} used to silently fall into the claude-code bucket — not + * matching {@code "opencode"} was the only check. With {@code argv:} also unset that meant the + * daemon tried to launch a program literally named after the typo. + */ + @Test + void unknownKindIsRefusedAtLoadNamingTheValueAndTheAcceptedSet(@TempDir Path dir) throws Exception { + Path f = dir.resolve("kind-typo.yaml"); + Files.writeString(f, """ + profiles: + gemini: + kind: opencod + model: google/gemini-2.5-pro + """); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> BridgedConfig.load(f)); + assertTrue(e.getMessage().contains("gemini"), "error names the profile: " + e.getMessage()); + assertTrue(e.getMessage().contains("opencod"), "error names the bad value: " + e.getMessage()); + assertTrue(e.getMessage().contains("claude-code") && e.getMessage().contains("opencode"), + "error names the accepted set: " + e.getMessage()); + } + + @Test + void blankKindStillDefaultsToClaudeCode(@TempDir Path dir) throws Exception { + Path f = dir.resolve("kind-blank.yaml"); + Files.writeString(f, """ + profiles: + gx10: + baseUrl: http://gx10.gw:8000 + kind: "" + argv: ["ccs", "gx10"] + """); + + BridgedConfig cfg = BridgedConfig.load(f); + assertEquals(BridgedConfig.Profile.KIND_CLAUDE_CODE, cfg.profiles().get("gx10").kind(), + "a blank kind: is documented to behave exactly like an absent one"); + } + @Test void authDefaultsToLoopbackTrustSoExistingConfigsBehaveAsBefore(@TempDir Path dir) throws Exception { Path f = dir.resolve("no-auth-block.yaml");