Squash of Gosalyn's96768766con gosalyn/c2-13a-r2 (base722136772), the 5 manifest paths, build.patch 56649f9a, manifest fbb0d7e42c67 (Webby approve round 2, rev 2563). Gosalyn's gate on96768766c: cli 541 pass 0 fail; scripts/test-*.sh 429 pass 0 fail (summed by script); mutants 10 of 10 killed. packages/cli/README.md is a 3-way merge with row 199 (cb5fdb064): the hunks don't overlap (line 710 vs 1022). host.test.mjs:855 is the only test that reads that file. Oncb5fdb064plus the patch, case2-unit.test.mjs 30/0 and host.test.mjs 42/0. Refactor moved to390ea5261since, a README fix touching no manifest path. The script runs on a host only in a separate window with Jason's go.
242 lines
13 KiB
Bash
Executable File
242 lines
13 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]
|
|
#
|
|
# 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
|
|
# <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.
|
|
#
|
|
# 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]' >&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
|