Files
stack/packages/mosaic/src/fleet/auth-bundles.ts
T
terraandClaude Opus 5 478e925041 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
2026-08-14 19:23:19 -05:00

416 lines
15 KiB
TypeScript

/**
* 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;
}