HOMELAB-2674: fix(forgejo): a worker never writes as the operator #92

Open
aaron wants to merge 4 commits from plane/HOMELAB-2674-operator-identity into main
Owner

The fact, established first (measured 2026-11-04 on mgmt, never a token value)

The extension resolves its credential through one path:

step where what
1 forgejo/api.ts:49-56 (loadConfig) process.env.FORGEJO_API_KEY, and FORGEJO_BASE_URL; no other input
2 absent → throws FORGEJO_API_KEY env var is required — no pi-config file fallback exists (no readFileSync of any config in the load path; the only file reads are the landing token file and the OpenBao helper output)
3 resolved ForgejoConfig { baseUrl, apiKey } — a token, no identity field
4 who that token is GET /api/v1/user → login aaron, is_admin=false, id 2
5 where the env var comes from ~/.bashrc:154 sources ~/.config/homelab/forgejo.env (0600), inherited by every pi session on mgmt

So the hypothesis holds with one correction: it is not "the pi config" but the inherited shell env, and step 3 is why nothing downstream could tell. tests/operator-identity.test.ts pins steps 1–3 (the config has exactly apiKey+baseUrl; unsetting the env throws).

The fix

New pure module forgejo/operator-identity.ts (checkOperatorIdentity), wired into the three worker-facing write paths:

  • forgejo_comment_on_pr (new optional as_operator param)
  • assertMergeIdentity → forgejo_merge_pr
  • verdict-cli.ts --post (submit-review; has no operator mode at all)

Rules, in order: a non-operator login is untouched (expectation matching stays in assertWriteIdentity, so the HOMELAB-2527/2550 messages and ordering are unchanged); the operator login is refused when headless, refused when interactive but undeclared, and refused unconditionally in a declared worker context (FORGEJO_WORKER_CONTEXT, LD_WORKER, PI_SUBAGENT, CI). Interactive TTY + as_operator: true is the only way through, which keeps the human able to post as themselves.

Why keying on worker context cannot be spoofed by omitting the flag

Omitting the flag is the refusal (headless / undeclared-operator), so silence is never inheritance. Passing as_operator: true is necessary and never sufficient: the other half of the condition is whether this process has a terminal on both streams — a property of how the session was launched, not something a tool caller can write. Residual gap, stated in the module: a worker that allocates a pty could satisfy the TTY half, and an operator with CI set is refused until they unset it. The structural fix (operator token absent from worker environments) is ticket work, not this PR.

Landing-lock grammar: untouched, and please keep it that way

landing.ts, verdict.ts body composition and VERDICT_GRAMMAR are not modified. A verdict must still open with the canonical first-block ## Verdict: APPROVED|CHANGES_REQUESTED block, model: and head: on their own lines — the lock reads that grammar, and this change decides only who may post, never what the body says. Any future change to verdict posting must keep tests/landing-* and tests/verdict-* green; they were all run here.

Tests — 638 pass, 22 files; tsc --noEmit clean

  • Fails pre-fix (demonstrated on main@48c5000 with the source stashed): "an undeclared identity on a headless process does not post as aaron" — pre-fix the fake recorded a real POST /issues/91/comments under aaron; post-fix zero mutating calls and a refusal naming operator account "aaron".
  • Negative/positive: ld-reviewer with expected_identity posts and is verified by read-back; as_operator: true from a headless worker is still refused; each worker marker refuses even interactive+flag.
  • Operator path: interactive + as_operator: true allowed; interactive without it refused.
  • Seven pre-existing assertions in comment-identity.test.ts / comment-identity-hardening.test.ts encoded "an ordinary comment from the operator token posts with no declaration". They now use a non-operator login (ci-reader) for that invariant — the 2527 rule (no universal reviewer constant; blank is unset) is unchanged for every other identity — and the operator case moved to the new file with a pointer.

Prepare-only: no deploy to ~/.pi/agent/extensions, no session restart. Activation needs the operator's go (see HOMELAB-2674).

## The fact, established first (measured 2026-11-04 on mgmt, never a token value) The extension resolves its credential through **one** path: | step | where | what | |---|---|---| | 1 | `forgejo/api.ts:49-56` (`loadConfig`) | `process.env.FORGEJO_API_KEY`, and `FORGEJO_BASE_URL`; no other input | | 2 | absent → | throws `FORGEJO_API_KEY env var is required` — **no pi-config file fallback exists** (no `readFileSync` of any config in the load path; the only file reads are the landing token file and the OpenBao helper output) | | 3 | resolved `ForgejoConfig` | `{ baseUrl, apiKey }` — a token, **no identity field** | | 4 | who that token is | `GET /api/v1/user` → login **`aaron`**, `is_admin=false`, id 2 | | 5 | where the env var comes from | `~/.bashrc:154` sources `~/.config/homelab/forgejo.env` (0600), inherited by every pi session on mgmt | So the hypothesis holds with one correction: it is not "the pi config" but the **inherited shell env**, and step 3 is why nothing downstream could tell. `tests/operator-identity.test.ts` pins steps 1–3 (the config has exactly `apiKey`+`baseUrl`; unsetting the env throws). ## The fix New pure module `forgejo/operator-identity.ts` (`checkOperatorIdentity`), wired into the three worker-facing write paths: - `forgejo_comment_on_pr` (new optional `as_operator` param) - `assertMergeIdentity` → `forgejo_merge_pr` - `verdict-cli.ts --post` (submit-review; has no operator mode at all) Rules, in order: a non-operator login is untouched (expectation matching stays in `assertWriteIdentity`, so the HOMELAB-2527/2550 messages and ordering are unchanged); the operator login is **refused** when headless, **refused** when interactive but undeclared, and **refused unconditionally** in a declared worker context (`FORGEJO_WORKER_CONTEXT`, `LD_WORKER`, `PI_SUBAGENT`, `CI`). Interactive TTY + `as_operator: true` is the only way through, which keeps the human able to post as themselves. ### Why keying on worker context cannot be spoofed by omitting the flag Omitting the flag **is** the refusal (`headless` / `undeclared-operator`), so silence is never inheritance. Passing `as_operator: true` is necessary and never sufficient: the other half of the condition is whether *this process* has a terminal on both streams — a property of how the session was launched, not something a tool caller can write. Residual gap, stated in the module: a worker that allocates a pty could satisfy the TTY half, and an operator with `CI` set is refused until they unset it. The structural fix (operator token absent from worker environments) is ticket work, not this PR. ## Landing-lock grammar: untouched, and please keep it that way `landing.ts`, `verdict.ts` body composition and `VERDICT_GRAMMAR` are **not modified**. A verdict must still open with the canonical first-block `## Verdict: APPROVED|CHANGES_REQUESTED` block, `model:` and `head:` on their own lines — the lock reads that grammar, and this change decides only *who* may post, never *what* the body says. Any future change to verdict posting must keep `tests/landing-*` and `tests/verdict-*` green; they were all run here. ## Tests — 638 pass, 22 files; `tsc --noEmit` clean - **Fails pre-fix** (demonstrated on `main@48c5000` with the source stashed): *"an undeclared identity on a headless process does not post as aaron"* — pre-fix the fake recorded a real `POST /issues/91/comments` under `aaron`; post-fix zero mutating calls and a refusal naming `operator account "aaron"`. - Negative/positive: `ld-reviewer` with `expected_identity` posts and is verified by read-back; `as_operator: true` from a headless worker is still refused; each worker marker refuses even interactive+flag. - Operator path: interactive + `as_operator: true` allowed; interactive without it refused. - Seven pre-existing assertions in `comment-identity.test.ts` / `comment-identity-hardening.test.ts` encoded "an ordinary comment from the operator token posts with no declaration". They now use a non-operator login (`ci-reader`) for that invariant — the 2527 rule (no universal reviewer constant; blank is unset) is unchanged for every other identity — and the operator case moved to the new file with a pointer. Prepare-only: no deploy to `~/.pi/agent/extensions`, no session restart. Activation needs the operator's go (see HOMELAB-2674).
HOMELAB-2674: a worker never writes as the operator
Some checks failed
CI / pr-title (pull_request) Failing after 1s
CI / forgejo-ci (pull_request) Successful in 53s
CI / pi-otel-telemetry (pull_request) Successful in 1m4s
CI / plane (pull_request) Successful in 1m7s
CI / skill-args (pull_request) Successful in 59s
CI / ask-user-question (pull_request) Successful in 44s
tests / python (pull_request) Successful in 7s
CI / mcp-bridge (pull_request) Successful in 39s
CI / nats-intercom (pull_request) Successful in 40s
CI / redact (pull_request) Successful in 1m56s
CI / forgejo (pull_request) Failing after 2m37s
CI / pi-friction (pull_request) Successful in 1m58s
CI / todo (pull_request) Successful in 2m19s
CI / refocus (pull_request) Successful in 2m17s
CI / pi-swarm-suite (pull_request) Successful in 4m35s
4aaff1d0b4
The defect: five landing-lock verdicts (20990, 20992, 21041, 21042, 21053)
were posted as the operator account 'aaron'. The resolution chain, measured:
loadConfig() reads process.env.FORGEJO_API_KEY and nothing else — no pi-config
file fallback, and no identity in the resolved config, only a token. An
inherited mgmt shell exports that var from ~/.config/homelab/forgejo.env, and
GET /api/v1/user answers 'aaron' (is_admin=false). So a write that declares no
identity IS a write as the operator; no brief can fix that, the tool must.

