From 1bfae9d45890e2a27638f54febd2d8298fc4a3ca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 15:14:16 +0100 Subject: [PATCH] fix(models): keep auth login out of main config Store provider login profiles in auth-state, preserve configured auth order/profile constraints, and keep legacy credential/keyRef normalization durable. Fixes #88565. --- ...tize-lastgood-round-robin-ordering.test.ts | 37 ++++ src/agents/auth-profiles/order.test.ts | 57 ++++++ src/agents/auth-profiles/order.ts | 19 +- .../auth-profiles/persisted-boundary.test.ts | 56 +++++- src/agents/auth-profiles/persisted.ts | 19 +- src/agents/auth-profiles/profiles.test.ts | 177 ++++++++++++++++++ src/agents/auth-profiles/profiles.ts | 20 +- .../store.runtime-external.test.ts | 6 +- src/agents/auth-profiles/store.ts | 33 +++- .../shared/stale-oauth-profile-shadows.ts | 4 +- src/commands/models/auth.test.ts | 55 +++++- src/commands/models/auth.ts | 104 ++++++---- src/infra/device-auth-store.test.ts | 24 +++ 13 files changed, 554 insertions(+), 57 deletions(-) diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts index 26e0cbc4dd6..0ffa693f372 100644 --- a/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts +++ b/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts @@ -478,6 +478,43 @@ describe("resolveAuthProfileOrder", () => { }); expect(order).toEqual(["anthropic:work", "anthropic:default"]); }); + it("prefers store order over stale configured profiles", () => { + const order = resolveAuthProfileOrder({ + cfg: { + auth: { + profiles: { + "openai:old-login": { + provider: "openai", + mode: "oauth", + }, + }, + }, + }, + store: { + version: 1, + order: { openai: ["openai:new-login", "openai:old-login"] }, + profiles: { + "openai:new-login": { + type: "oauth", + provider: "openai", + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 60_000, + }, + "openai:old-login": { + type: "oauth", + provider: "openai", + access: "old-access", + refresh: "old-refresh", + expires: Date.now() + 60_000, + }, + }, + }, + provider: "openai", + }); + + expect(order).toEqual(["openai:new-login", "openai:old-login"]); + }); it.each(["store", "config"] as const)( "pushes cooldown profiles to the end even with %s order", (orderSource) => { diff --git a/src/agents/auth-profiles/order.test.ts b/src/agents/auth-profiles/order.test.ts index 92cfb387bb5..d583b35317d 100644 --- a/src/agents/auth-profiles/order.test.ts +++ b/src/agents/auth-profiles/order.test.ts @@ -204,6 +204,63 @@ describe("resolveAuthProfileOrder", () => { expect(order).toStrictEqual([]); }); + it("falls back to stored profiles when a stored order only has missing credentials", async () => { + const store: AuthProfileStore = { + version: 1, + profiles: { + "fixture-provider:key": { + type: "api_key", + provider: "fixture-provider", + key: "sk-primary", + }, + "fixture-provider:oauth": { + type: "oauth", + provider: "fixture-provider", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + order: { + "fixture-provider": ["fixture-provider:deleted"], + }, + }; + + const order = resolveAuthProfileOrder({ + store, + provider: "fixture-provider", + }); + + expect(order).toStrictEqual(["fixture-provider:oauth", "fixture-provider:key"]); + }); + + it("does not fall back past an explicit configured auth order", async () => { + const store: AuthProfileStore = { + version: 1, + profiles: { + "fixture-provider:primary": { + type: "api_key", + provider: "fixture-provider", + key: "sk-primary", + }, + }, + }; + + const order = resolveAuthProfileOrder({ + cfg: { + auth: { + order: { + "fixture-provider": ["fixture-provider:missing"], + }, + }, + }, + store, + provider: "fixture-provider", + }); + + expect(order).toStrictEqual([]); + }); + it("lets Codex auth use friendly OpenAI auth order entries", async () => { const store: AuthProfileStore = { version: 1, diff --git a/src/agents/auth-profiles/order.ts b/src/agents/auth-profiles/order.ts index 4d2d9d34919..57f0a209ac8 100644 --- a/src/agents/auth-profiles/order.ts +++ b/src/agents/auth-profiles/order.ts @@ -246,6 +246,9 @@ export function resolveAuthProfileOrder(params: { : undefined; const directExplicitOrder = directStoredOrder ?? directConfiguredOrder; const aliasExplicitOrder = aliasStoredOrder ?? aliasConfiguredOrder; + const explicitOrderFromStore = + directStoredOrder !== undefined || + (directExplicitOrder === undefined && aliasStoredOrder !== undefined); const explicitProfiles = cfg?.auth?.profiles ? Object.entries(cfg.auth.profiles) .filter(([profileId, profile]) => @@ -298,13 +301,19 @@ export function resolveAuthProfileOrder(params: { now, }).eligible; let filtered = baseOrder.filter(isValidProfile); + let repairedFallbackToStoreProfiles = false; - // Repair config/store profile-id drift from older setup flows: - // if configured profile ids no longer exist in auth-profiles.json, scan the - // provider's stored credentials and use any valid entries. + // Repair stored-order and config-profile drift from older setup flows: + // bare config auth.order is a hard constraint, but configured profile ids + // can drift from their stored credential ids and still need repair. const allBaseProfilesMissing = baseOrder.every((profileId) => !store.profiles[profileId]); - if (filtered.length === 0 && explicitProfiles.length > 0 && allBaseProfilesMissing) { + if ( + filtered.length === 0 && + allBaseProfilesMissing && + (explicitOrderFromStore || explicitProfiles.length > 0) + ) { filtered = storeProfiles.filter(isValidProfile); + repairedFallbackToStoreProfiles = true; } const deduped = dedupeProfileIds(filtered); @@ -312,7 +321,7 @@ export function resolveAuthProfileOrder(params: { // If user specified explicit order (store override or config), respect it // exactly, but still apply cooldown sorting to avoid repeatedly selecting // known-bad/rate-limited keys as the first candidate. - if (explicitOrder && explicitOrder.length > 0) { + if (explicitOrder && explicitOrder.length > 0 && !repairedFallbackToStoreProfiles) { // ...but still respect cooldown tracking to avoid repeatedly selecting a // known-bad/rate-limited key as the first candidate. const available: string[] = []; diff --git a/src/agents/auth-profiles/persisted-boundary.test.ts b/src/agents/auth-profiles/persisted-boundary.test.ts index 46ea4165f85..4a61c68bb76 100644 --- a/src/agents/auth-profiles/persisted-boundary.test.ts +++ b/src/agents/auth-profiles/persisted-boundary.test.ts @@ -8,15 +8,26 @@ describe("persisted auth profile boundary", () => { version: "not-a-version", profiles: { "openai:default": { - type: "api_key", + type: "apiKey", provider: " OpenAI ", - key: 42, + apiKey: "demo-openai-key", keyRef: { source: "env", id: "OPENAI_API_KEY" }, metadata: { account: "acct_123", bad: 123 }, copyToAgents: "yes", email: ["wrong"], displayName: "Work", }, + "openai:legacy-api-key": { + type: "apiKey", + provider: "openai", + apiKey: "legacy-openai-key", + }, + "openai:legacy-malformed-ref": { + type: "apiKey", + provider: "openai", + apiKey: "legacy-fallback-key", + keyRef: { source: "env", id: "" }, + }, "minimax:default": { type: "token", provider: "minimax", @@ -70,6 +81,16 @@ describe("persisted auth profile boundary", () => { metadata: { account: "acct_123" }, displayName: "Work", }, + "openai:legacy-api-key": { + type: "api_key", + provider: "openai", + key: "legacy-openai-key", + }, + "openai:legacy-malformed-ref": { + type: "api_key", + provider: "openai", + key: "legacy-fallback-key", + }, "minimax:default": { type: "token", provider: "minimax", @@ -98,7 +119,6 @@ describe("persisted auth profile boundary", () => { }, }); expect(store?.profiles["broken:array"]).toBeUndefined(); - expect(store?.profiles["openai:default"]).not.toHaveProperty("key"); expect(store?.profiles["openai:default"]).not.toHaveProperty("copyToAgents"); expect(store?.profiles["openai:oauth"]).not.toHaveProperty("oauthRef"); }); @@ -194,6 +214,36 @@ describe("persisted auth profile boundary", () => { expect(merged.lastGood?.anthropic).toBe(profileId); }); + it("preserves config-only order fallbacks during agent-store merges", () => { + const merged = mergeAuthProfileStores( + { + version: AUTH_STORE_VERSION, + profiles: {}, + order: { + openai: ["openai:aws-sdk"], + }, + }, + { + version: AUTH_STORE_VERSION, + profiles: { + "openai:new-login": { + type: "oauth", + provider: "openai", + access: "new-access", + refresh: "new-refresh", + expires: 1, + }, + }, + order: { + openai: ["openai:new-login", "openai:aws-sdk"], + }, + }, + { preserveBaseRuntimeExternalProfiles: true }, + ); + + expect(merged.order?.openai).toEqual(["openai:new-login", "openai:aws-sdk"]); + }); + it("preserves inherited base runtime external profiles during agent-store merges", () => { const profileId = "anthropic:claude-cli"; const merged = mergeAuthProfileStores( diff --git a/src/agents/auth-profiles/persisted.ts b/src/agents/auth-profiles/persisted.ts index 50ea2c5d1de..edd4653c0a9 100644 --- a/src/agents/auth-profiles/persisted.ts +++ b/src/agents/auth-profiles/persisted.ts @@ -106,7 +106,14 @@ function normalizeRawCredentialEntry(raw: Record): Partial): Partial [ provider, - profileIds.filter((profileId) => profiles[profileId]), + profileIds.filter( + (profileId) => + profiles[profileId] || !removedRuntimeExternalProfileIds.has(profileId), + ), ]) .filter(([, profileIds]) => profileIds.length > 0), ) diff --git a/src/agents/auth-profiles/profiles.test.ts b/src/agents/auth-profiles/profiles.test.ts index c288604da66..c5991c1d690 100644 --- a/src/agents/auth-profiles/profiles.test.ts +++ b/src/agents/auth-profiles/profiles.test.ts @@ -458,6 +458,7 @@ describe("promoteAuthProfileInOrder", () => { agentDir, provider: "openai", profileId: newProfileId, + createIfMissing: true, }); expect(updated?.order?.["openai"]).toEqual([newProfileId, staleProfileId]); @@ -475,6 +476,182 @@ describe("promoteAuthProfileInOrder", () => { } }); + it("creates a per-agent provider order when relogin has no existing order", async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-order-create-")); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = stateDir; + try { + fs.mkdirSync(agentDir, { recursive: true }); + const newProfileId = "openai:new-login"; + const primaryProfileId = "openai:primary-login"; + const backupProfileId = "openai:backup-login"; + const unrelatedProfileId = "openai:unrelated-login"; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [primaryProfileId]: { + type: "oauth", + provider: "openai", + access: "primary-access", + refresh: "primary-refresh", + expires: Date.now() + 30 * 60 * 1000, + }, + [backupProfileId]: { + type: "oauth", + provider: "openai", + access: "backup-access", + refresh: "backup-refresh", + expires: Date.now() + 30 * 60 * 1000, + }, + [newProfileId]: { + type: "oauth", + provider: "openai", + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 60 * 60 * 1000, + }, + [unrelatedProfileId]: { + type: "oauth", + provider: "openai", + access: "unrelated-access", + refresh: "unrelated-refresh", + expires: Date.now() + 30 * 60 * 1000, + }, + }, + }, + agentDir, + ); + + const updated = await promoteAuthProfileInOrder({ + agentDir, + provider: "openai", + profileId: newProfileId, + createIfMissing: true, + createFromOrder: [backupProfileId, primaryProfileId], + }); + + expect(updated?.order?.["openai"]).toEqual([newProfileId, backupProfileId, primaryProfileId]); + expect(loadAuthProfileStoreForRuntime(agentDir).order?.["openai"]).toEqual([ + newProfileId, + backupProfileId, + primaryProfileId, + ]); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it("preserves config-only fallback ids when creating a relogin order", async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-order-config-only-")); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = stateDir; + try { + fs.mkdirSync(agentDir, { recursive: true }); + const newProfileId = "openai:new-login"; + const existingProfileId = "openai:old-login"; + const configOnlyProfileId = "openai:aws-sdk"; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [existingProfileId]: { + type: "oauth", + provider: "openai", + access: "old-access", + refresh: "old-refresh", + expires: Date.now() + 30 * 60 * 1000, + }, + [newProfileId]: { + type: "oauth", + provider: "openai", + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 60 * 60 * 1000, + }, + }, + }, + agentDir, + ); + + await promoteAuthProfileInOrder({ + agentDir, + provider: "openai", + profileId: newProfileId, + createIfMissing: true, + createFromOrder: [existingProfileId, configOnlyProfileId], + }); + + expect(loadAuthProfileStoreForRuntime(agentDir).order?.["openai"]).toEqual([ + newProfileId, + existingProfileId, + configOnlyProfileId, + ]); + saveAuthProfileStore(loadAuthProfileStoreForRuntime(agentDir), agentDir); + expect(loadAuthProfileStoreForRuntime(agentDir).order?.["openai"]).toEqual([ + newProfileId, + existingProfileId, + configOnlyProfileId, + ]); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it("keeps implicit round-robin when relogin has no existing order by default", async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-order-implicit-")); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = stateDir; + try { + fs.mkdirSync(agentDir, { recursive: true }); + const newProfileId = "openai:new-login"; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [newProfileId]: { + type: "oauth", + provider: "openai", + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 60 * 60 * 1000, + }, + }, + }, + agentDir, + ); + + const updated = await promoteAuthProfileInOrder({ + agentDir, + provider: "openai", + profileId: newProfileId, + }); + + expect(updated?.order?.["openai"]).toBeUndefined(); + expect(loadAuthProfileStoreForRuntime(agentDir).order?.["openai"]).toBeUndefined(); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + it("clears matching lastGood after a stale refresh_token_reused profile", async () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-clear-lastgood-")); const agentDir = path.join(stateDir, "agents", "main", "agent"); diff --git a/src/agents/auth-profiles/profiles.ts b/src/agents/auth-profiles/profiles.ts index 39d5c17a3ab..63f946b5ee7 100644 --- a/src/agents/auth-profiles/profiles.ts +++ b/src/agents/auth-profiles/profiles.ts @@ -91,10 +91,15 @@ export async function promoteAuthProfileInOrder(params: { agentDir?: string; provider: string; profileId: string; + createIfMissing?: boolean; + createFromOrder?: string[]; }): Promise { const providerKey = resolveProviderIdForAuth(params.provider); return await updateAuthProfileStoreWithLock({ agentDir: params.agentDir, + ...(params.createFromOrder + ? { saveOptions: { preserveOrderProfileIds: params.createFromOrder } } + : {}), updater: (store) => { const profile = store.profiles[params.profileId]; if (!profile || resolveProviderIdForAuth(profile.provider) !== providerKey) { @@ -106,7 +111,20 @@ export async function promoteAuthProfileInOrder(params: { normalizeProviderId(providerKey); const existing = store.order?.[orderKey]; if (!existing || existing.length === 0) { - return false; + if (!params.createIfMissing) { + return false; + } + const providerProfiles = dedupeProfileIds( + params.createFromOrder !== undefined + ? params.createFromOrder + : listProfilesForProvider(store, providerKey), + ); + const next = dedupeProfileIds([ + params.profileId, + ...providerProfiles.filter((profileId) => profileId !== params.profileId), + ]); + store.order = { ...store.order, [orderKey]: next }; + return true; } const next = dedupeProfileIds([ params.profileId, diff --git a/src/agents/auth-profiles/store.runtime-external.test.ts b/src/agents/auth-profiles/store.runtime-external.test.ts index b1d309fbc5c..77593afe630 100644 --- a/src/agents/auth-profiles/store.runtime-external.test.ts +++ b/src/agents/auth-profiles/store.runtime-external.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { ProviderExternalAuthProfile } from "../../plugins/types.js"; import { testing as externalAuthTesting } from "./external-auth.js"; -import { resolveAuthStorePath } from "./paths.js"; +import { resolveAuthStatePath, resolveAuthStorePath } from "./paths.js"; import { getRuntimeAuthProfileStoreSnapshot } from "./runtime-snapshots.js"; import { clearRuntimeAuthProfileStoreSnapshots, @@ -90,8 +90,12 @@ describe("auth profile store runtime external snapshots", () => { const persisted = JSON.parse( await fs.readFile(resolveAuthStorePath(agentDir), "utf8"), ) as AuthProfileStore; + const persistedState = JSON.parse( + await fs.readFile(resolveAuthStatePath(agentDir), "utf8"), + ) as AuthProfileStore; expect(persisted.profiles[externalProfileId]).toBeUndefined(); expect(persisted.order?.["claude-cli"]).toBeUndefined(); + expect(persistedState.order?.["claude-cli"]).toBeUndefined(); const snapshot = getRuntimeAuthProfileStoreSnapshot(agentDir); expect(snapshot?.profiles[externalProfileId]).toEqual(externalCredential); diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 148e8f9012c..9b43fa4f1c7 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -40,7 +40,7 @@ import { replaceRuntimeAuthProfileStoreSnapshots as replaceRuntimeAuthProfileStoreSnapshotsImpl, setRuntimeAuthProfileStoreSnapshot, } from "./runtime-snapshots.js"; -import { savePersistedAuthProfileState } from "./state.js"; +import { loadPersistedAuthProfileState, savePersistedAuthProfileState } from "./state.js"; import { clearLoadedAuthStoreCache, readCachedAuthProfileStore, @@ -60,6 +60,8 @@ type LoadAuthProfileStoreOptions = { type SaveAuthProfileStoreOptions = { filterExternalAuthProfiles?: boolean; + preserveOrderProfileIds?: Iterable; + pruneOrderProfileIds?: Iterable; syncExternalCli?: boolean; }; @@ -474,13 +476,14 @@ function shouldKeepProfileInLocalStore(params: { function pruneAuthProfileStoreReferences( store: AuthProfileStore, keptProfileIds: Set, + keptOrderProfileIds = keptProfileIds, ): void { store.order = store.order ? Object.fromEntries( Object.entries(store.order) .map(([provider, profileIds]) => [ provider, - profileIds.filter((profileId) => keptProfileIds.has(profileId)), + profileIds.filter((profileId) => keptOrderProfileIds.has(profileId)), ]) .filter(([, profileIds]) => profileIds.length > 0), ) @@ -534,7 +537,31 @@ function buildLocalAuthProfileStoreForSave(params: { ), ); const keptProfileIds = new Set(Object.keys(localStore.profiles)); - pruneAuthProfileStoreReferences(localStore, keptProfileIds); + const keptOrderProfileIds = new Set(keptProfileIds); + for (const profileIds of Object.values( + loadPersistedAuthProfileState(params.agentDir).order ?? {}, + )) { + for (const profileId of profileIds) { + keptOrderProfileIds.add(profileId); + } + } + for (const profileId of params.options?.preserveOrderProfileIds ?? []) { + const normalizedProfileId = profileId.trim(); + if (normalizedProfileId) { + keptOrderProfileIds.add(normalizedProfileId); + } + } + const prunedOrderProfileIds = new Set(); + for (const profileId of params.options?.pruneOrderProfileIds ?? []) { + const normalizedProfileId = profileId.trim(); + if (normalizedProfileId) { + prunedOrderProfileIds.add(normalizedProfileId); + } + } + for (const profileId of prunedOrderProfileIds) { + keptOrderProfileIds.delete(profileId); + } + pruneAuthProfileStoreReferences(localStore, keptProfileIds, keptOrderProfileIds); if (params.options?.filterExternalAuthProfiles !== false) { localStore.runtimeExternalProfileIds = undefined; localStore.runtimeExternalProfileIdsAuthoritative = undefined; diff --git a/src/commands/doctor/shared/stale-oauth-profile-shadows.ts b/src/commands/doctor/shared/stale-oauth-profile-shadows.ts index aeca20b4d0d..e12ac36ae1e 100644 --- a/src/commands/doctor/shared/stale-oauth-profile-shadows.ts +++ b/src/commands/doctor/shared/stale-oauth-profile-shadows.ts @@ -250,7 +250,9 @@ async function repairStaleOAuthProfilesForAgent(params: { if (result.removedProfileIds.length === 0) { return { status: "unchanged" }; } - saveAuthProfileStore(result.store, params.agentDir); + saveAuthProfileStore(result.store, params.agentDir, { + pruneOrderProfileIds: result.removedProfileIds, + }); return { status: "changed", removedProfileIds: result.removedProfileIds, diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index fb5d251a2ef..5a09ba7d311 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -484,10 +484,11 @@ describe("modelsAuthLoginCommand", () => { agentDir: "/tmp/openclaw/agents/main", provider: "openai", profileId: "openai:user@example.com", + createIfMissing: false, }); - const savedProfile = lastUpdatedConfig?.auth?.profiles?.["openai:user@example.com"]; - expect(savedProfile?.provider).toBe("openai"); - expect(savedProfile?.mode).toBe("oauth"); + expect(mocks.updateConfig).not.toHaveBeenCalled(); + expect(mocks.logConfigUpdated).not.toHaveBeenCalled(); + expect(lastUpdatedConfig).toBeNull(); expect(runtime.log).toHaveBeenCalledWith( "Auth profile: openai:user@example.com (openai/oauth)", ); @@ -496,6 +497,53 @@ describe("modelsAuthLoginCommand", () => { ); }); + it("creates store order for relogin when configured profiles would shadow the new profile", async () => { + const runtime = createRuntime(); + currentConfig = { + auth: { + profiles: { + "openai:old-login": { + provider: "openai", + mode: "oauth", + }, + }, + }, + }; + + await modelsAuthLoginCommand({ provider: "openai" }, runtime); + + expect(mocks.updateConfig).not.toHaveBeenCalled(); + expect(mocks.promoteAuthProfileInOrder).toHaveBeenCalledWith({ + agentDir: "/tmp/openclaw/agents/main", + provider: "openai", + profileId: "openai:user@example.com", + createIfMissing: true, + createFromOrder: ["openai:old-login"], + }); + }); + + it("creates store order for relogin when configured order would shadow the new profile", async () => { + const runtime = createRuntime(); + currentConfig = { + auth: { + order: { + openai: ["openai:old-login"], + }, + }, + }; + + await modelsAuthLoginCommand({ provider: "openai" }, runtime); + + expect(mocks.updateConfig).not.toHaveBeenCalled(); + expect(mocks.promoteAuthProfileInOrder).toHaveBeenCalledWith({ + agentDir: "/tmp/openclaw/agents/main", + provider: "openai", + profileId: "openai:user@example.com", + createIfMissing: true, + createFromOrder: ["openai:old-login"], + }); + }); + it("defaults OpenAI login to ChatGPT OAuth when API key is also available", async () => { const runtime = createRuntime(); const initialConfig = currentConfig; @@ -1059,6 +1107,7 @@ describe("modelsAuthLoginCommand", () => { "anthropic/claude-sonnet-4-6": {}, "openai/gpt-5.5": { alias: "GPT" }, }); + expect(lastUpdatedConfig?.auth).toBeUndefined(); expect(runtime.log).toHaveBeenCalledWith( "Default model available: openai/gpt-5.5 (use --set-default to apply)", ); diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 97ffd4bf12b..465341ee1a1 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -30,6 +30,7 @@ import { loadAuthProfileStoreForRuntime } from "../../agents/auth-profiles/store import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js"; import { clearAuthProfileCooldown } from "../../agents/auth-profiles/usage.js"; import { normalizeProviderId } from "../../agents/model-selection-normalize.js"; +import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; @@ -411,6 +412,7 @@ async function pickProviderTokenMethod(params: { async function persistProviderAuthResult(params: { result: ProviderAuthResult; profiles?: ProviderAuthResult["profiles"]; + config: OpenClawConfig; agentDir: string; runtime: RuntimeEnv; prompter: ReturnType; @@ -420,8 +422,15 @@ async function persistProviderAuthResult(params: { ? normalizeAgentModelRefForConfig(params.result.defaultModel) : undefined; const profiles = params.profiles ?? params.result.profiles; + const shouldUpdateConfig = Boolean( + params.result.configPatch || (params.setDefault && defaultModel), + ); for (const profile of profiles) { + const configuredSelection = resolveConfiguredAuthSelectionForProvider( + params.config, + profile.credential.provider, + ); await upsertAuthProfileWithLockOrThrow({ profileId: profile.profileId, credential: profile.credential, @@ -431,49 +440,49 @@ async function persistProviderAuthResult(params: { agentDir: params.agentDir, provider: profile.credential.provider, profileId: profile.profileId, + createIfMissing: configuredSelection.createIfMissing, + ...(configuredSelection.order ? { createFromOrder: configuredSelection.order } : {}), }); } - const updated = await updateConfig((cfg) => { - const priorAgentsDefaultsModel = cfg.agents?.defaults?.model; - let next = cfg; - if (params.result.configPatch) { - next = applyProviderAuthConfigPatch(next, params.result.configPatch, { - replaceDefaultModels: params.result.replaceDefaultModels, + // Auth login owns the credential store. Keep openclaw.json untouched unless + // the provider explicitly returns a config patch or the user opts into a + // default-model write. + if (shouldUpdateConfig) { + const updated = await updateConfig((cfg) => { + const priorAgentsDefaultsModel = cfg.agents?.defaults?.model; + let next = cfg; + if (params.result.configPatch) { + next = applyProviderAuthConfigPatch(next, params.result.configPatch, { + replaceDefaultModels: params.result.replaceDefaultModels, + }); + } + next = restorePriorAgentsDefaultsModelUnlessOptIn({ + cfg: next, + priorAgentsDefaultsModel, + setDefault: params.setDefault, }); - } - for (const profile of profiles) { - next = applyAuthProfileConfig(next, { - profileId: profile.profileId, - provider: profile.credential.provider, - mode: credentialMode(profile.credential), + if (params.setDefault && defaultModel) { + next = applyDefaultModel(next, defaultModel); + } + return next; + }); + if (defaultModel) { + const repaired = await repairCodexRuntimePluginInstallForModelSelection({ + cfg: updated, + model: defaultModel, }); + const copilotRepaired = await repairCopilotRuntimePluginInstallForModelSelection({ + cfg: updated, + model: defaultModel, + }); + for (const warning of [...repaired.warnings, ...copilotRepaired.warnings]) { + params.runtime.error?.(warning); + } } - next = restorePriorAgentsDefaultsModelUnlessOptIn({ - cfg: next, - priorAgentsDefaultsModel, - setDefault: params.setDefault, - }); - if (params.setDefault && defaultModel) { - next = applyDefaultModel(next, defaultModel); - } - return next; - }); - if (defaultModel) { - const repaired = await repairCodexRuntimePluginInstallForModelSelection({ - cfg: updated, - model: defaultModel, - }); - const copilotRepaired = await repairCopilotRuntimePluginInstallForModelSelection({ - cfg: updated, - model: defaultModel, - }); - for (const warning of [...repaired.warnings, ...copilotRepaired.warnings]) { - params.runtime.error?.(warning); - } + logConfigUpdated(params.runtime); } - logConfigUpdated(params.runtime); for (const profile of profiles) { params.runtime.log( `Auth profile: ${profile.profileId} (${profile.credential.provider}/${credentialMode(profile.credential)})`, @@ -491,6 +500,30 @@ async function persistProviderAuthResult(params: { } } +function resolveConfiguredAuthSelectionForProvider( + cfg: OpenClawConfig, + provider: string, +): { createIfMissing: boolean; order?: string[] } { + const providerAuthKey = resolveProviderIdForAuth(provider, { config: cfg }); + for (const [orderProvider, profileIds] of Object.entries(cfg.auth?.order ?? {})) { + if ( + profileIds.length > 0 && + resolveProviderIdForAuth(orderProvider, { config: cfg }) === providerAuthKey + ) { + return { createIfMissing: true, order: profileIds }; + } + } + const profileIds = Object.entries(cfg.auth?.profiles ?? {}) + .filter( + ([, profile]) => + resolveProviderIdForAuth(profile.provider, { config: cfg }) === providerAuthKey, + ) + .map(([profileId]) => profileId); + return profileIds.length > 0 + ? { createIfMissing: true, order: profileIds } + : { createIfMissing: false }; +} + async function runProviderAuthMethod(params: { config: OpenClawConfig; agentDir: string; @@ -539,6 +572,7 @@ async function runProviderAuthMethod(params: { await persistProviderAuthResult({ result, profiles, + config: params.config, agentDir: params.agentDir, runtime: params.runtime, prompter: params.prompter, diff --git a/src/infra/device-auth-store.test.ts b/src/infra/device-auth-store.test.ts index 4212b5c8e0a..23c644c5220 100644 --- a/src/infra/device-auth-store.test.ts +++ b/src/infra/device-auth-store.test.ts @@ -107,6 +107,30 @@ describe("infra/device-auth-store", () => { }); }); + it("loads valid roles when another persisted token entry is malformed", async () => { + await withTempDir("openclaw-device-auth-", async (stateDir) => { + const env = createEnv(stateDir); + await fs.mkdir(path.dirname(deviceAuthFile(stateDir)), { recursive: true }); + await fs.writeFile( + deviceAuthFile(stateDir), + JSON.stringify({ + version: 1, + deviceId: "device-1", + tokens: { + operator: { token: "operator-token", role: "operator", scopes: [], updatedAtMs: 1 }, + broken: { role: "broken", scopes: [], updatedAtMs: 1 }, + }, + }), + "utf8", + ); + + expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator", env })?.token).toBe( + "operator-token", + ); + expect(loadDeviceAuthToken({ deviceId: "device-1", role: "broken", env })).toBeNull(); + }); + }); + it("clears only the requested role and leaves unrelated tokens intact", async () => { await withTempDir("openclaw-device-auth-", async (stateDir) => { const env = createEnv(stateDir);