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:
*
- * - A loopback peer PID that maps to a herdr worker pane ⇒ {@link Role#WORKER}. This is
+ *
- 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.
+ * - 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.
* - Otherwise, under {@code token} mode, a valid bearer token ⇒ {@link Role#PRIMARY}.
@@ -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();