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:
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user