From 61af9aa57436facc7b530d5ab4fa5644d4c5c633 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sat, 15 Aug 2026 05:29:37 +0200 Subject: [PATCH 1/2] CB-572: reject profile names as send targets --- .../java/dev/ltms/bridged/mcp/BridgeMcp.java | 38 +++++++++++++++++-- .../dev/ltms/bridged/mcp/BridgeMcpTest.java | 37 ++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) 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 ee9046d..97945ef 100644 --- a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java +++ b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java @@ -33,6 +33,7 @@ import jakarta.servlet.http.HttpServlet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -151,8 +152,8 @@ public final class BridgeMcp { Runnable onAccepted = () -> primaryRegistry.recordDelegation(target, caller); // wait defaults to true (block for the reply); wait:false is fire-and-poll. return Boolean.FALSE.equals(a.get("wait")) - ? sendAsync(messages, target, content, onAccepted) - : send(messages, target, content, timeoutMs(a), onAccepted); + ? sendAsync(messages, target, content, onAccepted, workers.profiles()) + : send(messages, target, content, timeoutMs(a), onAccepted, workers.profiles()); }) // bridge_reply's identity is the CONNECTION, never an argument — so the authz check // is "is this caller a worker at all", and it can only ever reply as itself. @@ -390,10 +391,20 @@ public final class BridgeMcp { * a BUSY interloper never claims a turn it did not win. {@code null} disables recording. */ static McpSchema.CallToolResult send(MessageService messages, String sessionId, String content, - Long timeoutMs, Runnable onAccepted) { + Long timeoutMs, Runnable onAccepted) { + return send(messages, sessionId, content, timeoutMs, onAccepted, Set.of()); + } + + /** As above, rejecting a configured profile name before accepting a send to it. */ + static McpSchema.CallToolResult send(MessageService messages, String sessionId, String content, + Long timeoutMs, Runnable onAccepted, Set profiles) { if (isBlank(sessionId) || isBlank(content)) { return error("sessionId and content are required"); } + McpSchema.CallToolResult targetError = profileTargetError(sessionId, profiles); + if (targetError != null) { + return targetError; + } long timeout = clamp(timeoutMs == null ? DEFAULT_TIMEOUT_MS : timeoutMs); try { return formatReply(messages.send(sessionId, content, timeout, onAccepted), timeout); @@ -473,14 +484,33 @@ public final class BridgeMcp { * (CB-548) so an async flooding send records delegator ownership exactly once it is accepted. */ static McpSchema.CallToolResult sendAsync(MessageService messages, String sessionId, String content, - Runnable onAccepted) { + Runnable onAccepted) { + return sendAsync(messages, sessionId, content, onAccepted, Set.of()); + } + + /** As above, rejecting a configured profile name before handing out an async ticket. */ + static McpSchema.CallToolResult sendAsync(MessageService messages, String sessionId, String content, + Runnable onAccepted, Set profiles) { if (isBlank(sessionId) || isBlank(content)) { return error("sessionId and content are required"); } + McpSchema.CallToolResult targetError = profileTargetError(sessionId, profiles); + if (targetError != null) { + return targetError; + } String ticket = messages.sendAsync(sessionId, content, onAccepted); return text("accepted — task delegated. Poll bridge_poll with ticket=" + ticket); } + /** A configured profile is never a send target; other unknown values may be herdr-owned panes. */ + private static McpSchema.CallToolResult profileTargetError(String sessionId, Set profiles) { + if (profiles.contains(sessionId)) { + return error("unknown send target \"" + sessionId + "\": it is a configured profile name, not a " + + "session id. Call bridge_list to find a member or lead sessionId."); + } + return null; + } + /** {@code bridge_poll}: check an async delegation by ticket, or drain a worker's inbox by target. */ static McpSchema.CallToolResult poll(MessageService messages, String ticket, String target) { if (!isBlank(target)) { diff --git a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java index ab264ae..f1bad0f 100644 --- a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java @@ -53,6 +53,18 @@ class BridgeMcpTest { return ((McpSchema.TextContent) r.content().getFirst()).text(); } + private void assertSendRoundTrips(String target, Set profiles) throws Exception { + CompletableFuture send = CompletableFuture.supplyAsync( + () -> BridgeMcp.send(messages, target, "hi", 4000L, null, profiles)); + long deadline = System.currentTimeMillis() + 3000; + while (!rendezvous.isWaiting(target) && System.currentTimeMillis() < deadline) { + Thread.sleep(5); + } + assertTrue(rendezvous.isWaiting(target), "send should be accepted for " + target); + BridgeMcp.reply(messages, target, "received"); + assertEquals("received", textOf(send.get(6, TimeUnit.SECONDS))); + } + private static ClaudeCodeLauncher workerService(FakeHerdr h, String baseUrl, Set allow) { BridgedConfig.Profile cfg = new BridgedConfig.Profile( "ltms-local", baseUrl, "coder", null, "BRIDGED_WORKER_TOKEN", null, @@ -140,6 +152,31 @@ class BridgeMcpTest { assertTrue(BridgeMcp.send(messages, "term_a", " ", null).isError()); } + @Test + void sendRejectsAConfiguredProfileNameBeforeAcceptingIt() { + McpSchema.CallToolResult blocking = BridgeMcp.send(messages, "sol", "hi", 100L, null, Set.of("sol")); + McpSchema.CallToolResult async = BridgeMcp.sendAsync(messages, "sol", "hi", null, Set.of("sol")); + + assertTrue(blocking.isError()); + assertTrue(async.isError()); + assertTrue(textOf(blocking).contains("sol")); + assertTrue(textOf(blocking).contains("configured profile name")); + assertTrue(textOf(blocking).contains("bridge_list")); + assertFalse(textOf(async).contains("ticket=")); + } + + @Test + void sendAllowsPeerLeadMemberAndUnclassifiedTargets() throws Exception { + Set profiles = Set.of("sol"); + + assertSendRoundTrips("term_peer_lead", profiles); + assertSendRoundTrips("term_live_member", profiles); + + // A herdr-owned pane outside the bridge roster cannot be classified at accept time. + McpSchema.CallToolResult result = BridgeMcp.send(messages, "external-pane", "hi", 10L, null, profiles); + assertFalse(result.isError(), "an unclassified target must not be rejected at acceptance time"); + } + @Test void replyWithNoPendingSendIsQueuedNotError() { // CB-307: a reply with no open send is now queued in the inbox, not an error. From 4f0bf667b116a5bb18befd02d00285a0595b4c2a Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sat, 15 Aug 2026 05:33:15 +0200 Subject: [PATCH 2/2] CB-572: require profiles for send validation --- .../java/dev/ltms/bridged/mcp/BridgeMcp.java | 35 +++++-------------- .../dev/ltms/bridged/mcp/BridgeMcpTest.java | 12 +++---- 2 files changed, 14 insertions(+), 33 deletions(-) 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 97945ef..dde9242 100644 --- a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java +++ b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java @@ -380,24 +380,15 @@ public final class BridgeMcp { // --- tool logic (thin adapters over the services; unit-testable) --------------------------- - /** {@code bridge_send}: delegate {@code content} to a worker session and block for its reply. */ - static McpSchema.CallToolResult send(MessageService messages, String sessionId, String content, Long timeoutMs) { - return send(messages, sessionId, content, timeoutMs, null); - } - /** - * As {@link #send(MessageService, String, String, Long)}, wiring an accepted-delivery hook + * {@code bridge_send}: delegate {@code content} to a worker session and block for its reply. + * The configured profiles are required so a profile name can never bypass target validation. + * * (CB-548): {@code onAccepted} records delegator ownership the instant the send is accepted, so * a BUSY interloper never claims a turn it did not win. {@code null} disables recording. */ static McpSchema.CallToolResult send(MessageService messages, String sessionId, String content, - Long timeoutMs, Runnable onAccepted) { - return send(messages, sessionId, content, timeoutMs, onAccepted, Set.of()); - } - - /** As above, rejecting a configured profile name before accepting a send to it. */ - static McpSchema.CallToolResult send(MessageService messages, String sessionId, String content, - Long timeoutMs, Runnable onAccepted, Set profiles) { + Long timeoutMs, Runnable onAccepted, Set profiles) { if (isBlank(sessionId) || isBlank(content)) { return error("sessionId and content are required"); } @@ -474,23 +465,13 @@ public final class BridgeMcp { /** * {@code bridge_send} with {@code wait:false}: delegate {@code content} and return a ticket * immediately (fire-and-poll), so a long task isn't cut off by the caller's MCP call timeout. - */ - static McpSchema.CallToolResult sendAsync(MessageService messages, String sessionId, String content) { - return sendAsync(messages, sessionId, content, null); - } - - /** - * As {@link #sendAsync(MessageService, String, String)}, wiring the accepted-delivery hook + * The configured profiles are required so a profile name can never bypass target validation. + * + * This wires the accepted-delivery hook * (CB-548) so an async flooding send records delegator ownership exactly once it is accepted. */ static McpSchema.CallToolResult sendAsync(MessageService messages, String sessionId, String content, - Runnable onAccepted) { - return sendAsync(messages, sessionId, content, onAccepted, Set.of()); - } - - /** As above, rejecting a configured profile name before handing out an async ticket. */ - static McpSchema.CallToolResult sendAsync(MessageService messages, String sessionId, String content, - Runnable onAccepted, Set profiles) { + Runnable onAccepted, Set profiles) { if (isBlank(sessionId) || isBlank(content)) { return error("sessionId and content are required"); } diff --git a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java index f1bad0f..0aed9d2 100644 --- a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java @@ -81,7 +81,7 @@ class BridgeMcpTest { void sendThenReplyRoundTrips() throws Exception { // bridge_send blocks; bridge_reply resolves it with the worker's structured answer. CompletableFuture send = CompletableFuture.supplyAsync( - () -> BridgeMcp.send(messages, "term_a", "review this", 4000L)); + () -> BridgeMcp.send(messages, "term_a", "review this", 4000L, null, Set.of())); // Wait until the send has opened its waiter so the reply resolves it (CB-307: reply now // queues in the inbox if no waiter is open, which would break the round-trip). @@ -103,7 +103,7 @@ class BridgeMcpTest { @Test void asyncSendReturnsATicketThenPollReportsTheReply() throws Exception { // wait:false parity — a ticket is issued, resolved by a reply, and surfaced by bridge_poll. - McpSchema.CallToolResult accepted = BridgeMcp.sendAsync(messages, "term_a", "do it"); + McpSchema.CallToolResult accepted = BridgeMcp.sendAsync(messages, "term_a", "do it", null, Set.of()); assertNotEquals(Boolean.TRUE, accepted.isError()); String out = textOf(accepted); assertTrue(out.contains("ticket="), out); @@ -141,15 +141,15 @@ class BridgeMcpTest { @Test void sendTimesOutWithAWorkingNote() { - McpSchema.CallToolResult res = BridgeMcp.send(messages, "term_a", "hi", 120L); + McpSchema.CallToolResult res = BridgeMcp.send(messages, "term_a", "hi", 120L, null, Set.of()); assertNotEquals(Boolean.TRUE, res.isError(), "a timeout is informational, not a tool error"); assertTrue(textOf(res).contains("no reply"), "got: " + textOf(res)); } @Test void sendRejectsMissingArgs() { - assertTrue(BridgeMcp.send(messages, null, "hi", null).isError()); - assertTrue(BridgeMcp.send(messages, "term_a", " ", null).isError()); + assertTrue(BridgeMcp.send(messages, null, "hi", null, null, Set.of()).isError()); + assertTrue(BridgeMcp.send(messages, "term_a", " ", null, null, Set.of()).isError()); } @Test @@ -210,7 +210,7 @@ class BridgeMcpTest { void askThenAnswerRoundTrips() throws Exception { // The primary delegates and blocks; wait until its waiter is open before the worker asks. CompletableFuture send = CompletableFuture.supplyAsync( - () -> BridgeMcp.send(messages, "term_a", "do X", 5000L)); + () -> BridgeMcp.send(messages, "term_a", "do X", 5000L, null, Set.of())); long deadline = System.currentTimeMillis() + 3000; while (!rendezvous.isWaiting("term_a") && System.currentTimeMillis() < deadline) { //noinspection BusyWait