feat(web): author agent presets from a settings page

A composition is a file, but "edit it on the filesystem" is not a browser
affordance. The roster gains `read`/`write`/`remove` beside `select`, and
the browser gains a settings section over them: the presets as rows, one
composition open in a YAML editor at a time, and per-row default, duplicate,
and delete.

All four authoring methods are loopback-pinned. A composition names the
plugins a session runs, so reading one is reconnaissance, writing one is
arbitrary capability, and selecting one can move a session onto a preset
that edits the live runtime. `agentPreset.list` deliberately stays ordinary
and now reports `authorable`, so a surface knows whether creating is
possible at all rather than offering a button whose save always fails.

Authoring starts by duplicating: a shipped preset opens read-only because
the deployment's copy is what a broken local one is compared against. Ids
are contained before they become directory names, and the text is parsed
with the loader's own schema, so a save cannot leave a file no session
could load.

Fixes a defect the real-composition test found: a preset written under the
user's home could never mount, because the loader resolves a row against the
composition's own directory and Node's `node_modules` walk from there never
reaches the installed harness. The mount now records the host base and sends
bare specifiers there, leaving relative paths resolving from the preset.

Also closes the coverage the earlier surfaces in this stack shipped without —
the General row, the composer seat, and the plugin halves now have tests.
This commit is contained in:
Yichen Jiang
2026-08-04 12:23:40 +08:00
parent 52607cab69
commit 6dfc568ec2
56 changed files with 3478 additions and 110 deletions
+29 -1
View File
@@ -14,6 +14,11 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal
- `ctx.agentPresets.list(): Promise<AgentPreset[]>` Every preset the configured roots currently supply, earlier root winning a duplicate id.
- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it.
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` Compose one agent from a preset and return the preset that was mounted, for the caller to record.
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` Replace the composition installed for one agent. Valid only while the agent has produced nothing — **the caller owns that check**; this method does not read session history.
- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be written at all.
- `ctx.agentPresets.read(id): Promise<string>` One preset's composition text, exactly as stored.
- `ctx.agentPresets.write(id, content): Promise<void>` Create or replace a locally authored preset.
- `ctx.agentPresets.remove(id): Promise<void>` Delete a locally authored preset.
`AgentPreset` carries `id` (the directory name), `trust` (`system` or `user`, from the root it was found under), and `path` (the absolute composition file).
@@ -27,6 +32,28 @@ The creation header names the preset a session STARTED with; `resolveSessionPres
The header stays frozen because it is a creation fact. A switch is an `agent-preset/selected` session event appended after the swap commits, which is what the model-visible ⟺ logged rule requires: the preset decides the tool schemas and prompt sections the model sees, so it has to be reconstructable from the log. Reading the header alone would rebuild a switched session under the composition it was created with, replaying history the new tool set cannot act on — the exact hazard the blank-only lock exists to prevent.
### Switching a blank agent
`recompose()` unmounts the installed subtree and mounts the new one, because two compositions cannot coexist — both would register the same tool names into one layer. A failed mount restores the previous composition rather than leaving the agent with nothing, and an unknown id is rejected before anything is torn down.
The restriction to a produced-nothing agent is a product rule, not a mechanical one: swapping tools mid-conversation would leave logged tool calls the new composition cannot make. The gateway enforces it at the wire ([`dsh-apiproxy`](../../host/apiproxy/README.md) answers `agent-preset-locked`), which is where session history is in hand.
## Authoring
A locally authored preset is a directory under the first `user` root holding one `agent.cordis.yml`. `write()` refuses three things before anything lands:
- **An id that is not `[a-z0-9][a-z0-9-]*`.** The id becomes a directory name, so containment is a property of the id itself rather than of a path check after the fact — `../escape`, `a/b`, and an absolute path are all rejected as ids.
- **Text that is not a Cordis entry list.** The content is parsed with the loader's own schema and dialect (`!!js` included), so a save cannot leave a file no session could load. Shape only: a composition naming a plugin that does not exist is accepted here and fails at the next session that selects it.
- **A preset that ships with the deployment.** Overwriting one would remove the known-good composition a broken local preset is compared against. `remove()` refuses the same.
Writes are atomic and owner-only (`0o600`, in a `0o700` directory), and the root is created on first write — a deployment configuring a user root that does not exist yet is the normal first-run state.
### How a preset's rows resolve
A row's **package name** resolves from the host composition, not from the preset directory. The Loader normally resolves an entry against its own tree's `baseUrl`, which for a preset is wherever the composition file sits; a locally authored preset lives under the user's home, where Node's upward `node_modules` walk never reaches the harness, so every `@deepseek-ai/dsh-*` row would fail to import. The mount records the host base before plugging the subtree and sends bare specifiers there.
A **relative** path still resolves from the preset's own directory, so a preset's own plugin files and skill directories travel with it.
## Config
| Field | Default | Meaning |
@@ -79,6 +106,7 @@ Prefix-stable for the life of an agent: a composition is installed once, before
## Known Limitations and Deferred Work
- **A preset cannot be changed on a live agent** — the mount happens once during creation, so switching a running session's composition would mean unwinding its subtree mid-turn, dropping tools the model may already have called. Changing the default affects only sessions created afterwards.
- **A preset cannot be changed once a session has produced anything** — `recompose()` covers the blank-agent case; past the first turn, unwinding the subtree would drop tools the model may already have called, so the choice is fixed for the session's life. Changing the default affects only sessions created afterwards.
- **A written composition is never mounted to check it** — `write()` validates shape, not resolvability, so a preset naming a missing plugin is stored and fails at the next session that selects it.
- **Display names are the directory id** — a preset carries no manifest, so pickers and settings surfaces show the id until a consumer needs richer metadata.
- **Root scans are not watched** — every read hits the filesystem instead, which keeps the roster fresh but puts one `readdir` per root on each `list()`.
+29 -1
View File
@@ -14,6 +14,11 @@
- `ctx.agentPresets.list(): Promise<AgentPreset[]>` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出。
- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` 用一个 preset 组装一个 agent,并返回所挂载的 preset 供调用方记录。
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` 替换某个 agent 已装入的组装。仅在该 agent 尚未产出任何内容时有效——**该检查由调用方负责**,本方法不读取会话历史。
- `ctx.agentPresets.authorable: boolean` 是否存在 `user` 信任级别的根目录,也即是否可能写入 preset。
- `ctx.agentPresets.read(id): Promise<string>` 某个 preset 的组装文本,与存储内容完全一致。
- `ctx.agentPresets.write(id, content): Promise<void>` 创建或替换一个本地创作的 preset。
- `ctx.agentPresets.remove(id): Promise<void>` 删除一个本地创作的 preset。
`AgentPreset` 携带 `id`(目录名)、`trust`(`system` 或 `user`,取自它所在的根目录)以及 `path`(组装文件的绝对路径)。
@@ -27,6 +32,28 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有
头部保持冻结,因为它是创建期事实。切换以 `agent-preset/selected` 会话事件记录,在替换提交之后追加;这正是 model-visible ⟺ logged 规则的要求:preset 决定模型看到的工具 schema 与提示词段落,因此必须能从日志重建。只读头部会让切换过的会话按创建时的组装重建,从而重放新工具集无法执行的历史——这正是「仅空白可切」那道锁要防的危险。
### 切换空白 agent
`recompose()` 先卸载已装入的子树、再装入新的,因为两份组装无法共存——它们会把相同的工具名注册进同一个层。挂载失败会恢复先前的组装,而不是让 agent 一无所有;未知 id 则在任何东西被拆除之前就被拒绝。
"仅限尚未产出任何内容的 agent"是一条产品规则而非机制约束:在对话进行中调换工具,会留下新组装无法执行的、已被记录的工具调用。该规则由网关在传输层执行([`dsh-apiproxy`](../../host/apiproxy/README.md) 返回 `agent-preset-locked`),因为会话历史在那里才拿得到。
## 创作
本地创作的 preset 是首个 `user` 根目录下的一个目录,其中放置一份 `agent.cordis.yml`。`write()` 在任何内容落盘之前拒绝三种情况:
- **不符合 `[a-z0-9][a-z0-9-]*` 的 id。** id 会成为目录名,因此约束是 id 自身的性质,而非事后再做一次路径检查——`../escape`、`a/b` 与绝对路径都作为 id 被拒绝。
- **不是 Cordis entry 列表的文本。** 内容使用 loader 自身的 schema 与方言(含 `!!js`)解析,因此保存不会留下任何会话都无法加载的文件。只校验形状:引用了不存在插件的组装在此被接受,并在下一个选择它的会话处失败。
- **随部署提供的 preset。** 覆写它会抹掉那份用来对照有问题的本地 preset 的已知良好组装。`remove()` 同样拒绝。
写入是原子的、仅属主可读写(`0o600`,位于 `0o700` 的目录内),且根目录在首次写入时创建——部署配置了尚不存在的用户根目录,正是首次运行的正常状态。
### preset 的各行如何解析
行的**包名**从宿主组装解析,而非从 preset 目录解析。Loader 通常按 entry 所属树的 `baseUrl` 解析,而对 preset 而言那就是组装文件所在之处;本地创作的 preset 位于用户主目录之下,Node 向上查找 `node_modules` 永远够不到 harness,因此每一个 `@deepseek-ai/dsh-*` 行都会导入失败。挂载在插入子树之前先记录宿主的基址,并把裸标识符送往那里。
**相对**路径仍从 preset 自身的目录解析,因此 preset 自带的插件文件与 skill 目录会随它一同迁移。
## 配置
| 字段 | 默认值 | 含义 |
@@ -79,6 +106,7 @@ Indirectly, through the plugins a mounted composition registers, which own every
## Known Limitations and Deferred Work
- **无法在存活的 agent 上更换 preset** —— 挂载只在创建时发生一次,因此切换运行中会话的组装意味着要在轮次进行途中卸载其子树,抽走模型可能已经调用的工具。更改默认值只影响此后创建的会话。
- **会话一旦产出任何内容便无法更换 preset** —— `recompose()` 覆盖空白 agent 的情形;第一个轮次之后,卸载子树会抽走模型可能已经调用的工具,因此该选择在会话的整个生命周期内固定。更改默认值只影响此后创建的会话。
- **写入的组装从不被实际挂载以校验** —— `write()` 校验形状而非可解析性,因此引用了缺失插件的 preset 会被存下,并在下一个选择它的会话处失败。
- **展示名称就是目录 id** —— preset 不携带 manifest,因此选择器与设置界面在有消费方需要更丰富的元数据之前,只显示 id。
- **根目录扫描不做监听** —— 每次读取都实际访问文件系统,这让名单保持新鲜,但每次 `list()` 会对每个根目录产生一次 `readdir`。
@@ -27,6 +27,7 @@
"peerDependencies": {
"@cordisjs/plugin-include": "^1.0.4",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-atomic-write": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
@@ -35,6 +36,7 @@
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"js-yaml": "^4.1.0",
"schemastery": "^3.18.0"
},
"devDependencies": {
@@ -42,6 +44,7 @@
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-atomic-write": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
@@ -0,0 +1,157 @@
/**
* Creating, reading, and deleting locally authored presets.
*
* Authoring is confined to a `user` root: the shipped `.system` set is part of
* the deployment, and letting a browser rewrite it would turn "reset to a known
* preset" into something the same caller could have broken first.
* @module @deepseek-ai/dsh-agent-presets/authoring
*/
import { readFile, rm } from 'node:fs/promises'
import { isAbsolute, join, resolve } from 'node:path'
import * as yaml from 'js-yaml'
import { entryListSchema } from '@cordisjs/plugin-include'
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { expandHomePath } from '@deepseek-ai/dsh-paths'
import { COMPOSITION_FILE } from './discovery.ts'
import type { AgentPreset, PresetRoot } from './types.ts'
/**
* Ids a preset directory may use.
*
* The id becomes a path segment, so this is a containment boundary rather than
* a style rule: `..`, a separator, or an absolute-looking name would place the
* composition outside the root the deployment authorised.
*/
const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/
/** A preset id that cannot be used as a directory name under a root. */
export class InvalidPresetIdError extends Error {
constructor(
/** The rejected id. */
readonly presetId: string,
) {
super(
`agent-presets: preset id ${JSON.stringify(presetId)} must match ${String(PRESET_ID)} — `
+ 'the id is a directory name, so anything else could escape the preset root',
)
}
}
/** A composition that is not a usable entry list. */
export class InvalidCompositionError extends Error {
constructor(
/** Why the text cannot be a composition. */
readonly reason: string,
) {
super(`agent-presets: composition is not a valid entry list: ${reason}`)
}
}
/** Authoring was attempted where the deployment allows none. */
export class PresetNotWritableError extends Error {
constructor(
/** What the caller tried to change, for the diagnostic. */
readonly presetId: string,
reason: string,
) {
super(`agent-presets: preset "${presetId}" cannot be written: ${reason}`)
}
}
/**
* The root locally authored presets are written to.
* @param roots - the configured roots in precedence order.
* @returns the absolute path of the first `user` root.
* @throws when the deployment configured no writable root.
*/
export function writableRoot(roots: readonly PresetRoot[]): string {
const root = roots.find(candidate => candidate.trust === 'user')
if (root === undefined) {
throw new PresetNotWritableError('', 'this deployment configures no user-writable preset root')
}
return resolve(expandHomePath(root.path))
}
/**
* Validate one composition's text without mounting it.
*
* This is the shape check the Include performs when it reads a file — a
* top-level list of entries. It cannot prove the composition mounts (that
* needs the plugins), so it is a guard against saving something no session
* could ever load, not a substitute for trying it.
* @param content - the YAML text.
* @throws when the text does not parse or is not a top-level array.
*/
export function assertComposition(content: string): void {
let parsed: unknown
try {
parsed = yaml.load(content, { schema: entryListSchema })
} catch (error) {
/* v8 ignore next -- js-yaml rejects with a YAMLException, which is an Error; the
fallback keeps a hostile throw readable rather than printing `undefined`. */
throw new InvalidCompositionError(error instanceof Error ? error.message : String(error))
}
if (!Array.isArray(parsed)) {
throw new InvalidCompositionError('a composition must be a top-level list of plugin rows')
}
}
/**
* Read one preset's composition text.
* @param preset - the resolved preset.
* @returns the file's contents.
*/
export async function readComposition(preset: AgentPreset): Promise<string> {
return await readFile(preset.path, 'utf8')
}
/**
* Create or replace a locally authored preset.
* @param roots - the configured roots; the first `user` one receives the write.
* @param id - the preset id, which becomes its directory name.
* @param content - the composition text.
* @returns the absolute path written.
* @throws when the id is unusable, the content is not an entry list, or the
* deployment has no writable root.
*/
export async function writeComposition(
roots: readonly PresetRoot[],
id: string,
content: string,
): Promise<string> {
if (!PRESET_ID.test(id)) throw new InvalidPresetIdError(id)
assertComposition(content)
const dir = join(writableRoot(roots), id)
const path = join(dir, COMPOSITION_FILE)
// Owner-only: a composition names the plugins a session runs, so it carries
// the same weight as the settings document beside it.
await writeFileAtomic(path, content, { mode: 0o600, dirMode: 0o700 })
return path
}
/**
* Delete a locally authored preset.
*
* A shipped preset is refused: it belongs to the deployment. A preset a live
* session mounted is NOT refused — the composition was read at creation and is
* never re-read, so that session keeps running exactly as it was.
* @param roots - the configured roots.
* @param preset - the resolved preset to remove.
* @throws when the preset ships with the deployment or lies outside the writable root.
*/
export async function deleteComposition(
roots: readonly PresetRoot[],
preset: AgentPreset,
): Promise<void> {
if (preset.trust !== 'user') {
throw new PresetNotWritableError(preset.id, 'it ships with the deployment')
}
const dir = join(writableRoot(roots), preset.id)
// Belt and braces over the id pattern: the resolved directory must still be
// the one the writable root owns, whatever discovery reported.
if (!isAbsolute(preset.path) || !preset.path.startsWith(dir)) {
throw new PresetNotWritableError(preset.id, 'it does not live under the writable preset root')
}
await rm(dir, { recursive: true, force: true })
}
@@ -15,7 +15,9 @@ import { scopeOf } from '@deepseek-ai/dsh-scope'
import z from 'schemastery'
import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
import { discoverPresets } from './discovery.ts'
import { deleteComposition, readComposition, writeComposition } from './authoring.ts'
import { mountPreset, serviceForAgent, unmountPresetFor } from './mount.ts'
import { PresetNotWritableError } from './authoring.ts'
import { UnknownPresetError, type AgentPreset, type Config } from './types.ts'
/** Settings namespace carrying the user's chosen default preset. */
@@ -37,6 +39,10 @@ export {
inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent,
unmountPresetFor, type PresetMount,
} from './mount.ts'
export {
assertComposition, deleteComposition, InvalidCompositionError, InvalidPresetIdError,
PresetNotWritableError, readComposition, writableRoot, writeComposition,
} from './authoring.ts'
export { resolveSessionPreset, type PresetBearingSession } from './session.ts'
export { PresetMountError, UnknownPresetError } from './types.ts'
export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts'
@@ -142,6 +148,51 @@ export class AgentPresets extends Service {
return preset
}
/** Whether this deployment configures a root locally authored presets go to. */
get authorable(): boolean {
return this.config.roots.some(root => root.trust === 'user')
}
/**
* Read one preset's composition text.
* @param id - the preset id.
* @returns the composition exactly as stored.
* @throws when no configured root supplies that id.
*/
async read(id: string): Promise<string> {
return await readComposition(await this.resolve(id))
}
/**
* Create or replace a locally authored preset.
*
* The text is shape-checked before it lands, so a save cannot leave a file no
* session could load; it is NOT mounted, so a composition that parses but
* names a missing plugin still fails at the next session that selects it.
* @param id - the preset id, which becomes its directory name.
* @param content - the composition text.
* @throws when the id is unusable, the text is not an entry list, or the
* deployment configures no writable root.
*/
async write(id: string, content: string): Promise<void> {
// A shipped preset belongs to the deployment: overwriting it would remove
// the known-good composition a broken local one is compared against.
const existing = (await this.list()).find(preset => preset.id === id)
if (existing !== undefined && existing.trust !== 'user') {
throw new PresetNotWritableError(id, 'it ships with the deployment')
}
await writeComposition(this.config.roots, id, content)
}
/**
* Delete a locally authored preset.
* @param id - the preset id.
* @throws when the preset is unknown or ships with the deployment.
*/
async remove(id: string): Promise<void> {
await deleteComposition(this.config.roots, await this.resolve(id))
}
/**
* One agent's instance of a service its preset mounted.
*
@@ -41,6 +41,14 @@ interface MountedTree {
*/
const mounted = new WeakMap<object, MountedTree>()
/**
* The base URL bare specifiers resolve against, per pending mount, keyed by the
* same config object. Recorded before the subtree is plugged, because `Include`
* rewrites its own context's `baseUrl` to the composition's directory and the
* pre-mount value is the only handle on where the harness itself lives.
*/
const harnessBase = new WeakMap<object, string>()
/**
* Include subclass that publishes its tree and fiber for the audit, and never
* writes to the file it read.
@@ -51,6 +59,33 @@ class PresetTree extends Include {
mounted.set(config, { tree: this, fiber: ctx.fiber })
}
/**
* Resolve a bare specifier from the harness rather than from the preset.
*
* `EntryTree.import()` resolves against the tree's own `baseUrl`, which
* `Include` sets to the composition's directory. That is right for a
* relative specifier — a preset's own files travel with it — and wrong for
* a package name: a locally authored preset lives under the user's home,
* where Node's upward `node_modules` walk never reaches the harness's own
* dependencies, so every `@deepseek-ai/dsh-*` row would fail to import. The
* mount records the host composition's base instead, which is inside the
* installed harness, and bare names resolve from there.
* @param name - the module specifier from the row.
* @param getOuterStack - the loader's stack composer for import diagnostics.
* @returns the imported module, or the `cordis:` builtin.
*/
override import(name: string, getOuterStack?: () => string[]): unknown {
const base = harnessBase.get(this.config)
/* v8 ignore next -- every PresetTree is constructed by `mountPreset`, which records the base first */
if (base === undefined) return super.import(name, getOuterStack)
if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(name, getOuterStack)
const internal = this.ctx.loader.internal
/* v8 ignore next -- Node always supplies the internal module loader; the branch keeps a
hypothetical embedder from losing the row's name in a resolution error. */
if (internal === undefined) return super.import(name, getOuterStack)
return internal.import(name, base, {})
}
/**
* A preset is an input, never a persistence target.
*
@@ -269,6 +304,11 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi
)
}
const config: Include.Config = { path: pathToFileURL(preset.path).href }
// Captured before the subtree exists: the agent context still carries the
// host composition's base, which is inside the installed harness and is
// therefore where a row's package name has to resolve from.
/* v8 ignore next -- the Loader sets `baseUrl` on the root before any agent context derives from it */
if (agentCtx.baseUrl !== undefined) harnessBase.set(config, agentCtx.baseUrl)
// Before the record this mount is about to add: every session takes this
// path, so it is what keeps the set bounded on a host that never reads it.
pruneDisposedMounts()
@@ -0,0 +1,183 @@
/**
* Authoring a preset writes a composition into the deployment's `user` root.
* The id is a directory name, so its pattern is a containment boundary rather
* than a style rule; the shipped `.system` set stays read-only.
*/
import { mkdtemp, mkdir, readFile, 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 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import { beforeEach, describe, expect, it } from 'vitest'
import AgentPresets, { COMPOSITION_FILE, assertComposition } from '@deepseek-ai/dsh-agent-presets'
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n'
let ctx: Context
let userRoot: string
beforeEach(async () => {
userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-'))
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: join(FIXTURES, 'system'), trust: 'system' as const },
{ path: userRoot, trust: 'user' as const },
],
})
})
describe('authoring a preset', () => {
it('creates one in the user root and lists it', async () => {
await ctx.agentPresets.write('mine', VALID)
expect(await readFile(join(userRoot, 'mine', COMPOSITION_FILE), 'utf8')).toBe(VALID)
const listed = await ctx.agentPresets.list()
expect(listed.find(preset => preset.id === 'mine')?.trust).toBe('user')
})
it('reads back what it stored', async () => {
await ctx.agentPresets.write('mine', VALID)
expect(await ctx.agentPresets.read('mine')).toBe(VALID)
})
it('replaces an existing local preset', async () => {
await ctx.agentPresets.write('mine', VALID)
const next = '- id: tool-beta\n name: ../../plugins/contribute.js\n config:\n tool: beta\n'
await ctx.agentPresets.write('mine', next)
expect(await ctx.agentPresets.read('mine')).toBe(next)
})
it('refuses an id that could escape the preset root', async () => {
for (const id of ['../escape', 'a/b', '/abs', '..', 'Upper']) {
await expect(ctx.agentPresets.write(id, VALID)).rejects.toThrow(/must match/)
}
// Nothing was created for any of them.
expect(existsSync(join(userRoot, 'escape'))).toBe(false)
})
it('refuses text that is not a top-level entry list', async () => {
await expect(ctx.agentPresets.write('bad', 'tools: [a, b]\n'))
.rejects.toThrow(/top-level list of plugin rows/)
await expect(ctx.agentPresets.write('bad', '- id: x\n name: [unclosed\n'))
.rejects.toThrow(/not a valid entry list/)
expect(existsSync(join(userRoot, 'bad'))).toBe(false)
})
it('accepts a composition using the `!!js` dialect the include reads', () => {
// A preset legitimately carries expressions; rejecting them would make
// the editor refuse compositions the loader accepts.
expect(() => { assertComposition('- id: x\n name: y\n config:\n cwd: !!js process.cwd()\n') })
.not.toThrow()
})
it('refuses to overwrite a preset that ships with the deployment', async () => {
await expect(ctx.agentPresets.write('standard', VALID))
.rejects.toThrow(/ships with the deployment/)
expect(await ctx.agentPresets.read('standard')).not.toBe(VALID)
})
})
describe('deleting a preset', () => {
it('removes a locally authored one', async () => {
await ctx.agentPresets.write('mine', VALID)
await ctx.agentPresets.remove('mine')
expect(existsSync(join(userRoot, 'mine'))).toBe(false)
expect((await ctx.agentPresets.list()).some(preset => preset.id === 'mine')).toBe(false)
})
it('refuses to delete a shipped one', async () => {
await expect(ctx.agentPresets.remove('standard'))
.rejects.toThrow(/ships with the deployment/)
})
it('reports an unknown id rather than silently succeeding', async () => {
await expect(ctx.agentPresets.remove('never-existed')).rejects.toThrow(/not found/)
})
})
describe('a deployment with more than one user root', () => {
it('refuses to delete a preset the writable root does not own', async () => {
const second = await mkdtemp(join(tmpdir(), 'dsh-preset-second-'))
await mkdir(join(second, 'elsewhere'), { recursive: true })
await writeFile(join(second, 'elsewhere', COMPOSITION_FILE), VALID)
const layered = new Context()
layered.baseUrl = pathToFileURL(FIXTURES).href + '/'
await layered.plugin(Loader)
layered.loader.builtins.include = Include
await layered.plugin(AgentPresets, {
default: 'standard',
roots: [
{ path: userRoot, trust: 'user' as const },
{ path: second, trust: 'user' as const },
],
})
// Writes go to the first user root, so a preset discovered from a later
// one is `user` trust yet outside what deletion is allowed to touch —
// `rm -r` on a directory this root does not own is the failure to avoid.
await expect(layered.agentPresets.remove('elsewhere'))
.rejects.toThrow(/does not live under the writable preset root/)
expect(existsSync(join(second, 'elsewhere'))).toBe(true)
})
})
describe('a deployment with no writable root', () => {
it('says authoring is unavailable rather than guessing a directory', async () => {
const readOnly = new Context()
readOnly.baseUrl = pathToFileURL(FIXTURES).href + '/'
await readOnly.plugin(Loader)
readOnly.loader.builtins.include = Include
await readOnly.plugin(AgentPresets, {
default: 'standard',
roots: [{ path: join(FIXTURES, 'system'), trust: 'system' as const }],
})
expect(readOnly.agentPresets.authorable).toBe(false)
await expect(readOnly.agentPresets.write('mine', VALID))
.rejects.toThrow(/no user-writable preset root/)
})
})
describe('a user root that does not exist yet', () => {
it('is created by the first save', async () => {
const absent = join(await mkdtemp(join(tmpdir(), 'dsh-preset-absent-')), 'nested', 'preset')
const fresh = new Context()
fresh.baseUrl = pathToFileURL(FIXTURES).href + '/'
await fresh.plugin(Loader)
fresh.loader.builtins.include = Include
await fresh.plugin(AgentPresets, {
default: 'mine',
roots: [{ path: absent, trust: 'user' as const }],
})
await fresh.agentPresets.write('mine', VALID)
expect(await readFile(join(absent, 'mine', COMPOSITION_FILE), 'utf8')).toBe(VALID)
})
})
describe('a stray file beside the preset directories', () => {
it('does not become a preset', async () => {
await mkdir(join(userRoot, 'not-a-preset'), { recursive: true })
await writeFile(join(userRoot, 'not-a-preset', 'README.txt'), 'nope\n')
expect((await ctx.agentPresets.list()).some(preset => preset.id === 'not-a-preset')).toBe(false)
})
})
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -13,6 +13,7 @@ import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { beforeEach, describe, expect, it } from 'vitest'
import AgentPresets, { COMPOSITION_FILE, leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets'
import type { Config } from '@deepseek-ai/dsh-agent-presets'
declare module 'cordis' {
interface Context {
@@ -27,8 +28,13 @@ const ROOTS = [
{ path: join(FIXTURES, 'user'), trust: 'user' as const },
]
/** A composition carrying the registries a preset contributes to, plus the preset roster. */
async function harness(): Promise<Context> {
/**
* A composition carrying the registries a preset contributes to, plus the
* preset roster.
* @param roster - roster config, defaulting to the fixture roots.
* @returns the booted context.
*/
async function harness(roster: Config = { default: 'standard', roots: ROOTS }): Promise<Context> {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
await ctx.plugin(Loader)
@@ -39,7 +45,7 @@ async function harness(): Promise<Context> {
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, roster)
return ctx
}
@@ -331,6 +337,55 @@ describe('replacing a composition', () => {
expect(toolNames(ctx, handle.agent)).toEqual(['alpha'])
})
it('composes an agent that had nothing installed', async () => {
// An agent created without a preset has no subtree to discard, so the
// swap is a plain mount rather than a restore-on-failure path.
const handle = await ctx.agents.create({ sessionId: SessionId('sess-bare') })
await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal')
expect(toolNames(ctx, handle.agent)).toEqual(['beta'])
})
it('refuses a bare agent\'s broken composition without restoring anything', async () => {
const handle = await ctx.agents.create({ sessionId: SessionId('sess-bare-broken') })
await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'broken'))
.rejects.toThrow(/failed to mount/)
// Nothing was installed, so there is nothing to put back.
expect(toolNames(ctx, handle.agent)).toEqual([])
})
it('reports the switch failure even when the restore also fails', async () => {
// The previous preset's whole directory disappears between the unmount
// and the restore. The caller still needs to hear why the switch was
// refused rather than why putting the old one back did not work.
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-vanishing-'))
await mkdir(join(root, 'vanishing'), { recursive: true })
// An absolute plugin path, because a relative one resolves from the
// preset's own directory and this preset does not live beside the fixtures.
await writeFile(join(root, 'vanishing', COMPOSITION_FILE), [
'- id: alpha',
` name: ${join(FIXTURES, 'plugins', 'contribute.js')}`,
' config:',
' tool: vanishing',
'',
].join('\n'))
const local = await harness({
default: 'vanishing',
roots: [{ path: root, trust: 'user' as const }, ...ROOTS],
})
const handle = await local.agents.create({
sessionId: SessionId('sess-vanishing'),
setup: async (agentCtx: Context) => void await local.agentPresets.mount(agentCtx, 'vanishing'),
})
await rm(root, { recursive: true, force: true })
await expect(local.agentPresets.recompose(handle.agent.ctx, 'broken'))
.rejects.toThrow(/failed to mount/)
})
it('refuses an unscoped context', async () => {
await expect(ctx.agentPresets.recompose(ctx, 'minimal'))
.rejects.toThrow(/unscoped context/)
@@ -27,6 +27,9 @@
{
"path": "../../settings/settings"
},
{
"path": "../../util/atomic-write"
},
{
"path": "../../util/paths"
},