一切皆插件:DeepSeek Harness 源码精读 2 / 14

第 2 章

从 cordis.yml 到运行中的树

源码核对基于 deepseek-ai/deepseek-harness commit `b150a551b8`(2026-08-21),tag `course-anchor-20260823`对比语料 anthropics/claude-code commit `45bdfa96`(2026-08-23),tag `v2.1.241`本章字数:约 9600 字(不含代码与图)

场景还原

一台内网机器上跑着 dsh --profile web,浏览器形态的 agent 服务,几十个同事在用。你想给其中一组人放开沙箱:默认 workspace-write 只允许写工作目录,你想让这组人用 danger-full-access。拿到机器后你翻 profile 目录,profiles/web/cordis.yml 里只有一段注释和一行 []。改它没用,下次启动它会被重写。真正要改的是旁边的 cordis.patch.yml:把 sandbox-policy 那一行的 config 整段抄下来,改成你要的 mode,存盘。

三个月后 dsh 升级。base bundle 重排了行、重写了部分行的 config。你的 patch 活没活下来,取决于两件事:sandbox-policy 这个行 id 有没有改名,以及你 restate 的字段还和新结构对不对得上。id 没动,patch 照常命中;id 改了,启动时 stderr 会多一行 warn,告诉你这条 patch 落空了,你抄下来的那一整段 config 得重新对齐。

这一章把「一份 yml 变成一棵可卸载的树」的链路拆开:谁读文件、谁按 id 改行、谁把行挂成 fiber、每一层失败时会发生什么。读完你就能解释那行 warn,也能解释为什么 cordis.yml 必须保持为空。

逐行精读

图 2.1 层序。这张图回答:一份配置的四个来源按什么顺序压到空根上,最终得到一棵树。

flowchart LR ROOT[empty entry list] --> BASE[dsh-base bundle insert] BASE --> WEB[dsh-web-app overrides by id] WEB --> PROFILE[profile cordis.patch.yml] PROFILE --> HOME[home cordis.patch.yml] HOME --> PATCH[command-line patch overlays] PATCH --> TREE[composed entry tree]

整条链的入口是 runProfileapps/cli/src/profile-boot.ts:207),它先调用 composeProfile 把 patch 层收齐。这一段要证明「组合发生在文件层,运行时只拿到一份平铺的行列表」。四层来源:profile 清单里的 bundle、profile 自己的 patch、home 层的 patch、命令行的 --patch overlay。

apps/cli/src/profile-boot.ts142:153
142function composeProfile(143  name: string,144  patchFiles: readonly string[],145): ComposedProfile {146  const profile = prepareProfile(name)147  const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []148  const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))149  const bundlePatches = profile.layers.flatMap(layer => layer.patches)150  const rows = new Map<string, EntryOptions>()151  for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) {152    if (typeof row.id === 'string') rows.set(row.id, row)153  }

homePatchPath()$DSH_HOME/cordis.patch.ymlprofile-boot.ts:49-51),overlays 来自 argv,bundlePatches 来自 profile 清单里按顺序列出的每个 bundle 的 patch 文件。composeEntries 把四层平成一个列表。层与层分开存,没有预先合并,这个结构的用途在 ComposedProfile 的注释里写明了:

apps/cli/src/profile-boot.ts105:129
105/** One profile's patch layers (application order) and the row index of its pre-flag composition. */106interface ComposedProfile {107  profile: Profile108  /** Bundle layers concatenated — the part below the user layers on a live reload. */109  bundlePatches: PatchOptions[]110  /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */111  homePatches: PatchOptions[]112  /** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */113  overlays: PatchOptions[]114  /**115   * id → row of the composed tree (bundles + user layers + overlays), for the116   * launcher's own row checks.117   */118  rows: ReadonlyMap<string, EntryOptions>119}120121/** The full patch stack of one composed profile, in application order. */122function allPatches(composed: ComposedProfile): PatchOptions[] {123  return [    ...composed.bundlePatches,    ...composed.profile.patches,    ...composed.homePatches,    ...composed.overlays,128  ]129}

分层存是为了热重载:composeLiveprofile-boot.ts:240-245)要把用户层夹在 bundle 与 overlay 之间重新组合,用户编辑 cordis.patch.yml 时只重读用户文件,bundle 层和 overlay 层保持原样。rows 索引供启动器自检(比如判断 telemetry 行在不在,profile-boot.ts:168-169)。

接下来读 loadProfile 完整函数。这一段要证明「bundle 层的解析入口是 npm 包,不是路径」:每个 bundle 是一个包,它的 patch 文件位置写在包的 package.json 里。

packages/boot/app-boot/src/profile.ts371:403
371export function loadProfile(372  binName: string, name: string, installAnchor: string, home: string = resolveDshHome(),373  options: { userLayer?: boolean } = {},374): Profile {375  const dir = resolveProfileDir(name, home)376  if (!existsSync(join(dir, 'package.json'))) {377    const template = PROFILE_TEMPLATES[name]378    if (template === undefined) {379      throw new Error(380        `${binName}: profile ${JSON.stringify(name)} does not exist; create it with 'dsh plugin --profile ${name} add <package>'`,381      )382    }383    initProfile(dir, template)384  }385  const manifest = normalizeShippedProfile(name, dir, readProfileManifest(binName, dir))386  // A hand-written profile manifest may omit the dsh section entirely.387  const bundles = manifest.dsh?.profile?.bundles ?? []388  const layers = bundles.map((packageName): ProfileLayer => {389    const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)390    const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as ProfileManifest391    const declared = bundleManifest.dsh?.bundle?.patch392    if (declared === undefined) {393      throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)394    }395    const patchPath = join(packageDir, declared)396    return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) }397  })398  const patchPath = join(dir, PROFILE_PATCH_FILENAME)399  const patches = options.userLayer !== false && existsSync(patchPath)400    ? loadOverlayPatches(binName, patchPath)401    : []402  return { name, dir, layers, patchPath, patches }403}

逐步走:profile 目录没有 package.json 时自动用模板初始化(webheadless 两个模板,见 PROFILE_TEMPLATES),其他名字直接抛错。然后 bundles.map 里每个包名走 resolveBundleDirprofile.ts:344-355)双锚点解析:先按 dsh 安装目录找,再按 profile 目录的 package.json 找。包清单里没有 dsh.bundle.patch 就直接 throw(392-394 行),一个被列进 profile 却拿不出 patch 的包是配置错误,不是「没有 patch」。用户层 cordis.patch.yml 存在才解析,userLayer: false 可以让 dump 类命令跳过坏掉的用户层。

