@xinvxueyuan/cordis-plugin-secret 0.4.1 → 0.4.3

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 个标识符输入**(凭据键、标题)带 `autocomplete="off"` 的同一套袋**且**同样带**只读直到聚焦**的守卫,并且**自 0.4.3 起不再带 `id`/`name`**、改用**隐式 label 关联**(`<label>` 包住可见标题与控件,见下)。**依据要分清归属(t21 纠偏,勿混读)**:真正有文档支撑的是 **(a) 浏览器侧的键控行为**——Chrome 团队 web.dev《Learn Forms: Autofill》逐字写明浏览器**按 `name`(某些浏览器还看 `id`)记录与回填字段**,并对"值每次都不同、不可能复用"的字段(该页给的例子是一次性验证码,凭据键与标题同属此类)**推荐 `autocomplete="off"`**;同页还写明 `off` 对**密码字段无效**("即使使用 `autocomplete="off"`,浏览器仍会提供密码自动填充选项"),这正是那四个密钥字段必须留着 `new-password` 而不能改用 `off` 的原因,而 `new-password` 用在纯文本字段上只会招来"生成强密码"的建议;以及 **(b) 五个厂商忽略标记**(1Password 官方文档逐字给出 `data-1p-ignore`;Bitwarden / Dashlane / Proton Pass 是**厂商自家源码**;LastPass 只到支持页标题级)。**而"去掉 `id`/`name`"是本次的设计选择,不是厂商文档的推荐做法**:1Password 官方文档的原意恰好相反——它建议给字段加**唯一的 `id`/`name`**,好让 1Password 能识别该字段;我们只在这两个**不持密钥**的标识符字段上反向操作,唯一目的是让浏览器不再把历史值当作可复用项、从而不再弹下拉,代价是放弃显式关联(见下面的可访问性口径)。不要把这一条读成"厂商推荐去 id";**1 个不持值的掩码展示**(`disabled`)也带袋、不带守卫;**自由文本 `textarea`** 只带 4 键卫生子集(`autocomplete`/`autocorrect`/`autocapitalize`/`spellcheck`);**2 个 scope radio** 不持值、不带袋、**且带 `name`**。所有文本/密码输入都没有 `name`,整个 UI **没有 `<form>`、没有 submit 按钮**(每个按钮都是 `type="button"`)。**可访问性口径(0.4.3,如实记录取舍)**:两个标识符字段改成隐式关联后,MDN 把嵌套与 `for`/`id` 记为"等价",但同时提醒"并非所有辅助技术都实现隐式关联",并**一般推荐**用显式 `for`;因此这两个控件同时带**与可见标题逐字相同的 `aria-label`**,把可访问名固定成显式提供的那一份(可见文本与可访问名一致,满足 Label-in-Name),点击标题仍会聚焦控件(嵌套 label 的原生行为)。**代价如实写明**:这两个字段失去了"显式关联"这一 MDN 推荐形态,换来的是去掉浏览器与扩展据以识别字段的稳定 `id`;`tabIndex` 未改、字段仍可聚焦、可用性未牺牲其它属性(正对照与机械断言见测试)。四个密钥字段**保留**显式 `for`/`id`:它们靠 `new-password` + 厂商忽略标记 + 守卫关闭浏览器侧凭据路径,而 MDN 推荐的显式关联在这里更值得留。掩码**仍用 `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 起,只能真人做)**:在真实浏览器里打开四个密钥输入面**与两个标识符输入(凭据键、标题)**,逐条核「需真人确认」里那几项——是否还弹自动填充建议(标识符字段尤其要看历史值下拉是否消失)、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,16 @@ 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.3 修的两个标识符字段:机械断言已就位,浏览器侧仍需真人确认**(如实标注):凭据键与标题自 0.4.3 起改用 `autocomplete="off"`(Chrome 团队文档给出的"值每次都不同、不可复用"字段的取值)、去掉稳定 `id`/`name`、改成隐式 label 关联 + 同文本 `aria-label`,并同样带只读直到聚焦的守卫;机械断言钉住这一整套(正对照证明修复前形态下必然失败)。**未经真人浏览器验证**的部分:① Chrome/Edge/Firefox/Safari 在这两个字段上是否**真的不再**给历史值/自动填充下拉(`autocomplete="off"` 与"去掉 `id`"都是**提示级**手段,MDN 明写"该属性是给浏览器的提示;有些浏览器可能不遵守");② 已装的 1Password / LastPass / Bitwarden(opt-in)/ Dashlane / Proton Pass 是否确实不再捕获这两个字段;③ 隐式 label 关联在现场的辅助技术(屏幕阅读器、语音控制)下是否与原先的显式 `for`/`id` 等效(MDN 提醒并非所有辅助技术都实现隐式关联;`aria-label` 是为此准备的显式可访问名)。以上 3 项在无浏览器控制与未安装这些扩展时均为**需真人确认**,本 README 不把它们写成已验证。**依据归属(勿混读)**:有文档支撑的是**浏览器键控行为**(web.dev 逐字:浏览器按 `name`,有些还看 `id`,对一次性/不可复用值推荐 `autocomplete="off"`)与**五个厂商忽略标记**(1Password 官方;Bitwarden / Dashlane / Proton 自家源码;LastPass 支持页级);而**"去掉稳定 `id`"是本插件自己的设计选择,不是厂商文档的推荐做法**——1Password 官方文档的原意相反,它建议给字段加唯一 `id`/`name` 以便识别。
472
+ - **密钥输入控件不会被自动填充/被密码管理器捕获,但"现场是否还弹提示"只能真人确认**(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 不把它们写成已验证。
473
+ - **只读直到聚焦的守卫自身有代价(0.4.2 新增;0.4.3 起也覆盖两个标识符字段)**:四个密钥字段与两个标识符字段默认以 `readOnly` 渲染,聚焦时才由 `onFocus` 释放。已知代价:① 辅助技术(屏幕阅读器)可能把它读成只读字段;② iOS Safari 等平台上 `readOnly` 可能阻止软键盘自动弹出,需要用户二次点按;③ 若字段处于 `disabled` 状态则永不释放(本插件在不应输入的场合才这么做);④ 该守卫目前只有 props 层断言(正对照证明修复前缺失),**真实 DOM 重渲染下是否每次都释放未验证**——属需真人确认项(见上一条 ⑤)。这是为防自动填充付出的可用性代价,如实记录,不粉饰。
467
474
  - **0.3.0 新增的"需真人确认"清单(如实标注,未验证即写"未验证")**:① **旁挂胶囊确实渲染在用户气泡之后**(含四档"工作步骤展示"下的实际位置与视觉贴合);② **点旁挂胶囊**打开的是输入框上方的信息框;③ **点原胶囊**打开的是右侧栏详情页(且**不再是**"文件不存在"),以及页面顶栏右栏行为符合预期;④ **原胶囊接管的可争用性**在现场的表现(装/卸 `dsh-better-sidebar`,或临时注册一个更长的同档 pattern,观察是否被静默抢走);⑤ **移除即撤销**的真实时序(删掉 → 约 0.6s 后 `GET /api/secret.attached` 不再是 `staged`;删掉后 600ms 内插回 ⇒ 记录仍在;**发送不得触发撤销**);⑥ **历史区**在刷新后仍在、宿主重启/插件重载后清空、重放/分叉会话不重建;⑦ **`@` 菜单**两类来源的 `section`/`description` 文案在实际宽度下不被截断,且凭据库条目的确认→登记→插入链路真的能取到值。以上 7 项在无浏览器控制时均为**需真人确认**,本 README 不把它们写成已验证。
468
475
 
469
476
  ## 开发
470
477
 
471
478
  ```sh
