Muse-auth for token-exchange connectors

MS Teams (OAuth2 client-credentials, acquired inside the daemon) and Telegram (bot token embedded in the URL path) — plus a new path-kind credential placement and daemon-side token acquisition. daemon protocol v6

This is the fifth extension of the Muse-auth credential-isolation architecture. Earlier rounds covered Google/Notion/GitHub, then Slack/Firecrawl/Brave, then Discord/Home Assistant/ntfy/Govee, then seven messaging connectors. This one adds the two token-exchange shapes that no prior placement could express, and closes four concurrency-correctness defects that an independent review reproduced against a live daemon.

The problem these two connectors pose

Every previously-wired connector held a credential the agent could carry as a static string — a bearer, a custom header, a query parameter. MS Teams and Telegram break that assumption in opposite directions:

How the daemon boundary handles each

Agent process holds only a surrogate muse-sgt.<svc>.<hex> Muse daemon vault (real secrets) Sentinel egress policy client-cred exchange path/query/header splice transport-write gate (generation re-check under lock) Azure AD /oauth2/v2.0/token Graph / Telegram real API host surrogate client_secret real token
The agent never possesses the client secret, the acquired Graph token, or the bot token: the daemon acquires or splices them at the network boundary and hands back only the API response (with any echoed credential scrubbed to the surrogate).

MS Teams — daemon-side client-credentials

The vault stores an oauth2_client_credentials entry (token_url, client_id, client_secret, token_scope). At the boundary resolve_token performs the Azure exchange itself, caches the acquired token in the vault payload until shortly before it expires, and returns only a Authorization: Bearer <graph-token> to the outbound request. The exchange refuses redirects (so a 307 cannot forward the secret-bearing POST to an unpinned origin), pins the token endpoint to Azure (or loopback in tests), and never relays endpoint text that could reflect the secret.

Telegram — path-kind credential

The agent builds /bot<surrogate>/<Method>; the daemon substitutes the real token for the surrogate only in the path component just before each send, and normalizes every wire spelling of the token (raw, and single- or multiply-percent-encoded) back to the surrogate in response headers, reason phrases, bodies, and redirect targets. Redirect hops re-inject the token only when the hop stays on the pinned origin of the originally-authorized request.

The four concurrency defects this round closed

An independent read-only review reproduced four correctness defects against a live daemon. All are now fixed and covered by end-to-end regressions.

#DefectFix
1 MS Teams _wire_muse validated the legacy config before minting from the vault, so a stale/malformed config could disable an already-enrolled authoritative credential. Vault-first wiring: mint from the vault first; only when nothing is enrolled is the config candidate read and validated, and the mint is retried afterwards so a concurrently-enrolled credential wins.
2 The daemon's store-if-absent presence check, candidate validation, and write were three separate steps; a concurrent authoritative store landing between them made the now-irrelevant candidate fail the connect. One critical section: the vault uses a reentrant lock and exposes locked(); the whole _store_credentials handler runs inside it, so a concurrent store lands strictly before or after, never in between (created=False).
3 The config scrub compared an unlocked snapshot, then replaced the file — deleting a newer credential a concurrent writer had landed in between. Cross-process compare-and-swap: a shared config_file_lock (fcntl flock on a persistent .lock sibling) is held by every config writer and across the scrub's whole read-compare-replace cycle.
4 The pre-send generation check was pure Python, but requests then performed DNS/TCP/TLS before writing bytes — a rotation completing during that setup emitted the old-generation credential. Transport-write gate: a urllib3 endheaders override connects first (outside any lock), then re-checks the pinned generation under the vault lock at the instant the credential-bearing request head is written to the already-connected socket.

Why the transport-write gate does not deadlock

A lock held across the whole send was rejected in an earlier round because a request whose peer triggers a rotation would deadlock (the rotation's store waits on the lock the send holds while the send waits on the peer). The gate holds the vault lock for exactly the head write — a small buffer to an already-connected socket with an empty send buffer — and never across the body send or the response wait. Connection setup (DNS/TCP/TLS), which is where the old window lived, happens before the lock is taken.

# daemon.py — _GatedSendMixin.endheaders (abridged)
if self.sock is None:
    self.connect()            # DNS/TCP/TLS OUTSIDE any lock
with gate():                  # vault lock; re-check pinned generation
    super().endheaders(...)   # write the credential-bearing head

Verification

Files changed

FileChange
muse_auth/vault.pyreentrant lock + locked(); client-credentials token acquisition + finite-bounded cache
muse_auth/daemon.py_store_credentials critical section; transport-write generation gate; path-splice + scrub
muse_auth/_common.pypath-kind + token-endpoint validation; Telegram/Graph host + action rules; protocol v6
muse_auth/__main__.pyCLI imports for msteams/telegram; compare-and-swap scrub
msteams_agent.pyvault-first wiring, daemon-side probe, transactional authenticate, locked scrub
telegram_agent.pypath-kind adapter, transactional authenticate, locked scrub
_channel_agent_utils.pynew config_file_lock; locked save_json_config/clear_json_config
test_muse_auth_tokenx.py70-test e2e suite (4 new round-5 regressions)