Download src/agents/auth-profiles/oauth-manager.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 54.6 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/auth-profiles/oauth-manager.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/agents/auth-profiles/oauth-manager.ts
-
curl -L -o oauth-manager.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/auth-profiles/oauth-manager.ts
54.6 kB
| import { asDateTimestampMs } from "@openclaw/normalization-core/number-coercion"; | |
| /** | |
| * OAuth credential manager. | |
| * Resolves usable access tokens, refreshes expired credentials under global | |
| * locks, adopts safer main-store credentials, and mirrors refreshed tokens. | |
| */ | |
| import type { OpenClawConfig } from "../../config/types.openclaw.js"; | |
| import { normalizeSecretInputString } from "../../config/types.secrets.js"; | |
| import { formatErrorMessage, toErrorObject } from "../../infra/errors.js"; | |
| import { redactSensitiveText } from "../../logging/redact.js"; | |
| import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js"; | |
| import { isUserModelAuthProfileId } from "../../state/user-model-account-id.js"; | |
| import { OAUTH_REFRESH_CALL_TIMEOUT_MS, authProfilesLog } from "./constants.js"; | |
| import { hasUsableOAuthCredential } from "./credential-state.js"; | |
| import { isPersistedExternalCliAuthProfile } from "./external-cli-sync.js"; | |
| import { shouldMirrorRefreshedOAuthCredential } from "./oauth-identity.js"; | |
| import { withOAuthProfileLock } from "./oauth-profile-lock.js"; | |
| import { | |
| OAuthRefreshFailureError, | |
| readProviderOAuthRefreshFailure, | |
| } from "./oauth-refresh-failure.js"; | |
| import { | |
| isExactOAuthCredential, | |
| observeOAuthRefreshFenceSettlement, | |
| observeOAuthRefreshSettlement, | |
| } from "./oauth-refresh-fence.js"; | |
| import { | |
| buildRefreshContentionError, | |
| isGlobalRefreshLockTimeoutError, | |
| } from "./oauth-refresh-lock-errors.js"; | |
| import { | |
| createFailedOAuthRefreshFence, | |
| createOAuthRefreshFence, | |
| isOAuthRefreshFence, | |
| isPendingOAuthRefreshFence, | |
| isSameOAuthRefreshGeneration, | |
| } from "./oauth-refresh-marker.js"; | |
| import { | |
| failOAuthRefreshPeerClaims, | |
| fenceOAuthRefreshPeers, | |
| OAuthRefreshPeerFenceError, | |
| rollbackOAuthRefreshPeerClaims, | |
| settleOAuthRefreshPeerClaims, | |
| type OAuthRefreshPeerClaim, | |
| } from "./oauth-refresh-peers.js"; | |
| import { | |
| hasMatchingOAuthIdentity, | |
| isSafeOAuthOwnerRefreshResult, | |
| isSafeOAuthPostClaimSettlement, | |
| isSafeToAdoptBootstrapOAuthIdentity, | |
| isSafeToAdoptMainStoreOAuthIdentity, | |
| shouldBootstrapFromExternalCliCredential, | |
| } from "./oauth-shared.js"; | |
| import { resolveSharedAuthStorePath } from "./path-resolve.js"; | |
| import { resolveOAuthRefreshLockPath } from "./paths.js"; | |
| import { resolveAuthProfileDatabasePath } from "./sqlite.js"; | |
| import { | |
| ensureAuthProfileStoreWithoutExternalProfiles, | |
| loadAuthProfileStoreWithoutExternalProfiles, | |
| updateAuthProfileStoreWithLock, | |
| } from "./store-runtime.js"; | |
| import { resolvePersistedAuthProfileOwnerAgentDir } from "./store.js"; | |
| import type { AuthProfileStore, OAuthCredential, OAuthCredentials } from "./types.js"; | |
| type OAuthManagerAdapter = { | |
| buildApiKey: ( | |
| provider: string, | |
| credentials: OAuthCredential, | |
| context: { cfg?: OpenClawConfig; agentDir?: string }, | |
| ) => Promise<string>; | |
| refreshCredential: ( | |
| credential: OAuthCredential, | |
| context: { cfg?: OpenClawConfig; agentDir?: string }, | |
| ) => Promise<OAuthCredentials | null>; | |
| canRefreshCredential: ( | |
| credential: OAuthCredential, | |
| context: { cfg?: OpenClawConfig; agentDir?: string }, | |
| ) => Promise<boolean>; | |
| readBootstrapCredential: (params: { | |
| store: AuthProfileStore; | |
| profileId: string; | |
| credential: OAuthCredential; | |
| }) => OAuthCredential | null; | |
| }; | |
| type ResolvedOAuthAccess = { | |
| apiKey: string; | |
| credential: OAuthCredential; | |
| }; | |
| const oauthRefreshCleanupAggregates = new WeakSet<AggregateError>(); | |
| const oauthRefreshRecoveryBuildFailures = new WeakSet<Error>(); | |
| function appendOAuthRefreshCleanupErrors(error: unknown, cleanupErrors: readonly unknown[]): Error { | |
| const primaryError = toErrorObject(error, "OAuth refresh failed"); | |
| if (cleanupErrors.length === 0) { | |
| return primaryError; | |
| } | |
| const normalizedCleanupErrors = cleanupErrors.map((cleanupError) => | |
| toErrorObject(cleanupError, "OAuth refresh cleanup failed"), | |
| ); | |
| const errors = | |
| primaryError instanceof AggregateError && oauthRefreshCleanupAggregates.has(primaryError) | |
| ? [...primaryError.errors, ...normalizedCleanupErrors] | |
| : [primaryError, ...normalizedCleanupErrors]; | |
| const aggregate = new AggregateError( | |
| errors, | |
| "OAuth refresh failed and cleanup could not be completed.", | |
| { cause: errors[0] }, | |
| ); | |
| oauthRefreshCleanupAggregates.add(aggregate); | |
| return aggregate; | |
| } | |
| function readOAuthRefreshInitiatingError(error: unknown): unknown { | |
| return error instanceof AggregateError && | |
| oauthRefreshCleanupAggregates.has(error) && | |
| error.errors.length > 0 | |
| ? error.errors[0] | |
| : error; | |
| } | |
| function createOAuthRefreshUserFacingCause(cause: unknown): unknown { | |
| if (cause instanceof Error && "code" in cause && cause.code === "refresh_contention") { | |
| // The structured error retains diagnostics; public cause traversal must not expose lock paths. | |
| return new Error(cause.message); | |
| } | |
| return cause; | |
| } | |
| /** Refresh failure that preserves a redacted refreshed store and credential. */ | |
| export class OAuthManagerRefreshError extends OAuthRefreshFailureError { | |
| override readonly profileId: string; | |
| readonly code?: string; | |
| readonly lockPath?: string; | |
| readonly #refreshedStore: AuthProfileStore; | |
| readonly #credential: OAuthCredential; | |
| constructor(params: { | |
| credential: OAuthCredential; | |
| attemptedCredentials?: OAuthCredential[]; | |
| profileId: string; | |
| refreshedStore: AuthProfileStore; | |
| cause: unknown; | |
| }) { | |
| const initiatingCause = readOAuthRefreshInitiatingError(params.cause); | |
| const structuredCause = | |
| typeof initiatingCause === "object" && initiatingCause !== null | |
| ? (initiatingCause as { code?: unknown; lockPath?: unknown; cause?: unknown }) | |
| : undefined; | |
| const surfacedCause = createOAuthRefreshUserFacingCause(initiatingCause); | |
| const storedCredential = params.refreshedStore.profiles[params.profileId]; | |
| const secrets = collectOAuthCredentialSecrets( | |
| params.credential, | |
| ...(params.attemptedCredentials ?? []), | |
| storedCredential?.type === "oauth" ? storedCredential : undefined, | |
| ); | |
| const presentation = readProviderOAuthRefreshFailure(initiatingCause); | |
| const causeMessage = formatRedactedOAuthRefreshError(surfacedCause, secrets); | |
| super({ | |
| provider: params.credential.provider, | |
| profileId: params.profileId, | |
| message: `OAuth token refresh failed for ${params.credential.provider}: ${causeMessage}`, | |
| cause: createRedactedOAuthRefreshCause(params.cause, secrets), | |
| errorType: presentation?.errorType, | |
| reason: presentation?.reason, | |
| status: presentation?.status, | |
| summary: presentation?.summary | |
| ? formatRedactedOAuthRefreshError(presentation.summary, secrets) | |
| : undefined, | |
| }); | |
| this.name = "OAuthManagerRefreshError"; | |
| this.#credential = params.credential; | |
| this.profileId = params.profileId; | |
| this.#refreshedStore = params.refreshedStore; | |
| if (structuredCause) { | |
| this.code = typeof structuredCause.code === "string" ? structuredCause.code : undefined; | |
| if (typeof structuredCause.lockPath === "string") { | |
| this.lockPath = structuredCause.lockPath; | |
| } else if ( | |
| typeof structuredCause.cause === "object" && | |
| structuredCause.cause !== null && | |
| "lockPath" in structuredCause.cause && | |
| typeof structuredCause.cause.lockPath === "string" | |
| ) { | |
| this.lockPath = structuredCause.cause.lockPath; | |
| } | |
| } | |
| } | |
| getRefreshedStore(): AuthProfileStore { | |
| return this.#refreshedStore; | |
| } | |
| getCredential(): OAuthCredential { | |
| return this.#credential; | |
| } | |
| toJSON(): { name: string; message: string; profileId: string; provider: string } { | |
| return { | |
| name: this.name, | |
| message: this.message, | |
| profileId: this.profileId, | |
| provider: this.provider, | |
| }; | |
| } | |
| } | |
| function canReuseOAuthCredentialAfterRefreshFailure(params: { | |
| forceRefresh?: boolean; | |
| attempted: OAuthCredential; | |
| candidate: OAuthCredential; | |
| }): boolean { | |
| return ( | |
| !params.forceRefresh || | |
| (params.attempted.provider === params.candidate.provider && | |
| params.attempted.access !== params.candidate.access && | |
| hasMatchingOAuthIdentity(params.attempted, params.candidate)) | |
| ); | |
| } | |
| function collectOAuthCredentialSecrets( | |
| ...credentials: Array<OAuthCredential | undefined> | |
| ): string[] { | |
| const secrets = new Set<string>(); | |
| for (const credential of credentials) { | |
| for (const secret of [credential?.access, credential?.refresh, credential?.idToken]) { | |
| if (secret) { | |
| secrets.add(secret); | |
| } | |
| } | |
| } | |
| return Array.from(secrets).toSorted((a, b) => b.length - a.length); | |
| } | |
| function redactOAuthCredentialSecrets(message: string, secrets: string[]): string { | |
| let redacted = message; | |
| for (const secret of secrets) { | |
| redacted = redacted.split(secret).join("[redacted]"); | |
| } | |
| return redacted; | |
| } | |
| function formatRawErrorMessage(error: unknown): string { | |
| if (error instanceof Error) { | |
| let formatted = error.message || error.name || "Error"; | |
| let cause: unknown = error.cause; | |
| const seen = new Set<unknown>([error]); | |
| while (cause && !seen.has(cause)) { | |
| seen.add(cause); | |
| if (cause instanceof Error) { | |
| if (cause.message) { | |
| formatted += ` | ${cause.message}`; | |
| } | |
| cause = cause.cause; | |
| } else if (typeof cause === "string") { | |
| formatted += ` | ${cause}`; | |
| break; | |
| } else { | |
| break; | |
| } | |
| } | |
| return formatted; | |
| } | |
| if ( | |
| typeof error === "string" || | |
| typeof error === "number" || | |
| typeof error === "boolean" || | |
| typeof error === "bigint" | |
| ) { | |
| return String(error); | |
| } | |
| try { | |
| return JSON.stringify(error) ?? String(error); | |
| } catch { | |
| return Object.prototype.toString.call(error); | |
| } | |
| } | |
| function formatRedactedOAuthRefreshError(error: unknown, secrets: string[]): string { | |
| return redactSensitiveText(redactOAuthCredentialSecrets(formatRawErrorMessage(error), secrets)); | |
| } | |
| function createRedactedOAuthRefreshCause(cause: unknown, secrets: string[]): Error { | |
| if (cause instanceof AggregateError) { | |
| const errors = cause.errors.map((error) => createRedactedOAuthRefreshCause(error, secrets)); | |
| const sanitized = new AggregateError( | |
| errors, | |
| formatRedactedOAuthRefreshError(cause.message, secrets), | |
| errors.length > 0 ? { cause: errors[0] } : undefined, | |
| ); | |
| sanitized.name = cause.name; | |
| return sanitized; | |
| } | |
| const surfacedCause = createOAuthRefreshUserFacingCause(cause); | |
| const redacted = formatRedactedOAuthRefreshError(surfacedCause, secrets); | |
| const sanitized = new Error(redacted); | |
| if (surfacedCause instanceof Error && surfacedCause.name) { | |
| sanitized.name = surfacedCause.name; | |
| } | |
| return sanitized; | |
| } | |
| function loadStoredOAuthRefreshStore(agentDir?: string, profileId?: string): AuthProfileStore { | |
| return loadAuthProfileStoreWithoutExternalProfiles(agentDir, { | |
| allowKeychainPrompt: true, | |
| profileId, | |
| }); | |
| } | |
| /** Select local OAuth unless a safe external bootstrap credential should win. */ | |
| export function resolveEffectiveOAuthCredentialCore(params: { | |
| store: AuthProfileStore; | |
| profileId: string; | |
| credential: OAuthCredential; | |
| readBootstrapCredential: OAuthManagerAdapter["readBootstrapCredential"]; | |
| }): OAuthCredential { | |
| if (isUserModelAuthProfileId(params.profileId)) { | |
| return params.credential; | |
| } | |
| const imported = params.readBootstrapCredential({ | |
| store: params.store, | |
| profileId: params.profileId, | |
| credential: params.credential, | |
| }); | |
| if (!imported) { | |
| return params.credential; | |
| } | |
| if (hasUsableOAuthCredential(params.credential)) { | |
| authProfilesLog.debug("resolved oauth credential from canonical local store", { | |
| profileId: params.profileId, | |
| provider: params.credential.provider, | |
| localExpires: params.credential.expires, | |
| externalExpires: imported.expires, | |
| }); | |
| return params.credential; | |
| } | |
| if (!isSafeToAdoptBootstrapOAuthIdentity(params.credential, imported)) { | |
| authProfilesLog.warn( | |
| "refused external oauth bootstrap credential: identity mismatch or missing binding", | |
| { | |
| profileId: params.profileId, | |
| provider: params.credential.provider, | |
| }, | |
| ); | |
| return params.credential; | |
| } | |
| const shouldBootstrap = shouldBootstrapFromExternalCliCredential({ | |
| existing: params.credential, | |
| imported, | |
| }); | |
| if (shouldBootstrap) { | |
| authProfilesLog.debug("resolved oauth credential from external cli bootstrap", { | |
| profileId: params.profileId, | |
| provider: imported.provider, | |
| localExpires: params.credential.expires, | |
| externalExpires: imported.expires, | |
| }); | |
| return imported; | |
| } | |
| return params.credential; | |
| } | |
| /** Create an OAuth manager bound to provider-specific build/refresh adapters. */ | |
| export function createOAuthManager(adapter: OAuthManagerAdapter) { | |
| function adoptNewerMainOAuthCredential(params: { | |
| store: AuthProfileStore; | |
| profileId: string; | |
| agentDir?: string; | |
| credential: OAuthCredential; | |
| }): OAuthCredential | null { | |
| if (!params.agentDir || isUserModelAuthProfileId(params.profileId)) { | |
| return null; | |
| } | |
| try { | |
| const mainStore = ensureAuthProfileStoreWithoutExternalProfiles(undefined, { | |
| allowKeychainPrompt: false, | |
| }); | |
| const mainCred = mainStore.profiles[params.profileId]; | |
| if (mainCred?.type !== "oauth") { | |
| return null; | |
| } | |
| const mainExpires = asDateTimestampMs(mainCred.expires); | |
| const localExpires = asDateTimestampMs(params.credential.expires); | |
| if ( | |
| mainCred.provider === params.credential.provider && | |
| hasUsableOAuthCredential(mainCred) && | |
| mainExpires !== undefined && | |
| (localExpires === undefined || mainExpires > localExpires) && | |
| isSafeToAdoptMainStoreOAuthIdentity(params.credential, mainCred) | |
| ) { | |
| return mainCred; | |
| } | |
| } catch (err) { | |
| authProfilesLog.debug("adoptNewerMainOAuthCredential failed", { | |
| profileId: params.profileId, | |
| error: formatErrorMessage(err), | |
| }); | |
| } | |
| return null; | |
| } | |
| let refreshQueue = new KeyedAsyncQueue(); | |
| function refreshQueueKey(provider: string, profileId: string): string { | |
| return `${provider}\u0000${profileId}`; | |
| } | |
| class OAuthSettlementCredentialValidationError extends Error { | |
| constructor(cause: unknown, cleanupErrors: readonly unknown[] = []) { | |
| const error = toErrorObject(cause, "OAuth credential validation failed"); | |
| super(error.message, { cause: appendOAuthRefreshCleanupErrors(error, cleanupErrors) }); | |
| this.name = "OAuthSettlementCredentialValidationError"; | |
| } | |
| } | |
| function validateSettlementCredential( | |
| validateCredential: ((credential: OAuthCredential) => void) | undefined, | |
| credential: OAuthCredential, | |
| ): void { | |
| try { | |
| validateCredential?.(credential); | |
| } catch (error) { | |
| throw new OAuthSettlementCredentialValidationError(error); | |
| } | |
| } | |
| async function resolveAuthoritativeSharedOAuthCredentialUnderLock(params: { | |
| profileId: string; | |
| candidate: OAuthCredential; | |
| validateCredential?: (credential: OAuthCredential) => void; | |
| }): Promise<OAuthCredential | undefined> { | |
| let validatedAuthoritative = false; | |
| const acceptAuthoritative = (credential: OAuthCredential): boolean => { | |
| try { | |
| validateSettlementCredential(params.validateCredential, credential); | |
| validatedAuthoritative = true; | |
| return true; | |
| } catch { | |
| authProfilesLog.warn( | |
| "refused shared OAuth credential during settlement: credential validation failed", | |
| { | |
| profileId: params.profileId, | |
| }, | |
| ); | |
| return false; | |
| } | |
| }; | |
| const updated = await updateAuthProfileStoreWithLock({ | |
| agentDir: undefined, | |
| profileId: params.profileId, | |
| sharedStoreWrite: true, | |
| updater: (store) => { | |
| const existing = store.profiles[params.profileId]; | |
| const decision = shouldMirrorRefreshedOAuthCredential({ | |
| existing, | |
| refreshed: params.candidate, | |
| }); | |
| if (!decision.shouldMirror) { | |
| if (decision.reason === "identity-mismatch-or-regression") { | |
| authProfilesLog.warn( | |
| "refused to mirror OAuth credential: identity mismatch or regression", | |
| { | |
| profileId: params.profileId, | |
| }, | |
| ); | |
| } | |
| if (decision.reason === "incoming-not-fresher" && existing?.type === "oauth") { | |
| acceptAuthoritative(existing); | |
| } | |
| return false; | |
| } | |
| if (existing?.type === "oauth" && !acceptAuthoritative(existing)) { | |
| return false; | |
| } | |
| store.profiles[params.profileId] = { ...params.candidate }; | |
| validatedAuthoritative = true; | |
| authProfilesLog.debug("mirrored refreshed OAuth credential to main agent store", { | |
| profileId: params.profileId, | |
| expires: Number.isFinite(params.candidate.expires) | |
| ? new Date(params.candidate.expires).toISOString() | |
| : undefined, | |
| }); | |
| return true; | |
| }, | |
| }); | |
| if (updated === null) { | |
| throw new Error("Failed to read authoritative shared OAuth credential"); | |
| } | |
| if (!validatedAuthoritative) { | |
| return undefined; | |
| } | |
| const authoritative = updated.profiles[params.profileId]; | |
| return authoritative?.type === "oauth" ? authoritative : undefined; | |
| } | |
| async function settlePeerClaimsUnderRefreshLock(params: { | |
| claim: Extract<OAuthRefreshClaim, { kind: "claimed" }>; | |
| claims: readonly OAuthRefreshPeerClaim[]; | |
| replacement: OAuthCredential; | |
| validateCredential?: (credential: OAuthCredential) => void; | |
| }): Promise<void> { | |
| validateSettlementCredential(params.validateCredential, params.replacement); | |
| const authoritativeSharedCredential = | |
| params.claim.authPath === resolveSharedAuthStorePath() | |
| ? params.replacement | |
| : await resolveAuthoritativeSharedOAuthCredentialUnderLock({ | |
| profileId: params.claim.profileId, | |
| candidate: params.replacement, | |
| validateCredential: params.validateCredential, | |
| }); | |
| settleOAuthRefreshPeerClaims({ | |
| profileId: params.claim.profileId, | |
| fence: params.claim.fence, | |
| claims: params.claims, | |
| authoritativeSharedCredential, | |
| replacement: params.replacement, | |
| }); | |
| } | |
| type OAuthRefreshClaim = | |
| | { kind: "unavailable" } | |
| | { | |
| kind: "observe"; | |
| ownerAgentDir?: string; | |
| generation: OAuthCredential; | |
| } | |
| | { kind: "use"; credential: OAuthCredential } | |
| | { | |
| kind: "claimed"; | |
| profileId: string; | |
| credential: OAuthCredential; | |
| fence: OAuthCredential; | |
| ownerAgentDir?: string; | |
| authPath: string; | |
| peerClaims: OAuthRefreshPeerClaim[]; | |
| peerGeneration?: OAuthCredential; | |
| }; | |
| async function settleOAuthRefreshClaim(params: { | |
| agentDir?: string; | |
| profileId: string; | |
| generation: OAuthCredential; | |
| fence: OAuthCredential; | |
| refreshed: OAuthCredential; | |
| }): Promise<{ credential: OAuthCredential; persisted: boolean } | null> { | |
| const current = loadStoredOAuthRefreshStore(params.agentDir, params.profileId).profiles[ | |
| params.profileId | |
| ]; | |
| if ( | |
| current?.type === "oauth" && | |
| !isExactOAuthCredential(current, params.fence) && | |
| isSafeOAuthPostClaimSettlement(params.generation, current) | |
| ) { | |
| return { credential: current, persisted: false }; | |
| } | |
| let credential: OAuthCredential | null = null; | |
| let persisted = false; | |
| const result = await updateAuthProfileStoreWithLock({ | |
| agentDir: params.agentDir, | |
| profileId: params.profileId, | |
| updater: (store) => { | |
| const existing = store.profiles[params.profileId]; | |
| if (existing?.type !== "oauth") { | |
| return false; | |
| } | |
| if (isExactOAuthCredential(existing, params.fence)) { | |
| store.profiles[params.profileId] = { ...params.refreshed }; | |
| credential = params.refreshed; | |
| persisted = true; | |
| return true; | |
| } | |
| // A reconnect or newer owner generation wins. The stale refresh may use | |
| // that live credential for this call, but it never overwrites it. | |
| credential = isSafeOAuthPostClaimSettlement(params.generation, existing) ? existing : null; | |
| return false; | |
| }, | |
| }); | |
| return result === null || !credential ? null : { credential, persisted }; | |
| } | |
| async function markOAuthRefreshClaimFailed(params: { | |
| agentDir?: string; | |
| profileId: string; | |
| fence: OAuthCredential; | |
| settledCredential?: OAuthCredential; | |
| }): Promise<void> { | |
| const updated = await updateAuthProfileStoreWithLock({ | |
| agentDir: params.agentDir, | |
| profileId: params.profileId, | |
| updater: (store) => { | |
| const existing = store.profiles[params.profileId]; | |
| if ( | |
| !isExactOAuthCredential( | |
| existing?.type === "oauth" ? existing : undefined, | |
| params.settledCredential ?? params.fence, | |
| ) | |
| ) { | |
| return false; | |
| } | |
| store.profiles[params.profileId] = createFailedOAuthRefreshFence(params.fence); | |
| return true; | |
| }, | |
| }); | |
| if (updated === null) { | |
| throw new Error("Failed to persist terminal OAuth refresh fence"); | |
| } | |
| } | |
| async function rollbackOAuthRefreshOwnerClaim(params: { | |
| ownerAgentDir?: string; | |
| profileId: string; | |
| fence: OAuthCredential; | |
| original: OAuthCredential; | |
| }): Promise<void> { | |
| let restored = false; | |
| const updated = await updateAuthProfileStoreWithLock({ | |
| agentDir: params.ownerAgentDir, | |
| profileId: params.profileId, | |
| updater: (store) => { | |
| const existing = store.profiles[params.profileId]; | |
| if ( | |
| !isExactOAuthCredential(existing?.type === "oauth" ? existing : undefined, params.fence) | |
| ) { | |
| return false; | |
| } | |
| store.profiles[params.profileId] = { ...params.original }; | |
| restored = true; | |
| return true; | |
| }, | |
| }); | |
| if (updated !== null && restored) { | |
| return; | |
| } | |
| await markOAuthRefreshClaimFailed({ | |
| agentDir: params.ownerAgentDir, | |
| profileId: params.profileId, | |
| fence: params.fence, | |
| }); | |
| } | |
| function mergePeerClaims( | |
| existing: readonly OAuthRefreshPeerClaim[], | |
| discovered: readonly OAuthRefreshPeerClaim[], | |
| ): OAuthRefreshPeerClaim[] { | |
| const claims = new Map(existing.map((claim) => [claim.candidate.databasePath, claim])); | |
| for (const claim of discovered) { | |
| const current = claims.get(claim.candidate.databasePath); | |
| claims.set(claim.candidate.databasePath, current?.original ? current : claim); | |
| } | |
| return [...claims.values()].toSorted((left, right) => | |
| left.candidate.databasePath.localeCompare(right.candidate.databasePath), | |
| ); | |
| } | |
| async function claimOAuthRefresh(params: { | |
| profileId: string; | |
| provider: string; | |
| agentDir?: string; | |
| cfg?: OpenClawConfig; | |
| forceRefresh?: boolean; | |
| attemptedCredential: OAuthCredential; | |
| bootstrapCredential?: OAuthCredential | null; | |
| bootstrapBaseCredential?: OAuthCredential; | |
| validateCredential?: (credential: OAuthCredential) => void; | |
| }): Promise<OAuthRefreshClaim> { | |
| const personalProfile = isUserModelAuthProfileId(params.profileId); | |
| const ownerAgentDir = personalProfile | |
| ? undefined | |
| : resolvePersistedAuthProfileOwnerAgentDir(params); | |
| const authPath = ownerAgentDir | |
| ? resolveAuthProfileDatabasePath(ownerAgentDir) | |
| : resolveSharedAuthStorePath(); | |
| const globalRefreshLockPath = resolveOAuthRefreshLockPath(params.provider, params.profileId); | |
| const peerConfig = params.cfg ?? {}; | |
| try { | |
| return await withOAuthProfileLock( | |
| { provider: params.provider, profileId: params.profileId }, | |
| async () => { | |
| const store = loadStoredOAuthRefreshStore(ownerAgentDir, params.profileId); | |
| const cred = store.profiles[params.profileId]; | |
| if (!cred || cred.type !== "oauth" || cred.provider !== params.provider) { | |
| return { kind: "unavailable" }; | |
| } | |
| const storedFence = isOAuthRefreshFence(cred); | |
| if (!storedFence) { | |
| params.validateCredential?.(cred); | |
| } | |
| let credentialToRefresh = cred; | |
| if ( | |
| !storedFence && | |
| !personalProfile && | |
| isPersistedExternalCliAuthProfile({ | |
| profileId: params.profileId, | |
| credential: cred, | |
| }) | |
| ) { | |
| authProfilesLog.warn( | |
| "refused native OAuth refresh for an externally owned credential", | |
| { | |
| profileId: params.profileId, | |
| provider: cred.provider, | |
| }, | |
| ); | |
| return { kind: "unavailable" }; | |
| } | |
| if ( | |
| params.forceRefresh && | |
| hasUsableOAuthCredential(cred) && | |
| canReuseOAuthCredentialAfterRefreshFailure({ | |
| forceRefresh: true, | |
| attempted: params.attemptedCredential, | |
| candidate: cred, | |
| }) | |
| ) { | |
| return { kind: "use", credential: cred }; | |
| } | |
| if (!storedFence && !params.forceRefresh && hasUsableOAuthCredential(cred)) { | |
| return { kind: "use", credential: cred }; | |
| } | |
| if (!storedFence && params.agentDir && !personalProfile) { | |
| try { | |
| const mainStore = loadStoredOAuthRefreshStore(undefined); | |
| const mainCred = mainStore.profiles[params.profileId]; | |
| if ( | |
| ownerAgentDir && | |
| mainCred?.type === "oauth" && | |
| isSameOAuthRefreshGeneration({ | |
| profileId: params.profileId, | |
| left: cred, | |
| right: mainCred, | |
| }) | |
| ) { | |
| // The main store owns copied refresh generations. A stale owner | |
| // resolution must fail closed instead of claiming the local copy. | |
| return { kind: "unavailable" }; | |
| } | |
| if ( | |
| mainCred?.type === "oauth" && | |
| mainCred.provider === cred.provider && | |
| hasUsableOAuthCredential(mainCred) && | |
| !params.forceRefresh && | |
| isSafeToAdoptMainStoreOAuthIdentity(cred, mainCred) | |
| ) { | |
| params.validateCredential?.(mainCred); | |
| authProfilesLog.info( | |
| "adopted fresh OAuth credential from main store (under refresh lock)", | |
| { | |
| profileId: params.profileId, | |
| agentDir: params.agentDir, | |
| expires: new Date(mainCred.expires).toISOString(), | |
| }, | |
| ); | |
| return { kind: "use", credential: mainCred }; | |
| } else if ( | |
| mainCred?.type === "oauth" && | |
| mainCred.provider === cred.provider && | |
| hasUsableOAuthCredential(mainCred) && | |
| !isSafeToAdoptMainStoreOAuthIdentity(cred, mainCred) | |
| ) { | |
| authProfilesLog.warn( | |
| "refused to adopt fresh main-store OAuth credential: identity mismatch", | |
| { | |
| profileId: params.profileId, | |
| agentDir: params.agentDir, | |
| }, | |
| ); | |
| } | |
| } catch (err) { | |
| authProfilesLog.debug( | |
| "inside-lock main-store adoption failed; proceeding to refresh", | |
| { | |
| profileId: params.profileId, | |
| error: formatErrorMessage(err), | |
| }, | |
| ); | |
| } | |
| } | |
| const externallyManaged = | |
| !personalProfile && | |
| params.bootstrapCredential && | |
| params.bootstrapBaseCredential && | |
| isExactOAuthCredential(cred, params.bootstrapBaseCredential) | |
| ? params.bootstrapCredential | |
| : null; | |
| if (externallyManaged) { | |
| if (externallyManaged.provider !== cred.provider) { | |
| authProfilesLog.warn( | |
| "refused external oauth bootstrap credential: provider mismatch", | |
| { | |
| profileId: params.profileId, | |
| provider: cred.provider, | |
| }, | |
| ); | |
| } else if ( | |
| storedFence || | |
| !isSafeToAdoptBootstrapOAuthIdentity(cred, externallyManaged) | |
| ) { | |
| authProfilesLog.warn( | |
| "refused external oauth bootstrap credential: fenced or identity mismatch", | |
| { | |
| profileId: params.profileId, | |
| provider: cred.provider, | |
| }, | |
| ); | |
| } else { | |
| credentialToRefresh = externallyManaged; | |
| params.validateCredential?.(credentialToRefresh); | |
| if (!params.forceRefresh && hasUsableOAuthCredential(externallyManaged)) { | |
| return { kind: "use", credential: externallyManaged }; | |
| } | |
| } | |
| } | |
| if (storedFence && credentialToRefresh === cred) { | |
| if (isPendingOAuthRefreshFence(cred)) { | |
| return { kind: "observe", ownerAgentDir, generation: cred }; | |
| } | |
| const peerClaims = personalProfile | |
| ? [] | |
| : await fenceOAuthRefreshPeers({ | |
| cfg: peerConfig, | |
| ownerDatabasePath: authPath, | |
| profileId: params.profileId, | |
| generation: cred, | |
| fence: cred, | |
| }); | |
| failOAuthRefreshPeerClaims({ | |
| profileId: params.profileId, | |
| fence: cred, | |
| claims: peerClaims, | |
| }); | |
| return { kind: "unavailable" }; | |
| } | |
| if (normalizeSecretInputString(credentialToRefresh.refresh) === undefined) { | |
| return { kind: "unavailable" }; | |
| } | |
| if ( | |
| !(await adapter.canRefreshCredential(credentialToRefresh, { | |
| cfg: params.cfg, | |
| agentDir: params.agentDir, | |
| })) | |
| ) { | |
| return { kind: "unavailable" }; | |
| } | |
| const fence = createOAuthRefreshFence({ | |
| profileId: params.profileId, | |
| credential: credentialToRefresh, | |
| }); | |
| let claimed = false; | |
| const updated = await updateAuthProfileStoreWithLock({ | |
| agentDir: ownerAgentDir, | |
| profileId: params.profileId, | |
| updater: (authoritative) => { | |
| const existing = authoritative.profiles[params.profileId]; | |
| if ( | |
| !isExactOAuthCredential(existing?.type === "oauth" ? existing : undefined, cred) | |
| ) { | |
| return false; | |
| } | |
| authoritative.profiles[params.profileId] = fence; | |
| claimed = true; | |
| return true; | |
| }, | |
| }); | |
| if (updated === null || !claimed) { | |
| const current = loadStoredOAuthRefreshStore(ownerAgentDir, params.profileId).profiles[ | |
| params.profileId | |
| ]; | |
| if (current?.type !== "oauth" || current.provider !== params.provider) { | |
| return { kind: "unavailable" }; | |
| } | |
| if (!isOAuthRefreshFence(current)) { | |
| params.validateCredential?.(current); | |
| } | |
| if (isPendingOAuthRefreshFence(current)) { | |
| return { | |
| kind: "observe", | |
| ownerAgentDir, | |
| generation: current, | |
| }; | |
| } | |
| if (isOAuthRefreshFence(current)) { | |
| const peerClaims = personalProfile | |
| ? [] | |
| : await fenceOAuthRefreshPeers({ | |
| cfg: peerConfig, | |
| ownerDatabasePath: authPath, | |
| profileId: params.profileId, | |
| generation: current, | |
| fence: current, | |
| }); | |
| failOAuthRefreshPeerClaims({ | |
| profileId: params.profileId, | |
| fence: current, | |
| claims: peerClaims, | |
| }); | |
| return { kind: "unavailable" }; | |
| } | |
| return hasUsableOAuthCredential(current) | |
| ? { kind: "use", credential: current } | |
| : { kind: "unavailable" }; | |
| } | |
| let peerClaims: OAuthRefreshPeerClaim[] = []; | |
| const peerGeneration = credentialToRefresh === cred ? cred : undefined; | |
| try { | |
| if (!personalProfile && peerGeneration) { | |
| peerClaims = await fenceOAuthRefreshPeers({ | |
| cfg: peerConfig, | |
| ownerDatabasePath: authPath, | |
| profileId: params.profileId, | |
| generation: peerGeneration, | |
| fence, | |
| rollbackOnFailure: false, | |
| }); | |
| } | |
| } catch (error) { | |
| if (error instanceof OAuthRefreshPeerFenceError) { | |
| peerClaims = mergePeerClaims(peerClaims, error.claims); | |
| } | |
| const cleanupErrors: unknown[] = []; | |
| try { | |
| rollbackOAuthRefreshPeerClaims({ | |
| profileId: params.profileId, | |
| fence, | |
| claims: peerClaims, | |
| }); | |
| } catch (cleanupError) { | |
| cleanupErrors.push(cleanupError); | |
| } | |
| try { | |
| await rollbackOAuthRefreshOwnerClaim({ | |
| ownerAgentDir, | |
| profileId: params.profileId, | |
| fence, | |
| original: cred, | |
| }); | |
| } catch (cleanupError) { | |
| cleanupErrors.push(cleanupError); | |
| } | |
| if (cleanupErrors.length > 0) { | |
| throw new AggregateError( | |
| [error, ...cleanupErrors], | |
| "Failed to claim OAuth refresh ownership and roll back partial claims.", | |
| { cause: error }, | |
| ); | |
| } | |
| throw error; | |
| } | |
| return { | |
| kind: "claimed", | |
| profileId: params.profileId, | |
| credential: credentialToRefresh, | |
| fence, | |
| ownerAgentDir, | |
| authPath, | |
| peerClaims, | |
| ...(peerGeneration ? { peerGeneration } : {}), | |
| }; | |
| }, | |
| ); | |
| } catch (error) { | |
| if (isGlobalRefreshLockTimeoutError(error, globalRefreshLockPath)) { | |
| throw buildRefreshContentionError({ | |
| provider: params.provider, | |
| profileId: params.profileId, | |
| cause: error, | |
| }); | |
| } | |
| throw error; | |
| } | |
| } | |
| async function refreshOAuthTokenWithLock(params: { | |
| profileId: string; | |
| provider: string; | |
| agentDir?: string; | |
| cfg?: OpenClawConfig; | |
| forceRefresh?: boolean; | |
| attemptedCredential: OAuthCredential; | |
| attemptedCredentials?: OAuthCredential[]; | |
| bootstrapCredential?: OAuthCredential | null; | |
| bootstrapBaseCredential?: OAuthCredential; | |
| validateCredential?: (credential: OAuthCredential) => void; | |
| }): Promise<ResolvedOAuthAccess | null> { | |
| const claim = await claimOAuthRefresh(params); | |
| if (claim.kind === "unavailable") { | |
| return null; | |
| } | |
| if (claim.kind === "observe") { | |
| const observed = await observeOAuthRefreshFenceSettlement({ | |
| label: `refreshOAuthCredential(${params.provider})`, | |
| timeoutMs: OAUTH_REFRESH_CALL_TIMEOUT_MS, | |
| read: () => | |
| loadStoredOAuthRefreshStore(claim.ownerAgentDir, params.profileId).profiles[ | |
| params.profileId | |
| ], | |
| isPending: (credential) => | |
| credential?.type === "oauth" && | |
| credential.provider === claim.generation.provider && | |
| isPendingOAuthRefreshFence(credential), | |
| resolve: async (credential) => { | |
| if ( | |
| credential?.type !== "oauth" || | |
| !isSafeOAuthPostClaimSettlement(claim.generation, credential) | |
| ) { | |
| return null; | |
| } | |
| params.validateCredential?.(credential); | |
| return { | |
| apiKey: await adapter.buildApiKey(credential.provider, credential, { | |
| cfg: params.cfg, | |
| agentDir: params.agentDir, | |
| }), | |
| credential, | |
| }; | |
| }, | |
| }); | |
| return observed; | |
| } | |
| if (claim.kind === "use") { | |
| params.validateCredential?.(claim.credential); | |
| return { | |
| apiKey: await adapter.buildApiKey(claim.credential.provider, claim.credential, { | |
| cfg: params.cfg, | |
| agentDir: params.agentDir, | |
| }), | |
| credential: claim.credential, | |
| }; | |
| } | |
| params.attemptedCredentials?.push(claim.credential); | |
| const peerConfig = params.cfg ?? {}; | |
| let activePeerClaims = claim.peerClaims; | |
| type FailureSettlement = { | |
| supersedingOwner: OAuthCredential | null; | |
| validationError: OAuthSettlementCredentialValidationError | null; | |
| cleanupErrors: unknown[]; | |
| }; | |
| const failClaim = async (): Promise<FailureSettlement> => { | |
| let result: FailureSettlement = { | |
| supersedingOwner: null, | |
| validationError: null, | |
| cleanupErrors: [], | |
| }; | |
| try { | |
| await withOAuthProfileLock( | |
| { provider: params.provider, profileId: params.profileId }, | |
| async () => { | |
| const cleanupErrors: unknown[] = []; | |
| let supersedingOwner: OAuthCredential | null = null; | |
| let validationError: OAuthSettlementCredentialValidationError | null = null; | |
| try { | |
| const owner = loadStoredOAuthRefreshStore(claim.ownerAgentDir, params.profileId) | |
| .profiles[params.profileId]; | |
| supersedingOwner = | |
| owner?.type === "oauth" && | |
| !isExactOAuthCredential(owner, claim.fence) && | |
| isSafeOAuthPostClaimSettlement(claim.credential, owner) && | |
| canReuseOAuthCredentialAfterRefreshFailure({ | |
| forceRefresh: params.forceRefresh, | |
| attempted: claim.credential, | |
| candidate: owner, | |
| }) | |
| ? owner | |
| : null; | |
| } catch (error) { | |
| cleanupErrors.push(error); | |
| } | |
| if (claim.peerGeneration) { | |
| try { | |
| activePeerClaims = mergePeerClaims( | |
| activePeerClaims, | |
| await fenceOAuthRefreshPeers({ | |
| cfg: peerConfig, | |
| ownerDatabasePath: claim.authPath, | |
| profileId: params.profileId, | |
| generation: claim.peerGeneration, | |
| fence: claim.fence, | |
| rollbackOnFailure: false, | |
| }), | |
| ); | |
| } catch (error) { | |
| cleanupErrors.push(error); | |
| if (error instanceof OAuthRefreshPeerFenceError) { | |
| activePeerClaims = mergePeerClaims(activePeerClaims, error.claims); | |
| } | |
| } | |
| } | |
| if (supersedingOwner && cleanupErrors.length === 0) { | |
| try { | |
| await settlePeerClaimsUnderRefreshLock({ | |
| claim, | |
| claims: activePeerClaims, | |
| replacement: supersedingOwner, | |
| validateCredential: params.validateCredential, | |
| }); | |
| result = { supersedingOwner, validationError, cleanupErrors }; | |
| return; | |
| } catch (error) { | |
| if (error instanceof OAuthSettlementCredentialValidationError) { | |
| validationError = error; | |
| } else { | |
| cleanupErrors.push(error); | |
| } | |
| } | |
| } | |
| try { | |
| failOAuthRefreshPeerClaims({ | |
| profileId: params.profileId, | |
| fence: claim.fence, | |
| claims: activePeerClaims, | |
| }); | |
| } catch (error) { | |
| cleanupErrors.push(error); | |
| } | |
| try { | |
| await markOAuthRefreshClaimFailed({ | |
| agentDir: claim.ownerAgentDir, | |
| profileId: params.profileId, | |
| fence: claim.fence, | |
| }); | |
| } catch (error) { | |
| cleanupErrors.push(error); | |
| } | |
| result = { supersedingOwner: null, validationError, cleanupErrors }; | |
| }, | |
| ); | |
| return result; | |
| } catch (error) { | |
| return { | |
| supersedingOwner: null, | |
| validationError: result.validationError, | |
| cleanupErrors: [...result.cleanupErrors, error], | |
| }; | |
| } | |
| }; | |
| const settleFailure = async (failure?: { | |
| error: unknown; | |
| }): Promise<ResolvedOAuthAccess | null> => { | |
| const initiatingError = failure | |
| ? toErrorObject(failure.error, "OAuth refresh failed") | |
| : undefined; | |
| const { supersedingOwner, validationError, cleanupErrors } = await failClaim(); | |
| if (validationError) { | |
| throw new OAuthSettlementCredentialValidationError(validationError, cleanupErrors); | |
| } | |
| if (supersedingOwner) { | |
| try { | |
| params.validateCredential?.(supersedingOwner); | |
| return { | |
| apiKey: await adapter.buildApiKey(supersedingOwner.provider, supersedingOwner, { | |
| cfg: params.cfg, | |
| agentDir: params.agentDir, | |
| }), | |
| credential: supersedingOwner, | |
| }; | |
| } catch (error) { | |
| const combinedFailure = | |
| initiatingError !== undefined | |
| ? appendOAuthRefreshCleanupErrors(initiatingError, [...cleanupErrors, error]) | |
| : appendOAuthRefreshCleanupErrors(error, cleanupErrors); | |
| oauthRefreshRecoveryBuildFailures.add(combinedFailure); | |
| throw combinedFailure; | |
| } | |
| } | |
| if (initiatingError !== undefined) { | |
| throw appendOAuthRefreshCleanupErrors(initiatingError, cleanupErrors); | |
| } | |
| if (cleanupErrors.length === 0) { | |
| return null; | |
| } | |
| throw appendOAuthRefreshCleanupErrors(cleanupErrors[0], cleanupErrors.slice(1)); | |
| }; | |
| const settlement = (async (): Promise<ResolvedOAuthAccess | null> => { | |
| let refreshed: OAuthCredentials | null; | |
| try { | |
| refreshed = await adapter.refreshCredential(claim.credential, { | |
| cfg: params.cfg, | |
| agentDir: params.agentDir, | |
| }); | |
| } catch (error) { | |
| return await settleFailure({ error }); | |
| } | |
| if (!refreshed) { | |
| return await settleFailure(); | |
| } | |
| try { | |
| const rotated = { | |
| ...claim.credential, | |
| ...refreshed, | |
| type: "oauth", | |
| } satisfies OAuthCredential; | |
| if (!hasUsableOAuthCredential(rotated, { refreshMarginMs: 0 })) { | |
| throw new Error("OAuth refresh returned an unusable credential"); | |
| } | |
| if (!isSafeOAuthOwnerRefreshResult(claim.credential, rotated)) { | |
| throw new Error("OAuth refresh returned credentials for a different OAuth account"); | |
| } | |
| params.validateCredential?.(rotated); | |
| const settled = await withOAuthProfileLock( | |
| { provider: params.provider, profileId: params.profileId }, | |
| async () => { | |
| if (claim.peerGeneration) { | |
| try { | |
| activePeerClaims = mergePeerClaims( | |
| activePeerClaims, | |
| await fenceOAuthRefreshPeers({ | |
| cfg: peerConfig, | |
| ownerDatabasePath: claim.authPath, | |
| profileId: params.profileId, | |
| generation: claim.peerGeneration, | |
| fence: claim.fence, | |
| rollbackOnFailure: false, | |
| }), | |
| ); | |
| } catch (error) { | |
| if (error instanceof OAuthRefreshPeerFenceError) { | |
| activePeerClaims = mergePeerClaims(activePeerClaims, error.claims); | |
| } | |
| throw error; | |
| } | |
| } | |
| const claimSettlement = await settleOAuthRefreshClaim({ | |
| agentDir: claim.ownerAgentDir, | |
| profileId: params.profileId, | |
| generation: claim.credential, | |
| fence: claim.fence, | |
| refreshed: rotated, | |
| }); | |
| if (!claimSettlement) { | |
| return null; | |
| } | |
| try { | |
| await settlePeerClaimsUnderRefreshLock({ | |
| claim, | |
| claims: activePeerClaims, | |
| replacement: claimSettlement.credential, | |
| validateCredential: params.validateCredential, | |
| }); | |
| } catch (peerSettlementError) { | |
| if (peerSettlementError instanceof OAuthSettlementCredentialValidationError) { | |
| const cleanupErrors: unknown[] = []; | |
| try { | |
| failOAuthRefreshPeerClaims({ | |
| profileId: params.profileId, | |
| fence: claim.fence, | |
| claims: activePeerClaims, | |
| }); | |
| } catch (error) { | |
| cleanupErrors.push(error); | |
| } | |
| if (claimSettlement.persisted) { | |
| try { | |
| await markOAuthRefreshClaimFailed({ | |
| agentDir: claim.ownerAgentDir, | |
| profileId: params.profileId, | |
| fence: claim.fence, | |
| settledCredential: claimSettlement.credential, | |
| }); | |
| } catch (error) { | |
| cleanupErrors.push(error); | |
| } | |
| } | |
| throw new OAuthSettlementCredentialValidationError( | |
| peerSettlementError, | |
| cleanupErrors, | |
| ); | |
| } | |
| try { | |
| failOAuthRefreshPeerClaims({ | |
| profileId: params.profileId, | |
| fence: claim.fence, | |
| claims: activePeerClaims, | |
| }); | |
| } catch (error) { | |
| authProfilesLog.warn("failed to terminally fence an OAuth refresh peer", { | |
| profileId: params.profileId, | |
| error: formatErrorMessage(error), | |
| }); | |
| } | |
| authProfilesLog.warn("OAuth refresh peer settlement degraded", { | |
| profileId: params.profileId, | |
| error: formatErrorMessage(peerSettlementError), | |
| }); | |
| } | |
| return claimSettlement; | |
| }, | |
| ); | |
| if (!settled) { | |
| throw new Error("Failed to persist refreshed OAuth credential"); | |
| } | |
| params.validateCredential?.(settled.credential); | |
| return { | |
| apiKey: await adapter.buildApiKey(settled.credential.provider, settled.credential, { | |
| cfg: params.cfg, | |
| agentDir: params.agentDir, | |
| }), | |
| credential: settled.credential, | |
| }; | |
| } catch (error) { | |
| if (error instanceof OAuthSettlementCredentialValidationError) { | |
| throw error; | |
| } | |
| return await settleFailure({ error }); | |
| } | |
| })(); | |
| // The caller deadline observes the owner; it never cancels durable settlement. | |
| void settlement.catch(() => {}); | |
| return await observeOAuthRefreshSettlement( | |
| `refreshOAuthCredential(${claim.credential.provider})`, | |
| OAUTH_REFRESH_CALL_TIMEOUT_MS, | |
| settlement, | |
| ); | |
| } | |
| async function refreshOAuthTokenQueued( | |
| params: Parameters<typeof refreshOAuthTokenWithLock>[0], | |
| ): Promise<ResolvedOAuthAccess | null> { | |
| return await refreshQueue.enqueue(refreshQueueKey(params.provider, params.profileId), () => | |
| refreshOAuthTokenWithLock(params), | |
| ); | |
| } | |
| async function resolveOAuthAccess(params: { | |
| store: AuthProfileStore; | |
| profileId: string; | |
| credential: OAuthCredential; | |
| agentDir?: string; | |
| cfg?: OpenClawConfig; | |
| forceRefresh?: boolean; | |
| validateCredential?: (credential: OAuthCredential) => void; | |
| }): Promise<ResolvedOAuthAccess | null> { | |
| const personalProfile = isUserModelAuthProfileId(params.profileId); | |
| let credential = params.credential; | |
| if (personalProfile) { | |
| const owned = loadStoredOAuthRefreshStore(params.agentDir, params.profileId).profiles[ | |
| params.profileId | |
| ]; | |
| if (owned?.type !== "oauth") { | |
| return null; | |
| } | |
| credential = owned; | |
| } | |
| const newerMainCredential = adoptNewerMainOAuthCredential({ | |
| store: params.store, | |
| profileId: params.profileId, | |
| agentDir: params.agentDir, | |
| credential, | |
| }); | |
| if (newerMainCredential) { | |
| params.validateCredential?.(newerMainCredential); | |
| params.store.profiles[params.profileId] = { ...newerMainCredential }; | |
| authProfilesLog.info("adopted newer OAuth credentials from main agent", { | |
| profileId: params.profileId, | |
| agentDir: params.agentDir, | |
| expires: new Date(newerMainCredential.expires).toISOString(), | |
| }); | |
| } | |
| const adoptedCredential = newerMainCredential ?? credential; | |
| const bootstrapCredential = personalProfile | |
| ? null | |
| : adapter.readBootstrapCredential({ | |
| store: params.store, | |
| profileId: params.profileId, | |
| credential: adoptedCredential, | |
| }); | |
| const effectiveCredential = resolveEffectiveOAuthCredentialCore({ | |
| store: params.store, | |
| profileId: params.profileId, | |
| credential: adoptedCredential, | |
| readBootstrapCredential: () => bootstrapCredential, | |
| }); | |
| const attemptedCredentials: OAuthCredential[] = []; | |
| if ( | |
| !params.forceRefresh && | |
| !isOAuthRefreshFence(adoptedCredential) && | |
| hasUsableOAuthCredential(effectiveCredential) | |
| ) { | |
| params.validateCredential?.(effectiveCredential); | |
| return { | |
| apiKey: await adapter.buildApiKey(effectiveCredential.provider, effectiveCredential, { | |
| cfg: params.cfg, | |
| agentDir: params.agentDir, | |
| }), | |
| credential: effectiveCredential, | |
| }; | |
| } | |
| try { | |
| const refreshed = await refreshOAuthTokenQueued({ | |
| profileId: params.profileId, | |
| provider: credential.provider, | |
| agentDir: params.agentDir, | |
| cfg: params.cfg, | |
| forceRefresh: params.forceRefresh, | |
| attemptedCredential: effectiveCredential, | |
| attemptedCredentials, | |
| bootstrapCredential, | |
| bootstrapBaseCredential: adoptedCredential, | |
| validateCredential: params.validateCredential, | |
| }); | |
| return refreshed; | |
| } catch (error) { | |
| let refreshError: unknown = error; | |
| let recoveryBuildFailed = | |
| refreshError instanceof OAuthSettlementCredentialValidationError || | |
| (refreshError instanceof Error && oauthRefreshRecoveryBuildFailures.has(refreshError)); | |
| let refreshedStore = params.store; | |
| let recoveryStoreLoaded = false; | |
| const buildRecoveryAccess = async ( | |
| candidate: OAuthCredential, | |
| ): Promise<ResolvedOAuthAccess | null> => { | |
| try { | |
| params.validateCredential?.(candidate); | |
| return { | |
| apiKey: await adapter.buildApiKey(candidate.provider, candidate, { | |
| cfg: params.cfg, | |
| agentDir: params.agentDir, | |
| }), | |
| credential: candidate, | |
| }; | |
| } catch (cleanupError) { | |
| refreshError = appendOAuthRefreshCleanupErrors(refreshError, [cleanupError]); | |
| recoveryBuildFailed = true; | |
| return null; | |
| } | |
| }; | |
| try { | |
| refreshedStore = loadStoredOAuthRefreshStore(params.agentDir, params.profileId); | |
| recoveryStoreLoaded = true; | |
| } catch (cleanupError) { | |
| refreshError = appendOAuthRefreshCleanupErrors(refreshError, [cleanupError]); | |
| } | |
| const claimedGeneration = attemptedCredentials.at(-1) ?? effectiveCredential; | |
| const refreshed = refreshedStore.profiles[params.profileId]; | |
| if (recoveryStoreLoaded && !recoveryBuildFailed) { | |
| if ( | |
| refreshed?.type === "oauth" && | |
| isSafeOAuthPostClaimSettlement(claimedGeneration, refreshed) && | |
| canReuseOAuthCredentialAfterRefreshFailure({ | |
| forceRefresh: params.forceRefresh, | |
| attempted: claimedGeneration, | |
| candidate: refreshed, | |
| }) | |
| ) { | |
| const recovered = await buildRecoveryAccess(refreshed); | |
| if (recovered) { | |
| return recovered; | |
| } | |
| } | |
| } | |
| if (recoveryStoreLoaded && params.agentDir && !personalProfile && !recoveryBuildFailed) { | |
| try { | |
| const mainStore = ensureAuthProfileStoreWithoutExternalProfiles(undefined, { | |
| allowKeychainPrompt: false, | |
| }); | |
| const mainCred = mainStore.profiles[params.profileId]; | |
| if ( | |
| mainCred?.type === "oauth" && | |
| isSafeOAuthPostClaimSettlement(claimedGeneration, mainCred) && | |
| canReuseOAuthCredentialAfterRefreshFailure({ | |
| forceRefresh: params.forceRefresh, | |
| attempted: claimedGeneration, | |
| candidate: mainCred, | |
| }) | |
| ) { | |
| params.validateCredential?.(mainCred); | |
| refreshedStore.profiles[params.profileId] = { ...mainCred }; | |
| authProfilesLog.info("inherited fresh OAuth credentials from main agent", { | |
| profileId: params.profileId, | |
| agentDir: params.agentDir, | |
| expires: new Date(mainCred.expires).toISOString(), | |
| }); | |
| const recovered = await buildRecoveryAccess(mainCred); | |
| if (recovered) { | |
| return recovered; | |
| } | |
| } | |
| } catch { | |
| // keep the original refresh error below | |
| } | |
| } | |
| throw new OAuthManagerRefreshError({ | |
| credential, | |
| attemptedCredentials: [effectiveCredential, ...attemptedCredentials], | |
| profileId: params.profileId, | |
| refreshedStore, | |
| cause: refreshError, | |
| }); | |
| } | |
| } | |
| function resetRefreshQueuesForTest(): void { | |
| refreshQueue = new KeyedAsyncQueue(); | |
| } | |
| return { | |
| resolveOAuthAccess, | |
| resetRefreshQueuesForTest, | |
| }; | |
| } | |
| /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ | |