Skip to content

feat(browser): sandboxed local HTML preview via a tokenized loopback origin - #2942

Open
happy9zhang wants to merge 7 commits into
makecindy:mainfrom
happy9zhang:feat/preview-local-html-v2
Open

feat(browser): sandboxed local HTML preview via a tokenized loopback origin#2942
happy9zhang wants to merge 7 commits into
makecindy:mainfrom
happy9zhang:feat/preview-local-html-v2

Conversation

@happy9zhang

@happy9zhang happy9zhang commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

这次改了什么

摘要

给 agent 一条受控的本地 HTML 预览通道:新增 previewLocalHtml action,把工作区内的 HTML 文件通过一个带能力 token 的 loopback HTTP 服务提供给受管浏览器截图验证。file:// 和泛化的 localhost 导航仍然一律拒绝。

要解决的问题不是「浏览器打不开本地文件」,而是 agent 没有合法通道所以会绕开受管浏览器——#1766 记录的事故链是:SSRF 策略不放行本地地址 → agent 改用 Bash 裸跑 Chrome → 裸跑环境没有 profile 隔离和生命周期管理 → agent 用按进程名杀的方式清场 → 用户日常浏览器被连带关闭。

因此本 PR 的第一优先级是这条通道要真的好用。一条因为太严而经常失败的通道会把 agent 推回裸跑,等于没做。

本 PR 取代已关闭的 #1803(5308 行未合并)。范围缩到 #1766 的三条原始诉求:预览 action、受控通道、工具描述引导。

变更类型

  • feat 新功能

范围

UI 变化

不涉及

  • 引用的设计规范:不涉及。本 PR 只改主进程服务、MCP 工具层与浏览器运行时接线,不含任何渲染层或样式文件改动。

远程与手机端适配

docs/dev-rules/remote-and-mobile-adaptation.md 的门禁逐条作答:

  1. 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 会读到本机」。

    这条拒绝原先对 Pi 会话不生效,见下方「顺带修复:Pi 会话的远端身份未透传」。

  2. 新增/修改的 IPC channel 与推送事件,手机/远程控制场景需不需要用? —— 不涉及:本 PR 未新增或修改任何 IPC channel 与推送事件。
    previewLocalHtml 是既有 cindy_browser MCP 工具下的一个 action,走已有工具通道;主进程侧新增的只是模块内部函数,没有 ipcMain.handle,也没有 device-link topic 路由改动。因此不需要登记 invoke/push 白名单。

  3. 手机版需不需要对应的入口/UI/交互? —— 不需要。
    本功能的产物是给 agent 用来截图验证的一个 URL,没有面向用户的界面。手机端经 device-link 驱动被控桌面时,预览仍在被控端的受管浏览器里执行,行为与本地一致,手机端不需要新入口。

(本 PR 未触及 device-link 的重试/超时/断链恢复路径,故不涉及「故障半径三问」。)

顺带修复:Pi 会话的远端身份未透传

上面第 1 条的 fail-closed 依赖 MCP 层能看见 remoteHostId。审查中发现它对 Pi 会话看不见

  • PiExtraSpawnConfigContext 早就带 remoteHostId,也已经传进 getPiExtraSpawnConfig,但 piEnvironment.ts 组装 LiziMcpSessionContext 时没有把它拷过去(codexEnvironment.ts 有,Pi 漏了)。
  • 远端 Pi 确实会跑 MCP 工具remotePiSkipMcpBridge 返回 false,代码注释明写为远端 Pi 保留 bridge)。
  • 结果:远端 Pi 会话在 lizi-mcps 看来是本机会话,远端 workingDir 被交给本机 fs——通常是个误导性的「文件不存在」,而本机恰好存在同名绝对路径时会操作错误的本机文件

修复是一行,与 codexEnvironment.ts 已有写法对称:remoteHostId 存在时拷进会话上下文。新增一条测试断言远端会话能被工具识别、本机会话仍与改动前一致;去掉那一行该测试立即失败。

