fix(agent-presets,web): broken presets are roster rows, not gaps

A hand-damaged preset was silent until the worst moment. An unparsable
composition listed as an ordinary selectable row and failed only at the
next session start — set as default, every new session failed. A
directory whose composition file was deleted vanished from the roster
while still occupying its id: copy answered "delete the existing preset
first" while remove answered "not found", a dead end.

Discovery now owns health: every id-shaped directory is a roster slot,
broken when its composition is missing or unloadable, checked with the
loader's own entryListSchema dialect (!!js included) so health never
rejects what the loader accepts. `broken` rides AgentPreset, the
agentPreset.list entry, and the UI row; mount/recompose/standingKeyFor
refuse broken up front with the discovery-reported reason, while
resolve/read/remove still answer. The section renders marked red cards —
unselectable, uncopyable, deletable, location kept on custom rows — and
both pickers drop broken rows entirely.

The cordis preset's persona now forbids editing the shipped install
(corrupting cordis would disable the mode itself) and points authoring
at $DSH_HOME/.agent-presets; its skill teaches preset.yml metadata, the
copy-first workflow, the one-escalation sandbox reality, and honest
verification. Exercised live: asked to edit the shipped composition the
composed agent refuses citing both rules; asked for real presets (simple
and complex) it lands them under the user root with one approved
escalation each and self-checks with the loader dialect.
This commit is contained in:
Yichen Jiang
2026-08-09 02:17:56 +08:00
parent bf31797e95
commit c56f23ae1f
36 changed files with 720 additions and 87 deletions
@@ -258,11 +258,36 @@ describe('display metadata beside a composition', () => {
})
})
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')
describe('the on-disk occupancy backstop', () => {
it('refuses a directory the roster cannot see', async () => {
// The service's roster check sees every id-shaped directory now, so this
// is the race backstop: a directory appearing between the roster read and
// the copy still gets the readable refusal, not a filesystem error code.
await mkdir(join(userRoot, 'raced'), { recursive: true })
const source = await ctx.agentPresets.resolve('standard')
expect((await ctx.agentPresets.list()).some(preset => preset.id === 'not-a-preset')).toBe(false)
await expect(copyComposition(
[{ path: userRoot, trust: 'user' as const }], source, 'raced',
)).rejects.toThrow(/already exists/)
})
})
describe('a ghost directory under the user root', () => {
it('lists broken, blocks its id, and clears through remove', async () => {
// The classic hand-edit: the composition file was deleted, the directory
// stayed. It must not vanish from the roster — its id is still taken, so
// there has to be something to see and delete.
await mkdir(join(userRoot, 'ghost'), { recursive: true })
await writeFile(join(userRoot, 'ghost', 'README.txt'), 'composition deleted by hand\n')
const ghost = (await ctx.agentPresets.list()).find(preset => preset.id === 'ghost')
expect(ghost?.broken).toMatch(/agent\.cordis\.yml is missing/)
await expect(ctx.agentPresets.copy('standard', 'ghost')).rejects.toThrow(/already exists/)
// remove is the way out the roster row offers; the id is claimable again.
await ctx.agentPresets.remove('ghost')
expect(existsSync(join(userRoot, 'ghost'))).toBe(false)
await ctx.agentPresets.copy('standard', 'ghost')
expect((await ctx.agentPresets.list()).find(preset => preset.id === 'ghost')?.broken).toBeUndefined()
})
})
@@ -1,4 +1,4 @@
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
import { chmod, mkdtemp, mkdir, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -57,10 +57,28 @@ describe('preset discovery', () => {
})
})
it('skips a directory that holds no composition file', async () => {
it('reports a directory with no composition as a broken preset slot', async () => {
const found = await scanRoot(USER)
expect(found.map(preset => preset.id)).not.toContain('not-a-preset')
// The directory still occupies its id — a copy to that name is refused —
// so hiding it would leave nothing to see or delete. It surfaces broken.
const ghost = found.find(preset => preset.id === 'not-a-preset')
expect(ghost?.broken).toMatch(/agent\.cordis\.yml is missing/)
})
it('skips a directory whose name no preset id could ever claim', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-presets-oddname-'))
await mkdir(join(root, '.hidden'))
await mkdir(join(root, 'Has_Caps'))
await mkdir(join(root, 'usable'))
await writeFile(join(root, 'usable', COMPOSITION_FILE), '[]\n')
const found = await scanRoot({ path: root, trust: 'user' })
// `.hidden` and `Has_Caps` cannot collide with any copy target, so
// reporting tool residue as broken presets would only train users to
// ignore the marker.
expect(found.map(preset => preset.id)).toEqual(['usable'])
})
it('records the root trust on every preset it discovers', async () => {
@@ -111,3 +129,69 @@ describe('preset discovery', () => {
expect(found).toEqual([])
})
})
describe('composition health', () => {
/** One directory under a fresh root holding `composition`, scanned. */
async function scanned(composition: string): Promise<string | undefined> {
const root = await mkdtemp(join(tmpdir(), 'dsh-presets-health-'))
await mkdir(join(root, 'probe'))
await writeFile(join(root, 'probe', COMPOSITION_FILE), composition)
const [preset] = await scanRoot({ path: root, trust: 'user' })
return preset?.broken
}
it('reports unparsable YAML with the parser\'s reason', async () => {
expect(await scanned('- id: x\n name: [unclosed\n')).toMatch(/not valid YAML/)
})
it('reports a composition that is not a list of rows', async () => {
expect(await scanned('name: not-a-list\n')).toMatch(/top-level list of plugin rows/)
})
it('reports the first row that names no plugin, by position', async () => {
expect(await scanned('- id: ok\n name: some-plugin\n- id: broken\n'))
.toMatch(/row 2 names no plugin/)
})
it('reports a row that is not a map at all', async () => {
expect(await scanned('- just-a-string\n')).toMatch(/row 1 is not a plugin row/)
})
it('descends into a group\'s own row list', async () => {
const composition = '- id: grp\n name: cordis:group\n group: true\n config:\n - id: inner\n'
expect(await scanned(composition)).toMatch(/row 1 row 1 names no plugin/)
})
it('reports a group whose config is not a list', async () => {
const composition = '- id: grp\n name: cordis:group\n group: true\n config: not-a-list\n'
expect(await scanned(composition)).toMatch(/group row 1 must hold a list/)
})
it('accepts a group whose own list is healthy', async () => {
const composition = '- id: grp\n name: cordis:group\n group: true\n config:\n - id: inner\n name: some-plugin\n'
expect(await scanned(composition)).toBeUndefined()
})
it('reports a composition that stats but cannot be read', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-presets-unreadable-'))
await mkdir(join(root, 'sealed'))
const path = join(root, 'sealed', COMPOSITION_FILE)
await writeFile(path, '[]\n')
await chmod(path, 0o000)
const [preset] = await scanRoot({ path: root, trust: 'user' })
expect(preset?.broken).toMatch(/cannot be read/)
})
it('accepts the loader dialect, !!js scalars included', async () => {
// Health must never call a composition broken that the loader accepts:
// `!!js` is the loader's own extension, so it parses here too.
const composition = '- id: x\n name: some-plugin\n config:\n value: !!js "1 + 1"\n'
expect(await scanned(composition)).toBeUndefined()
})
it('accepts an empty list', async () => {
expect(await scanned('[]\n')).toBeUndefined()
})
})
@@ -238,9 +238,11 @@ describe('the preset roster', () => {
it('lists every root\'s presets with the earlier root winning', async () => {
const listed = await ctx.agentPresets.list()
// `not-a-preset` is the fixture ghost: no composition file, listed broken.
expect(listed.map(preset => preset.id).sort())
.toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', 'pending', 'standard', 'two-broken'])
.toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', 'not-a-preset', 'pending', 'standard', 'two-broken'])
expect(listed.find(preset => preset.id === 'standard')?.trust).toBe('system')
expect(listed.find(preset => preset.id === 'not-a-preset')?.broken).toMatch(/is missing/)
})
it('exposes the configured default id', () => {
@@ -248,6 +250,41 @@ describe('the preset roster', () => {
})
})
describe('composing from a broken preset', () => {
/** A roster whose only user preset carries `composition`. */
async function rosterWith(composition: string): Promise<Context> {
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-'))
await mkdir(join(root, 'damaged'))
await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition)
return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }] })
}
it('refuses the mount up front with the discovery-reported reason', async () => {
const scoped = await rosterWith('- id: x\n name: [unclosed\n')
// The refusal happens before the loader ever sees the file, so every
// unloadable shape gets the same early PresetMountError — and a rejected
// setup rolls the whole agent creation back.
await expect(agentOn(scoped, 'sess-broken', 'damaged')).rejects.toThrow(PresetMountError)
await expect(agentOn(scoped, 'sess-broken-2', 'damaged')).rejects.toThrow(/not valid YAML/)
expect(livePresetMounts().filter(mount => mount.presetId === 'damaged')).toHaveLength(0)
})
it('refuses the standing key a cold reader would mount by', async () => {
const scoped = await rosterWith('rows: not-a-list\n')
await expect(scoped.agentPresets.standingKeyFor('damaged'))
.rejects.toThrow(/top-level list of plugin rows/)
})
it('still resolves the broken row for the surfaces that manage it', async () => {
const scoped = await rosterWith('- id: x\n name: [unclosed\n')
// Deleting and reporting need the row; only composing refuses it.
expect((await scoped.agentPresets.resolve('damaged')).broken).toMatch(/not valid YAML/)
})
})
describe('a roster with nothing in it', () => {
it('says so instead of naming an empty list of candidates', async () => {
const bare = new Context()