Claude Code saves credentials by writing a sibling temp file and rename()-ing it over the target. rename(2) replaces a symlink rather than following it, so the managed link W-F1/W-F2 planted at <seat>/.claude/.credentials.json is destroyed by the first token refresh and the seat silently forks its credentials. The in-place fallback arm opens with O_NOFOLLOW and would refuse the link anyway. Evidence, quoting the 2.1.232 binary: docs/reports/harness/claude-credential-write-path-2026-08-14.md (jarvis-brain). CLAUDE_SECURESTORAGE_CONFIG_DIR resolves the credential directory independently of CLAUDE_CONFIG_DIR, so the temp file and the rename both land inside the bundle. That is the property the design wanted -- share the credential, never the transcripts -- with no symlink and no privileges. - new fleet/credential-sharing.ts owns the harness -> credential-file and harness -> credential-directory-variable maps, so scaffold and launch cannot disagree about the mechanism. It also removes the duplicate credential-file name table the two already carried. - launch composes CLAUDE_SECURESTORAGE_CONFIG_DIR from the resolved bundle directory and plans no credential link for Claude. The value is always the absolute bundle path: Claude reads an empty value as ~/.claude, which is the operator's own account. - scaffold stops emitting the credential symlink and its manifest entry for Claude, and tolerates one left by an earlier scaffold rather than reporting it as a foreign file or rewriting it. - FIRST_AUTH_REFUSAL still fires when a real file occupies the seat path. - Harnesses absent from the map (pi, codex, opencode) keep managed links; the containment specs now exercise them on pi. Answers promotion gate #1 negatively for the frozen mechanism and positively for the replacement. E3.3 (two seats refreshing one bundle at once) is still open.
803 lines
29 KiB
TypeScript
803 lines
29 KiB
TypeScript
import {
|
|
chmodSync,
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
readlinkSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { Command } from 'commander';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
applyFleetLaunchComposition,
|
|
deepMergeSettings,
|
|
FleetLaunchError,
|
|
formatFleetLaunchDryRun,
|
|
parseFleetAgentProfile,
|
|
registerFleetLaunchCommand,
|
|
resolveFleetLaunchComposition,
|
|
} from './fleet-launch-command.js';
|
|
|
|
const roots: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
function fixture(profile: Record<string, unknown> = { schema: 1, harness: 'claude' }): {
|
|
root: string;
|
|
systemHome: string;
|
|
userHome: string;
|
|
agentDir: string;
|
|
namedBundleDir: string;
|
|
credentialName: string;
|
|
} {
|
|
const harness = String(profile.harness ?? 'claude');
|
|
const credentialName = harness === 'claude' ? '.credentials.json' : 'auth.json';
|
|
const root = mkdtempSync(join(tmpdir(), 'mosaic-fleet-launch-'));
|
|
roots.push(root);
|
|
const systemHome = join(root, 'system');
|
|
const userHome = join(root, 'user');
|
|
const agentDir = join(userHome, 'fleet', 'agents', 'fred');
|
|
const namedBundleDir = join(userHome, 'auth', harness, 'fred_example.com');
|
|
mkdirSync(join(systemHome, 'runtime', harness), { recursive: true });
|
|
mkdirSync(agentDir, { recursive: true });
|
|
mkdirSync(namedBundleDir, { recursive: true });
|
|
writeFileSync(join(systemHome, 'runtime', harness, 'settings.json'), '{}\n');
|
|
writeFileSync(join(agentDir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`);
|
|
writeFileSync(join(namedBundleDir, credentialName), '{}\n', { mode: 0o600 });
|
|
writeFileSync(
|
|
join(namedBundleDir, 'account.json'),
|
|
'{"oauthAccount":{"emailAddress":"[email protected]"}}\n',
|
|
);
|
|
symlinkSync('fred_example.com', join(userHome, 'auth', harness, 'primary'), 'dir');
|
|
return { root, systemHome, userHome, agentDir, namedBundleDir, credentialName };
|
|
}
|
|
|
|
describe('fleet launch profile schema 1', () => {
|
|
it('rejects an unknown key and names it', () => {
|
|
expect(() =>
|
|
parseFleetAgentProfile('{"schema":1,"harness":"claude","pluigns":[]}'),
|
|
).toThrowError(/unknown profile key "pluigns"/);
|
|
});
|
|
|
|
it('uses a dedicated SCHEMA_TOO_NEW error with an upgrade hint', () => {
|
|
try {
|
|
parseFleetAgentProfile('{"schema":2,"harness":"claude"}');
|
|
throw new Error('expected parse to fail');
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(FleetLaunchError);
|
|
expect((error as FleetLaunchError).code).toBe('SCHEMA_TOO_NEW');
|
|
expect((error as Error).message).toMatch(/upgrade Mosaic/i);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('three-layer settings merge', () => {
|
|
it('keeps base-only settings', () => {
|
|
expect(deepMergeSettings({ base: { enabled: true } })).toEqual({ base: { enabled: true } });
|
|
});
|
|
|
|
it('uses the last layer for scalar conflicts', () => {
|
|
expect(deepMergeSettings({ model: 'base' }, { model: 'user' })).toEqual({ model: 'user' });
|
|
});
|
|
|
|
it('replaces arrays instead of appending', () => {
|
|
expect(deepMergeSettings({ hooks: ['base'] }, { hooks: ['user'] })).toEqual({
|
|
hooks: ['user'],
|
|
});
|
|
});
|
|
|
|
it('uses null as a key-deleting tombstone', () => {
|
|
expect(
|
|
deepMergeSettings({ nested: { keep: true, remove: true } }, { nested: { remove: null } }),
|
|
).toEqual({ nested: { keep: true } });
|
|
});
|
|
|
|
it('replaces a hook event array wholesale with the higher layer', () => {
|
|
const qaStop = { hooks: [{ type: 'command', command: 'qa-stop.sh' }] };
|
|
const leaseStop = { hooks: [{ type: 'command', command: 'receipt-observer.py' }] };
|
|
const qaPre = { matcher: 'Write', hooks: [{ type: 'command', command: 'qa-pre.sh' }] };
|
|
expect(
|
|
deepMergeSettings(
|
|
{ hooks: { Stop: [qaStop], PreToolUse: [qaPre] } },
|
|
{ hooks: { Stop: [leaseStop] } },
|
|
),
|
|
).toEqual({ hooks: { Stop: [leaseStop], PreToolUse: [qaPre] } });
|
|
});
|
|
|
|
it('reconstructs every gated hook event from the base and lease overlay', () => {
|
|
const fx = fixture({ schema: 1, harness: 'claude', overlay: 'lease-overlay.json' });
|
|
const frameworkRuntime = join(process.cwd(), 'framework', 'runtime', 'claude');
|
|
writeFileSync(
|
|
join(fx.systemHome, 'runtime', 'claude', 'settings.json'),
|
|
readFileSync(join(frameworkRuntime, 'settings.json'), 'utf8'),
|
|
);
|
|
writeFileSync(
|
|
join(fx.agentDir, 'lease-overlay.json'),
|
|
readFileSync(join(frameworkRuntime, 'lease-overlay.json'), 'utf8'),
|
|
);
|
|
|
|
const plan = resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
});
|
|
|
|
expect(plan.settings.merged['hooks']).toEqual({
|
|
PreToolUse: [
|
|
{
|
|
matcher: 'Write|Edit|MultiEdit',
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: '~/.config/mosaic/tools/qa/prevent-memory-write.sh',
|
|
timeout: 10,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
matcher: '.*',
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command:
|
|
'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py',
|
|
timeout: 3,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
PostToolUse: [
|
|
{
|
|
matcher: 'Edit|MultiEdit|Write',
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: '~/.config/mosaic/tools/qa/qa-hook-stdin.sh',
|
|
timeout: 60,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
matcher: 'Edit|MultiEdit|Write',
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: '~/.config/mosaic/tools/qa/typecheck-hook.sh',
|
|
timeout: 30,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
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,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
PreCompact: [
|
|
{
|
|
matcher: '.*',
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command:
|
|
'python3 "$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py" --runtime claude --reason pre-compact',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
SessionStart: [
|
|
{
|
|
matcher: 'compact',
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command:
|
|
'python3 "$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py" --runtime claude --reason session-start-compact',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
matcher: 'resume|clear',
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command:
|
|
'python3 "$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py" --runtime claude --reason session-start-rollover --bump-generation',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
UserPromptSubmit: [
|
|
{
|
|
matcher: '^/mosaic-promote$',
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: 'python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py',
|
|
timeout: 15,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('still deletes a whole hook event via the null tombstone', () => {
|
|
expect(
|
|
deepMergeSettings(
|
|
{ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'qa-stop.sh' }] }] } },
|
|
{ hooks: { Stop: null } },
|
|
),
|
|
).toEqual({ hooks: {} });
|
|
});
|
|
|
|
it('replaces an allowedCommands-shaped non-hook array wholesale', () => {
|
|
expect(
|
|
deepMergeSettings(
|
|
{ allowedCommands: ['pnpm', 'git'], nested: { hooks: { Stop: ['base'] } } },
|
|
{ allowedCommands: ['node'], nested: { hooks: { Stop: ['user'] } } },
|
|
),
|
|
).toEqual({ allowedCommands: ['node'], nested: { hooks: { Stop: ['user'] } } });
|
|
});
|
|
|
|
it('deep-merges all three layers in precedence order', () => {
|
|
expect(
|
|
deepMergeSettings(
|
|
{ nested: { system: true, shared: 'system' }, list: [1] },
|
|
{ nested: { user: true, shared: 'user' }, list: [2] },
|
|
{ nested: { agent: true, shared: 'agent' }, list: [3] },
|
|
),
|
|
).toEqual({
|
|
nested: { system: true, user: true, agent: true, shared: 'agent' },
|
|
list: [3],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('profile-selected overlay', () => {
|
|
it('defaults to no overlay when the optional profile field is omitted', () => {
|
|
const fx = fixture();
|
|
writeFileSync(join(fx.agentDir, 'overlay.json'), '{"mustNotLoad":true}\n');
|
|
|
|
const plan = resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
});
|
|
expect(plan.settings.merged).toEqual({});
|
|
expect(plan.settings.layers[2]?.present).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('unscaffolded agent names', () => {
|
|
it('points an unscaffolded name at mosaic fleet agent new', () => {
|
|
const fx = fixture();
|
|
try {
|
|
resolveFleetLaunchComposition('ghost', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
});
|
|
throw new Error('expected resolution to fail');
|
|
} catch (error: unknown) {
|
|
const launchError = error as FleetLaunchError;
|
|
expect(launchError.code).toBe('AGENT_NOT_SCAFFOLDED');
|
|
expect(launchError.message).toContain("no such fleet agent 'ghost'");
|
|
expect(launchError.message).toContain('mosaic fleet agent new ghost');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('A3 credential validation', () => {
|
|
it('refuses a symlinked bundle credential file', () => {
|
|
const fx = fixture();
|
|
rmSync(join(fx.namedBundleDir, '.credentials.json'));
|
|
const outside = join(fx.root, 'outside-credentials.json');
|
|
writeFileSync(outside, '{}\n');
|
|
symlinkSync(outside, join(fx.namedBundleDir, '.credentials.json'));
|
|
|
|
expect(() =>
|
|
resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
}),
|
|
).toThrowError(/real, non-symlink credential file/);
|
|
});
|
|
|
|
it('refuses an auth ancestor symlink that relocates the credential trust root', () => {
|
|
const fx = fixture();
|
|
rmSync(join(fx.userHome, 'auth'), { recursive: true, force: true });
|
|
const outsideAuth = join(fx.root, 'outside-auth');
|
|
const outsideBundle = join(outsideAuth, 'claude', 'fred_example.com');
|
|
mkdirSync(outsideBundle, { recursive: true });
|
|
writeFileSync(join(outsideBundle, '.credentials.json'), '{}\n', { mode: 0o600 });
|
|
writeFileSync(
|
|
join(outsideBundle, 'account.json'),
|
|
'{"oauthAccount":{"emailAddress":"[email protected]"}}\n',
|
|
);
|
|
symlinkSync('fred_example.com', join(outsideAuth, 'claude', 'primary'), 'dir');
|
|
symlinkSync(outsideAuth, join(fx.userHome, 'auth'), 'dir');
|
|
|
|
expect(() =>
|
|
resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
}),
|
|
).toThrowError(/auth directory must be a real, non-symlink directory/);
|
|
});
|
|
|
|
it('refuses a group- or world-readable credential file', () => {
|
|
const fx = fixture();
|
|
chmodSync(join(fx.namedBundleDir, '.credentials.json'), 0o644);
|
|
|
|
expect(() =>
|
|
resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
}),
|
|
).toThrowError(/credential file must not grant group or other permissions/);
|
|
});
|
|
|
|
it('accepts a real private credential file contained in the harness auth root', () => {
|
|
const fx = fixture();
|
|
const plan = resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
});
|
|
|
|
expect(plan.credential.target).toBe(join(fx.namedBundleDir, '.credentials.json'));
|
|
expect(plan.bundle.display).toBe('primary -> fred_example.com ([email protected])');
|
|
});
|
|
|
|
it('refuses first-auth state when a real file occupies the seat link', () => {
|
|
const fx = fixture();
|
|
const seatHome = join(fx.agentDir, '.claude');
|
|
mkdirSync(seatHome, { recursive: true });
|
|
writeFileSync(join(seatHome, '.credentials.json'), '{"private":true}\n');
|
|
|
|
expect(() =>
|
|
resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
}),
|
|
).toThrowError(/first-auth.*refusing to delete or overwrite/i);
|
|
expect(lstatSync(join(seatHome, '.credentials.json')).isSymbolicLink()).toBe(false);
|
|
});
|
|
|
|
it('points Claude at the resolved bundle directory and plans no credential link', () => {
|
|
const fx = fixture();
|
|
const plan = resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
});
|
|
|
|
expect(plan.credential.link).toBeUndefined();
|
|
expect(plan.credential.dir).toBe(fx.namedBundleDir);
|
|
// An empty value resolves to ~/.claude, which is the operator's own account,
|
|
// so the exported value must always be the absolute bundle path.
|
|
expect(plan.env['CLAUDE_SECURESTORAGE_CONFIG_DIR']).toBe(fx.namedBundleDir);
|
|
expect(plan.env['CLAUDE_SECURESTORAGE_CONFIG_DIR']).not.toBe('');
|
|
});
|
|
|
|
it('keeps the managed credential link for a harness with no credential-directory variable', () => {
|
|
const fx = fixture({ schema: 1, harness: 'pi' });
|
|
const plan = resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
});
|
|
|
|
expect(plan.credential.link).toBe(join(fx.agentDir, '.pi', 'auth.json'));
|
|
expect(plan.credential.target).toBe(join(fx.namedBundleDir, 'auth.json'));
|
|
expect(Object.keys(plan.env)).not.toContain('CLAUDE_SECURESTORAGE_CONFIG_DIR');
|
|
});
|
|
});
|
|
|
|
describe('managed plugin and skill links', () => {
|
|
it('refuses an unrecorded foreign symlink without mutating it', () => {
|
|
const fx = fixture({ schema: 1, harness: 'claude', plugins: [] });
|
|
const pluginHome = join(fx.agentDir, '.claude', 'plugins');
|
|
const foreign = join(fx.root, 'foreign-plugin');
|
|
mkdirSync(pluginHome, { recursive: true });
|
|
mkdirSync(foreign, { recursive: true });
|
|
symlinkSync(foreign, join(pluginHome, 'foreign'), 'dir');
|
|
|
|
const plan = resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
});
|
|
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
|
|
/unrecorded or retargeted symlink/,
|
|
);
|
|
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 });
|
|
const initial = resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
});
|
|
applyFleetLaunchComposition(initial);
|
|
|
|
writeFileSync(
|
|
join(fx.agentDir, 'profile.json'),
|
|
'{"schema":1,"harness":"claude","plugins":[]}\n',
|
|
);
|
|
const plan = resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
});
|
|
const pluginHome = join(fx.agentDir, '.claude', 'plugins');
|
|
expect(plan.prune).toEqual([join(pluginHome, 'old')]);
|
|
applyFleetLaunchComposition(plan);
|
|
|
|
expect(() => lstatSync(join(pluginHome, 'old'))).toThrow();
|
|
});
|
|
|
|
it('refuses a recorded link retargeted after composition and leaves it intact', () => {
|
|
const fx = fixture({ schema: 1, harness: 'claude', plugins: ['old'] });
|
|
const managedTarget = join(fx.userHome, 'plugins', 'old');
|
|
const foreignTarget = join(fx.root, 'foreign-plugin');
|
|
mkdirSync(managedTarget, { recursive: true });
|
|
mkdirSync(foreignTarget, { recursive: true });
|
|
const initial = resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
});
|
|
applyFleetLaunchComposition(initial);
|
|
|
|
writeFileSync(
|
|
join(fx.agentDir, 'profile.json'),
|
|
'{"schema":1,"harness":"claude","plugins":[]}\n',
|
|
);
|
|
const plan = resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
});
|
|
const link = join(fx.agentDir, '.claude', 'plugins', 'old');
|
|
rmSync(link);
|
|
symlinkSync(foreignTarget, link, 'dir');
|
|
|
|
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
|
|
/unrecorded or retargeted symlink/,
|
|
);
|
|
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');
|
|
const sentinel = join(fx.root, 'sentinel.json');
|
|
mkdirSync(target, { recursive: true });
|
|
mkdirSync(join(fx.agentDir, '.claude'), { recursive: true });
|
|
writeFileSync(sentinel, 'unchanged\n', { mode: 0o600 });
|
|
symlinkSync(sentinel, join(fx.agentDir, '.claude', '.mosaic-managed-links.json.tmp'), 'file');
|
|
const plan = resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
});
|
|
|
|
expect(() => applyFleetLaunchComposition(plan)).toThrowError(/cannot be created exclusively/);
|
|
expect(readFileSync(sentinel, 'utf8')).toBe('unchanged\n');
|
|
});
|
|
|
|
// Credential links exist only for harnesses that are not pointed at their bundle
|
|
// by environment, so the containment rules are exercised on one of those.
|
|
it('refuses an exact-target unrecorded credential symlink', () => {
|
|
const fx = fixture({ schema: 1, harness: 'pi' });
|
|
const seatHome = join(fx.agentDir, '.pi');
|
|
const link = join(seatHome, fx.credentialName);
|
|
mkdirSync(seatHome, { recursive: true });
|
|
symlinkSync(join(fx.namedBundleDir, fx.credentialName), link, 'file');
|
|
const plan = resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
});
|
|
|
|
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
|
|
/unrecorded or retargeted symlink/,
|
|
);
|
|
expect(readlinkSync(link)).toBe(join(fx.namedBundleDir, fx.credentialName));
|
|
});
|
|
|
|
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({ schema: 1, harness: 'pi' });
|
|
const seatHome = join(fx.agentDir, '.pi');
|
|
const foreignCredential = join(fx.root, 'foreign-credential.json');
|
|
mkdirSync(seatHome, { recursive: true });
|
|
writeFileSync(foreignCredential, '{}\n', { mode: 0o600 });
|
|
symlinkSync(foreignCredential, join(seatHome, fx.credentialName), 'file');
|
|
const plan = resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
});
|
|
|
|
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
|
|
/unrecorded or retargeted symlink/,
|
|
);
|
|
expect(readFileSync(join(seatHome, fx.credentialName), 'utf8')).toBe('{}\n');
|
|
});
|
|
|
|
it('tolerates harness metadata files in the install root and still refuses real directories', () => {
|
|
const fx = fixture({ schema: 1, harness: 'claude', plugins: [] });
|
|
const pluginHome = join(fx.agentDir, '.claude', 'plugins');
|
|
mkdirSync(pluginHome, { recursive: true });
|
|
writeFileSync(join(pluginHome, 'installed_plugins.json'), '{}\n');
|
|
|
|
const plan = resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
});
|
|
expect(plan.prune).toEqual([]);
|
|
expect(readFileSync(join(pluginHome, 'installed_plugins.json'), 'utf8')).toBe('{}\n');
|
|
|
|
mkdirSync(join(pluginHome, 'stray-plugin'), { recursive: true });
|
|
expect(() =>
|
|
resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
}),
|
|
).toThrowError(/real plugin directory occupies managed install root.*refusing to prune/i);
|
|
});
|
|
|
|
it('surfaces a real directory at a managed link path without deleting it', () => {
|
|
const fx = fixture({ schema: 1, harness: 'claude', plugins: ['keep'] });
|
|
mkdirSync(join(fx.userHome, 'plugins', 'keep'), { recursive: true });
|
|
const occupied = join(fx.agentDir, '.claude', 'plugins', 'keep');
|
|
mkdirSync(occupied, { recursive: true });
|
|
|
|
expect(() =>
|
|
resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
}),
|
|
).toThrowError(/real plugin directory.*refusing to delete/i);
|
|
expect(lstatSync(occupied).isDirectory()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('fleet launch command outcomes', () => {
|
|
it('--dry-run prints without writing or invoking the launcher', () => {
|
|
const fx = fixture();
|
|
const program = new Command().exitOverride();
|
|
const fleet = program.command('fleet');
|
|
const launcher = vi.fn();
|
|
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
registerFleetLaunchCommand(fleet, () => fx.systemHome, {
|
|
userHome: fx.userHome,
|
|
launcher,
|
|
});
|
|
|
|
try {
|
|
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred', '--dry-run']);
|
|
expect(stdout).toHaveBeenCalledWith(
|
|
expect.stringContaining('mosaic fleet launch fred --dry-run'),
|
|
);
|
|
expect(launcher).not.toHaveBeenCalled();
|
|
expect(() => lstatSync(join(fx.agentDir, '.claude'))).toThrow();
|
|
} finally {
|
|
stdout.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('applies the plan and invokes the existing launch seam with declared values', () => {
|
|
const fx = fixture({
|
|
schema: 1,
|
|
harness: 'claude',
|
|
model: 'opus',
|
|
env: { SEAT_FLAG: 'yes' },
|
|
});
|
|
const program = new Command().exitOverride();
|
|
const fleet = program.command('fleet');
|
|
const launcher = vi.fn();
|
|
registerFleetLaunchCommand(fleet, () => fx.systemHome, {
|
|
userHome: fx.userHome,
|
|
launcher,
|
|
});
|
|
|
|
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred']);
|
|
|
|
expect(launcher).toHaveBeenCalledWith(
|
|
'claude',
|
|
['--model', 'opus'],
|
|
{
|
|
CLAUDE_CONFIG_DIR: join(fx.agentDir, '.claude'),
|
|
CLAUDE_SECURESTORAGE_CONFIG_DIR: fx.namedBundleDir,
|
|
MOSAIC_AGENT_NAME: 'fred',
|
|
SEAT_FLAG: 'yes',
|
|
},
|
|
{ agentDir: fx.agentDir, mosaicHome: fx.systemHome },
|
|
);
|
|
// The bundle is reached by environment, so nothing is planted at the seat path.
|
|
expect(existsSync(join(fx.agentDir, '.claude', '.credentials.json'))).toBe(false);
|
|
});
|
|
|
|
it('sets a non-zero exit code and never invokes the launcher', () => {
|
|
const fx = fixture({ schema: 1, harness: 'claude', unknown: true });
|
|
const program = new Command().exitOverride();
|
|
const fleet = program.command('fleet');
|
|
const launcher = vi.fn();
|
|
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
const priorExitCode = process.exitCode;
|
|
process.exitCode = 0;
|
|
registerFleetLaunchCommand(fleet, () => fx.systemHome, {
|
|
userHome: fx.userHome,
|
|
launcher,
|
|
});
|
|
|
|
try {
|
|
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred']);
|
|
expect(process.exitCode).toBe(1);
|
|
expect(launcher).not.toHaveBeenCalled();
|
|
expect(stderr).toHaveBeenCalledWith(expect.stringContaining('unknown profile key "unknown"'));
|
|
} finally {
|
|
process.exitCode = priorExitCode;
|
|
stderr.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('dry-run composition', () => {
|
|
it('renders a deterministic full composition and writes nothing', () => {
|
|
const fx = fixture({
|
|
schema: 1,
|
|
harness: 'claude',
|
|
bundle: 'primary',
|
|
model: 'opus',
|
|
overlay: 'overlay.json',
|
|
plugins: ['code-review'],
|
|
skills: ['mosaic-tools'],
|
|
env: { SEAT_FLAG: 'yes' },
|
|
});
|
|
writeFileSync(
|
|
join(fx.systemHome, 'runtime', 'claude', 'settings.json'),
|
|
'{"theme":"dark","hooks":["system"],"nested":{"system":true}}\n',
|
|
);
|
|
mkdirSync(join(fx.userHome, 'config', 'claude'), { recursive: true });
|
|
writeFileSync(
|
|
join(fx.userHome, 'config', 'claude', 'settings.json'),
|
|
'{"hooks":["user"],"nested":{"user":true}}\n',
|
|
);
|
|
writeFileSync(join(fx.agentDir, 'overlay.json'), '{"theme":null,"nested":{"agent":true}}\n');
|
|
mkdirSync(join(fx.userHome, 'plugins', 'code-review'), { recursive: true });
|
|
mkdirSync(join(fx.userHome, 'skills', 'mosaic-tools'), { recursive: true });
|
|
|
|
const plan = resolveFleetLaunchComposition('fred', {
|
|
systemHome: fx.systemHome,
|
|
userHome: fx.userHome,
|
|
});
|
|
const output = formatFleetLaunchDryRun(plan).replaceAll(fx.root, '<ROOT>');
|
|
|
|
expect(output).toMatchInlineSnapshot(`
|
|
"mosaic fleet launch fred --dry-run
|
|
profile: <ROOT>/user/fleet/agents/fred/profile.json (schema 1)
|
|
harness: claude
|
|
seat-home: <ROOT>/user/fleet/agents/fred/.claude
|
|
settings sources:
|
|
system: <ROOT>/system/runtime/claude/settings.json
|
|
user: <ROOT>/user/config/claude/settings.json
|
|
agent: <ROOT>/user/fleet/agents/fred/overlay.json
|
|
output: <ROOT>/user/fleet/agents/fred/.claude/settings.json
|
|
snapshot: <ROOT>/user/fleet/agents/fred/settings.generated.json
|
|
merged settings:
|
|
{
|
|
"hooks": [
|
|
"user"
|
|
],
|
|
"nested": {
|
|
"agent": true,
|
|
"system": true,
|
|
"user": true
|
|
}
|
|
}
|
|
bundle: primary -> fred_example.com ([email protected])
|
|
credential: <ROOT>/user/auth/claude/fred_example.com/.credentials.json
|
|
symlinks:
|
|
plugin code-review: <ROOT>/user/fleet/agents/fred/.claude/plugins/code-review -> <ROOT>/user/plugins/code-review
|
|
skill mosaic-tools: <ROOT>/user/fleet/agents/fred/.claude/skills/mosaic-tools -> <ROOT>/user/skills/mosaic-tools
|
|
declared env:
|
|
CLAUDE_CONFIG_DIR=<ROOT>/user/fleet/agents/fred/.claude
|
|
CLAUDE_SECURESTORAGE_CONFIG_DIR=<ROOT>/user/auth/claude/fred_example.com
|
|
MOSAIC_AGENT_NAME=fred
|
|
SEAT_FLAG=yes
|
|
argv: ["claude","--model","opus"]"
|
|
`);
|
|
expect(() => readFileSync(join(fx.agentDir, '.claude', 'settings.json'), 'utf8')).toThrow();
|
|
|
|
applyFleetLaunchComposition(plan);
|
|
expect(JSON.parse(readFileSync(plan.settings.output, 'utf8'))).toEqual({
|
|
hooks: ['user'],
|
|
nested: { agent: true, system: true, user: true },
|
|
});
|
|
expect(readFileSync(plan.settings.snapshot, 'utf8')).toBe(
|
|
readFileSync(plan.settings.output, 'utf8'),
|
|
);
|
|
expect(plan.credential.link).toBeUndefined();
|
|
expect(plan.credential.dir).toBe(fx.namedBundleDir);
|
|
});
|
|
});
|