这一行会影响另一个消费者,必须说明packages/lizi-mcps/src/memory/_shared.tsbuildMemoryScopeKey(workdir, remoteHostId) 定位 memory store。修复后,远端 Pi 会话的 memory 将按 ssh:<hostId>:<path> 分域,而不再按远端路径当本机路径。

  • 这与该字段的既有约定一致(类型定义处注明远端路径不得当本机 key),Codex 侧本来就是这个行为,Pi 属于漏配。
  • 但对已有远端 Pi 会话写过的 memory,这是一次 key 变更:旧记录仍在磁盘上,只是不再被新 key 命中。
  • 我们判断修比不修好(不修则「读到错误本机文件」的路径一直开着),但这属于产品可感知变化,如果维护者认为应该拆成独立 PR 或需要迁移方案,请直接说,我们照办。

威胁模型与它的边界

这一节是本 PR 相对 #1803 最大的变化,且是实质收窄,所以完整写出依据。

先确定能力基线

设计安全边界前要先确定 agent 现在已经能做什么,否则会去防已经不设防的东西。agent 已有:读写工作区、跑任意 shell、任意网络访问,以及——在受管浏览器里跑任意 JScindy_browseract:evaluate)。

最后一条上游已经就此做过裁定,原文就在本 PR 修改的 apps/desktop/src/main/mcp-integrations/browser-managed-config.ts 的 SECURITY POSTURE 注释里:

