mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-05 09:52:54 +00:00
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:
committed by
GitHub
parent
2b61d38a45
commit
1bfae9d458
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)",
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user