fix(ollama): scope embedding credentials to the selected host (#118753)

This commit is contained in:
Peter Steinberger
2026-08-03 10:09:12 -07:00
committed by GitHub
parent 94f431417b
commit d857c8eebc
2 changed files with 436 additions and 50 deletions
+347 -17
View File
@@ -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(
+89 -33
View File
@@ -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<string | undefined> {
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<typeof resolveConfiguredProvider>,
): OllamaEmbeddingResolvedKeys {
providerOwnsHost: boolean,
): Promise<OllamaEmbeddingResolvedKeys> {
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<OllamaEmbeddingClientConfig> {
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<string, string> = {};
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<string, string> = {
"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<number[][]> => {