本节摘要:本节论证一件事:Agent 的浏览器必须当「半个生产环境」对待,而不是开发者的一个标签页。威胁模型先立起来:浏览器执行着不受信的页面代码(脚本、重定向、下载),模型可能被页面内容诱导执行危险操作(提示注入的浏览器形态),长任务可能吃穿内存与进程数,凭证与会话状态就住在浏览器里。对应四类隔离手段:容器封装(一次性文件系统,用完即焚)、资源限额(内存、CPU、标签页上限)、网络策略(域名白名单的网络层落实,补上 2.2 节应用层白名单的底)、一次性会话(每任务独立 context,互不污染)。然后介绍 steel-browser(steel-dev 出品,约 0.8 万 stars,Apache-2.0,2026-10-16 社区口径):定位是 Agent 浏览器沙箱的自托管实现,常被当作 Browserbase 的自托管替代——会话 API、隐式指纹一致性、COW 快照这类能力以服务形式供给。沙箱化的理论背景引《Strix 精读》第 4 章《工具、沙箱与集成》。示例双语,全部打在自有靶场。
第 2.2 节管住了「模型想做什么」(确认门、白名单),这一节管「浏览器本身会发生什么」。四个风险源:
| 风险源 | 具体形态 | 后果 |
|---|---|---|
| 不受信页面代码 | 恶意脚本、自动下载、挖矿、无限弹窗 | 宿主机资源被占满、文件系统被写 |
| 提示注入 | 页面文本诱导模型执行非任务动作 | 数据外传、误操作(第 8 章详述) |
| 资源失控 | 内存泄漏页、失控的标签页增长 | 一个任务拖垮整批并发 |
| 凭证暴露 | 登录态与 Cookie 常驻浏览器 | 被不受信脚本读取 |
一句话概括:Agent 的浏览器同时是「执行器、金库、攻击面」。开发者的个人浏览器还多一层「身份混用」风险(你的登录态、你的历史、你的指纹都在场),所以工程红线是第一条纪律:Agent 永远用独立实例,绝不共用人的浏览器。这条纪律同时是第 8 章指纹话题的起点——隔离本身就是一种身份管理。
隔离的物理形态是容器:每份浏览器跑在自己的命名空间里,文件系统一次性、资源有上限、退出即销毁。编排示意(参数为起步值,按负载调整):
# compose_sandbox.yml —— Agent 浏览器沙箱的容器编排示意(按部署环境调整) services: agent-browser: image: my-agent-browser:1 # 打包了浏览器与依赖的自有镜像 mem_limit: 2g # 资源限额:内存上限 cpus: "1.5" # 资源限额:CPU 上限 pids_limit: 256 # 进程数上限:防标签页失控增长 read_only: true # 只读根文件系统 tmpfs: - /tmp # 唯一可写目录,随容器销毁 shm_size: 512m # 浏览器共享内存(无头模式常被忽略的一项) networks: - allowlist-net # 网络策略:见下文与防火墙规则配合 networks: allowlist-net: internal: false # 需要出网时配合出口网关做域名过滤
四个参数的共同思想是把失败半径钉死:内存超限被杀的是一个容器而不是宿主机;文件系统只读意味着不受信代码落不了盘;进程数上限直接封死「开一百个标签页」这种失控形态。shm_size 单独提一句:无头浏览器对共享内存有隐性需求,漏配它会在长任务里表现为莫名崩溃——这类「环境税」在自托管路线上要一样一样交。
第 2.2 节的 allowed_domains 是应用层约束(Agent 不导航过去),网络层再补一道底(请求根本出不去)。双语实现用 Playwright 的 route 拦截:
# network_policy.py —— 网络层域名白名单(写法示意,以官方文档为准) from playwright.sync_api import sync_playwright ALLOWED_HOSTS = {"127.0.0.1", "localhost"} def route_guard(route) -> None: host = route.request.url.split("//", 1)[-1].split("/", 1)[0].split(":", 1)[0] if host in ALLOWED_HOSTS: route.continue_() else: route.abort() def main() -> None: with sync_playwright() as p: browser = p.chromium.launch() context = browser.new_context() context.route("**/*", route_guard) # 所有请求过闸 page = context.new_page() page.goto("http://127.0.0.1:8080/demo_todo.html") page.get_by_role("link", name="关于").click() # 同域链接:放行 print("当前地址:", page.url) browser.close() main()
// network_policy.mjs —— 网络层域名白名单(写法示意,以官方文档为准) import { chromium } from "playwright"; const ALLOWED_HOSTS = new Set(["127.0.0.1", "localhost"]); /** 请求闸门:白名单外一律中止 @param {import("playwright").Route} route 拦截到的请求 */ async function routeGuard(route) { const host = route.request().url().split("//")[1]?.split("/")[0].split(":")[0] ?? ""; if (ALLOWED_HOSTS.has(host)) { await route.continue(); } else { await route.abort(); } } const browser = await chromium.launch(); const context = await browser.newContext(); await context.route("**/*", routeGuard); // 所有请求过闸 const page = await context.newPage(); await page.goto("http://127.0.0.1:8080/demo_todo.html"); await page.getByRole("link", { name: "关于" }).click(); // 同域链接:放行 console.log("当前地址:", page.url); await browser.close();
两层白名单的分工:应用层管「任务语义上该去哪」(Agent 侧),网络层管「物理上能到哪」(浏览器侧)。提示注入的损失半径由后者封底——即便模型被页面内容骗去访问外传地址,请求也出不了网。生产形态里这道闸可以下沉到出口网关或服务网格,逻辑不变,位置更深。
隔离的最小单元不是容器而是 context:每个任务一个全新 context(第 7.1 节按账号注入 storageState),任务结束即销毁——Cookie、缓存、存储互不污染,一个任务的失败状态不会泄进下一个。第 10.1 节的实例池会把这个单元池化:容器是池的边界,context 是任务的边界。
自己从零拼这套东西(容器、会话 API、限额、回收)是一次性的工程投入,steel-browser 把它做成了现成的自托管件:定位是 Agent 浏览器沙箱——以服务形式供给浏览器会话,内置会话管理、连接串发放、指纹一致性这类 Agent 场景的刚需能力(能力清单以官方文档为准),Apache-2.0 许可,可完全部署在自己的基础设施里,常被社区当作 Browserbase 的自托管替代。它解决的正是本节的手工成本:你交出去的是「拼沙箱的工程量」,换回来的是开箱的会话供给;数据面仍完全在域内——这与 5.3 节的形态光谱一致(地基层向右挪一格,但没出域)。
沙箱化的更广语境推荐对读《Strix 精读》第 4 章《工具、沙箱与集成》:工具执行环境的安全边界是所有 Agent 系统的共性问题,浏览器只是其中火力最猛的一个实例——每类工具(shell、文件、浏览器)的隔离需求同源,方案可以互抄。
| 坑 | 现象 | 排查与处理 |
|---|---|---|
| shm 漏配 | 长任务随机崩溃 | 容器加 shm_size(或启动参数换共享内存模式) |
| 只做应用层白名单 | 注入诱导的请求照样出网 | 网络层 route 或网关再封一道底 |
| 共用人的浏览器 | 「顺便用我的 Chrome 跑」 | 红线:独立实例,身份与状态不混用 |
| context 跨任务复用 | 登录态与缓存串味 | 一任务一 context,结束即销毁 |
| 限额拍脑袋 | 要么误杀要么形同虚设 | 从压测数据起步,接第 11.2 节监控再调 |
沙箱立好了,问题变成「沙箱从哪来」——自己运维一批,还是买一份托管供给。下一节讲托管浏览器:Browserbase 类服务的价值,与自托管在成本、数据、运维、合规上的完整对照。