#!/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] # case2-precheck.sh --business ID --library-status # # --org ID is the same option as --business ID. # # 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, # the explicit notifier config is valid at 0600 in a private 0700 directory; # 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. # # C2-13b adds to the library profile: the business file is 's with no # group or other bit; businesses/.history// is 0700 and its changes.jsonl # 0600, when present; the persona dirs are 0700 and each persona file is # 's at 0600 with the sha256 its persona.sha256 names; and no Discord # binding exists under /discord. These read no mirror and sync # nothing, since the bus makes the mirror when it starts. # # --library-status (design 10 step 4a) runs instead, once the bus is up and # the token is placed. It reads the bus's recorded sync state for each library # project through libraryStatus(), the call the bus's library.status makes, # and syncs nothing: sync() fetches outside the library lock, so a second # process would race the bus's timer. It refuses a missing mirror, a recorded # lastError and a null lastFetch, in that order, and prints each R-problem as a # `problem` line with its path as a JSON string, then a count. It prints no # text from her repo. # # 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] | --business ID --library-status' >&2; exit 4; } # shellcheck source=scripts/selector.sh . "$REPO/scripts/selector.sh" || { echo "case2-precheck: cannot read $REPO/scripts/selector.sh" >&2; exit 4; } # --org and --business are one selector here (rename design r2). A missing value is a usage error, # a flag-shaped or invalid one the business-id refusal below, and a repeated or conflicting one # says why; all exit 4, as before. selector_refused() { case "$1" in missing) usage ;; flag|invalid) echo "case2-precheck: --business must be a business id: $BUSINESS" >&2; exit 4 ;; *) echo "case2-precheck: $2" >&2; exit 4 ;; esac } business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]; } PROFILE=library PROFILE_SET=0 BUSINESS='' STATUS=0 while [ $# -gt 0 ]; do case "$1" in --profile) [ $# -ge 2 ] || usage; PROFILE="$2"; PROFILE_SET=1; shift 2 ;; --business|--org) BUSINESS="${2-}"; selector_take args id selector_refused business_id_ok "$1" "${@:2:1}"; shift 2 ;; --library-status) STATUS=1; shift ;; *) usage ;; esac done [ -n "$BUSINESS" ] || usage [ "$STATUS" = 0 ] || [ "$PROFILE_SET" = 0 ] || 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; } mode_of() { stat -c '%a' -- "$1"; } # Regular, not a link, owned by this uid, with no group or other bit. private_file() { [ ! -L "$1" ] && [ -f "$1" ] && [ "$(stat -c '%u' -- "$1")" = "$(id -u)" ] && [ $((8#$(mode_of "$1") & 8#077)) -eq 0 ]; } # A directory, not a link, owned by this uid, at 0700. private_dir() { [ ! -L "$1" ] && [ -d "$1" ] && [ "$(stat -c '%u' -- "$1")" = "$(id -u)" ] && [ "$(mode_of "$1")" = 700 ]; } # --library-status: the bus's recorded sync state, and nothing else. if [ "$STATUS" = 1 ]; then status="$(cd "$REPO" && node --input-type=module -e ' import { lstatSync } from "node:fs"; const [repo, id] = process.argv.slice(1); const { loadSystem, rolesDir } = await import(`${repo}/packages/cli/src/config.mjs`); const { loadBusiness } = await import(`${repo}/packages/organization/src/index.mjs`); const { libraryStatus, mirrorPath } = await import(`${repo}/packages/library/src/index.mjs`); const say = (...f) => console.log(f.join("\t")); // A library error code is a fixed [a-z-] string; anything else prints as one name. const code = (e) => typeof e?.code === "string" && /^[a-z-]{1,64}$/.test(e.code) ? e.code : "library-status-failed"; // JSON.stringify leaves DEL, C1 controls, the line and paragraph separators and the bidi controls // raw. They print as \uXXXX too, so a path from her repo cannot move the cursor or reorder a line. const RAW = /[\u007f-\u009f\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g; const quote = (s) => JSON.stringify(s).replace(RAW, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`); let system, b; try { system = loadSystem(); } catch (e) { say("BAD", `config: ${e.message.split("\n")[0]}`); process.exit(0); } try { b = loadBusiness(id, { rolesDir: rolesDir() }); } catch (e) { say("BAD", `business file: ${e.message.split("\n")[0]}`); process.exit(0); } const projects = Object.keys(b.projects).filter((p) => b.projects[p].kind === "library"); if (!projects.length) say("BAD", `business ${id} has no library project`); for (const project of projects) { const context = { business: b, project, dataRoot: system.dataRoot }; let mirror, s; try { mirror = mirrorPath(context); } catch (e) { say("ERROR", project, code(e)); continue; } let there; try { there = lstatSync(mirror, { throwIfNoEntry: false }); } catch (e) { say("ERROR", project, "library-git"); continue; } if (!there) { say("MISSING", project, mirror); continue; } try { s = await libraryStatus(context); } catch (e) { say("ERROR", project, code(e)); continue; } // lastError first: a first sync with no token records an error and leaves lastFetch null. if (s.lastError !== null) { say("LASTERROR", project, s.lastError.code, s.lastError.at); continue; } if (s.lastFetch === null) { say("NEVER", project); continue; } say("OK", project, s.lastFetch, s.served ?? "none"); if (s.diverged !== null) say("DIVERGED", project, s.diverged.commit); for (const p of s.problems) say("PROBLEM", p.code, quote(p.path)); say("PROBLEMS", project, s.problems.length); } ' "$REPO" "$BUSINESS" 2>&1)" || true [ -n "$status" ] || refuse "library status: the check printed nothing" while IFS=$'\t' read -r kind a b c; do case "$kind" in BAD) refuse "$a" ;; ERROR) refuse "library-status: library $a: $b" ;; MISSING) refuse "library-mirror-missing: library $a has no mirror at $b; the bus makes it when it starts" ;; LASTERROR) refuse "library-last-error: library $a: $b at $c" ;; NEVER) refuse "library-never-synced: library $a has no fetch yet; wait for the bus's next sync, 60 s, or run library.sync" ;; OK) ok "library $a last fetched $b, serving $c" ;; DIVERGED) echo " library $a: a diverged tip $b waits for library.sync --accept" ;; PROBLEM) echo "problem $a $b" ;; PROBLEMS) echo "problems $b" ;; *) refuse "library status: $kind${a:+ $a}" ;; esac done <<<"$status" if [ "$FAILED" = 0 ]; then echo "case2-precheck: library status passed"; exit 0; fi echo "case2-precheck: refused" exit 1 fi # 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/organization/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()); try { const { readNotifyConfig } = await import(`${repo}/packages/cli/src/cli.mjs`); readNotifyConfig(system.dataRoot, id); say("NOTIFY", "ok"); } catch (e) { say("NOTIFY", "bad", e.message.split("\n")[0]); } const { parseJsonFileText } = await import(`${repo}/packages/organization/src/util.mjs`); const file = businessFilePath(id); let raw; try { raw = parseJsonFileText(readFileSync(file, "utf8"), file); } catch (e) { say("BUSINESS", "bad", `cannot read ${file}: ${e.code ?? "not valid JSON"}`); 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"); for (const [instance, role] of Object.entries(raw.roles ?? {})) if (typeof role?.vars?.["persona.sha256"] === "string") say("PERSONA", instance, role.vars["persona.sha256"]); 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 # Use the CLI's exact file/owner/mode/JSON rule; its journal also needs a private directory. if [ -n "$DATA_ROOT" ]; then notify_dir="$DATA_ROOT/notify/$BUSINESS" notify="$(line NOTIFY)" case "$notify" in "NOTIFY ok") if private_dir "$notify_dir"; then ok "notifier config $notify_dir/notify.json validates (directory 0700, file 0600)" else refuse "notifier config: $notify_dir must be $ME's 0700 directory"; fi ;; "NOTIFY bad "*) refuse "notifier config: ${notify#NOTIFY bad }" ;; *) refuse "notifier config: the check did not run" ;; esac fi # 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 # C2-13b: the library files' modes, the persona digests and no Discord # binding (design 4). Names and digests print, never persona text. if [ "$PROFILE" = library ] && [ -n "$CONFIG_DIR" ]; then bfile="$CONFIG_DIR/businesses/$BUSINESS.json" if [ ! -e "$bfile" ] && [ ! -L "$bfile" ]; then : elif private_file "$bfile"; then ok "business file $bfile is $ME's with no group or other bit" else refuse "business-file-mode: $bfile must be $ME's regular file with no group or other bit (mode $(mode_of "$bfile"))"; fi hdir="$CONFIG_DIR/businesses/.history/$BUSINESS" if [ ! -e "$hdir" ] && [ ! -L "$hdir" ]; then ok "no history for $BUSINESS yet" elif ! private_dir "$hdir"; then refuse "history-mode: $hdir must be $ME's 0700 directory (mode $(mode_of "$hdir"))" elif [ ! -e "$hdir/changes.jsonl" ] && [ ! -L "$hdir/changes.jsonl" ]; then ok "history $hdir is 0700" elif private_file "$hdir/changes.jsonl" && [ "$(mode_of "$hdir/changes.jsonl")" = 600 ]; then ok "history $hdir is 0700 and changes.jsonl 0600" else refuse "history-mode: $hdir/changes.jsonl must be $ME's 0600 file (mode $(mode_of "$hdir/changes.jsonl"))"; fi personas="$(line PERSONA)" if [ -z "$personas" ]; then ok "no instance has persona vars" else for d in "$CONFIG_DIR/personas" "$CONFIG_DIR/personas/$BUSINESS"; do if private_dir "$d"; then ok "persona dir $d is 0700" else refuse "persona-mode: $d must be $ME's 0700 directory"; fi done while IFS=$'\t' read -r _ inst want; do pfile="$CONFIG_DIR/personas/$BUSINESS/$inst.md" if ! private_file "$pfile" || [ "$(mode_of "$pfile")" != 600 ]; then refuse "persona-mode: $pfile must be $ME's 0600 file$( [ -e "$pfile" ] && echo " (mode $(mode_of "$pfile"))")" continue fi have="$(sha256sum -- "$pfile" | cut -d' ' -f1)" if [ "$have" = "$want" ]; then ok "persona $inst sha256 $have" else refuse "persona-mismatch: persona $inst has sha256 $have, and persona.sha256 is $want"; fi done <<<"$personas" fi fi if [ "$PROFILE" = library ] && [ -n "$DATA_ROOT" ]; then binding="$(find "$DATA_ROOT/discord" -maxdepth 1 -name '*.json' -print -quit 2>/dev/null)" if [ -n "$binding" ]; then refuse "discord-binding: $binding exists; a library hub has no Discord binding" else ok "no Discord binding under $DATA_ROOT/discord"; fi 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