fleetd #790: let an observer pane fleet_send to a lead
CI / shell-tests (pull_request) Failing after 7s
CI / contract (pull_request) Successful in 54s
CI / build (pull_request) Failing after 2m7s

An observer could only reach another observer pane, so a hand-opened tab
could answer a lead with fleet_reply but never start a conversation with
one.

CallerResolver.sendableObserverTarget() is renamed observerSendTarget()
and now accepts a configured lead terminal as well as a pane that falls
to the observer floor. It reads the same maps resolve() reads, in the
same order: a live spawned member is refused first, a lead is then
accepted even when the same pane is also bound to an architect slot,
and a collaborator or an architect-slot pane is refused. A collaborator,
an architect and a spawned member stay unreachable.

Authz keeps its one SEND case; only the classifier it is handed widened,
so the MCP gate (FleetMcp.denyFor) and the REST gate (FleetApp.allow)
give the same answer from the same predicate.

fleet_list shows the lead's address in the observer's filtered `panes`
rows rather than by adding the `leads` array: the panes filter already
reads the same classifier as the SEND gate, so reachability and
visibility cannot drift, and the reduced row carries no lead name,
context window or config dir.

Gates traced end to end for an observer -> lead send: denyFor, the
sendTool argument validation (profileTargetError rejects profile names
only), MessageService (records the caller's owner key, no role gate),
the injector's readiness gate (Fleetd.deliverableTo accepts a lead
through the leads map, never a presence entry) and its status gate.
Unchanged: an observer still holds no TASK_READ, so it cannot poll the
ticket a wait:false send returns.

Tests: observer -> lead allowed and observer -> collaborator / architect
slot / spawned member refused over denyFor, each with a positive control
in the same test; the same matrix over the REST route; a real MCP client
through the real MessageService and Injector to the real herdr call,
proving the [fleet_send from observer term_...] prefix reaches a lead's
pane and that the lead passes the production readiness gate; and the
observer's fleet_list panes rows now carrying the lead.

