fix(update): mosaic update runs the install-ordering guard post-reseed (#882 --sync-only bypass) (#883)
Co-authored-by: jason.woltje <[email protected]> Co-committed-by: jason.woltje <[email protected]>
This commit was merged in pull request #883.
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
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<string, unknown>;
|
||||
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<typeof runUpdatePathSettingsGuard>[2],
|
||||
_deps: Parameters<typeof runUpdatePathSettingsGuard>[3],
|
||||
): ReturnType<typeof runUpdatePathSettingsGuard> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user