diff --git a/packages/server/src/gateway/auth/settings/__tests__/auth-profiles-manager-cache-cap.test.ts b/packages/server/src/gateway/auth/settings/__tests__/auth-profiles-manager-cache-cap.test.ts new file mode 100644 index 000000000..e3e3c21ea --- /dev/null +++ b/packages/server/src/gateway/auth/settings/__tests__/auth-profiles-manager-cache-cap.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, mock, test } from "bun:test"; +import { AuthProfilesManager } from "../auth-profiles-manager.js"; + +/** Probes the bounded-cache invariant: even when the gateway sees many + * distinct one-shot agents, the agentOwner/agentOrg caches must not grow + * unbounded. Without the cap they would accumulate one entry per distinct + * agentId for the pod's lifetime (the existing TTL only refreshes values, + * not map size). Cap is 1024 — exercised at 2048 lookups. */ +describe("AuthProfilesManager: bounded auth-resolver caches", () => { + test("agentOwner cache stays bounded under many distinct one-shot lookups", async () => { + const manager = new AuthProfilesManager({ + ephemeralProfiles: { get: () => undefined } as never, + declaredAgents: { get: () => undefined } as never, + userAuthProfiles: { list: mock(async () => []) } as never, + secretStore: { get: mock(async () => undefined) } as never, + agentOwnerResolver: async (id) => `owner-${id}`, + agentOrgResolver: async (id) => `org-${id}`, + }); + + // 2048 distinct agentIds — twice the cap. + for (let i = 0; i < 2048; i++) { + // @ts-expect-error — exercising private resolver path via friend access + await manager["resolveAgentOwnerUserId"](`agent-${i}`); + // @ts-expect-error + await manager["resolveAgentOrgId"](`agent-${i}`); + } + // @ts-expect-error + expect(manager["agentOwnerCache"].size).toBeLessThanOrEqual(1024); + // @ts-expect-error + expect(manager["agentOrgCache"].size).toBeLessThanOrEqual(1024); + }); +}); diff --git a/packages/server/src/gateway/auth/settings/auth-profiles-manager.ts b/packages/server/src/gateway/auth/settings/auth-profiles-manager.ts index a644c2cb5..05c597bec 100644 --- a/packages/server/src/gateway/auth/settings/auth-profiles-manager.ts +++ b/packages/server/src/gateway/auth/settings/auth-profiles-manager.ts @@ -112,6 +112,17 @@ export class AuthProfilesManager { >(); private static readonly AGENT_OWNER_CACHE_TTL_MS = 60_000; private static readonly AGENT_ORG_CACHE_TTL_MS = 60_000; + /** Hard cap on either cache to bound retention if many distinct agents are + * looked up but never re-queried. When set() crosses this, the oldest + * insertion is evicted (Maps iterate in insertion order). */ + private static readonly AGENT_CACHE_MAX_ENTRIES = 1024; + private cacheSet(cache: Map, key: string, value: V): void { + if (cache.size >= AuthProfilesManager.AGENT_CACHE_MAX_ENTRIES && !cache.has(key)) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + cache.set(key, value); + } private lazyRefreshHooks?: LazyRefreshHooks; constructor(options: AuthProfilesManagerOptions) { @@ -230,7 +241,7 @@ export class AuthProfilesManager { ); return undefined; } - this.agentOwnerCache.set(agentId, { + this.cacheSet(this.agentOwnerCache, agentId, { ownerUserId, expiresAt: Date.now() + AuthProfilesManager.AGENT_OWNER_CACHE_TTL_MS, }); @@ -256,7 +267,7 @@ export class AuthProfilesManager { ); return undefined; } - this.agentOrgCache.set(agentId, { + this.cacheSet(this.agentOrgCache, agentId, { organizationId, expiresAt: Date.now() + AuthProfilesManager.AGENT_ORG_CACHE_TTL_MS, });