Files
deepseek-harness/packages/preset/agent-presets/src/authoring.ts
Yichen Jiang c56f23ae1f 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.
2026-08-09 02:17:56 +08:00

196 lines
7.6 KiB
TypeScript

/**
* 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 { 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 { METADATA_FILE, renderPresetMetadata } from './metadata.ts'
import { PRESET_ID, type AgentPreset, type PresetRoot } from './types.ts'
/** 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 copy target that is already occupied — a copy never overwrites. */
export class PresetExistsError extends Error {
constructor(
/** The id that is already taken. */
readonly presetId: string,
) {
super(
`agent-presets: preset "${presetId}" already exists — `
+ 'a copy never overwrites; delete the existing preset first or choose another id',
)
}
}
/** 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))
}
/**
* 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')
}
/** 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
}
/**
* 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.
*/
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,
name?: string,
): Promise<string> {
if (!PRESET_ID.test(id)) throw new InvalidPresetIdError(id)
const dir = join(writableRoot(roots), id)
// 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 dir
}
/**
* 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 })
}