Skip to content

Browser access for flow agents

Editions: OSS, Cloud, Enterprise. Unless stated otherwise, everything on this page ships in OSS.

This page says what enforces network access for a flow agent that drives a browser, and what does not. The profile that installs the browser is preloop-browser in Execution environments and checkpoint recovery. The MCP endpoint documented in MCP Tool Integration is a different path: it is not where these origins are enforced.

What a flow agent gets

environments/preloop/browser/enable.sh registers a browser MCP server on the harness and starts @playwright/mcp@0.0.82 from the image (/opt/preloop-env-tools/node_modules/.bin/playwright-mcp) with --isolated and --headless. The rendered config (environments/preloop/browser/playwright-mcp.config.json) sets "isolated": true. Chromium therefore starts with an empty profile: no operator cookies and no operator storage state.

The server runs inside the execution container. When the execution ends, that process is killed with the container. The egress proxy is a sidecar of the same execution and is removed with it; see Docker vs Kubernetes.

Where origins are enforced

Origin enforcement is the egress proxy in environments/egress-proxy/. Every plain HTTP request and every CONNECT is checked against EGRESS_ALLOWED_ORIGINS. The proxy then resolves the name itself and denies the request when any answer is private, loopback, link-local, CGNAT, ULA, multicast, or a cloud-metadata address. A name that is on the allowlist is still denied in that case (DNS rebinding). Denied targets are not dialed. The proxy dials an address it already checked.

environments/egress-proxy/README.md is the contract. The checks live in environments/egress-proxy/proxy/config.go and environments/egress-proxy/proxy/proxy_test.go:

  • Allowlist: TestEmptyAllowlistDenies, TestConnectNotAllowlistedLogsDenial.
  • Private-range denial after resolution, including DNS rebinding: TestDNSRebindingPrivateAddress, TestMixedAnswersDenied, TestConnectAllowedDialsCheckedIP.
  • Redirects are not followed. The proxy returns the redirect and does not fetch Location. The browser's next request is a new proxied request and is checked again: TestRedirectIsNotFollowedAndNextRequestIsChecked.

Two Chromium flags make a bypass of that proxy impossible. The proxy README requires both:

--proxy-server=http://egress-proxy:3128
--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE egress-proxy

Without them the browser can open connections that never pass the allowlist. enable.sh renders those flags, plus --proxy-bypass-list=<-loopback> so loopback is not a path around the proxy. environments/preloop/browser/selfcheck.sh refuses to launch unless all three are present, then probes http://169.254.169.254/, https://example.org/, and a listener on 127.0.0.1. The metadata probe is expected to fail before a dial: --host-resolver-rules maps that literal to ~NOTFOUND, so classify() accepts ERR_NAME_NOT_RESOLVED or chrome-error://chromewebdata when no response committed. The hostname and loopback probes must fail with egress_denied or a Chromium proxy/tunnel error. Any other outcome, including a missing proxy variable, exits browser_egress_not_enforced and aborts profile setup.

What is not enforcement

Playwright MCP's --allowed-origins flag is not a network boundary. The README for @playwright/mcp@0.0.82 describes it as:

semicolon-separated list of TRUSTED origins to allow the browser to request. Default is to allow all. Important: does not serve as a security boundary and does not affect redirects.

The same README says Playwright MCP is not a security boundary.

Preloop tool permissions are not a network boundary either. allowed_mcp_tools chooses which tool names a runtime credential can see (MCP Tool Integration). A tool_access_rules row can match call arguments with a CEL expression (condition_type = cel), for example args.url.matches('https://example.com/.*'). backend/preloop/services/policy_evaluator.py (_evaluate_cel_condition) evaluates that expression and returns allow, deny, or require approval. The decision is useful for approvals and audit. It does not open or close a socket, and it does not see traffic the browser sends after the tool call.

Operator responsibilities

Register the proxy image and any fixture image by digest. DependencyService in backend/preloop/services/flow_environment.py accepts only name@sha256:<64 hex digits>. Publishing a digest and registering the profile are operator steps; the proxy image does not register itself (environments/egress-proxy/README.md).

Choose EGRESS_ALLOWED_ORIGINS on the profile's egress-proxy service env. DependencyService.env is fixed when the profile is registered. A flow only selects the profile id, so the allowlist cannot be copied from agent_config (backend/preloop/services/flow_environment.py). The example profile allows http://fixture-site:8080 (environments/preloop/profile.json.example).

EGRESS_ALLOW_PRIVATE_CIDRS is the carve-out for the execution network (the example uses 172.20.0.0/16). Addresses in that list skip the private check. The proxy still denies CONNECT to its own listen port (TestConnectOwnPort, TestAllowPrivateCIDR). Leave EGRESS_DENY_PRIVATE at its default, true, unless you intend to turn the private check off.

On Kubernetes this stacks with the agent-execution NetworkPolicy (helm/preloop/templates/agent-networkpolicy.yaml). The internet egress rule excludes cluster CIDRs and 169.254.169.254 (the chart falls back to 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, and 169.254.169.254/32). Docker has no equivalent policy. There the proxy is the only boundary.

How test credentials enter

Put synthetic values in the profile env or the service env. Do not put operator cookies or a signed-in browser profile in the execution. --isolated starts Chromium with an empty profile, and nothing in enable.sh copies a host browser state.

A fixture site the agent can log in to is specified in issue #905. Use placeholders such as jane@example.com and fixture-pass. That fixture is not part of this page.

Docker vs Kubernetes

profile_env and profile_setup_shell in backend/preloop/services/flow_environment.py rewrite ${service.<name>} to the service name on Docker and to 127.0.0.1 on Kubernetes. Docker attaches the sidecar to a per-execution network with a DNS alias (backend/preloop/agents/container.py, _prepare_environment_services), so the proxy is http://egress-proxy:3128. Kubernetes runs the proxy as a native sidecar in the execution pod (_environment_sidecars), sharing the pod network namespace, so the proxy is http://127.0.0.1:3128. PRELOOP_BROWSER_PROXY on the example profile uses the ${service.egress-proxy} placeholder and follows that rewrite.

Sidecar lifetime matches the execution. On Docker, ContainerExecutor.aclose force-deletes the dependency containers and the execution network. On Kubernetes the sidecar is an init container with restartPolicy: Always, so it stops when the pod stops.

Limits and follow-ups

The sandbox browser server is not routed through the Preloop MCP firewall. backend/preloop/services/dynamic_fastmcp.py derives browser_step rows only for Playwright tools that pass through that firewall (_persist_playwright_browser_step, gated by mcp_playwright_derive_browser_steps). Flow agents therefore do not get browser_step timeline rows from issue #885 for this browser. The control plane cannot sit on the execution network today.

Resuming a browser session or a harness transcript across executions is out of scope. --isolated does not keep cookies or storage for the next run.