diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java index 29ea236..87be519 100644 --- a/bridged/src/main/java/dev/ltms/bridged/Bridged.java +++ b/bridged/src/main/java/dev/ltms/bridged/Bridged.java @@ -243,6 +243,7 @@ public final class Bridged { // what CallerResolver resolves against and what that lifecycle will read profiles from; // nothing here spawns a slot. MemberRegistry members = new MemberRegistry(cfg.fleet()); + sessions.setMemberLifecycle(members); if (!members.slots().isEmpty()) { log.info("member slots: {} configured {} — none bound yet (a slot is idle until the " + "spawn lifecycle binds a live terminal to it)", @@ -375,13 +376,11 @@ 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.withLeadsAndMembers(identity, true, token, leads, - members::snapshot); + callers = CallerResolver.withLeadsAndMembers(identity, true, token, leads, members); log.info("auth: token mode (bearer required for non-worker callers, env {})", cfg.auth().tokenEnv()); } else { - callers = CallerResolver.withLeadsAndMembers(identity, false, null, leads, - members::snapshot); + callers = CallerResolver.withLeadsAndMembers(identity, false, null, leads, members); log.info("auth: loopback-trust (any loopback non-worker caller is the primary)"); } 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 b55382c..833151a 100644 --- a/bridged/src/main/java/dev/ltms/bridged/auth/CallerResolver.java +++ b/bridged/src/main/java/dev/ltms/bridged/auth/CallerResolver.java @@ -1,10 +1,12 @@ package dev.ltms.bridged.auth; import dev.ltms.bridged.mcp.ConnectionIdentity; +import dev.ltms.bridged.peer.MemberRole; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.Map; +import java.util.function.Function; import java.util.function.Supplier; /** @@ -60,6 +62,8 @@ public final class CallerResolver { * in {@code Bridged} reads a constant from config, which is the degenerate live case. */ private final Supplier> architectTerminals; + private final Function memberSlotRoles; + private final Function memberSlotNames; /** Loopback-trust resolver: no token required, historical behaviour. */ public CallerResolver(ConnectionIdentity identity) { @@ -137,12 +141,23 @@ public final class CallerResolver { * unambiguous. */ public static CallerResolver withLeadsAndMembers(ConnectionIdentity identity, - boolean tokenMode, String token, - Supplier> leadTerminals, - Supplier> architectTerminals) { + boolean tokenMode, String token, + Supplier> leadTerminals, + Supplier> architectTerminals) { return new CallerResolver(identity, tokenMode, token, leadTerminals, architectTerminals); } + /** Live registry form that can confirm a bound slot is an architect slot. */ + public static CallerResolver withLeadsAndMembers(ConnectionIdentity identity, + boolean tokenMode, String token, + Supplier> leadTerminals, + MemberRegistry members) { + return new CallerResolver(identity, tokenMode, token, leadTerminals, + members == null ? null : members::snapshot, + members == null ? null : members::roleForSlot, + members == null ? null : members::nameForSlot); + } + private static Supplier> fixed(Map leadTerminals) { Map snapshot = leadTerminals == null ? Map.of() : Map.copyOf(leadTerminals); return () -> snapshot; @@ -151,6 +166,21 @@ public final class CallerResolver { private CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token, Supplier> leadTerminals, Supplier> architectTerminals) { + this(identity, tokenMode, token, leadTerminals, architectTerminals, null); + } + + private CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token, + Supplier> leadTerminals, + Supplier> architectTerminals, + Function memberSlotRoles) { + this(identity, tokenMode, token, leadTerminals, architectTerminals, memberSlotRoles, null); + } + + private CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token, + Supplier> leadTerminals, + Supplier> architectTerminals, + Function memberSlotRoles, + Function memberSlotNames) { if (tokenMode && (token == null || token.isBlank())) { throw new IllegalArgumentException( "auth.mode=token requires a non-empty token; check that the env var named by " @@ -161,6 +191,8 @@ public final class CallerResolver { this.expectedToken = tokenMode ? token.getBytes(StandardCharsets.UTF_8) : null; this.leadTerminals = leadTerminals == null ? Map::of : leadTerminals; this.architectTerminals = architectTerminals == null ? Map::of : architectTerminals; + this.memberSlotRoles = memberSlotRoles == null ? _ -> null : memberSlotRoles; + this.memberSlotNames = memberSlotNames == null ? Function.identity() : memberSlotNames; } /** @@ -206,11 +238,12 @@ public final class CallerResolver { return Principal.leader(lead, c.terminal(), c.pid()); } String slot = architectTerminals.get().get(c.terminal()); - if (slot != null) { + if (slot != null && memberSlotRoles.apply(slot) == MemberRole.ARCHITECT) { // 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()); + // Check the slot role too: this defence in depth prevents a bad lifecycle bind from + // escalating a dev or reviewer into an architect. Checked before the worker fallback. + return Principal.architect(memberSlotNames.apply(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/MemberLifecycle.java b/bridged/src/main/java/dev/ltms/bridged/auth/MemberLifecycle.java new file mode 100644 index 0000000..d4a9636 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/auth/MemberLifecycle.java @@ -0,0 +1,21 @@ +package dev.ltms.bridged.auth; + +import dev.ltms.bridged.peer.MemberRole; + +/** Optional session lifecycle hook for live member-slot bindings. */ +public interface MemberLifecycle { + + MemberLifecycle NONE = new MemberLifecycle() { + @Override + public void acquired(MemberRole role, String profile, String terminal) { + } + + @Override + public void released(String terminal) { + } + }; + + void acquired(MemberRole role, String profile, String terminal); + + void released(String terminal); +} diff --git a/bridged/src/main/java/dev/ltms/bridged/auth/MemberRegistry.java b/bridged/src/main/java/dev/ltms/bridged/auth/MemberRegistry.java index 2fcdf56..c6384d1 100644 --- a/bridged/src/main/java/dev/ltms/bridged/auth/MemberRegistry.java +++ b/bridged/src/main/java/dev/ltms/bridged/auth/MemberRegistry.java @@ -2,11 +2,14 @@ package dev.ltms.bridged.auth; import dev.ltms.bridged.config.BridgedConfig; import dev.ltms.bridged.peer.MemberRole; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Objects; /** * The architect-slot registry (CB-548): every gateway-local architect name and the strong-model @@ -29,7 +32,9 @@ import java.util.Map; * 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 MemberRegistry { +public final class MemberRegistry implements MemberLifecycle { + + private static final Logger log = LoggerFactory.getLogger(MemberRegistry.class); /** * One flattened {@code fleet:} entry. @@ -125,6 +130,12 @@ public final class MemberRegistry { return e == null ? null : e.role(); } + /** The unqualified configured name for a slot, or {@code null} if it is unknown. */ + public String nameForSlot(String slotName) { + Entry e = slots.get(slotName); + return e == null ? null : e.name(); + } + /** True when {@code slotName} is a configured architect slot. */ public boolean isSlot(String slotName) { return slots.containsKey(slotName); @@ -189,4 +200,33 @@ public final class MemberRegistry { return true; } } + + /** + * Bind only architect sessions to a free slot with the resolved profile. + * + *

The role check is lifecycle policy. {@link CallerResolver} repeats it when resolving a + * binding, so a later lifecycle regression cannot turn a worker into an architect. + */ + @Override + public void acquired(MemberRole role, String profile, String terminal) { + if (role != MemberRole.ARCHITECT || terminal == null || terminal.isBlank()) { + return; + } + // slotsFor preserves definition order, so duplicate-profile slots use the first free one. + for (Entry entry : slotsFor(MemberRole.ARCHITECT).values()) { + if (Objects.equals(profile, entry.profile()) && bind(entry.key(), terminal)) { + return; + } + } + log.info("member slot: no free architect slot for profile={}; session remains a worker", profile); + } + + /** Unbind a released terminal using the compare-safe registry operation. */ + @Override + public void released(String terminal) { + String slot = slotForTerminal(terminal); + if (slot != null) { + unbind(slot, terminal); + } + } } diff --git a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java index 99a8947..38b9e54 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java @@ -1,5 +1,6 @@ package dev.ltms.bridged.session; +import dev.ltms.bridged.auth.MemberLifecycle; import dev.ltms.bridged.herdr.Agent; import dev.ltms.bridged.inject.TurnListener; import dev.ltms.bridged.inject.MemberPresence; @@ -49,6 +50,7 @@ public final class SessionManager implements TurnListener { private final LongSupplier nowNanos; private final int contextCap; private final boolean clearAfterTurn; + private volatile MemberLifecycle memberLifecycle = MemberLifecycle.NONE; /** CB-520: notified with a terminalId on every acquire; no-op until wired. */ private final List> acquireListeners = new java.util.concurrent.CopyOnWriteArrayList<>(); @@ -157,6 +159,7 @@ public final class SessionManager implements TurnListener { null, null); registry.put(handle.id(), session); + memberLifecycle.acquired(session.role(), session.profile(), session.terminalId()); log.debug("acquired session id={} terminal={} profile={} owner={}", handle.id(), handle.terminalId(), session.profile(), session.ownerTerminal()); notifyAcquired(session.terminalId()); @@ -187,6 +190,7 @@ public final class SessionManager implements TurnListener { MemberSession removed = registry.remove(paneId); boolean preserveWorktree = cause == ReleaseCause.SHUTDOWN; if (removed != null) { + memberLifecycle.released(removed.terminalId()); log.debug("releasing session pane={} terminal={} state={} cause={}", removed.paneId(), removed.terminalId(), removed.state(), cause); if (preserveWorktree && removed.worktree() != null) { @@ -257,6 +261,11 @@ public final class SessionManager implements TurnListener { } } + /** Inject the optional member-slot lifecycle after construction without changing constructors. */ + public void setMemberLifecycle(MemberLifecycle memberLifecycle) { + this.memberLifecycle = memberLifecycle == null ? MemberLifecycle.NONE : memberLifecycle; + } + /** A listener failure must never prevent the acquisition it is reacting to. */ private void notifyAcquired(String terminalId) { if (terminalId == null) { @@ -330,6 +339,7 @@ public final class SessionManager implements TurnListener { path, branch); registry.put(handle.id(), session); + memberLifecycle.acquired(session.role(), session.profile(), session.terminalId()); log.debug("acquired worktree session id={} terminal={} profile={} branch={} path={}", handle.id(), handle.terminalId(), session.profile(), session.branch(), session.worktree()); notifyAcquired(session.terminalId()); 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 d3bca5a..6597548 100644 --- a/bridged/src/test/java/dev/ltms/bridged/auth/CallerResolverTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/auth/CallerResolverTest.java @@ -3,6 +3,8 @@ package dev.ltms.bridged.auth; import dev.ltms.bridged.herdr.FakeHerdr; import dev.ltms.bridged.herdr.PaneLocator; import dev.ltms.bridged.mcp.ConnectionIdentity; +import dev.ltms.bridged.config.BridgedConfig; +import dev.ltms.bridged.peer.MemberRole; import org.junit.jupiter.api.Test; import java.util.Map; @@ -32,6 +34,17 @@ class CallerResolverTest { return identity(999_999); } + private static MemberRegistry boundMembers(String slot, MemberRole role) { + Map architects = role == MemberRole.ARCHITECT + ? Map.of("lead-designer", new BridgedConfig.Slot("sonnet")) : Map.of(); + Map devs = role == MemberRole.DEV + ? Map.of("builder", new BridgedConfig.Slot("sonnet")) : Map.of(); + MemberRegistry members = new MemberRegistry( + new BridgedConfig.Fleet(Map.of(), architects, devs, Map.of(), null)); + assertTrue(members.bind(slot, "term_a")); + return members; + } + @Test void aLoopbackWorkerPaneResolvesToWorkerRegardlessOfAuthMode() { Principal underTrust = new CallerResolver(workerIdentity()).resolve("127.0.0.1", 42, null); @@ -303,7 +316,7 @@ class CallerResolverTest { @Test void aBoundArchitectPaneResolvesToArchitectBeforeTheWorkerFallback() { Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, - Map::of, () -> Map.of("term_a", "lead-designer")) + Map::of, boundMembers("architect:lead-designer", MemberRole.ARCHITECT)) .resolve("127.0.0.1", 42, null); assertEquals(Role.ARCHITECT, p.role(), @@ -316,7 +329,7 @@ class CallerResolverTest { @Test void anArchitectNeedsNoTokenEvenInTokenMode() { Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), true, "s3cret", - Map::of, () -> Map.of("term_a", "lead-designer")) + Map::of, boundMembers("architect:lead-designer", MemberRole.ARCHITECT)) .resolve("127.0.0.1", 42, null); assertEquals(Role.ARCHITECT, p.role(), @@ -337,7 +350,8 @@ class CallerResolverTest { @Test void aLeadWinsOverAnArchitectBindingForTheSamePane() { Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, - () -> Map.of("term_a", "opus-5.0"), () -> Map.of("term_a", "lead-designer")) + () -> Map.of("term_a", "opus-5.0"), + boundMembers("architect:lead-designer", MemberRole.ARCHITECT)) .resolve("127.0.0.1", 42, null); assertEquals(Role.PRIMARY, p.role(), @@ -349,16 +363,17 @@ class CallerResolverTest { /** The registry is live, like leads: a binding injected after construction is honoured. */ @Test void anArchitectBoundAfterConstructionIsHonouredWithoutRebuildingTheResolver() { - Map live = new java.util.HashMap<>(); + MemberRegistry members = new MemberRegistry(new BridgedConfig.Fleet(Map.of(), + Map.of("lead-designer", new BridgedConfig.Slot("sonnet")), Map.of(), Map.of(), null)); CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, - Map::of, () -> live); + Map::of, members); assertEquals(Role.WORKER, r.resolve("127.0.0.1", 42, null).role()); - live.put("term_a", "lead-designer"); // the later lifecycle binds the slot + assertTrue(members.bind("architect:lead-designer", "term_a")); // the later lifecycle binds the slot assertEquals(Role.ARCHITECT, r.resolve("127.0.0.1", 42, null).role()); - assertEquals("lead-designer", r.members().get("term_a")); + assertEquals("architect:lead-designer", r.members().get("term_a")); } @Test @@ -381,7 +396,8 @@ class CallerResolverTest { @Test void anArchitectOwnsItsOwnPaneAndNoOther() { Principal arch = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, - Map::of, () -> Map.of("term_a", "lead-designer")).resolve("127.0.0.1", 42, null); + Map::of, boundMembers("architect:lead-designer", MemberRole.ARCHITECT)) + .resolve("127.0.0.1", 42, null); assertTrue(arch.ownsSession("term_a")); assertTrue(Authz.permits(arch, Authz.Action.REPLY, "term_a")); @@ -389,6 +405,15 @@ class CallerResolverTest { assertFalse(Authz.permits(arch, Authz.Action.REPLY, "term_b")); } + @Test + void aBoundNonArchitectSlotStillResolvesAsAWorker() { + Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, + Map::of, boundMembers("dev:builder", MemberRole.DEV)) + .resolve("127.0.0.1", 42, null); + + assertEquals(Role.WORKER, p.role(), "a dev binding must never grant architect rights"); + } + @Test void tokenModeRequiresANonEmptyConfiguredToken() { ConnectionIdentity id = nonWorkerIdentity(); diff --git a/bridged/src/test/java/dev/ltms/bridged/session/WorktreeSessionManagerTest.java b/bridged/src/test/java/dev/ltms/bridged/session/WorktreeSessionManagerTest.java index 421b968..45eb6f8 100644 --- a/bridged/src/test/java/dev/ltms/bridged/session/WorktreeSessionManagerTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/session/WorktreeSessionManagerTest.java @@ -1,11 +1,13 @@ package dev.ltms.bridged.session; +import dev.ltms.bridged.auth.MemberRegistry; import dev.ltms.bridged.config.BridgedConfig; import dev.ltms.bridged.guard.SubscriptionGuard; import dev.ltms.bridged.herdr.AgentControl; import dev.ltms.bridged.herdr.FakeHerdr; import dev.ltms.bridged.herdr.WorkspaceControl; import dev.ltms.bridged.member.ClaudeCodeLauncher; +import dev.ltms.bridged.peer.MemberRole; import org.junit.jupiter.api.Test; import java.util.List; @@ -22,6 +24,13 @@ import static org.junit.jupiter.api.Assertions.*; */ class WorktreeSessionManagerTest { + private static MemberRegistry members() { + return new MemberRegistry(new BridgedConfig.Fleet(Map.of(), + Map.of("architect", new BridgedConfig.Slot("ltms-local")), + Map.of("dev", new BridgedConfig.Slot("ltms-local")), + Map.of("reviewer", new BridgedConfig.Slot("ltms-local")), null)); + } + private static ClaudeCodeLauncher workerService(FakeHerdr herdr) { BridgedConfig.Profile cfg = new BridgedConfig.Profile( "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", @@ -56,6 +65,36 @@ class WorktreeSessionManagerTest { assertEquals("/caller/proj", startCwd(herdr), "spawn receives the caller's cwd"); } + @Test + void onlyArchitectsBindAndReleaseMakesTheirSlotReusable() { + FakeHerdr herdr = new FakeHerdr(); + MemberRegistry members = members(); + SessionManager sessions = new SessionManager(workerService(herdr), new FakeWorktrees()); + sessions.setMemberLifecycle(members); + + MemberSession architect = sessions.acquire("ltms-local", MemberRole.ARCHITECT, + null, "/caller/proj", null, null); + MemberSession dev = sessions.acquire("ltms-local", MemberRole.DEV, + null, "/caller/proj", null, null); + MemberSession reviewer = sessions.acquire("ltms-local", MemberRole.REVIEWER, + null, "/caller/proj", null, null); + + assertEquals("architect:architect", members.slotForTerminal(architect.terminalId())); + assertNull(members.slotForTerminal(dev.terminalId()), "a dev must never receive architect rights"); + assertNull(members.slotForTerminal(reviewer.terminalId()), + "a reviewer must never receive architect rights"); + + sessions.release(architect.paneId()); + MemberSession replacement = sessions.acquire("ltms-local", MemberRole.ARCHITECT, + null, "/caller/proj", null, null); + assertEquals("architect:architect", members.slotForTerminal(replacement.terminalId())); + + MemberSession overflow = sessions.acquire("ltms-local", MemberRole.ARCHITECT, + null, "/caller/proj", null, null); + assertNull(members.slotForTerminal(overflow.terminalId()), + "a full slot pool must not stop the architect spawn"); + } + @Test void worktreeAcquireProvisionsAndRecordsPathAndBranch() { FakeHerdr herdr = new FakeHerdr(); @@ -79,6 +118,19 @@ class WorktreeSessionManagerTest { assertEquals(expectedPath, s.cwd(), "session cwd is the worktree path"); } + @Test + void worktreeArchitectAcquireAlsoBindsItsSlot() { + FakeHerdr herdr = new FakeHerdr(); + MemberRegistry members = members(); + SessionManager sessions = new SessionManager(workerService(herdr), new FakeWorktrees()); + sessions.setMemberLifecycle(members); + + MemberSession architect = sessions.acquire("ltms-local", MemberRole.ARCHITECT, + null, "/caller/proj", null, new WorktreeRequest("cb-548", null)); + + assertEquals("architect:architect", members.slotForTerminal(architect.terminalId())); + } + @Test void worktreeAcquireRunsParityOverlayWithProfileDefaults() { FakeHerdr herdr = new FakeHerdr();