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:
@@ -5,6 +5,7 @@ MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
MODE="apply"
|
||||
RUNTIME="all"
|
||||
STRICT_CHECK=0
|
||||
CLAUDE_CONFIG_DIR=""
|
||||
|
||||
PKG="@modelcontextprotocol/server-sequential-thinking"
|
||||
|
||||
@@ -29,6 +30,14 @@ while [[ $# -gt 0 ]]; do
|
||||
STRICT_CHECK=1
|
||||
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"
|
||||
exit 2
|
||||
@@ -67,10 +76,16 @@ warm_package() {
|
||||
}
|
||||
|
||||
check_claude_config() {
|
||||
python3 - <<'PY'
|
||||
CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
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():
|
||||
raise SystemExit(1)
|
||||
try:
|
||||
@@ -92,10 +107,15 @@ PY
|
||||
}
|
||||
|
||||
apply_claude_config() {
|
||||
python3 - <<'PY'
|
||||
CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
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)
|
||||
if p.exists():
|
||||
try:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { lstat, mkdtemp, readFile, readdir, readlink, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
@@ -23,7 +24,23 @@ function program(dataHome: string): Command {
|
||||
const result = new Command();
|
||||
result.exitOverride();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -59,9 +76,16 @@ describe('mosaic fleet agent new', (): void => {
|
||||
env: { MOSAIC_AGENT_NAME: 'mira' },
|
||||
});
|
||||
expect(await readFile(join(agent, 'SOUL.md'), 'utf8')).toContain('## Identity');
|
||||
expect(await readFile(join(agent, '.claude', '.claude.json'), 'utf8')).toEqual(
|
||||
`${JSON.stringify({ hasCompletedOnboarding: true, theme: 'dark' }, null, 2)}\n`,
|
||||
);
|
||||
expect(JSON.parse(await readFile(join(agent, '.claude', '.claude.json'), 'utf8'))).toEqual({
|
||||
hasCompletedOnboarding: true,
|
||||
theme: 'dark',
|
||||
mcpServers: {
|
||||
'sequential-thinking': {
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(await readlink(join(agent, '.claude', '.credentials.json'))).toBe(
|
||||
join(dataHome, 'auth', 'claude', 'primary', '.credentials.json'),
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { FleetAgentScaffoldError, scaffoldFleetAgent } from '../fleet/fleet-agen
|
||||
export interface FleetAgentScaffoldCommandDeps {
|
||||
/** Test seam for the user-owned ~/.mosaic root. */
|
||||
readonly fleetDataHome?: string;
|
||||
/** Resolves the active installed Mosaic root that owns the canonical runtime base. */
|
||||
readonly mosaicHomeFor?: () => string;
|
||||
}
|
||||
|
||||
interface NewAgentOptions {
|
||||
@@ -35,6 +37,7 @@ export function registerFleetAgentScaffoldCommand(
|
||||
bundle: options.bundle,
|
||||
model: options.model,
|
||||
...(deps.fleetDataHome === undefined ? {} : { dataHome: deps.fleetDataHome }),
|
||||
...(deps.mosaicHomeFor === undefined ? {} : { mosaicHome: deps.mosaicHomeFor() }),
|
||||
});
|
||||
console.log(
|
||||
result.idempotent
|
||||
|
||||
@@ -604,7 +604,7 @@ describe('fleet launch command outcomes', () => {
|
||||
MOSAIC_AGENT_NAME: 'fred',
|
||||
SEAT_FLAG: 'yes',
|
||||
},
|
||||
{ agentDir: fx.agentDir },
|
||||
{ agentDir: fx.agentDir, mosaicHome: fx.systemHome },
|
||||
);
|
||||
expect(lstatSync(join(fx.agentDir, '.claude', '.credentials.json')).isSymbolicLink()).toBe(
|
||||
true,
|
||||
|
||||
@@ -110,6 +110,7 @@ export interface FleetLaunchComposition {
|
||||
readonly profilePath: string;
|
||||
readonly profile: FleetAgentLaunchProfile;
|
||||
readonly agentDir: string;
|
||||
readonly systemHome: string;
|
||||
readonly seatHome: string;
|
||||
readonly settings: {
|
||||
readonly layers: readonly SettingsLayer[];
|
||||
@@ -749,6 +750,7 @@ export function resolveFleetLaunchComposition(
|
||||
return {
|
||||
name,
|
||||
profilePath,
|
||||
systemHome: roots.systemHome,
|
||||
profile,
|
||||
agentDir,
|
||||
seatHome,
|
||||
@@ -939,7 +941,10 @@ export function registerFleetLaunchCommand(
|
||||
applyFleetLaunchComposition(plan);
|
||||
console.log(`[mosaic] bundle: ${plan.bundle.display}`);
|
||||
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) {
|
||||
process.exitCode = 1;
|
||||
const code = error instanceof FleetLaunchError ? `${error.code}: ` : '';
|
||||
|
||||
@@ -2076,7 +2076,10 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
// `fleet agent new` owns user-data harness homes under ~/.mosaic. The
|
||||
// existing roster-v2 CRUD remains direct fleet control-plane CRUD, so there
|
||||
// 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
|
||||
// plane; they do not share the root `mosaic agent` gateway-backed surface.
|
||||
registerFleetAgentCrudCommands(cmd, deps);
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
|
||||
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 { join } from 'node:path';
|
||||
import {
|
||||
@@ -8,6 +19,7 @@ import {
|
||||
enumerateSkillDirs,
|
||||
piForceSkillNames,
|
||||
registerRuntimeLaunchers,
|
||||
checkSequentialThinking,
|
||||
type RuntimeLaunchHandler,
|
||||
type ClaudexLaunchHandler,
|
||||
} 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', () => {
|
||||
it('disables auto-discovery but force-loads fleet-critical skills by default', () => {
|
||||
expect(buildPiSkillArgs([], {}, fakeSkills, fakeForced)).toEqual([
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { execFileSync, execSync, spawnSync } from 'node:child_process';
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
@@ -19,14 +20,14 @@ import {
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
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 {
|
||||
buildResolvedFleetCommsBlock,
|
||||
renderToolsContractStatus,
|
||||
resolveFleetIdentity,
|
||||
} 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 { canonicalizeRoleClass } from './fleet-personas.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. */
|
||||
export interface FleetHarnessContext {
|
||||
readonly agentDir: string;
|
||||
/** Active installed Mosaic root for fleet-specific helper resolution. */
|
||||
readonly mosaicHome?: string;
|
||||
}
|
||||
|
||||
const RUNTIME_LABELS: Record<RuntimeName, string> = {
|
||||
@@ -343,13 +346,67 @@ function printSettingsWarnings(audit: SettingsAudit): void {
|
||||
);
|
||||
}
|
||||
|
||||
function checkSequentialThinking(runtime: string): void {
|
||||
const checker = fwScript('mosaic-ensure-sequential-thinking');
|
||||
function trustedFleetHelper(mosaicHome: string): string {
|
||||
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
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -947,7 +1004,7 @@ function launchRuntime(
|
||||
|
||||
// Pi doesn't need sequential-thinking (has native thinking levels)
|
||||
if (runtime !== 'pi') {
|
||||
checkSequentialThinking(runtime);
|
||||
checkSequentialThinking(runtime, context.fleet);
|
||||
}
|
||||
|
||||
checkResumableSession();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { lstat, mkdir, readFile, readdir, readlink, symlink, writeFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
@@ -6,6 +7,8 @@ export type FleetAgentHarness = 'claude' | 'pi';
|
||||
|
||||
export interface FleetAgentScaffoldOptions {
|
||||
readonly dataHome?: string;
|
||||
/** Active installed Mosaic root; supplies the canonical authored runtime base. */
|
||||
readonly mosaicHome?: string;
|
||||
readonly name: string;
|
||||
readonly harness?: string;
|
||||
readonly bundle?: string;
|
||||
@@ -48,6 +51,7 @@ export async function scaffoldFleetAgent(
|
||||
const bundle = requireBundle(options.bundle ?? 'primary');
|
||||
const model = optionalNonEmpty(options.model, '--model');
|
||||
const dataHome = resolve(options.dataHome ?? defaultFleetDataHome());
|
||||
const mosaicHome = resolve(options.mosaicHome ?? join(homedir(), '.config', 'mosaic'));
|
||||
const agentDir = join(dataHome, 'fleet', 'agents', name);
|
||||
const homeName = harness === 'claude' ? '.claude' : '.pi';
|
||||
const credentialName = harness === 'claude' ? '.credentials.json' : 'auth.json';
|
||||
@@ -73,7 +77,7 @@ export async function scaffoldFleetAgent(
|
||||
if (harness === 'claude') {
|
||||
entries.push([
|
||||
join(homeName, '.claude.json'),
|
||||
{ type: 'file', content: json(onboardingState()) },
|
||||
{ type: 'file', content: json(onboardingState(mosaicHome)) },
|
||||
]);
|
||||
}
|
||||
const files = new Map<string, ExpectedFile>(entries);
|
||||
@@ -232,8 +236,33 @@ function optionalNonEmpty(value: string | undefined, option: string): string | u
|
||||
return value;
|
||||
}
|
||||
|
||||
function onboardingState(): Record<string, unknown> {
|
||||
return { hasCompletedOnboarding: true, theme: 'dark' };
|
||||
function onboardingState(mosaicHome: string): Record<string, unknown> {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user