@xinvxueyuan/cordis-plugin-secret 0.2.1 → 0.3.0

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
@@ -9,7 +9,7 @@
9
9
  - Host 半(索取方向):注册 `secret_request` 工具;用 `ctx.authorization` 的凭据获取流程承载持久授权;用 `ctx.credentials` 落库;用 `ctx.shellEnv` 按会话注入 `DSH_SECRET_*`。
10
10
  - Host 半(附加方向):`POST /api/secret.attach` 把人类填的值**暂存**在进程内存(暂存不等于授权:此时不注入任何变量);携带标记 `@DSH_SECRET_*` 的用户消息一旦落进会话日志,`session/event` 就把它**提升为按该条消息锚定的授权**;`agent/pre-step` 追加一条**只有变量名**的说明消息,其中逐变量写明这枚标记在模型侧写作 `[secret DSH_SECRET_*]`(正文本身不改写——harness 会让改写落盘,见下文)。
11
11
  - Client 半(索取方向):在 Agent 输出流里渲染**会话流内一级卡片**(`conversation.chat.node`,`key=secret-request`),**无 `shell.overlay` 遮罩**;卡片带 `type="password"` 输入与显示/隐藏切换,把「同意 / 拒绝 / 忽略 / 其他」四个决定、申请理由、用途说明与**授权范围**摆在人类眼前,并允许人类**改写 Agent 请求的范围**。
12
- - Client 半(附加方向):在输入区 `conversation.input.left`(紧随「访问模式 / 计划」控件组右侧)加一个**切换式按钮**;按下后在输入框上方(`conversation.input.overlay`)浮起**填值胶囊**;填完点「插入到光标处」,草稿光标处得到**真正的内联 chip**(`data-composer-chip="secret"`,只显示变量名),可在后续 shell 取用;点击该 chip(或刷新后由 lexicon 装饰出的同名引用)会在同一浮层展开**只读详情胶囊**。
12
+ - Client 半(附加方向):在输入区 `conversation.input.left`(紧随「访问模式 / 计划」控件组右侧)加一个**切换式按钮**;按下后在输入框上方(`conversation.input.overlay`)浮起**填值胶囊**;填完点「插入到光标处」,草稿光标处得到**真正的内联 chip**(`data-composer-chip="secret"`,只显示变量名),可在后续 shell 取用;点击该 chip(或刷新后由 lexicon 装饰出的同名引用)会在同一浮层展开**只读详情胶囊**。0.3.0 起,消息旁的**旁挂胶囊**与(被接管后的)**原胶囊**都能打开详情——前者开输入框上方的信息框、后者开右侧栏详情页;信息框内另有**历史记录**区,`@` 菜单会列出可用密钥(详见「人类主动附加密钥(反方向)→ 0.3.0 的四项增强」)。
13
13
  - 传输:两个方向的对话框都经本插件自有的、位于 `ctx.connection` 信任栅栏内的 `/api` 路由与 Host 通信。密钥值只出现在 `POST /api/secret.answer` 与 `POST /api/secret.attach` 的请求体里,从不进入 URL / 查询串 / 会话日志 / 响应体。
14
14
 
15
15
  ## 安全不变量与副作用披露
@@ -18,7 +18,7 @@
18
18
 
19
19
  1. **插件自身的输出永不携带明文**:工具结果、错误消息、日志、事件、渲染文本、HTTP 响应体与 DOM 属性中都不含密钥值;`render()` 只输出变量名与元数据。单测对四种 decision 的所有字段做全量字符串扫描,断言值不出现;Client 半另有断言证明填值胶囊的值不越出那个掩码输入框。**边界**:值确实会按会话注入到 shell 环境(这是本插件的功能),因此"明文不进上下文"取决于 Agent 不回显 `$env:DSH_SECRET_*`,而不是插件的输出通道。
20
20
  2. **Agent 只拿到变量名**:`approved` 返回 `{ decision, variable, scope, ref, source }`,`variable` 形如 `DSH_SECRET_OPENAI`。
21
- 3. **值只发给本机 Host**:客户端只向 `/api/secret.answer`(索取方向)与 `/api/secret.attach`(附加方向)发起同源 POST(签名 HttpOnly Cookie + Host/Origin 栅栏),不写 URL、不写 localStorage、不打印 console。附加方向的 `GET /api/secret.attached` 只回传变量名/名称/范围/状态,永不回传值。
21
+ 3. **值只发给本机 Host**:客户端只向 `/api/secret.answer`(索取方向)与 `/api/secret.attach`(附加方向)发起同源 POST(签名 HttpOnly Cookie + Host/Origin 栅栏),不写 URL、不写 localStorage、不打印 console。附加方向的 `GET /api/secret.attached` 只回传变量名/名称/范围/状态,永不回传值;0.3.0 的 `POST /api/secret.adopt` 同样**不携带值**(由宿主自己从凭据库解析),`GET /api/secret.history` 与 `GET /api/secret.available` 也只回传值无关字段。
22
22
  4. **会话级密钥不落盘**:`scope: "session"` 的值只存在于进程内存(Host 的暂存表与会话授权表)。落盘只走凭据服务,且只发生在 `persistent`。
23
23
  5. **持久化只经凭据服务**:`persistent` 经 `ctx.credentials.set(<变量名>, value)` 写入凭据引用空间(provider 管理的可写源);同时向记录空间提交一条**不含密钥材料**的标记记录(索取方向 `kind: "grant"`,附加方向 `kind: "attachment"`,payload 只有 `envVar/name/scope/authorizedAt`)。绝不写自建文件,绝不在仓库里存明文。
24
24
  6. **会话边界失败关闭**(见「边界处理」):锚点离开会话表面即撤销并不再注入。
@@ -41,8 +41,8 @@
41
41
  - **可能写凭据库(仅当人类显式选「持久」)**:经 `ctx.credentials.set(<变量名>, value)` 写入凭据引用空间,并追加一条不含密钥材料的标记记录;`session` 范围不落盘。
