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:
-
+
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) {