Skip to content

[REQ-128][CODE] renderer 侧 package 安装:attempt/preview 往返与密钥采集接线 #732

Closed

Description

@jinjunnn

用户结果

用户在扩展中心点「安装」,能真的把一个 package 装上 —— 包括需要密钥的那种。

今天卡在哪(实测,非推断)

alpha-code#713 已交付 main 侧完整 admission:四组 digest 绑定、重取重验、
瞬时密钥采集、真实事务与写盘。端到端证据在案(密钥真落到
alpha-mcp-secrets/<server>/v-<hex>/<VAR>、0600、配置只留 {file:} 引用、
返回值/配置/installs.json/grants.json/journal 全不含明文)。

但用户走不到那条链。 extension-hub.tsx:474-477 发出的意图是
{catalogId, scope} —— 没有 attemptId,而 admission 要求它。
R1 审计用真注册的 IPC 喂这个真实形状,得到的是
{ok:false, reason:"package-prerequisite-required", package:<同一张视图>} ——
界面把同一张卡片再画一遍,没有 preview、没有密钥输入、没有安装。

为什么没有票拥有它

编排者把 REQ-128 的 21 张票剪到 Phase 1 的 7 张时,验证了「用户结果 AC7 用这 7 张能不能拿到」,
但没有逐条验证剩下这几张合起来闭合了用户可达路径:
#695 的范围是呈现,#713 的范围是 main 侧 admission,
renderer 的 attempt/preview/密钥采集不在任何一张的边界内。这是剪裁失误,非实现方缺陷。

已勘破:不是从零做新交互

extension-hub.tsx:2427-2450 已有完整的密钥采集对话框:
type="password" 输入、envValues() 状态、alpha.ext.keyPlaceholder / keyHint 文案、
运行时依赖与风险提示行。legacy catalog 条目一直在用它。

