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]>
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
# Queue D (#1508, row 12), candidate for review, round 2
|
||||
|
||||
Darkwing, 2026-09-27. Piece D is review requests, per plan section 8.9
|
||||
(`agents/filbert/work/queue-as-data-plan-2026-09-26.md`). Moving a row
|
||||
with reviewers to in-review posts one request comment on its issue as the
|
||||
acting seat, and the log records what happened. The row closes on the
|
||||
reviewers' recorded verdicts. The candidate also carries the DEFERRED
|
||||
item Sage attached to rows 12 and 13: `test-queue.sh` skips its live checks
|
||||
outside the canonical root.
|
||||
|
||||
Base is 8ffbd73b. The patch applies unchanged to HEAD cdcedb27, which
|
||||
since then has changed only `lead-decisions.md`, `QUEUE.md` and
|
||||
`queue.json`. Filbert reviews D. The helper patch is separate
|
||||
(`helper.md`); Sage approved it in lead decision 39, and it ships in the
|
||||
D commit. Sage commits. Nothing is committed, staged or pushed.
|
||||
|
||||
Round 2 fixes Filbert's C1
|
||||
(`agents/filbert/work/queue-d-review-r1-2026-09-27.md`): on a request
|
||||
round, in-review→waiting-on-jason now makes the same checks as
|
||||
in-review→done. See "Waiting-on-jason on a request round" below.
|
||||
|
||||
## Files
|
||||
|
||||
`build.patch` (sha256
|
||||
`28d0790e797e1f24f295f70b2e0162bb1b4b0a154b220c0527f7d6624f0aa1c8`) changes 7 files and adds 3.
|
||||
`build-manifest.sha256` (sha256
|
||||
`af57ead2697a88d74a9214783dfa35ba3bd171aa32a594584f018181e261e956`) pins all 10 after the
|
||||
patch. In a fresh clone at cdcedb27 the patch applies and the result
|
||||
matches the manifest 10/10.
|
||||
|
||||
- `packages/queue/src/review.mjs` (new): the credential check, the request
|
||||
body, the helper call under the deadline, and the reading of each
|
||||
answer.
|
||||
- `packages/queue/src/queue.mjs`: `SEMANTICS` 2, the five review verbs,
|
||||
the round, attempt and receipt shapes, and the rules for moves and done.
|
||||
- `packages/queue/src/store.mjs`: the request step after the move is
|
||||
logged, the resolve check, `review verify-commit`, and the retry and
|
||||
settle hints.
|
||||
- `packages/queue/src/cli.mjs`: the `review` subcommands.
|
||||
- `packages/queue/README.md`: a "Review requests" section, verbs, exit 3,
|
||||
known limits, tests.
|
||||
- `scripts/test-queue.sh`: the live checks skip outside the canonical root.
|
||||
- Tests: `review.test.mjs` (new, 20 tests), `fixtures/fake-gitea.mjs`
|
||||
(new), `fixtures/kill-at.mjs` (a step can be `NAME#N`, the Nth time it is
|
||||
reached), `store.test.mjs` (two A2 tests set row 9's reviewers to none
|
||||
first, so their moves post nothing; a note there moves from filbert to
|
||||
sage, since filbert is no longer a reviewer). 122 tests at A2, 142 now.
|
||||
|
||||
Outside the patch:
|
||||
|
||||
- `helper.patch` (sha256 `48edd46b93c54908e9d59737aab78a33e007ddfafa1e6ad4d34333015bb75430`): raw per-seat token files in
|
||||
`scripts/gitea-api.sh`, lead decision 37. Rocko reviewed two rounds,
|
||||
and Sage approved the result (decisions 38 and 39); notes in
|
||||
`helper.md`. D's tests don't need it, but the live round does.
|
||||
- `tools-md.patch` (sha256 `d30d65b842bd56077ccd9164eccd946f2aa9f53290d02eba61f284960d230e14`): `docs/TOOLS.md`, the review
|
||||
commands and the raw token file. It is Sage's file, so it's a proposal.
|
||||
|
||||
## How a request goes
|
||||
|
||||
1. **Intent.** `move ID in-review --candidate C --op OP` on a row with
|
||||
reviewers logs the round and its first attempt, `requesting`, under the
|
||||
lock, like any other op. If that write doesn't finish, nothing is sent.
|
||||
2. **Pre-send.** Without the lock: the credential file check (`lstat`
|
||||
only), then `GET user`, which must return the acting seat's login, or
|
||||
`jarvis` for sage. A failure here is `failed`, and the POST never runs.
|
||||
3. **POST** one comment on the round's issue through
|
||||
`scripts/gitea-api.sh`, under `timeout -s KILL 30`. The body carries
|
||||
two markers: `<!-- mosaic-queue-op: OP -->` and
|
||||
`<!-- mosaic-queue-round: row=N round=R candidate=DIGEST -->`.
|
||||
4. **Outcome.** A second entry, `OP.outcome`, under the lock. It holds the
|
||||
HTTP status, the comment id and a fixed detail string. Nothing from the
|
||||
response body is logged or printed.
|
||||
|
||||
Exit 0 is posted, 1 is failed (400, 401, 403, 404, 422, or pre-send), and
|
||||
3 is uncertain (anything else). A same-op retry prints the logged state and
|
||||
sends nothing. An uncertain or `requesting` attempt prints where to look
|
||||
and the two commands that settle it.
|
||||
|
||||
## Choices I made
|
||||
|
||||
- **The CLI shape of `resolve`.** The plan has
|
||||
`review resolve ID --attempt REQOP --posted <id>`. I made it
|
||||
`review resolve ID REQOP --comment N`, and abandon takes the attempt the
|
||||
same way. The attempt is always required, and `--comment` is the flag
|
||||
name `record` uses for a comment id too.
|
||||
- **Resolve checks the author.** The plan lists issue, marker, round and
|
||||
candidate. I added the comment's author: it must be the requester's
|
||||
login, fetched with the resolver's own token. Otherwise anyone could
|
||||
copy the markers into a comment of their own.
|
||||
- **Done on a request round** needs an approval recorded by every listed
|
||||
reviewer in the current round, and refuses `--evidence`. If the reviewers
|
||||
were removed after the round opened, done refuses until a privileged
|
||||
actor sets them. A round from before D, or on a row with no reviewers,
|
||||
closes with `--evidence` as in A2.
|
||||
- **Waiting-on-jason on a request round** (round 2, C1). The plan's table
|
||||
let the owner move in-review→waiting-on-jason with no other check, so a
|
||||
Jason-gated row could reach Jason, and then close, with no reviewer's
|
||||
verdict. The move now refuses while a request is unresolved, and on a
|
||||
request round it needs an approval recorded by every listed reviewer.
|
||||
`requireApprovals` in `queue.mjs` holds the approval checks that done
|
||||
and this move share. A round with no request is unchanged, so v1 replay
|
||||
is too. Filbert approved round 2 and asked for one more check (n1): an
|
||||
approval from an earlier round doesn't count in the current one. The
|
||||
Jason-gated test now has rocko approve round 2 first, and the move
|
||||
refuses until filbert approves round 2 as well. Jason gets no exemption, because the owner makes this move.
|
||||
In-review→in-progress, the changes path, is unchanged.
|
||||
- **The owner records no verdict,** even when listed as a reviewer.
|
||||
- **A late outcome on a done row.** `review-outcome` is the one entry a
|
||||
done row accepts, so a POST that answers after the row closed is still
|
||||
recorded. It can only set `conflict`; nothing reopens the row, and
|
||||
`review resolve` on it refuses without fetching the comment.
|
||||
- **A late `uncertain` after a resolve** keeps `posted`: the resolve saw
|
||||
the comment. A late `failed` after a resolve is `conflict`, because the
|
||||
server said it refused a comment someone found.
|
||||
- **Semantics per entry.** Every log entry already records `semantics`.
|
||||
Entries at 1 replay under A2's rules, so the live log (rev 13, all
|
||||
semantics 1) loads unchanged. Review verbs need 2, checked in the entry
|
||||
shape and again in apply.
|
||||
- **Body limit.** A body over 60,000 bytes is a pre-send `failed`, and
|
||||
nothing is sent. A 700-line manifest is enough to reach it.
|
||||
- **`verify-commit` on a commit candidate** compares every path the
|
||||
candidate changed against its first parent, by blob and mode, and
|
||||
requires the paths it deleted to be absent in REF. A `diff-tree` line it
|
||||
can't parse refuses with exit 1 instead of being skipped.
|
||||
- **The claim refusal message.** `ownerOrPriv` read "darkwing cannot
|
||||
resolve a request on". It now names the owner and the privileged actors
|
||||
in every case, with the claim first when there is one. No A2 test
|
||||
matched the old text; the resolve test pins the new one.
|
||||
- **`test-queue.sh`.** After genesis it reads `canonicalRoot` from
|
||||
`HEAD:docs/plans/queue.json` and compares it with the real path of the
|
||||
toplevel. If they differ, it prints
|
||||
`skip queue verify and render --check: this checkout (TOP) is not the queue's canonical root (CANON)`
|
||||
and passes. An empty `canonicalRoot` is a failure.
|
||||
|
||||
## Tests
|
||||
|
||||
`review.test.mjs` runs each case in a scratch repository with genesis
|
||||
committed. `scripts/gitea-api.sh` there is a stub that runs
|
||||
`fixtures/fake-gitea.mjs`: same argv and output as the helper, calls
|
||||
logged to a file, rules from a scenario file, and posted comments served
|
||||
back by id. The token files are dummies under the scratch directory. No
|
||||
test reads a real token or opens `~/.mosaic` or `~/.t3`.
|
||||
|
||||
Against the test list in 8.9:
|
||||
|
||||
| 8.9 asks for | Test |
|
||||
|---|---|
|
||||
| a kill before the POST, after it, at the outcome write, while retaking the lock | "a same-op retry after a kill sends nothing, even with a stale view" (steps `pre-send`, `posted`, `outcome`, `locked#2`), and "a held lock at the outcome exits 3" |
|
||||
| posted; each listed 4xx; 5xx; request failed; timeout; 201 without an id | "each transport answer maps to posted, failed or uncertain" (deadline 300 ms) |
|
||||
| a new op while `requesting` or `uncertain` refuses | "an unresolved request blocks a new request, a new round, waiting-on-jason and done", which also reaches waiting-on-jason with a late POST still out, and Jason's close refuses |
|
||||
| (round 2, C1) a Jason-gated row skips its reviewers | "a Jason-gated row reaches waiting-on-jason only on every reviewer's approval": Filbert's steps refuse, then pass after both reviewers approve a later round; reviewers removed refuse too |
|
||||
| a same-op retry after a stale view sends nothing | the kill test |
|
||||
| a late POST after abandon; a late outcome after resolve, same id and another | "late outcomes": rounds 1 to 3, plus a late 500 (stays posted) and a late 422 (conflict); "a late POST on a closed row" (abandoned, approved and closed while the POST was out) |
|
||||
| resolve with the wrong issue, marker, round or candidate | "resolve checks the comment", which adds author, id, 404, 500, a transport failure and the wrong credential |
|
||||
| `GET user` mismatch and timeout | "the pre-send checks" |
|
||||
| the lead posts as jarvis; `sage` as a login refuses | "the pre-send checks" and "the lead's request refuses a token for login sage" |
|
||||
| the credential checks: mode, another seat's path, the default file | "the credential file" (also unset, relative, missing, symlink, a linked directory, and jarvis's path for sage) |
|
||||
| request, changes, new candidate, approval, pinned | "request, changes, a new candidate, approval", which also checks no file lands under `docs/plans/reviews/` or `agents/*/work/` |
|
||||
|
||||
Every test that retries, kills or answers late counts the POSTs the fake
|
||||
saw. None sees a second POST for one attempt.
|
||||
|
||||
## Mutations
|
||||
|
||||
61 hand-written mutants: 29 in `queue.mjs`, 16 in `store.mjs`, 16 in
|
||||
`review.mjs`. Each runs alone against the whole queue test directory.
|
||||
Against the round 2 tests, 59 are killed, the same result as round 1.
|
||||
|
||||
Round 2 adds six in `queue.mjs` for C1, run against the round 2 tests.
|
||||
All six are killed:
|
||||
|
||||
- the round 1 branch restored (waiting-on-jason checks only the actor);
|
||||
- the approval check dropped from waiting-on-jason;
|
||||
- the unresolved check dropped from waiting-on-jason;
|
||||
- the unresolved check dropped from waiting-on-jason→done;
|
||||
- the no-reviewers check dropped from `requireApprovals`;
|
||||
- the approval check applied to every round, not only request rounds.
|
||||
|
||||
Seven survived earlier runs. Five of them now have a test that kills them:
|
||||
a second outcome for one attempt, the owner recording a verdict, done on a
|
||||
row whose reviewers were removed, a same-op resolve retry that fetched the
|
||||
comment again, and a resolve on a done row that fetched the comment before
|
||||
the log refused it. The last one is the new test "a late POST on a closed
|
||||
row".
|
||||
|
||||
Two survive, and each is equivalent while the other stays:
|
||||
|
||||
- the semantics check in `applyEntry` dropped;
|
||||
- the semantics check in the entry shape dropped.
|
||||
|
||||
Every entry passes the shape check before it is applied, so either check
|
||||
alone refuses a review verb at semantics 1. With both dropped, the
|
||||
semantics test fails.
|
||||
|
||||
## Suites
|
||||
|
||||
In the scratch clone with the patch and the helper patch applied: config
|
||||
24/0, task 90/0, foundation 44/0, conductor 17/0, release 14/0, auth 15/0,
|
||||
discord 64/0, extension-package 18/0, queue 27/0. `node --test
|
||||
packages/queue/tests/` passes 142, and `node --test packages/ledger/tests/`
|
||||
passes 58.
|
||||
|
||||
The scratch clone isn't the canonical root, so the queue suite printed its
|
||||
skip line for the two live checks. In the canonical tree they run.
|
||||
|
||||
In a fresh clone at cdcedb27 with `build.patch` applied: the manifest
|
||||
matches 10/10, the queue tests pass 142, the queue suite 27/0. With
|
||||
`helper.patch` applied on top, the ledger tests pass 58.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **Scope of darkwing's and dewey's tokens.** They hold
|
||||
`write:repository`. If that doesn't cover an issue comment, the live
|
||||
POST gets 403, which is `failed` with nothing posted (8.9 says so). The
|
||||
scope question then goes to Sage.
|
||||
- **Post to outcome.** A kill between the POST and the outcome entry
|
||||
leaves the attempt `requesting` with a comment on the issue. The queue
|
||||
never posts again on its own; a person resolves it.
|
||||
- **Verdict comments aren't fetched.** `review record` takes the
|
||||
reviewer's comment id on trust, as the queue takes `--by`.
|
||||
- **Commit candidates** stay checkable only while a ref keeps the commit.
|
||||
- The helper's own limits are in `helper.md`.
|
||||
|
||||
## After approval (Sage)
|
||||
|
||||
1. Apply `build.patch` to the canonical tree and check it against
|
||||
`build-manifest.sha256`. Run `scripts/test-queue.sh` and
|
||||
`node --test packages/queue/tests/`. Commit the 10 files by path.
|
||||
2. Apply `helper.patch` for the same commit (decision 39). The live
|
||||
round needs it: without it the helper refuses a raw token file.
|
||||
3. `tools-md.patch` if you want it.
|
||||
4. Row 12 lists no reviewers, so moving it to in-review now would open a
|
||||
round that posts nothing. Set them first:
|
||||
`scripts/mosaic queue set 12 reviewers filbert --op OP --by sage`, and
|
||||
add rocko if the helper counts as part of this round.
|
||||
|
||||
The live round, which is mine:
|
||||
|
||||
5. `MOSAIC_GITEA_CREDENTIAL_FILE=~/.mosaic/fleet/agents/darkwing/secrets/gitea-mosaicstack-darkwing.token
|
||||
scripts/mosaic queue move 12 in-review --candidate <D's commit> --op OP --by darkwing`.
|
||||
The queue `lstat`s the file, the helper reads it, and `GET user` must
|
||||
answer `darkwing`. The request goes on #1508.
|
||||
6. Exit 0 means posted. Exit 1 with HTTP 403 is the scope question above.
|
||||
Exit 3 means I look on #1508 for the marker and resolve, or ask you to
|
||||
abandon.
|
||||
7. Filbert posts a verdict on #1508 and runs `review record 12`. Then
|
||||
`move 12 done`, and `queue-commit.sh` for the queue ops.
|
||||
@@ -0,0 +1,184 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user