ci/woodpecker/pr/ci Pipeline failed
Resolves the review finding onc23a71d7: 'store add' silently deleted a markerless target directory and reported it as recovered-partial, but the code cannot distinguish its own interrupted-write debris from content the operator placed by hand — and the USER root's entire contract is that tooling never destroys operator content. - addStoreEntry now throws typed STORE_TARGET_UNMARKED on an unmarked target; deletion happens only when the caller passes { reclaim: true }. - CLI: 'store add' gains --reclaim ('replace an existing UNMARKED target directory; refuses without this flag'). - Status renamed recovered-partial -> reclaimed-unmarked so even the opted-in path names what it did (fix 2 folded into fix 1). - Spec: default-refusal test asserts operator content SURVIVES; opt-in test asserts replacement; two CLI tests cover exit codes. - Ordering test (second review round): 'reclaim can never destroy a marked, vetted entry' — adds a vetted entry, re-adds with reclaim:true, asserts STORE_ALREADY_PRESENT AND the original content + marker survive on disk. Pins marker-check-before-reclaim-check against the guard-clause-migrates-upward refactor; discrimination proven by sabotaging the order (1 failed, exactly this test) and restoring (49/49). - TOCTOU note added at assertSourceTreeHasNoSymlinks per review (known check-then-use window, accepted for a local operator-run CLI). Gates (settled set, rc-honest): store spec 49/49; package vitest 87 files / 1596 tests; package build+lint rc0; root build 25/25 + typecheck 45/45; prettier --check rc0 on all four touched files.c23a71d7remains the reviewed object, untouched.
545 lines
18 KiB
TypeScript
545 lines
18 KiB
TypeScript
import {
|
|
cpSync,
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
type Dirent,
|
|
type Stats,
|
|
} from 'node:fs';
|
|
import { isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
|
|
import type { Command } from 'commander';
|
|
import { DEFAULT_MOSAIC_USER_HOME } from '../constants.js';
|
|
|
|
/**
|
|
* `mosaic store` — the vetted user store under `~/.mosaic/{plugins,skills}` (W-F4).
|
|
*
|
|
* Two roots with distinct ownership (HARNESS-HOMES design, frozen REV3):
|
|
* - `~/.config/mosaic/` is the SYSTEM root: update-owned, replaceable wholesale.
|
|
* - `~/.mosaic/` is the USER root: never touched by installs or updates.
|
|
*
|
|
* This module only ever writes under the USER root. The store is the vetting
|
|
* boundary: content lands here only through an explicit `store add` carrying a
|
|
* named vetting attribution, and every entry is versioned
|
|
* (`store/<kind>s/<name>/<version>/`) with a `store-entry.json` marker written
|
|
* LAST — a version directory without its marker is never a usable entry, and
|
|
* an unmarked target is REFUSED by default: it may be this tool's own debris
|
|
* from an interrupted add, or content the operator placed by hand, and the
|
|
* code cannot tell those apart — so deletion happens only under an explicit
|
|
* `--reclaim` opt-in, and the result status names what was done.
|
|
*
|
|
* Deferred by design (W-F6 and later): activation/symlink-install into agent
|
|
* homes, `current`-pointer pinning, network acquisition. `add` accepts a local
|
|
* source path only — no network, no credentials, ever.
|
|
*/
|
|
|
|
export type StoreKind = 'plugin' | 'skill';
|
|
export const STORE_KINDS: readonly StoreKind[] = ['plugin', 'skill'];
|
|
|
|
/** On-disk metadata marker; written last so its presence commits an entry. */
|
|
export const STORE_ENTRY_MARKER = 'store-entry.json';
|
|
|
|
export interface StorePaths {
|
|
/** User data root, e.g. `~/.mosaic`. */
|
|
userRoot: string;
|
|
}
|
|
|
|
export interface StoreEntryMeta {
|
|
schema: 1;
|
|
kind: StoreKind;
|
|
name: string;
|
|
version: string;
|
|
/** Absolute source path the content was vetted from, as resolved at add time. */
|
|
sourcePath: string;
|
|
/** Operator who vouched for the content — required, non-empty. */
|
|
vettedBy: string;
|
|
/** ISO timestamp of the add. */
|
|
vettedAt: string;
|
|
/** Free-form vetting notes, if any. */
|
|
notes?: string;
|
|
}
|
|
|
|
export type StoreAddStatus = 'added' | 'reclaimed-unmarked';
|
|
|
|
export interface StoreAddResult {
|
|
kind: StoreKind;
|
|
name: string;
|
|
version: string;
|
|
status: StoreAddStatus;
|
|
entryPath: string;
|
|
sourcePath: string;
|
|
}
|
|
|
|
export type StoreEntryStatus = 'vetted' | 'incomplete' | 'invalid-metadata' | 'foreign';
|
|
|
|
export interface StoreListEntry {
|
|
kind: StoreKind;
|
|
name: string;
|
|
/** Undefined for name-level foreign files (not a directory at all). */
|
|
version?: string;
|
|
status: StoreEntryStatus;
|
|
entryPath: string;
|
|
meta?: StoreEntryMeta;
|
|
}
|
|
|
|
const SAFE_STORE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
const SAFE_STORE_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
|
|
|
export class StoreError extends Error {
|
|
public readonly code: string;
|
|
|
|
public constructor(code: string, message: string) {
|
|
super(message);
|
|
this.name = 'StoreError';
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
/** Resolve the user store root while keeping tests injectable. */
|
|
export function getDefaultStorePaths(): StorePaths {
|
|
const userRoot = process.env['MOSAIC_USER_HOME'] ?? DEFAULT_MOSAIC_USER_HOME;
|
|
return { userRoot };
|
|
}
|
|
|
|
/**
|
|
* Reject a user-supplied name before any filesystem operation.
|
|
* A store name identifies one directory under `store/<kind>s/`.
|
|
*/
|
|
export function validateStoreName(name: string): void {
|
|
if (
|
|
name.length === 0 ||
|
|
name.startsWith('-') ||
|
|
name.endsWith('.') ||
|
|
name.includes('..') ||
|
|
name.includes('/') ||
|
|
name.includes('\\') ||
|
|
isAbsolute(name) ||
|
|
!SAFE_STORE_NAME.test(name)
|
|
) {
|
|
throw new StoreError(
|
|
'STORE_INVALID_NAME',
|
|
`Invalid store name ${JSON.stringify(name)}: use letters, numbers, dots, underscores, or hyphens; start with a letter or number; and do not use paths, "..", or a leading "-".`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Versions share the name discipline plus `+` (semver build metadata). */
|
|
export function validateStoreVersion(version: string): void {
|
|
if (
|
|
version.length === 0 ||
|
|
version.startsWith('-') ||
|
|
version.endsWith('.') ||
|
|
version.includes('..') ||
|
|
version.includes('/') ||
|
|
version.includes('\\') ||
|
|
isAbsolute(version) ||
|
|
!SAFE_STORE_VERSION.test(version)
|
|
) {
|
|
throw new StoreError(
|
|
'STORE_INVALID_VERSION',
|
|
`Invalid version ${JSON.stringify(version)}: use letters, numbers, dots, underscores, hyphens, or plus; start with a letter or number; and do not use paths, "..", or a leading "-".`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function validateStoreKind(kind: string): asserts kind is StoreKind {
|
|
if (!(STORE_KINDS as readonly string[]).includes(kind)) {
|
|
throw new StoreError(
|
|
'STORE_INVALID_KIND',
|
|
`Invalid store kind ${JSON.stringify(kind)}: expected one of ${STORE_KINDS.map((k) => `"${k}"`).join(', ')}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function validateVettedBy(vettedBy: string): void {
|
|
if (vettedBy.trim().length === 0 || vettedBy.includes('\n') || vettedBy.length > 80) {
|
|
throw new StoreError(
|
|
'STORE_INVALID_VETTER',
|
|
'Invalid --by value: name the operator vouching for this content (single line, at most 80 characters).',
|
|
);
|
|
}
|
|
}
|
|
|
|
function lstatIfPresent(path: string): Stats | undefined {
|
|
try {
|
|
return lstatSync(path);
|
|
} catch (error: unknown) {
|
|
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return undefined;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function assertNoSymlinkAncestors(path: string): void {
|
|
const absolute = resolve(path);
|
|
const pathRoot = parse(absolute).root;
|
|
let current = pathRoot;
|
|
|
|
for (const segment of relative(pathRoot, absolute).split(sep)) {
|
|
if (segment.length === 0) continue;
|
|
current = join(current, segment);
|
|
const entry = lstatIfPresent(current);
|
|
if (!entry) break;
|
|
if (entry.isSymbolicLink()) {
|
|
throw new StoreError(
|
|
'STORE_SYMLINK_ROOT',
|
|
`Refusing symlink ancestor at ${current}; the user store root must resolve without symlink traversal.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** `plugins` for plugin, `skills` for skill — plural on disk per the layout. */
|
|
function kindDirName(kind: StoreKind): string {
|
|
return kind === 'plugin' ? 'plugins' : 'skills';
|
|
}
|
|
|
|
export function storeKindDir(kind: StoreKind, paths: StorePaths = getDefaultStorePaths()): string {
|
|
return join(paths.userRoot, kindDirName(kind));
|
|
}
|
|
|
|
function entryDir(kind: StoreKind, name: string, version: string, paths: StorePaths): string {
|
|
return join(storeKindDir(kind, paths), name, version);
|
|
}
|
|
|
|
function isInsideRoot(candidate: string, root: string): boolean {
|
|
const rel = relative(resolve(root), resolve(candidate));
|
|
return rel.length > 0 && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
}
|
|
|
|
/**
|
|
* Refuse any symlink in the source tree — the vetting boundary copies real
|
|
* content only, so a vetted entry can never carry a link that escapes it.
|
|
*
|
|
* NOTE: known check-then-use window between this walk and the `cpSync` below:
|
|
* a symlink created concurrently with the add could slip through. Accepted
|
|
* for a local, operator-run CLI; revisit before any unattended or networked
|
|
* acquisition path exists.
|
|
*/
|
|
function assertSourceTreeHasNoSymlinks(sourcePath: string): void {
|
|
const stack: string[] = [sourcePath];
|
|
while (stack.length > 0) {
|
|
const current = stack.pop()!;
|
|
for (const dirent of readdirSync(current, { withFileTypes: true })) {
|
|
const child = join(current, dirent.name);
|
|
if (dirent.isSymbolicLink()) {
|
|
throw new StoreError(
|
|
'STORE_SOURCE_SYMLINK',
|
|
`Refusing to vet content containing a symlink: ${child}. Resolve or remove symlinks before adding to the store.`,
|
|
);
|
|
}
|
|
if (dirent.isDirectory()) stack.push(child);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Vet and add one versioned entry to the user store.
|
|
*
|
|
* Copies the source directory (real content, no symlinks) to
|
|
* `<userRoot>/<kind>s/<name>/<version>/` and writes the `store-entry.json`
|
|
* marker LAST: a crash mid-copy leaves at most a recoverable partial, never a
|
|
* half-vetted entry that lists as present.
|
|
*/
|
|
export function addStoreEntry(
|
|
kind: StoreKind,
|
|
name: string,
|
|
version: string,
|
|
sourcePath: string,
|
|
vettedBy: string,
|
|
notes: string | undefined,
|
|
paths: StorePaths = getDefaultStorePaths(),
|
|
options: { reclaim?: boolean } = {},
|
|
): StoreAddResult {
|
|
validateStoreKind(kind);
|
|
validateStoreName(name);
|
|
validateStoreVersion(version);
|
|
validateVettedBy(vettedBy);
|
|
assertNoSymlinkAncestors(paths.userRoot);
|
|
|
|
const resolvedSource = resolve(sourcePath);
|
|
const source = lstatIfPresent(resolvedSource);
|
|
if (!source) {
|
|
throw new StoreError('STORE_SOURCE_MISSING', `Source path does not exist: ${resolvedSource}`);
|
|
}
|
|
if (source.isSymbolicLink()) {
|
|
throw new StoreError(
|
|
'STORE_SOURCE_SYMLINK',
|
|
`Refusing to vet a symlink as store content: ${resolvedSource} (points at ${resolve(sourcePath)}). Add the real directory.`,
|
|
);
|
|
}
|
|
if (!source.isDirectory()) {
|
|
throw new StoreError(
|
|
'STORE_SOURCE_NOT_DIR',
|
|
`Source path is not a directory: ${resolvedSource}`,
|
|
);
|
|
}
|
|
if (isInsideRoot(resolvedSource, paths.userRoot)) {
|
|
throw new StoreError(
|
|
'STORE_SOURCE_INSIDE_STORE',
|
|
`Refusing to add store content from inside the store itself: ${resolvedSource}`,
|
|
);
|
|
}
|
|
assertSourceTreeHasNoSymlinks(resolvedSource);
|
|
|
|
const target = entryDir(kind, name, version, paths);
|
|
const existing = lstatIfPresent(target);
|
|
let status: StoreAddStatus = 'added';
|
|
if (existing) {
|
|
if (existsSync(join(target, STORE_ENTRY_MARKER))) {
|
|
throw new StoreError(
|
|
'STORE_ALREADY_PRESENT',
|
|
`${kind} "${name}" version "${version}" is already present at ${target}; stores are append-only — add a new version instead.`,
|
|
);
|
|
}
|
|
// Unmarked target: either this tool's own debris from an interrupted add,
|
|
// or content the operator placed by hand — indistinguishable on disk. The
|
|
// USER root's contract is that tooling never destroys operator content,
|
|
// so deletion requires the explicit --reclaim opt-in (review finding on
|
|
// c23a71d7: silent rmSync under a benign-sounding status).
|
|
if (!options.reclaim) {
|
|
throw new StoreError(
|
|
'STORE_TARGET_UNMARKED',
|
|
`Target exists without ${STORE_ENTRY_MARKER}: ${target}. Refusing to delete unmarked content — if this is debris from an interrupted add, re-run with --reclaim to replace it.`,
|
|
);
|
|
}
|
|
rmSync(target, { recursive: true, force: true });
|
|
status = 'reclaimed-unmarked';
|
|
}
|
|
|
|
mkdirSync(target, { recursive: true });
|
|
cpSync(resolvedSource, target, { recursive: true });
|
|
|
|
const meta: StoreEntryMeta = {
|
|
schema: 1,
|
|
kind,
|
|
name,
|
|
version,
|
|
sourcePath: resolvedSource,
|
|
vettedBy: vettedBy.trim(),
|
|
vettedAt: new Date().toISOString(),
|
|
...(notes === undefined ? {} : { notes }),
|
|
};
|
|
writeFileSync(join(target, STORE_ENTRY_MARKER), `${JSON.stringify(meta, null, 2)}\n`);
|
|
|
|
return { kind, name, version, status, entryPath: target, sourcePath: resolvedSource };
|
|
}
|
|
|
|
function readEntryMeta(markerPath: string): { meta?: StoreEntryMeta; status: StoreEntryStatus } {
|
|
let raw: string;
|
|
try {
|
|
raw = readFileSync(markerPath, 'utf-8');
|
|
} catch {
|
|
return { status: 'invalid-metadata' };
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(raw) as StoreEntryMeta;
|
|
if (
|
|
parsed?.schema === 1 &&
|
|
(STORE_KINDS as readonly string[]).includes(parsed.kind) &&
|
|
typeof parsed.name === 'string' &&
|
|
typeof parsed.version === 'string' &&
|
|
typeof parsed.vettedBy === 'string' &&
|
|
typeof parsed.vettedAt === 'string'
|
|
) {
|
|
return { meta: parsed, status: 'vetted' };
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return { status: 'invalid-metadata' };
|
|
}
|
|
|
|
/**
|
|
* Enumerate every store entry deterministically (kind, then name, then
|
|
* version). Version directories without a marker list as `incomplete`; files
|
|
* where directories were expected list as `foreign` — surfaced, never mutated.
|
|
*/
|
|
export function listStoreEntries(
|
|
paths: StorePaths = getDefaultStorePaths(),
|
|
filter: { kind?: StoreKind; name?: string } = {},
|
|
): StoreListEntry[] {
|
|
if (filter.name !== undefined) validateStoreName(filter.name);
|
|
assertNoSymlinkAncestors(paths.userRoot);
|
|
|
|
const kinds = filter.kind ? [filter.kind] : [...STORE_KINDS];
|
|
const entries: StoreListEntry[] = [];
|
|
|
|
for (const kind of kinds) {
|
|
const kindRoot = lstatIfPresent(storeKindDir(kind, paths));
|
|
if (!kindRoot) continue;
|
|
if (!kindRoot.isDirectory()) {
|
|
entries.push({
|
|
kind,
|
|
name: kindDirName(kind),
|
|
status: 'foreign',
|
|
entryPath: storeKindDir(kind, paths),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
for (const nameDirent of readdirSync(storeKindDir(kind, paths), {
|
|
withFileTypes: true,
|
|
}).sort(byName) as Dirent[]) {
|
|
if (filter.name !== undefined && nameDirent.name !== filter.name) continue;
|
|
const namePath = join(storeKindDir(kind, paths), nameDirent.name);
|
|
|
|
if (!nameDirent.isDirectory()) {
|
|
entries.push({ kind, name: nameDirent.name, status: 'foreign', entryPath: namePath });
|
|
continue;
|
|
}
|
|
|
|
const versionDirents = readdirSync(namePath, { withFileTypes: true }).sort(byName);
|
|
if (versionDirents.length === 0) {
|
|
entries.push({ kind, name: nameDirent.name, status: 'incomplete', entryPath: namePath });
|
|
continue;
|
|
}
|
|
for (const versionDirent of versionDirents) {
|
|
const versionPath = join(namePath, versionDirent.name);
|
|
if (!versionDirent.isDirectory()) {
|
|
entries.push({
|
|
kind,
|
|
name: nameDirent.name,
|
|
version: versionDirent.name,
|
|
status: 'foreign',
|
|
entryPath: versionPath,
|
|
});
|
|
continue;
|
|
}
|
|
const markerPath = join(versionPath, STORE_ENTRY_MARKER);
|
|
if (!existsSync(markerPath)) {
|
|
entries.push({
|
|
kind,
|
|
name: nameDirent.name,
|
|
version: versionDirent.name,
|
|
status: 'incomplete',
|
|
entryPath: versionPath,
|
|
});
|
|
continue;
|
|
}
|
|
const { meta, status } = readEntryMeta(markerPath);
|
|
entries.push({
|
|
kind,
|
|
name: nameDirent.name,
|
|
version: versionDirent.name,
|
|
status,
|
|
entryPath: versionPath,
|
|
...(meta === undefined ? {} : { meta }),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function byName(a: Dirent, b: Dirent): number {
|
|
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
}
|
|
|
|
function reportCommandError(error: unknown): void {
|
|
if (error instanceof StoreError) {
|
|
console.error(`store: ${error.code}: ${error.message}`);
|
|
} else {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
}
|
|
process.exitCode = 1;
|
|
}
|
|
|
|
function displayStoreName(name: string): string {
|
|
return SAFE_STORE_NAME.test(name) ? name : JSON.stringify(name);
|
|
}
|
|
|
|
/** Register the `mosaic store` command group (W-F4). */
|
|
export function registerStoreCommand(
|
|
program: Command,
|
|
paths: StorePaths = getDefaultStorePaths(),
|
|
): void {
|
|
const store = program
|
|
.command('store')
|
|
.description('Manage the vetted user store under ~/.mosaic (plugins, skills)')
|
|
.configureHelp({ sortSubcommands: true });
|
|
|
|
store
|
|
.command('add <kind> <name> <version>')
|
|
.description(
|
|
'Vet and add a local plugin/skill directory to the user store (versioned, append-only)',
|
|
)
|
|
.requiredOption('--from <path>', 'Local source directory to vet (no network acquisition)')
|
|
.requiredOption('--by <operator>', 'Name of the operator vouching for this content')
|
|
.option('--notes <notes>', 'Vetting notes recorded in the entry metadata')
|
|
.option(
|
|
'--reclaim',
|
|
'Replace an existing UNMARKED target directory (e.g. debris from an interrupted add); refuses without this flag',
|
|
)
|
|
.action(
|
|
async (
|
|
kind: string,
|
|
name: string,
|
|
version: string,
|
|
opts: {
|
|
from: string;
|
|
by: string;
|
|
notes?: string;
|
|
reclaim: boolean;
|
|
},
|
|
) => {
|
|
try {
|
|
const result = addStoreEntry(
|
|
kind as StoreKind,
|
|
name,
|
|
version,
|
|
opts.from,
|
|
opts.by,
|
|
opts.notes,
|
|
paths,
|
|
{ reclaim: opts.reclaim },
|
|
);
|
|
const suffix =
|
|
result.status === 'reclaimed-unmarked' ? ' (replaced unmarked directory)' : '';
|
|
console.log(
|
|
`${result.kind} ${displayStoreName(result.name)} ${result.version}: added${suffix}`,
|
|
);
|
|
console.log(` entry: ${result.entryPath}`);
|
|
console.log(` vetted by ${opts.by.trim()}`);
|
|
} catch (error: unknown) {
|
|
reportCommandError(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
store
|
|
.command('list')
|
|
.description('List store entries with vetting status')
|
|
.option('--kind <kind>', 'Filter by kind (plugin | skill)')
|
|
.option('--name <name>', 'Filter by entry name')
|
|
.action((opts: { kind?: string; name?: string }) => {
|
|
try {
|
|
let kind: StoreKind | undefined;
|
|
if (opts.kind !== undefined) {
|
|
validateStoreKind(opts.kind);
|
|
kind = opts.kind;
|
|
}
|
|
const entries = listStoreEntries(paths, {
|
|
...(kind === undefined ? {} : { kind }),
|
|
...(opts.name === undefined ? {} : { name: opts.name }),
|
|
});
|
|
if (entries.length === 0) {
|
|
console.log('No store entries found.');
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
const version = entry.version ?? '-';
|
|
const vetter = entry.meta?.vettedBy ?? '-';
|
|
console.log(
|
|
`${entry.status.padEnd(17)}${entry.kind.padEnd(8)}${displayStoreName(entry.name).padEnd(24)}${version.padEnd(16)}${vetter}`,
|
|
);
|
|
}
|
|
} catch (error: unknown) {
|
|
reportCommandError(error);
|
|
}
|
|
});
|
|
}
|