夜雨聆风学习资料网

ARTICLE · 1008488

DeepSeek Harness 源码-Credentials 密钥与凭证管理

DeepSeek Harness 源码-Credentials 密钥与凭证管理

DeepSeek Harness 源码解析系列

第 32 讲:Credentials 密钥与凭证管理

基于 DeepSeek Harness 源码 · 2026-09-15

💡 本讲一句话:Harness 里配置只存"密钥的名字"(环境变量名),真正的值由凭证服务按四层优先级现场解析——继承环境 > 托管存储 > 项目 .env > 用户 .env;改一次 key,下一次请求立刻生效,全程不重启、不把值写进任何配置文件。

一、引用与值分离:凭证层的总设计

先回答一个容易踩坑的问题:Harness 把 API key 存在哪?答案是——它不存值,只存引用。配置面(settings、cordis.yml)里出现的永远是 DEEPSEEK_API_KEY 这样的环境变量名,真正的密钥值由凭证服务在每次操作时现场解析。这个"引用/值分离"的接缝(seam)定义在 @deepseek-ai/dsh-credentials 包里,只有 145 行,却把四条铁律写进了抽象基类:

🔹 引用必须是合法的 POSIX shell 标识符(/^[A-Za-z_][A-Za-z0-9_]*$/),否则在接缝上不可寻址;

🔹 空值即未配置:空白字符串永远不能伪装成"已配置的密钥";

🔹 解析是每次调用一次的,消费者禁止跨操作缓存——这正是"改 key 立即生效、无需重启"的来源;

🔹 describe() 只返回"配置状态/来源层/可写性",绝不返回值本身——UI 能看到 key 存在哪一层,但看不到 key。

先看接缝的入口:引用品牌化函数和两个结果类型(packages/credentials/credentials/src/index.ts):

📄 packages/credentials/credentials/src/index.ts (第 16-46 行)

const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ // 引用必须是合法 POSIX shell 标识符——它最终要能当环境变量名用,带空格/点号的"引用"在接缝上不可寻址// 品牌化:把裸字符串提升为 CredentialRef(名义类型),让"随便一个字符串"和"凭证引用"在类型层面分开export function credentialRef(value: string): CredentialRef { // 入口校验函数,非法引用在这里就 fail loud,而不是等到 resolve 时才炸  if (!REF_PATTERN.test(value)) { // 不匹配标识符规则(如 "MY KEY"、"a.b")直接拒绝    throw new TypeError(`credential ref "${value}" must match ${String(REF_PATTERN)}`) // 报错信息带上正则本身,调用方一眼知道合法格式是什么  }  return value as CredentialRef // 校验通过即品牌化返回——运行时零开销,约束全部落在类型系统里}// resolve() 的返回值:值 + 来源层 id(env / file / project-env / user-env),消费者据此知道这个 key 是谁给的export interface ResolvedCredential { // 接口定义开始  value: string // 非空的密钥值——空值在接缝规则里等于"未配置",根本不会出现在这里  source: string // provider 自定义的来源层 id:本地 provider 用 env、file、project-env、user-env 四种}// describe() 的返回值:给配置 UI 用的元数据——注意它只有三个字段,唯独没有 valueexport interface CredentialInfo { // "这个 key 配没配、从哪来、能不能写"三件事,UI 需要知道的全部事实  configured: boolean // resolve() 当前是否会返回一个值(空字符串不算配置)  source?: string // 当前供值的来源层;未配置时该字段缺席——可选类型本身就是语义  writable: boolean // set() 当前能否成功:继承环境是只读层,写它会被拒绝}// 模块声明合并:把 credentials 挂到 Cordis Context 上,之后任何插件 ctx.credentials 直接可用declare module '@deepseek-ai/cordis' { // 不新增文件、不改 cordis 源码,纯类型层面扩展  interface Context { // 给 Context 接口追加一个成员    credentials: CredentialProvider // 接缝服务——provider 实现四个抽象操作后注册到这里  }} // 声明合并结束

为什么这样设计:把"引用合法性"做成入口函数而不是散落在各 provider,是因为接缝上所有操作(resolve/set/unset/describe)都要求同一个约束——集中校验一次,任何实现者无法绕过。而 CredentialInfo 刻意不含 value,是安全边界:配置 UI、日志、诊断信息可以随便打印 describe() 的结果,永远不会泄密。注释里那句"key name is safe to print, a value is not"贯穿了整个本地 provider。

抽象基类定义了四个操作和一条全接缝规则(同文件第 60-99 行):

📄 packages/credentials/credentials/src/index.ts (第 60-99 行)

