HOMELAB-2479: feat(forgejo): seat-post helper — tracked ld-reviewer token route #122

Merged
pi-landing merged 2 commits from plane/HOMELAB-2479-seat-post-helper into main 2026-11-10 03:44:40 +00:00
Collaborator

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, beside verdict-cli, the single tracked route by which a review seat on the mgmt VM obtains the ld-reviewer token and posts. It mints a short-lived OpenBao token with role ld-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

  • Role login — 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 of working-files/homelab-2518/h2578-landing/read-landing-cred.py (the quarantined read-reviewer-cred.py was not read, copied, or called).
  • Reads — GET kv/data/ld/forgejo-reviewer (field token) with the login token; --kv-path is sanity-guarded to kv/data/ld/forgejo-<name> and nothing else. No OpenBao writes; no list; no other path.
  • Piping to verdict-cli — the token is written to the child's stdin (verdict-cli --token-file -), never argv.
  • forgejo_comment_as option (the one chosen) — a token-file option: new first-class resolution source 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 exported commentAs — the exact code path the host tool's execute now delegates to (registerCommentAsTool moved to index.ts, unchanged schema/description semantics, so comment-as.ts stays bare-import-free and runs under plain node --experimental-strip-types in the deployed source-only drop; api.ts/comment-as.ts relative specifiers switched .js → .ts accordingly).
  • No undeleted files — the only file written is the staged 0600 token inside a 0700 mkdtemp dir, removed in a finally on every exit path. The verdict route writes no file at all (stdin).
  • No argv / no log tokens — every emitted line passes defensive redaction by value (both the Bao token and the Forgejo token) and by shape; child argv and the allowlisted child env (FORGEJO_API_KEY deliberately excluded even when set) carry no credential.
  • Revoke on every path — try/finally plus SIGINT/SIGTERM/SIGHUP handlers; the revoke result is reported on every path; a revoke that is not proven dead (revoke-self 2xx and lookup-self 403/404, verdict from HTTP status like the landing helper) is exit 4.
  • Read-only default — the installed default logs in, reads the KV, checks the target identity (GET /user must equal --identity, default ld-reviewer) and prints exactly what WOULD post (--json supported). Posting requires explicit --post. No live posting happens by default.
  • Login identity + known gap — the login identity is still the operator key file, stated on every run and here as a KNOWN GAP: El Director's rotation and narrow-key plan covers it (tracked in 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) and tests/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 --noEmit clean; 1199 passed, 8 failed — the same 8 fail at pristine origin/main (verified in a baseline worktree; pre-existing and unrelated). Smoke-tested under plain node --experimental-strip-types (usage exit 3; live login attempt correctly stops at HTTP 400 because role ld-reviewer-post does 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 via verdict-cli --token-file /run/secrets/ld/identities/ld-reviewer/token inside sbx-lab, logged as bridge: 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.

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`, beside `verdict-cli`, the single tracked route by which a review seat on the mgmt VM obtains the `ld-reviewer` token and posts. It mints a short-lived OpenBao token with role `ld-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 - **Role login** — `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 of `working-files/homelab-2518/h2578-landing/read-landing-cred.py` (the quarantined `read-reviewer-cred.py` was not read, copied, or called). - **Reads** — `GET kv/data/ld/forgejo-reviewer` (field `token`) with the login token; `--kv-path` is sanity-guarded to `kv/data/ld/forgejo-<name>` and nothing else. No OpenBao writes; no list; no other path. - **Piping to verdict-cli** — the token is written to the child's **stdin** (`verdict-cli --token-file -`), never argv. - **forgejo_comment_as option (the one chosen)** — a **token-file option**: new first-class resolution source `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 **exported `commentAs`** — the exact code path the host tool's `execute` now delegates to (`registerCommentAsTool` moved to `index.ts`, unchanged schema/description semantics, so `comment-as.ts` stays bare-import-free and runs under plain `node --experimental-strip-types` in the deployed source-only drop; `api.ts`/`comment-as.ts` relative specifiers switched `.js` → `.ts` accordingly). - **No undeleted files** — the only file written is the staged 0600 token inside a 0700 `mkdtemp` dir, removed in a `finally` on every exit path. The verdict route writes no file at all (stdin). - **No argv / no log tokens** — every emitted line passes defensive redaction by value (both the Bao token and the Forgejo token) and by shape; child argv and the allowlisted child env (`FORGEJO_API_KEY` deliberately excluded even when set) carry no credential. - **Revoke on every path** — try/finally plus SIGINT/SIGTERM/SIGHUP handlers; the revoke result is reported on every path; a revoke that is not proven dead (revoke-self 2xx **and** lookup-self 403/404, verdict from HTTP status like the landing helper) is **exit 4**. - **Read-only default** — the installed default logs in, reads the KV, checks the target identity (`GET /user` must equal `--identity`, default `ld-reviewer`) and prints exactly what WOULD post (`--json` supported). Posting requires explicit `--post`. No live posting happens by default. - **Login identity + known gap** — the login identity is still the **operator key file**, stated on every run and here as a KNOWN GAP: El Director's rotation and narrow-key plan covers it (tracked in `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) and `tests/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 --noEmit` clean; 1199 passed, 8 failed — the same 8 fail at pristine `origin/main` (verified in a baseline worktree; pre-existing and unrelated). Smoke-tested under plain `node --experimental-strip-types` (usage exit 3; live login attempt correctly stops at HTTP 400 because role `ld-reviewer-post` does 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 via `verdict-cli --token-file /run/secrets/ld/identities/ld-reviewer/token` inside sbx-lab, logged as `bridge: 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`.
HOMELAB-2479: feat(forgejo): seat-post helper — tracked ld-reviewer token route
Some checks failed
CI / pr-title (pull_request) Successful in 1s
CI / forgejo-ci (pull_request) Successful in 1m1s
CI / pi-otel-telemetry (pull_request) Successful in 1m7s
CI / skill-args (pull_request) Successful in 59s
CI / todo (pull_request) Successful in 1m17s
CI / ask-user-question (pull_request) Successful in 1m35s
CI / refocus (pull_request) Successful in 1m44s
tests / python (pull_request) Successful in 38s
CI / redact (pull_request) Successful in 46s
CI / mcp-bridge (pull_request) Successful in 1m20s
CI / nats-intercom (pull_request) Successful in 1m24s
CI / pi-friction (pull_request) Successful in 1m33s
CI / pi-swarm-suite (pull_request) Successful in 3m54s
CI / forgejo (pull_request) Failing after 11m17s
CI / plane (pull_request) Failing after 11m18s
b9a2bd8772
ld-reviewer requested changes 2026-11-10 00:51:18 +00:00
Dismissed
ld-reviewer left a comment
Collaborator

Verdict: REQUEST_CHANGES

model: omniroute/deepseek-flash
head: b9a2bd8772
session: bridge-seat-1-122-b9a2bd87

Bridge review seat 1 — HOMELAB-2479 seat-post helper (head b9a2bd87)

Independent pass. I read forgejo/seat-post.ts at the pinned head in a clean
worktree, verified each PR-body bullet against the code, and ran the suite at
the head and at eb5576d3 myself. No prior verdict, PR-body claim or briefing
note was taken as evidence.

Verified in the code (matches the contract)

  • Role login — POST {bao}/v1/auth/oidc/login {role, jwt} with the role
    defaulting to ld-reviewer-post; the RS256 assertion is minted in-process
    (createSign("RSA-SHA256"), kid/iss/sub from 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.
  • KV read — one GET {bao}/v1/<kvPath>, field data.data.token. No list,
    no write anywhere. --kv-path is 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).
  • Verdict route delivery — argv carries --token-file - and nothing else
    credential-shaped; the token goes to the child on stdin. I checked
    verdict-cli.ts at this head and confirmed - is really read from stdin
    (line ~206, args.tokenFile === "-" ? await io.readStdin() : …), so the
    route actually works rather than failing closed at the child.
  • Child environment — an explicit allowlist (PATH, HOME,
    FORGEJO_BASE_URL, timeout, proxy vars). FORGEJO_API_KEY, VAULT_TOKEN,
    BAO_TOKEN and every FORGEJO_TOKEN_* are absent by construction, and
    main() also deletes FORGEJO_API_KEY from process.env so the in-process
    commentAs call cannot inherit it.
  • Comment route — token staged to a 0600 file inside a 0700 mkdtemp dir,
    named through FORGEJO_TOKEN_FILE_<IDENTITY>, in-process commentAs (the
    host tool's own body), dir removed in a finally.
  • Revoke — single-shot doRevoke() with revokeState.done, called from
    the finally, so a KV-read failure or any throw still revokes (the
    mint.sh:31-36 trap gap is covered by the try/finally here). dead is
    revoke-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.
  • Redaction — every emitted line funnels through log.out/log.say,
    which scrub by value (both secrets, ≥8 chars) and by shape
    ([A-Za-z0-9_\-+/=]{40,}). Error messages build URLs via redactUrl, and
    refusals quote only the KV path and a login, never a token.
  • Read-only default — --post is required to write; without it the helper
    logs in, reads, asserts GET /user equals --identity (default
    ld-reviewer), and prints the plan text or --json. Confirmed by test
    execution, not only by reading.
  • Exit codes — 0 ok / 1 refusal / 2 auth / 3 usage / 4 revoke-unproven as
    documented; postVerdict clamps a child's out-of-range code to 1.
  • Identity statement — the operator-key-file login is printed as a KNOWN
    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.ts asserts outcomes: token on child stdin and
absent from child argv and child env, the staged write + real-fs dir removal

  • existsSync === false, exit 1 with revoke still reported on identity
    mismatch, exit 4 when lookup-self answers 200, exit 3 before any network
    call, and no secret in any emitted line. comment-identity-tokenfile.test.ts
    covers 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 at
    eb5576d3
    — 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.ts and 1 in
    tests/merge-identity.test.ts. The delta is +15 tests, all passing (9
    seat-post + 6 comment-identity-tokenfile).
  • So the PR body's "the same 8 fail at pristine main" claim is confirmed:
    zero new failures from this diff. CI run #436 fails at tsc --noEmit in the
    forgejo and plane jobs; plane is untouched by this PR and tsc is
    clean 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 finally on every exit
path". It is not: on a signal the helper's handler ends with
process.exit(130), which terminates the process without unwinding the
pending finally in postComment
. 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/token with no owner process and no expiry.

This is not theoretical; I reproduced the exact pattern:

staged at /tmp/probe-XXXXXX
signal handler done, calling process.exit(130)
exit=130
LEFT BEHIND: /tmp/probe-XXXXXX/token   (0600, contents intact)   <-- finally never ran

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 — revoke
and cleanup — is entirely unexercised.

Minimal fix (either is enough, tests should then drive the handler and assert
the dir is gone):

  • track live staging dirs and remove them inside the signal handler before
    process.exit, e.g. for (const d of liveDirs) removePath(d) right after
    reportRevoke(...); or
  • register process.on("exit", () => { for (const d of liveDirs) rmSync(d, {recursive:true, force:true}); }),
    which runs on process.exit and 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 sees
revokeState.verdict === null and prints "nothing was minted, so there is
nothing to revoke" — false for that run. Worth fixing with the same change.

Concerns (not blocking)

  1. --kv-path is name-guarded, not path-allowlisted: kv/data/ld/forgejo-landing
    satisfies ^kv/data/ld/forgejo-[a-z0-9][a-z0-9-]*$. The narrowness therefore
    rests 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.
  2. --role is caller-settable free text (validated only as a name). A run could
    ask for a broader role than ld-reviewer-post; again server-side policy is the
    real boundary.
  3. The shape redaction [A-Za-z0-9_\-+/=]{40,} also masks a 40-hex --head-sha
    in the read-only/--json output, so the plan prints [REDACTED] where the sha
    should be. Cosmetic, but it removes evidence a seat may want.
  4. Exit 4 can be returned after a successful post (revoke proof failed), which
    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.
  5. child.stdin.write has no error listener; an EPIPE on an early child exit
    would 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.ts restructure in depth; I only confirmed the exported commentAs +
identityTokenFileEnvName surface that seat-post.ts depends on, and that the
.js→.ts specifier change does not break tsc (it is clean, above).

## Verdict: REQUEST_CHANGES model: omniroute/deepseek-flash head: b9a2bd8772768b8992d3d4ffd33124308be68944 session: bridge-seat-1-122-b9a2bd87 ## Bridge review seat 1 — HOMELAB-2479 seat-post helper (head b9a2bd87) Independent pass. I read `forgejo/seat-post.ts` at the pinned head in a clean worktree, verified each PR-body bullet against the code, and ran the suite at the head and at `eb5576d3` myself. No prior verdict, PR-body claim or briefing note was taken as evidence. ### Verified in the code (matches the contract) - **Role login** — `POST {bao}/v1/auth/oidc/login {role, jwt}` with the role defaulting to `ld-reviewer-post`; the RS256 assertion is minted in-process (`createSign("RSA-SHA256")`, `kid`/`iss`/`sub` from 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. - **KV read** — one `GET {bao}/v1/<kvPath>`, field `data.data.token`. No list, no write anywhere. `--kv-path` is 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`). - **Verdict route delivery** — argv carries `--token-file -` and nothing else credential-shaped; the token goes to the child on **stdin**. I checked `verdict-cli.ts` at this head and confirmed `-` is really read from stdin (line ~206, `args.tokenFile === "-" ? await io.readStdin() : …`), so the route actually works rather than failing closed at the child. - **Child environment** — an explicit allowlist (`PATH`, `HOME`, `FORGEJO_BASE_URL`, timeout, proxy vars). `FORGEJO_API_KEY`, `VAULT_TOKEN`, `BAO_TOKEN` and every `FORGEJO_TOKEN_*` are absent by construction, and `main()` also deletes `FORGEJO_API_KEY` from `process.env` so the in-process `commentAs` call cannot inherit it. - **Comment route** — token staged to a 0600 file inside a 0700 `mkdtemp` dir, named through `FORGEJO_TOKEN_FILE_<IDENTITY>`, in-process `commentAs` (the host tool's own body), dir removed in a `finally`. - **Revoke** — single-shot `doRevoke()` with `revokeState.done`, called from the `finally`, so a KV-read failure or any throw still revokes (the `mint.sh:31-36` trap gap is covered by the `try/finally` here). `dead` is `revoke-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. - **Redaction** — every emitted line funnels through `log.out`/`log.say`, which scrub by value (both secrets, ≥8 chars) and by shape (`[A-Za-z0-9_\-+/=]{40,}`). Error messages build URLs via `redactUrl`, and refusals quote only the KV path and a login, never a token. - **Read-only default** — `--post` is required to write; without it the helper logs in, reads, asserts `GET /user` equals `--identity` (default `ld-reviewer`), and prints the plan text or `--json`. Confirmed by test execution, not only by reading. - **Exit codes** — 0 ok / 1 refusal / 2 auth / 3 usage / 4 revoke-unproven as documented; `postVerdict` clamps a child's out-of-range code to 1. - **Identity statement** — the operator-key-file login is printed as a KNOWN 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.ts` asserts outcomes: token on child stdin and absent from child argv *and* child env, the staged write + real-fs dir removal + `existsSync === false`, exit 1 with revoke still reported on identity mismatch, exit 4 when `lookup-self` answers 200, exit 3 before any network call, and no secret in any emitted line. `comment-identity-tokenfile.test.ts` covers 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 at `eb5576d3`** — 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.ts` and 1 in `tests/merge-identity.test.ts`. The delta is +15 tests, all passing (9 seat-post + 6 comment-identity-tokenfile). - So the PR body's "the same 8 fail at pristine main" claim is **confirmed**: zero new failures from this diff. CI run #436 fails at `tsc --noEmit` in the `forgejo` **and** `plane` jobs; `plane` is untouched by this PR and tsc is clean 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 `finally` on every exit path". It is not: on a signal the helper's handler ends with `process.exit(130)`, which terminates the process **without unwinding the pending `finally` in `postComment`**. 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/token` with no owner process and no expiry. This is not theoretical; I reproduced the exact pattern: ``` staged at /tmp/probe-XXXXXX signal handler done, calling process.exit(130) exit=130 LEFT BEHIND: /tmp/probe-XXXXXX/token (0600, contents intact) <-- finally never ran ``` 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 — revoke *and* cleanup — is entirely unexercised. Minimal fix (either is enough, tests should then drive the handler and assert the dir is gone): - track live staging dirs and remove them inside the signal handler before `process.exit`, e.g. `for (const d of liveDirs) removePath(d)` right after `reportRevoke(...)`; or - register `process.on("exit", () => { for (const d of liveDirs) rmSync(d, {recursive:true, force:true}); })`, which runs on `process.exit` and 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 sees `revokeState.verdict === null` and prints "nothing was minted, so there is nothing to revoke" — false for that run. Worth fixing with the same change. ### Concerns (not blocking) 1. `--kv-path` is *name*-guarded, not path-allowlisted: `kv/data/ld/forgejo-landing` satisfies `^kv/data/ld/forgejo-[a-z0-9][a-z0-9-]*$`. The narrowness therefore rests 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. 2. `--role` is caller-settable free text (validated only as a name). A run could ask for a broader role than `ld-reviewer-post`; again server-side policy is the real boundary. 3. The shape redaction `[A-Za-z0-9_\-+/=]{40,}` also masks a 40-hex `--head-sha` in the read-only/`--json` output, so the plan prints `[REDACTED]` where the sha should be. Cosmetic, but it removes evidence a seat may want. 4. Exit 4 can be returned *after* a successful post (revoke proof failed), which 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. 5. `child.stdin.write` has no `error` listener; an EPIPE on an early child exit would 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.ts` restructure in depth; I only confirmed the exported `commentAs` + `identityTokenFileEnvName` surface that `seat-post.ts` depends on, and that the `.js`→`.ts` specifier change does not break `tsc` (it is clean, above).
HOMELAB-2479: fix(forgejo): remove the staged token dir on signal before exit
All checks were successful
CI / pr-title (pull_request) Successful in 0s
CI / skill-args (pull_request) Successful in 51s
CI / forgejo-ci (pull_request) Successful in 55s
CI / pi-otel-telemetry (pull_request) Successful in 1m4s
tests / python (pull_request) Successful in 5s
CI / mcp-bridge (pull_request) Successful in 25s
CI / nats-intercom (pull_request) Successful in 29s
CI / plane (pull_request) Successful in 1m28s
CI / redact (pull_request) Successful in 36s
CI / todo (pull_request) Successful in 41s
CI / pi-friction (pull_request) Successful in 42s
CI / ask-user-question (pull_request) Successful in 51s
CI / refocus (pull_request) Successful in 58s
CI / forgejo (pull_request) Successful in 1m57s
CI / pi-swarm-suite (pull_request) Successful in 3m54s
54b9d214f9
The signal handler ended in process.exit(130), which never unwinds the
pending finally in the comment route, so a SIGINT/SIGTERM/SIGHUP mid-posting
left the staged 0600 reviewer token file on disk — the review-#1973 blocker.
Track live staging dirs at runner scope: the handler rm -rf's them
(best-effort) before the Bao revoke and only then exits with the signal
code (129/130/143). Also add a stdin 'error' listener on the verdict-cli
child so a broken-pipe token write cannot throw unhandled.

The harness stubs onSignal, so it could never catch this: add a real
child-process test that hangs the posting phase, sends a real SIGTERM
while the staged file exists, and asserts the dir is gone, exit 143, and
the revoke still attempted.
ld-reviewer approved these changes 2026-11-10 01:16:53 +00:00
Dismissed
ld-reviewer left a comment
Collaborator

Verdict: APPROVED

model: omniroute/deepseek-flash
head: 54b9d214f9
session: bridge-seat-2-122-54b9d214

Seat 2 verification — APPROVED at 54b9d214

I read the delta b9a2bd87...54b9d214 and the whole PR at head 54b9d214f9627c99e6e73fe9aaa2c91279102e7f (base eb5576d3) 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 is b9a2bd8772768b8992d3d4ffd33124308be68944 — 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:

  • Pre-fix code (b9a2bd87 seat-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.
  • Post-fix code (54b9d214): the same test passes, the child exits 143, existsSync(stagedDir) is false, the mock server recorded POST /v1/auth/token/revoke-self, stderr carries revoked: true, and no seat-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:

  • liveStagingDirs is created after the Bao login succeeds, so the only signal window is one where a revoke is still owed — correct stage ordering.
  • postComment adds the dir to the set immediately after makePrivateDir(), 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.
  • The handler removes every live dir before await doRevoke() and before process.exit. Removal is rmSync(..., { 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.
  • Exit codes are right: SIGTERM 143, SIGHUP 129, SIGINT 130 (128+n).
  • reportRevoke(await doRevoke()) keeps the revoke proven on the signal path, and doRevoke's revokeState.done latch means the signal path and a pending finally cannot double-revoke — the second caller gets the cached verdict.
  • No double-remove hazard: if commentAs resolves during await doRevoke(), the finally runs liveDirs.delete(dir) then removePath(dir) on an already-removed path, and force: true makes that a no-op; the handler's for..of over the set has finished before the first await. Worst case is a redundant no-op rm, never a throw.
  • The verdict route stages no file at all (the token goes to the child's stdin), so the set is legitimately unused there — no gap.
  • The per-dir try/catch is best-effort by design and documented as such; the finally remains the primary path.

The stdin.on("error") addition is sound. It is attached inside spawnChild before postVerdict calls child.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

  • One KV read only: a single GET ${bao}/v1/${args.kvPath}, with --kv-path regex-pinned to kv/data/ld/forgejo-<name>. Nothing else touches KV.
  • Token never in argv, child env, or logs: the verdict route passes --token-file - and writes the token to stdin (asserted verbatim); childEnvironment is an allowlist excluding FORGEJO_API_KEY, VAULT_TOKEN/BAO_TOKEN and every FORGEJO_TOKEN_*; the comment route copies the parent env, deletes FORGEJO_API_KEY, and names the staged path through FORGEJO_TOKEN_FILE_<IDENTITY> — a path, not a value. main() also deletes FORGEJO_API_KEY from its own process.env first. Every emitted line passes scrub() (by value and by 40+ char shape).
  • Staged file lifecycle: mkdtempSync (0700) plus openSync(.., 0o600) with an explicit fchmodSync, removed by the finally on normal paths and by the handler on signals — verified both ways above.
  • Revoke proof and exit 4: dead requires revoke-self 2xx AND lookup-self 403/404; a lookup answering 200 is NOT proven dead and forces EXIT.revoke in the finally, dominating an otherwise-successful post. Tested.
  • Read-only by default: posting needs an explicit --post; otherwise the helper asserts identity and prints what would post, with a test proving zero Forgejo writes.
  • Identity assertion: GET /api/v1/user with the KV token, compared case-insensitively to --identity; a mismatch refuses (exit 1) and still revokes.
  • Operator-key known gap: stated on every run to stderr and asserted by a test — disclosed, not silent.
  • Tests assert behaviour: 10 contract tests, each mapped to a bullet, with sentinel values proving no secret reaches any output; the new one drives the real binary end to end.

Suite and typecheck at this head (my run): vitest run tests/seat-post.test.ts → 10/10 pass; tsc --noEmit → clean. The full forgejo suite 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 base eb5576d3 in 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 stdin error 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: 54b9d214f9627c99e6e73fe9aaa2c91279102e7f session: bridge-seat-2-122-54b9d214 ## Seat 2 verification — APPROVED at 54b9d214 I read the delta `b9a2bd87...54b9d214` and the whole PR at head `54b9d214f9627c99e6e73fe9aaa2c91279102e7f` (base `eb5576d3`) 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 is `b9a2bd8772768b8992d3d4ffd33124308be68944` — 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: - **Pre-fix code (`b9a2bd87` `seat-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. - **Post-fix code (`54b9d214`):** the same test passes, the child exits 143, `existsSync(stagedDir)` is false, the mock server recorded `POST /v1/auth/token/revoke-self`, stderr carries `revoked: true`, and no `seat-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: - `liveStagingDirs` is created after the Bao login succeeds, so the only signal window is one where a revoke is still owed — correct stage ordering. - `postComment` adds the dir to the set immediately after `makePrivateDir()`, 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. - The handler removes every live dir before `await doRevoke()` and before `process.exit`. Removal is `rmSync(..., { 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. - Exit codes are right: SIGTERM 143, SIGHUP 129, SIGINT 130 (128+n). - `reportRevoke(await doRevoke())` keeps the revoke proven on the signal path, and `doRevoke`'s `revokeState.done` latch means the signal path and a pending `finally` cannot double-revoke — the second caller gets the cached verdict. - No double-remove hazard: if `commentAs` resolves during `await doRevoke()`, the `finally` runs `liveDirs.delete(dir)` then `removePath(dir)` on an already-removed path, and `force: true` makes that a no-op; the handler's `for..of` over the set has finished before the first `await`. Worst case is a redundant no-op `rm`, never a throw. - The verdict route stages no file at all (the token goes to the child's stdin), so the set is legitimately unused there — no gap. - The per-dir `try/catch` is best-effort by design and documented as such; the `finally` remains the primary path. **The `stdin.on("error")` addition is sound.** It is attached inside `spawnChild` before `postVerdict` calls `child.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 - **One KV read only:** a single `GET ${bao}/v1/${args.kvPath}`, with `--kv-path` regex-pinned to `kv/data/ld/forgejo-<name>`. Nothing else touches KV. - **Token never in argv, child env, or logs:** the verdict route passes `--token-file -` and writes the token to stdin (asserted verbatim); `childEnvironment` is an allowlist excluding `FORGEJO_API_KEY`, `VAULT_TOKEN`/`BAO_TOKEN` and every `FORGEJO_TOKEN_*`; the comment route copies the parent env, deletes `FORGEJO_API_KEY`, and names the staged path through `FORGEJO_TOKEN_FILE_<IDENTITY>` — a path, not a value. `main()` also deletes `FORGEJO_API_KEY` from its own `process.env` first. Every emitted line passes `scrub()` (by value and by 40+ char shape). - **Staged file lifecycle:** `mkdtempSync` (0700) plus `openSync(.., 0o600)` with an explicit `fchmodSync`, removed by the `finally` on normal paths and by the handler on signals — verified both ways above. - **Revoke proof and exit 4:** `dead` requires revoke-self 2xx AND lookup-self 403/404; a lookup answering 200 is `NOT proven dead` and forces `EXIT.revoke` in the `finally`, dominating an otherwise-successful post. Tested. - **Read-only by default:** posting needs an explicit `--post`; otherwise the helper asserts identity and prints what would post, with a test proving zero Forgejo writes. - **Identity assertion:** `GET /api/v1/user` with the KV token, compared case-insensitively to `--identity`; a mismatch refuses (exit 1) and still revokes. - **Operator-key known gap:** stated on every run to stderr and asserted by a test — disclosed, not silent. - **Tests assert behaviour:** 10 contract tests, each mapped to a bullet, with sentinel values proving no secret reaches any output; the new one drives the real binary end to end. **Suite and typecheck at this head (my run):** `vitest run tests/seat-post.test.ts` → 10/10 pass; `tsc --noEmit` → clean. The full `forgejo` suite 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 base `eb5576d3` in 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 `stdin` error 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.
ld-reviewer left a comment
Collaborator

Verdict: APPROVED

model: omniroute/deepseek-flash
head: 54b9d214f9
session: 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 earlier
verdict, 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 of
this head (git merge-base --is-ancestor eb5576d3 54b9d214 → 1). The real shape is
54b9d214 → b9a2bd87 → 7953855 (main), so I diffed against the merge-base 7953855. Against that base the change
is 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 means
main 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.

  • One KV read only: a single GET /v1/<kvPath> with the default kv/data/ld/forgejo-reviewer for
    --identity ld-reviewer, taking the token field, and --kv-path is regex-pinned to kv/data/ld/forgejo-<name>
    so the helper cannot be pointed at an arbitrary path.
  • Login: the narrow role (ld-reviewer-post, TTL 10m) via OIDC, with the RS256 assertion signed in this process
    from the operator key file (createSign("RSA-SHA256"), kid from the key file, 60s exp). The
    operator-key-is-the-login-identity KNOWN GAP is emitted on stderr on every run that gets past parseArgs, and the
    test asserts that string. Confirmed by reading the run path, not by taking the header comment's word.
  • Verdict route: token to the child on stdin with --token-file - in argv; argv carries no credential (test
    asserts the argv string does not contain the token and that the child's stdin is exactly the token line).
  • Comment route: mkdtempSync(tmpdir(), "seat-post-") (0700 by construction) + openSync(…, 0o600) with an explicit
    fchmodSync, named through FORGEJO_TOKEN_FILE_<IDENTITY>, then the exported commentAs body — the same
    function the host tool now delegates to (index.ts registerCommentAsTool.execute is a one-line delegate).
  • No token in argv/child-env/logs: the verdict child gets a strict allowlist (childEnvironment), which excludes
    FORGEJO_API_KEY, VAULT_TOKEN/BAO_TOKEN and every FORGEJO_TOKEN_*; main() deletes FORGEJO_API_KEY from
    the helper's own env too. Emission goes through one scrub that redacts every learned secret by value (len ≥ 8)
    and long token-shaped runs by shape.
  • Revoke: finally runs doRevoke() + reportRevoke() on every unwind, dead = revoke-self 2xx AND lookup-self 403/404, and !dead forces EXIT.revoke (4). A lookup-self 200 is tested explicitly as exit 4.
  • Read-only default: without --post the helper logs in, reads the KV, asserts identity via GET /user and prints
    the plan; the tests assert zero Forgejo writes. Exit codes 0/1/2/3/4 as documented.
  • I also checked the staged-file path is not logged: the log line names the env var, not the file path.

The signal fix — the delta commit.

  • liveStagingDirs is declared before io.onSignal(...) is registered, added to in postComment before the write and
    removed in its finally, so the set holds exactly the directories that currently exist.
  • The handler iterates and removePaths the live dirs before reportRevoke(await doRevoke()) and before
    process.exit, with per-dir try/catch so cleanup can never keep the process alive. Exit codes are now
    signal-specific: SIGTERM → 143, SIGHUP → 129, SIGINT → 130 (was a flat 130). Revocation on the signal path is
    preserved.
  • The new test really spawns the helper: spawn(process.execPath, ["--experimental-strip-types", ../seat-post.ts, …])
    against a real node:http server on 127.0.0.1, with the harness not involved. It waits for the POST to be in
    flight, polls /tmp for a seat-post-XXXX dir that actually contains a token file (prefixes seat-post-test-
    and seat-post-sigtest- excluded), asserts the file is really there, sends a real SIGTERM, then asserts exit
    143, that the staged dir is gone, that POST /v1/auth/token/revoke-self was received, and that stderr says
    revoked: true. That is the right shape of test for a signal path: a stubbed onSignal harness structurally
    cannot reach it.

FORGEJO_TOKEN_FILE_<IDENTITY> in comment-as.ts.

  • Resolution order is staged file → mount → env value → OpenBao, and every process.env left in the module is only a
    default parameter — inside commentAs the passed env is threaded to resolveIdentityCredential, the ambient-key
    comparison and identityBaseConfig. I checked each of those call sites.
  • Configured-but-unusable refuses instead of falling through: unreadable path and empty file are both refusal tests.
  • Precedence over the mount is proved by a test that provides both. No credential parameter was added; the option is
    a path, and the source is reported as identity-token-file-env in details.

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 --noEmit in forgejo/ at 54b9d214: 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-types resolution check on the helper's own import graph: seat-post.ts →
    comment-as.ts → api.ts → landing.ts/types.ts contains no bare npm imports, and all specifiers in that
    graph are .ts. That is what makes the source-only drop runnable under plain node, and it is why moving
    registerCommentAsTool into index.ts (which keeps typebox) is load-bearing rather than cosmetic.

Blockers: None.

Behaviour I checked and accept as correct

  • Signal-path exit codes. The handler exits 128+signal (SIGTERM 143, SIGHUP 129, SIGINT 130) and reports the revoke
    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.
  • Signal coverage. The handler covers SIGINT, SIGTERM and SIGHUP; the shipped signal test drives SIGTERM end to
    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.

## Verdict: APPROVED model: omniroute/deepseek-flash head: 54b9d214f9627c99e6e73fe9aaa2c91279102e7f session: 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 earlier verdict, 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 of this head (`git merge-base --is-ancestor eb5576d3 54b9d214` → 1). The real shape is `54b9d214 → b9a2bd87 → 7953855 (main)`, so I diffed against the merge-base `7953855`. Against that base the change is 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 means main 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.** - One KV read only: a single `GET /v1/<kvPath>` with the default `kv/data/ld/forgejo-reviewer` for `--identity ld-reviewer`, taking the `token` field, and `--kv-path` is regex-pinned to `kv/data/ld/forgejo-<name>` so the helper cannot be pointed at an arbitrary path. - Login: the narrow role (`ld-reviewer-post`, TTL 10m) via OIDC, with the RS256 assertion signed **in this process** from the operator key file (`createSign("RSA-SHA256")`, `kid` from the key file, 60s `exp`). The operator-key-is-the-login-identity KNOWN GAP is emitted on stderr on every run that gets past `parseArgs`, and the test asserts that string. Confirmed by reading the run path, not by taking the header comment's word. - Verdict route: token to the child on **stdin** with `--token-file -` in argv; argv carries no credential (test asserts the argv string does not contain the token and that the child's stdin is exactly the token line). - Comment route: `mkdtempSync(tmpdir(), "seat-post-")` (0700 by construction) + `openSync(…, 0o600)` with an explicit `fchmodSync`, named through `FORGEJO_TOKEN_FILE_<IDENTITY>`, then the **exported** `commentAs` body — the same function the host tool now delegates to (`index.ts` `registerCommentAsTool.execute` is a one-line delegate). - No token in argv/child-env/logs: the verdict child gets a strict allowlist (`childEnvironment`), which excludes `FORGEJO_API_KEY`, `VAULT_TOKEN`/`BAO_TOKEN` and every `FORGEJO_TOKEN_*`; `main()` deletes `FORGEJO_API_KEY` from the helper's own env too. Emission goes through one `scrub` that redacts every learned secret by value (len ≥ 8) and long token-shaped runs by shape. - Revoke: `finally` runs `doRevoke()` + `reportRevoke()` on every unwind, `dead = revoke-self 2xx AND lookup-self 403/404`, and `!dead` forces `EXIT.revoke` (4). A `lookup-self` 200 is tested explicitly as exit 4. - Read-only default: without `--post` the helper logs in, reads the KV, asserts identity via `GET /user` and prints the plan; the tests assert zero Forgejo writes. Exit codes 0/1/2/3/4 as documented. - I also checked the staged-file path is not logged: the log line names the env var, not the file path. **The signal fix — the delta commit.** - `liveStagingDirs` is declared before `io.onSignal(...)` is registered, added to in `postComment` before the write and removed in its `finally`, so the set holds exactly the directories that currently exist. - The handler iterates and `removePath`s the live dirs **before** `reportRevoke(await doRevoke())` and before `process.exit`, with per-dir `try/catch` so cleanup can never keep the process alive. Exit codes are now signal-specific: SIGTERM → 143, SIGHUP → 129, SIGINT → 130 (was a flat 130). Revocation on the signal path is preserved. - The new test really spawns the helper: `spawn(process.execPath, ["--experimental-strip-types", ../seat-post.ts, …])` against a real `node:http` server on 127.0.0.1, with the harness *not* involved. It waits for the POST to be in flight, polls `/tmp` for a `seat-post-XXXX` dir that actually contains a `token` file (prefixes `seat-post-test-` and `seat-post-sigtest-` excluded), asserts the file is really there, sends a real `SIGTERM`, then asserts exit **143**, that the staged dir is **gone**, that `POST /v1/auth/token/revoke-self` was received, and that stderr says `revoked: true`. That is the right shape of test for a signal path: a stubbed `onSignal` harness structurally cannot reach it. **`FORGEJO_TOKEN_FILE_<IDENTITY>` in `comment-as.ts`.** - Resolution order is staged file → mount → env value → OpenBao, and every `process.env` left in the module is only a default parameter — inside `commentAs` the passed `env` is threaded to `resolveIdentityCredential`, the ambient-key comparison and `identityBaseConfig`. I checked each of those call sites. - Configured-but-unusable refuses instead of falling through: unreadable path and empty file are both refusal tests. - Precedence over the mount is proved by a test that provides both. No credential parameter was added; the option is a path, and the source is reported as `identity-token-file-env` in `details`. **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 --noEmit` in `forgejo/` at `54b9d214`: **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-types` resolution check on the helper's own import graph: `seat-post.ts` → `comment-as.ts` → `api.ts` → `landing.ts`/`types.ts` contains **no bare npm imports**, and all specifiers in that graph are `.ts`. That is what makes the source-only drop runnable under plain node, and it is why moving `registerCommentAsTool` into `index.ts` (which keeps `typebox`) is load-bearing rather than cosmetic. **Blockers: None.** ### Behaviour I checked and accept as correct - **Signal-path exit codes.** The handler exits 128+signal (SIGTERM 143, SIGHUP 129, SIGINT 130) and reports the revoke 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. - **Signal coverage.** The handler covers SIGINT, SIGTERM and SIGHUP; the shipped signal test drives SIGTERM end to 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.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Homelab/pi-extensions!122
No description provided.