Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 23 additions & 17 deletions packages/cli/src/commands/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
66 changes: 45 additions & 21 deletions packages/cli/src/commands/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import {
Expand All @@ -14,17 +13,27 @@ 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;
}

export async function runAuthBootstrap(opts: RunAuthBootstrapOpts): Promise<number> {
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;
Expand All @@ -51,33 +60,48 @@ export async function runAuthBootstrap(opts: RunAuthBootstrapOpts): Promise<numb
return 1;
}

const expiresAtIso = new Date(bundle.expiresAt).toISOString();

if (flags.dryRun) {
stdout(`✓ would write ${path}`);
stdout(` Access token expires: ${expiresAtIso}`);
return 0;
}
const newExpiryIso = new Date(bundle.expiresAt).toISOString();
const oldExpiryIso = oldBundle ? new Date(oldBundle.expiresAt).toISOString() : null;

const store = makeCredentialStore({ path });
await store.write(bundle);
stdout(`✓ Wrote ${path} (mode 0600)`);
stdout(` Access token expires: ${expiresAtIso}`);
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');
if (!flags.dryRun) await store.write(bundle);
printBootstrapResult(stdout, { path, oldExpiryIso, newExpiryIso, dryRun: flags.dryRun === true });
return 0;
}

function printBootstrapResult(
stdout: (line: string) => 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');
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,4 +26,5 @@ export {
type RefresherStatusSnapshot,
RefreshNetworkError,
RefreshTokenRejectedError,
RefreshTokenRevokedError,
} from './refresher.js';
76 changes: 75 additions & 1 deletion packages/core/src/auth/refresher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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);
});
});
Loading
Loading