凭据管理
一句话版:DSH 的凭据系统
ctx.credentials三条铁律:配置只存引用、不存密钥;按操作逐次解析;空存储值等于"未配置"。密钥真正存哪由 provider 决定(默认~/.dsh下)。
这一篇讲清楚"密钥在哪、怎么被读取、怎么轮换"。
一、三条原则(源码 README)
- 配置携带对密钥的引用,从不携带密钥本身。
apiKeyEnv: DEEPSEEK_API_KEY是引用;值活在 credential provider 里。所以 settings/cordis 文档可安全同步、可渲染,describe()能答"配没配、来自哪、能不能写",而从不持有值;轮换密钥不碰任何配置文件。 - 消费者按操作解析。
resolve(ref)在每次操作开始时调用(LLM adapter 每次模型请求解析一次),跨操作不缓存:这让改了的密钥立刻对下一次请求生效,不用重启插件。 - 空存储值 = 缺失。
resolve跳过它,describe报未配置。一个空串永远不能伪装成已配置的密钥。
二、表面 API
import type { Context } from '@deepseek-ai/cordis'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
declare const ctx: Context
const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell 标识,branded
const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined
const info = await ctx.credentials.describe(ref)// { configured, source?, writable } — 从不是值
await ctx.credentials.set(ref, 'sk-…') // 只读源遮蔽时拒绝
await ctx.credentials.unset(ref) // 缺失 no-op;同上遮蔽规则
credentials/updated (ref) 在 committed 改变后触发(用于配置 UI 刷新"已配置"徽标)。消费者不需要它:因为他们按操作 re-resolve。
三、解析流程与优先级
本地 provider(dsh-credentials-local)把四层来源按一条诚实优先级叠起来:
| 层 | 来源 id | 可写 | 谁赢 |
|---|---|---|---|
| 继承的进程环境 | env | 否 | 永远 |
$DSH_HOME/.credentials.yaml 文档 | file | 是(set/unset) | 压过两个 .env 层 |
<调用目录>/.env | project-env | 此处不可写 | 压过用户 .env |
$DSH_HOME/.env | user-env | 此处不可写 | 兜底 |
逐行读:
- 启动环境永远赢:
DEEPSEEK_API_KEY=… dsh、CI secret、容器-e是本次运行的算子意图;又因为它无法从内部改写,必须可见地只读——describe()报source: 'env', writable: false,set/unset直接拒绝。 - 受管存储压过
.env兜底:Models 页写进的 key 立即生效,哪怕.env里还躺着旧 key;两个.env层只在"什么都没存"时才解析,存一个 key 就把它们替换成实际生效来源。 - 快照,不是
process.env:产品 CLI 下解析读的是 launcher 冻结的环境快照,只有它能说清一个值来自启动 shell 还是某个文件。
四、遮蔽规则(fail-loud)
set/unset 有故意的 fail-loud:当一个只读源(本地 provider 里就是活动进程环境)当前供应这个 ref,写入会"看起来成功"但解析仍返回遮蔽值:seam 直接拒绝;describe().writable 让 UI 预先把这个 ref 渲染成只读。
| 场景 | 结果 |
|---|---|
进程环境里有 DEEPSEEK_API_KEY,代码再 set 它 | 拒绝;解析仍是环境里的旧值 |
.env 里有旧 key,set 写进 store | 成功;store 立即覆盖 .env 成为生效来源 |
set(ref, '')(空串) | 拒绝;空存储值 = 缺失,unset 才是删 key |
五、Provider
dsh-credentials-local 把继承的进程环境叠加在它管理的 $DSH_HOME/.credentials.yaml 文档上,launcher 的 project/user .env 层作为 fallback。
llm-pi-ai的 provider-native discovery 是另一条路径:无 credential ref 的 provider 直接读取进程环境,看不到 Harness 托管 store。AWS 场景需显式导出AWS_PROFILE或访问密钥变量;只有~/.aws/credentials文件还不够。
| 配置项 | 默认 | 含义 |
|---|---|---|
path | <harness home>/.credentials.yaml | 凭据文档位置 |
dshHome | $DSH_HOME 或 ~/.dsh | path 省略时使用的 Harness home |
watch | true | 热发布外部编辑 |
debounceMs | 100 | watcher 写静默窗口 |
文档是"凭据引用 → 值"的 YAML 映射、仅此而已;非映射根、非 POSIX 标识的 key、非字符串值、空串、重复 key、坏 YAML 都整份拒绝(启动时响亮失败,热重载时 warn 并保留最后一份好快照)。写入补丁式改解析后的文档(注释与未触碰项的排版保留),并在 dsh-atomic-write 的跨进程写锁下先重读合并、再以 0600(目录 0700)原子提交。外部编辑在快照整体替换后按变化的 ref 发布 credentials/updated,磁盘上删掉的条目绝不在内存残留。
seam 的形状为 keyring / helper-command / KMS-backed provider 留了空间;远程 settings provider 永远不需要携带密钥。
六、安全边界与已知限制
边界只到"别的 OS 用户"为止,不挡模型:0600 + 0700 挡住的是其它用户;工具进程(bash、文件系统工具)以同一用户运行,workspace-write 文件策略限制的是写而非读。harness 实际守住的更窄:从不把文档路径交给模型、也从不载入进程环境($DSH_HOME/.env 才是普通环境层)——这是克制,不是边界。
| 限制 | 说明 |
|---|---|
| 同 ref 并发写 | last-write-wins:写锁 + read-modify-write 防丢条目,但两写者改同一 ref 仍后者胜 |
| 同 UID 进程可读 | file-effect 沙箱不拒读;真正隔离需 OS-keychain provider(模型进程读不到的存储,deferred) |
| 环境变更不可见 | 快照启动时冻结,运行中导出变量不进解析;改环境来源凭据需重启 |
| 原子而非 crash-durable | 继承 dsh-atomic-write;store 在启动时重读 |
七、轮换演练
密钥换新不必改任何配置文件——因为配置只存引用。按"它现在从哪来"分三条路:
当前来源(describe().source) | 轮换步骤 |
|---|---|
env | 改启动环境(新 shell / CI secret / 容器 -e),重启生效 |
file | set(ref, 新值) 原子写 0600 立即生效;或直接编辑文件,watcher 热发布 |
project-env / user-env | 直接 set 写进 store 覆盖 .env;或先清 .env 旧值再 set |
完成后用 describe(ref) 核对 configured: true 且 source 指向预期层;旧值仍躺在 .env 里也没关系——只要 store 有值,.env 就退回兜底。
八、对模型的影响
间接的,经消费它的 LLM adapter:解析出的值授权 provider 请求,adapter 拥有所有模型可见面。
九、验证
# 看凭据文件(0600)
ls -la ~/.dsh/.credentials.yaml
# 组合里看 credentials 是否装载
dsh web --dump-config | grep -i credential