第 2 章
从 cordis.yml 到运行中的树
场景还原
一台内网机器上跑着 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 层序。这张图回答:一份配置的四个来源按什么顺序压到空根上,最终得到一棵树。
整条链的入口是 runProfile(apps/cli/src/profile-boot.ts:207),它先调用 composeProfile 把 patch 层收齐。这一段要证明「组合发生在文件层,运行时只拿到一份平铺的行列表」。四层来源:profile 清单里的 bundle、profile 自己的 patch、home 层的 patch、命令行的 --patch overlay。
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.yml(profile-boot.ts:49-51),overlays 来自 argv,bundlePatches 来自 profile 清单里按顺序列出的每个 bundle 的 patch 文件。composeEntries 把四层平成一个列表。层与层分开存,没有预先合并,这个结构的用途在 ComposedProfile 的注释里写明了:
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}分层存是为了热重载:composeLive(profile-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 里。
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 时自动用模板初始化(web、headless 两个模板,见 PROFILE_TEMPLATES),其他名字直接抛错。然后 bundles.map 里每个包名走 resolveBundleDir(profile.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 的内容:
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 事件监听器:
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:
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 语义的核心,完整函数:
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 行落在哪条分支,哪些情况被跳过、哪些情况真的生效。
patch 的类型定义把「能 patch 哪些字段」钉死。注意 disabled?: boolean | null 和 group?: boolean | null,null 是合法值,边界条件一节会用到:
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,文件在但读不动、解析不动,抛错:
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,因为调用方明确指了这个文件,它不在是配置错误:
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 共享时不需要匹配每棵树:
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 之前和之后:
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 服务,然后 mountRootInclude(index.ts:486 起)把 Include 注册成内置插件,读根文件、应用 patches、更新整棵树。树更新发生在 EntryGroup.update,完整函数:
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 决定「热更新还是重启」:
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:
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 }配置没变但 name、inject、group 变了,说明依赖图变了,旧的 fiber 必须卸掉重挂(214-244 行,失败时把旧插件重新 _start 回去做回滚)。只改了 config 的行走 _patchContext(entry.ts:114-122),把新 config 推进现有 fiber,不重启。图 2.4 行生命周期。这张图回答:一行在树里可以处在哪些状态,状态之间由什么触发。
挂载本身在 _start:unwrapExports 处理模块导出形态,registry.plugin(plugin, config) 创建 fiber,await 激活。任何失败包成 updateError('apply'):
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 命令到服务注册,每一跳在哪一层发生。
还剩最后一个环节:!!js 表达式。YAML 侧先把标量变成表达式节点,!!js 是 tag tag:yaml.org,2002:js 的写法,解析结果是一个 { __jsExpr: '...' } 节点:
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:
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:
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 里,写回时保持表达式形态:
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 行钉死:
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 上反过来:
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-local → dsh-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 文件头部注释就是决策记录:
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.架构文档把整套模型写成一节,全部读下来:
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,或者插入新行。节尾给读者留了自检入口:
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 一字不差:
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.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 的工作方式看:name、inject、group 决定树的拓扑和依赖图,Loader 必须在不执行代码的情况下解析「谁注入谁、谁是谁的子行」;这些字段一旦可以是表达式,静态解析就崩了。config 和 disabled 是运行时行为,允许表达式才有意义:环境变量选权限模式、平台选 shell 栈。两个求值点(loader index.ts:92-101 和 entry entry.ts:104-108)分别对插件上下文和 loader 上下文求值,谁挂载谁负责。web-app 关闭 hmr 行的写法就是最朴素的覆盖,一行 disabled: true,注释还交代了原因:
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 共享)。这个区分直接对应 loadOptionalPatches 与 loadOverlayPatches 的注释。启动窗口内的未捕获错误还有 installFailLoud(index.ts:578、609-649)兜底:把启动期的异常转成有界关闭,不让进程挂在半挂载状态。
边界条件剖析
第一个追问:patch 写的 id 在树里不存在会怎样。applyEntryPatches 110-114 行:warn 加 continue,boot 照常。测试把这个行为固化成了断言:
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 用同一个算法,所以两种路径行为一致:
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 是什么意思。PatchOptions 里 disabled?: 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 一次性覆盖:
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`).26- Before deploying configuration files to your organization, test them locally by applying to `managed-settings.json`, `settings.json` or `settings.local.json`企业层的位置在 MDM 部署模板里写得最清楚:
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 条目按整条目合并,不会跨层拼字段:
304- Changed `sandbox.ripgrep` to be honored only from user, managed, and `--settings` settings; project settings can no longer override the sandbox's ripgrep binary357- 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,一份完整文档:
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 公开仓库里检索 cordis、SettingsMap、userSettings、loadSettings 均为 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:
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 的输出,行首带 # == 来源注释。比喻:流水线上传的是装配工单,发行版先铺一份完整清单,你在清单上涂改,命令行批注盖在最上面,每项都标注了最后修改人。
分六步,每步一句字幕:
- 拉下 dsh-base 料斗。78 张行卡片(base 的 insert 行,
packages/bundle/base/cordis.patch.yml里逐条数出来的)铺满流水线,来源层全部标「dsh-base」。字幕:「空根上执行第一个 insert,dsh-base 把 78 行基础树铺进来」。 - 拉下 dsh-web-app 料斗。84 张卡片进场,与 base 同 id 的卡片整张变色,config 内容整体替换;hmr 卡片多出 disabled 徽标。字幕:「模式包按 id 覆盖基础行,整份 config 替换,不做深合并」。
- 拉下用户料斗,模拟编辑
cordis.patch.yml后保存。sandbox-policy 卡片第二次变色,config 换成用户写的 mode,来源层标「profile cordis.patch.yml」。字幕:「你的定制永远站在发行版之上,行 id 是寻址契约」。 - 拉下 --patch 料斗。再改一行,卡片来源层标「命令行」。字幕:「命令行 overlay 只在本次调用生效,压过一切层」。
- 撤销用户层里的一条 patch。对应卡片整张回到 bundle 层的值,变色动画反向播放。字幕:「删掉 patch,行回到上一层,克隆保证旧值没有被烘焙进 insert 行」。
- 切换「!!js 求值」开关。config 表达式在 fiber 激活的瞬间点亮一次,disabled 表达式在每次挂载决策时点亮,两处高亮用不同颜色。字幕:「config 对插件上下文求值,disabled 对 loader 上下文求值,其余字段保持字面量」。
读者能操作:开关每一层看卡片变化;编辑 patch 内容并实时观察;把一张卡片的 id 拖到另一个值,观察 warn 弹窗和卡片变灰(模拟 id 改名后 patch 落空);撤销层恢复原值。
逻辑轨迹面板,随步骤步进高亮,一行伪代码配一句白话,右侧标真实行号:
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 有必要,一次性命令直接重启即可。
思考题
- 动手题:在仓库根运行
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的格式)。 - 读
vendor/include/src/index.ts:96-101和packages/boot/app-boot/src/index.ts:348-356:为什么同一列表内后 patch 能命中先 insert 的行,而「config 替换一个 group 行之后再往里 insert」命中不了?单遍 id 索引的边界在哪? - 在 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 语义。 - 对比题:claude-code 的
--settings与 dsh 的--patch都是「一次性覆盖层」,前者按键合并、后者按行 id 替换。用examples/settings/README.md:26的层级列表,指出两种模型各自在哪一类覆盖场景下更省事。