@xinvxueyuan/cordis-plugin-secret 0.2.0 → 0.2.1
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 +50 -10
- package/lib/attach.d.ts +19 -2
- package/lib/attach.js +23 -4
- package/lib/index.js +4 -1
- package/lib/inject.d.ts +51 -8
- package/lib/inject.js +86 -54
- package/lib/naming.d.ts +11 -5
- package/lib/naming.js +11 -5
- package/package.json +2 -2
- package/src/attach.ts +25 -4
- package/src/index.ts +4 -1
- package/src/inject.ts +96 -55
- package/src/naming.ts +11 -5
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
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
12
|
- Client 半(附加方向):在输入区 `conversation.input.left`(紧随「访问模式 / 计划」控件组右侧)加一个**切换式按钮**;按下后在输入框上方(`conversation.input.overlay`)浮起**填值胶囊**;填完点「插入到光标处」,草稿光标处得到**真正的内联 chip**(`data-composer-chip="secret"`,只显示变量名),可在后续 shell 取用;点击该 chip(或刷新后由 lexicon 装饰出的同名引用)会在同一浮层展开**只读详情胶囊**。
|
|
13
13
|
- 传输:两个方向的对话框都经本插件自有的、位于 `ctx.connection` 信任栅栏内的 `/api` 路由与 Host 通信。密钥值只出现在 `POST /api/secret.answer` 与 `POST /api/secret.attach` 的请求体里,从不进入 URL / 查询串 / 会话日志 / 响应体。
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
- **会按会话把明文注入子进程环境**:`ctx.shellEnv.register` 为每个变量名声明一个 contributor,每次 shell 执行都**重新校验该执行的会话是否仍持有有效授权**,有效才注入。这是插件功能本身,也是明文唯一离开本进程的出口——注入给子进程意味着该子进程写下的任何输出都可能带上它,是否回显由 Agent 负责。
|
|
40
40
|
- **会在 Host 进程内存里持有明文**:人类填的值先落在暂存表(`attachTtlMs` 到期即丢、每会话 `maxAttachmentsPerSession` 条上限),随消息绑定后进会话授权表;两者都在内存。进程退出即消失。
|
|
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
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
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` 只替换本插件自己那次调用的泛型工具行,不触碰别的工具;其余都是增量座位。
|
|
@@ -149,6 +149,17 @@ profile 的组合树是「root 空清单 → `dsh.profile.bundles` 里每个 bun
|
|
|
149
149
|
|
|
150
150
|
## 人类主动附加密钥(反方向)
|
|
151
151
|
|
|
152
|
+
### 0.2.0 的缺陷与本版修复(0.2.1)
|
|
153
|
+
|
|
154
|
+
**0.2.0 已发布,但这条链路当时不可用。**缺陷有两条,0.2.1 一并修复:
|
|
155
|
+
|
|
156
|
+
1. **附加秘密后,后续 shell 取不到变量**:标记从未被提升为 grant,`shellEnv` 里因此没有这个变量(`GET /api/secret.attached` 会一直停在 `staged`,不会变成 `bound`)。
|
|
157
|
+
2. **对话流里那条消息没有变量名胶囊**:用户气泡显示原始文本,而不是"只显示变量名"的 chip。
|
|
158
|
+
|
|
159
|
+
**根因一句话**: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_*` 标记(绑定永不发生),气泡也因为正文里没有 `@` 标记而不再渲染胶囊。
|
|
160
|
+
|
|
161
|
+
**0.2.1 的修复**:不再改写正文——日志、对话流与模型请求里的正文都是人类客户端发出的原始形态 `@DSH_SECRET_*`(`session/event` 因此重新看得见标记:绑定、`anchorSeq`、`shellEnv` contributor 全部成立;`projectUserText` 也重新把它投影成"只显示变量名"的胶囊)。模型侧的对应关系改由一条**值无关**的注记**逐变量逐字**承担(见下「模型侧到底看到什么」)。同时把提升次序固定为「先声明 contributor → 再落 grant → 最后消费暂存项」(见「绑定与失效」第 5 条)。
|
|
162
|
+
|
|
152
163
|
### 形态
|
|
153
164
|
|
|
154
165
|
| 部件 | 座位 | 说明 |
|
|
@@ -181,9 +192,25 @@ Harness 的公开面 `InputActions` **故意不含**引用插入(`Command-styl
|
|
|
181
192
|
|---|---|
|
|
182
193
|
| 会话日志(durable) | `user/message` · `请用 @DSH_SECRET_OPENAI 跑测试` |
|
|
183
194
|
| 用户气泡 | `请用` + **胶囊(只显示 `DSH_SECRET_OPENAI`)** + `跑测试`,其后一行注入说明(header 标注 producer `secret-attach`) |
|
|
184
|
-
| 模型请求(`agent/pre-step
|
|
195
|
+
| 模型请求(`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"`、「不要把该标记当作文件路径读取。」 |
|
|
196
|
+
|
|
197
|
+
**为什么模型侧不是改写正文,而是由注记逐变量写明对应关系**(这是与早期设计稿不同的一点,原因在 harness 契约,不在取舍):
|
|
185
198
|
|
|
186
|
-
|
|
199
|
+
- `agent/pre-step` 返回的消息就是**落盘的那条**:loop 把它**原样** append 成 `user/message`(`dsh-agent-loop/lib/index.js:1061`,`surfaceOp: 'append'`),而同一步的模型请求由**同一份 surface** 派生(`:1262`)。所以"改写只作用于本次模型请求、不落盘"在这个接线下不可能成立:改写必然落盘,落盘就必然改变用户气泡。
|
|
200
|
+
- 模型输入按契约是**会话日志的纯函数**:loop 构造的请求被 deep-freeze,`llm/stream` 的监听者「read it, never rewrite it」(`dsh-llm/lib/types/index.d.ts:37-45`)。
|
|
201
|
+
- 唯一能保留"仅模型可见副本"的机制是 surface replacement / message projection,但它必须 append 在**目标事件之后**,而 loop 的 append(`:1061`)与请求构造(`:1262`)之间没有任何可插入点;由插件自己先 append 目标再替换,会把用户消息挪到本步 `system/message` 提交之前(模型会先读用户消息再读系统提示),代价大于收益。自定义事件类型还需要 `ignorable` 才不被持久化读取拒绝(`dsh-session-persistence/lib/index.js:184`),树外插件拿不到。
|
|
202
|
+
|
|
203
|
+
因此**日志与用户气泡保留人类客户端发出的原始形态** `@DSH_SECRET_*`(这样 `projectUserText` 才能把它投影成"只显示变量名"的胶囊),模型侧的改写改由注记承担:注记**逐变量逐字**写出「正文里的 `@VAR` 即该变量,模型侧写作 `[secret VAR]`;它不是文件路径。」——`[secret VAR]` 形态因此**逐字**出现在模型可见文本里,且与正文的对应关系是确定的、不依赖启发式。注记是**文本的纯函数**(只认 `@DSH_SECRET_*` 的形状),刷新、重放与 fork 拿到的模型文本一致。
|
|
204
|
+
|
|
205
|
+
**注记的落盘与去重**:注记本身是一条 durable 的 `user/message`(`source.kind = 'secret-attach'`),所以它在对话流里显示为一行注入说明;同一条注记**按自身 source 去重**(`src/inject.ts` 的 `noteVisible`:模型可见 surface 上已有完全相同的 source 就不再追加),同一步/后续步重复引入同一标记不会堆叠第二份。
|
|
206
|
+
|
|
207
|
+
### 自己复测(活体,约 3 分钟)
|
|
208
|
+
|
|
209
|
+
1. 在输入区点「附加密钥」按钮 → 胶囊里填名称(如 `openai`)与值,作用域保持默认「仅本次会话」→ 插入 → 输入框出现 `@DSH_SECRET_OPENAI` 胶囊。
|
|
210
|
+
2. **发送前**:在同一浏览器(同源、带签名 cookie)打开 `GET /api/secret.attached?sessionId=$DSH_SESSION_ID` → 该条的 `state` 必须是 `"staged"`,且此刻 shell 里没有这个变量(未发送永不注入)。
|
|
211
|
+
3. **发送后立刻**再查同一路由 → 该条的 `state` 变成 `"bound"`(不再停留在 `staged`)。**这是判别"绑定是否真的发生"的最快手段**,也就是这一轮修复的核心判据。
|
|
212
|
+
4. 让 Agent 在**后续**执行里只报存在性与长度(绝不回显值),例如 PowerShell:`if ($env:DSH_SECRET_OPENAI) { "present length=$($env:DSH_SECRET_OPENAI.Length)" } else { "absent" }`。看到 `present` 即「附加 → 绑定 → 注入」端到端可用。
|
|
213
|
+
5. 回退/编辑重写那条消息后再查:锚点离开 live surface ⇒ 授权撤销、变量重新不可用(`revoked-anchor`)。
|
|
187
214
|
|
|
188
215
|
### 绑定与失效
|
|
189
216
|
|
|
@@ -191,7 +218,8 @@ Harness 的公开面 `InputActions` **故意不含**引用插入(`Command-styl
|
|
|
191
218
|
2. 携带标记的用户消息一旦成为 durable 事件,`session/event` 钩子把它提升为授权,`anchorSeq` = **该条 `user/message` 事件的 seq**。该服务的契约原文是 *"Seed events never publish on `session/event`"*(`dsh-session/lib/index.js:1271-1275`),所以重放或恢复一份历史日志**不可能**重新绑定——这比"回头扫日志找锚点"更稳,因为锚点 seq 本来也只有消息落盘之后才可知。
|
|
192
219
|
3. 之后 `shellEnv` 才在每次执行时按会话注入。回退/编辑重写掉那条消息 ⇒ 锚点离开 live surface ⇒ 自动 `revoked-anchor`,无需额外记账。
|
|
193
220
|
4. `POST /api/secret.release` 只能丢弃**尚未绑定**的暂存项;已绑定项会如实回答 `state:"bound"`(唯一诚实的解除方式是回退那条消息)。
|
|
194
|
-
5.
|
|
221
|
+
5. 提升的次序是**先向 `shellEnv` 声明 contributor → 再落 grant → 最后消费暂存项**(`src/attach.ts` 的 `bindStaged`)。声明失败时会话原样不变(无 grant、暂存项仍 armed,可在下一次标记到达时重试);反向顺序会留下「有 grant、无 contributor、暂存未消费」的静默不注入状态。另一方向无害:grant 已落但 contributor 因无关原因缺失时,`resolve` 查不到值 ⇒ 不注入(fail-closed)。
|
|
222
|
+
6. Host 重启后暂存与授权都消失(都在内存里),日志里的标记仍在但不会自动重新武装——**故意 fail-closed**:没有人类在场的新进程里静默恢复一次授权,不是本插件愿意做的事。
|
|
195
223
|
|
|
196
224
|
### 已知限制(如实记录)
|
|
197
225
|
|
|
@@ -199,8 +227,9 @@ Harness 的公开面 `InputActions` **故意不含**引用插入(`Command-styl
|
|
|
199
227
|
- **为什么不存在"既是 chip 又不可点"的 `@` 形态**(已逐行证明):该函数只认三种 token(`primitives lib/index.js:6724` 的正则:`/名称`、`@"…"`、`@非空白`);`:6753` 的判定是 `@` 开头**必然**映射为 `'file'`(或 `'folder'`),只有 `/` token 才可能是 `void 0`;而 `/` token 又必须在 caller 传入的 `slashNames` 名单里(`:6731`)。所以 `@DSH_SECRET_*` 一定拿到 `referenceKind:'file'` 并因此挂上 `openFile`,没有任何插件钩子能改变它。
|
|
200
228
|
- 另外两条路都已评估并否决:wire session 形态 `@[label](dsh-session:…)` 虽是**不可点**的 session chip,但会被 `dsh-session-reference` 服务在 `agent/pre-step` 里当作跨会话引用解析(可能让整步失败),风险大于收益;整体接管 `conversation.chat.node` 的 `user` key 并自己重绘用户气泡需要重写附件、图片、markdown 与动作行,脆弱度过高。
|
|
201
229
|
- **交付给下一环节的验证项**:这条点击行为需真人在浏览器里确认(预期现象:点转录里的胶囊会尝试打开同名文件)。
|
|
230
|
+
- **`agent/pre-step` 追加的那条注记行本身也会被投影成 file chip**:注记是一条 durable 的 `user/message`,正文里逐字含 `@DSH_SECRET_*`,所以在转录里它同样由 `projectUserText` 渲染为 `data-ref-chip="file"` 并挂上同一个 `openFile` 行为。**与上面第一条同源**:观感问题、不泄露任何东西(注记里只有变量名与作用域,没有值),也不是未做完的功能——任何出现在消息正文里的 `@` 标记都逃不过这条 shipped 规则。
|
|
202
231
|
- 刷新后草稿里的 chip 会变回纯文本 `@DSH_SECRET_OPENAI`(草稿镜像只存文本),由 lexicon 装饰回"引用"观感,点击仍能打开详情胶囊;这与 chip 的原子性不同,是草稿投影的既有语义,不是本插件的取舍。
|
|
203
|
-
- **工具结果里的 `@DSH_SECRET_*`
|
|
232
|
+
- **工具结果里的 `@DSH_SECRET_*` 没有注记解释**(**值无关,不是泄漏**):注记只为**本步引入的、载有标记的用户消息**追加,所以当同一形状的标记出现在**工具结果**(例如某条命令的回显)里时,它会**原样**进入模型上下文,且那一步的注记不会覆盖它——模型可能按系统提示里"`@` 前缀是文件路径"的约定去解读它,例如尝试读取一个同名文件(会失败)。**它不会因此获得任何值**:这条缺口只涉及"标记的解释范围",与明文无关;变量名本来就在会话日志、用户气泡与注入说明里可见。**准确定性**:这是解释范围的一个已知缺口(不是未做完的功能),既没有把值带进上下文,也没有影响绑定、授权或 shell 注入。
|
|
204
233
|
- 视觉与真机点击路径需要人工确认(见「边界与已知限制」)。
|
|
205
234
|
|
|
206
235
|
## 存储与传播
|
|
@@ -237,8 +266,8 @@ Harness 的公开面 `InputActions` **故意不含**引用插入(`Command-styl
|
|
|
237
266
|
|
|
238
267
|
### 已知限制(如实记录)
|
|
239
268
|
|
|
240
|
-
- **转录气泡里的胶囊由 shipped 代码渲染,插件接不了钩子**:`@DSH_SECRET_*` 在用户消息气泡里被 `dsh-client-ui-primitives` 的 `projectUserText` 渲染成 `data-ref-chip="file"`,点击走它硬编码的 `openFile('DSH_SECRET_OPENAI')`(尝试打开一个同名文件,无害、不泄露任何东西,但行为不正确)。**编辑器内(草稿)的胶囊不受影响**:那里是插件自己注册的 `secret`
|
|
241
|
-
- **工具结果里的 `@DSH_SECRET_*`
|
|
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 又不可点的 `@` 形态"见「人类主动附加密钥(反方向)→ 已知限制」。
|
|
270
|
+
- **工具结果里的 `@DSH_SECRET_*` 没有注记解释**(值无关,不是泄漏):注记只为**本步引入的、载有标记的用户消息**追加,因此同一形状的标记出现在工具结果里时原样进入模型上下文且无注记覆盖,模型可能按"`@` = 文件路径"去解读它(会失败)。它不会因此获得任何值,也不影响绑定、授权或 shell 注入。详见「人类主动附加密钥(反方向)→ 已知限制」。
|
|
242
271
|
- **`ctx.authorization` 只承载 `persistent`**:该 seam 的契约要求"本次尝试期间提交并观察到一条凭据记录"(否则 `NOT_COMMITTED`),而 `session` 授权按定义不得落盘。因此 `session` 请求走同一套对话框、但不进该 seam;`persistent` 请求完整走 `registerFlow` + `begin`。这是 seam 契约决定的取舍,不是省事。
|
|
243
272
|
- **O1 / O2(第二轮修复,两条都如实回报)**:
|
|
244
273
|
- **O1**:`persistent` 请求里人类已点"同意",但 seam 的授权尝试最终 `failed` 时,**不再**声称"已持久化":人类的选择被保留,但按「仅本次会话有效」降级生效,`notice` 写明的是**"未能完成持久化登记的确认"**——本插件自己的代码路径不会写凭据库,但 seam 可能在提交授权记录**之前**就已把值写入凭据库(`persist` 先于 `commit`),因此这里不做"值一定没进库"的绝对断言;若值已进库,它只是缺少本次授权记录。
|
|
@@ -250,7 +279,7 @@ Harness 的公开面 `InputActions` **故意不含**引用插入(`Command-styl
|
|
|
250
279
|
|
|
251
280
|
```sh
|
|
252
281
|
npm run typecheck # tsc:Host 半(Node)+ Client 半(DOM),erasableSyntaxOnly,兼容 Node 原生类型剥离
|
|
253
|
-
npm test # node --test
|
|
282
|
+
npm test # node --test 六个文件:
|
|
254
283
|
# test/unit.test.ts 参数校验、变量名推导、decision 映射、session/persistent 路由、
|
|
255
284
|
# 四类返回都不含值、锚点撤销、fork 不继承、压缩不误撤销(含真实表面折叠)、
|
|
256
285
|
# 子代理失败关闭、超时、O1(已答复但落库失败 ⇒ 降级 session 并如实回报)、
|
|
@@ -264,6 +293,11 @@ npm test # node --test 五个文件:
|
|
|
264
293
|
# 假过期四态(不可达/未列出/已提交)、表单控件齐备、明文不越出掩码输入
|
|
265
294
|
# test/attach.test.ts 附加方向的 Host 侧:暂存 ≠ 授权(未发送不注入)、TTL 丢弃、
|
|
266
295
|
# 容量上限、绑定锚点、release 只丢未绑定项、值不出现在任何视图里
|
|
296
|
+
# test/attach-wiring.test.ts 接线级回归(0.2.0 缺陷的门):用真实 dsh-session Session 扮演 loop 的
|
|
297
|
+
# 三步(pre-step waterfall → 原样 append decision.messages → 发布
|
|
298
|
+
# session/event),断言 durable 正文逐字保留 `@DSH_SECRET_*`、
|
|
299
|
+
# anchorSeq = 该事件 seq、contributor 已声明、暂存项已消费、
|
|
300
|
+
# 真 shellEnv.collect() 能取到值;注记逐字写明模型侧记法
|
|
267
301
|
# test/client-attach.test.ts 用真 cordis Context + sibling provide 装载浏览器产物:apply 在
|
|
268
302
|
# **缺少三个可选服务**时也不抛(boot 回归门)、注册面、插入阶梯
|
|
269
303
|
# (L1 chip / L3 文本 / 无 sessions 时降级)、明文不越出掩码输入
|
|
@@ -273,7 +307,7 @@ npm run build # 产出 lib/(Host 半 + 浏览器产物 ./client)
|
|
|
273
307
|
## 设计要点
|
|
274
308
|
|
|
275
309
|
- **附加方向的两段式生命周期**:值先在客户端本地 state,再经一次 POST 进 Host 内存的暂存表;只有携带标记的用户消息成为 durable 事件时才提升为 grant,`anchorSeq` 取该事件的 seq。这样"未发送就永不注入",且锚点不依赖任何"回头扫日志"的启发式。
|
|
276
|
-
-
|
|
310
|
+
- **模型侧:不改写正文,改写与解释都由一条按 source 去重的值无关注记承担**:`agent/pre-step` 不再替换用户消息正文(它在真实接线下必然落盘,见上一条),而是追加一条只含变量名 / 作用域 / 取用写法 / **逐变量逐字的模型侧记法 `[secret DSH_SECRET_*]`** 的注记;同一注记在模型可见 surface 上只出现一次。因此刷新、重放与 fork 拿到的模型文本一致,且用户气泡始终渲染为变量名胶囊。
|
|
277
311
|
- **可选服务一律走 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
312
|
- **`ctx.authorization` 只承载持久授权**:见「边界与已知限制」的同名条目——`session` 范围按定义不落盘,不可能满足该 seam 的提交契约,因此走同一套对话框而不进 seam。
|
|
279
313
|
- **Harness 不自带 `AuthorizationPrompt` 的 Web 渲染器**(已核验:安装包中没有任何 client 包渲染 `AuthorizationPrompt`),所以本插件自己的对话框就是该 prompt 的界面;流程内 `session.prompt({kind:'secret'})` 的值直接来自对话框已收集的答案。
|
|
@@ -330,6 +364,12 @@ npm stage approve <stage-id> # 需要 2FA
|
|
|
330
364
|
|
|
331
365
|
### GitHub Release 与签名
|
|
332
366
|
|
|
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
|
+
> 下表是**该版本确实按之执行**的机制,不是"将来会做"的计划。
|
|
372
|
+
|
|
333
373
|
| 环节 | 机制 |
|
|
334
374
|
| --- | --- |
|
|
335
375
|
| tag | **annotated 且 GPG 签名**的 tag(`git tag -s`),GitHub 上显示 **Verified** 徽标。tag 由维护者在本机用私钥创建并推送,**私钥永不进入 CI**。 |
|
package/lib/attach.d.ts
CHANGED
|
@@ -106,6 +106,16 @@ export declare function scopeLabel(scope: SecretScope): string;
|
|
|
106
106
|
* an edit-and-retry that rewrites the message away revokes the exposure without
|
|
107
107
|
* any bookkeeping here.
|
|
108
108
|
*
|
|
109
|
+
* Order matters. The `shellEnv` contributor is declared **before** the grant is
|
|
110
|
+
* recorded and the staged entry is consumed, so a registry failure leaves the
|
|
111
|
+
* session exactly as it was (no grant, nothing consumed, the staged entry still
|
|
112
|
+
* armed for a later attempt). The reverse order would leave a grant with no
|
|
113
|
+
* contributor and an unconsumed staged entry — an exposure that silently never
|
|
114
|
+
* injects. In the other direction a recorded grant whose contributor
|
|
115
|
+
* registration failed for an unrelated reason is harmless: the resolver asks
|
|
116
|
+
* `valueFor`, so a missing contributor injects nothing (fail-closed) while the
|
|
117
|
+
* staged entry stays consumed.
|
|
118
|
+
*
|
|
109
119
|
* @returns the bound variable, or undefined when nothing was staged for it.
|
|
110
120
|
*/
|
|
111
121
|
export declare function bindStaged(deps: AttachBinderDeps, session: GrantSessionLike, seq: number, envVar: string): BoundAttach | undefined;
|
|
@@ -129,8 +139,15 @@ export declare function seqOf(event: SessionEventLike): number | undefined;
|
|
|
129
139
|
/**
|
|
130
140
|
* The value-free note injected beside a message that carried attachments.
|
|
131
141
|
*
|
|
132
|
-
* It names the variables and how to read them, and it says out loud
|
|
133
|
-
*
|
|
142
|
+
* It names the variables and how to read them, and it says out loud the two
|
|
143
|
+
* things the surrounding prompt would otherwise get wrong: what the marker in
|
|
144
|
+
* the message body *is*, and that it is not a file path.
|
|
145
|
+
*
|
|
146
|
+
* The mapping line is the model-facing rewrite. The harness derives every model
|
|
147
|
+
* request from the durable log, so the message body keeps the marker form the
|
|
148
|
+
* human's client sent (which is also the form the conversation view projects to
|
|
149
|
+
* a variable-name capsule); the rewrite therefore rides this note, which states
|
|
150
|
+
* the correspondence per variable, literally.
|
|
134
151
|
*/
|
|
135
152
|
export declare function renderAttachNote(notes: readonly BoundVariable[]): string;
|
|
136
153
|
/** 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
|
*
|
|
@@ -105,6 +105,16 @@ export function scopeLabel(scope) {
|
|
|
105
105
|
* an edit-and-retry that rewrites the message away revokes the exposure without
|
|
106
106
|
* any bookkeeping here.
|
|
107
107
|
*
|
|
108
|
+
* Order matters. The `shellEnv` contributor is declared **before** the grant is
|
|
109
|
+
* recorded and the staged entry is consumed, so a registry failure leaves the
|
|
110
|
+
* session exactly as it was (no grant, nothing consumed, the staged entry still
|
|
111
|
+
* armed for a later attempt). The reverse order would leave a grant with no
|
|
112
|
+
* contributor and an unconsumed staged entry — an exposure that silently never
|
|
113
|
+
* injects. In the other direction a recorded grant whose contributor
|
|
114
|
+
* registration failed for an unrelated reason is harmless: the resolver asks
|
|
115
|
+
* `valueFor`, so a missing contributor injects nothing (fail-closed) while the
|
|
116
|
+
* staged entry stays consumed.
|
|
117
|
+
*
|
|
108
118
|
* @returns the bound variable, or undefined when nothing was staged for it.
|
|
109
119
|
*/
|
|
110
120
|
export function bindStaged(deps, session, seq, envVar) {
|
|
@@ -112,6 +122,7 @@ export function bindStaged(deps, session, seq, envVar) {
|
|
|
112
122
|
const staged = deps.store.get(sessionId, envVar);
|
|
113
123
|
if (staged === undefined)
|
|
114
124
|
return undefined;
|
|
125
|
+
deps.envs.ensure(staged.envVar);
|
|
115
126
|
deps.grants.put({
|
|
116
127
|
sessionId,
|
|
117
128
|
name: staged.name,
|
|
@@ -123,7 +134,6 @@ export function bindStaged(deps, session, seq, envVar) {
|
|
|
123
134
|
replaceGenerationAtApproval: session.surface.replaceGeneration,
|
|
124
135
|
authorizedAt: deps.now(),
|
|
125
136
|
});
|
|
126
|
-
deps.envs.ensure(staged.envVar);
|
|
127
137
|
// Consuming the staged entry is what makes binding idempotent: whichever hook
|
|
128
138
|
// gets there first wins, and the other finds nothing to do.
|
|
129
139
|
deps.store.remove(sessionId, staged.envVar);
|
|
@@ -202,18 +212,27 @@ export function seqOf(event) {
|
|
|
202
212
|
/**
|
|
203
213
|
* The value-free note injected beside a message that carried attachments.
|
|
204
214
|
*
|
|
205
|
-
* It names the variables and how to read them, and it says out loud
|
|
206
|
-
*
|
|
215
|
+
* It names the variables and how to read them, and it says out loud the two
|
|
216
|
+
* things the surrounding prompt would otherwise get wrong: what the marker in
|
|
217
|
+
* the message body *is*, and that it is not a file path.
|
|
218
|
+
*
|
|
219
|
+
* The mapping line is the model-facing rewrite. The harness derives every model
|
|
220
|
+
* request from the durable log, so the message body keeps the marker form the
|
|
221
|
+
* human's client sent (which is also the form the conversation view projects to
|
|
222
|
+
* a variable-name capsule); the rewrite therefore rides this note, which states
|
|
223
|
+
* the correspondence per variable, literally.
|
|
207
224
|
*/
|
|
208
225
|
export function renderAttachNote(notes) {
|
|
209
226
|
if (notes.length === 0)
|
|
210
227
|
return '';
|
|
211
228
|
const heading = `本条消息附带 ${String(notes.length)} 个由人类主动提供的密钥;明文不进入对话,只能按变量名取用。`;
|
|
212
229
|
const bullets = notes.map((note) => `- ${note.variable} · ${scopeLabel(note.scope)}`);
|
|
230
|
+
const mappings = notes.map((note) => `正文里的 ${markerFor(note.variable)} 即该变量,模型侧写作 ${modelFormFor(note.variable)};它不是文件路径。`);
|
|
213
231
|
const reads = notes.map((note) => `PowerShell 用 $env:${note.variable},POSIX shell 用 "$${note.variable}"`).join(';');
|
|
214
232
|
return [
|
|
215
233
|
heading,
|
|
216
234
|
...bullets,
|
|
235
|
+
...mappings,
|
|
217
236
|
'',
|
|
218
237
|
`取用方式:${reads}。`,
|
|
219
238
|
'不要把该标记当作文件路径读取。',
|
package/lib/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { AttachStore } from "./attach.js";
|
|
|
3
3
|
import { assertConfig, Config } from "./config.js";
|
|
4
4
|
import { EnvContributorRegistry } from "./envs.js";
|
|
5
5
|
import { GrantStore } from "./grants.js";
|
|
6
|
-
import { installAttachBinding } from "./inject.js";
|
|
6
|
+
import { installAttachBinding, noteSourcesOn } from "./inject.js";
|
|
7
7
|
import { registerSecretRoutes } from "./routes.js";
|
|
8
8
|
import { SecretService } from "./service.js";
|
|
9
9
|
import { defineSecretRequestTool } from "./tool.js";
|
|
@@ -73,6 +73,9 @@ export function apply(ctx, config) {
|
|
|
73
73
|
envs,
|
|
74
74
|
now: () => Date.now(),
|
|
75
75
|
sessionOf: (agent) => sessionOf(ctx, agent),
|
|
76
|
+
// One note per attachment: the note itself is durable, so a step that
|
|
77
|
+
// re-admits the same markers must not stack another copy.
|
|
78
|
+
visibleNotes: (session) => noteSourcesOn(session),
|
|
76
79
|
onBound: (attach) => {
|
|
77
80
|
service.noteBound(attach);
|
|
78
81
|
},
|
package/lib/inject.d.ts
CHANGED
|
@@ -13,26 +13,69 @@ declare module '@deepseek-ai/dsh-llm' {
|
|
|
13
13
|
'secret-attach': SecretAttachSource;
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
|
-
/**
|
|
16
|
+
/** One session, as the note-dedupe pass reads it. */
|
|
17
|
+
export interface AttachNoteReader {
|
|
18
|
+
/** Model-visible surface event sequences, in order. */
|
|
19
|
+
readonly surface: {
|
|
20
|
+
readonly nodes: readonly number[];
|
|
21
|
+
};
|
|
22
|
+
/** One logged event by sequence, or undefined. */
|
|
23
|
+
eventAt(seq: number): {
|
|
24
|
+
readonly type?: unknown;
|
|
25
|
+
readonly data?: unknown;
|
|
26
|
+
} | undefined;
|
|
27
|
+
}
|
|
28
|
+
/** Everything the two hooks read from their host. */
|
|
17
29
|
export interface AttachBindingDeps extends AttachBinderDeps {
|
|
18
30
|
sessionOf(agent: unknown): GrantSessionLike | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* The attach notes already on this session's model-visible surface.
|
|
33
|
+
*
|
|
34
|
+
* Optional: without it the note is simply never deduplicated, which is safe
|
|
35
|
+
* (a duplicate note is value-free) but noisy.
|
|
36
|
+
*/
|
|
37
|
+
visibleNotes?(session: GrantSessionLike): readonly SecretAttachSource[];
|
|
19
38
|
}
|
|
39
|
+
/** Every attach note already present on one session's model-visible surface. */
|
|
40
|
+
export declare function noteSourcesOn(session: AttachNoteReader): readonly SecretAttachSource[];
|
|
20
41
|
/**
|
|
21
42
|
* Install the two hooks that turn "the human attached a secret" into "this
|
|
22
43
|
* session holds it, and this request says so".
|
|
23
44
|
*
|
|
24
|
-
* They are deliberately split, and deliberately independent of each other's
|
|
25
|
-
* order:
|
|
26
|
-
*
|
|
27
45
|
* 1. **Binding** rides `session/event`. The sequence number of the message is
|
|
28
46
|
* only knowable once the message is durable, and the session service never
|
|
29
47
|
* publishes seed events, so replaying or resuming a log can never re-bind an
|
|
30
48
|
* historical marker. The grant is anchored to that exact sequence, which is
|
|
31
49
|
* what makes an edit-and-retry revoke it without any bookkeeping here.
|
|
32
|
-
* 2. **Delivery** rides the `agent/pre-step` waterfall
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
50
|
+
* 2. **Delivery** rides the `agent/pre-step` waterfall: it appends one
|
|
51
|
+
* value-free note per admitted batch that carries markers.
|
|
52
|
+
*
|
|
53
|
+
* ## Why the user's own message is NOT rewritten here
|
|
54
|
+
*
|
|
55
|
+
* The admission path makes "rewrite the model-facing text without touching the
|
|
56
|
+
* durable log" impossible, so this plugin does not pretend otherwise:
|
|
57
|
+
*
|
|
58
|
+
* - `agent/pre-step` output *is* the durable record. The loop appends every
|
|
59
|
+
* returned message verbatim as `user/message` with `surfaceOp: 'append'`
|
|
60
|
+
* (`dsh-agent-loop/lib/index.js:1061`), and the model request for the same
|
|
61
|
+
* step is derived from that same surface (`:1262`). A rewrite here therefore
|
|
62
|
+
* lands in the log, and the log is what the conversation view renders.
|
|
63
|
+
* - The model input is by contract a pure function of the log: a loop-built
|
|
64
|
+
* request is deep-frozen and `llm/stream` listeners "read it, never rewrite
|
|
65
|
+
* it" (`dsh-llm/lib/types/index.d.ts:37-45`).
|
|
66
|
+
* - The one mechanism that *can* keep a model-only copy (a surface replacement
|
|
67
|
+
* or a message-projection event) has to be appended *after* its target, and
|
|
68
|
+
* there is no hook between the loop's append and its request build; appending
|
|
69
|
+
* the target ourselves first would move the human's message in front of the
|
|
70
|
+
* step's own `system/message` commit. Self-appended events would also have to
|
|
71
|
+
* carry a type the persisted-log reader accepts (`dsh-session-persistence/
|
|
72
|
+
* lib/index.js:184`), which an out-of-tree plugin cannot.
|
|
73
|
+
*
|
|
74
|
+
* So the marker stays in the log in the exact form the human's client sent and
|
|
75
|
+
* the conversation view parses (`@DSH_SECRET_*` — the shipped `projectUserText`
|
|
76
|
+
* projects it to a variable-name capsule), and the model-facing rewrite is
|
|
77
|
+
* delivered by the note, which states per variable that the marker *is* that
|
|
78
|
+
* variable and writes its model-side notation `[secret DSH_SECRET_*]`.
|
|
36
79
|
*
|
|
37
80
|
* @param ctx - the plugin's Host context.
|
|
38
81
|
* @param deps - the staged store, the grant store, and the session lookup.
|
package/lib/inject.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { attachSource, bindStaged, describeVariable, messageMarkers, renderAttachNote, seqOf, } from "./attach.js";
|
|
2
|
-
import { rewriteMarkers } from "./naming.js";
|
|
3
2
|
/** Freeze a value and everything reachable from it. */
|
|
4
3
|
function deepFreeze(value) {
|
|
5
4
|
if (typeof value !== 'object' || value === null || Object.isFrozen(value))
|
|
@@ -25,49 +24,81 @@ function userMessage(input) {
|
|
|
25
24
|
content: [{ type: 'text', text: input.text }],
|
|
26
25
|
}));
|
|
27
26
|
}
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
27
|
+
/** Every attach note already present on one session's model-visible surface. */
|
|
28
|
+
export function noteSourcesOn(session) {
|
|
29
|
+
const found = [];
|
|
30
|
+
for (const seq of session.surface.nodes) {
|
|
31
|
+
const event = session.eventAt(seq);
|
|
32
|
+
if (event === undefined || event.type !== 'user/message')
|
|
33
|
+
continue;
|
|
34
|
+
const source = event.data?.source;
|
|
35
|
+
if (typeof source !== 'object' || source === null)
|
|
36
|
+
continue;
|
|
37
|
+
if (source.kind !== 'secret-attach')
|
|
38
|
+
continue;
|
|
39
|
+
found.push(source);
|
|
40
|
+
}
|
|
41
|
+
return found;
|
|
42
|
+
}
|
|
43
|
+
/** Whether one attach note is already visible, compared by its value-free source. */
|
|
44
|
+
function noteVisible(deps, session, source) {
|
|
45
|
+
const visible = deps.visibleNotes?.(session);
|
|
46
|
+
if (visible === undefined)
|
|
47
|
+
return false;
|
|
48
|
+
const key = JSON.stringify(source);
|
|
49
|
+
for (const candidate of visible) {
|
|
50
|
+
if (candidate.kind !== 'secret-attach')
|
|
51
|
+
continue;
|
|
52
|
+
if (JSON.stringify(candidate) === key)
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
/** Whether one recorded payload is this plugin's own injected note. */
|
|
58
|
+
function isAttachNote(data) {
|
|
59
|
+
if (typeof data !== 'object' || data === null)
|
|
60
|
+
return false;
|
|
61
|
+
const source = data.source;
|
|
62
|
+
return typeof source === 'object' && source !== null && source.kind === 'secret-attach';
|
|
54
63
|
}
|
|
55
64
|
/**
|
|
56
65
|
* Install the two hooks that turn "the human attached a secret" into "this
|
|
57
66
|
* session holds it, and this request says so".
|
|
58
67
|
*
|
|
59
|
-
* They are deliberately split, and deliberately independent of each other's
|
|
60
|
-
* order:
|
|
61
|
-
*
|
|
62
68
|
* 1. **Binding** rides `session/event`. The sequence number of the message is
|
|
63
69
|
* only knowable once the message is durable, and the session service never
|
|
64
70
|
* publishes seed events, so replaying or resuming a log can never re-bind an
|
|
65
71
|
* historical marker. The grant is anchored to that exact sequence, which is
|
|
66
72
|
* what makes an edit-and-retry revoke it without any bookkeeping here.
|
|
67
|
-
* 2. **Delivery** rides the `agent/pre-step` waterfall
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
73
|
+
* 2. **Delivery** rides the `agent/pre-step` waterfall: it appends one
|
|
74
|
+
* value-free note per admitted batch that carries markers.
|
|
75
|
+
*
|
|
76
|
+
* ## Why the user's own message is NOT rewritten here
|
|
77
|
+
*
|
|
78
|
+
* The admission path makes "rewrite the model-facing text without touching the
|
|
79
|
+
* durable log" impossible, so this plugin does not pretend otherwise:
|
|
80
|
+
*
|
|
81
|
+
* - `agent/pre-step` output *is* the durable record. The loop appends every
|
|
82
|
+
* returned message verbatim as `user/message` with `surfaceOp: 'append'`
|
|
83
|
+
* (`dsh-agent-loop/lib/index.js:1061`), and the model request for the same
|
|
84
|
+
* step is derived from that same surface (`:1262`). A rewrite here therefore
|
|
85
|
+
* lands in the log, and the log is what the conversation view renders.
|
|
86
|
+
* - The model input is by contract a pure function of the log: a loop-built
|
|
87
|
+
* request is deep-frozen and `llm/stream` listeners "read it, never rewrite
|
|
88
|
+
* it" (`dsh-llm/lib/types/index.d.ts:37-45`).
|
|
89
|
+
* - The one mechanism that *can* keep a model-only copy (a surface replacement
|
|
90
|
+
* or a message-projection event) has to be appended *after* its target, and
|
|
91
|
+
* there is no hook between the loop's append and its request build; appending
|
|
92
|
+
* the target ourselves first would move the human's message in front of the
|
|
93
|
+
* step's own `system/message` commit. Self-appended events would also have to
|
|
94
|
+
* carry a type the persisted-log reader accepts (`dsh-session-persistence/
|
|
95
|
+
* lib/index.js:184`), which an out-of-tree plugin cannot.
|
|
96
|
+
*
|
|
97
|
+
* So the marker stays in the log in the exact form the human's client sent and
|
|
98
|
+
* the conversation view parses (`@DSH_SECRET_*` — the shipped `projectUserText`
|
|
99
|
+
* projects it to a variable-name capsule), and the model-facing rewrite is
|
|
100
|
+
* delivered by the note, which states per variable that the marker *is* that
|
|
101
|
+
* variable and writes its model-side notation `[secret DSH_SECRET_*]`.
|
|
71
102
|
*
|
|
72
103
|
* @param ctx - the plugin's Host context.
|
|
73
104
|
* @param deps - the staged store, the grant store, and the session lookup.
|
|
@@ -78,6 +109,10 @@ export function installAttachBinding(ctx, deps) {
|
|
|
78
109
|
const record = event;
|
|
79
110
|
if (record.type !== 'user/message')
|
|
80
111
|
return;
|
|
112
|
+
// Our own note *names* the marker in its prose, so it must never be read as
|
|
113
|
+
// a carrier: only a message the human's client submitted can bind.
|
|
114
|
+
if (isAttachNote(record.data))
|
|
115
|
+
return;
|
|
81
116
|
const seq = seqOf(record);
|
|
82
117
|
if (seq === undefined)
|
|
83
118
|
return;
|
|
@@ -85,42 +120,39 @@ export function installAttachBinding(ctx, deps) {
|
|
|
85
120
|
bindStaged(deps, session, seq, envVar);
|
|
86
121
|
}
|
|
87
122
|
});
|
|
88
|
-
// 2. Deliver:
|
|
123
|
+
// 2. Deliver: say what the markers in this batch are.
|
|
89
124
|
ctx.on('agent/pre-step', async (payload, next) => {
|
|
90
125
|
const decision = await next();
|
|
91
126
|
if (decision.kind !== 'enter')
|
|
92
127
|
return decision;
|
|
93
128
|
const session = deps.sessionOf(payload.agent);
|
|
94
|
-
|
|
129
|
+
if (session === undefined)
|
|
130
|
+
return decision;
|
|
95
131
|
const notes = [];
|
|
96
|
-
let changed = false;
|
|
97
132
|
for (const message of decision.messages) {
|
|
98
|
-
|
|
99
|
-
if (markers.length === 0) {
|
|
100
|
-
messages.push(message);
|
|
133
|
+
if (message.source.kind !== 'user')
|
|
101
134
|
continue;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const known = describeVariable(deps, session, envVar);
|
|
107
|
-
if (known !== undefined && !notes.some((note) => note.variable === known.variable)) {
|
|
108
|
-
notes.push(known);
|
|
109
|
-
}
|
|
135
|
+
for (const envVar of messageMarkers(message)) {
|
|
136
|
+
const known = describeVariable(deps, session, envVar);
|
|
137
|
+
if (known !== undefined && !notes.some((note) => note.variable === known.variable)) {
|
|
138
|
+
notes.push(known);
|
|
110
139
|
}
|
|
111
140
|
}
|
|
112
|
-
messages.push(rewriteMessage(message));
|
|
113
141
|
}
|
|
114
|
-
if (!changed)
|
|
115
|
-
return decision;
|
|
116
142
|
if (notes.length === 0)
|
|
117
|
-
return
|
|
143
|
+
return decision;
|
|
144
|
+
const source = attachSource(notes);
|
|
145
|
+
// The note is durable (see above), so a step that re-admits the same
|
|
146
|
+
// markers must not stack another copy: an identical note already on the
|
|
147
|
+
// model-visible surface is the same statement said twice.
|
|
148
|
+
if (noteVisible(deps, session, source))
|
|
149
|
+
return decision;
|
|
118
150
|
return {
|
|
119
151
|
...decision,
|
|
120
152
|
messages: [
|
|
121
|
-
...messages,
|
|
153
|
+
...decision.messages,
|
|
122
154
|
userMessage({
|
|
123
|
-
source
|
|
155
|
+
source,
|
|
124
156
|
text: renderAttachNote(notes),
|
|
125
157
|
}),
|
|
126
158
|
],
|
package/lib/naming.d.ts
CHANGED
|
@@ -39,13 +39,19 @@ export declare function modelFormFor(envVar: string): string;
|
|
|
39
39
|
/** Every distinct attached-secret marker in one text, in first-seen order. */
|
|
40
40
|
export declare function parseMarkers(text: string): readonly string[];
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
42
|
+
* Render every marker in one text in its model-facing form.
|
|
43
43
|
*
|
|
44
44
|
* A pure function of the text alone: it never consults whether this session
|
|
45
|
-
* still holds the variable
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
45
|
+
* still holds the variable, so the same text always yields the same model-side
|
|
46
|
+
* words, and no `@`-prefixed token survives into the rendered result to be
|
|
47
|
+
* mistaken for a file path.
|
|
48
|
+
*
|
|
49
|
+
* Note on where this runs: the durable `user/message` keeps the marker form
|
|
50
|
+
* (`@DSH_SECRET_*`) because the harness derives every model request from that
|
|
51
|
+
* log, so `src/inject.ts` does not rewrite the message body at admission — the
|
|
52
|
+
* value-free note states the mapping per variable instead
|
|
53
|
+
* ({@link renderAttachNote}). This function is the single owner of that
|
|
54
|
+
* notation and is what a transcript consumer (or the note) renders with.
|
|
49
55
|
*/
|
|
50
56
|
export declare function rewriteMarkers(text: string): string;
|
|
51
57
|
/**
|
package/lib/naming.js
CHANGED
|
@@ -90,13 +90,19 @@ export function parseMarkers(text) {
|
|
|
90
90
|
return found;
|
|
91
91
|
}
|
|
92
92
|
/**
|
|
93
|
-
*
|
|
93
|
+
* Render every marker in one text in its model-facing form.
|
|
94
94
|
*
|
|
95
95
|
* A pure function of the text alone: it never consults whether this session
|
|
96
|
-
* still holds the variable
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
96
|
+
* still holds the variable, so the same text always yields the same model-side
|
|
97
|
+
* words, and no `@`-prefixed token survives into the rendered result to be
|
|
98
|
+
* mistaken for a file path.
|
|
99
|
+
*
|
|
100
|
+
* Note on where this runs: the durable `user/message` keeps the marker form
|
|
101
|
+
* (`@DSH_SECRET_*`) because the harness derives every model request from that
|
|
102
|
+
* log, so `src/inject.ts` does not rewrite the message body at admission — the
|
|
103
|
+
* value-free note states the mapping per variable instead
|
|
104
|
+
* ({@link renderAttachNote}). This function is the single owner of that
|
|
105
|
+
* notation and is what a transcript consumer (or the note) renders with.
|
|
100
106
|
*/
|
|
101
107
|
export function rewriteMarkers(text) {
|
|
102
108
|
return text.replace(MARKER_PATTERN, (_whole, lead, envVar) => `${lead}${modelFormFor(envVar)}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xinvxueyuan/cordis-plugin-secret",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"scripts": {
|
|
53
53
|
"build": "tsc -p tsconfig.build.json && tsc -p tsconfig.client.json",
|
|
54
54
|
"prepublishOnly": "npm run build",
|
|
55
|
-
"test": "node --test test/unit.test.ts test/register.test.ts test/client-card.test.ts test/attach.test.ts test/client-attach.test.ts",
|
|
55
|
+
"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",
|
|
56
56
|
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json && tsc --noEmit -p tsconfig.test.json"
|
|
57
57
|
},
|
|
58
58
|
"repository": {
|
package/src/attach.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { GrantStore, GrantSessionLike } from './grants.ts'
|
|
2
|
-
import { parseMarkers } from './naming.ts'
|
|
2
|
+
import { markerFor, modelFormFor, parseMarkers } from './naming.ts'
|
|
3
3
|
import type { SecretScope } from './types.ts'
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -184,6 +184,16 @@ export function scopeLabel(scope: SecretScope): string {
|
|
|
184
184
|
* an edit-and-retry that rewrites the message away revokes the exposure without
|
|
185
185
|
* any bookkeeping here.
|
|
186
186
|
*
|
|
187
|
+
* Order matters. The `shellEnv` contributor is declared **before** the grant is
|
|
188
|
+
* recorded and the staged entry is consumed, so a registry failure leaves the
|
|
189
|
+
* session exactly as it was (no grant, nothing consumed, the staged entry still
|
|
190
|
+
* armed for a later attempt). The reverse order would leave a grant with no
|
|
191
|
+
* contributor and an unconsumed staged entry — an exposure that silently never
|
|
192
|
+
* injects. In the other direction a recorded grant whose contributor
|
|
193
|
+
* registration failed for an unrelated reason is harmless: the resolver asks
|
|
194
|
+
* `valueFor`, so a missing contributor injects nothing (fail-closed) while the
|
|
195
|
+
* staged entry stays consumed.
|
|
196
|
+
*
|
|
187
197
|
* @returns the bound variable, or undefined when nothing was staged for it.
|
|
188
198
|
*/
|
|
189
199
|
export function bindStaged(
|
|
@@ -195,6 +205,7 @@ export function bindStaged(
|
|
|
195
205
|
const sessionId = String(session.id)
|
|
196
206
|
const staged = deps.store.get(sessionId, envVar)
|
|
197
207
|
if (staged === undefined) return undefined
|
|
208
|
+
deps.envs.ensure(staged.envVar)
|
|
198
209
|
deps.grants.put({
|
|
199
210
|
sessionId,
|
|
200
211
|
name: staged.name,
|
|
@@ -206,7 +217,6 @@ export function bindStaged(
|
|
|
206
217
|
replaceGenerationAtApproval: session.surface.replaceGeneration,
|
|
207
218
|
authorizedAt: deps.now(),
|
|
208
219
|
})
|
|
209
|
-
deps.envs.ensure(staged.envVar)
|
|
210
220
|
// Consuming the staged entry is what makes binding idempotent: whichever hook
|
|
211
221
|
// gets there first wins, and the other finds nothing to do.
|
|
212
222
|
deps.store.remove(sessionId, staged.envVar)
|
|
@@ -296,17 +306,28 @@ export function seqOf(event: SessionEventLike): number | undefined {
|
|
|
296
306
|
/**
|
|
297
307
|
* The value-free note injected beside a message that carried attachments.
|
|
298
308
|
*
|
|
299
|
-
* It names the variables and how to read them, and it says out loud
|
|
300
|
-
*
|
|
309
|
+
* It names the variables and how to read them, and it says out loud the two
|
|
310
|
+
* things the surrounding prompt would otherwise get wrong: what the marker in
|
|
311
|
+
* the message body *is*, and that it is not a file path.
|
|
312
|
+
*
|
|
313
|
+
* The mapping line is the model-facing rewrite. The harness derives every model
|
|
314
|
+
* request from the durable log, so the message body keeps the marker form the
|
|
315
|
+
* human's client sent (which is also the form the conversation view projects to
|
|
316
|
+
* a variable-name capsule); the rewrite therefore rides this note, which states
|
|
317
|
+
* the correspondence per variable, literally.
|
|
301
318
|
*/
|
|
302
319
|
export function renderAttachNote(notes: readonly BoundVariable[]): string {
|
|
303
320
|
if (notes.length === 0) return ''
|
|
304
321
|
const heading = `本条消息附带 ${String(notes.length)} 个由人类主动提供的密钥;明文不进入对话,只能按变量名取用。`
|
|
305
322
|
const bullets = notes.map((note) => `- ${note.variable} · ${scopeLabel(note.scope)}`)
|
|
323
|
+
const mappings = notes.map(
|
|
324
|
+
(note) => `正文里的 ${markerFor(note.variable)} 即该变量,模型侧写作 ${modelFormFor(note.variable)};它不是文件路径。`,
|
|
325
|
+
)
|
|
306
326
|
const reads = notes.map((note) => `PowerShell 用 $env:${note.variable},POSIX shell 用 "$${note.variable}"`).join(';')
|
|
307
327
|
return [
|
|
308
328
|
heading,
|
|
309
329
|
...bullets,
|
|
330
|
+
...mappings,
|
|
310
331
|
'',
|
|
311
332
|
`取用方式:${reads}。`,
|
|
312
333
|
'不要把该标记当作文件路径读取。',
|
package/src/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { AttachStore } from './attach.ts'
|
|
|
11
11
|
import { assertConfig, Config, type SecretConfig } from './config.ts'
|
|
12
12
|
import { EnvContributorRegistry } from './envs.ts'
|
|
13
13
|
import { GrantStore } from './grants.ts'
|
|
14
|
-
import { installAttachBinding } from './inject.ts'
|
|
14
|
+
import { installAttachBinding, noteSourcesOn, type AttachNoteReader } from './inject.ts'
|
|
15
15
|
import { registerSecretRoutes } from './routes.ts'
|
|
16
16
|
import { SecretService } from './service.ts'
|
|
17
17
|
import { defineSecretRequestTool } from './tool.ts'
|
|
@@ -91,6 +91,9 @@ export function apply(ctx: Context, config: SecretConfig): void {
|
|
|
91
91
|
envs,
|
|
92
92
|
now: () => Date.now(),
|
|
93
93
|
sessionOf: (agent) => sessionOf(ctx, agent),
|
|
94
|
+
// One note per attachment: the note itself is durable, so a step that
|
|
95
|
+
// re-admits the same markers must not stack another copy.
|
|
96
|
+
visibleNotes: (session) => noteSourcesOn(session as unknown as AttachNoteReader),
|
|
94
97
|
onBound: (attach) => {
|
|
95
98
|
service.noteBound(attach)
|
|
96
99
|
},
|
package/src/inject.ts
CHANGED
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
type SessionEventLike,
|
|
14
14
|
} from './attach.ts'
|
|
15
15
|
import type { GrantSessionLike } from './grants.ts'
|
|
16
|
-
import { rewriteMarkers } from './naming.ts'
|
|
17
16
|
|
|
18
17
|
/**
|
|
19
18
|
* The value-free note this plugin injects is a first-class message source, so a
|
|
@@ -58,57 +57,101 @@ function userMessage(input: {
|
|
|
58
57
|
) as unknown as UserMessage
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
/**
|
|
60
|
+
/** One session, as the note-dedupe pass reads it. */
|
|
61
|
+
export interface AttachNoteReader {
|
|
62
|
+
/** Model-visible surface event sequences, in order. */
|
|
63
|
+
readonly surface: { readonly nodes: readonly number[] }
|
|
64
|
+
/** One logged event by sequence, or undefined. */
|
|
65
|
+
eventAt(seq: number): { readonly type?: unknown; readonly data?: unknown } | undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Everything the two hooks read from their host. */
|
|
62
69
|
export interface AttachBindingDeps extends AttachBinderDeps {
|
|
63
70
|
sessionOf(agent: unknown): GrantSessionLike | undefined
|
|
71
|
+
/**
|
|
72
|
+
* The attach notes already on this session's model-visible surface.
|
|
73
|
+
*
|
|
74
|
+
* Optional: without it the note is simply never deduplicated, which is safe
|
|
75
|
+
* (a duplicate note is value-free) but noisy.
|
|
76
|
+
*/
|
|
77
|
+
visibleNotes?(session: GrantSessionLike): readonly SecretAttachSource[]
|
|
64
78
|
}
|
|
65
79
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
80
|
+
/** Every attach note already present on one session's model-visible surface. */
|
|
81
|
+
export function noteSourcesOn(session: AttachNoteReader): readonly SecretAttachSource[] {
|
|
82
|
+
const found: SecretAttachSource[] = []
|
|
83
|
+
for (const seq of session.surface.nodes) {
|
|
84
|
+
const event = session.eventAt(seq)
|
|
85
|
+
if (event === undefined || event.type !== 'user/message') continue
|
|
86
|
+
const source = (event.data as { readonly source?: unknown } | undefined)?.source
|
|
87
|
+
if (typeof source !== 'object' || source === null) continue
|
|
88
|
+
if ((source as { readonly kind?: unknown }).kind !== 'secret-attach') continue
|
|
89
|
+
found.push(source as SecretAttachSource)
|
|
90
|
+
}
|
|
91
|
+
return found
|
|
71
92
|
}
|
|
72
93
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return
|
|
94
|
+
/** Whether one attach note is already visible, compared by its value-free source. */
|
|
95
|
+
function noteVisible(
|
|
96
|
+
deps: AttachBindingDeps,
|
|
97
|
+
session: GrantSessionLike,
|
|
98
|
+
source: SecretAttachSource,
|
|
99
|
+
): boolean {
|
|
100
|
+
const visible = deps.visibleNotes?.(session)
|
|
101
|
+
if (visible === undefined) return false
|
|
102
|
+
const key = JSON.stringify(source)
|
|
103
|
+
for (const candidate of visible) {
|
|
104
|
+
if (candidate.kind !== 'secret-attach') continue
|
|
105
|
+
if (JSON.stringify(candidate) === key) return true
|
|
106
|
+
}
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Whether one recorded payload is this plugin's own injected note. */
|
|
111
|
+
function isAttachNote(data: unknown): boolean {
|
|
112
|
+
if (typeof data !== 'object' || data === null) return false
|
|
113
|
+
const source = (data as { readonly source?: unknown }).source
|
|
114
|
+
return typeof source === 'object' && source !== null && (source as { readonly kind?: unknown }).kind === 'secret-attach'
|
|
94
115
|
}
|
|
95
116
|
|
|
96
117
|
/**
|
|
97
118
|
* Install the two hooks that turn "the human attached a secret" into "this
|
|
98
119
|
* session holds it, and this request says so".
|
|
99
120
|
*
|
|
100
|
-
* They are deliberately split, and deliberately independent of each other's
|
|
101
|
-
* order:
|
|
102
|
-
*
|
|
103
121
|
* 1. **Binding** rides `session/event`. The sequence number of the message is
|
|
104
122
|
* only knowable once the message is durable, and the session service never
|
|
105
123
|
* publishes seed events, so replaying or resuming a log can never re-bind an
|
|
106
124
|
* historical marker. The grant is anchored to that exact sequence, which is
|
|
107
125
|
* what makes an edit-and-retry revoke it without any bookkeeping here.
|
|
108
|
-
* 2. **Delivery** rides the `agent/pre-step` waterfall
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
126
|
+
* 2. **Delivery** rides the `agent/pre-step` waterfall: it appends one
|
|
127
|
+
* value-free note per admitted batch that carries markers.
|
|
128
|
+
*
|
|
129
|
+
* ## Why the user's own message is NOT rewritten here
|
|
130
|
+
*
|
|
131
|
+
* The admission path makes "rewrite the model-facing text without touching the
|
|
132
|
+
* durable log" impossible, so this plugin does not pretend otherwise:
|
|
133
|
+
*
|
|
134
|
+
* - `agent/pre-step` output *is* the durable record. The loop appends every
|
|
135
|
+
* returned message verbatim as `user/message` with `surfaceOp: 'append'`
|
|
136
|
+
* (`dsh-agent-loop/lib/index.js:1061`), and the model request for the same
|
|
137
|
+
* step is derived from that same surface (`:1262`). A rewrite here therefore
|
|
138
|
+
* lands in the log, and the log is what the conversation view renders.
|
|
139
|
+
* - The model input is by contract a pure function of the log: a loop-built
|
|
140
|
+
* request is deep-frozen and `llm/stream` listeners "read it, never rewrite
|
|
141
|
+
* it" (`dsh-llm/lib/types/index.d.ts:37-45`).
|
|
142
|
+
* - The one mechanism that *can* keep a model-only copy (a surface replacement
|
|
143
|
+
* or a message-projection event) has to be appended *after* its target, and
|
|
144
|
+
* there is no hook between the loop's append and its request build; appending
|
|
145
|
+
* the target ourselves first would move the human's message in front of the
|
|
146
|
+
* step's own `system/message` commit. Self-appended events would also have to
|
|
147
|
+
* carry a type the persisted-log reader accepts (`dsh-session-persistence/
|
|
148
|
+
* lib/index.js:184`), which an out-of-tree plugin cannot.
|
|
149
|
+
*
|
|
150
|
+
* So the marker stays in the log in the exact form the human's client sent and
|
|
151
|
+
* the conversation view parses (`@DSH_SECRET_*` — the shipped `projectUserText`
|
|
152
|
+
* projects it to a variable-name capsule), and the model-facing rewrite is
|
|
153
|
+
* delivered by the note, which states per variable that the marker *is* that
|
|
154
|
+
* variable and writes its model-side notation `[secret DSH_SECRET_*]`.
|
|
112
155
|
*
|
|
113
156
|
* @param ctx - the plugin's Host context.
|
|
114
157
|
* @param deps - the staged store, the grant store, and the session lookup.
|
|
@@ -118,6 +161,9 @@ export function installAttachBinding(ctx: Context, deps: AttachBindingDeps): voi
|
|
|
118
161
|
ctx.on('session/event', (session, event) => {
|
|
119
162
|
const record = event as SessionEventLike
|
|
120
163
|
if (record.type !== 'user/message') return
|
|
164
|
+
// Our own note *names* the marker in its prose, so it must never be read as
|
|
165
|
+
// a carrier: only a message the human's client submitted can bind.
|
|
166
|
+
if (isAttachNote(record.data)) return
|
|
121
167
|
const seq = seqOf(record)
|
|
122
168
|
if (seq === undefined) return
|
|
123
169
|
for (const envVar of messageMarkers(record.data)) {
|
|
@@ -125,39 +171,34 @@ export function installAttachBinding(ctx: Context, deps: AttachBindingDeps): voi
|
|
|
125
171
|
}
|
|
126
172
|
})
|
|
127
173
|
|
|
128
|
-
// 2. Deliver:
|
|
174
|
+
// 2. Deliver: say what the markers in this batch are.
|
|
129
175
|
ctx.on('agent/pre-step', async (payload, next) => {
|
|
130
176
|
const decision = await next()
|
|
131
177
|
if (decision.kind !== 'enter') return decision
|
|
132
178
|
const session = deps.sessionOf(payload.agent)
|
|
133
|
-
|
|
179
|
+
if (session === undefined) return decision
|
|
134
180
|
const notes: BoundVariable[] = []
|
|
135
|
-
let changed = false
|
|
136
181
|
for (const message of decision.messages) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
changed = true
|
|
143
|
-
if (session !== undefined) {
|
|
144
|
-
for (const envVar of markers) {
|
|
145
|
-
const known = describeVariable(deps, session, envVar)
|
|
146
|
-
if (known !== undefined && !notes.some((note) => note.variable === known.variable)) {
|
|
147
|
-
notes.push(known)
|
|
148
|
-
}
|
|
182
|
+
if (message.source.kind !== 'user') continue
|
|
183
|
+
for (const envVar of messageMarkers(message)) {
|
|
184
|
+
const known = describeVariable(deps, session, envVar)
|
|
185
|
+
if (known !== undefined && !notes.some((note) => note.variable === known.variable)) {
|
|
186
|
+
notes.push(known)
|
|
149
187
|
}
|
|
150
188
|
}
|
|
151
|
-
messages.push(rewriteMessage(message as unknown as MessageLike) as unknown as (typeof decision.messages)[number])
|
|
152
189
|
}
|
|
153
|
-
if (
|
|
154
|
-
|
|
190
|
+
if (notes.length === 0) return decision
|
|
191
|
+
const source = attachSource(notes)
|
|
192
|
+
// The note is durable (see above), so a step that re-admits the same
|
|
193
|
+
// markers must not stack another copy: an identical note already on the
|
|
194
|
+
// model-visible surface is the same statement said twice.
|
|
195
|
+
if (noteVisible(deps, session, source)) return decision
|
|
155
196
|
return {
|
|
156
197
|
...decision,
|
|
157
198
|
messages: [
|
|
158
|
-
...messages,
|
|
199
|
+
...decision.messages,
|
|
159
200
|
userMessage({
|
|
160
|
-
source
|
|
201
|
+
source,
|
|
161
202
|
text: renderAttachNote(notes),
|
|
162
203
|
}),
|
|
163
204
|
],
|
package/src/naming.ts
CHANGED
|
@@ -115,13 +115,19 @@ export function parseMarkers(text: string): readonly string[] {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
|
-
*
|
|
118
|
+
* Render every marker in one text in its model-facing form.
|
|
119
119
|
*
|
|
120
120
|
* A pure function of the text alone: it never consults whether this session
|
|
121
|
-
* still holds the variable
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
121
|
+
* still holds the variable, so the same text always yields the same model-side
|
|
122
|
+
* words, and no `@`-prefixed token survives into the rendered result to be
|
|
123
|
+
* mistaken for a file path.
|
|
124
|
+
*
|
|
125
|
+
* Note on where this runs: the durable `user/message` keeps the marker form
|
|
126
|
+
* (`@DSH_SECRET_*`) because the harness derives every model request from that
|
|
127
|
+
* log, so `src/inject.ts` does not rewrite the message body at admission — the
|
|
128
|
+
* value-free note states the mapping per variable instead
|
|
129
|
+
* ({@link renderAttachNote}). This function is the single owner of that
|
|
130
|
+
* notation and is what a transcript consumer (or the note) renders with.
|
|
125
131
|
*/
|
|
126
132
|
export function rewriteMarkers(text: string): string {
|
|
127
133
|
return text.replace(MARKER_PATTERN, (_whole, lead: string, envVar: string) => `${lead}${modelFormFor(envVar)}`)
|