Skip to content

dsh 中的凭证遵循 引用而非值 的模型:设置与组合文件携带对机密的引用(环境变量名),而提供方拥有实际值与它们的存储。消费表面只描述一个引用,永远不看到它的值。

包角色
packages/credentials/credentialsctx.credentials 服务定义 + CredentialRef/ResolvedCredential
packages/credentials/credentials-local基于 $DSH_HOME/.credentials.yaml 的文件后端提供方
packages/llm/llmassertUsableApiKey——共享的密钥有效性诊断
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/reference-updated 事件并包含监听器失败——损坏的观察者永远无法让一次持久化写入看起来失败(INVARIANT 编码的失败仍会重新抛出)。

这条 seam 在引用半边之外新增了一个记录半边:记录以 CredentialKey 寻址,它是一个带 brand 的 <scope>/<id> 对(credentialKey(scope, id))。除四个引用操作之外,CredentialProvider 还声明 readRecord(key)、describeRecord(key)、listRecords()、modifyRecord(key, fn)——唯一的记录写入 路径,一种跨进程串行化的读-改-写——以及 deleteRecord(key)。存储的记录在变化时通过 notifyRecordUpdated(key) 发出 credentials/record-updated 事件。

本地文件后端(credentials-local) ​

LocalCredentialProvider(packages/credentials/credentials-local/src/index.ts)以 <harness home>/.credentials.yaml(CREDENTIALS_FILENAME)为后端,并按信任度与环境分层:

text
继承的进程环境                    (只读,获胜)
> $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/reference-updated fan-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) 中展示了实际获取路径:

ts
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) 用于探查草稿端点,其中请求方表面编辑的是一份脱敏描述符,从不持有已存储机密。

Host 的凭据 RPC ​

旧的 Web 聚合代理(packages/host/apiproxy)已被删除;凭据 RPC 域现在位于 packages/api/settings-controller/src/credentials.ts。它暴露 credentialsController Remote 命名空间, 完全建立在 credentials.describe / credentials.set / credentials.unset 之上——值只在这个链路上 恰好跨过一次,入站、在 set 上,而且永远不再回传。zod schema 镜像 seam 的 credentialRef 守卫 (坏名字在到达服务前就以 bad-request 失败),describe 视图只携带 { configured, source, writable }, RPC 本身运行在 @deepseek-ai/dsh-typert-protocol 之上。Host 不把凭证注入出站请求——那是 LLM 适配器通过 ctx.credentials 的职责。

被脱敏的内容 ​

  • 链路上:凭证值只出现在 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 的 notifyUpdated fan-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/api/settings-controller/src/credentials.ts——Host 的 credentialsController RPC 域。