59a2e4d825
The windows-native job has been red since #1990 put the sandbox-windows-acl sources into the Windows 100%-per-file denominator without tests carrying them, and #1543 dropped the authoring.ts V8 ignore for the POSIX-only owner-execute branch. Non-blocking at merge time, the red state has propagated to every later pull request. Cover every in-process ACL-sandbox failure branch with stub-based failure-path suites (ffi/acl/token/spawn/index), following the package's existing failure-paths pattern; the package now measures 100% per file under the Windows denominator. Exclude only the runner entry from the win32 denominator: it executes exclusively as a spawned child outside the instrumented run, and its behavior is pinned end-to-end by the runner suite. Restore the authoring.ts narrow V8 ignore and add one for the dispose token guard whose absent-token arm is lifecycle-unreachable. Update the dual-lane Agent Note with the denominator composition.
197 lines
7.7 KiB
TypeScript
197 lines
7.7 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 {
|
|
/* v8 ignore next -- Windows exposes no POSIX owner-execute bit; the POSIX lane covers both file modes. */
|
|
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 })
|
|
}
|