xinvxueyuan/cordis-plugin-secret

cordis-plugin-secret

Cordis / DeepSeek Harness plugin — the agent asks the human for a secret in an inline conversation card and only ever receives an opaque session-scoped DSH_SECRET_* name, never the value

待分类ai-agentsauthorizationcordis

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 / 查询串 / 会话日志 / 响应体。

安全不变量与副作用披露

安全不变量(实现并测试)

  1. 插件自身的输出永不携带明文:工具结果、错误消息、日志、事件、渲染文本、HTTP 响应体与 DOM 属性中都不含密钥值;render() 只输出变量名与元数据。单测对四种 decision 的所有字段做全量字符串扫描,断言值不出现;Client 半另有断言证明填值胶囊的值不越出那个掩码输入框。边界:值确实会按会话注入到 shell 环境(这是本插件的功能),因此"明文不进上下文"取决于 Agent 不回显 $env:DSH_SECRET_*,而不是插件的输出通道。
  2. Agent 只拿到变量名:approved 返回 { decision, variable, scope, ref, source },variable 形如 DSH_SECRET_OPENAI。
  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 也只回传值无关字段。0.4.0 的 GET /api/secret.manage 只回传变量名与元数据(无值字段),POST /api/secret.manage 只在 action:"value" 时携带人类在掩码框里键入的值——它由人类提交,不经模型。
  4. 会话级密钥不落盘:scope: "session" 的值只存在于进程内存(Host 的暂存表与会话授权表)。落盘只走凭据服务,且只发生在 persistent。
  5. 持久化只经凭据服务:persistent 经 ctx.credentials.set(, value) 写入凭据引用空间(provider 管理的可写源);同时向记录空间提交一条不含密钥材料的标记记录(索取方向 kind: "grant",附加方向 kind: "attachment",payload 只有 envVar/name/scope/authorizedAt)。绝不写自建文件,绝不在仓库里存明文。
  6. 会话边界失败关闭(见「边界处理」):锚点离开会话表面即撤销并不再注入。
  7. 人类输入密钥的控件既不可自动填充,也不可被密码管理器捕获(用户实机报出的缺陷的修复):现象:真实浏览器会在这四个密钥输入面上给出自动填充建议,已装的密码管理器扩展也会捕获它们。根因:修复前这四个字段只有 autoComplete:'off'(现代浏览器对登录类凭据字段基本忽略这个值)、没有任何厂商忽略标记、也没有别的退出开关,而掩码用的又是原生 type="password",浏览器正是据此把它当作密码域。修复:本插件渲染的文本/密码输入都带一套抑制属性——autocomplete="new-password"、autocorrect="off"、autocapitalize="off"、spellcheck=false,以及厂商忽略标记 data-1p-ignore(1Password 官方文档逐字给出)、data-lpignore(LastPass:只有其支持页的标题级引用——本环境抓取失败、其公开仓库 0 命中,如实标注为证据最弱的一条)、data-bwignore(Bitwarden:opt-in,只有用户在该扩展的设置里打开对应选项后才生效)、data-form-type="other"(Dashlane)、data-protonpass-ignore(Proton Pass:依据是厂商源码——WebClients 里 PASSWORD_MANAGER_IGNORE_PROPS 收录的正是这同一套属性,见 packages/pass/…/Field.tsx 与 applications/wallet/…/attribute.ts)。按面分层(不是"每一个 input 都一样"):4 个能持密钥明文的字段(索要卡片的值输入、附加胶囊的密钥内容、管理面「改值」、管理确认卡片的「新密钥内容」)带齐 9 键袋且另加只读直到聚焦的守卫——字段先以 readOnly 渲染(自动填充只在加载时预填,而只读字段不在候选里),聚焦时由 onFocus 释放;2 个标识符输入(凭据键、标题)带 autocomplete="off" 的同一套袋且同样带只读直到聚焦的守卫,并且自 0.4.3 起不再带 `id`/`name`、改用隐式 label 关联(` 包住可见标题与控件,见下)。**依据要分清归属(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 测试逐一渲染这些面并断言上述属性与守卫,修复前的形态下它们全部失败(正对照见测试注释)。需真人:具体浏览器是否还弹自动填充建议/是否还提示保存密码、各管理器扩展是否仍捕获、以及只读守卫的代价(见下一条与「已知限制」),只能在真机浏览器里确认。
  8. 明文允许存在的全部位置(穷举,仅此六处):
  • P1 填值胶囊 / 管理面「改值」面的本地输入 state(掩码输入框);
  • P2 一次 POST /api/secret.attach(附加方向)或一次 action:"value" 的 POST /api/secret.manage(改值方向)的请求体;
  • P3 Host 进程内存里的暂存记录(已登记但尚未随消息发送);
  • P4 Host 进程内存里的 GrantStore 记录(已绑定到某条消息);
  • P5 仅当人类显式选择「持久」时写入的凭据库;
  • P6 shellEnv 在执行期把值注入子进程的环境变量。

明文绝不允许出现:模型上下文(对话文本)、持久会话日志(任何事件字段)、工具结果、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(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 只替换本插件自己那次调用的泛型工具行,不触碰别的工具;其余都是增量座位。0.4.0 再增加两处增量注册:conversation.chat.node(key sr-manage,Agent 侧的管理确认卡)与 tool.call.toolview(key secret_manage 的 null 占位);同样只影响本插件自己那次调用,且管理面的三个新面都在既有胶囊组件里,不新增座位、不新增引用源,dsh.client.inject 也不变。0.5.0 再增加一处增量注册:conversation.input.right(id secret-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=<包名 | 本包绝对路径>

安装做两件事:

  1. 用 pnpm 把本包写进当前 profile 的依赖(本地目录会成为 link:),并把包名追加到 profile package.json 的 dsh.profile.bundles;
  2. 从已安装包内的 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 对所有工具共有的既有行为,不是第二个交互面,也不含明文值。)

卡片内容,自上而下(与请求态一致;结算后折叠为摘要,可展开只读回看):

  1. 标题(label)+ 右侧状态:「准备中… / 等待你的决定 / 连接宿主失败,重试中… / 已提交… / 已授权 · 变量名 · 范围 / 已拒绝 / 已忽略 / 其他指示 / 出错 · 错误码」,以及「展开 / 收起」;
  2. 用途(reason,原样展示,左侧竖线引用块)+ description(如有);
  3. 保存方式:持久保存到凭据库 / 仅本次会话有效 两个单选(各带一句含义说明),并用加粗文字写明"Agent 请求的范围:"与"当前选择:";若人类改动范围,追加一行"你已把范围改为:…";
  4. 密钥值输入(type="password"、可切「显示 / 隐藏」);若该凭据已存在于凭据库则不显示输入框,只显示"已配置,本次仅需决定是否授权本次使用";
  5. 四个决定:同意 / 拒绝 / 忽略 / 其他(其他 切换出自由文本域 + 提交指示 + 返回);按钮带 title 说明拒绝与忽略的后果;
  6. 页脚说明:密钥明文不会交给模型,代理只会拿到变量名(并列出变量名)。

假过期缺陷已修复(客户端):卡片只把成功且可解析的 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 一并修复:

  1. 附加秘密后,后续 shell 取不到变量:标记从未被提升为 grant,shellEnv 里因此没有这个变量(GET /api/secret.attached 会一直停在 staged,不会变成 bound)。
  2. 对话流里那条消息没有变量名胶囊:用户气泡显示原始文本,而不是"只显示变量名"的 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 修的那件事),这也属预期。
  1. 在输入区点「附加密钥」按钮 → 胶囊里填名称(如 openai)与值,作用域保持默认「仅本次会话」→ 插入 → 输入框出现 @DSH_SECRET_OPENAI 胶囊。
  2. 发送前:在同一浏览器(同源、带签名 cookie)打开 GET /api/secret.attached?sessionId=$DSH_SESSION_ID → 该条的 state 必须是 "staged",且此刻 shell 里没有这个变量(未发送永不注入)。
  3. 发送后立刻再查同一路由 → 该条的 state 变成 "bound"(不再停留在 staged)。这是判别"绑定是否真的发生"的最快手段,也就是这一轮修复的核心判据。
  4. 让 Agent 在后续执行里只报存在性与长度(绝不回显值),例如 PowerShell:if ($env:DSH_SECRET_OPENAI) { "present length=$($env:DSH_SECRET_OPENAI.Length)" } else { "absent" }。看到 present 即「附加 → 绑定 → 注入」端到端可用。
  5. 回退/编辑重写那条消息后再查:锚点离开 live surface ⇒ 授权撤销、变量重新不可用(revoked-anchor)。

绑定与失效

  1. POST /api/secret.attach 只暂存(默认 scope: "session",可切 "persistent")。暂存不是授权:GrantStore 看不见它,shellEnv 里没有这个变量,未发送就永不注入。暂存有 TTL(默认 30 分钟)与会话结束两道兜底。
  2. 携带标记的用户消息一旦成为 durable 事件,session/event 钩子把它提升为授权,anchorSeq = 该条 `user/message` 事件的 seq。该服务的契约原文是 *"Seed events never publish on session/event"*(dsh-session/lib/index.js:1271-1275),所以重放或恢复一份历史日志不可能重新绑定——这比"回头扫日志找锚点"更稳,因为锚点 seq 本来也只有消息落盘之后才可知。
  3. 之后 shellEnv 才在每次执行时按会话注入。回退/编辑重写掉那条消息 ⇒ 锚点离开 live surface ⇒ 自动 revoked-anchor,无需额外记账。