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): 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 = [ ['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); }); });