daemon: pure free-ride on Claude Code's token (read-only, never refresh)#63
daemon: pure free-ride on Claude Code's token (read-only, never refresh)#63nathanjohnpayne wants to merge 12 commits into
Conversation
|
We had some discussions on this going in #32 and #39 I really am keen to avoid taking such an invasive action as refreshing a token on behalf of users since it interacts directly with Claude Code's credential store both without the user being aware and potentially breaking it in the future even though the chances of that are slim. I believe the right move is to go into an idle state if the token doesn't get refreshed since if the token is not refreshed by the user then we can at least say for certain the user isn't using Claude Code. WDYT? |
|
Pushed a small follow-on commit ( Pairs with #65 — a tiny firmware change that interprets the beat. #65 should land first so deployed firmware understands |
The macOS daemon read the access token from the Keychain but never used the refresh token sitting next to it, so when the ~8h token expired the API returned 401, no valid payload reached the device, and the screen froze until a manual re-auth. Add self-refresh that mirrors Claude Code's own OAuth flow: - refresh_access_token() POSTs the refresh_token grant to platform.claude.com/v1/oauth/token (client id + endpoint lifted from the installed CLI), then writes the rotated tokens back to the same Keychain item so Claude Code and the daemon stay in sync. Nothing is written unless the refresh returns 200, so a failed refresh leaves the existing credentials intact. - Proactive refresh within REFRESH_SKEW (120s) of expiry, plus an on-401/403 refresh-and-retry-once in the poll loop. - Reader now transparently hex-decodes a Keychain blob (`security -w` emits hex when the stored secret contains a newline), so a stray newline can't hard-fail token extraction. Tests (daemon/tests/test_macos_refresh.py) cover the 200-response mapping (rotation / non-rotation / missing token / sibling-field preservation), hex decode, and expiry parsing. Windows-daemon parity is tracked separately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The auto-refresh added in b4788f6 had no backoff. A data-hungry device fires a refresh request every few seconds, so an expired token whose refresh is failing (e.g. a 429) made every device request trigger another OAuth refresh — driving the token endpoint into a self-sustaining 429 loop (observed: 85 refreshes in ~9 min while the screen stayed frozen). Gate every refresh attempt (success or failure) to once per REFRESH_COOLDOWN (300s) via a module-level timestamp. Normal polling is untouched — only the refresh endpoint is throttled — so the daemon still recovers the instant a valid token appears (rate limit clears or the user re-logs in), just without ever flooding. Verified live (1 attempt + 23 cooldown-skips in 45s, vs ~6 on the old code) and unit-tested (two refreshes inside the window make exactly one network call). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the macOS self-refresh + cooldown (b4788f6, 445564c) to the Windows daemon so it no longer freezes when the ~8h token expires: - refresh_access_token() exchanges the stored refresh token at platform.claude.com/v1/oauth/token and writes the rotated tokens back to the plaintext .credentials.json the Windows client reads (write only on a 200). Proactive refresh within REFRESH_SKEW, on-401/403 retry, and a tray "run claude login" fallback when refresh can't recover. - REFRESH_COOLDOWN gates every refresh attempt to once per 300s so a data-hungry device can't drive the OAuth endpoint into a 429 hammer loop. - tests/test_windows_refresh.py (24): mock httpx + tmp_path, so nothing hits the live endpoint or a real credential store; an autouse fixture resets the cooldown timestamp between tests. - tests/conftest.py: autouse fixture neutralizing the new refresh touchpoints for the pre-existing connect_and_run tests (keeps them hermetic). - tests/test_windows_token.py: skip the two daemon-spawning tests on macOS, where running the BLE daemon trips the Bluetooth TCC gate and SIGABRTs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The flat 300s cooldown stopped the hammering but still retried every 5 min forever — against a sustained 429 that left the token frozen for hours (observed: ~5h of failed refreshes, the device stuck on stale data). Back off exponentially on consecutive refresh failures (300s → 2× → … → REFRESH_BACKOFF_CAP 30min), resetting to the base on a success, so a persistent rate-limit is left alone to clear instead of being poked every cycle. After REFRESH_GIVEUP_AFTER (5) consecutive failures, log a one-time "run claude login" notice — the device's amber status dot is the user-facing signal; this explains why. Recovery is still automatic: a re-login or a cleared limit yields a fresh token on the next poll, which resets the backoff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ity with macOS) Mirror ca06f4e on the Windows daemon: back off exponentially on consecutive refresh failures (300s → 2× → … → REFRESH_BACKOFF_CAP 30min), reset to base on success, and log a one-time "run claude login" notice after REFRESH_GIVEUP_AFTER (5) failures — so it can't get stuck in a multi-hour flat-5-min retry loop either. The tray's set_error toast remains the prominent user-facing signal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the token is dead and a refresh can't recover it (or there's no token at
all), send {"ok": false} to the device instead of going silent — the firmware
then shows its idle screen now instead of holding stale numbers for the full
~90s freshness window. Transient network / rate-limit misses fall through
silently and keep the existing retry behaviour, so a blip doesn't flicker the
device to idle. macOS + Windows.
Pairs with firmware ok:false handling (separate PR): that must land first so the
device interprets the beat. Verified end-to-end: beat -> idle within a second.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Proactive refresh competed with the Claude app's own refreshes for the same
token endpoint and tripped its rate limit (429), blocking recovery and forcing a
manual `claude login`. Free-ride instead: use whatever access token Claude Code
currently holds, and refresh only reactively (on a 401) as a last resort. While
you use Claude, the app keeps the token fresh and the daemon never touches the
refresh endpoint; if it lapses while Claude is fully closed, the device shows an
honest idle "No data" (the {"ok":false} beat) until next use / re-login.
macOS + Windows; connect-loop wiring test now asserts no proactive refresh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Refined the refresh strategy (commit Proactive refresh turned out to compete with the Claude app's own refreshes for the token endpoint and trip its rate limit (429) — which then blocked recovery and forced a manual Updated the title/description to match. Verified live: token recovered and steady data flow resumed. |
Update: switched the refresh strategy to "free-ride" — validated on macOS over ~16hTL;DR: proactive token refresh turned out to be the wrong call — it competed with Claude Code / the Claude app for the same OAuth endpoint and tripped its rate limit ( What was tried
How recovery worked before this changeThe cooldown/backoff kept the daemon from worsening the 429, but they couldn't clear it — they just spaced out retries that kept failing (I watched one throttle persist ~5 hours). With no way to wait it out, recovery was manual every time: run So the screen would sit on stale numbers — and, once the idle screen existed, "No data" — for an unbounded stretch until I noticed, re-authenticated by hand, and kicked the daemon. Removing that manual What worked: free-ride (reactive-only)Drop proactive refresh entirely. The daemon now uses whatever access token Claude Code currently holds (Keychain item Tradeoff: if nothing refreshes the token — laptop closed, or you only use the desktop app (which keeps a separate credential the daemon doesn't read) — the device shows an honest "No data" until you next use Claude Code. That's a better failure mode than the old self-inflicted rate-limit. Proof (macOS)Environment:
One continuous ~16.6 h daemon session (15:37 → 08:16), 0 proactive refreshes:
The overnight gap lines up exactly with the macOS sleep log (lid wake
|
_refresh_failures only reset on a successful *refresh*, but under free-ride the daemon recovers via Claude Code's refresh / a re-login, almost never its own — so the backoff stayed pinned at the 30-min cap and blocked the next reactive refresh that might have succeeded. Reset it on any successful poll (the token works) via a new _note_poll_success() in the poll loop. macOS + Windows, with tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the daemon a pure read-only consumer of Claude Code's credential: read the
stored access token, poll, and on a 401 emit {"ok": false} (idle "No data") and
wait for Claude Code (the token owner) to re-seed it. Never refresh — two clients
rotating one refresh token trips reuse-detection / a shared 429 bucket; the daemon's
refresh always 429'd while Claude Code's succeeded and the daemon free-rode on it.
Removes ~550 lines of refresh/backoff/write-back machinery (macOS + Windows) and
replaces the self-refresh suites with test_freeride.py (read-path + a regression
guard that neither daemon can refresh + a no-data-beat loop test).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Final pivot (and I think the right one): pure free-ride — the daemon now never touches the OAuth refresh endpoint at all. Earlier this PR did reactive refresh (on a 401). But validated on hardware + cross-checked against the literature, that was still wrong: the daemon's refresh always Evidence that two clients sharing one rotating refresh token is the anti-pattern: refresh-token rotation reuse-detection (Auth0), and a near-identical menu-bar tool that hit the exact "poll/refresh → 429-bucket exhaustion → manual re-login" cycle (CodexBar #575). So the daemon is now a pure read-only consumer: read Claude Code's token, poll, and on a 401 emit |
Why this went all the way to "never refresh": the failure and the researchThe failure I hitEven after switching to reactive-only refresh (refresh only on a 401, never proactively), the device still got stuck on "No data" and couldn't recover on its own. Captured live on macOS:
That last pair is the whole story: the daemon's refresh is persistently rate-limited, while Claude Code's own refresh succeeds. Same endpoint, same token, ~same minute. So the daemon's refresh was dead weight — it never worked, and every attempt only kept its own throttle warm. The thing that actually recovered the device was free-riding on the CLI's refresh. The research (why a second refresher can't win)
The fixStop competing. The daemon is now a pure read-only consumer of Claude Code's credential: read the stored access token, poll, and on a 401 emit Validated on macOS; still needs a Windows tester. |
|
Let's see how long this fix holds. It's complex, but I really can't even with manual refreshes. 😄 |
Thanks for the help Nathan, I'm on holiday until the 10th, just been sneaking code reviews on my phone when I get the chance, ping me if you need any more input on this or more testing, @kvenanzi opened a draft pull request on this topic as well hoping he'll be able to help us test on the windows side if he's able |
The daemon sends a {"ok":false} "no-data" beat when it can't fetch usage
(dead/rotating token), expecting the device to show its idle screen. The
firmware half of that contract was missing on main: ui_update gated only
on `valid`, so an ok:false beat — which parses fine but carries no s/w
fields — rendered as 0% on the usage screen instead of flipping to idle.
Restore the three-part gate (dropped when the heartbeat commit was
reshaped into PRs on origin/main, then again by a local reset --hard):
- track the last payload's ok flag in `data_ok`
- ui_update early-returns on ok:false (keep last numbers, don't refresh
the freshness clock)
- update_view_state requires data_ok for the live-usage view
Verified on the 2.16 in a live ok:false window: device shows the sleeping
creature + "No data…/Listening…" instead of 0%.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
I'm back in action now @nathanjohnpayne sorry you caught me almost as soon as I started my vacation so I was not very active this week :) I will test this tomorrow on my end and hopefully we can merge this, thanks for working on this hopefully I wasn't too hard to handle in the review. BTW a tip on Claude Code I noticed that I hope you don't mind me mentioning, I have this happen to me as well that sometimes it will update tickets titles and descriptions and pull request descriptions after you do a pivot like you did it will reword it based on the current session context "(read-only, never refresh)" which only makes sense if you were there at the time the pivot happened but not if you come to it later. It's a good instruction to add to have titles and descriptions reflective of the work performed and not the journey the chat session took. |
|
Now I have been on vacation, home for a few days, and back to traveling. Thanks for the tip! Claude does do that, and it makes it hard to follow along. PR and issue titles/descriptions: describe the work, not the sessionTitles and descriptions—for both pull requests and issues—must describe the
|
The daemon now polls every configured Claude config dir (upstream's
multi-plan active-plan support) while keeping the pure free-ride contract:
it reads whatever token each dir currently holds and never refreshes. When
no dir has a usable token — file/Keychain empty, or a 401/expired token —
it emits a {"ok": false} beat so the device shows "No data" instead of
stale numbers; a live token that merely fails transiently stays silent.
poll_active() carries the (payload, all_dead) result the loop needs and
catches TokenExpired per dir so a 401 can't crash the poll; poll_active_payload()
remains a thin dict|None wrapper. _read_token_keychain() is retained (used by
read_token_for's macOS default-dir fallback) and now applies the hex-dump
Keychain decode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR shifts the usage-tracker daemons to a “pure free-ride” model: they only read Claude Code’s existing access token (Keychain / .credentials.json), never refresh it, and explicitly emit a {"ok": false} “no data” beat when the token is dead so the device can switch to its idle “No data” screen promptly.
Changes:
- Firmware: treat
ok:falsepayloads as “no fresh data” (idle immediately, keep last rendered numbers). - Daemons (macOS + Windows): stop any OAuth refresh behavior; on 401/403 (or missing token) write
{"ok": false}instead of holding stale numbers. - Tests: add free-ride regression coverage and adjust Windows token tests to avoid macOS CoreBluetooth/TCC crashes.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| firmware/src/ui.cpp | Tracks last ok flag and gates “live usage” view on ok:true so ok:false yields immediate idle. |
| daemon/claude_usage_daemon.py | Adds Keychain hex-decode handling, introduces TokenExpired, and emits {"ok": false} when all configured tokens are dead. |
| daemon/claude_usage_daemon_windows.py | Emits {"ok": false} for missing/expired token and removes any refresh behavior. |
| daemon/tests/test_windows_token.py | Skips Windows-daemon-spawn tests on macOS to avoid CoreBluetooth/TCC SIGABRT in CI. |
| daemon/tests/test_freeride.py | New tests covering Keychain blob decoding, “no refresh machinery” regression, and ok:false emission on auth failure. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| log("No usable token; signalling no-data to device — run " | ||
| "`claude login` or use the CLI to let Claude Code renew it") | ||
| await session.write_payload({"ok": False}) | ||
| last_poll = time.time() |
| log("No token; signalling no-data to device") | ||
| await session.write_payload({"ok": False}) | ||
| last_poll = time.time() | ||
| if tray_state: | ||
| tray_state.set_error("token expired — run claude login") | ||
| else: |
| log("No data (token dead); signalling idle to device") | ||
| await session.write_payload({"ok": False}) | ||
| last_poll = time.time() |
|
@nathanjohnpayne Have we considered these alternative approaches:
Thoughts? |
Keeps the desk monitor honest when the Claude OAuth token expires, without freezing on stale numbers — and without the daemon ever touching the OAuth refresh endpoint.
Approach: pure free-ride. The daemon is a read-only consumer of Claude Code's credential. It reads whatever access token Claude Code currently holds (Keychain
Claude Code-credentialson macOS,~/.claude/.credentials.jsonon Windows/Linux), polls, and never refreshes the token itself. On a 401 it emits{"ok": false}and the device shows its idle "No data" screen until Claude Code — the token's legitimate owner — re-seeds it (i.e. when you next use the CLI, or runclaude login).Why no self-refresh (evidence in-thread): two clients sharing one rotating refresh token is a documented anti-pattern — concurrent rotation trips reuse-detection / exhausts a shared rate-limit bucket, and only a fresh login (a different OAuth grant) resets it (refresh-token rotation BCP, CodexBar #575, opencode #18329). We saw exactly that: the daemon's refresh persistently
429'd while Claude Code's own refresh succeeded and re-seeded the credential — which the daemon then free-rode on to recover. So the daemon stops competing entirely.Includes:
{"ok": false}"no-data" beat so the device shows idle "No data" promptly when the token's dead (pairs with firmware: flip to idle on the daemon's {"ok": false} no-data beat #65) instead of holding stale numberstest_freeride.py: read-path coverage, a regression guard that neither daemon can refresh, and a loop test that a dead token yields a{"ok": false}beatBehaviour: while you use Claude Code it keeps the token fresh and the daemon free-rides; when nothing has refreshed it (laptop closed, CLI unused), the device honestly shows "No data" until you next use Claude Code / re-login — a better outcome than a self-inflicted rate-limit.
🤖 Generated with Claude Code