import { describe, it, expect, afterEach, vi } from 'vitest'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { ENFORCEMENT_HOOK_MARKERS, FAIL_LOUD_MESSAGE, settingsHasEnforcementHooks, } from '../commands/install-ordering-guard.js'; import { runUpdatePathSettingsGuard, runUpdateReseedFlow, type FrameworkReseedResult, } from './update-checker.js'; /** * Red-first tests for issue #882 (b) — the `mosaic update --sync-only` * install-ordering-guard bypass (Mos-ruled "Option C"). * * Root cause under test: `runFrameworkReseed()` runs the package's * install.sh with MOSAIC_SYNC_ONLY=1, which exits after the file-system * phase, BEFORE the "Post-install tasks" step that would otherwise run * `mosaic-link-runtime-assets` — the only place the #869 Point-1 C2 * install-ordering guard evaluated whether the lease-enforcement hooks * (PreToolUse mutator-gate.py / Stop receipt-observer-client.py) may be * wired into `~/.claude/settings.json`. A plain `mosaic update` therefore * never re-evaluated that decision. These tests prove the post-reseed step * added to close that gap (`runUpdatePathSettingsGuard`, wired into the * `mosaic update` reseed flow via `runUpdateReseedFlow`) reuses the EXACT * C2 guard — no forked logic — and is skipped only when `--no-reseed` means * there was nothing to re-seed/re-link in the first place. * * All fixtures use temp directories — this suite never reads or writes the * real `~/.claude/settings.json` or `~/.config/mosaic`. */ const FIXTURE_SETTINGS = { model: 'opus', hooks: { PreToolUse: [ { matcher: '.*', hooks: [ { type: 'command', command: 'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude', timeout: 3, }, ], }, ], Stop: [ { hooks: [ { type: 'command', command: 'python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude', timeout: 3, }, ], }, ], }, }; function fixtureJson(): string { return JSON.stringify(FIXTURE_SETTINGS, null, 2) + '\n'; } describe('runUpdatePathSettingsGuard', () => { let root: string; let mosaicHome: string; let claudeHome: string; afterEach(() => { if (root) rmSync(root, { recursive: true, force: true }); }); function makeTemplate(): void { root = mkdtempSync(join(tmpdir(), 'mosaic-update-settings-guard-')); mosaicHome = join(root, 'mosaic-home'); claudeHome = join(root, 'claude-home'); mkdirSync(join(mosaicHome, 'runtime', 'claude'), { recursive: true }); writeFileSync(join(mosaicHome, 'runtime', 'claude', 'settings.json'), fixtureJson()); } it('does not run when there is no settings.json template to re-link', () => { root = mkdtempSync(join(tmpdir(), 'mosaic-update-settings-guard-')); mosaicHome = join(root, 'mosaic-home'); claudeHome = join(root, 'claude-home'); // Deliberately no runtime/claude/settings.json under mosaicHome. const outcome = runUpdatePathSettingsGuard(mosaicHome, claudeHome); expect(outcome.ran).toBe(false); expect(outcome.result).toBeUndefined(); expect(existsSync(join(claudeHome, 'settings.json'))).toBe(false); }); it('activatable=false (default, no opt-out): strips enforcement hooks and fails loud, exactly as install-time', () => { makeTemplate(); const outcome = runUpdatePathSettingsGuard( mosaicHome, claudeHome, {}, { activatable: () => false }, ); expect(outcome.ran).toBe(true); expect(outcome.result?.exitCode).toBe(1); expect(outcome.result?.wired).toBe(false); expect(outcome.result?.logs).toHaveLength(1); expect(outcome.result?.logs[0]?.level).toBe('error'); expect(outcome.result?.logs[0]?.message).toBe(FAIL_LOUD_MESSAGE); const written = JSON.parse(readFileSync(join(claudeHome, 'settings.json'), 'utf-8')) as Record< string, unknown >; expect(settingsHasEnforcementHooks(written)).toBe(false); }); it('activatable=true: wires hooks normally, no strip, no logs', () => { makeTemplate(); const outcome = runUpdatePathSettingsGuard( mosaicHome, claudeHome, {}, { activatable: () => true }, ); expect(outcome.ran).toBe(true); expect(outcome.result?.exitCode).toBe(0); expect(outcome.result?.wired).toBe(true); expect(outcome.result?.logs).toHaveLength(0); const written = JSON.parse(readFileSync(join(claudeHome, 'settings.json'), 'utf-8')) as Record< string, unknown >; expect(settingsHasEnforcementHooks(written)).toBe(true); expect(written).toEqual(FIXTURE_SETTINGS); }); it('activatable=false + --allow-inactive-enforcement: wires hooks anyway with a loud warning', () => { makeTemplate(); const outcome = runUpdatePathSettingsGuard( mosaicHome, claudeHome, { allowInactiveEnforcement: true }, { activatable: () => false }, ); expect(outcome.ran).toBe(true); expect(outcome.result?.exitCode).toBe(0); expect(outcome.result?.wired).toBe(true); expect(outcome.result?.logs).toHaveLength(1); expect(outcome.result?.logs[0]?.level).toBe('warn'); expect(outcome.result?.logs[0]?.message).toMatch(/WITHOUT confirmed activation/); const written = JSON.parse(readFileSync(join(claudeHome, 'settings.json'), 'utf-8')) as Record< string, unknown >; expect(settingsHasEnforcementHooks(written)).toBe(true); }); it('never touches the real home directory settings path used by this test file', () => { // Sanity guard for the suite itself. makeTemplate(); expect(mosaicHome).toContain('mosaic-update-settings-guard-'); expect(claudeHome).toContain('mosaic-update-settings-guard-'); }); }); describe('runUpdateReseedFlow (the `mosaic update` post-reseed guard wiring, #882 (b))', () => { const okReseed: FrameworkReseedResult = { ok: true }; it('--no-reseed: the reseed is never attempted and the settings guard is never invoked', () => { const doReseed = vi.fn(() => okReseed); const doGuard = vi.fn(() => ({ ran: true })); const doRefresh = vi.fn(() => ({ refreshed: [], ok: true })); const doReadRoster = vi.fn(() => []); const log = vi.fn(); const warnLog = vi.fn(); const errorLog = vi.fn(); const result = runUpdateReseedFlow( 'should never be printed', { reseed: false }, { runFrameworkReseed: doReseed, runUpdatePathSettingsGuard: doGuard, refreshActiveFleetUnits: doRefresh, readRosterAgentNames: doReadRoster, log, warnLog, errorLog, }, ); expect(result.attempted).toBe(false); expect(doReseed).not.toHaveBeenCalled(); expect(doGuard).not.toHaveBeenCalled(); expect(log).not.toHaveBeenCalled(); expect(errorLog).not.toHaveBeenCalled(); }); it('reseed ran + activatable=false: the guard fires (hooks stripped) and the fail-loud message is surfaced, not swallowed', () => { const doReseed = vi.fn(() => okReseed); const doGuard = vi.fn(() => ({ ran: true, result: { json: '{}', wired: false, exitCode: 1 as const, logs: [{ level: 'error' as const, message: FAIL_LOUD_MESSAGE }], destWritten: true, }, })); const doRefresh = vi.fn(() => ({ refreshed: [], ok: true })); const doReadRoster = vi.fn(() => []); const log = vi.fn(); const warnLog = vi.fn(); const errorLog = vi.fn(); const result = runUpdateReseedFlow( 'Re-seeding…', { reseed: true }, { runFrameworkReseed: doReseed, runUpdatePathSettingsGuard: doGuard, refreshActiveFleetUnits: doRefresh, readRosterAgentNames: doReadRoster, log, warnLog, errorLog, }, ); expect(result.attempted).toBe(true); expect(doReseed).toHaveBeenCalledTimes(1); expect(doGuard).toHaveBeenCalledTimes(1); expect(result.settingsGuard?.result?.exitCode).toBe(1); // The guard's fail-loud message must reach the operator (stderr), never swallowed. expect(errorLog).toHaveBeenCalledWith(FAIL_LOUD_MESSAGE); }); it('reseed ran + activatable=true: the guard wires hooks with no error output', () => { const doReseed = vi.fn(() => okReseed); const doGuard = vi.fn(() => ({ ran: true, result: { json: '{}', wired: true, exitCode: 0 as const, logs: [], destWritten: true, }, })); const doRefresh = vi.fn(() => ({ refreshed: [], ok: true })); const doReadRoster = vi.fn(() => []); const log = vi.fn(); const warnLog = vi.fn(); const errorLog = vi.fn(); const result = runUpdateReseedFlow( 'Re-seeding…', { reseed: true }, { runFrameworkReseed: doReseed, runUpdatePathSettingsGuard: doGuard, refreshActiveFleetUnits: doRefresh, readRosterAgentNames: doReadRoster, log, warnLog, errorLog, }, ); expect(result.attempted).toBe(true); expect(result.settingsGuard?.result?.exitCode).toBe(0); expect(errorLog).not.toHaveBeenCalled(); }); it('threads --allow-inactive-enforcement through to the settings guard', () => { const doReseed = vi.fn(() => okReseed); const doGuard = vi.fn(() => ({ ran: true, result: { json: '{}', wired: true, exitCode: 0 as const, logs: [{ level: 'warn' as const, message: 'opt-out warning' }], destWritten: true, }, })); const doRefresh = vi.fn(() => ({ refreshed: [], ok: true })); const doReadRoster = vi.fn(() => []); const warnLog = vi.fn(); runUpdateReseedFlow( 'Re-seeding…', { reseed: true, allowInactiveEnforcement: true }, { runFrameworkReseed: doReseed, runUpdatePathSettingsGuard: doGuard, refreshActiveFleetUnits: doRefresh, readRosterAgentNames: doReadRoster, log: vi.fn(), warnLog, errorLog: vi.fn(), }, ); expect(doGuard).toHaveBeenCalledWith(undefined, undefined, { allowInactiveEnforcement: true, }); expect(warnLog).toHaveBeenCalledWith('opt-out warning'); }); it('reseed failure: the settings guard is not invoked (nothing was re-seeded to re-link)', () => { const doReseed = vi.fn( () => ({ ok: false, reason: 'installer not found' }) as FrameworkReseedResult, ); const doGuard = vi.fn(() => ({ ran: true })); const doRefresh = vi.fn(() => ({ refreshed: [], ok: true })); const doReadRoster = vi.fn(() => []); const errorLog = vi.fn(); const result = runUpdateReseedFlow( 'Re-seeding…', { reseed: true }, { runFrameworkReseed: doReseed, runUpdatePathSettingsGuard: doGuard, refreshActiveFleetUnits: doRefresh, readRosterAgentNames: doReadRoster, log: vi.fn(), warnLog: vi.fn(), errorLog, }, ); expect(result.attempted).toBe(true); expect(result.settingsGuard).toBeUndefined(); expect(doGuard).not.toHaveBeenCalled(); expect(errorLog).toHaveBeenCalledWith(expect.stringContaining('Framework re-seed skipped')); }); it('end-to-end (real runUpdatePathSettingsGuard, real temp files): reseed ok + activatable=false strips hooks in the live settings.json path', () => { const root = mkdtempSync(join(tmpdir(), 'mosaic-update-reseed-flow-e2e-')); try { const mosaicHome = join(root, 'mosaic-home'); const claudeHome = join(root, 'claude-home'); mkdirSync(join(mosaicHome, 'runtime', 'claude'), { recursive: true }); writeFileSync(join(mosaicHome, 'runtime', 'claude', 'settings.json'), fixtureJson()); // Pre-existing (stale, install-time) settings.json still carrying the // enforcement hooks — this is the exact state #882 (b) left behind. mkdirSync(claudeHome, { recursive: true }); writeFileSync(join(claudeHome, 'settings.json'), fixtureJson()); const errorLog = vi.fn(); const result = runUpdateReseedFlow( 'Re-seeding…', { reseed: true }, { runFrameworkReseed: () => okReseed, runUpdatePathSettingsGuard: (mh, ch, options, deps) => // Exercise the REAL function (imported above), pointed at temp dirs, // with the activation probe faked to prove this is not a live-host test. runUpdatePathSettingsGuardWithFakeActivation( mh ?? mosaicHome, ch ?? claudeHome, options, deps, ), refreshActiveFleetUnits: () => ({ refreshed: [], ok: true }), readRosterAgentNames: () => [], log: vi.fn(), warnLog: vi.fn(), errorLog, }, ); expect(result.settingsGuard?.result?.exitCode).toBe(1); const written = JSON.parse( readFileSync(join(claudeHome, 'settings.json'), 'utf-8'), ) as Record; expect(settingsHasEnforcementHooks(written)).toBe(false); expect(errorLog).toHaveBeenCalledWith(FAIL_LOUD_MESSAGE); } finally { rmSync(root, { recursive: true, force: true }); } }); }); function runUpdatePathSettingsGuardWithFakeActivation( mosaicHome: string, claudeHome: string, options: Parameters[2], _deps: Parameters[3], ): ReturnType { return runUpdatePathSettingsGuard(mosaicHome, claudeHome, options, { activatable: () => false }); } /** * Sanity check: the enforcement markers this suite exercises must match the * ones the C2 guard (`install-ordering-guard.ts`) actually looks for, so a * drift in either module's marker strings would fail this suite loudly * rather than silently passing on the wrong hooks. */ describe('marker parity with the C2 guard', () => { it('the fixture uses the same marker commands the guard matches on', () => { const preToolUse = FIXTURE_SETTINGS.hooks.PreToolUse[0]?.hooks[0]?.command ?? ''; const stop = FIXTURE_SETTINGS.hooks.Stop[0]?.hooks[0]?.command ?? ''; expect(preToolUse).toContain(ENFORCEMENT_HOOK_MARKERS.preToolUse); expect(stop).toContain(ENFORCEMENT_HOOK_MARKERS.stop); }); });