Page-context evaluate (and recipe evaluate steps) run author/agent JS in Chromium, whose network stack is NOT subject to the Node SSRF guard — a same-origin fetch there can reach any host the browser can. This residual surface is accepted as inherent to browser automation (it's the same capability the act:evaluate tool already exposes), not a regression.

防的是内容,不是 agent

防的是预览的那个 HTML 里可能存在的第三方内容(拉来的模板、别人写的页面)。不防 agent——agent 想外泄工作区内容,curl 一行就够,不需要绕道预览页。

于是问题变成:预览通道给了「内容」什么是它原本没有的? 逐项排查后只有四条,每条对应一个承重控制:

差集 为什么是新的 承重控制
本机任意进程可读工作区文件 listener 在 loopback 上,本机任何进程都能连。这与 agent 有什么能力无关,是新开的面 256-bit 能力 token + 路径 containment
SSRF 边界被撑宽 要让浏览器导航到 127.0.0.1:<port> 必须开口。开成整个 loopback 就等于信任本机所有服务 精确 origin(allowedOrigins),不是 hostname
超出页面生命周期的持久化 预览 origin 上注册的 Service Worker 会活过本次预览 CSP worker-src 'none'
预览页身处私网,对本机其他服务的可达性高于普通页面 浏览器对「私网页面 → 私网服务」的限制远松于「公网页面 → 私网服务」;且页面内 fetch 不走 Node SSRF 守卫,精确 origin 管不到它 CSP connect-src 'self'

明确不防什么

  • 预览页把自身内容外泄到外部 origin(页面自发导航、WebRTC、DNS prefetch)。依据是上面的上游裁定:在这个浏览器里跑任意 JS + 任意联网已经是被接受的既有面。为预览页单独造一套外泄防线,等于给一扇敞着的门旁边的窗户装防盗网。
    需要澄清一点,避免文档与代码打架:connect-src 'self' 事实上挡住跨源 fetch,但它是为「够不到本机其他服务」而设,外泄只是顺带效果。我们不为外泄做任何主张、也不为它加任何机械。
  • 文件系统竞态(TOCTOU)。详见下一节。
  • 本机其它进程冒充预览服务(端口复用、旧 Service Worker 拦截)。前提是本机已有恶意进程且曾在同一个随机临时端口上服务过、注册过宽 scope 的 SW、还在同一个浏览器 profile 里——这落在「机器已沦陷」区间。
  • 预览页之间的浏览器状态隔离(localStorage / IndexedDB / cookie)。所有预览共享一个 origin;token 隔离的是「哪个目录可读」,不是同源浏览器状态。同一用户的多个预览之间没有保密需求。若将来要用它跑真正不受信的代码,这条边界必须重新评估。
  • 用户自己写的恶意 HTML;浏览器引擎 0day。

#1803 的差异:撤回了哪些曾经承诺过的加固

#1803 的产品确认门 #1808 已随 #1803 关闭,那里没法再原地说明,所以在这里逐条交代。这些都是我们公开写过、现在不做的东西,不主动说清楚就是把差异藏起来。

一、机器人在 #1808 列的合并前阻断项,第 3 条被本 PR 撤回

原文要求:

文件检查与打开之间存在 TOCTOU …… 修复目标应是:对实际打开的文件对象/句柄完成最终身份和 containment 验证,而不是简单再增加一次路径检查;并需要覆盖 Windows junction。

我们在 #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 会保留哪些、撤掉哪些。对照结果:

当时承诺 本 PR 实际 依据
保留 Service Worker 清理 未保留 前提是本机已有恶意进程曾占用同一随机端口并注册宽 scope SW,落在「机器已沦陷」区间;且原实现清理失败时 fail-closed,一次 CDP 抖动就会让预览整体不可用,可用性代价大于风险削减
保留 WebRTC 遮蔽 未保留 它防的是外泄,而外泄是上游已接受的既有面
保留一个最小的导航拦截 未保留 同上;另见下面关于上游既有行为的说明
previewLocalHtml action、HTTP 服务本体、路径 containment、token/TTL、CSP 响应头、工具描述引导 保留

另外两条当时说「会一并修掉、不当残余」的:

当时承诺 本 PR 实际 依据
守卫安装失败被吞错,会修 该守卫整体已移除,因此这条随之消失 见下
端口复用时预览页可借 BroadcastChannel 与同端口旧页通信,会修 归入明确不防 前提同样是「机器已沦陷」;BroadcastChannel 的跨预览通信则与「预览页之间不做状态隔离」是同一条已声明边界

关于「最小的导航拦截」为什么撤掉,有一个查证结果值得单独说:上游的导航守卫本来就是 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 条本质上是上游的接线疏漏,可以提给上游源仓库,届时我方补丁自然消失。


怎么验证的

自动验证

pnpm -r --parallel typecheck
结果:8 个包全部 Done,0 error

pnpm --filter @cindy/browser-control-runtime test
结果:9 个测试文件 / 50 条全部通过

pnpm exec vitest run src/main/mcp-integrations/__tests__/   (apps/desktop)
结果:31 个文件 / 638 条通过、8 条 skip

pnpm test:unit:related
结果:53 个包 PASS;唯一失败为 apps/desktop 的 devCliFlags.test.ts 一条用例

关于那条失败:它断言文件系统的大小写敏感语义,在 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 关闭、不按进程名终止任何进程),对真实预览服务跑端到端:

  • 预览页返回 200,相对资源(CSS/JS/JSON)正常加载
  • 页面里的 fetch('./data.json') 成功,页面渲染出数据、截图非空白(12678 字节 PNG)
  • 请求列表只有入口 HTML 与 data.json 两条(首次加载多出的一条 404 是浏览器自动请求 favicon)

connect-src 'self' 挡住其它本机服务这一条做了反向控制:直接 fetch 一个没人监听的端口时,「连接失败」和「CSP 拦截」无法区分,所以先起了一个真在监听的第二本地服务、确认从 Node 侧能读到它的内容,再从预览页去 fetch。页面读不到,浏览器给出的原因是:

Connecting to 'http://127.0.0.1:<port>/' violates the following Content Security Policy
directive: "connect-src 'self'". The action has been blocked.

这同时证明了两件事:connect-src 'self' 没有掐掉页面读自己数据文件的能力(可用性成立),也确实挡住了对其它本机服务的访问(承重控制成立)。

