cordis-plugin-secret
> Cordis(DeepSeek Harness)插件:密钥在人与 Agent 之间双向流动——Agent 可以开口索取,人类也可以把一枚密钥主动附加到自己的消息上——而 Agent 永远只拿到一个不透明的变量名(如 DSH_SECRET_OPENAI)。插件自身从不把值放进工具结果、错误消息、日志、DOM 或会话记录;值只经 ctx.shellEnv 按会话注入到 shell 环境——由 Agent 自己避免回显。
- Host 半(索取方向):注册
secret_request工具;用ctx.authorization的凭据获取流程承载持久授权;用ctx.credentials落库;用ctx.shellEnv按会话注入DSH_SECRET_*。 - Host 半(附加方向):
POST /api/secret.attach把人类填的值暂存在进程内存(暂存不等于授权:此时不注入任何变量);携带标记@DSH_SECRET_*的用户消息一旦落进会话日志,session/event就把它提升为按该条消息锚定的授权;agent/pre-step追加一条只有变量名的说明消息,其中逐变量写明这枚标记在模型侧写作[secret DSH_SECRET_*](正文本身不改写——harness 会让改写落盘,见下文)。 - Client 半(索取方向):在 Agent 输出流里渲染会话流内一级卡片(
conversation.chat.node,key=secret-request),无shell.overlay遮罩;卡片带type="password"输入与显示/隐藏切换,把「同意 / 拒绝 / 忽略 / 其他」四个决定、申请理由、用途说明与授权范围摆在人类眼前,并允许人类改写 Agent 请求的范围。 - Client 半(附加方向):在输入区
conversation.input.left(紧随「访问模式 / 计划」控件组右侧)加一个切换式按钮;按下后在输入框上方(conversation.input.overlay)浮起填值胶囊;填完点「插入到光标处」,草稿光标处得到真正的内联 chip(data-composer-chip="secret",只显示变量名),可在后续 shell 取用;点击该 chip(或刷新后由 lexicon 装饰出的同名引用)会在同一浮层展开只读详情胶囊。0.3.0 起,消息旁的旁挂胶囊与(被接管后的)原胶囊都能打开详情——前者开输入框上方的信息框、后者开右侧栏详情页;信息框内另有历史记录区,@菜单会列出可用密钥(详见「人类主动附加密钥(反方向)→ 0.3.0 的四项增强」)。 - 管理方向(0.4.0):注册第二个工具
secret_manage(列举 / 解绑 / 真删 / 改作用域 / 请人类改值)与/api/secret.manage(GET 列举、POST 执行);参数集里没有value,Agent 没有任何提交值的通道——值只能由人类在掩码输入框里键入。人类侧信息框新增「管理 / 改值 / 危险确认」三个面(详见「秘密的完整 CRUD(0.4.0)」)。 - 传输:两个方向的对话框都经本插件自有的、位于
ctx.connection信任栅栏内的/api路由与 Host 通信。密钥值只出现在POST /api/secret.answer、POST /api/secret.attach与(仅action:"value"时)POST /api/secret.manage的请求体里,从不进入 URL / 查询串 / 会话日志 / 响应体。
安全不变量与副作用披露
安全不变量(实现并测试)
- 插件自身的输出永不携带明文:工具结果、错误消息、日志、事件、渲染文本、HTTP 响应体与 DOM 属性中都不含密钥值;
render()只输出变量名与元数据。单测对四种 decision 的所有字段做全量字符串扫描,断言值不出现;Client 半另有断言证明填值胶囊的值不越出那个掩码输入框。边界:值确实会按会话注入到 shell 环境(这是本插件的功能),因此"明文不进上下文"取决于 Agent 不回显$env:DSH_SECRET_*,而不是插件的输出通道。 - Agent 只拿到变量名:
approved返回{ decision, variable, scope, ref, source },variable形如DSH_SECRET_OPENAI。 - 值只发给本机 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也只回传值无关字段。0.4.0 的GET /api/secret.manage只回传变量名与元数据(无值字段),POST /api/secret.manage只在action:"value"时携带人类在掩码框里键入的值——它由人类提交,不经模型。 - 会话级密钥不落盘:
scope: "session"的值只存在于进程内存(Host 的暂存表与会话授权表)。落盘只走凭据服务,且只发生在persistent。 - 持久化只经凭据服务:
persistent经ctx.credentials.set(<变量名>, value)写入凭据引用空间(provider 管理的可写源);同时向记录空间提交一条不含密钥材料的标记记录(索取方向kind: "grant",附加方向kind: "attachment",payload 只有envVar/name/scope/authorizedAt)。绝不写自建文件,绝不在仓库里存明文。 - 会话边界失败关闭(见「边界处理」):锚点离开会话表面即撤销并不再注入。
- 人类输入密钥的控件既不可自动填充,也不可被密码管理器捕获(用户实机报出的缺陷的修复):现象:真实浏览器会在这四个密钥输入面上给出自动填充建议,已装的密码管理器扩展也会捕获它们。根因:修复前这四个字段只有
autoComplete:'off'(现代浏览器对登录类凭据字段基本忽略这个值)、没有任何厂商忽略标记、也没有别的退出开关,而掩码用的又是原生type="password",浏览器正是据此把它当作密码域。修复:本插件渲染的文本/密码输入都带一套抑制属性——autocomplete="new-password"、autocorrect="off"、autocapitalize="off"、spellcheck=false,以及厂商忽略标记data-1p-ignore(1Password 官方文档逐字给出)、data-lpignore(LastPass:只有其支持页的标题级引用——本环境抓取失败、其公开仓库 0 命中,如实标注为证据最弱的一条)、data-bwignore(Bitwarden:opt-in,只有用户在该扩展的设置里打开对应选项后才生效)、data-form-type="other"(Dashlane)、data-protonpass-ignore(Proton Pass:依据是厂商源码——WebClients里PASSWORD_MANAGER_IGNORE_PROPS收录的正是这同一套属性,见packages/pass/…/Field.tsx与applications/wallet/…/attribute.ts)。按面分层(不是"每一个 input 都一样"):4 个能持密钥明文的字段(索要卡片的值输入、附加胶囊的密钥内容、管理面「改值」、管理确认卡片的「新密钥内容」)带齐 9 键袋且另加只读直到聚焦的守卫——字段先以readOnly渲染(自动填充只在加载时预填,而只读字段不在候选里),聚焦时由onFocus释放;2 个标识符输入(凭据键、标题)带autocomplete="off"的同一套袋且同样带只读直到聚焦的守卫,并且自 0.4.3 起不再带id/name、改用隐式 label 关联(包住可见标题与控件,见下)。**依据要分清归属(t21 纠偏,勿混读)**:真正有文档支撑的是 **(a) 浏览器侧的键控行为**——Chrome 团队 web.dev《Learn Forms: Autofill》逐字写明浏览器**按 `name`(某些浏览器还看 `id`)记录与回填字段**,并对"值每次都不同、不可能复用"的字段(该页给的例子是一次性验证码,凭据键与标题同属此类)**推荐 `autocomplete="off"`**;同页还写明 `off` 对**密码字段无效**("即使使用 `autocomplete="off"`,浏览器仍会提供密码自动填充选项"),这正是那四个密钥字段必须留着 `new-password` 而不能改用 `off` 的原因,而 `new-password` 用在纯文本字段上只会招来"生成强密码"的建议;以及 **(b) 五个厂商忽略标记**(1Password 官方文档逐字给出 `data-1p-ignore`;Bitwarden / Dashlane / Proton Pass 是**厂商自家源码**;LastPass 只到支持页标题级)。**而"去掉 `id`/`name`"是本次的设计选择,不是厂商文档的推荐做法**:1Password 官方文档的原意恰好相反——它建议给字段加**唯一的 `id`/`name`**,好让 1Password 能识别该字段;我们只在这两个**不持密钥**的标识符字段上反向操作,唯一目的是让浏览器不再把历史值当作可复用项、从而不再弹下拉,代价是放弃显式关联(见下面的可访问性口径)。不要把这一条读成"厂商推荐去 id";**1 个不持值的掩码展示**(`disabled`)也带袋、不带守卫;**自由文本 `textarea`** 只带 4 键卫生子集(`autocomplete`/`autocorrect`/`autocapitalize`/`spellcheck`);**2 个 scope radio** 不持值、不带袋、**且带 `name`**。所有文本/密码输入都没有 `name`,整个 UI **没有、没有 submit 按钮(每个按钮都是type="button")。可访问性口径(0.4.3,如实记录取舍):两个标识符字段改成隐式关联后,MDN 把嵌套与for/id记为"等价",但同时提醒"并非所有辅助技术都实现隐式关联",并一般推荐用显式for;因此这两个控件同时带与可见标题逐字相同的aria-label,把可访问名固定成显式提供的那一份(可见文本与可访问名一致,满足 Label-in-Name),点击标题仍会聚焦控件(嵌套 label 的原生行为)。代价如实写明:这两个字段失去了"显式关联"这一 MDN 推荐形态,换来的是去掉浏览器与扩展据以识别字段的稳定id;tabIndex未改、字段仍可聚焦、可用性未牺牲其它属性(正对照与机械断言见测试)。四个密钥字段保留显式for/id:它们靠new-password+ 厂商忽略标记 + 守卫关闭浏览器侧凭据路径,而 MDN 推荐的显式关联在这里更值得留。掩码仍用type="password":替代方案-webkit-text-security是非标准属性(MDN 明写"不建议在生产使用、浏览器支持有限"),一旦被忽略就会把密钥显示成明文,比自动填充更糟。机械断言:两个 Client 测试逐一渲染这些面并断言上述属性与守卫,修复前的形态下它们全部失败(正对照见测试注释)。需真人**:具体浏览器是否还弹自动填充建议/是否还提示保存密码、各管理器扩展是否仍捕获、以及只读守卫的代价(见下一条与「已知限制」),只能在真机浏览器里确认。 - 明文允许存在的全部位置(穷举,仅此六处):
- P1 填值胶囊 / 管理面「改值」面的本地输入 state(掩码输入框);
- P2 一次POST /api/secret.attach(附加方向)或一次action:"value"的POST /api/secret.manage(改值方向)的请求体;
- P3 Host 进程内存里的暂存记录(已登记但尚未随消息发送);
- P4 Host 进程内存里的GrantStore记录(已绑定到某条消息);
- P5 仅当人类显式选择「持久」时写入的凭据库;
- P6shellEnv在执行期把值注入子进程的环境变量。
明文绝不允许出现:模型上下文(对话文本)、持久会话日志(任何事件字段)、工具结果、DOM 属性与可见文本、未发送草稿的持久化投影、URL/查询串、HTTP 响应体、异常与错误消息(一律固定文案,不复述上游)、console、以及除 P5 之外的任何落盘文件。
这条穷举是刻意的:Host 必须持有值才能在后续 shell 里注入(否则"随消息取用"不可能成立),因此本插件不声称"值不进入内存"——只声称上面这张表,并且用单测钉住它。
副作用披露(按真实实现逐条列出)
- 会按会话把明文注入子进程环境:
ctx.shellEnv.register为每个变量名声明一个 contributor,每次 shell 执行都重新校验该执行的会话是否仍持有有效授权,有效才注入。这是插件功能本身,也是明文唯一离开本进程的出口——注入给子进程意味着该子进程写下的任何输出都可能带上它,是否回显由 Agent 负责。 - 会在 Host 进程内存里持有明文:人类填的值先落在暂存表(
attachTtlMs到期即丢、每会话maxAttachmentsPerSession条上限),随消息绑定后进会话授权表;两者都在内存。进程退出即消失。 - 可能写凭据库(仅当人类显式选「持久」):经
ctx.credentials.set(<变量名>, value)写入凭据引用空间,并追加一条不含密钥材料的标记记录;session范围不落盘。 - 模型侧的改写由注记承担,且注记会落盘(按 source 去重):
agent/pre-step不改用户消息正文(harness 会让改写落盘,见「人类主动附加密钥(反方向)→ 模型侧到底看到什么」),而是追加一条只含变量名的注记,其中逐变量逐字写出「正文里的@DSH_SECRET_*即该变量,模型侧写作[secret DSH_SECRET_*];它不是文件路径」。注记是 durable 的user/message(因此在对话流里是一行注入说明),且按自身 source 去重:模型可见 surface 上已有同一条就不再追加,重复引入同一标记不会堆叠。 - 会在会话日志里留下变量名标记:人类发送的消息本身(含
@DSH_SECRET_OPENAI这种变量名)作为普通user/message事件持久化。变量名不是密钥材料,但它会长期留在日志里。 - 会挂 10 条
/api路由(9 个精确路径:/api/secret.manage一条路径两种方法,单次注册、按request.method分派——真实注册表按精确路径建键、与methods无关,所以同一条路径注册两次会在装配时抛exact Fetch route ... is already registered并让整个插件条目不激活;0.4.0 正是这样在实机上不激活,0.4.1 修掉):/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,把凭据库里的持久记录登记到本会话),以及 0.4.0 新增的manage(GET 管理面列表 / POST 执行一个管理动作)。全部位于ctx.connection的信任栅栏内(本机 / 可信 Host、同源标记、签名浏览器 Cookie)。值只出现在attach、answer与(仅action:"value"时)manage的请求体里;其余路由都不回传值,adopt的取值也由宿主自己经credentials.resolve完成,值不跨线。 - 会注册客户端座位与一个引用源:
conversation.chat.node(keysecret-request)、tool.call.toolview(keysecret_request的null占位)、conversation.input.left(idsecret-attach-toggle)、conversation.input.overlay(idsecret-attach-capsule),外加一个名为secret的InputTriggerSource与 locale 命名空间secretAttach。0.3.0 起再增加三处增量注册:conversation.chat.node(keysr-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只替换本插件自己那次调用的泛型工具行,不触碰别的工具;其余都是增量座位。0.4.0 再增加两处增量注册:conversation.chat.node(keysr-manage,Agent 侧的管理确认卡)与tool.call.toolview(keysecret_manage的null占位);同样只影响本插件自己那次调用,且管理面的三个新面都在既有胶囊组件里,不新增座位、不新增引用源,dsh.client.inject也不变。0.5.0 再增加一处增量注册:conversation.input.right(idsecret-paste-composer,输入区的「粘贴」动作,见「0.5.0 的四项」第 2 条);仍然只增座位、不新增引用源,dsh.client.inject不变。0.6.0 该座位的用途变更:座位保留,但按用户裁定删掉了里面的「粘贴」按钮,改为 t46 粘贴接管提示的渲染位(见「0.6.0 的修复与需求变更」第 4、5 条);座位名与 id 未再变,dsh.client.inject仍不变。 - 不做的事:插件自身不 spawn 子进程、不读写仓库文件、不发起网络请求(除被 Harness 自己的 API 通道承载的上面列出的 10 条同源路由外),也没有任何遥测。
安装
# 在已加载的 Harness 中(危险全权限)
plugin_manager action=install_bundle target=<包名 | 本包绝对路径>
安装做两件事:
- 用 pnpm 把本包写进当前 profile 的依赖(本地目录会成为
link:<绝对路径>),并把包名追加到 profilepackage.json的dsh.profile.bundles; - 从已安装包内的
dsh.bundle.patch(即本包的cordis.patch.yml)加载 patch 层,Host 端热加载。
profile 的组合树是「root 空清单 → dsh.profile.bundles 里每个 bundle 的 patch → profile 的 cordis.patch.yml → --patch 覆盖」逐层拼出来的,所以 profile 的 cordis.patch.yml 里看不到 secret 行是正常的——那一行来自本包。本包的 cordis.patch.yml 是:
- insert:
- id: secret
name: '@xinvxueyuan/cordis-plugin-secret'
inject: [tools, authorization, credentials, shellEnv, sessions, agents, connection]
config:
requestTimeoutMs: 300000
maxPendingRequests: 4
(attachTtlMs、maxAttachmentsPerSession、maxHistoryPerSession、maxAvailableEntries 未在这里显式写出,取下面配置表的默认值。)
安装结果的 application 为 applied 表示本次变更已生效;warnings 会说明 Client 半是否需要刷新页面。
注意:对同一个 link: 依赖重复按绝对路径安装是 no-op,此时安装器报 changed: false + application: "failed" + error.code: "ambiguous-install"——它按 profile 依赖的 diff 归属安装目标,而路径 spec 与已存在的同名依赖对不上。这不代表插件没生效(该次调用没有改动任何文件);用 cordis_inspect_query 核对更可靠:host Tool/listTools 应出现 secret_request,host Config/listConfigs 应出现 include:secret,client Slots/listSubTree {root:"conversation.chat.node"} 的 occupants 应出现 secret-request(若同时看 {root:"shell.overlay"},判据是那里不应再有 secret.request.dialog)。
来源构建:npm install && npm run build(lib/ 是 loader 与浏览器实际加载的产物;src/ 为 TypeScript 源码)。
组合节点(官方)
Host 半声明 7 个硬依赖。Loader 会让这一行等待这些服务:缺任何一个时插件保持未 apply(不抛错),在该 profile 里表现为「不出现」而不是「出现但坏了」。
| 服务键 | 官方提供方(row / 包) | 必需性 | 用途 |
|---|---|---|---|
tools |
tools / @deepseek-ai/dsh-tools |
硬 | 注册 secret_request 与 secret_manage 两个工具。 |
authorization |
authorization / @deepseek-ai/dsh-authorization |
硬 | 用官方的凭据获取流程承载持久授权(每个 key 一次 attempt)。 |
credentials |
credentials / @deepseek-ai/dsh-credentials-local |
硬 | persistent 范围的落库与回读;记录空间里那条不含密钥材料的标记也写在这里。 |
shellEnv |
shell-env / @deepseek-ai/dsh-shell-env |
硬 | 为每个变量名声明 contributor,按会话、按执行解析 DSH_SECRET_*。 |
sessions |
session / @deepseek-ai/dsh-session |
硬 | ctx.sessions.get(id) 把授权锚定到活会话,并校验锚点仍在会话表面上。 |
agents |
agent / @deepseek-ai/dsh-agent |
硬 | ctx.agents.get/roots 把调用归到发起它的 Agent(live root / delegated / 失效 id)。 |
connection |
connection / @deepseek-ai/dsh-client-connection(dsh-web-app 层) |
硬 | 在官方信任栅栏内挂 /api/secret.* 路由(本机 / 可信 Host、同源、签名 Cookie)。 |
llm |
llm / @deepseek-ai/dsh-llm |
可选 | 附加密钥时让模型建议一个变量键名;缺 llm 时插件仍激活、附加仍成功,只是退回本地规则命名。 |
llm 故意不进 inject:它经 ctx.get('llm') 可选获取(src/index.ts 的注释写明了这一点),把它写进硬依赖会让没有 llm 的 profile 直接失去整个插件。
一致性约束:patch 的 inject: 与 src/index.ts 的 export const inject 是同一份依赖名单的两个声明处——前者决定 Loader 等哪些服务才激活该行,后者决定插件自己何时 apply。两处必须集合相等,改一处而漏另一处会造成「行已激活但插件不 apply」或反之。test/manifest.test.ts 从这两个文件各自的文本里提取列表并比对,不硬编码名单;它随 npm test 一起跑。
配置(Config)
| 字段 | 默认 | 说明 |
|---|---|---|
requestTimeoutMs |
300000 |
单个 secret_request 等待人工确认的上限(也是工具 timeoutMs 的基础,工具自身总在上层超时前返回)。 |
maxPendingRequests |
4 |
同时等待人工确认的授权请求数上限,超出返回 TOO_MANY_PENDING。 |
attachTtlMs |
1800000 |
人类已填入、但从未随消息发送的附加项在内存里保留多久(30 分钟)后被丢弃。暂存从不等于授权:等待期间不注入任何变量,这个上界正是"值不会在长命进程里无限期滞留"的保证。 |
maxAttachmentsPerSession |
8 |
单个会话同时可持有的已登记附加项上限。 |
maxHistoryPerSession |
32 |
单个会话保留的历史记录条数上限(内存态,超出丢最旧)。 |
maxAvailableEntries |
32 |
@ 菜单里「凭据库(持久)」一类最多列出的条目数(每项都要回读一次记录,因此有上限)。 |
六个键都必须是正整数:schema 的 .default() 之外,assertConfig 再手工兜一层(非正整数直接抛错)。
工具
secret_request
| 参数 | 必填 | 说明 |
|---|---|---|
name |
✅ | 凭据键:小写 kebab/snake(^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$),如 openai、openai-key。决定默认变量名 DSH_SECRET_。 |
label |
✅ | 对话框中的人类可读标题。 |
reason |
✅ | 你需要它的原因,原样展示给要授权的人,也是对方同意的那份理由。 |
scope |
✅ | session 或 persistent。Agent 显式选择;对话框会突出显示,人类可改。 |
description |
❌ | 补充说明,展示在对话框。 |
envVar |
❌ | 覆盖变量名,必须形如 DSH_SECRET_OPENAI,不得占用 DSH_HOME/DSH_SHELL/DSH_SESSION_ID/DSH_PROFILE/DSH_PROFILE_DIR。 |
返回(JSON;decision 四选一):
| decision | 形状 |
|---|---|
approved |
{ decision:'approved', variable:'DSH_SECRET_X', scope, ref:{space,name}, expiresAt?, source:'store'\|'entered', notice? } |
rejected |
{ decision:'rejected', reason? } —— 人类拒绝:立即停止,不得重试。 |
ignored |
{ decision:'ignored' } —— 本次未授权:可稍后再问。 |
other |
{ decision:'other', text } —— 人类给了自由文本指示:按文本执行。 |
ref.space 说明该名字在哪儿可用:credential-ref = 可用 ctx.credentials.resolve(<变量名>) 解析(persistent);session-shell-env = 仅通过 DSH_* 注入(session)。
source = store(凭据库里已有的值)或 entered(本次人工输入)。notice 是附加诊断(例如"上一次授权所锚定的事件已不在当前会话表面上"),永不含密钥。
失败(抛错,作为工具错误结果返回,同样不含密钥):BAD_REQUEST、CALLER_NOT_LIVE、DELEGATED_CALLER、NO_SESSION、NO_ANCHOR、TOO_MANY_PENDING、TIMEOUT、AUTHORIZATION_FAILED、AUTHORIZATION_CANCELLED、STORE_READ_FAILED、STORE_EMPTY。
其中两条会把上游细节收敛掉、只报固定的本插件文案(凭据后端不是本插件能控制的组件,它的错误文本可能引用路径、引用名或其他敏感材料,因此一律不透传):STORE_READ_FAILED(凭据库读取 describe/resolve 失败 →「凭据库读取失败;细节已省略」)与 AUTHORIZATION_FAILED(授权流程失败 →「凭据授权流程失败(上游细节已省略)」)。AUTHORIZATION_FAILED 仍会带上本插件自己的二次脱敏层(把该次对话已收集到的值替换掉),措辞本身从不被当作防泄漏的唯一手段。
授权卡片(Client 半)
一次 secret_request 调用在会话流里只呈现一个交互面:它自己的会话流内卡片,渲染在 Agent 的输出流中(conversation.chat.node,key = secret-request,由本插件的 ConversationNodeDefinition 在 tool/call 事件上生成节点)。不再有全视口遮罩:卡片是普通文档流里的带边框卡片,不 position: fixed、不 inset: 0、不铺满视口、不劫持整屏 pointer events,也不占用输入区。
卡片在每个"工作步骤展示"档位(简洁 / 标准 / 详细 / 完全展开)都可见:节点不带 Turn/Step 坐标(location = { kind: 'session' }),因此 Harness 会把它作为流的根条目输出,既不会落进「已调用工具」步骤进程分组,也不会被该分组在任何档位下折叠或隐藏。同一 key 上注册的 tool.call.toolview 占位组件返回 null,用来替换该工具的泛型工具行,避免同一次调用出现两个面。
(附带说明:步骤进程分组的标题仍会把这次调用计入工具计数,并可能显示参数里的 description/name 文本——这是 Harness 对所有工具共有的既有行为,不是第二个交互面,也不含明文值。)
卡片内容,自上而下(与请求态一致;结算后折叠为摘要,可展开只读回看):
- 标题(
label)+ 右侧状态:「准备中… / 等待你的决定 / 连接宿主失败,重试中… / 已提交… / 已授权 · 变量名 · 范围 / 已拒绝 / 已忽略 / 其他指示 / 出错 · 错误码」,以及「展开 / 收起」; - 用途(
reason,原样展示,左侧竖线引用块)+description(如有); - 保存方式:
持久保存到凭据库/仅本次会话有效两个单选(各带一句含义说明),并用加粗文字写明"Agent 请求的范围:"与"当前选择:";若人类改动范围,追加一行"你已把范围改为:…"; - 密钥值输入(
type="password"、可切「显示 / 隐藏」);若该凭据已存在于凭据库则不显示输入框,只显示"已配置,本次仅需决定是否授权本次使用"; - 四个决定:同意 / 拒绝 / 忽略 / 其他(
其他切换出自由文本域 + 提交指示 + 返回);按钮带 title 说明拒绝与忽略的后果; - 页脚说明:密钥明文不会交给模型,代理只会拿到变量名(并列出变量名)。
假过期缺陷已修复(客户端):卡片只把成功且可解析的 pending 响应当作事实来源。网络失败、非 2xx、响应体无法解析一律记为"暂时无法连接宿主,正在重试"——表单保持可见,按 1.2s→8s 退避重试,绝不判定为已失效、绝不关窗。只有"HTTP 成功且本次调用的 callId 确实不在等待列表中"才收束,且已提交后永不判过期(转入"已提交,等待 Agent 继续…",等待工具结果)。未提交时也只降级为卡片内的只读提示,卡片本身保留到这次调用结束。提交得到 HTTP 409 表示宿主已不再等待这份请求,同样只在卡片内说明,不再重复提交。
兜底(卡片构建失败 / 状态缺失时请求仍可见且可回答):
- 调用参数无法解析:仍然生成节点,卡片用 Host 的权威视图渲染完整表单;
- Host 暂不可达:表单可见,按钮禁用并就地提示,连接恢复即可提交;
- 结算载荷缺失(例如旧日志、嵌套调用):显示"已结束(未记录结算细节)",保留用途与范围,不回退为空白;
tool.call.toolview占位未生效:泛型工具行会重新出现(多一个面),卡片仍在且可答。
结算状态来自 tool/result.meta 里的值无关载荷(由工具的 output.presentationMeta 持久化,模型不可见),因此刷新页面/重放会话后同一张卡片可以从日志重建,不解析任何散文。
样式只用主题 token(--dsw-alias-*、--dsw-radius-*、--dsw-font-*),不 import 任何 Harness Client 包,浅色/深色主题都跟随 Host。
诊断面:Client 半在 globalThis.__cordisSecretClient 暴露一个只读、Object.freeze 的测试缝(纯函数、定义与占位组件;不含交互态、不含任何值),供测试与诊断使用。
人类主动附加密钥(反方向)
0.2.0 的缺陷与本版修复(0.2.1)
0.2.0 已发布,但这条链路当时不可用。缺陷有两条,0.2.1 一并修复:
- 附加秘密后,后续 shell 取不到变量:标记从未被提升为 grant,
shellEnv里因此没有这个变量(GET /api/secret.attached会一直停在staged,不会变成bound)。 - 对话流里那条消息没有变量名胶囊:用户气泡显示原始文本,而不是"只显示变量名"的 chip。
根因一句话: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_* 标记(绑定永不发生),气泡也因为正文里没有 @ 标记而不再渲染胶囊。
0.2.1 的修复:不再改写正文——日志、对话流与模型请求里的正文都是人类客户端发出的原始形态 @DSH_SECRET_*(session/event 因此重新看得见标记:绑定、anchorSeq、shellEnv contributor 全部成立;projectUserText 也重新把它投影成"只显示变量名"的胶囊)。模型侧的对应关系改由一条值无关的注记逐变量逐字承担(见下「模型侧到底看到什么」)。同时把提升次序固定为「先声明 contributor → 再落 grant → 最后消费暂存项」(见「绑定与失效」第 5 条)。
形态
| 部件 | 座位 | 说明 |
|---|---|---|
| 入口按钮 | conversation.input.left |
切换式(aria-pressed 与 aria-label 随状态变化),形状对齐 dsh-vision-router 的 VisionModeToggle(list 槽 {name, id, order, inject(sessionId)} + 模块级 store 承载活状态)。位置如实记录:它落在 [访问模式][计划] 这一组控件的右侧(.modes 组之后)。「夹在访问模式与计划之间」用公开座位做不到——那两个座位同在一个由 shell 渲染的 .modes div 内,除非整体接管 conversation.input.permission 这个 single 座位并自己重绘权限控件,属于脆弱做法,已否决。 |
| 填值胶囊 / 详情胶囊 | conversation.input.overlay |
同一座位、同一条目、两态渲染;空闲态 return null(槽位常驻)。浮在输入卡片上方(bottom:calc(100% + 4px)),与 @ 菜单同一几何,所以不遮挡正在输入的那一行。 |
| 内联胶囊 | 草稿 | 真正的 chip:ReferenceChipNode,DOM 为 ``,只显示变量名。 |
| 对话框那条消息上的胶囊 | 会话流 | 由 Harness 自己的 projectUserText 把文本里的 @DSH_SECRET_OPENAI 渲染成只显示变量名的胶囊。 |
参照对照(与 dsh-vision-router 的 VisionModeToggle):相同——list 槽 + id + order + inject(sessionId) 的注册形状;活状态放模块级 store 而非注册选项;按钮是新插件的增量贡献(不替换任何既有座位)。不同——座位是 conversation.input.left(用户要求紧邻「访问模式」,而参照在尾随侧的 input.right);本插件注册 locale 命名空间但有字面量兜底表(参照硬依赖 ctx.locale);本插件同时注册一个 InputTriggerSource(参照不需要)。
真 chip 是怎么来的(非 hack、非 monkey-patch)
Harness 的公开面 InputActions 故意不含引用插入(Command-style handles … stay InputBar-private),但同一份冻结契约里有一个 scoped bail 事件正是为此而设:
- 事件:
slash/input-insert-reference(dsh-client-ui-conversation/lib/types/client/contract/input.d.ts,@mode bail),载荷{ reference, span }; - 会话作用域的监听者由 shell 自己注册(
lib/client.js:14264:actx.on('slash/input-insert-reference', (req) => shell.insertReference(req.reference, req.span) ? true : void 0)); - 该契约动词的原文就是 Replace the trigger span with one reference chip (span-CAS'd);
- shipped 的
@菜单自己就走这条路(dsh-client-ui-input-trigger/lib/client.js:724)。
插件因此:注册一个 InputTriggerSource(name: 'secret',codec 决定模型形态,lexicon 让刷新后的纯文本标记仍被装饰成引用,openReference 接住点击,不实现 matchSpace/matchEnter、不产出任何候选),再用 ctx.inject(['sessions'], (scoped) => scoped.sessions.scope(sessionId)) 拿到该会话的 ctx 并发这个事件,span 取自公开动作 inputActions.captureInsertion()。
客户端半的三个可选协作方(locale / inputTriggers / sessions)一律经 cordis 的可选座位 ctx.inject([...], cb) 取得,不写进 inject、也不直接读 ctx.:cordis 的 ctx Proxy 对未声明服务抛 cannot get property "" without inject,直接在 apply() 里读会让整条 client entry failed、整页 web boot 报 Failed to load plugins。缺任何一个时按上述阶梯降级(没有 sessions 就从 L3 起步),不抛。inject 声明仍是 ['slots', 'uiConversation']。
兜底阶梯(每一级都可被测试观测):L1 上面的 chip 事件 → L3 inputActions.insertText('@DSH_SECRET_X', span)(纯文本,由 lexicon 装饰为可点击引用,DOM data-composer-text-ref)→ L4 在胶囊里显示变量名请人类手动输入。原定案里的 L2(toggleSource + pick)在实施中判定为冗余并移除:那条 pick 管线的终点就是 L1 这同一个事件、且传的是同一个 span,因此它不可能在 L1 失败的地方成功。只有 L1 成功才在 UI 与文档里说"真 chip"。
模型侧到底看到什么(逐字)
| 位置 | 内容 |
|---|---|
| 会话日志(durable) | user/message · 请用 @DSH_SECRET_OPENAI 跑测试 |
| 用户气泡 | 请用 + 胶囊(只显示 DSH_SECRET_OPENAI) + 跑测试,其后一行注入说明(header 标注 producer secret-attach) |
模型请求(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"、「不要把该标记当作文件路径读取。」 |
为什么模型侧不是改写正文,而是由注记逐变量写明对应关系(这是与早期设计稿不同的一点,原因在 harness 契约,不在取舍):
agent/pre-step返回的消息就是落盘的那条:loop 把它原样 append 成user/message(dsh-agent-loop/lib/index.js:1061,surfaceOp: 'append'),而同一步的模型请求由同一份 surface 派生(:1262)。所以"改写只作用于本次模型请求、不落盘"在这个接线下不可能成立:改写必然落盘,落盘就必然改变用户气泡。- 模型输入按契约是会话日志的纯函数:loop 构造的请求被 deep-freeze,
llm/stream的监听者「read it, never rewrite it」(dsh-llm/lib/types/index.d.ts:37-45)。 - 唯一能保留"仅模型可见副本"的机制是 surface replacement / message projection,但它必须 append 在目标事件之后,而 loop 的 append(
:1061)与请求构造(:1262)之间没有任何可插入点;由插件自己先 append 目标再替换,会把用户消息挪到本步system/message提交之前(模型会先读用户消息再读系统提示),代价大于收益。自定义事件类型还需要ignorable才不被持久化读取拒绝(dsh-session-persistence/lib/index.js:184),树外插件拿不到。
因此日志与用户气泡保留人类客户端发出的原始形态 @DSH_SECRET_*(这样 projectUserText 才能把它投影成"只显示变量名"的胶囊),模型侧的改写改由注记承担:注记逐变量逐字写出「正文里的 @VAR 即该变量,模型侧写作 [secret VAR];它不是文件路径。」——[secret VAR] 形态因此逐字出现在模型可见文本里,且与正文的对应关系是确定的、不依赖启发式。注记是文本的纯函数(只认 @DSH_SECRET_* 的形状),刷新、重放与 fork 拿到的模型文本一致。
注记的落盘与去重:注记本身是一条 durable 的 user/message(source.kind = 'secret-attach'),所以它在对话流里显示为一行注入说明;同一条注记按自身 source 去重(src/inject.ts 的 noteVisible:模型可见 surface 上已有完全相同的 source 就不再追加),同一步/后续步重复引入同一标记不会堆叠第二份。
自己复测(活体,约 3 分钟)
> 先重启。 dsh 在进程启动时加载插件的 lib/ 构建,所以发布 0.2.1 之后必须重启 dsh web 才会加载修复后的产物;重启前的活体复测仍会复现 0.2.0 的现象(staged 不变 bound、shell 里没有变量),那是预期,不是修复失败。同理,发布 0.3.0 之后也必须重启 dsh web 才会加载四项增强的产物——重启前看不到消息旁的旁挂胶囊、信息框里的「历史记录」区、草稿移除后的自动撤销,@ 菜单也不会有新分组,这些都属预期(下面是这四项各自的复测方式)。发布 0.4.0 之后同样必须重启 dsh web 才会加载管理面——重启前 secret_manage 工具不会出现在工具列表里,信息框里也没有五条管理路径,历史里不会有 updated/scope-changed/unbound/deleted 四类事件;活体上仍是 0.3.0 的四个方向,这属预期,不是发布失败。0.4.0 是个例外:它虽然在 GitHub Release 与 GitHub Packages 上发了,但在真实运行时整个插件条目不激活(dsh: warning: 1 entry did not activate,见下面「0.4.0 的发布级缺陷与 0.4.1 修复」),所以重启也换不来可用的管理面;必须重启到 0.4.1。发布 0.4.2 之后同样必须重启 dsh web 才会加载密钥输入的抑制属性——重启前浏览器仍会照旧给那四个字段自动填充建议、密码管理器扩展仍会捕获(这就是 0.4.2 修的那件事),这属预期,不是修复失败。发布 0.4.3 之后也必须重启 dsh web 才会加载两个标识符字段(凭据键、标题)的抑制与隐式关联改动——重启前这两个字段仍会弹浏览器的历史值/自动填充建议(这正是 0.4.3 修的那件事),这也属预期。
- 在输入区点「附加密钥」按钮 → 胶囊里填名称(如
openai)与值,作用域保持默认「仅本次会话」→ 插入 → 输入框出现@DSH_SECRET_OPENAI胶囊。 - 发送前:在同一浏览器(同源、带签名 cookie)打开
GET /api/secret.attached?sessionId=$DSH_SESSION_ID→ 该条的state必须是"staged",且此刻 shell 里没有这个变量(未发送永不注入)。 - 发送后立刻再查同一路由 → 该条的
state变成"bound"(不再停留在staged)。这是判别"绑定是否真的发生"的最快手段,也就是这一轮修复的核心判据。 - 让 Agent 在后续执行里只报存在性与长度(绝不回显值),例如 PowerShell:
if ($env:DSH_SECRET_OPENAI) { "present length=$($env:DSH_SECRET_OPENAI.Length)" } else { "absent" }。看到present即「附加 → 绑定 → 注入」端到端可用。 - 回退/编辑重写那条消息后再查:锚点离开 live surface ⇒ 授权撤销、变量重新不可用(
revoked-anchor)。
绑定与失效
POST /api/secret.attach只暂存(默认scope: "session",可切"persistent")。暂存不是授权:GrantStore看不见它,shellEnv里没有这个变量,未发送就永不注入。暂存有 TTL(默认 30 分钟)与会话结束两道兜底。- 携带标记的用户消息一旦成为 durable 事件,
session/event钩子把它提升为授权,anchorSeq= 该条user/message事件的 seq。该服务的契约原文是 "Seed events never publish onsession/event"(dsh-session/lib/index.js:1271-1275),所以重放或恢复一份历史日志不可能重新绑定——这比"回头扫日志找锚点"更稳,因为锚点 seq 本来也只有消息落盘之后才可知。 - 之后
shellEnv才在每次执行时按会话注入。回退/编辑重写掉那条消息 ⇒ 锚点离开 live surface ⇒ 自动revoked-anchor,无需额外记账。 POST /api/secret.release只能丢弃尚未绑定的暂存项;已绑定项会如实回答state:"bound"(唯一诚实的解除方式是回退那条消息)。- 提升的次序是先向
shellEnv声明 contributor → 再落 grant → 最后消费暂存项(src/attach.ts的bindStaged)。声明失败时会话原样不变(无 grant、暂存项仍 armed,可在下一次标记到达时重试);反向顺序会留下「有 grant、无 contributor、暂存未消费」的静默不注入状态。另一方向无害:grant 已落但 contributor 因无关原因缺失时,resolve查不到值 ⇒ 不注入(fail-closed)。 - Host 重启后暂存与授权都消失(都在内存里),日志里的标记仍在但不会自动重新武装——故意 fail-closed:没有人类在场的新进程里静默恢复一次授权,不是本插件愿意做的事。
0.3.0 的四项增强(历史胶囊点击 / 历史聚合 / 移除即撤销 / @ 菜单)
四项增强各一句:
| 增强 | 一句话 |
|---|---|
| 历史胶囊点击 | 插件在每条带标记的消息旁新增一行胶囊(旁挂节点),点击打开输入框上方的信息框;同时注册一个右侧栏文档查看器,接管 harness 渲染的原胶囊,点它时在右侧栏打开详情页。 |
| 信息框历史聚合 | 信息框内新增「历史记录」区,集中列出本会话的附加/授权流水(变量名、来源、作用域、状态、时间、锚点),默认全部展示(含已失效/已回退),用状态文案区分。 |
| 移除标记即撤销 | 草稿里的标记消失约 0.6s 后,对应的暂存记录被撤销:GET /api/secret.attached 不再把它报为 staged,会话级的值随即不可找回(重插须重填)。已绑定的记录不受本机制影响。 |
@ 引用菜单 |
输入 @ 列出本会话当前可用与凭据库已持久化的密钥,条目上用 section 标来源、description 标作用域;本会话可用的条目直接插入,凭据库条目先确认再登记(值由宿主自己从凭据库读取,不经过对话)。 |
两个入口的区别(同一份信息,两处入口)
| 你点的东西 | 它是什么 | 打开哪里 |
|---|---|---|
| 旁挂胶囊(插件在你的消息旁新增的那一行) | 本插件自己的 ConversationNodeDefinition 节点(conversation.chat.node,key = sr-chip),锚在同一条消息的 seq 上、location = { kind: 'session' },因此排在用户气泡之后、且永不折进「已调用工具」分组 |
输入框上方的信息框(与草稿 chip 的点击是同一个面) |
| 原胶囊(harness 渲染在你消息正文里的那个) | dsh-client-ui-primitives 的 projectUserText 产物,data-ref-chip="file",点击被硬编码为 openFile('DSH_SECRET_X') |
右侧栏的详情页(由本插件注册的 secret-attach-detail 类型接管),内容与信息框同源 |
旁挂胶囊的落位不是"大概齐":节点 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 及以上一律排在之后"的说法不成立,特此更正。)
原胶囊的接管是「长度竞争」,不是契约(如实披露)
原胶囊的点击最终走到右栏的 tab 注册表:openFile → fileAddressFor → ctx.sidebarRight.openResource('dsh-resource://file/session//DSH_SECRET_X') → claim(address)。该注册表按 priority 档 → 命中的 pattern 长度 → 注册顺序 排序(dsh-client-ui-sidebar-right/lib/client.js:8782-8798)。
- 本插件声明
priority: 'extension'、patterns: ['dsh-resource://file/**/DSH_SECRET_*'](实测长度 35),并用canOpen只接受最后一段形如DSH_SECRET_*的地址(不是我们的地址一律让给对方); - 本机已装的
dsh-better-sidebar0.24.1 在同一档声明兜底dsh-resource://file/**(实测长度 22):同档时更长的 pattern 胜出,因此这次打开归我们。
可争用性(机制固有,不是缺陷):任何后来者只要在同一 extension 档声明更长的 pattern(例如 dsh-resource://file/**/DSH_SECRET_*/**),就会静默抢走这次打开——用户会落到那个查看器,而不是我们的详情页,且我们收不到任何通知。这一点无法用契约保证,只能靠"我们当前最长"这一事实。若某天发现点原胶囊又开出别的东西(甚至退回"文件不存在"),原因就在这里。
历史记录的保留语义(如实)
| 维度 | 事实 |
|---|---|
| 存储 | Host 侧 HistoryStore 是纯进程内存,按会话隔离,有容量上限(maxHistoryPerSession,默认 32),读出时最新在前 |
| 刷新页面 | 仍在(Host 进程没变) |
| 宿主重启 / 插件重载 | 清空(历史不在磁盘上) |
| 重放 / 分叉会话 | 不重建:Host 不读 session log 还原历史;fork 出来的子会话从空历史开始 |
| 条目来源 | staged(附加登记,可含"取代了同变量的上一条")/ bound(提升为授权,带锚点 seq)/ discarded / withdrawn(草稿移除撤销)/ revoked(锚点消失,读取时懒观测)/ expired(TTL 到期)/ authorized(secret_request 索取方向的授权),并同时标 attach / request |
| 锚点 | 已绑定的条目带 anchorSeq(即那条 user/message 的 seq);未绑定或未落盘的条目没有锚点,UI 不编造 |
客户端的四种诚实状态(都出现过,都不编造):未读(「正在读取本会话的记录…」)/读失败(「历史暂不可用」,并保留上一次的答案,不清空)/本进程无记录(「本进程内暂无记录。」)/该变量无记录(「该变量在本进程内没有记录。」)。
「默认全部展示」的落地:已失效/已回退/已丢弃/已过期都列出,并用状态文案区分——已登记 / 已绑定到消息 / 已丢弃 / 已随草稿移除撤销 / 已随消息回退失效 / 已过期 / 经授权生效。其中 revoked(已随消息回退失效)只在有人读过该变量状态之后才出现(懒观测):没观测到就不写进流水,宁缺勿假。
从草稿移除标记即撤销(含边界)
只看"草稿里没有标记"是不够的,因为发送也会清空草稿(乐观清空):普通发送的提交相位全程是 plain(不走 submitting),但 pendingSubmission 回显会同步先行出现,且它的文本就是将要发送的文本。因此撤销要在全部条件成立时才触发:
- 草稿里没有该标记(
InputState.draft+MARKER_RE); - 没有任何
pendingSubmission的文本携带该标记(⇒ 发送中不撤销); - 提交相位是
plain; - 该变量曾在草稿里出现过(否则刚 attach 完还没插进去就会被立刻误撤销);
- 本地状态是
staged。
满足后去抖 600ms,再做一次同样的判定,然后调 POST /api/secret.release(带 reason: "withdrawn",与人类手点「丢弃」的 discarded 在历史里区分开)。
| 边界 | 行为 |
|---|---|
| 删掉后 600ms 内又插回(含剪切/粘贴) | 不撤销,记录保留 |
| 删掉后隔一会儿再插回 | 记录已撤销,@ 菜单不再列出该变量;重新附加必须重新填值(会话级的值不可找回;persistent 作用域的可以从凭据库条目重新登记) |
| 发送(Enter / 发送按钮) | 不撤销:回显携带标记 ⇒ 判定中止;消息落盘后该记录提升为 bound |
| 发送失败(草稿被恢复) | 标记回到草稿 ⇒ 不撤销,记录保持 staged |
| 发送中又手动删掉标记 | 同样不撤销(人类删的是草稿,不是那条已经发出的消息) |
已 bound / 已 Grant 的记录 |
本机制不碰:只有回退/改写那条消息才会让它失效(revoked-anchor,与既有一致) |
| 宿主未提供草稿观测能力 | 胶囊里显示「宿主未提供草稿观测能力,自动撤销不可用(可手动丢弃或等 TTL 过期)。」——宁可不撤销,也不误撤销 |
| 刷新页面 | 草稿恢复为纯文本 @VAR,MARKER_RE 仍命中 ⇒ 不撤销 |
| 撤销后重新附加同一凭据键 | 新记录带新的代次;任何在途的旧计时器因代次不匹配而作废,不会杀掉新记录 |
@ 菜单的两类来源
来源(section) |
含义 | 条目 description |
选中后的行为 |
|---|---|---|---|
本会话可用 |
本会话已登记(staged)或已绑定(bound)的变量 |
本会话 · <作用域> · <状态> |
直接插入引用标记:与既有 codec / serializeReference 完全一致(插入的就是 @DSH_SECRET_X),发送后照常绑定与注入 |
凭据库(持久) |
本插件在凭据库里留过持久记录的变量,且本会话当前没有 | 凭据库 · 持久保存到凭据库 · 尚未用于本会话 |
不直接插入:先弹一步确认(变量名 + 来源 + 作用域),确认后调 POST /api/secret.adopt,由宿主自己 credentials.resolve 取值并登记到本会话,成功之后才插入标记;失败则不插入任何东西,只显示固定文案 |
条目形态用足菜单的呈现能力:label 是主显示文本,但不总是变量名——「凭据库(持久)」一类是变量名(src/service.ts:664-668,记录里没有人类标题,变量名是唯一诚实的标签),「本会话可用」一类是人类标题(在胶囊里填的标题;留空即回退为凭据键,src/client/entry.ts:2808,attach 请求缺省 label ?? name)⇒ 带标题的本会话条目上不会显示变量名(变量名出现在插入后的草稿标记与胶囊详情里,那两处都按变量名显示;菜单条目的 description 只写来源/作用域/状态)。name = 凭据键(灰色别名 + 搜索键)、description = 来源·作用域·状态、section = 来源分组(不把来源塞进名字里)。菜单不渲染 hint 字段,所以来源/作用域不放在 hint。「凭据库」一类只列本插件提交过记录的密钥:凭据服务的 reference 半边没有枚举面,别处写进去的环境变量/.env 条目无法被发现,这一点如实说明而不是猜。
怎么复测这四项(先重启 dsh web)
下表左列是已在代码与 0.3.0 产物层面机器验证过的机制(lib/ 里可机械复核),右列是仍未验证、必须真人在浏览器/真实凭据库里确认的部分。两者不要混读。
| 增强 | 机制(机器已验证的判据) | 仍需真人现场确认 |
|---|---|---|
| 历史胶囊点击 | 旁挂节点 sr-chip:uiConversation.events.register + conversation.chat.node 座位(键 sr-chip)都在 lib/client/entry.js 里;点击 → setAttachMode({kind:'detail'}),与草稿胶囊共用同一个信息框。右栏 secret-attach-detail 类型以 priority: 'extension' + dsh-resource://file/**/DSH_SECRET_* 注册,canOpen 只收末段形如 DSH_SECRET_* 的地址 |
旁挂行在气泡之后的视觉位置与四档展示;点旁挂行是否真在输入框上方开出信息框;点转录里的原胶囊是否真开出右栏详情页(而不是「文件不存在」),以及有没有被别的插件抢走这次打开 |
| 信息框历史聚合 | 信息框头部「历史记录」链接 → history 面;行有 data-secret-history-count / data-secret-history-row[data-status];Host 侧 GET /api/secret.history?sessionId=$DSH_SESSION_ID → {ok:true, entries},逐字段重建、无 value |
视觉排版与长流水是否截断;四种诚实状态(未读/读失败/本进程无记录/该变量无记录)在现场是否可读 |
| 移除即撤销 | 插入标记 → 从草稿删掉标记 → 约 0.6s 后 POST /api/secret.release(reason:"withdrawn")⇒ GET /api/secret.attached 不再报 staged,/api/secret.history 多一条 withdrawn;重新插入不会把值找回来 |
真实编辑器里的时序(删掉→插回是否在 600ms 内、Enter 发送是否不触发撤销、发送失败恢复草稿是否仍为 staged) |
@ 菜单 |
输入 @ → GET /api/secret.available?sessionId=…;两类条目用 section 分组(本会话可用 / 凭据库(持久))、description 写作用域与状态;凭据库条目选中后先确认,确认才 POST /api/secret.adopt(body 只有 {sessionId, variable}),成功之后才插入 @VAR |
菜单宽度是否截断条目;带自定义标题时条目显示标题(不是变量名)在现场是否可接受 |
Shell 侧的可执行判据(只报存在性与长度,绝不回显值):
# 先重启 dsh web,再重跑一遍「自己复测」五步;下面是四项增强各自的检查点:
Invoke-RestMethod "http://127.0.0.1:/api/secret.history?sessionId=$env:DSH_SESSION_ID" # 历史聚合
Invoke-RestMethod "http://127.0.0.1:/api/secret.attached?sessionId=$env:DSH_SESSION_ID" # 移除标记后不再有 staged
Invoke-RestMethod "http://127.0.0.1:/api/secret.available?sessionId=$env:DSH_SESSION_ID" # @ 菜单的两类来源
if ($env:DSH_SECRET_OPENAI) { "present length=$($env:DSH_SECRET_OPENAI.Length)" } else { "absent" }
0.5.0 的四项(已随 0.5.0 发布)
> 版本说明(已发布):package.json 已是 0.5.0,v0.5.0 已于 2026-10-09 发布上线(tag 对象 e4240f88da8b51267b9263444009d6452d7a07d3、Release run 37971532160 / Publish run 37971532271;npm 侧已入队待 2FA 批准,详见「npm 发布」一节的 registry 现状)。
> 本节示例里的密钥值一律是假值(形如 sk-EXAMPLE-…),不是任何真实凭据。
1. 密钥键改为可选 + 附加时的会话外 AI 命名(R1)
- 契约放宽:
secret_attach的name由必填变为可选。空或缺失 ⇒ 由 Host 自动命名(旧客户端与直接调用同样得 200);非空但非法的键照旧走原来的错误分支被拒(ATTACH_KEY_RE)。 - 命名是「会话外」调用:经
llm.stream({ purpose: 'session-title', … }),不传sessionId、不传tools、不 append 任何会话事件;提示词只带标题与值形态(长度 / 字符集 / 类别三类 token,绝不含明文)。 - 有界且不阻塞发送:deadline 1500ms;超时、模型答案不合法、profile 里没有 llm,一律回落到本地兜底键(标题 slug → 形态默认 → 冲突时加
-2…-99去重)。有「模型挂起也不拖住 attach」的单测(elapsed < 500ms)。 - llm 不在硬依赖里:用
ctx.get('llm')可选获取(不进inject硬依赖),所以没有 llm 的 profile 插件仍激活、attach 仍成功(有正对照测试)。 - 未实现(如实):定案里「晚到结果润色显示标题」这一条没有实现。原因:键在插入胶囊(即报文标记)之前就已定型,不存在一个「不改报文」的安全作用点可以回填标题;因此这是不做,而不是"待办"。