本票是把 package 的 attempt/preview 往返接进这个已有对话框,不是新画一套。
因此不另开设计轮(与 #695 同理:已批交互上的数据源增量)。

Development plan

  1. 详情页/卡片的 package 动作改为:先向 main 请求 preview(不带密钥值),
    拿到 packageAuthorization.binding 与待确认能力集。
  2. 复用现有确认对话框呈现:能力差异、组件事实、需要哪些密钥;
    密钥输入沿用现有 type="password" 字段与文案。
  3. 提交 confirm:只送 attemptId + binding + 瞬时密钥值,不送 verdict/action
    (#702 的 preflight 会拒绝 renderer 提供的这些键)。
  4. 失败路径要有行内错误面 —— #695 R2 已记录 package 动作失败今天没有对应物
    (legacy 走 cardErr,package 路径没有),且 void runPackageAction(...) 会让
    reject 变成未处理拒绝。
  5. 成功后刷新卡片与详情(#695 R2 转记的「动作后新 view 回流」在这里一并闭合)。

Acceptance and named gates

  • 用户可达路径的端到端闸:从真实渲染的按钮点击出发,走到 #713 的 admission,
    断言密钥真落盘、返回值与 DOM 全程不含 canary。
    ⚠️ 这条必须挂在真实渲染路径上(#702/#695 的 Blocker 都是「闸门在、但守的不是生产路径」)。
  • 密钥值不得进入 renderer 全局 state、日志、DOM 属性或事件通道(断言返回值与 DOM,不断言源码文本)。
  • 取消/失败后零残留:无 attempt 泄漏、无密钥落盘、行内错误可见。
  • resolve-prerequisite 与 install 两种动作类各自被真正执行过
    (#695 R2 记录 resolve-prerequisite 从未真执行)。

Dependencies

alpha-code#713(admission,本票消费它)。

Out of scope

  • 新增 UI framework / 新对话框体系 / provider 专用交互;
  • MCP OAuth 与 Alpha Connection 的采集面(#703/#704,Phase 2);
  • Bundle / Plugin。

Exit condition

Merged PR + 上述 named gates 全绿 + 用户可达路径的端到端证据 + L2 视觉证据
(密钥采集态是新增帧,需补进 req128-package-detail 矩阵)。

Activity

  1. added a commit that references this issue on Jul 31, 2026
  2. self-assigned this
    on Jul 31, 2026
  3. jinjunnn commented on Jul 31, 2026

    @jinjunnn
    OwnerAuthor

    R1 对抗审计记录(Opus,只读,36 组变异)+ 编排者裁决

    预算:本票定级 M,审计上限 2 轮。R1 已用。修复后走 R2(只判闭合 + 修复 diff),R2 后合并。

    审计判定:RECOMMEND: BLOCK。
    两条 Blocker 都是闸门空缺,不是线上 bug —— 审计写探针实测生产在这两处行为是对的。
    但本票对 extension-detail.tsx 的全部生产改动(3 行)可整段删掉而 3314 条全绿,
    授权对话框里的能力差异面板也可整段删掉而全绿。

    存活项扛住了全量,不是抽样误差

    审计把 7 个零红存活一起打进 scratchpad 副本跑 bun test src:
    3127 pass / 34 fail / 19 errors,与该副本基线逐位相同。
    交叉验证:grep -rln "ExtensionDetail\|ExtAuthzView" 显示全仓只有
    ext-package-detail-wiring.cases.ts 一个测试会渲染这两个组件
    ,
    所以结构上不可能有别的用例捕获 B-1/B-2。


    编排者裁决

    B-1 详情页行内错误面零闸 → 修

    删掉 extension-detail.tsx:241-243 那三行 → cases 8 pass / 0 fail。
    那三行就是本票对该文件的 100% 改动(diff = extension-detail.tsx | 3 ++)。

    更糟的是:scripts/gate-files.tsv:57 这条登记的描述明写「…新 attempt 重试、行内错误及 8 pass」。
    卡片那半确实被钉住(M6 → 2 fail),详情页那半一条都没有。
    登记簿在替一个不存在的覆盖背书。

    这是 #695 R1 的形状再演一次(用例只点卡片、没点详情页)—— 只不过这次漏的是错误面而不是按钮。

    修法(审计已用探针跑通,约 5 行):第 7 条用例改成先进详情页再点 .alpha-ext-dsub button,
    断言 [data-package-detail] [role='alert'] 含 "transport failed"。

    B-2 授权对话框的能力差异面板零闸,且唯一的「闸门」断言的是手写静态 HTML → 修

    删掉 extension-hub.tsx:2519-2524 的 <ExtAuthzView … diffs={state().preview.items} />
    → 8 pass / 0 fail,安装照样成功、confirmed 照样带全量 diff key
    (它由 preview.items 构造,与渲染无关)。

    用户后果:在一个授权对话框里按「确认安装」,而对话框不告诉他要授予什么能力。
    审计探针实测生产今天渲染的是 alpha.ext.authz-intro「Generic Remote MCP」首次安装,请求以下能力:

    • alpha.secret-prerequisite.v1 + 新增 chip + 确认即授权上述完整能力集。
      删掉之后全部消失,而门不响。这是两趟 admission 流程里唯一给用户「知情」的那一面。

    闸门为什么是假的:本票恰恰为这个面板给 ext-package-presentation.test.ts 加了
    alpha.ext.authz.chipNew / alpha.ext.authz.note 两个 key —— 但那条断言查的是
    docs/verification/.../harness/package-detail-harness.html 这份手写复刻的静态 HTML
    里有没有这两句中文,不是生产组件渲染出没渲染。断言夹具文本而非运行结果 = 假闸门形态①。

    修法:第 4 条用例里 confirmPackageAuthorization() 之前加两行 DOM 断言。
    ⚠️ 别只钉一个值 —— package:renderer-ready 的 capabilities 是空集、
    package:renderer-prerequisite 是单能力集;在第 2 条用例里补一句
    「空能力集时不出现 .alpha-ext-authz-cap」,才杀得掉写死常量(形态⑨)。

    M-1(类)对话框渲染什么 / 何时点不动,成片无断言 → 修,走咽喉点一次收口

    8 条用例断言的几乎全是 installIntents 的形状。成员:

    成员 删掉后的用户后果 有断言吗
    M26 能力差异面板 盲授权 无(= B-2)
    M7 详情页错误面 详情页失败静默 无(= B-1)
    M11 packageSecretsComplete 恒 true 空密钥可点确认 → main 回 invalid grants,用户看到一句看不懂的失败 无
    M19 首趟 !ok 的 setErrFor 离线 / 包在浏览后变 blocked 时,点安装完全没反应 无
    M36 setAuthzBusy(true) 提交中可重复点确认(同 attemptId → stale or replayed),或提交中 Esc 关框而安装其实提交了 无

    按本仓「同一类第二次出现就切类模式」的规矩,走咽喉点:
    在已有的 packageAuthorizationDialog() 助手旁加一个 expectDialogContract(dlg),
    一次断住 {能力行 + 新增 chip + 密钥字段 + 空值时确认按钮 disabled},
    在第 3、4 条用例各调一次;第 7 条改走详情页入口。合计约 10 行,B-1/B-2/M11/M36 一起关掉。
    M19 另需约 5 行(现有 rejectPreviewOnce 只造 throw,造不出 {ok:false} 无 stage 的返回)。

    审计给的「归一化 innerHTML 快照」加强版:不采纳。 维护噪音大,且快照不点名什么重要;
    审计自己也按「修法最小」推荐前者。

    M-2 密钥零回显闸维度不全 → 修,四轴全补

    现有闸只扫 document.body.textContent 与 IPC 返回值。
    M21(明文写进 <input title>)、M22(console.log 明文)、M23(挂 globalThis)三个变异各自全绿。

    审计判 console / 全局两轴超纲、只补属性轴。我不采纳这个收窄 ——
    本票 AC 原文写的是「密钥值不得进入 renderer 全局 state、日志、DOM 属性或事件通道」,
    四轴是我自己在 AC 里点名的。只补一轴,AC 的另外三句就是没有支撑的散文。
    助手本来就要写,多两轴是几行的事。

    ⚠️ 与 #696 R1 同形(那次零回显闸去读 7 个源文件,而真实组装点不在清单里,两个绕过全绿)。
    这次是维度不全而非位置不对,但被绕的方式一样。

    审计实测生产是干净的(探针 P3,canary 走完整条真实链路后逐轴扫):
    逐元素全部属性 + .value = [];innerHTML/textContent = false;
    console.* 全捕获 = 0 命中;globalThis 全枚举 = [];IPC 返回值 = false;
    落盘全树递归扫描唯一命中 alpha-mcp-secrets/generic-remote/v-0badcafe/A_KEY 本身,
    alpha.jsonc / install 记录 / journal 全干净。

    Minor m-1 / m-2 → 不修,留痕

    hub 关闭兜底 resolve、busy/enabled 守卫无闸,但生产代码是对的且必要
    (前者不做的话 busy 永久卡住、该包按钮整个 session 点不动);属「主动加的防御分支无测试」,不拦合并。

    Minor m-3 / m-4 → 归 L2,主 session 采集

    extension-hub.css:1020 的 overflow-wrap: anywhere 只在静态 harness 可见;
    matrix P6「长 prerequisite label」截图待采。

    「留空」文案 → 不在本票改,另开 P2

    审计跑了真实渲染真实按钮:空 / 纯空格 → 按钮 disabled = true;非空 → false。
    同屏文案是 需要密钥(留空则装好后再配) + placeholder 粘贴密钥…(可留空)。

    按判据:按钮禁用 ⇒ 文案与行为不一致(困惑,但点不坏),不是 Major。
    真实代价是用户看到「可留空」、按钮却是灰的、且没有任何一句话解释为什么灰 —— 死胡同,不是数据损坏。
    不在本票改的理由已核:alpha.ext.confirmEnv / alpha.ext.keyPlaceholder 与 legacy 条目安装对话框共用,
    那条路径上留空确实合法。要修得新开 key 或改 legacy 语义,两者都超出「补 renderer 那半」的边界。


    审计核过、确认没问题的(射程记录)

    • 两个入口的动作接线两边都红:M8(详情页按钮 no-op)2 fail、M9(卡片按钮 no-op)5 fail。
      #695 那个形态在接线层已经堵住,只有错误面那层漏了(= B-1)。
    • 两类动作都真执行过:install 走详情页(用例 2)+ 卡片(用例 7);
      resolve-prerequisite 走卡片(用例 3/4/5/6),真写出 0600 密钥文件、真翻前置条件状态。
      不是 mock —— 真 createPackageAdmissionCoordinator、真 runCatalogInstallWithPackagePreflight、
      真注册的 IPC handler、真事务、真 secret store;installLegacy 直接 throw,漏到 legacy planner 会立刻红。
    • 四个陷阱全部真守住:A(M10 改回 legacy 过滤 → 2 fail,第 4 条用例专造 preauthorized 场景)、
      B(M30 首趟塞 secrets → 5 fail,Object.keys().sort() 全等断言)、
      C(M2 写死 attemptId → 2 fail,用例 5/6 断言取消与失败后都换新 id)、
      D(M5/M16/M31 全红)。
      D 的 required:false 子情形审计查了 package-installability.ts:150 ——
      if (!component.required) 直接判 blocked,Phase 1 结构上不存在可安装的 required:false 前置条件。第零问:到不了,不开。
    • binding 原样带回:M17 篡改 capabilityDigest → 4 fail。
    • 未处理 rejection 真的消除了:M13 删首趟 try/catch → 1 fail。
    • 失败/取消零残留:用例 5/6 断言 alpha-mcp-secrets 不存在、alpha.jsonc 不存在、canary 不在 DOM;
      加 P3 全树落盘扫描。泄漏的 attempt 只留在 main 的 attempts Map,MAX_ATTEMPTS=64 有界。
    • 闸门登记零余量:四条下界全是精确实跑条数,delegates_to 指向的 package-admission.test.ts 也在册。
    • 范围纪律:preload/ 零改动(那条「三个 import specifier + exposeInMainWorld 恰好 1 次」的闸没被碰);
      package.json/bun.lock 零改动;diff 里 ipcRenderer/ipcMain/GATED_WRITE_CHANNELS 新增行为 0;
      extension-detail.tsx 只加 3 行、没有为挂测试重构分支结构;无 as any/@ts-/TODO;未触 UPSTREAM_PATHS。
    • legacy 对话框回归:open={!!confirming() || !!packageAuthz()} 改写里 legacy body 分支
      加了 !packageAuthz() && 前缀,packageAuthz() 为 null 时行为逐字不变。

    这一轮最值得记的一条:审计自己撞上第零问并主动撤回

    审计本来准备把 Esc 键守卫开成 Blocker —— extension-hub.tsx:632 的
    if (confirming() || authz() || confirmBusy() || authzBusy()) return 白纸黑字写着
    「授权框在场…hub 层绝不消费 Esc —— 否则外层先关掉整个 hub」,
    而本票新增的模态状态没有登记进去。教科书级的「枚举漏,最先漏掉的是我们自己新产生的东西」。

    跑了一遍才发现到不了:dialog-core.ts:171 用 addEventListener(…, true) 挂在捕获相
    并 stopImmediatePropagation(),而 hub 的 onKey 是冒泡相、焦点又被 Dialog 圈在面板内
    ⇒ hub 的监听器根本不会执行。探针实测:卡片入口 Esc → 对话框关、onClose 0 次;
    详情页入口 Esc → 对话框关、详情页仍在、onClose 0 次;
    对照组(无对话框)Esc → onClose 1 次(证明探针接线有效)。

    审计原话:「论证越充分越容易跳过第零问 —— 这条就是。」

    审计已看过、主动丢掉的

    • M29 前置条件按 label 去重(零红存活):要触发需同包声明两个 label 相同而 prerequisiteId 不同的密钥;
      label 就是变量名,在签名 payload 里唯一且已排序去重。到不了,且即便到得了 main 会 secret-undeclared fail-closed。
    • M34 删 stage === "authorize" 判别(零红存活):与后面的 "packageAuthorization" in result && 在联合类型上等价,纯冗余收窄。
    • M14/M15 单删任一处 setEnvValues({}):两处互为兜底,同时删就红。冗余防御。
    • legacy authz({host:"confirm"}) 与 packageAuthz() 同时非空会渲染两个 ExtAuthzView:
      要到达需两个模态流程同时在场,而两者共用同一个模态 Dialog、各自关闭时清各自 state,底层被 inert 屏蔽。到不了。
    • 超纲明确丢掉:①为让对话框好测而把 packageAuthz 抽成独立组件/状态机(#695 已拒过一次的形状);
      ②给 ExtAuthzView 建通用契约测试框架;③为 Phase 2/3 预留 multi-component preview 渲染路径;④纯风格。

    审计自己跑的门(真实输出)

    $ bun test src                      3314 pass / 0 fail / 14667 expect / 233 files
    $ bun test ./test-component/ext-package-detail-wiring.cases.ts   8 pass / 0 fail / 246 expect
    $ bunx tsgo -b                      202 = base(全在 packages/app 冻结前端债);本票改动文件 0 条
    $ bash scripts/assert-gate-files.sh ✓ 60,EXIT=0
    $ git diff --check edaab603         EXIT=0
    

    审计结束时 git status --porcelain 与开工时逐字相同,全部变异只在 scratchpad 副本上做。


    待办(修复清单)

    1. B-1 第 7 条用例改走详情页入口 + [role='alert'] 断言(~5 行)
    2. B-2 第 4 条用例加两行 DOM 断言 + 第 2 条用例加「空能力集不出现 .alpha-ext-authz-cap」(~3 行)
    3. M-1 咽喉点 expectDialogContract(dlg),第 3/4 条各调一次(~10 行,连带关掉 M11/M36)
    4. M-1 补 M19 的 {ok:false} 无 stage 场景(~5 行)
    5. M-2 expectNoCanaryAnywhere() 助手,四轴:元素全部属性 + .value、innerHTML、
      console.* 捕获、globalThis 枚举(~10 行)
    6. 修完走 R2(只判闭合 + 修复 diff),R2 后合并
  4. added 2 commits that reference this issue on Jul 31, 2026
  5. jinjunnn commented on Jul 31, 2026

    @jinjunnn
    OwnerAuthor

    R2 审计记录(Opus,只读)+ 编排者裁决 + 已合并

    预算:M 级 ≤2 轮,两轮用满。R2 判 RECOMMEND: MERGE。

    R1 八条逐条闭合(审计独立重做,与自报逐位吻合,无一虚报)

    编号 判定 独立重做的真实结果
    B-1 删详情页行内错误面 FIXED 7 pass / 1 fail
    B-2 删对话框 ExtAuthzView FIXED 7 pass / 1 fail
    M-1a packageSecretsComplete 恒真 FIXED 7 pass / 1 fail
    M-1b 删 setAuthzBusy(true) FIXED 4 pass / 4 fail
    M-1c 删首趟 !ok 的 setErrFor FIXED 7 pass / 1 fail
    M-2a 明文进 DOM 属性 FIXED 7 pass / 1 fail
    M-2b 明文进 console.log FIXED 7 pass / 1 fail
    M-2c 明文进 globalThis FIXED 7 pass / 1 fail

    审计另补做四个不在 R1 表内的:明文进渲染 HTML(7/1)、删卡片行内错误面(6/2)、
    删 runPackageAction 的 catch(7/1)、用例数 8→7 时 wrapper 立刻红。

    新闸门自身的真伪(R2 的核心价值)

    • expectDialogContract 两种能力集都挂了,写死杀得掉:审计真去把 diffs 写死成
      单条常量 → 7/1 红在用例 2;写死成 [] → 7/1 红在用例 3;把 packagePrerequisites()
      写死 → 4 pass / 4 fail。两个方向都红 ⇒ 没有任何单一常量能同时满足两处期望。
    • 四轴各自单独能红、console 劫持在 finally 恢复。
    • 48 行删除逐条核过,没有一条是放松 —— 用例 3 的 46 行全是缩进搬进 try 块,
      新旧断言集合逐条对齐数过「一条不少」;两处 body.textContent 换成 expectNoCanaryAnywhere
      (含 innerHTML,是 textContent 的超集)。粒度没被吞并(助手内部仍是 6 个独立 expect)。
    • 生产代码零改动,用例仍精确 8 条。

    编排者裁决:R2 的两条 minor 修了

    预算已用尽,按治理默认:minor 筛完直接修、修完不复审直接合。由编排者亲自修 + 变异自检。

    minor-1「破坏它 = 破坏观测它」

    canary 扫描为避开用户刚输入的密钥,按选择器把所有
    .alpha-ext-key-input[type=password] 的 value 清空再扫 —— 而那恰好是唯一合法装密钥的 class。

    审计实测:生产多渲染一个同 class 的 password 框装明文 → 8 pass / 0 fail 完全隐形;
    对照组换成 class="leak-other" → 7/1 抓得到。

    改成按身份遮蔽:只遮蔽 fillPackageSecret 真正写入的那一个元素,其余一律照扫。

    ⚠️ 中间我先写成 expect(secretInputs.length).toBeLessThanOrEqual(1),变异实测仍然 8/0 存活 ——
    注入的框在对话框关闭后才存在,那时数量正好是 1。按身份遮蔽才是对的。
    这一步没自己跑变异的话,会以为已经修好了。

    minor-2 日志/全局轴只下潜一层

    审计实测存活的形状:console.debug("intent",{catalogId,grants:{secrets}})、
    console.error(new Error(secret))、globalThis.__leak={a:{b:secret}}、console.warn(new Map(...))。
    而 gate-files.tsv:57 写的是「日志/全局 state 零回显」——
    正是本轮 B-1 被判 Blocker 的同一个毛病:登记簿替不存在的覆盖背书。 不修就是重犯。

    ownScalarValues 带深度预算(4)继续下潜,Error 取 message、Map/Set 展开条目。
    minor-3(登记描述)随之成立,无需另改。

    第零问记录:今天 renderer/extensions/ 下 grep 不到任何 console.* 调用,
    所以这是防未来回归的棘轮,不是现行缺陷 —— 但棘轮的价值正在于此。

    编排者的变异自检(五条,每条先确认落地再看结果)

    变异 落地 结果
    第二个同 class 密钥框 ✓ 7 pass / 1 fail
    console 深度 2 {grants:{secrets}} ✓ 7 pass / 1 fail
    console.error(new Error(secret)) ✓ 7 pass / 1 fail
    globalThis 深度 2 {a:{b:secret}} ✓ 7 pass / 1 fail
    明文进 input title(R1 原变异) ✓ 7 pass / 1 fail

    第一轮我有三个变异静默没落地(perl 正则没匹配上),却把由此得到的"全红"当成了结论。
    加了落地校验后重跑才是真的。这个坑本场已经栽过一次,判据是:变异脚本必须自证生效。


    审计主动丢掉的(编排者复核后认同)

    归一化 HTML 快照加强版、m-1/m-2 防御分支无测试、把 packageAuthz 抽成组件/状态机
    (#695 已拒过的形状)、「留空」文案(已另开 P2)、三处构造上不可达的零红存活。

    out-of-round(不参与合并判定,记录备查)

    • packageSecretsComplete 用 .trim().length > 0 判完整,但提交的是未 trim 的原值 ——
      纯空白被拦、首尾空格原样写盘。审计认为原样保留用户输入是对的,只是判据与提交值不同源。
    • 用例 3 的 console 劫持会吞掉该用例期间的 [ext-transaction] tx-committed 日志,失败时诊断少一截。
    • expectNoCanaryAnywhere 走 querySelectorAll("*"),扫不到游离节点与 Solid signal 闭包 ——
      canary 扫描的固有边界,非本次引入。

    合并

    PR #742,squash 为 bbff1ea8。rebase 到 origin/alpha(含 #729)后全量复验:
    bun test src 3318 pass / 0 fail、cases 8 pass、assert-gate-files 60、
    tsgo 202 = base、文档链接 ✓、diff --check 干净、与 UPSTREAM_PATHS 零交集。

    rebase 时 matrix.md 有实质冲突:#732 那版写的「update-required 不带 presentation、
    名称回退为 package:next-profile-agent」是 #729 合并之前的事实,现已为假。
    已合并两者并注明旧行为的时间范围 —— 冲突解决不是选一边,是让文本重新变真。

  6. jinjunnn commented on Jul 31, 2026

    @jinjunnn
    OwnerAuthor

    订正:上面 R1/R2 记录里写的「留空文案已另开 P2 票」当时并未真的开票 —— 我把打算做的事写成了已做的事。现已补开 #743(P2)。

    裁决本身不变(不在 #732 内改,因为 alpha.ext.confirmEnv / alpha.ext.keyPlaceholder 与 legacy 安装对话框共用 key,那条路径上留空确实合法),变的只是它现在真的有票拥有它了。

  7. added a commit that references this issue on Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions