CENSUS CORRECTION: FOUR suites, not the three my own #1007 audit named. The fourth (test-pr-metadata-gitea.sh) was outside the candidate set that audit worked from and was found only by sweeping the discriminator across all 16 tools/git/test-*.sh suites. Recording that as a correction to my finding, not as part of the original claim. THE DEFECT. get_gitea_token() (detect-platform.sh:502-599) resolves a per-agent identity at STEP 0, from `git config --get mosaic.gitIdentity`, BEFORE both the Mosaic credential loader (step 1) and the GITEA_TOKEN env check (step 2). On a provisioned agent seat that value is set GLOBALLY in ~/.gitconfig and is inherited by any freshly-`git init`ed repo, so step 0 reads a REAL per-slot token out of $HOME and returns it without ever consulting the suite's own MOSAIC_CREDENTIALS_FILE / GITEA_TOKEN fixtures. The suites were running against production credentials, and the fixture credential each one carefully constructs was inert. THE FIX: an empty repo-local `mosaic.gitIdentity`. An empty local value shadows the global one and reads back empty at rc=0, so step 0 declines. The env route does NOT work: detect-platform.sh reads "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset. OPERATIVE vs CONTAINMENT — the two mechanisms are not interchangeable and the comment in each suite says so. The pin is operative: it prevents the resolution. The sandboxed HOME each suite now also gets is containment: it bounds a failure the pin should already have prevented. Conflating them is how this class stays invisible, because a decoy HOME REMOVES the trigger (~/.gitconfig is where the global identity lives), so any suite audited under one reads clean however vulnerable it is. To MEASURE, replicate a seat: a decoy HOME whose .gitconfig sets mosaic.gitIdentity with no per-slot token, so step 0 reaches its fail-loud branch. That note is in each file for the next auditor. SECOND, INDEPENDENT DEFECT in test-pr-metadata-gitea.sh. Applying the pin alone turned that suite RED — and a control at baseline826a8b3under a plain HOME reproduced the same failure, so it is pre-existing, not introduced. Its `GITEA_TOKEN="stub-token"` / `GITEA_URL="https://git.example.test"` pair can never satisfy step 2, because step 2 accepts GITEA_TOKEN only when GITEA_URL matches the remote host and this repo's origin is git.uscllc.com. The suite had therefore only ever passed by resolving a REAL credential — step 0 on a seat, or step 1 from the operator's own credentials.json. A MOSAIC_CREDENTIALS_FILE fixture is added rather than leaning on the sandboxed HOME making step 1 find nothing: a test that passes because production configuration is ABSENT fails the moment it is present. Shipping the pin without this would have moved the failure rather than removed it. NO CI ARM. .woodpecker/ci.yml does not run these suites; packages/mosaic/ package.json:28 (test:framework-shell) runs an ENUMERATED list that excludes all four. They run only by hand — i.e. exclusively on a provisioned seat, the one environment where the defect is live. "Passes in CI, fails on a seat" does not apply here; there is no CI observation at all. VERIFICATION (seat replica = decoy HOME with mosaic.gitIdentity set, no per-slot token; canary = same plus a marked non-credential at both per-slot paths; plain = empty HOME; real = ordinary invocation): - bash -n clean on all four. - Sweep of all 16 suites at baseline826a8b3under the seat replica: test-gitea-login-resolution rc=1 REACHES-STEP0; test-issue-create- interactive-auth rc=1 REACHES-STEP0; test-pr-merge-gitea-empty-uid rc=1 REACHES-STEP0; test-pr-metadata-gitea rc=1 REACHES-STEP0. - Same sweep after: every row rc=0 with step0 absent. - test-gitea-token-identity flags REACHES-STEP0 in BOTH arms and is NOT a defect: it runs under `env -i HOME="$FAKE_HOME"` (line 77) and its hit is its own deliberate assert_failloud fixtures (lines 158-171). The fail-loud grep matches the intended behaviour as well as the defect, so it needs the second discriminator; recorded here so the next sweep does not re-file it. - Durable-argv assay (a PATH shim that tees argv out of each suite's own mock curl, because test-pr-merge-gitea-empty-uid truncates its log between phases and its EXIT trap removes the sandbox — a post-hoc read of that suite is a non-measurement, and "no trace" there is not a clearance): test-pr-merge-gitea-empty-uid before: canary token in argv, fixture never used. after: fixture token in argv, canary absent. 5 curl calls both arms. test-pr-metadata-gitea before: canary in argv. after: both calls carry the fixture token against git.uscllc.com. - test-pr-metadata-gitea across seat/canary/plain HOMEs after the fix: rc=0, rc=0, rc=0. - All four under the real HOME: rc=0. No regression to ordinary invocation. The comment block is duplicated across the four files rather than pointing at a shared note. Deliberate, and matching the merged #1006 precedent (test-pr-review-gitea-comment.sh:87-95): the reader who needs it is auditing one file. TWO FINDINGS DELIBERATELY NOT FIXED HERE (out of this branch's scope, to be filed): 1. pr-metadata.sh:89-92 — the anonymous curl fallback does not check ^2, so an HTTP 200 carrying valid JSON is reported as "unknown API error" at rc=1. 2. test-issue-comment-readback.sh exits 1 with ZERO bytes on stdout AND stderr, dying at its first seed_state python3 heredoc. Reproduces at baseline826a8b3under both a seat replica and the real HOME. Silently red at main for everyone; unrelated to #1007. Refs #1007
Git provider wrappers
These scripts provide host-aware GitHub and Gitea issue, pull-request, milestone, and CI operations.
Durable review provenance
A successful provider write command—or a wrapper message based only on that command's exit code—is not durable review provenance. Review comments, approvals, and change requests count as durable provenance only after the wrapper reads the created provider record back and verifies that it was created by this write.
The write is a direct Gitea REST POST that returns the created record's id. Neither wrapper writes through tea — tea 0.11.1 can silently no-op while exiting 0 and cannot emit the id of a record it creates, so its exit code is worthless as proof of a durable write (#865). Instead:
- Comments (
issue-comment.sh, and thecommentaction ofpr-review.sh)POST /api/v1/repos/{owner}/{repo}/issues/{index}/comments, requiring a201and parsing the created comment'sidfrom the response body. - Reviews (
approve/request-changes)POST /api/v1/repos/{owner}/{repo}/pulls/{index}/reviewswith theevent(APPROVED/REQUEST_CHANGES), the reviewbody, andcommit_idpinned to the PR's current head, then parse the created review'sid. The review body travels in the review submit itself — there is no separate detached comment to reconcile (a GiteaREQUEST_CHANGESreview requires a non-empty body, which the submit carries).
Verification keys on that exact provider-returned id. The wrapper then GETs that one record directly — GET /issues/comments/{id} or GET /pulls/{n}/reviews/{id} — and requires that its id equals the created id, its author login equals the acting identity (resolved via GET /api/v1/user for the token in use), and, for comments, its body exactly matches what was submitted and its returned web URL belongs to this exact provider and repository (the issue_url / pull_request_url origin — scheme, host, and effective port — and full path, i.e. deployment prefix + exact owner/repo + kind + number, must match; a suffix/endsWith test would accept a look-alike host or a decoy path prefix, so the whole normalized URL is compared). The comment action of pr-review.sh additionally requires the returned resource be a pull request (a populated pull_request_url); a bare issue_url is rejected, so if issue #N exists but PR #N does not, an issue comment cannot be reported as a verified PR comment. (issue-comment.sh legitimately keeps the broader issue-or-PR acceptance.) For reviews, its state matches the requested action, its reviewed commit_id equals the PR head, and its persisted body equals the submitted body — an exact, presence- and type-checked equality (a missing/null persisted body no longer counts as an empty match), because Gitea can finalize/reuse a pending review id whose stored content was authored elsewhere, so the body is bound too. The write, the /user identity lookup, and the read-back all use the same credential — the effective login's token, or the host credential when no login is named — so the write is verified against the identity that actually performed it.
A review's pinned head is re-checked after verification (current-head TOCTOU). The commit_id is pinned to the PR head read before the submit; between that read and the read-back the branch could advance (a force-push or a new commit), leaving a verified review attached to a now-superseded commit while the live tip carries unreviewed code. After the exact-id read-back succeeds, the wrapper re-reads the live PR head (GET …/pulls/{n}) and requires it still equals the submitted SHA; if the head advanced it fails closed (non-zero, no success line) rather than reporting a review that no longer covers the PR's current commit.
This closes the concurrency window rather than documenting it. Because verification keys on the id the create returned, a no-op create yields no id and fails closed with no list-scan fallback, and a concurrent record — even one written by the same identity with an identical body/state — has a different id and cannot be mistaken for this write. There is no residual same-identity window: the earlier boundary-and-author heuristic (accept any id > pre-write-max with a matching author) is replaced entirely by exact-id attribution.
Exact-id read-back is the sole authority. Verification is a direct GET of the one record the create returned; there is no follow-up list enumeration. An earlier redundant pass that re-listed the record's page (?limit=&page=1,2,…) was removed: server-capped page sizes and list-pagination quirks made it a false-failure source (a durable, exact-id-verified record could be missed by a non-exhaustive enumeration), and it added nothing over the authoritative exact-id GET.
Credential handling
The Gitea API token is never passed on a curl command line. An Authorization: token <value> argument would be visible to any local process that can read the process table (ps / /proc/<pid>/cmdline) for the lifetime of the request. Instead, every authenticated curl call writes the header into a private, mode-0600 config file under $TMPDIR and passes it with curl --config <file> (gitea_write_auth_config), so only the file path — never the token — appears in argv. Each such file is unlinked on every exit path (success and failure) by the caller's RETURN trap.
tea invocation notes (Gitea)
- tea v0.11.1 has no
commentsubcommand undertea prortea issue— thetea pr comment/tea issue commentforms don't error, they silently fall through to a no-op and still exit 0, producing a false-success write (#865). tea's write subcommands (tea comment,tea pr approve/reject) also cannot report the id of the record they create, so their exit code cannot prove a durable write. These wrappers therefore do not write reviews or comments throughteaat all; they use direct Gitea RESTPOSTs that return the created record's id (see "Durable review provenance" above).teais consulted only to enumerate the login list for host→login resolution. - Because the review body is carried in the
POST …/reviewssubmit itself, there is no separate detached review comment, and the historicaltea pr approve/rejecttrailing-positional-argument vs. nonexistent--comment/-commentflag hazard (#835) no longer applies to these wrappers — no review comment is ever passed totea.
--login override
Both pr-review.sh and issue-comment.sh accept an optional --login <name> flag that overrides the automatically detected Gitea login for that single invocation. The override selects which credential the REST write, the /user identity lookup, and the read-back all use — its token is resolved from the tea config for that login name (get_gitea_token_for_login), falling back to the repo host's credential when no login is named. The resolved login is host- and port-bound: the login's configured URL host and effective port (the scheme's default port — 80 for http, 443 for https — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the same login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass --login <reviewer-login>.
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level --login bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.
Per-agent Gitea identity (Gate-16 author≠reviewer)
By default, git push/fetch (via git-credential-mosaic) and the API wrappers above (via
detect-platform.sh's get_gitea_token) all authenticate as the single shared Gitea
account/token configured through tools/_lib/credentials.sh. That means every agent in a
fleet commits, pushes, and opens PRs under one identity — with no cryptographic
separation between an author and a reviewer.
Both git-credential-mosaic and get_gitea_token() resolve an optional per-agent
identity before falling back to the shared account:
MOSAIC_GIT_IDENTITYenvironment variable, orgit config --get mosaic.gitIdentity(set per-worktree; persists on disk across non-persistent shells —git config mosaic.gitIdentity <agent-id>), or- (git-credential-mosaic only) the username git itself supplies for the credential request.
If the resolved identity has a token file at
~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-<agent-id>.token, that
identity + token is used. Nothing configured → nothing changes: with no per-slot
token file present, both tools fall through to the existing shared-account path
unchanged, so this feature is a no-op on any host that hasn't provisioned per-slot
tokens.
Enabling it for a clone
The framework installer syncs git-credential-mosaic to
~/.config/mosaic/tools/git/git-credential-mosaic (executable) on every install/update,
but does not register it as git's credential helper automatically. Registration is a
one-time, explicit step:
# Per-repo (recommended — scopes the helper to this clone only):
git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
# Per-worktree identity pin (Gate-16 separation):
git config mosaic.gitIdentity <agent-id>
This is deliberately not auto-registered on install/update: credential.helper is
global, order-sensitive git config (~/.gitconfig) that can already hold an
operator-chosen credential manager (keychain, store, manager-core, …) for
repositories unrelated to Mosaic. Silently inserting an entry on every framework
install/upgrade risks reordering or shadowing that operator-owned surface across the
whole host — the same operator-owned config the installer's manifest system is
otherwise careful never to touch. Because identity is already resolved per-worktree
(mosaic.gitIdentity), the correct granularity for registering the helper is per-clone
too, so a documented manual step is the right shape here, not a global auto-write.
PowerShell parity
detect-platform.ps1's Gitea wrappers authenticate through tea CLI logins
(Get-GiteaLoginForHost), not a raw-token get_gitea_token-equivalent function — there
is nothing to prepend the identity-resolution block to on the PowerShell side. A native
PowerShell git-credential helper is also unnecessary: git-credential-mosaic is invoked
by git's credential-helper protocol (stdin/stdout), which works identically under Git for
Windows' bundled bash/sh when configured via credential.helper, without a .ps1
counterpart. A tea-login-based per-agent identity for the PowerShell wrappers is a
separate, larger design (mapping identities to tea login profiles) and is out of scope
here.