DeepSeek Harness 把你的 API Key 放在哪:凭据子系统的存取与作用域
先说清楚这篇的前提:DeepSeek Harness 的 README 在「Developer preview」一节里明写它处于开发者预览阶段并且会有破坏兼容性的变更(README.md 第 9-11 行,仓库根 package.json 里的版本是 0.1.0-rc.5)。下面所有的字段名、默认值、文件路径都来自这个快照的源码,随时可能改。我们没有安装、也没有运行过它,讲的全是读代码读出来的口径。
从一次模型请求开始:配置里存的不是 key,是 key 的名字
翻这个仓库时最容易先入为主的一件事,是以为 cordis.yml 或 settings 里某处躺着一串 sk-。不是。
看 packages/llm/llm-deepseek/src/index.ts:第 45 行定义了 const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY',第 64 行的配置字段叫 apiKeyEnv,注释写得很直白——这是一个「credential reference(environment-variable name)」,每次请求解析一次。也就是说,配置里存的是环境变量名这个引用,不是值。
再往下到第 225 行开始的 resolveApiKey:它拿 connection.apiKeyEnv 作为 ref,用 ctx.get('credentials') 取 seam,命中就 await credentials.resolve(ref)(第 231 行)。如果这个组合里根本没挂 credentials seam,它退回去读启动环境快照,注释里的说法是「没有受管存储可以排序,环境就是全部的凭据平面」。两条路都拿不到,抛 MISSING_CREDENTIAL。
引用本身有语法约束。packages/credentials/credentials/src/index.ts 第 16 行:
const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
credentialRef() 拿这条正则做校验,不合规直接 TypeError。所以你不能把一个带点、带横杠的名字塞进 apiKeyEnv。
seam 本身只有四个抽象方法:resolve、describe、set、unset,都定义在同一个文件(CredentialProvider 从第 60 行起)。整条 seam 有一条贯穿规则,写在类的文档注释里:空的存储值在任何地方都等于不存在——resolve 跳过它,describe 把它报成未配置,空白值不许冒充一个已配置的密钥。
四层来源,一张优先级表
真正干活的是 packages/credentials/credentials-local/src/index.ts。这个文件开头的模块注释直接画了层级(第 5-10 行):
| 层 | 来源 id | 可写 | 位置 |
|---|---|---|---|
| 继承的进程环境 | env | 否 | 启动 shell / CI 机密 / 容器 -e |
| 受管文档 | file | 是(set / unset) | $DSH_HOME/.credentials.yaml |
项目 .env | project-env | 不在此处写 | <invocation cwd>/.env |
用户 .env | user-env | 不在此处写 | $DSH_HOME/.env |
resolve() 的实现(第 309-317 行)就是照这个顺序四段 if:先查继承环境,命中返回 source: 'env';再查内存里的文档快照,命中返回 source: 'file';再查两个 .env 兜底;全空返回 undefined。
文档自述的理由值得抄一遍,因为它解释了一个反直觉之处:继承环境赢过受管存储。注释里的说法是,DEEPSEEK_API_KEY=… dsh 这种按次覆盖代表本次运行的明确意图,而进程内部改不了它,所以它必须是「可见地只读」,而不是默默把写入吞掉。反过来,两个 .env 都排在受管存储之下,所以存进文档的密钥会立刻压过 checkout 里带的旧值。
还有一个细节,Windows 读者尤其要留意:这里解析读的不是 process.env,而是 dsh-launch-environment 在启动时冻结的快照(packages/util/launch-environment/)。那个包的 README 写明变量名匹配「POSIX 上精确匹配,Windows 上不区分大小写」,理由是同一个变量在 Windows 上写成 deepseek_api_key 和 DEEPSEEK_API_KEY 对操作系统而言是一回事,按大小写敏感去查会选错层。
set 被拒绝,多半是被环境挡住了
这是我觉得最值得单独拎出来的一段。credentials-local 的 write() 在入队前后各调一次 assertUnshadowed()(第 374、380 行),这个方法在第 410-417 行:只要 inherited(ref) 有值,就直接抛错,错误文案原文是「is supplied read-only by the launching environment, so … would be shadowed; unset it in the shell you start dsh from instead」。
对应地,describe() 在同样条件下返回 { configured: true, source: 'env', writable: false }(第 323-325 行)。Web 侧的契约也照搬了这层语义:packages/host/apiproxy/src/api/credentials.ts 里 set 的注释写明,被只读层遮蔽时会用 credential-rejected 拒绝。
怎么确认你撞的就是这一条:调 describe(或看 web 端返回的 CredentialView),source 是不是 env、writable 是不是 false。是的话,源码给的处置只有一个方向——在你启动 dsh 的那个 shell 里把这个变量 unset 掉再重启。注意这里不能靠「启动之后再 export 一下」来救:credentials-local 的 README 在「已知限制」里明写环境变化不可见,快照在启动时冻结,启动之后 export 的变量既不进解析也不进 describe,换环境来源的凭据需要重启。
如果 source 报的是 project-env 或 user-env,那不是这个问题——README 写明这两层 writable: true,因为存一个密钥进受管文档就会取代它们成为生效来源。
那个文件本身:严格 mapping,一点将就都没有
CREDENTIALS_FILENAME 常量在第 52 行,值是 .credentials.yaml。插件配置四个字段,resolveSpec()(第 79-85 行)把默认值集中在一处:path 缺省为 harness home 下的该文件名,dshHome 缺省走 resolveDshHome()(README 写明是 $DSH_HOME 或 ~/.dsh),watch 默认 true,debounceMs 默认 100。
文档格式是「凭据引用 → 非空字符串」的严格 YAML mapping,没有 version 字段也没有包装层。parseCredentialsDocument()(第 154 行起)对偏离一律拒绝而不是跳过:非 mapping 的根、不合 POSIX 标识符的键、非字符串值、空字符串、重复键(uniqueKeys: true)、格式错误的 YAML,全部抛错。注释给的理由是,一个被静默忽略的条目读起来就是「我存进去的密钥没有生效」。空字符串那条的报错文案是「is empty; remove the key instead」——想删就 unset,别置空。
有一处设计我第一次读的时候没反应过来,回头看很实在:describeYamlError()(第 135-140 行)只输出错误的 code 加行列号,绝不引用出错的那一行原文。因为在这个文件里,出错的那一行就是密钥。同一条纪律贯穿整个解析函数——键名可以进报错信息,值不行,连类型不对的条目也只 quote 键名(第 178 行)。
权限这块分平台。第 88 行 const GROUP_OTHER_BITS = 0o077,assertOwnerOnly()(第 103 行起)在启动读取前、以及每次 reload 与每次写入前都跑一遍:POSIX 上只要文件带任何 group 或 other 权限位就报错,并在错误里给出 chmod 600 <文件路径> 的修复命令。Windows 上第 113 行直接 return——注释写明 Windows 没有可检查的 mode、ACL 在这里表达不出来,所以是跳过检查而不是伪造它,那边的保护取决于创建与替换 API 本身。写入侧:目录以 0700 创建(第 383 行 mkdir 的 mode),文档以 0600 原子提交(第 394 行 writeFileAtomic 的 mode: 0o600, dirMode: 0o700)。
写入不是重建文件,是给已解析的文档打补丁(renderDocument(),第 197-204 行),所以注释与所有未触及条目的排版都保留。每次写都在 withFileLock 跨进程写锁下先 reconcileFromDisk() 再改(第 384-389 行),把此前没观察到的磁盘状态先并进来。
热更新与它服务的对象
watch 打开时,chokidar 的 awaitWriteFinish 用 stabilityThreshold: debounceMs,pollInterval: Math.max(1, Math.min(debounceMs, 10))(第 279-282 行)。默认值 100 的意思是稳定窗口 100 毫秒、轮询间隔 10 毫秒——这是配置默认值,不是对你机器上表现的承诺。
有意思的是 watcher.on('ready') 也排了一次 refresh(第 288-294 行),注释说明初始加载与 watcher 建立之间有窗口,这中间写入的改动不会触发事件,所以在 ready 时补一次对账。
快照是整体替换的(reconcileFromDisk() 第 477-481 行先算 changedRefs 再换 this.values),所以磁盘上删掉的条目不会在内存里滞留。变更逐个发 credentials/updated 事件。但这个事件的服务对象要看清:docs/subsystems/credentials.md 明说消费方不需要它——消费方按操作重新解析,那次按操作的读取本身就是热更新机制;事件是给配置界面刷「已配置」徽标用的。另外,进程环境自身的变化不可观测,永不发出事件。
顺带一提 key 值本身的合法性判定不在这个子系统里,在 packages/llm/llm/src/api-key.ts:normalizeApiKey() 先 trim(注释说填充空格只有一种读法所以静默处理),空值报 empty,不匹配 /^[\x21-\x7E]+$/ 的报 illegalCharacters。注释解释这是传输不变量而非某家的策略——超出这个集合的 key fetch 连 header 都建不出来。
仓库自己划的边界,别越界理解
packages/credentials/credentials-local/README.md 有一节叫「安全边界」,措辞相当克制,值得原样转述:文档以 0600 存在 0700 目录下,这挡得住其他 OS 用户,挡不住模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 workspace-write 文件策略限制的是修改而非读取,所以它们读这个文件与读该用户拥有的任何其他文件没有区别,也没有哪个沙箱模式会把它单独挑出来。
harness 声称守住的东西更窄:它不把该文档的解析后路径交给模型,也不把它载入进程环境——这一点与 $DSH_HOME/.env 那个普通环境层不同。README 自己给这段定了性,原文是「这是审慎,不是边界」,并把 OS 钥匙串提供方列为延后项。
「已知限制与暂缓事项」还有另外三条:同一引用的并发写入是后写胜出(写锁加读-改-写保证条目不互相丢失,但同一个引用没有修订检查);同 UID 进程可以读这个文档;原子写不具备崩溃持久性,存储在启动时重新读取。
这些都是仓库白纸黑字写的限制,不是我们的评价。要在这基础上做什么额外防护,那属于通用运维做法、不是这个项目文档的内容,得结合你自己的环境评估。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。