diff --git a/bridged/bridged.example.yaml b/bridged/bridged.example.yaml index fae1a03..fa4532d 100644 --- a/bridged/bridged.example.yaml +++ b/bridged/bridged.example.yaml @@ -31,6 +31,17 @@ bind: # mode: token # tokenEnv: BRIDGED_API_TOKEN +# Optional pinned primary terminal (CB-307). Names the herdr pane the PRIMARY itself runs in: +# a caller whose connection maps to this pane resolves as the primary (no credential needed — +# the pane mapping is as unforgeable as a worker's), and reply nudges are pushed to it. +# REQUIRED when the primary runs inside a herdr pane — without it the pane match reads the +# primary as a worker and refuses spawn/send/stop. Get the id from bridge_whoami; re-pin if +# the primary moves panes. +# primary: +# terminal: term_0123456789abcd +# pushReminders: 5 # max nudges before giving up (default 5) +# pushBackoffMs: 15000 # delay between nudges (default 15000) + # herdr Unix socket. Omit to use the client default # (${HERDR_SOCKET_PATH:-~/.config/herdr/herdr.sock}). herdrSocket: ~/.config/herdr/herdr.sock diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java index 54094fb..418e804 100644 --- a/bridged/src/main/java/dev/ltms/bridged/Bridged.java +++ b/bridged/src/main/java/dev/ltms/bridged/Bridged.java @@ -191,8 +191,10 @@ public final class Bridged { log.info("reply inbox: in-memory (soft-state)"); } // CB-307: learn the primary's terminal from orchestration tool calls (or pin from config). - PrimaryRegistry primaryRegistry = new PrimaryRegistry( - cfg.primary() != null ? cfg.primary().terminal() : null); + // The pin also feeds CallerResolver below: a primary running inside a herdr pane would + // otherwise resolve as a worker and be refused every orchestration tool. + String pinnedPrimaryTerminal = cfg.primary() != null ? cfg.primary().terminal() : null; + PrimaryRegistry primaryRegistry = new PrimaryRegistry(pinnedPrimaryTerminal); // CB-307: active push-to-primary loop — nudge the primary when replies land without an // open bridge_send. Uses its own lightweight scheduled executor, separate from the injector. @@ -229,11 +231,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 = new CallerResolver(identity, true, token); + callers = new CallerResolver(identity, true, token, pinnedPrimaryTerminal); log.info("auth: token mode (bearer required for non-worker callers, env {})", cfg.auth().tokenEnv()); } else { - callers = new CallerResolver(identity); + callers = new CallerResolver(identity, false, null, pinnedPrimaryTerminal); 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 67156ad..e986c4f 100644 --- a/bridged/src/main/java/dev/ltms/bridged/auth/CallerResolver.java +++ b/bridged/src/main/java/dev/ltms/bridged/auth/CallerResolver.java @@ -15,7 +15,11 @@ import java.security.MessageDigest; * *

