diff --git a/packages/core/src/agent-store.ts b/packages/core/src/agent-store.ts index 051f6030e..df56e61f8 100644 --- a/packages/core/src/agent-store.ts +++ b/packages/core/src/agent-store.ts @@ -8,7 +8,6 @@ import type { PluginsConfig } from "./plugin-types"; import type { - AuthProfile, InstalledProvider, McpServerConfig, ModelSelectionState, @@ -53,8 +52,6 @@ export interface AgentSettings { toolsConfig?: ToolsConfig; /** OpenClaw plugin configuration */ pluginsConfig?: PluginsConfig; - /** Ordered auth profiles (index 0 = primary). Used for multi-provider credential management. */ - authProfiles?: AuthProfile[]; /** Installed providers for this agent (index 0 = primary). */ installedProviders?: InstalledProvider[]; /** Enable verbose logging (show tool calls, reasoning, etc.) */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d5007ac9f..90544687a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -112,6 +112,7 @@ export type { AuthProfile, CliBackendConfig, ConversationMessage, + DeclaredCredential, HistoryMessage, InstalledProvider, InstructionContext, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 84147f0ca..5a4550d16 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -55,7 +55,9 @@ export interface CliBackendConfig { /** * Unified authentication profile for any model provider. - * Stored in AgentSettings.authProfiles as an ordered array (index 0 = primary). + * Persisted per-(userId, agentId) by the gateway's UserAuthProfileStore; + * also synthesized at read time from declared credentials and SDK-supplied + * ephemeral credentials. * * **Invariant:** at any point in time, a profile has **exactly one** credential * source set — either `credentialRef` (persisted profiles resolved through the @@ -91,6 +93,22 @@ export function hasCredentialSource(profile: AuthProfile): boolean { return Boolean(profile.credential || profile.credentialRef); } +/** + * Declared provider credential — a credential that ships with the agent's + * declared configuration (`lobu.toml` or SDK `GatewayConfig.agents`). + * + * Declared credentials are read-only at runtime. They are merged into the + * effective auth profile list when no user-scoped profile exists for the + * `(agentId, provider)` pair. + */ +export interface DeclaredCredential { + provider: string; + /** Plaintext key — present when the file/SDK supplies a value directly. */ + key?: string; + /** Persisted secret reference — present when the file/SDK supplies a ref. */ + secretRef?: SecretRef; +} + export interface SessionContext { // Core identifiers platform: string; // Platform identifier (e.g., "slack", "discord", "teams") diff --git a/packages/gateway/src/__tests__/agent-settings-store.test.ts b/packages/gateway/src/__tests__/agent-settings-store.test.ts index e33db82c3..1525780b3 100644 --- a/packages/gateway/src/__tests__/agent-settings-store.test.ts +++ b/packages/gateway/src/__tests__/agent-settings-store.test.ts @@ -1,54 +1,18 @@ -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - test, -} from "bun:test"; +import { beforeEach, describe, expect, test } from "bun:test"; import { MockRedisClient } from "@lobu/core/testing"; -import { AuthProfilesManager } from "../auth/settings/auth-profiles-manager"; import { AgentSettingsStore } from "../auth/settings/agent-settings-store"; -import { RedisSecretStore } from "../secrets"; - -const TEST_ENCRYPTION_KEY = - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - -let originalEncryptionKey: string | undefined; - -beforeAll(() => { - originalEncryptionKey = process.env.ENCRYPTION_KEY; - process.env.ENCRYPTION_KEY = TEST_ENCRYPTION_KEY; -}); - -afterAll(() => { - if (originalEncryptionKey !== undefined) { - process.env.ENCRYPTION_KEY = originalEncryptionKey; - } else { - delete process.env.ENCRYPTION_KEY; - } -}); function createStore(redis?: MockRedisClient) { const r = redis ?? new MockRedisClient(); - const secretStore = new RedisSecretStore(r as any, "lobu:test:secrets:"); - const store = new AgentSettingsStore(r as any, secretStore); - const authProfilesManager = new AuthProfilesManager(store, secretStore); - return { store, redis: r, secretStore, authProfilesManager }; + const store = new AgentSettingsStore(r as any); + return { store, redis: r }; } describe("AgentSettingsStore", () => { - let redis: MockRedisClient; let store: AgentSettingsStore; - let secretStore: RedisSecretStore; - let authProfilesManager: AuthProfilesManager; beforeEach(() => { - const created = createStore(); - redis = created.redis; - store = created.store; - secretStore = created.secretStore; - authProfilesManager = created.authProfilesManager; + store = createStore().store; }); describe("CRUD basics", () => { @@ -115,465 +79,13 @@ describe("AgentSettingsStore", () => { }); }); - describe("secret refs for authProfiles.credential", () => { - test("credential is normalized to a ref and encrypted in the secret store", async () => { - const apiKey = "sk-ant-secret-key-12345"; - await store.saveSettings("agent-1", { - authProfiles: [ - { - id: "profile-1", - provider: "anthropic", - model: "claude-sonnet-4", - credential: apiKey, - label: "test", - authType: "api-key", - createdAt: Date.now(), - }, - ], - }); - - const rawData = await redis.get("agent:settings:agent-1"); - expect(rawData).not.toBeNull(); - const parsed = JSON.parse(rawData!); - expect(parsed.authProfiles[0].credential).toBeUndefined(); - expect(parsed.authProfiles[0].credentialRef).toMatch(/^secret:\/\//); - - const resolved = await secretStore.get( - parsed.authProfiles[0].credentialRef - ); - expect(resolved).toBe(apiKey); - - const [, secretKeys] = await redis.scan( - "0", - "MATCH", - "lobu:test:secrets:*" - ); - expect(secretKeys).toHaveLength(1); - const rawSecret = await redis.get(secretKeys[0]!); - expect(rawSecret).not.toContain(apiKey); - - const [result] = await authProfilesManager.listProfiles("agent-1"); - expect(result!.credential).toBe(apiKey); - // Resolved view maintains the AuthProfile invariant: exactly one of - // credential / credentialRef is set. Since the credential was resolved - // from the ref, only `credential` should be present on the view. - expect(result!.credentialRef).toBeUndefined(); - }); - }); - - describe("secret refs for refreshToken", () => { - test("refreshToken is normalized to a ref and resolved on read", async () => { - const refreshToken = "rt-secret-refresh-token-xyz"; - await store.saveSettings("agent-1", { - authProfiles: [ - { - id: "profile-1", - provider: "anthropic", - model: "claude-sonnet-4", - credential: "sk-key", - label: "test", - authType: "oauth", - metadata: { - refreshToken, - email: "user@example.com", - }, - createdAt: Date.now(), - }, - ], - }); - - const rawData = await redis.get("agent:settings:agent-1"); - const parsed = JSON.parse(rawData!); - expect(parsed.authProfiles[0].metadata.refreshToken).toBeUndefined(); - expect(parsed.authProfiles[0].metadata.refreshTokenRef).toMatch( - /^secret:\/\// - ); - - const resolved = await secretStore.get( - parsed.authProfiles[0].metadata.refreshTokenRef - ); - expect(resolved).toBe(refreshToken); - - const [result] = await authProfilesManager.listProfiles("agent-1"); - expect(result!.metadata!.refreshToken).toBe(refreshToken); - }); - }); - - describe("ref persistence", () => { - test("existing secret refs survive unrelated settings updates", async () => { - await store.saveSettings("agent-1", { - authProfiles: [ - { - id: "profile-1", - provider: "anthropic", - model: "claude-sonnet-4", - credential: "sk-key", - label: "test", - authType: "api-key", - createdAt: Date.now(), - }, - ], - }); - - const rawAfterFirst = await redis.get("agent:settings:agent-1"); - const parsedFirst = JSON.parse(rawAfterFirst!); - const credentialRef = parsedFirst.authProfiles[0].credentialRef; - - await store.updateSettings("agent-1", { model: "claude-opus-4" }); - - const rawAfterSecond = await redis.get("agent:settings:agent-1"); - const parsedSecond = JSON.parse(rawAfterSecond!); - expect(parsedSecond.authProfiles[0].credentialRef).toBe(credentialRef); - const [result] = await authProfilesManager.listProfiles("agent-1"); - expect(result!.credential).toBe("sk-key"); - }); - - test("rotated refresh token rewrites the underlying secret value", async () => { - await store.saveSettings("agent-1", { - authProfiles: [ - { - id: "profile-1", - provider: "anthropic", - model: "claude-sonnet-4", - credential: "sk-key", - label: "test", - authType: "oauth", - metadata: { refreshToken: "rt-original" }, - createdAt: Date.now(), - }, - ], - }); - - const [initial] = await authProfilesManager.listProfiles("agent-1"); - const originalRef = initial!.metadata!.refreshTokenRef!; - expect(initial!.metadata!.refreshToken).toBe("rt-original"); - - // Simulate TokenRefreshJob updating the profile with a new plaintext - // refreshToken on top of the existing refreshTokenRef. The store MUST - // rewrite the secret value, not drop the new plaintext on the floor. - const existing = await store.getSettings("agent-1"); - const updated = existing!.authProfiles!.map((p) => ({ - ...p, - metadata: { - ...(p.metadata || {}), - refreshToken: "rt-rotated", - }, - })); - await store.updateSettings("agent-1", { authProfiles: updated }); - - const [after] = await authProfilesManager.listProfiles("agent-1"); - expect(after!.metadata!.refreshTokenRef).toBe(originalRef); - expect(after!.metadata!.refreshToken).toBe("rt-rotated"); - }); - }); - - describe("missing encryption key", () => { - test("fails to persist secret-backed settings when ENCRYPTION_KEY is missing", async () => { - const savedKey = process.env.ENCRYPTION_KEY; - delete process.env.ENCRYPTION_KEY; - - try { - const { store: noEncStore } = createStore(); - - const apiKey = "sk-plaintext-key"; - await expect( - noEncStore.saveSettings("agent-1", { - authProfiles: [ - { - id: "profile-1", - provider: "anthropic", - model: "claude-sonnet-4", - credential: apiKey, - label: "test", - authType: "api-key", - createdAt: Date.now(), - }, - ], - }) - ).rejects.toThrow("ENCRYPTION_KEY"); - } finally { - process.env.ENCRYPTION_KEY = savedKey; - } - }); - }); - - describe("cascade delete", () => { - test("deleteSettings removes auth profile secrets from the store", async () => { - await store.saveSettings("agent-1", { - authProfiles: [ - { - id: "profile-1", - provider: "anthropic", - model: "*", - credential: "sk-one", - label: "one", - authType: "api-key", - createdAt: Date.now(), - }, - { - id: "profile-2", - provider: "openai", - model: "*", - credential: "sk-two", - label: "two", - authType: "oauth", - metadata: { refreshToken: "rt-two" }, - createdAt: Date.now(), - }, - ], - }); - - // Sanity: both credentials + the refresh token are in the store. - const before = await secretStore.list("agents/agent-1/"); - expect(before).toHaveLength(3); - - await store.deleteSettings("agent-1"); - - const after = await secretStore.list("agents/agent-1/"); - expect(after).toHaveLength(0); - expect(await store.getSettings("agent-1")).toBeNull(); - }); - - test("deleteProviderProfiles removes only the targeted profile's secrets", async () => { - await store.saveSettings("agent-1", { - authProfiles: [ - { - id: "profile-1", - provider: "anthropic", - model: "*", - credential: "sk-anthropic", - label: "a", - authType: "api-key", - createdAt: Date.now(), - }, - { - id: "profile-2", - provider: "openai", - model: "*", - credential: "sk-openai", - label: "o", - authType: "api-key", - createdAt: Date.now(), - }, - ], - }); - - await authProfilesManager.deleteProviderProfiles("agent-1", "openai"); - - const remaining = await secretStore.list("agents/agent-1/"); - expect(remaining).toHaveLength(1); - expect(remaining[0]?.name).toBe( - "agents/agent-1/auth-profiles/profile-1/credential" - ); - - const [onlyProfile] = await authProfilesManager.listProfiles("agent-1"); - expect(onlyProfile?.provider).toBe("anthropic"); - }); - }); - - describe("shared ephemeral profile registry", () => { - test("ephemeral profiles registered on one manager are visible to others", async () => { - // Two managers built against the same store — simulates core-services - // and a provider module each constructing their own manager. - const managerA = new AuthProfilesManager(store, secretStore); - const managerB = new AuthProfilesManager(store, secretStore); - - managerA.registerEphemeralProfile({ - agentId: "agent-1", - provider: "anthropic", - credential: "sk-ephemeral", - authType: "api-key", - label: "from sdk", - }); - - const viaA = await managerA.listProfiles("agent-1"); - const viaB = await managerB.listProfiles("agent-1"); - expect(viaA).toHaveLength(1); - expect(viaB).toHaveLength(1); - expect(viaB[0]?.credential).toBe("sk-ephemeral"); - expect(await managerB.hasProviderProfiles("agent-1", "anthropic")).toBe( - true - ); - }); - }); - - describe("runtime credential resolver", () => { - test("returns runtime plaintext credentials without persisting settings", async () => { - const resolverCalls: Array> = []; - const runtimeStore = new AgentSettingsStore(redis as any, secretStore, { - runtimeCredentialResolver: async (input) => { - resolverCalls.push({ - agentId: input.agentId, - provider: input.provider, - model: input.model, - userId: input.userId, - }); - return { - credential: "sk-runtime-user-key", - label: "runtime override", - }; - }, - }); - const runtimeManager = new AuthProfilesManager(runtimeStore, secretStore); - - const profile = await runtimeManager.getBestProfile( - "agent-1", - "openai", - "gpt-5", - { userId: "user-123" } - ); - - expect(profile?.credential).toBe("sk-runtime-user-key"); - expect(profile?.label).toBe("runtime override"); - expect(await runtimeStore.getSettings("agent-1")).toBeNull(); - expect(await secretStore.list("agents/agent-1/")).toHaveLength(0); - expect(resolverCalls).toEqual([ - { - agentId: "agent-1", - provider: "openai", - model: "gpt-5", - userId: "user-123", - }, - ]); - }); - - test("resolves runtime credential refs through the configured secret store", async () => { - const runtimeRef = await secretStore.put( - "runtime/openai/user-456", - "sk-runtime-ref-key" - ); - const runtimeStore = new AgentSettingsStore(redis as any, secretStore, { - runtimeCredentialResolver: () => ({ - credentialRef: runtimeRef, - authType: "oauth", - metadata: { email: "user@example.com" }, - }), - }); - const runtimeManager = new AuthProfilesManager(runtimeStore, secretStore); - - const profile = await runtimeManager.getBestProfile("agent-1", "openai"); - - expect(profile?.credential).toBe("sk-runtime-ref-key"); - expect(profile?.credentialRef).toBeUndefined(); - expect(profile?.authType).toBe("oauth"); - expect(profile?.metadata?.email).toBe("user@example.com"); - expect(await runtimeStore.getSettings("agent-1")).toBeNull(); - }); - - test("swallows resolver errors and falls back to persisted profiles", async () => { - const persistedStore = new AgentSettingsStore(redis as any, secretStore, { - runtimeCredentialResolver: () => { - throw new Error("resolver boom"); - }, - }); - await persistedStore.saveSettings("agent-1", { - authProfiles: [ - { - id: "persisted", - provider: "openai", - model: "*", - credential: "sk-persisted-fallback", - label: "persisted", - authType: "api-key", - createdAt: Date.now(), - }, - ], - }); - const persistedManager = new AuthProfilesManager( - persistedStore, - secretStore - ); - - const profile = await persistedManager.getBestProfile( - "agent-1", - "openai" - ); - expect(profile?.credential).toBe("sk-persisted-fallback"); - }); - - test("ignores resolver results with neither credential nor credentialRef", async () => { - const emptyStore = new AgentSettingsStore(redis as any, secretStore, { - runtimeCredentialResolver: () => ({ label: "nothing" }), - }); - const emptyManager = new AuthProfilesManager(emptyStore, secretStore); - - const profile = await emptyManager.getBestProfile("agent-1", "openai"); - expect(profile).toBeNull(); - }); - - test("ignores context fields that would override explicit lookup args", async () => { - const calls: Array> = []; - const contextStore = new AgentSettingsStore(redis as any, secretStore, { - runtimeCredentialResolver: (input) => { - calls.push({ ...input }); - return { credential: "sk-context-safe" }; - }, - }); - const contextManager = new AuthProfilesManager(contextStore, secretStore); - - await contextManager.getBestProfile("agent-1", "openai", "gpt-5", { - userId: "user-42", - // @ts-expect-error — simulate a malformed context trying to override - agentId: "evil-agent", - provider: "evil-provider", - } as any); - - expect(calls[0]).toMatchObject({ - agentId: "agent-1", - provider: "openai", - model: "gpt-5", - userId: "user-42", - }); - }); - }); - - describe("findTemplateAgentId", () => { - test("returns first agent with installedProviders", async () => { - // Agent without installedProviders - await store.saveSettings("agent-no-providers", { - model: "claude-sonnet-4", - }); - - // Agent with installedProviders - await store.saveSettings("agent-with-providers", { - model: "claude-opus-4", - installedProviders: [ - { - id: "anthropic", - displayName: "Anthropic", - envVarName: "ANTHROPIC_API_KEY", - upstreamBaseUrl: "https://api.anthropic.com", - }, - ], - }); - - const templateId = await store.findTemplateAgentId(); - expect(templateId).toBe("agent-with-providers"); - }); - - test("returns null when no agents have providers", async () => { - await store.saveSettings("agent-1", { model: "claude-sonnet-4" }); - await store.saveSettings("agent-2", { model: "claude-opus-4" }); - - const templateId = await store.findTemplateAgentId(); - expect(templateId).toBeNull(); - }); - }); - describe("findSandboxAgentIds", () => { test("returns agent IDs referencing template", async () => { const templateId = "template-agent"; await store.saveSettings(templateId, { model: "claude-opus-4", - installedProviders: [ - { - id: "anthropic", - displayName: "Anthropic", - envVarName: "ANTHROPIC_API_KEY", - upstreamBaseUrl: "https://api.anthropic.com", - }, - ], + installedProviders: [{ providerId: "anthropic", installedAt: 1 }], }); await store.saveSettings("sandbox-1", { @@ -586,10 +98,7 @@ describe("AgentSettingsStore", () => { templateAgentId: templateId, }); - // Unrelated agent - await store.saveSettings("other-agent", { - model: "claude-sonnet-4", - }); + await store.saveSettings("other-agent", { model: "claude-sonnet-4" }); const sandboxIds = await store.findSandboxAgentIds(templateId); expect(sandboxIds).toHaveLength(2); diff --git a/packages/gateway/src/__tests__/core-services-store-selection.test.ts b/packages/gateway/src/__tests__/core-services-store-selection.test.ts index fce195d72..ef9ef1d47 100644 --- a/packages/gateway/src/__tests__/core-services-store-selection.test.ts +++ b/packages/gateway/src/__tests__/core-services-store-selection.test.ts @@ -162,25 +162,22 @@ describe("CoreServices store selection", () => { (coreServices as any).queue = new MockMessageQueue(); await (coreServices as any).initializeSessionServices(); - - const agentSettingsStore = coreServices.getAgentSettingsStore(); - await agentSettingsStore.saveSettings("agent-1", { - authProfiles: [ - { - id: "profile-1", - provider: "openai", - model: "*", - credential: "sk-host-store-only", - label: "host-backed", - authType: "api-key", - createdAt: Date.now(), - }, - ], + await (coreServices as any).initializeClaudeServices(); + + const authProfilesManager = coreServices.getAuthProfilesManager(); + expect(authProfilesManager).toBeDefined(); + await authProfilesManager!.upsertProfile({ + agentId: "agent-1", + userId: "user-1", + provider: "openai", + credential: "sk-host-store-only", + label: "host-backed", + authType: "api-key", }); const redis = (coreServices as any).queue.getRedisClient(); - const rawSettings = await redis.get("agent:settings:agent-1"); - expect(rawSettings).toContain("host://"); + const rawProfiles = await redis.get("user:auth-profiles:user-1:agent-1"); + expect(rawProfiles).toContain("host://"); const [, redisSecretKeys] = await redis.scan( "0", @@ -189,7 +186,9 @@ describe("CoreServices store selection", () => { ); expect(redisSecretKeys).toHaveLength(0); - const hostEntries = await hostStore.list("agents/agent-1/"); + const hostEntries = await hostStore.list( + "users/user-1/agents/agent-1/auth-profiles/" + ); expect(hostEntries).toHaveLength(1); expect(await hostStore.get(hostEntries[0]!.ref)).toBe("sk-host-store-only"); }); diff --git a/packages/gateway/src/__tests__/declared-agent-registry.test.ts b/packages/gateway/src/__tests__/declared-agent-registry.test.ts new file mode 100644 index 000000000..77874c3e3 --- /dev/null +++ b/packages/gateway/src/__tests__/declared-agent-registry.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test"; +import { + buildRegistryMap, + DeclaredAgentRegistry, + entryFromAgentConfig, + entryFromFileLoadedAgent, +} from "../services/declared-agent-registry"; + +describe("DeclaredAgentRegistry", () => { + test("starts empty", () => { + const registry = new DeclaredAgentRegistry(); + expect(registry.agentIds()).toEqual([]); + expect(registry.has("anything")).toBe(false); + expect(registry.get("anything")).toBeUndefined(); + expect(registry.findTemplateAgentId()).toBeNull(); + }); + + test("replaceAll wipes prior entries", () => { + const registry = new DeclaredAgentRegistry(); + registry.replaceAll(new Map([["a", { settings: {}, credentials: [] }]])); + expect(registry.has("a")).toBe(true); + + registry.replaceAll(new Map([["b", { settings: {}, credentials: [] }]])); + expect(registry.has("a")).toBe(false); + expect(registry.has("b")).toBe(true); + }); + + test("findTemplateAgentId returns first agent with installed providers", () => { + const registry = new DeclaredAgentRegistry(); + registry.replaceAll( + new Map([ + ["bare", { settings: {}, credentials: [] }], + [ + "with-providers", + { + settings: { + installedProviders: [{ providerId: "openai", installedAt: 1 }], + }, + credentials: [], + }, + ], + ]) + ); + expect(registry.findTemplateAgentId()).toBe("with-providers"); + }); +}); + +describe("entryFromFileLoadedAgent", () => { + test("preserves settings and credentials from file loader", () => { + const entry = entryFromFileLoadedAgent({ + agentId: "careops", + settings: { + installedProviders: [{ providerId: "gemini", installedAt: 5 }], + }, + credentials: [ + { provider: "gemini", key: "k1" }, + { provider: "openai", secretRef: "vault://openai/key" }, + ], + } as any); + + expect(entry.settings.installedProviders).toEqual([ + { providerId: "gemini", installedAt: 5 }, + ]); + expect(entry.credentials).toEqual([ + { provider: "gemini", key: "k1" }, + { provider: "openai", secretRef: "vault://openai/key" }, + ]); + }); +}); + +describe("entryFromAgentConfig", () => { + test("expands providers into installed list, credentials, and model preferences", () => { + const entry = entryFromAgentConfig({ + id: "agent-1", + name: "Agent 1", + providers: [ + { id: "openai", model: "gpt-4o", key: "sk-1" }, + { id: "anthropic", secretRef: "vault://anth" }, + ], + network: { allowed: ["github.com"] }, + nixPackages: ["jq"], + } as any); + + expect(entry.settings.installedProviders).toEqual([ + { providerId: "openai", installedAt: expect.any(Number) }, + { providerId: "anthropic", installedAt: expect.any(Number) }, + ]); + expect(entry.settings.providerModelPreferences).toEqual({ + openai: "gpt-4o", + }); + expect(entry.settings.modelSelection).toEqual({ mode: "auto" }); + expect(entry.settings.networkConfig).toEqual({ + allowedDomains: ["github.com"], + deniedDomains: undefined, + }); + expect(entry.settings.nixConfig).toEqual({ packages: ["jq"] }); + expect(entry.credentials).toEqual([ + { provider: "openai", key: "sk-1" }, + { provider: "anthropic", secretRef: "vault://anth" }, + ]); + }); +}); + +describe("buildRegistryMap", () => { + test("merges file and config sources, with config overriding on shared id", () => { + const map = buildRegistryMap( + [ + { + agentId: "shared", + settings: { + installedProviders: [{ providerId: "z-ai", installedAt: 1 }], + }, + credentials: [], + } as any, + { + agentId: "file-only", + settings: {}, + credentials: [], + } as any, + ], + [ + { + id: "shared", + name: "Shared", + providers: [{ id: "openai", key: "sk-2" }], + } as any, + ] + ); + + expect(map.get("file-only")).toBeDefined(); + const shared = map.get("shared"); + expect(shared?.settings.installedProviders?.[0]?.providerId).toBe("openai"); + expect(shared?.credentials).toEqual([{ provider: "openai", key: "sk-2" }]); + }); +}); diff --git a/packages/gateway/src/__tests__/platform-helpers-model-resolution.test.ts b/packages/gateway/src/__tests__/platform-helpers-model-resolution.test.ts index 2c6090007..4db91008d 100644 --- a/packages/gateway/src/__tests__/platform-helpers-model-resolution.test.ts +++ b/packages/gateway/src/__tests__/platform-helpers-model-resolution.test.ts @@ -252,27 +252,34 @@ describe("resolveAgentOptions model resolution", () => { }); describe("hasConfiguredProvider", () => { - test("accepts inherited template credentials from effective settings", async () => { + test("accepts declared agents with credentials regardless of system keys", async () => { + const { DeclaredAgentRegistry } = await import( + "../services/declared-agent-registry" + ); const settingsStore = { - getEffectiveSettings: async () => - ({ - authProfiles: [ - { - id: "profile-1", - provider: "z-ai", - credential: "secret", - authType: "api-key", - label: "z.ai", - model: "*", - createdAt: 1, - }, - ], - installedProviders: [{ providerId: "z-ai", installedAt: 1 }], - }) as any, + getEffectiveSettings: async () => null, }; + const declaredAgents = new DeclaredAgentRegistry(); + declaredAgents.replaceAll( + new Map([ + [ + "telegram-6570514069", + { + settings: { + installedProviders: [{ providerId: "z-ai", installedAt: 1 }], + }, + credentials: [{ provider: "z-ai", key: "secret" }], + }, + ], + ]) + ); await expect( - hasConfiguredProvider("telegram-6570514069", settingsStore as any) + hasConfiguredProvider( + "telegram-6570514069", + settingsStore as any, + declaredAgents + ) ).resolves.toBe(true); }); }); diff --git a/packages/gateway/src/__tests__/provider-inheritance.test.ts b/packages/gateway/src/__tests__/provider-inheritance.test.ts index 393546133..0c4c7cad3 100644 --- a/packages/gateway/src/__tests__/provider-inheritance.test.ts +++ b/packages/gateway/src/__tests__/provider-inheritance.test.ts @@ -11,15 +11,19 @@ import { ProviderCatalogService, resolveInstalledProviders, } from "../auth/provider-catalog"; -import { AgentSettingsStore } from "../auth/settings/agent-settings-store"; +import { + AgentSettingsStore, + EphemeralAuthProfileRegistry, +} from "../auth/settings/agent-settings-store"; import { AuthProfilesManager } from "../auth/settings/auth-profiles-manager"; import { canEditSettingsSection, canViewSettingsSection, resolveSettingsView, } from "../auth/settings/resolved-settings-view"; -import { buildDefaultSettingsFromSource } from "../auth/settings/template-utils"; +import { UserAuthProfileStore } from "../auth/settings/user-auth-profile-store"; import { RedisSecretStore } from "../secrets"; +import { DeclaredAgentRegistry } from "../services/declared-agent-registry"; import { hasConfiguredProvider } from "../services/platform-helpers"; const TEST_ENCRYPTION_KEY = @@ -44,13 +48,22 @@ describe("sandbox provider inheritance", () => { let redis: MockRedisClient; let store: AgentSettingsStore; let secretStore: RedisSecretStore; + let userAuthProfiles: UserAuthProfileStore; + let declaredAgents: DeclaredAgentRegistry; let authProfilesManager: AuthProfilesManager; beforeEach(() => { redis = new MockRedisClient(); secretStore = new RedisSecretStore(redis as any, "lobu:test:secrets:"); - store = new AgentSettingsStore(redis as any, secretStore); - authProfilesManager = new AuthProfilesManager(store, secretStore); + store = new AgentSettingsStore(redis as any); + userAuthProfiles = new UserAuthProfileStore(redis as any, secretStore); + declaredAgents = new DeclaredAgentRegistry(); + authProfilesManager = new AuthProfilesManager({ + ephemeralProfiles: new EphemeralAuthProfileRegistry(), + declaredAgents, + userAuthProfiles, + secretStore, + }); }); test("inherits installed providers through metadata and connection template fallback", async () => { @@ -74,93 +87,147 @@ describe("sandbox provider inheritance", () => { expect(providers).toEqual([{ providerId: "z-ai", installedAt: 1 }]); }); - test("inherits auth profiles through metadata and connection template fallback", async () => { - await store.saveSettings("template-agent", { - authProfiles: [ - { - id: "profile-1", - provider: "z-ai", - credential: "secret", - authType: "api-key", - label: "z.ai", - model: "*", - createdAt: 1, - }, - ], - installedProviders: [{ providerId: "z-ai", installedAt: 1 }], - }); - await redis.set( - "agent_metadata:telegram-6570514069", - JSON.stringify({ parentConnectionId: "conn-1" }) - ); - await redis.set( - "connection:conn-1", - JSON.stringify({ templateAgentId: "template-agent" }) + test("declared credentials surface as synthesized profiles", async () => { + declaredAgents.replaceAll( + new Map([ + [ + "template-agent", + { + settings: { + installedProviders: [{ providerId: "z-ai", installedAt: 1 }], + }, + credentials: [{ provider: "z-ai", key: "secret" }], + }, + ], + ]) ); - const profiles = await authProfilesManager.listProfiles( - "telegram-6570514069" - ); + const profiles = await authProfilesManager.listProfiles("template-agent"); expect(profiles).toHaveLength(1); expect(profiles[0]?.provider).toBe("z-ai"); expect(profiles[0]?.credential).toBe("secret"); + expect(profiles[0]?.id).toBe("declared:template-agent:z-ai"); }); - test("inherits auth profiles for cloned sandbox settings that copied providers", async () => { - await store.saveSettings("template-agent", { - authProfiles: [ - { - id: "profile-1", - provider: "z-ai", - credential: "secret", - authType: "api-key", - label: "z.ai", - model: "*", - createdAt: 1, - }, - ], - installedProviders: [{ providerId: "z-ai", installedAt: 1 }], + test("user-scoped profile takes precedence over declared credential", async () => { + declaredAgents.replaceAll( + new Map([ + [ + "template-agent", + { + settings: { + installedProviders: [{ providerId: "z-ai", installedAt: 1 }], + }, + credentials: [{ provider: "z-ai", key: "declared-secret" }], + }, + ], + ]) + ); + + await authProfilesManager.upsertProfile({ + agentId: "template-agent", + userId: "userA", + provider: "z-ai", + credential: "user-secret", + authType: "api-key", + label: "z.ai byok", }); - const templateSettings = await store.getSettings("template-agent"); - const cloned = buildDefaultSettingsFromSource(templateSettings); - cloned.templateAgentId = "template-agent"; - await store.saveSettings("telegram-6570514069", cloned); + const userView = await authProfilesManager.listProfiles( + "template-agent", + "userA" + ); + expect(userView).toHaveLength(1); + expect(userView[0]?.credential).toBe("user-secret"); - const effective = await store.getEffectiveSettings("telegram-6570514069"); - const profiles = await authProfilesManager.listProfiles( - "telegram-6570514069" + const anonView = await authProfilesManager.listProfiles("template-agent"); + expect(anonView).toHaveLength(1); + expect(anonView[0]?.credential).toBe("declared-secret"); + }); + + test("expired user OAuth does not mask a valid declared fallback", async () => { + declaredAgents.replaceAll( + new Map([ + [ + "template-agent", + { + settings: { + installedProviders: [{ providerId: "z-ai", installedAt: 1 }], + }, + credentials: [{ provider: "z-ai", key: "declared-secret" }], + }, + ], + ]) ); - expect(cloned.authProfiles).toBeUndefined(); - expect(effective?.authProfiles).toHaveLength(1); - expect(profiles).toHaveLength(1); + await authProfilesManager.upsertProfile({ + agentId: "template-agent", + userId: "userA", + provider: "z-ai", + credential: "expired-token", + authType: "oauth", + label: "z.ai oauth", + metadata: { expiresAt: Date.now() - 60_000 }, + }); + + const best = await authProfilesManager.getBestProfile( + "template-agent", + "z-ai", + undefined, + { userId: "userA" } + ); + expect(best?.credential).toBe("declared-secret"); }); - test("treats cloned sandbox settings as configured when template provides credentials", async () => { - await store.saveSettings("template-agent", { - authProfiles: [ - { - id: "profile-1", - provider: "z-ai", - credential: "secret", - authType: "api-key", - label: "z.ai", - model: "*", - createdAt: 1, - }, - ], - installedProviders: [{ providerId: "z-ai", installedAt: 1 }], + test("declared settings flow through agentSettingsStore.getEffectiveSettings", async () => { + declaredAgents.replaceAll( + new Map([ + [ + "declared-agent", + { + settings: { + installedProviders: [{ providerId: "openai", installedAt: 1 }], + modelSelection: { mode: "pinned", pinnedModel: "openai/gpt-5" }, + networkConfig: { allowedDomains: ["api.openai.com"] }, + }, + credentials: [{ provider: "openai", key: "sk-declared" }], + }, + ], + ]) + ); + store.setDeclaredAgents(declaredAgents); + + const effective = await store.getEffectiveSettings("declared-agent"); + expect(effective?.installedProviders).toEqual([ + { providerId: "openai", installedAt: 1 }, + ]); + expect(effective?.modelSelection).toEqual({ + mode: "pinned", + pinnedModel: "openai/gpt-5", }); + expect(effective?.networkConfig?.allowedDomains).toEqual([ + "api.openai.com", + ]); + }); - const templateSettings = await store.getSettings("template-agent"); - const cloned = buildDefaultSettingsFromSource(templateSettings); - cloned.templateAgentId = "template-agent"; - await store.saveSettings("telegram-6570514069", cloned); + test("treats declared agent as configured even without system key", async () => { + declaredAgents.replaceAll( + new Map([ + [ + "template-agent", + { + settings: { + installedProviders: [{ providerId: "z-ai", installedAt: 1 }], + }, + credentials: [{ provider: "z-ai", key: "declared-secret" }], + }, + ], + ]) + ); await expect( - hasConfiguredProvider("telegram-6570514069", store) + hasConfiguredProvider("template-agent", store, declaredAgents) ).resolves.toBe(true); }); @@ -224,7 +291,11 @@ describe("sandbox provider inheritance", () => { JSON.stringify({ templateAgentId: "template-agent" }) ); - const catalog = new ProviderCatalogService(store, authProfilesManager); + const catalog = new ProviderCatalogService( + store, + authProfilesManager, + declaredAgents + ); await catalog.uninstallProvider("telegram-6570514069", "z-ai"); const local = await store.getSettings("telegram-6570514069"); @@ -237,4 +308,30 @@ describe("sandbox provider inheritance", () => { { providerId: "openai", installedAt: 2 }, ]); }); + + test("blocks provider mutations on declared agents", async () => { + declaredAgents.replaceAll( + new Map([ + [ + "declared-agent", + { + settings: { + installedProviders: [{ providerId: "z-ai", installedAt: 1 }], + }, + credentials: [], + }, + ], + ]) + ); + + const catalog = new ProviderCatalogService( + store, + authProfilesManager, + declaredAgents + ); + + await expect( + catalog.uninstallProvider("declared-agent", "z-ai") + ).rejects.toThrow(/declared in lobu\.toml/); + }); }); diff --git a/packages/gateway/src/__tests__/user-auth-profile-store.test.ts b/packages/gateway/src/__tests__/user-auth-profile-store.test.ts new file mode 100644 index 000000000..dddcca4c8 --- /dev/null +++ b/packages/gateway/src/__tests__/user-auth-profile-store.test.ts @@ -0,0 +1,208 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { MockRedisClient } from "@lobu/core/testing"; +import { RedisSecretStore } from "../secrets"; +import { UserAuthProfileStore } from "../auth/settings/user-auth-profile-store"; + +const TEST_ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +let originalEncryptionKey: string | undefined; + +beforeAll(() => { + originalEncryptionKey = process.env.ENCRYPTION_KEY; + process.env.ENCRYPTION_KEY = TEST_ENCRYPTION_KEY; +}); + +afterAll(() => { + if (originalEncryptionKey !== undefined) { + process.env.ENCRYPTION_KEY = originalEncryptionKey; + } else { + delete process.env.ENCRYPTION_KEY; + } +}); + +function createStore() { + const redis = new MockRedisClient(); + const secretStore = new RedisSecretStore(redis as any, "lobu:test:secrets:"); + const store = new UserAuthProfileStore(redis as any, secretStore); + return { store, redis, secretStore }; +} + +describe("UserAuthProfileStore", () => { + let setup: ReturnType; + + beforeEach(() => { + setup = createStore(); + }); + + test("upsert stores profile under (userId, agentId) and replaces credential with ref", async () => { + const { store, redis, secretStore } = setup; + const stored = await store.upsert("u1", "agent-1", { + id: "p1", + provider: "openai", + credential: "sk-secret", + authType: "api-key", + label: "openai", + model: "*", + createdAt: 0, + }); + + expect(stored.credential).toBeUndefined(); + expect(stored.credentialRef).toBeDefined(); + expect(await secretStore.get(stored.credentialRef!)).toBe("sk-secret"); + + const raw = await redis.get("user:auth-profiles:u1:agent-1"); + expect(raw).toBeDefined(); + expect(raw).not.toContain("sk-secret"); + + const list = await store.list("u1", "agent-1"); + expect(list).toHaveLength(1); + expect(list[0]?.id).toBe("p1"); + }); + + test("upsert persists refresh token through secret store", async () => { + const { store, secretStore } = setup; + const stored = await store.upsert("u1", "agent-1", { + id: "oauth-1", + provider: "claude", + credential: "access-token", + authType: "oauth", + label: "claude", + model: "*", + createdAt: 0, + metadata: { + refreshToken: "refresh-token-123", + expiresAt: 9_999_999_999, + }, + }); + + expect(stored.metadata?.refreshToken).toBeUndefined(); + expect(stored.metadata?.refreshTokenRef).toBeDefined(); + expect(await secretStore.get(stored.metadata!.refreshTokenRef!)).toBe( + "refresh-token-123" + ); + }); + + test("upsert with same (provider, model) replaces existing entry", async () => { + const { store } = setup; + await store.upsert("u1", "agent-1", { + id: "p1", + provider: "openai", + credential: "sk-1", + authType: "api-key", + label: "old", + model: "*", + createdAt: 0, + }); + await store.upsert("u1", "agent-1", { + id: "p2", + provider: "openai", + credential: "sk-2", + authType: "api-key", + label: "new", + model: "*", + createdAt: 0, + }); + + const list = await store.list("u1", "agent-1"); + expect(list).toHaveLength(1); + expect(list[0]?.id).toBe("p2"); + }); + + test("isolates profiles per user", async () => { + const { store } = setup; + await store.upsert("u1", "agent-1", { + id: "p1", + provider: "openai", + credential: "sk-u1", + authType: "api-key", + label: "u1", + model: "*", + createdAt: 0, + }); + await store.upsert("u2", "agent-1", { + id: "p2", + provider: "openai", + credential: "sk-u2", + authType: "api-key", + label: "u2", + model: "*", + createdAt: 0, + }); + + expect(await store.list("u1", "agent-1")).toHaveLength(1); + expect(await store.list("u2", "agent-1")).toHaveLength(1); + expect(await store.list("u3", "agent-1")).toEqual([]); + }); + + test("remove drops profile and its secrets", async () => { + const { store, secretStore } = setup; + const stored = await store.upsert("u1", "agent-1", { + id: "p1", + provider: "openai", + credential: "sk-1", + authType: "api-key", + label: "u1", + model: "*", + createdAt: 0, + }); + + const result = await store.remove("u1", "agent-1", { provider: "openai" }); + expect(result.removed).toHaveLength(1); + expect(result.secretsDeleted).toBeGreaterThan(0); + expect(await store.list("u1", "agent-1")).toEqual([]); + expect(await secretStore.get(stored.credentialRef!)).toBeNull(); + }); + + test("dropAgent cascades through all secrets", async () => { + const { store, redis, secretStore } = setup; + const stored = await store.upsert("u1", "agent-1", { + id: "p1", + provider: "openai", + credential: "sk-1", + authType: "api-key", + label: "u1", + model: "*", + createdAt: 0, + }); + await store.dropAgent("u1", "agent-1"); + + expect(await redis.get("user:auth-profiles:u1:agent-1")).toBeNull(); + expect(await secretStore.get(stored.credentialRef!)).toBeNull(); + }); + + test("scanAllOAuth yields every (userId, agentId) pair", async () => { + const { store } = setup; + await store.upsert("u1", "agent-1", { + id: "p1", + provider: "claude", + credential: "x", + authType: "oauth", + label: "x", + model: "*", + createdAt: 0, + }); + await store.upsert("u2", "agent-2", { + id: "p2", + provider: "claude", + credential: "y", + authType: "oauth", + label: "y", + model: "*", + createdAt: 0, + }); + + const refs: string[] = []; + for await (const ref of store.scanAllOAuth()) { + refs.push(`${ref.userId}:${ref.agentId}`); + } + expect(refs.sort()).toEqual(["u1:agent-1", "u2:agent-2"]); + }); +}); diff --git a/packages/gateway/src/auth/api-key-provider-module.ts b/packages/gateway/src/auth/api-key-provider-module.ts index 350316917..6ffe3835f 100644 --- a/packages/gateway/src/auth/api-key-provider-module.ts +++ b/packages/gateway/src/auth/api-key-provider-module.ts @@ -1,8 +1,7 @@ import type { ConfigProviderMeta } from "@lobu/core"; import type { ModelOption } from "../modules/module-system"; import { BaseProviderModule } from "./base-provider-module"; -import type { AgentSettingsStore } from "./settings/agent-settings-store"; -import { AuthProfilesManager } from "./settings/auth-profiles-manager"; +import type { AuthProfilesManager } from "./settings/auth-profiles-manager"; export interface ApiKeyProviderConfig { providerId: string; @@ -28,7 +27,7 @@ export interface ApiKeyProviderConfig { registryAlias?: string; /** Whether to show in "Add Provider" catalog (default: true) */ catalogVisible?: boolean; - agentSettingsStore: AgentSettingsStore; + authProfilesManager: AuthProfilesManager; } /** @@ -43,10 +42,6 @@ export class ApiKeyProviderModule extends BaseProviderModule { protected readonly apiKeyConfig: ApiKeyProviderConfig; constructor(config: ApiKeyProviderConfig) { - const authProfilesManager = new AuthProfilesManager( - config.agentSettingsStore, - config.agentSettingsStore.getSecretStore() - ); super( { providerId: config.providerId, @@ -65,7 +60,7 @@ export class ApiKeyProviderModule extends BaseProviderModule { apiKeyPlaceholder: config.apiKeyPlaceholder, catalogVisible: config.catalogVisible, }, - authProfilesManager + config.authProfilesManager ); this.apiKeyConfig = config; this.name = `${config.providerId}-api-key`; diff --git a/packages/gateway/src/auth/chatgpt/chatgpt-oauth-module.ts b/packages/gateway/src/auth/chatgpt/chatgpt-oauth-module.ts index 4d4ed234e..35bf30bdc 100644 --- a/packages/gateway/src/auth/chatgpt/chatgpt-oauth-module.ts +++ b/packages/gateway/src/auth/chatgpt/chatgpt-oauth-module.ts @@ -1,9 +1,8 @@ import { createLogger } from "@lobu/core"; import type { ModelOption } from "../../modules/module-system"; import { BaseProviderModule } from "../base-provider-module"; -import type { AgentSettingsStore } from "../settings/agent-settings-store"; import { - AuthProfilesManager, + type AuthProfilesManager, createAuthProfileLabel, } from "../settings/auth-profiles-manager"; import { ChatGPTDeviceCodeClient } from "./device-code-client"; @@ -17,11 +16,7 @@ const logger = createLogger("chatgpt-oauth-module"); export class ChatGPTOAuthModule extends BaseProviderModule { private deviceCodeClient: ChatGPTDeviceCodeClient; - constructor(agentSettingsStore: AgentSettingsStore) { - const authProfilesManager = new AuthProfilesManager( - agentSettingsStore, - agentSettingsStore.getSecretStore() - ); + constructor(authProfilesManager: AuthProfilesManager) { super( { providerId: "chatgpt", @@ -141,6 +136,7 @@ export class ChatGPTOAuthModule extends BaseProviderModule { async pollDeviceCode( agentId: string, + userId: string, payload: { deviceAuthId: string; userCode: string } ): Promise<{ status: "pending" | "success"; @@ -159,6 +155,7 @@ export class ChatGPTOAuthModule extends BaseProviderModule { await this.authProfilesManager.upsertProfile({ agentId, + userId, provider: this.providerId, credential: result.accessToken, authType: "device-code", diff --git a/packages/gateway/src/auth/claude/oauth-module.ts b/packages/gateway/src/auth/claude/oauth-module.ts index b855e9f0d..4f991fa1c 100644 --- a/packages/gateway/src/auth/claude/oauth-module.ts +++ b/packages/gateway/src/auth/claude/oauth-module.ts @@ -81,11 +81,14 @@ export class ClaudeOAuthModule extends BaseProviderModule { override async buildEnvVars( agentId: string, - envVars: Record + envVars: Record, + context?: import("../../embedded").ProviderCredentialContext ): Promise> { const profile = await this.authProfilesManager.getBestProfile( agentId, - this.providerId + this.providerId, + undefined, + context ); if (profile?.credential) { @@ -152,23 +155,34 @@ export class ClaudeOAuthModule extends BaseProviderModule { return options; } - async setCredentials(agentId: string, credentials: unknown): Promise { - await this.saveOAuthCredentials(agentId, credentials as OAuthCredentials); + async setCredentials( + agentId: string, + userId: string, + credentials: unknown + ): Promise { + await this.saveOAuthCredentials( + agentId, + userId, + credentials as OAuthCredentials + ); } - async deleteCredentials(agentId: string): Promise { + async deleteCredentials(agentId: string, userId: string): Promise { await this.authProfilesManager.deleteProviderProfiles( agentId, - this.providerId + this.providerId, + { userId } ); } private async saveOAuthCredentials( agentId: string, + userId: string, credentials: OAuthCredentials ): Promise { await this.authProfilesManager.upsertProfile({ agentId, + userId, provider: this.providerId, credential: credentials.accessToken, authType: "oauth", diff --git a/packages/gateway/src/auth/mcp/resume-after-oauth.ts b/packages/gateway/src/auth/mcp/resume-after-oauth.ts index 03a8fb83d..e3d1b3945 100644 --- a/packages/gateway/src/auth/mcp/resume-after-oauth.ts +++ b/packages/gateway/src/auth/mcp/resume-after-oauth.ts @@ -60,7 +60,13 @@ export async function postOAuthCompletionPrompt( } = params; const agentSettingsStore = coreServices.getAgentSettingsStore(); - if (!(await hasConfiguredProvider(agentId, agentSettingsStore))) { + if ( + !(await hasConfiguredProvider( + agentId, + agentSettingsStore, + coreServices.getDeclaredAgentRegistry() + )) + ) { logger.warn("Skipping OAuth resume: agent has no configured provider", { agentId, }); diff --git a/packages/gateway/src/auth/provider-catalog.ts b/packages/gateway/src/auth/provider-catalog.ts index cba78c766..17e0c879e 100644 --- a/packages/gateway/src/auth/provider-catalog.ts +++ b/packages/gateway/src/auth/provider-catalog.ts @@ -3,6 +3,7 @@ import { getModelProviderModules, type ModelProviderModule, } from "../modules/module-system"; +import type { DeclaredAgentRegistry } from "../services/declared-agent-registry"; import type { AgentSettingsStore } from "./settings/agent-settings-store"; import type { AuthProfilesManager } from "./settings/auth-profiles-manager"; import { reconcileModelSelectionForInstalledProviders } from "./settings/model-selection"; @@ -28,12 +29,22 @@ export async function resolveInstalledProviders( * Providers are registered globally in the module registry, * but each agent chooses which providers to install from the catalog. */ +const DECLARED_AGENT_MUTATION_ERROR = + "provider list is declared in lobu.toml; edit the file and restart"; + export class ProviderCatalogService { constructor( private agentSettingsStore: AgentSettingsStore, - private authProfilesManager: AuthProfilesManager + private authProfilesManager: AuthProfilesManager, + private declaredAgents: DeclaredAgentRegistry ) {} + private guardDeclared(agentId: string): void { + if (this.declaredAgents.has(agentId)) { + throw new Error(DECLARED_AGENT_MUTATION_ERROR); + } + } + /** * List all catalog-visible providers from the module registry. */ @@ -75,6 +86,7 @@ export class ProviderCatalogService { providerId: string, config?: InstalledProvider["config"] ): Promise { + this.guardDeclared(agentId); const allModules = getModelProviderModules(); const module = allModules.find((m) => m.providerId === providerId); if (!module) { @@ -120,6 +132,7 @@ export class ProviderCatalogService { * Uninstall a provider from an agent. Also cleans up auth profiles. */ async uninstallProvider(agentId: string, providerId: string): Promise { + this.guardDeclared(agentId); const { localSettings, effectiveSettings } = await this.agentSettingsStore.getSettingsContext(agentId); const installed = effectiveSettings?.installedProviders || []; @@ -132,7 +145,10 @@ export class ProviderCatalogService { return; } - // Clean up auth profiles for this provider + // Clean up ephemeral auth profiles. User-scoped profiles in + // UserAuthProfileStore stay put — uninstalling a provider on a + // runtime agent shouldn't cascade-delete every user's tokens; users + // remove their own credentials from the per-user UI. await this.authProfilesManager.deleteProviderProfiles(agentId, providerId); const reconciled = reconcileModelSelectionForInstalledProviders({ model: localSettings?.model ?? effectiveSettings?.model, @@ -175,6 +191,7 @@ export class ProviderCatalogService { * exactly the same provider IDs as currently installed. */ async reorderProviders(agentId: string, orderedIds: string[]): Promise { + this.guardDeclared(agentId); const { localSettings, effectiveSettings } = await this.agentSettingsStore.getSettingsContext(agentId); const installed = effectiveSettings?.installedProviders || []; diff --git a/packages/gateway/src/auth/settings/agent-settings-store.ts b/packages/gateway/src/auth/settings/agent-settings-store.ts index 780e4ca14..941f3e9a3 100644 --- a/packages/gateway/src/auth/settings/agent-settings-store.ts +++ b/packages/gateway/src/auth/settings/agent-settings-store.ts @@ -6,8 +6,7 @@ import { safeJsonStringify, } from "@lobu/core"; import type Redis from "ioredis"; -import type { RuntimeProviderCredentialResolver } from "../../embedded"; -import { deleteSecretsByPrefix, type WritableSecretStore } from "../../secrets"; +import type { DeclaredAgentRegistry } from "../../services/declared-agent-registry"; // Re-export so existing imports from this module keep working. export type { AgentSettings }; @@ -18,10 +17,6 @@ export interface AgentSettingsContext { templateAgentId?: string; } -export interface AgentSettingsStoreOptions { - runtimeCredentialResolver?: RuntimeProviderCredentialResolver; -} - /** * Shared in-memory ephemeral auth profile registry. Lives on * AgentSettingsStore because it's the single shared instance every @@ -51,18 +46,16 @@ export class EphemeralAuthProfileRegistry { * Store and retrieve agent settings from Redis * Pattern: agent:settings:{agentId} * - * Sensitive values (auth profile credentials and refresh tokens) are never - * persisted inline. They are written to the secret store on save and the - * stored JSON only holds the resulting `credentialRef` / `refreshTokenRef`. + * Holds runtime-mutable settings for agents created via the UI or sandbox + * paths. Declared agents (lobu.toml / SDK config) live in + * `DeclaredAgentRegistry` and never touch Redis. Auth profiles are owned + * by `UserAuthProfileStore` keyed by `(userId, agentId)`. */ export class AgentSettingsStore extends BaseRedisStore { private readonly ephemeralAuthProfiles = new EphemeralAuthProfileRegistry(); + private declaredAgents?: DeclaredAgentRegistry; - constructor( - redis: Redis, - private readonly secretStore: WritableSecretStore, - private readonly options: AgentSettingsStoreOptions = {} - ) { + constructor(redis: Redis) { super({ redis, keyPrefix: "agent:settings", @@ -70,18 +63,18 @@ export class AgentSettingsStore extends BaseRedisStore { }); } - getSecretStore(): WritableSecretStore { - return this.secretStore; - } - getEphemeralAuthProfiles(): EphemeralAuthProfileRegistry { return this.ephemeralAuthProfiles; } - getRuntimeCredentialResolver(): - | RuntimeProviderCredentialResolver - | undefined { - return this.options.runtimeCredentialResolver; + /** + * Wire the declared-agent registry so `getEffectiveSettings` + * returns declared settings for declared agents (which never have a + * Redis copy by design). Called once from CoreServices after the + * registry is built. + */ + setDeclaredAgents(registry: DeclaredAgentRegistry): void { + this.declaredAgents = registry; } /** @@ -121,6 +114,16 @@ export class AgentSettingsStore extends BaseRedisStore { } async getSettingsContext(agentId: string): Promise { + const declared = this.declaredAgents?.get(agentId); + if (declared) { + // Declared agents are immutable from runtime: no Redis local copy, + // no template fallback. Return registry settings as effective. + return { + localSettings: null, + effectiveSettings: declared.settings as AgentSettings, + }; + } + const localSettings = await this.getSettings(agentId); const templateAgentId = await this.resolveTemplateAgentId( @@ -188,61 +191,30 @@ export class AgentSettingsStore extends BaseRedisStore { } } - /** - * Save settings for an agent. Any plaintext credential/refreshToken on - * authProfiles is moved into the secret store and replaced with a ref. - */ async saveSettings( agentId: string, settings: Omit ): Promise { const key = this.buildKey(agentId); - const fullSettings = await this.persistAuthProfileSecrets(agentId, { - ...settings, - updatedAt: Date.now(), - }); - await this.set(key, fullSettings); + await this.set(key, { ...settings, updatedAt: Date.now() }); this.logger.info(`Saved settings for agent ${agentId}`); } - /** - * Update specific settings fields (partial update). Existing secret refs - * are preserved. - */ async updateSettings( agentId: string, updates: Partial> ): Promise { const existing = await this.getSettings(agentId); - const merged = await this.persistAuthProfileSecrets(agentId, { - ...existing, - ...updates, - updatedAt: Date.now(), - }); const key = this.buildKey(agentId); - await this.set(key, merged); + await this.set(key, { ...existing, ...updates, updatedAt: Date.now() }); this.logger.info(`Updated settings for agent ${agentId}`); } async deleteSettings(agentId: string): Promise { const key = this.buildKey(agentId); - - // Cascade-delete every secret owned by this agent's auth profiles. - // Using a prefix sweep catches credentials and refresh tokens for all - // profile IDs without requiring us to re-read + parse the JSON. - const secretsDeleted = await deleteSecretsByPrefix( - this.secretStore, - `agents/${agentId}/auth-profiles/` - ); - - // Drop ephemeral profiles too so a subsequent getProfiles doesn't - // surface stale in-memory entries after the agent is torn down. this.ephemeralAuthProfiles.delete(agentId); - await this.delete(key); - this.logger.info( - `Deleted settings for agent ${agentId} (cascade-deleted ${secretsDeleted} secret(s))` - ); + this.logger.info(`Deleted settings for agent ${agentId}`); } /** @@ -274,31 +246,6 @@ export class AgentSettingsStore extends BaseRedisStore { return this.exists(key); } - /** - * Find the first agent that has installed providers configured. - * Used to find a template for ephemeral agents. - */ - async findTemplateAgentId(): Promise { - const prefix = `${this.keyPrefix}:`; - const keys = await this.scanByPrefix(prefix); - for (const key of keys) { - try { - const data = await this.redis.get(key); - if (!data) continue; - const parsed = safeJsonParse(data); - if ( - parsed?.installedProviders && - parsed.installedProviders.length > 0 - ) { - return key.slice(prefix.length); - } - } catch { - /* skip unparseable key */ - } - } - return null; - } - protected override serialize(value: AgentSettings): string { const json = safeJsonStringify(value); if (json === null) { @@ -306,70 +253,4 @@ export class AgentSettingsStore extends BaseRedisStore { } return json; } - - /** - * Walk authProfiles, move any plaintext credential or refreshToken into the - * secret store, and replace them with credentialRef / refreshTokenRef. - */ - private async persistAuthProfileSecrets( - agentId: string, - settings: AgentSettings - ): Promise { - if ( - !Array.isArray(settings.authProfiles) || - settings.authProfiles.length === 0 - ) { - return settings; - } - - const authProfiles = await Promise.all( - settings.authProfiles.map((profile) => - this.persistProfileSecrets(agentId, profile) - ) - ); - - return { ...settings, authProfiles }; - } - - private async persistProfileSecrets( - agentId: string, - profile: AuthProfile - ): Promise { - const next: AuthProfile = { ...profile }; - const metadata = profile.metadata ? { ...profile.metadata } : undefined; - - // Always rewrite plaintext into the secret store when it's present — - // even if a ref already exists. Callers like TokenRefreshJob update - // profiles with a freshly-rotated refresh token on top of the existing - // ref, and we must not drop it on the floor. `secretStore.put` uses a - // deterministic secret name so the returned ref is stable. - if (profile.credential) { - next.credentialRef = await this.secretStore.put( - this.buildAuthProfileSecretName(agentId, profile, "credential"), - profile.credential - ); - } - delete next.credential; - - if (metadata) { - if (metadata.refreshToken) { - metadata.refreshTokenRef = await this.secretStore.put( - this.buildAuthProfileSecretName(agentId, profile, "refresh-token"), - metadata.refreshToken - ); - } - delete metadata.refreshToken; - next.metadata = metadata; - } - - return next; - } - - private buildAuthProfileSecretName( - agentId: string, - profile: AuthProfile, - kind: "credential" | "refresh-token" - ): string { - return `agents/${agentId}/auth-profiles/${profile.id}/${kind}`; - } } diff --git a/packages/gateway/src/auth/settings/auth-profiles-manager.ts b/packages/gateway/src/auth/settings/auth-profiles-manager.ts index 5fa4ab016..0b5895b67 100644 --- a/packages/gateway/src/auth/settings/auth-profiles-manager.ts +++ b/packages/gateway/src/auth/settings/auth-profiles-manager.ts @@ -4,10 +4,9 @@ import type { RuntimeProviderCredentialResolver, } from "../../embedded"; import type { WritableSecretStore } from "../../secrets"; -import type { - AgentSettingsStore, - EphemeralAuthProfileRegistry, -} from "./agent-settings-store"; +import type { DeclaredAgentRegistry } from "../../services/declared-agent-registry"; +import type { EphemeralAuthProfileRegistry } from "./agent-settings-store"; +import type { UserAuthProfileStore } from "./user-auth-profile-store"; const logger = createLogger("auth-profiles-manager"); @@ -15,6 +14,8 @@ const ANY_MODEL_SCOPE = "*"; export interface UpsertAuthProfileInput { agentId: string; + /** Owning user. Required for persistent (Redis-backed) writes. */ + userId?: string; provider: string; credential?: string; credentialRef?: string; @@ -26,42 +27,76 @@ export interface UpsertAuthProfileInput { id?: string; } +export interface AuthProfilesManagerOptions { + ephemeralProfiles: EphemeralAuthProfileRegistry; + declaredAgents: DeclaredAgentRegistry; + userAuthProfiles: UserAuthProfileStore; + secretStore: WritableSecretStore; + runtimeCredentialResolver?: RuntimeProviderCredentialResolver; +} + +/** + * Resolve and write auth profiles by merging three sources: + * + * 1. **Runtime resolver** — SDK host can plug in a synchronous credential + * resolver that wins over everything else (ProviderCredentialContext). + * 2. **User-scoped profiles** — durable per-user profiles keyed by + * `(userId, agentId)` in `UserAuthProfileStore`. + * 3. **Declared credentials** — read-only credentials shipped with the + * agent's declared config (lobu.toml / SDK GatewayConfig.agents), + * surfaced via `DeclaredAgentRegistry`. + * + * Callers pass `ProviderCredentialContext.userId` when they have one + * (worker proxy, OAuth route, agent-config route). When `userId` is + * absent, only declared + runtime sources are consulted. + */ export class AuthProfilesManager { - /** - * Ephemeral profile registry is owned by `AgentSettingsStore` so that - * every `AuthProfilesManager` built against the same store (including - * the ones each provider module constructs internally) shares the same - * ephemeral view. Without this, a `provider.key` seeded on the central - * manager would be invisible to provider modules' own managers. - */ private readonly ephemeralProfiles: EphemeralAuthProfileRegistry; + private readonly declaredAgents: DeclaredAgentRegistry; + private readonly userAuthProfiles: UserAuthProfileStore; + private readonly secretStore: WritableSecretStore; private readonly runtimeCredentialResolver?: RuntimeProviderCredentialResolver; - constructor( - private readonly agentSettingsStore: AgentSettingsStore, - private readonly secretStore: WritableSecretStore, - runtimeCredentialResolver?: RuntimeProviderCredentialResolver - ) { - this.ephemeralProfiles = agentSettingsStore.getEphemeralAuthProfiles(); - this.runtimeCredentialResolver = - runtimeCredentialResolver ?? - agentSettingsStore.getRuntimeCredentialResolver(); + constructor(options: AuthProfilesManagerOptions) { + this.ephemeralProfiles = options.ephemeralProfiles; + this.declaredAgents = options.declaredAgents; + this.userAuthProfiles = options.userAuthProfiles; + this.secretStore = options.secretStore; + this.runtimeCredentialResolver = options.runtimeCredentialResolver; } - async listProfiles(agentId: string): Promise { - const settings = - await this.agentSettingsStore.getEffectiveSettings(agentId); + getDeclaredAgents(): DeclaredAgentRegistry { + return this.declaredAgents; + } + + getUserAuthProfileStore(): UserAuthProfileStore { + return this.userAuthProfiles; + } + + /** + * Return every profile known for `(agentId, userId?)`, with secret refs + * resolved to plaintext. Intended for admin/agent-config surfaces. + * + * Order: + * 1. user-scoped profiles (most authoritative) + * 2. ephemeral profiles registered by SDK host + * 3. declared credentials from registry (synthesized as `api-key`) + */ + async listProfiles(agentId: string, userId?: string): Promise { + const userProfiles = userId + ? await this.userAuthProfiles.list(userId, agentId) + : []; + const ephemeral = this.ephemeralProfiles.get(agentId) || []; + const declared = this.synthesizeDeclaredProfiles(agentId); - // Persistent profiles take precedence over ephemeral on (provider, model) - // collision, so the two halves are merged with the persistent set first. const merged = this.dedupeByScope([ - ...(settings?.authProfiles || []), - ...(this.ephemeralProfiles.get(agentId) || []), + ...this.normalizeProfiles(userProfiles), + ...this.normalizeProfiles(ephemeral), + ...declared, ]); - const profiles = this.normalizeProfiles(merged); const resolved = await Promise.all( - profiles.map(async (profile) => { + merged.map(async (profile) => { try { return await this.resolveProfile(profile); } catch (error) { @@ -92,15 +127,16 @@ export class AuthProfilesManager { ) { return true; } - const profiles = await this.listProfiles(agentId); + const profiles = await this.listProfiles(agentId, context?.userId); return profiles.some((profile) => profile.provider === provider); } async getProviderProfiles( agentId: string, - provider: string + provider: string, + userId?: string ): Promise { - const profiles = await this.listProfiles(agentId); + const profiles = await this.listProfiles(agentId, userId); return profiles.filter((profile) => profile.provider === provider); } @@ -120,7 +156,11 @@ export class AuthProfilesManager { return runtimeProfile; } - const providerProfiles = await this.getProviderProfiles(agentId, provider); + const providerProfiles = await this.getProviderProfiles( + agentId, + provider, + context?.userId + ); if (providerProfiles.length === 0) { return null; } @@ -139,26 +179,36 @@ export class AuthProfilesManager { return null; } - const candidates = validProfiles; if (!model) { - return candidates[0] || null; + return validProfiles[0] || null; } - const exact = candidates.find((profile) => profile.model === model); + const exact = validProfiles.find((profile) => profile.model === model); if (exact) return exact; - const wildcard = candidates.find( + const wildcard = validProfiles.find( (profile) => profile.model === ANY_MODEL_SCOPE ); - return wildcard || candidates[0] || null; + return wildcard || validProfiles[0] || null; } + /** + * Insert or update a persistent (Redis-backed) profile. + * + * Requires `userId` — declared agents cannot be mutated through this + * path. Runtime UI/sandbox agents that aren't owned by a single user + * should pass a synthetic principal (`$ADMIN`) chosen by the caller. + */ async upsertProfile(input: UpsertAuthProfileInput): Promise { - const settings = await this.agentSettingsStore.getSettings(input.agentId); - const current = this.normalizeProfiles(settings?.authProfiles); - const modelScope = input.model?.trim() || ANY_MODEL_SCOPE; + if (!input.userId) { + throw new Error( + "upsertProfile requires userId — declared agents cannot be mutated; " + + "runtime agents must specify the owning principal" + ); + } - const nextProfile: AuthProfile = { + const modelScope = input.model?.trim() || ANY_MODEL_SCOPE; + const profile: AuthProfile = { id: input.id || crypto.randomUUID(), provider: input.provider, ...(input.credential ? { credential: input.credential } : {}), @@ -170,69 +220,24 @@ export class AuthProfilesManager { createdAt: Date.now(), }; - let replaced = false; - const withoutExisting = current.filter((profile) => { - if (input.id && profile.id === input.id) { - replaced = true; - nextProfile.createdAt = profile.createdAt; - return false; - } - return true; - }); - - if (!input.id) { - const existingPrimary = withoutExisting.find( - (profile) => - profile.provider === input.provider && profile.model === modelScope - ); - if (existingPrimary) { - replaced = true; - nextProfile.createdAt = existingPrimary.createdAt; - } - } - - const withoutSameScope = withoutExisting.filter( - (profile) => - !( - profile.provider === input.provider && - profile.model === modelScope && - (!input.id || profile.id !== input.id) - ) + const stored = await this.userAuthProfiles.upsert( + input.userId, + input.agentId, + profile, + { makePrimary: input.makePrimary } ); - const nextProfiles: AuthProfile[] = []; - const providerProfiles: AuthProfile[] = []; - const otherProfiles: AuthProfile[] = []; - - for (const profile of withoutSameScope) { - if (profile.provider === input.provider) { - providerProfiles.push(profile); - } else { - otherProfiles.push(profile); - } - } - - if (input.makePrimary !== false) { - nextProfiles.push(nextProfile, ...providerProfiles, ...otherProfiles); - } else { - nextProfiles.push(...providerProfiles, nextProfile, ...otherProfiles); - } - - await this.agentSettingsStore.updateSettings(input.agentId, { - authProfiles: nextProfiles, - }); - logger.info( { agentId: input.agentId, + userId: input.userId, provider: input.provider, - profileId: nextProfile.id, - replaced, + profileId: stored.id, }, "Saved auth profile" ); - return nextProfile; + return stored; } registerEphemeralProfile(input: UpsertAuthProfileInput): AuthProfile { @@ -281,57 +286,24 @@ export class AuthProfilesManager { async deleteProviderProfiles( agentId: string, provider: string, - profileId?: string + options: { userId?: string; profileId?: string } = {} ): Promise { - const settings = await this.agentSettingsStore.getSettings(agentId); - const current = this.normalizeProfiles(settings?.authProfiles); - - // Collect profiles that are actually being removed so we can clean up - // their secrets from the secret store before updating settings. - const removed = current.filter((profile) => { - if (profile.provider !== provider) return false; - if (profileId && profile.id !== profileId) return false; - return true; - }); - const filtered = current.filter((profile) => !removed.includes(profile)); - - await this.agentSettingsStore.updateSettings(agentId, { - authProfiles: filtered, - }); - - // Delete orphaned secrets for each removed profile. Both kinds - // (credential + refresh-token) use the deterministic name built by - // AgentSettingsStore, so we can reconstruct the names here. - let secretsDeleted = 0; - for (const profile of removed) { - for (const kind of ["credential", "refresh-token"] as const) { - const name = `agents/${agentId}/auth-profiles/${profile.id}/${kind}`; - try { - await this.secretStore.delete(name); - secretsDeleted += 1; - } catch (error) { - logger.warn( - { - agentId, - profileId: profile.id, - kind, - error: error instanceof Error ? error.message : String(error), - }, - "Failed to delete auth profile secret" - ); - } - } + if (options.userId) { + await this.userAuthProfiles.remove(options.userId, agentId, { + provider, + ...(options.profileId ? { profileId: options.profileId } : {}), + }); } const ephemeral = this.ephemeralProfiles.get(agentId); if (ephemeral) { - const filteredEphemeral = ephemeral.filter((profile) => { + const filtered = ephemeral.filter((profile) => { if (profile.provider !== provider) return true; - if (!profileId) return false; - return profile.id !== profileId; + if (!options.profileId) return false; + return profile.id !== options.profileId; }); - if (filteredEphemeral.length > 0) { - this.ephemeralProfiles.set(agentId, filteredEphemeral); + if (filtered.length > 0) { + this.ephemeralProfiles.set(agentId, filtered); } else { this.ephemeralProfiles.delete(agentId); } @@ -341,14 +313,30 @@ export class AuthProfilesManager { { agentId, provider, - profileId: profileId || "all", - removedCount: removed.length, - secretsDeleted, + userId: options.userId || null, + profileId: options.profileId || "all", }, "Deleted auth profiles" ); } + private synthesizeDeclaredProfiles(agentId: string): AuthProfile[] { + const entry = this.declaredAgents.get(agentId); + if (!entry || entry.credentials.length === 0) return []; + + const now = Date.now(); + return entry.credentials.map((cred) => ({ + id: `declared:${agentId}:${cred.provider}`, + provider: cred.provider, + ...(cred.key ? { credential: cred.key } : {}), + ...(cred.secretRef ? { credentialRef: cred.secretRef } : {}), + authType: "api-key", + label: `${cred.provider} (declared)`, + model: ANY_MODEL_SCOPE, + createdAt: now, + })); + } + private normalizeProfiles( profiles: AuthProfile[] | undefined ): AuthProfile[] { @@ -364,24 +352,36 @@ export class AuthProfilesManager { } /** - * Merge persistent + ephemeral profile lists, preferring whichever came - * first in the input when two profiles cover the same (provider, model) - * scope. Callers pass persistent profiles before ephemeral ones so - * persistent always wins the scope on collision. + * Merge profile lists, preferring whichever came first in the input + * when two profiles cover the same (provider, model) scope. Callers + * pass user profiles before ephemeral and declared so the persisted + * per-user choice always wins. + * + * Within a scope, a non-expired profile beats an expired one regardless + * of order — this keeps a stale user OAuth token from masking a valid + * declared/ephemeral fallback for the same scope. */ private dedupeByScope(profiles: AuthProfile[]): AuthProfile[] { - const seenScopes = new Set(); - const seenIds = new Set(); - const result: AuthProfile[] = []; + const now = Date.now(); + const isExpired = (profile: AuthProfile) => + !!profile.metadata?.expiresAt && profile.metadata.expiresAt <= now; + + const scopeOrder: string[] = []; + const chosen = new Map(); for (const profile of profiles) { - if (seenIds.has(profile.id)) continue; const scope = `${profile.provider}:${profile.model ?? ANY_MODEL_SCOPE}`; - if (seenScopes.has(scope)) continue; - seenScopes.add(scope); - seenIds.add(profile.id); - result.push(profile); + const existing = chosen.get(scope); + if (!existing) { + chosen.set(scope, profile); + scopeOrder.push(scope); + continue; + } + if (existing.id === profile.id) continue; + if (isExpired(existing) && !isExpired(profile)) { + chosen.set(scope, profile); + } } - return result; + return scopeOrder.map((scope) => chosen.get(scope)!); } private async resolveProfile(profile: AuthProfile): Promise { @@ -413,9 +413,6 @@ export class AuthProfilesManager { refreshTokenResolvedFromRef = true; } - // Maintain the AuthProfile invariant: exactly one of credential / credentialRef - // (and the same for refreshToken / refreshTokenRef). When we inline the - // resolved plaintext, drop the ref so downstream code can't see both. const next: AuthProfile = { ...profile }; if (credentialResolvedFromRef) { next.credential = credential; diff --git a/packages/gateway/src/auth/settings/index.ts b/packages/gateway/src/auth/settings/index.ts index 1af7e415b..3d6580c1b 100644 --- a/packages/gateway/src/auth/settings/index.ts +++ b/packages/gateway/src/auth/settings/index.ts @@ -1,5 +1,4 @@ export { type AgentSettings, AgentSettingsStore } from "./agent-settings-store"; -export type { AgentSettingsStoreOptions } from "./agent-settings-store"; export { AuthProfilesManager, createAuthProfileLabel, diff --git a/packages/gateway/src/auth/settings/resolved-settings-view.ts b/packages/gateway/src/auth/settings/resolved-settings-view.ts index b65e4578b..8a4b3137f 100644 --- a/packages/gateway/src/auth/settings/resolved-settings-view.ts +++ b/packages/gateway/src/auth/settings/resolved-settings-view.ts @@ -65,7 +65,6 @@ const SECTION_SETTING_KEYS: Record< > = { model: [ "installedProviders", - "authProfiles", "model", "modelSelection", "providerModelPreferences", @@ -197,9 +196,6 @@ function resolveProviderSources( (provider) => provider.providerId ) ); - const localProfileProviders = new Set( - (localSettings?.authProfiles || []).map((profile) => profile.provider) - ); const localPreferenceProviders = new Set( Object.keys(localSettings?.providerModelPreferences || {}) ); @@ -213,7 +209,6 @@ function resolveProviderSources( effectiveProviderIds.map((providerId) => { const hasLocalOverride = localProviderIds.has(providerId) || - localProfileProviders.has(providerId) || localPreferenceProviders.has(providerId); const source = resolveSectionSource( diff --git a/packages/gateway/src/auth/settings/template-utils.ts b/packages/gateway/src/auth/settings/template-utils.ts index b308e454e..ebd2171e1 100644 --- a/packages/gateway/src/auth/settings/template-utils.ts +++ b/packages/gateway/src/auth/settings/template-utils.ts @@ -12,14 +12,11 @@ function cloneSettingValue(value: T): T { * - `updatedAt`: set fresh on save. * - `templateAgentId`: the derived agent tracks its own template pointer. * - `mcpInstallNotified`: per-agent UI state; not a config value. - * - `authProfiles`: credentials are deliberately not cloned into sandboxes; - * they're resolved at runtime from the effective settings chain. */ const NON_TEMPLATED_KEYS = new Set([ "updatedAt", "templateAgentId", "mcpInstallNotified", - "authProfiles", ]); export function buildDefaultSettingsFromSource( diff --git a/packages/gateway/src/auth/settings/user-auth-profile-store.ts b/packages/gateway/src/auth/settings/user-auth-profile-store.ts new file mode 100644 index 000000000..543da03ea --- /dev/null +++ b/packages/gateway/src/auth/settings/user-auth-profile-store.ts @@ -0,0 +1,252 @@ +import { + type AuthProfile, + createLogger, + safeJsonParse, + safeJsonStringify, +} from "@lobu/core"; +import type Redis from "ioredis"; +import { deleteSecretsByPrefix, type WritableSecretStore } from "../../secrets"; + +const logger = createLogger("user-auth-profile-store"); + +const KEY_PREFIX = "user:auth-profiles"; + +function buildKey(userId: string, agentId: string): string { + return `${KEY_PREFIX}:${userId}:${agentId}`; +} + +function buildSecretName( + userId: string, + agentId: string, + profileId: string, + kind: "credential" | "refresh-token" +): string { + return `users/${userId}/agents/${agentId}/auth-profiles/${profileId}/${kind}`; +} + +function buildAgentSecretPrefix(userId: string, agentId: string): string { + return `users/${userId}/agents/${agentId}/auth-profiles/`; +} + +function buildProfileSecretPrefix( + userId: string, + agentId: string, + profileId: string +): string { + return `users/${userId}/agents/${agentId}/auth-profiles/${profileId}/`; +} + +export interface UserAgentRef { + userId: string; + agentId: string; +} + +/** + * Per-user auth profile storage. + * + * Keyed by `(userId, agentId)`. Holds OAuth tokens, refresh tokens, and + * BYOK credentials owned by a specific user for a specific agent. + * + * Sensitive values (credential / refresh token) are persisted to the + * secret store and replaced inline with their refs, mirroring the policy + * previously implemented inside `AgentSettingsStore`. + */ +export class UserAuthProfileStore { + constructor( + private readonly redis: Redis, + private readonly secretStore: WritableSecretStore + ) {} + + async list(userId: string, agentId: string): Promise { + if (!userId || !agentId) return []; + try { + const raw = await this.redis.get(buildKey(userId, agentId)); + if (!raw) return []; + const parsed = safeJsonParse(raw); + if (!Array.isArray(parsed)) return []; + return parsed; + } catch (error) { + logger.warn("Failed to read user auth profiles", { + userId, + agentId, + error: error instanceof Error ? error.message : String(error), + }); + return []; + } + } + + /** + * Insert or update a profile. The supplied profile is normalized through + * the secret store: any plaintext credential/refreshToken is moved into + * the secret store and replaced with a ref before persistence. + * + * The stored ordering follows the same convention as + * `AuthProfilesManager.upsertProfile`: when `makePrimary === false` the + * profile is appended after sibling provider profiles; otherwise it is + * placed at the front of its provider group. + */ + async upsert( + userId: string, + agentId: string, + profile: AuthProfile, + options: { makePrimary?: boolean } = {} + ): Promise { + const persisted = await this.persistSecrets(userId, agentId, profile); + const current = await this.list(userId, agentId); + + let preservedCreatedAt = persisted.createdAt; + const filtered = current.filter((existing) => { + if (existing.id === persisted.id) { + preservedCreatedAt = existing.createdAt; + return false; + } + if ( + existing.provider === persisted.provider && + existing.model === persisted.model + ) { + preservedCreatedAt = existing.createdAt; + return false; + } + return true; + }); + const next: AuthProfile = { ...persisted, createdAt: preservedCreatedAt }; + + const sameProvider: AuthProfile[] = []; + const others: AuthProfile[] = []; + for (const entry of filtered) { + if (entry.provider === next.provider) { + sameProvider.push(entry); + } else { + others.push(entry); + } + } + + const ordered = + options.makePrimary === false + ? [...sameProvider, next, ...others] + : [next, ...sameProvider, ...others]; + + await this.redis.set(buildKey(userId, agentId), this.serialize(ordered)); + return next; + } + + async remove( + userId: string, + agentId: string, + options: { provider: string; profileId?: string } + ): Promise<{ removed: AuthProfile[]; secretsDeleted: number }> { + const current = await this.list(userId, agentId); + if (current.length === 0) { + return { removed: [], secretsDeleted: 0 }; + } + + const removed = current.filter((profile) => { + if (profile.provider !== options.provider) return false; + if (options.profileId && profile.id !== options.profileId) return false; + return true; + }); + const remaining = current.filter((profile) => !removed.includes(profile)); + + if (remaining.length > 0) { + await this.redis.set( + buildKey(userId, agentId), + this.serialize(remaining) + ); + } else { + await this.redis.del(buildKey(userId, agentId)); + } + + let secretsDeleted = 0; + for (const profile of removed) { + secretsDeleted += await deleteSecretsByPrefix( + this.secretStore, + buildProfileSecretPrefix(userId, agentId, profile.id) + ); + } + + return { removed, secretsDeleted }; + } + + /** + * Cascade-delete every profile and secret for a `(userId, agentId)`. + * Used when an agent is deleted entirely. + */ + async dropAgent(userId: string, agentId: string): Promise { + await this.redis.del(buildKey(userId, agentId)); + await deleteSecretsByPrefix( + this.secretStore, + buildAgentSecretPrefix(userId, agentId) + ); + } + + /** + * Yield every `(userId, agentId)` pair for which OAuth profiles exist. + * Used by `TokenRefreshJob` to scan refreshable tokens. + */ + async *scanAllOAuth(): AsyncIterable { + const pattern = `${KEY_PREFIX}:*`; + let cursor = "0"; + do { + const [next, keys] = await this.redis.scan( + cursor, + "MATCH", + pattern, + "COUNT", + 100 + ); + cursor = next; + for (const key of keys) { + const ref = parseKey(key); + if (ref) yield ref; + } + } while (cursor !== "0"); + } + + private serialize(profiles: AuthProfile[]): string { + const json = safeJsonStringify(profiles); + if (json === null) { + throw new Error("Failed to serialize user auth profiles"); + } + return json; + } + + private async persistSecrets( + userId: string, + agentId: string, + profile: AuthProfile + ): Promise { + const next: AuthProfile = { ...profile }; + const metadata = profile.metadata ? { ...profile.metadata } : undefined; + + if (profile.credential) { + next.credentialRef = await this.secretStore.put( + buildSecretName(userId, agentId, profile.id, "credential"), + profile.credential + ); + } + delete next.credential; + + if (metadata) { + if (metadata.refreshToken) { + metadata.refreshTokenRef = await this.secretStore.put( + buildSecretName(userId, agentId, profile.id, "refresh-token"), + metadata.refreshToken + ); + } + delete metadata.refreshToken; + next.metadata = metadata; + } + + return next; + } +} + +function parseKey(key: string): UserAgentRef | null { + const rest = key.startsWith(`${KEY_PREFIX}:`) + ? key.slice(KEY_PREFIX.length + 1) + : null; + if (!rest) return null; + const sep = rest.indexOf(":"); + if (sep <= 0 || sep === rest.length - 1) return null; + return { userId: rest.slice(0, sep), agentId: rest.slice(sep + 1) }; +} diff --git a/packages/gateway/src/cli/gateway.ts b/packages/gateway/src/cli/gateway.ts index 985a2c6b7..f7e2f06f7 100644 --- a/packages/gateway/src/cli/gateway.ts +++ b/packages/gateway/src/cli/gateway.ts @@ -405,12 +405,17 @@ export function createGatewayApp( const verifyProviderAuth = async ( c: any, agentId: string - ): Promise => { + ): Promise<{ userId: string; platform: string } | null> => { const payload = verifySettingsSessionOrToken(c); - if (!payload) return false; - if (payload.isAdmin) return true; + if (!payload) return null; + const principal = { + userId: payload.userId, + platform: payload.platform, + }; + if (payload.isAdmin) return principal; - if (payload.agentId) return payload.agentId === agentId; + if (payload.agentId) + return payload.agentId === agentId ? principal : null; if (userAgentsStore) { const owns = await userAgentsStore.ownsAgent( @@ -418,7 +423,7 @@ export function createGatewayApp( payload.userId, agentId ); - if (owns) return true; + if (owns) return principal; } if (agentMetadataStore) { @@ -432,11 +437,11 @@ export function createGatewayApp( .catch(() => { /* best-effort reconciliation */ }); - return true; + return principal; } } - return false; + return null; }; authRouter.post("/:provider/save-key", async (c: any) => { @@ -453,12 +458,14 @@ export function createGatewayApp( return c.json({ error: "Missing agentId or apiKey" }, 400); } - if (!(await verifyProviderAuth(c, agentId))) { + const principal = await verifyProviderAuth(c, agentId); + if (!principal) { return c.json({ error: "Unauthorized" }, 401); } await authProfilesManager.upsertProfile({ agentId, + userId: principal.userId, provider: providerId, credential: apiKey, authType: "api-key", @@ -550,11 +557,12 @@ export function createGatewayApp( ); } - if (!(await verifyProviderAuth(c, agentId))) { + const principal = await verifyProviderAuth(c, agentId); + if (!principal) { return c.json({ error: "Unauthorized" }, 401); } - const result = await mod.pollDeviceCode(agentId, { + const result = await mod.pollDeviceCode(agentId, principal.userId, { deviceAuthId, userCode, }); @@ -579,14 +587,18 @@ export function createGatewayApp( return c.json({ error: "Missing agentId" }, 400); } - if (!(await verifyProviderAuth(c, agentId))) { + const principal = await verifyProviderAuth(c, agentId); + if (!principal) { return c.json({ error: "Unauthorized" }, 401); } await authProfilesManager.deleteProviderProfiles( agentId, providerId, - body.profileId + { + userId: principal.userId, + ...(body.profileId ? { profileId: body.profileId } : {}), + } ); return c.json({ success: true }); diff --git a/packages/gateway/src/connections/chat-instance-manager.ts b/packages/gateway/src/connections/chat-instance-manager.ts index 17c1fccb9..4ef276f6c 100644 --- a/packages/gateway/src/connections/chat-instance-manager.ts +++ b/packages/gateway/src/connections/chat-instance-manager.ts @@ -1309,7 +1309,13 @@ export class ChatInstanceManager { const sessionId = `platform-chat:${name}:${options.channelId}:${conversationId}`; const sessionUserId = `${name}-${token.slice(0, 8) || "anonymous"}`; - if (!(await hasConfiguredProvider(options.agentId, agentSettingsStore))) { + if ( + !(await hasConfiguredProvider( + options.agentId, + agentSettingsStore, + this.services.getDeclaredAgentRegistry() + )) + ) { throw new Error( "No model configured. Ask an admin to connect a provider for the base agent." ); diff --git a/packages/gateway/src/connections/message-handler-bridge.ts b/packages/gateway/src/connections/message-handler-bridge.ts index 01890ccf2..2d5dc3148 100644 --- a/packages/gateway/src/connections/message-handler-bridge.ts +++ b/packages/gateway/src/connections/message-handler-bridge.ts @@ -448,7 +448,13 @@ export class MessageHandlerBridge { try { // Check if agent has any provider credentials before enqueuing - if (!(await hasConfiguredProvider(agentId, agentSettingsStore))) { + if ( + !(await hasConfiguredProvider( + agentId, + agentSettingsStore, + this.services.getDeclaredAgentRegistry() + )) + ) { await thread.post( "No AI provider is configured yet. Provider setup is not available in the end-user chat flow yet. Ask an admin to connect a provider for the base agent." ); @@ -614,7 +620,13 @@ export class MessageHandlerBridge { ); try { - if (!(await hasConfiguredProvider(agentId, agentSettingsStore))) { + if ( + !(await hasConfiguredProvider( + agentId, + agentSettingsStore, + this.services.getDeclaredAgentRegistry() + )) + ) { await thread.post( "No AI provider is configured yet. Provider setup is not available in the end-user chat flow yet. Ask an admin to connect a provider for the base agent." ); diff --git a/packages/gateway/src/lobu.ts b/packages/gateway/src/lobu.ts index e31bb77dc..300a15129 100644 --- a/packages/gateway/src/lobu.ts +++ b/packages/gateway/src/lobu.ts @@ -2,7 +2,11 @@ import type { Server } from "node:http"; import { createLogger } from "@lobu/core"; import { ApiPlatform } from "./api"; import { createGatewayApp, startGatewayServer } from "./cli/gateway"; -import { buildGatewayConfig, type GatewayConfig } from "./config"; +import { + type AgentConfig, + buildGatewayConfig, + type GatewayConfig, +} from "./config"; import { ChatInstanceManager, ChatResponseBridge } from "./connections"; import type { EmbeddedAuthProvider, @@ -10,7 +14,6 @@ import type { } from "./embedded"; import { Gateway } from "./gateway-main"; import type { SecretStoreRegistry } from "./secrets"; -import { InMemoryAgentStore } from "./stores/in-memory-agent-store"; const logger = createLogger("lobu"); @@ -93,8 +96,11 @@ export class Lobu { const defaultPublicUrl = `http://localhost:${this.port}`; - // Convert LobuConfig -> GatewayConfig via buildGatewayConfig overrides + // Convert LobuConfig -> GatewayConfig via buildGatewayConfig overrides. + // Passing `agents` lets core-services own InMemoryAgentStore population + // and DeclaredAgentRegistry seeding; avoids a parallel SDK seeding path. this.gatewayConfig = buildGatewayConfig({ + agents: this.agentConfigs.map(toAgentConfig), queues: { connectionString: config.redis }, orchestration: { deploymentMode: config.deploymentMode ?? "embedded", @@ -104,12 +110,7 @@ export class Lobu { }, }); - // Build InMemoryAgentStore pre-populated with agents from config - const store = new InMemoryAgentStore(); this.gateway = new Gateway(this.gatewayConfig, { - configStore: store, - connectionStore: store, - accessStore: store, secretStore: config.secretStore, providerCredentialResolver: config.providerCredentialResolver, }); @@ -128,14 +129,13 @@ export class Lobu { async initialize(): Promise { if (this.initialized) return; - // Start the gateway (initializes CoreServices, platforms, consumer) + // Start the gateway (initializes CoreServices, platforms, consumer). + // CoreServices populates agents + the declared registry from + // `gatewayConfig.agents` automatically — no separate seeding needed. await this.gateway.start(); const coreServices = this.gateway.getCoreServices(); - // Populate agents from config into the InMemoryAgentStore - await this.populateAgents(); - // Initialize Chat SDK connection manager for platform connections this.chatInstanceManager = new ChatInstanceManager(); try { @@ -217,103 +217,6 @@ export class Lobu { // ── Private Helpers ────────────────────────────────────────────────────── - /** - * Populate InMemoryAgentStore + seed credentials from LobuAgentConfig[]. - * Mirrors what CoreServices.populateStoreFromFiles() does for file-loaded agents. - */ - private async populateAgents(): Promise { - if (this.agentConfigs.length === 0) return; - - const coreServices = this.gateway.getCoreServices(); - const configStore = coreServices.getConfigStore(); - if (!configStore) return; - - const store = configStore as InMemoryAgentStore; - const authProfilesManager = coreServices.getAuthProfilesManager(); - - for (const agent of this.agentConfigs) { - // Save metadata - await store.saveMetadata(agent.id, { - agentId: agent.id, - name: agent.name ?? agent.id, - description: agent.description, - owner: { platform: "system", userId: "sdk" }, - createdAt: Date.now(), - }); - - // Build settings (same shape as FileLoadedAgent.settings) - const settings: Record = {}; - - if (agent.identity) settings.identityMd = agent.identity; - if (agent.soul) settings.soulMd = agent.soul; - if (agent.user) settings.userMd = agent.user; - - if (agent.providers?.length) { - settings.installedProviders = agent.providers.map((p) => ({ - providerId: p.id, - installedAt: Date.now(), - })); - settings.modelSelection = { mode: "auto" }; - const providerModelPreferences = Object.fromEntries( - agent.providers - .filter((p) => !!p.model?.trim()) - .map((p) => [p.id, p.model!.trim()]) - ); - if (Object.keys(providerModelPreferences).length > 0) { - settings.providerModelPreferences = providerModelPreferences; - } - } - - if (agent.network) { - settings.networkConfig = { - allowedDomains: agent.network.allowed, - deniedDomains: agent.network.denied, - }; - } - - if (agent.nixPackages?.length) { - settings.nixConfig = { packages: agent.nixPackages }; - } - - await store.saveSettings(agent.id, { - ...settings, - updatedAt: Date.now(), - } as any); - - // Seed provider credentials - if (authProfilesManager && agent.providers) { - for (const provider of agent.providers) { - if (provider.secretRef) { - await authProfilesManager.upsertProfile({ - agentId: agent.id, - provider: provider.id, - credentialRef: provider.secretRef, - authType: "api-key", - label: `${provider.id} (from SDK config)`, - makePrimary: true, - }); - continue; - } - - if (provider.key) { - authProfilesManager.registerEphemeralProfile({ - agentId: agent.id, - provider: provider.id, - credential: provider.key, - authType: "api-key", - label: `${provider.id} (from SDK config)`, - makePrimary: true, - }); - } - } - } - } - - logger.info( - `Populated ${this.agentConfigs.length} agent(s) from SDK config` - ); - } - /** * Seed platform connections from agent configs. * Mirrors the file-loaded agent connection seeding in gateway.ts startGateway(). @@ -375,3 +278,22 @@ export class Lobu { } } } + +/** + * Convert a public LobuAgentConfig into the internal AgentConfig shape so + * `buildGatewayConfig` and `CoreServices` can populate the agent store and + * declared registry from a single source. + */ +function toAgentConfig(agent: LobuAgentConfig): AgentConfig { + return { + id: agent.id, + name: agent.name ?? agent.id, + description: agent.description, + identityMd: agent.identity, + soulMd: agent.soul, + userMd: agent.user, + providers: agent.providers, + network: agent.network, + nixPackages: agent.nixPackages, + }; +} diff --git a/packages/gateway/src/modules/module-system.ts b/packages/gateway/src/modules/module-system.ts index 9c6ef4246..2554b37d5 100644 --- a/packages/gateway/src/modules/module-system.ts +++ b/packages/gateway/src/modules/module-system.ts @@ -70,6 +70,7 @@ export interface ModelProviderModule extends OrchestratorModule { }>; pollDeviceCode?( agentId: string, + userId: string, payload: { deviceAuthId: string; userCode: string } ): Promise<{ status: "pending" | "success"; diff --git a/packages/gateway/src/platform.ts b/packages/gateway/src/platform.ts index 05ee14b71..987298c6d 100644 --- a/packages/gateway/src/platform.ts +++ b/packages/gateway/src/platform.ts @@ -21,6 +21,7 @@ import type { IFileHandler } from "./platform/file-handler"; import type { ResponseRenderer } from "./platform/response-renderer"; import type { SecretProxy } from "./proxy/secret-proxy"; import type { WritableSecretStore } from "./secrets"; +import type { DeclaredAgentRegistry } from "./services/declared-agent-registry"; import type { InstructionService } from "./services/instruction-service"; import type { TranscriptionService } from "./services/transcription-service"; import type { ISessionManager } from "./session"; @@ -54,6 +55,7 @@ export interface CoreServices { getAgentMetadataStore(): AgentMetadataStore; getCommandRegistry(): CommandRegistry; getGrantStore(): GrantStore | undefined; + getDeclaredAgentRegistry(): DeclaredAgentRegistry | undefined; } // ============================================================================ diff --git a/packages/gateway/src/proxy/token-refresh-job.ts b/packages/gateway/src/proxy/token-refresh-job.ts index e940a288e..869bae8d1 100644 --- a/packages/gateway/src/proxy/token-refresh-job.ts +++ b/packages/gateway/src/proxy/token-refresh-job.ts @@ -1,5 +1,4 @@ import { createLogger } from "@lobu/core"; -import type Redis from "ioredis"; import type { OAuthClient } from "../auth/oauth/client"; import type { AuthProfilesManager } from "../auth/settings/auth-profiles-manager"; @@ -17,9 +16,9 @@ export interface RefreshableProvider { * Background job that proactively refreshes OAuth tokens before they expire. * * On each tick: - * 1. Scans authProfiles for OAuth tokens expiring soon across all registered providers - * 2. Refreshes via the provider's OAuth client - * 3. Updates authProfiles with new credentials + * 1. Scans `UserAuthProfileStore` for `(userId, agentId)` pairs holding OAuth profiles. + * 2. Refreshes any token expiring within `EXPIRY_BUFFER_MS` via its provider's OAuth client. + * 3. Writes the rotated credentials back through `AuthProfilesManager.upsertProfile`. */ export class TokenRefreshJob { private timer: Timer | null = null; @@ -27,7 +26,6 @@ export class TokenRefreshJob { constructor( private authProfilesManager: AuthProfilesManager, - private redis: Redis, private refreshableProviders: RefreshableProvider[] ) {} @@ -50,46 +48,35 @@ export class TokenRefreshJob { } private async tick(): Promise { - const pattern = "agent:settings:*"; - let cursor = "0"; - do { - const [next, keys] = await this.redis.scan( - cursor, - "MATCH", - pattern, - "COUNT", - 100 - ); - cursor = next; - for (const key of keys) { - const agentId = key.replace("agent:settings:", ""); - await this.maybeRefresh(agentId); - } - } while (cursor !== "0"); + const userAuthProfiles = this.authProfilesManager.getUserAuthProfileStore(); + for await (const { userId, agentId } of userAuthProfiles.scanAllOAuth()) { + await this.maybeRefresh(userId, agentId); + } } - private async maybeRefresh(agentId: string): Promise { - // Prevent concurrent refresh for the same agent - const existing = this.refreshLocks.get(agentId); + private async maybeRefresh(userId: string, agentId: string): Promise { + const lockKey = `${userId}:${agentId}`; + const existing = this.refreshLocks.get(lockKey); if (existing) { await existing; return; } - const promise = this.doRefresh(agentId); - this.refreshLocks.set(agentId, promise); + const promise = this.doRefresh(userId, agentId); + this.refreshLocks.set(lockKey, promise); try { await promise; } finally { - this.refreshLocks.delete(agentId); + this.refreshLocks.delete(lockKey); } } - private async doRefresh(agentId: string): Promise { + private async doRefresh(userId: string, agentId: string): Promise { for (const { providerId, oauthClient } of this.refreshableProviders) { const profiles = await this.authProfilesManager.getProviderProfiles( agentId, - providerId + providerId, + userId ); const oauthProfile = profiles.find( (profile) => @@ -103,7 +90,7 @@ export class TokenRefreshJob { if (!isExpiring) continue; logger.info( - `Refreshing ${providerId} token for agent ${agentId} profile ${oauthProfile.id}`, + `Refreshing ${providerId} token for user ${userId} agent ${agentId} profile ${oauthProfile.id}`, { expiresAt: new Date(expiresAt).toISOString() } ); @@ -114,6 +101,7 @@ export class TokenRefreshJob { await this.authProfilesManager.upsertProfile({ agentId, + userId, id: oauthProfile.id, provider: oauthProfile.provider, credential: newCredentials.accessToken, @@ -128,10 +116,12 @@ export class TokenRefreshJob { makePrimary: false, }); - logger.info(`Token refreshed for agent ${agentId} (${providerId})`); + logger.info( + `Token refreshed for user ${userId} agent ${agentId} (${providerId})` + ); } catch (error) { logger.error( - `Failed to refresh ${providerId} token for agent ${agentId}`, + `Failed to refresh ${providerId} token for user ${userId} agent ${agentId}`, { error, profileId: oauthProfile.id, diff --git a/packages/gateway/src/routes/public/agent-config.ts b/packages/gateway/src/routes/public/agent-config.ts index 31e0741da..3e1c9ba6c 100644 --- a/packages/gateway/src/routes/public/agent-config.ts +++ b/packages/gateway/src/routes/public/agent-config.ts @@ -5,7 +5,7 @@ */ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; -import type { AgentConfigStore, AuthProfile, SkillConfig } from "@lobu/core"; +import type { AgentConfigStore, SkillConfig } from "@lobu/core"; import type { ProviderCatalogService } from "../../auth/provider-catalog"; import { collectProviderModelOptions } from "../../auth/provider-model-options"; @@ -56,25 +56,6 @@ export interface ConfigChangeEntry { const SENSITIVE_KEY_PATTERN = /(?:credential|secret|token|password|api(?:_|-)?key|authorization)/i; -type SanitizedAuthProfile = Omit< - AuthProfile, - "credential" | "credentialRef" | "metadata" -> & { - credential: string; - credentialRedacted: true; - metadata?: Omit< - NonNullable, - "refreshToken" | "refreshTokenRef" - > & { - refreshToken?: string; - refreshTokenRedacted?: true; - }; -}; - -type PublicAgentSettings = Omit & { - authProfiles?: SanitizedAuthProfile[]; -}; - // --- Route Definitions --- const getConfigRoute = createRoute({ @@ -100,7 +81,10 @@ const getConfigRoute = createRoute({ }); export interface ProviderCredentialStore { - hasCredentials(agentId: string): Promise; + hasCredentials( + agentId: string, + context?: { userId?: string } + ): Promise; } export interface AgentConfigRoutesConfig { @@ -166,7 +150,6 @@ const SECTION_SETTING_KEYS: Record< > = { model: [ "installedProviders", - "authProfiles", "model", "modelSelection", "providerModelPreferences", @@ -227,9 +210,6 @@ function resolveProviderSources( (provider) => provider.providerId ) ); - const localProfileProviders = new Set( - (localSettings?.authProfiles || []).map((profile) => profile.provider) - ); const localPreferenceProviders = new Set( Object.keys(localSettings?.providerModelPreferences || {}) ); @@ -243,7 +223,6 @@ function resolveProviderSources( effectiveProviderIds.map((providerId) => { const hasLocalOverride = localProviderIds.has(providerId) || - localProfileProviders.has(providerId) || localPreferenceProviders.has(providerId); const source = resolveSectionSource( @@ -361,10 +340,17 @@ async function buildResolvedConfigResponse( try { const hasSystemCredentials = config.providerConnectedOverrides?.[name] === true; - const hasUserCredentials = await store.hasCredentials(agentId); + const hasUserCredentials = await store.hasCredentials( + agentId, + payload?.userId ? { userId: payload.userId } : undefined + ); const profiles = config.authProfilesManager - ? await config.authProfilesManager.getProviderProfiles(agentId, name) + ? await config.authProfilesManager.getProviderProfiles( + agentId, + name, + payload?.userId + ) : []; const now = Date.now(); const validProfiles = profiles.filter( @@ -603,10 +589,10 @@ export function createAgentConfigRoutes( function sanitizeSettingsForResponse( settings: AgentSettings | null -): PublicAgentSettings | Record { +): AgentSettings | Record { if (!settings) return {}; - const sanitized = redactSensitiveFields(settings) as PublicAgentSettings; + const sanitized = redactSensitiveFields(settings) as AgentSettings; if (sanitized.skillsConfig?.skills) { sanitized.skillsConfig = { @@ -625,47 +611,9 @@ function sanitizeSettingsForResponse( }; } - if (Array.isArray(settings.authProfiles)) { - sanitized.authProfiles = settings.authProfiles.map(sanitizeAuthProfile); - } - return sanitized; } -function sanitizeAuthProfile(profile: AuthProfile): SanitizedAuthProfile { - const hadRefreshToken = - !!profile.metadata?.refreshToken || !!profile.metadata?.refreshTokenRef; - const metadata = profile.metadata - ? (() => { - const { - refreshToken: _refreshToken, - refreshTokenRef: _refreshTokenRef, - ...rest - } = redactSensitiveFields(profile.metadata) as NonNullable< - AuthProfile["metadata"] - >; - return rest as SanitizedAuthProfile["metadata"]; - })() - : undefined; - - if (metadata && hadRefreshToken) { - metadata.refreshTokenRedacted = true; - } - - const { - credential: _credential, - credentialRef: _credentialRef, - ...rest - } = profile; - - return { - ...rest, - credential: REDACTED_VALUE, - credentialRedacted: true, - metadata, - }; -} - function redactSensitiveFields(value: unknown): unknown { if (Array.isArray(value)) { return value.map((entry) => redactSensitiveFields(entry)); diff --git a/packages/gateway/src/routes/public/agent.ts b/packages/gateway/src/routes/public/agent.ts index 9e177c242..d118006db 100644 --- a/packages/gateway/src/routes/public/agent.ts +++ b/packages/gateway/src/routes/public/agent.ts @@ -592,7 +592,7 @@ export function createAgentApi( // Also inherit pluginsConfig from template agent if available const templateId = agentConfigStore ? await findTemplateAgentId(agentConfigStore) - : await agentSettingsStore.findTemplateAgentId(); + : null; const templateSettings = templateId ? await (agentConfigStore?.getSettings(templateId) ?? agentSettingsStore.getSettings(templateId)) @@ -608,7 +608,7 @@ export function createAgentApi( // Fall back to using an existing agent as template (inherits its providers) const templateId = agentConfigStore ? await findTemplateAgentId(agentConfigStore) - : await agentSettingsStore.findTemplateAgentId(); + : null; if (templateId) { const templateSettings = await (agentConfigStore?.getSettings( templateId diff --git a/packages/gateway/src/routes/public/oauth.ts b/packages/gateway/src/routes/public/oauth.ts index cf2dd9ba3..b96b052a4 100644 --- a/packages/gateway/src/routes/public/oauth.ts +++ b/packages/gateway/src/routes/public/oauth.ts @@ -14,8 +14,12 @@ const ErrorResponse = z.object({ error: z.string() }); export interface ProviderCredentialStore { hasCredentials(agentId: string): Promise; - deleteCredentials(agentId: string): Promise; - setCredentials(agentId: string, credentials: unknown): Promise; + deleteCredentials(agentId: string, userId: string): Promise; + setCredentials( + agentId: string, + userId: string, + credentials: unknown + ): Promise; } export interface ProviderOAuthClient { @@ -133,7 +137,11 @@ export function createOAuthRoutes(config: OAuthRoutesConfig): OpenAPIHono { "https://console.anthropic.com/oauth/code/callback", state ); - await credentialStore.setCredentials(agentId, credentials); + await credentialStore.setCredentials( + agentId, + session.userId, + credentials + ); return c.json({ success: true }); } catch (e) { return c.json( diff --git a/packages/gateway/src/services/core-services.ts b/packages/gateway/src/services/core-services.ts index 4133975cf..92340ef7f 100644 --- a/packages/gateway/src/services/core-services.ts +++ b/packages/gateway/src/services/core-services.ts @@ -27,6 +27,7 @@ import { import { ProviderCatalogService } from "../auth/provider-catalog"; import { AgentSettingsStore, AuthProfilesManager } from "../auth/settings"; import { ModelPreferenceStore } from "../auth/settings/model-preference-store"; +import { UserAuthProfileStore } from "../auth/settings/user-auth-profile-store"; import { UserAgentsStore } from "../auth/user-agents-store"; import { ChannelBindingService } from "../channels"; import { ConversationStateStore } from "../connections/conversation-state-store"; @@ -69,6 +70,11 @@ import { } from "../stores/redis-agent-store"; import { BedrockModelCatalog } from "./bedrock-model-catalog"; import { BedrockOpenAIService } from "./bedrock-openai-service"; +import { + buildRegistryMap, + DeclaredAgentRegistry, + entryFromAgentConfig, +} from "./declared-agent-registry"; import { ImageGenerationService } from "./image-generation-service"; import { InstructionService } from "./instruction-service"; import { SessionManager, StateAdapterSessionStore } from "./session-manager"; @@ -100,6 +106,8 @@ export class CoreServices { // Auth & Provider Services // ============================================================================ private authProfilesManager?: AuthProfilesManager; + private declaredAgentRegistry?: DeclaredAgentRegistry; + private userAuthProfileStore?: UserAuthProfileStore; private modelPreferenceStore?: ModelPreferenceStore; private oauthStateStore?: ProviderOAuthStateStore; private secretProxy?: SecretProxy; @@ -362,13 +370,7 @@ export class CoreServices { logger.debug("Secret store initialized"); // Initialize agent configuration stores - this.agentSettingsStore = new AgentSettingsStore( - redisClient, - this.secretStore, - { - runtimeCredentialResolver: this.options?.providerCredentialResolver, - } - ); + this.agentSettingsStore = new AgentSettingsStore(redisClient); this.channelBindingService = new ChannelBindingService(redisClient); this.userAgentsStore = new UserAgentsStore(redisClient); this.agentMetadataStore = new AgentMetadataStore(redisClient); @@ -485,39 +487,52 @@ export class CoreServices { ); } - if (this.fileLoadedAgents.length > 0) { - for (const agent of this.fileLoadedAgents) { - await this.syncAgentSettingsToRuntimeStore( - agent.agentId, - agent.settings - ); - } - logger.debug( - `Synced settings for ${this.fileLoadedAgents.length} file-loaded agent(s)` - ); - } - - if (this.configAgents.length > 0) { - for (const agent of this.configAgents) { - await this.syncAgentSettingsToRuntimeStore( - agent.id, - this.buildSettingsFromAgentConfig(agent) - ); - } - logger.debug( - `Synced settings for ${this.configAgents.length} config agent(s)` - ); - } - // Initialize auth profile and preference stores if (!this.secretStore) { throw new Error("Secret store must be initialized before auth services"); } - this.authProfilesManager = new AuthProfilesManager( - this.agentSettingsStore, + // Declared registry: read-only snapshot of file/SDK-declared agents. + // No Redis copy is kept — declared settings live in memory and are + // rebuilt wholesale on hot-reload. + this.declaredAgentRegistry = new DeclaredAgentRegistry(); + this.declaredAgentRegistry.replaceAll( + buildRegistryMap(this.fileLoadedAgents, this.configAgents) + ); + // Plumb registry into the settings store so getEffectiveSettings + // returns declared settings for declared agents (no Redis copy exists + // by design — see one-shot cleanup below). + this.agentSettingsStore.setDeclaredAgents(this.declaredAgentRegistry); + + // User-scoped auth profile store: durable per-(userId, agentId) + // OAuth/BYOK state. Replaces the authProfiles field that used to + // live on AgentSettingsStore. + this.userAuthProfileStore = new UserAuthProfileStore( + redisClient, this.secretStore ); + + // One-shot cleanup: declared agents must not have stale Redis settings + // hanging around. Deleting the key keeps `agent:settings:*` reserved + // for runtime-created agents only. + for (const agentId of this.declaredAgentRegistry.agentIds()) { + try { + await this.agentSettingsStore.deleteSettings(agentId); + } catch (error) { + logger.warn("Failed to clear stale settings for declared agent", { + agentId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + this.authProfilesManager = new AuthProfilesManager({ + ephemeralProfiles: this.agentSettingsStore.getEphemeralAuthProfiles(), + declaredAgents: this.declaredAgentRegistry, + userAuthProfiles: this.userAuthProfileStore, + secretStore: this.secretStore, + runtimeCredentialResolver: this.options?.providerCredentialResolver, + }); this.transcriptionService = new TranscriptionService( this.authProfilesManager ); @@ -527,55 +542,24 @@ export class CoreServices { this.artifactStore = new ArtifactStore(); this.modelPreferenceStore = new ModelPreferenceStore(redisClient, "claude"); - // Seed provider credentials from file-loaded agents - if (this.authProfilesManager && this.fileLoadedAgents.length > 0) { - for (const agent of this.fileLoadedAgents) { - for (const cred of agent.credentials) { - await this.authProfilesManager.upsertProfile({ - agentId: agent.agentId, - provider: cred.provider, - ...(cred.key ? { credential: cred.key } : {}), - ...(cred.secretRef ? { credentialRef: cred.secretRef } : {}), - authType: "api-key", - label: `${cred.provider} (from lobu.toml)`, - makePrimary: true, - }); - } - } - logger.debug( - `Seeded credentials for ${this.fileLoadedAgents.length} file-loaded agent(s)` - ); - } - - // Seed provider credentials from config agents (embedded SDK mode). - // `secretRef` → durable upsertProfile (persists through secret store). - // `key` → registerEphemeralProfile (in-memory, SDK-process lifetime). - if (this.authProfilesManager && this.configAgents.length > 0) { + // Embedded SDK mode: per-agent in-memory credentials supplied via + // `provider.key` are exposed as ephemeral profiles. Credentials with + // a `secretRef` come through the declared registry (no separate + // ephemeral copy needed). + if (this.configAgents.length > 0) { for (const agent of this.configAgents) { for (const provider of agent.providers || []) { - if (!provider.key && !provider.secretRef) continue; - - const input = { + if (!provider.key || provider.secretRef) continue; + this.authProfilesManager.registerEphemeralProfile({ agentId: agent.id, provider: provider.id, - authType: "api-key" as const, + credential: provider.key, + authType: "api-key", label: `${provider.id} (from config)`, makePrimary: true, - ...(provider.secretRef - ? { credentialRef: provider.secretRef } - : { credential: provider.key }), - }; - - if (provider.secretRef) { - await this.authProfilesManager.upsertProfile(input); - } else { - this.authProfilesManager.registerEphemeralProfile(input); - } + }); } } - logger.debug( - `Seeded credentials for ${this.configAgents.length} config agent(s)` - ); } logger.debug( @@ -602,11 +586,9 @@ export class CoreServices { "Auth profiles manager must be initialized before token refresh job" ); } - this.tokenRefreshJob = new TokenRefreshJob( - this.authProfilesManager, - redisClient, - [{ providerId: "claude", oauthClient: new OAuthClient(CLAUDE_PROVIDER) }] - ); + this.tokenRefreshJob = new TokenRefreshJob(this.authProfilesManager, [ + { providerId: "claude", oauthClient: new OAuthClient(CLAUDE_PROVIDER) }, + ]); this.tokenRefreshJob.start(); logger.debug("Token refresh job started"); @@ -622,7 +604,7 @@ export class CoreServices { ); // Register ChatGPT OAuth module - const chatgptOAuthModule = new ChatGPTOAuthModule(this.agentSettingsStore); + const chatgptOAuthModule = new ChatGPTOAuthModule(this.authProfilesManager); moduleRegistry.register(chatgptOAuthModule); logger.debug( `ChatGPT OAuth module registered (system token: ${chatgptOAuthModule.hasSystemKey() ? "available" : "not available"})` @@ -689,7 +671,7 @@ export class CoreServices { registryAlias: entry.registryAlias, apiKeyInstructions: entry.apiKeyInstructions, apiKeyPlaceholder: entry.apiKeyPlaceholder, - agentSettingsStore: this.agentSettingsStore, + authProfilesManager: this.authProfilesManager, }); moduleRegistry.register(module); registeredIds.add(id); @@ -701,7 +683,8 @@ export class CoreServices { // Initialize provider catalog service this.providerCatalogService = new ProviderCatalogService( this.agentSettingsStore, - this.authProfilesManager + this.authProfilesManager, + this.declaredAgentRegistry ); logger.debug("Provider catalog service initialized"); @@ -898,64 +881,6 @@ export class CoreServices { } } - private buildSettingsFromAgentConfig( - agent: AgentConfig - ): Record { - const settings: Record = {}; - if (agent.identityMd) settings.identityMd = agent.identityMd; - if (agent.soulMd) settings.soulMd = agent.soulMd; - if (agent.userMd) settings.userMd = agent.userMd; - - if (agent.providers?.length) { - settings.installedProviders = agent.providers.map((p) => ({ - providerId: p.id, - installedAt: Date.now(), - })); - settings.modelSelection = { mode: "auto" }; - const providerModelPreferences = Object.fromEntries( - agent.providers - .filter((p) => !!p.model?.trim()) - .map((p) => [p.id, p.model!.trim()]) - ); - if (Object.keys(providerModelPreferences).length > 0) { - settings.providerModelPreferences = providerModelPreferences; - } - } - - if (agent.skills?.mcp) { - settings.mcpServers = agent.skills.mcp; - } - - if (agent.network) { - settings.networkConfig = { - allowedDomains: agent.network.allowed, - deniedDomains: agent.network.denied, - }; - } - - if (agent.nixPackages?.length) { - settings.nixConfig = { packages: agent.nixPackages }; - } - - return settings; - } - - private async syncAgentSettingsToRuntimeStore( - agentId: string, - settings: Record - ): Promise { - if (!this.agentSettingsStore) { - throw new Error("Agent settings store must be initialized"); - } - - const existing = await this.agentSettingsStore.getSettings(agentId); - await this.agentSettingsStore.saveSettings(agentId, { - ...settings, - authProfiles: existing?.authProfiles, - mcpInstallNotified: existing?.mcpInstallNotified, - }); - } - private async populateStoreFromAgentConfigs( store: InMemoryAgentStore, agents: AgentConfig[] @@ -969,7 +894,7 @@ export class CoreServices { createdAt: Date.now(), }); await store.saveSettings(agent.id, { - ...this.buildSettingsFromAgentConfig(agent), + ...entryFromAgentConfig(agent).settings, updatedAt: Date.now(), } as any); } @@ -1010,30 +935,13 @@ export class CoreServices { await this.populateStoreFromFiles(store, this.fileLoadedAgents); } - if (this.agentSettingsStore) { - for (const agent of this.fileLoadedAgents) { - await this.syncAgentSettingsToRuntimeStore( - agent.agentId, - agent.settings - ); - } - } - - // Re-seed credentials - if (this.authProfilesManager) { - for (const agent of this.fileLoadedAgents) { - for (const cred of agent.credentials) { - await this.authProfilesManager.upsertProfile({ - agentId: agent.agentId, - provider: cred.provider, - ...(cred.key ? { credential: cred.key } : {}), - ...(cred.secretRef ? { credentialRef: cred.secretRef } : {}), - authType: "api-key", - label: `${cred.provider} (from lobu.toml)`, - makePrimary: true, - }); - } - } + // Repopulate the declared registry so subsequent credential lookups + // see the new file-declared providers/keys. No Redis sync, no + // additive seeding — the registry IS the source of truth. + if (this.declaredAgentRegistry) { + this.declaredAgentRegistry.replaceAll( + buildRegistryMap(this.fileLoadedAgents, this.configAgents) + ); } const agentIds = this.fileLoadedAgents.map((a) => a.agentId); @@ -1232,6 +1140,14 @@ export class CoreServices { return this.authProfilesManager; } + getDeclaredAgentRegistry(): DeclaredAgentRegistry | undefined { + return this.declaredAgentRegistry; + } + + getUserAuthProfileStore(): UserAuthProfileStore | undefined { + return this.userAuthProfileStore; + } + getGrantStore(): GrantStore | undefined { return this.grantStore; } diff --git a/packages/gateway/src/services/declared-agent-registry.ts b/packages/gateway/src/services/declared-agent-registry.ts new file mode 100644 index 000000000..a662fd6db --- /dev/null +++ b/packages/gateway/src/services/declared-agent-registry.ts @@ -0,0 +1,150 @@ +import type { AgentSettings, DeclaredCredential } from "@lobu/core"; +import { createLogger } from "@lobu/core"; +import type { AgentConfig } from "../config"; +import type { FileLoadedAgent } from "../config/file-loader"; + +const logger = createLogger("declared-agent-registry"); + +export interface DeclaredAgentEntry { + settings: Partial; + credentials: DeclaredCredential[]; +} + +/** + * In-memory registry of agents declared by `lobu.toml` (file-loader) or + * `GatewayConfig.agents` (SDK embedded mode). + * + * Declared agents own their settings and credentials at runtime — there is + * no Redis copy to drift. The registry is rebuilt wholesale by callers + * (e.g. `reloadFromFiles`) so removing a provider from `lobu.toml` removes + * it from the registry on next reload. + */ +export class DeclaredAgentRegistry { + private readonly entries = new Map(); + + has(agentId: string): boolean { + return this.entries.has(agentId); + } + + get(agentId: string): DeclaredAgentEntry | undefined { + return this.entries.get(agentId); + } + + agentIds(): string[] { + return Array.from(this.entries.keys()); + } + + entriesList(): Array<[string, DeclaredAgentEntry]> { + return Array.from(this.entries.entries()); + } + + /** Replace the entire registry. Used at startup and on hot-reload. */ + replaceAll(next: Map): void { + this.entries.clear(); + for (const [agentId, entry] of next) { + this.entries.set(agentId, entry); + } + logger.debug(`Registry now holds ${this.entries.size} declared agent(s)`); + } + + /** + * Find the first declared agent that has installed providers. Used to + * pick a template for ephemeral/sandbox agents. + */ + findTemplateAgentId(): string | null { + for (const [agentId, entry] of this.entries) { + if (entry.settings.installedProviders?.length) { + return agentId; + } + } + return null; + } +} + +/** + * Build a registry entry from a file-loaded agent (lobu.toml). + */ +export function entryFromFileLoadedAgent( + agent: FileLoadedAgent +): DeclaredAgentEntry { + return { + settings: agent.settings, + credentials: agent.credentials.map((cred) => ({ + provider: cred.provider, + ...(cred.key ? { key: cred.key } : {}), + ...(cred.secretRef ? { secretRef: cred.secretRef } : {}), + })), + }; +} + +/** + * Build a registry entry from an embedded SDK `AgentConfig`. The settings + * shape mirrors `buildSettingsFromAgentConfig` in `core-services` so the + * registry can be populated from either source consistently. + */ +export function entryFromAgentConfig(agent: AgentConfig): DeclaredAgentEntry { + const settings: Partial = {}; + if (agent.identityMd) settings.identityMd = agent.identityMd; + if (agent.soulMd) settings.soulMd = agent.soulMd; + if (agent.userMd) settings.userMd = agent.userMd; + + if (agent.providers?.length) { + settings.installedProviders = agent.providers.map((p) => ({ + providerId: p.id, + installedAt: Date.now(), + })); + settings.modelSelection = { mode: "auto" }; + const providerModelPreferences = Object.fromEntries( + agent.providers + .filter((p) => !!p.model?.trim()) + .map((p) => [p.id, p.model!.trim()]) + ); + if (Object.keys(providerModelPreferences).length > 0) { + settings.providerModelPreferences = providerModelPreferences; + } + } + + if (agent.skills?.mcp) { + settings.mcpServers = agent.skills.mcp; + } + + if (agent.network) { + settings.networkConfig = { + allowedDomains: agent.network.allowed, + deniedDomains: agent.network.denied, + }; + } + + if (agent.nixPackages?.length) { + settings.nixConfig = { packages: agent.nixPackages }; + } + + const credentials: DeclaredCredential[] = (agent.providers || []) + .filter((p) => p.key || p.secretRef) + .map((p) => ({ + provider: p.id, + ...(p.key ? { key: p.key } : {}), + ...(p.secretRef ? { secretRef: p.secretRef } : {}), + })); + + return { settings, credentials }; +} + +/** + * Convenience: build a fresh registry map from a list of file-loaded + * agents and a list of SDK config agents. Used by `core-services` to + * populate the registry on startup and on `reloadFromFiles`. + */ +export function buildRegistryMap( + fileAgents: FileLoadedAgent[], + configAgents: AgentConfig[] +): Map { + const result = new Map(); + for (const agent of fileAgents) { + result.set(agent.agentId, entryFromFileLoadedAgent(agent)); + } + for (const agent of configAgents) { + result.set(agent.id, entryFromAgentConfig(agent)); + } + return result; +} diff --git a/packages/gateway/src/services/platform-helpers.ts b/packages/gateway/src/services/platform-helpers.ts index 031fda17e..565d96770 100644 --- a/packages/gateway/src/services/platform-helpers.ts +++ b/packages/gateway/src/services/platform-helpers.ts @@ -14,6 +14,7 @@ import type { ChannelBindingService } from "../channels"; import { buildMemoryPlugins, getInternalGatewayUrl } from "../config"; import type { MessagePayload } from "../infrastructure/queue/queue-producer"; import { getModelProviderModules } from "../modules/module-system"; +import type { DeclaredAgentRegistry } from "./declared-agent-registry"; import { platformAgentId } from "../spaces"; const logger = createLogger("platform-helpers"); @@ -194,30 +195,29 @@ export async function resolveAgentOptions( export async function hasConfiguredProvider( agentId: string, - agentSettingsStore?: AgentSettingsStore + agentSettingsStore?: AgentSettingsStore, + declaredAgents?: DeclaredAgentRegistry ): Promise { - if (!agentSettingsStore) { - return true; - } + if (!agentSettingsStore) return true; - const settings = await agentSettingsStore.getEffectiveSettings(agentId); - const installedProviderIds = new Set( - (settings?.installedProviders || []).map((provider) => provider.providerId) - ); + const declaredEntry = declaredAgents?.get(agentId); + if (declaredEntry?.credentials.length) return true; - if ((settings?.authProfiles?.length || 0) > 0) { - return true; - } + const settings = await agentSettingsStore.getEffectiveSettings(agentId); + const installedProviderIds = new Set([ + ...(settings?.installedProviders ?? []).map((p) => p.providerId), + ...(declaredEntry?.settings.installedProviders ?? []).map( + (p) => p.providerId + ), + ]); const modules = getModelProviderModules(); - if (installedProviderIds.size > 0) { - return modules.some( - (module) => - installedProviderIds.has(module.providerId) && module.hasSystemKey() - ); + if (installedProviderIds.size === 0) { + return modules.some((m) => m.hasSystemKey()); } - - return modules.some((module) => module.hasSystemKey()); + return modules.some( + (m) => installedProviderIds.has(m.providerId) && m.hasSystemKey() + ); } /**