Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/muster-server/banner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,10 @@ var bannerLedger = []string{
//
// ⚠ EACH REASON BELOW WAS READ OFF THE CODE, NOT INFERRED FROM THE GROUPING.
var bannerExempt = map[string]string{
envCCUsagePushURL: "printed on its own `claude account usage push: ON/OFF` line, which is drawn only under the poller's ON " +
"line; refused at boot with the poller off, whose OFF line names " + envCCUsagePoll,
envCCUsagePushToken: "a secret, never printed; refused at boot without " + envCCUsagePushURL + ", whose state the push line prints",
envCCUsagePushInstance: "printed with its value on the `claude account usage push: ON` line; refused at boot without " + envCCUsagePushURL,
envCCUsagePollInterval: "printed with its value on the `claude account usage: ON` line; refused at boot with the poller off, " +
"whose OFF line names " + envCCUsagePoll,
envAskLogTextTTL: "printed with its value on the `ask log: ON` line; it means nothing with the log off, whose OFF line names " + envAskLog,
Expand Down
48 changes: 48 additions & 0 deletions cmd/muster-server/ccusage_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,23 @@ type ccUsageConfig struct {
// Interval is MUSTER_CC_USAGE_POLL_INTERVAL (ccusage.DefaultInterval, 15m,
// by default), from ccusage.MinInterval to ccusage.MaxInterval.
Interval time.Duration
// Push is the optional pusher's; Push.URL "" is off.
Push ccUsagePushConfig
}

// ccUsagePushConfig is the usage pusher's (MUSTER_CC_USAGE_PUSH_*): after each
// poll cycle it PUTs the readings to URL with Token as a bearer, labelled
// Instance.
type ccUsagePushConfig struct {
URL string
Token string
Instance string
}

// parseCCUsage reads the poller's configuration. The interval is refused
// without the switch: a knob that does nothing reads as if it did something.
func parseCCUsage(getenv func(string) string) (ccUsageConfig, error) {
var err error
c := ccUsageConfig{On: envFlag(getenv, envCCUsagePoll), Interval: ccusage.DefaultInterval}
if v := strings.TrimSpace(getenv(envCCUsagePollInterval)); v != "" {
if !c.On {
Expand All @@ -37,9 +49,45 @@ func parseCCUsage(getenv func(string) string) (ccUsageConfig, error) {
}
c.Interval = d
}
if c.Push, err = parseCCUsagePush(getenv, c.On); err != nil {
return ccUsageConfig{}, err
}
return c, nil
}

// parseCCUsagePush reads the pusher's configuration. Boot is refused when the
// URL is set with the poller off (it would push nothing, ever), without a
// token, or not https (plain http only to a loopback host, for tests); and
// when the token or the instance is set without the URL (a knob that does
// nothing). The token never appears in a refusal.
func parseCCUsagePush(getenv func(string) string, pollerOn bool) (ccUsagePushConfig, error) {
u := strings.TrimSpace(getenv(envCCUsagePushURL))
tok := strings.TrimSpace(getenv(envCCUsagePushToken))
inst := strings.TrimSpace(getenv(envCCUsagePushInstance))
if u == "" {
for _, v := range []struct{ name, val string }{{envCCUsagePushToken, tok}, {envCCUsagePushInstance, inst}} {
if v.val != "" {
return ccUsagePushConfig{}, fmt.Errorf("%s is set but %s is not: it does nothing without the push URL", v.name, envCCUsagePushURL)
}
}
return ccUsagePushConfig{}, nil
}
if !pollerOn {
return ccUsagePushConfig{}, fmt.Errorf("%s is set but %s is not: with the poller off there are no readings to push", envCCUsagePushURL, envCCUsagePoll)
}
if err := ccusage.CheckPushURL(u); err != nil {
return ccUsagePushConfig{}, fmt.Errorf("invalid %s: %v", envCCUsagePushURL, err)
}
if tok == "" {
return ccUsagePushConfig{}, fmt.Errorf("%s is set but %s is not: the push needs its bearer token", envCCUsagePushURL, envCCUsagePushToken)
}
instance, err := ccusage.PushInstance(inst, getenv(envPublicURL))
if err != nil {
return ccUsagePushConfig{}, fmt.Errorf("invalid %s: %v", envCCUsagePushInstance, err)
}
return ccUsagePushConfig{URL: u, Token: tok, Instance: instance}, nil
}

// ccUsageRefusal is why this configuration cannot run the poller, or "": it
// reads the pool's tokens (the claude-code kind) and keeps its readings in the
// database.
Expand Down
61 changes: 61 additions & 0 deletions cmd/muster-server/ccusage_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,64 @@ func TestCCUsagePollerNeedsThePoolAndADatabase(t *testing.T) {
t.Fatalf("on with both: %q", r)
}
}

// TestCCUsagePushRefusalMatrix: the pusher is off unless its URL is set; with
// it, boot is refused when the poller is off, the token is missing, or the URL
// is not https (http only to loopback) — and the token or instance alone is
// refused as a knob that does nothing. No refusal carries the token.
func TestCCUsagePushRefusalMatrix(t *testing.T) {
const tok = "push-token-SENTINEL-1234"
on := map[string]string{envCCUsagePoll: "1"}
with := func(kv map[string]string) map[string]string {
out := map[string]string{}
for k, v := range on {
out[k] = v
}
for k, v := range kv {
out[k] = v
}
return out
}
c, err := parseCCUsage(ccUsageEnv(on))
if err != nil || c.Push != (ccUsagePushConfig{}) {
t.Fatalf("no push variables: %+v %v; want the pusher off", c.Push, err)
}
c, err = parseCCUsage(ccUsageEnv(with(map[string]string{envCCUsagePushURL: "https://dash.example.com/api/usage",
envCCUsagePushToken: tok, envPublicURL: "https://muster.example.com"})))
if err != nil || c.Push != (ccUsagePushConfig{URL: "https://dash.example.com/api/usage", Token: tok, Instance: "muster.example.com"}) {
t.Fatalf("a valid push: %+v %v", c.Push, err)
}
c, err = parseCCUsage(ccUsageEnv(with(map[string]string{envCCUsagePushURL: "http://127.0.0.1:9/x",
envCCUsagePushToken: tok, envCCUsagePushInstance: "lab"})))
if err != nil || c.Push.Instance != "lab" {
t.Fatalf("loopback http with an instance: %+v %v", c.Push, err)
}
c, err = parseCCUsage(ccUsageEnv(with(map[string]string{envCCUsagePushURL: "https://dash.example.com/x", envCCUsagePushToken: tok})))
if err != nil || c.Push.Instance != "muster" {
t.Fatalf("no public URL: instance %q %v, want muster", c.Push.Instance, err)
}
for _, bad := range []struct {
name string
env map[string]string
names string // the variable the refusal must name
}{
{"poller off", map[string]string{envCCUsagePushURL: "https://dash.example.com/x", envCCUsagePushToken: tok}, envCCUsagePoll},
{"no token", with(map[string]string{envCCUsagePushURL: "https://dash.example.com/x"}), envCCUsagePushToken},
{"plain http", with(map[string]string{envCCUsagePushURL: "http://dash.example.com/x", envCCUsagePushToken: tok}), envCCUsagePushURL},
{"relative", with(map[string]string{envCCUsagePushURL: "/api/usage", envCCUsagePushToken: tok}), envCCUsagePushURL},
{"userinfo", with(map[string]string{envCCUsagePushURL: "https://u:" + tok + "@dash.example.com/x", envCCUsagePushToken: tok}), envCCUsagePushURL},
{"instance too long", with(map[string]string{envCCUsagePushURL: "https://dash.example.com/x", envCCUsagePushToken: tok,
envCCUsagePushInstance: strings.Repeat("i", 65)}), envCCUsagePushInstance},
{"token without URL", with(map[string]string{envCCUsagePushToken: tok}), envCCUsagePushToken},
{"instance without URL", with(map[string]string{envCCUsagePushInstance: "lab"}), envCCUsagePushInstance},
} {
_, err := parseCCUsage(ccUsageEnv(bad.env))
if err == nil || !strings.Contains(err.Error(), bad.names) {
t.Errorf("%s: %v; want a refusal naming %s", bad.name, err, bad.names)
continue
}
if strings.Contains(err.Error(), tok) {
t.Errorf("%s: the refusal carries the token: %v", bad.name, err)
}
}
}
6 changes: 5 additions & 1 deletion cmd/muster-server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,9 +284,13 @@ const (

// THE CLAUDE ACCOUNT USAGE POLLER (internal/ccusage, docs/jobs.md "Claude
// account usage"; ccusage_config.go parses them): its switch (off by
// default) and its interval.
// default) and its interval; and its optional pusher, which PUTs each
// cycle's readings to an external dashboard (off unless the URL is set).
envCCUsagePoll = "MUSTER_CC_USAGE_POLL"
envCCUsagePollInterval = "MUSTER_CC_USAGE_POLL_INTERVAL"
envCCUsagePushURL = "MUSTER_CC_USAGE_PUSH_URL"
envCCUsagePushToken = "MUSTER_CC_USAGE_PUSH_TOKEN"
envCCUsagePushInstance = "MUSTER_CC_USAGE_PUSH_INSTANCE"

// Browser flows: the deployment's own infrastructure names (comma-separated)
// that an AGENT's browser-flow text may not carry (internal/flows
Expand Down
2 changes: 1 addition & 1 deletion cmd/muster-server/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ func featureCatalog() []features.Feature {
v(envAgentCCAccounts, "account names"), v(envAgentCCTokenPrefix+"<NAME>", "claude setup-token")},
Settings: []string{envAgentCCStorage, envAgentCCCooldown, envAgentCCMaxJobs, envAgentCCFailover, envAgentCCBypassPools,
envAgentCCPoolDeployKeys, envAgentCCEgressLockedPools, envAgentNetpolFromNS, envAgentNetpolFromLabels, envCheckoutWaitTimeout,
envChiefLLM, envCCUsagePoll, envCCUsagePollInterval}},
envChiefLLM, envCCUsagePoll, envCCUsagePollInterval, envCCUsagePushURL, envCCUsagePushToken, envCCUsagePushInstance}},
// The documented install: the Compose stack's `chief` profile and the
// Compose chief runtime (composechief.go). A Kubernetes deployment runs
// it through MUSTER_AGENT_PROVISIONER instead (docs/chief.md).
Expand Down
18 changes: 18 additions & 0 deletions cmd/muster-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,17 @@ func buildApp(ctx context.Context, cfg config, logger *log.Logger) (*app, error)