New pure module forgejo/operator-identity.ts decides:
  - a non-operator login: untouched here (expectation matching stays in
    assertWriteIdentity);
  - the operator login on a headless process: refused;
  - the operator login on an interactive terminal without as_operator: true:
    refused (nothing inherited silently);
  - a declared worker context (FORGEJO_WORKER_CONTEXT, LD_WORKER, PI_SUBAGENT,
    CI): refused even when interactive and declared.

Wired into the three worker-facing write paths: forgejo_comment_on_pr (new
as_operator param), assertMergeIdentity (landing credential), and
verdict-cli.ts --post (submit-review). The operator's own interactive use is
kept: TTY + as_operator: true.

Not touched: landing.ts, verdict.ts body composition and the VERDICT_GRAMMAR.
Verdicts are still posted with the canonical ## Verdict: first-block grammar;
this change only decides WHO may post, never WHAT the body says.

Tests: tests/operator-identity.test.ts (17). The pre-fix failure is
'an undeclared identity on a headless process does not post as aaron': on
main@48c5000 it POSTs, now it refuses with zero writes. Two pre-existing suites
asserted that an ordinary comment from the operator token posts with no
declaration; they now use a non-operator login for that invariant and point at
the new file (their 2527/2550 rule is unchanged for every other identity).
HOMELAB-2674: fix(forgejo): pin the refusal precedence so the test cannot follow the ambient environment
All checks were successful
CI / pr-title (pull_request) Successful in 2s
CI / pi-otel-telemetry (pull_request) Successful in 46s
CI / forgejo-ci (pull_request) Successful in 1m7s
CI / skill-args (pull_request) Successful in 58s
CI / ask-user-question (pull_request) Successful in 57s
CI / forgejo (pull_request) Successful in 1m31s
CI / plane (pull_request) Successful in 2m2s
CI / mcp-bridge (pull_request) Successful in 52s
tests / python (pull_request) Successful in 9s
CI / nats-intercom (pull_request) Successful in 55s
CI / todo (pull_request) Successful in 1m48s
CI / refocus (pull_request) Successful in 1m39s
CI / redact (pull_request) Successful in 1m12s
CI / pi-friction (pull_request) Successful in 1m16s
CI / pi-swarm-suite (pull_request) Successful in 4m56s
2e2db8375a
The head was red on tests/operator-identity.test.ts:182 and the reason was
structural, not cosmetic: the file asserted branch 2 (headless) while leaving
the worker markers in the ambient environment. Locally CI is unset, so the
headless rule fired and the assertion on /not an interactive terminal session/
matched; in the workflow CI is set, so the worker-context rule fired first and
the same assertion saw a different message. The test was reading the runner's
environment rather than the rule under test.

checkOperatorIdentity has a fixed precedence — worker-context, then headless,
then undeclared-operator — and nothing pinned it. Now:

* beforeEach clears every WORKER_CONTEXT_ENVS marker, so each test states its
  own environment instead of inheriting the runner's;
* the headless test asserts the terminal message POSITIVELY and asserts the
  worker message is absent, so it cannot pass by matching the wrong branch;
* a new test pins the precedence itself: with CI set and as_operator: true, the
  refusal must name the worker context and must NOT mention the terminal.

Verified in both shapes: env -u CI (18 passed) and CI=true (18 passed), and the
full forgejo suite is 6803 passed under CI=true. No source change: the module
was already correct, the test was not.
pi-landing changed title from HOMELAB-2674: a worker never writes as the operator (identity enforced in code) to HOMELAB-2674: fix(forgejo): a worker never writes as the operator 2026-11-04 18:09:58 +00:00
Collaborator

Verdict: APPROVED

model: omniroute/deepseek-flash
head: 2e2db8375a
session: homelab-2674-review-1

Reviewed the whole of forgejo/operator-identity.ts and forgejo/tests/operator-identity.test.ts with read, plus the wiring diffs in api.ts, index.ts, verdict-cli.ts, and the two adjusted identity suites. Verified independently rather than trusting the description.

Head and CI. GET /api/v1/repos/Homelab/pi-extensions/pulls/92 answers head 2e2db83…ee21, base main@48c5000, 7 files, +675/−15 — matches what I was asked to review. Workflow runs #251–#253 on this exact sha are green including the forgejo job; #250's only red leg was pr-title, which reads the live PR title, and it passes now. forgejo/node_modules/.bin/tsc --noEmit exits 0.

Tests, run by me. CI=true npx vitest run forgejo/tests/operator-identity.test.ts → 18 passed. env -u CI … → 18 passed. Whole forgejo/tests tree in both shapes → 6803 passed / 0 failed; the 36 failing files in that invocation are all collection errors under .worktrees/… (stale checkout, Cannot find package 'typebox'), none under forgejo/, and the CI job runs vitest from the package directory where .worktrees does not exist. So no recurrence of the flake class the second commit fixed.

Is the refusal rule sound? Enumeration of the decision in checkOperatorIdentity (operator-identity.ts:133-188) and its three call sites:

  • Worker that omits the flag: refused. index.ts:1107 maps a missing as_operator to declaredOperator === false, which lands on headless or undeclared-operator; silence is never inheritance.
  • Worker that passes as_operator: true: still refused when any marker in WORKER_CONTEXT_ENVS is truthy (operator-identity.ts:47-52, checked first), and refused when the process is headless. Correct precedence is documented and, as of the second commit, pinned.
  • Bypass by expectation mismatch is not available either: api.ts:365-378 refuses expected !== actual before the operator rule, and expected === actual === "aaron" then hits the operator rule. assertWriteIdentity reads GET /user (a non-mutating call) before refusing, so "zero writes" holds.
  • process.stdin.isTTY && process.stdout.isTTY is a defensible interactive test: it is a launch property, not a caller argument, and it fails closed (non-TTY → refuse).
  • verdict-cli.ts:900-916 runs the same check with declaredOperator: false and no operator mode at all, before any POST, and landing.ts / verdict body composition are untouched (absent from the diff), so the landing-lock grammar cannot have shifted.

Does the new test actually pin the precedence? Yes, and it is discriminating. beforeEach deletes every WORKER_CONTEXT_ENVS marker (operator-identity.test.ts:56-72) and afterEach restores the ambient values after vi.unstubAllEnvs(), so stubbing and restoring cannot fight each other. The precedence test (:236-245) stubs CI=true and asserts the refusal names declares itself a worker (CI is set) and does not mention the terminal: since the message can only name CI if process.env.CI was really set and the worker branch really won, the test fails if either premise breaks. It also cannot pass for the wrong reason — the comment-API path in assertWriteIdentity sees process.env, and vitest 5's stubEnv writes there directly. The headless test (:222-228) now asserts the terminal message positively and asserts the worker message is absent, which is what closes the original bleed. The test that deliberately sets FORGEJO_WORKER_CONTEXT (:248-256) still passes because it stubs after beforeEach clears, and it resets the base env itself; 6803 green in both env shapes confirms beforeEach broke nothing else.

Did the second commit change source? No. git show --stat 2e2db83 touches only forgejo/tests/operator-identity.test.ts (+63/−2), and git diff 4aaff1d 2e2db83 -- forgejo/operator-identity.ts is empty. The claim "the module was already correct, the test was not" is accurate.

Findings (none blocking; each is worth a follow-up).

  • F1 — api.ts:385-388 says the operator rule "catches every write". It does not: only the branded paths are gated (assertWriteIdentity → comment, assertMergeIdentity → merge, verdict CLI). createRepo (api.ts:1241, POST user/repos) and createPr (api.ts:1327-1329, POST repos/{o}/{r}/pulls) still write with whatever FORGEJO_API_KEY resolves to, so a worker can still open a PR or create a repo attributed to aaron. The PR title and the api comment overstate the invariant; the module docstring ("three worker-facing write paths") is the accurate version. Either soften the sentence or file the remaining paths on HOMELAB-2674.
  • F2 — the residual-gap paragraph (operator-identity.ts:32-45) names a worker that "allocates a pseudo-terminal", but omits the far more common shape: an agent running inside an interactive pi TUI session inherits a TTY and needs to allocate nothing, so for that agent as_operator: true alone is sufficient. "Cannot be spoofed by passing as_operator: true" is true of headless processes only. Worth stating, because the everyday cockpit case is exactly this one.
  • F3 — verdict-cli.ts:900-916 passes declaredOperator: false and no streams, so a human running --post interactively with the operator token is refused with a reason whose remedy is "requires an explicit as_operator: true" — an option the CLI does not have. The refusal is right; the remedy line will send a human hunting for a flag that does not exist. A CLI-specific reason string would be better.
  • F4 — OPERATOR_LOGIN is one hardcoded login (operator-identity.ts:50). The same class of ambient token exists for other human/owner accounts (e.g. the jesus / Sendrast panel owners in the landing policy); a session that inherits one of those passes the operator check untouched. Plausibly the right trade-off given the anti-spoofing rationale, but it should be named on the ticket rather than discovered later.
  • Minor: with beforeEach clearing the markers, the two tool-level tests now exercise only the headless branch; the CI-set tool path is covered for the as_operator: true case only. Fine, but a tool-level "worker, no flag" case with CI set would close the last combination.

Nothing here needs to change before merge; the refusal is fail-closed on every path it claims, the failing-branch confusion is genuinely fixed, and the second commit's "test-only" claim checks out.

