Repository navigation
Stop refreshing a dead login token (CE-20) - #74
Conversation
A daemon holding a refresh token the server refused kept refreshing it on every telemetry flush, so those installs stayed logged out and their authorship data never arrived. Make the refusal terminal on the client: - Persist a refused device refresh token (invalid_grant / login_required) with the credentials and stop refreshing until `autter login` writes new ones; remember it in-process if persisting fails. - Tell the user once: the next interactive command shows the server's error_description, whoami/doctor/debug show LoginRefused, and the daemon logs the refusal once instead of per queue per flush. - Write refreshed credentials compare-and-set on the refresh token that was sent, and wait 10 s (was 2 s) for credentials.lock. - Return the parsed OAuthError (error, error_code, login_required) from exchange_token instead of a string. Transient failures (network, 5xx, temporarily_unavailable, access_denied) are still retried. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
⏳ Still checking: nothing blocks merging so farThe code review is done. Autter is now running the build and tests and trying the change like a user.
What we checked
What to fix
Plus 9 minor suggestions (style, conventions, hardening) in the sections below. They don't block merging. 🧭 Trying it like a user: runningAutter is trying this change the way a user would. This section updates when it finishes. 💡 More findings (13): suggestions and ones Autter couldn't confirm🧭 PR hygiene & process suggestionsAutter has 1 suggestion(s) about the shape of this PR (size, scope, reviewability). These are process guidance — not code defects — so they are consolidated here instead of posted as inline comments on individual files. 🟡 Change not tied to any stated requirement — Risk: 25/100No stated requirement asked for this: The credentials.lock wait is lengthened from about 2 s (10 × 200 ms) to 10 s with 100 ms polling and a deadline. The intent does not ask for this timing change, though it is related to the refresh race.. Confirm it belongs in this PR rather than a separate change. 🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter PR-hygiene checks. 💡 2 minor suggestions: conventions, hardening and tidiness (not bugs)Comments on the code are kept for real bugs. These are things worth knowing that the change doesn't prove wrong: a missing safety layer, a convention the repo usually follows, a file that usually changes together with one you touched. Take what fits and ignore the rest. They don't block merging.
❔ 10 possible problems Autter couldn't confirmAutter's checks flagged these, but a second look couldn't confirm them from the code it could see, so they are listed here instead of on the code. Check the ones that look real to you. They don't block merging.
🔎 8 low-signal finding(s) (test-file conventions, hardening advice on internal routes, packages the registry knows) were kept off this PR — see the Autter dashboard. 🚦 Pre-merge checks: 8 worth a look, 155 passed
✅ 155 passedToo many files changed · Too many unrelated chapters · Generated files hiding real changes · Missing PR context · Mixed concerns (refactor + behavior change) · Migration + app logic + UI combined in one PR · Sensitive data in logs · Missing audit logging · Removed observability · Silent exception swallowing · Unhandled promise rejection · Circuit breaker not detected · Stack trace leakage · Multi-write without detected transaction · Possible TOCTOU in critical path · Idempotency key not detected · Optimistic locking not detected · Rate limiting not detected · Rate limiting removed · Batch size limit not detected · Pagination not detected · Prompt injection risk · LLM output used in a dangerous sink · Sensitive data in prompt or system-prompt leakage · Over-privileged LLM tool / excessive agency · Missing validation on an LLM-driven decision · Unbounded LLM usage (denial-of-wallet) · Table exposed without row-level security · Over-broad row-level security policy · Code path that bypasses row-level security · Privileged database credential reachable from the client · Privileged query without row-level scoping · Row-level security enabled with no policy · Template-default gradient styling · Interchangeable AI marketing copy · Placeholder content shipped to users · Emoji standing in for an icon system · Call-to-action that goes nowhere · Templated page composition · Color outside the brand palette · Hardcoded style bypassing design tokens · Typography outside the brand type system · One-off UI instead of the shared component · Copy that does not match brand voice · Screen does not match the rest of the product · Merge-blocking marker left in the change · Known-defect marker shipped in code · Untracked TODO without an issue reference · Test disabled or left pending · PII in logs · PII or internals leaked in error response · PII stored without application-level encryption · User data stored without retention controls · PII sent to external / cross-border destination · Lockfile resolution / integrity tampered · Dependency runs install-time lifecycle script · Possible dependency-confusion attack · Lockfile resolves a dependency the manifest does not declare · Checked-in build artefact modified without source change · Dockerfile build-step is insecure · External artefact pulled in without integrity pinning · Changed export, importer not updated · Missing linked tracker issue · Missing CODEOWNERS reviewer approval · Migration missing rollback / down step · Frontend importing database client directly · Route handler bypassing service layer · Backend service importing UI module · Cross-context internals import · Workspace package rule violation · Inconsistent logging pattern · Inconsistent error handling · Endpoint missing input validation · Multi-write without transaction wrapper · New feature shipped without feature flag · Module placed in the wrong workspace package · Hallucinated import (package not installed) · Nonexistent package (not found in registry) · Call to function that does not exist · Generic placeholder identifier in production logic · Repetitive boilerplate (duplicated block) · Overbroad try/catch swallowing all exceptions · TODO / FIXME on critical path · Comment contradicts or fabricates code behaviour · Abstraction defined but never used · Code style differs from rest of codebase · Established pattern ignored · Unhandled edge case (null / empty / zero / boundary) · Insecure training-data / doc-example pattern · Dead code (defined but never referenced) · Deprecated API call · API pattern from wrong library version · API endpoint removed · HTTP method changed (GET ↔ POST etc.) · New required field added to request · Field removed from response schema · Response field type changed · HTTP status code changed · Auth requirement added / removed / changed · Error response shape changed · Pagination behaviour changed · Outbound webhook payload schema changed · GraphQL field removed without deprecation · GraphQL enum value removed · Connected repositories · Vendor API consumer · SQL injection · Cross-site scripting (XSS) · Path traversal · Command injection · Insecure deserialization · Weak cryptography · Hardcoded secret · Insecure randomness for security material · Unsafe file upload · Missing input validation · Unsafe CORS configuration · Unsafe / open redirect · Missing CSRF protection · Unsafe cookie / session settings · Invisible Unicode / Trojan Source characters · API key in source · Access token in source · Private key in source · Database connection URL with embedded credentials · Cloud credential in source · Webhook signing secret in source · OAuth client secret in source · JWT signing secret in source · Hardcoded password · Auth middleware removed from route · Route protection changed (protected → public) · Permission / RBAC check removed · Required role weakened · Admin-only route exposed to lower privilege · Token validation skipped in middleware chain · JWT verification weakened or changed · Session expiration / TTL changed · Password reset flow changed · Webhook endpoint missing signature verification · Public route touches private/PII data · Runtime error risk · Resource leak risk · Data integrity risk · Maintainability issue · Redundant alias / duplicate import · Redundant type construct · Unnecessary type assertion · Module smell · Dead export (no callers) · Code duplication / DRY violation · Scanner suppressions added · Scanner settings changed · Release Notes Curator · Intent vs. implementation 🤖 Release Notes CuratorRelease impact: patch. This is a bug fix: Autter stops retrying a refresh token that has already expired, and users with a dead login get a clear prompt to sign in again instead of repeated failed refresh attempts. No public interface is removed or changed incompatibly, and no new capability is added. Changelog entry:
Custom agent · runs after review · configured in Autter 📄 What this PR changesThis change adds explicit refresh-token expiry and failure handling, and surfaces login-required status across authentication checks and sync reporting so users can identify when they need to log in again.
📖 What these words mean
View this PR in Autter · Checked commit |
### Fixed - A refused refresh token (invalid_grant / login_required) is recorded and no longer retried until `autter login` stores new credentials (#74). - One notice per refusal instead of a warning every daemon tick. - Stored credentials are replaced only if they still hold the refresh token that was sent. - OAuth errors keep error, error_code and login_required. Co-authored-by: Sagnik Ghosh <sagnikghosh@Sagniks-MacBook-Pro.local> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Why
Backend PR Autter-dev/autter-monorepo#513 (merged Oct 17) made the server answer a refused CLI refresh token from memory. That cut
invalid_grantrefusals from about 2,000–3,600 to about 110–180 per 6 hours. The rest come from about 5 older installs (CLI 1.6.9–2.2.0). Their daemon still sends a dead refresh token on every telemetry flush, so those developers stay logged out and their AI-authorship data never reaches the server.The loop on the client: the daemon flushes every 3 s. Each flush builds several
ApiContext::new(), and each one callstry_load_auth_token(), which refreshes with the stored token. When the refresh fails, the CLI keeps the dead credentials and tries again on the next call. The server sees bursts of 2–10 refreshes every 8–47 s per install.The server's refusal now (HTTP 400):
{"error":"invalid_grant","error_description":"Refresh token is not valid. Run `autter login` to sign in again.","error_code":"refresh_token_invalid","login_required":true}error_codeis one ofrefresh_token_invalid,device_revoked,refresh_token_expiredorrefresh_token_superseded. Older servers send onlyerror: "invalid_grant".What changed (the four follow-ups from #513)
error == "invalid_grant"(orlogin_required: true) for a device refresh token, the CLI saves the refusal with the credentials (refresh_failed: time,error,error_code,error_description). Every process then returnsAuthLoadIssue::LoginRequiredwithout a network call untilautter loginwrites new credentials (which never carry the field). If that write fails (disk full, keychain locked), the process remembers the refusal in memory, so the loop can't restart.error_descriptionthroughauth::notice. This happens right away, even inside the 24-hour reminder window, and only once per refusal (a stamp file holds the refusal's timestamp). The usual 24-hour "cloud sync is paused" reminder now names the server's reason.whoami,doctoranddebugshow a newLoginRefusedstate.autter loginno longer says "already logged in" for refused credentials. The daemon logs the refusal once per process instead of once per queue per flush.CredentialStore::store_if_currentoverwrites the stored credentials only if they still hold the refresh token that was sent. Marking a token refused uses the same check. The wait forcredentials.lockgoes from 2 s to 10 s before the CLI refreshes without it. This closes the last-writer-wins race when three or more processes refresh at once: the late writers no longer put back an older rotation, and a sibling'srefresh_token_supersededdoesn't mark a newer, live session as refused.exchange_tokenandrefresh_access_tokenreturnTokenRequestError::{Transport, Refused { status, error: OAuthError }}instead of a string.OAuthErrornow parseserror_codeandlogin_required. PAT and install-nonce sign-in keep their string errors for their callers.Transient failures are retried as before: network errors, non-OAuth error pages (e.g. a proxy 502), any 5xx,
temporarily_unavailableandaccess_denied. An explicitlogin_required: falsemakes a refusal non-terminal.Left as before on purpose: refused PATs. A PAT uses the same refresh grant, but the brief limited the terminal state to device refresh tokens, and the per-org PAT exchange can refuse for reasons that don't mean the PAT is dead.
Tests
New unit tests (all passing):
error_code, the oldinvalid_grant-only body,login_requiredtrue, false and null, transient errors, 4xx versus 5xx, and non-OAuth pages counted as transport errors.supersededrefusal after a sibling rotation uses the sibling's session, and the refusal mark only applies to the refused token.LoginRequired.Checks run locally:
cargo fmt: clean.cargo build: clean.task testruns it (daemon mode, 12 threads): 1,919 lib unit tests passed. Integration: 3,109 passed; 21 timed out waiting on the shared test daemon while the machine was at load average ~57 on 12 cores. All 21 are inutf8_filenames,windsurfandworktrees, and the same harness timeout hit each. Re-running those three modules alone: 150/150 passed.daemon_mode,async_modeand the other test binaries all passed.cargo clippy --all-targets -- -D warningsfails on 12 lints from stable 1.99 that are already onmain, none in this diff. CI only runscargo check --all-targetsand the Windows installer job.Rollout: the 5 stuck installs only stop after they update
The fix is client-side, so an install stops sending dead refreshes only once it runs a CLI with this change. Those users still need to run
autter loginonce, which the new CLI tells them to do. Until then the server keeps answering them from #513's refusal cache.How the release would go (not part of this PR):
versioninCargo.tomlandCargo.lock(e.g. 2.2.0 → 2.2.1) in a PR and merge it tomain.tag-release.ymltagsvX.Y.Zon that merge commit, andrelease.ymlbuilds the binaries and publishes@autter/clito npm.autter upgradeor the background update check. That check installs automatically unless auto-updates are off. In older versions it only runs ongit push. Not verified: whether 1.6.9–2.2.0 restart the daemon on the new binary right after a background upgrade.min_versionto the fixed release so older CLIs show the "below minimum version" warning on interactive commands.stats count(*) by tokenHashPrefix, cliVersion, installId) should show refusals only from versions older than the fix.Not verified
task dev).🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need.Summary
Summary generated by Autter.
Why this change
A refresh token that has expired cannot renew the login, but refresh handling needs to recognize that state instead of continuing to treat the credentials as refreshable.
What changes for users
This change adds explicit refresh-token expiry and failure handling, and surfaces login-required status across authentication checks and sync reporting so users can identify when they need to log in again.
Implementation and review notes
src/auth/types.rsandsrc/auth/credentials.rs, including compatibility with stored credentials.src/api/client.rsandsrc/auth/client.rs.doctor,whoami,debug, and sync notices.Changes
Acceptance Criteria
Test Plan
task testand confirm the auth-related tests pass.autter whoami,autter doctor, andautter debugwith the expired credentials and check that their auth status is consistent.Rollback Plan
Revert this PR to restore the previous refresh and auth-status handling. The supplied diff does not indicate a database schema migration requiring a separate rollback.
Related Issues
Written for commit 9fcde40. Summary will update on new commits.