@xinvxueyuan/cordis-plugin-secret 0.2.0 → 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
@@ -7,9 +7,9 @@
7
7
  > Cordis(DeepSeek Harness)插件:**密钥在人与 Agent 之间双向流动——Agent 可以开口索取,人类也可以把一枚密钥主动附加到自己的消息上——而 Agent 永远只拿到一个不透明的变量名(如 `DSH_SECRET_OPENAI`)。插件自身从不把值放进工具结果、错误消息、日志、DOM 或会话记录;值只经 `ctx.shellEnv` 按会话注入到 shell 环境——由 Agent 自己避免回显。**
8
8
 
9
9
  - Host 半(索取方向):注册 `secret_request` 工具;用 `ctx.authorization` 的凭据获取流程承载持久授权;用 `ctx.credentials` 落库;用 `ctx.shellEnv` 按会话注入 `DSH_SECRET_*`。
10
- - Host 半(附加方向):`POST /api/secret.attach` 把人类填的值**暂存**在进程内存(暂存不等于授权:此时不注入任何变量);携带标记 `@DSH_SECRET_*` 的用户消息一旦落进会话日志,`session/event` 就把它**提升为按该条消息锚定的授权**;`agent/pre-step` 再把该标记改写成模型形态并追加一条**只有变量名**的说明消息。
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. **会话边界失败关闭**(见「边界处理」):锚点离开会话表面即撤销并不再注入。
@@ -39,10 +39,10 @@
39
39
  - **会按会话把明文注入子进程环境**:`ctx.shellEnv.register` 为每个变量名声明一个 contributor,每次 shell 执行都**重新校验该执行的会话是否仍持有有效授权**,有效才注入。这是插件功能本身,也是明文唯一离开本进程的出口——注入给子进程意味着该子进程写下的任何输出都可能带上它,是否回显由 Agent 负责。
40
40
  - **会在 Host 进程内存里持有明文**:人类填的值先落在暂存表(`attachTtlMs` 到期即丢、每会话 `maxAttachmentsPerSession` 条上限),随消息绑定后进会话授权表;两者都在内存。进程退出即消失。
41
41
  - **可能写凭据库(仅当人类显式选「持久」)**:经 `ctx.credentials.set(<变量名>, value)` 写入凭据引用空间,并追加一条不含密钥材料的标记记录;`session` 范围不落盘。
42
- - **会改写发给模型的用户文本(不落盘)**:`agent/pre-step` 把 `@DSH_SECRET_*` 纯函数改写成 `[secret DSH_SECRET_*]`,并追加一条只含变量名 / 范围 / 取用写法的注记消息。改写只作用于这一次模型请求,会话日志里保留人类原本写下的文本。
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
 
@@ -149,6 +151,17 @@ profile 的组合树是「root 空清单 → `dsh.profile.bundles` 里每个 bun
149
151
 
150
152
  ## 人类主动附加密钥(反方向)
151
153
 
154
+ ### 0.2.0 的缺陷与本版修复(0.2.1)
155
+
156
+ **0.2.0 已发布,但这条链路当时不可用。**缺陷有两条,0.2.1 一并修复:
157
+
158
+ 1. **附加秘密后,后续 shell 取不到变量**:标记从未被提升为 grant,`shellEnv` 里因此没有这个变量(`GET /api/secret.attached` 会一直停在 `staged`,不会变成 `bound`)。
159
+ 2. **对话流里那条消息没有变量名胶囊**:用户气泡显示原始文本,而不是"只显示变量名"的 chip。
160
+
161
+ **根因一句话**:0.2.0 让 `agent/pre-step` 去改写用户消息**正文**(`@DSH_SECRET_*` → `[secret DSH_SECRET_*]`),并假定"改写只作用于本次模型请求、不落盘";但这条链路里 `agent/pre-step` 的返回值**就是被原样 append 落盘的那一条**(`dsh-agent-loop/lib/index.js:1061` 直接 append 成 `user/message`,同一步的模型请求由**同一份 surface** 派生,`:1262`),正文被换掉之后 `session/event` 的绑定再也看不到 `@DSH_SECRET_*` 标记(绑定永不发生),气泡也因为正文里没有 `@` 标记而不再渲染胶囊。
162
+
163
+ **0.2.1 的修复**:不再改写正文——日志、对话流与模型请求里的正文都是人类客户端发出的原始形态 `@DSH_SECRET_*`(`session/event` 因此重新看得见标记:绑定、`anchorSeq`、`shellEnv` contributor 全部成立;`projectUserText` 也重新把它投影成"只显示变量名"的胶囊)。模型侧的对应关系改由一条**值无关**的注记**逐变量逐字**承担(见下「模型侧到底看到什么」)。同时把提升次序固定为「先声明 contributor → 再落 grant → 最后消费暂存项」(见「绑定与失效」第 5 条)。
164
+
152
165
  ### 形态
