From 205c595b134c21e25dee7a7200ce01bcf8ea4327 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Thu, 21 May 2026 10:00:29 -0700 Subject: [PATCH] fix(auth): skip OAuth refresh adapter when credential has no refresh token (#85028) OAuth credentials that loaded without their sidecar material (no access, no refresh) would still enter the refresh path inside the per-profile lock, where the adapter call is bounded by OAUTH_REFRESH_CALL_TIMEOUT_MS (120s). That made the eventual "No API key found for provider" surface to the user only after a long stall, even though the resolver had no usable material to attempt with. Short-circuit doRefreshOAuthTokenWithLock to return null when there is no refresh token to use, after the in-lock main-store adoption and external bootstrap-credential checks have already had a chance to recover. Thanks @romneyda. --- CHANGELOG.md | 1 + .../auth-profiles/oauth-manager.test.ts | 43 +++++++++++++++++++ src/agents/auth-profiles/oauth-manager.ts | 4 ++ 3 files changed, 48 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 734a6ff541b2..8a43b3523449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai - CLI/perf: keep `secrets --help` and `nodes --help` on the precomputed help path so parent help avoids loading action-heavy command runtime modules. (#84818) Thanks @frankekn. - CLI/perf: serve `doctor`, `gateway`, `models`, and `plugins` parent help from startup metadata so common subcommand help avoids full CLI program construction. (#84786) Thanks @frankekn. - Codex/Lossless: keep context-engine history on the canonical run session when Telegram DMs use per-peer runtime policy keys. Fixes #84936. (#84954) Thanks @neeravmakwana. +- Auth/OAuth: skip the refresh adapter when a stored OAuth credential has no refresh token so agent turns fail fast on missing-key instead of waiting on the 120s refresh timeout. Thanks @romneyda. ## 2026.5.20 diff --git a/src/agents/auth-profiles/oauth-manager.test.ts b/src/agents/auth-profiles/oauth-manager.test.ts index ad7a33f0445f..181066979316 100644 --- a/src/agents/auth-profiles/oauth-manager.test.ts +++ b/src/agents/auth-profiles/oauth-manager.test.ts @@ -555,6 +555,49 @@ describe("createOAuthManager", () => { }); }); + it("skips the refresh adapter when the credential has no refresh token", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "oauth-manager-no-refresh-")); + tempDirs.push(tempRoot); + process.env.OPENCLAW_STATE_DIR = tempRoot; + const agentDir = path.join(tempRoot, "agents", "main", "agent"); + await fs.mkdir(agentDir, { recursive: true }); + const profileId = "openai-codex:default"; + const credential = createCredential({ + access: "", + refresh: "", + expires: Date.now() - 60_000, + }); + saveAuthProfileStore( + { + version: 1, + profiles: { + [profileId]: credential, + }, + }, + agentDir, + { filterExternalAuthProfiles: false }, + ); + const refreshCredential = vi.fn(async () => null); + const manager = createOAuthManager({ + buildApiKey: async (_provider, value) => value.access, + refreshCredential, + readBootstrapCredential: () => null, + isRefreshTokenReusedError: () => false, + }); + + const result = await manager.resolveOAuthAccess({ + store: ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { + allowKeychainPrompt: false, + }), + profileId, + credential, + agentDir, + }); + + expect(result).toBeNull(); + expect(refreshCredential).not.toHaveBeenCalled(); + }); + it("redacts the external oauth credential attempted during refresh failures", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "oauth-manager-refresh-redact-")); tempDirs.push(tempRoot); diff --git a/src/agents/auth-profiles/oauth-manager.ts b/src/agents/auth-profiles/oauth-manager.ts index 628cff04059e..6a8564aa7ce4 100644 --- a/src/agents/auth-profiles/oauth-manager.ts +++ b/src/agents/auth-profiles/oauth-manager.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { normalizeSecretInputString } from "../../config/types.secrets.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { withFileLock } from "../../infra/file-lock.js"; import { redactSensitiveText } from "../../logging/redact.js"; @@ -533,6 +534,9 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) { } } + if (normalizeSecretInputString(credentialToRefresh.refresh) === undefined) { + return null; + } const refreshedCredentials = await withRefreshCallTimeout( `refreshOAuthCredential(${cred.provider})`, OAUTH_REFRESH_CALL_TIMEOUT_MS,