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:
Yichen Jiang
2026-08-08 22:35:26 +08:00
parent 2cea99409f
commit b77fb9036c
60 changed files with 2253 additions and 1336 deletions

View File

@@ -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' })
})
})

View File

@@ -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)
})
})