HOMELAB-2479: feat(forgejo): seat-post helper — tracked ld-reviewer token route #122
No reviewers
Labels
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Homelab/pi-extensions!122
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-2479-seat-post-helper"
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-2479 — the tracked seat-posting helper (El Director ruling, fix (ii) of
seat-posting-bridge-brief.md). GATE TOOLING: this lands only on El Director's go through the overseer.What it does
Adds
forgejo/seat-post.ts, besideverdict-cli, the single tracked route by which a review seat on the mgmt VM obtains theld-reviewertoken and posts. It mints a short-lived OpenBao token with roleld-reviewer-post(the role added by the fix-(i) PR, Homelab/prod #1171), reads exactly one KV path, and delivers the token to the two sanctioned consumers. Aaron's account posts nothing; no helper other than this one is used for seat posting.The brief's (ii) list, bullet for bullet
POST {vault}/v1/auth/oidc/login {role: "ld-reviewer-post", jwt: <zitadel id token>}; the Zitadel JWT is minted in-process (node:crypto RS256) from the operator key file~/.config/openbao-operator-key.json, the proven pattern ofworking-files/homelab-2518/h2578-landing/read-landing-cred.py(the quarantinedread-reviewer-cred.pywas not read, copied, or called).GET kv/data/ld/forgejo-reviewer(fieldtoken) with the login token;--kv-pathis sanity-guarded tokv/data/ld/forgejo-<name>and nothing else. No OpenBao writes; no list; no other path.verdict-cli --token-file -), never argv.FORGEJO_TOKEN_FILE_<IDENTITY>naming a 0600 staged file (a path is not a credential, so the no-token-parameter contract holds). It wins over the mounted file; configured-but-unreadable/empty refuses rather than falling through. The helper drives the exportedcommentAs— the exact code path the host tool'sexecutenow delegates to (registerCommentAsToolmoved toindex.ts, unchanged schema/description semantics, socomment-as.tsstays bare-import-free and runs under plainnode --experimental-strip-typesin the deployed source-only drop;api.ts/comment-as.tsrelative specifiers switched.js→.tsaccordingly).mkdtempdir, removed in afinallyon every exit path. The verdict route writes no file at all (stdin).FORGEJO_API_KEYdeliberately excluded even when set) carry no credential.GET /usermust equal--identity, defaultld-reviewer) and prints exactly what WOULD post (--jsonsupported). Posting requires explicit--post. No live posting happens by default.pi-key-rotation/notes.md, mentioned not edited).Exit codes
0 ok (+ revoke proven) · 1 refusal · 2 auth refused · 3 usage · 4 revoke not proven dead.
Tests
tests/seat-post.test.ts(9 tests: read-only plan, stdin piping + child-env hygiene, staged-file comment route with real-fs deletion, identity mismatch, failed-revoke exit 4, zitadel refusal, usage exit 3, output-scrub) andtests/comment-identity-tokenfile.test.ts(6 tests: resolution, precedence over the mount, configured-but-unusable refusals, end-to-end post through the staged file). Full suite:tsc --noEmitclean; 1199 passed, 8 failed — the same 8 fail at pristineorigin/main(verified in a baseline worktree; pre-existing and unrelated). Smoke-tested under plainnode --experimental-strip-types(usage exit 3; live login attempt correctly stops at HTTP 400 because roleld-reviewer-postdoes not exist until fix (i) lands).Status and bridge note
SEAT HELPER PR READY-NOT-LANDED— gate tooling; lands only on El Director's go. Until (i) and (ii) land and every brief names this helper, bridge seats post only viaverdict-cli --token-file /run/secrets/ld/identities/ld-reviewer/tokeninside sbx-lab, logged asbridge: sbx-lab verdict-cli, prerequisite 4b failing. Rotation note: the ld-reviewer token went through argv (seats 420d543f, 974729fa); consumers are mapped first, right after (ii) lands —pi-key-rotation/notes.md.Verdict: REQUEST_CHANGES
model: omniroute/deepseek-flash
head:
b9a2bd8772session: bridge-seat-1-122-b9a2bd87
Bridge review seat 1 — HOMELAB-2479 seat-post helper (head
b9a2bd87)Independent pass. I read
forgejo/seat-post.tsat the pinned head in a cleanworktree, verified each PR-body bullet against the code, and ran the suite at
the head and at
eb5576d3myself. No prior verdict, PR-body claim or briefingnote was taken as evidence.
Verified in the code (matches the contract)
POST {bao}/v1/auth/oidc/login {role, jwt}with the roledefaulting to
ld-reviewer-post; the RS256 assertion is minted in-process(
createSign("RSA-SHA256"),kid/iss/subfrom the operator key file,60 s exp) and only ever goes to the Zitadel token endpoint over TLS. The full
set of endpoints the helper itself touches is: Zitadel
/oauth/v2/token,Bao OIDC login, one KV read, Bao revoke-self/lookup-self, and Forgejo
GET /api/v1/user. Nothing else.GET {bao}/v1/<kvPath>, fielddata.data.token. No list,no write anywhere.
--kv-pathis guarded by^kv/data/ld/forgejo-[a-z0-9][a-z0-9-]*$, and the default is derived from--identity(ld-reviewer→kv/data/ld/forgejo-reviewer).--token-file -and nothing elsecredential-shaped; the token goes to the child on stdin. I checked
verdict-cli.tsat this head and confirmed-is really read from stdin(line ~206,
args.tokenFile === "-" ? await io.readStdin() : …), so theroute actually works rather than failing closed at the child.
PATH,HOME,FORGEJO_BASE_URL, timeout, proxy vars).FORGEJO_API_KEY,VAULT_TOKEN,BAO_TOKENand everyFORGEJO_TOKEN_*are absent by construction, andmain()also deletesFORGEJO_API_KEYfromprocess.envso the in-processcommentAscall cannot inherit it.mkdtempdir,named through
FORGEJO_TOKEN_FILE_<IDENTITY>, in-processcommentAs(thehost tool's own body), dir removed in a
finally.doRevoke()withrevokeState.done, called fromthe
finally, so a KV-read failure or any throw still revokes (themint.sh:31-36trap gap is covered by thetry/finallyhere).deadisrevoke-self 2xx AND lookup-self 403/404, verdicts taken from HTTP status,and a non-dead verdict overrides the exit code to 4. Reported on every path.
log.out/log.say,which scrub by value (both secrets, ≥8 chars) and by shape
(
[A-Za-z0-9_\-+/=]{40,}). Error messages build URLs viaredactUrl, andrefusals quote only the KV path and a login, never a token.
--postis required to write; without it the helperlogs in, reads, asserts
GET /userequals--identity(defaultld-reviewer), and prints the plan text or--json. Confirmed by testexecution, not only by reading.
documented;
postVerdictclamps a child's out-of-range code to 1.GAP on every run that gets past argument parsing, and is stated in the PR
body. Accepted per the ruling.
Tests, run by me (behaviour, not trivia)
forgejo/tests/seat-post.test.tsasserts outcomes: token on child stdin andabsent from child argv and child env, the staged write + real-fs dir removal
existsSync === false, exit 1 with revoke still reported on identitymismatch, exit 4 when
lookup-selfanswers 200, exit 3 before any networkcall, and no secret in any emitted line.
comment-identity-tokenfile.test.tscovers resolution and precedence. Both suites pass at this head.
Head-vs-base verification of the CI claim (my own run)
tsc --noEmit: clean at the head (exit 0, no diagnostics) and clean ateb5576d3— same typescript 5.9.3 and the repo's own dependency tree.vitest run: head 1199 passed / 8 failed (50 files); base 1184 passed/ 8 failed (48 files). The 8 failures are the same names at both refs:
7 in
tests/comment-operator-refuse-2682.test.tsand 1 intests/merge-identity.test.ts. The delta is +15 tests, all passing (9seat-post + 6 comment-identity-tokenfile).
zero new failures from this diff. CI run #436 fails at
tsc --noEmitin theforgejoandplanejobs;planeis untouched by this PR and tsc isclean at both refs locally, so that job-level failure is not introduced by
this diff (runner/environmental), not a code regression. I did not treat the
red CI as an approval signal either way.
Blocker (1) — the staged reviewer token survives SIGINT/SIGTERM/SIGHUP on the comment route
The bullet says the staged 0600 file is "removed in a
finallyon every exitpath". It is not: on a signal the helper's handler ends with
process.exit(130), which terminates the process without unwinding thepending
finallyinpostComment. The Bao token is revoked on that path(so the minted token is handled correctly), but the Forgejo reviewer token —
a long-lived, write-capable credential — is left in
<tmpdir>/seat-post-XXXXXX/tokenwith no owner process and no expiry.This is not theoretical; I reproduced the exact pattern:
The window is the whole posting phase of
commentAs(GET /user → POST →read-back — several round-trips), i.e. exactly the window an interrupted
kubectl exec, a seat timeout or a Ctrl-C lands in. And no test can catch it:the harness stubs
onSignal: () => undefined, so the signal path — revokeand cleanup — is entirely unexercised.
Minimal fix (either is enough, tests should then drive the handler and assert
the dir is gone):
process.exit, e.g.for (const d of liveDirs) removePath(d)right afterreportRevoke(...); orprocess.on("exit", () => { for (const d of liveDirs) rmSync(d, {recursive:true, force:true}); }),which runs on
process.exitand on uncaught throws.The same handler ordering issue also makes the message misleading in one case:
a signal that lands while the
finally's own revoke is in flight seesrevokeState.verdict === nulland prints "nothing was minted, so there isnothing to revoke" — false for that run. Worth fixing with the same change.
Concerns (not blocking)
--kv-pathis name-guarded, not path-allowlisted:kv/data/ld/forgejo-landingsatisfies
^kv/data/ld/forgejo-[a-z0-9][a-z0-9-]*$. The narrowness thereforerests entirely on the policy of role
ld-reviewer-post(fix (i), prod #1171),not on this client. Worth confirming that role denies sibling
ld/forgejo-*paths, and worth a test asserting the guard rejects a path outside the prefix.
--roleis caller-settable free text (validated only as a name). A run couldask for a broader role than
ld-reviewer-post; again server-side policy is thereal boundary.
[A-Za-z0-9_\-+/=]{40,}also masks a 40-hex--head-shain the read-only/
--jsonoutput, so the plan prints[REDACTED]where the shashould be. Cosmetic, but it removes evidence a seat may want.
invites a caller retry that would double-post. A one-line note in the message
("the post already happened; verify before retrying") would prevent it.
child.stdin.writehas noerrorlistener; an EPIPE on an early child exitwould be an uncaught event. Timing makes it very unlikely (the write happens
immediately after spawn), so this is robustness only.
This verdict is REQUEST_CHANGES: the single finding above (with a test that
drives the signal handler and asserts the staging dir is gone) is what must be
fixed before merge. Everything else in the contract checks out.
Overlap note for seat 2: I did not review the
comment-as.ts/index.ts/api.tsrestructure in depth; I only confirmed the exportedcommentAs+identityTokenFileEnvNamesurface thatseat-post.tsdepends on, and that the.js→.tsspecifier change does not breaktsc(it is clean, above).Verdict: APPROVED
model: omniroute/deepseek-flash
head:
54b9d214f9session: bridge-seat-2-122-54b9d214
Seat 2 verification — APPROVED at
54b9d214I read the delta
b9a2bd87...54b9d214and the whole PR at head54b9d214f9627c99e6e73fe9aaa2c91279102e7f(baseeb5576d3) from my own checkout, and I reproduced the fix and the pre-fix failure myself. I formed this verdict from the code and from that reproduction, not from any prior verdict or report.Head verified: my checkout pins
54b9d214f9627c99e6e73fe9aaa2c91279102e7f, whose only parent isb9a2bd8772768b8992d3d4ffd33124308be68944— the delta I read is exactly the whole difference from the reviewed head.Finding 1 from review #1973 — fixed, and verified by reproduction
I checked this from outside the process, because the leak is only visible there:
b9a2bd87seat-post.ts+ the new test): the new signal test failed (expected 130 to be 143), and the child's staging directory was left on disk —/tmp/seat-post-XXXXXX/token, mode-rw-------, 20 bytes. I then deleted that leftover. That is the reported leak, reproduced by me rather than taken on trust.54b9d214): the same test passes, the child exits 143,existsSync(stagedDir)is false, the mock server recordedPOST /v1/auth/token/revoke-self, stderr carriesrevoked: true, and noseat-post-<random>directory remains in the tmpdir afterwards. A test that fails before the fix and passes after it is real coverage, not a vacuous pass.Mechanism, judged on its merits:
liveStagingDirsis created after the Bao login succeeds, so the only signal window is one where a revoke is still owed — correct stage ordering.postCommentadds the dir to the set immediately aftermakePrivateDir(), before the token is written, and synchronously; a Node signal handler cannot interleave mid-statement, so the set knows about a dir no later than the instant it can hold a secret.await doRevoke()and beforeprocess.exit. Removal isrmSync(..., { recursive: true, force: true })— synchronous — so the credential file is gone before any awaited call, not merely before exit. That is the right order: cleanup must not depend on a revoke round-trip that can run to the 30 s request timeout.reportRevoke(await doRevoke())keeps the revoke proven on the signal path, anddoRevoke'srevokeState.donelatch means the signal path and a pendingfinallycannot double-revoke — the second caller gets the cached verdict.commentAsresolves duringawait doRevoke(), thefinallyrunsliveDirs.delete(dir)thenremovePath(dir)on an already-removed path, andforce: truemakes that a no-op; the handler'sfor..ofover the set has finished before the firstawait. Worst case is a redundant no-oprm, never a throw.try/catchis best-effort by design and documented as such; thefinallyremains the primary path.The
stdin.on("error")addition is sound. It is attached insidespawnChildbeforepostVerdictcallschild.stdin.write(...), so an EPIPE from an early-dead child is absorbed instead of becoming an uncaught'error'event that would take the helper down with an unrevoked token. The child's non-zero exit code is what reports the failure, and the code does exactly that.Whole-PR contract re-verified at this head
GET ${bao}/v1/${args.kvPath}, with--kv-pathregex-pinned tokv/data/ld/forgejo-<name>. Nothing else touches KV.--token-file -and writes the token to stdin (asserted verbatim);childEnvironmentis an allowlist excludingFORGEJO_API_KEY,VAULT_TOKEN/BAO_TOKENand everyFORGEJO_TOKEN_*; the comment route copies the parent env, deletesFORGEJO_API_KEY, and names the staged path throughFORGEJO_TOKEN_FILE_<IDENTITY>— a path, not a value.main()also deletesFORGEJO_API_KEYfrom its ownprocess.envfirst. Every emitted line passesscrub()(by value and by 40+ char shape).mkdtempSync(0700) plusopenSync(.., 0o600)with an explicitfchmodSync, removed by thefinallyon normal paths and by the handler on signals — verified both ways above.deadrequires revoke-self 2xx AND lookup-self 403/404; a lookup answering 200 isNOT proven deadand forcesEXIT.revokein thefinally, dominating an otherwise-successful post. Tested.--post; otherwise the helper asserts identity and prints what would post, with a test proving zero Forgejo writes.GET /api/v1/userwith the KV token, compared case-insensitively to--identity; a mismatch refuses (exit 1) and still revokes.Suite and typecheck at this head (my run):
vitest run tests/seat-post.test.ts→ 10/10 pass;tsc --noEmit→ clean. The fullforgejosuite runs 1208 tests with 8 red (comment-operator-refuse-2682.test.ts×7,merge-identity.test.ts×1); I ran those same two files at baseeb5576d3in a separate worktree and got a byte-identical set of failing test names, so they pre-exist this PR and are unrelated to its paths. Not attributable here.Blockers: None.
resolved 1
Two notes, non-blocking. The swallowed
stdinerror loses the EPIPE reason, though the child's exit code still reports the failure. Best-effort removal cannot survive SIGKILL; no in-process code can.Verdict: APPROVED
model: omniroute/deepseek-flash
head:
54b9d214f9session: bridge-seat-3-122-54b9d214
Independent review — second seat, gate-tooling panel
I read the change myself at the pinned head
54b9d214f9627c99e6e73fe9aaa2c91279102e7f, in my own checkout(
git worktree add … 54b9d214 --detach), and ran the toolchain there. I did not use the PR body, any earlierverdict, or another seat's summary as evidence.
Head/base note for the record: my brief pinned the base as
eb5576d3…, but that commit is not an ancestor ofthis head (
git merge-base --is-ancestor eb5576d3 54b9d214→ 1). The real shape is54b9d214 → b9a2bd87 → 7953855 (main), so I diffed against the merge-base7953855. Against that base the changeis 7 files, +1549/−81:
seat-post.ts(new, 731),seat-post.test.ts(new, 512),comment-identity-tokenfile.test.ts(new, 161),comment-as.ts,index.ts,api.ts,package.json. It also meansmain has moved ahead of this base; that is a merge-ordering matter for El Director, not a defect in the diff.
What I verified, and how
seat-post.ts— the contract, from the source at head.GET /v1/<kvPath>with the defaultkv/data/ld/forgejo-reviewerfor--identity ld-reviewer, taking thetokenfield, and--kv-pathis regex-pinned tokv/data/ld/forgejo-<name>so the helper cannot be pointed at an arbitrary path.
ld-reviewer-post, TTL 10m) via OIDC, with the RS256 assertion signed in this processfrom the operator key file (
createSign("RSA-SHA256"),kidfrom the key file, 60sexp). Theoperator-key-is-the-login-identity KNOWN GAP is emitted on stderr on every run that gets past
parseArgs, and thetest asserts that string. Confirmed by reading the run path, not by taking the header comment's word.
--token-file -in argv; argv carries no credential (testasserts the argv string does not contain the token and that the child's stdin is exactly the token line).
mkdtempSync(tmpdir(), "seat-post-")(0700 by construction) +openSync(…, 0o600)with an explicitfchmodSync, named throughFORGEJO_TOKEN_FILE_<IDENTITY>, then the exportedcommentAsbody — the samefunction the host tool now delegates to (
index.tsregisterCommentAsTool.executeis a one-line delegate).childEnvironment), which excludesFORGEJO_API_KEY,VAULT_TOKEN/BAO_TOKENand everyFORGEJO_TOKEN_*;main()deletesFORGEJO_API_KEYfromthe helper's own env too. Emission goes through one
scrubthat redacts every learned secret by value (len ≥ 8)and long token-shaped runs by shape.
finallyrunsdoRevoke()+reportRevoke()on every unwind,dead = revoke-self 2xx AND lookup-self 403/404, and!deadforcesEXIT.revoke(4). Alookup-self200 is tested explicitly as exit 4.--postthe helper logs in, reads the KV, asserts identity viaGET /userand printsthe plan; the tests assert zero Forgejo writes. Exit codes 0/1/2/3/4 as documented.
The signal fix — the delta commit.
liveStagingDirsis declared beforeio.onSignal(...)is registered, added to inpostCommentbefore the write andremoved in its
finally, so the set holds exactly the directories that currently exist.removePaths the live dirs beforereportRevoke(await doRevoke())and beforeprocess.exit, with per-dirtry/catchso cleanup can never keep the process alive. Exit codes are nowsignal-specific: SIGTERM → 143, SIGHUP → 129, SIGINT → 130 (was a flat 130). Revocation on the signal path is
preserved.
spawn(process.execPath, ["--experimental-strip-types", ../seat-post.ts, …])against a real
node:httpserver on 127.0.0.1, with the harness not involved. It waits for the POST to be inflight, polls
/tmpfor aseat-post-XXXXdir that actually contains atokenfile (prefixesseat-post-test-and
seat-post-sigtest-excluded), asserts the file is really there, sends a realSIGTERM, then asserts exit143, that the staged dir is gone, that
POST /v1/auth/token/revoke-selfwas received, and that stderr saysrevoked: true. That is the right shape of test for a signal path: a stubbedonSignalharness structurallycannot reach it.
FORGEJO_TOKEN_FILE_<IDENTITY>incomment-as.ts.process.envleft in the module is only adefault parameter — inside
commentAsthe passedenvis threaded toresolveIdentityCredential, the ambient-keycomparison and
identityBaseConfig. I checked each of those call sites.a path, and the source is reported as
identity-token-file-envindetails.Tests assert behaviour, and they pass. Each test file maps to a contract bullet (read-only default, verdict-route
stdin/argv/env, comment route staging + deletion, identity-mismatch refusal that still revokes, unproven revoke → exit
4, Zitadel refusal → exit 2, usage → exit 3, no secret in emitted lines, the signal test above).
My own numbers
npx tsc --noEmitinforgejo/at54b9d214: clean, exit 0.npx vitest run tests/seat-post.test.ts tests/comment-identity-tokenfile.test.ts: 2 files, 16 tests passed(10 seat-post incl. the SIGTERM test at 751ms, 6 tokenfile). I did not re-run the full suite in my window, so I am
not claiming a whole-suite number.
node --experimental-strip-typesresolution check on the helper's own import graph:seat-post.ts→comment-as.ts→api.ts→landing.ts/types.tscontains no bare npm imports, and all specifiers in thatgraph are
.ts. That is what makes the source-only drop runnable under plain node, and it is why movingregisterCommentAsToolintoindex.ts(which keepstypebox) is load-bearing rather than cosmetic.Blockers: None.
Behaviour I checked and accept as correct
outcome on stderr, so a terminated run reports itself as terminated by that signal rather than as the generic
revoke-failure code 4. That is how a shell wrapper reads the exit status, and the revoke outcome is still reported
on that path.
end, which is the mechanism under test.
Scope
Gate tooling (a posting helper for review seats). Merge ordering — including that main has advanced past this PR's
base — is El Director's process, not something I am holding this review on.