dsh 中的凭证遵循 引用而非值 的模型:设置与组合文件携带对机密的引用(环境变量名),而提供方拥有实际值与它们的存储。消费表面只描述一个引用,永远不看到它的值。
| 包 | 角色 |
|---|---|
packages/credentials/credentials | ctx.credentials 服务定义 + CredentialRef/ResolvedCredential |
packages/credentials/credentials-local | 基于 $DSH_HOME/.credentials.yaml 的文件后端提供方 |
packages/llm/llm | assertUsableApiKey——共享的密钥有效性诊断 |
packages/llm/llm-pi-ai | 每次请求解析 apiKeyEnv 的 pi-ai 适配器 |
凭证模型
CredentialRef(packages/credentials/credentials/src/types.ts)是一个带 brand 的 POSIX shell 标识符,例如 DEEPSEEK_API_KEY,由 credentialRef(value) 对照 /^[A-Za-z_][A-Za-z0-9_]*$/ 校验。服务定义(packages/credentials/credentials/src/index.ts)是注册为 ctx.credentials 的抽象 CredentialProvider,含四个操作:
| 操作 | 签名 | 含义 |
|---|---|---|
resolve(ref) | Promise<ResolvedCredential | undefined> | 当前值 + 来源;未配置时为 undefined |
describe(ref) | Promise<CredentialInfo> | configured、source、writable——绝不含值(对 UI 安全) |
set(ref, value) | Promise<void> | 持久化存储一个非空值 |
unset(ref) | Promise<void> | 移除值;移除不存在的引用是 no-op |
ResolvedCredential 是 { value, source };本地提供方的来源为 env、file、project-env 或 user-env 之一。一条 seam 全局规则约束所有提供方:空存储值处处视为缺席——resolve 跳过它,describe 报告未配置,因此空值不会伪装成已配置的机密。
值每次变化时,CredentialProvider.notifyUpdated(ref) 会向外发出 credentials/updated 事件并包含监听器失败——损坏的观察者永远无法让一次持久化写入看起来失败(INVARIANT 编码的失败仍会重新抛出)。
本地文件后端(credentials-local)
LocalCredentialProvider(packages/credentials/credentials-local/src/index.ts)以 <harness home>/.credentials.yaml(CREDENTIALS_FILENAME)为后端,并按信任度与环境分层:
继承的进程环境 (只读,获胜)
> $DSH_HOME/.credentials.yaml (提供方管理,可写)
> <调用 cwd>/.env (只读回退)
> $DSH_HOME/.env (只读回退)- 继承环境获胜,因为
DEEPSEEK_API_KEY=… dsh、CI 机密或容器-e是本次运行的明确意图,无法从内部编辑——因此它可见地只读(source: 'env'、writable: false),而非静默遮蔽写入。 .env回退低于受管存储,因此通过 Models 页写入的密钥会立即生效,即便用户.env里有更旧的密钥;项目.env高于用户那份(更具体的位置获胜)。- 该文档本身是一个严格的
CredentialRef到字符串的 YAML 映射(不是 dotenv 文件):dsh 拥有且从不物化到环境中的存储,不能兼任用户的环境层。
存储安全
- 权限:文件以
0600创建/替换,目录以0700。assertOwnerOnly在读取前拒绝任何已存在且带 group 或 other 位(0o077)的文档——在 Windows 上跳过,它没有可检查的 POSIX 模式。 - 原子、保留注释的写入:每次写入在跨进程写锁(
withFileLock)下重新读取文档,再只修补自己的键,因此注释与每个未触碰条目的格式都保留;写入用writeFileAtomic。 - 热重载:一个 chokidar watcher(
watch: true,默认开)重新读取文档;变化值通过 seam 作为credentials/updatedfan-out 传播。每次重载都会整体替换快照。 - Fail-closed:启动时非法文档会令插件失败(一个存在但不可信的文档从不会被当作"未存储凭证");失败的重载保留最后一份好快照并告警。
插件配置:path(默认 <dshHome>/.credentials.yaml)、dshHome(默认 $DSH_HOME 或 ~/.dsh)、watch(默认 true)、debounceMs(默认 100)。
LLM 提供方如何获取凭证
LLM 包每次请求都通过 seam 解析,绝不在操作间缓存。
llm(packages/llm/llm/src/index.ts)贡献 assertUsableApiKey(raw, pkg, ref)——与 LlmError 并存于 LlmError 旁的共享有效性诊断。它静默修剪周围空白(存储的密钥可能来自凭证 seam、.env 行或 shell 导出),并以 INVALID_CREDENTIAL_CODE 拒绝空或无法放入 header 的密钥,点名要修复的 ref,绝不回显密钥。密钥绝不出现在消息或 UI 中。
llm-pi-ai(packages/llm/llm-pi-ai/src/index.ts)在 resolveApiKey(provider, profile) 中展示了实际获取路径:
const ref = profile.apiKeyEnv
if (ref === undefined) return undefined // defer to provider-native discovery
const credentials = ctx.get('credentials')
const hit = credentials !== undefined
? (await credentials.resolve(ref))?.value
: launchEnvironmentOf(ctx).get(ref)?.value // no seam ⇒ environment is the whole plane
if (hit !== undefined && hit.length > 0) return assertUsableApiKey(hit, 'llm-pi-ai', ref)
throw new LlmError(/* … */, 'MISSING_CREDENTIAL')每个提供方 profile 声明一个 apiKeyEnv(例如 OPENAI_API_KEY)。只有不声明任何凭证的 profile 才交给 pi-ai 提供方原生发现——一旦声明,miss 会大声失败,而非让 pi-ai 捡起无关的环境密钥(OPENAI_API_KEY 及其同侪),那会给别家租户计费。适配器还暴露 storedApiKey(provider) 用于探查草稿端点,其中请求方表面编辑的是一份脱敏描述符,从不持有已存储机密。
API 代理是否注入凭证?
Web 聚合代理(packages/host/apiproxy)暴露一个 credentials RPC 域(src/api/credentials.ts,schema 在 src/api/credentials.schema.ts),完全建立在 credentials.describe / credentials.set / credentials.unset 之上——值只在这个链路上恰好跨过一次,入站、在 set 上,而且永远不再回传。schema 镜像 seam 的 credentialRef 守卫(坏名字在到达服务前就以 bad-request 失败),describe 视图只携带 { configured, source, writable }。代理不把凭证注入出站请求——那是 LLM 适配器通过 ctx.credentials 的职责。(代理配置表面确实为其自身 settings schema 引用 apiKeyEnv/凭证形态的名字,但解析与脱敏都发生在提供方层。)
被脱敏的内容
- 链路上:凭证值只出现在
credentials.set的请求载荷中;describe 与不含 set 的 resolve 表面从不发出它。 - 日志中:
assertUsableApiKey从不回显密钥;YAML 解析错误以 代码+位置 报告,绝不报告有问题的源码行(describeYamlError只引用代码与行列,因为那行藏有机密);packages/subprocess/subprocess/src/index.ts里的子进程环境清洗会从scrubbedParentEnv丢弃SENSITIVE_ENV_PATTERN(KEY|PASSWORD|SECRET|TOKEN)与所有DSH_*名,因此 harness 机密不会隐式泄漏到派生的子进程中。 - 模型转录中:凭证事件是会话日志事实,不是面向模型的内容;提供方描述的是配置状态,而非值。
延伸阅读
- 权限与审批——与凭证 seam 并行的审批/沙箱管线;两者都上同一个
ctx.approval/ctx.credentials服务模型。 - 守卫:超时与重复提醒——
credentials的notifyUpdatedfan-out 也使用的不变量安全网模式。 packages/credentials/credentials/src/index.ts——抽象CredentialProvider与引用模型。packages/credentials/credentials-local/src/index.ts——分层提供方及其assertOwnerOnly存储检查。packages/llm/llm-pi-ai/src/index.ts——resolveApiKey,逐请求的凭证获取。packages/host/apiproxy/src/api/credentials.schema.ts——凭证值单向链路。