Files
gitea/sidecar/tea-config.ts
T
officer d4efc52447 ask for the URL first, and offer what tea already knows
The form asked for a URL and a token together, on a fresh instance, while the
machine in front of it often had both on disk already — somebody had run `tea
login` by hand.

Two changes.

One thing at a time: until the platform knows where Gitea is, a token cannot be
checked, because there is nothing to check it against. So the owner names the
instance first, and only then does the page ask for — or discover — a token.
That step now probes /api/v1/version unauthenticated, since demanding a token
to validate a URL refuses a good URL for the wrong reason and sends whoever
typed it looking for a credential problem.

And it reads tea's own config when the platform has no instance yet, offering
the URL it finds. A machine that already has a working login should not be
asked to retype what it can see.

Members skip the first step entirely — the instance is the owner's.
2026-08-19 10:25:22 +00:00

211 lines
9.2 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { getUserById } from 'officerdb';
import { 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.
*/
async function rawConfig(userId: number): Promise<string | null> {
const osUser = await osUserFor(userId);
if (isOwnerUser(osUser)) {
try {
return readFileSync(configPathFor(homedir()), 'utf8');
} catch {
return null;
}
}
return asUser(osUser, ['cat', configPathFor(join('/home', osUser!))]);
}
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' };
}