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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 0 additions & 37 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,43 +19,6 @@ Each slice entry has:

## Released

### Slice K3 — cabinet-style auth migration: delete `packages/core/src/auth/` · 2026-05-06 · PR (pending)

**Shipped**

- **Deleted entire `packages/core/src/auth/` tree** — `refresher.ts`, `credential-store.ts`, `credential-bundle.ts`, `credential-reader.ts`, `reader-factory.ts`, `index.ts`, every reader (`mac-keychain.ts`, `linux-fs.ts`, `linux-libsecret.ts`, `composite-linux.ts`, `parse-claude-credentials.ts`), and all matching `*.test.ts`. Mission Control no longer reimplements Claude Code OAuth.
- **Deleted `mc auth bootstrap` CLI command** (`packages/cli/src/commands/auth.ts` + test) and removed its registration from the `mc` entrypoint. The command is now meaningless — the SDK's bundled `claude` binary handles auth from `claude login` state directly.
- **Deleted `packages/daemon/src/bin/auth-alert.ts` + test** — the K1.5 Discord DM alert helper. With no MC-side refresher there are no `'expiring'` / `'error'` transitions to alert about.
- **`packages/daemon/src/bin/mcd-main.ts`** — removed `OAuthRefresher` construction, env-seeding of `CLAUDE_CODE_OAUTH_TOKEN`, the boot probe, the `auth-alert` wiring, and the `onReady` boot-race recovery. Replaced with a single `statusRegistry.setReady('claudeCode', detail)` seed at boot. Net delta: -~110 lines.
- **`packages/daemon/src/compose.ts`** — dropped `oauthRefresher` field from `ComposeMcdOpts` and removed the `auth-refresher` `DaemonService` lifecycle.
- **`packages/daemon/src/paths.ts`** — dropped `credentialsJsonPath`.
- **`packages/daemon/src/services.ts`** — dropped `credentialsJsonPath` from `MakeConfigWatcherServiceOpts` and the corresponding `statSync()` call in the config-watcher poll loop. One fewer syscall per tick.
- **New `packages/core/src/agent/auth-required-error.ts`** — small sentinel `AuthRequiredError` class. Replaces the deleted `isAuthRequiredError(err)` helper + the three `RefreshToken*Error` / `NotBootstrappedError` classes that all collapsed into "SDK reported a 401."
- **`packages/core/src/agent/sdk-source.ts`** — removed the refresher injection + retry-loop. Now streams eagerly; on a 401-result, throws `AuthRequiredError`. The K2 chat-lane fail-fast path takes over from there.
- **`packages/core/src/agent/runner.ts`** — replaced `isAuthRequiredError(err)` (deleted) with `err instanceof AuthRequiredError`.
- **`packages/core/src/messaging/chat-bridge.ts`** — `CHAT_AUTH_REQUIRED_REPLY` updated: drops `mc auth bootstrap` reference (command no longer exists), changes "expired" → "failed" (we only see a 401, can't distinguish expiry from revocation/missing-creds/etc.).
- **Dashboard wizard** — `BootstrapInstructionStep.tsx` rewritten: dropped the 2s `setInterval` poll of `/api/integrations/claudeCode/status` (now permanently `'ready'`); Continue button is always enabled. The polling was dead-weight under the K3 design.
- **Dashboard text updates** — `OnboardingPage.tsx`, `ClaudeCodeStatusPanel.tsx`, `useSetupAlerts.ts`, `integration-fields.ts`, `OnboardingRedirect.tsx`: every `mc auth bootstrap` reference replaced with `claude login`.
- **Net diff:** ~45 files changed, -2000 lines.

**Why it matters**

K1, K1.5, K2 fixed three concrete bugs in MC's reimplementation of Claude Code OAuth — but the deeper issue was that MC was reimplementing it at all. Cabinet (`hilash/cabinet`) showed the alternative: shell out to `claude` and trust the binary completely. K3's research spike confirmed the Claude Agent SDK already does exactly this — when `query()` runs with no `CLAUDE_CODE_OAUTH_TOKEN` set, it spawns the bundled `claude` binary at `node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64/claude` (216 MB Mach-O) which handles keychain reads, refresh-token rotation against `https://platform.claude.com/v1/oauth/token`, and credential storage in `~/.claude/.credentials.json`. A live probe with all three auth env vars unset returned a successful Haiku result in 8.2s.

The deletion eliminates: a hard-coded `CLAUDE_CODE_CLIENT_ID` (Bug 4 from K1's diagnosis — exposure to Anthropic rotating the public client_id), three error class hierarchies, a 5-min refresh ticker, a credentials-watch mtime poll, a daemon-respawn-on-credentials-change path, a Discord DM alerting helper, four reader implementations across two platforms, and ~2000 lines of code that exists in `~/.claude/credentials.json` writers. MC's onboarding gate is now a single text instruction: "run `claude login`." Runtime auth failures still fail fast through `AuthRequiredError` → `CHAT_AUTH_REQUIRED_REPLY` (the K2 path is preserved).

**Notable code**

- `auth-required-error.ts:7-12` — the entire OAuth-failure surface that K1+K1.5+K2 spun up is now this 6-line class. The runner detects it via instanceof; chat-bridge's `ChatAuthRequiredError` (private) translates it to the user-visible reply.
- `mcd-main.ts:298-302` — `statusRegistry.setReady('claudeCode', '...')` replaces ~110 lines of refresher/auth-alert/boot-probe wiring. The dashboard's onboarding flow keeps working because the only thing it ever cared about was a populated status body.
- `sdk-source.ts:73-81` — the new 401 detection: one `if` branch, one throw. Replaces the buffer-everything-then-replay-or-discard retry path. Memory profile is now flat instead of O(messages-per-run).

**Deferred**

- **Linux/Windows verification.** The probe was run on darwin-arm64. The SDK ships parallel platform binaries; Mission Control's own code makes no platform-specific assumptions, so the bundled `claude` should work transparently on Linux/Windows too — but the smoke test wasn't run there. Document for future contributors.
- **`useSetupAlerts.ts` defensive branches.** The `claudeCode disabled` and `claudeCode error` banner paths in `useSetupAlerts.ts` are now unreachable in production (the daemon hard-pins `'ready'`). Kept as defensive — if a future slice reintroduces a probe, those branches will pick it up automatically.
- **`ClaudeCodeStatusPanel.tsx` dead branches.** The `expiresAt` / `lastRefreshedAt` rendering paths are likewise defensive-only. Same rationale.

### Slice K2 — chat-lane fail-fast on Claude Code auth errors · 2026-05-06 · PR (pending)

**Shipped**
Expand Down
28 changes: 20 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <slug>` → `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 `<<READY_TO_PROMOTE>>` 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 `<private>...</private>` 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 <slug>` → `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 `<<READY_TO_PROMOTE>>` 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 `<private>...</private>` 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. |
Expand Down Expand Up @@ -530,19 +530,31 @@ Notes: `KeepAlive: { SuccessfulExit: false }` restarts the proxy on crash but no

## Authentication

Mission Control authenticates to Anthropic via your Claude Code subscription — but it doesn't manage the OAuth flow itself. Setup is one terminal command:
Mission Control authenticates to Anthropic via your Claude Code subscription. Setup is a one-time terminal command:

1. Log into Claude Code: `claude login` (opens a browser, completes the OAuth flow, writes credentials to your system keychain / Linux fs / libsecret).
2. Bootstrap Mission Control: `mc auth bootstrap`.

The bootstrap command reads from your platform's credential store (macOS keychain via `security`, Linux fs at `~/.config/Claude/credentials.json` or `~/.claude/.credentials.json`, Linux libsecret via `secret-tool`) and writes them to a daemon-readable file at `~/.mc/credentials.json` (mode 0600, atomic).

The daemon's `OAuthRefresher` then refreshes the OAuth token in the background — every 5 minutes it checks expiry; if less than 10 minutes remain, it POSTs to `https://claude.ai/v1/oauth/token` with `grant_type=refresh_token` to get a new bearer + new refresh token. The Claude Agent SDK reads `CLAUDE_CODE_OAUTH_TOKEN` from `process.env` directly; the refresher writes it on every successful rotation. No manual intervention required after bootstrap.

**Re-bootstrap when:**
- The bot DMs you a "⚠️ Claude Code auth expiring soon" or "❌ Claude Code auth failure" alert (proactive 2h heads-up via the Discord owner DM — fix it before the chat lane stalls)
- The dashboard surfaces a `Refresh failed: re-bootstrap` banner (refresh token revoked or rotated server-side, e.g. after `claude logout`)
- The dashboard's `claudeCode` integration goes `unverified` with a "Token expiring soon" detail
- You ran `claude login` again on a different account

```bash
claude login # opens a browser, completes the OAuth flow
mc auth bootstrap # always overwrites ~/.mc/credentials.json with the freshest keychain values
mc auth bootstrap --dry-run # preview the new expiry without touching the file
```

The Claude Agent SDK that Mission Control depends on ships a bundled `claude` binary at `node_modules/@anthropic-ai/claude-agent-sdk-<platform>/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: <error>` so the dashboard can render an actionable error.
The bootstrap command always overwrites by default and prints both the old and new expiry timestamps so you can confirm the rotation. (Earlier behavior — silent skip when the file existed — was a footgun: users running the command after `claude login` were surprised to see "Already bootstrapped" and the daemon kept using the stale token.)

**To recover:** run `claude login` again. No daemon restart needed — the bundled `claude` binary picks up the rotated credential on the next subprocess spawn.
**Why a separate file (not `state.db`):** the launchd-spawned daemon cannot access the user's keychain (different "responsible parent" attribute than the user's terminal). The bootstrap command runs from the user's terminal where keychain access works; the daemon only reads the file at `~/.mc/credentials.json` and refreshes via plain HTTPS — never touches keychain at runtime.

**Why no MC-side OAuth?** Earlier slices (K1, K1.5, K2) shipped a parallel implementation: `OAuthRefresher` reading the keychain via `security`, copying to `~/.mc/credentials.json`, refreshing every 5 min, surfacing `'expiring'` / `'error'` to the dashboard, DMing alerts on Discord. K3's research spike confirmed every one of those layers is duplicate work — the SDK already does it. Deleting MC's auth tree removed ~2000 lines, eliminated a hard-coded `CLAUDE_CODE_CLIENT_ID`, and made auth a single shell command that the user already understands.
The daemon's `mc auth bootstrap` watcher polls `~/.mc/credentials.json` mtime alongside the SQLite summaries, so re-running bootstrap triggers an `EX_TEMPFAIL=75` daemon respawn within ~1.5s — no `launchctl kickstart` needed.

---

Expand Down
Loading
Loading