From 2937c4861ffc8b2f4734fd653c3951614ce36886 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 7 Jan 2026 06:29:43 +0000 Subject: [PATCH] fix(auth): doctor-migrate anthropic oauth profiles --- CHANGELOG.md | 1 + README.md | 2 +- docs/gateway/configuration.md | 4 +- src/agents/auth-profiles.ts | 260 +++++++++++++++++++++++++++++++++- src/commands/configure.ts | 1 + src/commands/doctor.test.ts | 60 ++++++++ src/commands/doctor.ts | 29 +++- src/wizard/onboarding.ts | 1 + 8 files changed, 353 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1e69a4aa..4d1da8300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ - Commands: unify native + text chat commands behind `commands.*` config (Discord/Slack/Telegram). Thanks @thewilloftheshadow for PR #275. - Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes. - Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure. +- Auth/Doctor: migrate Anthropic OAuth configs from `anthropic:default` → `anthropic:` and surface a doctor hint on refresh failures. Thanks @RandyVentures for PR #361. (#363) - Gateway/CLI: stop forcing localhost URL in remote mode so remote gateway config works. Thanks @oswalpalash for PR #293. - Onboarding: prompt immediately for OpenAI Codex redirect URL on remote/headless logins. - Configure: add OpenAI Codex (ChatGPT OAuth) auth choice (align with onboarding). diff --git a/README.md b/README.md index 6c8a295fc..4baf9aee5 100644 --- a/README.md +++ b/README.md @@ -453,5 +453,5 @@ Thanks to all clawtributors: azade-c andranik-sahakyan adamgall jalehman jarvis-medmatic mneves75 regenrek tobiasbischoff MSch obviyus dbhurley Asleep123 Iamadig imfing kitze nachoiacovino VACInc cash-echo-bot claude kiranjd pcty-nextgen-service-account minghinmatthewlam - ngutman onutc oswalpalash snopoke ManuelHettich loukotal hugobarauna AbhisekBasu1 emanuelst dantelex erikpr1994 antons + ngutman onutc oswalpalash snopoke ManuelHettich loukotal hugobarauna AbhisekBasu1 emanuelst dantelex erikpr1994 antons RandyVentures

diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 89e76d143..bf8fc4501 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -119,11 +119,11 @@ rotation order used for failover. { auth: { profiles: { - "anthropic:default": { provider: "anthropic", mode: "oauth", email: "me@example.com" }, + "anthropic:me@example.com": { provider: "anthropic", mode: "oauth", email: "me@example.com" }, "anthropic:work": { provider: "anthropic", mode: "api_key" } }, order: { - anthropic: ["anthropic:default", "anthropic:work"] + anthropic: ["anthropic:me@example.com", "anthropic:work"] } } } diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index 7640d36d6..72513e47c 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -10,6 +10,7 @@ import lockfile from "proper-lockfile"; import type { ClawdbotConfig } from "../config/config.js"; import { resolveOAuthPath } from "../config/paths.js"; +import type { AuthProfileConfig } from "../config/types.js"; import { resolveUserPath } from "../utils.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js"; import { normalizeProviderId } from "./model-selection.js"; @@ -694,10 +695,36 @@ export async function resolveApiKeyForProfile(params: { email: refreshed.email ?? cred.email, }; } + const fallbackProfileId = suggestOAuthProfileIdForLegacyDefault({ + cfg, + store: refreshedStore, + provider: cred.provider, + legacyProfileId: profileId, + }); + if (fallbackProfileId && fallbackProfileId !== profileId) { + try { + const fallbackResolved = await tryResolveOAuthProfile({ + cfg, + store: refreshedStore, + profileId: fallbackProfileId, + agentDir: params.agentDir, + }); + if (fallbackResolved) return fallbackResolved; + } catch { + // keep original error + } + } const message = error instanceof Error ? error.message : String(error); + const hint = formatAuthDoctorHint({ + cfg, + store: refreshedStore, + provider: cred.provider, + profileId, + }); throw new Error( `OAuth token refresh failed for ${cred.provider}: ${message}. ` + - "Please try again or re-authenticate.", + "Please try again or re-authenticate." + + (hint ? `\n\n${hint}` : ""), ); } } @@ -745,3 +772,234 @@ export function resolveAuthProfileDisplayLabel(params: { if (email) return `${profileId} (${email})`; return profileId; } + +async function tryResolveOAuthProfile(params: { + cfg?: ClawdbotConfig; + store: AuthProfileStore; + profileId: string; + agentDir?: string; +}): Promise<{ apiKey: string; provider: string; email?: string } | null> { + const { cfg, store, profileId } = params; + const cred = store.profiles[profileId]; + if (!cred || cred.type !== "oauth") return null; + const profileConfig = cfg?.auth?.profiles?.[profileId]; + if (profileConfig && profileConfig.provider !== cred.provider) return null; + if (profileConfig && profileConfig.mode !== cred.type) return null; + + if (Date.now() < cred.expires) { + return { + apiKey: buildOAuthApiKey(cred.provider, cred), + provider: cred.provider, + email: cred.email, + }; + } + + const refreshed = await refreshOAuthTokenWithLock({ + profileId, + provider: cred.provider, + agentDir: params.agentDir, + }); + if (!refreshed) return null; + return { + apiKey: refreshed.apiKey, + provider: cred.provider, + email: cred.email, + }; +} + +function getProfileSuffix(profileId: string): string { + const idx = profileId.indexOf(":"); + if (idx < 0) return ""; + return profileId.slice(idx + 1); +} + +function isEmailLike(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) return false; + return trimmed.includes("@") && trimmed.includes("."); +} + +export function suggestOAuthProfileIdForLegacyDefault(params: { + cfg?: ClawdbotConfig; + store: AuthProfileStore; + provider: string; + legacyProfileId: string; +}): string | null { + const providerKey = normalizeProviderId(params.provider); + const legacySuffix = getProfileSuffix(params.legacyProfileId); + if (legacySuffix !== "default") return null; + + const legacyCfg = params.cfg?.auth?.profiles?.[params.legacyProfileId]; + if ( + legacyCfg && + normalizeProviderId(legacyCfg.provider) === providerKey && + legacyCfg.mode !== "oauth" + ) { + return null; + } + + const oauthProfiles = listProfilesForProvider( + params.store, + providerKey, + ).filter((id) => params.store.profiles[id]?.type === "oauth"); + if (oauthProfiles.length === 0) return null; + + const configuredEmail = legacyCfg?.email?.trim(); + if (configuredEmail) { + const byEmail = oauthProfiles.find((id) => { + const cred = params.store.profiles[id]; + if (!cred || cred.type !== "oauth") return false; + const email = cred.email?.trim(); + return ( + email === configuredEmail || id === `${providerKey}:${configuredEmail}` + ); + }); + if (byEmail) return byEmail; + } + + const lastGood = + params.store.lastGood?.[providerKey] ?? + params.store.lastGood?.[params.provider]; + if (lastGood && oauthProfiles.includes(lastGood)) return lastGood; + + const nonLegacy = oauthProfiles.filter((id) => id !== params.legacyProfileId); + if (nonLegacy.length === 1) return nonLegacy[0] ?? null; + + const emailLike = nonLegacy.filter((id) => isEmailLike(getProfileSuffix(id))); + if (emailLike.length === 1) return emailLike[0] ?? null; + + return null; +} + +export type AuthProfileIdRepairResult = { + config: ClawdbotConfig; + changes: string[]; + migrated: boolean; + fromProfileId?: string; + toProfileId?: string; +}; + +export function repairOAuthProfileIdMismatch(params: { + cfg: ClawdbotConfig; + store: AuthProfileStore; + provider: string; + legacyProfileId?: string; +}): AuthProfileIdRepairResult { + const legacyProfileId = + params.legacyProfileId ?? `${normalizeProviderId(params.provider)}:default`; + const legacyCfg = params.cfg.auth?.profiles?.[legacyProfileId]; + if (!legacyCfg) { + return { config: params.cfg, changes: [], migrated: false }; + } + if (legacyCfg.mode !== "oauth") { + return { config: params.cfg, changes: [], migrated: false }; + } + if ( + normalizeProviderId(legacyCfg.provider) !== + normalizeProviderId(params.provider) + ) { + return { config: params.cfg, changes: [], migrated: false }; + } + + const toProfileId = suggestOAuthProfileIdForLegacyDefault({ + cfg: params.cfg, + store: params.store, + provider: params.provider, + legacyProfileId, + }); + if (!toProfileId || toProfileId === legacyProfileId) { + return { config: params.cfg, changes: [], migrated: false }; + } + + const toCred = params.store.profiles[toProfileId]; + const toEmail = toCred?.type === "oauth" ? toCred.email?.trim() : undefined; + + const nextProfiles = { + ...(params.cfg.auth?.profiles as + | Record + | undefined), + } as Record; + delete nextProfiles[legacyProfileId]; + nextProfiles[toProfileId] = { + ...legacyCfg, + ...(toEmail ? { email: toEmail } : {}), + }; + + const providerKey = normalizeProviderId(params.provider); + const nextOrder = (() => { + const order = params.cfg.auth?.order; + if (!order) return undefined; + const resolvedKey = Object.keys(order).find( + (key) => normalizeProviderId(key) === providerKey, + ); + if (!resolvedKey) return order; + const existing = order[resolvedKey]; + if (!Array.isArray(existing)) return order; + const replaced = existing + .map((id) => (id === legacyProfileId ? toProfileId : id)) + .filter( + (id): id is string => typeof id === "string" && id.trim().length > 0, + ); + const deduped: string[] = []; + for (const entry of replaced) { + if (!deduped.includes(entry)) deduped.push(entry); + } + return { ...order, [resolvedKey]: deduped }; + })(); + + const nextCfg: ClawdbotConfig = { + ...params.cfg, + auth: { + ...params.cfg.auth, + profiles: nextProfiles, + ...(nextOrder ? { order: nextOrder } : {}), + }, + }; + + const changes = [ + `Auth: migrate ${legacyProfileId} → ${toProfileId} (OAuth profile id)`, + ]; + + return { + config: nextCfg, + changes, + migrated: true, + fromProfileId: legacyProfileId, + toProfileId, + }; +} + +export function formatAuthDoctorHint(params: { + cfg?: ClawdbotConfig; + store: AuthProfileStore; + provider: string; + profileId?: string; +}): string { + const providerKey = normalizeProviderId(params.provider); + if (providerKey !== "anthropic") return ""; + + const legacyProfileId = params.profileId ?? "anthropic:default"; + const suggested = suggestOAuthProfileIdForLegacyDefault({ + cfg: params.cfg, + store: params.store, + provider: providerKey, + legacyProfileId, + }); + if (!suggested || suggested === legacyProfileId) return ""; + + const storeOauthProfiles = listProfilesForProvider(params.store, providerKey) + .filter((id) => params.store.profiles[id]?.type === "oauth") + .join(", "); + + const cfgMode = params.cfg?.auth?.profiles?.[legacyProfileId]?.mode; + const cfgProvider = params.cfg?.auth?.profiles?.[legacyProfileId]?.provider; + + return [ + "Doctor hint (for GitHub issue):", + `- provider: ${providerKey}`, + `- config: ${legacyProfileId}${cfgProvider || cfgMode ? ` (provider=${cfgProvider ?? "?"}, mode=${cfgMode ?? "?"})` : ""}`, + `- auth store oauth profiles: ${storeOauthProfiles || "(none)"}`, + `- suggested profile: ${suggested}`, + 'Fix: run "clawdbot doctor --yes"', + ].join("\n"); +} diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 233442497..878731b58 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -304,6 +304,7 @@ async function promptAuthConfig( profileId, provider: "anthropic", mode: "oauth", + email: oauthCreds.email ?? undefined, }); } } catch (err) { diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index 0816c533b..db25b6962 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -40,6 +40,10 @@ const runCommandWithTimeout = vi.fn().mockResolvedValue({ killed: false, }); +const ensureAuthProfileStore = vi + .fn() + .mockReturnValue({ version: 1, profiles: {} }); + const legacyReadConfigFileSnapshot = vi.fn().mockResolvedValue({ path: "/tmp/clawdis.json", exists: false, @@ -103,6 +107,14 @@ vi.mock("../process/exec.js", () => ({ runCommandWithTimeout, })); +vi.mock("../agents/auth-profiles.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ensureAuthProfileStore, + }; +}); + vi.mock("../daemon/service.js", () => ({ resolveGatewayService: () => ({ label: "LaunchAgent", @@ -614,4 +626,52 @@ describe("doctor", () => { expect(serviceRestart).not.toHaveBeenCalled(); expect(confirm).not.toHaveBeenCalled(); }); + + it("migrates anthropic oauth config profile id when only email profile exists", async () => { + readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/clawdbot.json", + exists: true, + raw: "{}", + parsed: {}, + valid: true, + config: { + auth: { + profiles: { + "anthropic:default": { provider: "anthropic", mode: "oauth" }, + }, + }, + }, + issues: [], + legacyIssues: [], + }); + + ensureAuthProfileStore.mockReturnValueOnce({ + version: 1, + profiles: { + "anthropic:me@example.com": { + type: "oauth", + provider: "anthropic", + access: "access", + refresh: "refresh", + expires: Date.now() + 60_000, + email: "me@example.com", + }, + }, + }); + + const { doctorCommand } = await import("./doctor.js"); + await doctorCommand( + { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + { yes: true }, + ); + + const written = writeConfigFile.mock.calls.at(-1)?.[0] as Record< + string, + unknown + >; + const profiles = (written.auth as { profiles: Record }) + .profiles; + expect(profiles["anthropic:me@example.com"]).toBeTruthy(); + expect(profiles["anthropic:default"]).toBeUndefined(); + }); }); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 859623427..4e958c4a7 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -3,7 +3,10 @@ import os from "node:os"; import path from "node:path"; import { confirm, intro, note, outro, select } from "@clack/prompts"; - +import { + ensureAuthProfileStore, + repairOAuthProfileIdMismatch, +} from "../agents/auth-profiles.js"; import { DEFAULT_SANDBOX_BROWSER_IMAGE, DEFAULT_SANDBOX_COMMON_IMAGE, @@ -386,6 +389,28 @@ function createDoctorPrompter(params: { }; } +async function maybeRepairAnthropicOAuthProfileId( + cfg: ClawdbotConfig, + prompter: DoctorPrompter, +): Promise { + const store = ensureAuthProfileStore(); + const repair = repairOAuthProfileIdMismatch({ + cfg, + store, + provider: "anthropic", + legacyProfileId: "anthropic:default", + }); + if (!repair.migrated || repair.changes.length === 0) return cfg; + + note(repair.changes.map((c) => `- ${c}`).join("\n"), "Auth profiles"); + const apply = await prompter.confirm({ + message: "Update Anthropic OAuth profile id in config now?", + initialValue: true, + }); + if (!apply) return cfg; + return repair.config; +} + const MEMORY_SYSTEM_PROMPT = [ "Memory system not found in workspace.", "Paste this into your agent:", @@ -889,6 +914,8 @@ export async function doctorCommand( cfg = normalized.config; } + cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter); + const legacyState = await detectLegacyStateMigrations({ cfg }); if (legacyState.preview.length > 0) { note(legacyState.preview.join("\n"), "Legacy state detected"); diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 9aebc8085..6682e2f35 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -300,6 +300,7 @@ export async function runOnboardingWizard( profileId, provider: "anthropic", mode: "oauth", + email: oauthCreds.email ?? undefined, }); } } catch (err) {