#!/usr/bin/env bash # mosaic — fleet launcher (shipped-first, split-home safe). # # T110 / P5-RM-009 stack side. Carries the T106 brain launcher contract # (shipped-first, worktree dev opt-in, OFF pass-through, typed failure) with # one split-home correction: the SHIPPED npm mosaic is resolved from the real # user's home (passwd database), never from $HOME. Under split-home seat # layouts HOME is a seat home: it carries no npm prefix, and a # $HOME/.npm-global there would be a plantable descriptor, so the $HOME # candidate is consulted only when the passwd lookup itself fails, and then # only with a full symlink-component refusal (secure descriptor traversal). # # Contract: # 1. SHIPPED npm mosaic is the default. Candidate order: # a. /.npm-global/bin/mosaic — real home from the passwd # database. The final component may be npm's own bin symlink into # lib/node_modules; that indirection is npm's layout, not a plant. # b. $HOME/.npm-global/bin/mosaic — ONLY when the passwd lookup # fails, and then only when the candidate is a trusted-shape # absolute path: relative HOME and parent-escape (..) components # are refused outright, and every remaining component must be a # non-symlink (secure descriptor traversal). Refused candidates # are never executed. # 2. Worktree build is DEV OPT-IN: used only when MOSAIC_CLI_WORKTREE is # explicitly set. Health-checked via --version; ANY doubt (absent, # unreadable, or failing) falls back to the shipped npm mosaic with a # warning on stderr. With no environment set, worktree candidates are # never consulted — stale worktree builds cannot regain precedence. # 3. MOSAIC_FLEET_CLI_OFF keeps its pass-through semantics: set (any # value) forces pure pass-through. The dev path is not consulted even # when MOSAIC_CLI_WORKTREE is also set. # 4. Typed failure: with no runnable candidate the launcher prints one # stderr line naming what was checked and exits 127. # 5. NEVER writes to the mosaic home or the npm prefix. Deployment to the # fleet goes through the real channel (PR to next -> mosaic update). # # Env: # MOSAIC_CLI_WORKTREE dev opt-in: path to a stack worktree whose # packages/mosaic/dist/cli.js is used (health-checked, # shipped fallback on doubt) # MOSAIC_FLEET_CLI_OFF set (any value) to force pure pass-through # # Component walk note: the descriptor guard splits on "/" without quoting so # multi-byte HOME paths with spaces are not supported for the FALLBACK # candidate; the passwd candidate needs no walk (trusted derivation). set -u fail() { echo "mosaic: $*" >&2 exit 127 } # Real user home from the passwd database (HOME-independent). real_home() { getent passwd "$(id -u)" 2>/dev/null | cut -d: -f6 } # True when any component of an ABSOLUTE candidate path is a symlink. Only # ever called after fallback_candidate_usable's absolute-shape check. path_has_symlink_component() { local path="$1" dir base acc="" part dir="$(dirname -- "$path")" base="$(basename -- "$path")" local IFS='/' for part in $dir; do acc="$acc/$part" [ -L "$acc" ] && return 0 done [ -L "$dir/$base" ] && return 0 return 1 } # Reject the untrusted $HOME fallback candidate unless it is a trusted-shape # absolute path: absolute, no parent-escape (..) components, and no symlink # components anywhere on the path. Every rejection is named on stderr so the # typed failure explains itself. This is the launcher's descriptor guard; the # suite's mutation control (guard bypassed) must plant-exec, proving the guard # is what stands between a hostile HOME and code execution. fallback_candidate_usable() { local candidate="$1" case "$candidate" in /*) ;; *) echo "mosaic: refusing \$HOME candidate $candidate: relative path is untrusted without a passwd home" >&2 return 1 ;; esac if printf '%s' "$candidate" | grep -qE '(^|/)\.\.(/|$)'; then echo "mosaic: refusing \$HOME candidate $candidate: parent-escape component" >&2 return 1 fi if path_has_symlink_component "$candidate"; then echo "mosaic: refusing \$HOME candidate $candidate: symlink component (untrusted without a passwd home)" >&2 return 1 fi return 0 } # Print shipped candidates in contract order. Refusals are reported on stderr # so the typed failure names the cause. shipped_candidates() { local rh home_candidate rh="$(real_home)" if [ -n "$rh" ]; then printf '%s\n' "$rh/.npm-global/bin/mosaic" return 0 fi # passwd lookup failed: the only fallback is $HOME, descriptor-guarded. if [ -n "${HOME:-}" ]; then home_candidate="$HOME/.npm-global/bin/mosaic" if fallback_candidate_usable "$home_candidate"; then printf '%s\n' "$home_candidate" fi fi return 0 } resolve_shipped() { local candidate while IFS= read -r candidate; do [ -n "$candidate" ] || continue if [ -x "$candidate" ]; then printf '%s\n' "$candidate" return 0 fi done < <(shipped_candidates) return 1 } # Dev opt-in only: an explicit MOSAIC_CLI_WORKTREE reaches the worktree build, # and pure pass-through (MOSAIC_FLEET_CLI_OFF) outranks it. if [ -n "${MOSAIC_CLI_WORKTREE:-}" ] && [ -z "${MOSAIC_FLEET_CLI_OFF:-}" ]; then CLI="$MOSAIC_CLI_WORKTREE/packages/mosaic/dist/cli.js" if [ -r "$CLI" ]; then if v="$(node "$CLI" --version 2>/dev/null)" && [ -n "$v" ]; then exec node "$CLI" "$@" fi echo "mosaic: worktree build at $CLI failed its health check; using shipped npm mosaic" >&2 else echo "mosaic: worktree build at $CLI absent or unreadable; using shipped npm mosaic" >&2 fi fi SHIPPED="$(resolve_shipped)" || true if [ -n "${SHIPPED:-}" ]; then exec "$SHIPPED" "$@" fi fail "no runnable CLI (shipped npm mosaic absent from the passwd-home npm prefix and \$HOME; worktree build requires MOSAIC_CLI_WORKTREE)"