然后看那个必须保持为空的根文件。PROFILE_ROOT_CONFIG 是每次启动写入 profiles/web/cordis.yml 的内容:

apps/cli/src/profile-boot.ts59:64
59/** The empty root entry list every profile tree patches over. */60const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:61# each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any62# --patch overlays. Edit cordis.patch.yml, not this file.63[]64`

为什么每次启动重写?prepareProfile 的注释(profile-boot.ts:85-93)说了:Loader 的写回机制会把运行中的树烘焙进文件。写回来自 internal/update 事件监听器:

vendor/loader/src/index.ts103:109
103    ctx.on('internal/update', async function (config, noSave, next) {104      if (!this.entry || noSave || this.parent.fiber?.entry === this.entry) return next()105      await next()106      const unparse = this.runtime?.Config?.['simplify']107      this.entry.options.config = unparse ? unparse(config) : config108      this.entry.parent.tree.write()109    }, { global: true, prepend: true })

插件运行中改动自己的 config(ctx.set 之类)会走这个监听器,把新值写回 entry.options.config 并落盘到树文件。如果根文件装的是一份组合产物,组合结果就被当成用户配置烘焙进去,下次启动再叠一遍 bundle insert,同一行挂两次。所以根文件必须是空列表,且每次启动重写。

组合动作本身在 composeEntries 里,一行封装,直接转发给 include 的 applyEntryPatches

packages/boot/app-boot/src/profile.ts413:420
413export function composeEntries(414  layers: readonly PatchOptions[][], warn: (message: string) => void = () => {},415): EntryOptions[] {416  return applyEntryPatches([], structuredClone(layers.flat()), (message: string, ...args: unknown[]) => {417    let index = 0418    warn(message.replace(/%C/g, () => JSON.stringify(args[index++])))419  })420}

这一段是整个 patch 语义的核心,完整函数:

