Repository: ollama/ollama
Pull request: launch: fix web search, add web fetch, and enable both for local

Some recent OpenClaw changes were not compatible with some of the launch config changes we had. Updating those to enable both in OpenClaw again.

Changed files:
filename: cmd/launch/openclaw.go
patch: @@ -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,20 +619,24 @@ 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 {
 		return false
 	}
 
 	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
 

filename: cmd/launch/openclaw_test.go
patch: @@ -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")
+		}
+	})
+}