fix(auth/session): preserve override reset behavior and repair oauth profile-id drift (openclaw#18820) thanks @Glucksberg

Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Glucksberg
2026-02-19 23:16:26 -04:00
committed by GitHub
parent f1e1cc4ee3
commit 38b4fb5d55
16 changed files with 376 additions and 46 deletions

View File

@@ -49,6 +49,74 @@ describe("resolveAuthProfileOrder", () => {
});
expect(order).toEqual(["minimax:prod"]);
});
it("falls back to stored provider profiles when config profile ids drift", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
profiles: {
"openai-codex:default": {
provider: "openai-codex",
mode: "oauth",
},
},
order: {
"openai-codex": ["openai-codex:default"],
},
},
},
store: {
version: 1,
profiles: {
"openai-codex:user@example.com": {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
},
provider: "openai-codex",
});
expect(order).toEqual(["openai-codex:user@example.com"]);
});
it("does not bypass explicit ids when the configured profile exists but is invalid", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
profiles: {
"openai-codex:default": {
provider: "openai-codex",
mode: "token",
},
},
order: {
"openai-codex": ["openai-codex:default"],
},
},
},
store: {
version: 1,
profiles: {
"openai-codex:default": {
type: "token",
provider: "openai-codex",
token: "expired-token",
expires: Date.now() - 1_000,
},
"openai-codex:user@example.com": {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
},
provider: "openai-codex",
});
expect(order).toEqual([]);
});
it("drops explicit order entries that belong to another provider", () => {
const order = resolveAuthProfileOrder({
cfg: {

View File

@@ -37,7 +37,7 @@ export function resolveAuthProfileOrder(params: {
return [];
}
const filtered = baseOrder.filter((profileId) => {
const isValidProfile = (profileId: string): boolean => {
const cred = store.profiles[profileId];
if (!cred) {
return false;
@@ -78,7 +78,18 @@ export function resolveAuthProfileOrder(params: {
return Boolean(cred.access?.trim() || cred.refresh?.trim());
}
return false;
});
};
let filtered = baseOrder.filter(isValidProfile);
// Repair config/store profile-id drift from older onboarding flows:
// if configured profile ids no longer exist in auth-profiles.json, scan the
// provider's stored credentials and use any valid entries.
const allBaseProfilesMissing = baseOrder.every((profileId) => !store.profiles[profileId]);
if (filtered.length === 0 && explicitProfiles.length > 0 && allBaseProfilesMissing) {
const storeProfiles = listProfilesForProvider(store, providerKey);
filtered = storeProfiles.filter(isValidProfile);
}
const deduped = dedupeProfileIds(filtered);
// If user specified explicit order (store override or config), respect it

View File

@@ -139,6 +139,75 @@ describe("runWithModelFallback", () => {
});
});
it("falls back directly to configured primary when an override model fails", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: ["anthropic/claude-haiku-3-5", "openrouter/deepseek-chat"],
},
},
},
});
const run = vi.fn().mockImplementation(async (provider, model) => {
if (provider === "anthropic" && model === "claude-opus-4-5") {
throw Object.assign(new Error("unauthorized"), { status: 401 });
}
if (provider === "openai" && model === "gpt-4.1-mini") {
return "ok";
}
throw new Error(`unexpected fallback candidate: ${provider}/${model}`);
});
const result = await runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-opus-4-5",
run,
});
expect(result.result).toBe("ok");
expect(result.provider).toBe("openai");
expect(result.model).toBe("gpt-4.1-mini");
expect(run.mock.calls).toEqual([
["anthropic", "claude-opus-4-5"],
["openai", "gpt-4.1-mini"],
]);
});
it("treats normalized default refs as primary and keeps configured fallback chain", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: ["anthropic/claude-haiku-3-5"],
},
},
},
});
const run = vi
.fn()
.mockRejectedValueOnce(Object.assign(new Error("nope"), { status: 401 }))
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
provider: " OpenAI ",
model: "gpt-4.1-mini",
run,
});
expect(result.result).toBe("ok");
expect(run.mock.calls).toEqual([
["openai", "gpt-4.1-mini"],
["anthropic", "claude-haiku-3-5"],
]);
});
it("falls back on transient HTTP 5xx errors", async () => {
await expectFallsBackToHaiku({
provider: "openai",
@@ -167,12 +236,30 @@ describe("runWithModelFallback", () => {
});
});
it("falls back on credential validation errors", async () => {
await expectFallsBackToHaiku({
it("falls back to configured primary for override credential validation errors", async () => {
const cfg = makeCfg();
const run = vi.fn().mockImplementation(async (provider, model) => {
if (provider === "anthropic" && model === "claude-opus-4") {
throw new Error('No credentials found for profile "anthropic:default".');
}
if (provider === "openai" && model === "gpt-4.1-mini") {
return "ok";
}
throw new Error(`unexpected fallback candidate: ${provider}/${model}`);
});
const result = await runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-opus-4",
firstError: new Error('No credentials found for profile "anthropic:default".'),
run,
});
expect(result.result).toBe("ok");
expect(run.mock.calls).toEqual([
["anthropic", "claude-opus-4"],
["openai", "gpt-4.1-mini"],
]);
});
it("skips providers when all profiles are in cooldown", async () => {

View File

@@ -96,6 +96,10 @@ type ModelFallbackRunResult<T> = {
attempts: FallbackAttempt[];
};
function sameModelCandidate(a: ModelCandidate, b: ModelCandidate): boolean {
return a.provider === b.provider && a.model === b.model;
}
function throwFallbackFailureSummary(params: {
attempts: FallbackAttempt[];
candidates: ModelCandidate[];
@@ -193,6 +197,7 @@ function resolveFallbackCandidates(params: {
const providerRaw = String(params.provider ?? "").trim() || defaultProvider;
const modelRaw = String(params.model ?? "").trim() || defaultModel;
const normalizedPrimary = normalizeModelRef(providerRaw, modelRaw);
const configuredPrimary = normalizeModelRef(defaultProvider, defaultModel);
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg ?? {},
defaultProvider,
@@ -209,6 +214,11 @@ function resolveFallbackCandidates(params: {
if (params.fallbacksOverride !== undefined) {
return params.fallbacksOverride;
}
// Skip configured fallback chain when the user runs a non-default override.
// In that case, retry should return directly to configured primary.
if (!sameModelCandidate(normalizedPrimary, configuredPrimary)) {
return []; // Override model failed → go straight to configured default
}
const model = params.cfg?.agents?.defaults?.model as
| { fallbacks?: string[] }
| string