LLM之Agent(九十)|DeepSeek-Harness(一)安装与环境准备

dsh 的安装看起来只是一句 pnpm install,但这一句背后串联了三件容易被忽略的工程决策:用 packageManager 字段把包管理器版本钉死到具体补丁号、用 postinstall 自动接管 git hooks 而不指望贡献者记得手动执行、以及把"用户设置"和"敏感凭证"从一开始就拆成两个不同的 Seam(能力缝隙)来读写。理解这三件事,比记住几条安装命令更重要。

学习目标

  • 知道 dsh 对 Node 版本、pnpm 版本的硬性要求写在哪里,以及为什么这么严格。
  • 理解 pnpm install 触发的 postinstall 脚本做了什么,尤其是 lefthook git hooks 的自动安装。
  • 掌握 .env / DEEPSEEK_API_KEY 的读取顺序与优先级,理解"环境变量"和"凭证文件"两个来源如何共存。
  • 理解 credentials 这个 Seam 为什么要和 settings(用户设置)分开------即"敏感密钥"与"非敏感偏好"从设计上就不共享同一条读写路径。
  • 了解 pnpm-workspace.yamlallowBuilds 白名单机制的由来:pnpm 10+ 默认拒绝任何带安装/构建脚本的依赖,dsh 如何显式审查并放行少数几个包。

背景与设计动机

一个 229 个 leaf 包的 monorepo,如果对 Node/pnpm 版本、git hooks、凭证来源不做任何约束,会出现的典型问题是:贡献者 A 用 Node 20 装出来的依赖树和贡献者 B 用 Node 24 装出来的不一致;提交前忘了跑 lint,CI 才报错,来回折腾;有人把 DEEPSEEK_API_KEY 直接写进 cordis.yml 提交到仓库。dsh 的应对方式很直接:把这些约束前移到"安装"这一步,而不是依赖文档提醒或 code review 兜底。

  • 版本钉死package.json 同时声明 engines.nodepackageManagercorepack 会强制校验;
  • hooks 自动化postinstall 脚本把 git hooks 的安装做成幂等操作,任何一次 pnpm install 都会把 hooks 补齐;
  • 凭证隔离 :凭证从来不作为"配置"的一部分被解析进 cordis.yml,而是通过一个独立的 credentials Service,在请求发生时才被解析出来。

下面逐一拆开这三块。

核心机制详解

Node 与 pnpm 版本约束

根仓库 package.json 的开头几行就锁定了运行环境:

复制代码
// package.json
{
  "name": "@deepseek-ai/dsh-root",
  "version": "0.1.6-alpha.2",
  "license": "MIT",
  "private": true,
  "type": "module",
  "packageManager": "pnpm@11.7.0",
  "engines": {
    "node": "^22.19.0 || >=24.0.0"
  },
  ...
}

engines.node 的写法值得注意:^22.19.0 || >=24.0.0 意味着 Node 22 系列必须不低于 22.19.0(这是一条精确的下限,不是笼统的"22.x 都行"),而 Node 24 及以上则完全不设上限。docs/development.md 里对此有一句更直接的说明:

复制代码
Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note]...

也就是说 CI 会在三条 Node 版本线上跑(22.19、24、26),22.19 是一条被刻意选中的下限(而不是随便一个当时的最新小版本),这背后往往是某个 Node 22 补丁版本修复了一个 harness 依赖的运行时问题。packageManager: "pnpm@11.7.0" 同理------这不是"建议使用 pnpm",而是通过 Corepack 强制校验的精确版本号,任何 pnpm 版本不一致都可能导致 lockfile 解析行为或 allowBuilds 语义出现细微差异,进而让"在我机器上能装"变成一句空话。

docs/development.md 的 Setup 部分把这两条约束换成了操作步骤:

复制代码
- Node.js supports 22.19+ and 24+. ...
- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.
- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.
- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.

第三条"Git 2.26 或更新"看似和包管理无关,但下一节会看到它其实是 hooks 安装脚本的硬性前提。

pnpm install 之后发生了什么

安装依赖本身只是 pnpm install 这一句:

复制代码
pnpm install

package.jsonscripts 里藏着一个会被 pnpm 自动触发的生命周期脚本:

复制代码
// package.json(节选)
"postinstall": "node scripts/install-lefthook.mjs"

