Files
stack/agents/darkwing/work/queue-d/helper.md
T
jason.woltjeandClaude Opus 5.5 f539466fcb feat(queue): Piece D, reviews as issue comments, raw per-seat token helper (row 12, #1508)
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]>
2026-09-27 10:07:29 -05:00

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.

  1. lstat: a regular file, not a symlink, no group or other bits. Unchanged.

  2. Read the file. A read error exits 3. It used to be caught by the JSON parse's catch, with the same result.

  3. JSON.parse. If the text parses, the JSON path runs unchanged: url must be https://git.mosaicstack.dev (trailing slashes stripped), and api_token must be a nonempty string. A non-SyntaxError, such as null.mosaicstack, exits 3.

  4. The raw path runs only on a SyntaxError. It accepts the file only if all of these hold:

    • stat size is 40 or 41;
    • the byte length of the text equals the stat size;
    • 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.

  5. 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. Now CFG="$(node -e "$CRED_JS" cfg)" || exit 3 and [ -n "$CFG" ] || exit 3 run first, and curl reads -K <(printf '%s\n' "$CFG"). printf is a builtin, so the token is in no argv. Both curl branches, with and without a body, use it.

  6. export -n CFG runs right after the checked assignment, before any child starts. A CFG inherited from the caller's environment keeps its export attribute when assigned, and SHELLOPTS=allexport in the environment exports every assignment. Either way the config would reach curl's environment, and the body file's mktemp and chmod too.

Decision 37, condition by condition

  1. 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.
  2. One line of token characters. The format is 40 lowercase hex characters. I checked by stat that the real files are 40 bytes (jarvis) or 41 (the others), all mode 600. I then ran the patched CRED_JS in base mode against my own file only. It printed https://git.mosaicstack.dev and 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.
  3. Fixed base URL. The raw path assigns the literal. The test sets MOSAIC_GITEA_URL, GITEA_URL and MOSAIC_GITEA_BASE_URL to another host, and curl still gets https://git.mosaicstack.dev/api/v1/user.
  4. 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 -K stream. 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.
  5. Only the acting seat's file. That is D's job, not the helper's. D refuses unless MOSAIC_GITEA_CREDENTIAL_FILE resolves to …/agents/<login>/secrets/gitea-mosaicstack-<login>.token for the acting seat, or jarvis when Sage acts, and before posting it checks that GET user returns that login. See review.mjs credCheck and checkUser in the D candidate.
  6. 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_cfg parsed the file again without the lstat checks. 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 -n keeps it out of every child's environment, and nothing prints it. Before, it existed only in the pipe.
  • lstat then readFile is 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 || {};
  • cfg output in base mode;
  • the read's own try dropped (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 CFG dropped, 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 lstat and the read;
  • the SyntaxError test dropped: the only non-SyntaxError is JSON null, which then fails the raw pattern;
  • text.trim() for text.slice(0, 40): same result on every accepted text;
  • the isSymbolicLink test dropped: lstat of a symlink is never isFile;
  • !== "base" for === "cfg": the script calls only these two modes;
  • || true for || exit 3 on the CFG line: node prints nothing when it refuses, so [ -n "$CFG" ] refuses;
  • [ -n "$CFG" ] dropped: || exit 3 refuses first. Dropping || exit 3 outright is the same, since set -e exits on the failed assignment;
  • export CFG=… for CFG=…: export -n on 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.