fix: retire leftovers of the removed $DSH_HOME/config.yaml personal overlay

The profile rework left references to the old entry modes behind. Renames
the user patch-layer API and its spec file (watchPersonalPatches ->
watchUserPatches, personal-config.spec.ts -> user-patches.spec.ts) and
retargets the prose that still named `config.yaml`, `--config`, raw-config
mode, and surface overlays: repository-plugin and mcp-memory READMEs, the
credentials-local anchor into app-boot, vendor manifest items 12-13, the
vendored include/hmr comments, and install.sh.

Restores the boot-failure guard the rework dropped with raw mode: the
built-bin case now boots `--profile web --patch <invalid>` and asserts the
settled diagnostic and exit 1, so the HMR initial-scan deadlock stays
covered; its orphaned raw fixture is renamed and the unused one deleted.
The superseded personal-config Agent Note and its superseding profile note
are now cross-linked.
This commit is contained in:
Turtle
2026-08-06 17:28:51 +08:00
parent 20acfdb71d
commit ef30572e63
21 changed files with 79 additions and 87 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/cordis/repository-plugin/README.md
README.md: 2555876734ddcab7b0bc0f25780010bef0c97a01
README.zh.md: 41341f7d7ed92ac071dde274a0328784bc2888d4
README.md: 33cd763d7dbe21b72f9e604b7b2e313081cf656f
README.zh.md: 903dfbe601cc76acb0c1e87453dc03ef0321409b
+2 -2
View File
@@ -30,7 +30,7 @@ Place an ordinary package in the repository's `.dsh-plugin` directory:
## Standalone app configuration
The shipped `dsh` base used by raw-config, Web, and headless modes contains an empty `repository-plugins` row. A Web or headless user enables exact GitHub generations by replacing that row's config in `$DSH_HOME/config.yaml` (default `~/.dsh/config.yaml`); a raw-config deployment patches the same row in its explicit overlay:
The shipped `dsh-base` bundle every profile starts from contains an empty `repository-plugins` row. A user enables exact GitHub generations by replacing that row's config in a user patch layer — `$DSH_HOME/profiles/<name>/cordis.patch.yml`, or the home-level `$DSH_HOME/cordis.patch.yml` shared by every profile; a `--patch` overlay patches the same row for one run:
```yaml
- id: repository-plugins
@@ -43,7 +43,7 @@ The shipped `dsh` base used by raw-config, Web, and headless modes contains an e
Each source must use `github:owner/repository#<ref>`. Omitting `&path:` selects `/.dsh-plugin`; an explicit path is absolute within the repository and must end in `.dsh-plugin`. A commit ref gives the clearest immutable identity, while tags and branches remain accepted exact config values. `cacheDir` may override the default `$DSH_HOME/cache/repository-plugins` cache root.
Web watches `config.yaml` through Cordis HMR. A valid source-list change installs and swaps the complete repository Plugin generation; a failed fetch, prepare, import, or Plugin application keeps the last good tree and broadcasts `hmr/config-update-failed(filename, error)`. Headless reads the file only at startup, and raw-config mode reads only its explicit overlay. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md).
Long-lived surfaces watch both `cordis.patch.yml` layers through Cordis HMR. A valid source-list change installs and swaps the complete repository Plugin generation; a failed fetch, prepare, import, or Plugin application keeps the last good tree and broadcasts `hmr/config-update-failed(filename, error)`. One-shot runs read the layers only at startup, and a `--patch` overlay is never watched. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md).
## Preparation
@@ -30,7 +30,7 @@
## 独立应用配置
随附 `dsh` 中供原始配置、Web 与无头模式使用的基础配置包含一个空 `repository-plugins` 配置项。Web 或无头用户可在 `$DSH_HOME/config.yaml`(默认 `~/.dsh/config.yaml`)中替换该配置项的配置,以启用精确指定的 GitHub generation;原始配置部署则在显式 overlay 中 patch 同一配置项:
每个 profile 都以之为起点的随附 `dsh-base` 组合包包含一个空 `repository-plugins` 配置项。用户可在用户 patch 层中替换该配置项的配置来启用精确指定的 GitHub generation:写入 `$DSH_HOME/profiles/<name>/cordis.patch.yml`,或写入各 profile 共享的 home 级 `$DSH_HOME/cordis.patch.yml`;`--patch` overlay 则只为单次运行 patch 同一配置项:
```yaml
- id: repository-plugins
@@ -43,7 +43,7 @@
每个源都必须采用 `github:owner/repository#<ref>`。省略 `&path:` 时选择 `/.dsh-plugin`;显式路径是仓库内的绝对路径,并且必须以 `.dsh-plugin` 结尾。commit ref 提供最清晰的不可变身份;tag 和 branch 仍可作为精确配置值使用。`cacheDir` 可覆盖默认缓存根 `$DSH_HOME/cache/repository-plugins`。
Web 通过 Cordis HMR(热模块替换)监视 `config.yaml`。有效的源列表变更会安装并替换整套 repository Plugin generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。无头模式只在启动时读取该文件,原始配置模式只读取其显式 overlay。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入 repository Plugin 的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。
长期运行的 surface 通过 Cordis HMR(热模块替换)监视两个 `cordis.patch.yml` 层。有效的源列表变更会安装并替换整套 repository Plugin generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。一次性运行只在启动时读取这些层,`--patch` overlay 则从不被监视。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入 repository Plugin 的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。
## 准备阶段
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/credentials/credentials-local/README.md
README.md: 02b883958faf8b695a3a2abf2df77790cc2fca86
README.zh.md: a279336f59ca9525ebeb918dd23fb0b7682443d5
README.md: 7d541d42efc942310e9a4066a9edadedb608ef0b
README.zh.md: 6a3d697551c8607bbab59a81c84eb725bf6191eb
@@ -32,7 +32,7 @@ External edits publish `credentials/updated` per changed reference after the sna
## Security boundary
The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)), so reaching the value takes a deliberate read of a path the agent was not given.
The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Harness-home layers](../../ui/app-boot/README.md#profiles)), so reaching the value takes a deliberate read of a path the agent was not given.
That is discretion, not a boundary. A deployment that must keep provider keys away from its own agent cannot get there with file permissions; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package.
@@ -32,7 +32,7 @@ dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一
## 安全边界
文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)),因此要拿到这个值,需要刻意去读一条并未交给 agent(智能体)的路径。
文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的 Harness home 各层](../../ui/app-boot/README.md#profiles)),因此要拿到这个值,需要刻意去读一条并未交给 agent(智能体)的路径。
这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串提供方——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本提供方并列。
@@ -341,11 +341,11 @@ describe('include refresh with overlay patches', () => {
describe('include patches layered over one base', () => {
it('lets a later patch configure or disable a row an earlier patch inserted', async () => {
// The surface/`--config`/personal composition: `dsh` includes one shared
// base and applies each source as its own patch list at the SAME include
// The bundle/user-layer/`--patch` composition: `dsh` includes one root
// and applies each source as its own patch list at the SAME include
// level, because patches never cross an include boundary. A later layer
// must therefore be able to reach a row an earlier layer inserted, or
// surface-only rows would be invisible to the user's personal config.
// bundle-only rows would be invisible to the user's patch layer.
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-'))
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n')
@@ -355,30 +355,30 @@ describe('include patches layered over one base', () => {
' config:',
' path: ./base.yml',
' patches:',
// Layer 1 (a surface overlay): patch a base row and add two of its own.
// Layer 1 (a bundle layer): patch a base row and add two of its own.
' - id: shared',
' config:',
' value: surface',
' value: bundle',
' - insert:',
' - id: surface-kept',
' - id: bundle-kept',
' name: ./noop.mjs',
' config:',
' value: surface-default',
' - id: surface-dropped',
' value: bundle-default',
' - id: bundle-dropped',
' name: ./noop.mjs',
// Layer 2 (the user): reconfigure one inserted row and disable the other.
' - id: surface-kept',
' - id: bundle-kept',
' config:',
' value: personal',
' - id: surface-dropped',
' value: user',
' - id: bundle-dropped',
' disabled: true',
'',
].join('\n'))
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
try {
expect(entryConfig(ctx, 'shared')).toEqual({ value: 'surface' })
expect(entryConfig(ctx, 'surface-kept')).toEqual({ value: 'personal' })
const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'surface-dropped')
expect(entryConfig(ctx, 'shared')).toEqual({ value: 'bundle' })
expect(entryConfig(ctx, 'bundle-kept')).toEqual({ value: 'user' })
const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'bundle-dropped')
expect(dropped?.options.disabled).toBe(true)
expect(dropped?.fiber).toBeUndefined()
} finally {
@@ -17,12 +17,12 @@ import {
boot,
loadOptionalPatches,
PROFILE_PATCH_FILENAME,
watchPersonalPatches,
watchUserPatches,
} from '../src/index.ts'
const NAME = 'dsh-test-bin'
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-personal-config-'))
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-user-patches-'))
async function eventually(test: () => boolean, message: string): Promise<void> {
const deadline = Date.now() + 10_000
@@ -39,15 +39,15 @@ describe('loadOptionalPatches', () => {
delete process.env.DSH_HOME
})
it('returns undefined when no personal patches file exists', () => {
it('returns undefined when no user patch file exists', () => {
expect(loadOptionalPatches(NAME, join(tmp(), PROFILE_PATCH_FILENAME))).toBeUndefined()
})
it('parses a patch list and preserves !!js expressions as loader expression nodes', () => {
const dir = tmp()
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), [
'- id: tui-agent',
" name: '@deepseek-ai/dsh-tui-demo'",
'- id: agent-loop',
" name: '@deepseek-ai/dsh-agent-loop'",
' config:',
' model: !!js process.env.DSH_SPEC_MODEL',
'- insert:',
@@ -58,13 +58,13 @@ describe('loadOptionalPatches', () => {
const patches = loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))
expect(patches).toHaveLength(2)
expect(patches?.[0]).toMatchObject({
id: 'tui-agent',
id: 'agent-loop',
config: { model: { __jsExpr: 'process.env.DSH_SPEC_MODEL' } },
})
expect(patches?.[1]?.insert).toHaveLength(1)
})
it('fails loud on an unreadable file (a present personal config is never skipped)', () => {
it('fails loud on an unreadable file (a present user patch layer is never skipped)', () => {
const dir = tmp()
mkdirSync(join(dir, PROFILE_PATCH_FILENAME)) // a directory: present, unreadable as a file
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
@@ -92,7 +92,7 @@ describe('loadOptionalPatches', () => {
})
})
describe('boot with personal patches', () => {
describe('boot with user patches', () => {
function writeTree(dir: string): string {
writeFileSync(join(dir, 'noop.mjs'), [
'export const name = "noop"',
@@ -111,31 +111,31 @@ describe('boot with personal patches', () => {
it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => {
const dir = tmp()
const personal = tmp()
writeFileSync(join(personal, PROFILE_PATCH_FILENAME), [
const userDir = tmp()
writeFileSync(join(userDir, PROFILE_PATCH_FILENAME), [
'- id: noop',
' name: ./noop.mjs',
' config:',
' value: !!js process.env.DSH_APP_BOOT_PERSONAL_SPEC',
' value: !!js process.env.DSH_APP_BOOT_USER_SPEC',
'- insert:',
' - id: personal-extra',
' - id: user-extra',
' name: ./noop.mjs',
'',
].join('\n'))
process.env['DSH_APP_BOOT_PERSONAL_SPEC'] = 'personal-value'
const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(personal, PROFILE_PATCH_FILENAME)))
process.env['DSH_APP_BOOT_USER_SPEC'] = 'user-value'
const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(userDir, PROFILE_PATCH_FILENAME)))
try {
const noop = [...ctx.loader.entries()].find(entry => entry.options.id === 'noop')
// The mounted plugin received the interpolated environment value.
expect(noop?.fiber?.config).toEqual({ value: 'personal-value' })
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'personal-extra')).toBe(true)
expect(noop?.fiber?.config).toEqual({ value: 'user-value' })
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'user-extra')).toBe(true)
} finally {
await ctx.fiber.dispose()
delete process.env['DSH_APP_BOOT_PERSONAL_SPEC']
delete process.env['DSH_APP_BOOT_USER_SPEC']
}
})
it('mounts no patch layer for an absent or empty personal overlay', async () => {
it('mounts no patch layer for an absent or empty user layer', async () => {
const dir = tmp()
const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(tmp(), PROFILE_PATCH_FILENAME)))
try {
@@ -155,8 +155,8 @@ describe('boot with personal patches', () => {
it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => {
const dir = tmp()
const personal = tmp()
const filename = join(personal, PROFILE_PATCH_FILENAME)
const userDir = tmp()
const filename = join(userDir, PROFILE_PATCH_FILENAME)
const basePatches = [{ id: 'noop', config: { value: 'generated' } }]
const ctx = await boot(NAME, writeTree(dir), basePatches)
await ctx.plugin(Timer)
@@ -165,14 +165,14 @@ describe('boot with personal patches', () => {
ctx.on('hmr/config-update-failed', (failedFilename, error) => {
failures.push({ filename: failedFilename, error })
})
const dispose = await watchPersonalPatches(ctx, {
const dispose = await watchUserPatches(ctx, {
binName: NAME,
filename,
compose: personalPatches => [...basePatches, ...personalPatches],
compose: userPatches => [...basePatches, ...userPatches],
})
try {
writeFileSync(filename, '- id: noop\n config:\n value: live\n')
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'personal config addition was not applied')
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'user patch addition was not applied')
writeFileSync(filename, '- id: noop\n config:\n fail: true\n')
await eventually(() => failures.length === 1, 'failed candidate was not broadcast')
@@ -192,17 +192,17 @@ describe('boot with personal patches', () => {
await settleChokidarChangeThrottle()
unlinkSync(filename)
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'personal config removal did not restore the app-owned patch')
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'user patch removal did not restore the app-owned patch')
expect(failures).toHaveLength(2)
await settleChokidarChangeThrottle()
// Default compose: the personal overlay IS the whole patch list, so a
// Default compose: the user layer IS the whole patch list, so a
// fresh generation replaces the app-owned layer instead of stacking on it.
await dispose()
const disposeDefault = await watchPersonalPatches(ctx, { binName: NAME, filename })
const disposeDefault = await watchUserPatches(ctx, { binName: NAME, filename })
try {
writeFileSync(filename, '- id: noop\n config:\n value: identity\n')
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose personal patch was not applied')
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose user patch was not applied')
} finally {
await disposeDefault()
}
@@ -215,7 +215,7 @@ describe('boot with personal patches', () => {
it('fails loud when the exact watcher lacks HMR or a root Include', async () => {
const dir = tmp()
const withoutHmr = await boot(NAME, writeTree(dir))
await expect(watchPersonalPatches(withoutHmr, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the Cordis HMR service')
await expect(watchUserPatches(withoutHmr, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the Cordis HMR service')
await withoutHmr.fiber.dispose()
const withoutInclude = new Context()
@@ -223,7 +223,7 @@ describe('boot with personal patches', () => {
await withoutInclude.plugin(Loader)
await withoutInclude.plugin(Timer)
await withoutInclude.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
await expect(watchPersonalPatches(withoutInclude, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the root Include entry')
await expect(watchUserPatches(withoutInclude, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the root Include entry')
await withoutInclude.fiber.dispose()
})
@@ -238,7 +238,7 @@ describe('boot with personal patches', () => {
try {
const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' })
ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) })
const dispose = await watchPersonalPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })
const dispose = await watchUserPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })
await expect(dispose()).resolves.toBeUndefined()
} finally {
await ctx.fiber.dispose()
@@ -252,9 +252,9 @@ describe('boot with personal patches', () => {
try {
await ctx.plugin(Timer)
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
const dispose = await watchPersonalPatches(ctx, { binName: NAME, filename })
// Same personal path registered twice: HMR refuses; not a teardown race.
await expect(watchPersonalPatches(ctx, { binName: NAME, filename })).rejects.toThrow('already registered')
const dispose = await watchUserPatches(ctx, { binName: NAME, filename })
// Same user-layer path registered twice: HMR refuses; not a teardown race.
await expect(watchUserPatches(ctx, { binName: NAME, filename })).rejects.toThrow('already registered')
await dispose()
} finally {
await ctx.fiber.dispose()