@xinvxueyuan/cordis-plugin-secret 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,7 +23,8 @@
23
23
  4. **会话级密钥不落盘**:`scope: "session"` 的值只存在于进程内存(Host 的暂存表与会话授权表)。落盘只走凭据服务,且只发生在 `persistent`。
24
24
  5. **持久化只经凭据服务**:`persistent` 经 `ctx.credentials.set(<变量名>, value)` 写入凭据引用空间(provider 管理的可写源);同时向记录空间提交一条**不含密钥材料**的标记记录(索取方向 `kind: "grant"`,附加方向 `kind: "attachment"`,payload 只有 `envVar/name/scope/authorizedAt`)。绝不写自建文件,绝不在仓库里存明文。
25
25
  6. **会话边界失败关闭**(见「边界处理」):锚点离开会话表面即撤销并不再注入。
26
- 7. **明文允许存在的全部位置(穷举,仅此六处)**:
26
+ 7. **人类输入密钥的控件既不可自动填充,也不可被密码管理器捕获**(用户实机报出的缺陷的修复):**现象**:真实浏览器会在这四个密钥输入面上给出自动填充建议,已装的密码管理器扩展也会捕获它们。**根因**:修复前这四个字段只有 `autoComplete:'off'`(现代浏览器对登录类凭据字段基本忽略这个值)、**没有任何厂商忽略标记、也没有别的退出开关**,而掩码用的又是原生 `type="password"`,浏览器正是据此把它当作密码域。**修复**:本插件渲染的**文本/密码**输入都带一套抑制属性——`autocomplete="new-password"`、`autocorrect="off"`、`autocapitalize="off"`、`spellcheck=false`,以及厂商忽略标记 `data-1p-ignore`(1Password 官方文档逐字给出)、`data-lpignore`(LastPass:**只有其支持页的标题级引用**——本环境抓取失败、其公开仓库 0 命中,如实标注为证据最弱的一条)、`data-bwignore`(Bitwarden:**opt-in**,只有用户在该扩展的设置里打开对应选项后才生效)、`data-form-type="other"`(Dashlane)、`data-protonpass-ignore`(Proton Pass:依据是**厂商源码**——`WebClients` 里 `PASSWORD_MANAGER_IGNORE_PROPS` 收录的正是这同一套属性,见 `packages/pass/…/Field.tsx` 与 `applications/wallet/…/attribute.ts`)。按面分层(**不是"每一个 input 都一样"**):**4 个能持密钥明文的字段**(索要卡片的值输入、附加胶囊的密钥内容、管理面「改值」、管理确认卡片的「新密钥内容」)带齐 9 键袋**且**另加**只读直到聚焦**的守卫——字段先以 `readOnly` 渲染(自动填充只在加载时预填,而只读字段不在候选里),聚焦时由 `onFocus` 释放;**2 个标识符输入**(凭据键、标签)与**1 个不持值的掩码展示**(`disabled`)也带袋,但不带守卫;**自由文本 `textarea`** 只带 4 键卫生子集(`autocomplete`/`autocorrect`/`autocapitalize`/`spellcheck`);**2 个 scope radio** 不持值、不带袋、**且带 `name`**。8 个文本/密码字段都没有 `name`,整个 UI **没有 `<form>`、没有 submit 按钮**(每个按钮都是 `type="button"`)。掩码**仍用 `type="password"`**:替代方案 `-webkit-text-security` 是非标准属性(MDN 明写"不建议在生产使用、浏览器支持有限"),一旦被忽略就会把密钥显示成明文,比自动填充更糟。机械断言:两个 Client 测试逐一渲染这些面并断言上述属性与守卫,修复前的形态下它们**全部失败**(正对照见测试注释)。**需真人**:具体浏览器是否还弹自动填充建议/是否还提示保存密码、各管理器扩展是否仍捕获、以及只读守卫的代价(见下一条与「已知限制」),只能在真机浏览器里确认。
27
+ 8. **明文允许存在的全部位置(穷举,仅此六处)**:
27
28
  - P1 填值胶囊 / 管理面「改值」面的本地输入 state(掩码输入框);
28
29
  - P2 一次 `POST /api/secret.attach`(附加方向)或一次 `action:"value"` 的 `POST /api/secret.manage`(改值方向)的请求体;
29
30
  - P3 Host 进程内存里的暂存记录(已登记但尚未随消息发送);
