fix(update): mosaic update runs the install-ordering guard post-reseed (#882 --sync-only bypass) (#883)
Co-authored-by: jason.woltje <jason@diversecanvas.com> Co-committed-by: jason.woltje <jason@diversecanvas.com>
This commit was merged in pull request #883.
This commit is contained in:
@@ -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 (`<mosaicHome>/runtime/claude/settings.json`) and the live
|
||||
// `<claudeHome>/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 <agent>)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { attempted: true, reseed, settingsGuard };
|
||||
}
|
||||
|
||||
// ─── Framework drift detection (#642) ────────────────────────────────────────
|
||||
//
|
||||
// `mosaic update` only re-seeds the framework when the @mosaicstack/mosaic
|
||||
|
||||
Reference in New Issue
Block a user