diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml index 67d2b330b1..d243b435cc 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md -2026-08-11-preset-authoring-agent-validates-its-own-composition.md: 6b9cdf32b70e3ab4adc9f3b0e20bb3d2245486c7 -2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: e6e8dabcd886a6331d294744b667552caa01e7b4 +2026-08-11-preset-authoring-agent-validates-its-own-composition.md: 15efa02b703abe6b0b694dbdca7054051ce168ec +2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: 3d22799fffae2003b935e9360501164730313df3 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md index 6b9cdf32b7..15efa02b70 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md @@ -32,7 +32,9 @@ The agent reaches the roster service the way `cordis_mount` documents: a tempora "Whether a row publishes a service" resolves through `cordis_inspect what:"services"`, which names the owning fiber of every live service. -The guidance keeps `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the answer to "where do my presets live" — it is where every `dsh` launcher puts them — while routing the path an agent actually reads or edits through `list()` or `resolve()`. `Config.roots` defaults to `[]` and `apps/cli` patches both roots in, `writableRoot()` takes the first `user` one, and no call reports either path; `authorable` answers only whether a writable root exists, and `list()` cannot reveal a user root that holds nothing yet. Stating the path is therefore right for talking to a person and wrong for feeding a file tool. +The guidance keeps `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the answer to "where do my presets live" while routing the path an agent actually reads or edits through `list()` or `resolve()`. Stating the path is right for talking to a person and wrong for feeding a file tool: no call reports a root, `authorable` answers only whether a writable one exists, and `list()` cannot reveal a user root that holds nothing yet. + +That path is now a property of the package rather than of one launcher. `AgentPresets` derives `/.agent-presets` as a `user` root unless `includeUserRoot` is false, the way [`dsh-skill-local`](../../../../packages/skill/skill-local/README.md) derives `/skills`, and `apps/cli` supplies only the SHIPPED root — the one path an installed app alone can resolve. The asymmetry it replaces cost a bug: with both roots patched in by one launcher, `dsh run` booted a roster with no roots at all and failed resolving `standard` (fixed then by teaching every launcher the patch). The derived root is appended after every configured root, so a shipped id still shadows a home directory claiming it, and `writableRoot()` still prefers an explicitly configured `user` root. It is resolved once at construction: a root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw. The prohibition on touching the shipped install is promoted from a paragraph inside the authoring steps to a top `## Off-limits` section, extended to cover editing the host composition as a workaround. The new self-validation calls do not weaken it: `copy()` refuses an id any root supplies, and `remove()` refuses a preset that ships with the deployment. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md index e6e8dabcd8..3d22799fff 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md @@ -32,7 +32,9 @@ agent 按 `cordis_mount` 自身文档所述的方式够到 roster 服务:挂 「某行是否发布服务」改由 `cordis_inspect what:"services"` 回答,它会给出每个存活服务的持有 fiber。 -指导保留 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为「我的 preset 在哪」的答案——每个 `dsh` 启动器都把它们放在那里——同时把 agent 实际读取或编辑的路径改走 `list()` 或 `resolve()`。`Config.roots` 默认为 `[]`,两个根均由 `apps/cli` 补入,`writableRoot()` 取其中第一个 `user` 根,且没有任何调用会报告任一路径;`authorable` 只回答是否存在可写根,而 `list()` 无法揭示一个尚且为空的用户根。因此写出该路径对人讲是对的,喂给文件工具是错的。 +指导保留 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为「我的 preset 在哪」的答案,同时把 agent 实际读取或编辑的路径改走 `list()` 或 `resolve()`。写出该路径对人讲是对的,喂给文件工具是错的:没有任何调用会报告根目录,`authorable` 只回答是否存在可写根,而 `list()` 无法揭示一个尚且为空的用户根。 + +该路径如今是本包的属性,而非某个启动器的属性。除非 `includeUserRoot` 为 false,`AgentPresets` 自行推导 `/.agent-presets` 作为 `user` 根,正如 [`dsh-skill-local`](../../../../packages/skill/skill-local/README.md) 推导 `/skills`;`apps/cli` 只提供**随附**根——那是唯有已安装 app 才能解析的路径。它取代的那种不对称曾付出过代价:两个根都由单一启动器补入时,`dsh run` 启动的 roster 一个根都没有,解析 `standard` 直接失败(当时的修法是让每个启动器都执行该 patch)。推导出的根追加在全部已配置根之后,因此随附 id 仍会遮蔽占用它的家目录目录,而 `writableRoot()` 仍优先选择显式配置的 `user` 根。它在构造时解析一次:若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。 禁止改动随发布安装的约束,从创作步骤中的一段提升为顶部的 `## Off-limits` 一节,并扩展到禁止改宿主组装绕行。新增的自校验调用不削弱它:`copy()` 拒绝任何根已提供的 id,`remove()` 拒绝随部署发布的 preset。 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index dae6e992ec..462791d489 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -29,13 +29,11 @@ import { watchUserPatches, type Profile, } from '@deepseek-ai/dsh-app-boot' -import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' /** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url)) -/** Harness-home directory holding locally authored agent presets. */ -const USER_PRESET_DIR = '.agent-presets' import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' @@ -159,16 +157,16 @@ function composeProfile( if (typeof row.id === 'string') rows.set(row.id, row) } const composedOverlays = [...overlays] - // Preset roots belong to every dsh composition that mounts the roster. + // The SHIPPED root is the part of the roster only this app can resolve: it + // sits beside this app's own config, in both the source and built layouts. + // The writable root the roster appends is `dsh-agent-presets`' own, so a + // launcher that never reaches this patch still finds a person's presets. if (rows.has('agent-presets')) { composedOverlays.push({ id: 'agent-presets', config: { ...(rows.get('agent-presets')?.config ?? {}) as Record, - roots: [ - { path: SHIPPED_PRESET_ROOT, trust: 'system' }, - { path: dshHomePath(USER_PRESET_DIR), trust: 'user' }, - ], + roots: [{ path: SHIPPED_PRESET_ROOT, trust: 'system' }], }, }) } diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index f37da4c4a7..d554202b04 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -93,7 +93,11 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis // document overrides. { id: 'agent-presets', - config: { default: 'standard', roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }] }, + config: { + default: 'standard', + roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }], + includeUserRoot: false, + }, }, ...extra, ] @@ -447,6 +451,7 @@ describe('product subagent rows in user presets', () => { { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }, { path: userRoot, trust: 'user' }, ], + includeUserRoot: false, }, }]) }, 120_000) @@ -647,6 +652,7 @@ describe('authoring a preset on the shipped composition', () => { // nothing is the normal first-run state. { path: userRoot, trust: 'user' }, ], + includeUserRoot: false, }, }]) }) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 87f75b383d..344a539061 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -385,7 +385,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise/.agent-presets` as a `user` root, after every configured root | An absent root supplies no presets rather than failing: the user root does not exist until the first locally authored preset, and naming a default no root supplies already fails loud at resolution. +### The writable root is this package's, the shipped root is the app's + +`/.agent-presets` is where a person's own presets live, the way `/skills` is where their own skills live ([`dsh-skill-local`](../../skill/skill-local/README.md)), so the roster derives it rather than waiting for a deployment to remember it — a launcher that configures nothing still finds and authors presets. It is appended AFTER every configured root, which keeps an earlier root winning a duplicate id: a shipped `standard` still shadows a home directory that claimed the name, and `copy()` refuses that id rather than landing a preset nothing would resolve. + +The roots are resolved once, when the service is constructed. A root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw. + +`includeUserRoot: false` mounts a roster over `roots` alone. A deployment that confines presets to its own directories needs it, and so does any test pinning an exact roster — otherwise the machine's real `` decides what the roster contains. + +The SHIPPED root stays an assembly fact: it sits beside the installed app's own config, a path only that app can resolve. + ### The default preset is a user setting When a settings provider is composed, this plugin registers the `agent-presets` namespace with `config.default` as its composition base, so the user document layers over the deployment's engineering default: diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 1d8d1481c2..ceb8f0284c 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -86,9 +86,20 @@ description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agen |---|---|---| | `default` | 必填 | 调用方未指定时挂载的 preset id | | `roots` | `[]` | 按优先级排列的扫描目录;每项提供 `path`(开头的 `~` 会展开)与 `trust`(默认为 `user`) | +| `includeUserRoot` | `true` | 在全部已配置根目录之后,追加 `/.agent-presets` 作为 `user` 根目录 | 根目录不存在时视为不提供任何 preset,而非失败:用户根目录在写出第一个本地 preset 之前并不存在,而指定了没有任何根目录提供的默认值,在解析时本就会明确报错。 +### 可写根目录属于本包,随附根目录属于 app + +`/.agent-presets` 是个人自有 preset 的所在,正如 `/skills` 是其自有 skill 的所在([`dsh-skill-local`](../../skill/skill-local/README.md)),因此 roster 自行推导它,而不等某个部署记得配置——一个什么都没配的启动器同样能发现并创作 preset。它追加在全部已配置根目录**之后**,从而保持靠前的根目录赢得重复 id:随附的 `standard` 仍然遮蔽一个占用该名字的家目录目录,而 `copy()` 会拒绝该 id,不会落下一个无人解析得到的 preset。 + +根目录在服务构造时解析一次。若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。 + +`includeUserRoot: false` 使 roster 只覆盖 `roots`。把 preset 限制在自有目录内的部署需要它,任何钉住确切 roster 的测试同样需要——否则将由这台机器真实的 `` 决定 roster 的内容。 + +随附根目录仍然是装配事实:它位于已安装 app 自身配置的旁边,那个路径只有该 app 能解析。 + ### 默认 preset 是一项用户设置 当组装中存在 settings 提供方时,本插件会注册 `agent-presets` 命名空间,并以 `config.default` 作为其组装 base,因此用户文档会层叠覆盖部署方的工程默认值: diff --git a/packages/preset/agent-presets/src/discovery.ts b/packages/preset/agent-presets/src/discovery.ts index 4ab3f67e50..20efc80e0c 100644 --- a/packages/preset/agent-presets/src/discovery.ts +++ b/packages/preset/agent-presets/src/discovery.ts @@ -25,6 +25,17 @@ import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts' /** The composition file that makes a directory a preset. */ export const COMPOSITION_FILE = 'agent.cordis.yml' +/** + * Harness-home directory holding locally authored presets. + * + * This package owns the writable root the way `dsh-skill-local` owns + * `/skills`. An app must assemble the SHIPPED root, whose path only + * the installed app can resolve; where a person's own presets go is the same + * place in every deployment that does not say otherwise, so a launcher that + * forgets to configure one still finds them. + */ +export const USER_PRESET_DIR = '.agent-presets' + /** * Why `rows` cannot be an entry list, or undefined when it can. * diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 3da5e3b5c9..c146e75774 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -28,11 +28,12 @@ import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type // Type-only: resolves the `agent/created` lifecycle event this service watches. import type {} from '@deepseek-ai/dsh-agent' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' -import { discoverPresets } from './discovery.ts' +import { dshHomePath } from '@deepseek-ai/dsh-paths' +import { discoverPresets, USER_PRESET_DIR } from './discovery.ts' import { copyComposition, deleteComposition, readComposition } from './authoring.ts' import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts' import { PresetExistsError } from './authoring.ts' -import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './preset.ts' +import { PresetMountError, UnknownPresetError, type AgentPreset, type Config, type PresetRoot } from './preset.ts' import type {} from './types.ts' /** Settings namespace carrying the user's chosen default preset. */ @@ -49,7 +50,7 @@ export const AgentPresetSettingsSchema: z = z.object({ default: z.string(), }) -export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts' +export { COMPOSITION_FILE, discoverPresets, scanRoot, USER_PRESET_DIR } from './discovery.ts' export { METADATA_FILE, readPresetMetadata, renderPresetMetadata, type PresetMetadata, } from './metadata.ts' @@ -88,8 +89,21 @@ export class AgentPresets extends Service { path: z.string().required(), trust: z.union(['system', 'user'] as const).default('user'), })).default([]), + includeUserRoot: z.boolean().default(true), }) as z + /** + * The roots discovery and authoring actually scan: every configured root in + * order, then the harness-home user root unless `includeUserRoot` is false. + * + * Derived once, because a root set that changed between `list()` and the + * `copy()` acting on its answer would author into a directory the caller + * never saw. Appending rather than prepending keeps an earlier configured + * root winning a duplicate id, so a shipped preset still shadows a + * locally authored directory that claimed its name. + */ + private readonly resolvedRoots: readonly PresetRoot[] + /** * The user layer over `config.default`, present only while a settings * provider is composed. Held rather than snapshotted so a hot-reloaded @@ -116,6 +130,9 @@ export class AgentPresets extends Service { constructor(ctx: Context, public config: Config) { super(ctx, 'agentPresets') this.selfCtx = ctx + this.resolvedRoots = config.includeUserRoot + ? [...config.roots, { path: dshHomePath(USER_PRESET_DIR), trust: 'user' }] + : [...config.roots] // Deliberately not `installSettingsSection`: that helper exists to re-judge // what a consumer DERIVED from the source — memoized resolutions, // registration-level facts — across attach, detach, and change. Nothing @@ -147,7 +164,7 @@ export class AgentPresets extends Service { // does that today — the Web surface mounts in `setup` and children join // through `composeFrom` before publication. ctx.on('agent/created', ({ agent }) => { - if (this.config.roots.length === 0) return + if (this.resolvedRoots.length === 0) return if (this.composedPreset(agent.ctx) !== undefined) return ctx.logger.warn( `agent "${agent.id}" was published without joining an agent preset; ` @@ -180,7 +197,7 @@ export class AgentPresets extends Service { * @returns the presets, first-root-wins per id. */ async list(): Promise { - return await discoverPresets(this.config.roots) + return await discoverPresets(this.resolvedRoots) } /** @@ -322,7 +339,7 @@ export class AgentPresets extends Service { /** Whether this deployment configures a root locally authored presets go to. */ get authorable(): boolean { - return this.config.roots.some(root => root.trust === 'user') + return this.resolvedRoots.some(root => root.trust === 'user') } /** @@ -358,7 +375,7 @@ export class AgentPresets extends Service { if ((await this.list()).some(preset => preset.id === id)) { throw new PresetExistsError(id) } - await copyComposition(this.config.roots, source, id, name) + await copyComposition(this.resolvedRoots, source, id, name) // A settled mount under this id can only be stale (its preset was deleted // from disk outside `remove`); the new preset must not inherit it. Every // session already joined keeps the generation it runs on regardless. @@ -371,7 +388,7 @@ export class AgentPresets extends Service { * @throws when the preset is unknown or ships with the deployment. */ async remove(id: string): Promise { - await deleteComposition(this.config.roots, await this.resolve(id)) + await deleteComposition(this.resolvedRoots, await this.resolve(id)) // Sessions on the deleted preset keep their standing mount; only new // sessions see the roster without it. this.standing.delete(id) diff --git a/packages/preset/agent-presets/src/preset.ts b/packages/preset/agent-presets/src/preset.ts index b2b48ea6ea..554348cdd6 100644 --- a/packages/preset/agent-presets/src/preset.ts +++ b/packages/preset/agent-presets/src/preset.ts @@ -54,6 +54,11 @@ export interface Config { default: string /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ roots: PresetRoot[] + /** + * Append the harness home's `USER_PRESET_DIR` as a `user` root, after every + * configured root. False mounts a roster over `roots` alone. + */ + includeUserRoot: boolean } /** diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts index df69a792d5..8086996111 100644 --- a/packages/preset/agent-presets/tests/authoring.spec.ts +++ b/packages/preset/agent-presets/tests/authoring.spec.ts @@ -52,6 +52,10 @@ beforeEach(async () => { { path: join(FIXTURES, 'system'), trust: 'system' as const }, { path: userRoot, trust: 'user' as const }, ], + // Every roster in this file pins its own roots: the derived harness-home + // root would add the developer's real presets to what these assertions + // count, and `copy` would write into it. + includeUserRoot: false, }) }) @@ -199,6 +203,7 @@ describe('a deployment with more than one user root', () => { { path: userRoot, trust: 'user' as const }, { path: second, trust: 'user' as const }, ], + includeUserRoot: false, }) // Writes go to the first user root, so a preset discovered from a later @@ -219,6 +224,7 @@ describe('a deployment with no writable root', () => { await readOnly.plugin(AgentPresets, { default: 'standard', roots: [{ path: join(FIXTURES, 'system'), trust: 'system' as const }], + includeUserRoot: false, }) expect(readOnly.agentPresets.authorable).toBe(false) @@ -240,6 +246,7 @@ describe('a user root that does not exist yet', () => { { path: join(FIXTURES, 'system'), trust: 'system' as const }, { path: absent, trust: 'user' as const }, ], + includeUserRoot: false, }) await fresh.agentPresets.copy('standard', 'mine') diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts index 709ee5ba00..570fdb31b9 100644 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -31,7 +31,7 @@ async function harness(): Promise { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS }) + await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeUserRoot: false }) await ctx.plugin(InvariantService) await ctx.plugin(AgentPresetsInvariant) return ctx diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 92a080a930..a02b286fee 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -38,7 +38,7 @@ const ROOTS = [ * @param roster - roster config, defaulting to the fixture roots. * @returns the booted context. */ -async function harness(roster: Config = { default: 'standard', roots: ROOTS }): Promise { +async function harness(roster: Config = { default: 'standard', roots: ROOTS, includeUserRoot: false }): Promise { const ctx = new Context() ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' await ctx.plugin(Loader) @@ -94,7 +94,7 @@ describe('composing an agent from a preset', () => { join(presetDir, COMPOSITION_FILE), `- id: only\n name: ${plugin}\n config:\n tool: absolute\n`, ) - const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }] }) + const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }], includeUserRoot: false }) const imported = vi.spyOn(scoped.loader.internal!, 'import') await agentOn(scoped, 'sess-absolute-plugin') @@ -347,7 +347,7 @@ describe('composing from a broken preset', () => { const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-')) await mkdir(join(root, 'damaged')) await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition) - return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }] }) + return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) } it('refuses the mount up front with the discovery-reported reason', async () => { @@ -380,7 +380,7 @@ describe('a roster with nothing in it', () => { it('says so instead of naming an empty list of candidates', async () => { const bare = new Context() await bare.plugin(Loader) - await bare.plugin(AgentPresets, { default: 'standard', roots: [] }) + await bare.plugin(AgentPresets, { default: 'standard', roots: [], includeUserRoot: false }) await expect(bare.agentPresets.resolve()) .rejects.toThrow(/preset "standard" not found \(available: none\)/) @@ -418,7 +418,7 @@ describe('the preset file is an input, never a persistence target', () => { await scoped.plugin(ToolRegistry) await scoped.plugin(AgentRegistry) await scoped.plugin(AgentLoop, { agents: [] }) - await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }] }) + await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) await scoped.agents.create({ sessionId: SessionId('sess-self-dispose'), @@ -532,7 +532,7 @@ describe('replacing a composition', () => { // Presets are optional: every surface except the Web bundle keeps its // model-facing rows in the host plane, so an agent with a chain of one is // exactly right there and the diagnostic must stay silent. - const rosterless = await harness({ default: 'standard', roots: [] }) + const rosterless = await harness({ default: 'standard', roots: [], includeUserRoot: false }) const warnings: string[] = [] rosterless.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof rosterless.logger.warn @@ -581,7 +581,7 @@ describe('replacing a composition', () => { await scoped.plugin(ToolRegistry) await scoped.plugin(AgentRegistry) await scoped.plugin(AgentLoop, { agents: [] }) - await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }] }) + await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) const handle = await scoped.agents.create({ sessionId: SessionId('sess-restore-gone'), setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx, 'first'), @@ -621,7 +621,7 @@ describe('editing a composition file', () => { await mkdir(join(root, id)) const path = join(root, id, COMPOSITION_FILE) await writeFile(path, rowFor('before')) - const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }] }) + const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) return { scoped, path } } diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index ef75eb8b78..49f1636a6c 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -49,7 +49,7 @@ async function harness( await ctx.plugin(AgentLoop, { agents: [] }) const settingsFiber = ctx.plugin(SettingsLocal, { path: settingsFile, watch: false }) await settingsFiber - await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots] }) + await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots], includeUserRoot: false }) return { ctx, settingsFile, settingsFiber } } diff --git a/packages/preset/agent-presets/tests/user-root.spec.ts b/packages/preset/agent-presets/tests/user-root.spec.ts new file mode 100644 index 0000000000..0320d0c899 --- /dev/null +++ b/packages/preset/agent-presets/tests/user-root.spec.ts @@ -0,0 +1,129 @@ +/** + * The writable root is this package's own, not an assembly fact each app must + * remember: a roster configured with only a `system` root still discovers and + * authors into `/.agent-presets`, the way `dsh-skill-local` owns + * `/skills`. `includeUserRoot: false` is how a deployment — or a test + * pinning an exact roster — opts out. + * + * `$DSH_HOME` is repointed per test because the derived root is resolved in the + * constructor: the plugin must be mounted while the environment names the + * temporary home, or it would reach the developer's real one. + */ + +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import AgentPresets, { COMPOSITION_FILE, USER_PRESET_DIR, type Config } from '@deepseek-ai/dsh-agent-presets' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const SYSTEM_ROOT = join(FIXTURES, 'system') +const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n' + +let home: string +let previousHome: string | undefined + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'dsh-preset-home-')) + previousHome = process.env.DSH_HOME + process.env.DSH_HOME = home +}) + +afterEach(() => { + if (previousHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = previousHome +}) + +/** Boot a roster over the fixture system root, with the derived root left to the plugin. */ +async function roster(config: Partial = {}): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(AgentPresets, { + default: 'standard', + roots: [{ path: SYSTEM_ROOT, trust: 'system' as const }], + includeUserRoot: true, + ...config, + }) + return ctx +} + +/** Hand-place a preset directory under the harness home's preset root. */ +async function seedHomePreset(id: string): Promise { + await mkdir(join(home, USER_PRESET_DIR, id), { recursive: true }) + await writeFile(join(home, USER_PRESET_DIR, id, COMPOSITION_FILE), VALID) +} + +describe('the harness-home preset root', () => { + it('is what a roster gets when config names no roots at all', () => { + // The schema default is the contract an app relies on by saying nothing; + // every other case here passes the field explicitly. The cast stands for + // the untyped document the Loader hands the schema, which is where a + // composition that omits the key actually comes from. + const parsed = AgentPresets.Config({ default: 'standard' } as unknown as Config) + + expect(parsed).toMatchObject({ includeUserRoot: true, roots: [] }) + }) + + it('is discovered without any app configuring it', async () => { + await seedHomePreset('mine') + const ctx = await roster() + + const listed = await ctx.agentPresets.list() + + expect(listed.find(preset => preset.id === 'mine')).toMatchObject({ trust: 'user' }) + expect((await ctx.agentPresets.resolve('mine')).path) + .toBe(join(home, USER_PRESET_DIR, 'mine', COMPOSITION_FILE)) + }) + + it('makes a roster with only a system root authorable, and receives the copy', async () => { + const ctx = await roster() + + expect(ctx.agentPresets.authorable).toBe(true) + await ctx.agentPresets.copy('standard', 'copied') + + expect(existsSync(join(home, USER_PRESET_DIR, 'copied', COMPOSITION_FILE))).toBe(true) + }) + + it('sorts after every configured root, so a shipped id still shadows a home directory', async () => { + // `standard` exists in the fixture system root; claiming the name at home + // must not take it over, because `copy` refuses an id any root supplies + // and a session resolving `standard` must reach the shipped composition. + await seedHomePreset('standard') + const ctx = await roster() + + expect((await ctx.agentPresets.resolve('standard')).trust).toBe('system') + await expect(ctx.agentPresets.copy('standard', 'standard')).rejects.toThrow(/already exists/) + }) + + it('is absent under includeUserRoot: false, which leaves the roster unauthorable', async () => { + await seedHomePreset('mine') + const ctx = await roster({ includeUserRoot: false }) + + expect((await ctx.agentPresets.list()).map(preset => preset.id)).not.toContain('mine') + expect(ctx.agentPresets.authorable).toBe(false) + await expect(ctx.agentPresets.copy('standard', 'mine')) + .rejects.toThrow(/no user-writable preset root/) + }) + + it('yields to a configured user root for authoring, which writableRoot takes first', async () => { + const explicit = await mkdtemp(join(tmpdir(), 'dsh-preset-explicit-')) + const ctx = await roster({ + roots: [ + { path: SYSTEM_ROOT, trust: 'system' as const }, + { path: explicit, trust: 'user' as const }, + ], + }) + + await ctx.agentPresets.copy('standard', 'copied') + + expect(existsSync(join(explicit, 'copied', COMPOSITION_FILE))).toBe(true) + expect(existsSync(join(home, USER_PRESET_DIR, 'copied'))).toBe(false) + }) +}) diff --git a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts index 28306f0dcc..b4c5d5736e 100644 --- a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts @@ -40,7 +40,7 @@ async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter; ctx.loader.builtins.include = Include await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS }) + await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeUserRoot: false }) const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')]) ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({