It read /home/<osUser>, which is not where an Officer member's home is. Theirs
is DATA_PATH/<email>/home — where provisionUserDirs puts it and what useradd
was given — so a member with a working tea login was told they had none, and
the settings page asked for a token they had already set.
osUserHome is the platform's own answer to that question, and the same one tea
itself gets when run as that account, so reading and running now agree about
the path.
Not a sudo problem: runAs was escalating correctly the whole time, into a
directory that does not exist on any install.
/api/v1/version is not reliably public — an instance with REQUIRE_SIGNIN_VIEW
answers 403 — so naming a perfectly good URL was refused with "the server
answered 403" and whoever typed it had nothing to act on.
/api/healthz is Gitea's own health endpoint and answers before any
authentication, which is the whole point of it. Tried first, with version as
the fallback.
A 401 or 403 from either now counts as reachable. This step only asks whether
there is a Gitea at the address; whether the caller may READ it is the token
step's question, and failing here for that reason sends people looking for the
wrong problem.
Two things reported from a production instance.
The URL step was refused with "an access token is required the first time" — a
check that predates splitting the screen into two steps, and which made the
first step impossible to complete. Naming the instance is now its own step; a
member still needs a token, since setting one is all they do.
And the fields were a div, so pressing Enter did nothing and the button was the
only way to submit. That is a keyboard trap wearing a layout. Now a <form> with
onSubmit, with the other two buttons marked type="button" so they do not submit
it.
19 panel files across the platform have the same problem. Recorded in the
platform's TODO.md rather than swept blindly — a search box has no submit, and
a multi-field editor wants the useForm hook.
Also dropped the hint under the URL field, which repeated the sentence directly
above it.
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.
PM2 hands the sidecar a login-less PATH that excludes ~/.local/bin, which is
exactly where the platform installs a release binary. So `tea whoami` failed
with "the token may be wrong, or tea is not installed" on a machine where tea
was sitting in the owner's home and working perfectly from a shell.
Third time today the same trap: the probe in os-deps, then the agent's env, now
this. Anything the platform installs to ~/.local/bin has to be found
deliberately, because nothing the platform spawns inherits a PATH that includes
it.
Resolved per call rather than cached, so an install that adds it mid-run is
picked up instead of a cached "missing" outliving the thing that fixed it.
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.
The dashboard heatmap reads its endpoint from data-heatmap-url:
document.querySelector('#user-heatmap').getAttribute('data-heatmap-url').
Unprefixed it fetched from the PLATFORM's origin, got the SPA's index.html and
reported "Unexpected token '<'" while parsing JSON — the last error left after
everything visible was rendering correctly.
data-url carries the language switcher's links and had the same problem.
Matched on the `url` suffix rather than by listing attribute names, because the
list is Gitea's to change.
Found by reading Gitea's own bundle rather than by reasoning about what it
probably does — which is what the three previous attempts at this error were.
Gitea's inline script carries `appUrl: 'https:\/\/gitea.example.com\/'`, and
rewriteAbsolute only matched the literal `https://` form. So appUrl survived
pointing at the real host while every visible link was rewritten correctly.
The failure was quiet rather than obvious: the page rendered, and only the URLs
Gitea's JS BUILDS from appUrl went wrong — cross-origin to the real host,
answered with its login HTML. The heatmap reported "Unexpected token '<'" while
parsing JSON, and the event worker failed the same way.
Third instance of the same class today, after the asset paths and appSubUrl: a
URL hiding in a shape the rewriter did not think to look for. All three were
found by loading the page, none by the tests — and all three now have a test
written against a live sample rather than an imagined one.
Gitea inlines a script naming its own worker:
'\/assets\/js\/eventsource.sharedworker.…js'. That is not an attribute and its
slashes are backslash-escaped, so neither the attribute rewrite nor appSubUrl
saw it. Unprefixed, the browser resolved it against the PLATFORM's origin, got
the SPA's index.html, and reported "Uncaught SyntaxError: Unexpected token '<'"
from a file that appeared to load — along with a dead SharedWorker and a
heatmap parsing HTML as JSON.
appSubUrl covers everything Gitea's code BUILDS at runtime. This covers what is
already in the page before that code runs.
Narrow on purpose, like dev-proxy's /_bun/ rule: a blanket rewrite of absolute
paths inside JavaScript corrupts strings that are not URLs. /assets/ is Gitea's
own asset root and specific enough to match safely.
The escaping has to match the string it lands in — \/api\/gitea\/_web, not
\\/api — or the JS stops parsing. Both forms are tested against a live sample.
Bun's fetch sets its own Accept-Encoding and decompresses transparently, so
upstream.body is plain while upstream.headers still says gzip. Passing both on
hands the next hop a body that contradicts its own header, and it dies with
ZlibError.
Fixed for the HTML branch and missed on the pass-through, so the page rendered
while every stylesheet, script and image beside it answered 502 — which reads
as a broken asset pipeline rather than one header on one branch.
The regex expected JSON — "appSubUrl":"…" — and Gitea emits a JavaScript object
literal: unquoted key, single quotes, `appSubUrl: ''`. So the single
highest-value rewrite in this file silently did nothing on every real page,
while every attribute around it rewrote perfectly.
The test used the shape I guessed rather than the shape Gitea produces, which
is why it passed. Now covers both, with the real form taken verbatim from a
live response, and asserts the quote style survives — swapping it would stop
the literal parsing.
`.text()` decompresses, so the rewritten HTML goes back plain — while the
headers still said `content-encoding: gzip`. The next hop believed the header,
tried to gunzip plain text, and failed with ZlibError.
Which surfaced as "gitea unreachable": a 502 naming nothing, while a direct
curl to the sidecar returned 200 the whole time.
The frame answered {"error":"internal error"} on every request. The cause was
AES-GCM failing with "Unsupported state or unable to authenticate data" — the
token in service_connections was encrypted under a key that no longer exists,
because the platform mints its encryption key on first use and does not back it
up. The same loss took slskd, memos and gitea credentials on this project's own
server earlier today.
It presented as a broken sidecar. connectionState reported `configured: true`
because a ROW exists, so the panel skipped the form and went to the frame,
where every call threw. Nothing offered a way to fix it, and the connection
form was unreachable precisely because it only appears when unconfigured.
Now a named failure. `UnreadableCredentials` is distinguished from every other
error — a database that is down keeps its own message, or a real fault would be
reported as "re-enter your token" — and the panel shows the form saying the key
changed, nothing else is lost, and the URL is still there.
Verified by the platform itself refusing to decrypt: this is the state that
machine has been in all day.
Gitea believes it lives at /. Its pages are full of root-absolute links, and
inside the frame those resolve against OFFICER'S origin — the page renders
perfectly and falls apart on the first click.
Four places a URL hides, each failing differently: href/src/action attributes,
window.config.appSubUrl, Location headers, and absolute URLs built from
ROOT_URL.
appSubUrl is the highest-value line. Gitea already supports being hosted under
a subpath and its client JS prefixes every URL it builds with that value, so
filling it in makes Gitea correct ITSELF rather than by interception.
The ROOT_URL case needs Gitea's public origin, which the sidecar does not have
— it fetches from loopback. Rather than add a second setting that can disagree
with Gitea's own, appUrlOrigin reads it out of the page: Gitea emits "appUrl"
in the same config blob as appSubUrl.
Not rewritten, deliberately: JavaScript bodies, because a blanket rewrite of
absolute paths inside a script corrupts strings that are not URLs; and clone
URLs, which must keep working for whoever copies them.
/_web/* now forwards by hand rather than through createSidecarProxy. That
forwarder passes three headers and nothing else, on purpose — it is shared by
every sidecar and its restraint is what makes it safe to reuse. It strips
cookies, and this path is nothing but cookies. Loosening it for gitea would
loosen it for the wallet.
Eighteen tests, the only part of this plugin verifiable without a running
Gitea. One caught a real bug: rewriteAbsolute turns a full URL into <base>/path
and the attribute pass then prefixed it again.
Eleventh plugin out, and the first that is not a move. The seventeen files that
reimplemented a fraction of Gitea's interface were deleted rather than carried
across — each one a promise to keep pace with a project that moves faster than
this plugin will.
That removed a platform change nobody now has to build. useGiteaLocation read
:owner/:name/:tab/:item from five route patterns and a plugin gets two, so
extracting as-is would have needed the plugin system to declare extra routes.
Nothing inside an iframe needs a route, so the requirement went with the screen.
No regression window: gitea's router had been commented out of hono.ts since
2026-08-13 with api: [], and officer-gitea was running nowhere. The screen
rendered and every request 404'd.
The proxy is what makes the iframe possible, not decoration. Officer and Gitea
are different registrable domains, so a frame pointed straight at the instance
gets a login page whose cookie the browser drops as third-party. Through
Officer's origin it is first-party and survives; X-Frame-Options and CSP are
stripped, safe only because the frame is the origin the browser already
authenticated.
/_web/* forwards the browser's own cookies and injects NOTHING. The sidecar
holds an API token and injecting it there would show every user the owner's
view of every repository. It is used only by /_api/*.
Two doors, because an iframe's sub-resources carry no Authorization header:
the ordinary routes go through the platform's gate, and /_web/* is declared
unauthenticated and authenticates itself with a minted, path-scoped cookie —
32 random bytes against the user who asked, not dev-proxy's constant =1 which
anyone knowing the cookie name can set. Verifying it also sets `user`, because
createSidecarProxy fills X-Officer-User from it and the sidecar refuses a
request without one.
Ships an affordance: git for what git does, tea for pull requests and issues.
Nothing copies it into capabilities/ yet — gitea is that mechanism's first
customer.