472
479
  npm run typecheck # tsc:Host 半(Node)+ Client 半(DOM),erasableSyntaxOnly,兼容 Node 原生类型剥离
473
- npm test # node --test 七个文件:
480
+ npm test # node --test 八个文件:
474
481
  # test/unit.test.ts 参数校验、变量名推导、decision 映射、session/persistent 路由、
475
482
  # 四类返回都不含值、锚点撤销、fork 不继承、压缩不误撤销(含真实表面折叠)、
476
483
  # 子代理失败关闭、超时、O1(已答复但落库失败 ⇒ 降级 session 并如实回报)、
@@ -479,8 +486,16 @@ npm test # node --test 七个文件:
479
486
  # test/register.test.ts 用真实 apply + 假 Context 走完整链路:工具/路由/session-disposed 注册、
480
487
  # Config 校验、tool→卡片→shellEnv 的值交付、回退与会话结束后的取回消失、
481
488
  # persistent 经 authorization seam 落库且标记不含值、409 冲突、
482
- # 0.4.0 管理面(工具/两条路由/五条路径/降级两按钮/真删两档)、
489
+ # 0.4.0 管理面(一个工具/一条路由两种方法/五条路径/降级两按钮/真删两档)、
483
490
  # 管理列表两个方向(`origin`:人工附加 + Agent 索要)与委派子代理的可达性注意
491
+ # test/real-connection.test.ts 挂**真实** `@deepseek-ai/dsh-client-connection` 的
492
+ # `HostConnectionService` + 真实 cordis Context + 真实 `apply()`:
493
+ # 断言 fiber `ACTIVE`(不是 `FAILED`)、真实注册表的 9 条精确路径各注册
494
+ # 一次、`/api/secret.manage` 单次注册同时拥有 GET+POST、两个工具的
495
+ # schema 都在;并带正对照——同一精确路径二次注册时真实注册表抛出
496
+ # 现场同一条 `exact Fetch route ... is already registered`。
497
+ # (0.4.0 的发布级缺陷正是从这里漏过去的:其余用例用的是假注册表,
498
+ # 它不实施「精确路径唯一」,而且曾把两次注册钉成期望值。)
484
499
  # test/client-card.test.ts 以 __ModuleLoader__ + 假 React 加载浏览器产物:节点在 tool/call 即存在且
485
500
  # 无 Turn 坐标、结算态来自 meta、注册面恰好两处(无 shell.overlay/composer)、
486
501
  # 假过期四态(不可达/未列出/已提交)、表单控件齐备、明文不越出掩码输入
@@ -561,19 +576,41 @@ npm stage approve <stage-id> # 需要 2FA
561
576
  判断该版本是否已存在于目标 registry,已存在就跳过发布(并在 Step Summary 写明"该版本已存在,跳过发布"),
562
577
  因此对已发布版本重推 tag 不会产生必然失败的公开红叉。其它检查错误(网络、鉴权、registry 故障)仍会让 job 失败。
