fix(fleet): seed seat MCP preflight config

AMD1213-B5: derive Claude seat MCP configuration from the active installed runtime base and inspect the isolated seat during fleet launch.
This commit is contained in:
terra
2026-08-13 14:38:25 -05:00
parent fe2cf19461
commit 2755f86f7b
9 changed files with 326 additions and 22 deletions
@@ -5,6 +5,7 @@ MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
MODE="apply" MODE="apply"
RUNTIME="all" RUNTIME="all"
STRICT_CHECK=0 STRICT_CHECK=0
CLAUDE_CONFIG_DIR=""
PKG="@modelcontextprotocol/server-sequential-thinking" PKG="@modelcontextprotocol/server-sequential-thinking"
@@ -29,6 +30,14 @@ while [[ $# -gt 0 ]]; do
STRICT_CHECK=1 STRICT_CHECK=1
shift shift
;; ;;
--claude-config-dir)
if [[ $# -lt 2 ]]; then
err "--claude-config-dir requires an absolute seat config directory"
exit 2
fi
CLAUDE_CONFIG_DIR="$2"
shift 2
;;
*) *)
err "Unknown argument: $1" err "Unknown argument: $1"
exit 2 exit 2
@@ -67,10 +76,16 @@ warm_package() {
} }
check_claude_config() { check_claude_config() {
python3 - <<'PY' CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" python3 - <<'PY'
import json import json
import os
from pathlib import Path from pathlib import Path
p = Path.home() / ".claude" / "settings.json" # Claude reads MCP definitions from .claude.json, not settings.json. The
# settings.json fallback preserves legacy operator flows until their config is migrated.
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(): if not p.exists():
raise SystemExit(1) raise SystemExit(1)
try: try:
@@ -92,10 +107,15 @@ PY
} }
apply_claude_config() { apply_claude_config() {
python3 - <<'PY' CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" python3 - <<'PY'
import json import json
import os
from pathlib import Path from pathlib import Path
p = Path.home() / ".claude" / "settings.json" # Claude reads MCP definitions from .claude.json for both operator and
# explicitly isolated fleet config dirs. The checker retains a settings.json
# fallback only to avoid breaking legacy operator configurations.
config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
p = Path(config_dir) / ".claude.json" if config_dir else Path.home() / ".claude.json"
p.parent.mkdir(parents=True, exist_ok=True) p.parent.mkdir(parents=True, exist_ok=True)
if p.exists(): if p.exists():
try: try:
@@ -1,3 +1,4 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { lstat, mkdtemp, readFile, readdir, readlink, rm, writeFile } from 'node:fs/promises'; import { lstat, mkdtemp, readFile, readdir, readlink, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -23,7 +24,23 @@ function program(dataHome: string): Command {
const result = new Command(); const result = new Command();
result.exitOverride(); result.exitOverride();
const fleet = result.command('fleet'); const fleet = result.command('fleet');
registerFleetAgentScaffoldCommand(fleet, { fleetDataHome: dataHome }); const mosaicHome = join(root!, 'installed-mosaic');
mkdirSync(join(mosaicHome, 'runtime', 'claude'), { recursive: true });
writeFileSync(
join(mosaicHome, 'runtime', 'claude', 'settings.json'),
JSON.stringify({
mcpServers: {
'sequential-thinking': {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
},
},
}),
);
registerFleetAgentScaffoldCommand(fleet, {
fleetDataHome: dataHome,
mosaicHomeFor: () => mosaicHome,
});
return result; return result;
} }
@@ -59,9 +76,16 @@ describe('mosaic fleet agent new', (): void => {
env: { MOSAIC_AGENT_NAME: 'mira' }, env: { MOSAIC_AGENT_NAME: 'mira' },
}); });
expect(await readFile(join(agent, 'SOUL.md'), 'utf8')).toContain('## Identity'); expect(await readFile(join(agent, 'SOUL.md'), 'utf8')).toContain('## Identity');
expect(await readFile(join(agent, '.claude', '.claude.json'), 'utf8')).toEqual( expect(JSON.parse(await readFile(join(agent, '.claude', '.claude.json'), 'utf8'))).toEqual({
`${JSON.stringify({ hasCompletedOnboarding: true, theme: 'dark' }, null, 2)}\n`, hasCompletedOnboarding: true,
); theme: 'dark',
mcpServers: {
'sequential-thinking': {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
},
},
});
expect(await readlink(join(agent, '.claude', '.credentials.json'))).toBe( expect(await readlink(join(agent, '.claude', '.credentials.json'))).toBe(
join(dataHome, 'auth', 'claude', 'primary', '.credentials.json'), join(dataHome, 'auth', 'claude', 'primary', '.credentials.json'),
); );
@@ -4,6 +4,8 @@ import { FleetAgentScaffoldError, scaffoldFleetAgent } from '../fleet/fleet-agen
export interface FleetAgentScaffoldCommandDeps { export interface FleetAgentScaffoldCommandDeps {
/** Test seam for the user-owned ~/.mosaic root. */ /** Test seam for the user-owned ~/.mosaic root. */
readonly fleetDataHome?: string; readonly fleetDataHome?: string;
/** Resolves the active installed Mosaic root that owns the canonical runtime base. */
readonly mosaicHomeFor?: () => string;
} }
interface NewAgentOptions { interface NewAgentOptions {
@@ -35,6 +37,7 @@ export function registerFleetAgentScaffoldCommand(
bundle: options.bundle, bundle: options.bundle,
model: options.model, model: options.model,
...(deps.fleetDataHome === undefined ? {} : { dataHome: deps.fleetDataHome }), ...(deps.fleetDataHome === undefined ? {} : { dataHome: deps.fleetDataHome }),
...(deps.mosaicHomeFor === undefined ? {} : { mosaicHome: deps.mosaicHomeFor() }),
}); });
console.log( console.log(
result.idempotent result.idempotent
@@ -604,7 +604,7 @@ describe('fleet launch command outcomes', () => {
MOSAIC_AGENT_NAME: 'fred', MOSAIC_AGENT_NAME: 'fred',
SEAT_FLAG: 'yes', SEAT_FLAG: 'yes',
}, },
{ agentDir: fx.agentDir }, { agentDir: fx.agentDir, mosaicHome: fx.systemHome },
); );
expect(lstatSync(join(fx.agentDir, '.claude', '.credentials.json')).isSymbolicLink()).toBe( expect(lstatSync(join(fx.agentDir, '.claude', '.credentials.json')).isSymbolicLink()).toBe(
true, true,
@@ -110,6 +110,7 @@ export interface FleetLaunchComposition {
readonly profilePath: string; readonly profilePath: string;
readonly profile: FleetAgentLaunchProfile; readonly profile: FleetAgentLaunchProfile;
readonly agentDir: string; readonly agentDir: string;
readonly systemHome: string;
readonly seatHome: string; readonly seatHome: string;
readonly settings: { readonly settings: {
readonly layers: readonly SettingsLayer[]; readonly layers: readonly SettingsLayer[];
@@ -749,6 +750,7 @@ export function resolveFleetLaunchComposition(
return { return {
name, name,
profilePath, profilePath,
systemHome: roots.systemHome,
profile, profile,
agentDir, agentDir,
seatHome, seatHome,
@@ -939,7 +941,10 @@ export function registerFleetLaunchCommand(
applyFleetLaunchComposition(plan); applyFleetLaunchComposition(plan);
console.log(`[mosaic] bundle: ${plan.bundle.display}`); console.log(`[mosaic] bundle: ${plan.bundle.display}`);
const launcher = deps.launcher ?? launchFleetRuntime; const launcher = deps.launcher ?? launchFleetRuntime;
launcher(plan.profile.harness, plan.argv.slice(1), plan.env, { agentDir: plan.agentDir }); launcher(plan.profile.harness, plan.argv.slice(1), plan.env, {
agentDir: plan.agentDir,
mosaicHome: plan.systemHome,
});
} catch (error: unknown) { } catch (error: unknown) {
process.exitCode = 1; process.exitCode = 1;
const code = error instanceof FleetLaunchError ? `${error.code}: ` : ''; const code = error instanceof FleetLaunchError ? `${error.code}: ` : '';
+4 -1
View File
@@ -2076,7 +2076,10 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
// `fleet agent new` owns user-data harness homes under ~/.mosaic. The // `fleet agent new` owns user-data harness homes under ~/.mosaic. The
// existing roster-v2 CRUD remains direct fleet control-plane CRUD, so there // existing roster-v2 CRUD remains direct fleet control-plane CRUD, so there
// is one `agent` namespace but deliberately separate state authorities. // is one `agent` namespace but deliberately separate state authorities.
registerFleetAgentScaffoldCommand(cmd, { fleetDataHome: deps.fleetDataHome }); registerFleetAgentScaffoldCommand(cmd, {
...(deps.fleetDataHome === undefined ? {} : { fleetDataHome: deps.fleetDataHome }),
mosaicHomeFor: () => cmd.opts<{ mosaicHome: string }>().mosaicHome,
});
// Roster-v2 desired-state mutations belong directly to the fleet control // Roster-v2 desired-state mutations belong directly to the fleet control
// plane; they do not share the root `mosaic agent` gateway-backed surface. // plane; they do not share the root `mosaic agent` gateway-backed surface.
registerFleetAgentCrudCommands(cmd, deps); registerFleetAgentCrudCommands(cmd, deps);
+164 -1
View File
@@ -1,6 +1,17 @@
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
import { Command } from 'commander'; import { Command } from 'commander';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs'; import { spawnSync } from 'node:child_process';
import {
chmodSync,
copyFileSync,
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
writeFileSync,
symlinkSync,
rmSync,
} from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { import {
@@ -8,6 +19,7 @@ import {
enumerateSkillDirs, enumerateSkillDirs,
piForceSkillNames, piForceSkillNames,
registerRuntimeLaunchers, registerRuntimeLaunchers,
checkSequentialThinking,
type RuntimeLaunchHandler, type RuntimeLaunchHandler,
type ClaudexLaunchHandler, type ClaudexLaunchHandler,
} from './launch.js'; } from './launch.js';
@@ -86,6 +98,157 @@ describe('registerRuntimeLaunchers — non-yolo subcommands', () => {
}); });
}); });
describe('checkSequentialThinking', () => {
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-'));
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
try {
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'],
},
},
}),
);
vi.stubEnv('MOSAIC_HOME', installed);
vi.stubEnv('HOME', home);
expect(() =>
checkSequentialThinking('claude', { agentDir, mosaicHome: installed }),
).not.toThrow();
} finally {
vi.unstubAllEnvs();
rmSync(home, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
rmSync(installed, { recursive: true, force: true });
}
});
it('repairs a legacy seat config in place without using operator 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');
const bin = join(installed, 'bin');
try {
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
mkdirSync(join(agentDir, '.claude'), { recursive: true });
mkdirSync(bin, { recursive: true });
copyFileSync(
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
checker,
);
for (const name of ['node', 'npx']) {
writeFileSync(join(bin, name), '#!/usr/bin/env bash\nexit 0\n');
chmodSync(join(bin, name), 0o755);
}
writeFileSync(
join(agentDir, '.claude', '.claude.json'),
JSON.stringify({ hasCompletedOnboarding: true, theme: 'dark' }),
);
const env = { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` };
expect(
spawnSync(
checker,
['--runtime', 'claude', '--claude-config-dir', join(agentDir, '.claude')],
{
env,
},
).status,
).toBe(0);
expect(
spawnSync(
checker,
['--check', '--runtime', 'claude', '--claude-config-dir', join(agentDir, '.claude')],
{ env },
).status,
).toBe(0);
expect(
JSON.parse(readFileSync(join(agentDir, '.claude', '.claude.json'), 'utf8')),
).toMatchObject({
hasCompletedOnboarding: true,
theme: 'dark',
mcpServers: { 'sequential-thinking': { command: 'npx' } },
});
expect(existsSync(join(home, '.claude.json'))).toBe(false);
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
rmSync(installed, { recursive: true, force: true });
}
});
it('rejects a group-writable installed helper root', () => {
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 {
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
copyFileSync(
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
checker,
);
chmodSync(installed, 0o770);
expect(() => checkSequentialThinking('claude', { agentDir, mosaicHome: installed })).toThrow(
/not a trusted installed file/,
);
} finally {
chmodSync(installed, 0o700);
rmSync(agentDir, { recursive: true, force: true });
rmSync(installed, { recursive: true, force: true });
}
});
it('refuses an empty seat even when operator HOME is configured', () => {
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('MOSAIC_HOME', installed);
vi.stubEnv('HOME', home);
expect(() => checkSequentialThinking('claude', { agentDir, mosaicHome: installed })).toThrow(
'process.exit called',
);
expect(exit).toHaveBeenCalledWith(1);
} finally {
exit.mockRestore();
vi.unstubAllEnvs();
rmSync(home, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
rmSync(installed, { recursive: true, force: true });
}
});
});
describe('buildPiSkillArgs', () => { describe('buildPiSkillArgs', () => {
it('disables auto-discovery but force-loads fleet-critical skills by default', () => { it('disables auto-discovery but force-loads fleet-critical skills by default', () => {
expect(buildPiSkillArgs([], {}, fakeSkills, fakeForced)).toEqual([ expect(buildPiSkillArgs([], {}, fakeSkills, fakeForced)).toEqual([
+64 -7
View File
@@ -8,6 +8,7 @@
import { execFileSync, execSync, spawnSync } from 'node:child_process'; import { execFileSync, execSync, spawnSync } from 'node:child_process';
import { import {
existsSync, existsSync,
lstatSync,
mkdirSync, mkdirSync,
readFileSync, readFileSync,
writeFileSync, writeFileSync,
@@ -19,14 +20,14 @@ import {
import { createHash, randomBytes } from 'node:crypto'; import { createHash, randomBytes } from 'node:crypto';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { homedir, hostname } from 'node:os'; import { homedir, hostname } from 'node:os';
import { join, dirname } from 'node:path'; import { join, dirname, relative, resolve, sep } from 'node:path';
import type { Command } from 'commander'; import type { Command } from 'commander';
import { import {
buildResolvedFleetCommsBlock, buildResolvedFleetCommsBlock,
renderToolsContractStatus, renderToolsContractStatus,
resolveFleetIdentity, resolveFleetIdentity,
} from '../fleet/comms-onboarding.js'; } from '../fleet/comms-onboarding.js';
import { readRegularFileSecure } from '../fleet/secure-file.js'; import { assertNoSymlinkAncestors, readRegularFileSecure } from '../fleet/secure-file.js';
import { readPersonaContractBlock } from '../fleet/persona-contract.js'; import { readPersonaContractBlock } from '../fleet/persona-contract.js';
import { canonicalizeRoleClass } from './fleet-personas.js'; import { canonicalizeRoleClass } from './fleet-personas.js';
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js'; import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
@@ -40,6 +41,8 @@ export type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
/** Fleet context for the single harness-home resolution seam. */ /** Fleet context for the single harness-home resolution seam. */
export interface FleetHarnessContext { export interface FleetHarnessContext {
readonly agentDir: string; readonly agentDir: string;
/** Active installed Mosaic root for fleet-specific helper resolution. */
readonly mosaicHome?: string;
} }
const RUNTIME_LABELS: Record<RuntimeName, string> = { const RUNTIME_LABELS: Record<RuntimeName, string> = {
@@ -343,13 +346,67 @@ function printSettingsWarnings(audit: SettingsAudit): void {
); );
} }
function checkSequentialThinking(runtime: string): void { function trustedFleetHelper(mosaicHome: string): string {
const checker = fwScript('mosaic-ensure-sequential-thinking'); const root = resolve(mosaicHome);
const checker = join(root, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
try {
assertNoSymlinkAncestors(checker);
const owner = typeof process.getuid === 'function' ? process.getuid() : undefined;
let cursor = root;
for (const component of relative(root, checker).split(sep).filter(Boolean)) {
const info = lstatSync(cursor);
if (
!info.isDirectory() ||
info.isSymbolicLink() ||
(info.mode & 0o022) !== 0 ||
(owner !== undefined && info.uid !== owner && info.uid !== 0)
) {
throw new Error('helper directory has unsafe type, owner, or permissions');
}
cursor = join(cursor, component);
}
const helperInfo = lstatSync(checker);
if (
!helperInfo.isFile() ||
helperInfo.isSymbolicLink() ||
(helperInfo.mode & 0o022) !== 0 ||
(helperInfo.mode & 0o111) === 0 ||
(owner !== undefined && helperInfo.uid !== owner && helperInfo.uid !== 0)
) {
throw new Error('helper has unsafe type, owner, or permissions');
}
} catch (error: unknown) {
throw new Error(
`fleet sequential-thinking helper is not a trusted installed file under ${root}: ${error instanceof Error ? error.message : String(error)}`,
);
}
return checker;
}
export function checkSequentialThinking(runtime: RuntimeName, fleet?: FleetHarnessContext): void {
// Fleet launch must use the active --mosaic-home installation. Non-fleet
// launches retain the package/deployed helper resolver.
const checker = fleet?.mosaicHome
? trustedFleetHelper(fleet.mosaicHome)
: fwScript('mosaic-ensure-sequential-thinking');
if (!existsSync(checker)) return; // Skip if checker doesn't exist if (!existsSync(checker)) return; // Skip if checker doesn't exist
const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' }); const fleetClaudeConfig =
runtime === 'claude' && fleet ? harnessHome('claude', fleet) : undefined;
const result = spawnSync(
checker,
[
'--check',
'--runtime',
runtime,
...(fleetClaudeConfig === undefined ? [] : ['--claude-config-dir', fleetClaudeConfig]),
],
{ stdio: 'ignore' },
);
if (result.status !== 0) { if (result.status !== 0) {
console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.'); console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.');
console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}`); const repairArgs =
fleetClaudeConfig === undefined ? '' : ` --claude-config-dir ${fleetClaudeConfig}`;
console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}${repairArgs}`);
process.exit(1); process.exit(1);
} }
} }
@@ -947,7 +1004,7 @@ function launchRuntime(
// Pi doesn't need sequential-thinking (has native thinking levels) // Pi doesn't need sequential-thinking (has native thinking levels)
if (runtime !== 'pi') { if (runtime !== 'pi') {
checkSequentialThinking(runtime); checkSequentialThinking(runtime, context.fleet);
} }
checkResumableSession(); checkResumableSession();
@@ -1,3 +1,4 @@
import { readFileSync } from 'node:fs';
import { lstat, mkdir, readFile, readdir, readlink, symlink, writeFile } from 'node:fs/promises'; import { lstat, mkdir, readFile, readdir, readlink, symlink, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { isAbsolute, join, relative, resolve } from 'node:path'; import { isAbsolute, join, relative, resolve } from 'node:path';
@@ -6,6 +7,8 @@ export type FleetAgentHarness = 'claude' | 'pi';
export interface FleetAgentScaffoldOptions { export interface FleetAgentScaffoldOptions {
readonly dataHome?: string; readonly dataHome?: string;
/** Active installed Mosaic root; supplies the canonical authored runtime base. */
readonly mosaicHome?: string;
readonly name: string; readonly name: string;
readonly harness?: string; readonly harness?: string;
readonly bundle?: string; readonly bundle?: string;
@@ -48,6 +51,7 @@ export async function scaffoldFleetAgent(
const bundle = requireBundle(options.bundle ?? 'primary'); const bundle = requireBundle(options.bundle ?? 'primary');
const model = optionalNonEmpty(options.model, '--model'); const model = optionalNonEmpty(options.model, '--model');
const dataHome = resolve(options.dataHome ?? defaultFleetDataHome()); const dataHome = resolve(options.dataHome ?? defaultFleetDataHome());
const mosaicHome = resolve(options.mosaicHome ?? join(homedir(), '.config', 'mosaic'));
const agentDir = join(dataHome, 'fleet', 'agents', name); const agentDir = join(dataHome, 'fleet', 'agents', name);
const homeName = harness === 'claude' ? '.claude' : '.pi'; const homeName = harness === 'claude' ? '.claude' : '.pi';
const credentialName = harness === 'claude' ? '.credentials.json' : 'auth.json'; const credentialName = harness === 'claude' ? '.credentials.json' : 'auth.json';
@@ -73,7 +77,7 @@ export async function scaffoldFleetAgent(
if (harness === 'claude') { if (harness === 'claude') {
entries.push([ entries.push([
join(homeName, '.claude.json'), join(homeName, '.claude.json'),
{ type: 'file', content: json(onboardingState()) }, { type: 'file', content: json(onboardingState(mosaicHome)) },
]); ]);
} }
const files = new Map<string, ExpectedFile>(entries); const files = new Map<string, ExpectedFile>(entries);
@@ -232,8 +236,33 @@ function optionalNonEmpty(value: string | undefined, option: string): string | u
return value; return value;
} }
function onboardingState(): Record<string, unknown> { function onboardingState(mosaicHome: string): Record<string, unknown> {
return { hasCompletedOnboarding: true, theme: 'dark' }; const settingsPath = join(mosaicHome, 'runtime', 'claude', 'settings.json');
let authored: unknown;
try {
authored = JSON.parse(readFileSync(settingsPath, 'utf8')) as unknown;
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
throw new FleetAgentScaffoldError(
'invalid-request',
`canonical Claude settings are unavailable or invalid at ${settingsPath}: ${detail}`,
);
}
if (
typeof authored !== 'object' ||
authored === null ||
Array.isArray(authored) ||
!('mcpServers' in authored) ||
typeof authored.mcpServers !== 'object' ||
authored.mcpServers === null ||
Array.isArray(authored.mcpServers)
) {
throw new FleetAgentScaffoldError(
'invalid-request',
`canonical Claude settings lack an mcpServers object: ${settingsPath}`,
);
}
return { hasCompletedOnboarding: true, theme: 'dark', mcpServers: authored.mcpServers };
} }
function soul(name: string): string { function soul(name: string): string {