diff --git a/cloud/tests/test_websearch_mcp_env.py b/cloud/tests/test_websearch_mcp_env.py new file mode 100644 index 0000000..4f8d083 --- /dev/null +++ b/cloud/tests/test_websearch_mcp_env.py @@ -0,0 +1,71 @@ +"""ensure_websearch_mcp_env: MCP env lives in config.toml, because +Codex's MCP subprocess does not inherit the Codex process env +(2026-10-30: Codex bash had SEARXNG_URL + proxy, MCP chain was +still ["duckduckgo"] only; adding the config env section made +the same task return real SearXNG results).""" + +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) +from cloud.websearch import ensure_websearch_mcp_env # noqa: E402 + + +BASE = ("model = \"gpt-6.1-sol\"\n\n" + "[mcp_servers.websearch]\n" + "command = \"python3\"\n" + "args = [\"/var/lib/codex-app-server/codex-home/" + "websearch_mcp.py\"]\n") + + +class TestMcpEnv(unittest.TestCase): + def _home(self, text): + tmp = tempfile.mkdtemp() + with open(os.path.join(tmp, "config.toml"), "w") as fh: + fh.write(text) + return tmp + + def _read(self, home): + with open(os.path.join(home, "config.toml")) as fh: + return fh.read() + + def test_insert_env_after_websearch_section(self): + home = self._home(BASE) + self.assertTrue(ensure_websearch_mcp_env( + home, {"SEARXNG_URL": "http://searx.test", + "http_proxy": "http://10.0.0.1:8888"})) + text = self._read(home) + self.assertIn("[mcp_servers.websearch.env]", text) + self.assertIn('SEARXNG_URL = "http://searx.test"', text) + self.assertIn('http_proxy = "http://10.0.0.1:8888"', text) + self.assertIn('command = "python3"', text) + + def test_replace_existing_env_preserves_other_sections(self): + home = self._home(BASE + "[mcp_servers.websearch.env]\n" + "SEARXNG_URL = \"http://old.test\"\n\n" + "[other]\nfoo = 1\n") + self.assertTrue(ensure_websearch_mcp_env( + home, {"SEARXNG_URL": "http://new.test"})) + text = self._read(home) + self.assertIn('SEARXNG_URL = "http://new.test"', text) + self.assertNotIn("old.test", text) + self.assertIn("[other]\nfoo = 1", text) + + def test_empty_env_writes_nothing(self): + home = self._home(BASE) + self.assertFalse(ensure_websearch_mcp_env(home, {})) + self.assertEqual(self._read(home), BASE) + + def test_idempotent_second_write(self): + home = self._home(BASE) + env = {"SEARXNG_URL": "http://searx.test"} + self.assertTrue(ensure_websearch_mcp_env(home, env)) + self.assertFalse(ensure_websearch_mcp_env(home, env)) + self.assertEqual(self._read(home).count( + "[mcp_servers.websearch.env]"), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/cloud/websearch.py b/cloud/websearch.py index 528598e..2adc38c 100644 --- a/cloud/websearch.py +++ b/cloud/websearch.py @@ -647,3 +647,79 @@ def ensure_codex_home_websearch(codex_home_dir, command_args=None, "config_changed": config_changed, "script_changed": script_changed, "script_differs": script_differs} + + +def ensure_websearch_mcp_env(codex_home_dir, env): + """Write [mcp_servers.websearch.env] into a tenant config.toml. + + Why this exists (measured 2026-10-30, not guessed): Codex's + MCP server subprocess does NOT inherit the Codex process env. + A Docker task whose Codex bash env contained SEARXNG_URL and + http_proxy still ran web_search with chain ["duckduckgo"] + only, failing instantly with "Temporary failure in name + resolution" -- SEARXNG_URL never reached the MCP process, and + without a proxy the container has no direct DNS. Adding an + explicit ``env`` table to the MCP config made the same task + return real SearXNG results. Container env (docker -e) is + therefore NOT sufficient for MCP tools; the env must live in + config.toml, where Codex actually reads it for MCP servers. + + Idempotent: an existing env subsection is replaced in place, + other sections are left untouched, and a missing websearch + section is created first (command/args only, no env values + invented). Values are written as TOML basic strings via + json.dumps (safe for URLs and proxy URLs). Returns True when + the file was written, False when env is empty or the file + could not be written (caller logs, never crashes the task). + """ + if not env: + return False + home = os.path.abspath(codex_home_dir) + config_path = os.path.join(home, "config.toml") + try: + existing = "" + if os.path.exists(config_path): + with open(config_path, encoding="utf-8") as fh: + existing = fh.read() + if "[mcp_servers.websearch]" not in existing: + existing += ("\n[mcp_servers.websearch]\n" + "command = \"python3\"\n" + "args = [\"/var/lib/codex-app-server/" + "codex-home/websearch_mcp.py\"]\n") + env_lines = ["[mcp_servers.websearch.env]"] + for key in sorted(env): + env_lines.append("%s = %s" % (key, json.dumps(env[key]))) + env_block = "\n".join(env_lines) + "\n" + lines = existing.splitlines(keepends=True) + start = end = None + for i, line in enumerate(lines): + if line.strip() == "[mcp_servers.websearch.env]": + start = i + elif start is not None and line.startswith("["): + end = i + break + if start is not None: + if end is None: + end = len(lines) + lines[start:end] = [env_block] + new_text = "".join(lines) + else: + insert_at = None + in_section = False + for i, line in enumerate(lines): + if line.strip() == "[mcp_servers.websearch]": + in_section = True + elif in_section and line.startswith("["): + insert_at = i + break + if insert_at is None: + insert_at = len(lines) + lines.insert(insert_at, env_block) + new_text = "".join(lines) + if new_text == existing: + return False + with open(config_path, "w", encoding="utf-8") as fh: + fh.write(new_text) + return True + except Exception: + return False diff --git a/cloud/worker.py b/cloud/worker.py index 1e3854b..9fc8fd5 100644 --- a/cloud/worker.py +++ b/cloud/worker.py @@ -166,6 +166,7 @@ from cloud.peer_messages import peer_message_prefix as _peer_prefix from cloud.peer_messages import row_get as _row_get from cloud.sub2api import ensure_user_gateway_key as _ensure_sub2api_key from cloud.websearch import ensure_codex_home_websearch as _ensure_websearch_mcp +from cloud.websearch import ensure_websearch_mcp_env as _ensure_websearch_mcp_env from cloud.skill_seed import ensure_codex_home_skills as _ensure_skills from cloud.sub2api import gateway_name as _gateway_name from cloud.sub2api import gateway_origin as _gateway_origin @@ -2823,6 +2824,22 @@ class Worker: } if search_env: proxy_env = _merge_env(proxy_env, search_env) + # MCP env must live in config.toml, not just docker -e: + # Codex's MCP subprocess does not inherit the Codex + # process env (2026-10-30实测:Codex bash 里有 + # SEARXNG_URL + proxy,MCP 链却只有 duckduckgo, + # DNS 瞬间失败;给 config 加 [mcp_servers.websearch.env] + # 后同一任务立刻返回真实 SearXNG 结果)。网关在此时 + # 才已知,所以这里、容器启动前写租户 config,绝不 + # 在播种时写死一个网关。写失败只记日志,不阻塞任务。 + if self.tenant_isolation and proxy_env: + try: + home = self._tenant_dirs( + task.get("project_id"))["codex_home"] + _ensure_websearch_mcp_env(home, proxy_env) + except Exception as exc: + self.logger.warning( + "websearch MCP env write failed: %s" % exc) return (True, tenant_mounts, tenant_network, proxy_env, None) def _run_fork(self, task, quota_check=None):