Compare commits

..
Author SHA1 Message Date
jason.woltje 80924f7d5d row 211 (#1709): BUILD.md records the r2 rebase onto 78b69e1e6 (sage-4362); same patch-id, no conflict; doc only 2026-10-24 23:04:56 -05:00
jason.woltje 20e885dc35 row 211 (#1709, #426): a pre-push guard that refuses deletes and non-fast-forward pushes, its installer, and slot-busy.sh that exits 1 on BUSY
scripts/git-hooks/pre-push refuses a delete, a remote tip missing locally and
any update whose remote tip isn't an ancestor of the local tip; a new branch
and a fast-forward pass. scripts/install-git-hooks.sh puts it in the common
.git/hooks, refuses a set core.hooksPath or a different hook without
--force-install, and runs a canary through git hook run. scripts/slot-busy.sh
exits 0 CLEAR, 1 BUSY, 2 error. scripts/test-git-guard.sh (61 cases) and the
mutant runner in agents/darkwing/work/queue-211; TOOLS.md gets the usage.
Not run yet: written during Gander's slot (sage-2801).
2026-10-24 23:04:14 -05:00
8 changed files with 737 additions and 402 deletions

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
# Row 211 (#1709, Vikunja P19 #426): a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY
Darkwing, 2026-10-22. Brief: `docs/plans/2026-10-20_force-push-guard.md` (blob 3a8c2cf13). Moved from Rocko
to me by sage-2801 (Q220, mos-1301). Base: 78b69e1e6 (r2; r1 was aca16404c). Branch: `darkwing/row211-r2`
(r1 `darkwing/row211` 6db25b1f0 stays as pushed). Reviewer: Dewey. Gate owner: Sage.
## What ships
- `scripts/git-hooks/pre-push` (new, `/bin/sh`). For each ref line on stdin it refuses:
- a delete (local sha all zeros);
- a remote tip that isn't in this repository (`git cat-file -e <remote>^{commit}`), with "fetch first,
then push again";
- a remote tip that isn't an ancestor of the local tip (`git merge-base --is-ancestor`), naming the ref
and both shas;
- an ancestor check that fails for any other reason (exit above 1);
- a short or overlong ref line, or a non-hex object name.
A new ref (remote sha all zeros) passes, and so does an empty push. It fetches nothing. On any refusal
it prints one line per refused ref and a closing line, and exits 1, so git pushes no ref at all.
- `scripts/install-git-hooks.sh` (new). Installs the hook as `pre-push` in the common `.git/hooks`
(`git rev-parse --git-common-dir`), so a `--repo` naming a worktree lands in the shared directory.
It refuses (exit 2):
- a set `core.hooksPath` at any scope;
- a different installed `pre-push`, unless `--force-install` is given;
- a symlinked `pre-push`, even with `--force-install`;
- any of `GIT_DIR`, `GIT_WORK_TREE`, `GIT_COMMON_DIR`, `GIT_INDEX_FILE`, `GIT_OBJECT_DIRECTORY` or
`GIT_ALTERNATE_OBJECT_DIRECTORIES` in the environment;
- a directory that isn't a git checkout.
It never writes the installed file in place. A new hook goes in by `link()` from a temporary copy, so
a hook that appeared meanwhile is kept and the install refuses. A replacement goes in by `rename()`.
Then it compares the bytes, checks the mode, and runs a canary through `git hook run` (git 2.55 here):
an empty push must pass and a delete line must be refused with the guard's message.
- `scripts/slot-busy.sh` (new). Exit 0 CLEAR, 1 BUSY, 2 its own error, one `BUSY <pid> <rule> cwd=<cwd>
<command>` line per match. Rules: `node ... --test`, `*gate*.sh` (after a space, `/`, `_` or `-`), a
`mutant` substring, `test-*.sh`, `docker compose` and `docker-compose`. It skips its own pid and its
ancestors, empty command lines (kernel threads) and shell-snapshot wrappers.
- `scripts/test-git-guard.sh` (new), 61 cases, and three Maintenance rows plus a short section in
`docs/TOOLS.md`.
## Choices
1. **A separate `scripts/install-git-hooks.sh`, not `queue-commit.sh --install-push-hook`.** The brief
left this open. `queue-commit.sh --install-hook` is the queue guard's installer. It is tied to the
canonical checkout and to the lead (jason/sage) and runs the queue guard's canary. The push guard has
neither tie: it must also be installable in a scratch clone for the tests, and every seat worktree
shares it. Putting it in `queue-commit.sh` would widen that script's reach for no gain. The cost is a
second installer with a similar shape. The install pattern (copy beside, link or rename, verify,
canary) follows `queue-commit.sh`.
2. **No privilege check in the installer.** The brief makes the canonical install a separate step with
Sage after landing, and the tests need to install into scratch clones as the suite's user. So the
script installs wherever it is told. The guard is advisory on the client in any case (`--no-verify`).
3. **The hook source is the working file next to the installer** (`$ROOT/scripts/git-hooks/pre-push`),
not a blob from a ref. That lets the mutant runner install a mutated copy from its own tree, and
matches how `queue-commit.sh` reads its guard.
4. **A new `scripts/test-git-guard.sh`, not more cases in `test-land-work.sh`.** The brief allowed either.
Land-work's suite is about `land-work.sh`, and the gate runs every `scripts/test-*.sh`, so a new file
gets gated with no wiring.
5. **`--proc-root DIR` is a test seam in `slot-busy.sh`.** On a live host the real `/proc` may hold
other seats' runs at any time, so the suite can't demand exit 0 there. Exit 0 and every rule are
tested against a fake `/proc` with pids above `pid_max` (4194304), so they can't collide with the
ancestor set, which always comes from the real `/proc`. The real `/proc` is tested with a stand-in:
a `/bin/sh` script named `node`, started as `node --test standin.test.mjs` in its own cwd. The suite
asserts its BUSY line with that cwd, exit 1, that `slot-busy > /dev/null && echo ran` prints nothing,
that the suite itself (an ancestor whose command line matches `test-*.sh`) has no BUSY line, and that
after the stand-in exits its pid is gone. The brief's "0 without one" is the fake-`/proc` case.
6. **Ancestors are skipped**, not only its own pid as the brief says. A gate script that runs the busy
check matches its own rules (`*gate*.sh`, `test-*.sh`, `mutant`), so checking only `$$` would make
every such caller see itself as BUSY.
7. **The gate rule is broader than `gate.sh`**: `[ /_-]gate[^ /]*\.sh `, so `sage-gate.sh` and
`gate-253.sh` match too. A false BUSY costs a wait; a false CLEAR costs a broken run.
8. **Shell-snapshot wrappers are skipped.** An agent harness runs each command as `bash -c 'source
.../shell-snapshots/snapshot-*.sh && eval <command text>'`, so the wrapper's command line holds the
text of whatever the agent typed, including a busy check that names `test-*.sh`. The real child
processes the wrapper starts are still matched.
## Tests (`scripts/test-git-guard.sh`)
Scratch bare remote, a scratch clone with the guard installed, and a worktree of that clone. Git runs
with `GIT_CONFIG_NOSYSTEM=1` and an empty `GIT_CONFIG_GLOBAL`, as in `test-land-work.sh`. Nothing touches
origin, `~/.mosaic`, `~/.mosaic-dev` or `~/.config/mosaic-dev`.
- Install: exit 0, the canary passed, bytes and mode, no temporary file left, and the worktree's git
resolves `hooks/pre-push` to the common file.
- From the worktree: a new branch and a fast-forward pass. A `--force` push of a rewritten branch, a
`+refspec` push and a `--force-with-lease` push are refused by the guard's message, naming the ref and
both shas, and the remote ref is unchanged. `:ref` and `--delete` are refused and the branch stays.
- A second clone moves the remote; a `--force` push from the worktree is then refused with "fetch
first", the remote stays where the other clone put it, and the guard fetched nothing.
- The hook fed by hand: a short line, a non-hex name and an unknown local object are refused; an empty
push passes.
- The installer: `core.hooksPath` set refuses with that message and writes no hook (in `.git/hooks` or
the hooks path). A different hook refuses and stays byte-identical. `--force-install` replaces it, and
a hard link to the old file keeps the old bytes, so the replacement was a rename. A second install of
the same bytes exits 0. A symlinked hook refuses even with `--force-install`. A `--repo` naming a
worktree lands in the common directory. `GIT_DIR` set, a plain directory and an unknown argument
refuse.
- `slot-busy.sh`, fake `/proc`: CLEAR with exit 0 when nothing matches (a server, a `grep gate`, a
snapshot wrapper whose text holds `node --test`, an empty command line). Exit 1 and the BUSY line for
`node --test`, with no CLEAR in the output, and `> /dev/null && echo ran` prints nothing. Each rule
once more (7 cases). Exit 2 for a missing root, an empty root and an unknown argument.
- `slot-busy.sh`, real `/proc`: the stand-in cases in choice 5.
## Mutants (`agents/darkwing/work/queue-211/mutants.sh`)
Each mutant copies the four scripts into its own tree, edits one site there (exactly one match), and runs
that tree's suite under `timeout -k 10 180`. The worktree's files are never edited. The unmutated suite
runs first and a red base stops the run. The brief's five come first:
| Mutant | Site |
|---|---|
| ancestor-dropped | pre-push: `rc=0` in place of the `merge-base --is-ancestor` call |
| unknown-object-passes | pre-push: the missing-remote-tip branch skips the ref without refusing |
| delete-passes | pre-push: the delete branch skips the ref without refusing |
| install-over-hookspath | installer: the `core.hooksPath` refusal is `true \|\| die` |
| busy-exits-0 | slot-busy: `exit 0` when BUSY |
| differing-hook-replaced | installer: a different hook is replaced without `--force-install` |
| replace-in-place | installer: the replacement writes into the installed file (`cat >`) instead of renaming |
| symlink-followed | installer: no symlink refusal |
| ancestor-error-passes | pre-push: an ancestor check exiting above 1 doesn't refuse |
| snapshot-matched | slot-busy: no shell-snapshot skip |
| ancestors-matched | slot-busy: no ancestor skip |
| no-node-rule | slot-busy: the `node --test` rule removed |
| no-compose-rule | slot-busy: the `docker compose` rule removed |
`install-over-hookspath` still exits 2, because its canary then fails (git looks for the hook in the hooks
path). The suite kills it on the message and on "no hook was written", not on the exit code.
## Rebase r2 (sage-4362, 2026-10-24)
r1 6db25b1f0 sat on aca16404c, 555 commits behind refactor, and overlapped it in `docs/TOOLS.md`. r2
cherry-picks it onto 78b69e1e6 as a new commit on a new branch; no force-push. The TOOLS.md hunk merged
without a conflict, `git patch-id --stable` is the same for both commits (db451e11), and the four scripts,
`mutants.sh` and this file's other text are byte-identical to r1, so no assertion or reviewed line
changed. On the new base none of the six paths existed before, `scripts/git-hooks/` holds only the queue
guard's `pre-commit` (untouched here), and no test reads or pins `docs/TOOLS.md`.
## Runs
Pending: nothing has run yet. Gander held the gate slot (row 253 run 5, from 16:32Z) while this was
written, so only `bash -n`, `sh -n` and a `DRY=1` mutant pass (every site matched once, no suite run)
happened. The suite, the mutants and every other `scripts/test-*.sh` run in a CLEAR Sage announces, with
`TMPDIR` under `/mnt/storage/scratch/tmp`. Results and the measured suite time go here before review.
## Known limits
- `git push --no-verify` skips the hook. Gitea branch protection (ops-01, through Mos) is the guard that
holds.
- The hook doesn't check tags differently from branches: a moved tag is refused like a rewritten branch.
- `slot-busy.sh` can call a process BUSY that isn't a run, for example an editor with a `test-*.sh` open.
It never reads files under a seat's directory, so it can't tell.
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Row 211 (#426) mutants. Run from the candidate worktree's root:
# TMPDIR=<scratch dir> LOG=<dir> bash agents/darkwing/work/queue-211/mutants.sh > mutants.txt
# Each mutant copies the four row-211 scripts into its own tree, edits one
# site there (exactly one match, else NO MATCH), and runs that tree's
# scripts/test-git-guard.sh under a 180 s timeout. The worktree's files are
# never edited. "killed" means the suite failed; "survived" means it passed.
# The unmutated suite runs first, and a red base stops the run (exit 3).
set -u
LOG=${LOG:-.}
FILES="scripts/git-hooks/pre-push scripts/install-git-hooks.sh scripts/slot-busy.sh scripts/test-git-guard.sh"
WORK=$(mktemp -d "${TMPDIR:-/tmp}/r211-mutants.XXXXXX") || exit 2
trap 'rm -rf "$WORK"' EXIT
tree() { # $1: name. Prints the copy's root.
local t="$WORK/$1" f
for f in $FILES; do mkdir -p "$t/$(dirname "$f")" && cp -p -- "$f" "$t/$f" || return 1; done
echo "$t"
}
suite() { # $1: tree, $2: log
timeout -k 10 180 bash "$1/scripts/test-git-guard.sh" > "$2" 2>&1
}
edit() { # $1: file, $2: from, $3: to. Exit 3 unless $2 occurs exactly once.
python3 -B - "$1" "$2" "$3" <<'PY'
import sys
path, old, new = sys.argv[1:4]
text = open(path).read()
if text.count(old) != 1:
sys.exit(3)
open(path, "w").write(text.replace(old, new))
PY
}
base=$(tree base) || exit 2
if [ -n "${DRY:-}" ]; then
echo "base: not run (DRY)"
elif suite "$base" "$LOG/mut-base.log"; then
echo "base: green ($(tail -1 "$LOG/mut-base.log"))"
else
echo "base: RED, stopping ($(tail -1 "$LOG/mut-base.log"))"
exit 3
fi
run() { # $1: name, $2: file, $3: from, $4: to
local name=$1 file=$2 t log
t=$(tree "$name") || { echo "$name: COPY FAILED"; return; }
if ! edit "$t/$file" "$3" "$4"; then echo "$name: NO MATCH"; return; fi
if [ -n "${DRY:-}" ]; then echo "$name: match"; return; fi
log="$LOG/mut-$name.log"
if suite "$t" "$log"; then
echo "$name: survived"
else
echo "$name: killed (exit $?, $(grep -c '^FAIL' "$log") FAIL; first: $(grep -m1 '^FAIL' "$log" | cut -c6-100))"
fi
}
H=scripts/git-hooks/pre-push
I=scripts/install-git-hooks.sh
S=scripts/slot-busy.sh
# The brief's five.
run ancestor-dropped $H $' git merge-base --is-ancestor "$rsha" "$lsha" 2>/dev/null\n rc=$?' ' rc=0'
run unknown-object-passes $H $' refuse "$rref: the remote tip $rsha isn\'t in this repository; fetch first, then push again"\n continue' ' continue'
run delete-passes $H $' refuse "$rref: a delete (remote $rsha); seat branches are never deleted by push"\n continue' ' continue'
run install-over-hookspath $I '[ -z "$out" ] || die 2 "refused: core.hooksPath' '[ -z "$out" ] || true || die 2 "refused: core.hooksPath'
run busy-exits-0 $S 'if [ "$busy" = 1 ]; then exit 1; fi' 'if [ "$busy" = 1 ]; then exit 0; fi'
# Further sites the suite pins.
run differing-hook-replaced $I '[ "$FORCE" = 1 ] || die 2 "refused: a different' 'true || die 2 "refused: a different'
run replace-in-place $I 'mv -f -- "$tmp" "$HOOK" ||' 'cat -- "$tmp" > "$HOOK" && rm -f -- "$tmp" ||'
run symlink-followed $I 'if [ -L "$HOOK" ]; then die 2' 'if false; then die 2'
run ancestor-error-passes $H ' *) refuse "$rref: the ancestor check failed' ' *) : "$rref: the ancestor check failed'
run snapshot-matched $S $' if [[ "$c" == *"/shell-snapshots/snapshot-"* ]]; then return 1; fi\n' ''
run ancestors-matched $S ' [ -z "${SKIP[$pid]:-}" ] || continue' ' :'
run no-node-rule $S 'if [[ "$c" =~ $RE_NODE ]]; then echo "node --test"; return 0; fi' ':'
run no-compose-rule $S 'if [[ "$c" =~ $RE_COMPOSE ]]; then echo "docker compose"; return 0; fi' ':'
-402
View File
@@ -1,402 +0,0 @@
# Row 255 design: a bundled Pocket ID for users with no OIDC provider (Q203, Gitea #1760, Vikunja 458)
Launchpad, draft 1, 2026-10-25, on `launchpad/row255` off refactor 78b69e1e6. Brief
`docs/plans/2026-10-21_bundled-pocket-id.md` (blob 26f34400). Reviewer Webby. Sage checks it against Q203, O6,
REQ-WEB-2 and REQ-INST-1. REQ-INST-2 (PRD 1.10) is post-v1 (mos-1204), and so is everything here.
This is a design only. Nothing here installs, downloads or starts Pocket ID. It changes nothing on .53, .54 or
dragon-lin, which keeps Authentik.
## Sources
- Q203 and O6 as the brief quotes them. PRD `docs/prd/mosaic-stack.md` REQ-WEB-2 (:902), REQ-INST-1 (:1509),
REQ-INST-2 (:1513) and REQ-INST-3 (:1528). Lead decision 44 items 5 and 6
(`docs/plans/2026-09-26_lead-decisions.md:655-663`).
- The Q175 design `agents/sage/work/q175-sso/DESIGN.md` sections 4 and 10, and the code row 192 landed:
`packages/webui/src/sso-config.mjs` (the `console.sso` shape), `packages/webui/src/sso.mjs` (the slot read,
the discovery checks at :105-118, the client at :147-148, the callback at :133, the post-logout URI at :332).
- Researcher's note `agents/researcher/work/2026-10-04_vikunja-pocketid.md` part 2 (v2.17.0).
- A fresh read of upstream on 2026-10-25, from release metadata and source text only: no binary was fetched or
run. The latest stable release is **v2.19.1** (2026-10-22). Source paths below are under
`backend/internal/` at tag v2.19.1 in `github.com/pocket-id/pocket-id`, marked "P". Releases v2.18.0,
v2.19.0 and v2.19.1 are not marked breaking.
## What the Console already needs from any provider
This design keeps the Console's sign-in and start checks exactly as row 192 built them. Pocket ID becomes one
`console.sso.providers` entry, the same shape any compliant OP uses (Q175 section 4, "Several providers"). The
Console gets no new sign-in path, no password and no proxy-header identity (O6, REQ-WEB-2, REQ-INST-2). The
facts that drive the rest of the design:
- `publicOrigin` and every `issuer` must be https (`sso-config.mjs` `httpsURL`). A plain-http Pocket ID can't
be a provider at all.
- Each provider needs 1 to 64 `subjects`. A `console.sso` with no subject refuses to load. So the first
person's subject has to exist before the Console starts with `--sso` (section 5).
- The client authenticates with `client_secret_basic` (`sso.mjs:148`), so the client is confidential.
- The slot is `<configDir>/secrets/mosaic-stack/<slot>`: a regular file, owned by the Console's uid, mode
with no group or other bits, and outside the repo and every stack copy (`readSlot`).
- `config.json` is user-authored. bootstrap.sh creates it and nothing overwrites it (canon invariant 2, Q175
section 4). This matters for sections 1 and 5.
## 1. The choice
The installer is REQ-INST-3's install command (Vikunja #432; today `stack-upgrade.sh` and the per-case
install scripts). It takes one sign-in option per instance:
- `--oidc existing`. The user points the Console at one or more OIDC providers they already have. Nothing
is installed. The user writes `console.sso` themselves, as Q175 step 4 does today. The installer writes
nothing into `console.sso`; it prints the entry shape and the two URIs to register
(`<publicOrigin>/auth/callback`, `<publicOrigin>/auth/signed-out`).
- `--oidc bundled`. The stack installs Pocket ID as an install component (section 2), and
`console.sso.providers` holds exactly one entry, `id: "pocket-id"`, whose `issuer` is the component's
origin.
- No option keeps today's behaviour: no `console.sso`, and the Console runs without `--sso`.
How the bundled choice is recorded. REQ-INST-1 lets a component be switched on "in the organization file
or system config". It has to be the system config. The organization file can be written by agents through
`organizationWriter`, which is the reason Q175 B1 put `console.sso` in config.json, and the same reason
holds for a component that issues the Console's identities. So config.json gets one new optional key:
```json
"components": {
"pocket-id": {
"release": "v2.19.1",
"origin": "https://id.example.internal",
"port": 1411
}
}
```
The loader (`scripts/mosaic-config.mjs`) checks the shape: `release` is a `vX.Y.Z` tag, and the component script refuses one that
differs from the pin file (section 2), `origin` is an https origin, `port` is 1024 to 65535. It also cross-checks: when
`components["pocket-id"]` is present, `console.sso` must have exactly one provider, `id` `pocket-id`,
`issuer` equal to `origin`. A config with `console.sso` providers and no `components` is the existing
choice and loads as today.
**M1, approved by Mos with three conditions (mos-1666 item 3).** `components` is a new top-level config key.
The loader refuses unknown keys today, so this is a config schema change. The conditions:
1. **Rollback trap.** A copy older than P1 refuses a config that has the key, the same trap Q175 section 4
names for `console.sso`. P1 documents the order. On a rollback past P1, first take `components` out of
config.json (restore the backup made before the key went in), then roll back the pointer. The STATE check
after the rollback reads config.json and confirms the key is absent.
2. **Strict inside the key.** The loader refuses any name under `components` other than `pocket-id`, and any
field under `pocket-id` other than `release`, `origin` and `port`. Each refusal names the key path and
quotes no value, as `checkConsole` does.
3. **test-config.sh cases:**
- a valid `components` loads;
- a malformed one fails, naming the bad key path;
- a config without `components` loads;
- the cross-check refuses a `console.sso` whose provider doesn't match `origin`.
What each choice writes into `console.sso` is in section 5. For the bundled choice, Pocket ID is the only
providers entry. The design keeps it that way because REQ-INST-2 says "the Console's one providers entry".
A user who later adds an existing provider moves to `--oidc existing` and removes the component (section 2).
## 2. The component, against REQ-INST-1
**Pinned.** A Pocket ID release is a set of raw binaries plus `checksums.txt` (sha256). From v2.16.0 there is
an SPDX SBOM per binary, and the release workflow attests `checksums.txt` through Sigstore (`actions/attest`
in `.github/workflows/release.yml`). The repo holds one pin file,
`scripts/pocket-id-pins.sh`, sourced the way `scripts/case1-pins.sh` is: the tag, and for each supported platform (linux amd64 and arm64 to
start) the asset name and its sha256. The build row copies the sha256 from `checksums.txt` at the tag and
records the `gh attestation verify` output for each asset as its evidence. The pin is checked by hand at
build time, not trusted at install time. No Pocket ID source and no binary goes in the repo. The binary is
not in any image either, since the container install comes after v1 (REQ-INST-3).
Recommended pin: v2.19.1, the current release. The build row checks it again, because Pocket ID ships a
minor every one to four weeks. Upgrading is a pin file change in a reviewed commit.
**Fetched and checked.** `scripts/component-pocket-id.sh fetch` downloads the pinned asset for the host's
platform over https into a new 0700 staging directory, computes its sha256, and compares it with the pin.
On a mismatch it deletes the file and refuses with `pocket-id-digest-mismatch`, naming the expected value
and the computed one. On a match it installs the binary read-only (0555) at
`<dataRoot>/components/pocket-id/bin/pocket-id-<tag>`. The fetch is the only network step, and the user
runs it at install. Tests never run it against the network (section 9).
**Runs.** A systemd user unit, `mosaic-pocket-id.service` (`mosaic-pocket-id-NAME.service` for a named
instance, as row 190 names the bus and webui units), as the instance's user:
- `ExecStart=<dataRoot>/components/pocket-id/bin/pocket-id-<tag>`.
- `WorkingDirectory=<dataRoot>/components/pocket-id/run`, a directory the unit owns, so Pocket ID's habit of
reading `.env` from the working directory reads nothing we didn't write. The build row checks there is no
`.env` there at start.
- Environment, all from the rendered unit:
- `APP_URL=<origin>` and `HOST=127.0.0.1` (P `common/env_config.go`: HOST defaults to `0.0.0.0`).
- `PORT=<port>`.
- `DB_CONNECTION_STRING` and `UPLOAD_PATH` as absolute paths under `<dataRoot>/components/pocket-id/data/`
(P defaults are `data/pocket-id.db` and `data/uploads`, relative to the working directory).
- `ALLOW_USER_SIGNUPS=disabled` and `ALLOW_INSECURE_CALLBACK_URLS=false`.
- `EMAIL_ONE_TIME_ACCESS_AS_UNAUTHENTICATED_ENABLED=false` and `..._AS_ADMIN_ENABLED=false`.
- The `OUTBOUND_ALLOWED_HOSTS_*` allowlists set empty (added in 2.19.0).
- No `TRUST_PROXY` unless section 6's proxy step sets it to the proxy's address.
- `ENCRYPTION_KEY` (mandatory since v2.0, at least 16 bytes) never goes in the unit text. It lives in slot
`<configDir>/secrets/mosaic-stack/pocket-id-encryption-key`, 0600. The component script generates it from
`/dev/urandom` straight into the file and never prints it. The unit passes it with
`LoadCredential=` and `ENCRYPTION_KEY_FILE=%d/...`. The build row confirms `ENCRYPTION_KEY_FILE` at the pinned
tag; if it isn't there, it refuses rather than putting the key in `Environment=`.
- The unit never carries `STATIC_API_KEY`. That key exists only during the enroll run (section 4).
The data directory holds the SQLite database, Pocket ID's signing key (stored encrypted with
`ENCRYPTION_KEY`), and the passkey registrations. It is 0700. Agent sandboxes bind only an allowlist
(`packages/seat/src/sandbox.mjs`), and `<dataRoot>/components/` is not on it. The build row adds a sandbox
test that pins that.
**Health-checked.** `GET http://127.0.0.1:<port>/healthz` returns 204 (P `controller/healthz_controller.go`).
`component-pocket-id.sh status` reports the unit state, the binary tag against the pin, the binary's sha256
against the pin, and the health result. The Console's own `/healthz` already shows `sso: { "pocket-id": ready
| unreachable | refused }` from its discovery retries, so an operator sees the provider from either side.
**Removable.** `component-pocket-id.sh remove` refuses while `console.sso` still names `pocket-id`, so the
Console can't be left pointing at a provider that's gone. Once the user has taken the entry out (or moved to
`--oidc existing`), the script stops and removes the unit, then moves the data directory to
`<dataRoot>/components/pocket-id.removed-<utc>` with a receipt line in `<dataRoot>/state/components.log`
(append-only). It never deletes the passkey database outright. Deleting the moved directory and the
encryption key slot is a separate `purge` verb that names both paths and needs `--yes`. Removing the
`components` key from config.json is the user's edit.
## 3. The Q175 start checks against the bundled instance
The Console checks are unchanged. Here is each one against Pocket ID, and the setting it needs:
| Check (row 192) | Pocket ID v2.19.1 | Default that fails | Fix |
|---|---|---|---|
| `issuer` is https (loader) | issuer = `APP_URL` exactly (P `controller/well_known_controller.go`) | `APP_URL` defaults to `http://localhost:1411` | `APP_URL=<https origin>`, section 6 |
| discovery `issuer` equals config `issuer` | equal when `APP_URL` has no trailing slash; Pocket ID refuses a path | none | config `issuer` = `origin`, no trailing slash; the loader cross-check compares them |
| `id_token_signing_alg_values_supported` overlaps `algs` | advertises only the current key's alg. RS256 (RSA-2048) by default (P `service/jwt_service.go`) | none | `algs: ["RS256"]` |
| `code_challenge_methods_supported` has `S256` | advertises `plain` and `S256` | none | none. The Console always sends S256 (`sso.mjs:221`) |
| JWKS has a usable key for `algs` | one key at `/.well-known/jwks.json` | none | none. The build row's fake OP pins `use`/`alg`/`kid` as Pocket ID emits them |
| `end_session_endpoint` is https | `<APP_URL>/api/oidc/end-session` | http, if `APP_URL` is http | same fix as row 1 |
| `redirect_uri` is registered | exact match first, then wildcard patterns; loopback callbacks match any port (P `utils/callback_url_util.go`) | `ALLOW_INSECURE_CALLBACK_URLS` defaults to true | register exactly `<publicOrigin>/auth/callback`, with no `*`; set `ALLOW_INSECURE_CALLBACK_URLS=false` |
| post-logout URI | per-client `logoutCallbackURLs`, same matching | none | register exactly `<publicOrigin>/auth/signed-out` |
| ID token alg in `algs`, `iss` match | as above | none | none |
Two points the Console doesn't check, set on the client anyway:
- `pkceEnabled: true`. Then Pocket ID refuses an authorize request that has no `code_challenge`. Pocket ID has
no per-client S256-only setting (P `oidc/provider.go` turns on `plain` for the server). That doesn't relax
anything: the Console sends S256 every time, and `plain` stays available only to other clients.
- `isPublic: false`, which matches `client_secret_basic`.
Key rotation. `pocket-id key-rotate --alg ...` replaces the one signing key (P `cmds/key_rotate.go`). After a
rotation to another alg, the Console's discovery check refuses on the next restart (no overlap with
`algs`), and that is the intended fail-closed result. A rotation is a user step that updates `algs` in the
same edit. The component script never rotates.
## 4. The client, and where its secret goes
The person running the install creates the client through `component-pocket-id.sh enroll`. No agent runs
this verb. It refuses when any agent marker is in its environment: the `MARKERS` list in
`packages/bus/src/human.mjs` (`MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP`, `CLAUDECODE` and the rest) that the human
boundary uses. A test pins the refusal.
Enroll needs admin rights before any person has a passkey. Pocket ID offers two ways to get them, and one of
them is a hazard:
- `/setup` and its backing `POST /api/signup/setup` are unauthenticated: whoever calls first becomes admin
(P `usersignup/service.go`). Sandboxed agents share the host's network namespace (`sandbox.mjs` unshares
user, pid and ipc, not net), so any agent could reach a loopback Pocket ID and take that first admin.
- `STATIC_API_KEY` is a fixed admin key with no expiry (P `apikey/service.go`). It doesn't count as
completing setup, so `/setup` stays open while it's set.
So enroll never lets the real unit serve before an admin person exists. It runs the pinned binary once in a
**private network namespace** (`unshare --user --net`, or `bwrap --unshare-net` with the binary and data
directory bound), where only enroll's own loopback can reach it:
1. Generate a one-run static key into a 0600 temp file in a new 0700 directory, and start Pocket ID inside
the namespace with `STATIC_API_KEY_FILE` pointing at it, plus the unit's environment.
2. Wait for `/healthz` 204.
3. Create the admin user, `POST /api/users` with `isAdmin: true` and the username and email given with
`--admin-user` and `--admin-email`. Keep the returned `id`. That is the OIDC `sub` (P
`oidc/claims_service.go` uses `user.ID`, a UUIDv4 set at creation, so it's stable).
4. Create the Console client: client id `mosaic-console-<instance>`, callback and logout URLs exactly as in
section 3, `pkceEnabled: true`, `isPublic: false`. Supply the secret instead of letting Pocket ID
generate it. Enroll generates 48 random bytes, base64url, and writes them straight to slot
`<configDir>/secrets/mosaic-stack/console-sso-pocket-id` (0600, `O_CREAT|O_EXCL`, not a symlink). It sends
the same bytes in the create body over the namespace's loopback (`dto/oidc_dto.go` accepts a supplied
secret of 16 or more characters). The value is never printed, logged or put in an argv, and a test pins
that it appears in no output.
5. Mint a one-time access token for the admin, `POST /api/users/<id>/one-time-access-token`.
6. Stop the namespaced process, then shred and remove the static key file. The real unit has never had a
static key, so no admin key survives enroll.
7. Start the real unit and wait for `/healthz` 204.
8. Print the sign-in link `<origin>/lc/<token>` to the user's terminal (section 5), and the `console.sso`
entry (section 5).
The build row confirms each route and body against the pinned tag's source before it writes the client (the
upstream read for this design didn't trace the create-client route). If any of steps 1 to 7 fails, enroll
stops the process, removes the static key, leaves no slot file it created, and refuses with the step name.
The secret never enters the repo or an image. It sits in two places only: the slot file and Pocket ID's
database (stored as a hash). The Console's `readSlot` reads it as it does any provider's.
## 5. The first person, and their subject in `console.sso`
Pocket ID is passkey-only for people (REQ-INST-2). The installing user enrolls like this:
1. Enroll prints `<origin>/lc/<token>`. The token is valid for one hour and is a credential, so it goes only
to the terminal of the person who ran enroll. It goes in no log and no file.
2. The user opens it in a browser that can reach `origin` (section 6). Pocket ID signs them in and asks them
to add a passkey. Without one they can't sign in again. If the hour passes, `pocket-id
one-time-access-token <username>` (P `cmds/one_time_access_token.go`), run by the user against the data
directory, prints a new link. `component-pocket-id.sh relink` wraps that call.
3. Enroll already has the subject, the `id` from step 3 of section 4. It doesn't wait for a sign-in or read a
`subject-unknown` log line.
How the subject gets into `console.sso`. config.json is user-authored and never auto-written. Two ways
to respect that; I recommend the first:
- **R1 (recommended).** Enroll prints the full entry and writes nothing:
```json
"console": { "sso": { "publicOrigin": "<publicOrigin>", "providers": [ {
"id": "pocket-id", "issuer": "<origin>", "clientId": "mosaic-console-<instance>",
"clientSecret": { "slot": "console-sso-pocket-id" }, "algs": ["RS256"],
"subjects": [ { "sub": "<id>", "name": "<admin-user>" } ] } ] } },
"components": { "pocket-id": { "release": "<tag>", "origin": "<origin>", "port": <port> } }
```
The user pastes it into config.json, as with Q175 step 4. Rerunning `scripts/bootstrap.sh` (or
`--instance NAME`) then validates it through the loader, including the section 1 cross-check, and writes
nothing. Nothing changes in invariant 2.
- **R2 (rejected, Sage sage-4401: R1 chosen).** `enroll --write-config` would write those two keys itself, with a backup of config.json first, and
refuse if `console` or `components` were already present. That would be the first tool to modify an
existing config.json, against canon invariant 2's "never overwritten" practice. Not built.
Whose subject. Enroll creates one person, the one running it, and only that subject goes in the entry. On
Jason's instances, only his subject goes into `console.sso` (mos-1197). Further people are added with
Pocket ID's admin UI (a user plus a one-time link), and their subjects go into `console.sso` by the user's
edit, never by an agent. Pocket ID users who aren't in `subjects` can sign in to Pocket ID, but the Console
refuses them `subject-unknown`, so REQ-WEB-2's "must map to an authorized human" holds.
## 6. Reach: origin, TLS and proxy
WebAuthn takes its RP id from `APP_URL`'s host name, and its allowed origin is `APP_URL`
(P `webauthn/module.go`). Browsers allow passkeys only on a secure origin. The Console also needs https for
both `publicOrigin` and `issuer`. Pocket ID serves plain http, and I found no TLS listener setting in its
environment config. So:
- Pocket ID always binds `127.0.0.1:<port>`. That is the default, and the component never binds anything
else (`HOST=127.0.0.1`; the status verb refuses a unit whose `HOST` differs).
- `origin` is an https origin served by a TLS front that the user already has for the Console. The Console
needs one anyway, since `publicOrigin` is https. Pocket ID goes behind the same front under **its own host
name**, for example `id.<console host>`. Pocket ID doesn't support a path prefix (`APP_URL` must have no
path). A separate host name also keeps Pocket ID's cookies apart from the Console's
`__Host-mosaic_*` cookies; the two share cookies if they sit on one host name on different ports.
- The front reaches Pocket ID on loopback, so the front has to run on the same host. If it doesn't, Pocket
ID would need a non-loopback bind, and that is a `network.expose` hard wait (REQ-WAIT-1), not a component
option.
- `TRUST_PROXY` is set to the front's address only, never `true`.
- The Console fetches discovery and JWKS from `issuer` over https from the server side, so the Console's
node has to trust the front's certificate. A public CA does that. A private CA needs
`NODE_EXTRA_CA_CERTS` in the Console unit, which the build row adds as a render flag.
These steps are the user's, and on Jason's hosts they are Jason's, because each one changes who can reach
the Console or Pocket ID, or who the host trusts:
- J1: the DNS name for `origin`;
- J2: the certificate for it, or a private CA and its trust in the browser and in `NODE_EXTRA_CA_CERTS`;
- J3: the TLS front's route from `origin` to `127.0.0.1:<port>` (a Traefik change on Jason's hosts);
- J4: any listener off loopback.
The component checks and refuses; it never does them itself. With none of them done, Pocket ID runs on
loopback with an https `APP_URL` that nothing serves yet. Enroll's step 2 health probe goes straight to
`127.0.0.1`, so enroll still works. The person can't add a passkey until J1 to J3 are done.
## 7. Agents
Unchanged. Agents keep their per-role service tokens (lead decision 44 item 6, REQ-INST-2). Pocket ID issues
nothing an agent uses. The Console client is the only client enroll creates. No API, no client-credentials
grant and no admin API key exist after enroll. Residuals, stated plainly:
- Agents can reach `127.0.0.1:<port>`, because the sandbox shares the host network. What they can reach there
is the public sign-in pages, discovery and JWKS. Signup is disabled, email codes are off, `/setup` is
closed because an admin exists, and there is no static key. A test of the rendered unit pins all four
settings.
- The data directory and the two slots are outside every sandbox bind (section 2).
- A seat that ran as the same OS user outside a sandbox could read the data directory. The Console's
existing slot has the same exposure; this design adds no new class of it.
## 8. Instances
One Pocket ID per named instance, never shared:
- Each instance has one system config (row 190), and `components` and `console.sso` live in it. A shared
Pocket ID would make one instance's config depend on a unit, a data directory and an encryption key that
belong to another instance's config and data root.
- Unit names, ports, data directory, slots and removal are all per instance (`mosaic-pocket-id-NAME`,
`<dataRoot of NAME>/components/pocket-id`), so removing one instance's component can't break another's
sign-in.
- Each instance needs its own `origin` (section 6), so passkeys are per instance too. A person enrolls once
per instance.
- An instance that wants to use another instance's Pocket ID can still do it with `--oidc existing`. Then it
is just an OIDC provider, with its own client registered by that instance's owner. Nothing special-cases it.
Two instances on one host each need their own `port`. The loader can't see other instances' configs, so
`component-pocket-id.sh` refuses at fetch and enroll when the port is already bound on loopback.
## 9. Tests, stand-ins only
No test downloads or starts a real Pocket ID, and no test touches auth.diversecanvas.com,
mosaic.woltje.com or 127.0.0.1:7332. The stand-ins:
- **Fake Pocket ID OP**, a node server like row 192's fake OP, shaped from v2.19.1:
- the discovery fields `well_known_controller.go` emits (issuer = origin, `plain` and `S256`,
`end_session_endpoint` at `/api/oidc/end-session`, the three auth methods);
- one RS256 JWK at `/.well-known/jwks.json`;
- a token endpoint that takes only `client_secret_basic`;
- `/healthz` 204.
The Console starts against it under `--sso` with the section 5 entry, with no check changed. Negative
cases:
- an http issuer refuses at load;
- an ES256-only JWKS against `algs: ["RS256"]` refuses at discovery;
- an issuer with a trailing slash refuses on the cross-check.
- **Fake admin API** on the same stand-in: `POST /api/users`, the client create, the one-time token, and a
static-key check. Enroll runs against it with the binary replaced by a stub script that serves the stand-in.
The tests pin:
- the namespace argv;
- the slot file's mode and `O_EXCL`;
- that the secret appears in no stdout, stderr or argv;
- the static key file gone after success and after a failure at each step;
- the refusal under an agent marker in the environment.
- **Fetch**: a local file served by a test https server (self-signed, trusted only by the test) with a
matching pin, a mismatched pin and a truncated file.
- **Unit render**: the environment table in section 2. `HOST=127.0.0.1`, no `STATIC_API_KEY`, no
`ENCRYPTION_KEY` value in the text, and `ALLOW_USER_SIGNUPS=disabled`.
- **Loader**: test-config.sh cases for the `components` key and the cross-check, and the copy-rollback note.
- **Sandbox**: `<dataRoot>/components/` is in no bind list.
## 10. Build rows, in landing order
All are post-v1 (REQ-INST-2). Each row has Webby or Sage's named reviewer and its own gate. None of them
runs on .53, .54 or dragon-lin, and none installs Pocket ID on any host.
1. **P1, loader key.** `components["pocket-id"]` in `mosaic-config.mjs`, strict inside the key, and the
cross-check against `console.sso` (section 1, M1 conditions from mos-1666 item 3). P1 also writes the
rollback order into `docs/TOOLS.md` beside Q175's: remove `components` (restore the backup) before the
pointer goes back, and the post-rollback STATE check confirms the key is absent from config.json. Gate:
test-config.sh green with the four M1 cases.
2. **P2, pin and fetch.** `scripts/pocket-id-pins.sh` with the build-time sha256 and the
attestation evidence, and `component-pocket-id.sh fetch|verify`. Gate: the fetch tests against the local
stand-in; the evidence file shows `gh attestation verify` output for each pinned asset.
3. **P3, unit.** The unit template, `render|install|status|remove|purge`, and the encryption key slot. Gate:
the render and remove tests, and the sandbox bind test.
4. **P4, enroll.** The namespaced bootstrap, the client, the subject, `relink`, and printing the entry (R1),
(R2 rejected). Gate: the enroll tests in section 9, and a secret-leak grep over every test log.
5. **P5, Console against the fake Pocket ID.** The section 9 fake-OP start tests, and the
`NODE_EXTRA_CA_CERTS` render flag for the Console unit. Gate: the webui suite, with no change to
`sso.mjs` checks (a diff of `sso.mjs` and `sso-config.mjs` against the base shows none).
6. **P6, installer option.** `--oidc existing|bundled` on REQ-INST-3's install command, calling P2 to P4 in
order, and the docs: `docs/TOOLS.md`, the host run sheet's J1 to J4 steps. Gate: the installer's own
suite against the stubs; the docs list J1 to J4 as the user's steps.
## Open items for review
- M1 approved by Mos with three conditions (mos-1666 item 3), written into section 1 and P1.
- M2 dropped: Sage chose R1 and rejected R2 (sage-4401).
- **Q1 (Webby):** the private network namespace for enroll (section 4). The other option is to start the
real unit and race `/setup`, which I think is unsafe while agents share the host network.
- **Q2 (Webby):** one-providers-entry for the bundled choice (section 1). REQ-INST-2's wording reads that
way. Mixing a bundled Pocket ID with existing providers would need Sage's ruling.
- Not covered: Pocket ID for Vikunja or Gitea people sign-in (the Researcher note's part 3), and
client-credentials between Mosaic services. Neither is in Q203.
+29
View File
@@ -166,8 +166,37 @@ resolved through the CLI.
| `scripts/test-conductor.sh` | Auto-apply selftests (sandboxed) | 17 cases |
| `scripts/test-auth.sh` | Auth checkpoint selftests (no Docker) | 13 cases |
| `scripts/test-land-work.sh` | Land-work selftests (sandboxed, fake Docker) | 84 cases |
| `scripts/test-git-guard.sh` | Push guard, its installer and `slot-busy.sh` (scratch bare remote, fake `/proc`) | 61 cases |
| `scripts/install-git-hooks.sh [--repo DIR] [--force-install]` | Install the push guard as `pre-push` in the common `.git/hooks` | Refuses a set `core.hooksPath` or a different installed hook; exit 0 ok · 1 failed · 2 refused · 4 usage |
| `scripts/slot-busy.sh` | Busy check: one `BUSY` line per gate or test process, or `CLEAR` | Exit 0 CLEAR · 1 BUSY · 2 error; chain on the exit code |
| `scripts/gitea-api.sh <METHOD> <path> [body]` | Gitea API helper; reads the JSON credential file or a raw per-seat token file | Token never on argv/stdout |
### Push guard and busy check (row 211, #426)
`scripts/git-hooks/pre-push` refuses a push that deletes a branch, or that
moves a branch to a commit that doesn't descend from the remote tip (a
`--force`, `--force-with-lease` or `+refspec` push of a rewritten branch). A
new branch and a fast-forward pass. The hook fetches nothing: when the remote
tip isn't in the local repository it refuses and says to fetch first.
`git push --no-verify` skips it, so Gitea's branch protection (ops-01) is the
guard that holds. `scripts/install-git-hooks.sh` copies it into the common
`.git/hooks`, which every worktree of the checkout shares, then runs a canary
through `git hook run`. Installing it in the canonical checkout is a step for
the lead after the row lands.
Chain the busy check on its exit code, and set `pipefail` in any script that
pipes it:
```bash
bash scripts/slot-busy.sh > "$LOG" && gate
```
Never `bash scripts/slot-busy.sh | tail -1 && gate`: `tail` exits 0, so the
gate runs while BUSY. It matches `node ... --test`, a `*gate*.sh`, `mutant`, a
`test-*.sh` and `docker compose` command lines, skips itself, its ancestors
and agent shell-snapshot wrappers, and reads only `/proc/<pid>/cmdline` and
the cwd link.
## Seat launch with registration (`scripts/mosaic`)
```bash
+50
View File
@@ -0,0 +1,50 @@
#!/bin/sh
# Mosaic push guard (row 211, #426). Installed as pre-push in the common
# .git/hooks by `scripts/install-git-hooks.sh`; never edit the installed copy.
# For each ref git is about to push, it refuses a delete and any update whose
# remote tip isn't an ancestor of the local tip: a force-push or a +refspec.
# A new ref passes. It fetches nothing, so a remote tip that isn't in this
# repository refuses. Any failure of the check refuses too.
# `git push --no-verify` skips this hook; Gitea's branch protection is the
# guard that holds (ops-01). Canon: never force-push.
refused=0
refuse() {
echo "mosaic push guard: refused: $*" >&2
refused=1
}
zero() {
case "$1" in *[!0]*) return 1 ;; esac
return 0
}
while read -r lref lsha rref rsha extra || [ -n "${lref:-}" ]; do
if [ -z "${rsha:-}" ] || [ -n "${extra:-}" ]; then
refuse "unreadable ref line from git: $lref ${lsha:-} ${rref:-} ${rsha:-} ${extra:-}"
continue
fi
case "$lsha$rsha" in
*[!0-9a-f]*) refuse "$rref: a ref line with a non-hex object name (local $lsha, remote $rsha)"; continue ;;
esac
if zero "$lsha"; then
refuse "$rref: a delete (remote $rsha); seat branches are never deleted by push"
continue
fi
if zero "$rsha"; then
continue
fi
if ! git cat-file -e "$rsha^{commit}" 2>/dev/null; then
refuse "$rref: the remote tip $rsha isn't in this repository; fetch first, then push again"
continue
fi
git merge-base --is-ancestor "$rsha" "$lsha" 2>/dev/null
rc=$?
case $rc in
0) ;;
1) refuse "$rref: not a fast-forward; remote $rsha isn't an ancestor of local $lsha (a force-push or a rewritten branch)" ;;
*) refuse "$rref: the ancestor check failed (exit $rc) for remote $rsha and local $lsha" ;;
esac
done
if [ "$refused" != 0 ]; then
echo "mosaic push guard: nothing was pushed; a pushed branch only moves forward (canon: never force-push)" >&2
exit 1
fi
exit 0
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# install-git-hooks.sh: install the push guard (row 211, #426) as pre-push in
# a repository's common .git/hooks, which every linked worktree shares.
#
# Usage:
# scripts/install-git-hooks.sh [--repo DIR] [--force-install]
#
# DIR defaults to this script's own checkout. The hook's bytes are this
# checkout's scripts/git-hooks/pre-push. It refuses when core.hooksPath is set
# (git would not run the hook), and when a different pre-push is installed,
# unless --force-install is given. It never edits an installed hook in place:
# a new hook is linked in, a replacement is renamed over the old one. A canary
# then runs the installed hook through git and needs a delete refused.
#
# Exit codes: 0 installed (or already the same bytes); 1 failed; 2 refused;
# 4 usage. Installing in the canonical checkout is its own step with the lead.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
SRC="$ROOT/scripts/git-hooks/pre-push"
TMPD=""
say() { printf 'install-git-hooks: %s\n' "$*" >&2; }
die() { local code=$1; shift; say "$*"; exit "$code"; }
cleanup() { if [ -n "$TMPD" ]; then rm -rf -- "$TMPD"; fi; }
trap cleanup EXIT
usage() {
sed -n '5,6p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2
exit 4
}
REPO=$ROOT
FORCE=0
while [ $# -gt 0 ]; do
case "$1" in
--repo) [ $# -ge 2 ] || usage; REPO=$2; shift 2 ;;
--force-install) FORCE=1; shift ;;
-h|--help) usage ;;
*) say "unknown argument: $1"; usage ;;
esac
done
for v in GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES; do
if [ -n "${!v+x}" ]; then die 2 "refused: $v is set; run with the normal git environment"; fi
done
[ -f "$SRC" ] && [ ! -L "$SRC" ] || die 2 "refused: $SRC is missing or not a regular file"
g() { git -C "$REPO" "$@"; }
top=$(g rev-parse --show-toplevel 2>/dev/null) || die 2 "refused: $REPO is not a git checkout"
common=$(g rev-parse --path-format=absolute --git-common-dir) || die 1 "cannot read $REPO's git common directory"
HOOKS="$common/hooks"
HOOK="$HOOKS/pre-push"
out=$(g config --show-scope --get-all core.hooksPath 2>/dev/null)
[ -z "$out" ] || die 2 "refused: core.hooksPath is set ($(printf '%s' "$out" | tr '\n\t' '; ')), so git would not run the push guard; unset it first"
if [ -L "$HOOK" ]; then die 2 "refused: $HOOK is a symlink; remove it by hand if it is not wanted"; fi
mkdir -p "$HOOKS" || die 1 "cannot create $HOOKS"
tmp="$HOOKS/.pre-push.install-git-hooks.$$"
if [ -e "$HOOK" ]; then
[ -f "$HOOK" ] || die 2 "refused: $HOOK is not a regular file"
if cmp -s "$SRC" "$HOOK"; then
chmod 0755 "$HOOK" || die 1 "chmod failed"
note="the same bytes were already there"
else
[ "$FORCE" = 1 ] || die 2 "refused: a different pre-push hook is installed at $HOOK; compare it with $SRC, and pass --force-install to replace it"
cp -- "$SRC" "$tmp" && chmod 0755 "$tmp" || { rm -f -- "$tmp"; die 1 "cannot write $tmp"; }
# rename() swaps the whole file; the old hook's bytes are never edited.
mv -f -- "$tmp" "$HOOK" || { rm -f -- "$tmp"; die 1 "cannot replace $HOOK"; }
note="replaced a different hook (--force-install)"
fi
else
cp -- "$SRC" "$tmp" && chmod 0755 "$tmp" || { rm -f -- "$tmp"; die 1 "cannot write $tmp"; }
# link() never replaces, so a hook that appeared meanwhile is kept.
if ! ln -- "$tmp" "$HOOK" 2>/dev/null; then rm -f -- "$tmp"; die 2 "refused: a pre-push hook appeared at $HOOK while installing"; fi
rm -f -- "$tmp"
note="new"
fi
cmp -s "$SRC" "$HOOK" || die 1 "$HOOK differs from $SRC after install"
[ -x "$HOOK" ] || die 1 "$HOOK is not executable after install"
# The canary: git runs the hook it would run for a push. A delete line needs
# no objects; the guard must refuse it, and pass an empty push.
TMPD=$(mktemp -d "${TMPDIR:-/tmp}/install-git-hooks.XXXXXX") || die 1 "mktemp failed"
z=0000000000000000000000000000000000000000
printf '(delete) %s refs/heads/install-canary 1%s\n' "$z" "${z#0}" > "$TMPD/delete"
: > "$TMPD/empty"
if ! out=$(cd "$top" && git hook run --to-stdin="$TMPD/empty" pre-push -- canary canary 2>&1); then
die 2 "refused: the canary's empty push failed, so git is not running the push guard as installed: $out"
fi
out=$(cd "$top" && git hook run --to-stdin="$TMPD/delete" pre-push -- canary canary 2>&1); rc=$?
[ "$rc" -ne 0 ] && [[ "$out" == *"mosaic push guard: refused"* ]] \
|| die 2 "refused: the canary's delete was not refused by the push guard (exit $rc)"
printf 'ok installed the push guard at %s (%s, sha256 %s, mode 0755); canary passed\n' \
"$HOOK" "$note" "$(sha256sum < "$HOOK" | cut -d' ' -f1)"
exit 0
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# slot-busy.sh: the shared busy check (row 211, #426). Is a gate or a test run
# going on this host?
#
# Usage:
# scripts/slot-busy.sh [--proc-root DIR]
#
# It prints one BUSY line per matching process, or one CLEAR line. Exit codes:
# 0 CLEAR, 1 BUSY, 2 its own error. Chain it on the exit code, never on a
# filter of its output:
# bash scripts/slot-busy.sh > LOG && gate (set -o pipefail if piped)
# never `bash scripts/slot-busy.sh | tail -1 && gate`, which runs while BUSY.
#
# A process matches when its command line holds `node ... --test`, a
# `*gate*.sh`, `mutant`, a `test-*.sh` or `docker compose`/`docker-compose`.
# It skips itself and its ancestors (a gate script may run the check), and an
# agent harness's shell-snapshot wrapper, whose command line is the agent's
# command text; that shell's real children are still matched. It reads only
# /proc/<pid>/cmdline and the cwd link, never a file under a seat's directory.
# --proc-root names a stand-in for /proc in the selftests.
set -uo pipefail
PROC=/proc
while [ $# -gt 0 ]; do
case "$1" in
--proc-root) [ $# -ge 2 ] || { echo "slot-busy: error: --proc-root needs a directory" >&2; exit 2; }; PROC=$2; shift 2 ;;
-h|--help) sed -n '5,6p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2; exit 2 ;;
*) echo "slot-busy: error: unknown argument: $1" >&2; exit 2 ;;
esac
done
[ -d "$PROC" ] || { echo "slot-busy: error: $PROC is not a directory" >&2; exit 2; }
# This process and its ancestors, from the real /proc.
declare -A SKIP=()
p=$$
while [ -n "$p" ] && [ "$p" != 0 ]; do
SKIP[$p]=1
p=$(awk '/^PPid:/{print $2; exit}' "/proc/$p/status" 2>/dev/null) || p=""
done
[ "${#SKIP[@]}" -ge 1 ] && [ -n "${SKIP[$$]:-}" ] || { echo "slot-busy: error: cannot read its own process" >&2; exit 2; }
RE_NODE='[ /]node[^ ]* (.* )?--test '
RE_GATE='[ /_-]gate[^ /]*\.sh '
RE_TEST='[ /]test-[^ /]*\.sh '
RE_COMPOSE='[ /]docker( compose|-compose) '
match() { # $1: the command line, NULs as spaces. Prints the rule that matched.
local c=" $1 "
if [[ "$c" == *"/shell-snapshots/snapshot-"* ]]; then return 1; fi
if [[ "$c" =~ $RE_NODE ]]; then echo "node --test"; return 0; fi
if [[ "$c" =~ $RE_GATE ]]; then echo "gate"; return 0; fi
if [[ "$c" == *mutant* ]]; then echo "mutant"; return 0; fi
if [[ "$c" =~ $RE_TEST ]]; then echo "test script"; return 0; fi
if [[ "$c" =~ $RE_COMPOSE ]]; then echo "docker compose"; return 0; fi
return 1
}
seen=0
busy=0
for d in "$PROC"/[0-9]*; do
[ -d "$d" ] || continue
pid=${d##*/}
[[ "$pid" =~ ^[0-9]+$ ]] || continue
seen=$((seen + 1))
[ -z "${SKIP[$pid]:-}" ] || continue
# A process can exit between the listing and the read; that's not an error.
cmd=$(tr '\0' ' ' < "$d/cmdline" 2>/dev/null) || continue
cmd=${cmd% }
[ -n "$cmd" ] || continue
rule=$(match "$cmd") || continue
cwd=$(readlink "$d/cwd" 2>/dev/null) || cwd="?"
printf 'BUSY %s %s cwd=%s %s\n' "$pid" "$rule" "$cwd" "${cmd:0:200}"
busy=1
done
[ "$seen" -ge 1 ] || { echo "slot-busy: error: no processes under $PROC" >&2; exit 2; }
if [ "$busy" = 1 ]; then exit 1; fi
echo "CLEAR"
exit 0
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env bash
# Sandboxed selftests for the push guard, its installer and the busy check
# (row 211, #426): scripts/git-hooks/pre-push, scripts/install-git-hooks.sh
# and scripts/slot-busy.sh.
#
# Pushes go from a scratch clone and its worktree to a scratch bare remote,
# never to origin. Git sees no user or system config. The busy check runs
# against a fake /proc (pids above pid_max) for the exit codes and rules, and
# against the real /proc with a sleeping stand-in whose argv is `node --test`.
set -uo pipefail
cd "$(dirname "$0")/.."
REPO="$(pwd)"
unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES
SANDBOX="$(cd "$(mktemp -d)" && pwd -P)"
STANDIN=""
cleanup() {
if [ -n "$STANDIN" ]; then kill "$STANDIN" 2>/dev/null; wait "$STANDIN" 2>/dev/null; fi
rm -rf "$SANDBOX"
}
trap cleanup EXIT
PASS=0
FAIL=0
# Status colors: terminal-only, NO_COLOR-respecting; plain when piped.
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
C_OK=$'\033[0;32m'; C_FAIL=$'\033[0;31m'; C_RESET=$'\033[0m'
else
C_OK=""; C_FAIL=""; C_RESET=""
fi
check() {
if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $1"; else FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $1"; fi
}
has() { [[ "$1" == *"$2"* ]]; }
# Git sees no user or system config: no hooks path, no signing, a fixed identity.
: > "$SANDBOX/gitconfig"
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$SANDBOX/gitconfig"
export GIT_AUTHOR_NAME=suite GIT_AUTHOR_EMAIL=suite@local GIT_COMMITTER_NAME=suite GIT_COMMITTER_EMAIL=suite@local
HOOK_SRC="$REPO/scripts/git-hooks/pre-push"
INSTALL="$REPO/scripts/install-git-hooks.sh"
BUSY="$REPO/scripts/slot-busy.sh"
B="$SANDBOX/remote.git"
C="$SANDBOX/clone"
W="$SANDBOX/wt"
O="$SANDBOX/other"
commit() { # $1: repo, $2: file and message
echo "$2" > "$1/$2" && git -C "$1" add "$2" && git -C "$1" commit -q -m "$2"
}
rtip() { git -C "$B" rev-parse -q --verify "refs/heads/$1"; }
# ---- the remote, a clone with the guard, a worktree ----
git init -q --bare -b main "$B"
git init -q -b main "$SANDBOX/seed"
commit "$SANDBOX/seed" base
git -C "$SANDBOX/seed" push -q "$B" main
git clone -q "$B" "$C"
echo "== install =="
out=$(bash "$INSTALL" --repo "$C" 2>&1); rc=$?
check "the install into a clean clone exits 0" "$rc"
check "it reports the install and a passed canary" "$(has "$out" "ok installed the push guard" && has "$out" "canary passed"; echo $?)"
check "the hook is in the common .git/hooks, with the repository's bytes, executable" \
"$(cmp -s "$HOOK_SRC" "$C/.git/hooks/pre-push" && [ -x "$C/.git/hooks/pre-push" ]; echo $?)"
check "no temporary file is left in .git/hooks" \
"$(ls -A "$C/.git/hooks" | grep -q '^\.pre-push\.install'; [ $? = 1 ]; echo $?)"
git -C "$C" worktree add -q -b seat/w "$W" origin/main
check "the worktree's git runs the common hook" \
"$([ "$(git -C "$W" rev-parse --path-format=absolute --git-path hooks/pre-push)" = "$C/.git/hooks/pre-push" ]; echo $?)"
echo "== pushes from the worktree =="
commit "$W" one
out=$(git -C "$W" push origin seat/w 2>&1); rc=$?
check "a new branch passes" "$rc"
check "the remote branch is at the pushed commit" "$([ "$(rtip seat/w)" = "$(git -C "$W" rev-parse HEAD)" ]; echo $?)"
commit "$W" two
out=$(git -C "$W" push origin seat/w 2>&1); rc=$?
check "a fast-forward passes" "$rc"
check "the remote branch moved forward" "$([ "$(rtip seat/w)" = "$(git -C "$W" rev-parse HEAD)" ]; echo $?)"
before=$(rtip seat/w)
git -C "$W" reset -q --hard HEAD~1
commit "$W" two-rewritten
after=$(git -C "$W" rev-parse HEAD)
out=$(git -C "$W" push --force origin seat/w 2>&1); rc=$?
check "push --force of a rewritten branch is refused" "$([ "$rc" -ne 0 ]; echo $?)"
check "the refusal is the guard's, and names the ref and both shas" \
"$(has "$out" "mosaic push guard: refused: refs/heads/seat/w: not a fast-forward" && has "$out" "$before" && has "$out" "$after"; echo $?)"
check "the remote ref is unchanged after the refused --force" "$([ "$(rtip seat/w)" = "$before" ]; echo $?)"
out=$(git -C "$W" push origin +seat/w:seat/w 2>&1); rc=$?
check "a +refspec push is refused" "$([ "$rc" -ne 0 ] && has "$out" "not a fast-forward"; echo $?)"
check "the remote ref is unchanged after the refused +refspec" "$([ "$(rtip seat/w)" = "$before" ]; echo $?)"
out=$(git -C "$W" push --force-with-lease origin seat/w 2>&1); rc=$?
check "push --force-with-lease is refused too" "$([ "$rc" -ne 0 ] && has "$out" "not a fast-forward"; echo $?)"
out=$(git -C "$W" push origin :seat/w 2>&1); rc=$?
check "a delete (:ref) is refused" "$([ "$rc" -ne 0 ] && has "$out" "mosaic push guard: refused: refs/heads/seat/w: a delete"; echo $?)"
out=$(git -C "$W" push --delete origin seat/w 2>&1); rc=$?
check "a delete (--delete) is refused" "$([ "$rc" -ne 0 ] && has "$out" "a delete"; echo $?)"
check "the remote branch still exists after the refused deletes" "$([ "$(rtip seat/w)" = "$before" ]; echo $?)"
# Another clone moves the remote; this clone never fetches it.
git clone -q -b seat/w "$B" "$O"
commit "$O" from-other
git -C "$O" push -q origin seat/w
moved=$(rtip seat/w)
check "the other clone moved the remote, and this clone lacks that commit" \
"$([ "$moved" != "$before" ] && ! git -C "$C" cat-file -e "$moved^{commit}" 2>/dev/null; echo $?)"
out=$(git -C "$W" push --force origin seat/w 2>&1); rc=$?
check "a push over a remote tip missing locally is refused, saying fetch first" \
"$([ "$rc" -ne 0 ] && has "$out" "isn't in this repository; fetch first"; echo $?)"
check "the remote ref is unchanged after that refusal" "$([ "$(rtip seat/w)" = "$moved" ]; echo $?)"
check "the guard fetched nothing" "$(! git -C "$C" cat-file -e "$moved^{commit}" 2>/dev/null; echo $?)"
echo "== the hook, fed by hand =="
z=0000000000000000000000000000000000000000
hookrun() { (cd "$C" && printf '%s\n' "$1" | sh "$C/.git/hooks/pre-push" origin "$B" 2>&1); }
out=$(hookrun "refs/heads/x $(git -C "$C" rev-parse HEAD) refs/heads/x"); rc=$?
check "a short ref line is refused" "$([ "$rc" -ne 0 ] && has "$out" "unreadable ref line"; echo $?)"
out=$(hookrun "refs/heads/x HEAD refs/heads/x $z"); rc=$?
check "a non-hex object name is refused" "$([ "$rc" -ne 0 ] && has "$out" "non-hex"; echo $?)"
out=$(hookrun "refs/heads/x 1234567890123456789012345678901234567890 refs/heads/x $(git -C "$C" rev-parse HEAD)"); rc=$?
check "a failed ancestor check (an unknown local object) is refused" \
"$([ "$rc" -ne 0 ] && has "$out" "the ancestor check failed"; echo $?)"
out=$(printf '' | (cd "$C" && sh "$C/.git/hooks/pre-push" origin "$B" 2>&1)); rc=$?
check "an empty push passes" "$rc"
echo "== installer refusals =="
fresh() { rm -rf "$1"; git clone -q "$B" "$1"; }
fresh "$SANDBOX/hp"
git -C "$SANDBOX/hp" config core.hooksPath .husky
out=$(bash "$INSTALL" --repo "$SANDBOX/hp" 2>&1); rc=$?
check "the install refuses with core.hooksPath set (exit 2)" "$([ "$rc" = 2 ]; echo $?)"
check "it says core.hooksPath is set" "$(has "$out" "core.hooksPath is set"; echo $?)"
check "no hook was written under the hooksPath refusal" \
"$([ ! -e "$SANDBOX/hp/.git/hooks/pre-push" ] && [ ! -e "$SANDBOX/hp/.husky/pre-push" ]; echo $?)"
fresh "$SANDBOX/df"
printf '#!/bin/sh\nexit 0\n' > "$SANDBOX/df/.git/hooks/pre-push"
chmod 0755 "$SANDBOX/df/.git/hooks/pre-push"
ln "$SANDBOX/df/.git/hooks/pre-push" "$SANDBOX/df-old"
out=$(bash "$INSTALL" --repo "$SANDBOX/df" 2>&1); rc=$?
check "the install refuses a different installed pre-push (exit 2)" \
"$([ "$rc" = 2 ] && has "$out" "a different pre-push hook is installed"; echo $?)"
check "the different hook is untouched" \
"$([ "$(cat "$SANDBOX/df/.git/hooks/pre-push")" = "$(printf '#!/bin/sh\nexit 0')" ]; echo $?)"
out=$(bash "$INSTALL" --repo "$SANDBOX/df" --force-install 2>&1); rc=$?
check "--force-install replaces it" \
"$([ "$rc" = 0 ] && has "$out" "replaced a different hook" && cmp -s "$HOOK_SRC" "$SANDBOX/df/.git/hooks/pre-push"; echo $?)"
check "the replacement was a rename: the old file keeps its bytes" \
"$([ "$(cat "$SANDBOX/df-old")" = "$(printf '#!/bin/sh\nexit 0')" ]; echo $?)"
out=$(bash "$INSTALL" --repo "$SANDBOX/df" 2>&1); rc=$?
check "a second install over the same bytes exits 0" "$([ "$rc" = 0 ] && has "$out" "the same bytes"; echo $?)"
fresh "$SANDBOX/ln"
ln -s /dev/null "$SANDBOX/ln/.git/hooks/pre-push"
out=$(bash "$INSTALL" --repo "$SANDBOX/ln" --force-install 2>&1); rc=$?
check "the install refuses a symlinked pre-push, even with --force-install" "$([ "$rc" = 2 ] && has "$out" "is a symlink"; echo $?)"
fresh "$SANDBOX/wd"
git -C "$SANDBOX/wd" worktree add -q -b seat/x "$SANDBOX/wd-wt" origin/main
out=$(bash "$INSTALL" --repo "$SANDBOX/wd-wt" 2>&1); rc=$?
check "an install named by a worktree path lands in the common .git/hooks" \
"$([ "$rc" = 0 ] && cmp -s "$HOOK_SRC" "$SANDBOX/wd/.git/hooks/pre-push"; echo $?)"
out=$(GIT_DIR="$SANDBOX/wd/.git" bash "$INSTALL" --repo "$SANDBOX/wd" 2>&1); rc=$?
check "the install refuses with GIT_DIR set" "$([ "$rc" = 2 ] && has "$out" "GIT_DIR is set"; echo $?)"
mkdir -p "$SANDBOX/plain"
out=$(bash "$INSTALL" --repo "$SANDBOX/plain" 2>&1); rc=$?
check "the install refuses a directory that isn't a checkout" "$([ "$rc" = 2 ]; echo $?)"
out=$(bash "$INSTALL" --nope 2>&1); rc=$?
check "an unknown argument exits 4" "$([ "$rc" = 4 ]; echo $?)"
echo "== slot-busy, fake /proc =="
P="$SANDBOX/proc"
fakeproc() { # $1: pid, $2: cwd, rest: argv
local pid=$1 cwd=$2; shift 2
mkdir -p "$P/$pid"
printf '%s\0' "$@" > "$P/$pid/cmdline"
ln -sfn "$cwd" "$P/$pid/cwd"
}
busyrun() { bash "$BUSY" --proc-root "$P" 2>&1; }
fakeproc 4194401 /srv node server.mjs
fakeproc 4194402 /srv grep -r gate .
fakeproc 4194403 /srv /bin/bash -c "source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && eval 'node --test x.test.mjs'"
mkdir -p "$P/4194404"; : > "$P/4194404/cmdline"
out=$(busyrun); rc=$?
check "no matching process: exit 0 and CLEAR" "$([ "$rc" = 0 ] && [ "$out" = CLEAR ]; echo $?)"
check "an agent's shell-snapshot wrapper isn't a match" "$(! has "$out" "4194403"; echo $?)"
fakeproc 4194410 /seat/a node --test packages/x/tests/a.test.mjs
out=$(busyrun); rc=$?
check "a node --test process: exit 1" "$([ "$rc" = 1 ]; echo $?)"
check "its BUSY line names pid, rule and cwd" "$(has "$out" "BUSY 4194410 node --test cwd=/seat/a node --test"; echo $?)"
check "BUSY output never says CLEAR" "$(! has "$out" "CLEAR"; echo $?)"
ran=$(bash "$BUSY" --proc-root "$P" >/dev/null && echo ran)
check "\`slot-busy > LOG && step\` doesn't run the step while BUSY" "$([ -z "$ran" ]; echo $?)"
rm -rf "${P:?}/4194410"
rule() { # $1: expected rule, rest: argv
local want=$1; shift
rm -rf "${P:?}/4194420"; fakeproc 4194420 /seat/b "$@"
out=$(busyrun); rc=$?
check "busy on: $* ($want)" "$([ "$rc" = 1 ] && has "$out" "BUSY 4194420 $want "; echo $?)"
}
rule "node --test" /usr/bin/node --experimental-strip-types --test a.test.ts
rule "gate" bash /mnt/scratch/sage-gate.sh 253
rule "gate" bash ./gate.sh
rule "mutant" bash agents/x/work/queue-1/mutants.sh M3
rule "test script" bash scripts/test-land-work.sh
rule "docker compose" docker compose -p x up -d
rule "docker compose" /usr/bin/docker-compose up
rm -rf "${P:?}/4194420"
out=$(bash "$BUSY" --proc-root "$SANDBOX/no-such-proc" 2>&1); rc=$?
check "a missing proc root: exit 2" "$([ "$rc" = 2 ]; echo $?)"
mkdir -p "$SANDBOX/empty-proc"
out=$(bash "$BUSY" --proc-root "$SANDBOX/empty-proc" 2>&1); rc=$?
check "a proc root with no processes: exit 2" "$([ "$rc" = 2 ]; echo $?)"
out=$(bash "$BUSY" --nope 2>&1); rc=$?
check "an unknown argument: exit 2" "$([ "$rc" = 2 ]; echo $?)"
echo "== slot-busy, real /proc =="
mkdir -p "$SANDBOX/bin" "$SANDBOX/standin"
cat > "$SANDBOX/bin/node" <<'EOF'
#!/bin/sh
trap 'exit 0' TERM
while :; do sleep 0.2; done
EOF
chmod +x "$SANDBOX/bin/node"
(cd "$SANDBOX/standin" && exec "$SANDBOX/bin/node" --test standin.test.mjs) &
STANDIN=$!
for _ in $(seq 50); do
has "$(tr '\0' ' ' < "/proc/$STANDIN/cmdline" 2>/dev/null)" "--test standin" && break
sleep 0.1
done
out=$(bash "$BUSY" 2>&1); rc=$?
check "with a stand-in node --test running: exit 1" "$([ "$rc" = 1 ]; echo $?)"
check "the stand-in has a BUSY line with its cwd" \
"$(printf '%s\n' "$out" | grep -q "^BUSY $STANDIN node --test cwd=$SANDBOX/standin "; echo $?)"
check "this suite, an ancestor, has no BUSY line" "$(! printf '%s\n' "$out" | grep -q "^BUSY $$ "; echo $?)"
ran=$(bash "$BUSY" >/dev/null && echo ran)
check "\`slot-busy > LOG && step\` doesn't run the step with the stand-in up" "$([ -z "$ran" ]; echo $?)"
kill "$STANDIN" 2>/dev/null; wait "$STANDIN" 2>/dev/null
gone=$STANDIN; STANDIN=""
out=$(bash "$BUSY" 2>&1); rc=$?
check "after the stand-in exits, it has no BUSY line" "$(! printf '%s\n' "$out" | grep -q "^BUSY $gone "; echo $?)"
check "the real-/proc run exits 0 or 1, never 2" "$([ "$rc" = 0 ] || [ "$rc" = 1 ]; echo $?)"
echo "---"
echo "git-guard selftests: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]