Merge pull request #273 from openqj/feat-continue-gapfill

feat: continue gap-fill for search + browser (2a569ac follow-up)
This commit is contained in:
openqj authored and GitHub committed 2026-11-01 17:21:17 +08:00
commit 0602d06136
15 files changed
+1878 -46

No files matched your search

+260 -23
View File
@@ -103,6 +103,23 @@ def _check_allowed_url(url):
"domain" % (host, ", ".join(allowed)))
def _check_navigated_url(url):
"""Post-navigation / click-target check: same allowlist as
_check_allowed_url, but only for http(s) URLs. Anything else
is not a page navigation (file:// is always allowed anyway,
about:blank / data: / mailto: / javascript: never navigate
the page to a host), so it is skipped here instead of
raising a misleading non-http(s) error. Unset allowlist
means no check at all, same as _check_allowed_url."""
if _allowed_hosts() is None:
return
from urllib.parse import urlparse
scheme = (urlparse(str(url)).scheme or "").lower()
if scheme not in ("http", "https"):
return
_check_allowed_url(url)
class Browser:
"""Chromium pages (multi-tab). ``browser_factory`` is
injectable: tests pass a fake returning a fake
@@ -361,15 +378,84 @@ class Browser:
result["truncated"] = truncated
return result
def _click_href(self, selector):
"""Best-effort anchor href for a click target, or None.
Reads the first matching element's href (the same element
page.click would click). Returns the href resolved
against the current page URL, or None when the target has
no href, is not an anchor, or the locator API cannot
answer (fake pages, non-anchor selectors). A None here
does NOT mean allowed: it means the target is unknown
before the click, so only the post-click URL check can
catch it. Fragment-only hrefs resolve to the current
page and are returned as None for the same reason: they
never navigate to another host."""
try:
locator = self._page.locator(str(selector))
except Exception: # noqa: BLE001 - unknown target
return None
target = getattr(locator, "first", locator)
get_attr = getattr(target, "get_attribute", None)
if get_attr is None:
return None
try:
href = get_attr("href")
except Exception: # noqa: BLE001 - unknown target
return None
if not href or not isinstance(href, str):
return None
href = href.strip()
if not href or href.startswith("#"):
return None
from urllib.parse import urljoin
try:
return urljoin(self._current_url or "", href)
except Exception: # noqa: BLE001 - unknown target
return None
def _check_click_target(self, selector):
"""Pre-click allowlist gate for click/click_ref.
When the allowlist is unset this is a no-op, so click
behaviour is byte-identical to before. When set and the
target href resolves to an http(s) URL, the same check
as navigate runs BEFORE the click: a miss raises and
the click is never executed. Unknown targets (no href,
button/JS handler) skip this gate and are caught, if at
all, by _check_navigated_current after the click."""
if _allowed_hosts() is None:
return
target = self._click_href(selector)
if target is not None:
_check_navigated_url(target)
def _check_navigated_current(self):
"""Post-click allowlist detection (not prevention).
The click already happened, so this can only report a
blocked landing host, never undo the navigation. It
exists because some click targets cannot be resolved
before the click (coordinates, JS handlers, forms): for
those, a raised error here is still better than silently
continuing on a blocked host, and the egress proxy 403
is the layer that actually stops the page load when the
allowlist equals the proxy whitelist."""
if _allowed_hosts() is None or not self._current_url:
return
_check_navigated_url(self._current_url)
def click(self, selector):
if not selector or not str(selector).strip():
raise BrowserError("selector is required")
self._ensure()
self._check_click_target(selector)
try:
self._page.click(str(selector), timeout=10000)
self._current_url = self._page.url
except Exception as exc: # noqa: BLE001
raise BrowserError("click failed: %s" % exc)
self._check_navigated_current()
result = self._info()
result["ok"] = True
return result
@@ -499,11 +585,16 @@ class Browser:
"unknown ref %r: run browser_snapshot_refs first" % (ref,))
self._ensure()
path = self._ref_paths[ref]
# The ref path is a CSS path, so the same href gate as
# click applies: an anchor ref to a blocked host is
# refused before the click, never executed.
self._check_click_target(path)
try:
self._page.locator(path).first.click(timeout=10000)
self._current_url = self._page.url
except Exception as exc: # noqa: BLE001
raise BrowserError("click_ref failed: %s" % exc)
self._check_navigated_current()
result = self._info()
result["ok"] = True
result["ref"] = ref
@@ -619,11 +710,21 @@ class Browser:
raise BrowserError(
"click_at out of viewport 1280x800: %s,%s" % (x, y))
self._ensure()
# Deliberately NO pre-click gate here: coordinates do not
# identify an element, so there is no href to check
# before the click. Probing elementFromPoint would be
# viewport/scroll-dependent, would miss JS-driven
# navigations (the common click_at case: buttons, canvas,
# SPA controls), and would run page JS on every user
# click. What click_at does get is the post-click URL
# check below, plus the egress proxy 403 backstop for
# any host outside the proxy whitelist.
try:
self._page.mouse.click(fx, fy)
self._current_url = self._page.url
except Exception as exc: # noqa: BLE001
raise BrowserError("click_at failed: %s" % exc)
self._check_navigated_current()
result = self._info()
result["ok"] = True
result["x"] = fx
@@ -725,49 +826,183 @@ class Browser:
# ------------------------------------------------------- codex-home glue
PLAYWRIGHT_SCRIPT_NAMES = ("playwright_browser.py", "playwright_mcp.py",
"playwright_remote.py")
# SHA-256 of the three MCP scripts as shipped at origin/main
# 2a569ac (PR #272), verified against the git blobs. A tenant
# script matching one of these hashes is PROVEN unmodified
# (it is exactly what a previous seeding wrote), so the safe
# upgrade below may replace it even when no manifest exists
# (legacy tenants seeded before manifests were written).
# A tenant script matching neither a manifest entry nor a hash
# here is treated as tenant-modified and is NEVER overwritten.
KNOWN_SHIPPED_SCRIPT_HASHES = {
"playwright_browser.py":
"0c36a286da3f8733fef4fec2dd18dec003fa68f54385fd4dc8da4b38e7571828",
"playwright_mcp.py":
"8455e67c2eb2ec6eb07bc9645292e2323b587e01d5a75fd8a985bbcd04a11198",
"playwright_remote.py":
"15a8026a02dfed6c630ea1614f75d54764a851df9d977c2380e15d0ed4f8a173",
}
_MANIFEST_NAME = "playwright_scripts_manifest.json"
_AUDIT_NAME = "playwright_scripts_audit.jsonl"
def _sha256_bytes(data):
import hashlib
return hashlib.sha256(data).hexdigest()
def _read_script_manifest(home):
"""name -> sha256 last written by seeding, or {}.
The manifest is this helper's own metadata file in the
tenant codex-home (not tenant data): it records what the
last seeding wrote, so a later upgrade can tell an
untouched script from a tenant-modified one. Missing or
corrupt manifests read as empty -- the caller then falls
back to KNOWN_SHIPPED_SCRIPT_HASHES, never to overwriting
an unknown file."""
try:
with open(os.path.join(home, _MANIFEST_NAME),
encoding="utf-8") as fh:
data = json.load(fh)
except Exception: # noqa: BLE001 - missing/corrupt = no proof
return {}
if not isinstance(data, dict):
return {}
return {k: v for k, v in data.items()
if isinstance(k, str) and isinstance(v, str)}
def _write_script_manifest(home, hashes):
"""Best-effort manifest write; failures never block seeding."""
try:
with open(os.path.join(home, _MANIFEST_NAME), "w",
encoding="utf-8") as fh:
json.dump({k: hashes[k] for k in sorted(hashes)},
fh, ensure_ascii=False, sort_keys=True)
fh.write("\n")
except OSError:
pass
def _audit_script_skipped(home, name, dst_hash, src_hash):
"""Append one audit record for a skipped modified script.
Idempotent: a record with the same script + tenant hash is
never appended twice, so re-running an upgrade does not
grow the log. The audit file is append-only metadata; the
skipped script itself is left byte-identical."""
path = os.path.join(home, _AUDIT_NAME)
try:
with open(path, encoding="utf-8") as fh:
for line in fh:
try:
rec = json.loads(line)
except ValueError:
continue
if (isinstance(rec, dict)
and rec.get("script") == name
and rec.get("dst_sha256") == dst_hash
and rec.get("action") == "skipped_modified"):
return path
except OSError:
pass
record = {"ts": int(time.time()),
"action": "skipped_modified",
"script": name,
"dst_sha256": dst_hash,
"src_sha256": src_hash,
"note": "tenant-modified script left untouched; "
"manual review required before upgrade"}
try:
with open(path, "a", encoding="utf-8") as fh:
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
except OSError:
pass
return path
def ensure_codex_home_playwright(codex_home_dir, command_args=None,
script_dir=None):
"""Seed playwright MCP files + config into a tenant codex-home.
MCP scripts are SYSTEM files, not tenant data: a tenant never
edits them (user files live in the worktree, not codex-home),
so unlike the old never-overwrite rule, a differing script is
REFRESHED to the deployed version and reported in
``script_refreshed``. Without this, every MCP feature ships
dead for existing projects: they keep the first-seeded script
forever and only brand-new projects get the update.
Appends ``[mcp_servers.playwright]`` only when absent. No
secrets live here -- proxy vars arrive via
``ensure_playwright_mcp_env`` from worker proxy_env, and the
browsers path is a fixed image path.
SAFE UPGRADE RULE (changed from the old refresh-anything
rule): a tenant script is auto-merged to the deployed
version ONLY when it is proven unmodified -- its bytes
match the seeding manifest, or match a
KNOWN_SHIPPED_SCRIPT_HASHES entry (legacy tenants), or
the file is missing (fresh seed) or already current. A
script that differs AND matches neither proof is
tenant-modified: it is NEVER overwritten, it is reported
in ``script_skipped_modified`` (alias ``script_differs``),
and one deduped audit record is appended to
``playwright_scripts_audit.jsonl`` for manual review.
The upgrade never deletes a tenant file and never rewrites
existing config semantics: ``[mcp_servers.playwright]``
is appended only when absent, and the manifest/audit files
are this helper's own additive metadata. No secrets live
here -- proxy vars arrive via ``ensure_playwright_mcp_env``
from worker proxy_env, and the browsers path is a fixed
image path.
"""
import shutil
home = os.path.abspath(codex_home_dir)
os.makedirs(home, exist_ok=True)
here = os.path.abspath(script_dir or os.path.dirname(__file__))
manifest = _read_script_manifest(home)
script_changed = False
script_differs = []
script_refreshed = []
for name in ("playwright_browser.py", "playwright_mcp.py",
"playwright_remote.py"):
script_skipped = []
audit_path = os.path.join(home, _AUDIT_NAME)
new_hashes = {}
for name in PLAYWRIGHT_SCRIPT_NAMES:
src = os.path.join(here, name)
dst = os.path.join(home, name)
if not os.path.exists(src):
continue
with open(src, "rb") as fh:
src_bytes = fh.read()
src_hash = _sha256_bytes(src_bytes)
if not os.path.exists(dst):
shutil.copyfile(src, dst)
script_changed = True
new_hashes[name] = src_hash
continue
with open(dst, "rb") as fh:
dst_bytes = fh.read()
dst_hash = _sha256_bytes(dst_bytes)
if dst_bytes == src_bytes:
new_hashes[name] = src_hash
continue
proven_unmodified = (
manifest.get(name) == dst_hash
or KNOWN_SHIPPED_SCRIPT_HASHES.get(name) == dst_hash)
if proven_unmodified:
shutil.copyfile(src, dst)
script_refreshed.append(name)
script_changed = True
new_hashes[name] = src_hash
else:
with open(src, "rb") as fh:
src_bytes = fh.read()
with open(dst, "rb") as fh:
dst_bytes = fh.read()
if src_bytes != dst_bytes:
shutil.copyfile(src, dst)
script_refreshed.append(name)
script_changed = True
continue
script_differs.append(name)
script_skipped.append(name)
_audit_script_skipped(home, name, dst_hash, src_hash)
# NEVER record the tenant-modified hash as proven:
# writing dst_hash into the manifest would make the
# NEXT upgrade mistake this modified file for an
# untouched one and overwrite it. Keep any older
# proof entry (so a revert to that version can still
# auto-merge later); otherwise drop the entry, which
# fails closed to skip again.
if name in manifest:
new_hashes[name] = manifest[name]
if new_hashes:
_write_script_manifest(home, new_hashes)
config_path = os.path.join(home, "config.toml")
if command_args is None:
command_args = ["python3",
@@ -793,7 +1028,9 @@ def ensure_codex_home_playwright(codex_home_dir, command_args=None,
"config_changed": config_changed,
"script_changed": script_changed,
"script_differs": script_differs,
"script_refreshed": script_refreshed}
"script_refreshed": script_refreshed,
"script_skipped_modified": script_skipped,
"audit_path": audit_path}
def ensure_playwright_mcp_env(codex_home_dir, env):
+15 -3
View File
@@ -72,7 +72,12 @@ _TOOLS = [
"description": (
"Click an element by CSS selector or Playwright "
"selector (e.g. 'button.submit', 'text=Next'). "
"Then snapshot to see the result."
"Then snapshot to see the result. When "
"BROWSER_ALLOWED_HOSTS is set, an anchor click to "
"a non-listed host is refused BEFORE the click; a "
"click whose target cannot be resolved beforehand "
"(button, JS handler) is reported after it lands "
"on a blocked host instead."
),
"inputSchema": {
"type": "object",
@@ -120,7 +125,11 @@ _TOOLS = [
"top-left of the 1280x800 viewport). This is the tool "
"a live viewer drives: the user clicks on a screenshot "
"and the coordinates arrive here. Then snapshot to see "
"the result."
"the result. Coordinates identify no element, so "
"there is no pre-click allowlist gate here: when "
"BROWSER_ALLOWED_HOSTS is set, a landing on a "
"blocked host is reported after the click, and the "
"egress proxy 403 is the backstop that stops the load."
),
"inputSchema": {
"type": "object",
@@ -165,7 +174,10 @@ _TOOLS = [
"name": "browser_click_ref",
"description": (
"Click an element by its ref from browser_snapshot_refs. "
"More robust than a CSS selector across re-renders."
"More robust than a CSS selector across re-renders. "
"Same allowlist rule as browser_click: an anchor ref "
"to a non-listed host is refused before the click "
"when BROWSER_ALLOWED_HOSTS is set."
),
"inputSchema": {
"type": "object",
+30 -3
View File
@@ -82,9 +82,36 @@ navigate / snapshot / click / fill / screenshot 都针对该页;
就检查:`file://` 一律放行,`http(s)` 的 host 必须命中列表
(前导点条目做后缀匹配:`.python.org` 同时放行
`python.org` 与 `docs.python.org`,不放行
`notpython.org`),大小写与端口不影响匹配,未命中直接
报错且不启动 Chromium。`localhost` / `127.0.0.1` 没有
特殊待遇:本地开发服务器要自己把它列进变量,否则会被挡。
`notpython.org`;注意:不带前导点的 `python.org` 只精确
匹配自身,不放行 `docs.python.org`),大小写与端口不影响
匹配,未命中直接报错且不启动 Chromium。`localhost` /
`127.0.0.1` 没有特殊待遇:本地开发服务器要自己把它列进
变量,否则会被挡。
页内点击白名单(本轮补齐,与 navigate 同款检查):
`browser_click` / `browser_click_ref` 点到带 href 的链接时,
先解析 href(相对链接按当前页 URL 展开)再检查:未命中
直接报错、点击根本不执行;点到没有 href 的目标(按钮、
JS 处理函数)时事前无从得知目的地,只能在点击后检查落点
URL、命中 blocked host 时报错(此时点击已发生,只是事后
发现,不是事前拦截)。`browser_click_at` 是坐标点击,坐标
本身对应不了任何元素,所以没有事前检查,只有事后落点检查。
真正拦不住的路径(不要假装已拦):页内 JS 自行跳转
(`location.href`、表单提交、SPA 路由、回车提交)、
`browser_evaluate` 里跑的任意 JS、meta refresh、弹窗新页。
这些路径没有“点击前已知目的地”可供检查,只能靠 egress
代理 403 兜底——白名单与代理白名单取同一域名集合时,被挡
域名在代理层就加载失败,不会真的打开目标页;但 evaluate
与 shell 同权,白名单从来不是它的沙箱边界,不要把它当
成防绕过手段。
老租户脚本升级(本轮改动):播种器只对已证明未改动的
租户脚本自动合并新版(播种 manifest 命中,或命中
2a569ac 已发布版本的哈希,或文件缺失/已是最新);租户
改过的脚本绝不覆盖,原样不动、记一条审计
(租户 codex-home 里的 `playwright_scripts_audit.jsonl`),
留待人工处理——下次任务播种时它仍会被跳过,不会悄悄
被新版盖掉。
清理:`browser_close` 会关掉全部页,并尽力删除工作区里的
`browser-live.png` 与 `browser-live.json`(删不掉也不报错),
+220
View File
@@ -0,0 +1,220 @@
"""Task-level code-scope extension (search(..., allowed_hosts=[...])).
All HTTP is mocked; no network, no real keys. Fake key values are
distinctive so a leak into an error string or result is caught by
assertion. Extension values are the caller's own input echoed in
its own result only -- no test here writes them to a log, and the
code under test never formats them into error strings.
"""
import json
import sys
import unittest
from pathlib import Path
try:
from cloud import websearch as _ws
from cloud.websearch import (
CODE_SEARCH_DOMAINS, WebSearchError, _is_code_domain,
_normalize_allowed_hosts, search)
from cloud.websearch_mcp import handle_request
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud import websearch as _ws
from cloud.websearch import (
CODE_SEARCH_DOMAINS, WebSearchError, _is_code_domain,
_normalize_allowed_hosts, search)
from cloud.websearch_mcp import handle_request
_FAKE_KEYS = {
"PERPLEXITY_API_KEY": "pplx-SECRET-111",
"EXA_API_KEY": "exa-SECRET-222",
"TAVILY_API_KEY": "tvly-SECRET-333",
"BRAVE_API_KEY": "brave-SECRET-444",
}
_EXTRA = "docs.example-project.dev"
class FakeHttp:
"""post_json/get keyed by URL substring, in route order."""
def __init__(self, routes):
self.routes = routes
self.seen = []
def post_json(self, url, headers, payload):
self.seen.append(("POST", url, payload))
return self._route(url)
def get(self, url, headers=None):
self.seen.append(("GET", url, headers))
return self._route(url)
def _route(self, url):
for needle, body, status in self.routes:
if needle in url:
if status != 200:
raise WebSearchError("HTTP %s" % status)
if isinstance(body, dict):
return 200, json.dumps(body).encode(), \
"application/json"
return 200, body.encode(), "text/html"
raise WebSearchError("HTTP 404")
def _tavily_body(urls):
return {"results": [{"title": "T %s" % u, "url": u,
"content": "snippet %s" % u} for u in urls]}
class ExtensionTestCase(unittest.TestCase):
def test_specified_extra_domain_returned_and_native_param_sent(self):
http = FakeHttp([
("api.tavily.com", _tavily_body([
"https://docs.example-project.dev/guide",
"https://github.com/example/project",
"https://random-blog.example.com/post",
]), 200),
])
out = search("project guide", provider="tavily", scope="code",
allowed_hosts=[_EXTRA],
env=dict(_FAKE_KEYS), http=http)
payload = http.seen[0][2]
self.assertIn(_EXTRA, payload["include_domains"])
self.assertEqual(out["code_domains"],
list(CODE_SEARCH_DOMAINS) + [_EXTRA])
self.assertEqual(
[r["url"] for r in out["results"]],
["https://docs.example-project.dev/guide",
"https://github.com/example/project"])
self.assertEqual(out["attempted"][0]["domain_filtered_out"], 1)
for secret in _FAKE_KEYS.values():
self.assertNotIn(secret, json.dumps(out))
def test_unspecified_extra_domain_still_filtered_out(self):
http = FakeHttp([
("api.tavily.com", _tavily_body([
"https://docs.example-project.dev/guide",
"https://github.com/example/project",
]), 200),
])
out = search("project guide", provider="tavily", scope="code",
env=dict(_FAKE_KEYS), http=http)
self.assertEqual(out["code_domains"], list(CODE_SEARCH_DOMAINS))
self.assertEqual([r["url"] for r in out["results"]],
["https://github.com/example/project"])
def test_dot_boundary_positive_and_negative(self):
domains = list(CODE_SEARCH_DOMAINS) + [_EXTRA]
self.assertTrue(_is_code_domain(
"https://api.docs.example-project.dev/v1", domains))
self.assertTrue(_is_code_domain(
"https://docs.example-project.dev/", domains))
self.assertFalse(_is_code_domain(
"https://evildocs.example-project.dev/", domains))
self.assertFalse(_is_code_domain(
"https://docs.example-project.dev.evil.com/", domains))
# Same boundary rule for a builtin domain, unchanged.
self.assertFalse(_is_code_domain(
"https://evilgithub.com/x", domains))
self.assertTrue(_is_code_domain(
"https://gist.github.com/x", domains))
def test_builtin_entries_are_deduped_not_errors(self):
self.assertEqual(
_normalize_allowed_hosts(["github.com", _EXTRA]), [_EXTRA])
self.assertEqual(
_normalize_allowed_hosts(["Docs.Example-Project.Dev."]),
["docs.example-project.dev"])
def test_empty_and_illegal_values_raise_before_http(self):
bad_values = [
[], [""], [" "], ["*.example.com"],
["http://example.com"], ["https://example.com/x"],
["example.com:8080"], ["example.com/path"],
[".example.com"], ["example.com..dev"], ["exa mple.com"],
["user@example.com"], [123], [None], ["github.com"],
]
for bad in bad_values:
http = FakeHttp([("api.tavily.com", _tavily_body([]), 200)])
with self.assertRaises(ValueError, msg=repr(bad)):
search("q", provider="tavily", scope="code",
allowed_hosts=bad, env=dict(_FAKE_KEYS),
http=http)
self.assertEqual(http.seen, [],
"invalid %r must fail before any HTTP"
% (bad,))
# A bare string is iterable but is not a list of hostnames.
with self.assertRaises(ValueError):
search("q", provider="tavily", scope="code",
allowed_hosts="example.com",
env=dict(_FAKE_KEYS), http=FakeHttp([]))
def test_allowed_hosts_with_general_scope_raises_not_ignored(self):
http = FakeHttp([("api.tavily.com", _tavily_body(
["https://docs.example-project.dev/guide"]), 200)])
with self.assertRaises(ValueError):
search("q", provider="tavily", scope="general",
allowed_hosts=[_EXTRA],
env=dict(_FAKE_KEYS), http=http)
self.assertEqual(http.seen, [])
def test_perplexity_over_cap_skips_native_filter_keeps_postfilter(self):
extras = ["extra%d.example.com" % i for i in range(6)]
self.assertGreater(len(CODE_SEARCH_DOMAINS) + len(extras), 20)
http = FakeHttp([
("api.perplexity.ai",
{"choices": [{"message": {"content": "answer"}}],
"search_results": [
{"title": "P",
"url": "https://extra0.example.com/doc",
"snippet": "extra"},
{"title": "Ad", "url": "https://shop.example.com/x",
"snippet": "ad"}]}, 200),
])
out = search("q", provider="perplexity", scope="code",
allowed_hosts=extras,
env=dict(_FAKE_KEYS), http=http)
payload = http.seen[0][2]
self.assertNotIn("search_domain_filter", payload)
self.assertEqual([r["url"] for r in out["results"]],
["https://extra0.example.com/doc"])
self.assertEqual(len(out["code_domains"]), 21)
class McpPassthroughTestCase(unittest.TestCase):
def test_mcp_forwards_allowed_hosts_when_supplied(self):
captured = {}
def fake_search(query, limit=5, provider="auto", scope="general",
allowed_hosts=None):
captured["allowed_hosts"] = allowed_hosts
return {"provider": "fake", "results": [], "answer": None,
"attempted": [], "scope": scope}
resp = handle_request(
{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "web_search",
"arguments": {"query": "hi", "scope": "code",
"allowed_hosts": [_EXTRA]}}},
search_fn=fake_search)
self.assertFalse(resp["result"]["isError"])
self.assertEqual(captured["allowed_hosts"], [_EXTRA])
def test_mcp_without_allowed_hosts_keeps_old_fake_working(self):
def fake_search(query, limit=5, provider="auto",
scope="general"):
return {"provider": "fake", "results": [], "answer": None,
"attempted": [], "scope": scope}
resp = handle_request(
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "web_search",
"arguments": {"query": "hi"}}},
search_fn=fake_search)
self.assertFalse(resp["result"]["isError"])
if __name__ == "__main__":
unittest.main()
+155
View File
@@ -0,0 +1,155 @@
"""JS-heavy page extraction evaluation, mock-first.
All HTTP is mocked; no network, no real keys. The three page shapes
below are the mock stand-ins for real JS-heavy pages (a static shell
whose body is rendered by JS, a shell whose pagination is JS-loaded,
and a shell whose content lives only in a <script> blob):
- direct fetch (fetch_url) parses static HTML only: <script> and
<style> contents are stripped, so anything JS would have rendered
is absent from the result -- this is by construction, not a bug;
- a provider extract stage (fetch_via_provider, mocked here) can
return the rendered text when the provider actually renders, and
an empty extraction is a failure, never a fabricated page.
Live对照: only the keyless SearXNG route is ever used live, and
only when a test environment actually provides SEARXNG_URL; this
file never calls a paid backend live. Set
WEBSEARCH_EVAL_LIVE_SEARXNG=1 with SEARXNG_URL in the environment
to enable the live smoke test, otherwise it is skipped.
"""
import json
import os
import sys
import unittest
from pathlib import Path
try:
from cloud.websearch import (
WebSearchError, fetch_best, fetch_url, fetch_via_provider)
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.websearch import (
WebSearchError, fetch_best, fetch_url, fetch_via_provider)
_FAKE_KEYS = {
"TAVILY_API_KEY": "tvly-SECRET-333",
"EXA_API_KEY": "exa-SECRET-222",
}
# Shape 1: shell HTML only; the article body is JS-rendered.
_SHELL_HTML = """<html><head><title>Guide</title></head><body>
<div id="app">Loading…</div>
<script src="/app.js"></script>
<script>window.__DATA__ = {"body": "RENDERED-BODY-PARAGRAPH"};</script>
<a href="https://example.com/guide/page2">Next (static link)</a>
</body></html>"""
# Shape 2: paginated list; page 2 items arrive only via JS fetch.
_PAGED_SHELL_HTML = """<html><head><title>List</title></head><body>
<ul id="items"><li>ITEM-PAGE-1</li></ul>
<button id="more">Load more</button>
<script>fetch("/api/items?page=2").then(r => r.json());</script>
</body></html>"""
class FakeHttp:
"""get/post_json keyed by URL substring, in route order."""
def __init__(self, routes):
self.routes = routes
self.seen = []
def post_json(self, url, headers, payload):
self.seen.append(("POST", url, payload))
return self._route(url)
def get(self, url, headers=None):
self.seen.append(("GET", url, headers))
return self._route(url)
def _route(self, url):
for needle, body, status in self.routes:
if needle in url:
if status != 200:
raise WebSearchError("HTTP %s" % status)
if isinstance(body, dict):
return 200, json.dumps(body).encode(), \
"application/json"
return 200, body.encode(), "text/html"
raise WebSearchError("HTTP 404")
class JsHeavyDirectFetchTestCase(unittest.TestCase):
def test_direct_fetch_gets_shell_only_not_js_rendered_body(self):
http = FakeHttp([("example.com/guide", _SHELL_HTML, 200)])
out = fetch_url("https://example.com/guide", http=http, env={})
# Static shell survives: title + placeholder + static link.
self.assertEqual(out["title"], "Guide")
self.assertIn("Loading", out["text"])
self.assertIn("https://example.com/guide/page2", out["links"])
# JS-rendered body does not: <script> contents are stripped,
# so neither the rendered paragraph nor the data blob leaks
# in as text.
self.assertNotIn("RENDERED-BODY-PARAGRAPH", out["text"])
self.assertNotIn("window.__DATA__", out["text"])
def test_direct_fetch_gets_page1_only_not_js_paginated_items(self):
http = FakeHttp([("example.com/list", _PAGED_SHELL_HTML, 200)])
out = fetch_url("https://example.com/list", http=http, env={})
self.assertIn("ITEM-PAGE-1", out["text"])
self.assertNotIn("ITEM-PAGE-2", out["text"])
self.assertNotIn("/api/items?page=2", out["text"])
class JsHeavyProviderExtractTestCase(unittest.TestCase):
def test_mock_provider_extract_can_supply_rendered_text(self):
# Mock stand-in for "provider renders JS": Tavily extract
# returns the full rendered article text.
http = FakeHttp([
("api.tavily.com/extract",
{"results": [{"raw_content": "RENDERED-BODY-PARAGRAPH "
"plus ITEM-PAGE-2"}]}, 200),
])
out = fetch_via_provider("https://example.com/guide",
env=dict(_FAKE_KEYS), http=http)
self.assertEqual(out["source"], "tavily-extract")
self.assertIn("RENDERED-BODY-PARAGRAPH", out["text"])
def test_mock_provider_extract_empty_is_failure_not_fabrication(self):
http = FakeHttp([
("api.tavily.com/extract", {"results": []}, 200),
("api.exa.ai/contents", {"results": []}, 200),
])
with self.assertRaises(WebSearchError):
fetch_via_provider("https://example.com/guide",
env=dict(_FAKE_KEYS), http=http)
def test_fetch_best_falls_back_to_shell_when_extract_fails(self):
http = FakeHttp([
("api.tavily.com/extract", {"results": []}, 200),
("api.exa.ai/contents", {"results": []}, 200),
("example.com/guide", _SHELL_HTML, 200),
])
out = fetch_best("https://example.com/guide",
env=dict(_FAKE_KEYS), http=http)
self.assertEqual(out["source"], "direct-fetch")
self.assertNotIn("RENDERED-BODY-PARAGRAPH", out["text"])
@unittest.skipUnless(
os.environ.get("WEBSEARCH_EVAL_LIVE_SEARXNG") == "1"
and os.environ.get("SEARXNG_URL"),
"live SearXNG对照 only when the test env provides SEARXNG_URL")
class LiveSearxngSmokeTestCase(unittest.TestCase):
def test_live_searxng_chain_answers_without_paid_keys(self):
from cloud.websearch import search
out = search("python pathlib documentation", provider="searxng",
scope="general",
env={"SEARXNG_URL": os.environ["SEARXNG_URL"]})
self.assertTrue(out["results"])
if __name__ == "__main__":
unittest.main()
+209
View File
@@ -0,0 +1,209 @@
"""BROWSER_ALLOWED_HOSTS activation-candidate validation.
The candidate value is the ALREADY-DEPLOYED Tinyproxy
programming-domain set (deploy/egress/tinyproxy-filter) plus the
already-present github.com / pypi.org / npmjs.org -- no invented
domains. NOTE: the task text said "npmjs.com", but the deployed
filter (tinyproxy-filter line 23, new-host-checklist.md, and
docs/platform-browser.md) all say npmjs.org, so the candidate
follows the DEPLOYED set (npmjs.org); npmjs.com is deliberately
NOT in the candidate -- adding it would widen the allowlist
beyond the proxy, breaking allowlist==proxy parity. If the npm
website itself must be browsable, that is a separate filter
change for the parent to decide, not something this round
invents. Because the Tinyproxy lines are subdomain-inclusive
(``^\\(.*\\.\\)*domain$``), the env value uses leading-dot
entries, which are the allowlist encoding of the same
subdomain-inclusive set; plain entries would NOT match
subdomains (proven by test_plain_entries_would_break_subdomains)
and would therefore regress docs.python.org etc.
All checks run against fake browsers / the filter file itself:
no production tenant, no network, no real credentials.
"""
import os
import re
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from playwright_browser import Browser, BrowserError # noqa: E402
from playwright_browser import _check_allowed_url # noqa: E402
# The activation candidate: the deployed Tinyproxy programming
# set, encoded subdomain-inclusively. Domain SET is fixed by the
# task (no new domains); only the encoding (dot prefix) follows
# from the filter's own subdomain rule.
CANDIDATE_DOMAINS = [
"python.org", "nodejs.org", "go.dev", "pkg.go.dev",
"rust-lang.org", "developer.mozilla.org", "crates.io",
"stackoverflow.com", "cve.mitre.org", "nvd.nist.gov",
"github.com", "pypi.org", "npmjs.org",
]
CANDIDATE = ",".join("." + d for d in CANDIDATE_DOMAINS)
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__),
"..", ".."))
FILTER_PATH = os.path.join(REPO_ROOT, "deploy", "egress",
"tinyproxy-filter")
class _FakePage:
def __init__(self):
self.url = ""
self.calls = []
self.mouse = self
def title(self):
return "T"
def goto(self, url, timeout=None, wait_until=None):
self.calls.append(("goto", url))
self.url = url
def locator(self, selector):
raise AssertionError("no locator use in activation tests")
def click(self, selector, timeout=None):
self.calls.append(("click", selector))
def close(self):
pass
class _FakeHandle:
def __init__(self, page):
self._page = page
def new_page(self, viewport=None):
return self._page
def close(self):
pass
class _FakePW:
def __init__(self, page):
self._page = page
self.launch_count = 0
def start(self):
return self
@property
def chromium(self):
outer = self
class _Chromium:
def launch(self, **kwargs):
outer.launch_count += 1
return _FakeHandle(outer._page)
return _Chromium()
def stop(self):
pass
class _EnvGuard:
def setUp(self):
self._old = os.environ.get("BROWSER_ALLOWED_HOSTS")
def tearDown(self):
if self._old is None:
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
else:
os.environ["BROWSER_ALLOWED_HOSTS"] = self._old
def _filter_domains():
"""Domains covered by subdomain-inclusive filter lines."""
with open(FILTER_PATH, encoding="utf-8") as fh:
lines = fh.read().splitlines()
out = set()
for line in lines:
m = re.match(r"^\^\\\(\.\*\\.\\\)\*(.+)\$$", line)
if m:
out.add(m.group(1).replace("\\.", "."))
return out
class ActivationCandidateTest(_EnvGuard, unittest.TestCase):
def setUp(self):
_EnvGuard.setUp(self)
os.environ["BROWSER_ALLOWED_HOSTS"] = CANDIDATE
self._tmp = tempfile.TemporaryDirectory()
self.page = _FakePage()
self.factory = _FakePW(self.page)
self.browser = Browser(cwd=self._tmp.name,
proxy="http://proxy:8888",
browser_factory=lambda: self.factory)
def tearDown(self):
self._tmp.cleanup()
_EnvGuard.tearDown(self)
def test_candidate_matches_deployed_filter_set(self):
# Every candidate domain has a subdomain-inclusive line
# in the deployed filter; the candidate invents nothing.
filt = _filter_domains()
for domain in CANDIDATE_DOMAINS:
self.assertIn(domain, filt, domain)
def test_candidate_allows_bare_and_subdomains(self):
for domain in CANDIDATE_DOMAINS:
_check_allowed_url("https://%s/" % domain)
_check_allowed_url("https://www.%s/docs" % domain)
# The cases the filter explicitly exists for.
_check_allowed_url("https://docs.python.org/3/")
_check_allowed_url("https://docs.rust-lang.org/")
_check_allowed_url("https://api.github.com/")
_check_allowed_url("https://pypi.org/project/x/")
def test_candidate_blocks_suffix_tricks_and_outsiders(self):
for bad in ("https://notpython.org/",
"https://python.org.evil.com/",
"https://evilgithub.com/",
"https://api.diante.me/",
"https://www.amazon.com/",
"https://mail.google.com/"):
with self.assertRaises(BrowserError, msg=bad):
_check_allowed_url(bad)
def test_plain_entries_would_break_subdomains(self):
# Why the candidate uses dot forms: plain "python.org"
# does not match docs.python.org under the established
# navigate semantics, so a plain-entry activation would
# regress exactly the docs the filter was added for.
os.environ["BROWSER_ALLOWED_HOSTS"] = (
"python.org,nodejs.org,go.dev,pkg.go.dev,rust-lang.org,"
"developer.mozilla.org,crates.io,stackoverflow.com,"
"cve.mitre.org,nvd.nist.gov,github.com,pypi.org,npmjs.org")
with self.assertRaises(BrowserError):
_check_allowed_url("https://docs.python.org/3/")
def test_file_allowed_localhost_blocked_under_candidate(self):
out = self.browser.navigate("file:///tmp/page.html")
self.assertTrue(out["ok"])
with self.assertRaises(BrowserError):
self.browser.navigate("http://localhost:8000/")
# localhost blocking is a behaviour CHANGE vs unset:
# local dev servers need an explicit localhost entry.
self.assertEqual(self.factory.launch_count, 1)
def test_task_level_navigate_allow_and_block(self):
out = self.browser.navigate("https://docs.python.org/3/")
self.assertTrue(out["ok"])
launches = self.factory.launch_count
with self.assertRaises(BrowserError):
self.browser.navigate("https://evil.com/")
self.assertEqual(self.factory.launch_count, launches)
def test_unset_means_no_change(self):
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
out = self.browser.navigate("https://anything.example/")
self.assertTrue(out["ok"])
_check_allowed_url("https://anything.example/")
@@ -0,0 +1,309 @@
"""Click-path allowlist tests for the Playwright browser (fake only).
Covers the continuation of the navigate-only allowlist: click /
click_ref get the SAME check before the click when the target
anchor href is resolvable, and all three click paths get a
post-click URL check. click_at has no pre-click gate by design
(coordinates identify no element). No real Chromium, no network,
no real credentials -- values here are test placeholders.
"""
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from playwright_browser import Browser, BrowserError # noqa: E402
from playwright_browser import _check_navigated_url # noqa: E402
class _FakeLocator:
def __init__(self, page, href):
self._page = page
self._href = href
@property
def first(self):
return self
def click(self, timeout=None):
self._page.calls.append(("locator_click",))
if self._page.click_landing is not None:
self._page.url = self._page.click_landing
def get_attribute(self, name):
if name == "href":
return self._href
return None
def inner_text(self, timeout=None):
return "text"
class _FakeMouse:
def __init__(self, page):
self._page = page
def click(self, x, y):
self._page.calls.append(("mouse_click", x, y))
if self._page.click_landing is not None:
self._page.url = self._page.click_landing
def move(self, x, y):
self._page.calls.append(("mouse_move", x, y))
class _FakePage:
def __init__(self):
self.url = "https://example.com/"
self.calls = []
self.href_map = {}
self.click_landing = None
self.mouse = _FakeMouse(self)
def title(self):
return "Example"
def goto(self, url, timeout=None, wait_until=None):
self.calls.append(("goto", url))
self.url = url
def locator(self, selector):
self.calls.append(("locator", selector))
return _FakeLocator(self, self.href_map.get(selector))
def click(self, selector, timeout=None):
self.calls.append(("click", selector))
if self.click_landing is not None:
self.url = self.click_landing
def fill(self, selector, text, timeout=None):
self.calls.append(("fill", selector, text))
def evaluate(self, expression):
self.calls.append(("evaluate", expression))
return None
def screenshot(self, path=None):
with open(path, "wb") as fh:
fh.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 10)
def close(self):
pass
class _FakeHandle:
def __init__(self, page):
self._page = page
def new_page(self, viewport=None):
return self._page
def close(self):
pass
class _FakePW:
def __init__(self, page):
self._page = page
self.launch_count = 0
def start(self):
return self
@property
def chromium(self):
outer = self
class _Chromium:
def launch(self, **kwargs):
outer.launch_count += 1
return _FakeHandle(outer._page)
return _Chromium()
def stop(self):
pass
class _EnvGuard:
def setUp(self):
self._old = os.environ.get("BROWSER_ALLOWED_HOSTS")
def tearDown(self):
if self._old is None:
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
else:
os.environ["BROWSER_ALLOWED_HOSTS"] = self._old
class ClickAllowlistTest(_EnvGuard, unittest.TestCase):
def setUp(self):
_EnvGuard.setUp(self)
self._tmp = tempfile.TemporaryDirectory()
self.page = _FakePage()
self.factory = _FakePW(self.page)
self.browser = Browser(cwd=self._tmp.name,
proxy="http://proxy:8888",
browser_factory=lambda: self.factory)
def tearDown(self):
self._tmp.cleanup()
_EnvGuard.tearDown(self)
def _start(self, url="https://example.com/"):
return self.browser.navigate(url)
def test_click_allowed_href_executes(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com, .python.org"
self._start()
self.page.href_map["a.docs"] = "https://docs.python.org/3/"
self.page.click_landing = "https://docs.python.org/3/"
out = self.browser.click("a.docs")
self.assertTrue(out["ok"])
self.assertIn(("click", "a.docs"), self.page.calls)
self.assertEqual(out["url"], "https://docs.python.org/3/")
def test_click_blocked_href_refused_before_click(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
self._start()
self.page.href_map["a.evil"] = "https://evil.com/x"
self.page.click_landing = "https://evil.com/x"
with self.assertRaises(BrowserError) as ctx:
self.browser.click("a.evil")
self.assertIn("evil.com", str(ctx.exception))
self.assertNotIn(("click", "a.evil"), self.page.calls)
self.assertEqual(self.browser._current_url,
"https://example.com/")
def test_click_suffix_trick_blocked(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = ".python.org"
self._start("https://python.org/")
for bad in ("https://notpython.org/",
"https://python.org.evil.com/"):
self.page.href_map["a.bad"] = bad
with self.assertRaises(BrowserError):
self.browser.click("a.bad")
self.assertNotIn(("click", "a.bad"), self.page.calls)
self.page.calls.clear()
def test_click_plain_entry_is_exact_only(self):
# Same semantics as navigate: a plain entry does NOT
# match subdomains (that is what leading-dot entries
# are for). Documented, not a bug: the activation
# candidate therefore uses dot forms.
os.environ["BROWSER_ALLOWED_HOSTS"] = "python.org"
self._start("https://python.org/")
self.page.href_map["a.docs"] = "https://docs.python.org/"
with self.assertRaises(BrowserError):
self.browser.click("a.docs")
self.assertNotIn(("click", "a.docs"), self.page.calls)
def test_click_port_and_case_insensitive(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "Example.COM"
self._start()
self.page.href_map["a.p"] = "https://EXAMPLE.com:8443/p"
self.page.click_landing = "https://EXAMPLE.com:8443/p"
out = self.browser.click("a.p")
self.assertTrue(out["ok"])
def test_click_file_href_allowed(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
self._start()
self.page.href_map["a.file"] = "file:///tmp/page.html"
self.page.click_landing = "file:///tmp/page.html"
out = self.browser.click("a.file")
self.assertTrue(out["ok"])
def test_click_localhost_needs_explicit_listing(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
self._start()
self.page.href_map["a.local"] = "http://localhost:8000/"
with self.assertRaises(BrowserError):
self.browser.click("a.local")
self.assertNotIn(("click", "a.local"), self.page.calls)
os.environ["BROWSER_ALLOWED_HOSTS"] = "localhost"
self.page.click_landing = "http://localhost:8000/"
out = self.browser.click("a.local")
self.assertTrue(out["ok"])
def test_click_relative_href_resolves_against_current(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
self._start("https://example.com/dir/")
self.page.href_map["a.rel"] = "/other"
self.page.click_landing = "https://example.com/other"
out = self.browser.click("a.rel")
self.assertTrue(out["ok"])
# Fragment-only hrefs never navigate to another host.
self.page.href_map["a.frag"] = "#section"
self.page.click_landing = None
out = self.browser.click("a.frag")
self.assertTrue(out["ok"])
def test_click_unknown_target_post_click_detection(self):
# Button / JS handler: no href, so no pre-click gate.
# The click DOES execute; a blocked landing is then
# reported (detection, not prevention -- the proxy 403
# is the layer that stops the load in production).
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
self._start()
self.page.href_map["button.go"] = None
self.page.click_landing = "https://evil.com/"
with self.assertRaises(BrowserError) as ctx:
self.browser.click("button.go")
self.assertIn("evil.com", str(ctx.exception))
self.assertIn(("click", "button.go"), self.page.calls)
# Same target landing on an allowed host is fine.
self.page.click_landing = "https://example.com/ok"
out = self.browser.click("button.go")
self.assertTrue(out["ok"])
def test_click_unset_allowlist_unchanged(self):
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
self._start("https://anything.example/")
self.page.href_map["a.evil"] = "https://evil.com/"
self.page.click_landing = "https://evil.com/"
out = self.browser.click("a.evil")
self.assertTrue(out["ok"])
self.assertIn(("click", "a.evil"), self.page.calls)
def test_click_ref_blocked_href_refused_before_click(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
self._start()
self.browser._ref_paths = {"e0": "a.evil", "e1": "a.docs"}
self.page.href_map["a.evil"] = "https://evil.com/"
self.page.href_map["a.docs"] = "https://example.com/docs"
self.page.click_landing = "https://evil.com/"
with self.assertRaises(BrowserError):
self.browser.click_ref("e0")
self.assertNotIn(("locator_click",), self.page.calls)
self.page.click_landing = "https://example.com/docs"
out = self.browser.click_ref("e1")
self.assertTrue(out["ok"])
self.assertIn(("locator_click",), self.page.calls)
def test_click_at_post_click_only(self):
# click_at has no pre-click gate by design; a blocked
# landing raises after the click, an allowed one passes.
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
self._start()
self.page.click_landing = "https://evil.com/"
with self.assertRaises(BrowserError):
self.browser.click_at(100, 200)
self.assertIn(("mouse_click", 100.0, 200.0), self.page.calls)
self.page.click_landing = "https://example.com/"
out = self.browser.click_at(100, 200)
self.assertTrue(out["ok"])
def test_check_navigated_url_skips_non_page_schemes(self):
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
# about:blank / mailto: / javascript: never navigate
# the page to a host, so they must not raise here.
_check_navigated_url("about:blank")
_check_navigated_url("mailto:someone@example.com")
_check_navigated_url("javascript:void(0)")
_check_navigated_url("file:///tmp/page.html")
with self.assertRaises(BrowserError):
_check_navigated_url("https://evil.com/")
+216
View File
@@ -0,0 +1,216 @@
"""Safe tenant-script upgrade tests (no Docker, no network).
The rule under test: only a PROVEN-unmodified tenant script
(manifest match, known-shipped-hash match, missing, or already
current) is auto-merged. A tenant-modified script is NEVER
overwritten: it stays byte-identical, is reported as skipped,
and gets exactly one deduped audit record. Reruns are idempotent.
No real credentials appear here.
"""
import hashlib
import json
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import playwright_browser as pb # noqa: E402
from playwright_browser import ensure_codex_home_playwright # noqa: E402
NAMES = pb.PLAYWRIGHT_SCRIPT_NAMES
def _write_scripts(d, contents):
for name in NAMES:
with open(os.path.join(d, name), "w", encoding="utf-8") as fh:
fh.write(contents[name])
def _read_scripts(d):
out = {}
for name in NAMES:
with open(os.path.join(d, name), encoding="utf-8") as fh:
out[name] = fh.read()
return out
def _v1():
return {name: "# v1 %s\nprint('v1')\n" % name for name in NAMES}
def _v2():
return {name: "# v2 %s\nprint('v2')\n" % name for name in NAMES}
class UpgradeTest(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.src_v1 = os.path.join(self._tmp.name, "src-v1")
self.src_v2 = os.path.join(self._tmp.name, "src-v2")
self.home = os.path.join(self._tmp.name, "home")
os.makedirs(self.src_v1)
os.makedirs(self.src_v2)
os.makedirs(self.home)
_write_scripts(self.src_v1, _v1())
_write_scripts(self.src_v2, _v2())
def tearDown(self):
self._tmp.cleanup()
def _audit_records(self):
path = os.path.join(self.home, "playwright_scripts_audit.jsonl")
if not os.path.exists(path):
return []
with open(path, encoding="utf-8") as fh:
return [json.loads(line) for line in fh if line.strip()]
def test_fresh_seed_and_idempotent_rerun(self):
out = ensure_codex_home_playwright(self.home,
script_dir=self.src_v2)
self.assertEqual(_read_scripts(self.home), _v2())
self.assertFalse(out["script_skipped_modified"])
self.assertEqual(self._audit_records(), [])
# Config section appended exactly once.
with open(os.path.join(self.home, "config.toml"),
encoding="utf-8") as fh:
config = fh.read()
self.assertEqual(config.count("[mcp_servers.playwright]"), 1)
out2 = ensure_codex_home_playwright(self.home,
script_dir=self.src_v2)
self.assertFalse(out2["script_changed"])
self.assertFalse(out2["config_changed"])
self.assertFalse(out2["script_refreshed"])
self.assertEqual(_read_scripts(self.home), _v2())
with open(os.path.join(self.home, "config.toml"),
encoding="utf-8") as fh:
self.assertEqual(
fh.read().count("[mcp_servers.playwright]"), 1)
def test_unmodified_script_auto_merges(self):
ensure_codex_home_playwright(self.home, script_dir=self.src_v1)
out = ensure_codex_home_playwright(self.home,
script_dir=self.src_v2)
self.assertEqual(set(out["script_refreshed"]), set(NAMES))
self.assertFalse(out["script_skipped_modified"])
self.assertEqual(_read_scripts(self.home), _v2())
manifest_path = os.path.join(
self.home, "playwright_scripts_manifest.json")
with open(manifest_path, encoding="utf-8") as fh:
manifest = json.load(fh)
for name in NAMES:
self.assertEqual(
manifest[name],
hashlib.sha256(_v2()[name].encode()).hexdigest())
def test_modified_script_untouched_with_audit(self):
ensure_codex_home_playwright(self.home, script_dir=self.src_v1)
tenant_path = os.path.join(self.home, NAMES[0])
with open(tenant_path, "a", encoding="utf-8") as fh:
fh.write("# tenant tweak: custom timeout\n")
with open(tenant_path, encoding="utf-8") as fh:
tenant_bytes = fh.read()
out = ensure_codex_home_playwright(self.home,
script_dir=self.src_v2)
self.assertEqual(out["script_skipped_modified"], [NAMES[0]])
self.assertEqual(out["script_differs"], [NAMES[0]])
# Modified file byte-identical; the other two auto-merged.
with open(tenant_path, encoding="utf-8") as fh:
self.assertEqual(fh.read(), tenant_bytes)
rest = _read_scripts(self.home)
for name in NAMES[1:]:
self.assertEqual(rest[name], _v2()[name])
records = self._audit_records()
self.assertEqual(len(records), 1)
self.assertEqual(records[0]["action"], "skipped_modified")
self.assertEqual(records[0]["script"], NAMES[0])
self.assertEqual(
records[0]["dst_sha256"],
hashlib.sha256(tenant_bytes.encode()).hexdigest())
def test_modified_rerun_does_not_rewrite_or_duplicate_audit(self):
ensure_codex_home_playwright(self.home, script_dir=self.src_v1)
tenant_path = os.path.join(self.home, NAMES[0])
with open(tenant_path, "a", encoding="utf-8") as fh:
fh.write("# tenant tweak\n")
with open(tenant_path, encoding="utf-8") as fh:
tenant_bytes = fh.read()
ensure_codex_home_playwright(self.home, script_dir=self.src_v2)
out = ensure_codex_home_playwright(self.home,
script_dir=self.src_v2)
self.assertFalse(out["script_changed"])
self.assertEqual(out["script_skipped_modified"], [NAMES[0]])
self.assertEqual(len(self._audit_records()), 1)
with open(tenant_path, encoding="utf-8") as fh:
self.assertEqual(fh.read(), tenant_bytes)
# The manifest must NOT have learned the tenant hash:
# otherwise the next upgrade would mistake the modified
# file for untouched and overwrite it.
with open(os.path.join(
self.home, "playwright_scripts_manifest.json"),
encoding="utf-8") as fh:
manifest = json.load(fh)
self.assertNotEqual(
manifest.get(NAMES[0]),
hashlib.sha256(tenant_bytes.encode()).hexdigest())
def test_legacy_no_manifest_known_hash_merges(self):
# Legacy tenant: files seeded before manifests existed,
# content exactly a known shipped version -> proven
# unmodified, safe to merge even with no manifest.
old = {name: "# legacy %s\n" % name for name in NAMES}
_write_scripts(self.home, old)
patched = {name: hashlib.sha256(old[name].encode()).hexdigest()
for name in NAMES}
saved = pb.KNOWN_SHIPPED_SCRIPT_HASHES
pb.KNOWN_SHIPPED_SCRIPT_HASHES = patched
try:
out = ensure_codex_home_playwright(self.home,
script_dir=self.src_v2)
finally:
pb.KNOWN_SHIPPED_SCRIPT_HASHES = saved
self.assertEqual(set(out["script_refreshed"]), set(NAMES))
self.assertEqual(_read_scripts(self.home), _v2())
def test_legacy_no_manifest_unknown_hash_skips(self):
old = {name: "# tenant-custom %s\n" % name for name in NAMES}
_write_scripts(self.home, old)
saved = pb.KNOWN_SHIPPED_SCRIPT_HASHES
pb.KNOWN_SHIPPED_SCRIPT_HASHES = {}
try:
out = ensure_codex_home_playwright(self.home,
script_dir=self.src_v2)
finally:
pb.KNOWN_SHIPPED_SCRIPT_HASHES = saved
self.assertEqual(set(out["script_skipped_modified"]), set(NAMES))
self.assertEqual(_read_scripts(self.home), old)
self.assertEqual(len(self._audit_records()), 3)
def test_upgrade_never_touches_tenant_data_or_config(self):
notes = os.path.join(self.home, "notes.txt")
with open(notes, "w", encoding="utf-8") as fh:
fh.write("tenant notes, must survive\n")
config = os.path.join(self.home, "config.toml")
with open(config, "w", encoding="utf-8") as fh:
fh.write('[user]\nname = "tenant"\n\n'
'[mcp_servers.playwright.env]\n'
'FOO = "bar"\n')
ensure_codex_home_playwright(self.home, script_dir=self.src_v2)
ensure_codex_home_playwright(self.home, script_dir=self.src_v2)
with open(notes, encoding="utf-8") as fh:
self.assertEqual(fh.read(), "tenant notes, must survive\n")
with open(config, encoding="utf-8") as fh:
text = fh.read()
# Existing sections intact, playwright section not
# duplicated (it was absent, so appended exactly once).
self.assertIn('[user]\nname = "tenant"', text)
self.assertIn('FOO = "bar"', text)
self.assertEqual(text.count("[mcp_servers.playwright]"), 1)
def test_known_shipped_hashes_are_well_formed(self):
self.assertEqual(set(pb.KNOWN_SHIPPED_SCRIPT_HASHES), set(NAMES))
for value in pb.KNOWN_SHIPPED_SCRIPT_HASHES.values():
self.assertEqual(len(value), 64)
int(value, 16) # must be hex
+105 -8
View File
@@ -96,19 +96,83 @@ CODE_SEARCH_DOMAINS = (
)
def _is_code_domain(url):
"""True when url's host is in (or under) CODE_SEARCH_DOMAINS."""
def _is_code_domain(url, domains=None):
"""True when url's host is in (or under) ``domains``.
Defaults to CODE_SEARCH_DOMAINS. Matching is dot-boundary:
the host must equal a listed domain or end with "." + domain,
so evilgithub.com never matches github.com and
github.com.evil.com never matches either.
"""
if domains is None:
domains = CODE_SEARCH_DOMAINS
try:
host = (urllib.parse.urlparse(url).hostname or "").lower()
except Exception:
return False
host = host.rstrip(".")
for domain in CODE_SEARCH_DOMAINS:
for domain in domains:
if host == domain or host.endswith("." + domain):
return True
return False
def _normalize_allowed_hosts(allowed_hosts):
"""Validate a task-level code-scope extension list.
Returns the normalised extra domains (lowercased, trailing dot
stripped, builtin duplicates dropped, order preserved). Anything
else raises ValueError before any HTTP happens -- never silently
degrading to the builtin list or to an unscoped search:
- None is not valid here; callers pass it through only by
leaving ``search(..., allowed_hosts=None)`` unset.
- a bare string is rejected (it is iterable, so accepting it
would silently treat "example.com" as a list of characters);
- an empty list is rejected (empty means "no extension", which
is spelled by omitting the argument, not by passing []);
- each entry must be a bare hostname: no scheme, port, path,
userinfo, wildcard, whitespace, leading dot or empty label.
Extension values are never written to logs: this function only
validates, and search() never formats the list into an error
string or an attempted entry (the effective list is echoed
back to the caller in its own result's ``code_domains`` only).
"""
if isinstance(allowed_hosts, str) or not isinstance(
allowed_hosts, (list, tuple)):
raise ValueError(
"allowed_hosts must be a list of hostnames, not %r"
% (type(allowed_hosts).__name__,))
if not allowed_hosts:
raise ValueError(
"allowed_hosts must be non-empty; omit the argument "
"for no extension")
seen = set()
out = []
for entry in allowed_hosts:
if not isinstance(entry, str):
raise ValueError(
"allowed_hosts entries must be strings, not %r"
% (type(entry).__name__,))
host = entry.strip().lower().rstrip(".")
if (not host or host.startswith(".") or ".." in host
or any(c in host for c in "/:?#@[] \t")
or "*" in host or host in CODE_SEARCH_DOMAINS):
if host in CODE_SEARCH_DOMAINS:
continue
raise ValueError(
"invalid allowed_hosts entry: %r (expected a bare "
"hostname such as 'example.com')" % (entry,))
if host not in seen:
seen.add(host)
out.append(host)
if not out:
raise ValueError(
"allowed_hosts adds no new domain beyond the builtin "
"list; omit the argument for no extension")
return out
def _normalize_scope(scope):
"""Map scope to "general" (default) or "code"; reject anything else.
@@ -243,11 +307,19 @@ def _provider_perplexity(query, limit, env, http, domains=None):
# shape instead of degrading to a link list.
# Native domain filter: top-level search_domain_filter (Sonar chat
# completions; allowlist mode, max 20 domains -- CODE_SEARCH_DOMAINS
# has 15, so it fits whole).
# has 15, so the builtin list fits whole; see the over-cap
# handling below for task-level extensions).
payload = {"model": "sonar",
"messages": [{"role": "user", "content": query}]}
if domains:
payload["search_domain_filter"] = list(domains)
# search_domain_filter caps at 20 domains. The builtin list
# is 15, but a task-level allowed_hosts extension can push
# the effective list past 20; sending an over-cap filter
# would fail the provider call, so in that case the native
# filter is skipped and search()'s post-filter -- which
# always applies -- remains the authoritative scope.
if len(domains) <= 20:
payload["search_domain_filter"] = list(domains)
_status, body, _ctype = http.post_json(
"https://api.perplexity.ai/chat/completions",
_default_headers(env, "perplexity"),
@@ -502,7 +574,7 @@ def resolve_provider_chain(provider="auto", env=None):
def search(query, limit=5, provider="auto", env=None, http=None,
scope="general"):
scope="general", allowed_hosts=None):
"""Search the web. Returns {provider, results, answer, attempted,
scope} (plus code_domains when scope="code").
@@ -517,6 +589,20 @@ def search(query, limit=5, provider="auto", env=None, http=None,
Default scope="general" behaves exactly as before (no domain
filter, no post-filtering).
``allowed_hosts`` is a task-level, explicit extension of the
code list only -- it is NOT a general "search any site" switch.
It is honoured only with scope="code"; passing it with
scope="general" raises ValueError instead of being silently
ignored, and omitting it leaves behaviour exactly as before
(builtin 15 domains only). When given, the effective list is
builtin + validated extras, and the same native-filter /
post-filter split applies: native-capable backends receive the
effective list (Perplexity skips its native filter when the
effective list exceeds its 20-domain cap, relying on the
post-filter), and backends without a native filter are scoped
by post-filtering alone. Extension values are never formatted
into error strings or attempted entries.
Raises WebSearchError (with .attempted) when every provider in
the chain fails or returns no results. Error strings never contain
API keys; see the module docstring.
@@ -524,7 +610,17 @@ def search(query, limit=5, provider="auto", env=None, http=None,
if not query or not str(query).strip():
raise ValueError("query must be a non-empty string")
scope = _normalize_scope(scope)
domains = list(CODE_SEARCH_DOMAINS) if scope == "code" else None
if allowed_hosts is not None and scope != "code":
raise ValueError(
"allowed_hosts is only valid with scope=\"code\" "
"(got scope=%r); it is an extension of the code-domain "
"list, not a general search override" % (scope,))
if scope == "code":
domains = list(CODE_SEARCH_DOMAINS)
if allowed_hosts is not None:
domains.extend(_normalize_allowed_hosts(allowed_hosts))
else:
domains = None
env = env if env is not None else os.environ
http = http or HttpClient(
timeout=int(env.get("WEBSEARCH_TIMEOUT", "20") or 20))
@@ -549,7 +645,8 @@ def search(query, limit=5, provider="auto", env=None, http=None,
"error": "no results"})
continue
if scope == "code":
kept = [r for r in results if _is_code_domain(r.get("url"))]
kept = [r for r in results
if _is_code_domain(r.get("url"), domains)]
if not kept:
attempted.append(
{"provider": name, "ok": False,
+30 -2
View File
@@ -42,7 +42,12 @@ _TOOLS = [
"only (official language docs, GitHub, package "
"registries, Stack Overflow, CVE/NVD); anything else "
"is filtered out, never returned. Default scope "
"'general' is unfiltered. This tool does not do "
"'general' is unfiltered. With scope 'code' only, "
"allowed_hosts may name extra project domains for "
"this call (explicit allowlist extension, subdomains "
"included, dot-boundary matched); it is not an "
"arbitrary-site search, and it is rejected with "
"scope 'general'. This tool does not do "
"shopping, social, places, travel, general news, "
"weather, finance, or browser checkout."
),
@@ -61,6 +66,21 @@ _TOOLS = [
"search. code: programming "
"sources only, see tool "
"description."},
"allowed_hosts": {"type": "array",
"items": {"type": "string"},
"description": "Only with scope "
"'code': extra "
"domains for this "
"call only, on top "
"of the builtin "
"programming list. "
"Omit for no "
"extension; empty, "
"wildcard, or "
"non-hostname "
"values are "
"rejected, never "
"silently ignored."},
},
"required": ["query"],
},
@@ -139,11 +159,19 @@ def handle_request(request, search_fn=None, fetch_fn=None):
args = params.get("arguments") or {}
try:
if name == "web_search":
# allowed_hosts is passed through only when the
# caller supplied it, so older search_fn fakes
# without that parameter keep working.
search_kwargs = {}
if "allowed_hosts" in args:
search_kwargs["allowed_hosts"] = \
args.get("allowed_hosts")
result = search_fn(
args.get("query"),
limit=args.get("limit", 5),
provider=args.get("provider", "auto"),
scope=args.get("scope", "general"))
scope=args.get("scope", "general"),
**search_kwargs)
elif name == "web_fetch":
result = fetch_fn(
args.get("url"),
+10 -3
View File
@@ -2765,9 +2765,10 @@ class Worker:
# 与 imagegen 同款:播种失败直接抛错、任务不派发
# ——静默无浏览器比 loud 失败更糟,尤其对已宣传
# “能开浏览器”的任务。代理与浏览器路径由
# _prepare_tenant 的 env 块给。MCP 脚本是系统文件:
# 播种器在版本变化时刷新老租户的旧脚本(见
# ensure_codex_home_playwright),刷新记录打日志。
# _prepare_tenant 的 env 块给。播种器只对已证明
# 未改动的租户脚本自动合并新版(见
# ensure_codex_home_playwright);租户改过的脚本
# 绝不覆盖,只记审计并打 warning 留待人工处理。
_seeded = _ensure_playwright_mcp(
dirs["codex_home"],
command_args=[
@@ -2779,6 +2780,12 @@ class Worker:
"playwright MCP scripts refreshed for project %s: %s",
task.get("project_id"),
",".join(_seeded["script_refreshed"]))
if _seeded.get("script_skipped_modified"):
self.logger.warning(
"playwright MCP scripts skipped (tenant-modified, "
"left untouched, see audit) for project %s: %s",
task.get("project_id"),
",".join(_seeded["script_skipped_modified"]))
if self.seed_skills:
# 技能播种失败绝不阻塞任务:技能是可选的流程
# 说明,不是 websearch 那种“已宣传就必须有”的
+4 -1
View File
@@ -14,7 +14,10 @@ EnvironmentFile=/etc/codex-app-server/env
# scaler 会按实例强制追加;--db 只给 scaler,scaler 会补给 worker。
# 注意:启用 autoscaler 后必须停掉静态 codex-cloud-worker,
# 否则两边同时派任务(scaler 只管理 autoscale-* 实例)。
ExecStart=/usr/bin/python3 -m cloud.autoscaler --db /var/lib/codex-app-server/control.db --interval 30 --min-workers 1 --max-workers 2 --per-worker-capacity 2 --scale-down-after 3 --drain-timeout 60 --state-file /var/lib/codex-app-server/autoscaler-state.json --log-dir /var/lib/codex-app-server/autoscaler-logs -- --interval 10 --config /opt/codex-app-server/host-config-docker.json --executor docker --docker-image codex-app-server:latest --docker-user 1003:1004 --docker-cpus 2 --docker-memory 2g --docker-extra-mounts /opt/codex-app-server/work:/app/work,/var/lib/codex-app-server/codex-home:/var/lib/codex-app-server/codex-home,/var/lib/codex-app-server/repos:/var/lib/codex-app-server/repos,/var/lib/codex-app-server/test-repo:/var/lib/codex-app-server/test-repo --repos-dir /var/lib/codex-app-server/repos --tenant-isolation --tenants-root /var/lib/codex-app-server/tenants --tenant-template-home /var/lib/codex-app-server/codex-home --tenant-template-repo /var/lib/codex-app-server/test-repo --docker-security-opt apparmor=docker-codex --docker-pids-limit 512 --egress-proxy-port 8888 --docker-runtime runsc --websearch-mcp
# worker 侧 MCP/技能播种四项与生产主机现行 unit 一致
# (--websearch-mcp --seed-skills --imagegen-mcp --playwright-mcp);
# 主机 unit 再改时须同步改这里,deploy/tests/test_autoscaler_unit.py 会拦漂移。
ExecStart=/usr/bin/python3 -m cloud.autoscaler --db /var/lib/codex-app-server/control.db --interval 30 --min-workers 1 --max-workers 2 --per-worker-capacity 2 --scale-down-after 3 --drain-timeout 60 --state-file /var/lib/codex-app-server/autoscaler-state.json --log-dir /var/lib/codex-app-server/autoscaler-logs -- --interval 10 --config /opt/codex-app-server/host-config-docker.json --executor docker --docker-image codex-app-server:latest --docker-user 1003:1004 --docker-cpus 2 --docker-memory 2g --docker-extra-mounts /opt/codex-app-server/work:/app/work,/var/lib/codex-app-server/codex-home:/var/lib/codex-app-server/codex-home,/var/lib/codex-app-server/repos:/var/lib/codex-app-server/repos,/var/lib/codex-app-server/test-repo:/var/lib/codex-app-server/test-repo --repos-dir /var/lib/codex-app-server/repos --tenant-isolation --tenants-root /var/lib/codex-app-server/tenants --tenant-template-home /var/lib/codex-app-server/codex-home --tenant-template-repo /var/lib/codex-app-server/test-repo --docker-security-opt apparmor=docker-codex --docker-pids-limit 512 --egress-proxy-port 8888 --docker-runtime runsc --websearch-mcp --seed-skills --imagegen-mcp --playwright-mcp
Restart=on-failure
RestartSec=10
# 停 scaler 时要等它把 worker 优雅排空(drain 60 秒 + 缓冲),
+94
View File
@@ -0,0 +1,94 @@
"""autoscaler unit 与生产主机现行 unit 的同步回归。
背景:仓库版 deploy/autoscaler/codex-autoscaler.service 的 ExecStart
长期只带 --websearch-mcp,而生产主机现行 unit 在此之外还带
--seed-skills --imagegen-mcp --playwright-mcp(2026-11-01 主机只读核对:
主机 ExecStart = 仓库版 ExecStart + 这三项,别无差异)。
仓库版滞后意味着按仓库重装会静默丢掉技能播种与两路 MCP。
本文件只读仓库内两个文件,不碰主机、不联网、不跑 worker:
- unit 的 worker 侧(`--` 之后)必须含全部四项 MCP/技能播种 flag;
- worker 侧所有 --flag 必须在 cloud/worker.py 里有 add_argument 定义,
防止拼写漂移导致 worker 启动即报未知参数;
- 四项 flag 绝不能出现在 scaler 侧(`--` 之前),它们是 worker 参数。
"""
import os
import unittest
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
UNIT_PATH = os.path.join(REPO_ROOT, "deploy", "autoscaler",
"codex-autoscaler.service")
WORKER_PATH = os.path.join(REPO_ROOT, "cloud", "worker.py")
# 生产主机现行 unit 带的 worker 侧 MCP/技能播种四项。
MCP_SEED_FLAGS = ("--websearch-mcp", "--seed-skills",
"--imagegen-mcp", "--playwright-mcp")
def _execstart():
with open(UNIT_PATH, encoding="utf-8") as f:
lines = [ln.rstrip("\n") for ln in f]
starts = [ln for ln in lines if ln.startswith("ExecStart=")]
assert len(starts) == 1, "unit 应恰好有一行 ExecStart,实得 %d 行" % len(starts)
return starts[0][len("ExecStart="):]
def _split_sides():
cmd = _execstart()
assert cmd.startswith("/usr/bin/python3 -m cloud.autoscaler"), \
"unit 应以 cloud.autoscaler 启动,实得:%s" % cmd[:60]
parts = cmd.split(" -- ", 1)
assert len(parts) == 2, "ExecStart 缺少 scaler/worker 分隔符 ' -- '"
return parts[0], parts[1]
def _flag_tokens(side):
"""side 里以 -- 开头的 token(去重前顺序保留,含 =value 形式剥离)。"""
flags = []
for tok in side.split():
if tok.startswith("--"):
flags.append(tok.split("=", 1)[0])
return flags
class TestAutoscalerUnitSync(unittest.TestCase):
def test_worker_side_includes_all_four_mcp_seed_flags(self):
_, worker_side = _split_sides()
flags = _flag_tokens(worker_side)
for flag in MCP_SEED_FLAGS:
self.assertIn(flag, flags,
"worker 侧缺 %s,仓库 unit 已滞后于生产主机" % flag)
def test_worker_side_flags_have_no_duplicates(self):
_, worker_side = _split_sides()
flags = _flag_tokens(worker_side)
self.assertEqual(len(flags), len(set(flags)),
"worker 侧 flag 重复:%s" % (flags,))
def test_worker_side_flags_are_defined_in_worker_parser(self):
_, worker_side = _split_sides()
with open(WORKER_PATH, encoding="utf-8") as f:
worker_src = f.read()
for flag in _flag_tokens(worker_side):
self.assertIn('add_argument("%s"' % flag, worker_src,
"%s 未在 cloud/worker.py 定义,worker 会报未知参数"
% flag)
def test_mcp_seed_flags_not_on_scaler_side(self):
scaler_side, _ = _split_sides()
flags = _flag_tokens(scaler_side)
for flag in MCP_SEED_FLAGS:
self.assertNotIn(flag, flags,
"%s 不应出现在 scaler 侧(`--` 之前)" % flag)
def test_worker_side_keeps_docker_runtime_runsc(self):
_, worker_side = _split_sides()
tokens = worker_side.split()
self.assertIn("--docker-runtime", tokens)
self.assertEqual(tokens[tokens.index("--docker-runtime") + 1], "runsc")
if __name__ == "__main__":
unittest.main()
+101 -1
View File
@@ -82,9 +82,109 @@
## 本轨补充:多标签页、导航白名单、关闭清理
- 多标签页:`browser_new_page`(开新页并切为当前页)、`browser_list_pages`(未启动时不启动、返回空列表)、`browser_switch_page`(按 index 切页,不存在时报明确错误、当前页不变、旧页 refs 清空)、`browser_close_page`(缺省关当前页,关后自动切上一页;关最后一页报错“use browser_close”)。现有单页方法全部作用于当前页、行为不变。
- 导航白名单:`BROWSER_ALLOWED_HOSTS`(逗号列表,前导点后缀匹配,大小写与端口不影响匹配)。未设置时行为不变;设置后在启动 Chromium 之前检查,未命中直接报错且不启动;`file://` 一律放行;`localhost/127.0.0.1` 无特殊待遇、须显式列出(本地开发服务器要自己加 localhost)。注意缺口:白名单只卡 `navigate` 入口,页内链接点击导致的跳转不经过该检查,只能靠 Tinyproxy 代理 403 兜底;接线:worker 在容器启动前把 worker 环境里的 `BROWSER_ALLOWED_HOSTS`(已设置且非空时)写进租户 config 的 `[mcp_servers.playwright.env]`,MCP 进程由此拿到它;未设置时不写该键、白名单不生效(见未验证一节)。
- 导航白名单:`BROWSER_ALLOWED_HOSTS`(逗号列表,前导点后缀匹配,大小写与端口不影响匹配)。未设置时行为不变;设置后在启动 Chromium 之前检查,未命中直接报错且不启动;`file://` 一律放行;`localhost/127.0.0.1` 无特殊待遇、须显式列出(本地开发服务器要自己加 localhost)。接线:worker 在容器启动前把 worker 环境里的 `BROWSER_ALLOWED_HOSTS`(已设置且非空时)写进租户 config 的 `[mcp_servers.playwright.env]`,MCP 进程由此拿到它;未设置时不写该键、白名单不生效(见未验证一节)。
- 页内跳转白名单(本轮补齐,上一轮的缺口已收窄、未归零):`browser_click` / `browser_click_ref` 在目标是带 href 的链接时先解析 href(相对链接按当前页展开)再做同款检查,未命中报错且点击不执行;`click` / `click_ref` / `click_at` 在点击后都会检查落点 URL,落到 blocked host 时报错。已拦住的:navigate、带 href 的 click / click_ref(事前)。确实拦不住的(经代码确认,不硬编检查):`click_at` 的事前检查(坐标对应不了元素,elementFromPoint 探针受视口/滚动影响、且漏掉 JS 驱动的跳转,故只做事后检查);无 href 目标的点击(按钮、JS 处理函数,事前无目的地);页内 JS 自行跳转(`location.href`、表单提交、SPA 路由、回车提交)、`browser_evaluate` 内的任意 JS、meta refresh、弹窗新页(`page.click` 不跟随新页)。这些路径的兜底只能是 Tinyproxy 代理 403:当白名单与代理白名单取同一域名集合时,被挡域名在代理层就加载失败;但 evaluate 与 shell 同权,白名单不是它的沙箱边界。测试:`cloud/tests/test_playwright_allowlist_click.py`(13 项,fake browser,不联网):命中/未命中/后缀 trick(`notpython.org`、`python.org.evil.com`)/端口/大小写/file/localhost 正反例、事前拒点击不执行、事后落点报错、未设置时行为不变。
- 关闭清理:`browser_close()` 关掉全部页并尽力删除 cwd 下的 `browser-live.png` 与 `browser-live.json`(删失败不报错);自定义文件名的截图文件不在清理范围内。
## 本轮补齐:老租户脚本安全升级
上一轮的播种器是“版本变化就刷新一切不同脚本”;本轮改为
安全升级(`cloud/playwright_browser.py` 的
`ensure_codex_home_playwright`,worker 播种时调用):
- 自动合并(只对已证明未改动者):播种 manifest
(租户 codex-home 里的 `playwright_scripts_manifest.json`,
播种器自己的元数据,不是租户数据)命中、或命中 2a569ac
已发布三脚本的 SHA-256(`KNOWN_SHIPPED_SCRIPT_HASHES`,
已对 origin/main blob 验过哈希)、或文件缺失(全新播种)、
或已是最新(不写)。
- 绝不覆盖:内容已改动、且两项证明都不命中的脚本,原样
不动、列入返回值的 `script_skipped_modified`(兼容旧字段
`script_differs`)、在租户 codex-home 的
`playwright_scripts_audit.jsonl` 里记一条
`skipped_modified` 审计(脚本名、租户哈希、新版哈希),
worker 同时打 warning 日志;留待人工处理。
- 幂等与防误判:重跑不重复写(已是最新不写、审计按
脚本+租户哈希去重);跳过时 manifest 绝不记录租户哈希
——否则下次升级会把改动过的脚本误判为“未改动”而覆盖
(本轮修掉的正是这一处隐患,见测试
`test_modified_rerun_does_not_rewrite_or_duplicate_audit`)。
- 升级不删除任何租户文件、不改租户已有配置语义:
`[mcp_servers.playwright]` 只在缺失时追加,现有配置段
原样保留;manifest 与审计文件只是播种器自己的附加元数据。
- 测试:`cloud/tests/test_playwright_upgrade.py`(8 项):
全新播种、未变脚本自动合并、已变脚本原样不动且有审计、
幂等重跑不重复写、无 manifest 老租户(已知哈希合并 /
未知哈希跳过)、租户数据与配置不受影响。
## 本轮补齐:BROWSER_ALLOWED_HOSTS 激活方案(本轮不执行主机激活)
候选值(域名集合 = 已部署的 Tinyproxy 编程域名集合,不编
新清单;编码用前导点形式,因为 filter 行本身就是子域名
包含式,见下文编码说明):
```
BROWSER_ALLOWED_HOSTS=.python.org,.nodejs.org,.go.dev,.pkg.go.dev,.rust-lang.org,.developer.mozilla.org,.crates.io,.stackoverflow.com,.cve.mitre.org,.nvd.nist.gov,.github.com,.pypi.org,.npmjs.org
```
编码说明(关键,不可照抄纯域名版):filter 行
`^\(.*\.\)*python\.org$` 放行 `python.org` 及其全部子域名,
而白名单的不带点条目 `python.org` 只精确匹配自身、不放行
`docs.python.org`;所以候选值必须用前导点条目,否则激活后
`docs.python.org` / `docs.rust-lang.org` 会被白名单挡掉、
相对代理白名单是回归(测试
`test_plain_entries_would_break_subdomains` 已证明这一点)。
出入说明:任务文本写的是 `npmjs.com`,但已部署 filter
(`deploy/egress/tinyproxy-filter:23`)、`deploy/new-host-checklist.md`
与本文档旧文均为 `npmjs.org`,候选值按已部署集合取
`npmjs.org`,`npmjs.com` 不在候选值内;如需放行 npm 官网
本身,那是另起的 filter 变更,不由本轮擅自决定。
测试租户/测试任务层面验证(本轮已做,全部 mock/fake,
未碰生产租户):`cloud/tests/test_playwright_activation.py`
(7 项):候选值域名集合与已部署 filter 一致;裸域名与子
域名放行、后缀 trick 与圈外域名拦截;file 放行、localhost
在候选值下被挡(见行为变更);fake 任务级 navigate 放行与
拦截(拦截时不启动 Chromium);未设置时行为不变。
对已有浏览器任务的行为变更:
- 未设置 `BROWSER_ALLOWED_HOSTS`:行为完全不变(只靠代理
403),已有任务不受影响。
- 设置候选值后:navigate 与带 href 的 click / click_ref 在
命中时不变、未命中时事前报错(navigate 不启动浏览器、
click 不执行);无 href 点击与 click_at 只在事后落点检查;
file:// 一律放行;localhost / 127.0.0.1 被挡——本地开发
服务器任务须在值里另加 `localhost`,否则会新报错,这是
已知行为变更,不是 bug。
- JS 自行跳转等拦不住的路径:行为上只剩代理 403 兜底,
与激活前一致(激活前也是代理 403)。
主机激活步骤(只交付步骤,本轮不执行):
1. 在主机 worker/service 的环境里设置上述
`BROWSER_ALLOWED_HOSTS`(与 worker 透传链路一致:worker
环境变量 → 租户 config 的 `[mcp_servers.playwright.env]`,
未设置时不写该键)。
2. 重启 worker 相关 unit(只重启、不改 unit 文件与 filter)。
3. 用一个测试任务验证:navigate 到 `docs.python.org` 成功、
navigate 到圈外域名事前报错且不启动浏览器、click 圈外
链接事前拒点、localhost 任务按预期被挡(或按需加
`localhost` 后放行)。
回滚:清空该变量(从 worker 环境里删除
`BROWSER_ALLOWED_HOSTS` 并重启相关 unit),即回到未设置
行为——已有租户 config 里的旧 env 块由下次任务准备时的
`ensure_playwright_mcp_env` 覆盖(未设置时不写该键;注意:
已写入旧值的租户 config 需一次任务准备刷新后才彻底回到
未设置行为,见 worker 透传说明)。回滚不改 filter、不删
租户数据。
是否执行主机激活:本轮不执行。执行条件是本轮测试通过
(本轮测试已通过,见验证一节)且由 parent/用户拍板——
测试不通过时只交付方案与回滚、不强行激活;本轮测试通过
不等于主机已激活,主机激活仍待明确指令。
## 安全硬线
- 任何真实密码、cookie、token 绝不写进代码、测试、文档、回执。
+120 -2
View File
@@ -17,6 +17,12 @@ pi 的工具),只复用它的模式:统一工具 + provider 抽象 +
mock HTTP,不联网、不需要真实 key。
- `cloud/tests/test_code_search.py`:编程作用域与抽取回退链
的单测,同样全部 mock HTTP、不联网、不需要真实 key。
- `cloud/tests/test_code_search_ext.py`:任务级
`allowed_hosts` 扩展的单测(指定 / 未指定 / 点边界 /
空值与非法值 / Perplexity 超 20 域 / MCP 透传)。
- `cloud/tests/test_js_heavy_extract.py`:JS 重型页面
抽取评估的 mock 单测(直接抓取壳-only、provider 抽取
补与不补、回退语义;live SearXNG 烟囱默认跳过)。
## 工具
@@ -163,6 +169,50 @@ changelog / release notes 只有落在上述域名时才在
results`),链继续走下一个后端;全部后端都空才
报错。
### 清单外项目官网域:任务级显式扩展 `allowed_hosts`
这不是“任意站搜索”,仍是显式白名单机制,只是白名单
可以由调用方在当次调用里显式加域。选型是任务级参数
`(a)`,不是配置级扩展清单 `(b)`,理由:改动最小
(只动 `search()` 签名与 MCP 透传,不动 worker、
unit、env、播种与主机配置);按调用隔离(某任务要
某项目官网,不会把该域放宽给所有任务——配置级清单
一旦写入就是全局放宽,安全姿态更差);用完即弃
(不持久化,下次调用不带就不生效)。
用法:`search(..., scope="code",
allowed_hosts=["docs.example-project.dev"])`,MCP
侧 `web_search` 的 `allowed_hosts` 数组字段同名透传。
规则(全部由 `cloud/websearch.py` 的
`_normalize_allowed_hosts` 在发任何 HTTP 之前强制):
- 只在 `scope="code"` 下生效;`scope="general"` 下传
`allowed_hosts` 直接报 `ValueError`,绝不静默忽略;
不传时行为与本版之前完全一致(仍是 15 域)。
- 生效清单 = 内置 15 域 + 本次显式 extras(去重、保序);
返回结果的 `code_domains` 即为该生效清单。
- 匹配仍是点边界:子域名算(`api.docs.example-project.dev`
算),`evildocs.example-project.dev` 与
`docs.example-project.dev.evil.com` 都不算。
- 空值 / 非法值报错不静默退化:`[]`、裸字符串
(可迭代但不是域名列表)、`*` 通配、带 scheme /
端口 / 路径 / userinfo 的值、前导点、空标签、非字符串
元素、以及全部等于内置域(即“扩展了个寂寞”)都报
`ValueError`,且此时尚未发任何 HTTP。
- 扩展值绝不写进日志:错误串与 `attempted` 条目里不
出现扩展值;扩展值只出现在调用方自己那次结果的
`code_domains` 回显里(调用方自己指定的值回显给
自己,不是日志)。
- 与原生后端限定方式的关系,和现有逻辑一致:原生
支持的后端(Tavily / Exa 的 `include_domains`)直接传
生效清单;Perplexity 的 `search_domain_filter` 上限
20 个域,内置 15 + 扩展超 20 时跳过其原生过滤、只靠
事后过滤(事后过滤永远是权威,语义不变);原生不
支持的后端(Brave、SearXNG、DuckDuckGo)本来就不传
参数,只靠 `search()` 返回后的统一事后过滤。
- `web_fetch` 不受 `allowed_hosts` 影响:fetch 本来就是
按指定 URL 抓取,没有域名白名单可扩展。
### 抓取回退链与 403 语义(web_fetch)
`web_fetch` 实际调 `fetch_best`,顺序固定:
@@ -230,5 +280,73 @@ filter 行须用 POSIX BASIC 正则(`^\(.*\.\)*域名$`)、
主机 unit / filter / env 接线、GitHub API token
(本设计走网页搜索后端限定域名,不调 GitHub API,
所以不需要 token;真要 GitHub 代码内容搜索 API 是
另一功能,不在本轨)、清单外项目官网域、老租户
脚本升级。
另一功能,不在本轨)、老租户脚本升级。清单外项目
官网域本轨已做(见上节 `allowed_hosts`,本地提交、
未推未部署),仍缺真实 key 的 live 验证。
## JS 重型站点抽取效果评估(本轨新增,mock 为主)
评估对象是已有抽取链,不改默认抽取顺序、不因评估换
后端:`fetch_best` 固定为 Tavily extract → Exa contents
→ 直接抓取。结论只写在这里,不改代码行为。
测试文件:`cloud/tests/test_js_heavy_extract.py`(全部
mock HTTP,只有一个显式开关的 live 烟囱测试,默认跳过)。
### 阶段 1:直接抓取能拿到什么、拿不到什么
直接抓取是标准库 HTML 解析:`<script>` / `<style>` /
`<noscript>` 内容一律剥离,只留静态文本与绝对 http(s)
链接(相对链接不进 `links` 列表)。所以:
- 能拿到:静态壳里的标题、占位文本(如 "Loading…")、
静态正文、绝对链接;首屏静态内容本身就是正文的页面
不受影响。
- 拿不到:正文靠 JS 渲染的段落(mock 壳里
`RENDERED-BODY-PARAGRAPH` 只存在于 `<script>` 数据块
中,直接抓取结果里没有它,也不把数据块原文泄漏进
文本);分页靠 JS 加载的后续项(mock 里第 2 页
`ITEM-PAGE-2` 只经 `/api/items?page=2` 到达,直接抓取
只有第 1 页静态项,也拿不到该 API 地址本身)。
- 为什么:解析器没有 JS 引擎,也从不执行脚本或追
JS 发起的请求;这是结构性缺失,不是解析器 bug,
调解析参数补不回来。直接抓取在 JS 重型站上只能
给出“壳 + 首屏静态部分”,不能谎称是全文。
### 阶段 2:provider 抽取在 mock 层面能补什么、补不了什么
mock 层面(伪造的 Tavily extract 响应返回完整渲染文本)
证明链路本身能补:`fetch_via_provider` 返回
`source=tavily-extract` 的完整文本;抽取与直接都失败时
`fetch_best` 回退到直接抓取的壳(`source=direct-fetch`),
仍拿不到渲染正文——回退保证的是“不断链”,不是“补全文”。
mock 补不了、也证明不了的部分:
- 真实 provider 是否真的渲染该站的 JS(取决于各家
extract 实现是否跑无头浏览器、等多久、反爬策略),
mock 响应是“我假设它渲染了”,不是实测证据。
- JS 分页 / 交互(点“加载更多”、登录后内容、滚动加载)
provider 抽取一般只取单 URL 的渲染结果,不会替调用方
翻页或交互;多页内容仍需逐 URL fetch。
- 抽取返回空时是失败,不是空页:`fetch_via_provider`
空结果报 `WebSearchError`,不伪造页面;但 `fetch_best`
随后回退直接抓取会返回壳文本——调用方须以 `source`
字段分辨“渲染全文”与“壳”,不能只看 `text` 非空就当全文。
### Live 对照(与 mock 分开写,未验证的不写成已验证)
- 无 key 的 SearXNG 路线:本轨在 VM 测试环境实测一次
(`SEARXNG_URL=https://searx.diante.me`,`provider="searxng"`,
产品自身 `search()` 代码):失败,`URLError: Remote end
closed connection without response`。这是在本 VM 出口下
的一次结果,不能据此断言 SearXNG 服务本身挂了(生产
文档记载主机侧经代理 pinned 引擎曾 0.47s 通),live
对照在本测试环境记为**未完成**,需在主机侧可达的环境
重测;测试文件里的 live 烟囱测试默认跳过,只在
`WEBSEARCH_EVAL_LIVE_SEARXNG=1` 且配了 `SEARXNG_URL`
时才跑。
- 付费后端 live 效果(Tavily extract / Exa contents /
Perplexity / Brave 的真实 JS 渲染能力):无 key,本轨
**一律未验证**,不编造效果,逐项列为 blocker(见回执),
在拿到 key 之前,文档里关于 provider 渲染能力的任何
表述都只是 mock 层面的链路结论。