Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf6b245f3c | ||
|
|
478e925041 | ||
|
|
309a99a600 | ||
|
|
c1a42cdb81 | ||
|
|
a12eeb4786 |
@@ -286,12 +286,24 @@ _build_runtime_bin_prefix() {
|
||||
MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
|
||||
PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
# A seat scaffolded by `mosaic fleet agent new` owns its harness home, settings
|
||||
# overlay and auth bundle; launching it through `mosaic fleet launch` is what makes
|
||||
# ~/.mosaic real for a roster-started pane instead of a directory nothing reads.
|
||||
# Detection uses $PANE_HOME/.mosaic because the pane environment is cleared below,
|
||||
# so `mosaic fleet launch` resolves the same root from HOME and the two agree.
|
||||
FLEET_SEAT_DIR="$PANE_HOME/.mosaic/fleet/agents/$AGENT_NAME"
|
||||
FLEET_SEAT=0
|
||||
[ -f "$FLEET_SEAT_DIR/profile.json" ] && FLEET_SEAT=1
|
||||
|
||||
_ensure_claude_workdir_trusted() {
|
||||
local workdir="$1"
|
||||
local claude_json="$2"
|
||||
local resolved
|
||||
resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir"
|
||||
local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}"
|
||||
claude_json="${claude_json:-$HOME/.claude.json}"
|
||||
if [ -z "$claude_json" ]; then
|
||||
claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}"
|
||||
claude_json="${claude_json:-$HOME/.claude.json}"
|
||||
fi
|
||||
command -v python3 >/dev/null 2>&1 || return 1
|
||||
MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY'
|
||||
import json, os, sys, tempfile
|
||||
@@ -325,11 +337,23 @@ PY
|
||||
}
|
||||
|
||||
if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then
|
||||
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" || \
|
||||
# Trust belongs to the home the seat will actually run in. Writing it to the
|
||||
# operator's ~/.claude.json would leave the seat prompting on its first turn.
|
||||
SEAT_CLAUDE_JSON=""
|
||||
if [ "$FLEET_SEAT" = 1 ] && [ -d "$FLEET_SEAT_DIR/.claude" ]; then
|
||||
SEAT_CLAUDE_JSON="$FLEET_SEAT_DIR/.claude/.claude.json"
|
||||
fi
|
||||
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" "$SEAT_CLAUDE_JSON" || \
|
||||
echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
|
||||
fi
|
||||
|
||||
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
|
||||
if [ "$FLEET_SEAT" = 1 ]; then
|
||||
# --dangerous keeps the seat on the same permissions footing `mosaic yolo` gave it;
|
||||
# the composition, not the roster, decides harness home, bundle and settings.
|
||||
LAUNCH_COMMAND=(mosaic fleet launch "$AGENT_NAME" --dangerous)
|
||||
else
|
||||
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
|
||||
fi
|
||||
if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi
|
||||
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi
|
||||
|
||||
|
||||
@@ -409,4 +409,23 @@ if echo "$stop_args" | grep -qF 'ambient-socket'; then
|
||||
fail "exact stop trusted an ambient socket"
|
||||
fi
|
||||
|
||||
# A seat scaffolded under ~/.mosaic owns its harness home, so the pane launches
|
||||
# through the composition instead of the operator's own home. --dangerous keeps the
|
||||
# seat on the permissions footing `mosaic yolo` gave it.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_SEAT="$ROOT/seat"
|
||||
write_generated "$HOME_SEAT" "coder-seat"
|
||||
mkdir -p "$HOME_SEAT/.mosaic/fleet/agents/coder-seat"
|
||||
printf '{"schema":1,"harness":"pi","bundle":"primary"}\n' \
|
||||
> "$HOME_SEAT/.mosaic/fleet/agents/coder-seat/profile.json"
|
||||
run_start "$HOME_SEAT" "coder-seat"
|
||||
seat_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$seat_args" | grep -qxF 'fleet' || fail "scaffolded seat did not launch through fleet launch"
|
||||
echo "$seat_args" | grep -qxF 'launch' || fail "scaffolded seat did not launch through fleet launch"
|
||||
echo "$seat_args" | grep -qxF 'coder-seat' || fail "fleet launch did not name the seat"
|
||||
echo "$seat_args" | grep -qxF -- '--dangerous' || fail "scaffolded seat lost dangerous permissions"
|
||||
if echo "$seat_args" | grep -qxF 'yolo'; then
|
||||
fail "scaffolded seat still launched through mosaic yolo"
|
||||
fi
|
||||
|
||||
echo 'ok - start-agent-session generated environment boundary'
|
||||
|
||||
@@ -25,6 +25,7 @@ import { registerLaunchCommands } from './commands/launch.js';
|
||||
import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js';
|
||||
import { registerInstallOrderingGuardCommand } from './commands/install-ordering-guard.js';
|
||||
import { registerAuthCommand } from './commands/auth.js';
|
||||
import { registerFleetAuthCommands } from './commands/fleet-auth-command.js';
|
||||
import { registerFederationCommand } from './commands/federation.js';
|
||||
import { registerGatewayCommand } from './commands/gateway.js';
|
||||
import {
|
||||
@@ -350,7 +351,7 @@ sessionsCmd
|
||||
|
||||
// ─── auth ────────────────────────────────────────────────────────────────
|
||||
|
||||
registerAuthCommand(program);
|
||||
registerFleetAuthCommands(registerAuthCommand(program));
|
||||
|
||||
// ─── gateway ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -139,10 +139,11 @@ function printUser(u: UserDto): void {
|
||||
* Keeping packages/auth as a pure server-side library avoids adding commander
|
||||
* and CLI tooling as dependencies there.
|
||||
*/
|
||||
export function registerAuthCommand(parent: Command): void {
|
||||
/** Returns the `auth` command so local (non-gateway) verbs can be attached to it. */
|
||||
export function registerAuthCommand(parent: Command): Command {
|
||||
const auth = parent
|
||||
.command('auth')
|
||||
.description('Manage gateway authentication, users, SSO providers, and sessions')
|
||||
.description('Manage authentication: local credential bundles, and gateway users and sessions')
|
||||
.configureHelp({ sortSubcommands: true })
|
||||
.action(() => {
|
||||
auth.outputHelp();
|
||||
@@ -328,4 +329,6 @@ export function registerAuthCommand(parent: Command): void {
|
||||
);
|
||||
void opts;
|
||||
});
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { lstat, mkdtemp, readFile, readdir, readlink, rm, writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
lstat,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
readlink,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
@@ -60,9 +69,10 @@ describe('mosaic fleet agent new', (): void => {
|
||||
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
|
||||
const agent = join(dataHome, 'fleet', 'agents', 'mira');
|
||||
|
||||
// Claude reaches its bundle through CLAUDE_SECURESTORAGE_CONFIG_DIR at launch,
|
||||
// so no credential link is planted in the seat home.
|
||||
expect(await files(agent)).toEqual([
|
||||
'.claude/.claude.json',
|
||||
'.claude/.credentials.json',
|
||||
'.claude/.mosaic-managed-links.json',
|
||||
'.claude/CLAUDE.md',
|
||||
'SOUL.md',
|
||||
@@ -87,13 +97,30 @@ describe('mosaic fleet agent new', (): void => {
|
||||
},
|
||||
},
|
||||
});
|
||||
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 },
|
||||
});
|
||||
).toEqual({ links: {} });
|
||||
});
|
||||
|
||||
it('plants a managed credential link for a harness that is not shared by environment', async (): Promise<void> => {
|
||||
const dataHome = await fleetDataHome();
|
||||
await program(dataHome).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'agent',
|
||||
'new',
|
||||
'pi-seat',
|
||||
'--harness',
|
||||
'pi',
|
||||
]);
|
||||
const agent = join(dataHome, 'fleet', 'agents', 'pi-seat');
|
||||
const credentialTarget = join(dataHome, 'auth', 'pi', 'primary', 'auth.json');
|
||||
|
||||
expect(await readlink(join(agent, '.pi', 'auth.json'))).toBe(credentialTarget);
|
||||
expect(
|
||||
JSON.parse(await readFile(join(agent, '.pi', '.mosaic-managed-links.json'), 'utf8')),
|
||||
).toEqual({ links: { [join(agent, '.pi', 'auth.json')]: credentialTarget } });
|
||||
});
|
||||
|
||||
it('creates a Pi home without Claude onboarding state', async (): Promise<void> => {
|
||||
@@ -210,10 +237,61 @@ describe('mosaic fleet agent new', (): void => {
|
||||
|
||||
it('does not follow a managed credential link while comparing existing content', async (): Promise<void> => {
|
||||
const dataHome = await fleetDataHome();
|
||||
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
|
||||
const credential = join(dataHome, 'fleet', 'agents', 'mira', '.claude', '.credentials.json');
|
||||
const command = ['node', 'mosaic', 'fleet', 'agent', 'new', 'pi-seat', '--harness', 'pi'];
|
||||
await program(dataHome).parseAsync(command);
|
||||
const credential = join(dataHome, 'fleet', 'agents', 'pi-seat', '.pi', 'auth.json');
|
||||
expect((await lstat(credential)).isSymbolicLink()).toBe(true);
|
||||
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
|
||||
await program(dataHome).parseAsync(command);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('tolerates a credential link left by a scaffold that predates environment sharing', async (): Promise<void> => {
|
||||
const dataHome = await fleetDataHome();
|
||||
const command = ['node', 'mosaic', 'fleet', 'agent', 'new', 'mira'];
|
||||
await program(dataHome).parseAsync(command);
|
||||
const seatHome = join(dataHome, 'fleet', 'agents', 'mira', '.claude');
|
||||
const credential = join(seatHome, '.credentials.json');
|
||||
const target = join(dataHome, 'auth', 'claude', 'primary', '.credentials.json');
|
||||
await symlink(target, credential);
|
||||
await writeFile(
|
||||
join(seatHome, '.mosaic-managed-links.json'),
|
||||
`${JSON.stringify({ links: { [credential]: target } }, null, 2)}\n`,
|
||||
);
|
||||
|
||||
await program(dataHome).parseAsync(command);
|
||||
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect((await lstat(credential)).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('scaffolds against canonical settings that declare no mcpServers', async (): Promise<void> => {
|
||||
// The framework's shipped runtime/claude/settings.json has no mcpServers key, so
|
||||
// requiring one refused to scaffold any Claude seat on a clean install. Measured on a
|
||||
// greenfield Debian 13 VM against framework main.
|
||||
const dataHome = await fleetDataHome();
|
||||
const command = program(dataHome);
|
||||
const settings = join(root!, 'installed-mosaic', 'runtime', 'claude', 'settings.json');
|
||||
await writeFile(settings, JSON.stringify({ model: 'opus', hooks: {} }));
|
||||
|
||||
await command.parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
|
||||
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const claudeJson = join(dataHome, 'fleet', 'agents', 'mira', '.claude', '.claude.json');
|
||||
expect(JSON.parse(await readFile(claudeJson, 'utf8'))).toEqual({
|
||||
hasCompletedOnboarding: true,
|
||||
theme: 'dark',
|
||||
mcpServers: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('still refuses canonical settings whose mcpServers is the wrong shape', async (): Promise<void> => {
|
||||
const dataHome = await fleetDataHome();
|
||||
const command = program(dataHome);
|
||||
const settings = join(root!, 'installed-mosaic', 'runtime', 'claude', 'settings.json');
|
||||
await writeFile(settings, JSON.stringify({ mcpServers: ['sequential-thinking'] }));
|
||||
|
||||
await command.parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ export function registerFleetAgentScaffoldCommand(
|
||||
);
|
||||
if (!result.credentialTargetExists) {
|
||||
console.log(
|
||||
`Notice: credentials link is intentionally dangling until auth bundle "${result.profile['bundle']}" is enrolled: ${result.credentialTarget}`,
|
||||
`Notice: auth bundle "${result.profile['bundle']}" is not enrolled yet, so no credential exists at ${result.credentialTarget}. The seat will refuse to launch until it does.`,
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { registerFleetAuthCommands, type FleetAuthCommandDeps } from './fleet-auth-command.js';
|
||||
|
||||
let root: string | undefined;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
if (root) await rm(root, { recursive: true, force: true });
|
||||
root = undefined;
|
||||
});
|
||||
|
||||
interface Harness {
|
||||
readonly home: string;
|
||||
readonly out: string[];
|
||||
readonly err: string[];
|
||||
readonly logins: Array<{ command: string; args: readonly string[]; env: Record<string, string> }>;
|
||||
run: (argv: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
async function harness(
|
||||
overrides: Omit<FleetAuthCommandDeps, 'fleetDataHome'> = {},
|
||||
): Promise<Harness> {
|
||||
root = await mkdtemp(join(tmpdir(), 'mosaic-auth-cmd-'));
|
||||
const home = join(root, '.mosaic');
|
||||
const out: string[] = [];
|
||||
const err: string[] = [];
|
||||
const logins: Harness['logins'] = [];
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
// Every login is recorded regardless of which behaviour the test supplied, so a test can
|
||||
// assert on what the harness was actually handed as well as on what it wrote.
|
||||
const inner = overrides.runLogin ?? ((): number => 0);
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
const auth = program.command('auth');
|
||||
registerFleetAuthCommands(auth, {
|
||||
...overrides,
|
||||
fleetDataHome: home,
|
||||
runLogin: (command, args, env): number | null => {
|
||||
logins.push({ command, args, env: { ...env } });
|
||||
return inner(command, args, env);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
home,
|
||||
out,
|
||||
err,
|
||||
logins,
|
||||
run: async (argv: string[]): Promise<void> => {
|
||||
await program.parseAsync(['node', 'mosaic', 'auth', ...argv]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A login that behaves: writes the credential where the harness would write it, using only the
|
||||
* environment it was handed — the same way a real harness finds its home.
|
||||
*/
|
||||
function goodLogin(email?: string, status = 0): NonNullable<FleetAuthCommandDeps['runLogin']> {
|
||||
return (command, _args, env): number => {
|
||||
const dir =
|
||||
command === 'claude'
|
||||
? (env['CLAUDE_SECURESTORAGE_CONFIG_DIR'] ?? '')
|
||||
: (env['PI_CODING_AGENT_DIR'] ?? env['CODEX_HOME'] ?? env['XDG_CONFIG_HOME'] ?? '');
|
||||
writeFileSync(join(dir, command === 'claude' ? '.credentials.json' : 'auth.json'), '{}', {
|
||||
mode: 0o600,
|
||||
});
|
||||
if (email !== undefined) {
|
||||
writeFileSync(
|
||||
join(dir, command === 'claude' ? '.claude.json' : 'auth.json'),
|
||||
JSON.stringify(
|
||||
command === 'claude' ? { oauthAccount: { emailAddress: email } } : { account: { email } },
|
||||
),
|
||||
);
|
||||
}
|
||||
return status;
|
||||
};
|
||||
}
|
||||
|
||||
function scaffoldSeat(
|
||||
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 });
|
||||
const path = join(dir, 'profile.json');
|
||||
writeFileSync(path, `${JSON.stringify(profile, null, 2)}\n`);
|
||||
return path;
|
||||
}
|
||||
|
||||
describe('mosaic auth enroll', () => {
|
||||
it('runs the harness login against the bundle directory and reports what landed', async () => {
|
||||
const h = await harness({ runLogin: goodLogin('[email protected]') });
|
||||
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']);
|
||||
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const bundleDir = join(h.home, 'auth', 'claude', 'jason_woltje.com');
|
||||
expect(h.out.join('\n')).toContain(bundleDir);
|
||||
expect(h.out.join('\n')).toContain('account: [email protected]');
|
||||
const recorded = JSON.parse(await readFile(join(bundleDir, 'account.json'), 'utf8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(recorded['emailAddress']).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('hands the harness its own home and credential directory, never an empty value', async () => {
|
||||
const h = await harness({ runLogin: goodLogin() });
|
||||
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']);
|
||||
|
||||
const bundleDir = join(h.home, 'auth', 'claude', 'jason_woltje.com');
|
||||
expect(h.logins).toEqual([
|
||||
{
|
||||
command: 'claude',
|
||||
args: [],
|
||||
// An empty CLAUDE_SECURESTORAGE_CONFIG_DIR is not "unset" -- Claude resolves it to
|
||||
// ~/.claude, the operator's own account -- so exporting one would quietly log the
|
||||
// operator in over their own credentials instead of enrolling the seat's.
|
||||
env: { CLAUDE_CONFIG_DIR: bundleDir, CLAUDE_SECURESTORAGE_CONFIG_DIR: bundleDir },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('forwards login arguments to the harness', async () => {
|
||||
const h = await harness({ runLogin: goodLogin() });
|
||||
await h.run([
|
||||
'enroll',
|
||||
'--harness',
|
||||
'pi',
|
||||
'--bundle',
|
||||
'jason_woltje.com',
|
||||
'--login-arg',
|
||||
'/login',
|
||||
]);
|
||||
|
||||
expect(h.logins[0]?.args).toEqual(['/login']);
|
||||
expect(h.logins[0]?.env).toEqual({
|
||||
PI_CODING_AGENT_DIR: join(h.home, 'auth', 'pi', 'jason_woltje.com'),
|
||||
});
|
||||
});
|
||||
|
||||
it('exits non-zero when the account that logged in is not the account the bundle claims', async () => {
|
||||
const h = await harness({ runLogin: goodLogin('[email protected]') });
|
||||
await h.run(['enroll', '--harness', 'claude', '--bundle', 'reviewer_example.com']);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(h.err.join('')).toContain('[email protected]');
|
||||
expect(h.err.join('')).toContain('one principal wearing two names');
|
||||
});
|
||||
|
||||
it('fails clearly when the harness is not installed', async () => {
|
||||
const h = await harness({ runLogin: (): null => null });
|
||||
await h.run(['enroll', '--harness', 'pi', '--bundle', 'someone_example.com']);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(h.err.join('')).toContain('could not start "pi"');
|
||||
});
|
||||
|
||||
it('reports a login that wrote nothing rather than calling the bundle enrolled', async () => {
|
||||
const h = await harness({ runLogin: (): number => 0 });
|
||||
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(h.err.join('')).toContain('login left no credential');
|
||||
expect(h.err.join('')).toContain('nothing was assigned');
|
||||
});
|
||||
|
||||
it('still checks the bundle when the harness exits non-zero on quit', async () => {
|
||||
// Several harnesses exit non-zero on a normal quit after a successful login. The
|
||||
// credential on disk is the fact that matters, not the exit status.
|
||||
const h = await harness({ runLogin: goodLogin('[email protected]', 130) });
|
||||
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']);
|
||||
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(h.out.join('\n')).toContain('account: [email protected]');
|
||||
});
|
||||
|
||||
it('creates the directory and stops when the operator will run the login themselves', async () => {
|
||||
const h = await harness();
|
||||
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com', '--no-login']);
|
||||
|
||||
expect(h.logins).toHaveLength(0);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(h.out.join('\n')).toContain('CLAUDE_SECURESTORAGE_CONFIG_DIR=');
|
||||
});
|
||||
|
||||
it('rejects a harness it does not know', async () => {
|
||||
const h = await harness();
|
||||
await h.run(['enroll', '--harness', 'emacs', '--bundle', 'x']);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(h.err.join('')).toContain('--harness must be one of');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mosaic auth assign', () => {
|
||||
it('pegs a seat to a bundle and leaves every other profile field alone', async () => {
|
||||
const h = await harness();
|
||||
const path = scaffoldSeat(h.home, 'uc-e6-rev', {
|
||||
schema: 1,
|
||||
harness: 'claude',
|
||||
bundle: 'primary',
|
||||
model: 'opus',
|
||||
overlay: 'overlay.json',
|
||||
env: { MOSAIC_AGENT_NAME: 'uc-e6-rev' },
|
||||
});
|
||||
|
||||
await h.run(['assign', 'uc-e6-rev', '--bundle', 'reviewer_example.com']);
|
||||
|
||||
const written = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>;
|
||||
expect(written).toEqual({
|
||||
schema: 1,
|
||||
harness: 'claude',
|
||||
bundle: 'reviewer_example.com',
|
||||
model: 'opus',
|
||||
overlay: 'overlay.json',
|
||||
env: { MOSAIC_AGENT_NAME: 'uc-e6-rev' },
|
||||
});
|
||||
expect(h.out.join('\n')).toContain('uc-e6-rev: primary -> reviewer_example.com');
|
||||
});
|
||||
|
||||
it('says the bundle is not enrolled, because the seat will refuse to launch until it is', async () => {
|
||||
const h = await harness();
|
||||
scaffoldSeat(h.home, 'uc-e6-rev');
|
||||
|
||||
await h.run(['assign', 'uc-e6-rev', '--bundle', 'reviewer_example.com']);
|
||||
|
||||
expect(h.out.join('\n')).toContain('is not enrolled for claude');
|
||||
});
|
||||
|
||||
it('is quiet about enrolment when the bundle really is enrolled', async () => {
|
||||
const h = await harness({ runLogin: goodLogin('[email protected]') });
|
||||
await h.run(['enroll', '--harness', 'claude', '--bundle', 'reviewer_example.com']);
|
||||
scaffoldSeat(h.home, 'uc-e6-rev');
|
||||
h.out.length = 0;
|
||||
|
||||
await h.run(['assign', 'uc-e6-rev', '--bundle', 'reviewer_example.com']);
|
||||
|
||||
expect(h.out.join('\n')).not.toContain('is not enrolled');
|
||||
});
|
||||
|
||||
it('reports an unchanged seat instead of rewriting it', async () => {
|
||||
const h = await harness();
|
||||
scaffoldSeat(h.home, 'seat', { schema: 1, harness: 'pi', bundle: 'held_example.com' });
|
||||
|
||||
await h.run(['assign', 'seat', '--bundle', 'held_example.com']);
|
||||
|
||||
expect(h.out.join('\n')).toContain('seat: already held_example.com (pi)');
|
||||
});
|
||||
|
||||
it('assigns every scaffolded seat with --all', async () => {
|
||||
const h = await harness();
|
||||
scaffoldSeat(h.home, 'a');
|
||||
scaffoldSeat(h.home, 'b', { schema: 1, harness: 'pi', bundle: 'primary' });
|
||||
|
||||
await h.run(['assign', '--all', '--bundle', 'shared_example.com']);
|
||||
|
||||
for (const name of ['a', 'b']) {
|
||||
const written = JSON.parse(
|
||||
await readFile(join(h.home, 'fleet', 'agents', name, 'profile.json'), 'utf8'),
|
||||
) as Record<string, unknown>;
|
||||
expect(written['bundle']).toBe('shared_example.com');
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses an ambiguous target rather than guessing', async () => {
|
||||
const h = await harness();
|
||||
scaffoldSeat(h.home, 'a');
|
||||
|
||||
await h.run(['assign', 'a', '--all', '--bundle', 'x']);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(h.err.join('')).toContain('exactly one of');
|
||||
|
||||
process.exitCode = undefined;
|
||||
h.err.length = 0;
|
||||
await h.run(['assign', '--bundle', 'x']);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(h.err.join('')).toContain('exactly one of');
|
||||
});
|
||||
|
||||
it('names the seat that does not exist', async () => {
|
||||
const h = await harness();
|
||||
await h.run(['assign', 'ghost', '--bundle', 'x']);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(h.err.join('')).toContain('no such fleet agent');
|
||||
expect(h.err.join('')).toContain('mosaic fleet agent new ghost');
|
||||
});
|
||||
|
||||
it('refuses to rewrite a profile that is already invalid', async () => {
|
||||
const h = await harness();
|
||||
// Re-serializing a broken profile would produce a file that looks repaired and still
|
||||
// fails at launch, with the original damage no longer visible.
|
||||
scaffoldSeat(h.home, 'broken', { schema: 1, harness: 'claude', nonsense: true });
|
||||
|
||||
await h.run(['assign', 'broken', '--bundle', 'x']);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(h.err.join('')).toContain('unknown profile key "nonsense"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mosaic auth list', () => {
|
||||
it('says where bundles would live on a host that has none', async () => {
|
||||
const h = await harness();
|
||||
await h.run(['list']);
|
||||
|
||||
expect(h.out.join('\n')).toContain(join(h.home, 'auth'));
|
||||
expect(h.out.join('\n')).toContain('mosaic auth enroll');
|
||||
});
|
||||
|
||||
it('shows each bundle with its enrolment state and account', async () => {
|
||||
const h = await harness({ runLogin: goodLogin('[email protected]') });
|
||||
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']);
|
||||
h.out.length = 0;
|
||||
|
||||
await h.run(['list', '--harness', 'claude']);
|
||||
|
||||
const text = h.out.join('\n');
|
||||
expect(text).toContain('jason_woltje.com');
|
||||
expect(text).toContain('enrolled');
|
||||
expect(text).toContain('[email protected]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mosaic auth default', () => {
|
||||
it('moves the primary alias to a bundle', async () => {
|
||||
const h = await harness({ runLogin: goodLogin('[email protected]') });
|
||||
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']);
|
||||
h.out.length = 0;
|
||||
|
||||
await h.run(['default', 'jason_woltje.com', '--harness', 'claude']);
|
||||
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(h.out.join('\n')).toContain('primary -> jason_woltje.com');
|
||||
h.out.length = 0;
|
||||
await h.run(['list', '--harness', 'claude']);
|
||||
expect(h.out.join('\n')).toContain('primary -> jason_woltje.com');
|
||||
});
|
||||
|
||||
it('refuses a bundle that was never enrolled', async () => {
|
||||
const h = await harness();
|
||||
mkdirSync(join(h.home, 'auth', 'claude'), { recursive: true });
|
||||
|
||||
await h.run(['default', 'missing_example.com', '--harness', 'claude']);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(h.err.join('')).toContain('no such bundle');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* `mosaic auth enroll | assign | list | default` -- the operator surface for credential bundles.
|
||||
*
|
||||
* These are local commands. They never talk to the gateway, unlike the rest of `mosaic auth`,
|
||||
* and they work on a host where the gateway is down. What they do is give one host more than
|
||||
* one account per harness and let each seat be pegged to one of them.
|
||||
*
|
||||
* Enroll does not reimplement any harness's login. It creates a private bundle directory,
|
||||
* points the harness's own home at it by environment, and runs the harness. Whatever the
|
||||
* harness writes is then checked: credential present, owner-only, and the account it belongs
|
||||
* to recorded. Logging into the wrong account is the failure this catches -- it is otherwise
|
||||
* silent, and it collapses two principals back into one.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import {
|
||||
AuthBundleError,
|
||||
PRIMARY_ALIAS,
|
||||
completeEnrollment,
|
||||
listBundles,
|
||||
prepareEnrollment,
|
||||
setDefaultBundle,
|
||||
} from '../fleet/auth-bundles.js';
|
||||
import type { CredentialHarness } from '../fleet/credential-sharing.js';
|
||||
import { defaultFleetDataHome } from '../fleet/fleet-agent-scaffold.js';
|
||||
import { FleetLaunchError, parseFleetAgentProfile } from './fleet-launch-command.js';
|
||||
|
||||
const HARNESSES: readonly CredentialHarness[] = ['claude', 'codex', 'opencode', 'pi'];
|
||||
|
||||
export interface FleetAuthCommandDeps {
|
||||
/** Test seam for the user-owned ~/.mosaic root. */
|
||||
readonly fleetDataHome?: string;
|
||||
/**
|
||||
* Test seam for running the harness login. Returns the harness's exit status; `null` means
|
||||
* the harness could not be started at all.
|
||||
*/
|
||||
readonly runLogin?: (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
env: Readonly<Record<string, string>>,
|
||||
) => number | null;
|
||||
}
|
||||
|
||||
function requireHarness(value: string | undefined): CredentialHarness {
|
||||
if (value === undefined || !HARNESSES.includes(value as CredentialHarness)) {
|
||||
throw new AuthBundleError(
|
||||
'invalid-request',
|
||||
`--harness must be one of: ${HARNESSES.join(', ')}`,
|
||||
);
|
||||
}
|
||||
return value as CredentialHarness;
|
||||
}
|
||||
|
||||
function defaultRunLogin(
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
env: Readonly<Record<string, string>>,
|
||||
): number | null {
|
||||
const result = spawnSync(command, [...args], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
if (result.error !== undefined) return null;
|
||||
return result.status;
|
||||
}
|
||||
|
||||
function fail(error: unknown, verb: string): void {
|
||||
process.exitCode = 1;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const code =
|
||||
error instanceof AuthBundleError
|
||||
? error.code
|
||||
: error instanceof FleetLaunchError
|
||||
? error.code
|
||||
: 'failed';
|
||||
process.stderr.write(`mosaic auth ${verb} failed (${code}): ${message}\n`);
|
||||
}
|
||||
|
||||
// ─── assign ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AssignOutcome {
|
||||
readonly agent: string;
|
||||
readonly harness: CredentialHarness;
|
||||
readonly from: string;
|
||||
readonly to: string;
|
||||
readonly changed: boolean;
|
||||
}
|
||||
|
||||
function agentsRoot(dataHome: string): string {
|
||||
return join(dataHome, 'fleet', 'agents');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite one seat's `bundle`, leaving every other field byte-identical where possible.
|
||||
*
|
||||
* The profile is re-parsed before writing rather than patched blind: an already-invalid
|
||||
* profile should be reported as invalid here, not silently re-serialized into something that
|
||||
* looks fine and still fails at launch.
|
||||
*/
|
||||
function assignOne(dataHome: string, agent: string, bundle: string): AssignOutcome {
|
||||
const path = join(agentsRoot(dataHome), agent, 'profile.json');
|
||||
let source: string;
|
||||
try {
|
||||
source = readFileSync(path, 'utf8');
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw new AuthBundleError(
|
||||
'invalid-request',
|
||||
`no such fleet agent: ${path} — scaffold it first: mosaic fleet agent new ${agent}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const profile = parseFleetAgentProfile(source);
|
||||
const harness = profile.harness as CredentialHarness;
|
||||
const raw = JSON.parse(source) as Record<string, unknown>;
|
||||
const from = profile.bundle;
|
||||
if (from === bundle) return { agent, harness, from, to: bundle, changed: false };
|
||||
raw['bundle'] = bundle;
|
||||
writeFileSync(path, `${JSON.stringify(raw, null, 2)}\n`);
|
||||
return { agent, harness, from, to: bundle, changed: true };
|
||||
}
|
||||
|
||||
// ─── registration ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Adds the local bundle verbs onto the existing `mosaic auth` command. */
|
||||
export function registerFleetAuthCommands(
|
||||
authCommand: Command,
|
||||
deps: FleetAuthCommandDeps = {},
|
||||
): void {
|
||||
const dataHome = (): string => deps.fleetDataHome ?? defaultFleetDataHome();
|
||||
const runLogin = deps.runLogin ?? defaultRunLogin;
|
||||
|
||||
authCommand
|
||||
.command('enroll')
|
||||
.description('Enrol a credential bundle by running a harness login into a private directory')
|
||||
.requiredOption('--harness <harness>', `Harness: ${HARNESSES.join(', ')}`)
|
||||
.requiredOption('--bundle <bundle>', 'Bundle name, normally the account email with @ as _')
|
||||
.option('--login-arg <arg...>', 'Arguments to pass to the harness login invocation')
|
||||
.option('--no-login', 'Only create the bundle directory; run the login yourself')
|
||||
.action(
|
||||
(options: {
|
||||
harness?: string;
|
||||
bundle: string;
|
||||
loginArg?: string[];
|
||||
login?: boolean;
|
||||
}): void => {
|
||||
try {
|
||||
const harness = requireHarness(options.harness);
|
||||
const plan = prepareEnrollment(dataHome(), harness, options.bundle);
|
||||
|
||||
console.log(`Bundle directory: ${plan.bundleDir}`);
|
||||
if (plan.hadCredential) {
|
||||
console.log('A credential is already present. Logging in again replaces it.');
|
||||
}
|
||||
for (const [key, value] of Object.entries(plan.env)) {
|
||||
console.log(` ${key}=${value}`);
|
||||
}
|
||||
|
||||
if (options.login === false) {
|
||||
console.log(
|
||||
`\nRun the ${harness} login with the environment above, then verify with:\n mosaic auth list --harness ${harness}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\nStarting ${harness} against that directory. Complete the login inside it, then exit.`,
|
||||
);
|
||||
const status = runLogin(harness, options.loginArg ?? [], plan.env);
|
||||
if (status === null) {
|
||||
throw new AuthBundleError(
|
||||
'invalid-request',
|
||||
`could not start "${harness}" — is it installed and on PATH?`,
|
||||
);
|
||||
}
|
||||
// A non-zero login is reported but still checked: some harnesses exit non-zero on
|
||||
// a normal quit after a successful login, and the credential on disk is the fact
|
||||
// that matters, not the exit status.
|
||||
if (status !== 0) {
|
||||
console.log(`\nNote: ${harness} exited ${String(status)}. Checking the bundle anyway.`);
|
||||
}
|
||||
|
||||
const result = completeEnrollment(plan);
|
||||
console.log(`\nEnrolled ${harness} bundle "${result.bundle}".`);
|
||||
console.log(` credential: ${result.credentialPath}`);
|
||||
if (result.tightened) {
|
||||
console.log(' permissions: tightened to owner-only');
|
||||
}
|
||||
if (result.email !== undefined) {
|
||||
console.log(` account: ${result.email}`);
|
||||
} else {
|
||||
console.log(
|
||||
' account: could not be determined from what the harness wrote; the bundle name is not verified against the logged-in account',
|
||||
);
|
||||
}
|
||||
if (result.identityMismatch !== undefined) {
|
||||
process.exitCode = 1;
|
||||
process.stderr.write(
|
||||
`\nWARNING: this bundle is named "${result.bundle}" but the account that logged in is "${result.email ?? 'unknown'}", which implies "${result.identityMismatch}".\n` +
|
||||
'Two seats pointed at bundles that hold the same account are one principal wearing two names. Re-enrol under the right name, or delete this bundle.\n',
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`\nAssign it to a seat with:\n mosaic auth assign <agent> --bundle ${result.bundle}`,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
fail(error, 'enroll');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
authCommand
|
||||
.command('assign [agent]')
|
||||
.description('Peg a fleet seat to a credential bundle')
|
||||
.requiredOption('--bundle <bundle>', 'Bundle name to assign')
|
||||
.option('--all', 'Assign every scaffolded seat')
|
||||
.action((agent: string | undefined, options: { bundle: string; all?: boolean }): void => {
|
||||
try {
|
||||
const home = dataHome();
|
||||
if ((agent === undefined) === (options.all !== true)) {
|
||||
throw new AuthBundleError(
|
||||
'invalid-request',
|
||||
'give exactly one of: an agent name, or --all',
|
||||
);
|
||||
}
|
||||
const targets = options.all === true ? listAgents(home) : [agent as string];
|
||||
if (targets.length === 0) {
|
||||
console.log('No scaffolded fleet agents found; nothing to assign.');
|
||||
return;
|
||||
}
|
||||
// Assignment does not require the bundle to be enrolled -- scaffolding a seat before
|
||||
// its account exists is a normal order of operations -- but an unenrolled bundle is
|
||||
// worth saying out loud, because the seat will refuse to launch until it is. The
|
||||
// check is per harness: the same bundle name under a different harness is a
|
||||
// different bundle.
|
||||
const unenrolled = new Set<CredentialHarness>();
|
||||
for (const target of targets) {
|
||||
const outcome = assignOne(home, target, options.bundle);
|
||||
console.log(
|
||||
outcome.changed
|
||||
? `${outcome.agent}: ${outcome.from} -> ${outcome.to} (${outcome.harness})`
|
||||
: `${outcome.agent}: already ${outcome.to} (${outcome.harness})`,
|
||||
);
|
||||
const enrolled = listBundles(home, outcome.harness).some(
|
||||
(entry) => entry.name === options.bundle && entry.enrolled,
|
||||
);
|
||||
if (!enrolled) unenrolled.add(outcome.harness);
|
||||
}
|
||||
for (const harness of unenrolled) {
|
||||
console.log(
|
||||
`\nNotice: "${options.bundle}" is not enrolled for ${harness}, so those seats will refuse to launch until it is.\n mosaic auth enroll --harness ${harness} --bundle ${options.bundle}`,
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
fail(error, 'assign');
|
||||
}
|
||||
});
|
||||
|
||||
authCommand
|
||||
.command('list')
|
||||
.description('List local credential bundles and which accounts they hold')
|
||||
.option('--harness <harness>', `Limit to one harness: ${HARNESSES.join(', ')}`)
|
||||
.action((options: { harness?: string }): void => {
|
||||
try {
|
||||
const home = dataHome();
|
||||
const harnesses =
|
||||
options.harness === undefined ? HARNESSES : [requireHarness(options.harness)];
|
||||
let found = 0;
|
||||
for (const harness of harnesses) {
|
||||
const bundles = listBundles(home, harness);
|
||||
if (bundles.length === 0) continue;
|
||||
found += bundles.length;
|
||||
console.log(`${harness}:`);
|
||||
for (const bundle of bundles) {
|
||||
const parts = [
|
||||
bundle.alias ? `${bundle.name} -> ${bundle.target ?? '(dangling)'}` : bundle.name,
|
||||
bundle.enrolled ? 'enrolled' : 'NOT ENROLLED',
|
||||
];
|
||||
if (bundle.email !== undefined) parts.push(bundle.email);
|
||||
console.log(` ${parts.join(' ')}`);
|
||||
}
|
||||
}
|
||||
if (found === 0) {
|
||||
console.log(
|
||||
`No credential bundles under ${join(home, 'auth')}.\nEnrol one with: mosaic auth enroll --harness <harness> --bundle <account>`,
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
fail(error, 'list');
|
||||
}
|
||||
});
|
||||
|
||||
authCommand
|
||||
.command('default <bundle>')
|
||||
.description(`Point the movable "${PRIMARY_ALIAS}" alias at a bundle`)
|
||||
.requiredOption('--harness <harness>', `Harness: ${HARNESSES.join(', ')}`)
|
||||
.action((bundle: string, options: { harness?: string }): void => {
|
||||
try {
|
||||
const harness = requireHarness(options.harness);
|
||||
const alias = setDefaultBundle(dataHome(), harness, bundle);
|
||||
console.log(`${alias} -> ${bundle}`);
|
||||
console.log(
|
||||
`Seats with "bundle": "${PRIMARY_ALIAS}" now use ${bundle} at their next launch. Seats pinned to a named bundle are unaffected.`,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
fail(error, 'default');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -36,25 +36,28 @@ function fixture(profile: Record<string, unknown> = { schema: 1, harness: 'claud
|
||||
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', 'claude', 'fred_example.com');
|
||||
mkdirSync(join(systemHome, 'runtime', 'claude'), { recursive: true });
|
||||
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', 'claude', 'settings.json'), '{}\n');
|
||||
writeFileSync(join(systemHome, 'runtime', harness, 'settings.json'), '{}\n');
|
||||
writeFileSync(join(agentDir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`);
|
||||
writeFileSync(join(namedBundleDir, '.credentials.json'), '{}\n', { mode: 0o600 });
|
||||
writeFileSync(join(namedBundleDir, credentialName), '{}\n', { mode: 0o600 });
|
||||
writeFileSync(
|
||||
join(namedBundleDir, 'account.json'),
|
||||
'{"oauthAccount":{"emailAddress":"[email protected]"}}\n',
|
||||
);
|
||||
symlinkSync('fred_example.com', join(userHome, 'auth', 'claude', 'primary'), 'dir');
|
||||
return { root, systemHome, userHome, agentDir, namedBundleDir };
|
||||
symlinkSync('fred_example.com', join(userHome, 'auth', harness, 'primary'), 'dir');
|
||||
return { root, systemHome, userHome, agentDir, namedBundleDir, credentialName };
|
||||
}
|
||||
|
||||
describe('fleet launch profile schema 1', () => {
|
||||
@@ -288,6 +291,65 @@ describe('profile-selected overlay', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('system settings layer on a real install', () => {
|
||||
it('composes a harness whose runtime ships no settings.json', () => {
|
||||
// Measured on a greenfield Debian 13 VM against framework main: the install ships
|
||||
// runtime/<harness>/ for claude, codex, opencode and pi but a settings.json only for
|
||||
// claude. Requiring the file made every pi seat unlaunchable.
|
||||
const fx = fixture({ schema: 1, harness: 'pi' });
|
||||
rmSync(join(fx.systemHome, 'runtime', 'pi', 'settings.json'));
|
||||
writeFileSync(join(fx.systemHome, 'runtime', 'pi', 'RUNTIME.md'), '# pi\n');
|
||||
|
||||
const plan = resolveFleetLaunchComposition('fred', {
|
||||
systemHome: fx.systemHome,
|
||||
userHome: fx.userHome,
|
||||
});
|
||||
expect(plan.settings.layers[0]?.present).toBe(false);
|
||||
expect(plan.settings.merged).toEqual({});
|
||||
});
|
||||
|
||||
it('still refuses a harness the framework does not carry', () => {
|
||||
const fx = fixture({ schema: 1, harness: 'pi' });
|
||||
rmSync(join(fx.systemHome, 'runtime', 'pi'), { recursive: true });
|
||||
|
||||
try {
|
||||
resolveFleetLaunchComposition('fred', {
|
||||
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('COMPOSITION_FAILED');
|
||||
expect(launchError.message).toMatch(/harness runtime is not installed/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('never-enrolled hosts', () => {
|
||||
it('names the enroll command instead of reporting a shape violation', () => {
|
||||
// A host that has simply never logged in has no ~/.mosaic/auth at all. Reusing the
|
||||
// wrong-shape wording there told the operator their auth directory "must be a real,
|
||||
// non-symlink directory", which reads as tampering rather than "enroll a bundle".
|
||||
const fx = fixture();
|
||||
rmSync(join(fx.userHome, 'auth'), { recursive: true });
|
||||
|
||||
try {
|
||||
resolveFleetLaunchComposition('fred', {
|
||||
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('COMPOSITION_FAILED');
|
||||
expect(launchError.message).toMatch(/does not exist/);
|
||||
expect(launchError.message).toMatch(/mosaic auth enroll/);
|
||||
expect(launchError.message).not.toMatch(/non-symlink/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('unscaffolded agent names', () => {
|
||||
it('points an unscaffolded name at mosaic fleet agent new', () => {
|
||||
const fx = fixture();
|
||||
@@ -381,6 +443,33 @@ describe('A3 credential validation', () => {
|
||||
).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', () => {
|
||||
@@ -513,12 +602,14 @@ describe('managed plugin and skill links', () => {
|
||||
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();
|
||||
const seatHome = join(fx.agentDir, '.claude');
|
||||
const link = join(seatHome, '.credentials.json');
|
||||
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, '.credentials.json'), link, 'file');
|
||||
symlinkSync(join(fx.namedBundleDir, fx.credentialName), link, 'file');
|
||||
const plan = resolveFleetLaunchComposition('fred', {
|
||||
systemHome: fx.systemHome,
|
||||
userHome: fx.userHome,
|
||||
@@ -527,7 +618,7 @@ describe('managed plugin and skill links', () => {
|
||||
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
|
||||
/unrecorded or retargeted symlink/,
|
||||
);
|
||||
expect(readlinkSync(link)).toBe(join(fx.namedBundleDir, '.credentials.json'));
|
||||
expect(readlinkSync(link)).toBe(join(fx.namedBundleDir, fx.credentialName));
|
||||
});
|
||||
|
||||
it.each(['plugins', 'skills'] as const)(
|
||||
@@ -553,12 +644,12 @@ describe('managed plugin and skill links', () => {
|
||||
);
|
||||
|
||||
it('refuses an unrecorded mismatched credential symlink', () => {
|
||||
const fx = fixture();
|
||||
const seatHome = join(fx.agentDir, '.claude');
|
||||
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, '.credentials.json'), 'file');
|
||||
symlinkSync(foreignCredential, join(seatHome, fx.credentialName), 'file');
|
||||
const plan = resolveFleetLaunchComposition('fred', {
|
||||
systemHome: fx.systemHome,
|
||||
userHome: fx.userHome,
|
||||
@@ -567,7 +658,7 @@ describe('managed plugin and skill links', () => {
|
||||
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
|
||||
/unrecorded or retargeted symlink/,
|
||||
);
|
||||
expect(readFileSync(join(seatHome, '.credentials.json'), 'utf8')).toBe('{}\n');
|
||||
expect(readFileSync(join(seatHome, fx.credentialName), 'utf8')).toBe('{}\n');
|
||||
});
|
||||
|
||||
it('tolerates harness metadata files in the install root and still refuses real directories', () => {
|
||||
@@ -654,13 +745,44 @@ describe('fleet launch command outcomes', () => {
|
||||
['--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 },
|
||||
false,
|
||||
);
|
||||
expect(lstatSync(join(fx.agentDir, '.claude', '.credentials.json')).isSymbolicLink()).toBe(
|
||||
true,
|
||||
// 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('asks for dangerous permissions only when the caller does', () => {
|
||||
const fx = fixture({ schema: 1, harness: 'claude' });
|
||||
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', '--dangerous']);
|
||||
|
||||
expect(launcher).toHaveBeenCalledWith('claude', [], expect.anything(), expect.anything(), true);
|
||||
});
|
||||
|
||||
it('lets a caller-supplied --model replace the profile model instead of duplicating it', () => {
|
||||
const fx = fixture({ schema: 1, harness: 'claude', model: 'opus' });
|
||||
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', '--model', 'sonnet']);
|
||||
|
||||
expect(launcher).toHaveBeenCalledWith(
|
||||
'claude',
|
||||
['--model', 'sonnet'],
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -743,12 +865,13 @@ describe('dry-run composition', () => {
|
||||
}
|
||||
}
|
||||
bundle: primary -> fred_example.com ([email protected])
|
||||
credential: <ROOT>/user/auth/claude/fred_example.com/.credentials.json
|
||||
symlinks:
|
||||
credentials: <ROOT>/user/fleet/agents/fred/.claude/.credentials.json -> <ROOT>/user/auth/claude/fred_example.com/.credentials.json
|
||||
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"]"
|
||||
@@ -763,6 +886,7 @@ describe('dry-run composition', () => {
|
||||
expect(readFileSync(plan.settings.snapshot, 'utf8')).toBe(
|
||||
readFileSync(plan.settings.output, 'utf8'),
|
||||
);
|
||||
expect(lstatSync(plan.credential.link).isSymbolicLink()).toBe(true);
|
||||
expect(plan.credential.link).toBeUndefined();
|
||||
expect(plan.credential.dir).toBe(fx.namedBundleDir);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
type RuntimeName,
|
||||
} from './launch.js';
|
||||
import { defaultFleetDataHome } from '../fleet/fleet-agent-scaffold.js';
|
||||
import {
|
||||
CREDENTIAL_DIR_ENV as CREDENTIAL_DIR_ENV_BY_HARNESS,
|
||||
CREDENTIAL_FILE_NAMES,
|
||||
} from '../fleet/credential-sharing.js';
|
||||
|
||||
export const FLEET_AGENT_PROFILE_SCHEMA = 1;
|
||||
const PROFILE_KEYS = [
|
||||
@@ -41,12 +45,9 @@ const STORE_ENTRY = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/;
|
||||
const BUNDLE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/;
|
||||
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
const CREDENTIAL_FILES: Record<RuntimeName, string> = {
|
||||
claude: '.credentials.json',
|
||||
pi: 'auth.json',
|
||||
codex: 'auth.json',
|
||||
opencode: 'auth.json',
|
||||
};
|
||||
// Assignability here is what keeps CredentialHarness and RuntimeName from drifting apart.
|
||||
const CREDENTIAL_FILES: Record<RuntimeName, string> = CREDENTIAL_FILE_NAMES;
|
||||
const CREDENTIAL_DIR_ENV: Partial<Record<RuntimeName, string>> = CREDENTIAL_DIR_ENV_BY_HARNESS;
|
||||
|
||||
export type FleetLaunchErrorCode =
|
||||
| 'SCHEMA_TOO_NEW'
|
||||
@@ -125,8 +126,14 @@ export interface FleetLaunchComposition {
|
||||
readonly display: string;
|
||||
};
|
||||
readonly credential: {
|
||||
readonly link: string;
|
||||
/**
|
||||
* The seat-local managed link to the bundle credential. Absent for harnesses
|
||||
* that reach the shared bundle by environment instead (see CREDENTIAL_DIR_ENV).
|
||||
*/
|
||||
readonly link?: string;
|
||||
readonly target: string;
|
||||
/** Resolved bundle directory holding the credential file. */
|
||||
readonly dir: string;
|
||||
};
|
||||
readonly managedLinks: ManagedLinkState;
|
||||
readonly installs: readonly PlannedLink[];
|
||||
@@ -142,6 +149,7 @@ export interface FleetLaunchCommandDeps {
|
||||
args: string[],
|
||||
declaredEnv: Readonly<Record<string, string>>,
|
||||
context: FleetHarnessContext,
|
||||
dangerous: boolean,
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -310,9 +318,20 @@ function lstatIfPresent(path: string): Stats | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function assertRealDirectory(path: string, label: string): void {
|
||||
function assertRealDirectory(path: string, label: string, absentHint?: string): void {
|
||||
const info = lstatIfPresent(path);
|
||||
if (!info?.isDirectory() || info.isSymbolicLink()) {
|
||||
// Absent and wrong-shaped are different problems and want different words. A host that has
|
||||
// simply never enrolled a bundle was being told its auth directory "must be a real,
|
||||
// non-symlink directory", which reads as a tampering report rather than "log in first".
|
||||
if (!info) {
|
||||
throw new FleetLaunchError(
|
||||
'COMPOSITION_FAILED',
|
||||
absentHint
|
||||
? `${label} does not exist: ${path} — ${absentHint}`
|
||||
: `${label} does not exist: ${path}`,
|
||||
);
|
||||
}
|
||||
if (!info.isDirectory() || info.isSymbolicLink()) {
|
||||
throw new FleetLaunchError(
|
||||
'COMPOSITION_FAILED',
|
||||
`${label} must be a real, non-symlink directory: ${path}`,
|
||||
@@ -330,6 +349,22 @@ function assertContained(root: string, candidate: string, label: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proves the framework is installed and knows this harness. This is the check the required
|
||||
* system settings layer used to stand in for, moved to the thing that is actually always
|
||||
* present: the runtime directory. A missing one means an uninstalled framework or a harness
|
||||
* the install does not carry, and both are worth failing on before a seat is composed.
|
||||
*/
|
||||
function assertHarnessRuntimeInstalled(systemHome: string, harness: string): void {
|
||||
const runtimeDir = join(systemHome, 'runtime', harness);
|
||||
if (!lstatIfPresent(runtimeDir)?.isDirectory()) {
|
||||
throw new FleetLaunchError(
|
||||
'COMPOSITION_FAILED',
|
||||
`harness runtime is not installed: ${runtimeDir} — install the Mosaic framework, or check the harness name`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readSettingsLayer(
|
||||
name: SettingsLayer['name'],
|
||||
path: string,
|
||||
@@ -395,9 +430,10 @@ function resolveCredential(
|
||||
assertRealDirectory(userHome, 'user Mosaic root');
|
||||
const realUserHome = realpathSync(userHome);
|
||||
const authDirectory = join(userHome, 'auth');
|
||||
assertRealDirectory(authDirectory, 'auth directory');
|
||||
const enrollHint = `no auth bundle has been enrolled yet — run: mosaic auth enroll --harness ${profile.harness} --bundle ${profile.bundle}`;
|
||||
assertRealDirectory(authDirectory, 'auth directory', enrollHint);
|
||||
const authRoot = join(authDirectory, profile.harness);
|
||||
assertRealDirectory(authRoot, `${profile.harness} auth root`);
|
||||
assertRealDirectory(authRoot, `${profile.harness} auth root`, enrollHint);
|
||||
const resolvedAuthRoot = realpathSync(authRoot);
|
||||
assertContained(realUserHome, resolvedAuthRoot, `${profile.harness} auth root`);
|
||||
|
||||
@@ -448,6 +484,10 @@ function resolveCredential(
|
||||
`first-auth state detected at ${credentialLink}; refusing to delete or overwrite the real credential file. Enroll or promote it explicitly.`,
|
||||
);
|
||||
}
|
||||
// Environment-shared harnesses never read the seat-local path, so no link is
|
||||
// planned for it. A leftover link from an earlier scaffold is inert: the harness
|
||||
// resolves its credential directory from the environment instead.
|
||||
const sharesByEnv = CREDENTIAL_DIR_ENV[profile.harness] !== undefined;
|
||||
|
||||
const resolvedName = basename(resolvedBundleDir);
|
||||
const email = accountEmail(resolvedBundleDir);
|
||||
@@ -462,7 +502,11 @@ function resolveCredential(
|
||||
...(email === undefined ? {} : { email }),
|
||||
display,
|
||||
},
|
||||
credential: { link: credentialLink, target: resolvedCredential },
|
||||
credential: {
|
||||
...(sharesByEnv ? {} : { link: credentialLink }),
|
||||
target: resolvedCredential,
|
||||
dir: resolvedBundleDir,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -657,7 +701,10 @@ function buildArgv(
|
||||
passthrough: string[],
|
||||
): string[] {
|
||||
const argv: string[] = [profile.harness];
|
||||
if (profile.model) argv.push('--model', profile.model);
|
||||
// A caller-supplied --model replaces the profile's rather than being appended after
|
||||
// it. The fleet roster carries a model per seat and is the surface operators edit, so
|
||||
// it has to win; emitting both flags would leave that to each harness's arg parser.
|
||||
if (profile.model && !passthrough.includes('--model')) argv.push('--model', profile.model);
|
||||
if (profile.harness === 'pi') {
|
||||
for (const skill of profile.skills) argv.push('--skill', join(seatHome, 'skills', skill));
|
||||
}
|
||||
@@ -716,11 +763,16 @@ export function resolveFleetLaunchComposition(
|
||||
}
|
||||
const overlayPath = join(agentDir, profile.overlay ?? 'overlay.json');
|
||||
assertContained(agentDir, overlayPath, 'agent overlay');
|
||||
// The framework ships a runtime directory per harness but a settings.json only where it
|
||||
// has settings to state -- as of 0.0.49 that is claude alone, so requiring the file made
|
||||
// every pi, codex and opencode seat unlaunchable on a clean install. The install is what
|
||||
// has to be present; an absent base layer just means the harness has no system settings.
|
||||
assertHarnessRuntimeInstalled(roots.systemHome, profile.harness);
|
||||
const layers: SettingsLayer[] = [
|
||||
readSettingsLayer(
|
||||
'system',
|
||||
join(roots.systemHome, 'runtime', profile.harness, 'settings.json'),
|
||||
true,
|
||||
false,
|
||||
),
|
||||
readSettingsLayer(
|
||||
'user',
|
||||
@@ -754,9 +806,15 @@ export function resolveFleetLaunchComposition(
|
||||
codex: 'CODEX_HOME',
|
||||
opencode: 'XDG_CONFIG_HOME',
|
||||
};
|
||||
const credentialDirEnvName = CREDENTIAL_DIR_ENV[profile.harness];
|
||||
const env: Record<string, string> = {
|
||||
...profile.env,
|
||||
[homeEnvName[profile.harness]]: seatHome,
|
||||
// Only ever an absolute bundle path. Claude reads an empty value as ~/.claude,
|
||||
// which is the operator's own account, so an empty value is never exported.
|
||||
...(credentialDirEnvName === undefined
|
||||
? {}
|
||||
: { [credentialDirEnvName]: credential.credential.dir }),
|
||||
MOSAIC_AGENT_NAME: name,
|
||||
};
|
||||
return {
|
||||
@@ -839,7 +897,13 @@ function canonicalJson(value: unknown): unknown {
|
||||
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);
|
||||
if (plan.credential.link !== undefined) {
|
||||
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) {
|
||||
@@ -854,7 +918,9 @@ export function applyFleetLaunchComposition(plan: FleetLaunchComposition): void
|
||||
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 });
|
||||
ensureSymlink(plan.credential.link, plan.credential.target, plan.managedLinks);
|
||||
if (plan.credential.link !== undefined) {
|
||||
ensureSymlink(plan.credential.link, plan.credential.target, plan.managedLinks);
|
||||
}
|
||||
for (const path of plan.prune) {
|
||||
const info = lstatIfPresent(path);
|
||||
if (info?.isSymbolicLink()) {
|
||||
@@ -907,8 +973,13 @@ export function formatFleetLaunchDryRun(plan: FleetLaunchComposition): string {
|
||||
lines.push('merged settings:');
|
||||
lines.push(JSON.stringify(canonicalJson(plan.settings.merged), null, 2));
|
||||
lines.push(`bundle: ${plan.bundle.display}`);
|
||||
lines.push(`credential: ${plan.credential.target}`);
|
||||
lines.push('symlinks:');
|
||||
lines.push(` credentials: ${plan.credential.link} -> ${plan.credential.target}`);
|
||||
// Environment-shared harnesses have no credential symlink; the exported
|
||||
// credential-directory variable below is what points them at the bundle.
|
||||
if (plan.credential.link !== undefined) {
|
||||
lines.push(` credentials: ${plan.credential.link} -> ${plan.credential.target}`);
|
||||
}
|
||||
for (const install of plan.installs) {
|
||||
lines.push(` ${install.kind} ${install.name}: ${install.link} -> ${install.target}`);
|
||||
}
|
||||
@@ -929,33 +1000,43 @@ export function registerFleetLaunchCommand(
|
||||
.command('launch <name>')
|
||||
.description('Compose and launch one per-agent harness home')
|
||||
.option('--dry-run', 'Print the fully resolved composition without writing or launching')
|
||||
.option('--dangerous', 'Launch the seat in dangerous-permissions mode, as `mosaic yolo` does')
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action((name: string, opts: { dryRun?: boolean }, command: Command): void => {
|
||||
try {
|
||||
const userHome = deps.userHome ?? defaultFleetDataHome();
|
||||
const passthrough = command.args.slice(1);
|
||||
const plan = resolveFleetLaunchComposition(
|
||||
name,
|
||||
{ systemHome: systemHomeFor(), userHome },
|
||||
passthrough,
|
||||
);
|
||||
if (opts.dryRun === true) {
|
||||
process.stdout.write(`${formatFleetLaunchDryRun(plan)}\n`);
|
||||
return;
|
||||
.action(
|
||||
(name: string, opts: { dryRun?: boolean; dangerous?: boolean }, command: Command): void => {
|
||||
try {
|
||||
const userHome = deps.userHome ?? defaultFleetDataHome();
|
||||
const passthrough = command.args.slice(1);
|
||||
const plan = resolveFleetLaunchComposition(
|
||||
name,
|
||||
{ systemHome: systemHomeFor(), userHome },
|
||||
passthrough,
|
||||
);
|
||||
if (opts.dryRun === true) {
|
||||
process.stdout.write(`${formatFleetLaunchDryRun(plan)}\n`);
|
||||
return;
|
||||
}
|
||||
applyFleetLaunchComposition(plan);
|
||||
console.log(`[mosaic] bundle: ${plan.bundle.display}`);
|
||||
const launcher = deps.launcher ?? launchFleetRuntime;
|
||||
// Dangerous mode is the caller's to ask for, not the seat's to assume. An
|
||||
// unattended tmux seat needs it -- a permission prompt with nobody at the pane
|
||||
// is a hung agent -- so the roster launcher passes the flag explicitly and it
|
||||
// stays visible in the process table rather than hiding in a profile default.
|
||||
launcher(
|
||||
plan.profile.harness,
|
||||
plan.argv.slice(1),
|
||||
plan.env,
|
||||
{ agentDir: plan.agentDir, mosaicHome: plan.systemHome },
|
||||
opts.dangerous === true,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
process.exitCode = 1;
|
||||
const code = error instanceof FleetLaunchError ? `${error.code}: ` : '';
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`mosaic fleet launch failed: ${code}${message}\n`);
|
||||
}
|
||||
applyFleetLaunchComposition(plan);
|
||||
console.log(`[mosaic] bundle: ${plan.bundle.display}`);
|
||||
const launcher = deps.launcher ?? launchFleetRuntime;
|
||||
launcher(plan.profile.harness, plan.argv.slice(1), plan.env, {
|
||||
agentDir: plan.agentDir,
|
||||
mosaicHome: plan.systemHome,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
process.exitCode = 1;
|
||||
const code = error instanceof FleetLaunchError ? `${error.code}: ` : '';
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`mosaic fleet launch failed: ${code}${message}\n`);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ describe('registerFleetCommand', () => {
|
||||
expect(fleet).toBeDefined();
|
||||
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
|
||||
'add',
|
||||
'adopt',
|
||||
'agent',
|
||||
'apply',
|
||||
'backlog',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -122,6 +122,7 @@ describe('checkSequentialThinking', () => {
|
||||
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
|
||||
checker,
|
||||
);
|
||||
writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n');
|
||||
mkdirSync(join(agentDir, '.claude'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(agentDir, '.claude', '.claude.json'),
|
||||
@@ -174,6 +175,9 @@ describe('checkSequentialThinking', () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
// Scaffolded seat, unconfigured harness: the seat's own identity is present so this
|
||||
// still fails on the missing MCP configuration rather than on a missing SOUL.md.
|
||||
writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n');
|
||||
vi.stubEnv('HOME', home);
|
||||
expect(() =>
|
||||
launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, () => {
|
||||
@@ -200,6 +204,7 @@ describe('checkSequentialThinking', () => {
|
||||
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
|
||||
checker,
|
||||
);
|
||||
writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n');
|
||||
mkdirSync(join(agentDir, '.claude'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(agentDir, '.claude', '.claude.json'),
|
||||
@@ -234,6 +239,7 @@ describe('checkSequentialThinking', () => {
|
||||
const bin = join(installed, 'bin');
|
||||
try {
|
||||
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
|
||||
writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n');
|
||||
mkdirSync(join(agentDir, '.claude'), { recursive: true });
|
||||
mkdirSync(bin, { recursive: true });
|
||||
copyFileSync(
|
||||
@@ -281,6 +287,37 @@ describe('checkSequentialThinking', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses an unscaffolded seat instead of opening the interactive setup wizard', () => {
|
||||
// Measured on a greenfield VM: a roster-started seat whose host had no system SOUL.md
|
||||
// reached checkSoul(), which spawns `mosaic wizard` with inherited stdio. With nobody at
|
||||
// the pane the seat parked on the wizard's menu -- tmux session live, unit reporting
|
||||
// fine, no agent ever launched. A fleet seat's identity is its own SOUL.md, and an
|
||||
// unattended launch must fail loudly rather than wait for a keystroke.
|
||||
const home = mkdtempSync(join(tmpdir(), 'mosaic-soul-home-'));
|
||||
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-soul-seat-'));
|
||||
const installed = mkdtempSync(join(tmpdir(), 'mosaic-soul-installed-'));
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
try {
|
||||
vi.stubEnv('HOME', home);
|
||||
expect(() =>
|
||||
launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, () => {
|
||||
throw new Error('must not execute');
|
||||
}),
|
||||
).toThrow('process.exit called');
|
||||
expect(exit).toHaveBeenCalledWith(1);
|
||||
expect(error).toHaveBeenCalledWith(expect.stringContaining(join(agentDir, 'SOUL.md')));
|
||||
expect(error).toHaveBeenCalledWith(expect.stringContaining('mosaic fleet agent new'));
|
||||
} finally {
|
||||
error.mockRestore();
|
||||
exit.mockRestore();
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(agentDir, { recursive: true, force: true });
|
||||
rmSync(installed, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a group-writable installed helper root', () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
|
||||
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
|
||||
|
||||
@@ -244,7 +244,22 @@ function checkRuntime(cmd: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function checkSoul(): void {
|
||||
function checkSoul(fleet?: FleetHarnessContext): void {
|
||||
// A fleet seat carries its own identity -- `mosaic fleet agent new` writes SOUL.md into the
|
||||
// seat home -- so the operator's system-wide SOUL.md is not the file to check, and the
|
||||
// interactive wizard is never the right answer for an unattended seat. Measured on a
|
||||
// greenfield VM: a seat launched into tmux parked on the wizard's menu with nobody at the
|
||||
// pane. The session was live, the unit reported fine, and no agent ever started.
|
||||
if (fleet) {
|
||||
const seatSoul = join(fleet.agentDir, 'SOUL.md');
|
||||
if (!existsSync(seatSoul)) {
|
||||
console.error(`[mosaic] ERROR: seat identity not found: ${seatSoul}`);
|
||||
console.error('[mosaic] Scaffold the seat first: mosaic fleet agent new <name>');
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
|
||||
if (!existsSync(soulPath)) {
|
||||
console.log('[mosaic] SOUL.md not found. Running setup wizard...');
|
||||
@@ -1035,7 +1050,7 @@ function launchRuntime(
|
||||
): never {
|
||||
checkMosaicHome();
|
||||
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
|
||||
checkSoul();
|
||||
checkSoul(context.fleet);
|
||||
(context.runtimeCheck ?? checkRuntime)(runtime);
|
||||
|
||||
// Pi doesn't need sequential-thinking (has native thinking levels)
|
||||
@@ -1207,8 +1222,9 @@ export function launchFleetRuntime(
|
||||
args: string[],
|
||||
declaredEnv: Readonly<Record<string, string>>,
|
||||
fleet: FleetHarnessContext,
|
||||
dangerous = false,
|
||||
): never {
|
||||
return launchRuntime(runtime, args, false, { fleet, declaredEnv });
|
||||
return launchRuntime(runtime, args, dangerous, { fleet, declaredEnv });
|
||||
}
|
||||
|
||||
/** Bounded production-path test seam; all preflight and composition remain real. */
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { chmodSync, lstatSync, mkdirSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AuthBundleError,
|
||||
bundleNameForEmail,
|
||||
completeEnrollment,
|
||||
listBundles,
|
||||
prepareEnrollment,
|
||||
readBundleIdentity,
|
||||
setDefaultBundle,
|
||||
} from './auth-bundles.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-auth-'));
|
||||
return join(root, '.mosaic');
|
||||
}
|
||||
|
||||
describe('prepareEnrollment', () => {
|
||||
it('creates the bundle directory owner-only and names the environment the login needs', async () => {
|
||||
const home = await userHome();
|
||||
const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com');
|
||||
|
||||
expect(plan.created).toBe(true);
|
||||
expect(plan.hadCredential).toBe(false);
|
||||
expect(plan.bundleDir).toBe(join(home, 'auth', 'claude', 'jason_woltje.com'));
|
||||
expect(plan.credentialPath).toBe(join(plan.bundleDir, '.credentials.json'));
|
||||
// Claude reaches its bundle by CLAUDE_SECURESTORAGE_CONFIG_DIR because rename() replaces
|
||||
// a symlink rather than following it; the login has to write into the bundle directly.
|
||||
expect(plan.env).toEqual({
|
||||
CLAUDE_CONFIG_DIR: plan.bundleDir,
|
||||
CLAUDE_SECURESTORAGE_CONFIG_DIR: plan.bundleDir,
|
||||
});
|
||||
|
||||
for (const path of [home, join(home, 'auth'), join(home, 'auth', 'claude'), plan.bundleDir]) {
|
||||
expect(lstatSync(path).mode & 0o077).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives a harness without a credential-directory variable only its home variable', async () => {
|
||||
const home = await userHome();
|
||||
const plan = prepareEnrollment(home, 'pi', 'jason_woltje.com');
|
||||
|
||||
expect(plan.credentialPath).toBe(join(plan.bundleDir, 'auth.json'));
|
||||
expect(plan.env).toEqual({ PI_CODING_AGENT_DIR: plan.bundleDir });
|
||||
});
|
||||
|
||||
it('refuses to enrol into the primary alias and says what to do instead', async () => {
|
||||
const home = await userHome();
|
||||
// `primary` is a movable pointer, not storage. Enrolling into it would turn the alias
|
||||
// into a real directory and there would no longer be a default to move.
|
||||
expect(() => prepareEnrollment(home, 'claude', 'primary')).toThrow(
|
||||
/movable alias, not a bundle/u,
|
||||
);
|
||||
expect(() => prepareEnrollment(home, 'claude', 'primary')).toThrow(AuthBundleError);
|
||||
});
|
||||
|
||||
it('refuses a bundle name that could escape the auth root', async () => {
|
||||
const home = await userHome();
|
||||
expect(() => prepareEnrollment(home, 'claude', '../elsewhere')).toThrow(/not a safe bundle/u);
|
||||
});
|
||||
|
||||
it('tightens an existing world-readable bundle directory rather than trusting it', async () => {
|
||||
const home = await userHome();
|
||||
const bundleDir = join(home, 'auth', 'claude', 'loose');
|
||||
mkdirSync(bundleDir, { recursive: true });
|
||||
chmodSync(bundleDir, 0o755);
|
||||
|
||||
const plan = prepareEnrollment(home, 'claude', 'loose');
|
||||
|
||||
expect(plan.created).toBe(false);
|
||||
expect(lstatSync(plan.bundleDir).mode & 0o077).toBe(0);
|
||||
});
|
||||
|
||||
it('reports an existing credential so a re-login is not mistaken for a first enrolment', async () => {
|
||||
const home = await userHome();
|
||||
const first = prepareEnrollment(home, 'claude', 'jason_woltje.com');
|
||||
writeFileSync(first.credentialPath, '{}', { mode: 0o600 });
|
||||
|
||||
expect(prepareEnrollment(home, 'claude', 'jason_woltje.com').hadCredential).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeEnrollment', () => {
|
||||
it('fails when the login exited without writing a credential', async () => {
|
||||
const home = await userHome();
|
||||
const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com');
|
||||
|
||||
// The directory exists and looks fine; only the credential proves a login happened. Without
|
||||
// this check the failure surfaces much later, at composition, blaming the missing file
|
||||
// rather than the login that never completed.
|
||||
expect(() => completeEnrollment(plan)).toThrow(/login left no credential/u);
|
||||
try {
|
||||
completeEnrollment(plan);
|
||||
} catch (error: unknown) {
|
||||
expect((error as AuthBundleError).code).toBe('credential-missing');
|
||||
}
|
||||
});
|
||||
|
||||
it('tightens a credential the harness wrote with group or other permissions', async () => {
|
||||
const home = await userHome();
|
||||
const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com');
|
||||
writeFileSync(plan.credentialPath, '{}');
|
||||
chmodSync(plan.credentialPath, 0o644);
|
||||
|
||||
const result = completeEnrollment(plan);
|
||||
|
||||
expect(result.tightened).toBe(true);
|
||||
expect(lstatSync(plan.credentialPath).mode & 0o077).toBe(0);
|
||||
});
|
||||
|
||||
it('records the logged-in account so the bundle can say who it holds', async () => {
|
||||
const home = await userHome();
|
||||
const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com');
|
||||
writeFileSync(plan.credentialPath, '{}', { mode: 0o600 });
|
||||
writeFileSync(
|
||||
join(plan.bundleDir, '.claude.json'),
|
||||
JSON.stringify({ oauthAccount: { emailAddress: '[email protected]' } }),
|
||||
);
|
||||
|
||||
const result = completeEnrollment(plan);
|
||||
|
||||
expect(result.email).toBe('[email protected]');
|
||||
expect(result.identityMismatch).toBeUndefined();
|
||||
const recorded = JSON.parse(
|
||||
await readFile(join(plan.bundleDir, 'account.json'), 'utf8'),
|
||||
) as Record<string, unknown>;
|
||||
expect(recorded['emailAddress']).toBe('[email protected]');
|
||||
expect(lstatSync(join(plan.bundleDir, 'account.json')).mode & 0o077).toBe(0);
|
||||
});
|
||||
|
||||
it('flags a bundle whose name does not match the account that logged into it', async () => {
|
||||
const home = await userHome();
|
||||
// This is the failure the whole two-principal model rests on. If an operator enrolling a
|
||||
// reviewer bundle logs in as the author's account by habit, both seats end up holding one
|
||||
// principal, the review is self-review, and nothing else in the system notices.
|
||||
const plan = prepareEnrollment(home, 'claude', 'reviewer_example.com');
|
||||
writeFileSync(plan.credentialPath, '{}', { mode: 0o600 });
|
||||
writeFileSync(
|
||||
join(plan.bundleDir, '.claude.json'),
|
||||
JSON.stringify({ oauthAccount: { emailAddress: '[email protected]' } }),
|
||||
);
|
||||
|
||||
const result = completeEnrollment(plan);
|
||||
|
||||
expect(result.email).toBe('[email protected]');
|
||||
expect(result.identityMismatch).toBe('author_example.com');
|
||||
});
|
||||
|
||||
it('enrols a harness whose files carry no identity, without inventing one', async () => {
|
||||
const home = await userHome();
|
||||
const plan = prepareEnrollment(home, 'pi', 'someone_example.com');
|
||||
writeFileSync(plan.credentialPath, JSON.stringify({ token: 'x' }), { mode: 0o600 });
|
||||
|
||||
const result = completeEnrollment(plan);
|
||||
|
||||
expect(result.email).toBeUndefined();
|
||||
expect(result.identityMismatch).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bundleNameForEmail', () => {
|
||||
it('maps an account to its bundle name', () => {
|
||||
expect(bundleNameForEmail('[email protected]')).toBe('jason.woltje_uscllc.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readBundleIdentity', () => {
|
||||
it('prefers the recorded account over whatever the harness left lying around', async () => {
|
||||
const home = await userHome();
|
||||
const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com');
|
||||
writeFileSync(join(plan.bundleDir, 'account.json'), JSON.stringify({ emailAddress: '[email protected]' }));
|
||||
writeFileSync(
|
||||
join(plan.bundleDir, '.claude.json'),
|
||||
JSON.stringify({ oauthAccount: { emailAddress: '[email protected]' } }),
|
||||
);
|
||||
|
||||
expect(readBundleIdentity(plan.bundleDir, 'claude')).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('returns nothing rather than guessing when the files are unreadable', async () => {
|
||||
const home = await userHome();
|
||||
const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com');
|
||||
writeFileSync(join(plan.bundleDir, '.claude.json'), 'not json');
|
||||
|
||||
expect(readBundleIdentity(plan.bundleDir, 'claude')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listBundles', () => {
|
||||
it('is empty on a host that has never enrolled anything', async () => {
|
||||
expect(listBundles(await userHome(), 'claude')).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports enrolment state, the alias, and which account each bundle holds', async () => {
|
||||
const home = await userHome();
|
||||
const enrolled = prepareEnrollment(home, 'claude', 'jason_woltje.com');
|
||||
writeFileSync(enrolled.credentialPath, '{}', { mode: 0o600 });
|
||||
writeFileSync(
|
||||
join(enrolled.bundleDir, 'account.json'),
|
||||
JSON.stringify({ emailAddress: '[email protected]' }),
|
||||
);
|
||||
prepareEnrollment(home, 'claude', 'empty_example.com');
|
||||
setDefaultBundle(home, 'claude', 'jason_woltje.com');
|
||||
|
||||
const bundles = listBundles(home, 'claude');
|
||||
|
||||
expect(bundles.map((b) => b.name)).toEqual([
|
||||
'empty_example.com',
|
||||
'jason_woltje.com',
|
||||
'primary',
|
||||
]);
|
||||
expect(bundles.find((b) => b.name === 'jason_woltje.com')).toMatchObject({
|
||||
alias: false,
|
||||
enrolled: true,
|
||||
email: '[email protected]',
|
||||
});
|
||||
expect(bundles.find((b) => b.name === 'empty_example.com')).toMatchObject({
|
||||
alias: false,
|
||||
enrolled: false,
|
||||
});
|
||||
expect(bundles.find((b) => b.name === 'primary')).toMatchObject({
|
||||
alias: true,
|
||||
target: 'jason_woltje.com',
|
||||
enrolled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a dangling alias instead of failing the whole listing', async () => {
|
||||
const home = await userHome();
|
||||
mkdirSync(join(home, 'auth', 'claude'), { recursive: true });
|
||||
symlinkSync('gone', join(home, 'auth', 'claude', 'primary'));
|
||||
|
||||
expect(listBundles(home, 'claude')).toEqual([
|
||||
{
|
||||
name: 'primary',
|
||||
path: join(home, 'auth', 'claude', 'primary'),
|
||||
resolved: join(home, 'auth', 'claude', 'primary'),
|
||||
alias: true,
|
||||
enrolled: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setDefaultBundle', () => {
|
||||
it('retargets an existing alias without writing through into the old bundle', async () => {
|
||||
const home = await userHome();
|
||||
for (const name of ['one_example.com', 'two_example.com']) {
|
||||
const plan = prepareEnrollment(home, 'claude', name);
|
||||
writeFileSync(plan.credentialPath, '{}', { mode: 0o600 });
|
||||
}
|
||||
setDefaultBundle(home, 'claude', 'one_example.com');
|
||||
setDefaultBundle(home, 'claude', 'two_example.com');
|
||||
|
||||
expect(listBundles(home, 'claude').find((b) => b.name === 'primary')?.target).toBe(
|
||||
'two_example.com',
|
||||
);
|
||||
// The bundle it used to point at is untouched, not emptied by the retarget.
|
||||
expect(
|
||||
lstatSync(join(home, 'auth', 'claude', 'one_example.com', '.credentials.json')).isFile(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to point the alias at a bundle that does not exist', async () => {
|
||||
const home = await userHome();
|
||||
mkdirSync(join(home, 'auth', 'claude'), { recursive: true });
|
||||
|
||||
expect(() => setDefaultBundle(home, 'claude', 'missing_example.com')).toThrow(
|
||||
/no such bundle/u,
|
||||
);
|
||||
});
|
||||
|
||||
it('will not delete a real directory that occupies the alias path', async () => {
|
||||
const home = await userHome();
|
||||
prepareEnrollment(home, 'claude', 'real_example.com');
|
||||
mkdirSync(join(home, 'auth', 'claude', 'primary'), { recursive: true });
|
||||
|
||||
// A real `primary` directory means someone enrolled into the alias by hand and their
|
||||
// credentials are inside it. Deleting it to install a symlink would destroy an account.
|
||||
expect(() => setDefaultBundle(home, 'claude', 'real_example.com')).toThrow(
|
||||
/will not be deleted/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Credential bundles under `~/.mosaic/auth/<harness>/<bundle>/`.
|
||||
*
|
||||
* A bundle is one account's credentials for one harness. Seats point at a bundle by name in
|
||||
* their `profile.json`, so two seats can hold genuinely different principals on one host --
|
||||
* which is the whole reason the fleet can run an author seat and a reviewer seat without the
|
||||
* review being self-review wearing two hats.
|
||||
*
|
||||
* Enrolling does not reimplement any harness's login. It creates the bundle directory, points
|
||||
* the harness at it by environment, and runs the harness's own login. What this module owns is
|
||||
* everything around that: that the directory is a real directory nobody can read but its owner,
|
||||
* that the credential actually landed, and that the account you logged in as is the account the
|
||||
* bundle claims to hold.
|
||||
*
|
||||
* Composition-side reader: commands/fleet-launch-command.ts resolveCredential().
|
||||
*/
|
||||
|
||||
import {
|
||||
chmodSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
type Stats,
|
||||
} from 'node:fs';
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import {
|
||||
CREDENTIAL_DIR_ENV,
|
||||
CREDENTIAL_FILE_NAMES,
|
||||
type CredentialHarness,
|
||||
} from './credential-sharing.js';
|
||||
|
||||
/** Mirrors BUNDLE_NAME in commands/fleet-launch-command.ts; drift here is a launch failure. */
|
||||
const BUNDLE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/;
|
||||
|
||||
/**
|
||||
* The movable alias. `"bundle": "primary"` in a profile follows whatever this points at; a
|
||||
* named bundle stays pinned. It is the only symlink launch tolerates in an auth root.
|
||||
*/
|
||||
export const PRIMARY_ALIAS = 'primary';
|
||||
|
||||
/** Where each harness expects its own home, so login writes into the bundle we just made. */
|
||||
const HOME_ENV_NAME: Record<CredentialHarness, string> = {
|
||||
claude: 'CLAUDE_CONFIG_DIR',
|
||||
pi: 'PI_CODING_AGENT_DIR',
|
||||
codex: 'CODEX_HOME',
|
||||
opencode: 'XDG_CONFIG_HOME',
|
||||
};
|
||||
|
||||
/**
|
||||
* Files a harness writes that carry the logged-in account's identity, and the paths within
|
||||
* them to try. Best effort by design: a harness we cannot read an identity from still enrolls,
|
||||
* it just cannot be checked against its bundle name.
|
||||
*/
|
||||
const IDENTITY_SOURCES: Record<CredentialHarness, ReadonlyArray<readonly [string, string[]]>> = {
|
||||
claude: [
|
||||
['.claude.json', ['oauthAccount.emailAddress', 'oauthAccount.email']],
|
||||
['.credentials.json', ['claudeAiOauth.emailAddress']],
|
||||
],
|
||||
pi: [['auth.json', ['account.email', 'email', 'user.email']]],
|
||||
codex: [['auth.json', ['tokens.id_token.email', 'account.email', 'email']]],
|
||||
opencode: [['auth.json', ['account.email', 'email']]],
|
||||
};
|
||||
|
||||
export type AuthBundleErrorCode =
|
||||
| 'invalid-request'
|
||||
| 'bundle-not-found'
|
||||
| 'bundle-exists'
|
||||
| 'credential-missing'
|
||||
| 'unsafe-shape';
|
||||
|
||||
export class AuthBundleError extends Error {
|
||||
readonly code: AuthBundleErrorCode;
|
||||
|
||||
constructor(code: AuthBundleErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = 'AuthBundleError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface BundleInfo {
|
||||
readonly name: string;
|
||||
/** Absolute path of the entry as named, before alias resolution. */
|
||||
readonly path: string;
|
||||
/** Where it actually lives. Differs from `path` only for the primary alias. */
|
||||
readonly resolved: string;
|
||||
/** True when this entry is the movable primary alias rather than a real bundle. */
|
||||
readonly alias: boolean;
|
||||
/** Alias target's bundle name, when this is the alias. */
|
||||
readonly target?: string;
|
||||
/** True when the harness's credential file is present in the resolved bundle. */
|
||||
readonly enrolled: boolean;
|
||||
/** Account identity recorded at enrollment, when one could be determined. */
|
||||
readonly email?: string;
|
||||
}
|
||||
|
||||
export interface EnrollmentPlan {
|
||||
readonly harness: CredentialHarness;
|
||||
readonly bundle: string;
|
||||
readonly bundleDir: string;
|
||||
/** Absolute path the harness must end up writing its credential to. */
|
||||
readonly credentialPath: string;
|
||||
/** True when the directory did not exist before this call. */
|
||||
readonly created: boolean;
|
||||
/** True when a credential was already present -- a re-login, not a first enrollment. */
|
||||
readonly hadCredential: boolean;
|
||||
/**
|
||||
* Environment the harness login must run under. Every value is an absolute path; Claude
|
||||
* reads an empty credential-dir value as ~/.claude, the operator's own account, so an
|
||||
* empty value is never produced here.
|
||||
*/
|
||||
readonly env: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface EnrollmentResult {
|
||||
readonly harness: CredentialHarness;
|
||||
readonly bundle: string;
|
||||
readonly bundleDir: string;
|
||||
readonly credentialPath: string;
|
||||
/** Identity read back out of what the harness wrote, when it could be determined. */
|
||||
readonly email?: string;
|
||||
/**
|
||||
* Set when an identity was found and it does not match the bundle name. Logging into the
|
||||
* wrong account is silent otherwise, and it is the failure that quietly collapses two
|
||||
* principals back into one.
|
||||
*/
|
||||
readonly identityMismatch?: string;
|
||||
/** True when the credential file's permissions had to be tightened to owner-only. */
|
||||
readonly tightened: 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 assertContained(root: string, candidate: string, label: string): void {
|
||||
const rel = relative(resolve(root), resolve(candidate));
|
||||
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
||||
throw new AuthBundleError('unsafe-shape', `${label} resolves outside ${root}: ${candidate}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject a name before it is ever joined onto a path. */
|
||||
export function assertSafeBundleName(bundle: string): void {
|
||||
if (!BUNDLE_NAME.test(bundle)) {
|
||||
throw new AuthBundleError(
|
||||
'invalid-request',
|
||||
`"${bundle}" is not a safe bundle name; use letters, digits, and . _ @ -`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** `~/.mosaic/auth/<harness>`. */
|
||||
export function authRoot(userHome: string, harness: CredentialHarness): string {
|
||||
return join(userHome, 'auth', harness);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the auth root chain with owner-only permissions, refusing anything that is not a
|
||||
* real directory. An explicit mode on mkdir is not enough on its own -- it is masked by the
|
||||
* ambient umask -- so each level is chmod'ed after creation.
|
||||
*/
|
||||
function ensurePrivateDirectory(path: string, label: string): boolean {
|
||||
const info = lstatIfPresent(path);
|
||||
if (info) {
|
||||
if (!info.isDirectory() || info.isSymbolicLink()) {
|
||||
throw new AuthBundleError(
|
||||
'unsafe-shape',
|
||||
`${label} must be a real, non-symlink directory: ${path}`,
|
||||
);
|
||||
}
|
||||
if ((info.mode & 0o077) !== 0) chmodSync(path, 0o700);
|
||||
return false;
|
||||
}
|
||||
mkdirSync(path, { recursive: true, mode: 0o700 });
|
||||
chmodSync(path, 0o700);
|
||||
return true;
|
||||
}
|
||||
|
||||
function readJson(path: string): Record<string, unknown> | undefined {
|
||||
const info = lstatIfPresent(path);
|
||||
if (!info?.isFile() || info.isSymbolicLink()) return undefined;
|
||||
try {
|
||||
const value: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
||||
return value as Record<string, unknown>;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function dig(source: Record<string, unknown>, dotted: string): string | undefined {
|
||||
let cursor: unknown = source;
|
||||
for (const key of dotted.split('.')) {
|
||||
if (typeof cursor !== 'object' || cursor === null || Array.isArray(cursor)) return undefined;
|
||||
cursor = (cursor as Record<string, unknown>)[key];
|
||||
}
|
||||
return typeof cursor === 'string' && cursor.trim() !== '' ? cursor.trim() : undefined;
|
||||
}
|
||||
|
||||
/** Best-effort account identity from whatever the harness wrote into the bundle. */
|
||||
export function readBundleIdentity(
|
||||
bundleDir: string,
|
||||
harness: CredentialHarness,
|
||||
): string | undefined {
|
||||
const recorded = readJson(join(bundleDir, 'account.json'));
|
||||
if (recorded) {
|
||||
for (const path of ['emailAddress', 'email', 'oauthAccount.emailAddress']) {
|
||||
const found = dig(recorded, path);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
for (const [file, paths] of IDENTITY_SOURCES[harness]) {
|
||||
const source = readJson(join(bundleDir, file));
|
||||
if (!source) continue;
|
||||
for (const path of paths) {
|
||||
const found = dig(source, path);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bundle name an email implies. Bundles are named by account identity so that a roster
|
||||
* row's `"bundle"` says who the seat is, not merely which slot it uses.
|
||||
*/
|
||||
export function bundleNameForEmail(email: string): string {
|
||||
return email.trim().toLowerCase().replace(/@/gu, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the bundle directory and describe the environment its login must run under.
|
||||
*
|
||||
* This deliberately stops short of running anything. The caller runs the harness's own login
|
||||
* under `plan.env`, then calls completeEnrollment() to check what landed.
|
||||
*/
|
||||
export function prepareEnrollment(
|
||||
userHome: string,
|
||||
harness: CredentialHarness,
|
||||
bundle: string,
|
||||
): EnrollmentPlan {
|
||||
assertSafeBundleName(bundle);
|
||||
if (bundle === PRIMARY_ALIAS) {
|
||||
throw new AuthBundleError(
|
||||
'invalid-request',
|
||||
`"${PRIMARY_ALIAS}" is a movable alias, not a bundle. Enroll a bundle named for the account (for example: mosaic auth enroll --harness ${harness} --bundle jason_woltje.com), then point the alias at it with: mosaic auth default --harness ${harness} <bundle>`,
|
||||
);
|
||||
}
|
||||
|
||||
ensurePrivateDirectory(userHome, 'user Mosaic root');
|
||||
ensurePrivateDirectory(join(userHome, 'auth'), 'auth directory');
|
||||
const root = authRoot(userHome, harness);
|
||||
ensurePrivateDirectory(root, `${harness} auth root`);
|
||||
|
||||
const bundleDir = join(root, bundle);
|
||||
assertContained(realpathSync(root), resolve(bundleDir), 'credential bundle');
|
||||
const created = ensurePrivateDirectory(bundleDir, 'credential bundle');
|
||||
|
||||
const credentialPath = join(bundleDir, CREDENTIAL_FILE_NAMES[harness]);
|
||||
const credentialDirEnvName = CREDENTIAL_DIR_ENV[harness];
|
||||
return {
|
||||
harness,
|
||||
bundle,
|
||||
bundleDir,
|
||||
credentialPath,
|
||||
created,
|
||||
hadCredential: lstatIfPresent(credentialPath)?.isFile() === true,
|
||||
env: {
|
||||
[HOME_ENV_NAME[harness]]: bundleDir,
|
||||
...(credentialDirEnvName === undefined ? {} : { [credentialDirEnvName]: bundleDir }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check what the harness login actually left behind, tighten it, and record the identity.
|
||||
*
|
||||
* A login that exits zero having written nothing is the failure worth catching here: the seat
|
||||
* would then fail much later, at composition, with a message about a missing credential and no
|
||||
* hint that the login was the thing that did not work.
|
||||
*/
|
||||
export function completeEnrollment(plan: EnrollmentPlan): EnrollmentResult {
|
||||
const info = lstatIfPresent(plan.credentialPath);
|
||||
if (!info?.isFile() || info.isSymbolicLink()) {
|
||||
throw new AuthBundleError(
|
||||
'credential-missing',
|
||||
`login left no credential at ${plan.credentialPath}. The bundle directory exists but is not enrolled; nothing was assigned.`,
|
||||
);
|
||||
}
|
||||
let tightened = false;
|
||||
if ((info.mode & 0o077) !== 0) {
|
||||
chmodSync(plan.credentialPath, 0o600);
|
||||
tightened = true;
|
||||
}
|
||||
|
||||
const email = readBundleIdentity(plan.bundleDir, plan.harness);
|
||||
if (email !== undefined) {
|
||||
writeFileSync(
|
||||
join(plan.bundleDir, 'account.json'),
|
||||
`${JSON.stringify({ emailAddress: email, harness: plan.harness }, null, 2)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
chmodSync(join(plan.bundleDir, 'account.json'), 0o600);
|
||||
}
|
||||
|
||||
const expected = email === undefined ? undefined : bundleNameForEmail(email);
|
||||
return {
|
||||
harness: plan.harness,
|
||||
bundle: plan.bundle,
|
||||
bundleDir: plan.bundleDir,
|
||||
credentialPath: plan.credentialPath,
|
||||
...(email === undefined ? {} : { email }),
|
||||
...(expected === undefined || expected === plan.bundle.toLowerCase()
|
||||
? {}
|
||||
: { identityMismatch: expected }),
|
||||
tightened,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every entry in a harness's auth root, alias included, with enrollment state. */
|
||||
export function listBundles(userHome: string, harness: CredentialHarness): BundleInfo[] {
|
||||
const root = authRoot(userHome, harness);
|
||||
const info = lstatIfPresent(root);
|
||||
if (!info) return [];
|
||||
if (!info.isDirectory() || info.isSymbolicLink()) {
|
||||
throw new AuthBundleError(
|
||||
'unsafe-shape',
|
||||
`${harness} auth root must be a real, non-symlink directory: ${root}`,
|
||||
);
|
||||
}
|
||||
|
||||
const entries: BundleInfo[] = [];
|
||||
for (const entry of readdirSync(root, { withFileTypes: true }).sort((a, b) =>
|
||||
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
|
||||
)) {
|
||||
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
||||
const path = join(root, entry.name);
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = realpathSync(path);
|
||||
} catch {
|
||||
// A dangling alias is real state worth showing rather than a reason to fail the listing.
|
||||
entries.push({ name: entry.name, path, resolved: path, alias: true, enrolled: false });
|
||||
continue;
|
||||
}
|
||||
const alias = entry.isSymbolicLink();
|
||||
const credential = join(resolved, CREDENTIAL_FILE_NAMES[harness]);
|
||||
const email = readBundleIdentity(resolved, harness);
|
||||
entries.push({
|
||||
name: entry.name,
|
||||
path,
|
||||
resolved,
|
||||
alias,
|
||||
...(alias ? { target: resolved.slice(resolved.lastIndexOf(sep) + 1) } : {}),
|
||||
enrolled: lstatIfPresent(credential)?.isFile() === true,
|
||||
...(email === undefined ? {} : { email }),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the movable `primary` alias at a real bundle.
|
||||
*
|
||||
* Relative so the whole `~/.mosaic` tree stays relocatable, and replaced rather than followed
|
||||
* so retargeting never writes through into the old bundle.
|
||||
*/
|
||||
export function setDefaultBundle(
|
||||
userHome: string,
|
||||
harness: CredentialHarness,
|
||||
bundle: string,
|
||||
): string {
|
||||
assertSafeBundleName(bundle);
|
||||
if (bundle === PRIMARY_ALIAS) {
|
||||
throw new AuthBundleError('invalid-request', `the ${PRIMARY_ALIAS} alias cannot target itself`);
|
||||
}
|
||||
const root = authRoot(userHome, harness);
|
||||
const target = join(root, bundle);
|
||||
const info = lstatIfPresent(target);
|
||||
if (!info) {
|
||||
throw new AuthBundleError(
|
||||
'bundle-not-found',
|
||||
`no such bundle: ${target} — enroll it first: mosaic auth enroll --harness ${harness} --bundle ${bundle}`,
|
||||
);
|
||||
}
|
||||
if (!info.isDirectory() || info.isSymbolicLink()) {
|
||||
throw new AuthBundleError(
|
||||
'unsafe-shape',
|
||||
`the ${PRIMARY_ALIAS} alias may only target a real bundle directory: ${target}`,
|
||||
);
|
||||
}
|
||||
|
||||
const alias = join(root, PRIMARY_ALIAS);
|
||||
const existing = lstatIfPresent(alias);
|
||||
if (existing && !existing.isSymbolicLink()) {
|
||||
throw new AuthBundleError(
|
||||
'unsafe-shape',
|
||||
`a real directory occupies the ${PRIMARY_ALIAS} alias path and will not be deleted: ${alias}. Move it aside, or enroll under its own name.`,
|
||||
);
|
||||
}
|
||||
if (existing) rmSync(alias);
|
||||
symlinkSync(bundle, alias);
|
||||
return alias;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* How each harness reaches the credential stored in its auth bundle.
|
||||
*
|
||||
* Scaffolding and launch both act on this, so it lives in one module: a seat whose
|
||||
* scaffold planted a credential symlink that launch never maintains (or the reverse)
|
||||
* fails in a way that only shows up at the first token refresh.
|
||||
*/
|
||||
|
||||
/** Mirrors RuntimeName in commands/launch.ts; assignability is asserted there. */
|
||||
export type CredentialHarness = 'claude' | 'codex' | 'opencode' | 'pi';
|
||||
|
||||
/** Credential file each harness reads, relative to its credential directory. */
|
||||
export const CREDENTIAL_FILE_NAMES: Record<CredentialHarness, string> = {
|
||||
claude: '.credentials.json',
|
||||
pi: 'auth.json',
|
||||
codex: 'auth.json',
|
||||
opencode: 'auth.json',
|
||||
};
|
||||
|
||||
/**
|
||||
* Harnesses that can be pointed at a shared credential directory by environment,
|
||||
* and the variable that does it.
|
||||
*
|
||||
* Claude Code saves credentials by writing a sibling temp file and rename()-ing it
|
||||
* over the target. rename() replaces a symlink rather than following it, so a managed
|
||||
* link at the seat's credential path is destroyed by the first token refresh and the
|
||||
* seat silently forks its credentials. CLAUDE_SECURESTORAGE_CONFIG_DIR resolves the
|
||||
* credential directory independently of CLAUDE_CONFIG_DIR, which keeps both the temp
|
||||
* file and the rename inside the bundle where they belong. Evidence:
|
||||
* docs/reports/harness/claude-credential-write-path-2026-08-14.md (jarvis-brain).
|
||||
*
|
||||
* The value is always an absolute bundle path. Claude reads an empty value as
|
||||
* ~/.claude — the operator's own account — so an empty value must never be exported.
|
||||
*
|
||||
* Harnesses absent from this map keep the managed-link mechanism.
|
||||
*/
|
||||
export const CREDENTIAL_DIR_ENV: Partial<Record<CredentialHarness, string>> = {
|
||||
claude: 'CLAUDE_SECURESTORAGE_CONFIG_DIR',
|
||||
};
|
||||
|
||||
/** True when the harness reaches its bundle by environment instead of a seat-local link. */
|
||||
export function sharesCredentialDirByEnv(harness: CredentialHarness): boolean {
|
||||
return CREDENTIAL_DIR_ENV[harness] !== undefined;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { lstat, mkdir, readFile, readdir, readlink, symlink, writeFile } from 'n
|
||||
import { homedir } from 'node:os';
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
|
||||
import { CREDENTIAL_FILE_NAMES, sharesCredentialDirByEnv } from './credential-sharing.js';
|
||||
|
||||
export type FleetAgentHarness = 'claude' | 'pi';
|
||||
|
||||
export interface FleetAgentScaffoldOptions {
|
||||
@@ -54,7 +56,7 @@ export async function scaffoldFleetAgent(
|
||||
const mosaicHome = resolve(options.mosaicHome ?? join(homedir(), '.config', 'mosaic'));
|
||||
const agentDir = join(dataHome, 'fleet', 'agents', name);
|
||||
const homeName = harness === 'claude' ? '.claude' : '.pi';
|
||||
const credentialName = harness === 'claude' ? '.credentials.json' : 'auth.json';
|
||||
const credentialName = CREDENTIAL_FILE_NAMES[harness];
|
||||
const credentialTarget = join(dataHome, 'auth', harness, bundle, credentialName);
|
||||
const profile: Record<string, unknown> = {
|
||||
schema: 1,
|
||||
@@ -65,6 +67,7 @@ export async function scaffoldFleetAgent(
|
||||
env: { MOSAIC_AGENT_NAME: name },
|
||||
};
|
||||
const credentialLink = join(agentDir, homeName, credentialName);
|
||||
const sharesByEnv = sharesCredentialDirByEnv(harness);
|
||||
const entries: [string, ExpectedFile][] = [
|
||||
['profile.json', { type: 'file', content: json(profile) }],
|
||||
['SOUL.md', { type: 'file', content: soul(name) }],
|
||||
@@ -73,10 +76,18 @@ export async function scaffoldFleetAgent(
|
||||
join(homeName, harness === 'claude' ? 'CLAUDE.md' : 'AGENTS.md'),
|
||||
{ type: 'file', content: identityBootstrap(name) },
|
||||
],
|
||||
[join(homeName, credentialName), { type: 'symlink', target: credentialTarget }],
|
||||
...(sharesByEnv
|
||||
? []
|
||||
: ([[join(homeName, credentialName), { type: 'symlink', target: credentialTarget }]] as [
|
||||
string,
|
||||
ExpectedFile,
|
||||
][])),
|
||||
[
|
||||
join(homeName, '.mosaic-managed-links.json'),
|
||||
{ type: 'file', content: json({ links: { [credentialLink]: credentialTarget } }) },
|
||||
{
|
||||
type: 'file',
|
||||
content: json({ links: sharesByEnv ? {} : { [credentialLink]: credentialTarget } }),
|
||||
},
|
||||
],
|
||||
];
|
||||
if (harness === 'claude') {
|
||||
@@ -87,7 +98,19 @@ export async function scaffoldFleetAgent(
|
||||
}
|
||||
const files = new Map<string, ExpectedFile>(entries);
|
||||
|
||||
const differences = await findDifferences(agentDir, files);
|
||||
// Seats scaffolded before the harness moved to an environment-shared credential
|
||||
// directory still hold a credential symlink and name it in their manifest. The link
|
||||
// is inert once the harness resolves its credential directory from the environment,
|
||||
// so it is tolerated rather than reported as a foreign file or silently rewritten.
|
||||
const legacyCredentialShape = sharesByEnv
|
||||
? {
|
||||
path: join(homeName, credentialName),
|
||||
manifestPath: join(homeName, '.mosaic-managed-links.json'),
|
||||
manifestContent: json({ links: { [credentialLink]: credentialTarget } }),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const differences = await findDifferences(agentDir, files, legacyCredentialShape);
|
||||
if (differences.length > 0) {
|
||||
throw new FleetAgentScaffoldError(
|
||||
'agent-exists-different',
|
||||
@@ -123,9 +146,18 @@ type ExpectedFile =
|
||||
| { readonly type: 'file'; readonly content: string }
|
||||
| { readonly type: 'symlink'; readonly target: string };
|
||||
|
||||
interface LegacyCredentialShape {
|
||||
/** Seat-relative path of the now-unused credential symlink. */
|
||||
readonly path: string;
|
||||
readonly manifestPath: string;
|
||||
/** Manifest content written when that link was still maintained. */
|
||||
readonly manifestContent: string;
|
||||
}
|
||||
|
||||
async function findDifferences(
|
||||
agentDir: string,
|
||||
expected: ReadonlyMap<string, ExpectedFile>,
|
||||
legacy?: LegacyCredentialShape,
|
||||
): Promise<string[]> {
|
||||
let root;
|
||||
try {
|
||||
@@ -148,6 +180,7 @@ async function findDifferences(
|
||||
]);
|
||||
const differences: string[] = [];
|
||||
for (const path of [...paths].sort()) {
|
||||
if (legacy && path === legacy.path) continue;
|
||||
const required = expected.get(path);
|
||||
if (!required) {
|
||||
differences.push(path);
|
||||
@@ -156,10 +189,16 @@ async function findDifferences(
|
||||
try {
|
||||
const info = await lstat(join(agentDir, path));
|
||||
if (required.type === 'file') {
|
||||
const content = info.isFile() ? await readFile(join(agentDir, path), 'utf8') : undefined;
|
||||
const acceptable =
|
||||
legacy && path === legacy.manifestPath
|
||||
? [required.content, legacy.manifestContent]
|
||||
: [required.content];
|
||||
if (
|
||||
!info.isFile() ||
|
||||
info.isSymbolicLink() ||
|
||||
(await readFile(join(agentDir, path), 'utf8')) !== required.content
|
||||
content === undefined ||
|
||||
!acceptable.includes(content)
|
||||
) {
|
||||
differences.push(path);
|
||||
}
|
||||
@@ -253,21 +292,24 @@ function onboardingState(mosaicHome: string): Record<string, unknown> {
|
||||
`canonical Claude settings are unavailable or invalid at ${settingsPath}: ${detail}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof authored !== 'object' ||
|
||||
authored === null ||
|
||||
Array.isArray(authored) ||
|
||||
!('mcpServers' in authored) ||
|
||||
typeof authored.mcpServers !== 'object' ||
|
||||
authored.mcpServers === null ||
|
||||
Array.isArray(authored.mcpServers)
|
||||
) {
|
||||
if (typeof authored !== 'object' || authored === null || Array.isArray(authored)) {
|
||||
throw new FleetAgentScaffoldError(
|
||||
'invalid-request',
|
||||
`canonical Claude settings lack an mcpServers object: ${settingsPath}`,
|
||||
`canonical Claude settings must be a JSON object: ${settingsPath}`,
|
||||
);
|
||||
}
|
||||
return { hasCompletedOnboarding: true, theme: 'dark', mcpServers: authored.mcpServers };
|
||||
// The shipped settings.json has no mcpServers key at all, so demanding one refused to
|
||||
// scaffold any Claude seat on a clean install. Absent and empty mean the same thing here:
|
||||
// no MCP servers. A present-but-wrong-typed key is still an error -- that is a real
|
||||
// mistake in the file rather than a section the author had nothing to put in.
|
||||
const servers = 'mcpServers' in authored ? authored.mcpServers : {};
|
||||
if (typeof servers !== 'object' || servers === null || Array.isArray(servers)) {
|
||||
throw new FleetAgentScaffoldError(
|
||||
'invalid-request',
|
||||
`canonical Claude settings have a non-object mcpServers: ${settingsPath}`,
|
||||
);
|
||||
}
|
||||
return { hasCompletedOnboarding: true, theme: 'dark', mcpServers: servers };
|
||||
}
|
||||
|
||||
function soul(name: string): string {
|
||||
|
||||
Reference in New Issue
Block a user