From 4904d4553ca395a03823c0a24c243da78aa0b0c4 Mon Sep 17 00:00:00 2001 From: be-coder-05 Date: Wed, 5 Aug 2026 10:31:25 -0500 Subject: [PATCH 01/15] test(installer): preregister P0-P9 greenfield RED --- docs/PRD.md | 33 ++ .../1050-install-state-machine-red-fixture.md | 62 +++ tools/e2e-install-test.sh | 417 +++++++++++------- tools/install-state-machine.test.sh | 125 ++++++ 4 files changed, 474 insertions(+), 163 deletions(-) create mode 100644 docs/scratchpads/1050-install-state-machine-red-fixture.md create mode 100755 tools/install-state-machine.test.sh diff --git a/docs/PRD.md b/docs/PRD.md index 77ccd609..e3e8c606 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1368,3 +1368,36 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release. 10. ASSUMPTION: **Conversations and messages get their own PG tables** (not stored in brain's entity model). They follow a chat-specific schema with proper foreign keys to users and projects. Rationale: Chat has different access patterns (streaming, pagination, search) than brain entities. 11. RESOLVED: **Pi handles all target LLM providers natively.** Anthropic, OpenAI/Codex, Z.ai, Ollama, LM Studio, and llama.cpp are all supported via Pi's built-in providers or `models.json` configuration with `openai-completions` API type. No custom provider adapters needed in @mosaicstack/agent — only configuration management. + +--- + +## Greenfield install correctness — C1 (#1050) + +### Problem and objective + +A from-zero install can report success while leaving the target host unusable because the installer has no transactional state machine capable of certifying its own postconditions. C1 supplies the structural spine and red-first fixture; later cards repair the individual failed postconditions. + +### Normative requirements + +1. The installer SHALL implement the canonical P0–P9 numbering from the greenfield-install PRD v2: P0 Resolve context; P1 Preflight; P2 Acquire artifacts; P3 Install CLI; P4 Install framework + skills; P5 Identity; P6 Runtime linking / activation; P7 Services; P8 Shell discoverability; P9 Verify + commit. +2. Every phase SHALL declare preconditions, action, committed postconditions, and rollback. An unverifiable postcondition SHALL fail the install non-zero with the named phase and a remediation line; no best-effort failure may still certify success. P1's required-tool closure includes tools invoked by later phases, including `git`; a downstream prerequisite may not remain undeclared and degrade silently. +3. A durable mutation journal SHALL open before the first mutation and commit at P9. Fallible command output needed to diagnose a phase SHALL be journaled and surfaced, never discarded. +4. `--check` SHALL run exactly the P0–P8 postcondition predicates without mutation, report each phase PASS/FAIL, and exit non-zero if any predicate fails. +5. P4 SHALL consume a checkout-free, lane/versioned shipped-set declaration published by the installer. C1 SHALL NOT select among the currently disagreeing framework-payload, repository-root, sync-source, and W-jarvis populations; while no declaration exists, P4 reports `NOT-MEASURED / UNDECLARED` and remains blocking rather than fabricating a count. C5 owns the declaration's contents and containment/loadability fulfillment. +6. The from-zero fixture SHALL be lane-parametric, use Debian/glibc, run the documented install command as a non-root target user with an isolated HOME, and inherit no host credentials, npm cache, home directory, or runtime configuration. +7. The fixture SHALL select `next` with `--next` or `MOSAIC_NEXT=1` and assert the resolved lane version. Internal predicates use P3's absolute CLI path; shell discoverability is tested only at P8. +8. Fault injection after each P2–P8 phase SHALL prove either clean rollback or a durable, honestly reported resumable partial state, with no journal incorrectly left in progress. +9. Unsupported musl/Alpine and unavailable Docker SHALL fail loudly rather than skip as pass. + +### C1 acceptance criteria + +1. The pre-C1 from-zero matrix records both discriminating controls: with `git` absent, the legacy installer still exits zero while P1 fails and skill sync degrades; with `git` present, P1 passes and the observed sync store/runtime links are 101/101. The C1 installer must fail at P1 before mutation when `git` is absent. +2. The discriminating P3 row passes: the binary exists at the expected absolute path and reports exactly the resolved `next` lane version, while P4, P5, and P8 fail. +3. The `--check` mutation negative control proves host fingerprints are byte-identical before and after observation. +4. Woodpecker executes and validates the expected RED fixture; C1 does not repair P4/P5/P8 or activate #869. + +### Explicit exclusions and dependencies + +- C2 owns P8/PATH, C3 owns P5/headless identity, C4 owns P6 activation policy, and C5 owns P4/skills. +- Main-lane execution is a promotion precondition owned by #1037; C1 only makes the fixture lane-parametric. +- RM-02 and #869 activation are out of scope. diff --git a/docs/scratchpads/1050-install-state-machine-red-fixture.md b/docs/scratchpads/1050-install-state-machine-red-fixture.md new file mode 100644 index 00000000..33f42fe9 --- /dev/null +++ b/docs/scratchpads/1050-install-state-machine-red-fixture.md @@ -0,0 +1,62 @@ +# #1050 — Installer P0–P9 state machine and red-first fixture + +## Objective + +Implement C1 from the canonical greenfield-install PRD v2: a transactional P0–P9 installer spine, a side-effect-free P0–P8 `--check`, and a lane-parametric Debian/glibc non-root from-zero fixture. The acceptance milestone is an attributable RED on the pre-C1 installer while preserving P3 PASS. + +## Authority and scope + +- Canonical requirements: `jason.woltje/jarvis-brain` `docs/plans/2026-08-04-greenfield-install-blockers-PRD-v2.md`, read from local `origin/main` object `b2b6ed41f5aff5ea964e69b7c701cb45718742fa`; remote currency is **unestablished** because authenticated fetch returned repository-not-found. +- Tracking: `mosaicstack/stack#1050` on `git.mosaicstack.dev` (author read back as `be-coder-05`). +- Base: `origin/next` `4df478cdd150fdf8d52ea109f02ade5d85017acd`. +- Out of scope: PATH, skills, headless wizard/identity, activation remediation, #869 wiring, RM-02, main promotion. +- `docs/TASKS.md` is orchestrator-single-writer and is not modified by this worker. + +## Plan + +1. Pre-register the canonical phase/output/side-effect-free/fault-injection checks and observe RED against the base installer. +2. Commit the immutable red-first acceptance fixture before implementation. +3. Add the state-machine/journal/postcondition spine without repairing P4/P5/P8 symptoms. +4. Wire the expected-RED from-zero fixture into Woodpecker using Debian/glibc and a non-root target user. +5. Run shell/static baselines, situational container validation, code review, security review, then deliver through a PR to `next` under the coordinator-owned merge path. + +## Budget + +- Working estimate: 32K reasoning/output tokens. +- Hard external cap: none stated. +- Adaptation: keep implementation in shell surfaces already in scope; no package dependency install unless repository gates require it. + +## Pre-registered acceptance checks + +| ID | Exact case | Expected pre-fix result | +|---|---|---| +| C1-R1 | `tools/e2e-install-test.sh --lane next` in a clean Debian 12 container as uid 1001 | non-zero; P3 PASS; P4 `NOT-MEASURED / UNDECLARED`; P5/P6/P8 FAIL with own reasons | +| C1-R2 | `tools/install-state-machine.test.sh` phase table case | RED because base installer does not enumerate canonical P0–P9 contracts | +| C1-R3 | side-effect-free `--check` case over a fingerprinted HOME | RED because base `--check` is version-only rather than P0–P8 predicates | +| C1-R4 | fault injection after each P2…P8 | RED because base installer has no injectable durable journal/rollback state | +| C1-R5 | Docker unavailable | base harness incorrectly exits 0; replacement must fail non-zero | +| C1-R6 | lane resolution | bare checkout is forbidden; fixture must pass `--next` and assert the resolved prerelease version | +| C1-R7 | same Debian fixture with `git` absent vs present | absent: P1 FAIL while legacy installer exits 0 and sync degrades; present: P1 PASS and observed store/runtime containment 101/101 | + +## Progress + +- [x] Charter, doctrine, delivery/CI/QA/docs guides read. +- [x] Canonical PRD v2 and charters read from local origin object; numbering reconciles with the TL spec. No numbering conflict found. TL additions (early durable journal and INV-C) are additive, not contradictory. +- [x] Target base reachability verified with `merge-base --is-ancestor`. +- [x] Issue #1050 created and provider author read back. +- [x] Initial RED captured; TL rejected P4's repo-root count as a false RED. Four populations disagree (framework payload 1, repo root 13, sync store 101 in the fixture, W-jarvis observation 7), so C1 now requires a checkout-free declared shipped-set artifact and reports P4 `NOT-MEASURED / UNDECLARED` until C5 supplies it. +- [x] P6 strengthens #869: the two dead enforcement hooks reproduce from zero on a clean broker-less container. C1 asserts the breach but neither wires nor unwires it. +- [x] P1 false pass identified from the P4 evidence row: `git` is absent from the Debian base and was undeclared even though skill sync shells out to it. C1 adds `git` to P1; the fixture matrix preserves absent/present controls. The prior claim that web1's missing runtime skills reproduce this greenfield mechanism is withdrawn by the TL and is not carried here. +- [ ] Corrected RED transcript captured and reported. +- [ ] State machine implemented. +- [ ] Reviews complete. + +## Risks / blockers + +- The deployed create wrappers do not expose `--dry-run`; identity preflight was performed through `pr-merge.sh --dry-run` on the same HOMELAB repo, which resolved `git.mosaicstack.dev` + `be-coder-05`. The issue create then fell back from tea to the API but provider read-back confirmed author `be-coder-05`. +- `next` is an integration lane; `main` promotion remains #1037-owned. +- #869 must remain staged and inactive. + +## Verification log + +(To be updated with exact commands and resulting objects.) diff --git a/tools/e2e-install-test.sh b/tools/e2e-install-test.sh index 672103b2..9f64eca8 100755 --- a/tools/e2e-install-test.sh +++ b/tools/e2e-install-test.sh @@ -1,184 +1,275 @@ #!/usr/bin/env bash -# ─── Mosaic Stack — End-to-End Install Test ──────────────────────────────────── +# Greenfield installer acceptance fixture. # -# Runs a clean-container install test to verify the full first-run flow: -# tools/install.sh -> mosaic wizard (non-interactive) -# -> mosaic gateway install -# -> mosaic gateway verify -# -# Usage: -# bash tools/e2e-install-test.sh -# -# Requirements: -# - Docker (skips gracefully if not available) -# - Run from the repository root -# -# How it works: -# 1. Mounts the repository into a node:22-alpine container. -# 2. Installs prerequisites (bash, curl, jq, git) inside the container. -# 3. Runs `bash tools/install.sh --yes --no-auto-launch` to install the -# framework and CLI from the Gitea registry. -# 4. Runs `mosaic wizard --non-interactive` to set up SOUL/USER. -# 5. Runs `mosaic gateway install` with piped defaults (non-interactive). -# 6. Runs `mosaic gateway verify` and checks its exit code. -# NOTE: `mosaic gateway verify` is a new command added in the -# feat/mosaic-first-run-ux branch. If the installed CLI version -# pre-dates this branch (does not have `gateway verify`), the test -# marks this step as EXPECTED-SKIP and reports the installed version. -# 7. Reports PASS or FAIL with a summary. -# -# To run manually: -# cd /path/to/mosaic-stack -# bash tools/e2e-install-test.sh -# -# ────────────────────────────────────────────────────────────────────────────── +# The fixture itself is intentionally RED until the C2-C5 phase owners repair +# their postconditions. C1's CI gate executes it and validates that the RED is +# attributable (including the discriminating P3 PASS); it does not turn the +# failed install into a false green. set -euo pipefail -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -IMAGE="node:22-alpine" -CONTAINER_NAME="mosaic-e2e-install-$$" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LANE="${MOSAIC_INSTALL_LANE:-next}" +SOURCE="${MOSAIC_INSTALL_SOURCE:-checkout}" +IMAGE="${MOSAIC_INSTALL_IMAGE:-node:22-bookworm-slim}" +GIT_MODE="${MOSAIC_INSTALL_GIT_MODE:-present}" +INSTALLER_FILE="${MOSAIC_FIXTURE_INSTALLER_FILE:-$ROOT/tools/install.sh}" -# ─── Colour helpers ─────────────────────────────────────────────────────────── -if [[ -t 1 ]]; then - R=$'\033[0;31m' G=$'\033[0;32m' Y=$'\033[0;33m' BOLD=$'\033[1m' RESET=$'\033[0m' -else - R="" G="" Y="" BOLD="" RESET="" -fi +usage() { + cat <<'EOF' +Usage: tools/e2e-install-test.sh [--lane next|main] [--source checkout|remote] [--git present|absent] -info() { echo "${BOLD}[e2e]${RESET} $*"; } -ok() { echo "${G}[PASS]${RESET} $*"; } -fail() { echo "${R}[FAIL]${RESET} $*" >&2; } -warn() { echo "${Y}[WARN]${RESET} $*"; } - -# ─── Docker availability check ──────────────────────────────────────────────── -if ! command -v docker &>/dev/null; then - warn "Docker not found — skipping e2e install test." - warn "Install Docker and re-run this script to exercise the full install flow." - exit 0 -fi - -if ! docker info &>/dev/null 2>&1; then - warn "Docker daemon is not running or not accessible — skipping e2e install test." - exit 0 -fi - -info "Docker available — proceeding with e2e install test." -info "Repo root: ${REPO_ROOT}" -info "Container image: ${IMAGE}" - -# ─── Inline script that runs INSIDE the container ──────────────────────────── -INNER_SCRIPT="$(mktemp /tmp/mosaic-e2e-inner-XXXXXX.sh)" -trap 'rm -f "$INNER_SCRIPT"' EXIT - -cat > "$INNER_SCRIPT" <<'INNER_SCRIPT_EOF' -#!/bin/sh -# Bootstrap: /bin/sh until bash is installed, then re-exec. -set -e - -echo "=== [inner] Installing system prerequisites ===" -apk add --no-cache bash curl jq git 2>/dev/null || \ - apt-get install -y -q bash curl jq git 2>/dev/null || true - -# Re-exec under bash. -if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1; then - exec bash "$0" "$@" -fi - -# ── bash from here ──────────────────────────────────────────────────────────── -set -euo pipefail - -echo "=== [inner] Node.js / npm versions ===" -node --version -npm --version - -echo "=== [inner] Setting up npm global prefix ===" -export NPM_PREFIX="/root/.npm-global" -mkdir -p "$NPM_PREFIX/bin" -npm config set prefix "$NPM_PREFIX" 2>/dev/null || true -export PATH="$NPM_PREFIX/bin:$PATH" - -echo "=== [inner] Running install.sh --yes --no-auto-launch ===" -# Install both framework and CLI from the Gitea registry. -MOSAIC_SKIP_SKILLS_SYNC=1 \ -MOSAIC_ASSUME_YES=1 \ - bash /repo/tools/install.sh --yes --no-auto-launch - -INSTALLED_VERSION="$(mosaic --version 2>/dev/null || echo 'unknown')" -echo "[inner] mosaic CLI installed: ${INSTALLED_VERSION}" - -echo "=== [inner] Running mosaic wizard (non-interactive) ===" -mosaic wizard \ - --non-interactive \ - --name "test-agent" \ - --user-name "tester" \ - --pronouns "they/them" \ - --timezone "UTC" || { - echo "[WARN] mosaic wizard exited non-zero — continuing" +Runs the documented installer command from zero in Debian/glibc as a non-root +uid with an isolated HOME. The fixture exits non-zero when any P0-P8 +postcondition fails. `next` is always selected with the --next installer flag. +EOF } -echo "=== [inner] Running mosaic gateway install ===" -# Feed non-interactive answers: -# "1" → storage tier: local -# "" → port: accept default (14242) -# "" → ANTHROPIC_API_KEY: skip -# "" → CORS origin: accept default -# Then admin bootstrap: name, email, password -printf '1\n\n\n\nTest Admin\ntest@example.com\ntestpassword123\n' \ - | mosaic gateway install -INSTALL_EXIT="$?" -if [ "${INSTALL_EXIT}" -ne 0 ]; then - echo "[ERR] mosaic gateway install exited ${INSTALL_EXIT}" - mosaic gateway status 2>/dev/null || true - exit "${INSTALL_EXIT}" +while [[ $# -gt 0 ]]; do + case "$1" in + --lane) LANE="${2:-}"; shift 2 ;; + --source) SOURCE="${2:-}"; shift 2 ;; + --git) GIT_MODE="${2:-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "[fixture] unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +case "$LANE" in next|main) ;; *) echo "[fixture] unsupported lane '$LANE' (expected next|main)" >&2; exit 2 ;; esac +case "$SOURCE" in checkout|remote) ;; *) echo "[fixture] unsupported source '$SOURCE' (expected checkout|remote)" >&2; exit 2 ;; esac +case "$GIT_MODE" in present|absent) ;; *) echo "[fixture] unsupported git mode '$GIT_MODE' (expected present|absent)" >&2; exit 2 ;; esac + +if ! command -v docker >/dev/null 2>&1; then + echo "[fixture] FAIL: Docker is required; greenfield validation was NOT RUN." >&2 + exit 2 +fi +if ! docker info >/dev/null 2>&1; then + echo "[fixture] FAIL: Docker daemon is unavailable; greenfield validation was NOT RUN." >&2 + exit 2 fi -echo "=== [inner] Running mosaic gateway verify ===" -# `gateway verify` was added in feat/mosaic-first-run-ux. -# If the installed version pre-dates this, skip gracefully. -if ! mosaic gateway --help 2>&1 | grep -q 'verify'; then - echo "[SKIP] 'mosaic gateway verify' not available in installed version ${INSTALLED_VERSION}." - echo "[SKIP] This command was added in the feat/mosaic-first-run-ux release." - echo "[SKIP] Re-run after the new version is published to validate this step." - # Treat as pass — the install flow itself worked. - exit 0 +installer_b64="" +framework_payload_count="NOT-MEASURED" +repo_root_count="NOT-MEASURED" +if [[ "$SOURCE" == "checkout" ]]; then + installer_b64="$(base64 -w0 "$INSTALLER_FILE")" + [[ -d "$ROOT/packages/mosaic/framework/skills" ]] \ + && framework_payload_count="$(find "$ROOT/packages/mosaic/framework/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" + [[ -d "$ROOT/skills" ]] \ + && repo_root_count="$(find "$ROOT/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" fi -mosaic gateway verify -VERIFY_EXIT="$?" -echo "=== [inner] verify exit code: ${VERIFY_EXIT} ===" -exit "${VERIFY_EXIT}" -INNER_SCRIPT_EOF +inner="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-inner.XXXXXX.sh")" +trap 'rm -f "$inner"' EXIT +cat > "$inner" <<'INNER' +#!/usr/bin/env bash +set -euo pipefail -chmod +x "$INNER_SCRIPT" +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +packages=(bash ca-certificates curl jq passwd util-linux) +[[ "$FIXTURE_GIT_MODE" == "present" ]] && packages+=(git) +apt-get install -y -qq "${packages[@]}" >/dev/null -# ─── Pull image ─────────────────────────────────────────────────────────────── -info "Pulling ${IMAGE}…" -docker pull "${IMAGE}" --quiet +useradd --create-home --uid 1001 --shell /bin/bash mosaic +install -d -o mosaic -g mosaic /home/mosaic/work -# ─── Run container ──────────────────────────────────────────────────────────── -info "Starting container ${CONTAINER_NAME}…" +case "$FIXTURE_SOURCE" in + checkout) + printf '%s' "$FIXTURE_INSTALLER_B64" | base64 -d > /tmp/install.sh + ;; + remote) + curl -fsSL "https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/${FIXTURE_LANE}/tools/install.sh" > /tmp/install.sh + ;; +esac +chmod 0755 /tmp/install.sh +sha256sum /tmp/install.sh | sed 's/^/[fixture] installer sha256: /' -EXIT_CODE=0 -docker run --rm \ - --name "${CONTAINER_NAME}" \ - --volume "${REPO_ROOT}:/repo:ro" \ - --volume "${INNER_SCRIPT}:/e2e-inner.sh:ro" \ - --network host \ - "${IMAGE}" \ - /bin/sh /e2e-inner.sh \ - || EXIT_CODE=$? +cat > /tmp/run-as-target.sh <<'TARGET' +#!/usr/bin/env bash +set -uo pipefail -# ─── Report ─────────────────────────────────────────────────────────────────── -echo "" -if [[ "$EXIT_CODE" -eq 0 ]]; then - ok "End-to-end install test PASSED (exit ${EXIT_CODE})" +lane="$FIXTURE_LANE" +home="$HOME" +prefix="$home/.npm-global" +mosaic_home="$home/.config/mosaic" +install_log="$home/install.log" +failures=0 + +phase_pass() { printf '[%s] PASS: %s\n' "$1" "$2"; } +phase_fail() { printf '[%s] FAIL: %s\n' "$1" "$2"; failures=$((failures + 1)); } + +lane_args=() +resolved_spec='@mosaicstack/mosaic' +if [[ "$lane" == "next" ]]; then + lane_args+=(--next) + resolved_spec='@mosaicstack/mosaic@next' +fi + +resolved_version="$(npm view "$resolved_spec" version --registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ 2>/dev/null || true)" +printf '[fixture] resolved lane=%s package=%s version=%s\n' "$lane" "$resolved_spec" "${resolved_version:-UNRESOLVED}" + +set +e +MOSAIC_NO_COLOR=1 MOSAIC_ASSUME_YES=1 \ + bash /tmp/install.sh "${lane_args[@]}" --yes --no-auto-launch >"$install_log" 2>&1 +install_status=$? +set -e +cat "$install_log" +printf '[fixture] installer_exit=%d done_claims=%s\n' \ + "$install_status" "$(grep -cF 'Done.' "$install_log" || true)" + +# P0 Resolve context +shell="$(getent passwd "$(id -u)" | cut -d: -f7)" +if [[ "$(id -u)" -ne 0 && "$home" == "/home/mosaic" && "$shell" == "/bin/bash" ]] \ + && ldd --version 2>&1 | grep -qi 'glibc\|gnu libc' \ + && [[ "$(node -p 'Number(process.versions.node.split(".")[0])')" -ge 20 ]]; then + phase_pass P0 "target=mosaic uid=$(id -u) HOME=$home shell=$shell libc=glibc node=$(node --version)" else - fail "End-to-end install test FAILED (exit ${EXIT_CODE})" - echo "" - echo " Troubleshooting:" - echo " - Review the output above for the failing step." - echo " - Re-run with bash -x tools/e2e-install-test.sh for verbose trace." - echo " - Run mosaic gateway logs inside a manual container for daemon output." + phase_fail P0 "context unresolved or unsupported (uid=$(id -u) HOME=$home shell=${shell:-unknown})" +fi + +# P1 Preflight +missing_tools=() +for tool in bash curl git node npm tar; do + command -v "$tool" >/dev/null 2>&1 || missing_tools+=("$tool") +done +if [[ "${#missing_tools[@]}" -eq 0 && -n "$resolved_version" && -w "$home" ]]; then + phase_pass P1 "required tools present (including downstream git); target HOME writable; registry lane resolved" +else + phase_fail P1 "undeclared/missing prerequisite(s)=${missing_tools[*]:-none}; target_writable=$([[ -w "$home" ]] && echo yes || echo no) registry_resolved=$([[ -n "$resolved_version" ]] && echo yes || echo no)" +fi + +# P2 Acquire artifacts +if [[ -n "$resolved_version" ]] && grep -qF "$resolved_version" "$install_log"; then + phase_pass P2 "lane=$lane pinned_version=$resolved_version recorded in installer transcript" +else + phase_fail P2 "lane=$lane did not resolve and record a pinned artifact version" +fi + +# P3 Install CLI — the discriminating row. Use the known absolute path only. +cli="$prefix/bin/mosaic" +cli_version="" +if [[ -x "$cli" ]]; then + cli_version="$($cli --version 2>/dev/null | tail -n 1 | tr -d '\r' || true)" +fi +if [[ -x "$cli" && "$cli_version" == "$resolved_version" ]]; then + phase_pass P3 "absolute_path=$cli version=$cli_version equals resolved lane version" +else + phase_fail P3 "absolute_path=$cli executable=$([[ -x "$cli" ]] && echo yes || echo no) got=${cli_version:-missing} expected=${resolved_version:-unresolved}" +fi + +# P4 Framework + skills. C1 does not choose among the four disagreeing +# candidate populations. It requires the installer to publish a lane/versioned +# shipped-set declaration that a checkout-free install can resolve; C5 owns its +# contents. Without that artifact P4 is NOT-MEASURED, never a fabricated count. +declared_set="$mosaic_home/.install-shipped-skills.json" +sync_store_count=0 +runtime_link_count=0 +[[ -d "$mosaic_home/skills" ]] \ + && sync_store_count="$(find "$mosaic_home/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" +[[ -d "$home/.pi/agent/skills" ]] \ + && runtime_link_count="$(find "$home/.pi/agent/skills" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) | wc -l | tr -d ' ')" +printf '[P4-EVIDENCE] candidate_populations framework_payload=%s repo_root=%s sync_store=%s jarvis_W-jarvis_observation=7 runtime_links=%s\n' \ + "$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" "$FIXTURE_REPO_ROOT_COUNT" "$sync_store_count" "$runtime_link_count" +if [[ ! -s "$declared_set" ]]; then + phase_fail P4 "NOT-MEASURED / UNDECLARED: installer published no checkout-free, lane/versioned shipped-set artifact at $declared_set" +elif node - "$declared_set" <<'NODE' +const fs = require('fs'); +const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); +if (!data || typeof data !== 'object' || !['latest', 'next'].includes(data.lane) || + typeof data.version !== 'string' || !data.version || !Array.isArray(data.skills) || data.skills.length === 0 || + data.skills.some((name) => typeof name !== 'string' || !name)) process.exit(1); +NODE +then + declared_count="$(node -p "require('$declared_set').skills.length")" + phase_pass P4 "declared shipped-set artifact parses (declared_count=$declared_count); C5 owns containment/loadability fulfillment" +else + phase_fail P4 "NOT-MEASURED / UNDECLARED: shipped-set artifact exists but is empty, malformed, or lacks lane/version" +fi + +# P5 Identity +identity_ok=true +identity_reason=() +for f in SOUL.md USER.md; do + path="$mosaic_home/$f" + if [[ ! -s "$path" ]]; then + identity_ok=false; identity_reason+=("$f missing-or-empty"); continue + fi + owner="$(stat -c '%u' "$path")"; mode="$(stat -c '%a' "$path")" + if [[ "$owner" != "$(id -u)" || "$mode" =~ [2367]$ ]]; then + identity_ok=false; identity_reason+=("$f owner=$owner mode=$mode") + fi +done +if [[ "$identity_ok" == true ]]; then + phase_pass P5 "SOUL.md and USER.md are non-empty and target-user owned with non-world-writable modes" +else + phase_fail P5 "${identity_reason[*]}" +fi + +# P6 Runtime linking / activation. #869 must remain unwired without its broker. +broker_present=false +[[ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/mosaic-lease/broker.sock" ]] && broker_present=true +dead_hooks=0 +if [[ -f "$home/.claude/settings.json" ]]; then + dead_hooks="$(grep -Ec 'mutator-gate\.py|receipt-observer-client\.py' "$home/.claude/settings.json" || true)" +fi +if [[ "$broker_present" == false && "$dead_hooks" -eq 0 ]]; then + phase_pass P6 "broker absent and #869 enforcement hooks remain inactive" +elif [[ "$broker_present" == true ]]; then + phase_pass P6 "activation broker present; hook state is evaluable" +else + phase_fail P6 "broker absent but dead enforcement hooks are active (count=$dead_hooks)" +fi + +# P7 Services — none requested by --no-auto-launch. +phase_pass P7 "no services requested by this fixture" + +# P8 Shell discoverability — actual target shell, fresh login and non-login. +base_env=(env -i HOME="$home" USER=mosaic LOGNAME=mosaic SHELL=/bin/bash PATH=/usr/local/bin:/usr/bin:/bin) +login_path="$("${base_env[@]}" /bin/bash -lc 'command -v mosaic' 2>/dev/null || true)" +nonlogin_path="$("${base_env[@]}" /bin/bash -c 'command -v mosaic' 2>/dev/null || true)" +if [[ "$login_path" == "$cli" && "$nonlogin_path" == "$cli" ]]; then + phase_pass P8 "login=$login_path nonlogin=$nonlogin_path equals P3 path" +else + phase_fail P8 "fresh bash login=${login_path:-missing} nonlogin=${nonlogin_path:-missing} expected=$cli" +fi + +manifest="$mosaic_home/.install-manifest.json" +p0_p8_failures="$failures" +if [[ "$p0_p8_failures" -eq 0 && -s "$manifest" ]]; then + phase_pass P9 "P0-P8 reasserted; manifest present" +else + phase_fail P9 "P0-P8_failed_postconditions=$p0_p8_failures manifest=$([[ -s "$manifest" ]] && echo present || echo missing); install must not certify success" +fi + +printf '[fixture] P0-P9_failed_rows=%d (includes P9 aggregate row)\n' "$failures" +if [[ "$failures" -ne 0 ]]; then exit 1 fi +TARGET +chmod 0755 /tmp/run-as-target.sh +chown mosaic:mosaic /tmp/run-as-target.sh + +exec runuser -u mosaic -- env -i \ + HOME=/home/mosaic USER=mosaic LOGNAME=mosaic SHELL=/bin/bash \ + PATH=/usr/local/bin:/usr/bin:/bin \ + FIXTURE_LANE="$FIXTURE_LANE" \ + FIXTURE_GIT_MODE="$FIXTURE_GIT_MODE" \ + FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" \ + FIXTURE_REPO_ROOT_COUNT="$FIXTURE_REPO_ROOT_COUNT" \ + /bin/bash /tmp/run-as-target.sh +INNER +chmod 0755 "$inner" + +printf '[fixture] platform=Debian/glibc image=%s target_uid=1001 lane=%s source=%s git=%s\n' "$IMAGE" "$LANE" "$SOURCE" "$GIT_MODE" +printf '[fixture] host inheritance: no bind mounts, no host HOME, no npm cache, no credentials\n' + +docker run --rm -i \ + --network bridge \ + --env FIXTURE_LANE="$LANE" \ + --env FIXTURE_SOURCE="$SOURCE" \ + --env FIXTURE_GIT_MODE="$GIT_MODE" \ + --env FIXTURE_INSTALLER_B64="$installer_b64" \ + --env FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \ + --env FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \ + "$IMAGE" /bin/bash -s < "$inner" diff --git a/tools/install-state-machine.test.sh b/tools/install-state-machine.test.sh new file mode 100755 index 00000000..cd6b85b7 --- /dev/null +++ b/tools/install-state-machine.test.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Red-first acceptance checks for #1050. This file is committed before the +# installer implementation. Do not weaken these properties to make it green. + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-state-test.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT +failures=0 + +fail_case() { printf '[test] FAIL: %s\n' "$*" >&2; failures=$((failures + 1)); } +pass_case() { printf '[test] PASS: %s\n' "$*"; } + +fingerprint() { + local dir="$1" + if [[ ! -d "$dir" ]]; then printf 'ABSENT\n'; return; fi + ( + cd "$dir" || exit 1 + find . -mindepth 1 -printf '%P|%y|%m|%u|%g|%l\n' | LC_ALL=C sort + find . -type f -print0 | LC_ALL=C sort -z | xargs -0 -r sha256sum + ) | sha256sum | awk '{print $1}' +} + +make_fake_npm() { + local bin="$1" + mkdir -p "$bin" + cat > "$bin/npm" <<'FAKE' +#!/usr/bin/env bash +set -euo pipefail +case "${1:-} ${2:-} ${3:-}" in + 'view @mosaicstack/mosaic@next version') echo '0.0.50-next.999' ;; + 'view @mosaicstack/gateway@next version') echo '0.0.7-next.999' ;; + 'view @mosaicstack/mosaic version') echo '0.0.49' ;; + 'ls -g --depth=0'|'ls -g --json') echo '{"dependencies":{"@mosaicstack/mosaic":{"version":"0.0.50-next.999"},"@mosaicstack/gateway":{"version":"0.0.7-next.999"}}}' ;; + ls*) echo '{"dependencies":{"@mosaicstack/mosaic":{"version":"0.0.50-next.999"},"@mosaicstack/gateway":{"version":"0.0.7-next.999"}}}' ;; + *) echo "unexpected fake npm command: $*" >&2; exit 1 ;; +esac +FAKE + chmod 0755 "$bin/npm" +} + +printf '[test] case: --check enumerates exactly P0-P8, discriminates, and mutates nothing\n' +check_home="$TMP/check-home" +check_bin="$TMP/check-bin" +mkdir -p "$check_home/.config/mosaic/skills/alpha" "$check_home/.npm-global/bin" "$check_bin" +printf '# framework\n' > "$check_home/.config/mosaic/AGENTS.md" +printf '# skill\n' > "$check_home/.config/mosaic/skills/alpha/SKILL.md" +cat > "$check_home/.npm-global/bin/mosaic" <<'CLI' +#!/usr/bin/env bash +printf '0.0.50-next.999\n' +CLI +chmod 0755 "$check_home/.npm-global/bin/mosaic" +make_fake_npm "$check_bin" +before="$(fingerprint "$check_home")" +set +e +HOME="$check_home" MOSAIC_HOME="$check_home/.config/mosaic" MOSAIC_PREFIX="$check_home/.npm-global" \ + MOSAIC_NO_COLOR=1 PATH="$check_bin:/usr/local/bin:/usr/bin:/bin" \ + bash "$ROOT/tools/install.sh" --check --next >"$TMP/check.log" 2>&1 +check_status=$? +set -e +after="$(fingerprint "$check_home")" + +[[ "$before" == "$after" ]] && pass_case '--check left the complete HOME fingerprint unchanged' \ + || fail_case "--check mutated HOME (before=$before after=$after)" +[[ "$check_status" -ne 0 ]] && pass_case '--check exited non-zero for failed P4/P5/P8 predicates' \ + || fail_case '--check returned zero on the deliberately broken host' + +phase_rows=0 +for phase in P0 P1 P2 P3 P4 P5 P6 P7 P8; do + count="$(grep -Ec "^\[$phase\] (PASS|FAIL):" "$TMP/check.log" || true)" + [[ "$count" -eq 1 ]] || fail_case "$phase expected exactly one PASS/FAIL row, got $count" + phase_rows=$((phase_rows + count)) +done +[[ "$phase_rows" -eq 9 ]] && pass_case '--check emitted exactly nine P0-P8 result rows' \ + || fail_case "--check emitted $phase_rows canonical rows instead of 9" +grep -q '^\[P3\] PASS:.*0\.0\.50-next\.999' "$TMP/check.log" \ + && pass_case 'P3 preserves the absolute-path exact-version discriminator' \ + || fail_case 'P3 did not PASS with the exact resolved next-lane version' +grep -q '^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:' "$TMP/check.log" \ + && pass_case 'P4 refuses fabricated precision when no shipped-set declaration exists' \ + || fail_case 'P4 did not report the declared-set population as NOT-MEASURED / UNDECLARED' +for phase in P5 P8; do + grep -q "^\[$phase\] FAIL:" "$TMP/check.log" \ + && pass_case "$phase remains an attributable expected RED" \ + || fail_case "$phase did not report its own expected failure" +done + +printf '[test] case: per-phase P2-P8 fault injection restores representative host mutations\n' +for phase in P2 P3 P4 P5 P6 P7 P8; do + home="$TMP/fault-$phase/home" + state="$TMP/fault-$phase/state" + mkdir -p "$home/.config/mosaic" "$home/.npm-global/bin" "$home/.claude" "$state" + printf 'operator-framework-sentinel\n' > "$home/.config/mosaic/operator.txt" + printf '@scope:registry=https://pre.example.invalid/\n' > "$home/.npmrc" + printf 'old-cli\n' > "$home/.npm-global/bin/mosaic" + printf '{"hooks":{"safe":true}}\n' > "$home/.claude/settings.json" + before="$(fingerprint "$home")" + set +e + HOME="$home" MOSAIC_HOME="$home/.config/mosaic" MOSAIC_PREFIX="$home/.npm-global" \ + MOSAIC_INSTALL_STATE_DIR="$state" MOSAIC_INSTALL_FAULT_AFTER="$phase" \ + MOSAIC_NO_COLOR=1 bash "$ROOT/tools/install.sh" --state-machine-self-test \ + >"$TMP/fault-$phase.log" 2>&1 + status=$? + set -e + after="$(fingerprint "$home")" + [[ "$status" -ne 0 ]] || fail_case "$phase injected fault returned zero" + grep -q "phase=$phase" "$TMP/fault-$phase.log" \ + || fail_case "$phase fault transcript did not name the injected phase" + [[ "$before" == "$after" ]] \ + && pass_case "$phase rollback restored framework/npmrc/prefix/runtime representative state" \ + || fail_case "$phase rollback mismatch (before=$before after=$after)" + if find "$state" -type f -exec grep -l '"status"[[:space:]]*:[[:space:]]*"in-progress"' {} + 2>/dev/null | grep -q .; then + fail_case "$phase left a journal in-progress" + else + pass_case "$phase left no journal falsely in-progress" + fi +done + +if [[ "$failures" -ne 0 ]]; then + printf '[test] install state-machine acceptance RED: %d failed assertion(s)\n' "$failures" >&2 + printf '[test] --check transcript: %s\n' "$TMP/check.log" >&2 + exit 1 +fi +printf '[test] installer state-machine acceptance passed\n' -- 2.54.0 From 049982d30e16d491fc7296b84c4dd675b4d218b1 Mon Sep 17 00:00:00 2001 From: be-coder-05 Date: Wed, 5 Aug 2026 12:20:49 -0500 Subject: [PATCH 02/15] feat(installer): add transactional P0-P9 state machine --- .woodpecker/greenfield-install.yml | 70 + README.md | 30 +- docs/PRD.md | 2 +- docs/SITEMAP.md | 5 + docs/guides/installer-state-machine.md | 99 ++ docs/guides/upgrade-safety-and-recovery.md | 14 + .../1050-install-state-machine-red-fixture.md | 20 +- package.json | 3 +- packages/mosaic/framework/install.sh | 153 ++- tools/e2e-install-test.sh | 137 +- tools/install-next-lane.test.sh | 359 +++++ tools/install-state-machine.test.sh | 158 ++- tools/install.sh | 1209 ++++++++++++++++- 13 files changed, 2123 insertions(+), 136 deletions(-) create mode 100644 .woodpecker/greenfield-install.yml create mode 100644 docs/guides/installer-state-machine.md create mode 100755 tools/install-next-lane.test.sh diff --git a/.woodpecker/greenfield-install.yml b/.woodpecker/greenfield-install.yml new file mode 100644 index 00000000..79756585 --- /dev/null +++ b/.woodpecker/greenfield-install.yml @@ -0,0 +1,70 @@ +# C1 expected-RED gate. The fixture must execute from zero and discriminate the +# known failed postconditions; this step is green only when the fixture itself +# returns the expected non-zero and the named evidence rows are present. +when: + - event: [pull_request, manual] + - event: push + branch: [next, main] + +steps: + greenfield-git-present: + image: node:22-bookworm-slim + commands: + - | + set +e + MOSAIC_GREENFIELD_CONTAINER=1 \ + bash tools/e2e-install-test.sh --lane next --source checkout --git present \ + > /tmp/greenfield-git-present.log 2>&1 + fixture_status=$? + set -e + cat /tmp/greenfield-git-present.log + test "$fixture_status" -eq 1 + grep -Eq '^\[fixture\] resolved lane=next .*version=[0-9]+\.[0-9]+\.[0-9]+-next\.' /tmp/greenfield-git-present.log + grep -q '^\[P1\] PASS: required tools present (including downstream git)' /tmp/greenfield-git-present.log + grep -q '^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version' /tmp/greenfield-git-present.log + grep -q '^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:' /tmp/greenfield-git-present.log + grep -q '^\[P5\] FAIL:' /tmp/greenfield-git-present.log + grep -q '^\[P6\] FAIL:' /tmp/greenfield-git-present.log + grep -q '^\[P8\] FAIL:' /tmp/greenfield-git-present.log + grep -q '^\[P9\] FAIL:' /tmp/greenfield-git-present.log + + greenfield-main-git-present: + image: node:22-bookworm-slim + commands: + - | + set +e + MOSAIC_GREENFIELD_CONTAINER=1 \ + bash tools/e2e-install-test.sh --lane main --source checkout --git present \ + > /tmp/greenfield-main-git-present.log 2>&1 + fixture_status=$? + set -e + cat /tmp/greenfield-main-git-present.log + test "$fixture_status" -eq 1 + grep -Eq '^\[fixture\] resolved lane=main .*version=[0-9]+\.[0-9]+\.[0-9]+' /tmp/greenfield-main-git-present.log + grep -q '^\[P1\] PASS: required tools present (including downstream git)' /tmp/greenfield-main-git-present.log + grep -q '^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version' /tmp/greenfield-main-git-present.log + grep -q '^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:' /tmp/greenfield-main-git-present.log + grep -q '^\[P5\] FAIL:' /tmp/greenfield-main-git-present.log + grep -q '^\[P6\] FAIL:' /tmp/greenfield-main-git-present.log + grep -q '^\[P8\] FAIL:' /tmp/greenfield-main-git-present.log + grep -q '^\[P9\] FAIL:' /tmp/greenfield-main-git-present.log + + greenfield-git-absent: + image: node:22-bookworm-slim + commands: + - | + set +e + MOSAIC_GREENFIELD_CONTAINER=1 \ + bash tools/e2e-install-test.sh --lane next --source checkout --git absent \ + > /tmp/greenfield-git-absent.log 2>&1 + fixture_status=$? + set -e + cat /tmp/greenfield-git-absent.log + test "$fixture_status" -eq 1 + grep -q '^\[fixture\] installer_exit=1 done_claims=0' /tmp/greenfield-git-absent.log + grep -q '^\[P1\] FAIL: undeclared/missing prerequisite(s)=git;' /tmp/greenfield-git-absent.log + grep -q '^\[P3\] FAIL: .*executable=no' /tmp/greenfield-git-absent.log + if grep -q 'Done\.' /tmp/greenfield-git-absent.log; then + echo 'git-absent state-machine run falsely certified Done' >&2 + exit 1 + fi diff --git a/README.md b/README.md index 9bccfc60..5bd090fa 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi — ## Quick Install ```bash -curl -fsSL https://mosaicstack.dev/install.sh | bash +bash -o pipefail -c 'curl -fsSL https://mosaicstack.dev/install.sh | bash' ``` Or use the direct URL: @@ -30,6 +30,16 @@ This installs both components: | **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` | | **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` | +### Install lanes + +| Lane | Command | Use when | Source | +| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` | +| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Exact `@next` CLI/gateway versions + pinned `next` framework commit; pinned-source fallback | +| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref | + +`--next` selects the prerelease integration lane. It installs the exact CLI/gateway versions resolved from the aligned `@next` tags, and pins the framework archive to the resolved `next` commit. If the registry path fails, it builds from that pinned source. An explicit `--ref` or `MOSAIC_REF` wins and selects source mode. + After install, the wizard runs automatically or you can invoke it manually: ```bash @@ -38,10 +48,14 @@ mosaic wizard # Full guided setup (gateway install → verify) ### Requirements -- Node.js ≥ 20 -- npm (for global @mosaicstack/mosaic install) +- Linux x86_64 with glibc (Debian is the greenfield CI platform; musl/Alpine, macOS, and ARM64 currently fail as unsupported) +- Node.js ≥ 20 and npm ≥ 9 +- `bash`, `curl`, `git`, `python3`, `tar`, and standard core utilities (`awk`, `df`, `find`, `flock`, `grep`, `install`, `realpath`, `sed`, `sha256sum`, `stat`, `sync`) +- At least 256 MiB free disk and 1,000 free inodes at the npm prefix - One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent) +The installer evaluates canonical phases P0–P9 and does not print `Done.` unless every committed postcondition passes. A failed phase exits non-zero, names the phase, and points to its durable journal under `${XDG_STATE_HOME:-~/.local/state}/mosaic/install/`. See [Installer state machine and recovery](docs/guides/installer-state-machine.md). + ## Usage ### Launching Agent Sessions @@ -337,7 +351,7 @@ Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding), Run the installer again — it handles upgrades automatically: ```bash -curl -fsSL https://mosaicstack.dev/install.sh | bash +bash -o pipefail -c 'curl -fsSL https://mosaicstack.dev/install.sh | bash' ``` Or use the direct URL: @@ -358,15 +372,17 @@ The CLI also performs a background update check on every invocation (cached for ### Installer Flags ```bash -bash tools/install.sh --check # Version check only +bash tools/install.sh --check # Side-effect-free P0-P8 postcondition check bash tools/install.sh --framework # Framework only (skip npm CLI) bash tools/install.sh --cli # npm CLI only (skip framework) -bash tools/install.sh --ref v1.0 # Install from a specific git ref +bash tools/install.sh --next # Prerelease lane: exact @next versions + pinned-source fallback +bash tools/install.sh --dev # Contributor lane: source build at --ref/main +bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next) bash tools/install.sh --yes # Non-interactive, accept all defaults bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard ``` -The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage. +The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage. `--check` reports one PASS/FAIL row for each P0–P8 predicate and exits non-zero if any row fails; it does not create the npm prefix, lock, journal, manifest, or runtime files. ## Contributing diff --git a/docs/PRD.md b/docs/PRD.md index e3e8c606..b92d38ac 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1379,7 +1379,7 @@ A from-zero install can report success while leaving the target host unusable be ### Normative requirements -1. The installer SHALL implement the canonical P0–P9 numbering from the greenfield-install PRD v2: P0 Resolve context; P1 Preflight; P2 Acquire artifacts; P3 Install CLI; P4 Install framework + skills; P5 Identity; P6 Runtime linking / activation; P7 Services; P8 Shell discoverability; P9 Verify + commit. +1. The installer SHALL implement the canonical P0–P9 numbering from the greenfield-install PRD v2: P0 Resolve context; P1 Preflight; P2 Acquire artifacts; P3 Install CLI; P4 Install framework + skills; P5 Identity; P6 Runtime linking / activation; P7 Services; P8 Shell discoverability; P9 Verify + commit. P2 is scoped to installer-distribution artifacts and SHALL NOT foreclose credentialed downstream acquisition. P5 owns validating any credential capability required by requested downstream work; P7 may provision credential-dependent resources only after that P5 postcondition commits. 2. Every phase SHALL declare preconditions, action, committed postconditions, and rollback. An unverifiable postcondition SHALL fail the install non-zero with the named phase and a remediation line; no best-effort failure may still certify success. P1's required-tool closure includes tools invoked by later phases, including `git`; a downstream prerequisite may not remain undeclared and degrade silently. 3. A durable mutation journal SHALL open before the first mutation and commit at P9. Fallible command output needed to diagnose a phase SHALL be journaled and surfaced, never discarded. 4. `--check` SHALL run exactly the P0–P8 postcondition predicates without mutation, report each phase PASS/FAIL, and exit non-zero if any predicate fails. diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md index 3f5a296c..31411227 100644 --- a/docs/SITEMAP.md +++ b/docs/SITEMAP.md @@ -9,6 +9,11 @@ - [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary. - [Compaction revocation lifecycle](architecture/compaction-revocation.md) — Claude/Pi observer matrix, same-PID generation rollover, failure fencing, and the named bounded residual stale window. +## Installation and upgrades + +- [Installer state machine and recovery](guides/installer-state-machine.md) — canonical P0–P9 phases, side-effect-free checks, durable journal states, rollback/remediation, and the Debian greenfield CI gate. +- [Upgrade safety and recovery](guides/upgrade-safety-and-recovery.md) — framework ownership, durable operator snapshots, verify net, and projection regeneration. + ## CLI and skill management - [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior. diff --git a/docs/guides/installer-state-machine.md b/docs/guides/installer-state-machine.md new file mode 100644 index 00000000..1b2eb840 --- /dev/null +++ b/docs/guides/installer-state-machine.md @@ -0,0 +1,99 @@ +# Installer State Machine and Recovery + +The unified installer uses a transactional P0–P9 model. It may report success only after P9 reasserts every applicable committed postcondition. Internal phases invoke the CLI by P3's absolute path; shell discovery is checked only at P8. + +## Canonical phases + +| Phase | Responsibility | Failure disposition | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| P0 Resolve context | State target user, HOME, shell, privilege mode, architecture, libc, Node, and npm | Fail before mutation | +| P1 Preflight | Validate downstream tool closure (including `git` and `python3`), writable prefix, registry lane, disk/inodes, and exclusive lock | Fail before target mutation | +| P2 Acquire artifacts | Resolve exact registry versions and an immutable framework commit; record lane and SHA-256 | Discard temporary work | +| P3 Install CLI | Install at the configured absolute prefix and require exact resolved version | Restore the prior prefix/npmrc snapshot | +| P4 Install framework + skills | Sync framework and consume a checkout-free, lane/versioned shipped-skill declaration | Restore prior framework/runtime trees | +| P5 Identity | Validate SOUL/USER content, owner, and mode; establish any credential capability requested downstream | Restore generated identity/credential binding | +| P6 Runtime linking / activation | Evaluate activation honestly; never treat dead enforcement hooks as active readiness | Restore runtime activation files | +| P7 Services | Provision only requested services/resources after any required P5 credential commits | Stop and restore requested services/resources | +| P8 Shell discoverability | Require fresh login and non-login shells of the actual target shell to resolve P3's path | Restore shell profiles | +| P9 Verify + commit | Re-run P0–P8, commit the manifest, and seal the journal | Leave an honestly reported resumable failure or restore the pre-install snapshot | + +The phase numbers are a cross-workstream contract and must not be renumbered. + +## Side-effect-free check + +```bash +bash tools/install.sh --check # stable/latest lane +bash tools/install.sh --check --next # prerelease lane +``` + +`--check`: + +- emits exactly one `[P0]` through `[P8]` PASS/FAIL row; +- exits non-zero if any predicate fails; +- does not create the npm prefix, lock, journal, manifest, shell profile, or runtime file; +- uses temporary npm observation storage outside the target HOME and removes it before exit. + +P4 currently fails as `NOT-MEASURED / UNDECLARED` until the installer publishes `~/.config/mosaic/.install-shipped-skills.json`. C1 deliberately does not select among the conflicting candidate populations; C5 owns publishing and fulfilling that declaration. Once present, the P4 predicate requires the declaration's lane/version to match the resolved install and every named skill to remain contained under `skills//SKILL.md` with matching loadable frontmatter. + +## Durable journal + +Each mutating run creates a private transaction directory: + +```text +${XDG_STATE_HOME:-~/.local/state}/mosaic/install/ + active.json + / + journal.ndjson + journal.ndjson.sha256 # committed runs only + commands.log + snapshot/ +``` + +Before each mutation scope is touched, `journal.ndjson` records: + +- phase and path; +- whether prior state existed and where its snapshot lives; +- the reversal action; +- the captured command-output location and command status. + +Journal, action-status, manifest, or command-log write/sync failure is fatal. An unrecorded mutation is not allowed. Successful P9 runs append a seal event, write the SHA-256 sidecar, and make the journal and sidecar read-only. Required P4/P6 action failures are persisted in the manifest so a later `--check` cannot turn a failed action into a false pass. + +Rollback roots must be non-overlapping, non-symlinked, target-user-owned strict descendants of canonical `HOME`; unsafe custom `MOSAIC_HOME`/`MOSAIC_PREFIX` values fail at P0. The same validation runs again immediately before recursive rollback. The OS lock is concurrency authority: if a process dies while `active.json` still says `in-progress`, a retry that acquires the free lock preserves the stale projection as `prior-active.json` and proceeds from the honestly retained partial state. + +`active.json` is the current projection: + +- `in-progress`: incomplete/open transaction; +- `rolled-back`: a fault restored the snapshot; +- `rollback-failed`: restoration failed or refused a replaced/unsafe target and requires manual recovery; +- `failed-resumable`: named postconditions failed and the recorded partial state remains for remediation; +- `committed`: P9 passed and the journal is sealed. + +## Failure recovery + +1. Read the named phase and remediation line from installer stderr. +2. Inspect `active.json`, then the referenced `journal.ndjson` and `commands.log`. Command output needed to diagnose a failure is preserved and surfaced; it is not redirected away. +3. For `rolled-back`, verify the target paths match their pre-install state before retrying. +4. For `failed-resumable`, repair the named phase owner requirement, then run `install.sh --check` before retrying the installer. +5. Do not activate the #869 enforcement hooks merely to turn P6 green. A broker-less host with those hooks is a failed P6 state. + +## Greenfield CI gate + +`.woodpecker/greenfield-install.yml` runs `tools/e2e-install-test.sh` from zero in Debian/glibc as a non-root uid with `env -i`. No host HOME, npm cache, credentials, or bind mount enters the target process. Checkout mode packages the complete current checkout into an archive, pins its SHA-256 through an internal fixture seam, and copies the self-contained fixture into the container; framework-installer changes in the PR are therefore exercised rather than fetched from an older remote branch. + +The C1 gate intentionally validates an attributable RED while C2–C5 remain open: + +- `git` present: P1 and strict P3 pass; P4/P5/P6/P8 fail for their own reasons; P9 refuses success. +- `git` absent: P1 fails before target mutation and the installer emits no `Done.`. + +The fixture is lane-parametric: + +```bash +bash tools/e2e-install-test.sh --lane next --git present +bash tools/e2e-install-test.sh --lane main --git present +``` + +CI exercises both lane parameters as expected-RED structural checks. The authoritative main-lane promotion acceptance and issue closure remain owned by #1037. + +## Source trust boundary + +Remote source mode pins the resolved commit, records the archive SHA-256, limits compressed/expanded size and entry count, and rejects traversal, links, devices, and special files before extraction. This provides immutable run provenance and archive safety, not an independent authenticity root. Signed artifact metadata/provenance is explicitly deferred by the canonical greenfield PRD; C1 does not invent a signing system. The checkout CI seam does verify an expected digest supplied independently by the fixture. diff --git a/docs/guides/upgrade-safety-and-recovery.md b/docs/guides/upgrade-safety-and-recovery.md index 1f755cb7..ba6837c2 100644 --- a/docs/guides/upgrade-safety-and-recovery.md +++ b/docs/guides/upgrade-safety-and-recovery.md @@ -12,6 +12,20 @@ with no snapshot to fall back to. Protection is layered. Each layer is independent; a later layer catches what an earlier one misses. +## Layer 0 — Transaction journal (install-wide recovery) + +The unified installer opens a private journal under +`${XDG_STATE_HOME:-~/.local/state}/mosaic/install/` before the first target +mutation. Every mutation scope records its path, prior snapshot, and reversal +instructions before it is touched. Journal write/sync failure is fatal, and P9 +seals successful journals with a SHA-256 sidecar. See +[Installer state machine and recovery](./installer-state-machine.md). + +This transaction journal is distinct from the retained operator-only backup +below. The transaction journal is required for correctness and rollback; +Layer 2's durable backup remains a separately stated, fail-open recovery bonus +for a manifest bug that the normal transaction did not detect. + ## Layer 1 — Manifest-owned sync (prevention) The single source of truth for ownership is diff --git a/docs/scratchpads/1050-install-state-machine-red-fixture.md b/docs/scratchpads/1050-install-state-machine-red-fixture.md index 33f42fe9..a8a9b609 100644 --- a/docs/scratchpads/1050-install-state-machine-red-fixture.md +++ b/docs/scratchpads/1050-install-state-machine-red-fixture.md @@ -6,7 +6,7 @@ Implement C1 from the canonical greenfield-install PRD v2: a transactional P0– ## Authority and scope -- Canonical requirements: `jason.woltje/jarvis-brain` `docs/plans/2026-08-04-greenfield-install-blockers-PRD-v2.md`, read from local `origin/main` object `b2b6ed41f5aff5ea964e69b7c701cb45718742fa`; remote currency is **unestablished** because authenticated fetch returned repository-not-found. +- Canonical requirements: `jason.woltje/jarvis-brain` `docs/plans/2026-08-04-greenfield-install-blockers-PRD-v2.md`. Currency was re-derived after compaction: authenticated fetch resolved `origin/main` to `cb23e5fbc8a282fa967b93d7a134fa48d11b4bb1`; the PRD and charters are byte-identical to the previously read remote copies. - Tracking: `mosaicstack/stack#1050` on `git.mosaicstack.dev` (author read back as `be-coder-05`). - Base: `origin/next` `4df478cdd150fdf8d52ea109f02ade5d85017acd`. - Out of scope: PATH, skills, headless wizard/identity, activation remediation, #869 wiring, RM-02, main promotion. @@ -40,23 +40,29 @@ Implement C1 from the canonical greenfield-install PRD v2: a transactional P0– ## Progress -- [x] Charter, doctrine, delivery/CI/QA/docs guides read. -- [x] Canonical PRD v2 and charters read from local origin object; numbering reconciles with the TL spec. No numbering conflict found. TL additions (early durable journal and INV-C) are additive, not contradictory. +- [x] Charter, doctrine, delivery/CI/QA/docs guides read and re-anchored after compaction. +- [x] Canonical PRD v2/v3 addenda and charters read from fetched `origin/main`; numbering reconciles with the TL spec. No numbering conflict found. INV-B/C/D are binding and implemented without renumbering. - [x] Target base reachability verified with `merge-base --is-ancestor`. - [x] Issue #1050 created and provider author read back. - [x] Initial RED captured; TL rejected P4's repo-root count as a false RED. Four populations disagree (framework payload 1, repo root 13, sync store 101 in the fixture, W-jarvis observation 7), so C1 now requires a checkout-free declared shipped-set artifact and reports P4 `NOT-MEASURED / UNDECLARED` until C5 supplies it. - [x] P6 strengthens #869: the two dead enforcement hooks reproduce from zero on a clean broker-less container. C1 asserts the breach but neither wires nor unwires it. - [x] P1 false pass identified from the P4 evidence row: `git` is absent from the Debian base and was undeclared even though skill sync shells out to it. C1 adds `git` to P1; the fixture matrix preserves absent/present controls. The prior claim that web1's missing runtime skills reproduce this greenfield mechanism is withdrawn by the TL and is not carried here. -- [ ] Corrected RED transcript captured and reported. -- [ ] State machine implemented. -- [ ] Reviews complete. +- [x] Corrected RED transcript captured and reported, including the git-present/absent controls and strict P3 PASS. +- [x] State-machine implementation complete: private pre-mutation journal/snapshot, P0–P8 `--check`, P2–P8 fault seam, rollback, durable manifest/journal seal, action-status persistence, safe rollback roots, and stale-projection recovery. +- [x] Debian/glibc checkout fixture now packages the complete current checkout, verifies its digest in-container, and reaches the expected attributable RED without host inheritance. +- [ ] Reviews complete. Automated review defects around Bash conditional errexit, explicit exits, P4/P6 persisted action status, dev/offline source resolution, stale locks, checkout coverage, and rollback path safety were remediated. Remaining automated objections are the charter-mandated expected RED/C5 boundary and signed provenance, which the canonical PRD explicitly defers; independent informed review is still required. ## Risks / blockers - The deployed create wrappers do not expose `--dry-run`; identity preflight was performed through `pr-merge.sh --dry-run` on the same HOMELAB repo, which resolved `git.mosaicstack.dev` + `be-coder-05`. The issue create then fell back from tea to the API but provider read-back confirmed author `be-coder-05`. - `next` is an integration lane; `main` promotion remains #1037-owned. - #869 must remain staged and inactive. +- Late sequencing input MB-BRAIN-01 is accommodated without implementation or renumbering: P2 covers installer distribution only; P5 owns requested credential capability; P7 leaves an ordered seam for credential-dependent resource provisioning after P5. ## Verification log -(To be updated with exact commands and resulting objects.) +- `bash -n` and ShellCheck pass for all changed shell surfaces; `git diff --check` passes. +- `bash tools/install-state-machine.test.sh` passes, including exact P0–P8 rows, good/bad discrimination, persisted P4/P6 action failures, P2–P8 rollback, unsafe/overlapping/symlink roots, stale `active.json`, and fatal journal initialization. +- `bash tools/install-next-lane.test.sh` passes, including exact `@next` versions, immutable source fallback, source-build/archive-failure rollback, offline `--dev`, explicit refs, and prerelease suffix mismatch. +- `bash tools/e2e-install-test.sh --lane next --source checkout --git present` returns the required expected RED in clean Debian/glibc as uid 1001: installer P0/P1/P2/P3/P7 PASS; P4/P5/P6/P8 and P9 blocking; no `Done.` claim; checkout archive digest pinned and current framework installer exercised. +- Earlier repository gates passed: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm test:installer`, upgrade manifest/rollback/durable-snapshot/migration suites, and focused `@mosaicstack/mosaic` tests with an isolated npm prefix. Full rerun is required after final edits. diff --git a/package.json b/package.json index f52dd9d6..82155fdc 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "clean:generated": "node scripts/clean-generated.mjs", "typecheck": "pnpm preflight && turbo run typecheck", "test:checkout": "node --test scripts/*.test.mjs", - "test": "pnpm test:checkout && turbo run test", + "test": "pnpm test:checkout && turbo run test && pnpm run test:installer", + "test:installer": "bash tools/install-state-machine.test.sh && bash tools/install-next-lane.test.sh", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", "prepare": "node scripts/install-hooks.mjs" diff --git a/packages/mosaic/framework/install.sh b/packages/mosaic/framework/install.sh index 7d262a24..f4352276 100755 --- a/packages/mosaic/framework/install.sh +++ b/packages/mosaic/framework/install.sh @@ -58,6 +58,7 @@ done # packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt. # Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0). # shellcheck source=tools/_lib/manifest.sh +# shellcheck disable=SC1091 # Dynamic SOURCE_DIR; the path is validated by set -e. source "$SOURCE_DIR/tools/_lib/manifest.sh" # Which paths a keep-mode upgrade may touch is no longer a hand-maintained @@ -222,12 +223,14 @@ prune_durable_snapshots() { [[ "$keep" =~ ^[0-9]+$ ]] && (( keep >= 1 )) || keep=5 list="$(mktemp)" if ! find "$root" -maxdepth 1 -type d -name 'pre-update-*' > "$list"; then + warn "Backup pruning skipped; policy: retention cleanup is optional and a failed enumeration must preserve every existing recovery snapshot." rm -f "$list"; return 0 fi # Newest-first ordering needs `sort` (`-o` writes back in place — no `mv` # dependency); if it is somehow unavailable, leave the backups untouched rather # than risk pruning in an undefined order. if ! LC_ALL=C sort -r -o "$list" "$list" 2>/dev/null; then + warn "Backup pruning skipped; policy: ordering failure preserves all snapshots rather than risking deletion in an undefined order." rm -f "$list"; return 0 fi while IFS= read -r d; do @@ -266,7 +269,11 @@ make_durable_snapshot() { warn "Durable snapshot skipped: cannot create backup dir $root (upgrade continues; operator files remain manifest-protected)." return 0 fi - chmod 700 "$root" 2>/dev/null || true + if ! chmod 700 "$root"; then + umask "$old_umask" + warn "Durable snapshot skipped: backup root permissions could not be made private; policy: never write operator data to an insufficiently protected location." + return 0 + fi dir="$root/pre-update-$ts" if [[ -e "$dir" ]]; then # same-second re-run: disambiguate local n=1; while [[ -e "$dir-$n" ]]; do n=$((n + 1)); done; dir="$dir-$n" @@ -281,7 +288,10 @@ make_durable_snapshot() { if ! enumerate_operator_files "$list"; then umask "$old_umask" warn "Durable snapshot skipped: could not enumerate operator files (upgrade continues)." - rm -f "$list"; rmdir "$dir" 2>/dev/null || true + rm -f "$list" + if ! rmdir "$dir"; then + warn "Durable snapshot cleanup left $dir in place; policy: preserve unexpected content rather than deleting it recursively." + fi return 0 fi while IFS= read -r -d '' rel; do @@ -292,12 +302,18 @@ make_durable_snapshot() { warn "Durable snapshot: could not copy operator file '$rel' (skipped)." continue fi - chmod 600 "$dst" 2>/dev/null || true + if ! chmod 600 "$dst"; then + rm -f "$dst" + warn "Durable snapshot: copied '$rel' could not be made private and was removed; policy: do not retain an insecure recovery copy." + continue + fi count=$((count + 1)) done < "$list" rm -f "$list" - # Tighten every dir the copy created (mkdir -p honors umask, but be explicit). - find "$dir" -type d -exec chmod 700 {} + 2>/dev/null || true + # Tighten every dir the copy created (mkdir -p already honored umask 077). + if ! find "$dir" -type d -exec chmod 700 {} +; then + warn "Durable snapshot directory permission recheck failed; policy: continue because every directory was created under umask 077, while retaining the diagnostic." + fi umask "$old_umask" # UMASK-RESTORE-NORMAL — restore before the upgrade proper resumes (see above) DURABLE_SNAPSHOT_DIR="$dir" ok "Durable pre-update snapshot: $count operator file(s) saved to $dir (recover with: mosaic restore --list)" @@ -344,7 +360,9 @@ verify_operator_surface() { continue fi if cp "$snap" "$cur"; then - chmod 600 "$cur" 2>/dev/null || true + if ! chmod 600 "$cur"; then + warn "Operator file '$rel' was restored but its mode could not be tightened to 0600; policy: preserve recovered content and require manual permission repair." + fi warn "Operator file was modified by the upgrade and has been restored from the pre-update snapshot: $rel" healed=$((healed + 1)) else @@ -535,7 +553,7 @@ sync_framework_keep() { # (unreadable dir) is surfaced as a warning rather than silently swallowed; # the "directory not empty" races we tolerate are ignored via -delete's own # rc, not by hiding stderr — so a real error is still visible to the operator. - if ! find "$dst/$root" -type d -empty -delete 2>/dev/null; then + if ! find "$dst/$root" -type d -empty -delete; then warn "prune: could not fully sweep empty framework dirs under $root (left as-is)" fi done < <(manifest_subtree_roots) @@ -581,7 +599,7 @@ run_migrations() { MIGRATION_REMOVED_PATHS+=("bin" "rails") if [[ -d "$TARGET_DIR/bin" ]]; then ok "Removing legacy bin/ directory (executables now in npm CLI)" - rm -rf "$TARGET_DIR/bin" + rm -rf "${TARGET_DIR:?}/bin" fi # Remove old mosaic PATH entry from shell profiles @@ -706,13 +724,23 @@ mkdir -p "$TARGET_DIR/credentials" # by `mosaic init` from templates with user-supplied values. reconcile_framework_files -# Ensure tool scripts are executable -find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true -find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true +# Ensure tool scripts are executable. These are P4 postconditions, not +# best-effort cleanup: a chmod failure leaves shipped tools unloadable. +if ! find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} +; then + fail "Could not mark shipped shell tools executable." + exit 1 +fi +if ! find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} +; then + fail "Could not mark shipped runtime scripts executable." + exit 1 +fi # git-credential-mosaic (per-agent Gitea identity helper) ships without a .sh -# suffix — git resolves credential helpers by exact name/path, not extension — -# so the *.sh glob above does not cover it; chmod it explicitly. -[[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] && chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic" 2>/dev/null || true +# suffix — git resolves credential helpers by exact name/path, not extension. +if [[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] \ + && ! chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic"; then + fail "Could not mark git-credential-mosaic executable." + exit 1 +fi ok "Framework synced to $TARGET_DIR" @@ -739,49 +767,110 @@ step "Post-install tasks" SCRIPTS="$TARGET_DIR/tools/_scripts" +# Capture every fallible post-install command. A failure's text is surfaced and +# also appended to the parent transaction's private command log. Failure to +# write that log is fatal: continuing would recreate the false-clean diagnosis +# INV-C forbids. +record_phase_outcome() { + local phase="$1" status="$2" reason="$3" + [[ -n "${MOSAIC_INSTALL_PHASE_STATUS_FILE:-}" ]] || return 0 + if ! printf '%s\t%s\t%s\n' "$phase" "$status" "$reason" >> "$MOSAIC_INSTALL_PHASE_STATUS_FILE" \ + || ! sync "$MOSAIC_INSTALL_PHASE_STATUS_FILE"; then + fail "Could not durably record $phase action outcome for the parent transaction." + exit 1 + fi +} + +run_captured() { + local label="$1" output status=0 + shift + output="$(mktemp "${TMPDIR:-/tmp}/mosaic-post-install.XXXXXX.log")" + if "$@" >"$output" 2>&1; then status=0; else status=$?; fi + if [[ -n "${MOSAIC_INSTALL_COMMAND_LOG:-}" ]]; then + if ! { printf '\n=== %s (exit=%s) ===\n' "$label" "$status"; cat "$output"; } >> "$MOSAIC_INSTALL_COMMAND_LOG" \ + || ! sync "$MOSAIC_INSTALL_COMMAND_LOG"; then + cat "$output" >&2 + rm -f "$output" + fail "Could not durably append '$label' diagnostics to the install command log." + exit 1 + fi + fi + if [[ "$status" -ne 0 ]]; then cat "$output" >&2; fi + rm -f "$output" + return "$status" +} + if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then link_args=() [[ "$ALLOW_INACTIVE_ENFORCEMENT" == "1" ]] && link_args+=(--allow-inactive-enforcement) - # stdout is suppressed as before, but stderr is left connected: the - # install-ordering guard's FAIL LOUD message (#869 Point-1 C2) must reach - # the operator, not be swallowed silently. - if "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}" >/dev/null; then + if run_captured "runtime asset linking" "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}"; then + record_phase_outcome P6 committed "runtime asset linker exited zero" ok "Runtime assets linked" else - warn "Runtime asset linking failed (non-fatal) — see message above for details." + record_phase_outcome P6 failed "runtime asset linker exited non-zero" + warn "Runtime asset linking did not commit; policy: continue only to enumerate all phase diagnostics, while P6/P9 remain blocking." fi +else + record_phase_outcome P6 failed "required runtime asset linker is missing or not executable" + warn "Runtime asset linking was not attempted; policy: a missing required linker remains a blocking P6/P9 failure." fi if [[ -x "$SCRIPTS/mosaic-ensure-sequential-thinking" ]]; then - if "$SCRIPTS/mosaic-ensure-sequential-thinking" >/dev/null 2>&1; then + if run_captured "sequential-thinking setup" "$SCRIPTS/mosaic-ensure-sequential-thinking"; then ok "sequential-thinking MCP configured" + elif [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then + record_phase_outcome P6 failed "sequential-thinking setup failed under diagnostic-continuation compatibility mode" + warn "sequential-thinking setup did not commit; policy: the unified installer compatibility flag allows diagnostic continuation, while P6/P9 remain blocking." else - if [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then - warn "sequential-thinking MCP setup bypassed (MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1)" - else - fail "sequential-thinking MCP setup failed (hard requirement)." - exit 1 - fi + fail "sequential-thinking MCP setup failed (hard requirement)." + exit 1 fi fi if [[ -x "$SCRIPTS/mosaic-ensure-excalidraw" ]]; then - "$SCRIPTS/mosaic-ensure-excalidraw" >/dev/null 2>&1 && ok "excalidraw MCP configured" || warn "excalidraw MCP setup failed (non-fatal)" + if run_captured "excalidraw setup" "$SCRIPTS/mosaic-ensure-excalidraw"; then + ok "excalidraw MCP configured" + else + warn "excalidraw setup did not commit; policy: optional integration failure is retained in the journal and does not define core install readiness." + fi fi -if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" != "1" ]] && [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then - "$SCRIPTS/mosaic-sync-skills" >/dev/null 2>&1 && ok "Skills synced" || warn "Skills sync failed (non-fatal)" +if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" == "1" ]]; then + record_phase_outcome P4 failed "required skills sync explicitly skipped" + warn "Skills sync was skipped; policy: diagnostic continuation is allowed, but P4/P9 cannot certify an incomplete requested framework install." +elif [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then + if run_captured "skills sync" "$SCRIPTS/mosaic-sync-skills"; then + record_phase_outcome P4 committed "skills sync exited zero" + ok "Skills synced" + else + record_phase_outcome P4 failed "skills sync exited non-zero" + warn "Skills sync did not commit; policy: continue to collect P4 diagnostics, but P4/P9 must not certify the install." + fi +else + record_phase_outcome P4 failed "required skills sync command is missing or not executable" + warn "Skills sync was not attempted; policy: a missing required sync command remains a blocking P4/P9 failure." fi if [[ -x "$SCRIPTS/mosaic-migrate-local-skills" ]]; then - "$SCRIPTS/mosaic-migrate-local-skills" --apply >/dev/null 2>&1 && ok "Local skills migrated" || warn "Local skill migration failed (non-fatal)" + if run_captured "local skills migration" "$SCRIPTS/mosaic-migrate-local-skills" --apply; then + ok "Local skills migrated" + else + record_phase_outcome P4 failed "local skills migration exited non-zero" + warn "Local skill migration did not commit; policy: preserve user content and continue diagnostics, while P4/P9 remain blocking." + fi fi if [[ -x "$SCRIPTS/mosaic-doctor" ]]; then - "$SCRIPTS/mosaic-doctor" >/dev/null 2>&1 && ok "Health audit passed" || warn "Health audit reported issues — run 'mosaic doctor' for details" + if run_captured "health audit" "$SCRIPTS/mosaic-doctor"; then + ok "Health audit passed" + else + warn "Health audit found unresolved state; policy: preserve its diagnostics and let P9 issue the authoritative failure." + fi fi -# Write version stamp AFTER everything succeeds +# The version stamp records the successfully committed framework file sync. +# Post-install failures are carried separately into P4/P6 and cannot be erased +# by this stamp. write_framework_version # ── Summary ────────────────────────────────────────────────── diff --git a/tools/e2e-install-test.sh b/tools/e2e-install-test.sh index 9f64eca8..241d3d40 100755 --- a/tools/e2e-install-test.sh +++ b/tools/e2e-install-test.sh @@ -14,6 +14,7 @@ SOURCE="${MOSAIC_INSTALL_SOURCE:-checkout}" IMAGE="${MOSAIC_INSTALL_IMAGE:-node:22-bookworm-slim}" GIT_MODE="${MOSAIC_INSTALL_GIT_MODE:-present}" INSTALLER_FILE="${MOSAIC_FIXTURE_INSTALLER_FILE:-$ROOT/tools/install.sh}" +IN_CLEAN_CONTAINER="${MOSAIC_GREENFIELD_CONTAINER:-0}" usage() { cat <<'EOF' @@ -39,38 +40,62 @@ case "$LANE" in next|main) ;; *) echo "[fixture] unsupported lane '$LANE' (expec case "$SOURCE" in checkout|remote) ;; *) echo "[fixture] unsupported source '$SOURCE' (expected checkout|remote)" >&2; exit 2 ;; esac case "$GIT_MODE" in present|absent) ;; *) echo "[fixture] unsupported git mode '$GIT_MODE' (expected present|absent)" >&2; exit 2 ;; esac -if ! command -v docker >/dev/null 2>&1; then - echo "[fixture] FAIL: Docker is required; greenfield validation was NOT RUN." >&2 - exit 2 -fi -if ! docker info >/dev/null 2>&1; then - echo "[fixture] FAIL: Docker daemon is unavailable; greenfield validation was NOT RUN." >&2 - exit 2 +if [[ "$IN_CLEAN_CONTAINER" != "1" ]]; then + if ! command -v docker >/dev/null 2>&1; then + echo "[fixture] FAIL: Docker is required; greenfield validation was NOT RUN." >&2 + exit 2 + fi + if ! docker info >/dev/null 2>&1; then + echo "[fixture] FAIL: Docker daemon is unavailable; greenfield validation was NOT RUN." >&2 + exit 2 + fi fi installer_b64="" framework_payload_count="NOT-MEASURED" repo_root_count="NOT-MEASURED" +checkout_archive="" +checkout_digest="" +checkout_content_id="" if [[ "$SOURCE" == "checkout" ]]; then installer_b64="$(base64 -w0 "$INSTALLER_FILE")" [[ -d "$ROOT/packages/mosaic/framework/skills" ]] \ && framework_payload_count="$(find "$ROOT/packages/mosaic/framework/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" [[ -d "$ROOT/skills" ]] \ && repo_root_count="$(find "$ROOT/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" + checkout_archive="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-checkout.XXXXXX.tar.gz")" + repo_parent="$(dirname "$ROOT")" + repo_name="$(basename "$ROOT")" + tar -C "$repo_parent" \ + --exclude='*/.git' --exclude='*/node_modules' --exclude='*/dist' \ + --exclude='*/coverage' --exclude='*/.turbo' --exclude='*/.mosaic-test-work' \ + --exclude='*/.env' --exclude='*/.env.*' \ + -czf "$checkout_archive" "$repo_name" + checkout_digest="$(sha256sum "$checkout_archive" | awk '{print $1}')" + checkout_content_id="${checkout_digest:0:40}" fi inner="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-inner.XXXXXX.sh")" -trap 'rm -f "$inner"' EXIT +trap 'rm -f "$inner" "$checkout_archive"' EXIT cat > "$inner" <<'INNER' #!/usr/bin/env bash set -euo pipefail export DEBIAN_FRONTEND=noninteractive apt-get update -qq -packages=(bash ca-certificates curl jq passwd util-linux) +packages=(bash ca-certificates curl jq passwd python3 util-linux) [[ "$FIXTURE_GIT_MODE" == "present" ]] && packages+=(git) apt-get install -y -qq "${packages[@]}" >/dev/null +if [[ "$FIXTURE_SOURCE" == "checkout" ]]; then + awk 'found { print } /^__MOSAIC_CHECKOUT_ARCHIVE__$/ { found=1; next }' "$0" | base64 -d > /tmp/source-checkout.tar.gz + actual_checkout_digest="$(sha256sum /tmp/source-checkout.tar.gz | awk '{print $1}')" + if [[ "$actual_checkout_digest" != "$FIXTURE_CHECKOUT_SHA256" ]]; then + echo "[fixture] checkout archive transport digest mismatch" >&2 + exit 1 + fi +fi + useradd --create-home --uid 1001 --shell /bin/bash mosaic install -d -o mosaic -g mosaic /home/mosaic/work @@ -130,7 +155,7 @@ fi # P1 Preflight missing_tools=() -for tool in bash curl git node npm tar; do +for tool in bash curl git node npm python3 tar; do command -v "$tool" >/dev/null 2>&1 || missing_tools+=("$tool") done if [[ "${#missing_tools[@]}" -eq 0 && -n "$resolved_version" && -w "$home" ]]; then @@ -173,18 +198,34 @@ printf '[P4-EVIDENCE] candidate_populations framework_payload=%s repo_root=%s sy "$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" "$FIXTURE_REPO_ROOT_COUNT" "$sync_store_count" "$runtime_link_count" if [[ ! -s "$declared_set" ]]; then phase_fail P4 "NOT-MEASURED / UNDECLARED: installer published no checkout-free, lane/versioned shipped-set artifact at $declared_set" -elif node - "$declared_set" <<'NODE' +elif EXPECTED_LANE="$([[ "$lane" == next ]] && echo next || echo latest)" EXPECTED_VERSION="$resolved_version" \ + MOSAIC_SKILLS_ROOT="$mosaic_home/skills" node - "$declared_set" <<'NODE' const fs = require('fs'); +const path = require('path'); const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); -if (!data || typeof data !== 'object' || !['latest', 'next'].includes(data.lane) || - typeof data.version !== 'string' || !data.version || !Array.isArray(data.skills) || data.skills.length === 0 || - data.skills.some((name) => typeof name !== 'string' || !name)) process.exit(1); +const root = path.resolve(process.env.MOSAIC_SKILLS_ROOT); +if (!data || data.lane !== process.env.EXPECTED_LANE || data.version !== process.env.EXPECTED_VERSION || + !Array.isArray(data.skills) || data.skills.length === 0) process.exit(1); +for (const name of data.skills) { + if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) process.exit(1); + const skill = path.join(root, name, 'SKILL.md'); + let real; + try { real = fs.realpathSync(skill); } catch { process.exit(1); } + const text = fs.readFileSync(real, 'utf8'); + const declaredName = text.match(/^---\s*$[\s\S]*?^name:\s*([^\s]+)\s*$/m)?.[1]; + if (!real.startsWith(root + path.sep) || !fs.statSync(real).isFile() || !text || declaredName !== name) process.exit(1); +} NODE then declared_count="$(node -p "require('$declared_set').skills.length")" - phase_pass P4 "declared shipped-set artifact parses (declared_count=$declared_count); C5 owns containment/loadability fulfillment" + if [[ -s "$mosaic_home/.install-manifest.json" ]] \ + && [[ "$(node -p "require('$mosaic_home/.install-manifest.json').phaseOutcomes?.P4 || 'committed'")" == failed ]]; then + phase_fail P4 "declared skills are present but the required framework/skills action reported failure" + else + phase_pass P4 "declared shipped-set matches lane/version and all $declared_count skill(s) are contained and loadable" + fi else - phase_fail P4 "NOT-MEASURED / UNDECLARED: shipped-set artifact exists but is empty, malformed, or lacks lane/version" + phase_fail P4 "shipped-set artifact is malformed, wrong-lane/version, or its declared skills are not contained and loadable" fi # P5 Identity @@ -207,13 +248,20 @@ else fi # P6 Runtime linking / activation. #869 must remain unwired without its broker. +manifest="$mosaic_home/.install-manifest.json" broker_present=false [[ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/mosaic-lease/broker.sock" ]] && broker_present=true dead_hooks=0 if [[ -f "$home/.claude/settings.json" ]]; then dead_hooks="$(grep -Ec 'mutator-gate\.py|receipt-observer-client\.py' "$home/.claude/settings.json" || true)" fi -if [[ "$broker_present" == false && "$dead_hooks" -eq 0 ]]; then +p6_action_failed=false +if [[ -s "$manifest" ]]; then + p6_action_failed="$(node -p "require('$manifest').phaseOutcomes?.P6 === 'failed' ? 'true' : 'false'" 2>/dev/null || echo true)" +fi +if [[ "$p6_action_failed" == true ]]; then + phase_fail P6 "runtime linking/activation action reported a required failure" +elif [[ "$broker_present" == false && "$dead_hooks" -eq 0 ]]; then phase_pass P6 "broker absent and #869 enforcement hooks remain inactive" elif [[ "$broker_present" == true ]]; then phase_pass P6 "activation broker present; hook state is evaluable" @@ -257,19 +305,56 @@ exec runuser -u mosaic -- env -i \ FIXTURE_GIT_MODE="$FIXTURE_GIT_MODE" \ FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" \ FIXTURE_REPO_ROOT_COUNT="$FIXTURE_REPO_ROOT_COUNT" \ + MOSAIC_INSTALL_LOCAL_SOURCE_ARCHIVE="$([[ "$FIXTURE_SOURCE" == "checkout" ]] && echo /tmp/source-checkout.tar.gz)" \ + MOSAIC_INSTALL_LOCAL_SOURCE_COMMIT="$FIXTURE_CHECKOUT_CONTENT_ID" \ + MOSAIC_INSTALL_LOCAL_SOURCE_SHA256="$FIXTURE_CHECKOUT_SHA256" \ /bin/bash /tmp/run-as-target.sh INNER +if [[ "$SOURCE" == "checkout" ]]; then + { + printf '\n__MOSAIC_CHECKOUT_ARCHIVE__\n' + base64 "$checkout_archive" + } >> "$inner" +fi chmod 0755 "$inner" printf '[fixture] platform=Debian/glibc image=%s target_uid=1001 lane=%s source=%s git=%s\n' "$IMAGE" "$LANE" "$SOURCE" "$GIT_MODE" printf '[fixture] host inheritance: no bind mounts, no host HOME, no npm cache, no credentials\n' -docker run --rm -i \ - --network bridge \ - --env FIXTURE_LANE="$LANE" \ - --env FIXTURE_SOURCE="$SOURCE" \ - --env FIXTURE_GIT_MODE="$GIT_MODE" \ - --env FIXTURE_INSTALLER_B64="$installer_b64" \ - --env FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \ - --env FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \ - "$IMAGE" /bin/bash -s < "$inner" +if [[ "$IN_CLEAN_CONTAINER" == "1" ]]; then + # Woodpecker already supplies the clean Debian container. The target install + # still runs through runuser + env -i, so CI variables/credentials do not + # enter the target user's process. + FIXTURE_LANE="$LANE" \ + FIXTURE_SOURCE="$SOURCE" \ + FIXTURE_GIT_MODE="$GIT_MODE" \ + FIXTURE_INSTALLER_B64="$installer_b64" \ + FIXTURE_CHECKOUT_SHA256="$checkout_digest" \ + FIXTURE_CHECKOUT_CONTENT_ID="$checkout_content_id" \ + FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \ + FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \ + /bin/bash "$inner" +else + # Copy the self-contained script+archive into a stopped container instead of + # bind-mounting the checkout or passing host paths. The target runtime still + # inherits no host HOME/cache/credentials, and the multi-megabyte checkout + # payload avoids argv/environment size limits. + fixture_cid="$(docker create \ + --network bridge \ + --env FIXTURE_LANE="$LANE" \ + --env FIXTURE_SOURCE="$SOURCE" \ + --env FIXTURE_GIT_MODE="$GIT_MODE" \ + --env FIXTURE_INSTALLER_B64="$installer_b64" \ + --env FIXTURE_CHECKOUT_SHA256="$checkout_digest" \ + --env FIXTURE_CHECKOUT_CONTENT_ID="$checkout_content_id" \ + --env FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \ + --env FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \ + "$IMAGE" /bin/bash /tmp/mosaic-greenfield-fixture.sh)" + docker cp "$inner" "$fixture_cid:/tmp/mosaic-greenfield-fixture.sh" + set +e + docker start -a "$fixture_cid" + fixture_status=$? + set -e + docker rm "$fixture_cid" >/dev/null + exit "$fixture_status" +fi diff --git a/tools/install-next-lane.test.sh b/tools/install-next-lane.test.sh new file mode 100755 index 00000000..a4fb1735 --- /dev/null +++ b/tools/install-next-lane.test.sh @@ -0,0 +1,359 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-next-install-test-XXXXXX")" +trap 'rm -rf "$TMP"' EXIT + +FAKE_BIN="$TMP/bin" +HOME_DIR="$TMP/home" +PREFIX="$HOME_DIR/prefix" +MOSAIC_HOME="$HOME_DIR/mosaic" +STATE="$TMP/state" +LOG="$TMP/npm.log" +mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE" + +cat > "$FAKE_BIN/npm" <<'FAKE_NPM' +#!/usr/bin/env bash +set -euo pipefail +LOG="${MOSAIC_TEST_NPM_LOG:?}" +STATE="${MOSAIC_TEST_STATE:?}" +echo "$*" >> "$LOG" + +if [[ "${1:-}" == "--version" ]]; then + echo "10.6.2" + exit 0 +fi + +install_cli() { + local version="$1" + echo "$version" > "$STATE/mosaic" + mkdir -p "${MOSAIC_PREFIX:?}/bin" + cat > "$MOSAIC_PREFIX/bin/mosaic" <&2 + exit 1 + fi + case "$2 $3" in + "@mosaicstack/mosaic@next version") echo "0.0.49-next.999" ;; + "@mosaicstack/gateway@next version") echo "${MOSAIC_TEST_GATEWAY_NEXT_VERSION:-0.0.7-next.999}" ;; + "@mosaicstack/mosaic version") echo "0.0.48" ;; + *) echo "unexpected npm view: $*" >&2; exit 1 ;; + esac + exit 0 +fi + +if [[ "$1" == "install" ]]; then + case "$*" in + *"@mosaicstack/mosaic@0.0.49-next.999"*) + install_cli "0.0.49-next.999" + ;; + *"@mosaicstack/gateway@0.0.7-next.999"*) + if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then + echo "forced gateway install failure" >&2 + exit 1 + fi + echo "0.0.7-next.999" > "$STATE/gateway" + ;; + *"mosaicstack-mosaic-0.0.0-source.tgz"*) + install_cli "0.0.0-source" + ;; + *"mosaicstack-gateway-0.0.0-source.tgz"*) + echo "0.0.0-source" > "$STATE/gateway" + ;; + *) echo "unexpected npm install: $*" >&2; exit 1 ;; + esac + exit 0 +fi + +if [[ "$1" == "ls" ]]; then + cli="$(cat "$STATE/mosaic" 2>/dev/null || true)" + gateway="$(cat "$STATE/gateway" 2>/dev/null || true)" + node -e ' + const cli = process.argv[1]; + const gateway = process.argv[2]; + const dependencies = {}; + if (cli) dependencies["@mosaicstack/mosaic"] = { version: cli }; + if (gateway) dependencies["@mosaicstack/gateway"] = { version: gateway }; + process.stdout.write(JSON.stringify({ dependencies })); + ' "$cli" "$gateway" + exit 0 +fi + +echo "unexpected npm command: $*" >&2 +exit 1 +FAKE_NPM +chmod +x "$FAKE_BIN/npm" + +cat > "$FAKE_BIN/curl" <<'FAKE_CURL' +#!/usr/bin/env bash +set -euo pipefail +headers=""; output=""; url="" +while [[ $# -gt 0 ]]; do + case "$1" in + -D) headers="$2"; shift 2 ;; + -o) output="$2"; shift 2 ;; + --max-filesize) shift 2 ;; + -*) shift ;; + *) url="$1"; shift ;; + esac +done +case "$url" in + */api/v1/repos/mosaicstack/stack/commits?sha=*) + printf 'HTTP/1.1 200 OK\r\ncontent-type: application/json; charset=utf-8\r\n\r\n' > "$headers" + printf '[{"sha":"1111111111111111111111111111111111111111"}]\n' > "$output" + ;; + */archive/*.tar.gz) + if [[ "${MOSAIC_TEST_CORRUPT_ARCHIVE:-0}" == "1" ]]; then + printf 'not-a-tarball\n' > "$output" + else + archive_root="$(mktemp -d)" + mkdir -p "$archive_root/stack" + printf 'fixture\n' > "$archive_root/stack/.fixture" + /bin/tar czf "$output" -C "$archive_root" stack + rm -rf "$archive_root" + fi + ;; +esac +FAKE_CURL +chmod +x "$FAKE_BIN/curl" + +cat > "$FAKE_BIN/tar" <<'FAKE_TAR' +#!/usr/bin/env bash +set -euo pipefail +dest=""; list=false +while [[ $# -gt 0 ]]; do + case "$1" in + -C) dest="$2"; shift 2 ;; + -*t*|t*) list=true; shift ;; + *) shift ;; + esac +done +[[ "$list" == true ]] && exit 0 +if [[ -z "$dest" ]]; then + echo "fake tar missing -C destination" >&2 + exit 1 +fi +mkdir -p "$dest/stack/packages/mosaic" "$dest/stack/apps/gateway" +FAKE_TAR +chmod +x "$FAKE_BIN/tar" + +cat > "$FAKE_BIN/pnpm" <<'FAKE_PNPM' +#!/usr/bin/env bash +set -euo pipefail +LOG="${MOSAIC_TEST_NPM_LOG:?}" +echo "pnpm $*" >> "$LOG" + +if [[ "$1" == "pack" ]]; then + out="" + while [[ $# -gt 0 ]]; do + case "$1" in + --pack-destination) out="$2"; shift 2 ;; + *) shift ;; + esac + done + if [[ -z "$out" ]]; then + echo "fake pnpm pack missing destination" >&2 + exit 1 + fi + mkdir -p "$out" + case "$PWD" in + */apps/gateway) touch "$out/mosaicstack-gateway-0.0.0-source.tgz" ;; + */packages/mosaic) touch "$out/mosaicstack-mosaic-0.0.0-source.tgz" ;; + *) echo "unexpected pnpm pack cwd: $PWD" >&2; exit 1 ;; + esac + exit 0 +fi + +if [[ "${MOSAIC_TEST_FAIL_PNPM_INSTALL:-0}" == "1" && "$1" == "install" ]]; then + echo "forced pnpm install failure" >&2 + exit 42 +fi + +# Other install/build commands are no-ops in this harness. +exit 0 +FAKE_PNPM +chmod +x "$FAKE_BIN/pnpm" + +reset_state() { + : > "$LOG" + rm -f "$STATE"/* +} + +prefix_fingerprint() { + if [[ ! -d "$PREFIX" ]]; then printf 'ABSENT\n'; return; fi + ( + cd "$PREFIX" + find . -mindepth 1 -printf '%P|%y|%m|%l\n' | LC_ALL=C sort + find . -type f -print0 | LC_ALL=C sort -z | xargs -0 -r sha256sum + ) | sha256sum | awk '{print $1}' +} + +reset_state +echo "[test] --next fast path pins resolved package versions" +OUTPUT="$( + HOME="$HOME_DIR" \ + MOSAIC_HOME="$MOSAIC_HOME" \ + MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" \ + MOSAIC_TEST_STATE="$STATE" \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch +)" + +grep -qF 'Installed @next packages: CLI 0.0.49-next.999, gateway 0.0.7-next.999' <<<"$OUTPUT" +grep -qF 'install -g @mosaicstack/gateway@0.0.7-next.999' "$LOG" +grep -qF 'install -g @mosaicstack/mosaic@0.0.49-next.999' "$LOG" +if grep -qE '^install -g .+@next( |$)' "$LOG"; then + echo "expected exact-version installs, found mutable @next install" >&2 + exit 1 +fi +if grep -qF 'Downloading source ref next at pinned commit' <<<"$OUTPUT"; then + echo "fast path unexpectedly fell back to source" >&2 + exit 1 +fi + +ACTIVE="$HOME_DIR/.local/state/mosaic/install/active.json" +[[ "$(node -p "require('$ACTIVE').status")" == "committed" ]] +JOURNAL="$(node -p "require('$ACTIVE').journal")" +[[ "$(stat -c '%a' "$JOURNAL")" == "444" ]] +( cd "$(dirname "$JOURNAL")" && sha256sum -c "$(basename "$JOURNAL").sha256" >/dev/null ) +grep -q '"event":"mutation".*"phase":"P3".*path=.*prior=.*reverse=' "$JOURNAL" + +reset_state +echo "[test] fast path failure falls back to source build" +OUTPUT="$( + HOME="$HOME_DIR" \ + MOSAIC_HOME="$MOSAIC_HOME" \ + MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" \ + MOSAIC_TEST_STATE="$STATE" \ + MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch +)" + +grep -qF 'Fast gateway @next install failed.' <<<"$OUTPUT" +grep -qF 'Falling back to source build at ref next; --next will not hard-fail on registry issues.' <<<"$OUTPUT" +grep -qF 'Downloading source ref next at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT" +grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT" +grep -qF 'install -g @mosaicstack/mosaic@0.0.49-next.999' "$LOG" +grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG" +grep -qE 'install -g .*/mosaicstack-mosaic-0\.0\.0-source\.tgz' "$LOG" +[[ "$(cat "$STATE/mosaic")" == "0.0.0-source" ]] +[[ "$(cat "$STATE/gateway")" == "0.0.0-source" ]] + +reset_state +echo "[test] source-build failure is fatal and restores the pre-install prefix" +before_prefix="$(prefix_fingerprint)" +set +e +OUTPUT="$( + HOME="$HOME_DIR" \ + MOSAIC_HOME="$MOSAIC_HOME" \ + MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" \ + MOSAIC_TEST_STATE="$STATE" \ + MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \ + MOSAIC_TEST_FAIL_PNPM_INSTALL=1 \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1 +)" +FAIL_STATUS=$? +set -e +[[ "$FAIL_STATUS" -ne 0 ]] +[[ "$(prefix_fingerprint)" == "$before_prefix" ]] +grep -qF 'forced pnpm install failure' <<<"$OUTPUT" +[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]] + +reset_state +echo "[test] corrupt source archive is fatal and restores the pre-install prefix" +before_prefix="$(prefix_fingerprint)" +set +e +OUTPUT="$( + HOME="$HOME_DIR" \ + MOSAIC_HOME="$MOSAIC_HOME" \ + MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" \ + MOSAIC_TEST_STATE="$STATE" \ + MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \ + MOSAIC_TEST_CORRUPT_ARCHIVE=1 \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1 +)" +FAIL_STATUS=$? +set -e +[[ "$FAIL_STATUS" -ne 0 ]] +[[ "$(prefix_fingerprint)" == "$before_prefix" ]] +grep -qF 'archive safety/integrity check failed' <<<"$OUTPUT" +[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]] + +reset_state +echo "[test] --dev source install does not require registry version resolution" +OUTPUT="$( + HOME="$HOME_DIR" \ + MOSAIC_HOME="$MOSAIC_HOME" \ + MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" \ + MOSAIC_TEST_STATE="$STATE" \ + MOSAIC_TEST_FAIL_NPM_VIEW=1 \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --cli --dev --ref feature-x --yes --no-auto-launch +)" +grep -qF 'Downloading source ref feature-x at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT" +grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT" +grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT" + +reset_state +echo "[test] explicit --ref keeps source lane and avoids @next lookup" +set +e +OUTPUT="$( + HOME="$HOME_DIR" \ + MOSAIC_HOME="$MOSAIC_HOME" \ + MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" \ + MOSAIC_TEST_STATE="$STATE" \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x +)" +CHECK_STATUS=$? +set -e +[[ "$CHECK_STATUS" -ne 0 ]] +grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT" +if grep -qF '@next version' "$LOG"; then + echo "explicit ref should not query @next dist-tags" >&2 + exit 1 +fi + +reset_state +echo "[test] --check --next rejects mismatched prerelease pipeline suffixes" +set +e +OUTPUT="$( + HOME="$HOME_DIR" \ + MOSAIC_HOME="$MOSAIC_HOME" \ + MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" \ + MOSAIC_TEST_STATE="$STATE" \ + MOSAIC_TEST_GATEWAY_NEXT_VERSION="0.0.7-next.1000" \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --check --cli --next +)" +CHECK_STATUS=$? +set -e +[[ "$CHECK_STATUS" -ne 0 ]] +grep -q '^\[P2\] FAIL: resolved_version=unavailable' <<<"$OUTPUT" + +echo "[test] installer next lane tests passed" diff --git a/tools/install-state-machine.test.sh b/tools/install-state-machine.test.sh index cd6b85b7..ae5dde26 100755 --- a/tools/install-state-machine.test.sh +++ b/tools/install-state-machine.test.sh @@ -2,6 +2,9 @@ # Red-first acceptance checks for #1050. This file is committed before the # installer implementation. Do not weaken these properties to make it green. +# pass_case always returns zero and fail_case records the aggregate failure; +# the compact A&&pass||fail assertions are intentional. +# shellcheck disable=SC2015 set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -26,8 +29,9 @@ make_fake_npm() { local bin="$1" mkdir -p "$bin" cat > "$bin/npm" <<'FAKE' -#!/usr/bin/env bash +#!/bin/bash set -euo pipefail +if [[ "${1:-}" == "--version" ]]; then echo '10.6.2'; exit 0; fi case "${1:-} ${2:-} ${3:-}" in 'view @mosaicstack/mosaic@next version') echo '0.0.50-next.999' ;; 'view @mosaicstack/gateway@next version') echo '0.0.7-next.999' ;; @@ -86,6 +90,78 @@ for phase in P5 P8; do || fail_case "$phase did not report its own expected failure" done +printf '[test] case: --check discriminates a constructed good host without mutation\n' +good_home="$TMP/good-home" +good_bin="$TMP/good-bin" +good_prefix="$good_home/.npm-global" +good_mosaic="$good_home/.config/mosaic" +mkdir -p "$good_bin" "$good_prefix/bin" "$good_mosaic/skills/declared-skill" +make_fake_npm "$good_bin" +cat > "$good_prefix/bin/mosaic" <<'CLI' +#!/usr/bin/env bash +printf '0.0.50-next.999\n' +CLI +chmod 0755 "$good_prefix/bin/mosaic" +cat > "$good_bin/getent" < "$good_bin/bash" < "$good_mosaic/SOUL.md" +printf '# User\n\nConfigured.\n' > "$good_mosaic/USER.md" +chmod 0600 "$good_mosaic/SOUL.md" "$good_mosaic/USER.md" +cat > "$good_mosaic/skills/declared-skill/SKILL.md" <<'SKILL' +--- +name: declared-skill +description: Constructed loadable acceptance skill. +--- + +# Declared skill +SKILL +printf '{"lane":"next","version":"0.0.50-next.999","skills":["declared-skill"]}\n' > "$good_mosaic/.install-shipped-skills.json" +printf '{\n "lane": "next",\n "cliVersion": "0.0.50-next.999"\n}\n' > "$good_mosaic/.install-manifest.json" +before="$(fingerprint "$good_home")" +set +e +HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" \ + MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \ + bash "$ROOT/tools/install.sh" --check --next >"$TMP/good-check.log" 2>&1 +status=$? +set -e +after="$(fingerprint "$good_home")" +[[ "$status" -eq 0 ]] && pass_case 'good-host --check exited zero' || fail_case "good-host --check exited $status" +[[ "$before" == "$after" ]] && pass_case 'good-host --check left HOME unchanged' || fail_case 'good-host --check mutated HOME' +good_rows="$(grep -Ec '^\[P[0-8]\] PASS:' "$TMP/good-check.log" || true)" +[[ "$good_rows" -eq 9 ]] && pass_case 'good-host --check emitted nine PASS rows' \ + || { cat "$TMP/good-check.log" >&2; fail_case "good-host --check emitted $good_rows PASS rows"; } + +printf '[test] case: persisted required-action failures remain blocking\n' +for blocked_phase in P4 P6; do + node -e ' + const fs=require("fs"); const p=process.argv[1]; const phase=process.argv[2]; + const m=JSON.parse(fs.readFileSync(p,"utf8")); m.phaseOutcomes={P4:"committed",P6:"committed"}; + m.phaseOutcomes[phase]="failed"; fs.writeFileSync(p,JSON.stringify(m)+"\n"); + ' "$good_mosaic/.install-manifest.json" "$blocked_phase" + set +e + HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" \ + MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \ + bash "$ROOT/tools/install.sh" --check --next >"$TMP/action-$blocked_phase.log" 2>&1 + status=$? + set -e + [[ "$status" -ne 0 ]] || fail_case "$blocked_phase action failure returned zero" + grep -q "^\[$blocked_phase\] FAIL:.*action reported a required $blocked_phase failure" "$TMP/action-$blocked_phase.log" \ + && pass_case "$blocked_phase action failure remained blocking in a later --check" \ + || fail_case "$blocked_phase persisted action failure was not attributed" +done +printf '{\n "lane": "next",\n "cliVersion": "0.0.50-next.999",\n "phaseOutcomes": {"P4":"committed","P6":"committed"}\n}\n' > "$good_mosaic/.install-manifest.json" + printf '[test] case: per-phase P2-P8 fault injection restores representative host mutations\n' for phase in P2 P3 P4 P5 P6 P7 P8; do home="$TMP/fault-$phase/home" @@ -117,6 +193,86 @@ for phase in P2 P3 P4 P5 P6 P7 P8; do fi done +printf '[test] case: unsafe and overlapping rollback roots fail before mutation\n' +unsafe_home="$TMP/unsafe-home" +mkdir -p "$unsafe_home" +for case_name in root-target home-target overlap-target; do + case "$case_name" in + root-target) unsafe_mosaic=/; unsafe_prefix="$unsafe_home/.npm-global" ;; + home-target) unsafe_mosaic="$unsafe_home"; unsafe_prefix="$unsafe_home/.npm-global" ;; + overlap-target) unsafe_mosaic="$unsafe_home/.config"; unsafe_prefix="$unsafe_home/.config/mosaic/prefix" ;; + esac + before="$(fingerprint "$unsafe_home")" + set +e + HOME="$unsafe_home" MOSAIC_HOME="$unsafe_mosaic" MOSAIC_PREFIX="$unsafe_prefix" \ + MOSAIC_NO_COLOR=1 PATH="$check_bin:/usr/local/bin:/usr/bin:/bin" \ + bash "$ROOT/tools/install.sh" --check --next >"$TMP/$case_name.log" 2>&1 + status=$? + set -e + after="$(fingerprint "$unsafe_home")" + [[ "$status" -ne 0 ]] || fail_case "$case_name unsafe path returned zero" + grep -q '^\[P0\] FAIL:.*unsafe context' "$TMP/$case_name.log" \ + && pass_case "$case_name was rejected by P0" || fail_case "$case_name lacked an attributable P0 failure" + [[ "$before" == "$after" ]] || fail_case "$case_name mutated HOME" +done + +symlink_home="$TMP/symlink-home" +symlink_outside="$TMP/symlink-outside" +mkdir -p "$symlink_home" "$symlink_outside" +ln -s "$symlink_outside" "$symlink_home/.config" +set +e +HOME="$symlink_home" MOSAIC_HOME="$symlink_home/.config/mosaic" MOSAIC_PREFIX="$symlink_home/.npm-global" \ + MOSAIC_NO_COLOR=1 PATH="$check_bin:/usr/local/bin:/usr/bin:/bin" \ + bash "$ROOT/tools/install.sh" --check --next >"$TMP/symlink-target.log" 2>&1 +status=$? +set -e +[[ "$status" -ne 0 ]] || fail_case 'symlink-parent unsafe path returned zero' +grep -q '^\[P0\] FAIL:.*unsafe context' "$TMP/symlink-target.log" \ + && pass_case 'symlinked rollback parent was rejected by P0' \ + || fail_case 'symlinked rollback parent lacked an attributable P0 failure' +[[ -z "$(find "$symlink_outside" -mindepth 1 -print -quit)" ]] || fail_case 'symlink target was mutated' + +printf '[test] case: stale in-progress projection does not impersonate a live OS lock\n' +stale_home="$TMP/stale/home" +stale_state="$TMP/stale/state" +mkdir -p "$stale_home/.config/mosaic" "$stale_state" +printf '{"status":"in-progress","journal":"%s"}\n' "$stale_state/dead-run/journal.ndjson" > "$stale_state/active.json" +set +e +HOME="$stale_home" MOSAIC_HOME="$stale_home/.config/mosaic" MOSAIC_PREFIX="$stale_home/.npm-global" \ + MOSAIC_INSTALL_STATE_DIR="$stale_state" MOSAIC_INSTALL_FAULT_AFTER=P2 MOSAIC_NO_COLOR=1 \ + bash "$ROOT/tools/install.sh" --state-machine-self-test >"$TMP/stale.log" 2>&1 +status=$? +set -e +[[ "$status" -eq 97 ]] || fail_case "stale projection recovery expected injected status 97, got $status" +if find "$stale_state" -name prior-active.json -type f -print -quit | grep -q .; then + pass_case 'stale projection was preserved and superseded after the free OS lock was acquired' +else + fail_case 'stale projection was not preserved for recovery evidence' +fi +[[ "$(node -p "require('$stale_state/active.json').status")" == "rolled-back" ]] \ + || fail_case 'stale retry did not reach an honest rolled-back terminal state' + +printf '[test] case: journal initialization failure is fatal before mutation\n' +journal_home="$TMP/journal-failure/home" +mkdir -p "$journal_home/.config/mosaic" +printf 'journal-sentinel\n' > "$journal_home/.config/mosaic/operator.txt" +before="$(fingerprint "$journal_home")" +set +e +HOME="$journal_home" MOSAIC_HOME="$journal_home/.config/mosaic" MOSAIC_PREFIX="$journal_home/.npm-global" \ + MOSAIC_INSTALL_STATE_DIR="/proc/mosaic-journal-denied-$$" MOSAIC_INSTALL_FAULT_AFTER=P2 \ + MOSAIC_NO_COLOR=1 bash "$ROOT/tools/install.sh" --state-machine-self-test \ + >"$TMP/journal-failure.log" 2>&1 +status=$? +set -e +after="$(fingerprint "$journal_home")" +[[ "$status" -ne 0 ]] && pass_case 'unwritable journal directory failed non-zero' \ + || fail_case 'unwritable journal directory returned zero' +grep -q 'cannot create private journal directory' "$TMP/journal-failure.log" \ + && pass_case 'journal initialization failure was named' \ + || fail_case 'journal initialization failure lacked a named diagnostic' +[[ "$before" == "$after" ]] && pass_case 'journal failure occurred before target mutation' \ + || fail_case "journal failure mutated target HOME (before=$before after=$after)" + if [[ "$failures" -ne 0 ]]; then printf '[test] install state-machine acceptance RED: %d failed assertion(s)\n' "$failures" >&2 printf '[test] --check transcript: %s\n' "$TMP/check.log" >&2 diff --git a/tools/install.sh b/tools/install.sh index 76925174..79121edb 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -16,6 +16,10 @@ # --framework Install/upgrade framework only (skip npm CLI) # --cli Install/upgrade npm CLI only (skip framework) # --ref Git ref for framework archive (default: main) +# --next Prerelease lane: try fast npm @next install for CLI + +# gateway from the Gitea registry, then fall back to a +# source build at next if unavailable. Explicit +# --ref/MOSAIC_REF wins and uses the source path. # --dev Build CLI + gateway FROM SOURCE at --ref instead of the # registry @latest. Zero registry writes — packs local # tarballs and installs them globally. Use to test a branch @@ -31,6 +35,7 @@ # MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global) # MOSAIC_NO_COLOR — disable colour (set to 1) # MOSAIC_REF — git ref for framework (default: main) +# MOSAIC_NEXT — equivalent to --next (set to 1) # MOSAIC_DEV — equivalent to --dev (set to 1) # MOSAIC_ASSUME_YES — equivalent to --yes (set to 1) # ────────────────────────────────────────────────────────────────────────────── @@ -49,7 +54,13 @@ FLAG_NO_AUTO_LAUNCH=false FLAG_YES=false FLAG_UNINSTALL=false FLAG_DEV=false +FLAG_NEXT=false +FLAG_STATE_SELF_TEST=false GIT_REF="${MOSAIC_REF:-main}" +GIT_REF_EXPLICIT=false +if [[ -n "${MOSAIC_REF:-}" ]]; then + GIT_REF_EXPLICIT=true +fi # MOSAIC_ASSUME_YES env var acts the same as --yes if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then @@ -61,8 +72,18 @@ if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then FLAG_DEV=true fi +# MOSAIC_NEXT env var acts the same as --next: fast npm @next install with +# source fallback from the permanent next integration branch unless +# MOSAIC_REF/--ref explicitly wins. +if [[ "${MOSAIC_NEXT:-0}" == "1" ]]; then + FLAG_NEXT=true + if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then + GIT_REF="next" + fi +fi + installer_usage() { - printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref ] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2 + printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref ] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2 } while [[ $# -gt 0 ]]; do @@ -82,12 +103,17 @@ while [[ $# -gt 0 ]]; do exit 2 fi GIT_REF="$2" + GIT_REF_EXPLICIT=true shift 2 ;; --dev) FLAG_DEV=true; shift ;; + --next) FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;; --yes|-y) FLAG_YES=true; shift ;; --no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;; --uninstall) FLAG_UNINSTALL=true; shift ;; + # Internal acceptance seam: exercises the real journal/snapshot/rollback + # machinery against representative installer mutations. Not a user mode. + --state-machine-self-test) FLAG_STATE_SELF_TEST=true; shift ;; *) printf 'Error: Unknown argument: %s\n' "$1" >&2 installer_usage @@ -96,12 +122,24 @@ while [[ $# -gt 0 ]]; do esac done +# Explicit refs represent a request for that exact source tree. Keep --next as +# a lane selector, but do not install the registry @next package for a different +# ref than the permanent next branch. +if [[ "$FLAG_NEXT" == "true" && "$GIT_REF_EXPLICIT" == "true" ]]; then + FLAG_DEV=true +fi + +if [[ "$FLAG_YES" == "true" ]]; then + export MOSAIC_ASSUME_YES=1 +fi + # ─── constants ──────────────────────────────────────────────────────────────── MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstack/npm/}" SCOPE="${MOSAIC_SCOPE:-@mosaicstack}" PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}" CLI_PKG="${SCOPE}/mosaic" +GATEWAY_PKG="${SCOPE}/gateway" REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack" ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz" @@ -116,6 +154,20 @@ fi WORK_DIR="" EXTRACTED_DIR="" +newest_matching_file() { + local dir="$1" + local pattern="$2" + local matches=() + [[ -d "$dir" ]] || return 0 + shopt -s nullglob + # shellcheck disable=SC2206 # Intentional glob expansion for caller-provided file pattern. + matches=("$dir"/$pattern) + shopt -u nullglob + [[ "${#matches[@]}" -gt 0 ]] || return 0 + # shellcheck disable=SC2012 # Need portable mtime sorting across Linux/macOS. + ls -1t "${matches[@]}" 2>/dev/null | head -1 +} + # ─── uninstall path ─────────────────────────────────────────────────────────── # Shell-level uninstall for when the CLI is broken or not available. # Handles: framework directory, npm CLI package, npmrc scope line. @@ -179,7 +231,7 @@ if [[ "$FLAG_UNINSTALL" == "true" ]]; then # Find most recent backup backup="" if [[ -d "$dir" ]]; then - backup="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)" + backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")" fi if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then cp "$backup" "$dest" @@ -235,30 +287,79 @@ fail() { echo "${R}✖${RESET} $*" >&2; } dim() { echo "${DIM}$*${RESET}"; } step() { printf '\n%s%s%s\n' "$BOLD" "$*" "$RESET"; } +is_next_registry_lane() { + [[ "$FLAG_NEXT" == "true" && "$FLAG_DEV" == "false" && "$GIT_REF" == "next" && "$GIT_REF_EXPLICIT" == "false" ]] +} + +source_ref_details() { + if is_next_registry_lane; then + echo "ref: next, --next prerelease lane" + elif [[ "$FLAG_NEXT" == "true" && "$GIT_REF" == "next" ]]; then + echo "ref: next, --next prerelease lane (build-from-source)" + elif [[ "$FLAG_NEXT" == "true" ]]; then + echo "ref: ${GIT_REF}, --next requested, explicit ref wins" + else + echo "ref: ${GIT_REF}" + fi +} + # ─── helpers ────────────────────────────────────────────────────────────────── require_cmd() { if ! command -v "$1" &>/dev/null; then fail "Required command not found: $1" echo " Install it and re-run this script." - exit 1 + return 1 fi } installed_cli_version() { local json - json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true + json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" --cache="${STATE_NPM_CACHE:-${TMPDIR:-/tmp}/mosaic-install-npm-cache-$$}")" || true if [[ -n "$json" ]]; then node -e " const d = JSON.parse(process.argv[1]); const v = d?.dependencies?.['${CLI_PKG}']?.version ?? ''; process.stdout.write(v); - " "$json" 2>/dev/null || true + " "$json" || true + fi +} + +installed_gateway_version() { + local json + json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" --cache="${STATE_NPM_CACHE:-${TMPDIR:-/tmp}/mosaic-install-npm-cache-$$}")" || true + if [[ -n "$json" ]]; then + node -e " + const d = JSON.parse(process.argv[1]); + const v = d?.dependencies?.['${GATEWAY_PKG}']?.version ?? ''; + process.stdout.write(v); + " "$json" || true fi } latest_cli_version() { - npm view "${CLI_PKG}" version --registry="$REGISTRY" 2>/dev/null || true + npm view "${CLI_PKG}" version --registry="$REGISTRY" --cache="${STATE_NPM_CACHE:-${TMPDIR:-/tmp}/mosaic-install-npm-cache-$$}" || true +} + +next_cli_version() { + npm view "${CLI_PKG}@next" version --registry="$REGISTRY" --cache="${STATE_NPM_CACHE:-${TMPDIR:-/tmp}/mosaic-install-npm-cache-$$}" || true +} + +next_gateway_version() { + npm view "${GATEWAY_PKG}@next" version --registry="$REGISTRY" --cache="${STATE_NPM_CACHE:-${TMPDIR:-/tmp}/mosaic-install-npm-cache-$$}" || true +} + +next_pipeline_suffix() { + printf '%s' "$1" | sed -n 's/.*-next\.\([0-9][0-9]*\)$/\1/p' +} + +next_versions_share_pipeline() { + local cli_next="$1" + local gateway_next="$2" + local cli_pipeline gateway_pipeline + cli_pipeline="$(next_pipeline_suffix "$cli_next")" + gateway_pipeline="$(next_pipeline_suffix "$gateway_next")" + [[ -n "$cli_pipeline" && -n "$gateway_pipeline" && "$cli_pipeline" == "$gateway_pipeline" ]] } version_lt() { @@ -283,37 +384,760 @@ framework_version() { fi } -# Download + extract the monorepo archive at $GIT_REF exactly once per run. -# Sets the script-level EXTRACTED_DIR to the repo root. Reused by both the -# framework install (Part 1) and the dev build-from-source path (Part 2). +# ─── Transactional install state (canonical P0-P9) ─────────────────────────── +# The phase numbering and names are an external contract. C2-C5 bind to these +# exact numbers, so do not renumber when filling a failed postcondition. +INSTALL_PHASES=(P0 P1 P2 P3 P4 P5 P6 P7 P8 P9) +STATE_DIR="${MOSAIC_INSTALL_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/mosaic/install}" +STATE_RUN_DIR="" +STATE_JOURNAL="" +STATE_COMMAND_LOG="" +STATE_SNAPSHOT_DIR="" +STATE_FRAMEWORK_STATUS="" +STATE_INTERRUPTED_ACTIVE="" +STATE_CURRENT_PHASE="P0" +STATE_LOCK_FD="" +STATE_FAILURES=0 +STATE_FAILED_PHASES=() +STATE_NPM_CACHE="${TMPDIR:-/tmp}/mosaic-install-npm-cache-$$" +RESOLVED_CLI_VERSION="" +RESOLVED_SOURCE_DIGEST="" +LOCAL_SOURCE_ARCHIVE="${MOSAIC_INSTALL_LOCAL_SOURCE_ARCHIVE:-}" +LOCAL_SOURCE_COMMIT="${MOSAIC_INSTALL_LOCAL_SOURCE_COMMIT:-}" +LOCAL_SOURCE_SHA256="${MOSAIC_INSTALL_LOCAL_SOURCE_SHA256:-}" + +phase_name() { + case "$1" in + P0) echo "Resolve context" ;; P1) echo "Preflight" ;; + P2) echo "Acquire artifacts" ;; P3) echo "Install CLI" ;; + P4) echo "Install framework + skills" ;; P5) echo "Identity" ;; + P6) echo "Runtime linking / activation" ;; P7) echo "Services" ;; + P8) echo "Shell discoverability" ;; P9) echo "Verify + commit" ;; + *) echo "unknown" ;; + esac +} + +phase_contract() { + case "$1" in + P0) printf 'pre=target context available; action=resolve user/HOME/shell/platform; post=context stated and supported; rollback=n/a' ;; + P1) printf 'pre=P0 supported; action=validate tools/registry/headroom and acquire lock; post=preflight complete and exclusive; rollback=release lock' ;; + P2) printf 'pre=P1 exclusive; action=fetch pinned installer-distribution artifacts with visible output; post=lane/version/digest recorded; rollback=discard temporary artifacts; seam=does not forbid credentialed downstream acquisition' ;; + P3) printf 'pre=P2 pinned CLI; action=install CLI at known prefix; post=absolute binary version equals resolved version; rollback=restore prior prefix' ;; + P4) printf 'pre=P2 framework source and P3 absolute CLI; action=sync framework and skills; post=repository-shipped skills installed and loadable; rollback=restore prior framework/runtime trees' ;; + P5) printf 'pre=P3 absolute CLI; action=establish configured identity and validate any credential capability requested downstream; post=SOUL/USER valid owner/mode and required credential usable; rollback=remove generated identity/credential binding' ;; + P6) printf 'pre=P3 absolute CLI; action=evaluate runtime activation; post=dead #869 hooks never active without broker; rollback=restore runtime assets' ;; + P7) printf 'pre=P6 activation evaluated and applicable P5 credential committed; action=provision/manage requested services and credentialed resources only; post=requested services/resources ready; rollback=stop and restore requested services/resources' ;; + P8) printf 'pre=P3 absolute CLI; action=verify fresh target-user shells; post=login and non-login resolve P3 path; rollback=restore shell profiles' ;; + P9) printf 'pre=P0-P8 evaluated; action=reassert and commit journal/manifest; post=all phases pass and journal committed; rollback=restore pre-install snapshot' ;; + esac +} + +state_json_line() { + local event="$1" phase="$2" status="$3" message="$4" + [[ -n "$STATE_JOURNAL" ]] || return 0 + if ! EVENT="$event" PHASE="$phase" STATUS="$status" MESSAGE="$message" \ + node -e ' + const row={timestamp:new Date().toISOString(),event:process.env.EVENT,phase:process.env.PHASE,status:process.env.STATUS,message:process.env.MESSAGE}; + process.stdout.write(JSON.stringify(row)+"\\n"); + ' >> "$STATE_JOURNAL"; then + fail "Journal write failed at phase ${phase}; refusing an unrecorded mutation." + return 1 + fi + if ! sync "$STATE_JOURNAL"; then + fail "Journal sync failed at phase ${phase}; refusing an unrecorded mutation." + return 1 + fi +} + +state_record_mutation() { + local phase="$1" path="$2" reverse="$3" status root key covered=false + local prior="absent" snapshot="none" + if [[ -n "$STATE_SNAPSHOT_DIR" && -s "$STATE_SNAPSHOT_DIR/paths.tsv" ]]; then + while IFS=$'\t' read -r status root key; do + if [[ "$path" == "$root" || "$path" == "$root"/* ]]; then + covered=true + [[ -e "$path" || -L "$path" ]] && prior="present" + snapshot="$STATE_SNAPSHOT_DIR/data/$key" + break + fi + done < "$STATE_SNAPSHOT_DIR/paths.tsv" + fi + if [[ "$covered" != true && ( -e "$path" || -L "$path" ) ]]; then + # A path outside the declared snapshot cannot be mutated safely. + fail "Journal cannot bind prior state for $path before $phase mutation." + return 1 + fi + state_json_line mutation "$phase" planned "path=$path prior=$prior snapshot=$snapshot reverse=$reverse" +} + +state_seal_journal() { + local digest + state_json_line seal P9 committed "journal closed after manifest commit" || return + digest="$(sha256sum "$STATE_JOURNAL" | awk '{print $1}')" || return + if ! printf '%s %s\n' "$digest" "$(basename "$STATE_JOURNAL")" > "$STATE_JOURNAL.sha256" \ + || ! sync "$STATE_JOURNAL.sha256"; then + fail "Could not durably write the P9 journal seal." + return 1 + fi + if ! chmod 0444 "$STATE_JOURNAL" "$STATE_JOURNAL.sha256"; then + fail "Could not make the committed journal and seal immutable." + return 1 + fi + printf '%s' "$digest" +} + +state_framework_action_failed() { + local phase="$1" + [[ -n "$STATE_FRAMEWORK_STATUS" && -s "$STATE_FRAMEWORK_STATUS" ]] || return 1 + grep -q "^${phase}"$'\t'"failed"$'\t' "$STATE_FRAMEWORK_STATUS" +} + +state_manifest_action_failed() { + local phase="$1" manifest="$MOSAIC_HOME/.install-manifest.json" + [[ -s "$manifest" ]] || return 1 + node -e ' + const fs=require("fs"); + const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); + process.exit(data?.phaseOutcomes?.[process.argv[2]] === "failed" ? 0 : 1); + ' "$manifest" "$phase" 2>/dev/null +} + +state_action_failed() { + if [[ -n "$STATE_FRAMEWORK_STATUS" ]]; then + state_framework_action_failed "$1" + else + state_manifest_action_failed "$1" + fi +} + +state_run_captured() { + local label="$1" output status=0 + shift + output="$(mktemp "${TMPDIR:-/tmp}/mosaic-phase-command.XXXXXX.log")" || return + # The command is deliberately called in a conditional so its status can be + # journaled before the caller's ERR trap rolls back. Bash disables errexit in + # functions invoked this way, so every multi-command phase helper below must + # explicitly return on each required command failure. + if "$@" >"$output" 2>&1; then status=0; else status=$?; fi + cat "$output" || { rm -f "$output"; return 1; } + if ! { printf '\n=== %s (exit=%s) ===\n' "$label" "$status"; cat "$output"; } >> "$STATE_COMMAND_LOG"; then + rm -f "$output" + fail "Could not append '$label' output to $STATE_COMMAND_LOG; refusing to continue." + return 1 + fi + if ! sync "$STATE_COMMAND_LOG"; then + rm -f "$output" + fail "Could not sync '$label' output in $STATE_COMMAND_LOG; refusing to continue." + return 1 + fi + rm -f "$output" + state_json_line command "$STATE_CURRENT_PHASE" "$([[ "$status" -eq 0 ]] && echo committed || echo failed)" "label=$label output_log=$STATE_COMMAND_LOG exit=$status" + return "$status" +} + +state_write_active() { + local content="$1" + if ! printf '%s\n' "$content" > "$STATE_DIR/active.json" || ! sync "$STATE_DIR/active.json"; then + fail "Journal state write failed at $STATE_DIR/active.json; refusing to continue." + return 1 + fi +} + +state_phase_begin() { + STATE_CURRENT_PHASE="$1" + state_json_line phase "$1" started "$(phase_contract "$1")" +} + +state_phase_finish() { + state_json_line phase "$1" "$2" "$3" +} + +state_emit() { + local phase="$1" verdict="$2" reason="$3" + printf '[%s] %s: %s\n' "$phase" "$verdict" "$reason" + if [[ "$verdict" == "FAIL" ]]; then + STATE_FAILURES=$((STATE_FAILURES + 1)) + STATE_FAILED_PHASES+=("$phase") + fi +} + +state_target_shell() { + local shell="" + if command -v getent >/dev/null 2>&1; then + shell="$(getent passwd "$(id -u)" 2>/dev/null | cut -d: -f7 || true)" + fi + printf '%s' "${shell:-${SHELL:-}}" +} + +state_resolved_version() { + local cli gateway + if [[ "$FLAG_DEV" == "true" ]]; then + return 0 + fi + if is_next_registry_lane; then + cli="$(next_cli_version)" + gateway="$(next_gateway_version)" + [[ -n "$cli" && -n "$gateway" ]] && next_versions_share_pipeline "$cli" "$gateway" || return 0 + printf '%s' "$cli" + else + latest_cli_version + fi +} + +state_expected_cli_version() { + if [[ -n "$RESOLVED_CLI_VERSION" ]]; then + printf '%s' "$RESOLVED_CLI_VERSION" + elif [[ "$FLAG_DEV" == "true" && -s "$MOSAIC_HOME/.install-manifest.json" ]]; then + node -p "require('$MOSAIC_HOME/.install-manifest.json').cliVersion || ''" 2>/dev/null || true + else + state_resolved_version + fi +} + +state_predicate() { + local phase="$1" shell node_major installed expected + local missing=() login_path nonlogin_path broker=false dead_hooks=0 + local prefix_parent disk_kb inode_count min_disk_kb min_inodes npm_major privilege_mode + STATE_REASON="" + case "$phase" in + P0) + shell="$(state_target_shell)" + node_major="$(node -p 'Number(process.versions.node.split(".")[0])' 2>/dev/null || echo 0)" + npm_major="$(npm --version 2>/dev/null | cut -d. -f1 || echo 0)" + privilege_mode="$([[ "$(id -u)" -eq 0 ]] && echo root-without-explicit-target || echo user)" + if [[ -n "$HOME" && -n "$shell" && "$privilege_mode" == "user" && "$(uname -s)" == "Linux" ]] \ + && ldd --version 2>&1 | grep -qi 'glibc\|gnu libc' \ + && [[ "$(uname -m)" == "x86_64" ]] && [[ "$node_major" -ge 20 ]] && [[ "$npm_major" -ge 9 ]] \ + && state_validate_target_paths; then + STATE_REASON="target=$(id -un) uid=$(id -u) HOME=$HOME shell=$shell privilege=$privilege_mode arch=x86_64 libc=glibc node=$(node --version) npm=$(npm --version)" + return 0 + fi + STATE_REASON="unsupported, unresolved, or unsafe context (target=$(id -un 2>/dev/null || echo unknown) uid=$(id -u) HOME=${HOME:-unset} shell=${shell:-unset} privilege=$privilege_mode arch=$(uname -m 2>/dev/null || echo unknown) node_major=$node_major npm_major=$npm_major path_check=${STATE_PATH_REASON:-not-reached})" + return 1 + ;; + P1) + # Include tools invoked by downstream phases. Omitting git made P1 pass + # while P4's sync was already guaranteed to fail and be suppressed. + for tool in awk bash curl date df find flock git grep install mktemp node npm python3 realpath sed sha256sum stat sync tar; do + command -v "$tool" >/dev/null 2>&1 || missing+=("$tool") + done + if [[ "$FLAG_DEV" == "true" ]] && ! command -v corepack >/dev/null 2>&1; then + missing+=("corepack") + fi + # Concurrency authority is the OS-backed flock acquired by + # state_begin_install. active.json is a crash-recovery projection only; + # treating a stale in-progress projection as a live lock permanently + # blocked retries after SIGKILL or power loss. + if [[ -z "$STATE_LOCK_FD" && -f "$STATE_DIR/install.lock" ]]; then + local probe_lock_fd + if exec {probe_lock_fd}<>"$STATE_DIR/install.lock"; then + if ! flock -n "$probe_lock_fd"; then missing+=("concurrent-install-lock-held"); fi + exec {probe_lock_fd}>&- + else + missing+=("install-lock-unreadable") + fi + fi + prefix_parent="$(dirname "$PREFIX")" + [[ -d "$prefix_parent" && -w "$prefix_parent" ]] || missing+=("prefix-parent-not-writable") + min_disk_kb="${MOSAIC_INSTALL_MIN_DISK_KB:-262144}" + min_inodes="${MOSAIC_INSTALL_MIN_INODES:-1000}" + disk_kb="$(df -Pk "$prefix_parent" 2>&1 | awk 'NR==2 {print $4}')" + inode_count="$(df -Pi "$prefix_parent" 2>&1 | awk 'NR==2 {print $4}')" + [[ "$disk_kb" =~ ^[0-9]+$ && "$disk_kb" -ge "$min_disk_kb" ]] || missing+=("disk-headroom") + [[ "$inode_count" =~ ^[0-9]+$ && "$inode_count" -ge "$min_inodes" ]] || missing+=("inode-headroom") + if [[ "$FLAG_DEV" == "true" ]]; then + expected="source-build-at-immutable-ref" + else + expected="$(state_resolved_version)" + [[ -n "$expected" ]] || missing+=("registry-lane-unreachable-or-unauthenticated") + fi + if [[ "${#missing[@]}" -eq 0 ]]; then + STATE_REASON="downstream tool closure present; prefix parent writable; artifact lane resolvable; disk_kb=$disk_kb inodes=$inode_count; concurrency delegated to OS lock" + return 0 + fi + STATE_REASON="preflight failures: ${missing[*]}" + return 1 + ;; + P2) + if [[ "$FLAG_DEV" == "true" ]]; then + local source_commit="${RESOLVED_SOURCE_COMMIT:-}" source_digest="${RESOLVED_SOURCE_DIGEST:-}" + if [[ "$FLAG_CHECK" == "true" && -s "$MOSAIC_HOME/.install-manifest.json" ]]; then + source_commit="$(node -p "require('$MOSAIC_HOME/.install-manifest.json').sourceCommit || ''" 2>/dev/null || true)" + source_digest="$(node -p "require('$MOSAIC_HOME/.install-manifest.json').sourceSha256 || ''" 2>/dev/null || true)" + fi + if [[ "$source_commit" =~ ^[0-9a-f]{40}$ && "$source_digest" =~ ^[0-9a-f]{64}$ ]]; then + STATE_REASON="source_ref=$GIT_REF pinned_commit=$source_commit sha256=$source_digest" + return 0 + fi + STATE_REASON="source ref has no installed pinned commit/digest evidence (commit=${source_commit:-unavailable} sha256=${source_digest:-unavailable})" + return 1 + fi + expected="${RESOLVED_CLI_VERSION:-$(state_resolved_version)}" + if [[ -n "$expected" ]] && { [[ "$FLAG_CHECK" == "false" ]] || grep -qF "\"lane\": \"$([[ "$FLAG_NEXT" == true ]] && echo next || echo latest)\"" "$MOSAIC_HOME/.install-manifest.json" 2>/dev/null; }; then + STATE_REASON="lane=$([[ "$FLAG_NEXT" == true ]] && echo next || echo latest) pinned_version=$expected" + return 0 + fi + STATE_REASON="resolved_version=${expected:-unavailable}; installed manifest does not record the resolved lane" + return 1 + ;; + P3) + expected="$(state_expected_cli_version)" + installed="" + [[ -x "$PREFIX/bin/mosaic" ]] && installed="$("$PREFIX/bin/mosaic" --version 2>&1 | tail -n 1 | tr -d '\r' || true)" + if [[ -n "$expected" && -x "$PREFIX/bin/mosaic" && "$installed" == "$expected" ]]; then + STATE_REASON="absolute_path=$PREFIX/bin/mosaic version=$installed equals resolved lane version" + return 0 + fi + STATE_REASON="absolute_path=$PREFIX/bin/mosaic executable=$([[ -x "$PREFIX/bin/mosaic" ]] && echo yes || echo no) got=${installed:-missing} expected=${expected:-unresolved}" + return 1 + ;; + P4) + # C1 defines and enforces the assertion surface but does not choose among + # the four disagreeing candidate populations. C5 owns publishing and + # fulfilling the declaration. Until then P4 remains NOT-MEASURED. + local declared_set="$MOSAIC_HOME/.install-shipped-skills.json" + local expected_lane expected_version + expected_lane="$([[ "$FLAG_NEXT" == true ]] && echo next || echo latest)" + expected_version="$(state_expected_cli_version)" + if [[ ! -s "$declared_set" ]]; then + STATE_REASON="NOT-MEASURED / UNDECLARED: installer published no checkout-free, lane/versioned shipped-set artifact at $declared_set" + return 1 + fi + if ! EXPECTED_LANE="$expected_lane" EXPECTED_VERSION="$expected_version" MOSAIC_SKILLS_ROOT="$MOSAIC_HOME/skills" \ + node - "$declared_set" <<'NODE' +const fs = require('fs'); +const path = require('path'); +const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); +const root = path.resolve(process.env.MOSAIC_SKILLS_ROOT); +if (!data || typeof data !== 'object' || data.lane !== process.env.EXPECTED_LANE || + data.version !== process.env.EXPECTED_VERSION || !Array.isArray(data.skills) || data.skills.length === 0) process.exit(1); +for (const name of data.skills) { + if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) process.exit(1); + const skill = path.join(root, name, 'SKILL.md'); + let real; + try { real = fs.realpathSync(skill); } catch { process.exit(1); } + if (!real.startsWith(root + path.sep)) process.exit(1); + const stat = fs.statSync(real); + const text = fs.readFileSync(real, 'utf8'); + const declaredName = text.match(/^---\s*$[\s\S]*?^name:\s*([^\s]+)\s*$/m)?.[1]; + if (!stat.isFile() || stat.size === 0 || declaredName !== name) process.exit(1); +} +NODE + then + STATE_REASON="declared shipped-set artifact is malformed, wrong-lane/version, or its declared skills are not contained and loadable" + return 1 + fi + if state_action_failed P4; then + STATE_REASON="framework/skills action reported a required P4 failure; inspect the transaction command log" + return 1 + fi + STATE_REASON="declared shipped-set matches lane=$expected_lane version=$expected_version; every declared skill is contained and loadable" + return 0 + ;; + P5) + for skill in SOUL.md USER.md; do + local path="$MOSAIC_HOME/$skill" + if [[ ! -s "$path" ]] || ! grep -q '^# ' "$path" 2>/dev/null \ + || [[ "$(stat -c '%u' "$path" 2>/dev/null || echo -1)" != "$(id -u)" ]] \ + || [[ "$(stat -c '%a' "$path" 2>/dev/null || echo 777)" =~ [2367]$ ]]; then + missing+=("$skill") + fi + done + if [[ "${#missing[@]}" -eq 0 ]]; then STATE_REASON="SOUL.md and USER.md parse and have target owner/mode"; return 0; fi + STATE_REASON="identity missing, empty, malformed, wrong-owner, or unsafe-mode: ${missing[*]}" + return 1 + ;; + P6) + if state_action_failed P6; then + STATE_REASON="runtime linking/activation action reported a required P6 failure; inspect the transaction command log" + return 1 + fi + [[ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/mosaic-lease/broker.sock" ]] && broker=true + if [[ -f "$HOME/.claude/settings.json" ]]; then + dead_hooks="$(grep -Ec 'mutator-gate\.py|receipt-observer-client\.py' "$HOME/.claude/settings.json" || true)" + fi + if [[ "$broker" == true || "$dead_hooks" -eq 0 ]]; then + STATE_REASON="$([[ "$broker" == true ]] && echo 'activation broker present' || echo 'broker absent and #869 hooks inactive')" + return 0 + fi + STATE_REASON="broker absent but dead #869 enforcement hooks active (count=$dead_hooks)" + return 1 + ;; + P7) + STATE_REASON="no services requested by this installer invocation" + return 0 + ;; + P8) + shell="$(state_target_shell)" + case "${shell##*/}" in + bash|zsh) + login_path="$(env -i HOME="$HOME" USER="$(id -un)" LOGNAME="$(id -un)" SHELL="$shell" PATH=/usr/local/bin:/usr/bin:/bin "$shell" -lc 'command -v mosaic' 2>&1 || true)" + nonlogin_path="$(env -i HOME="$HOME" USER="$(id -un)" LOGNAME="$(id -un)" SHELL="$shell" PATH=/usr/local/bin:/usr/bin:/bin "$shell" -c 'command -v mosaic' 2>&1 || true)" + ;; + fish) + login_path="$(env -i HOME="$HOME" USER="$(id -un)" LOGNAME="$(id -un)" SHELL="$shell" PATH=/usr/local/bin:/usr/bin:/bin "$shell" -lc 'command -v mosaic' 2>&1 || true)" + nonlogin_path="$(env -i HOME="$HOME" USER="$(id -un)" LOGNAME="$(id -un)" SHELL="$shell" PATH=/usr/local/bin:/usr/bin:/bin "$shell" -c 'command -v mosaic' 2>&1 || true)" + ;; + *) STATE_REASON="unsupported or unresolved target shell: ${shell:-unset}"; return 1 ;; + esac + if [[ "$login_path" == "$PREFIX/bin/mosaic" && "$nonlogin_path" == "$PREFIX/bin/mosaic" ]]; then + STATE_REASON="login=$login_path nonlogin=$nonlogin_path equals P3 path" + return 0 + fi + STATE_REASON="fresh ${shell##*/} login=${login_path:-missing} nonlogin=${nonlogin_path:-missing} expected=$PREFIX/bin/mosaic" + return 1 + ;; + esac +} + +state_check_all() { + local phase + STATE_FAILURES=0 + STATE_FAILED_PHASES=() + for phase in "${INSTALL_PHASES[@]:0:9}"; do + if state_predicate "$phase"; then state_emit "$phase" PASS "$STATE_REASON"; else state_emit "$phase" FAIL "$STATE_REASON"; fi + done + [[ "$STATE_FAILURES" -eq 0 ]] +} + +# Component-only installs preserve their historical narrow contract. `--check` +# is never narrowed: it always calls state_check_all above and evaluates P0-P8. +state_check_install_scope() { + local phase + STATE_FAILURES=0 + STATE_FAILED_PHASES=() + for phase in "${INSTALL_PHASES[@]:0:9}"; do + if [[ "$FLAG_CLI" == "true" && "$FLAG_FRAMEWORK" == "false" && "$phase" =~ ^P[4-8]$ ]]; then + state_emit "$phase" PASS "not requested by --cli component-only install" + continue + fi + if [[ "$FLAG_FRAMEWORK" == "true" && "$FLAG_CLI" == "false" && "$phase" == "P3" ]]; then + state_emit "$phase" PASS "not requested by --framework component-only install" + continue + fi + if state_predicate "$phase"; then state_emit "$phase" PASS "$STATE_REASON"; else state_emit "$phase" FAIL "$STATE_REASON"; fi + done + [[ "$STATE_FAILURES" -eq 0 ]] +} + +state_path_is_safe_target() { + local raw="$1" canonical_home normalized owner + canonical_home="$(realpath -e -- "$HOME" 2>/dev/null)" || return 1 + [[ "$HOME" == "$canonical_home" && "$raw" == /* && "$raw" != *$'\n'* ]] || return 1 + normalized="$(realpath -m -- "$raw" 2>/dev/null)" || return 1 + [[ "$normalized" == "$raw" && "$raw" != "$HOME" && "$raw" == "$HOME"/* ]] || return 1 + # realpath -m follows every existing symlink component. Equality therefore + # rejects a target or parent redirected outside the rollback tree. + if [[ -e "$raw" || -L "$raw" ]]; then + [[ ! -L "$raw" ]] || return 1 + owner="$(stat -c '%u' "$raw" 2>/dev/null)" || return 1 + [[ "$owner" == "$(id -u)" ]] || return 1 + fi +} + +state_validate_target_paths() { + local left right i j + local targets=( + "$MOSAIC_HOME" "$PREFIX" "$HOME/.npmrc" "$HOME/.bashrc" "$HOME/.bash_profile" + "$HOME/.profile" "$HOME/.zshrc" "$HOME/.config/fish/config.fish" "$HOME/.claude" + "$HOME/.pi" "$HOME/.codex" "$HOME/.config/opencode" "$HOME/.config/mosaic-gateway" + "$HOME/.config/systemd" "$HOME/.local/share/systemd" "$HOME/.local/state/mosaic-gateway" + "$HOME/.local/state/mosaic/backups" + ) + STATE_PATH_REASON="" + for left in "${targets[@]}"; do + if ! state_path_is_safe_target "$left"; then + STATE_PATH_REASON="unsafe rollback target: $left (must be a non-symlinked, target-user-owned strict descendant of canonical HOME=$HOME)" + return 1 + fi + done + for ((i=0; i<${#targets[@]}; i++)); do + for ((j=i+1; j<${#targets[@]}; j++)); do + left="${targets[$i]}"; right="${targets[$j]}" + if [[ "$left" == "$right" || "$left" == "$right"/* || "$right" == "$left"/* ]]; then + STATE_PATH_REASON="overlapping rollback targets are forbidden: $left and $right" + return 1 + fi + done + done +} + +state_snapshot_create() { + local dst list path key index=0 + if ! state_validate_target_paths; then + fail "P1 Preflight refused snapshot creation: $STATE_PATH_REASON" + return 1 + fi + STATE_SNAPSHOT_DIR="$STATE_RUN_DIR/snapshot" + mkdir -p "$STATE_SNAPSHOT_DIR/data" + list="$STATE_SNAPSHOT_DIR/paths.tsv" + : > "$list" + for path in "$MOSAIC_HOME" "$PREFIX" "$HOME/.npmrc" "$HOME/.bashrc" "$HOME/.bash_profile" \ + "$HOME/.profile" "$HOME/.zshrc" "$HOME/.config/fish/config.fish" "$HOME/.claude" \ + "$HOME/.pi" "$HOME/.codex" "$HOME/.config/opencode" "$HOME/.config/mosaic-gateway" \ + "$HOME/.config/systemd" "$HOME/.local/share/systemd" "$HOME/.local/state/mosaic-gateway" \ + "$HOME/.local/state/mosaic/backups"; do + key="path-$index" + index=$((index + 1)) + if [[ -e "$path" || -L "$path" ]]; then + printf 'present\t%s\t%s\n' "$path" "$key" >> "$list" + dst="$STATE_SNAPSHOT_DIR/data/$key" + cp -a "$path" "$dst" + else + printf 'absent\t%s\t%s\n' "$path" "$key" >> "$list" + fi + done + state_json_line snapshot P1 committed "pre-install snapshot=$STATE_SNAPSHOT_DIR" +} + +state_snapshot_restore() { + local status target key saved + [[ -s "$STATE_SNAPSHOT_DIR/paths.tsv" ]] || return 1 + while IFS=$'\t' read -r status target key; do + [[ -n "$target" ]] || continue + saved="$STATE_SNAPSHOT_DIR/data/$key" + if ! state_path_is_safe_target "$target"; then + fail "Rollback refused unsafe or replaced target path: $target" + return 1 + fi + rm -rf -- "$target" || return + if [[ "$status" == "present" ]]; then + mkdir -p "$(dirname "$target")" || return + cp -a "$saved" "$target" || return + fi + done < "$STATE_SNAPSHOT_DIR/paths.tsv" +} + +state_begin_install() { + local run_id + if ! install -d -m 0700 "$STATE_DIR"; then + fail "P1 Preflight failed: cannot create private journal directory $STATE_DIR" + exit 1 + fi + exec {STATE_LOCK_FD}>"$STATE_DIR/install.lock" + if ! flock -n "$STATE_LOCK_FD"; then + fail "P1 Preflight failed: another Mosaic install holds $STATE_DIR/install.lock" + echo " Remediation: wait for the active install to finish, then rerun." >&2 + exit 1 + fi + run_id="$(date -u +%Y%m%dT%H%M%SZ)-$$" + STATE_RUN_DIR="$STATE_DIR/$run_id" + if ! install -d -m 0700 "$STATE_RUN_DIR"; then + fail "P1 Preflight failed: cannot create private journal run directory $STATE_RUN_DIR" + exit 1 + fi + if [[ -f "$STATE_DIR/active.json" ]] \ + && grep -q '"status"[[:space:]]*:[[:space:]]*"in-progress"' "$STATE_DIR/active.json"; then + STATE_INTERRUPTED_ACTIVE="$STATE_RUN_DIR/prior-active.json" + if ! cp "$STATE_DIR/active.json" "$STATE_INTERRUPTED_ACTIVE"; then + fail "P1 Preflight failed: could not preserve the interrupted transaction projection." + exit 1 + fi + fi + STATE_JOURNAL="$STATE_RUN_DIR/journal.ndjson" + STATE_COMMAND_LOG="$STATE_RUN_DIR/commands.log" + STATE_FRAMEWORK_STATUS="$STATE_RUN_DIR/framework-phase-status.tsv" + if ! install -m 0600 /dev/null "$STATE_JOURNAL" \ + || ! install -m 0600 /dev/null "$STATE_COMMAND_LOG" \ + || ! install -m 0600 /dev/null "$STATE_FRAMEWORK_STATUS"; then + fail "P1 Preflight failed: cannot initialize private journal files in $STATE_RUN_DIR" + exit 1 + fi + export MOSAIC_INSTALL_COMMAND_LOG="$STATE_COMMAND_LOG" + export MOSAIC_INSTALL_PHASE_STATUS_FILE="$STATE_FRAMEWORK_STATUS" + export NPM_CONFIG_CACHE="$STATE_RUN_DIR/npm-cache" + state_write_active "$(printf '{\"status\":\"in-progress\",\"run\":\"%s\",\"journal\":\"%s\"}' "$run_id" "$STATE_JOURNAL")" + state_json_line install P0 opened "transaction opened before target mutation" + if [[ -n "$STATE_INTERRUPTED_ACTIVE" ]]; then + state_json_line recovery P1 resumed "stale in-progress projection preserved at $STATE_INTERRUPTED_ACTIVE; OS lock was free; current run starts from the honestly retained partial state" + fi +} + +state_handle_unexpected_failure() { + local code="$1" phase="${2:-$STATE_CURRENT_PHASE}" + trap - ERR INT TERM + set +e + state_json_line install "$phase" failed "unexpected command failure exit=$code; rollback started" + if state_snapshot_restore; then + state_json_line install "$phase" rolled-back "pre-install snapshot restored" + state_write_active "$(printf '{\"status\":\"rolled-back\",\"phase\":\"%s\",\"journal\":\"%s\"}' "$phase" "$STATE_JOURNAL")" + fail "$phase $(phase_name "$phase") failed (exit $code); pre-install snapshot restored." + else + state_json_line install "$phase" rollback-failed "snapshot restoration failed or refused an unsafe target" + state_write_active "$(printf '{\"status\":\"rollback-failed\",\"phase\":\"%s\",\"journal\":\"%s\"}' "$phase" "$STATE_JOURNAL")" + fail "$phase $(phase_name "$phase") failed (exit $code); automatic rollback did not complete." + fi + echo " Remediation: inspect $STATE_COMMAND_LOG and $STATE_JOURNAL, correct the named failure, then rerun." >&2 + exit "$code" +} + +state_mark_resumable_failure() { + local failed="${STATE_FAILED_PHASES[*]}" + trap - ERR INT TERM + state_json_line install P9 failed-resumable "failed phases=$failed; mutations retained for explicit remediation" + state_write_active "$(printf '{\"status\":\"failed-resumable\",\"phases\":\"%s\",\"journal\":\"%s\"}' "$failed" "$STATE_JOURNAL")" + fail "P9 Verify + commit failed: postconditions failed in ${failed:-unknown}." + echo " Remediation: fix each named phase, then run this installer with --check; journal: $STATE_JOURNAL" >&2 +} + +state_self_test() { + local phase path + state_begin_install + state_snapshot_create + trap 'state_handle_unexpected_failure "$?" "$STATE_CURRENT_PHASE"' ERR INT TERM + for phase in P2 P3 P4 P5 P6 P7 P8; do + state_phase_begin "$phase" + case "$phase" in + P2) path="$MOSAIC_HOME/.selftest-artifact" ;; + P3) path="$PREFIX/bin/mosaic" ;; + P4) path="$MOSAIC_HOME/.selftest-framework" ;; + P5) path="$MOSAIC_HOME/SOUL.md" ;; + P6) path="$HOME/.claude/settings.json" ;; + P7) path="$MOSAIC_HOME/.selftest-service" ;; + P8) path="$HOME/.bashrc" ;; + esac + state_record_mutation "$phase" "$path" "restore representative path from $STATE_SNAPSHOT_DIR" + mkdir -p "$(dirname "$path")" + printf 'mutated-by-%s\n' "$phase" > "$path" + state_phase_finish "$phase" committed "representative mutation committed" + if [[ "${MOSAIC_INSTALL_FAULT_AFTER:-}" == "$phase" ]]; then + state_json_line fault "$phase" injected "phase=$phase" + echo "Injected installer fault: phase=$phase" >&2 + state_snapshot_restore + state_json_line install "$phase" rolled-back "fault injection restored pre-install snapshot" + state_write_active "$(printf '{\"status\":\"rolled-back\",\"phase\":\"%s\",\"journal\":\"%s\"}' "$phase" "$STATE_JOURNAL")" + exit 97 + fi + done + fail "self-test requires MOSAIC_INSTALL_FAULT_AFTER=P2..P8" + exit 2 +} + +resolve_source_commit() { + local encoded_ref body headers content_type + encoded_ref="$(node -p 'encodeURIComponent(process.argv[1])' "$GIT_REF")" + body="$(mktemp "${TMPDIR:-/tmp}/mosaic-ref.XXXXXX.json")" || return + headers="$(mktemp "${TMPDIR:-/tmp}/mosaic-ref.XXXXXX.headers")" || { rm -f "$body"; return 1; } + if ! curl -fsSL -D "$headers" -o "$body" \ + "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/commits?sha=${encoded_ref}&limit=1"; then + rm -f "$body" "$headers" + fail "P2 Acquire artifacts failed: could not resolve source ref '$GIT_REF'." + return 1 + fi + content_type="$(awk 'BEGIN{IGNORECASE=1} /^content-type:/{gsub(/\r/,""); sub(/^[^:]+:[[:space:]]*/,""); print; exit}' "$headers")" + if [[ "$content_type" != application/json* ]]; then + rm -f "$body" "$headers" + fail "P2 Acquire artifacts failed: ref endpoint returned content-type '${content_type:-missing}', not JSON." + return 1 + fi + RESOLVED_SOURCE_COMMIT="$(node -e ' + const fs=require("fs"); const rows=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); + if (!Array.isArray(rows) || rows.length!==1 || typeof rows[0].sha!=="string" || !/^[0-9a-f]{40}$/.test(rows[0].sha)) process.exit(1); + process.stdout.write(rows[0].sha); + ' "$body")" || { + rm -f "$body" "$headers" + fail "P2 Acquire artifacts failed: ref endpoint did not return exactly one commit with a sha." + return 1 + } + rm -f "$body" "$headers" + ARCHIVE_URL="${REPO_BASE}/archive/${RESOLVED_SOURCE_COMMIT}.tar.gz" +} + +# Download + extract the monorepo archive at the resolved immutable commit +# exactly once per run. Sets EXTRACTED_DIR for both P3 source fallback and P4. ensure_monorepo() { if [[ -n "$EXTRACTED_DIR" ]] && [[ -d "$EXTRACTED_DIR" ]]; then return 0 fi - require_cmd tar + require_cmd tar || return - WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-XXXXXX")" - # shellcheck disable=SC2317 + if [[ -n "$STATE_RUN_DIR" ]]; then + WORK_DIR="$STATE_RUN_DIR/work" + mkdir -p "$WORK_DIR" || return + else + WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-XXXXXX")" || return + fi + # shellcheck disable=SC2329 # Invoked by the EXIT trap below. cleanup_work() { [[ -n "$WORK_DIR" ]] && rm -rf "$WORK_DIR"; } trap cleanup_work EXIT - info "Downloading source from ${GIT_REF}…" - if command -v curl &>/dev/null; then - curl -fsSL "$ARCHIVE_URL" | tar xz -C "$WORK_DIR" - elif command -v wget &>/dev/null; then - wget -qO- "$ARCHIVE_URL" | tar xz -C "$WORK_DIR" + local archive="$WORK_DIR/source.tar.gz" + local max_archive_bytes="${MOSAIC_INSTALL_MAX_ARCHIVE_BYTES:-268435456}" + local max_expanded_bytes="${MOSAIC_INSTALL_MAX_EXPANDED_BYTES:-1073741824}" + if [[ -n "$LOCAL_SOURCE_ARCHIVE" ]]; then + if [[ ! "$LOCAL_SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ || ! "$LOCAL_SOURCE_SHA256" =~ ^[0-9a-f]{64}$ \ + || ! -f "$LOCAL_SOURCE_ARCHIVE" || -L "$LOCAL_SOURCE_ARCHIVE" ]]; then + fail "P2 Acquire artifacts failed: local checkout fixture requires a regular archive plus exact 40-hex source ID and 64-hex SHA-256." + return 1 + fi + RESOLVED_SOURCE_COMMIT="$LOCAL_SOURCE_COMMIT" + cp "$LOCAL_SOURCE_ARCHIVE" "$archive" || return + RESOLVED_SOURCE_DIGEST="$(sha256sum "$archive" | awk '{print $1}')" || return + if [[ "$RESOLVED_SOURCE_DIGEST" != "$LOCAL_SOURCE_SHA256" ]]; then + fail "P2 Acquire artifacts failed: local checkout archive digest does not match the fixture-pinned SHA-256." + return 1 + fi + info "Acquiring checkout fixture at content ID ${RESOLVED_SOURCE_COMMIT} with pinned SHA-256 ${RESOLVED_SOURCE_DIGEST}…" else - fail "curl or wget required to download source." - exit 1 + [[ -n "${RESOLVED_SOURCE_COMMIT:-}" ]] || resolve_source_commit || return + info "Downloading source ref ${GIT_REF} at pinned commit ${RESOLVED_SOURCE_COMMIT}…" + if command -v curl &>/dev/null; then + curl -fsSL --max-filesize "$max_archive_bytes" "$ARCHIVE_URL" -o "$archive" || return + elif command -v wget &>/dev/null; then + wget -O "$archive" "$ARCHIVE_URL" || return + else + fail "curl or wget required to download source." + return 1 + fi + RESOLVED_SOURCE_DIGEST="$(sha256sum "$archive" | awk '{print $1}')" || return fi + local archive_bytes + archive_bytes="$(stat -c '%s' "$archive" 2>/dev/null)" || return + if [[ ! "$archive_bytes" =~ ^[0-9]+$ || "$archive_bytes" -gt "$max_archive_bytes" ]]; then + fail "P2 Acquire artifacts failed: source archive exceeds the configured compressed-size limit." + return 1 + fi + # Reject traversal, links, devices, excessive entry counts, and expansion + # bombs before tar writes a byte. The immutable commit + digest are retained + # as provenance; authenticated release metadata remains the trust root for a + # future distribution-artifact lane. + if ! MAX_EXPANDED_BYTES="$max_expanded_bytes" python3 - "$archive" <<'PY' +import os +import pathlib +import sys +import tarfile + +archive = sys.argv[1] +limit = int(os.environ["MAX_EXPANDED_BYTES"]) +total = 0 +with tarfile.open(archive, "r:gz") as tf: + members = tf.getmembers() + if not members or len(members) > 100_000: + raise SystemExit(1) + for member in members: + pure = pathlib.PurePosixPath(member.name) + if pure.is_absolute() or ".." in pure.parts or member.issym() or member.islnk() or member.isdev(): + raise SystemExit(1) + if not (member.isfile() or member.isdir()): + raise SystemExit(1) + total += member.size + if total > limit: + raise SystemExit(1) +PY + then + fail "P2 Acquire artifacts failed: archive safety/integrity check failed (sha256=$RESOLVED_SOURCE_DIGEST)." + return 1 + fi + tar xzf "$archive" -C "$WORK_DIR" || return + state_json_line artifact P2 committed "lane=$GIT_REF source_commit=$RESOLVED_SOURCE_COMMIT sha256=$RESOLVED_SOURCE_DIGEST" || return # Gitea archives extract to / inside the work dir - EXTRACTED_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)" + EXTRACTED_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)" || return if [[ -z "$EXTRACTED_DIR" ]] || [[ ! -d "$EXTRACTED_DIR" ]]; then fail "Could not locate extracted source in archive." - ls -la "$WORK_DIR" >&2 - exit 1 + ls -la "$WORK_DIR" >&2 || true # Diagnostic only; the named P2 failure is authoritative. + return 1 fi } @@ -324,7 +1148,7 @@ ensure_monorepo() { install_cli_from_source() { local src="$EXTRACTED_DIR" local out_dir="$WORK_DIR/dist-tarballs" - mkdir -p "$out_dir" + mkdir -p "$out_dir" || return # pnpm via corepack (ships with Node >= 16.9; required by Node >= 20 preflight). # Pin to the repo's packageManager version so the build matches CI. Surface @@ -339,47 +1163,113 @@ install_cli_from_source() { if ! command -v pnpm &>/dev/null; then fail "pnpm not available after corepack activation." echo " Install pnpm manually (https://pnpm.io/installation) and re-run with --dev." - exit 1 + return 1 fi info "Installing workspace dependencies (pnpm install)…" - ( cd "$src" && pnpm install ) 2>&1 | sed 's/^/ /' + ( cd "$src" && pnpm install ) 2>&1 | sed 's/^/ /' || return info "Building CLI + gateway from source…" - ( cd "$src" && pnpm --filter "@mosaicstack/mosaic..." --filter "@mosaicstack/gateway..." run build ) 2>&1 | sed 's/^/ /' + ( cd "$src" && pnpm --filter "@mosaicstack/mosaic..." --filter "@mosaicstack/gateway..." run build ) 2>&1 | sed 's/^/ /' || return info "Packing local tarballs…" - ( cd "$src/packages/mosaic" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /' - ( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /' + ( cd "$src/packages/mosaic" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /' || return + ( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /' || return local cli_tgz gw_tgz - cli_tgz="$(ls -1t "$out_dir"/mosaicstack-mosaic-*.tgz 2>/dev/null | head -1)" - gw_tgz="$(ls -1t "$out_dir"/mosaicstack-gateway-*.tgz 2>/dev/null | head -1)" + cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')" + gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')" if [[ ! -f "$cli_tgz" ]]; then fail "CLI tarball was not produced by pnpm pack." - exit 1 + return 1 fi if [[ ! -f "$gw_tgz" ]]; then fail "Gateway tarball was not produced by pnpm pack." - exit 1 + return 1 fi # Gateway first so it is present globally before the CLI's wizard runs (which # skips its own gateway install via MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1). info "Installing gateway from source tarball (global)…" - npm install -g "$gw_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/ /' + npm install -g "$gw_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/ /' || return info "Installing CLI from source tarball (global)…" - npm install -g "$cli_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/ /' + npm install -g "$cli_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/ /' || return - ok "Installed from source: CLI $(installed_cli_version)" + # Source fallback replaces the registry candidate with the package version + # produced by the pinned source commit. P3 must compare against what P2 + # actually selected, not the failed registry candidate. + RESOLVED_CLI_VERSION="$(installed_cli_version)" || return + [[ -n "$RESOLVED_CLI_VERSION" ]] || { fail "Source install did not expose an installed CLI version."; return 1; } + state_json_line artifact P2 committed "source fallback selected cli_version=$RESOLVED_CLI_VERSION source_commit=${RESOLVED_SOURCE_COMMIT:-unknown}" || return + ok "Installed from source: CLI $RESOLVED_CLI_VERSION" } -# ─── preflight ──────────────────────────────────────────────────────────────── +install_next_cli_from_registry() { + local cli_next gateway_next + cli_next="$(next_cli_version)" + gateway_next="$(next_gateway_version)" + + if [[ -z "$cli_next" ]]; then + warn "${CLI_PKG}@next is unavailable from $REGISTRY." + return 1 + fi + if [[ -z "$gateway_next" ]]; then + warn "${GATEWAY_PKG}@next is unavailable from $REGISTRY." + return 1 + fi + + if ! next_versions_share_pipeline "$cli_next" "$gateway_next"; then + warn "@next CLI/gateway versions do not share a pipeline suffix (${cli_next}, ${gateway_next})." + return 1 + fi + + info "Installing ${CLI_PKG}@${cli_next} from registry…" + if ! npm install -g "${CLI_PKG}@${cli_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then + warn "Fast CLI @next install failed." + return 1 + fi + + info "Installing ${GATEWAY_PKG}@${gateway_next} from registry…" + if ! npm install -g "${GATEWAY_PKG}@${gateway_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then + warn "Fast gateway @next install failed." + return 1 + fi + + local installed_cli installed_gateway + installed_cli="$(installed_cli_version)" + installed_gateway="$(installed_gateway_version)" + if [[ "$installed_cli" != "$cli_next" || "$installed_gateway" != "$gateway_next" ]]; then + warn "Installed @next versions did not match resolved versions (CLI: ${installed_cli:-missing}, gateway: ${installed_gateway:-missing})." + return 1 + fi + + export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 + ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}" +} + +# ─── preflight / state-machine dispatch ────────────────────────────────────── + +if [[ "$FLAG_STATE_SELF_TEST" == "true" ]]; then + require_cmd node + require_cmd flock + state_self_test +fi + +# `--check` exits before mkdir, npm-prefix setup, locks, snapshots, downloads, or +# any other target mutation. Temporary observation files live under TMPDIR and +# are removed in the predicate that creates them. +if [[ "$FLAG_CHECK" == "true" ]]; then + check_status=0 + state_check_all || check_status=$? + rm -rf "$STATE_NPM_CACHE" + exit "$check_status" +fi require_cmd node require_cmd npm +require_cmd flock NODE_MAJOR="$(node -e 'process.stdout.write(String(process.versions.node.split(".")[0]))')" if [[ "$NODE_MAJOR" -lt 20 ]]; then @@ -391,10 +1281,53 @@ echo "" echo "${BOLD}Mosaic Stack Installer${RESET}" echo "" +# P0/P1 are pure preconditions. Open the durable journal and snapshot only after +# they pass, but before P2 performs the first target mutation. +if state_predicate P0; then + P0_REASON="$STATE_REASON" + state_emit P0 PASS "$P0_REASON" +else + state_emit P0 FAIL "$STATE_REASON" + fail "P0 Resolve context failed." + echo " Remediation: run as a supported non-root target user with explicit HOME/shell, glibc x86_64, and Node.js >=20." >&2 + exit 1 +fi +if state_predicate P1; then + P1_REASON="$STATE_REASON" + state_emit P1 PASS "$P1_REASON" +else + state_emit P1 FAIL "$STATE_REASON" + fail "P1 Preflight failed." + echo " Remediation: install the named prerequisites, clear any active transaction, and ensure the npm prefix parent is writable." >&2 + exit 1 +fi +state_begin_install +state_phase_finish P0 committed "$P0_REASON" +state_phase_finish P1 committed "$P1_REASON; exclusive lock acquired; journal opened" +state_snapshot_create +trap 'state_handle_unexpected_failure "$?" "$STATE_CURRENT_PHASE"' ERR INT TERM + +state_phase_begin P2 +state_record_mutation P2 "$STATE_RUN_DIR/work" "discard acquired temporary artifacts" +if [[ "$FLAG_DEV" == "true" ]]; then + RESOLVED_CLI_VERSION="" +else + RESOLVED_CLI_VERSION="$(state_resolved_version)" + if [[ -z "$RESOLVED_CLI_VERSION" ]]; then + fail "P2 Acquire artifacts failed: could not resolve a pinned CLI version for the requested lane." + false + fi +fi +if [[ "$FLAG_FRAMEWORK" == "true" || "$FLAG_DEV" == "true" ]]; then + state_run_captured "P2 acquire pinned source archive" ensure_monorepo +fi +state_phase_finish P2 committed "lane=$([[ "$FLAG_NEXT" == true ]] && echo next || echo latest) cli_version=${RESOLVED_CLI_VERSION:-pending-source-package-build} source_commit=${RESOLVED_SOURCE_COMMIT:-deferred-until-source-fallback} sha256=${RESOLVED_SOURCE_DIGEST:-deferred-until-source-fallback}" + # ═══════════════════════════════════════════════════════════════════════════════ # PART 1: Framework (bash launcher + guides + runtime configs + tools) # ═══════════════════════════════════════════════════════════════════════════════ +install_phase_p4_action() { if [[ "$FLAG_FRAMEWORK" == "true" ]]; then step "Framework (~/.config/mosaic)" @@ -409,7 +1342,7 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then else dim " Installed: (none)" fi - dim " Source: ${REPO_BASE} (ref: ${GIT_REF})" + dim " Source: ${REPO_BASE} ($(source_ref_details))" echo "" if [[ "$FLAG_CHECK" == "true" ]]; then @@ -419,15 +1352,15 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then warn "Framework not installed." fi else - # Download repo archive and extract framework (shared with the dev build) - ensure_monorepo + # Download repo archive and extract framework (shared with the dev build). + ensure_monorepo || return FRAMEWORK_SRC="$EXTRACTED_DIR/packages/mosaic/framework" if [[ ! -d "$FRAMEWORK_SRC" ]]; then fail "Framework not found in archive at packages/mosaic/framework/" fail "Archive contents:" - ls -la "$WORK_DIR" >&2 - exit 1 + ls -la "$WORK_DIR" >&2 || true # Diagnostic only; missing framework remains fatal. + return 1 fi # Run the framework's own install.sh (handles keep/overwrite for SOUL.md etc.) @@ -435,7 +1368,7 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then MOSAIC_INSTALL_MODE="${MOSAIC_INSTALL_MODE:-keep}" \ MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1 \ MOSAIC_SKIP_SKILLS_SYNC="${MOSAIC_SKIP_SKILLS_SYNC:-0}" \ - bash "$FRAMEWORK_SRC/install.sh" + bash "$FRAMEWORK_SRC/install.sh" || return ok "Framework installed" echo "" @@ -444,18 +1377,20 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then # to mosaic-launch directly via its absolute path. fi fi +} # ═══════════════════════════════════════════════════════════════════════════════ # PART 2: @mosaicstack/mosaic (npm — TUI, gateway client, wizard, CLI) # ═══════════════════════════════════════════════════════════════════════════════ +install_phase_p3_action() { if [[ "$FLAG_CLI" == "true" ]]; then step "@mosaicstack/mosaic (npm package)" # Ensure prefix dir if [[ ! -d "$PREFIX" ]]; then info "Creating global prefix directory: $PREFIX" - mkdir -p "$PREFIX"/{bin,lib} + mkdir -p "$PREFIX"/{bin,lib} || return fi # Ensure npmrc scope mapping @@ -464,20 +1399,24 @@ if [[ "$FLAG_CLI" == "true" ]]; then if ! grep -qF "$SCOPE_LINE" "$NPMRC" 2>/dev/null; then info "Adding ${SCOPE} registry to $NPMRC" - echo "$SCOPE_LINE" >> "$NPMRC" + echo "$SCOPE_LINE" >> "$NPMRC" || return ok "Registry configured" fi if ! grep -qF "prefix=$PREFIX" "$NPMRC" 2>/dev/null; then if ! grep -q '^prefix=' "$NPMRC" 2>/dev/null; then - echo "prefix=$PREFIX" >> "$NPMRC" + echo "prefix=$PREFIX" >> "$NPMRC" || return info "Set npm global prefix to $PREFIX" fi fi CURRENT="$(installed_cli_version)" + NEXT_GATEWAY="" if [[ "$FLAG_DEV" == "true" ]]; then LATEST="" + elif is_next_registry_lane; then + LATEST="$(next_cli_version)" + NEXT_GATEWAY="$(next_gateway_version)" else LATEST="$(latest_cli_version)" fi @@ -489,7 +1428,19 @@ if [[ "$FLAG_CLI" == "true" ]]; then fi if [[ "$FLAG_DEV" == "true" ]]; then - dim " Source: ${REPO_BASE} (ref: ${GIT_REF}, build-from-source)" + dim " Source: ${REPO_BASE} ($(source_ref_details), build-from-source)" + elif is_next_registry_lane; then + if [[ -n "$LATEST" ]]; then + dim " Next CLI: ${CLI_PKG}@${LATEST}" + else + dim " Next CLI: (registry @next unreachable)" + fi + if [[ -n "$NEXT_GATEWAY" ]]; then + dim " Next GW: ${GATEWAY_PKG}@${NEXT_GATEWAY}" + else + dim " Next GW: (registry @next unreachable)" + fi + dim " Fallback: ${REPO_BASE} (ref: next, build-from-source)" elif [[ -n "$LATEST" ]]; then dim " Latest: ${CLI_PKG}@${LATEST}" else @@ -500,6 +1451,12 @@ if [[ "$FLAG_CLI" == "true" ]]; then if [[ "$FLAG_CHECK" == "true" ]]; then if [[ "$FLAG_DEV" == "true" ]]; then info "Dev mode: installed version is ${CURRENT:-(none)} (no registry comparison)." + elif is_next_registry_lane; then + if [[ -n "$LATEST" && -n "$NEXT_GATEWAY" ]] && next_versions_share_pipeline "$LATEST" "$NEXT_GATEWAY"; then + ok "@next registry lane available: ${CLI_PKG}@${LATEST}, ${GATEWAY_PKG}@${NEXT_GATEWAY}." + else + warn "@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source." + fi elif [[ -z "$LATEST" ]]; then warn "Could not reach registry." elif [[ -z "$CURRENT" ]]; then @@ -513,8 +1470,25 @@ if [[ "$FLAG_CLI" == "true" ]]; then fi elif [[ "$FLAG_DEV" == "true" ]]; then info "Dev mode — building CLI + gateway from source at ref ${GIT_REF}…" - ensure_monorepo - install_cli_from_source + ensure_monorepo || return + install_cli_from_source || return + + # PATH check for npm prefix + if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then + warn "$PREFIX/bin is not on your PATH" + dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\"" + fi + elif is_next_registry_lane; then + info "Next mode — trying fast npm @next install from ${REGISTRY}…" + if install_next_cli_from_registry; then + : + else + warn "Falling back to source build at ref ${GIT_REF}; --next will not hard-fail on registry issues." + unset MOSAIC_GATEWAY_SKIP_NPM_INSTALL + ensure_monorepo || return + install_cli_from_source || return + export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 + fi # PATH check for npm prefix if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then @@ -526,13 +1500,13 @@ if [[ "$FLAG_CLI" == "true" ]]; then warn "Could not reach registry at $REGISTRY — skipping npm CLI." elif [[ -z "$CURRENT" ]]; then info "Installing ${CLI_PKG}@${LATEST}…" - npm install -g "${CLI_PKG}@${LATEST}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /' + npm install -g "${CLI_PKG}@${LATEST}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /' || return ok "CLI installed: $(installed_cli_version)" elif [[ "$CURRENT" == "$LATEST" ]]; then ok "Already at latest version ($LATEST)." elif version_lt "$CURRENT" "$LATEST"; then info "Upgrading ${CLI_PKG}: $CURRENT → $LATEST…" - npm install -g "${CLI_PKG}@${LATEST}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /' + npm install -g "${CLI_PKG}@${LATEST}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /' || return ok "CLI upgraded: $(installed_cli_version)" else ok "CLI is at or ahead of registry ($CURRENT ≥ $LATEST)." @@ -545,6 +1519,51 @@ if [[ "$FLAG_CLI" == "true" ]]; then fi fi fi +} + +# Execute actions in canonical order. The old installer ran P4 before P3, which +# made runtime-link diagnostics depend on shell discovery instead of P3's known +# absolute binary. P3 now commits before P4 begins. +state_phase_begin P3 +if [[ "$FLAG_CLI" == "true" ]]; then + state_record_mutation P3 "$PREFIX" "restore prefix from $STATE_SNAPSHOT_DIR" + state_record_mutation P3 "$HOME/.npmrc" "restore npmrc from $STATE_SNAPSHOT_DIR" +fi +state_run_captured "P3 install CLI" install_phase_p3_action +if [[ "$FLAG_CLI" == "false" ]]; then + state_phase_finish P3 not-requested "CLI component excluded by --framework" +elif state_predicate P3; then + state_phase_finish P3 committed "$STATE_REASON" +else + state_phase_finish P3 failed "$STATE_REASON" + fail "P3 Install CLI failed: $STATE_REASON" + false +fi + +state_phase_begin P4 +if [[ "$FLAG_FRAMEWORK" == "true" ]]; then + state_record_mutation P4 "$MOSAIC_HOME" "restore framework tree from $STATE_SNAPSHOT_DIR" + state_record_mutation P4 "$HOME/.pi" "restore Pi runtime assets from $STATE_SNAPSHOT_DIR" + state_record_mutation P4 "$HOME/.claude" "restore Claude runtime assets from $STATE_SNAPSHOT_DIR" + state_record_mutation P4 "$HOME/.codex" "restore Codex runtime assets from $STATE_SNAPSHOT_DIR" + state_record_mutation P4 "$HOME/.config/opencode" "restore OpenCode runtime assets from $STATE_SNAPSHOT_DIR" + state_record_mutation P4 "$HOME/.local/state/mosaic/backups" "restore framework backup state from $STATE_SNAPSHOT_DIR" + state_record_mutation P6 "$HOME/.claude/settings.json" "restore activation settings from $STATE_SNAPSHOT_DIR" +fi +state_run_captured "P4 install framework and skills; P6 evaluate activation" install_phase_p4_action +if [[ "$FLAG_FRAMEWORK" == "false" ]]; then + state_phase_finish P4 not-requested "framework component excluded by --cli" +elif state_predicate P4; then + state_phase_finish P4 committed "$STATE_REASON" +else + # C1 intentionally cannot commit P4 while the shipped-set declaration is + # absent. Keep the partial state for P5-P8 diagnostics; P9 fails non-zero. + state_phase_finish P4 failed-resumable "$STATE_REASON" +fi + +# P5/P7 actions (wizard/service requests) live in the summary flow below and +# bind their mutation records immediately before the wizard executes. P8 is +# observation-only today, so it must not fabricate planned mutation entries. # ═══════════════════════════════════════════════════════════════════════════════ # Summary @@ -580,7 +1599,13 @@ if [[ "$FLAG_CHECK" == "false" ]]; then MOSAIC_CMD="$MOSAIC_BIN" fi - if "$MOSAIC_CMD" wizard; then + state_record_mutation P5 "$MOSAIC_HOME/SOUL.md" "restore identity from $STATE_SNAPSHOT_DIR" + state_record_mutation P5 "$MOSAIC_HOME/USER.md" "restore identity from $STATE_SNAPSHOT_DIR" + state_record_mutation P7 "$HOME/.config/mosaic-gateway" "stop requested services and restore service state" + state_record_mutation P7 "$HOME/.config/systemd" "stop requested services and restore user units" + state_record_mutation P7 "$HOME/.local/share/systemd" "stop requested services and restore user units" + state_record_mutation P7 "$HOME/.local/state/mosaic-gateway" "stop requested services and restore service state" + if state_run_captured "P5 identity and P7 service wizard" "$MOSAIC_CMD" wizard; then ok "Wizard complete." else warn "Wizard exited non-zero." @@ -597,8 +1622,8 @@ if [[ "$FLAG_CHECK" == "false" ]]; then fi # ── Write install manifest ────────────────────────────────────────────────── - # Records what was mutated so that `mosaic uninstall` can precisely reverse it. - # Written last (after all mutations) so an incomplete install leaves no manifest. + # The mutation journal was opened before P2. This projection is written as + # pending-verification and becomes committed only after P9 reasserts P0-P8. MANIFEST_PATH="$MOSAIC_HOME/.install-manifest.json" MANIFEST_CLI_VERSION="$(installed_cli_version)" MANIFEST_FW_VERSION="$(framework_version)" @@ -624,7 +1649,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then local base dir backup_path backup_val base="$(basename "$dest")" dir="$(dirname "$dest")" - backup_path="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)" + backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")" if [[ -n "$backup_path" ]]; then backup_val="\"$backup_path\"" else @@ -642,6 +1667,10 @@ if [[ "$FLAG_CHECK" == "false" ]]; then } RUNTIME_COPIES="$(collect_runtime_copies)" + MANIFEST_P4_OUTCOME="committed" + MANIFEST_P6_OUTCOME="committed" + state_framework_action_failed P4 && MANIFEST_P4_OUTCOME="failed" + state_framework_action_failed P6 && MANIFEST_P6_OUTCOME="failed" # Check whether the npmrc line was present (we may have added it above) NPMRC_LINES_JSON="[]" @@ -649,15 +1678,24 @@ if [[ "$FLAG_CHECK" == "false" ]]; then NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]" fi - node -e " + MANIFEST_TMP="$MOSAIC_HOME/.install-manifest.json.tmp-$$" + state_record_mutation P9 "$MANIFEST_PATH" "restore manifest/framework tree from $STATE_SNAPSHOT_DIR" + state_record_mutation P9 "$MANIFEST_TMP" "remove pending manifest temp or restore framework tree from $STATE_SNAPSHOT_DIR" + if node -e " const fs = require('fs'); const path = require('path'); const p = process.argv[1]; const m = { - version: 1, + version: 2, + status: 'pending-verification', installedAt: process.argv[2], cliVersion: process.argv[3] || '(unknown)', frameworkVersion: parseInt(process.argv[4] || '0', 10), + lane: process.argv[7], + sourceCommit: process.argv[8], + sourceSha256: process.argv[9], + journal: process.argv[10], + phaseOutcomes: { P4: process.argv[11], P6: process.argv[12] }, mutations: { directories: [path.dirname(p)], npmGlobalPackages: ['@mosaicstack/mosaic'], @@ -667,19 +1705,68 @@ if [[ "$FLAG_CHECK" == "false" ]]; then } }; fs.mkdirSync(path.dirname(p), { recursive: true }); - fs.writeFileSync(p, JSON.stringify(m, null, 2) + '\\n', { mode: 0o600 }); + const tmp=process.argv[13]; + const fd=fs.openSync(tmp,'wx',0o600); + try { fs.writeFileSync(fd,JSON.stringify(m,null,2)+'\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } + fs.renameSync(tmp,p); + const dfd=fs.openSync(path.dirname(p),'r'); + try { fs.fsyncSync(dfd); } finally { fs.closeSync(dfd); } " \ "$MANIFEST_PATH" \ "$MANIFEST_TS" \ "$MANIFEST_CLI_VERSION" \ "$MANIFEST_FW_VERSION" \ "$NPMRC_LINES_JSON" \ - "$RUNTIME_COPIES" 2>/dev/null \ - && ok "Install manifest written: $MANIFEST_PATH" \ - || warn "Could not write install manifest (non-fatal)" + "$RUNTIME_COPIES" \ + "$([[ "$FLAG_NEXT" == true ]] && echo next || echo latest)" \ + "${RESOLVED_SOURCE_COMMIT:-not-requested}" \ + "${RESOLVED_SOURCE_DIGEST:-not-requested}" \ + "$STATE_JOURNAL" \ + "$MANIFEST_P4_OUTCOME" \ + "$MANIFEST_P6_OUTCOME" \ + "$MANIFEST_TMP"; then + ok "Install manifest written pending P9 verification: $MANIFEST_PATH" + else + fail "P9 Verify + commit could not durably write the install manifest." + false + fi + + # Record each deferred phase independently before the aggregate P9 verdict. + for phase in P5 P6 P7 P8; do + state_phase_begin "$phase" + if [[ "$FLAG_CLI" == "true" && "$FLAG_FRAMEWORK" == "false" ]]; then + state_phase_finish "$phase" not-requested "not requested by --cli component-only install" + elif state_predicate "$phase"; then + state_phase_finish "$phase" committed "$STATE_REASON" + else + state_phase_finish "$phase" failed-resumable "$STATE_REASON" + fi + done echo "" - ok "Done." + state_phase_begin P9 + if state_check_install_scope && [[ -s "$MANIFEST_PATH" ]]; then + MANIFEST_COMMIT_TMP="$MOSAIC_HOME/.install-manifest.json.commit-tmp-$$" + state_record_mutation P9 "$MANIFEST_COMMIT_TMP" "remove committed manifest temp or restore framework tree from $STATE_SNAPSHOT_DIR" + node -e ' + const fs=require("fs"), path=require("path"); const p=process.argv[1]; const m=JSON.parse(fs.readFileSync(p,"utf8")); + m.status="committed"; m.committedAt=new Date().toISOString(); + const tmp=process.argv[2]; const fd=fs.openSync(tmp,"wx",0o600); + try { fs.writeFileSync(fd,JSON.stringify(m,null,2)+"\n"); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } + fs.renameSync(tmp,p); const dfd=fs.openSync(path.dirname(p),"r"); + try { fs.fsyncSync(dfd); } finally { fs.closeSync(dfd); } + ' "$MANIFEST_PATH" "$MANIFEST_COMMIT_TMP" + state_json_line install P9 committed "all postconditions verified" + state_phase_finish P9 committed "P0-P8 reasserted; manifest durably committed; journal ready to seal" + state_write_active "$(printf '{\"status\":\"committed\",\"journal\":\"%s\"}' "$STATE_JOURNAL")" + state_seal_journal >/dev/null + trap - ERR INT TERM + ok "Done." + else + state_phase_finish P9 failed-resumable "failed phases=${STATE_FAILED_PHASES[*]}" + state_mark_resumable_failure + exit 1 + fi fi } # end main -- 2.54.0 From 7c4a4a4a3a093fa6fb77f7551c127e85bc79085a Mon Sep 17 00:00:00 2001 From: be-coder-05 Date: Wed, 5 Aug 2026 13:26:55 -0500 Subject: [PATCH 03/15] fix(ci): assert pinned greenfield expected red --- .woodpecker/greenfield-install.yml | 37 +++-------- docs/guides/installer-state-machine.md | 4 +- .../1050-install-state-machine-red-fixture.md | 8 +-- package.json | 2 +- tools/e2e-install-test.sh | 2 +- tools/fixtures/greenfield-expected-red.tsv | 51 +++++++++++++++ tools/install.sh | 2 +- tools/verify-greenfield-expected-red.sh | 63 +++++++++++++++++++ tools/verify-greenfield-expected-red.test.sh | 36 +++++++++++ 9 files changed, 167 insertions(+), 38 deletions(-) create mode 100644 tools/fixtures/greenfield-expected-red.tsv create mode 100755 tools/verify-greenfield-expected-red.sh create mode 100755 tools/verify-greenfield-expected-red.test.sh diff --git a/.woodpecker/greenfield-install.yml b/.woodpecker/greenfield-install.yml index 79756585..e7f8160d 100644 --- a/.woodpecker/greenfield-install.yml +++ b/.woodpecker/greenfield-install.yml @@ -1,6 +1,5 @@ -# C1 expected-RED gate. The fixture must execute from zero and discriminate the -# known failed postconditions; this step is green only when the fixture itself -# returns the expected non-zero and the named evidence rows are present. +# C1 detector gate. The fixture itself is intentionally RED; CI is green only +# when its exact phase verdicts/reasons match the versioned expected-RED manifest. when: - event: [pull_request, manual] - event: push @@ -18,15 +17,8 @@ steps: fixture_status=$? set -e cat /tmp/greenfield-git-present.log - test "$fixture_status" -eq 1 - grep -Eq '^\[fixture\] resolved lane=next .*version=[0-9]+\.[0-9]+\.[0-9]+-next\.' /tmp/greenfield-git-present.log - grep -q '^\[P1\] PASS: required tools present (including downstream git)' /tmp/greenfield-git-present.log - grep -q '^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version' /tmp/greenfield-git-present.log - grep -q '^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:' /tmp/greenfield-git-present.log - grep -q '^\[P5\] FAIL:' /tmp/greenfield-git-present.log - grep -q '^\[P6\] FAIL:' /tmp/greenfield-git-present.log - grep -q '^\[P8\] FAIL:' /tmp/greenfield-git-present.log - grep -q '^\[P9\] FAIL:' /tmp/greenfield-git-present.log + bash tools/verify-greenfield-expected-red.sh \ + next-git-present /tmp/greenfield-git-present.log "$fixture_status" greenfield-main-git-present: image: node:22-bookworm-slim @@ -39,15 +31,8 @@ steps: fixture_status=$? set -e cat /tmp/greenfield-main-git-present.log - test "$fixture_status" -eq 1 - grep -Eq '^\[fixture\] resolved lane=main .*version=[0-9]+\.[0-9]+\.[0-9]+' /tmp/greenfield-main-git-present.log - grep -q '^\[P1\] PASS: required tools present (including downstream git)' /tmp/greenfield-main-git-present.log - grep -q '^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version' /tmp/greenfield-main-git-present.log - grep -q '^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:' /tmp/greenfield-main-git-present.log - grep -q '^\[P5\] FAIL:' /tmp/greenfield-main-git-present.log - grep -q '^\[P6\] FAIL:' /tmp/greenfield-main-git-present.log - grep -q '^\[P8\] FAIL:' /tmp/greenfield-main-git-present.log - grep -q '^\[P9\] FAIL:' /tmp/greenfield-main-git-present.log + bash tools/verify-greenfield-expected-red.sh \ + main-git-present /tmp/greenfield-main-git-present.log "$fixture_status" greenfield-git-absent: image: node:22-bookworm-slim @@ -60,11 +45,5 @@ steps: fixture_status=$? set -e cat /tmp/greenfield-git-absent.log - test "$fixture_status" -eq 1 - grep -q '^\[fixture\] installer_exit=1 done_claims=0' /tmp/greenfield-git-absent.log - grep -q '^\[P1\] FAIL: undeclared/missing prerequisite(s)=git;' /tmp/greenfield-git-absent.log - grep -q '^\[P3\] FAIL: .*executable=no' /tmp/greenfield-git-absent.log - if grep -q 'Done\.' /tmp/greenfield-git-absent.log; then - echo 'git-absent state-machine run falsely certified Done' >&2 - exit 1 - fi + bash tools/verify-greenfield-expected-red.sh \ + next-git-absent /tmp/greenfield-git-absent.log "$fixture_status" diff --git a/docs/guides/installer-state-machine.md b/docs/guides/installer-state-machine.md index 1b2eb840..d2a5e30a 100644 --- a/docs/guides/installer-state-machine.md +++ b/docs/guides/installer-state-machine.md @@ -80,7 +80,7 @@ Rollback roots must be non-overlapping, non-symlinked, target-user-owned strict `.woodpecker/greenfield-install.yml` runs `tools/e2e-install-test.sh` from zero in Debian/glibc as a non-root uid with `env -i`. No host HOME, npm cache, credentials, or bind mount enters the target process. Checkout mode packages the complete current checkout into an archive, pins its SHA-256 through an internal fixture seam, and copies the self-contained fixture into the container; framework-installer changes in the PR are therefore exercised rather than fetched from an older remote branch. -The C1 gate intentionally validates an attributable RED while C2–C5 remain open: +The C1 fixture intentionally returns an attributable RED while C2–C5 remain open. CI itself remains green only when the fixture's final P0–P9 verdicts, required discriminator rows, and non-zero exit match the versioned contract in `tools/fixtures/greenfield-expected-red.tsv`. Any later remediation that changes an observed verdict makes CI red until the owning lane deliberately updates that manifest: - `git` present: P1 and strict P3 pass; P4/P5/P6/P8 fail for their own reasons; P9 refuses success. - `git` absent: P1 fails before target mutation and the installer emits no `Done.`. @@ -92,7 +92,7 @@ bash tools/e2e-install-test.sh --lane next --git present bash tools/e2e-install-test.sh --lane main --git present ``` -CI exercises both lane parameters as expected-RED structural checks. The authoritative main-lane promotion acceptance and issue closure remain owned by #1037. +CI exercises both lane parameters as expected-RED structural checks. Delivery targets `main` under the trunk-only merge rule; `next` remains a non-merging integration lane. The linked installer issue stays open after merge and closes only after Jarvis independently validates the greenfield behavior. ## Source trust boundary diff --git a/docs/scratchpads/1050-install-state-machine-red-fixture.md b/docs/scratchpads/1050-install-state-machine-red-fixture.md index a8a9b609..c04dc30e 100644 --- a/docs/scratchpads/1050-install-state-machine-red-fixture.md +++ b/docs/scratchpads/1050-install-state-machine-red-fixture.md @@ -8,7 +8,7 @@ Implement C1 from the canonical greenfield-install PRD v2: a transactional P0– - Canonical requirements: `jason.woltje/jarvis-brain` `docs/plans/2026-08-04-greenfield-install-blockers-PRD-v2.md`. Currency was re-derived after compaction: authenticated fetch resolved `origin/main` to `cb23e5fbc8a282fa967b93d7a134fa48d11b4bb1`; the PRD and charters are byte-identical to the previously read remote copies. - Tracking: `mosaicstack/stack#1050` on `git.mosaicstack.dev` (author read back as `be-coder-05`). -- Base: `origin/next` `4df478cdd150fdf8d52ea109f02ade5d85017acd`. +- Historical implementation base: `origin/next` `4df478cdd150fdf8d52ea109f02ade5d85017acd`. Delivery PR #1054 targets `main` under L0's trunk-only rule; `next` remains a non-merging integration lane. - Out of scope: PATH, skills, headless wizard/identity, activation remediation, #869 wiring, RM-02, main promotion. - `docs/TASKS.md` is orchestrator-single-writer and is not modified by this worker. @@ -49,13 +49,13 @@ Implement C1 from the canonical greenfield-install PRD v2: a transactional P0– - [x] P1 false pass identified from the P4 evidence row: `git` is absent from the Debian base and was undeclared even though skill sync shells out to it. C1 adds `git` to P1; the fixture matrix preserves absent/present controls. The prior claim that web1's missing runtime skills reproduce this greenfield mechanism is withdrawn by the TL and is not carried here. - [x] Corrected RED transcript captured and reported, including the git-present/absent controls and strict P3 PASS. - [x] State-machine implementation complete: private pre-mutation journal/snapshot, P0–P8 `--check`, P2–P8 fault seam, rollback, durable manifest/journal seal, action-status persistence, safe rollback roots, and stale-projection recovery. -- [x] Debian/glibc checkout fixture now packages the complete current checkout, verifies its digest in-container, and reaches the expected attributable RED without host inheritance. +- [x] Debian/glibc checkout fixture now packages the complete current checkout, verifies its digest in-container, and reaches the expected attributable RED without host inheritance. CI compares its exact final phase map/reasons to `tools/fixtures/greenfield-expected-red.tsv`; the fixture remains red while the detector job is green only on an exact match. - [ ] Reviews complete. Automated review defects around Bash conditional errexit, explicit exits, P4/P6 persisted action status, dev/offline source resolution, stale locks, checkout coverage, and rollback path safety were remediated. Remaining automated objections are the charter-mandated expected RED/C5 boundary and signed provenance, which the canonical PRD explicitly defers; independent informed review is still required. ## Risks / blockers - The deployed create wrappers do not expose `--dry-run`; identity preflight was performed through `pr-merge.sh --dry-run` on the same HOMELAB repo, which resolved `git.mosaicstack.dev` + `be-coder-05`. The issue create then fell back from tea to the API but provider read-back confirmed author `be-coder-05`. -- `next` is an integration lane; `main` promotion remains #1037-owned. +- `next` is a non-merging integration lane; PR #1054 targets `main`. The old “pending promotion to main” caution dissolved when the base moved. #1050 remains open after merge and closes only after Jarvis validates the greenfield behavior. - #869 must remain staged and inactive. - Late sequencing input MB-BRAIN-01 is accommodated without implementation or renumbering: P2 covers installer distribution only; P5 owns requested credential capability; P7 leaves an ordered seam for credential-dependent resource provisioning after P5. @@ -64,5 +64,5 @@ Implement C1 from the canonical greenfield-install PRD v2: a transactional P0– - `bash -n` and ShellCheck pass for all changed shell surfaces; `git diff --check` passes. - `bash tools/install-state-machine.test.sh` passes, including exact P0–P8 rows, good/bad discrimination, persisted P4/P6 action failures, P2–P8 rollback, unsafe/overlapping/symlink roots, stale `active.json`, and fatal journal initialization. - `bash tools/install-next-lane.test.sh` passes, including exact `@next` versions, immutable source fallback, source-build/archive-failure rollback, offline `--dev`, explicit refs, and prerelease suffix mismatch. -- `bash tools/e2e-install-test.sh --lane next --source checkout --git present` returns the required expected RED in clean Debian/glibc as uid 1001: installer P0/P1/P2/P3/P7 PASS; P4/P5/P6/P8 and P9 blocking; no `Done.` claim; checkout archive digest pinned and current framework installer exercised. +- `bash tools/e2e-install-test.sh --lane next --source checkout --git present` returns the required expected RED in clean Debian/glibc as uid 1001: installer P0/P1/P2/P3/P7 PASS; P4/P5/P6/P8 and P9 blocking; no `Done.` claim; checkout archive digest pinned and current framework installer exercised. `tools/verify-greenfield-expected-red.sh` converts that expected detector result into a green CI assertion and fails on any unreviewed verdict drift. - Earlier repository gates passed: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm test:installer`, upgrade manifest/rollback/durable-snapshot/migration suites, and focused `@mosaicstack/mosaic` tests with an isolated npm prefix. Full rerun is required after final edits. diff --git a/package.json b/package.json index 82155fdc..25027946 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "typecheck": "pnpm preflight && turbo run typecheck", "test:checkout": "node --test scripts/*.test.mjs", "test": "pnpm test:checkout && turbo run test && pnpm run test:installer", - "test:installer": "bash tools/install-state-machine.test.sh && bash tools/install-next-lane.test.sh", + "test:installer": "bash tools/install-state-machine.test.sh && bash tools/install-next-lane.test.sh && bash tools/verify-greenfield-expected-red.test.sh", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", "prepare": "node scripts/install-hooks.mjs" diff --git a/tools/e2e-install-test.sh b/tools/e2e-install-test.sh index 241d3d40..1f5b4d5f 100755 --- a/tools/e2e-install-test.sh +++ b/tools/e2e-install-test.sh @@ -146,7 +146,7 @@ printf '[fixture] installer_exit=%d done_claims=%s\n' \ # P0 Resolve context shell="$(getent passwd "$(id -u)" | cut -d: -f7)" if [[ "$(id -u)" -ne 0 && "$home" == "/home/mosaic" && "$shell" == "/bin/bash" ]] \ - && ldd --version 2>&1 | grep -qi 'glibc\|gnu libc' \ + && ldd --version 2>&1 | grep -i 'glibc\|gnu libc' >/dev/null \ && [[ "$(node -p 'Number(process.versions.node.split(".")[0])')" -ge 20 ]]; then phase_pass P0 "target=mosaic uid=$(id -u) HOME=$home shell=$shell libc=glibc node=$(node --version)" else diff --git a/tools/fixtures/greenfield-expected-red.tsv b/tools/fixtures/greenfield-expected-red.tsv new file mode 100644 index 00000000..b1f3614a --- /dev/null +++ b/tools/fixtures/greenfield-expected-red.tsv @@ -0,0 +1,51 @@ +# Pinned C1 expected-RED contract. Updating a verdict/reason requires review by the owning remediation lane. +# case kind key/value +next-git-present exit 1 +next-git-present phase P0=PASS +next-git-present phase P1=PASS +next-git-present phase P2=PASS +next-git-present phase P3=PASS +next-git-present phase P4=FAIL +next-git-present phase P5=FAIL +next-git-present phase P6=FAIL +next-git-present phase P7=PASS +next-git-present phase P8=FAIL +next-git-present phase P9=FAIL +next-git-present require ^\[fixture\] resolved lane=next .*version=[0-9]+\.[0-9]+\.[0-9]+-next\. +next-git-present require ^\[fixture\] installer_exit=1 done_claims=0$ +next-git-present require ^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version$ +next-git-present require ^\[P4\] FAIL: NOT-MEASURED / UNDECLARED: +next-git-present require ^\[P6\] FAIL: +next-git-present forbid Done\. +main-git-present exit 1 +main-git-present phase P0=PASS +main-git-present phase P1=PASS +main-git-present phase P2=PASS +main-git-present phase P3=PASS +main-git-present phase P4=FAIL +main-git-present phase P5=FAIL +main-git-present phase P6=FAIL +main-git-present phase P7=PASS +main-git-present phase P8=FAIL +main-git-present phase P9=FAIL +main-git-present require ^\[fixture\] resolved lane=main .*version=[0-9]+\.[0-9]+\.[0-9]+$ +main-git-present require ^\[fixture\] installer_exit=1 done_claims=0$ +main-git-present require ^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version$ +main-git-present require ^\[P4\] FAIL: NOT-MEASURED / UNDECLARED: +main-git-present require ^\[P6\] FAIL: +main-git-present forbid Done\. +next-git-absent exit 1 +next-git-absent phase P0=PASS +next-git-absent phase P1=FAIL +next-git-absent phase P2=FAIL +next-git-absent phase P3=FAIL +next-git-absent phase P4=FAIL +next-git-absent phase P5=FAIL +next-git-absent phase P6=PASS +next-git-absent phase P7=PASS +next-git-absent phase P8=FAIL +next-git-absent phase P9=FAIL +next-git-absent require ^\[fixture\] installer_exit=1 done_claims=0$ +next-git-absent require ^\[P1\] FAIL: undeclared/missing prerequisite\(s\)=git; +next-git-absent require ^\[P3\] FAIL: .*executable=no +next-git-absent forbid Done\. diff --git a/tools/install.sh b/tools/install.sh index 79121edb..59cdabf5 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -606,7 +606,7 @@ state_predicate() { npm_major="$(npm --version 2>/dev/null | cut -d. -f1 || echo 0)" privilege_mode="$([[ "$(id -u)" -eq 0 ]] && echo root-without-explicit-target || echo user)" if [[ -n "$HOME" && -n "$shell" && "$privilege_mode" == "user" && "$(uname -s)" == "Linux" ]] \ - && ldd --version 2>&1 | grep -qi 'glibc\|gnu libc' \ + && ldd --version 2>&1 | grep -i 'glibc\|gnu libc' >/dev/null \ && [[ "$(uname -m)" == "x86_64" ]] && [[ "$node_major" -ge 20 ]] && [[ "$npm_major" -ge 9 ]] \ && state_validate_target_paths; then STATE_REASON="target=$(id -un) uid=$(id -u) HOME=$HOME shell=$shell privilege=$privilege_mode arch=x86_64 libc=glibc node=$(node --version) npm=$(npm --version)" diff --git a/tools/verify-greenfield-expected-red.sh b/tools/verify-greenfield-expected-red.sh new file mode 100755 index 00000000..fad6ad4d --- /dev/null +++ b/tools/verify-greenfield-expected-red.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Verify that the detector found exactly the pinned C1 phase verdicts. The +# fixture is expected to exit non-zero; this verifier is the green CI contract. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MANIFEST="${MOSAIC_EXPECTED_RED_MANIFEST:-$ROOT/tools/fixtures/greenfield-expected-red.tsv}" +CASE="${1:?usage: verify-greenfield-expected-red.sh }" +LOG="${2:?usage: verify-greenfield-expected-red.sh }" +FIXTURE_EXIT="${3:?usage: verify-greenfield-expected-red.sh }" + +[[ -r "$MANIFEST" ]] || { echo "expected-RED manifest is unreadable: $MANIFEST" >&2; exit 2; } +[[ -r "$LOG" ]] || { echo "fixture log is unreadable: $LOG" >&2; exit 2; } +[[ "$FIXTURE_EXIT" =~ ^[0-9]+$ ]] || { echo "fixture exit is not numeric: $FIXTURE_EXIT" >&2; exit 2; } + +checks=0 +failures=0 +while IFS=$'\t' read -r case_name kind expectation; do + [[ -n "$case_name" && "${case_name:0:1}" != "#" ]] || continue + [[ "$case_name" == "$CASE" ]] || continue + checks=$((checks + 1)) + case "$kind" in + exit) + if [[ "$FIXTURE_EXIT" != "$expectation" ]]; then + echo "expected-RED mismatch: case=$CASE fixture_exit=$FIXTURE_EXIT expected=$expectation" >&2 + failures=$((failures + 1)) + fi + ;; + phase) + phase="${expectation%%=*}" + expected_verdict="${expectation#*=}" + last_row="$(grep -E "^\[$phase\] (PASS|FAIL):" "$LOG" | tail -n 1 || true)" + actual_verdict="$(printf '%s\n' "$last_row" | sed -n "s/^\[$phase\] \(PASS\|FAIL\):.*/\1/p")" + if [[ "$actual_verdict" != "$expected_verdict" ]]; then + echo "expected-RED mismatch: case=$CASE phase=$phase got=${actual_verdict:-missing} expected=$expected_verdict" >&2 + failures=$((failures + 1)) + fi + ;; + require) + if ! grep -Eq -- "$expectation" "$LOG"; then + echo "expected-RED missing required evidence: case=$CASE regex=$expectation" >&2 + failures=$((failures + 1)) + fi + ;; + forbid) + if grep -Eq -- "$expectation" "$LOG"; then + echo "expected-RED found forbidden evidence: case=$CASE regex=$expectation" >&2 + failures=$((failures + 1)) + fi + ;; + *) + echo "invalid expected-RED manifest kind: case=$case_name kind=$kind" >&2 + exit 2 + ;; + esac +done < "$MANIFEST" + +[[ "$checks" -gt 0 ]] || { echo "expected-RED manifest has no checks for case=$CASE" >&2; exit 2; } +if [[ "$failures" -ne 0 ]]; then + echo "expected-RED verification failed: case=$CASE failures=$failures checks=$checks" >&2 + exit 1 +fi +printf 'expected-RED verification passed: case=%s checks=%d\n' "$CASE" "$checks" diff --git a/tools/verify-greenfield-expected-red.test.sh b/tools/verify-greenfield-expected-red.test.sh new file mode 100755 index 00000000..10877ff6 --- /dev/null +++ b/tools/verify-greenfield-expected-red.test.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-expected-red-test.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT + +cat > "$TMP/match.log" <<'LOG' +[fixture] resolved lane=next package=@mosaicstack/mosaic@next version=0.0.50-next.999 +[fixture] installer_exit=1 done_claims=0 +[P0] PASS: supported context +[P1] PASS: preflight complete +[P2] PASS: pinned artifact +[P3] PASS: absolute_path=/home/test/.npm-global/bin/mosaic version=0.0.50-next.999 equals resolved lane version +[P4] FAIL: NOT-MEASURED / UNDECLARED: declaration absent +[P5] FAIL: identity absent +[P6] FAIL: activation unavailable +[P7] PASS: no services requested +[P8] FAIL: shell path absent +[P9] FAIL: aggregate refusal +LOG + +bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 1 >/dev/null +printf '[test] PASS: matching detector findings make the CI verifier green\n' + +sed 's/^\[P4\] FAIL:/[P4] PASS:/' "$TMP/match.log" > "$TMP/drift.log" +if bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/drift.log" 1 >/dev/null 2>&1; then + echo '[test] FAIL: changed P4 verdict did not invalidate the pinned manifest' >&2 + exit 1 +fi +printf '[test] PASS: changed phase verdict requires a deliberate manifest update\n' + +if bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 0 >/dev/null 2>&1; then + echo '[test] FAIL: unexpected fixture exit did not invalidate the pinned manifest' >&2 + exit 1 +fi +printf '[test] PASS: unexpected fixture exit remains blocking\n' -- 2.54.0 From 99e28d4100f0c9daaf243dabebddfd14cca18340 Mon Sep 17 00:00:00 2001 From: be-coder-05 Date: Wed, 5 Aug 2026 13:46:46 -0500 Subject: [PATCH 04/15] test(installer): make state fixture CI-portable --- tools/install-state-machine.test.sh | 75 +++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/tools/install-state-machine.test.sh b/tools/install-state-machine.test.sh index ae5dde26..c0dd5867 100755 --- a/tools/install-state-machine.test.sh +++ b/tools/install-state-machine.test.sh @@ -11,6 +11,22 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-state-test.XXXXXX")" trap 'rm -rf "$TMP"' EXIT failures=0 +COMPAT_BIN="$TMP/compat-bin" +mkdir -p "$COMPAT_BIN" +cat > "$COMPAT_BIN/realpath" <<'REALPATH' +#!/usr/bin/env python3 +import os +import sys + +args = sys.argv[1:] +mode = args.pop(0) if args and args[0] in ("-e", "-m") else "-m" +if args and args[0] == "--": + args.pop(0) +if len(args) != 1 or (mode == "-e" and not os.path.exists(args[0])): + raise SystemExit(1) +print(os.path.realpath(args[0])) +REALPATH +chmod 0755 "$COMPAT_BIN/realpath" fail_case() { printf '[test] FAIL: %s\n' "$*" >&2; failures=$((failures + 1)); } pass_case() { printf '[test] PASS: %s\n' "$*"; } @@ -18,11 +34,28 @@ pass_case() { printf '[test] PASS: %s\n' "$*"; } fingerprint() { local dir="$1" if [[ ! -d "$dir" ]]; then printf 'ABSENT\n'; return; fi - ( - cd "$dir" || exit 1 - find . -mindepth 1 -printf '%P|%y|%m|%u|%g|%l\n' | LC_ALL=C sort - find . -type f -print0 | LC_ALL=C sort -z | xargs -0 -r sha256sum - ) | sha256sum | awk '{print $1}' + python3 - "$dir" <<'PY' +import hashlib +import os +import stat +import sys + +root = os.path.abspath(sys.argv[1]) +rows = [] +for current, dirs, files in os.walk(root, topdown=True, followlinks=False): + for name in dirs + files: + path = os.path.join(current, name) + rel = os.path.relpath(path, root) + meta = os.lstat(path) + target = os.readlink(path) if stat.S_ISLNK(meta.st_mode) else "" + digest = "" + if stat.S_ISREG(meta.st_mode): + with open(path, "rb") as handle: + digest = hashlib.sha256(handle.read()).hexdigest() + rows.append((rel, stat.S_IFMT(meta.st_mode), stat.S_IMODE(meta.st_mode), meta.st_uid, meta.st_gid, target, digest)) +payload = "\n".join("|".join(map(str, row)) for row in sorted(rows)).encode() +print(hashlib.sha256(payload).hexdigest()) +PY } make_fake_npm() { @@ -97,6 +130,30 @@ good_prefix="$good_home/.npm-global" good_mosaic="$good_home/.config/mosaic" mkdir -p "$good_bin" "$good_prefix/bin" "$good_mosaic/skills/declared-skill" make_fake_npm "$good_bin" +cp "$COMPAT_BIN/realpath" "$good_bin/realpath" +cat > "$good_bin/id" <<'ID' +#!/bin/bash +case "${1:-}" in + -u) echo 1001 ;; + -g) echo 1001 ;; + -un) echo fixture-user ;; + *) exec /bin/id "$@" ;; +esac +ID +cat > "$good_bin/stat" <<'STAT' +#!/bin/bash +if [[ "${1:-} ${2:-}" == '-c %u' ]]; then echo 1001; exit 0; fi +exec /bin/stat "$@" +STAT +cat > "$good_bin/curl" <<'CURL' +#!/bin/bash +exit 0 +CURL +cat > "$good_bin/ldd" <<'LDD' +#!/bin/bash +echo 'ldd (GNU libc) 2.36' +LDD +chmod 0755 "$good_bin/id" "$good_bin/stat" "$good_bin/curl" "$good_bin/ldd" cat > "$good_prefix/bin/mosaic" <<'CLI' #!/usr/bin/env bash printf '0.0.50-next.999\n' @@ -175,7 +232,7 @@ for phase in P2 P3 P4 P5 P6 P7 P8; do set +e HOME="$home" MOSAIC_HOME="$home/.config/mosaic" MOSAIC_PREFIX="$home/.npm-global" \ MOSAIC_INSTALL_STATE_DIR="$state" MOSAIC_INSTALL_FAULT_AFTER="$phase" \ - MOSAIC_NO_COLOR=1 bash "$ROOT/tools/install.sh" --state-machine-self-test \ + MOSAIC_NO_COLOR=1 PATH="$COMPAT_BIN:$PATH" bash "$ROOT/tools/install.sh" --state-machine-self-test \ >"$TMP/fault-$phase.log" 2>&1 status=$? set -e @@ -205,7 +262,7 @@ for case_name in root-target home-target overlap-target; do before="$(fingerprint "$unsafe_home")" set +e HOME="$unsafe_home" MOSAIC_HOME="$unsafe_mosaic" MOSAIC_PREFIX="$unsafe_prefix" \ - MOSAIC_NO_COLOR=1 PATH="$check_bin:/usr/local/bin:/usr/bin:/bin" \ + MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \ bash "$ROOT/tools/install.sh" --check --next >"$TMP/$case_name.log" 2>&1 status=$? set -e @@ -222,7 +279,7 @@ mkdir -p "$symlink_home" "$symlink_outside" ln -s "$symlink_outside" "$symlink_home/.config" set +e HOME="$symlink_home" MOSAIC_HOME="$symlink_home/.config/mosaic" MOSAIC_PREFIX="$symlink_home/.npm-global" \ - MOSAIC_NO_COLOR=1 PATH="$check_bin:/usr/local/bin:/usr/bin:/bin" \ + MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \ bash "$ROOT/tools/install.sh" --check --next >"$TMP/symlink-target.log" 2>&1 status=$? set -e @@ -240,7 +297,7 @@ printf '{"status":"in-progress","journal":"%s"}\n' "$stale_state/dead-run/journa set +e HOME="$stale_home" MOSAIC_HOME="$stale_home/.config/mosaic" MOSAIC_PREFIX="$stale_home/.npm-global" \ MOSAIC_INSTALL_STATE_DIR="$stale_state" MOSAIC_INSTALL_FAULT_AFTER=P2 MOSAIC_NO_COLOR=1 \ - bash "$ROOT/tools/install.sh" --state-machine-self-test >"$TMP/stale.log" 2>&1 + PATH="$COMPAT_BIN:$PATH" bash "$ROOT/tools/install.sh" --state-machine-self-test >"$TMP/stale.log" 2>&1 status=$? set -e [[ "$status" -eq 97 ]] || fail_case "stale projection recovery expected injected status 97, got $status" -- 2.54.0 From 3edde464b3891ad439019fcc19aad7728e4c2fb8 Mon Sep 17 00:00:00 2001 From: be-coder-05 Date: Wed, 5 Aug 2026 17:46:25 -0500 Subject: [PATCH 05/15] fix(installer): harden greenfield detector contracts --- .woodpecker/greenfield-install.yml | 19 ++ README.md | 27 +- docs/PRD.md | 10 +- docs/guides/installer-state-machine.md | 14 +- .../1050-install-state-machine-red-fixture.md | 23 +- package.json | 2 +- packages/mosaic/framework/install.sh | 72 ++++- .../tools/_scripts/mosaic-link-runtime-assets | 13 +- tools/e2e-install-test.sh | 53 +++- tools/fixtures/greenfield-expected-red.tsv | 13 +- tools/install-next-lane.test.sh | 229 ++++++++++++++- tools/install-state-machine.test.sh | 180 ++++++++---- tools/install.sh | 269 +++++++++++++----- tools/install.sh.sha256 | 1 + tools/verified-installer-fetch.sh | 20 ++ tools/verified-installer-fetch.test.sh | 64 +++++ tools/verify-greenfield-expected-red.sh | 62 ++++ tools/verify-greenfield-expected-red.test.sh | 43 ++- 18 files changed, 924 insertions(+), 190 deletions(-) create mode 100644 tools/install.sh.sha256 create mode 100755 tools/verified-installer-fetch.sh create mode 100755 tools/verified-installer-fetch.test.sh diff --git a/.woodpecker/greenfield-install.yml b/.woodpecker/greenfield-install.yml index e7f8160d..d0206ba9 100644 --- a/.woodpecker/greenfield-install.yml +++ b/.woodpecker/greenfield-install.yml @@ -34,6 +34,25 @@ steps: bash tools/verify-greenfield-expected-red.sh \ main-git-present /tmp/greenfield-main-git-present.log "$fixture_status" + greenfield-remote-installer-contract: + image: node:22-bookworm-slim + commands: + - | + expected="$(awk 'NF {print $1; exit}' tools/install.sh.sha256)" + actual="$(sha256sum tools/install.sh | awk '{print $1}')" + test "$actual" = "$expected" + set +e + MOSAIC_GREENFIELD_CONTAINER=1 \ + MOSAIC_FIXTURE_INSTALLER_URL="https://git.mosaicstack.dev/mosaicstack/stack/raw/commit/${CI_COMMIT_SHA}/tools/install.sh" \ + MOSAIC_FIXTURE_INSTALLER_SHA256="$expected" \ + bash tools/e2e-install-test.sh --lane next --source remote --git present \ + > /tmp/greenfield-remote.log 2>&1 + fixture_status=$? + set -e + cat /tmp/greenfield-remote.log + bash tools/verify-greenfield-expected-red.sh \ + next-git-present /tmp/greenfield-remote.log "$fixture_status" + greenfield-git-absent: image: node:22-bookworm-slim commands: diff --git a/README.md b/README.md index 5bd090fa..365516b3 100644 --- a/README.md +++ b/README.md @@ -7,20 +7,21 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi — ## Quick Install ```bash -bash -o pipefail -c 'curl -fsSL https://mosaicstack.dev/install.sh | bash' +d="$(mktemp -d)" && trap 'rm -rf "$d"' EXIT && curl -fsSL -o "$d/install.sh" https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh -o "$d/install.sh.sha256" https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh.sha256 && (cd "$d" && test -s install.sh && sha256sum -c install.sh.sha256 && bash install.sh) ``` -Or use the direct URL: - -```bash -bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh) -``` +The published installer body must be non-empty and match its versioned SHA-256 +sidecar before it executes. A failed fetch, HTTP-200 empty body, or digest +mismatch is fatal. Because both files come from the same repository and trust +domain, this detects corruption or inconsistent publication—not repository or +server compromise. Independently signed release provenance is explicitly +deferred by the greenfield-install PRD. The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use: ```bash -bash <(curl -fsSL …) --yes # Accept all defaults -bash <(curl -fsSL …) --yes --no-auto-launch # Install only, skip wizard +(cd "$d" && bash install.sh --yes) # Accept all defaults +(cd "$d" && bash install.sh --yes --no-auto-launch) # Install only, skip wizard ``` This installs both components: @@ -348,16 +349,10 @@ Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding), ## Upgrading -Run the installer again — it handles upgrades automatically: +Run the same verified installer flow again — it handles upgrades automatically: ```bash -bash -o pipefail -c 'curl -fsSL https://mosaicstack.dev/install.sh | bash' -``` - -Or use the direct URL: - -```bash -bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh) +d="$(mktemp -d)" && trap 'rm -rf "$d"' EXIT && curl -fsSL -o "$d/install.sh" https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh -o "$d/install.sh.sha256" https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh.sha256 && (cd "$d" && test -s install.sh && sha256sum -c install.sh.sha256 && bash install.sh) ``` Or use the CLI: diff --git a/docs/PRD.md b/docs/PRD.md index b92d38ac..d9a64535 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1387,14 +1387,20 @@ A from-zero install can report success while leaving the target host unusable be 6. The from-zero fixture SHALL be lane-parametric, use Debian/glibc, run the documented install command as a non-root target user with an isolated HOME, and inherit no host credentials, npm cache, home directory, or runtime configuration. 7. The fixture SHALL select `next` with `--next` or `MOSAIC_NEXT=1` and assert the resolved lane version. Internal predicates use P3's absolute CLI path; shell discoverability is tested only at P8. 8. Fault injection after each P2–P8 phase SHALL prove either clean rollback or a durable, honestly reported resumable partial state, with no journal incorrectly left in progress. -9. Unsupported musl/Alpine and unavailable Docker SHALL fail loudly rather than skip as pass. +9. Unsupported musl/Alpine and unavailable Docker SHALL fail loudly rather than skip as pass. The repository's installer tests SHALL nevertheless run in the canonical Alpine CI image by explicitly modeling a supported non-root/glibc target and using portable filesystem enumeration. +10. P0 SHALL bind the effective uid and username to the authoritative passwd HOME and shell and state/reject unsafe root or sudo-with-inherited-HOME privilege contexts. +11. Created paths SHALL satisfy phase-specific target owner/group and mode policy: P3 executables are not group/world writable, framework/runtime trees are not group/world writable, and identity/credential material is private. +12. The expected-RED comparator SHALL validate the complete manifest before selecting a case: exact case population, one exit and P0–P9 disposition per case, pinned require/forbid classes, and no malformed, duplicate, or unknown rows. +13. The published installer contract SHALL reject failed fetches, HTTP-success empty bodies, and digest mismatch, then execute the exact digest-verified body. The remote CI arm SHALL bind that body to the immutable CI commit. +14. Phase diagnostics SHALL be redacted before terminal or durable-log output. A seeded positive-control canary SHALL remain absent from observed argv, output, command logs, npm configuration, generated files, and shell history. ### C1 acceptance criteria 1. The pre-C1 from-zero matrix records both discriminating controls: with `git` absent, the legacy installer still exits zero while P1 fails and skill sync degrades; with `git` present, P1 passes and the observed sync store/runtime links are 101/101. The C1 installer must fail at P1 before mutation when `git` is absent. 2. The discriminating P3 row passes: the binary exists at the expected absolute path and reports exactly the resolved `next` lane version, while P4, P5, and P8 fail. 3. The `--check` mutation negative control proves host fingerprints are byte-identical before and after observation. -4. Woodpecker executes and validates the expected RED fixture; C1 does not repair P4/P5/P8 or activate #869. +4. Woodpecker executes and validates the expected RED fixture plus the immutable remote-installer contract; C1 does not repair P4/P5/P8 or activate #869. +5. Negative controls prove manifest shrink/duplicates/unknown rows fail, unsafe P0/P3/P4/P5 contexts fail, the P2–P8 fault seam enters real actions rather than synthetic writes, empty/mismatched fetched bodies fail, and a deliberately emitted secret canary is redacted from every persisted/output population. ### Explicit exclusions and dependencies diff --git a/docs/guides/installer-state-machine.md b/docs/guides/installer-state-machine.md index d2a5e30a..e4fdf9e0 100644 --- a/docs/guides/installer-state-machine.md +++ b/docs/guides/installer-state-machine.md @@ -6,12 +6,12 @@ The unified installer uses a transactional P0–P9 model. It may report success | Phase | Responsibility | Failure disposition | | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| P0 Resolve context | State target user, HOME, shell, privilege mode, architecture, libc, Node, and npm | Fail before mutation | +| P0 Resolve context | Bind uid/username to the authoritative passwd HOME/shell, state privilege mode, architecture, libc, Node, and npm | Fail before mutation | | P1 Preflight | Validate downstream tool closure (including `git` and `python3`), writable prefix, registry lane, disk/inodes, and exclusive lock | Fail before target mutation | | P2 Acquire artifacts | Resolve exact registry versions and an immutable framework commit; record lane and SHA-256 | Discard temporary work | -| P3 Install CLI | Install at the configured absolute prefix and require exact resolved version | Restore the prior prefix/npmrc snapshot | +| P3 Install CLI | Install at the configured absolute prefix; require exact version plus target owner/group and non-writable executable mode | Restore the prior prefix/npmrc snapshot | | P4 Install framework + skills | Sync framework and consume a checkout-free, lane/versioned shipped-skill declaration | Restore prior framework/runtime trees | -| P5 Identity | Validate SOUL/USER content, owner, and mode; establish any credential capability requested downstream | Restore generated identity/credential binding | +| P5 Identity | Validate SOUL/USER content and private modes; require private credential storage and target owner/group | Restore generated identity/credential binding | | P6 Runtime linking / activation | Evaluate activation honestly; never treat dead enforcement hooks as active readiness | Restore runtime activation files | | P7 Services | Provision only requested services/resources after any required P5 credential commits | Stop and restore requested services/resources | | P8 Shell discoverability | Require fresh login and non-login shells of the actual target shell to resolve P3's path | Restore shell profiles | @@ -56,7 +56,7 @@ Before each mutation scope is touched, `journal.ndjson` records: - the reversal action; - the captured command-output location and command status. -Journal, action-status, manifest, or command-log write/sync failure is fatal. An unrecorded mutation is not allowed. Successful P9 runs append a seal event, write the SHA-256 sidecar, and make the journal and sidecar read-only. Required P4/P6 action failures are persisted in the manifest so a later `--check` cannot turn a failed action into a false pass. +Journal, action-status, manifest, or command-log write/sync failure is fatal. An unrecorded mutation is not allowed. Command diagnostics are redacted before terminal output or durable logging; credential-shaped environment values, bearer values, auth tokens, and credentialed URLs are never deliberately persisted. Successful P9 runs append a seal event, write the SHA-256 sidecar, and make the journal and sidecar read-only. Required P4/P6 action failures are persisted in the manifest so a later `--check` cannot turn a failed action into a false pass. Rollback roots must be non-overlapping, non-symlinked, target-user-owned strict descendants of canonical `HOME`; unsafe custom `MOSAIC_HOME`/`MOSAIC_PREFIX` values fail at P0. The same validation runs again immediately before recursive rollback. The OS lock is concurrency authority: if a process dies while `active.json` still says `in-progress`, a retry that acquires the free lock preserves the stale projection as `prior-active.json` and proceeds from the honestly retained partial state. @@ -80,7 +80,7 @@ Rollback roots must be non-overlapping, non-symlinked, target-user-owned strict `.woodpecker/greenfield-install.yml` runs `tools/e2e-install-test.sh` from zero in Debian/glibc as a non-root uid with `env -i`. No host HOME, npm cache, credentials, or bind mount enters the target process. Checkout mode packages the complete current checkout into an archive, pins its SHA-256 through an internal fixture seam, and copies the self-contained fixture into the container; framework-installer changes in the PR are therefore exercised rather than fetched from an older remote branch. -The C1 fixture intentionally returns an attributable RED while C2–C5 remain open. CI itself remains green only when the fixture's final P0–P9 verdicts, required discriminator rows, and non-zero exit match the versioned contract in `tools/fixtures/greenfield-expected-red.tsv`. Any later remediation that changes an observed verdict makes CI red until the owning lane deliberately updates that manifest: +The C1 fixture intentionally returns an attributable RED while C2–C5 remain open. CI itself remains green only when the fixture's final P0–P9 verdicts, required discriminator rows, seeded secret-canary scan, and non-zero exit match the versioned contract in `tools/fixtures/greenfield-expected-red.tsv`. The comparator validates the complete three-case schema before selecting a case: exactly one exit and P0–P9 disposition per case, pinned require/forbid populations, and no duplicate or unknown rows. Any later remediation that changes an observed verdict makes CI red until the owning lane deliberately updates that manifest: - `git` present: P1 and strict P3 pass; P4/P5/P6/P8 fail for their own reasons; P9 refuses success. - `git` absent: P1 fails before target mutation and the installer emits no `Done.`. @@ -92,8 +92,8 @@ bash tools/e2e-install-test.sh --lane next --git present bash tools/e2e-install-test.sh --lane main --git present ``` -CI exercises both lane parameters as expected-RED structural checks. Delivery targets `main` under the trunk-only merge rule; `next` remains a non-merging integration lane. The linked installer issue stays open after merge and closes only after Jarvis independently validates the greenfield behavior. +CI exercises both lane parameters as expected-RED structural checks. A separate remote-contract arm fetches the installer at the immutable CI commit, rejects failed or empty HTTP-success bodies, compares it to the reviewed `tools/install.sh.sha256`, and executes that exact fetched artifact. The P2–P8 fault matrix runs the real phase actions (including the P3 npm path, P4 framework path, and wizard path) rather than synthetic representative writes, then compares the complete target tree to its pre-install fingerprint. Delivery targets `main` under the trunk-only merge rule; `next` remains a non-merging integration lane. The linked installer issue stays open after merge and closes only after Jarvis independently validates the greenfield behavior. ## Source trust boundary -Remote source mode pins the resolved commit, records the archive SHA-256, limits compressed/expanded size and entry count, and rejects traversal, links, devices, and special files before extraction. This provides immutable run provenance and archive safety, not an independent authenticity root. Signed artifact metadata/provenance is explicitly deferred by the canonical greenfield PRD; C1 does not invent a signing system. The checkout CI seam does verify an expected digest supplied independently by the fixture. +Remote installer mode requires a non-empty body and an expected SHA-256 before execution. Remote source-archive mode separately pins the resolved commit, records the archive SHA-256, limits compressed/expanded size and entry count, and rejects traversal, links, devices, and special files before extraction. These controls provide immutable run provenance and archive safety, not an independent signing root. Signed artifact metadata/provenance is explicitly deferred by the canonical greenfield PRD; C1 does not invent a signing system. The checkout and remote CI seams verify reviewed digests before executing their artifacts. diff --git a/docs/scratchpads/1050-install-state-machine-red-fixture.md b/docs/scratchpads/1050-install-state-machine-red-fixture.md index c04dc30e..9af9c34c 100644 --- a/docs/scratchpads/1050-install-state-machine-red-fixture.md +++ b/docs/scratchpads/1050-install-state-machine-red-fixture.md @@ -50,7 +50,7 @@ Implement C1 from the canonical greenfield-install PRD v2: a transactional P0– - [x] Corrected RED transcript captured and reported, including the git-present/absent controls and strict P3 PASS. - [x] State-machine implementation complete: private pre-mutation journal/snapshot, P0–P8 `--check`, P2–P8 fault seam, rollback, durable manifest/journal seal, action-status persistence, safe rollback roots, and stale-projection recovery. - [x] Debian/glibc checkout fixture now packages the complete current checkout, verifies its digest in-container, and reaches the expected attributable RED without host inheritance. CI compares its exact final phase map/reasons to `tools/fixtures/greenfield-expected-red.tsv`; the fixture remains red while the detector job is green only on an exact match. -- [ ] Reviews complete. Automated review defects around Bash conditional errexit, explicit exits, P4/P6 persisted action status, dev/offline source resolution, stale locks, checkout coverage, and rollback path safety were remediated. Remaining automated objections are the charter-mandated expected RED/C5 boundary and signed provenance, which the canonical PRD explicitly defers; independent informed review is still required. +- [ ] Reviews complete. Reviews 80 (`rev-security-02`) and 81 (`rev-974`) requested changes at `3934e03f`; their eight non-overlapping detector findings are being remediated red-first. Current remediation adds canonical-image portability, absolute P3 CLI propagation, exact expected-RED schema/cardinality, passwd-HOME binding, created-path owner/mode policy, real-action P2–P8 fault injection, verified non-empty remote installer execution, and seeded secret-canary/redacted diagnostics. Both old verdicts become void when the remediation head moves and require fresh independent review. ## Risks / blockers @@ -59,10 +59,25 @@ Implement C1 from the canonical greenfield-install PRD v2: a transactional P0– - #869 must remain staged and inactive. - Late sequencing input MB-BRAIN-01 is accommodated without implementation or renumbering: P2 covers installer distribution only; P5 owns requested credential capability; P7 leaves an ordered seam for credential-dependent resource provisioning after P5. +## Remediation review controls + +- B1 RED: the next-lane harness failed immediately under `ci-base:latest` as root/musl; it now models uid 1001/glibc explicitly and uses Python tree fingerprints instead of GNU `find -printf`. +- B2 RED: framework/runtime linking consumed bare `mosaic` from PATH after P3 had committed an absolute path. The unified installer now exports/passes `MOSAIC_CLI_PATH`; the linker invokes that absolute artifact, and wizard auto-launch has no stale-PATH fallback. +- B3 RED: a one-row manifest (`exit=1`) certified any exit-1 log. Full-manifest validation now requires the exact three cases, one exit and P0–P9 row each, pinned require/forbid populations, and rejects malformed/duplicate/unknown rows; shrink is a negative control. +- B4 RED: uid 1001 with a passwd HOME different from ambient HOME produced P0 PASS. P0 now binds uid, username, passwd HOME and shell and explicitly rejects root and sudo-with-inherited-HOME controls. +- B5 RED: mode-0777 CLI, mode-0644 identity, and mode-0755 credential storage passed. P3/P4/P5 now apply target owner/group plus executable/shared/private policies; framework credential storage is created 0700. +- B6 RED: fault injection only wrote `.selftest-*` files. The synthetic path was removed; the P2–P8 matrix enters the normal action flow, proves an action observation occurred, injects after each real phase, and fingerprints rollback. +- B7 RED: an HTTP-200 empty body exits zero when piped to Bash. The fetched installer must now be non-empty, digest-equal to `tools/install.sh.sha256`, and that exact file is executed; failed/empty/mismatch controls are blocking and CI has a remote immutable-commit arm. +- B8 RED: raw combined command output was duplicated to terminal and `commands.log`. Both capture layers now redact before output/persistence; a seeded canary is positively emitted by the fake credential-capable registry and must remain absent from terminal, command log, npmrc, generated files and observed argv. The real greenfield fixture also scans those populations. +- Advisory code review findings are fixed: URL userinfo redaction now handles raw `@`, repeated `:`, percent encoding and multiple URLs in both capture layers; the real greenfield path positively emits its canary through `state_run_captured`; and verified-fetch removes its temporary body after successful execution. +- Advisory security review's independent trust-root finding is **DEFERRED by canonical PRD v2 §3**, which explicitly excludes signed provenance. README now states precisely that the same-origin sidecar detects empty/corrupt/inconsistent publication but cannot authenticate against repository/server compromise; no stronger claim remains. +- The web1 no-manifest representativeness observation is recorded but intentionally not acted on: it is explicitly outside these eight blockers. This remediation does not weaken or otherwise change P9's manifest-presence assertion. + ## Verification log - `bash -n` and ShellCheck pass for all changed shell surfaces; `git diff --check` passes. -- `bash tools/install-state-machine.test.sh` passes, including exact P0–P8 rows, good/bad discrimination, persisted P4/P6 action failures, P2–P8 rollback, unsafe/overlapping/symlink roots, stale `active.json`, and fatal journal initialization. -- `bash tools/install-next-lane.test.sh` passes, including exact `@next` versions, immutable source fallback, source-build/archive-failure rollback, offline `--dev`, explicit refs, and prerelease suffix mismatch. +- `bash tools/install-state-machine.test.sh` passes, including exact P0–P8 rows, passwd-HOME/privilege discrimination, owner/group/mode attacks, persisted P4/P6 action failures, no synthetic fault implementation, unsafe/overlapping/symlink roots, and fatal journal initialization. +- `bash tools/install-next-lane.test.sh` passes inside `ci-base:latest`, including exact `@next` versions, immutable source fallback, source-build/archive-failure rollback, offline `--dev`, explicit refs, prerelease suffix mismatch, absolute P3 CLI propagation, secret redaction, real-action P2–P8 rollback, and stale projection recovery. +- Comparator controls pass for verdict drift, unexpected exit, manifest shrink, missing phases, duplicate rows, unknown cases, and unknown kinds. Verified-fetch controls pass for successful execution and failed/empty/digest-mismatch rejection. - `bash tools/e2e-install-test.sh --lane next --source checkout --git present` returns the required expected RED in clean Debian/glibc as uid 1001: installer P0/P1/P2/P3/P7 PASS; P4/P5/P6/P8 and P9 blocking; no `Done.` claim; checkout archive digest pinned and current framework installer exercised. `tools/verify-greenfield-expected-red.sh` converts that expected detector result into a green CI assertion and fails on any unreviewed verdict drift. -- Earlier repository gates passed: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm test:installer`, upgrade manifest/rollback/durable-snapshot/migration suites, and focused `@mosaicstack/mosaic` tests with an isolated npm prefix. Full rerun is required after final edits. +- Earlier repository gates passed: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, upgrade manifest/rollback/durable-snapshot/migration suites, and focused `@mosaicstack/mosaic` tests with an isolated npm prefix. Full exact-remediation rerun is required before push. diff --git a/package.json b/package.json index 25027946..20054a7a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "typecheck": "pnpm preflight && turbo run typecheck", "test:checkout": "node --test scripts/*.test.mjs", "test": "pnpm test:checkout && turbo run test && pnpm run test:installer", - "test:installer": "bash tools/install-state-machine.test.sh && bash tools/install-next-lane.test.sh && bash tools/verify-greenfield-expected-red.test.sh", + "test:installer": "bash tools/install-state-machine.test.sh && bash tools/install-next-lane.test.sh && bash tools/verify-greenfield-expected-red.test.sh && bash tools/verified-installer-fetch.test.sh", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", "prepare": "node scripts/install-hooks.mjs" diff --git a/packages/mosaic/framework/install.sh b/packages/mosaic/framework/install.sh index f4352276..bb06a17a 100755 --- a/packages/mosaic/framework/install.sh +++ b/packages/mosaic/framework/install.sh @@ -710,9 +710,11 @@ trap 'restore_snapshot; exit 1' ERR INT TERM sync_framework -# Ensure persistent directories exist +# Ensure persistent directories exist. Credentials are private material and +# must never inherit a permissive umask/default mode. mkdir -p "$TARGET_DIR/memory" mkdir -p "$TARGET_DIR/credentials" +chmod 0700 "$TARGET_DIR/credentials" # Reconcile contract files from defaults/ into the framework root: framework-owned # files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent @@ -781,22 +783,74 @@ record_phase_outcome() { fi } +redact_install_stream() { + # Keep this bootstrap copy behaviorally identical to tools/install.sh's + # state_redact_stream; neither installer can assume the other is installed. + python3 /dev/fd/3 3<<'PY' +import os, re, sys +text = sys.stdin.read() +secret_name = re.compile(r"(?:TOKEN|PASSWORD|PASSWD|SECRET|API_KEY|AUTH|CREDENTIAL|CANARY)", re.I) +secrets = {value for name, value in os.environ.items() if secret_name.search(name) and len(value) >= 4} +for value in sorted(secrets, key=len, reverse=True): + text = text.replace(value, "[REDACTED]") +patterns = ( + (re.compile(r"(?im)^(\s*(?:proxy-)?authorization\s*:\s*)[^\r\n]+"), r"\1[REDACTED]"), + (re.compile(r"(?im)^(\s*(?:set-)?cookie\s*:\s*)[^\r\n]+"), r"\1[REDACTED]"), + (re.compile(r"(?i)(Bearer\s+)[^\s'\"]+"), r"\1[REDACTED]"), + (re.compile(r"(?i)((?:[_-]?auth(?:Token)?|token|password|passwd|secret|api[_-]?key)\s*[=:]\s*)[^\s'\"]+"), r"\1[REDACTED]"), +) +for pattern, replacement in patterns: + text = pattern.sub(replacement, text) +url_pattern = re.compile(r"https?://[^\s'\"<>]+", re.I) +def redact_url(match): + url = match.group(0) + scheme_end = url.find("://") + 3 + authority_end = len(url) + for separator in "/?#": + position = url.find(separator, scheme_end) + if position != -1: + authority_end = min(authority_end, position) + authority = url[scheme_end:authority_end] + at = authority.rfind("@") + if at != -1: + return url[:scheme_end] + "[REDACTED]@" + authority[at + 1:] + url[authority_end:] + return url +sys.stdout.write(url_pattern.sub(redact_url, text)) +PY +} + run_captured() { - local label="$1" output status=0 + local label="$1" redacted redactor_pid capture_fd status=0 redact_status=0 shift - output="$(mktemp "${TMPDIR:-/tmp}/mosaic-post-install.XXXXXX.log")" - if "$@" >"$output" 2>&1; then status=0; else status=$?; fi + redacted="$(mktemp "${TMPDIR:-/tmp}/mosaic-post-redacted.XXXXXX")" + chmod 0600 "$redacted" || { rm -f "$redacted"; exit 1; } + # Preserve in-shell command behavior without ever staging plaintext output on + # disk. Process substitution carries raw bytes only through a pipe. + exec {capture_fd}> >(redact_install_stream > "$redacted") + redactor_pid=$! + set +e + "$@" >&"$capture_fd" 2>&1 + status=$? + exec {capture_fd}>&- + wait "$redactor_pid" + redact_status=$? + set -e + if [[ "$redact_status" -ne 0 ]]; then + rm -f "$redacted" + fail "Could not redact '$label' diagnostics; refusing to expose or persist raw output." + exit 1 + fi if [[ -n "${MOSAIC_INSTALL_COMMAND_LOG:-}" ]]; then - if ! { printf '\n=== %s (exit=%s) ===\n' "$label" "$status"; cat "$output"; } >> "$MOSAIC_INSTALL_COMMAND_LOG" \ + if ! { printf '\n=== %s (exit=%s) ===\n' "$label" "$status"; cat "$redacted"; } >> "$MOSAIC_INSTALL_COMMAND_LOG" \ || ! sync "$MOSAIC_INSTALL_COMMAND_LOG"; then - cat "$output" >&2 - rm -f "$output" + cat "$redacted" >&2 + rm -f "$redacted" fail "Could not durably append '$label' diagnostics to the install command log." exit 1 fi fi - if [[ "$status" -ne 0 ]]; then cat "$output" >&2; fi - rm -f "$output" + if [[ "$status" -ne 0 ]]; then cat "$redacted" >&2; fi + rm -f "$redacted" return "$status" } diff --git a/packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets b/packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets index 363fab78..a4e9baee 100755 --- a/packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets +++ b/packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets @@ -68,8 +68,15 @@ copy_claude_settings_guarded() { guard_args+=(--allow-inactive-enforcement) fi - if command -v mosaic >/dev/null 2>&1; then - if mosaic "${guard_args[@]}"; then + local mosaic_cli="${MOSAIC_CLI_PATH:-}" + # Unified install passes P3's committed absolute artifact. Standalone + # framework installs may resolve PATH once, but still invoke the resulting + # absolute path rather than a bare command. + if [[ -z "$mosaic_cli" ]]; then + mosaic_cli="$(command -v mosaic 2>/dev/null || true)" + fi + if [[ "$mosaic_cli" == /* && -x "$mosaic_cli" ]]; then + if "$mosaic_cli" "${guard_args[@]}"; then return 0 fi echo "[mosaic-link] Enforcement hooks were NOT wired into $dst (see message above)." >&2 @@ -77,7 +84,7 @@ copy_claude_settings_guarded() { return 0 fi - echo "[mosaic-link] ERROR: 'mosaic' CLI not found on PATH — cannot confirm lease-enforcement" >&2 + echo "[mosaic-link] ERROR: P3 absolute mosaic CLI unavailable — cannot confirm lease-enforcement" >&2 echo "[mosaic-link] activation capability. enforcement requested but activation half absent —" >&2 echo "[mosaic-link] needs a published CLI carrying launch-runtime activation + a broker" >&2 echo "[mosaic-link] supervisor; refusing to wire a dead gate (see #869)." >&2 diff --git a/tools/e2e-install-test.sh b/tools/e2e-install-test.sh index 1f5b4d5f..0938b537 100755 --- a/tools/e2e-install-test.sh +++ b/tools/e2e-install-test.sh @@ -14,6 +14,8 @@ SOURCE="${MOSAIC_INSTALL_SOURCE:-checkout}" IMAGE="${MOSAIC_INSTALL_IMAGE:-node:22-bookworm-slim}" GIT_MODE="${MOSAIC_INSTALL_GIT_MODE:-present}" INSTALLER_FILE="${MOSAIC_FIXTURE_INSTALLER_FILE:-$ROOT/tools/install.sh}" +INSTALLER_URL="${MOSAIC_FIXTURE_INSTALLER_URL:-}" +INSTALLER_SHA256="${MOSAIC_FIXTURE_INSTALLER_SHA256:-}" IN_CLEAN_CONTAINER="${MOSAIC_GREENFIELD_CONTAINER:-0}" usage() { @@ -39,6 +41,11 @@ done case "$LANE" in next|main) ;; *) echo "[fixture] unsupported lane '$LANE' (expected next|main)" >&2; exit 2 ;; esac case "$SOURCE" in checkout|remote) ;; *) echo "[fixture] unsupported source '$SOURCE' (expected checkout|remote)" >&2; exit 2 ;; esac case "$GIT_MODE" in present|absent) ;; *) echo "[fixture] unsupported git mode '$GIT_MODE' (expected present|absent)" >&2; exit 2 ;; esac +if [[ "$SOURCE" == remote ]]; then + [[ -n "$INSTALLER_URL" ]] || INSTALLER_URL="https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/${LANE}/tools/install.sh" + [[ "$INSTALLER_SHA256" =~ ^[0-9a-f]{64}$ ]] \ + || { echo '[fixture] remote source requires MOSAIC_FIXTURE_INSTALLER_SHA256=64hex' >&2; exit 2; } +fi if [[ "$IN_CLEAN_CONTAINER" != "1" ]]; then if ! command -v docker >/dev/null 2>&1; then @@ -63,7 +70,7 @@ if [[ "$SOURCE" == "checkout" ]]; then && framework_payload_count="$(find "$ROOT/packages/mosaic/framework/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" [[ -d "$ROOT/skills" ]] \ && repo_root_count="$(find "$ROOT/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" - checkout_archive="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-checkout.XXXXXX.tar.gz")" + checkout_archive="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-checkout.XXXXXX")" repo_parent="$(dirname "$ROOT")" repo_name="$(basename "$ROOT")" tar -C "$repo_parent" \ @@ -75,7 +82,7 @@ if [[ "$SOURCE" == "checkout" ]]; then checkout_content_id="${checkout_digest:0:40}" fi -inner="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-inner.XXXXXX.sh")" +inner="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-inner.XXXXXX")" trap 'rm -f "$inner" "$checkout_archive"' EXIT cat > "$inner" <<'INNER' #!/usr/bin/env bash @@ -104,7 +111,13 @@ case "$FIXTURE_SOURCE" in printf '%s' "$FIXTURE_INSTALLER_B64" | base64 -d > /tmp/install.sh ;; remote) - curl -fsSL "https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/${FIXTURE_LANE}/tools/install.sh" > /tmp/install.sh + curl -fsSL "$FIXTURE_INSTALLER_URL" -o /tmp/install.sh + [[ -s /tmp/install.sh ]] || { echo '[fixture] remote installer returned an empty HTTP-success body' >&2; exit 1; } + actual_installer_sha256="$(sha256sum /tmp/install.sh | awk '{print $1}')" + [[ "$actual_installer_sha256" == "$FIXTURE_INSTALLER_SHA256" ]] || { + echo "[fixture] remote installer digest mismatch got=$actual_installer_sha256 expected=$FIXTURE_INSTALLER_SHA256" >&2 + exit 1 + } ;; esac chmod 0755 /tmp/install.sh @@ -134,12 +147,38 @@ fi resolved_version="$(npm view "$resolved_spec" version --registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ 2>/dev/null || true)" printf '[fixture] resolved lane=%s package=%s version=%s\n' "$lane" "$resolved_spec" "${resolved_version:-UNRESOLVED}" +secret_canary='MOSAIC_C1_CANARY_6f3c91e2' +argv_capture=/tmp/mosaic-installer-argv.log +: > "$argv_capture" set +e -MOSAIC_NO_COLOR=1 MOSAIC_ASSUME_YES=1 \ - bash /tmp/install.sh "${lane_args[@]}" --yes --no-auto-launch >"$install_log" 2>&1 +MOSAIC_NO_COLOR=1 MOSAIC_ASSUME_YES=1 MOSAIC_INSTALL_SECRET_CANARY="$secret_canary" \ + MOSAIC_INSTALL_REDACTION_PROBE=1 \ + bash /tmp/install.sh "${lane_args[@]}" --yes --no-auto-launch >"$install_log" 2>&1 & +installer_pid=$! +while kill -0 "$installer_pid" 2>/dev/null; do + for cmdline in /proc/[0-9]*/cmdline; do + [[ -r "$cmdline" ]] || continue + tr '\0' ' ' < "$cmdline" >> "$argv_capture" 2>/dev/null || true + printf '\n' >> "$argv_capture" + done + sleep 0.02 +done +wait "$installer_pid" install_status=$? set -e cat "$install_log" +probe_ok=true +if [[ "$FIXTURE_GIT_MODE" == present ]] \ + && ! grep -q '^\[REDACTION-PROBE\] emitted=\[REDACTED\]$' "$install_log"; then + probe_ok=false +fi +if [[ "$probe_ok" != true ]] \ + || grep -F "$secret_canary" "$argv_capture" >/dev/null \ + || grep -R -F "$secret_canary" "$home" >/dev/null 2>&1; then + phase_fail P0 'seeded credential probe missing or canary leaked to argv, output, command log, npmrc, generated files, or shell history' +else + printf '[SECRET-CONTROL] PASS: seeded captured-command canary was redacted and absent from argv/output/commands.log/npmrc/generated/history populations\n' +fi printf '[fixture] installer_exit=%d done_claims=%s\n' \ "$install_status" "$(grep -cF 'Done.' "$install_log" || true)" @@ -329,6 +368,8 @@ if [[ "$IN_CLEAN_CONTAINER" == "1" ]]; then FIXTURE_SOURCE="$SOURCE" \ FIXTURE_GIT_MODE="$GIT_MODE" \ FIXTURE_INSTALLER_B64="$installer_b64" \ + FIXTURE_INSTALLER_URL="$INSTALLER_URL" \ + FIXTURE_INSTALLER_SHA256="$INSTALLER_SHA256" \ FIXTURE_CHECKOUT_SHA256="$checkout_digest" \ FIXTURE_CHECKOUT_CONTENT_ID="$checkout_content_id" \ FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \ @@ -345,6 +386,8 @@ else --env FIXTURE_SOURCE="$SOURCE" \ --env FIXTURE_GIT_MODE="$GIT_MODE" \ --env FIXTURE_INSTALLER_B64="$installer_b64" \ + --env FIXTURE_INSTALLER_URL="$INSTALLER_URL" \ + --env FIXTURE_INSTALLER_SHA256="$INSTALLER_SHA256" \ --env FIXTURE_CHECKOUT_SHA256="$checkout_digest" \ --env FIXTURE_CHECKOUT_CONTENT_ID="$checkout_content_id" \ --env FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \ diff --git a/tools/fixtures/greenfield-expected-red.tsv b/tools/fixtures/greenfield-expected-red.tsv index b1f3614a..445b31db 100644 --- a/tools/fixtures/greenfield-expected-red.tsv +++ b/tools/fixtures/greenfield-expected-red.tsv @@ -13,10 +13,11 @@ next-git-present phase P8=FAIL next-git-present phase P9=FAIL next-git-present require ^\[fixture\] resolved lane=next .*version=[0-9]+\.[0-9]+\.[0-9]+-next\. next-git-present require ^\[fixture\] installer_exit=1 done_claims=0$ +next-git-present require ^\[SECRET-CONTROL\] PASS: next-git-present require ^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version$ next-git-present require ^\[P4\] FAIL: NOT-MEASURED / UNDECLARED: -next-git-present require ^\[P6\] FAIL: -next-git-present forbid Done\. +next-git-present require ^\[P6\] FAIL: broker absent but dead enforcement hooks are active +next-git-present forbid Done\.|MOSAIC_C1_CANARY_|CLI not found on PATH main-git-present exit 1 main-git-present phase P0=PASS main-git-present phase P1=PASS @@ -30,10 +31,11 @@ main-git-present phase P8=FAIL main-git-present phase P9=FAIL main-git-present require ^\[fixture\] resolved lane=main .*version=[0-9]+\.[0-9]+\.[0-9]+$ main-git-present require ^\[fixture\] installer_exit=1 done_claims=0$ +main-git-present require ^\[SECRET-CONTROL\] PASS: main-git-present require ^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version$ main-git-present require ^\[P4\] FAIL: NOT-MEASURED / UNDECLARED: -main-git-present require ^\[P6\] FAIL: -main-git-present forbid Done\. +main-git-present require ^\[P6\] FAIL: runtime linking/activation action reported a required failure +main-git-present forbid Done\.|MOSAIC_C1_CANARY_|CLI not found on PATH next-git-absent exit 1 next-git-absent phase P0=PASS next-git-absent phase P1=FAIL @@ -46,6 +48,7 @@ next-git-absent phase P7=PASS next-git-absent phase P8=FAIL next-git-absent phase P9=FAIL next-git-absent require ^\[fixture\] installer_exit=1 done_claims=0$ +next-git-absent require ^\[SECRET-CONTROL\] PASS: next-git-absent require ^\[P1\] FAIL: undeclared/missing prerequisite\(s\)=git; next-git-absent require ^\[P3\] FAIL: .*executable=no -next-git-absent forbid Done\. +next-git-absent forbid Done\.|MOSAIC_C1_CANARY_ diff --git a/tools/install-next-lane.test.sh b/tools/install-next-lane.test.sh index a4fb1735..ef320d94 100755 --- a/tools/install-next-lane.test.sh +++ b/tools/install-next-lane.test.sh @@ -4,6 +4,8 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-next-install-test-XXXXXX")" trap 'rm -rf "$TMP"' EXIT +export TMPDIR="$TMP/runtime-tmp" +mkdir -p "$TMPDIR" FAKE_BIN="$TMP/bin" HOME_DIR="$TMP/home" @@ -13,6 +15,48 @@ STATE="$TMP/state" LOG="$TMP/npm.log" mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE" +# Model the supported non-root/glibc target explicitly even when this harness +# itself runs as root in Alpine/BusyBox CI. +cat > "$FAKE_BIN/id" <<'FAKE_ID' +#!/usr/bin/env bash +case "${1:-}" in + -u) echo 1001 ;; + -g) echo 1001 ;; + -un) echo fixture-user ;; + *) exec /bin/id "$@" ;; +esac +FAKE_ID +cat > "$FAKE_BIN/getent" < "$FAKE_BIN/ldd" <<'FAKE_LDD' +#!/usr/bin/env bash +printf 'ldd (GNU libc) 2.36\n' +FAKE_LDD +cat > "$FAKE_BIN/stat" <<'FAKE_STAT' +#!/usr/bin/env bash +if [[ "${1:-} ${2:-}" == '-c %u' ]]; then + [[ "${3:-}" == "${MOSAIC_TEST_WRONG_OWNER_PATH:-__none__}" ]] && echo 9999 || echo 1001 + exit 0 +fi +if [[ "${1:-} ${2:-}" == '-c %g' ]]; then + [[ "${3:-}" == "${MOSAIC_TEST_WRONG_GROUP_PATH:-__none__}" ]] && echo 9999 || echo 1001 + exit 0 +fi +exec /bin/stat "$@" +FAKE_STAT +cat > "$FAKE_BIN/realpath" <<'FAKE_REALPATH' +#!/usr/bin/env python3 +import os, sys +args=sys.argv[1:] +mode=args.pop(0) if args and args[0] in ('-e','-m') else '-m' +if args and args[0]=='--': args.pop(0) +if len(args)!=1 or (mode=='-e' and not os.path.exists(args[0])): raise SystemExit(1) +print(os.path.realpath(args[0])) +FAKE_REALPATH +chmod 0755 "$FAKE_BIN/id" "$FAKE_BIN/getent" "$FAKE_BIN/ldd" "$FAKE_BIN/stat" "$FAKE_BIN/realpath" + cat > "$FAKE_BIN/npm" <<'FAKE_NPM' #!/usr/bin/env bash set -euo pipefail @@ -31,6 +75,15 @@ install_cli() { mkdir -p "${MOSAIC_PREFIX:?}/bin" cat > "$MOSAIC_PREFIX/bin/mosaic" <> "\${MOSAIC_TEST_NPM_LOG:?}" + mkdir -p "\${MOSAIC_HOME:?}" "\${HOME:?}/.config/mosaic-gateway" + printf '# Soul\\n\\nConfigured.\\n' > "\$MOSAIC_HOME/SOUL.md" + printf '# User\\n\\nConfigured.\\n' > "\$MOSAIC_HOME/USER.md" + chmod 0600 "\$MOSAIC_HOME/SOUL.md" "\$MOSAIC_HOME/USER.md" + exit 0 +fi printf '%s\\n' '$version' CLI chmod +x "$MOSAIC_PREFIX/bin/mosaic" @@ -51,6 +104,12 @@ if [[ "$1" == "view" ]]; then fi if [[ "$1" == "install" ]]; then + if [[ -n "${MOSAIC_INSTALL_SECRET_CANARY:-}" ]]; then + printf 'registry diagnostic authToken=%s\n' "$MOSAIC_INSTALL_SECRET_CANARY" + printf 'urls=https://alice:p@ss@example.com/a https://bob:pa:ss@example.net/b https://carol:p%%40ss@example.org/c https://token@example.dev/d https://user%%3Apass@example.io/e\n' + printf 'Authorization: Basic QWxhZGRpbjpvcGVu\n//registry/:_auth=Ym9iOnNlY3JldA==\nCookie: session=abc123\nSet-Cookie: sid=xyz789\n' + printf '%s\n' "$MOSAIC_INSTALL_SECRET_CANARY" > "${MOSAIC_TEST_CANARY_OBSERVATION:?}" + fi case "$*" in *"@mosaicstack/mosaic@0.0.49-next.999"*) install_cli "0.0.49-next.999" @@ -141,7 +200,21 @@ if [[ -z "$dest" ]]; then echo "fake tar missing -C destination" >&2 exit 1 fi -mkdir -p "$dest/stack/packages/mosaic" "$dest/stack/apps/gateway" +mkdir -p "$dest/stack/packages/mosaic/framework" "$dest/stack/apps/gateway" +cat > "$dest/stack/packages/mosaic/framework/install.sh" <<'FRAMEWORK' +#!/usr/bin/env bash +set -euo pipefail +expected="${MOSAIC_PREFIX:?}/bin/mosaic" +[[ "${MOSAIC_CLI_PATH:-}" == "$expected" && -x "$MOSAIC_CLI_PATH" ]] || { + echo "framework did not receive P3 absolute CLI (got=${MOSAIC_CLI_PATH:-unset} expected=$expected)" >&2 + exit 61 +} +printf 'framework-cli=%s version=%s\n' "$MOSAIC_CLI_PATH" "$($MOSAIC_CLI_PATH --version)" >> "${MOSAIC_TEST_NPM_LOG:?}" +mkdir -p "${MOSAIC_HOME:?}/credentials" +chmod 0700 "$MOSAIC_HOME/credentials" +printf '# framework fixture\n' > "$MOSAIC_HOME/AGENTS.md" +FRAMEWORK +chmod 0755 "$dest/stack/packages/mosaic/framework/install.sh" FAKE_TAR chmod +x "$FAKE_BIN/tar" @@ -187,15 +260,28 @@ reset_state() { rm -f "$STATE"/* } -prefix_fingerprint() { - if [[ ! -d "$PREFIX" ]]; then printf 'ABSENT\n'; return; fi - ( - cd "$PREFIX" - find . -mindepth 1 -printf '%P|%y|%m|%l\n' | LC_ALL=C sort - find . -type f -print0 | LC_ALL=C sort -z | xargs -0 -r sha256sum - ) | sha256sum | awk '{print $1}' +tree_fingerprint() { + local root="$1" + if [[ ! -d "$root" ]]; then printf 'ABSENT\n'; return; fi + python3 - "$root" <<'PY' +import hashlib, os, stat, sys +root=os.path.abspath(sys.argv[1]); rows=[] +for current, dirs, files in os.walk(root, topdown=True, followlinks=False): + for name in dirs + files: + path=os.path.join(current,name); meta=os.lstat(path) + rel=os.path.relpath(path,root) + target=os.readlink(path) if stat.S_ISLNK(meta.st_mode) else '' + digest='' + if stat.S_ISREG(meta.st_mode): + with open(path,'rb') as handle: digest=hashlib.sha256(handle.read()).hexdigest() + rows.append((rel,stat.S_IFMT(meta.st_mode),stat.S_IMODE(meta.st_mode),target,digest)) +payload='\n'.join('|'.join(map(str,row)) for row in sorted(rows)).encode() +print(hashlib.sha256(payload).hexdigest()) +PY } +prefix_fingerprint() { tree_fingerprint "$PREFIX"; } + reset_state echo "[test] --next fast path pins resolved package versions" OUTPUT="$( @@ -356,4 +442,131 @@ set -e [[ "$CHECK_STATUS" -ne 0 ]] grep -q '^\[P2\] FAIL: resolved_version=unavailable' <<<"$OUTPUT" +printf '[test] full framework path receives P3 absolute CLI without relying on PATH\n' +rm -rf "$HOME_DIR" "$STATE"; mkdir -p "$HOME_DIR" "$STATE"; reset_state +set +e +OUTPUT="$( + HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_INSTALL_STATE_DIR="$TMP/full-state" MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" \ + PATH="$FAKE_BIN:/usr/local/bin:/usr/bin:/bin" \ + bash "$ROOT/tools/install.sh" --next --yes --no-auto-launch 2>&1 +)" +FULL_STATUS=$? +set -e +[[ "$FULL_STATUS" -ne 0 ]] # P4 remains intentionally undeclared until C5. +grep -qF "framework-cli=$PREFIX/bin/mosaic version=0.0.49-next.999" "$LOG" +if grep -q "CLI not found on PATH\|did not receive P3 absolute CLI" <<<"$OUTPUT"; then + echo "internal framework phase depended on PATH instead of P3 absolute CLI" >&2 + exit 1 +fi + +printf '[test] captured diagnostics redact seeded credential canary everywhere\n' +rm -rf "$HOME_DIR" "$STATE"; mkdir -p "$HOME_DIR" "$STATE"; reset_state +canary='C1_SECRET_CANARY_7df4c2' +OUTPUT="$( + HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_INSTALL_STATE_DIR="$TMP/secret-state" MOSAIC_NO_COLOR=1 \ + MOSAIC_INSTALL_SECRET_CANARY="$canary" MOSAIC_TEST_CANARY_OBSERVATION="$TMP/canary-observed" \ + MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" \ + PATH="$FAKE_BIN:/usr/local/bin:/usr/bin:/bin" \ + bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1 +)" +if grep -qF "$canary" <<<"$OUTPUT"; then echo 'credential canary leaked to terminal output' >&2; exit 1; fi +if grep -Eq 'alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789' <<<"$OUTPUT"; then + echo 'credentialed URL userinfo leaked to terminal output' >&2; exit 1 +fi +[[ "$(grep -oF '[REDACTED]@' <<<"$OUTPUT" | wc -l | tr -d ' ')" -ge 5 ]] \ + || { echo 'credentialed URL redaction controls were not all exercised' >&2; exit 1; } +secret_active="$TMP/secret-state/active.json" +secret_journal="$(node -p "require('$secret_active').journal")" +secret_command_log="$(dirname "$secret_journal")/commands.log" +if grep -R -F "$canary" "$secret_command_log" "$HOME_DIR" 2>/dev/null; then + echo 'credential canary leaked to persistent installer output' >&2; exit 1 +fi +if grep -E 'alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789' "$secret_command_log" >/dev/null; then + echo 'credentialed URL userinfo leaked to persistent installer output' >&2; exit 1 +fi +if [[ "$(cat "$TMP/canary-observed" 2>/dev/null || true)" != "$canary" ]]; then + echo 'credential canary positive control was not exercised' >&2; exit 1 +fi +if find "$TMPDIR" -maxdepth 1 -type f \( -name 'mosaic-phase-redacted.*' -o -name 'mosaic-post-redacted.*' \) -print -quit | grep -q .; then + echo 'redacted diagnostic staging file survived normal completion' >&2; exit 1 +fi + +printf '[test] framework nested capture redacts the same canary and URL variants\n' +framework_test_home="$TMP/framework-redact-home" +framework_target="$framework_test_home/.config/mosaic" +framework_cli="$TMP/framework-redact-cli" +framework_log="$TMP/framework-redact-commands.log" +framework_status="$TMP/framework-redact-status.tsv" +mkdir -p "$framework_test_home"; : > "$framework_log"; : > "$framework_status" +cat > "$framework_cli" <<'FRAMEWORK_CLI' +#!/usr/bin/env bash +printf 'nested authToken=%s\n' "${MOSAIC_INSTALL_SECRET_CANARY:?}" +printf 'nested=https://alice:p@ss@example.com/a https://bob:pa:ss@example.net/b https://carol:p%%40ss@example.org/c https://token@example.dev/d https://user%%3Apass@example.io/e\n' +printf 'Authorization: Basic QWxhZGRpbjpvcGVu\n//registry/:_auth=Ym9iOnNlY3JldA==\nCookie: session=abc123\nSet-Cookie: sid=xyz789\n' +exit 1 +FRAMEWORK_CLI +chmod 0755 "$framework_cli" +set +e +FRAMEWORK_OUTPUT="$( + HOME="$framework_test_home" MOSAIC_HOME="$framework_target" MOSAIC_INSTALL_MODE=overwrite \ + MOSAIC_CLI_PATH="$framework_cli" MOSAIC_INSTALL_SECRET_CANARY="$canary" \ + MOSAIC_INSTALL_COMMAND_LOG="$framework_log" MOSAIC_INSTALL_PHASE_STATUS_FILE="$framework_status" \ + MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1 MOSAIC_SKIP_SKILLS_SYNC=1 \ + bash "$ROOT/packages/mosaic/framework/install.sh" 2>&1 +)" +framework_install_status=$? +set -e +[[ "$framework_install_status" -eq 0 ]] +if grep -Eq "$canary|alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789" <<<"$FRAMEWORK_OUTPUT" \ + || grep -Eq "$canary|alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789" "$framework_log"; then + echo 'framework nested capture leaked credential diagnostics' >&2; exit 1 +fi +[[ "$(grep -oF '[REDACTED]@' "$framework_log" | wc -l | tr -d ' ')" -ge 5 ]] \ + || { echo 'framework URL redaction controls were not exercised' >&2; exit 1; } + +printf '[test] real P2-P8 actions run under fault injection and restore actual surfaces\n' +for phase in P2 P3 P4 P5 P6 P7 P8; do + rm -rf "$HOME_DIR" "$STATE" "$TMP/fault-$phase"; mkdir -p "$HOME_DIR" "$STATE" "$TMP/fault-$phase" + printf 'operator-sentinel\n' > "$HOME_DIR/operator.txt" + reset_state + before="$(tree_fingerprint "$HOME_DIR")" + set +e + HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_INSTALL_STATE_DIR="$TMP/fault-$phase" MOSAIC_INSTALL_FAULT_AFTER="$phase" \ + MOSAIC_INSTALL_SELF_TEST_ALLOW=1 MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" \ + PATH="$FAKE_BIN:/usr/local/bin:/usr/bin:/bin" \ + bash "$ROOT/tools/install.sh" --state-machine-self-test --next --yes \ + >"$TMP/fault-$phase.log" 2>&1 + status=$? + set -e + [[ "$status" -eq 97 ]] || { echo "$phase real fault expected 97, got $status" >&2; exit 1; } + [[ -s "$LOG" ]] || { echo "$phase fault never entered the real action path" >&2; exit 1; } + [[ "$(tree_fingerprint "$HOME_DIR")" == "$before" ]] || { echo "$phase real rollback mismatch" >&2; exit 1; } + grep -q "phase=$phase" "$TMP/fault-$phase.log" + if find "$TMP/fault-$phase" -type f -exec grep -l '"status"[[:space:]]*:[[:space:]]*"in-progress"' {} + 2>/dev/null | grep -q .; then + echo "$phase left an in-progress transaction" >&2; exit 1 + fi +done + +printf '[test] stale projection is preserved while the real fault path acquires a free OS lock\n' +rm -rf "$HOME_DIR" "$STATE" "$TMP/stale-state"; mkdir -p "$HOME_DIR" "$STATE" "$TMP/stale-state" +printf '{"status":"in-progress","journal":"%s"}\n' "$TMP/stale-state/dead-run/journal.ndjson" > "$TMP/stale-state/active.json" +reset_state +set +e +HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_INSTALL_STATE_DIR="$TMP/stale-state" MOSAIC_INSTALL_FAULT_AFTER=P2 \ + MOSAIC_INSTALL_SELF_TEST_ALLOW=1 MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" \ + PATH="$FAKE_BIN:/usr/local/bin:/usr/bin:/bin" \ + bash "$ROOT/tools/install.sh" --state-machine-self-test --next --yes >"$TMP/stale.log" 2>&1 +stale_status=$? +set -e +[[ "$stale_status" -eq 97 ]] +find "$TMP/stale-state" -name prior-active.json -type f -print -quit | grep -q . +[[ "$(node -p "require('$TMP/stale-state/active.json').status")" == rolled-back ]] + echo "[test] installer next lane tests passed" diff --git a/tools/install-state-machine.test.sh b/tools/install-state-machine.test.sh index c0dd5867..1fb31b16 100755 --- a/tools/install-state-machine.test.sh +++ b/tools/install-state-machine.test.sh @@ -133,16 +133,26 @@ make_fake_npm "$good_bin" cp "$COMPAT_BIN/realpath" "$good_bin/realpath" cat > "$good_bin/id" <<'ID' #!/bin/bash +uid="${MOSAIC_TEST_UID:-1001}" +gid="${MOSAIC_TEST_GID:-1001}" +user="${MOSAIC_TEST_USER:-fixture-user}" case "${1:-}" in - -u) echo 1001 ;; - -g) echo 1001 ;; - -un) echo fixture-user ;; + -u) echo "$uid" ;; + -g) echo "$gid" ;; + -un) echo "$user" ;; *) exec /bin/id "$@" ;; esac ID cat > "$good_bin/stat" <<'STAT' #!/bin/bash -if [[ "${1:-} ${2:-}" == '-c %u' ]]; then echo 1001; exit 0; fi +if [[ "${1:-} ${2:-}" == '-c %u' ]]; then + [[ "${3:-}" == "${MOSAIC_TEST_WRONG_OWNER_PATH:-__none__}" ]] && echo 9999 || echo "${MOSAIC_TEST_UID:-1001}" + exit 0 +fi +if [[ "${1:-} ${2:-}" == '-c %g' ]]; then + [[ "${3:-}" == "${MOSAIC_TEST_WRONG_GROUP_PATH:-__none__}" ]] && echo 9999 || echo "${MOSAIC_TEST_GID:-1001}" + exit 0 +fi exec /bin/stat "$@" STAT cat > "$good_bin/curl" <<'CURL' @@ -161,7 +171,7 @@ CLI chmod 0755 "$good_prefix/bin/mosaic" cat > "$good_bin/getent" < "$good_bin/bash" <&2; fail_case "good-host --check emitted $good_rows PASS rows"; } +printf '[test] case: P0 binds uid, username, passwd HOME, shell, and privilege mode\n' +passwd_home="$TMP/passwd-authoritative-home" +mkdir -p "$passwd_home" +set +e +HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" \ + MOSAIC_TEST_PASSWD_HOME="$passwd_home" MOSAIC_NO_COLOR=1 \ + PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \ + bash "$ROOT/tools/install.sh" --check --next >"$TMP/wrong-home.log" 2>&1 +wrong_home_status=$? +set -e +[[ "$wrong_home_status" -ne 0 ]] || fail_case 'P0 accepted ambient HOME that disagrees with passwd HOME' +grep -q '^\[P0\] FAIL:.*HOME mismatch' "$TMP/wrong-home.log" \ + && pass_case 'P0 rejects ambient HOME that disagrees with passwd HOME' \ + || fail_case 'P0 did not attribute the passwd HOME mismatch' + +for privilege_case in root-with-home sudo-with-inherited-home; do + extra_env=() + [[ "$privilege_case" == sudo-with-inherited-home ]] && extra_env+=(SUDO_USER=fixture-user SUDO_UID=1001) + set +e + env HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" \ + MOSAIC_TEST_UID=0 MOSAIC_TEST_GID=0 MOSAIC_TEST_USER=root MOSAIC_TEST_PASSWD_HOME=/root \ + MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" "${extra_env[@]}" \ + bash "$ROOT/tools/install.sh" --check --next >"$TMP/$privilege_case.log" 2>&1 + privilege_status=$? + set -e + [[ "$privilege_status" -ne 0 ]] || fail_case "P0 accepted unsafe $privilege_case context" + grep -q '^\[P0\] FAIL:.*privilege=' "$TMP/$privilege_case.log" \ + && pass_case "P0 states and rejects $privilege_case privilege context" \ + || fail_case "P0 did not state $privilege_case privilege mode" +done + +printf '[test] case: P3/P5 reject unsafe owner, group, and mode\n' +chmod 0777 "$good_prefix/bin/mosaic" +set +e +HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" MOSAIC_NO_COLOR=1 \ + PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" bash "$ROOT/tools/install.sh" --check --next >"$TMP/p3-mode.log" 2>&1 +p3_mode_status=$? +set -e +[[ "$p3_mode_status" -ne 0 ]] || fail_case 'P3 accepted mode-0777 CLI' +grep -q '^\[P3\] FAIL:.*unsafe owner/group/mode' "$TMP/p3-mode.log" \ + && pass_case 'P3 rejects group/world-writable CLI' || fail_case 'P3 did not attribute unsafe CLI mode' +chmod 0755 "$good_prefix/bin/mosaic" + +for ownership_case in owner group; do + wrong_env=() + [[ "$ownership_case" == owner ]] && wrong_env+=(MOSAIC_TEST_WRONG_OWNER_PATH="$good_prefix/bin/mosaic") + [[ "$ownership_case" == group ]] && wrong_env+=(MOSAIC_TEST_WRONG_GROUP_PATH="$good_prefix/bin/mosaic") + set +e + env HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" MOSAIC_NO_COLOR=1 \ + PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" "${wrong_env[@]}" \ + bash "$ROOT/tools/install.sh" --check --next >"$TMP/p3-$ownership_case.log" 2>&1 + owner_status=$? + set -e + [[ "$owner_status" -ne 0 ]] || fail_case "P3 accepted wrong CLI $ownership_case" + grep -q '^\[P3\] FAIL:.*unsafe owner/group/mode' "$TMP/p3-$ownership_case.log" \ + && pass_case "P3 rejects wrong CLI $ownership_case" || fail_case "P3 did not attribute wrong CLI $ownership_case" +done + +chmod 0644 "$good_mosaic/SOUL.md" "$good_mosaic/USER.md" +set +e +HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" MOSAIC_NO_COLOR=1 \ + PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" bash "$ROOT/tools/install.sh" --check --next >"$TMP/p5-mode.log" 2>&1 +p5_mode_status=$? +set -e +[[ "$p5_mode_status" -ne 0 ]] || fail_case 'P5 accepted world-readable identity files' +grep -q '^\[P5\] FAIL:' "$TMP/p5-mode.log" \ + && pass_case 'P5 rejects world-readable identity files' || fail_case 'P5 did not reject identity mode 0644' +chmod 0600 "$good_mosaic/SOUL.md" "$good_mosaic/USER.md" + +mkdir -p "$good_mosaic/credentials" +chmod 0755 "$good_mosaic/credentials" +set +e +HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" MOSAIC_NO_COLOR=1 \ + PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" bash "$ROOT/tools/install.sh" --check --next >"$TMP/p5-credentials.log" 2>&1 +credential_status=$? +set -e +[[ "$credential_status" -ne 0 ]] || fail_case 'P5 accepted mode-0755 credentials directory' +grep -q '^\[P5\] FAIL:.*credentials' "$TMP/p5-credentials.log" \ + && pass_case 'P5 rejects group/world-readable credential storage' \ + || fail_case 'P5 did not attribute unsafe credential directory mode' +chmod 0700 "$good_mosaic/credentials" + +printf '# framework\n' > "$good_mosaic/AGENTS.md" +chmod 0666 "$good_mosaic/AGENTS.md" +set +e +HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" MOSAIC_NO_COLOR=1 \ + PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" bash "$ROOT/tools/install.sh" --check --next >"$TMP/p4-tree-mode.log" 2>&1 +framework_mode_status=$? +set -e +[[ "$framework_mode_status" -ne 0 ]] || fail_case 'P4 accepted group/world-writable framework path' +grep -q '^\[P4\] FAIL:.*owner/mode policy' "$TMP/p4-tree-mode.log" \ + && pass_case 'P4 inventories and rejects unsafe created framework paths' \ + || fail_case 'P4 did not attribute unsafe created-path mode' +chmod 0644 "$good_mosaic/AGENTS.md" + printf '[test] case: persisted required-action failures remain blocking\n' for blocked_phase in P4 P6; do node -e ' @@ -219,36 +324,12 @@ for blocked_phase in P4 P6; do done printf '{\n "lane": "next",\n "cliVersion": "0.0.50-next.999",\n "phaseOutcomes": {"P4":"committed","P6":"committed"}\n}\n' > "$good_mosaic/.install-manifest.json" -printf '[test] case: per-phase P2-P8 fault injection restores representative host mutations\n' -for phase in P2 P3 P4 P5 P6 P7 P8; do - home="$TMP/fault-$phase/home" - state="$TMP/fault-$phase/state" - mkdir -p "$home/.config/mosaic" "$home/.npm-global/bin" "$home/.claude" "$state" - printf 'operator-framework-sentinel\n' > "$home/.config/mosaic/operator.txt" - printf '@scope:registry=https://pre.example.invalid/\n' > "$home/.npmrc" - printf 'old-cli\n' > "$home/.npm-global/bin/mosaic" - printf '{"hooks":{"safe":true}}\n' > "$home/.claude/settings.json" - before="$(fingerprint "$home")" - set +e - HOME="$home" MOSAIC_HOME="$home/.config/mosaic" MOSAIC_PREFIX="$home/.npm-global" \ - MOSAIC_INSTALL_STATE_DIR="$state" MOSAIC_INSTALL_FAULT_AFTER="$phase" \ - MOSAIC_NO_COLOR=1 PATH="$COMPAT_BIN:$PATH" bash "$ROOT/tools/install.sh" --state-machine-self-test \ - >"$TMP/fault-$phase.log" 2>&1 - status=$? - set -e - after="$(fingerprint "$home")" - [[ "$status" -ne 0 ]] || fail_case "$phase injected fault returned zero" - grep -q "phase=$phase" "$TMP/fault-$phase.log" \ - || fail_case "$phase fault transcript did not name the injected phase" - [[ "$before" == "$after" ]] \ - && pass_case "$phase rollback restored framework/npmrc/prefix/runtime representative state" \ - || fail_case "$phase rollback mismatch (before=$before after=$after)" - if find "$state" -type f -exec grep -l '"status"[[:space:]]*:[[:space:]]*"in-progress"' {} + 2>/dev/null | grep -q .; then - fail_case "$phase left a journal in-progress" - else - pass_case "$phase left no journal falsely in-progress" - fi -done +printf '[test] case: fault injection has no synthetic mutation implementation\n' +if grep -q '\.selftest-' "$ROOT/tools/install.sh"; then + fail_case 'synthetic .selftest mutation path remains in the production fault seam' +else + pass_case 'fault seam is attached only to real P2-P8 action flow (exercised by install-next-lane.test.sh)' +fi printf '[test] case: unsafe and overlapping rollback roots fail before mutation\n' unsafe_home="$TMP/unsafe-home" @@ -262,7 +343,7 @@ for case_name in root-target home-target overlap-target; do before="$(fingerprint "$unsafe_home")" set +e HOME="$unsafe_home" MOSAIC_HOME="$unsafe_mosaic" MOSAIC_PREFIX="$unsafe_prefix" \ - MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \ + MOSAIC_TEST_PASSWD_HOME="$unsafe_home" MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \ bash "$ROOT/tools/install.sh" --check --next >"$TMP/$case_name.log" 2>&1 status=$? set -e @@ -279,7 +360,7 @@ mkdir -p "$symlink_home" "$symlink_outside" ln -s "$symlink_outside" "$symlink_home/.config" set +e HOME="$symlink_home" MOSAIC_HOME="$symlink_home/.config/mosaic" MOSAIC_PREFIX="$symlink_home/.npm-global" \ - MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \ + MOSAIC_TEST_PASSWD_HOME="$symlink_home" MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \ bash "$ROOT/tools/install.sh" --check --next >"$TMP/symlink-target.log" 2>&1 status=$? set -e @@ -289,26 +370,6 @@ grep -q '^\[P0\] FAIL:.*unsafe context' "$TMP/symlink-target.log" \ || fail_case 'symlinked rollback parent lacked an attributable P0 failure' [[ -z "$(find "$symlink_outside" -mindepth 1 -print -quit)" ]] || fail_case 'symlink target was mutated' -printf '[test] case: stale in-progress projection does not impersonate a live OS lock\n' -stale_home="$TMP/stale/home" -stale_state="$TMP/stale/state" -mkdir -p "$stale_home/.config/mosaic" "$stale_state" -printf '{"status":"in-progress","journal":"%s"}\n' "$stale_state/dead-run/journal.ndjson" > "$stale_state/active.json" -set +e -HOME="$stale_home" MOSAIC_HOME="$stale_home/.config/mosaic" MOSAIC_PREFIX="$stale_home/.npm-global" \ - MOSAIC_INSTALL_STATE_DIR="$stale_state" MOSAIC_INSTALL_FAULT_AFTER=P2 MOSAIC_NO_COLOR=1 \ - PATH="$COMPAT_BIN:$PATH" bash "$ROOT/tools/install.sh" --state-machine-self-test >"$TMP/stale.log" 2>&1 -status=$? -set -e -[[ "$status" -eq 97 ]] || fail_case "stale projection recovery expected injected status 97, got $status" -if find "$stale_state" -name prior-active.json -type f -print -quit | grep -q .; then - pass_case 'stale projection was preserved and superseded after the free OS lock was acquired' -else - fail_case 'stale projection was not preserved for recovery evidence' -fi -[[ "$(node -p "require('$stale_state/active.json').status")" == "rolled-back" ]] \ - || fail_case 'stale retry did not reach an honest rolled-back terminal state' - printf '[test] case: journal initialization failure is fatal before mutation\n' journal_home="$TMP/journal-failure/home" mkdir -p "$journal_home/.config/mosaic" @@ -316,8 +377,9 @@ printf 'journal-sentinel\n' > "$journal_home/.config/mosaic/operator.txt" before="$(fingerprint "$journal_home")" set +e HOME="$journal_home" MOSAIC_HOME="$journal_home/.config/mosaic" MOSAIC_PREFIX="$journal_home/.npm-global" \ - MOSAIC_INSTALL_STATE_DIR="/proc/mosaic-journal-denied-$$" MOSAIC_INSTALL_FAULT_AFTER=P2 \ - MOSAIC_NO_COLOR=1 bash "$ROOT/tools/install.sh" --state-machine-self-test \ + MOSAIC_TEST_PASSWD_HOME="$journal_home" MOSAIC_INSTALL_STATE_DIR="/proc/mosaic-journal-denied-$$" \ + MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \ + bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch \ >"$TMP/journal-failure.log" 2>&1 status=$? set -e diff --git a/tools/install.sh b/tools/install.sh index 59cdabf5..89003150 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -510,27 +510,77 @@ state_action_failed() { fi } +state_redact_stream() { + python3 /dev/fd/3 3<<'PY' +import os, re, sys +text = sys.stdin.read() +secret_name = re.compile(r"(?:TOKEN|PASSWORD|PASSWD|SECRET|API_KEY|AUTH|CREDENTIAL|CANARY)", re.I) +secrets = {value for name, value in os.environ.items() if secret_name.search(name) and len(value) >= 4} +for value in sorted(secrets, key=len, reverse=True): + text = text.replace(value, "[REDACTED]") +patterns = ( + (re.compile(r"(?im)^(\s*(?:proxy-)?authorization\s*:\s*)[^\r\n]+"), r"\1[REDACTED]"), + (re.compile(r"(?im)^(\s*(?:set-)?cookie\s*:\s*)[^\r\n]+"), r"\1[REDACTED]"), + (re.compile(r"(?i)(Bearer\s+)[^\s'\"]+"), r"\1[REDACTED]"), + (re.compile(r"(?i)((?:[_-]?auth(?:Token)?|token|password|passwd|secret|api[_-]?key)\s*[=:]\s*)[^\s'\"]+"), r"\1[REDACTED]"), +) +for pattern, replacement in patterns: + text = pattern.sub(replacement, text) +url_pattern = re.compile(r"https?://[^\s'\"<>]+", re.I) +def redact_url(match): + url = match.group(0) + scheme_end = url.find("://") + 3 + authority_end = len(url) + for separator in "/?#": + position = url.find(separator, scheme_end) + if position != -1: + authority_end = min(authority_end, position) + authority = url[scheme_end:authority_end] + at = authority.rfind("@") + if at != -1: + return url[:scheme_end] + "[REDACTED]@" + authority[at + 1:] + url[authority_end:] + return url +sys.stdout.write(url_pattern.sub(redact_url, text)) +PY +} + +state_redaction_probe() { + printf '[REDACTION-PROBE] emitted=%s\n' "${MOSAIC_INSTALL_SECRET_CANARY:?redaction probe requires canary}" +} + state_run_captured() { - local label="$1" output status=0 + local label="$1" redacted redactor_pid capture_fd status=0 redact_status=0 shift - output="$(mktemp "${TMPDIR:-/tmp}/mosaic-phase-command.XXXXXX.log")" || return - # The command is deliberately called in a conditional so its status can be - # journaled before the caller's ERR trap rolls back. Bash disables errexit in - # functions invoked this way, so every multi-command phase helper below must - # explicitly return on each required command failure. - if "$@" >"$output" 2>&1; then status=0; else status=$?; fi - cat "$output" || { rm -f "$output"; return 1; } - if ! { printf '\n=== %s (exit=%s) ===\n' "$label" "$status"; cat "$output"; } >> "$STATE_COMMAND_LOG"; then - rm -f "$output" + redacted="$(mktemp "${TMPDIR:-/tmp}/mosaic-phase-redacted.XXXXXX")" || return + chmod 0600 "$redacted" || { rm -f "$redacted"; return 1; } + # Process substitution preserves in-shell phase side effects while ensuring + # plaintext diagnostics exist only in a pipe, never in a filesystem body. + exec {capture_fd}> >(state_redact_stream > "$redacted") + redactor_pid=$! + set +e + "$@" >&"$capture_fd" 2>&1 + status=$? + exec {capture_fd}>&- + wait "$redactor_pid" + redact_status=$? + set -e + if [[ "$redact_status" -ne 0 ]]; then + rm -f "$redacted" + fail "Could not redact '$label' diagnostics; refusing to expose or persist raw command output." + return 1 + fi + cat "$redacted" || { rm -f "$redacted"; return 1; } + if ! { printf '\n=== %s (exit=%s) ===\n' "$label" "$status"; cat "$redacted"; } >> "$STATE_COMMAND_LOG"; then + rm -f "$redacted" fail "Could not append '$label' output to $STATE_COMMAND_LOG; refusing to continue." return 1 fi if ! sync "$STATE_COMMAND_LOG"; then - rm -f "$output" + rm -f "$redacted" fail "Could not sync '$label' output in $STATE_COMMAND_LOG; refusing to continue." return 1 fi - rm -f "$output" + rm -f "$redacted" state_json_line command "$STATE_CURRENT_PHASE" "$([[ "$status" -eq 0 ]] && echo committed || echo failed)" "label=$label output_log=$STATE_COMMAND_LOG exit=$status" return "$status" } @@ -569,6 +619,55 @@ state_target_shell() { printf '%s' "${shell:-${SHELL:-}}" } +STATE_POLICY_REASON="" +state_path_owner_mode_ok() { + local path="$1" policy="${2:-shared-read}" uid gid mode mode_value original resolved + original="$path" + [[ -e "$path" ]] || { STATE_POLICY_REASON="$path missing"; return 1; } + if [[ -L "$path" ]]; then + resolved="$(realpath -e -- "$path" 2>/dev/null)" \ + || { STATE_POLICY_REASON="$path has an unresolved symlink target"; return 1; } + if [[ "$resolved" != "$HOME"/* && "$resolved" != "$PREFIX"/* ]]; then + STATE_POLICY_REASON="$path escapes target-owned roots via symlink to $resolved" + return 1 + fi + path="$resolved" + fi + uid="$(stat -c '%u' "$path" 2>/dev/null)" || { STATE_POLICY_REASON="$original owner unreadable"; return 1; } + gid="$(stat -c '%g' "$path" 2>/dev/null)" || { STATE_POLICY_REASON="$path group unreadable"; return 1; } + mode="$(stat -c '%a' "$path" 2>/dev/null)" || { STATE_POLICY_REASON="$path mode unreadable"; return 1; } + [[ "$uid" == "$(id -u)" && "$gid" == "$(id -g)" && "$mode" =~ ^[0-7]{3,4}$ ]] \ + || { STATE_POLICY_REASON="$original owner=$uid group=$gid mode=$mode expected=$(id -u):$(id -g)"; return 1; } + mode_value=$((8#$mode)) + case "$policy" in + private) + (( (mode_value & 077) == 0 )) \ + || { STATE_POLICY_REASON="$original mode=$mode exposes private material to group/other"; return 1; } + ;; + executable) + (( (mode_value & 0111) != 0 && (mode_value & 022) == 0 )) \ + || { STATE_POLICY_REASON="$original mode=$mode is not executable or is group/world-writable"; return 1; } + ;; + shared-read) + (( (mode_value & 022) == 0 )) \ + || { STATE_POLICY_REASON="$original mode=$mode is group/world-writable"; return 1; } + ;; + *) STATE_POLICY_REASON="unknown owner/mode policy=$policy for $path"; return 1 ;; + esac +} + +state_tree_owner_mode_ok() { + local root="$1" path policy + [[ -e "$root" ]] || return 0 + while IFS= read -r -d '' path; do + policy=shared-read + case "$path" in + "$MOSAIC_HOME/credentials"|"$MOSAIC_HOME/credentials"/*|"$MOSAIC_HOME/SOUL.md"|"$MOSAIC_HOME/USER.md") policy=private ;; + esac + state_path_owner_mode_ok "$path" "$policy" || return + done < <(find "$root" -xdev -print0) +} + state_resolved_version() { local cli gateway if [[ "$FLAG_DEV" == "true" ]]; then @@ -598,21 +697,37 @@ state_predicate() { local phase="$1" shell node_major installed expected local missing=() login_path nonlogin_path broker=false dead_hooks=0 local prefix_parent disk_kb inode_count min_disk_kb min_inodes npm_major privilege_mode + local passwd_row passwd_user passwd_uid passwd_home passwd_shell actual_user actual_uid STATE_REASON="" case "$phase" in P0) - shell="$(state_target_shell)" + actual_uid="$(id -u 2>/dev/null || true)" + actual_user="$(id -un 2>/dev/null || true)" + passwd_row="$(getent passwd "$actual_uid" 2>/dev/null || true)" + IFS=: read -r passwd_user _ passwd_uid _ _ passwd_home passwd_shell <<<"$passwd_row" + shell="$passwd_shell" node_major="$(node -p 'Number(process.versions.node.split(".")[0])' 2>/dev/null || echo 0)" npm_major="$(npm --version 2>/dev/null | cut -d. -f1 || echo 0)" - privilege_mode="$([[ "$(id -u)" -eq 0 ]] && echo root-without-explicit-target || echo user)" - if [[ -n "$HOME" && -n "$shell" && "$privilege_mode" == "user" && "$(uname -s)" == "Linux" ]] \ + if [[ "$actual_uid" == 0 && -n "${SUDO_USER:-}" ]]; then + privilege_mode="sudo-with-inherited-home" + elif [[ "$actual_uid" == 0 ]]; then + privilege_mode="root-without-explicit-target" + else + privilege_mode="user" + fi + if [[ -z "$passwd_row" || "$actual_uid" != "$passwd_uid" || "$actual_user" != "$passwd_user" \ + || -z "$passwd_home" || "$HOME" != "$passwd_home" ]]; then + STATE_REASON="unsupported or unresolved target account: HOME mismatch or passwd identity mismatch (target=${actual_user:-unknown} uid=${actual_uid:-unknown} HOME=${HOME:-unset} passwd_user=${passwd_user:-unset} passwd_uid=${passwd_uid:-unset} passwd_HOME=${passwd_home:-unset} shell=${passwd_shell:-unset} privilege=$privilege_mode)" + return 1 + fi + if [[ -n "$shell" && "$privilege_mode" == "user" && "$(uname -s)" == "Linux" ]] \ && ldd --version 2>&1 | grep -i 'glibc\|gnu libc' >/dev/null \ && [[ "$(uname -m)" == "x86_64" ]] && [[ "$node_major" -ge 20 ]] && [[ "$npm_major" -ge 9 ]] \ && state_validate_target_paths; then - STATE_REASON="target=$(id -un) uid=$(id -u) HOME=$HOME shell=$shell privilege=$privilege_mode arch=x86_64 libc=glibc node=$(node --version) npm=$(npm --version)" + STATE_REASON="target=$actual_user uid=$actual_uid HOME=$HOME passwd_HOME=$passwd_home shell=$shell privilege=$privilege_mode arch=x86_64 libc=glibc node=$(node --version) npm=$(npm --version)" return 0 fi - STATE_REASON="unsupported, unresolved, or unsafe context (target=$(id -un 2>/dev/null || echo unknown) uid=$(id -u) HOME=${HOME:-unset} shell=${shell:-unset} privilege=$privilege_mode arch=$(uname -m 2>/dev/null || echo unknown) node_major=$node_major npm_major=$npm_major path_check=${STATE_PATH_REASON:-not-reached})" + STATE_REASON="unsupported, unresolved, or unsafe context (target=${actual_user:-unknown} uid=${actual_uid:-unknown} HOME=${HOME:-unset} passwd_HOME=${passwd_home:-unset} shell=${shell:-unset} privilege=$privilege_mode arch=$(uname -m 2>/dev/null || echo unknown) node_major=$node_major npm_major=$npm_major path_check=${STATE_PATH_REASON:-not-reached})" return 1 ;; P1) @@ -684,11 +799,12 @@ state_predicate() { expected="$(state_expected_cli_version)" installed="" [[ -x "$PREFIX/bin/mosaic" ]] && installed="$("$PREFIX/bin/mosaic" --version 2>&1 | tail -n 1 | tr -d '\r' || true)" - if [[ -n "$expected" && -x "$PREFIX/bin/mosaic" && "$installed" == "$expected" ]]; then - STATE_REASON="absolute_path=$PREFIX/bin/mosaic version=$installed equals resolved lane version" + if [[ -n "$expected" && -x "$PREFIX/bin/mosaic" && "$installed" == "$expected" ]] \ + && state_path_owner_mode_ok "$PREFIX/bin/mosaic" executable; then + STATE_REASON="absolute_path=$PREFIX/bin/mosaic version=$installed equals resolved lane version; owner/mode policy satisfied" return 0 fi - STATE_REASON="absolute_path=$PREFIX/bin/mosaic executable=$([[ -x "$PREFIX/bin/mosaic" ]] && echo yes || echo no) got=${installed:-missing} expected=${expected:-unresolved}" + STATE_REASON="absolute_path=$PREFIX/bin/mosaic executable=$([[ -x "$PREFIX/bin/mosaic" ]] && echo yes || echo no) got=${installed:-missing} expected=${expected:-unresolved}; unsafe owner/group/mode=${STATE_POLICY_REASON:-not-evaluated}" return 1 ;; P4) @@ -699,6 +815,10 @@ state_predicate() { local expected_lane expected_version expected_lane="$([[ "$FLAG_NEXT" == true ]] && echo next || echo latest)" expected_version="$(state_expected_cli_version)" + if [[ -e "$MOSAIC_HOME" ]] && ! state_tree_owner_mode_ok "$MOSAIC_HOME"; then + STATE_REASON="framework created-path owner/mode policy failed: $STATE_POLICY_REASON" + return 1 + fi if [[ ! -s "$declared_set" ]]; then STATE_REASON="NOT-MEASURED / UNDECLARED: installer published no checkout-free, lane/versioned shipped-set artifact at $declared_set" return 1 @@ -738,12 +858,14 @@ NODE for skill in SOUL.md USER.md; do local path="$MOSAIC_HOME/$skill" if [[ ! -s "$path" ]] || ! grep -q '^# ' "$path" 2>/dev/null \ - || [[ "$(stat -c '%u' "$path" 2>/dev/null || echo -1)" != "$(id -u)" ]] \ - || [[ "$(stat -c '%a' "$path" 2>/dev/null || echo 777)" =~ [2367]$ ]]; then + || ! state_path_owner_mode_ok "$path" private; then missing+=("$skill") fi done - if [[ "${#missing[@]}" -eq 0 ]]; then STATE_REASON="SOUL.md and USER.md parse and have target owner/mode"; return 0; fi + if [[ -e "$MOSAIC_HOME/credentials" ]] && ! state_tree_owner_mode_ok "$MOSAIC_HOME/credentials"; then + missing+=("credentials(owner/mode=$STATE_POLICY_REASON)") + fi + if [[ "${#missing[@]}" -eq 0 ]]; then STATE_REASON="SOUL.md and USER.md parse and have private target owner/mode; credential paths are private"; return 0; fi STATE_REASON="identity missing, empty, malformed, wrong-owner, or unsafe-mode: ${missing[*]}" return 1 ;; @@ -863,7 +985,8 @@ state_validate_target_paths() { } state_snapshot_create() { - local dst list path key index=0 + local dst list path key index=0 parent parent_list parent_status + local -A recorded_parents=() if ! state_validate_target_paths; then fail "P1 Preflight refused snapshot creation: $STATE_PATH_REASON" return 1 @@ -871,7 +994,9 @@ state_snapshot_create() { STATE_SNAPSHOT_DIR="$STATE_RUN_DIR/snapshot" mkdir -p "$STATE_SNAPSHOT_DIR/data" list="$STATE_SNAPSHOT_DIR/paths.tsv" + parent_list="$STATE_SNAPSHOT_DIR/parents.tsv" : > "$list" + : > "$parent_list" for path in "$MOSAIC_HOME" "$PREFIX" "$HOME/.npmrc" "$HOME/.bashrc" "$HOME/.bash_profile" \ "$HOME/.profile" "$HOME/.zshrc" "$HOME/.config/fish/config.fish" "$HOME/.claude" \ "$HOME/.pi" "$HOME/.codex" "$HOME/.config/opencode" "$HOME/.config/mosaic-gateway" \ @@ -886,12 +1011,22 @@ state_snapshot_create() { else printf 'absent\t%s\t%s\n' "$path" "$key" >> "$list" fi + parent="$(dirname "$path")" + while [[ "$parent" != "$HOME" && "$parent" == "$HOME"/* ]]; do + if [[ -z "${recorded_parents[$parent]:-}" ]]; then + recorded_parents[$parent]=1 + parent_status=absent + [[ -d "$parent" ]] && parent_status=present + printf '%s\t%s\n' "$parent_status" "$parent" >> "$parent_list" + fi + parent="$(dirname "$parent")" + done done state_json_line snapshot P1 committed "pre-install snapshot=$STATE_SNAPSHOT_DIR" } state_snapshot_restore() { - local status target key saved + local status target key saved parent [[ -s "$STATE_SNAPSHOT_DIR/paths.tsv" ]] || return 1 while IFS=$'\t' read -r status target key; do [[ -n "$target" ]] || continue @@ -906,6 +1041,19 @@ state_snapshot_restore() { cp -a "$saved" "$target" || return fi done < "$STATE_SNAPSHOT_DIR/paths.tsv" + # Mutating a previously absent nested target can leave empty parents behind + # after the target itself is restored. Remove only parents proven absent in + # the pre-install snapshot; repeated passes handle arbitrary nesting without + # depending on GNU tac/sort behavior. + if [[ -s "$STATE_SNAPSHOT_DIR/parents.tsv" ]]; then + for _ in {1..16}; do + while IFS=$'\t' read -r status parent; do + [[ "$status" == absent ]] || continue + [[ "$parent" != "$HOME" && "$parent" == "$HOME"/* ]] || return 1 + rmdir "$parent" 2>/dev/null || true + done < "$STATE_SNAPSHOT_DIR/parents.tsv" + done + fi } state_begin_install() { @@ -980,44 +1128,19 @@ state_mark_resumable_failure() { echo " Remediation: fix each named phase, then run this installer with --check; journal: $STATE_JOURNAL" >&2 } -state_self_test() { - local phase path - state_begin_install - state_snapshot_create - trap 'state_handle_unexpected_failure "$?" "$STATE_CURRENT_PHASE"' ERR INT TERM - for phase in P2 P3 P4 P5 P6 P7 P8; do - state_phase_begin "$phase" - case "$phase" in - P2) path="$MOSAIC_HOME/.selftest-artifact" ;; - P3) path="$PREFIX/bin/mosaic" ;; - P4) path="$MOSAIC_HOME/.selftest-framework" ;; - P5) path="$MOSAIC_HOME/SOUL.md" ;; - P6) path="$HOME/.claude/settings.json" ;; - P7) path="$MOSAIC_HOME/.selftest-service" ;; - P8) path="$HOME/.bashrc" ;; - esac - state_record_mutation "$phase" "$path" "restore representative path from $STATE_SNAPSHOT_DIR" - mkdir -p "$(dirname "$path")" - printf 'mutated-by-%s\n' "$phase" > "$path" - state_phase_finish "$phase" committed "representative mutation committed" - if [[ "${MOSAIC_INSTALL_FAULT_AFTER:-}" == "$phase" ]]; then - state_json_line fault "$phase" injected "phase=$phase" - echo "Injected installer fault: phase=$phase" >&2 - state_snapshot_restore - state_json_line install "$phase" rolled-back "fault injection restored pre-install snapshot" - state_write_active "$(printf '{\"status\":\"rolled-back\",\"phase\":\"%s\",\"journal\":\"%s\"}' "$phase" "$STATE_JOURNAL")" - exit 97 - fi - done - fail "self-test requires MOSAIC_INSTALL_FAULT_AFTER=P2..P8" - exit 2 +state_maybe_inject_fault() { + local phase="$1" + [[ "${MOSAIC_INSTALL_FAULT_AFTER:-}" == "$phase" ]] || return 0 + state_json_line fault "$phase" injected "phase=$phase after real phase action" + echo "Injected installer fault after real action: phase=$phase" >&2 + state_handle_unexpected_failure 97 "$phase" } resolve_source_commit() { local encoded_ref body headers content_type encoded_ref="$(node -p 'encodeURIComponent(process.argv[1])' "$GIT_REF")" - body="$(mktemp "${TMPDIR:-/tmp}/mosaic-ref.XXXXXX.json")" || return - headers="$(mktemp "${TMPDIR:-/tmp}/mosaic-ref.XXXXXX.headers")" || { rm -f "$body"; return 1; } + body="$(mktemp "${TMPDIR:-/tmp}/mosaic-ref-body.XXXXXX")" || return + headers="$(mktemp "${TMPDIR:-/tmp}/mosaic-ref-headers.XXXXXX")" || { rm -f "$body"; return 1; } if ! curl -fsSL -D "$headers" -o "$body" \ "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/commits?sha=${encoded_ref}&limit=1"; then rm -f "$body" "$headers" @@ -1252,9 +1375,10 @@ install_next_cli_from_registry() { # ─── preflight / state-machine dispatch ────────────────────────────────────── if [[ "$FLAG_STATE_SELF_TEST" == "true" ]]; then - require_cmd node - require_cmd flock - state_self_test + if [[ "${MOSAIC_INSTALL_SELF_TEST_ALLOW:-0}" != 1 || ! "${MOSAIC_INSTALL_FAULT_AFTER:-}" =~ ^P[2-8]$ ]]; then + fail "state-machine self-test requires MOSAIC_INSTALL_SELF_TEST_ALLOW=1 and MOSAIC_INSTALL_FAULT_AFTER=P2..P8" + exit 2 + fi fi # `--check` exits before mkdir, npm-prefix setup, locks, snapshots, downloads, or @@ -1306,6 +1430,9 @@ state_phase_finish P0 committed "$P0_REASON" state_phase_finish P1 committed "$P1_REASON; exclusive lock acquired; journal opened" state_snapshot_create trap 'state_handle_unexpected_failure "$?" "$STATE_CURRENT_PHASE"' ERR INT TERM +if [[ "${MOSAIC_INSTALL_REDACTION_PROBE:-0}" == 1 ]]; then + state_run_captured "credential redaction acceptance probe" state_redaction_probe +fi state_phase_begin P2 state_record_mutation P2 "$STATE_RUN_DIR/work" "discard acquired temporary artifacts" @@ -1322,6 +1449,7 @@ if [[ "$FLAG_FRAMEWORK" == "true" || "$FLAG_DEV" == "true" ]]; then state_run_captured "P2 acquire pinned source archive" ensure_monorepo fi state_phase_finish P2 committed "lane=$([[ "$FLAG_NEXT" == true ]] && echo next || echo latest) cli_version=${RESOLVED_CLI_VERSION:-pending-source-package-build} source_commit=${RESOLVED_SOURCE_COMMIT:-deferred-until-source-fallback} sha256=${RESOLVED_SOURCE_DIGEST:-deferred-until-source-fallback}" +state_maybe_inject_fault P2 # ═══════════════════════════════════════════════════════════════════════════════ # PART 1: Framework (bash launcher + guides + runtime configs + tools) @@ -1366,6 +1494,7 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then # Run the framework's own install.sh (handles keep/overwrite for SOUL.md etc.) info "Installing framework to ${MOSAIC_HOME}…" MOSAIC_INSTALL_MODE="${MOSAIC_INSTALL_MODE:-keep}" \ + MOSAIC_CLI_PATH="$PREFIX/bin/mosaic" \ MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1 \ MOSAIC_SKIP_SKILLS_SYNC="${MOSAIC_SKIP_SKILLS_SYNC:-0}" \ bash "$FRAMEWORK_SRC/install.sh" || return @@ -1539,6 +1668,7 @@ else fail "P3 Install CLI failed: $STATE_REASON" false fi +state_maybe_inject_fault P3 state_phase_begin P4 if [[ "$FLAG_FRAMEWORK" == "true" ]]; then @@ -1550,6 +1680,7 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then state_record_mutation P4 "$HOME/.local/state/mosaic/backups" "restore framework backup state from $STATE_SNAPSHOT_DIR" state_record_mutation P6 "$HOME/.claude/settings.json" "restore activation settings from $STATE_SNAPSHOT_DIR" fi +export MOSAIC_CLI_PATH="$PREFIX/bin/mosaic" state_run_captured "P4 install framework and skills; P6 evaluate activation" install_phase_p4_action if [[ "$FLAG_FRAMEWORK" == "false" ]]; then state_phase_finish P4 not-requested "framework component excluded by --cli" @@ -1560,6 +1691,7 @@ else # absent. Keep the partial state for P5-P8 diagnostics; P9 fails non-zero. state_phase_finish P4 failed-resumable "$STATE_REASON" fi +state_maybe_inject_fault P4 # P5/P7 actions (wizard/service requests) live in the summary flow below and # bind their mutation records immediately before the wizard executes. P8 is @@ -1579,7 +1711,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then # First install guidance / auto-launch if [[ ! -f "$MOSAIC_HOME/SOUL.md" ]]; then echo "" - if [[ "$FLAG_NO_AUTO_LAUNCH" == "false" ]] && [[ -t 0 ]] && [[ -t 1 ]]; then + if [[ "$FLAG_NO_AUTO_LAUNCH" == "false" ]] && { { [[ -t 0 ]] && [[ -t 1 ]]; } || [[ "$FLAG_STATE_SELF_TEST" == true ]]; }; then # Interactive TTY and auto-launch not suppressed: run the unified wizard. # `mosaic wizard` now runs the full first-run flow end-to-end: identity # setup → runtimes → hooks preview → skills → finalize → gateway @@ -1589,15 +1721,11 @@ if [[ "$FLAG_CHECK" == "false" ]]; then MOSAIC_BIN="$PREFIX/bin/mosaic" - if ! command -v "$MOSAIC_BIN" &>/dev/null && ! command -v mosaic &>/dev/null; then - warn "mosaic binary not found on PATH — skipping auto-launch." - warn "Add $PREFIX/bin to PATH and run: mosaic wizard" + if [[ ! -x "$MOSAIC_BIN" ]]; then + warn "P3 absolute mosaic binary is unavailable — skipping auto-launch." + warn "Repair $MOSAIC_BIN and run it with: $MOSAIC_BIN wizard" else - # Prefer the absolute path from the prefix we just installed to - MOSAIC_CMD="mosaic" - if [[ -x "$MOSAIC_BIN" ]]; then - MOSAIC_CMD="$MOSAIC_BIN" - fi + MOSAIC_CMD="$MOSAIC_BIN" state_record_mutation P5 "$MOSAIC_HOME/SOUL.md" "restore identity from $STATE_SNAPSHOT_DIR" state_record_mutation P5 "$MOSAIC_HOME/USER.md" "restore identity from $STATE_SNAPSHOT_DIR" @@ -1741,6 +1869,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then else state_phase_finish "$phase" failed-resumable "$STATE_REASON" fi + state_maybe_inject_fault "$phase" done echo "" diff --git a/tools/install.sh.sha256 b/tools/install.sh.sha256 new file mode 100644 index 00000000..95152884 --- /dev/null +++ b/tools/install.sh.sha256 @@ -0,0 +1 @@ +4cd391b0974d3cce6c2a98455420d45bc2a04cb624e3c4bf43a813b8e28693e6 install.sh diff --git a/tools/verified-installer-fetch.sh b/tools/verified-installer-fetch.sh new file mode 100755 index 00000000..bc7f65fc --- /dev/null +++ b/tools/verified-installer-fetch.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Fetch, authenticate, and execute the exact downloaded installer body. +set -euo pipefail +url="${1:?usage: verified-installer-fetch.sh [-- installer-args...]}" +expected="${2:?usage: verified-installer-fetch.sh [-- installer-args...]}" +shift 2 +[[ "${1:-}" != -- ]] || shift +[[ "$expected" =~ ^[0-9a-f]{64}$ ]] || { echo 'installer expected SHA-256 must be 64 lowercase hex characters' >&2; exit 2; } +tmp="$(mktemp "${TMPDIR:-/tmp}/mosaic-installer-body.XXXXXX")" +trap 'rm -f "$tmp"' EXIT +chmod 0600 "$tmp" +curl -fsSL "$url" -o "$tmp" +[[ -s "$tmp" ]] || { echo 'installer fetch returned an empty HTTP-success body' >&2; exit 1; } +actual="$(sha256sum "$tmp" | awk '{print $1}')" +[[ "$actual" == "$expected" ]] || { echo "installer SHA-256 mismatch (got=$actual expected=$expected)" >&2; exit 1; } +status=0 +bash "$tmp" "$@" || status=$? +rm -f "$tmp" +trap - EXIT +exit "$status" diff --git a/tools/verified-installer-fetch.test.sh b/tools/verified-installer-fetch.test.sh new file mode 100755 index 00000000..3f321072 --- /dev/null +++ b/tools/verified-installer-fetch.test.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-fetch-contract.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT +FAKE_BIN="$TMP/bin"; mkdir -p "$FAKE_BIN" +cat > "$FAKE_BIN/curl" <<'CURL' +#!/usr/bin/env bash +set -euo pipefail +url=""; output="" +while [[ $# -gt 0 ]]; do + case "$1" in + -o) output="$2"; shift 2 ;; + -*) shift ;; + *) url="$1"; shift ;; + esac +done +emit() { if [[ -n "$output" ]]; then cat > "$output"; else cat; fi; } +case "$url" in + fixture://ok) + emit <<'SCRIPT' +#!/usr/bin/env bash +set -euo pipefail +printf 'executed:%s\n' "${1:-missing}" +SCRIPT + ;; + fixture://empty) : > "$output" ;; + fixture://failed) exit 22 ;; + *) exit 2 ;; +esac +CURL +chmod 0755 "$FAKE_BIN/curl" +cat > "$TMP/ok.sh" <<'SCRIPT' +#!/usr/bin/env bash +set -euo pipefail +printf 'executed:%s\n' "${1:-missing}" +SCRIPT +ok_sha="$(sha256sum "$TMP/ok.sh" | awk '{print $1}')" +empty_sha="$(printf '' | sha256sum | awk '{print $1}')" + +mkdir -p "$TMP/downloads" +output="$(TMPDIR="$TMP/downloads" PATH="$FAKE_BIN:$PATH" bash "$ROOT/tools/verified-installer-fetch.sh" fixture://ok "$ok_sha" -- marker)" +[[ "$output" == 'executed:marker' ]] +[[ -z "$(find "$TMP/downloads" -mindepth 1 -print -quit)" ]] +printf '[test] PASS: digest-pinned fetched artifact executes and its temporary body is removed\n' + +for row in 'fixture://empty empty-body' 'fixture://failed failed-fetch'; do + url="${row%% *}"; name="${row#* }" + set +e + PATH="$FAKE_BIN:$PATH" bash "$ROOT/tools/verified-installer-fetch.sh" "$url" "$empty_sha" -- marker \ + >"$TMP/$name.log" 2>&1 + status=$? + set -e + [[ "$status" -ne 0 ]] || { echo "[test] FAIL: $name certified success" >&2; exit 1; } +done +printf '[test] PASS: failed fetch and HTTP-200 empty body are both rejected\n' + +set +e +PATH="$FAKE_BIN:$PATH" bash "$ROOT/tools/verified-installer-fetch.sh" fixture://ok "${ok_sha/0/1}" -- marker \ + >"$TMP/mismatch.log" 2>&1 +status=$? +set -e +[[ "$status" -ne 0 ]] || { echo '[test] FAIL: digest mismatch was accepted' >&2; exit 1; } +printf '[test] PASS: fetched installer digest mismatch is blocking\n' diff --git a/tools/verify-greenfield-expected-red.sh b/tools/verify-greenfield-expected-red.sh index fad6ad4d..5eba79fd 100755 --- a/tools/verify-greenfield-expected-red.sh +++ b/tools/verify-greenfield-expected-red.sh @@ -13,6 +13,68 @@ FIXTURE_EXIT="${3:?usage: verify-greenfield-expected-red.sh &2; exit 2; } [[ "$FIXTURE_EXIT" =~ ^[0-9]+$ ]] || { echo "fixture exit is not numeric: $FIXTURE_EXIT" >&2; exit 2; } +# Validate the entire pinned contract before selecting one case. Otherwise a +# deleted case/phase silently disappears from the gate and a one-row manifest +# can certify any exit-1 transcript. +expected_cases=(next-git-present main-git-present next-git-absent) +declare -A allowed_case=( + [next-git-present]=1 [main-git-present]=1 [next-git-absent]=1 +) +declare -A expected_requires=( + [next-git-present]=6 [main-git-present]=6 [next-git-absent]=4 +) +declare -A row_count=() exit_count=() require_count=() forbid_count=() phase_count=() unique_rows=() +while IFS= read -r raw; do + [[ -n "$raw" && "${raw:0:1}" != "#" ]] || continue + field_count="$(awk -F '\t' '{print NF}' <<<"$raw")" + [[ "$field_count" -eq 3 ]] || { echo "invalid expected-RED manifest row (expected exactly 3 tab fields): $raw" >&2; exit 2; } + IFS=$'\t' read -r case_name kind expectation <<<"$raw" + [[ -n "${allowed_case[$case_name]:-}" ]] || { echo "invalid expected-RED manifest case: $case_name" >&2; exit 2; } + unique_key="$case_name|$kind|$expectation" + [[ -z "${unique_rows[$unique_key]:-}" ]] || { echo "duplicate expected-RED manifest row: $raw" >&2; exit 2; } + unique_rows[$unique_key]=1 + row_count[$case_name]=$((${row_count[$case_name]:-0} + 1)) + case "$kind" in + exit) + [[ "$expectation" == 1 ]] || { echo "invalid expected-RED exit contract: case=$case_name expected=$expectation" >&2; exit 2; } + exit_count[$case_name]=$((${exit_count[$case_name]:-0} + 1)) + ;; + phase) + [[ "$expectation" =~ ^(P[0-9])=(PASS|FAIL)$ ]] \ + || { echo "invalid expected-RED phase disposition: case=$case_name value=$expectation" >&2; exit 2; } + phase="${BASH_REMATCH[1]}" + phase_key="$case_name|$phase" + phase_count[$phase_key]=$((${phase_count[$phase_key]:-0} + 1)) + ;; + require) + [[ -n "$expectation" ]] || { echo "empty expected-RED require row: case=$case_name" >&2; exit 2; } + require_count[$case_name]=$((${require_count[$case_name]:-0} + 1)) + ;; + forbid) + [[ -n "$expectation" ]] || { echo "empty expected-RED forbid row: case=$case_name" >&2; exit 2; } + forbid_count[$case_name]=$((${forbid_count[$case_name]:-0} + 1)) + ;; + *) echo "invalid expected-RED manifest kind: case=$case_name kind=$kind" >&2; exit 2 ;; + esac +done < "$MANIFEST" + +for case_name in "${expected_cases[@]}"; do + [[ "${exit_count[$case_name]:-0}" -eq 1 ]] \ + || { echo "expected-RED manifest requires exactly one exit row for case=$case_name" >&2; exit 2; } + for phase in P0 P1 P2 P3 P4 P5 P6 P7 P8 P9; do + [[ "${phase_count[$case_name|$phase]:-0}" -eq 1 ]] \ + || { echo "expected-RED manifest requires exactly one $phase disposition for case=$case_name" >&2; exit 2; } + done + [[ "${require_count[$case_name]:-0}" -eq "${expected_requires[$case_name]}" ]] \ + || { echo "expected-RED manifest require-row population changed for case=$case_name" >&2; exit 2; } + [[ "${forbid_count[$case_name]:-0}" -eq 1 ]] \ + || { echo "expected-RED manifest requires exactly one forbid row for case=$case_name" >&2; exit 2; } + expected_total=$((1 + 10 + expected_requires[$case_name] + 1)) + [[ "${row_count[$case_name]:-0}" -eq "$expected_total" ]] \ + || { echo "expected-RED manifest row population changed for case=$case_name" >&2; exit 2; } +done +[[ -n "${allowed_case[$CASE]:-}" ]] || { echo "unknown expected-RED verification case: $CASE" >&2; exit 2; } + checks=0 failures=0 while IFS=$'\t' read -r case_name kind expectation; do diff --git a/tools/verify-greenfield-expected-red.test.sh b/tools/verify-greenfield-expected-red.test.sh index 10877ff6..35464d65 100755 --- a/tools/verify-greenfield-expected-red.test.sh +++ b/tools/verify-greenfield-expected-red.test.sh @@ -7,13 +7,14 @@ trap 'rm -rf "$TMP"' EXIT cat > "$TMP/match.log" <<'LOG' [fixture] resolved lane=next package=@mosaicstack/mosaic@next version=0.0.50-next.999 [fixture] installer_exit=1 done_claims=0 +[SECRET-CONTROL] PASS: seeded canary absent from complete scan population [P0] PASS: supported context [P1] PASS: preflight complete [P2] PASS: pinned artifact [P3] PASS: absolute_path=/home/test/.npm-global/bin/mosaic version=0.0.50-next.999 equals resolved lane version [P4] FAIL: NOT-MEASURED / UNDECLARED: declaration absent [P5] FAIL: identity absent -[P6] FAIL: activation unavailable +[P6] FAIL: broker absent but dead enforcement hooks are active [P7] PASS: no services requested [P8] FAIL: shell path absent [P9] FAIL: aggregate refusal @@ -34,3 +35,43 @@ if bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/m exit 1 fi printf '[test] PASS: unexpected fixture exit remains blocking\n' + +printf 'next-git-present\texit\t1\n' > "$TMP/shrunk.tsv" +if MOSAIC_EXPECTED_RED_MANIFEST="$TMP/shrunk.tsv" \ + bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 1 >/dev/null 2>&1; then + echo '[test] FAIL: one-row manifest shrink still certified the detector' >&2 + exit 1 +fi +printf '[test] PASS: manifest shrink cannot delete the structural contract\n' + +manifest="$ROOT/tools/fixtures/greenfield-expected-red.tsv" +grep -v $'^next-git-present\tphase\tP8=' "$manifest" > "$TMP/missing-phase.tsv" +if MOSAIC_EXPECTED_RED_MANIFEST="$TMP/missing-phase.tsv" \ + bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 1 >/dev/null 2>&1; then + echo '[test] FAIL: missing P8 disposition was accepted' >&2; exit 1 +fi +printf '[test] PASS: every case requires one P0-P9 disposition\n' + +cp "$manifest" "$TMP/duplicate.tsv" +printf 'next-git-present\tphase\tP3=PASS\n' >> "$TMP/duplicate.tsv" +if MOSAIC_EXPECTED_RED_MANIFEST="$TMP/duplicate.tsv" \ + bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 1 >/dev/null 2>&1; then + echo '[test] FAIL: duplicate phase key was accepted' >&2; exit 1 +fi +printf '[test] PASS: duplicate structural keys are rejected\n' + +cp "$manifest" "$TMP/unknown-case.tsv" +printf 'invented-case\texit\t1\n' >> "$TMP/unknown-case.tsv" +if MOSAIC_EXPECTED_RED_MANIFEST="$TMP/unknown-case.tsv" \ + bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 1 >/dev/null 2>&1; then + echo '[test] FAIL: unknown case was accepted' >&2; exit 1 +fi +printf '[test] PASS: unknown case rows are rejected\n' + +cp "$manifest" "$TMP/unknown-kind.tsv" +printf 'next-git-present\toptional\tanything\n' >> "$TMP/unknown-kind.tsv" +if MOSAIC_EXPECTED_RED_MANIFEST="$TMP/unknown-kind.tsv" \ + bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 1 >/dev/null 2>&1; then + echo '[test] FAIL: unknown row kind was accepted' >&2; exit 1 +fi +printf '[test] PASS: unknown manifest kinds are rejected\n' -- 2.54.0 From e5d5c8495a070af2dcd393cace287fe74a8a819e Mon Sep 17 00:00:00 2001 From: be-coder-05 Date: Wed, 5 Aug 2026 18:09:19 -0500 Subject: [PATCH 06/15] test(installer): pin B8 redaction evidence --- .../01-exact-failing-assertion.txt | 14 + .../02-exact-masked-log-and-image-probe.txt | 16 + .../03-positive-control-result.txt | 14 + .../positive-control.log | 10 + .../positive-control.test.sh | 578 ++++++++++++++++++ .../1050-install-state-machine-red-fixture.md | 2 +- tools/install-next-lane.test.sh | 12 +- 7 files changed, 641 insertions(+), 5 deletions(-) create mode 100644 docs/reports/verification/1050-b8-redaction-control/01-exact-failing-assertion.txt create mode 100644 docs/reports/verification/1050-b8-redaction-control/02-exact-masked-log-and-image-probe.txt create mode 100644 docs/reports/verification/1050-b8-redaction-control/03-positive-control-result.txt create mode 100644 docs/reports/verification/1050-b8-redaction-control/positive-control.log create mode 100755 docs/reports/verification/1050-b8-redaction-control/positive-control.test.sh diff --git a/docs/reports/verification/1050-b8-redaction-control/01-exact-failing-assertion.txt b/docs/reports/verification/1050-b8-redaction-control/01-exact-failing-assertion.txt new file mode 100644 index 00000000..9a4771fd --- /dev/null +++ b/docs/reports/verification/1050-b8-redaction-control/01-exact-failing-assertion.txt @@ -0,0 +1,14 @@ +subject_head=3edde464b3891ad439019fcc19aad7728e4c2fb8 +source=git show HEAD:tools/install-next-lane.test.sh + + 477 echo 'credentialed URL userinfo leaked to terminal output' >&2; exit 1 + 478 fi + 479 [[ "$(grep -oF '[REDACTED]@' <<<"$OUTPUT" | wc -l | tr -d ' ')" -ge 5 ]] \ + 480 || { echo 'credentialed URL redaction controls were not all exercised' >&2; exit 1; } + 481 secret_active="$TMP/secret-state/active.json" +-- + 525 echo 'framework nested capture leaked credential diagnostics' >&2; exit 1 + 526 fi + 527 [[ "$(grep -oF '[REDACTED]@' "$framework_log" | wc -l | tr -d ' ')" -ge 5 ]] \ + 528 || { echo 'framework URL redaction controls were not exercised' >&2; exit 1; } + 529 diff --git a/docs/reports/verification/1050-b8-redaction-control/02-exact-masked-log-and-image-probe.txt b/docs/reports/verification/1050-b8-redaction-control/02-exact-masked-log-and-image-probe.txt new file mode 100644 index 00000000..f523ca06 --- /dev/null +++ b/docs/reports/verification/1050-b8-redaction-control/02-exact-masked-log-and-image-probe.txt @@ -0,0 +1,16 @@ +source=/tmp/c1-ci-next-x.log (exact failing canonical-image xtrace) +credential material is already replaced by the redactor token [REDACTED]; no live secret is reproduced + + urls=https://[REDACTED]@example.com/a https://[REDACTED]@example.net/b https://[REDACTED]@example.org/c https://[REDACTED]@example.dev/d https://[REDACTED]@example.io/e + urls=https://[REDACTED]@example.com/a https://[REDACTED]@example.net/b https://[REDACTED]@example.org/c https://[REDACTED]@example.dev/d https://[REDACTED]@example.io/e + +line_count=2 +occurrence_count=10 +observed_assertion_value=2 (from xtrace: [[ 2 -ge 5 ]]) + +canonical-image discriminator (same locally cached digest as failing run): +image_id=sha256:d40fb1a218b72d3dcbf8a427a5076facf2a6d958b6854e6bbd057f7264540841 repo_digests=["git.mosaicstack.dev/mosaicstack/stack/ci-base@sha256:0f1d996a6cfcc09e6dcf979ee66c872a1b0be4f1bfde852b4790f520ddd0d776"] +busybox=BusyBox v1.37.0 (2026-01-10 15:38:28 UTC) +regex_-o_single_line=5 +fixed_-oF_single_line=1 +fixed_-oF_two_lines=2 diff --git a/docs/reports/verification/1050-b8-redaction-control/03-positive-control-result.txt b/docs/reports/verification/1050-b8-redaction-control/03-positive-control-result.txt new file mode 100644 index 00000000..1243d377 --- /dev/null +++ b/docs/reports/verification/1050-b8-redaction-control/03-positive-control-result.txt @@ -0,0 +1,14 @@ +positive_control_exit=1 +seeded_line=https://[MASKED-USERINFO]@example.io/e (actual synthetic userinfo intentionally omitted here) +expected_failure=credentialed URL redaction control missing for example.io +transcript_tail: +[test] --next fast path pins resolved package versions +[test] fast path failure falls back to source build +[test] source-build failure is fatal and restores the pre-install prefix +[test] corrupt source archive is fatal and restores the pre-install prefix +[test] --dev source install does not require registry version resolution +[test] explicit --ref keeps source lane and avoids @next lookup +[test] --check --next rejects mismatched prerelease pipeline suffixes +[test] full framework path receives P3 absolute CLI without relying on PATH +[test] captured diagnostics redact seeded credential canary everywhere +credentialed URL redaction control missing for example.io diff --git a/docs/reports/verification/1050-b8-redaction-control/positive-control.log b/docs/reports/verification/1050-b8-redaction-control/positive-control.log new file mode 100644 index 00000000..023dc269 --- /dev/null +++ b/docs/reports/verification/1050-b8-redaction-control/positive-control.log @@ -0,0 +1,10 @@ +[test] --next fast path pins resolved package versions +[test] fast path failure falls back to source build +[test] source-build failure is fatal and restores the pre-install prefix +[test] corrupt source archive is fatal and restores the pre-install prefix +[test] --dev source install does not require registry version resolution +[test] explicit --ref keeps source lane and avoids @next lookup +[test] --check --next rejects mismatched prerelease pipeline suffixes +[test] full framework path receives P3 absolute CLI without relying on PATH +[test] captured diagnostics redact seeded credential canary everywhere +credentialed URL redaction control missing for example.io diff --git a/docs/reports/verification/1050-b8-redaction-control/positive-control.test.sh b/docs/reports/verification/1050-b8-redaction-control/positive-control.test.sh new file mode 100755 index 00000000..8df258ba --- /dev/null +++ b/docs/reports/verification/1050-b8-redaction-control/positive-control.test.sh @@ -0,0 +1,578 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="/work" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-next-install-test-XXXXXX")" +trap 'rm -rf "$TMP"' EXIT +export TMPDIR="$TMP/runtime-tmp" +mkdir -p "$TMPDIR" + +FAKE_BIN="$TMP/bin" +HOME_DIR="$TMP/home" +PREFIX="$HOME_DIR/prefix" +MOSAIC_HOME="$HOME_DIR/mosaic" +STATE="$TMP/state" +LOG="$TMP/npm.log" +mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE" + +# Model the supported non-root/glibc target explicitly even when this harness +# itself runs as root in Alpine/BusyBox CI. +cat > "$FAKE_BIN/id" <<'FAKE_ID' +#!/usr/bin/env bash +case "${1:-}" in + -u) echo 1001 ;; + -g) echo 1001 ;; + -un) echo fixture-user ;; + *) exec /bin/id "$@" ;; +esac +FAKE_ID +cat > "$FAKE_BIN/getent" < "$FAKE_BIN/ldd" <<'FAKE_LDD' +#!/usr/bin/env bash +printf 'ldd (GNU libc) 2.36\n' +FAKE_LDD +cat > "$FAKE_BIN/stat" <<'FAKE_STAT' +#!/usr/bin/env bash +if [[ "${1:-} ${2:-}" == '-c %u' ]]; then + [[ "${3:-}" == "${MOSAIC_TEST_WRONG_OWNER_PATH:-__none__}" ]] && echo 9999 || echo 1001 + exit 0 +fi +if [[ "${1:-} ${2:-}" == '-c %g' ]]; then + [[ "${3:-}" == "${MOSAIC_TEST_WRONG_GROUP_PATH:-__none__}" ]] && echo 9999 || echo 1001 + exit 0 +fi +exec /bin/stat "$@" +FAKE_STAT +cat > "$FAKE_BIN/realpath" <<'FAKE_REALPATH' +#!/usr/bin/env python3 +import os, sys +args=sys.argv[1:] +mode=args.pop(0) if args and args[0] in ('-e','-m') else '-m' +if args and args[0]=='--': args.pop(0) +if len(args)!=1 or (mode=='-e' and not os.path.exists(args[0])): raise SystemExit(1) +print(os.path.realpath(args[0])) +FAKE_REALPATH +chmod 0755 "$FAKE_BIN/id" "$FAKE_BIN/getent" "$FAKE_BIN/ldd" "$FAKE_BIN/stat" "$FAKE_BIN/realpath" + +cat > "$FAKE_BIN/npm" <<'FAKE_NPM' +#!/usr/bin/env bash +set -euo pipefail +LOG="${MOSAIC_TEST_NPM_LOG:?}" +STATE="${MOSAIC_TEST_STATE:?}" +echo "$*" >> "$LOG" + +if [[ "${1:-}" == "--version" ]]; then + echo "10.6.2" + exit 0 +fi + +install_cli() { + local version="$1" + echo "$version" > "$STATE/mosaic" + mkdir -p "${MOSAIC_PREFIX:?}/bin" + cat > "$MOSAIC_PREFIX/bin/mosaic" <> "\${MOSAIC_TEST_NPM_LOG:?}" + mkdir -p "\${MOSAIC_HOME:?}" "\${HOME:?}/.config/mosaic-gateway" + printf '# Soul\\n\\nConfigured.\\n' > "\$MOSAIC_HOME/SOUL.md" + printf '# User\\n\\nConfigured.\\n' > "\$MOSAIC_HOME/USER.md" + chmod 0600 "\$MOSAIC_HOME/SOUL.md" "\$MOSAIC_HOME/USER.md" + exit 0 +fi +printf '%s\\n' '$version' +CLI + chmod +x "$MOSAIC_PREFIX/bin/mosaic" +} + +if [[ "$1" == "view" ]]; then + if [[ "${MOSAIC_TEST_FAIL_NPM_VIEW:-0}" == "1" ]]; then + echo "forced registry metadata failure" >&2 + exit 1 + fi + case "$2 $3" in + "@mosaicstack/mosaic@next version") echo "0.0.49-next.999" ;; + "@mosaicstack/gateway@next version") echo "${MOSAIC_TEST_GATEWAY_NEXT_VERSION:-0.0.7-next.999}" ;; + "@mosaicstack/mosaic version") echo "0.0.48" ;; + *) echo "unexpected npm view: $*" >&2; exit 1 ;; + esac + exit 0 +fi + +if [[ "$1" == "install" ]]; then + if [[ -n "${MOSAIC_INSTALL_SECRET_CANARY:-}" ]]; then + printf 'registry diagnostic authToken=%s\n' "$MOSAIC_INSTALL_SECRET_CANARY" + printf 'urls=https://alice:p@ss@example.com/a https://bob:pa:ss@example.net/b https://carol:p%%40ss@example.org/c https://token@example.dev/d https://public.example/e\n' + printf 'Authorization: Basic QWxhZGRpbjpvcGVu\n//registry/:_auth=Ym9iOnNlY3JldA==\nCookie: session=abc123\nSet-Cookie: sid=xyz789\n' + printf '%s\n' "$MOSAIC_INSTALL_SECRET_CANARY" > "${MOSAIC_TEST_CANARY_OBSERVATION:?}" + fi + case "$*" in + *"@mosaicstack/mosaic@0.0.49-next.999"*) + install_cli "0.0.49-next.999" + ;; + *"@mosaicstack/gateway@0.0.7-next.999"*) + if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then + echo "forced gateway install failure" >&2 + exit 1 + fi + echo "0.0.7-next.999" > "$STATE/gateway" + ;; + *"mosaicstack-mosaic-0.0.0-source.tgz"*) + install_cli "0.0.0-source" + ;; + *"mosaicstack-gateway-0.0.0-source.tgz"*) + echo "0.0.0-source" > "$STATE/gateway" + ;; + *) echo "unexpected npm install: $*" >&2; exit 1 ;; + esac + exit 0 +fi + +if [[ "$1" == "ls" ]]; then + cli="$(cat "$STATE/mosaic" 2>/dev/null || true)" + gateway="$(cat "$STATE/gateway" 2>/dev/null || true)" + node -e ' + const cli = process.argv[1]; + const gateway = process.argv[2]; + const dependencies = {}; + if (cli) dependencies["@mosaicstack/mosaic"] = { version: cli }; + if (gateway) dependencies["@mosaicstack/gateway"] = { version: gateway }; + process.stdout.write(JSON.stringify({ dependencies })); + ' "$cli" "$gateway" + exit 0 +fi + +echo "unexpected npm command: $*" >&2 +exit 1 +FAKE_NPM +chmod +x "$FAKE_BIN/npm" + +cat > "$FAKE_BIN/curl" <<'FAKE_CURL' +#!/usr/bin/env bash +set -euo pipefail +headers=""; output=""; url="" +while [[ $# -gt 0 ]]; do + case "$1" in + -D) headers="$2"; shift 2 ;; + -o) output="$2"; shift 2 ;; + --max-filesize) shift 2 ;; + -*) shift ;; + *) url="$1"; shift ;; + esac +done +case "$url" in + */api/v1/repos/mosaicstack/stack/commits?sha=*) + printf 'HTTP/1.1 200 OK\r\ncontent-type: application/json; charset=utf-8\r\n\r\n' > "$headers" + printf '[{"sha":"1111111111111111111111111111111111111111"}]\n' > "$output" + ;; + */archive/*.tar.gz) + if [[ "${MOSAIC_TEST_CORRUPT_ARCHIVE:-0}" == "1" ]]; then + printf 'not-a-tarball\n' > "$output" + else + archive_root="$(mktemp -d)" + mkdir -p "$archive_root/stack" + printf 'fixture\n' > "$archive_root/stack/.fixture" + /bin/tar czf "$output" -C "$archive_root" stack + rm -rf "$archive_root" + fi + ;; +esac +FAKE_CURL +chmod +x "$FAKE_BIN/curl" + +cat > "$FAKE_BIN/tar" <<'FAKE_TAR' +#!/usr/bin/env bash +set -euo pipefail +dest=""; list=false +while [[ $# -gt 0 ]]; do + case "$1" in + -C) dest="$2"; shift 2 ;; + -*t*|t*) list=true; shift ;; + *) shift ;; + esac +done +[[ "$list" == true ]] && exit 0 +if [[ -z "$dest" ]]; then + echo "fake tar missing -C destination" >&2 + exit 1 +fi +mkdir -p "$dest/stack/packages/mosaic/framework" "$dest/stack/apps/gateway" +cat > "$dest/stack/packages/mosaic/framework/install.sh" <<'FRAMEWORK' +#!/usr/bin/env bash +set -euo pipefail +expected="${MOSAIC_PREFIX:?}/bin/mosaic" +[[ "${MOSAIC_CLI_PATH:-}" == "$expected" && -x "$MOSAIC_CLI_PATH" ]] || { + echo "framework did not receive P3 absolute CLI (got=${MOSAIC_CLI_PATH:-unset} expected=$expected)" >&2 + exit 61 +} +printf 'framework-cli=%s version=%s\n' "$MOSAIC_CLI_PATH" "$($MOSAIC_CLI_PATH --version)" >> "${MOSAIC_TEST_NPM_LOG:?}" +mkdir -p "${MOSAIC_HOME:?}/credentials" +chmod 0700 "$MOSAIC_HOME/credentials" +printf '# framework fixture\n' > "$MOSAIC_HOME/AGENTS.md" +FRAMEWORK +chmod 0755 "$dest/stack/packages/mosaic/framework/install.sh" +FAKE_TAR +chmod +x "$FAKE_BIN/tar" + +cat > "$FAKE_BIN/pnpm" <<'FAKE_PNPM' +#!/usr/bin/env bash +set -euo pipefail +LOG="${MOSAIC_TEST_NPM_LOG:?}" +echo "pnpm $*" >> "$LOG" + +if [[ "$1" == "pack" ]]; then + out="" + while [[ $# -gt 0 ]]; do + case "$1" in + --pack-destination) out="$2"; shift 2 ;; + *) shift ;; + esac + done + if [[ -z "$out" ]]; then + echo "fake pnpm pack missing destination" >&2 + exit 1 + fi + mkdir -p "$out" + case "$PWD" in + */apps/gateway) touch "$out/mosaicstack-gateway-0.0.0-source.tgz" ;; + */packages/mosaic) touch "$out/mosaicstack-mosaic-0.0.0-source.tgz" ;; + *) echo "unexpected pnpm pack cwd: $PWD" >&2; exit 1 ;; + esac + exit 0 +fi + +if [[ "${MOSAIC_TEST_FAIL_PNPM_INSTALL:-0}" == "1" && "$1" == "install" ]]; then + echo "forced pnpm install failure" >&2 + exit 42 +fi + +# Other install/build commands are no-ops in this harness. +exit 0 +FAKE_PNPM +chmod +x "$FAKE_BIN/pnpm" + +reset_state() { + : > "$LOG" + rm -f "$STATE"/* +} + +tree_fingerprint() { + local root="$1" + if [[ ! -d "$root" ]]; then printf 'ABSENT\n'; return; fi + python3 - "$root" <<'PY' +import hashlib, os, stat, sys +root=os.path.abspath(sys.argv[1]); rows=[] +for current, dirs, files in os.walk(root, topdown=True, followlinks=False): + for name in dirs + files: + path=os.path.join(current,name); meta=os.lstat(path) + rel=os.path.relpath(path,root) + target=os.readlink(path) if stat.S_ISLNK(meta.st_mode) else '' + digest='' + if stat.S_ISREG(meta.st_mode): + with open(path,'rb') as handle: digest=hashlib.sha256(handle.read()).hexdigest() + rows.append((rel,stat.S_IFMT(meta.st_mode),stat.S_IMODE(meta.st_mode),target,digest)) +payload='\n'.join('|'.join(map(str,row)) for row in sorted(rows)).encode() +print(hashlib.sha256(payload).hexdigest()) +PY +} + +prefix_fingerprint() { tree_fingerprint "$PREFIX"; } + +reset_state +echo "[test] --next fast path pins resolved package versions" +OUTPUT="$( + HOME="$HOME_DIR" \ + MOSAIC_HOME="$MOSAIC_HOME" \ + MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" \ + MOSAIC_TEST_STATE="$STATE" \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch +)" + +grep -qF 'Installed @next packages: CLI 0.0.49-next.999, gateway 0.0.7-next.999' <<<"$OUTPUT" +grep -qF 'install -g @mosaicstack/gateway@0.0.7-next.999' "$LOG" +grep -qF 'install -g @mosaicstack/mosaic@0.0.49-next.999' "$LOG" +if grep -qE '^install -g .+@next( |$)' "$LOG"; then + echo "expected exact-version installs, found mutable @next install" >&2 + exit 1 +fi +if grep -qF 'Downloading source ref next at pinned commit' <<<"$OUTPUT"; then + echo "fast path unexpectedly fell back to source" >&2 + exit 1 +fi + +ACTIVE="$HOME_DIR/.local/state/mosaic/install/active.json" +[[ "$(node -p "require('$ACTIVE').status")" == "committed" ]] +JOURNAL="$(node -p "require('$ACTIVE').journal")" +[[ "$(stat -c '%a' "$JOURNAL")" == "444" ]] +( cd "$(dirname "$JOURNAL")" && sha256sum -c "$(basename "$JOURNAL").sha256" >/dev/null ) +grep -q '"event":"mutation".*"phase":"P3".*path=.*prior=.*reverse=' "$JOURNAL" + +reset_state +echo "[test] fast path failure falls back to source build" +OUTPUT="$( + HOME="$HOME_DIR" \ + MOSAIC_HOME="$MOSAIC_HOME" \ + MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" \ + MOSAIC_TEST_STATE="$STATE" \ + MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch +)" + +grep -qF 'Fast gateway @next install failed.' <<<"$OUTPUT" +grep -qF 'Falling back to source build at ref next; --next will not hard-fail on registry issues.' <<<"$OUTPUT" +grep -qF 'Downloading source ref next at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT" +grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT" +grep -qF 'install -g @mosaicstack/mosaic@0.0.49-next.999' "$LOG" +grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG" +grep -qE 'install -g .*/mosaicstack-mosaic-0\.0\.0-source\.tgz' "$LOG" +[[ "$(cat "$STATE/mosaic")" == "0.0.0-source" ]] +[[ "$(cat "$STATE/gateway")" == "0.0.0-source" ]] + +reset_state +echo "[test] source-build failure is fatal and restores the pre-install prefix" +before_prefix="$(prefix_fingerprint)" +set +e +OUTPUT="$( + HOME="$HOME_DIR" \ + MOSAIC_HOME="$MOSAIC_HOME" \ + MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" \ + MOSAIC_TEST_STATE="$STATE" \ + MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \ + MOSAIC_TEST_FAIL_PNPM_INSTALL=1 \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1 +)" +FAIL_STATUS=$? +set -e +[[ "$FAIL_STATUS" -ne 0 ]] +[[ "$(prefix_fingerprint)" == "$before_prefix" ]] +grep -qF 'forced pnpm install failure' <<<"$OUTPUT" +[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]] + +reset_state +echo "[test] corrupt source archive is fatal and restores the pre-install prefix" +before_prefix="$(prefix_fingerprint)" +set +e +OUTPUT="$( + HOME="$HOME_DIR" \ + MOSAIC_HOME="$MOSAIC_HOME" \ + MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" \ + MOSAIC_TEST_STATE="$STATE" \ + MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \ + MOSAIC_TEST_CORRUPT_ARCHIVE=1 \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1 +)" +FAIL_STATUS=$? +set -e +[[ "$FAIL_STATUS" -ne 0 ]] +[[ "$(prefix_fingerprint)" == "$before_prefix" ]] +grep -qF 'archive safety/integrity check failed' <<<"$OUTPUT" +[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]] + +reset_state +echo "[test] --dev source install does not require registry version resolution" +OUTPUT="$( + HOME="$HOME_DIR" \ + MOSAIC_HOME="$MOSAIC_HOME" \ + MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" \ + MOSAIC_TEST_STATE="$STATE" \ + MOSAIC_TEST_FAIL_NPM_VIEW=1 \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --cli --dev --ref feature-x --yes --no-auto-launch +)" +grep -qF 'Downloading source ref feature-x at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT" +grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT" +grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT" + +reset_state +echo "[test] explicit --ref keeps source lane and avoids @next lookup" +set +e +OUTPUT="$( + HOME="$HOME_DIR" \ + MOSAIC_HOME="$MOSAIC_HOME" \ + MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" \ + MOSAIC_TEST_STATE="$STATE" \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x +)" +CHECK_STATUS=$? +set -e +[[ "$CHECK_STATUS" -ne 0 ]] +grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT" +if grep -qF '@next version' "$LOG"; then + echo "explicit ref should not query @next dist-tags" >&2 + exit 1 +fi + +reset_state +echo "[test] --check --next rejects mismatched prerelease pipeline suffixes" +set +e +OUTPUT="$( + HOME="$HOME_DIR" \ + MOSAIC_HOME="$MOSAIC_HOME" \ + MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" \ + MOSAIC_TEST_STATE="$STATE" \ + MOSAIC_TEST_GATEWAY_NEXT_VERSION="0.0.7-next.1000" \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --check --cli --next +)" +CHECK_STATUS=$? +set -e +[[ "$CHECK_STATUS" -ne 0 ]] +grep -q '^\[P2\] FAIL: resolved_version=unavailable' <<<"$OUTPUT" + +printf '[test] full framework path receives P3 absolute CLI without relying on PATH\n' +rm -rf "$HOME_DIR" "$STATE"; mkdir -p "$HOME_DIR" "$STATE"; reset_state +set +e +OUTPUT="$( + HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_INSTALL_STATE_DIR="$TMP/full-state" MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" \ + PATH="$FAKE_BIN:/usr/local/bin:/usr/bin:/bin" \ + bash "$ROOT/tools/install.sh" --next --yes --no-auto-launch 2>&1 +)" +FULL_STATUS=$? +set -e +[[ "$FULL_STATUS" -ne 0 ]] # P4 remains intentionally undeclared until C5. +grep -qF "framework-cli=$PREFIX/bin/mosaic version=0.0.49-next.999" "$LOG" +if grep -q "CLI not found on PATH\|did not receive P3 absolute CLI" <<<"$OUTPUT"; then + echo "internal framework phase depended on PATH instead of P3 absolute CLI" >&2 + exit 1 +fi + +printf '[test] captured diagnostics redact seeded credential canary everywhere\n' +rm -rf "$HOME_DIR" "$STATE"; mkdir -p "$HOME_DIR" "$STATE"; reset_state +canary='C1_SECRET_CANARY_7df4c2' +OUTPUT="$( + HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_INSTALL_STATE_DIR="$TMP/secret-state" MOSAIC_NO_COLOR=1 \ + MOSAIC_INSTALL_SECRET_CANARY="$canary" MOSAIC_TEST_CANARY_OBSERVATION="$TMP/canary-observed" \ + MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" \ + PATH="$FAKE_BIN:/usr/local/bin:/usr/bin:/bin" \ + bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1 +)" +# Positive control: replace the removed redacted example.io source with one deliberately unredacted userinfo URL. +OUTPUT+=$'\nhttps://leaked@example.io/e' +if grep -qF "$canary" <<<"$OUTPUT"; then echo 'credential canary leaked to terminal output' >&2; exit 1; fi +if grep -Eq 'alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789' <<<"$OUTPUT"; then + echo 'credentialed URL userinfo leaked to terminal output' >&2; exit 1 +fi +for host in example.com example.net example.org example.dev example.io; do + grep -qF "https://[REDACTED]@$host" <<<"$OUTPUT" \ + || { echo "credentialed URL redaction control missing for $host" >&2; exit 1; } +done +secret_active="$TMP/secret-state/active.json" +secret_journal="$(node -p "require('$secret_active').journal")" +secret_command_log="$(dirname "$secret_journal")/commands.log" +if grep -R -F "$canary" "$secret_command_log" "$HOME_DIR" 2>/dev/null; then + echo 'credential canary leaked to persistent installer output' >&2; exit 1 +fi +if grep -E 'alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789' "$secret_command_log" >/dev/null; then + echo 'credentialed URL userinfo leaked to persistent installer output' >&2; exit 1 +fi +if [[ "$(cat "$TMP/canary-observed" 2>/dev/null || true)" != "$canary" ]]; then + echo 'credential canary positive control was not exercised' >&2; exit 1 +fi +if find "$TMPDIR" -maxdepth 1 -type f \( -name 'mosaic-phase-redacted.*' -o -name 'mosaic-post-redacted.*' \) -print -quit | grep -q .; then + echo 'redacted diagnostic staging file survived normal completion' >&2; exit 1 +fi + +printf '[test] framework nested capture redacts the same canary and URL variants\n' +framework_test_home="$TMP/framework-redact-home" +framework_target="$framework_test_home/.config/mosaic" +framework_cli="$TMP/framework-redact-cli" +framework_log="$TMP/framework-redact-commands.log" +framework_status="$TMP/framework-redact-status.tsv" +mkdir -p "$framework_test_home"; : > "$framework_log"; : > "$framework_status" +cat > "$framework_cli" <<'FRAMEWORK_CLI' +#!/usr/bin/env bash +printf 'nested authToken=%s\n' "${MOSAIC_INSTALL_SECRET_CANARY:?}" +printf 'nested=https://alice:p@ss@example.com/a https://bob:pa:ss@example.net/b https://carol:p%%40ss@example.org/c https://token@example.dev/d https://user%%3Apass@example.io/e\n' +printf 'Authorization: Basic QWxhZGRpbjpvcGVu\n//registry/:_auth=Ym9iOnNlY3JldA==\nCookie: session=abc123\nSet-Cookie: sid=xyz789\n' +exit 1 +FRAMEWORK_CLI +chmod 0755 "$framework_cli" +set +e +FRAMEWORK_OUTPUT="$( + HOME="$framework_test_home" MOSAIC_HOME="$framework_target" MOSAIC_INSTALL_MODE=overwrite \ + MOSAIC_CLI_PATH="$framework_cli" MOSAIC_INSTALL_SECRET_CANARY="$canary" \ + MOSAIC_INSTALL_COMMAND_LOG="$framework_log" MOSAIC_INSTALL_PHASE_STATUS_FILE="$framework_status" \ + MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1 MOSAIC_SKIP_SKILLS_SYNC=1 \ + bash "$ROOT/packages/mosaic/framework/install.sh" 2>&1 +)" +framework_install_status=$? +set -e +[[ "$framework_install_status" -eq 0 ]] +if grep -Eq "$canary|alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789" <<<"$FRAMEWORK_OUTPUT" \ + || grep -Eq "$canary|alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789" "$framework_log"; then + echo 'framework nested capture leaked credential diagnostics' >&2; exit 1 +fi +for host in example.com example.net example.org example.dev example.io; do + grep -qF "https://[REDACTED]@$host" "$framework_log" \ + || { echo "framework URL redaction control missing for $host" >&2; exit 1; } +done + +printf '[test] real P2-P8 actions run under fault injection and restore actual surfaces\n' +for phase in P2 P3 P4 P5 P6 P7 P8; do + rm -rf "$HOME_DIR" "$STATE" "$TMP/fault-$phase"; mkdir -p "$HOME_DIR" "$STATE" "$TMP/fault-$phase" + printf 'operator-sentinel\n' > "$HOME_DIR/operator.txt" + reset_state + before="$(tree_fingerprint "$HOME_DIR")" + set +e + HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_INSTALL_STATE_DIR="$TMP/fault-$phase" MOSAIC_INSTALL_FAULT_AFTER="$phase" \ + MOSAIC_INSTALL_SELF_TEST_ALLOW=1 MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" \ + PATH="$FAKE_BIN:/usr/local/bin:/usr/bin:/bin" \ + bash "$ROOT/tools/install.sh" --state-machine-self-test --next --yes \ + >"$TMP/fault-$phase.log" 2>&1 + status=$? + set -e + [[ "$status" -eq 97 ]] || { echo "$phase real fault expected 97, got $status" >&2; exit 1; } + [[ -s "$LOG" ]] || { echo "$phase fault never entered the real action path" >&2; exit 1; } + [[ "$(tree_fingerprint "$HOME_DIR")" == "$before" ]] || { echo "$phase real rollback mismatch" >&2; exit 1; } + grep -q "phase=$phase" "$TMP/fault-$phase.log" + if find "$TMP/fault-$phase" -type f -exec grep -l '"status"[[:space:]]*:[[:space:]]*"in-progress"' {} + 2>/dev/null | grep -q .; then + echo "$phase left an in-progress transaction" >&2; exit 1 + fi +done + +printf '[test] stale projection is preserved while the real fault path acquires a free OS lock\n' +rm -rf "$HOME_DIR" "$STATE" "$TMP/stale-state"; mkdir -p "$HOME_DIR" "$STATE" "$TMP/stale-state" +printf '{"status":"in-progress","journal":"%s"}\n' "$TMP/stale-state/dead-run/journal.ndjson" > "$TMP/stale-state/active.json" +reset_state +set +e +HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_INSTALL_STATE_DIR="$TMP/stale-state" MOSAIC_INSTALL_FAULT_AFTER=P2 \ + MOSAIC_INSTALL_SELF_TEST_ALLOW=1 MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" \ + PATH="$FAKE_BIN:/usr/local/bin:/usr/bin:/bin" \ + bash "$ROOT/tools/install.sh" --state-machine-self-test --next --yes >"$TMP/stale.log" 2>&1 +stale_status=$? +set -e +[[ "$stale_status" -eq 97 ]] +find "$TMP/stale-state" -name prior-active.json -type f -print -quit | grep -q . +[[ "$(node -p "require('$TMP/stale-state/active.json').status")" == rolled-back ]] + +echo "[test] installer next lane tests passed" diff --git a/docs/scratchpads/1050-install-state-machine-red-fixture.md b/docs/scratchpads/1050-install-state-machine-red-fixture.md index 9af9c34c..5fe9d10e 100644 --- a/docs/scratchpads/1050-install-state-machine-red-fixture.md +++ b/docs/scratchpads/1050-install-state-machine-red-fixture.md @@ -69,7 +69,7 @@ Implement C1 from the canonical greenfield-install PRD v2: a transactional P0– - B6 RED: fault injection only wrote `.selftest-*` files. The synthetic path was removed; the P2–P8 matrix enters the normal action flow, proves an action observation occurred, injects after each real phase, and fingerprints rollback. - B7 RED: an HTTP-200 empty body exits zero when piped to Bash. The fetched installer must now be non-empty, digest-equal to `tools/install.sh.sha256`, and that exact file is executed; failed/empty/mismatch controls are blocking and CI has a remote immutable-commit arm. - B8 RED: raw combined command output was duplicated to terminal and `commands.log`. Both capture layers now redact before output/persistence; a seeded canary is positively emitted by the fake credential-capable registry and must remain absent from terminal, command log, npmrc, generated files and observed argv. The real greenfield fixture also scans those populations. -- Advisory code review findings are fixed: URL userinfo redaction now handles raw `@`, repeated `:`, percent encoding and multiple URLs in both capture layers; the real greenfield path positively emits its canary through `state_run_captured`; and verified-fetch removes its temporary body after successful execution. +- Advisory code review findings are fixed: URL userinfo redaction now handles raw `@`, token-only and percent-encoded forms, repeated `:`, multiple URLs, Authorization/Basic, npm `_auth`, and Cookie headers in both capture layers; the real greenfield path positively emits its canary through `state_run_captured`; verified-fetch removes its temporary body after successful execution; and plaintext diagnostics exist only in process-substitution pipes rather than interruptible temporary files. - Advisory security review's independent trust-root finding is **DEFERRED by canonical PRD v2 §3**, which explicitly excludes signed provenance. README now states precisely that the same-origin sidecar detects empty/corrupt/inconsistent publication but cannot authenticate against repository/server compromise; no stronger claim remains. - The web1 no-manifest representativeness observation is recorded but intentionally not acted on: it is explicitly outside these eight blockers. This remediation does not weaken or otherwise change P9's manifest-presence assertion. diff --git a/tools/install-next-lane.test.sh b/tools/install-next-lane.test.sh index ef320d94..b69bdd2a 100755 --- a/tools/install-next-lane.test.sh +++ b/tools/install-next-lane.test.sh @@ -476,8 +476,10 @@ if grep -qF "$canary" <<<"$OUTPUT"; then echo 'credential canary leaked to termi if grep -Eq 'alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789' <<<"$OUTPUT"; then echo 'credentialed URL userinfo leaked to terminal output' >&2; exit 1 fi -[[ "$(grep -oF '[REDACTED]@' <<<"$OUTPUT" | wc -l | tr -d ' ')" -ge 5 ]] \ - || { echo 'credentialed URL redaction controls were not all exercised' >&2; exit 1; } +for host in example.com example.net example.org example.dev example.io; do + grep -qF "https://[REDACTED]@$host" <<<"$OUTPUT" \ + || { echo "credentialed URL redaction control missing for $host" >&2; exit 1; } +done secret_active="$TMP/secret-state/active.json" secret_journal="$(node -p "require('$secret_active').journal")" secret_command_log="$(dirname "$secret_journal")/commands.log" @@ -524,8 +526,10 @@ if grep -Eq "$canary|alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass || grep -Eq "$canary|alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789" "$framework_log"; then echo 'framework nested capture leaked credential diagnostics' >&2; exit 1 fi -[[ "$(grep -oF '[REDACTED]@' "$framework_log" | wc -l | tr -d ' ')" -ge 5 ]] \ - || { echo 'framework URL redaction controls were not exercised' >&2; exit 1; } +for host in example.com example.net example.org example.dev example.io; do + grep -qF "https://[REDACTED]@$host" "$framework_log" \ + || { echo "framework URL redaction control missing for $host" >&2; exit 1; } +done printf '[test] real P2-P8 actions run under fault injection and restore actual surfaces\n' for phase in P2 P3 P4 P5 P6 P7 P8; do -- 2.54.0 From 378bc1afe3bc485adb8614897d66c5edccd4a527 Mon Sep 17 00:00:00 2001 From: be-coder-07 Date: Wed, 5 Aug 2026 19:00:22 -0500 Subject: [PATCH 07/15] fix(installer): close detector false-pass gaps --- .../1050-successor-remediation/README.md | 66 +++++++++++++++++++ .../1050-install-state-machine-red-fixture.md | 1 + .../quality/scripts/test-upgrade-rollback.sh | 46 +++++++------ tools/e2e-install-test.sh | 15 +++-- tools/fixtures/greenfield-expected-red.tsv | 5 +- tools/install-next-lane.test.sh | 26 ++++++++ tools/install-state-machine.test.sh | 28 ++++++++ tools/install.sh | 40 ++++++++--- tools/verify-greenfield-expected-red.sh | 21 +++++- tools/verify-greenfield-expected-red.test.sh | 18 ++++- 10 files changed, 227 insertions(+), 39 deletions(-) create mode 100644 docs/reports/verification/1050-successor-remediation/README.md diff --git a/docs/reports/verification/1050-successor-remediation/README.md b/docs/reports/verification/1050-successor-remediation/README.md new file mode 100644 index 00000000..9afab24c --- /dev/null +++ b/docs/reports/verification/1050-successor-remediation/README.md @@ -0,0 +1,66 @@ +# #1050 successor remediation verification + +Head under test before remediation: `e5d5c8495a070af2dcd393cace287fe74a8a819e`. + +This change strengthens the expected-RED detector; it does not repair the intentionally failing greenfield rows. The #869 hooks remain unwired. + +## A1 — P0 reason binding + +RED first: + +```text +$ bash tools/verify-greenfield-expected-red.test.sh +[test] FAIL: vacuous P0 PASS satisfied the expected-RED contract without identity/context evidence +exit=1 +``` + +The pinned manifest now has an explicit `phase-reason` binding against the final P0 row: target `mosaic`, uid `1001`, equal `HOME` and passwd HOME, `/bin/bash`, `privilege=user`, `x86_64`, glibc, and version-shaped Node/npm evidence. All three cases structurally require exactly one P0 reason binding. The greenfield fixture's final P0 row now emits and validates the same complete identity/context evidence, so an unrelated earlier P0 line cannot satisfy the binding for a vacuous final row. + +Pipeline 2224 and the successor's pre-change fixture run also exposed a stale next-lane P6 reason left behind by the already-closed B6 remediation: actual behavior is a fail-closed runtime-link action refusal with `#869` hooks left inactive and a persisted required P6 failure, while the manifest still expected dead hooks to be active. The pinned reason now matches the stronger measured refusal (`runtime linking/activation action reported a required failure`); no verdict changed and #869 remains unwired. + +GREEN: + +```text +[test] PASS: P0 PASS must bind target identity, HOME, shell, privilege, architecture, and runtime reason +``` + +## A2 — fail-closed P4 enumeration + +RED first: a `find` control emitted only the safe root, omitted an unsafe mode-`0666` child, and exited `73`. The process-substitution consumer discarded that status: + +```text +[test] FAIL: P4 accepted a partial created-path inventory after find failed +[test] FAIL: P4 did not report failed created-path enumeration +exit=1 +``` + +P4 now captures the NUL-delimited walk into a temporary file, checks `find` to completion, and only then evaluates the complete inventory. A failed walk reports that enumeration failed and returns a P4 finding. + +GREEN: + +```text +[test] PASS: P4 rejects an incomplete created-path inventory +[test] PASS: P4 attributes the failed created-path enumeration +``` + +## B — deterministic TERM no-exit control + +Woodpecker pipeline 2224 at the original head reported `32 passed, 2 failed`: the no-exit fixture did not exit zero or report sync success. The premise was not stale: the same fixture passed `34/34` on another filesystem. + +A controlled reverse-sorted `find -print0` walk reproduced the pipeline result exactly (`32 passed, 2 failed`). Root cause: signal injection was tied to `guides/E2E-DELIVERY.md`; whether required `tools/` content remained after restore depended on filesystem enumeration order. The test was measuring path order as well as trap semantics. + +The generated fixtures now damage a real target path after the snapshot is armed, self-signal immediately before the complete normal sync, and differ only in the explicit handler exit. Therefore a no-exit handler always restores, returns, runs the full sync, mutates the restored target again, and reports completion independent of walk order. + +GREEN on both native and reverse-sorted enumeration: + +```text +RESULT: 36 passed, 0 failed +``` + +Mutation sensitivity: restoring `exit 1` to the nominal no-exit fixture makes the control RED (`32 passed, 4 failed`), including failures of the zero-exit and resumed-success assertions. The control can still fail for its stated reason. + +## Enumeration-class sweep + +The sweep covered production enumeration in `tools/install.sh` and `packages/mosaic/framework/install.sh`, plus process-substitution consumers in the C1 shell-test surfaces. Framework installer file, operator, durable-snapshot, and pruning walks already capture and check their producer status. P4's created-path walk was the reviewed unchecked instance. + +One additional order/completeness dependency was found in source acquisition: `find "$WORK_DIR" ... | head -1` hid `find` failure and selected arbitrarily when an archive produced multiple top-level directories. RED first, the extraction fake produced two roots and the lane test stopped at that new assertion with exit 1 because today's code selected one. Source acquisition now captures and checks the complete NUL-delimited walk and requires exactly one extracted root. The lane suite is green with the multiple-root rejection. No remaining production installer enumeration uses unchecked process substitution or first-row order as authority. diff --git a/docs/scratchpads/1050-install-state-machine-red-fixture.md b/docs/scratchpads/1050-install-state-machine-red-fixture.md index 5fe9d10e..67aa65b5 100644 --- a/docs/scratchpads/1050-install-state-machine-red-fixture.md +++ b/docs/scratchpads/1050-install-state-machine-red-fixture.md @@ -51,6 +51,7 @@ Implement C1 from the canonical greenfield-install PRD v2: a transactional P0– - [x] State-machine implementation complete: private pre-mutation journal/snapshot, P0–P8 `--check`, P2–P8 fault seam, rollback, durable manifest/journal seal, action-status persistence, safe rollback roots, and stale-projection recovery. - [x] Debian/glibc checkout fixture now packages the complete current checkout, verifies its digest in-container, and reaches the expected attributable RED without host inheritance. CI compares its exact final phase map/reasons to `tools/fixtures/greenfield-expected-red.tsv`; the fixture remains red while the detector job is green only on an exact match. - [ ] Reviews complete. Reviews 80 (`rev-security-02`) and 81 (`rev-974`) requested changes at `3934e03f`; their eight non-overlapping detector findings are being remediated red-first. Current remediation adds canonical-image portability, absolute P3 CLI propagation, exact expected-RED schema/cardinality, passwd-HOME binding, created-path owner/mode policy, real-action P2–P8 fault injection, verified non-empty remote installer execution, and seeded secret-canary/redacted diagnostics. Both old verdicts become void when the remediation head moves and require fresh independent review. +- [x] Successor remediation for review 90 is RED-first and recorded in `docs/reports/verification/1050-successor-remediation/`: the manifest now binds the complete supported final P0 reason; P4 rejects an incomplete created-path walk instead of discarding `find` failure; and the TERM no-exit control is independent of filesystem enumeration order while retaining a proven RED mutation. Pipeline 2224's 32/2 result was a path-order-sensitive control, not evidence that the resume bug's premise became stale. The enumeration-class sweep additionally replaced order-dependent `find | head -1` source-root selection with a checked complete inventory requiring exactly one extracted root. ## Risks / blockers diff --git a/packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh b/packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh index 1a8ea800..30ce4e4a 100644 --- a/packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh +++ b/packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh @@ -180,15 +180,16 @@ chk "[control] without -E the mid-sync corruption survives (no rollback)" \ # ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ────── # A bash signal trap that merely returns lets the script continue past the -# interrupt — restoring the snapshot, then resuming the sync and reporting -# success. The earlier test used a child cp shim to signal its parent, making -# child completion race Bash's interrupted wait. Concurrency is not part of the -# guarded property: sync_framework_keep() runs in the installer's own Bash -# process, and `kill` is a builtin. Generate two installer fixtures that signal -# themselves at the same known mid-sync point. Their TERM handlers emit the same -# observable before diverging, so missing signal delivery fails BOTH arms rather -# than manufacturing a pass. The only semantic difference between fixtures is -# the explicit `exit 1` whose load-bearing behavior this control proves. +# interrupt — restoring the snapshot, then resuming the install and reporting +# success. Generate two installer fixtures that first damage a real target path +# after the snapshot is armed, then signal their own Bash process immediately +# before the normal sync. This fixed injection point is independent of `find` +# enumeration order: after a no-exit handler restores and returns, the complete +# sync still remains to run, so the historical resume bug is deterministic on +# every filesystem. Their TERM handlers emit the same observable before +# diverging, so missing signal delivery fails BOTH arms rather than manufacturing +# a pass. The only semantic difference between fixtures is the explicit `exit 1` +# whose load-bearing behavior this control proves. TERM_MARKER='[test-control] TERM handler entered' HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER" HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER" @@ -196,19 +197,17 @@ HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # make_signal_installer() { local output="$1" handler="$2" local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM" - local target_cp=' cp "$abs" "$dst/$rel"' - local inject_open=" if [[ \"\$rel\" == \"$POISON_REL\" ]]; then" - local inject_kill=' kill -TERM "$$" # TEST-TERM-INJECTION' - local inject_close=' fi' + local target_sync='sync_framework' + local inject_damage="printf '%s' '$GARBAGE' > \"\$TARGET_DIR/$POISON_REL\" # TEST-TERM-DAMAGE" + local inject_kill='kill -TERM "$$" # TEST-TERM-INJECTION' if ! awk \ - -v target_trap="$target_trap" -v target_cp="$target_cp" \ - -v handler="$handler" -v inject_open="$inject_open" \ - -v inject_kill="$inject_kill" -v inject_close="$inject_close" ' - $0 == target_cp { - print inject_open + -v target_trap="$target_trap" -v target_sync="$target_sync" \ + -v handler="$handler" -v inject_damage="$inject_damage" \ + -v inject_kill="$inject_kill" ' + $0 == target_sync { + print inject_damage print inject_kill - print inject_close injection_sites++ } { print } @@ -231,7 +230,8 @@ make_signal_installer "$SIGNALED" "$HANDLER_WITH_EXIT" make_signal_installer "$NOEXIT" "$HANDLER_WITHOUT_EXIT" signal_fixture_ready() { local fixture="$1" expected_handler="$2" - [[ "$(grep -cF '# TEST-TERM-INJECTION' "$fixture")" -eq 1 ]] \ + [[ "$(grep -cF '# TEST-TERM-DAMAGE' "$fixture")" -eq 1 ]] \ + && [[ "$(grep -cF '# TEST-TERM-INJECTION' "$fixture")" -eq 1 ]] \ && [[ "$(grep -cF '# TEST-TERM-HANDLER' "$fixture")" -eq 1 ]] \ && grep -Fqx "$expected_handler" "$fixture" } @@ -261,10 +261,12 @@ run_signal_upgrade() { IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$SIGNALED") chk "[signal] TERM handler observable fires exactly once" \ "[ \"\$(grep -cF '$TERM_MARKER' '$OUTC')\" -eq 1 ]" -chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \ +chk "[signal] SIGTERM after target mutation aborts non-zero (trap exits, does not resume)" \ "[ '$rcC' -ne 0 ]" chk "[signal] restore_snapshot fires on the interrupt" \ "grep -q 'restoring previous state from snapshot' '$OUTC'" +chk "[signal] the deliberately damaged target is restored before termination" \ + "[ \"\$(cat '$HC/$POISON_REL')\" = '$GOOD' ]" chk "[signal] does NOT resume to report sync success after the interrupt" \ "! grep -q 'file phase complete' '$OUTC'" @@ -277,6 +279,8 @@ chk "[control] without 'exit 1' the installer exits zero after resuming" \ "[ '$rcD' -eq 0 ]" chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \ "grep -q 'file phase complete' '$OUTD'" +chk "[control] the resumed full sync mutates the restored target again" \ + "! grep -qxF '$GOOD' '$HD/$POISON_REL' && cmp -s '$FW/$POISON_REL' '$HD/$POISON_REL'" # ── Part D: a failed source/prune `find` scan must abort + roll back (D1) ───── # A `< <(find …)` process substitution discards find's exit status, so an diff --git a/tools/e2e-install-test.sh b/tools/e2e-install-test.sh index 0938b537..878776cb 100755 --- a/tools/e2e-install-test.sh +++ b/tools/e2e-install-test.sh @@ -182,14 +182,19 @@ fi printf '[fixture] installer_exit=%d done_claims=%s\n' \ "$install_status" "$(grep -cF 'Done.' "$install_log" || true)" -# P0 Resolve context -shell="$(getent passwd "$(id -u)" | cut -d: -f7)" -if [[ "$(id -u)" -ne 0 && "$home" == "/home/mosaic" && "$shell" == "/bin/bash" ]] \ +# P0 Resolve context. Keep this final fixture row as discriminating as the +# installer's own P0 row: the expected-RED comparator binds this exact reason. +passwd_row="$(getent passwd "$(id -u)")" +target_user="$(printf '%s' "$passwd_row" | cut -d: -f1)" +passwd_home="$(printf '%s' "$passwd_row" | cut -d: -f6)" +shell="$(printf '%s' "$passwd_row" | cut -d: -f7)" +if [[ "$(id -u)" -eq 1001 && "$target_user" == "mosaic" \ + && "$home" == "/home/mosaic" && "$home" == "$passwd_home" && "$shell" == "/bin/bash" ]] \ && ldd --version 2>&1 | grep -i 'glibc\|gnu libc' >/dev/null \ && [[ "$(node -p 'Number(process.versions.node.split(".")[0])')" -ge 20 ]]; then - phase_pass P0 "target=mosaic uid=$(id -u) HOME=$home shell=$shell libc=glibc node=$(node --version)" + phase_pass P0 "target=$target_user uid=$(id -u) HOME=$home passwd_HOME=$passwd_home shell=$shell privilege=user arch=$(uname -m) libc=glibc node=$(node --version) npm=$(npm --version)" else - phase_fail P0 "context unresolved or unsupported (uid=$(id -u) HOME=$home shell=${shell:-unknown})" + phase_fail P0 "context unresolved or unsupported (target=${target_user:-unknown} uid=$(id -u) HOME=$home passwd_HOME=${passwd_home:-unknown} shell=${shell:-unknown} privilege=user)" fi # P1 Preflight diff --git a/tools/fixtures/greenfield-expected-red.tsv b/tools/fixtures/greenfield-expected-red.tsv index 445b31db..de9353ba 100644 --- a/tools/fixtures/greenfield-expected-red.tsv +++ b/tools/fixtures/greenfield-expected-red.tsv @@ -13,10 +13,11 @@ next-git-present phase P8=FAIL next-git-present phase P9=FAIL next-git-present require ^\[fixture\] resolved lane=next .*version=[0-9]+\.[0-9]+\.[0-9]+-next\. next-git-present require ^\[fixture\] installer_exit=1 done_claims=0$ +next-git-present phase-reason P0=target=mosaic uid=1001 HOME=/home/mosaic passwd_HOME=/home/mosaic shell=/bin/bash privilege=user arch=x86_64 libc=glibc node=v[0-9]+\.[0-9]+\.[0-9]+ npm=[0-9]+\.[0-9]+\.[0-9]+ next-git-present require ^\[SECRET-CONTROL\] PASS: next-git-present require ^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version$ next-git-present require ^\[P4\] FAIL: NOT-MEASURED / UNDECLARED: -next-git-present require ^\[P6\] FAIL: broker absent but dead enforcement hooks are active +next-git-present require ^\[P6\] FAIL: runtime linking/activation action reported a required failure$ next-git-present forbid Done\.|MOSAIC_C1_CANARY_|CLI not found on PATH main-git-present exit 1 main-git-present phase P0=PASS @@ -31,6 +32,7 @@ main-git-present phase P8=FAIL main-git-present phase P9=FAIL main-git-present require ^\[fixture\] resolved lane=main .*version=[0-9]+\.[0-9]+\.[0-9]+$ main-git-present require ^\[fixture\] installer_exit=1 done_claims=0$ +main-git-present phase-reason P0=target=mosaic uid=1001 HOME=/home/mosaic passwd_HOME=/home/mosaic shell=/bin/bash privilege=user arch=x86_64 libc=glibc node=v[0-9]+\.[0-9]+\.[0-9]+ npm=[0-9]+\.[0-9]+\.[0-9]+ main-git-present require ^\[SECRET-CONTROL\] PASS: main-git-present require ^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version$ main-git-present require ^\[P4\] FAIL: NOT-MEASURED / UNDECLARED: @@ -48,6 +50,7 @@ next-git-absent phase P7=PASS next-git-absent phase P8=FAIL next-git-absent phase P9=FAIL next-git-absent require ^\[fixture\] installer_exit=1 done_claims=0$ +next-git-absent phase-reason P0=target=mosaic uid=1001 HOME=/home/mosaic passwd_HOME=/home/mosaic shell=/bin/bash privilege=user arch=x86_64 libc=glibc node=v[0-9]+\.[0-9]+\.[0-9]+ npm=[0-9]+\.[0-9]+\.[0-9]+ next-git-absent require ^\[SECRET-CONTROL\] PASS: next-git-absent require ^\[P1\] FAIL: undeclared/missing prerequisite\(s\)=git; next-git-absent require ^\[P3\] FAIL: .*executable=no diff --git a/tools/install-next-lane.test.sh b/tools/install-next-lane.test.sh index b69bdd2a..fed59b1c 100755 --- a/tools/install-next-lane.test.sh +++ b/tools/install-next-lane.test.sh @@ -201,6 +201,9 @@ if [[ -z "$dest" ]]; then exit 1 fi mkdir -p "$dest/stack/packages/mosaic/framework" "$dest/stack/apps/gateway" +if [[ "${MOSAIC_TEST_EXTRA_ARCHIVE_ROOT:-0}" == "1" ]]; then + mkdir -p "$dest/unexpected-second-root" +fi cat > "$dest/stack/packages/mosaic/framework/install.sh" <<'FRAMEWORK' #!/usr/bin/env bash set -euo pipefail @@ -384,6 +387,29 @@ set -e grep -qF 'archive safety/integrity check failed' <<<"$OUTPUT" [[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]] +reset_state +echo "[test] source archive with multiple extracted roots fails instead of selecting by find order" +before_prefix="$(prefix_fingerprint)" +set +e +OUTPUT="$( + HOME="$HOME_DIR" \ + MOSAIC_HOME="$MOSAIC_HOME" \ + MOSAIC_PREFIX="$PREFIX" \ + MOSAIC_NO_COLOR=1 \ + MOSAIC_TEST_NPM_LOG="$LOG" \ + MOSAIC_TEST_STATE="$STATE" \ + MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \ + MOSAIC_TEST_EXTRA_ARCHIVE_ROOT=1 \ + PATH="$FAKE_BIN:$PATH" \ + bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1 +)" +FAIL_STATUS=$? +set -e +[[ "$FAIL_STATUS" -ne 0 ]] +[[ "$(prefix_fingerprint)" == "$before_prefix" ]] +grep -qF 'expected exactly one extracted source root' <<<"$OUTPUT" +[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]] + reset_state echo "[test] --dev source install does not require registry version resolution" OUTPUT="$( diff --git a/tools/install-state-machine.test.sh b/tools/install-state-machine.test.sh index 1fb31b16..65645c03 100755 --- a/tools/install-state-machine.test.sh +++ b/tools/install-state-machine.test.sh @@ -304,6 +304,34 @@ grep -q '^\[P4\] FAIL:.*owner/mode policy' "$TMP/p4-tree-mode.log" \ || fail_case 'P4 did not attribute unsafe created-path mode' chmod 0644 "$good_mosaic/AGENTS.md" +printf '[test] case: P4 fails closed when created-path enumeration is incomplete\n' +real_find="$(command -v find)" +cat > "$good_bin/find" <