@@ -209,7 +210,7 @@ Harness 的公开面 `InputActions` **故意不含**引用插入(`Command-styl
209
210
 
210
211
  ### 自己复测(活体,约 3 分钟)
211
212
 
212
- > **先重启。** dsh 在进程启动时加载插件的 `lib/` 构建,所以**发布 0.2.1 之后必须重启 `dsh web`** 才会加载修复后的产物;重启前的活体复测仍会复现 0.2.0 的现象(`staged` 不变 `bound`、shell 里没有变量),那是预期,不是修复失败。同理,**发布 0.3.0 之后也必须重启 `dsh web`** 才会加载四项增强的产物——重启前看不到消息旁的旁挂胶囊、信息框里的「历史记录」区、草稿移除后的自动撤销,`@` 菜单也不会有新分组,这些都属预期(下面是这四项各自的复测方式)。**发布 0.4.0 之后同样必须重启 `dsh web`** 才会加载管理面——重启前 `secret_manage` 工具不会出现在工具列表里,信息框里也没有五条管理路径,历史里不会有 `updated`/`scope-changed`/`unbound`/`deleted` 四类事件;活体上仍是 `0.3.0` 的四个方向,这属预期,不是发布失败。
213
+ > **先重启。** dsh 在进程启动时加载插件的 `lib/` 构建,所以**发布 0.2.1 之后必须重启 `dsh web`** 才会加载修复后的产物;重启前的活体复测仍会复现 0.2.0 的现象(`staged` 不变 `bound`、shell 里没有变量),那是预期,不是修复失败。同理,**发布 0.3.0 之后也必须重启 `dsh web`** 才会加载四项增强的产物——重启前看不到消息旁的旁挂胶囊、信息框里的「历史记录」区、草稿移除后的自动撤销,`@` 菜单也不会有新分组,这些都属预期(下面是这四项各自的复测方式)。**发布 0.4.0 之后同样必须重启 `dsh web`** 才会加载管理面——重启前 `secret_manage` 工具不会出现在工具列表里,信息框里也没有五条管理路径,历史里不会有 `updated`/`scope-changed`/`unbound`/`deleted` 四类事件;活体上仍是 `0.3.0` 的四个方向,这属预期,不是发布失败。**0.4.0 是个例外**:它虽然在 GitHub Release 与 GitHub Packages 上发了,但在真实运行时**整个插件条目不激活**(`dsh: warning: 1 entry did not activate`,见下面「0.4.0 的发布级缺陷与 0.4.1 修复」),所以重启也换不来可用的管理面;**必须重启到 0.4.1**。**发布 0.4.2 之后同样必须重启 `dsh web`** 才会加载密钥输入的抑制属性——重启前浏览器仍会照旧给那四个字段自动填充建议、密码管理器扩展仍会捕获(这就是 0.4.2 修的那件事),这属预期,不是修复失败。
213
214
 
214
215
  1. 在输入区点「附加密钥」按钮 → 胶囊里填名称(如 `openai`)与值,作用域保持默认「仅本次会话」→ 插入 → 输入框出现 `@DSH_SECRET_OPENAI` 胶囊。
215
216
  2. **发送前**:在同一浏览器(同源、带签名 cookie)打开 `GET /api/secret.attached?sessionId=$DSH_SESSION_ID` → 该条的 `state` 必须是 `"staged"`,且此刻 shell 里没有这个变量(未发送永不注入)。
@@ -407,15 +408,18 @@ if ($env:DSH_SECRET_OPENAI) { "present length=$($env:DSH_SECRET_OPENAI.Length)"
407
408
 
408
409
  **不可恢复性与副作用(逐字告知)**:引用空间的值被原子重写掉(`writeFileAtomic` + 0600),**没有任何副本**,也无法从会话日志反推(日志里只有变量名);记录空间的标记一并删除,因此**其它会话再也 adopt 不到它**。想再用同一变量,只能重新走 `secret_request` 或重新附加,**重新输入新值**。删除后刷新页面,「管理」面(每次都向 Host 现问)里该行消失,历史里多一条 `deleted`(仅本进程内可见)。若某个会话此前已把它绑到某条消息上,那个会话的内存副本仍在,直到消息回退或会话结束。
409
410
 
410
- ### 怎么复测 0.4.0(先重启 `dsh web`)
411
+ ### 怎么复测 0.4.2(先重启 `dsh web`)
412
+
413
+ 0. **重启后先看启动日志**(这是 0.4.0 缺陷的判据,也是本版修复的判据):`dsh` 的启动输出里**不应再有** `warning: 1 entry did not activate`,也不应再出现 `secret (@xinvxueyuan/cordis-plugin-secret): Error: connection: exact Fetch route "/api/secret.manage" is already registered`;工具列表里应当有 `secret_manage`,管理面可用。(0.4.0 在实机上正是这条 warning;`lib/routes.js` 里 `/api/secret.manage` 现在只注册一次、`methods` 是 `['GET','POST']`,可以离线核对。)
411
414
 
412
415
  1. **自动化(不需要浏览器,约 6 秒,自然退出,无需清场)**:
413
416
  ```sh
414
417
  npm run typecheck && npm test
415
418
  ```
416
- 共 7 个测试文件 / 122 个用例。管理面在 `test/register.test.ts`:两个工具的参数集(逐字断言没有 `value`)、`list`/`unbind`/`delete`/`scope`/`value` 五个动作、五条人类侧路径、降级两个按钮(文案与线上动作都不同)、真删两档(缺 `confirm` 零副作用)、列表两个方向的 `origin` 与委派子代理的可达性注意(含根代理的正对照);四类新历史事件在 `test/history.test.ts`。`npm test` 若挂住,按仓库红线处理:**先查泄漏**(本插件自己的 teardown/定时器),临时排障才用 `node --test --test-force-exit`,**不得按进程名清场**。
419
+ 共 8 个测试文件 / 128 个用例。管理面在 `test/register.test.ts`:两个工具的参数集(逐字断言没有 `value`)、`list`/`unbind`/`delete`/`scope`/`value` 五个动作、五条人类侧路径、降级两个按钮(文案与线上动作都不同)、真删两档(缺 `confirm` 零副作用)、列表两个方向的 `origin` 与委派子代理的可达性注意(含根代理的正对照);**0.4.1 起还有实施真实注册表规则的严格注册表**——它断言 9 条精确路径各注册一次、`/api/secret.manage` 一行两方法,并在修复前的形态上以**同一条** `already registered` 错误失败(这就是 0.4.0 漏网的那个门);**0.4.2 起新增两条密钥输入抑制用例**(`test/client-card.test.ts` / `test/client-attach.test.ts`)——它们逐面枚举渲染出的字段并断言那一套抑制属性与只读守卫,在修复前的形态上逐条列出缺口(正对照)。四类新历史事件在 `test/history.test.ts`。`npm test` 若挂住,按仓库红线处理:**先查泄漏**(本插件自己的 teardown/定时器),临时排障才用 `node --test --test-force-exit`,**不得按进程名清场**。
417
420
  2. **活体(需真人;管理面每次都向 Host 现问,改完刷新即可再核对)**:按上面「人类侧信息框(五条路径)」逐条点一遍——列举(两个分区读得清)、改值(掩码框)、改作用域(两个降级按钮各点一次,核对其后果不同)、解绑(凭据库不动)、真删(两次点击)。断言点是上面「需真人确认」列出的那些:不可用的动作不出现;改值后活体 shell 里真的是新值;`scope → persistent` 真的写库;真删后该行从库侧分区消失、会话侧作用域如实变「仅本次会话」、历史多一条 `deleted`、其它三条外来记录一字未动;子代理调 `list` 不挂起、调四个写动作得到结构化 `DELEGATED_CALLER`(无对话框)。
418
- 3. **旧版对照**:`git stash` 或 checkout `v0.3.0` 后 `git diff v0.3.0 -- src test` 可看到本轮的全部改动面;`.credentials.yaml` 的基线(大小与 SHA256)应在复测前后逐字节不变。
421
+ 3. **浏览器侧(0.4.2,只能真人做)**:在真实浏览器里打开四个密钥输入面,逐条核「需真人确认」里那 5 项——是否还弹自动填充建议、Chromium 是否还给"生成强密码"建议、是否还弹"保存密码?"、已装的密码管理器扩展是否仍捕获、以及**聚焦之后能否正常键入**(只读守卫的代价)。
422
+ 4. **旧版对照**:`git stash` 或 checkout `v0.3.0` 后 `git diff v0.3.0 -- src test` 可看到本轮的全部改动面;`.credentials.yaml` 的基线(大小与 SHA256)应在复测前后逐字节不变。
419
423
 
420
424
  ## 存储与传播
421
425
 
@@ -464,13 +468,15 @@ if ($env:DSH_SECRET_OPENAI) { "present length=$($env:DSH_SECRET_OPENAI.Length)"
464
468
  - **R5(质量门 r2 报出,0.4.0 已知限制:重叠场景会低估可用性)**:列表的既定边界是**一个变量只占一行**;当同一变量**既有一条人工附加记录(`staged` / `bound`)又有一份经 `secret_request` 的活跃会话授权**时(复现两步:先把某变量附上但不发送 → 再对同名变量 `secret_request` 并批准),附加行先入行、`requestViews` 随即跳过该变量,于是列表只回 `{state:'staged', origin:'attach', source:'session'}` 这一行——**这枚变量其实已被授权、也可注入**,而问方向的授权只在本会话**历史**(`authorized` / 来源 `request`)里可见。影响范围**仅**是人类管理列表在重叠场景下**低估可用性、方向不完整**:它**不泄漏任何值**、**不误报可执行动作**(该行的 `can.*` 仍为真,`unbind` 仍能撤掉本会话的暴露),**安全不变量与权限边界未受影响**(口径由 `manageList` 的 `seen` 去重决定,发布后如需让一行同时承载两个方向,单独立项再改,0.4.0 不返工)。
465
469
  - **记录键可能撞名**:记录键由 name 推导,`recordKeyId` 把 `_` 换成 `-`,所以 `a_b` 与 `a-b` 指向同一条记录键;真删按记录键寻址,撞键时删掉的是同一条。
466
470
  - **历史里 `deleted` 行的 `scope` 字段记的是「被删掉那条的作用域」**(`persistent`),不是"现在还剩什么";会话里那份的当前作用域以管理列表为准(`session`)。
471
+ - **密钥输入控件不会被自动填充/被密码管理器捕获,但"现场是否还弹提示"只能真人确认**(0.4.2 修复,如实标注):代码层面已加齐抑制属性与只读守卫(见「安全不变量」第 7 条)并由两个 Client 测试机械钉住,**未经真人浏览器验证**的部分是:① Chrome/Edge/Firefox/Safari 在四个密钥字段上是否**完全不再**给自动填充建议;② Chromium 在 `autocomplete="new-password"` 字段上可能出现的"生成强密码"建议是否出现、是否可忽略(它不会回填已保存的凭据,也不读取字段);③ 是否还会弹"保存密码?"气泡;④ 1Password / LastPass / Bitwarden / Dashlane / Proton Pass 扩展在现场是否确实**不再**捕获或提供保存(各厂商忽略标记的实际效力只能在其真实扩展上验;其中 Bitwarden 的忽略还是 **opt-in**,LastPass 那条只有支持页标题级引用);⑤ **只读直到聚焦的守卫在现场是否有代价**——辅助技术可能把 `readOnly` 字段读成"只读"、iOS 上 `readOnly` 可能压住软键盘(需要二次点按)、`disabled` 的字段永不释放、以及守卫目前只在 props 层被断言(真实 DOM 重渲染下是否稳定释放未验),因此"**聚焦之后能正常键入**"必须由真人确认。以上 5 项在无浏览器控制与未安装这些扩展时均为**需真人确认**,本 README 不把它们写成已验证。
472
+ - **只读直到聚焦的守卫自身有代价(0.4.2 新增)**:四个密钥字段默认以 `readOnly` 渲染,聚焦时才由 `onFocus` 释放。已知代价:① 辅助技术(屏幕阅读器)可能把它读成只读字段;② iOS Safari 等平台上 `readOnly` 可能阻止软键盘自动弹出,需要用户二次点按;③ 若字段处于 `disabled` 状态则永不释放(本插件在不应输入的场合才这么做);④ 该守卫目前只有 props 层断言(正对照证明修复前缺失),**真实 DOM 重渲染下是否每次都释放未验证**——属需真人确认项(见上一条 ⑤)。这是为防自动填充付出的可用性代价,如实记录,不粉饰。
467
473
  - **0.3.0 新增的"需真人确认"清单(如实标注,未验证即写"未验证")**:① **旁挂胶囊确实渲染在用户气泡之后**(含四档"工作步骤展示"下的实际位置与视觉贴合);② **点旁挂胶囊**打开的是输入框上方的信息框;③ **点原胶囊**打开的是右侧栏详情页(且**不再是**"文件不存在"),以及页面顶栏右栏行为符合预期;④ **原胶囊接管的可争用性**在现场的表现(装/卸 `dsh-better-sidebar`,或临时注册一个更长的同档 pattern,观察是否被静默抢走);⑤ **移除即撤销**的真实时序(删掉 → 约 0.6s 后 `GET /api/secret.attached` 不再是 `staged`;删掉后 600ms 内插回 ⇒ 记录仍在;**发送不得触发撤销**);⑥ **历史区**在刷新后仍在、宿主重启/插件重载后清空、重放/分叉会话不重建;⑦ **`@` 菜单**两类来源的 `section`/`description` 文案在实际宽度下不被截断,且凭据库条目的确认→登记→插入链路真的能取到值。以上 7 项在无浏览器控制时均为**需真人确认**,本 README 不把它们写成已验证。
468
474
 
469
475
  ## 开发
470
476
 
471
477
  ```sh
472
478
  npm run typecheck # tsc:Host 半(Node)+ Client 半(DOM),erasableSyntaxOnly,兼容 Node 原生类型剥离
473
- npm test # node --test 七个文件:
479
+ npm test # node --test 八个文件:
474
480
  # test/unit.test.ts 参数校验、变量名推导、decision 映射、session/persistent 路由、
475
481
  # 四类返回都不含值、锚点撤销、fork 不继承、压缩不误撤销(含真实表面折叠)、
476
482
  # 子代理失败关闭、超时、O1(已答复但落库失败 ⇒ 降级 session 并如实回报)、
@@ -479,8 +485,16 @@ npm test # node --test 七个文件:
479
485
  # test/register.test.ts 用真实 apply + 假 Context 走完整链路:工具/路由/session-disposed 注册、
480
486
  # Config 校验、tool→卡片→shellEnv 的值交付、回退与会话结束后的取回消失、
481
487
  # persistent 经 authorization seam 落库且标记不含值、409 冲突、
482
- # 0.4.0 管理面(工具/两条路由/五条路径/降级两按钮/真删两档)、
488
+ # 0.4.0 管理面(一个工具/一条路由两种方法/五条路径/降级两按钮/真删两档)、
483
489
  # 管理列表两个方向(`origin`:人工附加 + Agent 索要)与委派子代理的可达性注意
490
+ # test/real-connection.test.ts 挂**真实** `@deepseek-ai/dsh-client-connection` 的
491
+ # `HostConnectionService` + 真实 cordis Context + 真实 `apply()`:
492
+ # 断言 fiber `ACTIVE`(不是 `FAILED`)、真实注册表的 9 条精确路径各注册
493
+ # 一次、`/api/secret.manage` 单次注册同时拥有 GET+POST、两个工具的
494
+ # schema 都在;并带正对照——同一精确路径二次注册时真实注册表抛出
495
+ # 现场同一条 `exact Fetch route ... is already registered`。
496
+ # (0.4.0 的发布级缺陷正是从这里漏过去的:其余用例用的是假注册表,
497
+ # 它不实施「精确路径唯一」,而且曾把两次注册钉成期望值。)
484
498
  # test/client-card.test.ts 以 __ModuleLoader__ + 假 React 加载浏览器产物:节点在 tool/call 即存在且
485
499
  # 无 Turn 坐标、结算态来自 meta、注册面恰好两处(无 shell.overlay/composer)、
486
500
  # 假过期四态(不可达/未列出/已提交)、表单控件齐备、明文不越出掩码输入
@@ -561,19 +575,41 @@ npm stage approve <stage-id> # 需要 2FA
561
575
  判断该版本是否已存在于目标 registry,已存在就跳过发布(并在 Step Summary 写明"该版本已存在,跳过发布"),
562
576
  因此对已发布版本重推 tag 不会产生必然失败的公开红叉。其它检查错误(网络、鉴权、registry 故障)仍会让 job 失败。
563
577
 
564
- **registry 现状(截至本文)**:npmjs 上已上线 `0.0.0-stage`、`0.1.0`、`0.2.0`、`0.2.1`、`0.3.0`(`dist-tags.latest = 0.3.0`,说明 `0.3.0` 的 stage 已由维护者用 2FA 批准);`0.4.0` 已由 CI 放入 stage 队列(stage id `dd9ef8e8-866b-498d-a8f5-8ee33e32f62f`,shasum `22dfadfc613695018adfd5ee09eb8b71b1ba9edc`,provenance 已发布到 Sigstore 透明度日志 `logIndex=3133868807`),等维护者用 2FA 批准(`npm stage approve dd9ef8e8-866b-498d-a8f5-8ee33e32f62f`)后才上线——**批准只能由人来做**,发布流程不登录 npm、不处理任何令牌、不代做需要 2FA 的动作。GitHub Packages 上 `0.1.0`/`0.2.0`/`0.2.1`/`0.3.0`/`0.4.0` 都在(那里由 CI 直接发布,不经人工批准;`0.4.0` 的包版本 id `1351737947`,上一个 `0.3.0` 的是 `1346087280`)。
578
+ **registry 现状(截至本文)**:npmjs 上已上线 `0.0.0-stage`、`0.1.0`、`0.2.0`、`0.2.1`、`0.3.0`(`dist-tags.latest = 0.3.0`)。`0.4.0` 与 `0.4.1` 都已由 CI 放入 stage 队列、**都还没被批准**:`0.4.0` 的 stage id `dd9ef8e8-866b-498d-a8f5-8ee33e32f62f`(shasum `22dfadfc613695018adfd5ee09eb8b71b1ba9edc`)**不建议批准**(该制品有启动缺陷,见「0.4.0 的发布级缺陷与 0.4.1 修复」),`0.4.1` 的 stage id `22abf7a3-606b-4ee4-a1f9-5e84b557d6d0`(shasum `9b64a832abad45123ec96b3c2eec398c4b7377dd`,provenance 已发布到 Sigstore 透明度日志 `logIndex=3142251804`)才是要批准的那个:`npm stage approve 22abf7a3-606b-4ee4-a1f9-5e84b557d6d0`。已经躺在队列里的 0.4.0 可以显式丢弃:`npm stage reject dd9ef8e8-866b-498d-a8f5-8ee33e32f62f`(本机 npm CLI 12.2.0 的 `npm stage` 确实有 `reject` 子命令,帮助文本:「Reject a staged package, removing it from the registry」,可用 `--otp <otp>`)——**批准与丢弃都需要登录 + 2FA,只能由人来做**;本发布流程不登录 npm、不读取或处理任何用户令牌、不代做任何需要 2FA 的动作。GitHub Packages 上 `0.1.0`/`0.2.0`/`0.2.1`/`0.3.0`/`0.4.1` 都在(`0.4.1` 的包版本 id `1354170442`,上一个可用版本 `0.3.0` 的是 `1346087280`),另有**不可用**的 `0.4.0`(包版本 id `1351737947`)——GitHub Packages 由 CI 直接发布、不经人工批准,所以撤不回,只能靠 Release notes 的警告与本文说明。
565
579
 
566
580
  同一份包也会发布到 **GitHub Packages**(`npm.pkg.github.com`,`github-packages` job,用内置 `GITHUB_TOKEN`),
567
581
  使包在仓库页面上可见、可被 `@xinvxueyuan:registry=https://npm.pkg.github.com` 的消费者安装。
568
582
 
569
- `publish.yml` 的两个发布 job 在日志里都打印了同一份包的 `shasum`(sha1)`22dfadfc613695018adfd5ee09eb8b71b1ba9edc`
570
- 与 `integrity` `sha512-N2aBH5qpKPZPl…==`;对 Release 的 tgz 逐字节计算,得到的正是这两个值(`sha1` 与 `sha512` 都相同),
571
- 所以 npm stage 队列里与 GitHub Packages 上的那份,就是 Release 的那 260287 B(未做的一步是:从 `npm.pkg.github.com`
572
- 下载回来再逐字节比对——那需要认证,本文没有做,如实标注)。
583
+ `publish.yml` 的两个发布 job 在日志里都打印了同一份包的 `shasum`(sha1)与 `integrity`(sha512);
584
+ 对 Release 的 tgz 逐字节计算,得到的正是同一对值,所以 npm stage 队列里与 GitHub Packages 上的那份,就是 Release 的那份字节
585
+ (未做的一步是:从 `npm.pkg.github.com` 下载回来再逐字节比对——那需要认证,本文没有做,如实标注):
586
+
587
+ - `0.4.0`:`shasum 22dfadfc613695018adfd5ee09eb8b71b1ba9edc`、`integrity sha512-N2aBH5qpKPZPl…==` ↔ Release tgz 260287 B、sha256 `4612be9c…`;
588
+ - `0.4.1`:`shasum 9b64a832abad45123ec96b3c2eec398c4b7377dd`、`integrity sha512-yyBWKC1zH6vHZ…==` ↔ Release tgz 262988 B、sha256 `46286a2e…`。
589
+
590
+ ### 0.4.0 的发布级缺陷与 0.4.1 修复(如实)
591
+
592
+ **缺陷(0.4.0 在真实运行时不可用)**:`/api/secret.manage` 被**注册了两次**(GET 一次、POST 一次),而真实的 connection 注册表按**精确路径**建名、与 `methods` 无关(`@deepseek-ai/dsh-client-connection/lib/index.js:625-639`,抛错在 `:633`)。第二次注册在**装配时**抛
593
+ `connection: exact Fetch route "/api/secret.manage" is already registered`;失败的 fiber 连同它的 effects 一起被回滚,于是**整个插件条目不激活**(`dsh: warning: 1 entry did not activate`):两个工具都不注册、九条路由都不在,管理面在实机上完全不可用。**根因一句话**:把设计文档里「两条新路由(GET/POST 同路径)」按字面实现成了两次 `register()`。
594
+
595
+ **为什么三道质量门没抓到**:它们的 harness 用的是自造 connection 假注册表,假表**不实施「精确路径唯一」规则**;`test/register.test.ts` 里那条 `assert.deepEqual(registered, …)` 甚至把「两次注册」当作期望值钉住了。
596
+
597
+ **修复(0.4.1)**:`src/routes.ts` 合并为**一次注册**(`methods:['GET','POST']`,按 `request.method` 分派;GET/POST 两个分支的行为逐字不变),并新增**实施真实注册表规则的严格注册表**与两条回归用例——它在修复前的形态上以**同一条错误**失败(实测留存),修复后通过;那条钉住重复注册的断言按事实更新为 9 条精确路径(只有 manage 两行合并成一行)。产物层的对照:0.4.0 的 `lib/routes.js` 里 `fetch.register(` 10 次、`path: MANAGE_PATH` 2 次、manage 分别是 `methods:['GET']` 与 `methods:['POST']`;0.4.1 是 9 次、1 次、`methods:['GET','POST']` 一行。
598
+
599
+ **已发布但不可用的制品范围(如实)**:
600
+
601
+ - **GitHub Release 上 `v0.4.0` 存在**:tag 对象 `05942e0bba94b717b61cb0896ddf74eb2025dc9f` → commit `26bd420`,三附件(tgz 260287 B sha256 `4612be9c…`、`SHA256SUMS`、`SHA256SUMS.asc`)**保持原样未动**;Release notes 顶部已加警告并指向 0.4.1(未改 tag、未改附件)。
602
+ - **GitHub Packages 上 `0.4.0` 存在**(包版本 id `1351737947`),且**撤不回**——那里由 CI 直接发布、不经人工批准。
603
+ - **npmjs 上 0.4.0 从未上线**:它只进过 stage 队列(`dd9ef8e8-…`)且未经 2FA 批准;建议显式 `npm stage reject`(见上一节),或至少不要批准它。
604
+ - 因此任何消费者都应使用 **0.4.1**;已经在跑 0.4.0 的环境(插件条目不激活、管理面不可用)**重启 `dsh web` 到 0.4.1** 即恢复。
573
605
 
574
606
  ### GitHub Release 与签名
575
607
 
576
- > **已发生的事实**:`v0.4.0` 的 Release 是 https://github.com/xinvxueyuan/cordis-plugin-secret/releases/tag/v0.4.0
608
+ > **已发生的事实**:`v0.4.1` 的 Release 是 https://github.com/xinvxueyuan/cordis-plugin-secret/releases/tag/v0.4.1
609
+ > (2026-10-08 发布,`draft: false`;`release.yml` 运行 `37735146290` 成功,`publish.yml` 运行 `37735146256` 成功),附件三件:`xinvxueyuan-cordis-plugin-secret-0.4.1.tgz`(262988 B,sha256 `46286a2eefebb0809696377ebfecf7569c8aa723287cbe7a8e2c7bee979a76f8`,与 `SHA256SUMS` 里记的一致,也与本机 `core.autocrlf=false` 干净克隆里 `npm pack` 的产物逐字节一致)、`SHA256SUMS`(109 B,sha256 `ddd3b78232803958fefd699bf7488d208db97462a8985eef891a766598f643dd`)、`SHA256SUMS.asc`(887 B,sha256 `3ded718895f81d5b03b8b6a1037f2b1138d87d10dd9f58523b88a67bc3934ec1`;RSA 4096 `6C6FD9B2…72B7B35A` 对 **CI 那份 `SHA256SUMS`** 的分离签名,本机 `gpg --verify` 通过);
610
+ > tgz 与 SHA256SUMS 都由 `gh attestation verify` 可验(一份 attestation,subject 同时列出两者),且**证明绑定在 tag 上**(builder id / 签名证书 SAN = `.../release.yml@refs/tags/v0.4.1`,`externalParameters.workflow.ref` = `refs/tags/v0.4.1`,`resolvedDependencies` = `git+https://github.com/xinvxueyuan/cordis-plugin-secret@refs/tags/v0.4.1` @ commit `744f5a675d3641cbf6a7dd0121e88b5fa333f4bf`)——即 tag→commit 是证明的一部分,而不是只绑定到 `refs/heads/main`(`--format json` 全文对 `refs/heads/` **零命中**)。tag 对象为 annotated + GPG 签名
611
+ > (`git cat-file -t v0.4.1` → `tag`,tag 对象 sha `43da6f8a85797be40356691db38079de50fa0844`;GitHub API 的 `verification.verified` → `true`,`reason` → `valid`,`verified_at` → `2026-10-08T06:00:40Z`)。
612
+ > 上一个版本 `v0.4.0` 的 Release 是 https://github.com/xinvxueyuan/cordis-plugin-secret/releases/tag/v0.4.0 **(该版本在真实运行时不可用,Release notes 顶部已加警告,见上一节;tag 与附件未动)**
577
613
  > (2026-10-07 发布,`draft: false`;`release.yml` 运行 `37661019203` 成功,`publish.yml` 运行 `37661018704` 成功),附件三件:`xinvxueyuan-cordis-plugin-secret-0.4.0.tgz`(260287 B,sha256 `4612be9c9c8ed9475d236693c32d3d473bad2803aeea2ff55f027da9f677c7e8`,与 `SHA256SUMS` 里记的一致)、`SHA256SUMS`(109 B,sha256 `f54b947346dd196ed6aa48dead4721c828b81d611b073f82548132d311d3e3be`)、`SHA256SUMS.asc`(887 B,sha256 `239251f88159500f6af917c41138b4a83e6aa42c307e98e6e8cbdc86dba4bb13`;RSA 4096 `6C6FD9B2…72B7B35A` 对 **CI 那份 `SHA256SUMS`** 的分离签名,本机 `gpg --verify` 通过);
578
614
  > tgz 与 SHA256SUMS 都由 `gh attestation verify` 可验(一份 attestation,subject 同时列出两者),且**证明绑定在 tag 上**(builder id / 签名证书 SAN = `.../release.yml@refs/tags/v0.4.0`,`externalParameters.workflow.ref` = `refs/tags/v0.4.0`,`resolvedDependencies` = `git+https://github.com/xinvxueyuan/cordis-plugin-secret@refs/tags/v0.4.0` @ commit `26bd420e40bd242d6096478fce50ff0fbdb84655`)——即 tag→commit 是证明的一部分,而不是只绑定到 `refs/heads/main`(`--format json` 全文对 `refs/heads/` **零命中**)。tag 对象为 annotated + GPG 签名
579
615
  > (`git cat-file -t v0.4.0` → `tag`,tag 对象 sha `05942e0bba94b717b61cb0896ddf74eb2025dc9f`;GitHub API 的 `verification.verified` → `true`,`reason` → `valid`,`verified_at` → `2026-10-07T17:42:58Z`)。
@@ -594,25 +630,25 @@ npm stage approve <stage-id> # 需要 2FA
594
630
  | 构建来源证明 | `release.yml` 调用 `actions/attest-build-provenance`(pin 到 commit SHA),为 **tgz 与 SHA256SUMS 两者**生成 Sigstore 签名的 SLSA 构建来源证明,可用 `gh attestation verify` 校验。 |
595
631
  | npm 侧 | `release.yml` **完全不执行任何 npm publish**;npm 发布只由上面的 `publish.yml` staged publishing 负责。 |
596
632
 
597
- 维护者操作顺序(`v0.2.0`、`v0.2.1`、`v0.3.0` 与 `v0.4.0` 都已按此执行):
633
+ 维护者操作顺序(`v0.2.0`、`v0.2.1`、`v0.3.0`、`v0.4.0` 与 `v0.4.1` 都已按此执行):
598
634
 
599
635
  ```sh
600
636
  # 1) 本机确认工作区干净、package.json 的 version 已就位(版本号由发布者手工提升)
601
637
  git status --porcelain
602
638
 
603
639
  # 2) 创建 annotated + GPG 签名 tag(私钥仅在本机使用;本机需能完成 GPG 签名)
604
- git tag -s v0.4.0 -m "v0.4.0"
640
+ git tag -s v0.4.1 -m "v0.4.1"
605
641
 
606
642
  # 3) 只推 tag —— release.yml 会构建产物、生成来源证明并创建 Release(不要用 workflow_dispatch:那样证明会绑到 refs/heads/main)
607
- git push origin v0.4.0
643
+ git push origin v0.4.1
608
644
 
609
645
  # 4) 取 Release 上 CI 生成的那份 SHA256SUMS,做分离签名并附回 Release(私钥不进 CI)
610
- gh release download v0.4.0 --pattern SHA256SUMS --clobber
646
+ gh release download v0.4.1 --pattern SHA256SUMS --clobber
611
647
  gpg --armor --detach-sign SHA256SUMS # 生成 SHA256SUMS.asc
612
- gh release upload v0.4.0 SHA256SUMS.asc --clobber
648
+ gh release upload v0.4.1 SHA256SUMS.asc --clobber
613
649
  ```
614
650
 
615
- > **为什么第 4 步要下 CI 的 `SHA256SUMS` 而不是签本机 `npm pack` 那份**:本机是 Windows 且 `core.autocrlf=true`,工作树里 `LICENSE-MIT`/`LICENSE-APACHE` 被 checkout 成 CRLF,本机打出的 tgz 是 260311 B / sha256 `0a6fd66c…`,而 CI(Linux checkout,LF)打出的是 260287 B / sha256 `4612be9c…`——两者只差这两个 LICENSE 文件的行尾。在一个 `core.autocrlf=false` 的干净克隆里本机 `npm pack` 复现出的正是 CI 的 `4612be9c…`(`docs/` 之类不进包,`lib/**` 与 `src/**` 逐字节相同)。签错那份会让 `gpg --verify SHA256SUMS.asc SHA256SUMS` 在下载来的附件上对不上。
651
+ > **为什么第 4 步要下 CI 的 `SHA256SUMS` 而不是签本机 `npm pack` 那份**:本机是 Windows 且 `core.autocrlf=true`,工作树里 `LICENSE-MIT`/`LICENSE-APACHE` 被 checkout 成 CRLF,本机打出的 tgz 与 CI(Linux checkout,LF)打出的只差这两个文件的行尾——`0.4.0`:本机 260311 B / `0a6fd66c…` 对 CI 260287 B / `4612be9c…`;`0.4.1`:本机 263015 B / `4b3c3a7d…` 对 CI 262988 B / `46286a2e…`。在 `core.autocrlf=false` 的干净克隆里本机 `npm pack` 复现出的正是 CI 的那份(`docs/` 之类不进包,`lib/**` 与 `src/**` 逐字节相同)。签错那份会让 `gpg --verify SHA256SUMS.asc SHA256SUMS` 在下载来的附件上对不上。
616
652
 
617
653
  校验方式:
618
654
 
@@ -624,9 +660,9 @@ sha256sum -c SHA256SUMS
624
660
  gpg --verify SHA256SUMS.asc SHA256SUMS
625
661
 
626
662
  # 校验构建来源证明(需要 gh CLI)
627
- gh attestation verify xinvxueyuan-cordis-plugin-secret-0.4.0.tgz --repo xinvxueyuan/cordis-plugin-secret
663
+ gh attestation verify xinvxueyuan-cordis-plugin-secret-0.4.1.tgz --repo xinvxueyuan/cordis-plugin-secret
628
664
  gh attestation verify SHA256SUMS --repo xinvxueyuan/cordis-plugin-secret
629
- gh attestation verify xinvxueyuan-cordis-plugin-secret-0.4.0.tgz --repo xinvxueyuan/cordis-plugin-secret --format json # 查 externalParameters.workflow.ref 是否为 refs/tags/v0.4.0
665
+ gh attestation verify xinvxueyuan-cordis-plugin-secret-0.4.1.tgz --repo xinvxueyuan/cordis-plugin-secret --format json # 查 externalParameters.workflow.ref 是否为 refs/tags/v0.4.1
630
666
  ```
631
667
 
632
668
  补充说明:
@@ -619,6 +619,93 @@ const secretRequestDefinition = {
619
619
  function HiddenSecretToolRow() {
620
620
  return null;
621
621
  }
622
+ /**
623
+ * The attribute bag every text/password field this plugin renders carries.
624
+ *
625
+ * The fields that receive a secret value are why it exists: a browser or a
626
+ * password-manager extension must never autofill one, offer to save one, or
627
+ * remember what was typed into one. No single attribute does that, so this is a
628
+ * deliberately redundant set, one layer per line of defence:
629
+ *
630
+ * - `autocomplete="new-password"`: the one hint the HTML spec has for exactly
631
+ * this case — MDN's "How to turn off form autocompletion" documents it for
632
+ * "a user [who] can specify a new password for another person" and records
633
+ * that plain `off` is *ignored* by modern browsers for login-like fields.
634
+ * - **no `name` attribute**: MDN's own fallback advice when a browser keeps
635
+ * suggesting is to change the field's `name`; a field with no name and no
636
+ * owning form is not something a browser can key a credential on.
637
+ * - **no `<form>` and no submit button anywhere in this plugin's UI**: those are
638
+ * the conditions MDN lists for a browser to offer autocompletion or to build a
639
+ * login out of the fields. The capsule and the cards are plain `div`s and
640
+ * every button in them is `type="button"`.
641
+ * - the vendor ignore attributes, each named by its own vendor:
642
+ * `data-1p-ignore` (1Password: "use the `data-1p-ignore` or `data-op-ignore`
643
+ * attribute to tell 1Password it should ignore the field"), `data-lpignore`
644
+ * (LastPass), `data-bwignore` (Bitwarden), `data-form-type="other"`
645
+ * (Dashlane) and `data-protonpass-ignore` (Proton Pass — the only one of the
646
+ * five whose vendor documentation we could not reach, kept because an extra
647
+ * attribute costs nothing while a missed one costs a copied key).
648
+ * - `spellcheck=false`, `autocorrect="off"`, `autocapitalize="off"`: a secret is
649
+ * an opaque string, so it must not be sent to a spell service or silently
650
+ * rewritten.
651
+ *
652
+ * The masked state keeps `type="password"` on purpose. The alternative, a text
653
+ * field obfuscated with `-webkit-text-security`, is non-standard — MDN marks it
654
+ * "not standardized … we do not recommend using non-standard features in
655
+ * production … limited browser support" — so a browser that ignores it would
656
+ * render the key in clear text, a worse failure than the capture this bag
657
+ * closes. Masking stays with the browser's own password field; suppression
658
+ * stays with the attributes above.
659
+ *
660
+ * One trade-off is recorded rather than hidden: on a `new-password` field
661
+ * Chromium may offer to *generate* a password. That offer neither autofills a
662
+ * stored credential nor reads the field, and ignoring it is harmless.
663
+ */
664
+ const SECRET_FIELD_SUPPRESSION = {
665
+ autoComplete: 'new-password',
666
+ autoCorrect: 'off',
667
+ autoCapitalize: 'off',
668
+ spellCheck: false,
669
+ 'data-1p-ignore': 'true',
670
+ 'data-lpignore': 'true',
671
+ 'data-bwignore': 'true',
672
+ 'data-form-type': 'other',
673
+ 'data-protonpass-ignore': 'true',
674
+ };
675
+ /**
676
+ * The second layer for a field that can hold a secret: it starts `readOnly` and
677
+ * is released the moment it is focused.
678
+ *
679
+ * Autofill primes fields on load, before the human has done anything, and a
680
+ * read-only field is not a fill candidate for that pass. Nothing is lost: a
681
+ * field cannot be typed into without being focused first, and the focus handler
682
+ * here is what makes it editable again. This is why the guard is a pair of
683
+ * props rather than a `readOnly: true` on its own (which would make the field
684
+ * unusable).
685
+ */
686
+ function secretFieldGuards() {
687
+ return {
688
+ readOnly: true,
689
+ onFocus: (event) => {
690
+ const node = event;
691
+ const field = node.currentTarget ?? node.target;
692
+ if (field !== undefined && field !== null)
693
+ field.readOnly = false;
694
+ },
695
+ };
696
+ }
697
+ /**
698
+ * The hygiene subset for a free-text box that can hold no secret (the card's
699
+ * "other instruction" field): no spelling service, no autocorrection, and no
700
+ * autocomplete — but not the password-manager ignore attributes, because there
701
+ * is no credential here for a manager to capture.
702
+ */
703
+ const PLAIN_TEXT_HYGIENE = {
704
+ autoComplete: 'off',
705
+ autoCorrect: 'off',
706
+ autoCapitalize: 'off',
707
+ spellCheck: false,
708
+ };
622
709
  /** One card for one `secret_request` call, drawn in the conversation flow. */
623
710
  function SecretRequestCard(props) {
624
711
  const h = React.createElement;
@@ -801,13 +888,13 @@ function SecretRequestCard(props) {
801
888
  if (showValueField) {
802
889
  const valueId = `dsh-secret-value-${callId}`;
803
890
  bodyChildren.push(h('div', { key: 'value', className: C.field }, h('label', { className: C.fieldLabel, htmlFor: valueId }, TEXT.valueLabel), h('div', { className: C.inputRow }, h('input', {
891
+ ...SECRET_FIELD_SUPPRESSION,
892
+ ...secretFieldGuards(),
804
893
  id: valueId,
805
894
  className: C.input,
806
895
  type: reveal ? 'text' : 'password',
807
896
  value,
808
897
  placeholder: TEXT.valuePlaceholder,
809
- autoComplete: 'off',
810
- spellCheck: false,
811
898
  disabled: !answerable,
812
899
  'aria-label': TEXT.valueLabel,
813
900
  onChange: (event) => setValue(event.target.value),
@@ -822,6 +909,7 @@ function SecretRequestCard(props) {
822
909
  if (!settled) {
823
910
  if (mode === 'other') {
824
911
  bodyChildren.push(h('div', { key: 'other', className: C.field }, h('label', { className: C.fieldLabel, htmlFor: `dsh-secret-other-${callId}` }, TEXT.otherLabel), h('textarea', {
912
+ ...PLAIN_TEXT_HYGIENE,
825
913
  id: `dsh-secret-other-${callId}`,
826
914
  className: C.textarea,
827
915
  value: otherText,
@@ -2975,32 +3063,31 @@ function SecretAttachCapsule(props) {
2975
3063
  onChange: () => setScope(next),
2976
3064
  }), h('span', { className: A.optionBody }, h('span', { className: A.optionTitle }, title), h('span', { className: A.optionHint }, hint)));
2977
3065
  return h('div', { className: A.box, 'data-secret-attach-capsule': 'fill', role: 'group', 'aria-label': t('fillTitle') }, head, h('div', { className: A.body }, h('div', { className: A.field }, h('label', { className: A.label, htmlFor: 'dsh-secret-attach-key' }, t('keyLabel')), h('input', {
3066
+ ...SECRET_FIELD_SUPPRESSION,
2978
3067
  id: 'dsh-secret-attach-key',
2979
3068
  className: A.input,
2980
3069
  type: 'text',
2981
3070
  value: key,
2982
3071
  placeholder: t('keyPlaceholder'),
2983
- autoComplete: 'off',
2984
- spellCheck: false,
2985
3072
  disabled: busy,
2986
3073
  onChange: (event) => setKey(event.target.value),
2987
3074
  }), h('span', { className: A.optionHint }, t('keyHint'))), h('div', { className: A.field }, h('label', { className: A.label, htmlFor: 'dsh-secret-attach-label' }, t('labelLabel')), h('input', {
3075
+ ...SECRET_FIELD_SUPPRESSION,
2988
3076
  id: 'dsh-secret-attach-label',
2989
3077
  className: A.input,
2990
3078
  type: 'text',
2991
3079
  value: label,
2992
3080
  placeholder: t('labelPlaceholder'),
2993
- autoComplete: 'off',
2994
3081
  disabled: busy,
2995
3082
  onChange: (event) => setLabel(event.target.value),
2996
3083
  })), h('div', { className: A.field }, h('label', { className: A.label, htmlFor: 'dsh-secret-attach-value' }, t('valueLabel')), h('div', { className: A.inputRow }, h('input', {
3084
+ ...SECRET_FIELD_SUPPRESSION,
3085
+ ...secretFieldGuards(),
2997
3086
  id: 'dsh-secret-attach-value',
2998
3087
  className: A.input,
2999
3088
  type: reveal ? 'text' : 'password',
3000
3089
  value,
3001
3090
  placeholder: t('valuePlaceholder'),
3002
- autoComplete: 'off',
3003
- spellCheck: false,
3004
3091
  disabled: busy,
3005
3092
  'aria-label': t('valueLabel'),
3006
3093
  onChange: (event) => setValue(event.target.value),
@@ -3136,13 +3223,13 @@ function SecretAttachCapsule(props) {
3136
3223
  role: 'group',
3137
3224
  'aria-label': target === 'store' ? t('editTitleStore') : t('editTitleSession'),
3138
3225
  }, head, h('div', { className: `${A.body} ${A.detail}` }, h('div', { className: A.row }, h('span', { className: A.rowLabel }, t('variableLabel')), h('code', { className: A.code }, mode.variable)), h('p', { className: A.notice }, target === 'store' ? t('editHintStore') : t('editHintSession')), h('div', { className: A.field }, h('label', { className: A.label, htmlFor: 'dsh-secret-manage-value' }, t('editValueLabel')), h('div', { className: A.inputRow }, h('input', {
3226
+ ...SECRET_FIELD_SUPPRESSION,
3227
+ ...secretFieldGuards(),
3139
3228
  id: 'dsh-secret-manage-value',
3140
3229
  className: A.input,
3141
3230
  type: editReveal ? 'text' : 'password',
3142
3231
  value: editValue,
3143
3232
  placeholder: t('editValuePlaceholder'),
3144
- autoComplete: 'off',
3145
- spellCheck: false,
3146
3233
  disabled: busy,
3147
3234
  'aria-label': t('editValueLabel'),
3148
3235
  onChange: (event) => setEditValue(event.target.value),
@@ -3601,6 +3688,7 @@ function SecretManageCard(props) {
3601
3688
  // into a field that asks the human to retype (or reveals) the value.
3602
3689
  request !== null && request.action === 'scope' && request.to === 'persistent'
3603
3690
  ? h('div', { className: C.field, 'data-secret-manage-scope-up': 'true' }, h('p', { className: C.notice }, t('manageScopeUpLead')), h('span', { className: C.fieldLabel }, t('manageScopeUpCurrent')), h('div', { className: C.inputRow }, h('input', {
3691
+ ...SECRET_FIELD_SUPPRESSION,
3604
3692
  className: C.input,
3605
3693
  // Masked and disabled: the value is never rendered here, and the
3606
3694
  // human is not asked for it again — the Host writes the copy this
@@ -3609,8 +3697,6 @@ function SecretManageCard(props) {
3609
3697
  value: '',
3610
3698
  readOnly: true,
3611
3699
  disabled: true,
3612
- autoComplete: 'off',
3613
- spellCheck: false,
3614
3700
  'aria-label': t('manageScopeUpCurrent'),
3615
3701
  placeholder: '••••••••',
3616
3702
  })))
@@ -3621,12 +3707,12 @@ function SecretManageCard(props) {
3621
3707
  ? h('p', { className: C.notice, 'data-secret-manage-delete': 'true' }, t('manageDeleteLead'))
3622
3708
  : null, expectValue && !settled
3623
3709
  ? h('div', { className: C.field }, h('span', { className: C.fieldLabel }, '新密钥内容'), h('div', { className: C.inputRow }, h('input', {
3710
+ ...SECRET_FIELD_SUPPRESSION,
3711
+ ...secretFieldGuards(),
3624
3712
  className: C.input,
3625
3713
  type: reveal ? 'text' : 'password',
3626
3714
  value,
3627
3715
  placeholder: '粘贴新的密钥…',
3628
- autoComplete: 'off',
3629
- spellCheck: false,
3630
3716
  disabled: busy || !answerable,
3631
3717
  'aria-label': '新密钥内容',
3632
3718
  onChange: (event) => setValue(event.target.value),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xinvxueyuan/cordis-plugin-secret",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Cordis (DeepSeek Harness) plugin: secrets travel between a human and the agent in both directions — the agent asks, or the human attaches one to their own message — and the agent only ever receives an opaque session-scoped variable name (DSH_SECRET_*), never the value",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -53,7 +53,7 @@
53
53
  "scripts": {
54
54
  "build": "tsc -p tsconfig.build.json && tsc -p tsconfig.client.json",
55
55
  "prepublishOnly": "npm run build",
56
- "test": "node --test test/unit.test.ts test/register.test.ts test/client-card.test.ts test/attach.test.ts test/attach-wiring.test.ts test/client-attach.test.ts test/history.test.ts",
56
+ "test": "node --test test/unit.test.ts test/register.test.ts test/real-connection.test.ts test/client-card.test.ts test/attach.test.ts test/attach-wiring.test.ts test/client-attach.test.ts test/history.test.ts",
57
57
  "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json && tsc --noEmit -p tsconfig.test.json"
58
58
  },
59
59
  "repository": {
@@ -756,6 +756,95 @@ function HiddenSecretToolRow(): unknown {
756
756
  return null
757
757
  }
758
758
 
759
+ /**
760
+ * The attribute bag every text/password field this plugin renders carries.
761
+ *
762
+ * The fields that receive a secret value are why it exists: a browser or a
763
+ * password-manager extension must never autofill one, offer to save one, or
764
+ * remember what was typed into one. No single attribute does that, so this is a
765
+ * deliberately redundant set, one layer per line of defence:
766
+ *
767
+ * - `autocomplete="new-password"`: the one hint the HTML spec has for exactly
768
+ * this case — MDN's "How to turn off form autocompletion" documents it for
769
+ * "a user [who] can specify a new password for another person" and records
770
+ * that plain `off` is *ignored* by modern browsers for login-like fields.
771
+ * - **no `name` attribute**: MDN's own fallback advice when a browser keeps
772
+ * suggesting is to change the field's `name`; a field with no name and no
773
+ * owning form is not something a browser can key a credential on.
774
+ * - **no `<form>` and no submit button anywhere in this plugin's UI**: those are
775
+ * the conditions MDN lists for a browser to offer autocompletion or to build a
776
+ * login out of the fields. The capsule and the cards are plain `div`s and
777
+ * every button in them is `type="button"`.
778
+ * - the vendor ignore attributes, each named by its own vendor:
779
+ * `data-1p-ignore` (1Password: "use the `data-1p-ignore` or `data-op-ignore`
780
+ * attribute to tell 1Password it should ignore the field"), `data-lpignore`
781
+ * (LastPass), `data-bwignore` (Bitwarden), `data-form-type="other"`
782
+ * (Dashlane) and `data-protonpass-ignore` (Proton Pass — the only one of the
783
+ * five whose vendor documentation we could not reach, kept because an extra
784
+ * attribute costs nothing while a missed one costs a copied key).
785
+ * - `spellcheck=false`, `autocorrect="off"`, `autocapitalize="off"`: a secret is
786
+ * an opaque string, so it must not be sent to a spell service or silently
787
+ * rewritten.
788
+ *
789
+ * The masked state keeps `type="password"` on purpose. The alternative, a text
790
+ * field obfuscated with `-webkit-text-security`, is non-standard — MDN marks it
791
+ * "not standardized … we do not recommend using non-standard features in
792
+ * production … limited browser support" — so a browser that ignores it would
793
+ * render the key in clear text, a worse failure than the capture this bag
794
+ * closes. Masking stays with the browser's own password field; suppression
795
+ * stays with the attributes above.
796
+ *
797
+ * One trade-off is recorded rather than hidden: on a `new-password` field
798
+ * Chromium may offer to *generate* a password. That offer neither autofills a
799
+ * stored credential nor reads the field, and ignoring it is harmless.
800
+ */
801
+ const SECRET_FIELD_SUPPRESSION = {
802
+ autoComplete: 'new-password',
803
+ autoCorrect: 'off',
804
+ autoCapitalize: 'off',
805
+ spellCheck: false,
806
+ 'data-1p-ignore': 'true',
807
+ 'data-lpignore': 'true',
808
+ 'data-bwignore': 'true',
809
+ 'data-form-type': 'other',
810
+ 'data-protonpass-ignore': 'true',
811
+ } as const
812
+
813
+ /**
814
+ * The second layer for a field that can hold a secret: it starts `readOnly` and
815
+ * is released the moment it is focused.
816
+ *
817
+ * Autofill primes fields on load, before the human has done anything, and a
818
+ * read-only field is not a fill candidate for that pass. Nothing is lost: a
819
+ * field cannot be typed into without being focused first, and the focus handler
820
+ * here is what makes it editable again. This is why the guard is a pair of
821
+ * props rather than a `readOnly: true` on its own (which would make the field
822
+ * unusable).
823
+ */
824
+ function secretFieldGuards(): { readOnly: true; onFocus: (event: unknown) => void } {
825
+ return {
826
+ readOnly: true,
827
+ onFocus: (event: unknown) => {
828
+ const node = event as { currentTarget?: { readOnly?: boolean }; target?: { readOnly?: boolean } }
829
+ const field = node.currentTarget ?? node.target
830
+ if (field !== undefined && field !== null) field.readOnly = false
831
+ },
832
+ }
833
+ }
834
+
835
+ /**
836
+ * The hygiene subset for a free-text box that can hold no secret (the card's
837
+ * "other instruction" field): no spelling service, no autocorrection, and no
838
+ * autocomplete — but not the password-manager ignore attributes, because there
839
+ * is no credential here for a manager to capture.
840
+ */
841
+ const PLAIN_TEXT_HYGIENE = {
842
+ autoComplete: 'off',
843
+ autoCorrect: 'off',
844
+ autoCapitalize: 'off',
845
+ spellCheck: false,
846
+ } as const
847
+
759
848
  /** One card for one `secret_request` call, drawn in the conversation flow. */
760
849
  function SecretRequestCard(props: {
761
850
  readonly node?: { readonly data?: unknown }
@@ -986,13 +1075,13 @@ function SecretRequestCard(props: {
986
1075
  'div',
987
1076
  { className: C.inputRow },
988
1077
  h('input', {
1078
+ ...SECRET_FIELD_SUPPRESSION,
1079
+ ...secretFieldGuards(),
989
1080
  id: valueId,
990
1081
  className: C.input,
991
1082
  type: reveal ? 'text' : 'password',
992
1083
  value,
993
1084
  placeholder: TEXT.valuePlaceholder,
994
- autoComplete: 'off',
995
- spellCheck: false,
996
1085
  disabled: !answerable,
997
1086
  'aria-label': TEXT.valueLabel,
998
1087
  onChange: (event: { target: { value: string } }) => setValue(event.target.value),
@@ -1021,6 +1110,7 @@ function SecretRequestCard(props: {
1021
1110
  { key: 'other', className: C.field },
1022
1111
  h('label', { className: C.fieldLabel, htmlFor: `dsh-secret-other-${callId}` }, TEXT.otherLabel),
1023
1112
  h('textarea', {
1113
+ ...PLAIN_TEXT_HYGIENE,
1024
1114
  id: `dsh-secret-other-${callId}`,
1025
1115
  className: C.textarea,
1026
1116
  value: otherText,
@@ -3511,13 +3601,12 @@ function SecretAttachCapsule(props: {
3511
3601
  { className: A.field },
3512
3602
  h('label', { className: A.label, htmlFor: 'dsh-secret-attach-key' }, t('keyLabel')),
3513
3603
  h('input', {
3604
+ ...SECRET_FIELD_SUPPRESSION,
3514
3605
  id: 'dsh-secret-attach-key',
3515
3606
  className: A.input,
3516
3607
  type: 'text',
3517
3608
  value: key,
3518
3609
  placeholder: t('keyPlaceholder'),
3519
- autoComplete: 'off',
3520
- spellCheck: false,
3521
3610
  disabled: busy,
3522
3611
  onChange: (event: { target: { value: string } }) => setKey(event.target.value),
3523
3612
  }),
@@ -3528,12 +3617,12 @@ function SecretAttachCapsule(props: {
3528
3617
  { className: A.field },
3529
3618
  h('label', { className: A.label, htmlFor: 'dsh-secret-attach-label' }, t('labelLabel')),
3530
3619
  h('input', {
3620
+ ...SECRET_FIELD_SUPPRESSION,
3531
3621
  id: 'dsh-secret-attach-label',
3532
3622
  className: A.input,
3533
3623
  type: 'text',
3534
3624
  value: label,
3535
3625
  placeholder: t('labelPlaceholder'),
3536
- autoComplete: 'off',
3537
3626
  disabled: busy,
3538
3627
  onChange: (event: { target: { value: string } }) => setLabel(event.target.value),
3539
3628
  }),
@@ -3546,13 +3635,13 @@ function SecretAttachCapsule(props: {
3546
3635
  'div',
3547
3636
  { className: A.inputRow },
3548
3637
  h('input', {
3638
+ ...SECRET_FIELD_SUPPRESSION,
3639
+ ...secretFieldGuards(),
3549
3640
  id: 'dsh-secret-attach-value',
3550
3641
  className: A.input,
3551
3642
  type: reveal ? 'text' : 'password',
3552
3643
  value,
3553
3644
  placeholder: t('valuePlaceholder'),
3554
- autoComplete: 'off',
3555
- spellCheck: false,
3556
3645
  disabled: busy,
3557
3646
  'aria-label': t('valueLabel'),
3558
3647
  onChange: (event: { target: { value: string } }) => setValue(event.target.value),
@@ -3845,13 +3934,13 @@ function SecretAttachCapsule(props: {
3845
3934
  'div',
3846
3935
  { className: A.inputRow },
3847
3936
  h('input', {
3937
+ ...SECRET_FIELD_SUPPRESSION,
3938
+ ...secretFieldGuards(),
3848
3939
  id: 'dsh-secret-manage-value',
3849
3940
  className: A.input,
3850
3941
  type: editReveal ? 'text' : 'password',
3851
3942
  value: editValue,
3852
3943
  placeholder: t('editValuePlaceholder'),
3853
- autoComplete: 'off',
3854
- spellCheck: false,
3855
3944
  disabled: busy,
3856
3945
  'aria-label': t('editValueLabel'),
3857
3946
  onChange: (event: { target: { value: string } }) => setEditValue(event.target.value),
@@ -4539,6 +4628,7 @@ function SecretManageCard(props: {
4539
4628
  'div',
4540
4629
  { className: C.inputRow },
4541
4630
  h('input', {
4631
+ ...SECRET_FIELD_SUPPRESSION,
4542
4632
  className: C.input,
4543
4633
  // Masked and disabled: the value is never rendered here, and the
4544
4634
  // human is not asked for it again — the Host writes the copy this
@@ -4547,8 +4637,6 @@ function SecretManageCard(props: {
4547
4637
  value: '',
4548
4638
  readOnly: true,
4549
4639
  disabled: true,
4550
- autoComplete: 'off',
4551
- spellCheck: false,
4552
4640
  'aria-label': t('manageScopeUpCurrent'),
4553
4641
  placeholder: '••••••••',
4554
4642
  }),
@@ -4569,12 +4657,12 @@ function SecretManageCard(props: {
4569
4657
  'div',
4570
4658
  { className: C.inputRow },
4571
4659
  h('input', {
4660
+ ...SECRET_FIELD_SUPPRESSION,
4661
+ ...secretFieldGuards(),
4572
4662
  className: C.input,
4573
4663
  type: reveal ? 'text' : 'password',
4574
4664
  value,
4575
4665
  placeholder: '粘贴新的密钥…',
4576
- autoComplete: 'off',
4577
- spellCheck: false,
4578
4666
  disabled: busy || !answerable,
4579
4667
  'aria-label': '新密钥内容',
4580
4668
  onChange: (event: { target: { value: string } }) => setValue(event.target.value),