diff --git a/CHANGELOG.md b/CHANGELOG.md index b5629de..19d6240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,43 @@ 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 8691f38..a7f5d4c 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 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/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/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,31 +530,19 @@ Notes: `KeepAlive: { SuccessfulExit: false }` restarts the proxy on crash but no ## Authentication -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 +Mission Control authenticates to Anthropic via your Claude Code subscription — but it doesn't manage the OAuth flow itself. Setup is one terminal command: ```bash -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 +claude login # opens a browser, completes the OAuth flow ``` -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.) +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. -**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. +**To recover:** run `claude login` again. No daemon restart needed — the bundled `claude` binary picks up the rotated credential on the next subprocess spawn. -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. +**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. --- diff --git a/packages/cli/src/commands/auth.test.ts b/packages/cli/src/commands/auth.test.ts deleted file mode 100644 index 35f49ca..0000000 --- a/packages/cli/src/commands/auth.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -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 deleted file mode 100644 index 1fb37a4..0000000 --- a/packages/cli/src/commands/auth.ts +++ /dev/null @@ -1,120 +0,0 @@ -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 0ee733a..57235df 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,5 +1,4 @@ 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'; @@ -14,7 +13,6 @@ 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 new file mode 100644 index 0000000..87da074 --- /dev/null +++ b/packages/core/src/agent/auth-required-error.ts @@ -0,0 +1,12 @@ +/** + * 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 0cf999d..d876ee7 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 { AUTH_REQUIRED_ERROR_CTORS } from '../auth/refresher.js'; +import { AuthRequiredError } from './auth-required-error.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.each(AUTH_REQUIRED_ERROR_CTORS)('tags %s as failed with subtype: auth_required', async (ErrorCtor) => { + it('tags AuthRequiredError as failed with subtype: auth_required', async () => { async function* failing(): AsyncIterable { yield initMsg; - throw new ErrorCtor('hi'); + throw new AuthRequiredError('SDK returned HTTP 401'); } 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 c51d609..dee424d 100644 --- a/packages/core/src/agent/runner.ts +++ b/packages/core/src/agent/runner.ts @@ -1,4 +1,4 @@ -import { isAuthRequiredError } from '../auth/refresher.js'; +import { AuthRequiredError } from './auth-required-error.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 → refresher 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 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, - ...(isAuthRequiredError(err) && { subtype: 'auth_required' }), + ...(err instanceof AuthRequiredError && { 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 995d16d..a840c46 100644 --- a/packages/core/src/agent/sdk-source-401.test.ts +++ b/packages/core/src/agent/sdk-source-401.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { OAuthRefresher } from '../auth/refresher.js'; -describe('makeSdkSourceFactory — 401 retry', () => { +describe('makeSdkSourceFactory — 401 handling', () => { let queryMock: ReturnType; beforeEach(() => { @@ -14,23 +13,6 @@ describe('makeSdkSourceFactory — 401 retry', () => { 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; } @@ -50,75 +32,13 @@ describe('makeSdkSourceFactory — 401 retry', () => { expect(queryMock).toHaveBeenCalledTimes(1); }); - 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 () => { + it('throws AuthRequiredError when the SDK returns a 401 result', 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)) { @@ -127,7 +47,21 @@ describe('makeSdkSourceFactory — 401 retry', () => { } catch (err) { caught = err as Error; } - expect(caught).toBeInstanceOf(ErrorCtor); + expect(caught).toBeInstanceOf(AuthRequiredError); 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 f11962a..c730876 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 { isAuthRequiredError, type OAuthRefresher } from '../auth/refresher.js'; +import { AuthRequiredError } from './auth-required-error.js'; import type { RecordingSink } from './replay.js'; import type { RawSourceFactory } from './runner.js'; import type { RawSdkMessage } from './sdk-stream-adapter.js'; @@ -7,7 +7,6 @@ import type { AgentRunInput } from './types.js'; export interface MakeSdkSourceFactoryOpts { record?: RecordingSink; - refresher?: OAuthRefresher; } /** @@ -20,107 +19,65 @@ 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. * - * 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). + * 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. */ export function makeSdkSourceFactory(opts: MakeSdkSourceFactoryOpts = {}): RawSourceFactory { return async function* (input: AgentRunInput): AsyncIterable { - 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; - } - } - - // 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); - } + const abortController = new AbortController(); + if (input.abortSignal) { + if (input.abortSignal.aborted) abortController.abort(); + else input.abortSignal.addEventListener('abort', () => abortController.abort(), { once: true }); + } - if (!saw401) { - for (const r of buffered) { - opts.record?.write(r); - yield r; - } - return; + // 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; } + } - // 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. + 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'); } - attemptedRefresh = true; + opts.record?.write(raw); + yield raw; } }; } diff --git a/packages/core/src/auth/credential-bundle.ts b/packages/core/src/auth/credential-bundle.ts deleted file mode 100644 index f7db3e2..0000000 --- a/packages/core/src/auth/credential-bundle.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 2ea0010..0000000 --- a/packages/core/src/auth/credential-reader.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 47c60df..0000000 --- a/packages/core/src/auth/credential-store.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index 168cb16..0000000 --- a/packages/core/src/auth/credential-store.ts +++ /dev/null @@ -1,83 +0,0 @@ -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 deleted file mode 100644 index 41d82fe..0000000 --- a/packages/core/src/auth/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index d0a2c6a..0000000 --- a/packages/core/src/auth/reader-factory.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 8abcc24..0000000 --- a/packages/core/src/auth/reader-factory.ts +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 4c9a666..0000000 --- a/packages/core/src/auth/readers/composite-linux.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index ae1aaa1..0000000 --- a/packages/core/src/auth/readers/composite-linux.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index ceb4430..0000000 --- a/packages/core/src/auth/readers/linux-fs.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index a454176..0000000 --- a/packages/core/src/auth/readers/linux-fs.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 6d07895..0000000 --- a/packages/core/src/auth/readers/linux-libsecret.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 5782295..0000000 --- a/packages/core/src/auth/readers/linux-libsecret.ts +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 6b9c590..0000000 --- a/packages/core/src/auth/readers/mac-keychain.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index def1112..0000000 --- a/packages/core/src/auth/readers/mac-keychain.ts +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 3e94082..0000000 --- a/packages/core/src/auth/readers/parse-claude-credentials.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 642b1fb..0000000 --- a/packages/core/src/auth/refresher.test.ts +++ /dev/null @@ -1,317 +0,0 @@ -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 deleted file mode 100644 index 72223dd..0000000 --- a/packages/core/src/auth/refresher.ts +++ /dev/null @@ -1,314 +0,0 @@ -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 54c0d22..c4e6984 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,3 @@ -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 877a480..7c2efb6 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 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. + * 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. */ export const CHAT_AUTH_REQUIRED_REPLY = - 'Claude Code authentication expired. Run `claude login`, then `mc auth bootstrap` to restore access.'; + 'Claude Code authentication failed. Run `claude login` 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 f640a9f..015045d 100644 --- a/packages/daemon/src/anthropic-stream.smoke.test.ts +++ b/packages/daemon/src/anthropic-stream.smoke.test.ts @@ -7,19 +7,14 @@ * drift (which would silently break the offline replay fixtures used by the * AgentRunner unit tests). * - * 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. + * 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 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 + * Excluded from normal CI. Runs with: `MC_SMOKE=1 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 @@ -33,10 +28,7 @@ import { join } from 'node:path'; import { query } from '@anthropic-ai/claude-agent-sdk'; import { describe, expect, it } from 'vitest'; -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 SMOKE = process.env.MC_SMOKE === '1'; 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 deleted file mode 100644 index b3972c1..0000000 --- a/packages/daemon/src/bin/auth-alert.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index 55d3327..0000000 --- a/packages/daemon/src/bin/auth-alert.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 6b7c6e3..c84be88 100644 --- a/packages/daemon/src/bin/mcd-main.ts +++ b/packages/daemon/src/bin/mcd-main.ts @@ -6,16 +6,10 @@ import { chmodSync, existsSync, statSync } from 'node:fs'; import { - CorruptedCredentialsError, - makeCredentialStore, - makeOAuthRefresher, makeStatusRegistry, - NotBootstrappedError, probeAiGatewayDimension, probeAndRegister, probeAnthropic1Token, - RefreshTokenRejectedError, - RefreshTokenRevokedError, resolveConfig, type StatusRegistry, sanitizeUpstreamError, @@ -39,7 +33,6 @@ 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 @@ -176,18 +169,6 @@ 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 @@ -314,99 +295,16 @@ export async function runMcdMain(_argv: readonly string[] = [], opts: RunMcdOpts logger.info({ propagated }, 'secrets propagated from DB'); } - // 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. + // 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.', + ); 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; @@ -416,19 +314,6 @@ 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: () => { @@ -477,7 +362,6 @@ export async function runMcdMain(_argv: readonly string[] = [], opts: RunMcdOpts store, statusRegistry, dashboardAuthToken, - oauthRefresher, testConnection, ...(dashboardPort !== undefined && { dashboardPort }), ...(discordClient !== undefined && { discordClient }), @@ -510,7 +394,6 @@ 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 deleted file mode 100644 index 6140884..0000000 --- a/packages/daemon/src/claude-code-refresh.smoke.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * 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 b6bd0ab..b785e86 100644 --- a/packages/daemon/src/compose.test.ts +++ b/packages/daemon/src/compose.test.ts @@ -3,7 +3,6 @@ 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'; @@ -13,13 +12,6 @@ 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 }); } @@ -51,7 +43,6 @@ describe('composeMcd', () => { dashboardAuthToken: 'a'.repeat(64), store: baseStore, statusRegistry: makeStatusRegistry(), - oauthRefresher: stubRefresher, agentRunner: new FakeAgentRunner(), fileWatcher: new FakeFileWatcher(), ...overrides, @@ -60,13 +51,12 @@ describe('composeMcd', () => { return result; }; - it('returns services in startup order: data-store → memory → reconcile → auth-refresher → vacuum → sleep-worker → dashboard', () => { + it('returns services in startup order: data-store → memory → reconcile → 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', @@ -109,7 +99,6 @@ describe('composeMcd', () => { dashboardAuthToken: 'a'.repeat(64), store: openStoreForTest(dir), statusRegistry: makeStatusRegistry(), - oauthRefresher: stubRefresher, agentRunner: new FakeAgentRunner(), fileWatcher: new FakeFileWatcher(), }); @@ -132,7 +121,6 @@ describe('composeMcd', () => { dashboardAuthToken: 'a'.repeat(64), store: openStoreForTest(dir), statusRegistry: makeStatusRegistry(), - oauthRefresher: stubRefresher, agentRunner: new FakeAgentRunner(), fileWatcher: watcher, }); @@ -233,7 +221,6 @@ describe('composeMcd Discord wiring', () => { dashboardAuthToken: 'a'.repeat(64), store: openStoreForTest(dir), statusRegistry: makeStatusRegistry(), - oauthRefresher: stubRefresher, agentRunner: new FakeAgentRunner(), fileWatcher: new FakeFileWatcher(), }); @@ -251,7 +238,6 @@ describe('composeMcd Discord wiring', () => { dashboardAuthToken: 'a'.repeat(64), store: openStoreForTest(dir), statusRegistry: makeStatusRegistry(), - oauthRefresher: stubRefresher, agentRunner: new FakeAgentRunner(), fileWatcher: new FakeFileWatcher(), }); @@ -269,7 +255,6 @@ describe('composeMcd Discord wiring', () => { dashboardAuthToken: 'a'.repeat(64), store: openStoreForTest(dir), statusRegistry: makeStatusRegistry(), - oauthRefresher: stubRefresher, agentRunner: new FakeAgentRunner(), fileWatcher: new FakeFileWatcher(), discordClient: fakeDiscordClient(), @@ -279,7 +264,6 @@ 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 efffc17..3ccb7c8 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 { OAuthRefresher, StatusRegistry } from '@mc/core'; +import type { StatusRegistry } from '@mc/core'; import { type AgentRunner, BRAINSTORM_SYSTEM_PROMPT, @@ -84,13 +84,6 @@ 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 @@ -155,8 +148,7 @@ export function composeMcd(opts: ComposeMcdOpts): ComposedMcd { const bus = makeEventBus(); const git = makeSimpleGitOps(); const queue = makeMessageQueue(); - const runner = - opts.agentRunner ?? makeAgentRunnerFromSource(makeSdkSourceFactory({ refresher: opts.oauthRefresher })); + const runner = opts.agentRunner ?? makeAgentRunnerFromSource(makeSdkSourceFactory({})); const memoryProvider = new MarkdownGitProvider({ store, @@ -205,18 +197,6 @@ 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: { @@ -234,7 +214,6 @@ 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 2a8e6eb..68ee763 100644 --- a/packages/daemon/src/paths.ts +++ b/packages/daemon/src/paths.ts @@ -6,8 +6,6 @@ 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. */ @@ -30,7 +28,6 @@ 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 03ee925..7697589 100644 --- a/packages/daemon/src/services.test.ts +++ b/packages/daemon/src/services.test.ts @@ -1,6 +1,3 @@ -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'; @@ -339,7 +336,6 @@ 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, @@ -436,31 +432,4 @@ 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 0f7cbb0..c165640 100644 --- a/packages/daemon/src/services.ts +++ b/packages/daemon/src/services.ts @@ -7,7 +7,6 @@ * 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'; @@ -254,8 +253,6 @@ 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; @@ -270,24 +267,16 @@ 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, }; } @@ -296,8 +285,7 @@ function differs(a: ConfigSummary, b: ConfigSummary): boolean { a.credCount !== b.credCount || a.credLatest !== b.credLatest || a.settingsCount !== b.settingsCount || - a.settingsLatest !== b.settingsLatest || - a.credJsonMtime !== b.credJsonMtime + a.settingsLatest !== b.settingsLatest ); } diff --git a/packages/dashboard/web/src/components/BootstrapInstructionStep.test.tsx b/packages/dashboard/web/src/components/BootstrapInstructionStep.test.tsx index 7ac0716..a408bc7 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, waitFor } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { 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: 'mc auth bootstrap', + command: 'claude login', docHelpHref: 'https://example.com/docs', step: 1, total: 4, @@ -13,80 +13,26 @@ 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('mc auth bootstrap')).toBeInTheDocument(); + expect(screen.getByText('claude login')).toBeInTheDocument(); expect(screen.getByText(/Authenticate with Claude Code/)).toBeInTheDocument(); }); - 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 }), - }); + it('Continue is enabled and forwards the click', () => { const onContinue = vi.fn(); render(); - await waitFor(() => { - const btn = screen.getByRole('button', { name: /continue/i }); - expect(btn).not.toBeDisabled(); - }); - fireEvent.click(screen.getByRole('button', { name: /continue/i })); + const btn = screen.getByRole('button', { name: /continue/i }); + expect(btn).not.toBeDisabled(); + fireEvent.click(btn); 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 0a264dc..ca15ff8 100644 --- a/packages/dashboard/web/src/components/BootstrapInstructionStep.tsx +++ b/packages/dashboard/web/src/components/BootstrapInstructionStep.tsx @@ -1,6 +1,3 @@ -import { useEffect, useState } from 'react'; -import type { IntegrationStatus } from '@/lib/api'; - interface StepCommonProps { step: number; total: number; @@ -15,38 +12,14 @@ export interface BootstrapInstructionStepProps extends StepCommonProps { docHelpHref: string; } -const POLL_INTERVAL_MS = 2_000; - +/** + * 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. + */ 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 (
@@ -66,22 +39,11 @@ 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 5943038..d76fb84 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 `mc auth bootstrap` completes and the daemon + * unblocks automatically when `claude login` 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 60a6942..7252903 100644 --- a/packages/dashboard/web/src/hooks/useSetupAlerts.ts +++ b/packages/dashboard/web/src/hooks/useSetupAlerts.ts @@ -4,10 +4,13 @@ import { useStatusContext } from './StatusContext'; /** * Aggregates the banner sources the App layout cares about: - * 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) + * 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. */ export function useSetupAlerts(): SetupBannerAlert[] { const { statuses, online } = useStatusContext(); @@ -18,7 +21,7 @@ export function useSetupAlerts(): SetupBannerAlert[] { alerts.push({ id: 'claudeCode-not-bootstrapped', tone: 'warning', - message: 'Claude Code not authenticated. Run `mc auth bootstrap` to enable agent runs.', + message: 'Claude Code not authenticated. Run `claude login` to enable agent runs.', cta: { label: 'Open onboarding', href: '/onboarding' }, }); } @@ -26,7 +29,7 @@ export function useSetupAlerts(): SetupBannerAlert[] { alerts.push({ id: 'claudeCode-refresh-failed', tone: 'error', - message: statuses.claudeCode.detail ?? 'Claude Code auth refresh failed', + message: statuses.claudeCode.detail ?? 'Claude Code authentication 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 df9f134..1da62c8 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 via `mc auth bootstrap` + claudeCode: [], // no user-pasted credential; managed by the Claude Code CLI (`claude login`) 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 d7e9f64..0357341 100644 --- a/packages/dashboard/web/src/pages/OnboardingPage.test.tsx +++ b/packages/dashboard/web/src/pages/OnboardingPage.test.tsx @@ -75,51 +75,26 @@ describe('OnboardingPage', () => { fireEvent.click(screen.getByRole('button', { name: /get started/i })); } - it('Continue is disabled while status is not ready', async () => { + it('Continue is always enabled (Slice K3 — no daemon-side gating)', async () => { vi.stubGlobal( 'fetch', - makeFetchStub(() => ({ body: { status: 'disabled' } })), + makeFetchStub(() => ({ body: { status: 'ready' } })), ); renderPage(); advanceToClaudeCode(); await waitFor(() => { expect(screen.getByRole('heading', { name: /authenticate with claude code/i })).toBeInTheDocument(); }); - 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(); - }); + expect(screen.getByRole('button', { name: /continue/i })).not.toBeDisabled(); }); - it('clicking Continue on a ready status advances to Discord step', async () => { + it('clicking Continue advances to Discord step', async () => { vi.stubGlobal( 'fetch', - makeFetchStub((path) => { - if (path === '/api/integrations/claudeCode/status') { - return { body: { status: 'ready', expiresAt: Date.now() + 3_600_000 } }; - } - return { body: {} }; - }), + makeFetchStub(() => ({ body: { status: 'ready' } })), ); 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 c19d302..39f882b 100644 --- a/packages/dashboard/web/src/pages/OnboardingPage.tsx +++ b/packages/dashboard/web/src/pages/OnboardingPage.tsx @@ -28,9 +28,13 @@ export default function OnboardingPage() { Mission Control runs through your Claude Code subscription. Open a terminal and run: + <> + 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. + } - command="mc auth bootstrap" + command="claude login" docHelpHref="/README.md#auth" step={stepNumber} total={TOTAL} @@ -76,7 +80,7 @@ function WelcomeStep({ step, total, onContinue }: StepCommonProps) { return ( { expect(screen.getByText(/Last refreshed/i)).toBeInTheDocument(); }); - it('shows disabled badge with bootstrap instructions', () => { + it('shows disabled badge with login instructions', () => { render(); expect(screen.getByText(/Disabled/)).toBeInTheDocument(); - expect(screen.getByText(/mc auth bootstrap/)).toBeInTheDocument(); + expect(screen.getByText(/claude login/)).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 ccdc202..9cc380f 100644 --- a/packages/dashboard/web/src/pages/settings/ClaudeCodeStatusPanel.tsx +++ b/packages/dashboard/web/src/pages/settings/ClaudeCodeStatusPanel.tsx @@ -61,7 +61,8 @@ export function ClaudeCodeStatusPanel(props: ClaudeCodeStatusPanelProps): React.
Setup instructions

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