diff --git a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java index cb3d68dd..1e979aa1 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java @@ -560,6 +560,7 @@ public final class FleetMcp { return listFleet(workers, sessions, messages, capacity, healthCoverage, loopHealth, quarantine, outage, leadSeats, leadContextGauge, leadConfigDirs, callers.leads(), callerTerminal(exchange), + callers.collaborators(), collaboratorsVisibleTo(principal(exchange)), new CoordinationSource(leadChannel, peers), coordinatorVisibleTo(principal(exchange))); }; @@ -743,6 +744,21 @@ public final class FleetMcp { return caller.isPrimary(); } + /** + * fleetd #703: who may see {@code fleet_list}'s {@code collaborators} array — a roster of the + * human-opened tabs this daemon recognises as named peers. Visible to exactly the roles that + * may {@link Authz.Action#SEND} to a named peer ({@code Authz.java}'s {@code SEND} case): + * the primary, an architect, and a collaborator (reaching another collaborator or a lead). A + * worker holds {@code READ} but can never {@code SEND} to a collaborator, so listing them to a + * worker would expose which human tabs exist on the host with no use to that caller. Split out + * for the same reason as {@link #coordinatorVisibleTo}: the decision must be unit-testable + * without fabricating an SDK {@code McpSyncServerExchange}, and the handler must call this + * named predicate rather than inlining the check. + */ + static boolean collaboratorsVisibleTo(Principal caller) { + return caller.isPrimary() || caller.isArchitect() || caller.isCollaborator(); + } + /** The worker identity resolved from this call's connection, or {@code null} if the primary. */ private static String callerTerminal(McpSyncServerExchange exchange) { Object v = exchange.transportContext().get(CALLER_TERMINAL); @@ -1761,7 +1777,8 @@ public final class FleetMcp { Map leads, String selfTerm, CoordinationSource coordination) { return listFleet(workers, sessions, messages, capacity, healthCoverage, loopHealth, quarantine, - OutageSource.none(), LeadSeatSource.none(), new LeadContextGauge(), LeadConfigDirSource.none(), leads, selfTerm, coordination, false); + OutageSource.none(), LeadSeatSource.none(), new LeadContextGauge(), LeadConfigDirSource.none(), leads, selfTerm, + Map.of(), false, coordination, false); } /** As above, plus fleetd #201 Unit 5 cool-off facts (see {@link OutageSource}). */ @@ -1770,7 +1787,8 @@ public final class FleetMcp { QuarantineSource quarantine, OutageSource outage, Map leads, String selfTerm) { return listFleet(workers, sessions, messages, capacity, healthCoverage, LoopHealthSource.none(), quarantine, outage, - LeadSeatSource.none(), new LeadContextGauge(), LeadConfigDirSource.none(), leads, selfTerm, CoordinationSource.none(), false); + LeadSeatSource.none(), new LeadContextGauge(), LeadConfigDirSource.none(), leads, selfTerm, + Map.of(), false, CoordinationSource.none(), false); } /** @@ -1787,7 +1805,8 @@ public final class FleetMcp { QuarantineSource quarantine, Map leads, String selfTerm, CoordinationSource coordination) { return listFleet(workers, sessions, messages, capacity, healthCoverage, LoopHealthSource.none(), quarantine, OutageSource.none(), - LeadSeatSource.none(), new LeadContextGauge(), LeadConfigDirSource.none(), leads, selfTerm, coordination, false); + LeadSeatSource.none(), new LeadContextGauge(), LeadConfigDirSource.none(), leads, selfTerm, + Map.of(), false, coordination, false); } /** As above, plus fleetd #201 Unit 5 cool-off facts (see {@link OutageSource}). */ @@ -1796,7 +1815,8 @@ public final class FleetMcp { QuarantineSource quarantine, OutageSource outage, Map leads, String selfTerm, CoordinationSource coordination) { return listFleet(workers, sessions, messages, capacity, healthCoverage, LoopHealthSource.none(), quarantine, outage, - LeadSeatSource.none(), new LeadContextGauge(), LeadConfigDirSource.none(), leads, selfTerm, coordination, false); + LeadSeatSource.none(), new LeadContextGauge(), LeadConfigDirSource.none(), leads, selfTerm, + Map.of(), false, coordination, false); } /** @@ -1818,7 +1838,8 @@ public final class FleetMcp { LeadSeatSource leadSeats, Map leads, String selfTerm, CoordinationSource coordination) { return listFleet(workers, sessions, messages, capacity, healthCoverage, LoopHealthSource.none(), quarantine, outage, - leadSeats, new LeadContextGauge(), LeadConfigDirSource.none(), leads, selfTerm, coordination, false); + leadSeats, new LeadContextGauge(), LeadConfigDirSource.none(), leads, selfTerm, + Map.of(), false, coordination, false); } /** @@ -1842,7 +1863,8 @@ public final class FleetMcp { LeadSeatSource leadSeats, Map leads, String selfTerm, CoordinationSource coordination, boolean callerIsPrimary) { return listFleet(workers, sessions, messages, capacity, healthCoverage, LoopHealthSource.none(), quarantine, - outage, leadSeats, new LeadContextGauge(), LeadConfigDirSource.none(), leads, selfTerm, coordination, callerIsPrimary); + outage, leadSeats, new LeadContextGauge(), LeadConfigDirSource.none(), leads, selfTerm, + Map.of(), false, coordination, callerIsPrimary); } /** @@ -1852,6 +1874,13 @@ public final class FleetMcp { * matter); the one caller that matters for caching, {@code fleet_list}'s MCP handler, passes * its own single long-lived instance instead (see {@code FleetMcp}'s {@code leadContextGauge} * field). + * + * @param collaborators terminal_id → collaborator name, live from the resolver + * ({@link dev.ltms.fleet.auth.CallerResolver#collaborators()}) + * @param collaboratorsVisible whether this caller may see the {@code collaborators} array (see + * {@link #collaboratorsVisibleTo}); every wrapper overload above + * passes {@code false}, so a test that wants the row must call this + * overload with an explicit {@code true} */ static McpSchema.CallToolResult listFleet(PeerLauncher workers, SessionManager sessions, MessageService messages, CapacitySource capacity, HealthCoverageSource healthCoverage, @@ -1860,6 +1889,7 @@ public final class FleetMcp { LeadSeatSource leadSeats, LeadContextGauge contextGauge, LeadConfigDirSource leadConfigDirs, Map leads, String selfTerm, + Map collaborators, boolean collaboratorsVisible, CoordinationSource coordination, boolean callerIsPrimary) { try { Map live = workers.list().stream() @@ -1886,6 +1916,16 @@ public final class FleetMcp { result.put("loopHealth", Map.of( "statusPoller", loopHealth.statusPoller().get().name(), "sessionReaper", loopHealth.sessionReaper().get().name())); + // fleetd #703: a collaborator tab is a person's own tab, so the row is assembled and + // included only for the roles that may SEND to a named peer -- gate BEFORE assembling + // it, same reason as the coordinator row just below: the key must be absent for a + // worker, never present-and-empty. + if (collaboratorsVisible) { + result.put("collaborators", collaborators.entrySet().stream() + .sorted(Map.Entry.comparingByValue()) + .map(e -> collaboratorRow(e.getKey(), e.getValue())) + .toList()); + } // fleetd #439: coordinator/coordinatorView is lead-to-lead coordination state and must // never reach a worker or an architect -- gate BEFORE assembling it, not after, so the // key is absent rather than present-and-empty. @@ -2197,6 +2237,20 @@ public final class FleetMcp { return m; } + /** + * fleetd #703: one row of the {@code collaborators} array — a named peer's registry name and + * the herdr {@code terminal_id} a {@code fleet_send} must target to reach it. No status, no + * context gauge, no profile: a collaborator is never spawned and carries no profile, so those + * fields have no meaning for it, and the scan behind {@code terminal} only reports a tab it + * actually found in herdr, so a tab nobody has open does not appear here at all. + */ + private static Map collaboratorRow(String terminal, String name) { + Map m = new LinkedHashMap<>(); + m.put("name", name); + m.put("sessionId", terminal); + return m; + } + /** * Reads the lead context gauge for one lead. {@code configDir} is this lead's configured * {@code CLAUDE_CONFIG_DIR} override (see {@link LeadConfigDirSource}), derived from @@ -2422,7 +2476,12 @@ public final class FleetMcp { + "msgId/from/preview, never the full body), and one row per coordinator.peers " + "coord-id ('peers': coordId/reachable, plus pending/consumers when reachable) — " + "this is peer DISCOVERY for cross-host leads, distinct from the local 'leads' " - + "array above. It is omitted entirely when no coordinator is configured.", + + "array above. It is omitted entirely when no coordinator is configured. A " + + "'collaborators' array, visible only to the primary, an architect, and a " + + "collaborator (never a worker), reports every other named peer tab this daemon " + + "recognises: each row is 'name' (its registry name) and 'sessionId' (the " + + "terminal id a fleet_send targets to reach it). Absent entirely for a worker, " + + "whatever is configured.", objectSchema(Map.of(), List.of())); } diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java index aeb3e758..cb6db342 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java @@ -366,6 +366,58 @@ class FleetMcpAuthzTest { + "calling, not pass a literal boolean -- found: " + trailing); } + // --- fleetd #703: who may see fleet_list's collaborators array ------------------------------- + + /** + * fleetd #703: {@link FleetMcp#collaboratorsVisibleTo} is the whole policy decision for + * {@code fleet_list}'s {@code collaborators} array. Visible to exactly the roles that may + * {@code SEND} to a named peer -- the primary, an architect, and a collaborator itself -- never + * a worker, which holds {@code READ} but can never {@code SEND} to a collaborator, and never an + * anonymous caller. + */ + @Test + void onlyPrimaryArchitectAndCollaboratorMaySeeTheCollaboratorsArray() { + assertTrue(FleetMcp.collaboratorsVisibleTo(PRIMARY), "the primary must see the collaborators array"); + assertTrue(FleetMcp.collaboratorsVisibleTo(ARCH_DESIGN), "an architect must see the collaborators array"); + assertTrue(FleetMcp.collaboratorsVisibleTo(COLLABORATOR), "a collaborator must see its own peer roster"); + assertFalse(FleetMcp.collaboratorsVisibleTo(WORKER_A), + "a worker holds READ but can never SEND to a collaborator, so it must not see the array"); + assertFalse(FleetMcp.collaboratorsVisibleTo(ANON), "authenticated as nothing must not see it either"); + } + + /** + * fleetd #703, same reasoning as {@link #theFleetListHandlerActuallyConsultsCoordinatorVisibleTo}: + * the predicate above can be perfectly correct while the one production call site never asks it. + * This reads {@code FleetMcp.java}'s own source and asserts the {@code fleet_list} handler's + * {@code listFleet(...)} call both threads {@code callers.collaborators()} into the payload and + * asks {@code collaboratorsVisibleTo(principal(exchange))} for the visibility flag, rather than a + * literal boolean or an empty map. + */ + @Test + void theFleetListHandlerActuallyConsultsCollaboratorsVisibleTo() throws Exception { + String source = Files.readString(MCP_SOURCE); + + int start = source.indexOf("listHandler ="); + assertTrue(start >= 0, "could not find the fleet_list handler (listHandler) in " + MCP_SOURCE + + " -- the scrape has stopped matching, fix the anchor before trusting this test"); + int end = source.indexOf("stopHandler =", start); + assertTrue(end > start, "could not find the handler declared after listHandler to bound the scrape"); + String handlerBlock = source.substring(start, end); + + // CONTROL: the block we scraped really does contain a call to listFleet(...) -- if this + // fails, the anchors above moved and the assertions below would otherwise pass on nothing. + assertTrue(handlerBlock.contains("listFleet("), + "control failed: the scraped listHandler block contains no listFleet( call at all -- " + + "the anchors have drifted, this test is not testing what it claims to"); + + assertTrue(handlerBlock.contains("callers.collaborators()"), + "the fleet_list handler must thread callers.collaborators() into listFleet(...), not an " + + "empty or literal map -- block: " + handlerBlock); + assertTrue(handlerBlock.contains("collaboratorsVisibleTo(principal(exchange))"), + "the fleet_list handler must ask collaboratorsVisibleTo(principal(exchange)) who is " + + "calling, not pass a literal boolean -- block: " + handlerBlock); + } + // --- which action each tool hands the gate (fleetd #272) ------------------------------------ /** diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpLeadContextGaugeWiringTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpLeadContextGaugeWiringTest.java index 323711f9..7b1abee0 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpLeadContextGaugeWiringTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpLeadContextGaugeWiringTest.java @@ -121,6 +121,7 @@ class FleetMcpLeadContextGaugeWiringTest { FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"), FleetMcp.LoopHealthSource.none(), FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(), contextGauge, leadConfigDirs, - Map.of(LEAD_TERMINAL, LEAD_NAME), LEAD_TERMINAL, FleetMcp.CoordinationSource.none(), false); + Map.of(LEAD_TERMINAL, LEAD_NAME), LEAD_TERMINAL, Map.of(), false, + FleetMcp.CoordinationSource.none(), false); } } diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java index 1dbc779c..d83252cd 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java @@ -10,6 +10,7 @@ import dev.ltms.fleet.herdr.AgentControl; import dev.ltms.fleet.herdr.AgentStatus; import dev.ltms.fleet.herdr.FakeHerdr; import dev.ltms.fleet.inject.LoopWatchdog; +import dev.ltms.fleet.lead.LeadContextGauge; import dev.ltms.fleet.herdr.PaneLocator; import dev.ltms.fleet.herdr.WorkspaceControl; import dev.ltms.fleet.inject.Injector; @@ -879,6 +880,83 @@ class FleetMcpTest { assertTrue(out.contains("x".repeat(80) + "…"), "expected an 80-char preview with an ellipsis: " + out); } + // --- fleetd #703: fleet_list's collaborators array ------------------------------------------- + + /** Calls the canonical {@code listFleet} overload directly, so a test can set the collaborators + * payload and its visibility independently of a real {@code Principal} / MCP exchange. */ + private static McpSchema.CallToolResult listFleetWithCollaborators(FakeHerdr h, + Map collaborators, boolean collaboratorsVisible) { + SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw"))); + return FleetMcp.listFleet( + workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), sessions, null, + FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"), + FleetMcp.LoopHealthSource.none(), FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), + FleetMcp.LeadSeatSource.none(), new LeadContextGauge(), FleetMcp.LeadConfigDirSource.none(), + Map.of(), "", collaborators, collaboratorsVisible, + FleetMcp.CoordinationSource.none(), false); + } + + /** + * fleetd #703 acceptance A: a visible caller with one configured collaborator gets a + * {@code collaborators} row whose key ({@code CallerResolver.collaborators()}'s + * {@code terminal_id -> name} entry) lands as that row's {@code sessionId}, and {@code leads}/ + * {@code members} are unaffected by the new key. + */ + @Test + void listReportsACollaboratorsRowKeyedByTheCollaboratorsTerminalId() { + FakeHerdr h = new FakeHerdr(); + String out = textOf(listFleetWithCollaborators(h, Map.of("term_collab", "ops"), true)); + + assertTrue(out.contains("\"collaborators\":["), out); + assertTrue(out.contains("\"name\":\"ops\""), out); + assertTrue(out.contains("\"sessionId\":\"term_collab\""), out); + assertTrue(out.contains("\"leads\":[]"), out); + assertTrue(out.contains("\"members\":[]"), out); + } + + /** + * fleetd #703 acceptance A, control half: with no collaborator configured, the key is absent + * (caller cannot see it) or an empty array (caller can), and {@code leads}/{@code members} are + * unchanged either way. + */ + @Test + void listOmitsOrEmptiesCollaboratorsWhenNoneAreConfigured() { + FakeHerdr h = new FakeHerdr(); + + String visibleButEmpty = textOf(listFleetWithCollaborators(h, Map.of(), true)); + assertTrue(visibleButEmpty.contains("\"collaborators\":[]"), visibleButEmpty); + assertTrue(visibleButEmpty.contains("\"leads\":[]"), visibleButEmpty); + assertTrue(visibleButEmpty.contains("\"members\":[]"), visibleButEmpty); + + String notVisible = textOf(listFleetWithCollaborators(h, Map.of(), false)); + assertFalse(notVisible.contains("\"collaborators\""), notVisible); + assertTrue(notVisible.contains("\"leads\":[]"), notVisible); + assertTrue(notVisible.contains("\"members\":[]"), notVisible); + } + + /** + * fleetd #703 acceptance B: a worker must not see the {@code collaborators} array at all, while + * an architect -- a role that also holds READ, same as a worker -- does see it. Both halves are + * asserted: a test that only checked the worker-hidden half would pass even if the feature were + * never wired up for anyone. + */ + @Test + void listOmitsCollaboratorsForAWorkerAndIncludesThemForAnArchitect() { + FakeHerdr h = new FakeHerdr(); + Map collaborators = Map.of("term_collab", "ops"); + + String asWorker = textOf(listFleetWithCollaborators(h, collaborators, + FleetMcp.collaboratorsVisibleTo(Principal.worker("term_w", 1)))); + assertFalse(asWorker.contains("\"collaborators\""), + "a worker must not see the collaborators array: " + asWorker); + + String asArchitect = textOf(listFleetWithCollaborators(h, collaborators, + FleetMcp.collaboratorsVisibleTo(Principal.architect("design", "term_arch", 2)))); + assertTrue(asArchitect.contains("\"collaborators\":["), + "an architect must see the collaborators array: " + asArchitect); + assertTrue(asArchitect.contains("\"sessionId\":\"term_collab\""), asArchitect); + } + /** * fleetd #421: {@code mailbox.pending} counts only broker-ready messages, so a blocked lead's * normal, healthy state is {@code "pending": 0} next to a non-empty {@code held[]} — which