Skip to content

feat(waker): dynamic per-agent supervisor (build order step 3) - #50

Merged
yjc801 merged 11 commits into
mainfrom
claude/waker-dynamic-supervisor
Aug 13, 2026
Merged

feat(waker): dynamic per-agent supervisor (build order step 3)#50
yjc801 merged 11 commits into
mainfrom
claude/waker-dynamic-supervisor

Conversation

@yjc801

@yjc801 yjc801 commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Summary

Stacks on #48 (approved, not yet merged) — base is claude/waker-enrolment-schema rather than main because this needs its roster/credential taps. Once #48 merges, this PR's base should be retargeted to main.

Implements PLANS/BUZZ_WAKER_DESIGN.md §12 build order step 3, the "highest risk" step: making the daemon's watched-agent set dynamic instead of a fixed startup snapshot.

  • Extracts the per-agent spawn block (presence tap, bundle tap, wake loop) into spawn_agent_watch, shared by both the static WAKER_AGENTS_CONFIG_PATH startup path (unchanged behavior) and a new reconciliation loop that diffs every authorized owner's roster against a supervised map, spawns a per-agent credential tap to fetch a newly-listed agent's nsec, then calls spawn_agent_watch once that credential arrives. A statically configured pubkey always wins a collision against a roster-discovered one (compute_desired_roster_agents).
  • Each watched agent's tasks share one CancellationToken::child_token. An unsolicited exit is classified (classify_exit): fatal for the whole daemon if the agent was statically configured (preserves today's exact behavior when WAKER_OWNER_PUBKEYS is unset), but tears down only that one agent if it was roster-discovered — one tenant's agent misbehaving must not take down a daemon serving several.
  • confirm_author_not_known_agent's baseline moves from a frozen Arc<[String]> snapshot to a new live WatchList. A frozen snapshot would let a roster-added agent's own mention wake another agent undetected, defeating the no-agent-to-agent-wake-loop invariant.
  • New optional env vars: WAKER_OWNER_PUBKEYS (empty/unset disables dynamic enrolment) and WAKER_IDENTITY_NSEC (required only when owners are configured).

