From 921427c7a2fc487d22e1c849e7e6e421453e3f99 Mon Sep 17 00:00:00 2001 From: officer Date: Wed, 19 Aug 2026 10:08:34 +0000 Subject: [PATCH] the token lives in tea's config, once, per user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- sidecar/index.ts | 41 +++++++++- sidecar/tea-config.ts | 168 ++++++++++++++++++++++++++++++++++++++++ web/GiteaConnection.tsx | 52 ++++++++++++- 3 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 sidecar/tea-config.ts diff --git a/sidecar/index.ts b/sidecar/index.ts index 4fac38c..3d049f4 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -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 { } 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 { 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(); diff --git a/sidecar/tea-config.ts b/sidecar/tea-config.ts new file mode 100644 index 0000000..8376d26 --- /dev/null +++ b/sidecar/tea-config.ts @@ -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 { + 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. + */ +export async function readTeaLogin(userId: number, instanceUrl: string): Promise { + 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' }; +} diff --git a/web/GiteaConnection.tsx b/web/GiteaConnection.tsx index 9a0ee49..e1872cc 100644 --- a/web/GiteaConnection.tsx +++ b/web/GiteaConnection.tsx @@ -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(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) => { /> + {/* 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 ? ( +

+ + tea is configured{state.tea.user ? ` as ${state.tea.user}` : ''} — leave the field blank to keep it +

+ ) : null} + + {state.canStoreToken === false ? ( +

+ + This account has no system user yet, so a token cannot be stored for the command line. The web + view above works regardless. +

+ ) : null} + + {tested ? ( +

+ {tested.ok ? `Authenticated as ${tested.user}` : tested.error} +

+ ) : null} + {error &&

{error}

}
@@ -101,6 +143,12 @@ export const GiteaConnection = ({ state, onSaved }: Props) => { {busy ? : null} {state.configured ? 'Save' : 'Connect'} + {/* Only offered when there is something to test. */} + {state.tea?.hasToken ? ( + + ) : null} {/* Only once there is something to go back TO. */} {state.configured && !state.unreadable ? (