#!/usr/bin/env bash # Case 2 row C2-13a (design 10 step 8): the host checks a hub needs before # its units start. It runs as , after root's 2c step, and changes # nothing. It runs every check, prints one line for each, and refuses if # any one failed. # # case2-precheck.sh --business ID [--profile factory|library] # # Checks: the config and business file validate; `sudo -n true` fails and # `id -G` prints one group; bus.tester is off for library and on for factory, # and factory refuses a business file with a library project # (profile-library-business); the run root is 0700; XDG_DATA_HOME is unset or # /.local/share; node is 24 or later; bwrap runs; the unprivileged port # floor is above the installed Console unit's --port; the host's claude # replaces its default prompt with --system-prompt-file, when some instance # runs claude-code; and claude-session.env sets ZAI_API_KEY, when some # instance's provider is zai. It prints free memory and checks that HOME is # . It never prints a value from claude-session.env. # # Test seams, for packages/cli/tests/case2-unit.test.mjs only: # CASE2_UNIT_DIR the installed units, default /etc/systemd/system # CASE2_PROC_SYS default /proc/sys # CASE2_GETENT the getent command, default getent # # Exit 0 every check passed, 1 refused, 4 usage. set -uo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" UNIT_DIR="${CASE2_UNIT_DIR:-/etc/systemd/system}" PROC_SYS="${CASE2_PROC_SYS:-/proc/sys}" GETENT="${CASE2_GETENT:-getent}" BUS_UNIT="$UNIT_DIR/mosaic-bus@.service" WEBUI_UNIT="$UNIT_DIR/mosaic-webui.service" # The most input tokens a marker run may report with its prompt replaced. # Claude Code's own prompt is many times this. MARKER_TOKEN_CEILING=2000 usage() { echo 'usage: case2-precheck.sh --business ID [--profile factory|library]' >&2; exit 4; } PROFILE=library BUSINESS='' while [ $# -gt 0 ]; do case "$1" in --profile) [ $# -ge 2 ] || usage; PROFILE="$2"; shift 2 ;; --business) [ $# -ge 2 ] || usage; BUSINESS="$2"; shift 2 ;; *) usage ;; esac done [ -n "$BUSINESS" ] || usage case "$PROFILE" in factory|library) ;; *) echo "case2-precheck: --profile must be factory or library: $PROFILE" >&2; exit 4 ;; esac [[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]] || { echo "case2-precheck: --business must be a business id: $BUSINESS" >&2; exit 4; } FAILED=0 ok() { echo "ok $1"; } refuse() { echo "REFUSED $1"; FAILED=1; } # One setting's value from an installed unit: the last KEY= line in the file. unit_value() { [ -f "$1" ] && sed -n "s/^[[:space:]]*$2[[:space:]]*=[[:space:]]*//p" -- "$1" | tail -n 1; } # The last assignment of NAME in an EnvironmentFile, quotes removed, as # systemd reads it. Prints the value; the callers never echo it. env_value() { awk -v name="$2" ' { sub(/^[ \t]+/, "") } /^[#;]/ || $0 == "" { next } { i = index($0, "="); if (i == 0) next k = substr($0, 1, i - 1); sub(/[ \t]+$/, "", k) if (k != name) next v = substr($0, i + 1); sub(/^[ \t]+/, "", v); sub(/[ \t]+$/, "", v) if (v ~ /^".*"$/ || v ~ /^\x27.*\x27$/) v = substr(v, 2, length(v) - 2) last = v; found = 1 } END { if (found) printf "%s", last }' "$1" } # The user and its home from the passwd database. ME="$(id -un)" ME_UID="$(id -u)" PW="$("$GETENT" passwd "$ME")" || { refuse "user: no $ME in the passwd database"; PW=''; } IFS=: read -r _ _ _ _ _ PW_HOME _ <<<"$PW" if [ -n "${PW_HOME:-}" ] && [ "${HOME:-}" = "$PW_HOME" ]; then ok "HOME is $PW_HOME" else refuse "HOME is ${HOME:-unset}, not $ME's home ${PW_HOME:-unknown}"; fi HOME_DIR="${PW_HOME:-$HOME}" # The config, then the business file through the same loaders the bus uses. # The helper prints tab-separated facts and never a credential. facts="$(cd "$REPO" && node --input-type=module -e ' import { readFileSync } from "node:fs"; const [repo, id] = process.argv.slice(1); const { loadSystem, rolesDir } = await import(`${repo}/packages/cli/src/config.mjs`); const { businessFilePath, configDir, loadBusiness, resolveInstance, systemVars } = await import(`${repo}/packages/business/src/index.mjs`); const say = (...f) => console.log(f.join("\t")); let system; try { system = loadSystem(); } catch (e) { say("CONFIG", "bad", e.message.split("\n")[0]); process.exit(0); } say("CONFIG", "ok", system.dataRoot, configDir()); const file = businessFilePath(id); let raw; try { raw = JSON.parse(readFileSync(file, "utf8")); } catch (e) { say("BUSINESS", "bad", `cannot read ${file}: ${e.code ?? e.message}`); process.exit(0); } const library = Object.entries(raw?.projects ?? {}).filter(([, p]) => p && p.kind === "library").map(([k]) => k); if (library.length) say("LIBRARY", library.join(",")); let b; try { b = loadBusiness(id, { rolesDir: rolesDir() }); } catch (e) { say("BUSINESS", "bad", e.message.split("\n")[0]); process.exit(0); } say("BUSINESS", "ok", file); say("TESTER", b.vars["bus.tester"] === true ? "on" : "off"); const vars = systemVars(system); const models = b.launch?.models ?? null; for (const instance of Object.keys(b.roles)) { const v = resolveInstance({ system: vars, business: b, instance }).vars; for (const [h, m] of [["harness", "model"], ["fallback.harness", "fallback.model"]]) { if (typeof v[h] !== "string") continue; const provider = models && Object.hasOwn(models, v[m]) ? models[v[m]].provider : ""; say("ROUTE", instance, v[h], provider); } } ' "$REPO" "$BUSINESS" 2>&1)" || true line() { printf '%s\n' "$facts" | awk -F'\t' -v k="$1" '$1 == k' ; } cfg="$(line CONFIG)" DATA_ROOT='' CONFIG_DIR='' case "$cfg" in "CONFIG ok "*) IFS=$'\t' read -r _ _ DATA_ROOT CONFIG_DIR <<<"$cfg"; ok "config validates (data root $DATA_ROOT)" ;; "CONFIG bad "*) refuse "config: ${cfg#CONFIG bad }" ;; *) refuse "config: the check did not run: $(printf '%s' "$facts" | head -n 1)" ;; esac # Tester and profile. library="$(line LIBRARY | cut -f2)" biz="$(line BUSINESS)" if [ "$PROFILE" = factory ] && [ -n "$library" ]; then refuse "profile-library-business: the factory profile refuses business $BUSINESS, whose projects $library are library projects" elif [ -n "$cfg" ] && [ "${cfg#CONFIG ok}" != "$cfg" ]; then case "$biz" in "BUSINESS ok "*) ok "business file ${biz#BUSINESS ok } validates" tester="$(line TESTER | cut -f2)" if [ "$PROFILE" = library ] && [ "$tester" = on ]; then refuse "bus.tester is on; the library profile needs it off" elif [ "$PROFILE" = factory ] && [ "$tester" != on ]; then refuse "bus.tester is off; the factory profile needs it on" else ok "bus.tester is $tester"; fi ;; *) refuse "business file: ${biz#BUSINESS bad }" ;; esac fi # No sudo, and one group. if sudo -n true >/dev/null 2>&1; then refuse "sudo -n true succeeds as $ME"; else ok "sudo -n true fails as $ME"; fi groups="$(id -G)" if [ "$(printf '%s\n' "$groups" | wc -w)" -eq 1 ]; then ok "id -G prints one group ($groups)" else refuse "id -G prints more than one group: $groups"; fi # The run root (packages/seat/src/session.mjs runsDir). newRun makes it # 0700, so before the first launch it may not exist yet. if [ -n "$DATA_ROOT" ]; then runs="$DATA_ROOT/launches/$BUSINESS" if [ -L "$runs" ]; then refuse "run root $runs is a symlink" elif [ ! -e "$runs" ]; then ok "run root $runs does not exist yet; the first launch makes it 0700" elif [ "$(stat -c '%a' -- "$runs")" = 700 ] && [ -d "$runs" ]; then ok "run root $runs is 0700" else refuse "run root $runs is not a 0700 directory (mode $(stat -c '%a' -- "$runs"))"; fi fi # XDG_DATA_HOME, which copy_root reads (scripts/stack-copy.sh:8). if [ -z "${XDG_DATA_HOME+x}" ] || [ "$XDG_DATA_HOME" = "$HOME_DIR/.local/share" ]; then ok "XDG_DATA_HOME is ${XDG_DATA_HOME:-unset}" else refuse "XDG_DATA_HOME is $XDG_DATA_HOME; the unit runs the copy under $HOME_DIR/.local/share"; fi # node 24 or later, every package's engines field. nv="$(node --version 2>/dev/null)" || nv='' major="${nv#v}"; major="${major%%.*}" if [[ "$major" =~ ^[0-9]+$ ]] && [ "$major" -ge 24 ]; then ok "node $nv" else refuse "node ${nv:-not found} is older than 24"; fi # bwrap, as a session needs it. if bwrap --unshare-all --ro-bind / / true >/dev/null 2>&1; then ok "bwrap --unshare-all runs" else userns="$(cat "$PROC_SYS/kernel/apparmor_restrict_unprivileged_userns" 2>/dev/null || echo absent)" where="$(command -v bwrap || echo 'not on PATH')" refuse "bwrap --unshare-all --ro-bind / / true failed; bwrap: $where; kernel.apparmor_restrict_unprivileged_userns: $userns" fi # The port floor, against the installed Console unit's --port (design 9.3). floor="$(cat "$PROC_SYS/net/ipv4/ip_unprivileged_port_start" 2>/dev/null)" || floor='' port="$(unit_value "$WEBUI_UNIT" ExecStart | awk '{ for (i = 1; i < NF; i++) if ($i == "--port") p = $(i + 1) } END { print p }')" if [ ! -f "$WEBUI_UNIT" ]; then refuse "no installed Console unit $WEBUI_UNIT" elif ! [[ "$port" =~ ^[0-9]+$ ]]; then refuse "$WEBUI_UNIT has no --port in its ExecStart" elif ! [[ "$floor" =~ ^[0-9]+$ ]]; then refuse "cannot read net.ipv4.ip_unprivileged_port_start" elif [ "$floor" -le "$port" ]; then refuse "net.ipv4.ip_unprivileged_port_start is $floor, at or below the Console's port $port, so any $ME process can bind it" else ok "net.ipv4.ip_unprivileged_port_start $floor is above the Console's port $port"; fi routes="$(line ROUTE)" SESSION_ENV="${CONFIG_DIR:-$HOME_DIR/.config/mosaic-dev}/secrets/claude-session.env" # The session env file, checked as the bus unit's EnvironmentFile. env_file_ok() { [ ! -L "$SESSION_ENV" ] && [ -f "$SESSION_ENV" ] && [ "$(stat -c '%u' -- "$SESSION_ENV")" = "$ME_UID" ] && [ "$(stat -c '%a' -- "$SESSION_ENV")" = 600 ] } # Claude Code replaces its prompt with --system-prompt-file. if printf '%s\n' "$routes" | awk -F'\t' '$3 == "claude-code" { f = 1 } END { exit !f }'; then claude="$(unit_value "$BUS_UNIT" Environment | sed -n 's/^MOSAIC_CLAUDE_BIN=//p')" claude="${claude:-$(command -v claude || true)}" if [ -z "$claude" ] || [ ! -x "$claude" ]; then refuse "an instance runs claude-code and no claude is installed" else echo " claude $claude: $("$claude" --version 2>/dev/null | head -n 1)" work="$(mktemp -d)" marker="case2-marker-$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')" printf 'Reply with exactly this text and nothing else: %s\n' "$marker" >"$work/prompt.md" out="$(cd "$work" && ( if env_file_ok; then while IFS= read -r l; do case "$l" in [A-Za-z_]*=*) k="${l%%=*}"; [[ "$k" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] && export "$k=$(env_value "$SESSION_ENV" "$k")" ;; esac done <"$SESSION_ENV" fi timeout 180 "$claude" -p --system-prompt-file "$work/prompt.md" --tools "" --setting-sources "" \ --no-session-persistence --output-format json "Reply now." 2>/dev/null ))" || out='' rm -rf -- "$work" verdict="$(printf '%s' "$out" | node -e ' let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => { const [marker, ceiling] = process.argv.slice(1); let r; try { r = JSON.parse(s); } catch { console.log("noreply"); return; } const u = r.usage ?? {}; const n = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0); if (!String(r.result ?? "").includes(marker)) console.log(`nomarker ${n}`); else console.log(`${n <= Number(ceiling) ? "replaced" : "appended"} ${n}`); });' "$marker" "$MARKER_TOKEN_CEILING")" case "$verdict" in replaced\ *) ok "claude replaces its prompt with --system-prompt-file (${verdict#replaced } input tokens)" ;; appended\ *) refuse "claude adds to its own prompt with --system-prompt-file: ${verdict#appended } input tokens, more than $MARKER_TOKEN_CEILING" ;; nomarker\ *) refuse "claude's reply to the marker prompt lacks the marker (${verdict#nomarker } input tokens)" ;; *) refuse "claude gave no JSON reply to the marker prompt" ;; esac fi fi # ZAI_API_KEY, when some instance's provider is zai (design 10 step 2d). if printf '%s\n' "$routes" | awk -F'\t' '$4 == "zai" { f = 1 } END { exit !f }'; then if [ -L "$SESSION_ENV" ]; then refuse "$SESSION_ENV is a symlink" elif [ ! -f "$SESSION_ENV" ]; then refuse "an instance's provider is zai and $SESSION_ENV is missing" elif ! env_file_ok; then refuse "$SESSION_ENV must be owned by $ME at 0600 (mode $(stat -c '%a' -- "$SESSION_ENV"))" elif [ -z "$(env_value "$SESSION_ENV" ZAI_API_KEY)" ]; then refuse "$SESSION_ENV sets no nonempty ZAI_API_KEY" else ok "$SESSION_ENV sets ZAI_API_KEY"; fi fi echo " free memory: $(awk '$1 == "MemAvailable:" { printf "%d MiB", $2 / 1024 }' /proc/meminfo)" if [ "$FAILED" = 0 ]; then echo "case2-precheck: every check passed"; exit 0; fi echo "case2-precheck: refused" exit 1