Files
gitea/sidecar/tea-config.ts
pastilhasandClaude Opus 5 6d5b75f4f2 use the platform's binary resolver instead of a second copy
teaBinary hardcoded ~/.local/bin:$PATH. Right for tea, 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 its own
sidecar could build.

locateBinary asks the owner's login shell, so it knows about every directory a per-user installer
added without anyone listing them. Verified it still resolves tea from ~/.local/bin, which is the case
the comment above it was written for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:19:47 +00:00

228 lines
10 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';
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<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' };
}