websearch: MCP env 写进租户 config,不只靠 docker -e

2026-10-30 实测:Codex bash 里有 SEARXNG_URL + proxy,
MCP 链却只有 duckduckgo、DNS 瞬间失败——Codex 的 MCP
子进程不继承 Codex 进程 env;给租户 config 加
[mcp_servers.websearch.env] 后同一任务立刻返回真实
SearXNG 结果。修法:worker 在 _docker_task_context
算出网关后、容器启动前,用 ensure_websearch_mcp_env
把 proxy + 搜索 env 写进租户 config(网关此时才已知,
播种时写不死;写失败只记日志不阻塞)。
测试:新增 test_websearch_mcp_env 4 项,相关共 33 项全过。
This commit is contained in:
host committed 2026-10-30 01:16:49 +08:00
1 parent 08d56b15ce
commit 71e31b22a0
3 files changed
+164

No files matched your search

+71
View File
@@ -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()
+76
View File
@@ -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
+17
View File
@@ -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):