All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Invert the framework updater from a denylist ("framework owns everything unless
preserved") to an explicit allow-list manifest ("operator owns everything unless
framework"). A path the manifest never anticipated resolves to operator-owned by
the fail-safe default, so it is structurally unreachable by any write or prune.
Root cause (#791): `mosaic update` re-seeds via `install.sh` keep-mode, whose
`rsync -a --delete` + hand-maintained PRESERVE_PATHS denylist wiped operator
paths the denylist forgot (agents/*.conf, policy/*.md, *.local.md, harvester
SOP, tools/_lib/credentials.json, unanticipated fleet files).
- framework-manifest.txt: single SSOT ([framework]/[operator], deny-wins,
UNKNOWN=>operator fail-safe), read by BOTH installers.
- src/framework/manifest.ts: pure resolver (parse/matchGlob/resolveOwnership/
frameworkSubtreeRoots/planPrune) — the testable seam.
- tools/_lib/manifest.sh: bash resolver (compiled globs, fork-free hot path),
sourced by install.sh; parity-tested against the TS resolver.
- install.sh keep mode is now manifest-driven (no --delete): overlay-copy
framework files, scoped-prune only retired framework files inside shipped
subtrees. Operator + unknown paths are never written or deleted.
- file-ops.syncDirectory gains an isOperatorOwned guard; file-adapter derives it
from the shared manifest, replacing the drifted hardcoded preservePaths.
Tests (TDD, red->green):
- HARD GATE test-upgrade-manifest-guard.sh: 10 operator sentinels (incl. an
unanticipated one) survive a keep-mode reseed byte-identical + mtime-unchanged;
retired framework file pruned; secret value absent from output. RED 31 fail on
the old installer -> GREEN 48 pass. Wired merge-blocking into CI.
- manifest-parity.spec.ts (§6.1): bash<->TS agree on 34 paths + subtree roots.
- manifest.spec.ts: 18 tests incl. planPrune property test + shipped-tree
completeness (§6.2).
- test-install-migration.sh F6 flipped: an unanticipated operator fleet file now
MUST survive keep-mode reseed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
189 lines
6.6 KiB
TypeScript
189 lines
6.6 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
|
|
/**
|
|
* Framework path-ownership manifest (#791).
|
|
*
|
|
* The updater must operate from an explicit framework-owned path manifest and
|
|
* NEVER write outside it. This module is the TypeScript reader for the shared
|
|
* SSOT manifest (`packages/mosaic/framework/framework-manifest.txt`) that the
|
|
* bash installer also consumes. Keeping both paths on one data file is what
|
|
* closes the two-copies-drift failure class (see #631 → #791).
|
|
*
|
|
* Everything here is pure (parse + resolve + plan) so the ownership guarantee
|
|
* is unit- and property-testable without touching the filesystem.
|
|
*/
|
|
|
|
export type Ownership = 'framework' | 'operator';
|
|
|
|
export interface FrameworkManifest {
|
|
/** Globs the updater MAY create/overwrite, and prune only when retired. */
|
|
readonly framework: readonly string[];
|
|
/** Globs the updater must NEVER write over or prune. Win over `framework`. */
|
|
readonly operator: readonly string[];
|
|
}
|
|
|
|
type Section = 'framework' | 'operator' | null;
|
|
|
|
/**
|
|
* Parse the line-oriented manifest text. `#` comments and blank lines are
|
|
* ignored; `[framework]` / `[operator]` headers switch the active section.
|
|
* Lines before any header are rejected — the format must be explicit.
|
|
*/
|
|
export function parseManifest(text: string): FrameworkManifest {
|
|
const framework: string[] = [];
|
|
const operator: string[] = [];
|
|
let section: Section = null;
|
|
|
|
const lines = text.split(/\r?\n/);
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const raw = lines[i] ?? '';
|
|
const line = raw.trim();
|
|
if (line === '' || line.startsWith('#')) continue;
|
|
|
|
if (line === '[framework]') {
|
|
section = 'framework';
|
|
continue;
|
|
}
|
|
if (line === '[operator]') {
|
|
section = 'operator';
|
|
continue;
|
|
}
|
|
if (line.startsWith('[')) {
|
|
throw new Error(`Unknown manifest section header on line ${i + 1}: ${line}`);
|
|
}
|
|
|
|
if (section === null) {
|
|
throw new Error(`Manifest entry before any [section] header on line ${i + 1}: ${line}`);
|
|
}
|
|
(section === 'framework' ? framework : operator).push(line);
|
|
}
|
|
|
|
return { framework, operator };
|
|
}
|
|
|
|
/** Read and parse the manifest from a framework root directory. */
|
|
export function loadManifest(frameworkRoot: string): FrameworkManifest {
|
|
const text = readFileSync(`${frameworkRoot}/framework-manifest.txt`, 'utf-8');
|
|
return parseManifest(text);
|
|
}
|
|
|
|
/**
|
|
* Match a mosaic-home-relative POSIX path against one glob.
|
|
*
|
|
* Supported: `**` (any depth, including zero segments) and `*` (any run of
|
|
* characters within a single segment, not crossing `/`). A glob with no
|
|
* wildcard matches either the exact path OR any path beneath it (so a bare
|
|
* directory entry like `memory` covers `memory/notes.md`).
|
|
*/
|
|
export function matchGlob(glob: string, relPath: string): boolean {
|
|
const path = normalizeRel(relPath);
|
|
const pattern = normalizeRel(glob);
|
|
if (pattern === '') return false;
|
|
|
|
if (!pattern.includes('*')) {
|
|
// Exact file, or any descendant of a bare directory prefix.
|
|
return path === pattern || path.startsWith(`${pattern}/`);
|
|
}
|
|
|
|
const re = new RegExp(`^${globToRegExpBody(pattern)}$`);
|
|
return re.test(path);
|
|
}
|
|
|
|
/** True if the path matches any glob in the list. */
|
|
export function matchesAny(globs: readonly string[], relPath: string): boolean {
|
|
return globs.some((g) => matchGlob(g, relPath));
|
|
}
|
|
|
|
/**
|
|
* Resolve ownership of a mosaic-home-relative path (deny-wins / fail-safe):
|
|
* operator globs win, then framework globs, else operator by default.
|
|
*/
|
|
export function resolveOwnership(manifest: FrameworkManifest, relPath: string): Ownership {
|
|
if (matchesAny(manifest.operator, relPath)) return 'operator';
|
|
if (matchesAny(manifest.framework, relPath)) return 'framework';
|
|
return 'operator';
|
|
}
|
|
|
|
/**
|
|
* The set of `[framework]` subtree roots that pruning is allowed to descend
|
|
* into (glob entries of the form `dir/**`). Single-file framework entries
|
|
* (e.g. `CONSTITUTION.md`) are reconcile-managed and never pruned.
|
|
*/
|
|
export function frameworkSubtreeRoots(manifest: FrameworkManifest): string[] {
|
|
const roots: string[] = [];
|
|
for (const g of manifest.framework) {
|
|
if (g.endsWith('/**')) roots.push(g.slice(0, -3));
|
|
}
|
|
return roots;
|
|
}
|
|
|
|
export interface PrunePlanInput {
|
|
readonly manifest: FrameworkManifest;
|
|
/** Mosaic-home-relative paths currently present in the target. */
|
|
readonly targetPaths: readonly string[];
|
|
/** Mosaic-home-relative paths the framework currently ships (source). */
|
|
readonly sourcePaths: readonly string[];
|
|
}
|
|
|
|
/**
|
|
* Pure prune planner — the testable seam of the #791 fix.
|
|
*
|
|
* Returns the delete-set: target paths that are framework-owned, live inside a
|
|
* shipped framework subtree, and are absent from the current source (retired
|
|
* framework files). By construction the result never contains an operator-owned
|
|
* or unknown path: those either resolve to `operator` or fall outside every
|
|
* framework subtree root, so they are structurally unreachable by pruning.
|
|
*/
|
|
export function planPrune(input: PrunePlanInput): string[] {
|
|
const { manifest, targetPaths, sourcePaths } = input;
|
|
const source = new Set(sourcePaths.map(normalizeRel));
|
|
const roots = frameworkSubtreeRoots(manifest);
|
|
|
|
const deleteSet: string[] = [];
|
|
for (const raw of targetPaths) {
|
|
const path = normalizeRel(raw);
|
|
if (source.has(path)) continue; // still shipped — keep
|
|
if (resolveOwnership(manifest, path) !== 'framework') continue; // operator/unknown — never prune
|
|
if (!roots.some((root) => path === root || path.startsWith(`${root}/`))) continue; // outside shipped subtrees
|
|
deleteSet.push(path);
|
|
}
|
|
return deleteSet;
|
|
}
|
|
|
|
function normalizeRel(p: string): string {
|
|
return p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, '');
|
|
}
|
|
|
|
/** Translate a glob body (already normalized) into a RegExp source fragment. */
|
|
function globToRegExpBody(pattern: string): string {
|
|
let out = '';
|
|
for (let i = 0; i < pattern.length; i++) {
|
|
const c = pattern[i];
|
|
if (c === undefined) continue;
|
|
if (c === '*') {
|
|
if (pattern[i + 1] === '*') {
|
|
// `**` — any depth. `a/**` must also match the bare root `a`, so when a
|
|
// literal `/` was just emitted, make it optional along with the rest.
|
|
i++;
|
|
let trailingSlash = false;
|
|
if (pattern[i + 1] === '/') {
|
|
i++;
|
|
trailingSlash = true;
|
|
}
|
|
if (out.endsWith('/')) {
|
|
out = `${out.slice(0, -1)}(?:/.*)?`;
|
|
} else if (trailingSlash) {
|
|
out += '(?:.*/)?';
|
|
} else {
|
|
out += '.*';
|
|
}
|
|
} else {
|
|
out += '[^/]*';
|
|
}
|
|
} else {
|
|
out += c.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
}
|
|
return out;
|
|
}
|