也就是说,只要你跑过一次 pnpm installscripts/install-lefthook.mjs 就会自动执行一遍,而不需要贡献者记得去读文档找到"安装 git hooks"这一步。docs/development.md 里明确写了这个兜底关系:

复制代码
The install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing`
Git merge driver through `scripts/install-lefthook.mjs`. ... If either integration is missing
because dependencies were restored from cache or `postinstall` was skipped, install them manually:
node scripts/install-lefthook.mjs

这条"手动兜底命令"的存在本身就说明了设计取舍:postinstall 不是唯一入口,而是"默认路径"------比如 CI 环境里依赖是从缓存恢复的,postinstall 可能被跳过,这时候需要能够手动补一次。脚本本身(scripts/install-lefthook.mjs)做的事情比听起来复杂,它不是简单调用 lefthook install,而是维护一套 worktree 级别(而非仓库全局)的 hooks 路径,并带一个跨进程安装锁:

复制代码
// scripts/install-lefthook.mjs(节选)
const MINIMUM_GIT = [2, 26, 0]
const HOOKS_DIRECTORY = 'dsh-hooks'
const OWNERSHIP_MARKER = '.dsh-lefthook-owned'
const OWNERSHIP_MARKER_VERSION = 1
const OWNERSHIP_MARKER_OWNER = 'deepseek-harness worktree-local lefthook hooks'
const INSTALL_LOCK = 'dsh-lefthook-install.lock'
const INSTALL_LOCK_TIMEOUT_MS = 30_000

MINIMUM_GIT = [2, 26, 0] 正对应上一节 docs/development.md 里"Git 2.26 或更新"的要求------Git 从这个版本才支持 worktree 级别的独立 hooks 配置(core.hooksPath 的 worktree 作用域扩展),dsh 依赖这个特性把 hooks 装在 dsh-hooks 这个 worktree 本地目录,而不是污染整个仓库的全局 hooks 路径。OWNERSHIP_MARKER 则是为了防止脚本覆盖一个贡献者手工配置的、非 dsh 拥有的 hooks 目录------写一个带版本号的所有权标记文件,下次安装时先检查这个标记,而不是盲目覆盖。

装好之后,lefthook.yml 定义的钩子会在每次 git commit / git push 时跑起来:

复制代码
# lefthook.yml(节选)
pre-commit:
  jobs:
    - name: lint (staged)
      glob: '*.{ts,tsx,mts,cts,mjs}'
      exclude:
        - 'vendor/*/src/**'
      run: node_modules/.bin/tsx scripts/run-oxlint.ts --config .oxlintrc.staged.json --fix --no-error-on-unmatched-pattern {staged_files}
      stage_fixed: true

    - name: whitespace (staged)
      run: git diff --cached --check

pre-push:
  jobs:
    - name: typecheck
      run: pnpm run typecheck

值得留意的设计取舍是 pre-commit 只对暂存的文件 做增量 lint({staged_files}),而完整的类型检查被放到了 pre-push 才跑------docs/development.md 对这条分工有明确解释:

复制代码
Apart from the scoped staged-record verification, the hooks intentionally do not run tests,
snapshots, documentation checks, builds, or hygiene. Contributors run the checks relevant to
the changed behavior once; CI owns exhaustive coverage, ...

也就是说本地 hooks 只做"快速本地检查点",完整的测试矩阵、构建产物校验、跨平台兼容性检查全部交给 CI,避免每次 commit 都要等一次完整的 monorepo 构建。

.envDEEPSEEK_API_KEY:凭证从哪里读取

真实调用 DeepSeek API 的测试、demo 都需要一个 API Key。docs/development.md 给出的写法很朴素:

复制代码
DEEPSEEK_API_KEY=sk-...
DEEPSEEK_BASE_URL=https://... # optional

AGENTS.md 的"Secrets / .env"一节则从贡献者规范的角度重申了这一点:

复制代码
## Secrets / .env

Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and root `.env`.
... Never commit credentials. CI e2e skips without a key; [testing.md] owns key policy.

但"读取 .env"这句话背后,实际的分层比看起来复杂得多。packages/credentials/credentials-local 这个包的模块级注释画出了完整的优先级链条:

复制代码
// packages/credentials/credentials-local/src/index.ts
/**
 * File-backed credentials provider over `$DSH_HOME/.credentials.yaml`, layered
 * against the environment by how much each layer is trusted:
 *
 * ```text
 * inherited process environment      (read-only, wins)
 * > $DSH_HOME/.credentials.yaml      (provider-managed, writable)
 * > <invocation cwd>/.env            (read-only fallback)
 * > $DSH_HOME/.env                   (read-only fallback)
 * ```
 * ...
 */

也就是说".env 文件"并不是唯一来源,而是优先级最低的两层兜底:继承自启动进程的环境变量 (比如 DEEPSEEK_API_KEY=sk-xxx dsh、CI secret、容器 -e 参数)永远优先,因为这是"这次运行"的显式意图,进程内部没有办法(也不应该)覆盖它;其次是 $DSH_HOME/.credentials.yaml------这是唯一"可写"的一层,Web UI 的 Models 页面写密钥就是写这个文件;最后才轮到项目目录下的 .env 和用户主目录下 .env 作为只读兜底。注释里解释了为什么继承环境变量必须"可见地只读"而不能被静默覆盖:

复制代码
// packages/credentials/credentials-local/src/index.ts
/**
 * The inherited environment wins because `DEEPSEEK_API_KEY=... dsh`, a CI
 * secret, or a container `-e` is this run's explicit intent; it cannot be
 * edited from inside, so it must be *visibly* read-only rather than silently
 * shadow writes.
 */

代码里对应的判断很直接------一旦某个引用(如 DEEPSEEK_API_KEY)在继承环境中存在,任何 set/unset 调用都会被拒绝,而不是静默无效:

复制代码
// packages/credentials/credentials-local/src/index.ts
private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void {
  if (this.inherited(ref) !== undefined) {
    throw new Error(
      `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',
    )
  }
}

