Skip to content

Commit 03231c0

Browse files
authored
fix(auth): prevent stale auth store reverts (#53211)
1 parent 47bdc36 commit 03231c0

5 files changed

Lines changed: 93 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
1414

1515
- 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.
1616
- 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.
17+
- 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.
1718
- 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.
1819
- Diagnostics/cache trace: strip credential fields from cache-trace JSONL output while preserving non-sensitive diagnostic fields and image redaction metadata.
1920
- 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.

src/agents/auth-profiles.markauthprofilefailure.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import os from "node:os";
33
import path from "node:path";
44
import { describe, expect, it } from "vitest";
55
import {
6+
clearRuntimeAuthProfileStoreSnapshots,
67
calculateAuthProfileCooldownMs,
78
ensureAuthProfileStore,
89
markAuthProfileFailure,
10+
replaceRuntimeAuthProfileStoreSnapshots,
911
} from "./auth-profiles.js";
1012

1113
type AuthProfileStore = ReturnType<typeof ensureAuthProfileStore>;
@@ -48,6 +50,73 @@ function expectCooldownInRange(remainingMs: number, minMs: number, maxMs: number
4850
}
4951

5052
describe("markAuthProfileFailure", () => {
53+
it("does not overwrite fresher on-disk credentials with a stale runtime snapshot", async () => {
54+
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
55+
try {
56+
const authPath = path.join(agentDir, "auth-profiles.json");
57+
fs.writeFileSync(
58+
authPath,
59+
JSON.stringify({
60+
version: 1,
61+
profiles: {
62+
"openai:default": {
63+
type: "api_key",
64+
provider: "openai",
65+
key: "sk-expired-old",
66+
},
67+
},
68+
}),
69+
);
70+
71+
replaceRuntimeAuthProfileStoreSnapshots([
72+
{
73+
agentDir,
74+
store: ensureAuthProfileStore(agentDir),
75+
},
76+
]);
77+
78+
fs.writeFileSync(
79+
authPath,
80+
JSON.stringify({
81+
version: 1,
82+
profiles: {
83+
"openai:default": {
84+
type: "api_key",
85+
provider: "openai",
86+
key: "sk-fresh-new",
87+
},
88+
},
89+
}),
90+
);
91+
92+
const staleRuntimeStore = ensureAuthProfileStore(agentDir);
93+
const staleCredential = staleRuntimeStore.profiles["openai:default"];
94+
expect(staleCredential?.type).toBe("api_key");
95+
expect(staleCredential && "key" in staleCredential ? staleCredential.key : undefined).toBe(
96+
"sk-expired-old",
97+
);
98+
99+
await markAuthProfileFailure({
100+
store: staleRuntimeStore,
101+
profileId: "openai:default",
102+
reason: "rate_limit",
103+
agentDir,
104+
});
105+
106+
clearRuntimeAuthProfileStoreSnapshots();
107+
const reloaded = ensureAuthProfileStore(agentDir);
108+
const reloadedCredential = reloaded.profiles["openai:default"];
109+
expect(reloadedCredential?.type).toBe("api_key");
110+
expect(
111+
reloadedCredential && "key" in reloadedCredential ? reloadedCredential.key : undefined,
112+
).toBe("sk-fresh-new");
113+
expect(typeof reloaded.usageStats?.["openai:default"]?.cooldownUntil).toBe("number");
114+
} finally {
115+
clearRuntimeAuthProfileStoreSnapshots();
116+
fs.rmSync(agentDir, { recursive: true, force: true });
117+
}
118+
});
119+
51120
it("disables billing failures for ~5 hours by default", async () => {
52121
await withAuthProfileStore(async ({ agentDir, store }) => {
53122
const startedAt = Date.now();

src/agents/auth-profiles/store.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,10 @@ export async function updateAuthProfileStoreWithLock(params: {
130130

131131
try {
132132
return await withFileLock(authPath, AUTH_STORE_LOCK_OPTIONS, async () => {
133-
const store = ensureAuthProfileStore(params.agentDir);
133+
// Locked writers must reload from disk, not from any runtime snapshot.
134+
// Otherwise a live gateway can overwrite fresher CLI/config-auth writes
135+
// with stale in-memory auth state during usage/cooldown updates.
136+
const store = loadAuthProfileStoreForAgent(params.agentDir);
134137
const shouldSave = params.updater(store);
135138
if (shouldSave) {
136139
saveAuthProfileStore(store, params.agentDir);

src/commands/models/auth.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,23 @@ describe("modelsAuthLoginCommand", () => {
315315
}
316316
});
317317

318+
it("writes pasted tokens to the resolved agent store", async () => {
319+
const runtime = createRuntime();
320+
mocks.clackText.mockResolvedValue("tok-fresh");
321+
322+
await modelsAuthPasteTokenCommand({ provider: "openai" }, runtime);
323+
324+
expect(mocks.upsertAuthProfile).toHaveBeenCalledWith({
325+
profileId: "openai:manual",
326+
credential: {
327+
type: "token",
328+
provider: "openai",
329+
token: "tok-fresh",
330+
},
331+
agentDir: "/tmp/openclaw/agents/main",
332+
});
333+
});
334+
318335
it("runs token auth for any token-capable provider plugin", async () => {
319336
const runtime = createRuntime();
320337
const runTokenAuth = vi.fn().mockResolvedValue({

src/commands/models/auth.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,7 @@ export async function modelsAuthPasteTokenCommand(
359359
},
360360
runtime: RuntimeEnv,
361361
) {
362+
const { agentDir } = await resolveModelsAuthContext();
362363
const rawProvider = opts.provider?.trim();
363364
if (!rawProvider) {
364365
throw new Error("Missing --provider.");
@@ -385,6 +386,7 @@ export async function modelsAuthPasteTokenCommand(
385386
token,
386387
...(expires ? { expires } : {}),
387388
},
389+
agentDir,
388390
});
389391

390392
await updateConfig((cfg) => applyAuthProfileConfig(cfg, { profileId, provider, mode: "token" }));

0 commit comments

Comments
 (0)