fix(fleet): harden managed launch composition

AMD1213-C: repair stale array consumer, fail closed on foreign link provenance, validate manifests before mutation, and exercise the fleet MCP preflight call path.
This commit is contained in:
terra
2026-08-13 15:37:32 -05:00
parent 2755f86f7b
commit 326a1a58b5
8 changed files with 347 additions and 89 deletions
@@ -86,7 +86,9 @@ config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
p = Path(config_dir) / ".claude.json" if config_dir else Path.home() / ".claude.json"
if not p.exists() and not config_dir:
p = Path.home() / ".claude" / "settings.json"
if not p.exists():
# Only explicit fleet seats require a private, non-symlink config. Operator
# config remains compatible with pre-existing permission conventions.
if not p.exists() or p.is_symlink() or (config_dir and (p.stat().st_mode & 0o077) != 0):
raise SystemExit(1)
try:
data = json.loads(p.read_text(encoding="utf-8"))
@@ -137,7 +139,7 @@ PY
}
check_codex_config() {
local cfg="$HOME/.codex/config.toml"
local cfg="${CODEX_HOME:-$HOME/.codex}/config.toml"
[[ -f "$cfg" ]] || return 1
grep -Eq '^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]' "$cfg" && \
grep -q '^command = "npx"' "$cfg" && \
@@ -145,7 +147,7 @@ check_codex_config() {
}
apply_codex_config() {
local cfg="$HOME/.codex/config.toml"
local cfg="${CODEX_HOME:-$HOME/.codex}/config.toml"
mkdir -p "$(dirname "$cfg")"
[[ -f "$cfg" ]] || touch "$cfg"
@@ -168,10 +170,11 @@ apply_codex_config() {
}
check_opencode_config() {
python3 - <<'PY'
XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" python3 - <<'PY'
import json
import os
from pathlib import Path
p = Path.home() / ".config" / "opencode" / "config.json"
p = Path(os.environ["XDG_CONFIG_HOME"]) / "opencode" / "config.json" if os.environ.get("XDG_CONFIG_HOME") else Path.home() / ".config" / "opencode" / "config.json"
if not p.exists():
raise SystemExit(1)
try:
@@ -194,10 +197,11 @@ PY
}
apply_opencode_config() {
python3 - <<'PY'
XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" python3 - <<'PY'
import json
import os
from pathlib import Path
p = Path.home() / ".config" / "opencode" / "config.json"
p = Path(os.environ["XDG_CONFIG_HOME"]) / "opencode" / "config.json" if os.environ.get("XDG_CONFIG_HOME") else Path.home() / ".config" / "opencode" / "config.json"
p.parent.mkdir(parents=True, exist_ok=True)
if p.exists():
try:
@@ -63,6 +63,7 @@ describe('mosaic fleet agent new', (): void => {
expect(await files(agent)).toEqual([
'.claude/.claude.json',
'.claude/.credentials.json',
'.claude/.mosaic-managed-links.json',
'.claude/CLAUDE.md',
'SOUL.md',
'overlay.json',
@@ -86,9 +87,13 @@ describe('mosaic fleet agent new', (): void => {
},
},
});
expect(await readlink(join(agent, '.claude', '.credentials.json'))).toBe(
join(dataHome, 'auth', 'claude', 'primary', '.credentials.json'),
);
const credentialTarget = join(dataHome, 'auth', 'claude', 'primary', '.credentials.json');
expect(await readlink(join(agent, '.claude', '.credentials.json'))).toBe(credentialTarget);
expect(
JSON.parse(await readFile(join(agent, '.claude', '.mosaic-managed-links.json'), 'utf8')),
).toEqual({
links: { [join(agent, '.claude', '.credentials.json')]: credentialTarget },
});
});
it('creates a Pi home without Claude onboarding state', async (): Promise<void> => {
@@ -104,6 +109,7 @@ describe('mosaic fleet agent new', (): void => {
'pi',
]);
expect(await files(join(dataHome, 'fleet', 'agents', 'pi-seat'))).toEqual([
'.pi/.mosaic-managed-links.json',
'.pi/AGENTS.md',
'.pi/auth.json',
'SOUL.md',
@@ -1,5 +1,6 @@
import {
chmodSync,
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
@@ -401,6 +402,29 @@ describe('managed plugin and skill links', () => {
expect(readlinkSync(join(pluginHome, 'foreign'))).toBe(foreign);
});
it('performs no writes when a late foreign install link is refused', () => {
const fx = fixture({ schema: 1, harness: 'claude', plugins: ['keep'] });
const target = join(fx.userHome, 'plugins', 'keep');
const link = join(fx.agentDir, '.claude', 'plugins', 'keep');
mkdirSync(target, { recursive: true });
mkdirSync(join(link, '..'), { recursive: true });
writeFileSync(join(fx.agentDir, '.claude', '.mosaic-managed-links.json'), '{"links":{}}\n');
symlinkSync(target, link, 'dir');
const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
const snapshot = join(fx.agentDir, 'settings.generated.json');
const temp = join(fx.agentDir, '.claude', '.mosaic-managed-links.json.tmp');
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
/unrecorded or retargeted symlink/,
);
expect(existsSync(snapshot)).toBe(false);
expect(existsSync(temp)).toBe(false);
expect(readlinkSync(link)).toBe(target);
});
it('prunes a recorded matching stale symlink', () => {
const fx = fixture({ schema: 1, harness: 'claude', plugins: ['old'] });
mkdirSync(join(fx.userHome, 'plugins', 'old'), { recursive: true });
@@ -455,6 +479,23 @@ describe('managed plugin and skill links', () => {
expect(readlinkSync(link)).toBe(foreignTarget);
});
it('refuses a tampered manifest entry outside this seat and leaves it intact', () => {
const fx = fixture({ schema: 1, harness: 'claude' });
const seatHome = join(fx.agentDir, '.claude');
mkdirSync(seatHome, { recursive: true });
const manifest = join(seatHome, '.mosaic-managed-links.json');
const crossSeat = join(fx.userHome, 'fleet', 'agents', 'other', '.claude', 'plugins', 'keep');
writeFileSync(
manifest,
JSON.stringify({ links: { [crossSeat]: join(fx.userHome, 'plugins', 'keep') } }),
);
expect(() =>
resolveFleetLaunchComposition('fred', { systemHome: fx.systemHome, userHome: fx.userHome }),
).toThrowError(/escapes an approved seat\/store root/);
expect(readFileSync(manifest, 'utf8')).toContain(crossSeat);
});
it('refuses a symlinked manifest temporary path without modifying its target', () => {
const fx = fixture({ schema: 1, harness: 'claude', plugins: ['keep'] });
const target = join(fx.userHome, 'plugins', 'keep');
@@ -472,33 +513,45 @@ describe('managed plugin and skill links', () => {
expect(readFileSync(sentinel, 'utf8')).toBe('unchanged\n');
});
it('adopts a pre-manifest credential link inside auth root before retargeting it', () => {
const fx = fixture({ schema: 1, harness: 'claude', bundle: 'next' });
const previousBundle = join(fx.userHome, 'auth', 'claude', 'previous');
const nextBundle = join(fx.userHome, 'auth', 'claude', 'next');
it('refuses an exact-target unrecorded credential symlink', () => {
const fx = fixture();
const seatHome = join(fx.agentDir, '.claude');
mkdirSync(previousBundle, { recursive: true });
mkdirSync(nextBundle, { recursive: true });
writeFileSync(join(previousBundle, '.credentials.json'), '{}\n', { mode: 0o600 });
writeFileSync(join(nextBundle, '.credentials.json'), '{}\n', { mode: 0o600 });
const link = join(seatHome, '.credentials.json');
mkdirSync(seatHome, { recursive: true });
symlinkSync(
join(previousBundle, '.credentials.json'),
join(seatHome, '.credentials.json'),
'file',
);
symlinkSync(join(fx.namedBundleDir, '.credentials.json'), link, 'file');
const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
applyFleetLaunchComposition(plan);
expect(readlinkSync(join(seatHome, '.credentials.json'))).toBe(
join(nextBundle, '.credentials.json'),
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
/unrecorded or retargeted symlink/,
);
expect(readlinkSync(link)).toBe(join(fx.namedBundleDir, '.credentials.json'));
});
it.each(['plugins', 'skills'] as const)(
'refuses an exact-target unrecorded %s symlink',
(kind) => {
const fx = fixture({ schema: 1, harness: 'claude', [kind]: ['keep'] });
const target = join(fx.userHome, kind, 'keep');
const link = join(fx.agentDir, '.claude', kind, 'keep');
mkdirSync(target, { recursive: true });
mkdirSync(join(link, '..'), { recursive: true });
writeFileSync(join(fx.agentDir, '.claude', '.mosaic-managed-links.json'), '{"links":{}}\n');
symlinkSync(target, link, 'dir');
const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
/unrecorded or retargeted symlink/,
);
expect(readlinkSync(link)).toBe(target);
},
);
it('refuses an unrecorded mismatched credential symlink', () => {
const fx = fixture();
const seatHome = join(fx.agentDir, '.claude');
@@ -390,7 +390,7 @@ function resolveCredential(
profile: FleetAgentLaunchProfile,
userHome: string,
seatHome: string,
managedLinks: ManagedLinkState,
_managedLinks: ManagedLinkState,
): Pick<FleetLaunchComposition, 'bundle' | 'credential'> {
assertRealDirectory(userHome, 'user Mosaic root');
const realUserHome = realpathSync(userHome);
@@ -442,16 +442,6 @@ function resolveCredential(
const credentialLink = join(seatHome, CREDENTIAL_FILES[profile.harness]);
const seatInfo = lstatIfPresent(credentialLink);
if (seatInfo?.isSymbolicLink() && !managedLinks.existed) {
const existingTarget = currentLinkTarget(credentialLink);
try {
assertContained(resolvedAuthRoot, realpathSync(existingTarget), 'credential migration');
// A pre-manifest seat may adopt only a credential link inside the central auth root.
managedLinks.links.set(credentialLink, existingTarget);
} catch {
// Foreign links remain unrecorded and are refused by apply before any writes.
}
}
if (seatInfo && !seatInfo.isSymbolicLink()) {
throw new FleetLaunchError(
'FIRST_AUTH_REFUSAL',
@@ -571,7 +561,11 @@ function resolveManagedLinks(
return { installs, prune };
}
function readManagedLinkState(seatHome: string): ManagedLinkState {
function readManagedLinkState(
seatHome: string,
profile: FleetAgentLaunchProfile,
userHome: string,
): ManagedLinkState {
const path = join(seatHome, '.mosaic-managed-links.json');
const info = lstatIfPresent(path);
if (!info) return { path, links: new Map(), existed: false };
@@ -605,6 +599,24 @@ function readManagedLinkState(seatHome: string): ManagedLinkState {
`managed link manifest has invalid entry: ${path}`,
);
}
const credential = join(seatHome, CREDENTIAL_FILES[profile.harness]);
const pluginRoot = join(seatHome, 'plugins');
const skillRoot = join(seatHome, 'skills');
const authRoot = join(userHome, 'auth', profile.harness);
const inRoot = (root: string, candidate: string): boolean => {
const rel = relative(resolve(root), resolve(candidate));
return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
};
const valid =
(link === credential && inRoot(authRoot, target)) ||
(inRoot(pluginRoot, link) && inRoot(join(userHome, 'plugins'), target)) ||
(inRoot(skillRoot, link) && inRoot(join(userHome, 'skills'), target));
if (!valid) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`managed link manifest entry escapes an approved seat/store root: ${path}`,
);
}
links.set(link, target);
}
return { path, links, existed: true };
@@ -720,7 +732,7 @@ export function resolveFleetLaunchComposition(
: readSettingsLayer('agent', overlayPath, false),
];
const merged = deepMergeSettings(...layers.map((layer) => layer.value));
const managedLinks = readManagedLinkState(seatHome);
const managedLinks = readManagedLinkState(seatHome, profile, roots.userHome);
const credential = resolveCredential(profile, roots.userHome, seatHome, managedLinks);
const plugins = resolveManagedLinks(
'plugin',
@@ -773,10 +785,6 @@ function ensureSymlink(link: string, target: string, managedLinks: ManagedLinkSt
const info = lstatIfPresent(link);
if (info?.isSymbolicLink()) {
const current = currentLinkTarget(link);
if (current === target) {
managedLinks.links.set(link, target);
return;
}
if (managedLinks.links.get(link) !== current) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
@@ -809,7 +817,6 @@ function assertManagedLinkMutationAllowed(
);
}
const current = currentLinkTarget(link);
if (target !== undefined && current === target) return;
if (managedLinks.links.get(link) !== current) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
@@ -830,22 +837,20 @@ function canonicalJson(value: unknown): unknown {
/** Apply a previously resolved plan. No caller should apply a dry-run plan. */
export function applyFleetLaunchComposition(plan: FleetLaunchComposition): void {
// All link-state checks must complete before the first filesystem mutation.
// This makes a late foreign/retargeted link refusal leave the seat untouched.
assertManagedLinkMutationAllowed(plan.credential.link, plan.credential.target, plan.managedLinks);
for (const path of plan.prune)
assertManagedLinkMutationAllowed(path, undefined, plan.managedLinks);
for (const install of plan.installs) {
assertManagedLinkMutationAllowed(install.link, install.target, plan.managedLinks);
}
mkdirSync(plan.seatHome, { recursive: true });
const preparedManifest = prepareManagedLinkManifest(plan.managedLinks);
let descriptorOpen = true;
let committedManifest = false;
try {
assertManagedLinkMutationAllowed(
plan.credential.link,
plan.credential.target,
plan.managedLinks,
);
for (const path of plan.prune) {
assertManagedLinkMutationAllowed(path, undefined, plan.managedLinks);
}
for (const install of plan.installs) {
assertManagedLinkMutationAllowed(install.link, install.target, plan.managedLinks);
}
const settings = `${JSON.stringify(canonicalJson(plan.settings.merged), null, 2)}\n`;
writeFileSync(plan.settings.output, settings, { mode: 0o600 });
writeFileSync(plan.settings.snapshot, settings, { mode: 0o600 });
@@ -874,11 +879,12 @@ export function applyFleetLaunchComposition(plan: FleetLaunchComposition): void
}
writeManagedLinkState(plan.managedLinks, preparedManifest);
closeSync(preparedManifest.descriptor);
descriptorOpen = false;
renameSync(preparedManifest.path, plan.managedLinks.path);
committedManifest = true;
} finally {
if (!committedManifest) {
closeSync(preparedManifest.descriptor);
if (descriptorOpen) closeSync(preparedManifest.descriptor);
rmSync(preparedManifest.path, { force: true });
}
}
@@ -20,6 +20,7 @@ import {
piForceSkillNames,
registerRuntimeLaunchers,
checkSequentialThinking,
launchFleetRuntimeForTest,
type RuntimeLaunchHandler,
type ClaudexLaunchHandler,
} from './launch.js';
@@ -99,6 +100,95 @@ describe('registerRuntimeLaunchers — non-yolo subcommands', () => {
});
describe('checkSequentialThinking', () => {
it('runs the real fleet launch preflight against the injected seat, not HOME', () => {
const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-'));
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
try {
expect(
JSON.parse(
readFileSync(
join(process.cwd(), 'framework', 'runtime', 'claude', 'settings.json'),
'utf8',
),
).mcpServers['sequential-thinking'],
).toEqual({
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
});
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
copyFileSync(
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
checker,
);
mkdirSync(join(agentDir, '.claude'), { recursive: true });
writeFileSync(
join(agentDir, '.claude', '.claude.json'),
JSON.stringify({
mcpServers: {
'sequential-thinking': {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
},
},
}),
{ mode: 0o600 },
);
vi.stubEnv('HOME', home);
const final = vi.fn((): never => {
throw new Error('final runtime boundary');
});
expect(() =>
launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, final),
).toThrow('final runtime boundary');
expect(final).toHaveBeenCalledOnce();
} finally {
vi.unstubAllEnvs();
rmSync(home, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
rmSync(installed, { recursive: true, force: true });
}
});
it('fails the real fleet launch preflight when only operator HOME is seeded', () => {
const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-'));
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
const exit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
try {
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
copyFileSync(
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
checker,
);
writeFileSync(
join(home, '.claude.json'),
JSON.stringify({
mcpServers: {
'sequential-thinking': {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
},
},
}),
);
vi.stubEnv('HOME', home);
expect(() =>
launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, () => {
throw new Error('must not execute');
}),
).toThrow('process.exit called');
} finally {
exit.mockRestore();
vi.unstubAllEnvs();
rmSync(home, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
rmSync(installed, { recursive: true, force: true });
}
});
it('passes with a seeded seat even when operator HOME has no MCP configuration', () => {
const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-'));
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
@@ -121,6 +211,7 @@ describe('checkSequentialThinking', () => {
},
},
}),
{ mode: 0o600 },
);
vi.stubEnv('MOSAIC_HOME', installed);
vi.stubEnv('HOME', home);
@@ -156,6 +247,7 @@ describe('checkSequentialThinking', () => {
writeFileSync(
join(agentDir, '.claude', '.claude.json'),
JSON.stringify({ hasCompletedOnboarding: true, theme: 'dark' }),
{ mode: 0o600 },
);
const env = { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` };
expect(
+63 -5
View File
@@ -20,7 +20,7 @@ import {
import { createHash, randomBytes } from 'node:crypto';
import { createRequire } from 'node:module';
import { homedir, hostname } from 'node:os';
import { join, dirname, relative, resolve, sep } from 'node:path';
import { isAbsolute, join, dirname, relative, resolve, sep } from 'node:path';
import type { Command } from 'commander';
import {
buildResolvedFleetCommsBlock,
@@ -346,6 +346,15 @@ function printSettingsWarnings(audit: SettingsAudit): void {
);
}
function resolveExecutable(name: string): string {
const result = spawnSync('which', [name], { encoding: 'utf8' });
const path = result.status === 0 ? result.stdout.trim() : '';
if (!path || !isAbsolute(path) || !existsSync(path)) {
throw new Error(`required helper executable is unavailable: ${name}`);
}
return path;
}
function trustedFleetHelper(mosaicHome: string): string {
const root = resolve(mosaicHome);
const checker = join(root, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
@@ -392,6 +401,13 @@ export function checkSequentialThinking(runtime: RuntimeName, fleet?: FleetHarne
if (!existsSync(checker)) return; // Skip if checker doesn't exist
const fleetClaudeConfig =
runtime === 'claude' && fleet ? harnessHome('claude', fleet) : undefined;
const fleetCodexHome = runtime === 'codex' && fleet ? harnessHome('codex', fleet) : undefined;
const fleetOpenCodeHome =
runtime === 'opencode' && fleet ? harnessHome('opencode', fleet) : undefined;
const python = resolveExecutable('python3');
const node = resolveExecutable('node');
const npx = resolveExecutable('npx');
const capabilityPath = [...new Set([dirname(python), dirname(node), dirname(npx)])].join(':');
const result = spawnSync(
checker,
[
@@ -400,7 +416,22 @@ export function checkSequentialThinking(runtime: RuntimeName, fleet?: FleetHarne
runtime,
...(fleetClaudeConfig === undefined ? [] : ['--claude-config-dir', fleetClaudeConfig]),
],
{ stdio: 'ignore' },
{
stdio: 'ignore',
env: {
HOME: process.env['HOME'] ?? '',
PATH: capabilityPath,
LANG: process.env['LANG'] ?? 'C.UTF-8',
...(process.env['MOSAIC_SEQ_CHECK_WARM'] === undefined
? {}
: { MOSAIC_SEQ_CHECK_WARM: process.env['MOSAIC_SEQ_CHECK_WARM'] }),
...(process.env['MOSAIC_SEQ_WARM_TIMEOUT_SEC'] === undefined
? {}
: { MOSAIC_SEQ_WARM_TIMEOUT_SEC: process.env['MOSAIC_SEQ_WARM_TIMEOUT_SEC'] }),
...(fleetCodexHome === undefined ? {} : { CODEX_HOME: fleetCodexHome }),
...(fleetOpenCodeHome === undefined ? {} : { XDG_CONFIG_HOME: fleetOpenCodeHome }),
},
},
);
if (result.status !== 0) {
console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.');
@@ -968,6 +999,11 @@ function getMissionPrompt(): string {
interface RuntimeLaunchContext {
readonly fleet?: FleetHarnessContext;
readonly declaredEnv?: Readonly<Record<string, string>>;
/** Test seam: bypass only final runtime binary discovery. */
readonly runtimeCheck?: (runtime: RuntimeName) => void;
/** Test seam: receives the fully composed final runtime invocation. */
readonly finalExecutor?: (runtime: RuntimeName, args: string[], env: NodeJS.ProcessEnv) => void;
readonly recordLaunch?: boolean;
}
function minimalLaunchEnv(declared: Readonly<Record<string, string>>): NodeJS.ProcessEnv {
@@ -1000,7 +1036,7 @@ function launchRuntime(
checkMosaicHome();
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
checkSoul();
checkRuntime(runtime);
(context.runtimeCheck ?? checkRuntime)(runtime);
// Pi doesn't need sequential-thinking (has native thinking levels)
if (runtime !== 'pi') {
@@ -1050,11 +1086,16 @@ function launchRuntime(
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('claude', cliArgs, yolo, context.fleet, launchEnv);
if (context.recordLaunch !== false)
recordLaunch('claude', cliArgs, yolo, context.fleet, launchEnv);
if (process.env['MOSAIC_LAUNCH_ID']) {
launchEnv['MOSAIC_LAUNCH_ID'] = process.env['MOSAIC_LAUNCH_ID'];
}
execLeaseGatedRuntime('claude', cliArgs, launchEnv, yolo, context.fleet);
if (context.finalExecutor) {
context.finalExecutor('claude', cliArgs, launchEnv);
} else {
execLeaseGatedRuntime('claude', cliArgs, launchEnv, yolo, context.fleet);
}
break;
}
@@ -1170,6 +1211,23 @@ export function launchFleetRuntime(
return launchRuntime(runtime, args, false, { fleet, declaredEnv });
}
/** Bounded production-path test seam; all preflight and composition remain real. */
export function launchFleetRuntimeForTest(
runtime: RuntimeName,
args: string[],
declaredEnv: Readonly<Record<string, string>>,
fleet: FleetHarnessContext,
finalExecutor: NonNullable<RuntimeLaunchContext['finalExecutor']>,
): never {
return launchRuntime(runtime, args, false, {
fleet,
declaredEnv,
runtimeCheck: () => undefined,
finalExecutor,
recordLaunch: false,
});
}
/** exec into the runtime, replacing the current process. */
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
try {
@@ -64,6 +64,7 @@ export async function scaffoldFleetAgent(
...(model === undefined ? {} : { model }),
env: { MOSAIC_AGENT_NAME: name },
};
const credentialLink = join(agentDir, homeName, credentialName);
const entries: [string, ExpectedFile][] = [
['profile.json', { type: 'file', content: json(profile) }],
['SOUL.md', { type: 'file', content: soul(name) }],
@@ -73,6 +74,10 @@ export async function scaffoldFleetAgent(
{ type: 'file', content: identityBootstrap(name) },
],
[join(homeName, credentialName), { type: 'symlink', target: credentialTarget }],
[
join(homeName, '.mosaic-managed-links.json'),
{ type: 'file', content: json({ links: { [credentialLink]: credentialTarget } }) },
],
];
if (harness === 'claude') {
entries.push([
@@ -20,28 +20,11 @@ function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
// This fixture contract composes hook event arrays additively. It is deliberately
// limited to verifying that the split is lossless; production launch merge
// semantics remain owned by W-F1.
// Production composition uses universal last-layer-wins array replacement. The
// lease overlay therefore carries complete affected event arrays, including the
// two QA carry-forward entries needed to avoid dropping non-lease hooks.
function deepMerge(base: Json, overlay: Json): Json {
if (Array.isArray(base) && Array.isArray(overlay)) {
const merged = [...base];
for (const entry of overlay) {
if (!isObject(entry) || !Array.isArray(entry['hooks'])) {
merged.push(entry);
continue;
}
const matchingIndex = merged.findIndex(
(candidate) =>
isObject(candidate) &&
Array.isArray(candidate['hooks']) &&
candidate['matcher'] === entry['matcher'],
);
if (matchingIndex === -1) merged.push(entry);
else merged[matchingIndex] = deepMerge(merged[matchingIndex]!, entry);
}
return merged;
}
if (Array.isArray(base) && Array.isArray(overlay)) return overlay;
if (isObject(base) && isObject(overlay)) {
const merged: JsonObject = { ...base };
for (const [key, value] of Object.entries(overlay)) {
@@ -107,6 +90,30 @@ describe('canonical Claude base and lease-promotion overlay', () => {
const preSplit = readJson(gatedFixturePath);
const expected: JsonObject = {
...preSplit,
hooks: {
...(preSplit['hooks'] as JsonObject),
Stop: [
{
hooks: [
{
type: 'command',
command: '~/.config/mosaic/tools/qa/reflect-stop-hook.sh',
timeout: 15,
},
],
},
{
hooks: [
{
type: 'command',
command:
'python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status',
timeout: 15,
},
],
},
],
},
mcpServers: { 'sequential-thinking': sequentialThinking },
};
@@ -118,14 +125,41 @@ describe('canonical Claude base and lease-promotion overlay', () => {
expect(base['mcpServers']).toEqual({ 'sequential-thinking': sequentialThinking });
});
it('limits the overlay to lease hook entries', () => {
it('carries six lease commands plus exactly two deliberate QA carry-forward commands', () => {
const overlay = readJson(overlayPath);
expect(Object.keys(overlay)).toEqual(['hooks']);
const commands = hookCommands(overlay);
expect(commands).toHaveLength(6);
for (const command of commands) {
expect(command).toMatch(/mutator-gate|receipt-observer|promote-|revoke-lease/);
}
const lease = commands.filter((command) =>
/mutator-gate|receipt-observer|promote-|revoke-lease/.test(command),
);
const qa = commands.filter((command) => /prevent-memory-write|reflect-stop/.test(command));
expect(lease).toHaveLength(6);
expect(qa).toHaveLength(2);
expect(commands).toHaveLength(8);
});
it.each(['prevent-memory-write', 'reflect-stop'])(
'fails lossless reconstruction if QA carry-forward %s is removed',
(marker) => {
const base = readJson(basePath);
const overlay = readJson(overlayPath);
const expected = {
...readJson(gatedFixturePath),
mcpServers: { 'sequential-thinking': sequentialThinking },
};
const hooks = overlay['hooks'] as JsonObject;
const mutated: JsonObject = {
hooks: Object.fromEntries(
Object.entries(hooks).map(([event, entries]) => [
event,
Array.isArray(entries)
? entries.filter((entry) => !JSON.stringify(entry).includes(marker))
: entries,
]),
),
};
expect(normalize(deepMerge(base, mutated))).not.toEqual(normalize(expected));
},
);
});