import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { getUserById } from 'officerdb'; import { osUserHome, runAs } from '@@/os-user'; import { locateBinary } from '@@/plugins/os-deps'; // Reading and writing `tea`'s own config, per user. // // ── Why the token is not in the database ── // // It used to be, in `service_connections`. Nothing needs it there any more: the UI is an iframe carrying // the browser's own Gitea session, so the only consumer of a stored token was the health check. Meanwhile // `tea` — which is what the agent actually uses, and what the plugin's affordance tells it to use — keeps // its own copy in `~/.config/tea/config.yml`. // // Two copies of one secret is a drift waiting to happen: run `tea login` to rotate, and the database // quietly keeps the dead one. So the token lives once, where it is used. `service_connections` keeps the // URL, which is the OWNER'S and genuinely shared — one instance per server. // // ── Why this needs to escalate ── // // Each member has their own Linux user and their own home at 700, and their agent runs as them. The // sidecar runs as the OWNER, so it cannot read or write a member's config directly — the kernel refuses, // which is the point of the arrangement. // // `runAs` is the same escalation that provisions their home. Worth being honest that this is not a // security boundary against the platform: anything that can `runAs` can read the file anyway. What it // buys is that a member's token is never handled by another member, and never sits in a shared table. /** Where the platform's own login is recorded, so it never fights whatever the owner set up by hand. */ export const LOGIN_NAME = 'officer'; export type TeaLogin = { name: string; url: string; user: string | null; hasToken: boolean }; /** The owner IS this process, so no escalation — and no sudo prompt on a machine that has no sudo. */ const isOwnerUser = (osUser: string | null): boolean => !osUser || osUser === process.env.USER; /** * `tea`, by absolute path. * * NOT by bare name. PM2 hands this sidecar a login-less PATH that does not include `~/.local/bin`, which * is precisely where the platform installs a release binary — so `tea whoami` failed with "not * installed" on a machine where `tea` was sitting in the owner's home and working perfectly from a * shell. The same trap caught the agent's PATH an hour earlier. * * Resolved once per call rather than cached: an install can add it while this process is running, and a * cached "missing" would outlive the thing that fixed it. * * `locateBinary` is the PLATFORM's resolver, not a copy of it. This used to hardcode * `~/.local/bin:$PATH`, which was right for tea and wrong as a habit — iris wrote the same function with * `~/.cargo/bin` added, the two disagreed, and a rustup-only machine was refused a plugin it could build. * The shared one asks the owner's login shell, so it knows about every directory a per-user installer * added without anybody listing them. */ const teaBinary = (): string => locateBinary('tea') ?? 'tea'; const configPathFor = (home: string): string => join(home, '.config', 'tea', 'config.yml'); /** * Run something as the account, or directly when it is ours. * * Returns null rather than throwing on failure: every caller here treats "could not read it" and "there * is nothing there" the same way, because to the person looking at the settings page they are the same. */ async function asUser(osUser: string | null, command: string[]): Promise { try { const proc = isOwnerUser(osUser) ? Bun.spawn(command, { stdout: 'pipe', stderr: 'ignore' }) : runAs(osUser!, command); // runAs already pipes both const out = await new Response(proc.stdout as ReadableStream).text(); return (await proc.exited) === 0 ? out : null; } catch { return null; } } /** That user's OS account, or null when they have never been provisioned one. */ export async function osUserFor(userId: number): Promise { const user = await getUserById(userId); return user?.osUser ?? null; } /** * What `tea` is already configured with for this instance, if anything. * * Parsed rather than shelled out to, because `tea logins list` renders a table meant for a person and * changes shape between versions. The file is YAML the platform already reads elsewhere. * * The TOKEN ITSELF IS NOT RETURNED. Callers want to know whether one exists and who it belongs to; the * value never needs to leave this file, and returning it would put it in a response body. */ /** * A member's home is `DATA_PATH//home`, NOT `/home/`. * * This said `/home/` and therefore looked in a directory that does not exist on any Officer * install — so a member with a working `tea` login was told they had none. `osUserHome` is the * platform's own answer and the same one `provisionUserDirs` and `useradd` were given, so `tea` run as * that account finds its config in exactly the place this now reads. */ async function rawConfig(userId: number): Promise { const user = await getUserById(userId); const osUser = user?.osUser ?? null; if (isOwnerUser(osUser)) { try { return readFileSync(configPathFor(homedir()), 'utf8'); } catch { return null; } } if (!user?.email) return null; return asUser(osUser, ['cat', configPathFor(osUserHome(user.email))]); } export async function readTeaLogin(userId: number, instanceUrl: string): Promise { const raw = await rawConfig(userId); if (!raw) return null; // One block per login. Matched loosely on purpose — the file is hand-editable and its key order is not // guaranteed. const wanted = instanceUrl.replace(/\/$/, ''); for (const block of raw.split(/^\s*-\s+name:/m).slice(1)) { const name = block.split('\n')[0]?.trim() ?? ''; const url = block.match(/^\s*url:\s*(\S+)/m)?.[1]?.replace(/\/$/, '') ?? ''; if (url !== wanted) continue; return { name, url, user: block.match(/^\s*user:\s*(\S+)/m)?.[1] ?? null, hasToken: /^\s*token:\s*\S+/m.test(block), }; } return null; } /** * Whatever login `tea` already has, when the platform does not yet know the instance. * * A machine where somebody ran `tea login` by hand already knows the URL and holds a working token. Not * offering that back means asking them to retype something the box in front of them can see. * * Returns the FIRST login, which for a config with one is the only one, and for a config with several is * the one `tea` itself defaults to — the same answer the agent would get. */ export async function readAnyTeaLogin(userId: number): Promise { const raw = await rawConfig(userId); if (!raw) return null; const block = raw.split(/^\s*-\s+name:/m)[1]; if (!block) return null; const url = block.match(/^\s*url:\s*(\S+)/m)?.[1]?.replace(/\/$/, '') ?? ''; if (!url) return null; return { name: block.split('\n')[0]?.trim() ?? '', url, user: block.match(/^\s*user:\s*(\S+)/m)?.[1] ?? null, hasToken: /^\s*token:\s*\S+/m.test(block), }; } /** * Write a login for this instance, as that user. * * `tea login add` rather than editing the YAML: it is the tool's own command, it validates the token * against the instance before writing, and it will keep working when the file format changes. * * `--name officer` so the platform never overwrites a login the owner set up by hand — on the machine * this was built on, that would have replaced a working admin login with a narrower one. * * The token goes in ARGV, which is visible in `ps` for as long as the command runs. `tea` offers no stdin * form; the alternative is writing the YAML by hand, which trades a visible argument for a fragile * format. Recorded rather than hidden. */ export async function writeTeaLogin( userId: number, instanceUrl: string, token: string, ): Promise<{ ok: boolean; error?: string }> { const osUser = await osUserFor(userId); const command = [teaBinary(), 'login', 'add', '--name', LOGIN_NAME, '--url', instanceUrl, '--token', token]; try { const proc = isOwnerUser(osUser) ? Bun.spawn(command, { stdout: 'pipe', stderr: 'pipe' }) : runAs(osUser!, command); const [out, err] = await Promise.all([ new Response(proc.stdout as ReadableStream).text(), new Response(proc.stderr as ReadableStream).text(), ]); if ((await proc.exited) !== 0) { // `tea` puts its refusal on stdout as often as stderr, and the useful half is usually the last line. const message = (err || out).trim().split('\n').slice(-1)[0] ?? 'tea login failed'; return { ok: false, error: message }; } return { ok: true }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } } /** * Prove the stored token works, by asking `tea` to use it. * * `tea whoami` is an authenticated call — it prints the account the token belongs to. Running it AS the * user means the platform never reads the token to test it: the one copy stays in their config, and the * thing being tested is exactly the thing the agent will use, not a database row that might disagree * with it. * * `tea whoami` takes no `--login`, so it answers for whichever login is default. That is the honest * answer anyway: it is what the agent gets when it runs `tea` in that home. */ export async function testTeaLogin(userId: number): Promise<{ ok: boolean; user?: string; error?: string }> { const osUser = await osUserFor(userId); const out = await asUser(osUser, [teaBinary(), 'whoami']); if (out === null) { return { ok: false, error: 'tea could not authenticate — the token may be wrong, or tea is not installed' }; } // Prints `# pastilhas (admin)`. Taking the first non-empty line keeps this working if the decoration // around it changes. const line = out .split('\n') // Trim FIRST: tea indents the line, so stripping the `#` before trimming left it in place and the // page read "Authenticated as # pastilhas (admin)". .map((l) => l.trim().replace(/^#\s*/, '')) .find(Boolean); return line ? { ok: true, user: line } : { ok: false, error: 'tea answered, but named no account' }; }