Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions codex-rs/local/cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
30 changes: 25 additions & 5 deletions codex-rs/local/cli/src/overrides.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ pub fn apply_overrides(args: Vec<OsString>, env: EnvFn) -> Result<Vec<OsString>>
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::<u64>() {
Expand Down Expand Up @@ -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]
Expand All @@ -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",
];
Expand All @@ -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]
Expand All @@ -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!(
Expand All @@ -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"
Expand All @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions codex-rs/local/cli/src/seed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item = (&'static str, Value)>) -> Value {
Value::Table(
entries
Expand All @@ -18,6 +22,17 @@ fn table(entries: impl IntoIterator<Item = (&'static str, Value)>) -> 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<String> {
let s = |v: &str| Value::String(v.to_string());
let provider = table([
Expand Down Expand Up @@ -45,6 +60,12 @@ pub(crate) fn seed_toml(root: &str, stock_home: &str) -> Result<String> {
("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(),
Expand All @@ -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));
}
}
17 changes: 17 additions & 0 deletions codex-rs/local/cli/src/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"] {
Expand All @@ -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)?;
Expand Down
1 change: 1 addition & 0 deletions local/docs/plan-deltas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<n>` (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.
10 changes: 10 additions & 0 deletions local/scripts/_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions local/scripts/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions local/scripts/verify_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions local/tests/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down