- Multi-tab: browser_new_page / browser_list_pages /
browser_switch_page / browser_close_page in playwright_mcp.py;
each new page gets a fresh Playwright context, so cookies and
storage are isolated between pages. Single-page tools act on
the current page; switching to a missing index is an error,
not a crash; closing the last page is an error (use
browser_close).
- Navigation allowlist: BROWSER_ALLOWED_HOSTS (comma list,
leading-dot suffix match, case/port-insensitive) checked in
_check_allowed_url before launching Chromium; unset (or empty)
means no check, the old behaviour. file:// always allowed;
localhost/127.0.0.1 have no special treatment.
- Close cleanup: browser_close closes all pages and best-effort
removes cwd browser-live.png / browser-live.json; custom
screenshot files are untouched. list_pages needs no launch.
- Egress filter: 10 programming-source Tinyproxy BASIC rules
(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); already-present github.com /
pypi.org / npmjs.org not duplicated; searx.diante.me stays
exact-only, no diante.me wildcard.
- Allowlist worker wiring: _docker_task_context now passes a
set, non-empty BROWSER_ALLOWED_HOSTS from the worker env into
[mcp_servers.playwright.env] in the tenant config, so the MCP
process actually sees it (container -e alone does not reach
MCP subprocesses, 2026-10-30). Unset/empty writes nothing and
never overwrites; the value never goes into logs; it is not
added to the websearch env block or the container env.
ensure_playwright_mcp_env now returns False without rewriting
when the result would be identical (canonical join: one blank
line before the block), so a second prep leaves an unchanged
tenant config alone; other sections untouched.
- Skill + docs: browser-automation SKILL.md multi-tab/allowlist
section; docs/platform-browser.md comparison doc (new file).
- Tests (mock/fake only, no network, no real Chromium, no real
credentials): cloud.tests.test_playwright +
cloud.tests.test_websearch_worker, 48 tests OK, including 6
new wiring tests (switch off passes no variable, switch on
reaches MCP env verbatim, empty never written, old-tenant
merge idempotent + no-rewrite, ensure no-op when identical,
allowlist enforces only after wiring).
- NOT done (see PR body): no persistent profile, no credential
vault, no approval card, no CAPTCHA bypass, in-page link
navigation does not pass the allowlist, no real-Chromium live
verification, host tinyproxy filter not reloaded.
Worker --websearch-mcp (default off) seeds tenant codex-home with
websearch MCP on _tenant_dirs (container path); old tenants merge
on next task. Autoscaler unit gains the flag. Tinyproxy filter
adds Perplexity/Exa/Tavily/Brave/DuckDuckGo + exact
searx.diante.me (no diante.me wildcard, control plane stays out).
Tests: test_websearch_worker 4 + websearch_mcp 12 + tenant 7 = 23 OK.
Not pushed, not merged, not deployed; SearXNG host/DNS/nginx and
tinyproxy reload are host steps.
- AppArmor network rules must use socket types (stream/dgram),
not protocol names (tcp/udp/icmp): the parser rejected the
profile (Invalid network entry) until fixed.
- Tinyproxy has no 'Upstream none' syntax; the line failed the
service at startup. Direct connection is the default, so the
line is removed.
- Tinyproxy Filter is POSIX BASIC regex, not extended: the
extended-style anchored patterns denied even allowed domains
(verified: xcmapi.org got 403). Rewritten in basic syntax
(\(...\)* groups); the anchored form still blocks
evilgithub.com and xcmapi.org.evil.com (verified on host).
- Filter file must be world-readable (was 640 root:root, so
tinyproxy could not read it and exited on reload); install
steps now chmod 644 (documented in the filter header via
the install note in tinyproxy.conf).
Host already runs these exact files (proxy allow/block
verified: xcmapi/github/api.github 200, example/evilgithub/
xcmapi.org.evil.com 403).
Inner codex sandbox stays danger-full-access by choice (bwrap
path is a separate decision); this hardens everything around it:
- DockerExecutor: --security-opt plumbing (repeatable, dedups
no-new-privileges), --pids-limit (CLI default 512, fork-bomb
guard), per-call extra_env (-e NAME=VALUE, non-secrets only,
e.g. proxy URLs; the model key still goes by -e NAME only).
- Worker: --egress-proxy-port (0 = off). When on, Docker task
containers get HTTP(S)_PROXY pointing at the host filtering
proxy via their network gateway IP (tenant network gateway
or default bridge gateway). Gateway unresolvable => the
attempt fails instead of running unproxied (fail closed,
same rule as tenant network creation failure).
- AppArmor profile deploy/apparmor/docker-codex: docker-default
base + deny network raw/packet, deny ptrace, deny mount/
umount/pivot_root, deny dangerous capabilities, deny writes
to /proc/sys, /sys, /proc/keys. Deliberately NO userns rule
(that would enable the bwrap path, not this one). File rules
mirror docker-default (broad + sensitive denies); a strict
file allowlist would need profiling codex's file access set
and is not claimed here.
- Egress proxy deploy/egress/: Tinyproxy with
FilterDefaultDeny + anchored domain allowlist (relay,
github, pypi, npm). Anchoring matters: an unanchored
'github.com' regex would also match 'evilgithub.com'.
- Seccomp honesty (README): Docker default seccomp already
enforces on all task containers; no new stricter allowlist
is claimed in this change (building one from memory risks
silently breaking the toolchain; it needs syscall profiling
first).
Tests: 3 outer-hardening tests (security-opt dedup, pids
limit, proxy env without leaking the key value, gateway
parse/fail, proxy fail-closed). Full suites pass.