能搬进标签页的任务,是那些不需要伸手到这四扇门之外的任务。
一个 Agent 能不能住进浏览器,看它要做的事会不会伸到四扇门之外:模型、文件、进程、网络。前三扇今天都有现成的东西能推开,只是各带条件。第四扇是网络,浏览器只开了一条缝:大多数给人看的网页读不了,TCP 连不了。所以“纯网页 Agent”在工程上是本机计算加一个很薄的出口;任务一旦要从出口之外拿东西(git、ssh、数据库、普通网页、无人值守),就搬不进来。
showDirectoryPicker 只有 Chromium 有(Chrome 86 起),Firefox 和 Safari 没有,只剩 OPFS,那不是你的磁盘。我的页面只读导入,上限 300 个文件、4MB,改动先留在内存。两个运行时里,Agent 都是先用页面自带沙箱跑测试,撞上一个报错,才想起改用真 shell;任务描述里没人提 Bash。限制它的不是会不会用工具,是页面有没有把真工具摆出来。
我最开始担心的不是速度。一个纯网页的 Agent 去改你本地目录里的项目,听上去该被浏览器的安全模型卡死:网页凭什么读你的磁盘,凭什么跑 shell,凭什么联网装依赖?
10 月 14 日到 20 日,我做了两个页面来拆这个怀疑。它们共用同一个 Claude Code 风格的 Agent 外壳:读写文件,改前确认,跑测试,改完要自己验证。区别只在 Bash 工具背后的运行时。第一个页面用 StackBlitz 的 WebContainers,第二个用开源的 Wasmer,各自只多了一百多行代码。模型是 BigModel 的 glm-5.3。
结论先说:安全机制没有把它卡死。浏览器给了文件一把钥匙,给了进程一把钥匙,只是每把都带条件。真正卡住它的是第四样东西,网络。
先给答案,再讲怎么量的。“证据”一列:实测是我或我派的会话亲手跑过,文档是读官方文档得到,推断是没测、由前两类推出来的。
| 任务 | 能在标签页里做吗 | 证据 |
|---|---|---|
| 读你的项目目录,改完写回 | Chromium 能;Firefox、Safari 退化成内存里的空工作区 | 文档。授权、读取、写回的整条路径我没有自动化跑过(系统弹窗点不了) |
| 修纯 JS 项目里的 bug,跑单元测试 | 能 | 实测,两个运行时各一次真实模型任务 |
| 装 npm 依赖再跑测试 | WebContainers 能;Wasmer 要你自己提供中继,且只能用 pnpm | 实测 |
| 调 GitHub、npm、PyPI、Wikipedia 的 API | 能 | 实测(CORS 放行) |
| 读一篇普通新闻或文档网页 | 多数不能 | 实测,31 个 URL 里 19 个没有放行头 |
| git commit、push | 不能,两个运行时里都没有 git | 实测 |
| ssh、连数据库 | 不能 | 文档加推断:浏览器没有 TCP |
| 跑 Python 脚本 | Wasmer 能(Python 3.13,首次约 22 秒);WebContainers 里只有 RustPython alpha | 实测;科学计算库没测 |
| 无人值守,跑一整夜 | 不能,标签页一关就停 | 推断,后台标签页被降频的行为没测 |
| 几千个文件、要原生编译的工程 | 不能 | 页面导入上限 300 个文件、4MB;两个运行时都没有 gcc |
每一行最后都落在四扇门里的某一扇上,下面一扇一扇讲。
Agent 在浏览器里干活,想的却是远处的模型。第一个问题是页面能不能直接调它。
我用 curl 冒充一个来自 roam.xiaopingfeng.com 的页面,对 14 个模型接口各发一次预检请求,再发一个带假 key 的 POST,看错误响应有没有允许来源的头。脚本和原始输出放在这个文本文件里。14 个预检全部放行,三处细节值得记。
Anthropic 的接口,预检里不声明 anthropic-dangerous-direct-browser-access 这个头,就不返回允许来源;声明之后返回通配,假 key 的 401 也带着头。这个头名自己把话说完了:他们默许你这么干,但要你亲口承认风险。火山方舟的预检过了,错误响应却没带 CORS 头,页面发一个错 key,浏览器只报一个不透明的网络错误,读不到 401,调试这类接口最费劲。其余十二个,有的回显我的域名,有的回 *。
放行只回答了能不能调。页面把 API key 存在这个域名的 localStorage 里,请求从浏览器直接发给你填的地址,不经过站点;也就是说,能在这个域名下执行脚本的任何东西,一个被投毒的依赖,一个扩展,都读得到它。
本地模型是另一回事。WebLLM 在浏览器里推理,页面代码按 4–8k 上下文给它限了 14 个核心工具;Ollama 默认不让别的域名调,要在服务端设 OLLAMA_ORIGINS。10 月 15 日的实测里,qwen2.5:7b 能守住动作格式,却会在动作后面自己编一段“观察结果”,没验证就说完成,贪吃蛇做不出来;gemma4:31b 44 秒做出了能玩的版本;glm-5.3 默认的最深推理档做同一个贪吃蛇要 246 秒,调成 low 是 45 秒。
所以这扇门的条件是:你愿意把代码片段发给某个模型 API,或者本机有 30B 级的模型。WebLLM 的真实推理我没有跑过。
浏览器让网页读写你的真实磁盘,靠的是 File System Access API 里的 showDirectoryPicker。规矩很具体:只在 HTTPS 下可用;必须由用户点击这类动作触发;你选哪个目录,页面就只拿到哪个目录;选到浏览器认为太敏感的目录会被拒(Chrome 的文档举的例子是 Windows 和 macOS 的系统库目录);写入要另外弹一次授权,同一站点的标签页全关之后,授权也跟着丢。
所以我最初的怀疑,在文件这一格上是错的。这套机制就是为这件事设计的。
卡点在别处:兼容数据里 showDirectoryPicker 只有 Chrome 86 起,Firefox 和 Safari 都是不支持。后两者有 OPFS(Firefox 111、Safari 15.2 起),但那是浏览器给站点划的私有空间,不是你桌面上的文件夹。换句话说,“对你本地的项目工作”只在 Chromium 里成立,在别的浏览器里退化成一个从零开始的内存工作区。
我的页面还做了两个收窄。打开文件夹时只读导入,最多 300 个文件、单个 200KB、总共 4MB,node_modules、.git、dist 这些目录直接跳过;Agent 的改动先留在内存里,你输入 /save 才会申请写权限落盘。两个运行时里的文件系统本来也是虚拟的,所以每条 Bash 命令前后,页面都要把工作区同步进去、再把被改过的文本文件同步回来。
这一格我有一件事没验证:授权、读取、写回的整条路径,我没有自动化跑过,因为那个选择框是系统弹窗。
这一扇最难,变化也最快。
门槛先说清楚。在浏览器里跑一个像样的运行时,要多线程,要多线程就要 SharedArrayBuffer,而它只给跨源隔离的页面用:响应头要带 Cross-Origin-Opener-Policy: same-origin,再配 Cross-Origin-Embedder-Policy 的 require-corp 或 credentialless(见 MDN)。对一个静态站意味着给这个页面的路径单独配响应头:roam 部署在 Cloudflare Pages,我在 _headers 里只对两个路径开,别的页面不动。
我选了 credentialless,因为 require-corp 会拦掉预览里没带 CORP 头的第三方图片(这是另一个会话测出来的)。代价写在兼容数据里:credentialless 是 Chrome 96、Firefox 119 起,Safari 不支持。WebContainers 官方自己也说,桌面 Chromium 开箱即用,Safari 和 Firefox 还在 beta。进程这一扇,实际上也是 Chromium 的。
两个运行时放在一起:
| WebContainers | Wasmer | |
|---|---|---|
| 运行时在哪 | StackBlitz 托管,从它的域名加载,闭源 | SDK 随页面同源发出(约 5MB),bash、Node 等包首次从 Wasmer 的包仓库下载 |
| 里面是什么 | Node 22、npm | bash、Node 24(Edge.js)、pnpm,Python 3.13 按需加载 |
| 冷启动 | 27.2 秒(1 个样本);另一些样本 7 到 8 秒;线上我测到 15.1 秒 | 27 到 38 秒(约 89MB);Python 另约 22 秒 |
| 暖启动 | 0.34 到 0.68 秒 | 0.66 秒 |
| 联网装包 | 开箱即用 | 要自己提供 WISP 中继,只能用 pnpm |
| 真实 glm-5.3 修 cart | 29 秒 | 61 秒,含 34.4 秒冷启动 |
这些数字的样本都很小,WebContainers 那一列冷启动只有一两个,别当基准。cart 是个三个 bug 的小项目(reduce 缺初值、价格没补零、折扣返回的是折扣额),WebContainers 那一列来自另一个 Claude Code 会话的报告。
比数字更有意思的是 Agent 的行为。两次真实任务里,它都是先调页面自带沙箱的 Test,得到一句 test is not a function,读了测试文件,然后自己改用 Bash 去跑 node --test。那个报错是页面的 bug:我的沙箱对标准写法 import test from 'node:test' 返回了一个没有默认导出的对象。任务描述里没提 Bash;系统提示里有这个工具,但它是撞上报错之后才去用的。它换了,修好三处,重跑,5/5 通过。我把 Wasmer 那次的工作区导出,在真实 Node 里跑原测试,5/5,测试文件没被改动。
另一面是,每个运行时都有一串坑,最后都变成工具说明里的一句话,和一个守卫。Wasmer 这边我踩到的:
node --test 默认模式报“serializer payload is no longer available”,要用 --test-isolation=none,页面现在自动替它加上。node x.js | tail 得到空输出,echo hi | cat 却正常。工具说明里写着“node 的输出别接管道”。npm install 失败,报 https 协议不支持,只能用 pnpm,还要一个 .npmrc。timeout_ms 会失灵:SDK 自带的超时要等阻塞的 guest 调用(比如 sleep 30)返回才生效,我限 3 秒的命令曾经 30 秒才回来。页面现在自己计时、到点强杀,这是跑下面那批任务时发现的,已修。两个运行时共同的缺口更值得记:没有 git,没有 grep、sed、awk、find。文件搜索还有页面里的 Grep 和 Glob 兜底,git commit 就没有替代品了。WebContainers 里原生模块能装上、却在 require 时报 ERR_DLOPEN_DISABLED,装得上不等于用得了。
我后来把这些“能不能”写成了十一个任务,用同一个接口(boot / put / exec)分别接到 WebContainers、Wasmer,和一个临时部署的 Cloudflare Sandbox 上跑。浏览器两格跑的是线上页面里真实的 Bash 工具;云端一格是官方最小示例默认的 lite 实例,我把镜像换成了带 git、Python 和编译工具的 Debian、打开了出站网络,测完把 Worker、容器应用和镜像都删了。每格只跑了一次,不是基准;任务脚本、适配器和原始结果在这个文本文件里。
| 任务 | WebContainers | Wasmer | 云端 lite |
|---|---|---|---|
| node:test 红到绿(3 个 bug) | ✓ | ✓ | ✓ |
| 装纯 JS 依赖再 require | ✓ npm | ✓ pnpm,要中继 | ✓ |
| git init / commit / log | ✗ 没有 git | ✗ 没有 git | ✓ |
| grep / sed / awk / find | ✗ 全没有 | ✗ 全没有 | ✓ |
| 装原生模块(better-sqlite3)并调用 | ✗ 装得上,加载被禁 | ✗ 没有可加载的二进制 | ✓ 约 37 秒 |
| Python 跑脚本、import sqlite3 | ✗ 有 python3,缺 _sqlite3 | ✓ Python 3.13 | ✓ Python 3.11 |
| 出站 HTTPS 到没开 CORS 的站点 | ✗ socket 被关 | ✓ 经自备中继 | ✓ |
| node 当管道的上游 | ✓ | ✗ 输出为空 | ✓ |
| 超时强杀:node 空转 30 秒,限 3 秒 | ✓ 3.1 秒 | ✓ 3.8 秒 | ✓ 3.1 秒 |
超时强杀:shell 的 sleep 30 | ✗ jsh 里没有 sleep | ✓ 3.0 秒 | ✓ 3.3 秒 |
| 3 秒空转的迭代次数 | 1.2 亿 | 1800 万 | 160 万 |
| 冷启动 | 7.5–7.7 秒 | 27–34 秒 | 9–13 秒 |
暖命令(echo) | 约 105 毫秒 | 约 58 毫秒 | 150–615 毫秒 |
浏览器里的两个运行时和云端,在“跑 Node、跑测试、装纯 JS 包”这一层没有区别,红绿测试三家都是改前 4 败 1 过、改后 5 过。差在工具链:git、grep 这一类、原生模块,云端都有,浏览器里两家都没有。这和 § 01 判定表里那几行是同一回事,现在每一行都有同一批任务跑出来的结果。
两处我没想到。一是出站网络:WebContainers 里 fetch('https://example.com') 直接失败,Wasmer 靠我自备的中继反而通了,云端本来就通;和 § 05 说的一致,出口在谁手里,谁就决定能读什么。二是算力:同一个 3 秒空转,WebContainers 跑了约 1.2 亿次,Wasmer 约 1800 万,云端 lite 约 160 万。浏览器里的 Node 用的是你自己机器的 CPU,比官方最小示例默认的那一档云端实例快得多;Wasmer 比 WebContainers 慢约 7 倍,原因我没查。云端这一格别读成“云端都这么慢”,我没试更大的实例。冷启动的次序也反过来:云端 9 到 13 秒,比 Wasmer 的 27 到 34 秒快,但每条命令要多一次网络往返,暖命令 150 到 615 毫秒,浏览器里是 58 到 105 毫秒。
“计算在你本机”这件事,我也量了一次,没有只信文档:让两个运行时各跑一个 16 秒的空转循环,同时每两秒用 top 采一次样,把名字以浏览器应用名开头的进程(主进程、各个渲染进程)的 CPU 加起来。空闲时合计 12 到 23%,循环期间升到 53 到 67%,结束后回落到 13 到 32%;循环期间有一个渲染进程单独占了约 40 到 49%。命令如果是在远端跑的,本机不会有这个起伏。这个测量有噪声:第一次试跑时 WebContainers 的空闲基线就有 100% 以上,所以我重跑了一次,这里写的是重跑的结果,每个运行时只有这一次。它只能说明这些循环的计算发生在这台机器上,我没有抓包去证明没有别的流量。
还有一个坑,是关掉跨源隔离之后才看到的。我的页面在没有响应头时会把 Bash 工具藏起来,但直接用 Wasmer 的 SDK,页面没隔离它也不报错:run() 返回成功、退出码 0、标准输出和标准错误都是空的,用时 22 毫秒,命令根本没有跑;同样的代码在隔离的页面里输出了 hi。拿它做集成测试的人,会得到一个看起来全绿的空结果。
网络分两层。第一层是 HTTP,浏览器的规矩是 CORS:页面只能读那些在响应头里写明“允许你这个来源读”的站点。
我同样用 curl 冒充页面,测了 31 个公开 URL(原始输出在同一个文本文件里),挑的偏开发者常去的站点,不是随机抽样。
| 结果 | 数量 | 例子 |
|---|---|---|
| 放行 | 11 | GitHub raw、GitHub API、npm registry、PyPI JSON、jsDelivr、unpkg、Wikipedia REST、Hacker News API、Hugging Face API |
| 只放行自己的域名 | 1 | 大麦网 |
| 没有放行头 | 19 | github.com 网页、Wikipedia 网页、MDN、Python 文档、arXiv、BBC、知乎、掘金、36氪、百度、豆瓣;Reddit、Stack Overflow、纽约时报直接返回 403 |
模式很清楚:给机器读的端点基本放行,给人看的页面基本不放行。按我划的性质拆开:12 个给机器读的端点里 10 个放行,19 个给人看的页面里只有 1 个。同一个 Hugging Face,API 放行,模型页不放行;同一个 Wikipedia,REST 接口放行,词条页不放行。另一个会话在 WebContainers 里看到同样的事:容器里的 curl 和 fetch 读大麦、秀动都失败,npm registry 通。
对 Agent 来说,后果是它能调 API、读文档仓库、拉包,但大多数你会让它“去网上查一下”的页面,它读不到。我的页面里有个 WebFetch 工具,默认每个域名先问你,只读开放了 CORS 的站点;你可以手动打开一个阅读代理(r.jina.ai)绕过去,代价是代理会看到你要读的 URL。
第二层是 TCP,浏览器根本没有。npm 装包、ssh、连数据库,全都要 TCP。两个运行时的办法不同:WebContainers 靠 StackBlitz 的代理服务;Wasmer 要你自己提供一个 WISP 中继,一个把 WebSocket 转成 TCP 的小服务。
我在这上面花了不少时间。没有中继时,npm install 一直卡着,60 秒没有任何输出,也没有报错。起了本机的中继,pnpm add 还是失败,guest 里只有一个 ECONNRESET。最后在中继的日志里才找到那一行:refusing to create a stream。原因是我这台机器的 DNS 返回的是 fake-IP(198.18.x.x,代理软件的常见做法),而中继默认把这个网段当私网拒绝。加上 allow_private_ips 之后,pnpm add is-odd 4.4 秒,pnpm add -D mocha@10 约 12 秒,mocha 先 2 过 3 败,改完代码 5 过。TLS 是在 guest 里完成的,我用 tls.connect 验证过握手,协商出 TLSv1.2;所以中继看到的是目标主机、端口和密文。
这里有三件事要摊开。中继是个服务端,“纯网页”到这一步就不纯了。放开私网段的中继,等于在本机开了一个可以到内网的跳板,我没有验证它会不会按来源页面拦截,所以只在测试时开,用完就关。还有,WISP 的客户端库 @mercuryworkshop/wisp-js 的许可是 AGPL-3.0,所以我没有把它放进仓库,页面只在你配置了中继之后才从 jsDelivr 加载它。这样处理够不够,我没有请人确认过。
浏览器厂商不是没想过:Chrome 有一个 Direct Sockets API,让页面开 TCP 和 UDP,但只给 Isolated Web Apps,一种要安装、带严格内容安全策略的隔离应用,不给普通网页。2024 年提交发布意向时,Gecko 没表态,WebKit 没有信号;2026 年的最新状态我没有核。
问这个问题的人通常想知道有没有标准。浏览器这一层没有。有标准的是底下几块:文件 API(还不是 Baseline)、跨源隔离、WebAssembly。往上,每个运行时都是一家公司的产品:
@webcontainer/api 包标 MIT,只有 35KB,是个客户端壳。boot() 会插一个隐藏的 iframe,指向 stackblitz.com,真正的运行时从那里加载。GitHub 上的 webcontainer-core 仓库,README 自己写着是个 issue 汇总处。生产环境的商业使用要买授权,原型和 POC 不用;自托管只在企业版里。我去读这些,是因为 MIT 这个标签很容易让人以为整套都开源。行业里用得最多的 Agent 沙箱不在浏览器里。2026 年 5 月一份 Agent 沙箱清单把方案分成托管沙箱(E2B 和 Vercel Sandbox 用 Firecracker 微虚拟机,Cloudflare 是 V8 隔离加容器)、本机操作系统级沙箱(macOS 的 Seatbelt、Linux 的 Landlock、bubblewrap),以及内置在 Agent 里的沙箱:Codex CLI、Claude Code 都是这一类。E2B 是 Apache-2.0,约 1.4 万星。浏览器路线在这张图里是一小块,卖点只有两个:零安装,代码不离开你的机器(模型 API 除外)。
我另一篇文章量过服务端这一头对 Agent 的开放程度:哪家服务真正做到了 Agent native。两篇放一起,我的读法是:Agent 能走多远,要看对面有没有给机器留一条不用问人的路。服务端缺的是这条路,浏览器缺的也是。
我自己的做法:浏览器里的 Agent 适合演示、让别人零安装试用、前端小项目和教学;真实的工作仓库,我还是在本机的 Claude Code 里跑。
没验证的:
会让这篇结论改写的事:
showDirectoryPicker,或者 Direct Sockets 走出 Isolated Web Apps。那时文件和网络两扇门的条件会变。下一步我想做的,是把上面那批任务固定下来:每次运行时升版本就重跑一遍,比我每次手写一段“实测”可靠,那张表就是第一次的结果。还想补两件:在 Safari 里验证(要手动打开页面),和更大的云端实例。