Compare commits

..
Author SHA1 Message Date
fred 62321700a3 fix(git): review follow-ups for #1357 (S1, S2, indent)
ci/woodpecker/pr/ci Pipeline was successful
S1: get_gitea_login_for_repo_override() now distinguishes "tea is not
installed" from "no tea login named X exists", mirroring the host path.
The old message diagnosed a cause that was never checked and pointed at
seat-logins.sh, which cannot run without tea. Branch 6 in
test-gitea-login-resolution.sh pins it (tea removed from PATH); reverting
the fix fails that branch.

S2: issue-list/pr-list/pr-view override-path error now points at the
lines above for the cause instead of suggesting a default tea login.

verify-release.mjs: indent of the #1356 test line fixed (cosmetic).
2026-08-21 18:12:12 -05:00
fred 341be60723 fix(git-tools): issue-view shows comment bodies and names the real tea failure (#1357)
Four defects in issue-view.sh, each pinned by the new hermetic suite
test-issue-view-comments.sh (mock tea + curl, sandboxed repo):

F1  tea exits 1 in any repo with extensions.worktreeconfig=true. The wrapper
    now names that as a git-config condition and falls back to the API.
F2  The API fallback dumped raw issue JSON, which carries only a comment
    COUNT. It now fetches /comments and renders issue + comment bodies.
F3  The tea path never passed --comments, so comment bodies were never shown
    non-interactively. It now does.
F4  Every tea failure printed the REVOKED OR STALE TOKEN note. The wrapper now
    relays tea's own error line and only hints at credentials when tea did.

The suite joins ci.yml and the verify-release canonical list (mirror test).

Closes #1357
2026-08-21 18:06:06 -05:00
18 changed files with 31 additions and 1764 deletions
+1 -8
View File
@@ -1,11 +1,4 @@
{
"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": "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."
"release_branch": "main"
}
-34
View File
@@ -113,40 +113,6 @@ 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. 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`.
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`.
### Code Review (Codex)
@@ -219,23 +219,6 @@ 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,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] [--no-ci-expected]
# Usage: ci-queue-wait.sh [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
set -euo pipefail
@@ -14,11 +14,10 @@ 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] [--no-ci-expected]
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
Options:
-B, --branch BRANCH Branch head to inspect (default: current branch)
@@ -28,7 +27,6 @@ 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:
@@ -177,50 +175,6 @@ 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"
@@ -228,24 +182,6 @@ 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"
@@ -370,41 +306,6 @@ 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)
@@ -435,10 +336,6 @@ while [[ $# -gt 0 ]]; do
REQUIRE_STATUS=1
shift
;;
--no-ci-expected)
NO_CI_EXPECTED=1
shift
;;
-h|--help)
usage
exit 0
@@ -468,10 +365,6 @@ 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"
@@ -591,48 +484,6 @@ 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,41 +19,6 @@ 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) || {
@@ -73,28 +38,14 @@ gitea_pr_create_api() {
echo "Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup." >&2
fi
# 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'
payload=$(TITLE="$TITLE" BODY="$BODY" HEAD_BRANCH="$HEAD_BRANCH" BASE_BRANCH="$BASE_BRANCH" python3 - <<'PY'
import json
import os
payload = {
"title": os.environ["TITLE"],
"head": os.environ["HEAD_BRANCH"],
"base": os.environ["API_BASE"],
"base": os.environ["BASE_BRANCH"] or "main",
}
body = os.environ.get("BODY", "")
if body:
@@ -121,7 +72,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: the forge repository's default branch)
-B, --base BRANCH Base branch to merge into (default: main/master)
-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] [--no-ci-expected] [--co-author-trailers --escalate-to PRINCIPAL]
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
set -euo pipefail
@@ -16,7 +16,6 @@ DRY_RUN=false
EXPECT_HEAD=""
CO_AUTHOR_TRAILERS=false
ESCALATE_TO=""
NO_CI_EXPECTED=false
usage() {
cat <<EOF
@@ -30,7 +29,6 @@ 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
@@ -72,10 +70,6 @@ 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
@@ -160,18 +154,13 @@ if [[ "$DRY_RUN" != true ]]; then
if [[ -z "$BASE_REPO" ]]; then
BASE_REPO="$(get_repo_owner)/$(get_repo_name)"
fi
guard_args=(
--purpose merge
-B "$HEAD_BRANCH"
-R "$BASE_REPO"
--sha "$HEAD_SHA"
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}"
"$SCRIPT_DIR/ci-queue-wait.sh" \
--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)
@@ -1,323 +0,0 @@
#!/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,15 +100,6 @@ 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,18 +67,7 @@ cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG"
# 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
printf '%s\n' '{"number":703}'
SH
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
@@ -1,178 +0,0 @@
#!/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"
@@ -1,84 +0,0 @@
#!/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,10 +29,6 @@ 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 ---
@@ -1,289 +0,0 @@
#!/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"
@@ -1,386 +0,0 @@
#!/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
@@ -32,9 +32,7 @@
# 0 delivered (submitted) or queued (agent busy; will process when free)
# 1 tmux target not found
# 2 submission NOT confirmed — either still an unsubmitted draft, or the REPL
# input box could not be located to confirm the message actually landed.
# Locating the box is runtime-specific; see locate_input_box() below, and
# add a shape there before pointing this tool at a new runtime.
# input prompt could not be located to confirm the message actually landed.
# Delivery is NEVER inferred from absence of evidence: if we cannot positively
# see the input box clear of the message (or the queued banner), we fail loud
# so the sender learns immediately instead of a silent worker->lead stall.
@@ -64,31 +62,13 @@ if [ -n "$SOCKET_NAME" ]; then
tmux_cmd+=(-L "$SOCKET_NAME")
fi
# 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.
# 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.
EFFECTIVE_TARGET=$TARGET
case "$TARGET" in
=*|%*|@*|\$*) ;;
*) EFFECTIVE_TARGET="=$TARGET" ;;
esac
case "$EFFECTIVE_TARGET" in
=*) [[ "$EFFECTIVE_TARGET" == *:* ]] || EFFECTIVE_TARGET="${EFFECTIVE_TARGET}:" ;;
esac
if [[ "$TARGET" == =* && "$TARGET" != *:* ]]; then
EFFECTIVE_TARGET="${TARGET}:0.0"
fi
# Target must resolve to a live pane.
if ! "${tmux_cmd[@]}" list-panes -t "$EFFECTIVE_TARGET" >/dev/null 2>&1; then
@@ -117,50 +97,10 @@ printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
# would otherwise accumulate forever.
sleep 0.5
# Locate the REPL input box in a captured pane. Prints the box's contents on
# stdout and returns 0 when the box was FOUND; returns 1 when it could not be
# located at all. Found-but-empty is a real, distinct answer (an empty input box
# is what a submitted message leaves behind), so the caller must branch on the
# return code, never on whether the output is empty.
#
# Two REPL shapes are recognised:
# * a prompt-glyph line — ``, a leading `>`, or `│ >`. Claude Code and most
# readline REPLs.
# * a box drawn as two horizontal `─` rules with the input between them and NO
# prompt glyph anywhere. pi renders this. Anchoring on the LAST rule pair is
# what makes it safe: agent output can contain its own rules, but nothing is
# drawn below the input box except the status line.
#
# Adding a runtime means adding its shape HERE. A shape that is missing does not
# degrade gracefully: it turns every send to that runtime into a false
# "may be UNDELIVERED", which is what #1362 measured on pi and #1257 on another
# arm of the same probe.
locate_input_box() {
local pane=$1 glyph_line rule_lines top bottom
glyph_line=$(printf '%s\n' "$pane" | grep -E '|^>|│ >' | tail -1)
if [ -n "$glyph_line" ]; then printf '%s\n' "$glyph_line"; return 0; fi
rule_lines=$(printf '%s\n' "$pane" | grep -nE '^[[:space:]]*─{4,}[[:space:]]*$' | cut -d: -f1 | tail -2)
[ -n "$rule_lines" ] || return 1
# Split the (at most two) captured line numbers with parameter expansion. Not
# `head -1`: piping into an early-exiting consumer SIGPIPEs the producer, which
# under `set -euo pipefail` aborts the caller with rc=141 and no output. The
# scripts/pipefail-early-exit.test.mjs guard reds on that shape, correctly.
# With one rule captured both halves resolve to the same value and the
# ordering test below rejects it, which is the answer we want anyway.
top=${rule_lines%%$'\n'*}
bottom=${rule_lines##*$'\n'}
[ "$top" != "$bottom" ] || return 1
[ "$bottom" -gt "$top" ] || return 1
# An empty range (adjacent rules) prints nothing and still returns 0: found,
# empty, which is the delivered shape.
printf '%s\n' "$pane" | sed -n "$((top + 1)),$((bottom - 1))p"
return 0
}
# 2) Submit, then POSITIVELY confirm submission; flush with another Enter if it is
# still a draft. Success requires positive evidence — the queued banner, OR the
# REPL input box located AND clear of our message tail. The historical bug was
# treating ABSENCE of a draft as delivery: if the input box was never located
# treating ABSENCE of a draft as delivery: if the prompt glyph was never matched
# (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered"
# and worker->lead relays stalled silently. We now default to UNCONFIRMED and only
# upgrade to delivered on positive evidence; anything we cannot confirm fails loud.
@@ -173,14 +113,15 @@ for attempt in $(seq 1 $((RETRIES + 1))); do
if grep -qF "$QUEUED_RE" <<<"$pane"; then
status="queued"; break
fi
# If we cannot see the input box, we have NO evidence of submission state —
# stay UNCONFIRMED and retry; never infer delivery.
if ! inputbox=$(locate_input_box "$pane"); then
# Locate the REPL input box (prompt glyph). If we cannot see it, we have NO
# evidence of submission state — stay UNCONFIRMED and retry; never infer delivery.
promptline=$(printf '%s' "$pane" | grep -E '|^>|│ >' | tail -1)
if [ -z "$promptline" ]; then
status="unconfirmed"; continue
fi
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
# (Submitted messages scroll up into history; a draft stays in the box.)
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$inputbox"; then
# (Submitted messages scroll up into history; a draft stays on the line.)
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$promptline"; then
status="draft"; continue
fi
# Input box located AND clear of our tail => positively submitted. This is the
@@ -194,6 +135,6 @@ case "$status" in
delivered) echo "✓ delivered to $TARGET"; exit 0 ;;
queued) echo "✓ queued to $TARGET (agent busy — will process when it returns to prompt)"; exit 0 ;;
draft) echo "✗ still an unsubmitted draft on $TARGET after $RETRIES flush attempts" >&2; exit 2 ;;
unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input box not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;;
unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input prompt not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;;
*) echo "✗ could not confirm submission on $TARGET (unexpected state '$status')" >&2; exit 2 ;;
esac
@@ -1,66 +0,0 @@
#!/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"
@@ -10,13 +10,6 @@
# "could not confirm submission").
# 3. DRAFT — a ` `-prompt pane that never submits (message stays on the
# input line) => exit 2, stderr "unsubmitted draft".
# 4. DELIVERED — a pane whose input box is two `─` rules with NO prompt glyph
# (box shape) anywhere (pi's shape) and which submits => exit 0. Pre-#1362
# the glyph probe could not see this box at all, so EVERY send
# to such a pane reported "may be UNDELIVERED" while landing.
# 5. DRAFT — the same glyphless box, holding our tail across every flush
# (box shape) Enter => exit 2, stderr "unsubmitted draft". Pre-#1362 this
# also reported unconfirmed, so the true state was invisible.
set -uo pipefail
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
@@ -76,56 +69,6 @@ else
fi
fi
# --- Fixtures 4 and 5: a pi-shaped pane. The input box is two `─` rules with the
# text between them and NO prompt glyph anywhere, so the glyph probe alone can
# never locate it and every send reports "may be UNDELIVERED" (#1362). The
# renderer below is the shape, not the runtime: MODE=clear submits (box empties),
# MODE=keep leaves the text sitting in the box.
cat > "$TMP/pibox.sh" <<'PIBOX'
#!/usr/bin/env bash
MODE=${1:-clear}
RULE=$(printf '─%.0s' $(seq 1 60))
buf=""
draw() {
printf '\033[H\033[2J'
printf 'fixture output line\n\n'
printf '%s\n' "$RULE"
printf '%s\n' "$buf"
printf '%s\n' "$RULE"
printf '~/fixture (main)\n'
printf 'tok 0 model fixture\n'
}
draw
while IFS= read -r line; do
# keep: hold the tail across every flush Enter, which is what a stuck draft does.
if [ "$MODE" = keep ]; then [ -n "$line" ] && buf=$line; else buf=""; fi
draw
done
PIBOX
chmod +x "$TMP/pibox.sh"
tmux -L "$SOCKET" new-session -d -s pibox -c "$TMP" "exec bash '$TMP/pibox.sh' clear"
sleep 0.3
out=$("$SEND" -L "$SOCKET" -t "=pibox" -m "pi fixture four delivered ok" 2>"$TMP/e4"); rc=$?
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
ok "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered"
else
no "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e4")]"
fi
tmux -L "$SOCKET" new-session -d -s piboxdraft -c "$TMP" "exec bash '$TMP/pibox.sh' keep"
sleep 0.3
if out=$("$SEND" -L "$SOCKET" -t "=piboxdraft" -r 1 -m "pi fixture five stuck in the box" 2>"$TMP/e5"); then
no "draft: glyphless box-drawn pane holding our tail must NOT report success" "expected exit 2, got 0 (out=[$out])"
else
rc=$?
if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e5"; then
ok "draft: message left in a glyphless box => exit 2 + 'unsubmitted draft'"
else
no "draft: message left in a glyphless box => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e5")]"
fi
fi
echo "---"
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]
+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-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"
"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"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",