CB-548: config-declared architect slots + Role.ARCHITECT authz
This commit is contained in:
@@ -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. Slots live in config (name → strong-model
|
||||
// profile); the terminal → slot binding is the live half, sourced from the slots' declared
|
||||
// terminals today and swapped for a live binding by the later spawn lifecycle. 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(),
|
||||
() -> cfg.architectTerminals());
|
||||
if (!architects.slots().isEmpty()) {
|
||||
log.info("architect slots: {} configured {}, terminals {}", architects.slots().size(),
|
||||
architects.slots().keySet(), cfg.architectTerminals().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::terminalBindings);
|
||||
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::terminalBindings);
|
||||
log.info("auth: loopback-trust (any loopback non-worker caller is the primary)");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package dev.ltms.bridged.auth;
|
||||
|
||||
import dev.ltms.bridged.config.BridgedConfig;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* The architect-slot registry (CB-548): every gateway-local architect name and the strong-model
|
||||
* profile it points at, plus the live binding from a live architect's herdr terminal to its slot.
|
||||
*
|
||||
* <p>Two halves, split by who owns each:
|
||||
* <ul>
|
||||
* <li><b>slots</b> — configured once, keyed by the gateway-local unique name; each carries the
|
||||
* {@code profile} reference the <em>future</em> spawn lifecycle will read when it stands the
|
||||
* slot up. A read-only snapshot taken at construction.</li>
|
||||
* <li><b>terminal bindings</b> — a {@link Supplier} consulted on every read, so a binding
|
||||
* injected <em>after</em> startup (an operator pin, or the later lifecycle once it spawns a
|
||||
* session) takes effect without a restart. {@link CallerResolver} reads this to turn a pane
|
||||
* into an {@link Role#ARCHITECT}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Spawning/lifecycle is deliberately a separate unit: this class only exposes the map the
|
||||
* resolver resolves against and the profile lookup that lifecycle will call. Nothing here
|
||||
* creates or manages an architect session.
|
||||
*/
|
||||
public final class ArchitectRegistry {
|
||||
|
||||
private final Map<String, BridgedConfig.Architect> slots;
|
||||
private final Supplier<Map<String, String>> terminalBindings;
|
||||
|
||||
public ArchitectRegistry(Map<String, BridgedConfig.Architect> slots,
|
||||
Supplier<Map<String, String>> terminalBindings) {
|
||||
this.slots = slots == null ? Map.of() : Map.copyOf(slots);
|
||||
this.terminalBindings = terminalBindings == null ? Map::of : terminalBindings;
|
||||
}
|
||||
|
||||
/** The configured slots, keyed by gateway-local unique name. Unmodifiable snapshot. */
|
||||
public Map<String, BridgedConfig.Architect> slots() {
|
||||
return slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* The live {@code terminal_id → slot name} bindings, re-read on every call.
|
||||
*
|
||||
* <p>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.
|
||||
*/
|
||||
public Map<String, String> terminalBindings() {
|
||||
return terminalBindings.get();
|
||||
}
|
||||
|
||||
/** The slot a live terminal is bound to, or {@code null} if it is no architect slot. */
|
||||
public String slotForTerminal(String terminal) {
|
||||
return terminal == null ? null : terminalBindings.get().get(terminal);
|
||||
}
|
||||
|
||||
/**
|
||||
* The strong-model profile a slot runs under — what the future 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ import java.util.function.Supplier;
|
||||
* that pane as a lead's own — without this rule a lead running <em>inside</em> 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.</li>
|
||||
* <li>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 <em>live</em> terminal→slot binding (never a request argument), before
|
||||
* the generic worker fallback.</li>
|
||||
* <li>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.</li>
|
||||
@@ -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<Map<String, String>> leadTerminals;
|
||||
/**
|
||||
* terminal_id → architect slot name; empty when nothing is configured. CB-548.
|
||||
*
|
||||
* <p>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<Map<String, String>> 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<String, String> 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<String, String> leadTerminals,
|
||||
Map<String, String> 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<Map<String, String>> 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.
|
||||
*
|
||||
* <p>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<Map<String, String>> leadTerminals,
|
||||
Supplier<Map<String, String>> architectTerminals) {
|
||||
return new CallerResolver(identity, tokenMode, token, leadTerminals, architectTerminals);
|
||||
}
|
||||
|
||||
private static Supplier<Map<String, String>> fixed(Map<String, String> leadTerminals) {
|
||||
@@ -110,7 +149,8 @@ public final class CallerResolver {
|
||||
}
|
||||
|
||||
private CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token,
|
||||
Supplier<Map<String, String>> leadTerminals) {
|
||||
Supplier<Map<String, String>> leadTerminals,
|
||||
Supplier<Map<String, String>> 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).
|
||||
*
|
||||
* <p>Read from the same supplier {@link #resolve} consults, so a slot that is <em>listed</em>
|
||||
* here but would not <em>resolve</em> (or the reverse) cannot drift apart. Live for the same
|
||||
* reason as {@link #leads()}.
|
||||
*/
|
||||
public Map<String, String> 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>Carries {@link Role#ARCHITECT}. {@code slotName} is reporting only — it lets
|
||||
* {@code bridge_whoami} say <em>which</em> 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";
|
||||
};
|
||||
|
||||
@@ -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 <em>not</em> {@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
|
||||
}
|
||||
|
||||
@@ -43,6 +43,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, and the identity a live session is matched by is
|
||||
* its {@code terminal} binding (see {@link #architectTerminals()}). A slot is
|
||||
* the hook the future spawn lifecycle reads a profile back from — nothing here
|
||||
* spawns it.
|
||||
* @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 +70,7 @@ public record BridgedConfig(
|
||||
Broker broker,
|
||||
Primary primary,
|
||||
Map<String, Leader> leaders,
|
||||
Map<String, Architect> architects,
|
||||
LeadScan leadScan,
|
||||
String placement,
|
||||
Auth auth) {
|
||||
@@ -379,6 +385,32 @@ 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.
|
||||
*
|
||||
* <p>A lead and an architect differ in <em>authority</em>, not in how identity is established:
|
||||
* both are recognised by configuration rather than spawned. A lead resolves to
|
||||
* {@link dev.ltms.bridged.auth.Role#PRIMARY} and owns the whole lifecycle (spawn/stop/drain);
|
||||
* an architect resolves to {@link dev.ltms.bridged.auth.Role#ARCHITECT}, which delegates turns
|
||||
* ({@code SEND}) and replies/asks as its own pane but cannot stand up or tear down workers —
|
||||
* lifecycle stays in one pair of hands.
|
||||
*
|
||||
* <p>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 terminal the architect's herdr {@code terminal_id}; the field identity is matched by,
|
||||
* via the live terminal→slot binding. Optional at config time — binding may be
|
||||
* injected live — but a slot with no binding matches nothing yet.
|
||||
* @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 terminal, String profile) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover leads by tab label instead of by pasted {@code terminal_id} (CB-531).
|
||||
*
|
||||
@@ -436,6 +468,30 @@ public record BridgedConfig(
|
||||
return Collections.unmodifiableMap(byTerminal);
|
||||
}
|
||||
|
||||
/**
|
||||
* The terminal → architect-slot-name map that {@link dev.ltms.bridged.auth.CallerResolver}
|
||||
* resolves against (CB-548), derived from the {@code architects:} registry.
|
||||
*
|
||||
* <p>Keyed by terminal because a live session is matched by its pane; the value is the
|
||||
* gateway-local slot name. Slot names are inherently unique (a map key); a duplicate terminal
|
||||
* across two slots is last-wins here (the later entry overrides), which {@code leadership} has
|
||||
* always tolerated rather than refused. This is consumed as the <em>initial</em> live binding —
|
||||
* the supplier that feeds the resolver may be swapped for a live one by the future lifecycle.
|
||||
*
|
||||
* @return an unmodifiable map, empty when no architect slot is configured
|
||||
*/
|
||||
public Map<String, String> architectTerminals() {
|
||||
Map<String, String> byTerminal = new LinkedHashMap<>();
|
||||
if (architects != null) {
|
||||
architects.forEach((name, arch) -> {
|
||||
if (arch != null && arch.terminal() != null && !arch.terminal().isBlank()) {
|
||||
byTerminal.put(arch.terminal(), name);
|
||||
}
|
||||
});
|
||||
}
|
||||
return Collections.unmodifiableMap(byTerminal);
|
||||
}
|
||||
|
||||
/**
|
||||
* API authentication (CB-501). Governs how a caller that is <em>not</em> an on-host worker
|
||||
* pane proves it is the primary.
|
||||
@@ -539,7 +595,7 @@ public record BridgedConfig(
|
||||
private static final Set<String> 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) {
|
||||
@@ -615,7 +671,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 +778,46 @@ public record BridgedConfig(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an architect slot whose profile reference does not resolve (CB-548).
|
||||
*
|
||||
* <p>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 <em>does</em> 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.
|
||||
*
|
||||
* <p>Slot-name uniqueness needs no check here: the registry is a {@code Map} keyed by name, so
|
||||
* duplicates are unrepresentable by construction.
|
||||
*
|
||||
* @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<String, Worker> profiles = workerProfiles();
|
||||
List<String> 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()) {
|
||||
|
||||
@@ -507,6 +507,17 @@ public final class BridgeMcp {
|
||||
static McpSchema.CallToolResult whoami(Principal caller, SessionManager sessions) {
|
||||
Map<String, Object> 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()));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user