fleet: give one host several accounts per harness, and peg each seat to one
`mosaic auth enroll | assign | list | default` (W-F5). Until now a host had one account per harness, so an author seat and a reviewer seat were the same principal wearing two names, and a review carried out under that arrangement is self-review. Bundles under ~/.mosaic/auth/<harness>/<bundle>/ are what a seat's profile.json points at, so two seats on one host can hold genuinely different accounts. Enroll does not reimplement any harness's login. It creates the bundle directory owner-only, points the harness's own home at it by environment, runs the harness, and then checks what landed: credential present, permissions tightened, and the account recorded. Claude is reached through CLAUDE_SECURESTORAGE_CONFIG_DIR rather than a symlink because it writes by rename(2), which replaces a symlink instead of following it. --no-login prints the environment for an operator who would rather run the login themselves. The check worth naming is identity: enroll reads the account back out of what the harness wrote and refuses quietly to accept a bundle named for one account that holds another. That mistake is otherwise silent -- an operator enrolling the reviewer bundle logs in out of habit as the author, both seats collapse to one principal, and nothing else in the system notices. Assign re-parses a seat's profile before rewriting its bundle, so an already broken profile is reported here rather than re-serialized into something that looks repaired and still fails at launch. An unenrolled bundle is assigned but said out loud, because the seat will refuse to launch until the account exists. registerAuthCommand now returns its Command so these local verbs can hang off it. They never talk to the gateway and work on a host where it is down. 41 tests. Each of the load-bearing checks was mutation-tested: nine mutations, each killing exactly the one test that covers it. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1
This commit is contained in:
@@ -25,6 +25,7 @@ import { registerLaunchCommands } from './commands/launch.js';
|
|||||||
import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js';
|
import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js';
|
||||||
import { registerInstallOrderingGuardCommand } from './commands/install-ordering-guard.js';
|
import { registerInstallOrderingGuardCommand } from './commands/install-ordering-guard.js';
|
||||||
import { registerAuthCommand } from './commands/auth.js';
|
import { registerAuthCommand } from './commands/auth.js';
|
||||||
|
import { registerFleetAuthCommands } from './commands/fleet-auth-command.js';
|
||||||
import { registerFederationCommand } from './commands/federation.js';
|
import { registerFederationCommand } from './commands/federation.js';
|
||||||
import { registerGatewayCommand } from './commands/gateway.js';
|
import { registerGatewayCommand } from './commands/gateway.js';
|
||||||
import {
|
import {
|
||||||
@@ -350,7 +351,7 @@ sessionsCmd
|
|||||||
|
|
||||||
// ─── auth ────────────────────────────────────────────────────────────────
|
// ─── auth ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerAuthCommand(program);
|
registerFleetAuthCommands(registerAuthCommand(program));
|
||||||
|
|
||||||
// ─── gateway ──────────────────────────────────────────────────────────
|
// ─── gateway ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -139,10 +139,11 @@ function printUser(u: UserDto): void {
|
|||||||
* Keeping packages/auth as a pure server-side library avoids adding commander
|
* Keeping packages/auth as a pure server-side library avoids adding commander
|
||||||
* and CLI tooling as dependencies there.
|
* 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
|
const auth = parent
|
||||||
.command('auth')
|
.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 })
|
.configureHelp({ sortSubcommands: true })
|
||||||
.action(() => {
|
.action(() => {
|
||||||
auth.outputHelp();
|
auth.outputHelp();
|
||||||
@@ -328,4 +329,6 @@ export function registerAuthCommand(parent: Command): void {
|
|||||||
);
|
);
|
||||||
void opts;
|
void opts;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return auth;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user