From 6c6b4f33aca9af749c21805544d36769ac18929d Mon Sep 17 00:00:00 2001 From: mosaic-coder Date: Thu, 23 Jul 2026 14:29:24 -0500 Subject: [PATCH] fix(update): mosaic update runs the install-ordering guard post-reseed (#882 --sync-only bypass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mosaic update`'s framework reseed runs install.sh with MOSAIC_SYNC_ONLY=1, which exits after the file-system phase — before the post-install step that runs mosaic-link-runtime-assets, the only place the #869 Point-1 C2 install-ordering guard decided whether the lease-enforcement hooks (mutator-gate.py / receipt-observer-client.py) get wired into ~/.claude/settings.json. `mosaic update` therefore never re-evaluated that wiring decision (Point-2 precondition, Option C per Mos's ruling). Add `runUpdatePathSettingsGuard` (reuses `runInstallOrderingGuard` from install-ordering-guard.ts directly — no forked logic) and extract the `mosaic update` reseed flow into the testable `runUpdateReseedFlow`, which invokes the settings guard immediately after a successful reseed and surfaces its fail-loud/opt-out output to the operator. `--no-reseed` is a pure no-op (nothing reseeded, nothing to re-link). Threads a new `--allow-inactive-enforcement` flag on `mosaic update`, matching install. Part of #882 (resolves the (b) --sync-only Point-2 precondition) Co-Authored-By: Claude Sonnet 5 --- packages/mosaic/src/cli.ts | 82 ++-- .../update-checker.settings-guard.spec.ts | 424 ++++++++++++++++++ packages/mosaic/src/runtime/update-checker.ts | 175 ++++++++ 3 files changed, 625 insertions(+), 56 deletions(-) create mode 100644 packages/mosaic/src/runtime/update-checker.settings-guard.spec.ts diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 76c0fafa..958cb3d0 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -32,10 +32,7 @@ import { formatAllPackagesTable, getInstallAllCommand, repairFleetCommsTools, - runFrameworkReseed, - refreshActiveFleetUnits, - readRosterAgentNames, - buildRelaunchCommands, + runUpdateReseedFlow, checkFrameworkDrift, FRAMEWORK_RESEED_PACKAGE, } from './runtime/update-checker.js'; @@ -445,12 +442,18 @@ program '--repair-tools', 'Restore the supported current-version TOOLS contract and executable fleet helper', ) + .option( + '--allow-inactive-enforcement', + 'Wire lease-enforcement hooks into settings.json even when activation cannot be confirmed ' + + '(explicit, loud, non-default opt-out for the post-reseed install-ordering guard — see #869/#882)', + ) .action( async (opts: { check?: boolean; reseed?: boolean; relaunch?: boolean; repairTools?: boolean; + allowInactiveEnforcement?: boolean; }) => { if (opts.repairTools) { const repair = repairFleetCommsTools(); @@ -471,57 +474,24 @@ program // checkForAllUpdates imported statically above const { execSync } = await import('node:child_process'); - // Re-seed the framework from the freshly-installed package, propagate shipped - // systemd unit fixes to the active units, and (opt-in) relaunch durable - // agents. Shared by the "packages updated" and the "framework drift" paths. + // Re-seed the framework from the freshly-installed package, re-apply the + // install-ordering guard to settings.json (#882 (b) — closes the + // `--sync-only` bypass so `mosaic update` never leaves enforcement-hook + // wiring stale/unguarded), propagate shipped systemd unit fixes to the + // active units, and (opt-in) relaunch durable agents. Shared by the + // "packages updated" and the "framework drift" paths. Extracted to + // update-checker.ts (`runUpdateReseedFlow`) for direct unit testability. const reseedFramework = (reason: string): void => { - console.log(reason); - const reseed = runFrameworkReseed(); - if (!reseed.ok) { - console.error( - `\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` + - ' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' + - '(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)', - ); - return; - } - console.log('✔ Framework re-seeded.'); - if (reseed.skillSyncError) { - console.error(` ⚠ Claude skill reconciliation skipped: ${reseed.skillSyncError}`); - } - const skillConflicts = reseed.skillSync?.conflicts ?? []; - const skillChanges = - (reseed.skillSync?.registered.length ?? 0) + (reseed.skillSync?.repaired.length ?? 0); - if (skillChanges > 0) { - console.log(`✔ Registered ${skillChanges.toString()} Mosaic skill(s) with Claude Code.`); - } - for (const conflict of skillConflicts) { - console.error(` ⚠ Skill registration skipped for ${conflict.name}: ${conflict.reason}`); - } - // Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only - // touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user). - const units = refreshActiveFleetUnits(); - if (units.refreshed.length > 0) { - console.log(`✔ Refreshed ${units.refreshed.length} active systemd unit(s).`); - } - const agents = readRosterAgentNames(); - if (agents.length === 0) return; - if (opts.relaunch) { - console.log(`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`); - for (const restart of buildRelaunchCommands(agents)) { - try { - execSync(restart.join(' '), { stdio: 'inherit', timeout: 30_000 }); - } catch { - console.error(` ⚠ failed to restart agent — run: ${restart.join(' ')}`); - } - } - console.log('✔ Agents relaunched.'); - } else { - console.log( - `\nℹ ${agents.length} fleet agent(s) are still running the previous runtime. ` + - 'Restart them to activate the update:\n mosaic update --relaunch ' + - '(or: mosaic fleet restart )', - ); + const flow = runUpdateReseedFlow(reason, { + reseed: opts.reseed, + relaunch: opts.relaunch, + allowInactiveEnforcement: opts.allowInactiveEnforcement === true, + }); + if (flow.settingsGuard?.ran && flow.settingsGuard.result?.exitCode === 1) { + // Fail-loud: enforcement hooks were refused/stripped. Surface this + // in the command's own exit status without aborting the rest of + // the update (mirrors mosaic-link-runtime-assets' guard_degraded). + process.exitCode = 1; } }; @@ -544,7 +514,7 @@ program // package is reported outdated. Detect that via the framework version and // re-seed so shipped launcher/runtime fixes still activate. const drift = checkFrameworkDrift(); - if (drift.drifted && opts.reseed !== false) { + if (drift.drifted) { reseedFramework( `\nFramework drift detected (on-disk v${drift.installed} < bundled v${drift.bundled}) — ` + 'the CLI was updated outside `mosaic update`. Re-seeding framework files into ' + @@ -582,7 +552,7 @@ program (r: { package: string }) => r.package === FRAMEWORK_RESEED_PACKAGE, ); const drift = checkFrameworkDrift(); - if ((mosaicUpdated || drift.drifted) && opts.reseed !== false) { + if (mosaicUpdated || drift.drifted) { reseedFramework( '\nRe-seeding framework files into ~/.config/mosaic (data-safe; keeps your edits)…', ); diff --git a/packages/mosaic/src/runtime/update-checker.settings-guard.spec.ts b/packages/mosaic/src/runtime/update-checker.settings-guard.spec.ts new file mode 100644 index 00000000..c5385859 --- /dev/null +++ b/packages/mosaic/src/runtime/update-checker.settings-guard.spec.ts @@ -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; + 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); + }); +}); diff --git a/packages/mosaic/src/runtime/update-checker.ts b/packages/mosaic/src/runtime/update-checker.ts index 813fe4ee..adf70558 100644 --- a/packages/mosaic/src/runtime/update-checker.ts +++ b/packages/mosaic/src/runtime/update-checker.ts @@ -44,6 +44,12 @@ import { readRegularFileSecure, } from '../fleet/secure-file.js'; import { getDefaultSkillPaths, syncClaudeSkills, type SkillSyncResult } from '../commands/skill.js'; +import { + runInstallOrderingGuard, + type InstallOrderingGuardDeps, + type InstallOrderingGuardOptions, + type RunInstallOrderingGuardResult, +} from '../commands/install-ordering-guard.js'; // ─── Types ────────────────────────────────────────────────────────────────── @@ -908,6 +914,175 @@ export function runFrameworkReseed( } } +// ─── Post-reseed install-ordering guard (#882, Point-2 precondition) ──────── +// +// Root cause (restated): `runFrameworkReseed` above runs the package's +// install.sh with MOSAIC_SYNC_ONLY=1, which — by design (see install.sh) — +// exits after the file-system phase, BEFORE the "Post-install tasks" step +// that runs `mosaic-link-runtime-assets`. That script is where the #869 +// Point-1 C2 install-ordering guard (`runInstallOrderingGuard`, +// `packages/mosaic/src/commands/install-ordering-guard.ts`) decides whether +// the lease-enforcement hooks (PreToolUse mutator-gate.py / Stop +// receipt-observer-client.py) get wired into `~/.claude/settings.json`. A +// plain `mosaic update` reseed therefore never re-evaluated that wiring +// decision against current activation state — the bypass this closes. +// +// `runUpdatePathSettingsGuard` re-applies the EXACT SAME guard (no forked +// logic) against the MANAGED settings.json template the reseed just +// refreshed (`/runtime/claude/settings.json`) and the live +// `/settings.json` — mirroring `copy_claude_settings_guarded`'s +// src/dest pair in `mosaic-link-runtime-assets`. + +export interface UpdatePathSettingsGuardResult { + /** False when there is no settings.json template on disk to re-link (e.g. a + * framework layout that predates runtime/claude/settings.json) — nothing to + * guard, so the guard did not run. */ + ran: boolean; + result?: RunInstallOrderingGuardResult; +} + +export function runUpdatePathSettingsGuard( + mosaicHome = join(homedir(), '.config', 'mosaic'), + claudeHome = process.env['CLAUDE_HOME'] ?? join(homedir(), '.claude'), + options: InstallOrderingGuardOptions = {}, + deps: InstallOrderingGuardDeps = {}, +): UpdatePathSettingsGuardResult { + const src = join(mosaicHome, 'runtime', 'claude', 'settings.json'); + if (!existsSync(src)) { + return { ran: false }; + } + const dest = join(claudeHome, 'settings.json'); + return { ran: true, result: runInstallOrderingGuard(src, dest, options, deps) }; +} + +// ─── update-reseed flow (extracted for testability; called from cli.ts) ──── +// +// Everything `mosaic update`'s `.action()` does once it has decided a reseed +// should happen (both call sites already gate on `opts.reseed !== false` +// before invoking this). Extracted out of cli.ts so the post-reseed guard +// wiring (#882 (b)) — and the `--no-reseed` short-circuit — are directly unit +// testable with injected fakes, matching the existing update-checker +// conventions (see update-checker.reseed.spec.ts). + +export interface UpdateReseedFlowOptions { + /** Mirrors the CLI's `--no-reseed` flag (commander sets `reseed: false` + * when passed). `false` is a pure no-op: nothing is reseeded and the + * post-reseed settings guard is not invoked either — there is nothing to + * re-link. */ + reseed?: boolean; + relaunch?: boolean; + /** Threads `--allow-inactive-enforcement` to the post-reseed settings + * guard, identically to the install path (see install-ordering-guard.ts). + * Never sourced from an environment variable — explicit per-invocation + * opt-out only. */ + allowInactiveEnforcement?: boolean; +} + +export interface UpdateReseedFlowDeps { + runFrameworkReseed?: typeof runFrameworkReseed; + runUpdatePathSettingsGuard?: typeof runUpdatePathSettingsGuard; + refreshActiveFleetUnits?: typeof refreshActiveFleetUnits; + readRosterAgentNames?: typeof readRosterAgentNames; + execSync?: typeof execSync; + log?: (message: string) => void; + warnLog?: (message: string) => void; + errorLog?: (message: string) => void; +} + +export interface UpdateReseedFlowResult { + /** Whether a reseed was actually attempted (false only for `--no-reseed`). */ + attempted: boolean; + reseed?: FrameworkReseedResult; + settingsGuard?: UpdatePathSettingsGuardResult; +} + +export function runUpdateReseedFlow( + reason: string, + options: UpdateReseedFlowOptions = {}, + deps: UpdateReseedFlowDeps = {}, +): UpdateReseedFlowResult { + if (options.reseed === false) { + // Nothing to re-seed, and therefore nothing to re-link/guard either. + return { attempted: false }; + } + + const log = deps.log ?? console.log; + const warnLog = deps.warnLog ?? console.warn; + const errorLog = deps.errorLog ?? console.error; + const doReseed = deps.runFrameworkReseed ?? runFrameworkReseed; + const doGuard = deps.runUpdatePathSettingsGuard ?? runUpdatePathSettingsGuard; + const doRefresh = deps.refreshActiveFleetUnits ?? refreshActiveFleetUnits; + const doReadRoster = deps.readRosterAgentNames ?? readRosterAgentNames; + const exec = deps.execSync ?? execSync; + + log(reason); + const reseed = doReseed(); + if (!reseed.ok) { + errorLog( + `\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` + + ' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' + + '(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)', + ); + return { attempted: true, reseed }; + } + log('✔ Framework re-seeded.'); + if (reseed.skillSyncError) { + errorLog(` ⚠ Claude skill reconciliation skipped: ${reseed.skillSyncError}`); + } + const skillConflicts = reseed.skillSync?.conflicts ?? []; + const skillChanges = + (reseed.skillSync?.registered.length ?? 0) + (reseed.skillSync?.repaired.length ?? 0); + if (skillChanges > 0) { + log(`✔ Registered ${skillChanges.toString()} Mosaic skill(s) with Claude Code.`); + } + for (const conflict of skillConflicts) { + errorLog(` ⚠ Skill registration skipped for ${conflict.name}: ${conflict.reason}`); + } + + // #882 (b): re-apply the install-ordering guard (C2) to the MANAGED + // settings.json the reseed just refreshed. install.sh's sync-only mode + // never reaches the post-install step that would otherwise do this, so + // `mosaic update` must do it itself — closing the bypass for every update + // path. Never swallow the guard's fail-loud/opt-out output on this path. + const settingsGuard = doGuard(undefined, undefined, { + allowInactiveEnforcement: options.allowInactiveEnforcement === true, + }); + if (settingsGuard.ran && settingsGuard.result) { + for (const line of settingsGuard.result.logs) { + (line.level === 'error' ? errorLog : warnLog)(line.message); + } + } + + // Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only + // touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user). + const units = doRefresh(); + if (units.refreshed.length > 0) { + log(`✔ Refreshed ${units.refreshed.length} active systemd unit(s).`); + } + const agents = doReadRoster(); + if (agents.length > 0) { + if (options.relaunch) { + log(`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`); + for (const restart of buildRelaunchCommands(agents)) { + try { + exec(restart.join(' '), { stdio: 'inherit', timeout: 30_000 }); + } catch { + errorLog(` ⚠ failed to restart agent — run: ${restart.join(' ')}`); + } + } + log('✔ Agents relaunched.'); + } else { + log( + `\nℹ ${agents.length} fleet agent(s) are still running the previous runtime. ` + + 'Restart them to activate the update:\n mosaic update --relaunch ' + + '(or: mosaic fleet restart )', + ); + } + } + + return { attempted: true, reseed, settingsGuard }; +} + // ─── Framework drift detection (#642) ──────────────────────────────────────── // // `mosaic update` only re-seeds the framework when the @mosaicstack/mosaic