wip(sync): merge main into next with combined resolutions
This commit is contained in:
@@ -1,5 +1,19 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
@@ -11,8 +25,8 @@ import {
|
||||
readInstalledFrameworkVersion,
|
||||
readBundledFrameworkVersion,
|
||||
checkFrameworkDrift,
|
||||
repairFleetCommsTools,
|
||||
} from './update-checker.js';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches
|
||||
@@ -66,6 +80,7 @@ describe('readRosterAgentNames', () => {
|
||||
join(home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: orchestrator',
|
||||
' runtime: pi',
|
||||
@@ -77,9 +92,259 @@ describe('readRosterAgentNames', () => {
|
||||
);
|
||||
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0', 'reviewer-1']);
|
||||
});
|
||||
|
||||
it('extracts agent names from a JSON-only roster', () => {
|
||||
mkdirSync(join(home, 'fleet'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, 'fleet', 'roster.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [
|
||||
{ name: 'orchestrator', runtime: 'pi', class: 'orchestrator' },
|
||||
{ name: 'coder0', runtime: 'claude', class: 'worker' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repairFleetCommsTools', () => {
|
||||
let root: string;
|
||||
let framework: string;
|
||||
let home: string;
|
||||
const toolsContent = '# tools\n<!-- fleet-comms-contract: 1 -->\n';
|
||||
const helperContent = '#!/bin/sh\nexit 0\n';
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'mosaic-tools-repair-'));
|
||||
framework = join(root, 'framework');
|
||||
home = join(root, 'home');
|
||||
mkdirSync(join(framework, 'defaults'), { recursive: true });
|
||||
mkdirSync(join(framework, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(framework, 'defaults', 'TOOLS.md'), toolsContent);
|
||||
const helper = join(framework, 'tools', 'tmux', 'agent-send.sh');
|
||||
writeFileSync(helper, helperContent);
|
||||
chmodSync(helper, 0o755);
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
it('restores a partially deleted current-version installation without package updates', () => {
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(home, 'TOOLS.md'), toolsContent);
|
||||
|
||||
const result = repairFleetCommsTools(framework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: true, changed: true });
|
||||
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(toolsContent);
|
||||
expect(readFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 'utf8')).toBe(helperContent);
|
||||
expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).mode & 0o111).not.toBe(0);
|
||||
});
|
||||
|
||||
it('creates a digest-qualified no-clobber backup and is idempotent', () => {
|
||||
mkdirSync(home, { recursive: true });
|
||||
const stale = '# user tools\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), stale);
|
||||
|
||||
const first = repairFleetCommsTools(framework, home);
|
||||
expect(first).toMatchObject({ ok: true, changed: true });
|
||||
expect(first.backupPath).toMatch(/\.pre-fleet-comms-[a-f0-9]{16}\.bak$/);
|
||||
expect(readFileSync(first.backupPath!, 'utf8')).toBe(stale);
|
||||
|
||||
const second = repairFleetCommsTools(framework, home);
|
||||
expect(second).toEqual({ ok: true, changed: false, backupPath: undefined });
|
||||
expect(readFileSync(first.backupPath!, 'utf8')).toBe(stale);
|
||||
});
|
||||
|
||||
it('rejects an installed helper symlink without modifying its target', () => {
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(home, 'TOOLS.md'), toolsContent);
|
||||
const target = join(root, 'external-helper');
|
||||
writeFileSync(target, 'do not touch\n');
|
||||
symlinkSync(target, join(home, 'tools', 'tmux', 'agent-send.sh'));
|
||||
|
||||
const result = repairFleetCommsTools(framework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('symbolic link');
|
||||
expect(readFileSync(target, 'utf8')).toBe('do not touch\n');
|
||||
expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a helper directory before replacing stale TOOLS content', () => {
|
||||
mkdirSync(join(home, 'tools', 'tmux', 'agent-send.sh'), { recursive: true });
|
||||
const stale = '# user tools\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), stale);
|
||||
|
||||
const result = repairFleetCommsTools(framework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('not a regular file');
|
||||
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
|
||||
});
|
||||
|
||||
it('refuses a pre-existing digest backup whose bytes do not match', () => {
|
||||
mkdirSync(home, { recursive: true });
|
||||
const stale = '# user tools\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), stale);
|
||||
const digest = createHash('sha256').update(stale).digest('hex').slice(0, 16);
|
||||
writeFileSync(join(home, `TOOLS.md.pre-fleet-comms-${digest}.bak`), 'collision\n');
|
||||
|
||||
const result = repairFleetCommsTools(framework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('backup collision');
|
||||
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
|
||||
});
|
||||
|
||||
it('rejects a symlink in each installed destination ancestor without external writes', () => {
|
||||
const cases = [
|
||||
{ name: 'home', prefix: join(root, 'linked-home'), suffix: '' },
|
||||
{ name: 'tools', prefix: join(root, 'real-home'), suffix: 'tools' },
|
||||
{ name: 'tmux', prefix: join(root, 'real-home'), suffix: join('tools', 'tmux') },
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const external = join(root, `external-${testCase.name}`);
|
||||
mkdirSync(external, { recursive: true });
|
||||
const targetHome =
|
||||
testCase.name === 'home' ? testCase.prefix : join(root, `installed-${testCase.name}`);
|
||||
if (testCase.name === 'home') {
|
||||
symlinkSync(external, targetHome);
|
||||
} else {
|
||||
mkdirSync(targetHome, { recursive: true });
|
||||
const linkPath = join(targetHome, testCase.suffix);
|
||||
mkdirSync(join(linkPath, '..'), { recursive: true });
|
||||
symlinkSync(external, linkPath);
|
||||
}
|
||||
|
||||
const result = repairFleetCommsTools(framework, targetHome);
|
||||
|
||||
expect(result, testCase.name).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason, testCase.name).toContain('symbolic link');
|
||||
expect(readdirSync(external), testCase.name).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('rolls back the backup and exact TOOLS bytes/mode when helper commit fails', () => {
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
const staleTools = '# user tools\n';
|
||||
const staleHelper = '#!/bin/sh\nexit 17\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), staleTools, { mode: 0o640 });
|
||||
writeFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), staleHelper, { mode: 0o710 });
|
||||
|
||||
const result = repairFleetCommsTools(framework, home, {
|
||||
beforeCommit(which) {
|
||||
if (which === 'helper') throw new Error('injected helper commit failure');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.backupPath).toBeUndefined();
|
||||
expect(result.reason).toContain('injected helper commit failure');
|
||||
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(staleTools);
|
||||
expect(statSync(join(home, 'TOOLS.md')).mode & 0o777).toBe(0o640);
|
||||
expect(readFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 'utf8')).toBe(staleHelper);
|
||||
expect(statSync(join(home, 'tools', 'tmux', 'agent-send.sh')).mode & 0o777).toBe(0o710);
|
||||
expect(readdirSync(home).filter((name) => name.includes('pre-fleet-comms'))).toEqual([]);
|
||||
expect(
|
||||
readdirSync(home).some((name) => name.includes('.repair-')) ||
|
||||
readdirSync(join(home, 'tools', 'tmux')).some((name) => name.includes('.repair-')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rolls back initially absent destinations and created directories on commit failure', () => {
|
||||
const result = repairFleetCommsTools(framework, home, {
|
||||
beforeCommit(which) {
|
||||
if (which === 'helper') throw new Error('injected absent helper failure');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('injected absent helper failure');
|
||||
expect(existsSync(home)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not persist a backup or replacement when backup commit fails', () => {
|
||||
mkdirSync(home, { recursive: true });
|
||||
const stale = '# user tools\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), stale, { mode: 0o640 });
|
||||
|
||||
const result = repairFleetCommsTools(framework, home, {
|
||||
beforeCommit(which) {
|
||||
if (which === 'backup') throw new Error('injected backup commit failure');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
|
||||
expect(statSync(join(home, 'TOOLS.md')).mode & 0o777).toBe(0o640);
|
||||
expect(readdirSync(home).filter((name) => name.includes('pre-fleet-comms'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('fails before writes when bundled source paths traverse a symlink ancestor', () => {
|
||||
const external = join(root, 'external-source');
|
||||
mkdirSync(join(external, 'defaults'), { recursive: true });
|
||||
mkdirSync(join(external, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(external, 'defaults', 'TOOLS.md'), toolsContent);
|
||||
writeFileSync(join(external, 'tools', 'tmux', 'agent-send.sh'), helperContent, { mode: 0o755 });
|
||||
const linkedFramework = join(root, 'linked-framework');
|
||||
symlinkSync(external, linkedFramework);
|
||||
|
||||
const result = repairFleetCommsTools(linkedFramework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('symbolic link');
|
||||
expect(existsSync(home)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runFrameworkReseed', () => {
|
||||
it('auto-registers every canonical skill after a successful upgrade re-seed', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-reseed-skills-'));
|
||||
const framework = join(root, 'framework');
|
||||
const home = join(root, 'mosaic');
|
||||
const claudeSkills = join(root, '.claude', 'skills');
|
||||
mkdirSync(framework, { recursive: true });
|
||||
mkdirSync(join(home, 'skills', 'added-after-setup'), { recursive: true });
|
||||
mkdirSync(join(home, 'skills', 'another-new-skill'), { recursive: true });
|
||||
writeFileSync(join(framework, 'install.sh'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
|
||||
|
||||
const res = runFrameworkReseed(framework, home, claudeSkills);
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.skillSync).toMatchObject({
|
||||
registered: ['added-after-setup', 'another-new-skill'],
|
||||
conflicts: [],
|
||||
});
|
||||
expect(readlinkSync(join(claudeSkills, 'added-after-setup'))).toBe(
|
||||
join(home, 'skills', 'added-after-setup'),
|
||||
);
|
||||
expect(readlinkSync(join(claudeSkills, 'another-new-skill'))).toBe(
|
||||
join(home, 'skills', 'another-new-skill'),
|
||||
);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('keeps a successful framework re-seed successful when bridge reconciliation fails', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-reseed-bridge-failure-'));
|
||||
const framework = join(root, 'framework');
|
||||
const home = join(root, 'mosaic');
|
||||
const claudeSkills = join(root, '.claude', 'skills');
|
||||
mkdirSync(framework, { recursive: true });
|
||||
mkdirSync(home, { recursive: true });
|
||||
writeFileSync(join(home, 'skills'), 'invalid canonical root\n');
|
||||
writeFileSync(join(framework, 'install.sh'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
|
||||
|
||||
const res = runFrameworkReseed(framework, home, claudeSkills);
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.skillSync).toBeUndefined();
|
||||
expect(res.skillSyncError).toMatch(/not a directory/i);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports not-ok (not throw) when the installer is absent', () => {
|
||||
const missing = mkdtempSync(join(tmpdir(), 'mosaic-noinstaller-'));
|
||||
const res = runFrameworkReseed(missing, join(missing, 'home'));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -15,16 +15,41 @@
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
readdirSync,
|
||||
closeSync,
|
||||
constants,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
fchmodSync,
|
||||
fsyncSync,
|
||||
linkSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmdirSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseFleetRosterV1, resolveInstalledFleetRosterPath } from '../fleet/fleet-roster-v1.js';
|
||||
import {
|
||||
assertCanonicalContainment,
|
||||
assertNoSymlinkAncestors,
|
||||
ensureManagedDirectory,
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -54,6 +79,10 @@ const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const NETWORK_TIMEOUT_MS = 5_000;
|
||||
|
||||
function isNodeErrorCode(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === code;
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function npmExec(args: string, timeoutMs = NETWORK_TIMEOUT_MS): string {
|
||||
@@ -466,9 +495,13 @@ export function getInstallAllCommand(outdated: PackageUpdateResult[]): string {
|
||||
// `mosaic update` installs the new npm CLI but, on its own, leaves the framework
|
||||
// files in ~/.config/mosaic/ stale — so shipped launcher/runtime changes (e.g.
|
||||
// the agent-name export + native heartbeat) never ACTIVATE until a re-seed.
|
||||
// These helpers run the package's own install.sh in sync-only mode (the P4
|
||||
// data-safe reconcile: framework-owned overwrite + backup-once; SOUL/USER/
|
||||
// *.local/credentials preserved) and, opt-in, relaunch durable agents.
|
||||
// These helpers run the package's own install.sh in sync-only mode. The re-seed
|
||||
// is manifest-driven (#791): keep mode writes ONLY framework-owned paths from the
|
||||
// shared framework-manifest.txt and prunes only retired framework files inside
|
||||
// shipped subtrees — every operator path (SOUL/USER/*.local/credentials, fleet
|
||||
// roster + agents + backlog, and anything the manifest never anticipated) is
|
||||
// left byte-identical. Contract files are still reconciled (overwrite +
|
||||
// backup-once). Opt-in, this also relaunches durable agents.
|
||||
|
||||
/** Resolve the framework/ directory bundled in the installed package. */
|
||||
export function resolveBundledFrameworkRoot(): string {
|
||||
@@ -500,25 +533,554 @@ export function buildReseedCommand(
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolsRepairResult {
|
||||
ok: boolean;
|
||||
changed: boolean;
|
||||
backupPath?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ToolsRepairHooks {
|
||||
beforeCommit?: (which: 'backup' | 'tools' | 'helper') => void;
|
||||
}
|
||||
|
||||
function optionalSecureFile(
|
||||
path: string,
|
||||
root: string,
|
||||
): ReturnType<typeof readRegularFileSecure> | undefined {
|
||||
try {
|
||||
return readRegularFileSecure(path, { root });
|
||||
} catch (error) {
|
||||
if (isNodeErrorCode(error, 'ENOENT')) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function stageManagedFile(
|
||||
root: string,
|
||||
directory: string,
|
||||
target: string,
|
||||
content: Buffer,
|
||||
mode: number,
|
||||
): string {
|
||||
assertCanonicalContainment(root, target);
|
||||
ensureManagedDirectory(root, directory);
|
||||
assertNoSymlinkAncestors(target);
|
||||
const staged = join(directory, `.${basename(target)}.repair-${process.pid}-${cryptoRandom()}`);
|
||||
assertCanonicalContainment(root, staged);
|
||||
const fd = openSync(
|
||||
staged,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
writeFileSync(fd, content);
|
||||
fchmodSync(fd, mode);
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
return staged;
|
||||
}
|
||||
|
||||
function cryptoRandom(): string {
|
||||
return randomBytes(8).toString('hex');
|
||||
}
|
||||
|
||||
interface ManagedOriginal {
|
||||
path: string;
|
||||
snapshot?: ReturnType<typeof readRegularFileSecure>;
|
||||
}
|
||||
|
||||
function assertManagedOriginalUnchanged(original: ManagedOriginal, root: string): void {
|
||||
if (!original.snapshot) {
|
||||
try {
|
||||
lstatSync(original.path);
|
||||
throw new Error(`repair destination appeared during staging: ${original.path}`);
|
||||
} catch (error) {
|
||||
if (isNodeErrorCode(error, 'ENOENT')) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const current = readRegularFileSecure(original.path, { root });
|
||||
if (
|
||||
current.dev !== original.snapshot.dev ||
|
||||
current.ino !== original.snapshot.ino ||
|
||||
current.mode !== original.snapshot.mode ||
|
||||
!current.content.equals(original.snapshot.content)
|
||||
) {
|
||||
throw new Error(`repair destination changed during staging: ${original.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function installBackupNoClobber(staged: string, target: string, root: string): void {
|
||||
assertCanonicalContainment(root, target);
|
||||
assertNoSymlinkAncestors(target);
|
||||
try {
|
||||
lstatSync(target);
|
||||
throw new Error(`digest-qualified backup collision at ${target}`);
|
||||
} catch (error) {
|
||||
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
linkSync(staged, target);
|
||||
unlinkSync(staged);
|
||||
}
|
||||
|
||||
function atomicInstall(staged: string, target: string, root: string): void {
|
||||
assertCanonicalContainment(root, target);
|
||||
assertNoSymlinkAncestors(target);
|
||||
try {
|
||||
const current = lstatSync(target);
|
||||
if (current.isSymbolicLink() || !current.isFile()) {
|
||||
throw new Error(`repair destination is not a regular file: ${target}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
renameSync(staged, target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly repair the user-owned TOOLS contract and required helper from the
|
||||
* bundled current framework. Existing divergent TOOLS content is preserved in
|
||||
* a digest-qualified no-clobber backup; repeated repairs are idempotent.
|
||||
*/
|
||||
export function repairFleetCommsTools(
|
||||
frameworkRoot = resolveBundledFrameworkRoot(),
|
||||
mosaicHome = join(homedir(), '.config', 'mosaic'),
|
||||
hooks: ToolsRepairHooks = {},
|
||||
): ToolsRepairResult {
|
||||
const sourceTools = join(frameworkRoot, 'defaults', 'TOOLS.md');
|
||||
const sourceHelper = join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh');
|
||||
const installedTools = join(mosaicHome, 'TOOLS.md');
|
||||
const helperDirectory = join(mosaicHome, 'tools', 'tmux');
|
||||
const installedHelper = join(helperDirectory, 'agent-send.sh');
|
||||
let stagedBackup: string | undefined;
|
||||
let stagedTools: string | undefined;
|
||||
let stagedHelper: string | undefined;
|
||||
let rollbackTools: string | undefined;
|
||||
let rollbackHelper: string | undefined;
|
||||
let committedBackup = false;
|
||||
let committedTools = false;
|
||||
let committedHelper = false;
|
||||
let createdHome = false;
|
||||
let createdToolsDirectory = false;
|
||||
let createdHelperDirectory = false;
|
||||
let backupPath: string | undefined;
|
||||
let toolsOriginal: ManagedOriginal | undefined;
|
||||
let helperOriginal: ManagedOriginal | undefined;
|
||||
try {
|
||||
const sourceToolsSnapshot = readRegularFileSecure(sourceTools, { root: frameworkRoot });
|
||||
const sourceHelperSnapshot = readRegularFileSecure(sourceHelper, {
|
||||
root: frameworkRoot,
|
||||
executable: true,
|
||||
});
|
||||
if (!sourceToolsSnapshot.content.includes('<!-- fleet-comms-contract: 1 -->')) {
|
||||
return { ok: false, changed: false, reason: 'bundled TOOLS contract has wrong version' };
|
||||
}
|
||||
|
||||
assertCanonicalContainment(mosaicHome, installedTools);
|
||||
assertCanonicalContainment(mosaicHome, installedHelper);
|
||||
assertNoSymlinkAncestors(mosaicHome);
|
||||
const homeExisted = existsSync(mosaicHome);
|
||||
const toolsDirectory = dirname(helperDirectory);
|
||||
const toolsDirectoryExisted = existsSync(toolsDirectory);
|
||||
const helperDirectoryExisted = existsSync(helperDirectory);
|
||||
if (homeExisted) {
|
||||
const homeStat = lstatSync(mosaicHome);
|
||||
if (homeStat.isSymbolicLink()) {
|
||||
throw new Error(`managed root is a symbolic link: ${mosaicHome}`);
|
||||
}
|
||||
if (!homeStat.isDirectory()) {
|
||||
throw new Error(`managed root is not a real directory: ${mosaicHome}`);
|
||||
}
|
||||
}
|
||||
|
||||
const installedToolsSnapshot = homeExisted
|
||||
? optionalSecureFile(installedTools, mosaicHome)
|
||||
: undefined;
|
||||
let installedHelperSnapshot: ReturnType<typeof readRegularFileSecure> | undefined;
|
||||
let installedHelperExecutable = false;
|
||||
if (homeExisted) {
|
||||
try {
|
||||
installedHelperSnapshot = readRegularFileSecure(installedHelper, {
|
||||
root: mosaicHome,
|
||||
executable: true,
|
||||
});
|
||||
installedHelperExecutable = true;
|
||||
} catch (error) {
|
||||
if (!isNodeErrorCode(error, 'ENOENT') && !isNodeErrorCode(error, 'EACCES')) throw error;
|
||||
if (isNodeErrorCode(error, 'EACCES')) {
|
||||
installedHelperSnapshot = optionalSecureFile(installedHelper, mosaicHome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toolsChanged = !installedToolsSnapshot?.content.equals(sourceToolsSnapshot.content);
|
||||
const helperChanged =
|
||||
!installedHelperExecutable ||
|
||||
!installedHelperSnapshot?.content.equals(sourceHelperSnapshot.content);
|
||||
if (!toolsChanged && !helperChanged) return { ok: true, changed: false };
|
||||
|
||||
toolsOriginal = { path: installedTools, snapshot: installedToolsSnapshot };
|
||||
helperOriginal = { path: installedHelper, snapshot: installedHelperSnapshot };
|
||||
|
||||
ensureManagedDirectory(dirname(mosaicHome), mosaicHome);
|
||||
createdHome = !homeExisted;
|
||||
ensureManagedDirectory(mosaicHome, helperDirectory);
|
||||
createdToolsDirectory = !toolsDirectoryExisted;
|
||||
createdHelperDirectory = !helperDirectoryExisted;
|
||||
|
||||
if (toolsChanged) {
|
||||
stagedTools = stageManagedFile(
|
||||
mosaicHome,
|
||||
mosaicHome,
|
||||
installedTools,
|
||||
sourceToolsSnapshot.content,
|
||||
sourceToolsSnapshot.mode & 0o777,
|
||||
);
|
||||
if (installedToolsSnapshot) {
|
||||
const digest = createHash('sha256')
|
||||
.update(installedToolsSnapshot.content)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
backupPath = `${installedTools}.pre-fleet-comms-${digest}.bak`;
|
||||
const existingBackup = optionalSecureFile(backupPath, mosaicHome);
|
||||
if (existingBackup && !existingBackup.content.equals(installedToolsSnapshot.content)) {
|
||||
throw new Error(`digest-qualified backup collision at ${backupPath}`);
|
||||
}
|
||||
if (!existingBackup) {
|
||||
stagedBackup = stageManagedFile(
|
||||
mosaicHome,
|
||||
mosaicHome,
|
||||
backupPath,
|
||||
installedToolsSnapshot.content,
|
||||
installedToolsSnapshot.mode & 0o777,
|
||||
);
|
||||
}
|
||||
rollbackTools = stageManagedFile(
|
||||
mosaicHome,
|
||||
mosaicHome,
|
||||
installedTools,
|
||||
installedToolsSnapshot.content,
|
||||
installedToolsSnapshot.mode & 0o777,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (helperChanged) {
|
||||
stagedHelper = stageManagedFile(
|
||||
mosaicHome,
|
||||
helperDirectory,
|
||||
installedHelper,
|
||||
sourceHelperSnapshot.content,
|
||||
sourceHelperSnapshot.mode & 0o777,
|
||||
);
|
||||
if (installedHelperSnapshot) {
|
||||
rollbackHelper = stageManagedFile(
|
||||
mosaicHome,
|
||||
helperDirectory,
|
||||
installedHelper,
|
||||
installedHelperSnapshot.content,
|
||||
installedHelperSnapshot.mode & 0o777,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
|
||||
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
|
||||
if (stagedBackup && backupPath) {
|
||||
hooks.beforeCommit?.('backup');
|
||||
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
|
||||
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
|
||||
installBackupNoClobber(stagedBackup, backupPath, mosaicHome);
|
||||
stagedBackup = undefined;
|
||||
committedBackup = true;
|
||||
}
|
||||
if (stagedTools) {
|
||||
hooks.beforeCommit?.('tools');
|
||||
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
|
||||
atomicInstall(stagedTools, installedTools, mosaicHome);
|
||||
stagedTools = undefined;
|
||||
committedTools = true;
|
||||
}
|
||||
if (stagedHelper) {
|
||||
hooks.beforeCommit?.('helper');
|
||||
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
|
||||
atomicInstall(stagedHelper, installedHelper, mosaicHome);
|
||||
stagedHelper = undefined;
|
||||
committedHelper = true;
|
||||
}
|
||||
if (rollbackTools) unlinkSync(rollbackTools);
|
||||
if (rollbackHelper) unlinkSync(rollbackHelper);
|
||||
return { ok: true, changed: true, backupPath };
|
||||
} catch (error) {
|
||||
const failures: string[] = [];
|
||||
try {
|
||||
if (committedHelper) {
|
||||
if (rollbackHelper) atomicInstall(rollbackHelper, installedHelper, mosaicHome);
|
||||
else unlinkSync(installedHelper);
|
||||
rollbackHelper = undefined;
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
failures.push(`helper rollback failed: ${String(rollbackError)}`);
|
||||
}
|
||||
try {
|
||||
if (committedTools) {
|
||||
if (rollbackTools) atomicInstall(rollbackTools, installedTools, mosaicHome);
|
||||
else unlinkSync(installedTools);
|
||||
rollbackTools = undefined;
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
failures.push(`TOOLS rollback failed: ${String(rollbackError)}`);
|
||||
}
|
||||
try {
|
||||
if (committedBackup && backupPath) {
|
||||
unlinkSync(backupPath);
|
||||
committedBackup = false;
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
failures.push(`backup rollback failed: ${String(rollbackError)}`);
|
||||
}
|
||||
for (const staged of [stagedBackup, stagedTools, stagedHelper, rollbackTools, rollbackHelper]) {
|
||||
if (!staged) continue;
|
||||
try {
|
||||
unlinkSync(staged);
|
||||
} catch {
|
||||
failures.push(`staging cleanup failed: ${staged}`);
|
||||
}
|
||||
}
|
||||
for (const [created, directory] of [
|
||||
[createdHelperDirectory, helperDirectory],
|
||||
[createdToolsDirectory, dirname(helperDirectory)],
|
||||
[createdHome, mosaicHome],
|
||||
] as const) {
|
||||
if (!created) continue;
|
||||
try {
|
||||
rmdirSync(directory);
|
||||
} catch (cleanupError) {
|
||||
if (!isNodeErrorCode(cleanupError, 'ENOENT')) {
|
||||
failures.push(`directory cleanup failed: ${directory}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
ok: false,
|
||||
changed: failures.length > 0,
|
||||
backupPath: committedBackup ? backupPath : undefined,
|
||||
reason: failures.length > 0 ? `${reason}; ${failures.join('; ')}` : reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seed the framework from the freshly-installed package. Returns a result
|
||||
* describing what happened (so callers can message + decide on relaunch).
|
||||
* Best-effort: a missing installer or a non-zero exit is reported, not thrown.
|
||||
*/
|
||||
export interface FrameworkReseedResult {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
skillSync?: SkillSyncResult;
|
||||
skillSyncError?: string;
|
||||
}
|
||||
|
||||
export function runFrameworkReseed(
|
||||
frameworkRoot = resolveBundledFrameworkRoot(),
|
||||
mosaicHome = join(homedir(), '.config', 'mosaic'),
|
||||
): { ok: boolean; reason?: string } {
|
||||
claudeSkillsDir = getDefaultSkillPaths().claudeSkillsDir,
|
||||
): FrameworkReseedResult {
|
||||
const { installer, command, env } = buildReseedCommand(frameworkRoot, mosaicHome);
|
||||
if (!existsSync(installer)) {
|
||||
return { ok: false, reason: `installer not found: ${installer}` };
|
||||
}
|
||||
try {
|
||||
execSync(command, { stdio: 'inherit', env: { ...process.env, ...env }, timeout: 120_000 });
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
||||
} catch (error: unknown) {
|
||||
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
||||
try {
|
||||
const skillSync = syncClaudeSkills({
|
||||
mosaicSkillsDir: join(mosaicHome, 'skills'),
|
||||
claudeSkillsDir,
|
||||
});
|
||||
return { ok: true, skillSync };
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
ok: true,
|
||||
skillSyncError: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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) ────────────────────────────────────────
|
||||
@@ -591,25 +1153,20 @@ export function checkFrameworkDrift(
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort parse of the fleet roster for agent names (used to relaunch
|
||||
* durable agents after a re-seed). Returns [] when no roster exists.
|
||||
* Canonically parse the installed fleet roster for relaunch targets. JSON is
|
||||
* considered only when roster.yaml is genuinely absent; all other failures
|
||||
* return no targets rather than guessing.
|
||||
*/
|
||||
export function readRosterAgentNames(mosaicHome = join(homedir(), '.config', 'mosaic')): string[] {
|
||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
if (!existsSync(rosterPath)) return [];
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(rosterPath, 'utf-8');
|
||||
const rosterPath = resolveInstalledFleetRosterPath(mosaicHome);
|
||||
const source = readFileSync(rosterPath, 'utf8');
|
||||
return parseFleetRosterV1(source, rosterPath.endsWith('.json') ? 'json' : 'yaml').agents.map(
|
||||
(agent) => agent.name,
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
// Roster agents are listed as `- name: <id>` entries under `agents:`.
|
||||
const names: string[] = [];
|
||||
for (const line of text.split('\n')) {
|
||||
const m = line.match(/^\s*-?\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/);
|
||||
if (m && m[1]) names.push(m[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user