import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol'; import { createSidecarConnector } from '@@/sidecar/connect'; import { rewriteHtml, rewriteLocation, rewriteSetCookie } from './rewrite'; import { getServiceConnection, saveServiceConnection, deleteServiceConnection, recordServiceProbe, getServiceInstanceUrl, getOwnerUser, } from 'officerdb'; import { callGitea, getGiteaConfig, invalidateGiteaConfig, invalidateAllGiteaConfigs, normalizeBase, probe, reachable, UnreadableCredentials, } from './upstream'; 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 // token. The platform side is a thin auth-gated forwarder holding no Gitea credentials. // // ───────────────────────────────────────────────────────────────────────────────────────────────── // HTTP CONTRACT — the platform strips its /api/gitea mount prefix before forwarding. // // GET /_health is the instance up, and does the stored token work // GET /_config the connection MINUS the token // PUT/POST /_config { url, token } — validated live before it is stored // DELETE /_config forget the connection // ANY /_api/* forwarded to the instance's own /api/*, token attached // // That is the SINGLE-CONNECTION contract slskd and transmission already serve, matched deliberately so // the UI can use the shared `useServiceConnection` / `useServiceConnectionActions` hooks rather than // growing a fourth copy of them. Which is why PUT is accepted (the shared hook's `save` uses it), why a // save answers `{ connection }`, and why DELETE answers the connection state rather than `{ ok }`. // Gitea genuinely is one-of: nobody runs two. // // `/_api/*` is a deliberate pass-through rather than a hand-written wrapper per endpoint, for the same // reason memos does it: Gitea's REST surface is enormous (its swagger is 850KB) and moves between minor // versions, so re-describing it here would mean a second thing to keep in sync, and the UI is going to // speak the upstream's shapes anyway. The allow-list below is the one piece of policy: it keeps the // sidecar from being a general-purpose SSRF hop into whatever else is on that host. // ───────────────────────────────────────────────────────────────────────────────────────────────── // Everything under /api/v1 the UI legitimately needs. // // `/api/v1/admin/*` is excluded ON PURPOSE and should stay excluded. A Gitea token minted by a site // admin carries the admin API with it — create and delete users, rewrite anyone's repositories, mint // tokens for other accounts. None of that is a thing a notes-and-repos panel needs, and a token pasted // into a connection form should not silently become a remote root shell for the instance. If an admin // screen is ever genuinely wanted it gets its own reviewed entry here, not a widened prefix. const ALLOWED = [ /^\/api\/v1\/version(\/|$|\?)/, /^\/api\/v1\/user(\/|$|\?)/, // Public profile data. Note this prefix also covers `/users/{name}/tokens`, which lists and CREATES // access tokens — a token-minting door if it were reachable. It is not: Gitea requires basic auth there // precisely to stop a token escalating its own scopes, and answers `401 auth required` to the // `Authorization: token …` this sidecar sends. Verified against 1.27.0 on 2026-08-06. That protection is // GITEA's, not ours, so if this ever needs re-checking on an upgrade, that is the endpoint to re-check. /^\/api\/v1\/users(\/|$|\?)/, /^\/api\/v1\/orgs(\/|$|\?)/, /^\/api\/v1\/org(\/|$|\?)/, /^\/api\/v1\/teams(\/|$|\?)/, /^\/api\/v1\/repos(\/|$|\?)/, /^\/api\/v1\/repositories(\/|$|\?)/, /^\/api\/v1\/notifications(\/|$|\?)/, /^\/api\/v1\/issues(\/|$|\?)/, /^\/api\/v1\/packages(\/|$|\?)/, // Instance metadata the UI reads to match the instance's own behaviour rather than guess it — // default branch name, whether the markdown renderer allows raw HTML, which features are enabled. /^\/api\/v1\/settings(\/|$|\?)/, /^\/api\/v1\/topics(\/|$|\?)/, /^\/api\/v1\/licenses(\/|$|\?)/, /^\/api\/v1\/gitignore(\/|$|\?)/, /^\/api\/v1\/label(\/|$|\?)/, // Rendering. Gitea's own web UI renders a README through these, and matching it matters: the // instance resolves relative links, #issue references and @mentions against the repo context, which a // client-side markdown pass cannot do. Sanitisation is the instance's too — see renderMarkdown. /^\/api\/v1\/markdown(\/|$|\?)/, /^\/api\/v1\/markup(\/|$|\?)/, ]; function getFreePort(): number { const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); const p = probeServer.port; probeServer.stop(true); if (p == null) throw new Error('failed to acquire a free port'); return p; } const port = getFreePort(); const bad = (error: string, status = 400) => Response.json({ error }, { status }); /** Whoever owns the platform configures the instance; everyone else supplies only their own token. */ async function isOwner(userId: number): Promise { const owner = await getOwnerUser(); return !!owner && owner.id === userId; } // A member's row has no URL to return — it is genuinely not stored, not merely withheld — so this needs // no per-caller filtering. `instanceConfigured` is what the UI needs instead: it distinguishes "you have // not connected yet" from "there is nothing here to connect to", which are different screens. // // `instanceUrl` is the resolved base, returned to members as well as the owner. Not setting it is the // invariant (a member naming the URL would be a per-user SSRF hop); not SEEING it never was, and could // not be: every avatar_url and html_url the instance hands back is on that origin and gets rendered. // The UI needs it as a value rather than as a thousand strings because this instance's ROOT_URL disagrees // with its public host, so links have to be rebased client-side — see retargetUrls. async function connectionState(userId: number): Promise { // Asked first, and only for its failure: a row whose token cannot be decrypted must NOT be reported as // configured. It would send the panel to the frame, which then throws on every request — which is // exactly what happened, and it reads as the sidecar being broken rather than as a token needing // re-entering. try { await getGiteaConfig(userId); } catch (err) { if (err instanceof UnreadableCredentials) { return Response.json({ configured: false, unreadable: true, isOwner: await isOwner(userId), error: err.message, }); } throw err; } 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. // 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({ 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. // `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)), }); } async function handleConfig(req: Request, userId: number): Promise { if (req.method === 'GET') return connectionState(userId); if (req.method === 'DELETE') { const owner = await isOwner(userId); await deleteServiceConnection(userId, 'gitea'); // The owner disconnecting removes the instance every member's row inherits, so every cached config // is now wrong — they must all resolve to null rather than keep talking to a server the platform no // longer claims. A member disconnecting only affects themselves. if (owner) invalidateAllGiteaConfigs(); else invalidateGiteaConfig(userId); return connectionState(userId); } if (req.method === 'PUT' || req.method === 'POST') { const body = ((await req.json().catch(() => null)) as { url?: unknown; token?: unknown } | null) ?? {}; // A blank token on a save means "keep the stored one", so the URL can be corrected without the // token being re-pasted. There is no way to read it back out to re-send it. const token = typeof body.token === 'string' && body.token.trim() ? body.token.trim() : null; const owner = await isOwner(userId); // The instance is the owner's; the token is each person's own. Which half of the form you get is // decided here rather than in the UI, because a UI that hides a field is a suggestion and this is a // rule: a member who could name the URL would have a per-user SSRF hop behind a settings form. let base: string; if (owner) { const url = typeof body.url === 'string' ? normalizeBase(body.url) : ''; if (!url) return bad('url is required'); if (!/^https?:\/\//i.test(url)) return bad('url must start with http:// or https://'); base = url; } else { // Rejected, not ignored. Ignoring it would let a member believe they had pointed Gitea somewhere // and leave them debugging a screen that is quietly talking to a different server. if (body.url !== undefined) return bad('only the server owner can set the Gitea instance URL', 403); const instance = await getServiceInstanceUrl('gitea'); if (!instance) return bad('the server owner has not connected a Gitea instance yet', 409); base = instance; } const existing = await getServiceConnection(userId, 'gitea'); // NOT required on the first save any more. Naming the instance is its own step — the owner says // where Gitea is, and the token is asked for (or found in tea's config) afterwards. This check // predates that split and refused the URL step outright. // // A member is different: they set nothing but a token, so an empty save from them means nothing. if (!owner && !token && !(await readTeaLogin(userId, base))?.hasToken) { return bad('an access token is required'); } // Probe with the credentials as they WILL be, so a save either stores something that works or // 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 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. // // 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, // 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. // Clearing only the caller would leave them pointed at the previous host until this process restarts. if (owner) invalidateAllGiteaConfigs(); else invalidateGiteaConfig(userId); return Response.json({ connection, user: result.user, version: result.version }); } return bad('method not allowed', 405); } const server = Bun.serve({ port, hostname: '127.0.0.1', // Release attachments and file uploads through the contents API. maxRequestBodySize: 64 * 1024 * 1024, async fetch(req) { const url = new URL(req.url); const officerUser = req.headers.get('X-Officer-User'); const userId = Number(officerUser); if (!officerUser || !Number.isInteger(userId) || userId <= 0) { return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 }); } try { if (url.pathname === '/_config') return await handleConfig(req, userId); 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(); const result = await probe(config); const ms = Date.now() - started; if (!result.ok) { return Response.json({ ok: false, configured: true, error: result.error, ms }, { status: 502 }); } await recordServiceProbe(userId, 'gitea', result.version); return Response.json({ ok: true, configured: true, user: result.user, version: result.version, ms }); } // ── The web UI, proxied for the iframe ── // // Everything above speaks Gitea's JSON API with the OWNER'S stored token. This does the opposite: // it forwards the browser's own request, untouched, and injects nothing. // // That is deliberate. Gitea's web interface authenticates with its own session cookie, not with an // API token, and the person looking at the iframe signs in to Gitea as themselves. Injecting the // stored token here would silently show every user the OWNER'S view of every repository — the one // failure this whole plugin must not have. // // Same-origin is what makes it work at all: the browser is on the platform's origin, so Gitea's // cookie is first-party and survives. Pointed at gitea.officer.dev directly, an iframe gets a login // page whose cookie the browser discards. if (url.pathname.startsWith('/_web')) { if (!config) return new Response('gitea not connected', { status: 503 }); const subpath = url.pathname.slice('/_web'.length) || '/'; // Hop-by-hop headers and the platform's own auth are dropped; everything else — cookies above // all — goes through verbatim. const headers = new Headers(req.headers); for (const h of ['host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'authorization']) { headers.delete(h); } headers.delete('x-officer-user'); const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; let upstream: Response; try { upstream = await fetch(`${config.base}${subpath}${url.search}`, { method: req.method, headers, body: hasBody ? await req.arrayBuffer() : undefined, // MANUAL: a 302 to /user/login is meaningful to the iframe and must reach it as a redirect, // not be followed here into a page the browser never asked for. redirect: 'manual', signal: AbortSignal.timeout(30_000), }); } catch { return new Response('gitea unreachable', { status: 502 }); } const out = new Headers(upstream.headers); // Gitea refuses to be framed by default, and the whole point here is to frame it. Safe because // the frame is the platform's own origin, which the browser has already authenticated. out.delete('x-frame-options'); out.delete('content-security-policy'); for (const h of ['connection', 'keep-alive', 'transfer-encoding']) out.delete(h); // CONTENT-ENCODING ALWAYS, not just when rewriting. // // Bun's `fetch` sets its own Accept-Encoding and decompresses the response transparently, so // `upstream.body` is plain while `upstream.headers` still says gzip. Passing both on hands the // next hop a body that does not match its own header, and it dies with ZlibError. // // This was fixed for the HTML branch below and missed here, so the PAGE rendered while every // stylesheet, script and image beside it answered 502 — which looks like a broken asset pipeline // rather than one header. out.delete('content-encoding'); out.delete('content-length'); // Where the browser thinks this is. Passed by the plugin's router rather than hardcoded — the // sidecar has no business knowing how the platform routes to it, and `mountPrefix` is the only // thing entitled to decide. const base = req.headers.get('X-Officer-Frame-Base'); const origins = [config.base]; if (base) { const location = out.get('location'); if (location) out.set('location', rewriteLocation(location, base, origins)); // Every Set-Cookie, not just the first: Gitea sets a session and a CSRF cookie together, and // `Headers.get` would concatenate them into one broken value. const cookies = (out as unknown as { getSetCookie?: () => string[] }).getSetCookie?.() ?? []; if (cookies.length) { out.delete('set-cookie'); for (const c of cookies) out.append('set-cookie', rewriteSetCookie(c, base)); } // HTML only. Rewriting JavaScript hits strings that are not URLs and corrupts the file — // `appSubUrl`, patched below by the same pass, is what makes Gitea's own scripts correct. if ((out.get('content-type') ?? '').includes('text/html')) { const body = rewriteHtml(await upstream.text(), base, origins); // `content-encoding` and `content-length` are already gone, dropped for every response // above — the body changing length here is one more reason they have to be. return new Response(body, { status: upstream.status, headers: out }); } } return new Response(upstream.body, { status: upstream.status, headers: out }); } if (url.pathname.startsWith('/_api/')) { if (!config) return Response.json({ error: 'gitea not connected', configured: false }, { status: 503 }); const subpath = url.pathname.slice('/_api'.length) + url.search; if (!ALLOWED.some((re) => re.test(subpath))) return bad('path not allowed', 403); const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; const upstream = await callGitea(config, subpath, { method: req.method, headers: req.headers.get('content-type') ? { 'content-type': req.headers.get('content-type')! } : {}, body: hasBody ? await req.arrayBuffer() : undefined, }); return new Response(upstream.body, { status: upstream.status, headers: { 'content-type': upstream.headers.get('content-type') ?? 'application/json' }, }); } } catch (err) { // Path only. A repository body is the owner's private code. console.error(`[gitea] ${req.method} ${url.pathname} failed`, String(err)); // Named, because the remedy is specific and the generic message sends people to the logs. This one // is not a fault: a token was encrypted under a key that no longer exists. if (err instanceof UnreadableCredentials) { return Response.json({ error: err.message, unreadable: true }, { status: 409 }); } return Response.json({ error: 'internal error' }, { status: 500 }); } return Response.json({ error: 'not found' }, { status: 404 }); }, }); console.log(`[gitea] listening on 127.0.0.1:${port} (instance configured from the UI, stored in service_connections)`); type ReplyFn = (msg: SidecarEvent) => void; function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { switch (cmd.type) { case 'ping': reply({ type: 'pong', id: cmd.id }); break; default: reply({ type: 'error', id: (cmd as SidecarCommand).id, error: `Unknown command type: ${(cmd as Record).type}`, }); } } const connection = createSidecarConnector({ apiUrl: `${API_URL}/api/sidecar/register`, name: 'gitea', handles: ['gitea'], onCommand(cmd, reply) { handleCommand(cmd as SidecarCommand, reply as ReplyFn); }, onConnected() { connection.send({ type: 'gitea:server', port }); console.log(`[gitea] reported server port ${port} to API`); }, }); function shutdown(signal: string) { console.log(`[gitea] ${signal} received, shutting down...`); try { server.stop(true); } catch { /* already stopped */ } connection.destroy(); process.exit(0); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));