42
42
  - **模型侧的改写由注记承担,且注记会落盘(按 source 去重)**:`agent/pre-step` **不改**用户消息正文(harness 会让改写落盘,见「人类主动附加密钥(反方向)→ 模型侧到底看到什么」),而是追加一条只含变量名的注记,其中**逐变量逐字**写出「正文里的 `@DSH_SECRET_*` 即该变量,模型侧写作 `[secret DSH_SECRET_*]`;它不是文件路径」。注记是 durable 的 `user/message`(因此在对话流里是一行注入说明),且**按自身 source 去重**:模型可见 surface 上已有同一条就不再追加,重复引入同一标记不会堆叠。
43
43
  - **会在会话日志里留下变量名标记**:人类发送的消息本身(含 `@DSH_SECRET_OPENAI` 这种**变量名**)作为普通 `user/message` 事件持久化。变量名不是密钥材料,但它会长期留在日志里。
44
- - **会挂 5 条 `/api` 路由**:`/api/secret.pending`(GET)、`/api/secret.attached`(GET)、`/api/secret.attach`(POST)、`/api/secret.release`(POST)、`/api/secret.answer`(POST),全部位于 `ctx.connection` 的信任栅栏内(本机 / 可信 Host、同源标记、签名浏览器 Cookie)。值只出现在后两者的请求体里。
45
- - **会注册客户端座位与一个引用源**:`conversation.chat.node`(key `secret-request`)、`tool.call.toolview`(key `secret_request` 的 `null` 占位)、`conversation.input.left`(id `secret-attach-toggle`)、`conversation.input.overlay`(id `secret-attach-capsule`),外加一个名为 `secret` 的 `InputTriggerSource` 与 locale 命名空间 `secretAttach`。`tool.call.toolview` 只替换本插件自己那次调用的泛型工具行,不触碰别的工具;其余都是增量座位。
44
+ - **会挂 8 条 `/api` 路由**:`/api/secret.pending`(GET)、`/api/secret.attached`(GET)、`/api/secret.attach`(POST)、`/api/secret.release`(POST)、`/api/secret.answer`(POST),以及 0.3.0 新增的三条——`/api/secret.history`(GET,本会话历史流水)、`/api/secret.available`(GET,`@` 菜单的可用密钥)、`/api/secret.adopt`(POST,把凭据库里的持久记录登记到本会话)。全部位于 `ctx.connection` 的信任栅栏内(本机 / 可信 Host、同源标记、签名浏览器 Cookie)。值只出现在 `attach` 与 `answer` 的请求体里;三条新路由都**不回传值**,`adopt` 的取值也由宿主自己经 `credentials.resolve` 完成,值不跨线。
45
+ - **会注册客户端座位与一个引用源**:`conversation.chat.node`(key `secret-request`)、`tool.call.toolview`(key `secret_request` 的 `null` 占位)、`conversation.input.left`(id `secret-attach-toggle`)、`conversation.input.overlay`(id `secret-attach-capsule`),外加一个名为 `secret` 的 `InputTriggerSource` 与 locale 命名空间 `secretAttach`。0.3.0 起再增加三处**增量**注册:`conversation.chat.node`(key `sr-chip`,消息旁的旁挂胶囊)、`sidebarRightTabs` 的 `secret-attach-detail` 类型,以及它的**正文座位** `sidebar.right.pane.tab`(key `@xinvxueyuan/cordis-plugin-secret/secret-attach-detail`)。该页签的**标题不另注册座位**:注册表走 fallback 取类型自己声明的 `title(address)`,即**变量名**(`src/client/entry.ts:3555`),所以用户看到的结果是对的。`tool.call.toolview` 只替换本插件自己那次调用的泛型工具行,不触碰别的工具;其余都是增量座位。
46
46
  - **不做的事**:插件自身不 spawn 子进程、不读写仓库文件、不发起网络请求(除被 Harness 自己的 API 通道承载的那 5 条同源路由外),也没有任何遥测。
47
47
 
48
48
  ## 安装
@@ -68,7 +68,7 @@ profile 的组合树是「root 空清单 → `dsh.profile.bundles` 里每个 bun
68
68
  maxPendingRequests: 4
69
69
  ```
70
70
 
71
- (`attachTtlMs` 与 `maxAttachmentsPerSession` 未在这里显式写出,取下面配置表的默认值。)
71
+ (`attachTtlMs`、`maxAttachmentsPerSession`、`maxHistoryPerSession`、`maxAvailableEntries` 未在这里显式写出,取下面配置表的默认值。)
72
72
 
73
73
  安装结果的 `application` 为 `applied` 表示本次变更已生效;`warnings` 会说明 Client 半是否需要刷新页面。
74
74
 
@@ -84,8 +84,10 @@ profile 的组合树是「root 空清单 → `dsh.profile.bundles` 里每个 bun
84
84
  | `maxPendingRequests` | `4` | 同时等待人工确认的授权请求数上限,超出返回 `TOO_MANY_PENDING`。 |
85
85
  | `attachTtlMs` | `1800000` | 人类已填入、但**从未随消息发送**的附加项在内存里保留多久(30 分钟)后被丢弃。暂存从不等于授权:等待期间不注入任何变量,这个上界正是"值不会在长命进程里无限期滞留"的保证。 |
86
86
  | `maxAttachmentsPerSession` | `8` | 单个会话同时可持有的已登记附加项上限。 |
87
+ | `maxHistoryPerSession` | `32` | 单个会话保留的历史记录条数上限(**内存态**,超出丢最旧)。 |
88
+ | `maxAvailableEntries` | `32` | `@` 菜单里「凭据库(持久)」一类最多列出的条目数(每项都要回读一次记录,因此有上限)。 |
87
89
 
88
- 四个键都必须是正整数:schema 的 `.default()` 之外,`assertConfig` 再手工兜一层(非正整数直接抛错)。
90
+ 六个键都必须是正整数:schema 的 `.default()` 之外,`assertConfig` 再手工兜一层(非正整数直接抛错)。
89
91
 
90
92
  ## 工具
91
93
 
@@ -206,6 +208,8 @@ Harness 的公开面 `InputActions` **故意不含**引用插入(`Command-styl
206
208
 
207
209
  ### 自己复测(活体,约 3 分钟)
208
210
 
