fix(auth): prevent stale auth store reverts (#53211)

This commit is contained in:
Vincent Koc
2026-03-23 15:56:46 -07:00
committed by GitHub
parent 47bdc36831
commit 03231c0633
5 changed files with 93 additions and 1 deletions

View File

@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
- Control UI/auth: preserve operator scopes through the device-auth bypass path, ignore cached under-scoped operator tokens, and show a clear `operator.read` fallback message when a connection really lacks read scope, so operator sessions stop failing or blanking on read-backed pages. (#53110) Thanks @BunsDev.
- Plugins/uninstall: accept installed `clawhub:` specs and versionless ClawHub package names as uninstall targets, so `openclaw plugins uninstall clawhub:<package>` works again even when the recorded install was pinned to a version.
- Auth/OpenAI tokens: stop live gateway auth-profile writes from reverting freshly saved credentials back to stale in-memory values, and make `models auth paste-token` write to the resolved agent store, so Configure, Onboard, and token-paste flows stop snapping back to expired OpenAI tokens. Fixes #53207. Related to #45516.
- Agents/failover: classify generic `api_error` payloads as retryable only when they include transient failure signals, so MiniMax-style backend failures still trigger model fallback without misclassifying billing, auth, or format/context errors. (#49611) Thanks @ayushozha.
- Diagnostics/cache trace: strip credential fields from cache-trace JSONL output while preserving non-sensitive diagnostic fields and image redaction metadata.
- Docs/Feishu: replace `botName` with `name` in the channel config examples so the docs match the strict account schema for per-account display names. (#52753) Thanks @haroldfabla2-hue.

View File

@@ -3,9 +3,11 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
clearRuntimeAuthProfileStoreSnapshots,
calculateAuthProfileCooldownMs,
ensureAuthProfileStore,
markAuthProfileFailure,
replaceRuntimeAuthProfileStoreSnapshots,
} from "./auth-profiles.js";
type AuthProfileStore = ReturnType<typeof ensureAuthProfileStore>;
@@ -48,6 +50,73 @@ function expectCooldownInRange(remainingMs: number, minMs: number, maxMs: number
}
describe("markAuthProfileFailure", () => {
it("does not overwrite fresher on-disk credentials with a stale runtime snapshot", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
try {
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-expired-old",
},
},
}),
);
replaceRuntimeAuthProfileStoreSnapshots([
{
agentDir,
store: ensureAuthProfileStore(agentDir),
},
]);
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-fresh-new",
},
},
}),
);
const staleRuntimeStore = ensureAuthProfileStore(agentDir);
const staleCredential = staleRuntimeStore.profiles["openai:default"];
expect(staleCredential?.type).toBe("api_key");
expect(staleCredential && "key" in staleCredential ? staleCredential.key : undefined).toBe(
"sk-expired-old",
);
await markAuthProfileFailure({
store: staleRuntimeStore,
profileId: "openai:default",
reason: "rate_limit",
agentDir,
});
clearRuntimeAuthProfileStoreSnapshots();
const reloaded = ensureAuthProfileStore(agentDir);
const reloadedCredential = reloaded.profiles["openai:default"];
expect(reloadedCredential?.type).toBe("api_key");
expect(
reloadedCredential && "key" in reloadedCredential ? reloadedCredential.key : undefined,
).toBe("sk-fresh-new");
expect(typeof reloaded.usageStats?.["openai:default"]?.cooldownUntil).toBe("number");
} finally {
clearRuntimeAuthProfileStoreSnapshots();
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("disables billing failures for ~5 hours by default", async () => {
await withAuthProfileStore(async ({ agentDir, store }) => {
const startedAt = Date.now();

View File

@@ -130,7 +130,10 @@ export async function updateAuthProfileStoreWithLock(params: {
try {
return await withFileLock(authPath, AUTH_STORE_LOCK_OPTIONS, async () => {
const store = ensureAuthProfileStore(params.agentDir);
// Locked writers must reload from disk, not from any runtime snapshot.
// Otherwise a live gateway can overwrite fresher CLI/config-auth writes
// with stale in-memory auth state during usage/cooldown updates.
const store = loadAuthProfileStoreForAgent(params.agentDir);
const shouldSave = params.updater(store);
if (shouldSave) {
saveAuthProfileStore(store, params.agentDir);

View File

@@ -315,6 +315,23 @@ describe("modelsAuthLoginCommand", () => {
}
});
it("writes pasted tokens to the resolved agent store", async () => {
const runtime = createRuntime();
mocks.clackText.mockResolvedValue("tok-fresh");
await modelsAuthPasteTokenCommand({ provider: "openai" }, runtime);
expect(mocks.upsertAuthProfile).toHaveBeenCalledWith({
profileId: "openai:manual",
credential: {
type: "token",
provider: "openai",
token: "tok-fresh",
},
agentDir: "/tmp/openclaw/agents/main",
});
});
it("runs token auth for any token-capable provider plugin", async () => {
const runtime = createRuntime();
const runTokenAuth = vi.fn().mockResolvedValue({

View File

@@ -359,6 +359,7 @@ export async function modelsAuthPasteTokenCommand(
},
runtime: RuntimeEnv,
) {
const { agentDir } = await resolveModelsAuthContext();
const rawProvider = opts.provider?.trim();
if (!rawProvider) {
throw new Error("Missing --provider.");
@@ -385,6 +386,7 @@ export async function modelsAuthPasteTokenCommand(
token,
...(expires ? { expires } : {}),
},
agentDir,
});
await updateConfig((cfg) => applyAuthProfileConfig(cfg, { profileId, provider, mode: "token" }));