Files
stack/scripts/case2-precheck.sh
T

382 lines
22 KiB
Bash
Executable File

#!/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 <user>, 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
#
# 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
# <home>/.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
# <home>. It never prints a value from claude-session.env.
#
# C2-13b adds to the library profile: the business file is <user>'s with no
# group or other bit; businesses/.history/<id>/ is 0700 and its changes.jsonl
# 0600, when present; the persona dirs are 0700 and each persona file is
# <user>'s at 0600 with the sha256 its persona.sha256 names; and no Discord
# binding exists under <dataRoot>/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/[email protected]"
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; }
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) [ $# -ge 2 ] || usage; BUSINESS="$2"; 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/business/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/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());
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 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");
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