queue move ID in-review posts the review request as a Gitea comment and review record reads verdicts back, so reviews stop being files in docs/plans/reviews/. On a comment round, in-review to waiting-on-jason now needs every listed reviewer's approval for the current round, the same as in-review to done (Filbert r1 C1). scripts/gitea-api.sh reads the raw per-seat token files (lead decisions 37 to 39): config built and checked before curl starts, export attribute cleared, fixed base URL. test-queue.sh skips its live checks outside the canonical root. Darkwing authored. Filbert approved D r2 (cf1d3fd0) after r1 (a2dc2302) and corrected the plan (293747cd). Rocko reviewed the helper (e896192f, 2096b0a3), and Sage's lead check passed under decision 38. Manifest b402fb38, 19 files. Co-Authored-By: Claude Opus 5.5 <[email protected]>
9.5 KiB
Raw per-seat token files in scripts/gitea-api.sh (row 12, #1508)
Darkwing, 2026-09-27, after round 2 (lead decision 38). Lead decision 37 approved this patch. It ships next to the Piece D candidate but is a separate patch, and Rocko reviews it because it handles a credential. Filbert reviews D. Sage commits both. Nothing is committed, staged or pushed.
helper.patch (sha256 48edd46b93c54908e9d59737aab78a33e007ddfafa1e6ad4d34333015bb75430)
changes scripts/gitea-api.sh and adds
packages/ledger/tests/gitea-helper-raw.test.mjs. It applies to HEAD
8efc0ff3 by itself. With only this patch applied, node --test packages/ledger/tests/ passes 58/58. D does not depend on it, and it does
not depend on D.
Why
Each seat's Gitea token is at
~/.mosaic/fleet/agents/<seat>/secrets/gitea-mosaicstack-<seat>.token.
These files hold the bare token, not the JSON mosaic.gitea.json shape
that the helper reads today. Without this change the helper refuses them,
so D's live round can't post as the seat.
What changed
Both old node snippets, the one that printed the base URL and the one that
wrote the curl config, are now one script, CRED_JS, run in two modes:
base and cfg. Each mode runs every check before it prints anything.
-
lstat: a regular file, not a symlink, no group or other bits. Unchanged. -
Read the file. A read error exits 3. It used to be caught by the JSON parse's catch, with the same result.
-
JSON.parse. If the text parses, the JSON path runs unchanged:urlmust behttps://git.mosaicstack.dev(trailing slashes stripped), andapi_tokenmust be a nonempty string. A non-SyntaxError, such asnull.mosaicstack, exits 3. -
The raw path runs only on a
SyntaxError. It accepts the file only if all of these hold:statsize is 40 or 41;- the byte length of the text equals the
statsize; - the text matches
^[0-9a-f]{40}\n?$.
Then the base URL is the fixed string
https://git.mosaicstack.dev, and the token is the first 40 characters. Anything else exits 3 before any request. -
The second read runs, and must succeed, before curl starts. Round 1 ran it inside
curl -K <(…), where bash drops its exit status, so a refusal gave curl an empty config and the request still went out. NowCFG="$(node -e "$CRED_JS" cfg)" || exit 3and[ -n "$CFG" ] || exit 3run first, and curl reads-K <(printf '%s\n' "$CFG").printfis a builtin, so the token is in no argv. Both curl branches, with and without a body, use it. -
export -n CFGruns right after the checked assignment, before any child starts. ACFGinherited from the caller's environment keeps its export attribute when assigned, andSHELLOPTS=allexportin the environment exports every assignment. Either way the config would reach curl's environment, and the body file'smktempandchmodtoo.
Decision 37, condition by condition
- The JSON path is unchanged. The last test runs a good JSON file, four refused ones, and content that parses as JSON but would pass the raw pattern: 40 decimal digits, with and without a newline. Those take the JSON path and refuse.
- One line of token characters. The format is 40 lowercase hex
characters. I checked by
statthat the real files are 40 bytes (jarvis) or 41 (the others), all mode 600. I then ran the patchedCRED_JSinbasemode against my own file only. It printedhttps://git.mosaicstack.devand exited 0, so darkwing's file matches the pattern. Nothing printed the token, and I read no other seat's file. The other seats' files are unconfirmed beyond size and mode. If one doesn't match, the helper exits 3 before any request. The test refuses 16 bad contents before curl runs: empty, 39 characters, 41 characters, upper case, CRLF, a trailing CR, a trailing space, a trailing tab, two newlines, a leading space, a trailing space at 40, a second line, a quote, non-hex, non-ASCII, and 80 characters. - Fixed base URL. The raw path assigns the literal. The test sets
MOSAIC_GITEA_URL,GITEA_URLandMOSAIC_GITEA_BASE_URLto another host, and curl still getshttps://git.mosaicstack.dev/api/v1/user. - File checks and the config stream. Modes 640, 604, 660, 644, 000
and 200, a symlink, a missing file and a directory all refuse before
curl runs. 000 and 200 pass the group and other check, and the read
refuses them. The stub curl records its argv and its
-Kstream. The token is in the stream only, never in argv, stdout or stderr. Every token in the test is a dummy that the test writes. - Only the acting seat's file. That is D's job, not the helper's. D
refuses unless
MOSAIC_GITEA_CREDENTIAL_FILEresolves to…/agents/<login>/secrets/gitea-mosaicstack-<login>.tokenfor the acting seat, or jarvis when Sage acts, and before posting it checks thatGET userreturns that login. Seereview.mjscredCheckandcheckUserin the D candidate. - This review.
Round 1 and what changed for round 2
Rocko's round 1 (agents/rocko/work/gitea-helper-raw-r1-review-2026-09-27.md)
found one blocker: a second read that refused still let curl run with an
empty config, and a 2xx then exited 0. My round 1 notes called that a 401,
which the server doesn't guarantee. Sage agreed it belongs in this patch.
Step 5 above is the fix.
The new test, "a file that changes between the two reads refuses before
curl runs", uses the git stub, which runs between the two reads, to
change a valid dummy file to invalid text, to {}, to mode 644, to a
symlink, and to missing. It runs each change for GET user and for a POST
with a dummy body. Every case exits 3 with no curl call and nothing on
stdout. It also changes a valid JSON file to invalid text. As a control,
both calls reach curl with the right config when nothing changes. The
first test now also checks that the token is not in the environment curl
gets.
Rocko's round 2 (agents/rocko/work/gitea-helper-raw-r2-review-2026-09-27.md)
closed that blocker and found another: the inherited export attribute in
step 6. Lead decision 38 took the fix, export -n CFG, with no round 3;
Sage checks it with Rocko's reproducer. The test "the token reaches no
child environment, even with an inherited CFG or SHELLOPTS=allexport" runs
raw and JSON dummy files, GET and POST, with an exported harmless CFG,
with SHELLOPTS=allexport, and with both. Each call must exit 0 with the
token in curl's config stream and not in its environment, argv, stdout or
stderr. I found the SHELLOPTS route while checking the fix; export -n
covers it too, so I added no second line for it.
Not in this patch: the predictable response path
/tmp/gitea-api-response.$$. Sage has it on DEFERRED as "Gitea helper
response-file hardening".
What I'd like you to look at
- The second read now re-validates. Before,
gen_curl_cfgparsed the file again without thelstatchecks. Now both reads run every check. So a file swapped for a symlink or a wider mode between the two reads refuses. On the JSON path this is a tightening. - The token now sits in a shell variable for the rest of the script.
export -nkeeps it out of every child's environment, and nothing prints it. Before, it existed only in the pipe. lstatthenreadFileis still not atomic. A swap between those two calls inside one read is the pre-existing race Rocko noted. This patch doesn't claim to close it.text.slice(0, 40)relies on the pattern having matched. The only accepted texts are the token alone or the token plus\n.
Mutations
25 mutants, each run alone against gitea-helper-raw.test.mjs: 19 of
CRED_JS and 6 of the gate and the export. 15 are killed:
- uppercase allowed, any trailing whitespace allowed, the
^anchor dropped; - a raw file refused outright (the old behaviour);
- an env override of the raw base URL;
- the token taken with its newline;
- the mode check dropped;
- the JSON path's host check or token check dropped, or its
|| {}; cfgoutput inbasemode;- the read's own
trydropped (killed by the 000 and 200 modes; I added those after this mutant first survived); - both gate checks dropped, and the round 1 code (no gate, curl reading
<(node … cfg)), killed by the between-reads test; export -n CFGdropped, killed by the inherited-environment test.
Ten survive, and each is equivalent while the other checks stay:
\n*$for\n?$: the size check caps the file at 41 bytes;- the size check dropped: the pattern caps the length;
- the byte-length check dropped: the pattern is ASCII, so a match means
bytes equal characters; the check differs only if the file changes
between
lstatand the read; - the
SyntaxErrortest dropped: the only non-SyntaxErroris JSONnull, which then fails the raw pattern; text.trim()fortext.slice(0, 40): same result on every accepted text;- the
isSymbolicLinktest dropped:lstatof a symlink is neverisFile; !== "base"for=== "cfg": the script calls only these two modes;|| truefor|| exit 3on theCFGline: node prints nothing when it refuses, so[ -n "$CFG" ]refuses;[ -n "$CFG" ]dropped:|| exit 3refuses first. Dropping|| exit 3outright is the same, sinceset -eexits on the failed assignment;export CFG=…forCFG=…:export -non the next line clears it. In round 2 this one was killed; the fix makes it equivalent.
I kept the size, byte-length and symlink checks anyway. Decision 37 asks for size plus pattern, and the other two cost nothing.