fleet: move directories off managed paths instead of refusing forever

Launch will not delete a real directory sitting where it expects a managed
link -- an auth/<harness>/primary that someone logged into by hand, or a
plugin directory a seat acquired before the central store existed. That
refusal is right and it is also a dead end: the operator gets a composition
error and no way forward.

`mosaic fleet adopt` is the way forward. Bare, it lists every such directory
and the command that resolves it. With a verb, it moves one where it belongs.

Nothing here deletes. A promotion is a rename; an occupied destination is a
refusal, not a merge; a cross-device rename is reported rather than retried as
copy-then-delete, because a copy-then-delete is a delete.

Store adoption stops at the move and does not install the link. The seat's
.mosaic-managed-links.json belongs to launch, and a link written behind it
fails the next composition as an unrecorded symlink -- one refusal traded for
another. The next launch installs and records it when the profile lists the
entry; whether a seat gets a plugin stays `mosaic fleet plugin`'s decision.

W-F3 of docs/plans/2026-08-14_fleet-seats-on-web1.md.
This commit is contained in:
terra
2026-08-14 19:38:41 -05:00
parent 478e925041
commit bf6b245f3c
6 changed files with 1061 additions and 0 deletions
@@ -0,0 +1,196 @@
import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerFleetAdoptCommand } from './fleet-adopt-command.js';
let root: string | undefined;
interface Harness {
readonly home: string;
readonly out: string[];
readonly err: string[];
readonly run: (argv: string[]) => Promise<void>;
}
beforeEach((): void => {
process.exitCode = undefined;
});
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
process.exitCode = undefined;
if (root) await rm(root, { recursive: true, force: true });
root = undefined;
});
async function harness(): Promise<Harness> {
root = await mkdtemp(join(tmpdir(), 'mosaic-adopt-cmd-'));
const home = join(root, '.mosaic');
const out: string[] = [];
const err: string[] = [];
vi.spyOn(console, 'log').mockImplementation((...parts: unknown[]): void => {
out.push(parts.map(String).join(' '));
});
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: unknown): boolean => {
err.push(String(chunk));
return true;
});
const program = new Command();
program.exitOverride();
const fleet = program.command('fleet');
registerFleetAdoptCommand(fleet, { fleetDataHome: home });
return {
home,
out,
err,
run: async (argv: string[]): Promise<void> => {
await program.parseAsync(['node', 'mosaic', 'fleet', 'adopt', ...argv]);
},
};
}
function realAliasDirectory(home: string, harnessName: string): string {
const path = join(home, 'auth', harnessName, 'primary');
mkdirSync(path, { recursive: true });
writeFileSync(join(path, '.credentials.json'), '{"token":"kept"}');
return path;
}
function seat(home: string, name: string, profile: Record<string, unknown>): void {
const dir = join(home, 'fleet', 'agents', name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`);
}
function seatDirectory(home: string, agent: string, plural: string, name: string): string {
const path = join(home, 'fleet', 'agents', agent, '.claude', plural, name);
mkdirSync(path, { recursive: true });
writeFileSync(join(path, 'marker.txt'), 'kept');
return path;
}
describe('mosaic fleet adopt', () => {
it('says there is nothing to adopt on a clean host', async () => {
const h = await harness();
await h.run([]);
expect(h.out.join('\n')).toContain('Nothing to adopt');
expect(process.exitCode).toBeUndefined();
});
// A read-only listing that exits non-zero is one people stop running, so the scan reports
// and stays out of the way.
it('lists each finding with the command that resolves it, and exits zero', async () => {
const h = await harness();
const path = realAliasDirectory(h.home, 'claude');
await h.run([]);
const printed = h.out.join('\n');
expect(printed).toContain(path);
expect(printed).toContain('resolve: mosaic fleet adopt bundle --harness claude --as <account>');
expect(printed).toContain('1 found, 0 needing a decision before adoption. Nothing was moved.');
expect(process.exitCode).toBeUndefined();
});
it('separates findings it can resolve from findings that need a decision first', async () => {
const h = await harness();
seat(h.home, 'uc-e6-coder', { schema: 1, harness: 'claude', bundle: 'primary' });
seatDirectory(h.home, 'uc-e6-coder', 'plugins', 'reviewer');
mkdirSync(join(h.home, 'plugins', 'reviewer'), { recursive: true });
await h.run([]);
const printed = h.out.join('\n');
expect(printed).toContain('blocked (destination occupied)');
expect(printed).toContain('1 found, 1 needing a decision before adoption.');
});
it('adopts a bundle and reports where the credentials went and what the alias points at', async () => {
const h = await harness();
realAliasDirectory(h.home, 'claude');
await h.run(['bundle', '--harness', 'claude', '--as', 'jason_woltje.com']);
const target = join(h.home, 'auth', 'claude', 'jason_woltje.com');
expect(readFileSync(join(target, '.credentials.json'), 'utf8')).toBe('{"token":"kept"}');
const printed = h.out.join('\n');
expect(printed).toContain(`bundle: ${target}`);
expect(printed).toContain('-> jason_woltje.com');
// The name is the operator's claim about the account; only a listing shows what is in it.
expect(printed).toContain('mosaic auth list --harness claude');
expect(process.exitCode).toBeUndefined();
});
it('rejects an unknown harness instead of building a path out of it', async () => {
const h = await harness();
await h.run(['bundle', '--harness', 'nonsense', '--as', 'x']);
expect(process.exitCode).toBe(1);
expect(h.err.join('')).toContain('--harness must be one of: claude, codex, opencode, pi');
});
it('exits non-zero and names the failure when there is nothing to adopt', async () => {
const h = await harness();
await h.run(['bundle', '--harness', 'pi', '--as', 'jason_woltje.com']);
expect(process.exitCode).toBe(1);
expect(h.err.join('')).toContain('mosaic fleet adopt bundle failed (nothing-to-adopt)');
});
it('adopts a plugin into the store and says the next launch links it back', async () => {
const h = await harness();
seat(h.home, 'uc-e6-coder', {
schema: 1,
harness: 'claude',
bundle: 'primary',
plugins: ['reviewer'],
});
seatDirectory(h.home, 'uc-e6-coder', 'plugins', 'reviewer');
await h.run(['plugin', 'reviewer', '--seat', 'uc-e6-coder']);
expect(readFileSync(join(h.home, 'plugins', 'reviewer', 'marker.txt'), 'utf8')).toBe('kept');
expect(h.out.join('\n')).toContain('next launch links it back from the store');
});
it('says plainly when no seat uses the adopted entry yet', async () => {
const h = await harness();
seat(h.home, 'uc-e6-coder', { schema: 1, harness: 'claude', bundle: 'primary' });
seatDirectory(h.home, 'uc-e6-coder', 'plugins', 'reviewer');
await h.run(['plugin', 'reviewer', '--seat', 'uc-e6-coder']);
expect(h.out.join('\n')).toContain("is not listed in uc-e6-coder's profile");
});
it('adopts a skill into the skill store, not the plugin store', async () => {
const h = await harness();
seat(h.home, 'uc-e6-rev', { schema: 1, harness: 'claude', bundle: 'primary' });
seatDirectory(h.home, 'uc-e6-rev', 'skills', 'spec-audit');
await h.run(['skill', 'spec-audit', '--seat', 'uc-e6-rev']);
expect(readFileSync(join(h.home, 'skills', 'spec-audit', 'marker.txt'), 'utf8')).toBe('kept');
});
it('leaves an already-linked entry alone and exits non-zero', async () => {
const h = await harness();
seat(h.home, 'uc-e6-coder', { schema: 1, harness: 'claude', bundle: 'primary' });
mkdirSync(join(h.home, 'plugins', 'reviewer'), { recursive: true });
const installRoot = join(h.home, 'fleet', 'agents', 'uc-e6-coder', '.claude', 'plugins');
mkdirSync(installRoot, { recursive: true });
symlinkSync(join(h.home, 'plugins', 'reviewer'), join(installRoot, 'reviewer'));
await h.run(['plugin', 'reviewer', '--seat', 'uc-e6-coder']);
expect(process.exitCode).toBe(1);
expect(h.err.join('')).toContain('already a link into the store');
});
});
@@ -0,0 +1,137 @@
/**
* `mosaic fleet adopt` -- resolve the real directories that sit where a managed link belongs.
*
* Launch refuses to delete anything an operator put on a managed path, which is right, but on
* its own it leaves the operator holding a composition error and no way forward. This command
* is the way forward: bare, it lists every such directory and the command that resolves it;
* with a verb, it moves one of them where it belongs.
*
* The bare scan reads only, and exits zero whatever it finds. It is meant to be safe to run
* out of curiosity, and a non-zero exit from a read-only listing would make it something
* people avoid running.
*/
import type { Command } from 'commander';
import {
AdoptionError,
type StoreKind,
promoteBundleAlias,
promoteStoreEntry,
scanAdoptions,
} from '../fleet/adoption.js';
import type { CredentialHarness } from '../fleet/credential-sharing.js';
import { defaultFleetDataHome } from '../fleet/fleet-agent-scaffold.js';
const HARNESSES: readonly CredentialHarness[] = ['claude', 'codex', 'opencode', 'pi'];
export interface FleetAdoptCommandDeps {
/** Test seam for the user-owned ~/.mosaic root. */
readonly fleetDataHome?: string;
}
function requireHarness(value: string | undefined): CredentialHarness {
if (value === undefined || !HARNESSES.includes(value as CredentialHarness)) {
throw new AdoptionError('invalid-request', `--harness must be one of: ${HARNESSES.join(', ')}`);
}
return value as CredentialHarness;
}
function requireSeat(value: string | undefined): string {
if (value === undefined || value.trim() === '') {
throw new AdoptionError(
'invalid-request',
'give the seat this directory belongs to: --seat <agent>',
);
}
return value;
}
function fail(error: unknown, verb: string): void {
process.exitCode = 1;
const message = error instanceof Error ? error.message : String(error);
const code = error instanceof AdoptionError ? error.code : 'failed';
process.stderr.write(
`mosaic fleet adopt${verb === '' ? '' : ` ${verb}`} failed (${code}): ${message}\n`,
);
}
/** Registers the adoption scan and its three promotion verbs. */
export function registerFleetAdoptCommand(
fleetCommand: Command,
deps: FleetAdoptCommandDeps = {},
): void {
const dataHome = (): string => deps.fleetDataHome ?? defaultFleetDataHome();
const adopt = fleetCommand
.command('adopt')
.description('Find and resolve real directories occupying paths the fleet manages with links')
.action((): void => {
try {
const findings = scanAdoptions(dataHome());
if (findings.length === 0) {
console.log('Nothing to adopt: no real directory occupies a managed path.');
return;
}
for (const finding of findings) {
console.log(finding.path);
console.log(` ${finding.reason}`);
console.log(
finding.blocked === undefined
? ` resolve: ${finding.remedy}`
: ` blocked (${finding.blocked}): ${finding.remedy}`,
);
}
const blocked = findings.filter((finding) => finding.blocked !== undefined).length;
console.log(
`\n${String(findings.length)} found, ${String(blocked)} needing a decision before adoption. Nothing was moved.`,
);
} catch (error: unknown) {
fail(error, '');
}
});
adopt
.command('bundle')
.description(`Adopt a real directory on the "primary" alias path as a named bundle`)
.requiredOption('--harness <harness>', `Harness: ${HARNESSES.join(', ')}`)
.requiredOption('--as <bundle>', 'Account this directory holds, e.g. jason_woltje.com')
.action((options: { harness?: string; as: string }): void => {
try {
const result = promoteBundleAlias(dataHome(), requireHarness(options.harness), options.as);
console.log(`Adopted ${result.from}`);
console.log(` bundle: ${result.to}`);
console.log(` alias: ${result.alias} -> ${result.bundle}`);
console.log(
`\nCheck the account it actually holds before trusting the name:\n mosaic auth list --harness ${result.harness}`,
);
} catch (error: unknown) {
fail(error, 'bundle');
}
});
for (const store of ['plugin', 'skill'] as const) {
adopt
.command(`${store} <name>`)
.description(`Move a real ${store} directory out of a seat and into the central store`)
.requiredOption('--seat <agent>', 'Seat the directory currently sits in')
.action((name: string, options: { seat?: string }): void => {
try {
const result = promoteStoreEntry(
dataHome(),
requireSeat(options.seat),
store as StoreKind,
name,
);
console.log(`Adopted ${result.from}`);
console.log(` store: ${result.to}`);
console.log(
result.listedInProfile
? `\n"${result.name}" is listed in ${result.agent}'s profile, so its next launch links it back from the store.`
: `\n"${result.name}" is not listed in ${result.agent}'s profile, so no seat uses it yet. It is now vetted store content any seat can be given.`,
);
} catch (error: unknown) {
fail(error, store);
}
});
}
}
@@ -82,6 +82,7 @@ describe('registerFleetCommand', () => {
expect(fleet).toBeDefined();
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
'add',
'adopt',
'agent',
'apply',
'backlog',
+6
View File
@@ -42,6 +42,7 @@ import {
registerFleetAgentScaffoldCommand,
type FleetAgentScaffoldCommandDeps,
} from './fleet-agent-scaffold-command.js';
import { registerFleetAdoptCommand } from './fleet-adopt-command.js';
import {
registerFleetMigrationCommand,
type FleetMigrationCommandDeps,
@@ -2080,6 +2081,11 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
...(deps.fleetDataHome === undefined ? {} : { fleetDataHome: deps.fleetDataHome }),
mosaicHomeFor: () => cmd.opts<{ mosaicHome: string }>().mosaicHome,
});
// The counterpart to launch's refusals: launch will not delete a real directory sitting on
// a managed path, and this is how one gets moved out of the way instead.
registerFleetAdoptCommand(cmd, {
...(deps.fleetDataHome === undefined ? {} : { fleetDataHome: deps.fleetDataHome }),
});
// 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);
+341
View File
@@ -0,0 +1,341 @@
import {
existsSync,
lstatSync,
mkdirSync,
readFileSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { AdoptionError, promoteBundleAlias, promoteStoreEntry, scanAdoptions } from './adoption.js';
let root: string | undefined;
afterEach(async (): Promise<void> => {
if (root) await rm(root, { recursive: true, force: true });
root = undefined;
});
async function userHome(): Promise<string> {
root = await mkdtemp(join(tmpdir(), 'mosaic-adopt-'));
return join(root, '.mosaic');
}
/** A real directory where the primary alias belongs, with something inside worth not losing. */
function realAliasDirectory(
home: string,
harness: string,
credential = '.credentials.json',
): string {
const path = join(home, 'auth', harness, 'primary');
mkdirSync(path, { recursive: true });
writeFileSync(join(path, credential), '{"token":"kept"}');
return path;
}
function seat(
home: string,
name: string,
profile: Record<string, unknown> = { schema: 1, harness: 'claude', bundle: 'primary' },
): string {
const dir = join(home, 'fleet', 'agents', name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`);
return dir;
}
/** A real plugin/skill directory inside a seat, where a link into the store belongs. */
function seatDirectory(
home: string,
agent: string,
harness: string,
plural: string,
name: string,
): string {
const path = join(home, 'fleet', 'agents', agent, `.${harness}`, plural, name);
mkdirSync(path, { recursive: true });
writeFileSync(join(path, 'marker.txt'), 'kept');
return path;
}
describe('scanAdoptions', () => {
it('finds nothing on a host that has no ~/.mosaic at all', async () => {
expect(scanAdoptions(await userHome())).toEqual([]);
});
it('finds a real directory on the primary alias path and names the command that resolves it', async () => {
const home = await userHome();
const path = realAliasDirectory(home, 'claude');
const findings = scanAdoptions(home);
expect(findings).toHaveLength(1);
expect(findings[0]?.kind).toBe('bundle-alias');
expect(findings[0]?.path).toBe(path);
expect(findings[0]?.harness).toBe('claude');
expect(findings[0]?.blocked).toBeUndefined();
expect(findings[0]?.remedy).toBe('mosaic fleet adopt bundle --harness claude --as <account>');
});
it('ignores a primary alias that is already a symlink', async () => {
const home = await userHome();
mkdirSync(join(home, 'auth', 'claude', 'jason_woltje.com'), { recursive: true });
symlinkSync('jason_woltje.com', join(home, 'auth', 'claude', 'primary'));
expect(scanAdoptions(home)).toEqual([]);
});
it('finds a real plugin directory inside a seat', async () => {
const home = await userHome();
seat(home, 'uc-e6-coder');
const path = seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer');
const findings = scanAdoptions(home);
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
kind: 'store-entry',
path,
agent: 'uc-e6-coder',
store: 'plugin',
name: 'reviewer',
});
expect(findings[0]?.remedy).toBe('mosaic fleet adopt plugin reviewer --seat uc-e6-coder');
});
it('finds skills the same way it finds plugins', async () => {
const home = await userHome();
seat(home, 'uc-e6-rev');
seatDirectory(home, 'uc-e6-rev', 'claude', 'skills', 'spec-audit');
const findings = scanAdoptions(home);
expect(findings).toHaveLength(1);
expect(findings[0]?.store).toBe('skill');
expect(findings[0]?.remedy).toBe('mosaic fleet adopt skill spec-audit --seat uc-e6-rev');
});
// Scanning the wrong directory name would report nothing on a pi seat while launch keeps
// refusing to compose it, which is worse than not having the scan.
it('looks in the seat home the launcher uses, not always the claude one', async () => {
const home = await userHome();
seat(home, 'terra', { schema: 1, harness: 'pi', bundle: 'primary' });
const path = seatDirectory(home, 'terra', 'pi', 'plugins', 'notes');
expect(scanAdoptions(home).map((finding) => finding.path)).toEqual([path]);
});
it('does not report a link that is already pointing into the store', async () => {
const home = await userHome();
seat(home, 'uc-e6-coder');
mkdirSync(join(home, 'plugins', 'reviewer'), { recursive: true });
const installRoot = join(home, 'fleet', 'agents', 'uc-e6-coder', '.claude', 'plugins');
mkdirSync(installRoot, { recursive: true });
symlinkSync(join(home, 'plugins', 'reviewer'), join(installRoot, 'reviewer'));
expect(scanAdoptions(home)).toEqual([]);
});
it('marks the finding blocked when the store already holds that name', async () => {
const home = await userHome();
seat(home, 'uc-e6-coder');
seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer');
mkdirSync(join(home, 'plugins', 'reviewer'), { recursive: true });
const findings = scanAdoptions(home);
expect(findings[0]?.blocked).toBe('destination occupied');
expect(findings[0]?.remedy).toContain('compare the two');
});
// One malformed profile hiding every finding behind it would make the scan useless exactly
// on the hosts that need it most.
it('reports an unreadable seat as a gap and keeps scanning the others', async () => {
const home = await userHome();
mkdirSync(join(home, 'fleet', 'agents', 'broken'), { recursive: true });
writeFileSync(join(home, 'fleet', 'agents', 'broken', 'profile.json'), 'not json');
seat(home, 'working');
const path = seatDirectory(home, 'working', 'claude', 'plugins', 'reviewer');
const findings = scanAdoptions(home);
expect(findings.map((finding) => finding.kind)).toEqual(['unreadable-seat', 'store-entry']);
expect(findings[0]?.blocked).toBe('unreadable profile');
expect(findings[1]?.path).toBe(path);
});
});
describe('promoteBundleAlias', () => {
it('moves the directory to its account name and points the alias at it', async () => {
const home = await userHome();
const from = realAliasDirectory(home, 'claude');
const result = promoteBundleAlias(home, 'claude', 'jason_woltje.com');
expect(result.to).toBe(join(home, 'auth', 'claude', 'jason_woltje.com'));
expect(result.from).toBe(from);
// The credential travelled with the directory; adoption is a move, never a re-creation.
expect(readFileSync(join(result.to, '.credentials.json'), 'utf8')).toBe('{"token":"kept"}');
const alias = lstatSync(result.alias);
expect(alias.isSymbolicLink()).toBe(true);
expect(scanAdoptions(home)).toEqual([]);
});
it('refuses when the alias path is already a symlink', async () => {
const home = await userHome();
mkdirSync(join(home, 'auth', 'pi', 'jason_woltje.com'), { recursive: true });
symlinkSync('jason_woltje.com', join(home, 'auth', 'pi', 'primary'));
expect(() => promoteBundleAlias(home, 'pi', 'other')).toThrow(
/already an alias symlink.*mosaic auth default/su,
);
});
it('refuses when there is nothing on the alias path', async () => {
const home = await userHome();
expect(() => promoteBundleAlias(home, 'claude', 'jason_woltje.com')).toThrow(AdoptionError);
});
it('refuses to adopt a directory as the alias name itself', async () => {
const home = await userHome();
realAliasDirectory(home, 'claude');
expect(() => promoteBundleAlias(home, 'claude', 'primary')).toThrow(
/that is the alias being freed/u,
);
});
it('refuses a name that would escape the auth root', async () => {
const home = await userHome();
realAliasDirectory(home, 'claude');
expect(() => promoteBundleAlias(home, 'claude', '../elsewhere')).toThrow(
/not a safe bundle name/u,
);
expect(existsSync(join(home, 'auth', 'claude', 'primary', '.credentials.json'))).toBe(true);
});
// The failure that would cost data: an occupied destination silently merged into, or worse,
// replaced. Both directories must still be exactly where they were.
it('refuses an occupied destination and moves nothing', async () => {
const home = await userHome();
realAliasDirectory(home, 'claude');
const occupied = join(home, 'auth', 'claude', 'jason_woltje.com');
mkdirSync(occupied, { recursive: true });
writeFileSync(join(occupied, '.credentials.json'), '{"token":"other"}');
expect(() => promoteBundleAlias(home, 'claude', 'jason_woltje.com')).toThrow(
/already exists and will not be overwritten/u,
);
expect(readFileSync(join(home, 'auth', 'claude', 'primary', '.credentials.json'), 'utf8')).toBe(
'{"token":"kept"}',
);
expect(readFileSync(join(occupied, '.credentials.json'), 'utf8')).toBe('{"token":"other"}');
});
});
describe('promoteStoreEntry', () => {
it('moves the directory into the central store, creating the store root', async () => {
const home = await userHome();
seat(home, 'uc-e6-coder', {
schema: 1,
harness: 'claude',
bundle: 'primary',
plugins: ['reviewer'],
});
const from = seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer');
const result = promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer');
expect(result.to).toBe(join(home, 'plugins', 'reviewer'));
expect(readFileSync(join(result.to, 'marker.txt'), 'utf8')).toBe('kept');
expect(existsSync(from)).toBe(false);
expect(result.listedInProfile).toBe(true);
});
// Installing the link here would fail the next launch as an unrecorded symlink, because the
// seat's .mosaic-managed-links.json is launch's to write. Adoption stops at the move.
it('leaves the seat path empty rather than installing the link itself', async () => {
const home = await userHome();
seat(home, 'uc-e6-coder', {
schema: 1,
harness: 'claude',
bundle: 'primary',
plugins: ['reviewer'],
});
const from = seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer');
promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer');
expect(existsSync(from)).toBe(false);
expect(() => lstatSync(from)).toThrow();
});
it('says when the seat does not list the entry, because then nothing links it back', async () => {
const home = await userHome();
seat(home, 'uc-e6-coder');
seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer');
expect(promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer').listedInProfile).toBe(
false,
);
});
it('refuses an occupied destination and moves nothing', async () => {
const home = await userHome();
seat(home, 'uc-e6-coder');
const from = seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer');
mkdirSync(join(home, 'plugins', 'reviewer'), { recursive: true });
writeFileSync(join(home, 'plugins', 'reviewer', 'marker.txt'), 'store copy');
expect(() => promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer')).toThrow(
/already exists and will not be overwritten/u,
);
expect(readFileSync(join(from, 'marker.txt'), 'utf8')).toBe('kept');
expect(readFileSync(join(home, 'plugins', 'reviewer', 'marker.txt'), 'utf8')).toBe(
'store copy',
);
});
it('refuses an entry that is already a link into the store', async () => {
const home = await userHome();
seat(home, 'uc-e6-coder');
mkdirSync(join(home, 'plugins', 'reviewer'), { recursive: true });
const installRoot = join(home, 'fleet', 'agents', 'uc-e6-coder', '.claude', 'plugins');
mkdirSync(installRoot, { recursive: true });
symlinkSync(join(home, 'plugins', 'reviewer'), join(installRoot, 'reviewer'));
expect(() => promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer')).toThrow(
/already a link into the store/u,
);
});
it('refuses a name that would escape the store root', async () => {
const home = await userHome();
seat(home, 'uc-e6-coder');
expect(() => promoteStoreEntry(home, 'uc-e6-coder', 'plugin', '../escape')).toThrow(
/not a safe plugin name/u,
);
});
it('names the profile it could not read rather than guessing the seat home', async () => {
const home = await userHome();
expect(() => promoteStoreEntry(home, 'ghost', 'plugin', 'reviewer')).toThrow(
/ghost.*profile\.json.*cannot be located/su,
);
});
it('reports a missing directory as nothing to adopt', async () => {
const home = await userHome();
seat(home, 'uc-e6-coder');
expect(() => promoteStoreEntry(home, 'uc-e6-coder', 'skill', 'absent')).toThrow(
/no such skill directory/u,
);
});
});
+380
View File
@@ -0,0 +1,380 @@
/**
* Adopting real directories that sit where the fleet expects a managed link.
*
* A host used before the fleet arrived -- or an operator who ran a login by hand -- ends up
* with a real directory on a path launch reserves for a link: `auth/<harness>/primary`, or a
* plugin/skill directory inside a seat's home. Launch refuses those on purpose, because the
* only way to make a link fit there is to delete whatever is already there.
*
* This module is the other half of that refusal. It finds those directories and moves them
* where they belong. Nothing here deletes anything: a promotion is a rename, and an occupied
* destination is a refusal rather than a merge or an overwrite. Cross-device renames are
* surfaced instead of being retried as copy-then-delete, because a copy-then-delete is a
* delete and this module does not do that.
*
* Link creation is deliberately NOT done here. Seat store links are recorded in the seat's
* `.mosaic-managed-links.json`, and that manifest is owned by launch -- a link installed
* behind its back reads as "unrecorded symlink occupies managed path" on the next launch,
* which trades one refusal for another. So a promoted plugin lands in the central store and
* the next launch links it, provided the seat's profile lists it. Whether a seat gets a
* plugin is `mosaic fleet plugin`'s decision, not this one's.
*
* The auth alias is different: it lives in the auth root, no manifest covers it, and
* setDefaultBundle() already owns installing it. So a bundle promotion finishes the job.
*/
import { lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, type Stats } from 'node:fs';
import { join } from 'node:path';
import { PRIMARY_ALIAS, assertSafeBundleName, authRoot, setDefaultBundle } from './auth-bundles.js';
import type { CredentialHarness } from './credential-sharing.js';
/** Mirrors the harness list the auth and launch surfaces accept. */
const HARNESSES: readonly CredentialHarness[] = ['claude', 'codex', 'opencode', 'pi'];
/** Store kinds a seat can hold, and the directory name each uses in both trees. */
const STORE_DIRECTORY: Record<StoreKind, string> = { plugin: 'plugins', skill: 'skills' };
/** Same charset as a bundle name; anything with a separator or a dot-dot never reaches a join. */
const ENTRY_NAME = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/;
export type StoreKind = 'plugin' | 'skill';
export type AdoptionErrorCode =
| 'invalid-request'
| 'nothing-to-adopt'
| 'destination-occupied'
| 'cross-device'
| 'unsafe-shape';
export class AdoptionError extends Error {
readonly code: AdoptionErrorCode;
constructor(code: AdoptionErrorCode, message: string) {
super(message);
this.name = 'AdoptionError';
this.code = code;
}
}
export interface AdoptionFinding {
/** `bundle-alias` and `store-entry` are adoptable; `unreadable-seat` is a scan gap. */
readonly kind: 'bundle-alias' | 'store-entry' | 'unreadable-seat';
/** The real directory that a launch would refuse to touch. */
readonly path: string;
/** What this is, in one line. */
readonly reason: string;
/** The exact command that resolves it, or what to look at when nothing can. */
readonly remedy: string;
readonly harness?: CredentialHarness;
readonly agent?: string;
readonly store?: StoreKind;
readonly name?: string;
/** Set when the promotion cannot run as-is; the remedy then describes the obstacle. */
readonly blocked?: string;
}
export interface BundlePromotion {
readonly harness: CredentialHarness;
/** Where the adopted directory now lives. */
readonly bundle: string;
readonly from: string;
readonly to: string;
/** The alias path now pointing at it. */
readonly alias: string;
}
export interface StorePromotion {
readonly agent: string;
readonly store: StoreKind;
readonly name: string;
readonly from: string;
readonly to: string;
/** True when the seat's profile lists this entry, so the next launch will link it back. */
readonly listedInProfile: boolean;
}
function lstatIfPresent(path: string): Stats | undefined {
try {
return lstatSync(path);
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw error;
}
}
function isRealDirectory(path: string): boolean {
const info = lstatIfPresent(path);
return info !== undefined && info.isDirectory() && !info.isSymbolicLink();
}
function assertSafeEntryName(name: string, store: StoreKind): void {
if (!ENTRY_NAME.test(name)) {
throw new AdoptionError(
'invalid-request',
`"${name}" is not a safe ${store} name; use letters, digits, and . _ @ -`,
);
}
}
function agentsRoot(dataHome: string): string {
return join(dataHome, 'fleet', 'agents');
}
/**
* A seat's harness home, by the same rule launch uses (`harnessHome()` in commands/launch.ts).
* Scanning by any other rule finds directories launch never looks at and misses the ones it
* refuses on.
*/
function seatHome(dataHome: string, agent: string, harness: CredentialHarness): string {
return join(agentsRoot(dataHome), agent, `.${harness}`);
}
interface SeatProfile {
readonly harness: CredentialHarness;
readonly plugins: readonly string[];
readonly skills: readonly string[];
}
/**
* Read only what adoption needs out of a seat profile, leniently.
*
* A scan that dies on one malformed profile hides every finding behind it, so an unreadable
* profile is reported as a scan gap and the walk continues. Strictness belongs at launch,
* which validates the whole profile and refuses to run the seat.
*/
function readSeatProfile(dataHome: string, agent: string): SeatProfile | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(join(agentsRoot(dataHome), agent, 'profile.json'), 'utf8'));
} catch {
return undefined;
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined;
const raw = parsed as Record<string, unknown>;
const harness = raw['harness'];
if (typeof harness !== 'string' || !HARNESSES.includes(harness as CredentialHarness)) {
return undefined;
}
const names = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
return {
harness: harness as CredentialHarness,
plugins: names(raw['plugins']),
skills: names(raw['skills']),
};
}
function listDirectory(path: string): string[] {
try {
return readdirSync(path, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
.map((entry) => entry.name)
.sort();
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw error;
}
}
function listAgents(dataHome: string): string[] {
try {
return readdirSync(agentsRoot(dataHome), { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw error;
}
}
/**
* Everything under `~/.mosaic` that occupies a path the fleet manages with a link.
*
* Read-only. Every finding carries the command that resolves it, because the value of the
* scan is that an operator does not have to work out what a composition refusal meant.
*/
export function scanAdoptions(dataHome: string): AdoptionFinding[] {
const findings: AdoptionFinding[] = [];
for (const harness of HARNESSES) {
const alias = join(authRoot(dataHome, harness), PRIMARY_ALIAS);
if (!isRealDirectory(alias)) continue;
findings.push({
kind: 'bundle-alias',
path: alias,
harness,
reason: `a real directory occupies the ${PRIMARY_ALIAS} alias path; ${harness} seats pointed at "${PRIMARY_ALIAS}" cannot launch`,
remedy: `mosaic fleet adopt bundle --harness ${harness} --as <account>`,
});
}
for (const agent of listAgents(dataHome)) {
const profile = readSeatProfile(dataHome, agent);
if (profile === undefined) {
findings.push({
kind: 'unreadable-seat',
path: join(agentsRoot(dataHome), agent, 'profile.json'),
agent,
reason:
'profile could not be read, or names no known harness, so this seat was not scanned',
remedy: `mosaic fleet agent get ${agent}`,
blocked: 'unreadable profile',
});
continue;
}
for (const store of ['plugin', 'skill'] as const) {
const plural = STORE_DIRECTORY[store];
const installRoot = join(seatHome(dataHome, agent, profile.harness), plural);
for (const name of listDirectory(installRoot)) {
const destination = join(dataHome, plural, name);
const occupied = lstatIfPresent(destination) !== undefined;
findings.push({
kind: 'store-entry',
path: join(installRoot, name),
agent,
store,
name,
reason: `a real ${store} directory sits where the seat expects a link into the central store`,
remedy: occupied
? `${destination} already exists; compare the two and remove or rename one by hand`
: `mosaic fleet adopt ${store} ${name} --seat ${agent}`,
...(occupied ? { blocked: 'destination occupied' } : {}),
});
}
}
}
return findings;
}
/**
* Move a directory, refusing every case where the move would cost data.
*
* EXDEV is surfaced rather than handled: the fallback for a cross-device rename is copy then
* delete, and this module does not delete.
*/
function movePreservingBoth(from: string, to: string, label: string): void {
if (lstatIfPresent(to) !== undefined) {
throw new AdoptionError(
'destination-occupied',
`${label} destination already exists and will not be overwritten: ${to}`,
);
}
try {
renameSync(from, to);
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'EXDEV') {
throw new AdoptionError(
'cross-device',
`${from} and ${to} are on different filesystems, so this cannot be a rename. Copy it across yourself and remove the original once you have checked the copy: ${to}`,
);
}
throw error;
}
}
/**
* Adopt a real directory sitting on the `primary` alias path as a named bundle.
*
* The directory is moved to its account name first and the alias installed second. That order
* is the one that survives a failure: if the alias cannot be created, the credentials are
* intact under their own name and the error says where they are. The reverse order would have
* a window where the alias points at nothing.
*/
export function promoteBundleAlias(
dataHome: string,
harness: CredentialHarness,
as: string,
): BundlePromotion {
assertSafeBundleName(as);
if (as === PRIMARY_ALIAS) {
throw new AdoptionError(
'invalid-request',
`--as must be the account this directory holds, not "${PRIMARY_ALIAS}" — that is the alias being freed`,
);
}
const root = authRoot(dataHome, harness);
const alias = join(root, PRIMARY_ALIAS);
const info = lstatIfPresent(alias);
if (info === undefined) {
throw new AdoptionError('nothing-to-adopt', `nothing at ${alias}; there is nothing to adopt`);
}
if (info.isSymbolicLink()) {
throw new AdoptionError(
'nothing-to-adopt',
`${alias} is already an alias symlink. Retarget it with: mosaic auth default --harness ${harness} <bundle>`,
);
}
if (!info.isDirectory()) {
throw new AdoptionError(
'unsafe-shape',
`${alias} is neither a directory nor a symlink; adoption only moves directories`,
);
}
const destination = join(root, as);
movePreservingBoth(alias, destination, 'bundle');
return {
harness,
bundle: as,
from: alias,
to: destination,
alias: setDefaultBundle(dataHome, harness, as),
};
}
/**
* Adopt a real plugin/skill directory out of a seat and into the central store.
*
* No link is installed. The seat's link manifest belongs to launch, and a link this command
* created behind it would fail the next composition as an unrecorded symlink. The next launch
* installs and records the link itself when the seat's profile lists the entry -- and when it
* does not, the entry is now vetted store content that any seat can be given deliberately,
* which is the outcome that was wanted anyway.
*/
export function promoteStoreEntry(
dataHome: string,
agent: string,
store: StoreKind,
name: string,
): StorePromotion {
assertSafeEntryName(name, store);
const profile = readSeatProfile(dataHome, agent);
if (profile === undefined) {
throw new AdoptionError(
'invalid-request',
`cannot read a harness out of ${join(agentsRoot(dataHome), agent, 'profile.json')}, so the seat's home cannot be located`,
);
}
const plural = STORE_DIRECTORY[store];
const source = join(seatHome(dataHome, agent, profile.harness), plural, name);
const info = lstatIfPresent(source);
if (info === undefined) {
throw new AdoptionError('nothing-to-adopt', `no such ${store} directory: ${source}`);
}
if (info.isSymbolicLink()) {
throw new AdoptionError(
'nothing-to-adopt',
`${source} is already a link into the store; there is nothing to adopt`,
);
}
if (!info.isDirectory()) {
throw new AdoptionError(
'unsafe-shape',
`${source} is not a directory; adoption only moves directories`,
);
}
mkdirSync(join(dataHome, plural), { recursive: true });
const destination = join(dataHome, plural, name);
movePreservingBoth(source, destination, store);
return {
agent,
store,
name,
from: source,
to: destination,
listedInProfile: (store === 'plugin' ? profile.plugins : profile.skills).includes(name),
};
}