export abstract class CredentialProvider extends Service { // 抽象基类:provider 实现四个操作,接缝规则约束所有实现  constructor(ctx: Context) { // 构造时绑定 Cordis 上下文    super(ctx, 'credentials') // 注册服务名为 credentials——ctx.credentials 由此而来  }  // resolve:把引用解析成当前值。注意 JSDoc 强调"per call"——消费者每次操作都要重新解析,禁止跨操作缓存  abstract resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> // 未配置时返回 undefined(而不是空串)——空值即未配置的规则在这里兑现  // describe:给配置面看元数据,不暴露值——UI 能显示"已配置/来源 env/只读",但永远看不到 key 本身  abstract describe(ref: CredentialRef): Promise<CredentialInfo> // 返回 configured/source/writable 三件套  // set:把值持久化到 provider 管理的可写源。两条拒绝规则写在契约里:  abstract set(ref: CredentialRef, value: string): Promise<void> // ① 只读层遮蔽该引用时拒绝(写了也白写,不如 fail loud);② 空值拒绝(要删请用 unset)  // unset:从可写源删除引用;删一个不存在的引用是 no-op——幂等语义  abstract unset(ref: CredentialRef): Promise<void> // 与 set 一样,被只读层遮蔽时同样拒绝} // 基类结束——四个抽象方法 + 受保护的 notifyUpdated(下一节细讲)

为什么这样设计:"set 在只读层遮蔽时拒绝"这条规则最容易被省略——很多实现会静默写入、让 resolve 继续返回旧值,用户以为改成功了其实没生效。Harness 选择 fail loud:宁可报错说"这个 key 由启动环境提供,去 shell 里 unset 它",也不制造"写成功但无效"的假象。

二、四层优先级:同一个 key,谁说了算

本地 provider(@deepseek-ai/dsh-credentials-local)把值放在 $DSH_HOME/.credentials.yaml,并与环境分层。文件头部的注释(第 1-36 行)把优先级和理由写得像设计文档:

📄 packages/credentials/credentials-local/src/index.ts (第 5-34 行,文件头注释)

* 继承的进程环境            (只读,最高优先级) // DEEPSEEK_API_KEY=… dsh、CI secret、容器 -e——这是本次运行的显式意图* > $DSH_HOME/.credentials.yaml(provider 管理,可写) // Models 页面写入的 key 落在这里;它压过所有 .env* > <启动目录>/.env          (只读回退) // 项目自带的 key:产品信任"被启动的那个项目"* > $DSH_HOME/.env           (只读回退) // 用户主目录的兜底,优先级最低// 为什么继承环境最高?因为它从进程内部不可编辑——所以必须"可见地只读"而不是静默遮蔽写入// 为什么托管存储压过 .env?Models 页面写的 key 要立即生效,不能被一个旧 .env 里的老 key 顶掉// 为什么项目 .env 排在用户 .env 之上?更具体的位置赢——与环境变量分层同构

resolve() 的实现就是这四层顺序的直译(第 309-317 行):

📄 packages/credentials/credentials-local/src/index.ts (第 309-365 行)

override resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> { // 每次调用都走一遍四层——这就是"改 key 立即生效"的实现  const inherited = this.inherited(ref) // 第一层:继承的进程环境(launchEnvironmentOf 从 process 源取)  if (inherited !== undefined) return Promise.resolve({ value: inherited, source: 'env' }) // 命中即返回,source 标 env——只读层的标记  const stored = this.values.get(ref) // 第二层:托管存储的内存快照(.credentials.yaml 解析结果)  if (stored !== undefined) return Promise.resolve({ value: stored, source: 'file' }) // 命中即返回,source 标 file——可写层的标记  const fallback = this.dotenvFallback(ref) // 第三、四层:项目 .env 优先于用户 .env(getFrom 按 ['project-env','user-env'] 顺序找)  if (fallback !== undefined) return Promise.resolve({ value: fallback.value, source: fallback.source }) // 命中即返回,source 由 launch-environment 给出  return Promise.resolve(undefined) // 四层全空 = 未配置——注意是 undefined 而不是空串}// describe:与 resolve 同序,但只报元数据。关键差异在 writable:override describe(ref: CredentialRef): Promise<CredentialInfo> { // UI 问"这个 key 什么状态"时走这里  if (this.inherited(ref) !== undefined) { // 继承环境命中——唯一不可写的层,因为本进程改不了它    return Promise.resolve({ configured: true, source: 'env', writable: false }) // writable:false 让 UI 把输入框置灰并提示"去 shell 里 unset"  }  const stored = this.values.get(ref) // 托管存储命中——可写,set() 会直接覆盖它  if (stored !== undefined) return Promise.resolve({ configured: true, source: 'file', writable: true }) // 页面写入的 key 永远可以再次修改  const fallback = this.dotenvFallback(ref) // .env 回退命中——"可写"的含义是:存一个值就能把它顶掉  if (fallback !== undefined) return Promise.resolve({ configured: true, source: fallback.source, writable: true }) // 不直接改 .env,而是用托管存储压过它  return Promise.resolve({ configured: false, writable: true }) // 未配置但可写——UI 显示"请输入"}// set/unset:入口校验后都汇入 write()(下一节细讲)override async set(ref: CredentialRef, value: string): Promise<void> { // 存值  if (value.length === 0) { // 空值拒绝——接缝规则"空白不能伪装成已配置密钥"    throw new Error(`credentials-local: an empty value cannot be stored for "${ref}"; use unset`) // 明确指路:要删就用 unset,别存空串  }  await this.write(ref, value) // 委托给带锁的行编辑路径}override async unset(ref: CredentialRef): Promise<void> { // 删值  await this.write(ref, undefined) // value=undefined 即删除语义——与 set 共用同一条写入链路}// enqueue:把操作排进单条独占链。这是并发正确性的核心:private enqueue<T>(operation: () => Promise<T>): Promise<T> { // watcher 重载和行编辑都走这里,保证一次只有一个文档操作在跑  const task = this.operations.then(operation) // 新任务接在当前链尾——按入队顺序串行执行  this.operations = task.then(() => undefined, () => undefined) // 链本身吞掉成败:一个失败的操作不能毒死整条队列,后续操作照常排队  return task // 调用方拿到的是带原始成败的 Promise——失败该抛给谁抛给谁}// queueRefresh:watcher 事件触发的重载入队。注意它 catch 了错误:private queueRefresh(): void { // 热重载是"尽力而为",不能因为一次坏文档就永久停摆  void this.enqueue(() => this.refresh()).catch((error: unknown) => { // 只有 INVARIANT 违规才会从 refresh 逃逸到这里(refresh 内部已吞掉普通错误)    this.ctx.logger.error('credentials-local: reload commit failed at %s', this.spec.filename) // 记 error 级日志——一个被毒化的提交不能悄悄终结热重载    this.ctx.logger.error(error) // 把完整错误对象也打出来,方便定位是哪个监听器炸的  })} // 队列保持存活:下一次 watcher 事件仍会触发新的 refresh

为什么这样设计:enqueue 里那行 task.then(() => undefined, () => undefined) 是全文件最微妙的一行——它把"任务的结果"和"队列的存活"解耦了:某个操作失败(比如 set 时权限不对)会抛给调用方,但不会让链上后续的重载/写入永远挂起。而 describe 对 .env 层报 writable:true 看似矛盾(.env 明明是只读回退),实际语义是"存一个值就能顶掉它"——可写性描述的是 set() 的效果,不是文件本身。

🔑 优先级口诀:进程环境(只读,赢)> .credentials.yaml(可写,压 .env)> 项目 .env > 用户 .env。记住"谁不可编辑谁就最高、谁被产品管理谁就能顶掉回退",四层顺序就不会记混。

三、安全校验:权限位与文档解析

存密钥的文件,第一件事不是读内容而是查权限。assertOwnerOnly()(第 103-127 行)在读取任何内容之前拒绝 group/other 可读的文档:

📄 packages/credentials/credentials-local/src/index.ts (第 87-127 行)

const GROUP_OTHER_BITS = 0o077 // group/other 的全部权限位——凭证文档必须一位都不给它们// 在读取内容之前拒绝"其他 OS 用户可读"的文档:provider 自己创建/替换文件时都是 0600,// 但手写或外部生成的文件带着 umask 给的任意权限——静默服务一个 world-readable 的文件会让承诺的 mode 失去意义async function assertOwnerOnly(filename: string): Promise<void> { // 启动、每次重载、每次写入前都会调它(外部编辑器/备份恢复可能事后放宽权限)  let mode: number // 文件当前权限位,下面从 stat 结果里取  try { // 进入 stat:文件不存在会抛 ENOENT,由下面 catch 分流    mode = (await stat(filename)).mode // 只查元数据不读内容——权限不对时连密钥文本都不该被打开  } catch (error) {    if (!isENOENT(error)) throw error // 非"不存在"的失败(如 EACCES)必须上浮——不能把权限错误误判成空存储    await canonicalizeWatchPath(filename) // 文件不存在:规范化路径(建父目录、解析符号链接),为后续创建/watcher 做准备    return // 没有文件就没有可泄露的权限问题,直接放行  } // catch 分支结束——stat 成功时跳过这里继续判权限位  if (process.platform === 'win32') return // Windows 没有 POSIX mode 可查——跳过而不是假装检查(ACL 在此不可表达)  const offending = mode & GROUP_OTHER_BITS // 取出 group/other 位:0o644、0o604 这类都会命中  if (offending === 0) return // 纯 owner 权限(如 0o600)——放行  throw new Error( // 有越权位:fail loud,并直接给出修复命令    `credentials-local: ${filename} is readable beyond its owner (mode ${(mode & 0o777).toString(8)});` // 报错里用八进制显示实际 mode,用户一眼看出问题在哪一位    + ` run "chmod 600 ${filename}" before starting again`, // 把修复动作写进错误信息——诊断和处方一步到位  )}// 判断文件系统错误是否"文件不存在":所有非 ENOENT 的失败都必须上浮,不能吞function isENOENT(error: unknown): boolean { // 全文件共用的判别器(loadInitial/reconcileFromDisk 都靠它区分"空存储"和"读失败")  return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' // 可选链兜住 error 为 null/非对象的情况} // ENOENT = 文件不存在,是凭证场景里唯一可以安全当作"空"的错误码

为什么这样设计:"读内容前先查权限"的顺序很关键——如果先 readFile 再检查 mode,密钥文本已经进了内存和调用栈。而 Windows 上选择跳过而不是伪造检查,是诚实的降级:宁可少一道防线,也不给"已检查"的假安全感。

文档解析(parseCredentialsDocument,第 154-186 行)把"这个文件只装凭证"做成了硬约束:

📄 packages/credentials/credentials-local/src/index.ts (第 135-204 行)

// 描述一个 YAML 解析错误但不引用源码——parser 的 message 会内嵌出错行,而这一行在这里是密钥function describeYamlError(error: YAMLError): string { // 只让 code + 行列号离开这个函数  const at = error.linePos?.[0] // prettyErrors 保证每个错误都有 linePos;可选链只是回应它的可选类型  const where = at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}` // 拼出"第几行第几列"——定位信息不含任何值  return `${error.code}${where}` // 返回形如 "BAD_ALIAS at line 3, column 5" 的安全描述}// 把文档解析成条目。严格映射 CredentialRef→非空字符串:根不是 mapping、key 不是 POSIX 标识符、// value 不是字符串或为空——全部拒绝而不是跳过,因为静默忽略一条目等于告诉用户"你存的 key 没生效"export function parseCredentialsDocument(text: string, filename: string): Map<string, string> { // 返回按引用索引的条目表  const document = parseDocument(text, { prettyErrors: true, uniqueKeys: true }) // prettyErrors 只为拿 linePos;uniqueKeys 让重复 key 直接成为解析错误(而不是后者覆盖前者)  if (document.errors.length > 0) { // 有解析错误:整份文档不可信    throw new Error(`credentials-local: invalid document at ${filename}: ${ // 注意拼进错误的只有 code+行列号,绝不引用出错行本身——那一行是密钥      document.errors.map(describeYamlError).join('; ')}`) // 多个错误用分号串起来一次报全,避免用户修一个炸一个  }  const root: unknown = document.toJS() ?? {} // 转成 JS 对象;空文档(null)归一成空对象——空存储是合法状态  if (typeof root !== 'object' || root === null || Array.isArray(root)) { // 根必须是 mapping:标量、数组都拒绝    throw new TypeError(`credentials-local: ${filename} must be a mapping of credential reference to value`) // 报错说清期望形状——"引用到值的映射"  }  const entries = new Map<string, string>() // 结果容器:key=引用名,value=密钥值  for (const [key, value] of Object.entries(root as Record<string, unknown>)) { // 逐条校验——任何一条不合法整份文档作废    credentialRef(key) // key 必须是 POSIX 标识符:复用接缝的入口校验,保证存储的引用在接缝上可寻址(返回值丢弃,只要它的副作用)    if (typeof value !== 'string') { // 值必须是字符串——数字/布尔/嵌套对象都拒绝      throw new TypeError(`credentials-local: the value for "${key}" in ${filename} must be a string`) // 只引用 key 名不引用 value:类型错误的条目仍然是用户想存的密钥    }    if (value.length === 0) { // 空字符串拒绝——接缝规则"空白不能伪装成已配置"      throw new Error(`credentials-local: the value for "${key}" in ${filename} is empty; remove the key instead`) // 处方明确:删掉这个 key,而不是存一个空值    }    entries.set(key, value) // 全部校验通过才入表——要么整份可信,要么整份拒绝  }  return entries // 返回解析结果;调用方(loadInitial/reconcileFromDisk)决定何时替换内存快照}// 渲染"改了一个引用之后"的下一版文档文本。编辑已解析的 Document 树而不是重建,// 是为了保住注释和所有未动条目的格式——行级编辑,不是整文件重写function renderDocument(text: string | undefined, ref: CredentialRef, value: string | undefined): string { // text=undefined 表示文件尚不存在,从空白文档开始  const document = text === undefined ? new Document({}) : parseDocument(text) // text 只缓存过"解析成功的内容",所以这里的再解析不可能失败(注释里写明了这个前提)  if (value === undefined) document.deleteIn([ref]) // value=undefined 即删除语义:从树里摘掉该 key  else document.setIn([ref], value) // 否则设置/覆盖该 key——其他条目原样保留  return document.toString() // 序列化回文本:注释、缩进、顺序都来自原 Document 树} // set/unset 共用这一个渲染函数,保证两种操作对文档的触碰面完全一致

为什么这样设计:这里有一条贯穿全文件的信息安全规则——key 名可以进错误信息,value 永远不能。YAML parser 的原始 message 会引用出错行(即密钥本身),所以 describeYamlError() 只取 code 和行列号;类型错误的报错也只引 key 不引 value。而 renderDocument() 选择"编辑 Document 树"而非"重新生成 YAML",是因为用户手写的注释和格式是资产——set 一个 key 不该把整个文件重排。

四、写入路径:行编辑 + 文件锁 + 原子写

set/unset 最终都汇入 write()(第 369-417 行)。这条链路把"多进程并发写同一个 YAML"做成了安全操作:

📄 packages/credentials/credentials-local/src/index.ts (第 369-417 行)

// write:一次行编辑的完整链路。入口检查尽早拒绝,入队后在运行时再判一遍——因为排队期间环境可能变了private async write(ref: CredentialRef, value: string | undefined): Promise<void> { // set 传值、unset 传 undefined,共用此函数  const verb = value === undefined ? 'unset' : 'set' // 错误信息里用动词区分操作类型,用户能看出是哪个动作失败  if (this.isClosed()) { // provider 已 dispose:拒绝新写入(isClosed() 是独立方法,因为 TS 控制流无法跨 await 收窄字段)    throw new Error(`credentials-local is disposed: cannot ${verb} "${ref}"`) // fail loud——disposed 后的写入没有意义  }  this.assertUnshadowed(ref, verb) // 入口先查一次遮蔽:继承环境供值时 set/unset 都会被拒绝(见下方 assertUnshadowed)  return this.enqueue(async () => { // 排进独占操作链——与 watcher 重载串行,保证"读-改-写"不被并发刷新撕开    if (this.isClosed()) { // 运行时再判一次:排队期间 provider 可能已被 dispose      throw new Error(`credentials-local was disposed before the queued "${ref}" ${verb} ran`) // 错误信息点明时序——是"排队时还活着、执行时已死"    }    this.assertUnshadowed(ref, verb) // 运行时再查一次遮蔽:环境可能在排队期间变化(如父进程改了 env)    await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 }) // 文件锁的独占创建需要父目录存在;0700 因为 harness home 装的是用户私有数据    await withFileLock(this.spec.filename, async () => { // 跨进程写者锁:另一个 dsh 实例正在写同一文档时,这里排队等待      await this.reconcileFromDisk() // 读-改-写的"读":合并磁盘上本进程尚未观察到的状态——debounce 窗口内的外部编辑、watcher 漏掉的变化、其他进程的写入      const existing = this.values.get(ref) // 取当前快照里该引用的值,用于 no-op 判断      if (value === undefined && existing === undefined) return // unset 一个本来就不存在的 key:直接返回,不碰文件(幂等)      const nextText = renderDocument(this.text, ref, value) // "改":在 Document 树上编辑这一个引用,注释和其余条目格式原样保留      await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 }) // "写":原子替换(临时文件+rename),0600 保证密钥文档永不被 world-readable      this.text = nextText // 更新文本缓存——watcher 随后触发的重载会因内容相同而 no-op(自写抑制)      if (value === undefined) this.values.delete(ref) // unset:从内存快照删除该引用      else this.values.set(ref, value) // set:更新内存快照——resolve() 下一次调用立即看到新值      this.notifyUpdated(ref) // "提交后"才扇出事件:坏观察者不能让一次持久化写入看起来失败(INVARIANT 违规除外,仍会重抛)    })  })}// assertUnshadowed:拒绝会被继承环境遮蔽成"看似无效"的写入。只有这一层能遮蔽:// 本 provider 解析的其他所有层都排在被写文档之下private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void { // set/unset 共用——对 unset 同样成立:删了存储值,resolve 仍会返回环境里的旧值  if (this.inherited(ref) !== undefined) { // 继承环境正在供这个 key:写入会被它压住    throw new Error( // fail loud + 处方:      `credentials-local: "${ref}" is supplied read-only by the launching environment, so ${verb} would be` // 说明因果——是"启动环境只读供给"导致操作无效      + ' shadowed; unset it in the shell you start dsh from instead', // 指路:去启动 dsh 的那个 shell 里 unset,而不是在这里硬写    )  }} // 不抛错静默写入 = 用户以为改成功了、实际 resolve 永远返回旧值——Harness 拒绝这种假象

为什么这样设计:write() 里有两次 assertUnshadowed(入队前 + 运行时)和两次 isClosed 检查——因为 enqueue 是异步的,排队期间世界会变。而 reconcileFromDisk() 在锁内先跑,是为了处理"watcher debounce 窗口内的外部编辑":如果直接基于内存快照渲染再写盘,就会把别人刚改的值覆盖回去(丢失更新)。文件锁 + 锁内重读,让多进程并发写退化成串行。

五、热重载:watcher、竞态窗口与失败策略

外部编辑 .credentials.yaml(比如另一个终端里 vim)要能热生效,靠 chokidar watcher + 一次"ready 对账"补竞态窗口(第 265-307 行):

📄 packages/credentials/credentials-local/src/index.ts (第 265-307 行)

async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> { // Cordis 服务生命周期:yield 的函数是 disposer,按 LIFO 顺序执行  yield async () => { // 第一个 disposer(最后执行):drain——拒绝新操作,然后等队列里的在途操作跑完    this.closed = true // 置位后 write() 入口直接抛错、watcher 回调直接 return    await this.operations // 等独占链排空——dispose 只在存储静默后才算完成  } // drain disposer 结束  await this.loadInitial() // 启动读取:文件不存在=空存储;存在但非法=插件激活失败(见 loadInitial)  if (!this.spec.watch) return // watch=false:不装 watcher,直接结束 init(disposer 照常生效)  const watcher = chokidarWatch(await canonicalizeWatchPath(this.spec.filename), { // 监听规范化后的路径(符号链接/相对路径都先解析掉)    ignoreInitial: true, // 忽略"开始监听时已存在"的初始事件——避免启动即触发一次无意义重载    awaitWriteFinish: { // 等写入稳定再报事件:编辑器通常分多次写盘(备份、临时文件、rename)      stabilityThreshold: this.spec.debounceMs, // 内容连续 N ms 不变才算"写完",默认 100ms      pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)), // 轮询间隔取 debounce 与 10ms 的较小值、下限 1ms——别比稳定窗口还慢    }, // awaitWriteFinish 配置对象结束  })  watcher.on('all', () => { // 任意文件事件(change/add/unlink)都走同一条路:排队重载    if (this.closed) return // disposed 后丢弃事件——不再接受任何外部输入    this.queueRefresh() // 入队串行执行;多个事件在 debounce 窗口内合并成一次或几次 refresh  })  watcher.on('ready', () => { // ready = watcher 完成初始扫描、开始真正监听    if (this.closed) return // disposed 后同样丢弃    this.queueRefresh() // 关键竞态补丁:loadInitial 的读取与 watcher 激活之间写进来的变化不会触发任何事件——ready 时做一次对账把这个窗口补上  })  watcher.on('error', (error) => { // watcher 自身错误(如路径不可访问):只告警不崩溃    this.ctx.logger.warn('credentials-local: watcher error on %s', this.spec.filename) // warn 级——热重载是增强能力,不是进程存亡问题    this.ctx.logger.warn(error) // 完整错误对象另起一行打出来  })  yield async () => { // 第二个 disposer(先执行):quiesce——停止接收事件、关 watcher、等在途操作    this.closed = true // 先关门:之后的 'all'/'ready' 回调全部 no-op    await watcher.close() // 关闭 chokidar,释放 inotify/FSEvents 句柄    await this.operations // 再等独占链排空——保证 dispose 之后没有任何事件被发布出去  }} // init 结束:两个 disposer 按 LIFO(先 quiesce 后 drain)执行

为什么这样设计:ready 事件里那次 queueRefresh() 是最容易被省略的——"启动读取和 watcher 激活之间写进来的变化"是一个真实的竞态窗口,不补的话用户会看到"我明明改了文件但没生效"。而 refresh/reconcileFromDisk 的失败策略是分场景的:

📄 packages/credentials/credentials-local/src/index.ts (第 424-493 行)

// loadInitial:启动读取。文件不存在=空存储(合法);存在但非法=激活失败private async loadInitial(): Promise<void> { // 插件启动时调用一次——此时"不可信文档"必须让插件死掉,而不是被当成"没存凭证"  await assertOwnerOnly(this.spec.filename) // 先查权限再读内容(见第三节)  let text: string // 文件文本;读取失败且非 ENOENT 时直接抛出  try { // 读文件:ENOENT=空存储,其他错误上浮    text = await readFile(this.spec.filename, 'utf8') // 读全文——凭证文档很小,整读无压力  } catch (error) {    if (!isENOENT(error)) throw error // EACCES/EBUSY 等必须上浮:把"读不了"当"没存"会静默丢失用户已配置的 key    return // ENOENT = 文件不存在 = 空存储,正常返回  } // catch 分支结束——到这里 text 一定已读到  this.values = parseCredentialsDocument(text, this.spec.filename) // 解析失败(非法文档)在这里抛错 → 插件激活失败:存在但不可信的凭证文档绝不能被当作"无凭证"  this.text = text // 缓存原文——后续行编辑基于它渲染,watcher 重载用它做 no-op 判断}// refresh:watcher 事件后的重载。策略=warn-and-keep:坏文档保留最后好快照,绝不让热重载拖死进程private async refresh(): Promise<void> { // 由 queueRefresh 入队调用——与写入串行  if (this.closed) return // disposed 后直接返回(双保险:queueRefresh 的 catch 也不会再触发)  try {    await this.reconcileFromDisk() // 真正的重载逻辑在 reconcileFromDisk(见下)  } catch (error) {    if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error // INVARIANT 违规不是"重载失败"而是契约破坏——必须上浮到队列错误面,不能吞    this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename) // warn:保留最后好快照继续服务(last-good 语义)    this.ctx.logger.warn(error) // 完整错误另起一行——用户能看到是哪个 key/哪行坏了  } // catch 结束:普通失败已告警,INVARIANT 已在上面重抛} // 对比 loadInitial:启动时坏文档=激活失败;运行中坏文档=告警+保留旧值。同一份解析代码,两种失败策略// reconcileFromDisk:比对磁盘文本与缓存,有差异就发布到接缝。核心 no-op 判断:private async reconcileFromDisk(): Promise<void> { // 三个调用方——refresh(重载)、write(锁内重读)、ready 对账——共用  await assertOwnerOnly(this.spec.filename) // 每次重载/写入前都重查权限:外部编辑器或备份恢复可能在启动后放宽了 mode  let text: string | undefined // undefined = 文件不存在(发布空存储)  try { // 读磁盘当前内容:ENOENT=被删,其他错误上浮    text = await readFile(this.spec.filename, 'utf8') // 读当前磁盘内容  } catch (error) {    if (!isENOENT(error)) throw error // 非 ENOENT 上浮:调用方各自决定策略(refresh 吞掉告警,write 直接失败)    text = undefined // ENOENT → 文件被删了  }  if (text === this.text || this.isClosed()) return // 核心 no-op:磁盘文本与缓存相同=无变化(包括本 provider 自己的写入——自写抑制);disposed 后不再发布任何事件  const next = text === undefined ? new Map<string, string>() : parseCredentialsDocument(text, this.spec.filename) // 文件被删→空存储;否则严格解析(非法文档在这里抛错,由调用方定策略)  const changed = this.changedRefs(this.values, next) // 算出"值变了"的引用集合——只给真正变化的 key 发事件  this.text = text // 先更新缓存……  this.values = next // ……再整体替换快照(不是增量合并):被删的条目不会残留在内存里  for (const ref of changed) this.notifyUpdated(ref) // 最后扇出 credentials/updated——只发变化过的引用,监听器能精确知道谁变了}// changedRefs:两个快照之间"值不同"的条目。key 合法性已由 parser 保证(credentialRef 校验过)private changedRefs(prev: Map<string, string>, next: Map<string, string>): CredentialRef[] { // 返回需要发事件的引用列表  const changed: CredentialRef[] = [] // 结果容器  for (const key of new Set([...prev.keys(), ...next.keys()])) { // 取两个快照 key 的并集——新增、删除、修改都要覆盖到    if (prev.get(key) === next.get(key)) continue // 两边都没有(undefined===undefined)或值相同:跳过,不发事件    changed.push(credentialRef(key)) // 值不同(含一边缺失):加入变化列表;parser 已证明 key 可寻址,这里品牌化不会抛错  } // 并集遍历结束——新增/删除/修改三类变化都覆盖到了  return changed // 返回精确的变化集合——"文件动了但内容没变"不产生任何事件} // 自写抑制 + 精确事件 = watcher 风暴下监听器也不会被无意义地唤醒

为什么这样设计:"整体替换快照"(this.values = next)而不是增量 diff-merge,是为了让"删除条目"自然生效——增量合并很容易漏掉"该删没删"。而 changedRefs() 只发真正变化的 key,配合 text === this.text 的 no-op 判断,自己写入触发的 watcher 事件、编辑器保存但内容未变的事件,都不会惊动任何监听器。

六、事件扇出:坏观察者不能推翻已提交的写入

credentials/updated 事件的发布逻辑 notifyUpdated()(接缝基类第 115-142 行)是"提交后通知 + 失败隔离"的教科书实现:

📄 packages/credentials/credentials/src/index.ts (第 115-142 行)

protected notifyUpdated(ref: CredentialRef): void { // 扇出 credentials/updated:每个监听器都跑,单个失败不改变已提交操作的结果  let invariantFailure: unknown // 暂存 INVARIANT 违规——它要等"所有监听器都跑完"再重抛(见循环后)  const args = ['credentials/updated', ref] // 事件名 + 引用,作为 dispatch 的参数对  for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) { // 向 Cordis 事件系统取监听器列表('emit' 模式)    try {      const returned = listener(ref) // 同步调用;返回值可能是 Promise(异步监听器)      if (returned != null && typeof (returned as PromiseLike<unknown>).then === 'function') { // duck-typing 判断"是 thenable"        void Promise.resolve(returned as PromiseLike<unknown>).then(undefined, (error: unknown) => { // 异步拒绝:挂一个 rejection 处理器,不让它变成 unhandledRejection          this.warnListenerFailure(ref, error) // 记 warn 日志——异步失败被"包含"在诊断里,不影响已提交的写入        }) // thenable 分支结束:同步/异步两条失败路径都收敛到 warn      }    } catch (error) { // 同步抛出:当场捕获      if ((error as { code?: unknown } | null)?.code === 'INVARIANT') { // INVARIANT = 契约级违规(如测试断言接缝不变量),单独走重抛路径        invariantFailure ??= error // 只记第一个——但注意 continue,后面的监听器仍要跑完        continue // 不立即抛:保证"每个监听器都执行过"再上报      } // INVARIANT 分支结束——普通失败落到下面统一告警      this.warnListenerFailure(ref, error) // 普通同步失败:同样只告警、不上浮    }  }  if (invariantFailure !== undefined) throw invariantFailure as Error // 所有监听器跑完后才重抛 INVARIANT——调用方(write/refresh)能感知契约破坏} // 时序保证:普通失败→日志;INVARIANT→全量执行后重抛。两种情况都不会让"已落盘的写入"看起来失败// warnListenerFailure:同步/异步两条失败路径共用的诊断输出private warnListenerFailure(ref: CredentialRef, error: unknown): void { // 统一格式,方便日志检索  this.ctx.logger.warn('credentials: a credentials/updated listener for "%s" failed', ref) // 第一行说清哪个引用的哪个监听器坏了  this.ctx.logger.warn(error) // 第二行打完整错误对象(堆栈)} // 注意:这里只 warn——凭证写入本身已经成功,观察者坏掉是观察者的问题

为什么这样设计:顺序很讲究——先落盘、后通知(write() 里 notifyUpdated 在 writeFileAtomic 之后),所以监听器再炸也不能回滚已提交的写入;而 INVARIANT 违规要等所有监听器跑完才重抛,是为了让测试里的多个断言监听器都有机会执行。JSDoc 还点出一个微妙约束:异步监听器的 INVARIANT 失败无法重抛(Promise 已经返回了),所以"这个事件上的不变量检查不能写成 async 函数"——契约把限制写在了文档里。

七、消费方视角:LLM provider 怎么用接缝

理论落地看一个真实消费者——llm-deepseek 在每次建连时解析 API key(packages/llm/llm-deepseek/src/index.ts 第 225-246 行):

📄 packages/llm/llm-deepseek/src/index.ts (第 225-246 行)

const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise<string> => { // 每次建连都重新解析——"改 key 立即生效"在消费端的体现  const ref = connection.apiKeyEnv // 引用来自本次连接的配置快照(默认 DEEPSEEK_API_KEY):被拒绝的 settings 代次不能把它的 key 泄漏到上一个 endpoint  const credentials = ctx.get('credentials') // 用 ctx.get 而非 ctx.credentials——接缝是可选服务,可能没装  if (credentials !== undefined) { // 有接缝:走四层优先级解析(env → file → project-env → user-env)    const hit = await credentials.resolve(ref) // 现场解析一次,不缓存    if (hit !== undefined) return assertUsableApiKey(hit.value, 'llm-deepseek', ref) // 命中:再做一次"可用 key"断言(格式/前缀检查)后返回  } else { // 接缝未安装:降级到纯环境读取    const ambient = launchEnvironmentOf(ctx).get(ref) // 没有接缝时环境就是整个凭证平面——没有托管存储可分层,直接读进程环境    if (ambient !== undefined && ambient.value.length > 0) { // 命中且非空(空值即未配置的规则在消费端同样执行)      return assertUsableApiKey(ambient.value, 'llm-deepseek', ref) // 返回可用的 key    } // 环境命中分支结束——未命中则落到下面的统一报错  } // if/else 结束:两条来源路径都试过了  throw new LlmError( // 四层全空:fail loud,错误信息直接给出两条修复路径    `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials` // 路径一:通过凭证服务存(Web Models 页面会写 .credentials.yaml)    + ` service (the web Models page writes it), or export ${ref} in the launching environment`, // 路径二:在启动环境里 export——两条路都指向接缝的合法来源    'MISSING_CREDENTIAL', // 错误码 MISSING_CREDENTIAL——调用方可按码分支处理,而不是解析文案  ) // LlmError 构造结束:消息+错误码齐备} // 消费端零存储逻辑:它只认"引用",值的来源、优先级、热更新全部由接缝负责

为什么这样设计:消费端拿到的永远是 ctx.get('credentials')(可选服务)而不是硬依赖——接缝没装时优雅降级到纯环境读取。而"每次建连重新 resolve"正是接缝 JSDoc 里"per call, must not cache"的消费端兑现:用户在 Models 页面改完 key,下一个请求就带新 key,无需重启进程。

八、四层来源对照表

来源层
source id
优先级
可写性(describe)
set() 行为
典型场景
继承进程环境
env
最高(赢)
只读(writable:false)
拒绝:assertUnshadowed 抛错,指路去 shell unset
DEEPSEEK_API_KEY=… dsh、CI secret、容器 -e
托管存储 .credentials.yaml
file
第二(压所有 .env)
可写
锁内行编辑 + 原子写 0600,立即生效并发事件
Web Models 页面写入的 key
项目 .env(启动目录)
project-env
第三
"可写"(存值即顶掉它)
不直接改 .env,写入托管存储压过它
仓库自带的开发 key(产品信任被启动的项目)
用户 .env($DSH_HOME)
user-env
最低(兜底)
"可写"(存值即顶掉它)
同上——托管存储永远压过回退层
用户主目录的旧 key、跨项目兜底
四层全空
—
未配置
writable:true(可首次写入)
正常写入,创建/更新文档
全新安装、key 被 unset 后

再看三条跨层规则:

🔹 空值即未配置:set() 拒绝空串、parseCredentialsDocument() 拒绝空值条目、resolve() 把空环境值当 undefined——三处同一语义,空白永远不能伪装成密钥;

🔹 只读层遮蔽写入即拒绝:只有继承环境能压住托管存储(其他层都在它之下),所以 assertUnshadowed() 只需查这一层;

🔹 key 名可进日志,value 永不进日志:从 describeYamlError() 到 set() 的报错文案,错误信息里出现的永远是引用名。

九、数据流图

读路径(resolve,每次操作一次)

① 消费端取引用:connection.apiKeyEnv → credentialRef

LLM provider 每次建连都重新解析,不缓存——改 key 立即生效的起点。

▼

② 第一层:inherited(ref) → launchEnvironmentOf(ctx).getFrom(ref, ['process'])

继承进程环境,空串视为未配置;命中即返回 source:'env'。

▼

③ 第二层:this.values.get(ref)(托管存储快照)

内存 Map,由 loadInitial/refresh/write 维护;命中返回 source:'file'。

▼

④ 第三、四层:dotenvFallback(ref) → getFrom(ref, ['project-env','user-env'])

项目 .env 优先于用户 .env;命中返回对应 source。

▼

⑤ 全空 → undefined(未配置)

消费端抛 MISSING_CREDENTIAL,错误信息给出两条修复路径。

写路径(set / unset)

① 入口校验:isClosed() + assertUnshadowed()

disposed 拒绝;继承环境遮蔽则 fail loud,指路去 shell unset。

▼

② enqueue:排进独占操作链

与 watcher 重载串行;运行时再判一次 closed/遮蔽(排队期间世界会变)。

▼

③ withFileLock:跨进程写者锁

mkdir 0700 备父目录;另一实例在写同一文档时排队等待。

▼

④ reconcileFromDisk:锁内重读

合并 debounce 窗口内的外部编辑/他进程写入,杜绝丢失更新。

▼

⑤ renderDocument + writeFileAtomic(0600)

Document 树行编辑保注释;临时文件+rename 原子替换,权限 0600。

▼

⑥ 更新快照 → notifyUpdated(ref)

提交后才扇出事件;坏观察者只告警,INVARIANT 违规全量执行后重抛。

热重载路径(外部编辑 → 生效)

① chokidar 'all' → queueRefresh()

awaitWriteFinish 等写入稳定(默认 100ms);ready 事件补启动竞态窗口。

▼

② refresh → reconcileFromDisk

重查权限位;文本与缓存相同则 no-op(自写抑制)。

▼

③ changedRefs → 整体替换快照

只算"值变了"的 key;values 整表替换,删除条目不残留。

▼

④ notifyUpdated(每个变化的 ref)

坏文档保留最后好快照(warn);INVARIANT 违规上浮到队列错误面。

📚 系列导航

← 第 31 讲:Storage 系统与 Settings 配置

→ 第 33 讲:Typert RPC 网关

关注公众号「AI技术推荐官」获取更多源码解析内容

相关学习资料