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,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
}
/**