Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c59a55f8f2 | ||
|
|
f01be762af | ||
|
|
8b97d3ee7b | ||
|
|
f9e1be5391 | ||
|
|
120af4e193 | ||
|
|
ec260e678f | ||
|
|
b590a5c3d8 | ||
|
|
540ec5b6ef | ||
|
|
563d1ac053 | ||
|
|
722163671f | ||
|
|
f158be8003 | ||
|
|
2fa6bcd576 | ||
|
|
1afe2b36dc |
@@ -1,5 +1,14 @@
|
||||
# PRD: Mosaic Stack v0.1.0
|
||||
|
||||
## Current addendum: #1194 — Installed framework-tool drift detection
|
||||
|
||||
- Compare the framework tools shipped with the executing Mosaic package against the deployed `$MOSAIC_HOME/tools` tree by content hash.
|
||||
- Treat every shipped `tools/**` file as framework-owned/required according to `framework-manifest.txt`, while excluding the explicit operator-owned credential carve-out and preserving installed-only operator/unknown files.
|
||||
- Distinguish and count `IN_SYNC`, `STALE`, `NOT_INSTALLED`, and installed-only classifications; fail non-zero when shipped tools are stale or absent and refuse self-comparison that would make drift unobservable.
|
||||
- Surface the observational check through `mosaic doctor`; do not refresh files, restart seats, or mutate live tooling.
|
||||
- Document identity/messaging/gate behavior changes in the current stale set, the reviewed quiet-window keep-mode refresh command, and post-refresh probes against the installed path.
|
||||
- Prove by construction that a stale and missing deployed tool are detected; that regression must fail before this checker exists.
|
||||
|
||||
## Metadata
|
||||
|
||||
- **Owner:** Jason Woltje
|
||||
|
||||
@@ -69,4 +69,15 @@ All 22 baseline sites below are `FIXED`; the checked-in tranche fixture is passe
|
||||
| `tmux/test-send-message-socket.sh:37,38,44-46,68,72` | FIXED | capture commands complete before redirected grep assertions |
|
||||
| `tmux/test-send-message-verdict.sh:34` | FIXED | grep reads from a here-string |
|
||||
|
||||
Remaining wake-validation sites are intentionally deferred to the final review-sized tranche and are not yet assigned a safety verdict here.
|
||||
## Tranche 3 — wake validation harnesses
|
||||
|
||||
All 26 baseline occurrences (25 normalized identities; one preimage selector occurs twice) are `FIXED` and mechanically bound through the wake fixture and shared scanner.
|
||||
|
||||
| Baseline site(s) | Verdict | Construction |
|
||||
| --- | --- | --- |
|
||||
| `wake/test-wake-digest-quarantine.sh:567` | FIXED | complete match populations are captured, then first line selected by parameter expansion |
|
||||
| `wake/test-wake-preimage.sh:182-183,346-347` | FIXED | jq `first(...)` reads each JSONL file directly |
|
||||
| `wake/validate-973/microtest-wake-assert.sh:153,170-171,176,204-209,233-234,251-252,286-287` | FIXED | scalar assertions use here-strings; diagnostics use non-early sed ranges; source line captured before matching |
|
||||
| `wake/validate-973/validate-973.sh:110,119,180,182,187` | FIXED | scalar assertions use here-strings; diagnostic truncation uses consuming sed ranges |
|
||||
|
||||
The scoped inventory is complete: 26 runtime/general + 22 non-wake tests + 26 wake tests fixed; 11 explicitly withdrawn or non-load-bearing sites retain their documented verdicts.
|
||||
|
||||
@@ -25,6 +25,10 @@ Site-by-site verdicts: `docs/reports/quality/1099-pipefail-sweep.md`.
|
||||
|
||||
Expanded the unconditional scanner over 11 non-wake test harnesses. RED named exactly 22 source lines; a second immutable-baseline fixture now asserts those 22 entries through the same scanner. Rewrites preserve command status by capturing producers before redirected assertions, use parameter expansion for line selection, and use complete `mapfile` populations where ordering matters. Current-tree finding count is zero for tranches 1 and 2.
|
||||
|
||||
## Tranche 3 TDD
|
||||
|
||||
Expanded the shared scanner over four wake validation harnesses. RED named 26 occurrences. The wake fixture asserts 26 occurrences / 25 normalized identities through the same scanner; all scalar assertions now use redirection, direct jq selection, complete capture, or consuming diagnostic ranges. Current-tree finding count is zero across the full scoped population.
|
||||
|
||||
## Verification so far
|
||||
|
||||
- `bash -n` on every changed shell script: pass.
|
||||
@@ -33,5 +37,5 @@ Expanded the unconditional scanner over 11 non-wake test harnesses. RED named ex
|
||||
- `test-send-message-verdict.sh`: 3/3 pass.
|
||||
- `test-send-message-socket.sh`: pass.
|
||||
- Independent review 143 found two semantic regressions: a help-probe `|| true` changed the failure truth table, and an unguarded Git capture changed non-Git data-dir behavior from rc 0 + JSON to silent rc 128. Both received RED-first regressions before correction; help status is now separate and required, and Git status remains condition-guarded.
|
||||
- Wake detector/reconcile/digest/preimage suites terminate at their existing fail-closed #973 `BASH_LINENO` environment probe (exit 97, observed `[3 5]`, expected `[3 4]`) before subject tests. No bypass or skip was used; canonical CI remains required.
|
||||
- Wake static inventory remains aligned at 261/261 after line-neutral rewrites; no static-set mismatch. Wake detector/reconcile/digest/preimage suites terminate at their existing fail-closed #973 `BASH_LINENO` environment probe (exit 97, observed `[3 5]`, expected `[3 4]`) before subject tests. No bypass or skip was used; canonical CI remains required.
|
||||
- ShellCheck reports only pre-existing source-following, unused-variable, and untouched `ls | head` findings; no new diagnostic was introduced.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# #1194 — Installed framework-tool drift detection and refresh analysis
|
||||
|
||||
## Decision
|
||||
|
||||
The reported queue-guard source defect was already fixed on `main` by `58b971ab`; the live failure came from a stale `~/.config/mosaic/tools/git/ci-queue-wait.sh`. The durable fix is therefore a detector, not a duplicate queue-guard patch.
|
||||
|
||||
`mosaic doctor` now compares the framework tools bundled with the executing Mosaic package against the deployed tools tree. Doctor is the selected visibility boundary because it is observational and operator-invoked: unlike session start, it does not add a repository/network scan to every seat launch, and it cannot silently replace identity or messaging tools while seats are active. It reports drift without changing files. `--fail-on-warn` converts detected drift into a non-zero doctor result.
|
||||
|
||||
## Classification
|
||||
|
||||
The existing `framework-manifest.txt` is authoritative. The detector invokes the canonical shared `tools/_lib/manifest.sh classify` implementation over the complete source census and refuses missing, unreadable, malformed, incomplete, or zero-framework ownership output. Policy is therefore read rather than duplicated:
|
||||
|
||||
- Current policy classifies source files under `tools/**` as framework-owned and required in the deployed tools tree.
|
||||
- Current policy explicitly classifies `tools/_lib/credentials.json` operator-owned and excludes it from byte comparison; future policy changes take effect without a detector edit.
|
||||
- A file present only in the deployed tools tree is operator-owned/unknown by the manifest's fail-safe default. The detector reports it as `INSTALLED_ONLY operator-or-unknown` under `--verbose` but does not fail or delete it.
|
||||
- Empty/partial source traversal, unreadable directories/files, symlinked census entries, root aliases, and descendant source aliases all return `CANNOT_ASSERT` rather than manufacturing agreement.
|
||||
|
||||
This means `NOT_INSTALLED` is not suppressed by filename guesses such as “test” or “README”: if it ships below source `tools/**`, the installer contract says it should be installed. Source-only implementation files outside `tools/**` are outside this detector population by construction.
|
||||
|
||||
## Current host analysis (observation only; no refresh performed)
|
||||
|
||||
A direct source-vs-installed census showed broad drift, including identity and messaging behavior:
|
||||
|
||||
- Identity/provider operations: stale `git/detect-platform.sh`, `issue-comment.sh`, `issue-create.sh`, `issue-close.sh`, `issue-view.sh`, `pr-create.sh`, `pr-merge.sh`, `pr-review.sh`, `pr-metadata.sh`; missing `pr-edit.sh` and several identity/read-back regression tools.
|
||||
- Messaging/session: stale `tmux/agent-send.sh`, `tmux/send-message.sh`, their regressions, and `fleet/start-agent-session.sh`.
|
||||
- Gate enforcement: stale `git/ci-queue-wait.sh`; missing the queue tri-state/process-level suites and terminal-green verifier.
|
||||
- Lease/QA behavior: stale lease-broker launch/mutation/receipt tools and QA hooks.
|
||||
|
||||
Counts vary with source head and installed local/operator files; the detector prints measured counts every run rather than baking this snapshot into policy.
|
||||
|
||||
## Reviewed refresh command — analyse only, do not run during active seats
|
||||
|
||||
Use the package/release updater's manifest-driven keep-mode sync during a quiet maintenance window:
|
||||
|
||||
```bash
|
||||
MOSAIC_SYNC_ONLY=1 \
|
||||
MOSAIC_INSTALL_MODE=keep \
|
||||
MOSAIC_HOME="$HOME/.config/mosaic" \
|
||||
bash /path/to/reviewed/@mosaicstack/mosaic/framework/install.sh
|
||||
```
|
||||
|
||||
For the globally installed package, resolve the reviewed installer rather than guessing its path:
|
||||
|
||||
```bash
|
||||
PACKAGE_ROOT="$(dirname "$(node -p "require.resolve('@mosaicstack/mosaic/package.json')")")"
|
||||
MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep MOSAIC_HOME="$HOME/.config/mosaic" \
|
||||
bash "$PACKAGE_ROOT/framework/install.sh"
|
||||
```
|
||||
|
||||
Do not run this while agent seats are active: the stale set includes identity selection, provider mutation, messaging, queue/merge guards, lease enforcement, and session launch. Syncing those files in place can change behavior between a seat's preflight and mutation.
|
||||
|
||||
## Post-refresh verification
|
||||
|
||||
1. Run `mosaic doctor --fail-on-warn`; require `stale=0 not-installed=0` from the framework drift summary (other unrelated doctor warnings must also be adjudicated).
|
||||
2. Re-run the constructed process-level queue probes against the **installed path**, not the source checkout. Use the source suite while overriding its subject path in a reviewed scratch copy, or reproduce these exact observations:
|
||||
- pending provider payload: guard must print `state=pending`, print the pending context, wait, and exit non-zero/timeout — never return immediately with rc 0;
|
||||
- malformed payload: guard must print `state=malformed` and exit non-zero;
|
||||
- unsupported but valid status vocabulary: guard must print `state=unknown` and exit non-zero.
|
||||
3. Run provider author read-back for one deliberately low-risk wrapper operation before resuming fleet mutation work; wrapper self-report is not identity evidence.
|
||||
4. Relaunch seats only after the quiet-window verification, because existing processes retain loaded environment/context.
|
||||
|
||||
## Probe evidence
|
||||
|
||||
The detector regression constructs a stale installed tool plus a missing shipped tool and observes rc 1 with distinct `STALE` and `NOT_INSTALLED` lines. That case would pass or be invisible before this change because no installed-vs-shipped comparison existed. Additional review-red controls prove:
|
||||
|
||||
- empty and unreadable source censuses return `CANNOT_ASSERT` (they returned clean rc 0 at the first PR head);
|
||||
- deleting the manifest returns `CANNOT_ASSERT`, while changing manifest ownership changes the verdict through the canonical resolver (the first head never opened the manifest);
|
||||
- root and descendant symlink/source aliases cannot return clean (the first head returned clean for a source-backed installed subtree);
|
||||
- a checker hung during doctor is terminated by a bounded watchdog, emits `CANNOT_ASSERT`, and doctor reaches its final warnings line (the first head hung and suppressed the remaining audit).
|
||||
|
||||
Controls retain byte-identical success, exact credential carve-out behavior, and installed-only preservation.
|
||||
@@ -153,6 +153,38 @@ warn_if_symlink_tree_present() {
|
||||
|
||||
echo "[mosaic-doctor] Mosaic home: $MOSAIC_HOME"
|
||||
|
||||
# Compare the framework tools that this CLI/package ships with the deployed
|
||||
# ~/.config copy that direct wrappers and systemd units actually execute. Doctor
|
||||
# is the right boundary: observational, operator-invoked, and already designed
|
||||
# to report drift without mutating live tooling or restarting active seats.
|
||||
framework_drift_checker="$(cd -- "$(dirname -- "$0")/../quality/scripts" && pwd)/framework-drift-check.py"
|
||||
if [[ -f "$framework_drift_checker" ]]; then
|
||||
echo "[mosaic-doctor] Checking installed framework-tool drift..."
|
||||
drift_timeout="${MOSAIC_DOCTOR_DRIFT_TIMEOUT_SEC:-15}"
|
||||
if ! [[ "$drift_timeout" =~ ^[1-9][0-9]*$ ]]; then
|
||||
warn "Invalid MOSAIC_DOCTOR_DRIFT_TIMEOUT_SEC='$drift_timeout' (expected positive integer); using 15s"
|
||||
drift_timeout=15
|
||||
fi
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
set +e
|
||||
timeout -s TERM -k 2 "${drift_timeout}s" \
|
||||
python3 "$framework_drift_checker" --installed-root "$MOSAIC_HOME/tools"
|
||||
drift_rc=$?
|
||||
set -e
|
||||
if [[ "$drift_rc" -eq 0 ]]; then
|
||||
pass "Installed framework tools match shipped source"
|
||||
elif [[ "$drift_rc" -eq 124 || "$drift_rc" -eq 137 || "$drift_rc" -eq 143 ]]; then
|
||||
warn "CANNOT_ASSERT framework drift checker timed out after ${drift_timeout}s; continuing remaining doctor checks"
|
||||
else
|
||||
warn "Installed framework-tool drift detected (checker exit $drift_rc; no files changed)"
|
||||
fi
|
||||
else
|
||||
warn "CANNOT_ASSERT timeout utility unavailable; refusing unbounded framework drift check and continuing remaining doctor checks"
|
||||
fi
|
||||
else
|
||||
warn "Framework drift checker is absent from the shipped tools tree"
|
||||
fi
|
||||
|
||||
# Canonical Mosaic checks
|
||||
expect_file "$MOSAIC_HOME/STANDARDS.md"
|
||||
expect_file "$MOSAIC_HOME/USER.md"
|
||||
|
||||
@@ -254,15 +254,32 @@ from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _origin_and_path(url):
|
||||
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
||||
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
||||
# port and its explicit default form compare equal.
|
||||
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
|
||||
#
|
||||
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
|
||||
# ROOT_URL is configured http:// returns http:// object URLs even when every
|
||||
# client reaches it over https://, so a scheme-strict comparison rejects the
|
||||
# provider's own correct answer about a write that landed — a deterministic
|
||||
# false negative on every comment posted against such a deployment. The
|
||||
# scheme is also not what this check defends: the forgeries it exists to
|
||||
# catch (look-alike host, decoy path prefix, wrong owner/repo/number) all
|
||||
# vary the HOST or the PATH, both of which stay strict below. Any OTHER
|
||||
# scheme (file:, ftp:, javascript:) remains distinguishing and is rejected.
|
||||
#
|
||||
# Port: an implicit port and its own scheme's default compare equal, so
|
||||
# http://h == https://h. An EXPLICIT non-default port still distinguishes,
|
||||
# because a different port is a different service on the same host.
|
||||
parsed = urlparse(url or "")
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
host = (parsed.hostname or "").lower()
|
||||
default_port = 80 if scheme == "http" else 443
|
||||
port = parsed.port if parsed.port is not None else default_port
|
||||
return (scheme, host, port), parsed.path.rstrip("/")
|
||||
if scheme in ("http", "https"):
|
||||
scheme_class = "web"
|
||||
default_port = 80 if scheme == "http" else 443
|
||||
port = None if parsed.port in (None, default_port) else parsed.port
|
||||
else:
|
||||
scheme_class = scheme
|
||||
port = parsed.port
|
||||
return (scheme_class, host, port), parsed.path.rstrip("/")
|
||||
|
||||
|
||||
try:
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env bash
|
||||
# pr-edit.sh - Edit a pull request on GitHub or Gitea
|
||||
# Usage: pr-edit.sh -n <pr_number> [-t <title>] [-b <body>] [-B <base>] [--draft|--ready] [--login <name>] [-r owner/repo] [-H host]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=detect-platform.sh
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
PR_NUMBER=""
|
||||
TITLE=""
|
||||
BODY=""
|
||||
BASE_BRANCH=""
|
||||
DRAFT_MODE=""
|
||||
LOGIN_OVERRIDE=""
|
||||
REPO_OVERRIDE=""
|
||||
HOST_OVERRIDE=""
|
||||
AUTH_CONFIG=""
|
||||
|
||||
cleanup() {
|
||||
[[ -z "$AUTH_CONFIG" ]] || rm -f -- "$AUTH_CONFIG"
|
||||
}
|
||||
terminate() {
|
||||
local signal="$1"
|
||||
trap - "$signal"
|
||||
cleanup
|
||||
kill -s "$signal" "$$"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'terminate HUP' HUP
|
||||
trap 'terminate INT' INT
|
||||
trap 'terminate TERM' TERM
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [OPTIONS]
|
||||
|
||||
Edit a pull request on the current repository (Gitea or GitHub).
|
||||
|
||||
Options:
|
||||
-n, --number NUMBER Pull request number (required)
|
||||
-t, --title TITLE New title
|
||||
-b, --body BODY New body/description
|
||||
-B, --base BRANCH New base branch
|
||||
--draft Mark the pull request as draft
|
||||
--ready Mark the pull request ready for review
|
||||
-l, --login NAME Gitea login (must authenticate as MOSAIC_GIT_IDENTITY)
|
||||
-r, --repo OWNER/REPO Explicit target repository
|
||||
-H, --host HOST Explicit Gitea host (required with --repo off-host)
|
||||
-h, --help Show this help message
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-n|--number) PR_NUMBER="${2:-}"; shift 2 ;;
|
||||
-t|--title) TITLE="${2:-}"; shift 2 ;;
|
||||
-b|--body) BODY="${2:-}"; shift 2 ;;
|
||||
-B|--base) BASE_BRANCH="${2:-}"; shift 2 ;;
|
||||
--draft)
|
||||
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
|
||||
DRAFT_MODE="draft"; shift ;;
|
||||
--ready)
|
||||
[[ "$DRAFT_MODE" != "draft" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
|
||||
DRAFT_MODE="ready"; shift ;;
|
||||
-l|--login) LOGIN_OVERRIDE="${2:-}"; shift 2 ;;
|
||||
-r|--repo) REPO_OVERRIDE="${2:-}"; shift 2 ;;
|
||||
-H|--host) HOST_OVERRIDE="${2:-}"; shift 2 ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$PR_NUMBER" ]] || { echo "Error: Pull request number is required (-n)" >&2; exit 1; }
|
||||
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 1; }
|
||||
if [[ -z "$TITLE" && -z "$BODY" && -z "$BASE_BRANCH" && -z "$DRAFT_MODE" ]]; then
|
||||
echo "Error: At least one edit option is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -z "$REPO_OVERRIDE" || "$REPO_OVERRIDE" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || {
|
||||
echo "Error: --repo must be OWNER/REPO" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ -n "$HOST_OVERRIDE" || -n "$REPO_OVERRIDE" ]]; then
|
||||
PLATFORM="gitea"
|
||||
else
|
||||
PLATFORM=$(detect_platform)
|
||||
fi
|
||||
|
||||
case "$PLATFORM" in
|
||||
github)
|
||||
[[ -z "$LOGIN_OVERRIDE" ]] || { echo "Error: --login is only valid for Gitea" >&2; exit 1; }
|
||||
if [[ -n "$TITLE" || -n "$BODY" || -n "$BASE_BRANCH" ]]; then
|
||||
CMD=(gh pr edit "$PR_NUMBER")
|
||||
[[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
||||
"${CMD[@]}"
|
||||
fi
|
||||
if [[ "$DRAFT_MODE" == "draft" ]]; then
|
||||
gh pr ready "$PR_NUMBER" --undo
|
||||
elif [[ "$DRAFT_MODE" == "ready" ]]; then
|
||||
gh pr ready "$PR_NUMBER"
|
||||
fi
|
||||
;;
|
||||
gitea)
|
||||
IDENTITY="${MOSAIC_GIT_IDENTITY:-}"
|
||||
[[ -n "$IDENTITY" ]] || {
|
||||
echo "Error: MOSAIC_GIT_IDENTITY is required for a mutating Gitea operation" >&2
|
||||
exit 1
|
||||
}
|
||||
HOST="${HOST_OVERRIDE:-}"
|
||||
if [[ -z "$HOST" ]]; then
|
||||
HOST=$(get_remote_host) || {
|
||||
echo "Error: Could not resolve Gitea host; pass --host with --repo" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
HOST="${HOST#http://}"; HOST="${HOST#https://}"; HOST="${HOST%%/*}"
|
||||
REPO_SLUG="${REPO_OVERRIDE:-}"
|
||||
if [[ -z "$REPO_SLUG" ]]; then
|
||||
REPO_SLUG=$(get_repo_slug) || { echo "Error: Could not resolve Gitea repo slug from remote" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
if [[ -n "$LOGIN_OVERRIDE" ]]; then
|
||||
GITEA_LOGIN_NAME="$LOGIN_OVERRIDE"
|
||||
elif [[ -n "${GITEA_LOGIN:-}" ]]; then
|
||||
GITEA_LOGIN_NAME="$GITEA_LOGIN"
|
||||
else
|
||||
echo "Error: --login (or GITEA_LOGIN) is required; refusing host-first login selection" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOKEN=$(get_gitea_token_for_login "$GITEA_LOGIN_NAME" "$HOST") || {
|
||||
echo "Error: login '$GITEA_LOGIN_NAME' is not configured for target host '$HOST'" >&2
|
||||
exit 1
|
||||
}
|
||||
AUTH_CONFIG=$(gitea_write_auth_config "$TOKEN") || {
|
||||
echo "Error: could not stage private Gitea authentication" >&2
|
||||
exit 1
|
||||
}
|
||||
unset TOKEN
|
||||
|
||||
API_BASE="https://${HOST}/api/v1"
|
||||
# Resolve identity through the SAME private curl config used for the
|
||||
# mutation. Tea login names are globally scoped and can be duplicated
|
||||
# across hosts; a separate `tea api --login NAME` could validate another
|
||||
# credential than this host-bound token.
|
||||
AUTHENTICATED_USER=$(curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$API_BASE/user" \
|
||||
| python3 -c 'import json,sys; value=json.load(sys.stdin).get("login"); print(value) if isinstance(value,str) and value else sys.exit(1)') || {
|
||||
echo "Error: could not authenticate the host-bound credential for '$GITEA_LOGIN_NAME'" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$AUTHENTICATED_USER" == "$IDENTITY" ]] || {
|
||||
echo "Error: host-bound credential authenticates as '$AUTHENTICATED_USER', not MOSAIC_GIT_IDENTITY '$IDENTITY'" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
REPO_API="$API_BASE/repos/${REPO_SLUG}"
|
||||
curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$REPO_API" >/dev/null || {
|
||||
echo "Error: target repository preflight failed for https://${HOST}/${REPO_SLUG}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
PAYLOAD=$(TITLE="$TITLE" BODY="$BODY" BASE_BRANCH="$BASE_BRANCH" DRAFT_MODE="$DRAFT_MODE" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
payload = {}
|
||||
if os.environ["TITLE"]: payload["title"] = os.environ["TITLE"]
|
||||
if os.environ["BODY"]: payload["body"] = os.environ["BODY"]
|
||||
if os.environ["BASE_BRANCH"]: payload["base"] = os.environ["BASE_BRANCH"]
|
||||
if os.environ["DRAFT_MODE"]: payload["draft"] = os.environ["DRAFT_MODE"] == "draft"
|
||||
print(json.dumps(payload))
|
||||
PY
|
||||
)
|
||||
curl -fsS --config "$AUTH_CONFIG" -X PATCH \
|
||||
-H "User-Agent: mosaic-pr-edit" -H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" "$REPO_API/pulls/${PR_NUMBER}"
|
||||
echo "Updated Gitea pull request #$PR_NUMBER as '$AUTHENTICATED_USER'" >&2
|
||||
;;
|
||||
*) echo "Error: Could not detect git platform" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -243,15 +243,35 @@ from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _origin_and_path(url):
|
||||
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
||||
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
||||
# port and its explicit default form compare equal.
|
||||
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
|
||||
#
|
||||
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
|
||||
# ROOT_URL is configured http:// returns http:// object URLs even when every
|
||||
# client reaches it over https://, so a scheme-strict comparison rejects the
|
||||
# provider's own correct answer about a comment that landed — a deterministic
|
||||
# false negative on EVERY review comment posted against such a deployment.
|
||||
# That matters more here than anywhere else: on a host where no seat can
|
||||
# create a review OBJECT, the comment-form review record this path produces
|
||||
# is the only gate-16 evidence available, and this check refuses all of it.
|
||||
# The scheme is also not what the check defends: the forgeries it exists to
|
||||
# catch (look-alike host, decoy path prefix, wrong owner/repo/kind/number)
|
||||
# all vary the HOST or the PATH, both of which stay strict below. Any OTHER
|
||||
# scheme (file:, ftp:, javascript:) remains distinguishing and is rejected.
|
||||
#
|
||||
# Port: an implicit port and its own scheme's default compare equal, so
|
||||
# http://h == https://h. An EXPLICIT non-default port still distinguishes,
|
||||
# because a different port is a different service on the same host.
|
||||
parsed = urlparse(url or "")
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
host = (parsed.hostname or "").lower()
|
||||
default_port = 80 if scheme == "http" else 443
|
||||
port = parsed.port if parsed.port is not None else default_port
|
||||
return (scheme, host, port), parsed.path.rstrip("/")
|
||||
if scheme in ("http", "https"):
|
||||
scheme_class = "web"
|
||||
default_port = 80 if scheme == "http" else 443
|
||||
port = None if parsed.port in (None, default_port) else parsed.port
|
||||
else:
|
||||
scheme_class = scheme
|
||||
port = parsed.port
|
||||
return (scheme_class, host, port), parsed.path.rstrip("/")
|
||||
|
||||
|
||||
try:
|
||||
|
||||
@@ -7,14 +7,40 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-login-resolution}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
HOME_DIR="$WORK_DIR/home"
|
||||
LOG_FILE="$WORK_DIR/calls.log"
|
||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR"
|
||||
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
||||
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||
#
|
||||
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||
# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever
|
||||
# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credentials below are silently
|
||||
# ignored. This suite is the one where the consequence is not subtle: it FAILS
|
||||
# outright on a provisioned seat (rc=1 bare, rc=0 with $HOME sandboxed, one
|
||||
# variable changed) and passes everywhere else, including CI, which has no
|
||||
# per-agent token to leak.
|
||||
#
|
||||
# CONTAINMENT: the sandboxed HOME in the four run helpers below. It only has to
|
||||
# bound a failure that the pin should already have prevented.
|
||||
#
|
||||
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||
# by construction and every suite reads clean however vulnerable it is. To measure,
|
||||
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
||||
# per-slot token) so step 0 reaches its fail-loud branch.
|
||||
#
|
||||
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||
|
||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||
{
|
||||
@@ -86,6 +112,7 @@ run_in_repo() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||
"$@"
|
||||
@@ -283,6 +310,7 @@ run_in_repo2() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR2:$PATH" \
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||
"$@"
|
||||
@@ -343,7 +371,7 @@ write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; }
|
||||
token_fallback() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
|
||||
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
|
||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||
get_gitea_token_for_login "$1" "$2"
|
||||
' _ "$1" "$2"
|
||||
@@ -354,7 +382,7 @@ token_fallback() {
|
||||
token_pyyaml() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
|
||||
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
|
||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||
get_gitea_token_for_login "$1" "$2"
|
||||
' _ "$1" "$2"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for #701: -h/--help must exit 0, bad args must still exit nonzero.
|
||||
#
|
||||
# Covers the 7 wrappers whose usage() previously hard-coded `exit 1`, so every
|
||||
# Covers wrappers whose usage() previously hard-coded `exit 1`, so every
|
||||
# --help invocation exited nonzero and logged a phantom isError across fleet lanes.
|
||||
# Asserts, per wrapper:
|
||||
# 1. `--help` exits 0 and prints usage.
|
||||
@@ -18,6 +18,7 @@ WRAPPERS=(
|
||||
issue-list.sh
|
||||
milestone-create.sh
|
||||
pr-create.sh
|
||||
pr-edit.sh
|
||||
pr-list.sh
|
||||
pr-merge.sh
|
||||
)
|
||||
@@ -47,7 +48,7 @@ for wrapper in "${WRAPPERS[@]}"; do
|
||||
done
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "help-exit-code regression passed (7/7 wrappers)"
|
||||
echo "help-exit-code regression passed (8/8 wrappers)"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
|
||||
@@ -61,15 +61,54 @@ STATE_FILE="$WORK_DIR/comments.json"
|
||||
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
|
||||
# check can assert every POST/GET body + metadata temp file is cleaned up.
|
||||
TMP_SCRATCH="$WORK_DIR/scratch"
|
||||
HOME_DIR="$WORK_DIR/home"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH"
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH" "$HOME_DIR"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||
#
|
||||
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||
# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever
|
||||
# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credential below is silently
|
||||
# ignored. The stub curl then rejects the unrecognised bearer, and this suite
|
||||
# fails at its FIRST case with `Gitea authenticated-identity read failed with
|
||||
# HTTP 401`. An empty repo-local value shadows the global one and reads back
|
||||
# empty at rc=0. Measured: without this pin the suite is RED on every seat.
|
||||
#
|
||||
# CONTAINMENT: the sandboxed HOME in run_comment(). It only has to bound a
|
||||
# failure that the pin should already have prevented.
|
||||
#
|
||||
# THIS SUITE WAS THE HARDEST OF THE FIVE TO SEE, and the reason is worth stating
|
||||
# because it generalises: run_comment() sends the wrapper's stdout AND stderr to
|
||||
# $OUTPUT_FILE, and the EXIT trap above deletes $WORK_DIR. So the 401 — the only
|
||||
# thing that says what went wrong — exists only inside a directory that is gone
|
||||
# by the time anyone looks. The suite exits 1 with ZERO bytes on stdout and
|
||||
# stderr. A suite that discards or deletes its own evidence turns any post-hoc
|
||||
# assay into a non-measurement: "nothing found" there means "no surviving
|
||||
# trace", never "clean". It was found by intercepting the identity read at its
|
||||
# SOURCE (a PATH shim over `git` logging every `mosaic.gitIdentity` read to a
|
||||
# file outside $WORK_DIR), which is deletion-proof by construction, rather than
|
||||
# by grepping for the symptom.
|
||||
#
|
||||
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||
# by construction and every suite reads clean however vulnerable it is. To
|
||||
# measure, REPLICATE a seat (a decoy HOME whose .gitconfig sets
|
||||
# mosaic.gitIdentity, with no per-slot token) so step 0 reaches its fail-loud
|
||||
# branch — or intercept the read as described above.
|
||||
#
|
||||
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||
|
||||
ISSUE_NUMBER=7
|
||||
REPO_SLUG="mosaicstack/stack"
|
||||
@@ -372,6 +411,7 @@ run_comment() {
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
TMPDIR="$TMP_SCRATCH" \
|
||||
HOME="$HOME_DIR" \
|
||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
|
||||
|
||||
@@ -7,13 +7,38 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
HOME_DIR="$WORK_DIR/home"
|
||||
LOG_FILE="$WORK_DIR/calls.log"
|
||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||
#
|
||||
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||
# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever
|
||||
# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credential below is silently
|
||||
# ignored and the suite runs against a production credential. An empty repo-local
|
||||
# value shadows the global one and reads back empty at rc=0. Measured: this suite
|
||||
# resolves a per-slot token without it.
|
||||
#
|
||||
# CONTAINMENT: the sandboxed HOME in run_wrapper(). It only has to bound a failure
|
||||
# that the pin should already have prevented.
|
||||
#
|
||||
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||
# by construction and every suite reads clean however vulnerable it is. To measure,
|
||||
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
||||
# per-slot token) so step 0 reaches its fail-loud branch.
|
||||
#
|
||||
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||
|
||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||
{"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}}
|
||||
@@ -50,6 +75,7 @@ run_wrapper() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||
"$@"
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for secret-safe, identity-bound PR editing and explicit targets.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-edit}"
|
||||
REPO_DIR="$WORK_DIR/repo"; BIN_DIR="$WORK_DIR/bin"; HOME_DIR="$WORK_DIR/home"
|
||||
XDG_DIR="$WORK_DIR/xdg"; LOG_FILE="$WORK_DIR/calls.log"
|
||||
rm -rf "$WORK_DIR"; mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR" "$XDG_DIR/tea"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/other/wrong-checkout.git
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||
|
||||
cat > "$XDG_DIR/tea/config.yml" <<'YAML'
|
||||
logins:
|
||||
- name: usc-coder3
|
||||
url: https://git.uscllc.com
|
||||
token: fixture-usc-token
|
||||
- name: same-host-other
|
||||
url: https://git.uscllc.com
|
||||
token: fixture-other-token
|
||||
- name: mosaic-coder3
|
||||
url: https://git.mosaicstack.dev
|
||||
token: fixture-mosaic-token
|
||||
YAML
|
||||
|
||||
cat > "$BIN_DIR/tea" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# Deliberately misleading duplicate-name response: the wrapper must never use
|
||||
# tea for identity validation because its name lookup is not host-bound.
|
||||
[[ "$*" == "api --login duplicate /user" ]] && { printf '{"login":"coder3"}\n'; exit 0; }
|
||||
exit 1
|
||||
SH
|
||||
cat > "$BIN_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'curl' >> "$MOSAIC_TEST_LOG"; printf ' <%s>' "$@" >> "$MOSAIC_TEST_LOG"; printf '\n' >> "$MOSAIC_TEST_LOG"
|
||||
if [[ "${*: -1}" == */user ]]; then
|
||||
printf '{"login":"%s"}\n' "${MOSAIC_STUB_AUTH_USER:-coder3}"
|
||||
elif [[ "${*: -1}" == */repos/* && " $* " != *" -X PATCH "* ]]; then
|
||||
[[ "${MOSAIC_STUB_SIGNAL:-}" == "TERM" ]] && { kill -TERM "$PPID"; sleep 1; }
|
||||
[[ "${MOSAIC_STUB_SIGNAL:-}" == "INT" ]] && { kill -INT "$PPID"; sleep 1; }
|
||||
printf '{"name":"repo"}\n'
|
||||
else
|
||||
printf '{"number":42,"draft":false}\n'
|
||||
fi
|
||||
SH
|
||||
cat > "$BIN_DIR/gh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'gh' >> "$MOSAIC_TEST_LOG"; printf ' <%s>' "$@" >> "$MOSAIC_TEST_LOG"; printf '\n' >> "$MOSAIC_TEST_LOG"
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl" "$BIN_DIR/gh" "$SCRIPT_DIR/pr-edit.sh"
|
||||
|
||||
run_wrapper() {
|
||||
(cd "$REPO_DIR"; PATH="$BIN_DIR:$PATH" HOME="$HOME_DIR" XDG_CONFIG_HOME="$XDG_DIR" \
|
||||
MOSAIC_TEST_LOG="$LOG_FILE" "$SCRIPT_DIR/pr-edit.sh" "$@")
|
||||
}
|
||||
assert_no_secret() {
|
||||
! grep -q 'fixture-.*-token' "$LOG_FILE" || { echo "Credential leaked into curl argv/log" >&2; exit 1; }
|
||||
}
|
||||
|
||||
# The explicit target differs from CWD origin and must govern BOTH host and slug.
|
||||
: > "$LOG_FILE"
|
||||
# shellcheck disable=SC2016 # literal backticks prove argument-array body safety.
|
||||
MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login mosaic-coder3 -r mosaicstack/stack \
|
||||
-H git.mosaicstack.dev --title 'New title' --body 'Body with `literal` bytes' --base develop --draft >/dev/null
|
||||
python3 - "$LOG_FILE" <<'PY'
|
||||
import json, pathlib, sys
|
||||
lines = pathlib.Path(sys.argv[1]).read_text().splitlines()
|
||||
assert len(lines) == 3, lines
|
||||
assert "https://git.mosaicstack.dev/api/v1/user" in lines[0], lines
|
||||
assert "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack" in lines[1], lines
|
||||
assert "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/42" in lines[2], lines
|
||||
assert all("--config" in line for line in lines), lines
|
||||
assert "Authorization:" not in "\n".join(lines), lines
|
||||
payload = lines[2].split(" <-d> <", 1)[1].split("> <https://", 1)[0]
|
||||
assert json.loads(payload) == {"title":"New title","body":"Body with `literal` bytes","base":"develop","draft":True}
|
||||
PY
|
||||
assert_no_secret
|
||||
|
||||
# Ready maps to false and still preflights before the write.
|
||||
: > "$LOG_FILE"
|
||||
MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login usc-coder3 -r USC/uconnect -H git.uscllc.com --ready >/dev/null
|
||||
grep -q '"draft": false' "$LOG_FILE"; assert_no_secret
|
||||
|
||||
# Identity is mandatory; no ambient/first-host login can write.
|
||||
: > "$LOG_FILE"
|
||||
if run_wrapper -n 42 --login usc-coder3 --draft >/dev/null 2>&1; then echo "Unset identity wrote" >&2; exit 1; fi
|
||||
[[ ! -s "$LOG_FILE" ]] || { echo "Unset identity reached curl" >&2; exit 1; }
|
||||
|
||||
# Explicit and ambient same-host wrong principals both refuse after identity
|
||||
# lookup but before repo preflight/PATCH. The /user read is expected curl #1.
|
||||
for mode in explicit ambient; do
|
||||
: > "$LOG_FILE"
|
||||
if [[ "$mode" == explicit ]]; then
|
||||
cmd=(--login same-host-other)
|
||||
else
|
||||
cmd=(); export GITEA_LOGIN=same-host-other
|
||||
fi
|
||||
if MOSAIC_STUB_AUTH_USER=other MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 "${cmd[@]}" -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then
|
||||
echo "$mode wrong identity wrote" >&2; exit 1
|
||||
fi
|
||||
unset GITEA_LOGIN
|
||||
[[ "$(wc -l < "$LOG_FILE")" -eq 1 ]] || { echo "$mode wrong identity passed identity lookup" >&2; exit 1; }
|
||||
! grep -q '/repos/' "$LOG_FILE" || { echo "$mode wrong identity reached repo preflight/PATCH" >&2; exit 1; }
|
||||
done
|
||||
|
||||
# Set identity with no explicit/ambient login refuses rather than selecting first host login.
|
||||
: > "$LOG_FILE"
|
||||
if MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then
|
||||
echo "Missing login selected a principal" >&2; exit 1
|
||||
fi
|
||||
[[ ! -s "$LOG_FILE" ]] || { echo "Missing login reached curl" >&2; exit 1; }
|
||||
|
||||
# Split-credential probe for the duplicate-name cross-host seam: tea's
|
||||
# name-only /user would report coder3, while the selected host-bound curl token
|
||||
# reports other. The wrapper must trust only the latter handle used by PATCH.
|
||||
: > "$LOG_FILE"
|
||||
if MOSAIC_STUB_AUTH_USER=other MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login mosaic-coder3 \
|
||||
-r mosaicstack/stack -H git.mosaicstack.dev --draft >/dev/null 2>&1; then
|
||||
echo "Duplicate-name split credential reached PATCH" >&2; exit 1
|
||||
fi
|
||||
[[ "$(wc -l < "$LOG_FILE")" -eq 1 ]] || { echo "Duplicate-name identity mismatch passed /user" >&2; cat "$LOG_FILE" >&2; exit 1; }
|
||||
! grep -q -- '-X> <PATCH' "$LOG_FILE" || { echo "Duplicate-name mismatch mutated" >&2; exit 1; }
|
||||
|
||||
# TERM and INT during repo preflight clean up, do not mutate, and return the
|
||||
# signal status rather than swallowing termination into success.
|
||||
for sig in TERM INT; do
|
||||
: > "$LOG_FILE"
|
||||
set +e
|
||||
MOSAIC_STUB_SIGNAL="$sig" MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login usc-coder3 \
|
||||
-r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
[[ "$rc" -ne 0 ]] || { echo "$sig was swallowed into success" >&2; exit 1; }
|
||||
[[ "$rc" -eq 143 || "$rc" -eq 130 ]] || { echo "$sig returned unexpected status $rc" >&2; exit 1; }
|
||||
! grep -q -- '-X> <PATCH' "$LOG_FILE" || { echo "$sig continued into PATCH" >&2; exit 1; }
|
||||
assert_no_secret
|
||||
done
|
||||
|
||||
# Cross-host credential fails before curl; explicit target preflight failure blocks PATCH.
|
||||
: > "$LOG_FILE"
|
||||
if MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login mosaic-coder3 -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then
|
||||
echo "Cross-host login wrote" >&2; exit 1
|
||||
fi
|
||||
[[ ! -s "$LOG_FILE" ]] || { echo "Cross-host login reached curl" >&2; exit 1; }
|
||||
|
||||
if run_wrapper -n 42 --draft --ready >/dev/null 2>&1; then echo "Accepted conflicting modes" >&2; exit 1; fi
|
||||
if run_wrapper -n 42 >/dev/null 2>&1; then echo "Accepted no-op edit" >&2; exit 1; fi
|
||||
run_wrapper --help 2>&1 | grep -q '^Usage:'
|
||||
|
||||
# GitHub retains provider-native edit/readiness behavior.
|
||||
git -C "$REPO_DIR" remote set-url origin https://github.com/acme/widgets.git
|
||||
: > "$LOG_FILE"; run_wrapper -n 7 --title 'GitHub title' --draft >/dev/null
|
||||
grep -q 'gh <pr> <edit> <7> <--title> <GitHub title>' "$LOG_FILE"
|
||||
grep -q 'gh <pr> <ready> <7> <--undo>' "$LOG_FILE"
|
||||
echo "PR edit regression harness passed"
|
||||
@@ -8,6 +8,7 @@ WORK_ROOT="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||
SANDBOX="$WORK_ROOT/pr-merge-empty-uid-test-$$"
|
||||
MOCK_BIN="$SANDBOX/bin"
|
||||
REPO_DIR="$SANDBOX/repo"
|
||||
HOME_DIR="$SANDBOX/home"
|
||||
LOG_FILE="$SANDBOX/mock.log"
|
||||
|
||||
cleanup() {
|
||||
@@ -15,7 +16,7 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$MOCK_BIN" "$REPO_DIR"
|
||||
mkdir -p "$MOCK_BIN" "$REPO_DIR" "$HOME_DIR"
|
||||
: > "$LOG_FILE"
|
||||
|
||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||
@@ -109,7 +110,48 @@ chmod +x "$MOCK_BIN/curl"
|
||||
cd "$REPO_DIR"
|
||||
git init -q
|
||||
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||
#
|
||||
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||
# repo. Step 0 runs BEFORE the credential loader AND before the GITEA_TOKEN env
|
||||
# check, so the `GITEA_TOKEN=redacted-test-token` exported below is silently
|
||||
# overridden and a REAL per-slot token from $HOME is what flows through the
|
||||
# wrapper. Measured on a provisioned seat before this pin: all 5 mock-curl calls
|
||||
# carried the real per-slot token in argv and the fixture token was never used at
|
||||
# ALL. Three consequences specific to this suite:
|
||||
# 1. pr-merge.sh passes the token as `-H "Authorization: token $token"` and the
|
||||
# mock curl logs full argv, so the real credential is written to $LOG_FILE
|
||||
# on disk — transiently: the suite truncates that file between phases and
|
||||
# the EXIT trap removes $SANDBOX, so it leaves NO post-hoc trace. That is
|
||||
# why this suite was the hardest of the three to detect; observing it needs
|
||||
# an instrument that captures argv while the run is live.
|
||||
# 2. Every failure path dumps $OUTPUT/$LOG_FILE to stderr through
|
||||
# `sed 's/redacted-test-token/***REDACTED***/g'` — a redaction pattern that
|
||||
# is the literal fixture string and therefore CANNOT match the token
|
||||
# actually in use.
|
||||
# 3. The leak assertion at "Token leaked to pr-merge.sh output" greps for that
|
||||
# same fixture string, so on a provisioned seat it passes vacuously: it is
|
||||
# searching for a value the run never used.
|
||||
# An empty repo-local value shadows the global one and reads back empty at rc=0.
|
||||
#
|
||||
# CONTAINMENT: the sandboxed HOME exported below. It only has to bound a failure
|
||||
# that the pin should already have prevented.
|
||||
#
|
||||
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||
# by construction and every suite reads clean however vulnerable it is. To measure,
|
||||
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
||||
# per-slot token) so step 0 reaches its fail-loud branch.
|
||||
#
|
||||
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||
|
||||
# $SANDBOX/$HOME_DIR were derived from the real $HOME above, before this export.
|
||||
export HOME="$HOME_DIR"
|
||||
export PATH="$MOCK_BIN:$PATH"
|
||||
export PR_MERGE_TEST_LOG="$LOG_FILE"
|
||||
export GITEA_LOGIN="git.mosaicstack.dev"
|
||||
|
||||
@@ -8,12 +8,68 @@ WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-gitea}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
FIXTURE_DIR="$WORK_DIR/fixtures"
|
||||
STUB_DIR="$WORK_DIR/stubs"
|
||||
HOME_DIR="$WORK_DIR/home"
|
||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$FIXTURE_DIR" "$STUB_DIR"
|
||||
mkdir -p "$REPO_DIR" "$FIXTURE_DIR" "$STUB_DIR" "$HOME_DIR"
|
||||
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
||||
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||
#
|
||||
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||
# repo. Step 0 runs BEFORE the credential loader AND before the GITEA_TOKEN env
|
||||
# check, so the `GITEA_TOKEN="stub-token"` set in the run helpers below is
|
||||
# silently overridden and a REAL per-slot token from $HOME is what reaches curl.
|
||||
# Measured on a provisioned seat before this pin: both stub-curl calls carried
|
||||
# the real token in argv. An empty repo-local value shadows the global one and
|
||||
# reads back empty at rc=0.
|
||||
#
|
||||
# CONTAINMENT: the sandboxed HOME in the three run helpers below. It only has to
|
||||
# bound a failure that the pin should already have prevented.
|
||||
#
|
||||
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||
# by construction and every suite reads clean however vulnerable it is. To measure,
|
||||
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
||||
# per-slot token) so step 0 reaches its fail-loud branch. See
|
||||
# test-gitea-token-identity.sh for the stronger `env -i HOME=…` form used where a
|
||||
# suite's whole subject IS identity resolution.
|
||||
#
|
||||
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||
|
||||
# The pin above removes step 0, but this suite has a SECOND, independent
|
||||
# dependency on operator state, and closing only the first would leave the suite
|
||||
# red on any hermetic environment. The `GITEA_TOKEN="stub-token"` /
|
||||
# `GITEA_URL="https://git.example.test"` pair the run helpers set is INERT: step 2
|
||||
# of get_gitea_token accepts GITEA_TOKEN only when GITEA_URL matches the remote
|
||||
# host, and this repo's origin is git.uscllc.com, so that pair can never satisfy
|
||||
# it. Before this fixture the only credential that could reach the authenticated
|
||||
# curl branch was a REAL one — from step 0 on an agent seat, or from step 1
|
||||
# reading the operator's own ~/.config/mosaic/credentials.json. That is why the
|
||||
# "curl success path" case passed: not because the stub credential worked, but
|
||||
# because a production credential was available.
|
||||
#
|
||||
# A fixture is used rather than relying on the sandboxed HOME making step 1 find
|
||||
# nothing: a test that passes because production configuration is ABSENT fails
|
||||
# the moment it is present. Step 1 now resolves deterministically to a value that
|
||||
# is a fixture on every machine.
|
||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||
{
|
||||
"gitea": {
|
||||
"usc": {
|
||||
"url": "https://git.uscllc.com",
|
||||
"token": "stub-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
cat > "$FIXTURE_DIR/gitea-standard.json" <<'JSON'
|
||||
{
|
||||
@@ -131,6 +187,8 @@ run_curl_success_case() {
|
||||
set +e
|
||||
output=$(cd "$REPO_DIR" && \
|
||||
PATH="$STUB_DIR:$PATH" \
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
TMPDIR="$tmpdir" \
|
||||
GITEA_TOKEN="stub-token" \
|
||||
GITEA_URL="https://git.example.test" \
|
||||
@@ -170,6 +228,8 @@ run_curl_early_exit_cleanup_case() {
|
||||
set +e
|
||||
output=$(cd "$REPO_DIR" && \
|
||||
PATH="$STUB_DIR:$PATH" \
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
TMPDIR="$tmpdir" \
|
||||
GITEA_TOKEN="stub-token" \
|
||||
GITEA_URL="https://git.example.test" \
|
||||
@@ -204,7 +264,8 @@ run_curl_early_exit_cleanup_case() {
|
||||
run_case() {
|
||||
local fixture="$1" expected_number="$2" expected_head="$3"
|
||||
local output
|
||||
output=$(cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number")
|
||||
output=$(cd "$REPO_DIR" && HOME="$HOME_DIR" MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number")
|
||||
PR_METADATA_OUTPUT="$output" python3 - "$expected_number" "$expected_head" <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -439,6 +439,19 @@ elif mode == "comment-url-wrong-repo":
|
||||
elif mode == "comment-url-suffix-injection":
|
||||
# Prefix-injected: a bare endswith("/<slug>/pulls/123") test would ACCEPT it.
|
||||
pr_url = f"{_origin}/deceptive{_slug}/pulls/123"
|
||||
elif mode == "comment-url-wrong-port":
|
||||
# #991 bound: an EXPLICIT non-default port is a different service on the same
|
||||
# host. Relaxing http-vs-https must NOT relax this.
|
||||
pr_url = f"{_p.scheme}://{_p.hostname}:8443{_slug}/pulls/123"
|
||||
elif mode == "comment-url-non-web-scheme":
|
||||
# #991 bound: ONLY http/https collapse; any other scheme stays distinguishing.
|
||||
pr_url = f"ftp://{_p.netloc}{_slug}/pulls/123"
|
||||
elif mode == "comment-url-scheme-downgrade":
|
||||
# #991, and the only URL mode here that must be ACCEPTED. A Gitea whose
|
||||
# ROOT_URL is http:// returns http:// object URLs for a repo reached over
|
||||
# https://. Same host, same path, correct record — a truthful provider
|
||||
# answer about a comment that landed, not a forgery.
|
||||
pr_url = f"http://{_p.netloc}{_slug}/pulls/123"
|
||||
elif mode == "comment-mixed-case-slug":
|
||||
# #875: EXPECTED_REPO_SLUG is taken verbatim from GITEA_API_BASE and can be
|
||||
# mixed-case (e.g. "USC/uconnect"), but Gitea canonicalizes the returned
|
||||
@@ -893,11 +906,16 @@ fi
|
||||
assert_no_temp_leak "review-body-reuse"
|
||||
|
||||
# Cases 12-15 (#865 Blocker 3): a PR comment whose id/author/body are all correct
|
||||
# but whose provider-returned pull_request_url is forged must FAIL CLOSED.
|
||||
# Verification pins the URL's ORIGIN (scheme+host+effective-port) and FULL path
|
||||
# (deployment prefix + exact owner/repo + kind + number); a bare endswith/suffix
|
||||
# test would wrongly accept the look-alike-host and prefix-injection variants.
|
||||
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo comment-url-suffix-injection; do
|
||||
# but whose provider-returned pull_request_url does not belong to this PR must
|
||||
# FAIL CLOSED. Verification pins the URL's ORIGIN (scheme-class + host + explicit
|
||||
# non-default port) and FULL path (deployment prefix + exact owner/repo + kind +
|
||||
# number); a bare endswith/suffix test would wrongly accept the look-alike-host
|
||||
# and prefix-injection variants. comment-url-wrong-port and
|
||||
# comment-url-non-web-scheme (#991) bound the scheme relaxation from the other
|
||||
# side: collapsing http/https must not also collapse a different port or a
|
||||
# different scheme family.
|
||||
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo \
|
||||
comment-url-suffix-injection comment-url-wrong-port comment-url-non-web-scheme; do
|
||||
if run_review "$bad_mode" comment durable-body; then
|
||||
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
@@ -923,6 +941,19 @@ run_review comment-mixed-case-slug comment durable-body https://git.mosaicstack.
|
||||
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
||||
assert_no_temp_leak "comment-mixed-case-slug"
|
||||
|
||||
# Case 15c (#991): the deployment's Gitea ROOT_URL is http:// while every client
|
||||
# reaches it over https://, so the provider returns an http:// pull_request_url
|
||||
# for a comment that is otherwise entirely correct. Same class as 15b — a
|
||||
# legitimate provider response, not a spoof — and a scheme-strict compare
|
||||
# rejects it on EVERY comment, deterministically. That is not a cosmetic false
|
||||
# negative here: on a host where no seat can create a review OBJECT, this
|
||||
# comment-form record is the only gate-16 evidence obtainable, and the wrapper
|
||||
# refuses all of it while the comment sits durably on the PR. Host, path, owner,
|
||||
# repo, kind and number stay strict; only http-vs-https is relaxed.
|
||||
run_review comment-url-scheme-downgrade comment durable-body
|
||||
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
||||
assert_no_temp_leak "comment-url-scheme-downgrade"
|
||||
|
||||
# Case 16 (#865 ITEM 1, current-head TOCTOU): the PR head advances between the
|
||||
# pre-submit head read (which pins the review) and the post-verify re-read. The
|
||||
# review is genuinely created and verified as pinned to the OLD head, but the
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed comparison of deployed Mosaic tools to manifest-owned shipped tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
value = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
value.update(chunk)
|
||||
return value.hexdigest()
|
||||
|
||||
|
||||
def default_source_tools() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def normalize_source(path: Path) -> Path:
|
||||
candidate = path.resolve()
|
||||
return candidate / "tools" if (candidate / "tools").is_dir() else candidate
|
||||
|
||||
|
||||
def assert_traversable_directory(path: Path) -> None:
|
||||
mode = stat.S_IMODE(path.stat(follow_symlinks=False).st_mode)
|
||||
# At least one principal class must have both read and search. This catches
|
||||
# mode-000 even for privileged reviewers for whom os.access() would lie.
|
||||
if not any(mode & read and mode & execute for read, execute in ((0o400, 0o100), (0o040, 0o010), (0o004, 0o001))):
|
||||
raise PermissionError(f"directory has no readable/searchable mode: {path}")
|
||||
|
||||
|
||||
def census(root: Path, *, reject_symlinks: bool) -> dict[str, Path]:
|
||||
result: dict[str, Path] = {}
|
||||
|
||||
def onerror(error: OSError) -> None:
|
||||
raise error
|
||||
|
||||
for current, directories, filenames in os.walk(root, topdown=True, followlinks=False, onerror=onerror):
|
||||
current_path = Path(current)
|
||||
assert_traversable_directory(current_path)
|
||||
for name in directories:
|
||||
entry = current_path / name
|
||||
if entry.is_symlink() and reject_symlinks:
|
||||
# A source symlink makes the shipped census incomplete. Deployed
|
||||
# aliases are assessed later only when they occupy a required
|
||||
# framework path; installed-only aliases remain operator state.
|
||||
raise OSError(f"symlinked directory is not an independent census entry: {entry}")
|
||||
for name in filenames:
|
||||
entry = current_path / name
|
||||
if entry.is_symlink():
|
||||
if reject_symlinks:
|
||||
raise OSError(f"symlinked file is not an independent census entry: {entry}")
|
||||
result[entry.relative_to(root).as_posix()] = entry
|
||||
continue
|
||||
mode = entry.stat(follow_symlinks=False).st_mode
|
||||
if not stat.S_ISREG(mode):
|
||||
raise OSError(f"non-regular census entry: {entry}")
|
||||
if stat.S_IMODE(mode) & 0o444 == 0:
|
||||
raise PermissionError(f"file has no readable mode: {entry}")
|
||||
result[entry.relative_to(root).as_posix()] = entry
|
||||
return result
|
||||
|
||||
|
||||
def classify_with_manifest(source: Path, relatives: list[str]) -> dict[str, str]:
|
||||
framework = source.parent
|
||||
manifest = framework / "framework-manifest.txt"
|
||||
resolver = source / "_lib" / "manifest.sh"
|
||||
if not manifest.is_file() or not os.access(manifest, os.R_OK):
|
||||
raise OSError(f"ownership manifest is missing or unreadable: {manifest}")
|
||||
if not resolver.is_file() or not os.access(resolver, os.R_OK):
|
||||
raise OSError(f"canonical manifest resolver is missing or unreadable: {resolver}")
|
||||
payload = "".join(f"tools/{relative}\n" for relative in relatives)
|
||||
completed = subprocess.run(
|
||||
["bash", str(resolver), "classify"],
|
||||
input=payload,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env={**os.environ, "MANIFEST_FILE": str(manifest)},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
detail = completed.stderr.strip() or f"resolver rc={completed.returncode}"
|
||||
raise OSError(f"ownership manifest failed canonical resolution: {detail}")
|
||||
classified: dict[str, str] = {}
|
||||
for line in completed.stdout.splitlines():
|
||||
ownership, separator, manifest_path = line.partition("\t")
|
||||
if not separator or not manifest_path.startswith("tools/") or ownership not in {"framework", "operator"}:
|
||||
raise OSError(f"invalid canonical ownership output: {line!r}")
|
||||
relative = manifest_path.removeprefix("tools/")
|
||||
if relative in classified:
|
||||
raise OSError(f"duplicate canonical ownership output: {relative}")
|
||||
classified[relative] = ownership
|
||||
if set(classified) != set(relatives):
|
||||
raise OSError("canonical ownership output did not classify the complete source census")
|
||||
return classified
|
||||
|
||||
|
||||
def has_symlinked_component(root: Path, relative: str) -> bool:
|
||||
current = root
|
||||
for component in Path(relative).parts:
|
||||
current = current / component
|
||||
if current.is_symlink():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Detect deployed Mosaic framework-tool drift")
|
||||
parser.add_argument("--source-root", type=Path, default=Path(os.environ["MOSAIC_FRAMEWORK_SOURCE_ROOT"]) if os.environ.get("MOSAIC_FRAMEWORK_SOURCE_ROOT") else default_source_tools())
|
||||
parser.add_argument("--installed-root", type=Path, default=Path(os.environ.get("MOSAIC_HOME", Path.home() / ".config/mosaic")) / "tools")
|
||||
parser.add_argument("--verbose", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
source = normalize_source(args.source_root)
|
||||
installed = args.installed_root.resolve()
|
||||
try:
|
||||
if not source.is_dir():
|
||||
raise OSError(f"source tools missing: {source}")
|
||||
if not installed.is_dir():
|
||||
raise OSError(f"installed tools missing: {installed}")
|
||||
if source.samefile(installed):
|
||||
raise OSError("source and installed roots identify the same filesystem object")
|
||||
source_files = census(source, reject_symlinks=True)
|
||||
if not source_files:
|
||||
raise OSError("source tools census is empty")
|
||||
ownership = classify_with_manifest(source, sorted(source_files))
|
||||
required = sorted(relative for relative, owner in ownership.items() if owner == "framework")
|
||||
if not required:
|
||||
raise OSError("ownership manifest classifies zero shipped tools as framework-owned")
|
||||
installed_files = census(installed, reject_symlinks=False)
|
||||
except (OSError, PermissionError) as error:
|
||||
print(f"[framework-drift] CANNOT_ASSERT {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
in_sync: list[str] = []
|
||||
stale: list[str] = []
|
||||
not_installed: list[str] = []
|
||||
unsafe_alias: list[str] = []
|
||||
for relative in required:
|
||||
deployed = installed / relative
|
||||
if not deployed.is_file():
|
||||
not_installed.append(relative)
|
||||
continue
|
||||
if has_symlinked_component(installed, relative):
|
||||
unsafe_alias.append(relative)
|
||||
continue
|
||||
try:
|
||||
if source_files[relative].samefile(deployed):
|
||||
unsafe_alias.append(relative)
|
||||
elif digest(source_files[relative]) == digest(deployed):
|
||||
in_sync.append(relative)
|
||||
else:
|
||||
stale.append(relative)
|
||||
except OSError as error:
|
||||
print(f"[framework-drift] CANNOT_ASSERT cannot compare {relative}: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
source_relative = set(source_files)
|
||||
installed_only = sorted(set(installed_files) - source_relative)
|
||||
if args.verbose:
|
||||
for relative in in_sync:
|
||||
print(f"[framework-drift] IN_SYNC {relative}")
|
||||
for relative in stale:
|
||||
print(f"[framework-drift] STALE {relative}")
|
||||
for relative in not_installed:
|
||||
print(f"[framework-drift] NOT_INSTALLED {relative}")
|
||||
for relative in unsafe_alias:
|
||||
print(f"[framework-drift] UNSAFE_ALIAS {relative}")
|
||||
if args.verbose:
|
||||
for relative in installed_only:
|
||||
print(f"[framework-drift] INSTALLED_ONLY operator-or-unknown {relative}")
|
||||
|
||||
print(
|
||||
"[framework-drift] summary "
|
||||
f"in-sync={len(in_sync)} stale={len(stale)} not-installed={len(not_installed)} "
|
||||
f"unsafe-alias={len(unsafe_alias)} installed-only={len(installed_only)}"
|
||||
)
|
||||
print("[framework-drift] classification canonical framework-manifest ownership; installed-only=operator-or-unknown-preserved")
|
||||
if stale or not_installed or unsafe_alias:
|
||||
print("[framework-drift] FAIL deployed framework tools do not match independent shipped source; schedule a reviewed framework reseed", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
CHECKER = Path(__file__).with_name("framework-drift-check.py")
|
||||
REAL_RESOLVER = CHECKER.parents[2] / "_lib" / "manifest.sh"
|
||||
|
||||
|
||||
class FrameworkDriftCheckTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
root = Path(self.temp.name)
|
||||
self.framework = root / "framework"
|
||||
self.source = self.framework / "tools"
|
||||
self.installed = root / "home" / "tools"
|
||||
for directory in (self.source / "git", self.source / "_lib", self.installed / "git", self.installed / "_lib"):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(REAL_RESOLVER, self.source / "_lib" / "manifest.sh")
|
||||
(self.source / "git" / "guard.sh").write_text("fixed\n")
|
||||
(self.source / "git" / "new-wrapper.sh").write_text("new\n")
|
||||
(self.source / "_lib" / "credentials.json").write_text("source-placeholder\n")
|
||||
self.write_manifest()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp.cleanup()
|
||||
|
||||
def write_manifest(self, operator_extra: str = "") -> None:
|
||||
(self.framework / "framework-manifest.txt").write_text(
|
||||
"[framework]\ntools/**\n[operator]\ntools/_lib/credentials.json\n" + operator_extra
|
||||
)
|
||||
|
||||
def run_check(self, *extra: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(CHECKER), "--source-root", str(self.framework), "--installed-root", str(self.installed), *extra],
|
||||
text=True, capture_output=True, check=False,
|
||||
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
|
||||
)
|
||||
|
||||
def install_matching(self) -> None:
|
||||
for relative in ("git/guard.sh", "git/new-wrapper.sh", "_lib/manifest.sh"):
|
||||
shutil.copy2(self.source / relative, self.installed / relative)
|
||||
(self.installed / "_lib" / "credentials.json").write_text("different-operator-secret\n")
|
||||
|
||||
def test_fails_loudly_and_classifies_stale_missing_and_installed_only(self) -> None:
|
||||
(self.installed / "git" / "guard.sh").write_text("broken\n")
|
||||
shutil.copy2(self.source / "_lib" / "manifest.sh", self.installed / "_lib" / "manifest.sh")
|
||||
(self.installed / "local-helper.sh").write_text("operator\n")
|
||||
result = self.run_check("--verbose")
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("STALE git/guard.sh", result.stdout)
|
||||
self.assertIn("NOT_INSTALLED git/new-wrapper.sh", result.stdout)
|
||||
self.assertIn("INSTALLED_ONLY operator-or-unknown local-helper.sh", result.stdout)
|
||||
self.assertIn("FAIL deployed framework tools", result.stderr)
|
||||
|
||||
def test_passes_only_when_every_manifest_owned_source_file_matches(self) -> None:
|
||||
self.install_matching()
|
||||
result = self.run_check()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("stale=0 not-installed=0 unsafe-alias=0", result.stdout)
|
||||
|
||||
def test_manifest_is_required_and_policy_changes_take_effect(self) -> None:
|
||||
self.install_matching()
|
||||
(self.installed / "git" / "guard.sh").write_text("operator-divergence\n")
|
||||
self.write_manifest("tools/git/guard.sh\n")
|
||||
self.assertEqual(self.run_check().returncode, 0)
|
||||
(self.framework / "framework-manifest.txt").unlink()
|
||||
result = self.run_check()
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("CANNOT_ASSERT ownership manifest is missing", result.stderr)
|
||||
|
||||
def test_empty_and_unreadable_source_census_cannot_assert(self) -> None:
|
||||
empty_framework = Path(self.temp.name) / "empty-framework"
|
||||
empty_source = empty_framework / "tools"
|
||||
empty_source.mkdir(parents=True)
|
||||
shutil.copy2(self.framework / "framework-manifest.txt", empty_framework / "framework-manifest.txt")
|
||||
# The canonical resolver is supplied outside the empty census solely so
|
||||
# this probe reaches the explicit minimum-population guard.
|
||||
result = subprocess.run([sys.executable, str(CHECKER), "--source-root", str(empty_framework), "--installed-root", str(self.installed)], text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("CANNOT_ASSERT", result.stderr)
|
||||
|
||||
blocked = self.source / "blocked"
|
||||
blocked.mkdir(); (blocked / "hidden.sh").write_text("hidden\n"); blocked.chmod(0)
|
||||
try:
|
||||
result = self.run_check()
|
||||
finally:
|
||||
blocked.chmod(0o700)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("CANNOT_ASSERT", result.stderr)
|
||||
self.assertTrue("Permission denied" in result.stderr or "no readable/searchable mode" in result.stderr)
|
||||
|
||||
def test_root_and_descendant_aliases_cannot_report_clean(self) -> None:
|
||||
result = subprocess.run([sys.executable, str(CHECKER), "--source-root", str(self.framework), "--installed-root", str(self.source)], text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("same filesystem object", result.stderr)
|
||||
|
||||
shutil.copy2(self.source / "_lib" / "manifest.sh", self.installed / "_lib" / "manifest.sh")
|
||||
shutil.rmtree(self.installed / "git")
|
||||
(self.installed / "git").symlink_to(self.source / "git", target_is_directory=True)
|
||||
result = self.run_check()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue("symlinked directory" in result.stderr or "UNSAFE_ALIAS" in result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Doctor must contain a stalled drift checker and continue its remaining audit.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DOCTOR="$SCRIPT_DIR/../../_scripts/mosaic-doctor"
|
||||
WORK="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/framework-drift-doctor}"
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/source/tools/quality/scripts" "$WORK/source/tools/_scripts" "$WORK/home/tools"
|
||||
cp "$DOCTOR" "$WORK/source/tools/_scripts/mosaic-doctor"
|
||||
cat > "$WORK/source/tools/quality/scripts/framework-drift-check.py" <<'PY'
|
||||
import time
|
||||
time.sleep(30)
|
||||
PY
|
||||
|
||||
start=$(date +%s)
|
||||
set +e
|
||||
output=$(MOSAIC_HOME="$WORK/home" MOSAIC_DOCTOR_DRIFT_TIMEOUT_SEC=1 \
|
||||
bash "$WORK/source/tools/_scripts/mosaic-doctor" --fail-on-warn 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
elapsed=$(( $(date +%s) - start ))
|
||||
|
||||
[[ "$rc" -ne 0 ]] || { echo "FAIL: checker timeout became doctor success" >&2; exit 1; }
|
||||
[[ "$elapsed" -lt 10 ]] || { echo "FAIL: checker hang escaped watchdog (${elapsed}s)" >&2; exit 1; }
|
||||
[[ "$output" == *"CANNOT_ASSERT framework drift checker timed out"* ]] || {
|
||||
echo "FAIL: missing timeout CANNOT_ASSERT diagnostic" >&2; printf '%s\n' "$output" >&2; exit 1;
|
||||
}
|
||||
[[ "$output" == *"[mosaic-doctor] warnings="* ]] || {
|
||||
echo "FAIL: doctor did not continue after checker timeout" >&2; printf '%s\n' "$output" >&2; exit 1;
|
||||
}
|
||||
|
||||
echo "framework drift doctor watchdog regression passed"
|
||||
@@ -564,7 +564,7 @@ echo "== Q16 (guard): Q2's ENUM-B fixture must STAY address-free — the reconci
|
||||
# Token concatenated so THIS guard's own source lines never contain the
|
||||
# literal fixture id and cannot self-match.
|
||||
enum_id='ENUM''-B'
|
||||
fixture_line="$(has_match -F "\"id\":\"$enum_id\"" "$self" | has_match -F '"observed_seq":5' | head -n1)"
|
||||
fixture_lines="$(has_match -F "\"id\":\"$enum_id\"" "$self")"; fixture_matches="$(has_match -F '"observed_seq":5' <<<"$fixture_lines")"; fixture_line="${fixture_matches%%$'\n'*}"
|
||||
[ -n "$fixture_line" ] || fail_msg "Q16: could not locate Q2's $enum_id fixture line (renamed/renumbered? update this guard)"
|
||||
fixture_json="$(printf '%s' "$fixture_line" | sed "s/.*'\({.*}\)'.*/\1/")"
|
||||
# Positive controls FIRST (blind-instrument rule): the extraction must yield
|
||||
|
||||
@@ -179,8 +179,8 @@ echo "== P3: detector orders the cause line BEFORE the delta it explains =="
|
||||
printf 'r1 state v2\n' >"$fx/repo_r1"
|
||||
"$DET" poll-once >/dev/null 2>&1 || fail_msg "P3: second poll failed"
|
||||
sd="$(state_dir)"
|
||||
pre_seq="$(jq -r 'select(.locators.kind == "preimage") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
|
||||
src_seq="$(jq -r 'select(.locators.kind == "repo") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
|
||||
pre_seq="$(jq -nr 'first(inputs | select(.locators.kind == "preimage") | .observed_seq) // empty' "$sd/pending.jsonl")"
|
||||
src_seq="$(jq -nr 'first(inputs | select(.locators.kind == "repo") | .observed_seq) // empty' "$sd/pending.jsonl")"
|
||||
[ -n "$pre_seq" ] || fail_msg "P3: no preimage cause entry enqueued"
|
||||
[ -n "$src_seq" ] || fail_msg "P3: no source delta entry enqueued"
|
||||
if [ -n "$pre_seq" ] && [ -n "$src_seq" ]; then
|
||||
@@ -343,8 +343,8 @@ echo "== P11: reconcile surfaces the cause line before its enumerations =="
|
||||
printf '# adapter changed while detector down\n' >>"$fx/adapter.sh"
|
||||
"$RECON" reconcile >/dev/null 2>&1 # rc 1 expected (unaccounted enumerated)
|
||||
sd="$(state_dir)"
|
||||
pre_seq="$(jq -r 'select(.locators.kind == "preimage") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
|
||||
enum_seq="$(jq -r 'select(.locators.reconciled == true) | .observed_seq' "$sd/pending.jsonl" | head -n1)"
|
||||
pre_seq="$(jq -nr 'first(inputs | select(.locators.kind == "preimage") | .observed_seq) // empty' "$sd/pending.jsonl")"
|
||||
enum_seq="$(jq -nr 'first(inputs | select(.locators.reconciled == true) | .observed_seq) // empty' "$sd/pending.jsonl")"
|
||||
[ -n "$pre_seq" ] || fail_msg "P11: reconcile must enqueue the preimage cause line"
|
||||
[ -n "$enum_seq" ] || fail_msg "P11: reconcile must still enumerate the unaccounted source"
|
||||
if [ -n "$pre_seq" ] && [ -n "$enum_seq" ]; then
|
||||
|
||||
@@ -150,7 +150,7 @@ site_line() { # site_line FILE MARKER -> first physical line of that call
|
||||
local f="$1" marker="$2" ln
|
||||
ln="$(grep -n "# SITE:${marker}\$" "$f" | cut -d: -f1)"
|
||||
# continuation marker sits on the tail line; the call starts one line up
|
||||
if ! sed -n "${ln}p" "$f" | grep -Eq 'has_match|count_lines'; then
|
||||
source_line="$(sed -n "${ln}p" "$f")"; if ! grep -Eq 'has_match|count_lines' <<<"$source_line"; then
|
||||
ln=$((ln - 1))
|
||||
fi
|
||||
printf '%s' "$ln"
|
||||
@@ -167,13 +167,13 @@ rcB=$?
|
||||
sort "$LEDGER" >"$TMP/got-c1"
|
||||
n_expected="$(grep -c . "$TMP/expected-c1")"
|
||||
if [ "$rcA" -eq 0 ] && [ "$rcB" -eq 0 ] &&
|
||||
printf '%s' "$outA" | grep -q 'mini-a: OK' &&
|
||||
printf '%s' "$outB" | grep -q 'mini-b: OK' &&
|
||||
grep -q 'mini-a: OK' <<<"$outA"&&
|
||||
grep -q 'mini-b: OK' <<<"$outB"&&
|
||||
[ "$n_expected" -gt 1 ] &&
|
||||
cmp -s "$TMP/expected-c1" "$TMP/got-c1"; then
|
||||
check C1 0 ""
|
||||
else
|
||||
check C1 1 "rcA=$rcA rcB=$rcB expected($n_expected)/got diff: $(diff "$TMP/expected-c1" "$TMP/got-c1" 2>&1 | head -n 10 | tr '\n' ' ')"
|
||||
check C1 1 "rcA=$rcA rcB=$rcB expected($n_expected)/got diff: $(diff "$TMP/expected-c1" "$TMP/got-c1" 2>&1 | sed -n '1,10p' | tr '\n' ' ')"
|
||||
fi
|
||||
|
||||
# --- C2: early exit -> short ledger, comparison catches it -----------------
|
||||
@@ -201,12 +201,12 @@ abort_case() { # abort_case NAME MARKER HELPER
|
||||
bash "$TMP/mini-a.sh" "$TMP" 2>&1)"
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ] &&
|
||||
! printf '%s' "$out" | grep -q 'mini-a: OK' &&
|
||||
! printf '%s' "$out" | grep -q 'mini-a: FAILED' &&
|
||||
printf '%s' "$out" | grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" &&
|
||||
printf '%s' "$out" | grep -q "WAKE-ASSERT ABORT" &&
|
||||
printf '%s' "$out" | grep -q "$site" &&
|
||||
printf '%s' "$out" | grep -q "grep exit 2" &&
|
||||
! grep -q 'mini-a: OK' <<<"$out"&&
|
||||
! grep -q 'mini-a: FAILED' <<<"$out"&&
|
||||
grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" <<<"$out"&&
|
||||
grep -q "WAKE-ASSERT ABORT" <<<"$out"&&
|
||||
grep -q "$site" <<<"$out"&&
|
||||
grep -q "grep exit 2" <<<"$out"&&
|
||||
grep -q "^${helper} ${site}\$" "$ledger"; then
|
||||
check "$name" 0 ""
|
||||
else
|
||||
@@ -230,8 +230,8 @@ if [ "$got" = "1" ]; then check C8 0 ""; else check C8 1 "env-prefix did not rea
|
||||
out="$(WAKE_ASSERT_FORCE_GREP_ERROR_AT="mini-a.sh:9999" bash "$TMP/mini-a.sh" "$TMP" 2>&1)"
|
||||
rc=$?
|
||||
if [ "$rc" -eq 0 ] &&
|
||||
printf '%s' "$out" | grep -q 'mini-a: OK' &&
|
||||
! printf '%s' "$out" | grep -q 'WAKE-ASSERT ARMED'; then
|
||||
grep -q 'mini-a: OK' <<<"$out"&&
|
||||
! grep -q 'WAKE-ASSERT ARMED' <<<"$out"; then
|
||||
check C9 0 ""
|
||||
else
|
||||
check C9 1 "rc=$rc out=$(printf '%s' "$out" | tail -n 3 | tr '\n' ' ')"
|
||||
@@ -248,8 +248,8 @@ chmod +x "$TMP/fake-bash"
|
||||
out="$(WAKE_ASSERT_PIN_BASH="$TMP/fake-bash" bash -c '. "$WAKE_COMMON" && wake_assert_init && echo REACHED-PAST-INIT' 2>&1)"
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ] &&
|
||||
! printf '%s' "$out" | grep -q 'REACHED-PAST-INIT' &&
|
||||
printf '%s' "$out" | grep -q 'WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated'; then
|
||||
! grep -q 'REACHED-PAST-INIT' <<<"$out"&&
|
||||
grep -q 'WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated' <<<"$out"; then
|
||||
check C10 0 ""
|
||||
else
|
||||
check C10 1 "rc=$rc out=$(printf '%s' "$out" | tail -n 2 | tr '\n' ' ')"
|
||||
@@ -283,8 +283,8 @@ out="$(WAKE_ASSERT_LEDGER="$LEDGER" bash "$TMP/mini-c.sh" "$TMP" 2>&1)"
|
||||
rc=$?
|
||||
summary_ln="$(site_line "$TMP/mini-c.sh" c-summary)"
|
||||
if [ "$rc" -eq 1 ] &&
|
||||
printf '%s' "$out" | grep -q 'wake mini-c harness: FAILED (1 assertion(s))' &&
|
||||
! printf '%s' "$out" | grep -q 'all invariants passed' &&
|
||||
grep -q 'wake mini-c harness: FAILED (1 assertion(s))' <<<"$out"&&
|
||||
! grep -q 'all invariants passed' <<<"$out"&&
|
||||
grep -q "^count_lines mini-c.sh:${summary_ln}\$" "$LEDGER"; then
|
||||
check C11 0 ""
|
||||
else
|
||||
|
||||
@@ -107,7 +107,7 @@ if cmp -s "$TMP/expected.txt" "$TMP/static.txt"; then
|
||||
echo "STATIC-INVENTORY equals expected set ($(grep -c . "$TMP/static.txt") rows from source text)"
|
||||
else
|
||||
flag "static inventory (source text) differs from expected set (artifact):"
|
||||
diff "$TMP/expected.txt" "$TMP/static.txt" | head -n 20 | sed 's/^/ /'
|
||||
diff "$TMP/expected.txt" "$TMP/static.txt" | sed -n '1,20p' | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
# --- 3: green instrumented run ----------------------------------------------
|
||||
@@ -116,7 +116,7 @@ LEDGER="$TMP/ledger"
|
||||
for s in "${SUITES[@]}"; do
|
||||
out="$(WAKE_ASSERT_LEDGER="$LEDGER" bash "$WAKE/$s" 2>&1)"
|
||||
rc=$?
|
||||
if printf '%s\n' "$out" | grep -Eq "$(sentinel_for "$s")"; then
|
||||
if grep -Eq "$(sentinel_for "$s")" <<<"$out"; then
|
||||
sent="present"
|
||||
else
|
||||
sent="ABSENT"
|
||||
@@ -177,14 +177,14 @@ while read -r helper site form; do
|
||||
rc=$?
|
||||
bad=""
|
||||
[ "$rc" -ne 0 ] || bad="$bad exit=0"
|
||||
printf '%s\n' "$out" | grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" ||
|
||||
grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" <<<"$out"||
|
||||
bad="$bad no-ARMED-line"
|
||||
printf '%s\n' "$out" | grep -q "WAKE-ASSERT ABORT: ${helper} at ${site}: grep exit" ||
|
||||
grep -q "WAKE-ASSERT ABORT: ${helper} at ${site}: grep exit" <<<"$out"||
|
||||
bad="$bad no-ABORT-line"
|
||||
# AND-polarity check (a match is the defect): a grep error (rc>=2) must be
|
||||
# its own loud arm — it cannot fall through as "no sentinel = pass".
|
||||
rc_sent=0
|
||||
printf '%s\n' "$out" | grep -Eq "$(sentinel_for "$f")" || rc_sent=$?
|
||||
grep -Eq "$(sentinel_for "$f")" <<<"$out"|| rc_sent=$?
|
||||
case "$rc_sent" in
|
||||
0) bad="$bad sentinel-emitted" ;;
|
||||
1) : ;;
|
||||
|
||||
@@ -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 && bash framework/tools/fleet/test-start-agent-session.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/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/framework_skill_portability_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-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.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-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/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"
|
||||
"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/tools/fleet/test-start-agent-session.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/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/framework_skill_portability_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-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-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/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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
[
|
||||
"packages/mosaic/framework/tools/wake/test-wake-digest-quarantine.sh:fixture_line=\"$(has_match -F \"\\\"id\\\":\\\"$enum_id\\\"\" \"$self\" | has_match -F '\"observed_seq\":5' | head -n1)\"",
|
||||
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:pre_seq=\"$(jq -r 'select(.locators.kind == \"preimage\") | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
|
||||
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:src_seq=\"$(jq -r 'select(.locators.kind == \"repo\") | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
|
||||
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:pre_seq=\"$(jq -r 'select(.locators.kind == \"preimage\") | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
|
||||
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:enum_seq=\"$(jq -r 'select(.locators.reconciled == true) | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:if ! sed -n \"${ln}p\" \"$f\" | grep -Eq 'has_match|count_lines'; then",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$outA\" | grep -q 'mini-a: OK' &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$outB\" | grep -q 'mini-b: OK' &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:check C1 1 \"rcA=$rcA rcB=$rcB expected($n_expected)/got diff: $(diff \"$TMP/expected-c1\" \"$TMP/got-c1\" 2>&1 | head -n 10 | tr '\\n' ' ')\"",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'mini-a: OK' &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'mini-a: FAILED' &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"WAKE-ASSERT ARMED: forcing real grep error at $site\" &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"WAKE-ASSERT ABORT\" &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"$site\" &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"grep exit 2\" &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q 'mini-a: OK' &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'WAKE-ASSERT ARMED'; then",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'REACHED-PAST-INIT' &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q 'WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated'; then",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q 'wake mini-c harness: FAILED (1 assertion(s))' &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'all invariants passed' &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:diff \"$TMP/expected.txt\" \"$TMP/static.txt\" | head -n 20 | sed 's/^/ /'",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:if printf '%s\\n' \"$out\" | grep -Eq \"$(sentinel_for \"$s\")\"; then",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:printf '%s\\n' \"$out\" | grep -q \"WAKE-ASSERT ARMED: forcing real grep error at $site\" ||",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:printf '%s\\n' \"$out\" | grep -q \"WAKE-ASSERT ABORT: ${helper} at ${site}: grep exit\" ||",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:printf '%s\\n' \"$out\" | grep -Eq \"$(sentinel_for \"$f\")\" || rc_sent=$?"
|
||||
]
|
||||
@@ -8,6 +8,7 @@ import test from 'node:test';
|
||||
const ROOT = new URL('../', import.meta.url);
|
||||
const EXPECTED_BASELINE_SITES = 26;
|
||||
const EXPECTED_TEST_BASELINE_SITES = 22;
|
||||
const EXPECTED_WAKE_BASELINE_SITES = 26;
|
||||
const TARGETS = [
|
||||
'tools/matrix-presence-harness/run.sh',
|
||||
'tools/e2e-install-test.sh',
|
||||
@@ -37,6 +38,10 @@ const TARGETS = [
|
||||
'packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh',
|
||||
'packages/mosaic/framework/tools/tmux/test-send-message-socket.sh',
|
||||
'packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh',
|
||||
'packages/mosaic/framework/tools/wake/test-wake-digest-quarantine.sh',
|
||||
'packages/mosaic/framework/tools/wake/test-wake-preimage.sh',
|
||||
'packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh',
|
||||
'packages/mosaic/framework/tools/wake/validate-973/validate-973.sh',
|
||||
];
|
||||
|
||||
// These statuses are explicitly non-load-bearing or unreachable at designed input.
|
||||
@@ -106,6 +111,14 @@ test('the registered test baseline denominator is exactly 22 unsafe sites', asyn
|
||||
);
|
||||
});
|
||||
|
||||
test('the registered wake baseline denominator is exactly 26 unsafe sites', async () => {
|
||||
await assertBaselineFixture(
|
||||
'scripts/fixtures/pipefail-early-exit-wake-baseline.json',
|
||||
EXPECTED_WAKE_BASELINE_SITES,
|
||||
25,
|
||||
);
|
||||
});
|
||||
|
||||
test('load-bearing pipefail paths do not pipe into early-exiting consumers', async () => {
|
||||
assert.deepEqual(scan(await currentSources()), []);
|
||||
});
|
||||
@@ -138,6 +151,40 @@ test('gateway verify capability preserves the complete help-probe truth table',
|
||||
}
|
||||
});
|
||||
|
||||
test('wake JSONL selectors take the first match across the complete input stream', async () => {
|
||||
const source = await readFile(
|
||||
new URL('packages/mosaic/framework/tools/wake/test-wake-preimage.sh', ROOT),
|
||||
'utf8',
|
||||
);
|
||||
assert.equal((source.match(/jq -nr 'first\(inputs \| select\(/g) ?? []).length, 4);
|
||||
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'wake-jsonl-first-'));
|
||||
const input = path.join(directory, 'pending.jsonl');
|
||||
const filter = 'first(inputs | select(.locators.kind == "preimage") | .observed_seq) // empty';
|
||||
try {
|
||||
await writeFile(
|
||||
input,
|
||||
'{"locators":{"kind":"repo"},"observed_seq":1}\n' +
|
||||
'{"locators":{"kind":"preimage"},"observed_seq":4}\n' +
|
||||
'{"locators":{"kind":"preimage"},"observed_seq":9}\n',
|
||||
);
|
||||
let result = spawnSync('jq', ['-nr', filter, input], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, '4\n');
|
||||
|
||||
await writeFile(input, '{"locators":{"kind":"repo"},"observed_seq":1}\n');
|
||||
result = spawnSync('jq', ['-nr', filter, input], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, '');
|
||||
|
||||
await writeFile(input, '{invalid json}\n');
|
||||
result = spawnSync('jq', ['-nr', filter, input], { encoding: 'utf8' });
|
||||
assert.notEqual(result.status, 0);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('board-history preserves non-git data-dir as a non-detectable result', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'reflect-board-non-git-'));
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user