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

@@ -0,0 +1,343 @@
import { afterAll, describe, it, expect } from 'vitest';
import { execFileSync, spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
loadManifest,
parseManifest,
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();
}
/** Drive the bash resolver against an arbitrary manifest file (MANIFEST_FILE override). */
function bashResolveWith(manifestFile: string, relPath: string): string {
return execFileSync('bash', [MANIFEST_SH, 'resolve', relPath], {
encoding: 'utf-8',
env: { ...process.env, MANIFEST_FILE: manifestFile },
}).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);
}
/**
* Drive the bash resolver CLI against a manifest file and report how it exited.
* A fail-closed manifest must make the CLI exit non-zero with a message on
* stderr — never exit 0 having silently resolved everything to operator.
*/
function bashCli(manifestFile: string): { status: number; stderr: string } {
const res = spawnSync('bash', [MANIFEST_SH, 'resolve', 'CONSTITUTION.md'], {
encoding: 'utf-8',
env: { ...process.env, MANIFEST_FILE: manifestFile },
});
return { status: res.status ?? -1, stderr: res.stderr ?? '' };
}
// 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',
// #797 Runtime Session Ledger — must resolve operator on both paths.
'fleet/run/sessions/events.ndjson',
'fleet/run/sessions/ledger.json',
'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());
});
});
/**
* Format-safety parity (#791, Decision 1 — the `.txt` line-oriented format is
* accepted only because both resolvers agree on the format edge cases a hand-
* edited text file invites: comments, blank lines, stray whitespace, duplicate
* and overlapping globs (where deny-wins must resolve), and section ordering.
* Each fixture is driven through BOTH resolvers (bash via MANIFEST_FILE, TS via
* parseManifest) and must agree AND land on the expected ownership. Any
* divergence here means the format itself is unsafe and must be fixed/converted.
*/
describe.skipIf(!hasBash)('bash ↔ TS manifest format-edge parity (§6.1, Decision 1)', () => {
const tmp = mkdtempSync(join(tmpdir(), 'mf-parity-'));
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
let fixtureSeq = 0;
function writeFixture(text: string): string {
const file = join(tmp, `manifest-${fixtureSeq++}.txt`);
writeFileSync(file, text);
return file;
}
// Both resolvers must agree, and on the expected value, for every probe.
function expectParity(text: string, cases: ReadonlyArray<readonly [string, string]>): void {
const file = writeFixture(text);
const manifest = parseManifest(text);
for (const [path, expected] of cases) {
const ts = resolveOwnership(manifest, path);
const bash = bashResolveWith(file, path);
expect(bash, `bash disagrees with TS on ${path}`).toBe(ts);
expect(ts, `ownership of ${path}`).toBe(expected);
}
}
it('tolerates comments, blank lines, and leading/trailing whitespace identically', () => {
// Entries and headers are padded with spaces/tabs; comments and blanks are
// interleaved. Both resolvers must trim and ignore them the same way.
const text = [
'# leading comment',
' ',
'\t[framework] ',
' tools/** ',
'# mid-section comment',
'',
'\tguides/**\t',
' [operator] ',
'\ttools/_lib/credentials.json ',
'*.local.md',
'',
].join('\n');
expectParity(text, [
['tools/git/pr-create.sh', 'framework'],
['guides/E2E-DELIVERY.md', 'framework'],
['tools/_lib/credentials.json', 'operator'], // deny-wins carve-out inside tools/**
['SOUL.local.md', 'operator'],
['nowhere/unknown.md', 'operator'], // negative probe: matches NO rule → operator
]);
});
it('resolves deny-wins for overlapping and duplicate globs identically', () => {
// Framework claims tools/** (twice) and the overlapping tools/git/**;
// operator carves out tools/_lib/**. Operator must win the overlap on both.
const text = [
'[framework]',
'tools/**',
'tools/**', // duplicate — must not change resolution
'tools/git/**', // overlaps tools/**
'[operator]',
'tools/_lib/**',
].join('\n');
expectParity(text, [
['tools/git/pr-create.sh', 'framework'],
['tools/other.sh', 'framework'],
['tools/_lib/credentials.json', 'operator'], // deny-wins over both framework globs
['tools/_lib/nested/deep.json', 'operator'],
]);
});
it('is independent of section and glob ordering', () => {
// Same rule set, operator section first and entries reordered. Resolution
// must be identical because deny-wins checks all operator globs before any
// framework glob — order within or between sections cannot matter.
const forward = [
'[framework]',
'guides/**',
'tools/**',
'[operator]',
'tools/_lib/credentials.json',
'*.local.md',
].join('\n');
const reversed = [
'[operator]',
'*.local.md',
'tools/_lib/credentials.json',
'[framework]',
'tools/**',
'guides/**',
].join('\n');
const probes: ReadonlyArray<readonly [string, string]> = [
['tools/git/pr-create.sh', 'framework'],
['guides/E2E-DELIVERY.md', 'framework'],
['tools/_lib/credentials.json', 'operator'],
['SOUL.local.md', 'operator'],
['unanticipated/path.md', 'operator'],
];
expectParity(forward, probes);
expectParity(reversed, probes);
// And the two orderings agree path-for-path on both resolvers.
const fFile = writeFixture(forward);
const rFile = writeFixture(reversed);
for (const [path] of probes) {
expect(bashResolveWith(fFile, path)).toBe(bashResolveWith(rFile, path));
}
});
it('defaults an unmatched path to operator on both resolvers (UNKNOWN → operator)', () => {
// A manifest that names only a narrow framework slice. Everything else —
// including paths under no rule at all — must fail safe to operator.
const text = ['[framework]', 'guides/**', '[operator]', 'agents/**'].join('\n');
expectParity(text, [
['guides/x.md', 'framework'],
['agents/coder0.conf', 'operator'],
['totally/unlisted/file.txt', 'operator'], // negative probe
['fleet/run/sessions/ledger.json', 'operator'], // unlisted → operator
['README.md', 'operator'],
]);
});
});
/**
* Failure-mode parity (#791 B2/B3). A bad manifest is the dangerous case: if the
* two resolvers DISAGREED on rejection — one throwing while the other quietly
* resolved everything to operator — an upgrade could fail loud on one code path
* and no-op on the other. So for every malformed/empty/missing manifest, BOTH
* must reject: TS throws, and the bash CLI exits non-zero with a stderr message.
*/
describe.skipIf(!hasBash)('bash ↔ TS manifest failure-mode parity (§6.1, B2/B3)', () => {
const tmp = mkdtempSync(join(tmpdir(), 'mf-failmode-'));
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
let seq = 0;
function writeFixture(text: string): string {
const file = join(tmp, `bad-manifest-${seq++}.txt`);
writeFileSync(file, text);
return file;
}
// TS throws AND bash CLI exits non-zero with a non-empty stderr — identical rejection.
function expectBothReject(label: string, manifestFile: string): void {
expect(() => parseManifestFile(manifestFile), `TS accepted ${label}`).toThrow();
const cli = bashCli(manifestFile);
expect(cli.status, `bash did not exit non-zero for ${label}`).not.toBe(0);
expect(cli.stderr.trim().length, `bash was silent for ${label}`).toBeGreaterThan(0);
}
// Read the file for the TS side the same way loadManifest does, so both halves
// see identical bytes (loadManifest keys off a directory, not an arbitrary file).
function parseManifestFile(file: string): void {
parseManifest(readFileSync(file, 'utf-8'));
}
it('both reject a completely empty manifest', () => {
expectBothReject('empty', writeFixture(''));
});
it('both reject a comment/blank-only manifest', () => {
expectBothReject('comment-only', writeFixture('# header only\n\n \n'));
});
it('both reject an operator-only manifest (zero framework paths)', () => {
expectBothReject('operator-only', writeFixture('[operator]\nSOUL.md\n*.local.md\n'));
});
it('both reject a [framework] section with no entries', () => {
expectBothReject('empty-framework-section', writeFixture('[framework]\n[operator]\nSOUL.md\n'));
});
it('both reject a [framework] entry that normalizes to an empty glob (/)', () => {
expectBothReject('root-slash-framework', writeFixture('[framework]\n/\n'));
});
it('both reject a [framework] entry that normalizes to nothing (./)', () => {
expectBothReject('dot-slash-framework', writeFixture('[framework]\n./\n[operator]\nSOUL.md\n'));
});
it('both reject [framework] entries that are only bare dot segments', () => {
expectBothReject('bare-dot-framework', writeFixture('[framework]\n.\n..\n'));
});
it('both reject an entry that appears before any section header', () => {
expectBothReject('entry-before-header', writeFixture('stray.md\n[framework]\nguides/**\n'));
});
it('both reject an unknown section header', () => {
expectBothReject('unknown-header', writeFixture('[bogus]\nx\n'));
});
it('both reject a missing manifest file (fail-closed, not empty result)', () => {
const missing = join(tmp, 'does-not-exist.txt');
// TS: loadManifest would throw a read error; here read-then-parse throws on read.
expect(() => parseManifestFile(missing)).toThrow();
const cli = bashCli(missing);
expect(cli.status).not.toBe(0);
expect(cli.stderr.trim().length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,327 @@
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,
ManifestError,
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/);
});
});
// Fail-closed parsing/loading (#791 B2/B3). An empty, comment-only, operator-only,
// or unreadable manifest must NOT resolve to "framework owns nothing" (which would
// make an upgrade a silent no-op). Both must throw so finalizeStage surfaces the
// abort instead of reporting "Installation complete". The bash reader rejects the
// same inputs — asserted for parity in manifest-parity.spec.ts.
describe('parseManifest / loadManifest fail closed on empty or unreadable input', () => {
it('throws on a completely empty manifest', () => {
expect(() => parseManifest('')).toThrow(/no \[framework\] paths/);
});
it('throws on a comment- and blank-only manifest (no entries at all)', () => {
expect(() => parseManifest('# just a header comment\n\n \n')).toThrow(
/no \[framework\] paths/,
);
});
it('throws when only an [operator] section is present (zero framework paths)', () => {
expect(() => parseManifest('[operator]\nSOUL.md\n*.local.md\n')).toThrow(
/no \[framework\] paths/,
);
});
it('throws on a [framework] header with no entries beneath it', () => {
expect(() => parseManifest('[framework]\n\n[operator]\nSOUL.md\n')).toThrow(
/no \[framework\] paths/,
);
});
// Degenerate framework entries that pass the length check but normalize to a
// glob matching nothing — the manifest would silently protect the whole tree
// as operator (#791 blocker-B). Both `/` and `./` normalize to '' ; `.`/`..`
// are bare-dot segments.
it.each([['/'], ['./'], ['.'], ['..'], ['/\n./']])(
'throws when the only [framework] entry (%j) normalizes to nothing usable',
(entry) => {
expect(() => parseManifest(`[framework]\n${entry}\n`)).toThrow(
/no usable \[framework\] paths/,
);
},
);
it('accepts a wildcard-only framework glob (** is usable)', () => {
expect(() => parseManifest('[framework]\n**\n')).not.toThrow();
});
it('loadManifest throws a clear fail-closed error when the manifest file is missing', () => {
const missingRoot = fileURLToPath(new URL('./__no_such_framework_root__', import.meta.url));
expect(() => loadManifest(missingRoot)).toThrow(/Cannot read framework manifest/);
});
// The distinct error type is what lets finalizeStage tell a pre-sync validation
// abort (nothing written) from a mid-sync filesystem failure (#791 blocker-C).
it('every fail-closed rejection is a ManifestError', () => {
expect(() => parseManifest('')).toThrow(ManifestError);
expect(() => parseManifest('[operator]\nSOUL.md\n')).toThrow(ManifestError);
expect(() => parseManifest('[framework]\n/\n')).toThrow(ManifestError);
expect(() => parseManifest('[bogus]\nx\n')).toThrow(ManifestError);
expect(() => parseManifest('stray.md\n[framework]\n')).toThrow(ManifestError);
const missingRoot = fileURLToPath(new URL('./__no_such_framework_root__', import.meta.url));
expect(() => loadManifest(missingRoot)).toThrow(ManifestError);
});
});
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);
});
});
// The #797 Runtime Session Ledger lives at fleet/run/sessions/. Today it is safe
// twice over: it matches the explicit `fleet/run/**` operator carve-out AND, even
// without it, the UNKNOWN→operator fail-safe. This test isolates the CARVE-OUT's
// load-bearing value by simulating a future framework author who broadens fleet
// ownership to `fleet/**`: without the operator carve-out the ledger would resolve
// framework and be pruned; deny-wins is what keeps it protected. If deleting the
// `fleet/run/**` line ever stops turning this test red, the carve-out has silently
// stopped mattering — which is exactly the #797 regression we are gating against.
describe('fleet/run/** carve-out is load-bearing for the #797 ledger (deny-wins)', () => {
const LEDGER = ['fleet/run/sessions/events.ndjson', 'fleet/run/sessions/ledger.json'];
// A framework that (hypothetically) ships all of fleet/** as a subtree.
const withoutCarveOut: FrameworkManifest = {
framework: ['fleet/**'],
operator: [],
};
const withCarveOut: FrameworkManifest = {
framework: ['fleet/**'],
operator: ['fleet/run/**'],
};
it('RED without the carve-out: the ledger resolves framework and is pruned', () => {
for (const p of LEDGER) expect(resolveOwnership(withoutCarveOut, p)).toBe('framework');
const del = planPrune({ manifest: withoutCarveOut, targetPaths: LEDGER, sourcePaths: [] });
expect(del.sort()).toEqual([...LEDGER].sort());
});
it('GREEN with the carve-out: deny-wins makes the ledger operator and unprunable', () => {
for (const p of LEDGER) expect(resolveOwnership(withCarveOut, p)).toBe('operator');
const del = planPrune({ manifest: withCarveOut, targetPaths: LEDGER, sourcePaths: [] });
expect(del).toEqual([]);
});
it('the SHIPPED manifest reserves fleet/run/** so the ledger is operator-owned', () => {
const shipped = loadManifest(FRAMEWORK_ROOT);
for (const p of LEDGER) expect(resolveOwnership(shipped, p)).toBe('operator');
// And it is structurally unreachable by pruning even if it were in a subtree.
expect(planPrune({ manifest: shipped, targetPaths: LEDGER, sourcePaths: [] })).toEqual([]);
});
});
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');
}
});
});

