refactor(doctor): unify legacy auth migration ownership (#117361)

This commit is contained in:
Peter Steinberger
2026-08-01 04:51:46 -07:00
committed by GitHub
parent 7bd7d7d2a1
commit 3777f009d0
9 changed files with 237 additions and 865 deletions
@@ -1,337 +1,152 @@
// Doctor auth alias tests cover canonical API-key profile repair and auth-profile store migration.
// Historical API-key aliases migrate directly into the receipted SQLite auth owner.
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js";
import { clearRuntimeAuthProfileStoreSnapshots } from "../agents/auth-profiles/store.js";
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import {
createOpenClawTestState,
type OpenClawTestState,
} from "../test-utils/openclaw-test-state.js";
import { maybeRepairCanonicalApiKeyFieldAlias } from "./doctor-auth-flat-profiles.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
import { maybeMigrateAuthProfileJsonStoresToSqlite } from "./doctor-auth-flat-profiles.js";
const states: OpenClawTestState[] = [];
function makePrompter(shouldRepair: boolean): DoctorPrompter {
return {
confirm: vi.fn(async () => shouldRepair),
confirmAutoFix: vi.fn(async () => shouldRepair),
confirmAggressiveAutoFix: vi.fn(async () => shouldRepair),
confirmRuntimeRepair: vi.fn(async () => shouldRepair),
select: vi.fn(async (_params, fallback) => fallback),
shouldRepair,
shouldForce: false,
repairMode: {
shouldRepair,
shouldForce: false,
nonInteractive: false,
canPrompt: true,
updateInProgress: false,
},
};
}
const secretRef = { source: "env", provider: "default", id: "MY_PROVIDER_API_KEY" } as const;
async function makeTestState(): Promise<OpenClawTestState> {
const state = await createOpenClawTestState({
layout: "state-only",
prefix: "openclaw-doctor-canonical-api-key-",
env: {
OPENCLAW_AGENT_DIR: undefined,
},
env: { OPENCLAW_AGENT_DIR: undefined, PI_CODING_AGENT_DIR: undefined },
});
states.push(state);
return state;
}
async function writeLegacyAuthProfilesJson(
async function writeProfiles(
state: OpenClawTestState,
value: unknown,
profile: Record<string, unknown>,
options: { agentDir?: string; order?: boolean } = {},
): Promise<string> {
return await state.writeText(
"agents/main/agent/auth-profiles.json",
`${JSON.stringify(value, null, 2)}\n`,
const authPath = path.join(options.agentDir ?? state.agentDir(), "auth-profiles.json");
fs.mkdirSync(path.dirname(authPath), { recursive: true });
fs.writeFileSync(
authPath,
`${JSON.stringify({
version: 1,
profiles: { "my-key": { type: "api_key", provider: "my-provider", ...profile } },
...(options.order ? { order: { "my-provider": ["my-key"] } } : {}),
})}\n`,
);
return authPath;
}
afterEach(async () => {
clearRuntimeAuthProfileStoreSnapshots();
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
for (const state of states.splice(0)) {
await state.cleanup();
}
});
describe("maybeRepairCanonicalApiKeyFieldAlias", () => {
it('rewrites the non-canonical "api_key" field to "key" with a backup (57389)', async () => {
describe("canonical SQLite migration for historical API-key aliases", () => {
it.each([
{
name: "inline snake-case key",
profile: { api_key: "fake-snake-case-key" },
expected: { key: "fake-snake-case-key" },
},
{
name: "snake-case SecretRef",
profile: { api_key: secretRef },
expected: { keyRef: secretRef },
},
{
name: "canonical inline key wins over the stale alias",
profile: { key: "fake-canonical-key", api_key: "fake-stale-key" },
expected: { key: "fake-canonical-key" },
},
{
name: "canonical keyRef wins over the stale alias",
profile: { keyRef: secretRef, api_key: "fake-stale-key" },
expected: { keyRef: secretRef },
},
{
name: "canonical inline SecretRef wins over the stale alias",
profile: { key: secretRef, api_key: "fake-stale-key" },
expected: { keyRef: secretRef },
},
])("preserves $name and archives untouched source bytes", async ({ profile, expected }) => {
const state = await makeTestState();
const canonical = {
version: 1,
profiles: {
"my-key": {
type: "api_key",
provider: "my-provider",
api_key: "sk-snake-case-key",
},
},
order: {
"my-provider": ["my-key"],
},
};
const authPath = await writeLegacyAuthProfilesJson(state, canonical);
const authPath = await writeProfiles(state, profile, { order: true });
const original = fs.readFileSync(authPath);
const prompter = { confirmAutoFix: vi.fn(async () => true) };
const result = await maybeRepairCanonicalApiKeyFieldAlias({
const result = await maybeMigrateAuthProfileJsonStoresToSqlite({
cfg: {},
prompter: makePrompter(true),
now: () => 123,
prompter,
env: state.env,
});
expect(result.detected).toEqual([authPath]);
expect(result.changes).toStrictEqual([
`Rewrote 1 "api_key" field(s) to "key" in ${authPath} (backup: ${authPath}.api-key-alias.123.bak).`,
]);
expect(result.warnings).toStrictEqual([]);
// After the fix: api_key is aliased to the canonical key, other fields untouched.
expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual({
version: 1,
profiles: {
"my-key": {
type: "api_key",
provider: "my-provider",
key: "sk-snake-case-key",
},
},
order: {
"my-provider": ["my-key"],
},
expect(result.warnings).toEqual([]);
expect(loadPersistedAuthProfileStore(state.agentDir())).toMatchObject({
profiles: { "my-key": { type: "api_key", provider: "my-provider", ...expected } },
order: { "my-provider": ["my-key"] },
});
// The backup preserves the original non-canonical shape.
expect(JSON.parse(fs.readFileSync(`${authPath}.api-key-alias.123.bak`, "utf8"))).toEqual(
canonical,
);
expect(fs.existsSync(authPath)).toBe(false);
const archive = fs
.readdirSync(path.dirname(authPath))
.find((entry) => entry.startsWith(`${path.basename(authPath)}.migrated-`));
expect(archive).toBeDefined();
expect(fs.readFileSync(path.join(path.dirname(authPath), archive!))).toEqual(original);
const rerun = await maybeMigrateAuthProfileJsonStoresToSqlite({
cfg: {},
prompter,
env: state.env,
});
expect(rerun).toEqual({ detected: [], changes: [], warnings: [] });
expect(prompter.confirmAutoFix).toHaveBeenCalledOnce();
});
it('rewrites non-canonical SecretRef "api_key" fields to canonical "key"', async () => {
it.each(["OPENCLAW_AGENT_DIR", "PI_CODING_AGENT_DIR"] as const)(
"migrates aliases from the shipped %s agent override",
async (agentDirVariable) => {
const state = await makeTestState();
const agentDir = state.path(`external-${agentDirVariable.toLowerCase()}`);
const authPath = await writeProfiles(state, { api_key: "fake-external-key" }, { agentDir });
const result = await maybeMigrateAuthProfileJsonStoresToSqlite({
cfg: {},
prompter: { confirmAutoFix: async () => true },
env: { ...state.env, [agentDirVariable]: agentDir },
});
expect(result.detected).toEqual([authPath]);
expect(loadPersistedAuthProfileStore(agentDir)?.profiles["my-key"]).toMatchObject({
key: "fake-external-key",
});
expect(fs.existsSync(authPath)).toBe(false);
},
);
it("leaves original credentials untouched when interactive repair is declined", async () => {
const state = await makeTestState();
const canonical = {
version: 1,
profiles: {
"my-key": {
type: "api_key",
provider: "my-provider",
api_key: { source: "env", provider: "default", id: "MY_PROVIDER_API_KEY" },
},
},
};
const authPath = await writeLegacyAuthProfilesJson(state, canonical);
const authPath = await writeProfiles(state, { api_key: "fake-declined-key" });
const original = fs.readFileSync(authPath);
const result = await maybeRepairCanonicalApiKeyFieldAlias({
const result = await maybeMigrateAuthProfileJsonStoresToSqlite({
cfg: {},
prompter: makePrompter(true),
now: () => 123,
prompter: { confirmAutoFix: async () => false },
env: state.env,
});
expect(result.detected).toEqual([authPath]);
expect(result.changes).toStrictEqual([
`Rewrote 1 "api_key" field(s) to "key" in ${authPath} (backup: ${authPath}.api-key-alias.123.bak).`,
]);
expect(result.warnings).toStrictEqual([]);
expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual({
version: 1,
profiles: {
"my-key": {
type: "api_key",
provider: "my-provider",
key: { source: "env", provider: "default", id: "MY_PROVIDER_API_KEY" },
},
},
});
expect(JSON.parse(fs.readFileSync(`${authPath}.api-key-alias.123.bak`, "utf8"))).toEqual(
canonical,
);
});
it("repairs auth profiles from OPENCLAW_AGENT_DIR", async () => {
const state = await makeTestState();
const agentDir = state.path("external-agent");
const authPath = path.join(agentDir, "auth-profiles.json");
const canonical = {
version: 1,
profiles: {
"my-key": {
type: "api_key",
provider: "my-provider",
api_key: "sk-snake-case-key",
},
},
};
fs.mkdirSync(agentDir, { recursive: true });
fs.writeFileSync(authPath, `${JSON.stringify(canonical, null, 2)}\n`, "utf8");
const result = await maybeRepairCanonicalApiKeyFieldAlias({
cfg: {},
prompter: makePrompter(true),
now: () => 123,
env: {
...state.env,
OPENCLAW_AGENT_DIR: agentDir,
},
});
expect(result.detected).toEqual([authPath]);
expect(result.changes).toStrictEqual([
`Rewrote 1 "api_key" field(s) to "key" in ${authPath} (backup: ${authPath}.api-key-alias.123.bak).`,
]);
expect(result.warnings).toStrictEqual([]);
expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual({
version: 1,
profiles: {
"my-key": {
type: "api_key",
provider: "my-provider",
key: "sk-snake-case-key",
},
},
});
});
it("repairs auth profiles from PI_CODING_AGENT_DIR", async () => {
const state = await makeTestState();
const agentDir = state.path("legacy-external-agent");
const authPath = path.join(agentDir, "auth-profiles.json");
const canonical = {
version: 1,
profiles: {
"my-key": {
type: "api_key",
provider: "my-provider",
api_key: "sk-snake-case-key",
},
},
};
fs.mkdirSync(agentDir, { recursive: true });
fs.writeFileSync(authPath, `${JSON.stringify(canonical, null, 2)}\n`, "utf8");
const result = await maybeRepairCanonicalApiKeyFieldAlias({
cfg: {},
prompter: makePrompter(true),
now: () => 123,
env: {
...state.env,
OPENCLAW_AGENT_DIR: undefined,
PI_CODING_AGENT_DIR: agentDir,
},
});
expect(result.detected).toEqual([authPath]);
expect(JSON.parse(fs.readFileSync(authPath, "utf8")).profiles["my-key"]).toEqual({
type: "api_key",
provider: "my-provider",
key: "sk-snake-case-key",
});
});
it('does not touch profiles that already have the canonical "key" field', async () => {
const state = await makeTestState();
const canonical = {
version: 1,
profiles: {
"good-key": {
type: "api_key",
provider: "my-provider",
key: "sk-already-canonical",
},
},
};
const authPath = await writeLegacyAuthProfilesJson(state, canonical);
const result = await maybeRepairCanonicalApiKeyFieldAlias({
cfg: {},
prompter: makePrompter(true),
now: () => 123,
});
expect(result.detected).toStrictEqual([]);
expect(result.changes).toStrictEqual([]);
expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual(canonical);
expect(fs.existsSync(`${authPath}.api-key-alias.123.bak`)).toBe(false);
});
it('does not replace canonical "keyRef" credentials with stale "api_key" fields', async () => {
const state = await makeTestState();
const canonical = {
version: 1,
profiles: {
"ref-key": {
type: "api_key",
provider: "my-provider",
keyRef: { source: "env", provider: "default", id: "MY_PROVIDER_API_KEY" },
api_key: "stale-inline-key",
},
},
};
const authPath = await writeLegacyAuthProfilesJson(state, canonical);
const result = await maybeRepairCanonicalApiKeyFieldAlias({
cfg: {},
prompter: makePrompter(true),
now: () => 123,
});
expect(result.detected).toStrictEqual([]);
expect(result.changes).toStrictEqual([]);
expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual(canonical);
expect(fs.existsSync(`${authPath}.api-key-alias.123.bak`)).toBe(false);
});
it('does not replace inline canonical SecretRef "key" credentials with stale "api_key" fields', async () => {
const state = await makeTestState();
const canonical = {
version: 1,
profiles: {
"inline-ref-key": {
type: "api_key",
provider: "my-provider",
key: { source: "env", provider: "default", id: "MY_PROVIDER_API_KEY" },
api_key: "stale-inline-key",
},
},
};
const authPath = await writeLegacyAuthProfilesJson(state, canonical);
const result = await maybeRepairCanonicalApiKeyFieldAlias({
cfg: {},
prompter: makePrompter(true),
now: () => 123,
});
expect(result.detected).toStrictEqual([]);
expect(result.changes).toStrictEqual([]);
expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual(canonical);
expect(fs.existsSync(`${authPath}.api-key-alias.123.bak`)).toBe(false);
});
it('reports the non-canonical "api_key" field without rewriting when repair is declined', async () => {
const state = await makeTestState();
const canonical = {
version: 1,
profiles: {
"my-key": {
type: "api_key",
provider: "my-provider",
api_key: "sk-snake-case-key",
},
},
};
const authPath = await writeLegacyAuthProfilesJson(state, canonical);
const result = await maybeRepairCanonicalApiKeyFieldAlias({
cfg: {},
prompter: makePrompter(false),
now: () => 123,
});
expect(result.detected).toEqual([authPath]);
expect(result.changes).toStrictEqual([]);
expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual(canonical);
expect(fs.existsSync(`${authPath}.api-key-alias.123.bak`)).toBe(false);
expect(result).toEqual({ detected: [authPath], changes: [], warnings: [] });
expect(fs.readFileSync(authPath)).toEqual(original);
expect(loadPersistedAuthProfileStore(state.agentDir())).toBeNull();
});
});
+32 -44
View File
@@ -26,9 +26,7 @@ import {
import {
collectOpenAICodexAuthProfileStoreIdMap,
maybeMigrateAuthProfileJsonStoresToSqlite,
maybeRepairLegacyFlatAuthProfileStores,
maybeRepairOpenAICodexAuthConfig,
maybeRepairOpenAICodexAuthProfileStores,
} from "./doctor-auth-flat-profiles.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
@@ -1308,8 +1306,8 @@ describe("maybeMigrateAuthProfileJsonStoresToSqlite", () => {
});
});
describe("maybeRepairLegacyFlatAuthProfileStores", () => {
it("migrates legacy flat auth-profiles.json stores with a backup", async () => {
describe("legacy flat profiles through the canonical auth migration owner", () => {
it("migrates legacy flat auth-profiles.json stores with a receipted archive", async () => {
const state = await makeTestState();
const legacy = {
"ollama-windows": {
@@ -1319,16 +1317,14 @@ describe("maybeRepairLegacyFlatAuthProfileStores", () => {
};
const authPath = await writeLegacyAuthProfilesJson(state, legacy);
const result = await maybeRepairLegacyFlatAuthProfileStores({
const result = await maybeMigrateAuthProfileJsonStoresToSqlite({
cfg: {},
prompter: makePrompter(true),
now: () => 123,
});
expect(result.detected).toEqual([authPath]);
expect(result.changes).toStrictEqual([
`Migrated ${authPath} to the SQLite auth profile store (backup: ${authPath}.legacy-flat.123.bak).`,
]);
expect(result.changes).toEqual([expect.stringContaining("Migrated auth profile JSON")]);
expect(result.warnings).toStrictEqual([]);
expect(loadPersistedAuthProfileStore(state.agentDir())).toEqual({
version: 1,
@@ -1341,7 +1337,8 @@ describe("maybeRepairLegacyFlatAuthProfileStores", () => {
},
});
expect(fs.existsSync(authPath)).toBe(false);
expect(JSON.parse(fs.readFileSync(`${authPath}.legacy-flat.123.bak`, "utf8"))).toEqual(legacy);
const [archive] = listMigratedArchives(authPath);
expect(JSON.parse(fs.readFileSync(archive!, "utf8"))).toEqual(legacy);
});
it("preserves existing SQLite auth profiles when migrating a legacy flat store", async () => {
@@ -1364,16 +1361,14 @@ describe("maybeRepairLegacyFlatAuthProfileStores", () => {
const legacy = { openai: { apiKey: "sk-openai-flat" } };
const authPath = await writeLegacyAuthProfilesJson(state, legacy);
const result = await maybeRepairLegacyFlatAuthProfileStores({
const result = await maybeMigrateAuthProfileJsonStoresToSqlite({
cfg: {},
prompter: makePrompter(true),
now: () => 123,
});
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toStrictEqual([
`Migrated ${authPath} to the SQLite auth profile store (backup: ${authPath}.legacy-flat.123.bak).`,
]);
expect(result.changes).toEqual([expect.stringContaining("Migrated auth profile JSON")]);
expect(loadPersistedAuthProfileStore(state.agentDir())?.profiles).toEqual({
"anthropic:default": {
type: "oauth",
@@ -1400,7 +1395,7 @@ describe("maybeRepairLegacyFlatAuthProfileStores", () => {
};
const authPath = await writeLegacyAuthProfilesJson(state, legacy);
const result = await maybeRepairLegacyFlatAuthProfileStores({
const result = await maybeMigrateAuthProfileJsonStoresToSqlite({
cfg: {},
prompter: makePrompter(false),
});
@@ -1430,15 +1425,16 @@ describe("maybeRepairLegacyFlatAuthProfileStores", () => {
const authPath = await writeLegacyAuthProfilesJson(state, legacy);
const cfg = {};
const result = await maybeRepairLegacyFlatAuthProfileStores({
const result = await maybeMigrateAuthProfileJsonStoresToSqlite({
cfg,
prompter: makePrompter(true),
now: () => 456,
});
expect(result.detected).toEqual([authPath]);
expect(result.changes).toStrictEqual([
`Moved aws-sdk profile metadata from ${authPath} to auth.profiles (backup: ${authPath}.aws-sdk-profile.456.bak).`,
expect(result.changes).toEqual([
expect.stringContaining("Migrated auth profile JSON"),
expect.stringContaining("Moved aws-sdk profile metadata"),
]);
expect(result.warnings).toStrictEqual([]);
expect(cfg).toEqual({
@@ -1451,19 +1447,16 @@ describe("maybeRepairLegacyFlatAuthProfileStores", () => {
},
},
});
expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual({
version: 1,
profiles: {
"openrouter:default": {
type: "api_key",
provider: "openrouter",
key: "sk-openrouter",
},
expect(loadPersistedAuthProfileStore(state.agentDir())?.profiles).toEqual({
"openrouter:default": {
type: "api_key",
provider: "openrouter",
key: "sk-openrouter",
},
});
expect(JSON.parse(fs.readFileSync(`${authPath}.aws-sdk-profile.456.bak`, "utf8"))).toEqual(
legacy,
);
expect(fs.existsSync(authPath)).toBe(false);
const [archive] = listMigratedArchives(authPath);
expect(JSON.parse(fs.readFileSync(archive!, "utf8"))).toEqual(legacy);
});
});
@@ -1780,7 +1773,7 @@ describe("maybeRepairOpenAICodexAuthConfig", () => {
});
});
describe("maybeRepairOpenAICodexAuthProfileStores", () => {
describe("legacy OpenAI auth profiles through the canonical migration owner", () => {
it("collects the store-derived legacy OpenAI Codex profile id map", async () => {
const state = await makeTestState();
await writeLegacyAuthProfilesJson(state, {
@@ -1811,7 +1804,7 @@ describe("maybeRepairOpenAICodexAuthProfileStores", () => {
).toEqual([["openai-codex:default", "openai:chatgpt-default"]]);
});
it("renames legacy OpenAI Codex auth store profiles with a backup", async () => {
it("renames legacy OpenAI Codex auth profiles while archiving the untouched source", async () => {
const state = await makeTestState();
const legacy = {
version: 1,
@@ -1839,18 +1832,20 @@ describe("maybeRepairOpenAICodexAuthProfileStores", () => {
};
const authPath = await writeLegacyAuthProfilesJson(state, legacy);
const result = await maybeRepairOpenAICodexAuthProfileStores({
const result = await maybeMigrateAuthProfileJsonStoresToSqlite({
cfg: {},
env: state.env,
prompter: makePrompter(true),
now: () => 789,
});
expect(result.detected).toEqual([authPath]);
expect(result.changes).toStrictEqual([
`Migrated 1 OpenAI Codex auth profile(s) in ${authPath} to provider "openai" (backup: ${authPath}.openai-provider-unification.789.bak).`,
expect(result.changes).toEqual([
expect.stringContaining("Migrated auth profile JSON"),
`Migrated 1 OpenAI Codex auth profile(s) in ${authPath} to provider "openai".`,
]);
expect(result.warnings).toStrictEqual([]);
expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual({
expect(loadPersistedAuthProfileStore(state.agentDir())).toEqual({
version: 1,
profiles: {
"openai:work": {
@@ -1874,9 +1869,9 @@ describe("maybeRepairOpenAICodexAuthProfileStores", () => {
},
},
});
expect(
JSON.parse(fs.readFileSync(`${authPath}.openai-provider-unification.789.bak`, "utf8")),
).toEqual(legacy);
expect(fs.existsSync(authPath)).toBe(false);
const [archive] = listMigratedArchives(authPath);
expect(JSON.parse(fs.readFileSync(archive!, "utf8"))).toEqual(legacy);
});
it("canonicalizes a mixed Codex store before importing it into SQLite", async () => {
@@ -1904,13 +1899,6 @@ describe("maybeRepairOpenAICodexAuthProfileStores", () => {
},
});
const providerRepair = await maybeRepairOpenAICodexAuthProfileStores({
cfg: {},
env: state.env,
now: () => 790,
});
expect(providerRepair.warnings).toStrictEqual([]);
const sqliteMigration = await maybeMigrateAuthProfileJsonStoresToSqlite({
cfg: {},
env: state.env,
+56 -452
View File
@@ -69,12 +69,6 @@ type AuthProfileRepairCandidate = {
authPath: string;
};
type LegacyFlatAuthProfileStore = {
agentDir?: string;
authPath: string;
store: AuthProfileStore;
};
type AuthProfileSqliteMigrationCandidate = AuthProfileRepairCandidate & {
statePath: string;
legacyPath: string;
@@ -334,16 +328,12 @@ function listAuthProfileSqliteMigrationCandidates(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
): AuthProfileSqliteMigrationCandidate[] {
const candidates: AuthProfileSqliteMigrationCandidate[] = [];
for (const candidate of listAuthProfileRepairCandidates(cfg, env)) {
candidates.push({
agentDir: candidate.agentDir,
authPath: candidate.authPath,
statePath: resolveAuthStatePath(candidate.agentDir),
legacyPath: resolveLegacyAuthStorePath(candidate.agentDir),
});
}
return candidates;
return listAuthProfileRepairCandidates(cfg, env).map((candidate) => ({
agentDir: candidate.agentDir,
authPath: candidate.authPath,
statePath: resolveAuthStatePath(candidate.agentDir),
legacyPath: resolveLegacyAuthStorePath(candidate.agentDir),
}));
}
function hasAuthProfileState(state: AuthProfileState): boolean {
@@ -363,7 +353,9 @@ function normalizeLegacyApiKeyAliasesForImport(raw: unknown): void {
continue;
}
const hasCanonicalCredential =
readNonEmptyString(profile.key) !== undefined || coerceSecretRef(profile.keyRef) !== null;
readNonEmptyString(profile.key) !== undefined ||
coerceSecretRef(profile.key) !== null ||
coerceSecretRef(profile.keyRef) !== null;
if (hasCanonicalCredential || profile["api_key"] === undefined) {
continue;
}
@@ -372,19 +364,13 @@ function normalizeLegacyApiKeyAliasesForImport(raw: unknown): void {
}
function collectAuthProfileStateProfileIds(state: AuthProfileState): string[] {
const profileIds = new Set<string>();
for (const entries of Object.values(state.order ?? {})) {
for (const profileId of entries) {
profileIds.add(profileId);
}
}
for (const profileId of Object.values(state.lastGood ?? {})) {
profileIds.add(profileId);
}
for (const profileId of Object.keys(state.usageStats ?? {})) {
profileIds.add(profileId);
}
return [...profileIds];
return [
...new Set([
...Object.values(state.order ?? {}).flat(),
...Object.values(state.lastGood ?? {}),
...Object.keys(state.usageStats ?? {}),
]),
];
}
function inferLegacyConfigAuthProfileMode(
@@ -569,45 +555,23 @@ function mergeImportedAuthProfileState(params: {
existingState: AuthProfileState;
}): AuthProfileStore {
// Preserve current SQLite state over imported JSON state; old files are backup-only after import.
return {
...params.store,
...(params.state.order
? {
order: {
...params.store.order,
...Object.fromEntries(
Object.entries(params.state.order).filter(
([provider]) => !params.existingState.order?.[provider],
),
),
},
}
: {}),
...(params.state.lastGood
? {
lastGood: {
...params.store.lastGood,
...Object.fromEntries(
Object.entries(params.state.lastGood).filter(
([provider]) => !params.existingState.lastGood?.[provider],
),
),
},
}
: {}),
...(params.state.usageStats
? {
usageStats: {
...params.store.usageStats,
...Object.fromEntries(
Object.entries(params.state.usageStats).filter(
([profileId]) => !params.existingState.usageStats?.[profileId],
),
),
},
}
: {}),
};
const next = { ...params.store };
for (const field of ["order", "lastGood", "usageStats"] as const) {
const incoming = params.state[field];
if (!incoming) {
continue;
}
const existing = params.existingState[field] ?? {};
Object.assign(next, {
[field]: {
...params.store[field],
...Object.fromEntries(
Object.entries(incoming).filter(([key]) => !Object.hasOwn(existing, key)),
),
},
});
}
return next;
}
function formatMissingAuthProfileSqliteVerification(params: {
@@ -1089,6 +1053,7 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: {
return result;
}
const openAIProfileIdMap = collectOpenAICodexAuthProfileStoreIdMap({ cfg: params.cfg, env });
for (const candidate of detected) {
let releaseSources: (() => void) | undefined;
try {
@@ -1098,41 +1063,16 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: {
}
releaseSources = acquireAuthProfileMigrationSourceLocks(candidateSourcePaths);
const targetDatabasePath = resolveAuthProfileDatabasePath(candidate.agentDir);
let sourceReceipts = [
...(fs.existsSync(candidate.authPath)
? [
prepareAuthProfileSourceReceipt({
pathname: candidate.authPath,
targetDatabasePath,
targetTable: "auth_profile_store",
now,
env,
}),
]
: []),
...(fs.existsSync(candidate.statePath)
? [
prepareAuthProfileSourceReceipt({
pathname: candidate.statePath,
targetDatabasePath,
targetTable: "auth_profile_state",
now,
env,
}),
]
: []),
...(fs.existsSync(candidate.legacyPath)
? [
prepareAuthProfileSourceReceipt({
pathname: candidate.legacyPath,
targetDatabasePath,
targetTable: "auth_profile_store",
now,
env,
}),
]
: []),
];
let sourceReceipts = candidateSourcePaths.filter(fs.existsSync).map((pathname) =>
prepareAuthProfileSourceReceipt({
pathname,
targetDatabasePath,
targetTable:
pathname === candidate.statePath ? "auth_profile_state" : "auth_profile_store",
now,
env,
}),
);
sourceReceipts = sourceReceipts.filter(
(receipt) => !archivePreviouslyMigratedAuthProfileSource(receipt, result),
);
@@ -1146,6 +1086,7 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: {
const rawStore = parseAuthProfileMigrationSource(
receiptByPath.get(path.resolve(candidate.authPath)),
);
const openAIProviderRepair = canonicalizeLegacyOpenAIAuthStore(rawStore, openAIProfileIdMap);
const unresolvedSidecarProfileIds = new Set(
collectUnresolvedLegacyOAuthSidecarProfileIds(rawStore),
);
@@ -1406,6 +1347,11 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: {
result.changes.push(
`Migrated auth profile JSON for ${shortenHomePath(candidate.authPath)} into SQLite (${archiveText}).`,
);
if (openAIProviderRepair !== null) {
result.changes.push(
`Migrated ${openAIProviderRepair} OpenAI Codex auth profile(s) in ${shortenHomePath(candidate.authPath)} to provider "openai".`,
);
}
if (awsSdkMarkerStore) {
result.changes.push(
`Moved aws-sdk profile metadata from ${shortenHomePath(candidate.authPath)} to auth.profiles before removing the legacy auth profile JSON.`,
@@ -1448,38 +1394,6 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: {
return result;
}
function resolveLegacyFlatStore(
candidate: AuthProfileRepairCandidate,
): LegacyFlatAuthProfileStore | null {
if (!fs.existsSync(candidate.authPath)) {
return null;
}
const raw = loadJsonFile(candidate.authPath);
if (!raw || typeof raw !== "object" || "profiles" in raw) {
return null;
}
const store = coerceLegacyFlatAuthProfileStore(raw);
if (!store || Object.keys(store.profiles).length === 0) {
return null;
}
return {
...candidate,
store,
};
}
function backupAuthProfileStore(authPath: string, now: () => number): string {
const backupPath = `${authPath}.legacy-flat.${now()}.bak`;
fs.copyFileSync(authPath, backupPath);
return backupPath;
}
function backupAwsSdkProfileMarkerStore(authPath: string, now: () => number): string {
const backupPath = `${authPath}.aws-sdk-profile.${now()}.bak`;
fs.copyFileSync(authPath, backupPath);
return backupPath;
}
function resolveAwsSdkAuthProfileMarkerStore(
candidate: AuthProfileRepairCandidate,
): AwsSdkAuthProfileMarkerStore | null {
@@ -1542,254 +1456,6 @@ function removeAwsSdkProfileMarkers(raw: Record<string, unknown>, profileIds: st
}
}
/**
* Rewrites pre-versioned flat auth profile JSON into canonical profile stores.
*
* Also lifts aws-sdk profile markers into config because those entries are routing metadata, not
* credentials, and the runtime no longer treats them as stored secrets.
*/
export async function maybeRepairLegacyFlatAuthProfileStores(params: {
cfg: OpenClawConfig;
prompter: DoctorPrompter;
now?: () => number;
env?: NodeJS.ProcessEnv;
}): Promise<LegacyFlatAuthProfileRepairResult> {
const now = params.now ?? Date.now;
const env = params.env ?? process.env;
const legacyStores = listAuthProfileRepairCandidates(params.cfg, env)
.map(resolveLegacyFlatStore)
.filter((entry): entry is LegacyFlatAuthProfileStore => entry !== null);
const awsSdkMarkerStores = listAuthProfileRepairCandidates(params.cfg, env)
.map(resolveAwsSdkAuthProfileMarkerStore)
.filter((entry): entry is AwsSdkAuthProfileMarkerStore => entry !== null);
const result: LegacyFlatAuthProfileRepairResult = {
detected: [
...legacyStores.map((entry) => entry.authPath),
...awsSdkMarkerStores.map((entry) => entry.authPath),
],
changes: [],
warnings: [],
};
if (legacyStores.length === 0 && awsSdkMarkerStores.length === 0) {
return result;
}
const noteLines = [
...legacyStores.map(
(entry) => `- ${shortenHomePath(entry.authPath)} uses the legacy flat auth profile format.`,
),
...awsSdkMarkerStores.map(
(entry) =>
`- ${shortenHomePath(entry.authPath)} contains aws-sdk profile markers that belong in openclaw.json auth.profiles.`,
),
];
if (legacyStores.length > 0) {
noteLines.push(
`- The gateway expects the canonical version/profiles store; ${formatCliCommand("openclaw doctor --fix")} rewrites this legacy shape with a backup.`,
);
}
if (awsSdkMarkerStores.length > 0) {
noteLines.push(
`- AWS SDK profile markers are routing metadata, not stored credentials; ${formatCliCommand("openclaw doctor --fix")} moves them to config with a backup.`,
);
}
note(noteLines.join("\n"), "Auth profiles");
const shouldRepair = await params.prompter.confirmAutoFix({
message: "Repair legacy auth-profiles.json files now?",
initialValue: true,
});
if (!shouldRepair) {
return result;
}
for (const entry of legacyStores) {
try {
const existing = loadPersistedAuthProfileStore(entry.agentDir) ?? {
version: AUTH_STORE_VERSION,
profiles: {},
};
const importedProfileIds = new Set(Object.keys(entry.store.profiles));
const merged = mergeImportedAuthProfiles({
store: { ...existing, version: Math.max(existing.version, entry.store.version) },
profiles: entry.store.profiles,
existingProfileIds: new Set(Object.keys(existing.profiles)),
});
const backupPath = backupAuthProfileStore(entry.authPath, now);
saveAuthProfileStore(merged, entry.agentDir, { syncExternalCli: false });
const verificationFailure = formatMissingAuthProfileSqliteVerification({
expected: merged,
importedProfileIds,
loaded: loadPersistedAuthProfileStore(entry.agentDir),
});
if (verificationFailure) {
result.warnings.push(
`Left auth profile JSON in place for ${shortenHomePath(entry.authPath)} because SQLite verification did not find ${verificationFailure}.`,
);
continue;
}
fs.unlinkSync(entry.authPath);
result.changes.push(
`Migrated ${shortenHomePath(entry.authPath)} to the SQLite auth profile store (backup: ${shortenHomePath(backupPath)}).`,
);
} catch (err) {
result.warnings.push(`Failed to rewrite ${shortenHomePath(entry.authPath)}: ${String(err)}`);
}
}
for (const entry of awsSdkMarkerStores) {
try {
const backupPath = backupAwsSdkProfileMarkerStore(entry.authPath, now);
const configProfiles = ensureConfigAuthProfiles(params.cfg);
for (const marker of entry.profiles) {
configProfiles[marker.profileId] = {
provider: marker.provider,
mode: "aws-sdk",
...(marker.email ? { email: marker.email } : {}),
...(marker.displayName ? { displayName: marker.displayName } : {}),
};
}
removeAwsSdkProfileMarkers(
entry.raw,
entry.profiles.map((profile) => profile.profileId),
);
fs.writeFileSync(entry.authPath, `${JSON.stringify(entry.raw, null, 2)}\n`);
result.changes.push(
`Moved aws-sdk profile metadata from ${shortenHomePath(entry.authPath)} to auth.profiles (backup: ${shortenHomePath(backupPath)}).`,
);
} catch (err) {
result.warnings.push(
`Failed to migrate aws-sdk profile markers from ${shortenHomePath(entry.authPath)}: ${String(err)}`,
);
}
}
clearRuntimeAuthProfileStoreSnapshots();
if (result.changes.length > 0) {
note(result.changes.map((change) => `- ${change}`).join("\n"), "Doctor changes");
}
if (result.warnings.length > 0) {
note(result.warnings.map((warning) => `- ${warning}`).join("\n"), "Doctor warnings");
}
return result;
}
type CanonicalApiKeyAliasRepair = {
authPath: string;
raw: Record<string, unknown>;
profileIds: string[];
};
function resolveCanonicalApiKeyAliasRepair(
candidate: AuthProfileRepairCandidate,
): CanonicalApiKeyAliasRepair | null {
if (!fs.existsSync(candidate.authPath)) {
return null;
}
const raw = loadJsonFile(candidate.authPath);
if (!isRecord(raw) || !isRecord(raw.profiles)) {
return null;
}
const profileIds: string[] = [];
for (const [profileId, value] of Object.entries(raw.profiles)) {
if (!isRecord(value)) {
continue;
}
const type = readNonEmptyString(value.type) ?? readNonEmptyString(value.mode);
const hasApiKeyField =
readNonEmptyString(value["api_key"]) !== undefined ||
coerceSecretRef(value["api_key"]) !== null;
const hasCanonicalKey =
readNonEmptyString(value.key) !== undefined || coerceSecretRef(value.key) !== null;
const hasCanonicalKeyRef = coerceSecretRef(value.keyRef) !== null;
if (type === "api_key" && hasApiKeyField && !hasCanonicalKey && !hasCanonicalKeyRef) {
profileIds.push(profileId);
}
}
return profileIds.length > 0 ? { authPath: candidate.authPath, raw, profileIds } : null;
}
function backupCanonicalApiKeyAlias(authPath: string, now: () => number): string {
const backupPath = `${authPath}.api-key-alias.${now()}.bak`;
fs.copyFileSync(authPath, backupPath);
return backupPath;
}
/**
* Repairs auth profile JSON that used the historical "api_key" credential field.
*
* Runtime parsing reads "key" or "keyRef"; doctor preserves the original file as a backup before
* moving the alias into the canonical key slot.
*/
export async function maybeRepairCanonicalApiKeyFieldAlias(params: {
cfg: OpenClawConfig;
prompter: DoctorPrompter;
now?: () => number;
env?: NodeJS.ProcessEnv;
}): Promise<LegacyFlatAuthProfileRepairResult> {
const now = params.now ?? Date.now;
const env = params.env ?? process.env;
const repairs = listAuthProfileRepairCandidates(params.cfg, env)
.map(resolveCanonicalApiKeyAliasRepair)
.filter((entry): entry is CanonicalApiKeyAliasRepair => entry !== null);
const result: LegacyFlatAuthProfileRepairResult = {
detected: repairs.map((entry) => entry.authPath),
changes: [],
warnings: [],
};
if (repairs.length === 0) {
return result;
}
const noteLines = repairs.map(
(entry) =>
`- ${shortenHomePath(entry.authPath)} has ${entry.profileIds.length} profile(s) using the non-canonical "api_key" field; the canonical field is "key".`,
);
noteLines.push(
`- Runtime auth parsing only reads canonical "key" and "keyRef" fields, so these profiles are silently skipped; ${formatCliCommand("openclaw doctor --fix")} rewrites "api_key" to "key" with a backup.`,
);
note(noteLines.join("\n"), "Auth profiles");
const shouldRepair = await params.prompter.confirmAutoFix({
message: 'Rewrite non-canonical "api_key" fields to "key" now?',
initialValue: true,
});
if (!shouldRepair) {
return result;
}
for (const entry of repairs) {
try {
const backupPath = backupCanonicalApiKeyAlias(entry.authPath, now);
const profiles = entry.raw.profiles as Record<string, Record<string, unknown>>;
for (const profileId of entry.profileIds) {
const profile = profiles[profileId];
if (!isRecord(profile)) {
continue;
}
profile.key = profile["api_key"];
delete profile["api_key"];
}
fs.writeFileSync(entry.authPath, `${JSON.stringify(entry.raw, null, 2)}\n`);
result.changes.push(
`Rewrote ${entry.profileIds.length} "api_key" field(s) to "key" in ${shortenHomePath(entry.authPath)} (backup: ${shortenHomePath(backupPath)}).`,
);
} catch (err) {
result.warnings.push(
`Failed to rewrite "api_key" fields in ${shortenHomePath(entry.authPath)}: ${String(err)}`,
);
}
}
clearRuntimeAuthProfileStoreSnapshots();
if (result.changes.length > 0) {
note(result.changes.map((change) => `- ${change}`).join("\n"), "Doctor changes");
}
if (result.warnings.length > 0) {
note(result.warnings.map((warning) => `- ${warning}`).join("\n"), "Doctor warnings");
}
return result;
}
const LEGACY_OPENAI_CODEX_PROVIDER_ID = "openai-codex";
const OPENAI_PROVIDER_ID = "openai";
@@ -2072,21 +1738,10 @@ export function maybeRepairOpenAICodexAuthConfig(
};
}
type OpenAICodexAuthStoreRepair = {
authPath: string;
raw: Record<string, unknown>;
profileIdMap: Map<string, string>;
changed: boolean;
};
function resolveOpenAICodexAuthStoreRepair(
candidate: AuthProfileRepairCandidate,
profileIdMap?: ReadonlyMap<string, string>,
): OpenAICodexAuthStoreRepair | null {
if (!fs.existsSync(candidate.authPath)) {
return null;
}
const raw = loadJsonFile(candidate.authPath);
function canonicalizeLegacyOpenAIAuthStore(
raw: unknown,
profileIdMap: ReadonlyMap<string, string>,
): number | null {
if (!isRecord(raw) || !isRecord(raw.profiles)) {
return null;
}
@@ -2101,14 +1756,8 @@ function resolveOpenAICodexAuthStoreRepair(
if (rewrite.profileIdMap.size > 0) {
replaceMappedProfileId(raw, rewrite.profileIdMap);
}
const changed = rewrite.changed || orderChanged || usageChanged || lastGoodChanged;
return changed
? {
authPath: candidate.authPath,
raw,
profileIdMap: rewrite.profileIdMap,
changed,
}
return rewrite.changed || orderChanged || usageChanged || lastGoodChanged
? rewrite.profileIdMap.size
: null;
}
@@ -2143,49 +1792,4 @@ export function collectOpenAICodexAuthProfileStoreIdMap(params: {
return profileIdMap;
}
function backupOpenAIProviderUnification(authPath: string, now: () => number): string {
const backupPath = `${authPath}.openai-provider-unification.${now()}.bak`;
fs.copyFileSync(authPath, backupPath);
return backupPath;
}
/**
* Rewrites legacy OpenAI Codex auth profiles in JSON stores to the canonical OpenAI provider id.
*/
export async function maybeRepairOpenAICodexAuthProfileStores(params: {
cfg: OpenClawConfig;
now?: () => number;
env?: NodeJS.ProcessEnv;
}): Promise<LegacyFlatAuthProfileRepairResult> {
const now = params.now ?? Date.now;
const env = params.env ?? process.env;
const profileIdMap = collectOpenAICodexAuthProfileStoreIdMap({ cfg: params.cfg, env });
const repairs = listAuthProfileRepairCandidates(params.cfg, env)
.map((candidate) => resolveOpenAICodexAuthStoreRepair(candidate, profileIdMap))
.filter((entry): entry is OpenAICodexAuthStoreRepair => entry !== null);
const result: LegacyFlatAuthProfileRepairResult = {
detected: repairs.map((entry) => entry.authPath),
changes: [],
warnings: [],
};
if (repairs.length === 0) {
return result;
}
for (const entry of repairs) {
try {
const backupPath = backupOpenAIProviderUnification(entry.authPath, now);
fs.writeFileSync(entry.authPath, `${JSON.stringify(entry.raw, null, 2)}\n`);
const movedCount = entry.profileIdMap.size;
result.changes.push(
`Migrated ${movedCount} OpenAI Codex auth profile(s) in ${shortenHomePath(entry.authPath)} to provider "openai" (backup: ${shortenHomePath(backupPath)}).`,
);
} catch (err) {
result.warnings.push(
`Failed to migrate OpenAI Codex auth profiles in ${shortenHomePath(entry.authPath)}: ${String(err)}`,
);
}
}
clearRuntimeAuthProfileStoreSnapshots();
return result;
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
+27 -41
View File
@@ -8,6 +8,10 @@ import {
getNodeSqliteKysely,
} from "../infra/kysely-sync.js";
import { openNodeSqliteDatabase } from "../infra/node-sqlite.js";
import {
recordLegacyMigrationRun,
recordLegacyMigrationSource,
} from "../infra/state-migrations.receipts.js";
import type { DB as OpenClawAgentKyselyDatabase } from "../state/openclaw-agent-db.generated.js";
import type { DB as OpenClawStateDatabase } from "../state/openclaw-state-db.generated.js";
import {
@@ -112,47 +116,29 @@ function recordAuthProfileMigrationImported(
`auth profile migration source already owned by ${existing.status} receipt`,
);
}
executeSqliteQuerySync(
db,
kysely
.insertInto("migration_runs")
.values({
id: receipt.runId,
started_at: now,
finished_at: null,
status: "imported",
report_json: reportJson(receipt),
})
.onConflict((conflict) => conflict.column("id").doNothing()),
);
executeSqliteQuerySync(
db,
kysely
.insertInto("migration_sources")
.values({
source_key: receipt.sourceKey,
migration_kind: MIGRATION_KIND,
source_path: receipt.sourcePath,
target_table: receipt.targetTable,
source_sha256: receipt.sourceSha256,
source_size_bytes: receipt.sourceSizeBytes,
source_record_count: receipt.sourceRecordCount,
last_run_id: receipt.runId,
status: "imported",
imported_at: now,
removed_source: 0,
report_json: reportJson(receipt),
})
.onConflict((conflict) =>
conflict.column("source_key").doUpdateSet({
last_run_id: receipt.runId,
status: "imported",
imported_at: now,
removed_source: 0,
report_json: reportJson(receipt),
}),
),
);
const report = reportJson(receipt);
recordLegacyMigrationRun(db, {
runId: receipt.runId,
startedAt: now,
finishedAt: null,
status: "imported",
reportJson: report,
upsert: true,
});
recordLegacyMigrationSource(db, {
sourceKey: receipt.sourceKey,
migrationKind: MIGRATION_KIND,
sourcePath: receipt.sourcePath,
targetTable: receipt.targetTable,
sourceSha256: receipt.sourceSha256,
sourceSizeBytes: receipt.sourceSizeBytes,
sourceRecordCount: receipt.sourceRecordCount,
runId: receipt.runId,
status: "imported",
importedAt: now,
reportJson: report,
upsert: true,
});
},
{ env: receipt.env },
);
-3
View File
@@ -10,14 +10,11 @@ vi.mock("./doctor-bootstrap-size.js", () => ({
}));
vi.mock("./doctor-auth-flat-profiles.js", () => ({
maybeRepairCanonicalApiKeyFieldAlias: vi.fn(async (params: { cfg: unknown }) => params.cfg),
maybeMigrateAuthProfileJsonStoresToSqlite: vi.fn().mockResolvedValue({
changes: [],
warnings: [],
}),
maybeRepairLegacyFlatAuthProfileStores: vi.fn().mockResolvedValue(undefined),
maybeRepairOpenAICodexAuthConfig: vi.fn((cfg: unknown) => cfg),
maybeRepairOpenAICodexAuthProfileStores: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./doctor-auth-legacy-oauth.js", () => ({
@@ -18,7 +18,6 @@ const mocks = vi.hoisted(() => ({
migrateLegacyOnboardingRecommendationsScope: vi.fn(),
maybeMigrateAuthProfileJsonStoresToSqlite: vi.fn(),
maybeRepairOpenAICodexAuthConfig: vi.fn(),
maybeRepairOpenAICodexAuthProfileStores: vi.fn(),
maybeRepairOpenPolicyAllowFrom: vi.fn(),
maybeRepairStaleManagedNpmBundledPlugins: vi.fn(),
maybeRepairStaleConfiguredAuthOrders: vi.fn(),
@@ -52,7 +51,6 @@ vi.mock("../doctor-auth-flat-profiles.js", () => ({
collectOpenAICodexAuthProfileStoreIdMap: vi.fn(() => new Map()),
maybeMigrateAuthProfileJsonStoresToSqlite: mocks.maybeMigrateAuthProfileJsonStoresToSqlite,
maybeRepairOpenAICodexAuthConfig: mocks.maybeRepairOpenAICodexAuthConfig,
maybeRepairOpenAICodexAuthProfileStores: mocks.maybeRepairOpenAICodexAuthProfileStores,
}));
vi.mock("./shared/missing-configured-plugin-install.js", () => ({
@@ -267,11 +265,6 @@ describe("doctor repair sequencing", () => {
config: cfg,
warnings: [],
}));
mocks.maybeRepairOpenAICodexAuthProfileStores.mockResolvedValue({
detected: [],
changes: [],
warnings: [],
});
mocks.maybeRepairOpenPolicyAllowFrom.mockImplementation((cfg: OpenClawConfig) => ({
config: cfg,
changes: [],
@@ -565,8 +558,8 @@ describe("doctor repair sequencing", () => {
expect(result.authProfilesRepaired).toBe(true);
});
it("reports auth profiles repaired after OpenAI Codex auth-provider migration", async () => {
mocks.maybeRepairOpenAICodexAuthProfileStores.mockResolvedValueOnce({
it("reports receipt-owned OpenAI auth-provider migration as an auth repair", async () => {
mocks.maybeMigrateAuthProfileJsonStoresToSqlite.mockResolvedValueOnce({
changes: ["Migrated OpenAI Codex auth-provider profile openai-codex."],
warnings: [],
});
-7
View File
@@ -9,7 +9,6 @@ import {
collectOpenAICodexAuthProfileStoreIdMap,
maybeMigrateAuthProfileJsonStoresToSqlite,
maybeRepairOpenAICodexAuthConfig,
maybeRepairOpenAICodexAuthProfileStores,
} from "../doctor-auth-flat-profiles.js";
import { maybeRepairLegacyOAuthSidecarProfiles } from "../doctor-auth-oauth-sidecar.js";
import {
@@ -244,11 +243,6 @@ export async function runDoctorRepairSequence(params: {
env,
});
appendRepairNotes(legacyOAuthSidecarRepair);
const openAIAuthProviderRepair = await maybeRepairOpenAICodexAuthProfileStores({
cfg: state.candidate,
env,
});
appendRepairNotes(openAIAuthProviderRepair);
const staleOAuthShadowRepair = await repairStaleOAuthProfileShadows({
cfg: state.candidate,
env,
@@ -277,7 +271,6 @@ export async function runDoctorRepairSequence(params: {
applyMutation(staleAuthOrderRepair);
const authProfilesRepaired =
legacyOAuthSidecarRepair.changes.length > 0 ||
openAIAuthProviderRepair.changes.length > 0 ||
staleOAuthShadowRepair.changes.length > 0 ||
authProfileSqliteMigration.changes.length > 0;
+10 -11
View File
@@ -22,8 +22,7 @@ const mocks = vi.hoisted(() => ({
maybeRunConfiguredPluginInstallReleaseStep: vi.fn(),
registerBundledHealthChecks: vi.fn(),
runDoctorHealthRepairs: vi.fn(),
maybeRepairLegacyFlatAuthProfileStores: vi.fn().mockResolvedValue(undefined),
maybeRepairCanonicalApiKeyFieldAlias: vi.fn().mockResolvedValue(undefined),
maybeMigrateAuthProfileJsonStoresToSqlite: vi.fn().mockResolvedValue(undefined),
maybeMigrateLegacyPluginModelCatalogs: vi.fn().mockResolvedValue({
detected: 0,
migrated: 0,
@@ -219,8 +218,7 @@ vi.mock("../commands/doctor-gateway-services.js", () => ({
}));
vi.mock("../commands/doctor-auth-flat-profiles.js", () => ({
maybeRepairLegacyFlatAuthProfileStores: mocks.maybeRepairLegacyFlatAuthProfileStores,
maybeRepairCanonicalApiKeyFieldAlias: mocks.maybeRepairCanonicalApiKeyFieldAlias,
maybeMigrateAuthProfileJsonStoresToSqlite: mocks.maybeMigrateAuthProfileJsonStoresToSqlite,
}));
vi.mock("../commands/doctor-plugin-model-catalog.js", () => ({
@@ -566,10 +564,8 @@ describe("doctor health contributions", () => {
mocks.maybeRunConfiguredPluginInstallReleaseStep.mockReset();
mocks.registerBundledHealthChecks.mockReset();
mocks.runDoctorHealthRepairs.mockReset();
mocks.maybeRepairLegacyFlatAuthProfileStores.mockClear();
mocks.maybeRepairLegacyFlatAuthProfileStores.mockResolvedValue(undefined);
mocks.maybeRepairCanonicalApiKeyFieldAlias.mockClear();
mocks.maybeRepairCanonicalApiKeyFieldAlias.mockResolvedValue(undefined);
mocks.maybeMigrateAuthProfileJsonStoresToSqlite.mockClear();
mocks.maybeMigrateAuthProfileJsonStoresToSqlite.mockResolvedValue(undefined);
mocks.maybeMigrateLegacyPluginModelCatalogs.mockClear();
mocks.maybeMigrateLegacyPluginModelCatalogs.mockResolvedValue({
detected: 0,
@@ -1845,7 +1841,7 @@ describe("doctor health contributions", () => {
);
});
it("keeps canonical api_key alias repair wired through auth profile health", async () => {
it("runs the receipted auth migration after repairing OAuth sidecars", async () => {
const contribution = requireDoctorContribution("doctor:auth-profiles");
const ctx = {
cfg: {},
@@ -1857,14 +1853,17 @@ describe("doctor health contributions", () => {
await contribution.run(ctx);
expect(mocks.maybeRepairLegacyFlatAuthProfileStores).toHaveBeenCalledWith({
expect(mocks.maybeRepairLegacyOAuthSidecarProfiles).toHaveBeenCalledWith({
cfg: ctx.cfg,
prompter: ctx.prompter,
});
expect(mocks.maybeRepairCanonicalApiKeyFieldAlias).toHaveBeenCalledWith({
expect(mocks.maybeMigrateAuthProfileJsonStoresToSqlite).toHaveBeenCalledWith({
cfg: ctx.cfg,
prompter: ctx.prompter,
});
expect(mocks.maybeRepairLegacyOAuthSidecarProfiles.mock.invocationCallOrder[0]).toBeLessThan(
mocks.maybeMigrateAuthProfileJsonStoresToSqlite.mock.invocationCallOrder[0]!,
);
expect(mocks.maybeMigrateLegacyPluginModelCatalogs).toHaveBeenCalledWith({
cfg: ctx.cfg,
prompter: ctx.prompter,
+6 -9
View File
@@ -54,7 +54,7 @@ async function runGatewayConfigHealth(ctx: DoctorHealthFlowContext): Promise<voi
}
async function runAuthProfileHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { maybeRepairLegacyFlatAuthProfileStores, maybeRepairCanonicalApiKeyFieldAlias } =
const { maybeMigrateAuthProfileJsonStoresToSqlite } =
await import("../commands/doctor-auth-flat-profiles.js");
const { maybeRepairLegacyOAuthProfileIds } =
await import("../commands/doctor-auth-legacy-oauth.js");
@@ -66,18 +66,15 @@ async function runAuthProfileHealth(ctx: DoctorHealthFlowContext): Promise<void>
await import("../commands/doctor-auth.js");
const { buildGatewayConnectionDetails } = await import("../gateway/call.js");
const { note } = await loadNoteModule();
await maybeRepairLegacyFlatAuthProfileStores({
cfg: ctx.cfg,
prompter: ctx.prompter,
});
await maybeRepairCanonicalApiKeyFieldAlias({
cfg: ctx.cfg,
prompter: ctx.prompter,
});
await maybeRepairLegacyOAuthSidecarProfiles({
cfg: ctx.cfg,
prompter: ctx.prompter,
});
await maybeMigrateAuthProfileJsonStoresToSqlite({
cfg: ctx.cfg,
prompter: ctx.prompter,
...(ctx.env ? { env: ctx.env } : {}),
});
await maybeMigrateLegacyPluginModelCatalogs({
cfg: ctx.cfg,
...(ctx.env ? { env: ctx.env } : {}),