feat(mosaic): manifest-owned upgrade guard so updates never wipe operator config (#791) (#802)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

This commit was merged in pull request #802.
This commit is contained in:
2026-07-16 23:01:26 +00:00
parent 8536454257
commit 32a0ffba13
18 changed files with 2711 additions and 124 deletions

View File

@@ -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');

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