Skip to content

feat(mobile): HTML 渲染态透传同目录资源,多文件产物不再缺图缺样式(重开 #1455) - #1618

Merged
MagicLizi merged 2 commits into
makecindy:mainfrom
zqchris:mobile-html-resource-passthrough-v2
Aug 4, 2026
Merged

feat(mobile): HTML 渲染态透传同目录资源,多文件产物不再缺图缺样式(重开 #1455)#1618
MagicLizi merged 2 commits into
makecindy:mainfrom
zqchris:mobile-html-resource-passthrough-v2

Conversation

@zqchris

@zqchris zqchris commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

♻️ 本 PR 是 #1455 的重新落地(内容未变)

#1455 堆叠在 #1441 的分支上。#1441 于 2026-08-03 19:00 UTC squash 合并并删除了 base
分支,GitHub 在同一分钟把 #1455 自动关闭 —— 不是有人否掉它,功能也没交付。

本 PR 把 #1455 关闭前 head(330d2e50)的净 delta 原样应用到已含 #1441 的最新 main
上:16 文件 / +2092 / −39,与关闭前逐字一致,11 轮 review 的全部修复都在。base 换成
main,不再堆叠。

重新验证(在新基底上):apps/mobile 四个相关测试文件 145 passed;
pnpm --filter mobile typecheckpnpm --filter desktop typecheck 均通过。

不触发冷更:只改 apps/mobilesrc/ app/ 下 TS 与 i18n JSON,没碰
app.json / app.config.js / eas.json / apps/mobile/package.json / plugins/ /
modules/,不进 runtime fingerprint,可走 OTA。

原 PR:#1455 · 父 PR:#1441


⚠️ 一条 D 档待 owner 确认(2026-08-03,不埋在线程里)

软链可绕过「同目录」约束:手机端只做词法校验(拒 ..),被控端 mediaFetch.ts 解析
realpath 后只比对全局敏感目录 blocklist,没有「资源 realpath 必须落在 HTML base 的
realpath 子树内」这条。因此产物所在目录里若已存在指向目录外的软链,可把 blocklist 之外的
用户文件拉进来内联;与 iOS 侧已记录的 WebRTC 残留信道叠加即构成外传路径。

正确修法在取件端:取件调用多带 base 目录、被控端做 realpath 包含判定。这是跨端接口 +
权限边界,按仓库分档属「全量验证 + owner 先确认」,故本轮未动手。

当前实际暴露面:触发需要产物目录里已存在这样一个软链(agent 自写产物通常不含);
拉取仍受被控端 blocklist 与 media:fetch 既有三道 gate 约束;单资源 2 MiB / 整页 8 MiB
(按回填倍数计费)上限已在下载前生效。

另:掩码层已整块删除(第 5 轮被挖后)。伪引用会占取件配额 —— 这是刻意接受的退化
(图少取几个),换掉「掩错导致正常页面静默全缺」这个更坏的失败模式。不要加回来,除非
引入真正的 HTML tokenizer。

本轮修复与已知限制(2026-08-04,584e7848)

一批 11 条 review 意见按根因分三组处理,不逐条打补丁(本文件的掩码层已经因逐条补丁走完五轮并整块删除)。

① 已修:RAWTEXT 内容整段跳过。 全局标签正则此前会命中 <script> 体里的伪标签,而回填会
真的替换它 —— const tpl = '<img src="logo.png">' 整段变成 data: URI,作者脚本后续的
字符串处理全部失效。把 HTML 模板放 JS 字符串里是常见写法,所以这打坏的是正常页面
这不是把掩码加回来:掩码要判「< 处在哪个数据态」(开放集合),而 RAWTEXT 的终止规则由规范
写死为「</tag 后跟空白 / / / >」,字符串与注释一律不影响,是闭合规则、可精确实现。
残留误判(属性值里的字面 <script>)已钉进用例,该形态极罕见,净收益为正。

一处定级修正:review 把它标为 security(「脚本能读未被 DOM 引用的文件」)。按实际情况
不构成攻击面增量 —— 页面整份由不可信产物控制,作者直接写真标签 <img src="secret.css">
就会被正常回填,不需要伪标签;可达范围同样受「同目录子树 + MIME 白名单 + 容量预算」约束。
故按功能正确性记,未当安全修复宣传。

② 已修:四处有界的识别缺陷。 CSS url() 大小写不敏感;内联 SVG 的 <image href> /
xlink:href 纳入白名单;Windows 判定改为只看根形态(盘符 / UNC)—— 原先用「路径含反斜杠」
会把 POSIX 上合法的 /tmp/a\b/ 误判、导致该页所有资源失败;htmlResourceMimeFor
不再剥 query/fragment(唯一调用方传的已是解析后的文件系统路径,再剥会把 chart#1.png
这类真实文件名的 #1.png 当 fragment 截掉)。

③ 已知限制(需要真 HTML tokenizer,故不修 —— 不埋在线程里)

这四类的后果都是个别资源不被内联(破图 / 空白背景,可见的退化、fail-closed),
不改写文档结构、不取目录外文件、不影响其它资源:

形态 触发写法 后果
标签边界遇属性值里的 > <img alt="w>90" src="a.png"> src 收不到
style 属性用字符引用写 CSS 引号 style="background:url(&quot;a.png&quot;)" &quot; 被计入 URL
表外命名字符引用 <img src="caf&eacute;.png"> 保持原样 → 取不到
SVG fragment 含引号 fragment 取自解码值,回填破坏属性 标记被截断

前两类与第四类的共同缺口是缺少「解码值 ↔ 原文区间」的双向映射,那是 tokenizer 的产物;
第三类是 HTML 命名引用表有 2000+ 项,内联进 bundle 属独立的体积/维护取舍。
apps/mobile 新增 parser 依赖会改动 runtime fingerprint → 触发冷更门
(docs/dev-rules/mobile-development.md:须把关人针对冷更明确确认、转 draft、退出自动合并),
属独立决定,不是本 PR 能顺手带的。

若放行人认为这四类中某条必须先修,那等于要求本 PR 先过冷更门 —— 请直接说,我按那条路走。

堆叠 PR:base 是 #1441 的分支 mobile-html-preview,不是 main。
#1441 让手机端能渲染 HTML,本 PR 补它明确列为「不包含」的那条已知边界。
#1441 合并后本 PR 的 base 会自动改指 main、diff 自动收敛;请按 #1441 → 本 PR 的顺序看。

这次改了什么

摘要

#1441 让手机端能渲染 agent 产出的 HTML,但只拿到 HTML 本身 —— 页面里
<img src="./chart.png"><link href="assets/a.css"> 这类相对引用在 about:blank
文档里解析不到,于是多文件产物「页面能开、图和样式全缺」。桌面端靠 file://
同目录天然没有这个问题,手机端得自己把资源取回来。

做法:把相对引用挑出来 → 换算成被控端绝对路径 → 逐个走既有的 media:fetch
绝对路径取件通道拿 presign 地址 → 回填进 HTML,取完一次性渲染。

不新增 device-link channel、不新增安全面 —— 用的还是单文件预览已经在用的那条
通道(fetchRemoteAbsFileToUrl,对 workdir 内外一视同仁)。这是刻意选的方案:另一条
路(被控端 http 服务透传,让手机开 http://localhost:5173)需要新增代理转发通道,
等于把被控端的本机网络位置借给手机,安全面是另一个量级,不在本 PR。

变更类型

  • feat 新功能
  • fix 缺陷修复
  • refactor / perf 重构或性能优化
  • docs / test / chore 文档、测试或工程维护
  • 其他:

范围

  • 关联 Issue / 需求:无 issue;承接 feat(mobile): HTML 生成物在手机端进渲染态(WebView 离线沙箱) #1441 描述里列为「已知边界」的那一条
  • 本 PR 包含:本地 HTML 渲染态的同目录资源透传
  • 明确不包含:
    • 被控端 http 服务透传(手机打开 agent 起的 dev server)—— 需要新 device-link
      channel + HTTP 代理转发,安全面显著扩大,须独立立项并过确认门。
    • 外链 CSS 内部的 url():要先取回 CSS 再递归解析它引用的字体/图片。本 PR 只处理
      同一份 HTML 文档内的引用(含 <style> 块)。
    • srcset(多候选 + 密度描述符,收益低于复杂度)。
    • 统一云端交付物接口(仍是独立立项)。
  • 用户可见变化:手机端预览带 assets/ 的多文件 HTML 时,样式与图片会正常显示;取件
    期间显示占位;有资源取不到或超上限时顶部给一条如实提示。
  • 是否存在 breaking change:无。自包含页面走零请求路径,行为与 feat(mobile): HTML 生成物在手机端进渲染态(WebView 离线沙箱) #1441 完全一致。

UI 变化

  • 引用的设计规范:
    • DESIGN.md §10 Light / Dark 双模式交付门槛 —— 本次新增的两处 UI(取件占位、
      资源提示条)全部复用同页既有 themed 组件与语义 token:占位用 centerFill +
      ActivityIndicator(colors.textTertiary) + hintText(与既有 PDF / 音视频取件占位
      同款),提示条直接复用截断横幅的 truncBar / truncText(surfaceChip /
      textSecondary)+ Info 图标。零新增颜色值、零硬编码。
    • 截图:多文件产物回填后的渲染结果见「手工验证」。Light / Dark 未真机目检,如实记在
      「未执行的验证」。
    • 文案:四语言(en / zh-CN / ja / ko)同步新增 3 个 key,pnpm check:i18n-glossary 通过。

怎么验证的

自动验证

pnpm test:unit
结果:exit 0(全绿,3073 tests)

pnpm --filter mobile typecheck
结果:exit 0

pnpm check:i18n-glossary
结果:exit 0(en / zh-CN / ja / ko 无新增违规)

pnpm check:dco
结果:passed(3 commits signed off,1 exempt merge commit)

新增 32 个用例,集中在纯函数层(apps/mobile/src/__tests__/htmlLocalResources.test.ts):

  • 路径换算:相对引用 / ./ / 子目录 / 尾分隔符 baseDir / 查询串与片段剥离 /
    %20 解码(含非法百分号不 throw)/ Windows 被控端反斜杠 join / 中文目录名。
  • fail-closed 边界(逐条钉住):含 .. 段、根相对 /assets/x.png/etc/passwd
    本机绝对 D:\…https://http://localhost:5173data:file://、协议相对
    //host、纯锚点、空 baseDir —— 一律不改写。
  • 词法定位:白名单标签的资源属性、<style> 块的 url()、无引号属性值;
    <a href> / data-src / img-wrapper 不误收;区间精确指向属性值本身(不含引号)。
  • 回填:多处替换不串位、取不到的保留原引用、同路径多处共用一个地址、style 块与
    属性混排、去重与上限(超出计入 skipped,不静默截断)。
  • 取件编排(fetchHtmlResourceUrls):全成功 / 单个失败不影响其它 / 回空地址算失败 /
    并发不超上限且每路径只取一次 / 已取消后停止后续取件 / 空清单不发请求。

取件核心特意抽成纯异步函数就是为了能这样测 —— 本仓 mobile 没有 hook 测试设施,而为一个
测试给 apps/mobile/package.json 加 devDependency 会动 runtime fingerprint、触发冷更
(docs/dev-rules/mobile-development.md 冷更边界),代价不成比例。

手工验证

端到端跑通了核心机制(不只是单测):造一份真实多文件产物 —— index.html +
assets/style.css(外链 CSS)+ assets/logo.svg(<img> 相对引用)+ assets/dot.svg
(<style> 块里的 url())—— 用本 PR 的函数识别出 3 个资源、回填成可下载地址,把
回填后的 HTML 单独放进一个没有 assets/ 兄弟目录的空目录,再交浏览器按 390px
手机视口渲染。三项全部正常显示(蓝底白字圆角卡片 = 外链 CSS 生效;橙色圆点 =
<style> 里的 url() 生效;绿色方块 = <img> 相对引用生效)。渲染目录里没有任何
资源文件,所以它们能显示只可能是回填的结果 —— 这正是要验的那一步。

未执行的验证

  • RN WebView 端到端未在真机或模拟器验证(需手机 app + 在线被控桌面 + device-link
    会话)。上面验证的是「识别 → 回填 → 浏览器能加载」这条链;真机上还要经 media:fetch
    取件与 presign 下载,那两段是 feat(mobile): HTML 生成物在手机端进渲染态(WebView 离线沙箱) #1441 之前就在跑的既有通道(图片 / PDF / 音视频共用)。
    用户可执行的验证步骤:让 agent 在会话 workdir 里产出 index.html + assets/(含一张
    图和一份 CSS)→ 手机点开该 HTML → 应看到完整样式与图片;把图删掉再看,应显示破图 +
    顶部「N 项页面资源没取到」提示。
  • Light / Dark 未真机目检:新增 UI 全部复用既有 themed 组件、零新增颜色值,但按 §10
    的要求这不等于「双模式已验证」,如实记为未目检。
  • 未验证超大资源的实际耗时:并发上限 4、条数上限 32,单个资源走既有 media:fetch
    上传 OSS。真机上一页引用几十张大图时的总耗时未实测。

风险

风险分类

  • 无已知风险
  • SQLite / migration
  • system prompt
  • 协议兼容
  • 权限 / 安全 / 用户数据
  • 存量插件兼容(批准状态 / 指纹 / manifest 校验 / 安装布局 / 包格式)
  • 原生层 / fingerprint / OTA
  • 跨平台差异
  • 其他:

⚠️ 两条待放行人裁决(已 resolve 线程,但不是已消除)

按仓库规则「产品取舍归放行人」「同一根因反复被触碰时暂停局部补丁」交出,不在实现层单方面决定。

1. 惰性文本掩码层:删掉,还是换真 tokenizer?

maskInertHtmlText 存在的唯一目的是「注释 / 脚本体 / 模板体里的伪引用不要占掉 32 项取件配额」。
它已经被 review 连挖四轮,每轮一种新的误判形态:注释里的伪引用 → <template> 体 →
属性值里的字面标签(<div data-tpl="<template>">)与脚本字符串里的孤立 <!-- → 跨两个脚本
配对的 <!-- / -->。根因是正则扫标签无法判别「< 处在哪个 HTML 数据态里」,要根治得有
真正的 tokenizer。补特例可以无限补下去。

两条路:

  • (a) 删掉整个掩码层(我的倾向)。它消除的风险是「图少取几个」(可见的退化);它引入的
    风险曾经是「正常页面静默全缺」(不可见)。四轮下来,后者已经大于前者。删掉后回到照原文扫,
    行为可预测、代码更少。
  • (b) 换成真 tokenizer。能根治,但属独立设计;且 apps/mobile 的依赖是 runtime fingerprint
    输入,引入解析库会触发冷更门。

当前 head 的行为是安全的(未闭合标记一律不掩码,不会再让正常页面全缺),所以留到裁决为止没有
用户风险,只是仍存在上述误判。

2. 资源预算是单页的,不是屏级的

取件挂在 active 上(父级把当前页 ±1 都置 active,文本预取需要它),而 visible 只管 WebView
挂载。所以目录里有多个相邻 HTML 文件时,3 个 TextPreviewPage 会各自跑一份 8 MiB / 4 并发,
实际上限变成约 24 MiB / 12 并发

  • (a) 把取件也收到 visible 门后:上限自然回到单页,但滑到 HTML 文件时才开始取资源 ——
    必然先看到一段 spinner,把现在预取消掉等待的体验退掉。
  • (b) 提升成模块级(屏级)预算与并发池(我的倾向):体验不变,但要引入跨页共享的预算账本
    与取消语义(翻页后在途取件退还多少额度),属独立的状态设计。

风险已被压小的部分:单资源 2 MiB 上限在下载之前就按 media.size 生效,所以倍增的是
「总量与并发」,不是「单个对象可以多大」。


补充说明(勾「无已知风险」但请 reviewer 主动看这四点):

  1. ⚠️ 本轮改掉了一个用户可见行为,需要放行人过目:预览里点 http(s) 外链不再转系统
    浏览器打开,现在只放行 about:(页内锚点)。原因是 review 挖出的一条真实外传路径 ——
    页面内联了从被控电脑取回的资源字节(data: URI),而 CSP 必须允许内联脚本,于是作者
    脚本能把这些字节拼进一个真实 <a href="https://attacker/?d=…">,用户随手一点数据就发
    出去了;CSP 管不到顶层导航(navigate-to 已从 CSP3 移除),点击门只挡程序化导航。
    两点让代价比看起来小:① 这条能力本来只在 iOS 上存在(Android 不上报
    navigationType,一直是拒绝),删掉是把两端对齐;② 与本 PR 已接受的取舍一致 ——
    CSP 已让公网图片/字体在预览里不加载,预览本就是离线的。
    若产品上希望保留外链可点,正确做法是补一套「只放原文里字面存在的 URL」的静态
    白名单 + URL 归一化,属独立设计;本 PR 按「宁可不放行」收窄。外链退路:工具栏
    「分享」送到电脑打开,或切「源码」态自己看 URL。

  2. 不触发冷更。未碰 apps/mobile/package.json / app.json / app.config.js /
    eas.json / plugins/ / modules/ 任何一处,零新增依赖,改动全在 JS/TS 源码。

  3. 不新增 device-link channel,取件通道与安全边界不变。资源取件复用
    fetchRemoteAbsFileToUrlmedia:fetch(xdt-file://open?path=<abs>),与单文件
    预览、聊天 chip 打开 workdir 外文件走的是同一条;media:fetch 本就不限 workdir,
    靠 device-link 三道 gate(被控开关 / 撤销黑名单 / channel 白名单)保证「等同本地
    访问」的信任级。本 PR 没有放宽这层。

  4. 路径面 fail-closed,一个 HTML 拉不到它目录之外的东西。只改写相对引用,且含 ..
    段一律拒绝 —— 引用只能落在 HTML 自己所在目录的子树内。根相对 /x(语义是 web root)
    与本机绝对 file:///… / D:\…(最该警惕的形态)都不改写,保持原样渲染成破图。
    放行 .. 需要先定义「逃到哪层还算安全」,那是独立的边界决定,本 PR 不做。

影响与回滚

  • 影响范围:仅 apps/mobile(文件预览页 + 两个新模块 + 3 个 i18n key)。桌面端零改动,
    共享层零改动。
  • 回滚 / 降级方式:整体 revert 即可,无数据迁移 / 无持久化格式 / 无协议变化。单独降级
    也容易:把 <HtmlFileReader html={htmlResources.html}> 换回 state.content,立刻退回
    feat(mobile): HTML 生成物在手机端进渲染态(WebView 离线沙箱) #1441 的行为(自包含页面照常,多文件缺资源)。

提交前检查

  • 已 review 完整 diff
  • 每个 commit 都带 DCO 签名(git commit -s,见 DCO
  • UI 改动已在「UI 变化」注明引用的设计规范章节(不涉及 UI 则跳过)
  • 未提交凭证、令牌或授权文件
  • 已补充必要文档(why 全部落在代码注释:htmlLocalResources 头注的边界推导、
    useHtmlLocalResources 的状态语义与「为什么抽纯函数」、预览页的通道选择理由)
  • 已确认测试结果或说明未执行原因

Review 修复(本轮)

安全(P1,Codex + Greptile 各报一次):签名地址不得进可执行页面。 原实现把同目录资源的
预签名地址回填进 DOM,而渲染态保留 JavaScript —— 内联脚本可读 img.src 再以 no-cors 外发,
第三方即可在有效期内下载该被控端文件;<img src=".env"> 这类引用会把同目录任意文件变成可
外传的 bearer URL。我在原描述里写的「权限面比桌面更小」是自我说服,这条确实是本 PR 引入的
漏洞。两层修:

  1. 页面里不再出现签名地址:app 侧下载后转 data: URI 再回填(downloadRemoteMediaAsDataUri),
    单资源 2 MiB 上限,MIME 按扩展名白名单给准(表外类型含音视频一律不改写 —— 整份内联会
    撑爆内存;.env 这类也因此被挡)。
  2. 关掉页面网络出口:新增 htmlPreviewCsp,注入 default-src 'none' + 资源只放行 data:
    • connect-src 'none' + form-action 'none',挂在渲染载体上,任何进 WebView 的 HTML 都带
      策略。

刻意接受的代价:公网 https 图片与字体在预览里不再加载。 放行 img-src https: 等于留
new Image().src='…?d=…' 这条经典外传通道,会让上面第 2 条形同虚设。预览态因此是完全离线
渲染的 —— 这也修正了原描述里「公网图片正常显示」的说法。

SSH(P2):资源取件的 xdt-file://sessionId/remoteHostId/workdir(三项齐备才拼,
与被控端完整性校验同口径),并进取件缓存键(同一路径在不同远端主机上是不同文件)。

CI 红为既有基线问题,不在本 PR 修:Windows unit tests 的失败用例是
src/renderer/voice-input/__tests__/VoiceInputSection.recordingGate.test.ts,已由 issue #1448
记录「在 main 上稳定失败(疑与 CRLF 检出有关)」,另有修复 PR #1440 与维护者确认 #1444
本 PR 只改 apps/mobile,不可能影响桌面 renderer 的该用例;该用例本地全绿。

本轮新增用例:CSP 策略内容与注入位置(含 doctype 不得被挤后导致 quirks mode)、MIME 白名单
正反例、「签名地址不得回填」与「SSH 上下文必须齐备」的接线守卫。pnpm test:unit
pnpm --filter mobile typecheck 全绿。

重新落地 makecindy#1455。原 PR 堆叠在 makecindy#1441 的分支上,makecindy#1441 于 2026-08-03 19:00 squash 合并
并删除 base 分支,GitHub 因此把 makecindy#1455 自动关闭(同一分钟),功能并未交付。本分支把
makecindy#1455 的净 delta(16 文件 / +2092 / -39)原样应用到含 makecindy#1441 的最新 main 上,内容与
关闭前的 head 330d2e5 一致,11 轮 review 的修复全部保留。

能力:手机端预览 HTML 产物时,把同目录下被引用的 img / css / js / 字体等资源按需取回
并内联成 data: URI,多文件产物不再缺图缺样式。取件受「同目录子树 + MIME 白名单 +
单资源 2 MiB / 整页 8 MiB 预算」三重约束,失败一律 fail-closed(个别资源不内联,
不改写文档结构)。

不动 runtime fingerprint:只改 apps/mobile 的 src/app 下 TS 与 i18n JSON,不碰
app.json / app.config.js / eas.json / apps/mobile/package.json / plugins / modules,
可走 OTA 热更,无冷更边界。

验证:
- apps/mobile 相关四个测试文件 145 passed
  (htmlLocalResources 79 / htmlPreviewCsp 27 / filePreviewPagerWiring 25 / remoteMedia 14)
- pnpm --filter mobile typecheck 通过
- pnpm --filter desktop typecheck 通过

遗留(与关闭前一致,见 PR 正文首段):软链可绕过「同目录」约束这条 D 档仍待 owner
确认,正确修法在被控端做 realpath 包含判定,属跨端接口 + 权限边界,未在本 PR 动手。

Signed-off-by: Chris <tkdv42k4mg@privaterelay.appleid.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 4, 2026 02:21
@zqchris
zqchris requested a review from a team as a code owner August 4, 2026 02:21
@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown

Greptile Summary

本 PR 为手机端 HTML 预览补齐同目录资源透传,并强化取件边界。

  • 解析 HTML 中受支持的相对资源引用,按条数和整页容量预算并发取回,再以内联 data URI 回填。
  • 为本地和 SSH 取件增加 baseDir、单资源大小及 SSH 会话上下文约束,并补充 OSS 对象回收。
  • 为 WebView 注入离线 CSP、限制导航出口,并新增加载态、资源缺失提示及四语言文案。
  • 扩充资源解析、预算调度、CSP、远程媒体和桌面取件测试。

Confidence Score: 4/5

当前 PR 尚不适合合并,SSH HTML 资源取件仍可通过基目录内软链读取同一 workdir 中基目录外的文件。

本地分支已经比较资源与基目录的 realpath,并发预算也通过同步预留避免超额;但 SSH 分支仅比较词法前缀,远端服务随后只验证资源 realpath 是否仍在整个 workdir 内,因此指向 workdir 内其他目录的软链仍能绕过 HTML 同目录边界。

Files Needing Attention: apps/desktop/src/main/file-browser/ssh-media.ts、packages/file-browser-core/src/scanner.ts

Important Files Changed

Filename Overview
apps/mobile/src/session/htmlLocalResources.ts 新增 HTML 相对资源识别、路径解析、MIME 白名单、RAWTEXT 跳过及安全回填逻辑。
apps/mobile/src/session/useHtmlLocalResources.ts 新增带共享预留的并发取件调度,当前实现避免并发请求共同突破整页预算。
apps/mobile/app/files/preview/[sessionId].tsx 将资源取件、data URI 转换、OSS 清理、加载提示与 HTML 预览生命周期接入页面。
apps/desktop/src/main/device-link/mediaFetch.ts 本地文件分支在上传前执行资源与基目录的 realpath 包含校验和大小限制。
apps/desktop/src/main/file-browser/ssh-media.ts SSH 分支增加大小限制和 baseDir 约束,但 baseDir 仍仅按词法路径判断,未完整封闭既有软链边界问题。

Sequence Diagram

sequenceDiagram
  participant H as HTML 预览
  participant M as 手机端资源调度
  participant D as Desktop media:fetch
  participant S as SSH 文件服务
  H->>M: 相对资源引用
  M->>D: absPath + baseDir + maxBytes
  D->>D: 词法 baseDir 前缀检查
  D->>S: stat/readFileChunk(relPath)
  S->>S: realpath 校验 workdir 边界
  S-->>D: 文件字节
  D-->>M: OSS 地址
  M->>M: 下载并转 data URI
  M-->>H: 回填后的 HTML
Loading

Reviews (2): Last reviewed commit: "fix(mobile): 同目录约束与大小门禁改由被控端强制,并给整页预算加预留..." | Re-trigger Greptile

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

本 PR 为 mobile 侧 HTML 渲染态补齐“同目录资源透传”能力:在 WebView 渲染前识别 HTML 内的相对资源引用(含 <style>style="" 内的 url()),将其换算为被控端绝对路径,复用既有 media:fetch 取回字节并转为 data: URI 回填,从而解决多文件产物在手机端“缺图缺样式”的问题,并补齐 SSH 会话取件上下文与资源回收等安全/资源治理细节。

Changes:

  • 新增纯函数模块 htmlLocalResources + hook useHtmlLocalResources:负责引用收集、预算/并发编排、回填与状态汇报。
  • 资源取件链路强化:资源不回填 presign URL、下载转 data: URI、并在失败路径也回收 OSS 对象;SSH 场景补齐 sessionId/remoteHostId/workdir,并将其纳入缓存键。
  • UI 与测试:预览页增加取件占位与资源缺失/截断提示;新增/扩展多组单测与 i18n 文案。

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
apps/mobile/src/session/useHtmlLocalResources.ts 新增 hook 与限并发/预算的批量取件编排,并回填 HTML 与上报失败/预算信息
apps/mobile/src/session/remoteMediaDiskCacheExpo.ts 新增下载后转 data: URI 的工具函数,供 HTML 资源内联使用
apps/mobile/src/session/remoteMedia.ts resolveMobileRemoteMedia 增加 onOssKey 回调,支持 presign 失败等路径下的对象回收
apps/mobile/src/session/remoteAbsFileFetch.ts media:fetch 绝对路径取件支持 SSH 上下文并进缓存键;新增“一次性取件”以拿到 ossKey 且不进共享缓存
apps/mobile/src/session/htmlLocalResources.ts 新增资源引用识别/路径换算/MIME 白名单/RAWTEXT 跳过/回填/去重与上限规划等纯函数实现
apps/mobile/src/session/fileBrowserGallery.ts xdt-file://open URL 生成支持 SSH 上下文三项齐备校验与 query 拼接
apps/mobile/src/i18n/locales/zh-CN/files.json 增加 HTML 资源取件占位与缺失/截断/超预算提示文案
apps/mobile/src/i18n/locales/ko/files.json 同上(韩语)
apps/mobile/src/i18n/locales/ja/files.json 同上(日语)
apps/mobile/src/i18n/locales/en/files.json 同上(英语)
apps/mobile/src/tests/remoteMedia.test.ts 增加 onOssKey 时序与“校验失败仍交出 key”等回归用例
apps/mobile/src/tests/htmlPreviewCsp.test.ts 增加资源取件安全接线守卫(data URI 回填、下载前 size gate、OSS 回收、SSH 上下文)
apps/mobile/src/tests/htmlLocalResources.test.ts 覆盖路径换算、fail-closed 边界、RAWTEXT 跳过、预算/并发与回填等核心逻辑
apps/mobile/src/tests/filePreviewPagerWiring.test.ts 扩展预览页接线守卫:资源透传链路、提示语、WebView 挂载/滑动让路条件等
apps/mobile/app/files/preview/[sessionId].tsx 预览页集成资源透传:计算 baseDir、取件与回填、loading 占位、notice 提示、OSS 回收与 SSH 上下文传递
apps/desktop/src/main/file-browser/ssh-media.ts SSH 文件服务 MIME 白名单补齐(css/js/fonts 等),保证 SSH 预览同目录资源能被正确服务
Suppressed comments (1)

apps/mobile/src/session/htmlLocalResources.ts:268

  • htmlBaseDirOf 在 Windows 盘符根目录场景会返回 "C:" 之前的 "C:"(缺少分隔符),例如 C:\a.html 会得到 C:,导致后续 isWindowsAbsPath 判定失败并把路径当成 POSIX 拼接,进而让同目录资源定位错误。建议对 C:\a.html / C:/a.html 这类 drive root 单独保住根形态。
export function htmlBaseDirOf(htmlAbsPath: string): string {
  const lastSep = Math.max(htmlAbsPath.lastIndexOf('/'), htmlAbsPath.lastIndexOf('\\'));
  if (lastSep < 0) return '';
  if (lastSep === 0) return '/'; // `/a.html` → 根目录
  return htmlAbsPath.slice(0, lastSep);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apps/mobile/src/session/remoteAbsFileFetch.ts Outdated
Comment thread apps/mobile/src/session/htmlLocalResources.ts Outdated
Comment thread apps/mobile/src/session/useHtmlLocalResources.ts Outdated
Comment thread apps/mobile/src/session/htmlLocalResources.ts
Comment thread apps/mobile/src/session/useHtmlLocalResources.ts Outdated

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

ℹ️ 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/mobile/app/files/preview/[sessionId].tsx Outdated
@MagicLizi

Copy link
Copy Markdown
Contributor

@zqchris 👋 这个 PR 还有 6 条 review conversation 没 resolve(apps/mobile/src/session/remoteAbsFileFetch.ts / apps/mobile/src/session/htmlLocalResources.ts / apps/mobile/src/session/useHtmlLocalResources.ts / apps/mobile/app/files/preview/[sessionId].tsx),auto-review 因此暂时跳过、没法继续审查 / 合并。

如果你已经按评论改完或回应了,请到对应 thread 上点 Resolve conversation;全部 resolve 后,下一轮 auto-review 会自动重新审查这个 PR。

@MagicLizi MagicLizi added status:threads-open 还有未 resolve 的评审讨论(review-pr 自动维护,仅展示) touches:core 改动碰到架构核心路径(review-pr 自动维护,仅展示) touches:large-diff 改动量较大(review-pr 自动维护,仅展示) touches:product-ui 改动碰到产品 / UI 面(review-pr 自动维护,仅展示) labels Aug 4, 2026
一批 6 条 review 意见按根因分三组处理。

① 软链绕过同目录边界(P1 security,Copilot + Greptile 同根因)
手机端只做词法 `..` 校验,那**只能保证词法子树**;产物目录里若有指向目录外的软链,
词法路径完全合法,而被控端 mediaFetch 在 realpath 后只比对全局敏感目录 blocklist ——
blocklist 之外的用户文件会被取回、内联进不可信页面,再叠上已记录的 WebRTC 残留信道
即构成外传路径。

修法就是 review 指的那条:取件 URL 带 `baseDir`,被控端对**资源与 baseDir 各自
realpath 后**判定包含关系(按路径段比,不是字符串前缀)。两侧都 realpath 是必须的 ——
只 realpath 一侧时 `/tmp` → `/private/tmp` 这类平台软链会把合法资源误拒。
baseDir 解析不了一律拒绝(fail-closed)。SSH 分支在 materializeSshRemoteMedia 内按
workdir 相对路径做同一判定(远端 realpath 需要 file-service 未暴露的 RPC,这条限制
与既有 SSH 媒体边界同级,代码里写明了)。

② 上传前大小门禁(P2)
原先只在手机侧按 `media.size` 拒,而那时被控端已经上传完 OSS(SSH 还先整份拉进
Desktop 磁盘缓存),流量与磁盘已经花掉。改为 URL 带 `maxBytes`,被控端在 stat 之后、
上传/拉取之前拒绝。手机侧那道判断**保留**:老被控端不认新参数(版本歪斜是 fail-open),
size 缺失或谎报时也要兜住。

两个参数畸形一律抛错,不静默降级成"不约束"——否则约束可被畸形输入摘掉。

③ 并发取件突破总量预算(P1)
旧实现只在取回**之后**结算,4 路并发会全部先进 fetchOne,手机同时持有约 4× 单资源
上限的字节,整页 8 MiB 预算形同虚设。改成预留制:开工前按「剩余预算 ÷ refCount」把
这一次的字节上限收窄并预留对应字符数,取回后按实际长度结算、立刻归还没用掉的部分。
收窄出来的上限就是 ② 里下发给被控端的 `maxBytes`,于是**在途**下载量也被总预算约束。
预算不足时不立刻判超预算而是等一次结算(在途预留随时会释放),否则并发满载那一刻
排到的资源会被误判成超预算;只有"没有在途、预算又确实不够"才是真耗尽。
换算函数 bytesForDataUriChars 刻意保守,保证预留恒不越总预算(已钉进用例)。

④ SSH 缓存键与 URL 判定不一致(P2)
抽出 effectiveRemoteMediaSshContext,URL 构造与 fetchRemoteAbsFileToUrl 的缓存键共用
同一份「三项 trim 后非空」判定。原先缓存键只看 ssh 对象是否存在就无条件拼三个字段,
字段为空时 URL 已退化成不带 SSH 参数,键却被分叉 —— 同一次取件永远命不中缓存。

⑤ 两处注释表述不准(P2/P3)
htmlLocalResources 的「子树」改写成「词法子树」并点明真实边界在被控端;预算注释按
预留制的实际语义重写(「后续不再启动新的取件」)。

不动 runtime fingerprint:只改 apps/mobile 的 src/app 下 TS 与 apps/desktop main 侧,
没碰 app.json / app.config.js / eas.json / apps/mobile/package.json / plugins / modules。

验证:
- pnpm --filter mobile test → 3205 passed(285 文件),含新增 6 个预算/收窄/换算用例
- pnpm --filter desktop exec vitest run src/main/device-link src/main/file-browser
  → 628 passed(36 文件),含新增 11 个 baseDir/maxBytes 用例(软链逃逸、兄弟目录前缀、
  /tmp 软链不误拒、fail-closed、畸形参数、SSH 侧 stat 前拒绝)
- pnpm --filter mobile typecheck / pnpm --filter desktop typecheck 均通过

Signed-off-by: Chris <tkdv42k4mg@privaterelay.appleid.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 4, 2026 02:59
zqchris added a commit to zqchris/cindy that referenced this pull request Aug 4, 2026
`withLock > 不同 key 并行,耗时近似单次而非 N 倍` 断言
`elapsed < delay * 2.5`(20ms × 2.5 = 50ms)。三个 20ms sleep 串行是 60ms —— 阈值
离"串行"只差 10ms,等于拿调度抖动当被测行为。Windows CI 两分片并跑时实测 52ms,
直接把 main 打红(makecindy#1618 的 client-ci 就是被这条挡下的,与它的 diff 毫无关系)。

改成直接量"并行"本身:记每个临界区的进入 / 离开时刻,断言
`max(进入) < min(离开)` —— 存在一个瞬间三者同时在临界区内。串行执行下第二个的进入
必然晚于第一个的离开,这条一定不成立;而机器多慢都不影响它成立。

被测语义没变(不同 key 不互相串行),只是把判据从"跑得够快"换成"确实重叠"。

验证: `vitest run src/main/skillhub/registry/__tests__/lock.test.ts` 5 passed。

Signed-off-by: Chris <tkdv42k4mg@privaterelay.appleid.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated no new comments.

Suppressed comments (2)

apps/desktop/src/main/device-link/mediaFetch.ts:189

  • parsePathMediaConstraints 里对 baseDir 使用了 path.resolve(rawBaseDir)。当被控端运行在 Windows、但 URL 携带的是 SSH 远端的 POSIX 路径(如 /home/u/proj/out)时,win32 的 resolve 会把它变成 C:\\home\\u\\proj\\out,随后 materializeSshRemoteMedia 的 toWorkdirRelPosix 会判 baseDir 不在 workdir 内,导致 SSH 会话下的 HTML 资源取件被误拒。建议这里不要做平台相关的 resolve,仅做 trim + 绝对形态校验,把规范化留给后续 realpath(本机分支)或 SSH 分支的 posix 逻辑。
  let baseDir: string | null = null;
  if (rawBaseDir !== null) {
    if (!rawBaseDir.trim()) throw new Error('媒体 baseDir 不能为空');
    if (!(rawBaseDir.startsWith('/') || WIN_ABS_RE.test(rawBaseDir))) {
      throw new Error('媒体 baseDir 必须为绝对路径');
    }
    baseDir = path.resolve(rawBaseDir);
  }

apps/mobile/src/session/htmlLocalResources.ts:301

  • htmlBaseDirOf 在 Windows 盘符根目录场景会返回 C: 而不是 C:\\/C:/:例如 C:\\a.html 的 lastSep=2 时 slice(0,lastSep) 会丢掉分隔符,导致后续 isWindowsAbsPath 判定失败、baseDir 约束参数也会被 mediaFetch 判为“非绝对路径”。建议对 drive root 特判,保留分隔符。
export function htmlBaseDirOf(htmlAbsPath: string): string {
  const lastSep = Math.max(htmlAbsPath.lastIndexOf('/'), htmlAbsPath.lastIndexOf('\\'));
  if (lastSep < 0) return '';
  if (lastSep === 0) return '/'; // `/a.html` → 根目录
  return htmlAbsPath.slice(0, lastSep);

@MagicLizi MagicLizi added status:awaiting-bot-review 等外部审查机器人表态(review-pr 自动维护,仅展示) and removed status:threads-open 还有未 resolve 的评审讨论(review-pr 自动维护,仅展示) labels Aug 4, 2026
zqchris added a commit to zqchris/cindy that referenced this pull request Aug 4, 2026
同一根因的第三、四条(前两条见本 PR 前两个 commit)。

① `packages/device-link` client.test.ts —— `getToken 挂起超过 getTokenTimeoutMs → 走
退避重连,不永久卡在 connecting`

用固定 `await tick(30)` 赌「10ms getToken 超时 + ≤5ms 退避 + 第二轮 getToken 都能在
30ms 内跑完」。Windows 两分片并跑时 socket 还没建出来 → `expected 1, received 0`
(实测 makecindy#1616 的 client-ci)。改成与同文件其它用例一致的有界等待,断言语义不变。

② `packages/maker-core` contacts/manager.test.ts —— `Test timed out in 5000ms`

这条**不是**墙钟断言,是纯正确性用例被默认超时判死。该文件每个用例都在 os.tmpdir()
里真开 better-sqlite3 落库(建目录 → 建表 → v1→v2 迁移 → FTS5 重建 → 删目录),
Windows CI 两分片并跑、叠上 Defender 对新建文件的实时扫描,这些**同步** IO 会超过
vitest 默认的 5s。实测 makecindy#1618 的 client-ci 就是被它挡下的(makecindy#1618 的 diff 只碰 mobile
HTML 预览,与 contacts 毫无关系);此前值班日志也记过同一文件 4 个用例同时 5s 超时。

packages/maker-core 没有自己的 vitest 配置,拿不到 apps/desktop 那份
`testTimeout: win32 ? 20_000 : 5_000`,所以按仓内既有写法(git-integration 系列用例的
`vi.setConfig`)在该文件单独放宽到 win32 30s。**只放宽时间,不放宽任何断言** ——
它测的是迁移正确性,从来不是"迁移够快"。

验证:
- `pnpm --filter @cindy/device-link exec vitest run src/__tests__/client.test.ts` → 100 passed
- `pnpm --filter @cindy/maker-core exec vitest run src/contacts` → 122 passed(6 文件)
- `pnpm --filter desktop exec vitest run src/main/skillhub` → 281 passed(24 文件)
- @cindy/device-link / @cindy/maker-core / desktop 三个 package 的 typecheck 均通过

Signed-off-by: Chris <tkdv42k4mg@privaterelay.appleid.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
@MagicLizi MagicLizi removed the status:awaiting-bot-review 等外部审查机器人表态(review-pr 自动维护,仅展示) label Aug 4, 2026
@zqchris

zqchris commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

本轮 CI 红灯不在本 PR 范围内,记录一下判定过程,免得下一轮再被同一条挡住。

失败点:

Windows unit tests (2/2)
FAIL packages/maker-core/src/contacts/__tests__/manager.test.ts > MakerContactsManager > v1 库升级到 v2: 老数据保留 + relations 可用 + FTS 重建
Error: Test timed out in 5000ms.

本 PR 的 diff 只有 apps/mobile 的 HTML 资源透传与 apps/desktop main 侧的取件门禁(16+4 个文件),不碰 packages/maker-core,更不碰 contacts。这条是 main 基线上的既有问题:该文件每个用例都在 tmpdir 里真开 better-sqlite3 落库并跑 v1→v2 迁移 + FTS5 重建,Windows 两分片并跑时同步 IO 超过 vitest 默认 5s。此前值班日志记过同一文件 4 个用例同时 5s 超时。

上一轮挡下本 PR 的 Windows unit tests (1/2) 也是同类(skillhub 锁的 elapsed < 50ms,而三个 20ms sleep 串行是 60ms)。

修复在 #1616,那个 PR 现在收了四条同类用例(device-link 两条、skillhub 一条、maker-core contacts 一条),纯测试改动 +25 −11。没有把它们塞进本 PR,是因为那会把 device-link / skillhub / maker-core 三拨评审面拖进一个 mobile 预览 PR。

所以本 PR 的推进顺序是:#1616 先合进 main → 本 PR merge main(或重跑)→ CI 绿。本 PR 侧 0 条未 resolve、Greptile / DCO / verify / Desktop Git integration / shard(1/2) 全绿,没有待办。

@MagicLizi MagicLizi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

自动审查通过(heavy 档)。

安全扫描:无凭证泄露、无恶意代码、无后门。三层防御(词法校验 → realpath 包含 → stat 预检)设计合理。

代码质量:19 文件 / 2793 行变更,架构遵循仓库模式(纯函数模块 + hook 编排 + 复用 media:fetch 通道)。测试覆盖充分(864 行新测试,含路径穿越、RAWTEXT 跳过、CSS 大小写、Windows 路径、SVG fragment、预算与并发)。

P2 建议(不阻断):

  1. htmlLocalResources.ts:577 — style block body offset 空串边界情况(无实际影响)
  2. htmlLocalResources.ts:552 — findCssUrlRefs 的 indexOf 理论上在重复子串时可能偏移(概率极低)
  3. useHtmlLocalResources.ts:189 — presign 轮换会触发重取(cancellation 已正确处理)

注意:chatgpt-codex-connector 静默期已超时放行(等待 30.9min),其意见可能在合并后才到。

@MagicLizi
MagicLizi merged commit 0e1e791 into makecindy:main Aug 4, 2026
6 of 8 checks passed
@MagicLizi

Copy link
Copy Markdown
Contributor

整套本地资源透传写得相当扎实——三层防御加上完善的预算控制,让「多文件产物在手机上不缺图」这个长期痛点终于闭合了。测试里那批 RAWTEXT 边界用例特别好,把五轮 review 积累的经验都钉住了。🛡️

justinbao19 pushed a commit to justinbao19/cindy-ci that referenced this pull request Aug 4, 2026
* test(device-link): 握手超时用例改有界等待,修 main 上 Windows 单测红灯

`握手超时(open 后 hello-ack 一直不来)→ 强制断开走退避重连` 用固定
`await tick(50)` 等 15ms 握手看门狗 + 退避重连落地。Windows CI 两分片并跑时
事件循环调度远超名义毫秒数,50ms 内 watchdog 可能还没换连接,断言
`sockets.length >= 2` 拿到 1 → main HEAD (e931a57) 的 client-ci 红灯。

改成同文件已有的有界等待模式(与紧邻的「open 从未到来」用例一致):
`for (let i = 0; i < 40 && h.sockets.length < 2; i++) await tick(10)`。
断言语义不变,只把"固定窗口"换成"等到发生或超时",上界 400ms。

纯测试改动,不动产品代码。

验证: `pnpm --filter @cindy/device-link test` 163 passed。

Signed-off-by: Chris <tkdv42k4mg@privaterelay.appleid.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>

* test(skillhub): 锁并行用例改断言临界区重叠,去掉墙钟阈值

`withLock > 不同 key 并行,耗时近似单次而非 N 倍` 断言
`elapsed < delay * 2.5`(20ms × 2.5 = 50ms)。三个 20ms sleep 串行是 60ms —— 阈值
离"串行"只差 10ms,等于拿调度抖动当被测行为。Windows CI 两分片并跑时实测 52ms,
直接把 main 打红(makecindy#1618 的 client-ci 就是被这条挡下的,与它的 diff 毫无关系)。

改成直接量"并行"本身:记每个临界区的进入 / 离开时刻,断言
`max(进入) < min(离开)` —— 存在一个瞬间三者同时在临界区内。串行执行下第二个的进入
必然晚于第一个的离开,这条一定不成立;而机器多慢都不影响它成立。

被测语义没变(不同 key 不互相串行),只是把判据从"跑得够快"换成"确实重叠"。

验证: `vitest run src/main/skillhub/registry/__tests__/lock.test.ts` 5 passed。

Signed-off-by: Chris <tkdv42k4mg@privaterelay.appleid.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>

* test: 再修两条把 main 打红的 Windows 计时型用例

同一根因的第三、四条(前两条见本 PR 前两个 commit)。

① `packages/device-link` client.test.ts —— `getToken 挂起超过 getTokenTimeoutMs → 走
退避重连,不永久卡在 connecting`

用固定 `await tick(30)` 赌「10ms getToken 超时 + ≤5ms 退避 + 第二轮 getToken 都能在
30ms 内跑完」。Windows 两分片并跑时 socket 还没建出来 → `expected 1, received 0`
(实测 makecindy#1616 的 client-ci)。改成与同文件其它用例一致的有界等待,断言语义不变。

② `packages/maker-core` contacts/manager.test.ts —— `Test timed out in 5000ms`

这条**不是**墙钟断言,是纯正确性用例被默认超时判死。该文件每个用例都在 os.tmpdir()
里真开 better-sqlite3 落库(建目录 → 建表 → v1→v2 迁移 → FTS5 重建 → 删目录),
Windows CI 两分片并跑、叠上 Defender 对新建文件的实时扫描,这些**同步** IO 会超过
vitest 默认的 5s。实测 makecindy#1618 的 client-ci 就是被它挡下的(makecindy#1618 的 diff 只碰 mobile
HTML 预览,与 contacts 毫无关系);此前值班日志也记过同一文件 4 个用例同时 5s 超时。

packages/maker-core 没有自己的 vitest 配置,拿不到 apps/desktop 那份
`testTimeout: win32 ? 20_000 : 5_000`,所以按仓内既有写法(git-integration 系列用例的
`vi.setConfig`)在该文件单独放宽到 win32 30s。**只放宽时间,不放宽任何断言** ——
它测的是迁移正确性,从来不是"迁移够快"。

验证:
- `pnpm --filter @cindy/device-link exec vitest run src/__tests__/client.test.ts` → 100 passed
- `pnpm --filter @cindy/maker-core exec vitest run src/contacts` → 122 passed(6 文件)
- `pnpm --filter desktop exec vitest run src/main/skillhub` → 281 passed(24 文件)
- @cindy/device-link / @cindy/maker-core / desktop 三个 package 的 typecheck 均通过

Signed-off-by: Chris <tkdv42k4mg@privaterelay.appleid.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>

* test(device-link): 心跳僵死用例改有界等待(第五条打红 main 的计时型用例)

`心跳:连续无 pong 超限 → terminate + 重连` 用固定 `await tick(40)` 赌「8ms ping 周期
× 2 轮都能在 40ms 内跑完」。刚合并的 main(0e1e791)的 client-ci 就是被它挡下的:
`expected false to be true`(terminate 还没发生)。改成有界等待到 terminate 真的发生。

同时对整个 client.test.ts 做了一次清扫,结论记在 issue 里(见 PR 描述):全文件共 35 处
`await tick(>=10)`,其中约 15 处属本类(固定等待后断言"某事已发生",慢机器会假失败),
另有约 15 处是反向形态(等一段时间断言"某事没发生"),后者不能用轮询修 —— 它要求真实经过
的时间**小于**某个阈值,慢机器上等越久越危险,只能调被测时间参数,属另一件事。
本 PR 只收正在发作的那几条,不把整份清扫塞进来。

验证: `pnpm --filter @cindy/device-link test` → 163 passed(6 文件)。

Signed-off-by: Chris <tkdv42k4mg@privaterelay.appleid.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>

---------

Signed-off-by: Chris <tkdv42k4mg@privaterelay.appleid.com>
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
Co-authored-by: Chris <4436110+zqchris@users.noreply.github.com>
wykris added a commit to wykris/cindy that referenced this pull request Aug 4, 2026
* main: (39 commits)
  fix(desktop): preserve legacy plugin permission approvals (makecindy#1657)
  fix(im): 控制命令收归主人专属,群成员的 !stop / slash 不再生效 (makecindy#1658)
  fix(pi): 第一方 MCP 工具走 host 审批策略,与 Claude 的 auto-review 判定对齐 (makecindy#1639)
  test(desktop): validate Python probe execution (makecindy#1653)
  fix(plugin): 支持真实包权限变化后重新确认 (makecindy#1648)
  fix(desktop): sync pinned sessions to mobile (makecindy#1492)
  test: 五条把 main 打红的 Windows 计时型用例改成断言行为 (makecindy#1616)
  fix: 补齐任务列表导出菜单 (makecindy#1620)
  feat(mobile): HTML 渲染态透传同目录资源,多文件产物不再缺图缺样式(重开 makecindy#1455) (makecindy#1618)
  fix(device-link): 暴露待命状态并诊断不稳定连接 (makecindy#1611)
  fix(desktop): 修复 Claude Opus 套餐错误提示 (makecindy#1607)
  feat(hook-control): 官方 Telegram 卡消除配置重叠,并支持默认工作目录 (makecindy#1595)
  feat(pi): PI 子代理(只读画像),与 Claude / Codex 共用同一张子代理卡 (makecindy#1458)
  fix(desktop): 让原生分类器故障观察器的漏检在日志里可见 (makecindy#1588)
  fix: 把「审阅器不可用」与「模型判定危险」拆开,前者提示一次 (makecindy#1597)
  test(maker-core): 钉住 MCP fail-closed 闸绑定 resolver 而非界面 (makecindy#1587)
  feat(mobile): HTML 生成物在手机端进渲染态(WebView 离线沙箱) (makecindy#1441)
  feat(desktop): 统一 @ 资源引用与搜索 (makecindy#1557)
  feat(codex): 子代理卡补齐实时状态,与 Claude 子代理卡形态统一 (makecindy#1438)
  test(maker-core): 锁定 auto-review 送审用目录模型 id 而非 wire 串 (makecindy#1582)
  ...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

touches:core 改动碰到架构核心路径(review-pr 自动维护,仅展示) touches:large-diff 改动量较大(review-pr 自动维护,仅展示) touches:product-ui 改动碰到产品 / UI 面(review-pr 自动维护,仅展示)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants