refactor: move oauth profile repair metadata into providers

This commit is contained in:
Peter Steinberger
2026-03-27 17:13:53 +00:00
parent 570bfb655f
commit e25f634d50
8 changed files with 222 additions and 64 deletions

View File

@@ -3,11 +3,21 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { ProviderPlugin } from "../plugins/types.js";
import { captureEnv } from "../test-utils/env.js";
import { maybeRemoveDeprecatedCliAuthProfiles } from "./doctor-auth.js";
import {
maybeRemoveDeprecatedCliAuthProfiles,
maybeRepairLegacyOAuthProfileIds,
} from "./doctor-auth.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
import type { DoctorRepairMode } from "./doctor-repair-mode.js";
const resolvePluginProvidersMock = vi.fn<() => ProviderPlugin[]>(() => []);
vi.mock("../plugins/providers.runtime.js", () => ({
resolvePluginProviders: () => resolvePluginProvidersMock(),
}));
let envSnapshot: ReturnType<typeof captureEnv>;
let tempAgentDir: string | undefined;
@@ -36,6 +46,8 @@ beforeEach(() => {
tempAgentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
process.env.OPENCLAW_AGENT_DIR = tempAgentDir;
process.env.PI_CODING_AGENT_DIR = tempAgentDir;
resolvePluginProvidersMock.mockReset();
resolvePluginProvidersMock.mockReturnValue([]);
});
afterEach(() => {
@@ -87,6 +99,21 @@ describe("maybeRemoveDeprecatedCliAuthProfiles", () => {
"utf8",
);
resolvePluginProvidersMock.mockReturnValue([
{
id: "anthropic",
label: "Anthropic",
auth: [],
deprecatedProfileIds: ["anthropic:claude-cli"],
},
{
id: "openai-codex",
label: "OpenAI Codex",
auth: [],
deprecatedProfileIds: ["openai-codex:codex-cli"],
},
]);
const cfg = {
auth: {
profiles: {
@@ -120,3 +147,67 @@ describe("maybeRemoveDeprecatedCliAuthProfiles", () => {
expect(next.auth?.order?.["openai-codex"]).toEqual(["openai-codex:default"]);
});
});
describe("maybeRepairLegacyOAuthProfileIds", () => {
it("repairs provider-owned legacy OAuth profile ids", async () => {
if (!tempAgentDir) {
throw new Error("Missing temp agent dir");
}
const authPath = path.join(tempAgentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
`${JSON.stringify(
{
version: 1,
profiles: {
"anthropic:user@example.com": {
type: "oauth",
provider: "anthropic",
access: "token-a",
refresh: "token-r",
expires: Date.now() + 60_000,
email: "user@example.com",
},
},
lastGood: {
anthropic: "anthropic:user@example.com",
},
},
null,
2,
)}\n`,
"utf8",
);
resolvePluginProvidersMock.mockReturnValue([
{
id: "anthropic",
label: "Anthropic",
auth: [],
oauthProfileIdRepairs: [{ legacyProfileId: "anthropic:default" }],
},
]);
const next = await maybeRepairLegacyOAuthProfileIds(
{
auth: {
profiles: {
"anthropic:default": { provider: "anthropic", mode: "oauth" },
},
order: {
anthropic: ["anthropic:default"],
},
},
} as OpenClawConfig,
makePrompter(true),
);
expect(next.auth?.profiles?.["anthropic:default"]).toBeUndefined();
expect(next.auth?.profiles?.["anthropic:user@example.com"]).toMatchObject({
provider: "anthropic",
mode: "oauth",
email: "user@example.com",
});
expect(next.auth?.order?.anthropic).toEqual(["anthropic:user@example.com"]);
});
});

View File

@@ -22,30 +22,42 @@ import {
resolveProviderAuthLoginCommand,
} from "./provider-auth-guidance.js";
export async function maybeRepairAnthropicOAuthProfileId(
export async function maybeRepairLegacyOAuthProfileIds(
cfg: OpenClawConfig,
prompter: DoctorPrompter,
): Promise<OpenClawConfig> {
const store = ensureAuthProfileStore();
const repair = repairOAuthProfileIdMismatch({
cfg,
store,
provider: "anthropic",
legacyProfileId: "anthropic:default",
let nextCfg = cfg;
const providers = resolvePluginProviders({
config: cfg,
env: process.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
});
if (!repair.migrated || repair.changes.length === 0) {
return cfg;
}
for (const provider of providers) {
for (const repairSpec of provider.oauthProfileIdRepairs ?? []) {
const repair = repairOAuthProfileIdMismatch({
cfg: nextCfg,
store,
provider: provider.id,
legacyProfileId: repairSpec.legacyProfileId,
});
if (!repair.migrated || repair.changes.length === 0) {
continue;
}
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;
note(repair.changes.map((c) => `- ${c}`).join("\n"), "Auth profiles");
const apply = await prompter.confirm({
message: `Update ${repairSpec.promptLabel ?? provider.label} OAuth profile id in config now?`,
initialValue: true,
});
if (!apply) {
continue;
}
nextCfg = repair.config;
}
}
return repair.config;
return nextCfg;
}
function pruneAuthOrder(

View File

@@ -10,7 +10,7 @@ import {
import { formatCliCommand } from "../cli/command-format.js";
import {
maybeRemoveDeprecatedCliAuthProfiles,
maybeRepairAnthropicOAuthProfileId,
maybeRepairLegacyOAuthProfileIds,
noteAuthProfileHealth,
} from "../commands/doctor-auth.js";
import { noteBootstrapFileSize } from "../commands/doctor-bootstrap-size.js";
@@ -136,7 +136,7 @@ async function runGatewayConfigHealth(ctx: DoctorHealthFlowContext): Promise<voi
}
async function runAuthProfileHealth(ctx: DoctorHealthFlowContext): Promise<void> {
ctx.cfg = await maybeRepairAnthropicOAuthProfileId(ctx.cfg, ctx.prompter);
ctx.cfg = await maybeRepairLegacyOAuthProfileIds(ctx.cfg, ctx.prompter);
ctx.cfg = await maybeRemoveDeprecatedCliAuthProfiles(ctx.cfg, ctx.prompter);
await noteAuthProfileHealth({
cfg: ctx.cfg,

View File

@@ -41,6 +41,28 @@ function normalizeOnboardingScopes(
return normalized.length > 0 ? normalized : undefined;
}
function normalizeProviderOAuthProfileIdRepairs(
values: ProviderPlugin["oauthProfileIdRepairs"],
): ProviderPlugin["oauthProfileIdRepairs"] {
if (!Array.isArray(values)) {
return undefined;
}
const normalized = values
.map((value) => {
const legacyProfileId = normalizeText(value?.legacyProfileId);
const promptLabel = normalizeText(value?.promptLabel);
if (!legacyProfileId && !promptLabel) {
return null;
}
return {
...(legacyProfileId ? { legacyProfileId } : {}),
...(promptLabel ? { promptLabel } : {}),
};
})
.filter((value): value is NonNullable<typeof value> => value !== null);
return normalized.length > 0 ? normalized : undefined;
}
function normalizeProviderWizardSetup(params: {
providerId: string;
pluginId: string;
@@ -273,6 +295,9 @@ export function normalizeRegisteredProvider(params: {
const docsPath = normalizeText(params.provider.docsPath);
const aliases = normalizeTextList(params.provider.aliases);
const deprecatedProfileIds = normalizeTextList(params.provider.deprecatedProfileIds);
const oauthProfileIdRepairs = normalizeProviderOAuthProfileIdRepairs(
params.provider.oauthProfileIdRepairs,
);
const envVars = normalizeTextList(params.provider.envVars);
const wizard = normalizeProviderWizard({
providerId: id,
@@ -309,6 +334,7 @@ export function normalizeRegisteredProvider(params: {
...(docsPath ? { docsPath } : {}),
...(aliases ? { aliases } : {}),
...(deprecatedProfileIds ? { deprecatedProfileIds } : {}),
...(oauthProfileIdRepairs ? { oauthProfileIdRepairs } : {}),
...(envVars ? { envVars } : {}),
auth,
...(catalog ? { catalog } : {}),

View File

@@ -733,6 +733,21 @@ export type ProviderPluginWizard = {
modelPicker?: ProviderPluginWizardModelPicker;
};
export type ProviderOAuthProfileIdRepair = {
/**
* Legacy OAuth profile id to migrate away from.
*
* When omitted, OpenClaw falls back to `<provider>:default`.
*/
legacyProfileId?: string;
/**
* Optional custom doctor prompt label.
*
* Defaults to the provider label when omitted.
*/
promptLabel?: string;
};
export type ProviderModelSelectedContext = {
config: OpenClawConfig;
model: string;
@@ -989,6 +1004,14 @@ export type ProviderPlugin = {
* in hardcoded doctor tables.
*/
deprecatedProfileIds?: string[];
/**
* Legacy OAuth profile-id migrations that `openclaw doctor` should offer.
*
* Use this when a provider moved from a legacy default OAuth profile id to a
* newer identity-based id and wants doctor to own the config rewrite without
* another core-specific migration branch.
*/
oauthProfileIdRepairs?: ProviderOAuthProfileIdRepair[];
/**
* Provider-owned OAuth refresh.
*