import type { UserRole } from 'officerdb'; import { updateUser } from 'officerdb'; import { ensureOsUser, hardenOwnerHome, osUserHome } from '@@/os-user'; import { provisionSshAccess } from '@@/os-user-ssh'; import { seedShellConfig } from '@@/os-user-shell'; import { provisionClaudeCli } from '@@/os-user-claude'; import { provisionPostgresRole, rolePermitsDevTools } from '@@/os-user-postgres'; import { provisionRootlessDocker } from '@@/os-user-docker'; import { provisionUserDirs } from '@@/data-path'; // Giving an account its Linux side: the directory skeleton, the Linux user, the confinement, the keys. // // Shared by account creation and the retry route, because they are the same work and there are two moments // it has to happen at — the same reasoning as app-store/members.ts. The list of reasons a retry is needed is // not exotic: // // - the host was not set up for it when the account was made (no sudoers entry for the service user) // - an ancestor directory was not traversable, which is the one everybody hits once // - the owner wants to replace the inbound SSH key // // Before this existed the answer to all three was "delete the account and make it again", which loses the // password, the dashboards and anything else keyed to the row — for what is really a retryable side effect. export type OsProvisionOutcome = { osUser: string | null; sshPublicKey: string | null; /** Null on success. Everything here is non-fatal — the platform account works regardless. */ error: string | null; }; /** * Idempotent. Every step underneath adopts what already exists: `useradd` is skipped for an account whose * home already matches, the confinement re-applies mode bits, and an existing `id_ed25519` is kept rather * than rotated (it has been added to Gitea by then). * * Never throws. A failure here must not undo or block a platform account that otherwise works, so the error * is returned and the row keeps `osUser: null` — which is exactly what an account created before this * feature looks like, and which every consumer already handles. */ export async function provisionOsAccount(params: { userId: number; email: string; username: string; /** Decides whether they get a Postgres role. See `rolePermitsDevTools`. */ role: UserRole; /** Inbound SSH key for `authorized_keys`. Already validated by the caller. */ inboundKey?: string | null; }): Promise { // First, because a missing skeleton is the reason `useradd --home-dir … -M` would have nothing to point at. try { provisionUserDirs(params.email); } catch (ex) { return { osUser: null, sshPublicKey: null, error: `could not provision data directories: ${ex instanceof Error ? ex.message : String(ex)}`, }; } const account = await ensureOsUser({ email: params.email, username: params.username }); if (!account.ok) return { osUser: null, sshPublicKey: null, error: account.error }; // The owner's own dotfiles, closed the moment somebody else can open a shell. Here rather than in setup // because ~/.claude and ~/.pm2 are created by USE, not by install — a chmod at install time finds them // missing and silently does nothing. Deliberately after `ensureOsUser`: before it there is no account to // hide anything from, and the log line is more useful when it names one. const hardened = await hardenOwnerHome(); if (hardened.closed.length > 0) { console.log(`[provision] closed the owner's ${hardened.closed.join(', ')} to ${account.osUser}`); } if (!hardened.ok) console.warn(`[provision] could not close the owner's home: ${hardened.error}`); // SSH after the account, because everything it writes lives inside a home that is not ours until // `ensureOsUser` has chowned it away. const ssh = await provisionSshAccess({ email: params.email, osUser: account.osUser, uid: account.uid, gid: account.gid, authorizedKey: params.inboundKey, }); // The shell configuration. Late because its failure leaves nothing broken — the account works, the keys // work, the terminal opens; it just opens with zsh's bare defaults. const shell = await seedShellConfig({ email: params.email, uid: account.uid, gid: account.gid }); // Their own `claude`, in their own home. After the shell because it installs into a home that is not ours // until `ensureOsUser` has chowned it away, and because the installer wants a working HOME. // // Only the binary. Logging in is the member's own act against their own Anthropic account — the platform // cannot do it for them and must not try, because the alternative is lending them the owner's credential. const claude = await provisionClaudeCli({ email: params.email, osUser: account.osUser }); // A Postgres login role of the same name, with CREATEDB — for a Developer, and nobody else. What // replaced rootless Docker for the "let me run a database to develop against" case, and it inherits // that feature's role gate along with its purpose: `rolePermitsDevTools` is the one place that rule is // written down. // // Also the step that shuts PUBLIC out of the platform's own database — deliberately inside the function // that creates the role rather than in the setup script, so it cannot be skipped by an install that was // set up before this existed. See os-user-postgres.ts. // // Null, not a skipped-but-ok result: "there is no database role because of who they are" and "the // database role worked" are different answers and the caller reports them differently. const postgres = rolePermitsDevTools(params.role) ? await provisionPostgresRole({ email: params.email, osUser: account.osUser, uid: account.uid, gid: account.gid, }) : null; // Their own rootless Docker daemon — for a Developer, and nobody else. Last, and the most tolerant of // failure: a host without the uidmap package or a kernel that will not do rootless still gets a perfectly // good account, minus containers. // // Behind the role gate rather than unconditional. The cost is real and it is per member, not per install: // one daemon, one image cache and one subuid range each — see os-user-docker.ts. That is worth paying for // someone whose job is to build things here, and not for an account that just reads its dashboards. const docker = rolePermitsDevTools(params.role) ? await provisionRootlessDocker({ osUser: account.osUser, uid: account.uid, gid: account.gid, home: osUserHome(params.email), }) : null; // The Linux account is recorded either way: it exists, it is confined, and a member's terminal can run as // it. Only the keys are missing, and that is what the error says. const sshPublicKey = ssh.ok ? ssh.publicKey : null; await updateUser(params.userId, { osUser: account.osUser, osSshPublicKey: sshPublicKey }); // Reported in order of consequence, not in order of execution: no keys matters more than a plain prompt, // which matters more than no containers. Only one is surfaced because the UI shows one line — the rest are // in the log. for (const step of [claude, shell, postgres, docker]) { if (step && !step.ok) console.warn(`[users] ${params.email}: ${step.error}`); } const error = !ssh.ok ? ssh.error : !claude.ok ? claude.error : !shell.ok ? shell.error : postgres && !postgres.ok ? postgres.error : docker && !docker.ok ? docker.error : null; return { osUser: account.osUser, sshPublicKey, error }; }