vendor/include/src/index.ts58:128
58export function applyEntryPatches(59  data: EntryOptions[],60  patches: PatchOptions[] | undefined,61  warn: (message: string, ...args: any[]) => void,62): EntryOptions[] {63  data = structuredClone(data)64  if (!patches?.length) return data6566  const entryMap = new Map<string, EntryOptions>()67  const buildMap = (entries: EntryOptions[]) => {68    for (const entry of entries) {69      if (entry.id) entryMap.set(entry.id, entry)70      if (entry.group && Array.isArray(entry.config)) {71        buildMap(entry.config)72      }73    }74  }75  buildMap(data)7677  for (const patch of patches) {78    const { id, insert, name, ...overrides } = patch7980    if (insert) {81      if (id) {82        const target = entryMap.get(id)83        if (!target) {84          warn('patch insert: entry %C not found', id)85          continue86        }87        if (!target.group) {88          warn('patch insert: entry %C is not a group', id)89          continue90        }91        if (!Array.isArray(target.config)) target.config = []92        target.config.push(...insert)93      } else {94        data.push(...insert)95      }96      // Index what this patch added so a LATER patch in the same list can97      // target it. Patch lists compose one layer per source (each bundle98      // layer, then the user's, then `--patch` overlays), and a layer must be99      // able to configure or disable a row an earlier layer inserted; without100      // this, inserted rows were silently unpatchable.101      buildMap(insert)102      continue103    }104105    if (!id) {106      warn('patch: id is required for non-insert patches')107      continue108    }109110    const target = entryMap.get(id)111    if (!target) {112      warn('patch: entry %C not found', id)113      continue114    }115116    if (name && name !== target.name) {117      warn('patch: name mismatch for %C (expected %C, got %C), skipping', id, target.name, name)118      continue119    }120121    for (const [key, value] of Object.entries(overrides)) {122      if (key === 'id') continue123      target[key] = value124    }125  }126127  return data128}

输入输出先说:输入是空根 [] 加一份平铺的 patch 列表,输出是一份 detached(与输入无共享引用)的行列表。算法分三件事。第一,structuredClone 复制输入,函数永远不碰调用方的对象,这个克隆在边界条件一节还会再出现一次。第二,buildMap 递归建 id 索引,group 行(一个包含子行的行)的 config 数组里的子行也会被索引。第三,逐条 patch:带 insert 的走插入分支,不带 insert 的走覆盖分支。覆盖分支就是「按 id 寻址、整份 config 替换」的实现:id 找不到 warn 跳过,name 对不上 warn 跳过,命中后把 patch 里出现的每个字段原样赋给目标行(121-124 行),config 整份被换掉,没有深合并。

图 2.3 patch 判定。这张图回答:一条 patch 行落在哪条分支,哪些情况被跳过、哪些情况真的生效。

flowchart TD ROW[one patch row] --> Q1{carries insert?} Q1 -->|yes| Q2{carries id?} Q2 -->|no| PUSH[push rows into the top-level list] Q2 -->|yes| Q3{target id is a group?} Q3 -->|yes| GINS[push rows into the group config] Q3 -->|no| W1[warn and skip the patch] Q1 -->|no| Q4{id present in the row index?} Q4 -->|no| W2[warn and skip the patch] Q4 -->|yes| Q5{name matches the target row?} Q5 -->|no| W3[warn and skip the patch] Q5 -->|yes| SET[assign every provided field to the target row]

patch 的类型定义把「能 patch 哪些字段」钉死。注意 disabled?: boolean | nullgroup?: boolean | null,null 是合法值,边界条件一节会用到:

vendor/include/src/index.ts144:156
144/** Runtime patch applied to entries loaded from an included config file. */145export interface PatchOptions {146  id?: string147  insert?: EntryOptions[]148  name?: string149  config?: any150  group?: boolean | null151  disabled?: boolean | null152  inject?: any153  intercept?: any154  isolate?: any155  [key: string]: any156}

然后是文件解析层,三种失败语义在这里分家。loadOptionalPatches 处理「可能没有这一层」的文件:缺文件返回 undefined,文件在但读不动、解析不动,抛错:

packages/boot/app-boot/src/index.ts267:287
267/**268 * Load an optional patch-list file: a top-level YAML array of loader patch269 * entries (`@deepseek-ai/cordis-plugin-include`'s `PatchOptions`): id-targeted config270 * overrides and `insert` lists, with `!!js` expressions allowed. A missing271 * file means "no layer"; an unreadable, unparsable, or non-array file throws —272 * a present patch file that cannot apply is a misconfiguration and must fail273 * loud at boot, never be silently skipped.274 * @param binName - the diagnostic prefix on the thrown error.275 * @param file - absolute path of the patch file.276 * @returns the parsed patches, or `undefined` when the file does not exist.277 */278export function loadOptionalPatches(binName: string, file: string): PatchOptions[] | undefined {279  let content: string280  try {281    content = readFileSync(file, 'utf8')282  } catch (error) {283    if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined284    throw new Error(`${binName}: failed to read patches ${file}: ${String(error)}`)285  }286  return parsePatchList(binName, file, content, 'patches')287}

loadOverlayPatches 处理「点名了就必须在」的文件:bundle 的 patch 和 --patch 文件。缺文件直接 throw,因为调用方明确指了这个文件,它不在是配置错误:

packages/boot/app-boot/src/index.ts289:306
289/**290 * Load a required overlay patch list: a bundle's `cordis.patch.yml` or a291 * `--patch <path>` overlay. Same file format as {@link loadOptionalPatches},292 * but a missing file throws, because the caller named this file — its absence293 * is a misconfiguration, not "no overlay".294 * @param binName - the diagnostic prefix on the thrown error.295 * @param file - absolute path of the overlay file.296 * @returns the parsed patch list.297 */298export function loadOverlayPatches(binName: string, file: string): PatchOptions[] {299  let content: string300  try {301    content = readFileSync(file, 'utf8')302  } catch (error) {303    throw new Error(`${binName}: failed to read overlay ${file}: ${String(error)}`)304  }305  return parsePatchList(binName, file, content, 'overlay')306}

两者共用 parsePatchList:文件必须是顶层 YAML 数组,每个条目必须是 mapping,!!js 表达式允许。注释第 311-313 行把「文件坏了」和「目标行不在」两种失败分开:前者抛错,后者降级成 per-entry 的 Loader 警告,理由是一个 overlay 跨多个 surface 共享时不需要匹配每棵树:

packages/boot/app-boot/src/index.ts320:338
320function parsePatchList(321  binName: string, file: string, content: string, label: string,322): PatchOptions[] {323  let parsed: unknown324  try {325    parsed = yaml.load(content, { schema: userPatchesSchema })326  } catch (error) {327    throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`)328  }329  if (!Array.isArray(parsed)) {330    throw new Error(`${binName}: ${label} ${file} must be a top-level YAML array of loader patch entries`)331  }332  parsed.forEach((entry, index) => {333    if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {334      throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)335    }336  })337  return parsed as PatchOptions[]338}

到这里组合完成,进入 boot。这一段要证明「挂载顺序是固定的:Loader 服务先装,树后挂」。boot 失败时抛两段式错误,阶段切换点在 mountRootInclude 之前和之后:

packages/boot/app-boot/src/index.ts757:774
757export async function boot(758  binName: string,759  absoluteConfigPath: string,760  patches?: PatchOptions[],761  prepare?: (ctx: Context) => Promise<void> | void,762  bareModuleBaseUrl?: string,763): Promise<Context> {764  const ctx = new Context()765  // Two failure labels: `prepare` runs before any config-tree entry mounts,766  // so its failure is host setup, not the plugin tree.767  let stage = 'host preparation failed'768  try {769    ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'770    ctx.provide('dshHomePath', dshHomePath)771    await ctx.plugin(Loader)772    await prepare?.(ctx)773    stage = 'plugin tree failed to load'774    await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl)

ctx.plugin(Loader) 先注册 Loader 服务,然后 mountRootIncludeindex.ts:486 起)把 Include 注册成内置插件,读根文件、应用 patches、更新整棵树。树更新发生在 EntryGroup.update,完整函数:

vendor/loader/src/config/group.ts59:106
59  async update(config: EntryOptions[]) {60    const oldConfig = this.data as EntryOptions[]61    const seen = new Set<string>()62    for (const options of config) {63      const id = this.tree.ensureId(options)64      if (seen.has(id)) throw new TypeError(`duplicate loader entry id: ${id}`)65      seen.add(id)66    }67    const oldMap = Object.fromEntries(oldConfig.map(options => [options.id, options]))68    const newMap = Object.fromEntries(config.map(options => [options.id, options]))6970    try {71      const outcomes = await Promise.allSettled(config.map(options => this.create(options)))72      // Disposal owns termination: sibling starts can still be settling after73      // the containing tree has gone away, but their failures no longer74      // describe a live update to roll back.75      if (this.ctx.fiber.uid === null) return76      const failures = outcomes77        .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected')78        .map(outcome => outcome.reason)79      if (failures.length === 1) throw failures[0]80      if (failures.length > 1) throw new AggregateError(failures, 'loader entries failed to apply')81      for (const id of Object.keys(oldMap)) {82        if (!newMap[id]) await this.remove(id, true)83      }84      this.data = config85    } catch (error) {86      const rollbackErrors: unknown[] = []87      for (const id of Object.keys(newMap).reverse()) {88        if (oldMap[id]) continue89        try {90          await this.remove(id, true)91        } catch (rollbackError) {92          rollbackErrors.push(rollbackError)93        }94      }95      for (const options of oldConfig) {96        try {97          await this.create(options)98        } catch (rollbackError) {99          rollbackErrors.push(rollbackError)100        }101      }102      this.data = oldConfig103      if (rollbackErrors.length) throw new AggregateError([error, ...rollbackErrors], 'loader entry rollback failed')104      throw error105    }106  }

组更新是「整组对账」:先查重复 id(62-66 行,重复 id 直接 TypeError),Promise.allSettled 全量启动新行,全部成功后再删掉消失的行,最后更新 this.data。任何一个失败走 catch:把新加的逆序删掉、把旧配置全量重建,回滚完成才抛错。第 75 行的判断有意思:树已经没了就放弃回滚,因为 disposal 拥有终止权,兄弟行的失败不再描述一次活着的更新。

每个 Entry 自己的 update 决定「热更新还是重启」:

vendor/loader/src/config/entry.ts142:160
142  async update(options: Partial<EntryOptions>, create = false, force = false) {143    const previousOptions = this.options144    const legacy = { ...previousOptions }145    const candidate = create ? options as EntryOptions : { ...previousOptions }146    if (!create) {147      for (const [key, value] of Object.entries(options)) {148        if (isNullable(value)) {149          delete candidate[key as keyof EntryOptions]150        } else {151          candidate[key as keyof EntryOptions] = value as never152        }153      }154    }155    sortKeys(candidate)156157    const diff = Object158      .keys({ ...candidate, ...legacy })159      .filter(key => !deepEqual(candidate[key as keyof EntryOptions], legacy[key as keyof EntryOptions]))160    if (!diff.length && !force) return

注意 147-154 行:null 值会从 candidate 里删掉对应键,这是 disabled: null 能表达「解除」语义的机制之一。diff 用 deepEqual 逐键比对,没有变化直接返回。拓扑字段变了才重启整棵 fiber:

vendor/loader/src/config/entry.ts194:212
194    const replace = diff.some(key => key === 'name' || key === 'inject' || key === 'group')195    if (!replace) {196      this.options = candidate197      try {198        await this._patchContext(diff)199      } catch (error) {200        this.options = previousOptions201        try {202          await this._patchContext(diff)203        } catch (rollbackError) {204          throw updateError('rollback', legacy, new AggregateError([error, rollbackError]))205        }206        this.context.emit('loader/partial-dispose', this, candidate, true)207        throw updateError('apply', candidate, error)208      }209      commit()210      this.context.emit('loader/partial-dispose', this, legacy, true)211      return212    }

配置没变但 nameinjectgroup 变了,说明依赖图变了,旧的 fiber 必须卸掉重挂(214-244 行,失败时把旧插件重新 _start 回去做回滚)。只改了 config 的行走 _patchContextentry.ts:114-122),把新 config 推进现有 fiber,不重启。图 2.4 行生命周期。这张图回答:一行在树里可以处在哪些状态,状态之间由什么触发。

stateDiagram-v2 [*] --> absent: file read, row indexed by id absent --> running: entry activated, fiber alive running --> running: config-only diff hot-patched in place running --> disabled: disabled flips to true disabled --> running: disabled flips back to false running --> removed: row gone from the composed list removed --> [*]

挂载本身在 _startunwrapExports 处理模块导出形态,registry.plugin(plugin, config) 创建 fiber,await 激活。任何失败包成 updateError('apply')

vendor/loader/src/config/entry.ts277:303
277  private async _init() {278    let plugin: any279    try {280      plugin = this.loader.unwrapExports(await this.parent.tree.import(this.options.name, this.getOuterStack))281    } catch (error) {282      throw updateError('import', this.options, error)283    }284    try {285      await this._start(plugin)286    } catch (error) {287      throw updateError('apply', this.options, error)288    }289  }290291  private async _start(plugin: any) {292    let fiber: Fiber | undefined293    try {294      await this._patchContext([])295      this.loader.showLog(this, 'apply')296      fiber = this.fiber = this.ctx.registry.plugin(plugin, this.options.config, this.getOuterStack)297      await fiber.await()298    } catch (error) {299      await this._dispose(fiber)300      throw error301    }302  }303}

至此「行」变成了「fiber」:插件的服务以 effect 注册进 ctx,卸载就是逆操作,这就是可卸载性的来源。图 2.2 启动时序。这张图回答:从 dsh 命令到服务注册,每一跳在哪一层发生。

sequenceDiagram participant BIN as dsh-bin participant PB as profile-boot participant AB as app-boot participant INC as Include participant LDR as Loader participant FBR as Fiber BIN->>PB: runProfile profile web PB->>PB: composeProfile stacks four patch layers PB->>AB: boot with one flattened patch list AB->>LDR: ctx.plugin Loader AB->>INC: mountRootInclude empty root file plus patches INC->>INC: applyEntryPatches in one pass INC->>LDR: root.update entry list LDR->>LDR: import plugin modules by name LDR->>FBR: registry.plugin plugin and config FBR-->>BIN: services registered as reversible effects

还剩最后一个环节:!!js 表达式。YAML 侧先把标量变成表达式节点,!!js 是 tag tag:yaml.org,2002:js 的写法,解析结果是一个 { __jsExpr: '...' } 节点:

vendor/include/src/index.ts9:23
9const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {10  kind: 'scalar',11  resolve: (data) => typeof data === 'string',12  construct: (data) => ({ __jsExpr: data }),13  predicate: isJsExpr,14  represent: (data) => data['__jsExpr'],15})1617/**18 * The entry-list YAML dialect: `!!js` scalars round-trip as expression nodes19 * the Loader evaluates at entry activation. Exported so config tooling20 * (`dsh --dump-config`) parses and prints exactly the dialect this include21 * mounts.22 */23export const entryListSchema = yaml.JSON_SCHEMA.extend(JsExpr)

求值点有两个。第一个是 config:fiber 激活时对插件上下文求值。树载体(Group、Include)的 config 保持字面量,因为里面的 !!js 属于子行自己的 fiber:

vendor/loader/src/index.ts92:101
92    ctx.on('internal/config', function (this: Fiber, _config, next) {93      const config = next()94      if (!this.entry || this.parent.fiber?.entry === this.entry) return config95      // Tree carriers (Group, Include) keep their configs literal: their96      // entry and patch lists hold other rows' configs, whose `!!js`97      // expressions belong to those rows' own fibers.98      const plugin = this.runtime?.callback as Record<PropertyKey, unknown> | undefined99      if (plugin?.[EntryGroup.key]) return config100      return interpolate(this.ctx, config)101    }, { global: true })

interpolate 递归替换表达式节点,真正执行的是 new Function 包出来的 evaluate,在 with (ctx) 作用域里 eval:

vendor/loader/src/config/utils.ts3:22
3// eslint-disable-next-line no-new-func4/** Evaluate a JavaScript expression against a loader context scope. */5export const evaluate = new Function('ctx', 'expr', `6  with (ctx) {7    return eval(expr)8  }9`) as ((ctx: object, expr: string) => any)1011/** Recursively replace YAML `!js` expression nodes with evaluated values. */12export function interpolate(ctx: object, value: any) {13  if (isJsExpr(value)) {14    return evaluate(ctx, value.__jsExpr)15  } else if (!value || typeof value !== 'object') {16    return value17  } else if (Array.isArray(value)) {18    return value.map(item => interpolate(ctx, item))19  } else {20    return valueMap(value, item => interpolate(ctx, item))21  }22}

第二个求值点是 disabled:每次挂载决策时对 loader 上下文求值,原始节点留在 options 里,写回时保持表达式形态:

vendor/loader/src/config/entry.ts100:108
100  /**101   * Effective disabled state: a `!!js` expression evaluates against the loader102   * context. The raw node stays in the options, so write-back keeps the form.103   */104  private disabledOf(options: EntryOptions): boolean {105    return isJsExpr(options.disabled)106      ? Boolean(this.evaluate(options.disabled.__jsExpr))107      : Boolean(options.disabled)108  }

表达式能进哪些字段,AGENTS.md 第 97 行钉死:

AGENTS.md97:97
97Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and root `.env`. cordis.yml allows `!!js` (never `!js`) under plugin `config` and entry `disabled`; other metadata stays literal, so conditional composition also uses overlays ([primer](docs/cordis-primer.md#loader-configuration)). Never commit credentials. CI e2e skips without a key; [testing.md](docs/testing.md) owns key policy.

docs/cordis-primer.md:38 补充了求值时机:config 在声明注入激活之后对插件上下文求值,disabled 在每次挂载决策时对 loader 上下文求值,Include 保留嵌套行表达式直到目标行激活,其余元数据保持字面量。环境要选择插件时用 overlay,因为选择插件属于拓扑,拓扑字段不允许表达式。

平台分叉也靠这个机制:base 的 patch 用两条互斥的 disabled 表达式,一份文件同时服务三平台。win32 上 bash 栈关闭、pwsh 栈打开,POSIX 上反过来:

packages/bundle/base/cordis.patch.yml178:186
178    - id: bash-sandbox179      name: '@deepseek-ai/dsh-bash-sandbox'180      disabled: !!js process.platform === 'win32'181      config:182        timeoutMs: 60000183184    - id: pwsh-sandbox185      name: '@deepseek-ai/dsh-pwsh-sandbox'186      disabled: !!js process.platform !== 'win32'

base README 第 7 行解释了这条链的完整形态:bash 在 win32 没有 runner,所以 bash-sandbox/tool-bash 被关,pwsh-sandbox/tool-pwsh 只在 win32 挂;权限面在 Windows 上由 ACL restricted-token runner 保持(dsh-sandbox-localdsh-sandbox-windows-acl),approval 服务双平台同一套;想用无限制 pwsh 的 Windows 用户要按「bash-restore recipe」完整操作:同时关掉 pwsh 两个行并恢复 bash 两个行,因为两个 executor 家族注册同一个 bash 服务,配一半会 fail loud。三个平台各自的路径:bash-sandbox 行(178-180 行)、pwsh-sandbox 行(184-186 行)、sandbox/sandbox-policy 行(172-176 行,双平台同一套配置,mode 由 !!js 表达式从环境变量选)。

设计决策分析

第一个决策:按 id 寻址、整份 config 替换,不做深合并。base 的 patch 文件头部注释就是决策记录:

packages/bundle/base/cordis.patch.yml1:13
1# The dsh-base bundle patch: the shared core of every dsh profile, applied as2# ONE insert over the empty profile root. Later bundle patches and the user's3# profile cordis.patch.yml address these rows by id, with the last write4# winning per row.5#6# A patch replaces the targeted row's whole `config` rather than merging into7# it, so a row whose value differs by mode does NOT live here: it belongs to8# each mode bundle, keeping any single row down to one bundle layer plus the9# user's. Mode-specific rows appear below only with shared plugin identity and10# neutral defaults; each mode bundle restates its complete configuration.11#12# Row order carries no load semantics (activation is service-availability13# driven); the grouping is for readers.

架构文档把整套模型写成一节,全部读下来:

docs/architecture.md17:27
17A running `dsh` is a plugin tree composed at boot from ordered layers.1819A **profile** is a named composition stored in the Harness home. It lists the bundles it stacks, holds any out-of-tree plugins it installs, and keeps the user's own `cordis.patch.yml`. `web` and `headless` ship as templates.2021A **bundle** is a distribution format for Cordis config rows and the code they mount, so whatever it inserts stays patchable by the layers above it.2223Each declares itself in its own `package.json` under a `dsh` field: `dsh.profile` lists a profile's bundles, and `dsh.bundle` points at a bundle's patch file.2425[`dsh-base`](../packages/bundle/base/README.md) is the first layer of every profile: model adapters, tools, persistence, sandbox and approval policy, settings, credentials, telemetry. [`dsh-web-app`](../packages/bundle/web-app/README.md) adds the browser application; [`dsh-headless`](../packages/bundle/headless/README.md) adds a one-shot runner with no server at all.2627Layers apply to an empty entry list in this order: each bundle in the profile's listed order, then the profile's `cordis.patch.yml`, then the home-level one, then any `--patch` overlay. A patch targets a row by id and replaces its whole config, or inserts new rows.

这一节就是 docs/architecture.md:27 那一句的展开:patch 按 id 定位一行并替换它的整份 config,或者插入新行。节尾给读者留了自检入口:

docs/architecture.md35:37
35Any row it prints can be replaced by a patch of your own.3637Composition mechanics are in [app-boot](../packages/boot/app-boot/README.md#profiles); config fields are in the generated [config catalog](config-catalog.md).

为什么选整值替换:审计性。层与层之间「谁最后写了这个值」可以从 diff 里看见,任意一行有且只有一个最后写入者。深合并会让字段所有权模糊:升级时 bundle 改了一个子字段,用户旧值会混进新结构,哪个值是谁写的一笔账乱掉。不这样做的后果有两个版本。没有分层:用户定制直接写进发行版文件,升级覆盖即丢,用户每次升级都要重抄配置。有分层但深合并:升级改结构时用户旧值污染新字段,diff 无法解释一行值为何变成这样。代价也写在明面上,两处 Known Limitations 一字不差:

packages/bundle/base/README.md5:5
5The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, telemetry, and the core spawn/fork subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. The optional Codex and Claude Code providers stay outside this package and its production dependency closure; a Profile installs either [product provider Bundle](../../subagent/README.md) only when needed. The default `@deepseek-ai/dsh` production closure therefore includes neither product provider, the Claude Agent SDK, nor the Codex wrapper and platform payloads. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code.
packages/bundle/base/README.md21:21
21- **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer.

packages/boot/app-boot/README.md:43 把用户层的行为写成文档(id 寻址、整份 config 替换、insert 加行、!!js 挂载时插值、缺失 id 是 stderr 警告),:60 再补一条 Known Limitations:id 寻址的 patch 不做深合并,profile 覆盖要 restate 它保留的 bundle 字段。于是行 id 成了跨版本契约:bundle 升级改名,用户 patch 会变成 stderr 警告而不是报错,这就是场景还原里那行 warn 的来源。

第二个决策:!!js 只进 config 和 disabled。理由要从 Loader 的工作方式看:nameinjectgroup 决定树的拓扑和依赖图,Loader 必须在不执行代码的情况下解析「谁注入谁、谁是谁的子行」;这些字段一旦可以是表达式,静态解析就崩了。config 和 disabled 是运行时行为,允许表达式才有意义:环境变量选权限模式、平台选 shell 栈。两个求值点(loader index.ts:92-101 和 entry entry.ts:104-108)分别对插件上下文和 loader 上下文求值,谁挂载谁负责。web-app 关闭 hmr 行的写法就是最朴素的覆盖,一行 disabled: true,注释还交代了原因:

packages/bundle/web-app/cordis.patch.yml1:23
1# The dsh-web-app bundle patch: the browser surface over the dsh-base layer.2# Applied after dsh-base's insert; rows here override base rows by id, with3# the profile's own cordis.patch.yml and any --patch overlays still to come.4#5# A patch replaces the targeted row's whole `config`, so each row below6# restates every key it owns.7#8# The web-startup plugin injects `cmdlineArgs` and provides `webStartup` as an9# ordinary Cordis service. Rows configured from flags inject that service, so10# Loader resolves their expressions only after it exists. The web runtime then11# provides bind-dependent `webRuntime` values to the trust fence.12# `dsh --profile web --help` provides neither service, so no server binds.1314# ── surface-specific values the base deliberately omits ─────────────────────1516- id: system-prompt17  config:18    persona: >-19      You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.2021# TODO: Re-enable shared HMR for Web after its reload lifecycle is tested.22- id: hmr23  disabled: true

第三个决策:分层顺序。docs/architecture.md:27 写明:profile 清单里每个 bundle 按列出顺序、然后 profile 的 cordis.patch.yml、然后 home 层、然后 --patch overlay。home 层排在 profile 之上,composeProfile 的注释(profile-boot.ts:131-137)给了理由:机器级本地偏好对每个 profile 生效,所以它压过 per-profile 层。--patch 在 argv 里,每次调用独立,是最高优先级。telemetry 开关在最后追加成一行 patch(profile-boot.ts:168-169)。composeLive 热重载时把用户层夹在 bundle 与 overlay 之间(profile-boot.ts:240-245),用户怎么改都动不了 overlay 和 bundle 的相对位置。不这样做的后果:home 层若低于 profile 层,机器级偏好会被某个 profile 的 patch 覆盖,换 profile 就丢偏好;overlay 若低于用户层,命令行就表达不了「本次调用强制」的意图。

第四个决策:fail loud 的三级粒度。缺文件是正常状态(没有这一层),文件坏了抛错(配置错误),patch 找不到目标行 warn 跳过(可能跨 surface 共享)。这个区分直接对应 loadOptionalPatchesloadOverlayPatches 的注释。启动窗口内的未捕获错误还有 installFailLoudindex.ts:578、609-649)兜底:把启动期的异常转成有界关闭,不让进程挂在半挂载状态。

边界条件剖析

第一个追问:patch 写的 id 在树里不存在会怎样。applyEntryPatches 110-114 行:warn 加 continue,boot 照常。测试把这个行为固化成了断言:

packages/boot/app-boot/tests/profile.spec.ts200:212
200describe('composeEntries', () => {201  it('applies layers over an empty root and reports skipped patches', () => {202    const warnings: string[] = []203    const entries = composeEntries([204      [{ insert: [{ id: 'x', name: 'pkg-x', config: { a: 1 } }] }],205      [{ id: 'x', config: { a: 2 } }, { id: 'missing', config: {} }],206    ], message => warnings.push(message))207    expect(entries).toEqual([{ id: 'x', name: 'pkg-x', config: { a: 2 } }])208    expect(warnings.join('\n')).toContain('"missing"')209    // Default warn sink: skipped patches are silently dropped (boot repeats them).210    expect(composeEntries([[{ id: 'missing', config: {} }]])).toEqual([])211  })212})

这是一条设计边界:一个 overlay 跨多个 surface 共享时不必匹配每棵树(parsePatchList 注释 311-313 行)。代价是拼写错的 id 不报错,只有一行 warn,而且默认 warn sink 是静默的,boot 才会重放警告。

第二个追问:同一列表里,后一个 patch 能不能命中前一个 patch 插入的行。能。applyEntryPatches 96-101 行插入后立刻 buildMap(insert),新行进入索引,后面的 patch 可以配置或禁用它们。注释说得很直白:没有这步,insert 的行永远无法被后续层修改。反方向的角落:用 config 替换一个 group 行之后,再向它 insert,单遍 id 索引看不见子行。renderConfigDump 的注释明说了这个限制,并且强调 dump 和 boot 用同一个算法,所以两种路径行为一致:

packages/boot/app-boot/src/index.ts348:356
348/**349 * Compose the effective entry list exactly as `boot()` would mount it: parse350 * the base config file with the include's entry-list dialect, apply every351 * layer's patches as ONE flattened list through the include's own patch352 * algorithm (`applyEntryPatches`) — the same single call `boot()` makes, so353 * even patch-visibility corner cases (a later layer targeting a group child a354 * plain config replacement introduced, which the single-pass id index never355 * sees) compose identically — then render the result as YAML in the same356 * dialect (`!!js` expressions print verbatim, unevaluated).

第三个追问:disabled: null 是什么意思。PatchOptionsdisabled?: boolean | null(include:151 行)允许 null;applyEntryPatches 121-124 行把 null 原样赋给目标行;disabledOf 104-108 行 Boolean(null) 是 false,行被启用。语义上它和「不写 disabled」等价,但它表达了意图:显式解除上一层(比如 web-app 对 hmr 行的 disabled: true)的禁用。配套机制在 Entry.update 147-154 行:null 值会从 candidate 里删掉键,写回时不会留下 disabled: null

第四个追问:为什么组合的每一步都在克隆。composeLive 的注释(profile-boot.ts:235-239)把原因写透了:include 把 insert 行按引用推进挂载树,后面的 id 寻址 patch 会原地修改这些对象。如果复用同一份解析结果,用户覆盖会被烘焙进 bundle 的 insert 行对象,删掉 patch 无法还原成 bundle 默认值。所以 composeLive 每次 structuredClone(240-245 行),composeEntries 也克隆(416 行),boot 的调用再克隆一次(248 行)。插入别名是一个真实的坑:不克隆的话,配置系统最基本的「删除覆盖即还原」会失效。

第五个追问:表达式求值失败会怎样。_init/_start(entry.ts:277-302 行):import 失败是 updateError('import'),激活失败是 updateError('apply'),fiber 先 dispose 再抛。group 层用 Promise.allSettled 收集所有失败,单个失败原样抛、多个失败包 AggregateError,然后整组回滚(group.ts:76-104 行)。boot 层把整棵树 dispose 掉,抛「plugin tree failed to load」(index.ts:786-800 行),错误链里带上最深层的原始激活错误。结果是表达式写错没有中间态:要么整层没挂,要么全部挂好。

横向对比

同一个问题:用户的配置怎么盖过发行方的配置。dsh 的答案是「任意层数的 patch 文件,按行 id 寻址,整份 config 替换」,claude-code 的答案是「固定文件层级,逐键合并」。两侧都看。

claude-code 的层级由官方文档定义,本仓库能拿到的最具体材料是 examples 目录。层级清单(企业 managed-settings.json、用户 settings.json、项目级、settings.local.json)加 --settings 一次性覆盖:

examples/settings/README.md5:5
5These may be applied at any level of the [settings hierarchy](https://code.claude.com/docs/en/settings#settings-files), though certain properties only take effect if specified in enterprise settings (e.g. `strictKnownMarketplaces`, `allowManagedHooksOnly`, `allowManagedPermissionRulesOnly`).
examples/settings/README.md26:26
26- Before deploying configuration files to your organization, test them locally by applying to `managed-settings.json`, `settings.json` or `settings.local.json`

企业层的位置在 MDM 部署模板里写得最清楚:

examples/mdm/README.md24:24
24- Settings deployed this way sit at the top of the precedence order and cannot be overridden by users

层级不是均匀的,哪些键在哪些层生效有明确规则。CHANGELOG 两条记载最典型:sandbox.ripgrep 只从 user、managed、--settings 三层读取,项目级设置改不动它;marketplace 条目按整条目合并,不会跨层拼字段:

CHANGELOG.md304:304
304- Changed `sandbox.ripgrep` to be honored only from user, managed, and `--settings` settings; project settings can no longer override the sandbox's ripgrep binary
CHANGELOG.md357:357
357- Fixed a settings-merge issue where a marketplace entry redefined in a higher-precedence settings tier could inherit another tier's custom headers; marketplace entries now merge as whole entries

企业层的完整样例 settings-strict.json,一份完整文档:

examples/settings/settings-strict.json1:28
1{2  "permissions": {3    "disableBypassPermissionsMode": "disable",4    "ask": [5      "Bash"6    ],7    "deny": [8      "WebSearch",9      "WebFetch"10    ]11  },12  "allowManagedPermissionRulesOnly": true,13  "allowManagedHooksOnly": true,14  "strictKnownMarketplaces": [],15  "sandbox": {16    "autoAllowBashIfSandboxed": false,17    "excludedCommands": [],18    "network": {19      "allowUnixSockets": [],20      "allowAllUnixSockets": false,21      "allowLocalBinding": false,22      "allowedDomains": [],23      "httpProxyPort": null,24      "socksProxyPort": null25    },26    "enableWeakerNestedSandbox": false27  }28}

实现侧没找到对应实现。claude-code 公开仓库里检索 cordisSettingsMapuserSettingsloadSettings 均为 0 个文件,settings.local.json 只出现在 2 个 examples/部署文件里,managed-settings 出现在 6 个 examples/模板里。加载与合并代码不在公开仓库,层级行为只能从官方文档与 CHANGELOG 佐证。复查方式:升级 claude-code 语料后重新检索这几个词,或直接读官方 settings 文档。后续章节若需要 claude-code 侧的合并语义,目前只能按文档描述转述,标为推断。

两种答案各自的代价。dsh 的整值替换要求覆盖者 restate 全部字段,漏一个丢一个;行 id 是跨版本契约,bundle 改名后 patch 只剩 warn。换来的是行级粒度:任何层能插入任意新行,新插件就是新行,一行只动一行;锁定粒度也是行级,--patch 每次调用压过一切,没有「锁层」概念。claude-code 的逐键合并让用户只写要改的键,不需要知道任何「行号」,但配置面是封闭 schema:产品 schema 之外的键写不进去,想加一个产品没有的「行」没有位置。CHANGELOG 357 行那笔修复等于承认逐键合并对「行」类数据不适用,marketplace 条目得退回整条目合并。锁定粒度是整层:managed 层整体不可覆盖(mdm README 24 行),而 dsh 的 telemetry 开关只是加一行 patch(profile-boot.ts:168-169)。claude-code 为什么可以没有行 id:它的配置面由产品定义,没有「可插入的新行」这个概念,逐键合并已经覆盖了全部可表达的空间。dsh 为什么不能只用逐键合并:patch 要表达的不只是改值,还有「插入新插件行」「把行挪进 group」,键空间是开放的。

dsh 内部还有一对形态对照。leaf 形态:直接写完整的 cordis.yml,文件里每一行都是配置本体,例如 examples/acp-agent:

examples/acp-agent/cordis.yml26:33
26- id: sandbox27  name: '@deepseek-ai/dsh-sandbox-local'2829- id: sandbox-policy30  name: '@deepseek-ai/dsh-sandbox-policy'31  config:32    mode: !!js "process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')"33    workspaceRoot: !!js process.cwd()

profile 形态:空根加 patch 层。同一份 sandbox-policy 配置,leaf 里是文件内容的一部分,profile 里是覆盖层。leaf 的定制和发行内容混在一个文件里,升级要自己 diff;profile 的定制分离在 patch 文件里,升级只动 bundle。有意思的是 dsh 还会消费 claude-code 的配置格式:packages/hooks/hooks-claude-code/README.md:5 写明这个插件直接读取用户现成的 hooks.json 或 settings 文件的 hooks 键,把 CC 的钩子协议当外部输入翻译到自己的拦截面上。两个工具在「配置格式互操作」这个方向上反而通了。

互动演示设计

形态:格式实验台。一句话结论:同一份 yml 逐层 patch 之后,每一行都能说清是谁最后写了它。

舞台上有四个料斗、一条流水线、一个输出窗。左侧四个料斗标着 dsh-base、dsh-web-app、profile cordis.patch.yml、--patch overlay。中间是组装流水线,上面跑一张张行卡片,每张卡片有四个字段:id、name、config、来源层。右侧是 dump 输出窗,模拟 dsh --profile web --dump-config 的输出,行首带 # == 来源注释。比喻:流水线上传的是装配工单,发行版先铺一份完整清单,你在清单上涂改,命令行批注盖在最上面,每项都标注了最后修改人。

分六步,每步一句字幕:

  1. 拉下 dsh-base 料斗。78 张行卡片(base 的 insert 行,packages/bundle/base/cordis.patch.yml 里逐条数出来的)铺满流水线,来源层全部标「dsh-base」。字幕:「空根上执行第一个 insert,dsh-base 把 78 行基础树铺进来」。
  2. 拉下 dsh-web-app 料斗。84 张卡片进场,与 base 同 id 的卡片整张变色,config 内容整体替换;hmr 卡片多出 disabled 徽标。字幕:「模式包按 id 覆盖基础行,整份 config 替换,不做深合并」。
  3. 拉下用户料斗,模拟编辑 cordis.patch.yml 后保存。sandbox-policy 卡片第二次变色,config 换成用户写的 mode,来源层标「profile cordis.patch.yml」。字幕:「你的定制永远站在发行版之上,行 id 是寻址契约」。
  4. 拉下 --patch 料斗。再改一行,卡片来源层标「命令行」。字幕:「命令行 overlay 只在本次调用生效,压过一切层」。
  5. 撤销用户层里的一条 patch。对应卡片整张回到 bundle 层的值,变色动画反向播放。字幕:「删掉 patch,行回到上一层,克隆保证旧值没有被烘焙进 insert 行」。
  6. 切换「!!js 求值」开关。config 表达式在 fiber 激活的瞬间点亮一次,disabled 表达式在每次挂载决策时点亮,两处高亮用不同颜色。字幕:「config 对插件上下文求值,disabled 对 loader 上下文求值,其余字段保持字面量」。

读者能操作:开关每一层看卡片变化;编辑 patch 内容并实时观察;把一张卡片的 id 拖到另一个值,观察 warn 弹窗和卡片变灰(模拟 id 改名后 patch 落空);撤销层恢复原值。

逻辑轨迹面板,随步骤步进高亮,一行伪代码配一句白话,右侧标真实行号:

text
composeProfile(name, patchFiles)             // apps/cli/src/profile-boot.ts:142
loadProfile 双锚点解析 bundle 层与用户层      // packages/boot/app-boot/src/profile.ts:371
composeEntries 把四层平成一个列表            // packages/boot/app-boot/src/profile.ts:413
applyEntryPatches 在空根上逐行应用            // vendor/include/src/index.ts:58
boot 装 Loader 服务再挂根 Include            // packages/boot/app-boot/src/index.ts:757
root.update 整组对账,失败整体回滚            // vendor/loader/src/config/group.ts:59
entry.update 算 diff,config 热补丁或重启    // vendor/loader/src/config/entry.ts:142
registry.plugin 建 fiber,服务以 effect 注册  // vendor/loader/src/config/entry.ts:291

可迁移结论

第一条,分层覆盖加稳定 id,最小形态不依赖 TypeScript。做发行配置和用户覆盖,不需要插件框架,不需要进程模型。最小形态是两份 list:发行配置一份、用户覆盖一份,覆盖按行 id 定位,整值替换,找不到目标打 warn。Python 大约三十行:build 一个 id 索引,遍历覆盖列表逐字段赋值,返回新列表。规则只有一条,「覆盖必须写全」,配合 warn 就够了。

第二条,整值替换比深合并更可审计。「谁最后写了这行」从 diff 里直接可见,排查配置问题先问「哪一层最后写了它」。代价是覆盖者要 restate,抄整段。要不要深合并取决于你的配置结构会不会被上游升级改动;会的话,整值替换是省心的一方,因为深合并的「字段漂移」问题在配置系统里比在代码里更难 debug。

第三条,表达式只放进行为字段,拓扑字段保持字面量。任何配置系统都能照抄这条:解析时把表达式节点存成数据,需要执行时才求值,求值失败在挂载时炸出来。不要在解析阶段悄悄执行表达式,那会把「读配置」变成「跑代码」,解析错误和执行错误搅在一起。

第四条,哪些是过度设计。dsh 的威胁模型是多 surface 长驻进程加发行版升级:双用户层(profile 加 home)、--patch overlay、HMR watch、insert 别名克隆、整组回滚、fail-loud 全套,都是这个体量才值得的。单进程单用户的工具,两层覆盖加 warn 就够;HMR 只在长驻 surface 有必要,一次性命令直接重启即可。

思考题

  1. 动手题:在仓库根运行 pnpm dsh --profile web --dump-config,对照 apps/cli/composition.md 的生成图找 sandbox-policy 行;然后写一个 --patch 文件覆盖这一行,把 mode 改成 danger-full-access,再跑一次 dump,观察输出里该行的来源注释变化;接着故意把 patch 里的 id 写错,再跑一次,观察 stderr 的 warn 文本(对照 vendor/include/src/index.ts:110-114 的格式)。
  2. vendor/include/src/index.ts:96-101packages/boot/app-boot/src/index.ts:348-356:为什么同一列表内后 patch 能命中先 insert 的行,而「config 替换一个 group 行之后再往里 insert」命中不了?单遍 id 索引的边界在哪?
  3. 在 profile 的 cordis.patch.yml 里给 hmr 行写 disabled: null(web-app 的 patch 把它置成了 disabled: true),用 dump 验证结果,然后解释 disabled: null 与不写 disabled 的区别,答案落到 vendor/loader/src/config/entry.ts:104-108 的 Boolean 语义。
  4. 对比题:claude-code 的 --settings 与 dsh 的 --patch 都是「一次性覆盖层」,前者按键合并、后者按行 id 替换。用 examples/settings/README.md:26 的层级列表,指出两种模型各自在哪一类覆盖场景下更省事。