feat(mosaic): manifest-owned upgrade guard so updates never wipe operator config (#791)

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 <[email protected]>
This commit is contained in:
Hermes Agent
2026-07-16 15:47:28 -05:00
co-authored by Claude Opus 4.8
parent 87e21fd933
commit 34e55d4a2e
15 changed files with 1160 additions and 142 deletions
@@ -1,9 +1,22 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
import {
mkdtempSync,
mkdirSync,
writeFileSync,
rmSync,
readFileSync,
existsSync,
copyFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { FileConfigAdapter, DEFAULT_SEED_FILES } from './file-adapter.js';
// The real shipping manifest — the fixture uses it verbatim so these tests
// exercise production ownership resolution, not a synthetic copy (#791).
const REAL_FRAMEWORK_ROOT = fileURLToPath(new URL('../../framework', import.meta.url));
/**
* Regression tests for the `FileConfigAdapter.syncFramework` seed behavior.
*
@@ -34,6 +47,13 @@ function makeFixture(): { sourceDir: string; mosaicHome: string; defaultsDir: st
mkdirSync(defaultsDir, { recursive: true });
mkdirSync(mosaicHome, { recursive: true });
// #791: syncFramework resolves ownership from the shared manifest under the
// source dir. Seed the real one so keep-mode syncs behave as in production.
copyFileSync(
join(REAL_FRAMEWORK_ROOT, 'framework-manifest.txt'),
join(sourceDir, 'framework-manifest.txt'),
);
// Framework-contract defaults we expect the wizard to seed.
writeFileSync(join(defaultsDir, 'CONSTITUTION.md'), '# CONSTITUTION default\n');
writeFileSync(join(defaultsDir, 'AGENTS.md'), '# AGENTS default\n');
@@ -102,9 +122,10 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
});
it('overwrites framework-owned files (backup-once) but preserves user-seeded files', async () => {
// Plant a root-level AGENTS.md in sourceDir so syncDirectory's preserve is exercised.
writeFileSync(join(fixture.sourceDir, 'AGENTS.md'), '# shipped AGENTS from source root\n');
// Contract files (CONSTITUTION/AGENTS/STANDARDS) ship only under defaults/ —
// reconcile_framework_files is their sole writer (backup-once). The bulk
// sync never sees a root-level copy, so a user's edited root file is backed
// up, not silently clobbered, on upgrade.
writeFileSync(join(fixture.mosaicHome, 'TOOLS.md'), '# user-customized TOOLS\n');
writeFileSync(join(fixture.mosaicHome, 'AGENTS.md'), '# user-customized AGENTS\n');
+14 -29
View File
@@ -34,6 +34,7 @@ import {
buildToolsTemplateVars,
} from '../template/builders.js';
import { atomicWrite, backupFile, syncDirectory } from '../platform/file-ops.js';
import { loadManifest, resolveOwnership } from '../framework/manifest.js';
/**
* Parse a SoulConfig from an existing SOUL.md file.
@@ -155,38 +156,22 @@ export class FileConfigAdapter implements ConfigService {
}
async syncFramework(action: InstallAction): Promise<void> {
// Must match PRESERVE_PATHS in packages/mosaic/framework/install.sh so
// the bash and TS install paths have the same upgrade-preservation
// semantics. Contract files (AGENTS.md, STANDARDS.md, TOOLS.md) are
// seeded from defaults/ on first install and preserved thereafter;
// identity files (SOUL.md, USER.md) are generated by wizard stages and
// must never be touched by the framework sync.
const preservePaths =
action === 'keep' || action === 'reconfigure'
? [
'CONSTITUTION.md',
'AGENTS.md',
'SOUL.md',
'USER.md',
'TOOLS.md',
'STANDARDS.md',
'memory',
'sources',
'credentials',
// User-authored fleet data MUST survive `mosaic update`'s re-seed.
// The framework seeds only fleet/examples + fleet/roles +
// fleet/roster.schema.json; the operator's roster, per-agent env, and
// heartbeat run dir stay user-owned. (Mirror of install.sh PRESERVE_PATHS.)
'fleet/roster.yaml',
'fleet/roster.json',
'fleet/agents',
'fleet/run',
]
: [];
// #791: ownership is derived from the shared framework manifest
// (packages/mosaic/framework/framework-manifest.txt) — the SAME file the
// bash installer reads — so the TS and bash paths can never drift. On an
// upgrade (keep/reconfigure) the sync must NEVER write an operator-owned
// path: every operator file, and any path the manifest never anticipated
// (which resolves to operator by the fail-safe default), is left untouched.
// A fresh install ('overwrite'/'reconfigure' onto an empty home) seeds the
// full tree, so the guard applies only when preserving an existing home.
const guardOwnership = action === 'keep' || action === 'reconfigure';
const manifest = guardOwnership ? loadManifest(this.sourceDir) : undefined;
syncDirectory(this.sourceDir, this.mosaicHome, {
preserve: preservePaths,
excludeGit: true,
isOperatorOwned: manifest
? (relPath) => resolveOwnership(manifest, relPath) === 'operator'
: undefined,
});
// Reconcile framework-contract files from framework/defaults/ into the mosaic
@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest';
import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadManifest, resolveOwnership, frameworkSubtreeRoots } from './manifest.js';
/**
* Bash ↔ TS parity (#791, §6.1).
*
* The installer (bash) and the config adapter (TS) each resolve path ownership
* from framework-manifest.txt. If the two resolvers disagreed on a single path,
* an upgrade could protect a file on one code path and wipe it on the other —
* exactly the two-copies drift that #631 patched by hand. This test drives the
* bash resolver (`tools/_lib/manifest.sh`) as a subprocess and asserts it agrees
* with the TS resolver for a broad set of paths spanning every ownership class.
*/
const FRAMEWORK_ROOT = fileURLToPath(new URL('../../framework', import.meta.url));
const MANIFEST_SH = join(FRAMEWORK_ROOT, 'tools', '_lib', 'manifest.sh');
const hasBash = (() => {
try {
execFileSync('bash', ['-c', 'true'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
})();
function bashResolve(relPath: string): string {
return execFileSync('bash', [MANIFEST_SH, 'resolve', relPath], {
encoding: 'utf-8',
}).trim();
}
function bashSubtreeRoots(): string[] {
return execFileSync('bash', [MANIFEST_SH, 'subtree-roots'], { encoding: 'utf-8' })
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
// Paths spanning every ownership class: framework single-files, framework
// subtrees, operator declared trees, operator carve-out inside a framework
// subtree, local overlays, and deliberately UNANTICIPATED paths (fail-safe).
const PROBE_PATHS = [
'CONSTITUTION.md',
'AGENTS.md',
'STANDARDS.md',
'install.sh',
'framework-manifest.txt',
'guides/E2E-DELIVERY.md',
'tools/git/pr-create.sh',
'tools/_lib/manifest.sh',
'defaults/SOUL.md',
'fleet/README.md',
'fleet/roles/coder.md',
'fleet/roster.schema.json',
'fleet/examples/general.yaml',
// operator
'SOUL.md',
'USER.md',
'TOOLS.md',
'SOUL.local.md',
'USER.local.md',
'STANDARDS.local.md',
'agents/coder0.conf',
'policy/custom.md',
'memory/note.md',
'sources/skills/x.md',
'credentials/c.json',
'tools/_lib/credentials.json',
'fleet/roster.yaml',
'fleet/roster.json',
'fleet/agents/coder0.env',
'fleet/run/coder0.hb',
'fleet/backlog/data.db',
'fleet/roles.local/custom.md',
// unanticipated → operator (fail-safe)
'harvester/sop.md',
'unknown-operator-dir/x',
'fleet/my-fleet.yaml',
'random-root-file.md',
'tools/some-new-framework-tool.sh',
];
describe.skipIf(!hasBash)('bash ↔ TS manifest parity (§6.1)', () => {
it('the bash resolver CLI exists and is executable', () => {
expect(existsSync(MANIFEST_SH)).toBe(true);
});
it('bash and TS resolve identical ownership for every probe path', () => {
const manifest = loadManifest(FRAMEWORK_ROOT);
const disagreements: Array<{ path: string; ts: string; bash: string }> = [];
for (const p of PROBE_PATHS) {
const ts = resolveOwnership(manifest, p);
const bash = bashResolve(p);
if (ts !== bash) disagreements.push({ path: p, ts, bash });
}
expect(disagreements).toEqual([]);
});
it('bash and TS agree on the framework subtree roots', () => {
const manifest = loadManifest(FRAMEWORK_ROOT);
expect(bashSubtreeRoots().sort()).toEqual(frameworkSubtreeRoots(manifest).sort());
});
});
@@ -0,0 +1,223 @@
import { describe, it, expect } from 'vitest';
import { readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
parseManifest,
loadManifest,
matchGlob,
resolveOwnership,
frameworkSubtreeRoots,
planPrune,
type FrameworkManifest,
} from './manifest.js';
const FRAMEWORK_ROOT = fileURLToPath(new URL('../../framework', import.meta.url));
const SAMPLE = `
# comment
[framework]
CONSTITUTION.md
guides/**
tools/**
[operator]
SOUL.md
*.local.md
agents/**
tools/_lib/credentials.json
`;
describe('parseManifest', () => {
it('splits entries into framework and operator sections, ignoring comments/blanks', () => {
const m = parseManifest(SAMPLE);
expect(m.framework).toEqual(['CONSTITUTION.md', 'guides/**', 'tools/**']);
expect(m.operator).toEqual([
'SOUL.md',
'*.local.md',
'agents/**',
'tools/_lib/credentials.json',
]);
});
it('rejects an entry that appears before any section header', () => {
expect(() => parseManifest('stray.md\n[framework]\n')).toThrow(/before any \[section\]/);
});
it('rejects an unknown section header', () => {
expect(() => parseManifest('[bogus]\nx\n')).toThrow(/Unknown manifest section/);
});
});
describe('matchGlob', () => {
it('matches an exact file', () => {
expect(matchGlob('CONSTITUTION.md', 'CONSTITUTION.md')).toBe(true);
expect(matchGlob('CONSTITUTION.md', 'AGENTS.md')).toBe(false);
});
it('treats a bare directory entry as covering its descendants', () => {
expect(matchGlob('memory', 'memory')).toBe(true);
expect(matchGlob('memory', 'memory/notes.md')).toBe(true);
expect(matchGlob('memory', 'memoryfoo')).toBe(false);
});
it('** matches any depth including the root itself', () => {
expect(matchGlob('agents/**', 'agents')).toBe(true);
expect(matchGlob('agents/**', 'agents/a.conf')).toBe(true);
expect(matchGlob('agents/**', 'agents/nested/deep.conf')).toBe(true);
expect(matchGlob('agents/**', 'agentsX')).toBe(false);
});
it('* stays within a single segment', () => {
expect(matchGlob('*.local.md', 'SOUL.local.md')).toBe(true);
expect(matchGlob('*.local.md', 'a/SOUL.local.md')).toBe(false);
});
});
describe('resolveOwnership (deny-wins + fail-safe)', () => {
const m = parseManifest(SAMPLE);
it('operator globs win over framework globs (carve-out inside a framework subtree)', () => {
expect(resolveOwnership(m, 'tools/_lib/credentials.json')).toBe('operator');
expect(resolveOwnership(m, 'tools/git/pr-create.sh')).toBe('framework');
});
it('framework-declared paths resolve to framework', () => {
expect(resolveOwnership(m, 'guides/E2E-DELIVERY.md')).toBe('framework');
expect(resolveOwnership(m, 'CONSTITUTION.md')).toBe('framework');
});
it('UNKNOWN paths default to operator (the #791 root-cause guarantee)', () => {
expect(resolveOwnership(m, 'agents/coder0.conf')).toBe('operator'); // declared
expect(resolveOwnership(m, 'harvester/sop.md')).toBe('operator'); // undeclared → fail-safe
expect(resolveOwnership(m, 'totally-unknown-dir/x')).toBe('operator');
expect(resolveOwnership(m, 'random-root-file.md')).toBe('operator');
});
});
describe('planPrune (pure prune planner)', () => {
const m = parseManifest(SAMPLE);
it('prunes a retired framework file inside a shipped subtree', () => {
const del = planPrune({
manifest: m,
targetPaths: ['guides/OLD.md', 'guides/KEEP.md'],
sourcePaths: ['guides/KEEP.md'],
});
expect(del).toEqual(['guides/OLD.md']);
});
it('never prunes operator-reserved paths even when absent from source', () => {
const del = planPrune({
manifest: m,
targetPaths: ['agents/coder0.conf', 'tools/_lib/credentials.json', 'SOUL.local.md'],
sourcePaths: [],
});
expect(del).toEqual([]);
});
it('never prunes UNKNOWN paths outside every framework subtree (fail-safe)', () => {
const del = planPrune({
manifest: m,
targetPaths: ['harvester/sop.md', 'my-fleet.yaml', 'unknown-dir/deep/x'],
sourcePaths: [],
});
expect(del).toEqual([]);
});
it('never prunes single-file framework entries (reconcile-managed, not in subtree)', () => {
const del = planPrune({ manifest: m, targetPaths: ['CONSTITUTION.md'], sourcePaths: [] });
expect(del).toEqual([]);
});
it('property: delete-set ⊆ {framework-owned ∧ in-target ∧ not-in-source} and ∩ operator = ∅', () => {
const operatorish = [
'agents/a.conf',
'policy/p.md',
'SOUL.local.md',
'memory/m.md',
'tools/_lib/credentials.json',
'harvester/sop.md',
'unknown-top/x',
'another-unknown/deep/y.txt',
];
const frameworkish = ['guides/A.md', 'guides/sub/B.md', 'tools/git/x.sh'];
const targetPaths = [...operatorish, ...frameworkish];
const del = planPrune({ manifest: m, targetPaths, sourcePaths: [] });
for (const p of del) {
expect(resolveOwnership(m, p)).toBe('framework');
expect(targetPaths).toContain(p);
}
// No operator/unknown path ever appears in the delete-set.
for (const p of operatorish) expect(del).not.toContain(p);
});
});
describe('frameworkSubtreeRoots', () => {
it('returns only the /** subtree roots, not single-file entries', () => {
const m = parseManifest(SAMPLE);
expect(frameworkSubtreeRoots(m)).toEqual(['guides', 'tools']);
});
});
// ── SSOT manifest: shipped-file completeness (§6.2) ──────────────────────────
// A newly-shipped framework file must not silently fall outside the manifest —
// if it did, the updater could neither guarantee it as framework-owned nor
// prune it when retired. Every file the framework actually ships must resolve
// to `framework` (except the defaults/{SOUL,USER}.md identity seeds, which are
// operator-owned by design).
describe('manifest completeness against shipped framework tree', () => {
const manifest = loadManifest(FRAMEWORK_ROOT);
const IGNORED_TOP = new Set(['.git', 'node_modules']);
// Framework-shipped files that are operator-owned by design: the identity
// seeds under defaults/, and the `.gitkeep` placeholder that lets the empty
// operator-owned memory/ directory exist in git.
function isOperatorShipped(rel: string): boolean {
if (rel === 'defaults/SOUL.md' || rel === 'defaults/USER.md') return true;
if (rel.startsWith('memory/')) return true;
return false;
}
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const abs = join(dir, entry);
const rel = relative(FRAMEWORK_ROOT, abs);
if (IGNORED_TOP.has(rel)) continue;
if (statSync(abs).isDirectory()) out.push(...walk(abs));
else out.push(rel);
}
return out;
}
it('every shipped framework file resolves to framework ownership', () => {
const shipped = walk(FRAMEWORK_ROOT);
const misclassified = shipped.filter(
(p) => !isOperatorShipped(p) && resolveOwnership(manifest, p) !== 'framework',
);
expect(misclassified).toEqual([]);
});
it('the operator-owned surface from #791 resolves to operator', () => {
const operatorPaths = [
'agents/coder0.conf',
'fleet/agents/coder0.env',
'memory/note.md',
'policy/custom.md',
'SOUL.local.md',
'USER.local.md',
'STANDARDS.local.md',
'tools/_lib/credentials.json',
'fleet/roster.yaml',
'fleet/roster.json',
'fleet/run/coder0.hb',
'fleet/backlog/data.db',
'fleet/roles.local/custom.md',
];
for (const p of operatorPaths) {
expect(resolveOwnership(manifest, p), p).toBe('operator');
}
});
});
+188
View File
@@ -0,0 +1,188 @@
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;
}
+18 -2
View File
@@ -62,16 +62,28 @@ function rotateBackups(filePath: string): void {
/**
* Sync a source directory to a target, with optional preserve paths.
* Replaces the rsync/cp logic from install.sh.
*
* `isOperatorOwned` is the #791 ownership guard: when supplied, any source path
* it flags as operator-owned is never copied (the framework must never write an
* operator path). Callers derive it from the shared framework manifest so the TS
* and bash sync paths obey one source of truth. This copy is non-destructive —
* it never deletes a target file — so honoring the guard is sufficient to leave
* operator config untouched.
*/
export function syncDirectory(
source: string,
target: string,
options: { preserve?: string[]; excludeGit?: boolean } = {},
options: {
preserve?: string[];
excludeGit?: boolean;
isOperatorOwned?: (relPath: string) => boolean;
} = {},
): void {
// Guard: source and target are the same directory — nothing to sync
if (resolve(source) === resolve(target)) return;
const preserveSet = new Set(options.preserve ?? []);
const isOperatorOwned = options.isOperatorOwned ?? (() => false);
// Collect files from source
function copyRecursive(src: string, dest: string, relBase: string): void {
@@ -86,7 +98,7 @@ export function syncDirectory(
if (options.excludeGit && (dirName === '.git' || relPath.includes('/.git'))) return;
// Skip preserved paths at top level
if (preserveSet.has(relPath) && existsSync(dest)) return;
if (relPath !== '' && preserveSet.has(relPath) && existsSync(dest)) return;
mkdirSync(dest, { recursive: true });
for (const entry of readdirSync(src)) {
@@ -101,6 +113,10 @@ export function syncDirectory(
// Skip preserved files at top level
if (preserveSet.has(relPath) && existsSync(dest)) return;
// #791: never write an operator-owned path (the framework owns only its
// own files; unknown paths resolve to operator and are skipped too).
if (isOperatorOwned(relPath)) return;
mkdirSync(dirname(dest), { recursive: true });
copyFileSync(src, dest);
}
@@ -488,9 +488,13 @@ export function getInstallAllCommand(outdated: PackageUpdateResult[]): string {
// `mosaic update` installs the new npm CLI but, on its own, leaves the framework
// files in ~/.config/mosaic/ stale — so shipped launcher/runtime changes (e.g.
// the agent-name export + native heartbeat) never ACTIVATE until a re-seed.
// These helpers run the package's own install.sh in sync-only mode (the P4
// data-safe reconcile: framework-owned overwrite + backup-once; SOUL/USER/
// *.local/credentials preserved) and, opt-in, relaunch durable agents.
// These helpers run the package's own install.sh in sync-only mode. The re-seed
// is manifest-driven (#791): keep mode writes ONLY framework-owned paths from the
// shared framework-manifest.txt and prunes only retired framework files inside
// shipped subtrees — every operator path (SOUL/USER/*.local/credentials, fleet
// roster + agents + backlog, and anything the manifest never anticipated) is
// left byte-identical. Contract files are still reconciled (overwrite +
// backup-once). Opt-in, this also relaunches durable agents.
/** Resolve the framework/ directory bundled in the installed package. */
export function resolveBundledFrameworkRoot(): string {