diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java index 267aa99..34e828a 100644 --- a/bridged/src/main/java/dev/ltms/bridged/Bridged.java +++ b/bridged/src/main/java/dev/ltms/bridged/Bridged.java @@ -14,6 +14,7 @@ import dev.ltms.bridged.inject.Injector; import dev.ltms.bridged.inject.StatusPoller; import dev.ltms.bridged.inject.TurnListener; import dev.ltms.bridged.inject.WorkerPresence; +import dev.ltms.bridged.auth.ArchitectRegistry; import dev.ltms.bridged.auth.CallerResolver; import dev.ltms.bridged.mcp.BridgeMcp; import dev.ltms.bridged.mcp.ConnectionIdentity; @@ -90,6 +91,9 @@ public final class Bridged { // 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(); + // 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.validateArchitects(); Path socket = cfg.herdrSocket() != null && !cfg.herdrSocket().isBlank() ? Path.of(cfg.herdrSocket()) @@ -199,6 +203,19 @@ public final class Bridged { leads = () -> leadTerminals; } + // CB-548: config-declared architect slots. Config supplies only the stable name → profile + // map; the terminal → slot binding is owned by the registry and is empty at startup, so no + // pane resolves to an architect until the later spawn lifecycle binds one. The registry is + // what CallerResolver resolves against and what that lifecycle will read profiles from; + // nothing here spawns a slot. + ArchitectRegistry architects = new ArchitectRegistry( + cfg.architects() == null ? Map.of() : cfg.architects()); + if (!architects.slots().isEmpty()) { + log.info("architect slots: {} configured {} — none bound yet (a slot is idle until the " + + "spawn lifecycle binds a live terminal to it)", + architects.slots().size(), architects.slots().keySet()); + } + // Status-gated injector (CB-103): the single writer into workers, fed by a poller. // The blocking message endpoint (CB-104) is the producer; the poller is inert until then. // CB-106: a confirmed turn completion resolves a blocked send whose worker never replied. @@ -308,11 +325,13 @@ public final class Bridged { throw new IllegalStateException("auth.mode=token but env var " + cfg.auth().tokenEnv() + " is unset or empty — export it before starting bridged"); } - callers = CallerResolver.withLeads(identity, true, token, leads); + callers = CallerResolver.withLeadsAndArchitects(identity, true, token, leads, + architects::snapshot); log.info("auth: token mode (bearer required for non-worker callers, env {})", cfg.auth().tokenEnv()); } else { - callers = CallerResolver.withLeads(identity, false, null, leads); + callers = CallerResolver.withLeadsAndArchitects(identity, false, null, leads, + architects::snapshot); log.info("auth: loopback-trust (any loopback non-worker caller is the primary)"); } diff --git a/bridged/src/main/java/dev/ltms/bridged/auth/ArchitectRegistry.java b/bridged/src/main/java/dev/ltms/bridged/auth/ArchitectRegistry.java new file mode 100644 index 0000000..1e7c673 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/auth/ArchitectRegistry.java @@ -0,0 +1,142 @@ +package dev.ltms.bridged.auth; + +import dev.ltms.bridged.config.BridgedConfig; + +import java.util.HashMap; +import java.util.Map; + +/** + * The architect-slot registry (CB-548): every gateway-local architect name and the strong-model + * profile it points at, plus the live bindings from a live architect's herdr terminal to + * its slot. + * + *

Two halves, split by who owns each: + *

+ * + *

Spawning/lifecycle is deliberately a separate unit: this class only owns the bindings and + * exposes the map the resolver resolves against plus the profile lookup lifecycle will call. + * Nothing here creates or manages an architect session. + */ +public final class ArchitectRegistry { + + private final Map slots; + /** Live {@code terminal_id → slot name}; guarded by {@code this}. */ + private final Map terminalToSlot = new HashMap<>(); + + public ArchitectRegistry(Map slots) { + this.slots = slots == null ? Map.of() : Map.copyOf(slots); + } + + /** The configured slots, keyed by gateway-local unique name. Unmodifiable snapshot. */ + public Map slots() { + return slots; + } + + /** + * An immutable copy of the live {@code terminal_id → slot name} bindings. + * + *

Passed to {@link CallerResolver} as the source of architect identity, and what + * {@code bridge_whoami}/the roster will read to say which slot a pane hosts. Empty until the + * spawn lifecycle binds a slot. + */ + public Map snapshot() { + synchronized (terminalToSlot) { + return Map.copyOf(terminalToSlot); + } + } + + /** The slot a live terminal is bound to, or {@code null} if it is not an architect slot. */ + public String slotForTerminal(String terminal) { + if (terminal == null) { + return null; + } + synchronized (terminalToSlot) { + return terminalToSlot.get(terminal); + } + } + + /** + * The strong-model profile a slot runs under — what the spawn lifecycle reads. + * + * @return the slot's configured {@code profile}, or {@code null} if the slot is unknown or + * declares none + */ + public String profileForSlot(String slotName) { + BridgedConfig.Architect a = slots.get(slotName); + return (a == null || a.profile() == null) ? null : a.profile(); + } + + /** True when {@code slotName} is a configured architect slot. */ + public boolean isSlot(String slotName) { + return slots.containsKey(slotName); + } + + /** + * Bind {@code terminal} to {@code slot} (CB-548). + * + *

The spawn lifecycle calls this when it stands a slot up. The bind is atomic and preserves + * the two cardinality invariants: a terminal may occupy at most one slot, and a slot may host at + * most one terminal. Binding the same terminal to the same slot again is a harmless no-op. + * + * @param slot a configured slot name, or the bind is refused + * @param terminal the pane that will act as this architect + * @return {@code true} if the binding is now {@code terminal → slot}; {@code false} if it was + * refused — an unknown slot, a terminal already bound to a different slot, or a slot + * already hosting a different terminal + */ + public boolean bind(String slot, String terminal) { + if (slot == null || terminal == null || terminal.isBlank()) { + return false; + } + synchronized (terminalToSlot) { + if (!isSlot(slot)) { + return false; // unknown slot — nothing to bind to + } + String existingSlot = terminalToSlot.get(terminal); + if (existingSlot != null) { + return slot.equals(existingSlot); // already this slot (idempotent) or a different one + } + if (terminalToSlot.containsValue(slot)) { + return false; // slot already hosts a terminal — no second one + } + terminalToSlot.put(terminal, slot); + return true; + } + } + + /** + * Compare-safe unbind of {@code expectedTerminal} from {@code slot} (CB-548). + * + *

The spawn lifecycle calls this when it tears a slot down. Only the exact binding + * {@code expectedTerminal → slot} is removed; if that terminal was since rebound to a different + * slot (or the slot to a different terminal), the call is a no-op returning {@code false} — a + * stale unbind must never remove a replacement. + * + * @param slot the slot the caller believes the terminal is bound to + * @param expectedTerminal the terminal it expects to be bound there + * @return {@code true} if {@code expectedTerminal → slot} was removed; {@code false} if nothing + * was (no such binding, or the binding had already moved) + */ + public boolean unbind(String slot, String expectedTerminal) { + if (slot == null || expectedTerminal == null) { + return false; + } + synchronized (terminalToSlot) { + String current = terminalToSlot.get(expectedTerminal); + if (current == null || !slot.equals(current)) { + return false; // absent, or a replacement/moved binding — leave it in place + } + terminalToSlot.remove(expectedTerminal); + return true; + } + } +} diff --git a/bridged/src/main/java/dev/ltms/bridged/auth/Authz.java b/bridged/src/main/java/dev/ltms/bridged/auth/Authz.java index 0f96310..a89d6aa 100644 --- a/bridged/src/main/java/dev/ltms/bridged/auth/Authz.java +++ b/bridged/src/main/java/dev/ltms/bridged/auth/Authz.java @@ -46,20 +46,28 @@ public final class Authz { return false; // authenticated as nothing ⇒ authorized for nothing } return switch (action) { - // Orchestration is the primary's alone. A worker driving spawn/stop/send would be a - // worker escalating into the orchestrator role. - case SPAWN, STOP, SEND, DRAIN -> caller.isPrimary(); + // Fleet lifecycle is the primary's alone — spawn, stop, drain. An architect + // deliberately does NOT get these (CB-548), so it cannot tear down or stand up workers + // even though it coordinates them; and a worker driving any of these would be a worker + // escalating into the orchestrator role. + case SPAWN, STOP, DRAIN -> caller.isPrimary(); + + // Delivering a turn is open to the primary and the architect: an architect delegates + // to workers (that is the role's point) but still has no lifecycle rights. A worker is + // excluded — sending would be it escalating. + case SEND -> caller.isPrimary() || caller.isArchitect(); // The load-bearing rule: a caller acts only as the pane it occupies. CB-532 widened who // that can be — a lead answering another lead is replying for its OWN terminal, which // this already permits — while the rule itself is unchanged, and is what stops anyone - // forging a reply for a rendezvous someone else is waiting on. An unnamed primary - // (token/loopback, no pane) owns nothing and is still excluded. + // forging a reply for a rendezvous someone else is waiting on. An architect's own pane + // passes through the same check, so it can answer a funnel that delegated to it. An + // unnamed primary (token/loopback, no pane) owns nothing and is still excluded. case REPLY, ASK -> caller.ownsSession(targetSession); - // Observation is open to both authenticated roles: a worker legitimately polls its own + // Observation is open to every authenticated role: a worker legitimately polls its own // status, and the roster carries no secrets. - case READ, METRICS -> caller.isPrimary() || caller.isWorker(); + case READ, METRICS -> caller.isPrimary() || caller.isWorker() || caller.isArchitect(); }; } diff --git a/bridged/src/main/java/dev/ltms/bridged/auth/CallerResolver.java b/bridged/src/main/java/dev/ltms/bridged/auth/CallerResolver.java index 5f10789..ccc5ad6 100644 --- a/bridged/src/main/java/dev/ltms/bridged/auth/CallerResolver.java +++ b/bridged/src/main/java/dev/ltms/bridged/auth/CallerResolver.java @@ -24,6 +24,10 @@ import java.util.function.Supplier; * that pane as a lead's own — without this rule a lead running inside a herdr pane * is misread as a worker and locked out of orchestration. More than one pane may be named, * so two leads can work as peers rather than one being demoted. + *

  • A loopback peer PID that maps to a pane bound to a CB-548 architect slot ⇒ + * {@link Role#ARCHITECT}, carrying the slot name. Just unforgeable as a worker's, and + * resolved from the live terminal→slot binding (never a request argument), before + * the generic worker fallback.
  • *
  • A loopback peer PID that maps to any other herdr pane ⇒ {@link Role#WORKER}. This is * unforgeable (the OS reports the PID, herdr owns the PID→pane map) and is honoured * regardless of auth mode, so enabling auth never breaks the fleet.
  • @@ -47,6 +51,15 @@ public final class CallerResolver { * it is TTL-cached, so this is a map lookup in the common case. */ private final Supplier> leadTerminals; + /** + * terminal_id → architect slot name; empty when nothing is configured. CB-548. + * + *

    Like {@link #leadTerminals}, a supplier rather than a fixed map, so a binding injected + * after startup — when the later spawn lifecycle establishes a live architect session, or an + * operator pins one — takes effect without a restart. Consulted per resolve; today's wiring + * in {@code Bridged} reads a constant from config, which is the degenerate live case. + */ + private final Supplier> architectTerminals; /** Loopback-trust resolver: no token required, historical behaviour. */ public CallerResolver(ConnectionIdentity identity) { @@ -87,7 +100,17 @@ public final class CallerResolver { */ public CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token, Map leadTerminals) { - this(identity, tokenMode, token, fixed(leadTerminals)); + this(identity, tokenMode, token, fixed(leadTerminals), null); + } + + /** + * Map-form of both registries (CB-548): lead terminals and the initial architect terminal + * bindings, each snapshotted at construction (a handed-over map is not offered as live state). + */ + public CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token, + Map leadTerminals, + Map architectTerminals) { + this(identity, tokenMode, token, fixed(leadTerminals), fixed(architectTerminals)); } /** @@ -101,7 +124,23 @@ public final class CallerResolver { public static CallerResolver withLeads(ConnectionIdentity identity, boolean tokenMode, String token, Supplier> leadTerminals) { - return new CallerResolver(identity, tokenMode, token, leadTerminals); + return new CallerResolver(identity, tokenMode, token, leadTerminals, null); + } + + /** + * Live-registry form for both {@code leadTerminals} and the CB-548 architect registry: both + * are consulted on every resolve, so a slot binding injected after startup takes effect + * without a restart. + * + *

    A static factory rather than a constructor overload, for the same reason as + * {@link #pinnedTo}: too many {@code Map}/{@code Supplier} combinations to make {@code null} + * unambiguous. + */ + public static CallerResolver withLeadsAndArchitects(ConnectionIdentity identity, + boolean tokenMode, String token, + Supplier> leadTerminals, + Supplier> architectTerminals) { + return new CallerResolver(identity, tokenMode, token, leadTerminals, architectTerminals); } private static Supplier> fixed(Map leadTerminals) { @@ -110,7 +149,8 @@ public final class CallerResolver { } private CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token, - Supplier> leadTerminals) { + Supplier> leadTerminals, + Supplier> architectTerminals) { if (tokenMode && (token == null || token.isBlank())) { throw new IllegalArgumentException( "auth.mode=token requires a non-empty token; check that the env var named by " @@ -120,6 +160,7 @@ public final class CallerResolver { this.tokenMode = tokenMode; this.expectedToken = tokenMode ? token.getBytes(StandardCharsets.UTF_8) : null; this.leadTerminals = leadTerminals == null ? Map::of : leadTerminals; + this.architectTerminals = architectTerminals == null ? Map::of : architectTerminals; } /** @@ -135,6 +176,17 @@ public final class CallerResolver { return leadTerminals.get(); } + /** + * The currently-recognised architect slots, {@code terminal_id → slot name} (CB-548). + * + *

    Read from the same supplier {@link #resolve} consults, so a slot that is listed + * here but would not resolve (or the reverse) cannot drift apart. Live for the same + * reason as {@link #leads()}. + */ + public Map architects() { + return architectTerminals.get(); + } + /** * Resolve the caller of a request. * @@ -149,8 +201,17 @@ public final class CallerResolver { if (lead != null) { // The config names this pane as a lead's own. The pane mapping is exactly as // unforgeable as a worker's, so it outranks the token path — no credential needed. + // Checked before the architect registry so a pane named in BOTH is still the lead + // (CB-548 preserves every existing leader behaviour). return Principal.leader(lead, c.terminal(), c.pid()); } + String slot = architectTerminals.get().get(c.terminal()); + if (slot != null) { + // The config/live binding names this pane as an architect slot's own. Same + // unforgeable pane mapping; the live binding, never a request argument, decides. + // Checked before the generic worker fallback, per the CB-548 precedence order. + return Principal.architect(slot, c.terminal(), c.pid()); + } return Principal.worker(c.terminal(), c.pid()); // unforgeable; never token-gated } diff --git a/bridged/src/main/java/dev/ltms/bridged/auth/Principal.java b/bridged/src/main/java/dev/ltms/bridged/auth/Principal.java index e4803bf..6341e22 100644 --- a/bridged/src/main/java/dev/ltms/bridged/auth/Principal.java +++ b/bridged/src/main/java/dev/ltms/bridged/auth/Principal.java @@ -10,7 +10,8 @@ package dev.ltms.bridged.auth; * token or loopback trust, and for {@code ANONYMOUS} * @param pid the connecting process id, or {@code -1} when not resolvable (audit context) * @param name for a lead resolved from the CB-530 {@code leaders:} registry, which lead it is; - * {@code null} for every other caller, including an unnamed primary + * for an architect resolved from the CB-548 {@code architects:} registry, which + * slot it occupies; {@code null} for every other caller, including an unnamed primary */ public record Principal(Role role, String terminal, long pid, String name) { @@ -58,10 +59,28 @@ public record Principal(Role role, String terminal, long pid, String name) { return new Principal(Role.WORKER, terminal, pid); } + /** + * An architect (CB-548), identified by the slot it occupies and the pane bound to it. + * + *

    Carries {@link Role#ARCHITECT}. {@code slotName} is reporting only — it lets + * {@code bridge_whoami} say which architect slot is asking, and it is the key the + * (future) spawn lifecycle reads a profile back from. Identity is the {@code terminal}: like a + * worker's it comes from the connection and the live terminal→slot binding, so + * {@code ownsSession} works exactly as it does for a worker — an architect acts as its own + * pane and no other. + */ + public static Principal architect(String slotName, String terminal, long pid) { + return new Principal(Role.ARCHITECT, terminal, pid, slotName); + } + public boolean isPrimary() { return role == Role.PRIMARY; } + public boolean isArchitect() { + return role == Role.ARCHITECT; + } + public boolean isWorker() { return role == Role.WORKER; } @@ -90,6 +109,7 @@ public record Principal(Role role, String terminal, long pid, String name) { public String describe() { return switch (role) { case WORKER -> "worker:" + terminal; + case ARCHITECT -> "architect:" + name; case PRIMARY -> name == null ? "primary" : "leader:" + name; case ANONYMOUS -> "anonymous"; }; diff --git a/bridged/src/main/java/dev/ltms/bridged/auth/Role.java b/bridged/src/main/java/dev/ltms/bridged/auth/Role.java index 0d3da30..b43f3ac 100644 --- a/bridged/src/main/java/dev/ltms/bridged/auth/Role.java +++ b/bridged/src/main/java/dev/ltms/bridged/auth/Role.java @@ -24,6 +24,16 @@ public enum Role { */ WORKER, + /** + * A config-declared architect slot (CB-548): a gateway-local named session on a strong-model + * profile that coordinates and delegates turns but does not own the fleet. Unforgeable like a + * worker's — derived from the connection's pane and the live terminal→slot binding, never from + * a request argument. May {@code SEND} a turn, {@code REPLY}/{@code ASK} only as its own pane, + * and {@code READ}/{@code METRICS}; may not {@code SPAWN}/{@code STOP}/{@code DRAIN} + * (those stay the primary's, to keep lifecycle in one pair of hands). + */ + ARCHITECT, + /** Authenticated as nothing. Authorized for nothing but {@code /healthz}. */ ANONYMOUS } 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 6a3229f..65e42fd 100644 --- a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java +++ b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java @@ -1,6 +1,8 @@ package dev.ltms.bridged.config; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import org.slf4j.Logger; @@ -11,6 +13,7 @@ import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -43,6 +46,11 @@ import java.util.Set; * @param leaders named panes that orchestrate rather than are orchestrated (CB-530), keyed by * lead name; supersedes the singular {@code primary} pin, which stays honoured. * See {@link #leaderTerminals()} for how the two merge + * @param architects CB-548 architect slots, keyed by gateway-local unique slot name; each points + * at a strong-model profile the future spawn lifecycle reads back. An architect + * is not recognised like a lead: config declares the slots only, and a + * live session becomes an architect when the spawn lifecycle binds its terminal + * to a slot. Nothing here spawns a slot. * @param leadScan opt-in discovery of leads by tab label (CB-531); {@code null} ⇒ no scanning, * and only {@code leaders:}/{@code primary:} name a lead * @param placement how to choose a worker profile for an unqualified spawn: @@ -65,6 +73,7 @@ public record BridgedConfig( Broker broker, Primary primary, Map leaders, + Map architects, LeadScan leadScan, String placement, Auth auth) { @@ -379,6 +388,29 @@ public record BridgedConfig( public record Leader(String terminal, String kind, String model) { } + /** + * One entry of the CB-548 {@code architects:} registry — a gateway-local named slot that points + * at a strong-model profile. + * + *

    A slot is declared, not recognised: config names the slot and the profile it runs, + * and nothing else. Unlike a lead (which config pins by herdr {@code terminal_id} and is + * recognised at startup), an architect slot is idle at boot — config supplies no terminal, so no + * session resolves to one until the spawn lifecycle binds a live terminal to the slot. The + * stable name + profile pair is the only config-time identity; live identity is defined purely + * by the runtime {@link dev.ltms.bridged.auth.ArchitectRegistry} binding. + * + *

    Why a {@code profile} reference: an architect is meant to run a strong model, and the slot + * records which {@code workers:} profile that is — the value the future spawn lifecycle reads. + * It must name a configured profile, enforced by {@link #validateArchitects()} (a stale or + * typo'd reference fails at startup rather than silently spawning the wrong backend later). + * + * @param profile the name of the strong-model {@code workers:} profile this slot runs; + * required and validated against {@link #workerProfiles()} + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record Architect(String profile) { + } + /** * Discover leads by tab label instead of by pasted {@code terminal_id} (CB-531). * @@ -539,13 +571,14 @@ public record BridgedConfig( private static final Set KNOWN_TOP_LEVEL_KEYS = Set.of( "bind", "herdrSocket", "worker", "workers", "defaultWorker", "guard", "worktreeRoot", "lifecycle", "spawnReadyTimeoutMs", "spawnReadyPollMs", "broker", "primary", "leaders", - "leadScan", "placement", "auth"); + "architects", "leadScan", "placement", "auth"); /** Load and validate config from {@code path}. */ public static BridgedConfig load(Path path) { try { String yaml = Files.readString(path); warnUnknownTopLevelKeys(yaml, path); + rejectDuplicateArchitectSlots(yaml); BridgedConfig cfg = YAML.readValue(yaml, BridgedConfig.class); return cfg.withDefaults(); } catch (IOException e) { @@ -553,6 +586,101 @@ public record BridgedConfig( } } + /** + * Reject an {@code architects:} registry whose slot names repeat (CB-548). + * + *

    The registry is a {@code Map} keyed by slot name, so by the time it is read duplicate keys + * have already collapsed last-wins — a duplicated slot name would silently drop one slot and the + * daemon would never know. Jackson's YAML parser does not fail on duplicate mapping keys by + * default, so duplicates are caught here, at parse time, before the map is built. Only the + * top-level {@code architects:} block is considered, and only its direct child keys (the + * slot names) — a nested field elsewhere, even one also named {@code architects:}, is ignored, so + * parsing of the rest of the config is unaffected. + * + * @throws IllegalStateException when two {@code architects:} entries share a slot name, naming it + */ + static void rejectDuplicateArchitectSlots(String yaml) { + try (JsonParser p = YAML.createParser(yaml)) { + if (p.nextToken() != JsonToken.START_OBJECT) { + return; // not a mapping at top level — readValue reports the malformed file + } + // Scan the TOP-LEVEL mapping only. Every other field's value (however deep, including + // any nested field also literally named "architects") is consumed whole by skipValue, so + // the loop below can only ever see the top-level field names — a nested `architects:` can + // neither suppress the real block nor be misread as one. + JsonToken t; + while ((t = p.nextToken()) != null && t != JsonToken.END_OBJECT) { + if (t == JsonToken.FIELD_NAME) { + String name = p.getCurrentName(); + JsonToken value = p.nextToken(); + if ("architects".equals(name)) { + if (value == JsonToken.START_OBJECT) { + rejectDuplicateChildSlotKeys(p); + } + return; // the single top-level architects block is handled; nothing more to check + } + skipValue(p, value); + } + } + } catch (IOException e) { + // Not a duplicate-name condition — let readValue report the malformed file itself. + } + } + + /** + * Reject a duplicated direct child key of the (already-positioned) {@code architects:} + * mapping — i.e. a duplicated {@code slot name}. + * + *

    Each slot's value is consumed whole by {@link #skipValue}, so a duplicated field inside + * a slot (e.g. two {@code profile:} keys, or a duplicate nested {@code architects:}) is never seen + * here and cannot masquerade as a duplicated slot name. + * + * @throws IllegalStateException when two {@code architects:} entries share a slot name, naming it + */ + private static void rejectDuplicateChildSlotKeys(JsonParser p) throws IOException { + Set seen = new HashSet<>(); + JsonToken t; + while ((t = p.nextToken()) != null && t != JsonToken.END_OBJECT) { + if (t == JsonToken.FIELD_NAME) { + if (!seen.add(p.getCurrentName())) { + throw new IllegalStateException("refusing to start: duplicate architect slot name '" + + p.getCurrentName() + "' — slot names must be unique; a later entry would " + + "silently overwrite the earlier one"); + } + skipValue(p, p.nextToken()); // the slot's entire value + } + } + } + + /** + * Consume the whole value that starts at {@code start}, including every nested structure, and + * leave the parser positioned just past it. Used so depth is handled structurally rather than by + * a heuristic — a nested field is never interpreted as a top-level {@code architects:}. + */ + private static void skipValue(JsonParser p, JsonToken start) throws IOException { + switch (start) { + case START_OBJECT: { + JsonToken t; + while ((t = p.nextToken()) != null && t != JsonToken.END_OBJECT) { + if (t == JsonToken.FIELD_NAME) { + skipValue(p, p.nextToken()); + } + } + return; + } + case START_ARRAY: { + JsonToken t; + while ((t = p.nextToken()) != null && t != JsonToken.END_ARRAY) { + skipValue(p, t); + } + return; + } + default: + // A scalar (VALUE_* / VALUE_NULL) is already fully consumed by the nextToken that + // returned it — nothing further to skip. + } + } + /** * Log a WARN naming any top-level key this version does not understand (CB-530). * @@ -615,7 +743,9 @@ public record BridgedConfig( // leadScan is left as-is: null is "off", and LeadScan's own compact constructor defaults the // fields of a block that IS present. Defaulting it here would switch the feature on for // every config that never mentioned it. - return new BridgedConfig(b, herdrSocket, worker, workers, defaultWorker, g, worktreeRoot, l, timeout, pollMs, broker, primary, leaders, leadScan, placementOrDefault, a); + // architects is left as-is: null is "none configured", and Architect's fields have no + // defaults to fill. Defaulting it here would change nothing, so leave the call natural. + return new BridgedConfig(b, herdrSocket, worker, workers, defaultWorker, g, worktreeRoot, l, timeout, pollMs, broker, primary, leaders, architects, leadScan, placementOrDefault, a); } /** @@ -720,6 +850,47 @@ public record BridgedConfig( } } + /** + * Reject an architect slot whose profile reference does not resolve (CB-548). + * + *

    An architect's {@code profile} is the strong-model {@code workers:} profile the future + * spawn lifecycle will read to stand the slot up. A reference that names no configured profile + * is a typo or a stale config — and unlike a worker spawn (which fails loudly at its call site + * when it cannot resolve), an architect slot fails only when something later tries to use it. + * This config class does have access to {@link #workerProfiles()}, so the reference + * is validated at startup and the mistake is named then, not discovered months later by a + * spawn that quietly has no backend to use. + * + *

    Slot-name uniqueness needs no check here: the registry is a {@code Map} keyed by name, so + * duplicates are unrepresentable by construction once loaded — and {@link #load(Path)} already + * rejects a duplicated slot name at parse time, before the map collapses. + * + * @throws IllegalStateException when any architect slot is missing or names an unknown profile, + * naming the slot and the offending reference + */ + public void validateArchitects() { + if (architects == null) { + return; + } + Map profiles = workerProfiles(); + List bad = new java.util.ArrayList<>(); + architects.forEach((name, arch) -> { + if (arch == null || arch.profile() == null || arch.profile().isBlank()) { + bad.add("architect slot '" + name + "' has no profile: — give it the name of a " + + "workers: profile (the strong-model backend it runs)."); + return; + } + if (!profiles.containsKey(arch.profile())) { + bad.add("architect slot '" + name + "' references profile '" + arch.profile() + + "', which is not a configured workers: profile (have: " + profiles.keySet() + + ")."); + } + }); + 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/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java index 223f65e..3056344 100644 --- a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java +++ b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java @@ -507,6 +507,17 @@ public final class BridgeMcp { static McpSchema.CallToolResult whoami(Principal caller, SessionManager sessions) { Map m = new LinkedHashMap<>(); m.put("role", caller.role().name().toLowerCase()); + if (caller.isArchitect()) { + // CB-548: the role reads "architect"; the name is the gateway-local slot the pane is + // bound to, and the pane itself so a peer knows where to reach it. + if (caller.name() != null) { + m.put("architect", caller.name()); + } + if (caller.terminal() != null) { + m.put("sessionId", caller.terminal()); + } + return text(json(m)); + } if (!caller.isWorker()) { // CB-530: which lead, once more than one pane is configured as one. `role` deliberately // still reads "primary" — the fallback ladder in CLAUDE.md keys on it, and a lead IS a @@ -827,11 +838,13 @@ public final class BridgeMcp { "Report who YOU are on the bridge — your role is resolved from your connection " + "(unforgeable), never from anything you claim. Returns role 'primary' (you " + "orchestrate: spawn/send/stop; reply ONLY to answer a peer lead that " - + "messaged you, never to answer a worker) or 'worker' (you were delegated " - + "to: you must end every turn with exactly one bridge_reply, and cannot " - + "spawn or send), plus 'leader' naming which lead you are, your own " - + "sessionId, and profile/worktree/branch when you are a worker. Call this " - + "first when following role-conditional instructions rather than guessing.", + + "messaged you, never to answer a worker), 'architect' (you delegate turns " + + "and reply/ask as your own pane, but cannot spawn/stop/drain), or 'worker' " + + "(you were delegated to: you must end every turn with exactly one " + + "bridge_reply, and cannot spawn or send), plus 'leader'/'architect' naming " + + "which one you are, your own sessionId, and profile/worktree/branch when " + + "you are a worker. Call this first when following role-conditional " + + "instructions rather than guessing.", objectSchema(Map.of(), List.of())); } diff --git a/bridged/src/test/java/dev/ltms/bridged/auth/ArchitectRegistryTest.java b/bridged/src/test/java/dev/ltms/bridged/auth/ArchitectRegistryTest.java new file mode 100644 index 0000000..1b642c2 --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/auth/ArchitectRegistryTest.java @@ -0,0 +1,238 @@ +package dev.ltms.bridged.auth; + +import dev.ltms.bridged.config.BridgedConfig; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * CB-548 — the architect-slot registry: the config snapshot of slot → profile, and the live + * terminal → slot bindings it owns. The role a binding produces is asserted in + * {@link CallerResolverTest}; this pins the registry object itself — its invariants and their + * thread-safety. + */ +class ArchitectRegistryTest { + + private static final Map SLOTS = Map.of( + "lead-designer", new BridgedConfig.Architect("sonnet"), + "reviewer", new BridgedConfig.Architect("gx10")); + + private final ArchitectRegistry registry = new ArchitectRegistry(SLOTS); + + @Test + void exposesTheConfiguredSlots() { + assertEquals(SLOTS.keySet(), registry.slots().keySet()); + assertTrue(registry.isSlot("reviewer")); + assertFalse(registry.isSlot("nope")); + } + + @Test + void theSpawnLifecycleReadsTheProfileBackFromASlot() { + assertEquals("sonnet", registry.profileForSlot("lead-designer")); + assertEquals("gx10", registry.profileForSlot("reviewer")); + assertNull(registry.profileForSlot("unknown"), "an unknown slot has no profile"); + } + + @Test + void startsEmptySoNoTerminalResolvesToAnArchitect() { + assertTrue(registry.snapshot().isEmpty()); + assertNull(registry.slotForTerminal("term_design"), + "config declares no architect terminal — nothing is recognised until a bind"); + assertNull(registry.slotForTerminal(null), "no terminal ⇒ no slot"); + } + + // ── bind ────────────────────────────────────────────────────────────────────────────────── + + @Test + void bindResolvesTheTerminalToTheSlot() { + assertTrue(registry.bind("lead-designer", "term_design")); + assertEquals("lead-designer", registry.slotForTerminal("term_design")); + assertEquals(Map.of("term_design", "lead-designer"), registry.snapshot()); + } + + @Test + void bindRefusesAnUnknownSlot() { + assertFalse(registry.bind("nope", "term_x"), + "a slot that is not configured must be refused — bind is not a way to invent one"); + assertNull(registry.slotForTerminal("term_x")); + } + + @Test + void bindRefusesATerminalInTwoSlots() { + assertTrue(registry.bind("lead-designer", "term_design")); + assertFalse(registry.bind("reviewer", "term_design"), + "a terminal may occupy at most one slot"); + assertEquals("lead-designer", registry.slotForTerminal("term_design"), + "the first binding survives the refused second"); + } + + @Test + void bindRefusesASlotWithTwoTerminals() { + assertTrue(registry.bind("lead-designer", "term_design")); + assertFalse(registry.bind("lead-designer", "term_other"), + "a slot may host at most one terminal"); + assertEquals("lead-designer", registry.slotForTerminal("term_design"), + "the first binding survives the refused second"); + assertNull(registry.slotForTerminal("term_other")); + } + + @Test + void rebindingTheSamePairIsAnIdempotentNoOp() { + assertTrue(registry.bind("lead-designer", "term_design")); + assertTrue(registry.bind("lead-designer", "term_design"), + "the same terminal → slot is harmless to repeat"); + assertEquals(1, registry.snapshot().size()); + } + + // ── unbind ──────────────────────────────────────────────────────────────────────────────── + + @Test + void unbindRemovesTheExactBinding() { + assertTrue(registry.bind("lead-designer", "term_design")); + assertTrue(registry.unbind("lead-designer", "term_design")); + assertNull(registry.slotForTerminal("term_design")); + assertTrue(registry.snapshot().isEmpty()); + } + + @Test + void aStaleUnbindDoesNotRemoveAReplacement() { + // Bind, tear down, and stand the slot back up with a NEW terminal. + assertTrue(registry.bind("lead-designer", "term_design")); + registry.unbind("lead-designer", "term_design"); + assertTrue(registry.bind("lead-designer", "term_new")); + + // A late unbind naming the OLD terminal must not remove the replacement binding. + assertFalse(registry.unbind("lead-designer", "term_design")); + assertEquals("lead-designer", registry.slotForTerminal("term_new"), + "the replacement terminal stays bound"); + } + + @Test + void aStaleUnbindForATerminalThatMovedSlotsDoesNothing() { + // term_design starts in lead-designer, is torn down, and stands back up in a FREE slot. + assertTrue(registry.bind("lead-designer", "term_design")); + registry.unbind("lead-designer", "term_design"); + assertTrue(registry.bind("reviewer", "term_design")); + + // Unbinding against the slot it no longer occupies is refused; the new binding is intact. + assertFalse(registry.unbind("lead-designer", "term_design"), + "the old slot must not unbind a terminal that moved elsewhere"); + assertEquals("reviewer", registry.slotForTerminal("term_design")); + } + + @Test + void unbindOfNothingIsAFalseNoOp() { + assertFalse(registry.unbind("lead-designer", "term_design"), + "nothing was bound, so nothing is removed"); + } + + // ── snapshot ───────────────────────────────────────────────────────────────────────────── + + @Test + void theSnapshotIsAnImmutableCopyNotAliveState() { + assertTrue(registry.bind("lead-designer", "term_design")); + Map snap = registry.snapshot(); + + assertThrows(UnsupportedOperationException.class, () -> snap.put("x", "y"), + "a handed-out snapshot cannot be mutated in place"); + + // Later binds must not leak into an earlier snapshot. + assertTrue(registry.bind("reviewer", "term_review")); + assertFalse(snap.containsKey("term_review"), + "a snapshot is a point-in-time copy, not a live view"); + } + + // ── concurrency (CB-548 invariants hold under contention) ───────────────────────────────── + + @Test + void concurrentBindsNeverGiveASlotTwoTerminals() throws Exception { + int n = 16; + ExecutorService pool = Executors.newFixedThreadPool(n); + try { + CountDownLatch go = new CountDownLatch(1); + List> results = new ArrayList<>(); + for (int i = 0; i < n; i++) { + final String term = "term_" + i; // every thread races for the SAME slot + results.add(pool.submit(() -> { + go.await(); + return registry.bind("lead-designer", term); + })); + } + go.countDown(); + + int won = 0; + for (Future r : results) { + if (r.get()) { + won++; + } + } + assertEquals(1, won, "exactly one terminal may win the sole slot, got " + won); + assertEquals(1, registry.snapshot().size(), + "the slot hosts at most one terminal after the race"); + } finally { + pool.shutdownNow(); + } + } + + @Test + void concurrentBindsNeverPutOneTerminalInTwoSlots() throws Exception { + int n = 16; + ExecutorService pool = Executors.newFixedThreadPool(n); + try { + CountDownLatch go = new CountDownLatch(1); + List> results = new ArrayList<>(); + for (int i = 0; i < n; i++) { + final String slot = (i % 2 == 0) ? "lead-designer" : "reviewer"; // all race for ONE terminal + results.add(pool.submit(() -> { + go.await(); + return registry.bind(slot, "shared_term") + ? registry.slotForTerminal("shared_term") : null; + })); + } + go.countDown(); + + // Rebinding the same terminal to the same slot is a harmless idempotent true, so count + // winners is not the assertion — agreement is: every thread that reported success must + // have seen the terminal in the SAME slot, never in two at once. + String bound = null; + boolean conflict = false; + for (Future r : results) { + String s = r.get(); + if (s != null) { + if (bound == null) { + bound = s; + } else if (!bound.equals(s)) { + conflict = true; + } + } + } + assertFalse(conflict, "a terminal was observed in two slots at once"); + assertNotNull(bound, "at least one thread bound the terminal"); + assertEquals(1, registry.snapshot().size(), + "the terminal occupies exactly one slot in the final snapshot"); + assertEquals(bound, registry.slotForTerminal("shared_term")); + } finally { + pool.shutdownNow(); + } + } + + /** A handed-over slot map is snapshotted at construction, not offered as live state. */ + @Test + void theSlotSnapshotIsFixedByConstruction() { + Map mutable = new HashMap<>(SLOTS); + ArchitectRegistry r = new ArchitectRegistry(mutable); + + mutable.put("hijack", new BridgedConfig.Architect("gx10")); + + assertFalse(r.isSlot("hijack"), "a handed-over map is not offered as live state"); + } +} diff --git a/bridged/src/test/java/dev/ltms/bridged/auth/AuthzTest.java b/bridged/src/test/java/dev/ltms/bridged/auth/AuthzTest.java index 5b2b8c9..63cfd2c 100644 --- a/bridged/src/test/java/dev/ltms/bridged/auth/AuthzTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/auth/AuthzTest.java @@ -12,6 +12,8 @@ class AuthzTest { private static final Principal WORKER_A = Principal.worker("term_a", 200); private static final Principal WORKER_B = Principal.worker("term_b", 300); private static final Principal ANON = Principal.anonymous(); + private static final Principal ARCH_DESIGN = Principal.architect("lead-designer", "term_design", 400); + private static final Principal ARCH_OTHER = Principal.architect("reviewer", "term_review", 500); @Test void anonymousIsAuthorizedForNothing() { @@ -61,6 +63,48 @@ class AuthzTest { "an absent session id must not satisfy the own-session rule"); } + // ── CB-548: the architect matrix ─────────────────────────────────────────────────────────── + + @Test + void anArchitectMaySendButNotSpawnStopOrDrain() { + assertTrue(Authz.permits(ARCH_DESIGN, SEND, "term_worker"), + "delegating a turn to a worker IS the architect's job"); + assertTrue(Authz.permits(ARCH_DESIGN, SEND, null)); + + for (Authz.Action a : new Authz.Action[]{SPAWN, STOP, DRAIN}) { + assertFalse(Authz.permits(ARCH_DESIGN, a, null), + "an architect must not " + a + " — fleet lifecycle is the primary's alone, so " + + "a coordinator cannot also stand up or tear down the fleet"); + } + } + + @Test + void anArchitectMayReplyAndAskOnlyAsItsOwnPane() { + assertTrue(Authz.permits(ARCH_DESIGN, REPLY, "term_design"), "its own pane is its own"); + assertTrue(Authz.permits(ARCH_DESIGN, ASK, "term_design")); + + assertFalse(Authz.permits(ARCH_DESIGN, REPLY, "term_review"), + "architect 'lead-designer' must not reply on reviewer's pane"); + assertFalse(Authz.permits(ARCH_OTHER, ASK, "term_design"), + "reviewer must not ask as lead-designer — no terminal is another's"); + assertFalse(Authz.permits(ARCH_DESIGN, REPLY, null), + "an absent target must not pass the own-session rule"); + } + + @Test + void anArchitectMayReadAndScrapeMetrics() { + assertTrue(Authz.permits(ARCH_DESIGN, READ, null)); + assertTrue(Authz.permits(ARCH_DESIGN, METRICS, null)); + } + + @Test + void anArchitectIsNotCountedAsPrimaryOrWorker() { + assertFalse(Authz.permits(ARCH_DESIGN, SPAWN, null), "not a primary — no lifecycle"); + assertFalse(ARCH_DESIGN.isPrimary()); + assertFalse(ARCH_DESIGN.isWorker(), "an architect is its own role, not a widened worker"); + assertTrue(ARCH_DESIGN.isArchitect()); + } + @Test void observationIsOpenToBothAuthenticatedRoles() { assertTrue(Authz.permits(PRIMARY, READ, null)); diff --git a/bridged/src/test/java/dev/ltms/bridged/auth/CallerResolverTest.java b/bridged/src/test/java/dev/ltms/bridged/auth/CallerResolverTest.java index bea0417..46a79ed 100644 --- a/bridged/src/test/java/dev/ltms/bridged/auth/CallerResolverTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/auth/CallerResolverTest.java @@ -298,6 +298,97 @@ class CallerResolverTest { assertEquals(Role.WORKER, r.resolve("127.0.0.1", 42, null).role()); } + // ── CB-548: architect slots ───────────────────────────────────────────────────────────────── + + @Test + void aBoundArchitectPaneResolvesToArchitectBeforeTheWorkerFallback() { + Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null, + Map::of, () -> Map.of("term_a", "lead-designer")) + .resolve("127.0.0.1", 42, null); + + assertEquals(Role.ARCHITECT, p.role(), + "a terminal bound to an architect slot is an architect, NOT the generic worker it " + + "would otherwise resolve to"); + assertEquals("lead-designer", p.name(), "whoami must say WHICH slot is asking"); + assertEquals("term_a", p.terminal(), "the pane identity is carried so ownsSession works"); + } + + @Test + void anArchitectNeedsNoTokenEvenInTokenMode() { + Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), true, "s3cret", + Map::of, () -> Map.of("term_a", "lead-designer")) + .resolve("127.0.0.1", 42, null); + + assertEquals(Role.ARCHITECT, p.role(), + "the pane mapping is as unforgeable as a worker's — it outranks the token path"); + } + + @Test + void anUnboundPaneStillResolvesAsAWorker() { + Map arch = Map.of("term_elsewhere", "reviewer"); + Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null, + Map::of, () -> arch).resolve("127.0.0.1", 42, null); + + assertEquals(Role.WORKER, p.role()); + assertNull(p.name()); + } + + /** CB-548 precedence: lead > architect > worker, so a pane named in BOTH is still a lead. */ + @Test + void aLeadWinsOverAnArchitectBindingForTheSamePane() { + Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null, + () -> Map.of("term_a", "opus-5.0"), () -> Map.of("term_a", "lead-designer")) + .resolve("127.0.0.1", 42, null); + + assertEquals(Role.PRIMARY, p.role(), + "a pane the config calls a lead must keep resolving as a lead — no behaviour change " + + "when an architect binding is added to an existing fleet"); + assertEquals("opus-5.0", p.name()); + } + + /** The registry is live, like leads: a binding injected after construction is honoured. */ + @Test + void anArchitectBoundAfterConstructionIsHonouredWithoutRebuildingTheResolver() { + Map live = new java.util.HashMap<>(); + CallerResolver r = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null, + Map::of, () -> live); + + assertEquals(Role.WORKER, r.resolve("127.0.0.1", 42, null).role()); + + live.put("term_a", "lead-designer"); // the later lifecycle binds the slot + + assertEquals(Role.ARCHITECT, r.resolve("127.0.0.1", 42, null).role()); + assertEquals("lead-designer", r.architects().get("term_a")); + } + + @Test + void theArchitectMapFormIsCopiedSoLaterMutationCannotGrantArchitect() { + Map mutable = new java.util.LinkedHashMap<>(); + CallerResolver r = new CallerResolver(workerIdentity(), false, null, Map.of(), mutable); + + mutable.put("term_a", "sneaky"); + + assertEquals(Role.WORKER, r.resolve("127.0.0.1", 42, null).role()); + } + + @Test + void describeNamesTheArchitectSlot() { + assertEquals("architect:lead-designer", + Principal.architect("lead-designer", "term_a", 1).describe()); + } + + /** An architect acts only as its own pane — the same ownsSession rule as a worker or lead. */ + @Test + void anArchitectOwnsItsOwnPaneAndNoOther() { + Principal arch = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null, + Map::of, () -> Map.of("term_a", "lead-designer")).resolve("127.0.0.1", 42, null); + + assertTrue(arch.ownsSession("term_a")); + assertTrue(Authz.permits(arch, Authz.Action.REPLY, "term_a")); + assertFalse(arch.ownsSession("term_b")); + assertFalse(Authz.permits(arch, Authz.Action.REPLY, "term_b")); + } + @Test void tokenModeRequiresANonEmptyConfiguredToken() { ConnectionIdentity id = nonWorkerIdentity(); 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 b5bf45d..fb8e17c 100644 --- a/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java @@ -1,5 +1,6 @@ package dev.ltms.bridged.config; +import dev.ltms.bridged.auth.ArchitectRegistry; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -129,6 +130,7 @@ class BridgedConfigTest { broker: {} primary: {} leaders: {} + architects: {} leadScan: {} placement: fixed auth: {} @@ -326,6 +328,257 @@ class BridgedConfigTest { assertEquals(Map.of("term_real", "real"), BridgedConfig.load(f).leaderTerminals()); } + // ── CB-548: the architects registry ──────────────────────────────────────────────────────── + + @Test + void architectsBlockDeclaresSlotsByNameAndProfileOnly(@TempDir Path dir) throws Exception { + Path f = dir.resolve("architects.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + workers: + sonnet: + baseUrl: http://gx10.gw:8000 + architects: + lead-designer: + profile: sonnet + reviewer: + profile: sonnet + """); + + BridgedConfig cfg = BridgedConfig.load(f); + assertEquals(Set.of("lead-designer", "reviewer"), cfg.architects().keySet(), + "slot names are the keys — gateway-local unique by construction"); + assertEquals("sonnet", cfg.architects().get("lead-designer").profile(), + "each slot carries its strong-model profile reference"); + assertEquals("sonnet", cfg.architects().get("reviewer").profile()); + } + + @Test + void anArchitectCarriesNoConfigTerminalSoNothingIsRecognisedYet(@TempDir Path dir) throws Exception { + // The corrected CB-548 premise: config declares slots (name + profile) only. A `terminal:` + // key left over from the earlier premise is ignored — an architect is NOT recognised from + // config the way a lead is, so it binds nothing at startup and resolves no architect. + Path f = dir.resolve("arch-stale-terminal.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + workers: + sonnet: + baseUrl: http://gx10.gw:8000 + architects: + lead-designer: + terminal: term_design + profile: sonnet + """); + BridgedConfig cfg = BridgedConfig.load(f); + assertEquals("sonnet", cfg.architects().get("lead-designer").profile(), + "the profile is still read even when a stray terminal is ignored"); + + // The registry built from this config owns no bindings: the slot is idle at startup. + ArchitectRegistry r = new ArchitectRegistry(cfg.architects()); + assertTrue(r.snapshot().isEmpty()); + assertNull(r.slotForTerminal("term_design"), + "a config terminal must not resolve an architect — slots start idle"); + } + + @Test + void noArchitectsBlockLeavesNothingConfigured(@TempDir Path dir) throws Exception { + Path f = dir.resolve("no-arch.yaml"); + Files.writeString(f, "bind:\n port: 8080\n"); + + assertNull(BridgedConfig.load(f).architects(), + "no architects: block ⇒ no architect identity, exactly as before CB-548"); + } + + @Test + void anArchitectSlotMayResolveToTheSoleProfileWithoutPrivileging(@TempDir Path dir) throws Exception { + // Even a single unqualified worker profile can back an architect slot — the reference is + // by name, not by position, so an explicit name is required. + Path f = dir.resolve("arch-single.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + worker: + profile: ltms-local + baseUrl: http://gx10.gw:8000 + architects: + lead-designer: + profile: ltms-local + """); + + BridgedConfig cfg = BridgedConfig.load(f); + assertDoesNotThrow(cfg::validateArchitects); + assertEquals("ltms-local", cfg.architects().get("lead-designer").profile()); + } + + @Test + void anArchitectProfileThatIsNotConfiguredRefusesToStart(@TempDir Path dir) throws Exception { + Path f = dir.resolve("arch-bad-profile.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + workers: + gx10: + baseUrl: http://gx10.gw:8000 + architects: + lead-designer: + profile: sonnet + """); + BridgedConfig cfg = BridgedConfig.load(f); + + IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateArchitects); + assertTrue(e.getMessage().contains("lead-designer"), "the refusal names the slot"); + assertTrue(e.getMessage().contains("sonnet"), "the refusal names the offending profile"); + } + + @Test + void anArchitectSlotMissingAProfileRefusesToStart(@TempDir Path dir) throws Exception { + Path f = dir.resolve("arch-no-profile.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + workers: + gx10: + baseUrl: http://gx10.gw:8000 + architects: + lead-designer: + profile: "" + """); + BridgedConfig cfg = BridgedConfig.load(f); + + IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateArchitects); + assertTrue(e.getMessage().contains("lead-designer"), "the refusal names the slot"); + } + + @Test + void aValidArchitectRegistryPassesValidation(@TempDir Path dir) throws Exception { + Path f = dir.resolve("arch-ok.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + workers: + sonnet: + baseUrl: http://gx10.gw:8000 + gx10: + baseUrl: http://gx10.gw:8000 + architects: + lead-designer: + profile: sonnet + reviewer: + profile: gx10 + """); + + assertDoesNotThrow(() -> BridgedConfig.load(f).validateArchitects()); + } + + @Test + void absentArchitectsBlockPassesValidation(@TempDir Path dir) throws Exception { + Path f = dir.resolve("no-arch.yaml"); + Files.writeString(f, "bind:\n port: 8080\n"); + + assertDoesNotThrow(() -> BridgedConfig.load(f).validateArchitects()); + } + + @Test + void duplicateArchitectSlotNamesAreRejectedAtParseTime(@TempDir Path dir) throws Exception { + Path f = dir.resolve("arch-dup.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + workers: + sonnet: + baseUrl: http://gx10.gw:8000 + architects: + lead-designer: + profile: sonnet + lead-designer: + profile: sonnet + """); + + IllegalStateException e = + assertThrows(IllegalStateException.class, () -> BridgedConfig.load(f)); + assertTrue(e.getMessage().contains("lead-designer"), + "the refusal names the duplicated slot, was: " + e.getMessage()); + assertTrue(e.getMessage().contains("duplicate architect"), + "the refusal says the slot name is duplicated"); + } + + @Test + void duplicateKeysOutsideArchitectsAreUnaffected(@TempDir Path dir) throws Exception { + // The duplicate check is scoped to the architects block — a duplicate elsewhere is not this + // guard's concern and must not change parsing of the rest of the config. + Path f = dir.resolve("dup-other.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + workers: + sonnet: + baseUrl: http://gx10.gw:8000 + sonnet: + baseUrl: http://gx10.gw:8000 + """); + // Last-wins for a non-architect duplicate is untouched: only the architects block is walked. + assertEquals(Set.of("sonnet"), BridgedConfig.load(f).workerProfiles().keySet()); + } + + @Test + void aNestedArchitectsFieldDoesNotSuppressRealDuplicateDetection(@TempDir Path dir) throws Exception { + // A field ALSO named `architects` nested under another block carries its own duplicate and + // sits BEFORE the real top-level block. Only the top-level block is ever inspected: the + // refusal must name the real slot (lead-designer), not the nested one (nested-slot). + Path f = dir.resolve("nested-arch.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + architects: + nested-slot: + k: v + nested-slot: + k: v + architects: + lead-designer: + profile: sonnet + lead-designer: + profile: sonnet + """); + + IllegalStateException e = + assertThrows(IllegalStateException.class, () -> BridgedConfig.load(f)); + assertTrue(e.getMessage().contains("lead-designer"), + "the real top-level duplicate must be reported, was: " + e.getMessage()); + assertTrue(e.getMessage().contains("duplicate architect"), + "the refusal says the slot name is duplicated"); + } + + @Test + void nestedDuplicateFieldsInsideASlotAreNotDuplicateSlotNames(@TempDir Path dir) throws Exception { + // A duplicated field nested inside one slot's own value (here inside an ignored `extra:` + // sub-block) is not a duplicate SLOT name — it must not be rejected as one. Only the direct + // child keys of the architects mapping are slot names; whatever is deeper is the slot's + // business and must not masquerade as a duplicate slot. + Path f = dir.resolve("nested-dup-inside-slot.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + workers: + sonnet: + baseUrl: http://gx10.gw:8000 + architects: + lead-designer: + profile: sonnet + extra: + a: 1 + a: 1 + """); + + BridgedConfig cfg = assertDoesNotThrow(() -> BridgedConfig.load(f)); + assertDoesNotThrow(cfg::validateArchitects, + "a nested duplicate inside a slot is not a duplicate slot and must not refuse startup"); + assertEquals(Set.of("lead-designer"), cfg.architects().keySet()); + assertEquals("sonnet", cfg.architects().get("lead-designer").profile()); + } + @Test void absentBrokerBlockLeavesInboxSoftState(@TempDir Path dir) throws Exception { Path f = dir.resolve("no-broker.yaml"); diff --git a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpAuthzTest.java b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpAuthzTest.java index 5dc9a6d..1f53a04 100644 --- a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpAuthzTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpAuthzTest.java @@ -77,6 +77,7 @@ class BridgeMcpAuthzTest { private static final Principal PRIMARY = Principal.primary(100); private static final Principal WORKER_A = Principal.worker("term_a", 200); private static final Principal ANON = Principal.anonymous(); + private static final Principal ARCH_DESIGN = Principal.architect("lead-designer", "term_design", 400); // --- the table, enforced on THIS path too --------------------------------------------------- @@ -119,6 +120,33 @@ class BridgeMcpAuthzTest { assertNotNull(m.denyFor(PRIMARY, Authz.Action.ASK, "term_a")); } + // --- CB-548: the architect on this path ------------------------------------------------ + + @Test + void anArchitectMaySendAndReadButNotOrchestrateOverMcp() { + BridgeMcp m = mcp(true); + assertNull(m.denyFor(ARCH_DESIGN, Authz.Action.SEND, "term_a"), + "delegating a turn is the architect's job"); + assertNull(m.denyFor(ARCH_DESIGN, Authz.Action.READ, null)); + + for (Authz.Action a : new Authz.Action[]{Authz.Action.SPAWN, Authz.Action.STOP, + Authz.Action.DRAIN}) { + McpSchema.CallToolResult denied = m.denyFor(ARCH_DESIGN, a, null); + assertNotNull(denied, a + " must be refused to an architect"); + assertTrue(denied.isError(), "a refusal is returned as an MCP tool error"); + } + } + + @Test + void anArchitectMayReplyAndAskOnlyAsItsOwnPaneOverMcp() { + BridgeMcp m = mcp(true); + assertNull(m.denyFor(ARCH_DESIGN, Authz.Action.REPLY, "term_design")); + assertNull(m.denyFor(ARCH_DESIGN, Authz.Action.ASK, "term_design")); + + assertNotNull(m.denyFor(ARCH_DESIGN, Authz.Action.REPLY, "term_a"), + "architect 'lead-designer' must not reply on worker term_a's session"); + } + @Test void anonymousIsRefusedEverythingAndCountedAsUnauthenticated() { BridgeMcp m = mcp(true); @@ -156,6 +184,11 @@ class BridgeMcpAuthzTest { assertEquals("term_a", BridgeMcp.principalFrom("WORKER", "term_a", 7).terminal()); assertEquals(Role.PRIMARY, BridgeMcp.principalFrom("PRIMARY", null, 7).role()); assertEquals(Role.ANONYMOUS, BridgeMcp.principalFrom("ANONYMOUS", null, -1).role()); + // CB-548: an architect round-trips through the same stash, carrying its slot name. + Principal arch = BridgeMcp.principalFrom("ARCHITECT", "term_design", 7, "lead-designer"); + assertEquals(Role.ARCHITECT, arch.role()); + assertEquals("lead-designer", arch.name()); + assertEquals("term_design", arch.terminal()); } @Test diff --git a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java index 6e65fa5..5daccde 100644 --- a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java @@ -466,4 +466,22 @@ class BridgeMcpTest { assertTrue(out.contains("\"sessionId\":\"term_orphan\""), out); assertFalse(out.contains("profile"), out); // nothing invented for a session we don't track } + + /** + * CB-548: an architect reports its role and which gateway-local slot its pane is bound to — + * the same shape as a lead, under the architect key, so it can tell a peer where to reach it. + */ + @Test + void whoamiReportsAnArchitectWithItsSlotAndPane() { + FakeHerdr h = new FakeHerdr(); + McpSchema.CallToolResult res = BridgeMcp.whoami( + Principal.architect("lead-designer", "term_design", 400), + sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw"))); + + assertNotEquals(Boolean.TRUE, res.isError()); + String out = textOf(res); + assertTrue(out.contains("\"role\":\"architect\""), out); + assertTrue(out.contains("\"architect\":\"lead-designer\""), out); + assertTrue(out.contains("\"sessionId\":\"term_design\""), out); + } }