srv := api.New(bus, cfg.authConfig(), logger)
srv.UseExtensions(ext)
// The usage pusher (MUSTER_CC_USAGE_PUSH_URL): after each poll cycle the
// readings, exactly as GET /api/cc-accounts/usage answers the admin, are
// PUT to the dashboard. parseCCUsagePush refused it without the poller.
if a.ccUsage != nil && cfg.CCUsage.Push.URL != "" {
push := cfg.CCUsage.Push
a.ccUsage.AfterPass = (&ccusage.Pusher{URL: push.URL, Token: push.Token, Logger: logger,
Client: &http.Client{Timeout: ccusage.PushTimeout},
Body: func(ctx context.Context, now time.Time) ([]byte, error) {
return srv.CCUsagePushBody(ctx, authz.OperatorUserID, push.Instance, now)
}}).PushAfterPass
}
if cfg.goTrueConfigured() && a.pool != nil {
srv.UseGoTrue(goTrueAuth(cfg, a.pool))
}
Expand Down Expand Up @@ -1756,6 +1767,13 @@ func (a *app) logBanner(ext api.Extensions, port router.Port) {
"token, from %s when the token may use it, else from a one-token probe of %s (one output token of quota per read; "+
"never an account the pool marks cooling); numbers only, in cc_account_usage, served at %s", envCCUsagePoll,
ext.CCUsageInterval, ccusage.Endpoint, ccusage.ProbeURL, api.CCAccountUsagePath)
if a.cfg.CCUsage.Push.URL != "" {
l.Printf("claude account usage push: ON (%s) — after each poll cycle the readings are PUT to the configured URL "+
"as source_instance %q (expects 204; a failure is logged and counted, and the next cycle pushes again)",
envCCUsagePushURL, a.cfg.CCUsage.Push.Instance)
} else {
l.Printf("claude account usage push: OFF — %s is unset", envCCUsagePushURL)
}
} else {
l.Printf("claude account usage: OFF — %s is unset; no account's utilization is read", envCCUsagePoll)
}
Expand Down
3 changes: 3 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ See [auth.md](auth.md).
| `MUSTER_ASK_LOG_CLASSIFY` | off | no | `1` runs the stall classifier on a plain-text question the [ask log](asks.md) kept, once per turn end (reusing the stall sweep's answer for the streak when it has one), on the owner's OpenRouter key; its scores are stored on the row. Needs `MUSTER_CHIEF_NUDGES=1` (the classifier's configuration). Off by default so the pre-filter's volume (`muster_ask_plain_text_prefilter_total`) is read before the classifier is spent on it. |
| `MUSTER_CC_USAGE_POLL` | off | no | `1` reads each of the admin's pooled Claude accounts' subscription utilization once per `MUSTER_CC_USAGE_POLL_INTERVAL` and keeps the latest reading per account (numbers only): from the endpoint Claude Code's `/usage` reads when the account's token may use it (one discovery call per token; a `setup-token` is refused there), else from a one-token Messages probe's `anthropic-ratelimit-unified-*` headers, which spends one output token of the account's quota per read. An account the pool marks rate-limited is not probed until its cooldown ends. Served at `GET /api/cc-accounts/usage` and on Account → Credentials ([jobs](jobs.md#claude-account-usage)). muster-server itself calls `api.anthropic.com:443`. Needs the `claude-code` kind and a database, or boot is refused. Members' own accounts are not read. |
| `MUSTER_CC_USAGE_POLL_INTERVAL` | `15m` | no | How often the usage poller reads every account (a Go duration, 5m–24h). A probe answered 429 waits longer (doubling, at least `Retry-After`, at most 6h — or until the window's reset when its status is `rejected`); a token the probe refused (401/403) is not read again until it changes. Refused without `MUSTER_CC_USAGE_POLL`. |
| `MUSTER_CC_USAGE_PUSH_URL` | none | no | Turns on the [usage pusher](jobs.md#pushing-the-readings): after each poll cycle muster `PUT`s the readings, exactly as `GET /api/cc-accounts/usage` answers the admin, to this URL, once, with a 10-second timeout, and expects `204`. A failure is logged (never the token or the body; a 4xx's answer as one sanitized line), counted in `muster_cc_usage_push_failures_total{reason}` and not retried until the next cycle; a redirect is not followed. An absolute `https` URL (plain `http` only to a loopback host); credentials in the URL are refused. Refused without `MUSTER_CC_USAGE_POLL` or without `MUSTER_CC_USAGE_PUSH_TOKEN`. |
| `MUSTER_CC_USAGE_PUSH_TOKEN` | none | with the push URL | The pusher's `Authorization: Bearer` token. Never logged. Refused without `MUSTER_CC_USAGE_PUSH_URL`. |
| `MUSTER_CC_USAGE_PUSH_INSTANCE` | `MUSTER_PUBLIC_URL`'s host, else `muster` | no | The pushed `source_instance` label, at most 64 characters (a public host longer than that falls back to `muster`). Refused without `MUSTER_CC_USAGE_PUSH_URL`. |
| `MUSTER_SESSION_STATE` | off | no | `1` runs session state's pass (design: `docs/design/session-state.md`, #425): every 30 seconds each owner's sessions get a state, and every stop an episode in `session_episodes`. The stall classifier runs only where the chief's nudges would run it. It also replaces the waiting-window push and the chief's stall notice with **one push per stop** (an episode), naming the session and never quoting it, and draws the open stops as the Asks page's **Stalled / waiting** section ([attention.md](attention.md#stalled--waiting-session-state)). Off, every push is as before. Needs a database. |
| `MUSTER_CHIEF_NUDGE_LIVE_OWNERS` | none | no | The user ids, comma-separated, whose stall nudges are **acted on**: a labelled nudge quoting the owner is sent, or a [notice](attention.md#notices) naming where the session is is put on the owner's Asks list (nothing to answer, nothing relayed). A value that is not a list of positive integers refuses to boot. |
| `MUSTER_CHIEF_NUDGE_NEEDS_YOU_LIVE_OWNERS` | none | no | The user ids, comma-separated, whose **needs-you** stall nudges are sent too (with `MUSTER_SESSION_STATE=1`; with it off this does nothing): a nudge to an idle session the stall classifier says asked something, quoting the owner's words that answer it ([chief-nudges.md](chief-nudges.md#needs-you-nudges)). It is sent only for an owner listed here **and** in `MUSTER_CHIEF_NUDGE_LIVE_OWNERS`; for everyone else it is shadow (recorded on the Chief nudges tab, never pasted, and the session's stop pushed). Unset: every owner's needs-you nudges are shadow; their other nudges keep `MUSTER_CHIEF_NUDGE_LIVE_OWNERS` alone. Same format as that variable; a value that is not a list of positive integers refuses to boot. The boot line counts the owners on both lists. Taking an owner off it (and restarting) stops a needs-you nudge already queued at its paste. |
Expand Down
42 changes: 42 additions & 0 deletions docs/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,48 @@ call without an actor.
account only. A job counts on the account its last turn ran on.
- `poll_interval_seconds` is 0 with the poller off; the numbers then never move.

### Pushing the readings

With `MUSTER_CC_USAGE_PUSH_URL` set (and its `MUSTER_CC_USAGE_PUSH_TOKEN`),
muster also **pushes** the readings to an external dashboard instead of
waiting to be read: after each poll cycle, once, it sends

```
PUT <MUSTER_CC_USAGE_PUSH_URL>
Authorization: Bearer <MUSTER_CC_USAGE_PUSH_TOKEN>
Content-Type: application/json

{"source_instance": "muster.example.com",
"read_at": "2026-11-10T18:21:00Z",
"poll_interval_seconds": 900,
"accounts": [ … ]}
```

- `accounts` is the admin's accounts **byte for byte** as `GET
/api/cc-accounts/usage` answers them (above, `used_by` included): both are
built by the same code.
- `source_instance` is `MUSTER_CC_USAGE_PUSH_INSTANCE` (at most 64
characters), else `MUSTER_PUBLIC_URL`'s host, else `muster`. `read_at` is
when the push was built. `poll_interval_seconds` is the poller's
configured interval in whole seconds, as the API's own field: a dashboard can
mark the source stale once a few intervals pass with no push.
- The push is made after **every** cycle, whether or not a reading changed, so
a dashboard can tell a quiet muster from a dead one.
- The dashboard answers **`204`**. Anything else — a 4xx, a 5xx, another 2xx,
a redirect (never followed: it would re-send the bearer), no answer in 10
seconds, a transport error, or readings the store could not give — is logged
(the status or the error's class only, never the token or the body; on a
4xx, also the first 512 bytes of the dashboard's answer as one line, control
characters made spaces, at most 200 characters, the token redacted, so a
refusal such as a cap exceeded says why) and counted in
`muster_cc_usage_push_failures_total{reason}` (`http_4xx`, `http_5xx`,
`http_other`, `timeout`, `transport`, `build`, or `canceled` when muster
was shutting down mid-push). It
is not retried: the next cycle's push carries newer readings.
- Boot is refused when the URL is set with the poller off, without the token,
or not `https` (plain `http` is allowed only to a loopback host, for tests),
and when the token or the instance is set without the URL.

The same readings are drawn on **Account → Credentials** ("Claude account
usage"), for the admin and for a member alike.

Expand Down
29 changes: 29 additions & 0 deletions internal/api/cc_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package api

import (
"context"
"encoding/json"
"errors"
"fmt"
"html"
"net/http"
Expand Down Expand Up @@ -185,6 +187,33 @@ func (s *Server) officerPoolOf(name string) (string, string, bool, bool) {
return o.ID, o.Tier, proxied, true
}

// ccUsagePush is the usage pusher's body (MUSTER_CC_USAGE_PUSH_URL): the
// accounts exactly as GET /api/cc-accounts/usage answers the admin (the same
// ccUsageAccounts, so the two cannot drift), under the instance's label.
type ccUsagePush struct {
SourceInstance string `json:"source_instance"`
ReadAt string `json:"read_at"`
// PollIntervalSeconds is the poller's interval, as the API's own field:
// the dashboard marks the source stale at a multiple of it.
PollIntervalSeconds int `json:"poll_interval_seconds"`
Accounts []ccUsageAccount `json:"accounts"`
}

// CCUsagePushBody is the usage pusher's request body at now for owner (the
// operator, whose pool the poller reads), labelled instance. ctx must act as
// owner. An error is the store's; the body is then not built.
func (s *Server) CCUsagePushBody(ctx context.Context, owner int64, instance string, now time.Time) ([]byte, error) {
if s.ext.CCUsage == nil {
return nil, errors.New("this server keeps no Claude account usage (no database)")
}
accts, err := s.ccUsageAccounts(ctx, owner, now)
if err != nil {
return nil, err
}
return json.Marshal(ccUsagePush{SourceInstance: instance, ReadAt: now.UTC().Format(time.RFC3339),
PollIntervalSeconds: int(s.ext.CCUsageInterval / time.Second), Accounts: accts})
}

func (s *Server) handleCCAccountUsage(w http.ResponseWriter, r *http.Request) {
if s.ext.CCUsage == nil {
s.writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "this server keeps no Claude account usage (no database)"})
Expand Down
Loading
Loading