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.
This commit is contained in:
Peter Steinberger
2026-05-31 15:14:16 +01:00
committed by GitHub
parent 2b61d38a45
commit 1bfae9d458
13 changed files with 554 additions and 57 deletions

View File

@@ -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) => {

View File

@@ -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,

View File

@@ -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[] = [];

View File

@@ -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(

View File

@@ -106,7 +106,14 @@ function normalizeRawCredentialEntry(raw: Record<string, unknown>): Partial<Auth
if (!("type" in entry) && typeof entry["mode"] === "string") {
entry["type"] = entry["mode"];
}
if (!("key" in entry) && typeof entry["apiKey"] === "string") {
if (entry.type === "apiKey") {
entry.type = "api_key";
}
if (
!("key" in entry) &&
!coerceSecretRef(entry["keyRef"]) &&
typeof entry["apiKey"] === "string"
) {
entry["key"] = entry["apiKey"];
}
normalizeSecretBackedField({ entry, valueField: "key", refField: "keyRef" });
@@ -119,11 +126,10 @@ function normalizeRawCredentialEntry(raw: Record<string, unknown>): Partial<Auth
const key = normalizeOptionalCredentialString(entry.key);
const keyRef = coerceSecretRef(entry.keyRef);
const metadata = normalizeCredentialMetadata(entry.metadata);
if (key !== undefined) {
normalized.key = key;
}
if (keyRef) {
normalized.keyRef = keyRef;
} else if (key !== undefined) {
normalized.key = key;
}
if (metadata) {
normalized.metadata = metadata;
@@ -524,7 +530,10 @@ export function mergeAuthProfileStores(
Object.entries(mergedOrder)
.map(([provider, profileIds]) => [
provider,
profileIds.filter((profileId) => profiles[profileId]),
profileIds.filter(
(profileId) =>
profiles[profileId] || !removedRuntimeExternalProfileIds.has(profileId),
),
])
.filter(([, profileIds]) => profileIds.length > 0),
)

View File

@@ -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");

View File

@@ -91,10 +91,15 @@ export async function promoteAuthProfileInOrder(params: {
agentDir?: string;
provider: string;
profileId: string;
createIfMissing?: boolean;
createFromOrder?: string[];
}): Promise<AuthProfileStore | null> {
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,

View File

@@ -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);

View File

@@ -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<string>;
pruneOrderProfileIds?: Iterable<string>;
syncExternalCli?: boolean;
};
@@ -474,13 +476,14 @@ function shouldKeepProfileInLocalStore(params: {
function pruneAuthProfileStoreReferences(
store: AuthProfileStore,
keptProfileIds: Set<string>,
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<string>();
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;

View File

@@ -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,

View File

@@ -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)",
);

View File

@@ -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<typeof createClackPrompter>;
@@ -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,

View File

@@ -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);