为什么凭证(credentials)和设置(settings)是两个 Seam

dsh 里有一条被反复提及的规则:能力以 Seam(服务缝隙)的方式组织 ------"接口定义 → Provider 实现 → 消费者"三元结构。凭证是这条规则的一个典型例子,而且刻意和"用户设置"(ctx.settings)分成了两个独立的 Seam。packages/credentials/credentials/src/index.ts 的模块注释说明了原因:

复制代码
// packages/credentials/credentials/src/index.ts
/**
 * Service Definition for the credential-reference capability seam (`ctx.credentials`).
 * Settings and composition files carry *references* to secrets --- environment-variable
 * names --- while providers own the actual values and their storage. Consumers resolve a
 * reference once per operation, so a changed credential reaches the next operation
 * without any plugin restart, and configuration surfaces describe a reference without
 * ever seeing its value.
 */

关键点是:cordis.yml / settings 里出现的从来不是密钥本身,而是一个引用 (比如字符串 DEEPSEEK_API_KEY,一个环境变量名),真正的值由 credentials Service 在每次调用时解析:

复制代码
// packages/credentials/credentials/src/index.ts
export abstract class CredentialProvider extends Service {
  abstract resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined>
  abstract describe(ref: CredentialRef): Promise<CredentialInfo>
  abstract set(ref: CredentialRef, value: string): Promise<void>
  abstract unset(ref: CredentialRef): Promise<void>
}

describe() 特意被设计成"只回答配置是否存在、来自哪一层、能不能写"而绝不返回值本身(CredentialInfo 里只有 configured / source / writable 三个字段),这样配置界面(Web 的 Models 页面)可以展示"这个 Key 已配置,来自 .env,可覆盖",而永远不会意外把密钥渲染到 DOM 里。如果凭证和设置合并成一套读写路径,这种"界面只描述状态、绝不回显值"的保证就很难维持------任何一次把 settings.get() 的结果直接序列化给前端的代码,都可能顺带把密钥泄漏出去。两个 Seam 分开之后,这类风险被结构性地排除了。

allowBuilds:pnpm 10+ 的安装脚本白名单

pnpm-workspace.yaml 里还有一段专门处理依赖生命周期脚本的配置:

复制代码
# pnpm-workspace.yaml(节选)
# pnpm 10+ blocks any dependency shipping an install/build script until it is
# explicitly reviewed here (strictDepBuilds defaults to true: an unlisted script
# is a hard install error). Every such package MUST be listed; we deny by
# default and only allow scripts we need. esbuild (native binary) and lefthook
# (git hooks) genuinely need theirs.
allowBuilds:
  esbuild: true
  lefthook: true
  # Cross-platform boundary for the persistent PTY backend, including ConPTY on Windows.
  node-pty: true
  '@google/genai': false
  protobufjs: false
  node-addon-require-builtin: false
  # JSONL durability calls MoveFileExW with write-through publication on Windows.
  koffi: true
  '@deepseek-ai/dsh-subprocess-local@file:packages/subprocess/subprocess-local': true
  # electron-builder pulls in the optional Squirrel.Windows helper, whose
  # install script only selects its bundled 7-Zip executable. Desktop ships
  # Windows through NSIS, so that mutation is not part of our build.
  electron-winstaller: false

