diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java index 797d76a..498d09f 100644 --- a/bridged/src/main/java/dev/ltms/bridged/Bridged.java +++ b/bridged/src/main/java/dev/ltms/bridged/Bridged.java @@ -3,11 +3,14 @@ package dev.ltms.bridged; import dev.ltms.bridged.config.BridgedConfig; import dev.ltms.bridged.guard.SubscriptionGuard; import dev.ltms.bridged.herdr.AgentControl; +import dev.ltms.bridged.herdr.PaneLocator; import dev.ltms.bridged.herdr.UnixSocketHerdrClient; import dev.ltms.bridged.herdr.WorkspaceControl; import dev.ltms.bridged.inject.Injector; import dev.ltms.bridged.inject.StatusPoller; import dev.ltms.bridged.mcp.BridgeMcp; +import dev.ltms.bridged.mcp.ConnectionIdentity; +import dev.ltms.bridged.mcp.LsofPeerPidLookup; import dev.ltms.bridged.msg.MessageService; import dev.ltms.bridged.msg.Rendezvous; import dev.ltms.bridged.rest.BridgedApp; @@ -60,7 +63,9 @@ public final class Bridged { MessageService messages = new MessageService(agents, injector, rendezvous); // MCP server face (CB-105): bridge_send/bridge_reply/bridge_status, mounted at /mcp. - BridgeMcp mcp = new BridgeMcp(messages, rendezvous); + // Caller identity is resolved from the connection (peer PID → herdr pane), not arguments. + ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), new LsofPeerPidLookup()); + BridgeMcp mcp = new BridgeMcp(messages, rendezvous, identity); Runtime.getRuntime().addShutdownHook(new Thread(mcp::close)); Javalin app = new BridgedApp(herdr, workers, messages, rendezvous, mcp.servlet()).build(); app.start(cfg.bind().host(), cfg.bind().port()); diff --git a/bridged/src/main/java/dev/ltms/bridged/herdr/PaneLocator.java b/bridged/src/main/java/dev/ltms/bridged/herdr/PaneLocator.java new file mode 100644 index 0000000..46d7fb3 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/herdr/PaneLocator.java @@ -0,0 +1,59 @@ +package dev.ltms.bridged.herdr; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.Map; + +/** + * Resolves which herdr pane a process belongs to — the herdr half of connection-based MCP + * identity (CB-105). Given the PID that opened an MCP connection, {@link #terminalForPid} finds + * the agent pane whose process tree contains it, so {@code bridged} can tell which worker + * is calling without the worker sending anything spoofable. + * + *
herdr owns the PID→pane truth: {@code pane.process_info} reports each pane's {@code shell_pid}
+ * and foreground process PIDs. This scans agent panes; a spawn-time {@code pid→terminal} cache is
+ * the obvious optimization once wired into {@code WorkerService}.
+ */
+public final class PaneLocator {
+
+ private final HerdrClient herdr;
+
+ public PaneLocator(HerdrClient herdr) {
+ this.herdr = herdr;
+ }
+
+ /**
+ * The {@code terminal_id} of the agent pane whose process tree contains {@code pid}, or
+ * {@code null} if no agent pane owns it (e.g. the caller is the primary, or off-host).
+ */
+ public String terminalForPid(long pid) {
+ if (pid <= 0) {
+ return null;
+ }
+ for (JsonNode pane : herdr.call("pane.list", Map.of()).path("panes")) {
+ String paneId = pane.path("pane_id").asText(null);
+ if (paneId != null && paneOwnsPid(paneId, pid)) {
+ return pane.path("terminal_id").asText(null);
+ }
+ }
+ return null;
+ }
+
+ private boolean paneOwnsPid(String paneId, long pid) {
+ JsonNode info;
+ try {
+ info = herdr.call("pane.process_info", Map.of("pane_id", paneId)).path("process_info");
+ } catch (HerdrException e) {
+ return false; // pane vanished mid-scan — just skip it
+ }
+ if (info.path("shell_pid").asLong(-1) == pid) {
+ return true;
+ }
+ for (JsonNode p : info.path("foreground_processes")) {
+ if (p.path("pid").asLong(-1) == pid) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java
index e630146..17398b7 100644
--- a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java
+++ b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java
@@ -3,10 +3,12 @@ package dev.ltms.bridged.mcp;
import dev.ltms.bridged.herdr.HerdrException;
import dev.ltms.bridged.msg.MessageService;
import dev.ltms.bridged.msg.Rendezvous;
+import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapperSupplier;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpSyncServer;
+import io.modelcontextprotocol.server.McpSyncServerExchange;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema;
import jakarta.servlet.http.HttpServlet;
@@ -29,14 +31,21 @@ public final class BridgeMcp {
private static final long DEFAULT_TIMEOUT_MS = 25_000;
private static final long MAX_TIMEOUT_MS = 120_000;
+ /** Transport-context key under which the extractor stashes the resolved caller identity. */
+ static final String CALLER_TERMINAL = "callerTerminal";
+
private final HttpServletStreamableServerTransportProvider transport;
private final McpSyncServer server;
- public BridgeMcp(MessageService messages, Rendezvous rendezvous) {
+ public BridgeMcp(MessageService messages, Rendezvous rendezvous, ConnectionIdentity identity) {
McpJsonMapper json = new JacksonMcpJsonMapperSupplier().get();
this.transport = HttpServletStreamableServerTransportProvider.builder()
.jsonMapper(json)
.mcpEndpoint("/mcp")
+ // Resolve the caller's worker identity from the connection (peer PID → herdr pane),
+ // so bridge_reply needs no spoofable session argument.
+ .contextExtractor(req -> McpTransportContext.create(Map.of(
+ CALLER_TERMINAL, orEmpty(identity.callerTerminal(req.getRemoteAddr(), req.getRemotePort())))))
.build();
this.server = McpServer.sync(transport)
.serverInfo("bridge", "0.1.0")
@@ -45,15 +54,25 @@ public final class BridgeMcp {
Map Both sources are authoritative and unforgeable: the OS reports the real connecting PID, and
+ * herdr owns the PID→pane mapping. A worker cannot claim to be another worker, nor the primary.
+ * Single-host only (the herd shares the {@code bridged} host); the token path is the split-host
+ * fallback.
+ */
+public final class ConnectionIdentity {
+
+ private final PaneLocator panes;
+ private final PeerPidLookup pids;
+
+ public ConnectionIdentity(PaneLocator panes, PeerPidLookup pids) {
+ this.panes = panes;
+ this.pids = pids;
+ }
+
+ /**
+ * The calling worker's {@code terminal_id}, or {@code null} if the caller is not a known
+ * on-host worker (treat as the primary).
+ */
+ public String callerTerminal(String remoteAddr, int remotePort) {
+ if (!isLoopback(remoteAddr)) {
+ return null; // only same-host callers can be workers
+ }
+ return panes.terminalForPid(pids.pidForLocalPort(remotePort));
+ }
+
+ private static boolean isLoopback(String addr) {
+ return "127.0.0.1".equals(addr) || "::1".equals(addr) || "0:0:0:0:0:0:0:1".equals(addr);
+ }
+}
diff --git a/bridged/src/main/java/dev/ltms/bridged/mcp/LsofPeerPidLookup.java b/bridged/src/main/java/dev/ltms/bridged/mcp/LsofPeerPidLookup.java
new file mode 100644
index 0000000..fa47546
--- /dev/null
+++ b/bridged/src/main/java/dev/ltms/bridged/mcp/LsofPeerPidLookup.java
@@ -0,0 +1,58 @@
+package dev.ltms.bridged.mcp;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * {@link PeerPidLookup} via {@code lsof} (present on macOS and Linux). For a loopback TCP source
+ * {@code port}, both the client and this daemon appear on that port — so we exclude our own PID
+ * and take the other end, which is the calling process.
+ */
+public final class LsofPeerPidLookup implements PeerPidLookup {
+
+ private static final Logger log = LoggerFactory.getLogger(LsofPeerPidLookup.class);
+
+ private final long selfPid = ProcessHandle.current().pid();
+
+ @Override
+ public long pidForLocalPort(int port) {
+ try {
+ Process p = new ProcessBuilder("lsof", "-nP", "-FpP", "-iTCP:" + port)
+ .redirectErrorStream(true).start();
+ long found = -1;
+ try (BufferedReader r = new BufferedReader(
+ new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
+ long current = -1;
+ String line;
+ // -Fp emits records: a 'p Tagged {@code contract}; run with {@code mvn test -Pcontract}.
+ */
+@Tag("contract")
+class PaneLocatorContractTest {
+
+ @Test
+ void resolvesTheTerminalOwningARealProcessPid() throws Exception {
+ assumeTrue(Files.exists(UnixSocketHerdrClient.defaultSocketPath()), "no herdr socket — skipping");
+ try (UnixSocketHerdrClient herdr = UnixSocketHerdrClient.connect()) {
+ AgentControl agents = new AgentControl(herdr);
+ Agent probe = agents.start("__pidprobe__", List.of("bash", "-c", "sleep 20"), Map.of());
+ try {
+ JsonNode info = herdr.call("pane.process_info", Map.of("pane_id", probe.paneId()))
+ .path("process_info");
+ long shellPid = info.path("shell_pid").asLong(-1);
+ assertTrue(shellPid > 0, "probe pane should report a shell pid");
+
+ assertEquals(probe.terminalId(), new PaneLocator(herdr).terminalForPid(shellPid),
+ "a real PID must resolve back to its own pane's terminal_id");
+ } finally {
+ agents.close(probe.paneId());
+ }
+ }
+ }
+}
diff --git a/bridged/src/test/java/dev/ltms/bridged/herdr/PaneLocatorTest.java b/bridged/src/test/java/dev/ltms/bridged/herdr/PaneLocatorTest.java
new file mode 100644
index 0000000..54262d4
--- /dev/null
+++ b/bridged/src/test/java/dev/ltms/bridged/herdr/PaneLocatorTest.java
@@ -0,0 +1,27 @@
+package dev.ltms.bridged.herdr;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Unit tests for PID → pane resolution (the herdr half of connection-based MCP identity). */
+class PaneLocatorTest {
+
+ private final PaneLocator loc = new PaneLocator(new FakeHerdr());
+
+ @Test
+ void resolvesTerminalForAForegroundPid() {
+ assertEquals("term_a", loc.terminalForPid(FakeHerdr.WORKER_PID));
+ }
+
+ @Test
+ void nullForAPidInNoPane() {
+ assertNull(loc.terminalForPid(999_999));
+ }
+
+ @Test
+ void nullForNonPositivePid() {
+ assertNull(loc.terminalForPid(0));
+ assertNull(loc.terminalForPid(-1));
+ }
+}
diff --git a/bridged/src/test/java/dev/ltms/bridged/mcp/ConnectionIdentityTest.java b/bridged/src/test/java/dev/ltms/bridged/mcp/ConnectionIdentityTest.java
new file mode 100644
index 0000000..1048524
--- /dev/null
+++ b/bridged/src/test/java/dev/ltms/bridged/mcp/ConnectionIdentityTest.java
@@ -0,0 +1,34 @@
+package dev.ltms.bridged.mcp;
+
+import dev.ltms.bridged.herdr.FakeHerdr;
+import dev.ltms.bridged.herdr.PaneLocator;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Connection → caller-identity resolution, with the OS peer-PID lookup faked. */
+class ConnectionIdentityTest {
+
+ private final FakeHerdr herdr = new FakeHerdr();
+
+ private ConnectionIdentity with(PeerPidLookup pids) {
+ return new ConnectionIdentity(new PaneLocator(herdr), pids);
+ }
+
+ @Test
+ void resolvesWorkerFromLoopbackPeerPid() {
+ assertEquals("term_a", with(_ -> FakeHerdr.WORKER_PID).callerTerminal("127.0.0.1", 55555));
+ }
+
+ @Test
+ void nullForOffHostCaller() {
+ // A non-loopback peer can't be an on-host worker → treat as primary/unknown.
+ assertNull(with(_ -> FakeHerdr.WORKER_PID).callerTerminal("10.0.0.9", 55555));
+ }
+
+ @Test
+ void nullWhenPidOwnsNoPane() {
+ // e.g. the primary — its PID maps to no worker pane.
+ assertNull(with(_ -> 999_999).callerTerminal("127.0.0.1", 55555));
+ }
+}