refactor(agent-presets,web): copy-only preset authoring with a path to the files
The web YAML editor is gone. agentPreset.write (arbitrary composition
text) became agentPreset.copy { from, agentPreset, name? }: a host-side
whole-directory copy of ids the host resolves itself — symlinks
dereferenced, modes re-tightened to owner-only with owner-execute kept,
metadata rewritten to keep the source's description but never its name or
roster order. No composition text or path crosses the wire in either
authoring direction, and the entryListSchema/!!js concern dissolves with
assertComposition itself.
The settings section becomes: a read-only viewer over shipped
compositions, a copy dialog (id + optional display name) as the only
create entry, delete for custom rows, and a location action leading into
the preset's own files — agentPreset.openDocument { agentPreset } resolves
the directory host-side and opens it natively, or answers
{ opened: false, path } for the row to show as text where the deployment
has no desktop. agentPreset.list reports hasDocument beside authorable;
the gateway's nativeOpen config pins the capability where
canOpenNativePath platform detection would mislead. The privileged set is
now read/copy/openDocument/remove.
With files as the only composition editor, standing mounts grew
stamp-keyed generations: ensureStanding compares the composition file's
mtime+size and starts the next generation for later sessions, while every
joined session keeps the generation it runs on.
New keyless web lane (agent-preset-authoring, overlay pins
nativeOpen: false so goldens render one branch on every platform) drives
view/copy/reveal/delete end to end; the real-composition CLI e2e switches
to copy semantics.
This commit is contained in:
@@ -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/preset/agent-presets/README.md
|
||||
README.md: 66145b1870fdfa2b0ca7395f02dc5ac21fa46cab
|
||||
README.zh.md: aef118435d0957e79fc9b5baee531f9bfe206586
|
||||
README.md: 26f54f3efe4eadc933b0b3aed7b0ed8f8816b7d4
|
||||
README.zh.md: 6688e84994beac9f937a8f01501d726267af08cf
|
||||
|
||||
@@ -16,16 +16,16 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal
|
||||
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record.
|
||||
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was.
|
||||
- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn.
|
||||
- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be written at all.
|
||||
- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created 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. Edits reach only future generations: the standing pointer drops, sessions already joined keep the mount they run on.
|
||||
- `ctx.agentPresets.copy(from, id, name?): Promise<void>` Create a locally authored preset by copying an existing one's whole directory — the only authoring write. No composition text crosses this seam, so a copy is exactly as loadable as its source; the copied metadata keeps the source's description but never its name or roster order, and `name` (or the id fallback) is what distinguishes the rows.
|
||||
- `ctx.agentPresets.remove(id): Promise<void>` Delete a locally authored preset; joined sessions keep their standing mount. Clears the user default when it named the preset just deleted: storing a default that does not exist yet is deliberate, but one this call removed will never be supplied again and would fail every session created without an explicit pick.
|
||||
|
||||
`AgentPreset` carries `id` (the directory name), `trust` (`system` or `user`, from the root it was found under), and `path` (the absolute composition file).
|
||||
|
||||
### Where to call `mount()`
|
||||
|
||||
The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the join installed while the agent is still unpublished, so a rejected composition rolls the whole creation back rather than leaving a half-composed session. The standing subtree is owned by the roster service's own fiber — deliberately its UNTRACED context, because a subtree minted from a traced `this.ctx` resolves every service through the caller's shadow fiber instead of each entry's own inject store — so it survives every agent and unwinds only with the whole tree. A settled mount is permanent for the process: the composition a running session joined must outlive its file changing or disappearing underneath it, so file edits reach only future generations.
|
||||
The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the join installed while the agent is still unpublished, so a rejected composition rolls the whole creation back rather than leaving a half-composed session. The standing subtree is owned by the roster service's own fiber — deliberately its UNTRACED context, because a subtree minted from a traced `this.ctx` resolves every service through the caller's shadow fiber instead of each entry's own inject store — so it survives every agent and unwinds only with the whole tree. Each generation records its composition file's stamp (mtime and size): a session that finds the stamp stale starts the next generation, while every session already joined keeps the one it runs on — the composition a running session joined outlives its file changing or disappearing underneath it, and files are the only composition editor, so the stamp is what carries an edit to later sessions.
|
||||
|
||||
### Which preset a session runs
|
||||
|
||||
@@ -41,13 +41,13 @@ The restriction to a produced-nothing agent is a product rule, not a mechanical
|
||||
|
||||
## 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:
|
||||
Authoring is copy-only. A new preset is a whole-directory copy of an existing one — composition, metadata, skill directories, assets — landed under the first `user` root; the inputs are two ids the service resolves against its own roots plus an optional display name, so no caller ever supplies composition text and a copy grants nothing the roster did not already carry. Everything after creation happens in the preset's own files. `copy()` 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.
|
||||
- **An id that is already taken.** A copy never overwrites: any root supplying the id refuses it (a user directory named like a shipped preset would be shadowed by it), and a directory occupying the name on disk without being a preset refuses it too.
|
||||
- **An unknown source.** The source may be any trust — copying a shipped preset is the primary case — but it must exist; a failed copy rolls its half-made directory back rather than leaving one discovery cannot see.
|
||||
|
||||
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.
|
||||
The copied tree is re-tightened to owner-only (`0o600` files keeping their owner-execute bit, `0o700` directories), symlinks are dereferenced so the copy is self-contained, and the root is created on first copy — a deployment configuring a user root that does not exist yet is the normal first-run state. The copied `preset.yml` is rewritten: the source's description is kept for the author to edit in place, but its name and roster `order` are dropped — a copy presenting itself identically to its source, or sorted into the shipped set's declared order, would make the roster stop distinguishing them. `remove()` refuses a preset that ships with the deployment; the shipped set is the known-good compositions copies start from.
|
||||
|
||||
### How a preset's rows resolve
|
||||
|
||||
@@ -121,7 +121,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 once a session has produced anything** — `recompose` re-links a BLANK session's parent scope to another standing mount, and only a blank one: switching a composition that already ran would strand tools the model has called. Changing the default affects only sessions created afterwards.
|
||||
- **A standing mount reads its file once per generation** — the first session to name a preset fixes its composition until an authoring `write()`/`remove()` drops the pointer or the whole tree unloads; sessions already joined keep their generation, and nothing reclaims a superseded one while the process lives (bounded by how often compositions are edited, not by sessions).
|
||||
- **A written composition is never mounted to validate** — `write()` checks 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.
|
||||
- **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts. Sessions already joined keep their generation, and nothing reclaims a superseded one while the process lives (bounded by how often compositions are edited, not by sessions).
|
||||
- **A copy is never mounted to validate** — it is byte-identical to its source, so a source broken on disk yields a copy that fails at the next session that selects it, exactly as the source would.
|
||||
- **A copy is a snapshot that drifts** — upgrading the deployment does not update copies of shipped presets, and there is no patch semantics at this layer to express "standard plus one change" (that is the bundle layer's `cordis.patch.yml`); the shipped set itself accepts the same cost — `cordis` and `code` are full copies of `standard` — so the whole assembly stays readable in one file.
|
||||
- **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()`.
|
||||
|
||||
@@ -16,16 +16,16 @@
|
||||
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。
|
||||
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。
|
||||
- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。
|
||||
- `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可写。
|
||||
- `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可创建。
|
||||
- `ctx.agentPresets.read(id): Promise<string>` 某个 preset 的组装文本,与存储内容逐字一致。
|
||||
- `ctx.agentPresets.write(id, content): Promise<void>` 创建或替换一个本地创作的 preset。编辑只影响未来的代际:常驻指针被丢弃,已加入的会话保持其正在运行的挂载。
|
||||
- `ctx.agentPresets.copy(from, id, name?): Promise<void>` 通过整目录复制一个既有 preset 来创建本地创作的 preset——唯一的创作写入。组装文本不经过这道接缝,因此副本与其来源同等可加载;复制出的元数据保留来源的描述、但绝不保留其名称与 roster 排序,`name`(或回退到 id)才是区分两行的依据。
|
||||
- `ctx.agentPresets.remove(id): Promise<void>` 删除一个本地创作的 preset;已加入的会话保留其常驻挂载。若用户默认值恰好指向刚删除的 preset 则一并清除:存一个尚不存在的默认值是刻意的,但本次删除的这个再也不会有人提供,留着会让所有未显式指定的新会话无法启动。
|
||||
|
||||
`AgentPreset` 携带 `id`(目录名)、`trust`(`system` 或 `user`,取自它所在的根目录)以及 `path`(组装文件的绝对路径)。
|
||||
|
||||
### 应在何处调用 `mount()`
|
||||
|
||||
agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,认父是在 agent 尚未发布时完成的,因此组装被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。常驻子树归 roster 服务自己的 fiber 所有——刻意用其未追踪的上下文,因为从被追踪的 `this.ctx` 派生的子树会经调用方的 shadow fiber 解析一切服务、无视各 entry 自己的 inject store——所以它比任何 agent 都活得久,只随整棵树卸载。挂载一旦成功即进程级永久:正在运行的会话所加入的组装必须在其文件被修改或删除后继续存活,因此文件编辑只影响未来的代际。
|
||||
agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,认父是在 agent 尚未发布时完成的,因此组装被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。常驻子树归 roster 服务自己的 fiber 所有——刻意用其未追踪的上下文,因为从被追踪的 `this.ctx` 派生的子树会经调用方的 shadow fiber 解析一切服务、无视各 entry 自己的 inject store——所以它比任何 agent 都活得久,只随整棵树卸载。每个代际记录其组装文件的 stamp(mtime 与大小):发现 stamp 过期的会话会开启下一个代际,而所有已加入的会话保持各自正在运行的那个——正在运行的会话所加入的组装在其文件被修改或删除后继续存活;文件是唯一的组装编辑器,stamp 正是把编辑送达后续会话的机制。
|
||||
|
||||
### 会话实际运行的是哪个 preset
|
||||
|
||||
@@ -41,13 +41,13 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有
|
||||
|
||||
## 创作
|
||||
|
||||
本地创作的 preset 是首个 `user` 根目录下的一个目录,其中放置一份 `agent.cordis.yml`。`write()` 在任何内容落盘之前拒绝三种情况:
|
||||
创作即复制。新 preset 是某个既有 preset 的整目录副本——组装、元数据、skill 目录、附带资产——落在首个 `user` 根目录之下;输入只有两个由服务对照自身根目录解析的 id 加一个可选显示名,因此调用方从不提供组装文本,一次复制不会授予 roster 尚未携带的任何能力。创建之后的一切都发生在 preset 自己的文件里。`copy()` 在任何内容落盘之前拒绝三种情况:
|
||||
|
||||
- **不符合 `[a-z0-9][a-z0-9-]*` 的 id。** id 会成为目录名,因此约束是 id 自身的性质,而非事后再做一次路径检查——`../escape`、`a/b` 与绝对路径都作为 id 被拒绝。
|
||||
- **不是 Cordis entry 列表的文本。** 内容使用 loader 自身的 schema 与方言(含 `!!js`)解析,因此保存不会留下任何会话都无法加载的文件。只校验形状:引用了不存在插件的组装在此被接受,并在下一个选择它的会话处失败。
|
||||
- **随部署提供的 preset。** 覆写它会抹掉那份用来对照有问题的本地 preset 的已知良好组装。`remove()` 同样拒绝。
|
||||
- **已被占用的 id。** 复制从不覆写:任一根目录已提供该 id 即拒绝(与随附 preset 同名的用户目录只会被它遮蔽),磁盘上占着该名字却不是 preset 的目录同样拒绝。
|
||||
- **未知的来源。** 来源可以是任何信任级别——复制随附 preset 正是主要用途——但必须存在;复制失败会回滚做到一半的目录,而不是留下一个 discovery 看不见的目录。
|
||||
|
||||
写入是原子的、仅属主可读写(`0o600`,位于 `0o700` 的目录内),且根目录在首次写入时创建——部署配置了尚不存在的用户根目录,正是首次运行的正常状态。
|
||||
复制出的目录树被收紧为仅属主可用(文件 `0o600` 并保留属主执行位,目录 `0o700`),符号链接被解引用以保证副本自包含,且根目录在首次复制时创建——部署配置了尚不存在的用户根目录,正是首次运行的正常状态。复制出的 `preset.yml` 会被重写:保留来源的描述供作者就地编辑,但丢弃其名称与 roster `order`——副本若与来源呈现得一模一样、或按随附集合声明的顺序排序,roster 就不再能区分它们。`remove()` 拒绝随部署提供的 preset;随附集合正是副本的已知良好起点。
|
||||
|
||||
### preset 的各行如何解析
|
||||
|
||||
@@ -121,7 +121,7 @@ Indirectly, through the plugins a standing composition registers, which own ever
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。
|
||||
- **常驻挂载每个代际只读一次文件** —— 首个命名某 preset 的会话固定其组装,直到创作面的 `write()`/`remove()` 丢弃指针或整棵树卸载;已加入的会话保持其代际,进程存活期间不回收被替代的代际(上限取决于组装被编辑的频率,而非会话数)。
|
||||
- **写入的组装从不被实际挂载以校验** —— `write()` 校验形状而非可解析性,因此引用了缺失插件的 preset 会被存下,并在下一个选择它的会话处失败。
|
||||
- **展示名称就是目录 id** —— preset 不携带 manifest,因此选择器与设置界面在有消费方需要更丰富的元数据之前,只显示 id。
|
||||
- **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。已加入的会话保持其代际,进程存活期间不回收被替代的代际(上限取决于组装被编辑的频率,而非会话数)。
|
||||
- **副本从不被实际挂载以校验** —— 它与来源逐字节相同,因此磁盘上已坏的来源会产出同样在下一个选择它的会话处失败的副本,与来源的失败方式完全一致。
|
||||
- **副本是会漂移的快照** —— 升级部署不会更新随附 preset 的副本,本层也没有表达「standard 加一处改动」的 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力);随附集合自己也接受同样的代价——`cordis` 与 `code` 就是 `standard` 的完整副本——换来整份组装在一个文件里可读。
|
||||
- **根目录扫描不做监听** —— 每次读取都实际访问文件系统,这让名单保持新鲜,但每次 `list()` 会对每个根目录产生一次 `readdir`。
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
/**
|
||||
* Creating, reading, and deleting locally authored presets.
|
||||
* Copying, 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.
|
||||
*
|
||||
* The only authoring write is a whole-directory copy of an existing preset.
|
||||
* No caller supplies composition text: the inputs are ids the host resolves
|
||||
* against its own roots plus an optional display name, so authoring grants no
|
||||
* capability the copied preset did not already carry.
|
||||
* @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 { chmod, cp, readdir, readFile, rm, stat } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { expandHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import { COMPOSITION_FILE } from './discovery.ts'
|
||||
import { METADATA_FILE, renderPresetMetadata, type PresetMetadata } from './metadata.ts'
|
||||
import { METADATA_FILE, renderPresetMetadata } from './metadata.ts'
|
||||
import type { AgentPreset, PresetRoot } from './types.ts'
|
||||
|
||||
/**
|
||||
@@ -39,13 +41,16 @@ export class InvalidPresetIdError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** A composition that is not a usable entry list. */
|
||||
export class InvalidCompositionError extends Error {
|
||||
/** A copy target that is already occupied — a copy never overwrites. */
|
||||
export class PresetExistsError extends Error {
|
||||
constructor(
|
||||
/** Why the text cannot be a composition. */
|
||||
readonly reason: string,
|
||||
/** The id that is already taken. */
|
||||
readonly presetId: string,
|
||||
) {
|
||||
super(`agent-presets: composition is not a valid entry list: ${reason}`)
|
||||
super(
|
||||
`agent-presets: preset "${presetId}" already exists — `
|
||||
+ 'a copy never overwrites; delete the existing preset first or choose another id',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,30 +79,6 @@ export function writableRoot(roots: readonly PresetRoot[]): string {
|
||||
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.
|
||||
@@ -107,40 +88,93 @@ export async function readComposition(preset: AgentPreset): Promise<string> {
|
||||
return await readFile(preset.path, 'utf8')
|
||||
}
|
||||
|
||||
/** Whether anything occupies the path (cp's own errorOnExist backstops races). */
|
||||
async function occupied(path: string): Promise<boolean> {
|
||||
let present = true
|
||||
try {
|
||||
await stat(path)
|
||||
} catch {
|
||||
// Every stat failure means the same thing here: nothing usable occupies
|
||||
// the path, so the copy may claim it.
|
||||
present = false
|
||||
}
|
||||
return present
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param metadata - display name and description; clearing both removes the file.
|
||||
* @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.
|
||||
* Re-tighten a copied tree to owner-only. A shipped preset is world-readable
|
||||
* in its install and `cp` preserves that; the copy carries the same weight as
|
||||
* the settings document beside it, so group/other access is stripped. A
|
||||
* file's owner-execute bit survives — a preset may ship runnable helpers.
|
||||
*/
|
||||
export async function writeComposition(
|
||||
async function tightenModes(dir: string): Promise<void> {
|
||||
await chmod(dir, 0o700)
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const target = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await tightenModes(target)
|
||||
} else {
|
||||
await chmod(target, ((await stat(target)).mode & 0o100) === 0 ? 0o600 : 0o700)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a preset by copying an existing one's whole directory.
|
||||
*
|
||||
* The copy carries everything the source directory holds — composition,
|
||||
* metadata, skill directories, assets — because a preset is its directory,
|
||||
* not one file. Symlinks are dereferenced so the copy is self-contained
|
||||
* rather than a set of links back into the install it was copied from.
|
||||
*
|
||||
* The copied metadata is then rewritten: the source's description is kept
|
||||
* (the file is the author's to edit afterwards), but its name and roster
|
||||
* `order` are not — a copy presenting itself identically to its source, or
|
||||
* sorted into the shipped set's declared order, would make the roster stop
|
||||
* distinguishing them. With no name given and no description to keep, the
|
||||
* file is removed so the copy publishes nothing rather than a blank.
|
||||
* @param roots - the configured roots; the first `user` one receives the copy.
|
||||
* @param source - the resolved preset the copy starts from.
|
||||
* @param id - the new preset's id, which becomes its directory name.
|
||||
* @param name - display name for the copy; omitted falls back to the id.
|
||||
* @returns the absolute path of the new preset directory.
|
||||
* @throws when the id is unusable or already occupied on disk, or the
|
||||
* deployment configures no writable root.
|
||||
*/
|
||||
export async function copyComposition(
|
||||
roots: readonly PresetRoot[],
|
||||
source: AgentPreset,
|
||||
id: string,
|
||||
content: string,
|
||||
metadata: PresetMetadata = {},
|
||||
name?: 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 })
|
||||
// Display text lands after the composition, and only when there is any: a
|
||||
// preset with no name should carry no metadata file rather than an empty
|
||||
// one. Clearing both fields therefore removes the file.
|
||||
const rendered = renderPresetMetadata(metadata)
|
||||
const metadataPath = join(dir, METADATA_FILE)
|
||||
if (rendered === undefined) {
|
||||
await rm(metadataPath, { force: true })
|
||||
} else {
|
||||
await writeFileAtomic(metadataPath, rendered, { mode: 0o600, dirMode: 0o700 })
|
||||
// The roster check upstream only sees discovered presets; a directory with
|
||||
// no composition file still occupies the name and deserves a readable
|
||||
// refusal rather than a filesystem error code.
|
||||
if (await occupied(dir)) throw new PresetExistsError(id)
|
||||
try {
|
||||
await cp(dirname(source.path), dir, {
|
||||
recursive: true, dereference: true, force: false, errorOnExist: true,
|
||||
})
|
||||
await tightenModes(dir)
|
||||
const rendered = renderPresetMetadata({
|
||||
...name === undefined ? {} : { name },
|
||||
...source.description === undefined ? {} : { description: source.description },
|
||||
})
|
||||
const metadataPath = join(dir, METADATA_FILE)
|
||||
if (rendered === undefined) {
|
||||
await rm(metadataPath, { force: true })
|
||||
} else {
|
||||
await writeFileAtomic(metadataPath, rendered, { mode: 0o600, dirMode: 0o700 })
|
||||
}
|
||||
} catch (error) {
|
||||
// A half-copied directory would be invisible to discovery at best and a
|
||||
// mountable-but-incomplete preset at worst; a failed copy leaves nothing.
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
return path
|
||||
return dir
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,16 +21,16 @@
|
||||
* @module @deepseek-ai/dsh-agent-presets
|
||||
*/
|
||||
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { createScope, scopeOf, setScopeParent, type Scope, type ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings'
|
||||
import { discoverPresets } from './discovery.ts'
|
||||
import { deleteComposition, readComposition, writeComposition } from './authoring.ts'
|
||||
import type { PresetMetadata } from './metadata.ts'
|
||||
import { copyComposition, deleteComposition, readComposition } from './authoring.ts'
|
||||
import { mountPreset, serviceForAgent } from './mount.ts'
|
||||
import { PresetNotWritableError } from './authoring.ts'
|
||||
import { UnknownPresetError, type AgentPreset, type Config } from './types.ts'
|
||||
import { PresetExistsError } from './authoring.ts'
|
||||
import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './types.ts'
|
||||
|
||||
/** Settings namespace carrying the user's chosen default preset. */
|
||||
export const SETTINGS_NAMESPACE = 'agent-presets'
|
||||
@@ -55,8 +55,8 @@ export {
|
||||
type PresetMount,
|
||||
} from './mount.ts'
|
||||
export {
|
||||
assertComposition, deleteComposition, InvalidCompositionError, InvalidPresetIdError,
|
||||
PresetNotWritableError, readComposition, writableRoot, writeComposition,
|
||||
copyComposition, deleteComposition, InvalidPresetIdError, PresetExistsError,
|
||||
PresetNotWritableError, readComposition, writableRoot,
|
||||
} from './authoring.ts'
|
||||
export { resolveSessionPreset, type PresetBearingSession } from './session.ts'
|
||||
export { PresetMountError, UnknownPresetError } from './types.ts'
|
||||
@@ -171,10 +171,12 @@ export class AgentPresets extends Service {
|
||||
* Standing mounts by preset id, single-flight so two agents racing the
|
||||
* first use of one preset share one composition. A settled failure is
|
||||
* removed so a later session retries a preset whose file has been fixed; a
|
||||
* settled success is permanent for the process — the composition a running
|
||||
* session joined must survive the file changing or disappearing underneath
|
||||
* it, so file edits reach only future generations (a later authoring layer
|
||||
* swaps this pointer; it never disposes a joined generation).
|
||||
* settled success serves until the composition FILE visibly changes — each
|
||||
* generation records its file stamp, and a stale stamp starts the next
|
||||
* generation for sessions created afterwards. Sessions already joined keep
|
||||
* the generation they run on; a superseded one is never disposed while the
|
||||
* process lives (reclaimed only by whole-tree teardown), so editing files
|
||||
* is bounded by how often compositions change, not by session count.
|
||||
*/
|
||||
private readonly standing = new Map<string, Promise<StandingMount>>()
|
||||
|
||||
@@ -218,29 +220,32 @@ export class AgentPresets extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or replace a locally authored preset.
|
||||
* Create a locally authored preset by copying an existing one whole.
|
||||
*
|
||||
* 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.
|
||||
* @param metadata - display name and description; clearing both removes the file.
|
||||
* @throws when the id is unusable, the text is not an entry list, or the
|
||||
* deployment configures no writable root.
|
||||
* Copy is the only authoring write. Composition text never crosses this
|
||||
* seam: the source is named by id and its directory is copied as it stands,
|
||||
* so the copy is exactly as loadable as its source and authoring grants no
|
||||
* capability the roster did not already carry. The copy is NOT mounted to
|
||||
* validate — a source that mounts today yields a copy that mounts today.
|
||||
* @param from - the preset the copy starts from; shipped presets are the
|
||||
* primary source, so any trust is accepted.
|
||||
* @param id - the new preset's id, which becomes its directory name.
|
||||
* @param name - display name for the copy; absent falls back to the id.
|
||||
* @throws when the source is unknown, the id is unusable or already taken,
|
||||
* or the deployment configures no writable root.
|
||||
*/
|
||||
async write(id: string, content: string, metadata: PresetMetadata = {}): 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')
|
||||
async copy(from: string, id: string, name?: string): Promise<void> {
|
||||
const source = await this.resolve(from)
|
||||
// The roster check refuses ids any root supplies — shipped ones included,
|
||||
// since a user directory named like a shipped preset is shadowed by it.
|
||||
// The disk check inside copyComposition only sees the writable root.
|
||||
if ((await this.list()).some(preset => preset.id === id)) {
|
||||
throw new PresetExistsError(id)
|
||||
}
|
||||
await writeComposition(this.config.roots, id, content, metadata)
|
||||
// Future generations only: the standing pointer is dropped so the NEXT
|
||||
// session composes the edited file, while every session already joined
|
||||
// keeps the mount it runs on — a superseded generation is never disposed
|
||||
// while the process lives (reclaimed only by whole-tree teardown).
|
||||
await copyComposition(this.config.roots, 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.
|
||||
this.standing.delete(id)
|
||||
}
|
||||
|
||||
@@ -251,8 +256,8 @@ export class AgentPresets extends Service {
|
||||
*/
|
||||
async remove(id: string): Promise<void> {
|
||||
await deleteComposition(this.config.roots, await this.resolve(id))
|
||||
// Same generation rule as `write`: sessions on the deleted preset keep
|
||||
// their standing mount; only new sessions see the roster without it.
|
||||
// Sessions on the deleted preset keep their standing mount; only new
|
||||
// sessions see the roster without it.
|
||||
this.standing.delete(id)
|
||||
// Storing a default that does not exist YET is deliberate — the roster is a
|
||||
// live directory, so a name absent now may exist by the time a session asks
|
||||
@@ -332,32 +337,79 @@ export class AgentPresets extends Service {
|
||||
}
|
||||
|
||||
/** Resolve (or create, single-flight) the standing mount of one preset. */
|
||||
private ensureStanding(preset: AgentPreset): Promise<StandingMount> {
|
||||
private async ensureStanding(preset: AgentPreset): Promise<StandingMount> {
|
||||
const pending = this.standing.get(preset.id)
|
||||
if (pending !== undefined) return pending
|
||||
if (pending !== undefined) {
|
||||
const mounted = await pending
|
||||
// Files are the only composition editor (authoring is copy/delete), so
|
||||
// the stamp is what notices an edit: a changed file starts the next
|
||||
// generation here, for this and later sessions. An unreadable stamp
|
||||
// serves the current generation — a mount must survive its file
|
||||
// disappearing, and failing the session over a stat would not.
|
||||
const current = await compositionStamp(preset.path)
|
||||
if (current === undefined || sameStamp(mounted.stamp, current)) return mounted
|
||||
// Guarded delete: a caller that raced this one may have already started
|
||||
// the next generation, and dropping THAT pointer would fork a third.
|
||||
if (this.standing.get(preset.id) === pending) this.standing.delete(preset.id)
|
||||
return this.ensureStanding(preset)
|
||||
}
|
||||
const created = (async (): Promise<StandingMount> => {
|
||||
const key: ScopeKey = { agentPreset: preset.id }
|
||||
const scope = createScope(this.selfCtx, key)
|
||||
try {
|
||||
// Stamped before the file is read: an edit racing the mount makes the
|
||||
// stamp stale rather than silently current, so the next session
|
||||
// refreshes instead of trusting a composition older than its stamp.
|
||||
const stamp = await compositionStamp(preset.path)
|
||||
if (stamp === undefined) {
|
||||
throw new PresetMountError(preset.id, `composition file is unreadable: ${preset.path}`)
|
||||
}
|
||||
await mountPreset(scope.ctx, preset)
|
||||
return { key, scope, stamp }
|
||||
} catch (error) {
|
||||
this.standing.delete(preset.id)
|
||||
await scope.dispose()
|
||||
throw error
|
||||
}
|
||||
return { key, scope }
|
||||
})()
|
||||
this.standing.set(preset.id, created)
|
||||
return created
|
||||
}
|
||||
}
|
||||
|
||||
/** The composition file identity one standing generation was mounted from. */
|
||||
interface CompositionStamp {
|
||||
/** Modification time in milliseconds, as `stat` reports it. */
|
||||
readonly mtimeMs: number
|
||||
/** File size in bytes, the tiebreak for edits within one mtime tick. */
|
||||
readonly size: number
|
||||
}
|
||||
|
||||
/** Read one composition file's stamp, or undefined when it cannot be statted. */
|
||||
async function compositionStamp(path: string): Promise<CompositionStamp | undefined> {
|
||||
try {
|
||||
const { mtimeMs, size } = await stat(path)
|
||||
return { mtimeMs, size }
|
||||
} catch {
|
||||
// Deleted, replaced by an unreadable entry, or otherwise unstattable all
|
||||
// mean the same to the caller: the file offers no identity to compare.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether two stamps name the same file state. */
|
||||
function sameStamp(a: CompositionStamp, b: CompositionStamp): boolean {
|
||||
return a.mtimeMs === b.mtimeMs && a.size === b.size
|
||||
}
|
||||
|
||||
/** One preset's standing composition. */
|
||||
interface StandingMount {
|
||||
/** Scope key agents are parented to; also the mount's registration scope. */
|
||||
readonly key: ScopeKey
|
||||
/** Disposal boundary; held for whole-tree teardown, never per-session. */
|
||||
readonly scope: Scope
|
||||
/** Stamp of the composition file this generation was mounted from. */
|
||||
readonly stamp: CompositionStamp
|
||||
}
|
||||
|
||||
export default AgentPresets
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* 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.
|
||||
* Authoring a preset copies an existing one's directory into the deployment's
|
||||
* `user` root — copy is the only authoring write, so no caller ever supplies
|
||||
* composition text. 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 { chmod, mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
@@ -14,7 +16,7 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import AgentPresets, {
|
||||
COMPOSITION_FILE, METADATA_FILE, assertComposition,
|
||||
COMPOSITION_FILE, copyComposition, METADATA_FILE,
|
||||
} from '@deepseek-ai/dsh-agent-presets'
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
||||
@@ -23,6 +25,21 @@ const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\
|
||||
let ctx: Context
|
||||
let userRoot: string
|
||||
|
||||
/** Hand-craft a preset directory (tests cannot author text through the service). */
|
||||
async function seedPreset(
|
||||
root: string, id: string, options: { composition?: string; metadata?: string; extras?: Record<string, string> } = {},
|
||||
): Promise<void> {
|
||||
await mkdir(join(root, id), { recursive: true })
|
||||
await writeFile(join(root, id, COMPOSITION_FILE), options.composition ?? VALID)
|
||||
if (options.metadata !== undefined) {
|
||||
await writeFile(join(root, id, METADATA_FILE), options.metadata)
|
||||
}
|
||||
for (const [name, content] of Object.entries(options.extras ?? {})) {
|
||||
await mkdir(dirname(join(root, id, name)), { recursive: true })
|
||||
await writeFile(join(root, id, name), content)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-'))
|
||||
ctx = new Context()
|
||||
@@ -38,76 +55,59 @@ beforeEach(async () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('authoring a preset', () => {
|
||||
it('creates one in the user root and lists it', async () => {
|
||||
await ctx.agentPresets.write('mine', VALID)
|
||||
describe('copying a preset', () => {
|
||||
it('copies a shipped preset into the user root and lists it', async () => {
|
||||
await ctx.agentPresets.copy('standard', 'mine')
|
||||
|
||||
expect(await readFile(join(userRoot, 'mine', COMPOSITION_FILE), 'utf8')).toBe(VALID)
|
||||
expect(await readFile(join(userRoot, 'mine', COMPOSITION_FILE), 'utf8'))
|
||||
.toBe(await ctx.agentPresets.read('standard'))
|
||||
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)
|
||||
it('copies the whole directory, execute bits kept and group/other stripped', async () => {
|
||||
await seedPreset(userRoot, 'source', {
|
||||
extras: { 'skills/demo/SKILL.md': '# demo\n', 'skills/demo/run.sh': '#!/bin/sh\n' },
|
||||
})
|
||||
await chmod(join(userRoot, 'source', 'skills', 'demo', 'run.sh'), 0o755)
|
||||
|
||||
expect(await ctx.agentPresets.read('mine')).toBe(VALID)
|
||||
await ctx.agentPresets.copy('source', 'mine')
|
||||
|
||||
expect(await readFile(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'), 'utf8')).toBe('# demo\n')
|
||||
// A preset may ship runnable helpers; the copy keeps them runnable for the
|
||||
// owner while withdrawing the world-readability of the install.
|
||||
expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'run.sh'))).mode & 0o777).toBe(0o700)
|
||||
expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'))).mode & 0o777).toBe(0o600)
|
||||
expect((await stat(join(userRoot, 'mine'))).mode & 0o777).toBe(0o700)
|
||||
})
|
||||
|
||||
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'
|
||||
it('keeps the source description but never its name or order', async () => {
|
||||
await seedPreset(userRoot, 'source', { metadata: 'name: 源模式\ndescription: 只做检索。\norder: 1\n' })
|
||||
|
||||
await ctx.agentPresets.write('mine', next)
|
||||
await ctx.agentPresets.copy('source', 'mine')
|
||||
|
||||
expect(await ctx.agentPresets.read('mine')).toBe(next)
|
||||
// Two rows presenting identically is how a roster stops being a chooser,
|
||||
// and the shipped set's declared order is not the copy's to claim.
|
||||
const metadata = await readFile(join(userRoot, 'mine', METADATA_FILE), 'utf8')
|
||||
expect(metadata).toContain('description: 只做检索。')
|
||||
expect(metadata).not.toContain('name:')
|
||||
expect(metadata).not.toContain('order:')
|
||||
expect((await ctx.agentPresets.list()).find(preset => preset.id === 'mine'))
|
||||
.toMatchObject({ description: '只做检索。' })
|
||||
})
|
||||
|
||||
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('stores the display name the author supplied', async () => {
|
||||
await ctx.agentPresets.copy('standard', 'mine', '我的模式')
|
||||
|
||||
expect(await readFile(join(userRoot, 'mine', METADATA_FILE), 'utf8')).toContain('name: 我的模式')
|
||||
expect((await ctx.agentPresets.list()).find(preset => preset.id === 'mine'))
|
||||
.toMatchObject({ name: '我的模式' })
|
||||
})
|
||||
|
||||
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/)
|
||||
it('publishes no metadata file when there is nothing to publish', async () => {
|
||||
await seedPreset(userRoot, 'source')
|
||||
|
||||
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('display metadata beside a composition', () => {
|
||||
it('stores the name and description the author supplied', async () => {
|
||||
await ctx.agentPresets.write('mine', VALID, { name: '我的模式', description: '只做检索。' })
|
||||
|
||||
expect(await readFile(join(userRoot, 'mine', METADATA_FILE), 'utf8'))
|
||||
.toContain('name: 我的模式')
|
||||
const listed = (await ctx.agentPresets.list()).find(preset => preset.id === 'mine')
|
||||
expect(listed).toMatchObject({ name: '我的模式', description: '只做检索。' })
|
||||
})
|
||||
|
||||
it('removes the file when both fields are cleared', async () => {
|
||||
await ctx.agentPresets.write('mine', VALID, { name: '我的模式' })
|
||||
|
||||
await ctx.agentPresets.write('mine', VALID, {})
|
||||
await ctx.agentPresets.copy('source', 'mine')
|
||||
|
||||
// An empty metadata document would read as an intentional blank name;
|
||||
// absence is what "this preset publishes no display text" looks like.
|
||||
@@ -115,20 +115,56 @@ describe('display metadata beside a composition', () => {
|
||||
expect((await ctx.agentPresets.list()).find(preset => preset.id === 'mine')?.name).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a composition mountable when its metadata is unreadable', async () => {
|
||||
await ctx.agentPresets.write('mine', VALID)
|
||||
await writeFile(join(userRoot, 'mine', METADATA_FILE), 'name: [unclosed\n')
|
||||
it('refuses an id that could escape the preset root', async () => {
|
||||
for (const id of ['../escape', 'a/b', '/abs', '..', 'Upper']) {
|
||||
await expect(ctx.agentPresets.copy('standard', id)).rejects.toThrow(/must match/)
|
||||
}
|
||||
// Nothing was created for any of them.
|
||||
expect(existsSync(join(userRoot, 'escape'))).toBe(false)
|
||||
})
|
||||
|
||||
// Presentation is not capability: discovery still yields the preset.
|
||||
const listed = (await ctx.agentPresets.list()).find(preset => preset.id === 'mine')
|
||||
expect(listed?.name).toBeUndefined()
|
||||
expect(await ctx.agentPresets.resolve('mine')).toMatchObject({ id: 'mine' })
|
||||
it('refuses an id the roster already supplies, shipped ones included', async () => {
|
||||
await ctx.agentPresets.copy('standard', 'mine')
|
||||
|
||||
await expect(ctx.agentPresets.copy('standard', 'mine')).rejects.toThrow(/already exists/)
|
||||
// A user directory named like a shipped preset would be shadowed by it.
|
||||
await expect(ctx.agentPresets.copy('standard', 'minimal')).rejects.toThrow(/already exists/)
|
||||
})
|
||||
|
||||
it('refuses a directory that occupies the name without being a preset', async () => {
|
||||
await mkdir(join(userRoot, 'occupied'), { recursive: true })
|
||||
await writeFile(join(userRoot, 'occupied', 'README.txt'), 'nope\n')
|
||||
|
||||
// Discovery does not list it (no composition file), so only the disk
|
||||
// check can refuse it with a readable error instead of a filesystem code.
|
||||
await expect(ctx.agentPresets.copy('standard', 'occupied')).rejects.toThrow(/already exists/)
|
||||
expect(await readFile(join(userRoot, 'occupied', 'README.txt'), 'utf8')).toBe('nope\n')
|
||||
})
|
||||
|
||||
it('reports an unknown source rather than creating anything', async () => {
|
||||
await expect(ctx.agentPresets.copy('never-existed', 'mine')).rejects.toThrow(/not found/)
|
||||
expect(existsSync(join(userRoot, 'mine'))).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves nothing behind when the copy itself fails', async () => {
|
||||
const source = {
|
||||
id: 'gone',
|
||||
trust: 'user' as const,
|
||||
path: join(userRoot, 'gone', COMPOSITION_FILE),
|
||||
}
|
||||
|
||||
// The source vanished between resolve and copy: the half-made target is
|
||||
// rolled back rather than left invisible to discovery.
|
||||
await expect(copyComposition(
|
||||
[{ path: userRoot, trust: 'user' as const }], source, 'mine',
|
||||
)).rejects.toThrow()
|
||||
expect(existsSync(join(userRoot, 'mine'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleting a preset', () => {
|
||||
it('removes a locally authored one', async () => {
|
||||
await ctx.agentPresets.write('mine', VALID)
|
||||
await ctx.agentPresets.copy('standard', 'mine')
|
||||
|
||||
await ctx.agentPresets.remove('mine')
|
||||
|
||||
@@ -149,8 +185,7 @@ describe('deleting a preset', () => {
|
||||
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)
|
||||
await seedPreset(second, 'elsewhere')
|
||||
const layered = new Context()
|
||||
layered.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await layered.plugin(Loader)
|
||||
@@ -184,26 +219,42 @@ describe('a deployment with no writable root', () => {
|
||||
})
|
||||
|
||||
expect(readOnly.agentPresets.authorable).toBe(false)
|
||||
await expect(readOnly.agentPresets.write('mine', VALID))
|
||||
await expect(readOnly.agentPresets.copy('standard', 'mine'))
|
||||
.rejects.toThrow(/no user-writable preset root/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a user root that does not exist yet', () => {
|
||||
it('is created by the first save', async () => {
|
||||
it('is created by the first copy', 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 }],
|
||||
default: 'standard',
|
||||
roots: [
|
||||
{ path: join(FIXTURES, 'system'), trust: 'system' as const },
|
||||
{ path: absent, trust: 'user' as const },
|
||||
],
|
||||
})
|
||||
|
||||
await fresh.agentPresets.write('mine', VALID)
|
||||
await fresh.agentPresets.copy('standard', 'mine')
|
||||
|
||||
expect(await readFile(join(absent, 'mine', COMPOSITION_FILE), 'utf8')).toBe(VALID)
|
||||
expect(await readFile(join(absent, 'mine', COMPOSITION_FILE), 'utf8'))
|
||||
.toBe(await fresh.agentPresets.read('standard'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('display metadata beside a composition', () => {
|
||||
it('keeps a composition mountable when its metadata is unreadable', async () => {
|
||||
await ctx.agentPresets.copy('standard', 'mine')
|
||||
await writeFile(join(userRoot, 'mine', METADATA_FILE), 'name: [unclosed\n')
|
||||
|
||||
// Presentation is not capability: discovery still yields the preset.
|
||||
const listed = (await ctx.agentPresets.list()).find(preset => preset.id === 'mine')
|
||||
expect(listed?.name).toBeUndefined()
|
||||
expect(await ctx.agentPresets.resolve('mine')).toMatchObject({ id: 'mine' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -12,8 +12,11 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
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 AgentPresets, {
|
||||
COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, PresetMountError, serviceForAgent,
|
||||
} from '@deepseek-ai/dsh-agent-presets'
|
||||
import type { Config } from '@deepseek-ai/dsh-agent-presets'
|
||||
import { createScope, scopeOf, setScopeParent } from '@deepseek-ai/dsh-scope'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -196,11 +199,35 @@ describe('rejecting a composition that cannot be used', () => {
|
||||
})
|
||||
|
||||
it('answers undefined for a service the agent\'s preset does not mount', async () => {
|
||||
// The isolated preset's standing instance exists in the same runtime, so
|
||||
// the lookup finds the NAME and must still refuse it: the instance lives
|
||||
// under another mount's fiber, not this agent's composition.
|
||||
await agentOn(ctx, 'sess-reach-other', 'isolated')
|
||||
const agent = await agentOn(ctx, 'sess-reach-none', 'standard')
|
||||
|
||||
expect(ctx.agentPresets.serviceFor(agent, 'fixtureIsolatedSvc')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('answers undefined for an agent outside the scope machinery', async () => {
|
||||
// Unscoped, scoped-but-unparented, and parented to a key no live mount
|
||||
// owns are the three ways a context can fail to name a standing mount;
|
||||
// each is an answer, not a throw, because the caller asked a question.
|
||||
expect(serviceForAgent(ctx, { ctx }, 'fixtureIsolatedSvc')).toBeUndefined()
|
||||
const loner = createScope(ctx, { test: 'loner' })
|
||||
expect(serviceForAgent(ctx, { ctx: loner.ctx }, 'fixtureIsolatedSvc')).toBeUndefined()
|
||||
const orphan = createScope(ctx, { test: 'orphan' })
|
||||
setScopeParent(scopeOf(orphan.ctx)!, { agentPreset: 'never-mounted' })
|
||||
expect(serviceForAgent(ctx, { ctx: orphan.ctx }, 'fixtureIsolatedSvc')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses to mount a preset directly into an unscoped context', async () => {
|
||||
// The service's own mount() guards this before delegating; the exported
|
||||
// function is callable on its own, so the boundary holds there too.
|
||||
const preset = await ctx.agentPresets.resolve('standard')
|
||||
|
||||
await expect(mountPreset(ctx, preset)).rejects.toThrow(/unscoped context/)
|
||||
})
|
||||
|
||||
it('reports the known ids when a preset is unknown', async () => {
|
||||
await expect(ctx.agentPresets.resolve('nope'))
|
||||
.rejects.toThrow(/preset "nope" not found \(available: .*standard/)
|
||||
@@ -409,3 +436,101 @@ describe('replacing a composition', () => {
|
||||
.rejects.toThrow(/unscoped context/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('editing a composition file', () => {
|
||||
/** One-row composition whose single tool is named `tool`. */
|
||||
const rowFor = (tool: string): string =>
|
||||
`- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: ${tool}\n`
|
||||
|
||||
/**
|
||||
* A context over a temp root holding one editable preset. The id is
|
||||
* per-test because `livePresetMounts()` is a process-global registry.
|
||||
*/
|
||||
async function editable(id: string): Promise<{ scoped: Context; path: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-edit-'))
|
||||
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 }] })
|
||||
return { scoped, path }
|
||||
}
|
||||
|
||||
it('starts a new generation for later sessions while joined ones keep theirs', async () => {
|
||||
const { scoped, path } = await editable('edited')
|
||||
const first = await agentOn(scoped, 'sess-gen-first', 'edited')
|
||||
expect(toolNames(scoped, first)).toEqual(['before'])
|
||||
|
||||
// Files are the only composition editor now (authoring is copy/delete),
|
||||
// so the standing mount notices the file's stamp changing on its own.
|
||||
await writeFile(path, rowFor('afterwards'))
|
||||
|
||||
const second = await agentOn(scoped, 'sess-gen-second', 'edited')
|
||||
expect(toolNames(scoped, second)).toEqual(['afterwards'])
|
||||
// The joined session keeps the generation it runs on.
|
||||
expect(toolNames(scoped, first)).toEqual(['before'])
|
||||
})
|
||||
|
||||
it('gives two sessions racing the refreshed file one shared new generation', async () => {
|
||||
const { scoped, path } = await editable('raced')
|
||||
await agentOn(scoped, 'sess-race-seed', 'raced')
|
||||
|
||||
await writeFile(path, rowFor('afterwards'))
|
||||
|
||||
// Whichever racer swaps the pointer first, the other must join it rather
|
||||
// than fork a third generation off the same edit.
|
||||
const [left, right] = await Promise.all([
|
||||
agentOn(scoped, 'sess-race-left', 'raced'),
|
||||
agentOn(scoped, 'sess-race-right', 'raced'),
|
||||
])
|
||||
expect(toolNames(scoped, left)).toEqual(['afterwards'])
|
||||
expect(toolNames(scoped, right)).toEqual(['afterwards'])
|
||||
expect(livePresetMounts().filter(mount => mount.presetId === 'raced')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('hands a host reader the standing key without starting an agent', async () => {
|
||||
const { scoped } = await editable('cold-read')
|
||||
|
||||
const key = await scoped.agentPresets.standingKeyFor('cold-read')
|
||||
|
||||
// The mount exists for the reader; no agent, session, or turn started.
|
||||
expect(key).toEqual({ agentPreset: 'cold-read' })
|
||||
expect(livePresetMounts().filter(mount => mount.presetId === 'cold-read')).toHaveLength(1)
|
||||
expect(scoped.agents.get(SessionId('cold-read'))).toBeUndefined()
|
||||
// A second reader resolves the same generation, not a new mount.
|
||||
expect(await scoped.agentPresets.standingKeyFor('cold-read')).toBe(key)
|
||||
})
|
||||
|
||||
it('refuses to mount a generation it cannot stamp', async () => {
|
||||
const { scoped, path } = await editable('unstampable')
|
||||
await rm(path)
|
||||
|
||||
// Discovery would refuse the preset too; a caller that resolved just
|
||||
// before the deletion must get a mount failure, not an unstamped
|
||||
// generation that no later edit could ever refresh.
|
||||
const racer = scoped.agentPresets as unknown as {
|
||||
ensureStanding(preset: { id: string; trust: 'user'; path: string }): Promise<unknown>
|
||||
}
|
||||
await expect(racer.ensureStanding({ id: 'unstampable', trust: 'user', path }))
|
||||
.rejects.toThrow(PresetMountError)
|
||||
expect(livePresetMounts().filter(mount => mount.presetId === 'unstampable')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps serving the mounted generation when the file cannot be statted', async () => {
|
||||
const { scoped, path } = await editable('stale')
|
||||
await agentOn(scoped, 'sess-stale-served', 'stale')
|
||||
expect(livePresetMounts().filter(mount => mount.presetId === 'stale')).toHaveLength(1)
|
||||
|
||||
await rm(path)
|
||||
|
||||
// Discovery refuses a preset whose composition cannot be statted, so the
|
||||
// public route cannot reach this state — but a caller that resolved just
|
||||
// before the deletion still can, and it must be served the standing
|
||||
// generation rather than failed over a stat.
|
||||
const racer = scoped.agentPresets as unknown as {
|
||||
ensureStanding(preset: { id: string; trust: 'user'; path: string }): Promise<unknown>
|
||||
}
|
||||
await racer.ensureStanding({ id: 'stale', trust: 'user', path })
|
||||
|
||||
expect(livePresetMounts().filter(mount => mount.presetId === 'stale')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user