153
166
 
154
167
  | 部件 | 座位 | 说明 |
@@ -181,9 +194,27 @@ Harness 的公开面 `InputActions` **故意不含**引用插入(`Command-styl
181
194
  |---|---|
182
195
  | 会话日志(durable) | `user/message` · `请用 @DSH_SECRET_OPENAI 跑测试` |
183
196
  | 用户气泡 | `请用` + **胶囊(只显示 `DSH_SECRET_OPENAI`)** + `跑测试`,其后一行注入说明(header 标注 producer `secret-attach`) |
184
- | 模型请求(`agent/pre-step`,**不落盘**) | `请用 [secret DSH_SECRET_OPENAI] 跑测试`,其后追加一条值无关说明:变量名 · 作用域 · `PowerShell 用 $env:DSH_SECRET_OPENAI,POSIX shell 用 "$DSH_SECRET_OPENAI"` · 「不要把该标记当作文件路径读取。」 |
197
+ | 模型请求(`agent/pre-step`) | `请用 @DSH_SECRET_OPENAI 跑测试`(**与日志逐字相同**),其后追加一条值无关说明:`- DSH_SECRET_OPENAI · 仅本次会话有效`、`正文里的 @DSH_SECRET_OPENAI 即该变量,模型侧写作 [secret DSH_SECRET_OPENAI];它不是文件路径。`、`取用方式:PowerShell 用 $env:DSH_SECRET_OPENAI,POSIX shell 用 "$DSH_SECRET_OPENAI"`、「不要把该标记当作文件路径读取。」 |
198
+
199
+ **为什么模型侧不是改写正文,而是由注记逐变量写明对应关系**(这是与早期设计稿不同的一点,原因在 harness 契约,不在取舍):
200
+
201
+ - `agent/pre-step` 返回的消息就是**落盘的那条**:loop 把它**原样** append 成 `user/message`(`dsh-agent-loop/lib/index.js:1061`,`surfaceOp: 'append'`),而同一步的模型请求由**同一份 surface** 派生(`:1262`)。所以"改写只作用于本次模型请求、不落盘"在这个接线下不可能成立:改写必然落盘,落盘就必然改变用户气泡。
202
+ - 模型输入按契约是**会话日志的纯函数**:loop 构造的请求被 deep-freeze,`llm/stream` 的监听者「read it, never rewrite it」(`dsh-llm/lib/types/index.d.ts:37-45`)。
203
+ - 唯一能保留"仅模型可见副本"的机制是 surface replacement / message projection,但它必须 append 在**目标事件之后**,而 loop 的 append(`:1061`)与请求构造(`:1262`)之间没有任何可插入点;由插件自己先 append 目标再替换,会把用户消息挪到本步 `system/message` 提交之前(模型会先读用户消息再读系统提示),代价大于收益。自定义事件类型还需要 `ignorable` 才不被持久化读取拒绝(`dsh-session-persistence/lib/index.js:184`),树外插件拿不到。
204
+
205
+ 因此**日志与用户气泡保留人类客户端发出的原始形态** `@DSH_SECRET_*`(这样 `projectUserText` 才能把它投影成"只显示变量名"的胶囊),模型侧的改写改由注记承担:注记**逐变量逐字**写出「正文里的 `@VAR` 即该变量,模型侧写作 `[secret VAR]`;它不是文件路径。」——`[secret VAR]` 形态因此**逐字**出现在模型可见文本里,且与正文的对应关系是确定的、不依赖启发式。注记是**文本的纯函数**(只认 `@DSH_SECRET_*` 的形状),刷新、重放与 fork 拿到的模型文本一致。
206
+
207
+ **注记的落盘与去重**:注记本身是一条 durable 的 `user/message`(`source.kind = 'secret-attach'`),所以它在对话流里显示为一行注入说明;同一条注记**按自身 source 去重**(`src/inject.ts` 的 `noteVisible`:模型可见 surface 上已有完全相同的 source 就不再追加),同一步/后续步重复引入同一标记不会堆叠第二份。
208
+
209
+ ### 自己复测(活体,约 3 分钟)
185
210
 
186
- 改写是**文本的纯函数**(只认 `@DSH_SECRET_*` 的形状,不看本会话当时是否还持有它),所以刷新、重放与 fork 后拿到的是同一个模型文本;也正因为如此,**经改写的用户消息**里的标记不会去误导系统提示中"`@` 前缀是文件路径"的既有约定。**限定(不要读成全局结论)**:改写只作用于 `source.kind === 'user'` 的消息(`src/inject.ts:137`),因此同一形状的标记出现在**工具结果**里时**不**在本改写范围内,模型仍可能按 `@` = 文件路径的语义去解读它——见「已知限制(如实记录)」。
211
+ > **先重启。** dsh 在进程启动时加载插件的 `lib/` 构建,所以**发布 0.2.1 之后必须重启 `dsh web`** 才会加载修复后的产物;重启前的活体复测仍会复现 0.2.0 的现象(`staged` 不变 `bound`、shell 里没有变量),那是预期,不是修复失败。
212
+
213
+ 1. 在输入区点「附加密钥」按钮 → 胶囊里填名称(如 `openai`)与值,作用域保持默认「仅本次会话」→ 插入 → 输入框出现 `@DSH_SECRET_OPENAI` 胶囊。
214
+ 2. **发送前**:在同一浏览器(同源、带签名 cookie)打开 `GET /api/secret.attached?sessionId=$DSH_SESSION_ID` → 该条的 `state` 必须是 `"staged"`,且此刻 shell 里没有这个变量(未发送永不注入)。
215
+ 3. **发送后立刻**再查同一路由 → 该条的 `state` 变成 `"bound"`(不再停留在 `staged`)。**这是判别"绑定是否真的发生"的最快手段**,也就是这一轮修复的核心判据。
216
+ 4. 让 Agent 在**后续**执行里只报存在性与长度(绝不回显值),例如 PowerShell:`if ($env:DSH_SECRET_OPENAI) { "present length=$($env:DSH_SECRET_OPENAI.Length)" } else { "absent" }`。看到 `present` 即「附加 → 绑定 → 注入」端到端可用。
217
+ 5. 回退/编辑重写那条消息后再查:锚点离开 live surface ⇒ 授权撤销、变量重新不可用(`revoked-anchor`)。
187
218
 
188
219
  ### 绑定与失效
189
220
 
@@ -191,16 +222,95 @@ Harness 的公开面 `InputActions` **故意不含**引用插入(`Command-styl
191
222
  2. 携带标记的用户消息一旦成为 durable 事件,`session/event` 钩子把它提升为授权,`anchorSeq` = **该条 `user/message` 事件的 seq**。该服务的契约原文是 *"Seed events never publish on `session/event`"*(`dsh-session/lib/index.js:1271-1275`),所以重放或恢复一份历史日志**不可能**重新绑定——这比"回头扫日志找锚点"更稳,因为锚点 seq 本来也只有消息落盘之后才可知。
192
223
  3. 之后 `shellEnv` 才在每次执行时按会话注入。回退/编辑重写掉那条消息 ⇒ 锚点离开 live surface ⇒ 自动 `revoked-anchor`,无需额外记账。
193
224
  4. `POST /api/secret.release` 只能丢弃**尚未绑定**的暂存项;已绑定项会如实回答 `state:"bound"`(唯一诚实的解除方式是回退那条消息)。
194
- 5. Host 重启后暂存与授权都消失(都在内存里),日志里的标记仍在但不会自动重新武装——**故意 fail-closed**:没有人类在场的新进程里静默恢复一次授权,不是本插件愿意做的事。
225
+ 5. 提升的次序是**先向 `shellEnv` 声明 contributor → 再落 grant → 最后消费暂存项**(`src/attach.ts` 的 `bindStaged`)。声明失败时会话原样不变(无 grant、暂存项仍 armed,可在下一次标记到达时重试);反向顺序会留下「有 grant、无 contributor、暂存未消费」的静默不注入状态。另一方向无害:grant 已落但 contributor 因无关原因缺失时,`resolve` 查不到值 ⇒ 不注入(fail-closed)。
226
+ 6. Host 重启后暂存与授权都消失(都在内存里),日志里的标记仍在但不会自动重新武装——**故意 fail-closed**:没有人类在场的新进程里静默恢复一次授权,不是本插件愿意做的事。
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` 条目无法被发现,这一点如实说明而不是猜。
195
304
 
196
305
  ### 已知限制(如实记录)
197
306
 
198
- - **对话框那条消息上的胶囊是 `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` 只作用于**草稿编辑器**,不作用于转录气泡)。
199
308
  - **为什么不存在"既是 chip 又不可点"的 `@` 形态**(已逐行证明):该函数只认三种 token(`primitives lib/index.js:6724` 的正则:`/名称`、`@"…"`、`@非空白`);`:6753` 的判定是 `@` 开头**必然**映射为 `'file'`(或 `'folder'`),只有 `/` token 才可能是 `void 0`;而 `/` token 又必须在 caller 传入的 `slashNames` 名单里(`:6731`)。所以 `@DSH_SECRET_*` 一定拿到 `referenceKind:'file'` 并因此挂上 `openFile`,没有任何插件钩子能改变它。
200
309
  - 另外两条路都已评估并否决:wire session 形态 `@[label](dsh-session:…)` 虽是**不可点**的 session chip,但会被 `dsh-session-reference` 服务在 `agent/pre-step` 里当作跨会话引用解析(可能让整步失败),风险大于收益;整体接管 `conversation.chat.node` 的 `user` key 并自己重绘用户气泡需要重写附件、图片、markdown 与动作行,脆弱度过高。
201
- - **交付给下一环节的验证项**:这条点击行为需真人在浏览器里确认(预期现象:点转录里的胶囊会尝试打开同名文件)。
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 规则。
202
312
  - 刷新后草稿里的 chip 会变回纯文本 `@DSH_SECRET_OPENAI`(草稿镜像只存文本),由 lexicon 装饰回"引用"观感,点击仍能打开详情胶囊;这与 chip 的原子性不同,是草稿投影的既有语义,不是本插件的取舍。
203
- - **工具结果里的 `@DSH_SECRET_*` 不在改写范围内**(**值无关,不是泄漏**):`agent/pre-step` 的改写只针对 `source.kind === 'user'` 的消息(`src/inject.ts:137`),所以当同一形状的标记出现在**工具结果**(例如某条命令的回显)里时,它会**原样**进入模型上下文,不会被改写成 `[secret VAR]`。影响**仅限于语义**:模型可能按系统提示里"`@` 前缀是文件路径"的约定去解读它,例如尝试读取一个同名文件(会失败)。**它不会因此获得任何值**——这条缺口只涉及"变量名标记的改写范围",与明文无关;变量名本来就在会话日志、用户气泡与注入说明里可见。**准确定性**:这是改写范围的一个已知缺口(不是未做完的功能),既没有把值带进上下文,也没有影响绑定、授权或 shell 注入。
313
+ - **工具结果里的 `@DSH_SECRET_*` 没有注记解释**(**值无关,不是泄漏**):注记只为**本步引入的、载有标记的用户消息**追加,所以当同一形状的标记出现在**工具结果**(例如某条命令的回显)里时,它会**原样**进入模型上下文,且那一步的注记不会覆盖它——模型可能按系统提示里"`@` 前缀是文件路径"的约定去解读它,例如尝试读取一个同名文件(会失败)。**它不会因此获得任何值**:这条缺口只涉及"标记的解释范围",与明文无关;变量名本来就在会话日志、用户气泡与注入说明里可见。**准确定性**:这是解释范围的一个已知缺口(不是未做完的功能),既没有把值带进上下文,也没有影响绑定、授权或 shell 注入。
204
314
  - 视觉与真机点击路径需要人工确认(见「边界与已知限制」)。
205
315
 
206
316
  ## 存储与传播
@@ -237,20 +347,21 @@ Harness 的公开面 `InputActions` **故意不含**引用插入(`Command-styl
237
347
 
238
348
  ### 已知限制(如实记录)
239
349
 
240
- - **转录气泡里的胶囊由 shipped 代码渲染,插件接不了钩子**:`@DSH_SECRET_*` 在用户消息气泡里被 `dsh-client-ui-primitives` 的 `projectUserText` 渲染成 `data-ref-chip="file"`,点击走它硬编码的 `openFile('DSH_SECRET_OPENAI')`(尝试打开一个同名文件,无害、不泄露任何东西,但行为不正确)。**编辑器内(草稿)的胶囊不受影响**:那里是插件自己注册的 `secret` 引用源,点击打开只读详情胶囊。逐行证据与"为什么不存在既是 chip 又不可点的 `@` 形态"见「人类主动附加密钥(反方向)→ 已知限制」。
241
- - **工具结果里的 `@DSH_SECRET_*` 不在改写范围内**(值无关,不是泄漏):`agent/pre-step` 的改写只针对 `source.kind === 'user'` 的消息(`src/inject.ts:137`),因此同一形状的标记出现在工具结果里时原样进入模型上下文,模型可能按"`@` = 文件路径"去解读它(会失败)。它不会因此获得任何值,也不影响绑定、授权或 shell 注入。详见「人类主动附加密钥(反方向)→ 已知限制」。
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 又不可点的 `@` 形态"见「人类主动附加密钥(反方向)→ 已知限制」。
351
+ - **工具结果里的 `@DSH_SECRET_*` 没有注记解释**(值无关,不是泄漏):注记只为**本步引入的、载有标记的用户消息**追加,因此同一形状的标记出现在工具结果里时原样进入模型上下文且无注记覆盖,模型可能按"`@` = 文件路径"去解读它(会失败)。它不会因此获得任何值,也不影响绑定、授权或 shell 注入。详见「人类主动附加密钥(反方向)→ 已知限制」。
242
352
  - **`ctx.authorization` 只承载 `persistent`**:该 seam 的契约要求"本次尝试期间提交并观察到一条凭据记录"(否则 `NOT_COMMITTED`),而 `session` 授权按定义不得落盘。因此 `session` 请求走同一套对话框、但不进该 seam;`persistent` 请求完整走 `registerFlow` + `begin`。这是 seam 契约决定的取舍,不是省事。
243
353
  - **O1 / O2(第二轮修复,两条都如实回报)**:
244
354
  - **O1**:`persistent` 请求里人类已点"同意",但 seam 的授权尝试最终 `failed` 时,**不再**声称"已持久化":人类的选择被保留,但按「仅本次会话有效」降级生效,`notice` 写明的是**"未能完成持久化登记的确认"**——本插件自己的代码路径不会写凭据库,但 seam 可能在提交授权记录**之前**就已把值写入凭据库(`persist` 先于 `commit`),因此这里不做"值一定没进库"的绝对断言;若值已进库,它只是缺少本次授权记录。
245
355
  - **O2**:`persistent` 等待本身超时(`requestTimeoutMs`)时,错误码一律是 `TIMEOUT`,不会被 seam 的 `failed` 包装成 `AUTHORIZATION_FAILED`。
246
356
  - **`shellEnv` 的 resolver 是同步的**,而 `ctx.credentials.resolve` 是异步的:无法在每次 shell 执行时回源凭据库。因此授权通过时把值读入该会话的授权表(并在每次注入前做锚点/会话校验),凭据库仍是持久层的真相。**轮换凭据后请重新调用 `secret_request` 刷新会话内副本。**
247
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 不把它们写成已验证。
248
359
 
249
360
  ## 开发
250
361
 
251
362
  ```sh
252
363
  npm run typecheck # tsc:Host 半(Node)+ Client 半(DOM),erasableSyntaxOnly,兼容 Node 原生类型剥离
253
- npm test # node --test 五个文件:
364
+ npm test # node --test 六个文件:
254
365
  # test/unit.test.ts 参数校验、变量名推导、decision 映射、session/persistent 路由、
255
366
  # 四类返回都不含值、锚点撤销、fork 不继承、压缩不误撤销(含真实表面折叠)、
256
367
  # 子代理失败关闭、超时、O1(已答复但落库失败 ⇒ 降级 session 并如实回报)、
@@ -264,16 +375,25 @@ npm test # node --test 五个文件:
264
375
  # 假过期四态(不可达/未列出/已提交)、表单控件齐备、明文不越出掩码输入
265
376
  # test/attach.test.ts 附加方向的 Host 侧:暂存 ≠ 授权(未发送不注入)、TTL 丢弃、
266
377
  # 容量上限、绑定锚点、release 只丢未绑定项、值不出现在任何视图里
378
+ # test/attach-wiring.test.ts 接线级回归(0.2.0 缺陷的门):用真实 dsh-session Session 扮演 loop 的
379
+ # 三步(pre-step waterfall → 原样 append decision.messages → 发布
380
+ # session/event),断言 durable 正文逐字保留 `@DSH_SECRET_*`、
381
+ # anchorSeq = 该事件 seq、contributor 已声明、暂存项已消费、
382
+ # 真 shellEnv.collect() 能取到值;注记逐字写明模型侧记法
267
383
  # test/client-attach.test.ts 用真 cordis Context + sibling provide 装载浏览器产物:apply 在
268
384
  # **缺少三个可选服务**时也不抛(boot 回归门)、注册面、插入阶梯
269
- # (L1 chip / L3 文本 / 无 sessions 时降级)、明文不越出掩码输入
385
+ # (L1 chip / L3 文本 / 无 sessions 时降级)、明文不越出掩码输入;
386
+ # 0.3.0 起还覆盖:旁挂节点定义的 match/buildViewNode 与其 key 的
387
+ # 排序性质、`@` 菜单候选的两类来源与描述、凭据库条目的确认分支、
388
+ # 历史读取器的防御式校验、撤销判定 `decideWithdraw` 的真值表;
389
+ # Host 侧的历史存储/三条新路由/release 的 reason 由上述 Host 用例覆盖
270
390
  npm run build # 产出 lib/(Host 半 + 浏览器产物 ./client)
271
391
  ```
272
392
 
273
393
  ## 设计要点
274
394
 
275
395
  - **附加方向的两段式生命周期**:值先在客户端本地 state,再经一次 POST 进 Host 内存的暂存表;只有携带标记的用户消息成为 durable 事件时才提升为 grant,`anchorSeq` 取该事件的 seq。这样"未发送就永不注入",且锚点不依赖任何"回头扫日志"的启发式。
276
- - **模型侧的改写是纯函数且不落盘**:`agent/pre-step` 只认 `@DSH_SECRET_*` 的形状做文本替换(且只作用于 `source.kind === 'user'` 的消息),再追加一条值无关注记。因此刷新、重放与 fork 拿到的模型文本一致。
396
+ - **模型侧:不改写正文,改写与解释都由一条按 source 去重的值无关注记承担**:`agent/pre-step` 不再替换用户消息正文(它在真实接线下必然落盘,见上一条),而是追加一条只含变量名 / 作用域 / 取用写法 / **逐变量逐字的模型侧记法 `[secret DSH_SECRET_*]`** 的注记;同一注记在模型可见 surface 上只出现一次。因此刷新、重放与 fork 拿到的模型文本一致,且用户气泡始终渲染为变量名胶囊。
277
397
  - **可选服务一律走 cordis 的可选座位**:`inject` 只声明真正的硬依赖 `['slots', 'uiConversation']`;`locale` / `inputTriggers` / `sessions` 经 `ctx.inject([...], cb)` 取得。cordis 的 ctx Proxy 对未声明服务取属性即抛,直接在 `apply()` 里读会让整条 client entry failed、整页 web boot 报 `Failed to load plugins`——这正是第三轮修复的发布阻塞缺陷(`lib/client/entry.js` 现在不再出现对这三个服务的直接 `ctx.<name>` 读取)。
278
398
  - **`ctx.authorization` 只承载持久授权**:见「边界与已知限制」的同名条目——`session` 范围按定义不落盘,不可能满足该 seam 的提交契约,因此走同一套对话框而不进 seam。
279
399
  - **Harness 不自带 `AuthorizationPrompt` 的 Web 渲染器**(已核验:安装包中没有任何 client 包渲染 `AuthorizationPrompt`),所以本插件自己的对话框就是该 prompt 的界面;流程内 `session.prompt({kind:'secret'})` 的值直接来自对话框已收集的答案。
@@ -325,11 +445,21 @@ npm stage approve <stage-id> # 需要 2FA
325
445
  判断该版本是否已存在于目标 registry,已存在就跳过发布(并在 Step Summary 写明"该版本已存在,跳过发布"),
326
446
  因此对已发布版本重推 tag 不会产生必然失败的公开红叉。其它检查错误(网络、鉴权、registry 故障)仍会让 job 失败。
327
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
+
328
450
  同一份包也会发布到 **GitHub Packages**(`npm.pkg.github.com`,`github-packages` job,用内置 `GITHUB_TOKEN`),
329
451
  使包在仓库页面上可见、可被 `@xinvxueyuan:registry=https://npm.pkg.github.com` 的消费者安装。
330
452
 
331
453
  ### GitHub Release 与签名
332
454
 
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
+ > 下表是**这些版本确实按之执行**的机制,不是"将来会做"的计划。
462
+
333
463
  | 环节 | 机制 |
334
464
  | --- | --- |
335
465
  | tag | **annotated 且 GPG 签名**的 tag(`git tag -s`),GitHub 上显示 **Verified** 徽标。tag 由维护者在本机用私钥创建并推送,**私钥永不进入 CI**。 |
@@ -339,21 +469,21 @@ npm stage approve <stage-id> # 需要 2FA
339
469
  | 构建来源证明 | `release.yml` 调用 `actions/attest-build-provenance`(pin 到 commit SHA),为 **tgz 与 SHA256SUMS 两者**生成 Sigstore 签名的 SLSA 构建来源证明,可用 `gh attestation verify` 校验。 |
340
470
  | npm 侧 | `release.yml` **完全不执行任何 npm publish**;npm 发布只由上面的 `publish.yml` staged publishing 负责。 |
341
471
 
342
- 维护者操作顺序(`v0.2.0` 已按此执行):
472
+ 维护者操作顺序(`v0.2.0` 与 `v0.2.1` 都已按此执行):
343
473
 
344
474
  ```sh
345
475
  # 1) 本机确认工作区干净、package.json 的 version 已就位(版本号由发布者手工提升)
346
476
  git status --porcelain
347
477
 
348
478
  # 2) 创建 annotated + GPG 签名 tag(私钥仅在本机使用;本机需能完成 GPG 签名)
349
- git tag -s v0.2.0 -m "v0.2.0"
479
+ git tag -s v0.2.1 -m "v0.2.1"
350
480
 
351
481
  # 3) 只推 tag —— release.yml 会构建产物、生成来源证明并创建 Release
352
- git push origin v0.2.0
482
+ git push origin v0.2.1
353
483
 
354
484
  # 4) 对本机生成的 SHA256SUMS 做分离签名并附到 Release(私钥不进 CI)
355
485
  gpg --armor --detach-sign SHA256SUMS
356
- gh release upload v0.2.0 SHA256SUMS.asc --clobber
486
+ gh release upload v0.2.1 SHA256SUMS.asc --clobber
357
487
  ```
358
488
 
359
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. */
@@ -106,6 +120,16 @@ export declare function scopeLabel(scope: SecretScope): string;
106
120
  * an edit-and-retry that rewrites the message away revokes the exposure without
107
121
  * any bookkeeping here.
108
122
  *
123
+ * Order matters. The `shellEnv` contributor is declared **before** the grant is
124
+ * recorded and the staged entry is consumed, so a registry failure leaves the
125
+ * session exactly as it was (no grant, nothing consumed, the staged entry still
126
+ * armed for a later attempt). The reverse order would leave a grant with no
127
+ * contributor and an unconsumed staged entry — an exposure that silently never
128
+ * injects. In the other direction a recorded grant whose contributor
129
+ * registration failed for an unrelated reason is harmless: the resolver asks
130
+ * `valueFor`, so a missing contributor injects nothing (fail-closed) while the
131
+ * staged entry stays consumed.
132
+ *
109
133
  * @returns the bound variable, or undefined when nothing was staged for it.
110
134
  */
111
135
  export declare function bindStaged(deps: AttachBinderDeps, session: GrantSessionLike, seq: number, envVar: string): BoundAttach | undefined;
@@ -129,8 +153,15 @@ export declare function seqOf(event: SessionEventLike): number | undefined;
129
153
  /**
130
154
  * The value-free note injected beside a message that carried attachments.
131
155
  *
132
- * It names the variables and how to read them, and it says out loud what the
133
- * system prompt would otherwise get wrong: the marker is not a file path.
156
+ * It names the variables and how to read them, and it says out loud the two
157
+ * things the surrounding prompt would otherwise get wrong: what the marker in
158
+ * the message body *is*, and that it is not a file path.
159
+ *
160
+ * The mapping line is the model-facing rewrite. The harness derives every model
161
+ * request from the durable log, so the message body keeps the marker form the
162
+ * human's client sent (which is also the form the conversation view projects to
163
+ * a variable-name capsule); the rewrite therefore rides this note, which states
164
+ * the correspondence per variable, literally.
134
165
  */
135
166
  export declare function renderAttachNote(notes: readonly BoundVariable[]): string;
136
167
  /** The durable, value-free source of the injected note. */
package/lib/attach.js CHANGED
@@ -1,4 +1,4 @@
1
- import { parseMarkers } from "./naming.js";
1
+ import { markerFor, modelFormFor, parseMarkers } from "./naming.js";
2
2
  /**
3
3
  * In-memory, session-scoped staged attaches.
4
4
  *
@@ -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
  }
@@ -105,6 +116,16 @@ export function scopeLabel(scope) {
105
116
  * an edit-and-retry that rewrites the message away revokes the exposure without
106
117
  * any bookkeeping here.
107
118
  *
119
+ * Order matters. The `shellEnv` contributor is declared **before** the grant is
120
+ * recorded and the staged entry is consumed, so a registry failure leaves the
121
+ * session exactly as it was (no grant, nothing consumed, the staged entry still
122
+ * armed for a later attempt). The reverse order would leave a grant with no
123
+ * contributor and an unconsumed staged entry — an exposure that silently never
124
+ * injects. In the other direction a recorded grant whose contributor
125
+ * registration failed for an unrelated reason is harmless: the resolver asks
126
+ * `valueFor`, so a missing contributor injects nothing (fail-closed) while the
127
+ * staged entry stays consumed.
128
+ *
108
129
  * @returns the bound variable, or undefined when nothing was staged for it.
109
130
  */
110
131
  export function bindStaged(deps, session, seq, envVar) {
@@ -112,6 +133,7 @@ export function bindStaged(deps, session, seq, envVar) {
112
133
  const staged = deps.store.get(sessionId, envVar);
113
134
  if (staged === undefined)
114
135
  return undefined;
136
+ deps.envs.ensure(staged.envVar);
115
137
  deps.grants.put({
116
138
  sessionId,
117
139
  name: staged.name,
@@ -123,7 +145,6 @@ export function bindStaged(deps, session, seq, envVar) {
123
145
  replaceGenerationAtApproval: session.surface.replaceGeneration,
124
146
  authorizedAt: deps.now(),
125
147
  });
126
- deps.envs.ensure(staged.envVar);
127
148
  // Consuming the staged entry is what makes binding idempotent: whichever hook
128
149
  // gets there first wins, and the other finds nothing to do.
129
150
  deps.store.remove(sessionId, staged.envVar);
@@ -202,18 +223,27 @@ export function seqOf(event) {
202
223
  /**
203
224
  * The value-free note injected beside a message that carried attachments.
204
225
  *
205
- * It names the variables and how to read them, and it says out loud what the
206
- * system prompt would otherwise get wrong: the marker is not a file path.
226
+ * It names the variables and how to read them, and it says out loud the two
227
+ * things the surrounding prompt would otherwise get wrong: what the marker in
228
+ * the message body *is*, and that it is not a file path.
229
+ *
230
+ * The mapping line is the model-facing rewrite. The harness derives every model
231
+ * request from the durable log, so the message body keeps the marker form the
232
+ * human's client sent (which is also the form the conversation view projects to
233
+ * a variable-name capsule); the rewrite therefore rides this note, which states
234
+ * the correspondence per variable, literally.
207
235
  */
208
236
  export function renderAttachNote(notes) {
209
237
  if (notes.length === 0)
210
238
  return '';
211
239
  const heading = `本条消息附带 ${String(notes.length)} 个由人类主动提供的密钥;明文不进入对话,只能按变量名取用。`;
212
240
  const bullets = notes.map((note) => `- ${note.variable} · ${scopeLabel(note.scope)}`);
241
+ const mappings = notes.map((note) => `正文里的 ${markerFor(note.variable)} 即该变量,模型侧写作 ${modelFormFor(note.variable)};它不是文件路径。`);
213
242
  const reads = notes.map((note) => `PowerShell 用 $env:${note.variable},POSIX shell 用 "$${note.variable}"`).join(';');
214
243
  return [
215
244
  heading,
216
245
  ...bullets,
246
+ ...mappings,
217
247
  '',
218
248
  `取用方式:${reads}。`,
219
249
  '不要把该标记当作文件路径读取。',