View File

@@ -0,0 +1,248 @@
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';
/**
* Thrown when the manifest is missing, empty, or malformed. A distinct type lets
* callers (e.g. finalizeStage) tell a pre-sync validation abort — where NO files
* were touched — apart from a generic mid-sync filesystem failure, and message
* the user accurately (#791 blocker-C).
*/
export class ManifestError extends Error {
constructor(message: string) {
super(message);
this.name = 'ManifestError';
}
}
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 ManifestError(`Unknown manifest section header on line ${i + 1}: ${line}`);
}
if (section === null) {
throw new ManifestError(
`Manifest entry before any [section] header on line ${i + 1}: ${line}`,
);
}
(section === 'framework' ? framework : operator).push(line);
}
// Fail CLOSED on an empty or comment-only manifest. A manifest with zero
// framework-owned globs would make resolveOwnership() return `operator` for
// every path: an upgrade would prune nothing and refresh nothing — a silent
// no-op indistinguishable from success. Refuse loudly instead, mirroring the
// bash reader's `manifest_load` guard so both halves reject it identically (#791 B2).
if (framework.length === 0) {
throw new ManifestError(
'Framework manifest defines no [framework] paths — refusing to proceed (empty or malformed manifest).',
);
}
// Fail CLOSED on framework entries that normalize to nothing usable. A manifest
// like `[framework]\n/` or `[framework]\n./` passes the length check above but
// every entry normalizes to an empty (or bare-dot) glob that matches no real
// path — so the compiled framework matcher is empty and every path resolves
// `operator`: the same silent no-op as an empty manifest. Require at least one
// entry with a real, non-dot character (the bash reader applies the identical
// `[^/.]` test, so both halves reject these inputs together — #791 blocker-B).
if (!framework.some(isUsableFrameworkGlob)) {
throw new ManifestError(
'Framework manifest defines no usable [framework] paths (every entry is empty or a bare dot segment) — refusing to proceed (malformed manifest).',
);
}
return { framework, operator };
}
/**
* A framework glob is usable only if, once normalized, it still contains a
* character other than `/` or `.` — i.e. it names a real path segment or a
* wildcard. `''`, `/`, `./`, `.`, `..` are all unusable (they compile to a glob
* that matches nothing). Kept byte-compatible with the bash `[[ =~ [^/.] ]]`
* test so TS and bash accept/reject exactly the same manifests.
*/
function isUsableFrameworkGlob(glob: string): boolean {
return /[^/.]/.test(normalizeRel(glob));
}
/** Read and parse the manifest from a framework root directory. */
export function loadManifest(frameworkRoot: string): FrameworkManifest {
const file = `${frameworkRoot}/framework-manifest.txt`;
let text: string;
try {
text = readFileSync(file, 'utf-8');
} catch (err) {
// A missing/unreadable manifest must fail closed with a clear message, not a
// raw ENOENT that a caller might mistake for an empty result set (#791 B2/B3).
throw new ManifestError(
`Cannot read framework manifest at ${file}: ${(err as Error).message} — refusing to sync (fail-closed).`,
);
}
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;
}