563
578
 
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`)。
579
+ **registry 现状(截至本文)**:npmjs 上已上线 `0.0.0-stage`、`0.1.0`、`0.2.0`、`0.2.1`、`0.3.0`、`0.4.1`、`0.4.2`(`dist-tags.latest = 0.4.2`,2026-10-08T12:09:17Z 上线)。三个 0.4.x 的 stage 队列历史与**现状**逐个写清,避免批错版本:`0.4.0` 的 stage id `dd9ef8e8-866b-498d-a8f5-8ee33e32f62f`(shasum `22dfadfc613695018adfd5ee09eb8b71b1ba9edc`)**从未被批准、从未上线**(该制品有启动缺陷,见「0.4.0 的发布级缺陷与 0.4.1 修复」),建议显式丢弃:`npm stage reject dd9ef8e8-866b-498d-a8f5-8ee33e32f62f`;`0.4.1` 的 stage id `22abf7a3-606b-4ee4-a1f9-5e84b557d6d0`(shasum `9b64a832abad45123ec96b3c2eec398c4b7377dd`,provenance `logIndex=3142251804`)**已由用户 2FA 批准上线**(2026-10-08T09:30:00Z),现已被 0.4.2 取代;`0.4.2` 的 stage id `a4056fb7-0a1a-4740-91d3-856665498b5e`(shasum `60e61cc5395e2f279a8b9a0a769165ecf4b3c403`、integrity `sha512-S34f977fgdNiR…HDV7DobbX5K8A==`、provenance `logIndex=3146400254`;由 tag `v0.4.2` 推送触发:Release run `37764853236` / Publish run `37764853223`,tag 对象 `b5a20422edde9b3951ba433945c72c9a430c3e0f`(GitHub 显示 verified),Release <https://github.com/xinvxueyuan/cordis-plugin-secret/releases/tag/v0.4.2>)**已由用户 2FA 批准上线**(2026-10-08T12:09:17Z),即当前的 `dist-tags.latest`——**此刻队列里没有任何待批准的版本**(已上线的版本不能再 `npm stage reject`)。本机 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.2` 都在(`0.4.2` 的包版本 id `1355352850`;`0.4.1` 的是 `1354170442`;上一个可用版本 `0.3.0` 的是 `1346087280`),另有**不可用**的 `0.4.0`(包版本 id `1351737947`)——GitHub Packages 由 CI 直接发布、不经人工批准,所以撤不回,只能靠 Release notes 的警告与本文说明。
565
580
 
566
581
  同一份包也会发布到 **GitHub Packages**(`npm.pkg.github.com`,`github-packages` job,用内置 `GITHUB_TOKEN`),
567
582
  使包在仓库页面上可见、可被 `@xinvxueyuan:registry=https://npm.pkg.github.com` 的消费者安装。
