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
pull from: plane/HOMELAB-2674-operator-identity
merge into: Homelab:main
Homelab:main
Homelab:reviewers-by-repo-f1
Homelab:lr-20261111-017-s1
Homelab:lr-20261111-017-f5
Homelab:lr-20261111-017-census
Homelab:lr-20261111-017-lockharden
Homelab:lr/IR-20261111-extskill
Homelab:lr-20261111-017-n6split
Homelab:lr-20261111-017-headfollow2
Homelab:lr-20261111-017-headfollow
Homelab:lr-20261111-017-headpin
Homelab:plane/HOMELAB-2479-122-signal-exit-fix
Homelab:plane/HOMELAB-2479-seat-post-helper
Homelab:archive/plane/HOMELAB-1850-attribution-wip-20261110
Homelab:archive/plane/HOMELAB-1688-routines-wip-20261110
Homelab:archive/plane/HOMELAB-2674-operator-identity-wip-20261110
Homelab:lock-frozen-20261107
Homelab:ld/pi-skills-lab-run-archive-fix
Homelab:ldv0/ld-sessiontag-f
Homelab:plane/HOMELAB-2682-reload-trace
Homelab:plane/HOMELAB-2682-fake-tmpfix
Homelab:plane/HOMELAB-2682-forge-write-refuse
Homelab:plane/HOMELAB-2682-ci-key-file
Homelab:plane/ci-pending-status-truth
Homelab:plane/HOMELAB-2682-watchdog-fix
Homelab:plane/HOMELAB-2682-operator-refuse
Homelab:plane/HOMELAB-2682-refusal-forms
Homelab:plane/HOMELAB-2682-selfresolve-nearmiss
Homelab:plane/HOMELAB-2682-cheap-plane-comments
Homelab:plane/HOMELAB-2682-verdict-arm-fix
Homelab:plane/HOMELAB-2682-comment-refuse
Homelab:plane/HOMELAB-2682-finding-rule
Homelab:plane/HOMELAB-2682-blocker-none
Homelab:plane/HOMELAB-2682-collapse
Homelab:plane/HOMELAB-2682-negation-heading
Homelab:plane/HOMELAB-2682-comment-as
Homelab:plane/HOMELAB-2682-bold-negation
Homelab:plane/HOMELAB-2682-get-pr-sha
Homelab:plane/HOMELAB-2682-phrase-hardening
Homelab:plane/HOMELAB-2682-plane-comments-limit
Homelab:plane/HOMELAB-2682-lock-defects
Homelab:plane/HOMELAB-ci-runs-limit
Homelab:plane/HOMELAB-lock-heading-delta
Homelab:plane/HOMELAB-lock-holes
Homelab:plane/HOMELAB-2686-lock-read
Homelab:plane/HOMELAB-2634-janitor-background
Homelab:archive/plane/HOMELAB-2674-operator-identity-20261109
Homelab:plane/HOMELAB-2634-janitor-relief
Homelab:plane/HOMELAB-2524-ci-contexts
Homelab:plane/HOMELAB-2612-gradle-danger
Homelab:plane/HOMELAB-2661-hold-session
Homelab:plane/HOMELAB-2659-merge-panel
Homelab:plane/HOMELAB-2634-herdr-janitor
Homelab:plane/HOMELAB-2578-openbao-auth
Homelab:plane/HOMELAB-2590-blocker-record
Homelab:plane/HOMELAB-2586-wrapped-negation
Homelab:plane/HOMELAB-2566-formal-verdicts
Homelab:plane/HOMELAB-2578-runtime-landing-credential
Homelab:plane/HOMELAB-2567-typebox-tests
Homelab:plane/HOMELAB-2522-formal-supersession
Homelab:plane/HOMELAB-2550-comment-identity-hardening
Homelab:plane/HOMELAB-2554-comment-identity-hardening
Homelab:plane/HOMELAB-2521-blocker-ids
Homelab:plane/HOMELAB-2529-comment-identity
Homelab:plane/HOMELAB-2526-merge-identity
Homelab:plane/HOMELAB-2527-comment-identity
Homelab:plane/HOMELAB-2512-hold-identity
Homelab:plane/HOMELAB-2423-verdict-helper
Homelab:plane/HOMELAB-2411-lock-supersede
Homelab:plane/HOMELAB-2346-exact-head-lock
Homelab:plane/HOMELAB-2323-otel-prompt-undefined
Homelab:plane/HOMELAB-2321-poll-loop-guard
Homelab:plane/HOMELAB-2218-redact-fp
Homelab:plane/HOMELAB-1727-friction-actor
Homelab:plane/HOMELAB-1880-meeting-enriched-transcript
Homelab:plane/HOMELAB-1690-l7
Homelab:plane/HOMELAB-1850-attribution
Homelab:plane/HOMELAB-1877-voice-whisper-tier1
Homelab:plane/HOMELAB-1855-voice-whisper-remote-mic
Homelab:plane/HOMELAB-1688-routines
Homelab:plane/HOMELAB-1842-intercom-conflict
Homelab:plane/HOMELAB-1678-refocus
Homelab:plane/HOMELAB-1788-fallback-fixture
Homelab:plane/HOMELAB-ci-swarm-fix
Homelab:plane/HOMELAB-1788-intercom
Homelab:plane/HOMELAB-1679-closure
Homelab:plane/HOMELAB-1769-mcp-bridge
Homelab:plane/HOMELAB-1750-meeting-video-review
Homelab:plane/HOMELAB-1736-forgejo-ci
Homelab:plane/HOMELAB-1695-modelrole
Homelab:plane/HOMELAB-1741-watchdog-challenge
Homelab:plane/HOMELAB-1668-stall-watchdog
Homelab:plane/HOMELAB-1734-epic-session-generic
Homelab:plane/HOMELAB-1629-attach-kickoff
Homelab:plane/epic-session-emit-fix
Homelab:plane/HOMELAB-1628-voice-whisper
Homelab:plane/HOMELAB-1624-docs
Homelab:plane/HOMELAB-1560-epic-session
Homelab:plane/HOMELAB-1618-pane-ux
Homelab:plane/HOMELAB-1614-swarm-view-ux
Homelab:plane/HOMELAB-1609-ext
Homelab:plane/HOMELAB-1609-skills-agents
Homelab:plane/HOMELAB-1577-steer-send
Homelab:plane/HOMELAB-1576-split-direction
Homelab:plane/HOMELAB-1544-async-only-dispatch
Homelab:plane/HOMELAB-1575-swarm-ux
Homelab:plane/HOMELAB-1571-pane-nits
Homelab:plane/HOMELAB-1569-settings-reload
Homelab:plane/HOMELAB-1573-chat-first
Homelab:plane/HOMELAB-1572-on-demand-panes
Homelab:plane/HOMELAB-1570-pane-ux
Homelab:plane/HOMELAB-1558-phase3
Homelab:plane/HOMELAB-1558-phase4
Homelab:plane/HOMELAB-1557-phase2
Homelab:plane/HOMELAB-1557-phase1
Homelab:plane/HOMELAB-1561-ci
Homelab:plane/HOMELAB-1559-contract-followup
Homelab:plane/HOMELAB-1559-phase0-foundation
Homelab:plane/HOMELAB-1555-telemetry-attribution
Homelab:plane/HOMELAB-1552-otel-subagent-spans
Homelab:plane/HOMELAB-1553-subagent-result-fallback
Homelab:plane/HOMELAB-1552-forgejo-merge-default
Homelab:plane/HOMELAB-1548-model-override
Homelab:rescue/HOMELAB-1534-1542-inflight
Homelab:plane/HOMELAB-1527-tunnel-spans
Homelab:plane/HOMELAB-1532-unknown-prefix-fail-loud
Homelab:plane/HOMELAB-1531-send-project-map
Homelab:homelab-1517/otel-header-format-fix
No reviewers
Labels
Clear labels
No items
No labels
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
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
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-2674-operator-identity"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The fact, established first (measured 2026-11-04 on mgmt, never a token value)
The extension resolves its credential through one path:
forgejo/api.ts:49-56(loadConfig)process.env.FORGEJO_API_KEY, andFORGEJO_BASE_URL; no other inputFORGEJO_API_KEY env var is required— no pi-config file fallback exists (noreadFileSyncof any config in the load path; the only file reads are the landing token file and the OpenBao helper output)ForgejoConfig{ baseUrl, apiKey }— a token, no identity fieldGET /api/v1/user→ loginaaron,is_admin=false, id 2~/.bashrc:154sources~/.config/homelab/forgejo.env(0600), inherited by every pi session on mgmtSo 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.tspins steps 1–3 (the config has exactlyapiKey+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 optionalas_operatorparam)assertMergeIdentity→forgejo_merge_prverdict-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: trueis 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. Passingas_operator: trueis 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 withCIset 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.tsbody composition andVERDICT_GRAMMARare not modified. A verdict must still open with the canonical first-block## Verdict: APPROVED|CHANGES_REQUESTEDblock,model:andhead: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 keeptests/landing-*andtests/verdict-*green; they were all run here.Tests — 638 pass, 22 files;
tsc --noEmitcleanmain@48c5000with the source stashed): "an undeclared identity on a headless process does not post as aaron" — pre-fix the fake recorded a realPOST /issues/91/commentsunderaaron; post-fix zero mutating calls and a refusal namingoperator account "aaron".ld-reviewerwithexpected_identityposts and is verified by read-back;as_operator: truefrom a headless worker is still refused; each worker marker refuses even interactive+flag.as_operator: trueallowed; interactive without it refused.comment-identity.test.ts/comment-identity-hardening.test.tsencoded "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 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: a worker never writes as the operator (identity enforced in code)to HOMELAB-2674: fix(forgejo): a worker never writes as the operatorVerdict: APPROVED
model: omniroute/deepseek-flash
head:
2e2db8375asession: homelab-2674-review-1
Reviewed the whole of
forgejo/operator-identity.tsandforgejo/tests/operator-identity.test.tswithread, plus the wiring diffs inapi.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/92answers head2e2db83…ee21, basemain@48c5000, 7 files, +675/−15 — matches what I was asked to review. Workflow runs #251–#253 on this exact sha are green including theforgejojob; #250's only red leg waspr-title, which reads the live PR title, and it passes now.forgejo/node_modules/.bin/tsc --noEmitexits 0.Tests, run by me.
CI=true npx vitest run forgejo/tests/operator-identity.test.ts→ 18 passed.env -u CI …→ 18 passed. Wholeforgejo/teststree 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 underforgejo/, and the CI job runs vitest from the package directory where.worktreesdoes 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:index.ts:1107maps a missingas_operatortodeclaredOperator === false, which lands onheadlessorundeclared-operator; silence is never inheritance.as_operator: true: still refused when any marker inWORKER_CONTEXT_ENVSis 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.api.ts:365-378refusesexpected !== actualbefore the operator rule, andexpected === actual === "aaron"then hits the operator rule.assertWriteIdentityreadsGET /user(a non-mutating call) before refusing, so "zero writes" holds.process.stdin.isTTY && process.stdout.isTTYis a defensible interactive test: it is a launch property, not a caller argument, and it fails closed (non-TTY → refuse).verdict-cli.ts:900-916runs the same check withdeclaredOperator: falseand no operator mode at all, before any POST, andlanding.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.
beforeEachdeletes everyWORKER_CONTEXT_ENVSmarker (operator-identity.test.ts:56-72) andafterEachrestores the ambient values aftervi.unstubAllEnvs(), so stubbing and restoring cannot fight each other. The precedence test (:236-245) stubsCI=trueand asserts the refusal namesdeclares itself a worker (CI is set)and does not mention the terminal: since the message can only nameCIifprocess.env.CIwas 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 inassertWriteIdentityseesprocess.env, and vitest 5'sstubEnvwrites 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 setsFORGEJO_WORKER_CONTEXT(:248-256) still passes because it stubs afterbeforeEachclears, and it resets the base env itself; 6803 green in both env shapes confirmsbeforeEachbroke nothing else.Did the second commit change source? No.
git show --stat 2e2db83touches onlyforgejo/tests/operator-identity.test.ts(+63/−2), andgit diff 4aaff1d 2e2db83 -- forgejo/operator-identity.tsis empty. The claim "the module was already correct, the test was not" is accurate.Findings (none blocking; each is worth a follow-up).
api.ts:385-388says 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) andcreatePr(api.ts:1327-1329,POST repos/{o}/{r}/pulls) still write with whateverFORGEJO_API_KEYresolves to, so a worker can still open a PR or create a repo attributed toaaron. 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.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 agentas_operator: truealone is sufficient. "Cannot be spoofed by passingas_operator: true" is true of headless processes only. Worth stating, because the everyday cockpit case is exactly this one.verdict-cli.ts:900-916passesdeclaredOperator: falseand no streams, so a human running--postinteractively 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.OPERATOR_LOGINis one hardcoded login (operator-identity.ts:50). The same class of ambient token exists for other human/owner accounts (e.g. thejesus/Sendrastpanel 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.beforeEachclearing the markers, the two tool-level tests now exercise only the headless branch; the CI-set tool path is covered for theas_operator: truecase only. Fine, but a tool-level "worker, no flag" case withCIset 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: CHANGES_REQUESTED
model: omniroute/deepseek-flash
head:
2e2db8375asession: 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 inapi.ts, the tool registrations inindex.ts, and thecheckOperatorIdentitycall inverdict-cli.ts. Head sha confirmed as2e2db83via GET. I ranCI=true npx vitest run forgejo/tests/operator-identity.test.tsandenv -u CI npx vitest run forgejo/tests/operator-identity.test.ts— 18/18 pass both ways. Fullforgejo/testson this head: 6803 tests pass, zero non-.worktreesfailures (the 36 failures are the stale-worktreetypeboxresolution errors, ignored as instructed).1. Coverage of the rule — the highest-value question, and it fails.
There are five
POSTsites inapi.tsand four mutating tools inindex.ts. Gated:commentOnPR(api.ts:1752) →postVerifiedComment(api.ts:449) → requires theIDENTITY_PROOFbrand minted only byassertWriteIdentity(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()callsgetConfig()(the operator's ambientFORGEJO_API_KEY) thencreatePR(api.ts:1317) and returns. NoassertWriteIdentity, nocheckOperatorIdentity, noas_operatorparameter. A worker with the inherited token opens a PR authored byaaron. Confirmed mechanically: noregisterToolwrapper exists inindex.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.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.
isInteractiveSessionisstdin.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 andas_operator: trueis sufficient — it can post asaaronat will. That is the common orchestrator case, not an exotic one. The module doc says the opposite: "It cannot be spoofed by passingas_operator: trueeither, 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_SUBAGENTmarker, not headlessness. And that marker is not verified anywhere: in this reviewer session, which is a subagent,PI_SUBAGENTandLD_WORKERare both unset in the tool subprocess environment (measured withenv). If the harness does setPI_SUBAGENTin 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
CIdeliberately 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
beforeEachclearing does not weaken theFORGEJO_WORKER_CONTEXTtest: that test stubs its marker inside the body, afterbeforeEachruns. The save-oncesavedWorkerEnvmap plusrestoreWorkerEnv()inafterEachround-trips correctly (eachafterEachclears 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
checkOperatorIdentityat 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 callsvi.unstubAllEnvs()mid-body then re-sets two vars, which is dead work under the currentafterEach.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.
ld-revieweris not the operator login, socheckOperatorIdentityreturns ok immediately (operator-identity.ts:126-129); pinned by "the reviewer identity is unaffected: ld-reviewer still posts, verified".pi-landinglikewise short-circuits;assertMergeIdentity's new operator check is additive and only fires when the landing credential itself resolves toaaron. Pinned by "assertMergeIdentity refuses the operator login off a terminal".as_operator: truepasses (pinned at the decision layer and atassertWriteIdentity). Noteforgejo_merge_pr(index.ts:708) never passesdeclaredOperator, so an operator cannot merge asaaroneven 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-cli.ts:903hardcodesdeclaredOperator: false, so a verdict asaaronis 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-2and: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 thatforgejo_create_pr/forgejo_create_repostill 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
assertWriteIdentitycall in each ofindex.ts:607andindex.ts:471, or a corrected, narrow claim in the module header andapi.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:
1d2268037csession: homelab-2674-review-3
BLOCKER B1
forgejo/tsconfig.jsontypecheck is RED on this head:npx tsc --noEmitinforgejo/reportsFound 2 errors in the same file, starting at: index.ts:489, and CI jobforgejoon sha1d2268037c(run #258, attempt #1, task 63737) failed on exactly that (RUN exit status 2). The two errors are TS2322 atforgejo/index.ts:489(forgejo_create_repo) andforgejo/index.ts:661(forgejo_create_pr), both newly introduced by commit1d22680. The cause is the same in both: the refusal branch returnsdetails: { refused: true, expected, expectedSource, actual, reason }while the success branch returnsdetails: Repository/details: PullRequest, and the tool's inferredAgentToolResult<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 typecheckis a gating step in.forgejo/workflows/ci.yaml:178-203(jobforgejo), 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 wayforgejo_comment_on_prdoes withsatisfies CommentToolDetails), or annotate theexecutereturn explicitly. Please also re-runcd forgejo && npm run typecheckbefore 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 viapostVerifiedComment(api.ts:452-467 throws withoutIDENTITY_PROOF);forgejo_merge_pr(:713) gated by the stricter landing leg —resolveLandingCredentialAtRuntimethenassertMergeIdentity(api.ts:1117), which callscheckOperatorIdentityat api.ts:1154 before the landing-login comparison. The remaining eleven tools are reads. Non-tool entry pointforgejo/verdict-cli.ts:903callscheckOperatorIdentitywithdeclaredOperator: 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 createWebhookare all exported and all POST with whateverFORGEJO_API_KEYholds. The module header (operator-identity.ts:6-8) and the api.ts:388-391 comment are honest thatassertWriteIdentityis not a chokepoint, so this is not a doc lie — butcreateWebhookis imported atforgejo/index.ts:61and never used anywhere in the tree: a live ungated POST carried by a dead import. Please either drop the import and makecreateWebhookmodule-private, or gate it. Severity: medium (unreachable from the tool surface today, oneimportaway 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-476drivestools.get("forgejo_create_pr").execute(...)andtools.get("forgejo_create_repo").execute(...)throughfakePi(), not the pure function, withmockFetchroutes that would answer 201 for the POST. Remove eitherassertWriteIdentitycall andexpect(mutating(h.calls)).toEqual([])goes red because the POST lands, andtextOf(result)stops matching/Refusing to open a PR/. The third test pins the non-regression direction (a declaredld-reviewerstill opens the PR,mutating(calls)length 1). One residual softness, not blocking: thecreate_repotest asserts only/operator account "aaron"/, which both the worker-context and headless reasons satisfy, so it would still pass if one branch were deleted — thecreate_prtest and the precedence test do the discriminating work.Legitimate flows: I traced all three. A reviewer posting as
ld-reviewerreturns{ok: true}from the first branch ofcheckOperatorIdentity(operator-identity.ts:139-144) before any marker or TTY test, and is pinned by the test at :253. The landing worker merging aspi-landinglikewise short-circuits at :139 and then satisfies the landing-login comparison. A human operator needs an interactive terminal, no worker marker, andas_operator: true— which is the intended cost; worth noting for the operator that an exportedCIin 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_SUBAGENTabsence 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 inafterEach, so deletingCIcannot leak into another file; bothCI=true npx vitest runandenv -u CI npx vitest rungive 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.
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.Verdict: APPROVED
model: omniroute/deepseek-flash
head:
d9caa532a4session: homelab-2674-review-5
Verified independently on this exact head.
GET /pulls/92answers headd9caa532a4…, basemain, mergeable, open. Workflow run on this sha:forgejojob success (run #262),pythontag #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.
CreateToolDetailsand every return.forgejo/index.ts:85-100declares one flat interface (refused, optionalactual/expected/expectedSource/reason, optionalcreated). All five return sites conform, each via a declaredconst details: CreateToolDetails:index.ts:520(create_repo refusal),:545(create_repo success),:690(create_pr unparseable ref, previouslydetails: null),:707(create_pr refusal),:733(create_pr success). The success-shape change is safe: no consumer exists —grepfordetails.created/details.refusedoutsidetests/operator-identity.test.tsfinds nothing, and the human/agent-visiblecontenttext is byte-identical (Created …: <url>), so an agent sees the same output as before.api.tsstill exports the rawcreateRepo/createPR; the gate lives at the tool layer, whichapi.ts:404-407now says explicitly.3. Tests.
CI=true→ 21/21.env -u CI→ 21/21. Wholeforgejo/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,assertWriteIdentitythen verified read-back),forgejo_merge_pr(:812resolveLandingCredentialAtRuntime+assertMergeIdentity, whosecheckOperatorIdentitysits atapi.ts:1151).verdict-cli.ts:899covers verdict posting. The three/-commands (index.ts:1424,1445,1458) only emit prompts — no write.createWebhook(api.ts:2001) is imported-but-unused atindex.ts:61and 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 aboveCreateToolDetails;MergeToolDetails(:102) is left undocumented and the 2526 note mislabels the new interface.docs/forgejo.md:44and the usage example:818do not mentionexpected_identity/as_operator; the refusal text (index.ts:521-534,:711-724) offers only "use a FORGEJO_EXPECTED_IDENTITY token" and never namesas_operator: trueas the interactive operator's route.FORGEJO_API_KEYresolves toaaronviaGET /user, and none ofLD_WORKER/PI_SUBAGENT/CI/FORGEJO_WORKER_CONTEXTis set; only non-TTY (stdin.isTTY=false) refused me. The markers are absent in practice, so the harness holeoperator-identity.ts:47-58documents is real; dispatchers should exportFORGEJO_WORKER_CONTEXT=1. Follow-up, not a blocker on this diff.Verdict: CHANGES_REQUESTED
model: omniroute/deepseek-flash
head:
d9caa532a4session: homelab-2674-review-6
BLOCKER HOMELAB-2674-B1
BLOCKER HOMELAB-2674-B2
Head verified by GET:
d9caa53, open, mergeable.tsc --noEmitclean;operator-identity.test.ts21/21 underCI=trueand underenv -u CI; wholeforgejo/tests7117/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:POST user/reposforgejo_create_repo(index.ts:540)assertWriteIdentityindex.ts:514POST repos/*/pullsforgejo_create_pr(index.ts:727)POST repos/*/pulls/N/mergeforgejo_merge_pr(index.ts:833)mergeAsLanding;assertMergeIdentityindex.ts:811POST repos/*/issues/N/commentsforgejo_comment_on_pr(index.ts:1236)postVerifiedComment(no identity, no write) + index.ts:1206POST repos/*/hooksSo 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-8says 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_KEYresolves toaaron(GET /user),stdin.isTTY=false,stdout.isTTY=false, and none ofCI/LD_WORKER/PI_SUBAGENT/FORGEJO_WORKER_CONTEXTis set. That is exactly the shape of a homelab worker. For that process, after this change:forgejo_create_prandforgejo_create_reporefuse, always.expected_identityis NOT an escape: it must match the token's own login (api.ts:380), soexpected_identity: aaronreaches the operator rule and is refused for headlessness;expected_identity: <anyone else>is refused as a mismatch before that.as_operator: trueis NOT an escape: headless (operator-identity.ts:160-170).CI/LD_WORKERare 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 onlyld/forgejo-reviewerexists for verdicts — or (b) rawcurlwith the inherited token, which lands the sameaaronauthorship the tool now refuses and is strictly worse hygiene.homelab-ticketSKILL 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_prandcreate_repoarrived in1d22680and 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
interactivea 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, soas_operator: truealone 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, soPI_SUBAGENTbeing 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
api.ts:2013—createWebhookis an ungated POST (andindex.ts:61imports it without registering a tool). Unreachable today, but the moment someone wiresforgejo_create_webhookit 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.index.ts:79-88— the HOMELAB-2526 doc comment now sits directly aboveCreateToolDetails(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.verdict-cli.ts:903-909— passesenv: io.envbut nostreams, so the rule reads the real globalprocesswhile everything else inrunCliis injectable throughCliIO. Untestable at the CLI layer, and it means a dry-run by an operator now exits 1 rather than printing the preview.verdict-cli.ts:907hard-codesdeclaredOperator: 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 offeras_operator.CI
Not flaky as it stands:
beforeEachclears all four markers and the precedence is pinned by its own test (operator-identity.test.ts:236-249), which is the fix from2e2db83. The 21 tests pass with and withoutCI. No test depends on ambientFORGEJO_EXPECTED_IDENTITY.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.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.