the token lives in tea's config, once, per user

It was stored encrypted in service_connections as well. Nothing needs it there
any more: the UI is an iframe carrying the browser's own Gitea session, so the
only consumer left was the health probe. Meanwhile tea keeps its own copy
regardless — it is what the agent uses, and what this plugin's affordance tells
the agent to use.

Two copies of one secret is a drift waiting to happen: rotate with `tea login`
and the database quietly keeps the dead one. So the token lives once, where it
is used, and saving now writes `tea login add --name officer` AS that account —
a member's token lands in a home only they can read. `secret: null` on save
clears whatever an earlier version stored, so an upgraded machine stops
carrying a copy nothing reads.

service_connections keeps the URL, which is the owner's and genuinely shared.

The settings page READS the existing config first, so on a machine where the
owner and their members already ran `tea login` by hand it says "tea is
configured as X" rather than demanding something they have. It also says when
an account has no OS user yet, since there is then nowhere to write one — the
web view works regardless.

Test runs `tea whoami` as that account. Deliberately not a call the platform
makes with a token it read: the point is to prove what the AGENT will get, and
those are now the same thing. `--name officer` so the platform never overwrites
a login somebody set up by hand.

Noted rather than hidden: `tea login add` takes the token in argv, visible in
ps while it runs. tea offers no stdin form, and the alternative is writing its
YAML by hand — a visible argument traded for a fragile format.
This commit is contained in:
officer
2026-08-19 10:08:34 +00:00
parent c31f95155f
commit 921427c7a2
3 changed files with 257 additions and 4 deletions
+39 -2
View File
@@ -18,6 +18,7 @@ import {
probe,
UnreadableCredentials,
} from './upstream';
import { readTeaLogin, writeTeaLogin, osUserFor, testTeaLogin } from './tea-config';
import { API_URL } from '@@/officer-url.mjs';
// The officer-gitea sidecar. Owns the whole Gitea contract: the instance URL and the personal access
@@ -129,12 +130,26 @@ async function connectionState(userId: number): Promise<Response> {
}
const connection = await getServiceConnection(userId, 'gitea');
const instanceUrl = await getServiceInstanceUrl('gitea');
// The token's home is `tea`'s own config, not the database — see tea-config.ts. So "configured" for a
// MEMBER means tea has a login for this instance; the row only carries the URL, which is the owner's.
//
// Read rather than asked for: on a machine where the owner and their members already ran `tea login`
// by hand, the settings page should say "connected" instead of demanding something they have.
const tea = instanceUrl ? await readTeaLogin(userId, instanceUrl) : null;
const osUser = await osUserFor(userId);
return Response.json({
configured: !!connection,
configured: !!instanceUrl && !!tea?.hasToken,
connection,
instanceUrl,
instanceConfigured: !!instanceUrl,
isOwner: await isOwner(userId),
// What tea already knows, so the page can say who you are without a round trip to Gitea.
tea: tea ? { name: tea.name, user: tea.user, hasToken: tea.hasToken } : null,
// Null means never provisioned an OS account, so there is nowhere to write a config. The page says
// so rather than failing on save.
canStoreToken: !!osUser || (await isOwner(userId)),
});
}
@@ -188,12 +203,28 @@ async function handleConfig(req: Request, userId: number): Promise<Response> {
const result = await probe({ id: existing?.id ?? 0, base, token: token ?? current?.token ?? null });
if (!result.ok) return bad(result.error ?? 'could not reach the instance');
// THE TOKEN GOES TO `tea`, NOT THE DATABASE.
//
// It used to be stored encrypted in `service_connections`, and nothing needs it there any more: the
// UI is an iframe carrying the browser's own Gitea session, so the only consumer left was this
// probe. `tea` keeps its own copy regardless — it is what the agent uses, and what this plugin's
// affordance tells the agent to use — so storing a second one is a drift waiting to happen: rotate
// with `tea login` and the database quietly keeps the dead one.
//
// Written as the account, so a member's token lands in a home only they can read.
if (token) {
const written = await writeTeaLogin(userId, base, token);
if (!written.ok) return bad(`could not store the token for tea: ${written.error}`);
}
const connection = await saveServiceConnection({
userId,
service: 'gitea',
// Null for anyone but the owner: their row is a credential, and inherits the instance on read.
url: owner ? base : null,
secret: token ?? undefined,
// Deliberately NOT stored. `secret: null` clears anything an earlier version of this wrote, so a
// machine upgraded into this design stops carrying a copy it no longer reads.
secret: null,
version: result.version,
});
// An owner's save may have moved the instance, and every member's cached base was resolved from it.
@@ -225,6 +256,12 @@ const server = Bun.serve({
const config = await getGiteaConfig(userId);
// Prove the token works, by asking `tea` to use it as this user. Deliberately not a call the
// platform makes with a token it read: the point is to test what the AGENT will get.
if (url.pathname === '/_test') {
return Response.json(await testTeaLogin(userId));
}
if (url.pathname === '/_health') {
if (!config) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
const started = Date.now();
+168
View File
@@ -0,0 +1,168 @@
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;
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.
*/
export async function readTeaLogin(userId: number, instanceUrl: string): Promise<TeaLogin | null> {
const osUser = await osUserFor(userId);
const raw = isOwnerUser(osUser)
? await (async () => {
try {
return readFileSync(configPathFor(homedir()), 'utf8');
} catch {
return null;
}
})()
: await asUser(osUser, ['cat', configPathFor(join('/home', osUser!))]);
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;
}
/**
* 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 = ['tea', '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, ['tea', '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')
.map((l) => l.replace(/^#\s*/, '').trim())
.find(Boolean);
return line ? { ok: true, user: line } : { ok: false, error: 'tea answered, but named no account' };
}
+50 -2
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Loader2 } from 'lucide-react';
import { CheckCircle2, Loader2, TriangleAlert } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -29,12 +29,16 @@ export type ConnectionState = {
* token has to be pasted again.
*/
unreadable?: boolean;
/** What `tea` already has for this instance. Null when it has no login for it. */
tea?: { name: string; user: string | null; hasToken: boolean } | null;
/** False when this account has no OS user, so there is nowhere to write a config. */
canStoreToken?: boolean;
};
type Props = { state: ConnectionState; onSaved: () => void };
export const GiteaConnection = ({ state, onSaved }: Props) => {
const { put } = useClient();
const { get, put } = useClient();
const navigate = useNavigate();
// Loopback is the right default and worth putting in front of someone: the sidecar fetches
// SERVER-SIDE, so `localhost` means the machine Officer runs on. It needs no TLS and never leaves the
@@ -43,6 +47,21 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
const [token, setToken] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tested, setTested] = useState<{ ok: boolean; user?: string; error?: string } | null>(null);
// Runs `tea whoami` AS this account. It proves the token the AGENT will use, not one the platform read
// out of a database — which is the only test worth having, since those are now the same thing.
const test = async () => {
setBusy(true);
setTested(null);
try {
setTested(await get<{ ok: boolean; user?: string; error?: string }>('/gitea/_test'));
} catch (err) {
setTested({ ok: false, error: err instanceof Error ? err.message : 'could not test' });
} finally {
setBusy(false);
}
};
const save = async () => {
setBusy(true);
@@ -94,6 +113,29 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
/>
</label>
{/* What tea already has, read from the account's own config. On a machine where someone ran
`tea login` by hand, this says so instead of demanding a token they already gave. */}
{state.tea?.hasToken ? (
<p className="flex items-center gap-1.5 text-xs text-success">
<CheckCircle2 className="h-3.5 w-3.5" />
tea is configured{state.tea.user ? ` as ${state.tea.user}` : ''} — leave the field blank to keep it
</p>
) : null}
{state.canStoreToken === false ? (
<p className="flex items-start gap-1.5 text-xs text-warning">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
This account has no system user yet, so a token cannot be stored for the command line. The web
view above works regardless.
</p>
) : null}
{tested ? (
<p className={`text-xs ${tested.ok ? 'text-success' : 'text-destructive'}`}>
{tested.ok ? `Authenticated as ${tested.user}` : tested.error}
</p>
) : null}
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex gap-2">
@@ -101,6 +143,12 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
{busy ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
{state.configured ? 'Save' : 'Connect'}
</Button>
{/* Only offered when there is something to test. */}
{state.tea?.hasToken ? (
<Button variant="outline" onClick={test} disabled={busy}>
Test
</Button>
) : null}
{/* Only once there is something to go back TO. */}
{state.configured && !state.unreadable ? (
<Button variant="outline" onClick={() => navigate('/gitea')} disabled={busy}>