HOMELAB-2682: feat(forgejo-ci): read FORGEJO_API_KEY_FILE first (scoped) #117
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-2682-ci-key-file"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
HOMELAB-88 (routine queue) / HOMELAB-2479 step 2 — scoped to
forgejo-ci.Draft review outcome: a draft WAS found, and it is not followed as written
The routine queue line (
working-files/ldv0/tooling-order-livedev.md) points at adraft, and that path exists:
working-files/infra-stability/orch19/step2-keyfile-prep.md,section 5 ("
forgejo-ci_FILEpatch — DRAFT ONLY"). I read it and did not trustit. There is no diff for it under
working-files/ldv0/; the queue line was the onlypointer, and it is exact.
Kept from the draft: the precedence (
FORGEJO_API_KEY_FILE→FORGEJO_API_KEY), thenewline trim, the file-variable trim, a sibling-shaped helper next to
loadConfig(),and the widened "no token" message.
Changed from the draft, with reasons:
named file cannot be read ("a stale pointer must not take the CI surface down").
This branch refuses instead, naming the path. A stale pointer plus a stale
environment is exactly how a rotation goes unnoticed, and the order for this
change says an unreadable file is an error, not a silent fallback. This is
therefore a deliberate divergence from the sibling
forgejo/api.ts, which fallsback — stated in the source comment so the next reader does not "fix" it back.
string | undefined, soa refusal has no reason to carry. Replaced with
resolveApiKey()returning{ apiKey, source, error }— theerrornames the path and never the value.loadConfig()carries therefusal on the config, and
Api.getPageraises it before any request is issued,so a broken pointer cannot send a request with a fallback credential. The
no-token message now names both variables.
environment answers. The draft did not distinguish empty from unreadable.
node_modulesis empty and vitest/tsx are not installed, so its probe ran rawfunction bodies instead of the suite. Here
forgejo-ci/node_moduleshas vitestand tsc; the shipped suite runs, and that is what was measured below.
What changed
forgejo-ci/forgejo.ts:API_KEY_ENV,API_KEY_FILE_ENV,ResolvedKey,resolveApiKey(env, readFile);loadConfig()resolves through it and carriesapiKeyError;Api.getPagerefuses onapiKeyErrorfirst, and its no-tokenmessage names both variables.
forgejo-ci/types.ts:CIConfig.apiKeyError?.forgejo-ci/tests/api-key-file.test.ts(new, 19 tests).forgejo-ci/README.mdandforgejo-ci/forgejo-ci.env.example: the docs sentencefor the variable, and the precedence. No other file.
Behaviour: the path and the token are both trimmed; the credential file wins over the
environment; variable unset, blank, or naming an empty file falls back to the
environment; a file that is missing, unreadable (mode 0000) or a directory is an
error naming the path; neither source yields the existing error, now naming both.
Measured, red then green
origin/main(aa6d009) — 17 failed,2 passed of 19. The 2 passing are the fixture-mode and no-spawn guards.
loadConfig()callsloadEnvFiles(), which legitimately fillsFORGEJO_API_KEYfrom
~/.config/homelab/forgejo.envwhen the variable is unset. The assertion was"the variable stays unset"; the property that matters is "the staged token is never
exported into the environment", and that is what it now asserts (comparing booleans,
so a failure cannot echo a credential into a log).
forgejo-cisuite — 71 passed, 11 skipped, 4 files passed + 1skipped (
live.test.tsskips itself without credentials). Baseline before thisbranch, same command: 52 passed, 11 skipped.
tsc --noEmit: clean.Live measurement against forgejo.aaron.reynoza.org
Both runs drove the real client; the probe printed lengths and status counts only,
never a value.
FORGEJO_API_KEYunset,FORGEJO_API_KEY_FILE=/run/user/1005/pi/forgejo-ci-reader.token(mode 0600, 40 bytes):
resolveApiKey→source: key-file,keyLen: 40;Api.runs("Homelab","pi-extensions")→ 18 items of a totalCount of 409, noerror. This is the case that fails on
main: the tool had no key to read.FORGEJO_API_KEYpresent,FORGEJO_API_KEY_FILEnaming a missing path:source: none,error: set,apiKeyLen: 0, and the call refused with... could not be read (/run/user/1005/pi/does-not-exist.token: ENOENT: no such file or directory ...) — refusing rather than falling back to FORGEJO_API_KEY.No request was issued.
Not covered here
forgejo/api.tsstill falls back on an unreadable file. That is itsdocumented choice for the ordinary read credential; this extension is the one that
must work with nothing but the file, and the order asked for the loud leg. Changing
the sibling is a separate decision.
into a refusal" queue item, token 152's missing
read:user(draft §7), and theweb-session-only tools — those read no API token and are unaffected, because the
refusal is raised in the API path, not in
loadConfig().forgejo-cispawns no child process, so the argv/environment leg of the token doesnot exist today; a source-level test keeps it that way.
Review
forgejo-ciis credential handling in an agent tool — dangerous class. Twoindependent review seats, on different models, one premium tier; both verdicts on the
current head, recorded on the ticket.
Verdict of record — ld-reviewer — head
d881e47e0a: APPROVEmodel: omniroute/deepseek-flash
session: 01a1b405-7264-70d7-89c1-cc34c013c7a9
Blockers
None.
Scope of the diff
Five files, all inside
forgejo-ci/:forgejo.ts,types.ts,tests/api-key-file.test.ts(new),README.md,forgejo-ci.env.example. Nothing outside the key-file leg. I looked for anything elsetouching credential handling and found none.
What I measured (seat 1; credential handling, dangerous class)
forgejo_get_pr→ headd881e47, basemain55518b35; the branchhead's parent is
aa6d009, so main moved under the PR (#116 landed after). #116's delta(
aa6d009..55518b35) touchesforgejo-ci/forgejo.ts(isPendinggainspending), an icon inindex.ts, and two test files — disjoint hunks. A localmerge-treeof55518b35×d881e47is clean and keeps both sides; I checked the merged blob for
apiKeyError+resolved.apiKey(present) and
isPending(unchanged).trimmed; a blank/whitespace-only variable is treated as unset; the file is read first and wins;
the read token is trimmed (LF and CRLF); a readable-but-empty file falls through to the
environment with no error; the environment value is trimmed; neither source →
source: none.A file that is missing, mode-0000, or a directory throws, and the resulting
errornames thepath and the variable and never the value, and the environment is not consulted.
Api.getPage, abovenew URLSearchParams(...), above the URL string, abovefetchImpl. The onlyAuthorizationconstruction in the module is the one guarded line;loadConfig()setsapiKey: ""wheneverapiKeyErroris set, so on the refusal path no credential exists tosend.
paged(),tasks(),runs(),status()all funnel throughgetPage; the web-sessionclass uses username/password and no token.
child_process,execFile,execSync,spawnSync, barespawn, orforkanywhere inforgejo-ci; the onlyoccurrence of those names is inside the new test's own tripwire literal. I also checked the
fixture value never appears in
process.envafter a resolve.spaces trimmed; whitespace-only variable = unset; readable-empty → env, no error; newline-only →
env; empty + no env →
source: nonewith no error (empty is not a refusal); mode-0000 →refuse + no env fallback; missing → refuse; directory → refuse;
loadConfig()carriesapiKeyErrorwithapiKey === ""even when the environment is set; zerofetchcalls on therefusal path for both
tasks()andruns(); the no-key message names both variables; the filevalue is never exported into
process.env.env -u FORGEJO_API_KEY -u FORGEJO_API_KEY_FILE vitest run):71 passed | 11 skipped, 4 files passed + 1 skipped (
live.test.tsself-skips). The new filecontributes 19 tests.
tsc --noEmitclean. This matches the PR body exactly.55518b35×d881e47, node_modules symlinked, nothing installed):73 passed | 11 skipped — head's 71 plus the 2 tests #116 added. So the PR stays green on the
main it will actually merge into.
aa6d009'sforgejo.ts/types.ts:17 failed | 2 passed of 19 — exactly the number the PR reports.
resolveApiKey({FORGEJO_API_KEY_FILE: /run/user/1005/pi/forgejo-ci-reader.token})with no
FORGEJO_API_KEYin the map at all →source: key-file, non-zero length (40), filemode 600, no error. That is the order's goal: a session with nothing but the file route works.
I did not re-drive the network measurement, and I printed no value.
forgejo-ci51s); run #411pythonsuccess.Divergence review — draft
step2-keyfile-prep.md§5 vs the codeAll five claimed divergences are real, in the code, and consistent with the order (file first;
an unreadable named file refuses).
readKeyFileswallows the error and returnsundefined; its expression then falls through toprocess.env.FORGEJO_API_KEY. The code refusesand does not consult the environment. It cannot hurt the stated goal: in a file-only session
there is no environment value to fall back to, so the refusal only replaces a misleading "no
token" with a message naming the broken pointer.
readKeyFile(path): string | undefinedhas no channel to carry a reason;
resolveApiKeyreturns{apiKey, source, error}.hunk was a widened message only. The code makes the diagnostic mandatory and adds the
apiKeyErrorgate ingetPage— a hunk that did not exist in the draft, and the thing thatturns "unreadable refuses" from a comment into an enforced refusal at the request boundary.
environment answers", and states so. Precision: the draft's outcome for an empty file
coincided (fall back to env), so this divergence is the explicit separation of the two legs, not
a behaviour change on the empty path.
forgejo-ci/node_moduleshas vitest and tsc and the shipped suite runs (I ran it, counts above). One wording note, not a
finding: the draft's §3 sentence was about the sibling
forgejoextension, whosenode_modulesis not empty (it carries esbuild, pi, tsc, vite, …) althoughvitestis indeedabsent from its
.bin. The claim is accurate forforgejo-ci, and I reproduced its operativehalf ("the shipped suite runs here").
Concerns (non-blocking, no id)
resolveApiKeynow trims the value taken fromFORGEJO_API_KEY, which the previousprocess.env.FORGEJO_API_KEY || ""did not. This can only turn a previously-brokentrailing-newline export into a working one; Forgejo PATs carry no leading/trailing space. Recorded
so it is not a surprise later.
fork, or a barespawnreached without one of the namedimports, would slip past it. Fine as a tripwire, not as proof.
clients()resolves the config once per extension instance (pre-existing): a pointer repairedmid-session is not re-read until the extension is reloaded.
Exposure line
Read: PR metadata and CI status through the harness's forgejo tools (tool credential, not a shell
token); the full PR diff and all head blobs from local git objects in
/home/aaron/repos/pi-extensions-2682-getpr;working-files/infra-stability/orch19/step2-keyfile-prep.md§5;forgejo-ci/{forgejo,types,index}.ts,README.md,forgejo-ci.env.example,tests/api-key-file.test.ts.Ran:
vitest runandtsc --noEmitin throwaway worktrees at headd881e47and at the55518b35 × d881e47merge tree (node_modules symlinked, nothing installed); my own 13-case probe; thered reproduction against
aa6d009; a credential-free resolve of the staged 0600 file (source,length, mode only). Base:
main @ 55518b35obtained by anonymousgit ls-remote/fetch— notoken. Wrote: nothing in any repository; scratch directories under
/tmponly. No token valuewas read, printed, or exported.
Verdict of record — ld-reviewer — head
d881e47e0a: REQUEST_CHANGESmodel: omniroute/deepseek-flash
session: 01a1b40b-8da6-70d7-89c1-cc3630ef6d1c
Blockers
B1 — the second half of the order this change serves is missing, and its absence masks the file leg:
forgejo-ci/forgejo.tsloadEnvFiles()(head lines 50-78) is byte-identical to the base (git show d881e47:forgejo-ci/forgejo.tslines 50-78 == base lines 50-78), so it still re-injectsFORGEJO_API_KEYfrom~/.config/homelab/forgejo.envinto the process environment; the order (working-files/ldv0/tooling-order-livedev.md:61) puts that work in THIS change — "WITH THE FILE ROUTE, same PR …loadEnvFiles()re-injects~/.config/homelab/forgejo.envinto pi's environment, so pi gets Aaron's key back however the launcher starts it. Stop that. Both changes together finish key removal for pi. Read that a new session's environment has no FORGEJO_API_KEY and forgejo_ci_status still works" — and the PR body's "Not covered here" list does not name it, so the omission is silent rather than an argued deferral. Measured on the head withenv -u FORGEJO_API_KEY -u FORGEJO_API_KEY_FILE(vite-node,loadConfig()from the head worktree; only booleans and lengths printed): key absent at import → afterloadEnvFiles()FORGEJO_API_KEYis inprocess.env→loadConfig()answers with a 40-char key,apiKeyErrorunset; withFORGEJO_API_KEY_FILEdeleted the same 40-char key still answers. Consequence for acceptance: the order's completion read ("a new session's environment has no FORGEJO_API_KEY and forgejo_ci_status still works") cannot be performed against this head — a keyless session still gets a key from the env file, so the tool would answer even if the file leg were broken, which is exactly the masking the order says to remove.Measurements (this seat, independent)
Diff scope, base = current
main55518b3504e1867024427bc8a35999b7c1ed0b88(merge-baseaa6d009, headd881e47): 5 files, all insideforgejo-ci/—forgejo.ts(+79/-7),types.ts(+6),tests/api-key-file.test.ts(new, 244),README.md(+12/-2),forgejo-ci.env.example(+7/-2). Nothing outside the key-file leg; no scope creep.Main moved under this branch (
#116):git merge-tree --write-tree origin/main d881e47→ clean, single tree7e2bb01, no conflicts; the mergedforgejo.tscarries both#116'sisPending("pending")and this change'sapiKeyErrorguard atgetPage.Precedence and refusal semantics, traced in the merged tree rather than assumed:
resolveApiKey()trims the variable, opens withreadFileSync, trims the token; a file that opens and yields text wins over the environment (source: "key-file"); a throw from the open (missing, mode 0000, directory) returns{apiKey: undefined, source: "none", error}naming the path and the first line of the OS message, consulting nothing else; a readable-but-empty file falls through to the environment, exactly as the README says; neither source →source: "none"with no error of its own.Api.getPageraisescfg.apiKeyErrorbeforeURLSearchParams, before the URL, beforefetchImpl— my own probe (injected fetch counting calls) got the refusal thrown, message identical to the carried string, 0 requests issued; the no-token path likewise names both variables with 0 requests issued.apiKeyreaches exactly one place in the whole module — theAuthorizationheader insidegetPage(grep of the head) — so no authenticated path escapes the guard;Api.get(web/cookie transport) and the log/artifact tools never read it. No child process: nochild_process/spawn/execSync/execFilein non-test sources (the onlycross-spawnhits arepackage-lock.jsontransitive deps), so there is no argv or env leg.Divergences claimed against the draft (
working-files/infra-stability/orch19/step2-keyfile-prep.md§5, read in full), each checked against the code: (1) the refusal leg IS inverted — the draft'sreadKeyFilecatches and returnsundefinedso "a file that cannot be read … FALLS BACK", the head refuses; consistent with the order's loud leg. (2) The helper shape IS as claimed, and it does match the sibling:forgejo/api.ts:93-107returns{apiKey, source, note}; the head returns{apiKey, source, error}— same shape, reason-carrying. (3) The draft's "optional second hunk" IS promoted and does the work:CIConfig.apiKeyError+ thegetPageguard, so no credential is left in the config to send. (4) Empty ≠ unreadable IS real and is documented (README: "An empty (but readable) file is not an error"). (5) The draft's "node_modulesis empty, hence a raw-function probe" IS stale:forgejo-ci/node_moduleshas vitest 2.1.9 and tsc, both run. No divergence breaks the new-session goal: with onlyFORGEJO_API_KEY_FILEpresent the file leg resolves and answers (that leg is what B1 leaves unproven in production, not broken).Tests at the head, run by me in the head worktree (
.worktrees/HOMELAB-2682-ci-key-file,node_moduleslinked from the main clone because a fresh worktree has none):vitest run→ 71 passed, 11 skipped, 5 files (4 passed,live.test.tsself-skips) —api-key-file.test.ts19,forgejo.test.ts24,tools.test.ts12,regression.test.ts16;tsc --noEmitclean (no output). Test fixtures: both token literals are 20 and 19 chars and appear in no other file at the head — local fakes, not a reused real credential.Non-blocking, for the fix worker's same pass:
forgejo-ci/index.ts:9still lists "Env: FORGEJO_BASE_URL, FORGEJO_API_KEY," without the file variable, andforgejo-ci.env.example:8says the variable is the fallback "when that variable is unset/blank/empty" without the unreadable-file refusal that README:118-122 states correctly.Exposure — what I read and ran, and nothing else: this seat read the diff and merged tree from local git objects only in
~/repos/pi-extensions(noFORGEJO_API_KEYused for any read; no token printed, no token in any argv), read the draft §5, the order line 61, the builder's PR body andci-key-file-build-findings.md, andforgejo/api.ts; ranvitest runandtsc --noEmitin the head worktree and twovite-nodeprobes under/tmp/rv117(printing booleans, lengths, request counts only, with the credential variables unset). Base55518b3504e1867024427bc8a35999b7c1ed0b88, merge-baseaa6d00972ad9e4d2df4e9c68f24b6d19ccf69ba9, headd881e47e0a1eea848ebd198cb16c406cb959044f. Reviewer only: no file edited, no commit, no push.Verdict of record — ld-reviewer — head
a502276e67: APPROVEmodel: deepseek-flash
session: 01a1b452-3e28-702c-a118-436124958b51
Blockers
None.
A problem only blocks if it is a REQUEST_CHANGES verdict, a line under ## Blockers, or an exact BLOCKER : line.
What I verified at this head (git objects only)
Order item 1 —
FORGEJO_API_KEY_FILEfirst.resolveApiKey(env, readFile)trims the path ((env[API_KEY_FILE_ENV] ?? "").trim()), reads the file, and trims the token ((text ?? "").trim()) before using it. When the path is non-blank and yields a token, it returns inside theif (filePath)block —FORGEJO_API_KEYis not read on that path.{apiKey: undefined, source: "none", error}before the environment is consulted; there is no fallback leg out of that branch. The message interpolates the trimmed path, the variable name, and the first line of the reader's error only — never file contents, never the token.readFileSyncthrows for a missing path (ENOENT), an unreadable path (EACCES) and a directory (EISDIR), so all three reach that refusal; the new tests exercise the three as separate cases.FORGEJO_API_KEY— the code order puts the env read after theif (filePath)block, and the tests assert that fall-through for both.no CI read token: point FORGEJO_API_KEY_FILE at a readable 0600 file, or set FORGEJO_API_KEY — ....Api.getPagechecksthis.cfg.apiKeyErrorand then!this.cfg.apiKeybefore the query string, the URL, or anyfetchcall.paged()reachesfetchonly throughgetPage; in the whole fileapiKeyis used only insidegetPage, and only in theAuthorization: token …header — never in a URL.loadConfig()carriesresolved.errorintocfg.apiKeyError(added toCIConfigintypes.tsas optional).Order item 2 —
loadEnvFiles()stops re-injecting the key.ENV_FILE_NEVER_INJECT = new Set(["FORGEJO_API_KEY"])and the loadercontinues for that key, so no env file can put the value intoprocess.env; every other key is still copied (a new test assertsFORGEJO_WEB_USERNAME,FORGEJO_WEB_PASSWORD,FORGEJO_BASE_URLstill load from a staged file — i.e. this is not "stop reading the files").ENV_FILES, which is what makes a staged-fixture test possible; the default path is still exercised by a separate test that callsloadEnvFiles()with no argument.expect(process.env[KEY_VAR] === undefined).toBe(true),expect(cfg.apiKey === ENV_FILE_TOKEN).toBe(false)), so a failing assertion cannot echo a key; the token-shaped literals in the tests are placeholders.Deviation I am recording, not blocking on. The set holds
FORGEJO_API_KEYonly —FORGEJO_API_KEY_FILEis deliberately not skipped, while the order's second item names "(and the file variable)". The source comment states the reason (a path is not a credential; the loader keeps exporting the web credentials for the same reason). Consequence I verified in the code: on a host whoseforgejo.envitself namesFORGEJO_API_KEY_FILE, a session launched with neither variable would still resolve a token from that path through the loader — the credential value still never enters the environment, because the loader has no path that setsFORGEJO_API_KEYfrom a file. What that scenario does mask is a broken launcher pointer, which is the argument used to deny the key itself. I judged this a documented deviation that exposes no credential, not a blocker; the orchestrator should either accept it as stated or ask for the path variable to be skipped too.No credential on any output path.
forgejo.tscontains noconsole.*, noprocess.stdoutwrite and noJSON.stringify; the only use of the key is the request header. The refusal message carries the path and the reader's first error line. A source-scanning test fails ifchild_process/execFile/execSync/spawnSync/Bun.spawnever appear inforgejo.ts, which is the argv leg. The web-session login postsuser_name/passwordin a request body (pre-existing code, unchanged by this diff).Base and merge observation. The head's merge-base with
mainisaa6d00972ad9e4d2df4e9c68f24b6d19ccf69ba9, not55518b35: the branch predates the merge of PR #116. So the two-dot diff showsisPendinglosing"pending",STATUS_ICONlosingpending: "⏳"and two #116 tests disappearing — and neither commit in the range touches those lines (git showof each commit).git merge-tree --write-tree 55518b35 a502276completes with no conflict, and in the merged treeisPendingstill lists"pending",index.tsstill haspending: "⏳", andtests/forgejo.test.tsstill holds thetreats a commit-status pending as pendingcase. I record this because the diff a reader sees says the opposite of what a three-way merge produces, and any landing path that applies the two-dot diff literally would revert #116.Test hygiene note (non-blocking).
env-file-key.test.ts→ "the REAL homelab env files do not put a key back either" callsloadEnvFiles()with the default list, so it reads the operator's own~/.config/homelab/*.envinto the vitest process environment (its web credentials included) for that test;afterEachrestores the watched names. The test's own comment acknowledges the assertion is trivially satisfiable where those files are absent.I did not execute the suites: this checkout has no
node_modules.Exposure: git objects of Homelab/pi-extensions at 55518b35..a502276e only; no PR comments, census, or ticket thread read; my only write is this one verdict posted as ld-reviewer, preceded by one read of this PR's
branch_protections/main.Verdict of record — ld-reviewer — head
a502276e67: REQUEST_CHANGESmodel: omniroute/deepseek-flash
session: 01a1b452-3df6-702c-a118-435fda35a6c3
Blockers
loadEnvFiles()still injectsFORGEJO_API_KEY_FILEfrom the env files.ENV_FILE_NEVER_INJECT(forgejo-ci/forgejo.ts:86) holdsFORGEJO_API_KEYonly, so a session with neither variable exported still acquires a usable CI token from~/.config/homelab/forgejo-ci.env/forgejo.env. Item 2 of the order names the file variable too, and no test pins it either way. Verified at this head with a staged env file naming a 0600 token:loadEnvFiles([envFile])leftFORGEJO_API_KEYabsent fromprocess.env, injected the path, andresolveApiKey(process.env)then returnedsource: "key-file"carrying the staged token with no error. That is the "pi gets the key back however the launcher starts it" path the order removes, now carried by the pointer instead of the value — and it is the exact line the PR's ownforgejo-ci.env.exampleandforgejo-ci/README.mdnow tell the operator to write. The comment added at forgejo.ts:93-94 states the invariant "a credential must arrive from the caller's real environment or from the 0600 file named byFORGEJO_API_KEY_FILE, never from a file this module reads on its own"; the loader does not enforce it.What I verified at this head
Item 1 (file first), by reading the head and the new tests:
FORGEJO_API_KEY_FILE→FORGEJO_API_KEYinresolveApiKey: the file wins when both are present and answers alone when the variable is absent..trim()ed (trailing spaces in the variable; LF, CRLF and surrounding spaces in the token).apiKeyundefined,source: "none",errorset and naming the path, the environment not consulted. Three separate tests, one per case.FORGEJO_API_KEY_FILEandFORGEJO_API_KEY.getPagebefore the URL is assembled and beforefetchImplis called; the tests assert the fake server saw zero calls. TheAuthorizationheader is set at exactly one place in this file (getPage), andWebSessionauthenticates by cookie and never readsapiKey, so a broken pointer cannot reach the wire some other way. The swallow inApi.runByNumberis followed in the same loop byapi.runs, which re-raises the refusal, soforgejo_ci_statusandforgejo_ci_failuressurface it instead of reporting "no runs found".Item 2 (env files), by reading the head and by running this head's module against a staged fixture:
FORGEJO_API_KEYnamed in an env file is skipped, never reachesprocess.env, andresolveApiKeythen reportssource: "none". Pinned by tests.Hygiene:
err.messageonly, and the tests assert both token values are absent from it; the env-file suite asserts booleans so a failing assertion cannot echo a token.child_process/exec*/spawn*(a test pins that statically), and the token reaches the wire only as anAuthorization: token …header — nothing is placed in a query string.Not blocking (recorded, no effect on the vote)
docs/forgejo-ci.md:34still readsFORGEJO_API_KEY… "(~/.config/homelab/forgejo.envalso supplies it)", which this change makes false. The extension's own README and env example were updated; that page was not.tests/env-file-key.test.ts, "the REAL homelab env files do not put a key back either", callsloadEnvFiles()with the real default file list and assertsresolveApiKey(process.env).source === "none". It is not hermetic: on a machine whose env file namesFORGEJO_API_KEY_FILE— the line the env example now documents — the loader injects that path and the assertion fails. The same test encodes the expectation B1 declines to implement.FORGEJO_WEB_PASSWORD/FORGEJO_SESSION_COOKIEremain injectable from the env files by design (the added comment argues the log/artifact tools have no other source). The order named only the key and I do not vote on it; noted because it is a credential that still entersprocess.envfrom those files.Branch rule
GET /api/v1/repos/Homelab/pi-extensions/branch_protections/mainwith the staged ld-reviewer credential answered HTTP 403 —user should be an owner or a collaborator with admin write of a repository. I therefore did not readrequired_approvalsmyself, and I state that value as unverified by me. This verdict is posted as a PR comment, the route the sanctioned posting template records for this repository.Diff-scope note (git objects, not a finding)
The branch's merge-base with main is
aa6d0097(#109); main has since moved to55518b35(#116, commit72c18ab), which this branch does not contain. The two-dot diff55518b35..a502276etherefore showsisPendinglosing"pending"andSTATUS_ICONlosingpending. That region is unchanged by this branch relative toaa6d0097(git diff aa6d0097..a502276ehas no such hunk): it is main's commit showing up as a removal because the branch is behind. Landing must not put the branch's tree in place of main's, or #116 would be dropped.A problem only blocks if it is a REQUEST_CHANGES verdict, a line under ## Blockers, or an exact BLOCKER : line.
Exposure: git objects of Homelab/pi-extensions only — 55518b35..a502276e, the merge-base
aa6d0097for comparison, and file contents ata502276e— plus that head'sforgejo.tsexecuted from a throwaway /tmp copy against a staged fixture; the only file read under working-files/ was the posting template, copied with REPO/PR changed; no PR comments, no landing census, no ticket thread read; the only API reads wereGET /api/v1/userandGET /api/v1/repos/Homelab/pi-extensions/branch_protections/mainwith the staged ld-reviewer credential; my only write is this one verdict posted as ld-reviewer.Verdict of record — ld-reviewer — head
6fb394ce43: APPROVEmodel: omniroute/deepseek-flash
session: 01a1b463-a4aa-702c-a118-436c17115330
Blockers
None.
Fixed text (standing rule)
Fixed text, verbatim: "A problem only blocks if it is a REQUEST_CHANGES verdict, a line under ## Blockers, or an exact BLOCKER : line."
Branch rule
GET /api/v1/repos/Homelab/pi-extensions/branch_protections/main with the staged ld-reviewer credential answered HTTP 403 ("user should be an owner or a collaborator with admin write of a repository"), so
required_approvalson this repo could not be read by me. Per the documented state of pi-extensions (no branch protection on main) I posted this as a comment rather than a formal review.What I verified
forgejo-ci/forgejo.tscredential resolution.resolveApiKey()trims the pointer path and triesFORGEJO_API_KEY_FILEfirst; a readable file whose content trims to non-empty answerssource: "key-file".FORGEJO_API_KEYis consulted only when the pointer is unset/blank, or when the file is readable and empty (including newline-only) — I checked both cases in the source and they are covered by tests I ran. An unreadable, missing, or directory path returns{apiKey: undefined, source: "none", error}whose message names the path and the variable, and the environment is not consulted — the message contains only the path, no credential value. The no-token message names bothFORGEJO_API_KEY_FILEandFORGEJO_API_KEY.Api.getPage()raisesapiKeyErrorbefore it builds the query string or calls fetch, so nothing is sent behind a broken pointer; all REST calls in the module go throughgetPage(the otherfetchImpluses are inWebSession, which sends a cookie, not the token), andrunByNumber's catch returns null only until the followingrunscall re-raises the same error.loadEnvFiles().ENV_FILE_NEVER_INJECT = {FORGEJO_API_KEY, FORGEJO_API_KEY_FILE}is consulted after the KEY=VALUE match and before the "existing process env wins" guard, so both variables are skipped (includingexport-prefixed lines) while the remaining keys still load. I extracted this head'sforgejo-citree and ran its own suite:tests/env-file-key.test.tsandtests/api-key-file.test.tspass, 30 tests. The same two test files run against commita502276(the commit immediately before the FILE skip was added) fail 3 tests, including "a FILE pointer in an env file is not injected, so a keyless session resolves nothing" and "skips BOTH credential variables while every other key still loads"; againstd881e477 of the 11 fail. Credential assertions on those paths are boolean comparisons, not echoed values.forgejo-ci/README.md,forgejo-ci/forgejo-ci.env.exampleanddocs/forgejo-ci.mdno longer presentFORGEJO_API_KEY(orFORGEJO_API_KEY_FILE) as an env-file setting; each states that the loader skips both and that the credential is exported by the caller, with the_FILEroute named as the preferred one.docs/forgejo-ci.mdshows onlyFORGEJO_API_KEYas a table row and namesFORGEJO_API_KEY_FILEin that row's meaning.forgejo-ci/forgejo.tsandforgejo-ci/index.tsat this head contain noconsole.*,process.stdout, orprocess.stderrwrite, nochild_process/spawn/execuse (the head also carries a static tripwire test for that), and the token reaches only theAuthorizationrequest header — it is not interpolated into a URL, a query string, or argv.tsc --noEmiton the head'sforgejo-citree is clean, and the suite excludingtests/live.test.tspasses, 82 tests; I ran nothing that needs a live Forgejo or a real token.Non-blocking observations
forgejo-ci/tests/env-file-key.test.tscarries a docstring line saying "The PR's own env example tells operators to writeFORGEJO_API_KEY_FILEinto~/.config/homelab/forgejo.env", which contradicts this head'sforgejo-ci.env.example(it says neither variable belongs there). It is an internal test comment, not an operator-facing instruction.resolveApiKey()trims only the ends of the file content, so a file with more than one non-empty line yields a token containing an interior newline; the request then fails as an invalid header rather than naming the file. Outside the order's words, and it cannot expose the value.~/.config/homelab/*.envfiles pass trivially on a machine where those files do not name the credential variables; the fixture tests beside them are the ones that pin the behaviour.Exposure: git objects of Homelab/pi-extensions at aa6d0097..6fb394ce433a589cba395fa7e55da7c529ad238e (fetch, diff, log, and file contents at the head), plus that head's
forgejo-citree extracted to a temp directory to run its own vitest and tsc; the branch-protection read and this verdict were made with the staged ld-reviewer credential; I read no PR comments, no landing census, and no Plane ticket, and never touched FORGEJO_API_KEY; my only write is this one verdict posted as ld-reviewer.Verdict of record — ld-reviewer — head
6fb394ce43: APPROVEmodel: omniroute/deepseek-flash
session: 01a1b463-a478-702c-a118-436bdb529972
Blockers
None.
Fixed text (standing rule)
"A problem only blocks if it is a REQUEST_CHANGES verdict, a line under ## Blockers, or an exact BLOCKER : line."
What this seat read
Local git objects of
Homelab/pi-extensionsonly: a fetch, the rangeaa6d0097..6fb394ce(the branch's own work;aa6d0097is its merge-base withmain), the four commit messages, and file contents at the head —forgejo-ci/forgejo.ts,forgejo-ci/types.ts,forgejo-ci/tests/api-key-file.test.ts,forgejo-ci/tests/env-file-key.test.ts,forgejo-ci/tests/fake.ts,forgejo-ci/README.md,forgejo-ci/forgejo-ci.env.example,docs/forgejo-ci.md, plusforgejo/api.tsat the head for the sibling comparison the order names.Order item 1 —
FORGEJO_API_KEY_FILEread firstresolveApiKey(forgejo.ts:175-199) trims the pointer ((env[API_KEY_FILE_ENV] ?? "").trim()), reads the file, and only reachesenv[API_KEY_ENV]when the pointer is unset/blank or the file yields no token. File-before-env, as inforgejo/api.ts.(text ?? "").trim()— a trailing newline, a CRLF, and surrounding spaces all leave the same token; the path is trimmed too, so a trailing space in the pointer is harmless.catchbuilds afailurestring and returns{ apiKey: undefined, source: "none", error }— the function returns before thefromEnvread, so the environment is not consulted on that path. The message names the pointer variable, the path, and the first line of the errno detail only. A directory named by the pointer raisesEISDIRfromreadFileSyncand is caught by the samecatch; a missing file isENOENT, likewise caught.if (token)is false, control falls through tofromEnv. A file holding only a newline behaves the same.Api.getPagethrows`no CI read token: point ${API_KEY_FILE_ENV} at a readable 0600 file, or set ${API_KEY_ENV} …`.apiKeyErrorthrow and the missing-key throw are the first two statements ofApi.getPage, ahead ofURLSearchParams, the URL string, andthis.fetchImpl.paged,tasks,runs,runByNumberandcommitStatusesall reach the network only throughgetPage; I found no otherfetchImplcall site insideApi.forgejo/api.ts— which falls back to the environment on an unreadable file (api.ts:78-118) — is the divergence the order's own wording requires, and the code comment and README both state it.Order item 2 —
loadEnvFiles()no longer re-injects the credentialENV_FILE_NEVER_INJECT = new Set(["FORGEJO_API_KEY", "FORGEJO_API_KEY_FILE"]), andloadEnvFilesappliesif (ENV_FILE_NEVER_INJECT.has(key)) continue;immediately after key extraction — before the "existing process env wins" check and before any assignment, so neither variable from an env file can reachprocess.env.FORGEJO_BASE_URL,FORGEJO_SESSION_COOKIE,FORGEJO_WEB_USERNAMEandFORGEJO_WEB_PASSWORDpass the same loop unchanged (the skip is a two-name set, not a stop-reading-the-files change), and the code comment states why those two web-session values stay eligible.tests/env-file-key.test.ts"an env file may not name the key FILE either" assertsprocess.env[FILE_VAR] === undefined,resolveApiKey(process.env).source === "none", andresolved.apiKey === FILE_TOKENisfalse(boolean form) for a file the test has itself filled with a token; the sibling block asserts the same for the value variable, and "skips BOTH credential variables while every other key still loads" asserts the three non-credential keys do load.FORGEJO_API_KEY_FILEitself, callsloadConfig(), and getssource: "key-file"— the pointer is denied only as an env-file source; "a genuinely exported key is still the fallback" pins the exported-value path.Order item 3 — Docs
forgejo-ci/README.md: the auth table now readsFORGEJO_API_KEY_FILE, elseFORGEJO_API_KEY; the Configure section states "The API credential is not an env-file setting: export it in the caller's own shell/environment", shows the two exports (FORGEJO_API_KEY_FILE=/path/to/tokenfirst, the value as fallback), and states that both are skipped byloadEnvFiles()so an entry inforgejo.envorforgejo-ci.envis inert.forgejo-ci/forgejo-ci.env.example: both credential lines are gone from the block; the header says both must be EXPORTED, thatloadEnvFiles()skips both, and that a value written in the file for either is inert; the commented placeholder now reads "Neither credential variable belongs here".docs/forgejo-ci.md: the variable table says "Export it, or exportFORGEJO_API_KEY_FILEnaming a 0600 file", and a new paragraph says the env-file read "is for non-credential settings only" with both names skipped.~/.config/homelab/*.envfile; all three name the caller's environment as where the route is chosen.Order item 4 — behaviour tests, no value exposure
sourcereports, what a broken pointer does toprocess.env-backed resolution, andexpect(calls).toHaveLength(0)— the injectedfetchImplis never invoked behind a broken pointer (two separate tests, in both files).mkdtemp0700 directory as a 0600 file;tests/env-file-key.test.tscompares with booleans throughout, and the value comparisons intests/api-key-file.test.tsuse only those synthetic constants. No test reads a real credential.tests/fake.tsrecordspathname + searchper call and the credential travels in theAuthorizationheader the module has always used; a grep of the wholeforgejo-ci/tree at this head fortoken=,access_token,api_key=andprivate_tokenreturns nothing. A tripwire test assertsforgejo.tscontains none ofchild_process,execFile,execSync,spawnSync,Bun.spawn.Test evidence I produced
I exported the head tree to a throwaway directory under
/tmp(no write to the repository) and ranvitest run tests/api-key-file.test.ts tests/env-file-key.test.tsthere: 30 tests passed, 2 files passed. The three remaining test files could not importtypeboxin that throwaway copy — that package is absent from this checkout'snode_modules, so those three do not run here at all; it is a property of my staging, not of the diff.Observation, not a blocker
Api.runByNumberwraps its call intry { … } catch { return null }, so the credential refusal is swallowed there and the lookup falls through toapi.runs(...), which throws it; the refusal is therefore still reported, one call later. Worth knowing, nothing hidden.Branch rule
I could not read
GET /api/v1/repos/Homelab/pi-extensions/branch_protections/mainasld-reviewer: HTTP 403, "user should be an owner or a collaborator with admin write of a repository" — the same refusal a prior seat recorded, andpi-extensionsis documented as having no branch protection. So this verdict is posted on the comment route and is not a formal review.Exposure: git objects of Homelab/pi-extensions at aa6d0097..6fb394ce only, plus file contents at that head, the single canonical posting template, and a throwaway /tmp export of the head tree for the two test files; no PR comments, no landing census, no Plane ticket, no other working-files path, no images; my only write is this one verdict posted as ld-reviewer.
Closure of record — ld-reviewer — HOMELAB-2682 PR #117 — head
6fb394ce43model: omniroute/deepseek-flash
session: 01a1b469-73a2-702c-a118-436e6cae7995
Closure seat. No verdict is posted here, nothing was edited, committed or pushed. Both findings that the landing lock still reads as open on this head were re-verified by this seat against the code and tests at
6fb394ce, and each is closed below with its own line naming the comment that raised it.Finding raised in comment #23094 — verdict of record at head
d881e47e, REQUEST_CHANGES, finding labelled B1Claimed:
loadEnvFiles()was byte-identical to the base and still re-injectedFORGEJO_API_KEYfrom~/.config/homelab/forgejo.envinto the process environment, so a keyless session got the key back and the file route could not be proven at all.Verified by this seat at head
6fb394ce(local git objects and an independent probe, booleans and lengths only):loadEnvFiles()carried only the "existing process env wins" rule. At6fb394ceit skips every key inENV_FILE_NEVER_INJECT(forgejo-ci/forgejo.ts:100={"FORGEJO_API_KEY", "FORGEJO_API_KEY_FILE"}, the skip at:121, the singleprocess.envwrite at:127), so a value named in an env file never reaches the environment.FORGEJO_API_KEY: the key is absent afterloadEnvFiles(),resolveApiKey()answerssource: "none"with no error,loadConfig().apiKeyis empty andapiKeyErrorunset.~/.config/homelab/forgejo.env(which does nameFORGEJO_API_KEY; this seat read key NAMES only, never values) with the default file list: the key is still absent and the source isnone.tests/env-file-key.test.tsred.resolved #23094
resolved B1 — the finding raised in comment #23094 is closed: verified fixed at head
6fb394ce43Finding raised in comment #23120 — verdict of record at head
a502276e, REQUEST_CHANGES, finding labelled B1Claimed:
ENV_FILE_NEVER_INJECTheldFORGEJO_API_KEYonly, soFORGEJO_API_KEY_FILEwas still injected from the env files and a session with neither variable exported still acquired a working token (source: "key-file", no error), carried by the pointer instead of the value; no test pinned it.Verified by this seat at head
6fb394ce:["FORGEJO_API_KEY"](a502276e:forgejo-ci/forgejo.ts:86); at6fb394ceit is["FORGEJO_API_KEY", "FORGEJO_API_KEY_FILE"].loadEnvFiles(), andresolveApiKey()answerssource: "none",apiKeyundefined, no error. Naming both credential variables in one file denies both while the rest of that file still loads (FORGEJO_BASE_URLapplies).source: "key-file"with the token and no error, and an unreadable pointer still refuses, naming the path, with a key named in an env file not rescuing it.FORGEJO_API_KEY_FILEback out of the deny set in a throwaway copy of this head turns 3 of the 11 tests intests/env-file-key.test.tsred.resolved #23120
resolved B1 — the finding raised in comment #23120 is closed: verified fixed at head
6fb394ce43Other measurements at this head
vitest runinforgejo-ciat6fb394ce: 82 passed, 11 skipped (5 files pass;live.test.tsself-skips).tsc --noEmit: clean.B1, so the lock's id-ed closure arm cannot tell one from the other by label alone. That is why each finding above carries its own line naming the comment that raised it.assessFindingGrammarat this head reports one livemalformed-blockersproblem on comment #23118 (its## Blockerssection carries the standing fixed text, which the lock reads as template vocabulary). This closure does not cure it — its lawful exit is a later exact-head verdict from the same reviewer, which only a reviewer posts — and the reason its two later exact-head verdicts do not supersede it is that #23118 declaresmodel: deepseek-flashwhile #23132/#23133 declaremodel: omniroute/deepseek-flash, so the lock's own-later-word test does not match them.A problem only blocks if it is a REQUEST_CHANGES verdict, a line under ## Blockers, or an exact BLOCKER : line.
Exposure — what this seat read and ran, and nothing else: the two raising comments (#23094, #23120) and PR #117's full comment set through the staged ld-reviewer credential (the comment set is what the dry-run needs); local git objects of Homelab/pi-extensions at
6fb394ce,d881e47eanda502276e(git show,git log), plus a throwaway copy of this head under /tmp for the two mutation checks; key NAMES of~/.config/homelab/forgejo.envandforgejo-ci.env(values never printed); the deployed lock compiled from~/.pi/agent/extensions/forgejo/landing.tsfor the dry-run;vitest runandtsc --noEmitin the head worktree. No token was printed, put in argv, or placed in a URL. This seat edited no file, made no commit and no push, and its only write is this one closure comment posted as ld-reviewer.Verdict of record — ld-reviewer — head
6fb394ce43: APPROVEmodel: deepseek-flash
session: 01a1b478-2e8c-702c-a118-4379d67a4732
Blockers
None.
Exposure
git objects of Homelab/pi-extensions at aa6d0097..6fb394ce; the comment set read for the dry-run via the staged ld-reviewer route; my only write is this verdict posted as ld-reviewer.