211
+ > **先重启。** dsh 在进程启动时加载插件的 `lib/` 构建,所以**发布 0.2.1 之后必须重启 `dsh web`** 才会加载修复后的产物;重启前的活体复测仍会复现 0.2.0 的现象(`staged` 不变 `bound`、shell 里没有变量),那是预期,不是修复失败。
212
+
209
213
  1. 在输入区点「附加密钥」按钮 → 胶囊里填名称(如 `openai`)与值,作用域保持默认「仅本次会话」→ 插入 → 输入框出现 `@DSH_SECRET_OPENAI` 胶囊。
210
214
  2. **发送前**:在同一浏览器(同源、带签名 cookie)打开 `GET /api/secret.attached?sessionId=$DSH_SESSION_ID` → 该条的 `state` 必须是 `"staged"`,且此刻 shell 里没有这个变量(未发送永不注入)。
211
215
  3. **发送后立刻**再查同一路由 → 该条的 `state` 变成 `"bound"`(不再停留在 `staged`)。**这是判别"绑定是否真的发生"的最快手段**,也就是这一轮修复的核心判据。
@@ -221,13 +225,90 @@ Harness 的公开面 `InputActions` **故意不含**引用插入(`Command-styl
221
225
  5. 提升的次序是**先向 `shellEnv` 声明 contributor → 再落 grant → 最后消费暂存项**(`src/attach.ts` 的 `bindStaged`)。声明失败时会话原样不变(无 grant、暂存项仍 armed,可在下一次标记到达时重试);反向顺序会留下「有 grant、无 contributor、暂存未消费」的静默不注入状态。另一方向无害:grant 已落但 contributor 因无关原因缺失时,`resolve` 查不到值 ⇒ 不注入(fail-closed)。
222
226
  6. Host 重启后暂存与授权都消失(都在内存里),日志里的标记仍在但不会自动重新武装——**故意 fail-closed**:没有人类在场的新进程里静默恢复一次授权,不是本插件愿意做的事。
223
227
 
228
+ ### 0.3.0 的四项增强(历史胶囊点击 / 历史聚合 / 移除即撤销 / `@` 菜单)
229
+
230
+ 四项增强各一句:
231
+
232
+ | 增强 | 一句话 |
233
+ |---|---|
234
+ | **历史胶囊点击** | 插件在**每条带标记的消息旁**新增一行胶囊(旁挂节点),点击打开**输入框上方**的信息框;同时注册一个右侧栏文档查看器,**接管 harness 渲染的原胶囊**,点它时在**右侧栏**打开详情页。 |
235
+ | **信息框历史聚合** | 信息框内新增「历史记录」区,集中列出本会话的附加/授权流水(变量名、来源、作用域、状态、时间、锚点),**默认全部展示**(含已失效/已回退),用状态文案区分。 |
236
+ | **移除标记即撤销** | 草稿里的标记消失约 0.6s 后,对应的**暂存**记录被撤销:`GET /api/secret.attached` 不再把它报为 `staged`,**会话级的值随即不可找回**(重插须重填)。已绑定的记录不受本机制影响。 |
237
+ | **`@` 引用菜单** | 输入 `@` 列出**本会话当前可用**与**凭据库已持久化**的密钥,条目上用 `section` 标来源、`description` 标作用域;本会话可用的条目直接插入,凭据库条目**先确认再登记**(值由宿主自己从凭据库读取,不经过对话)。 |
238
+
239
+ #### 两个入口的区别(同一份信息,两处入口)
240
+
241
+ | 你点的东西 | 它是什么 | 打开哪里 |
242
+ |---|---|---|
243
+ | **旁挂胶囊**(插件在你的消息旁新增的那一行) | 本插件自己的 `ConversationNodeDefinition` 节点(`conversation.chat.node`,`key = sr-chip`),锚在**同一条消息的 seq** 上、`location = { kind: 'session' }`,因此排在用户气泡**之后**、且永不折进「已调用工具」分组 | **输入框上方**的信息框(与草稿 chip 的点击是同一个面) |
244
+ | **原胶囊**(harness 渲染在你消息正文里的那个) | `dsh-client-ui-primitives` 的 `projectUserText` 产物,`data-ref-chip="file"`,点击被硬编码为 `openFile('DSH_SECRET_X')` | **右侧栏**的详情页(由本插件注册的 `secret-attach-detail` 类型接管),内容与信息框同源 |
245
+
246
+ 旁挂胶囊的落位不是"大概齐":节点 key 形如 `${kind.length}:${kind}${id}`,同锚点、同 rank 时由 `key.localeCompare` 定序(`dsh-client-ui-chat/lib/client.js:8273-8281`)。人类消息的 definition kind 是 `input-message`(13 字符 ⇒ 键前缀 `13:`;`chat:9264`),而 `sr-chip` 是 7 字符(⇒ `7:`);字典序下 `7:` 排在 `13:` **之后**。实测(`node -e`,与 `localeCompare` 同语义):长度 10/11/12 的 kind 会排到 `13:input-message…` **之前**;而该键是**整体字典序**比较,**长度前缀与 kind 文本共同决定顺序**,因此「某几个长度区间一律排在后面」并不成立(实测反例:长度 1 的 kind、以及长度 13 且文本排在 `input-message` 之前的 kind,都排到气泡**之前**)——kind 的位置**必须逐个实测**。本插件实际采用的 `sr-chip`(7 字符)已机器证明排在气泡**之后**。(早前版本里"长度 4 到 9 与 13 及以上一律排在之后"的说法不成立,特此更正。)
247
+
248
+ #### 原胶囊的接管是「长度竞争」,不是契约(如实披露)
249
+
250
+ 原胶囊的点击最终走到右栏的 tab 注册表:`openFile` → `fileAddressFor` → `ctx.sidebarRight.openResource('dsh-resource://file/session/<id>/DSH_SECRET_X')` → `claim(address)`。该注册表按 **priority 档 → 命中的 pattern 长度 → 注册顺序** 排序(`dsh-client-ui-sidebar-right/lib/client.js:8782-8798`)。
251
+
252
+ - 本插件声明 `priority: 'extension'`、`patterns: ['dsh-resource://file/**/DSH_SECRET_*']`(**实测长度 35**),并用 `canOpen` 只接受最后一段形如 `DSH_SECRET_*` 的地址(不是我们的地址一律让给对方);
253
+ - 本机已装的 `dsh-better-sidebar` 0.24.1 在**同一档**声明兜底 `dsh-resource://file/**`(**实测长度 22**):同档时更长的 pattern 胜出,因此这次打开归我们。
254
+
255
+ **可争用性(机制固有,不是缺陷)**:任何**后来者**只要在同一 `extension` 档声明**更长**的 pattern(例如 `dsh-resource://file/**/DSH_SECRET_*/**`),就会**静默抢走**这次打开——用户会落到那个查看器,而不是我们的详情页,且我们收不到任何通知。这一点无法用契约保证,只能靠"我们当前最长"这一事实。**若某天发现点原胶囊又开出别的东西(甚至退回"文件不存在"),原因就在这里。**
256
+
257
+ #### 历史记录的保留语义(如实)
258
+
259
+ | 维度 | 事实 |
260
+ |---|---|
261
+ | 存储 | Host 侧 `HistoryStore` 是**纯进程内存**,按会话隔离,有容量上限(`maxHistoryPerSession`,默认 32),读出时**最新在前** |
262
+ | 刷新页面 | **仍在**(Host 进程没变) |
263
+ | 宿主重启 / 插件重载 | **清空**(历史不在磁盘上) |
264
+ | 重放 / 分叉会话 | **不重建**:Host **不读** session log 还原历史;fork 出来的子会话从空历史开始 |
265
+ | 条目来源 | `staged`(附加登记,可含"取代了同变量的上一条")/ `bound`(提升为授权,带锚点 seq)/ `discarded` / `withdrawn`(草稿移除撤销)/ `revoked`(锚点消失,**读取时懒观测**)/ `expired`(TTL 到期)/ `authorized`(`secret_request` 索取方向的授权),并同时标 `attach` / `request` |
266
+ | 锚点 | 已绑定的条目带 `anchorSeq`(即那条 `user/message` 的 seq);未绑定或未落盘的条目没有锚点,UI 不编造 |
267
+
268
+ 客户端的四种诚实状态(都出现过,都不编造):**未读**(「正在读取本会话的记录…」)/**读失败**(「历史暂不可用」,并**保留上一次的答案**,不清空)/**本进程无记录**(「本进程内暂无记录。」)/**该变量无记录**(「该变量在本进程内没有记录。」)。
269
+
270
+ 「默认全部展示」的落地:已失效/已回退/已丢弃/已过期都列出,并用状态文案区分——`已登记` / `已绑定到消息` / `已丢弃` / `已随草稿移除撤销` / `已随消息回退失效` / `已过期` / `经授权生效`。其中 `revoked`(已随消息回退失效)只在**有人读过该变量状态之后**才出现(懒观测):没观测到就不写进流水,宁缺勿假。
271
+
272
+ #### 从草稿移除标记即撤销(含边界)
273
+
274
+ 只看"草稿里没有标记"是不够的,因为**发送也会清空草稿**(乐观清空):普通发送的提交相位**全程是 `plain`**(不走 `submitting`),但 `pendingSubmission` 回显会**同步先行**出现,且它的文本就是将要发送的文本。因此撤销要在**全部**条件成立时才触发:
275
+
276
+ 1. 草稿里没有该标记(`InputState.draft` + `MARKER_RE`);
277
+ 2. **没有任何** `pendingSubmission` 的文本携带该标记(⇒ 发送中不撤销);
278
+ 3. 提交相位是 `plain`;
279
+ 4. 该变量**曾在草稿里出现过**(否则刚 attach 完还没插进去就会被立刻误撤销);
280
+ 5. 本地状态是 `staged`。
281
+
282
+ 满足后**去抖 600ms**,再做一次同样的判定,然后调 `POST /api/secret.release`(带 `reason: "withdrawn"`,与人类手点「丢弃」的 `discarded` 在历史里区分开)。
283
+
284
+ | 边界 | 行为 |
285
+ |---|---|
286
+ | 删掉后 600ms 内又插回(含剪切/粘贴) | **不撤销**,记录保留 |
287
+ | 删掉后隔一会儿再插回 | 记录**已撤销**,`@` 菜单不再列出该变量;重新附加**必须重新填值**(会话级的值不可找回;`persistent` 作用域的可以从凭据库条目重新登记) |
288
+ | 发送(Enter / 发送按钮) | **不撤销**:回显携带标记 ⇒ 判定中止;消息落盘后该记录提升为 `bound` |
289
+ | 发送失败(草稿被恢复) | 标记回到草稿 ⇒ 不撤销,记录保持 `staged` |
290
+ | 发送中又手动删掉标记 | 同样不撤销(人类删的是草稿,不是那条已经发出的消息) |
291
+ | 已 `bound` / 已 Grant 的记录 | **本机制不碰**:只有回退/改写那条消息才会让它失效(`revoked-anchor`,与既有一致) |
292
+ | 宿主未提供草稿观测能力 | 胶囊里显示「宿主未提供草稿观测能力,自动撤销不可用(可手动丢弃或等 TTL 过期)。」——**宁可不撤销,也不误撤销** |
293
+ | 刷新页面 | 草稿恢复为纯文本 `@VAR`,`MARKER_RE` 仍命中 ⇒ 不撤销 |
294
+ | 撤销后重新附加同一凭据键 | 新记录带新的代次;任何在途的旧计时器因代次不匹配而作废,不会杀掉新记录 |
295
+
296
+ #### `@` 菜单的两类来源
297
+
298
+ | 来源(`section`) | 含义 | 条目 `description` | 选中后的行为 |
299
+ |---|---|---|---|
300
+ | `本会话可用` | 本会话已登记(`staged`)或已绑定(`bound`)的变量 | `本会话 · <作用域> · <状态>` | **直接插入**引用标记:与既有 `codec` / `serializeReference` 完全一致(插入的就是 `@DSH_SECRET_X`),发送后照常绑定与注入 |
301
+ | `凭据库(持久)` | 本插件在凭据库里留过持久记录的变量,且本会话当前没有 | `凭据库 · 持久保存到凭据库 · 尚未用于本会话` | **不直接插入**:先弹一步确认(变量名 + 来源 + 作用域),确认后调 `POST /api/secret.adopt`,由宿主自己 `credentials.resolve` 取值并登记到本会话,**成功之后**才插入标记;失败则不插入任何东西,只显示固定文案 |
302
+
303
+ 条目形态用足菜单的呈现能力:**`label` 是主显示文本,但不总是变量名**——「凭据库(持久)」一类是**变量名**(`src/service.ts:664-668`,记录里没有人类标题,变量名是唯一诚实的标签),「本会话可用」一类是**人类标题**(在胶囊里填的标题;留空即回退为凭据键,`src/client/entry.ts:2808`,attach 请求缺省 `label ?? name`)⇒ **带标题的本会话条目上不会显示变量名**(变量名出现在插入后的草稿标记与胶囊详情里,那两处都按变量名显示;菜单条目的 `description` 只写来源/作用域/状态)。`name` = 凭据键(灰色别名 + 搜索键)、`description` = 来源·作用域·状态、`section` = 来源分组(**不把来源塞进名字里**)。菜单**不渲染** `hint` 字段,所以来源/作用域不放在 `hint`。「凭据库」一类只列**本插件提交过记录**的密钥:凭据服务的 reference 半边**没有枚举面**,别处写进去的环境变量/`.env` 条目无法被发现,这一点如实说明而不是猜。
304
+
224
305
  ### 已知限制(如实记录)
225
306
 
226
- - **对话框那条消息上的胶囊是 `data-ref-chip="file"`,点击会尝试 `openFile('DSH_SECRET_OPENAI')`**(打开一个不存在的文件,无害但不正确;不泄露任何东西——变量名本来就在那里可见)。**这是已知限制,不是未做完的功能。**原因:用户气泡的正文由 `dsh-client-ui-primitives` 的 `projectUserText` 独家渲染,它对 `@token` 形状**硬编码**为文件引用并挂 `openFile`;ui-chat 的消息管线没有可注册的引用种类(能接住点击的 `openReference` 只作用于**草稿编辑器**,不作用于转录气泡)。
307
+ - **对话框那条消息上的胶囊是 `data-ref-chip="file"`**,`openFile('DSH_SECRET_OPENAI')` 仍是它唯一的点击路径。**0.3.0 起的现状**:插件已注册 `sidebarRightTabs` 的 `secret-attach-detail` 类型**接管这次打开**,所以点它会开出**右侧栏的详情页**(见「0.3.0 的四项增强 → 原胶囊的接管是长度竞争」);0.2.x 的行为是打开一个不存在的文件(无害但不正确;不泄露任何东西——变量名本来就在那里可见),该行为作为历史如实保留。**注意这条接管是"当前 pattern 最长"的长度竞争结果,不是契约。**原因(点击目标为何无法从插件侧直接改写):用户气泡的正文由 `dsh-client-ui-primitives` 的 `projectUserText` 独家渲染,它对 `@token` 形状**硬编码**为文件引用并挂 `openFile`;ui-chat 的消息管线没有可注册的引用种类(能接住点击的 `openReference` 只作用于**草稿编辑器**,不作用于转录气泡)。
227
308
  - **为什么不存在"既是 chip 又不可点"的 `@` 形态**(已逐行证明):该函数只认三种 token(`primitives lib/index.js:6724` 的正则:`/名称`、`@"…"`、`@非空白`);`:6753` 的判定是 `@` 开头**必然**映射为 `'file'`(或 `'folder'`),只有 `/` token 才可能是 `void 0`;而 `/` token 又必须在 caller 传入的 `slashNames` 名单里(`:6731`)。所以 `@DSH_SECRET_*` 一定拿到 `referenceKind:'file'` 并因此挂上 `openFile`,没有任何插件钩子能改变它。
228
309
  - 另外两条路都已评估并否决:wire session 形态 `@[label](dsh-session:…)` 虽是**不可点**的 session chip,但会被 `dsh-session-reference` 服务在 `agent/pre-step` 里当作跨会话引用解析(可能让整步失败),风险大于收益;整体接管 `conversation.chat.node` 的 `user` key 并自己重绘用户气泡需要重写附件、图片、markdown 与动作行,脆弱度过高。
229
- - **交付给下一环节的验证项**:这条点击行为需真人在浏览器里确认(预期现象:点转录里的胶囊会尝试打开同名文件)。
230
- - **`agent/pre-step` 追加的那条注记行本身也会被投影成 file chip**:注记是一条 durable 的 `user/message`,正文里逐字含 `@DSH_SECRET_*`,所以在转录里它同样由 `projectUserText` 渲染为 `data-ref-chip="file"` 并挂上同一个 `openFile` 行为。**与上面第一条同源**:观感问题、不泄露任何东西(注记里只有变量名与作用域,没有值),也不是未做完的功能——任何出现在消息正文里的 `@` 标记都逃不过这条 shipped 规则。
310
+ - **交付给下一环节的验证项**:这条点击行为需真人在浏览器里确认。**预期现象已随 0.3.0 改变**:点转录里的胶囊应打开**右侧栏的详情页**(0.2.x 的预期现象才是"尝试打开同名文件");该打开归谁,取决于「0.3.0 的四项增强 → 原胶囊的接管是长度竞争」里那条长度竞争,因此现场还需确认没有被别的插件抢走。
311
+ - **`agent/pre-step` 追加的那条注记行本身也会被投影成 file chip**:注记是一条 durable 的 `user/message`,正文里逐字含 `@DSH_SECRET_*`,所以在转录里它同样由 `projectUserText` 渲染为 `data-ref-chip="file"` 并挂上同一个 `openFile` 行为(0.3.0 起这个 `openFile` 也被我们的右栏查看器接管,因此点它同样打开我们的详情页)。**与上面第一条同源**:观感问题、不泄露任何东西(注记里只有变量名与作用域,没有值),也不是未做完的功能——任何出现在消息正文里的 `@` 标记都逃不过这条 shipped 规则。
231
312
  - 刷新后草稿里的 chip 会变回纯文本 `@DSH_SECRET_OPENAI`(草稿镜像只存文本),由 lexicon 装饰回"引用"观感,点击仍能打开详情胶囊;这与 chip 的原子性不同,是草稿投影的既有语义,不是本插件的取舍。
232
313
  - **工具结果里的 `@DSH_SECRET_*` 没有注记解释**(**值无关,不是泄漏**):注记只为**本步引入的、载有标记的用户消息**追加,所以当同一形状的标记出现在**工具结果**(例如某条命令的回显)里时,它会**原样**进入模型上下文,且那一步的注记不会覆盖它——模型可能按系统提示里"`@` 前缀是文件路径"的约定去解读它,例如尝试读取一个同名文件(会失败)。**它不会因此获得任何值**:这条缺口只涉及"标记的解释范围",与明文无关;变量名本来就在会话日志、用户气泡与注入说明里可见。**准确定性**:这是解释范围的一个已知缺口(不是未做完的功能),既没有把值带进上下文,也没有影响绑定、授权或 shell 注入。
233
314
  - 视觉与真机点击路径需要人工确认(见「边界与已知限制」)。
@@ -266,7 +347,7 @@ Harness 的公开面 `InputActions` **故意不含**引用插入(`Command-styl
266
347
 
267
348
  ### 已知限制(如实记录)
268
349
 
269
- - **转录气泡里的胶囊由 shipped 代码渲染,插件接不了钩子**:`@DSH_SECRET_*` 在用户消息气泡里被 `dsh-client-ui-primitives` 的 `projectUserText` 渲染成 `data-ref-chip="file"`,点击走它硬编码的 `openFile('DSH_SECRET_OPENAI')`(尝试打开一个同名文件,无害、不泄露任何东西,但行为不正确)。**编辑器内(草稿)的胶囊不受影响**:那里是插件自己注册的 `secret` 引用源,点击打开只读详情胶囊。同一条 shipped 规则也适用于 `agent/pre-step` 追加的那行注记(它正文里同样含 `@DSH_SECRET_*`,因此也会显示为 file chip)。逐行证据与"为什么不存在既是 chip 又不可点的 `@` 形态"见「人类主动附加密钥(反方向)→ 已知限制」。
350
+ - **转录气泡里的胶囊由 shipped 代码渲染,插件改不了那次点击的目标**:`@DSH_SECRET_*` 在用户消息气泡里被 `dsh-client-ui-primitives` 的 `projectUserText` 渲染成 `data-ref-chip="file"`,点击走它硬编码的 `openFile('DSH_SECRET_OPENAI')`。**0.3.0 起**:该 `openFile` 解析出的地址(`dsh-resource://file/session/<id>/DSH_SECRET_X`)已被本插件的右栏查看器接管,因此点击打开的是**我们的详情页**;0.2.x 的行为(打开一个同名文件)作为历史如实保留,且**接管随时可能被同档更长的 pattern 抢走**(见「0.3.0 的四项增强 → 原胶囊的接管是长度竞争」)。**编辑器内(草稿)的胶囊不受影响**:那里是插件自己注册的 `secret` 引用源,点击打开只读详情胶囊。同一条 shipped 规则也适用于 `agent/pre-step` 追加的那行注记(它正文里同样含 `@DSH_SECRET_*`,因此也会显示为 file chip)。逐行证据与"为什么不存在既是 chip 又不可点的 `@` 形态"见「人类主动附加密钥(反方向)→ 已知限制」。
270
351
  - **工具结果里的 `@DSH_SECRET_*` 没有注记解释**(值无关,不是泄漏):注记只为**本步引入的、载有标记的用户消息**追加,因此同一形状的标记出现在工具结果里时原样进入模型上下文且无注记覆盖,模型可能按"`@` = 文件路径"去解读它(会失败)。它不会因此获得任何值,也不影响绑定、授权或 shell 注入。详见「人类主动附加密钥(反方向)→ 已知限制」。
271
352
  - **`ctx.authorization` 只承载 `persistent`**:该 seam 的契约要求"本次尝试期间提交并观察到一条凭据记录"(否则 `NOT_COMMITTED`),而 `session` 授权按定义不得落盘。因此 `session` 请求走同一套对话框、但不进该 seam;`persistent` 请求完整走 `registerFlow` + `begin`。这是 seam 契约决定的取舍,不是省事。
272
353
  - **O1 / O2(第二轮修复,两条都如实回报)**:
@@ -274,6 +355,7 @@ Harness 的公开面 `InputActions` **故意不含**引用插入(`Command-styl
274
355
  - **O2**:`persistent` 等待本身超时(`requestTimeoutMs`)时,错误码一律是 `TIMEOUT`,不会被 seam 的 `failed` 包装成 `AUTHORIZATION_FAILED`。
275
356
  - **`shellEnv` 的 resolver 是同步的**,而 `ctx.credentials.resolve` 是异步的:无法在每次 shell 执行时回源凭据库。因此授权通过时把值读入该会话的授权表(并在每次注入前做锚点/会话校验),凭据库仍是持久层的真相。**轮换凭据后请重新调用 `secret_request` 刷新会话内副本。**
276
357
  - **验证限制**:本插件的安装与注册由 `cordis_inspect_query` 的 Tool/Slots 证据覆盖;卡片的**视觉**(浅色/深色、布局、四档"工作步骤展示"下的实际渲染位置)与附加方向胶囊的**视觉 / 真机点击路径**只有在浏览器里有页面时才可能确认,无浏览器控制时不做渲染器/截图等替代验证。"卡片在四个档位下都位于步骤进程分组之外"由结构证明(节点无 Turn/Step 坐标 ⇒ 根条目、非 process member)加单测(`buildViewNode` 的 `location.kind === 'session'`)覆盖,**未经真人点击/切档验证**。单测覆盖 Host 侧全部纯逻辑、register 级装配与 Client 半的纯逻辑(节点状态机、四态判定、表单控件、明文不越界、以及浏览器产物在真 cordis 上下文里的 boot);真机点击路径需要人工确认。
358
+ - **0.3.0 新增的"需真人确认"清单(如实标注,未验证即写"未验证")**:① **旁挂胶囊确实渲染在用户气泡之后**(含四档"工作步骤展示"下的实际位置与视觉贴合);② **点旁挂胶囊**打开的是输入框上方的信息框;③ **点原胶囊**打开的是右侧栏详情页(且**不再是**"文件不存在"),以及页面顶栏右栏行为符合预期;④ **原胶囊接管的可争用性**在现场的表现(装/卸 `dsh-better-sidebar`,或临时注册一个更长的同档 pattern,观察是否被静默抢走);⑤ **移除即撤销**的真实时序(删掉 → 约 0.6s 后 `GET /api/secret.attached` 不再是 `staged`;删掉后 600ms 内插回 ⇒ 记录仍在;**发送不得触发撤销**);⑥ **历史区**在刷新后仍在、宿主重启/插件重载后清空、重放/分叉会话不重建;⑦ **`@` 菜单**两类来源的 `section`/`description` 文案在实际宽度下不被截断,且凭据库条目的确认→登记→插入链路真的能取到值。以上 7 项在无浏览器控制时均为**需真人确认**,本 README 不把它们写成已验证。
277
359
 
278
360
  ## 开发
279
361
 
@@ -300,7 +382,11 @@ npm test # node --test 六个文件:
300
382
  # 真 shellEnv.collect() 能取到值;注记逐字写明模型侧记法
301
383
  # test/client-attach.test.ts 用真 cordis Context + sibling provide 装载浏览器产物:apply 在
302
384
  # **缺少三个可选服务**时也不抛(boot 回归门)、注册面、插入阶梯
303
- # (L1 chip / L3 文本 / 无 sessions 时降级)、明文不越出掩码输入
385
+ # (L1 chip / L3 文本 / 无 sessions 时降级)、明文不越出掩码输入;
386
+ # 0.3.0 起还覆盖:旁挂节点定义的 match/buildViewNode 与其 key 的
387
+ # 排序性质、`@` 菜单候选的两类来源与描述、凭据库条目的确认分支、
388
+ # 历史读取器的防御式校验、撤销判定 `decideWithdraw` 的真值表;
389
+ # Host 侧的历史存储/三条新路由/release 的 reason 由上述 Host 用例覆盖
304
390
  npm run build # 产出 lib/(Host 半 + 浏览器产物 ./client)
305
391
  ```
306
392
 
@@ -359,16 +445,20 @@ npm stage approve <stage-id> # 需要 2FA
359
445
  判断该版本是否已存在于目标 registry,已存在就跳过发布(并在 Step Summary 写明"该版本已存在,跳过发布"),
360
446
  因此对已发布版本重推 tag 不会产生必然失败的公开红叉。其它检查错误(网络、鉴权、registry 故障)仍会让 job 失败。
361
447
 
448
+ **registry 现状(截至本文)**:npmjs 上有 `0.0.0-stage`、`0.1.0`、`0.2.0`;`0.2.1` 已由 CI 放入 stage 队列,等维护者用 2FA 批准(`npm stage approve`)后才上线。GitHub Packages 上 `0.1.0`/`0.2.0`/`0.2.1` 都在(那里由 CI 直接发布,不经人工批准)。
449
+
362
450
  同一份包也会发布到 **GitHub Packages**(`npm.pkg.github.com`,`github-packages` job,用内置 `GITHUB_TOKEN`),
363
451
  使包在仓库页面上可见、可被 `@xinvxueyuan:registry=https://npm.pkg.github.com` 的消费者安装。
364
452
 
365
453
  ### GitHub Release 与签名
366
454
 
367
- > **已发生的事实**:`v0.2.0` 的 Release 是 https://github.com/xinvxueyuan/cordis-plugin-secret/releases/tag/v0.2.0
368
- > (2026-10-06 发布,`draft: false`),附件三件:`xinvxueyuan-cordis-plugin-secret-0.2.0.tgz`、`SHA256SUMS`、`SHA256SUMS.asc`;
369
- > tgz 与 SHA256SUMS 各有一份 `gh attestation verify` 可验的构建来源证明。tag 对象为 annotated + GPG 签名
370
- > (`git cat-file -t v0.2.0` → `tag`;GitHub API 的 `verification.verified` → `true`,`reason` → `valid`)。
371
- > 下表是**该版本确实按之执行**的机制,不是"将来会做"的计划。
455
+ > **已发生的事实**:`v0.2.1` 的 Release 是 https://github.com/xinvxueyuan/cordis-plugin-secret/releases/tag/v0.2.1
456
+ > (2026-10-06 发布,`draft: false`),附件三件:`xinvxueyuan-cordis-plugin-secret-0.2.1.tgz`(sha256 `55076023121254ba1a12c3718d4d2f6c3d03c64eb2667920fc19036e270b7edd`)、`SHA256SUMS`、`SHA256SUMS.asc`;
457
+ > tgz 与 SHA256SUMS 各有一份 `gh attestation verify` 可验的构建来源证明,且**证明绑定在 tag 上**(签名证书 SAN = `.../release.yml@refs/tags/v0.2.1`,`resolvedDependencies` = `git+https://github.com/xinvxueyuan/cordis-plugin-secret@refs/tags/v0.2.1@a01dee656eb0d9ec654a79842b9a7064378d02f1`)——即 tag→commit 是证明的一部分,而不是只绑定到 `refs/heads/main`。tag 对象为 annotated + GPG 签名
458
+ > (`git cat-file -t v0.2.1` → `tag`;GitHub API 的 `verification.verified` → `true`,`reason` → `valid`)。
459
+ > 上一个版本 `v0.2.0` 的 Release(https://github.com/xinvxueyuan/cordis-plugin-secret/releases/tag/v0.2.0 )同样是同形三附件;
460
+ > 它的功能缺陷与 0.2.1 的修复见「人类主动附加密钥(反方向)→ 0.2.0 的缺陷与本版修复(0.2.1)」。
461
+ > 下表是**这些版本确实按之执行**的机制,不是"将来会做"的计划。
372
462
 
373
463
  | 环节 | 机制 |
374
464
  | --- | --- |
@@ -379,21 +469,21 @@ npm stage approve <stage-id> # 需要 2FA
379
469
  | 构建来源证明 | `release.yml` 调用 `actions/attest-build-provenance`(pin 到 commit SHA),为 **tgz 与 SHA256SUMS 两者**生成 Sigstore 签名的 SLSA 构建来源证明,可用 `gh attestation verify` 校验。 |
380
470
  | npm 侧 | `release.yml` **完全不执行任何 npm publish**;npm 发布只由上面的 `publish.yml` staged publishing 负责。 |
381
471
 
382
- 维护者操作顺序(`v0.2.0` 已按此执行):
472
+ 维护者操作顺序(`v0.2.0` 与 `v0.2.1` 都已按此执行):
383
473
 
384
474
  ```sh
385
475
  # 1) 本机确认工作区干净、package.json 的 version 已就位(版本号由发布者手工提升)
386
476
  git status --porcelain
387
477
 
388
478
  # 2) 创建 annotated + GPG 签名 tag(私钥仅在本机使用;本机需能完成 GPG 签名)
389
- git tag -s v0.2.0 -m "v0.2.0"
479
+ git tag -s v0.2.1 -m "v0.2.1"
390
480
 
391
481
  # 3) 只推 tag —— release.yml 会构建产物、生成来源证明并创建 Release
392
- git push origin v0.2.0
482
+ git push origin v0.2.1
393
483
 
394
484
  # 4) 对本机生成的 SHA256SUMS 做分离签名并附到 Release(私钥不进 CI)
395
485
  gpg --armor --detach-sign SHA256SUMS
396
- gh release upload v0.2.0 SHA256SUMS.asc --clobber
486
+ gh release upload v0.2.1 SHA256SUMS.asc --clobber
397
487
  ```
398
488
 
399
489
  校验方式:
package/lib/adapters.js CHANGED
@@ -58,6 +58,24 @@ export function credentialsPort(ctx) {
58
58
  const record = { kind: 'grant', payload };
59
59
  await credentials.modifyRecord(key, () => Promise.resolve(record));
60
60
  },
61
+ // Enumeration is optional to the plugin's own job: a credentials
62
+ // implementation that cannot list records simply answers "nothing to
63
+ // enumerate", and the `@` menu then lists only what this session holds.
64
+ // Reading one record back is how a stored key's payload (envVar/name/scope)
65
+ // becomes visible without ever touching the value.
66
+ listRecords: async () => typeof credentials.listRecords === 'function' ? await credentials.listRecords() : [],
67
+ readRecord: async (key) => {
68
+ if (typeof credentials.readRecord !== 'function')
69
+ return undefined;
70
+ const record = await credentials.readRecord(key);
71
+ if (record === undefined)
72
+ return undefined;
73
+ // The store's union has a payload-free arm (an api-key record carries its
74
+ // own key instead). This plugin only ever commits and reads grant
75
+ // records, so anything else reports "not readable" rather than a payload
76
+ // that never existed.
77
+ return record.kind === 'grant' ? { kind: record.kind, payload: record.payload } : undefined;
78
+ },
61
79
  };
62
80
  }
63
81
  /**
package/lib/attach.d.ts CHANGED
@@ -25,6 +25,12 @@ export interface StagedAttach {
25
25
  }
26
26
  /** Timer seam so tests never depend on the wall clock (mirrors `Scheduler`). */
27
27
  export type AttachScheduler = (delayMs: number, callback: () => void) => () => void;
28
+ /** Why one staged attach left the store. */
29
+ export type AttachDropReason =
30
+ /** The TTL elapsed before the entry was sent. */
31
+ 'expired'
32
+ /** A caller removed it explicitly (the capsule's discard, or the draft watcher's withdrawal). */
33
+ | 'removed';
28
34
  /** Everything the staged store needs from its host. */
29
35
  export interface AttachStoreDeps {
30
36
  /** How long a staged attach waits before it is dropped. */
@@ -32,6 +38,13 @@ export interface AttachStoreDeps {
32
38
  /** How many staged attaches one session may hold. */
33
39
  readonly capacity: number;
34
40
  readonly schedule: AttachScheduler;
41
+ /**
42
+ * Reported once per entry that leaves the store by TTL or by an explicit
43
+ * removal. Session teardown and plugin unload do not report (their history is
44
+ * dropped with them), and a replacement never reports: the new entry under the
45
+ * same key keeps the slot alive.
46
+ */
47
+ readonly onDrop?: (sessionId: string, envVar: string, reason: AttachDropReason) => void;
35
48
  }
36
49
  /** The result of staging one attach. */
37
50
  export interface StagedWrite {
@@ -71,6 +84,7 @@ export declare class AttachStore {
71
84
  /** Drop everything and cancel every timer (plugin unload). */
72
85
  disposeAll(): void;
73
86
  private drop;
87
+ private dropQuiet;
74
88
  private clearTimer;
75
89
  }
76
90
  /** One attached variable as the injected note and the capsule describe it. */
package/lib/attach.js CHANGED
@@ -42,7 +42,7 @@ export class AttachStore {
42
42
  // A replacement installed a new entry under the same key: only the
43
43
  // entry this timer was armed for may be dropped.
44
44
  if (this.items.get(key) === stored)
45
- this.drop(key);
45
+ this.drop(key, 'expired');
46
46
  }));
47
47
  return { attach: stored, replaced };
48
48
  }