(这份清单目前还在增长:仓库在 2026-08-28 之后新增了 apps/desktop------一个基于 Electron 的桌面壳(详见下一篇),随之带来的几个打包相关依赖也被逐一审查后加进了这份白名单,electron-winstaller: false 就是其中一条,思路和前面几条完全一致:显式拒绝一个"声明了脚本但其实不需要跑"的依赖。)

这背后的安全动机很直接:npm 生态里"依赖在安装阶段执行任意脚本"是一条常见的供应链攻击面------一个被劫持的依赖可以在 postinstall 里做任何事。pnpm 10 起默认策略反转为"未经审查的安装/构建脚本直接拒绝安装"(strictDepBuilds 默认 true),allowBuilds 就是这份审查清单:每一个真的需要跑安装脚本的包(esbuild 需要下载对应平台的原生二进制、lefthook 需要写入 git hooks、node-pty 需要编译原生 PTY 绑定)都被显式列出并注明理由;反过来,像 @google/genaiprotobufjs 这些包即使声明了生命周期脚本,也被显式标记为 false------它们的脚本是无操作的空跑,dsh 选择拒绝而不是默默放行,安装依然会成功。

这种"默认拒绝、逐项放行并注明原因"的写法,本质上和前面凭证 Seam 的设计是同一套哲学:任何隐式的、未经审查的行为路径都不被信任,配置和权限必须显式声明。

常见问题/易踩坑

  • pnpm --version 对不上 packageManager 字段 :先确认 Corepack 已启用(corepack enable),而不是本地手装了另一个版本的 pnpm,二者混用可能导致 allowBuilds 语义或 lockfile 解析结果不一致。
  • 提交时 hooks 没有触发 :优先怀疑依赖是从缓存恢复导致 postinstall 被跳过,手动执行 node scripts/install-lefthook.mjs 补装,而不要用 --no-verify 跳过钩子。
  • 把 API Key 写进了 cordis.yml 或某个 patch 文件 :这违反了凭证 Seam 的设计意图(配置只应携带引用名),应该改为写入 .env 或通过 Web UI 的 Models 页面写入 $DSH_HOME/.credentials.yaml
  • 改了 .env 里的 Key 但没生效 :检查是否有更高优先级的层覆盖了它------尤其是启动进程时是否已经在 shell 里 export 过同名变量,那一层会"可见地"胜出,且无法从 .env 内部覆盖。

小结

dsh 的安装流程把三类"容易被人忽略却影响很大"的问题都前移到了工具链层面:用精确的版本号和 Corepack 杜绝环境漂移;用 postinstall + 幂等安装脚本让 git hooks 不再依赖人的记忆;用凭证 Seam 与设置 Seam 的物理分离,让"配置携带引用、Provider 拥有真值"成为一条结构性保证而不是约定。下一篇会在装好环境之后,正式跑起 dsh 的 CLI 与 Web UI,建立"用户输入任务 → 会话开始 → Agent 工作"的第一个心智模型。

相关推荐
知几蜗牛2 小时前
npm自动发布最危险的一步,被拆成了“先暂存再批准”
人工智能
甲维斯2 小时前
阶跃星辰Step5 这个“老头乐”有点东西!
人工智能
科创致远2 小时前
科创致远 ESD 静电监控系统全场景落地指南
大数据·人工智能·制造·精益工程
jerryinwuhan2 小时前
《机器学习快速入门》10周教学提纲
人工智能
蓝速科技2 小时前
商用复杂环境翻译机耐用性选型指南丨蓝速科技
大数据·运维·数据库·人工智能·科技
Raas1002 小时前
AI 编程助手 5 选 1:场景匹配决策树
人工智能·深度学习·机器学习·企业级产品
用户72722197943622 小时前
AI Agent 语义分析与数据引擎查询:从"听懂人话"到"查对数据"的技术拆解
人工智能
zuozewei2 小时前
附录 A:主流工具对比选型表
人工智能·测试工具
hhb_6182 小时前
偶现Bug根因定位实战解析
人工智能