feat(web): author agent presets from a settings page

A composition is a file, but "edit it on the filesystem" is not a browser
affordance. The roster gains `read`/`write`/`remove` beside `select`, and
the browser gains a settings section over them: the presets as rows, one
composition open in a YAML editor at a time, and per-row default, duplicate,
and delete.

All four authoring methods are loopback-pinned. A composition names the
plugins a session runs, so reading one is reconnaissance, writing one is
arbitrary capability, and selecting one can move a session onto a preset
that edits the live runtime. `agentPreset.list` deliberately stays ordinary
and now reports `authorable`, so a surface knows whether creating is
possible at all rather than offering a button whose save always fails.

Authoring starts by duplicating: a shipped preset opens read-only because
the deployment's copy is what a broken local one is compared against. Ids
are contained before they become directory names, and the text is parsed
with the loader's own schema, so a save cannot leave a file no session
could load.

Fixes a defect the real-composition test found: a preset written under the
user's home could never mount, because the loader resolves a row against the
composition's own directory and Node's `node_modules` walk from there never
reaches the installed harness. The mount now records the host base and sends
bare specifiers there, leaving relative paths resolving from the preset.

Also closes the coverage the earlier surfaces in this stack shipped without —
the General row, the composer seat, and the plugin halves now have tests.
This commit is contained in:
Yichen Jiang
2026-08-04 12:23:40 +08:00
parent 52607cab69
commit 6dfc568ec2
56 changed files with 3478 additions and 110 deletions

View File

@@ -0,0 +1,157 @@
/**
* Creating, 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.
* @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 { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { expandHomePath } from '@deepseek-ai/dsh-paths'
import { COMPOSITION_FILE } from './discovery.ts'
import type { AgentPreset, PresetRoot } from './types.ts'
/**
* Ids a preset directory may use.
*
* The id becomes a path segment, so this is a containment boundary rather than
* a style rule: `..`, a separator, or an absolute-looking name would place the
* composition outside the root the deployment authorised.
*/
const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/
/** 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 composition that is not a usable entry list. */
export class InvalidCompositionError extends Error {
constructor(
/** Why the text cannot be a composition. */
readonly reason: string,
) {
super(`agent-presets: composition is not a valid entry list: ${reason}`)
}
}
/** 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))
}
/**
* 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.
* @returns the file's contents.
*/
export async function readComposition(preset: AgentPreset): Promise<string> {
return await readFile(preset.path, 'utf8')
}
/**
* 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.
* @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.
*/
export async function writeComposition(
roots: readonly PresetRoot[],
id: string,
content: 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 })
return path
}
/**
* 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 })
}

View File

@@ -15,7 +15,9 @@ import { scopeOf } from '@deepseek-ai/dsh-scope'
import z from 'schemastery'
import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
import { discoverPresets } from './discovery.ts'
import { deleteComposition, readComposition, writeComposition } from './authoring.ts'
import { mountPreset, serviceForAgent, unmountPresetFor } from './mount.ts'
import { PresetNotWritableError } from './authoring.ts'
import { UnknownPresetError, type AgentPreset, type Config } from './types.ts'
/** Settings namespace carrying the user's chosen default preset. */
@@ -37,6 +39,10 @@ export {
inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent,
unmountPresetFor, type PresetMount,
} from './mount.ts'
export {
assertComposition, deleteComposition, InvalidCompositionError, InvalidPresetIdError,
PresetNotWritableError, readComposition, writableRoot, writeComposition,
} from './authoring.ts'
export { resolveSessionPreset, type PresetBearingSession } from './session.ts'
export { PresetMountError, UnknownPresetError } from './types.ts'
export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts'
@@ -142,6 +148,51 @@ export class AgentPresets extends Service {
return preset
}
/** Whether this deployment configures a root locally authored presets go to. */
get authorable(): boolean {
return this.config.roots.some(root => root.trust === 'user')
}
/**
* Read one preset's composition text.
* @param id - the preset id.
* @returns the composition exactly as stored.
* @throws when no configured root supplies that id.
*/
async read(id: string): Promise<string> {
return await readComposition(await this.resolve(id))
}
/**
* Create or replace a locally authored preset.
*
* 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.
* @throws when the id is unusable, the text is not an entry list, or the
* deployment configures no writable root.
*/
async write(id: string, content: string): 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')
}
await writeComposition(this.config.roots, id, content)
}
/**
* Delete a locally authored preset.
* @param id - the preset id.
* @throws when the preset is unknown or ships with the deployment.
*/
async remove(id: string): Promise<void> {
await deleteComposition(this.config.roots, await this.resolve(id))
}
/**
* One agent's instance of a service its preset mounted.
*

View File

@@ -41,6 +41,14 @@ interface MountedTree {
*/
const mounted = new WeakMap<object, MountedTree>()
/**
* The base URL bare specifiers resolve against, per pending mount, keyed by the
* same config object. Recorded before the subtree is plugged, because `Include`
* rewrites its own context's `baseUrl` to the composition's directory and the
* pre-mount value is the only handle on where the harness itself lives.
*/
const harnessBase = new WeakMap<object, string>()
/**
* Include subclass that publishes its tree and fiber for the audit, and never
* writes to the file it read.
@@ -51,6 +59,33 @@ class PresetTree extends Include {
mounted.set(config, { tree: this, fiber: ctx.fiber })
}
/**
* Resolve a bare specifier from the harness rather than from the preset.
*
* `EntryTree.import()` resolves against the tree's own `baseUrl`, which
* `Include` sets to the composition's directory. That is right for a
* relative specifier — a preset's own files travel with it — and wrong for
* a package name: a locally authored preset lives under the user's home,
* where Node's upward `node_modules` walk never reaches the harness's own
* dependencies, so every `@deepseek-ai/dsh-*` row would fail to import. The
* mount records the host composition's base instead, which is inside the
* installed harness, and bare names resolve from there.
* @param name - the module specifier from the row.
* @param getOuterStack - the loader's stack composer for import diagnostics.
* @returns the imported module, or the `cordis:` builtin.
*/
override import(name: string, getOuterStack?: () => string[]): unknown {
const base = harnessBase.get(this.config)
/* v8 ignore next -- every PresetTree is constructed by `mountPreset`, which records the base first */
if (base === undefined) return super.import(name, getOuterStack)
if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(name, getOuterStack)
const internal = this.ctx.loader.internal
/* v8 ignore next -- Node always supplies the internal module loader; the branch keeps a
hypothetical embedder from losing the row's name in a resolution error. */
if (internal === undefined) return super.import(name, getOuterStack)
return internal.import(name, base, {})
}
/**
* A preset is an input, never a persistence target.
*
@@ -269,6 +304,11 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi
)
}
const config: Include.Config = { path: pathToFileURL(preset.path).href }
// Captured before the subtree exists: the agent context still carries the
// host composition's base, which is inside the installed harness and is
// therefore where a row's package name has to resolve from.
/* v8 ignore next -- the Loader sets `baseUrl` on the root before any agent context derives from it */
if (agentCtx.baseUrl !== undefined) harnessBase.set(config, agentCtx.baseUrl)
// Before the record this mount is about to add: every session takes this
// path, so it is what keeps the set bounded on a host that never reads it.
pruneDisposedMounts()