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 ? (