Files
gitea/sidecar/tea-config.ts
T
officer 7d79cab95c look for a member's tea config where their home actually is
It read /home/<osUser>, which is not where an Officer member's home is. Theirs
is DATA_PATH/<email>/home — where provisionUserDirs puts it and what useradd
was given — so a member with a working tea login was told they had none, and
the settings page asked for a token they had already set.

osUserHome is the platform's own answer to that question, and the same one tea
itself gets when run as that account, so reading and running now agree about
the path.

Not a sudo problem: runAs was escalating correctly the whole time, into a
directory that does not exist on any install.
2026-08-19 12:27:58 +00:00

222 lines
9.7 KiB
TypeScript

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';
// 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.
*/
const teaBinary = (): string =>
Bun.which('tea', { PATH: `${join(homedir(), '.local', 'bin')}:${process.env.PATH ?? ''}` }) ?? '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<string | null> {
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<string | null> {
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/<email>/home`, NOT `/home/<name>`.
*
* This said `/home/<osUser>` 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<string | null> {
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<TeaLogin | null> {
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<TeaLogin | null> {
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' };
}