diff --git a/src/commands/doctor-auth-canonical-api-key-alias.test.ts b/src/commands/doctor-auth-canonical-api-key-alias.test.ts index b0ae47c94922..62f2fedf796b 100644 --- a/src/commands/doctor-auth-canonical-api-key-alias.test.ts +++ b/src/commands/doctor-auth-canonical-api-key-alias.test.ts @@ -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 { 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, + options: { agentDir?: string; order?: boolean } = {}, ): Promise { - 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(); }); }); diff --git a/src/commands/doctor-auth-flat-profiles.test.ts b/src/commands/doctor-auth-flat-profiles.test.ts index 3e82dca77ffb..94885740c515 100644 --- a/src/commands/doctor-auth-flat-profiles.test.ts +++ b/src/commands/doctor-auth-flat-profiles.test.ts @@ -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, diff --git a/src/commands/doctor-auth-flat-profiles.ts b/src/commands/doctor-auth-flat-profiles.ts index 907279e83dc4..b70b1210cca3 100644 --- a/src/commands/doctor-auth-flat-profiles.ts +++ b/src/commands/doctor-auth-flat-profiles.ts @@ -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(); - 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, 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 { - 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; - 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 { - 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>; - 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; - profileIdMap: Map; - changed: boolean; -}; - -function resolveOpenAICodexAuthStoreRepair( - candidate: AuthProfileRepairCandidate, - profileIdMap?: ReadonlyMap, -): OpenAICodexAuthStoreRepair | null { - if (!fs.existsSync(candidate.authPath)) { - return null; - } - const raw = loadJsonFile(candidate.authPath); +function canonicalizeLegacyOpenAIAuthStore( + raw: unknown, + profileIdMap: ReadonlyMap, +): 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 { - 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. */ diff --git a/src/commands/doctor-auth-migration-receipts.ts b/src/commands/doctor-auth-migration-receipts.ts index 57c193e52747..96641b1c5058 100644 --- a/src/commands/doctor-auth-migration-receipts.ts +++ b/src/commands/doctor-auth-migration-receipts.ts @@ -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 }, ); diff --git a/src/commands/doctor.fast-path-mocks.ts b/src/commands/doctor.fast-path-mocks.ts index 84d4ccf3201e..7d16da212724 100644 --- a/src/commands/doctor.fast-path-mocks.ts +++ b/src/commands/doctor.fast-path-mocks.ts @@ -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", () => ({ diff --git a/src/commands/doctor/repair-sequencing.test.ts b/src/commands/doctor/repair-sequencing.test.ts index 3931cee5df88..efcd44bca28e 100644 --- a/src/commands/doctor/repair-sequencing.test.ts +++ b/src/commands/doctor/repair-sequencing.test.ts @@ -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: [], }); diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index d31885308bff..649784efe550 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -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; diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index 83842156716a..49b6be71fdf3 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -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, diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index a52de196b010..765172ee378b 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -54,7 +54,7 @@ async function runGatewayConfigHealth(ctx: DoctorHealthFlowContext): Promise { - 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 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 } : {}),