382 lines
22 KiB
Bash
Executable File
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
|