@@ -61,7 +61,7 @@ export class AttachStore {
61
61
  const key = pairKey(sessionId, envVar);
62
62
  if (!this.items.has(key))
63
63
  return false;
64
- this.drop(key);
64
+ this.drop(key, 'removed');
65
65
  return true;
66
66
  }
67
67
  /** Drop every staged attach of one session (session end). */
@@ -70,7 +70,10 @@ export class AttachStore {
70
70
  for (const [key, item] of [...this.items]) {
71
71
  if (item.sessionId !== sessionId)
72
72
  continue;
73
- this.drop(key);
73
+ // Session teardown drops the history with the entries, so it reports
74
+ // nothing: an `expired`/`removed` entry for a session that no longer
75
+ // exists would be a fact nobody could ever read.
76
+ this.dropQuiet(key);
74
77
  dropped += 1;
75
78
  }
76
79
  return dropped;
@@ -81,7 +84,15 @@ export class AttachStore {
81
84
  this.clearTimer(key);
82
85
  this.items.clear();
83
86
  }
84
- drop(key) {
87
+ drop(key, reason) {
88
+ const item = this.items.get(key);
89
+ this.clearTimer(key);
90
+ this.items.delete(key);
91
+ if (item === undefined)
92
+ return;
93
+ this.deps.onDrop?.(item.sessionId, item.envVar, reason);
94
+ }
95
+ dropQuiet(key) {
85
96
  this.clearTimer(key);
86
97
  this.items.delete(key);
87
98
  }