From d4efc52447ee38e1d8281e20738d09f353d86e95 Mon Sep 17 00:00:00 2001 From: officer Date: Wed, 19 Aug 2026 10:25:22 +0000 Subject: [PATCH] ask for the URL first, and offer what tea already knows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- sidecar/index.ts | 19 +++++++++++++---- sidecar/tea-config.ts | 46 ++++++++++++++++++++++++++++++++--------- sidecar/upstream.ts | 21 +++++++++++++++++++ web/GiteaConnection.tsx | 39 ++++++++++++++++++++++++++-------- 4 files changed, 102 insertions(+), 23 deletions(-) diff --git a/sidecar/index.ts b/sidecar/index.ts index 3d049f4..0543c90 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -16,9 +16,10 @@ import { invalidateAllGiteaConfigs, normalizeBase, probe, + reachable, UnreadableCredentials, } from './upstream'; -import { readTeaLogin, writeTeaLogin, osUserFor, testTeaLogin } from './tea-config'; +import { readTeaLogin, readAnyTeaLogin, 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 @@ -136,7 +137,10 @@ async function connectionState(userId: number): Promise { // // 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; + // With an instance known, ask about THAT one. Without, offer whatever tea already has — a machine + // where somebody ran `tea login` by hand knows the URL and holds a working token, and asking them to + // retype it is asking for something the box can already see. + const tea = instanceUrl ? await readTeaLogin(userId, instanceUrl) : await readAnyTeaLogin(userId); const osUser = await osUserFor(userId); return Response.json({ @@ -146,7 +150,8 @@ async function connectionState(userId: number): Promise { 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, + // `url` included so the form can offer it when the platform has no instance yet. + tea: tea ? { name: tea.name, url: tea.url, 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)), @@ -199,8 +204,14 @@ async function handleConfig(req: Request, userId: number): Promise { // fails with the reason. Storing first and discovering later makes every downstream screen fail // mysteriously instead. For a member this also proves their token is valid against the shared // instance, which is the only thing that can be wrong on their side. + // Setting the INSTANCE is its own step, and it must not demand a token: the owner names where Gitea + // is first, and only then does the page ask for — or discover — a token for it. Probing with one + // when there is none would refuse a perfectly good URL for the wrong reason. const current = token ? null : await getGiteaConfig(userId); - const result = await probe({ id: existing?.id ?? 0, base, token: token ?? current?.token ?? null }); + const candidate = token ?? current?.token ?? null; + const result = candidate + ? await probe({ id: existing?.id ?? 0, base, token: candidate }) + : await reachable(base); if (!result.ok) return bad(result.error ?? 'could not reach the instance'); // THE TOKEN GOES TO `tea`, NOT THE DATABASE. diff --git a/sidecar/tea-config.ts b/sidecar/tea-config.ts index 2f5771b..81c85d0 100644 --- a/sidecar/tea-config.ts +++ b/sidecar/tea-config.ts @@ -84,18 +84,20 @@ export async function osUserFor(userId: number): Promise { * 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 { +async function rawConfig(userId: number): 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 (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 { + 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 @@ -115,6 +117,30 @@ export async function readTeaLogin(userId: number, instanceUrl: string): Promise 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 { + 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. * diff --git a/sidecar/upstream.ts b/sidecar/upstream.ts index af2b43a..0b269d2 100644 --- a/sidecar/upstream.ts +++ b/sidecar/upstream.ts @@ -113,6 +113,27 @@ export type ProbeResult = { ok: boolean; version: string | null; user: string | * 200 → both good * Verified against the live instance: unauthenticated /version → 403, /version with a junk token → 401. */ +/** + * Is there a Gitea at this URL at all — no token involved. + * + * Naming the instance is its own step: the owner says where Gitea is, and only then does anyone ask for + * a token for it. Probing with a token that does not exist yet would refuse a perfectly good URL for the + * wrong reason, and send whoever typed it looking for a problem with their credentials. + * + * `/api/v1/version` answers unauthenticated on every Gitea, so a 200 means "this is a Gitea and it is + * up", which is exactly what this step needs to establish. + */ +export async function reachable(base: string): Promise { + try { + const res = await fetch(`${base}/api/v1/version`, { signal: AbortSignal.timeout(8000) }); + if (!res.ok) return { ok: false, version: null, user: null, error: `the server answered ${res.status}` }; + const body = (await res.json().catch(() => null)) as { version?: string } | null; + return { ok: true, version: body?.version ?? null, user: null }; + } catch { + return { ok: false, version: null, user: null, error: 'could not reach that URL from the server' }; + } +} + export async function probe(config: GiteaConfig): Promise { if (!config.token) return { ok: false, version: null, user: null, error: 'no access token stored' }; diff --git a/web/GiteaConnection.tsx b/web/GiteaConnection.tsx index e1872cc..97a7564 100644 --- a/web/GiteaConnection.tsx +++ b/web/GiteaConnection.tsx @@ -20,6 +20,8 @@ export type ConnectionState = { url?: string | null; user?: string | null; isOwner?: boolean; + /** Whether the OWNER has set the instance URL. Members inherit it and never set one. */ + instanceConfigured?: boolean; /** * A token is stored but cannot be decrypted. * @@ -30,7 +32,7 @@ export type ConnectionState = { */ 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; + tea?: { name: string; url?: 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; }; @@ -43,7 +45,10 @@ export const GiteaConnection = ({ state, onSaved }: Props) => { // 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 // box — where the public URL would take the same traffic out and back. - const [url, setUrl] = useState(state.url ?? 'http://localhost:3000'); + // Seeded from what tea already knows before falling back to a guess. On a machine where somebody ran + // `tea login` by hand, the URL is already on disk and asking for it again is asking for something the + // box can see. + const [url, setUrl] = useState(state.url ?? state.tea?.url ?? 'http://localhost:3000'); const [token, setToken] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -63,6 +68,13 @@ export const GiteaConnection = ({ state, onSaved }: Props) => { } }; + // ONE THING AT A TIME. Until the platform knows where Gitea is, a token cannot be checked — there is + // nothing to check it against — and once the URL is known, tea may already hold a working token, in + // which case the second step never appears at all. + // + // Members skip step one entirely: the instance is the owner's. + const needsUrl = state.isOwner !== false && !state.instanceConfigured; + const save = async () => { setBusy(true); setError(null); @@ -86,13 +98,15 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {

{state.unreadable ? "The stored token can no longer be read — this server's encryption key changed. Nothing else is lost; paste it again." - : state.isOwner === false - ? 'Your own access token. The instance is already set.' - : 'Where Gitea runs, and a token for it.'} + : needsUrl + ? 'Where Gitea runs. Officer reaches it from the server, so localhost means the machine Officer runs on.' + : state.isOwner === false + ? 'Your own access token. The instance is already set.' + : 'Your own access token for this instance.'}

- {state.isOwner !== false && ( + {needsUrl && ( )} + {/* Not shown while the instance is still unknown: a token cannot be validated without one, and + tea may already hold one for whatever URL is entered above. */} + {needsUrl ? null : ( + )} {/* 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. */} @@ -139,9 +157,12 @@ export const GiteaConnection = ({ state, onSaved }: Props) => { {error &&

{error}

}
- {/* Only offered when there is something to test. */} {state.tea?.hasToken ? (