diff --git a/CHANGELOG.md b/CHANGELOG.md index 19d6240..b5629de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,43 +19,6 @@ Each slice entry has: ## Released -### Slice K3 — cabinet-style auth migration: delete `packages/core/src/auth/` · 2026-05-06 · PR (pending) - -**Shipped** - -- **Deleted entire `packages/core/src/auth/` tree** — `refresher.ts`, `credential-store.ts`, `credential-bundle.ts`, `credential-reader.ts`, `reader-factory.ts`, `index.ts`, every reader (`mac-keychain.ts`, `linux-fs.ts`, `linux-libsecret.ts`, `composite-linux.ts`, `parse-claude-credentials.ts`), and all matching `*.test.ts`. Mission Control no longer reimplements Claude Code OAuth. -- **Deleted `mc auth bootstrap` CLI command** (`packages/cli/src/commands/auth.ts` + test) and removed its registration from the `mc` entrypoint. The command is now meaningless — the SDK's bundled `claude` binary handles auth from `claude login` state directly. -- **Deleted `packages/daemon/src/bin/auth-alert.ts` + test** — the K1.5 Discord DM alert helper. With no MC-side refresher there are no `'expiring'` / `'error'` transitions to alert about. -- **`packages/daemon/src/bin/mcd-main.ts`** — removed `OAuthRefresher` construction, env-seeding of `CLAUDE_CODE_OAUTH_TOKEN`, the boot probe, the `auth-alert` wiring, and the `onReady` boot-race recovery. Replaced with a single `statusRegistry.setReady('claudeCode', detail)` seed at boot. Net delta: -~110 lines. -- **`packages/daemon/src/compose.ts`** — dropped `oauthRefresher` field from `ComposeMcdOpts` and removed the `auth-refresher` `DaemonService` lifecycle. -- **`packages/daemon/src/paths.ts`** — dropped `credentialsJsonPath`. -- **`packages/daemon/src/services.ts`** — dropped `credentialsJsonPath` from `MakeConfigWatcherServiceOpts` and the corresponding `statSync()` call in the config-watcher poll loop. One fewer syscall per tick. -- **New `packages/core/src/agent/auth-required-error.ts`** — small sentinel `AuthRequiredError` class. Replaces the deleted `isAuthRequiredError(err)` helper + the three `RefreshToken*Error` / `NotBootstrappedError` classes that all collapsed into "SDK reported a 401." -- **`packages/core/src/agent/sdk-source.ts`** — removed the refresher injection + retry-loop. Now streams eagerly; on a 401-result, throws `AuthRequiredError`. The K2 chat-lane fail-fast path takes over from there. -- **`packages/core/src/agent/runner.ts`** — replaced `isAuthRequiredError(err)` (deleted) with `err instanceof AuthRequiredError`. -- **`packages/core/src/messaging/chat-bridge.ts`** — `CHAT_AUTH_REQUIRED_REPLY` updated: drops `mc auth bootstrap` reference (command no longer exists), changes "expired" → "failed" (we only see a 401, can't distinguish expiry from revocation/missing-creds/etc.). -- **Dashboard wizard** — `BootstrapInstructionStep.tsx` rewritten: dropped the 2s `setInterval` poll of `/api/integrations/claudeCode/status` (now permanently `'ready'`); Continue button is always enabled. The polling was dead-weight under the K3 design. -- **Dashboard text updates** — `OnboardingPage.tsx`, `ClaudeCodeStatusPanel.tsx`, `useSetupAlerts.ts`, `integration-fields.ts`, `OnboardingRedirect.tsx`: every `mc auth bootstrap` reference replaced with `claude login`. -- **Net diff:** ~45 files changed, -2000 lines. - -**Why it matters** - -K1, K1.5, K2 fixed three concrete bugs in MC's reimplementation of Claude Code OAuth — but the deeper issue was that MC was reimplementing it at all. Cabinet (`hilash/cabinet`) showed the alternative: shell out to `claude` and trust the binary completely. K3's research spike confirmed the Claude Agent SDK already does exactly this — when `query()` runs with no `CLAUDE_CODE_OAUTH_TOKEN` set, it spawns the bundled `claude` binary at `node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64/claude` (216 MB Mach-O) which handles keychain reads, refresh-token rotation against `https://platform.claude.com/v1/oauth/token`, and credential storage in `~/.claude/.credentials.json`. A live probe with all three auth env vars unset returned a successful Haiku result in 8.2s. - -The deletion eliminates: a hard-coded `CLAUDE_CODE_CLIENT_ID` (Bug 4 from K1's diagnosis — exposure to Anthropic rotating the public client_id), three error class hierarchies, a 5-min refresh ticker, a credentials-watch mtime poll, a daemon-respawn-on-credentials-change path, a Discord DM alerting helper, four reader implementations across two platforms, and ~2000 lines of code that exists in `~/.claude/credentials.json` writers. MC's onboarding gate is now a single text instruction: "run `claude login`." Runtime auth failures still fail fast through `AuthRequiredError` → `CHAT_AUTH_REQUIRED_REPLY` (the K2 path is preserved). - -**Notable code** - -- `auth-required-error.ts:7-12` — the entire OAuth-failure surface that K1+K1.5+K2 spun up is now this 6-line class. The runner detects it via instanceof; chat-bridge's `ChatAuthRequiredError` (private) translates it to the user-visible reply. -- `mcd-main.ts:298-302` — `statusRegistry.setReady('claudeCode', '...')` replaces ~110 lines of refresher/auth-alert/boot-probe wiring. The dashboard's onboarding flow keeps working because the only thing it ever cared about was a populated status body. -- `sdk-source.ts:73-81` — the new 401 detection: one `if` branch, one throw. Replaces the buffer-everything-then-replay-or-discard retry path. Memory profile is now flat instead of O(messages-per-run). - -**Deferred** - -- **Linux/Windows verification.** The probe was run on darwin-arm64. The SDK ships parallel platform binaries; Mission Control's own code makes no platform-specific assumptions, so the bundled `claude` should work transparently on Linux/Windows too — but the smoke test wasn't run there. Document for future contributors. -- **`useSetupAlerts.ts` defensive branches.** The `claudeCode disabled` and `claudeCode error` banner paths in `useSetupAlerts.ts` are now unreachable in production (the daemon hard-pins `'ready'`). Kept as defensive — if a future slice reintroduces a probe, those branches will pick it up automatically. -- **`ClaudeCodeStatusPanel.tsx` dead branches.** The `expiresAt` / `lastRefreshedAt` rendering paths are likewise defensive-only. Same rationale. - ### Slice K2 — chat-lane fail-fast on Claude Code auth errors · 2026-05-06 · PR (pending) **Shipped** diff --git a/README.md b/README.md index a7f5d4c..8691f38 100644 --- a/README.md +++ b/README.md @@ -293,7 +293,7 @@ graph TB | `@mc/core/config` | `resolveConfig(store, registry)` — reads secrets and operational settings from SQLite (`integration_credentials` + `settings`), validates, returns deep-frozen `ResolvedConfig` with discriminated unions on `discord`, `memory.vec`, and `skills` (Slice J2: env-driven `MC_SKILLS_ROOT`, default `~/.claude/skills/`, validated for existence). `pbrainCli: boolean` and `neonCli: boolean` are sibling top-level fields set by `resolvePbrainCli()` and `resolveNeonCli()` respectively (both walk `$PATH` with `X_OK` checks via a private `isOnPath` helper). The Neon gate trusts the CLI's own auth cache (`neonctl auth` → `~/.config/neonctl/`, inherited via `$HOME` by the launchd-spawned daemon) — no parallel `NEON_API_KEY` env var required. Skills availability, pbrain CLI presence, and Neon CLI presence are independent capabilities. Invalid setting values surface via the `StatusRegistry` as `system` errors and fall back to defaults. | | `@mc/core/git` | `simple-git` ops — branch fork before query, diff stat at completion. | | `@mc/core/logger` | pino `makeLogger` — structured JSON, child loggers per module, redaction for secrets. | -| `@mc/core/messaging` | `MessagingAdapter` interface (`start({ onInbound, onCommand })`, `stop`, `send`, optional `react`) — every transport adapter implements it; Discord is the first concrete impl as of Slice H4a. `InboundDispatcher` — two-stage `accept(msg)` (sync-fast: single-statement `INSERT OR IGNORE` dedup, conversation upsert, fresh trace span, `inbound.accepted`/`inbound.deduped` event) → `process(msg, traceId)` (re-enters the trace and calls the injected `onProcess` handler — `makeProcessRouter` is the production handler as of Slice I1b). `resolveRouteForScope(channelId, cleanText, deps)` returns a discriminated `RouteResolution` (`explicit-task` / `chat-with-project` / `chat-no-project` / `rejected`); `makeProcessRouter({ orchestrator, chatBridge, resolveProject*, sendReply, adapterId })` dispatches by kind: explicit `work on … in ` → `orchestrator.create()`, free-form text in routed/default-DM channels → chat-bridge with the route's project, free-form text in unrouted DMs → chat-bridge with no project, unknown explicit slug → `sendReply` with the unknown-project hint. `makeChatBridge` runs the chat lane against the same `AgentRunner` the orchestrator uses but with a brainstorming system prompt (read-only `Read`/`Glob`/`Grep` filesystem tools sandboxed via `additionalDirectories: [project.rootPath]`, optional `mc-memory` MCP server for the `memory_search` tool); inserts a `runs` row with NULL `task_id`, captures `started.sessionId` into `conversations.last_session_id`, strips the `<>` sentinel and emits `chat.ready_to_promote`, and on a thrown stream error emits `chat.failed` plus a user-visible reply (Slice K2: when the agent failure carries `subtype: 'auth_required'` from the runner's `AuthRequiredError` tagging, the bridge sends `CHAT_AUTH_REQUIRED_REPLY` — "run `claude login`" — instead of the generic `CHAT_ERROR_REPLY`. Slice K3: the `AuthRequiredError` is now thrown by `sdk-source` directly when the bundled `claude` subprocess returns a 401, with no MC-side OAuth refresher in the loop). `makeRunCoordinator()` enforces single-run-per-conversation across both lanes. `makeDoCommandHandler` implements `/do` promotion (refuses on no-active-conversation, null lastSessionId, in-flight run, or unknown explicit slug; otherwise builds a `CreateTaskCommand` whose orchestrator path resumes the SDK session). `makeDispatcherHandlers(dispatcher, { onError })` is the shared handler shape compose + tests both wire. `command(cmd)` switches on `cmd.name`: `/new`/`/clear` close the active conversation and enqueue a `summarize_conversation` job (refused with `REPLY_RESET_REFUSED` when `isRunInFlight` says a run is mid-flight); `/do` calls the wired-in promotion handler and does NOT close the conversation. `stripPrivate(text)` removes `...` regions with depth-tracked nesting; `persistScopedMessage` is the shared assistant/user-row writer used by both lanes (single source of truth for the redaction policy). `trim(messages, budget)` — pure turn-group-atomic trimmer (drops whole atoms oldest-first, never orphans tool_use/tool_result pairs); ships locked-in for the future non-Claude provider slice. | +| `@mc/core/messaging` | `MessagingAdapter` interface (`start({ onInbound, onCommand })`, `stop`, `send`, optional `react`) — every transport adapter implements it; Discord is the first concrete impl as of Slice H4a. `InboundDispatcher` — two-stage `accept(msg)` (sync-fast: single-statement `INSERT OR IGNORE` dedup, conversation upsert, fresh trace span, `inbound.accepted`/`inbound.deduped` event) → `process(msg, traceId)` (re-enters the trace and calls the injected `onProcess` handler — `makeProcessRouter` is the production handler as of Slice I1b). `resolveRouteForScope(channelId, cleanText, deps)` returns a discriminated `RouteResolution` (`explicit-task` / `chat-with-project` / `chat-no-project` / `rejected`); `makeProcessRouter({ orchestrator, chatBridge, resolveProject*, sendReply, adapterId })` dispatches by kind: explicit `work on … in ` → `orchestrator.create()`, free-form text in routed/default-DM channels → chat-bridge with the route's project, free-form text in unrouted DMs → chat-bridge with no project, unknown explicit slug → `sendReply` with the unknown-project hint. `makeChatBridge` runs the chat lane against the same `AgentRunner` the orchestrator uses but with a brainstorming system prompt (read-only `Read`/`Glob`/`Grep` filesystem tools sandboxed via `additionalDirectories: [project.rootPath]`, optional `mc-memory` MCP server for the `memory_search` tool); inserts a `runs` row with NULL `task_id`, captures `started.sessionId` into `conversations.last_session_id`, strips the `<>` sentinel and emits `chat.ready_to_promote`, and on a thrown stream error emits `chat.failed` plus a user-visible reply (Slice K2: when the agent failure carries `subtype: 'auth_required'` from the runner's auth-error tagging, the bridge sends `CHAT_AUTH_REQUIRED_REPLY` with the exact recovery commands instead of the generic `CHAT_ERROR_REPLY`). `makeRunCoordinator()` enforces single-run-per-conversation across both lanes. `makeDoCommandHandler` implements `/do` promotion (refuses on no-active-conversation, null lastSessionId, in-flight run, or unknown explicit slug; otherwise builds a `CreateTaskCommand` whose orchestrator path resumes the SDK session). `makeDispatcherHandlers(dispatcher, { onError })` is the shared handler shape compose + tests both wire. `command(cmd)` switches on `cmd.name`: `/new`/`/clear` close the active conversation and enqueue a `summarize_conversation` job (refused with `REPLY_RESET_REFUSED` when `isRunInFlight` says a run is mid-flight); `/do` calls the wired-in promotion handler and does NOT close the conversation. `stripPrivate(text)` removes `...` regions with depth-tracked nesting; `persistScopedMessage` is the shared assistant/user-row writer used by both lanes (single source of truth for the redaction policy). `trim(messages, budget)` — pure turn-group-atomic trimmer (drops whole atoms oldest-first, never orphans tool_use/tool_result pairs); ships locked-in for the future non-Claude provider slice. | | `@mc/core/observability` | `withRootSpan(traceId, fn)` / `withSpan(fn)` / `withTraceSpan(fn)` — `AsyncLocalStorage`-backed trace propagation. `withTraceSpan` opens a child span when a parent context exists, otherwise a fresh root — used by the orchestrator so dispatcher-rooted runs join the inbound trace and CLI/scheduler runs auto-root. `EventBus.publish` is synchronous, so handlers see the publisher's trace context with zero per-listen plumbing. `EventWriter` (`info` / `warn` / `error` / `denied` / `compacted`) — façade over `AgentEventsRepo` that auto-pulls `traceId`/`spanId`/`parentSpanId` from the current context; throws if called outside any trace as a deliberate boundary. | | `@mc/core/testing` | `makeOrchestratorIntegration`, `silentLogger`, `captureLogs` — real DataStore + real bus + faked SDK. | | `@mc/memory` | `MemoryProvider` interface + `memoryProviderContract` shared spec runnable against any provider impl. | @@ -530,19 +530,31 @@ Notes: `KeepAlive: { SuccessfulExit: false }` restarts the proxy on crash but no ## Authentication -Mission Control authenticates to Anthropic via your Claude Code subscription — but it doesn't manage the OAuth flow itself. Setup is one terminal command: +Mission Control authenticates to Anthropic via your Claude Code subscription. Setup is a one-time terminal command: + +1. Log into Claude Code: `claude login` (opens a browser, completes the OAuth flow, writes credentials to your system keychain / Linux fs / libsecret). +2. Bootstrap Mission Control: `mc auth bootstrap`. + +The bootstrap command reads from your platform's credential store (macOS keychain via `security`, Linux fs at `~/.config/Claude/credentials.json` or `~/.claude/.credentials.json`, Linux libsecret via `secret-tool`) and writes them to a daemon-readable file at `~/.mc/credentials.json` (mode 0600, atomic). + +The daemon's `OAuthRefresher` then refreshes the OAuth token in the background — every 5 minutes it checks expiry; if less than 10 minutes remain, it POSTs to `https://claude.ai/v1/oauth/token` with `grant_type=refresh_token` to get a new bearer + new refresh token. The Claude Agent SDK reads `CLAUDE_CODE_OAUTH_TOKEN` from `process.env` directly; the refresher writes it on every successful rotation. No manual intervention required after bootstrap. + +**Re-bootstrap when:** +- The bot DMs you a "⚠️ Claude Code auth expiring soon" or "❌ Claude Code auth failure" alert (proactive 2h heads-up via the Discord owner DM — fix it before the chat lane stalls) +- 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 +- You ran `claude login` again on a different account ```bash -claude login # opens a browser, completes the OAuth flow +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 Claude Agent SDK that Mission Control depends on ships a bundled `claude` binary at `node_modules/@anthropic-ai/claude-agent-sdk-/claude`. Every `query()` call spawns this subprocess, which inherits your env, reads its own credential store (macOS keychain entry `Claude Code-credentials`, Linux fs at `~/.claude/.credentials.json` / libsecret, Windows credential manager), and runs its own refresh-token rotation against `https://platform.claude.com/v1/oauth/token`. From Mission Control's perspective, `claude login` is the only setup step — there's no `mc auth bootstrap`, no `~/.mc/credentials.json`, no daemon-side refresher. - -**If `claude login` is stale, expired, revoked server-side, or never run:** the SDK surfaces a `result` message with `is_error: true, api_error_status: 401` on the next agent run. `sdk-source.ts` translates that into `AuthRequiredError`, which the runner tags as `subtype: 'auth_required'`. Chat-bridge sends `CHAT_AUTH_REQUIRED_REPLY` ("Claude Code authentication failed. Run `claude login` to restore access.") to Discord; the task lane fails fast with `auth_revoked: ` so the dashboard can render an actionable error. +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.) -**To recover:** run `claude login` again. No daemon restart needed — the bundled `claude` binary picks up the rotated credential on the next subprocess spawn. +**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. -**Why no MC-side OAuth?** Earlier slices (K1, K1.5, K2) shipped a parallel implementation: `OAuthRefresher` reading the keychain via `security`, copying to `~/.mc/credentials.json`, refreshing every 5 min, surfacing `'expiring'` / `'error'` to the dashboard, DMing alerts on Discord. K3's research spike confirmed every one of those layers is duplicate work — the SDK already does it. Deleting MC's auth tree removed ~2000 lines, eliminated a hard-coded `CLAUDE_CODE_CLIENT_ID`, and made auth a single shell command that the user already understands. +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 new file mode 100644 index 0000000..35f49ca --- /dev/null +++ b/packages/cli/src/commands/auth.test.ts @@ -0,0 +1,128 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { CredentialReader } from '@mc/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { runAuthBootstrap } from './auth.js'; + +describe('mc auth bootstrap', () => { + let dir: string; + let path: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'mc-auth-cli-')); + path = join(dir, 'credentials.json'); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + function reader(result: Awaited>): CredentialReader { + return { read: vi.fn().mockResolvedValue(result) }; + } + + function failingReader(err: Error): CredentialReader { + return { read: vi.fn().mockRejectedValue(err) }; + } + + it('exits 0 and writes file when credentials found and target missing', async () => { + const out: string[] = []; + const code = await runAuthBootstrap({ + path, + reader: reader({ accessToken: 'A', refreshToken: 'R', expiresAt: Date.now() + 60_000 }), + flags: {}, + stdout: (l) => out.push(l), + stderr: (l) => out.push(l), + }); + expect(code).toBe(0); + expect(out.join('\n')).toMatch(/Wrote /); + const persisted = JSON.parse(await readFile(path, 'utf8')); + expect(persisted.accessToken).toBe('A'); + }); + + 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 code = await runAuthBootstrap({ + path, + reader: reader({ accessToken: 'NEW', refreshToken: 'NEW', expiresAt: newExpiry }), + flags: {}, + stdout: (l) => out.push(l), + stderr: (l) => out.push(l), + }); + expect(code).toBe(0); + 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('NEW'); + }); + + 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: '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('OLD'); + }); + + it('--dry-run with no existing file still does not write', async () => { + const out: string[] = []; + const code = await runAuthBootstrap({ + path, + reader: reader({ accessToken: 'A', refreshToken: 'R', expiresAt: Date.now() + 60_000 }), + flags: { dryRun: true }, + stdout: (l) => out.push(l), + stderr: (l) => out.push(l), + }); + expect(code).toBe(0); + expect(out.join('\n')).toMatch(/would write/i); + await expect(readFile(path)).rejects.toThrow(/ENOENT/); + }); + + it('exits 1 when reader returns null (not bootstrapped in system store)', async () => { + const out: string[] = []; + const code = await runAuthBootstrap({ + path, + reader: reader(null), + flags: {}, + stdout: (l) => out.push(l), + stderr: (l) => out.push(l), + }); + expect(code).toBe(1); + expect(out.join('\n')).toMatch(/Run `claude login`/); + }); + + it('exits 1 with troubleshooting hint on KeychainAuthFailedError', async () => { + const { KeychainAuthFailedError } = await import('@mc/core'); + const out: string[] = []; + const code = await runAuthBootstrap({ + path, + reader: failingReader(new KeychainAuthFailedError('test')), + flags: {}, + stdout: (l) => out.push(l), + stderr: (l) => out.push(l), + }); + expect(code).toBe(1); + expect(out.join('\n')).toMatch(/keychain/i); + expect(out.join('\n')).toMatch(/claude logout && claude login/); + }); +}); diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts new file mode 100644 index 0000000..1fb37a4 --- /dev/null +++ b/packages/cli/src/commands/auth.ts @@ -0,0 +1,120 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { + CorruptedCredentialsError, + type CredentialBundle, + type CredentialReader, + KeychainAuthFailedError, + makeCredentialReader, + makeCredentialStore, +} from '@mc/core'; +import { Command } from 'commander'; + +export interface RunAuthBootstrapOpts { + path: string; + reader: CredentialReader; + flags: { dryRun?: boolean }; + stdout: (line: string) => void; + stderr: (line: string) => void; +} + +export async function runAuthBootstrap(opts: RunAuthBootstrapOpts): Promise { + const { path, reader, flags, stdout, stderr } = opts; + + // 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; + try { + stdout('Reading credentials from system store...'); + bundle = await reader.read(); + } catch (err) { + if (err instanceof KeychainAuthFailedError) { + stderr(`✗ ${err.message}`); + stderr('Try: `claude logout && claude login`, then re-run `mc auth bootstrap`.'); + return 1; + } + if (err instanceof CorruptedCredentialsError) { + stderr(`✗ ${err.message}`); + return 1; + } + stderr(`✗ Read failed: ${err instanceof Error ? err.message : String(err)}`); + return 1; + } + + if (bundle === null) { + stderr('✗ No credentials found in system store.'); + stderr('Run `claude login` first, then re-run `mc auth bootstrap`.'); + return 1; + } + + const newExpiryIso = new Date(bundle.expiresAt).toISOString(); + const oldExpiryIso = oldBundle ? new Date(oldBundle.expiresAt).toISOString() : null; + + 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('--dry-run', 'Read from system store but do not write') + .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'); + const reader = makeCredentialReader({ platform: process.platform }); + const code = await runAuthBootstrap({ + path, + reader, + flags: rawFlags, + stdout: (l) => process.stdout.write(`${l}\n`), + stderr: (l) => process.stderr.write(`${l}\n`), + }); + process.exit(code); + }); + + return cmd; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 57235df..0ee733a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; +import { buildAuthCommand } from './commands/auth.js'; import { buildDaemonCommand, makeDaemonCommandsApi } from './commands/daemon.js'; import { buildIntegrateCommand, makeIntegrateCommandsApi } from './commands/integrate.js'; import { buildMemoryCommand, makeMemoryCommandsApi } from './commands/memory.js'; @@ -13,6 +14,7 @@ export function runMain(argv: string[]): void { const program = new Command(); program.name('mc').description('Mission Control — local-first AI agent fleet manager').version('0.0.0'); + program.addCommand(buildAuthCommand()); program.addCommand(buildProjectCommand(makeProjectCommandsApi())); program.addCommand(buildTasksCommand(makeTasksCommandsApi())); program.addCommand(buildRunsCommand(makeRunsCommandsApi())); diff --git a/packages/core/src/agent/auth-required-error.ts b/packages/core/src/agent/auth-required-error.ts deleted file mode 100644 index 87da074..0000000 --- a/packages/core/src/agent/auth-required-error.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Thrown by `makeSdkSourceFactory` when the SDK reports a 401-result. The - * runner catches this and tags the failed event with `subtype: 'auth_required'` - * so chat-bridge can render `CHAT_AUTH_REQUIRED_REPLY` instead of the generic - * "something went wrong" reply. Sentinel-only — no extra data is carried. - */ -export class AuthRequiredError extends Error { - constructor(detail: string) { - super(`Claude Code authentication required: ${detail}. Run \`claude login\` to authenticate.`); - this.name = 'AuthRequiredError'; - } -} diff --git a/packages/core/src/agent/runner.test.ts b/packages/core/src/agent/runner.test.ts index d876ee7..0cf999d 100644 --- a/packages/core/src/agent/runner.test.ts +++ b/packages/core/src/agent/runner.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { AuthRequiredError } from './auth-required-error.js'; +import { AUTH_REQUIRED_ERROR_CTORS } from '../auth/refresher.js'; import { makeAgentRunnerFromSource } from './runner.js'; import type { RawSdkMessage } from './sdk-stream-adapter.js'; import type { AgentEvent } from './types.js'; @@ -131,10 +131,10 @@ describe('makeAgentRunnerFromSource', () => { }); }); - it('tags AuthRequiredError as failed with subtype: auth_required', async () => { + it.each(AUTH_REQUIRED_ERROR_CTORS)('tags %s as failed with subtype: auth_required', async (ErrorCtor) => { async function* failing(): AsyncIterable { yield initMsg; - throw new AuthRequiredError('SDK returned HTTP 401'); + throw new ErrorCtor('hi'); } const runner = makeAgentRunnerFromSource(() => failing()); const evs = await collect(runner.run({ runId: 'r1', cwd: '/tmp', prompt: 'hi', model: MODEL })); diff --git a/packages/core/src/agent/runner.ts b/packages/core/src/agent/runner.ts index dee424d..c51d609 100644 --- a/packages/core/src/agent/runner.ts +++ b/packages/core/src/agent/runner.ts @@ -1,4 +1,4 @@ -import { AuthRequiredError } from './auth-required-error.js'; +import { isAuthRequiredError } from '../auth/refresher.js'; import { readJsonlMessages } from './replay.js'; import { type RawSdkMessage, SdkStreamAdapter } from './sdk-stream-adapter.js'; import type { AgentEvent, AgentRunInput, AgentRunner } from './types.js'; @@ -39,14 +39,14 @@ export function makeAgentRunnerFromSource(factory: RawSourceFactory): AgentRunne } } catch (err) { const message = err instanceof Error ? err.message : String(err); - // Auth errors propagated from sdk-source must be tagged so downstream - // lanes (chat-bridge, orchestrator) can surface a re-auth prompt - // instead of a generic "something went wrong" reply. + // Auth errors propagated from sdk-source → refresher must be tagged so + // downstream lanes (chat-bridge, orchestrator) can surface a re-auth + // prompt instead of a generic "something went wrong" reply. yield { type: 'failed', runId: input.runId, error: message, - ...(err instanceof AuthRequiredError && { subtype: 'auth_required' }), + ...(isAuthRequiredError(err) && { subtype: 'auth_required' }), }; } }, diff --git a/packages/core/src/agent/sdk-source-401.test.ts b/packages/core/src/agent/sdk-source-401.test.ts index a840c46..995d16d 100644 --- a/packages/core/src/agent/sdk-source-401.test.ts +++ b/packages/core/src/agent/sdk-source-401.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { OAuthRefresher } from '../auth/refresher.js'; -describe('makeSdkSourceFactory — 401 handling', () => { +describe('makeSdkSourceFactory — 401 retry', () => { let queryMock: ReturnType; beforeEach(() => { @@ -13,6 +14,23 @@ describe('makeSdkSourceFactory — 401 handling', () => { vi.unmock('@anthropic-ai/claude-agent-sdk'); }); + function refresherStub(): OAuthRefresher & { ensureFreshCalls: number } { + let calls = 0; + const stub = { + get ensureFreshCalls() { + return calls; + }, + ensureFresh: vi.fn(async () => { + calls += 1; + return 'new-token'; + }), + currentAccessToken: () => 'tok', + getStatus: () => ({ status: 'ready' as const, detail: '' }), + start: () => () => {}, + }; + return stub as unknown as OAuthRefresher & { ensureFreshCalls: number }; + } + async function* messages(...msgs: unknown[]) { for (const m of msgs) yield m; } @@ -32,13 +50,75 @@ describe('makeSdkSourceFactory — 401 handling', () => { expect(queryMock).toHaveBeenCalledTimes(1); }); - it('throws AuthRequiredError when the SDK returns a 401 result', async () => { + it('retries once on 401 result message when refresher is provided', async () => { + queryMock + .mockReturnValueOnce( + messages({ type: 'result', subtype: 'success', is_error: true, api_error_status: 401 }), + ) + .mockReturnValueOnce( + messages( + { type: 'assistant', message: { content: [{ type: 'text', text: 'hi' }] } }, + { type: 'result', subtype: 'success', is_error: false }, + ), + ); + const refresher = refresherStub(); + const { makeSdkSourceFactory } = await import('./sdk-source.js'); + const factory = makeSdkSourceFactory({ refresher }); + const out = []; + for await (const m of factory({ prompt: 'hi', cwd: '/tmp' } as never)) out.push(m); + expect(refresher.ensureFreshCalls).toBe(1); + expect(queryMock).toHaveBeenCalledTimes(2); + expect(out.find((m) => (m as { type: string }).type === 'result')).toMatchObject({ + is_error: false, + }); + }); + + it('does NOT retry a second time if 401 happens again after refresh', async () => { + queryMock.mockImplementation(() => + messages({ type: 'result', subtype: 'success', is_error: true, api_error_status: 401 }), + ); + const refresher = refresherStub(); + const { makeSdkSourceFactory } = await import('./sdk-source.js'); + const factory = makeSdkSourceFactory({ refresher }); + const out = []; + for await (const m of factory({ prompt: 'hi', cwd: '/tmp' } as never)) out.push(m); + expect(refresher.ensureFreshCalls).toBe(1); + expect(queryMock).toHaveBeenCalledTimes(2); + expect(out.find((m) => (m as { type: string; is_error?: boolean }).is_error === true)).toBeDefined(); + }); + + it('does NOT attempt refresh when no refresher provided', async () => { queryMock.mockReturnValue( messages({ type: 'result', subtype: 'success', is_error: true, api_error_status: 401 }), ); const { makeSdkSourceFactory } = await import('./sdk-source.js'); - const { AuthRequiredError } = await import('./auth-required-error.js'); const factory = makeSdkSourceFactory({}); + const out = []; + for await (const m of factory({ prompt: 'hi', cwd: '/tmp' } as never)) out.push(m); + expect(queryMock).toHaveBeenCalledTimes(1); + expect(out.find((m) => (m as { type: string; is_error?: boolean }).is_error === true)).toBeDefined(); + }); + + // Resolve the ctor list inside each test because beforeEach calls + // vi.resetModules() — the freshly loaded sdk-source binds against a fresh + // refresher module, so any statically-imported class identity here would + // miss the instanceof check. + it.each([ + 'NotBootstrappedError', + 'RefreshTokenRejectedError', + 'RefreshTokenRevokedError', + ])('rethrows %s from refresher.ensureFresh — never retries', async (name) => { + queryMock.mockReturnValue( + messages({ type: 'result', subtype: 'success', is_error: true, api_error_status: 401 }), + ); + const auth = await import('../auth/refresher.js'); + const ErrorCtor = (auth as unknown as Record Error>)[name]; + const refresher = refresherStub(); + refresher.ensureFresh = vi.fn(async () => { + throw new ErrorCtor('hi'); + }); + const { makeSdkSourceFactory } = await import('./sdk-source.js'); + const factory = makeSdkSourceFactory({ refresher }); let caught: Error | undefined; try { for await (const _ of factory({ prompt: 'hi', cwd: '/tmp' } as never)) { @@ -47,21 +127,7 @@ describe('makeSdkSourceFactory — 401 handling', () => { } catch (err) { caught = err as Error; } - expect(caught).toBeInstanceOf(AuthRequiredError); + expect(caught).toBeInstanceOf(ErrorCtor); expect(queryMock).toHaveBeenCalledTimes(1); }); - - it('non-401 errors are NOT converted to AuthRequiredError', async () => { - queryMock.mockReturnValue( - messages({ type: 'result', subtype: 'error_during_execution', is_error: true, api_error_status: 500 }), - ); - const { makeSdkSourceFactory } = await import('./sdk-source.js'); - const { AuthRequiredError } = await import('./auth-required-error.js'); - const factory = makeSdkSourceFactory({}); - const out = []; - for await (const m of factory({ prompt: 'hi', cwd: '/tmp' } as never)) out.push(m); - // 500 is yielded as a normal error result; only 401 throws. - expect(out).toHaveLength(1); - expect(out[0]).not.toBeInstanceOf(AuthRequiredError); - }); }); diff --git a/packages/core/src/agent/sdk-source.ts b/packages/core/src/agent/sdk-source.ts index c730876..f11962a 100644 --- a/packages/core/src/agent/sdk-source.ts +++ b/packages/core/src/agent/sdk-source.ts @@ -1,5 +1,5 @@ import { query } from '@anthropic-ai/claude-agent-sdk'; -import { AuthRequiredError } from './auth-required-error.js'; +import { isAuthRequiredError, type OAuthRefresher } from '../auth/refresher.js'; import type { RecordingSink } from './replay.js'; import type { RawSourceFactory } from './runner.js'; import type { RawSdkMessage } from './sdk-stream-adapter.js'; @@ -7,6 +7,7 @@ import type { AgentRunInput } from './types.js'; export interface MakeSdkSourceFactoryOpts { record?: RecordingSink; + refresher?: OAuthRefresher; } /** @@ -19,65 +20,107 @@ export interface MakeSdkSourceFactoryOpts { * to the JSONL fixture verbatim — that's how `MC_RECORD=1` produces replay * fixtures usable by `MC_REPLAY=path` for offline tests. * - * Auth: the SDK spawns a bundled `claude` binary that handles its own - * keychain reads + token refresh against `claude login` state. When the SDK - * surfaces a 401-result, MC has nothing it can do to recover — throw - * `AuthRequiredError` so the runner can tag the failure as `auth_required` - * and the chat-bridge can render an actionable re-auth reply. + * If a `refresher` is provided, the factory transparently retries ONCE when + * the SDK emits a 401 result message — calls `refresher.ensureFresh()` to + * force a token rotation, then issues a fresh query() call with the refreshed + * env. If the retry also 401s, the result is yielded as-is (no infinite loop). */ export function makeSdkSourceFactory(opts: MakeSdkSourceFactoryOpts = {}): RawSourceFactory { return async function* (input: AgentRunInput): AsyncIterable { - const abortController = new AbortController(); - if (input.abortSignal) { - if (input.abortSignal.aborted) abortController.abort(); - else input.abortSignal.addEventListener('abort', () => abortController.abort(), { once: true }); - } + let attemptedRefresh = false; + while (true) { + const abortController = new AbortController(); + if (input.abortSignal) { + if (input.abortSignal.aborted) abortController.abort(); + else input.abortSignal.addEventListener('abort', () => abortController.abort(), { once: true }); + } - // exactOptionalPropertyTypes forbids passing `{ key: undefined }`, so we - // only set each option when defined. - const queryOptions: Parameters[0]['options'] = { - cwd: input.cwd, - model: input.model, - abortController, - }; - if (input.systemPrompt !== undefined) queryOptions.systemPrompt = input.systemPrompt; - if (input.allowedTools !== undefined) queryOptions.allowedTools = input.allowedTools; - if (input.maxTurns !== undefined) queryOptions.maxTurns = input.maxTurns; - if (input.resumeSessionId !== undefined) queryOptions.resume = input.resumeSessionId; - if (input.mcpServers !== undefined) { - // Slice I1: chat lane registers the memory MCP server here. The SDK's - // `mcpServers` option keys server name → server config. Our `AgentRunInput` - // alias is `unknown`-typed to avoid leaking SDK types into the messaging - // layer; cast at this single seam. Drop undefined from the outer + inner - // (both optional under exactOptionalPropertyTypes) before assigning. - type McpMap = NonNullable['mcpServers']>; - queryOptions.mcpServers = input.mcpServers as McpMap; - } - if (input.additionalDirectories !== undefined) { - queryOptions.additionalDirectories = input.additionalDirectories; - } - if (input.canUseTool !== undefined) { - type CanUseToolOpt = NonNullable['canUseTool']>; - queryOptions.canUseTool = input.canUseTool as CanUseToolOpt; - } - if (input.permissionMode !== undefined) { - queryOptions.permissionMode = input.permissionMode; - // The SDK requires `allowDangerouslySkipPermissions: true` to be set - // alongside `permissionMode: 'bypassPermissions'` as a deliberate-bypass - // affordance. Pair them automatically so callers only think about one. - if (input.permissionMode === 'bypassPermissions') { - queryOptions.allowDangerouslySkipPermissions = true; + // exactOptionalPropertyTypes forbids passing `{ key: undefined }`, so we + // only set each option when defined. + const queryOptions: Parameters[0]['options'] = { + cwd: input.cwd, + model: input.model, + abortController, + }; + if (input.systemPrompt !== undefined) queryOptions.systemPrompt = input.systemPrompt; + if (input.allowedTools !== undefined) queryOptions.allowedTools = input.allowedTools; + if (input.maxTurns !== undefined) queryOptions.maxTurns = input.maxTurns; + if (input.resumeSessionId !== undefined) queryOptions.resume = input.resumeSessionId; + if (input.mcpServers !== undefined) { + // Slice I1: chat lane registers the memory MCP server here. The SDK's + // `mcpServers` option keys server name → server config. Our `AgentRunInput` + // alias is `unknown`-typed to avoid leaking SDK types into the messaging + // layer; cast at this single seam. Drop undefined from the outer + inner + // (both optional under exactOptionalPropertyTypes) before assigning. + type McpMap = NonNullable['mcpServers']>; + queryOptions.mcpServers = input.mcpServers as McpMap; + } + if (input.additionalDirectories !== undefined) { + queryOptions.additionalDirectories = input.additionalDirectories; + } + if (input.canUseTool !== undefined) { + type CanUseToolOpt = NonNullable['canUseTool']>; + queryOptions.canUseTool = input.canUseTool as CanUseToolOpt; + } + if (input.permissionMode !== undefined) { + queryOptions.permissionMode = input.permissionMode; + // The SDK requires `allowDangerouslySkipPermissions: true` to be set + // alongside `permissionMode: 'bypassPermissions'` as a deliberate-bypass + // affordance. Pair them automatically so callers only think about one. + if (input.permissionMode === 'bypassPermissions') { + queryOptions.allowDangerouslySkipPermissions = true; + } + } + + // Fast path: no refresher OR retry already attempted means no further + // retry is possible — stream eagerly. Avoids buffering an entire run's + // worth of messages in memory when we have no use for them. + if (opts.refresher === undefined || attemptedRefresh) { + for await (const msg of query({ prompt: input.prompt, options: queryOptions })) { + const raw = msg as unknown as RawSdkMessage; + opts.record?.write(raw); + yield raw; + } + return; + } + + // Retry path: buffer messages so we can discard them all if a 401 lands. + let saw401 = false; + const buffered: RawSdkMessage[] = []; + for await (const msg of query({ prompt: input.prompt, options: queryOptions })) { + const raw = msg as unknown as RawSdkMessage; + const m = raw as { type: string; is_error?: boolean; api_error_status?: number }; + if (m.type === 'result' && m.is_error === true && m.api_error_status === 401) { + saw401 = true; + break; + } + buffered.push(raw); + } + + if (!saw401) { + for (const r of buffered) { + opts.record?.write(r); + yield r; + } + return; } - } - for await (const msg of query({ prompt: input.prompt, options: queryOptions })) { - const raw = msg as unknown as RawSdkMessage; - const m = raw as { type: string; is_error?: boolean; api_error_status?: number }; - if (m.type === 'result' && m.is_error === true && m.api_error_status === 401) { - throw new AuthRequiredError('SDK returned HTTP 401'); + // Refresh + retry. Discard the buffered messages from the failed attempt. + try { + await opts.refresher.ensureFresh(); + } catch (err) { + // Non-transient auth errors mean the user MUST act — surface them + // directly so the agent runner can produce a meaningful error rather + // than another opaque 401. (HTTP 400/`invalid_grant` → revoked, + // HTTP 401 → rejected, missing creds → not bootstrapped.) + if (isAuthRequiredError(err)) { + throw err; + } + // Transient (network, 5xx): fall through, mark attempted, and let the + // loop re-issue query() with the current env. Next attempt will likely + // 401 again, which then yields via the eager fast path above. } - opts.record?.write(raw); - yield raw; + attemptedRefresh = true; } }; } diff --git a/packages/core/src/auth/credential-bundle.ts b/packages/core/src/auth/credential-bundle.ts new file mode 100644 index 0000000..f7db3e2 --- /dev/null +++ b/packages/core/src/auth/credential-bundle.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export interface CredentialBundle { + accessToken: string; + refreshToken: string; + /** Unix milliseconds. */ + expiresAt: number; +} + +export const credentialBundleSchema = z.object({ + accessToken: z.string().min(1), + refreshToken: z.string().min(1), + expiresAt: z.number().finite(), +}); diff --git a/packages/core/src/auth/credential-reader.ts b/packages/core/src/auth/credential-reader.ts new file mode 100644 index 0000000..2ea0010 --- /dev/null +++ b/packages/core/src/auth/credential-reader.ts @@ -0,0 +1,10 @@ +import type { CredentialBundle } from './credential-bundle.js'; + +export interface CredentialReader { + /** + * Read credentials from the platform's credential store. + * Returns null if not bootstrapped (user hasn't logged into Claude Code yet). + * Throws if the store is reachable but returns malformed/inaccessible data. + */ + read(): Promise; +} diff --git a/packages/core/src/auth/credential-store.test.ts b/packages/core/src/auth/credential-store.test.ts new file mode 100644 index 0000000..47c60df --- /dev/null +++ b/packages/core/src/auth/credential-store.test.ts @@ -0,0 +1,79 @@ +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { CorruptedCredentialsError, type CredentialBundle, makeCredentialStore } from './credential-store.js'; + +describe('CredentialStore', () => { + let dir: string; + let path: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'mc-cred-store-')); + path = join(dir, 'credentials.json'); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('read returns null when file does not exist', async () => { + const store = makeCredentialStore({ path }); + expect(await store.read()).toBeNull(); + }); + + it('read returns parsed bundle for valid JSON', async () => { + const bundle: CredentialBundle = { accessToken: 'a', refreshToken: 'r', expiresAt: 1234 }; + await writeFile(path, JSON.stringify(bundle)); + const store = makeCredentialStore({ path }); + expect(await store.read()).toEqual(bundle); + }); + + it('read throws CorruptedCredentialsError for malformed JSON', async () => { + await writeFile(path, '{not json'); + const store = makeCredentialStore({ path }); + await expect(store.read()).rejects.toBeInstanceOf(CorruptedCredentialsError); + await expect(store.read()).rejects.toThrow(/credentials.json is corrupted/); + }); + + it('read throws CorruptedCredentialsError when required fields missing', async () => { + await writeFile(path, JSON.stringify({ accessToken: 'a' })); // missing refreshToken, expiresAt + const store = makeCredentialStore({ path }); + await expect(store.read()).rejects.toBeInstanceOf(CorruptedCredentialsError); + await expect(store.read()).rejects.toThrow(/credentials.json is corrupted/); + }); + + it('write creates file with mode 0600', async () => { + const store = makeCredentialStore({ path }); + await store.write({ accessToken: 'a', refreshToken: 'r', expiresAt: 1 }); + expect(existsSync(path)).toBe(true); + const mode = statSync(path).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it('write is atomic (no tempfile remains on success)', async () => { + const store = makeCredentialStore({ path }); + await store.write({ accessToken: 'a', refreshToken: 'r', expiresAt: 1 }); + const files = readdirSync(dir); + expect(files).toEqual(['credentials.json']); + }); + + it('write replaces existing file atomically', async () => { + await writeFile(path, JSON.stringify({ accessToken: 'old', refreshToken: 'old', expiresAt: 0 })); + const store = makeCredentialStore({ path }); + await store.write({ accessToken: 'new', refreshToken: 'new', expiresAt: 99 }); + expect(JSON.parse(await readFile(path, 'utf8'))).toEqual({ + accessToken: 'new', + refreshToken: 'new', + expiresAt: 99, + }); + }); + + it('write round-trips through read', async () => { + const store = makeCredentialStore({ path }); + const bundle: CredentialBundle = { accessToken: 'a', refreshToken: 'r', expiresAt: 1234 }; + await store.write(bundle); + expect(await store.read()).toEqual(bundle); + }); +}); diff --git a/packages/core/src/auth/credential-store.ts b/packages/core/src/auth/credential-store.ts new file mode 100644 index 0000000..168cb16 --- /dev/null +++ b/packages/core/src/auth/credential-store.ts @@ -0,0 +1,83 @@ +import { mkdir, open, rename, rm, stat } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { type CredentialBundle, credentialBundleSchema } from './credential-bundle.js'; + +export type { CredentialBundle } from './credential-bundle.js'; + +export class CorruptedCredentialsError extends Error { + constructor(detail: string) { + super(`credentials.json is corrupted: ${detail}`); + this.name = 'CorruptedCredentialsError'; + } +} + +export interface CredentialStore { + read(): Promise; + write(bundle: CredentialBundle): Promise; +} + +export interface MakeCredentialStoreOpts { + path: string; +} + +export function makeCredentialStore(opts: MakeCredentialStoreOpts): CredentialStore { + return { + async read() { + let raw: string; + try { + const fh = await open(opts.path, 'r'); + try { + raw = await fh.readFile({ encoding: 'utf8' }); + } finally { + await fh.close(); + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw err; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new CorruptedCredentialsError((err as Error).message); + } + const result = credentialBundleSchema.safeParse(parsed); + if (!result.success) { + throw new CorruptedCredentialsError(result.error.message); + } + return result.data; + }, + + async write(bundle) { + const dir = dirname(opts.path); + await mkdir(dir, { recursive: true }); + const tmpPath = `${opts.path}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`; + let fh: Awaited> | undefined; + try { + fh = await open(tmpPath, 'w', 0o600); + await fh.writeFile(JSON.stringify(bundle), 'utf8'); + await fh.sync(); + } finally { + if (fh) await fh.close(); + } + try { + await rename(tmpPath, opts.path); + } catch (err) { + await rm(tmpPath, { force: true }).catch(() => {}); + throw err; + } + // POSIX rename preserves the source file's mode (0600). On systems where + // umask interferes (rare), enforce explicitly via stat+chmod fallback — + // most platforms don't need this, but it costs nothing on the happy path. + const st = await stat(opts.path); + if ((st.mode & 0o777) !== 0o600) { + const final = await open(opts.path, 'r+', 0o600); + try { + await final.chmod(0o600); + } finally { + await final.close(); + } + } + }, + }; +} diff --git a/packages/core/src/auth/index.ts b/packages/core/src/auth/index.ts new file mode 100644 index 0000000..41d82fe --- /dev/null +++ b/packages/core/src/auth/index.ts @@ -0,0 +1,30 @@ +export type { CredentialBundle } from './credential-bundle.js'; +export type { CredentialReader } from './credential-reader.js'; +export type { CredentialStore, MakeCredentialStoreOpts } from './credential-store.js'; +export { + CorruptedCredentialsError, + makeCredentialStore, +} from './credential-store.js'; +export { + type MakeCredentialReaderOpts, + makeCredentialReader, + UnsupportedPlatformError, +} from './reader-factory.js'; +export { makeCompositeLinuxReader } from './readers/composite-linux.js'; +export { makeLinuxFsReader } from './readers/linux-fs.js'; +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, + type OAuthRefresher, + type OAuthRefresherOpts, + type RefresherStatus, + type RefresherStatusSnapshot, + RefreshNetworkError, + RefreshTokenRejectedError, + RefreshTokenRevokedError, +} from './refresher.js'; diff --git a/packages/core/src/auth/reader-factory.test.ts b/packages/core/src/auth/reader-factory.test.ts new file mode 100644 index 0000000..d0a2c6a --- /dev/null +++ b/packages/core/src/auth/reader-factory.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { makeCredentialReader, UnsupportedPlatformError } from './reader-factory.js'; + +describe('makeCredentialReader', () => { + it('returns a MacKeychainReader-shaped reader on darwin', () => { + const r = makeCredentialReader({ platform: 'darwin' }); + expect(typeof r.read).toBe('function'); + }); + + it('returns a CompositeLinuxReader-shaped reader on linux', () => { + const r = makeCredentialReader({ platform: 'linux', home: '/home/test' }); + expect(typeof r.read).toBe('function'); + }); + + it('throws UnsupportedPlatformError on win32', () => { + expect(() => makeCredentialReader({ platform: 'win32' })).toThrow(UnsupportedPlatformError); + }); + + it('throws UnsupportedPlatformError on other unknown platforms', () => { + expect(() => makeCredentialReader({ platform: 'aix' as NodeJS.Platform })).toThrow( + UnsupportedPlatformError, + ); + }); +}); diff --git a/packages/core/src/auth/reader-factory.ts b/packages/core/src/auth/reader-factory.ts new file mode 100644 index 0000000..8abcc24 --- /dev/null +++ b/packages/core/src/auth/reader-factory.ts @@ -0,0 +1,33 @@ +import type { CredentialReader } from './credential-reader.js'; +import { makeCompositeLinuxReader } from './readers/composite-linux.js'; +import { makeLinuxFsReader } from './readers/linux-fs.js'; +import { makeLinuxLibsecretReader } from './readers/linux-libsecret.js'; +import { makeMacKeychainReader } from './readers/mac-keychain.js'; + +export class UnsupportedPlatformError extends Error { + constructor(platform: string) { + super( + `Mission Control auth bootstrap is not supported on platform "${platform}". Use macOS, Linux, or WSL on Windows.`, + ); + this.name = 'UnsupportedPlatformError'; + } +} + +export interface MakeCredentialReaderOpts { + platform: NodeJS.Platform; + home?: string; +} + +export function makeCredentialReader(opts: MakeCredentialReaderOpts): CredentialReader { + switch (opts.platform) { + case 'darwin': + return makeMacKeychainReader(); + case 'linux': + return makeCompositeLinuxReader({ + fs: makeLinuxFsReader({ ...(opts.home !== undefined && { home: opts.home }) }), + libsecret: makeLinuxLibsecretReader(), + }); + default: + throw new UnsupportedPlatformError(opts.platform); + } +} diff --git a/packages/core/src/auth/readers/composite-linux.test.ts b/packages/core/src/auth/readers/composite-linux.test.ts new file mode 100644 index 0000000..4c9a666 --- /dev/null +++ b/packages/core/src/auth/readers/composite-linux.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { CredentialReader } from '../credential-reader.js'; +import { makeCompositeLinuxReader } from './composite-linux.js'; + +function reader(result: Awaited>): CredentialReader { + return { read: vi.fn().mockResolvedValue(result) }; +} + +const bundle = { accessToken: 'a', refreshToken: 'r', expiresAt: 1 }; + +describe('CompositeLinuxReader', () => { + it('returns fs bundle when fs reader succeeds', async () => { + const fs = reader(bundle); + const libsecret = reader(null); + const composite = makeCompositeLinuxReader({ fs, libsecret }); + expect(await composite.read()).toEqual(bundle); + expect(libsecret.read).not.toHaveBeenCalled(); + }); + + it('falls through to libsecret when fs returns null', async () => { + const fs = reader(null); + const libsecret = reader(bundle); + const composite = makeCompositeLinuxReader({ fs, libsecret }); + expect(await composite.read()).toEqual(bundle); + expect(libsecret.read).toHaveBeenCalled(); + }); + + it('returns null when both return null', async () => { + const composite = makeCompositeLinuxReader({ fs: reader(null), libsecret: reader(null) }); + expect(await composite.read()).toBeNull(); + }); + + it('propagates fs error without trying libsecret', async () => { + const fs: CredentialReader = { read: vi.fn().mockRejectedValue(new Error('fs boom')) }; + const libsecret = reader(bundle); + const composite = makeCompositeLinuxReader({ fs, libsecret }); + await expect(composite.read()).rejects.toThrow(/fs boom/); + expect(libsecret.read).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/auth/readers/composite-linux.ts b/packages/core/src/auth/readers/composite-linux.ts new file mode 100644 index 0000000..ae1aaa1 --- /dev/null +++ b/packages/core/src/auth/readers/composite-linux.ts @@ -0,0 +1,17 @@ +import type { CredentialBundle } from '../credential-bundle.js'; +import type { CredentialReader } from '../credential-reader.js'; + +export interface MakeCompositeLinuxReaderOpts { + fs: CredentialReader; + libsecret: CredentialReader; +} + +export function makeCompositeLinuxReader(opts: MakeCompositeLinuxReaderOpts): CredentialReader { + return { + async read(): Promise { + const fsResult = await opts.fs.read(); + if (fsResult !== null) return fsResult; + return opts.libsecret.read(); + }, + }; +} diff --git a/packages/core/src/auth/readers/linux-fs.test.ts b/packages/core/src/auth/readers/linux-fs.test.ts new file mode 100644 index 0000000..ceb4430 --- /dev/null +++ b/packages/core/src/auth/readers/linux-fs.test.ts @@ -0,0 +1,53 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { makeLinuxFsReader } from './linux-fs.js'; + +describe('LinuxFsReader', () => { + let home: string; + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'mc-linux-fs-')); + }); + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + }); + + function bundle() { + return { + claudeAiOauth: { + accessToken: 'sk-ant-oat01-AAA', + refreshToken: 'sk-ant-ort01-RRR', + expiresAt: 1777921762412, + }, + }; + } + + it('reads from ~/.config/Claude/credentials.json (preferred)', async () => { + await mkdir(join(home, '.config/Claude'), { recursive: true }); + await writeFile(join(home, '.config/Claude/credentials.json'), JSON.stringify(bundle())); + const reader = makeLinuxFsReader({ home }); + const result = await reader.read(); + expect(result?.accessToken).toBe('sk-ant-oat01-AAA'); + }); + + it('falls back to ~/.claude/.credentials.json when first path missing', async () => { + await mkdir(join(home, '.claude'), { recursive: true }); + await writeFile(join(home, '.claude/.credentials.json'), JSON.stringify(bundle())); + const reader = makeLinuxFsReader({ home }); + const result = await reader.read(); + expect(result?.refreshToken).toBe('sk-ant-ort01-RRR'); + }); + + it('returns null when neither path exists', async () => { + const reader = makeLinuxFsReader({ home }); + expect(await reader.read()).toBeNull(); + }); + + it('throws when file exists but is malformed', async () => { + await mkdir(join(home, '.config/Claude'), { recursive: true }); + await writeFile(join(home, '.config/Claude/credentials.json'), '{not json'); + const reader = makeLinuxFsReader({ home }); + await expect(reader.read()).rejects.toThrow(/credentials file/i); + }); +}); diff --git a/packages/core/src/auth/readers/linux-fs.ts b/packages/core/src/auth/readers/linux-fs.ts new file mode 100644 index 0000000..a454176 --- /dev/null +++ b/packages/core/src/auth/readers/linux-fs.ts @@ -0,0 +1,34 @@ +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { CredentialBundle } from '../credential-bundle.js'; +import type { CredentialReader } from '../credential-reader.js'; +import { parseClaudeCredentialsJson } from './parse-claude-credentials.js'; + +const CANDIDATE_PATHS = (home: string): string[] => [ + join(home, '.config', 'Claude', 'credentials.json'), + join(home, '.claude', '.credentials.json'), +]; + +export interface MakeLinuxFsReaderOpts { + home?: string; +} + +export function makeLinuxFsReader(opts: MakeLinuxFsReaderOpts = {}): CredentialReader { + const home = opts.home ?? homedir(); + return { + async read(): Promise { + for (const path of CANDIDATE_PATHS(home)) { + let raw: string; + try { + raw = await readFile(path, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw err; + } + return parseClaudeCredentialsJson(raw, `Linux credentials file ${path}`); + } + return null; + }, + }; +} diff --git a/packages/core/src/auth/readers/linux-libsecret.test.ts b/packages/core/src/auth/readers/linux-libsecret.test.ts new file mode 100644 index 0000000..6d07895 --- /dev/null +++ b/packages/core/src/auth/readers/linux-libsecret.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest'; +import { makeLinuxLibsecretReader } from './linux-libsecret.js'; + +describe('LinuxLibsecretReader', () => { + it('returns parsed bundle on successful lookup', async () => { + const exec = vi.fn().mockReturnValue( + `${JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-oat01-AAA', + refreshToken: 'sk-ant-ort01-RRR', + expiresAt: 1777921762412, + }, + })}\n`, + ); + const reader = makeLinuxLibsecretReader({ execFile: exec as never }); + const result = await reader.read(); + expect(result?.accessToken).toBe('sk-ant-oat01-AAA'); + expect(exec).toHaveBeenCalledWith( + 'secret-tool', + ['lookup', 'service', 'Claude Code-credentials'], + expect.objectContaining({ encoding: 'utf8' }), + ); + }); + + it('returns null when secret-tool exits 1 (no item)', async () => { + const err = Object.assign(new Error('Command failed'), { status: 1, stderr: '' }); + const exec = vi.fn().mockImplementation(() => { + throw err; + }); + const reader = makeLinuxLibsecretReader({ execFile: exec as never }); + expect(await reader.read()).toBeNull(); + }); + + it('returns null when secret-tool is not installed (ENOENT)', async () => { + const err = Object.assign(new Error('spawn secret-tool ENOENT'), { code: 'ENOENT' }); + const exec = vi.fn().mockImplementation(() => { + throw err; + }); + const reader = makeLinuxLibsecretReader({ execFile: exec as never }); + expect(await reader.read()).toBeNull(); + }); + + it('throws on other non-zero exit codes', async () => { + const err = Object.assign(new Error('Command failed'), { status: 2, stderr: 'permission denied' }); + const exec = vi.fn().mockImplementation(() => { + throw err; + }); + const reader = makeLinuxLibsecretReader({ execFile: exec as never }); + await expect(reader.read()).rejects.toThrow(/secret-tool failed/); + }); +}); diff --git a/packages/core/src/auth/readers/linux-libsecret.ts b/packages/core/src/auth/readers/linux-libsecret.ts new file mode 100644 index 0000000..5782295 --- /dev/null +++ b/packages/core/src/auth/readers/linux-libsecret.ts @@ -0,0 +1,33 @@ +import { execFileSync as defaultExecFileSync } from 'node:child_process'; +import type { CredentialBundle } from '../credential-bundle.js'; +import type { CredentialReader } from '../credential-reader.js'; +import { parseClaudeCredentialsJson } from './parse-claude-credentials.js'; + +const SECRET_TOOL = 'secret-tool'; +const SECRET_SERVICE = 'Claude Code-credentials'; + +export interface MakeLinuxLibsecretReaderOpts { + execFile?: typeof defaultExecFileSync; +} + +export function makeLinuxLibsecretReader(opts: MakeLinuxLibsecretReaderOpts = {}): CredentialReader { + const exec = opts.execFile ?? defaultExecFileSync; + return { + async read(): Promise { + let raw: string; + try { + raw = exec(SECRET_TOOL, ['lookup', 'service', SECRET_SERVICE], { + encoding: 'utf8', + timeout: 5_000, + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + } catch (err) { + const e = err as { status?: number; code?: string }; + if (e.code === 'ENOENT') return null; // secret-tool not installed + if (e.status === 1) return null; // no matching item + throw new Error(`secret-tool failed (status ${e.status}): ${(err as Error).message}`); + } + return parseClaudeCredentialsJson(raw, 'libsecret payload'); + }, + }; +} diff --git a/packages/core/src/auth/readers/mac-keychain.test.ts b/packages/core/src/auth/readers/mac-keychain.test.ts new file mode 100644 index 0000000..6b9c590 --- /dev/null +++ b/packages/core/src/auth/readers/mac-keychain.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; +import { KeychainAuthFailedError, makeMacKeychainReader } from './mac-keychain.js'; + +describe('MacKeychainReader', () => { + it('returns parsed bundle on successful read', async () => { + const exec = vi.fn().mockReturnValue( + `${JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-oat01-AAA', + refreshToken: 'sk-ant-ort01-RRR', + expiresAt: 1777921762412, + }, + })}\n`, + ); + const reader = makeMacKeychainReader({ execFile: exec as never }); + expect(await reader.read()).toEqual({ + accessToken: 'sk-ant-oat01-AAA', + refreshToken: 'sk-ant-ort01-RRR', + expiresAt: 1777921762412, + }); + expect(exec).toHaveBeenCalledWith( + '/usr/bin/security', + ['find-generic-password', '-s', 'Claude Code-credentials', '-w'], + expect.objectContaining({ encoding: 'utf8' }), + ); + }); + + it('throws KeychainAuthFailedError on status 36 (errSecAuthFailed)', async () => { + const err = Object.assign(new Error('Command failed'), { status: 36, stderr: '' }); + const exec = vi.fn().mockImplementation(() => { + throw err; + }); + const reader = makeMacKeychainReader({ execFile: exec as never }); + await expect(reader.read()).rejects.toThrow(KeychainAuthFailedError); + }); + + it('returns null on status 44 (item not found)', async () => { + const err = Object.assign(new Error('Command failed'), { status: 44, stderr: '' }); + const exec = vi.fn().mockImplementation(() => { + throw err; + }); + const reader = makeMacKeychainReader({ execFile: exec as never }); + expect(await reader.read()).toBeNull(); + }); + + it('throws on malformed JSON', async () => { + const exec = vi.fn().mockReturnValue('{not json\n'); + const reader = makeMacKeychainReader({ execFile: exec as never }); + await expect(reader.read()).rejects.toThrow(/keychain payload/i); + }); + + it('throws when claudeAiOauth field is missing', async () => { + const exec = vi.fn().mockReturnValue(`${JSON.stringify({ mcpOAuth: {} })}\n`); + const reader = makeMacKeychainReader({ execFile: exec as never }); + await expect(reader.read()).rejects.toThrow(/claudeAiOauth/); + }); +}); diff --git a/packages/core/src/auth/readers/mac-keychain.ts b/packages/core/src/auth/readers/mac-keychain.ts new file mode 100644 index 0000000..def1112 --- /dev/null +++ b/packages/core/src/auth/readers/mac-keychain.ts @@ -0,0 +1,47 @@ +import { execFileSync as defaultExecFileSync } from 'node:child_process'; +import type { CredentialBundle } from '../credential-bundle.js'; +import type { CredentialReader } from '../credential-reader.js'; +import { parseClaudeCredentialsJson } from './parse-claude-credentials.js'; + +export class KeychainAuthFailedError extends Error { + constructor(detail: string) { + super(`macOS keychain returned errSecAuthFailed (status 36): ${detail}`); + this.name = 'KeychainAuthFailedError'; + } +} + +const SECURITY_BIN = '/usr/bin/security'; +const KEYCHAIN_SERVICE = 'Claude Code-credentials'; +const STATUS_AUTH_FAILED = 36; +const STATUS_NOT_FOUND = 44; + +export interface MakeMacKeychainReaderOpts { + /** Injectable for tests; defaults to node:child_process execFileSync. */ + execFile?: typeof defaultExecFileSync; +} + +export function makeMacKeychainReader(opts: MakeMacKeychainReaderOpts = {}): CredentialReader { + const exec = opts.execFile ?? defaultExecFileSync; + return { + async read(): Promise { + let raw: string; + try { + raw = exec(SECURITY_BIN, ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'], { + encoding: 'utf8', + timeout: 5_000, + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + } catch (err) { + const status = (err as { status?: number }).status; + if (status === STATUS_NOT_FOUND) return null; + if (status === STATUS_AUTH_FAILED) { + throw new KeychainAuthFailedError( + 'Run `claude logout && claude login` to refresh keychain ACL, then retry.', + ); + } + throw err; + } + return parseClaudeCredentialsJson(raw, 'keychain payload'); + }, + }; +} diff --git a/packages/core/src/auth/readers/parse-claude-credentials.ts b/packages/core/src/auth/readers/parse-claude-credentials.ts new file mode 100644 index 0000000..3e94082 --- /dev/null +++ b/packages/core/src/auth/readers/parse-claude-credentials.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { type CredentialBundle, credentialBundleSchema } from '../credential-bundle.js'; + +const claudePayloadSchema = z.object({ + claudeAiOauth: credentialBundleSchema, +}); + +/** + * Parse a Claude Code credential payload (the wire format used by macOS keychain, + * Linux fs files, and libsecret entries — all use the same `{ claudeAiOauth: ... }` + * envelope). Centralizes JSON.parse + zod validation so each reader's failure + * mode is consistent. + * + * `source` is a human-readable label (e.g. `'keychain payload'`, + * `'/home/user/.config/Claude/credentials.json'`) included in error messages + * so the bootstrap CLI can produce actionable output. + * + * Validation goes through `credentialBundleSchema` (z.number().finite()) — this + * rejects NaN / ±Infinity that the previous `typeof !== 'number'` guards let + * through. + */ +export function parseClaudeCredentialsJson(raw: string, source: string): CredentialBundle { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error(`${source} not valid JSON: ${(err as Error).message}`); + } + const result = claudePayloadSchema.safeParse(parsed); + if (!result.success) { + throw new Error(`${source} missing claudeAiOauth.{accessToken,refreshToken,expiresAt}`); + } + return result.data.claudeAiOauth; +} diff --git a/packages/core/src/auth/refresher.test.ts b/packages/core/src/auth/refresher.test.ts new file mode 100644 index 0000000..642b1fb --- /dev/null +++ b/packages/core/src/auth/refresher.test.ts @@ -0,0 +1,317 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +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 = { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + fatal: () => {}, + child: () => NOOP_LOGGER, +} as unknown as Parameters[0]['logger']; + +function fakeFetcher(responses: Array): typeof fetch { + let i = 0; + return (async () => { + const r = responses[i++]; + if (r instanceof Error) throw r; + if (!r) throw new Error('fake fetcher exhausted'); + return r; + }) as typeof fetch; +} + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + ...init, + }); +} + +describe('OAuthRefresher', () => { + let dir: string; + let path: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'mc-refresher-')); + path = join(dir, 'credentials.json'); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + async function seed(bundle: { accessToken: string; refreshToken: string; expiresAt: number }) { + const store = makeCredentialStore({ path }); + await store.write(bundle); + return store; + } + + it('ensureFresh throws NotBootstrappedError when file missing', async () => { + const store = makeCredentialStore({ path }); + const refresher = makeOAuthRefresher({ store, fetcher: fakeFetcher([]), logger: NOOP_LOGGER }); + await expect(refresher.ensureFresh()).rejects.toBeInstanceOf(NotBootstrappedError); + }); + + it('ensureFresh returns cached token when fresh (>10 min until expiry)', async () => { + const now = 1_000_000_000_000; + const store = await seed({ accessToken: 'A1', refreshToken: 'R1', expiresAt: now + 30 * 60 * 1000 }); + const fetcher = vi.fn() as unknown as typeof fetch; + const refresher = makeOAuthRefresher({ + store, + fetcher, + clock: () => now, + logger: NOOP_LOGGER, + }); + expect(await refresher.ensureFresh()).toBe('A1'); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('ensureFresh refreshes when expires within 10-min window', async () => { + const now = 1_000_000_000_000; + const store = await seed({ accessToken: 'A1', refreshToken: 'R1', expiresAt: now + 5 * 60 * 1000 }); + const fetcher = fakeFetcher([ + jsonResponse({ access_token: 'A2', refresh_token: 'R2', expires_in: 3600 }), + ]); + const refresher = makeOAuthRefresher({ store, fetcher, clock: () => now, logger: NOOP_LOGGER }); + expect(await refresher.ensureFresh()).toBe('A2'); + const persisted = JSON.parse(await readFile(path, 'utf8')); + expect(persisted).toEqual({ accessToken: 'A2', refreshToken: 'R2', expiresAt: now + 3600 * 1000 }); + }); + + it('ensureFresh throws RefreshTokenRejectedError on 401', async () => { + const now = 1_000_000_000_000; + await seed({ accessToken: 'A1', refreshToken: 'R1', expiresAt: now + 1000 }); + const store = makeCredentialStore({ path }); + const fetcher = fakeFetcher([jsonResponse({ error: 'invalid_grant' }, { status: 401 })]); + const refresher = makeOAuthRefresher({ store, fetcher, clock: () => now, logger: NOOP_LOGGER }); + await expect(refresher.ensureFresh()).rejects.toBeInstanceOf(RefreshTokenRejectedError); + }); + + it('ensureFresh returns cached token when network fails AND 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 Error('ENOTFOUND claude.ai')]); + const refresher = makeOAuthRefresher({ store, fetcher, clock: () => now, logger: NOOP_LOGGER }); + expect(await refresher.ensureFresh()).toBe('A1'); + }); + + it('ensureFresh throws RefreshNetworkError when network fails AND cached is expired', async () => { + const now = 1_000_000_000_000; + await seed({ accessToken: 'A1', refreshToken: 'R1', expiresAt: now - 1000 }); + const store = makeCredentialStore({ path }); + const fetcher = fakeFetcher([new Error('ENOTFOUND claude.ai')]); + const refresher = makeOAuthRefresher({ store, fetcher, clock: () => now, logger: NOOP_LOGGER }); + await expect(refresher.ensureFresh()).rejects.toBeInstanceOf(RefreshNetworkError); + }); + + it('ensureFresh dedupes concurrent calls (one POST, not N)', async () => { + const now = 1_000_000_000_000; + await seed({ accessToken: 'A1', refreshToken: 'R1', expiresAt: now + 1000 }); + const store = makeCredentialStore({ path }); + const fetcher = vi + .fn() + .mockResolvedValue( + jsonResponse({ access_token: 'A2', refresh_token: 'R2', expires_in: 3600 }), + ) as unknown as typeof fetch; + const refresher = makeOAuthRefresher({ store, fetcher, clock: () => now, logger: NOOP_LOGGER }); + const [a, b, c] = await Promise.all([ + refresher.ensureFresh(), + refresher.ensureFresh(), + refresher.ensureFresh(), + ]); + expect(a).toBe('A2'); + expect(b).toBe('A2'); + expect(c).toBe('A2'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('onStatusChange fires only on transitions, not every refresh', async () => { + 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: 8 * 3600 }), + ) as unknown as typeof fetch; + const onStatusChange = vi.fn(); + const refresher = makeOAuthRefresher({ + store, + fetcher, + clock: () => now, + logger: NOOP_LOGGER, + onStatusChange, + }); + await refresher.ensureFresh(); // disabled → ready transition + await refresher.ensureFresh(); // ready → ready (no transition) + expect(onStatusChange).toHaveBeenCalledTimes(1); + expect(onStatusChange).toHaveBeenCalledWith( + 'ready', + expect.stringContaining('Connected'), + expect.any(Object), + ); + }); + + it('start() schedules periodic ticks; stop fn clears the timer', async () => { + vi.useFakeTimers(); + try { + const now = 1_000_000_000_000; + vi.setSystemTime(now); + await seed({ accessToken: 'A1', refreshToken: 'R1', expiresAt: now + 30 * 60 * 1000 }); + const store = makeCredentialStore({ path }); + const fetcher = vi.fn() as unknown as typeof fetch; + const refresher = makeOAuthRefresher({ store, fetcher, logger: NOOP_LOGGER }); + const stop = refresher.start(); + // Advance 5 min — tick fires, but token still fresh so no fetch + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(fetcher).not.toHaveBeenCalled(); + stop(); + // Advance another 5 min — tick should NOT fire (timer cleared) + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(fetcher).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('currentAccessToken returns null when not bootstrapped, populated after ensureFresh', async () => { + const store = makeCredentialStore({ path }); + const refresher = makeOAuthRefresher({ store, fetcher: fakeFetcher([]), logger: NOOP_LOGGER }); + expect(refresher.currentAccessToken()).toBeNull(); + await seed({ accessToken: 'A1', refreshToken: 'R1', expiresAt: Date.now() + 60 * 60 * 1000 }); + await refresher.ensureFresh(); + expect(refresher.currentAccessToken()).toBe('A1'); + }); + + it('getStatus returns disabled when not bootstrapped', () => { + const store = makeCredentialStore({ path }); + const refresher = makeOAuthRefresher({ store, fetcher: fakeFetcher([]), logger: NOOP_LOGGER }); + expect(refresher.getStatus().status).toBe('disabled'); + }); + + it('throws RefreshNetworkError when response body is not valid JSON', async () => { + const now = 1_000_000_000_000; + await seed({ accessToken: 'A1', refreshToken: 'R1', expiresAt: now + 1000 }); + const store = makeCredentialStore({ path }); + const fetcher = fakeFetcher([ + new Response('503 service unavailable', { + status: 200, + headers: { 'content-type': 'text/html' }, + }), + ]); + const refresher = makeOAuthRefresher({ store, fetcher, clock: () => now, logger: NOOP_LOGGER }); + await expect(refresher.ensureFresh()).rejects.toBeInstanceOf(RefreshNetworkError); + }); + + it('throws RefreshTokenRejectedError when expires_in is negative or NaN', async () => { + const now = 1_000_000_000_000; + await seed({ accessToken: 'A1', refreshToken: 'R1', expiresAt: now + 1000 }); + const store = makeCredentialStore({ path }); + const fetcher = fakeFetcher([jsonResponse({ access_token: 'A2', refresh_token: 'R2', expires_in: -1 })]); + const refresher = makeOAuthRefresher({ store, fetcher, clock: () => now, logger: NOOP_LOGGER }); + await expect(refresher.ensureFresh()).rejects.toBeInstanceOf(RefreshTokenRejectedError); + }); + + it('returns cached token when 5xx response AND 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('upstream timeout', { status: 503 })]); + const refresher = makeOAuthRefresher({ store, fetcher, clock: () => now, logger: NOOP_LOGGER }); + expect(await refresher.ensureFresh()).toBe('A1'); + }); + + it('throws RefreshNetworkError when 5xx response AND cached is expired', async () => { + const now = 1_000_000_000_000; + await seed({ accessToken: 'A1', refreshToken: 'R1', expiresAt: now - 1000 }); + const store = makeCredentialStore({ path }); + const fetcher = fakeFetcher([new Response('upstream timeout', { status: 503 })]); + 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 new file mode 100644 index 0000000..72223dd --- /dev/null +++ b/packages/core/src/auth/refresher.ts @@ -0,0 +1,314 @@ +import type { Logger } from '../logger/index.js'; +import type { CredentialBundle } from './credential-bundle.js'; +import type { CredentialStore } from './credential-store.js'; + +export const CLAUDE_CODE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; +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() { + super('No credentials.json found — run `mc auth bootstrap` to authenticate.'); + this.name = 'NotBootstrappedError'; + } +} + +export class RefreshTokenRejectedError extends Error { + constructor(detail: string) { + super(`Refresh token rejected: ${detail}. Re-run \`mc auth bootstrap\`.`); + this.name = 'RefreshTokenRejectedError'; + } +} + +/** + * 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}`); + this.name = 'RefreshNetworkError'; + } +} + +/** + * The set of refresher error classes that mean the user must re-authenticate. + * Single source of truth used by `isAuthRequiredError` and by tests that need + * to parametrize over every member. + */ +export const AUTH_REQUIRED_ERROR_CTORS = [ + NotBootstrappedError, + RefreshTokenRejectedError, + RefreshTokenRevokedError, +] as const; + +/** + * `true` when the error means the user must re-authenticate (no automatic + * recovery is possible). Used by the agent runner + chat-bridge to fail-fast + * with a friendly "run `claude login`" reply instead of stalling on retries. + */ +export function isAuthRequiredError(err: unknown): boolean { + return AUTH_REQUIRED_ERROR_CTORS.some((Ctor) => err instanceof Ctor); +} + +export type RefresherStatus = 'ready' | 'expiring' | 'error' | 'disabled'; + +export interface RefresherStatusSnapshot { + status: RefresherStatus; + detail: string; + expiresAt?: number; + lastRefreshedAt?: number; +} + +export interface OAuthRefresherOpts { + store: CredentialStore; + fetcher?: typeof fetch; + clock?: () => number; + envTarget?: NodeJS.ProcessEnv; + logger: Logger; + onStatusChange?: ( + status: RefresherStatus, + detail: string, + extra?: { expiresAt?: number; lastRefreshedAt?: number }, + ) => void; +} + +export interface OAuthRefresher { + ensureFresh(): Promise; + 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 { + const fetcher = opts.fetcher ?? fetch; + const clock = opts.clock ?? Date.now; + const envTarget = opts.envTarget ?? process.env; + + let cached: CredentialBundle | null = null; + let inFlight: Promise | null = null; + let lastStatus: RefresherStatus | null = null; + let lastRefreshedAt: number | undefined; + let lastDetail = ''; + + function emitStatus( + status: RefresherStatus, + detail: string, + extra?: { expiresAt?: number; lastRefreshedAt?: number }, + ): void { + // Always track latest detail so getStatus() reflects the current expiry, + // not the value from the first transition into 'ready'. The transition + // gate below only suppresses the onStatusChange callback fire — comparing + // detail there would defeat the gate (the 'ready' detail string includes + // the ISO expiry which changes on every 5-min refresh). + lastDetail = detail; + if (lastStatus === status) return; + lastStatus = status; + opts.onStatusChange?.(status, detail, extra); + } + + async function ensureFreshInner(): Promise { + if (!cached) { + cached = await opts.store.read(); + if (cached === null) { + emitStatus('disabled', 'Run `mc auth bootstrap` to authenticate'); + throw new NotBootstrappedError(); + } + } + + const nowMs = clock(); + const remainingMs = cached.expiresAt - nowMs; + if (remainingMs > REFRESH_WINDOW_MS) { + envTarget.CLAUDE_CODE_OAUTH_TOKEN = cached.accessToken; + 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 }), + }); + return cached.accessToken; + } + + let response: Response; + try { + response = await fetcher(OAUTH_TOKEN_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: cached.refreshToken, + client_id: CLAUDE_CODE_CLIENT_ID, + }), + signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS), + }); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + opts.logger.warn({ err: detail }, 'OAuth refresh network error'); + if (cached.expiresAt < clock()) { + emitStatus('error', `Refresh network error: ${detail}`); + throw new RefreshNetworkError(detail); + } + // cached still valid — keep going + envTarget.CLAUDE_CODE_OAUTH_TOKEN = cached.accessToken; + return cached.accessToken; + } + + if (response.status === 401) { + const body = await response.text().catch(() => ''); + emitStatus('error', 'Refresh failed: re-run `mc auth bootstrap`'); + throw new RefreshTokenRejectedError(body || 'HTTP 401'); + } + + if (!response.ok) { + 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); + } + return cached.accessToken; + } + + let parsed: { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown }; + try { + parsed = (await response.json()) as typeof parsed; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new RefreshNetworkError(`OAuth response body not valid JSON: ${detail}`); + } + if ( + typeof parsed.access_token !== 'string' || + typeof parsed.refresh_token !== 'string' || + typeof parsed.expires_in !== 'number' || + !(parsed.expires_in >= 1) + ) { + throw new RefreshTokenRejectedError( + 'OAuth response missing access_token/refresh_token/expires_in or expires_in invalid', + ); + } + + const newBundle: CredentialBundle = { + accessToken: parsed.access_token, + refreshToken: parsed.refresh_token, + expiresAt: clock() + parsed.expires_in * 1000, + }; + await opts.store.write(newBundle); + cached = newBundle; + lastRefreshedAt = clock(); + envTarget.CLAUDE_CODE_OAUTH_TOKEN = newBundle.accessToken; + emitStatus('ready', formatReadyDetail(newBundle.expiresAt), { + expiresAt: newBundle.expiresAt, + lastRefreshedAt, + }); + return newBundle.accessToken; + } + + // Bind ensureFresh to the closure-level inFlight + ensureFreshInner so it + // can be called both from the returned object's method AND from setInterval + // (where `this` would be undefined in factory-function context). + const ensureFresh = async (): Promise => { + if (inFlight) return inFlight; + inFlight = ensureFreshInner().finally(() => { + inFlight = null; + }); + return inFlight; + }; + + return { + ensureFresh, + + currentAccessToken(): string | null { + return cached?.accessToken ?? null; + }, + + getStatus(): RefresherStatusSnapshot { + if (lastStatus === null) return { status: 'disabled', detail: 'Not yet checked' }; + const snap: RefresherStatusSnapshot = { status: lastStatus, detail: lastDetail }; + if (cached && lastStatus === 'ready') { + snap.expiresAt = cached.expiresAt; + if (lastRefreshedAt !== undefined) snap.lastRefreshedAt = lastRefreshedAt; + } + return snap; + }, + + start(): () => void { + const interval = setInterval(() => { + // Use the closure-captured ensureFresh (NOT this.ensureFresh, which + // would be undefined in factory-function context). + ensureFresh().catch((err) => { + opts.logger.warn( + { err: err instanceof Error ? err.message : String(err) }, + 'periodic refresh failed', + ); + }); + }, 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/core/src/index.ts b/packages/core/src/index.ts index c4e6984..54c0d22 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ +export * from './auth/index.js'; export { sanitizeUpstreamError } from './config/error-sanitize.js'; export type { LogLevel, ResolvedConfig } from './config/index.js'; export { DEFAULTS, deepFreeze, resolveConfig } from './config/index.js'; diff --git a/packages/core/src/messaging/chat-bridge.ts b/packages/core/src/messaging/chat-bridge.ts index 7c2efb6..877a480 100644 --- a/packages/core/src/messaging/chat-bridge.ts +++ b/packages/core/src/messaging/chat-bridge.ts @@ -51,13 +51,13 @@ export type ChatEvent = export const CHAT_ERROR_REPLY = 'Something went wrong on my end. Run `/new` to reset and try again.'; export const IN_FLIGHT_REPLY = 'Working on a previous turn — give me a moment, or run `/new` to reset.'; /** - * User-visible reply when the agent run aborts because the Claude Code SDK - * surfaced a 401. The runner emits `failed` with `subtype: 'auth_required'`; - * we throw `ChatAuthRequiredError` so the catch branch can pick this reply - * instead of the generic one. + * User-visible reply when the agent run aborts because the Claude Code OAuth + * refresh token is rejected/revoked. The runner emits `failed` with + * `subtype: 'auth_required'`; we throw `ChatAuthRequiredError` so the catch + * branch can pick this reply instead of the generic one. */ export const CHAT_AUTH_REQUIRED_REPLY = - 'Claude Code authentication failed. Run `claude login` to restore access.'; + 'Claude Code authentication expired. Run `claude login`, then `mc auth bootstrap` to restore access.'; class ChatAuthRequiredError extends Error { constructor(detail: string) { diff --git a/packages/daemon/src/anthropic-stream.smoke.test.ts b/packages/daemon/src/anthropic-stream.smoke.test.ts index 015045d..f640a9f 100644 --- a/packages/daemon/src/anthropic-stream.smoke.test.ts +++ b/packages/daemon/src/anthropic-stream.smoke.test.ts @@ -7,14 +7,19 @@ * drift (which would silently break the offline replay fixtures used by the * AgentRunner unit tests). * - * Slice K3: Mission Control no longer manages OAuth — the SDK's bundled - * `claude` binary handles auth from `claude login` state (keychain → - * `~/.claude/.credentials.json` → its own refresh loop). The probe now - * runs whenever `MC_SMOKE=1` is set; it succeeds if either: - * - The user has run `claude login` and the bundled binary can resolve auth, OR - * - `ANTHROPIC_API_KEY` is exported (the SDK falls back to it). + * The SDK accepts two auth modes from the environment, in priority order: + * 1. `CLAUDE_CODE_OAUTH_TOKEN` — long-lived OAuth token from `claude + * setup-token`. This is Mission Control's intended production path: + * the agent runner uses Claude Code's identity, not a Console key. + * 2. `ANTHROPIC_API_KEY` — bring-your-own Console API key, useful for + * developers without a Claude Code subscription. * - * Excluded from normal CI. Runs with: `MC_SMOKE=1 pnpm --filter @mc/daemon test`. + * The probe gates on `MC_SMOKE=1` plus *either* auth var being set. Without + * `MC_SMOKE` the file is excluded from the run entirely. + * + * Excluded from normal CI. Runs only when MC_SMOKE=1 and one of: + * MC_SMOKE=1 CLAUDE_CODE_OAUTH_TOKEN=... pnpm --filter @mc/daemon test + * MC_SMOKE=1 ANTHROPIC_API_KEY=sk-ant-... pnpm --filter @mc/daemon test * * Lives under daemon/src to reuse the *.smoke.test.ts gating in * `packages/daemon/vitest.config.ts`. Uses `tmpdir()` as `cwd` so the SDK has @@ -28,7 +33,10 @@ import { join } from 'node:path'; import { query } from '@anthropic-ai/claude-agent-sdk'; import { describe, expect, it } from 'vitest'; -const SMOKE = process.env.MC_SMOKE === '1'; +const HAS_AUTH = + typeof process.env.CLAUDE_CODE_OAUTH_TOKEN === 'string' || + typeof process.env.ANTHROPIC_API_KEY === 'string'; +const SMOKE = process.env.MC_SMOKE === '1' && HAS_AUTH; const itSmoke = SMOKE ? it : it.skip; describe('Claude Agent SDK streaming smoke', () => { diff --git a/packages/daemon/src/bin/auth-alert.test.ts b/packages/daemon/src/bin/auth-alert.test.ts new file mode 100644 index 0000000..b3972c1 --- /dev/null +++ b/packages/daemon/src/bin/auth-alert.test.ts @@ -0,0 +1,93 @@ +import type { Logger } from '@mc/core/logger'; +import type { DiscordClient } from '@mc/discord'; +import { describe, expect, it, vi } from 'vitest'; +import { makeAuthAlertSender } from './auth-alert.js'; + +const SILENT: Logger = { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + fatal: () => {}, + child: () => SILENT, +} as unknown as Logger; + +function fakeDiscord(send: DiscordClient['sendDirectMessage']): DiscordClient { + return { + start: async () => {}, + stop: async () => {}, + onDirectMessage: () => () => {}, + onCommand: () => () => {}, + sendInChannel: async () => ({ channelId: 'c', messageId: 'm' }), + sendDirectMessage: send, + editMessage: async () => {}, + react: async () => {}, + }; +} + +describe('makeAuthAlertSender', () => { + it('sends a DM to the owner and resolves true when both client and ownerId are present', async () => { + const sendSpy = vi.fn().mockResolvedValue({ channelId: 'dm-1', messageId: 'm-1' }); + const client = fakeDiscord(sendSpy); + const send = makeAuthAlertSender({ + getDiscordClient: () => client, + getOwnerId: () => 'owner-1', + logger: SILENT, + }); + await expect(send('Auth expiring soon')).resolves.toBe(true); + expect(sendSpy).toHaveBeenCalledWith('owner-1', 'Auth expiring soon'); + }); + + it('resolves false (no-op) when discord client is undefined', async () => { + const send = makeAuthAlertSender({ + getDiscordClient: () => undefined, + getOwnerId: () => 'owner-1', + logger: SILENT, + }); + await expect(send('whatever')).resolves.toBe(false); + }); + + it('resolves false (no-op) when ownerId is undefined', async () => { + const sendSpy = vi.fn(); + const client = fakeDiscord(sendSpy); + const send = makeAuthAlertSender({ + getDiscordClient: () => client, + getOwnerId: () => undefined, + logger: SILENT, + }); + await expect(send('whatever')).resolves.toBe(false); + expect(sendSpy).not.toHaveBeenCalled(); + }); + + it('swallows DM failure and resolves false (logs raw err for Pino serializer)', async () => { + const warnSpy = vi.fn(); + const noisyLogger = { ...SILENT, warn: warnSpy } as unknown as Logger; + const restErr = new Error('discord rest 500'); + const sendSpy = vi.fn().mockRejectedValue(restErr); + const client = fakeDiscord(sendSpy); + const send = makeAuthAlertSender({ + getDiscordClient: () => client, + getOwnerId: () => 'owner-1', + logger: noisyLogger, + }); + await expect(send('boom')).resolves.toBe(false); + expect(warnSpy).toHaveBeenCalledWith({ err: restErr }, expect.stringMatching(/auth alert DM/i)); + }); + + it('reads getDiscordClient lazily (captures by reference, handles late-bound client)', async () => { + let client: DiscordClient | undefined; + const sendSpy = vi.fn().mockResolvedValue({ channelId: 'dm-1', messageId: 'm-1' }); + const send = makeAuthAlertSender({ + getDiscordClient: () => client, + getOwnerId: () => 'owner-1', + logger: SILENT, + }); + // First call: no client yet → no-op, resolves false. + await expect(send('first')).resolves.toBe(false); + expect(sendSpy).not.toHaveBeenCalled(); + // Now late-bind the client (mimics the boot ordering in mcd-main). + client = fakeDiscord(sendSpy); + await expect(send('second')).resolves.toBe(true); + expect(sendSpy).toHaveBeenCalledWith('owner-1', 'second'); + }); +}); diff --git a/packages/daemon/src/bin/auth-alert.ts b/packages/daemon/src/bin/auth-alert.ts new file mode 100644 index 0000000..55d3327 --- /dev/null +++ b/packages/daemon/src/bin/auth-alert.ts @@ -0,0 +1,40 @@ +import type { Logger } from '@mc/core/logger'; +import type { DiscordClient } from '@mc/discord'; + +export interface MakeAuthAlertSenderOpts { + /** + * Read the current Discord client by reference. The OAuth refresher's + * `onStatusChange` closure is wired before the Discord client is + * constructed; reading lazily lets the boot-time alert fall through as a + * no-op while later transitions DM successfully. + */ + getDiscordClient: () => DiscordClient | undefined; + /** Read the owner user id by reference; undefined when discord is disabled. */ + getOwnerId: () => string | undefined; + logger: Logger; +} + +/** + * Returns an async sender that DMs the Discord owner about auth status + * transitions (`'expiring'`, `'error'`). Resolves to `true` iff a DM was + * actually sent — callers use this to track the last successfully-alerted + * status so the boot-race recovery in `mcd-main` doesn't double-DM and a + * shard reconnect after a stable status doesn't re-spam. Resolves to `false` + * (and no throw) when discord isn't yet wired, `ownerId` is missing, or the + * Discord REST call fails — a flaky DM must not break the OAuth refresher's + * hot path. + */ +export function makeAuthAlertSender(opts: MakeAuthAlertSenderOpts): (content: string) => Promise { + return async (content: string) => { + const client = opts.getDiscordClient(); + const ownerId = opts.getOwnerId(); + if (!client || !ownerId) return false; + try { + await client.sendDirectMessage(ownerId, content); + return true; + } catch (err) { + opts.logger.warn({ err }, 'failed to send auth alert DM to owner'); + return false; + } + }; +} diff --git a/packages/daemon/src/bin/mcd-main.ts b/packages/daemon/src/bin/mcd-main.ts index c84be88..6b7c6e3 100644 --- a/packages/daemon/src/bin/mcd-main.ts +++ b/packages/daemon/src/bin/mcd-main.ts @@ -6,10 +6,16 @@ import { chmodSync, existsSync, statSync } from 'node:fs'; import { + CorruptedCredentialsError, + makeCredentialStore, + makeOAuthRefresher, makeStatusRegistry, + NotBootstrappedError, probeAiGatewayDimension, probeAndRegister, probeAnthropic1Token, + RefreshTokenRejectedError, + RefreshTokenRevokedError, resolveConfig, type StatusRegistry, sanitizeUpstreamError, @@ -33,6 +39,7 @@ import { type ComposedMcd, type ComposeMcdOpts, composeMcd } from '../compose.js import { makeDaemonPaths } from '../paths.js'; import { type MakeConfigWatcherServiceOpts, makeConfigWatcherService } from '../services.js'; import { type DaemonService, type StartDaemonOpts, startDaemon } from '../start-daemon.js'; +import { makeAuthAlertSender } from './auth-alert.js'; /** * Hooks for `mcd` lifecycle constructors. The Discord factory takes a @@ -169,6 +176,18 @@ class MalformedPortError extends Error { */ const EX_TEMPFAIL = 75; +/** + * Two-line message format for auth-status DMs. Refresh-token-revoked / + * expiring-soon are the only two states the operator must act on; the + * recovery command is embedded in the refresher's detail string already + * (e.g. "run `claude login` then `mc auth bootstrap`"), so the prefix + * just signals urgency. + */ +const AUTH_ALERT_PREFIX: Record<'expiring' | 'error', string> = { + expiring: '⚠️ Claude Code auth expiring soon', + error: '❌ Claude Code auth failure', +}; + /** * Copy DB-resident secrets into `target` so the Claude Agent SDK and the * embedding gateway (both of which read from `process.env` directly) see them @@ -295,16 +314,99 @@ export async function runMcdMain(_argv: readonly string[] = [], opts: RunMcdOpts logger.info({ propagated }, 'secrets propagated from DB'); } - // The Claude Agent SDK's bundled `claude` binary owns OAuth (keychain reads, - // refresh-token rotation, the OAuth POST). The dashboard polls - // /api/integrations/claudeCode/status every 2s, so seed once at boot; - // runtime auth failures flow through `AuthRequiredError` → - // `CHAT_AUTH_REQUIRED_REPLY` instead of through the registry. - statusRegistry.setReady( - 'claudeCode', - 'Auth managed by Claude Code CLI. Run `claude login` if agent runs return 401.', - ); + // Construct OAuthRefresher. The store may not exist yet (file-not-found is + // a status, not an error). The `onStatusChange` callback writes through to + // statusRegistry so the dashboard can surface the refresher's state, and + // DMs the Discord owner on `'expiring'` / `'error'` transitions so the + // alert reaches them before the chat lane stalls. let discordClient: DiscordClient | undefined; + const ownerId = config.discord.enabled === true ? config.discord.ownerId : undefined; + const sendAuthAlert = makeAuthAlertSender({ + getDiscordClient: () => discordClient, + getOwnerId: () => ownerId, + logger: logger.child({ module: 'auth-alert' }), + }); + // Track the last status we successfully DM'd about. Suppresses duplicate + // alerts in two cases: (a) a shard reconnect re-fires `onReady` while the + // status hasn't changed; (b) the boot probe resolves AFTER the Discord + // client is constructed but BEFORE `onReady`, so both `onStatusChange` + // and the `onReady` recovery would otherwise DM the same status. Reset + // on a `'ready'` transition so a future re-degradation re-arms the alert. + let lastAlertedStatus: 'expiring' | 'error' | null = null; + const dmAuthAlert = (status: 'expiring' | 'error', detail: string): void => { + void sendAuthAlert(`${AUTH_ALERT_PREFIX[status]}\n${detail}`).then((sent) => { + if (sent) lastAlertedStatus = status; + }); + }; + const credentialStore = makeCredentialStore({ path: paths.credentialsJsonPath }); + const oauthRefresher = makeOAuthRefresher({ + store: credentialStore, + envTarget, + logger: logger.child({ module: 'auth-refresher' }), + onStatusChange: (status, detail, extra) => { + switch (status) { + case 'ready': + statusRegistry.set('claudeCode', { status: 'ready', detail, ...(extra ?? {}) }); + lastAlertedStatus = null; + 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); + dmAuthAlert('expiring', detail); + break; + case 'error': + statusRegistry.setError('claudeCode', detail); + dmAuthAlert('error', detail); + break; + case 'disabled': + statusRegistry.setDisabled('claudeCode'); + break; + default: { + const _exhaustive: never = status; + void _exhaustive; + } + } + }, + }); + + // Seed initial 'disabled' status BEFORE the await ensureFresh below — so + // GET /api/integrations/claudeCode/status returns a valid shape during the + // brief boot window before the initial refresh resolves. The dashboard + // polls this route every 2s; without seeding, it gets undefined → empty body. + statusRegistry.setDisabled('claudeCode'); + + // Boot probe runs in the background — don't block dashboard bind on a slow + // network (refresh has a 15s timeout). If the probe fails, the SDK's 401 + // retry wrapper recovers on the first agent run, and the 5-min refresh tick + // (owned by compose's auth-refresher service) keeps trying. + void oauthRefresher + .ensureFresh() + .then(() => { + logger.info({ source: 'auth-refresher' }, 'CLAUDE_CODE_OAUTH_TOKEN set from refresher (boot)'); + }) + .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) { + logger.error({ err: err.message }, 'credentials.json is corrupted — re-run `mc auth bootstrap`'); + } else { + logger.warn( + { err: err instanceof Error ? err.message : String(err) }, + 'OAuth refresh failed (network); will retry on next tick', + ); + } + }); if (config.discord.enabled === true) { const factory = opts.makeDiscordClient ?? defaultMakeDiscordClient; @@ -314,6 +416,19 @@ export async function runMcdMain(_argv: readonly string[] = [], opts: RunMcdOpts logger, onReady: (detail) => { statusRegistry.setReady('discord', detail); + // Boot-race recovery: the OAuth refresher may have already emitted + // 'expiring' or 'error' before this client was wired (the boot probe + // runs ~immediately after refresher construction). The transition + // gate would otherwise swallow the alert. Sample the current status + // here and re-fire the DM so the operator hears about it once the + // gateway reaches READY. `lastAlertedStatus` suppresses re-firing on + // shard reconnects (onReady fires again) when the status hasn't + // changed, AND on the narrow window where `onStatusChange` already + // succeeded just before this callback fires. + const snap = oauthRefresher.getStatus(); + if ((snap.status === 'expiring' || snap.status === 'error') && snap.status !== lastAlertedStatus) { + dmAuthAlert(snap.status, snap.detail); + } }, onError: (detail) => statusRegistry.setError('discord', detail), onIntentDenied: () => { @@ -362,6 +477,7 @@ export async function runMcdMain(_argv: readonly string[] = [], opts: RunMcdOpts store, statusRegistry, dashboardAuthToken, + oauthRefresher, testConnection, ...(dashboardPort !== undefined && { dashboardPort }), ...(discordClient !== undefined && { discordClient }), @@ -394,6 +510,7 @@ export async function runMcdMain(_argv: readonly string[] = [], opts: RunMcdOpts const watcherService = makeWatcher({ credentials: composed.store.integrationCredentials, settings: composed.store.settings, + credentialsJsonPath: paths.credentialsJsonPath, logger: logger.child({ module: 'config-watcher' }), onChange: triggerRestart, }); diff --git a/packages/daemon/src/claude-code-refresh.smoke.test.ts b/packages/daemon/src/claude-code-refresh.smoke.test.ts new file mode 100644 index 0000000..6140884 --- /dev/null +++ b/packages/daemon/src/claude-code-refresh.smoke.test.ts @@ -0,0 +1,54 @@ +/** + * Real-network smoke for OAuthRefresher. Reads the developer's actual + * ~/.mc/credentials.json, performs one refresh against + * https://claude.ai/v1/oauth/token, verifies tokens rotate successfully, + * verifies the file got rewritten with new values. + * + * Restores the original file at the end so re-running this smoke + * doesn't burn through refresh tokens unnecessarily. + * + * Excluded from normal CI. Runs only when MC_SMOKE=1 AND + * ~/.mc/credentials.json exists. + * + * MC_SMOKE=1 pnpm test packages/daemon/src/claude-code-refresh.smoke.test.ts + */ + +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { makeCredentialStore, makeOAuthRefresher } from '@mc/core'; +import { describe, expect, it } from 'vitest'; + +const PATH = join(process.env.MC_DATA_DIR ?? join(homedir(), '.mc'), 'credentials.json'); +const SMOKE = process.env.MC_SMOKE === '1' && existsSync(PATH); +const itSmoke = SMOKE ? it : it.skip; + +const NOOP_LOGGER = { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + fatal: () => {}, + child: () => NOOP_LOGGER, +} as never; + +describe('OAuthRefresher real-network smoke', () => { + itSmoke( + 'refreshes successfully against live Anthropic OAuth endpoint', + async () => { + const original = readFileSync(PATH, 'utf8'); + try { + const store = makeCredentialStore({ path: PATH }); + const refresher = makeOAuthRefresher({ store, logger: NOOP_LOGGER }); + const newToken = await refresher.ensureFresh(); + expect(newToken).toMatch(/^sk-ant-oat01-/); + const written = JSON.parse(readFileSync(PATH, 'utf8')); + expect(written.expiresAt).toBeGreaterThan(Date.now()); + expect(written.accessToken).toBe(newToken); + } finally { + writeFileSync(PATH, original); + } + }, + 30_000, + ); +}); diff --git a/packages/daemon/src/compose.test.ts b/packages/daemon/src/compose.test.ts index b785e86..b6bd0ab 100644 --- a/packages/daemon/src/compose.test.ts +++ b/packages/daemon/src/compose.test.ts @@ -3,6 +3,7 @@ import { mkdirSync } from 'node:fs'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import type { OAuthRefresher } from '@mc/core'; import { makeStatusRegistry } from '@mc/core'; import { openDataStore } from '@mc/core/datastore'; import { defaultTestConfig, FakeAgentRunner, makeCapturingLogger, silentLogger } from '@mc/core/testing'; @@ -12,6 +13,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type ComposedMcd, composeMcd } from './compose.js'; import { makeDaemonPaths } from './paths.js'; +const stubRefresher: OAuthRefresher = { + ensureFresh: async () => 'stub-token', + currentAccessToken: () => 'stub-token', + getStatus: () => ({ status: 'ready' as const, detail: 'stub' }), + start: () => () => {}, +}; + function openStoreForTest(dir: string) { return openDataStore({ path: join(dir, 'state.db'), loadVecExtension: true }); } @@ -43,6 +51,7 @@ describe('composeMcd', () => { dashboardAuthToken: 'a'.repeat(64), store: baseStore, statusRegistry: makeStatusRegistry(), + oauthRefresher: stubRefresher, agentRunner: new FakeAgentRunner(), fileWatcher: new FakeFileWatcher(), ...overrides, @@ -51,12 +60,13 @@ describe('composeMcd', () => { return result; }; - it('returns services in startup order: data-store → memory → reconcile → vacuum → sleep-worker → dashboard', () => { + it('returns services in startup order: data-store → memory → reconcile → auth-refresher → vacuum → sleep-worker → dashboard', () => { const composed = compose(); expect(composed.services.map((s) => s.name)).toEqual([ 'data-store', 'memory', 'reconcile', + 'auth-refresher', 'vacuum', 'sleep-worker', 'dashboard', @@ -99,6 +109,7 @@ describe('composeMcd', () => { dashboardAuthToken: 'a'.repeat(64), store: openStoreForTest(dir), statusRegistry: makeStatusRegistry(), + oauthRefresher: stubRefresher, agentRunner: new FakeAgentRunner(), fileWatcher: new FakeFileWatcher(), }); @@ -121,6 +132,7 @@ describe('composeMcd', () => { dashboardAuthToken: 'a'.repeat(64), store: openStoreForTest(dir), statusRegistry: makeStatusRegistry(), + oauthRefresher: stubRefresher, agentRunner: new FakeAgentRunner(), fileWatcher: watcher, }); @@ -221,6 +233,7 @@ describe('composeMcd Discord wiring', () => { dashboardAuthToken: 'a'.repeat(64), store: openStoreForTest(dir), statusRegistry: makeStatusRegistry(), + oauthRefresher: stubRefresher, agentRunner: new FakeAgentRunner(), fileWatcher: new FakeFileWatcher(), }); @@ -238,6 +251,7 @@ describe('composeMcd Discord wiring', () => { dashboardAuthToken: 'a'.repeat(64), store: openStoreForTest(dir), statusRegistry: makeStatusRegistry(), + oauthRefresher: stubRefresher, agentRunner: new FakeAgentRunner(), fileWatcher: new FakeFileWatcher(), }); @@ -255,6 +269,7 @@ describe('composeMcd Discord wiring', () => { dashboardAuthToken: 'a'.repeat(64), store: openStoreForTest(dir), statusRegistry: makeStatusRegistry(), + oauthRefresher: stubRefresher, agentRunner: new FakeAgentRunner(), fileWatcher: new FakeFileWatcher(), discordClient: fakeDiscordClient(), @@ -264,6 +279,7 @@ describe('composeMcd Discord wiring', () => { 'data-store', 'memory', 'reconcile', + 'auth-refresher', 'vacuum', 'sleep-worker', 'dashboard', diff --git a/packages/daemon/src/compose.ts b/packages/daemon/src/compose.ts index 3ccb7c8..efffc17 100644 --- a/packages/daemon/src/compose.ts +++ b/packages/daemon/src/compose.ts @@ -7,7 +7,7 @@ import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; -import type { StatusRegistry } from '@mc/core'; +import type { OAuthRefresher, StatusRegistry } from '@mc/core'; import { type AgentRunner, BRAINSTORM_SYSTEM_PROMPT, @@ -84,6 +84,13 @@ export interface ComposeMcdOpts { embedder?: EmbeddingService; /** When provided AND config.discord.enabled, the Discord service is built. */ discordClient?: DiscordClient; + /** + * Refresher that owns Claude Code OAuth lifecycle. mcd-main constructs it + * (needs paths.credentialsJsonPath + envTarget + logger) and passes it in. + * Compose owns its lifecycle: a DaemonService whose start() calls + * refresher.start() and stop() calls the returned cleanup fn. + */ + oauthRefresher: OAuthRefresher; /** * Probe factories for `POST /api/test-connection/:kind`. Production wires * real `discord.js` and `OpenAIGatewayEmbedder` constructors here; tests @@ -148,7 +155,8 @@ export function composeMcd(opts: ComposeMcdOpts): ComposedMcd { const bus = makeEventBus(); const git = makeSimpleGitOps(); const queue = makeMessageQueue(); - const runner = opts.agentRunner ?? makeAgentRunnerFromSource(makeSdkSourceFactory({})); + const runner = + opts.agentRunner ?? makeAgentRunnerFromSource(makeSdkSourceFactory({ refresher: opts.oauthRefresher })); const memoryProvider = new MarkdownGitProvider({ store, @@ -197,6 +205,18 @@ export function composeMcd(opts: ComposeMcdOpts): ComposedMcd { const reconcileService = makeReconcileService({ orchestrator }); + let stopRefresher: (() => void) | undefined; + const authRefresherService: DaemonService = { + name: 'auth-refresher', + async start() { + stopRefresher = opts.oauthRefresher.start(); + }, + async stop() { + stopRefresher?.(); + stopRefresher = undefined; + }, + }; + const sleepWorkerService = makeSleepWorker({ store, handlers: { @@ -214,6 +234,7 @@ export function composeMcd(opts: ComposeMcdOpts): ComposedMcd { storeService, memoryService, reconcileService, + authRefresherService, vacuumService, sleepWorkerService, dashboardService, diff --git a/packages/daemon/src/paths.ts b/packages/daemon/src/paths.ts index 68ee763..2a8e6eb 100644 --- a/packages/daemon/src/paths.ts +++ b/packages/daemon/src/paths.ts @@ -6,6 +6,8 @@ export interface DaemonPaths { readonly dataDir: string; /** ~/.mc/state.db — SQLite operational store. */ readonly dbPath: string; + /** ~/.mc/credentials.json — Claude Code OAuth credential bundle (atomic-written by `mc auth bootstrap`). */ + readonly credentialsJsonPath: string; /** ~/.mc/daemon.lock — single-instance lockfile. */ readonly lockPath: string; /** ~/.mc/logs — directory for daily-rotated daemon log files. */ @@ -28,6 +30,7 @@ export function makeDaemonPaths(opts: { dataDir?: string } = {}): DaemonPaths { const logsDir = join(dataDir, 'logs'); return { dataDir, + credentialsJsonPath: join(dataDir, 'credentials.json'), dbPath: join(dataDir, 'state.db'), lockPath: join(dataDir, 'daemon.lock'), logsDir, diff --git a/packages/daemon/src/services.test.ts b/packages/daemon/src/services.test.ts index 7697589..03ee925 100644 --- a/packages/daemon/src/services.test.ts +++ b/packages/daemon/src/services.test.ts @@ -1,3 +1,6 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { silentLogger } from '@mc/core/testing'; import type { MemoryProviderOpenOpts } from '@mc/memory'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -336,6 +339,7 @@ describe('makeConfigWatcherService', () => { return makeConfigWatcherService({ credentials: opts.credentials, settings: opts.settings, + credentialsJsonPath: join(tmpdir(), 'mc-test-nonexistent-credentials.json'), logger: silentLogger, onChange: opts.onChange, intervalMs: 100, @@ -432,4 +436,31 @@ describe('makeConfigWatcherService', () => { await vi.advanceTimersByTimeAsync(500); expect(onChange).not.toHaveBeenCalled(); }); + + it('triggers onChange when credentials.json mtime changes', async () => { + const dir = await mkdtemp(join(tmpdir(), 'mc-cred-watch-')); + const credentialsJsonPath = join(dir, 'credentials.json'); + await writeFile(credentialsJsonPath, 'initial'); + const onChange = vi.fn(); + const svc = makeConfigWatcherService({ + credentials: { summary: () => ({ count: 0, latestUpdatedAt: 0 }) }, + settings: { summary: () => ({ count: 0, latestUpdatedAt: 0 }) }, + credentialsJsonPath, + intervalMs: 50, + onChange, + logger: silentLogger, + }); + await svc.start(); + // Tick once with no change + await new Promise((r) => setTimeout(r, 100)); + expect(onChange).not.toHaveBeenCalled(); + // Force a measurable mtime change (overlay2 CI filesystems may round to 1s) + await new Promise((r) => setTimeout(r, 100)); + await writeFile(credentialsJsonPath, 'changed'); + // Wait for next tick + await new Promise((r) => setTimeout(r, 200)); + expect(onChange).toHaveBeenCalled(); + await svc.stop(); + await rm(dir, { recursive: true }); + }); }); diff --git a/packages/daemon/src/services.ts b/packages/daemon/src/services.ts index c165640..0f7cbb0 100644 --- a/packages/daemon/src/services.ts +++ b/packages/daemon/src/services.ts @@ -7,6 +7,7 @@ * and `hono`. The `mcd` bin entry point is the only place that wires real components. */ +import { statSync } from 'node:fs'; import type { DataStore, IntegrationCredentialsRepo, SettingsRepo } from '@mc/core/datastore'; import type { EventBus } from '@mc/core/events'; import type { Logger } from '@mc/core/logger'; @@ -253,6 +254,8 @@ export function makeDiscordService(opts: MakeDiscordServiceOpts): DaemonService export interface MakeConfigWatcherServiceOpts { credentials: Pick; settings: Pick; + /** ~/.mc/credentials.json — watched for mtime changes (re-running `mc auth bootstrap` triggers respawn). */ + credentialsJsonPath: string; logger: Logger; /** One-shot. Called the first time either source's summary changes after start(). */ onChange: () => void; @@ -267,16 +270,24 @@ interface ConfigSummary { credLatest: number; settingsCount: number; settingsLatest: number; + credJsonMtime: number; } function snapshot(opts: MakeConfigWatcherServiceOpts): ConfigSummary { const cred = opts.credentials.summary(); const set = opts.settings.summary(); + let credJsonMtime = 0; + try { + credJsonMtime = statSync(opts.credentialsJsonPath).mtimeMs; + } catch { + // ENOENT — file not yet bootstrapped, keep mtime 0 + } return { credCount: cred.count, credLatest: cred.latestUpdatedAt, settingsCount: set.count, settingsLatest: set.latestUpdatedAt, + credJsonMtime, }; } @@ -285,7 +296,8 @@ function differs(a: ConfigSummary, b: ConfigSummary): boolean { a.credCount !== b.credCount || a.credLatest !== b.credLatest || a.settingsCount !== b.settingsCount || - a.settingsLatest !== b.settingsLatest + a.settingsLatest !== b.settingsLatest || + a.credJsonMtime !== b.credJsonMtime ); } diff --git a/packages/dashboard/web/src/components/BootstrapInstructionStep.test.tsx b/packages/dashboard/web/src/components/BootstrapInstructionStep.test.tsx index a408bc7..7ac0716 100644 --- a/packages/dashboard/web/src/components/BootstrapInstructionStep.test.tsx +++ b/packages/dashboard/web/src/components/BootstrapInstructionStep.test.tsx @@ -1,11 +1,11 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { BootstrapInstructionStep } from './BootstrapInstructionStep'; const STEP_PROPS = { title: 'Authenticate with Claude Code', description: <>Mission Control runs through your subscription. Run:, - command: 'claude login', + command: 'mc auth bootstrap', docHelpHref: 'https://example.com/docs', step: 1, total: 4, @@ -13,26 +13,80 @@ const STEP_PROPS = { onBack: vi.fn(), }; +const fetchMock = vi.fn(); + +beforeEach(() => { + fetchMock.mockReset(); + global.fetch = fetchMock as never; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + describe('BootstrapInstructionStep', () => { it('renders the command in a copyable code block', () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ status: 'disabled' }), + }); render(); - expect(screen.getByText('claude login')).toBeInTheDocument(); + expect(screen.getByText('mc auth bootstrap')).toBeInTheDocument(); expect(screen.getByText(/Authenticate with Claude Code/)).toBeInTheDocument(); }); - it('Continue is enabled and forwards the click', () => { + it('disables Continue until status === ready', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ status: 'disabled' }), + }); + render(); + await waitFor(() => { + const btn = screen.getByRole('button', { name: /continue/i }); + expect(btn).toBeDisabled(); + }); + }); + + it('enables Continue and shows expiry when status === ready', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + status: 'ready', + detail: 'Connected', + expiresAt: Date.now() + 3_600_000, + }), + }); + render(); + await waitFor(() => { + const btn = screen.getByRole('button', { name: /continue/i }); + expect(btn).not.toBeDisabled(); + }); + expect(screen.getByText(/Authenticated/i)).toBeInTheDocument(); + }); + + it('shows error detail when status === error', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ status: 'error', detail: 'Refresh failed: re-bootstrap' }), + }); + render(); + await waitFor(() => { + expect(screen.getByText(/Refresh failed/)).toBeInTheDocument(); + }); + }); + + it('calls onContinue when Continue clicked while ready', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ status: 'ready', detail: 'OK', expiresAt: Date.now() + 60_000 }), + }); const onContinue = vi.fn(); render(); - const btn = screen.getByRole('button', { name: /continue/i }); - expect(btn).not.toBeDisabled(); - fireEvent.click(btn); + await waitFor(() => { + const btn = screen.getByRole('button', { name: /continue/i }); + expect(btn).not.toBeDisabled(); + }); + fireEvent.click(screen.getByRole('button', { name: /continue/i })); expect(onContinue).toHaveBeenCalledTimes(1); }); - - it('Back forwards the click', () => { - const onBack = vi.fn(); - render(); - fireEvent.click(screen.getByRole('button', { name: /back/i })); - expect(onBack).toHaveBeenCalledTimes(1); - }); }); diff --git a/packages/dashboard/web/src/components/BootstrapInstructionStep.tsx b/packages/dashboard/web/src/components/BootstrapInstructionStep.tsx index ca15ff8..0a264dc 100644 --- a/packages/dashboard/web/src/components/BootstrapInstructionStep.tsx +++ b/packages/dashboard/web/src/components/BootstrapInstructionStep.tsx @@ -1,3 +1,6 @@ +import { useEffect, useState } from 'react'; +import type { IntegrationStatus } from '@/lib/api'; + interface StepCommonProps { step: number; total: number; @@ -12,14 +15,38 @@ export interface BootstrapInstructionStepProps extends StepCommonProps { docHelpHref: string; } -/** - * Onboarding step for any setup that boils down to "run this command in your - * terminal." The Claude Code auth case (Slice K3) no longer needs to gate - * Continue on a daemon-side probe — the SDK self-heals from `claude login` - * state and runtime failures surface through `CHAT_AUTH_REQUIRED_REPLY`. - * Continue is always enabled; the user opts in by clicking it. - */ +const POLL_INTERVAL_MS = 2_000; + export function BootstrapInstructionStep(props: BootstrapInstructionStepProps): React.ReactElement { + const [status, setStatus] = useState(null); + + useEffect(() => { + let cancelled = false; + async function poll(): Promise { + try { + const res = await fetch('/api/integrations/claudeCode/status'); + if (!res.ok) { + if (!cancelled) setStatus({ status: 'error', detail: `HTTP ${res.status}` }); + return; + } + const body = (await res.json()) as IntegrationStatus; + if (!cancelled) setStatus(body); + } catch (err) { + if (!cancelled) { + setStatus({ status: 'error', detail: err instanceof Error ? err.message : String(err) }); + } + } + } + void poll(); + const interval = setInterval(poll, POLL_INTERVAL_MS); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, []); + + const isReady = status?.status === 'ready'; + return (
@@ -39,11 +66,22 @@ export function BootstrapInstructionStep(props: BootstrapInstructionStepProps): Copy +
+ {status === null && 'Checking status...'} + {status?.status === 'disabled' && 'Waiting for credentials...'} + {status?.status === 'ready' && ( + <> + ✓ Authenticated + {status.expiresAt !== undefined && <> (expires {new Date(status.expiresAt).toISOString()})} + + )} + {status?.status === 'error' && <>✗ {status.detail ?? 'Refresh failed'}} +
-
diff --git a/packages/dashboard/web/src/components/OnboardingRedirect.tsx b/packages/dashboard/web/src/components/OnboardingRedirect.tsx index d76fb84..5943038 100644 --- a/packages/dashboard/web/src/components/OnboardingRedirect.tsx +++ b/packages/dashboard/web/src/components/OnboardingRedirect.tsx @@ -5,7 +5,7 @@ import { useClaudeCodeConfigured } from '@/data-context'; /** * Gates the home route on Claude Code OAuth readiness — the post-Slice-G-v2 * required credential. Reads from the polled `StatusContext`, so the gate - * unblocks automatically when `claude login` completes and the daemon + * unblocks automatically when `mc auth bootstrap` completes and the daemon * publishes `claudeCode.status === 'ready'`. Other paths render unchanged. */ export function OnboardingRedirect({ children }: { children: ReactNode }) { diff --git a/packages/dashboard/web/src/hooks/useSetupAlerts.ts b/packages/dashboard/web/src/hooks/useSetupAlerts.ts index 7252903..60a6942 100644 --- a/packages/dashboard/web/src/hooks/useSetupAlerts.ts +++ b/packages/dashboard/web/src/hooks/useSetupAlerts.ts @@ -4,13 +4,10 @@ import { useStatusContext } from './StatusContext'; /** * Aggregates the banner sources the App layout cares about: - * 1. `claudeCode` disabled/error → SDK couldn't auth; user must run `claude login` - * 2. `useDaemonOnline()` false → persistent daemon-unreachable banner - * 3. `system` integration in error → expose the detail (e.g. DB tighten) - * - * Slice K3: the daemon no longer manages OAuth — claudeCode status is set - * once at boot. The disabled/error branches stay defensively in case a - * downstream version reintroduces a probe. + * 1. `claudeCode` disabled → bootstrap not run, link to /onboarding + * 2. `claudeCode` error → refresh failed, link to /onboarding + * 3. `useDaemonOnline()` false → persistent daemon-unreachable banner + * 4. `system` integration in error → expose the detail (e.g. DB tighten) */ export function useSetupAlerts(): SetupBannerAlert[] { const { statuses, online } = useStatusContext(); @@ -21,7 +18,7 @@ export function useSetupAlerts(): SetupBannerAlert[] { alerts.push({ id: 'claudeCode-not-bootstrapped', tone: 'warning', - message: 'Claude Code not authenticated. Run `claude login` to enable agent runs.', + message: 'Claude Code not authenticated. Run `mc auth bootstrap` to enable agent runs.', cta: { label: 'Open onboarding', href: '/onboarding' }, }); } @@ -29,7 +26,7 @@ export function useSetupAlerts(): SetupBannerAlert[] { alerts.push({ id: 'claudeCode-refresh-failed', tone: 'error', - message: statuses.claudeCode.detail ?? 'Claude Code authentication failed', + message: statuses.claudeCode.detail ?? 'Claude Code auth refresh failed', cta: { label: 'Re-authenticate', href: '/onboarding' }, }); } diff --git a/packages/dashboard/web/src/lib/integration-fields.ts b/packages/dashboard/web/src/lib/integration-fields.ts index 1da62c8..df9f134 100644 --- a/packages/dashboard/web/src/lib/integration-fields.ts +++ b/packages/dashboard/web/src/lib/integration-fields.ts @@ -32,7 +32,7 @@ export const INTEGRATION_FIELDS: Record = { }, ], anthropic: [{ key: 'apiKey', label: 'API key', secret: true, placeholder: 'sk-ant-…' }], - claudeCode: [], // no user-pasted credential; managed by the Claude Code CLI (`claude login`) + claudeCode: [], // no user-pasted credential; managed via `mc auth bootstrap` aiGateway: [{ key: 'apiKey', label: 'API key', secret: true }], telegram: [], whatsapp: [], diff --git a/packages/dashboard/web/src/pages/OnboardingPage.test.tsx b/packages/dashboard/web/src/pages/OnboardingPage.test.tsx index 0357341..d7e9f64 100644 --- a/packages/dashboard/web/src/pages/OnboardingPage.test.tsx +++ b/packages/dashboard/web/src/pages/OnboardingPage.test.tsx @@ -75,26 +75,51 @@ describe('OnboardingPage', () => { fireEvent.click(screen.getByRole('button', { name: /get started/i })); } - it('Continue is always enabled (Slice K3 — no daemon-side gating)', async () => { + it('Continue is disabled while status is not ready', async () => { vi.stubGlobal( 'fetch', - makeFetchStub(() => ({ body: { status: 'ready' } })), + makeFetchStub(() => ({ body: { status: 'disabled' } })), ); renderPage(); advanceToClaudeCode(); await waitFor(() => { expect(screen.getByRole('heading', { name: /authenticate with claude code/i })).toBeInTheDocument(); }); - expect(screen.getByRole('button', { name: /continue/i })).not.toBeDisabled(); + expect(screen.getByRole('button', { name: /continue/i })).toBeDisabled(); + }); + + it('Continue becomes enabled when status poll returns ready', async () => { + vi.stubGlobal( + 'fetch', + makeFetchStub((path) => { + if (path === '/api/integrations/claudeCode/status') { + return { body: { status: 'ready', expiresAt: Date.now() + 3_600_000 } }; + } + return { body: {} }; + }), + ); + renderPage(); + advanceToClaudeCode(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /continue/i })).not.toBeDisabled(); + }); }); - it('clicking Continue advances to Discord step', async () => { + it('clicking Continue on a ready status advances to Discord step', async () => { vi.stubGlobal( 'fetch', - makeFetchStub(() => ({ body: { status: 'ready' } })), + makeFetchStub((path) => { + if (path === '/api/integrations/claudeCode/status') { + return { body: { status: 'ready', expiresAt: Date.now() + 3_600_000 } }; + } + return { body: {} }; + }), ); renderPage(); advanceToClaudeCode(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /continue/i })).not.toBeDisabled(); + }); fireEvent.click(screen.getByRole('button', { name: /continue/i })); expect(screen.getByRole('heading', { name: /discord/i })).toBeInTheDocument(); }); diff --git a/packages/dashboard/web/src/pages/OnboardingPage.tsx b/packages/dashboard/web/src/pages/OnboardingPage.tsx index 39f882b..c19d302 100644 --- a/packages/dashboard/web/src/pages/OnboardingPage.tsx +++ b/packages/dashboard/web/src/pages/OnboardingPage.tsx @@ -28,13 +28,9 @@ export default function OnboardingPage() { - Mission Control runs through your Claude Code subscription. Open a terminal and run - claude login if you haven't already — the bundled Claude Code CLI manages auth on - Mission Control's behalf. - + <>Mission Control runs through your Claude Code subscription. Open a terminal and run: } - command="claude login" + command="mc auth bootstrap" docHelpHref="/README.md#auth" step={stepNumber} total={TOTAL} @@ -80,7 +76,7 @@ function WelcomeStep({ step, total, onContinue }: StepCommonProps) { return ( { expect(screen.getByText(/Last refreshed/i)).toBeInTheDocument(); }); - it('shows disabled badge with login instructions', () => { + it('shows disabled badge with bootstrap instructions', () => { render(); expect(screen.getByText(/Disabled/)).toBeInTheDocument(); - expect(screen.getByText(/claude login/)).toBeInTheDocument(); + expect(screen.getByText(/mc auth bootstrap/)).toBeInTheDocument(); }); it('shows error badge with detail', () => { diff --git a/packages/dashboard/web/src/pages/settings/ClaudeCodeStatusPanel.tsx b/packages/dashboard/web/src/pages/settings/ClaudeCodeStatusPanel.tsx index 9cc380f..ccdc202 100644 --- a/packages/dashboard/web/src/pages/settings/ClaudeCodeStatusPanel.tsx +++ b/packages/dashboard/web/src/pages/settings/ClaudeCodeStatusPanel.tsx @@ -61,8 +61,7 @@ export function ClaudeCodeStatusPanel(props: ClaudeCodeStatusPanelProps): React.
Setup instructions

- Run claude login in your terminal to authenticate. Mission Control delegates auth to - the bundled Claude Code CLI; no further bootstrap step is needed. + Run mc auth bootstrap in your terminal after logging in via claude login.