From bcf6d55b54b9792083e785b5cfaf6ad8eb9f8dca Mon Sep 17 00:00:00 2001 From: Parth Sareen Date: Mon, 16 Mar 2026 16:26:19 -0700 Subject: [PATCH] launch: fix web search, add web fetch, and enable both for local (#14886) --- cmd/launch/openclaw.go | 72 +++++++++++++++++++++++++----- cmd/launch/openclaw_test.go | 88 +++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 12 deletions(-) diff --git a/cmd/launch/openclaw.go b/cmd/launch/openclaw.go index 9d6812c8a..03c884985 100644 --- a/cmd/launch/openclaw.go +++ b/cmd/launch/openclaw.go @@ -14,6 +14,8 @@ 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" @@ -90,10 +92,8 @@ func (c *Openclaw) Run(model string, args []string) error { patchDeviceScopes() } - if strings.HasSuffix(model, ":cloud") || strings.HasSuffix(model, "-cloud") { - if ensureWebSearchPlugin() { - registerWebSearchPlugin() - } + if ensureWebSearchPlugin() { + registerWebSearchPlugin() } fmt.Fprintf(os.Stderr, "\n%sStarting your assistant — this may take a moment...%s\n\n", ansiGray, ansiReset) @@ -619,11 +619,15 @@ func clearSessionModelOverride(primary string) { _ = os.WriteFile(path, out, 0o600) } -const webSearchNpmPackage = "@ollama/openclaw-web-search" +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. Returns true if the extension is available. +// 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 { @@ -631,8 +635,8 @@ func ensureWebSearchPlugin() bool { } pluginDir := filepath.Join(home, ".openclaw", "extensions", "openclaw-web-search") - if _, err := os.Stat(filepath.Join(pluginDir, "index.ts")); err == nil { - return true // already installed + if webSearchPluginUpToDate(pluginDir) { + return true } npmBin, err := exec.LookPath("npm") @@ -666,6 +670,34 @@ func ensureWebSearchPlugin() bool { 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. @@ -692,23 +724,39 @@ func registerWebSearchPlugin() { if entries == nil { entries = make(map[string]any) } - if _, ok := entries["openclaw-web-search"]; ok { - return // already registered - } entries["openclaw-web-search"] = map[string]any{"enabled": true} plugins["entries"] = entries config["plugins"] = plugins - // Disable the built-in web search since our plugin replaces it. + // 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} tools["web"] = web config["tools"] = tools diff --git a/cmd/launch/openclaw_test.go b/cmd/launch/openclaw_test.go index 37b3d5e04..87aac72e3 100644 --- a/cmd/launch/openclaw_test.go +++ b/cmd/launch/openclaw_test.go @@ -1515,3 +1515,91 @@ 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") + } + }) +}