diff --git a/extensions/microsoft-foundry/index.test.ts b/extensions/microsoft-foundry/index.test.ts index e9643774e00b..39a5f0e9ba22 100644 --- a/extensions/microsoft-foundry/index.test.ts +++ b/extensions/microsoft-foundry/index.test.ts @@ -126,6 +126,7 @@ const defaultFoundryModelId = "gpt-5.4"; const defaultFoundryProfileId = "microsoft-foundry:entra"; const defaultFoundryAgentDir = "/tmp/test-agent"; const defaultAzureCliLoginError = "Please run 'az login' to setup account."; +const foundryTokenCacheMaxEntries = 128; let runtimeAuthTestSequence = 0; let runtimeAuthTestTenantId = "tenant-0"; @@ -615,6 +616,34 @@ describe("microsoft-foundry plugin", () => { expect(requireRuntimeAuthResult(second).apiKey).toBe("deduped-token"); }); + it("bounds settled Entra tokens by least-recently-used account tuple", async () => { + const provider = registerProvider(); + const prepareRuntimeAuth = requirePrepareRuntimeAuth(provider); + execFileMock.mockImplementation(async () => ({ + stdout: JSON.stringify({ + accessToken: `token-${execFileMock.mock.calls.length}`, + expiresOn: new Date(Date.now() + 10 * 60_000).toISOString(), + }), + stderr: "", + })); + const prepareForTenant = async (tenantId: string) => { + ensureAuthProfileStoreMock.mockReturnValueOnce(buildEntraProfileStore({ tenantId })); + return await prepareRuntimeAuth(buildFoundryRuntimeAuthContext()); + }; + + for (let index = 0; index < foundryTokenCacheMaxEntries; index += 1) { + await prepareForTenant(`lru-${runtimeAuthTestTenantId}-${index}`); + } + expect(execFileMock).toHaveBeenCalledTimes(foundryTokenCacheMaxEntries); + + await prepareForTenant(`lru-${runtimeAuthTestTenantId}-0`); + expect(execFileMock).toHaveBeenCalledTimes(foundryTokenCacheMaxEntries); + + await prepareForTenant(`lru-${runtimeAuthTestTenantId}-${foundryTokenCacheMaxEntries}`); + await prepareForTenant(`lru-${runtimeAuthTestTenantId}-1`); + expect(execFileMock).toHaveBeenCalledTimes(foundryTokenCacheMaxEntries + 2); + }); + it("clears failed refresh state so later concurrent retries succeed", async () => { const provider = registerProvider(); const prepareRuntimeAuth = requirePrepareRuntimeAuth(provider); diff --git a/extensions/microsoft-foundry/runtime.ts b/extensions/microsoft-foundry/runtime.ts index fc17210b0d4d..6863dadc2d33 100644 --- a/extensions/microsoft-foundry/runtime.ts +++ b/extensions/microsoft-foundry/runtime.ts @@ -1,4 +1,5 @@ // Microsoft Foundry plugin module implements runtime behavior. +import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime"; import type { ProviderPreparedRuntimeAuth, ProviderPrepareRuntimeAuthContext, @@ -27,6 +28,9 @@ import { const cachedTokens = new Map(); const refreshPromises = new Map>(); const FOUNDRY_TOKEN_FALLBACK_LIFETIME_MS = 55 * 60 * 1000; +// Bound settled credential material across profile generations. In-flight +// refresh ownership remains separate so admission never evicts active work. +const FOUNDRY_TOKEN_CACHE_MAX_ENTRIES = 128; async function refreshEntraToken(params?: { scope?: string; @@ -40,10 +44,18 @@ async function refreshEntraToken(params?: { asDateTimestampMs(rawExpiry) ?? resolveExpiresAtMsFromDurationMs(FOUNDRY_TOKEN_FALLBACK_LIFETIME_MS, { nowMs: now }) ?? now; - cachedTokens.set(getFoundryTokenCacheKey(params), { + for (const [cacheKey, cachedToken] of cachedTokens) { + if (cachedToken.expiresAt <= now) { + cachedTokens.delete(cacheKey); + } + } + const cacheKey = getFoundryTokenCacheKey(params); + cachedTokens.delete(cacheKey); + cachedTokens.set(cacheKey, { token: result.accessToken, expiresAt, }); + pruneMapToMaxSize(cachedTokens, FOUNDRY_TOKEN_CACHE_MAX_ENTRIES); return { apiKey: result.accessToken, expiresAt }; } @@ -107,6 +119,9 @@ export async function prepareFoundryRuntimeAuth( const refreshAfterMs = resolveExpiresAtMsFromDurationMs(TOKEN_REFRESH_MARGIN_MS, { nowMs: now }) ?? now; if (cachedToken && hasValidClock && cachedToken.expiresAt > refreshAfterMs) { + // Map insertion order is the eviction order; touch valid hits to retain active accounts. + cachedTokens.delete(cacheKey); + cachedTokens.set(cacheKey, cachedToken); return { apiKey: cachedToken.token, expiresAt: cachedToken.expiresAt, @@ -116,6 +131,7 @@ export async function prepareFoundryRuntimeAuth( }, }; } + cachedTokens.delete(cacheKey); let refreshPromise = refreshPromises.get(cacheKey); if (!refreshPromise) { refreshPromise = refreshEntraToken({