chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { platform } from 'node:os';
|
||||
import type { RuntimeName } from '../types.js';
|
||||
|
||||
export interface RuntimeInfo {
|
||||
name: RuntimeName;
|
||||
label: string;
|
||||
installed: boolean;
|
||||
path?: string;
|
||||
version?: string;
|
||||
installHint: string;
|
||||
}
|
||||
|
||||
const RUNTIME_DEFS: Record<
|
||||
RuntimeName,
|
||||
{ label: string; command: string; versionFlag: string; installHint: string }
|
||||
> = {
|
||||
claude: {
|
||||
label: 'Claude Code',
|
||||
command: 'claude',
|
||||
versionFlag: '--version',
|
||||
installHint: 'npm install -g @anthropic-ai/claude-code',
|
||||
},
|
||||
codex: {
|
||||
label: 'Codex',
|
||||
command: 'codex',
|
||||
versionFlag: '--version',
|
||||
installHint: 'npm install -g @openai/codex',
|
||||
},
|
||||
opencode: {
|
||||
label: 'OpenCode',
|
||||
command: 'opencode',
|
||||
versionFlag: 'version',
|
||||
installHint: 'See https://opencode.ai for install instructions',
|
||||
},
|
||||
pi: {
|
||||
label: 'Pi',
|
||||
command: 'pi',
|
||||
versionFlag: '--version',
|
||||
installHint: 'curl -fsSL https://pi.dev/install.sh | sh',
|
||||
},
|
||||
};
|
||||
|
||||
export function detectRuntime(name: RuntimeName): RuntimeInfo {
|
||||
const def = RUNTIME_DEFS[name];
|
||||
const isWindows = platform() === 'win32';
|
||||
const whichCmd = isWindows ? `where ${def.command} 2>nul` : `which ${def.command} 2>/dev/null`;
|
||||
|
||||
try {
|
||||
const pathOutput =
|
||||
execSync(whichCmd, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
})
|
||||
.trim()
|
||||
.split('\n')[0] ?? '';
|
||||
|
||||
let version: string | undefined;
|
||||
try {
|
||||
version = execSync(`${def.command} ${def.versionFlag} 2>/dev/null`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
} catch {
|
||||
// Version detection is optional
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
label: def.label,
|
||||
installed: true,
|
||||
path: pathOutput,
|
||||
version,
|
||||
installHint: def.installHint,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
name,
|
||||
label: def.label,
|
||||
installed: false,
|
||||
installHint: def.installHint,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getInstallInstructions(name: RuntimeName): string {
|
||||
return RUNTIME_DEFS[name].installHint;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
createManifest,
|
||||
readManifest,
|
||||
writeManifest,
|
||||
manifestPath,
|
||||
heuristicRuntimeAssetDests,
|
||||
DEFAULT_SCOPE_LINE,
|
||||
MANIFEST_VERSION,
|
||||
} from './install-manifest.js';
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-manifest-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── createManifest ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('createManifest', () => {
|
||||
it('creates a valid manifest with version 1', () => {
|
||||
const m = createManifest('0.0.24', 2);
|
||||
expect(m.version).toBe(MANIFEST_VERSION);
|
||||
expect(m.cliVersion).toBe('0.0.24');
|
||||
expect(m.frameworkVersion).toBe(2);
|
||||
});
|
||||
|
||||
it('sets installedAt to an ISO-8601 date string', () => {
|
||||
const before = new Date();
|
||||
const m = createManifest('0.0.24', 2);
|
||||
const after = new Date();
|
||||
const ts = new Date(m.installedAt);
|
||||
expect(ts.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
expect(ts.getTime()).toBeLessThanOrEqual(after.getTime());
|
||||
});
|
||||
|
||||
it('starts with empty mutation arrays', () => {
|
||||
const m = createManifest('0.0.24', 2);
|
||||
expect(m.mutations.directories).toHaveLength(0);
|
||||
expect(m.mutations.npmGlobalPackages).toHaveLength(0);
|
||||
expect(m.mutations.npmrcLines).toHaveLength(0);
|
||||
expect(m.mutations.shellProfileEdits).toHaveLength(0);
|
||||
expect(m.mutations.runtimeAssetCopies).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('merges partial mutations', () => {
|
||||
const m = createManifest('0.0.24', 2, {
|
||||
npmGlobalPackages: ['@mosaicstack/mosaic'],
|
||||
});
|
||||
expect(m.mutations.npmGlobalPackages).toEqual(['@mosaicstack/mosaic']);
|
||||
expect(m.mutations.directories).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── manifestPath ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('manifestPath', () => {
|
||||
it('returns mosaicHome/.install-manifest.json', () => {
|
||||
const p = manifestPath('/home/user/.config/mosaic');
|
||||
expect(p).toBe('/home/user/.config/mosaic/.install-manifest.json');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── writeManifest / readManifest round-trip ─────────────────────────────────
|
||||
|
||||
describe('writeManifest + readManifest', () => {
|
||||
it('round-trips a manifest through disk', () => {
|
||||
const m = createManifest('0.0.24', 2, {
|
||||
npmGlobalPackages: ['@mosaicstack/mosaic'],
|
||||
npmrcLines: [DEFAULT_SCOPE_LINE],
|
||||
});
|
||||
|
||||
writeManifest(tmpDir, m);
|
||||
const loaded = readManifest(tmpDir);
|
||||
|
||||
expect(loaded).toBeDefined();
|
||||
expect(loaded!.version).toBe(1);
|
||||
expect(loaded!.cliVersion).toBe('0.0.24');
|
||||
expect(loaded!.mutations.npmGlobalPackages).toEqual(['@mosaicstack/mosaic']);
|
||||
expect(loaded!.mutations.npmrcLines).toEqual([DEFAULT_SCOPE_LINE]);
|
||||
});
|
||||
|
||||
it('preserves runtimeAssetCopies with backup path', () => {
|
||||
const m = createManifest('0.0.24', 2, {
|
||||
runtimeAssetCopies: [
|
||||
{
|
||||
source: '/src/settings.json',
|
||||
dest: '/home/user/.claude/settings.json',
|
||||
backup: '/home/user/.claude/settings.json.mosaic-bak-20260405120000',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
writeManifest(tmpDir, m);
|
||||
const loaded = readManifest(tmpDir);
|
||||
|
||||
const copies = loaded!.mutations.runtimeAssetCopies;
|
||||
expect(copies).toHaveLength(1);
|
||||
expect(copies[0]!.backup).toBe('/home/user/.claude/settings.json.mosaic-bak-20260405120000');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── readManifest — missing / invalid ────────────────────────────────────────
|
||||
|
||||
describe('readManifest error cases', () => {
|
||||
it('returns undefined when the file does not exist', () => {
|
||||
expect(readManifest('/nonexistent/path')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the file contains invalid JSON', () => {
|
||||
const { writeFileSync } = require('node:fs');
|
||||
writeFileSync(join(tmpDir, '.install-manifest.json'), 'not json', 'utf8');
|
||||
expect(readManifest(tmpDir)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when version field is wrong', () => {
|
||||
const { writeFileSync } = require('node:fs');
|
||||
writeFileSync(
|
||||
join(tmpDir, '.install-manifest.json'),
|
||||
JSON.stringify({
|
||||
version: 99,
|
||||
installedAt: new Date().toISOString(),
|
||||
cliVersion: '1',
|
||||
frameworkVersion: 1,
|
||||
mutations: {},
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
expect(readManifest(tmpDir)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── heuristicRuntimeAssetDests ──────────────────────────────────────────────
|
||||
|
||||
describe('heuristicRuntimeAssetDests', () => {
|
||||
it('returns a non-empty list of absolute paths', () => {
|
||||
const dests = heuristicRuntimeAssetDests('/home/user');
|
||||
expect(dests.length).toBeGreaterThan(0);
|
||||
for (const d of dests) {
|
||||
expect(d).toMatch(/^\/home\/user\//);
|
||||
}
|
||||
});
|
||||
|
||||
it('includes the claude settings.json path', () => {
|
||||
const dests = heuristicRuntimeAssetDests('/home/user');
|
||||
expect(dests).toContain('/home/user/.claude/settings.json');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DEFAULT_SCOPE_LINE ───────────────────────────────────────────────────────
|
||||
|
||||
describe('DEFAULT_SCOPE_LINE', () => {
|
||||
it('contains the mosaicstack registry URL', () => {
|
||||
expect(DEFAULT_SCOPE_LINE).toContain('mosaicstack');
|
||||
expect(DEFAULT_SCOPE_LINE).toContain('@mosaicstack:registry=');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* install-manifest.ts
|
||||
*
|
||||
* Read/write helpers for ~/.config/mosaic/.install-manifest.json
|
||||
*
|
||||
* The manifest is the authoritative record of what the installer mutated on the
|
||||
* host system so that `mosaic uninstall` can precisely reverse every change.
|
||||
* If the manifest is absent the uninstaller falls back to heuristic mode and
|
||||
* warns the user.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, chmodSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const MANIFEST_FILENAME = '.install-manifest.json';
|
||||
export const MANIFEST_VERSION = 1;
|
||||
|
||||
/** A single runtime asset copy recorded during install. */
|
||||
export interface RuntimeAssetCopy {
|
||||
/** Absolute path to the source file in MOSAIC_HOME (or the npm package). */
|
||||
source: string;
|
||||
/** Absolute path to the destination on the host. */
|
||||
dest: string;
|
||||
/**
|
||||
* Absolute path to the backup that was created when an existing file was
|
||||
* displaced. Undefined when no pre-existing file was found.
|
||||
*/
|
||||
backup?: string;
|
||||
}
|
||||
|
||||
/** The full shape of the install manifest (version 1). */
|
||||
export interface InstallManifest {
|
||||
version: 1;
|
||||
/** ISO-8601 timestamp of when the install completed. */
|
||||
installedAt: string;
|
||||
/** Version of @mosaicstack/mosaic that was installed. */
|
||||
cliVersion: string;
|
||||
/** Framework schema version (integer) that was installed. */
|
||||
frameworkVersion: number;
|
||||
mutations: {
|
||||
/** Directories that were created by the installer. */
|
||||
directories: string[];
|
||||
/** npm global packages that were installed. */
|
||||
npmGlobalPackages: string[];
|
||||
/**
|
||||
* Exact lines that were appended to ~/.npmrc.
|
||||
* Each entry is the full line text (no trailing newline).
|
||||
*/
|
||||
npmrcLines: string[];
|
||||
/**
|
||||
* Shell profile edits — each entry is an object recording which file was
|
||||
* edited and what line was appended.
|
||||
*/
|
||||
shellProfileEdits: Array<{ file: string; line: string }>;
|
||||
/** Runtime asset copies performed by mosaic-link-runtime-assets. */
|
||||
runtimeAssetCopies: RuntimeAssetCopy[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Default empty mutations block. */
|
||||
function emptyMutations(): InstallManifest['mutations'] {
|
||||
return {
|
||||
directories: [],
|
||||
npmGlobalPackages: [],
|
||||
npmrcLines: [],
|
||||
shellProfileEdits: [],
|
||||
runtimeAssetCopies: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a new manifest with sensible defaults.
|
||||
* Callers fill in the mutation fields before persisting.
|
||||
*/
|
||||
export function createManifest(
|
||||
cliVersion: string,
|
||||
frameworkVersion: number,
|
||||
partial?: Partial<InstallManifest['mutations']>,
|
||||
): InstallManifest {
|
||||
return {
|
||||
version: MANIFEST_VERSION,
|
||||
installedAt: new Date().toISOString(),
|
||||
cliVersion,
|
||||
frameworkVersion,
|
||||
mutations: { ...emptyMutations(), ...partial },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the absolute path to the manifest file.
|
||||
*/
|
||||
export function manifestPath(mosaicHome: string): string {
|
||||
return join(mosaicHome, MANIFEST_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the manifest from disk.
|
||||
* Returns `undefined` if the file does not exist or cannot be parsed.
|
||||
* Never throws — callers decide how to handle heuristic-fallback mode.
|
||||
*/
|
||||
export function readManifest(mosaicHome: string): InstallManifest | undefined {
|
||||
const p = manifestPath(mosaicHome);
|
||||
if (!existsSync(p)) return undefined;
|
||||
try {
|
||||
const raw = readFileSync(p, 'utf8');
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!isValidManifest(parsed)) return undefined;
|
||||
return parsed;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the manifest to disk with mode 0600 (owner read/write only).
|
||||
* Creates the mosaicHome directory if it does not exist.
|
||||
*/
|
||||
export function writeManifest(mosaicHome: string, manifest: InstallManifest): void {
|
||||
const p = manifestPath(mosaicHome);
|
||||
const json = JSON.stringify(manifest, null, 2) + '\n';
|
||||
writeFileSync(p, json, { encoding: 'utf8' });
|
||||
try {
|
||||
chmodSync(p, 0o600);
|
||||
} catch {
|
||||
// chmod may fail on some systems (e.g. Windows); non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an unknown value to InstallManifest.
|
||||
* Only checks the minimum structure; does not validate every field.
|
||||
*/
|
||||
function isValidManifest(v: unknown): v is InstallManifest {
|
||||
if (typeof v !== 'object' || v === null) return false;
|
||||
const m = v as Record<string, unknown>;
|
||||
if (m['version'] !== 1) return false;
|
||||
if (typeof m['installedAt'] !== 'string') return false;
|
||||
if (typeof m['cliVersion'] !== 'string') return false;
|
||||
if (typeof m['frameworkVersion'] !== 'number') return false;
|
||||
if (typeof m['mutations'] !== 'object' || m['mutations'] === null) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The known set of runtime asset destinations managed by
|
||||
* mosaic-link-runtime-assets / framework/install.sh.
|
||||
*
|
||||
* Used by heuristic mode when no manifest is available.
|
||||
*/
|
||||
export function heuristicRuntimeAssetDests(homeDir: string): string[] {
|
||||
return [
|
||||
join(homeDir, '.claude', 'CLAUDE.md'),
|
||||
join(homeDir, '.claude', 'settings.json'),
|
||||
join(homeDir, '.claude', 'hooks-config.json'),
|
||||
join(homeDir, '.claude', 'context7-integration.md'),
|
||||
join(homeDir, '.config', 'opencode', 'AGENTS.md'),
|
||||
join(homeDir, '.codex', 'instructions.md'),
|
||||
];
|
||||
}
|
||||
|
||||
/** The npmrc scope line added by tools/install.sh. */
|
||||
export const DEFAULT_SCOPE_LINE =
|
||||
'@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/';
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { RuntimeName } from '../types.js';
|
||||
import { getInstallInstructions } from './detector.js';
|
||||
|
||||
export function formatInstallInstructions(name: RuntimeName): string {
|
||||
const hint = getInstallInstructions(name);
|
||||
const labels: Record<RuntimeName, string> = {
|
||||
claude: 'Claude Code',
|
||||
codex: 'Codex',
|
||||
opencode: 'OpenCode',
|
||||
pi: 'Pi',
|
||||
};
|
||||
return `To install ${labels[name]}:\n ${hint}`;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { RuntimeName } from '../types.js';
|
||||
|
||||
const MCP_ENTRY = {
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
||||
};
|
||||
|
||||
export function configureMcpForRuntime(runtime: RuntimeName): void {
|
||||
switch (runtime) {
|
||||
case 'claude':
|
||||
return configureClaudeMcp();
|
||||
case 'codex':
|
||||
return configureCodexMcp();
|
||||
case 'opencode':
|
||||
return configureOpenCodeMcp();
|
||||
case 'pi':
|
||||
return configurePiMcp();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDir(filePath: string): void {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
}
|
||||
|
||||
function configureClaudeMcp(): void {
|
||||
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
||||
ensureDir(settingsPath);
|
||||
|
||||
let data: Record<string, unknown> = {};
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
data = JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Start fresh if corrupt
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!data['mcpServers'] ||
|
||||
typeof data['mcpServers'] !== 'object' ||
|
||||
Array.isArray(data['mcpServers'])
|
||||
) {
|
||||
data['mcpServers'] = {};
|
||||
}
|
||||
(data['mcpServers'] as Record<string, unknown>)['sequential-thinking'] = MCP_ENTRY;
|
||||
|
||||
writeFileSync(settingsPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
function configureCodexMcp(): void {
|
||||
const configPath = join(homedir(), '.codex', 'config.toml');
|
||||
ensureDir(configPath);
|
||||
|
||||
let content = '';
|
||||
if (existsSync(configPath)) {
|
||||
content = readFileSync(configPath, 'utf-8');
|
||||
// Remove existing sequential-thinking section
|
||||
content = content
|
||||
.replace(/\[mcp_servers\.(sequential-thinking|sequential_thinking)\][\s\S]*?(?=\n\[|$)/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
content +=
|
||||
'\n\n[mcp_servers.sequential-thinking]\n' +
|
||||
'command = "npx"\n' +
|
||||
'args = ["-y", "@modelcontextprotocol/server-sequential-thinking"]\n';
|
||||
|
||||
writeFileSync(configPath, content, 'utf-8');
|
||||
}
|
||||
|
||||
function configurePiMcp(): void {
|
||||
const settingsPath = join(homedir(), '.pi', 'agent', 'settings.json');
|
||||
ensureDir(settingsPath);
|
||||
|
||||
let data: Record<string, unknown> = {};
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
data = JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Start fresh if corrupt
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!data['mcpServers'] ||
|
||||
typeof data['mcpServers'] !== 'object' ||
|
||||
Array.isArray(data['mcpServers'])
|
||||
) {
|
||||
data['mcpServers'] = {};
|
||||
}
|
||||
(data['mcpServers'] as Record<string, unknown>)['sequential-thinking'] = MCP_ENTRY;
|
||||
|
||||
writeFileSync(settingsPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
function configureOpenCodeMcp(): void {
|
||||
const configPath = join(homedir(), '.config', 'opencode', 'config.json');
|
||||
ensureDir(configPath);
|
||||
|
||||
let data: Record<string, unknown> = {};
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
data = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Start fresh
|
||||
}
|
||||
}
|
||||
|
||||
if (!data['mcp'] || typeof data['mcp'] !== 'object' || Array.isArray(data['mcp'])) {
|
||||
data['mcp'] = {};
|
||||
}
|
||||
(data['mcp'] as Record<string, unknown>)['sequential-thinking'] = {
|
||||
type: 'local',
|
||||
command: ['npx', '-y', '@modelcontextprotocol/server-sequential-thinking'],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
writeFileSync(configPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
interface FakeEntry {
|
||||
type: string;
|
||||
customType?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface SentMessage {
|
||||
message: {
|
||||
customType: string;
|
||||
content: string;
|
||||
display: boolean;
|
||||
};
|
||||
options?: {
|
||||
triggerTurn?: boolean;
|
||||
deliverAs?: 'steer' | 'followUp' | 'nextTurn';
|
||||
};
|
||||
}
|
||||
|
||||
interface FakeContext {
|
||||
cwd: string;
|
||||
ui: {
|
||||
notifications: Array<{ message: string; level?: string }>;
|
||||
statuses: Map<string, string | undefined>;
|
||||
notify(message: string, level?: string): void;
|
||||
setStatus(key: string, value: string | undefined): void;
|
||||
};
|
||||
sessionManager: {
|
||||
getBranch(): FakeEntry[];
|
||||
};
|
||||
isIdle(): boolean;
|
||||
hasPendingMessages(): boolean;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
type EventHandler = (
|
||||
event: Record<string, unknown>,
|
||||
context: FakeContext,
|
||||
) => unknown | Promise<unknown>;
|
||||
|
||||
type CommandHandler = (args: string, context: FakeContext) => unknown | Promise<unknown>;
|
||||
|
||||
interface FakeToolResult {
|
||||
content: Array<{ type: string; text: string }>;
|
||||
details?: unknown;
|
||||
terminate?: boolean;
|
||||
}
|
||||
|
||||
interface FakeTool {
|
||||
name: string;
|
||||
execute(
|
||||
toolCallId: string,
|
||||
params: Record<string, unknown>,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: undefined,
|
||||
context: FakeContext,
|
||||
): Promise<FakeToolResult>;
|
||||
}
|
||||
|
||||
interface GoalExtensionFactory {
|
||||
(api: FakePiApi): void;
|
||||
}
|
||||
|
||||
interface GoalExtensionModule {
|
||||
default: GoalExtensionFactory;
|
||||
}
|
||||
|
||||
interface FakePiApi {
|
||||
on(event: string, handler: EventHandler): void;
|
||||
registerCommand(name: string, options: { description: string; handler: CommandHandler }): void;
|
||||
registerTool(tool: FakeTool): void;
|
||||
appendEntry(customType: string, data?: unknown): void;
|
||||
sendMessage(message: SentMessage['message'], options?: SentMessage['options']): void;
|
||||
}
|
||||
|
||||
function isGoalExtensionModule(value: unknown): value is GoalExtensionModule {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
return typeof Reflect.get(value, 'default') === 'function';
|
||||
}
|
||||
|
||||
const goalExtensionUrl = new URL('../../framework/runtime/pi/goal-extension.ts', import.meta.url)
|
||||
.href;
|
||||
const importedGoalExtension: unknown = await import(goalExtensionUrl);
|
||||
if (!isGoalExtensionModule(importedGoalExtension)) {
|
||||
throw new Error('Pi goal extension must export a default registration function');
|
||||
}
|
||||
const registerGoalExtension = importedGoalExtension.default;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
class FakePi {
|
||||
readonly handlers = new Map<string, EventHandler[]>();
|
||||
readonly commands = new Map<string, CommandHandler>();
|
||||
readonly tools = new Map<string, FakeTool>();
|
||||
readonly entries: FakeEntry[] = [];
|
||||
readonly sentMessages: SentMessage[] = [];
|
||||
readonly notifications: Array<{ message: string; level?: string }> = [];
|
||||
readonly statuses = new Map<string, string | undefined>();
|
||||
branch: FakeEntry[] = [];
|
||||
idle = true;
|
||||
pending = false;
|
||||
abortCount = 0;
|
||||
|
||||
readonly context: FakeContext = {
|
||||
cwd: '/tmp/project',
|
||||
ui: {
|
||||
notifications: this.notifications,
|
||||
statuses: this.statuses,
|
||||
notify: (message: string, level?: string): void => {
|
||||
this.notifications.push({ message, level });
|
||||
},
|
||||
setStatus: (key: string, value: string | undefined): void => {
|
||||
this.statuses.set(key, value);
|
||||
},
|
||||
},
|
||||
sessionManager: {
|
||||
getBranch: (): FakeEntry[] => [...this.branch],
|
||||
},
|
||||
isIdle: (): boolean => this.idle,
|
||||
hasPendingMessages: (): boolean => this.pending,
|
||||
abort: (): void => {
|
||||
this.abortCount += 1;
|
||||
},
|
||||
};
|
||||
|
||||
readonly api: FakePiApi = {
|
||||
on: (event: string, handler: EventHandler): void => {
|
||||
this.handlers.set(event, [...(this.handlers.get(event) ?? []), handler]);
|
||||
},
|
||||
registerCommand: (
|
||||
name: string,
|
||||
options: { description: string; handler: CommandHandler },
|
||||
): void => {
|
||||
this.commands.set(name, options.handler);
|
||||
},
|
||||
registerTool: (tool: FakeTool): void => {
|
||||
this.tools.set(tool.name, tool);
|
||||
},
|
||||
appendEntry: (customType: string, data?: unknown): void => {
|
||||
const entry: FakeEntry = { type: 'custom', customType, data };
|
||||
this.entries.push(entry);
|
||||
this.branch.push(entry);
|
||||
},
|
||||
sendMessage: (message: SentMessage['message'], options?: SentMessage['options']): void => {
|
||||
this.sentMessages.push({ message, options });
|
||||
},
|
||||
};
|
||||
|
||||
constructor(initialBranch: FakeEntry[] = []) {
|
||||
this.branch = [...initialBranch];
|
||||
registerGoalExtension(this.api);
|
||||
}
|
||||
|
||||
async emit(event: string, value: Record<string, unknown> = {}): Promise<unknown[]> {
|
||||
const results: unknown[] = [];
|
||||
for (const handler of this.handlers.get(event) ?? []) {
|
||||
results.push(await handler(value, this.context));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async goal(args: string): Promise<void> {
|
||||
const handler = this.commands.get('goal');
|
||||
if (handler === undefined) throw new Error('/goal was not registered');
|
||||
await handler(args, this.context);
|
||||
}
|
||||
|
||||
async report(params: Record<string, unknown>): Promise<FakeToolResult> {
|
||||
const tool = this.tools.get('mosaic_goal_report');
|
||||
if (tool === undefined) throw new Error('mosaic_goal_report was not registered');
|
||||
return await tool.execute('goal-report-1', params, undefined, undefined, this.context);
|
||||
}
|
||||
}
|
||||
|
||||
function latestGoalStateData(pi: FakePi): Record<string, unknown> {
|
||||
for (let index = pi.entries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = pi.entries[index];
|
||||
if (entry?.customType === 'mosaic-goal-state' && isRecord(entry.data)) return entry.data;
|
||||
}
|
||||
throw new Error('No persisted Mosaic goal state found');
|
||||
}
|
||||
|
||||
function stateField(pi: FakePi, field: string): unknown {
|
||||
return latestGoalStateData(pi)[field];
|
||||
}
|
||||
|
||||
function activeGoalStatementFromContext(result: unknown): string {
|
||||
if (!isRecord(result)) throw new Error('Context handler did not return an object');
|
||||
const messages = result['messages'];
|
||||
if (!Array.isArray(messages)) throw new Error('Context result did not include messages');
|
||||
const goalMessage = messages.find(
|
||||
(message: unknown): boolean =>
|
||||
isRecord(message) && message['customType'] === 'mosaic-goal-context',
|
||||
);
|
||||
if (!isRecord(goalMessage) || typeof goalMessage['content'] !== 'string') {
|
||||
throw new Error('Goal context message was not injected');
|
||||
}
|
||||
return goalMessage['content'];
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('Mosaic Pi goal extension commands', () => {
|
||||
it('shows help and handles controls safely when no goal exists', async () => {
|
||||
const pi = new FakePi();
|
||||
|
||||
await pi.goal('');
|
||||
expect(pi.notifications.at(-1)?.message).toContain('/goal set');
|
||||
await pi.goal('status');
|
||||
expect(pi.notifications.at(-1)?.message).toContain('No Mosaic goal is set');
|
||||
|
||||
for (const command of ['pause', 'resume', 'cancel']) {
|
||||
await pi.goal(command);
|
||||
expect(pi.notifications.at(-1)?.level).toBe('warning');
|
||||
}
|
||||
expect(pi.entries).toHaveLength(0);
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('sets, reports, pauses, resumes, and cancels a bounded goal', async () => {
|
||||
const pi = new FakePi();
|
||||
|
||||
await pi.goal('set Deliver the local goal extension with tests');
|
||||
expect(stateField(pi, 'phase')).toBe('active');
|
||||
expect(stateField(pi, 'statement')).toBe('Deliver the local goal extension with tests');
|
||||
expect(pi.sentMessages).toHaveLength(1);
|
||||
expect(pi.sentMessages[0]?.options?.triggerTurn).toBe(true);
|
||||
|
||||
await pi.goal('status');
|
||||
expect(pi.notifications.at(-1)?.message).toContain('Deliver the local goal extension');
|
||||
expect(pi.notifications.at(-1)?.message).toContain('active');
|
||||
|
||||
await pi.goal('pause');
|
||||
expect(stateField(pi, 'phase')).toBe('paused');
|
||||
pi.sentMessages.length = 0;
|
||||
await pi.emit('agent_settled');
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
|
||||
await pi.goal('resume');
|
||||
expect(stateField(pi, 'phase')).toBe('active');
|
||||
expect(pi.sentMessages).toHaveLength(1);
|
||||
|
||||
await pi.goal('cancel');
|
||||
expect(stateField(pi, 'phase')).toBe('cancelled');
|
||||
pi.sentMessages.length = 0;
|
||||
await pi.emit('agent_settled');
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts /goal <statement> shorthand but refuses to replace an active goal', async () => {
|
||||
const pi = new FakePi();
|
||||
|
||||
await pi.goal('First goal');
|
||||
const firstGoalId = stateField(pi, 'goalId');
|
||||
await pi.goal('set Second goal');
|
||||
|
||||
expect(stateField(pi, 'goalId')).toBe(firstGoalId);
|
||||
expect(stateField(pi, 'statement')).toBe('First goal');
|
||||
expect(pi.notifications.at(-1)?.level).toBe('warning');
|
||||
expect(pi.notifications.at(-1)?.message).toContain('/goal cancel');
|
||||
});
|
||||
|
||||
it('rejects invalid phase transitions, aborts busy work, and supports clear as cancel', async () => {
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Preserve transition safety');
|
||||
|
||||
await pi.goal('resume');
|
||||
expect(pi.notifications.at(-1)?.message).toContain('cannot be resumed');
|
||||
pi.idle = false;
|
||||
await pi.goal('pause maintenance window');
|
||||
expect(stateField(pi, 'stopReason')).toBe('maintenance window');
|
||||
expect(pi.abortCount).toBe(1);
|
||||
await pi.goal('pause');
|
||||
expect(pi.notifications.at(-1)?.message).toContain('cannot be paused');
|
||||
await pi.goal('clear');
|
||||
expect(stateField(pi, 'phase')).toBe('cancelled');
|
||||
expect(pi.abortCount).toBe(2);
|
||||
});
|
||||
|
||||
it('rejects empty and oversized goal statements without starting a run', async () => {
|
||||
const pi = new FakePi();
|
||||
|
||||
await pi.goal('set');
|
||||
await pi.goal(`set ${'x'.repeat(8_001)}`);
|
||||
|
||||
expect(pi.entries).toHaveLength(0);
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
expect(pi.notifications.at(-1)?.level).toBe('warning');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mosaic Pi goal lifecycle', () => {
|
||||
it('injects one fresh active contract before every model context', async () => {
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Keep the agent oriented');
|
||||
|
||||
const existingGoalContext = {
|
||||
role: 'custom',
|
||||
customType: 'mosaic-goal-context',
|
||||
content: 'stale',
|
||||
};
|
||||
const existingContinuation = {
|
||||
role: 'custom',
|
||||
customType: 'mosaic-goal-continuation',
|
||||
content: 'stale continuation',
|
||||
};
|
||||
const first = await pi.emit('context', {
|
||||
messages: [existingGoalContext, existingContinuation],
|
||||
});
|
||||
const second = await pi.emit('context', { messages: [] });
|
||||
|
||||
expect(activeGoalStatementFromContext(first[0])).toContain('Keep the agent oriented');
|
||||
expect(activeGoalStatementFromContext(first[0])).toContain('mosaic_goal_report');
|
||||
expect(activeGoalStatementFromContext(first[0])).not.toContain('stale');
|
||||
if (!isRecord(first[0]) || !Array.isArray(first[0]['messages'])) {
|
||||
throw new Error('Expected filtered context messages');
|
||||
}
|
||||
expect(first[0]['messages']).toHaveLength(1);
|
||||
expect(activeGoalStatementFromContext(second[0])).toContain('Keep the agent oriented');
|
||||
|
||||
await pi.goal('cancel');
|
||||
expect(
|
||||
await pi.emit('context', {
|
||||
messages: [existingGoalContext, existingContinuation],
|
||||
}),
|
||||
).toEqual([{ messages: [] }]);
|
||||
});
|
||||
|
||||
it('lets an existing busy run adopt the goal and waits behind a pending message', async () => {
|
||||
const busy = new FakePi();
|
||||
busy.idle = false;
|
||||
await busy.goal('set Join the current run safely');
|
||||
expect(busy.sentMessages).toHaveLength(0);
|
||||
|
||||
busy.pending = true;
|
||||
await busy.emit('agent_settled');
|
||||
expect(busy.sentMessages).toHaveLength(0);
|
||||
busy.pending = false;
|
||||
busy.idle = true;
|
||||
await busy.emit('agent_settled');
|
||||
expect(busy.sentMessages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('records every turn and continues once when an active run settles', async () => {
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Finish all acceptance criteria');
|
||||
pi.sentMessages.length = 0;
|
||||
|
||||
await pi.emit('turn_end', { turnIndex: 0, message: {}, toolResults: [] });
|
||||
expect(stateField(pi, 'turnCount')).toBe(1);
|
||||
expect(stateField(pi, 'lastCheckSource')).toBe('turn');
|
||||
|
||||
await pi.emit('agent_settled');
|
||||
await pi.emit('agent_settled');
|
||||
expect(pi.sentMessages).toHaveLength(1);
|
||||
expect(pi.sentMessages[0]?.message.content).toContain('Goal remains active');
|
||||
|
||||
await pi.emit('agent_start');
|
||||
await pi.emit('agent_settled');
|
||||
expect(pi.sentMessages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('requires two consecutive evidence-bearing achievement reports', async () => {
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Prove the feature works');
|
||||
pi.sentMessages.length = 0;
|
||||
|
||||
const first = await pi.report({
|
||||
status: 'achieved',
|
||||
summary: 'Focused tests pass',
|
||||
evidence: ['pnpm test: 12 passed'],
|
||||
});
|
||||
expect(first.terminate).toBe(true);
|
||||
expect(stateField(pi, 'phase')).toBe('verifying');
|
||||
expect(stateField(pi, 'verificationPasses')).toBe(1);
|
||||
|
||||
await pi.emit('agent_settled');
|
||||
expect(pi.sentMessages).toHaveLength(1);
|
||||
expect(pi.sentMessages[0]?.message.content).toContain('verification pass');
|
||||
|
||||
await pi.emit('agent_start');
|
||||
const second = await pi.report({
|
||||
status: 'achieved',
|
||||
summary: 'Independent recheck confirms completion',
|
||||
evidence: ['rerun: 12 passed', 'framework path verified'],
|
||||
});
|
||||
expect(second.terminate).toBe(true);
|
||||
expect(stateField(pi, 'phase')).toBe('achieved');
|
||||
expect(stateField(pi, 'verificationPasses')).toBe(2);
|
||||
|
||||
pi.sentMessages.length = 0;
|
||||
await pi.emit('agent_settled');
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects malformed progress reports and reports submitted without an active goal', async () => {
|
||||
const noGoal = new FakePi();
|
||||
await expect(
|
||||
noGoal.report({ status: 'continue', summary: 'work', evidence: [] }),
|
||||
).rejects.toThrow(/No active Mosaic goal/);
|
||||
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Validate report boundaries');
|
||||
const invalidReports: Record<string, unknown>[] = [
|
||||
{},
|
||||
{ status: 'invalid', summary: 'work', evidence: [] },
|
||||
{ status: 'continue', evidence: [] },
|
||||
{ status: 'continue', summary: ' ', evidence: [] },
|
||||
{ status: 'continue', summary: 'x'.repeat(2_001), evidence: [] },
|
||||
{ status: 'continue', summary: 'work', evidence: 'not-an-array' },
|
||||
{ status: 'continue', summary: 'work', evidence: Array.from({ length: 21 }, () => 'x') },
|
||||
{ status: 'continue', summary: 'work', evidence: [4] },
|
||||
{ status: 'continue', summary: 'work', evidence: [''] },
|
||||
{ status: 'continue', summary: 'work', evidence: ['x'.repeat(1_001)] },
|
||||
{ status: 'continue', summary: 'work', evidence: [], nextStep: 4 },
|
||||
{ status: 'continue', summary: 'work', evidence: [], nextStep: ' ' },
|
||||
{ status: 'continue', summary: 'work', evidence: [], nextStep: 'x'.repeat(2_001) },
|
||||
];
|
||||
for (const report of invalidReports) {
|
||||
await expect(pi.report(report)).rejects.toThrow();
|
||||
}
|
||||
expect(stateField(pi, 'phase')).toBe('active');
|
||||
});
|
||||
|
||||
it('rejects an achievement claim without evidence', async () => {
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Require evidence');
|
||||
|
||||
await expect(
|
||||
pi.report({ status: 'achieved', summary: 'Trust me', evidence: [] }),
|
||||
).rejects.toThrow(/evidence/i);
|
||||
expect(stateField(pi, 'phase')).toBe('active');
|
||||
});
|
||||
|
||||
it('redacts credential-shaped goal and report text before persistence or display', async () => {
|
||||
const githubToken = `ghp_${'a'.repeat(32)}`;
|
||||
const anthropicKey = `sk-ant-api03-${'b'.repeat(40)}`;
|
||||
const bearerToken = 'header.payload.signature-canary';
|
||||
const databaseUrl = 'postgresql://mosaic:[email protected]/mosaic';
|
||||
const password = 'password-canary';
|
||||
const pi = new FakePi();
|
||||
|
||||
await pi.goal(`set Rotate ${githubToken} without retaining it`);
|
||||
const context = await pi.emit('context', { messages: [] });
|
||||
expect(activeGoalStatementFromContext(context[0])).not.toContain(githubToken);
|
||||
|
||||
const result = await pi.report({
|
||||
status: 'achieved',
|
||||
summary: `Validated ${anthropicKey}`,
|
||||
evidence: [`Authorization: Bearer ${bearerToken}`, `DATABASE_URL=${databaseUrl}`],
|
||||
nextStep: `password=${password}`,
|
||||
});
|
||||
await pi.goal('status');
|
||||
|
||||
const persisted = JSON.stringify(latestGoalStateData(pi));
|
||||
const displayed = pi.notifications.at(-1)?.message ?? '';
|
||||
const toolOutput = JSON.stringify(result);
|
||||
for (const secret of [githubToken, anthropicKey, bearerToken, databaseUrl, password]) {
|
||||
expect(persisted).not.toContain(secret);
|
||||
expect(displayed).not.toContain(secret);
|
||||
expect(toolOutput).not.toContain(secret);
|
||||
}
|
||||
expect(persisted).toContain('[REDACTED-SECRET]');
|
||||
});
|
||||
|
||||
it('preserves ordinary typed fields that resemble sensitive assignment names', async () => {
|
||||
const typedFields = 'token: string, password: boolean, secret: false';
|
||||
const pi = new FakePi();
|
||||
|
||||
await pi.goal(`set Preserve TypeScript fields: ${typedFields}`);
|
||||
await pi.report({
|
||||
status: 'continue',
|
||||
summary: `Schema still contains ${typedFields}`,
|
||||
evidence: [`interface Config { ${typedFields} }`],
|
||||
nextStep: `Keep ${typedFields} unchanged`,
|
||||
});
|
||||
|
||||
expect(stateField(pi, 'statement')).toContain(typedFields);
|
||||
expect(JSON.stringify(stateField(pi, 'lastReport'))).toContain(typedFields);
|
||||
expect(JSON.stringify(latestGoalStateData(pi))).not.toContain('[REDACTED-SECRET]');
|
||||
});
|
||||
|
||||
it('stops autonomous continuation when the max-turn limit is reached', async () => {
|
||||
vi.stubEnv('MOSAIC_GOAL_MAX_TURNS', '2');
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Bound this run');
|
||||
pi.sentMessages.length = 0;
|
||||
|
||||
await pi.emit('turn_end', { turnIndex: 0, message: {}, toolResults: [] });
|
||||
await pi.emit('turn_end', { turnIndex: 1, message: {}, toolResults: [] });
|
||||
|
||||
expect(stateField(pi, 'phase')).toBe('exhausted');
|
||||
expect(pi.abortCount).toBe(1);
|
||||
await pi.emit('agent_settled');
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('resets the no-progress sequence when a continuation report changes', async () => {
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Track changing progress');
|
||||
|
||||
await pi.report({
|
||||
status: 'continue',
|
||||
summary: 'First checkpoint',
|
||||
evidence: ['file A changed'],
|
||||
nextStep: 'Run focused tests',
|
||||
});
|
||||
await pi.report({
|
||||
status: 'continue',
|
||||
summary: 'Second checkpoint',
|
||||
evidence: ['focused tests passed'],
|
||||
nextStep: 'Review the diff',
|
||||
});
|
||||
|
||||
expect(stateField(pi, 'phase')).toBe('active');
|
||||
expect(stateField(pi, 'noProgressReports')).toBe(1);
|
||||
await pi.goal('status');
|
||||
expect(pi.notifications.at(-1)?.message).toContain('Next step: Review the diff');
|
||||
expect(pi.notifications.at(-1)?.message).toContain('focused tests passed');
|
||||
});
|
||||
|
||||
it('stops after a bounded number of identical no-progress reports', async () => {
|
||||
vi.stubEnv('MOSAIC_GOAL_MAX_NO_PROGRESS', '2');
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Detect stalled work');
|
||||
|
||||
const report = {
|
||||
status: 'continue',
|
||||
summary: 'No change yet',
|
||||
evidence: ['same observation'],
|
||||
nextStep: 'Try again',
|
||||
};
|
||||
await pi.report(report);
|
||||
await pi.report(report);
|
||||
|
||||
expect(stateField(pi, 'phase')).toBe('exhausted');
|
||||
expect(stateField(pi, 'noProgressReports')).toBe(2);
|
||||
pi.sentMessages.length = 0;
|
||||
await pi.emit('agent_settled');
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a goal report mixed with another tool result in the same turn', async () => {
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Require a sole final report');
|
||||
await pi.report({
|
||||
status: 'achieved',
|
||||
summary: 'Premature mixed claim',
|
||||
evidence: ['one observation'],
|
||||
});
|
||||
|
||||
await pi.emit('turn_end', {
|
||||
turnIndex: 0,
|
||||
message: {},
|
||||
toolResults: [{ toolName: 'mosaic_goal_report' }, { toolName: 'read' }],
|
||||
});
|
||||
|
||||
expect(stateField(pi, 'phase')).toBe('active');
|
||||
expect(stateField(pi, 'verificationPasses')).toBe(0);
|
||||
expect(stateField(pi, 'lastCheckOutcome')).toBe('mixed-goal-report-rejected');
|
||||
expect(pi.notifications.at(-1)?.level).toBe('warning');
|
||||
});
|
||||
|
||||
it('marks blocked reports terminal until the operator resumes', async () => {
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Stop on a real blocker');
|
||||
|
||||
await pi.report({
|
||||
status: 'blocked',
|
||||
summary: 'Missing required access',
|
||||
evidence: ['provider returned 403'],
|
||||
});
|
||||
expect(stateField(pi, 'phase')).toBe('blocked');
|
||||
|
||||
pi.sentMessages.length = 0;
|
||||
await pi.emit('agent_settled');
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
|
||||
await pi.goal('resume');
|
||||
expect(stateField(pi, 'phase')).toBe('active');
|
||||
expect(stateField(pi, 'turnCount')).toBe(0);
|
||||
expect(pi.sentMessages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mosaic Pi goal compaction and restoration', () => {
|
||||
it('resets provisional verification and defers manual-compaction continuation until idle', async () => {
|
||||
vi.useFakeTimers();
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Survive compaction');
|
||||
await pi.report({
|
||||
status: 'achieved',
|
||||
summary: 'Initial claim',
|
||||
evidence: ['focused test passed'],
|
||||
});
|
||||
expect(stateField(pi, 'phase')).toBe('verifying');
|
||||
pi.sentMessages.length = 0;
|
||||
|
||||
await pi.emit('session_compact', { reason: 'manual', willRetry: false });
|
||||
expect(stateField(pi, 'phase')).toBe('active');
|
||||
expect(stateField(pi, 'verificationPasses')).toBe(0);
|
||||
expect(stateField(pi, 'compactionCount')).toBe(1);
|
||||
expect(stateField(pi, 'lastCheckSource')).toBe('compact');
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
expect(pi.sentMessages).toHaveLength(1);
|
||||
expect(pi.sentMessages[0]?.message.content).toContain('compaction');
|
||||
});
|
||||
|
||||
it('does not re-enter an active automatic compaction and relies on the settled backstop', async () => {
|
||||
vi.useFakeTimers();
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Avoid compaction races');
|
||||
pi.sentMessages.length = 0;
|
||||
pi.idle = false;
|
||||
|
||||
await pi.emit('session_compact', { reason: 'threshold', willRetry: false });
|
||||
await vi.runAllTimersAsync();
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
|
||||
pi.idle = true;
|
||||
await pi.emit('agent_settled');
|
||||
expect(pi.sentMessages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('restores branch-specific state on session start and tree navigation', async () => {
|
||||
vi.useFakeTimers();
|
||||
const source = new FakePi();
|
||||
await source.goal('set Restore this exact branch goal');
|
||||
const activeState = latestGoalStateData(source);
|
||||
|
||||
const restored = new FakePi([
|
||||
{ type: 'custom', customType: 'mosaic-goal-state', data: activeState },
|
||||
]);
|
||||
await restored.emit('session_start', { reason: 'resume' });
|
||||
expect(restored.statuses.get('mosaic-goal')).toContain('active');
|
||||
const context = await restored.emit('context', { messages: [] });
|
||||
expect(activeGoalStatementFromContext(context[0])).toContain('Restore this exact branch goal');
|
||||
|
||||
await restored.goal('pause');
|
||||
const pausedState = latestGoalStateData(restored);
|
||||
restored.branch = [{ type: 'custom', customType: 'mosaic-goal-state', data: pausedState }];
|
||||
await restored.emit('session_tree', {});
|
||||
await vi.runAllTimersAsync();
|
||||
expect(stateField(restored, 'phase')).toBe('paused');
|
||||
expect(restored.sentMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('restores only fully valid persisted states and ignores malformed entries', async () => {
|
||||
vi.useFakeTimers();
|
||||
const source = new FakePi();
|
||||
await source.goal('set Validate persisted branch state');
|
||||
await source.report({
|
||||
status: 'continue',
|
||||
summary: 'Valid report',
|
||||
evidence: ['valid evidence'],
|
||||
nextStep: 'Continue validation',
|
||||
});
|
||||
const valid = latestGoalStateData(source);
|
||||
|
||||
const validRestore = new FakePi([
|
||||
{ type: 'custom', customType: 'mosaic-goal-state', data: valid },
|
||||
]);
|
||||
await validRestore.emit('session_start', { reason: 'resume' });
|
||||
expect(stateField(validRestore, 'statement')).toBe('Validate persisted branch state');
|
||||
await validRestore.emit('session_shutdown', { reason: 'reload' });
|
||||
|
||||
const validReport = latestGoalStateData(source)['lastReport'];
|
||||
if (!isRecord(validReport)) throw new Error('Expected a valid persisted report fixture');
|
||||
const integerFields = [
|
||||
'turnCount',
|
||||
'reportCount',
|
||||
'verificationPasses',
|
||||
'requiredVerificationPasses',
|
||||
'noProgressReports',
|
||||
'maxTurns',
|
||||
'maxNoProgressReports',
|
||||
'compactionCount',
|
||||
];
|
||||
const corruptions: Array<(state: Record<string, unknown>) => unknown> = [
|
||||
(): unknown => null,
|
||||
(state): unknown => ({ ...state, version: 99 }),
|
||||
(state): unknown => ({ ...state, goalId: '' }),
|
||||
(state): unknown => ({ ...state, statement: '' }),
|
||||
(state): unknown => ({ ...state, statement: 'x'.repeat(8_001) }),
|
||||
(state): unknown => ({ ...state, phase: 'unknown' }),
|
||||
(state): unknown => ({ ...state, lastCheckSource: 'unknown' }),
|
||||
(state): unknown => ({ ...state, startedAt: 4 }),
|
||||
(state): unknown => ({ ...state, lastCheckAt: 4 }),
|
||||
(state): unknown => ({ ...state, lastReport: null }),
|
||||
(state): unknown => ({ ...state, lastProgressFingerprint: 4 }),
|
||||
(state): unknown => ({ ...state, stopReason: 4 }),
|
||||
...integerFields.map((field: string) => (state: Record<string, unknown>): unknown => ({
|
||||
...state,
|
||||
[field]: -1,
|
||||
})),
|
||||
];
|
||||
|
||||
corruptions.push(
|
||||
(state: Record<string, unknown>): unknown => ({ ...state, maxTurns: 501 }),
|
||||
(state: Record<string, unknown>): unknown => ({ ...state, maxNoProgressReports: 101 }),
|
||||
(state: Record<string, unknown>): unknown => ({ ...state, requiredVerificationPasses: 3 }),
|
||||
);
|
||||
const reportCorruptions: Array<Record<string, unknown>> = [
|
||||
{ ...validReport, status: 'bad' },
|
||||
{ ...validReport, summary: '' },
|
||||
{ ...validReport, evidence: 'bad' },
|
||||
{ ...validReport, evidence: [4] },
|
||||
{ ...validReport, fingerprint: '' },
|
||||
{ ...validReport, reportedAt: '' },
|
||||
{ ...validReport, nextStep: 4 },
|
||||
];
|
||||
for (const corruptReport of reportCorruptions) {
|
||||
corruptions.push((state: Record<string, unknown>): unknown => ({
|
||||
...state,
|
||||
lastReport: corruptReport,
|
||||
}));
|
||||
}
|
||||
|
||||
for (const corrupt of corruptions) {
|
||||
const candidate = corrupt(structuredClone(valid));
|
||||
const restored = new FakePi([
|
||||
{ type: 'custom', customType: 'mosaic-goal-state', data: candidate },
|
||||
]);
|
||||
await restored.emit('session_start', { reason: 'resume' });
|
||||
expect(restored.statuses.get('mosaic-goal')).toBeUndefined();
|
||||
expect(await restored.emit('context', { messages: [] })).toEqual([undefined]);
|
||||
}
|
||||
|
||||
const failClosed = new FakePi([
|
||||
{ type: 'custom', customType: 'mosaic-goal-state', data: valid },
|
||||
{ type: 'custom', customType: 'mosaic-goal-state', data: { ...valid, version: 99 } },
|
||||
]);
|
||||
await failClosed.emit('session_start', { reason: 'resume' });
|
||||
expect(failClosed.statuses.get('mosaic-goal')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fails closed instead of reusing credential-bearing legacy branch state', async () => {
|
||||
const source = new FakePi();
|
||||
await source.goal('set Build a valid restore fixture');
|
||||
const cleanState = latestGoalStateData(source);
|
||||
const legacyState = structuredClone(cleanState);
|
||||
legacyState['statement'] = `Legacy secret ghp_${'z'.repeat(32)}`;
|
||||
|
||||
const restored = new FakePi([
|
||||
{ type: 'custom', customType: 'mosaic-goal-state', data: legacyState },
|
||||
{ type: 'custom', customType: 'mosaic-goal-state', data: cleanState },
|
||||
]);
|
||||
await restored.emit('session_start', { reason: 'resume' });
|
||||
|
||||
expect(restored.statuses.get('mosaic-goal')).toBeUndefined();
|
||||
expect(await restored.emit('context', { messages: [] })).toEqual([undefined]);
|
||||
expect(restored.notifications.at(-1)?.level).toBe('warning');
|
||||
expect(restored.notifications.at(-1)?.message).toContain('was not restored');
|
||||
expect(restored.entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('schedules an active tree-restored goal and preserves terminal state through compaction', async () => {
|
||||
vi.useFakeTimers();
|
||||
const source = new FakePi();
|
||||
await source.goal('set Restore active tree work');
|
||||
const active = latestGoalStateData(source);
|
||||
|
||||
const restored = new FakePi();
|
||||
restored.branch = [{ type: 'custom', customType: 'mosaic-goal-state', data: active }];
|
||||
await restored.emit('session_tree', {});
|
||||
await vi.runAllTimersAsync();
|
||||
expect(restored.sentMessages).toHaveLength(1);
|
||||
expect(restored.sentMessages[0]?.message.content).toContain('tree navigation');
|
||||
|
||||
await restored.goal('cancel');
|
||||
await restored.emit('session_compact', { reason: 'manual', willRetry: false });
|
||||
expect(stateField(restored, 'phase')).toBe('cancelled');
|
||||
expect(stateField(restored, 'compactionCount')).toBe(1);
|
||||
});
|
||||
|
||||
it('cancels deferred continuation when the session shuts down', async () => {
|
||||
vi.useFakeTimers();
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Do not leak a stale timer');
|
||||
pi.sentMessages.length = 0;
|
||||
|
||||
await pi.emit('session_compact', { reason: 'manual', willRetry: false });
|
||||
await pi.emit('session_shutdown', { reason: 'reload' });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,468 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
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 {
|
||||
buildReseedCommand,
|
||||
buildRelaunchCommands,
|
||||
readRosterAgentNames,
|
||||
runFrameworkReseed,
|
||||
refreshActiveFleetUnits,
|
||||
readInstalledFrameworkVersion,
|
||||
readBundledFrameworkVersion,
|
||||
checkFrameworkDrift,
|
||||
repairFleetCommsTools,
|
||||
} from './update-checker.js';
|
||||
|
||||
/**
|
||||
* F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches
|
||||
* durable agents so shipped launcher/runtime changes activate. These cover the
|
||||
* pure builders + the missing-installer guard (the exec path is integration).
|
||||
*/
|
||||
|
||||
describe('buildReseedCommand', () => {
|
||||
it('invokes the package install.sh in data-safe sync-only keep mode', () => {
|
||||
const out = buildReseedCommand('/pkg/framework', '/home/u/.config/mosaic');
|
||||
expect(out.installer).toBe('/pkg/framework/install.sh');
|
||||
expect(out.command).toBe('bash /pkg/framework/install.sh');
|
||||
expect(out.env).toEqual({
|
||||
MOSAIC_SYNC_ONLY: '1',
|
||||
MOSAIC_INSTALL_MODE: 'keep',
|
||||
MOSAIC_HOME: '/home/u/.config/mosaic',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRelaunchCommands', () => {
|
||||
it('builds a systemctl --user restart per agent unit', () => {
|
||||
expect(buildRelaunchCommands(['orchestrator', 'coder0'])).toEqual([
|
||||
['systemctl', '--user', 'restart', '[email protected]'],
|
||||
['systemctl', '--user', 'restart', '[email protected]'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('is empty for an empty roster', () => {
|
||||
expect(buildRelaunchCommands([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readRosterAgentNames', () => {
|
||||
let home: string;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'mosaic-roster-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns [] when no roster exists', () => {
|
||||
expect(readRosterAgentNames(home)).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts agent names from roster.yaml', () => {
|
||||
mkdirSync(join(home, 'fleet'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: orchestrator',
|
||||
' runtime: pi',
|
||||
' - name: coder0',
|
||||
' runtime: claude',
|
||||
' - name: "reviewer-1"',
|
||||
' runtime: codex',
|
||||
].join('\n') + '\n',
|
||||
);
|
||||
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 });
|
||||
// stack#1380: resolve-then-validate — an escaping symlink is still
|
||||
// refused, with the new escape diagnostic.
|
||||
expect(result.reason).toContain('symlink target escapes managed roots');
|
||||
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 });
|
||||
// stack#1380: escaping ancestor symlinks stay refused. The home case is
|
||||
// caught by the managed-root guard ('is a symbolic link'); deeper
|
||||
// components by resolve-then-validate ('symlink target escapes').
|
||||
expect(result.reason, testCase.name).toMatch(/symbolic link|symlink target escapes/);
|
||||
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'));
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toContain('installer not found');
|
||||
rmSync(missing, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshActiveFleetUnits', () => {
|
||||
let root: string;
|
||||
let mosaicHome: string;
|
||||
let configHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'mosaic-units-'));
|
||||
mosaicHome = join(root, 'mosaic');
|
||||
configHome = join(root, 'config');
|
||||
mkdirSync(join(mosaicHome, 'systemd', 'user'), { recursive: true });
|
||||
mkdirSync(join(configHome, 'systemd', 'user'), { recursive: true });
|
||||
// Freshly re-seeded units (new content).
|
||||
writeFileSync(join(mosaicHome, 'systemd', 'user', '[email protected]'), 'NEW\n');
|
||||
writeFileSync(join(mosaicHome, 'systemd', 'user', 'mosaic-tmux-holder.service'), 'NEW\n');
|
||||
});
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
it('refreshes active units when a fleet is already installed', () => {
|
||||
// Active dir already carries mosaic units (stale) → fleet is installed.
|
||||
writeFileSync(join(configHome, 'systemd', 'user', '[email protected]'), 'OLD\n');
|
||||
const res = refreshActiveFleetUnits(mosaicHome, {
|
||||
XDG_CONFIG_HOME: configHome,
|
||||
} as NodeJS.ProcessEnv);
|
||||
expect(res.refreshed).toContain('[email protected]');
|
||||
expect(
|
||||
readFileSync(join(configHome, 'systemd', 'user', '[email protected]'), 'utf-8'),
|
||||
).toBe('NEW\n');
|
||||
});
|
||||
|
||||
it('is a no-op when no fleet is installed (active dir has no mosaic units)', () => {
|
||||
const res = refreshActiveFleetUnits(mosaicHome, {
|
||||
XDG_CONFIG_HOME: configHome,
|
||||
} as NodeJS.ProcessEnv);
|
||||
expect(res.refreshed).toEqual([]);
|
||||
expect(existsSync(join(configHome, 'systemd', 'user', '[email protected]'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #642: re-seed when the on-disk framework is older than the bundled one even
|
||||
* if no package is reported outdated (CLI upgraded outside `mosaic update`).
|
||||
*/
|
||||
describe('framework drift detection', () => {
|
||||
let home: string; // stand-in for ~/.config/mosaic
|
||||
let fw: string; // stand-in for the bundled framework root
|
||||
|
||||
beforeEach(() => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-drift-'));
|
||||
home = join(root, 'mosaic');
|
||||
fw = join(root, 'framework');
|
||||
mkdirSync(home, { recursive: true });
|
||||
mkdirSync(fw, { recursive: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(join(home, '..'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const writeInstalled = (v: string) => writeFileSync(join(home, '.framework-version'), v);
|
||||
const writeBundled = (v: string) =>
|
||||
writeFileSync(join(fw, 'install.sh'), `#!/usr/bin/env bash\nFRAMEWORK_VERSION=${v}\n`);
|
||||
|
||||
describe('readInstalledFrameworkVersion', () => {
|
||||
it('returns undefined when the version file is absent', () => {
|
||||
expect(readInstalledFrameworkVersion(home)).toBeUndefined();
|
||||
});
|
||||
it('parses the integer (tolerating surrounding whitespace)', () => {
|
||||
writeInstalled(' 3\n');
|
||||
expect(readInstalledFrameworkVersion(home)).toBe(3);
|
||||
});
|
||||
it('returns undefined for non-numeric content', () => {
|
||||
writeInstalled('not-a-number\n');
|
||||
expect(readInstalledFrameworkVersion(home)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readBundledFrameworkVersion', () => {
|
||||
it('returns undefined when install.sh is absent', () => {
|
||||
expect(readBundledFrameworkVersion(fw)).toBeUndefined();
|
||||
});
|
||||
it('parses FRAMEWORK_VERSION=<n> from install.sh', () => {
|
||||
writeBundled('4');
|
||||
expect(readBundledFrameworkVersion(fw)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkFrameworkDrift', () => {
|
||||
it('reports drift when on-disk is older than bundled', () => {
|
||||
writeInstalled('3');
|
||||
writeBundled('4');
|
||||
expect(checkFrameworkDrift(home, fw)).toEqual({ drifted: true, installed: 3, bundled: 4 });
|
||||
});
|
||||
it('no drift when versions match', () => {
|
||||
writeInstalled('4');
|
||||
writeBundled('4');
|
||||
expect(checkFrameworkDrift(home, fw)).toMatchObject({ drifted: false });
|
||||
});
|
||||
it('no drift when on-disk is newer than bundled', () => {
|
||||
writeInstalled('5');
|
||||
writeBundled('4');
|
||||
expect(checkFrameworkDrift(home, fw)).toMatchObject({ drifted: false });
|
||||
});
|
||||
it('no drift (conservative) when a version cannot be read', () => {
|
||||
writeBundled('4'); // installed version file missing
|
||||
expect(checkFrameworkDrift(home, fw)).toMatchObject({ drifted: false, bundled: 4 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user