diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e66e5b..dbbbb79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,36 @@ Each slice entry has: ## Released +### Slice K1 — auth bootstrap UX fix + invalid_grant classification + proactive expiry status · 2026-05-06 · PR (pending) + +**Shipped** + +- `packages/cli/src/commands/auth.ts` — `mc auth bootstrap` now always overwrites `~/.mc/credentials.json` and prints the old → new expiry diff. The earlier silent-skip-when-file-exists guard (with the `--force` opt-in) is gone — running the command is itself the explicit "make my auth fresh" intent. `--dry-run` still works and now shows the same diff without writing. +- `packages/core/src/auth/refresher.ts` — new `RefreshTokenRevokedError` class. HTTP 400 with `error: invalid_grant` in the body now throws this immediately (regardless of cached-still-valid state), instead of falling through the generic non-200 branch. Anthropic uses 400 for permanent revocation; the previous code masked it as a transient "network error" until the cached access token expired 5–10 min later. Also: `'expiring'` is now a first-class `RefresherStatus` — the periodic tick emits it when the cached token has under 2h remaining (claudeclaw-os pattern). +- `packages/core/src/auth/refresher.ts` — new `invalidate()` method on the `OAuthRefresher` interface drops the in-memory `cached` bundle so the next `ensureFresh()` re-reads the credential store. Available for future credentials-watch wiring (deferred to its own slice); the existing config-watcher → respawn path still recovers correctly today. +- `packages/daemon/src/bin/mcd-main.ts` — handles the new `'expiring'` status (maps to `statusRegistry.setUnverified` with a clear remediation detail, plus a WARN-level log so it shows up in `~/.mc/logs/`) and the new `RefreshTokenRevokedError` (logs an actionable "run `claude login` then `mc auth bootstrap`" message at boot probe time). + +**Why it matters** + +Three concrete bugs were keeping `claude login` + `mc auth bootstrap` from being a reliable recovery path: + +1. **Silent no-op when the file already exists.** Users who ran `mc auth bootstrap` after `claude login` saw "Already bootstrapped" and assumed the rotation was applied. It wasn't — the stale token in `~/.mc/credentials.json` was untouched. The fix: always overwrite, always print before/after expiry, so the result is observable. +2. **HTTP 400 `invalid_grant` was treated as transient.** When Anthropic revokes a refresh token (e.g. after server-side rotation, account-level logout, or downstream auth-state change), the OAuth refresh endpoint returns 400 with `{"error":"invalid_grant"}`, not 401. The refresher's `!response.ok` branch silently returned the cached access token if it was still valid, hiding the permanent rejection until the access token expired and surfaced as `RefreshNetworkError` ("network error"). Now it throws `RefreshTokenRevokedError` immediately with the exact recovery command. +3. **No proactive heads-up before expiry.** Operators only learned auth was about to break when the chat lane hung for 5 minutes after the access token expired. Mirroring the claudeclaw-os UX, the refresher now emits `'expiring'` 2h before expiry; the daemon surfaces it via `statusRegistry.setUnverified('claudeCode', ...)` and a WARN log. (The follow-up K1.5 slice will add a `DiscordClient.sendDirectMessage(userId, content)` surface so this also DMs the owner directly — claudeclaw-os does this via Telegram; MC's Discord adapter doesn't yet have the DM-by-userId method.) + +**Notable code** + +- `refresher.ts:33–45` — `RefreshTokenRevokedError` parallels `RefreshTokenRejectedError` (401) for the 400 case. Both message the same recovery: re-bootstrap. +- `refresher.ts:173` — the 400-detection regex `/["']?error["']?\s*:\s*["']invalid_grant["']/i`. Tolerates the OAuth standard's permitted body variations (json or url-encoded with quoted/unquoted keys). Other 400 bodies (e.g. `invalid_request`) fall through to the existing transient-error branch unchanged. +- `refresher.ts:128–135` — the `'expiring'` emission inside the cached-fast-path. `EXPIRING_THRESHOLD_MS = 2h` is `export`ed for any callers that want to inspect the threshold; tests assert it equals 2 hours. +- `auth.ts:25–34` — bootstrap now does an upfront `store.read()` to capture the old bundle. Corrupted-existing-file is handled gracefully (a stdout note, then overwrite) rather than aborting. + +**Deferred** + +- **Credentials-watch → invalidate() wiring.** The existing config-watcher fires a full daemon respawn when `~/.mc/credentials.json` mtime changes; calling `refresher.invalidate()` directly would avoid the respawn round-trip. Held back because the respawn path works today, the watcher's one-shot semantics need a small redesign to support both "DB change → respawn" and "credentials change → invalidate", and the user's reported bug (bootstrap "doesn't work") is fully addressed by bugs (1) + (2). +- **K1.5: `DiscordClient.sendDirectMessage(userId, content)`.** Adding the DM surface so the proactive expiry alert reaches Joe directly without him having to look at the dashboard. Roughly 30 lines (`discord/src/client.ts` interface + `discord/src/client-js.ts` impl using `users.fetch(id).createDM().send(content)` + a fake for tests). +- **K3: cabinet-style migration.** Long-term, MC could delete the entire `packages/core/src/auth/` tree and shell out to the `claude` CLI binary (cabinet does this; claudeclaw-os reads `~/.claude/.credentials.json` for monitoring only, never refreshes). Both prior arts duplicate zero of MC's OAuth refresh logic. Research spike planned for next week — verify whether the Claude Agent SDK can fall back to the `claude` CLI's auth state when `CLAUDE_CODE_OAUTH_TOKEN` is unset. + ### Slice J3.2 — task lane sets `permissionMode: 'bypassPermissions'` so the agent can run Bash · 2026-05-05 · PR (pending) **Shipped** diff --git a/README.md b/README.md index feba6f8..8e99ba3 100644 --- a/README.md +++ b/README.md @@ -541,12 +541,16 @@ The daemon's `OAuthRefresher` then refreshes the OAuth token in the background **Re-bootstrap when:** - The dashboard surfaces a `Refresh failed: re-bootstrap` banner (refresh token revoked or rotated server-side, e.g. after `claude logout`) +- The dashboard's `claudeCode` integration goes `unverified` with a "Token expiring soon" detail (proactive 2h heads-up — fix it before the chat lane stalls) - You ran `claude login` again on a different account ```bash -mc auth bootstrap --force # overwrites ~/.mc/credentials.json with the freshest keychain values +mc auth bootstrap # always overwrites ~/.mc/credentials.json with the freshest keychain values +mc auth bootstrap --dry-run # preview the new expiry without touching the file ``` +The bootstrap command always overwrites by default and prints both the old and new expiry timestamps so you can confirm the rotation. (Earlier behavior — silent skip when the file existed — was a footgun: users running the command after `claude login` were surprised to see "Already bootstrapped" and the daemon kept using the stale token.) + **Why a separate file (not `state.db`):** the launchd-spawned daemon cannot access the user's keychain (different "responsible parent" attribute than the user's terminal). The bootstrap command runs from the user's terminal where keychain access works; the daemon only reads the file at `~/.mc/credentials.json` and refreshes via plain HTTPS — never touches keychain at runtime. The daemon's `mc auth bootstrap` watcher polls `~/.mc/credentials.json` mtime alongside the SQLite summaries, so re-running bootstrap triggers an `EX_TEMPFAIL=75` daemon respawn within ~1.5s — no `launchctl kickstart` needed. diff --git a/packages/cli/src/commands/auth.test.ts b/packages/cli/src/commands/auth.test.ts index b21bf6b..35f49ca 100644 --- a/packages/cli/src/commands/auth.test.ts +++ b/packages/cli/src/commands/auth.test.ts @@ -41,44 +41,50 @@ describe('mc auth bootstrap', () => { expect(persisted.accessToken).toBe('A'); }); - it('exits 0 with "already bootstrapped" when file exists and no --force', async () => { - await writeFile( - path, - JSON.stringify({ accessToken: 'OLD', refreshToken: 'OLD', expiresAt: Date.now() + 60_000 }), - ); + it('overwrites by default when file already exists, printing old → new expiry', async () => { + const oldExpiry = Date.parse('2030-01-01T00:00:00.000Z'); + const newExpiry = Date.parse('2031-06-15T12:00:00.000Z'); + await writeFile(path, JSON.stringify({ accessToken: 'OLD', refreshToken: 'OLD', expiresAt: oldExpiry })); const out: string[] = []; - const r = reader(null); const code = await runAuthBootstrap({ path, - reader: r, + reader: reader({ accessToken: 'NEW', refreshToken: 'NEW', expiresAt: newExpiry }), flags: {}, stdout: (l) => out.push(l), stderr: (l) => out.push(l), }); expect(code).toBe(0); - expect(out.join('\n')).toMatch(/Already bootstrapped/); - expect(r.read).not.toHaveBeenCalled(); - // File should NOT be overwritten + const joined = out.join('\n'); + expect(joined).toMatch(/Replaced/); + expect(joined).toContain('2030-01-01T00:00:00.000Z'); + expect(joined).toContain('2031-06-15T12:00:00.000Z'); const persisted = JSON.parse(await readFile(path, 'utf8')); - expect(persisted.accessToken).toBe('OLD'); + expect(persisted.accessToken).toBe('NEW'); }); - it('--force overwrites existing file', async () => { - await writeFile(path, JSON.stringify({ accessToken: 'OLD', refreshToken: 'OLD', expiresAt: 0 })); + it('--dry-run reads but does not write, printing the diff', async () => { + const oldExpiry = Date.parse('2030-01-01T00:00:00.000Z'); + const newExpiry = Date.parse('2031-06-15T12:00:00.000Z'); + await writeFile(path, JSON.stringify({ accessToken: 'OLD', refreshToken: 'OLD', expiresAt: oldExpiry })); const out: string[] = []; const code = await runAuthBootstrap({ path, - reader: reader({ accessToken: 'NEW', refreshToken: 'NEW', expiresAt: Date.now() + 60_000 }), - flags: { force: true }, + reader: reader({ accessToken: 'A', refreshToken: 'R', expiresAt: newExpiry }), + flags: { dryRun: true }, stdout: (l) => out.push(l), stderr: (l) => out.push(l), }); expect(code).toBe(0); + const joined = out.join('\n'); + expect(joined).toMatch(/would (write|replace)/i); + expect(joined).toContain('2030-01-01T00:00:00.000Z'); + expect(joined).toContain('2031-06-15T12:00:00.000Z'); + // File contents are unchanged. const persisted = JSON.parse(await readFile(path, 'utf8')); - expect(persisted.accessToken).toBe('NEW'); + expect(persisted.accessToken).toBe('OLD'); }); - it('--dry-run reads but does not write', async () => { + it('--dry-run with no existing file still does not write', async () => { const out: string[] = []; const code = await runAuthBootstrap({ path, diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index c646c60..1fb37a4 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -1,4 +1,3 @@ -import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { @@ -14,7 +13,7 @@ import { Command } from 'commander'; export interface RunAuthBootstrapOpts { path: string; reader: CredentialReader; - flags: { force?: boolean; dryRun?: boolean }; + flags: { dryRun?: boolean }; stdout: (line: string) => void; stderr: (line: string) => void; } @@ -22,9 +21,19 @@ export interface RunAuthBootstrapOpts { export async function runAuthBootstrap(opts: RunAuthBootstrapOpts): Promise { const { path, reader, flags, stdout, stderr } = opts; - if (existsSync(path) && !flags.force) { - stdout(`Already bootstrapped: ${path} exists. Use --force to overwrite.`); - return 0; + // Read the existing bundle (if any) up-front so we can show a before/after + // diff. Silent skip is a footgun — if the user is running this command, the + // intent is "make my auth fresh." See plan: shiny-hugging-pretzel.md. + const store = makeCredentialStore({ path }); + let oldBundle: CredentialBundle | null = null; + try { + oldBundle = await store.read(); + } catch (err) { + if (err instanceof CorruptedCredentialsError) { + stdout(`Existing credentials.json is corrupted (${err.message}); will overwrite.`); + } else { + throw err; + } } let bundle: CredentialBundle | null; @@ -51,33 +60,48 @@ export async function runAuthBootstrap(opts: RunAuthBootstrapOpts): Promise void, + ctx: { path: string; oldExpiryIso: string | null; newExpiryIso: string; dryRun: boolean }, +): void { + const verb = ctx.dryRun + ? ctx.oldExpiryIso + ? 'would replace' + : 'would write' + : ctx.oldExpiryIso + ? 'Replaced' + : 'Wrote'; + const suffix = ctx.dryRun ? '' : ' (mode 0600)'; + stdout(`✓ ${verb} ${ctx.path}${suffix}`); + if (ctx.oldExpiryIso) { + stdout(` Old expiry: ${ctx.oldExpiryIso}`); + stdout(` New expiry: ${ctx.newExpiryIso}`); + } else { + stdout(` Access token expires: ${ctx.newExpiryIso}`); + } + if (!ctx.dryRun) { + stdout(''); + stdout('Mission Control daemon will pick up the new credentials within 30s.'); + stdout('Restart for immediate effect: launchctl kickstart -k gui/$UID/net.ticc.mc'); + } +} + export function buildAuthCommand(): Command { const cmd = new Command('auth').description('Manage Mission Control authentication'); cmd .command('bootstrap') .description("Read OAuth credentials from your system's credential store and write them for the daemon") - .option('--force', 'Overwrite existing credentials.json') .option('--dry-run', 'Read from system store but do not write') - .action(async (rawFlags: { force?: boolean; dryRun?: boolean }) => { + .action(async (rawFlags: { dryRun?: boolean }) => { const path = process.env.MC_DATA_DIR ? join(process.env.MC_DATA_DIR, 'credentials.json') : join(homedir(), '.mc', 'credentials.json'); diff --git a/packages/core/src/auth/index.ts b/packages/core/src/auth/index.ts index 2b720c5..41d82fe 100644 --- a/packages/core/src/auth/index.ts +++ b/packages/core/src/auth/index.ts @@ -16,6 +16,7 @@ export { makeLinuxLibsecretReader } from './readers/linux-libsecret.js'; export { KeychainAuthFailedError, makeMacKeychainReader } from './readers/mac-keychain.js'; export { CLAUDE_CODE_CLIENT_ID, + EXPIRING_THRESHOLD_MS, makeOAuthRefresher, NotBootstrappedError, OAUTH_TOKEN_URL, @@ -25,4 +26,5 @@ export { type RefresherStatusSnapshot, RefreshNetworkError, RefreshTokenRejectedError, + RefreshTokenRevokedError, } from './refresher.js'; diff --git a/packages/core/src/auth/refresher.test.ts b/packages/core/src/auth/refresher.test.ts index cd3c7e0..642b1fb 100644 --- a/packages/core/src/auth/refresher.test.ts +++ b/packages/core/src/auth/refresher.test.ts @@ -4,10 +4,12 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { makeCredentialStore } from './credential-store.js'; import { + EXPIRING_THRESHOLD_MS, makeOAuthRefresher, NotBootstrappedError, RefreshNetworkError, RefreshTokenRejectedError, + RefreshTokenRevokedError, } from './refresher.js'; const NOOP_LOGGER = { @@ -140,10 +142,12 @@ describe('OAuthRefresher', () => { const now = 1_000_000_000_000; await seed({ accessToken: 'A1', refreshToken: 'R1', expiresAt: now + 1000 }); const store = makeCredentialStore({ path }); + // 8h expiry keeps the post-refresh state firmly outside the 2h "expiring" + // band so this test stays focused on the disabled→ready transition gate. const fetcher = vi .fn() .mockResolvedValue( - jsonResponse({ access_token: 'A2', refresh_token: 'R2', expires_in: 3600 }), + jsonResponse({ access_token: 'A2', refresh_token: 'R2', expires_in: 8 * 3600 }), ) as unknown as typeof fetch; const onStatusChange = vi.fn(); const refresher = makeOAuthRefresher({ @@ -240,4 +244,74 @@ describe('OAuthRefresher', () => { const refresher = makeOAuthRefresher({ store, fetcher, clock: () => now, logger: NOOP_LOGGER }); await expect(refresher.ensureFresh()).rejects.toBeInstanceOf(RefreshNetworkError); }); + + it('throws RefreshTokenRevokedError on HTTP 400 with invalid_grant body, even when cached is still valid', async () => { + const now = 1_000_000_000_000; + await seed({ accessToken: 'A1', refreshToken: 'R1', expiresAt: now + 5 * 60 * 1000 }); + const store = makeCredentialStore({ path }); + const fetcher = fakeFetcher([ + new Response(JSON.stringify({ error: 'invalid_grant' }), { + status: 400, + headers: { 'content-type': 'application/json' }, + }), + ]); + const refresher = makeOAuthRefresher({ store, fetcher, clock: () => now, logger: NOOP_LOGGER }); + await expect(refresher.ensureFresh()).rejects.toBeInstanceOf(RefreshTokenRevokedError); + }); + + it('falls back to cached token on HTTP 400 without invalid_grant when cached is still valid', async () => { + const now = 1_000_000_000_000; + await seed({ accessToken: 'A1', refreshToken: 'R1', expiresAt: now + 5 * 60 * 1000 }); + const store = makeCredentialStore({ path }); + const fetcher = fakeFetcher([ + new Response(JSON.stringify({ error: 'invalid_request' }), { + status: 400, + headers: { 'content-type': 'application/json' }, + }), + ]); + const refresher = makeOAuthRefresher({ store, fetcher, clock: () => now, logger: NOOP_LOGGER }); + expect(await refresher.ensureFresh()).toBe('A1'); + }); + + it('invalidate() forces next ensureFresh to re-read the store', async () => { + const now = 1_000_000_000_000; + await seed({ accessToken: 'A1', refreshToken: 'R1', expiresAt: now + 30 * 60 * 1000 }); + const store = makeCredentialStore({ path }); + const refresher = makeOAuthRefresher({ + store, + fetcher: fakeFetcher([]), + clock: () => now, + logger: NOOP_LOGGER, + }); + expect(await refresher.ensureFresh()).toBe('A1'); + // External rewrite (e.g. mc auth bootstrap) — refresher must re-read on next call. + await store.write({ accessToken: 'A2', refreshToken: 'R2', expiresAt: now + 30 * 60 * 1000 }); + refresher.invalidate(); + expect(await refresher.ensureFresh()).toBe('A2'); + }); + + it("emits 'expiring' status when cached token is within 2h of expiry but outside the 10-min refresh window", async () => { + const now = 1_000_000_000_000; + // 1h until expiry: inside the 2h "expiring" warn band, outside the 10m refresh window. + await seed({ accessToken: 'A1', refreshToken: 'R1', expiresAt: now + 60 * 60 * 1000 }); + const store = makeCredentialStore({ path }); + const onStatusChange = vi.fn(); + const refresher = makeOAuthRefresher({ + store, + fetcher: fakeFetcher([]), + clock: () => now, + logger: NOOP_LOGGER, + onStatusChange, + }); + await refresher.ensureFresh(); + expect(onStatusChange).toHaveBeenCalledWith( + 'expiring', + expect.stringMatching(/expir/i), + expect.objectContaining({ expiresAt: now + 60 * 60 * 1000 }), + ); + }); + + it('EXPIRING_THRESHOLD_MS is exported and is 2h', () => { + expect(EXPIRING_THRESHOLD_MS).toBe(2 * 60 * 60 * 1000); + }); }); diff --git a/packages/core/src/auth/refresher.ts b/packages/core/src/auth/refresher.ts index d796441..500e7ba 100644 --- a/packages/core/src/auth/refresher.ts +++ b/packages/core/src/auth/refresher.ts @@ -7,6 +7,14 @@ export const OAUTH_TOKEN_URL = 'https://claude.ai/v1/oauth/token'; const REFRESH_WINDOW_MS = 10 * 60 * 1000; const REFRESH_TICK_MS = 5 * 60 * 1000; const REFRESH_TIMEOUT_MS = 15 * 1000; +/** + * Warn band: when the cached access token is within this distance of expiry + * but outside the active 10-min refresh window, emit `'expiring'` so the + * dashboard / log can surface a heads-up. Mirrors the claudeclaw-os UX + * (proactive 2h warning) without spamming — emitStatus already gates on + * transitions. + */ +export const EXPIRING_THRESHOLD_MS = 2 * 60 * 60 * 1000; export class NotBootstrappedError extends Error { constructor() { @@ -22,6 +30,20 @@ export class RefreshTokenRejectedError extends Error { } } +/** + * Server-side revocation of the refresh token (HTTP 400 with `invalid_grant`). + * Distinguished from `RefreshTokenRejectedError` (401) because Anthropic + * returns 400 for permanent rejection — without this branch a revoked token + * was silently masked by the cached-still-valid fall-through, so users only + * saw "network error" 5–10 min later when the access token expired. + */ +export class RefreshTokenRevokedError extends Error { + constructor(detail: string) { + super(`Refresh token revoked: ${detail}. Re-run \`claude login\` then \`mc auth bootstrap\`.`); + this.name = 'RefreshTokenRevokedError'; + } +} + export class RefreshNetworkError extends Error { constructor(detail: string) { super(`Refresh failed (network) and cached token is expired: ${detail}`); @@ -29,7 +51,7 @@ export class RefreshNetworkError extends Error { } } -export type RefresherStatus = 'ready' | 'error' | 'disabled'; +export type RefresherStatus = 'ready' | 'expiring' | 'error' | 'disabled'; export interface RefresherStatusSnapshot { status: RefresherStatus; @@ -56,6 +78,13 @@ export interface OAuthRefresher { currentAccessToken(): string | null; getStatus(): RefresherStatusSnapshot; start(): () => void; + /** + * Drop the in-memory cached bundle. Next `ensureFresh()` re-reads the + * credential store. Call when an external writer (e.g. `mc auth bootstrap`) + * has rewritten `~/.mc/credentials.json` and the daemon should pick up the + * new tokens without exiting + relying on launchd respawn. + */ + invalidate(): void; } export function makeOAuthRefresher(opts: OAuthRefresherOpts): OAuthRefresher { @@ -94,9 +123,14 @@ export function makeOAuthRefresher(opts: OAuthRefresherOpts): OAuthRefresher { } } - if (cached.expiresAt - clock() > REFRESH_WINDOW_MS) { + const nowMs = clock(); + const remainingMs = cached.expiresAt - nowMs; + if (remainingMs > REFRESH_WINDOW_MS) { envTarget.CLAUDE_CODE_OAUTH_TOKEN = cached.accessToken; - emitStatus('ready', formatReadyDetail(cached.expiresAt), { + const status: RefresherStatus = remainingMs < EXPIRING_THRESHOLD_MS ? 'expiring' : 'ready'; + const detail = + status === 'expiring' ? formatExpiringDetail(cached.expiresAt) : formatReadyDetail(cached.expiresAt); + emitStatus(status, detail, { expiresAt: cached.expiresAt, ...(lastRefreshedAt !== undefined && { lastRefreshedAt }), }); @@ -137,6 +171,10 @@ export function makeOAuthRefresher(opts: OAuthRefresherOpts): OAuthRefresher { const body = await response.text().catch(() => ''); const detail = `HTTP ${response.status}: ${body}`; opts.logger.warn({ detail }, 'OAuth refresh non-200'); + if (response.status === 400 && isInvalidGrantBody(body)) { + emitStatus('error', 'Refresh failed: refresh token revoked — re-bootstrap'); + throw new RefreshTokenRevokedError(body || 'HTTP 400 invalid_grant'); + } if (cached.expiresAt < clock()) { emitStatus('error', `Refresh failed: ${detail}`); throw new RefreshNetworkError(detail); @@ -219,9 +257,38 @@ export function makeOAuthRefresher(opts: OAuthRefresherOpts): OAuthRefresher { }, REFRESH_TICK_MS); return () => clearInterval(interval); }, + + invalidate(): void { + cached = null; + inFlight = null; + }, }; } function formatReadyDetail(expiresAt: number): string { return `Connected (Claude Code OAuth, expires ${new Date(expiresAt).toISOString()})`; } + +function formatExpiringDetail(expiresAt: number): string { + return `Token expiring soon — run \`claude login\` then \`mc auth bootstrap\` (expires ${new Date(expiresAt).toISOString()})`; +} + +/** + * Parse the OAuth error body and return true iff `error === "invalid_grant"`. + * Matches the JSON shape Anthropic returns. Non-JSON or other error codes + * fall through to the generic non-200 path so legitimate 400s (e.g. + * `invalid_request`) don't get mis-classified as revocation. + */ +function isInvalidGrantBody(body: string): boolean { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return false; + } + return ( + typeof parsed === 'object' && + parsed !== null && + (parsed as Record).error === 'invalid_grant' + ); +} diff --git a/packages/daemon/src/bin/mcd-main.ts b/packages/daemon/src/bin/mcd-main.ts index 1984fc2..bd56bd1 100644 --- a/packages/daemon/src/bin/mcd-main.ts +++ b/packages/daemon/src/bin/mcd-main.ts @@ -15,6 +15,7 @@ import { probeAndRegister, probeAnthropic1Token, RefreshTokenRejectedError, + RefreshTokenRevokedError, resolveConfig, type StatusRegistry, sanitizeUpstreamError, @@ -309,12 +310,28 @@ export async function runMcdMain(_argv: readonly string[] = [], opts: RunMcdOpts envTarget, logger: logger.child({ module: 'auth-refresher' }), onStatusChange: (status, detail, extra) => { - if (status === 'ready') { - statusRegistry.set('claudeCode', { status: 'ready', detail, ...(extra ?? {}) }); - } else if (status === 'error') { - statusRegistry.setError('claudeCode', detail); - } else { - statusRegistry.setDisabled('claudeCode'); + switch (status) { + case 'ready': + statusRegistry.set('claudeCode', { status: 'ready', detail, ...(extra ?? {}) }); + break; + case 'expiring': + // Surface as `unverified` (semantic: "still works, but act now") so + // the dashboard's existing render path picks it up. Log at WARN with + // the exact remediation so the operator sees it before the chat + // lane hangs. + logger.warn({ detail }, 'claudeCode auth expiring soon — re-bootstrap recommended'); + statusRegistry.setUnverified('claudeCode', detail); + break; + case 'error': + statusRegistry.setError('claudeCode', detail); + break; + case 'disabled': + statusRegistry.setDisabled('claudeCode'); + break; + default: { + const _exhaustive: never = status; + void _exhaustive; + } } }, }); @@ -337,6 +354,11 @@ export async function runMcdMain(_argv: readonly string[] = [], opts: RunMcdOpts .catch((err) => { if (err instanceof NotBootstrappedError) { logger.warn('No credentials.json — agent runs will fail until `mc auth bootstrap` runs'); + } else if (err instanceof RefreshTokenRevokedError) { + logger.error( + { err: err.message }, + 'OAuth refresh token revoked (HTTP 400 invalid_grant) — run `claude login` then `mc auth bootstrap`', + ); } else if (err instanceof RefreshTokenRejectedError) { logger.error({ err: err.message }, 'OAuth refresh rejected — re-bootstrap needed'); } else if (err instanceof CorruptedCredentialsError) {