diff --git a/bridged/bridged.example.yaml b/bridged/bridged.example.yaml index fa4532d..080812c 100644 --- a/bridged/bridged.example.yaml +++ b/bridged/bridged.example.yaml @@ -206,6 +206,15 @@ guard: # the first orchestration-side MCP call (the normal case). An off-host or # non-herdr primary leaves this unresolved → the loop is a no-op and delivery # degrades to pull; the reply is still never lost. +# +# REQUIRED (CB-522) if the primary itself runs inside a herdr pane. Caller +# identity resolves a loopback PID to its herdr pane, and PaneLocator scans +# EVERY pane — not just bridged-spawned ones — so such a primary is otherwise +# classified as a WORKER and refused SPAWN/SEND/STOP. That failure is +# self-locking: the learned terminal is populated by the very orchestration +# calls being refused, so only this pinned value can break the cycle. Read the +# id off bridge_whoami (it reports the current terminal even while +# misclassified) and re-pin whenever the primary moves panes. # pushReminders → max nudges before giving up (default 5) # pushBackoffMs → delay between nudges in ms (default 15000) # primary: 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 366f011..deba8e2 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java @@ -401,7 +401,19 @@ public final class SessionManager implements TurnListener { return registry.size(); } + /** + * The registered session owning {@code terminalId}, or {@code null} if none does. + * + *
A null {@code terminalId} is a normal input, not a caller bug: every lifecycle hook here is + * fed from the MCP transport, where the primary resolves to a {@link + * dev.ltms.bridged.auth.Principal} with no terminal. {@code BridgeMcp} documents that contact as + * a no-op, and {@link dev.ltms.bridged.inject.WorkerPresence#markPresent} honours it — but + * {@code PresenceBridge} then forwards the same null here. Matching on a null id can never + * succeed anyway (a registered session always has a terminal), so answer "no match" rather than + * throwing: an NPE on this path takes down an unrelated tool call for the primary. + */ private WorkerSession findByTerminal(String terminalId) { + if (terminalId == null) return null; for (WorkerSession s : registry.values()) { if (terminalId.equals(s.terminalId())) return s; } 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 320d7ee..425ade3 100644 --- a/bridged/src/test/java/dev/ltms/bridged/auth/CallerResolverTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/auth/CallerResolverTest.java @@ -72,6 +72,15 @@ class CallerResolverTest { assertEquals("term_a", p.terminal()); } + /** The pin is optional config, so an absent or whitespace one must change nothing at all. */ + @Test + void aBlankPinLeavesWorkerResolutionUntouched() { + assertEquals(Role.WORKER, + new CallerResolver(workerIdentity(), false, null, " ").resolve("127.0.0.1", 42, null).role()); + assertEquals(Role.WORKER, + new CallerResolver(workerIdentity(), false, null, null).resolve("127.0.0.1", 42, null).role()); + } + @Test void loopbackTrustTreatsANonWorkerLoopbackCallerAsThePrimary() { Principal p = new CallerResolver(nonWorkerIdentity()).resolve("127.0.0.1", 99, null); diff --git a/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java b/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java index ef3defc..1c0bb79 100644 --- a/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java @@ -80,6 +80,25 @@ class SessionManagerTest { assertEquals(2, sessions.roster().size(), "both sessions are registered"); } + @Test + void aNullTerminalFromThePrimaryIsANoOpEvenWithSessionsRegistered() { + // The primary resolves to a Principal with no terminal, and BridgeMcp's context extractor + // forwards that null into markPresent on EVERY MCP call. It only reached the registry scan + // once a session existed, so this NPE'd the primary's second spawn while the first passed. + FakeHerdr herdr = new FakeHerdr(); + SessionManager sessions = sessionManager(herdr); + WorkerSession session = sessions.acquire("ltms-local", null, "/caller", "term_primary"); + + assertDoesNotThrow(() -> sessions.asPresence().markPresent(null), + "the primary's null terminal must not blow up an unrelated tool call"); + assertDoesNotThrow(() -> sessions.onDelivered(null)); + assertDoesNotThrow(() -> sessions.onTurnComplete(null)); + assertDoesNotThrow(() -> sessions.onTurnFailed(null)); + + assertEquals(WorkerSession.State.SPAWNING, sessions.get(session.paneId()).orElseThrow().state(), + "and must not transition any registered session"); + } + @Test void presenceMovesSpawningToReadyAndDeliveredTurnMovesToDone() { FakeHerdr herdr = new FakeHerdr();