# 设计稿:独立钥匙台账 + 面板统计(v0.4.0) > 状态:**已实现,且被 0.6.0 取代**。 > > - **0.4.0** 按本稿落地:钥匙成为一等公民、带元数据、面板按类别分组、模型能建条目但拿不到值。 > - **0.6.0** 又走了一步(本稿第六节的"每个条目一个录入框"被推翻): > 1. 台账从手写 `sources.mjs` 迁到代写分片 `sources.local.json` 的 `accounts` —— 手写文件界面只读,迁走才能真正编辑(P3); > 2. 数据模型升级为**两层:账号 + 字段**。"同一个网站只占一条目录",字段各自有自己的引用名与配置状态;类别补 `mcp`(P2); > 3. 面板:已配置条目默认折叠、「更换值」才出现输入框、「移除值」二次确认、**值不再 trim**、多行值走 textarea(P0); > 4. 「编辑信息」与「新建」复用同一张字段表,字段带 `secret` / `inject` / `multiline` / `notes`(P1、P2)。 > > 当前的事实来源是 `README.md` 与 `lib/vault.js` 的模块注释;本文件保留为决策记录。 > 起因:面板只能显示"被某个库声明过"的钥匙,因为它们只是**库的附属品**;现实里钥匙常常独立于库存在。 --- ## 一、问题 当前模型有一个**错误的耦合**: ``` 库注册表 sources.mjs └─ { id: 'example', credentials: ['EXAMPLE_ACCOUNT', ...] } ``` 钥匙被当作**库的属性**。后果: 1. 想登记一个不属于任何库的钥匙(网站账号、临时 API key)→ 造不出来 2. 调整钥匙清单 = 改库定义,两件事纠缠 3. 面板只能列出"库声明过的"钥匙,看不到你的钥匙全貌 ## 二、目标 | # | 目标 | |---|---| | 1 | 钥匙成为**一等公民**:可独立增删,不必依附库 | | 2 | 钥匙带**人类可读元数据**:中文名、类别、网址、备注 | | 3 | 面板**一眼可读**:总数 / 已配置 / 未配置 / 按类别分组 | | 4 | 模型可以**建条目**(元数据),但**永远拿不到值** | | 5 | 不新增文件(按你的选择):台账并入 `sources.mjs` | ## 三、数据模型 ### 3.1 `sources.mjs` 变成"两个导出" ```js // 新增:钥匙台账(手写,可写注释) export const credentials = [ { ref: 'EXAMPLE_ACCOUNT', // 必填,^[A-Z][A-Z0-9_]{2,}$ label: '示例:某网站账号', // 必填,中文可读名 category: 'site', // 必填:site|api|database|token|other url: 'https://example.com/login', // 可选 notes: '非机密提示,例如登录入口或轮换周期', // 可选(提示,不是值) usedBy: ['example'], // 可选,手工关联的库 id }, // ... ] // 不变:库注册表 export default [ { id: 'example', /* ... */ credentials: ['EXAMPLE_ACCOUNT', 'EXAMPLE_PASSWORD', 'EXAMPLE_PROXY'] }, ] ``` **库上的 `credentials:` 数组保留不变**——它表达的是"这个库需要哪些钥匙",与台账"我有哪些钥匙"是两个问题。 ### 3.2 合并规则(以 `ref` 为键) | 情形 | 面板显示 | |---|---| | 台账有 + 库声明 | 完整:中文名、类别、网址、被哪些库引用 | | 仅台账有 | 完整元数据,标注「仅台账」 | | 仅库声明 | 显示引用名 + 被哪些库引用,标注「未登记详情」(引导你去台账补中文名/类别) | 冲突(两处都写 label 等)→ **台账优先**,并在 `problems` 里记一条提示。 ### 3.3 模型新增的条目落在哪(连带后果,需你定) 你选了"并入 sources.mjs",但 `sources.mjs` 是**手写文件,插件程序永不改写**(这是为了不毁掉你的注释与排版)。所以模型新增的条目必须落到**可写的分片**: **方案 A(推荐)**:`sources.local.json` 从数组扩成对象,向后兼容: ```json { "sources": [], "credentials": [] } ``` 读取端两种形状都接受(旧数组 = 只有库)。新增文件数 0。 **方案 B**:模型**直接用文件工具编辑 `sources.mjs`**(它本来就有权限)。 优点:真正只有一个文件。缺点:模型手改 JS 有写坏语法的风险(加载器会报错,面板也会显示 problems,但毕竟多一道风险)。 → **我建议 A。** ## 四、值的路径(不变量,不可协商) ``` 台账 / 库注册表 / sources.local.json → 只存「引用名」,永不存值 密钥的值 → 只有一条路径: 浏览器输入框 → ctx.remote.credentials.set(ref, value) → 宿主凭据服务 → $DSH_HOME/.credentials.yaml ``` 任何让值经过 tool 参数、会话记录、或模型上下文的做法都不做——理由是**机械后果**:值进会话记录就会发给模型服务商。 ## 五、模型能填什么 / 不能填什么 **能填**(新工具 `stash_credential_add`): ``` stash_credential_add({ ref, label, category, url?, notes?, usedBy? }) ``` 典型用法:你说"我有个 XX 招标网站的账号,帮我登记",模型建条目 → 面板出现一条「未配置」→ 你在界面上贴值 → 变绿。**模型全程不知道值。** 校验: - `ref` 必须匹配 `^[A-Z][A-Z0-9_]{2,}$`,否则拒绝(顺带挡住"填口令当引用名") - `label` / `category` 必填,`category` 必须属于枚举 - 所有字符串过一遍厂商密钥特征扫描(`sk-` / `ghp_` / `AKIA` / `AIza` / `eyJ` / `-----BEGIN PRIVATE KEY-----`)——命中即拒绝 **不能填**:值。工具**没有** value 参数;如果模型试图把值塞进 `notes`,上面的扫描会拦下。 配套:`stash_credential_remove({ ref })`(只删台账条目,**不动**凭据库里的值;要清值请在面板上点移除)。 ## 六、面板设计 ``` 钥匙 总计 8 · ✅ 已配置 5 · ❌ 未配置 3 ────────────────────────────────────────────────────────────────── 网站账号 (3) ✅ 示例:某网站账号 EXAMPLE_ACCOUNT [覆盖值…] [移除] example.com · 被 example 引用 ❌ 某招标网站 BID_PORTAL_ACCOUNT [粘贴值…] [保存] bid.example.com · 仅台账 API 密钥 (2) ✅ DeepSeek DEEPSEEK_API_KEY [覆盖值…] [移除] platform.deepseek.com 数据库 (1) ❌ 公司 PostgreSQL CORP_DB_PASSWORD [粘贴值…] [保存] 令牌 (1) ❌ 企业微信机器人 WX_BOT_TOKEN [粘贴值…] [保存] 其他 (1) ⚠️ EXAMPLE_PROXY (未登记详情) [覆盖值…] [移除] 被 example 引用 · 建议在台账补中文名与类别 ``` 要点: - 顶部统计条:总数 / 已配置 / 未配置 - **按类别分组**,组头带 `(n)` 计数 - 每行:状态图标 + 中文名 + 引用名 + 操作按钮;第二行放网址与"被谁引用" - 未登记详情的条目标 ⚠️ 并给出补登记提示 - 值的输入框仍是 `type="password"`,值不回显(凭据服务没有回值方法) ## 七、HTTP 端点响应(面板唯一数据源) `GET /stash/credentials` 扩展为(**仍然绝不回值**): ```json { "ok": true, "credentialsAvailable": true, "stats": { "total": 8, "configured": 5, "missing": 3, "byCategory": [{ "category": "site", "categoryLabel": "网站账号", "total": 3, "configured": 2 }] }, "entries": [{ "ref": "EXAMPLE_ACCOUNT", "label": "示例:某网站账号", "category": "site", "categoryLabel": "网站账号", "url": "https://example.com/login", "notes": "…", "origin": "vault", "configured": true, "writable": true, "source": "file", "usedBy": [{ "id": "example", "name": "示例:某个库" }] }], "problems": [] } ``` 统计在 host 侧算好,客户端保持"只渲染、不计算"。 ## 八、兼容与迁移 - 库上的 `credentials: [...]` **原样继续有效** → 现有 5 个引用名一条不丢,只会多出"未登记详情"标注 - `sources.local.json` 旧数组形状继续可读(方案 A) - 会把注册表里声明过的引用名**预填**进 `sources.mjs` 的 `credentials` 导出,并附上已知的分类信息,你可随时改 - 面板接口字段**只增不改**,旧客户端 bundle 仍能渲染 ## 九、测试计划(沿用现有两个测试文件) | 测试 | 新增断言 | |---|---| | `test/host-assembly.mjs` | 台账能解析;合并规则三种情形各一条;`stash_credential_add` 拒绝疑似口令值;拒绝非法 ref;端点返回的 entries 不含任何值字段;stats 数字自洽 | | `test/client-runtime.mjs` | 统计条渲染;按类别分组渲染;未配置条目显示"粘贴值";仅库声明的条目显示 ⚠️ 提示;无 unhandled rejection | 两个测试都在本机跑通后才交付,并且**变异验证**(把合并规则改坏,测试必须失败)。 ## 十、明确不做的事 1. **不在台账/注册表里存密钥值**(见第四节) 2. **不做"让模型读回已存的值"**——凭据服务本身没有回值方法,这是框架保证 3. **不在面板里删除凭据库的值之外的副作用**:`stash_credential_remove` 只删台账条目,不动 `.credentials.yaml` 4. 本轮不做:界面新建条目表单(用模型工具或手写文件建;如果之后觉得需要再加)、导入导出、凭据轮换提醒 ## 十一、需要你拍板的两个点 **① 模型新增条目落哪**(第三节 3.3) - **A(我建议)**:`sources.local.json` 扩成 `{sources, credentials}`,向后兼容,文件数 0 新增 - B:让模型直接编辑 `sources.mjs`(真·一个文件,但手改 JS 有语法风险) **② 台账里要不要存"账号名"(手机号 / 邮箱)?** - **建议不存。** 把账号名本身当作密钥放进凭据库更一致:既不进明文文件,也不进模型上下文。 - 如果你希望在面板上直接看到"哪个账号",它就得进 `sources.mjs`——**那意味着账号名会进入模型上下文**(因为模型会读这个文件)。两者不可兼得,你定。 - 折中:台账里写**非机密的提示**(如 `notes: '用读者卡登录'`),实际账号仍作为密钥存。 --- **批准方式**:回复"按 A 做,账号名不存"这类即可;或指出要改的点。