未执行的验证

  • 没有在真实 Desktop 环境里完整跑通 previewLocalHtml 工具调用链。上面两部分分别验证了「预览服务与 CSP 在真实浏览器里的行为」和「导航守卫会放行且只放行那一个 origin」,但没有在运行中的 Desktop 里实际调用一次该工具。这条与 维护者确认:PR #1803 feat(browser): add sandboxed local HTML preview #1808 当时要求的验收范围相比是缩小的,如果维护者认为必须补,请告诉我。
  • 没有验证 RSB 侧栏后端。本 PR 只支持受管浏览器后端,切到侧栏后端时该 action 直接返回错误,不存在需要验证的路径。
  • 没有在 macOS / Linux 上验证。预览服务只用 Node 标准库的 http 与 fs,路径处理走 node:path,但跨平台差异未实测。

风险

风险分类

  • 权限 / 安全 / 用户数据

影响与回滚

  • 影响范围:只在受管浏览器后端且 agent 主动调用 previewLocalHtml 时才有行为。未调用时不监听端口、不修改 SSRF 策略。切换到侧栏后端会关闭 listener 并撤销 origin 授权;服务内部用一个代次计数器保证切换发生在某个请求处理到一半时,那个在途请求也不会重新拿到授权(每次切换推进代次,跨越 await 的请求在每个检查点比对代次,不符即失败)。不触及右栏 store、侧栏、更新链路。
  • 回滚 / 降级方式:整体 revert 即可,无数据迁移、无持久化状态。SSRF 放行是纯内存态(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 里「误杀用户浏览器」那一环,两者不要混为一谈。

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>
@happy9zhang
happy9zhang requested a review from a team as a code owner August 18, 2026 09:12

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/desktop/src/main/mcp-integrations/browser.ts Outdated
Comment thread packages/lizi-mcps/src/browser/tools.ts
@greptile-apps

greptile-apps Bot commented Aug 18, 2026

Copy link
Copy Markdown

Greptile Summary

本 PR 新增基于 token 的 loopback HTML 预览服务,并将其精确 origin 接入受管浏览器的 SSRF 策略。

  • 为工作区 HTML 与相对资源提供带 TTL、路径约束和 CSP 的预览 URL
  • 在后端切换、listener 错误和服务销毁时撤销 origin 授权
  • 将远程主机身份传入 MCP 会话,并在 SSH 会话中拒绝本地预览
  • 为预览服务生命周期、路径边界和导航授权增加回归测试

Confidence Score: 5/5

当前代码已修复先前报告的 listener 错误永久锁死问题,未发现仍需阻止合并的故障。

当前实现会在绑定失败后清除启动状态并允许下一次调用重新创建 listener,同时 generation 与 origin 身份检查阻止被淘汰的启动轮次重新授权,因此没有阻断性故障遗留。

Important Files Changed

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 并清理服务]
Loading

Reviews (3): Last reviewed commit: "fix(pi): carry remoteHostId into the Pi ..." | Re-trigger Greptile

Comment thread apps/desktop/src/main/mcp-integrations/local-html-preview-server.ts Outdated
…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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/lizi-mcps/src/browser/tools.ts
@MagicLizi MagicLizi added awaiting-discussion 等待维护者讨论(review-pr) touches:core 改动碰到架构核心路径(review-pr 自动维护,仅展示) touches:product-ui 改动碰到产品 / UI 面(review-pr 自动维护,仅展示) touches:security 改动碰到安全边界(review-pr 自动维护,仅展示) labels Aug 18, 2026
@MagicLizi

Copy link
Copy Markdown
Contributor

⏸️ 本 PR 触发 product 维护者确认门。

新增沙箱化本地 HTML 预览能力(tokenized 访问),涉及安全和架构。 需维护者在 PR 上 Approve 后方可合并。

讨论 issue 已创建,详见上方链接。

讨论 issue:#2963

@MagicLizi

Copy link
Copy Markdown
Contributor

@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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/desktop/src/main/mcp-integrations/piEnvironment.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting-discussion 等待维护者讨论(review-pr) touches:core 改动碰到架构核心路径(review-pr 自动维护,仅展示) touches:product-ui 改动碰到产品 / UI 面(review-pr 自动维护,仅展示) touches:security 改动碰到安全边界(review-pr 自动维护,仅展示)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants