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
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:
client_secret is POSTed to
Azure AD, which returns a short-lived Graph access token. The secret must never
reach the agent, and the acquired token must never reach the agent either — so
the token acquisition itself has to happen inside the daemon./bot<token>/sendMessage), not a header — so the daemon must
splice the real token into the URL path at the boundary and scrub every echo of
it back out of responses and redirects.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.
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.
An independent read-only review reproduced four correctness defects against a live daemon. All are now fixed and covered by end-to-end regressions.
| # | Defect | Fix |
|---|---|---|
| 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. |
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
test_muse_auth_tokenx.py — a real daemon subprocess plus real
loopback emulators for the Azure token endpoint, Microsoft Graph, and the
Telegram Bot API; no mocks. Four new regressions cover each round-5 fix,
including a rotation that lands while a real TCP connect is blocked on a
saturated accept backlog (the accepted socket provably receives zero bytes).uv run check --full passes: ruff, mypy,
pyright, compileall, docs. Legacy behavior with KISS_MUSE_AUTH unset
is byte-identical.| File | Change |
|---|---|
muse_auth/vault.py | reentrant 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.py | path-kind + token-endpoint validation; Telegram/Graph host + action rules; protocol v6 |
muse_auth/__main__.py | CLI imports for msteams/telegram; compare-and-swap scrub |
msteams_agent.py | vault-first wiring, daemon-side probe, transactional authenticate, locked scrub |
telegram_agent.py | path-kind adapter, transactional authenticate, locked scrub |
_channel_agent_utils.py | new config_file_lock; locked save_json_config/clear_json_config |
test_muse_auth_tokenx.py | 70-test e2e suite (4 new round-5 regressions) |