From 160660e572837cf25ca8ea1ed44d6ce074237784 Mon Sep 17 00:00:00 2001 From: Parth Sareen Date: Wed, 22 Apr 2026 16:34:19 -0700 Subject: [PATCH] launch: use bundled OpenClaw ollama web search (#15757) --- cmd/launch/openclaw.go | 236 +++++++++++++-------------------- cmd/launch/openclaw_test.go | 212 +++++++++++------------------ docs/integrations/openclaw.mdx | 11 +- 3 files changed, 178 insertions(+), 281 deletions(-) diff --git a/cmd/launch/openclaw.go b/cmd/launch/openclaw.go index d5980a532..b86034797 100644 --- a/cmd/launch/openclaw.go +++ b/cmd/launch/openclaw.go @@ -14,8 +14,6 @@ import ( "strings" "time" - "golang.org/x/mod/semver" - "github.com/ollama/ollama/api" "github.com/ollama/ollama/cmd/internal/fileutil" "github.com/ollama/ollama/envconfig" @@ -98,9 +96,7 @@ func (c *Openclaw) Run(model string, args []string) error { patchDeviceScopes() } - if ensureWebSearchPlugin() { - registerWebSearchPlugin() - } + configureOllamaWebSearch() // When extra args are passed through, run exactly what the user asked for // after setup and skip the built-in gateway+TUI convenience flow. @@ -738,89 +734,13 @@ func clearSessionModelOverride(primary string) { _ = os.WriteFile(path, out, 0o600) } -const ( - webSearchNpmPackage = "@ollama/openclaw-web-search" - webSearchMinVersion = "0.2.1" -) - -// ensureWebSearchPlugin installs the openclaw-web-search extension into the -// user-level extensions directory (~/.openclaw/extensions/) if it isn't already -// present, or re-installs if the installed version is older than webSearchMinVersion. -// Returns true if the extension is available. -func ensureWebSearchPlugin() bool { - home, err := os.UserHomeDir() - if err != nil { - return false - } - - pluginDir := filepath.Join(home, ".openclaw", "extensions", "openclaw-web-search") - if webSearchPluginUpToDate(pluginDir) { - return true - } - - npmBin, err := exec.LookPath("npm") - if err != nil { - return false - } - - if err := os.MkdirAll(pluginDir, 0o755); err != nil { - return false - } - - // Download the tarball via `npm pack`, extract it flat into the plugin dir. - pack := exec.Command(npmBin, "pack", webSearchNpmPackage, "--pack-destination", pluginDir) - out, err := pack.Output() - if err != nil { - fmt.Fprintf(os.Stderr, "%s Warning: could not download web search plugin: %v%s\n", ansiYellow, err, ansiReset) - return false - } - - tgzName := strings.TrimSpace(string(out)) - tgzPath := filepath.Join(pluginDir, tgzName) - defer os.Remove(tgzPath) - - tar := exec.Command("tar", "xzf", tgzPath, "--strip-components=1", "-C", pluginDir) - if err := tar.Run(); err != nil { - fmt.Fprintf(os.Stderr, "%s Warning: could not extract web search plugin: %v%s\n", ansiYellow, err, ansiReset) - return false - } - - fmt.Fprintf(os.Stderr, "%s ✓ Installed Ollama web search %s\n", ansiGreen, ansiReset) - return true -} - -// webSearchPluginUpToDate returns true if the plugin is installed and its -// package.json version is >= webSearchMinVersion. -func webSearchPluginUpToDate(pluginDir string) bool { - data, err := os.ReadFile(filepath.Join(pluginDir, "package.json")) - if err != nil { - return false - } - var pkg struct { - Version string `json:"version"` - } - if json.Unmarshal(data, &pkg) != nil || pkg.Version == "" { - return false - } - return !versionLessThan(pkg.Version, webSearchMinVersion) -} - -// versionLessThan compares two semver version strings (major.minor.patch). -// Inputs may omit the "v" prefix; it is added automatically for semver.Compare. -func versionLessThan(a, b string) bool { - if !strings.HasPrefix(a, "v") { - a = "v" + a - } - if !strings.HasPrefix(b, "v") { - b = "v" + b - } - return semver.Compare(a, b) < 0 -} - -// registerWebSearchPlugin adds plugins.entries.openclaw-web-search to the OpenClaw -// config so the gateway activates it on next start. Best-effort; silently returns -// on any error. -func registerWebSearchPlugin() { +// configureOllamaWebSearch keeps launch-managed OpenClaw installs on the +// bundled Ollama web_search provider. Older launch builds installed an +// external openclaw-web-search plugin that added custom ollama_web_search and +// ollama_web_fetch tools. Current OpenClaw versions ship Ollama web_search as +// the bundled "ollama" plugin instead, so we migrate stale config and ensure +// fresh installs select the bundled provider. +func configureOllamaWebSearch() { home, err := os.UserHomeDir() if err != nil { return @@ -835,6 +755,8 @@ func registerWebSearchPlugin() { return } + stalePluginConfigured := false + plugins, _ := config["plugins"].(map[string]any) if plugins == nil { plugins = make(map[string]any) @@ -843,68 +765,100 @@ func registerWebSearchPlugin() { if entries == nil { entries = make(map[string]any) } - entries["openclaw-web-search"] = map[string]any{"enabled": true} - plugins["entries"] = entries - - // Pin trust so the gateway doesn't warn about untracked plugins. - allow, _ := plugins["allow"].([]any) - hasAllow := false - for _, v := range allow { - if s, ok := v.(string); ok && s == "openclaw-web-search" { - hasAllow = true - break - } - } - if !hasAllow { - allow = append(allow, "openclaw-web-search") - } - plugins["allow"] = allow - - // Record install provenance so the loader can verify the plugin origin. - installs, _ := plugins["installs"].(map[string]any) - if installs == nil { - installs = make(map[string]any) - } - pluginDir := filepath.Join(home, ".openclaw", "extensions", "openclaw-web-search") - installs["openclaw-web-search"] = map[string]any{ - "source": "npm", - "spec": webSearchNpmPackage, - "installPath": pluginDir, - } - plugins["installs"] = installs - - config["plugins"] = plugins - - // Add plugin tools to tools.alsoAllow so they survive the coding profile's - // policy pipeline (which has an explicit allow list of core tools only). tools, _ := config["tools"].(map[string]any) if tools == nil { tools = make(map[string]any) } - - alsoAllow, _ := tools["alsoAllow"].([]any) - needed := []string{"ollama_web_search", "ollama_web_fetch"} - have := make(map[string]bool, len(alsoAllow)) - for _, v := range alsoAllow { - if s, ok := v.(string); ok { - have[s] = true - } - } - for _, name := range needed { - if !have[name] { - alsoAllow = append(alsoAllow, name) - } - } - tools["alsoAllow"] = alsoAllow - - // Disable built-in web search/fetch since our plugin replaces them. web, _ := tools["web"].(map[string]any) if web == nil { web = make(map[string]any) } - web["search"] = map[string]any{"enabled": false} - web["fetch"] = map[string]any{"enabled": false} + search, _ := web["search"].(map[string]any) + if search == nil { + search = make(map[string]any) + } + fetch, _ := web["fetch"].(map[string]any) + if fetch == nil { + fetch = make(map[string]any) + } + + alsoAllow, _ := tools["alsoAllow"].([]any) + var filteredAlsoAllow []any + for _, v := range alsoAllow { + s, ok := v.(string) + if !ok { + filteredAlsoAllow = append(filteredAlsoAllow, v) + continue + } + if s == "ollama_web_search" || s == "ollama_web_fetch" { + stalePluginConfigured = true + continue + } + filteredAlsoAllow = append(filteredAlsoAllow, v) + } + if len(filteredAlsoAllow) > 0 { + tools["alsoAllow"] = filteredAlsoAllow + } else { + delete(tools, "alsoAllow") + } + + if _, ok := entries["openclaw-web-search"]; ok { + delete(entries, "openclaw-web-search") + stalePluginConfigured = true + } + ollamaEntry, _ := entries["ollama"].(map[string]any) + if ollamaEntry == nil { + ollamaEntry = make(map[string]any) + } + ollamaEntry["enabled"] = true + entries["ollama"] = ollamaEntry + plugins["entries"] = entries + + if allow, ok := plugins["allow"].([]any); ok { + var nextAllow []any + hasOllama := false + for _, v := range allow { + s, ok := v.(string) + if ok && s == "openclaw-web-search" { + stalePluginConfigured = true + continue + } + if ok && s == "ollama" { + hasOllama = true + } + nextAllow = append(nextAllow, v) + } + if !hasOllama { + nextAllow = append(nextAllow, "ollama") + } + plugins["allow"] = nextAllow + } + + if installs, ok := plugins["installs"].(map[string]any); ok { + if _, exists := installs["openclaw-web-search"]; exists { + delete(installs, "openclaw-web-search") + stalePluginConfigured = true + } + if len(installs) > 0 { + plugins["installs"] = installs + } else { + delete(plugins, "installs") + } + } + + if stalePluginConfigured || search["provider"] == nil { + search["provider"] = "ollama" + } + if stalePluginConfigured { + fetch["enabled"] = true + } + search["enabled"] = true + web["search"] = search + if len(fetch) > 0 { + web["fetch"] = fetch + } tools["web"] = web + config["plugins"] = plugins config["tools"] = tools out, err := json.MarshalIndent(config, "", " ") diff --git a/cmd/launch/openclaw_test.go b/cmd/launch/openclaw_test.go index 434496bae..b25c2e618 100644 --- a/cmd/launch/openclaw_test.go +++ b/cmd/launch/openclaw_test.go @@ -2242,95 +2242,7 @@ func TestIntegrationOnboarded(t *testing.T) { }) } -func TestVersionLessThan(t *testing.T) { - tests := []struct { - a, b string - want bool - }{ - {"0.1.7", "0.2.1", true}, - {"0.2.0", "0.2.1", true}, - {"0.2.1", "0.2.1", false}, - {"0.2.2", "0.2.1", false}, - {"1.0.0", "0.2.1", false}, - {"0.2.1", "1.0.0", true}, - {"v0.1.7", "0.2.1", true}, - {"0.2.1", "v0.2.1", false}, - } - for _, tt := range tests { - t.Run(tt.a+"_vs_"+tt.b, func(t *testing.T) { - if got := versionLessThan(tt.a, tt.b); got != tt.want { - t.Errorf("versionLessThan(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want) - } - }) - } -} - -func TestWebSearchPluginUpToDate(t *testing.T) { - t.Run("missing directory", func(t *testing.T) { - if webSearchPluginUpToDate(filepath.Join(t.TempDir(), "nonexistent")) { - t.Error("expected false for missing directory") - } - }) - - t.Run("missing package.json", func(t *testing.T) { - dir := t.TempDir() - if webSearchPluginUpToDate(dir) { - t.Error("expected false for missing package.json") - } - }) - - t.Run("old version", func(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"version":"0.1.7"}`), 0o644); err != nil { - t.Fatal(err) - } - if webSearchPluginUpToDate(dir) { - t.Error("expected false for old version 0.1.7") - } - }) - - t.Run("exact minimum version", func(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"version":"0.2.1"}`), 0o644); err != nil { - t.Fatal(err) - } - if !webSearchPluginUpToDate(dir) { - t.Error("expected true for exact minimum version 0.2.1") - } - }) - - t.Run("newer version", func(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644); err != nil { - t.Fatal(err) - } - if !webSearchPluginUpToDate(dir) { - t.Error("expected true for newer version 1.0.0") - } - }) - - t.Run("invalid json", func(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`not json`), 0o644); err != nil { - t.Fatal(err) - } - if webSearchPluginUpToDate(dir) { - t.Error("expected false for invalid json") - } - }) - - t.Run("empty version", func(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"version":""}`), 0o644); err != nil { - t.Fatal(err) - } - if webSearchPluginUpToDate(dir) { - t.Error("expected false for empty version") - } - }) -} - -func TestRegisterWebSearchPlugin(t *testing.T) { +func TestConfigureOllamaWebSearch(t *testing.T) { home := t.TempDir() setTestHome(t, home) @@ -2345,7 +2257,7 @@ func TestRegisterWebSearchPlugin(t *testing.T) { t.Fatal(err) } - registerWebSearchPlugin() + configureOllamaWebSearch() data, err := os.ReadFile(configPath) if err != nil { @@ -2361,40 +2273,30 @@ func TestRegisterWebSearchPlugin(t *testing.T) { t.Fatal("plugins section missing") } - // Check entries entries, _ := plugins["entries"].(map[string]any) - entry, _ := entries["openclaw-web-search"].(map[string]any) + entry, _ := entries["ollama"].(map[string]any) if enabled, _ := entry["enabled"].(bool); !enabled { - t.Error("expected entries.openclaw-web-search.enabled = true") + t.Error("expected entries.ollama.enabled = true") + } + if _, ok := entries["openclaw-web-search"]; ok { + t.Error("expected stale openclaw-web-search entry to be absent") } - // Check allow list - allow, _ := plugins["allow"].([]any) - found := false - for _, v := range allow { - if s, ok := v.(string); ok && s == "openclaw-web-search" { - found = true - } + if _, ok := plugins["allow"]; ok { + t.Error("did not expect plugins.allow to be created when no allowlist exists") } - if !found { - t.Error("expected plugins.allow to contain openclaw-web-search") + if _, ok := plugins["installs"]; ok { + t.Error("did not expect plugins.installs to be created") } - // Check install provenance - installs, _ := plugins["installs"].(map[string]any) - record, _ := installs["openclaw-web-search"].(map[string]any) - if record == nil { - t.Fatal("expected plugins.installs.openclaw-web-search") + tools, _ := config["tools"].(map[string]any) + web, _ := tools["web"].(map[string]any) + search, _ := web["search"].(map[string]any) + if got, _ := search["provider"].(string); got != "ollama" { + t.Errorf("search provider = %q, want %q", got, "ollama") } - if source, _ := record["source"].(string); source != "npm" { - t.Errorf("install source = %q, want %q", source, "npm") - } - if spec, _ := record["spec"].(string); spec != webSearchNpmPackage { - t.Errorf("install spec = %q, want %q", spec, webSearchNpmPackage) - } - expectedPath := filepath.Join(home, ".openclaw", "extensions", "openclaw-web-search") - if installPath, _ := record["installPath"].(string); installPath != expectedPath { - t.Errorf("installPath = %q, want %q", installPath, expectedPath) + if enabled, _ := search["enabled"].(bool); !enabled { + t.Error("expected tools.web.search.enabled = true") } }) @@ -2403,8 +2305,8 @@ func TestRegisterWebSearchPlugin(t *testing.T) { t.Fatal(err) } - registerWebSearchPlugin() - registerWebSearchPlugin() + configureOllamaWebSearch() + configureOllamaWebSearch() data, err := os.ReadFile(configPath) if err != nil { @@ -2416,30 +2318,39 @@ func TestRegisterWebSearchPlugin(t *testing.T) { } plugins, _ := config["plugins"].(map[string]any) - allow, _ := plugins["allow"].([]any) - count := 0 - for _, v := range allow { - if s, ok := v.(string); ok && s == "openclaw-web-search" { - count++ - } + entries, _ := plugins["entries"].(map[string]any) + if len(entries) != 1 { + t.Fatalf("expected only bundled ollama entry, got %v", entries) } - if count != 1 { - t.Errorf("expected exactly 1 openclaw-web-search in allow, got %d", count) + if _, ok := entries["ollama"]; !ok { + t.Fatalf("expected entries.ollama to exist, got %v", entries) } }) - t.Run("preserves existing config", func(t *testing.T) { + t.Run("migrates stale plugin config and preserves unrelated settings", func(t *testing.T) { initial := map[string]any{ "plugins": map[string]any{ - "allow": []any{"some-other-plugin"}, + "allow": []any{"some-other-plugin", "openclaw-web-search"}, "entries": map[string]any{ - "some-other-plugin": map[string]any{"enabled": true}, + "some-other-plugin": map[string]any{"enabled": true}, + "openclaw-web-search": map[string]any{"enabled": true}, }, "installs": map[string]any{ "some-other-plugin": map[string]any{ "source": "npm", "installPath": "/some/path", }, + "openclaw-web-search": map[string]any{ + "source": "npm", + "installPath": "/old/path", + }, + }, + }, + "tools": map[string]any{ + "alsoAllow": []any{"ollama_web_search", "ollama_web_fetch", "browser"}, + "web": map[string]any{ + "search": map[string]any{"enabled": false}, + "fetch": map[string]any{"enabled": false}, }, }, "customField": "preserved", @@ -2449,7 +2360,7 @@ func TestRegisterWebSearchPlugin(t *testing.T) { t.Fatal(err) } - registerWebSearchPlugin() + configureOllamaWebSearch() out, err := os.ReadFile(configPath) if err != nil { @@ -2469,28 +2380,61 @@ func TestRegisterWebSearchPlugin(t *testing.T) { if entries["some-other-plugin"] == nil { t.Error("existing plugin entry was lost") } + if entries["openclaw-web-search"] != nil { + t.Error("stale openclaw-web-search entry should be removed") + } + if ollamaEntry, _ := entries["ollama"].(map[string]any); ollamaEntry == nil { + t.Fatal("expected bundled ollama entry to be enabled") + } installs, _ := plugins["installs"].(map[string]any) if installs["some-other-plugin"] == nil { t.Error("existing install record was lost") } + if installs["openclaw-web-search"] != nil { + t.Error("stale openclaw-web-search install record should be removed") + } allow, _ := plugins["allow"].([]any) - hasOther, hasWebSearch := false, false + hasOther, hasStalePlugin, hasOllama := false, false, false for _, v := range allow { s, _ := v.(string) if s == "some-other-plugin" { hasOther = true } if s == "openclaw-web-search" { - hasWebSearch = true + hasStalePlugin = true + } + if s == "ollama" { + hasOllama = true } } if !hasOther { t.Error("existing allow entry was lost") } - if !hasWebSearch { - t.Error("openclaw-web-search not added to allow") + if hasStalePlugin { + t.Error("stale openclaw-web-search allow entry should be removed") + } + if !hasOllama { + t.Error("expected plugins.allow to contain bundled ollama plugin") + } + + tools, _ := config["tools"].(map[string]any) + alsoAllow, _ := tools["alsoAllow"].([]any) + if len(alsoAllow) != 1 || alsoAllow[0] != "browser" { + t.Errorf("expected stale custom web tools to be removed, got %v", alsoAllow) + } + web, _ := tools["web"].(map[string]any) + search, _ := web["search"].(map[string]any) + fetch, _ := web["fetch"].(map[string]any) + if got, _ := search["provider"].(string); got != "ollama" { + t.Errorf("search provider = %q, want %q", got, "ollama") + } + if enabled, _ := search["enabled"].(bool); !enabled { + t.Error("expected migrated tools.web.search.enabled = true") + } + if enabled, _ := fetch["enabled"].(bool); !enabled { + t.Error("expected migrated tools.web.fetch.enabled = true") } }) } diff --git a/docs/integrations/openclaw.mdx b/docs/integrations/openclaw.mdx index 10df4a1c1..ff5f91e26 100644 --- a/docs/integrations/openclaw.mdx +++ b/docs/integrations/openclaw.mdx @@ -15,7 +15,7 @@ Ollama handles everything automatically: 1. **Install** — If OpenClaw isn't installed, Ollama prompts to install it via npm 2. **Security** — On the first launch, a security notice explains the risks of tool access 3. **Model** — Pick a model from the selector (local or cloud) -4. **Onboarding** — Ollama configures the provider, installs the gateway daemon, sets your model as the primary, and installs the web search and fetch plugin +4. **Onboarding** — Ollama configures the provider, installs the gateway daemon, sets your model as the primary, and enables OpenClaw's bundled Ollama web search 5. **Gateway** — Starts in the background and opens the OpenClaw TUI OpenClaw requires a larger context window. It is recommended to use a context window of at least 64k tokens if using local models. See [Context length](/context-length) for more information. @@ -24,19 +24,19 @@ Ollama handles everything automatically: ## Web search and fetch -OpenClaw ships with a web search and fetch plugin that gives local or cloud models the ability to search the web and extract readable page content. +OpenClaw ships with a bundled Ollama `web_search` provider that lets local or cloud-backed Ollama setups search the web through the configured Ollama host. ```bash ollama launch openclaw ``` -Web search and fetch is enabled automatically when launching OpenClaw through Ollama. To install the plugin directly: +Ollama web search is enabled automatically when launching OpenClaw through Ollama. To configure it manually: ```bash -openclaw plugins install @ollama/openclaw-web-search +openclaw configure --section web ``` -Web search for local models requires `ollama signin`. +Ollama web search for local models requires `ollama signin`. ## Configure without launching @@ -93,4 +93,3 @@ Link WhatsApp, Telegram, Slack, Discord, or iMessage to chat with your local mod ```bash openclaw gateway stop ``` -