diff --git a/CHANGELOG.md b/CHANGELOG.md index 66b2e39dab0..3b91d35f507 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai - Context Engine: gracefully fall back to the legacy engine when a third-party context engine plugin fails at resolution time (unregistered id, factory throw, or contract violation), preventing a full gateway outage on every channel. (#66930) Thanks @openperf. - Control UI/chat: keep optimistic user message cards visible during active sends by deferring same-session history reloads until the active run ends, including aborted and errored runs. (#66997) Thanks @scotthuang and @vincentkoc. - Media/Slack: allow host-local CSV and Markdown uploads only when the fallback buffer actually decodes as text, so real plain-text files work without letting opaque non-text blobs renamed to `.csv` or `.md` slip past the host-read guard. (#67047) Thanks @Unayung. +- Ollama/onboarding: split setup into `Cloud + Local`, `Cloud only`, and `Local only`, support direct `OLLAMA_API_KEY` cloud setup without a local daemon, and keep Ollama web search on the local-host path. (#67005) Thanks @obviyus. ## 2026.4.14 diff --git a/docs/providers/ollama.md b/docs/providers/ollama.md index ed392b1745a..8b49d2ba699 100644 --- a/docs/providers/ollama.md +++ b/docs/providers/ollama.md @@ -8,7 +8,7 @@ title: "Ollama" # Ollama -Ollama is a local LLM runtime that makes it easy to run open-source models on your machine. OpenClaw integrates with Ollama's native API (`/api/chat`), supports streaming and tool calling, and can auto-discover local Ollama models when you opt in with `OLLAMA_API_KEY` (or an auth profile) and do not define an explicit `models.providers.ollama` entry. +OpenClaw integrates with Ollama's native API (`/api/chat`) for hosted cloud models and local/self-hosted Ollama servers. You can use Ollama in three modes: `Cloud + Local` through a reachable Ollama host, `Cloud only` against `https://ollama.com`, or `Local only` against a reachable Ollama host. **Remote Ollama users**: Do not use the `/v1` OpenAI-compatible URL (`http://host:11434/v1`) with OpenClaw. This breaks tool calling and models may output raw tool JSON as plain text. Use the native Ollama API URL instead: `baseUrl: "http://host:11434"` (no `/v1`). @@ -20,7 +20,7 @@ Choose your preferred setup method and mode. - **Best for:** fastest path to a working Ollama setup with automatic model discovery. + **Best for:** fastest path to a working Ollama cloud or local setup. @@ -31,13 +31,12 @@ Choose your preferred setup method and mode. Select **Ollama** from the provider list. - - **Cloud + Local** — cloud-hosted models and local models together - - **Local** — local models only - - If you choose **Cloud + Local** and are not signed in to ollama.com, onboarding opens a browser sign-in flow. + - **Cloud + Local** — local Ollama host plus cloud models routed through that host + - **Cloud only** — hosted Ollama models via `https://ollama.com` + - **Local only** — local models only - Onboarding discovers available models and suggests defaults. It auto-pulls the selected model if it is not available locally. + `Cloud only` prompts for `OLLAMA_API_KEY` and suggests hosted cloud defaults. `Cloud + Local` and `Local only` ask for an Ollama base URL, discover available models, and auto-pull the selected local model if it is not available yet. `Cloud + Local` also checks whether that Ollama host is signed in for cloud access. ```bash @@ -67,13 +66,15 @@ Choose your preferred setup method and mode. - **Best for:** full control over installation, model pulls, and config. + **Best for:** full control over cloud or local setup. - - Download from [ollama.com/download](https://ollama.com/download). + + - **Cloud + Local**: install Ollama, sign in with `ollama signin`, and route cloud requests through that host + - **Cloud only**: use `https://ollama.com` with an `OLLAMA_API_KEY` + - **Local only**: install Ollama from [ollama.com/download](https://ollama.com/download) - + ```bash ollama pull gemma4 # or @@ -82,22 +83,18 @@ Choose your preferred setup method and mode. ollama pull llama3.3 ``` - - If you want cloud models too: - - ```bash - ollama signin - ``` - - Set any value for the API key (Ollama does not require a real key): + For `Cloud only`, use your real `OLLAMA_API_KEY`. For host-backed setups, any placeholder value works: ```bash - # Set environment variable + # Cloud + export OLLAMA_API_KEY="your-ollama-api-key" + + # Local-only export OLLAMA_API_KEY="ollama-local" # Or configure in your config file - openclaw config set models.providers.ollama.apiKey "ollama-local" + openclaw config set models.providers.ollama.apiKey "OLLAMA_API_KEY" ``` @@ -127,18 +124,23 @@ Choose your preferred setup method and mode. - Cloud models let you run cloud-hosted models alongside your local models. Examples include `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, and `glm-5.1:cloud` -- these do **not** require a local `ollama pull`. + `Cloud + Local` uses a reachable Ollama host as the control point for both local and cloud models. This is Ollama's preferred hybrid flow. - Select **Cloud + Local** mode during setup. The wizard checks whether you are signed in and opens a browser sign-in flow when needed. If authentication cannot be verified, the wizard falls back to local model defaults. + Use **Cloud + Local** during setup. OpenClaw prompts for the Ollama base URL, discovers local models from that host, and checks whether the host is signed in for cloud access with `ollama signin`. When the host is signed in, OpenClaw also suggests hosted cloud defaults such as `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, and `glm-5.1:cloud`. - You can also sign in directly at [ollama.com/signin](https://ollama.com/signin). + If the host is not signed in yet, OpenClaw keeps the setup local-only until you run `ollama signin`. - OpenClaw currently suggests these cloud defaults: `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, `glm-5.1:cloud`. + + + + `Cloud only` runs against Ollama's hosted API at `https://ollama.com`. + + Use **Cloud only** during setup. OpenClaw prompts for `OLLAMA_API_KEY`, sets `baseUrl: "https://ollama.com"`, and seeds the hosted cloud model list. This path does **not** require a local Ollama server or `ollama signin`. - In local-only mode, OpenClaw discovers models from the local Ollama instance. No cloud sign-in is needed. + In local-only mode, OpenClaw discovers models from the configured Ollama instance. This path is for local or self-hosted Ollama servers. OpenClaw currently suggests `gemma4` as the local default. @@ -182,7 +184,7 @@ If you set `models.providers.ollama` explicitly, auto-discovery is skipped and y - The simplest way to enable Ollama is via environment variable: + The simplest local-only enablement path is via environment variable: ```bash export OLLAMA_API_KEY="ollama-local" @@ -195,25 +197,25 @@ If you set `models.providers.ollama` explicitly, auto-discovery is skipped and y - Use explicit config when Ollama runs on another host/port, you want to force specific context windows or model lists, or you want fully manual model definitions. + Use explicit config when you want hosted cloud setup, Ollama runs on another host/port, you want to force specific context windows or model lists, or you want fully manual model definitions. ```json5 { models: { providers: { ollama: { - baseUrl: "http://ollama-host:11434", - apiKey: "ollama-local", + baseUrl: "https://ollama.com", + apiKey: "OLLAMA_API_KEY", api: "ollama", models: [ { - id: "gpt-oss:20b", - name: "GPT-OSS 20B", + id: "kimi-k2.5:cloud", + name: "kimi-k2.5:cloud", reasoning: false, - input: ["text"], + input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 8192, - maxTokens: 8192 * 10 + contextWindow: 128000, + maxTokens: 8192 } ] } diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md index 6546167ecbb..76d5bd61b5b 100644 --- a/docs/reference/wizard.md +++ b/docs/reference/wizard.md @@ -40,7 +40,7 @@ For a high-level overview, see [Onboarding (CLI)](/start/wizard). - Sets `agents.defaults.model` to `openai/gpt-5.4` when model is unset, `openai/*`, or `openai-codex/*`. - **xAI (Grok) API key**: prompts for `XAI_API_KEY` and configures xAI as a model provider. - **OpenCode**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth) and lets you pick the Zen or Go catalog. - - **Ollama**: prompts for the Ollama base URL, offers **Cloud + Local** or **Local** mode, discovers available models, and auto-pulls the selected local model when needed. + - **Ollama**: offers **Cloud + Local**, **Cloud only**, or **Local only** first. `Cloud only` prompts for `OLLAMA_API_KEY` and uses `https://ollama.com`; the host-backed modes prompt for the Ollama base URL, discover available models, and auto-pull the selected local model when needed; `Cloud + Local` also checks whether that Ollama host is signed in for cloud access. - More detail: [Ollama](/providers/ollama) - **API key**: stores the key for you. - **Vercel AI Gateway (multi-model proxy)**: prompts for `AI_GATEWAY_API_KEY`. diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index 70acd7f0086..14a8d20f4b4 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -181,8 +181,10 @@ What you set: More detail: [Synthetic](/providers/synthetic). - Prompts for base URL (default `http://127.0.0.1:11434`), then offers Cloud + Local or Local mode. - Discovers available models and suggests defaults. + Prompts for `Cloud + Local`, `Cloud only`, or `Local only` first. + `Cloud only` uses `OLLAMA_API_KEY` with `https://ollama.com`. + The host-backed modes prompt for base URL (default `http://127.0.0.1:11434`), discover available models, and suggest defaults. + `Cloud + Local` also checks whether that Ollama host is signed in for cloud access. More detail: [Ollama](/providers/ollama). diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index 7555cc1b930..866e60bb503 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -4,6 +4,7 @@ import plugin from "./index.js"; const promptAndConfigureOllamaMock = vi.hoisted(() => vi.fn(async () => ({ + credential: "ollama-local", config: { models: { providers: { @@ -81,9 +82,11 @@ describe("ollama plugin", () => { expect(promptAndConfigureOllamaMock).toHaveBeenCalledWith({ cfg: {}, + env: undefined, + opts: undefined, prompter: {}, - isRemote: false, - openUrl: expect.any(Function), + secretInputMode: undefined, + allowSecretRefPrompt: undefined, }); expect(result.configPatch).toEqual({ models: { diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 775d2532abe..40ace5c55b6 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -6,6 +6,7 @@ import { type ProviderAuthResult, type ProviderDiscoveryContext, } from "openclaw/plugin-sdk/plugin-entry"; +import { buildApiKeyCredential } from "openclaw/plugin-sdk/provider-auth"; import { OPENAI_COMPATIBLE_REPLAY_HOOKS, type ModelProviderConfig, @@ -57,9 +58,7 @@ function shouldSkipAmbientOllamaDiscovery(env: NodeJS.ProcessEnv): boolean { return Boolean(env.VITEST) || env.NODE_ENV === "test"; } -function hasMeaningfulExplicitOllamaConfig( - providerConfig: OllamaProviderLikeConfig | undefined, -): boolean { +function hasMeaningfulExplicitOllamaConfig(providerConfig?: OllamaProviderLikeConfig): boolean { if (!providerConfig) { return false; } @@ -116,19 +115,27 @@ export default definePluginEntry({ run: async (ctx: ProviderAuthContext): Promise => { const result = await promptAndConfigureOllama({ cfg: ctx.config, + env: ctx.env, + opts: ctx.opts as Record | undefined, prompter: ctx.prompter, - isRemote: ctx.isRemote, - openUrl: ctx.openUrl, + secretInputMode: ctx.secretInputMode, + allowSecretRefPrompt: ctx.allowSecretRefPrompt, }); return { profiles: [ { profileId: "ollama:default", - credential: { - type: "api_key", - provider: PROVIDER_ID, - key: DEFAULT_API_KEY, - }, + credential: buildApiKeyCredential( + PROVIDER_ID, + result.credential, + undefined, + result.credentialMode + ? { + secretInputMode: result.credentialMode, + config: ctx.config, + } + : undefined, + ), }, ], configPatch: result.config, diff --git a/extensions/ollama/src/defaults.ts b/extensions/ollama/src/defaults.ts index 5ac438bdea7..ca913216072 100644 --- a/extensions/ollama/src/defaults.ts +++ b/extensions/ollama/src/defaults.ts @@ -1,4 +1,5 @@ export const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434"; +export const OLLAMA_CLOUD_BASE_URL = "https://ollama.com"; export const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000; export const OLLAMA_DEFAULT_MAX_TOKENS = 8192; diff --git a/extensions/ollama/src/provider-models.ssrf.test.ts b/extensions/ollama/src/provider-models.ssrf.test.ts index 159be7f6e33..6f64e6e62ba 100644 --- a/extensions/ollama/src/provider-models.ssrf.test.ts +++ b/extensions/ollama/src/provider-models.ssrf.test.ts @@ -4,12 +4,31 @@ import { buildOllamaBaseUrlSsrFPolicy } from "./provider-models.js"; describe("buildOllamaBaseUrlSsrFPolicy", () => { it("pins requests to the configured Ollama hostname for HTTP(S) URLs", () => { expect(buildOllamaBaseUrlSsrFPolicy("http://127.0.0.1:11434")).toEqual({ - allowedHostnames: ["127.0.0.1"], hostnameAllowlist: ["127.0.0.1"], + allowPrivateNetwork: true, + }); + expect(buildOllamaBaseUrlSsrFPolicy("http://192.168.1.10:11434")).toEqual({ + hostnameAllowlist: ["192.168.1.10"], + allowPrivateNetwork: true, }); expect(buildOllamaBaseUrlSsrFPolicy("https://ollama.example.com/v1")).toEqual({ - allowedHostnames: ["ollama.example.com"], hostnameAllowlist: ["ollama.example.com"], + allowPrivateNetwork: true, + }); + }); + + it("opts into private-network access for explicit Ollama hosts", () => { + expect(buildOllamaBaseUrlSsrFPolicy("http://localhost:11434")).toEqual({ + hostnameAllowlist: ["localhost"], + allowPrivateNetwork: true, + }); + expect(buildOllamaBaseUrlSsrFPolicy("http://[fd00::1]:11434")).toEqual({ + hostnameAllowlist: ["[fd00::1]"], + allowPrivateNetwork: true, + }); + expect(buildOllamaBaseUrlSsrFPolicy("https://ollama.local:11434")).toEqual({ + hostnameAllowlist: ["ollama.local"], + allowPrivateNetwork: true, }); }); @@ -17,5 +36,6 @@ describe("buildOllamaBaseUrlSsrFPolicy", () => { expect(buildOllamaBaseUrlSsrFPolicy("")).toBeUndefined(); expect(buildOllamaBaseUrlSsrFPolicy("ftp://ollama.example.com")).toBeUndefined(); expect(buildOllamaBaseUrlSsrFPolicy("not-a-url")).toBeUndefined(); + expect(buildOllamaBaseUrlSsrFPolicy("http://metadata.google.internal")).toBeUndefined(); }); }); diff --git a/extensions/ollama/src/provider-models.ts b/extensions/ollama/src/provider-models.ts index e7482273f59..6d86e62108d 100644 --- a/extensions/ollama/src/provider-models.ts +++ b/extensions/ollama/src/provider-models.ts @@ -31,6 +31,7 @@ export type OllamaModelWithContext = OllamaTagModel & { const OLLAMA_SHOW_CONCURRENCY = 8; const MAX_OLLAMA_SHOW_CACHE_ENTRIES = 256; const ollamaModelShowInfoCache = new Map>(); +const OLLAMA_ALWAYS_BLOCKED_HOSTNAMES = new Set(["metadata.google.internal"]); export function buildOllamaBaseUrlSsrFPolicy(baseUrl: string) { const trimmed = baseUrl.trim(); @@ -42,9 +43,12 @@ export function buildOllamaBaseUrlSsrFPolicy(baseUrl: string) { if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return undefined; } + if (OLLAMA_ALWAYS_BLOCKED_HOSTNAMES.has(parsed.hostname)) { + return undefined; + } return { - allowedHostnames: [parsed.hostname], hostnameAllowlist: [parsed.hostname], + allowPrivateNetwork: true, }; } catch { return undefined; diff --git a/extensions/ollama/src/setup.test.ts b/extensions/ollama/src/setup.test.ts index eed31d6773b..2805e00d639 100644 --- a/extensions/ollama/src/setup.test.ts +++ b/extensions/ollama/src/setup.test.ts @@ -2,6 +2,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import type { WizardPrompter } from "openclaw/plugin-sdk/setup"; import { afterEach, describe, expect, it, vi } from "vitest"; import { jsonResponse, requestBodyText, requestUrl } from "../../../src/test-helpers/http.js"; +import { resetOllamaModelShowInfoCacheForTest } from "./provider-models.js"; import { configureOllamaNonInteractive, ensureOllamaModelPulled, @@ -9,18 +10,21 @@ import { } from "./setup.js"; const upsertAuthProfileWithLock = vi.hoisted(() => vi.fn(async () => {})); -vi.mock("../../../src/agents/auth-profiles.js", () => ({ - upsertAuthProfileWithLock, -})); +vi.mock("openclaw/plugin-sdk/provider-auth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + upsertAuthProfileWithLock, + }; +}); function createOllamaFetchMock(params: { tags?: string[]; show?: Record; - meResponses?: Response[]; pullResponse?: Response; tagsError?: Error; + meResponse?: Response; }) { - const meResponses = [...(params.meResponses ?? [])]; return vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = requestUrl(input); if (url.endsWith("/api/tags")) { @@ -37,7 +41,7 @@ function createOllamaFetchMock(params: { : jsonResponse({}); } if (url.endsWith("/api/me")) { - return meResponses.shift() ?? jsonResponse({ username: "testuser" }); + return params.meResponse ?? jsonResponse({}); } if (url.endsWith("/api/pull")) { return params.pullResponse ?? new Response('{"status":"success"}\n', { status: 200 }); @@ -46,28 +50,29 @@ function createOllamaFetchMock(params: { }); } -function createModePrompter( - mode: "local" | "remote", - params?: { confirm?: boolean }, -): WizardPrompter { +function createLocalPrompter(): WizardPrompter { return { + select: vi.fn().mockResolvedValueOnce("local-only"), text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"), - select: vi.fn().mockResolvedValueOnce(mode), - ...(params?.confirm !== undefined - ? { confirm: vi.fn().mockResolvedValueOnce(params.confirm) } - : {}), note: vi.fn(async () => undefined), } as unknown as WizardPrompter; } -function createSignedOutRemoteFetchMock() { - return createOllamaFetchMock({ - tags: ["llama3:8b"], - meResponses: [ - jsonResponse({ error: "not signed in", signin_url: "https://ollama.com/signin" }, 401), - jsonResponse({ username: "testuser" }), - ], - }); +function createCloudPrompter(): WizardPrompter { + return { + select: vi.fn().mockResolvedValueOnce("cloud-only"), + confirm: vi.fn().mockResolvedValueOnce(false), + text: vi.fn().mockResolvedValueOnce("test-ollama-key"), + note: vi.fn(async () => undefined), + } as unknown as WizardPrompter; +} + +function createCloudLocalPrompter(): WizardPrompter { + return { + select: vi.fn().mockResolvedValueOnce("cloud-local"), + text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"), + note: vi.fn(async () => undefined), + } as unknown as WizardPrompter; } function createDefaultOllamaConfig(primary: string) { @@ -89,10 +94,11 @@ describe("ollama setup", () => { afterEach(() => { vi.unstubAllGlobals(); upsertAuthProfileWithLock.mockClear(); + resetOllamaModelShowInfoCacheForTest(); }); it("puts suggested local model first in local mode", async () => { - const prompter = createModePrompter("local"); + const prompter = createLocalPrompter(); const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] }); vi.stubGlobal("fetch", fetchMock); @@ -100,33 +106,73 @@ describe("ollama setup", () => { const result = await promptAndConfigureOllama({ cfg: {}, prompter, - isRemote: false, - openUrl: vi.fn(async () => undefined), }); const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id); expect(modelIds?.[0]).toBe("gemma4"); }); - it("puts suggested cloud model first in remote mode", async () => { - const prompter = createModePrompter("remote"); + it("puts suggested cloud model first in cloud mode", async () => { + const prompter = createCloudPrompter(); + const result = await promptAndConfigureOllama({ + cfg: {}, + env: {}, + prompter, + allowSecretRefPrompt: false, + }); + const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id); - const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] }); + expect(modelIds?.[0]).toBe("kimi-k2.5:cloud"); + expect(result.config.models?.providers?.ollama?.baseUrl).toBe("https://ollama.com"); + expect(result.config.models?.providers?.ollama?.apiKey).toBe("test-ollama-key"); + expect(result.credential).toBe("test-ollama-key"); + }); + + it("uses generic token flags for cloud-only setup", async () => { + const prompter = createCloudPrompter(); + + const result = await promptAndConfigureOllama({ + cfg: {}, + env: {}, + opts: { + token: "generic-ollama-key", + tokenProvider: "ollama", + }, + prompter, + allowSecretRefPrompt: false, + }); + + expect(result.credential).toBe("generic-ollama-key"); + expect(prompter.text).not.toHaveBeenCalled(); + }); + + it("puts hybrid cloud model suggestions after the local default when signed in", async () => { + const prompter = createCloudLocalPrompter(); + const fetchMock = createOllamaFetchMock({ + tags: ["llama3:8b"], + meResponse: jsonResponse({ user: "signed-in" }), + }); vi.stubGlobal("fetch", fetchMock); const result = await promptAndConfigureOllama({ cfg: {}, prompter, - isRemote: false, - openUrl: vi.fn(async () => undefined), }); const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id); - expect(modelIds?.[0]).toBe("kimi-k2.5:cloud"); + expect(modelIds).toEqual([ + "gemma4", + "kimi-k2.5:cloud", + "minimax-m2.7:cloud", + "glm-5.1:cloud", + "llama3:8b", + ]); + expect(result.config.models?.providers?.ollama?.baseUrl).toBe("http://127.0.0.1:11434"); + expect(result.credential).toBe("ollama-local"); }); it("mode selection affects model ordering (local)", async () => { - const prompter = createModePrompter("local"); + const prompter = createLocalPrompter(); const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b", "gemma4"] }); vi.stubGlobal("fetch", fetchMock); @@ -134,8 +180,6 @@ describe("ollama setup", () => { const result = await promptAndConfigureOllama({ cfg: {}, prompter, - isRemote: false, - openUrl: vi.fn(async () => undefined), }); const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id); @@ -143,31 +187,39 @@ describe("ollama setup", () => { expect(modelIds).toContain("llama3:8b"); }); - it("cloud+local mode triggers /api/me check and opens sign-in URL", async () => { - const prompter = createModePrompter("remote", { confirm: true }); - const fetchMock = createSignedOutRemoteFetchMock(); - const openUrl = vi.fn(async () => undefined); + it("cloud mode does not hit local Ollama endpoints", async () => { + const prompter = createCloudPrompter(); + const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); - await promptAndConfigureOllama({ cfg: {}, prompter, isRemote: false, openUrl }); + await promptAndConfigureOllama({ + cfg: {}, + env: {}, + prompter, + allowSecretRefPrompt: false, + }); - expect(openUrl).toHaveBeenCalledWith("https://ollama.com/signin"); - expect(prompter.confirm).toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); }); - it("cloud+local mode does not open browser in remote environment", async () => { - const prompter = createModePrompter("remote", { confirm: true }); - const fetchMock = createSignedOutRemoteFetchMock(); - const openUrl = vi.fn(async () => undefined); - vi.stubGlobal("fetch", fetchMock); + it("rejects the local marker during cloud-only setup", async () => { + const prompter = createCloudPrompter(); - await promptAndConfigureOllama({ cfg: {}, prompter, isRemote: true, openUrl }); - - expect(openUrl).not.toHaveBeenCalled(); + await expect( + promptAndConfigureOllama({ + cfg: {}, + env: {}, + opts: { + ollamaApiKey: "ollama-local", + }, + prompter, + allowSecretRefPrompt: false, + }), + ).rejects.toThrow("Cloud-only Ollama setup requires a real OLLAMA_API_KEY."); }); - it("local mode does not trigger cloud auth", async () => { - const prompter = createModePrompter("local"); + it("local mode only hits local model discovery endpoints", async () => { + const prompter = createLocalPrompter(); const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] }); vi.stubGlobal("fetch", fetchMock); @@ -175,8 +227,6 @@ describe("ollama setup", () => { await promptAndConfigureOllama({ cfg: {}, prompter, - isRemote: false, - openUrl: vi.fn(async () => undefined), }); expect(fetchMock).toHaveBeenCalledTimes(2); @@ -186,35 +236,97 @@ describe("ollama setup", () => { ); }); - it("suggested models appear first in model list (cloud+local)", async () => { + it("asks for Ollama mode before cloud api key", async () => { + const events: string[] = []; const prompter = { - text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"), - select: vi.fn().mockResolvedValueOnce("remote"), + select: vi.fn(async () => { + events.push("select"); + return "cloud-only"; + }), + confirm: vi.fn(async () => false), + text: vi.fn(async () => { + events.push("text"); + return "test-ollama-key"; + }), note: vi.fn(async () => undefined), } as unknown as WizardPrompter; + await promptAndConfigureOllama({ + cfg: {}, + env: {}, + prompter, + allowSecretRefPrompt: false, + }); + + expect(events).toEqual(["select", "text"]); + }); + + it("shows cloud-mode unreachable guidance when the host is down", async () => { + const prompter = createLocalPrompter(); + const fetchMock = createOllamaFetchMock({ tagsError: new Error("down") }); + vi.stubGlobal("fetch", fetchMock); + + await expect( + promptAndConfigureOllama({ + cfg: {}, + prompter, + }), + ).rejects.toThrow("Ollama not reachable"); + + expect(prompter.note).toHaveBeenCalledWith( + [ + "Ollama could not be reached at http://127.0.0.1:11434.", + "Download it at https://ollama.com/download", + "", + "Start Ollama and re-run setup.", + ].join("\n"), + "Ollama", + ); + }); + + it("cloud + local mode falls back to local models when ollama signin is missing", async () => { + const prompter = createCloudLocalPrompter(); const fetchMock = createOllamaFetchMock({ - tags: ["llama3:8b", "gemma4", "deepseek-r1:14b"], + tags: ["llama3:8b"], + meResponse: new Response(JSON.stringify({ signin_url: "https://ollama.com/signin" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), }); vi.stubGlobal("fetch", fetchMock); const result = await promptAndConfigureOllama({ cfg: {}, prompter, - isRemote: false, - openUrl: vi.fn(async () => undefined), + }); + + expect(result.config.models?.providers?.ollama?.models?.map((m) => m.id)).toEqual([ + "gemma4", + "llama3:8b", + ]); + expect(prompter.note).toHaveBeenCalledWith( + [ + "Cloud models on this Ollama host need `ollama signin`.", + "https://ollama.com/signin", + "", + "Continuing with local models only for now.", + ].join("\n"), + "Ollama Cloud + Local", + ); + }); + + it("cloud mode seeds the hosted cloud model list", async () => { + const prompter = createCloudPrompter(); + const result = await promptAndConfigureOllama({ + cfg: {}, + env: {}, + prompter, + allowSecretRefPrompt: false, }); const models = result.config.models?.providers?.ollama?.models; const modelIds = models?.map((m) => m.id); - expect(modelIds).toEqual([ - "kimi-k2.5:cloud", - "minimax-m2.7:cloud", - "glm-5.1:cloud", - "llama3:8b", - "gemma4", - "deepseek-r1:14b", - ]); + expect(modelIds).toEqual(["kimi-k2.5:cloud", "minimax-m2.7:cloud", "glm-5.1:cloud"]); expect(models?.find((model) => model.id === "kimi-k2.5:cloud")?.input).toEqual([ "text", "image", @@ -224,7 +336,7 @@ describe("ollama setup", () => { it("uses /api/show context windows when building Ollama model configs", async () => { const prompter = { text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"), - select: vi.fn().mockResolvedValueOnce("local"), + select: vi.fn().mockResolvedValueOnce("local-only"), note: vi.fn(async () => undefined), } as unknown as WizardPrompter; @@ -237,8 +349,6 @@ describe("ollama setup", () => { const result = await promptAndConfigureOllama({ cfg: {}, prompter, - isRemote: false, - openUrl: vi.fn(async () => undefined), }); const model = result.config.models?.providers?.ollama?.models?.find( (m) => m.id === "llama3:8b", diff --git a/extensions/ollama/src/setup.ts b/extensions/ollama/src/setup.ts index d86be70c7ad..eb7c4fd0007 100644 --- a/extensions/ollama/src/setup.ts +++ b/extensions/ollama/src/setup.ts @@ -1,6 +1,17 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth"; -import { upsertAuthProfileWithLock } from "openclaw/plugin-sdk/provider-auth"; +import type { + OpenClawConfig, + SecretInput, + SecretInputMode, +} from "openclaw/plugin-sdk/provider-auth"; +import { + ensureApiKeyFromOptionEnvOrPrompt, + isNonSecretApiKeyMarker, + normalizeApiKeyInput, + normalizeOptionalSecretInput, + upsertAuthProfileWithLock, + validateApiKeyInput, +} from "openclaw/plugin-sdk/provider-auth"; import { applyAgentDefaultModelPrimary } from "openclaw/plugin-sdk/provider-onboard"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; import { WizardCancelledError, type WizardPrompter } from "openclaw/plugin-sdk/setup"; @@ -9,7 +20,11 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "openclaw/plugin-sdk/text-runtime"; -import { OLLAMA_DEFAULT_BASE_URL, OLLAMA_DEFAULT_MODEL } from "./defaults.js"; +import { + OLLAMA_CLOUD_BASE_URL, + OLLAMA_DEFAULT_BASE_URL, + OLLAMA_DEFAULT_MODEL, +} from "./defaults.js"; import { buildOllamaBaseUrlSsrFPolicy, buildOllamaModelDefinition, @@ -23,15 +38,15 @@ const OLLAMA_SUGGESTED_MODELS_LOCAL = [OLLAMA_DEFAULT_MODEL]; const OLLAMA_SUGGESTED_MODELS_CLOUD = ["kimi-k2.5:cloud", "minimax-m2.7:cloud", "glm-5.1:cloud"]; const OLLAMA_CONTEXT_ENRICH_LIMIT = 200; -type OllamaMode = "remote" | "local"; type OllamaSetupOptions = { customBaseUrl?: string; customModelId?: string; }; -type OllamaCloudAuthResult = { - signedIn: boolean; - signinUrl?: string; +type OllamaSetupResult = { + config: OpenClawConfig; + credential: SecretInput; + credentialMode?: SecretInputMode; }; type ProviderConfig = { @@ -40,6 +55,41 @@ type ProviderConfig = { models: ReturnType[]; }; +type OllamaInteractiveMode = "cloud-local" | "cloud-only" | "local-only"; +type HostBackedOllamaInteractiveMode = Exclude; + +const HOST_BACKED_OLLAMA_MODE_CONFIG: Record< + HostBackedOllamaInteractiveMode, + { includeCloudModels: boolean; noteTitle: string } +> = { + "cloud-local": { + includeCloudModels: true, + noteTitle: "Ollama Cloud + Local", + }, + "local-only": { + includeCloudModels: false, + noteTitle: "Ollama", + }, +}; + +function buildOllamaUnreachableLines(baseUrl: string): string[] { + return [ + `Ollama could not be reached at ${baseUrl}.`, + "Download it at https://ollama.com/download", + "", + "Start Ollama and re-run setup.", + ]; +} + +function buildOllamaCloudSigninLines(signinUrl?: string): string[] { + return [ + "Cloud models on this Ollama host need `ollama signin`.", + signinUrl ?? "Run `ollama signin` on the configured Ollama host.", + "", + "Continuing with local models only for now.", + ]; +} + function normalizeOllamaModelName(value: string | undefined): string | undefined { const trimmed = value?.trim(); if (!trimmed) { @@ -68,7 +118,9 @@ function formatOllamaPullStatus(status: string): { text: string; hidePercent: bo return { text: trimmed, hidePercent: false }; } -export async function checkOllamaCloudAuth(baseUrl: string): Promise { +export async function checkOllamaCloudAuth( + baseUrl: string, +): Promise<{ signedIn: boolean; signinUrl?: string }> { try { const apiBase = resolveOllamaApiBase(baseUrl); const { response, release } = await fetchWithSsrFGuard({ @@ -246,6 +298,51 @@ async function pullOllamaModelNonInteractive( return true; } +async function promptForOllamaCloudCredential(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + opts?: Record; + prompter: WizardPrompter; + secretInputMode?: SecretInputMode; + allowSecretRefPrompt?: boolean; +}): Promise<{ credential: SecretInput; credentialMode?: SecretInputMode }> { + const captured: { credential?: SecretInput; credentialMode?: SecretInputMode } = {}; + const optionToken = normalizeOptionalSecretInput(params.opts?.ollamaApiKey); + await ensureApiKeyFromOptionEnvOrPrompt({ + token: optionToken ?? normalizeOptionalSecretInput(params.opts?.token), + tokenProvider: optionToken + ? "ollama" + : normalizeOptionalSecretInput(params.opts?.tokenProvider), + secretInputMode: + params.allowSecretRefPrompt === false + ? (params.secretInputMode ?? "plaintext") + : params.secretInputMode, + config: params.cfg, + env: params.env, + expectedProviders: ["ollama"], + provider: "ollama", + envLabel: "OLLAMA_API_KEY", + promptMessage: "Ollama API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: params.prompter, + setCredential: async (apiKey, mode) => { + captured.credential = apiKey; + captured.credentialMode = mode; + }, + }); + if (!captured.credential) { + throw new Error("Missing Ollama API key input."); + } + if ( + typeof captured.credential === "string" && + isNonSecretApiKeyMarker(captured.credential, { includeEnvVarName: false }) + ) { + throw new Error("Cloud-only Ollama setup requires a real OLLAMA_API_KEY."); + } + return { credential: captured.credential, credentialMode: captured.credentialMode }; +} + function buildOllamaModelsConfig( modelNames: string[], discoveredModelsByName?: Map, @@ -260,11 +357,27 @@ function buildOllamaModelsConfig( }); } +function mergeUniqueModelNames(...groups: string[][]): string[] { + const seen = new Set(); + const merged: string[] = []; + for (const group of groups) { + for (const name of group) { + if (seen.has(name)) { + continue; + } + seen.add(name); + merged.push(name); + } + } + return merged; +} + function applyOllamaProviderConfig( cfg: OpenClawConfig, baseUrl: string, modelNames: string[], discoveredModelsByName?: Map, + apiKey: SecretInput = "OLLAMA_API_KEY", ): OpenClawConfig { return { ...cfg, @@ -276,8 +389,7 @@ function applyOllamaProviderConfig( ollama: { baseUrl, api: "ollama", - // pragma: allowlist secret - apiKey: "OLLAMA_API_KEY", + apiKey, models: buildOllamaModelsConfig(modelNames, discoveredModelsByName), }, }, @@ -293,6 +405,74 @@ async function storeOllamaCredential(agentDir?: string): Promise { }); } +async function promptForOllamaBaseUrl(prompter: WizardPrompter): Promise { + const baseUrlRaw = await prompter.text({ + message: "Ollama base URL", + initialValue: OLLAMA_DEFAULT_BASE_URL, + placeholder: OLLAMA_DEFAULT_BASE_URL, + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + return resolveOllamaApiBase((baseUrlRaw ?? "").trim().replace(/\/+$/, "")); +} + +async function resolveHostBackedSuggestedModelNames(params: { + mode: HostBackedOllamaInteractiveMode; + baseUrl: string; + prompter: WizardPrompter; +}): Promise { + const modeConfig = HOST_BACKED_OLLAMA_MODE_CONFIG[params.mode]; + if (!modeConfig.includeCloudModels) { + return OLLAMA_SUGGESTED_MODELS_LOCAL; + } + + const auth = await checkOllamaCloudAuth(params.baseUrl); + if (auth.signedIn) { + return mergeUniqueModelNames(OLLAMA_SUGGESTED_MODELS_LOCAL, OLLAMA_SUGGESTED_MODELS_CLOUD); + } + + await params.prompter.note( + buildOllamaCloudSigninLines(auth.signinUrl).join("\n"), + modeConfig.noteTitle, + ); + return OLLAMA_SUGGESTED_MODELS_LOCAL; +} + +async function promptAndConfigureHostBackedOllama(params: { + cfg: OpenClawConfig; + mode: HostBackedOllamaInteractiveMode; + prompter: WizardPrompter; +}): Promise { + const baseUrl = await promptForOllamaBaseUrl(params.prompter); + const { reachable, models } = await fetchOllamaModels(baseUrl); + + if (!reachable) { + await params.prompter.note(buildOllamaUnreachableLines(baseUrl).join("\n"), "Ollama"); + throw new WizardCancelledError("Ollama not reachable"); + } + + const enrichedModels = await enrichOllamaModelsWithContext( + baseUrl, + models.slice(0, OLLAMA_CONTEXT_ENRICH_LIMIT), + ); + const discoveredModelsByName = new Map(enrichedModels.map((model) => [model.name, model])); + const discoveredModelNames = models.map((model) => model.name); + const suggestedModelNames = await resolveHostBackedSuggestedModelNames({ + mode: params.mode, + baseUrl, + prompter: params.prompter, + }); + + return { + credential: "ollama-local", + config: applyOllamaProviderConfig( + params.cfg, + baseUrl, + mergeUniqueModelNames(suggestedModelNames, discoveredModelNames), + discoveredModelsByName, + ), + }; +} + export async function buildOllamaProvider( configuredBaseUrl?: string, opts?: { quiet?: boolean }, @@ -317,100 +497,50 @@ export async function buildOllamaProvider( export async function promptAndConfigureOllama(params: { cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + opts?: Record; prompter: WizardPrompter; - isRemote: boolean; - openUrl: (url: string) => Promise; -}): Promise<{ config: OpenClawConfig }> { - const baseUrlRaw = await params.prompter.text({ - message: "Ollama base URL", - initialValue: OLLAMA_DEFAULT_BASE_URL, - placeholder: OLLAMA_DEFAULT_BASE_URL, - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - const baseUrl = resolveOllamaApiBase((baseUrlRaw ?? "").trim().replace(/\/+$/, "")); - const { reachable, models } = await fetchOllamaModels(baseUrl); - - if (!reachable) { - await params.prompter.note( - [ - `Ollama could not be reached at ${baseUrl}.`, - "Download it at https://ollama.com/download", - "", - "Start Ollama and re-run setup.", - ].join("\n"), - "Ollama", - ); - throw new WizardCancelledError("Ollama not reachable"); - } - - const enrichedModels = await enrichOllamaModelsWithContext( - baseUrl, - models.slice(0, OLLAMA_CONTEXT_ENRICH_LIMIT), - ); - const discoveredModelsByName = new Map(enrichedModels.map((model) => [model.name, model])); - const modelNames = models.map((model) => model.name); + secretInputMode?: SecretInputMode; + allowSecretRefPrompt?: boolean; +}): Promise { const mode = (await params.prompter.select({ message: "Ollama mode", options: [ - { value: "remote", label: "Cloud + Local", hint: "Cloud models + local models" }, - { value: "local", label: "Local", hint: "Local models only" }, + { + value: "cloud-local", + label: "Cloud + Local", + hint: "Route cloud and local models through your Ollama host", + }, + { value: "cloud-only", label: "Cloud only", hint: "Hosted Ollama models via ollama.com" }, + { value: "local-only", label: "Local only", hint: "Local models only" }, ], - })) as OllamaMode; - - let cloudAuthVerified = false; - if (mode === "remote") { - const authResult = await checkOllamaCloudAuth(baseUrl); - if (!authResult.signedIn) { - if (authResult.signinUrl) { - if (!params.isRemote) { - await params.openUrl(authResult.signinUrl); - } - await params.prompter.note( - ["Run `ollama signin`:", authResult.signinUrl].join("\n"), - "Ollama Sign-In", - ); - const confirmed = await params.prompter.confirm({ message: "Have you signed in?" }); - if (!confirmed) { - throw new WizardCancelledError("Ollama sign-in cancelled"); - } - if (!(await checkOllamaCloudAuth(baseUrl)).signedIn) { - throw new WizardCancelledError("Ollama sign-in required"); - } - cloudAuthVerified = true; - } else { - await params.prompter.note( - [ - "Could not verify `ollama signin`.", - "Cloud models may not work until you sign in at https://ollama.com.", - ].join("\n"), - "Ollama Sign-In", - ); - if (!(await params.prompter.confirm({ message: "Continue without sign-in?" }))) { - throw new WizardCancelledError("Ollama sign-in could not be verified"); - } - } - } else { - cloudAuthVerified = true; - } + })) as OllamaInteractiveMode; + if (mode === "cloud-only") { + const { credential, credentialMode } = await promptForOllamaCloudCredential({ + cfg: params.cfg, + env: params.env, + opts: params.opts, + prompter: params.prompter, + secretInputMode: params.secretInputMode, + allowSecretRefPrompt: params.allowSecretRefPrompt, + }); + return { + credential, + credentialMode, + config: applyOllamaProviderConfig( + params.cfg, + OLLAMA_CLOUD_BASE_URL, + OLLAMA_SUGGESTED_MODELS_CLOUD, + undefined, + credential, + ), + }; } - - const suggestedModels = - mode === "local" || !cloudAuthVerified - ? OLLAMA_SUGGESTED_MODELS_LOCAL - : OLLAMA_SUGGESTED_MODELS_CLOUD; - const orderedModelNames = [ - ...suggestedModels, - ...modelNames.filter((name) => !suggestedModels.includes(name)), - ]; - - return { - config: applyOllamaProviderConfig( - params.cfg, - baseUrl, - orderedModelNames, - discoveredModelsByName, - ), - }; + return await promptAndConfigureHostBackedOllama({ + cfg: params.cfg, + mode, + prompter: params.prompter, + }); } export async function configureOllamaNonInteractive(params: { @@ -426,12 +556,7 @@ export async function configureOllamaNonInteractive(params: { const explicitModel = normalizeOllamaModelName(params.opts.customModelId); if (!reachable) { - params.runtime.error( - [ - `Ollama could not be reached at ${baseUrl}.`, - "Download it at https://ollama.com/download", - ].join("\n"), - ); + params.runtime.error(buildOllamaUnreachableLines(baseUrl).slice(0, 2).join("\n")); params.runtime.exit(1); return params.nextConfig; } diff --git a/extensions/ollama/src/web-search-provider.test.ts b/extensions/ollama/src/web-search-provider.test.ts index 86aa5b467de..89ef3ab87fe 100644 --- a/extensions/ollama/src/web-search-provider.test.ts +++ b/extensions/ollama/src/web-search-provider.test.ts @@ -39,6 +39,23 @@ function createOllamaConfig(provider: OllamaProviderConfigOverride = {}): OpenCl }; } +function createOllamaConfigWithWebSearchBaseUrl(baseUrl: string): OpenClawConfig { + return { + ...createOllamaConfig(), + plugins: { + entries: { + ollama: { + config: { + webSearch: { + baseUrl, + }, + }, + }, + }, + }, + }; +} + function createSetupNotes() { const notes: Array<{ title?: string; message: string }> = []; return { @@ -90,6 +107,24 @@ describe("ollama web search provider", () => { ).toBe("http://ollama.local:11434"); }); + it("prefers the plugin web search base URL over the model provider host", () => { + expect( + testing.resolveOllamaWebSearchBaseUrl( + createOllamaConfigWithWebSearchBaseUrl("http://localhost:11434/v1"), + ), + ).toBe("http://localhost:11434"); + }); + + it("falls back to the local Ollama host when the model provider uses ollama cloud", () => { + expect( + testing.resolveOllamaWebSearchBaseUrl( + createOllamaConfig({ + baseUrl: "https://ollama.com", + }), + ), + ).toBe("http://127.0.0.1:11434"); + }); + it("maps generic search args into the Ollama experimental search endpoint", async () => { const release = vi.fn(async () => {}); fetchWithSsrFGuardMock.mockResolvedValue({ diff --git a/extensions/ollama/src/web-search-provider.ts b/extensions/ollama/src/web-search-provider.ts index 3569a55a8cd..a2872ae4889 100644 --- a/extensions/ollama/src/web-search-provider.ts +++ b/extensions/ollama/src/web-search-provider.ts @@ -10,6 +10,7 @@ import { readNumberParam, readResponseText, readStringParam, + resolveProviderWebSearchPluginConfig, resolveSearchCount, resolveSiteName, truncateText, @@ -18,7 +19,7 @@ import { } from "openclaw/plugin-sdk/provider-web-search"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js"; +import { OLLAMA_CLOUD_BASE_URL, OLLAMA_DEFAULT_BASE_URL } from "./defaults.js"; import { buildOllamaBaseUrlSsrFPolicy, fetchOllamaModels, @@ -64,9 +65,18 @@ function resolveOllamaWebSearchApiKey(config?: OpenClawConfig): string | undefin } function resolveOllamaWebSearchBaseUrl(config?: OpenClawConfig): string { + const pluginBaseUrl = normalizeOptionalString( + resolveProviderWebSearchPluginConfig(config, "ollama")?.baseUrl, + ); + if (pluginBaseUrl) { + return resolveOllamaApiBase(pluginBaseUrl); + } const configuredBaseUrl = config?.models?.providers?.ollama?.baseUrl; if (normalizeOptionalString(configuredBaseUrl)) { - return resolveOllamaApiBase(configuredBaseUrl); + const baseUrl = resolveOllamaApiBase(configuredBaseUrl); + if (baseUrl !== OLLAMA_CLOUD_BASE_URL) { + return baseUrl; + } } return OLLAMA_DEFAULT_BASE_URL; } diff --git a/src/plugins/provider-auth-env-trust.test.ts b/src/plugins/provider-auth-env-trust.test.ts index 2f566bc6ac0..2eea1051ece 100644 --- a/src/plugins/provider-auth-env-trust.test.ts +++ b/src/plugins/provider-auth-env-trust.test.ts @@ -25,6 +25,20 @@ describe("provider auth env trust", () => { }); }); + it("buildApiKeyCredential keeps secret-ref-like input literal in plaintext mode", async () => { + const { buildApiKeyCredential } = await import("./provider-auth-helpers.js"); + + const credential = buildApiKeyCredential("ollama", "${AWS_SECRET_ACCESS_KEY}", undefined, { + secretInputMode: "plaintext", + }); + + expect(credential).toEqual({ + type: "api_key", + provider: "ollama", + key: "${AWS_SECRET_ACCESS_KEY}", + }); + }); + it("resolveRefFallbackInput excludes untrusted workspace plugin env vars", async () => { const { resolveRefFallbackInput } = await import("./provider-auth-ref.js"); const config = { plugins: {} }; diff --git a/src/plugins/provider-auth-helpers.ts b/src/plugins/provider-auth-helpers.ts index 245c32ac99f..8730904a6b9 100644 --- a/src/plugins/provider-auth-helpers.ts +++ b/src/plugins/provider-auth-helpers.ts @@ -63,6 +63,9 @@ function resolveApiKeySecretInput( input: SecretInput, options?: ApiKeyStorageOptions, ): SecretInput { + if (options?.secretInputMode === "plaintext") { + return normalizeSecretInput(input); + } const coercedRef = coerceSecretRef(input); if (coercedRef) { return coercedRef;