diff --git a/codex-rs/local/cli/src/lib.rs b/codex-rs/local/cli/src/lib.rs index 63f18d5e1f93..19d210439bec 100644 --- a/codex-rs/local/cli/src/lib.rs +++ b/codex-rs/local/cli/src/lib.rs @@ -195,6 +195,10 @@ mod tests { .parse() .unwrap(); assert_eq!(cfg["mcp_servers"]["a"]["command"].as_str(), Some("x")); + assert_eq!( + cfg["mcp_servers"]["web-search"]["command"].as_str(), + Some("uvx") + ); assert_eq!( cfg["projects"]["/p"]["trust_level"].as_str(), Some("trusted") diff --git a/codex-rs/local/cli/src/overrides.rs b/codex-rs/local/cli/src/overrides.rs index 6f6f7b9ffef8..0b24c513a678 100644 --- a/codex-rs/local/cli/src/overrides.rs +++ b/codex-rs/local/cli/src/overrides.rs @@ -73,6 +73,8 @@ pub fn apply_overrides(args: Vec, env: EnvFn) -> Result> push_c(format!( "model_providers.{PROVIDER_ID}.stream_max_retries={MAX_RETRIES}" )); + // Ollama fails the built-in server-side web_search tool; also covers old installs. + push_c("web_search=\"disabled\"".to_string()); // `CODEX_LOCAL_MAX_AGENTS` beats the seeded `[agents] max_threads` in config.toml. if let Some(raw) = env("CODEX_LOCAL_MAX_AGENTS") { match raw.trim().parse::() { @@ -104,6 +106,7 @@ mod tests { const RQ: &str = "model_providers.codex-local.request_max_retries=2"; const ST: &str = "model_providers.codex-local.stream_max_retries=2"; + const WS: &str = "web_search=\"disabled\""; const URL_KEY: &str = "model_providers.codex-local.base_url"; #[test] @@ -114,6 +117,8 @@ mod tests { "model_providers.codex-local.request_max_retries=2", "-c", "model_providers.codex-local.stream_max_retries=2", + "-c", + "web_search=\"disabled\"", "exec", "hi", ]; @@ -124,12 +129,12 @@ mod tests { fn max_agents_env_valid_invalid_unset() { let k = "CODEX_LOCAL_MAX_AGENTS"; let ma = "agents.max_threads=8"; - assert_eq!(run(&["x", "exec"], &[(k, "8")])[6], ma); - assert_eq!(run(&["x", "exec"], &[(k, "8")]).len(), 8); + assert_eq!(run(&["x", "exec"], &[(k, "8")])[8], ma); + assert_eq!(run(&["x", "exec"], &[(k, "8")]).len(), 10); for bad in ["0", "-1", "abc", ""] { - assert_eq!(run(&["x", "exec"], &[(k, bad)]).len(), 6); + assert_eq!(run(&["x", "exec"], &[(k, bad)]).len(), 8); } - assert_eq!(run(&["x", "exec"], &[]).len(), 6); + assert_eq!(run(&["x", "exec"], &[]).len(), 8); } #[test] @@ -138,7 +143,18 @@ mod tests { let want = |u: &str| format!("{URL_KEY}=\"{u}/v1\""); assert_eq!( run(&["x", "--ollama-url", "http://f:3", "exec"], &env), - ["x", "-c", &want("http://f:3"), "-c", RQ, "-c", ST, "exec"] + [ + "x", + "-c", + &want("http://f:3"), + "-c", + RQ, + "-c", + ST, + "-c", + WS, + "exec" + ] ); assert_eq!(run(&["x", "exec"], &env)[2], want("http://h1:1")); assert_eq!( @@ -159,6 +175,8 @@ mod tests { "model_providers.codex-local.request_max_retries=2", "-c", "model_providers.codex-local.stream_max_retries=2", + "-c", + "web_search=\"disabled\"", "-m", "flagm", "exec" @@ -172,6 +190,8 @@ mod tests { "model_providers.codex-local.request_max_retries=2", "-c", "model_providers.codex-local.stream_max_retries=2", + "-c", + "web_search=\"disabled\"", "exec", "--", "--ollama-url", diff --git a/codex-rs/local/cli/src/seed.rs b/codex-rs/local/cli/src/seed.rs index 98f52075b0f7..9e7b690a8e01 100644 --- a/codex-rs/local/cli/src/seed.rs +++ b/codex-rs/local/cli/src/seed.rs @@ -9,6 +9,10 @@ use crate::DEFAULT_MODEL; use crate::Homes; use crate::PROVIDER_ID; +pub(crate) const WEB_SEARCH_ID: &str = "web-search"; +/// Pinned DuckDuckGo MCP server (tools: search, fetch_content); needs no API key. +pub(crate) const WEB_SEARCH_PKG: &str = "duckduckgo-mcp-server==0.7.0"; + fn table(entries: impl IntoIterator) -> Value { Value::Table( entries @@ -18,6 +22,17 @@ fn table(entries: impl IntoIterator) -> Value { ) } +/// The `[mcp_servers.web-search]` entry. Both tools are read-only, so approve them +/// (otherwise `approval_policy = never` runs reject every call). +pub(crate) fn web_search_server() -> Value { + let s = |v: &str| Value::String(v.to_string()); + table([ + ("command", s("uvx")), + ("args", Value::Array(vec![s(WEB_SEARCH_PKG)])), + ("default_tools_approval_mode", s("approve")), + ]) +} + pub(crate) fn seed_toml(root: &str, stock_home: &str) -> Result { let s = |v: &str| Value::String(v.to_string()); let provider = table([ @@ -45,6 +60,12 @@ pub(crate) fn seed_toml(root: &str, stock_home: &str) -> Result { ("plugins", Value::Boolean(false)), ]), ); + // Ollama can't run the server-side `web_search` tool; use the local MCP server. + t.insert("web_search".into(), s("disabled")); + t.insert( + "mcp_servers".into(), + table([(WEB_SEARCH_ID, web_search_server())]), + ); t.insert("agents".into(), table([("max_threads", Value::Integer(6))])); t.insert( "shell_environment_policy".into(), @@ -71,5 +92,10 @@ mod tests { let body = super::seed_toml("http://127.0.0.1:11434", "/h").unwrap(); let v: toml::Value = toml::from_str(&body).unwrap(); assert_eq!(v["agents"]["max_threads"].as_integer(), Some(6)); + assert_eq!(v["web_search"].as_str(), Some("disabled")); + let ws = &v["mcp_servers"]["web-search"]; + assert_eq!(ws["command"].as_str(), Some("uvx")); + assert_eq!(ws["default_tools_approval_mode"].as_str(), Some("approve")); + assert_eq!(ws["args"][0].as_str(), Some(super::WEB_SEARCH_PKG)); } } diff --git a/codex-rs/local/cli/src/sync.rs b/codex-rs/local/cli/src/sync.rs index a053353351ea..7c0839b1472b 100644 --- a/codex-rs/local/cli/src/sync.rs +++ b/codex-rs/local/cli/src/sync.rs @@ -17,6 +17,11 @@ pub fn sync_config(h: &Homes) -> Result<()> { .parse() .with_context(|| format!("parse {}", local_path.display()))?; let mut copied = Vec::new(); + // codex-local's own web-search server must survive the stock copy. + let own_search = local + .get("mcp_servers") + .and_then(|m| m.get(crate::seed::WEB_SEARCH_ID)) + .cloned(); if let Ok(text) = fs::read_to_string(h.stock.join("config.toml")) { let stock: DocumentMut = text.parse().context("parse stock config.toml")?; for key in ["mcp_servers", "projects"] { @@ -26,6 +31,18 @@ pub fn sync_config(h: &Homes) -> Result<()> { } } } + // Keep the existing entry (user edits win); add the default if an older home lacks it. + let own_search = own_search.unwrap_or_else(|| { + let doc: DocumentMut = toml::to_string(&crate::seed::web_search_server()) + .unwrap_or_default() + .parse() + .unwrap_or_default(); + toml_edit::Item::Table(doc.as_table().clone()) + }); + if local.get("mcp_servers").is_none() { + local["mcp_servers"] = toml_edit::table(); + } + local["mcp_servers"][crate::seed::WEB_SEARCH_ID] = own_search; fs::write(&local_path, local.to_string()) .with_context(|| format!("write {}", local_path.display()))?; refresh_skills(h)?; diff --git a/local/docs/plan-deltas.md b/local/docs/plan-deltas.md index 40f0ab8e06fe..6226b94bf6a9 100644 --- a/local/docs/plan-deltas.md +++ b/local/docs/plan-deltas.md @@ -13,3 +13,4 @@ Where the build differs from the approved plan. - **Models catalog:** `codex-local-models` builds its manager with `.with_provider_catalog()`, so Ollama's catalog is authoritative and no bundled gpt-* models are merged in; `ensure_loaded` lazily fetches because the initial list is empty. - **Agent threads:** seeded `[agents] max_threads` is now 6 (existing configs keep 4, never rewritten); override with `CODEX_LOCAL_MAX_AGENTS=` (positive int, beats config.toml, invalid warns and is ignored) or the `agents.max_threads` config key. - **UPSTREAM_SYNC_TOKEN:** the upstream-sync workflow needs this secret (PAT or App token with workflows scope) to push and open the PR whenever the merge touches `.github/workflows/**`; without it that run fails early, otherwise it falls back to GITHUB_TOKEN. +- **Local web search:** Ollama fails the built-in server-side `web_search` tool, so config seeds `web_search = "disabled"` (also forced via `-c` on every run) and `[mcp_servers.web-search]` runs `uvx duckduckgo-mcp-server==0.7.0` (search + fetch_content, no key); `sync-config` keeps that entry; install/verify warn if `uvx` is missing. diff --git a/local/scripts/_lib.py b/local/scripts/_lib.py index e6e9a417a1b1..e7a88e0410af 100644 --- a/local/scripts/_lib.py +++ b/local/scripts/_lib.py @@ -18,6 +18,16 @@ DEFAULT_OLLAMA_URL = "http://localhost:11434" +WEB_SEARCH_RUNNER = "uvx" +WEB_SEARCH_HINT = ("web search needs `uvx` (not found on PATH); install uv: " + "curl -LsSf https://astral.sh/uv/install.sh | sh " + "(Windows: powershell -c \"irm https://astral.sh/uv/install.ps1 | iex\")") + + +def web_search_runner_missing() -> bool: + return shutil.which(WEB_SEARCH_RUNNER) is None + + def make_output_safe() -> None: """Windows consoles default to cp1252; never let a stray glyph kill the run. diff --git a/local/scripts/install.py b/local/scripts/install.py index cb3bcfd5445a..2608266b0751 100644 --- a/local/scripts/install.py +++ b/local/scripts/install.py @@ -365,6 +365,9 @@ def main(argv=None) -> int: " WARN: stock `codex` not found on PATH; codex-local works without it, " "but cross-resume needs it (npm i -g @openai/codex)") + if _lib.web_search_runner_missing(): + print(f" WARN: {_lib.WEB_SEARCH_HINT}") + print(f"==> Release ({a.repo}, asset {name})") if a.release_dir: d = Path(a.release_dir) diff --git a/local/scripts/verify_install.py b/local/scripts/verify_install.py index c9e24cc272a7..c6072d95b91c 100644 --- a/local/scripts/verify_install.py +++ b/local/scripts/verify_install.py @@ -169,6 +169,8 @@ def full(rep, exe, sums) -> None: else: rc, sver, raw = _lib.probe_version(sexe) (rep.ok if rc == 0 and sver else rep.fail)("stock codex", f"{sexe} -> {raw[:80]}") + if _lib.web_search_runner_missing(): + rep.warn("web search", _lib.WEB_SEARCH_HINT) cfg = stock / "config.toml" if cfg.is_file(): try: diff --git a/local/tests/test_install.py b/local/tests/test_install.py index 911929de2515..53802fd79b23 100644 --- a/local/tests/test_install.py +++ b/local/tests/test_install.py @@ -28,6 +28,15 @@ def releases(): ] +class WebSearchRunner(unittest.TestCase): + def test_missing_runner_detected(self): + from unittest import mock + with mock.patch.object(_lib.shutil, "which", return_value=None): + self.assertTrue(_lib.web_search_runner_missing()) + with mock.patch.object(_lib.shutil, "which", return_value="/x/uvx"): + self.assertFalse(_lib.web_search_runner_missing()) + + class Selection(unittest.TestCase): def test_newest_local_ignores_rust_and_drafts(self): self.assertEqual(install.select_release(releases())["tag_name"], "local-v0.2.0")