Resolution order — connection identity first, token second, nothing third: *

    - *
  1. A loopback peer PID that maps to a herdr worker pane ⇒ {@link Role#WORKER}. This is + *
  2. A loopback peer PID that maps to the pinned {@code primary.terminal} pane (CB-307) ⇒ + * {@link Role#PRIMARY}. The pane mapping is as unforgeable as a worker's, and the config + * explicitly names that pane as the primary's own — without this rule a primary running + * inside a herdr pane is misread as a worker and locked out of orchestration.
  3. + *
  4. 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.
  5. *
  6. Otherwise, under {@code token} mode, a valid bearer token ⇒ {@link Role#PRIMARY}.
  7. @@ -29,18 +33,29 @@ public final class CallerResolver { private final ConnectionIdentity identity; private final boolean tokenMode; private final byte[] expectedToken; // null unless tokenMode + private final String pinnedPrimaryTerminal; // null unless primary.terminal is configured /** Loopback-trust resolver: no token required, historical behaviour. */ public CallerResolver(ConnectionIdentity identity) { - this(identity, false, null); + this(identity, false, null, null); + } + + /** As {@link #CallerResolver(ConnectionIdentity, boolean, String, String)} with no pin. */ + public CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token) { + this(identity, tokenMode, token, null); } /** - * @param identity connection-based worker identification - * @param tokenMode when true, a non-worker caller must present a valid bearer token - * @param token the expected bearer token; required (non-blank) when {@code tokenMode} + * @param identity connection-based worker identification + * @param tokenMode when true, a non-worker caller must present a valid bearer token + * @param token the expected bearer token; required (non-blank) when + * {@code tokenMode} + * @param pinnedPrimaryTerminal the primary's own herdr {@code terminal_id} from + * {@code primary.terminal} ({@code null}/blank = unpinned); a + * caller resolving to this pane is the primary, not a worker */ - public CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token) { + public CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token, + String pinnedPrimaryTerminal) { if (tokenMode && (token == null || token.isBlank())) { throw new IllegalArgumentException( "auth.mode=token requires a non-empty token; check that the env var named by " @@ -49,6 +64,9 @@ public final class CallerResolver { this.identity = identity; this.tokenMode = tokenMode; this.expectedToken = tokenMode ? token.getBytes(StandardCharsets.UTF_8) : null; + this.pinnedPrimaryTerminal = + pinnedPrimaryTerminal == null || pinnedPrimaryTerminal.isBlank() + ? null : pinnedPrimaryTerminal; } /** @@ -61,6 +79,11 @@ public final class CallerResolver { public Principal resolve(String remoteAddr, int remotePort, String authorizationHeader) { ConnectionIdentity.Caller c = identity.resolve(remoteAddr, remotePort); if (c.terminal() != null) { + if (c.terminal().equals(pinnedPrimaryTerminal)) { + // The config names this pane as the primary's own. The pane mapping is exactly as + // unforgeable as a worker's, so it outranks the token path — no credential needed. + return Principal.primary(c.pid()); + } return Principal.worker(c.terminal(), c.pid()); // unforgeable; never token-gated } 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 ed937dd..83c6526 100644 --- a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java +++ b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java @@ -254,8 +254,11 @@ public record BridgedConfig( /** * Optional pinned primary terminal config (CB-307). When present with a non-blank * {@code terminal}, the bridge uses this as the primary's herdr identity instead of - * deriving it from the MCP connection. Useful when the primary runs off-host or in a - * non-herdr terminal where connection-derived identity is unavailable. + * deriving it from the MCP connection. It feeds two consumers: the push loop (where to nudge + * when replies land), and caller resolution — a caller whose connection maps to this pane is + * the primary, where the pane match would otherwise classify it as a worker. Pin it when the + * primary runs inside a herdr pane; it also helps off-host or non-herdr primaries, + * where connection-derived identity is unavailable and only the nudge target matters. * * @param terminal the primary's herdr {@code terminal_id} ({@code null}/blank → derive) * @param pushReminders max reminder nudges before giving up (default 5) 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 d60c24b..366f011 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java @@ -437,6 +437,9 @@ public final class SessionManager implements TurnListener { @Override public void markPresent(String terminal) { + if (terminal == null || terminal.isBlank()) { + return; // the primary's contact carries no worker terminal — not a readiness signal + } super.markPresent(terminal); sessions.onReady(terminal); } 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 82d05f8..320d7ee 100644 --- a/bridged/src/test/java/dev/ltms/bridged/auth/CallerResolverTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/auth/CallerResolverTest.java @@ -44,6 +44,34 @@ class CallerResolverTest { assertEquals("term_a", underToken.terminal()); } + @Test + void aPinnedPrimaryTerminalResolvesToPrimaryNotWorker() { + // The primary's own session lives in a herdr pane (term_a here). Without the pin the pane + // match wins and the primary is locked out of spawn/send/stop as a misread worker. + Principal p = new CallerResolver(workerIdentity(), false, null, "term_a") + .resolve("127.0.0.1", 42, null); + + assertEquals(Role.PRIMARY, p.role()); + } + + @Test + void aPinnedPrimaryTerminalNeedsNoTokenEvenInTokenMode() { + Principal p = new CallerResolver(workerIdentity(), true, "s3cret", "term_a") + .resolve("127.0.0.1", 42, null); + + assertEquals(Role.PRIMARY, p.role(), + "the pane mapping is as unforgeable as a worker's — the pin outranks the token path"); + } + + @Test + void otherPanesRemainWorkersWhenAPinIsSet() { + Principal p = new CallerResolver(workerIdentity(), false, null, "term_someone_else") + .resolve("127.0.0.1", 42, null); + + assertEquals(Role.WORKER, p.role()); + assertEquals("term_a", p.terminal()); + } + @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 3338e7a..ef3defc 100644 --- a/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java @@ -48,6 +48,17 @@ class SessionManagerTest { return new SessionManager(workers, new GitWorktrees(), clock, contextCap); } + @Test + void primaryContactWithNoTerminalIsNotAReadinessSignal() { + // The MCP context extractor calls presence.markPresent(p.terminal()) on EVERY request, + // and the primary's terminal is null — the presence bridge must treat that as a no-op, + // not feed it into the READY transition (which NPEd on the first real primary contact). + SessionManager sessions = sessionManager(new FakeHerdr()); + + assertDoesNotThrow(() -> sessions.asPresence().markPresent(null)); + assertDoesNotThrow(() -> sessions.asPresence().markPresent(" ")); + } + @Test void acquireRegistersSpawningSessionWithDistinctPaneId() { FakeHerdr herdr = new FakeHerdr();