feat(browser): sandboxed local HTML preview via a tokenized loopback origin - #2942
feat(browser): sandboxed local HTML preview via a tokenized loopback origin#2942happy9zhang wants to merge 7 commits into
Conversation
previewLocalHtml action + tokenized loopback HTTP server (127.0.0.1), path containment + CSP + token/TTL + SW/WebRTC/BroadcastChannel guards. RSB sidebar excluded (managed browser only). Design doc in docs/design-preview-v2.md. Work in progress — not yet pushed. Signed-off-by: happy9zhang <happy9zhang@users.noreply.github.com>
Rewritten per 'honest threat model' direction (preview channel must not exceed agent's existing capabilities). This is a DRAFT — the incoming window should NOT treat it as authoritative. Re-derive the design from first principles; this document is input, not constraint. Signed-off-by: happy9zhang <happy9zhang@users.noreply.github.com>
Rework the preview channel so it defends only what the previewed content
actually gains, and nothing the caller already had.
Baseline: agent-authored JS already runs in the managed browser with
unrestricted network access via act:evaluate — an explicitly accepted
surface (see SECURITY POSTURE in browser-managed-config.ts). Only three
things are genuinely new here, and each keeps its control:
- any local process could read workspace files through the listener
-> 256-bit capability token + path containment
- the SSRF allowlist has to open for loopback
-> one exact origin, never a hostname
- a page could persist past the preview via Service Worker
-> CSP worker-src 'none'
Plus one the preview's position creates: it sits on loopback, where
private->private requests are far less restricted, and page-context fetch
bypasses the Node SSRF guard -> CSP connect-src 'self' keeps it off other
local services (notably CDP) while still letting it read its own files.
Removed, with reasons:
- filesystem race defence (fd binding, dev/ino + nlink comparison,
per-level lstat walk, pre/post stat, root identity pinning). Winning
that race requires filesystem write access and timing control; such
an attacker reads the file directly.
- the entire preview route guard and its 9 vendored pw-session patches
(exact-origin lockdown, live re-authorization, Service Worker
clearing, RTCPeerConnection/BroadcastChannel shadowing, goto-failure
teardown). These fight content exfiltration, which the baseline above
already accepts. Upstream installs its navigation guard per-goto and
removes it in finally, so page-initiated navigation is unguarded for
every page; the patches made preview pages the sole exception -
stricter than normal pages, for a threat the stack accepts.
- connect-src 'none' -> 'self'. 'none' also blocked the page from
reading its own data files, so pages that fetch to render screenshot
blank, which pushes callers back to launching a raw browser - the
incident this feature exists to prevent.
Four vendored patches remain, all wiring an upstream mechanism that is
implemented but never called: resolveSsrFPolicyForUrl, which is what
makes allowedOrigins effective on the browser navigation path. Without
them the only option is trusting every port on 127.0.0.1.
Verification: preview suite 18/18; typecheck green across 8 packages;
test:unit:related 53 packages pass, sole failure devCliFlags is a
filesystem case-sensitivity assertion that fails identically on a clean
upstream checkout.
Signed-off-by: happy9zhang <happy9zhang@users.noreply.github.com>
Adds the regression guard that was missing around the one vendored change this feature depends on: the navigation guard's call to resolveSsrFPolicyForUrl. Removing that call makes the first assertion fail, so the tests cannot pass with the wiring broken. - runtime: the exact preview origin is navigable, while another port on the same loopback host (the managed browser's own CDP port), another loopback host, and every other private range stay blocked; dropping the origin grant puts the preview URL back behind the private-network block. - desktop: buildManagedConfig() trusts exactly the running preview origin and never falls back to a hostname-level or private-network allowance. Signed-off-by: happy9zhang <happy9zhang@users.noreply.github.com>
…ontains local-service reach The no-cors shapes connect-src never sees (img/form/frame) are denied by the same 'self'/'none' posture, so relaxing any directive to a remote scheme is a security decision, not a convenience one. Signed-off-by: happy9zhang <happy9zhang@users.noreply.github.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f822e48cff
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
| Filename | Overview |
|---|---|
| apps/desktop/src/main/mcp-integrations/local-html-preview-server.ts | 实现 token、TTL、路径 containment、CSP 与 generation-based 生命周期管理;此前报告的 listener 错误永久锁死问题已修复。 |
| apps/desktop/src/main/mcp-integrations/browser.ts | 接入本地预览服务,并在受管浏览器后端切换时关闭 listener、撤销精确 origin 授权。 |
| apps/desktop/src/main/mcp-integrations/browser-managed-config.ts | 仅在预览服务运行时向 SSRF 策略加入精确的 loopback origin。 |
| apps/desktop/src/main/mcp-integrations/piEnvironment.ts | 将 remoteHostId 传递到 MCP 会话上下文,使预览 action 能对 SSH 工作区 fail-closed。 |
| packages/browser-control-runtime/src/_generated/extension/src/browser/navigation-guard.ts | 在导航私网检查前按当前 URL 解析 allowedOrigins,使授权仅作用于匹配的精确 origin。 |
| scripts/browser-runtime/sync.mjs | 将 allowedOrigins 导出与导航守卫接线记录为可重复应用的 vendored runtime 补丁。 |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
A[previewLocalHtml] --> B{本地受管浏览器会话?}
B -- 否 --> C[返回不支持错误]
B -- 是 --> D[校验工作区 HTML 路径]
D --> E[启动 tokenized loopback listener]
E --> F[授权精确 preview origin]
F --> G[受管浏览器导航并验证页面]
H[listener 错误或后端切换] --> I[撤销 origin 并清理服务]
Reviews (3): Last reviewed commit: "fix(pi): carry remoteHostId into the Pi ..." | Re-trigger Greptile
…the live backend A single listener error used to latch a process-level failure flag that dispose() never cleared, so one transient bind failure left local preview unavailable until the app was restarted. An unusable preview channel is what sends callers back to launching a raw browser, which is the incident this feature exists to prevent. The same lifecycle carried a second defect: ensureStarted() cleared the disposed flag, so a request that began before a backend switch could finish afterwards and re-grant the SSRF origin for a backend that does not support previews. Replace both sticky flags with a generation counter. dispose() advances it; everything that spans an await pins the generation it began in and re-checks at each checkpoint, so a superseded request fails closed while a request that starts after a dispose still gets a fresh listener. getBrowserMcpDeps() also re-checks the backend after awaiting, as a second layer. Regression tests cover both: reverting either fix turns the matching test red. Signed-off-by: happy9zhang <happy9zhang@users.noreply.github.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c222b66b54
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
⏸️ 本 PR 触发 product 维护者确认门。 新增沙箱化本地 HTML 预览能力(tokenized 访问),涉及安全和架构。 需维护者在 PR 上 Approve 后方可合并。 讨论 issue 已创建,详见上方链接。 讨论 issue:#2963 |
|
@happy9zhang 👋 这个 PR 还有 1 条 review conversation 没 resolve(packages/lizi-mcps/src/browser/tools.ts),auto-review 因此暂时跳过、没法继续审查 / 合并。 如果你已经按评论改完或回应了,请到对应 thread 上点 Resolve conversation;全部 resolve 后,下一轮 auto-review 会自动重新审查这个 PR。 |
Remote Pi sessions keep the MCP bridge (remotePiSkipMcpBridge returns false), but piEnvironment never copied remoteHostId into the lizi session context the way codexEnvironment does. Every remote Pi session therefore looked local to the MCP tools, which resolve the session workingDir against the local filesystem: usually a misleading not-found, and silently the wrong local file whenever the same absolute path happens to exist on this machine. The field was already defined on PiExtraSpawnConfigContext and already passed in, so forwarding it is a one-line change mirroring the Codex side. This also makes memory scope keys correct for remote Pi sessions (buildMemoryScopeKey takes remoteHostId), which is the documented intent of the field but does move existing remote-Pi memories to a new key. Called out in the PR description for maintainer review. The new test asserts a remote session is recognisable and a local one is unchanged; dropping the forwarding line turns it red. Signed-off-by: happy9zhang <happy9zhang@users.noreply.github.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 91c214434a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
这次改了什么
摘要
给 agent 一条受控的本地 HTML 预览通道:新增
previewLocalHtmlaction,把工作区内的 HTML 文件通过一个带能力 token 的 loopback HTTP 服务提供给受管浏览器截图验证。file://和泛化的 localhost 导航仍然一律拒绝。要解决的问题不是「浏览器打不开本地文件」,而是 agent 没有合法通道所以会绕开受管浏览器——#1766 记录的事故链是:SSRF 策略不放行本地地址 → agent 改用 Bash 裸跑 Chrome → 裸跑环境没有 profile 隔离和生命周期管理 → agent 用按进程名杀的方式清场 → 用户日常浏览器被连带关闭。
因此本 PR 的第一优先级是这条通道要真的好用。一条因为太严而经常失败的通道会把 agent 推回裸跑,等于没做。
本 PR 取代已关闭的 #1803(5308 行未合并)。范围缩到 #1766 的三条原始诉求:预览 action、受控通道、工具描述引导。
变更类型
feat新功能范围
previewLocalHtmlaction、主进程 loopback 预览服务(token + TTL + 路径 containment + CSP)、精确 origin 的 SSRF 放行接线、工具描述引导、28 条测试;另含一行 Pi 会话远端身份透传修复(见「顺带修复」一节,它是上面 fail-closed 生效的前提)store.ts与browser-preview-tabs.ts的任何改动、更新链路(updateService)的任何改动、SSH 远程会话预览(工作目录在远端机器,fail-closed 拒绝)UI 变化
不涉及
远程与手机端适配
按
docs/dev-rules/remote-and-mobile-adaptation.md的门禁逐条作答:SSH 远程工作区下能否正常工作? —— 不能,已开跟踪 issue:feature: SSH 远程会话支持本地 HTML 预览(previewLocalHtml 远程适配) #2000(feature: SSH 远程会话支持本地 HTML 预览(previewLocalHtml 远程适配))。
预览服务在本机用 Node 的
http+fs提供文件,而 SSH 会话的 workdir 与文件都在远端主机;要支持必须改走remote-file-service/ cc-manager / exec 通道,适配量超出本 PR 边界。当前行为是 fail-closed 而不是静默降级:MCP 层检测到会话带
remoteHostId时直接返回错误并说明原因,不会拿远端路径去读本机同名文件——那正是这条规则警告的「直接fs读 workdir 会读到本机」。新增/修改的 IPC channel 与推送事件,手机/远程控制场景需不需要用? —— 不涉及:本 PR 未新增或修改任何 IPC channel 与推送事件。
previewLocalHtml是既有cindy_browserMCP 工具下的一个 action,走已有工具通道;主进程侧新增的只是模块内部函数,没有ipcMain.handle,也没有 device-link topic 路由改动。因此不需要登记 invoke/push 白名单。手机版需不需要对应的入口/UI/交互? —— 不需要。
本功能的产物是给 agent 用来截图验证的一个 URL,没有面向用户的界面。手机端经 device-link 驱动被控桌面时,预览仍在被控端的受管浏览器里执行,行为与本地一致,手机端不需要新入口。
(本 PR 未触及 device-link 的重试/超时/断链恢复路径,故不涉及「故障半径三问」。)
顺带修复:Pi 会话的远端身份未透传
上面第 1 条的 fail-closed 依赖 MCP 层能看见
remoteHostId。审查中发现它对 Pi 会话看不见:PiExtraSpawnConfigContext早就带remoteHostId,也已经传进getPiExtraSpawnConfig,但piEnvironment.ts组装LiziMcpSessionContext时没有把它拷过去(codexEnvironment.ts有,Pi 漏了)。remotePiSkipMcpBridge返回false,代码注释明写为远端 Pi 保留 bridge)。workingDir被交给本机fs——通常是个误导性的「文件不存在」,而本机恰好存在同名绝对路径时会操作错误的本机文件。修复是一行,与
codexEnvironment.ts已有写法对称:remoteHostId存在时拷进会话上下文。新增一条测试断言远端会话能被工具识别、本机会话仍与改动前一致;去掉那一行该测试立即失败。这一行会影响另一个消费者,必须说明:
packages/lizi-mcps/src/memory/_shared.ts用buildMemoryScopeKey(workdir, remoteHostId)定位 memory store。修复后,远端 Pi 会话的 memory 将按ssh:<hostId>:<path>分域,而不再按远端路径当本机路径。威胁模型与它的边界
这一节是本 PR 相对 #1803 最大的变化,且是实质收窄,所以完整写出依据。
先确定能力基线
设计安全边界前要先确定 agent 现在已经能做什么,否则会去防已经不设防的东西。agent 已有:读写工作区、跑任意 shell、任意网络访问,以及——在受管浏览器里跑任意 JS(
cindy_browser的act:evaluate)。最后一条上游已经就此做过裁定,原文就在本 PR 修改的
apps/desktop/src/main/mcp-integrations/browser-managed-config.ts的 SECURITY POSTURE 注释里:防的是内容,不是 agent
防的是预览的那个 HTML 里可能存在的第三方内容(拉来的模板、别人写的页面)。不防 agent——agent 想外泄工作区内容,
curl一行就够,不需要绕道预览页。于是问题变成:预览通道给了「内容」什么是它原本没有的? 逐项排查后只有四条,每条对应一个承重控制:
127.0.0.1:<port>必须开口。开成整个 loopback 就等于信任本机所有服务allowedOrigins),不是 hostnameworker-src 'none'fetch不走 Node SSRF 守卫,精确 origin 管不到它connect-src 'self'明确不防什么
需要澄清一点,避免文档与代码打架:
connect-src 'self'事实上会挡住跨源 fetch,但它是为「够不到本机其他服务」而设,外泄只是顺带效果。我们不为外泄做任何主张、也不为它加任何机械。与 #1803 的差异:撤回了哪些曾经承诺过的加固
#1803 的产品确认门 #1808 已随 #1803 关闭,那里没法再原地说明,所以在这里逐条交代。这些都是我们公开写过、现在不做的东西,不主动说清楚就是把差异藏起来。
一、机器人在 #1808 列的合并前阻断项,第 3 条被本 PR 撤回
原文要求:
我们在 #1808 回复过「已修复:改为先打开 fd 再对句柄做 fstat 验证」,#1803 里也确实实现了整套(fd 绑定、dev/ino 比对、nlink 硬链接检查、pre/post stat 六重比对、逐层 lstat 走链)。本 PR 把这些全部删掉了,约 200 行。
依据:能赢得文件系统竞态的攻击者,必须已经拥有文件系统写权限和精确的时序控制。这样的攻击者可以直接读那个文件,不需要绕道预览服务。这套防御假设了一个「能赢竞态但不能直接读文件」的攻击者,而这个攻击者不存在。
保留的是非竞态的静态检查:词法 containment、realpath 校验、隐藏段拒绝(入口与请求两处)、扩展名白名单、无目录索引。其中「入口 realpath 后重新校验扩展名」看起来像竞态防御,其实不是——它防的是「名字叫
index.html实际指向.js的软链」这个静态事实,保留并有测试。阻断项 1(隐藏目录全路径段拒绝)、2(CSP 声明与实际一致)、4(token 明确为进程级 bearer capability + TTL)在本 PR 中保留。
顺带更正一处我们当时的错误陈述:#1808 回复里说「额外加了
navigate-to 'self',"不能向外网 exfil" 的声明现在成立」。这是错的——Chromium 不实现navigate-to,会静默忽略它(实测:location.href到外部 origin 照样跳转)。当时那条声明依赖一个并不生效的指令。这也是本 PR 干脆不再对外泄做任何主张的直接原因;现在测试里有一条断言navigate-to不得出现在 CSP 里,避免它再被当成一个存在的控制。二、#1803 撤回声明里承诺「新 PR 会保留」的,有三条没有保留
我们 2026-08-12 在 #1803 写过新 PR 会保留哪些、撤掉哪些。对照结果:
另外两条当时说「会一并修掉、不当残余」的:
关于「最小的导航拦截」为什么撤掉,有一个查证结果值得单独说:上游的导航守卫本来就是 per-goto 安装、
finally里卸载的,任何页面在初次 goto 之后的自发导航,上游都不拦。#1803 里那套补丁的作用是反过来——让预览页成为全局唯一「goto 之后仍被持续约束」的页面,即比普通页面更严。它安全上无害,但为此要往 vendored 运行时里打 9 条补丁(约 170 行生成代码)并承担每次上游同步的断裂风险,而防的是基线已接受的威胁。三、关于「撤掉 allowedOrigins 授权链」
撤回声明里把「allowedOrigins 授权链」列在要撤掉的一侧,而本 PR 把
allowedOrigins留成了承重项,这里说清楚不是出尔反尔:当时撤的是授权链——#1803 里那套围绕 origin 授权的动态生命周期机械(运行时反复重新授权、逐请求 live 重查、守卫与授权状态互相耦合)。那套确实全部撤掉了。
本 PR 保留的是这个字段本身的最简用法:bind 成功后放入一个精确 origin,dispose/后端切换时清空。没有链,没有动态重授权。
为什么仍然需要改 vendored 运行时(4 条补丁)
本 PR 在
scripts/browser-runtime/sync.mjs的 LOCAL_PATCHES 里保留 4 条补丁:两条是一行re-export,一条是 import,一条是在导航守卫里调用resolveSsrFPolicyForUrl。这不是新增机制,而是把上游已经写好但没有调用者的函数接上线:
allowedOrigins是上游SsrFPolicy的既有字段,归一化、合并、按 URL 解析的逻辑全部在上游代码里(_generated/leaf/src/infra/net/ssrf.ts),只是浏览器导航路径上没有调用它。零补丁的替代方案是
allowedHostnames: ['127.0.0.1'],那等于信任本机所有端口——包括受管浏览器自己的 CDP 端口。用 4 条一共约 10 行的补丁换掉整个 loopback 的信任面,我们认为是划算的;如果维护者更希望避免 vendored 补丁,这是可以推翻的决定,请直接说。一处次序上的说明,避免审查时产生疑问:
resolveSsrFPolicyForUrl是在assertBrowserNavigationAllowed里私网拦截之前调用的。这是有意的——loopback 本身会被私网规则拦掉,只有先把精确 origin 提升进 hostname 白名单,这一个 origin 才能通过。上游该函数的语义是「只为当前请求 URL 提升匹配 origin 的 hostname」,重定向与子框架会各自带自己的 URL 重新进入判断,所以这份信任不会泄漏到其它端口或主机。长期方向:这 4 条本质上是上游的接线疏漏,可以提给上游源仓库,届时我方补丁自然消失。
怎么验证的
自动验证
关于那条失败:它断言文件系统的大小写敏感语义,在 NTFS 上必然失败。已在不含本 PR 任何改动的上游基线上单独跑同一文件对拍,结果同样是 1 failed / 29 passed,本 PR 也未修改
devCliFlags相关的任何文件。本 PR 新增 28 条测试,覆盖:token 屏障、路径穿越(
../ NUL / 反斜杠 / 百分号编码)、隐藏段(请求路径与 realpath 两处)、软链逃逸、入口 realpath 后的扩展名重查、CSP 各承重指令、错误响应同样带 CSP、origin 授权的发放与撤销、TTL 过期,以及启动/拆除生命周期(listener 报错后能否恢复、后端在校验中途切换时是否拒绝)。其中三条是刻意做成能证伪的——把对应的修复临时撤掉后它们必须变红,否则只是「去掉修复也照过」的假保护:
local-html-preview-server.lifecycle.test.ts的两条:把dispose()里推进代次那一行去掉,「后端在校验中途切换时拒绝」立刻失败;把 listener 错误改回置一个进程级的粘滞失败标志,「listener 报错后能恢复」立刻失败。preview-origin-navigation.test.ts:见下。preview-origin-navigation.test.ts直接调用真实的assertBrowserNavigationAllowed,断言精确预览 origin 可导航、而同一 loopback 主机的其它端口(受管浏览器自己的 CDP 端口)、不同 loopback 主机、撤销授权后的同一 URL、以及其它私网段全部被拦。把上面那条resolveSsrFPolicyForUrl调用临时注释掉重跑,第一条立即失败、其余四条仍然通过——既证明这组测试不是「去掉修复也照过」的假保护,也证明这条补丁的作用仅限于放开那一个精确 origin,没有削弱任何其它拦截。手工验证
用
playwright-core驱动真实 Chrome(headless、独立临时 profile,通过 API 关闭、不按进程名终止任何进程),对真实预览服务跑端到端:fetch('./data.json')成功,页面渲染出数据、截图非空白(12678 字节 PNG)data.json两条(首次加载多出的一条 404 是浏览器自动请求 favicon)connect-src 'self'挡住其它本机服务这一条做了反向控制:直接 fetch 一个没人监听的端口时,「连接失败」和「CSP 拦截」无法区分,所以先起了一个真在监听的第二本地服务、确认从 Node 侧能读到它的内容,再从预览页去 fetch。页面读不到,浏览器给出的原因是:这同时证明了两件事:
connect-src 'self'没有掐掉页面读自己数据文件的能力(可用性成立),也确实挡住了对其它本机服务的访问(承重控制成立)。未执行的验证
previewLocalHtml工具调用链。上面两部分分别验证了「预览服务与 CSP 在真实浏览器里的行为」和「导航守卫会放行且只放行那一个 origin」,但没有在运行中的 Desktop 里实际调用一次该工具。这条与 维护者确认:PR #1803 feat(browser): add sandboxed local HTML preview #1808 当时要求的验收范围相比是缩小的,如果维护者认为必须补,请告诉我。node:path,但跨平台差异未实测。风险
风险分类
影响与回滚
previewLocalHtml时才有行为。未调用时不监听端口、不修改 SSRF 策略。切换到侧栏后端会关闭 listener 并撤销 origin 授权;服务内部用一个代次计数器保证切换发生在某个请求处理到一半时,那个在途请求也不会重新拿到授权(每次切换推进代次,跨越 await 的请求在每个检查点比对代次,不符即失败)。不触及右栏 store、侧栏、更新链路。runtime-config-snapshot无任何落盘路径),进程退出即失效。updateService的强制退出路径直接结束进程、不走正常关闭流程,因此不会执行 listener 的关闭与撤销。由于 origin 授权是纯内存态,进程结束后该授权即不存在,无实际后果。受管浏览器是独立进程、可能在 Cindy 退出后存活,其中的预览标签会指向一个已死的 listener——若此时另一个本机进程占用了该端口,刷新那个标签会拿到冒充者的内容;这属于上面已声明不防的「本机已有恶意进程」场景,且此时 Cindy 已退出、SSRF 策略根本不存在。修复它需要改动更新链路,属高危路径,不在本 PR 范围。关于 PR 体量
本 PR 为 21 文件 / +1509 行,超过 500 行。不再拆分的理由:这已经是 #1803(5308 行)缩范围后的结果,剩下的部分是一个不可再分的最小闭环——预览服务、它的 SSRF 接线、暴露它的工具面,去掉任何一块另外两块都没有意义。其中约 570 行是测试、约 90 行是
sync.mjs的补丁定义。核心服务文件里有相当篇幅是说明「哪些指令是承重的、哪些不是、为什么不防某些东西」的注释,是刻意保留的。补充:一份来自第三方的同类机制报告
#2842(2026-08-16,其他用户提交)报告 agent 使用 Playwright CLI 打开本地地址(
127.0.0.1:4173/:5173)做页面验收后不回收会话,daemon 与 headless Chrome 跨任务、跨 Cindy 重启存活并变成孤儿进程。引用它是因为它独立佐证了本 PR 要处理的前半段机制:agent 为了验证本地内容会绕开受管浏览器、自建一套,并留下不受管理的东西。它不佐证 #1766 里「误杀用户浏览器」那一环,两者不要混为一谈。