fleetd #743: let one observer pane message another, and nothing else #754

Closed
agent wants to merge 0 commits from worker/743-observer-send-4706db-6 into main
Member

Closes fleetd #743.

Grant. Authz.permits SEND now carries:

case SEND -> caller.isPrimary() || caller.isArchitect()
        || (caller.isCollaborator() && knownLeadOrCollaborator.test(targetSession))
        || (caller.isObserver() && knownObserverTarget.test(targetSession));

mirroring the existing collaborator clause rather than an unconditional caller.isObserver() grant (fleetd #705 rejected that shape already).

Target classifier. CallerResolver.sendableObserverTarget() answers true only for a terminal that is none of: a live spawned member (spawnedMemberRole), a configured lead, a terminal bound to a confirmed architect slot, or a configured collaborator tab — read from the exact same maps/functions resolve() itself consults, so the classifier's answer is provably consistent with what resolve() would actually hand back.

Attribution. A delivery into an observer's pane is indistinguishable from a human paste, so FleetMcp.attributeIfObserver(caller, content) prefixes the text with the sender's own connection-resolved terminal ([fleet_send from observer term_x] ...) whenever the caller is an observer; every other caller's content is unchanged. Wired into both the MCP sendHandler and FleetApp#sendMessage (after the blank-content check, so a blank observer SEND still 400s instead of silently becoming non-blank).

Gates traced. FleetMcp.denyFor/FleetApp.allow -> Authz.permits is the only authorization gate before delivery; MessageService.send"/"sendAsync pass content through unchanged into Rendezvous/Injector.enqueue; the only other pre-delivery check, profileTargetError, blocks configured profile names, not arbitrary pane terminals. Both entry paths now thread the same two classifiers.

Tests.

  • AuthzTest: the observer SEND matrix at the Authz.permits level, including a positive/negative pair proving the classifier (not the role) is what moves the decision.
  • CallerResolverTest: sendableObserverTarget() true for an unclassified terminal, false for a lead, a collaborator, a live spawned member, and a terminal bound to a configured architect slot with no live session.
  • FleetMcpAuthzTest: denyFor wired with a real CallerResolver — observer->lead, observer->collaborator, observer->live-worker, and observer->bound-architect-slot all refused, each paired with a same-wiring positive control to another unclassified pane; plus a source-scrape proving sendHandler actually calls attributeIfObserver.
  • FleetMcpObserverSendDeliveryTest (new): end to end over a real Jetty server and a real MCP client — an observer's fleet_send is accepted, opens the real Rendezvous waiter, and injector.onStatus(..., IDLE) drives the real herdr agent.prompt call; asserts the exact attributed text FakeHerdr recorded.

Collateral fix. FleetAppAuthTest's start() helper never wired a spawnedMemberRole, so its "worker" fixture actually resolved as OBSERVER under the real CallerResolver" — invisible before since OBSERVER and WORKER both got zero SEND grant. Granting OBSERVER a real SEND surfaced it: aWorkerMayNotOrchestrateandaDeniedCallerIsRefusedOnSendEvenWithATurnIdBodyAndNeverReadsTheBodystarted passing for the wrong caller (an observer reaching another unclassified pane, not a worker being refused). Fixed the fixture to resolveterm_a` as a live DEV, matching the helper's own documented contract.

Build. mvn clean install from fleetd/: BUILD SUCCESS. Surefire totals summed fresh from target/surefire-reports/*.xml (178 files): Tests run: 2131, Failures: 0, Errors: 0, Skipped: 0.

Scope. FleetMcp.java changes are confined to the send path (sendHandler, denyFor, the new attributeIfObserver helper) — listHandler"/"listFleet" untouched, verified by diff. FleetApp.java" was also updated (REST parity for the same grant/attribution) even though the ticket named only FleetMcp.java's send path — left unupdated it would have been a second, inconsistent entry path for the same grant.

Closes fleetd #743. **Grant.** `Authz.permits` SEND now carries: ```java case SEND -> caller.isPrimary() || caller.isArchitect() || (caller.isCollaborator() && knownLeadOrCollaborator.test(targetSession)) || (caller.isObserver() && knownObserverTarget.test(targetSession)); ``` mirroring the existing collaborator clause rather than an unconditional `caller.isObserver()` grant (fleetd #705 rejected that shape already). **Target classifier.** `CallerResolver.sendableObserverTarget()` answers true only for a terminal that is none of: a live spawned member (`spawnedMemberRole`), a configured lead, a terminal bound to a confirmed architect slot, or a configured collaborator tab — read from the exact same maps/functions `resolve()` itself consults, so the classifier's answer is provably consistent with what `resolve()` would actually hand back. **Attribution.** A delivery into an observer's pane is indistinguishable from a human paste, so `FleetMcp.attributeIfObserver(caller, content)` prefixes the text with the sender's own connection-resolved terminal (`[fleet_send from observer term_x] ...`) whenever the caller is an observer; every other caller's content is unchanged. Wired into both the MCP `sendHandler` and `FleetApp#sendMessage` (after the blank-content check, so a blank observer SEND still 400s instead of silently becoming non-blank). **Gates traced.** `FleetMcp.denyFor`/`FleetApp.allow` -> `Authz.permits` is the only authorization gate before delivery; `MessageService.send"/"sendAsync` pass `content` through unchanged into `Rendezvous`/`Injector.enqueue`; the only other pre-delivery check, `profileTargetError`, blocks configured profile names, not arbitrary pane terminals. Both entry paths now thread the same two classifiers. **Tests.** - `AuthzTest`: the observer SEND matrix at the `Authz.permits` level, including a positive/negative pair proving the classifier (not the role) is what moves the decision. - `CallerResolverTest`: `sendableObserverTarget()` true for an unclassified terminal, false for a lead, a collaborator, a live spawned member, and a terminal bound to a configured architect slot with no live session. - `FleetMcpAuthzTest`: `denyFor` wired with a real `CallerResolver` — observer->lead, observer->collaborator, observer->live-worker, and observer->bound-architect-slot all refused, each paired with a same-wiring positive control to another unclassified pane; plus a source-scrape proving `sendHandler` actually calls `attributeIfObserver`. - `FleetMcpObserverSendDeliveryTest` (new): end to end over a real Jetty server and a real MCP client — an observer's `fleet_send` is accepted, opens the real `Rendezvous` waiter, and `injector.onStatus(..., IDLE)` drives the real herdr `agent.prompt` call; asserts the exact attributed text FakeHerdr recorded. **Collateral fix.** `FleetAppAuthTest`'s `start()` helper never wired a `spawnedMemberRole`, so its "worker" fixture actually resolved as OBSERVER under the real `CallerResolver" — invisible before since OBSERVER and WORKER both got zero SEND grant. Granting OBSERVER a real SEND surfaced it: `aWorkerMayNotOrchestrate` and `aDeniedCallerIsRefusedOnSendEvenWithATurnIdBodyAndNeverReadsTheBody` started passing for the wrong caller (an observer reaching another unclassified pane, not a worker being refused). Fixed the fixture to resolve `term_a` as a live DEV, matching the helper's own documented contract. **Build.** `mvn clean install` from `fleetd/`: BUILD SUCCESS. Surefire totals summed fresh from `target/surefire-reports/*.xml` (178 files): Tests run: 2131, Failures: 0, Errors: 0, Skipped: 0. **Scope.** `FleetMcp.java` changes are confined to the send path (`sendHandler`, `denyFor`, the new `attributeIfObserver` helper) — `listHandler"/"listFleet" untouched, verified by diff. `FleetApp.java" was also updated (REST parity for the same grant/attribution) even though the ticket named only `FleetMcp.java`'s send path — left unupdated it would have been a second, inconsistent entry path for the same grant.
agent added 1 commit 2026-10-05 09:03:44 +02:00
fleetd #743: let one observer pane message another, and nothing else
CI / shell-tests (pull_request) Failing after 8s
CI / contract (pull_request) Successful in 56s
CI / build (pull_request) Failing after 2m0s
9fdcaaa8fd
Authz.SEND now grants an observer a narrow path: it may reach only a
target that CallerResolver.sendableObserverTarget() would itself
resolve as OBSERVER, never a lead, a collaborator, or a live spawned
member's terminal. This mirrors the existing collaborator SEND clause
rather than adding an unconditional caller.isObserver() grant, which
fleetd #705 already rejected as too broad.

Because the receiving pane cannot otherwise tell an observer's SEND
apart from a human paste, FleetMcp.attributeIfObserver prefixes the
delivered text with the sender's own daemon-resolved terminal on both
the MCP and REST entry paths, for exactly this one new path.

FleetAppAuthTest's start() helper never wired a spawnedMemberRole, so
its "worker" fixture actually resolved as OBSERVER under the real
CallerResolver -- invisible before because OBSERVER and WORKER shared
the same (zero) SEND grant. Granting OBSERVER a real SEND surfaced it:
two SEND-denial tests started passing for the wrong caller. Fixed the
fixture to resolve term_a as a live DEV, matching the helper's own
documented contract.
agent added 1 commit 2026-10-05 09:16:35 +02:00
fleetd #743: drop the redundant source-scrape test for attributeIfObserver
CI / shell-tests (pull_request) Failing after 6s
CI / contract (pull_request) Successful in 57s
CI / build (pull_request) Failing after 2m4s
001367d82c
FleetMcpObserverSendDeliveryTest already kills the same mutation end to
end (it asserts the exact attributed text herdr receives), and the code
quality rule in CLAUDE.md caps new source-text tests in this file at the
existing count.
Owner

Merged locally into main as 92b6d94, pushed (be835aa..02eff4c). Closing because the commits are on main, not because it was rejected.

Verified by the lead (full detail in the #743 comment):

  • Alone on main: MVN_EXIT=0, xml=178 tests=2130 failures=0 errors=0 — matches the worker's claim.
  • Combined with #753: 2137, 0 failures, and the merge was a clean auto-merge in FleetMcp.java, FleetApp.java and FleetMcpAuthzTest.java despite both PRs touching all three.
  • The deletion is safe. Mutating the call site to String content = str(a, "content") kills exactly one class, FleetMcpObserverSendDeliveryTest, on the assertion that the receiving pane sees [fleet_send from observer term_shell]. So the behavioural test, not the deleted source scrape, is what pins the call site — a strictly better pin, because it survives a rename and fails on wrong attribution rather than only on absent attribution.

I also checked the grant against #705's own stated hazard rather than against the diff's rationale. #705 refused option 3 precisely because defaulting unconfigured panes to a role with SEND would let any of them inject text into a lead's pane. This grant does not do that: sendableObserverTarget() excludes spawned members, leads, architect slots and collaborators, and TASK_READ is still primary || worker || architect, so #705 stays closed.

Both authorization gates get the classifier — FleetMcp.denyFor:714 and FleetApp.allow:309 — which is the #669 shape ("a grant at one gate of two is dead") avoided. Authz.permits has no third production call site; I enumerated them.

One residual, recorded in wiki/11-Features.md rather than left for someone to rediscover: the refusal for a lead depends on that lead's terminal being in the scanned lead map, and LeadTabScanner is TTL-cached at scanIntervalSeconds: 10 on this host (fleetd/fleetd.yaml:259). For up to one interval after a lead's labelled tab first appears, an observer's send to it passes the gate. In that same window the lead itself resolves as an observer, and the text arrives with the attribution prefix, so the window is narrow, visible and self-clearing — but it is a window, and attributeIfObserver is what makes it acceptable rather than covert.

Merged locally into `main` as `92b6d94`, pushed (`be835aa..02eff4c`). Closing because the commits are on `main`, not because it was rejected. Verified by the lead (full detail in the #743 comment): - Alone on `main`: `MVN_EXIT=0`, `xml=178 tests=2130 failures=0 errors=0` — matches the worker's claim. - Combined with #753: `2137`, 0 failures, and the merge was a clean auto-merge in `FleetMcp.java`, `FleetApp.java` and `FleetMcpAuthzTest.java` despite both PRs touching all three. - The deletion is safe. Mutating the call site to `String content = str(a, "content")` kills exactly one class, `FleetMcpObserverSendDeliveryTest`, on the assertion that the receiving pane sees `[fleet_send from observer term_shell]`. So the behavioural test, not the deleted source scrape, is what pins the call site — a strictly better pin, because it survives a rename and fails on wrong attribution rather than only on absent attribution. I also checked the grant against #705's own stated hazard rather than against the diff's rationale. #705 refused option 3 precisely because defaulting unconfigured panes to a role with `SEND` would let any of them inject text into a lead's pane. This grant does not do that: `sendableObserverTarget()` excludes spawned members, leads, architect slots and collaborators, and `TASK_READ` is still `primary || worker || architect`, so #705 stays closed. Both authorization gates get the classifier — `FleetMcp.denyFor:714` and `FleetApp.allow:309` — which is the #669 shape ("a grant at one gate of two is dead") avoided. `Authz.permits` has no third production call site; I enumerated them. One residual, recorded in `wiki/11-Features.md` rather than left for someone to rediscover: the refusal for a lead depends on that lead's terminal being in the scanned lead map, and `LeadTabScanner` is TTL-cached at `scanIntervalSeconds: 10` on this host (`fleetd/fleetd.yaml:259`). For up to one interval after a lead's labelled tab first appears, an observer's send to it passes the gate. In that same window the lead itself resolves as an observer, and the text arrives with the attribution prefix, so the window is narrow, visible and self-clearing — but it is a window, and `attributeIfObserver` is what makes it acceptable rather than covert.
ltms closed this pull request 2026-10-05 09:44:02 +02:00
Some checks are pending
CI / shell-tests (pull_request) Failing after 6s
CI / contract (pull_request) Successful in 57s
CI / build (pull_request) Failing after 2m4s

Pull request closed

Sign in to join this conversation.