mvn clean install in fleetd/: Tests run: 2208, Failures: 0, Errors: 0,
Skipped: 0 — BUILD SUCCESS. Reverting the widening in CallerResolver
alone turns 7 of the new assertions red across all five touched test
classes, so none of them passes vacuously.
This commit is contained in:
Dai Ha
2026-10-06 06:20:43 +02:00
parent 98f3cd5aa7
commit 72ea7def0a
10 changed files with 392 additions and 108 deletions
@@ -80,34 +80,35 @@ public final class Authz {
/** /**
* The fail-closed classifier for an observer's {@code SEND}: answers no for every target, so * The fail-closed classifier for an observer's {@code SEND}: answers no for every target, so
* the grant is refused unless a caller supplies a real one. {@code * the grant is refused unless a caller supplies a real one. {@code
* CallerResolver#sendableObserverTarget()} is the real one, read from the same maps {@code * CallerResolver#observerSendTarget()} is the real one, read from the same maps {@code
* CallerResolver#resolve} consults, so a target that classifier calls known is one {@code * CallerResolver#resolve} consults, so a target that classifier accepts is one {@code resolve}
* resolve} would actually resolve as {@link Role#OBSERVER}. * would actually resolve as a lead ({@link Role#PRIMARY}) or as {@link Role#OBSERVER}.
*/ */
public static final Predicate<String> NO_KNOWN_OBSERVER_TARGET = target -> false; public static final Predicate<String> NO_OBSERVER_SEND_TARGET = target -> false;
/** /**
* Convenience form for a caller with no classifier to supply. Fails closed: a collaborator's * Convenience form for a caller with no classifier to supply. Fails closed: a collaborator's
* or an observer's {@code SEND} is refused, as if no terminal were a configured lead, * or an observer's {@code SEND} is refused, as if no terminal were a configured lead,
* collaborator, or observer target — the same decision {@link #NO_KNOWN_LEAD_OR_COLLABORATOR} * collaborator, or observer-reachable target — the same decision
* and {@link #NO_KNOWN_OBSERVER_TARGET} give explicitly. Every other action's result is * {@link #NO_KNOWN_LEAD_OR_COLLABORATOR} and {@link #NO_OBSERVER_SEND_TARGET} give explicitly.
* identical to the five-argument form's, since none of them consult either classifier. * Every other action's result is identical to the five-argument form's, since none of them
* consult either classifier.
* *
* <p>Its default classifiers deny every collaborator and every observer, so a caller * <p>Its default classifiers deny every collaborator and every observer, so a caller
* enforcing authorization must use the five-argument form instead. * enforcing authorization must use the five-argument form instead.
*/ */
public static boolean permits(Principal caller, Action action, String targetSession) { public static boolean permits(Principal caller, Action action, String targetSession) {
return permits(caller, action, targetSession, NO_KNOWN_LEAD_OR_COLLABORATOR, NO_KNOWN_OBSERVER_TARGET); return permits(caller, action, targetSession, NO_KNOWN_LEAD_OR_COLLABORATOR, NO_OBSERVER_SEND_TARGET);
} }
/** /**
* As {@link #permits(Principal, Action, String)}, with a real classifier for a collaborator's * As {@link #permits(Principal, Action, String)}, with a real classifier for a collaborator's
* {@code SEND}. An observer's {@code SEND} still fails closed ({@link #NO_KNOWN_OBSERVER_TARGET}) — * {@code SEND}. An observer's {@code SEND} still fails closed ({@link #NO_OBSERVER_SEND_TARGET}) —
* a caller enforcing both grants must use the five-argument form. * a caller enforcing both grants must use the five-argument form.
*/ */
public static boolean permits(Principal caller, Action action, String targetSession, public static boolean permits(Principal caller, Action action, String targetSession,
Predicate<String> knownLeadOrCollaborator) { Predicate<String> knownLeadOrCollaborator) {
return permits(caller, action, targetSession, knownLeadOrCollaborator, NO_KNOWN_OBSERVER_TARGET); return permits(caller, action, targetSession, knownLeadOrCollaborator, NO_OBSERVER_SEND_TARGET);
} }
/** /**
@@ -122,14 +123,15 @@ public final class Authz {
* consulted only for a collaborator's {@code SEND}, to confine * consulted only for a collaborator's {@code SEND}, to confine
* it to another named peer and never a spawned member's * it to another named peer and never a spawned member's
* terminal * terminal
* @param knownObserverTarget whether a terminal is one this daemon would itself resolve as * @param observerSendTarget whether a terminal is one this daemon would itself resolve as
* {@link Role#OBSERVER} — consulted only for an observer's * a lead ({@link Role#PRIMARY}) or as {@link Role#OBSERVER} —
* {@code SEND}, to confine it to another observer pane and never * consulted only for an observer's {@code SEND}, to confine it
* a lead, a collaborator, or a spawned member * to a lead or another observer pane and never a collaborator,
* an architect, or a spawned member
*/ */
public static boolean permits(Principal caller, Action action, String targetSession, public static boolean permits(Principal caller, Action action, String targetSession,
Predicate<String> knownLeadOrCollaborator, Predicate<String> knownLeadOrCollaborator,
Predicate<String> knownObserverTarget) { Predicate<String> observerSendTarget) {
if (caller == null || caller.isAnonymous()) { if (caller == null || caller.isAnonymous()) {
return false; // authenticated as nothing ⇒ authorized for nothing return false; // authenticated as nothing ⇒ authorized for nothing
} }
@@ -143,12 +145,12 @@ public final class Authz {
// Delivering a turn to a local session is open to the primary and the architect // Delivering a turn to a local session is open to the primary and the architect
// unconditionally. A collaborator may reach only a target that is itself a configured // unconditionally. A collaborator may reach only a target that is itself a configured
// lead or collaborator, never a spawned member's terminal. An observer may reach only // lead or collaborator, never a spawned member's terminal. An observer may reach only
// a target that would itself resolve as an observer, never a lead, a collaborator, or // a target that would itself resolve as a lead or as another observer, never a
// a spawned member. A worker is excluded from every case — sending would be it // collaborator, an architect, or a spawned member. A worker is excluded from every
// escalating into the orchestrator role. // case — sending would be it escalating into the orchestrator role.
case SEND -> caller.isPrimary() || caller.isArchitect() case SEND -> caller.isPrimary() || caller.isArchitect()
|| (caller.isCollaborator() && knownLeadOrCollaborator.test(targetSession)) || (caller.isCollaborator() && knownLeadOrCollaborator.test(targetSession))
|| (caller.isObserver() && knownObserverTarget.test(targetSession)); || (caller.isObserver() && observerSendTarget.test(targetSession));
// Resolving a worker's blocked question is part of delegating to it, open to the same // Resolving a worker's blocked question is part of delegating to it, open to the same
// two roles that may stand up that delegation in the first place. Not a collaborator: // two roles that may stand up that delegation in the first place. Not a collaborator:
@@ -271,22 +271,27 @@ public final class CallerResolver {
} }
/** /**
* Whether {@code target} names a terminal this resolver would itself resolve as {@link * Whether {@code target} names a terminal an observer may {@code SEND} to: one this resolver
* Role#OBSERVER} — the classifier an observer's {@code SEND} is checked against, read from the * would itself resolve as a lead ({@link Role#PRIMARY}) or as {@link Role#OBSERVER}. Read from
* same maps and functions {@link #resolve} consults so a target this accepts is exactly one * the same maps and functions {@link #resolve} consults, and in the same order, so a target
* {@code resolve} would hand back {@link Role#OBSERVER} for, and the reverse. * this accepts is exactly one {@code resolve} would hand back one of those two roles for, and
* the reverse.
*
* <p>A live spawned member is refused first, whatever a tab map says about its terminal — the
* order {@link #resolve} itself uses. A pane named as a lead is then accepted even when it is
* also bound to an architect slot, because that is the role {@code resolve} gives it.
*/ */
public Predicate<String> sendableObserverTarget() { public Predicate<String> observerSendTarget() {
return target -> target != null return target -> target != null
&& spawnedMemberRole.apply(target) == null && spawnedMemberRole.apply(target) == null
&& !leadTerminals.get().containsKey(target) && (leadTerminals.get().containsKey(target)
&& !boundToArchitectSlot(target) || (!boundToArchitectSlot(target)
&& !collaboratorTerminals.get().containsKey(target); && !collaboratorTerminals.get().containsKey(target)));
} }
/** /**
* Whether {@code terminal} is bound to a configured slot the live roster still confirms as an * Whether {@code terminal} is bound to a configured slot the live roster still confirms as an
* architect — the one classifier {@link #sendableObserverTarget()} and {@code FleetMcp}'s * architect — the one classifier {@link #observerSendTarget()} and {@code FleetMcp}'s
* {@code panes} row both read, so a pane's reported role and its {@code SEND} reachability can * {@code panes} row both read, so a pane's reported role and its {@code SEND} reachability can
* never drift apart. * never drift apart.
*/ */
@@ -52,9 +52,9 @@ public enum Role {
* like a worker's — derived from the connection's pane, never from a request argument, and * like a worker's — derived from the connection's pane, never from a request argument, and
* honoured regardless of auth mode. May {@code READ} and {@code METRICS}, {@code REPLY}/ * honoured regardless of auth mode. May {@code READ} and {@code METRICS}, {@code REPLY}/
* {@code ASK} only as its own pane, and {@code SEND} only to a target that would itself * {@code ASK} only as its own pane, and {@code SEND} only to a target that would itself
* resolve as {@code OBSERVER}; may not {@code SPAWN}/{@code STOP}/{@code DRAIN}/ * resolve as a lead ({@link #PRIMARY}) or as {@code OBSERVER}; may not {@code SPAWN}/
* {@code HANDOVER}, poll a ticket ({@code TASK_READ}), or reach the coordination broker * {@code STOP}/{@code DRAIN}/{@code HANDOVER}, poll a ticket ({@code TASK_READ}), or reach the
* ({@code COORD_SEND}/{@code COORD_READ}). * coordination broker ({@code COORD_SEND}/{@code COORD_READ}).
*/ */
OBSERVER, OBSERVER,
@@ -121,9 +121,9 @@ public final class FleetMcp {
/** /**
* Kept as a field (rather than only captured by the {@code contextExtractor} closure) so * Kept as a field (rather than only captured by the {@code contextExtractor} closure) so
* {@link #denyFor} can read {@link CallerResolver#knownLeadOrCollaborator()} and {@link * {@link #denyFor} can read {@link CallerResolver#knownLeadOrCollaborator()} and {@link
* CallerResolver#sendableObserverTarget()} — the classifiers a collaborator's and an * CallerResolver#observerSendTarget()} — the classifiers a collaborator's and an observer's
* observer's {@code SEND} are each checked against, built from the same maps {@link #identity}- * {@code SEND} are each checked against, built from the same maps {@link #identity}-based
* based resolution reads. * resolution reads.
*/ */
private final CallerResolver callers; private final CallerResolver callers;
private final Metrics metrics; // CB-502: null → auth failures not counted private final Metrics metrics; // CB-502: null → auth failures not counted
@@ -323,12 +323,12 @@ public final class FleetMcp {
* injector, keyed by terminal id — never a second, separately-derived check * injector, keyed by terminal id — never a second, separately-derived check
* @param architectSlot the same classifier {@link CallerResolver#boundToArchitectSlot} resolves * @param architectSlot the same classifier {@link CallerResolver#boundToArchitectSlot} resolves
* a caller against — never a second, separately-derived check * a caller against — never a second, separately-derived check
* @param sendableToObserver the same predicate {@link CallerResolver#sendableObserverTarget} * @param observerSendTarget the same predicate {@link CallerResolver#observerSendTarget}
* builds for the {@code SEND} gate — never a second, separately-derived * builds for the {@code SEND} gate — never a second, separately-derived
* check * check
*/ */
public record PaneSource(Supplier<Map<String, String>> tabLabels, Supplier<Map<String, String>> workspaceLabels, public record PaneSource(Supplier<Map<String, String>> tabLabels, Supplier<Map<String, String>> workspaceLabels,
Predicate<String> deliverable, Predicate<String> architectSlot, Predicate<String> sendableToObserver) { Predicate<String> deliverable, Predicate<String> architectSlot, Predicate<String> observerSendTarget) {
/** Inert source — no labels, no deliverable targets, no architect slots, nothing sendable. */ /** Inert source — no labels, no deliverable targets, no architect slots, nothing sendable. */
public static PaneSource none() { public static PaneSource none() {
return new PaneSource(Map::of, Map::of, _ -> false, _ -> false, _ -> false); return new PaneSource(Map::of, Map::of, _ -> false, _ -> false, _ -> false);
@@ -616,7 +616,7 @@ public final class FleetMcp {
PaneSource panes = new PaneSource(() -> identity.panes().tabLabelsByTabId(), PaneSource panes = new PaneSource(() -> identity.panes().tabLabelsByTabId(),
() -> identity.panes().workspaceLabelsByWorkspaceId(), () -> identity.panes().workspaceLabelsByWorkspaceId(),
Fleetd.deliverableTo(presence, callers::leads, callers::collaborators), Fleetd.deliverableTo(presence, callers::leads, callers::collaborators),
callers::boundToArchitectSlot, callers.sendableObserverTarget()); callers::boundToArchitectSlot, callers.observerSendTarget());
return listFleet(workers, sessions, messages, capacity, healthCoverage, loopHealth, quarantine, outage, return listFleet(workers, sessions, messages, capacity, healthCoverage, loopHealth, quarantine, outage,
leadSeats, leadContextGauge, leadConfigDirs, callers.leads(), leadSeats, leadContextGauge, leadConfigDirs, callers.leads(),
callerTerminal(exchange), callerTerminal(exchange),
@@ -764,7 +764,7 @@ public final class FleetMcp {
return null; // AuthorizationMode.UNENFORCED: authorization not enforced (fleetd #518) return null; // AuthorizationMode.UNENFORCED: authorization not enforced (fleetd #518)
} }
if (Authz.permits(caller, action, target, callers.knownLeadOrCollaborator(), if (Authz.permits(caller, action, target, callers.knownLeadOrCollaborator(),
callers.sendableObserverTarget())) { callers.observerSendTarget())) {
if (action != Authz.Action.READ && action != Authz.Action.TASK_READ) { if (action != Authz.Action.READ && action != Authz.Action.TASK_READ) {
AuditLog.allowed(caller, action, target); // reads would drown the trail AuditLog.allowed(caller, action, target); // reads would drown the trail
} }
@@ -826,13 +826,15 @@ public final class FleetMcp {
} }
/** /**
* Who may see {@code fleet_list}'s {@code leads} array — exactly the roles that may * Who may see {@code fleet_list}'s {@code leads} array — the primary, an architect, and a
* {@link Authz.Action#SEND} to a lead: the primary, an architect, and a collaborator. A * collaborator. A collaborator's own {@code fleet_whoami} carries no lead address, and this is
* collaborator's own {@code fleet_whoami} carries no lead address, and {@code leads} is the * the only place this tool gives one, so a collaborator needs this array to use the
* only place this tool gives one, so a collaborator needs this array to use the send it * {@link Authz.Action#SEND} it already holds. An observer holds that send to a lead too, but
* already holds. A worker can never {@code SEND} at all, so it still sees neither this array * learns the address from its filtered {@code panes} rows instead: a {@code leads} row carries
* nor {@code members}; a worker's own facts come from {@code fleet_whoami} instead. Split * a lead's name, its configured context window and its config dir, which are the fleet's own
* out for the same reason as {@link #coordinatorVisibleTo} and * shape rather than an address. A worker can never {@code SEND} at all, so it still sees
* neither this array nor {@code members}; a worker's own facts come from {@code fleet_whoami}
* instead. Split out for the same reason as {@link #coordinatorVisibleTo} and
* {@link #collaboratorsVisibleTo}: the decision must be unit-testable without fabricating an * {@link #collaboratorsVisibleTo}: the decision must be unit-testable without fabricating an
* SDK {@code McpSyncServerExchange}, and the handler must call this named predicate rather * SDK {@code McpSyncServerExchange}, and the handler must call this named predicate rather
* than inlining the check. * than inlining the check.
@@ -855,9 +857,10 @@ public final class FleetMcp {
/** /**
* Who may see {@code fleet_list}'s {@code panes} array — every role that may * Who may see {@code fleet_list}'s {@code panes} array — every role that may
* {@link Authz.Action#SEND} to some other pane. A plain worker holds {@code READ} but never * {@link Authz.Action#SEND} to some other pane. A plain worker holds {@code READ} but never
* {@code SEND}, so it still does not see this array. An observer does hold {@code SEND}, to * {@code SEND}, so it still does not see this array. An observer does hold {@code SEND}, to a
* another observer pane only, so it sees the array too — but {@code listFleet} filters its rows * lead or another observer pane, so it sees the array too — and it is the only place this tool
* to {@link CallerResolver#sendableObserverTarget} and reduces each one; see {@code paneRows}. * gives it a lead's address. {@code listFleet} filters its rows to
* {@link CallerResolver#observerSendTarget} and reduces each one; see {@code paneRows}.
*/ */
static boolean panesVisibleTo(Principal caller) { static boolean panesVisibleTo(Principal caller) {
return caller.isPrimary() || caller.isArchitect() || caller.isCollaborator() || caller.isObserver(); return caller.isPrimary() || caller.isArchitect() || caller.isCollaborator() || caller.isObserver();
@@ -2155,8 +2158,8 @@ public final class FleetMcp {
} }
/** /**
* As above, plus fleetd #758: an observer sees the {@code panes} array too, but filtered to * As above, plus: an observer sees the {@code panes} array too, but filtered to
* {@link CallerResolver#sendableObserverTarget} and each row reduced to the five fields an * {@link CallerResolver#observerSendTarget} and each row reduced to the five fields an
* observer may learn — see {@code paneRows}/{@code paneRow}. * observer may learn — see {@code paneRows}/{@code paneRow}.
* *
* @param callerIsObserver whether the {@code fleet_list} caller is an observer; every wrapper * @param callerIsObserver whether the {@code fleet_list} caller is an observer; every wrapper
@@ -2579,9 +2582,10 @@ public final class FleetMcp {
* already read, so a pane neither configured as a lead nor spawned as a member — a hand-opened * already read, so a pane neither configured as a lead nor spawned as a member — a hand-opened
* tab — still gets a row here. * tab — still gets a row here.
* *
* <p>fleetd #758: for an observer caller ({@code observerView}), the rows are filtered to * <p>For an observer caller ({@code observerView}), the rows are filtered to
* {@link PaneSource#sendableToObserver} before being built, and each row is reduced — see * {@link PaneSource#observerSendTarget} before being built, and each row is reduced — see
* {@code paneRow}. * {@code paneRow}. A lead's pane survives that filter, so it is where an observer reads a
* lead's {@code sessionId}.
*/ */
private static List<Map<String, Object>> paneRows(Map<String, Agent> live, List<MemberSession> roster, private static List<Map<String, Object>> paneRows(Map<String, Agent> live, List<MemberSession> roster,
Map<String, String> leads, Map<String, String> collaborators, PaneSource panes, Map<String, String> leads, Map<String, String> collaborators, PaneSource panes,
@@ -2592,7 +2596,7 @@ public final class FleetMcp {
.filter(s -> s.terminalId() != null) .filter(s -> s.terminalId() != null)
.collect(Collectors.toMap(MemberSession::terminalId, Function.identity(), (_, b) -> b)); .collect(Collectors.toMap(MemberSession::terminalId, Function.identity(), (_, b) -> b));
return live.values().stream() return live.values().stream()
.filter(a -> !observerView || panes.sendableToObserver().test(a.terminalId())) .filter(a -> !observerView || panes.observerSendTarget().test(a.terminalId()))
.sorted(Comparator.comparing(Agent::terminalId)) .sorted(Comparator.comparing(Agent::terminalId))
.map(a -> paneRow(a, byTerminal.get(a.terminalId()), leads, collaborators, tabLabels, .map(a -> paneRow(a, byTerminal.get(a.terminalId()), leads, collaborators, tabLabels,
workspaceLabels, panes, observerView)) workspaceLabels, panes, observerView))
@@ -2607,9 +2611,9 @@ public final class FleetMcp {
* @param workspaceLabels workspace id → its herdr display label (the space name); a workspace * @param workspaceLabels workspace id → its herdr display label (the space name); a workspace
* absent here, or carrying a {@code null} label itself, projects as a * absent here, or carrying a {@code null} label itself, projects as a
* {@code null} "workspaceLabel" * {@code null} "workspaceLabel"
* @param observerView fleetd #758: an observer's row carries only {@code sessionId}, {@code * @param observerView an observer's row carries only {@code sessionId}, {@code label},
* label}, {@code status}, {@code role}, {@code deliverable} — never {@code * {@code status}, {@code role}, {@code deliverable} — never {@code paneId}
* paneId} (the {@code fleet_stop} handle), {@code workspaceId}, * (the {@code fleet_stop} handle), {@code workspaceId},
* {@code workspaceLabel}, {@code tabId}, {@code agentType}, or {@code cwd} * {@code workspaceLabel}, {@code tabId}, {@code agentType}, or {@code cwd}
* (a member's worktree path is the lead's business) * (a member's worktree path is the lead's business)
*/ */
@@ -2857,9 +2861,12 @@ public final class FleetMcp {
return tool(FleetTool.LIST.wireName(), return tool(FleetTool.LIST.wireName(),
"List the whole fleet the bridge tracks, in two parts. 'members' is visible to " "List the whole fleet the bridge tracks, in two parts. 'members' is visible to "
+ "the primary and an architect only. 'leads' is visible to those two AND a " + "the primary and an architect only. 'leads' is visible to those two AND a "
+ "collaborator — exactly the roles that may fleet_send to a lead, so a " + "collaborator, so a collaborator can learn a lead's sessionId before using "
+ "collaborator can learn a lead's sessionId before using the send it already " + "the send it already holds. An observer may fleet_send to a lead too, but "
+ "holds. A worker holds READ to call this tool at all, but gets neither " + "reads that sessionId from its own 'panes' rows instead: a 'panes' row for "
+ "an observer is filtered to the panes it may send to — a lead's pane and "
+ "another observer's — and reduced to sessionId, label, status, role and "
+ "deliverable. A worker holds READ to call this tool at all, but gets neither "
+ "array, never an empty one; a worker reads its own session, " + "array, never an empty one; a worker reads its own session, "
+ "profile, state, worktree, branch and owner from fleet_whoami instead. " + "profile, state, worktree, branch and owner from fleet_whoami instead. "
+ "'leads' are your PEERS — other " + "'leads' are your PEERS — other "
@@ -285,13 +285,12 @@ public final class FleetApp {
/** /**
* As {@link #permitsFor(Principal, Authz.Action, String, Predicate)}, also threading the * As {@link #permitsFor(Principal, Authz.Action, String, Predicate)}, also threading the
* classifier an observer's {@code SEND} is checked against; pass {@link #auth}'s own * classifier an observer's {@code SEND} is checked against; pass {@link #auth}'s own
* {@code sendableObserverTarget()} to exercise the real production gate, as {@link #allow} * {@code observerSendTarget()} to exercise the real production gate, as {@link #allow} does.
* does.
*/ */
static boolean permitsFor(Principal caller, Authz.Action action, String target, static boolean permitsFor(Principal caller, Authz.Action action, String target,
Predicate<String> knownLeadOrCollaborator, Predicate<String> knownLeadOrCollaborator,
Predicate<String> knownObserverTarget) { Predicate<String> observerSendTarget) {
return Authz.permits(caller, action, target, knownLeadOrCollaborator, knownObserverTarget); return Authz.permits(caller, action, target, knownLeadOrCollaborator, observerSendTarget);
} }
/** /**
@@ -307,7 +306,7 @@ public final class FleetApp {
return true; // legacy: authorization not enforced return true; // legacy: authorization not enforced
} }
Principal caller = ctx.attribute(CALLER); Principal caller = ctx.attribute(CALLER);
if (permitsFor(caller, action, target, auth.knownLeadOrCollaborator(), auth.sendableObserverTarget())) { if (permitsFor(caller, action, target, auth.knownLeadOrCollaborator(), auth.observerSendTarget())) {
if (action != Authz.Action.READ && action != Authz.Action.METRICS if (action != Authz.Action.READ && action != Authz.Action.METRICS
&& action != Authz.Action.TASK_READ) { && action != Authz.Action.TASK_READ) {
AuditLog.allowed(caller, action, target); // reads would drown the trail AuditLog.allowed(caller, action, target); // reads would drown the trail
@@ -912,42 +912,65 @@ class CallerResolverTest {
assertFalse(r.knownLeadOrCollaborator().test("term_a")); assertFalse(r.knownLeadOrCollaborator().test("term_a"));
} }
// ── fleetd #743: sendableObserverTarget() reads the same maps and functions resolve() does ──── // ── observerSendTarget() reads the same maps and functions resolve() does, in the same order ──
/** /**
* A terminal this resolver recognises as none of the privileged roles is exactly the one * A terminal this resolver recognises as none of the privileged roles is exactly the one
* {@code resolve} would itself hand back {@link Role#OBSERVER} for. * {@code resolve} would itself hand back {@link Role#OBSERVER} for.
*/ */
@Test @Test
void sendableObserverTargetIsTrueForATerminalKnownAsNoOtherRole() { void observerSendTargetIsTrueForATerminalKnownAsNoOtherRole() {
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
() -> Map.of("term_lead", "opus-5.0"), new MemberRegistry(null), () -> Map.of("term_lead", "opus-5.0"), new MemberRegistry(null),
t -> "term_worker".equals(t) ? MemberRole.DEV : null, t -> "term_worker".equals(t) ? MemberRole.DEV : null,
() -> Map.of("term_collab", "ops")); () -> Map.of("term_collab", "ops"));
assertTrue(r.sendableObserverTarget().test("term_other")); assertTrue(r.observerSendTarget().test("term_other"));
} }
/**
* A configured lead's own terminal is reachable: an observer may open a conversation with a
* lead, and this is the classifier that grant is checked against.
*/
@Test @Test
void sendableObserverTargetIsFalseForALeadTerminal() { void observerSendTargetIsTrueForALeadTerminal() {
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
() -> Map.of("term_lead", "opus-5.0"), new MemberRegistry(null), t -> null, Map::of); () -> Map.of("term_lead", "opus-5.0"), new MemberRegistry(null), t -> null, Map::of);
assertFalse(r.sendableObserverTarget().test("term_lead"), assertTrue(r.observerSendTarget().test("term_lead"),
"a lead's own terminal must never be a sendable observer target"); "a lead's own terminal must be reachable from an observer pane");
}
/**
* A pane named as a lead AND bound to an architect slot resolves as the lead, because
* {@code resolve} reads the lead map first — so the classifier must accept it, or a pane's
* resolved role and its reachability would disagree.
*/
@Test
void observerSendTargetIsTrueForALeadTerminalThatIsAlsoABoundArchitectSlot() {
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
() -> Map.of("term_a", "opus-5.0"),
boundMembers("architect:lead-designer", MemberRole.ARCHITECT), t -> null, Map::of);
assertEquals(Role.PRIMARY, r.resolve("127.0.0.1", 42, null).role(),
"premise: the lead map is read before the architect registry");
assertTrue(r.observerSendTarget().test("term_a"));
} }
@Test @Test
void sendableObserverTargetIsFalseForACollaboratorTerminal() { void observerSendTargetIsFalseForACollaboratorTerminal() {
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Map::of, CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
() -> Map.of("term_lead", "opus-5.0"),
new MemberRegistry(null), t -> null, () -> Map.of("term_collab", "ops")); new MemberRegistry(null), t -> null, () -> Map.of("term_collab", "ops"));
assertFalse(r.sendableObserverTarget().test("term_collab"), assertFalse(r.observerSendTarget().test("term_collab"),
"a collaborator's own terminal must never be a sendable observer target"); "a collaborator's own terminal must never be reachable from an observer pane");
// CONTROL: the same wiring, a target recognised as no configured role at all.
assertTrue(r.observerSendTarget().test("term_other"));
} }
@Test @Test
void sendableObserverTargetIsFalseForALiveSpawnedMembersTerminal() { void observerSendTargetIsFalseForALiveSpawnedMembersTerminal() {
// Covers both a worker and an architect: spawnedMemberRole.apply(target) is non-null for // Covers both a worker and an architect: spawnedMemberRole.apply(target) is non-null for
// either, and resolve() never falls through to OBSERVER once it is. // either, and resolve() never falls through to OBSERVER once it is.
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Map::of, CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Map::of,
@@ -958,26 +981,47 @@ class CallerResolverTest {
default -> null; default -> null;
}, Map::of); }, Map::of);
assertFalse(r.sendableObserverTarget().test("term_worker")); assertFalse(r.observerSendTarget().test("term_worker"));
assertFalse(r.sendableObserverTarget().test("term_architect")); assertFalse(r.observerSendTarget().test("term_architect"));
// CONTROL: the same wiring, a target the member lookup above answers null for.
assertTrue(r.observerSendTarget().test("term_other"));
}
/**
* A lead map entry does not rescue a terminal a live spawned member occupies: the member
* lookup runs first, exactly as in {@code resolve}.
*/
@Test
void observerSendTargetIsFalseForASpawnedMemberOnATerminalTheLeadMapAlsoNames() {
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
() -> Map.of("term_worker", "opus-5.0"), new MemberRegistry(null),
t -> "term_worker".equals(t) ? MemberRole.DEV : null, Map::of);
assertFalse(r.observerSendTarget().test("term_worker"));
// CONTROL: the same wiring, the same lead map, a terminal no member occupies.
assertTrue(r.observerSendTarget().test("term_other"));
} }
@Test @Test
void sendableObserverTargetIsFalseForABoundArchitectSlotWithNoLiveMember() { void observerSendTargetIsFalseForABoundArchitectSlotWithNoLiveMember() {
// The edge case resolve() itself carries: a terminal bound to a configured architect slot // The edge case resolve() itself carries: a terminal bound to a configured architect slot
// but with no live spawned-member session yet. // but with no live spawned-member session yet.
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Map::of, CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Map::of,
boundMembers("architect:lead-designer", MemberRole.ARCHITECT), t -> null, Map::of); boundMembers("architect:lead-designer", MemberRole.ARCHITECT), t -> null, Map::of);
assertFalse(r.sendableObserverTarget().test("term_a")); assertFalse(r.observerSendTarget().test("term_a"));
// CONTROL: the same wiring, a terminal the bind above never touched.
assertTrue(r.observerSendTarget().test("term_other"));
} }
@Test @Test
void sendableObserverTargetIsFalseForANullTarget() { void observerSendTargetIsFalseForANullTarget() {
CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Map::of, CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Map::of,
new MemberRegistry(null), t -> null, Map::of); new MemberRegistry(null), t -> null, Map::of);
assertFalse(r.sendableObserverTarget().test(null)); assertFalse(r.observerSendTarget().test(null));
// CONTROL: the same wiring, a non-null target.
assertTrue(r.observerSendTarget().test("term_other"));
} }
} }
@@ -270,18 +270,19 @@ class FleetMcpAuthzTest {
"a spawned member's own terminal must stay unreachable, even once the classifier is real"); "a spawned member's own terminal must stay unreachable, even once the classifier is real");
} }
// --- fleetd #743: the observer SEND matrix, over MCP's denyFor ------------------------------- // --- the observer SEND matrix, over MCP's denyFor -------------------------------------------
private static final Principal OBSERVER = Principal.observer("term_observer", 700); private static final Principal OBSERVER = Principal.observer("term_observer", 700);
/** /**
* Wires one real {@link CallerResolver} that recognises a lead, a collaborator, and a live * Wires one real {@link CallerResolver} that recognises a lead, a collaborator, and a live
* spawned worker, leaving "term_other_observer" classified as none of them — so the same * spawned worker, leaving "term_other_observer" classified as none of them — so the same
* wiring both denies an observer's {@code SEND} to every privileged role and grants it to * wiring denies an observer's {@code SEND} to a collaborator and to a member while granting
* another unclassified pane, proving the refusals are the rule and not a missing fixture. * it to a lead and to another unclassified pane, proving the refusals are the rule and not a
* missing fixture.
*/ */
@Test @Test
void anObserverMaySendOnlyToAnotherObserverNeverToALeadWorkerOrArchitect() { void anObserverMaySendToALeadOrAnotherObserverButNeverToACollaboratorOrAMember() {
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> 999_999); ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> 999_999);
CallerResolver callers = CallerResolver.withLeadsAndMembers(identity, false, null, CallerResolver callers = CallerResolver.withLeadsAndMembers(identity, false, null,
() -> Map.of("term_lead_known", "lead-x"), new MemberRegistry(null), () -> Map.of("term_lead_known", "lead-x"), new MemberRegistry(null),
@@ -289,22 +290,23 @@ class FleetMcpAuthzTest {
() -> Map.of("term_collab_known", "ops2")); () -> Map.of("term_collab_known", "ops2"));
FleetMcp m = mcp(true, callers); FleetMcp m = mcp(true, callers);
assertNotNull(m.denyFor(OBSERVER, Authz.Action.SEND, "term_lead_known"), assertNull(m.denyFor(OBSERVER, Authz.Action.SEND, "term_lead_known"),
"an observer must never reach a lead's terminal"); "an observer must reach a lead's terminal, so a peer session can open a "
+ "conversation with a lead");
assertNotNull(m.denyFor(OBSERVER, Authz.Action.SEND, "term_collab_known"), assertNotNull(m.denyFor(OBSERVER, Authz.Action.SEND, "term_collab_known"),
"an observer must never reach a collaborator's terminal"); "an observer must never reach a collaborator's terminal");
assertNotNull(m.denyFor(OBSERVER, Authz.Action.SEND, "term_a"), assertNotNull(m.denyFor(OBSERVER, Authz.Action.SEND, "term_a"),
"an observer must never reach a live spawned member's terminal"); "an observer must never reach a live spawned member's terminal");
// CONTROL: the same wiring, the same denyFor call, a target recognised as none of the // CONTROL: the same wiring, the same denyFor call, a target recognised as none of the
// three privileged roles above -- this is what proves the three refusals above are the // configured roles above -- this is what proves the two refusals above are the rule
// rule working, not a classifier that refuses every target regardless of what it is. // working, not a classifier that refuses every target regardless of what it is.
assertNull(m.denyFor(OBSERVER, Authz.Action.SEND, "term_other_observer"), assertNull(m.denyFor(OBSERVER, Authz.Action.SEND, "term_other_observer"),
"an observer must reach another pane that resolves as an observer itself"); "an observer must reach another pane that resolves as an observer itself");
} }
/** /**
* As {@link #anObserverMaySendOnlyToAnotherObserverNeverToALeadWorkerOrArchitect}, for a * As {@link #anObserverMaySendToALeadOrAnotherObserverButNeverToACollaboratorOrAMember}, for a
* terminal bound to a configured architect slot but hosting no live spawned-member session -- * terminal bound to a configured architect slot but hosting no live spawned-member session --
* the case {@link CallerResolver#resolve} itself treats separately from a live worker/architect. * the case {@link CallerResolver#resolve} itself treats separately from a live worker/architect.
*/ */
@@ -324,6 +326,26 @@ class FleetMcpAuthzTest {
assertNull(m.denyFor(OBSERVER, Authz.Action.SEND, "term_other_observer")); assertNull(m.denyFor(OBSERVER, Authz.Action.SEND, "term_other_observer"));
} }
/**
* An observer's reach is widened for {@code SEND} alone. It still holds no {@code TASK_READ},
* so it cannot poll a ticket or read a lead's session status, and no {@code SPAWN}/
* {@code STOP}/{@code COORD_SEND}, so it cannot drive the fleet it can now message.
*/
@Test
void anObserverReachingALeadStillHoldsNoTicketReadAndNoLifecycle() {
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> 999_999);
CallerResolver callers = CallerResolver.withLeadsAndMembers(identity, false, null,
() -> Map.of("term_lead_known", "lead-x"), new MemberRegistry(null), t -> null, Map::of);
FleetMcp m = mcp(true, callers);
assertNull(m.denyFor(OBSERVER, Authz.Action.SEND, "term_lead_known"),
"premise: this wiring grants the observer's send to that lead");
assertNotNull(m.denyFor(OBSERVER, Authz.Action.TASK_READ, "term_lead_known"));
assertNotNull(m.denyFor(OBSERVER, Authz.Action.SPAWN, null));
assertNotNull(m.denyFor(OBSERVER, Authz.Action.STOP, null));
assertNotNull(m.denyFor(OBSERVER, Authz.Action.COORD_SEND, null));
}
@Test @Test
void theLegacyConstructorLeavesTheGateOpen() { void theLegacyConstructorLeavesTheGateOpen() {
// The 22 pre-existing FleetMcpTest cases rely on no authorization being enforced. // The 22 pre-existing FleetMcpTest cases rely on no authorization being enforced.
@@ -477,9 +499,10 @@ class FleetMcpAuthzTest {
/** /**
* {@link FleetMcp#leadsVisibleTo} is the whole policy decision for {@code fleet_list}'s * {@link FleetMcp#leadsVisibleTo} is the whole policy decision for {@code fleet_list}'s
* {@code leads} array: visible to exactly the roles that may {@code SEND} to a lead -- the * {@code leads} array: visible to the primary, an architect, and a collaborator -- never a
* primary, an architect, and a collaborator -- never a worker, which holds {@code READ} but * worker, which holds {@code READ} but can never {@code SEND} at all, never an observer, which
* can never {@code SEND} at all, and never an anonymous caller. * reads a lead's address from its filtered {@code panes} rows instead, and never an anonymous
* caller.
*/ */
@Test @Test
void primaryArchitectAndCollaboratorMaySeeTheLeadsArray() { void primaryArchitectAndCollaboratorMaySeeTheLeadsArray() {
@@ -489,6 +512,9 @@ class FleetMcpAuthzTest {
"a collaborator may SEND to a lead, so it must see the leads array to learn where"); "a collaborator may SEND to a lead, so it must see the leads array to learn where");
assertFalse(FleetMcp.leadsVisibleTo(WORKER_A), assertFalse(FleetMcp.leadsVisibleTo(WORKER_A),
"a worker holds READ but can never SEND, so it must not see the leads array"); "a worker holds READ but can never SEND, so it must not see the leads array");
assertFalse(FleetMcp.leadsVisibleTo(Principal.observer("term_obs", 700)),
"an observer may SEND to a lead but learns the address from its panes rows, which "
+ "carry no lead name, context window or config dir");
assertFalse(FleetMcp.leadsVisibleTo(ANON), "authenticated as nothing must not see it either"); assertFalse(FleetMcp.leadsVisibleTo(ANON), "authenticated as nothing must not see it either");
} }
@@ -513,8 +539,8 @@ class FleetMcpAuthzTest {
/** /**
* {@link FleetMcp#panesVisibleTo} is the whole policy decision for {@code fleet_list}'s * {@link FleetMcp#panesVisibleTo} is the whole policy decision for {@code fleet_list}'s
* {@code panes} array: visible to every role that may {@code SEND} to some other pane -- the * {@code panes} array: visible to every role that may {@code SEND} to some other pane -- the
* primary, an architect, a collaborator, and an observer (to another observer pane only, with * primary, an architect, a collaborator, and an observer (to a lead or another observer pane,
* its row filtered and reduced -- see {@code listFleet}) -- never a worker, never an * with its rows filtered and reduced -- see {@code listFleet}) -- never a worker, never an
* anonymous caller. * anonymous caller.
*/ */
@Test @Test
@@ -525,8 +551,8 @@ class FleetMcpAuthzTest {
assertFalse(FleetMcp.panesVisibleTo(WORKER_A), assertFalse(FleetMcp.panesVisibleTo(WORKER_A),
"a worker holds READ but can never SEND, so it must not see the panes array"); "a worker holds READ but can never SEND, so it must not see the panes array");
assertTrue(FleetMcp.panesVisibleTo(Principal.observer("term_obs", 700)), assertTrue(FleetMcp.panesVisibleTo(Principal.observer("term_obs", 700)),
"an observer holds SEND to another observer pane, so it must see the (filtered, " "an observer holds SEND to a lead and to another observer pane, so it must see the "
+ "reduced) panes array"); + "(filtered, reduced) panes array");
assertFalse(FleetMcp.panesVisibleTo(ANON), "authenticated as nothing must not see it either"); assertFalse(FleetMcp.panesVisibleTo(ANON), "authenticated as nothing must not see it either");
} }
@@ -0,0 +1,178 @@
package dev.ltms.fleet.mcp;
import dev.ltms.fleet.Fleetd;
import dev.ltms.fleet.auth.CallerResolver;
import dev.ltms.fleet.auth.MemberRegistry;
import dev.ltms.fleet.config.FleetConfig;
import dev.ltms.fleet.guard.SubscriptionGuard;
import dev.ltms.fleet.herdr.AgentControl;
import dev.ltms.fleet.herdr.AgentStatus;
import dev.ltms.fleet.herdr.FakeHerdr;
import dev.ltms.fleet.herdr.PaneLocator;
import dev.ltms.fleet.herdr.WorkspaceControl;
import dev.ltms.fleet.inject.Injector;
import dev.ltms.fleet.inject.MemberPresence;
import dev.ltms.fleet.inject.TurnListener;
import dev.ltms.fleet.member.ClaudeCodeLauncher;
import dev.ltms.fleet.msg.InMemoryReplyInbox;
import dev.ltms.fleet.msg.MessageService;
import dev.ltms.fleet.msg.Rendezvous;
import dev.ltms.fleet.session.FakeWorktrees;
import dev.ltms.fleet.session.SessionManager;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpSchema;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* An observer's {@code fleet_send} to a lead, driven end to end: a real MCP client over a real
* HTTP transport, resolved by the real {@link CallerResolver} to
* {@link dev.ltms.fleet.auth.Role#OBSERVER}, through the real {@link MessageService} and the real
* {@link Injector} to the point of its real herdr call.
*
* <p>{@link FleetMcpAuthzTest} proves {@code denyFor} grants this case. This is the path that also
* proves the grant is not dead at the injector's readiness gate: that gate is the production
* {@link Fleetd#deliverableTo} predicate, and the lead's terminal carries no
* {@link MemberPresence} entry, so the delivery can only pass by the lead being a configured lead.
*/
class FleetMcpObserverSendToLeadDeliveryTest {
private static final String LEAD = "term_lead_pane";
private static final String COLLABORATOR = "term_collab_pane";
private final FakeHerdr herdr = new FakeHerdr();
private final AgentControl agents = new AgentControl(herdr);
private final Rendezvous rendezvous = new Rendezvous();
private final MemberPresence presence = new MemberPresence();
private Injector injector;
private MessageService messages;
private FleetMcp mcp;
private Server server;
private String baseUrl;
@BeforeEach
void startServer() throws Exception {
FleetConfig.Profile cfg = new FleetConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN", null,
"tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null);
ClaudeCodeLauncher workers = new ClaudeCodeLauncher(agents, new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
_ -> "tok");
SessionManager sessions = new SessionManager(workers, new FakeWorktrees());
// The fake's pane list carries a second pane, "term_shell", whose shell pid is 9001 and
// which hosts no agent -- so the real resolver lands the caller on the observer floor.
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> 9001L);
CallerResolver callers = CallerResolver.withLeadsAndMembers(identity, false, null,
() -> Map.of(LEAD, "fleet01-lead"), new MemberRegistry(null),
_ -> null, () -> Map.of(COLLABORATOR, "ops"));
Predicate<String> deliverable =
Fleetd.deliverableTo(presence, callers::leads, callers::collaborators);
injector = new Injector(agents, TurnListener.NOOP, deliverable);
messages = new MessageService(agents, injector, rendezvous, new InMemoryReplyInbox());
mcp = new FleetMcp(messages, workers, sessions, identity, presence,
new PrimaryRegistry(null), callers, FleetMcp.AuthorizationMode.ENFORCED,
null, FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"),
FleetMcp.QuarantineSource.none(), null, FleetMcp.OutageSource.none(),
FleetMcp.LeadSeatSource.none(), List.of(), null);
ServletContextHandler handler = new ServletContextHandler();
handler.setContextPath("/");
handler.addServlet(new ServletHolder(mcp.servlet()), "/mcp");
server = new Server(0);
server.setHandler(handler);
server.start();
baseUrl = "http://127.0.0.1:"
+ ((ServerConnector) server.getConnectors()[0]).getLocalPort();
}
@AfterEach
void tearDown() throws Exception {
if (server != null) {
server.stop();
}
if (mcp != null) {
mcp.close();
}
}
@Test
void anObserversSendToALeadIsAttributedAndReachesTheRealInjector() throws Exception {
assertFalse(presence.isPresent(LEAD),
"premise: the lead's terminal is deliverable only as a configured lead, never "
+ "through a presence entry");
McpSchema.CallToolResult result = sendFleetSend(LEAD, "can we split the review?");
assertFalse(result.isError(), "an observer sending to a lead must be accepted: "
+ textOf(result));
long waiterDeadline = System.currentTimeMillis() + 3000;
while (!rendezvous.isWaiting(LEAD) && System.currentTimeMillis() < waiterDeadline) {
Thread.sleep(5);
}
assertTrue(rendezvous.isWaiting(LEAD), "the async send must have opened its rendezvous waiter");
injector.onStatus(LEAD, AgentStatus.IDLE); // drives the real delivery attempt to herdr
long deliveryDeadline = System.currentTimeMillis() + 3000;
while (!herdr.called("agent.prompt") && System.currentTimeMillis() < deliveryDeadline) {
Thread.sleep(5);
}
assertTrue(herdr.called("agent.prompt"), "the delivery attempt must have reached herdr");
@SuppressWarnings("unchecked")
Map<String, Object> params = (Map<String, Object>) herdr.lastCall("agent.prompt").params();
assertEquals("[fleet_send from observer term_shell]\ncan we split the review?",
params.get("text"),
"a lead must see the sender's own daemon-resolved terminal, never a raw echo of "
+ "the content and never a client-supplied name");
}
/**
* Control for the test above, over the same live server and the same wiring: the grant is
* specific to a lead target, so a collaborator's terminal is still refused at the handler and
* nothing is ever queued for it.
*/
@Test
void thatSameObserverIsStillRefusedACollaboratorsTerminal() {
McpSchema.CallToolResult result = sendFleetSend(COLLABORATOR, "can we split the review?");
assertTrue(result.isError(), "an observer must not reach a collaborator's terminal");
assertFalse(rendezvous.isWaiting(COLLABORATOR),
"a refused send must never open a waiter for its target");
}
private McpSchema.CallToolResult sendFleetSend(String target, String content) {
McpClientTransport transport = HttpClientStreamableHttpTransport.builder(baseUrl)
.endpoint("/mcp")
.build();
try (McpSyncClient client = McpClient.sync(transport).build()) {
client.initialize();
return client.callTool(McpSchema.CallToolRequest.builder("fleet_send")
.arguments(Map.of("sessionId", target, "content", content, "wait", false))
.build());
}
}
private static String textOf(McpSchema.CallToolResult r) {
return ((McpSchema.TextContent) r.content().getFirst()).text();
}
}
@@ -1239,7 +1239,7 @@ class FleetMcpTest {
/** /**
* fleetd #756: a pane bound to a configured architect slot with no live member session must * fleetd #756: a pane bound to a configured architect slot with no live member session must
* report {@code role: "architect"}, read from {@link CallerResolver#boundToArchitectSlot} — * report {@code role: "architect"}, read from {@link CallerResolver#boundToArchitectSlot} —
* the same classifier {@link CallerResolver#sendableObserverTarget} refuses as a {@code SEND} * the same classifier {@link CallerResolver#observerSendTarget} refuses as a {@code SEND}
* target — rather than falling through to {@code "observer"}. * target — rather than falling through to {@code "observer"}.
*/ */
@Test @Test
@@ -1276,14 +1276,13 @@ class FleetMcpTest {
} }
/** /**
* fleetd #758: an observer's {@code fleet_list} now carries a {@code panes} key, filtered to * An observer's {@code fleet_list} carries a {@code panes} key, filtered to
* {@link CallerResolver#sendableObserverTarget} (so a lead's pane, a spawned member's pane, a * {@link CallerResolver#observerSendTarget} (so a lead's pane survives, while a spawned
* collaborator's pane, and an unoccupied architect-slot pane are all absent) and every * member's pane, a collaborator's pane and an unoccupied architect-slot pane are all absent)
* surviving row reduced to exactly {@code sessionId}, {@code label}, {@code status}, * and every surviving row reduced to exactly {@code sessionId}, {@code label}, {@code status},
* {@code role}, {@code deliverable} — never {@code paneId}, {@code workspaceId}, * {@code role}, {@code deliverable} — never {@code paneId}, {@code workspaceId},
* {@code workspaceLabel} (fleetd #771 — a space name is host shape, a stronger disclosure than * {@code workspaceLabel} (a space name is host shape, a stronger disclosure than a pane id, so
* a pane id, so it stays out of the reduced row too), {@code tabId}, {@code agentType}, or * it stays out of the reduced row too), {@code tabId}, {@code agentType}, or {@code cwd}.
* {@code cwd}.
*/ */
@Test @Test
void listFiltersAndReducesThePanesArrayForAnObserver() { void listFiltersAndReducesThePanesArrayForAnObserver() {
@@ -1306,7 +1305,7 @@ class FleetMcpTest {
FleetMcp.PaneSource panes = new FleetMcp.PaneSource( FleetMcp.PaneSource panes = new FleetMcp.PaneSource(
() -> new PaneLocator(h).tabLabelsByTabId(), () -> new PaneLocator(h).tabLabelsByTabId(),
() -> new PaneLocator(h).workspaceLabelsByWorkspaceId(), _ -> true, () -> new PaneLocator(h).workspaceLabelsByWorkspaceId(), _ -> true,
callers::boundToArchitectSlot, callers.sendableObserverTarget()); callers::boundToArchitectSlot, callers.observerSendTarget());
String out = textOf(listFleetAsObserver(h, callers.leads(), String out = textOf(listFleetAsObserver(h, callers.leads(),
Map.of("term_collab_pane", "ops"), panes)); Map.of("term_collab_pane", "ops"), panes));
@@ -1314,7 +1313,10 @@ class FleetMcpTest {
assertTrue(out.contains("\"panes\":["), out); assertTrue(out.contains("\"panes\":["), out);
assertTrue(out.contains("\"sessionId\":\"term_sendable\""), assertTrue(out.contains("\"sessionId\":\"term_sendable\""),
"an ordinary unclassified pane must still be sendable and visible: " + out); "an ordinary unclassified pane must still be sendable and visible: " + out);
assertFalse(out.contains("term_lead_pane"), "a lead's pane must not be enumerated: " + out); assertTrue(out.contains("\"sessionId\":\"term_lead_pane\""),
"a lead's pane is where an observer reads the sessionId its send needs: " + out);
assertTrue(out.contains("\"role\":\"lead\""),
"the lead's row must name the role, so an observer can tell it from a peer pane: " + out);
assertFalse(out.contains("term_member_pane"), "a spawned member's pane must not be enumerated: " + out); assertFalse(out.contains("term_member_pane"), "a spawned member's pane must not be enumerated: " + out);
assertFalse(out.contains("term_collab_pane"), "a collaborator's pane must not be enumerated: " + out); assertFalse(out.contains("term_collab_pane"), "a collaborator's pane must not be enumerated: " + out);
assertFalse(out.contains("term_architect_pane"), assertFalse(out.contains("term_architect_pane"),
@@ -694,6 +694,27 @@ class FleetAppAuthTest {
assertEquals(403, toSpawnedMembersTerminal.statusCode(), toSpawnedMembersTerminal.body()); assertEquals(403, toSpawnedMembersTerminal.statusCode(), toSpawnedMembersTerminal.body());
} }
/**
* The REST route must give the same answer as MCP for an observer: pid 9001 resolves to
* "term_shell", a herdr pane recognised as no configured role, so the real
* {@link CallerResolver#observerSendTarget()} classifier reaches the known lead and refuses
* the known collaborator -- over the route, not just the unit-level classifier, so a grant
* covering only MCP cannot leave this one behind.
*/
@Test
void anObserverMaySendToAKnownLeadButNotToAKnownCollaboratorOverRest() throws Exception {
int port = startWithRealClassifier(9001L,
Map.of("term_lead_known", "lead-x"), Map.of("term_collab_known", "ops2"));
HttpResponse<String> toLead = send(port, "POST", "/sessions/term_lead_known/message",
"{\"content\":\"hi\",\"wait\":false}", null);
assertEquals(202, toLead.statusCode(), toLead.body());
HttpResponse<String> toCollaborator = send(port, "POST", "/sessions/term_collab_known/message",
"{\"content\":\"hi\",\"wait\":false}", null);
assertEquals(403, toCollaborator.statusCode(), toCollaborator.body());
}
/** /**
* As {@link #start}, but with explicit lead/collaborator maps and no spawned-member roster, so * As {@link #start}, but with explicit lead/collaborator maps and no spawned-member roster, so
* a test can wire the real {@link CallerResolver#knownLeadOrCollaborator()} classifier instead * a test can wire the real {@link CallerResolver#knownLeadOrCollaborator()} classifier instead