Compare commits

..
Author SHA1 Message Date
jarvis-enhance a77afe6778 fix(tmux): resolve send-message targets to an exact session and window
tmux resolves the two halves of a target with different, individually
dangerous defaults, and send-message.sh took both defaults:

  * An unpinned name PREFIX-matches. With `foobar` alive and no `foo`,
    `-t foo` resolves to `foobar` at rc=0 -- pasted, Enter-ed, verified
    and reported OK against the wrong agent's pane.
  * A bare `=name` is only half a pin. capture-pane REJECTS it ("can't
    find pane") while list-panes silently PREFIX-MATCHES it, and the
    validation at :76 uses list-panes -- so for any caller already
    supplying `=name`, that rewrite was the only thing between them and
    a wrong-session pass.

The direction is what makes this expensive. Paste (:93-94), Enter (:151)
and the verifying capture (:153) all read one EFFECTIVE_TARGET, so a
wrong-window send is confirmed by a wrong-window read: it manufactures a
false "delivered", not a loud failure. A false negative gets
investigated; a false positive gets believed.

Normalise to `=session:` -- exact session, active window. Explicit tmux
ids (%pane, @window, $session) pass through untouched.

BEHAVIOUR CHANGE for callers that already pass `=name`: they previously
landed on `:0.0` (window 0 unconditionally) and now land on the session's
ACTIVE window. This is the intended fix -- window 0 is not where a
multi-window agent is sitting -- but it does move a live target rather
than being a no-op normalisation.

Test: test-send-message-target.sh covers all four arms (absent name must
not prefix-match, delivery follows the active window, an explicit window
part is preserved, a unique prefix is still refused). Proven able to go
red: against the pre-fix script it FAILs at arm 1, and with arm 1 removed
it FAILs at arm 2. The multi-window fixture is load-bearing -- a
single-window session cannot tell `=s:` from `=s:0.0`, which is why this
survived.

It is registered as a signed enumeration exclusion rather than on a CI
surface: it drives a real tmux server and the CI image ships no tmux,
the same condition its two siblings are already excluded under. It
hard-fails when tmux is absent rather than skipping, so it cannot go
quietly green where it cannot run.
2026-08-24 09:47:57 -05:00
code-be-01andorch-01 9014a510a9 ci(mosaic): repo-structure declaration CI gate (T51 WP5c) (#1378)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-be-01 <[email protected]>
2026-08-24 04:43:26 +00:00
code-be-01andorch-01 974e4740ab docs(mosaic): declare repo structure v2 (T51 WP2a) (#1377)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-be-01 <[email protected]>
2026-08-24 03:49:33 +00:00
orch-01 9cd6d39b71 fix(git-tools): pr-create API fallback resolves base from forge default branch (E4) (#1376)
ci/woodpecker/push/publish Pipeline was successful
2026-08-24 02:49:38 +00:00
code-be-01andorch-01 143f925fd8 fix(git-tools): admin-gated --no-ci-expected merge assertion for CI-less repositories (#1373)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-be-01 <[email protected]>
2026-08-23 18:54:49 +00:00
21 changed files with 1657 additions and 526 deletions
+8 -1
View File
@@ -1,4 +1,11 @@
{
"schema_version": 2,
"integration_trunk": "next",
"release_branch": "main"
"release_branch": "main",
"flow": "trunk-release",
"canonical_remote": "https://git.mosaicstack.dev/mosaicstack/stack",
"canonical_clone": "host:/src/mosaic-stack",
"worktree_root": "host:/src/mosaic-stack-worktrees",
"worktree_policy": "orchestrator-precreated",
"notes": "next=development/integration; main=production release. Never branch work off main. worktree_policy is TRANSITIONAL: the wrapper worktree consumer is BLOCKED on the J3/#1174 amendment (checked roots + capacity guard); pre-creation is the interim orchestration choice, not closed policy — it becomes a timing choice only after the wrapper can validate this root."
}
+34 -5
View File
@@ -100,11 +100,6 @@ steps:
# repo. Pins that comment BODIES render on both paths and that a tea
# failure is named as what it was (git-config vs credential).
- bash packages/mosaic/framework/tools/git/test-issue-view-comments.sh
# Hermetic regression for mint-seat-credential.sh (fleet onboarding moved into
# the framework): mock curl, sandboxed brain home, no tea, no network. Pins
# that the admin seat is configured rather than hardcoded and that the seat
# slot is written from the mint response at mode 600.
- bash packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
# it still blocks the three mistakes AND still lets reads, unwrapped
# endpoints and ordinary commands through. Both directions are asserted —
@@ -118,6 +113,40 @@ steps:
# stub supplies the scale instead of the host's own checkout.
- bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh
# Canonical repo-structure declaration gate (T51 WP5c, spec §5.4 point 2):
# .mosaic/repo.json is the machine-readable structure SSOT consumed by git
# wrappers and the T32 gate seat; this is its repo-side CI enforcement.
# Path-conditional: runs when the declaration, the vendored validator, or this
# pipeline config changes (manual runs always include it). Fails the pipeline
# on any VALIDATION_ERROR and enforces the schema_version 2 authoring rule
# (--require-v2: edited/new declarations may not stay v1). The validator is
# vendored into the framework tree (spec §5.1 final home) — provenance in its
# header; the hostile-input suite (101 arms, hermetic) runs alongside so the
# gate's own instrument ships in the same commit as the gate.
structure-declaration:
image: *node_image
commands:
- apk add --no-cache bash git
# MOSAIC_HOST_ROOT is a runtime anchor (spec §1.2a: unset fails closed
# for managed validation). CI has no host, so the step provisions an
# EXPLICIT fixture root — honest configuration for the resolution path,
# never a guess about a real host; the per-host containment checks are
# runtime concerns and do not run against a fixture. Grammar, schema,
# refs, flow, remote normalization, and path grammar all prove here.
- mkdir -p /tmp/t51-ci-hostroot
- bash packages/mosaic/framework/tools/structure/validate-repo-json.sh .mosaic/repo.json --require-v2
- bash packages/mosaic/framework/tools/structure/test-validate-repo-json.sh
environment:
MOSAIC_HOST_ROOT: /tmp/t51-ci-hostroot
when:
- event: pull_request
path:
include:
- '.mosaic/repo.json'
- 'packages/mosaic/framework/tools/structure/**'
- '.woodpecker/ci.yml'
- event: manual
# Canonical verify:release stage `upgrade-guard`.
# Blocking gate (#791): a framework upgrade must never write or delete an
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
@@ -111,7 +111,7 @@ approve path carries the trap.) `pr-review.sh` sends the correct token for the d
Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict
placed.
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. For a repository with no CI configured at all, `pr-merge.sh --no-ci-expected` is the sanctioned merge path: it forwards to `ci-queue-wait.sh --no-ci-expected`, which reclassifies a zero-context merge head as queue-clear only when the acting token holds repository admin and `MOSAIC_GIT_IDENTITY` names the asserting identity (a caller without one is refused with exit 78 before the admin lookup), and records the assertion (or its refusal) in the same JSONL audit log. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
### Code Review (Codex)
@@ -219,6 +219,23 @@ Multi-instance support: `-a <instance>` selects a named instance (e.g. `personal
~/.config/mosaic/tools/health/stack-health.sh -f json
```
### Repo Structure Declaration (T51)
```bash
# Validate a .mosaic/repo.json declaration (schema v1/v2, host:/ grammar,
# ref grammar, cross-field rules, remote normalization; spec §5)
~/.config/mosaic/tools/structure/validate-repo-json.sh <repo>/.mosaic/repo.json
# CI authoring rule: new/edited declarations must be schema_version 2
~/.config/mosaic/tools/structure/validate-repo-json.sh <repo>/.mosaic/repo.json --require-v2
# Display mode (warns and omits root-dependent checks when MOSAIC_HOST_ROOT unset)
~/.config/mosaic/tools/structure/validate-repo-json.sh <repo>/.mosaic/repo.json --mode display
# Hermetic hostile-input suite (101 arms)
~/.config/mosaic/tools/structure/test-validate-repo-json.sh
```
### Shared Credential Loader
```bash
@@ -1,24 +0,0 @@
# Fleet tools
Seat lifecycle tools for a Mosaic fleet. Paths are relative to
`packages/mosaic/framework/tools/fleet/` (deployed to `~/.config/mosaic/tools/fleet/`).
| Script | Purpose |
| ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `start-agent-session.sh` | launch, stop, or attach a roster-driven agent session (reads `<seat>.env.generated`, honours `MOSAIC_TMUX_SOCKET`) |
| `seat-logins.sh` | project seat tokens into `tea` logins named `<instance>-<seat>` (dry-run by default, `--apply`, `--adopt`) |
| `mint-seat-credential.sh` | create the Gitea account for a seat on every configured instance, mint a token, write the seat's credential slot, then project it into `tea` |
| `start-interaction-service.sh`, `print-interaction-effective-policy.sh`, `start-tmux-holder.sh` | operator interaction service and tmux holder |
## Onboarding a seat's credential
```
MOSAIC_ADMIN_SEAT=<admin-seat> MOSAIC_SEAT_EMAIL_DOMAIN=<domain> mint-seat-credential.sh <seat>
```
- The admin token is read from `$MOSAIC_BRAIN_HOME/fleet/agents/<admin-seat>/secrets/gitea-<instance>-<admin-seat>.token`. It is never printed.
- `MOSAIC_SEAT_EMAIL_DOMAIN` is required (no default): the framework ships no estate-specific domain.
- Instances default to the map shared with `seat-logins.sh`; `MOSAIC_GITEA_INSTANCES="a b"` limits the set and `MOSAIC_GITEA_URL_<INSTANCE>` overrides a server URL (hyphens in the instance name become underscores in the variable, as in `seat-logins.sh`).
- The seat slot is written from the mint response: `.token`, `.scopes` (what was granted), `.principal`, each mode 600.
- `tea` absent is a warning, not a failure: REST-path wrappers work with the token alone.
- Regression suite: `test-mint-seat-credential.sh` (hermetic, mock curl, no network).
@@ -1,227 +0,0 @@
#!/usr/bin/env bash
# mint-seat-credential.sh — create the Gitea account and mint a token for one seat,
# on every configured instance, writing the result into that seat's credential slot.
#
# mint-seat-credential.sh [--admin-seat <seat>] [--instances "<a> <b>"] <seat>
#
# Configuration (environment; flags win over environment):
# MOSAIC_ADMIN_SEAT seat whose admin token is used to call the Gitea
# admin API. Required. Its token is read from
# $MOSAIC_BRAIN_HOME/fleet/agents/<admin>/secrets/
# gitea-<instance>-<admin>.token. Never printed.
# MOSAIC_GITEA_INSTANCES space-separated instance names to mint on.
# Default: every instance in the map below.
# MOSAIC_GITEA_URL_<INSTANCE> server URL override per instance (same
# convention as seat-logins.sh).
# MOSAIC_SEAT_EMAIL_DOMAIN domain for the account email (<seat>@<domain>).
# Required, no default: the framework tree
# carries no estate-specific domain
# (framework-PR firewall; the instance host
# map stays per seat-logins.sh precedent).
# MOSAIC_BRAIN_HOME brain checkout; default ~/.mosaic.
#
# Exit codes: 0 minted and projected on every instance; 1 at least one instance
# failed (the others are untouched or complete); 3 usage error.
#
# WHY BASIC AUTH, WHICH LOOKS WRONG AT FIRST
# Gitea refuses token auth on POST /users/{user}/tokens by design, and the Sudo
# header and sudo query parameter are both rejected there (probed 2026-08-19, probe
# token deleted). So minting for another account needs a password: this script
# generates a random one, uses it once, and never stores or prints it. Agents
# authenticate by token; the password is not a credential anyone keeps.
#
# The .scopes file is written from the mint RESPONSE rather than from what was
# requested, so the record is what was granted rather than what was asked for.
#
# SECRETS NEVER TOUCH ARGV (#1343 class, rev-security-01 review 259), and the
# staging area is a single trap-swept directory (review 263 blocker 2): the
# admin token, the generated password, and the minted seat token all pass
# through 0600 files under a per-run staging dir removed by an EXIT/INT/TERM
# trap, so a transport failure mid-run cannot leave secrets at rest in /tmp.
#
# TRACE CHANNEL, stated plainly (review 263 blocker 1): this script is held to
# a higher bar than ordinary wrappers because it mints admin-grade
# credentials and a durable password. Secrets here are assembled FILE-TO-FILE
# — source token file, password generated straight into its staging file,
# bodies composed with jq from those files — so no secret is ever expanded
# into a shell word a trace would print. A plain `bash -x` of this script
# shows staging PATHS only. (The landed gitea_write_auth_config in
# detect-platform.sh still expands tokens into shell words and DOES leak
# under -x; that fleet-wide parity gap is tracked in its own issue — see the
# framework-hardening issue referenced from this PR.)
set -Eeuo pipefail
STAGE_DIR=""
cleanup_stage() {
[ -n "$STAGE_DIR" ] && rm -rf -- "$STAGE_DIR"
STAGE_DIR=""
}
trap cleanup_stage EXIT INT TERM
# All staging lives in one per-run dir, swept by the trap above. Files are
# created 0600 and secrets are moved between them only by tool reads
# (jq/cat), never through shell-word expansion.
new_stage() { STAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-mint.XXXXXX")"; chmod 700 "$STAGE_DIR"; }
# stage_auth <token-file> — curl --config carrying the Authorization header,
# reading the token from the file with jq so it never
# becomes a shell word.
# stage_user <seat> <pw-file> — curl --config with `user =`; the password is
# read from its file by jq. <seat> is not a secret.
# stage_body <template-json> <pw-file> — body file; jq injects the password
# file's value into the template. The mint body has
# no secret and is written directly.
stage_auth() {
local f="$STAGE_DIR/auth.cfg"
jq -rn --rawfile t "$1" '"header = \"Authorization: token " + $t + "\""' >"$f" || return 1
chmod 600 "$f"; printf '%s' "$f"
}
stage_user() {
local f="$STAGE_DIR/user.cfg"
jq -rn --rawfile p "$2" --arg u "$1" '"user = \"" + $u + ":" + $p + "\""' >"$f" || return 1
chmod 600 "$f"; printf '%s' "$f"
}
stage_body() {
# $1 is a JSON template string (no secrets); $2 is the password file. jq
# parses the template and injects the password read straight from the file.
local f="$STAGE_DIR/body.json"
jq -c --rawfile p "$2" '.password = $p' <<<"$1" >"$f" || return 1
chmod 600 "$f"; printf '%s' "$f"
}
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BRAIN="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
ADMIN="${MOSAIC_ADMIN_SEAT:-}"
INSTANCES="${MOSAIC_GITEA_INSTANCES:-}"
EMAIL_DOMAIN="${MOSAIC_SEAT_EMAIL_DOMAIN:-}"
SEAT=""
usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" >&2; exit 3; }
while [[ $# -gt 0 ]]; do
case "$1" in
--admin-seat) ADMIN="${2:-}"; shift 2 ;;
--instances) INSTANCES="${2:-}"; shift 2 ;;
-h|--help) usage ;;
-*) echo "mint: unknown flag: $1" >&2; exit 3 ;;
*) [[ -z "$SEAT" ]] || { echo "mint: one seat only" >&2; exit 3; }; SEAT="$1"; shift ;;
esac
done
[[ -n "$SEAT" ]] || usage
[[ "$SEAT" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "mint: bad seat name: $SEAT" >&2; exit 3; }
[[ -n "$ADMIN" ]] || { echo "mint: no admin seat. Set MOSAIC_ADMIN_SEAT or pass --admin-seat." >&2; exit 3; }
[[ "$ADMIN" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "mint: bad admin seat name: $ADMIN" >&2; exit 3; }
[[ -n "$EMAIL_DOMAIN" ]] || { echo "mint: no email domain. Set MOSAIC_SEAT_EMAIL_DOMAIN (the framework ships no estate default)." >&2; exit 3; }
# Instance -> server URL. Same map and override convention as seat-logins.sh:
# hyphens in instance names map to underscores in the override variable
# (MOSAIC_GITEA_URL_MY-INST is not a valid shell name; MY_INST is).
url_override_var() { printf 'MOSAIC_GITEA_URL_%s' "$(printf '%s' "$1" | tr '[:lower:]-' '[:upper:]_')"; }
declare -A INSTANCE_URL=(
[mosaicstack]="https://git.mosaicstack.dev"
[usc]="https://git.uscllc.com"
)
for inst in "${!INSTANCE_URL[@]}"; do
ov="$(url_override_var "$inst")"
[[ -n "${!ov:-}" ]] && INSTANCE_URL[$inst]="${!ov}"
done
[[ -n "$INSTANCES" ]] || INSTANCES="$(printf '%s\n' "${!INSTANCE_URL[@]}" | sort | tr '\n' ' ')"
SCOPES='["read:user","write:repository","write:issue","read:organization"]'
D="$BRAIN/fleet/agents/$SEAT/secrets"
mkdir -p "$D"; chmod 700 "$D"
rc=0
for KEY in $INSTANCES; do
ov="$(url_override_var "$KEY")"
BASE="${INSTANCE_URL[$KEY]:-${!ov:-}}"
[[ -n "$BASE" ]] || { echo " $KEY: no URL known for this instance (set $ov), skipped" >&2; rc=1; continue; }
ADMIN_TOKEN_FILE="$BRAIN/fleet/agents/$ADMIN/secrets/gitea-$KEY-$ADMIN.token"
[[ -r "$ADMIN_TOKEN_FILE" ]] || { echo " $KEY: no admin token for seat '$ADMIN' ($ADMIN_TOKEN_FILE), skipped" >&2; rc=1; continue; }
# Per-instance staging dir: everything under it dies with the trap, so a
# transport failure (review 263 blocker 2) cannot leave secrets at rest.
new_stage
AUTH_CFG="$(stage_auth "$ADMIN_TOKEN_FILE")"
# The password is generated STRAIGHT INTO its staging file; the variable
# below is its path, never the value (review 263 blocker 1).
openssl rand -base64 33 | tr -d '\n/+=' | head -c 32 >"$STAGE_DIR/pw"
chmod 600 "$STAGE_DIR/pw"
USER_CFG="$(stage_user "$SEAT" "$STAGE_DIR/pw")"
if curl -sf -o /dev/null --config "$AUTH_CFG" "$BASE/api/v1/users/$SEAT"; then
BODY="$(stage_body '{"login_name":"'"$SEAT"'","source_id":0,"password":"","must_change_password":false}' "$STAGE_DIR/pw")"
curl -s -o /dev/null -X PATCH -H "Content-Type: application/json" \
--config "$AUTH_CFG" --data "@$BODY" \
"$BASE/api/v1/admin/users/$SEAT"
act="reset-pw"
else
BODY="$(stage_body '{"username":"'"$SEAT"'","email":"'"$SEAT@$EMAIL_DOMAIN"'","password":"","must_change_password":false,"full_name":"Mosaic fleet seat '"$SEAT"'"}' "$STAGE_DIR/pw")"
curl -s -o /dev/null -X POST -H "Content-Type: application/json" \
--config "$AUTH_CFG" --data "@$BODY" \
"$BASE/api/v1/admin/users"
act="create"
fi
tmp="$STAGE_DIR/mint-response.json"
printf '{"name":"mosaic-seat","scopes":%s}' "$SCOPES" >"$STAGE_DIR/mint-body.json"; chmod 600 "$STAGE_DIR/mint-body.json"
MINT_BODY="$STAGE_DIR/mint-body.json"
code="$(curl -s -o "$tmp" -w '%{http_code}' -X POST -H "Content-Type: application/json" \
--config "$USER_CFG" --data "@$MINT_BODY" "$BASE/api/v1/users/$SEAT/tokens")"
if [[ "$code" != "201" ]]; then
echo " $KEY: mint FAILED http=$code ($act)" >&2; rm -f "$tmp"; rc=1; cleanup_stage; continue
fi
python3 - "$tmp" "$D" "$KEY" "$SEAT" <<'PY'
import json,sys,pathlib
tmp,d,key,seat=sys.argv[1:5]
t=json.load(open(tmp))
p=pathlib.Path(d)
(p/f"gitea-{key}-{seat}.token").write_text(t["sha1"]+"\n")
(p/f"gitea-{key}-{seat}.scopes").write_text(json.dumps(t.get("scopes",[]))+"\n")
(p/f"gitea-{key}-{seat}.principal").write_text(seat+"\n")
for suf in ("token","scopes","principal"):
(p/f"gitea-{key}-{seat}.{suf}").chmod(0o600)
PY
rm -f "$tmp"; cleanup_stage
new_stage # fresh staging for the verify read
VERIFY_CFG="$(stage_auth "$D/gitea-$KEY-$SEAT.token")"
login="$(curl -s --config "$VERIFY_CFG" "$BASE/api/v1/user" \
| python3 -c 'import json,sys;print(json.load(sys.stdin).get("login","ERR"))' 2>/dev/null || echo ERR)"
cleanup_stage
if [[ "$login" == "$SEAT" ]]; then
echo " $KEY: $act, minted, GET /user -> $login"
else
echo " $KEY: minted but identity check returned '$login', expected '$SEAT'" >&2; rc=1
fi
done
# ── Project into tea ─────────────────────────────────────────────────────────
# A token in the secrets dir is only half a credential. tea 0.14.0 cannot read
# that store, it only uses logins already in its own config, so a seat minted
# but not projected holds a working token and no login. Minting and projecting
# are therefore ONE operation.
#
# --adopt is deliberately NOT passed. Adopting deletes an operator-made login,
# which is a human decision. A collision reports BLOCK and a nonzero rc instead.
#
# tea absent is not a minting failure. The REST-path wrappers still work with
# the token that was just written, so warn and carry on.
SEAT_LOGINS="$SCRIPT_DIR/seat-logins.sh"
if [[ "$rc" -eq 0 ]]; then
if command -v tea >/dev/null 2>&1; then
if "$SEAT_LOGINS" --apply --seat "$SEAT"; then
:
else
echo " projection FAILED: token is minted and valid, but no tea login exists for $SEAT." >&2
echo " tea-path wrappers will not act as this seat. Re-run:" >&2
echo " $SEAT_LOGINS --apply --seat $SEAT" >&2
rc=1
fi
else
echo " tea not on PATH: token minted, no login projected (REST-path wrappers still work)." >&2
fi
fi
exit $rc
@@ -1,247 +0,0 @@
#!/usr/bin/env bash
# Hermetic regression for mint-seat-credential.sh: mock curl on PATH, sandboxed
# brain home, no tea, no network, no real credentials.
#
# Pins:
# M1 the seat slot is written from the mint RESPONSE (token, granted scopes,
# principal), each file mode 600, and the identity check passes.
# M2 the admin token is read from MOSAIC_ADMIN_SEAT's slot, never hardcoded;
# a missing admin token is reported per instance and exits nonzero.
# M3 MOSAIC_GITEA_INSTANCES limits which instances are touched, and the URL
# override MOSAIC_GITEA_URL_<INSTANCE> is honoured.
# M4 no admin seat configured is a usage error (rc=3), nothing written.
# M5 the admin token value never appears on stdout or stderr.
# M6 secrets never touch argv: no Authorization header, no -u user:pass, no
# inline --data JSON carrying the password, on any curl invocation; auth
# travels in --config files and bodies in --data @files (#1343 class,
# rev-security-01 review 259 blocker).
# M7 the scopes record discriminates: a requested-but-not-granted scope
# (write:issue) must be ABSENT from .scopes — the pin is on the RESPONSE,
# and a mutant writing the requested set fails here (both reviewers).
# M8 hyphenated instance names resolve their override through the underscore
# variable, matching seat-logins.sh (SF3).
# M9 MOSAIC_SEAT_EMAIL_DOMAIN is required: unset is a usage error (rc=3),
# nothing written (framework-PR firewall answer).
# M10 no secret at rest after ANY exit, including transport failure mid-run
# (review 263 blocker 2): the staging dir is swept by the trap, so a curl
# that dies rc=7 on the admin POST leaves nothing behind.
# M11 a real `bash -x` trace of the whole run contains no secret VALUE —
# staging appears only as paths (review 263 blocker 1).
set -euo pipefail
WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}"
SANDBOX="$WORK_ROOT/mint-seat-credential-test-$$"
MOCK_BIN="$SANDBOX/bin"; BRAIN="$SANDBOX/brain"; CALLS="$SANDBOX/calls.log"
cleanup() { rm -rf "$SANDBOX"; }
trap cleanup EXIT
fail() { echo "FAIL: $*"; exit 1; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET="$SCRIPT_DIR/mint-seat-credential.sh"
[ -f "$TARGET" ] || fail "mint-seat-credential.sh not found beside this test"
mkdir -p "$MOCK_BIN" "$BRAIN/fleet/agents/admin-seat/secrets" || fail "setup: sandbox"
: > "$CALLS"
ADMIN_TOKEN_VALUE="admin-token-value-sentinel-4491"
printf '%s\n' "$ADMIN_TOKEN_VALUE" > "$BRAIN/fleet/agents/admin-seat/secrets/gitea-alpha-admin-seat.token"
chmod 600 "$BRAIN/fleet/agents/admin-seat/secrets/gitea-alpha-admin-seat.token"
# A PATH with only the mock bin plus the system tools the script needs, and no tea.
SYS_BIN="$SANDBOX/sys"; mkdir -p "$SYS_BIN"
for t in bash sed cat mktemp openssl tr head python3 sort printf chmod mkdir rm dirname grep stat jq find wc; do
p="$(command -v "$t" 2>/dev/null || true)"; [ -n "$p" ] && ln -s "$p" "$SYS_BIN/$t"
done
export PATH="$MOCK_BIN:$SYS_BIN" CALLS
export MOSAIC_BRAIN_HOME="$BRAIN"
export MOSAIC_GITEA_URL_ALPHA="https://alpha.example.test"
export MOSAIC_SEAT_EMAIL_DOMAIN="seats.example.test"
unset MOSAIC_ADMIN_SEAT MOSAIC_GITEA_INSTANCES
# --- mock curl: records method + URL + a REDACTED auth marker, answers minting --
cat > "$MOCK_BIN/curl" <<'EOF'
#!/bin/bash
method=GET; url=""; out=""; wcode=0; auth=""; body=""
while [ $# -gt 0 ]; do
case "$1" in
-X) method="$2"; shift 2 ;;
-o) out="$2"; shift 2 ;;
-w) wcode=1; shift 2 ;;
--config)
if grep -q 'Authorization: token' "$2" 2>/dev/null; then auth="${auth}token,"; fi
if grep -q '^user = ' "$2" 2>/dev/null; then auth="${auth}user,"; fi
shift 2 ;;
--data)
case "$2" in
@*) body="@file" ;;
*) body="inline" ;;
esac
shift 2 ;;
-H|-u) shift 2 ;;
http*) url="$1"; shift ;;
*) shift ;;
esac
done
printf '%s %s auth=%s body=%s\n' "$method" "$url" "${auth:-NONE}" "$body" >> "$CALLS"
emit() { if [ -n "$out" ]; then printf '%s' "$1" > "$out"; else printf '%s' "$1"; fi; }
case "$method $url" in
"GET "*/api/v1/users/newseat) exit 22 ;; # 404 under -f: account does not exist yet
"POST "*/api/v1/admin/users) emit '{}'; exit 0 ;;
"POST "*/api/v1/users/newseat/tokens) emit '{"id":9,"name":"mosaic-seat","sha1":"minted-token-7f3a","scopes":["read:user","write:repository"]}'
[ "$wcode" = 1 ] && printf '201'; exit 0 ;;
"GET "*/api/v1/user) emit '{"login":"newseat"}'; exit 0 ;;
*) emit '{}'; exit 0 ;;
esac
EOF
chmod +x "$MOCK_BIN/curl"
[ "$(command -v curl)" = "$MOCK_BIN/curl" ] || fail "setup: curl does not resolve to the mock"
command -v tea >/dev/null 2>&1 && fail "setup: tea must be absent from the sandbox PATH"
run() { bash "$TARGET" "$@" >"$SANDBOX/out" 2>"$SANDBOX/err"; echo $?; }
# M4: no admin seat configured.
rc=$(run newseat)
[ "$rc" = 3 ] || fail "M4: expected rc=3 without an admin seat, got $rc: $(cat "$SANDBOX/err")"
grep -q 'MOSAIC_ADMIN_SEAT' "$SANDBOX/err" || fail "M4: error does not name MOSAIC_ADMIN_SEAT"
[ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M4: a token was written without an admin seat"
# M9: email domain is required, unset is a usage error, nothing written.
rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha MOSAIC_SEAT_EMAIL_DOMAIN= run newseat)
[ "$rc" = 3 ] || fail "M9: expected rc=3 with no email domain, got $rc: $(cat "$SANDBOX/err")"
grep -q 'MOSAIC_SEAT_EMAIL_DOMAIN' "$SANDBOX/err" || fail "M9: error does not name MOSAIC_SEAT_EMAIL_DOMAIN"
[ ! -s "$CALLS" ] || fail "M9: API called without an email domain"
[ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M9: token written without an email domain"
# M1 + M3 + M5: mint on the single configured instance.
: > "$CALLS"
rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha run newseat)
[ "$rc" = 0 ] || fail "M1: expected rc=0, got $rc: $(cat "$SANDBOX/err")"
SLOT="$BRAIN/fleet/agents/newseat/secrets"
[ "$(cat "$SLOT/gitea-alpha-newseat.token")" = "minted-token-7f3a" ] || fail "M1: token file not written from the mint response"
grep -q 'write:repository' "$SLOT/gitea-alpha-newseat.scopes" || fail "M1: scopes file not written from the response"
[ "$(cat "$SLOT/gitea-alpha-newseat.principal")" = "newseat" ] || fail "M1: principal file wrong"
for suf in token scopes principal; do
m=$(stat -c '%a' "$SLOT/gitea-alpha-newseat.$suf"); [ "$m" = 600 ] || fail "M1: $suf is mode $m, expected 600"
done
grep -q 'alpha: create, minted, GET /user -> newseat' "$SANDBOX/out" || fail "M1: success line missing: $(cat "$SANDBOX/out")"
grep -q 'https://alpha.example.test/api/v1/admin/users' "$CALLS" || fail "M3: URL override not honoured: $(cat "$CALLS")"
if grep -q 'usc\|mosaicstack' "$CALLS"; then fail "M3: an instance outside MOSAIC_GITEA_INSTANCES was touched: $(cat "$CALLS")"; fi
grep -q 'tea not on PATH' "$SANDBOX/err" || fail "tea-absent path should warn, not fail: $(cat "$SANDBOX/err")"
if grep -q "$ADMIN_TOKEN_VALUE" "$SANDBOX/out" "$SANDBOX/err" "$CALLS"; then fail "M5: admin token value leaked to output or call log"; fi
# M7: scopes pin discriminates — requested-but-not-granted scope is ABSENT.
if grep -q 'write:issue' "$SLOT/gitea-alpha-newseat.scopes"; then
fail "M7: write:issue appears in .scopes — the record is the REQUESTED set, not the response"
fi
# M6: no secret ever travels argv — every call authenticates via --config
# (token header or user= basic-auth directive) and bodies go as --data @file.
while IFS= read -r line; do
case "$line" in
*auth=NONE*) fail "M6: unauthenticated call: $line" ;;
*body=inline*) fail "M6: inline body (secret in argv risk): $line" ;;
esac
done < "$CALLS"
[ "$(grep -c 'auth=token' "$CALLS")" -eq 3 ] || fail "M6: expected exactly 3 token-auth calls (exists-check, admin write, verify), got: $(cat "$CALLS")"
grep -q 'auth=user' "$CALLS" || fail "M6: mint call did not use the user= directive: $(cat "$CALLS")"
# M8: hyphenated instance name resolves its override via the underscore variable.
printf '%s\n' "$ADMIN_TOKEN_VALUE" > "$BRAIN/fleet/agents/admin-seat/secrets/gitea-my-inst-admin-seat.token"
chmod 600 "$BRAIN/fleet/agents/admin-seat/secrets/gitea-my-inst-admin-seat.token"
export MOSAIC_GITEA_URL_MY_INST="https://myinst.example.test"
: > "$CALLS"; rm -rf "$BRAIN/fleet/agents/newseat"
rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=my-inst run newseat)
[ "$rc" = 0 ] || fail "M8: hyphenated instance mint failed rc=$rc: $(cat "$SANDBOX/err")"
grep -q 'https://myinst.example.test/api/v1/admin/users' "$CALLS" || fail "M8: hyphen override (MY_INST) not honoured: $(cat "$CALLS")"
unset MOSAIC_GITEA_URL_MY_INST
# M2: admin token missing for the instance is reported, rc=1, nothing written.
rm -rf "$BRAIN/fleet/agents/newseat"
: > "$CALLS"
rc=$(MOSAIC_ADMIN_SEAT=other-admin MOSAIC_GITEA_INSTANCES=alpha run newseat)
[ "$rc" = 1 ] || fail "M2: expected rc=1 with no admin token, got $rc"
grep -q "no admin token for seat 'other-admin'" "$SANDBOX/err" || fail "M2: missing-admin-token not reported: $(cat "$SANDBOX/err")"
[ ! -s "$CALLS" ] || fail "M2: API was called without an admin token: $(cat "$CALLS")"
[ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M2: token written without an admin token"
# M10: transport failure mid-run leaves NO secret at rest (review 263 blocker 2).
# A second mock that dies rc=7 on the admin POST; the trap must sweep the staging dir.
rm -rf "$BRAIN/fleet/agents/newseat"
M10_TMP="$SANDBOX/m10-tmp"; mkdir -p "$M10_TMP"
cat > "$MOCK_BIN/curl" <<'EOF'
#!/bin/bash
while [ $# -gt 0 ]; do
case "$1" in
http*) echo "$1" >> "${FAIL_URLS:?}"; exit 7 ;;
*) shift ;;
esac
done
exit 7
EOF
chmod +x "$MOCK_BIN/curl"
export FAIL_URLS="$SANDBOX/failed-urls.txt"; : > "$FAIL_URLS"
BEFORE=$(find "$M10_TMP" -maxdepth 1 -name 'mosaic-mint.*' 2>/dev/null | wc -l)
rc=$(TMPDIR="$M10_TMP" MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha run newseat)
[ "$rc" != 0 ] || fail "M10: transport failure reported rc=0"
AFTER=$(find "$M10_TMP" -maxdepth 1 -name 'mosaic-mint.*' 2>/dev/null | wc -l)
[ "$AFTER" -le "$BEFORE" ] || fail "M10: staging left at rest after failure: $AFTER dir(s) under $M10_TMP"
[ -s "$FAIL_URLS" ] || fail "M10: mock never called"
# Restore the well-behaved mock for M11.
cat > "$MOCK_BIN/curl" <<'EOF'
#!/bin/bash
method=GET; url=""; out=""; wcode=0; auth=""; body=""
while [ $# -gt 0 ]; do
case "$1" in
-X) method="$2"; shift 2 ;;
-o) out="$2"; shift 2 ;;
-w) wcode=1; shift 2 ;;
--config)
if grep -q 'Authorization: token' "$2" 2>/dev/null; then auth="${auth}token,"; fi
if grep -q '^user = ' "$2" 2>/dev/null; then auth="${auth}user,"; fi
shift 2 ;;
--data)
case "$2" in
@*) body="@file" ;;
*) body="inline" ;;
esac
shift 2 ;;
-H|-u) shift 2 ;;
http*) url="$1"; shift ;;
*) shift ;;
esac
done
printf '%s %s auth=%s body=%s\n' "$method" "$url" "${auth:-NONE}" "$body" >> "$CALLS"
emit() { if [ -n "$out" ]; then printf '%s' "$1" > "$out"; else printf '%s' "$1"; fi; }
case "$method $url" in
"GET "*/api/v1/users/newseat) exit 22 ;;
"POST "*/api/v1/admin/users) emit '{}'; exit 0 ;;
"POST "*/api/v1/users/newseat/tokens) emit '{"id":9,"name":"mosaic-seat","sha1":"minted-token-7f3a","scopes":["read:user","write:repository"]}'
[ "$wcode" = 1 ] && printf '201'; exit 0 ;;
"GET "*/api/v1/user) emit '{"login":"newseat"}'; exit 0 ;;
*) emit '{}'; exit 0 ;;
esac
EOF
chmod +x "$MOCK_BIN/curl"
unset FAIL_URLS
# M11: a real bash -x trace of a full mint contains no secret VALUE (review 263
# blocker 1). Secrets are generated straight into staging files and moved only
# by jq reads, so only staging PATHS may appear. The mock above has no secrets,
# so this measures the SCRIPT's word handling: the admin token sentinel and the
# mint response token must not appear in the xtrace of a successful run.
rm -rf "$BRAIN/fleet/agents/newseat"
: > "$CALLS"
TRACE="$SANDBOX/trace.log"
MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha bash -x "$TARGET" newseat >"$SANDBOX/out11" 2>"$TRACE" || fail "M11: traced run failed"
if grep -qF "$ADMIN_TOKEN_VALUE" "$TRACE"; then fail "M11: admin token value appears in xtrace"; fi
if grep -qF 'minted-token-7f3a' "$TRACE"; then fail "M11: minted token value appears in xtrace"; fi
# Any password-shaped expansion: the password is 32 base64ish chars. A trace
# that expands it into a word (assignment or argument) prints exactly that
# shape; the clean script's trace contains no 32-char base64ish run at all
# (paths and URLs are the only long strings).
if grep -qE "[[:space:]=\"'][A-Za-z0-9]{32}([[:space:]\"']|$)" "$TRACE"; then
LEAK=$(grep -oE "[[:space:]=\"'][A-Za-z0-9]{32}" "$TRACE" | head -1)
fail "M11: password-like value expansion in xtrace: $LEAK"
fi
echo "mint-seat-credential regression harness passed"
@@ -1,6 +1,6 @@
#!/bin/bash
# ci-queue-wait.sh - Wait until project CI queue is clear (no running/queued pipeline on branch head)
# Usage: ci-queue-wait.sh [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
# Usage: ci-queue-wait.sh [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status] [--no-ci-expected]
set -euo pipefail
@@ -14,10 +14,11 @@ TIMEOUT_SEC=900
INTERVAL_SEC=15
PURPOSE="merge"
REQUIRE_STATUS=0
NO_CI_EXPECTED=0
usage() {
cat <<EOF
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status] [--no-ci-expected]
Options:
-B, --branch BRANCH Branch head to inspect (default: current branch)
@@ -27,6 +28,7 @@ Options:
-i, --interval SECONDS Poll interval in seconds (default: 15)
--purpose VALUE Log context: push|merge (default: merge)
--require-status Fail if no CI status contexts are present
--no-ci-expected Assert this repository has no CI configured: a merge guard on a zero-context head becomes queue-clear (requires the acting token to hold repository admin); refused with exit 78 when MOSAIC_GIT_IDENTITY is unset or empty
-h, --help Show this help
Examples:
@@ -175,6 +177,50 @@ PY
return 0
}
# Durable audit record for an explicit no-CI assertion event (granted or
# refused). Same JSONL sink and field shape as record_cannot_assert so one
# reader covers all three outcomes; the outcome value distinguishes them.
# rc 70 on an unwritable sink: a merge pass that cannot be audited must not
# be reachable, mirroring record_cannot_assert's refusal of a degraded pass.
record_assertion_event() {
local outcome="$1" reason="$2" asserted_by="$3"
local audit_log="${MOSAIC_CI_QUEUE_AUDIT_LOG:-${XDG_STATE_HOME:-${HOME:-}/.local/state}/mosaic/audit/ci-queue-wait.jsonl}"
if [[ -z "$audit_log" ]] || ! mkdir -p "$(dirname "$audit_log")"; then
echo "Error: could not write ${outcome} audit record (audit directory unavailable at ${audit_log})." >&2
return 70
fi
if ! python3 - "$audit_log" "$outcome" "$reason" "$asserted_by" "${PLATFORM:-unknown}" "$PURPOSE" "${BRANCH:-unknown}" "${OWNER:-unknown}/${REPO:-unknown}" <<'PY'
import datetime
import json
import os
import sys
path, outcome, reason, asserted_by, platform, purpose, branch, repo = sys.argv[1:]
record = {
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"outcome": outcome,
"reason": reason,
"platform": platform,
"purpose": purpose,
"branch": branch,
"repo": repo,
"asserted_by": asserted_by,
}
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
try:
os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode())
finally:
os.close(fd)
PY
then
echo "Error: could not write ${outcome} audit record at ${audit_log}; refusing to proceed unaudited." >&2
return 70
fi
return 0
}
github_get_branch_head_sha() {
local owner="$1"
local repo="$2"
@@ -182,6 +228,24 @@ github_get_branch_head_sha() {
gh api "repos/${owner}/${repo}/branches/${branch}" --jq '.commit.sha'
}
# Repository-admin state for the acting credential, GitHub flavor. The
# repository object's permissions.admin is the field; read through the same
# gh CLI the guard already authenticates with. rc 0 = admin, 1 = not admin
# (or field absent), 2 = indeterminate (transport/API failure).
github_repo_admin_state() {
local owner="$1"
local repo="$2"
local perm
if ! perm=$(gh api "repos/${owner}/${repo}" --jq '.permissions.admin' 2>/dev/null); then
return 2
fi
case "$perm" in
true) return 0 ;;
false|null|"") return 1 ;;
*) return 2 ;;
esac
}
github_get_commit_status_json() {
local owner="$1"
local repo="$2"
@@ -306,6 +370,41 @@ gitea_get_commit_status_json() {
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
}
# Repository-admin state for the acting credential, Gitea flavor. The guard's
# existing fetches (branch head, combined status) carry no permissions object
# (measured: neither response includes one), so the elevation check reads the
# repository object's permissions.admin, the one documented carrier of that
# field. rc 0 = admin, 1 = not admin (or field absent), 2 = indeterminate
# (non-200 or unparseable).
gitea_repo_admin_state() {
local host="$1"
local repo="$2"
local token="$3"
local url="https://${host}/api/v1/repos/${repo}"
local resp code body
resp=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token ${token}" -w $'\n%{http_code}' "$url") || return 2
code="${resp##*$'\n'}"
body="${resp%$'\n'*}"
if [[ "$code" != "200" ]]; then
return 2
fi
printf '%s' "$body" | python3 -c '
import json
import sys
try:
payload = json.load(sys.stdin)
except Exception:
raise SystemExit(2)
if not isinstance(payload, dict):
raise SystemExit(2)
permissions = payload.get("permissions")
if not isinstance(permissions, dict) or permissions.get("admin") is not True:
raise SystemExit(1)
raise SystemExit(0)
'
}
while [[ $# -gt 0 ]]; do
case "$1" in
-B|--branch)
@@ -336,6 +435,10 @@ while [[ $# -gt 0 ]]; do
REQUIRE_STATUS=1
shift
;;
--no-ci-expected)
NO_CI_EXPECTED=1
shift
;;
-h|--help)
usage
exit 0
@@ -365,6 +468,10 @@ if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
echo "Error: --purpose must be push or merge." >&2
exit 1
fi
if [[ "$NO_CI_EXPECTED" -eq 1 && "$REQUIRE_STATUS" -eq 1 ]]; then
echo "Error: --no-ci-expected and --require-status contradict each other: one asserts the repository has no CI, the other demands status contexts. Pass at most one." >&2
exit 1
fi
OWNER="unknown"
REPO="unknown"
@@ -484,6 +591,48 @@ while true; do
echo "[ci-queue-wait] queue-clear state=no-status purpose=push branch=${BRANCH}; no queued or running CI."
exit 0
fi
if [[ "$NO_CI_EXPECTED" -eq 1 ]]; then
# Explicit, elevated, audit-visible assertion that this
# repository has no CI to wait on. The zero-context case is
# the ONLY state the flag reclassifies: a pending or failed
# context still holds or fails exactly as without it, and a
# non-admin token is refused rather than trusted.
# The assertion must name an asserting identity: "unknown"
# attributes nothing, so a caller with MOSAIC_GIT_IDENTITY
# unset or empty is refused (exit 78) BEFORE the permission
# lookup -- an unattributable caller never triggers that
# network call.
if [[ -z "${MOSAIC_GIT_IDENTITY:-}" ]]; then
record_assertion_event "ASSERTION_UNATTRIBUTABLE" "actor-unattributable" "unknown" \
|| echo "Warning: could not write the ASSERTION_UNATTRIBUTABLE audit record; the refusal itself stands." >&2
echo "Error: ASSERTION_UNATTRIBUTABLE state=no-status purpose=merge asserted-by=unknown reason=no-ci-expected branch=${BRANCH}; --no-ci-expected requires MOSAIC_GIT_IDENTITY to name the asserting identity and it is unset or empty (exit 78)." >&2
exit 78
fi
ASSERTED_BY="${MOSAIC_GIT_IDENTITY}"
ADMIN_STATE=2
if [[ "$PLATFORM" == "github" ]]; then
if github_repo_admin_state "$OWNER" "$REPO"; then ADMIN_STATE=0; else ADMIN_STATE=$?; fi
else
if gitea_repo_admin_state "$HOST" "$OWNER/$REPO" "$TOKEN"; then ADMIN_STATE=0; else ADMIN_STATE=$?; fi
fi
case "$ADMIN_STATE" in
0)
record_assertion_event "NO_CI_ASSERTED" "no-ci-expected" "$ASSERTED_BY" || exit $?
echo "[ci-queue-wait] queue-clear state=no-status purpose=merge asserted-by=${ASSERTED_BY} reason=no-ci-expected branch=${BRANCH}"
exit 0
;;
1)
record_assertion_event "ASSERTION_REFUSED" "actor-not-repo-admin" "$ASSERTED_BY" \
|| echo "Warning: could not write the ASSERTION_REFUSED audit record; the refusal itself stands." >&2
echo "Error: ASSERTION_REFUSED state=no-status purpose=merge asserted-by=${ASSERTED_BY} reason=no-ci-expected branch=${BRANCH}; --no-ci-expected requires repository admin and the acting token is not an admin of ${OWNER}/${REPO} (exit 77)." >&2
exit 77
;;
*)
record_cannot_assert "repo-permissions-unavailable"
exit $?
;;
esac
fi
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
exit 3
;;
@@ -19,6 +19,41 @@ ISSUE=""
# get_remote_host, get_gitea_token, get_repo_info, and get_gitea_repo_args are provided by detect-platform.sh
gitea_default_branch() {
# Forge default branch for the current repo (T51-P2 WP5a / spec E4): the
# API fallback must not guess a base. Empty output or any lookup failure
# returns nonzero so the caller fails loud instead of mistargeting a PR.
local host repo token url body branch
host=$(get_remote_host) || return 1
repo=$(get_repo_info) || return 1
token=$(get_gitea_token "$host") || return 1
url="https://${host}/api/v1/repos/${repo}"
# Fetch and parse as separate steps (T51P2WP5AR B2): a piped
# `curl | python` reports only python's status, so an HTTP failure that
# still emits parseable JSON would masquerade as success. curl's own
# exit status is authoritative here.
if ! body=$(curl -fsS \
-H "User-Agent: curl/8" \
-H "Authorization: token ${token}" \
"$url" 2>/dev/null); then
return 1
fi
# A valid base is a NONBLANK JSON STRING (T51P2WP5AR B3): null, numbers,
# and whitespace-only values are failed resolution, never a POSTed base.
branch=$(printf '%s' "$body" | python3 -c '
import json, sys
try:
value = json.load(sys.stdin).get("default_branch")
except Exception:
sys.exit(1)
if not isinstance(value, str) or not value.strip():
sys.exit(1)
print(value.strip())
' 2>/dev/null) || return 1
[[ -n "$branch" ]] || return 1
printf '%s' "$branch"
}
gitea_pr_create_api() {
local host repo token url payload
host=$(get_remote_host) || {
@@ -38,14 +73,28 @@ gitea_pr_create_api() {
echo "Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup." >&2
fi
payload=$(TITLE="$TITLE" BODY="$BODY" HEAD_BRANCH="$HEAD_BRANCH" BASE_BRANCH="$BASE_BRANCH" python3 - <<'PY'
# Base resolution (spec E4): an explicit -B always wins; with none, the
# forge default branch is resolved from the provider API -- never the
# historical "main" literal, which mistargeted every fallback PR on
# repos whose trunk is not main (e.g. mosaicstack/stack -> next).
local api_base=""
if [[ -n "$BASE_BRANCH" ]]; then
api_base="$BASE_BRANCH"
else
api_base=$(gitea_default_branch) || {
echo "Error: could not resolve the forge default branch for the API-fallback base; pass -B <branch> explicitly" >&2
return 1
}
fi
payload=$(TITLE="$TITLE" BODY="$BODY" HEAD_BRANCH="$HEAD_BRANCH" API_BASE="$api_base" python3 - <<'PY'
import json
import os
payload = {
"title": os.environ["TITLE"],
"head": os.environ["HEAD_BRANCH"],
"base": os.environ["BASE_BRANCH"] or "main",
"base": os.environ["API_BASE"],
}
body = os.environ.get("BODY", "")
if body:
@@ -72,7 +121,7 @@ Create a pull request on the current repository (Gitea or GitHub).
Options:
-t, --title TITLE PR title (required, or use --issue)
-b, --body BODY PR description/body
-B, --base BRANCH Base branch to merge into (default: main/master)
-B, --base BRANCH Base branch to merge into (default: the forge repository's default branch)
-H, --head BRANCH Head branch with changes (default: current branch)
-l, --labels LABELS Comma-separated labels
-m, --milestone NAME Milestone name
@@ -1,6 +1,6 @@
#!/bin/bash
# pr-merge.sh - Merge pull requests on Gitea or GitHub
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--no-ci-expected] [--co-author-trailers --escalate-to PRINCIPAL]
set -euo pipefail
@@ -16,6 +16,7 @@ DRY_RUN=false
EXPECT_HEAD=""
CO_AUTHOR_TRAILERS=false
ESCALATE_TO=""
NO_CI_EXPECTED=false
usage() {
cat <<EOF
@@ -29,6 +30,7 @@ Options:
-d, --delete-branch Delete the head branch after merge
--dry-run Run metadata/login preflight without merging
--expect-head SHA Refuse unless the PR head matches this full commit SHA
--no-ci-expected Assert the target repository has no CI: forward --no-ci-expected to the queue guard (requires repository admin)
--co-author-trailers Build verified trailers from linked PR commit authors
--escalate-to NAME Named principal for an unresolved-author BLOCK
-h, --help Show this help message
@@ -70,6 +72,10 @@ while [[ $# -gt 0 ]]; do
EXPECT_HEAD="$2"
shift 2
;;
--no-ci-expected)
NO_CI_EXPECTED=true
shift
;;
--co-author-trailers)
CO_AUTHOR_TRAILERS=true
shift
@@ -154,13 +160,18 @@ if [[ "$DRY_RUN" != true ]]; then
if [[ -z "$BASE_REPO" ]]; then
BASE_REPO="$(get_repo_owner)/$(get_repo_name)"
fi
"$SCRIPT_DIR/ci-queue-wait.sh" \
--purpose merge \
-B "$HEAD_BRANCH" \
-R "$BASE_REPO" \
--sha "$HEAD_SHA" \
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
guard_args=(
--purpose merge
-B "$HEAD_BRANCH"
-R "$BASE_REPO"
--sha "$HEAD_SHA"
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}"
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
)
if [[ "$NO_CI_EXPECTED" == true ]]; then
guard_args+=(--no-ci-expected)
fi
"$SCRIPT_DIR/ci-queue-wait.sh" "${guard_args[@]}"
fi
PLATFORM=$(detect_platform)
@@ -0,0 +1,323 @@
#!/usr/bin/env bash
# Regression harness for ci-queue-wait.sh's --no-ci-expected assertion:
# the sanctioned merge path for a repository with no CI configured at all.
#
# Zero status contexts ("no-status") stays fail-closed for --purpose merge
# by default, because at merge time no-status can also mean "CI has not
# reported yet". --no-ci-expected reclassifies ONLY that zero-context case
# as queue-clear, and only for a caller whose acting token holds repository
# admin. This harness pins:
# (a) merge + no-status + flag + admin -> exit 0, audit line + JSONL.
# (b) merge + no-status, no flag -> exit 3, existing text (unchanged).
# (c) merge + no-status + flag + non-admin -> exit 77 ASSERTION_REFUSED
# (distinct text, exit code NOT 3) + JSONL refusal record.
# (c2) flag + admin payload without the admin field -> fail closed as (c).
# (d) flag + --require-status -> usage error, before any network.
# (e) flag + a real pending context -> still holds (timeout 124),
# and the admin endpoint is never consulted.
# (f) push + no-status, with and without the flag -> push queue-clear
# unchanged; no admin consultation on push.
# (g) flag + admin lookup unreachable -> CANNOT_ASSERT hold (75),
# not a silent pass and not a refusal.
# (h) flag + admin stub + NO MOSAIC_GIT_IDENTITY -> refusal BEFORE
# queue-clear and BEFORE the admin lookup: exit 78, no queue-clear
# line, an ASSERTION_UNATTRIBUTABLE JSONL record, no repos/ call.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-no-ci-expected}"
REPO_DIR="$WORK_DIR/repo"
STUB_DIR="$WORK_DIR/stubs"
URL_LOG="$WORK_DIR/urls.log"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$STUB_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
# Same stub conventions as test-ci-queue-wait-no-status.sh; adds the
# repository-object endpoint (admin state) selected by MOSAIC_STUB_ADMIN_MODE.
cat > "$STUB_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
has_w=0
url=""
for arg in "$@"; do
case "$arg" in
-w) has_w=1 ;;
http://*|https://*) url="$arg" ;;
esac
done
printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
case "$url" in
*/branches/*)
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
if [[ "$has_w" == 1 ]]; then
printf '%s\n200' "$body"
else
printf '%s' "$body"
fi
exit 0
;;
*/status)
mode="${MOSAIC_STUB_STATUS_MODE:?MOSAIC_STUB_STATUS_MODE not set}"
case "$mode" in
no-status) body='{"state":"","statuses":[]}' ;;
real-pending) body='{"state":"pending","statuses":[{"context":"ci/woodpecker","status":"running","target_url":""}]}' ;;
*) echo "curl stub: unknown status mode=$mode" >&2; exit 2 ;;
esac
printf '%s' "$body"
exit 0
;;
*/repos/*)
mode="${MOSAIC_STUB_ADMIN_MODE:?MOSAIC_STUB_ADMIN_MODE not set}"
case "$mode" in
admin) body='{"permissions":{"admin":true,"push":true,"pull":true}}' ;;
non-admin) body='{"permissions":{"admin":false,"push":true,"pull":true}}' ;;
no-admin-field) body='{"permissions":{}}' ;;
unreachable) exit 7 ;;
*) echo "curl stub: unknown admin mode=$mode" >&2; exit 2 ;;
esac
if [[ "$has_w" == 1 ]]; then
printf '%s\n200' "$body"
else
printf '%s' "$body"
fi
exit 0
;;
*)
echo "curl stub: unrecognized URL: $url" >&2
exit 2
;;
esac
SH
chmod +x "$STUB_DIR/curl"
failures=0
run_guard() {
local name="$1"; shift
(
cd "$REPO_DIR" || exit
export PATH="$STUB_DIR:$PATH"
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit-$name.jsonl"
export MOSAIC_STUB_URL_LOG="$URL_LOG"
export GITEA_TOKEN="stub-token"
export GITEA_URL="https://git.example.test"
export MOSAIC_GIT_IDENTITY="test-identity"
"$SCRIPT_DIR/ci-queue-wait.sh" -B main -t 3 -i 1 "$@"
)
}
# The suite exports test-identity globally, so the unattributable-caller
# case must strip it from the child environment at invocation with env -u,
# not rely on the export order.
run_guard_no_identity() {
local name="$1"; shift
(
cd "$REPO_DIR" || exit
export PATH="$STUB_DIR:$PATH"
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit-$name.jsonl"
export MOSAIC_STUB_URL_LOG="$URL_LOG"
export GITEA_TOKEN="stub-token"
export GITEA_URL="https://git.example.test"
export MOSAIC_GIT_IDENTITY="test-identity"
env -u MOSAIC_GIT_IDENTITY \
"$SCRIPT_DIR/ci-queue-wait.sh" -B main -t 3 -i 1 "$@"
)
}
expect_rc() {
local name="$1" want="$2" got="$3"
if [[ "$want" == "not3" ]]; then
if [[ "$got" -eq 0 || "$got" -eq 3 ]]; then
echo "FAIL $name: expected a refusal rc (nonzero, not 3), got $got" >&2
failures=$((failures + 1))
return 1
fi
elif [[ "$got" -ne "$want" ]]; then
echo "FAIL $name: expected rc=$want, got rc=$got" >&2
failures=$((failures + 1))
return 1
fi
return 0
}
expect_text() {
local name="$1" want="$2" output="$3" polarity="${4:-present}"
if [[ "$polarity" == "present" && "$output" != *"$want"* ]]; then
echo "FAIL $name: output missing '$want'" >&2
printf '%s\n' "$output" >&2
failures=$((failures + 1))
elif [[ "$polarity" == "absent" && "$output" == *"$want"* ]]; then
echo "FAIL $name: output unexpectedly contains '$want'" >&2
printf '%s\n' "$output" >&2
failures=$((failures + 1))
fi
}
repo_root_fetched() {
grep -q 'repos/acme/widgets$' "$URL_LOG"
}
# (a) merge + no-status + flag + admin -> exit 0, assertion line, JSONL record.
: > "$URL_LOG"
set +e
out_a=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=admin run_guard a --purpose merge --no-ci-expected 2>&1)
rc_a=$?
set -u
if expect_rc a 0 "$rc_a"; then
expect_text a "queue-clear state=no-status purpose=merge asserted-by=test-identity reason=no-ci-expected branch=main" "$out_a"
expect_text a "ASSERTED_NOT_READY" "$out_a" absent
if ! grep -q '"outcome":"NO_CI_ASSERTED"' "$WORK_DIR/audit-a.jsonl" 2>/dev/null; then
echo "FAIL a: expected a NO_CI_ASSERTED JSONL audit record" >&2
failures=$((failures + 1))
elif ! grep -q '"asserted_by":"test-identity"' "$WORK_DIR/audit-a.jsonl"; then
echo "FAIL a: audit record does not name the asserting identity" >&2
failures=$((failures + 1))
fi
fi
# (b) merge + no-status, no flag -> exit 3, existing error text unchanged.
: > "$URL_LOG"
set +e
out_b=$(MOSAIC_STUB_STATUS_MODE=no-status run_guard b --purpose merge 2>&1)
rc_b=$?
set -u
if expect_rc b 3 "$rc_b"; then
expect_text b "Error: ASSERTED_NOT_READY state=no-status purpose=merge branch=main." "$out_b"
expect_text b "asserted-by" "$out_b" absent
fi
if repo_root_fetched; then
echo "FAIL b: admin endpoint consulted without the flag" >&2
failures=$((failures + 1))
fi
# (c) merge + no-status + flag + non-admin -> distinct refusal, rc NOT 3.
: > "$URL_LOG"
set +e
out_c=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=non-admin run_guard c --purpose merge --no-ci-expected 2>&1)
rc_c=$?
set -u
if expect_rc c not3 "$rc_c"; then
if [[ "$rc_c" -ne 77 ]]; then
echo "FAIL c: expected the documented refusal rc=77, got $rc_c" >&2
failures=$((failures + 1))
fi
expect_text c "ASSERTION_REFUSED state=no-status purpose=merge asserted-by=test-identity reason=no-ci-expected branch=main" "$out_c"
expect_text c "ASSERTED_NOT_READY" "$out_c" absent
if ! grep -q '"outcome":"ASSERTION_REFUSED"' "$WORK_DIR/audit-c.jsonl" 2>/dev/null; then
echo "FAIL c: expected an ASSERTION_REFUSED JSONL audit record" >&2
failures=$((failures + 1))
fi
fi
# (c2) admin payload with no admin field -> fail closed as non-admin.
: > "$URL_LOG"
set +e
out_c2=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=no-admin-field run_guard c2 --purpose merge --no-ci-expected 2>&1)
rc_c2=$?
set -u
if expect_rc c2 77 "$rc_c2"; then
expect_text c2 "ASSERTION_REFUSED" "$out_c2"
fi
# (d) flag + --require-status -> usage error before any network I/O.
: > "$URL_LOG"
set +e
out_d=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=admin run_guard d --purpose merge --no-ci-expected --require-status 2>&1)
rc_d=$?
set -u
if expect_rc d 1 "$rc_d"; then
expect_text d "--no-ci-expected and --require-status contradict" "$out_d"
fi
if [[ -s "$URL_LOG" ]]; then
echo "FAIL d: usage error must precede every network call" >&2
failures=$((failures + 1))
fi
# (e) flag + a real pending context -> still holds; admin endpoint never asked.
: > "$URL_LOG"
set +e
out_e=$(MOSAIC_STUB_STATUS_MODE=real-pending MOSAIC_STUB_ADMIN_MODE=admin run_guard e --purpose merge --no-ci-expected 2>&1)
rc_e=$?
set -u
if expect_rc e 124 "$rc_e"; then
expect_text e "ASSERTED_NOT_READY" "$out_e"
expect_text e "ci/woodpecker=running" "$out_e"
fi
if repo_root_fetched; then
echo "FAIL e: a pending context must not trigger the admin assertion" >&2
failures=$((failures + 1))
fi
# (f) push + no-status stays queue-clear, with and without the flag.
: > "$URL_LOG"
set +e
out_f=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=non-admin run_guard f --purpose push 2>&1)
rc_f=$?
set -u
if expect_rc f 0 "$rc_f"; then
expect_text f "queue-clear state=no-status purpose=push branch=main; no queued or running CI." "$out_f"
fi
: > "$URL_LOG"
set +e
out_f2=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=non-admin run_guard f2 --purpose push --no-ci-expected 2>&1)
rc_f2=$?
set -u
if expect_rc f2 0 "$rc_f2"; then
expect_text f2 "queue-clear state=no-status purpose=push branch=main; no queued or running CI." "$out_f2"
expect_text f2 "asserted-by" "$out_f2" absent
fi
if repo_root_fetched; then
echo "FAIL f: push must not consult the admin endpoint" >&2
failures=$((failures + 1))
fi
# (g) flag + admin lookup unreachable -> CANNOT_ASSERT hold (75), not a pass.
: > "$URL_LOG"
set +e
out_g=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=unreachable run_guard g --purpose merge --no-ci-expected 2>&1)
rc_g=$?
set -u
if expect_rc g 75 "$rc_g"; then
expect_text g "CANNOT_ASSERT reason=repo-permissions-unavailable" "$out_g"
fi
if ! grep -q '"outcome":"CANNOT_ASSERT"' "$WORK_DIR/audit-g.jsonl" 2>/dev/null; then
echo "FAIL g: expected a CANNOT_ASSERT JSONL audit record" >&2
failures=$((failures + 1))
fi
# (h) flag + admin stub + no asserting identity -> refusal before queue-clear
# and before the admin lookup: rc 78, no queue-clear line, an
# ASSERTION_UNATTRIBUTABLE JSONL record, and zero repos/ network calls.
: > "$URL_LOG"
set +e
out_h=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=admin run_guard_no_identity h --purpose merge --no-ci-expected 2>&1)
rc_h=$?
set -u
if expect_rc h 78 "$rc_h"; then
expect_text h "ASSERTION_UNATTRIBUTABLE state=no-status purpose=merge asserted-by=unknown reason=no-ci-expected branch=main" "$out_h"
expect_text h "queue-clear" "$out_h" absent
if ! grep -q '"outcome":"ASSERTION_UNATTRIBUTABLE"' "$WORK_DIR/audit-h.jsonl" 2>/dev/null; then
echo "FAIL h: expected an ASSERTION_UNATTRIBUTABLE JSONL audit record" >&2
failures=$((failures + 1))
fi
fi
if repo_root_fetched; then
echo "FAIL h: an unattributable caller must not trigger the permission lookup" >&2
failures=$((failures + 1))
fi
if [[ "$failures" -ne 0 ]]; then
echo "ci-queue-wait no-ci-expected regression failed ($failures assertions)" >&2
exit 1
fi
echo "ci-queue-wait no-ci-expected regression passed (all outcome classes)"
@@ -100,6 +100,15 @@ case "$url" in
*/commits/*/status)
printf '{"state":"success","statuses":[{"context":"ci/mock","status":"success"}]}'
;;
# Repo roots: the pr-create API fallback resolves its base from the forge
# default_branch (T51-P2 WP5a). Exact-suffix matches so the /pulls POST
# endpoint (no trailing path) still falls through to the catch-all.
*/api/v1/repos/USC/uconnect)
printf '{"default_branch":"main"}'
;;
*/api/v1/repos/mosaicstack/stack)
printf '{"default_branch":"next"}'
;;
*)
printf '{}'
;;
@@ -67,7 +67,18 @@ cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG"
printf '%s\n' '{"number":703}'
# Repo roots: the pr-create API fallback resolves its base from the forge
# default_branch (T51-P2 WP5a). Exact-suffix so every other endpoint keeps
# the historical answer below.
url="${*: -1}"
case "$url" in
*/api/v1/repos/mosaicstack/stack)
printf '%s\n' '{"default_branch":"next"}'
;;
*)
printf '%s\n' '{"number":703}'
;;
esac
SH
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
@@ -0,0 +1,178 @@
#!/usr/bin/env bash
# test-pr-create-fallback-default-base.sh — hermetic test for the API-fallback
# base resolution in pr-create.sh (T51-P2 WP5a / spec E4).
#
# The API-fallback payload historically hardcoded "base": "main", mistargeting
# every fallback PR on repos whose trunk is not main (e.g. mosaicstack/stack,
# default branch "next"). The fix: an explicit -B always wins; with none, the
# base is resolved from the provider API default_branch, and a failed
# resolution fails loud instead of guessing.
#
# Hermetic by construction: every curl invocation is a PATH-first stub; the
# fixture repo's remote is git.example.test (never dialed); HOME is a sandbox
# with no tea config (so the wrapper takes the API fallback path); GITEA_TOKEN
# comes from the environment. No real forge is contacted.
# shellcheck disable=SC2317
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-create-fallback-base}"
PASS=0 FAIL=0 FAILED_CASES=""
ok() { PASS=$((PASS + 1)); }
bad() { FAIL=$((FAIL + 1)); FAILED_CASES="$FAILED_CASES $1"; printf 'FAIL: %s\n' "$1" >&2; }
assert_rc() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected rc=$e got rc=$a)"; }
assert_eq() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected [$e] got [$a])"; }
assert_contains() { local d="$1" h="$2" n="$3"; case "$h" in *"$n"*) ok ;; *) bad "$d (missing [$n])" ;; esac; }
json_field() { # $1 payload file, $2 field
python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], ""))' "$1" "$2"
}
rm -rf "$WORK_DIR"
# ---- fixture -----------------------------------------------------------------
ROOT="$WORK_DIR/fixture"
TOOLS="$ROOT/tools/git"
mkdir -p "$TOOLS" "$ROOT/repo" "$ROOT/home" "$ROOT/stub"
cp "$SCRIPT_DIR/pr-create.sh" "$TOOLS/pr-create.sh"
cp "$SCRIPT_DIR/detect-platform.sh" "$TOOLS/detect-platform.sh"
git -C "$ROOT/repo" init -q -b fix/e4
git -C "$ROOT/repo" -c user.name=fixture -c user.email=fixture@test commit -q --allow-empty -m base
git -C "$ROOT/repo" remote add origin https://git.example.test/acme/widgets.git
# curl stub: GET repo -> default_branch JSON (or failure mode); POST pulls ->
# capture payload, answer with a minimal PR JSON. Every call is logged.
cat > "$ROOT/stub/curl" <<STUB
#!/usr/bin/env bash
set -u
mode="\${CURL_STUB_GET_MODE:-ok}"
printf '%s\n' "\$*" >> "$ROOT/curl-calls.log"
url="\${!#}"
if [[ "\$url" == */api/v1/repos/acme/widgets ]]; then
# repo GET (default-branch resolution). Modes cover the value shapes the
# resolver must accept or refuse (T51P2WP5AR B2/B3).
case "\$mode" in
ok) printf '%s\n' '{"id":1,"default_branch":"next","full_name":"acme/widgets"}' ;;
fail) echo "curl stub: simulated repo lookup failure" >&2; exit 1 ;;
fail-json) printf '%s\n' '{"default_branch":"next"}'; exit 22 ;;
null) printf '%s\n' '{"default_branch":null}' ;;
numeric) printf '%s\n' '{"default_branch":7}' ;;
blank) printf '%s\n' '{"default_branch":" "}' ;;
*) echo "curl stub: unknown GET mode \$mode" >&2; exit 1 ;;
esac
exit 0
fi
if [[ "\$url" == */api/v1/repos/acme/widgets/pulls ]]; then
# PR POST: capture the payload, emit a PR-shaped answer
while [[ \$# -gt 0 ]]; do
case "\$1" in
-d) printf '%s' "\$2" > "$ROOT/payload.json"; shift 2 ;;
*) shift ;;
esac
done
printf '%s\n' '{"number":42,"html_url":"https://git.example.test/acme/widgets/pulls/42"}'
exit 0
fi
echo "curl stub: unexpected URL \$url" >&2
exit 1
STUB
chmod +x "$ROOT/stub/curl"
run_pr_create() { # args... -> sets RC/OUT/ERR
RC=0
OUT=$(cd "$ROOT/repo" && env -i \
PATH="$ROOT/stub:/usr/bin:/bin" \
HOME="$ROOT/home" \
GITEA_TOKEN=stub-token \
bash "$TOOLS/pr-create.sh" "$@" 2>"$ROOT/err.txt")
RC=$?
ERR="$(cat "$ROOT/err.txt")"
}
calls_matching() { grep -c -- "$1" "$ROOT/curl-calls.log" 2>/dev/null || true; }
echo "== (1) no -B: fallback base resolves to the forge default branch, not main =="
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
run_pr_create -t "fix thing"
assert_rc "rc" 0 "$RC"
assert_contains "API fallback path taken (tea login unresolvable in fixture)" "$ERR" "trying Gitea API fallback"
assert_eq "repo GET performed" 1 "$(calls_matching '/api/v1/repos/acme/widgets$')"
assert_eq "POST performed" 1 "$(calls_matching '/pulls$')"
assert_eq "payload base is forge default (next)" "next" "$(json_field "$ROOT/payload.json" base)"
assert_eq "payload head" "fix/e4" "$(json_field "$ROOT/payload.json" head)"
assert_eq "payload title" "fix thing" "$(json_field "$ROOT/payload.json" title)"
echo "== (2) explicit -B wins; the default branch is not consulted =="
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
run_pr_create -t "fix thing" -B release/1.x
assert_rc "rc" 0 "$RC"
assert_eq "repo GET not consulted for explicit base" 0 "$(calls_matching '/api/v1/repos/acme/widgets$')"
assert_eq "payload base is the explicit -B" "release/1.x" "$(json_field "$ROOT/payload.json" base)"
echo "== (3) default-branch lookup failure: loud refusal, no POST =="
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
RC=0
OUT=$(cd "$ROOT/repo" && env -i \
PATH="$ROOT/stub:/usr/bin:/bin" \
HOME="$ROOT/home" \
GITEA_TOKEN=stub-token \
CURL_STUB_GET_MODE=fail \
bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt")
RC=$?
ERR="$(cat "$ROOT/err.txt")"
assert_rc "nonzero rc on unresolvable base" 1 "$RC"
assert_contains "loud error names -B" "$ERR" "could not resolve the forge default branch"
assert_contains "error names the remedy" "$ERR" "pass -B <branch> explicitly"
assert_eq "no POST issued" 0 "$(calls_matching '/pulls$')"
echo "== (4) payload never contains the literal fallback main =="
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
run_pr_create -t "fix thing"
assert_rc "rc" 0 "$RC"
assert_eq "base field is next, never main" "next" "$(json_field "$ROOT/payload.json" base)"
echo "== (5) B2: HTTP failure with parseable JSON on stdout is a FAILED resolution =="
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
RC=0
OUT=$(cd "$ROOT/repo" && env -i \
PATH="$ROOT/stub:/usr/bin:/bin" \
HOME="$ROOT/home" \
GITEA_TOKEN=stub-token \
CURL_STUB_GET_MODE=fail-json \
bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt")
RC=$?
ERR="$(cat "$ROOT/err.txt")"
assert_rc "nonzero rc on HTTP failure despite valid JSON" 1 "$RC"
assert_contains "loud error names -B" "$ERR" "could not resolve the forge default branch"
assert_contains "error names the remedy" "$ERR" "pass -B <branch> explicitly"
assert_eq "no POST issued" 0 "$(calls_matching '/pulls$')"
echo "== (6) B3: null / numeric / blank default_branch are failed resolutions =="
for bad in null numeric blank; do
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
RC=0
OUT=$(cd "$ROOT/repo" && env -i \
PATH="$ROOT/stub:/usr/bin:/bin" \
HOME="$ROOT/home" \
GITEA_TOKEN=stub-token \
CURL_STUB_GET_MODE="$bad" \
bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt")
RC=$?
ERR="$(cat "$ROOT/err.txt")"
assert_rc "B3 $bad: nonzero rc" 1 "$RC"
assert_contains "B3 $bad: loud error" "$ERR" "could not resolve the forge default branch"
assert_eq "B3 $bad: no POST issued" 0 "$(calls_matching '/pulls$')"
done
echo
echo "pass=$PASS fail=$FAIL"
if [ "$FAIL" -gt 0 ]; then
echo "FAILED CASES:$FAILED_CASES"
exit 1
fi
echo "ALL GREEN"
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# pr-merge must forward --no-ci-expected to the queue guard, and only then.
# The flag is the sanctioned merge path for a repository with no CI configured
# (see test-ci-queue-wait-no-ci-expected.sh for the guard-side semantics);
# this harness pins only the pass-through: present when requested, absent when
# not, with the rest of the guard invocation unchanged.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-no-ci-expected}"
FIXTURE_DIR="$WORK_DIR/tools/git"
CALL_LOG="$WORK_DIR/queue-call.log"
rm -rf "$WORK_DIR"
mkdir -p "$FIXTURE_DIR"
cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh"
cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/no-ci-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}'
SH
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' "$*" > "${MOSAIC_QUEUE_CALL_LOG:?}"
exit 42
SH
chmod +x "$FIXTURE_DIR"/*.sh
run_merge() {
(
cd "$WORK_DIR"
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
"$FIXTURE_DIR/pr-merge.sh" -n 123 "$@"
) >/dev/null 2>&1
}
fail=0
# With the flag: it must reach the guard invocation.
: > "$CALL_LOG"
set +e
run_merge --no-ci-expected
rc_with=$?
set -e
if [[ "$rc_with" -ne 42 ]]; then
echo "FAIL(with): expected queue stub rc=42 to propagate, got $rc_with" >&2
fail=1
elif ! grep -q -- '--no-ci-expected' "$CALL_LOG"; then
echo "FAIL(with): --no-ci-expected did not reach the queue guard" >&2
cat "$CALL_LOG" >&2
fail=1
fi
# The rest of the guard invocation is unchanged by the flag.
for required in '--purpose merge' '-B fix/no-ci-fixture' '-R mosaicstack/stack' \
'--sha 0123456789abcdef0123456789abcdef01234567'; do
if ! grep -qF -- "$required" "$CALL_LOG"; then
echo "FAIL(with): guard invocation lost '$required'" >&2
cat "$CALL_LOG" >&2
fail=1
fi
done
# Without the flag: it must NOT appear in the guard invocation.
: > "$CALL_LOG"
set +e
run_merge
rc_without=$?
set -e
if [[ "$rc_without" -ne 42 ]]; then
echo "FAIL(without): expected queue stub rc=42 to propagate, got $rc_without" >&2
fail=1
elif grep -q -- '--no-ci-expected' "$CALL_LOG"; then
echo "FAIL(without): --no-ci-expected reached the guard without being requested" >&2
cat "$CALL_LOG" >&2
fail=1
fi
if [[ "$fail" -eq 0 ]]; then
echo "pr-merge no-ci-expected pass-through regression passed"
fi
exit "$fail"
@@ -29,6 +29,10 @@ packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh | unmeasured i
# --- tools/tmux: require a live tmux server ---
packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a real tmux server on a throwaway socket; CI image ships no tmux; #1017 burndown (needs tmux in image or a signed permanent exclusion)
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
# The entry below is NOT covered by the #1017 signature block above: it was
# signed by jarvis-enhance (dragon-lin, 2026-08-24) at a later base, for the
# test added alongside the send-message.sh exact-target fix.
packages/mosaic/framework/tools/tmux/test-send-message-target.sh | requires a real tmux server on a throwaway socket, and specifically a MULTI-WINDOW session (the bug it guards is invisible on a single-window fixture); CI image ships no tmux; same burndown condition as its two siblings above
# --- single-suite directories: unmeasured in CI ---
@@ -0,0 +1,289 @@
#!/usr/bin/env bash
# test-validate-repo-json.sh — hostile-input suite for the T51 declaration validator.
# (Vendored with validate-repo-json.sh from mosaic-brain @ 515bcbab — see the
# validator header for provenance.)
#
# Hermetic: all fixtures in a tracked mktemp sandbox removed by an EXIT trap
# (pass and fail paths both — zero residue). No network, no real repos, no host
# state mutated. MOSAIC_HOST_ROOT is set/unset per arm via env only.
#
# T51P2RW1: arms extended per review T51P2R1 (F1-F5): root gate for ordinary
# v2 declarations (unset AND explicitly empty; display warns), git-grammar
# branch arms (double slash, dot component, control byte), contract-escape
# arms (list enums, invalid UTF-8, NaN — one VALIDATION_ERROR line, never a
# traceback), remote normalization (.git/ ordering, port preservation), and
# mirror-component fullmatch arms (trailing newline, control bytes).
set -u
HERE=$(cd "$(dirname "$0")" && pwd)
V="$HERE/validate-repo-json.sh"
PASS=0; FAIL=0; FAILED=""
ok() { PASS=$((PASS+1)); }
bad() { FAIL=$((FAIL+1)); FAILED="$FAILED $1"; printf 'FAIL: %s\n' "$1" >&2; }
SB=$(mktemp -d "${TMPDIR:-/tmp}/vrj-test.XXXXXX")
trap 'rm -rf "$SB"' EXIT
fx() { printf '%s' "$2" > "$SB/$1"; }
run() { # [env KV=V ...] -- args...
local envs=()
while [ "$1" != "--" ]; do envs+=("$1"); shift; done; shift
OUT=$(env "${envs[@]:-_=_}" bash "$V" "$@" 2>&1 < /dev/null; echo "__RC__$?")
RC=${OUT##*__RC__}; OUT=${OUT%__RC__*}; OUT=${OUT%$'\n'}
}
expect_ok() { local d="$1"; shift; run "$@"; if [ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q '^OK'; then ok; else bad "$d (rc=$RC out=$(printf '%s' "$OUT" | head -1))"; fi; }
expect_err() { # desc expected-substring [env... -- args...]
local d="$1" sub="$2"; shift 2
run "$@"
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "VALIDATION_ERROR.*$sub"; then ok
else bad "$d (rc=$RC, wanted error ~$sub, got: ${OUT%%$'\n'*})"; fi
}
expect_err_notrace() { # like expect_err, plus no traceback anywhere in output
local d="$1" sub="$2"; shift 2
run "$@"
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "VALIDATION_ERROR.*$sub" && ! printf '%s' "$OUT" | grep -q "Traceback"; then ok
else bad "$d (rc=$RC, wanted clean error ~$sub, got: ${OUT%%$'\n'*})"; fi
}
STACK='{"schema_version":2,"integration_trunk":"next","release_branch":"main","flow":"trunk-release","canonical_remote":"https://git.mosaicstack.dev/mosaicstack/stack","canonical_clone":"host:/src/mosaic-stack","worktree_root":"host:/src/mosaic-stack-worktrees","worktree_policy":"orchestrator-precreated","notes":"x"}'
BRAIN='{"schema_version":2,"integration_trunk":"main","release_branch":"main","flow":"direct","canonical_remote":"https://git.example.invalid/acme/brain","canonical_clone":"host:/.mosaic","worktree_root":"host:/.mosaic-worktrees","worktree_policy":"orchestrator-precreated"}'
ROOT="$SB/hostroot"; mkdir -p "$ROOT"
echo "== (0) syntax + version =="
bash -n "$V" && ok || bad "bash -n"
run -- --version; [ "$RC" = 0 ] && case "$OUT" in validate-repo-json\ *) ok ;; *) bad "version output" ;; esac || bad "version rc"
echo "== (1) spec examples: stack + brain OK (root set) =="
fx stack.json "$STACK"; fx brain.json "$BRAIN"
expect_ok a1 MOSAIC_HOST_ROOT=$ROOT -- "$SB/stack.json"
expect_ok a2 MOSAIC_HOST_ROOT=$ROOT -- "$SB/brain.json"
echo "== (2) malformed JSON (stable contract, no traceback) =="
fx bad.json '{"schema_version": 2, '
expect_err_notrace b1 "json:" -- "$SB/bad.json"
fx arr.json '[1,2]'
expect_err_notrace b2 "top level" -- "$SB/arr.json"
printf '\xff\xfe{"schema_version":2}' > "$SB/utf8.json"
expect_err_notrace b3 "UTF-8" MOSAIC_HOST_ROOT=$ROOT -- "$SB/utf8.json"
fx nan.json '{"schema_version":NaN}'
expect_err_notrace b4 "malformed JSON" MOSAIC_HOST_ROOT=$ROOT -- "$SB/nan.json"
echo "== (3) unknown schema_version = ABSENT-loud =="
fx v3.json "${STACK/schema_version\":2/schema_version\":3}"
expect_err c1 "schema_version" MOSAIC_HOST_ROOT=$ROOT -- "$SB/v3.json"
echo "== (4) v1 mode + authoring rule (v1 consumes no paths: no root needed) =="
fx v1.json '{"integration_trunk":"next","release_branch":"main"}'
expect_ok d1 -- "$SB/v1.json"
expect_err d2 "schema_version" -- --require-v2 "$SB/v1.json"
fx v1x.json '{"integration_trunk":"next","release_branch":"main","notes":"no"}'
expect_err d3 "x_extensions" -- "$SB/v1x.json"
echo "== (5) unknown top-level key rejected; x_extensions home OK =="
fx unk.json "${STACK%\}*},\"typo_key\":1}"
expect_err e1 "typo_key" MOSAIC_HOST_ROOT=$ROOT -- "$SB/unk.json"
fx ext.json "${STACK%\}*},\"x_extensions\":{\"future\":true}}"
expect_ok e2 MOSAIC_HOST_ROOT=$ROOT -- "$SB/ext.json"
echo "== (6) flow: required (no defaulting) + cross-field =="
fx noflow.json "$(printf '%s' "$STACK" | python3 -c 'import json,sys; d=json.load(sys.stdin); del d["flow"]; print(json.dumps(d))')"
expect_err f1 "flow" MOSAIC_HOST_ROOT=$ROOT -- "$SB/noflow.json"
fx xdirect.json "${STACK/\"trunk-release\"/\"direct\"}"
expect_err f2 "direct" MOSAIC_HOST_ROOT=$ROOT -- "$SB/xdirect.json"
fx xtr.json "${BRAIN/\"direct\"/\"trunk-release\"}"
expect_err f3 "trunk-release" MOSAIC_HOST_ROOT=$ROOT -- "$SB/xtr.json"
echo "== (7) dot-segment / empty-segment / tilde escapes =="
fx dots.json "${STACK/host:\/src\/mosaic-stack\"/host:/src/../secrets\"}"
expect_err g1 "dot segment" MOSAIC_HOST_ROOT=$ROOT -- "$SB/dots.json"
fx dot1.json "${STACK/host:\/src\/mosaic-stack\"/host:/src/./mosaic-stack\"}"
expect_err g2 "dot segment" MOSAIC_HOST_ROOT=$ROOT -- "$SB/dot1.json"
fx empty.json "${STACK/host:\/src\/mosaic-stack\"/host://src/mosaic-stack\"}"
expect_err g3 "empty segment" MOSAIC_HOST_ROOT=$ROOT -- "$SB/empty.json"
fx tild.json "${STACK/host:\/src\/mosaic-stack\"/~jw/src/mosaic-stack\"}"
expect_err g4 "tilde" MOSAIC_HOST_ROOT=$ROOT -- "$SB/tild.json"
fx tailslash.json "${STACK/host:\/src\/mosaic-stack\"/host:/src/mosaic-stack/\"}"
expect_err g5 "empty segment" MOSAIC_HOST_ROOT=$ROOT -- "$SB/tailslash.json"
fx noanchor.json "${STACK/host:\/src\/mosaic-stack\"//src/mosaic-stack\"}"
expect_err g6 "host:/" MOSAIC_HOST_ROOT=$ROOT -- "$SB/noanchor.json"
echo "== (8) branch-name grammar (delegated to git check-ref-format, F2) =="
fx badbr.json "${STACK/\"next\"/\"bad..name\"}"
expect_err h1 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/badbr.json"
fx sp.json "${STACK/\"next\"/\"fea ture\"}"
expect_err h2 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/sp.json"
fx lock.json "${STACK/\"next\"/\"feature/x.lock\"}"
expect_err h3 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/lock.json"
fx slash.json "${STACK/\"next\"/\"feature/x\"}"
expect_ok h4 MOSAIC_HOST_ROOT=$ROOT -- "$SB/slash.json"
fx dslash.json "${STACK/\"next\"/\"feature//x\"}"
expect_err h5 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/dslash.json"
fx hidden.json "${STACK/\"next\"/\"feature/.hidden\"}"
expect_err h6 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/hidden.json"
fx ctrl.json "$(printf '%s' "$STACK" | python3 -c 'import json,sys; d=json.load(sys.stdin); d["integration_trunk"]="feature/\x01x"; print(json.dumps(d))')"
expect_err h7 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/ctrl.json"
echo "== (8b) reflog shorthand rejected independent of ambient checkout history (B1) =="
# Hermetic repo WITH checkout history: proves '@{-1}' (which git would expand to
# 'main' from THIS repo's reflog) is still refused by the pre-delegation gate.
HISTREPO="$SB/histrepo"; mkdir -p "$HISTREPO"
(cd "$HISTREPO" && git init -q -b main . \
&& git -c user.name=t -c user.email=t@t commit -q --allow-empty -m m \
&& git checkout -q -b feature/x \
&& git checkout -q main \
&& git check-ref-format --branch "@{-1}" >/dev/null 2>&1 && echo "ambient-expandable" || echo "not-expandable") \
| grep -q ambient-expandable && ok || bad "fixture repo failed to make @{-1} expandable"
fx atminus1.json "$(printf '%s' "$STACK" | python3 -c 'import json,sys; d=json.load(sys.stdin); d["integration_trunk"]="@{-1}"; print(json.dumps(d))')"
# Run the validator from INSIDE the history repo via command substitution so the
# assertion runs in the PARENT shell (T51P2R3 B1: the previous ( subshell ) form
# mutated ok/bad counters only in a dead subshell — FAIL printed, suite rc 0).
OUTX=$(cd "$HISTREPO" && MOSAIC_HOST_ROOT=$ROOT bash "$V" "$SB/atminus1.json" 2>&1 </dev/null; echo "__RC__$?")
RCX=${OUTX##*__RC__}
if [ "$RCX" = 1 ] && printf '%s' "$OUTX" | grep -q "VALIDATION_ERROR.*@{"; then ok
else bad "@{-1} must be rejected inside a repo with checkout history (got rc=$RCX)"; fi
fx atbrace.json "$(printf '%s' "$STACK" | python3 -c 'import json,sys; d=json.load(sys.stdin); d["integration_trunk"]="@{u}"; print(json.dumps(d))')"
expect_err h9 "@{" MOSAIC_HOST_ROOT=$ROOT -- "$SB/atbrace.json"
echo "== (9) canonical_remote: userinfo, list-type, normalization (F3/F4) =="
fx user.json "${STACK/https:\/\/git.mosaicstack.dev/https:\/\/bot:s3cret@git.mosaicstack.dev}"
expect_err_notrace i1 "userinfo" MOSAIC_HOST_ROOT=$ROOT -- "$SB/user.json"
fx listflow.json "${STACK/\"trunk-release\"/[\"trunk-release\"]}"
expect_err_notrace i2 "flow" MOSAIC_HOST_ROOT=$ROOT -- "$SB/listflow.json"
fx listpol.json "${STACK/\"orchestrator-precreated\"/[\"tool-managed\"]}"
expect_err_notrace i3 "worktree_policy" MOSAIC_HOST_ROOT=$ROOT -- "$SB/listpol.json"
run -- --normalize-remote "HTTPS://Git.Example.Invalid/o/r.git"
[ "$RC" = 0 ] && [ "$OUT" = "https://git.example.invalid/o/r" ] && ok || bad "norm .git/case ($OUT)"
run -- --normalize-remote "https://git.mosaicstack.dev/mosaicstack/stack/"
[ "$RC" = 0 ] && [ "$OUT" = "https://git.mosaicstack.dev/mosaicstack/stack" ] && ok || bad "norm trailing slash ($OUT)"
run -- --normalize-remote "git.mosaicstack.dev/mosaicstack/stack"
[ "$RC" = 1 ] && ok || bad "schemeless must fail"
run -- --normalize-remote "HTTPS://Git.Example.Invalid/o/r.git/"
[ "$RC" = 0 ] && [ "$OUT" = "https://git.example.invalid/o/r" ] && ok || bad "norm .git-then-slash ($OUT)"
run -- --normalize-remote "https://Git.Example.Invalid:8443/o/r.git"
[ "$RC" = 0 ] && [ "$OUT" = "https://git.example.invalid:8443/o/r" ] && ok || bad "port must be preserved ($OUT)"
run -- --normalize-remote "https://[2001:db8::1]:8443/o/r.git"
[ "$RC" = 0 ] && [ "$OUT" = "https://[2001:db8::1]:8443/o/r" ] && ok || bad "IPv6 must stay bracketed with port ($OUT)"
run -- --normalize-remote "https://[2001:db8::1]/o/r.git"
[ "$RC" = 0 ] && [ "$OUT" = "https://[2001:db8::1]/o/r" ] && ok || bad "IPv6 must stay bracketed ($OUT)"
run -- --normalize-remote "https://Git.Example.Invalid:0/o/r.git"
[ "$RC" = 0 ] && [ "$OUT" = "https://git.example.invalid:0/o/r" ] && ok || bad "explicit port 0 must be preserved ($OUT)"
run -- --normalize-remote "https://[::1].evil.example/o/r.git"
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "suffix after ] must be rejected (.evil.example)"
run -- --normalize-remote "https://[::1]x:8443/o/r.git"
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "suffix after ] must be rejected (x:8443)"
run -- --normalize-remote "https://[::1]x/o/r.git"
[ "$RC" = 1 ] && ok || bad "suffix after ] must be rejected (x)"
echo "== (9b) IPvFuture bracketed authorities (R4-B1: guard keys off raw netloc) =="
run -- --normalize-remote "https://[v1.fe80]/o/r.git"
[ "$RC" = 0 ] && [ "$OUT" = "https://[v1.fe80]/o/r" ] && ok || bad "valid IPvFuture must keep brackets ($OUT)"
run -- --normalize-remote "https://[vF.foo]:8443/o/r.git"
[ "$RC" = 0 ] && [ "$OUT" = "https://[vf.foo]:8443/o/r" ] && ok || bad "valid IPvFuture+port must keep brackets ($OUT)"
run -- --normalize-remote "https://[v1.fe80]evil/o/r.git"
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "IPvFuture suffix must be rejected (evil)"
run -- --normalize-remote "https://[v1.fe80].evil.example/o/r.git"
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "IPvFuture suffix must be rejected (.evil.example)"
run -- --normalize-remote "https://[vF.foo]x:8443/o/r.git"
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "IPvFuture suffix must be rejected (x:8443)"
echo "== (9d) non-bracketed authority grammar (R6-B1) =="
for U in "https://:8443/o/r.git" "https://bad host/o/r.git" "https://bad^host/o/r.git" "https://bad\\host/o/r.git" "https://bad%zz/o/r.git" "https://bad%2/o/r.git" "https://bad%/o/r.git"; do
run -- --normalize-remote "$U"
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "VALIDATION_ERROR canonical_remote"; then ok
else bad "non-bracketed authority must be rejected: $U (rc=$RC out=$OUT)"; fi
done
run -- --normalize-remote "https://git.mosaicstack.dev:9000/mosaicstack/stack"
[ "$RC" = 0 ] && [ "$OUT" = "https://git.mosaicstack.dev:9000/mosaicstack/stack" ] && ok || bad "valid host:port unchanged ($OUT)"
run -- --normalize-remote "https://192.168.1.10:8443/o/r.git"
[ "$RC" = 0 ] && [ "$OUT" = "https://192.168.1.10:8443/o/r" ] && ok || bad "IPv4 reg-name stays valid ($OUT)"
run -- --normalize-remote "https://bad%2Fx/o/r.git"
[ "$RC" = 0 ] && [ "$OUT" = "https://bad%2fx/o/r" ] && ok || bad "complete %HH must stay legal, case-normalized ($OUT)"
echo "== (9e) ASCII-only authority bytes (R7-B1) =="
# isolated port arm (R8): VALID ASCII host + full-width-digit port ONLY —
# unconfounded, so restoring Unicode-aware isdigit() goes red right here.
run -- --normalize-remote "https://git.example.invalid:443/o/r.git"
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "ASCII digits only"; then ok
else bad "full-width-digit port on a VALID host must be rejected with the port reason (rc=$RC out=$OUT)"; fi
for U in "https://éxample.invalid/o/r.git" "https://例え.テスト/o/r.git" "https://fullwidth.invalid/o/r.git" "https://mosaicstack.dev:443/o/r.git"; do
run -- --normalize-remote "$U"
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "VALIDATION_ERROR canonical_remote"; then ok
else bad "non-ASCII authority must be rejected: $U (rc=$RC out=$OUT)"; fi
done
run -- --normalize-remote "https://xn--xample-9ua.invalid/o/r.git"
[ "$RC" = 0 ] && [ "$OUT" = "https://xn--xample-9ua.invalid/o/r" ] && ok || bad "punycode xn-- host must stay legal ($OUT)"
echo "== (9c) bracket-payload grammar + raw control bytes (R5-B1) =="
for P in "v1. " "v1.a b" "v1.a^b" "v1.a\\b" "v1.%20" "not-an-ip" "::gg::1"; do
run -- --normalize-remote "https://[$P]/o/r.git"
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "VALIDATION_ERROR canonical_remote"; then ok
else bad "bracket payload [$P] must be rejected (rc=$RC out=$OUT)"; fi
done
for CB in $'\t' $'\n' $'\r'; do
run -- --normalize-remote "https://[v1.a${CB}b]/o/r.git"
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "control byte"; then ok
else bad "raw control byte must be rejected before urlsplit (rc=$RC out=$OUT)"; fi
done
run -- --normalize-remote "https://[v1.fe80%zone]/o/r.git"
[ "$RC" = 1 ] && ok || bad "percent (not in RFC host grammar) must be rejected ($OUT)"
run -- --normalize-remote "https://[fe80::1%eth0]/o/r.git"
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "IPv6 zone-id (not RFC host grammar) must be rejected ($OUT)"
echo "== (10) root gate: every v2 managed validation fails closed (F1) =="
# NOTE (spec §4.5): host:/ paths resolve UNDER MOSAIC_HOST_ROOT by construction,
# so tool-managed is not declarable today — the cross-check fails for every
# host:/ root until the anchor scheme grows an outside-root form (J3 era).
expect_err j1 "MOSAIC_HOST_ROOT" MOSAIC_HOST_ROOT= -- "$SB/stack.json"
expect_err j2 "MOSAIC_HOST_ROOT" MOSAIC_HOST_ROOT= -- "$SB/brain.json"
expect_err j3 "MOSAIC_HOST_ROOT" MOSAIC_HOST_ROOT= -- "$SB/stack.json"
# rider (T51P2R2): genuine ABSENCE, not just explicitly empty — captured via
# command substitution, asserted in the parent shell (no subshell-counter shape).
OUTU=$(cd "$SB" && env -u MOSAIC_HOST_ROOT bash "$V" "$SB/stack.json" 2>&1 </dev/null; echo "__RC__$?")
RCU=${OUTU##*__RC__}
if [ "$RCU" = 1 ] && printf '%s' "$OUTU" | grep -q "VALIDATION_ERROR.*MOSAIC_HOST_ROOT"; then ok
else bad "unset-by-absence root must fail closed in managed mode (rc=$RCU)"; fi
run MOSAIC_HOST_ROOT= -- --mode display "$SB/stack.json"
if [ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q '^OK' && printf '%s' "$OUT" | grep -q "host root unset"; then ok
else bad "display-mode unset must pass with the specified warning (rc=$RC)"; fi
run MOSAIC_HOST_ROOT= -- --mode display "$SB/brain.json"
if [ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q "host root unset"; then ok
else bad "display-mode warn missing for brain fixture"; fi
TM='{"schema_version":2,"integration_trunk":"next","release_branch":"main","flow":"trunk-release","canonical_remote":"https://git.mosaicstack.dev/mosaicstack/stack","canonical_clone":"host:/src/mosaic-stack","worktree_root":"host:/src/mosaic-stack-worktrees","worktree_policy":"tool-managed"}'
fx tm.json "$TM"; mkdir -p "$ROOT/src"
expect_err j4 "inside MOSAIC_HOST_ROOT" MOSAIC_HOST_ROOT=$ROOT -- "$SB/tm.json"
fx tm_noroot.json "$(printf '%s' "$TM" | python3 -c 'import json,sys; d=json.load(sys.stdin); del d["worktree_root"]; print(json.dumps(d))')"
expect_err j5 "worktree_root" MOSAIC_HOST_ROOT=$ROOT -- "$SB/tm_noroot.json"
echo "== (10b) symlink escape cannot fake outside-ness (B3 fix: lexical containment) =="
OUTSIDE="$SB/outside-target"; mkdir -p "$OUTSIDE"
ln -s "$OUTSIDE" "$ROOT/escape"
TM_ESC="${TM/host:\/src\/mosaic-stack-worktrees/host:/escape/worktrees}"
fx tmsym.json "$TM_ESC"
expect_err j6 "inside MOSAIC_HOST_ROOT" MOSAIC_HOST_ROOT=$ROOT -- "$SB/tmsym.json"
echo "== (11) mirror-path components: collision/delimiter/control fixtures (F5) =="
run -- --mirror-path git.mosaicstack.dev mosaicstack stack
[ "$RC" = 0 ] && [ "$OUT" = "projects/git.mosaicstack.dev/mosaicstack/stack/repo.json" ] && ok || bad "mirror path ok ($OUT)"
expect_err k1 "mirror-component" -- --mirror-path "git.mosaicstack.dev" "a__b" "c"
expect_err k2 "mirror-component" -- --mirror-path "git.mosaicstack.dev" "a" "b__c"
expect_err k3 "mirror-component" -- --mirror-path "git.mosaicstack.dev/x" "a" "b"
expect_err k4 "mirror-component" -- --mirror-path "git.mosaicstack.dev" "A" "B"
expect_err k5 "mirror-component" -- --mirror-path "git.mosaicstack.dev" "" "stack"
run -- --mirror-path git.mosaicstack.dev a b.c
[ "$RC" = 0 ] && [ "$OUT" = "projects/git.mosaicstack.dev/a/b.c/repo.json" ] && ok || bad "distinct path ($OUT)"
run -- --mirror-path $'git.example.invalid\n' owner repo
[ "$RC" = 1 ] && ok || bad "trailing-newline host must be rejected (fullmatch)"
run -- --mirror-path $'git.\texample' owner repo
[ "$RC" = 1 ] && ok || bad "control-byte host must be rejected"
echo "== (12) missing required keys =="
for key in release_branch canonical_clone; do
fx miss.json "$(printf '%s' "$STACK" | python3 -c "import json,sys; d=json.load(sys.stdin); del d['$key']; print(json.dumps(d))")"
expect_err "l-$key" "$key" MOSAIC_HOST_ROOT=$ROOT -- "$SB/miss.json"
done
echo "== (13) absent file =="
expect_err m1 "file" -- "$SB/nonexistent.json"
echo
echo "pass=$PASS fail=$FAIL"
if [ "$FAIL" -gt 0 ]; then echo "FAILED:$FAILED"; exit 1; fi
echo "ALL GREEN"
@@ -0,0 +1,386 @@
#!/usr/bin/env bash
# validate-repo-json.sh — declaration validator for T51 repo structure declarations.
#
# Spec of record: docs/plans/2026-08-23_repo-structure-declaration.md @ 1896adc1
# (R3). Implements the spec's validation surface: schema v1/v2 (§1.2), host:/
# path grammar with canonical segment normalization — empty/./.. rejected
# BEFORE resolution — and tilde rejection (§1.2a), mirror path component
# validation (§3.1), cross-field rules (§5.2), remote normalization (§5.3).
#
# PROVENANCE (T51 WP5c vendoring): ported verbatim from the mosaic-brain tree —
# tools/repo-structure-decl/validate-repo-json.sh @ brain main merge 515bcbab
# (PR 28, wave-1 R9 PASS, 101-arm suite green). This file is now the framework
# home per spec §5.1 ("shipped in the framework package"); the brain copy is the
# development origin. Re-sync rule: changes land here via reviewed PR and are
# back-ported to the brain tree (or the brain copy retires) — never fork silently.
# Validator version at port: 1.1.0+t51spec-r3+t51p2rw1 (R2-R8 rework included).
# No operator literal appears in this file; the host root is read from
# MOSAIC_HOST_ROOT configuration only.
#
# Unset-root semantics (spec §1.2a, fail-closed; T51P2R1 F1): v2 declarations
# always consume a path (canonical_clone is required), so in --mode managed an
# unset OR EMPTY MOSAIC_HOST_ROOT is a VALIDATION_ERROR for every v2 file —
# not only tool-managed. In --mode display it warns and omits root-dependent
# resolution; grammar checks still run.
#
# Error contract (T51P2R1 F3): every malformed input — bad UTF-8, non-RFC JSON
# constants (NaN/Infinity), wrong-typed enums, anything unexpected — yields
# exactly one stable VALIDATION_ERROR line and exit 1. No traceback ever
# escapes. Branch names are validated by delegating to `git check-ref-format
# --branch` (F2), translated into this contract.
#
# Usage:
# validate-repo-json.sh <repo.json> [--mode managed|display] [--require-v2]
# validate-repo-json.sh --mirror-path <host> <owner> <repo> # §3.1 component check
# validate-repo-json.sh --normalize-remote <url> # §5.3, prints normalized
# validate-repo-json.sh --version
#
# Output: OK (exit 0) | VALIDATION_ERROR <key>: <reason> (exit 1) | warnings on stderr.
set -euo pipefail
VERSION="1.1.0+t51spec-r3+t51p2rw1"
if [ "${1:-}" = "--version" ]; then echo "validate-repo-json $VERSION"; exit 0; fi
exec python3 - "$@" <<'PYEOF'
import json, os, re, subprocess, sys, urllib.parse
def err(key, reason):
print(f"VALIDATION_ERROR {key}: {reason}")
sys.exit(1)
def warn(msg):
print(f"warning: {msg}", file=sys.stderr)
def main():
ARGS = sys.argv[1:]
MODE = "managed"
REQUIRE_V2 = False
# ---- subcommands first (they take no file argument) ----
def check_mirror_components(host, owner, repo):
# fullmatch: '$' must bind at true end (F5 — a trailing newline must
# NOT pass); the charset excludes control bytes outright.
comp_re = re.compile(r"[a-z0-9][a-z0-9.-]*")
for label, value in (("host", host), ("owner", owner), ("repo", repo)):
if not isinstance(value, str) or not value or not comp_re.fullmatch(value):
err("mirror-component", f"{label} {value!r} fails §3.1 charset ^[a-z0-9][a-z0-9.-]*$ (fullmatch, no '/', no delimiter, no control bytes)")
return f"projects/{host}/{owner}/{repo}/repo.json"
def normalize_remote(url):
# R5-B1 part 1: reject raw control bytes BEFORE urlsplit — urlsplit
# silently strips TAB/LF/CR, so different input bytes would normalize
# to a different host. The raw bytes ARE the input; nothing may rewrite them.
for ch in url:
if ord(ch) < 0x20 or ord(ch) == 0x7F:
err("canonical_remote", "control byte in URL rejected before parsing (urlsplit would strip it and change the host)")
try:
p = urllib.parse.urlsplit(url)
except ValueError as e:
# py3.12 urlsplit itself validates bracketed hosts (ipaddress) and
# raises for garbage authorities — translate, never traceback.
err("canonical_remote", f"invalid URL authority: {e}")
if not p.scheme or not p.netloc:
err("canonical_remote", f"not a URL with scheme+host: {url!r}")
if p.username or p.password or "@" in (p.netloc or ""):
err("canonical_remote", "userinfo in URL is rejected (§5.3)")
scheme = p.scheme.lower()
# T51P2R4 B1: bracketing is detected from the RAW netloc ('[' prefix),
# not from a ':' in the parsed hostname — IPvFuture literals ([v1.fe80])
# contain no colon and must not bypass the raw-authority proof.
bracketed = p.netloc.startswith("[")
if bracketed:
# Prove the RAW authority is exactly '[host]' + optional ':port'
# (case-normalized); any text after ']' is hostile/truncated input,
# rejected — never silently rewritten.
import re as _re
import ipaddress as _ip
m = _re.fullmatch(r"\[([^\]]*)\](?::([0-9]+))?", p.netloc)
if not m:
err("canonical_remote", f"malformed bracketed authority {p.netloc!r}: text after ']' is rejected (no silent truncation)")
payload = m.group(1)
# R5-B1 part 2: the bracket payload must be a REAL RFC literal —
# an IPv6 address (ipaddress parse) or an IPvFuture literal
# ("v" + HEXDIG+ + "." + unreserved / sub-delims / ":" only).
# Anything else inside brackets is rejected, closing the payload
# grammar as a class.
if _re.fullmatch(r"v[0-9A-Fa-f]+\.[A-Za-z0-9._~!$&'()*+,;=:-]*", payload):
pass # IPvFuture (case-normalized below)
elif _re.fullmatch(r"[0-9A-Fa-f:.]+", payload):
# strict IPv6 lexical form (hex/colon/dot only — ipaddress alone
# would also accept scoped zone-ids like fe80::1%eth0, which are
# not valid URI host grammar unless %25-encoded)
try:
_ip.IPv6Address(payload)
except ValueError:
err("canonical_remote",
f"bracket payload {payload!r} is not a valid IPv6 address")
else:
err("canonical_remote",
f"bracket payload {payload!r} is neither a valid IPv6 address nor an IPvFuture literal (v+HEXDIG+.+unreserved/sub-delims/colon)")
host = f"[{payload.lower()}]" # brackets preserved (IPv6 + IPvFuture)
else:
# R6-B1: the non-bracketed branch — urlsplit PARSES but does not
# VALIDATE reg-name, and netloc-nonempty is not host presence.
# Split the raw authority ourselves (host[:port]) and validate the
# raw host against real grammar: unreserved / sub-delims / complete
# %HH octets (reg-name), or IPv4 dotted-quad (reg-name's numeric
# case). Port must be all digits. No branch trusts urlsplit alone.
import re as _re
raw_host, sep, raw_port = p.netloc.rpartition(":")
if sep and _re.fullmatch(r"[0-9]+", raw_port):
pass # host:port split
elif sep:
err("canonical_remote", f"invalid port {raw_port!r} in authority {p.netloc!r} (ASCII digits only)")
else:
raw_host, raw_port = p.netloc, None
if not raw_host:
err("canonical_remote", f"empty host in authority {p.netloc!r}")
# strict reg-name / IPv4 scan: unreserved + sub-delims, with '%'
# only inside complete %HH octets (IPv4 dotted-quad is a subset of
# this charset — digits and dots — so one scan covers both).
import re as _re
i = 0
ok_host = True
while i < len(raw_host):
c = raw_host[i]
if c == "%":
if i + 2 >= len(raw_host) or not _re.fullmatch(r"[0-9A-Fa-f]{2}", raw_host[i+1:i+3]):
ok_host = False; break
i += 3
elif c in "!$&'()*+,;=-._~" or ("a" <= c <= "z") or ("A" <= c <= "Z") or ("0" <= c <= "9"):
# R7-B1: EXPLICIT ASCII only — str.isalnum() is Unicode-aware
# and admits non-ASCII letters/digits (é, full-width ). Policy
# is ASCII-only reg-name; punycode xn-- is the sanctioned
# Unicode spelling and remains legal under this charset.
i += 1
else:
ok_host = False; break
if not ok_host:
err("canonical_remote", f"host {raw_host!r} is not valid reg-name/IPv4 grammar (unreserved/sub-delims/complete %HH only)")
host = raw_host.lower()
port = p.port # None when absent; preserved whenever explicitly present (B2: incl. 0)
authority = host + (f":{port}" if port is not None else "")
path = p.path or "/"
# canonical trailing-slash + .git strip as ONE operation (F4): slash
# first, then .git, then any slash exposed by that strip.
path = path.rstrip("/")
if path.endswith(".git"):
path = path[:-4].rstrip("/")
return f"{scheme}://{authority}{path or ''}"
if "--mirror-path" in ARGS:
idx = ARGS.index("--mirror-path")
parts = ARGS[idx + 1:]
if len(parts) != 3:
err("usage", "--mirror-path takes <host> <owner> <repo>")
print(check_mirror_components(*parts))
sys.exit(0)
if "--normalize-remote" in ARGS:
idx = ARGS.index("--normalize-remote")
vals = ARGS[idx + 1:]
if len(vals) != 1:
err("usage", "--normalize-remote takes <url>")
print(normalize_remote(vals[0]))
sys.exit(0)
# ---- arg parsing ----
if not ARGS:
err("usage", "a repo.json path is required")
path = None
i = 0
while i < len(ARGS):
a = ARGS[i]
if a == "--mode":
i += 1
if i >= len(ARGS) or ARGS[i] not in ("managed", "display"):
err("usage", "--mode takes managed|display")
MODE = ARGS[i]
elif a == "--require-v2":
REQUIRE_V2 = True
elif a.startswith("--"):
err("usage", f"unknown option {a}")
else:
if path is not None:
err("usage", "multiple file arguments")
path = a
i += 1
if path is None:
err("usage", "a repo.json path is required")
# ---- load: strict UTF-8, strict RFC JSON (F3) ----
try:
with open(path, "rb") as fh:
raw_bytes = fh.read()
except OSError as e:
err("file", str(e))
try:
raw = raw_bytes.decode("utf-8")
except UnicodeDecodeError as e:
err("json", f"invalid UTF-8: {e}")
def _reject_constant(name):
raise ValueError(f"non-RFC JSON constant {name}")
try:
doc = json.loads(raw, parse_constant=_reject_constant)
except (json.JSONDecodeError, ValueError) as e:
err("json", f"malformed JSON: {e}")
if not isinstance(doc, dict):
err("json", "top level must be an object")
HOST_ROOT = os.environ.get("MOSAIC_HOST_ROOT", "")
V1_KEYS = {"integration_trunk", "release_branch"}
V2_REQUIRED = ["schema_version", "integration_trunk", "release_branch", "flow",
"canonical_remote", "canonical_clone"]
V2_OPTIONAL = {"worktree_root", "worktree_policy", "notes", "x_extensions"}
ENUM_FLOW = {"direct", "trunk-release"}
ENUM_POLICY = {"tool-managed", "orchestrator-precreated"}
def check_branch(key, value):
# Delegate the full git branch grammar to git itself (F2). B1: reject
# reflog shorthand BEFORE delegation — `git check-ref-format --branch
# '@{-n}'` expands from the CALLER repo's checkout history, making
# validation cwd-dependent; a persistent declaration must never bind
# to ambient reflog state.
if not isinstance(value, str) or not value:
err(key, "must be a non-empty string")
if "@{" in value:
err(key, f"{value!r} contains '@{{' reflog/namespace shorthand — declarations must be literal branch names (B1)")
if value.startswith("refs/heads/"): # check-ref-format --branch strips this; we do not allow it
err(key, "bare branch name expected, not a full ref")
try:
r = subprocess.run(["git", "check-ref-format", "--branch", value],
capture_output=True)
except OSError as e:
err(key, f"cannot invoke git check-ref-format: {e}")
if r.returncode != 0:
err(key, f"{value!r} is not a valid git branch name (git check-ref-format, §5.2)")
def check_host_path(key, value):
# §1.2a: host:/-anchored; canonical segment normalization; empty/./.. rejected
# BEFORE resolution; tilde rejected outright.
if not isinstance(value, str) or not value:
err(key, "must be a non-empty string")
if "~" in value:
err(key, "tilde-anchored path rejected (§1.2a: ~ binds to caller HOME)")
if not value.startswith("host:/"):
err(key, "must be host:/-anchored (§1.2a)")
rest = value[len("host:/"):]
if rest == "":
err(key, "no segments after host:/")
segments = rest.split("/")
for seg in segments:
if seg == "":
err(key, f"empty segment in {value!r} (canonical normalization, §1.2a)")
if seg in (".", ".."):
err(key, f"dot segment {seg!r} rejected before resolution (§1.2a)")
return segments
# ---- version ----
sv = doc.get("schema_version")
if "schema_version" in doc:
if not isinstance(sv, int) or isinstance(sv, bool):
err("schema_version", "must be an integer")
if sv not in (1, 2):
err("schema_version", f"unknown schema_version {sv} — treated as ABSENT per keep-list K3; update tooling")
version = sv
else:
version = 1
warn("schema_version absent → v1 compatibility mode (two keys only)")
if REQUIRE_V2 and version != 2:
err("schema_version", "CI authoring rule: new or edited declarations must declare schema_version 2")
# ---- v1 ----
if version == 1:
for k in V1_KEYS:
check_branch(k, doc.get(k))
extra = set(doc) - V1_KEYS
if extra:
err("x_extensions", f"unknown top-level keys in v1: {sorted(extra)}")
print("OK (v1)")
sys.exit(0)
# ---- v2 required ----
for k in V2_REQUIRED:
if k not in doc:
err(k, "required for v2 (§1.2)")
check_branch("integration_trunk", doc["integration_trunk"])
check_branch("release_branch", doc["release_branch"])
# type-check BEFORE membership (F3: list-typed enums must not traceback)
if not isinstance(doc["flow"], str) or doc["flow"] not in ENUM_FLOW:
err("flow", f"must be one of {sorted(ENUM_FLOW)} (§1.2, required — no defaulting, R7)")
unknown = set(doc) - set(V2_REQUIRED) - V2_OPTIONAL
if unknown:
err("x_extensions", f"unknown top-level keys {sorted(unknown)} — place extensions inside x_extensions")
if "worktree_policy" in doc and (not isinstance(doc["worktree_policy"], str)
or doc["worktree_policy"] not in ENUM_POLICY):
err("worktree_policy", f"must be one of {sorted(ENUM_POLICY)}")
if "notes" in doc and not isinstance(doc["notes"], str):
err("notes", "must be a string")
if "x_extensions" in doc and not isinstance(doc["x_extensions"], dict):
err("x_extensions", "must be an object")
for strkey in ("canonical_remote", "canonical_clone", "worktree_root"):
if strkey in doc and not isinstance(doc[strkey], str):
err(strkey, "must be a string")
# ---- remote (§5.3) ----
if not isinstance(doc["canonical_remote"], str):
err("canonical_remote", "must be a string")
else:
normalize_remote(doc["canonical_remote"])
# ---- paths (§1.2a) ----
canonical_segments = check_host_path("canonical_clone", doc["canonical_clone"])
wt_segments = None
if "worktree_root" in doc:
wt_segments = check_host_path("worktree_root", doc["worktree_root"])
# ---- cross-field (§5.2) ----
trunk, rel, flow = doc["integration_trunk"], doc["release_branch"], doc["flow"]
if flow == "direct" and trunk != rel:
err("flow", "direct requires integration_trunk == release_branch (§5.2)")
if flow == "trunk-release" and trunk == rel:
err("flow", "trunk-release requires integration_trunk != release_branch (§5.2)")
# ---- root gate (§1.2a fail-closed; T51P2R1 F1) ----
# Every v2 declaration consumes a path (canonical_clone is required), so
# managed mode cannot proceed without a provable host anchor. Display mode
# warns and omits root-dependent resolution only.
if not HOST_ROOT:
if MODE == "managed":
err("MOSAIC_HOST_ROOT",
"unset or empty — managed validation of a v2 declaration consumes paths "
"(canonical_clone required); fail closed (§1.2a, DR3 X2b)")
else:
warn("host root unset; root-dependent resolution omitted (display mode, §1.2a)")
if doc.get("worktree_policy") == "tool-managed":
if "worktree_root" not in doc:
err("worktree_policy", "tool-managed requires worktree_root (containment provable, §4.5)")
if HOST_ROOT:
root_real = os.path.realpath(HOST_ROOT)
# B3 fix: containment is tested on the LEXICAL normalized path, not
# on realpath of the joined result — a child symlink under the host
# root can no longer fake outside-ness. host:/ segments are always
# lexically under the root, so tool-managed fails universally until
# the anchor scheme grows a real outside-root form (J3 charter).
resolved = os.path.normpath(os.path.join(root_real, *wt_segments))
if resolved == root_real or resolved.startswith(root_real + os.sep):
err("worktree_policy",
f"tool-managed worktree_root resolves inside MOSAIC_HOST_ROOT (§4.5: outside-root requirement)")
# no-root case: managed mode already failed at the gate above; display warned
print("OK")
try:
main()
except SystemExit:
raise
except Exception as e: # F3: no traceback may ever escape the contract
err("internal", f"input rejected (unexpected condition: {type(e).__name__})")
PYEOF
@@ -64,13 +64,31 @@ if [ -n "$SOCKET_NAME" ]; then
tmux_cmd+=(-L "$SOCKET_NAME")
fi
# tmux accepts `=session` for some commands, but pane-level commands such as
# capture-pane require a pane-qualified target. Keep exact-session addressing
# convenient while avoiding accidental prefix matches.
# Normalise the target to an EXACT session plus a window part, because tmux
# resolves the two halves with different and individually dangerous defaults:
#
# * An unpinned name is a PREFIX match. With a session `foobar` alive and
# no session `foo`, `-t foo` resolves to `foobar` at rc=0, so a message is
# delivered, verified and reported OK against the wrong agent's pane.
# * A bare `=name` is not enough on its own: capture-pane REJECTS it
# ("can't find pane") while list-panes silently PREFIX-MATCHES it, so the
# validation below would pass on a session the capture cannot read.
# * A trailing `:` follows the session's ACTIVE window. Pinning `:0.0`
# instead addresses window 0 unconditionally, and since the paste, the
# Enter and the verifying capture all use EFFECTIVE_TARGET, a multi-window
# agent gets typed into window 0 and confirmed by reading window 0 --
# a false "delivered" rather than a loud failure.
#
# Explicit tmux ids (%pane, @window, $session) are passed through untouched;
# prefixing `=` to them would break addressing that is already unambiguous.
EFFECTIVE_TARGET=$TARGET
if [[ "$TARGET" == =* && "$TARGET" != *:* ]]; then
EFFECTIVE_TARGET="${TARGET}:0.0"
fi
case "$TARGET" in
=*|%*|@*|\$*) ;;
*) EFFECTIVE_TARGET="=$TARGET" ;;
esac
case "$EFFECTIVE_TARGET" in
=*) [[ "$EFFECTIVE_TARGET" == *:* ]] || EFFECTIVE_TARGET="${EFFECTIVE_TARGET}:" ;;
esac
# Target must resolve to a live pane.
if ! "${tmux_cmd[@]}" list-panes -t "$EFFECTIVE_TARGET" >/dev/null 2>&1; then
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Target normalisation: send-message.sh must address an EXACT session and the
# session's ACTIVE window. Both halves have caused silent wrong-pane delivery:
# * an unpinned name prefix-matches, so a message for an absent session is
# delivered to a different agent and reported OK;
# * a `:0.0` pin addresses window 0 regardless of where the agent is, and
# because the paste, the Enter and the verifying capture share one target,
# the wrong window is also the window that confirms the send.
# Both rows below FAIL against the pre-fix script, which is the point of them.
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
SEND_MESSAGE="$SCRIPT_DIR/send-message.sh"
SOCKET="mosaic-test-target-$RANDOM-$$"
TMPDIR=$(mktemp -d)
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TMPDIR"' EXIT
fail() { echo "FAIL: $*" >&2; exit 1; }
command -v tmux >/dev/null 2>&1 || fail "tmux is required"
tmux_() { tmux -L "$SOCKET" "$@"; }
newsess() { tmux_ new-session -d -s "$1" -c "$TMPDIR" 'PS1=" " exec bash --noprofile --norc -i'; }
hits() { tmux_ capture-pane -p -t "$1" 2>/dev/null | grep -cF "$2" || true; }
# ── 1. an absent session must not prefix-match a live one ───────────────────
newsess sibling-long
nonce="absent-target-$RANDOM"
rc=0; "$SEND_MESSAGE" -L "$SOCKET" -t sibling -m "$nonce" >/dev/null 2>&1 || rc=$?
[ "$rc" -ne 0 ] || fail "send to absent session 'sibling' returned rc=0 (prefix-matched)"
[ "$(hits sibling-long "$nonce")" -eq 0 ] || fail "message for absent 'sibling' was delivered to 'sibling-long'"
# positive control: the detector above can see a real delivery
nonce_ok="control-$RANDOM"
"$SEND_MESSAGE" -L "$SOCKET" -t sibling-long -m "$nonce_ok" >/dev/null 2>&1 \
|| fail "send to a live session failed"
[ "$(hits sibling-long "$nonce_ok")" -gt 0 ] || fail "control: live delivery not observed — detector is blind"
# ── 2. delivery follows the ACTIVE window, not window 0 ─────────────────────
# A single-window fixture cannot tell `=s:` from `=s:0.0`; the active window
# must be non-zero or this test proves nothing.
newsess multi
tmux_ new-window -t multi -c "$TMPDIR" 'PS1=" " exec bash --noprofile --norc -i'
tmux_ select-window -t multi:1
active=$(tmux_ display-message -p -t multi '#{window_index}')
[ "$active" = "1" ] || fail "fixture setup: expected active window 1, got $active"
for target in multi "=multi"; do
nonce="active-win-$RANDOM"
"$SEND_MESSAGE" -L "$SOCKET" -t "$target" -m "$nonce" >/dev/null 2>&1 \
|| fail "send to '$target' failed"
[ "$(hits multi:1 "$nonce")" -gt 0 ] || fail "'$target' did not deliver to the active window"
[ "$(hits multi:0 "$nonce")" -eq 0 ] || fail "'$target' delivered to window 0 instead of the active window"
done
# ── 3. an explicit window part is preserved ─────────────────────────────────
nonce="explicit-win-$RANDOM"
"$SEND_MESSAGE" -L "$SOCKET" -t multi:0 -m "$nonce" >/dev/null 2>&1 || fail "send to 'multi:0' failed"
[ "$(hits multi:0 "$nonce")" -gt 0 ] || fail "explicit 'multi:0' did not deliver to window 0"
# ── 4. a unique prefix of a live session is still refused ───────────────────
nonce="prefix-$RANDOM"
rc=0; "$SEND_MESSAGE" -L "$SOCKET" -t mult -m "$nonce" >/dev/null 2>&1 || rc=$?
[ "$rc" -ne 0 ] || fail "send to prefix 'mult' returned rc=0"
[ "$(hits multi:1 "$nonce")" -eq 0 ] || fail "prefix 'mult' was delivered to 'multi'"
echo "PASS: send-message.sh target normalisation"
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh"
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
-1
View File
@@ -63,7 +63,6 @@ export const STAGES = [
'bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh',
'bash packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh',
'bash packages/mosaic/framework/tools/git/test-issue-view-comments.sh',
'bash packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh',
'bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh',
'bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh',
],