## Verdict: APPROVED model: omniroute/deepseek-flash head: 2e2db8375ab8bb155cc213a16ddb532acae9ee21 session: homelab-2674-review-1 Reviewed the whole of `forgejo/operator-identity.ts` and `forgejo/tests/operator-identity.test.ts` with `read`, plus the wiring diffs in `api.ts`, `index.ts`, `verdict-cli.ts`, and the two adjusted identity suites. Verified independently rather than trusting the description. **Head and CI.** `GET /api/v1/repos/Homelab/pi-extensions/pulls/92` answers head `2e2db83…ee21`, base `main@48c5000`, 7 files, +675/−15 — matches what I was asked to review. Workflow runs #251–#253 on this exact sha are green including the `forgejo` job; #250's only red leg was `pr-title`, which reads the live PR title, and it passes now. `forgejo/node_modules/.bin/tsc --noEmit` exits 0. **Tests, run by me.** `CI=true npx vitest run forgejo/tests/operator-identity.test.ts` → 18 passed. `env -u CI …` → 18 passed. Whole `forgejo/tests` tree in both shapes → 6803 passed / 0 failed; the 36 failing *files* in that invocation are all collection errors under `.worktrees/…` (stale checkout, `Cannot find package 'typebox'`), none under `forgejo/`, and the CI job runs vitest from the package directory where `.worktrees` does not exist. So no recurrence of the flake class the second commit fixed. **Is the refusal rule sound?** Enumeration of the decision in `checkOperatorIdentity` (`operator-identity.ts:133-188`) and its three call sites: - Worker that omits the flag: refused. `index.ts:1107` maps a missing `as_operator` to `declaredOperator === false`, which lands on `headless` or `undeclared-operator`; silence is never inheritance. - Worker that passes `as_operator: true`: still refused when any marker in `WORKER_CONTEXT_ENVS` is truthy (`operator-identity.ts:47-52`, checked *first*), and refused when the process is headless. Correct precedence is documented and, as of the second commit, pinned. - Bypass by expectation mismatch is not available either: `api.ts:365-378` refuses `expected !== actual` before the operator rule, and `expected === actual === "aaron"` then hits the operator rule. `assertWriteIdentity` reads `GET /user` (a non-mutating call) before refusing, so "zero writes" holds. - `process.stdin.isTTY && process.stdout.isTTY` is a defensible interactive test: it is a launch property, not a caller argument, and it fails closed (non-TTY → refuse). - `verdict-cli.ts:900-916` runs the same check with `declaredOperator: false` and no operator mode at all, before any POST, and `landing.ts` / verdict body composition are untouched (absent from the diff), so the landing-lock grammar cannot have shifted. **Does the new test actually pin the precedence?** Yes, and it is discriminating. `beforeEach` deletes every `WORKER_CONTEXT_ENVS` marker (`operator-identity.test.ts:56-72`) and `afterEach` restores the ambient values after `vi.unstubAllEnvs()`, so stubbing and restoring cannot fight each other. The precedence test (`:236-245`) stubs `CI=true` and asserts the refusal names `declares itself a worker (CI is set)` *and* does not mention the terminal: since the message can only name `CI` if `process.env.CI` was really set and the worker branch really won, the test fails if either premise breaks. It also cannot pass for the wrong reason — the comment-API path in `assertWriteIdentity` sees `process.env`, and vitest 5's `stubEnv` writes there directly. The headless test (`:222-228`) now asserts the terminal message positively *and* asserts the worker message is absent, which is what closes the original bleed. The test that deliberately sets `FORGEJO_WORKER_CONTEXT` (`:248-256`) still passes because it stubs after `beforeEach` clears, and it resets the base env itself; 6803 green in both env shapes confirms `beforeEach` broke nothing else. **Did the second commit change source?** No. `git show --stat 2e2db83` touches only `forgejo/tests/operator-identity.test.ts` (+63/−2), and `git diff 4aaff1d 2e2db83 -- forgejo/operator-identity.ts` is empty. The claim "the module was already correct, the test was not" is accurate. **Findings (none blocking; each is worth a follow-up).** - F1 — `api.ts:385-388` says the operator rule "catches every write". It does not: only the branded paths are gated (`assertWriteIdentity` → comment, `assertMergeIdentity` → merge, verdict CLI). `createRepo` (`api.ts:1241`, `POST user/repos`) and `createPr` (`api.ts:1327-1329`, `POST repos/{o}/{r}/pulls`) still write with whatever `FORGEJO_API_KEY` resolves to, so a worker can still open a PR or create a repo attributed to `aaron`. The PR title and the api comment overstate the invariant; the module docstring ("three worker-facing write paths") is the accurate version. Either soften the sentence or file the remaining paths on HOMELAB-2674. - F2 — the residual-gap paragraph (`operator-identity.ts:32-45`) names a worker that "allocates a pseudo-terminal", but omits the far more common shape: an agent running inside an *interactive* pi TUI session inherits a TTY and needs to allocate nothing, so for that agent `as_operator: true` alone is sufficient. "Cannot be spoofed by passing `as_operator: true`" is true of headless processes only. Worth stating, because the everyday cockpit case is exactly this one. - F3 — `verdict-cli.ts:900-916` passes `declaredOperator: false` and no streams, so a human running `--post` interactively with the operator token is refused with a reason whose remedy is "requires an explicit as_operator: true" — an option the CLI does not have. The refusal is right; the remedy line will send a human hunting for a flag that does not exist. A CLI-specific reason string would be better. - F4 — `OPERATOR_LOGIN` is one hardcoded login (`operator-identity.ts:50`). The same class of ambient token exists for other human/owner accounts (e.g. the `jesus` / `Sendrast` panel owners in the landing policy); a session that inherits one of those passes the operator check untouched. Plausibly the right trade-off given the anti-spoofing rationale, but it should be named on the ticket rather than discovered later. - Minor: with `beforeEach` clearing the markers, the two tool-level tests now exercise only the headless branch; the CI-set tool path is covered for the `as_operator: true` case only. Fine, but a tool-level "worker, no flag" case with `CI` set would close the last combination. Nothing here needs to change before merge; the refusal is fail-closed on every path it claims, the failing-branch confusion is genuinely fixed, and the second commit's "test-only" claim checks out.
Collaborator

Verdict: CHANGES_REQUESTED

model: omniroute/deepseek-flash
head: 2e2db8375a
session: homelab-2674-review-2

BLOCKER B1 — two exported write tools still write to Forgejo as the operator with no identity gate at all.

I checked the code, not the prose: forgejo/operator-identity.ts, forgejo/tests/operator-identity.test.ts, the diff 48c5000..2e2db83, both call sites in api.ts, the tool registrations in index.ts, and the checkOperatorIdentity call in verdict-cli.ts. Head sha confirmed as 2e2db83 via GET. I ran CI=true npx vitest run forgejo/tests/operator-identity.test.ts and env -u CI npx vitest run forgejo/tests/operator-identity.test.ts — 18/18 pass both ways. Full forgejo/tests on this head: 6803 tests pass, zero non-.worktrees failures (the 36 failures are the stale-worktree typebox resolution errors, ignored as instructed).

1. Coverage of the rule — the highest-value question, and it fails.

There are five POST sites in api.ts and four mutating tools in index.ts. Gated: commentOnPR (api.ts:1752) → postVerifiedComment (api.ts:449) → requires the IDENTITY_PROOF brand minted only by assertWriteIdentity (api.ts:320, operator check at api.ts:389); mergePR (api.ts:1356) → mergeAsLanding (api.ts:1374) → assertMergeIdentity (api.ts:1114, operator check at api.ts:1151). Ungated:

  • forgejo_create_pr — index.ts:607-641. execute() calls getConfig() (the operator's ambient FORGEJO_API_KEY) then createPR (api.ts:1317) and returns. No assertWriteIdentity, no checkOperatorIdentity, no as_operator parameter. A worker with the inherited token opens a PR authored by aaron. Confirmed mechanically: no registerTool wrapper exists in index.ts (registrations at 409/435/471/499/533/571/607/643/788/970/1056/1186/1224/1261/1294), so nothing gates it upstream.
  • forgejo_create_repo — index.ts:471-497. Same shape: createRepo (api.ts:1241, POST at api.ts:1251) with no identity assertion of any kind.
  • Latent: createWebhook (api.ts:1998, POST at api.ts:2010) is exported and unguarded; no tool registers it today, so it is not currently reachable, but it is the next hole the moment someone wires it up.

So the invariant the PR states — the module header "a worker never writes as the operator" (operator-identity.ts:1-2) and the refusal text "A worker never writes as the operator, whatever it passes" (operator-identity.ts:140) — is false, and the comment at api.ts:388 "this catches every write" is false. It catches the two writes that were already the ones under examination. Everything else in the change is sound; this is the gap.

2. Is the "interactive" test honest? No — and the doc claims impossibility where the code has a gap.

isInteractiveSession is stdin.isTTY && stdout.isTTY (operator-identity.ts:104-106). An agent running inside the operator's own pi TUI inherits both without allocating anything, so for that process the TTY half is already satisfied and as_operator: true is sufficient — it can post as aaron at will. That is the common orchestrator case, not an exotic one. The module doc says the opposite: "It cannot be spoofed by passing as_operator: true either, because that argument is only half of the condition — the other half is whether this PROCESS has a terminal … a property of how the session was launched" (operator-identity.ts:32-36). The half-condition is real but it does not separate worker from operator in a TUI session: the launch property is identical for the human and the agent sitting in that terminal.

The residual-gap paragraph is also wrong where it is most needed. It says "Worker sessions (subagents, systemd units, CI) are headless" (operator-identity.ts:36). Subagents are not headless when their parent has a terminal — they inherit it. The actual defence for a subagent is the PI_SUBAGENT marker, not headlessness. And that marker is not verified anywhere: in this reviewer session, which is a subagent, PI_SUBAGENT and LD_WORKER are both unset in the tool subprocess environment (measured with env). If the harness does set PI_SUBAGENT in the pi process and the bash tool filters it, this point is moot — but nothing in the PR establishes it either way, so the doc's "subagents are headless" is load-bearing prose that has never been tested. What the doc should say: worker markers are the real defence; TTY plus flag is sufficient for any process that has a terminal, including an agent in the operator's cockpit; the residual gap is wider than "a worker that allocates a pty for itself" and includes "an agent that inherits one, in the session type that most often holds the operator's token".

3. Tests: behaviour is pinned; two nits.

The precedence test (tests/operator-identity.test.ts, "the worker-context rule outranks headlessness and the flag") is honest — it stubs CI deliberately and asserts both positively (declares itself a worker (CI is set)) and negatively (not /not an interactive terminal session/), so it cannot pass by matching the wrong branch. Good.

The beforeEach clearing does not weaken the FORGEJO_WORKER_CONTEXT test: that test stubs its marker inside the body, after beforeEach runs. The save-once savedWorkerEnv map plus restoreWorkerEnv() in afterEach round-trips correctly (each afterEach clears the map), so no test leaks a marker into the next and no test depends on ambient state. Verified by the two runs (CI set / CI unset) agreeing.

Nit (low): "a declared worker context refuses even with a terminal and the flag" does not make anything interactive — the tool passes this process's real streams, which are headless, so it enters checkOperatorIdentity at the headless point and would still refuse if the marker were absent-but-headless. The property its title claims is genuinely proven, but only at the pure layer ("every worker marker refuses, even interactive + flag"). The title should say so, or the tool path should accept an injected streams double. It also calls vi.unstubAllEnvs() mid-body then re-sets two vars, which is dead work under the current afterEach.

Nit (low): the file's header comment in tests/operator-identity.test.ts states the failure mode of the first version precisely; that is the kind of prose that earns its place. No action.

4. Legitimate flows: no regression found.

  • Reviewer verdicts: ld-reviewer is not the operator login, so checkOperatorIdentity returns ok immediately (operator-identity.ts:126-129); pinned by "the reviewer identity is unaffected: ld-reviewer still posts, verified".
  • Landing merges: pi-landing likewise short-circuits; assertMergeIdentity's new operator check is additive and only fires when the landing credential itself resolves to aaron. Pinned by "assertMergeIdentity refuses the operator login off a terminal".
  • Human operator as themselves: interactive + as_operator: true passes (pinned at the decision layer and at assertWriteIdentity). Note forgejo_merge_pr (index.ts:708) never passes declaredOperator, so an operator cannot merge as aaron even from a terminal — consistent with the landing-identity policy, so I treat it as intended, not a break. Worth a sentence in the PR body so it is not later mistaken for a bug.
  • Verdict helper: verdict-cli.ts:903 hardcodes declaredOperator: false, so a verdict as aaron is refused by rule 2 even from a real terminal — the strongest leg of the change, and the one that closes the actual incident.

5. Overstated claims (fix with B1).

api.ts:388 "this catches every write"; operator-identity.ts:1-2 and :140 "a worker never writes as the operator"; operator-identity.ts:32-36 "cannot be spoofed"/"subagents … are headless". Each is a guarantee the code does not deliver as stated. Either gate the remaining write paths, or narrow the text to "a worker never posts a comment or merges as the operator" and say plainly that forgejo_create_pr / forgejo_create_repo still write with the ambient token.

Why I differ from the first reviewer. They approved. On everything the diff touches, I agree the implementation is correct and the tests are real. Where I disagree is scope: the change's own documentation asserts a universal invariant, and two live tools break it. On a dangerous-class change whose entire purpose is "a worker never writes as the operator", shipping with the claim intact and the holes open is the failure the review exists to catch. B1 is cheap to close — one assertWriteIdentity call in each of index.ts:607 and index.ts:471, or a corrected, narrow claim in the module header and api.ts:388 — and I will approve immediately on either.

Not fixed here and not reviewed for: whether the harness should stop exporting the operator token into worker environments at all (the true structural fix, recorded as HOMELAB-2674 activation work). That is out of this diff and I am not blocking on it.

## Verdict: CHANGES_REQUESTED model: omniroute/deepseek-flash head: 2e2db8375ab8bb155cc213a16ddb532acae9ee21 session: homelab-2674-review-2 BLOCKER B1 — two exported write tools still write to Forgejo as the operator with no identity gate at all. I checked the code, not the prose: `forgejo/operator-identity.ts`, `forgejo/tests/operator-identity.test.ts`, the diff 48c5000..2e2db83, both call sites in `api.ts`, the tool registrations in `index.ts`, and the `checkOperatorIdentity` call in `verdict-cli.ts`. Head sha confirmed as 2e2db83 via GET. I ran `CI=true npx vitest run forgejo/tests/operator-identity.test.ts` and `env -u CI npx vitest run forgejo/tests/operator-identity.test.ts` — 18/18 pass both ways. Full `forgejo/tests` on this head: 6803 tests pass, zero non-`.worktrees` failures (the 36 failures are the stale-worktree `typebox` resolution errors, ignored as instructed). **1. Coverage of the rule — the highest-value question, and it fails.** There are five `POST` sites in `api.ts` and four mutating tools in `index.ts`. Gated: `commentOnPR` (api.ts:1752) → `postVerifiedComment` (api.ts:449) → requires the `IDENTITY_PROOF` brand minted only by `assertWriteIdentity` (api.ts:320, operator check at api.ts:389); `mergePR` (api.ts:1356) → `mergeAsLanding` (api.ts:1374) → `assertMergeIdentity` (api.ts:1114, operator check at api.ts:1151). Ungated: - `forgejo_create_pr` — `index.ts:607-641`. `execute()` calls `getConfig()` (the operator's ambient `FORGEJO_API_KEY`) then `createPR` (api.ts:1317) and returns. No `assertWriteIdentity`, no `checkOperatorIdentity`, no `as_operator` parameter. A worker with the inherited token opens a PR authored by `aaron`. Confirmed mechanically: no `registerTool` wrapper exists in `index.ts` (registrations at 409/435/471/499/533/571/607/643/788/970/1056/1186/1224/1261/1294), so nothing gates it upstream. - `forgejo_create_repo` — `index.ts:471-497`. Same shape: `createRepo` (api.ts:1241, POST at api.ts:1251) with no identity assertion of any kind. - Latent: `createWebhook` (api.ts:1998, POST at api.ts:2010) is exported and unguarded; no tool registers it today, so it is not currently reachable, but it is the next hole the moment someone wires it up. So the invariant the PR states — the module header "a worker never writes as the operator" (operator-identity.ts:1-2) and the refusal text "A worker never writes as the operator, whatever it passes" (operator-identity.ts:140) — is false, and the comment at api.ts:388 "this catches every write" is false. It catches the two writes that were already the ones under examination. Everything else in the change is sound; this is the gap. **2. Is the "interactive" test honest? No — and the doc claims impossibility where the code has a gap.** `isInteractiveSession` is `stdin.isTTY && stdout.isTTY` (operator-identity.ts:104-106). An agent running inside the operator's own pi TUI inherits both without allocating anything, so for that process the TTY half is already satisfied and `as_operator: true` is sufficient — it can post as `aaron` at will. That is the common orchestrator case, not an exotic one. The module doc says the opposite: "It cannot be spoofed by passing `as_operator: true` either, because that argument is only half of the condition — the other half is whether this PROCESS has a terminal … a property of how the session was launched" (operator-identity.ts:32-36). The half-condition is real but it does not separate worker from operator in a TUI session: the launch property is *identical* for the human and the agent sitting in that terminal. The residual-gap paragraph is also wrong where it is most needed. It says "Worker sessions (subagents, systemd units, CI) are headless" (operator-identity.ts:36). Subagents are not headless when their parent has a terminal — they inherit it. The actual defence for a subagent is the `PI_SUBAGENT` marker, not headlessness. And that marker is not verified anywhere: in this reviewer session, which *is* a subagent, `PI_SUBAGENT` and `LD_WORKER` are both unset in the tool subprocess environment (measured with `env`). If the harness does set `PI_SUBAGENT` in the pi process and the bash tool filters it, this point is moot — but nothing in the PR establishes it either way, so the doc's "subagents are headless" is load-bearing prose that has never been tested. What the doc should say: worker markers are the real defence; TTY plus flag is sufficient for any process that has a terminal, including an agent in the operator's cockpit; the residual gap is wider than "a worker that allocates a pty for itself" and includes "an agent that inherits one, in the session type that most often holds the operator's token". **3. Tests: behaviour is pinned; two nits.** The precedence test (tests/operator-identity.test.ts, "the worker-context rule outranks headlessness and the flag") is honest — it stubs `CI` deliberately *and* asserts both positively (`declares itself a worker (CI is set)`) and negatively (`not /not an interactive terminal session/`), so it cannot pass by matching the wrong branch. Good. The `beforeEach` clearing does not weaken the `FORGEJO_WORKER_CONTEXT` test: that test stubs its marker *inside* the body, after `beforeEach` runs. The save-once `savedWorkerEnv` map plus `restoreWorkerEnv()` in `afterEach` round-trips correctly (each `afterEach` clears the map), so no test leaks a marker into the next and no test depends on ambient state. Verified by the two runs (CI set / CI unset) agreeing. Nit (low): "a declared worker context refuses even with a terminal and the flag" does not make anything interactive — the tool passes this process's real streams, which are headless, so it enters `checkOperatorIdentity` at the headless point and would still refuse if the marker were absent-but-headless. The property its title claims is genuinely proven, but only at the pure layer ("every worker marker refuses, even interactive + flag"). The title should say so, or the tool path should accept an injected streams double. It also calls `vi.unstubAllEnvs()` mid-body then re-sets two vars, which is dead work under the current `afterEach`. Nit (low): the file's header comment in tests/operator-identity.test.ts states the failure mode of the first version precisely; that is the kind of prose that earns its place. No action. **4. Legitimate flows: no regression found.** - Reviewer verdicts: `ld-reviewer` is not the operator login, so `checkOperatorIdentity` returns ok immediately (operator-identity.ts:126-129); pinned by "the reviewer identity is unaffected: ld-reviewer still posts, verified". - Landing merges: `pi-landing` likewise short-circuits; `assertMergeIdentity`'s new operator check is additive and only fires when the landing credential itself resolves to `aaron`. Pinned by "assertMergeIdentity refuses the operator login off a terminal". - Human operator as themselves: interactive + `as_operator: true` passes (pinned at the decision layer and at `assertWriteIdentity`). Note `forgejo_merge_pr` (index.ts:708) never passes `declaredOperator`, so an operator cannot merge as `aaron` even from a terminal — consistent with the landing-identity policy, so I treat it as intended, not a break. Worth a sentence in the PR body so it is not later mistaken for a bug. - Verdict helper: `verdict-cli.ts:903` hardcodes `declaredOperator: false`, so a verdict as `aaron` is refused by rule 2 even from a real terminal — the strongest leg of the change, and the one that closes the actual incident. **5. Overstated claims (fix with B1).** `api.ts:388` "this catches every write"; `operator-identity.ts:1-2` and `:140` "a worker never writes as the operator"; `operator-identity.ts:32-36` "cannot be spoofed"/"subagents … are headless". Each is a guarantee the code does not deliver as stated. Either gate the remaining write paths, or narrow the text to "a worker never posts a comment or merges as the operator" and say plainly that `forgejo_create_pr` / `forgejo_create_repo` still write with the ambient token. **Why I differ from the first reviewer.** They approved. On everything the diff touches, I agree the implementation is correct and the tests are real. Where I disagree is scope: the change's own documentation asserts a universal invariant, and two live tools break it. On a dangerous-class change whose entire purpose is "a worker never writes as the operator", shipping with the claim intact and the holes open is the failure the review exists to catch. B1 is cheap to close — one `assertWriteIdentity` call in each of `index.ts:607` and `index.ts:471`, or a corrected, narrow claim in the module header and `api.ts:388` — and I will approve immediately on either. Not fixed here and not reviewed for: whether the harness should stop exporting the operator token into worker environments at all (the true structural fix, recorded as HOMELAB-2674 activation work). That is out of this diff and I am not blocking on it.
HOMELAB-2674: fix(forgejo): gate the two write tools that could still write as the operator
Some checks failed
CI / pr-title (pull_request) Successful in 3s
CI / pi-otel-telemetry (pull_request) Successful in 1m10s
CI / forgejo-ci (pull_request) Successful in 38s
CI / forgejo (pull_request) Failing after 1m13s
CI / plane (pull_request) Successful in 1m21s
CI / skill-args (pull_request) Successful in 34s
CI / mcp-bridge (pull_request) Successful in 24s
CI / todo (pull_request) Successful in 45s
CI / nats-intercom (pull_request) Successful in 37s
tests / python (pull_request) Successful in 5s
CI / pi-swarm-suite (pull_request) Successful in 4m6s
CI / ask-user-question (pull_request) Successful in 54s
CI / refocus (pull_request) Successful in 1m1s
CI / redact (pull_request) Successful in 46s
CI / pi-friction (pull_request) Successful in 49s
1d2268037c
Round 2. An independent reviewer found the invariant was overstated: `forgejo_create_pr`
and `forgejo_create_repo` POST with whatever credential the process holds and never
called `assertWriteIdentity`, so a worker carrying the inherited operator token could
open a PR or create a repository as `aaron` while the module documentation claimed a
worker never writes as the operator. The claim was false in exactly the way this ticket
exists to stop.

Code:
* `forgejo_create_pr` and `forgejo_create_repo` now take `expected_identity` /
  `as_operator` and call `assertWriteIdentity` BEFORE any POST; a refusal writes
  nothing and names the operator account.
* Three tests run against the tool surface, not the pure layer: both tools refuse an
  operator write from a worker process, and a declared non-operator identity still
  opens a PR (the gate does not break a legitimate flow).

Documentation, narrowed to what the code does:
* `api.ts` no longer says the assertion "catches every write" — it is not a
  chokepoint, and the gated set is now enumerated.
* `operator-identity.ts` no longer claims the distinction "cannot be spoofed": an agent
  inside an interactive pi TUI INHERITS a terminal, so `as_operator: true` IS sufficient
  for it. What covers that is the worker marker, and `PI_SUBAGENT` being absent from a
  worker environment is a harness hole this module cannot close. "Subagents are
  headless" was wrong and is removed.

Verified: CI=true and env -u CI both 21/21 on this file; whole forgejo suite green.
Collaborator

Verdict: CHANGES_REQUESTED

model: omniroute/deepseek-flash
head: 1d2268037c
session: homelab-2674-review-3

BLOCKER B1 forgejo/tsconfig.json typecheck is RED on this head: npx tsc --noEmit in forgejo/ reports Found 2 errors in the same file, starting at: index.ts:489, and CI job forgejo on sha 1d2268037c (run #258, attempt #1, task 63737) failed on exactly that (RUN exit status 2). The two errors are TS2322 at forgejo/index.ts:489 (forgejo_create_repo) and forgejo/index.ts:661 (forgejo_create_pr), both newly introduced by commit 1d22680. The cause is the same in both: the refusal branch returns details: { refused: true, expected, expectedSource, actual, reason } while the success branch returns details: Repository / details: PullRequest, and the tool's inferred AgentToolResult<T> is pinned to the refusal shape, so the success branch is no longer assignable (Type 'Repository' is missing the following properties from type '{refused, expected, expectedSource, actual, reason}'). npm run typecheck is a gating step in .forgejo/workflows/ci.yaml:178-203 (job forgejo), so this head cannot land. Green vitest does not cover it — esbuild strips types, so the 21 tests pass while the types are wrong. Fix by giving both branches one declared details type (the way forgejo_comment_on_pr does with satisfies CommentToolDetails), or annotate the execute return explicitly. Please also re-run cd forgejo && npm run typecheck before pushing; the same red exists locally and only CI was consulted.

I verified the two previously reported holes are genuinely closed. Enumeration I performed (every POST-capable path):

Tool surface, forgejo/index.ts — writes: forgejo_create_repo (:471) gated at :494; forgejo_create_pr (:642) gated at :672; forgejo_comment_on_pr (:1126) gated at :1172 and additionally requires the minted brand via postVerifiedComment (api.ts:452-467 throws without IDENTITY_PROOF); forgejo_merge_pr (:713) gated by the stricter landing leg — resolveLandingCredentialAtRuntime then assertMergeIdentity (api.ts:1117), which calls checkOperatorIdentity at api.ts:1154 before the landing-login comparison. The remaining eleven tools are reads. Non-tool entry point forgejo/verdict-cli.ts:903 calls checkOperatorIdentity with declaredOperator: false, so a verdict can never be authored by the operator there either — that is the highest-stakes path and it is covered.

Not gated, and this is where I part company with "the invariant is complete": api.ts:1244 createRepo, api.ts:1320 createPR, api.ts:1347 mergePR, api.ts:2001 createWebhook are all exported and all POST with whatever FORGEJO_API_KEY holds. The module header (operator-identity.ts:6-8) and the api.ts:388-391 comment are honest that assertWriteIdentity is not a chokepoint, so this is not a doc lie — but createWebhook is imported at forgejo/index.ts:61 and never used anywhere in the tree: a live ungated POST carried by a dead import. Please either drop the import and make createWebhook module-private, or gate it. Severity: medium (unreachable from the tool surface today, one import away from being reachable again); the three repo/PR/merge helpers are low, since the tool layer is the only caller and it now gates.

The three new tool-level tests are real, and I checked them the way that matters — would they fail with the gate removed? forgejo/tests/operator-identity.test.ts:407-476 drives tools.get("forgejo_create_pr").execute(...) and tools.get("forgejo_create_repo").execute(...) through fakePi(), not the pure function, with mockFetch routes that would answer 201 for the POST. Remove either assertWriteIdentity call and expect(mutating(h.calls)).toEqual([]) goes red because the POST lands, and textOf(result) stops matching /Refusing to open a PR/. The third test pins the non-regression direction (a declared ld-reviewer still opens the PR, mutating(calls) length 1). One residual softness, not blocking: the create_repo test asserts only /operator account "aaron"/, which both the worker-context and headless reasons satisfy, so it would still pass if one branch were deleted — the create_pr test and the precedence test do the discriminating work.

Legitimate flows: I traced all three. A reviewer posting as ld-reviewer returns {ok: true} from the first branch of checkOperatorIdentity (operator-identity.ts:139-144) before any marker or TTY test, and is pinned by the test at :253. The landing worker merging as pi-landing likewise short-circuits at :139 and then satisfies the landing-login comparison. A human operator needs an interactive terminal, no worker marker, and as_operator: true — which is the intended cost; worth noting for the operator that an exported CI in their own shell refuses them until they unset it, as the header at operator-identity.ts:63-66 already says.

Doc comments are now accurate, including the part I expected to be wrong: operator-identity.ts:41-58 states the TUI-inherited-terminal gap, that "Subagents are headless is NOT true in general", and that PI_SUBAGENT absence is a harness hole this module cannot close. That is the honest version of the claim that was overstated before, and I could not find a surviving sentence that promises more than the code delivers.

No new flakiness. clearWorkerEnv/restoreWorkerEnv (tests:47-68) save the ambient value on first clear and restore in afterEach, so deleting CI cannot leak into another file; both CI=true npx vitest run and env -u CI npx vitest run give 21/21 locally, matching CI.

Nothing here is a design objection — the feature is right and the two holes are closed. B1 is a compile error on a gating CI step and must be fixed before merge.

## Verdict: CHANGES_REQUESTED model: omniroute/deepseek-flash head: 1d2268037c9ad808843d81c3ba52827283ec49b6 session: homelab-2674-review-3 BLOCKER B1 `forgejo/tsconfig.json` typecheck is RED on this head: `npx tsc --noEmit` in `forgejo/` reports `Found 2 errors in the same file, starting at: index.ts:489`, and CI job `forgejo` on sha 1d2268037c (run #258, attempt #1, task 63737) failed on exactly that (`RUN exit status 2`). The two errors are TS2322 at `forgejo/index.ts:489` (`forgejo_create_repo`) and `forgejo/index.ts:661` (`forgejo_create_pr`), both newly introduced by commit 1d22680. The cause is the same in both: the refusal branch returns `details: { refused: true, expected, expectedSource, actual, reason }` while the success branch returns `details: Repository` / `details: PullRequest`, and the tool's inferred `AgentToolResult<T>` is pinned to the refusal shape, so the success branch is no longer assignable (`Type 'Repository' is missing the following properties from type '{refused, expected, expectedSource, actual, reason}'`). `npm run typecheck` is a gating step in `.forgejo/workflows/ci.yaml:178-203` (job `forgejo`), so this head cannot land. Green vitest does not cover it — esbuild strips types, so the 21 tests pass while the types are wrong. Fix by giving both branches one declared details type (the way `forgejo_comment_on_pr` does with `satisfies CommentToolDetails`), or annotate the `execute` return explicitly. Please also re-run `cd forgejo && npm run typecheck` before pushing; the same red exists locally and only CI was consulted. I verified the two previously reported holes are genuinely closed. Enumeration I performed (every POST-capable path): Tool surface, `forgejo/index.ts` — writes: `forgejo_create_repo` (:471) gated at :494; `forgejo_create_pr` (:642) gated at :672; `forgejo_comment_on_pr` (:1126) gated at :1172 and additionally requires the minted brand via `postVerifiedComment` (api.ts:452-467 throws without `IDENTITY_PROOF`); `forgejo_merge_pr` (:713) gated by the stricter landing leg — `resolveLandingCredentialAtRuntime` then `assertMergeIdentity` (api.ts:1117), which calls `checkOperatorIdentity` at api.ts:1154 before the landing-login comparison. The remaining eleven tools are reads. Non-tool entry point `forgejo/verdict-cli.ts:903` calls `checkOperatorIdentity` with `declaredOperator: false`, so a verdict can never be authored by the operator there either — that is the highest-stakes path and it is covered. Not gated, and this is where I part company with "the invariant is complete": `api.ts:1244 createRepo`, `api.ts:1320 createPR`, `api.ts:1347 mergePR`, `api.ts:2001 createWebhook` are all exported and all POST with whatever `FORGEJO_API_KEY` holds. The module header (operator-identity.ts:6-8) and the api.ts:388-391 comment are honest that `assertWriteIdentity` is not a chokepoint, so this is not a doc lie — but `createWebhook` is imported at `forgejo/index.ts:61` and never used anywhere in the tree: a live ungated POST carried by a dead import. Please either drop the import and make `createWebhook` module-private, or gate it. Severity: medium (unreachable from the tool surface today, one `import` away from being reachable again); the three repo/PR/merge helpers are low, since the tool layer is the only caller and it now gates. The three new tool-level tests are real, and I checked them the way that matters — would they fail with the gate removed? `forgejo/tests/operator-identity.test.ts:407-476` drives `tools.get("forgejo_create_pr").execute(...)` and `tools.get("forgejo_create_repo").execute(...)` through `fakePi()`, not the pure function, with `mockFetch` routes that would answer 201 for the POST. Remove either `assertWriteIdentity` call and `expect(mutating(h.calls)).toEqual([])` goes red because the POST lands, and `textOf(result)` stops matching `/Refusing to open a PR/`. The third test pins the non-regression direction (a declared `ld-reviewer` still opens the PR, `mutating(calls)` length 1). One residual softness, not blocking: the `create_repo` test asserts only `/operator account "aaron"/`, which both the worker-context and headless reasons satisfy, so it would still pass if one branch were deleted — the `create_pr` test and the precedence test do the discriminating work. Legitimate flows: I traced all three. A reviewer posting as `ld-reviewer` returns `{ok: true}` from the first branch of `checkOperatorIdentity` (operator-identity.ts:139-144) before any marker or TTY test, and is pinned by the test at :253. The landing worker merging as `pi-landing` likewise short-circuits at :139 and then satisfies the landing-login comparison. A human operator needs an interactive terminal, no worker marker, and `as_operator: true` — which is the intended cost; worth noting for the operator that an exported `CI` in their own shell refuses them until they unset it, as the header at operator-identity.ts:63-66 already says. Doc comments are now accurate, including the part I expected to be wrong: operator-identity.ts:41-58 states the TUI-inherited-terminal gap, that "Subagents are headless is NOT true in general", and that `PI_SUBAGENT` absence is a harness hole this module cannot close. That is the honest version of the claim that was overstated before, and I could not find a surviving sentence that promises more than the code delivers. No new flakiness. `clearWorkerEnv`/`restoreWorkerEnv` (tests:47-68) save the ambient value on first clear and restore in `afterEach`, so deleting `CI` cannot leak into another file; both `CI=true npx vitest run` and `env -u CI npx vitest run` give 21/21 locally, matching CI. Nothing here is a design objection — the feature is right and the two holes are closed. B1 is a compile error on a gating CI step and must be fixed before merge.
HOMELAB-2674: fix(forgejo): one flat details type for the two gated write tools
All checks were successful
CI / pr-title (pull_request) Successful in 3s
CI / forgejo-ci (pull_request) Successful in 1m5s
CI / pi-otel-telemetry (pull_request) Successful in 1m10s
CI / skill-args (pull_request) Successful in 1m2s
CI / todo (pull_request) Successful in 1m4s
CI / mcp-bridge (pull_request) Successful in 28s
CI / ask-user-question (pull_request) Successful in 28s
CI / plane (pull_request) Successful in 1m25s
CI / refocus (pull_request) Successful in 37s
tests / python (pull_request) Successful in 7s
CI / nats-intercom (pull_request) Successful in 26s
CI / forgejo (pull_request) Successful in 1m36s
CI / redact (pull_request) Successful in 45s
CI / pi-friction (pull_request) Successful in 47s
CI / pi-swarm-suite (pull_request) Successful in 4m12s
d9caa532a4
The previous commit's refusal and success branches returned two different
`details` shapes, and `registerTool` infers a single details type, so the tool
did not typecheck:

  index.ts(489,11): Type 'Promise<... | { details: Repository }>' is not
  assignable ... 'details' are incompatible: Type 'Repository' is missing
  'refused', 'expected', 'expectedSource', 'actual', 'reason'

CI's `forgejo` job caught it (the job typechecks, the local vitest run does not),
which is why the head went red.

Fix, following the existing MergeToolDetails pattern: one flat interface
`CreateToolDetails` that BOTH outcomes conform to, assigned to a declared
`const details` in every return site so the inferred type is the same on both
branches. Success carries `created`; refusal carries `actual`/`reason`.
`forgejo_create_pr`'s unparseable-repo-ref branch also returns that shape instead
of `null`, so the tool has one details type rather than a union with null. No
message text and no behaviour changed.

Verified: `forgejo/node_modules/.bin/tsc --noEmit` clean; operator-identity suite
21/21 under CI=true and env -u CI; whole forgejo suite 7117 tests pass.
Collaborator

Verdict: APPROVED

model: omniroute/deepseek-flash
head: d9caa532a4
session: homelab-2674-review-5

Verified independently on this exact head. GET /pulls/92 answers head d9caa532a4…, base main, mergeable, open. Workflow run on this sha: forgejo job success (run #262), python tag #263 green — the red typecheck from review-3 is closed.

1. Typecheck (the claimed fix is real). cd forgejo && ./node_modules/.bin/tsc --noEmit → exit 0, no output. Both TS2322 sites are gone.

2. CreateToolDetails and every return. forgejo/index.ts:85-100 declares one flat interface (refused, optional actual/expected/expectedSource/reason, optional created). All five return sites conform, each via a declared const details: CreateToolDetails: index.ts:520 (create_repo refusal), :545 (create_repo success), :690 (create_pr unparseable ref, previously details: null), :707 (create_pr refusal), :733 (create_pr success). The success-shape change is safe: no consumer exists — grep for details.created/details.refused outside tests/operator-identity.test.ts finds nothing, and the human/agent-visible content text is byte-identical (Created …: <url>), so an agent sees the same output as before. api.ts still exports the raw createRepo/createPR; the gate lives at the tool layer, which api.ts:404-407 now says explicitly.

3. Tests. CI=true → 21/21. env -u CI → 21/21. Whole forgejo/tests → 7117 passed; the 48 failing files are all .worktrees/** Cannot find package 'typebox' collection errors, no real failure.

4. Write paths from registered tools — all four gated. forgejo_create_repo (index.ts:514), forgejo_create_pr (:701), forgejo_comment_on_pr (:1211, assertWriteIdentity then verified read-back), forgejo_merge_pr (:812 resolveLandingCredentialAtRuntime + assertMergeIdentity, whose checkOperatorIdentity sits at api.ts:1151). verdict-cli.ts:899 covers verdict posting. The three /-commands (index.ts:1424,1445,1458) only emit prompts — no write.
createWebhook (api.ts:2001) is imported-but-unused at index.ts:61 and reachable from no tool: dead code, not a live risk, but it is a loaded gun for the next wiring and the unused import proves nothing calls it — delete it or gate it when used.

5. Legitimate flows. No in-repo or homelab-repo automation calls these tools (skills, AGENTS.md, loadtest scripts: none). The behavior change is real but intended: a headless/worker process can no longer open a PR as aaron; it must hold a non-operator token.

Non-blocking findings:

  • index.ts:80-84: the HOMELAB-2526 comment now sits above CreateToolDetails; MergeToolDetails (:102) is left undocumented and the 2526 note mislabels the new interface.
  • docs/forgejo.md:44 and the usage example :818 do not mention expected_identity/as_operator; the refusal text (index.ts:521-534, :711-724) offers only "use a FORGEJO_EXPECTED_IDENTITY token" and never names as_operator: true as the interactive operator's route.
  • Measured in this review session, which is itself a worker: FORGEJO_API_KEY resolves to aaron via GET /user, and none of LD_WORKER/PI_SUBAGENT/CI/FORGEJO_WORKER_CONTEXT is set; only non-TTY (stdin.isTTY=false) refused me. The markers are absent in practice, so the harness hole operator-identity.ts:47-58 documents is real; dispatchers should export FORGEJO_WORKER_CONTEXT=1. Follow-up, not a blocker on this diff.
## Verdict: APPROVED model: omniroute/deepseek-flash head: d9caa532a46cacda2c5525115d2aed88ac12ab59 session: homelab-2674-review-5 Verified independently on this exact head. `GET /pulls/92` answers head `d9caa532a4…`, base `main`, mergeable, open. Workflow run on this sha: `forgejo` job **success** (run #262), `python` tag #263 green — the red typecheck from review-3 is closed. **1. Typecheck (the claimed fix is real).** `cd forgejo && ./node_modules/.bin/tsc --noEmit` → exit **0**, no output. Both TS2322 sites are gone. **2. `CreateToolDetails` and every return.** `forgejo/index.ts:85-100` declares one flat interface (`refused`, optional `actual`/`expected`/`expectedSource`/`reason`, optional `created`). All five return sites conform, each via a declared `const details: CreateToolDetails`: `index.ts:520` (create_repo refusal), `:545` (create_repo success), `:690` (create_pr unparseable ref, previously `details: null`), `:707` (create_pr refusal), `:733` (create_pr success). The success-shape change is **safe**: no consumer exists — `grep` for `details.created`/`details.refused` outside `tests/operator-identity.test.ts` finds nothing, and the human/agent-visible `content` text is byte-identical (`Created …: <url>`), so an agent sees the same output as before. `api.ts` still exports the raw `createRepo`/`createPR`; the gate lives at the tool layer, which `api.ts:404-407` now says explicitly. **3. Tests.** `CI=true` → 21/21. `env -u CI` → 21/21. Whole `forgejo/tests` → 7117 passed; the 48 failing *files* are all `.worktrees/**` `Cannot find package 'typebox'` collection errors, no real failure. **4. Write paths from registered tools — all four gated.** `forgejo_create_repo` (`index.ts:514`), `forgejo_create_pr` (`:701`), `forgejo_comment_on_pr` (`:1211`, `assertWriteIdentity` then verified read-back), `forgejo_merge_pr` (`:812` `resolveLandingCredentialAtRuntime` + `assertMergeIdentity`, whose `checkOperatorIdentity` sits at `api.ts:1151`). `verdict-cli.ts:899` covers verdict posting. The three `/`-commands (`index.ts:1424,1445,1458`) only emit prompts — no write. `createWebhook` (`api.ts:2001`) is imported-but-unused at `index.ts:61` and reachable from no tool: **dead code, not a live risk**, but it is a loaded gun for the next wiring and the unused import proves nothing calls it — delete it or gate it when used. **5. Legitimate flows.** No in-repo or homelab-repo automation calls these tools (skills, AGENTS.md, loadtest scripts: none). The behavior change is real but intended: a headless/worker process can no longer open a PR as `aaron`; it must hold a non-operator token. Non-blocking findings: - `index.ts:80-84`: the HOMELAB-2526 comment now sits above `CreateToolDetails`; `MergeToolDetails` (`:102`) is left undocumented and the 2526 note mislabels the new interface. - `docs/forgejo.md:44` and the usage example `:818` do not mention `expected_identity`/`as_operator`; the refusal text (`index.ts:521-534`, `:711-724`) offers only "use a FORGEJO_EXPECTED_IDENTITY token" and never names `as_operator: true` as the interactive operator's route. - Measured in this review session, which is itself a worker: `FORGEJO_API_KEY` resolves to `aaron` via `GET /user`, and **none** of `LD_WORKER`/`PI_SUBAGENT`/`CI`/`FORGEJO_WORKER_CONTEXT` is set; only non-TTY (`stdin.isTTY=false`) refused me. The markers are absent in practice, so the harness hole `operator-identity.ts:47-58` documents is real; dispatchers should export `FORGEJO_WORKER_CONTEXT=1`. Follow-up, not a blocker on this diff.
Collaborator

Verdict: CHANGES_REQUESTED

model: omniroute/deepseek-flash
head: d9caa532a4
session: homelab-2674-review-6

BLOCKER HOMELAB-2674-B1
BLOCKER HOMELAB-2674-B2

Head verified by GET: d9caa53, open, mergeable. tsc --noEmit clean; operator-identity.test.ts 21/21 under CI=true and under env -u CI; whole forgejo/tests 7117/7117 (48 unrelated .worktrees/** collection errors, as expected). The CODE is sound and I found no unsafe write path. Both blockers are about the change not saying what it does — which, for a change whose entire subject is "which writes are gated", is the substance, not the paperwork.

1. The invariant, path by path

Every write in api.ts, and its gate:

write api.ts tool gated?
POST user/repos 1254 forgejo_create_repo (index.ts:540) yes, assertWriteIdentity index.ts:514
POST repos/*/pulls 1332 forgejo_create_pr (index.ts:727) yes, index.ts:701
POST repos/*/pulls/N/merge 1359 forgejo_merge_pr (index.ts:833) yes, branded mergeAsLanding; assertMergeIdentity index.ts:811
POST repos/*/issues/N/comments 1755 forgejo_comment_on_pr (index.ts:1236) yes, via postVerifiedComment (no identity, no write) + index.ts:1206
POST repos/*/hooks 2013 none NO — see N1

So the invariant is true for the whole registered tool surface, and true by construction for comments and merges (branded proofs). It is NOT a chokepoint, and operator-identity.ts:1-8 says so honestly — that is the right framing.

2. Regression risk — my explicit answer: it IS a regression, and it is unstated

Measured in this session's own environment: FORGEJO_API_KEY resolves to aaron (GET /user), stdin.isTTY=false, stdout.isTTY=false, and none of CI/LD_WORKER/PI_SUBAGENT/FORGEJO_WORKER_CONTEXT is set. That is exactly the shape of a homelab worker. For that process, after this change:

  • forgejo_create_pr and forgejo_create_repo refuse, always.
  • expected_identity is NOT an escape: it must match the token's own login (api.ts:380), so expected_identity: aaron reaches the operator rule and is refused for headlessness; expected_identity: <anyone else> is refused as a mismatch before that.
  • as_operator: true is NOT an escape: headless (operator-identity.ts:160-170).
  • CI/LD_WORKER are not even needed to trigger it — headlessness alone does it.

The only escapes are (a) a token whose login is not aaron — not provisioned for PR creation anywhere in this change, and only ld/forgejo-reviewer exists for verdicts — or (b) raw curl with the inherited token, which lands the same aaron authorship the tool now refuses and is strictly worse hygiene.

homelab-ticket SKILL step 5 is "push the branch; open a PR". In practice this change removes that step for every headless worker. That may well be the correct end state, but it is a delivery-loop outage, and the PR states neither that it happens nor what replaces it. That is B2: name the identity a worker should open PRs as, provision it, or state that workers must not and that activation is gated on that provisioning.

B1 is a narrower instance of the same silence: the PR body says the fix is wired into "the three worker-facing write paths" (comment, merge, verdict-cli). The head gates five — create_pr and create_repo arrived in 1d22680 and are absent from that section. A maintainer reading the description and the code gets two different answers to "what is gated", on the one change that exists to answer that question. Please update the body (and the activation note on HOMELAB-2674) rather than the code.

3. Is interactive a real control?

Yes for the population that matters, and the module now states the limit accurately (operator-identity.ts:43-62): an agent inside an interactive pi TUI inherits a TTY, so as_operator: true alone suffices, and the worker marker is what covers it. My own session confirms the marker cannot be assumed — no marker is set for this subagent, so PI_SUBAGENT being absent from a worker is real, not hypothetical, and headlessness is then the only leg. The text says exactly that; it does not overclaim. Good.

4/5. Non-blocking findings

  • N1 api.ts:2013 — createWebhook is an ungated POST (and index.ts:61 imports it without registering a tool). Unreachable today, but the moment someone wires forgejo_create_webhook it is an operator-attributed write with no gate. Either gate it or delete the dead import so the "gated set is enumerated" claim has no loose end.
  • N2 index.ts:79-88 — the HOMELAB-2526 doc comment now sits directly above CreateToolDetails (index.ts:89); MergeToolDetails (index.ts:103) has lost its comment. Two stacked blocks, wrong one attached: exactly the shape that makes a maintainer misread which contract they are changing.
  • N3 verdict-cli.ts:903-909 — passes env: io.env but no streams, so the rule reads the real global process while everything else in runCli is injectable through CliIO. Untestable at the CLI layer, and it means a dry-run by an operator now exits 1 rather than printing the preview.
  • N4 verdict-cli.ts:907 hard-codes declaredOperator: false, so the CLI has no operator mode even for a genuine interactive operator. Defensible (a verdict authored by the operator is not a review) but worth one sentence in the usage text, since the extension does offer as_operator.

CI

Not flaky as it stands: beforeEach clears all four markers and the precedence is pinned by its own test (operator-identity.test.ts:236-249), which is the fix from 2e2db83. The 21 tests pass with and without CI. No test depends on ambient FORGEJO_EXPECTED_IDENTITY.

## Verdict: CHANGES_REQUESTED model: omniroute/deepseek-flash head: d9caa532a46cacda2c5525115d2aed88ac12ab59 session: homelab-2674-review-6 BLOCKER HOMELAB-2674-B1 BLOCKER HOMELAB-2674-B2 Head verified by GET: `d9caa53`, open, mergeable. `tsc --noEmit` clean; `operator-identity.test.ts` 21/21 under `CI=true` and under `env -u CI`; whole `forgejo/tests` 7117/7117 (48 unrelated `.worktrees/**` collection errors, as expected). The CODE is sound and I found no unsafe write path. Both blockers are about the change not saying what it does — which, for a change whose entire subject is "which writes are gated", is the substance, not the paperwork. ### 1. The invariant, path by path Every write in `api.ts`, and its gate: | write | api.ts | tool | gated? | |---|---|---|---| | `POST user/repos` | 1254 | `forgejo_create_repo` (index.ts:540) | yes, `assertWriteIdentity` index.ts:514 | | `POST repos/*/pulls` | 1332 | `forgejo_create_pr` (index.ts:727) | yes, index.ts:701 | | `POST repos/*/pulls/N/merge` | 1359 | `forgejo_merge_pr` (index.ts:833) | yes, branded `mergeAsLanding`; `assertMergeIdentity` index.ts:811 | | `POST repos/*/issues/N/comments` | 1755 | `forgejo_comment_on_pr` (index.ts:1236) | yes, via `postVerifiedComment` (no identity, no write) + index.ts:1206 | | `POST repos/*/hooks` | 2013 | **none** | **NO** — see N1 | So the invariant is true for the whole registered tool surface, and true by construction for comments and merges (branded proofs). It is NOT a chokepoint, and `operator-identity.ts:1-8` says so honestly — that is the right framing. ### 2. Regression risk — my explicit answer: it IS a regression, and it is unstated Measured in this session's own environment: `FORGEJO_API_KEY` resolves to `aaron` (`GET /user`), `stdin.isTTY=false`, `stdout.isTTY=false`, and none of `CI`/`LD_WORKER`/`PI_SUBAGENT`/`FORGEJO_WORKER_CONTEXT` is set. That is exactly the shape of a homelab worker. For that process, after this change: - `forgejo_create_pr` and `forgejo_create_repo` **refuse, always**. - `expected_identity` is NOT an escape: it must match the token's own login (`api.ts:380`), so `expected_identity: aaron` reaches the operator rule and is refused for headlessness; `expected_identity: <anyone else>` is refused as a mismatch before that. - `as_operator: true` is NOT an escape: headless (`operator-identity.ts:160-170`). - `CI`/`LD_WORKER` are not even needed to trigger it — headlessness alone does it. The only escapes are (a) a token whose login is not `aaron` — not provisioned for PR creation anywhere in this change, and only `ld/forgejo-reviewer` exists for verdicts — or (b) raw `curl` with the inherited token, which lands the same `aaron` authorship the tool now refuses and is strictly worse hygiene. `homelab-ticket` SKILL step 5 is "push the branch; open a PR". In practice this change removes that step for every headless worker. That may well be the correct end state, but it is a delivery-loop outage, and the PR states neither that it happens nor what replaces it. That is B2: name the identity a worker should open PRs as, provision it, or state that workers must not and that activation is gated on that provisioning. B1 is a narrower instance of the same silence: the PR body says the fix is wired into "the three worker-facing write paths" (comment, merge, verdict-cli). The head gates five — `create_pr` and `create_repo` arrived in `1d22680` and are absent from that section. A maintainer reading the description and the code gets two different answers to "what is gated", on the one change that exists to answer that question. Please update the body (and the activation note on HOMELAB-2674) rather than the code. ### 3. Is `interactive` a real control? Yes for the population that matters, and the module now states the limit accurately (`operator-identity.ts:43-62`): an agent inside an interactive pi TUI inherits a TTY, so `as_operator: true` alone suffices, and the worker marker is what covers it. My own session confirms the marker cannot be assumed — no marker is set for this subagent, so `PI_SUBAGENT` being absent from a worker is real, not hypothetical, and headlessness is then the only leg. The text says exactly that; it does not overclaim. Good. ### 4/5. Non-blocking findings - **N1** `api.ts:2013` — `createWebhook` is an ungated POST (and `index.ts:61` imports it without registering a tool). Unreachable today, but the moment someone wires `forgejo_create_webhook` it is an operator-attributed write with no gate. Either gate it or delete the dead import so the "gated set is enumerated" claim has no loose end. - **N2** `index.ts:79-88` — the HOMELAB-2526 doc comment now sits directly above `CreateToolDetails` (index.ts:89); `MergeToolDetails` (index.ts:103) has lost its comment. Two stacked blocks, wrong one attached: exactly the shape that makes a maintainer misread which contract they are changing. - **N3** `verdict-cli.ts:903-909` — passes `env: io.env` but no `streams`, so the rule reads the real global `process` while everything else in `runCli` is injectable through `CliIO`. Untestable at the CLI layer, and it means a dry-run by an operator now exits 1 rather than printing the preview. - **N4** `verdict-cli.ts:907` hard-codes `declaredOperator: false`, so the CLI has no operator mode even for a genuine interactive operator. Defensible (a verdict authored by the operator is not a review) but worth one sentence in the usage text, since the extension *does* offer `as_operator`. ### CI Not flaky as it stands: `beforeEach` clears all four markers and the precedence is pinned by its own test (`operator-identity.test.ts:236-249`), which is the fix from `2e2db83`. The 21 tests pass with and without `CI`. No test depends on ambient `FORGEJO_EXPECTED_IDENTITY`.
All checks were successful
CI / pr-title (pull_request) Successful in 3s
CI / forgejo-ci (pull_request) Successful in 1m5s
CI / pi-otel-telemetry (pull_request) Successful in 1m10s
CI / skill-args (pull_request) Successful in 1m2s
CI / todo (pull_request) Successful in 1m4s
CI / mcp-bridge (pull_request) Successful in 28s
CI / ask-user-question (pull_request) Successful in 28s
CI / plane (pull_request) Successful in 1m25s
CI / refocus (pull_request) Successful in 37s
tests / python (pull_request) Successful in 7s
CI / nats-intercom (pull_request) Successful in 26s
CI / forgejo (pull_request) Successful in 1m36s
CI / redact (pull_request) Successful in 45s
CI / pi-friction (pull_request) Successful in 47s
CI / pi-swarm-suite (pull_request) Successful in 4m12s
This pull request has changes conflicting with the target branch.
  • forgejo/api.ts
  • forgejo/index.ts
  • forgejo/tests/comment-identity-hardening.test.ts
  • forgejo/tests/comment-identity.test.ts
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin plane/HOMELAB-2674-operator-identity:plane/HOMELAB-2674-operator-identity
git switch plane/HOMELAB-2674-operator-identity

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff plane/HOMELAB-2674-operator-identity
git switch plane/HOMELAB-2674-operator-identity
git rebase main
git switch main
git merge --ff-only plane/HOMELAB-2674-operator-identity
git switch plane/HOMELAB-2674-operator-identity
git rebase main
git switch main
git merge --no-ff plane/HOMELAB-2674-operator-identity
git switch main
git merge --squash plane/HOMELAB-2674-operator-identity
git switch main
git merge --ff-only plane/HOMELAB-2674-operator-identity
git switch main
git merge plane/HOMELAB-2674-operator-identity
git push origin main
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!92
No description provided.