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