Repository navigation
feat(attention): GET /api/attention/summary + summary-only token scope - #385
Merged
Merged
Conversation
…uster attention summary` What a status bar polls, and a credential it can hold safely. GET /api/attention/summary (personal token only, requireAPIToken): every item waiting on the token's owner — exactly s.attentionItems, no second "waiting" rule — each with its host, title (textplain.Title of its source) and why (the first line of attentionPushBody: secrets redacted, a waiting session summary only, a reply draft who-and-where only), age, and its link (the tap route /attention/open/<key> for an ask or a session, the item's own page otherwise; absolute on MUSTER_PUBLIC_URL). Per host: online, status_report, busy and idle Claude windows (an offline host counts nothing). Global: nudge_mode (off | shadow | live for this owner), ts, and partial when a queue source could not be read. No per-item lookups, no stream. Contract in docs/attention.md#the-summary-api. A reply draft's item now carries its draftPushBody, and attentionPushBody returns it for a plugin item, so "the push body" is defined for every kind and the summary never quotes an inbound message. Read-only personal tokens (migration 0068, api_tokens.read_only default false): minted with a checkbox on /account/tokens (unchecked = full, as before); the list, /api/tokens and whoami show the scope. The check is at the one token door (admitAPIToken), by an allowlist of METHODS (GET, HEAD): any other method on any route a personal token reaches is 403 before its handler runs. Existing tokens stay full access. Rollback note in the migration. CLI: `muster attention summary` (verb 24; ledgers moved), JSON passthrough, existing exit codes (401/403 -> 3). A poller keeps its own token in its own file via the existing --env-file. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner
Author
|
Audit round 0 (requirements & deletion) at The audit found 15 requirements, 5 of them unattributed. All 5 were kept apart from D1 and D2. |
Owner
Author
|
The operator's answers to the round-0 premises, relayed word for word: |
… only
Round-0 answers on the PR.
Q1: the read_only boolean becomes a token SCOPE. Migration 0068 is edited in
place (unreleased): api_tokens.scope TEXT NOT NULL DEFAULT 'full', CHECK
(scope IN ('full', 'summary')). A 'summary' token reaches GET (and so HEAD)
/api/attention/summary and NOTHING else: every other route a personal token
reaches, the hook-tier door included, answers 403 before its handler runs.
The rule is one allowlist, tokenScopeRoutes (scope -> mux patterns, read
from http.Request.Pattern), checked in the single admit path
(admitAPIToken); only 'full' is unlimited, and an unknown or blank scope
reaches nothing. GET /api/whoami is denied to a summary token on purpose: a
bar does not need it, and the summary's own 200/401/403 already says
whether the token works. Minting is a choice between "Full access" (the
default) and "Bar summary only (can read the Attention summary and nothing
else)"; the token page, /api/tokens (muster token ls) and whoami show the
scope. An unknown scope at mint is a 400. Existing tokens stay full.
D1: the `muster attention summary` verb and everything that only it used
are gone (verb count back to 23). The bar GETs the endpoint over HTTPS.
D2: the item's `path` field is dropped; `url` alone remains, absolute on
MUSTER_PUBLIC_URL and "" when that is unset (documented).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Round-1 audit findings (final round). - One Hosts() read per summary. The handler reads the hub once and hands the snapshot to attentionItemsIn -> waitingSessionsIn, so a flapping host cannot be online in hosts[] while its item says offline. attentionItems and waitingSessions keep their signatures and read the hub themselves. TestTheSummaryReadsTheHubOnce: red at 69c3b85 ("read the hub 2 times"), green here. - 0068 header + docs/schema.md: mint 'summary' tokens only after the rollout completes. A pre-0068 pod ignores scope, so a summary token minted mid-rollout (RollingUpdate, maxUnavailable 0) has full access on every request an old pod answers. - 0068 rollback: api_tokens is under FORCED row-level security, so the revoke must run with SET LOCAL muster.scope = 'all'. Measured against a throwaway Postgres, with counts taken as the superuser: as muster_app without the scope the UPDATE left 1 of 1 live summary tokens (and raised no error); with it, 0. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner
Author
|
Audit round 1 (full, at
No route bypasses the scope check. Every personal-token door goes through |
ZacxDev
marked this pull request as ready for review
November 1, 2026 05:45
ZacxDev
added a commit
that referenced
this pull request
Nov 1, 2026
…mary's one hub read - attentionItemsIn (#385) takes the caller's ONE read of the hub; the ask loop had a second, lazy read of its own for mirrored sessions' asks. It now names them from that same read, so a summary reads the hub once with a mirrored ask open too (new seam test; a second read is caught as "read the hub 2 times; want 1"). - #385's summary tests pin the new titles and whys: "tmux main:1 @ ws", "Waiting: tmux main:1 @ ws"; the unit fixture's ask is built with its window, "Gold:1 · tmux main:4 @ ws"; the member-leak list also refuses the operator's "tmux main:1", with a control that the operator's own summary carries it. - An answered ask's page passes its source through textplain.Title before it reaches the delivery line (internal/textplain's title-sink ledger caught the raw read, introduced in the previous commit). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ZacxDev
added a commit
that referenced
this pull request
Nov 1, 2026
…388) * fix(attention): asks and waiting sessions name the exact tmux target An ask, a waiting session and their notifications named a mirrored window by its host's codename ("mango:1 @ ws", which is no tmux name) or by its tmux session alone with no window ("build @ ws"), so the operator could not find it. Every attention surface that names a mirrored window now takes its words from one helper, mirror.Where: "tmux <session>:<index> @ <host>", led by the codename when the host has one ("mango:1 · tmux scratch3:5 @ ws"). - chiefnudge.Session carries the window's Index (filled by stallInputs), and nudgeAskQuestion builds its "where" from mirror.Where; agents keep "(agent <name>)". - A waiting session / held question / Hermes item's source is e.Where(). - A mirrored session's ask resolves the window running its Claude session NOW (claudeSessionEntry, the rule claudeSessionWindow already used) for its source on the queue, its page header, its session page's asks block and its notification; when no window runs it, its stored name is followed by "(not shown in any mirrored window now)" instead of an invented target. - Docs: attention.md "How a window is named", chief-nudges.md ask text. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor(attention): keep askItem's and nudgeAskQuestion's signatures for the in-flight PRs Test-merged with the two open PRs that touch these functions: one changes nudgeAskQuestion's parameters, the other's test calls askItem(a). So: - askItem takes its hosts variadically: askItem(a) still compiles (a mirrored session's ask is then named as no window showing it); every production caller passes the owner's windows. The test call sites go back to main's. - The nudge ask's "where" is its own function, nudgeAskWhere, placed away from nudgeAskQuestion; nudgeAskQuestion's only change is calling it. The unit tests pin nudgeAskWhere whole; the live-ask PG test now pins the whole question built through the real path. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor(chief-nudges): the ask's place is nudgeAskPlace, leaving nudgeAskQuestion's head untouched Test-merged with the open PR that changes nudgeAskQuestion's parameters: a helper replacing the function's first lines conflicted with it textually. Only the mirror/agent branch now changes (it calls nudgeAskPlace), as in the first commit, which merged cleanly. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(chief-nudges): the generic ask's pinned head names the exact tmux target #384 (now on main) pinned the generic ask's head as "A session (Gold:1 @ ws)"; with this branch the place is mirror.Where's "(Gold:1 · tmux main:1 @ ws)". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(attention): delivery lines and the chief's session reads name the exact tmux target The operator's "always" is wider than the open-ask surfaces: an answered ask's card and its delivery line ("Queued for X …", "Waiting for X to read it …") send them to the session too, and the chief only ever saw a session as its codename and host, so an ask the chief wrote could not name the window. D1, delivery: answerDeliveryText takes its asker's name, and every caller passes askSource's — the Answered list (answeredAsksNow reads the owner's windows once, when an answer is a mirrored session's) and an answered ask's own page (its item's Source). A window no longer shown reads "(not shown in any mirrored window now)", never an invented target. D2, the chief: every session listing it reads carries the same text, as `where` (additive; every existing field kept): - fleet_list rows, sessions_overview rows (and the session hints built from the same live sessions), fleet_report rows on the chief's door only (the mail door names no host, so it never carries it); - a session question's block: "where: <askSource>" inside the fence; - route candidates and an online PR author's window line. The supervisor prompt's shared live-reads paragraph says to give that `where` exactly when pointing the owner at a session (its digest re-recorded after reading the diff). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(attention): with #385 merged, asks name their window from the summary's one hub read - attentionItemsIn (#385) takes the caller's ONE read of the hub; the ask loop had a second, lazy read of its own for mirrored sessions' asks. It now names them from that same read, so a summary reads the hub once with a mirrored ask open too (new seam test; a second read is caught as "read the hub 2 times; want 1"). - #385's summary tests pin the new titles and whys: "tmux main:1 @ ws", "Waiting: tmux main:1 @ ws"; the unit fixture's ask is built with its window, "Gold:1 · tmux main:4 @ ws"; the member-leak list also refuses the operator's "tmux main:1", with a control that the operator's own summary carries it. - An answered ask's page passes its source through textplain.Title before it reaches the delivery line (internal/textplain's title-sink ledger caught the raw read, introduced in the previous commit). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(attention): a window's tmux target is shown verbatim, never markdown-stripped The title rule (textplain.Title) plains markdown out of every page and notification title; on a where it also plained the tmux target itself, so a session named "_scratch_" read "scratch", "*dev*" read "dev" and "a`b`c" read "abc" — places the operator cannot find. - textplain.TitleKeeping(s, keep): Title over s, keep kept verbatim. keep is mirror.WhereTarget ("tmux <session>:<index> @ <host>", Where's machine part), carried on attentionItem (target) and RouterNotification (TitleKeep). Every surface that plains a Source uses it: the push body and the push title sink (goNotify), the queue card, the summary title/why, the ask page header and delivery line, the Answered list. The codename around the target is still plained; per-surface escaping is unchanged. - The title-sink ledger records the exemption, counts TitleKeeping as the rule, tracks Where / askSource / askSourceParts reads, and refuses a plain Title over any value that can name a window ("where kept verbatim", with a positive control). - A where from a host the hub still lists but has dropped ends " (host offline)" (an ask's source, the chief's fleet_report). - The session page's asks block reads the hub only for a mirrored ask (its comment said so; the code did not). - docs/attention.md: the summary's why is "Waiting: <tool> · <where>"; the target is verbatim; "(host offline)". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
GET /api/attention/summary: what a status bar polls. It takes a personal token only (requireAPIToken) and is owner-scoped.0068):full(the default; every existing token) orsummary. Asummarytoken reachesGET(andHEAD)/api/attention/summaryand nothing else.Reworked per the round-0 answers:
urlonly; thepathfield is gone.Why
The bar needs one cheap, owner-scoped read of "what is waiting on me", plus a credential on disk that can do nothing but that read.
How
The summary
s.attentionItems(ctx). It is the same function the Asks page, the badge andattention_listuse, and there is no second predicate. Busy and idle are context only.titleistextplain.Title(Source).whyis the first line ofattentionPushBody. Secrets are redacted, a session is summary only, and a draft is who and where only.Detail, options or suggestions.draftPushBody, andattentionPushBodyreturns it forpluginitems, so the summary never quotes an inbound message.urlisMUSTER_PUBLIC_URL+ the tap route/attention/open/<key>for an ask or a session, or + the item's own link for a draft or a task. It is""whenMUSTER_PUBLIC_URLis unset. There are no per-item lookups (noByAsk, no agent read, no focus read), so the cost is about one badge render.online,status_report, andbusy/idlecounts of Claude (or agent-runtime) windows. An offline host counts 0/0.nudge_modeisoff, orchiefNudgeMode(owner)when chief nudges are on.tsis server time.partialis set when a queue source failed.Scopes
tokenScopeRoutes(scope → mux patterns, matched onhttp.Request.Pattern), is checked in the single admit pathadmitAPIToken. Every token wrapper goes through that path, including the hook-tier door atauth.go:438. The current table issummary → {"GET /api/attention/summary"};net/httpservesHEADthrough the GET pattern.fullis unlimited. An unknown or blank scope reaches nothing, and a request with no matched pattern is refused to every scope butfull. A route added later is refused to a summary token until someone adds it to the table.GET /api/whoamiis denied to a summary token, on purpose. A bar doesn't need it, and the summary's own 200/401/403 already tells you whether the token works. There was no strong reason to allow it.GET /api/tokens(muster token ls,"scope") andwhoamishow the scope. An unknown scope at mint is a 400, andapi_tokens_scope_checkbacks that up in the database.summarytoken as full access, so the migration header says to revoke all non-fulltokens first.Contract (docs/attention.md#the-summary-api)
{"ts":1800000000,"nudge_mode":"off","partial":false,"hosts":[{"host":"host-a","online":true,"status_report":true,"busy":1,"idle":0}],"items":[{"key":"ask-5","kind":"ask","host":"","title":"chief","why":"Ship it?","since":1799999940,"age_secs":60,"offline":false,"url":"https://muster.example.test/attention/open/ask-5"}]}kindis one ofask,session,question,plugin,task.nudge_modeis one ofoff,shadow,live.hostsanditemsare never null.urlis""whenMUSTER_PUBLIC_URLis unset.TestTheSummaryDocumentIsTheDocumentedShape.Tests
origin/main: the new tests do not compile (undefined: ScopeFull,undefined: attentionSummary).go test -racepassed underscripts/with-test-pg.sh(DB required) forinternal/api,internal/apitokens,internal/dbandcmd/muster.internal/chiefnudgewas added to the same run, and all passed.go buildandgo vetpass, the leakscan is clean, andmake verb-ledgerreports 23/23.Scope tests:
TestABarSummaryTokenReachesTheSummaryAndNothingElse: GET/HEAD summary → 200. A summary token gets 403 with the scope sentence before the handler runs (store List/Revoke never called) on these 12 routes:GET /api/agents/chief/messagesGET /api/agentsGET /api/tasksGET /api/tasks/1GET /api/flows/jobsGET /api/whoamiGET /api/tokensDELETE /api/tokens/1POST /api/tasksPATCH /api/tasks/1/statusPOST /api/flows/jobsPOST /api/agents/chief/messagesThe same requests with a full token are not refused, and its List and Revoke reach the store.
TestATokenOfAnUnknownScopeReachesNothing:"",adminandSummaryare refused everywhere, the summary included.TestTheScopeAllowlistIsExactlyTheSummaryRoute: pins the whole table, and checks that its pattern is inroutes.golden.TestTheMintPageOffersABarSummaryTokenandTestAMintNamingAnUnknownScopeIsABadRequest.TestATokensScopeIsStoredAndReadBack(PG): checks the record, list and Authenticate; that an unknown scope is refused before any write; the DB CHECK; the audit scope; andNOT NULL DEFAULT 'full'.TestABarSummaryTokenMintedOnThePageReadsTheSummaryAndNothingElse(PG): the end-to-end loop. A summary token gets the summary, and is refused onGET /api/tasks,GET /api/whoamiand revoking itself, after which it is still live. A full token minted on the same page reads/api/tasks.Mutation battery (detached copy, real Postgres). The unmutated control was 20 PASS, 0 FAIL, 0 SKIP. "Killed" means every named test failed, counted from
--- FAILlines.tokenScopeAdmitsalways true)TestABarSummaryTokenReachesTheSummaryAndNothingElse,TestATokenOfAnUnknownScopeReachesNothing, PG loopsummaryTestABarSummaryTokenReachesTheSummaryAndNothingElse, PG loopGET /api/whoamiTestTheScopeAllowlistIsExactlyTheSummaryRouteTestATokenOfAnUnknownScopeReachesNothingTestATokensScopeIsStoredAndReadBack+ PG loopTestATokensScopeIsStoredAndReadBackTestTheMintPageOffersABarSummaryToken(+ PG loop)TestTheSummaryIsTheOwnersQueueAndHosts, isolation walkTestNoRouteLetsOneUserReachAnothersDataTextTestTheSummaryItemSaysWhatItsPushSays,TestTheSummaryRedactsExactlyAsThePushDoes, PG testurlurlbuilt without a public originTestTheSummaryItemSaysWhatItsPushSaysThe first round of this battery is in the earlier version of this description; those summary-shape mutants are unchanged.
Two harness defects, found and fixed before these numbers:
-runfilter (Scope|UnknownScope) did not matchTestABarSummaryToken…, so those tests never ran and the mutants read as SURVIVED.Not verified: nothing has been deployed. The endpoint and the scope have not been exercised against the live server or a real bar.
🤖 Generated with Claude Code