Deliberately deferred (flagged, not silently dropped): a WAKER_MAX_AGENTS capacity bound (open tuning value in the design doc, not part of this step's own build-order text) and reacting to a credential rotation/revocation for an already-watched agent (the tap keeps running and would log one, but only the first delivered credential bootstraps identity). WAKER_AGENTS_CONFIG_PATH still requires at least one entry — a pure roster-only daemon with zero static agents isn't possible yet.

Test plan

  • cargo test -p buzz-waker: 261 lib + 17 main pass (12 new tests)
  • cargo clippy -p buzz-waker --all-targets -- -D warnings clean
  • cargo fmt -p buzz-waker -- --check clean
  • git diff --check clean
  • just gate / just ci — not run against this diff standalone (base isn't main yet); ran clean against the equivalent buzz-waker-only scope

🤖 Generated with Claude Code

yjc801 added 8 commits August 12, 2026 20:53
Phase 1 of docs/waker-agent-enrolment.md / PLANS/BUZZ_WAKER_DESIGN.md
§12: the pure, network-free half of replacing hand-edited
WAKER_AGENTS_CONFIG_PATH JSON with credentials delivered over the
relay.

Adds crates/buzz-waker/src/enrolment.rs:
- RosterBody/SignedRoster: which agent pubkeys are currently enrolled
  with an owner, republished in full on every add/remove/rotate. This
  is the discovery primitive that closes the design review's P2
  finding — a single coordinate per (owner, waker) pair gets the same
  small-bounded-query completeness the bundle tap already has, instead
  of needing history pagination or a relay guarantee the non-
  replaceable kind-1059 envelope doesn't provide.
- CredentialBody/SignedCredential: one agent's nsec/auth_tag, once the
  roster says that pubkey exists. Reuses the bundle tap's exact
  per-agent delivery shape.
- parse_authorized_owners: WAKER_OWNER_PUBKEYS parsing/validation —
  the daemon-level trust anchor for a brand-new agent's first
  admission, before any per-agent FloorStore exists to pin an owner
  into. Mirrors main.rs's existing parse_owner_pubkey.

Both signed types mirror bundle.rs's SignedLaunchBundle shape and
verification order (identity before cryptography, cryptography before
parsing) with their own domain separators, and redact secret fields
from Debug the same way LaunchBundleBody/SignedLaunchBundle do.

Wire I/O (a roster/credential tap mirroring bundle_feed.rs) and the
dynamic per-agent supervisor main.rs needs to act on any of this are
later phases — this crate has zero production callers of the new
module yet, matching how bundle issuance itself shipped unwired
before its own daemon-side plumbing landed.

Testing:
- cargo test -p buzz-waker: 216 lib tests + 8 main tests pass
  (13 new in enrolment::tests)
- cargo clippy -p buzz-waker --all-targets -- -D warnings: clean
- cargo fmt -p buzz-waker -- --check: clean
- cargo doc -p buzz-waker --no-deps with -D warnings surfaces 4
  pre-existing private-doc-link errors in bundle_feed.rs/cursor.rs/
  feed.rs/attempt.rs, none in enrolment.rs; reproduces identically on
  main with this diff stashed out, and cargo doc is not part of
  just ci's gate set

Signed-off-by: Junchao Yan <yjc801@gmail.com>
…ries

Addresses Alex's two review findings on PR #48:

- [P1] SignedCredential::verify accepted any owner-signed
  {agent_pubkey, nsec} pair without proving the nsec actually derives
  agent_pubkey. A signed-but-self-inconsistent credential
  (agent_pubkey: A, nsec: key-for-B) would verify successfully, and
  later phases key durable state (floor, roster membership, state
  directory) by A while a spawned connection authenticates as B.
  Fixed by parsing nsec after signature verification and requiring
  its derived pubkey to equal the claimed agent_pubkey, mirroring the
  identical binding crates/buzz-core/src/private_managed_agent.rs
  already enforces (validate_active_definition). The check (and the
  new auth_tag shape check) is skipped for a revoked credential, since
  CredentialBody's own doc already establishes nsec/auth_tag as unused
  issuer placeholders there, same as LaunchBundleBody::revoked.

- [P2] SignedRoster::verify accepted a roster with malformed agent
  pubkeys or the same agent listed more than once with disagreeing
  credential_version, leaving Phase 2/3's diff/fold order-dependent
  and letting an invalid coordinate reach durable state. Fixed by
  validating and normalizing the roster as one semantic unit after
  signature verification: every agent_pubkey must parse as a
  canonical Nostr public key, duplicates are refused outright, and a
  new MAX_ROSTER_ENTRIES (256, comfortably under the ~65KB practical
  NIP-44 ceiling bundle_feed.rs's own NIP44_CONTENT_LEN_RANGE already
  enforces) bounds the roster's serialized size.

Testing:
- cargo test -p buzz-waker: 224 lib tests + 8 main tests pass
  (9 new in enrolment::tests covering both findings)
- cargo clippy -p buzz-waker --all-targets -- -D warnings: clean
- cargo fmt -p buzz-waker -- --check: clean

Signed-off-by: Junchao Yan <yjc801@gmail.com>
Updates PR #48's schema to match docs/waker-agent-enrolment.md's now-
approved multi-tenant delta (PR #47): a live CredentialBody can carry
the agent owner's provider deploy credential, so one daemon can deploy
on behalf of several owners.

Adds ProviderCredential, a typed per-provider enum (Sprites { sprite_token }
today) rather than an arbitrary environment map — the design doc explains
why: Command::env() doesn't distinguish a credential from any other
process-control variable, so an unconstrained map would let a tenant set
LD_PRELOAD/PATH/proxy flags in the trusted provider subprocess. Actually
spawning from a sanitized environment (daemon-controlled baseline plus
this schema) is deploy-wiring, a later phase — this is schema only,
matching Phase 1's scope.

Validation mirrors nsec/auth_tag: checked for a live credential, skipped
for a revocation (an issuer's placeholder value must not need to be
well-formed). Debug redacts provider_credential the same way it already
redacts nsec.

WAKER_OWNER_PUBKEYS's fail-closed contract (round-2 finding on PR #47)
needs no code change here: main.rs has no call site for
parse_authorized_owners yet (Phase 2/3 wires that in), and the existing
doc comment already states the correct contract for when it does.

cargo test -p buzz-waker: 38 lib + 8 main pass (8 new tests). clippy -D
warnings and fmt --check clean.

Signed-off-by: Junchao Yan <yjc801@gmail.com>
Addresses Alex's REQUEST-CHANGES on PR #48 (c549d54): ProviderCredential
derived Debug, so formatting it directly — not nested inside
CredentialBody, whose manual formatter only redacts the enclosing-body
case — printed sprite_token verbatim. Any future error, assertion, or log
line in deploy wiring that formats a bare ProviderCredential would have
leaked the tenant's token.

Replaces the derive with a manual redacting Debug, matching the existing
pattern for CredentialBody and buzz-backend-sprites::Credential.
CredentialBody's own formatter now just defers to it instead of a
separate ad hoc redaction. Added a test that formats ProviderCredential
directly, per the finding.

cargo test -p buzz-waker: 230 lib + 8 main pass (1 new test). clippy -D
warnings and fmt --check clean.

Signed-off-by: Junchao Yan <yjc801@gmail.com>
Implements build order step 2 from PLANS/BUZZ_WAKER_DESIGN.md §12: the
wire I/O half of agent enrolment, on top of Phase 1's schema (PR #48).

roster_feed.rs — one connection for the whole daemon, authenticated as
the waker's own identity rather than any watched agent's: a roster's
whole job is telling the daemon which agents exist before it can
authenticate as any of them. RosterState tracks the latest roster *per
owner* (WAKER_OWNER_PUBKEYS may list several, and one owner's roster
says nothing about another's membership), applying a delivery only if
its roster_version exceeds what's already tracked for that owner —
correct regardless of relay delivery order, not just the common
created_at DESC case. run_roster_tap refuses to open a connection at
all when given no authorized owners, enforcing the design's fail-closed
contract (docs/waker-agent-enrolment.md, approved) itself rather than
trusting main.rs's future wiring alone.

credential_feed.rs mirrors bundle_feed.rs closely — same connect/
backoff/idle-timeout loop, same decrypt-verify-admit split against a
per-agent FloorStore (bundle_feed's NIP44_CONTENT_LEN_RANGE constant is
now pub(crate) and reused directly rather than duplicated) — with the
two changes the design calls for: it also connects and decrypts as the
waker identity, since the daemon doesn't have the target agent's key
yet, and the query adds #d pinned to the target agent's pubkey, since
#p is now the waker's shared identity rather than agent-specific. Both
taps re-check their own #d value per received frame (roster: the fixed
sentinel; credential: the specific agent pubkey) as defense in depth
against a filter bug crossing the two streams, per the design's own
instruction.

Not wired into main.rs — diffing RosterState against the daemon's watch
list and spawning/cancelling credential taps is step 3, the dynamic
supervisor, deliberately not started here.

cargo test -p buzz-waker: 251 lib + 8 main pass (21 new tests). clippy
-D warnings and fmt --check clean.

Signed-off-by: Junchao Yan <yjc801@gmail.com>
Alex's Phase 2 review (PR #48, head 008bcd6) found two real gaps in the
roster tap:

- RosterState only remembered the highest roster_version seen this
  process's lifetime. Because the envelope kind isn't replaceable, a
  relay can replay an owner's old-but-still-validly-signed roster after
  a restart, resurrecting an agent a newer roster already removed. Each
  authorized owner now gets its own FloorStore (crate::floors), opened
  lazily under roster_floor_dir and re-read from disk on every delivery,
  so the anti-replay floor survives a restart the same way it already
  does for bundle versions.

- The REQ filter combined every authorized owner into one authors array
  with a single limit, but a NIP-01 filter's limit applies to the whole
  filter, not once per author. A burst of reissues from one owner could
  crowd another owner's current roster out of the response entirely.
  roster_filters now builds one filter per owner (buzz-relay runs each
  filter in a multi-filter REQ as its own independently-limited query),
  still under one subscription.

cargo test/clippy/fmt -p buzz-waker all clean. `just gate`'s only
failure is an unrelated pre-existing file-size ratchet trip in
desktop/src-tauri/src/managed_agents/runtime.rs, a file this diff never
touches.

Signed-off-by: Junchao Yan <yjc801@gmail.com>
Alex's re-review of the durable-floor fix (PR #48, head 6c93cd1) found
the P2 fix itself was incomplete: roster_filters emits one filter per
authorized owner, but buzz-relay refuses any REQ with more than
MAX_FILTERS_PER_REQ (10, advertised via NIP-11 as max_filters). An
11-owner configuration would open no roster subscription at all and
recover no tenants' rosters.

roster_req is replaced by roster_reqs, which chunks authorized_owners
into batches of at most ROSTER_MAX_FILTERS_PER_REQ and opens one REQ
frame per batch, each under its own roster_subscription_id (always
suffixed, even for the common single-batch case, so roster_frame has one
shape to match against instead of two). run_roster_tap now sends every
batch's REQ after connecting and tracks the full set of subscription ids
for frame routing; a failure partway through subscribing reconnects the
whole connection rather than leaving a partial subscription set running.

cargo test/clippy/fmt -p buzz-waker all clean (255 lib + 8 main, +2 new
tests: one proving an 11th owner opens a second batch rather than being
dropped or refusing to start, one proving a delivery on the second
batch's subscription id is still recognized).

Signed-off-by: Junchao Yan <yjc801@gmail.com>
Stacks on claude/waker-enrolment-schema (PR #48, approved, not yet
merged) — needs its roster_feed/credential_feed taps.

Extracts the per-agent spawn block (presence tap, bundle tap, wake loop)
into spawn_agent_watch, called both by the static WAKER_AGENTS_CONFIG_PATH
startup loop (unchanged behavior) and by a new reconciliation loop that
diffs every authorized owner's roster against a supervised map, spawning a
per-agent credential tap to fetch a newly-listed agent's nsec before it can
be watched, then calling spawn_agent_watch once that credential arrives.
A statically configured pubkey always wins a collision against a
roster-discovered one and is never touched by the roster diff in either
direction (compute_desired_roster_agents).

Each watched agent's three tasks share one CancellationToken::child_token
of the daemon's global token, tracked in a SupervisedAgent map keyed by
pubkey. An unsolicited exit is classified (classify_exit): fatal for the
whole daemon if the agent was statically configured (preserves today's
exact behavior when WAKER_OWNER_PUBKEYS is unset), but tears down only
that one agent if it was roster-discovered — a single tenant's agent
misbehaving must not take down a daemon serving several.

confirm_author_not_known_agent's baseline (this daemon's watch list) moves
from a frozen Arc<[String]> snapshot taken once at startup to a new live
WatchList (Arc<Mutex<HashSet<String>>>) updated as agents are added or
removed. A frozen snapshot would let a roster-added agent's own mention
wake another agent undetected, defeating the no-agent-to-agent-wake-loop
invariant the guard exists to enforce.

New env vars, both optional: WAKER_OWNER_PUBKEYS (comma-separated
authorized owners; empty/unset disables dynamic enrolment, matching
parse_authorized_owners' existing fail-closed contract) and
WAKER_IDENTITY_NSEC (required only when WAKER_OWNER_PUBKEYS is set — the
roster/credential taps' own connecting identity, never a watched agent's).

Deliberately deferred, not implemented this round: a WAKER_MAX_AGENTS
capacity bound (open tuning value in the design doc, not part of this
step's own build-order text) and reacting to a credential rotation/
revocation for an already-running dynamically watched agent (the
credential tap keeps running and would log one, but only the first
delivered credential bootstraps identity). WAKER_AGENTS_CONFIG_PATH still
requires at least one entry — a pure roster-only daemon with zero static
agents isn't possible yet.

cargo test -p buzz-waker: 261 lib + 17 main pass (12 new: 6 for WatchList,
6 for compute_desired_roster_agents/classify_exit). clippy -D warnings and
fmt --check clean.

Signed-off-by: Junchao Yan <yjc801@gmail.com>
Base automatically changed from claude/waker-enrolment-schema to main August 13, 2026 06:27
yjc801 and others added 3 commits August 12, 2026 23:43
Alex's review of the dynamic supervisor (PR #50, head 7b149cd) found
three real P1s:

1. RosterEntry.credential_version was discarded, and an agent was
   promoted the moment CredentialState held *any* delivery — a stale or
   replayed version could bootstrap the wrong identity, and once running,
   nothing ever re-checked a later rotation or revocation. Fixed:
   SupervisedAgent now tracks expected_credential_version and keeps its
   CredentialState wired for the agent's whole supervised lifetime, not
   just bootstrap. reconcile_roster gained two new passes: one tears down
   a still-listed roster agent (pending or already running) the moment
   the roster's own claimed version moves, so re-bootstrap starts fresh
   under the new version in the same tick; promotion (and a running
   agent's continued operation) now requires an exact version match
   against the live delivery, and a revocation (CredentialState flipping
   to None while running) tears the agent down too.

2. CredentialBody.provider_credential was decoded but never used —
   provider_deploy_pinned ran with no tenant environment, so a
   dynamically enrolled owner's deploy would use the daemon's own
   inherited credentials or fail outright. buzz-provider-deploy gained an
   additive env: Option<&HashMap<String, String>> parameter on every
   entry point (None preserves every existing caller's behavior exactly,
   including desktop's), applied as an overlay via Command::envs — not
   isolation, and the module doc says so plainly, since no daemon-baseline
   spec for that ever existed anywhere in this codebase to reuse.
   ProviderCredential::to_env() derives the overlay (SPRITE_TOKEN today);
   RealWakeEffects/WakeLoopConfig/spawn_agent_watch now carry it from the
   delivered credential through to the actual deploy call.

3. Dynamic enrolment had no admission cap — an authorized owner's roster,
   however large, was adopted in full. New required-alongside-
   WAKER_IDENTITY_NSEC env var WAKER_MAX_AGENTS: a refuse-not-evict
   ceiling counting every supervised pubkey (config, pending, and running
   together), checked before each new roster adoption.

cargo test -p buzz-waker -p buzz-provider-deploy: 262 lib + 18 main + 31
(provider-deploy, +2 new proving the env overlay actually reaches the
child and overrides an inherited variable) all pass. clippy -D warnings
and fmt --check clean across both crates plus desktop/src-tauri (checked
and clippy'd standalone; backend.rs's two call sites needed a trailing
None each).

Signed-off-by: Junchao Yan <yjc801@gmail.com>
…atic baseline

Alex's re-review of PR #50 (head ef8172b) found three more real gaps in
the dynamic supervisor:

1. A roster credential-version change tore down and re-adopted the same
   pubkey within one reconcile_roster pass. The predecessor generation's
   presence/bundle/wake/credential tasks kept running until their own
   cancellation propagated, and when they finally exited, the join loop
   looked up `supervised` by pubkey and found the *replacement*
   generation's fresh, uncancelled token — misclassifying an expected
   predecessor exit as the replacement's unsolicited failure and tearing
   the new generation down too, so rotation could never converge. Fixed:
   TaskExit now carries a clone of the exact CancellationToken the task
   was spawned under, captured at spawn time; the join loop checks that
   token's own is_cancelled() directly instead of re-deriving cancellation
   status from whatever currently occupies the pubkey in `supervised`.

2. The `env` overlay onto a tenant's deploy subprocess only ever added
   SPRITE_TOKEN on top of this daemon's own inherited environment — the
   child still saw WAKER_IDENTITY_NSEC and everything else the daemon's
   process carries. Since the tenant's own bundle authorizes which
   provider binary/digest runs, an unisolated child could read and
   exfiltrate secrets across the shared-daemon boundary. Fixed:
   buzz-provider-deploy now clears the child's environment before adding
   back a small fixed baseline (HOME, PATH — both required by
   buzz-backend-sprites's own credential resolution and provisioning code)
   plus the tenant's own credential, whenever `env` is `Some`. `None`
   (every non-multi-tenant caller, including desktop) is unaffected.

3. WAKER_MAX_AGENTS is documented as a total ceiling over config, pending,
   and running agents together, but reconcile_roster's refuse-not-evict
   check only ever guarded roster *additions* — a static
   WAKER_AGENTS_CONFIG_PATH baseline already past the ceiling started
   unchecked and stayed that way for the daemon's whole life. Fixed:
   ensure_static_agent_count_fits_cap fails startup before any agent is
   spawned or gets per-agent state on disk when the static baseline alone
   exceeds WAKER_MAX_AGENTS and dynamic enrolment is enabled.

cargo test -p buzz-waker -p buzz-provider-deploy: 262 lib + 21 main (4
new) + 32 (1 new) pass. clippy -D warnings and fmt --check clean across
both crates plus desktop/src-tauri (unaffected call sites verified).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Junchao Yan <yjc801@gmail.com>
PR #48 squash-merged into main after this branch's base was
claude/waker-enrolment-schema, so GitHub auto-retargeted PR #50's base
to main and reported it as conflicting. Both conflicts were mechanical:
lib.rs's module doc comment on main still described the supervisor as
unimplemented, and enrolment.rs's add/add conflict was this branch's
own to_env()/test addition versus main's pre-existing content that
this branch's copy already contains in full. This branch's version is
a strict superset of main's for both files (verified by diff before
resolving) — no logic changed by this merge.

Signed-off-by: Junchao Yan <yjc801@gmail.com>
@yjc801
yjc801 merged commit 42fb7ff into main Aug 13, 2026
4 checks passed
@yjc801
yjc801 deleted the claude/waker-dynamic-supervisor branch August 13, 2026 07:14
yjc801 added a commit that referenced this pull request Aug 13, 2026
* feat(desktop): issue waker enrolment rosters and credentials

#48 and #50 built the daemon half of enrolment — roster and credential
taps, per-owner deploy, a reconciling supervisor — but nothing anywhere
published what those taps read. A grep of desktop/ for SignedRoster or
CredentialBody found only MeshLLM's unrelated roster, so the daemon had
subscriptions with nothing to receive and enrolment was inert end to end.
This is the issuing side.

Two payloads, both owner-signed and NIP-44-encrypted to the waker identity
rather than to the agent, riding the same envelope and the same pending_sync
row the launch bundle already uses — so publishing, retries, and the
WebSocket routing #38 added come for free. A roster of every enrolled agent
at the fixed coordinate the daemon queries, and one credential per agent
carrying its nsec, auth_tag, and the owner's own provider credential.

The retention store made this less mechanical than it looks. Its primary key
is (kind, pubkey, d_tag) with no p column, and on the relay a credential is
distinguished from that agent's launch bundle *only* by the p tag — so a
credential keyed by the bare agent pubkey lands on the bundle's own row and
displaces it, leaving whichever was written second as the only one ever
published. Remote wake would have broken for exactly the agents that
enrolled. Rows are namespaced (`credential:<pubkey>`) while the published
event keeps the real d tag, which is sound because the flush loop publishes
raw_event verbatim. A test asserts the bundle survives issuing a credential
and still decrypts as the agent.

Ordering is credential first, roster second, never the reverse: the roster
names the credential version each agent is expected to have, so that order
leaves a partial failure with a credential no roster mentions — inert —
rather than a roster promising a credential that does not exist. For the
same reason the roster lists only agents whose credential was actually
issued.

Revocation does both halves. Roster omission is what stops the daemon
watching an agent; the revoked credential is what reaches a daemon already
holding the old one and raises its durable floor. Not best-effort, matching
revoke_waker_bundle_pending: a caller must refuse to persist the disable
rather than report success with the agent still enrolled.

Deliberately incomplete in one place, and it does not regress anything:
provider credentials resolve from SPRITE_TOKEN/SPRITES_TOKEN only. The
production path for a Finder-launched desktop is the keychain arm in
buzz-backend-sprites, but that crate is bin-only, so reaching it means
either restructuring it into a library or duplicating its ~/.sprites walk —
neither belongs here. An absent credential means the daemon deploys with its
own, which is what it does today for every statically configured agent.

The waker identity comes from WAGGLE_WAKER_IDENTITY_PUBKEY over a
compiled-in default that is empty, since only whoever deploys the daemon
knows its pubkey. Unset means enrolment is a silent no-op, which is every
install that does not use a remote waker.

Found while writing the tests: the daemon rejects a live credential whose
nsec does not parse or does not derive the claimed agent_pubkey, so
placeholder keys prove nothing. Tests use real keys and run the daemon's own
verifier rather than a re-implementation.

Verified: 2505 desktop unit tests pass, clippy clean, fmt clean, file-size
ratchet clean. Not yet exercised against a live daemon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Junchao Yan <yjc801@gmail.com>

* fix(desktop): drop per-user provider credentials from enrolment

There are two roles, not three: a service provider who runs the waker and
holds the one Sprites token it deploys with, and users who toggle Remote
wake and configure nothing. The design's "per-owner provider credentials"
reads as the provider's, but "owner" is this codebase's word for the key
that signs an agent's events — the user's own desktop key. Under the two-role
model a user has no Sprites token to send, so the field could never be
filled: `sprite login` is not part of using a hosted waker.

So the resolver goes rather than growing a keychain arm to feed it. The
credential now carries the agent's nsec and auth_tag and nothing else —
exactly what the daemon needs to connect *as* that agent.

Better wire, not just less code: no provider token ever transits the relay,
and no user can choose which token the daemon spends. `org` still travels
per agent in the signed provider_config, which is where per-agent provider
targeting already belonged.

The prior commit's env-only resolver would have read SPRITE_TOKEN from
whatever environment the desktop happened to launch with and encrypted it to
the waker. For a Finder-launched app that is empty, so it was mostly dead;
where it did fire it sent a secret that did not need to travel.

Terminology fixed throughout: "service provider" for whoever runs the daemon,
"user" for whoever toggles the switch, and a note at the top of the commands
module that "owner" means the signing key rather than either of them.

2504 desktop unit tests pass, clippy clean, fmt clean, ratchet clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Junchao Yan <yjc801@gmail.com>

* fix(desktop): issue enrolment on enable, commit-gate the roster, and don't strand a revoked disable

Three round-1 review findings on the waker enrolment path:

- set_managed_agent_waker_enabled's false-to-true branch only issued a
  launch bundle, bypassing retain_waker_enrolment_pending entirely. A
  freshly enabled agent published no credential or roster entry, so the
  daemon could never discover it until an unrelated later edit happened
  to go through the generic retain helper. Issue enrolment in that path
  too, with the same fail-and-roll-back semantics as the bundle half.

- IssuanceLedger::reserve durably burns a version before signing,
  encrypting, or retaining a credential, so a failure after reservation
  (invalid waker key, retention error) left the roster reading a version
  nothing was ever published to. Add a committed counter recorded only
  once a credential is actually retained, and build roster entries from
  that instead of the raw reservation counter.

- revoke_waker_enrolment_pending retained the credential revocation before
  the roster. If the roster write then failed, the caller reported the
  disable as aborted while the already-queued revocation could still
  publish and tear the agent down — local state and the external effect
  claiming opposites. Once the credential revocation is retained the
  security effect is committed and cannot be un-queued, so treat that as
  a completed disable and make the roster retract best-effort from there;
  a roster that still names the agent self-corrects the next time any
  other agent's enrolment is retained.

just gate: fmt, clippy -D warnings, desktop-tauri tests (2505 passed),
desktop typecheck/lint/build all clean.

Signed-off-by: Junchao Yan <yjc801@gmail.com>

* fix(desktop): don't fail a disable after the revocation is queued

sign_and_retain_credential_at reserved a version, signed, retained the
encrypted credential envelope (the durable, irreversible queue point for
the flush loop), and then made a separate fallible ledger write to record
the committed version. A failure in that trailing write propagated as an
Err even though the credential — including a revocation — was already
retained and would still publish.

For revoke_waker_enrolment_pending this meant a caller could report the
disable as aborted ("waker remains enabled") while the already-queued
revocation went on to tear the agent down regardless, contradicting the
reported result. Treat record_committed's failure as it's treated
elsewhere in this ledger: log and continue once the thing it is
bookkeeping for has actually landed.

Signed-off-by: Junchao Yan <yjc801@gmail.com>

* fix(desktop): distinguish live issuance from revocation on a lost commit marker

The round-3 fix made every record_committed failure non-fatal once the
credential envelope was retained. That's correct for a revocation, whose
irreversible effect is the thing that must commit — but wrong for a live
issuance: swallowing the failure there let the enable command report
success while committed() stayed at 0 and retain_roster_at silently
omitted the agent, leaving Remote wake enabled locally but undiscoverable
by the daemon.

Gate the behavior on the existing `revoked` parameter instead of applying
it unconditionally: a revocation logs and continues (the disable must
persist regardless), a live issuance still propagates the failure (the
existing enable-path caller already rolls back waker_enabled on Err, which
is the correct outcome here).

Signed-off-by: Junchao Yan <yjc801@gmail.com>

---------

Signed-off-by: Junchao Yan <yjc801@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant