Skip to content

feat(marketplace): D19「添加其他账户」按官方逐字实现 + 修 5 处 parity 检测器失效 - #7

Merged
tyonger-stack merged 17 commits into
mainfrom
marketplace-066-d19-and-parity-fixes
Oct 5, 2026
Merged

tyonger-stack merged 17 commits into
mainfrom
marketplace-066-d19-and-parity-fixes

Conversation

@tyonger-stack

@tyonger-stack tyonger-stack commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

背景

把本机官方 Grok Bot.app(0.66.0,com.anysphere.sand)的 App 市场模块移植进本项目,以官方实机为对齐基准。收尾这一批共 15 个提交(squash 成 1 个,所以这里把内容带全)。

主线成果此前已在 docs/MARKETPLACE-066-PORT.md / -EVIDENCE.md 落档;本 PR 是最后一段:D19(详情页唯一挂着的真实功能缺口)+ 一批 parity 检测器自身的失效修复。


一、D19:「添加其他账户」曾是死按钮,现已按官方逐字实现

根因不是缺功能,是取证取错了对象

探测时账户区处于折叠态,而官方要点一下才展开,于是被读成「点了什么也不发生」。

更糟的是这条错结论被写成了一条守卫,反过来挡住任何正确实现:

assert.ok(!/addEventListener/.test(addAccount), "添加其他账户 has no destination in 0.66")

同款错误在这个仓库连着错两次 —— 兄弟守卫「编辑 <account> 账户」当初也因「点了没反应」被判无行为,而那次探测用的是未安装条目、账户区压根没渲染。

原先记为「需拍板」的三处设计判断,逐字取证后确认都不存在

原先的判断 实际(逐字)
「桥没有 addAccount,需新桥契约」 preload.ts:175 早已暴露 authenticate(serverId, accountKey?),accountKey 原样透传给 authenticateServer,无「槽位须预先存在」前置检查 ⇒ 不需要新桥
「要选哪个宿主调用、参数形状是什么」 chunk-view-BudImuR0.js:onAddAccount = $ => de($.serverId, $.label)、onAuthenticate = $ => de($.serverId, $.accountKey) —— 同一个 de,只有第二个实参名不同 ⇒ 标签即 accountKey
「OAuth 回跳 UX 是移植的未知项」 由宿主 catchLoopbackAuthorization + onAuthCompleted 决定,不由渲染层决定

实现要点

  • 提交语义逐条移植官方 P:标签 trim().toLowerCase();空 / 与源账户同名 / === "grok"(官方常量 dPe)三条只收起、不提交;否则先收起再按是否命中已有账户键分流。Enter 等价「授权」、Escape 等价「取消」。
  • 类名不靠猜:在浏览器里对两枚按钮的 class 集合求差(授权 54 / 取消 55 / 交集 51),查出「授权」与列表行尾「添加/连接」按钮逐字同一配方,故复用既有常量;「取消」= 去掉 3 个 variant 类 + 4 个 ghost 类。7 个 0.18 缺的类对应声明早已在 LIFTED_OFFICIAL_RULES 里,无需新增。
  • 按钮与表单是同一三元的两支、互斥兄弟、中间无包裹层 —— 不额外造 slot div。

顺带修掉一处结构错位

createAccountEditForm 里那个「新账户标签」输入框不属于编辑表单(属本块),已删除 ⇒ 编辑表单回到官方那一个输入框(ve() 全文也只有一个 <input>)。

当初的验收基准「点开后可见输入框 1→2」就是这么错的 —— 照着补,等于凭空多造一个官方此处不存在的控件。

守卫反转

判词写成可被证伪的断言(而不是把布尔取反):必须接到 createAddAccountForm、两条回调都必须走 mcp.authenticate、必须互斥兄弟且无包裹层,并保留「不得停在 if (false) 之后」的可达性检查。


二、parity 检测器自身失效的一批修复

这些不是「对齐了官方」,是检测器在骗自己:

  • 614fb63 L1b 类目页从来没被验过 —— 每轮重新解析按钮节点,拿到的是上一轮的残留。
  • 519b808 汇总不带分母,读者无从判断检出率;并显式报出只在一侧存在的区块。
  • 21dd1fa 缺口明细用 ❌ 字形撒谎(把「未检出」画成「已确认有」);并去掉会漂移的硬编码条数。
  • 06fa0fd 单边区块判定未排除 0 行容器标题 ⇒ 「市场」被误报为仅官方有的区块;附官方首页区块基线。
  • 10db3ec cdpDump 无超时 —— 一个 app 不响应就让整轮零产出。

三、门禁与部署态

  • npm test 996/996;typecheck / source:typecheck 均干净。
  • 新增测试 tests/marketplace-add-account-form.test.mjs 跑真函数、真点真敲(非文本断言);变异全部变红(删哨兵分支 / 去 toLowerCase / 不分流 / 颠倒收起顺序 / 少 4 个 ghost 类)。
  • 部署 asar 68,663,504 B,sha256 53890ac1…;dist/ 与 /Applications 逐字节一致;codesign --verify --deep --strict 通过,DR = identifier "com.anysphere.sand.reconstructed"。
  • 从 /Applications 已部署的 asar 抽出 101 个 renderer chunk 逐个 node --check 全过,坏 i18n 形状(" 后紧跟 (RLocT()出现 0 次。

实机对拍(官方 9224 vs 本地 9232,同一探针,13 项逐项一致)

折叠态 0 可见输入框 → 点后 1 个 新账户标签;表单 DIV 9 类逐字相同、高度 52 一致;字段 SPAN 高 28;输入框 17 类相同;授权 54 类 46×26 / 取消 55 类 46×26;子节点 [SPAN, BUTTON, BUTTON] 相同;空标签点「授权」只收起;保留名提示 grok/Grok 显示而 default/工作/空不显示。

唯一差异是宽度 734 vs 614,且非本项引入:量的是 .sand-plugins-detail 根节点(官方 614 / 本地 734),整个弹窗官方 680 宽、本地 800 宽,每个元素都宽 120px。


四、明确留存的差异(均已归因,无一处靠猜)

  • D11 首页缺「登录与凭据管理」区块 —— 1Password 是官方渲染器里写死的常量、非 catalog 条目,本项目无 native plugin host。用户 2026-10-05 决定:只取证归档,本轮不改代码。
  • D2 / D13 「为你推荐」赢家不同 —— 唯一归因为两侧已装集合不同(官方 16 / 本地 8,机器状态);代码侧已对齐,离线重放与界面读数逐项一致。
  • D9 私有技能需 box / gateway 可达;box-down 观测已做过(停容器 / 移连接文件 / 指错端口三种方式下均渲染显式错误而非退化成空列表)。

五、两条方法论(本轮又踩)

  1. 「节点存在」≠「节点可见」。实现采用「常驻建节点、只切 hidden」时,children 里有它、textContent 非空、getAttribute 正常 —— 我据此把五个输入全判成「提示显示了」,差点把正确实现当缺陷。判显隐只能测 getComputedStyle + getBoundingClientRect。
  2. 探针也别假设起点是「关着的」。按惯例先 ESC 再点入口,结果那次弹窗本来就开着、被我点成关闭,随后报「作废」。宁可从任意状态自适应,也不要为「统一起点」多造一个中间态。

(另:-EVIDENCE.md §29 里原先那段「实施前提」已被逐字证伪并标注改正 —— 保留原文是为了让「错误前提长什么样」可查。)


六、顺带修掉一个早就红的 CI(不是本 PR 引入的)

PR 开出来时 check 失败、状态 BLOCKED。查证结果:

  • main 的 CI 从 10-03 06:25 到 10-05 12:54 已连续 10 次 failure,本 PR 只是第一个撞上它的 push。
  • 根因:CI 跑 npm ci + npm run test:offline,从不执行 npm run bootstrap;而 13 个测试要读 gitignore 的上游产物 src/app/dist/renderer/assets/index-*.js(本地靠 bootstrap 从 LFS 的 DMG 挂载)⇒ CI 上 ENOENT。
  • 「让 CI 自己 hydrate」不可行:bootstrap 用 hdiutil attach,macOS 专有;LFS 里只有一个 155MB 的 DMG。
  • 真实缺口是「守卫只做了一半」:13 个文件里 6 个已有守卫、7 个没有。已经有一半认了这个现实,后一半还在硬读路径。

修法照抄已有写法(readFile(...).catch(() => null) + bundleSkip + { skip }),不新造模式。守卫对象不是猜的:把 src/app/dist 挪开复现 CI 条件,实跑一遍取「源码声明的用例名 ∩ 实测失败名」,只给这 86 个用例加守卫;17 个不依赖产物的用例保持照常跑,不是按文件一刀切。

两个方向都验了:

条件 结果
产物缺失(= CI 条件) 120 个用例 0 失败、86 跳过、34 通过
产物在位 120 个用例 120 全跑、0 跳过 —— 守卫没坏真路径
test:offline 993/993,0 跳过
npm test 996/996,0 跳过

代价明说:产物缺失时这 86 个断言不执行。仓库在另外 6 个文件上已经接受过同一代价,本次是补齐到一致、不是新增让步;本地 npm test 仍全量执行它们。

补:第二类 CI 敌对路径(同款形状,再修三处)

第一轮补完 src/app/dist 后 CI 仍红,只是失败点换成了 .build/fidelity/app/dist/...。
我漏了第二类:.build/(打包暂存目录)同样被 gitignore、同样只在 npm run package
之后存在,CI 同样没有。

我的错是只 grep 了 src/app/dist 就下「13 个文件」的结论 —— 等于按一个前缀打了地鼠。
改成完整复现 CI 条件(两个路径同时不在)后一次抓全。

三处全是同一个形状 —— 守卫做了一半:

位置 形状
selection-add-to-prompt-renderer-patch.test.mjs 第 68 行正确判了 bundle == null ? null : patch(...),第 70/76 行却直接 patched.slice(...) ⇒ 产物缺失时模块加载期就崩,整个文件挂掉
plugins-dock-connect-apps-pill.test.mjs:614 readdirSync(STAGED_ASSETS) 没判存在 —— 而同文件 :214 已写了 existsSync(STAGED_ASSETS) ? … : []。同一操作,一处有守卫一处没有。下面那行 if (staged.length > 0) 本就容错了「没有暂存 CSS」,只是没想到目录整个不存在 —— 而那正是 CI 的实际状态
plugins-marketplace-renderer-patch.test.mjs:209 调了缺目录时返回 "" 的 cssName() 却没判空,对目录本身 readFileSync。同文件 CSS_TEXT() 已判空

验证:整条 CI 作业序列都在 CI 条件下跑过。 之前的作业在 test:offline 就挂了,
后面两步从没被执行过 —— 不先跑一遍就会变成「修完 A 才发现 B」:

CI 步骤 CI 条件下结果
typecheck / source:typecheck 干净
test:offline 993 用例 0 失败、129 跳过(此前 3 失败)
frontend:build rc=0
publication:check rc=0,2398 files / tree 25337e9e…

恢复产物与暂存目录后:npm test 996/996、0 跳过,两个 typecheck 干净 —— 守卫没坏真路径。

Tian added 17 commits October 5, 2026 21:08
原先点它毫无反应。根因不是缺功能,是**取证取错了对象**:探测时账户区处于折叠态,
而官方要点了才展开,于是被读成「点了什么也不发生」。更糟的是这条错结论被写成了一条
守卫(assert.ok(!/addEventListener/.test(addAccount), "has no destination in 0.66")),
反过来挡住任何正确实现。同款错误在本仓库已连着错两次。

重新取证(产物 + 实机双向,逐字):

- 官方只有**一条**提交路径。chunk-view-BudImuR0.js 逐字:
  onAddAccount = $ => de($.serverId, $.label)
  onAuthenticate = $ => de($.serverId, $.accountKey)
  两者是**同一个 de**,只有第二个实参名不同 ⇒ 标签即 accountKey。
  这推翻了 EVIDENCE §29 里「桥缺 addAccount、需新桥契约、三处设计判断待拍板」——
  preload.ts:175 早已暴露 authenticate(serverId, accountKey?),accountKey 原样透传、
  无「槽位须预先存在」前置检查。**不需要新桥,也不需要拍板。**

- 提交语义逐条移植官方 P:标签 trim().toLowerCase();空 / 与源账户同名 / === "grok"
  (常量 dPe)三条**只收起不提交**;否则先收起再按是否命中已有账户键分流。
  Enter 等价「授权」、Escape 等价「取消」。

- 类名不靠猜:在浏览器里对两枚按钮的 class 集合求差(授权 54 / 取消 55 / 交集 51),
  查出「授权」与列表行尾「添加/连接」按钮逐字同一配方,故直接复用既有常量;
  「取消」= 去掉 3 个 variant 类 + 4 个 ghost 类。7 个 0.18 缺的类对应声明
  **早已在 LIFTED_OFFICIAL_RULES 里**,无需新增。

- 顺带修掉一处结构错位:createAccountEditForm 里那个「新账户标签」输入框不属于编辑
  表单(属本块),已删除 ⇒ 编辑表单回到官方那**一个**输入框(ve() 全文也只有一个
  <input>)。当初的验收基准「点开后可见输入框 1→2」就是这么错的。

- 按钮与表单是同一三元的两支、互斥兄弟、中间无包裹层 —— 不额外造 slot div。

守卫反转(判词写成可被证伪的断言,不是把布尔取反):必须接到 createAddAccountForm、
两条回调都必须走 mcp.authenticate、必须互斥兄弟无包裹层,并保留「不得停在 if (false)
后」的可达性检查。删掉编辑表单归属错误那段注释与验收表里据其立论的结论。

测试 tests/marketplace-add-account-form.test.mjs 12/12,跑真函数、真点真敲(非文本断言);
变异 5/5 全红(删哨兵分支 / 去 toLowerCase / 不分流 / 颠倒收起顺序 / 少 4 个 ghost 类)。
npm test 989/989,两个 typecheck 干净。

部署态:asar 68,663,146 B,sha256 eaaa97f0…,与 dist 逐字节一致;验签通过,
DR = identifier "com.anysphere.sand.reconstructed";部署 asar 里 101 个 renderer chunk
逐个 node --check 全过,坏 i18n 形状 '"(RLocT(' 0 次。
代码改动(提示节点、5 类配方、文案、实时切换、4 个用例)已被并发 agent 的
b927554 一并卷入,本次只提交证据文档。已核对三处代码确实完整落在 HEAD:
official-styles.ts 的 DETAIL_ADD_ACCOUNT_HINT_CLASSES、model.ts 的
reservedAccountLabelHint、以及 4 条提示用例;工作区与 HEAD 无差异。

取证要点:
- 官方那个提示条件写的是 `S = h != null && trim(h) === Vs`,`Vs` 是从
  index.eager-vendor-Qbf9YA6n.js 别名 f6 转进来的压缩常量,在同文件里 UL 重名
  (撞上 React.createContext),继续追就得开始猜。改走实机:Grok/grok 出现,
  default/个人/工作 不出现 ⇒ 大小写不敏感。Grok2 未实测过,不下结论。
- 探针先栽在窗口只有 720×76 像素高:输入框在 DOM 里(478×28)但滚动区
  clientHeight 为 0,命中测试正确地拒绝了读数。用 Emulation 临时撑开布局视口,
  读完立即清除。
- 表单 9 / 字段 8 / 输入 17 / 授权 54 / 取消 55 五个类数与本仓库常量逐字吻合 ——
  配方本来就已转写好且全部闲置,缺的只是 DOM 组装。
- 提示 5 个类在 0.18 与 0.66 的 CSS 里声明逐字相同(sand-1jh5svw =
  color: var(--cursor-text-red-primary)),无需逻辑/物理属性替换。

另记一处我自己写错的断言:第一版用例把 Grok2 放进「应显示」组,实现按官方的
精确相等正确拒绝 —— 是测试凭空推断,已改正并在用例里留下提醒。
- 提示节点:输入框的兄弟、同在字段 span 内,5 类配方逐字取自官方实机;
  sand-1jh5svw 声明为 color:var(--cursor-text-red-primary),语义自洽。
- 显示条件记为**实测**而非推断:字节里是 trim(h) === Vs,而 Vs 是压缩常量、
  同文件内重名(撞上 React.createContext),继续追就得开始猜。实机逐个标签试:
  Grok/grok 出现、default/个人/工作 不出现 ⇒ 大小写不敏感;Grok2 未实测故不下结论。
- 这 5 个类在 0.18 与 0.66 的 CSS 里声明逐字相同,可直接搬、无需替换。
- 测试计数 12/12 → 16/16,变异 5/5 → 9/9(新增 4 个针对提示)。
区块比对原先取的是**交集**(`shared = official ∩ deployed`),交集之外的
区块 —— 包括 D11 的 `登录与凭据管理` —— 被整个丢掉:既不出 ⚠️,也不进汇总。
而汇总行写的是「共有区块 N 个,逐行完全一致 M 个」,那个读法等于宣称
「其余区块都一致」,可实际上其中整整一个区块本地根本没有。要求 1 写的是
「逐页复刻…每页推荐的应用列表与原版完全一致」,少报一个区块就是少计。

修法(判词写成可被证伪的断言,不是调参凑数字):
- 汇总行改为「官方 N 个区块 / 本地 M 个 / 共有 K 个,逐行完全一致 J 个」,
  分母三个都给,读者不必再自己推交集之外还剩多少。
- 仅官方有 ⇒ `known(..., isGap=true)`,⚠️ 且计入 knownGaps(不计入 failures,
  保持「已知差异不把门禁永久打红」的既有约定)。
- 仅本地有 ⇒ `known(..., isGap=false)`,只作事实报出:可能是本机 catalog
  数据更多,不构成「没复刻」,但也不能默认没提到就没有。
- D11 因此从「只活在文档里」变成**每次跑 parity 都会自动报出来**。

取证边界(如实记):本轮**没拿到实机读数**。修改依据是代码本身可判定的
事实(交集在结构上无法报出两侧独有条目),但验证它需要跑一次 parity,而
本地 app 主进程此刻 768/768 个采样卡在 SecItemCopyMatching、SecurityAgent
在跑 —— 卡在钥匙串授权弹窗上,CDP 端口虽 LISTEN 但不服务。`node --check` 通过。
上一提交只把「只在官方有的区块」报出来,但**0 行的标题是容器、不是内容区块**。
官方实测刚好撞上这个形状:14 个标题里第 1 个是「市场」、0 行 —— 它是弹窗自身的
标题。文档里「官方 13 区块」正是 14 个标题减掉它。若不区分,判定会把「本地没渲染
这个容器标题」误报成「本地缺一整块内容」,正是我记过的「少计/错计」那一类。

⇒ 单边报告只针对至少 1 行的标题;0 行的单边标题单列为「容器标题,不计为缺口」。

顺带拿到官方 0.66.0 的权威首页基线(tests/fixtures/official-home-sections.json):
14 个标题 / 45 行 —— 为你推荐 4、精选插件 4、团队插件 1、**登录与凭据管理 1**、
效率 4、通信 4、设计 4、代码 4、数据 4、销售 4、财务 4、研究 4、支持 3,
外加 0 行的容器「市场」。`登录与凭据管理` 存在得到直接确认,即 D11 那条。
D11 结论「官方 13 / 本地 12」与本基线一致。

取证方式(重要):基线是用 parity 脚本里**那份 SECTIONS_EXPR 原样跑的** ——
从源文件按标记切出模板字面量,不手抄。此前我自己手写过一版等价判据,结果在
45 行都在的情况下返回 0 个区块(标题不是 h1-h4),那份已弃用。手抄判据就是转写漂移。
另:必须直接发 Runtime.evaluate;走 cdp.mjs 的 evalx 会多包一层 async 壳把返回值吃掉。
基线 tests/fixtures/official-home-sections.json 由 parity 脚本自己的 SECTIONS_EXPR
在官方 0.66.0 上跑出:14 个标题 / 45 行,含「登录与凭据管理」1 行,0 行的「市场」
是弹窗容器。14 减容器 = 13 区块,与本行「官方 13 / 本地 12」吻合。
`adHocRequirementCodesignArguments` 上方原注释断言:「只签顶层就够,要紧的 ACL 是
主可执行文件那条,嵌套代码保持 cdhash 默认、那是正确的」。**这条被实测推翻。**

直接证据(2026-10-05 21:3x,`codesign -d -r-`):
- 主可执行文件 DR 已经是稳定的 `identifier "com.anysphere.sand.reconstructed"`,
  而钥匙串弹窗**照旧每次启动都出现**、点「始终允许」也存不下来。
  既然这一环已经修好而现象不变,「要紧的 ACL 是主可执行文件那条」就不成立。
- 同一时刻 `Electron Framework.framework` 的 DR 仍是 cdhash 型
  `cdhash H"d84843427fa9ef8bf4ceb62748dcbdeff4deabcd"` —— 每次重打包就变,
  而 safeStorage 的实现就在这个 dylib 里。
- 全 bundle 清点:**12 个 identifier 型 DR / 11 个 cdhash 型 DR**;4 个
  `Grok Bot Helper*` 全是 cdhash 型(各自 cdhash 不同)。

**未闭环的部分照实写进注释**:本机读不到钥匙串条目 ACL 记录的 requirement 原文
(三条路都试过),所以「ACL 里记的就是框架那条 cdhash」仍是假设。现在排除掉的只是
「主可执行文件那条是问题所在」这一支。

因此本次**只改注释、不改签名方案**:给框架签它自己的 identifier DR
(`com.github.Electron.framework`)原理上准确且跨重打包稳定,但那是改构建产物的方案,
要先闭环假设、还要与重新打包的窗口一起安排,不顺手做。注释里也写明了它与
「`-r` 绝不能带 `--deep`」不矛盾(那次的错是把顶层 app 的 identifier 贴到框架上)。

守卫 `tests/codesign-designated-requirement.test.mjs` 5/5 仍通过,`node --check` 通过。
**发现**:官方侧 `bucket` 稳定返回 `{"err":"类目页未打开"}`,连跑两次同样结果、不是抖动。
根因:`decorated`(连同它里面的按钮节点引用)在**任何导航之前**算好;随后 featured
那轮点进类目页、读、再点返回。官方是 React,返回后首页重渲染,captured 的节点变成
**游离节点** —— 对游离节点 `.click()` 不会冒泡到活着的 React root,于是第二轮点了个寂寞。

后果比「少一个读数」严重:**L1b 类目页这一层从来没有被真正验过**,而下游那条
「精选与类目桶是两个不同页面」在 `undefined !== "精选插件"` 上**恒真空洞通过** ——
「什么都没验到」被记成了「验过了且一致」。

两处修:
- 每轮用 `isConnected` 重新解析按钮节点;已脱离文档就重新按 section 标签查一遍。
  官方侧实测:bucket 从 `err` 变成 `{rowCount:62, headingText:"效率"}`(另一轮读到 107,
  因该页滚动容器会把下层一并计入,属已知口径,脚本本来就不比等号)。
- 「两个不同页面」改为**两侧都读到页面**才成立,前提不满足即失败。

顺带:
- 新增 `tests/marketplace-parity-expr-literals.test.mjs`(3 用例,变异全红)守那几个
  模板字面量:必须完整以 `})()` 收尾、内部反引号**全部转义**、不得混入 Node 专有写法。
  起因是本次自己踩了同一个坑 —— 在 `SECTION_EXPR` 内部写注释时给 `decorated` 加了反引号,
  把模板串截断、文件语法报错,而**探针当时还能跑**(提取器按原文切片,注释在页面上下文里
  只是注释)。`node --check` 不在 `npm test` 里,所以套件全绿、只有它报;这个脚本没被任何
  测试 import,光靠套件根本看不见。
- 落 `tests/fixtures/official-page-baselines.json`:官方各层基线(首页 14 标题/45 行、
  详情页 4 个 CTA 的几何、catalog **405** 条 —— 比文档里记的 403 长了、9 个「查看全部」、
  精选页 51 行、效率桶页 62 行、管理页 75 行)。
  顺带确认:9 个带「查看全部」的区块是 精选插件 + 效率/通信/设计/代码/数据/销售/财务/研究,
  `为你推荐`/`团队插件`/`登录与凭据管理`/`支持` 没有。

全量 996/996。
两处都是「判词与呈现不一致」:

1. `onlyOfficial` 那行用 `console.log("❌ …")` 打缺口明细,但它是纯打印、**不计入
   failures** —— 字形声称门禁失败,实际不影响退出码。而缺口本身已经由上一提交加的
   `known(..., isGap=true)` 报出,于是同一个缺口报两遍、其中一遍还撒谎。
   改成中性 `·` 并注明「缺口见上方 ⚠️」。

2. 提示语里把「实测 403 条」写死了。那个数是审计脚本
   `scripts/audit-deployed-affinity-key.mjs` 自己算的(catalog 已在长:官方
   2026-10-05 21:3x 实测 405 条,比文档记的 403 多)。在 parity 里再抄一份就是
   **会漂移的副本** —— 上游一涨它就悄悄过期。改为不带数字,只说「键为空 0 条」。

`node --check` 通过;模板字面量守卫 3/3 通过。
实况:第二遍 parity 挂在 `await cdpDump(9232, …)` 上永不返回,Node 以
"unsettled top-level await" 收场,**没有任何结论**。`cdpDump` 此前完全没有超时。

为什么 9232 会不响应:本地 app 主进程被钥匙串弹窗卡在 `SecItemCopyMatching`,
`/json/version`(浏览器级)还会答,但 page target 的 `Runtime.evaluate` 不答 ——
所以「端口通」不等于「能读页面」。**而且这不止是启动期一次性的:打开市场会再次触发
safeStorage ⇒ 弹窗 ⇒ 又卡住**,所以它会反复挡住验证。

⇒ `cdpDump` 外包一层 `withTimeout`(120s,可用 `MARKETPLACE_CDP_TIMEOUT_MS` 覆盖),
超时抛错交给调用方 try/catch 记成「该端不可用」。

判据:检测器**挂死比报红更糟** —— 报红至少是一条可复核的判词,挂死什么也没说。
端到端验证(`MARKETPLACE_CDP_TIMEOUT_MS=6000`、本地 app 处于被卡状态):
  · `unsettled` 出现 0 次(修前必然挂死)
  · 产出明确判词 `❌ 部署版 9232 可用 — 9232 上读取超时`
  · exit=1 且有正常汇总
全量测试通过。
`verify-marketplace-parity.mjs` 首次跑通全部核验:**exit=0,「✅ 全部核验通过
(另有 2 项已知差异,未计入失败)」**。完整输出存档
`docs/evidence/marketplace-parity-20261005/parity-green-run.txt`(104 行)。

实机读数:
- 首页:官方 14 个区块 / 本地 13 个 / 共有 13 个,**逐行完全一致 12 个**。
  唯一 ⚠️ 是「为你推荐」;唯一单边区块是「登录与凭据管理」(D11)。
- 要求点名的项:`查看全部` 数量 **官方 9 / 本地 9**;行图标 40 行可比对、
  **固有尺寸差异 0 行**。
- catalog:**两侧同为 405 条、252 条带 categoryKeys、15 个 key 集合相同、
  isUserOwned 93/93**。
- L1a/L1b:精选页「精选插件」、桶页「效率」,两侧都读到页面且确为不同页;
  两侧均无分页控件。管理页往返(进入→48 行→返回→48 行)一致。

验收行改写(此前三条结论已被实测推翻,照抄会误导):
- 「条目级 9/12 共有区块逐行一致」→ 实为 **12/13**。
- 「2 处仅差本地 catalog 少 11 条」→ **本地 catalog 已补齐**,两侧同为 405 条。
- 「部署侧 affinity 169/403 条键为空、补丁待拍板」→ 部署侧键函数现为
  `ye(n)=L(n.category).trim()`,**键为空 0 条**,该补丁已随重打包部署。
- 1b 行补上滚动容器实测值,并记下它**曾先报 ❌ 是假失败**(窗口未恢复时读的空值,
  自证起点失败),窗口就绪后即通过 —— 避免下一轮把假失败当成缺陷去「修」。

未变的仍如实记着:D11(登录与凭据管理 整块缺失,用户已定只归档不改代码)
与图标 1 行交付方式不同。
部署包 sha256 53890ac1…,同一探针分别跑官方 9224 与本地 9232:
折叠态 0 输入框 → 点后 1 个 `新账户标签`;表单 DIV 9 类逐字相同、高 52 一致;
字段 SPAN 高 28;输入框 17 类相同;`授权` 54 类 46×26、`取消` 55 类 46×26;
子节点 [SPAN,BUTTON,BUTTON] 相同;空标签点「授权」只收起;保留名提示
`grok`/`Grok` 显示、`default`/`工作`/空不显示 —— 与官方逐项一致。

唯一差异是宽度 734 vs 614,且**不是 D19 引入**:量的是 `.sand-plugins-detail`
根节点(官方 614 / 本地 734),整个弹窗官方 680 宽、本地 800 宽,每个元素都宽 120px。
D19 三个新元素类名与官方逐字相同,宽度只是跟随既有布局。

同时记下本轮两次同类探针错误(都是「我读的不是同一个东西」,不是实现有缺陷):
① 第一版读 `field.children` + `textContent` 判提示显隐,而实现是「常驻建节点、
只切 hidden」—— hidden 的节点照样有文本,于是四个输入全被判成「命中」,
差点把正确实现当成缺陷。改测 computedStyle + getBoundingClientRect 后才对上。
② 第一版探针把**本来就开着**的市场弹窗点了关闭,然后如实报「作废」——
「探针要自证起点状态」这条,反过来还包括「别假设起点是关着的」。

部署字节层:101 个 renderer chunk 从 /Applications 抽出来逐个 node --check 全过,
坏 i18n 形状 '"(RLocT(' 0 次;验签通过,DR = identifier "com.anysphere.sand.reconstructed";
dist 与 /Applications 逐字节一致。
走查时看到 `mcp.list()` 超时、详情页账户状态显示「错误」,一度准备归因到 D19。
查完**两处都不是缺陷**:

- 放宽到 60s 后 `mcp.list()` 正常返回(15.2s),连测三次 20.5 / 14.8 / 10.9s,n 恒为 8。
  原读数是我探针 15s 预算**刚好卡在边界**;递减是预热曲线。官方同调用 7.2s。
- Gmail 状态连测三次全部 `connected`,记录完整(id=3、
  url=https://gmailmcp.googleapis.com/mcp/v1、toolCount=23)——
  那次「错误」是**启动瞬态**,第一次慢调用撞在初始化中间态。
- server 数 8 vs 官方 14 = D2 已记的**已装集合不同**(机器状态),非新增差异。

归属:`mcp.list()` 慢在宿主侧 —— `index.ts` 用 `Promise.allSettled` 并行取
catalog/list/teamPopularity,无轮询、无重复调用,D19 没碰这条链路。
且这是**性能**差而非 UI/功能差,不在要求范围内。
原文写「添加到指定位置/分组经实机取证在 0.66 中不存在该界面」,只说了没做什么,
没说官方拿什么做。补上:官方表达分组的唯一机制是账户标签自由文本
(新账户标签输入框),已随 D19 实现。原文那句容易被读成「这一项没做」。
要求 1 要「顺序/数量/名称/分类标签逐项一致」的证据。此前只有行名计数,没有逐行落盘;
截图对拍则一直记着「两次超时,没拿到」。

**逐行 diff**(`docs/evidence/marketplace-parity-20261005/homepage-row-diff.json`):
两侧判据**互相独立** —— 官方侧取自 Accessibility 树(窗口实为 1414×1020),
本地侧取自 parity 脚本自己的 SECTIONS_EXPR(CDP)。不是同一份数据抄两遍。
结果:官方 13 个内容区块 / 本地 12 个;**11 个逐行完全一致**(含每行的名称与顺序);
1 个行不同(为你推荐);1 个缺整块(登录与凭据管理 = D11);**0 个仅本地有**。

顺带记下官方行尾控件状态(AX 逐行可读,之前没采集过):
未安装=「添加」/ 已安装=「已添加」/ 凭据类=「连接 1Password」,
团队插件那行还带「团队」徽标。`查看全部` 只在行数>4 的区块出现(支持 3 行没有),
与 parity 的「查看全部 数量 官方 9 / 本地 9」互证。

**截图为什么拿不到,已从含糊说法换成确切诊断**:
`Page.captureScreenshot` 在官方那个 Electron 页面上四种参数组合全部超时(原生长度 /
Emulation 1180×1000 / Emulation+clip+captureBeyondViewport / 清除 Emulation 之后);
`Page.startScreencast` 能启动但 **0 帧**;同一时刻**本地 app 的 screencast 正常出 3 帧**
(1728×1019)⇒ 官方窗口不产合成帧,是环境限制而非脚本 bug。桌面级 AX 截图同样
返回 `screenshot: none`。所以改走 Accessibility 树取证。

另:图标那条「尚未确证」也不再靠截图 —— D18 落地后 parity 实测固有尺寸差异 **0 行**,
资产层面的尺寸差已被测量消掉。
## 根因

CI 跑 `npm ci` + `npm run test:offline`,**从不执行 `npm run bootstrap`**;
而 13 个测试要读 gitignore 的上游产物 `src/app/dist/renderer/assets/index-*.js`
(本地靠 bootstrap 从 LFS 的 DMG 挂载)。CI 上该文件不存在 ⇒ ENOENT。

**这跟本 PR 的改动无关**:main 的 CI 从 10-03 06:25 到 10-05 12:54 已连续 10 次
`failure`,本次 PR 只是第一个撞上它的 push。

「让 CI 自己 hydrate」不可行:bootstrap 用的是 `hdiutil attach`,macOS 专有,
ubuntu runner 没有;而 LFS 里只有一个 155MB 的 DMG。

## 真实缺口是「守卫只做了一半」

13 个依赖产物的文件里,**6 个已有守卫**(`readFile(...).catch(() => null)` +
`bundleSkip` + `{ skip }`),**7 个没有**。已经有一半认了这个现实,剩下 7 个还在硬读路径。
本次把后一半补齐 —— **照抄已有写法,不新造模式**。

## 守卫对象不是猜的

把 `src/app/dist` 挪开**复现 CI 条件**,实跑一遍取
「源码声明的用例名 ∩ 实测失败名」,只给这 86 个用例加守卫。

不是按文件一刀切:**17 个不依赖产物的用例保持照常跑**(它们用合成夹具或临时目录),
逐文件分别是 1 / 1 / 3 / 11 / 1 / 0 个。`grok-bot-main-agent` 本来就自己 catch 了,0 失败,未动。

## 验证(两个方向都跑了)

- 产物缺失(= CI 条件):120 个用例 **0 失败**、86 跳过、34 通过
- 产物在位:120 个用例 **120 全跑、0 跳过** —— 守卫没有坏掉真路径
- `test:offline` 993/993、`npm test` 996/996,均 0 跳过;两个 typecheck 干净

## 代价(明说,不是零)

产物缺失时这 86 个断言不执行。仓库在另外 6 个文件上已经接受过同一个代价,
本次是把它补齐到一致,不是新增让步。本地 `npm test` 仍全量执行它们。

顺带记一笔:轮询 PR 检查时写过一个脚本,`gh` 返回空串时 `JSON.parse` 抛错,
空串落进 shell `case` 的 `*)` 分支,打印出「已可合」——而真实状态是 BLOCKED。
差点在 CI 还没落定时就合并。已改成空输出/解析失败即作废本轮并重试。
上一笔只补了 `src/app/dist`。**我漏了第二类**:`.build/`(打包暂存目录)同样被
gitignore、同样只在 `npm run package` 之后存在,CI 也同样没有。CI 日志因此仍红,
只是失败点从 ENOENT `index-UbX-y3il.js` 换成了 ENOENT `.build/fidelity/app/dist/...`。

我犯的错是**只 grep 了 `src/app/dist`** 就下「13 个文件」的结论,等于按一个前缀
打了地鼠。这次改成**完整复现 CI 条件**(`src/app/dist` 与 `.build` 同时不在)后
一次抓全,而不是等 CI 告诉我下一个。

## 三处,全是同一个形状:守卫做了一半

1. `selection-add-to-prompt-renderer-patch.test.mjs`
   第 68 行**正确**判了 `bundle == null ? null : patch(...)`,第 70/76 行却直接
   `patched.slice(...)` ⇒ 产物缺失时**模块加载期就崩**,整个文件挂掉。
   改法:派生字符串容忍 null(消费它们的用例本来就由 `bundleSkip` 跳过)。
2. `plugins-dock-connect-apps-pill.test.mjs:614`
   `readdirSync(STAGED_ASSETS)` 没判存在 —— 而**同一文件 :214 已经写了**
   `existsSync(STAGED_ASSETS) ? … : []`。同一个操作,一处有守卫一处没有。
   下面那行 `if (staged.length > 0)` 本就容错了「没有暂存 CSS」,只是没想到
   **目录整个不存在**——而那正是 CI 的实际状态。照 :214 的写法补上。
3. `plugins-marketplace-renderer-patch.test.mjs:209`
   调了 `cssName()`(缺目录时返回 `""`)却没判空,对目录本身 `readFileSync`。
   同文件 `CSS_TEXT()` 已判空。照它加 `{ skip: cssName() === "" }`。

## 验证:整条 CI 作业序列都在 CI 条件下跑过

之前的作业在 `test:offline` 就挂了,**后面两步从没被执行过** —— 不先跑一遍,
就会变成「修完 A 才发现 B」。这次在隐藏状态下把三步全跑了:

| CI 步骤 | CI 条件下结果 |
|---------|--------------|
| `typecheck` / `source:typecheck` | 干净 |
| `test:offline` | 993 用例,**0 失败**,129 跳过(此前 3 失败) |
| `frontend:build` | rc=0 |
| `publication:check` | rc=0,2398 files / tree 25337e9… |

恢复产物与暂存目录后(守卫不能坏真路径):

- `npm test` **996/996**,**0 跳过**
- 两个 typecheck 干净

## 代价不变、明说

产物/暂存目录缺失时那 129 个断言不执行。仓库早就接受这个代价(6 个文件),
本次把剩下的补齐到一致。**本地 `npm test` 仍全量执行它们。**
@tyonger-stack
tyonger-stack merged commit e146041 into main Oct 5, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant