diff --git a/extensions/ollama/src/embedding-provider.test.ts b/extensions/ollama/src/embedding-provider.test.ts index d406ae61a651..4d12b444eb3d 100644 --- a/extensions/ollama/src/embedding-provider.test.ts +++ b/extensions/ollama/src/embedding-provider.test.ts @@ -259,25 +259,159 @@ describe("ollama embedding provider", () => { ).rejects.toThrow(/memory\.search\.remote\.apiKey: unresolved SecretRef/i); }); - it("falls back to env key when provider apiKey is an unresolved SecretRef", async () => { - vi.stubEnv("OLLAMA_API_KEY", "ollama-env"); + it.each(["ollama", "ollama-private"])( + "resolves selected %s provider credential and header SecretRefs before ambient cloud auth", + async (providerId) => { + vi.stubEnv("OLLAMA_API_KEY", "synthetic-cloud-key"); + vi.stubEnv("OLLAMA_SELECTED_HOST_KEY", "synthetic-selected-host-key"); + vi.stubEnv("OLLAMA_PROXY_KEY", "synthetic-proxy-key"); - const { fetchMock } = await embedTestQuery({ - config: createProviderConfig({ - baseUrl: "http://127.0.0.1:11434/v1", - apiKey: { source: "env", provider: "default", id: "OLLAMA_API_KEY" }, - models: [], - }), - }); + const { fetchMock } = await embedTestQuery({ + config: createProviderConfig( + { + baseUrl: "https://selected-private-host.invalid/v1", + apiKey: { + source: "env", + provider: "default", + id: "OLLAMA_SELECTED_HOST_KEY", + }, + headers: { + "X-Proxy-Auth": { + source: "env", + provider: "default", + id: "OLLAMA_PROXY_KEY", + }, + }, + models: [], + }, + providerId, + ), + provider: providerId, + }); - expectEmbeddingFetch(fetchMock, "http://127.0.0.1:11434/api/embed", { - input: "search_query: hello", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer ollama-env", - }, - }); - }); + expectEmbeddingFetch(fetchMock, "https://selected-private-host.invalid/api/embed", { + input: "search_query: hello", + headers: { + "Content-Type": "application/json", + "X-Proxy-Auth": "synthetic-proxy-key", + Authorization: "Bearer synthetic-selected-host-key", + }, + }); + }, + ); + + it.each([ + { providerId: "ollama", surface: "apiKey", source: "env" }, + { providerId: "ollama-private", surface: "apiKey", source: "env" }, + { providerId: "ollama", surface: "headers", source: "env" }, + { providerId: "ollama-private", surface: "headers", source: "env" }, + { providerId: "ollama", surface: "apiKey", source: "file" }, + { providerId: "ollama", surface: "headers", source: "file" }, + { providerId: "ollama-private", surface: "apiKey", source: "exec" }, + { providerId: "ollama-private", surface: "headers", source: "exec" }, + ])( + "fails closed before any request for unresolved $providerId $surface $source SecretRefs", + async ({ providerId, surface, source }) => { + vi.stubEnv("OLLAMA_API_KEY", "synthetic-cloud-key"); + vi.stubEnv("OLLAMA_SELECTED_HOST_KEY", "synthetic-selected-host-key"); + vi.stubEnv("OLLAMA_MISSING_SELECTED_SECRET", ""); + const fetchMock = mockEmbeddingFetch([1, 0]); + const unavailableSecret = { + source, + provider: "default" as const, + id: + source === "file" + ? "/synthetic/missing-ollama-secret" + : source === "exec" + ? "synthetic-missing-ollama-secret" + : "OLLAMA_MISSING_SELECTED_SECRET", + }; + const selectedHostKey = { + source: "env" as const, + provider: "default" as const, + id: "OLLAMA_SELECTED_HOST_KEY", + }; + + await expect( + createEmbeddingProvider({ + config: createProviderConfig( + { + baseUrl: "https://selected-private-host.invalid/v1", + apiKey: surface === "apiKey" ? unavailableSecret : selectedHostKey, + ...(surface === "headers" + ? { + headers: { + "X-Proxy-Auth": unavailableSecret, + }, + } + : {}), + models: [], + }, + providerId, + ), + provider: providerId, + }), + ).rejects.toThrow( + `models.providers.${providerId}.${ + surface === "headers" ? "headers.X-Proxy-Auth" : "apiKey" + }`, + ); + expect(fetchMock).not.toHaveBeenCalled(); + expect(fetchConfiguredLocalOriginWithSsrFGuardMock).not.toHaveBeenCalled(); + }, + ); + + it.each(["OLLAMA_API_KEY", "ollama-local"])( + "keeps explicit selected-host SecretRef value %s opaque", + async (resolvedSecret) => { + vi.stubEnv("OLLAMA_API_KEY", "synthetic-cloud-key"); + vi.stubEnv("OLLAMA_SELECTED_HOST_KEY", resolvedSecret); + + const { fetchMock } = await embedTestQuery({ + config: createProviderConfig({ + baseUrl: "https://selected-private-host.invalid/v1", + apiKey: { + source: "env", + provider: "default", + id: "OLLAMA_SELECTED_HOST_KEY", + }, + models: [], + }), + }); + + expectEmbeddingFetch(fetchMock, "https://selected-private-host.invalid/api/embed", { + input: "search_query: hello", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${resolvedSecret}`, + }, + }); + }, + ); + + it.each(["$OLLAMA_SELECTED_HOST_KEY", "${OLLAMA_SELECTED_HOST_KEY}"])( + "resolves selected-host env shorthand SecretRef %s before ambient cloud auth", + async (apiKey) => { + vi.stubEnv("OLLAMA_API_KEY", "synthetic-cloud-key"); + vi.stubEnv("OLLAMA_SELECTED_HOST_KEY", "synthetic-selected-host-key"); + + const { fetchMock } = await embedTestQuery({ + config: createProviderConfig({ + baseUrl: "https://selected-private-host.invalid/v1", + apiKey, + models: [], + }), + }); + + expectEmbeddingFetch(fetchMock, "https://selected-private-host.invalid/api/embed", { + input: "search_query: hello", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer synthetic-selected-host-key", + }, + }); + }, + ); it("sends batch embeddings in one Ollama request", async () => { const { fetchMock, inputs } = mockBatchEmbeddingFetch(3); @@ -504,6 +638,95 @@ describe("ollama embedding provider", () => { }); }); + it.each(["Authorization", "authorization", "AUTHORIZATION"])( + "keeps explicit remote %s header ahead of ambient Ollama Cloud credentials", + async (headerName) => { + vi.stubEnv("OLLAMA_API_KEY", "synthetic-cloud-tenant-b"); + + const { fetchMock } = await embedTestQuery({ + remote: { + baseUrl: "https://ollama.com", + headers: { [headerName]: "Bearer synthetic-explicit-tenant-a" }, + }, + }); + + expectEmbeddingFetch(fetchMock, "https://ollama.com/api/embed", { + input: "search_query: hello", + headers: { + "Content-Type": "application/json", + [headerName]: "Bearer synthetic-explicit-tenant-a", + }, + }); + }, + ); + + it("uses explicit header auth without resolving an inactive selected-host apiKey SecretRef", async () => { + vi.stubEnv("OLLAMA_API_KEY", "synthetic-cloud-key"); + vi.stubEnv("OLLAMA_MISSING_SELECTED_SECRET", ""); + + const { fetchMock } = await embedTestQuery({ + config: createProviderConfig({ + baseUrl: "https://selected-private-host.invalid/v1", + apiKey: { + source: "env", + provider: "default", + id: "OLLAMA_MISSING_SELECTED_SECRET", + }, + models: [], + }), + remote: { + baseUrl: "https://selected-private-host.invalid", + headers: { authorization: "Bearer synthetic-explicit-tenant-a" }, + }, + }); + + expectEmbeddingFetch(fetchMock, "https://selected-private-host.invalid/api/embed", { + input: "search_query: hello", + headers: { + "Content-Type": "application/json", + authorization: "Bearer synthetic-explicit-tenant-a", + }, + }); + }); + + it("skips a selected-host SecretRef header overridden case-insensitively by remote auth", async () => { + vi.stubEnv("OLLAMA_API_KEY", "synthetic-cloud-key"); + vi.stubEnv("OLLAMA_SELECTED_HOST_KEY", "synthetic-selected-host-key"); + vi.stubEnv("OLLAMA_MISSING_SELECTED_SECRET", ""); + + const { fetchMock } = await embedTestQuery({ + config: createProviderConfig({ + baseUrl: "https://selected-private-host.invalid/v1", + apiKey: { + source: "env", + provider: "default", + id: "OLLAMA_SELECTED_HOST_KEY", + }, + headers: { + "X-Proxy-Auth": { + source: "env", + provider: "default", + id: "OLLAMA_MISSING_SELECTED_SECRET", + }, + }, + models: [], + }), + remote: { + baseUrl: "https://selected-private-host.invalid", + headers: { "x-proxy-auth": "synthetic-remote-header" }, + }, + }); + + expectEmbeddingFetch(fetchMock, "https://selected-private-host.invalid/api/embed", { + input: "search_query: hello", + headers: { + "Content-Type": "application/json", + "x-proxy-auth": "synthetic-remote-header", + Authorization: "Bearer synthetic-selected-host-key", + }, + }); + }); + it("does not attach provider apiKey to a different remote embedding host", async () => { const { fetchMock } = await embedTestQuery({ config: createProviderConfig({ @@ -519,6 +742,74 @@ describe("ollama embedding provider", () => { expect(headers?.Authorization).toBeUndefined(); }); + it("does not forward selected-host SecretRefs to a different remote embedding host", async () => { + vi.stubEnv("OLLAMA_API_KEY", "synthetic-cloud-key"); + vi.stubEnv("OLLAMA_SELECTED_HOST_KEY", "synthetic-selected-host-key"); + vi.stubEnv("OLLAMA_SELECTED_PROXY_KEY", "synthetic-selected-proxy-key"); + + const { fetchMock } = await embedTestQuery({ + config: createProviderConfig({ + baseUrl: "https://selected-private-host.invalid/v1", + apiKey: { + source: "env", + provider: "default", + id: "OLLAMA_SELECTED_HOST_KEY", + }, + headers: { + "X-Selected-Host-Auth": { + source: "env", + provider: "default", + id: "OLLAMA_SELECTED_PROXY_KEY", + }, + }, + models: [], + }), + remote: { + baseUrl: "https://remote-embedding-host.invalid", + apiKey: "synthetic-remote-key", + headers: { "X-Remote-Auth": "synthetic-remote-header" }, + }, + }); + + expectEmbeddingFetch(fetchMock, "https://remote-embedding-host.invalid/api/embed", { + input: "search_query: hello", + headers: { + "Content-Type": "application/json", + "X-Remote-Auth": "synthetic-remote-header", + Authorization: "Bearer synthetic-remote-key", + }, + }); + }); + + it("ignores an inactive selected-host SecretRef when remote credentials own another host", async () => { + vi.stubEnv("OLLAMA_API_KEY", "synthetic-cloud-key"); + vi.stubEnv("OLLAMA_MISSING_SELECTED_SECRET", ""); + + const { fetchMock } = await embedTestQuery({ + config: createProviderConfig({ + baseUrl: "https://selected-private-host.invalid/v1", + apiKey: { + source: "env", + provider: "default", + id: "OLLAMA_MISSING_SELECTED_SECRET", + }, + models: [], + }), + remote: { + baseUrl: "https://remote-embedding-host.invalid", + apiKey: "synthetic-remote-key", + }, + }); + + expectEmbeddingFetch(fetchMock, "https://remote-embedding-host.invalid/api/embed", { + input: "search_query: hello", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer synthetic-remote-key", + }, + }); + }); + it("attaches remote apiKey to a remote embedding host", async () => { const { fetchMock } = await embedTestQuery({ remote: { baseUrl: "https://memory.example.com", apiKey: "remote-host-key" }, @@ -624,6 +915,45 @@ describe("ollama embedding provider", () => { expect(otherTenant.runtime?.cacheKeyData).not.toEqual(result.runtime?.cacheKeyData); }); + it("keys memory cache identity by resolved tenant SecretRefs without exposing their values", async () => { + vi.stubEnv("OLLAMA_SELECTED_HOST_KEY", "synthetic-selected-host-key"); + vi.stubEnv("OLLAMA_SELECTED_TENANT", "synthetic-tenant-a"); + const options = { + config: createProviderConfig( + { + api: "ollama", + baseUrl: "https://selected-private-host.invalid", + apiKey: { + source: "env", + provider: "default", + id: "OLLAMA_SELECTED_HOST_KEY", + }, + headers: { + "X-Ollama-Tenant": { + source: "env", + provider: "default", + id: "OLLAMA_SELECTED_TENANT", + }, + }, + models: [], + }, + "ollama-private", + ), + provider: "ollama-private", + model: "qwen3-embedding:4b", + }; + + const firstTenant = await createMemoryEmbeddingProvider(options); + vi.stubEnv("OLLAMA_SELECTED_TENANT", "synthetic-tenant-b"); + const secondTenant = await createMemoryEmbeddingProvider(options); + + expect(firstTenant.runtime?.cacheKeyData).not.toEqual(secondTenant.runtime?.cacheKeyData); + expect(JSON.stringify(firstTenant.runtime?.cacheKeyData)).not.toContain("synthetic-tenant-a"); + expect(JSON.stringify(firstTenant.runtime?.cacheKeyData)).not.toContain( + "synthetic-selected-host-key", + ); + }); + it("preserves configured provider aliases in the memory adapter", async () => { const result = await createMemoryEmbeddingProvider({ config: createProviderConfig( diff --git a/extensions/ollama/src/embedding-provider.ts b/extensions/ollama/src/embedding-provider.ts index 3b09c284187a..095fe28c90b8 100644 --- a/extensions/ollama/src/embedding-provider.ts +++ b/extensions/ollama/src/embedding-provider.ts @@ -12,9 +12,11 @@ import { } from "openclaw/plugin-sdk/provider-http"; import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; import { + coerceSecretRef, hasConfiguredSecretInput, normalizeResolvedSecretInputString, -} from "openclaw/plugin-sdk/secret-input"; + resolveConfiguredSecretInputString, +} from "openclaw/plugin-sdk/secret-input-runtime"; import { formatErrorMessage, ssrfPolicyFromHttpBaseUrlAllowedOrigin, @@ -202,9 +204,12 @@ type OllamaEmbeddingResolvedKeys = { function resolveSourcedOllamaEmbeddingKey(params: { configString: string | undefined; declared: boolean; + resolvedSecretRef?: boolean; }): OllamaEmbeddingSourceResolution { if (params.configString !== undefined) { - if (!isNonSecretApiKeyMarker(params.configString)) { + // Resolved SecretRefs are opaque credentials, even when their values happen + // to match an ambient env marker or the synthetic local-auth placeholder. + if (params.resolvedSecretRef || !isNonSecretApiKeyMarker(params.configString)) { return { apiKey: params.configString }; } if (!isKnownEnvApiKeyMarker(params.configString)) { @@ -213,17 +218,35 @@ function resolveSourcedOllamaEmbeddingKey(params: { const envKey = resolveEnvApiKey("ollama")?.apiKey; return envKey && !isNonSecretApiKeyMarker(envKey) ? { apiKey: envKey } : "opt-out"; } - if (params.declared) { - const envKey = resolveEnvApiKey("ollama")?.apiKey; - return envKey && !isNonSecretApiKeyMarker(envKey) ? { apiKey: envKey } : "opt-out"; - } - return "unset"; + return params.declared ? "opt-out" : "unset"; } -function resolveOllamaEmbeddingResolvedKeys( +async function resolveConfiguredOllamaEmbeddingSecret(params: { + config: OpenClawConfig; + value: unknown; + path: string; +}): Promise { + if (!coerceSecretRef(params.value, params.config.secrets?.defaults)) { + return normalizeOptionalSecretInput(params.value); + } + const resolved = await resolveConfiguredSecretInputString({ + config: params.config, + env: process.env, + value: params.value, + path: params.path, + unresolvedReasonStyle: "detailed", + }); + if (resolved.unresolvedRefReason) { + throw new Error(resolved.unresolvedRefReason); + } + return normalizeOptionalSecretInput(resolved.value); +} + +async function resolveOllamaEmbeddingResolvedKeys( options: OllamaEmbeddingOptions, providerConfig: ReturnType, -): OllamaEmbeddingResolvedKeys { + providerOwnsHost: boolean, +): Promise { const remoteValue = options.remote?.apiKey; const remote = resolveSourcedOllamaEmbeddingKey({ configString: resolveMemorySecretInputString({ @@ -233,10 +256,18 @@ function resolveOllamaEmbeddingResolvedKeys( declared: hasConfiguredSecretInput(remoteValue), }); const providerValue = providerConfig?.config.apiKey; - const provider = resolveSourcedOllamaEmbeddingKey({ - configString: normalizeOptionalSecretInput(providerValue), - declared: hasConfiguredSecretInput(providerValue), - }); + let provider: OllamaEmbeddingSourceResolution = "unset"; + if (remote === "unset" && providerOwnsHost && providerConfig) { + provider = resolveSourcedOllamaEmbeddingKey({ + configString: await resolveConfiguredOllamaEmbeddingSecret({ + config: options.config, + value: providerValue, + path: `models.providers.${providerConfig.providerId}.apiKey`, + }), + declared: hasConfiguredSecretInput(providerValue), + resolvedSecretRef: Boolean(coerceSecretRef(providerValue, options.config.secrets?.defaults)), + }); + } const envKey = resolveEnvApiKey("ollama")?.apiKey; const env = envKey && !isNonSecretApiKeyMarker(envKey) ? envKey : undefined; return { remote, provider, env }; @@ -285,17 +316,12 @@ function isOllamaCloudBaseUrl(baseUrl: string): boolean { function selectOllamaEmbeddingApiKey(params: { resolved: OllamaEmbeddingResolvedKeys; baseUrl: string; - baseUrlOrigin: OllamaEmbeddingBaseUrlOrigin; - providerOwnedHost: string; + providerOwnsHost: boolean; }): string | undefined { if (params.resolved.remote !== "unset") { return typeof params.resolved.remote === "object" ? params.resolved.remote.apiKey : undefined; } - const reachesProviderHost = - params.baseUrlOrigin === "provider-config" || - params.baseUrlOrigin === "default" || - areOllamaHostsEquivalent(params.baseUrl, params.providerOwnedHost); - if (params.resolved.provider !== "unset" && reachesProviderHost) { + if (params.resolved.provider !== "unset" && params.providerOwnsHost) { return typeof params.resolved.provider === "object" ? params.resolved.provider.apiKey : undefined; @@ -306,30 +332,60 @@ function selectOllamaEmbeddingApiKey(params: { return undefined; } -function resolveOllamaEmbeddingClient( +async function resolveOllamaEmbeddingClient( options: OllamaEmbeddingOptions, -): OllamaEmbeddingClientConfig { +): Promise { const providerConfig = resolveConfiguredProvider(options); const { baseUrl, origin: baseUrlOrigin } = resolveOllamaEmbeddingBaseUrl({ remoteBaseUrl: options.remote?.baseUrl, providerConfig, }); const model = normalizeEmbeddingModel(options.model, options.provider); - const headerOverrides = Object.assign( - {}, - providerConfig?.config.headers, - options.remote?.headers, + const providerOwnedHost = resolveOllamaApiBase(readProviderBaseUrl(providerConfig?.config)); + // Provider keys and headers belong to this origin only; a remote override + // must neither resolve nor inherit another host's configured credentials. + const providerOwnsHost = + baseUrlOrigin !== "remote-config" || areOllamaHostsEquivalent(baseUrl, providerOwnedHost); + const remoteHeaderNames = new Set( + Object.keys(options.remote?.headers ?? {}).map((headerName) => headerName.toLowerCase()), ); + const headerOverrides: Record = {}; + if (providerOwnsHost && providerConfig?.config.headers) { + for (const [headerName, headerValue] of Object.entries(providerConfig.config.headers)) { + if (remoteHeaderNames.has(headerName.toLowerCase())) { + continue; + } + const resolvedValue = await resolveConfiguredOllamaEmbeddingSecret({ + config: options.config, + value: headerValue, + path: `models.providers.${providerConfig.providerId}.headers.${headerName}`, + }); + if (resolvedValue) { + headerOverrides[headerName] = resolvedValue; + } + } + } + Object.assign(headerOverrides, options.remote?.headers); const headers: Record = { "Content-Type": "application/json", ...headerOverrides, }; - const apiKey = selectOllamaEmbeddingApiKey({ - resolved: resolveOllamaEmbeddingResolvedKeys(options, providerConfig), - baseUrl, - baseUrlOrigin, - providerOwnedHost: resolveOllamaApiBase(readProviderBaseUrl(providerConfig?.config)), - }); + // Explicit HTTP auth owns its request; resolving a competing bearer can leak + // another tenant's key or fail on a SecretRef that is already inactive. + const hasAuthorizationHeader = Object.entries(headers).some( + ([name, value]) => name.toLowerCase() === "authorization" && value.trim().length > 0, + ); + const apiKey = hasAuthorizationHeader + ? undefined + : selectOllamaEmbeddingApiKey({ + resolved: await resolveOllamaEmbeddingResolvedKeys( + options, + providerConfig, + providerOwnsHost, + ), + baseUrl, + providerOwnsHost, + }); if (apiKey) { headers.Authorization = `Bearer ${apiKey}`; } @@ -356,7 +412,7 @@ function resolveOllamaEmbeddingClient( export async function createOllamaEmbeddingProvider( options: OllamaEmbeddingOptions, ): Promise<{ provider: OllamaEmbeddingProvider; client: OllamaEmbeddingClient }> { - const client = resolveOllamaEmbeddingClient(options); + const client = await resolveOllamaEmbeddingClient(options); const embedUrl = `${client.baseUrl.replace(/\/$/, "")}/api/embed`; const embedMany = async (input: string | string[], signal?: AbortSignal): Promise => {