568
583
 
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
- 下载回来再逐字节比对——那需要认证,本文没有做,如实标注)。
584
+ `publish.yml` 的两个发布 job 在日志里都打印了同一份包的 `shasum`(sha1)与 `integrity`(sha512);
585
+ 对 Release 的 tgz 逐字节计算,得到的正是同一对值,所以 npm stage 队列里与 GitHub Packages 上的那份,就是 Release 的那份字节
586
+ (未做的一步是:从 `npm.pkg.github.com` 下载回来再逐字节比对——那需要认证,本文没有做,如实标注):
587
+
588
+ - `0.4.0`:`shasum 22dfadfc613695018adfd5ee09eb8b71b1ba9edc`、`integrity sha512-N2aBH5qpKPZPl…==` ↔ Release tgz 260287 B、sha256 `4612be9c…`;
589
+ - `0.4.1`:`shasum 9b64a832abad45123ec96b3c2eec398c4b7377dd`、`integrity sha512-yyBWKC1zH6vHZ…==` ↔ Release tgz 262988 B、sha256 `46286a2e…`。
590
+
591
+ ### 0.4.0 的发布级缺陷与 0.4.1 修复(如实)
592
+
593
+ **缺陷(0.4.0 在真实运行时不可用)**:`/api/secret.manage` 被**注册了两次**(GET 一次、POST 一次),而真实的 connection 注册表按**精确路径**建名、与 `methods` 无关(`@deepseek-ai/dsh-client-connection/lib/index.js:625-639`,抛错在 `:633`)。第二次注册在**装配时**抛
594
+ `connection: exact Fetch route "/api/secret.manage" is already registered`;失败的 fiber 连同它的 effects 一起被回滚,于是**整个插件条目不激活**(`dsh: warning: 1 entry did not activate`):两个工具都不注册、九条路由都不在,管理面在实机上完全不可用。**根因一句话**:把设计文档里「两条新路由(GET/POST 同路径)」按字面实现成了两次 `register()`。
595
+
596
+ **为什么三道质量门没抓到**:它们的 harness 用的是自造 connection 假注册表,假表**不实施「精确路径唯一」规则**;`test/register.test.ts` 里那条 `assert.deepEqual(registered, …)` 甚至把「两次注册」当作期望值钉住了。
597
+
598
+ **修复(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']` 一行。
599
+
600
+ **已发布但不可用的制品范围(如实)**:
601
+
602
+ - **GitHub Release 上 `v0.4.0` 存在**:tag 对象 `05942e0bba94b717b61cb0896ddf74eb2025dc9f` → commit `26bd420`,三附件(tgz 260287 B sha256 `4612be9c…`、`SHA256SUMS`、`SHA256SUMS.asc`)**保持原样未动**;Release notes 顶部已加警告并指向 0.4.1(未改 tag、未改附件)。
603
+ - **GitHub Packages 上 `0.4.0` 存在**(包版本 id `1351737947`),且**撤不回**——那里由 CI 直接发布、不经人工批准。
604
+ - **npmjs 上 0.4.0 从未上线**:它只进过 stage 队列(`dd9ef8e8-…`)且未经 2FA 批准;建议显式 `npm stage reject`(见上一节),或至少不要批准它。
605
+ - 因此任何消费者都应使用 **0.4.1**;已经在跑 0.4.0 的环境(插件条目不激活、管理面不可用)**重启 `dsh web` 到 0.4.1** 即恢复。
573
606
 
574
607
  ### GitHub Release 与签名
575
608
 
576
- > **已发生的事实**:`v0.4.0` 的 Release 是 https://github.com/xinvxueyuan/cordis-plugin-secret/releases/tag/v0.4.0
609
+ > **已发生的事实**:`v0.4.1` 的 Release 是 https://github.com/xinvxueyuan/cordis-plugin-secret/releases/tag/v0.4.1
610
+ > (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` 通过);
611
+ > 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 签名
612
+ > (`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`)。
613
+ > 上一个版本 `v0.4.0` 的 Release 是 https://github.com/xinvxueyuan/cordis-plugin-secret/releases/tag/v0.4.0 **(该版本在真实运行时不可用,Release notes 顶部已加警告,见上一节;tag 与附件未动)**
577
614
  > (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
615
  > 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
616
  > (`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 +631,25 @@ npm stage approve <stage-id> # 需要 2FA
594
631
  | 构建来源证明 | `release.yml` 调用 `actions/attest-build-provenance`(pin 到 commit SHA),为 **tgz 与 SHA256SUMS 两者**生成 Sigstore 签名的 SLSA 构建来源证明,可用 `gh attestation verify` 校验。 |
595
632
  | npm 侧 | `release.yml` **完全不执行任何 npm publish**;npm 发布只由上面的 `publish.yml` staged publishing 负责。 |
596
633
 
597
- 维护者操作顺序(`v0.2.0`、`v0.2.1`、`v0.3.0` 与 `v0.4.0` 都已按此执行):
634
+ 维护者操作顺序(`v0.2.0`、`v0.2.1`、`v0.3.0`、`v0.4.0` 与 `v0.4.1` 都已按此执行):
598
635
 
599
636
  ```sh
600
637
  # 1) 本机确认工作区干净、package.json 的 version 已就位(版本号由发布者手工提升)
601
638
  git status --porcelain
602
639
 
603
640
  # 2) 创建 annotated + GPG 签名 tag(私钥仅在本机使用;本机需能完成 GPG 签名)
604
- git tag -s v0.4.0 -m "v0.4.0"
641
+ git tag -s v0.4.1 -m "v0.4.1"
605
642
 
606
643
  # 3) 只推 tag —— release.yml 会构建产物、生成来源证明并创建 Release(不要用 workflow_dispatch:那样证明会绑到 refs/heads/main)
607
- git push origin v0.4.0
644
+ git push origin v0.4.1
608
645
 
609
646
  # 4) 取 Release 上 CI 生成的那份 SHA256SUMS,做分离签名并附回 Release(私钥不进 CI)
610
- gh release download v0.4.0 --pattern SHA256SUMS --clobber
647
+ gh release download v0.4.1 --pattern SHA256SUMS --clobber
611
648
  gpg --armor --detach-sign SHA256SUMS # 生成 SHA256SUMS.asc
612
- gh release upload v0.4.0 SHA256SUMS.asc --clobber
649
+ gh release upload v0.4.1 SHA256SUMS.asc --clobber
613
650
  ```
614
651
 
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` 在下载来的附件上对不上。
652
+ > **为什么第 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
653
 
617
654
  校验方式:
618
655
 
@@ -624,9 +661,9 @@ sha256sum -c SHA256SUMS
624
661
  gpg --verify SHA256SUMS.asc SHA256SUMS
625
662
 
626
663
  # 校验构建来源证明(需要 gh CLI)
627
- gh attestation verify xinvxueyuan-cordis-plugin-secret-0.4.0.tgz --repo xinvxueyuan/cordis-plugin-secret
664
+ gh attestation verify xinvxueyuan-cordis-plugin-secret-0.4.1.tgz --repo xinvxueyuan/cordis-plugin-secret
628
665
  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
666
+ 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
667
  ```
631
668
 
632
669
  补充说明:
@@ -619,6 +619,120 @@ 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 same bag for the two **identifier** fields (the credential key and the
699
+ * title), with one deliberate difference: `autocomplete="off"` instead of
700
+ * `new-password`.
701
+ *
702
+ * The first-hand reason is the Chrome team's own Autofill guide: for a field
703
+ * whose value is unique every time and can never be reused — its example is a
704
+ * one-time code, and the credential key and title are the same shape of value —
705
+ * it says to use `autocomplete="off"`, because "the value is different every
706
+ * time, and the browser should not save the value or offer autofill options".
707
+ * The same page records the limit that makes `off` the wrong token for the four
708
+ * *secret* fields: "even with `autocomplete="off"`, the browser will still offer
709
+ * password autofill options so that these password management tools keep
710
+ * working" — a `type="password"` field keeps its credential UI, which is why
711
+ * those four keep `new-password`.
712
+ *
713
+ * `new-password` on a plain text field is not a "do not autofill" hint at all: it
714
+ * is the sign-up hint that tells Chrome to offer *generating* a password, which
715
+ * is exactly the kind of noise the user reported on these two fields.
716
+ *
717
+ * @see https://web.dev/learn/forms/autofill (Learn Forms: Autofill)
718
+ * @see https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/Turning_off_form_autocompletion
719
+ */
720
+ const IDENTIFIER_FIELD_SUPPRESSION = {
721
+ ...SECRET_FIELD_SUPPRESSION,
722
+ autoComplete: 'off',
723
+ };
724
+ /**
725
+ * The hygiene subset for a free-text box that can hold no secret (the card's
726
+ * "other instruction" field): no spelling service, no autocorrection, and no
727
+ * autocomplete — but not the password-manager ignore attributes, because there
728
+ * is no credential here for a manager to capture.
729
+ */
730
+ const PLAIN_TEXT_HYGIENE = {
731
+ autoComplete: 'off',
732
+ autoCorrect: 'off',
733
+ autoCapitalize: 'off',
734
+ spellCheck: false,
735
+ };
622
736
  /** One card for one `secret_request` call, drawn in the conversation flow. */
623
737
  function SecretRequestCard(props) {
624
738
  const h = React.createElement;
@@ -801,13 +915,13 @@ function SecretRequestCard(props) {
801
915
  if (showValueField) {
802
916
  const valueId = `dsh-secret-value-${callId}`;
803
917
  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', {
918
+ ...SECRET_FIELD_SUPPRESSION,
919
+ ...secretFieldGuards(),
804
920
  id: valueId,
805
921
  className: C.input,
806
922
  type: reveal ? 'text' : 'password',
807
923
  value,
808
924
  placeholder: TEXT.valuePlaceholder,
809
- autoComplete: 'off',
810
- spellCheck: false,
811
925
  disabled: !answerable,
812
926
  'aria-label': TEXT.valueLabel,
813
927
  onChange: (event) => setValue(event.target.value),
@@ -822,6 +936,7 @@ function SecretRequestCard(props) {
822
936
  if (!settled) {
823
937
  if (mode === 'other') {
824
938
  bodyChildren.push(h('div', { key: 'other', className: C.field }, h('label', { className: C.fieldLabel, htmlFor: `dsh-secret-other-${callId}` }, TEXT.otherLabel), h('textarea', {
939
+ ...PLAIN_TEXT_HYGIENE,
825
940
  id: `dsh-secret-other-${callId}`,
826
941
  className: C.textarea,
827
942
  value: otherText,
@@ -1293,6 +1408,9 @@ const A = {
1293
1408
  close: `${AP}_close`,
1294
1409
  body: `${AP}_body`,
1295
1410
  field: `${AP}_field`,
1411
+ // A `<label>` that wraps the visible caption and the control, so the control
1412
+ // needs no `id` and the caption no `for`: implicit association.
1413
+ fieldGroup: `${AP}_fieldGroup`,
1296
1414
  label: `${AP}_label`,
1297
1415
  input: `${AP}_input`,
1298
1416
  inputRow: `${AP}_inputRow`,
@@ -1337,6 +1455,7 @@ const ATTACH_CSS = `
1337
1455
  .${A.close}:hover{background:var(--dsw-alias-interactive-bg-hover)}
1338
1456
  .${A.body}{flex-direction:column;gap:8px;display:flex;min-width:0}
1339
1457
  .${A.field}{flex-direction:column;gap:6px;display:flex;min-width:0}
1458
+ .${A.fieldGroup}{flex-direction:column;gap:6px;display:flex;min-width:0}
1340
1459
  .${A.label}{color:var(--dsw-alias-label-caption);font-size:12px;line-height:18px}
1341
1460
  .${A.inputRow}{align-items:center;gap:8px;display:flex}
1342
1461
  .${A.input}{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-label-primary);flex:auto;font:inherit;font-size:13px;height:32px;min-width:0;outline:none;padding:0 10px}
@@ -2974,33 +3093,44 @@ function SecretAttachCapsule(props) {
2974
3093
  disabled: busy,
2975
3094
  onChange: () => setScope(next),
2976
3095
  }), h('span', { className: A.optionBody }, h('span', { className: A.optionTitle }, title), h('span', { className: A.optionHint }, hint)));
2977
- 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', {
2978
- id: 'dsh-secret-attach-key',
3096
+ 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 },
3097
+ // Implicit label association: the caption and the control live inside
3098
+ // one `<label>`, so the control carries no `id` and the caption no
3099
+ // `for`. Chrome's own Autofill guide says the browser stores and fills
3100
+ // a field by its `name` attribute and, in some browsers, its `id`, so
3101
+ // the stable identity is exactly what has to go; MDN documents the
3102
+ // nested form as the equivalent association ("the `for` and `id`
3103
+ // attributes are not needed because the association is implicit"), and
3104
+ // `aria-label` carries the same text as an explicit accessible name for
3105
+ // the assistive technologies that do not implement implicit labels.
3106
+ h('label', { className: A.fieldGroup }, h('span', { className: A.label }, t('keyLabel')), h('input', {
3107
+ ...IDENTIFIER_FIELD_SUPPRESSION,
3108
+ ...secretFieldGuards(),
2979
3109
  className: A.input,
2980
3110
  type: 'text',
2981
3111
  value: key,
2982
3112
  placeholder: t('keyPlaceholder'),
2983
- autoComplete: 'off',
2984
- spellCheck: false,
2985
3113
  disabled: busy,
3114
+ 'aria-label': t('keyLabel'),
2986
3115
  onChange: (event) => setKey(event.target.value),
2987
- }), 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', {
2988
- id: 'dsh-secret-attach-label',
3116
+ })), h('span', { className: A.optionHint }, t('keyHint'))), h('div', { className: A.field }, h('label', { className: A.fieldGroup }, h('span', { className: A.label }, t('labelLabel')), h('input', {
3117
+ ...IDENTIFIER_FIELD_SUPPRESSION,
3118
+ ...secretFieldGuards(),
2989
3119
  className: A.input,
2990
3120
  type: 'text',
2991
3121
  value: label,
2992
3122
  placeholder: t('labelPlaceholder'),
2993
- autoComplete: 'off',
2994
3123
  disabled: busy,
3124
+ 'aria-label': t('labelLabel'),
2995
3125
  onChange: (event) => setLabel(event.target.value),
2996
- })), h('div', { className: A.field }, h('label', { className: A.label, htmlFor: 'dsh-secret-attach-value' }, t('valueLabel')), h('div', { className: A.inputRow }, h('input', {
3126
+ }))), h('div', { className: A.field }, h('label', { className: A.label, htmlFor: 'dsh-secret-attach-value' }, t('valueLabel')), h('div', { className: A.inputRow }, h('input', {
3127
+ ...SECRET_FIELD_SUPPRESSION,
3128
+ ...secretFieldGuards(),
2997
3129
  id: 'dsh-secret-attach-value',
2998
3130
  className: A.input,
2999
3131
  type: reveal ? 'text' : 'password',
3000
3132
  value,
3001
3133
  placeholder: t('valuePlaceholder'),
3002
- autoComplete: 'off',
3003
- spellCheck: false,
3004
3134
  disabled: busy,
3005
3135
  'aria-label': t('valueLabel'),
3006
3136
  onChange: (event) => setValue(event.target.value),
@@ -3136,13 +3266,13 @@ function SecretAttachCapsule(props) {
3136
3266
  role: 'group',
3137
3267
  'aria-label': target === 'store' ? t('editTitleStore') : t('editTitleSession'),
3138
3268
  }, 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', {
3269
+ ...SECRET_FIELD_SUPPRESSION,
3270
+ ...secretFieldGuards(),
3139
3271
  id: 'dsh-secret-manage-value',
3140
3272
  className: A.input,
3141
3273
  type: editReveal ? 'text' : 'password',
3142
3274
  value: editValue,
3143
3275
  placeholder: t('editValuePlaceholder'),
3144
- autoComplete: 'off',
3145
- spellCheck: false,
3146
3276
  disabled: busy,
3147
3277
  'aria-label': t('editValueLabel'),
3148
3278
  onChange: (event) => setEditValue(event.target.value),
@@ -3601,6 +3731,7 @@ function SecretManageCard(props) {
3601
3731
  // into a field that asks the human to retype (or reveals) the value.
3602
3732
  request !== null && request.action === 'scope' && request.to === 'persistent'
3603
3733
  ? 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', {
3734
+ ...SECRET_FIELD_SUPPRESSION,
3604
3735
  className: C.input,
3605
3736
  // Masked and disabled: the value is never rendered here, and the
3606
3737
  // human is not asked for it again — the Host writes the copy this
@@ -3609,8 +3740,6 @@ function SecretManageCard(props) {
3609
3740
  value: '',
3610
3741
  readOnly: true,
3611
3742
  disabled: true,
3612
- autoComplete: 'off',
3613
- spellCheck: false,
3614
3743
  'aria-label': t('manageScopeUpCurrent'),
3615
3744
  placeholder: '••••••••',
3616
3745
  })))
@@ -3621,12 +3750,12 @@ function SecretManageCard(props) {
3621
3750
  ? h('p', { className: C.notice, 'data-secret-manage-delete': 'true' }, t('manageDeleteLead'))
3622
3751
  : null, expectValue && !settled
3623
3752
  ? h('div', { className: C.field }, h('span', { className: C.fieldLabel }, '新密钥内容'), h('div', { className: C.inputRow }, h('input', {
3753
+ ...SECRET_FIELD_SUPPRESSION,
3754
+ ...secretFieldGuards(),
3624
3755
  className: C.input,
3625
3756
  type: reveal ? 'text' : 'password',
3626
3757
  value,
3627
3758
  placeholder: '粘贴新的密钥…',
3628
- autoComplete: 'off',
3629
- spellCheck: false,
3630
3759
  disabled: busy || !answerable,
3631
3760
  'aria-label': '新密钥内容',
3632
3761
  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.3",
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,123 @@ 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 same bag for the two **identifier** fields (the credential key and the
837
+ * title), with one deliberate difference: `autocomplete="off"` instead of
838
+ * `new-password`.
839
+ *
840
+ * The first-hand reason is the Chrome team's own Autofill guide: for a field
841
+ * whose value is unique every time and can never be reused — its example is a
842
+ * one-time code, and the credential key and title are the same shape of value —
843
+ * it says to use `autocomplete="off"`, because "the value is different every
844
+ * time, and the browser should not save the value or offer autofill options".
845
+ * The same page records the limit that makes `off` the wrong token for the four
846
+ * *secret* fields: "even with `autocomplete="off"`, the browser will still offer
847
+ * password autofill options so that these password management tools keep
848
+ * working" — a `type="password"` field keeps its credential UI, which is why
849
+ * those four keep `new-password`.
850
+ *
851
+ * `new-password` on a plain text field is not a "do not autofill" hint at all: it
852
+ * is the sign-up hint that tells Chrome to offer *generating* a password, which
853
+ * is exactly the kind of noise the user reported on these two fields.
854
+ *
855
+ * @see https://web.dev/learn/forms/autofill (Learn Forms: Autofill)
856
+ * @see https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/Turning_off_form_autocompletion
857
+ */
858
+ const IDENTIFIER_FIELD_SUPPRESSION = {
859
+ ...SECRET_FIELD_SUPPRESSION,
860
+ autoComplete: 'off',
861
+ } as const
862
+
863
+ /**
864
+ * The hygiene subset for a free-text box that can hold no secret (the card's
865
+ * "other instruction" field): no spelling service, no autocorrection, and no
866
+ * autocomplete — but not the password-manager ignore attributes, because there
867
+ * is no credential here for a manager to capture.
868
+ */
869
+ const PLAIN_TEXT_HYGIENE = {
870
+ autoComplete: 'off',
871
+ autoCorrect: 'off',
872
+ autoCapitalize: 'off',
873
+ spellCheck: false,
874
+ } as const
875
+
759
876
  /** One card for one `secret_request` call, drawn in the conversation flow. */
760
877
  function SecretRequestCard(props: {
761
878
  readonly node?: { readonly data?: unknown }
@@ -986,13 +1103,13 @@ function SecretRequestCard(props: {
986
1103
  'div',
987
1104
  { className: C.inputRow },
988
1105
  h('input', {
1106
+ ...SECRET_FIELD_SUPPRESSION,
1107
+ ...secretFieldGuards(),
989
1108
  id: valueId,
990
1109
  className: C.input,
991
1110
  type: reveal ? 'text' : 'password',
992
1111
  value,
993
1112
  placeholder: TEXT.valuePlaceholder,
994
- autoComplete: 'off',
995
- spellCheck: false,
996
1113
  disabled: !answerable,
997
1114
  'aria-label': TEXT.valueLabel,
998
1115
  onChange: (event: { target: { value: string } }) => setValue(event.target.value),
@@ -1021,6 +1138,7 @@ function SecretRequestCard(props: {
1021
1138
  { key: 'other', className: C.field },
1022
1139
  h('label', { className: C.fieldLabel, htmlFor: `dsh-secret-other-${callId}` }, TEXT.otherLabel),
1023
1140
  h('textarea', {
1141
+ ...PLAIN_TEXT_HYGIENE,
1024
1142
  id: `dsh-secret-other-${callId}`,
1025
1143
  className: C.textarea,
1026
1144
  value: otherText,
@@ -1552,6 +1670,9 @@ const A = {
1552
1670
  close: `${AP}_close`,
1553
1671
  body: `${AP}_body`,
1554
1672
  field: `${AP}_field`,
1673
+ // A `<label>` that wraps the visible caption and the control, so the control
1674
+ // needs no `id` and the caption no `for`: implicit association.
1675
+ fieldGroup: `${AP}_fieldGroup`,
1555
1676
  label: `${AP}_label`,
1556
1677
  input: `${AP}_input`,
1557
1678
  inputRow: `${AP}_inputRow`,
@@ -1597,6 +1718,7 @@ const ATTACH_CSS = `
1597
1718
  .${A.close}:hover{background:var(--dsw-alias-interactive-bg-hover)}
1598
1719
  .${A.body}{flex-direction:column;gap:8px;display:flex;min-width:0}
1599
1720
  .${A.field}{flex-direction:column;gap:6px;display:flex;min-width:0}
1721
+ .${A.fieldGroup}{flex-direction:column;gap:6px;display:flex;min-width:0}
1600
1722
  .${A.label}{color:var(--dsw-alias-label-caption);font-size:12px;line-height:18px}
1601
1723
  .${A.inputRow}{align-items:center;gap:8px;display:flex}
1602
1724
  .${A.input}{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-label-primary);flex:auto;font:inherit;font-size:13px;height:32px;min-width:0;outline:none;padding:0 10px}
@@ -3509,34 +3631,52 @@ function SecretAttachCapsule(props: {
3509
3631
  h(
3510
3632
  'div',
3511
3633
  { className: A.field },
3512
- h('label', { className: A.label, htmlFor: 'dsh-secret-attach-key' }, t('keyLabel')),
3513
- h('input', {
3514
- id: 'dsh-secret-attach-key',
3515
- className: A.input,
3516
- type: 'text',
3517
- value: key,
3518
- placeholder: t('keyPlaceholder'),
3519
- autoComplete: 'off',
3520
- spellCheck: false,
3521
- disabled: busy,
3522
- onChange: (event: { target: { value: string } }) => setKey(event.target.value),
3523
- }),
3634
+ // Implicit label association: the caption and the control live inside
3635
+ // one `<label>`, so the control carries no `id` and the caption no
3636
+ // `for`. Chrome's own Autofill guide says the browser stores and fills
3637
+ // a field by its `name` attribute and, in some browsers, its `id`, so
3638
+ // the stable identity is exactly what has to go; MDN documents the
3639
+ // nested form as the equivalent association ("the `for` and `id`
3640
+ // attributes are not needed because the association is implicit"), and
3641
+ // `aria-label` carries the same text as an explicit accessible name for
3642
+ // the assistive technologies that do not implement implicit labels.
3643
+ h(
3644
+ 'label',
3645
+ { className: A.fieldGroup },
3646
+ h('span', { className: A.label }, t('keyLabel')),
3647
+ h('input', {
3648
+ ...IDENTIFIER_FIELD_SUPPRESSION,
3649
+ ...secretFieldGuards(),
3650
+ className: A.input,
3651
+ type: 'text',
3652
+ value: key,
3653
+ placeholder: t('keyPlaceholder'),
3654
+ disabled: busy,
3655
+ 'aria-label': t('keyLabel'),
3656
+ onChange: (event: { target: { value: string } }) => setKey(event.target.value),
3657
+ }),
3658
+ ),
3524
3659
  h('span', { className: A.optionHint }, t('keyHint')),
3525
3660
  ),
3526
3661
  h(
3527
3662
  'div',
3528
3663
  { className: A.field },
3529
- h('label', { className: A.label, htmlFor: 'dsh-secret-attach-label' }, t('labelLabel')),
3530
- h('input', {
3531
- id: 'dsh-secret-attach-label',
3532
- className: A.input,
3533
- type: 'text',
3534
- value: label,
3535
- placeholder: t('labelPlaceholder'),
3536
- autoComplete: 'off',
3537
- disabled: busy,
3538
- onChange: (event: { target: { value: string } }) => setLabel(event.target.value),
3539
- }),
3664
+ h(
3665
+ 'label',
3666
+ { className: A.fieldGroup },
3667
+ h('span', { className: A.label }, t('labelLabel')),
3668
+ h('input', {
3669
+ ...IDENTIFIER_FIELD_SUPPRESSION,
3670
+ ...secretFieldGuards(),
3671
+ className: A.input,
3672
+ type: 'text',
3673
+ value: label,
3674
+ placeholder: t('labelPlaceholder'),
3675
+ disabled: busy,
3676
+ 'aria-label': t('labelLabel'),
3677
+ onChange: (event: { target: { value: string } }) => setLabel(event.target.value),
3678
+ }),
3679
+ ),
3540
3680
  ),
3541
3681
  h(
3542
3682
  'div',
@@ -3546,13 +3686,13 @@ function SecretAttachCapsule(props: {
3546
3686
  'div',
3547
3687
  { className: A.inputRow },
3548
3688
  h('input', {
3689
+ ...SECRET_FIELD_SUPPRESSION,
3690
+ ...secretFieldGuards(),
3549
3691
  id: 'dsh-secret-attach-value',
3550
3692
  className: A.input,
3551
3693
  type: reveal ? 'text' : 'password',
3552
3694
  value,
3553
3695
  placeholder: t('valuePlaceholder'),
3554
- autoComplete: 'off',
3555
- spellCheck: false,
3556
3696
  disabled: busy,
3557
3697
  'aria-label': t('valueLabel'),
3558
3698
  onChange: (event: { target: { value: string } }) => setValue(event.target.value),
@@ -3845,13 +3985,13 @@ function SecretAttachCapsule(props: {
3845
3985
  'div',
3846
3986
  { className: A.inputRow },
3847
3987
  h('input', {
3988
+ ...SECRET_FIELD_SUPPRESSION,
3989
+ ...secretFieldGuards(),
3848
3990
  id: 'dsh-secret-manage-value',
3849
3991
  className: A.input,
3850
3992
  type: editReveal ? 'text' : 'password',
3851
3993
  value: editValue,
3852
3994
  placeholder: t('editValuePlaceholder'),
3853
- autoComplete: 'off',
3854
- spellCheck: false,
3855
3995
  disabled: busy,
3856
3996
  'aria-label': t('editValueLabel'),
3857
3997
  onChange: (event: { target: { value: string } }) => setEditValue(event.target.value),
@@ -4539,6 +4679,7 @@ function SecretManageCard(props: {
4539
4679
  'div',
4540
4680
  { className: C.inputRow },
4541
4681
  h('input', {
4682
+ ...SECRET_FIELD_SUPPRESSION,
4542
4683
  className: C.input,
4543
4684
  // Masked and disabled: the value is never rendered here, and the
4544
4685
  // human is not asked for it again — the Host writes the copy this
@@ -4547,8 +4688,6 @@ function SecretManageCard(props: {
4547
4688
  value: '',
4548
4689
  readOnly: true,
4549
4690
  disabled: true,
4550
- autoComplete: 'off',
4551
- spellCheck: false,
4552
4691
  'aria-label': t('manageScopeUpCurrent'),
4553
4692
  placeholder: '••••••••',
4554
4693
  }),
@@ -4569,12 +4708,12 @@ function SecretManageCard(props: {
4569
4708
  'div',
4570
4709
  { className: C.inputRow },
4571
4710
  h('input', {
4711
+ ...SECRET_FIELD_SUPPRESSION,
4712
+ ...secretFieldGuards(),
4572
4713
  className: C.input,
4573
4714
  type: reveal ? 'text' : 'password',
4574
4715
  value,
4575
4716
  placeholder: '粘贴新的密钥…',
4576
- autoComplete: 'off',
4577
- spellCheck: false,
4578
4717
  disabled: busy || !answerable,
4579
4718
  'aria-label': '新密钥内容',
4580
4719
  onChange: (event: { target: { value: string } }) => setValue(event.target.value),