From f018945eff80945d4319b6076cac02a0af1dc089 Mon Sep 17 00:00:00 2001 From: pick-cat Date: Tue, 16 Jun 2026 05:42:51 +0800 Subject: [PATCH] fix(doctor): import default-agent auth profiles into sqlite (#93156) * fix(doctor): import default-agent auth profiles into sqlite * fix(doctor): type legacy config auth imports * fix(doctor): preserve auth json precedence * fix(doctor): import config auth with state-only stores Signed-off-by: sallyom --------- Signed-off-by: sallyom Co-authored-by: sallyom --- .../doctor-auth-flat-profiles.test.ts | 407 ++++++++++++++++++ src/commands/doctor-auth-flat-profiles.ts | 323 +++++++++++++- 2 files changed, 721 insertions(+), 9 deletions(-) diff --git a/src/commands/doctor-auth-flat-profiles.test.ts b/src/commands/doctor-auth-flat-profiles.test.ts index 440b15a9e4c..d37a7483f3d 100644 --- a/src/commands/doctor-auth-flat-profiles.test.ts +++ b/src/commands/doctor-auth-flat-profiles.test.ts @@ -442,6 +442,413 @@ describe("maybeMigrateAuthProfileJsonStoresToSqlite", () => { expect(fs.existsSync(authPath)).toBe(true); expect(fs.existsSync(`${authPath}.sqlite-import.464.bak`)).toBe(false); }); + + it("imports default-agent config auth profiles into sqlite when no legacy files exist", async () => { + const state = await makeTestState(); + const cfg = { + auth: { + profiles: { + "openai:default": { + provider: "openai", + mode: "api_key", + key: "sk-config", + }, + "anthropic:default": { + provider: "anthropic", + mode: "token", + token: { + source: "env", + provider: "default", + id: "ANTHROPIC_TOKEN", + }, + }, + "router:default": { + provider: "router", + mode: "api_key", + displayName: "routing only", + }, + }, + order: { + openai: ["openai:default"], + anthropic: ["anthropic:default"], + }, + }, + } as OpenClawConfig; + + const result = await maybeMigrateAuthProfileJsonStoresToSqlite({ + cfg, + prompter: makePrompter(true), + now: () => 465, + }); + + const authPath = `${state.agentDir()}/auth-profiles.json`; + expect(result.detected).toEqual([authPath]); + expect(result.configChanged).toBe(true); + expect(result.warnings).toStrictEqual([]); + expect(cfg.auth?.profiles?.["openai:default"]).toEqual({ + provider: "openai", + mode: "api_key", + }); + expect(cfg.auth?.profiles?.["anthropic:default"]).toEqual({ + provider: "anthropic", + mode: "token", + }); + expect(cfg.auth?.profiles?.["router:default"]).toEqual({ + provider: "router", + mode: "api_key", + displayName: "routing only", + }); + expect(cfg.auth?.order).toEqual({ + openai: ["openai:default"], + anthropic: ["anthropic:default"], + }); + expect(loadPersistedAuthProfileStore(state.agentDir())).toMatchObject({ + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + key: "sk-config", + }, + "anthropic:default": { + type: "token", + provider: "anthropic", + tokenRef: { + source: "env", + provider: "default", + id: "ANTHROPIC_TOKEN", + }, + }, + }, + }); + expect( + loadPersistedAuthProfileStore(state.agentDir())?.profiles["router:default"], + ).toBeUndefined(); + expect(loadPersistedAuthProfileStore(state.agentDir())?.order).toBeUndefined(); + expect(fs.existsSync(authPath)).toBe(false); + expect(fs.existsSync(`${authPath}.sqlite-import.465.bak`)).toBe(false); + }); + + it("imports default-agent config auth profiles when only legacy state exists", async () => { + const state = await makeTestState(); + const statePath = await state.writeText( + "agents/main/agent/auth-state.json", + `${JSON.stringify({ + version: 1, + order: { openai: ["openai:default"] }, + lastGood: { openai: "openai:default" }, + })}\n`, + ); + const cfg = { + auth: { + profiles: { + "openai:default": { + provider: "openai", + mode: "api_key", + key: "sk-config", + }, + }, + }, + } as OpenClawConfig; + + const result = await maybeMigrateAuthProfileJsonStoresToSqlite({ + cfg, + prompter: makePrompter(true), + now: () => 467, + }); + + const authPath = `${state.agentDir()}/auth-profiles.json`; + expect(result.detected.toSorted()).toEqual([authPath, statePath].toSorted()); + expect(result.configChanged).toBe(true); + expect(result.warnings).toStrictEqual([]); + expect(cfg.auth?.profiles?.["openai:default"]).toEqual({ + provider: "openai", + mode: "api_key", + }); + expect(loadPersistedAuthProfileStore(state.agentDir())).toMatchObject({ + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + key: "sk-config", + }, + }, + order: { openai: ["openai:default"] }, + lastGood: { openai: "openai:default" }, + }); + expect(fs.existsSync(statePath)).toBe(false); + expect(fs.existsSync(`${statePath}.sqlite-import.467.bak`)).toBe(true); + expect(fs.existsSync(authPath)).toBe(false); + expect(fs.existsSync(`${authPath}.sqlite-import.467.bak`)).toBe(false); + }); + + it("infers config credential provider and mode before stripping config", async () => { + const cases: Array<{ profileId: string; cfg: OpenClawConfig; now: number }> = [ + { + profileId: "openai:default", + cfg: { + auth: { profiles: { "openai:default": { key: "sk-config" } } }, + } as unknown as OpenClawConfig, + now: 468, + }, + { + profileId: "work", + cfg: { + auth: { profiles: { work: { key: "sk-config" } } }, + agents: { defaults: { model: { primary: "openai/gpt-5.5@work" } } }, + } as unknown as OpenClawConfig, + now: 470, + }, + { + profileId: "ordered", + cfg: { + auth: { + profiles: { ordered: { key: "sk-config" } }, + order: { openai: ["ordered"] }, + }, + } as unknown as OpenClawConfig, + now: 474, + }, + ]; + + for (const entry of cases) { + const state = await makeTestState(); + const result = await maybeMigrateAuthProfileJsonStoresToSqlite({ + cfg: entry.cfg, + prompter: makePrompter(true), + now: () => entry.now, + }); + + const authPath = `${state.agentDir()}/auth-profiles.json`; + expect(result.detected).toEqual([authPath]); + expect(result.configChanged).toBe(true); + expect(result.warnings).toStrictEqual([]); + expect(entry.cfg.auth?.profiles?.[entry.profileId]).toEqual({ + provider: "openai", + mode: "api_key", + }); + expect(loadPersistedAuthProfileStore(state.agentDir())?.profiles[entry.profileId]).toEqual({ + type: "api_key", + provider: "openai", + key: "sk-config", + }); + expect(fs.existsSync(authPath)).toBe(false); + expect(fs.existsSync(`${authPath}.sqlite-import.${entry.now}.bak`)).toBe(false); + } + }); + + it("imports missing config credentials while preserving legacy JSON precedence", async () => { + const state = await makeTestState(); + const authPath = await writeLegacyAuthProfilesJson(state, { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + key: "sk-json", + }, + "openai:work": { + type: "api_key", + provider: "openai", + key: "sk-work", + }, + }, + order: { + openai: ["openai:work"], + }, + }); + const cfg = { + auth: { + profiles: { + "openai:default": { + provider: "openai", + mode: "api_key", + key: "sk-config", + }, + "anthropic:default": { + provider: "anthropic", + mode: "api_key", + key: "sk-anthropic", + }, + }, + order: { + openai: ["openai:default"], + }, + }, + } as OpenClawConfig; + + const result = await maybeMigrateAuthProfileJsonStoresToSqlite({ + cfg, + prompter: makePrompter(true), + now: () => 471, + }); + + expect(result.detected).toEqual([authPath]); + expect(result.configChanged).toBe(true); + expect(result.warnings).toStrictEqual([]); + expect(loadPersistedAuthProfileStore(state.agentDir())?.profiles).toMatchObject({ + "openai:default": { + type: "api_key", + provider: "openai", + key: "sk-json", + }, + "openai:work": { + type: "api_key", + provider: "openai", + key: "sk-work", + }, + "anthropic:default": { + type: "api_key", + provider: "anthropic", + key: "sk-anthropic", + }, + }); + expect(cfg.auth?.profiles?.["openai:default"]).toEqual({ + provider: "openai", + mode: "api_key", + }); + expect(cfg.auth?.profiles?.["anthropic:default"]).toEqual({ + provider: "anthropic", + mode: "api_key", + }); + expect(loadPersistedAuthProfileStore(state.agentDir())).toMatchObject({ + order: { + openai: ["openai:work"], + }, + }); + expect(fs.existsSync(authPath)).toBe(false); + expect(fs.existsSync(`${authPath}.sqlite-import.471.bak`)).toBe(true); + }); + + it("imports default-agent config api key alias SecretRefs as key refs", async () => { + const cases = [ + { + profileId: "openai:api-key-object", + profile: { + provider: "openai", + apiKey: { + source: "env", + provider: "default", + id: "OPENAI_API_KEY", + }, + }, + }, + { + profileId: "openai:api-key-template", + profile: { + provider: "openai", + mode: "api_key", + apiKey: "${OPENAI_API_KEY}", + }, + }, + { + profileId: "openai:api-key-legacy-field", + profile: { + provider: "openai", + api_key: { + source: "env", + provider: "default", + id: "OPENAI_API_KEY", + }, + }, + }, + ]; + + for (const entry of cases) { + const state = await makeTestState(); + const cfg = { + auth: { + profiles: { + [entry.profileId]: entry.profile, + }, + }, + } as unknown as OpenClawConfig; + + const result = await maybeMigrateAuthProfileJsonStoresToSqlite({ + cfg, + prompter: makePrompter(true), + now: () => 473, + }); + + expect(result.configChanged).toBe(true); + expect(result.warnings).toStrictEqual([]); + expect(cfg.auth?.profiles?.[entry.profileId]).toEqual({ + provider: "openai", + mode: "api_key", + }); + expect(loadPersistedAuthProfileStore(state.agentDir())?.profiles[entry.profileId]).toEqual({ + type: "api_key", + provider: "openai", + keyRef: { + source: "env", + provider: "default", + id: "OPENAI_API_KEY", + }, + }); + } + }); + + it("uses config credentials only when same-id sqlite credentials are incomplete", async () => { + const cases = [ + { + existing: { + type: "api_key" as const, + provider: "openai", + key: "sk-sqlite", + }, + expectedKey: "sk-sqlite", + }, + { + existing: { + type: "api_key" as const, + provider: "openai", + }, + expectedKey: "sk-config", + }, + ]; + + for (const entry of cases) { + const state = await makeTestState(); + saveAuthProfileStore( + { + version: 1, + profiles: { + "openai:default": entry.existing, + }, + }, + state.agentDir(), + { syncExternalCli: false }, + ); + const cfg = { + auth: { + profiles: { + "openai:default": { + provider: "openai", + mode: "api_key", + key: "sk-config", + }, + }, + }, + } as OpenClawConfig; + + const result = await maybeMigrateAuthProfileJsonStoresToSqlite({ + cfg, + prompter: makePrompter(true), + now: () => 469, + }); + + expect(result.configChanged).toBe(true); + expect(result.warnings).toStrictEqual([]); + expect(cfg.auth?.profiles?.["openai:default"]).toEqual({ + provider: "openai", + mode: "api_key", + }); + expect(loadPersistedAuthProfileStore(state.agentDir())?.profiles["openai:default"]).toEqual({ + type: "api_key", + provider: "openai", + key: entry.expectedKey, + }); + } + }); }); describe("maybeRepairLegacyFlatAuthProfileStores", () => { diff --git a/src/commands/doctor-auth-flat-profiles.ts b/src/commands/doctor-auth-flat-profiles.ts index e2d37d6f337..04e174c1eaa 100644 --- a/src/commands/doctor-auth-flat-profiles.ts +++ b/src/commands/doctor-auth-flat-profiles.ts @@ -1,6 +1,7 @@ /** Doctor repairs for legacy auth profile JSON stores and OpenAI provider-id migrations. */ import fs from "node:fs"; import path from "node:path"; +import { collectConfiguredModelRefs } from "@openclaw/model-catalog-core/configured-model-refs"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { note } from "../../packages/terminal-core/src/note.js"; import { resolveAgentDir, resolveDefaultAgentDir, listAgentIds } from "../agents/agent-scope.js"; @@ -26,6 +27,7 @@ import type { AuthProfileState, AuthProfileStore, } from "../agents/auth-profiles/types.js"; +import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; import { formatCliCommand } from "../cli/command-format.js"; import { resolveStateDir } from "../config/paths.js"; import type { AuthProfileConfig } from "../config/types.auth.js"; @@ -65,6 +67,12 @@ type AwsSdkAuthProfileMarkerStore = { profiles: AwsSdkProfileMarker[]; }; +type RawAuthProfileImportStore = { + version: number; + profiles: Record>; + order?: Record; +}; + export type LegacyFlatAuthProfileRepairResult = { detected: string[]; changes: string[]; @@ -90,6 +98,86 @@ function extractProviderFromProfileId(profileId: string): string | undefined { return readNonEmptyString(profileId.slice(0, colon)); } +function extractProviderFromModelRef(modelRef: string): string | undefined { + const { model } = splitTrailingAuthProfile(modelRef); + const slash = model.indexOf("/"); + if (slash <= 0) { + return undefined; + } + return readNonEmptyString(model.slice(0, slash)); +} + +function collectLegacyConfigAuthProfileProviderHints( + cfg: OpenClawConfig, +): ReadonlyMap { + const hints = new Map(); + const conflicted = new Set(); + const addHint = (profileId: string, provider: string): void => { + const existing = hints.get(profileId); + if (existing && existing !== provider) { + hints.delete(profileId); + conflicted.add(profileId); + return; + } + if (!conflicted.has(profileId)) { + hints.set(profileId, provider); + } + }; + const addModelHints = (models: unknown): void => { + if (!isRecord(models)) { + return; + } + for (const [modelRef, rawModel] of Object.entries(models)) { + const provider = extractProviderFromModelRef(modelRef); + if (!provider || !isSafeLegacyProviderKey(provider) || !isRecord(rawModel)) { + continue; + } + const agentRuntime = isRecord(rawModel.agentRuntime) ? rawModel.agentRuntime : null; + const authProfileId = agentRuntime + ? readNonEmptyString(agentRuntime.authProfileId) + : undefined; + if (authProfileId) { + addHint(authProfileId, provider); + } + } + }; + + for (const { value } of collectConfiguredModelRefs(cfg)) { + const { profile } = splitTrailingAuthProfile(value); + const provider = extractProviderFromModelRef(value); + if (profile && provider && isSafeLegacyProviderKey(provider)) { + addHint(profile, provider); + } + } + + const root: Record = cfg; + const auth = isRecord(root.auth) ? root.auth : null; + const order = auth && isRecord(auth.order) ? auth.order : null; + if (order) { + for (const [provider, profileIds] of Object.entries(order)) { + if (!isSafeLegacyProviderKey(provider) || !Array.isArray(profileIds)) { + continue; + } + for (const profileId of profileIds) { + const normalizedProfileId = readNonEmptyString(profileId); + if (normalizedProfileId) { + addHint(normalizedProfileId, provider); + } + } + } + } + const agents = isRecord(root.agents) ? root.agents : null; + const defaults = agents && isRecord(agents.defaults) ? agents.defaults : null; + addModelHints(defaults?.models); + const agentList = agents && Array.isArray(agents.list) ? agents.list : []; + for (const agent of agentList) { + if (isRecord(agent)) { + addModelHints(agent.models); + } + } + return hints; +} + function inferLegacyCredentialType( record: Record, ): AuthProfileCredential["type"] | undefined { @@ -293,15 +381,177 @@ function collectAuthProfileStateProfileIds(state: AuthProfileState): string[] { return [...profileIds]; } +function inferLegacyConfigAuthProfileMode( + raw: Record, +): AuthProfileCredential["type"] | undefined { + const explicit = readNonEmptyString(raw.mode) ?? readNonEmptyString(raw.type); + if (explicit === "api_key" || explicit === "token" || explicit === "oauth") { + return explicit; + } + if ( + readNonEmptyString(raw.key) || + readNonEmptyString(raw.apiKey) || + readNonEmptyString(raw["api_key"]) || + coerceSecretRef(raw.keyRef) || + coerceSecretRef(raw.key) || + coerceSecretRef(raw.apiKey) || + coerceSecretRef(raw["api_key"]) + ) { + return "api_key"; + } + if ( + readNonEmptyString(raw.token) || + coerceSecretRef(raw.tokenRef) || + coerceSecretRef(raw.token) + ) { + return "token"; + } + if ( + readNonEmptyString(raw.access) && + readNonEmptyString(raw.refresh) && + typeof raw.expires === "number" + ) { + return "oauth"; + } + return undefined; +} + +function coerceLegacyConfigAuthProfileStore(cfg: OpenClawConfig): AuthProfileStore | null { + const cfgRecord: Record = cfg; + const auth = isRecord(cfgRecord.auth) ? cfgRecord.auth : null; + const profiles = auth && isRecord(auth.profiles) ? auth.profiles : null; + if (!profiles) { + return null; + } + const providerHints = collectLegacyConfigAuthProfileProviderHints(cfg); + const store: RawAuthProfileImportStore = { version: AUTH_STORE_VERSION, profiles: {} }; + for (const [profileId, raw] of Object.entries(profiles)) { + if (!isRecord(raw)) { + continue; + } + const mode = inferLegacyConfigAuthProfileMode(raw); + if (mode !== "api_key" && mode !== "token" && mode !== "oauth") { + continue; + } + const provider = + readNonEmptyString(raw.provider) ?? + extractProviderFromProfileId(profileId) ?? + providerHints.get(profileId); + if (!provider || !isSafeLegacyProviderKey(provider)) { + continue; + } + const next: Record = { ...raw, provider, mode }; + if (mode === "api_key") { + const keyRef = + coerceSecretRef(raw.keyRef) ?? + coerceSecretRef(raw.key) ?? + coerceSecretRef(raw.apiKey) ?? + coerceSecretRef(raw["api_key"]); + const key = + readNonEmptyString(raw.key) ?? + readNonEmptyString(raw.apiKey) ?? + readNonEmptyString(raw["api_key"]); + if (keyRef) { + next.keyRef = keyRef; + delete next.key; + delete next.apiKey; + delete next["api_key"]; + } else if (key) { + next.key = key; + delete next.keyRef; + } else { + continue; + } + } else if (mode === "token") { + const tokenRef = coerceSecretRef(raw.tokenRef) ?? coerceSecretRef(raw.token); + const token = readNonEmptyString(raw.token); + if (tokenRef) { + next.tokenRef = tokenRef; + delete next.token; + } else if (token) { + next.token = token; + delete next.tokenRef; + } else { + continue; + } + } else if ( + !readNonEmptyString(raw.access) || + !readNonEmptyString(raw.refresh) || + typeof raw.expires !== "number" + ) { + continue; + } + store.profiles[profileId] = next; + } + const canonicalStore = coercePersistedAuthProfileStore(store); + return canonicalStore && Object.keys(canonicalStore.profiles).length > 0 ? canonicalStore : null; +} + +function isDefaultAgentCandidate( + candidate: AuthProfileSqliteMigrationCandidate, + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv, +): boolean { + return path.resolve(candidate.agentDir ?? "") === path.resolve(resolveDefaultAgentDir(cfg, env)); +} + +function stripImportedConfigAuthProfileCredentials( + cfg: OpenClawConfig, + store: AuthProfileStore, +): boolean { + const profiles = ensureConfigAuthProfiles(cfg); + let changed = false; + for (const [profileId, credential] of Object.entries(store.profiles)) { + const current = profiles[profileId]; + if (!current) { + continue; + } + const metadata: AuthProfileConfig = { + provider: current.provider || credential.provider, + mode: credential.type, + ...(current.email ? { email: current.email } : {}), + ...(current.displayName ? { displayName: current.displayName } : {}), + }; + profiles[profileId] = metadata; + changed = true; + } + return changed; +} + +function hasUsableAuthProfileCredential(credential: AuthProfileCredential): boolean { + if (credential.type === "api_key") { + return Boolean(readNonEmptyString(credential.key) || credential.keyRef); + } + if (credential.type === "token") { + return Boolean(readNonEmptyString(credential.token) || credential.tokenRef); + } + return ( + Boolean(readNonEmptyString(credential.access)) && + Boolean(readNonEmptyString(credential.refresh)) && + typeof credential.expires === "number" + ); +} + function mergeImportedAuthProfiles(params: { store: AuthProfileStore; profiles: AuthProfileStore["profiles"]; existingProfileIds: ReadonlySet; + replaceExistingWithoutCredential?: boolean; }): AuthProfileStore { const profiles = { ...params.store.profiles }; for (const [profileId, credential] of Object.entries(params.profiles)) { if (!params.existingProfileIds.has(profileId)) { profiles[profileId] = credential; + continue; + } + const existing = profiles[profileId]; + if ( + params.replaceExistingWithoutCredential && + existing && + !hasUsableAuthProfileCredential(existing) && + hasUsableAuthProfileCredential(credential) + ) { + profiles[profileId] = credential; } } return { ...params.store, profiles }; @@ -492,6 +742,14 @@ function hasImportableAuthProfileStore(store: AuthProfileStore | null): store is return Boolean(store && (Object.keys(store.profiles).length > 0 || hasAuthProfileState(store))); } +function hasLegacyAuthProfileSource(candidate: AuthProfileSqliteMigrationCandidate): boolean { + return ( + fs.existsSync(candidate.authPath) || + fs.existsSync(candidate.statePath) || + fs.existsSync(candidate.legacyPath) + ); +} + function backupAuthProfileJson(pathname: string, suffix: string, now: () => number): string { const backupPath = `${pathname}.${suffix}.${now()}.bak`; fs.copyFileSync(pathname, backupPath); @@ -532,17 +790,30 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: { const loadMigratedStore = params.deps?.loadPersistedAuthProfileStore ?? loadPersistedAuthProfileStore; const candidates = listAuthProfileSqliteMigrationCandidates(params.cfg, env); + const configStore = coerceLegacyConfigAuthProfileStore(params.cfg); const detected = candidates.filter( (candidate) => - fs.existsSync(candidate.authPath) || - fs.existsSync(candidate.statePath) || - fs.existsSync(candidate.legacyPath), + hasLegacyAuthProfileSource(candidate) || + (configStore && isDefaultAgentCandidate(candidate, params.cfg, env)), ); const result: LegacyFlatAuthProfileRepairResult = { detected: detected.flatMap((candidate) => - [candidate.authPath, candidate.statePath, candidate.legacyPath].filter((pathname) => - fs.existsSync(pathname), - ), + [ + candidate.authPath, + candidate.statePath, + candidate.legacyPath, + ...(configStore && isDefaultAgentCandidate(candidate, params.cfg, env) + ? [candidate.authPath] + : []), + ] + .filter((pathname, index, entries) => entries.indexOf(pathname) === index) + .filter( + (pathname) => + fs.existsSync(pathname) || + (configStore && + isDefaultAgentCandidate(candidate, params.cfg, env) && + pathname === candidate.authPath), + ), ), changes: [], warnings: [], @@ -615,12 +886,20 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: { const canonicalStore = hasImportableAuthProfileStore(maybeCanonicalStore) ? maybeCanonicalStore : null; + const configCanonicalStore = + configStore && isDefaultAgentCandidate(candidate, params.cfg, env) ? configStore : null; const legacyStore = loadLegacyAuthProfileStore(candidate.agentDir); const rawState = fs.existsSync(candidate.statePath) ? loadJsonFile(candidate.statePath) : null; const state = coerceAuthProfileState(rawState); - if (!canonicalStore && !legacyStore && !hasAuthProfileState(state) && !awsSdkMarkerStore) { + if ( + !canonicalStore && + !configCanonicalStore && + !legacyStore && + !hasAuthProfileState(state) && + !awsSdkMarkerStore + ) { result.warnings.push( `Left auth profile JSON in place for ${shortenHomePath(candidate.authPath)} because no importable auth profiles or state were found.`, ); @@ -666,16 +945,32 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: { existingState, }); } + if (configCanonicalStore) { + for (const profileId of Object.keys(configCanonicalStore.profiles)) { + importedProfileIds.add(profileId); + } + // Config imports fill missing SQLite credentials only; when both exist, + // the canonical per-agent SQLite store wins over legacy config secrets. + next = mergeImportedAuthProfiles({ + store: next, + profiles: configCanonicalStore.profiles, + existingProfileIds: new Set(Object.keys(next.profiles)), + replaceExistingWithoutCredential: true, + }); + } if (hasAuthProfileState(state)) { next = mergeImportedAuthProfileState({ store: next, state, existingState }); } - if (canonicalStore || legacyStore || hasAuthProfileState(state)) { + if (canonicalStore || configCanonicalStore || legacyStore || hasAuthProfileState(state)) { const stateProfileIds = [ ...collectAuthProfileStateProfileIds(state), ...(canonicalStore ? collectAuthProfileStateProfileIds(coerceAuthProfileState(canonicalStore)) : []), + ...(configCanonicalStore + ? collectAuthProfileStateProfileIds(coerceAuthProfileState(configCanonicalStore)) + : []), ]; saveAuthProfileStore(next, candidate.agentDir, { filterExternalAuthProfiles: false, @@ -694,6 +989,12 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: { ); continue; } + if ( + configCanonicalStore && + stripImportedConfigAuthProfileCredentials(params.cfg, configCanonicalStore) + ) { + result.configChanged = true; + } } const backups: string[] = []; @@ -711,8 +1012,12 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: { if (fs.existsSync(candidate.legacyPath)) { backups.push(backupAndRemoveAuthProfileJson(candidate.legacyPath, "sqlite-import", now)); } + const backupText = + backups.length > 0 + ? `backup${backups.length === 1 ? "" : "s"}: ${backups.map(shortenHomePath).join(", ")}` + : "no legacy JSON backup needed"; result.changes.push( - `Migrated auth profile JSON for ${shortenHomePath(candidate.authPath)} into SQLite (backup${backups.length === 1 ? "" : "s"}: ${backups.map(shortenHomePath).join(", ")}).`, + `Migrated auth profile JSON for ${shortenHomePath(candidate.authPath)} into SQLite (${backupText}).`, ); if (awsSdkMarkerStore) { result.changes.push(