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,