diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index f38e64a7..46e3b570 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -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 ────────────────────────────────────────────────────────── diff --git a/packages/mosaic/src/commands/auth.ts b/packages/mosaic/src/commands/auth.ts index 6d190b7f..040ad797 100644 --- a/packages/mosaic/src/commands/auth.ts +++ b/packages/mosaic/src/commands/auth.ts @@ -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; } diff --git a/packages/mosaic/src/commands/fleet-auth-command.spec.ts b/packages/mosaic/src/commands/fleet-auth-command.spec.ts new file mode 100644 index 00000000..141be03d --- /dev/null +++ b/packages/mosaic/src/commands/fleet-auth-command.spec.ts @@ -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 => { + 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 }>; + run: (argv: string[]) => Promise; +} + +async function harness( + overrides: Omit = {}, +): Promise { + 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 => { + 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 { + 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 = { 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('jason@woltje.com') }); + 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: jason@woltje.com'); + const recorded = JSON.parse(await readFile(join(bundleDir, 'account.json'), 'utf8')) as Record< + string, + unknown + >; + expect(recorded['emailAddress']).toBe('jason@woltje.com'); + }); + + 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('author@example.com') }); + await h.run(['enroll', '--harness', 'claude', '--bundle', 'reviewer_example.com']); + + expect(process.exitCode).toBe(1); + expect(h.err.join('')).toContain('author@example.com'); + 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('jason@woltje.com', 130) }); + await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']); + + expect(process.exitCode).toBeUndefined(); + expect(h.out.join('\n')).toContain('account: jason@woltje.com'); + }); + + 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; + 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('reviewer@example.com') }); + 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; + 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('jason@woltje.com') }); + 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('jason@woltje.com'); + }); +}); + +describe('mosaic auth default', () => { + it('moves the primary alias to a bundle', async () => { + const h = await harness({ runLogin: goodLogin('jason@woltje.com') }); + 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'); + }); +}); diff --git a/packages/mosaic/src/commands/fleet-auth-command.ts b/packages/mosaic/src/commands/fleet-auth-command.ts new file mode 100644 index 00000000..1bada992 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-auth-command.ts @@ -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>, + ) => 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>, +): 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; + 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: ${HARNESSES.join(', ')}`) + .requiredOption('--bundle ', 'Bundle name, normally the account email with @ as _') + .option('--login-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 --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 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(); + 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 ', `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 --bundle `, + ); + } + } catch (error: unknown) { + fail(error, 'list'); + } + }); + + authCommand + .command('default ') + .description(`Point the movable "${PRIMARY_ALIAS}" alias at a bundle`) + .requiredOption('--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'); + } + }); +} diff --git a/packages/mosaic/src/fleet/auth-bundles.spec.ts b/packages/mosaic/src/fleet/auth-bundles.spec.ts new file mode 100644 index 00000000..d3919a31 --- /dev/null +++ b/packages/mosaic/src/fleet/auth-bundles.spec.ts @@ -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 => { + if (root) await rm(root, { recursive: true, force: true }); + root = undefined; +}); + +async function userHome(): Promise { + 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: 'jason@woltje.com' } }), + ); + + const result = completeEnrollment(plan); + + expect(result.email).toBe('jason@woltje.com'); + expect(result.identityMismatch).toBeUndefined(); + const recorded = JSON.parse( + await readFile(join(plan.bundleDir, 'account.json'), 'utf8'), + ) as Record; + expect(recorded['emailAddress']).toBe('jason@woltje.com'); + 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: 'author@example.com' } }), + ); + + const result = completeEnrollment(plan); + + expect(result.email).toBe('author@example.com'); + 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('Jason.Woltje@uscllc.com')).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: 'a@b.c' })); + writeFileSync( + join(plan.bundleDir, '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: 'stale@old.example' } }), + ); + + expect(readBundleIdentity(plan.bundleDir, 'claude')).toBe('a@b.c'); + }); + + 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: 'jason@woltje.com' }), + ); + 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: 'jason@woltje.com', + }); + 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, + ); + }); +}); diff --git a/packages/mosaic/src/fleet/auth-bundles.ts b/packages/mosaic/src/fleet/auth-bundles.ts new file mode 100644 index 00000000..6b7286a5 --- /dev/null +++ b/packages/mosaic/src/fleet/auth-bundles.ts @@ -0,0 +1,415 @@ +/** + * Credential bundles under `~/.mosaic/auth///`. + * + * 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 = { + 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> = { + 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>; +} + +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/`. */ +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 | 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; + } catch { + return undefined; + } +} + +function dig(source: Record, 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)[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} `, + ); + } + + 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; +}