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.
This commit is contained in:
Dallin Romney
2026-05-21 10:00:29 -07:00
committed by GitHub
parent 178e510aae
commit 205c595b13
3 changed files with 48 additions and 0 deletions
+1
View File
@@ -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
@@ -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);
@@ -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,