clawdie-iso/build.sh
Sam & Claude bb5460427d docs: drop sudo from flash commands, append sync
All decompress-and-write one-liners now share the same form:
  xz -dc ...img.xz | of=/dev/sdX bs=4M status=progress conv=fsync && sync

- sudo removed (operator runs as root on USB stick)
- && sync appended to all image-write commands
- /dev/zero wipe commands unchanged
- build.sh echo updated to match
2026-06-23 06:41:58 +02:00

2530 lines
112 KiB
Bash
Executable file

#!/bin/sh
# clawdie-iso operator USB build script
# Produces a bootable FreeBSD XFCE operator USB image with Clawdie-AI pre-bundled.
# All packages are fetched and bundled for fully offline installation.
#
# Runtime detection handles configuration (display, GPU, etc.)
#
# Usage:
# ./build.sh # full build (fetch + assemble)
# ./build.sh --fetch-only # fetch packages/memstick only (no root needed)
# ./build.sh --skip-fetch # assemble only (use cached packages)
# ./build.sh --skip-memstick-fetch # fetch packages, reuse cached FreeBSD memstick
# ./build.sh --live-default-password # set live clawdie password to quindecim
# ./build.sh --clawdie-password ... # set an explicit live clawdie password
# ./build.sh --clawdie-version 1.0.2 # pin Clawdie-AI release tag v1.0.2
# ./build.sh --clawdie-ref main # bundle a branch/tag/commit ref
#
# Requirements (run on FreeBSD host):
# pkg install curl # for fetching
# pkg install node24 npm-node24 # to bundle clawdie-ai node_modules for offline firstboot
# pkg install (root) # for step 5-6 (mdconfig, mount)
#
# The tmp/packages/ directory produced here is dual-purpose:
# 1. Bundled into the ISO for offline installation
# 2. Used to seed zroot/pkg-cache on the installed system for offline jail provisioning
set -e
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
# Make build host command lookup independent from the invoking user's login PATH.
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
export PATH
NPM_CONFIG_UPDATE_NOTIFIER=false
export NPM_CONFIG_UPDATE_NOTIFIER
TMP_DIR="${SCRIPT_DIR}/tmp"
NPM_CONFIG_GLOBALCONFIG="${TMP_DIR}/npm-globalconfig"
NPM_CONFIG_USERCONFIG="${TMP_DIR}/npm-userconfig"
export NPM_CONFIG_GLOBALCONFIG NPM_CONFIG_USERCONFIG
PKG_LIST_DIR="${SCRIPT_DIR}/packages"
PKG_REPO_DIR="${TMP_DIR}/packages"
NPM_GLOBALS_DIR="${TMP_DIR}/npm-globals"
CACHE_DIR="${TMP_DIR}/cache"
OUTPUT_DIR="${TMP_DIR}/output"
LIVE_SESSION_DIR="${SCRIPT_DIR}/live/operator-session"
mkdir -p "$TMP_DIR"
. "${SCRIPT_DIR}/build.cfg"
BUILD_HOST_USER=""
BUILD_HOST_HOME=""
if [ "$(id -u)" -eq 0 ] && [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then
BUILD_HOST_USER="$SUDO_USER"
BUILD_HOST_HOME="$(getent passwd "$BUILD_HOST_USER" | cut -d: -f6)"
fi
if [ "$(id -u)" -eq 0 ] && [ -z "${BUILD_HOST_USER}" ]; then
echo "WARN: build is running as root without SUDO_USER; npm stages cannot drop to a non-root build user."
fi
# --- argument parsing ---
SKIP_FETCH=0
SKIP_MEMSTICK_FETCH=0
FETCH_ONLY=0
LIVE_DEFAULT_PASSWORD=0
while [ "$#" -gt 0 ]; do
case "$1" in
--clawdie-version) CLAWDIE_VERSION="$2"; CLAWDIE_REF="v$2"; shift 2 ;;
--clawdie-ref) CLAWDIE_REF="$2"; CLAWDIE_VERSION="$2"; shift 2 ;;
--skip-fetch) SKIP_FETCH=1; shift ;;
--skip-memstick-fetch) SKIP_MEMSTICK_FETCH=1; shift ;;
--fetch-only) FETCH_ONLY=1; shift ;;
--live-default-password) LIVE_DEFAULT_PASSWORD=1; shift ;;
--assistant-name) ASSISTANT_NAME="$2"; shift 2 ;;
--domain) AGENT_DOMAIN="$2"; shift 2 ;;
--tz) TZ="$2"; shift 2 ;;
--ssh-key) SSH_PUBLIC_KEY="$2"; shift 2 ;;
--tailscale-auth-key) TAILSCALE_AUTHKEY="$2"; shift 2 ;;
--root-password) ROOT_PASSWORD="$2"; shift 2 ;;
--clawdie-password) CLAWDIE_USER_PASSWORD="$2"; shift 2 ;;
*) echo "Unknown arg: $1"; exit 1 ;;
esac
done
if [ "$LIVE_DEFAULT_PASSWORD" -eq 1 ] && [ -z "${CLAWDIE_USER_PASSWORD:-}" ]; then
CLAWDIE_USER_PASSWORD="quindecim"
fi
TAILSCALE_AUTH_KEY_BAKED=false
if [ -n "${TAILSCALE_AUTHKEY:-}" ]; then
TAILSCALE_AUTH_KEY_BAKED=true
fi
LIVE_SSH_PUBKEY_FP=""
if [ -n "${SSH_PUBLIC_KEY:-}" ]; then
_ssh_fp_tmp=$(mktemp "${TMP_DIR}/live-ssh-pubkey.XXXXXX")
printf '%s\n' "${SSH_PUBLIC_KEY}" > "${_ssh_fp_tmp}"
LIVE_SSH_PUBKEY_FP=$(ssh-keygen -lf "${_ssh_fp_tmp}" 2>/dev/null | awk '{print $2}')
rm -f "${_ssh_fp_tmp}"
if [ -z "${LIVE_SSH_PUBKEY_FP:-}" ]; then
echo "ERROR: could not derive fingerprint from --ssh-key input"
exit 1
fi
fi
# The release gate runs in the preflight section below — it depends on the
# resolve_* helpers, which are defined later in this script.
# The ISO carries its own product version (ISO_VERSION in build.cfg). It does not
# track any single component. Component versions are resolved below purely for
# provenance in build-manifest.json. A milestone build must declare a version.
if [ -z "${ISO_VERSION:-}" ] || [ "${ISO_VERSION}" = "auto" ]; then
echo "ERROR: ISO_VERSION must be an explicit product version (e.g. 0.10.0)."
echo " The image no longer tracks zot — set ISO_VERSION in build.cfg."
exit 1
fi
_iso_zot_repo="${ZOT_REPO:-${SCRIPT_DIR}/../zot}"
case "${_iso_zot_repo}" in /*) ;; *) _iso_zot_repo="${SCRIPT_DIR}/${_iso_zot_repo}" ;; esac
ZOT_RESOLVED_VERSION=""
ZOT_RESOLVED_COMMIT=""
if [ -d "${_iso_zot_repo}/.git" ]; then
ZOT_RESOLVED_VERSION="$(git -C "${_iso_zot_repo}" describe --tags --always 2>/dev/null || true)"
ZOT_RESOLVED_COMMIT="$(git -C "${_iso_zot_repo}" rev-parse --short=12 HEAD 2>/dev/null || true)"
fi
[ -n "${ZOT_RESOLVED_VERSION}" ] || ZOT_RESOLVED_VERSION="${ZOT_VERSION}"
echo "==> clawdie-iso build"
echo " ISO : ${ISO_VERSION}-${BUILD_CHANNEL} (zot ${ZOT_RESOLVED_VERSION})"
echo " FreeBSD : ${FREEBSD_VERSION} ${FREEBSD_ARCH}"
echo " Clawdie : ${CLAWDIE_REF}"
echo " Desktop : ${DEFAULT_DESKTOP}"
echo " Pkg : ${DEFAULT_PKG_BRANCH}"
echo " GPU : ${GPU_DRIVER:-auto-detect}"
echo " NVIDIA universal : ${NVIDIA_UNIVERSAL:-NO}"
echo " Target : ${TARGET:-baremetal}"
echo " Colibri : ${FEATURE_COLIBRI:-NO} (agent ${ZOT_VERSION:-} / stage ${COLIBRI_STAGE_AGENT:-YES} / test-agent ${COLIBRI_STAGE_TEST_AGENT:-NO})"
echo ""
# Name the output: clawdie-<freebsd-codename>-<version>.img, where the version is
# the ISO product version (see ISO_VERSION above). Per-build provenance — date,
# clawdie-iso/colibri/zot/clawdie-ai commits — lives in build-manifest.json.
BUILD_FREEBSD_MAJOR="${FREEBSD_RELEASE_SERIES%%.*}"
BUILD_FREEBSD_STAMP="$(printf '%s' "${FREEBSD_RELEASE_SERIES}" | tr 'A-Z' 'a-z')"
case "${BUILD_FREEBSD_MAJOR}" in
15) BUILD_FREEBSD_CODENAME="quindecim" ;;
*) BUILD_FREEBSD_CODENAME="fbsd${BUILD_FREEBSD_STAMP}" ;;
esac
IMAGE_NAME="clawdie-${BUILD_FREEBSD_CODENAME}-${ISO_VERSION}.img"
: > "$NPM_CONFIG_GLOBALCONFIG"
: > "$NPM_CONFIG_USERCONFIG"
# --- helper: read package lists into a single deduplicated list ---
pkg_list_all() {
# Operator USB: include host, jail, desktop, GPU, and disk-install extras
# for offline use. Runtime detection handles configuration.
echo "==> Operator USB: including packages (host + jails + XFCE desktop + GPU drivers + disk extras)" >&2
cat \
"${PKG_LIST_DIR}/pkg-list-host.txt" \
"${PKG_LIST_DIR}/pkg-list-jails.txt" \
"${PKG_LIST_DIR}/pkg-list-desktop-base.txt" \
"${PKG_LIST_DIR}/pkg-list-xfce.txt" \
"${PKG_LIST_DIR}/pkg-list-xfce-theming.txt" \
"${PKG_LIST_DIR}/pkg-list-live-operator.txt" \
"${PKG_LIST_DIR}/pkg-list-disk-install-extras.txt" \
"${PKG_LIST_DIR}/pkg-list-nvidia-all.txt" \
| grep -v '^#' | grep -v '^$' | sort -u
}
pkg_list_live_installer() {
cat \
"${PKG_LIST_DIR}/pkg-list-desktop-base.txt" \
"${PKG_LIST_DIR}/pkg-list-xfce.txt" \
"${PKG_LIST_DIR}/pkg-list-xfce-theming.txt" \
"${PKG_LIST_DIR}/pkg-list-live-operator.txt" \
| grep -v '^#' \
| grep -v '^$' \
| sort -u
}
pkg_list_live_nvidia() {
case "${GPU_DRIVER:-}" in
nvidia-390)
cat "${PKG_LIST_DIR}/pkg-list-nvidia-390.txt"
;;
nvidia-470)
cat "${PKG_LIST_DIR}/pkg-list-nvidia-470.txt"
;;
nvidia-590)
cat "${PKG_LIST_DIR}/pkg-list-nvidia-590.txt"
;;
*)
return 0
;;
esac | grep -v '^#' | grep -v '^$' | sort -u
}
set_config_line() {
_file="$1"
_assignment="$2"
_name=$(echo "$_assignment" | cut -d= -f1)
mkdir -p "$(dirname "$_file")"
touch "$_file"
if grep -q "^${_name}=" "$_file" 2>/dev/null; then
sed -i '' "s|^${_name}=.*|${_assignment}|" "$_file"
else
echo "$_assignment" >> "$_file"
fi
}
append_rc_list_values() {
_file="$1"
_name="$2"
shift 2
mkdir -p "$(dirname "$_file")"
touch "$_file"
_current=""
if grep -q "^${_name}=" "$_file" 2>/dev/null; then
_current=$(sed -n "s/^${_name}=\"\\(.*\\)\"/\\1/p" "$_file" | head -1)
fi
_merged="$_current"
for _value in "$@"; do
if [ -z "$_merged" ]; then
_merged="$_value"
continue
fi
case " ${_merged} " in
*" ${_value} "*) ;;
*)
_merged="${_merged} ${_value}"
;;
esac
done
set_config_line "$_file" "${_name}=\"${_merged}\""
}
ensure_fstab_line() {
_file="$1"
_pattern="$2"
_line="$3"
mkdir -p "$(dirname "$_file")"
touch "$_file"
if ! grep -Eq "$_pattern" "$_file" 2>/dev/null; then
echo "$_line" >> "$_file"
fi
}
set_fstab_line() {
_file="$1"
_pattern="$2"
_line="$3"
mkdir -p "$(dirname "$_file")"
touch "$_file"
_tmp="${_file}.tmp.$$"
awk -v pattern="$_pattern" -v line="$_line" '
BEGIN { replaced = 0 }
$0 ~ pattern {
if (!replaced) {
print line
replaced = 1
}
next
}
{ print }
END {
if (!replaced) {
print line
}
}
' "$_file" > "$_tmp"
mv "$_tmp" "$_file"
}
pkg_archive_for() {
_pkg_name="$1"
# Match exact package names only. A loose `${name}-*.pkg` pattern lets a
# missing package such as `xfce` resolve to `xfce-icons-elementary`, which
# silently builds an image without the requested desktop session.
{
find "${PKG_REPO_DIR}/All" -type f -name "${_pkg_name}-[0-9]*.pkg" 2>/dev/null
find "${PKG_REPO_DIR}" -maxdepth 2 -type f -name "${_pkg_name}-[0-9]*.pkg" 2>/dev/null
} | sort -u | tail -1
}
resolve_colibri_paths() {
_resolved_colibri_repo="${COLIBRI_REPO:-/home/clawdie/ai/colibri}"
case "${_resolved_colibri_repo}" in
/*) ;;
*) _resolved_colibri_repo="${SCRIPT_DIR}/${_resolved_colibri_repo}" ;;
esac
if [ -n "${COLIBRI_ARTIFACT_DIR:-}" ]; then
_resolved_colibri_artifact_dir="${COLIBRI_ARTIFACT_DIR}"
case "${_resolved_colibri_artifact_dir}" in
/*) ;;
*) _resolved_colibri_artifact_dir="${SCRIPT_DIR}/${_resolved_colibri_artifact_dir}" ;;
esac
else
_resolved_colibri_artifact_dir="${_resolved_colibri_repo}/target/release"
fi
}
resolve_clawdie_ai_repo() {
_resolved_clawdie_ai_repo="${CLAWDIE_AI_REPO:-/home/clawdie/ai/clawdie-ai}"
case "${_resolved_clawdie_ai_repo}" in
/*) ;;
*) _resolved_clawdie_ai_repo="${SCRIPT_DIR}/${_resolved_clawdie_ai_repo}" ;;
esac
}
preflight_colibri_artifacts() {
[ "${FEATURE_COLIBRI:-NO}" = "YES" ] || return 0
[ "${FETCH_ONLY:-0}" -eq 0 ] || return 0
resolve_colibri_paths
_colibri_rc="${_resolved_colibri_repo}/packaging/freebsd/colibri_daemon.in"
_colibri_newsyslog="${_resolved_colibri_repo}/packaging/freebsd/newsyslog-colibri.conf"
if [ ! -f "${_colibri_rc}" ]; then
echo "ERROR: Colibri rc.d source missing: ${_colibri_rc}"
echo " Set COLIBRI_REPO=/path/to/colibri or FEATURE_COLIBRI=NO."
exit 1
fi
if ! grep -q '^command="/usr/sbin/daemon"' "${_colibri_rc}"; then
echo "ERROR: Colibri rc.d source does not supervise with daemon(8): ${_colibri_rc}"
echo " Update the Colibri checkout before building; a foreground daemon blocks live boot."
exit 1
fi
if [ ! -f "${_colibri_newsyslog}" ]; then
echo "ERROR: Colibri newsyslog source missing: ${_colibri_newsyslog}"
echo " Set COLIBRI_REPO=/path/to/colibri or FEATURE_COLIBRI=NO."
exit 1
fi
for _colibri_bin in colibri-daemon colibri colibri-mcp; do
if [ ! -x "${_resolved_colibri_artifact_dir}/${_colibri_bin}" ]; then
echo "ERROR: Colibri release binary missing: ${_resolved_colibri_artifact_dir}/${_colibri_bin}"
command -v cargo >/dev/null 2>&1 || \
echo " NOTE: rust toolchain not found on this host — install it: pkg install rust"
echo " Build first: (cd ${_resolved_colibri_repo} && cargo build --workspace --release)"
echo " Or set FEATURE_COLIBRI=NO to skip Colibri staging."
exit 1
fi
done
if [ "${COLIBRI_STAGE_TEST_AGENT:-NO}" = "YES" ] && \
[ ! -x "${_resolved_colibri_artifact_dir}/colibri-test-agent" ]; then
echo "ERROR: Colibri test-agent staging requested, but binary is missing: ${_resolved_colibri_artifact_dir}/colibri-test-agent"
echo " Set COLIBRI_STAGE_TEST_AGENT=NO for production images, or rebuild Colibri with cargo build --workspace --release."
exit 1
fi
}
resolve_zot_paths() {
_resolved_zot_repo="${ZOT_REPO:-${SCRIPT_DIR}/../zot}"
case "${_resolved_zot_repo}" in
/*) ;;
*) _resolved_zot_repo="${SCRIPT_DIR}/${_resolved_zot_repo}" ;;
esac
if [ -n "${ZOT_ARTIFACT_DIR:-}" ]; then
_resolved_zot_artifact_dir="${ZOT_ARTIFACT_DIR}"
case "${_resolved_zot_artifact_dir}" in
/*) ;;
*) _resolved_zot_artifact_dir="${SCRIPT_DIR}/${_resolved_zot_artifact_dir}" ;;
esac
else
_resolved_zot_artifact_dir="${_resolved_zot_repo}/bin"
fi
}
preflight_zot_artifacts() {
[ "${FEATURE_COLIBRI:-NO}" = "YES" ] || return 0
[ "${COLIBRI_STAGE_AGENT:-YES}" = "YES" ] || return 0
[ "${FETCH_ONLY:-0}" -eq 0 ] || return 0
resolve_zot_paths
if [ ! -x "${_resolved_zot_artifact_dir}/zot" ]; then
echo "ERROR: Colibri agent binary missing: ${_resolved_zot_artifact_dir}/zot"
command -v go >/dev/null 2>&1 || \
echo " NOTE: go toolchain not found on this host — install it: pkg install go"
echo " The agent has no FreeBSD release — build it first:"
echo " (cd ${_resolved_zot_repo} && git checkout ${ZOT_VERSION:-v0.2.42} && \\"
echo " ZOT_BUILD_VERSION=\"\${ZOT_VERSION:-v0.2.42}\" && VERSION=\"\${ZOT_BUILD_VERSION#v}\" make build)"
echo " Or set COLIBRI_STAGE_AGENT=NO to skip agent staging."
exit 1
fi
}
override_networkmgr_package() {
_networkmgr_pkg=$(find "${PKG_REPO_DIR}/All/Hashed" -maxdepth 1 -type f -name 'networkmgr-*.pkg' | sort | tail -1)
if [ -z "${_networkmgr_pkg:-}" ]; then
echo "ERROR: networkmgr package not found under ${PKG_REPO_DIR}/All/Hashed"
exit 1
fi
echo "==> [2c/7] Repacking NetworkMgr for mac_do/mdo policy..."
_networkmgr_stage=$(mktemp -d "${TMP_DIR}/networkmgr-override.XXXXXX")
_networkmgr_log="${TMP_DIR}/networkmgr-repack.log"
if ! node "${SCRIPT_DIR}/scripts/repack-networkmgr-for-mdo.mjs" "${_networkmgr_pkg}" "${_networkmgr_stage}" >"${_networkmgr_log}" 2>&1; then
cat "${_networkmgr_log}"
rm -rf "${_networkmgr_stage}"
echo "ERROR: failed to rebuild local networkmgr package"
exit 1
fi
rm -f "${PKG_REPO_DIR}/All/Hashed"/networkmgr-*.pkg
mv "${_networkmgr_stage}"/networkmgr-*.pkg "${PKG_REPO_DIR}/All/Hashed/"
# After replacing NetworkMgr's sudo dependency with the local mac_do/mdo
# package, remove any fetched sudo archive from the offline repo that will
# be copied onto the USB image.
find "${PKG_REPO_DIR}/All" -type f -name 'sudo-*.pkg' -delete
rm -rf "${_networkmgr_stage}"
}
json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
resolve_clawdie_commit() {
_ref="$1"
_repo="https://code.smilepowered.org/clawdie/clawdie-ai.git"
if printf '%s' "$_ref" | grep -Eq '^[0-9a-fA-F]{40}$'; then
printf '%s\n' "$_ref"
return 0
fi
if command -v git >/dev/null 2>&1; then
git ls-remote "$_repo" \
"refs/heads/${_ref}" \
"refs/tags/${_ref}^{}" \
"refs/tags/${_ref}" 2>/dev/null \
| awk '
$2 ~ /\^\{\}$/ { print $1; found = 1; exit }
first == "" { first = $1 }
END { if (!found && first != "") print first }
'
fi
}
resolve_latest_clawdie_tag() {
_repo_api="https://code.smilepowered.org/api/v1/repos/clawdie/clawdie-ai"
_repo_git="https://code.smilepowered.org/clawdie/clawdie-ai.git"
_tag=$(
curl -fsS "${_repo_api}/releases?limit=20" 2>/dev/null \
| grep -o '"tag_name":"[^"]*"' \
| head -1 \
| cut -d'"' -f4
)
if [ -n "$_tag" ]; then
printf '%s\n' "$_tag"
return 0
fi
git ls-remote --tags "$_repo_git" 2>/dev/null \
| awk -F/ '$2 == "tags" && $3 ~ /^v[0-9]+\.[0-9]+\.[0-9]+$/ { print $3 }' \
| sed 's/^v//' \
| sort -t. -k1,1n -k2,2n -k3,3n \
| tail -n 1 \
| sed 's/^/v/'
}
is_pinned_clawdie_ref() {
_ref="$1"
printf '%s' "$_ref" | grep -Eq '^[0-9a-fA-F]{40}$|^v[0-9]+\.[0-9]+\.[0-9]+$'
}
# Assert a repo has no uncommitted *or untracked* changes. `git status --porcelain`
# is used (not `git diff`) so stray untracked files — which would change the build
# yet pass a diff-only check — also fail the gate. Increments _release_errors on a
# modified/untracked tree; a non-git path is skipped (nothing to assert).
assert_clean_repo() {
_acr_name="$1"
_acr_path="$2"
command -v git >/dev/null 2>&1 || return 0
git -C "${_acr_path}" rev-parse --git-dir >/dev/null 2>&1 || return 0
if [ -n "$(git -C "${_acr_path}" status --porcelain 2>/dev/null)" ]; then
echo "ERROR: release builds require a clean ${_acr_name} repo (no uncommitted or untracked files)"
echo " Modified tree at: ${_acr_path}"
_release_errors=$((_release_errors + 1))
fi
}
# Release builds must be reproducible from the recorded provenance. We do not
# require components to be on a named tag — a recorded commit is just as pinned —
# but every staged source must be a clean, committed tree so the manifest's
# commits fully describe the artifact. (build-manifest.json records each commit.)
check_release_gate() {
_release_errors=0
assert_clean_repo "clawdie-iso" "${SCRIPT_DIR}"
resolve_clawdie_ai_repo
assert_clean_repo "clawdie-ai" "${_resolved_clawdie_ai_repo}"
if [ "${FEATURE_COLIBRI:-NO}" = "YES" ]; then
resolve_colibri_paths
assert_clean_repo "colibri" "${_resolved_colibri_repo}"
if [ "${COLIBRI_STAGE_AGENT:-YES}" = "YES" ]; then
resolve_zot_paths
assert_clean_repo "zot" "${_resolved_zot_repo}"
fi
fi
# Guard against a re-introduced duplicate port. The canonical Colibri FreeBSD
# port lives in the colibri repo (packaging/freebsd/port/sysutils/colibri) and
# is consumed from there; a copy in this repo would silently drift from
# Colibri's Cargo.lock / binaries / license.
if [ -e "${SCRIPT_DIR}/ports/sysutils/colibri" ]; then
echo "ERROR: duplicate Colibri port at ports/sysutils/colibri — the canonical port lives in the colibri repo (packaging/freebsd/port/sysutils/colibri); remove this copy."
_release_errors=$(( _release_errors + 1 ))
fi
# A baked mother SSH private key must never reach a publicly hosted release
# image. Fail fast here so a release build aborts in seconds; the image
# assembly step also refuses to copy it, as defense in depth.
if [ -f "/home/clawdie/.ssh/osa-mother-2026" ]; then
echo "ERROR: mother SSH key present on build host (/home/clawdie/.ssh/osa-mother-2026) — refuse to bake it into a release image. Remove it, or build with BUILD_CHANNEL=dev."
_release_errors=$(( _release_errors + 1 ))
fi
if [ "${_release_errors}" -gt 0 ]; then
echo "ERROR: release build aborted — ${_release_errors} modified repo(s). Use BUILD_CHANNEL=dev for iteration builds."
exit 1
fi
}
write_build_manifest() {
_manifest_path="$1"
_iso_repo_commit="unknown"
_iso_repo_modified="null"
_live_ssh_pubkey_fp_json="null"
_tailscale_auth_key_baked="${TAILSCALE_AUTH_KEY_BAKED:-false}"
if command -v git >/dev/null 2>&1 && git -C "$SCRIPT_DIR" rev-parse --git-dir >/dev/null 2>&1; then
_iso_repo_commit=$(git -C "$SCRIPT_DIR" rev-parse HEAD 2>/dev/null || echo unknown)
if [ -z "$(git -C "$SCRIPT_DIR" status --porcelain 2>/dev/null)" ]; then
_iso_repo_modified="false"
else
_iso_repo_modified="true"
fi
fi
# Clawdie-AI provenance: the image stages a git checkout of the AI source,
# so record whether the tree is modified at build time.
_clawdie_ai_modified="null"
if command -v git >/dev/null 2>&1; then
resolve_clawdie_ai_repo
if git -C "${_resolved_clawdie_ai_repo}" rev-parse --git-dir >/dev/null 2>&1; then
if [ -z "$(git -C "${_resolved_clawdie_ai_repo}" status --porcelain 2>/dev/null)" ]; then
_clawdie_ai_modified="false"
else
_clawdie_ai_modified="true"
fi
fi
fi
if [ -n "${LIVE_SSH_PUBKEY_FP:-}" ]; then
_live_ssh_pubkey_fp_json="\"$(json_escape "${LIVE_SSH_PUBKEY_FP}")\""
fi
# Colibri provenance: the image stages adjacent colibri binaries, so record
# which commit produced them (the biggest gap for a reproducible release).
_colibri_commit="unknown"
_colibri_modified="null"
if [ "${FEATURE_COLIBRI:-NO}" = "YES" ]; then
resolve_colibri_paths
if git -C "${_resolved_colibri_repo}" rev-parse --git-dir >/dev/null 2>&1; then
_colibri_commit=$(git -C "${_resolved_colibri_repo}" rev-parse HEAD 2>/dev/null || echo unknown)
if [ -z "$(git -C "${_resolved_colibri_repo}" status --porcelain 2>/dev/null)" ]; then
_colibri_modified="false"
else
_colibri_modified="true"
fi
fi
fi
# Zot provenance (agent binary built from source — record if repo is modified).
_zot_modified="null"
if [ "${FEATURE_COLIBRI:-NO}" = "YES" ] && [ "${COLIBRI_STAGE_AGENT:-YES}" = "YES" ]; then
resolve_zot_paths
if command -v git >/dev/null 2>&1 && git -C "${_resolved_zot_repo}" rev-parse --git-dir >/dev/null 2>&1; then
if [ -z "$(git -C "${_resolved_zot_repo}" status --porcelain 2>/dev/null)" ]; then
_zot_modified="false"
else
_zot_modified="true"
fi
fi
fi
mkdir -p "$(dirname "$_manifest_path")"
cat > "$_manifest_path" <<EOF
{
"iso_version": "$(json_escape "${ISO_VERSION}")",
"version_scheme": "product",
"zot_version": "$(json_escape "${ZOT_RESOLVED_VERSION:-${ZOT_VERSION}}")",
"zot_commit": "$(json_escape "${ZOT_RESOLVED_COMMIT:-unknown}")",
"zot_modified": ${_zot_modified:-null},
"colibri_commit": "$(json_escape "${_colibri_commit:-unknown}")",
"colibri_modified": ${_colibri_modified:-null},
"build_channel": "$(json_escape "${BUILD_CHANNEL}")",
"freebsd_version": "$(json_escape "${FREEBSD_VERSION}")",
"freebsd_arch": "$(json_escape "${FREEBSD_ARCH}")",
"clawdie_ai_ref": "$(json_escape "${CLAWDIE_REF}")",
"clawdie_ai_commit": "$(json_escape "${CLAWDIE_AI_COMMIT:-unknown}")",
"clawdie_ai_modified": ${_clawdie_ai_modified:-null},
"live_ssh_pubkey_fp": ${_live_ssh_pubkey_fp_json},
"tailscale_auth_key_baked": ${_tailscale_auth_key_baked},
"iso_repo_commit": "$(json_escape "${_iso_repo_commit}")",
"iso_repo_modified": ${_iso_repo_modified},
"built_at": "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
}
EOF
}
mount_memstick_rootfs() {
_memstick_mount="$1"
_memstick_slice_img="${CACHE_DIR}/memstick-freebsd-slice.img"
_memstick_ufs_img="${CACHE_DIR}/memstick-rootfs.img"
mkdir -p "$_memstick_mount"
rm -f "$_memstick_slice_img" "$_memstick_ufs_img"
MD_SRC=$(mdconfig -a -t vnode -f "$MEMSTICK")
_slice_meta=$(
gpart show -p "/dev/${MD_SRC}" \
| awk '$4 == "freebsd" { print $1 " " $2; exit }'
)
_slice_start=$(echo "$_slice_meta" | awk '{print $1}')
_slice_size=$(echo "$_slice_meta" | awk '{print $2}')
if [ -z "${_slice_start:-}" ] || [ -z "${_slice_size:-}" ]; then
echo "ERROR: could not determine FreeBSD slice start in ${MEMSTICK}"
mdconfig -d -u "${MD_SRC}" 2>/dev/null || true
exit 1
fi
truncate -s "$((_slice_size * 512))" "$_memstick_slice_img"
dd if="$MEMSTICK" of="$_memstick_slice_img" bs=512 skip="$_slice_start" conv=sparse status=none
MD_SLICE=$(mdconfig -a -t vnode -f "$_memstick_slice_img")
_ufs_meta=$(
bsdlabel "/dev/${MD_SLICE}" 2>/dev/null \
| awk '$1 == "a:" { print $2 " " $3; exit }'
)
_ufs_size=$(echo "$_ufs_meta" | awk '{print $1}')
_ufs_offset=$(echo "$_ufs_meta" | awk '{print $2}')
if [ -z "${_ufs_size:-}" ] || [ -z "${_ufs_offset:-}" ]; then
echo "ERROR: could not determine UFS root partition offset in ${MEMSTICK}"
mdconfig -d -u "${MD_SLICE}" 2>/dev/null || true
mdconfig -d -u "${MD_SRC}" 2>/dev/null || true
exit 1
fi
truncate -s "$((_ufs_size * 512))" "$_memstick_ufs_img"
dd if="$_memstick_slice_img" of="$_memstick_ufs_img" bs=512 skip="$_ufs_offset" conv=sparse status=none
MD_ROOTFS=$(mdconfig -a -t vnode -f "$_memstick_ufs_img")
mount -r -t ufs "/dev/${MD_ROOTFS}" "$_memstick_mount"
}
cleanup_memstick_rootfs() {
_memstick_mount="$1"
umount "$_memstick_mount" 2>/dev/null || true
[ -n "${MD_ROOTFS:-}" ] && mdconfig -d -u "${MD_ROOTFS}" 2>/dev/null || true
[ -n "${MD_SLICE:-}" ] && mdconfig -d -u "${MD_SLICE}" 2>/dev/null || true
[ -n "${MD_SRC:-}" ] && mdconfig -d -u "${MD_SRC}" 2>/dev/null || true
rm -f "${CACHE_DIR}/memstick-freebsd-slice.img" "${CACHE_DIR}/memstick-rootfs.img"
MD_ROOTFS=""
MD_SLICE=""
MD_SRC=""
}
verify_memstick_cache() {
if [ ! -f "$MEMSTICK" ] || [ ! -f "${MEMSTICK}.SHA256" ]; then
return 1
fi
_expected_sha=$(
awk -v image="$(basename "$MEMSTICK")" '
$2 == "(" image ")" { print $4; exit }
' "${MEMSTICK}.SHA256"
)
[ -n "${_expected_sha:-}" ] || return 1
[ "$(/sbin/sha256 -q "$MEMSTICK")" = "$_expected_sha" ]
}
install_image_bootcode() {
_image_md="$1"
_image_root="$2"
_freebsd_slice_index="${3:-}"
if [ -z "${_freebsd_slice_index}" ]; then
if [ -e "/dev/${_image_md}s2" ]; then
_freebsd_slice_index="2"
else
_freebsd_slice_index="1"
fi
fi
if [ ! -f "${_image_root}/boot/mbr" ] || [ ! -f "${_image_root}/boot/boot" ]; then
echo "ERROR: missing bootcode files in ${_image_root}/boot"
exit 1
fi
gpart bootcode -b "${_image_root}/boot/mbr" "/dev/${_image_md}"
gpart bootcode -b "${_image_root}/boot/boot" "/dev/${_image_md}s${_freebsd_slice_index}"
}
install_image_uefi_bootcode() {
_image_md="$1"
_image_root="$2"
_efi_slice_index="${3:-1}"
_efi_mount="${CACHE_DIR}/efi-mnt"
if [ ! -f "${_image_root}/boot/loader.efi" ]; then
echo "ERROR: missing UEFI loader at ${_image_root}/boot/loader.efi"
exit 1
fi
newfs_msdos -L EFISYS "/dev/${_image_md}s${_efi_slice_index}"
mkdir -p "${_efi_mount}"
mount_msdosfs "/dev/${_image_md}s${_efi_slice_index}" "${_efi_mount}"
mkdir -p "${_efi_mount}/EFI/BOOT"
cp "${_image_root}/boot/loader.efi" "${_efi_mount}/EFI/BOOT/BOOTX64.EFI"
sync
umount "${_efi_mount}"
rmdir "${_efi_mount}" 2>/dev/null || true
}
run_live_chroot() {
chroot "$MOUNT_POINT" /usr/bin/env \
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
LD_LIBRARY_PATH="/usr/local/lib:/usr/local/lib/compat:/lib:/usr/lib" \
"$@"
}
refresh_live_desktop_caches() {
echo " Refreshing live desktop schema/icon/mime caches..."
if [ -x "${MOUNT_POINT}/usr/local/bin/glib-compile-schemas" ]; then
run_live_chroot /usr/local/bin/glib-compile-schemas /usr/local/share/glib-2.0/schemas
fi
if [ ! -f "${MOUNT_POINT}/usr/local/share/glib-2.0/schemas/gschemas.compiled" ]; then
echo "ERROR: GLib GSettings schema cache missing from live image"
exit 1
fi
if [ -x "${MOUNT_POINT}/usr/local/bin/gdk-pixbuf-query-loaders" ]; then
run_live_chroot /usr/local/bin/gdk-pixbuf-query-loaders --update-cache
fi
if [ ! -f "${MOUNT_POINT}/usr/local/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache" ]; then
echo "ERROR: gdk-pixbuf loader cache missing from live image"
exit 1
fi
if ! grep -qi 'png' "${MOUNT_POINT}/usr/local/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache"; then
echo "ERROR: gdk-pixbuf PNG loader missing from live image cache"
exit 1
fi
if [ -x "${MOUNT_POINT}/usr/local/bin/update-mime-database" ] && [ -d "${MOUNT_POINT}/usr/local/share/mime" ]; then
run_live_chroot /usr/local/bin/update-mime-database /usr/local/share/mime
fi
if [ -x "${MOUNT_POINT}/usr/local/bin/update-desktop-database" ] && [ -d "${MOUNT_POINT}/usr/local/share/applications" ]; then
run_live_chroot /usr/local/bin/update-desktop-database /usr/local/share/applications
fi
if [ -x "${MOUNT_POINT}/usr/local/bin/gtk-update-icon-cache" ]; then
# Regenerate every installed icon theme cache. FreeBSD packages and
# Clawdie branding install icons into hicolor and other theme
# directories; missed caches show up as blank/dot panel icons.
for _icon_theme_dir in "${MOUNT_POINT}/usr/local/share/icons"/*/; do
[ -d "$_icon_theme_dir" ] || continue
_theme_name=$(basename "$_icon_theme_dir")
run_live_chroot /usr/local/bin/gtk-update-icon-cache -f -t "/usr/local/share/icons/${_theme_name}" || true
done
fi
if [ -x "${MOUNT_POINT}/usr/local/bin/fc-cache" ] && [ -d "${MOUNT_POINT}/usr/local/share/fonts" ]; then
run_live_chroot /usr/local/bin/fc-cache -f
fi
}
ensure_sshd_include_line() {
_sshd_config="$1"
_include_line='Include /etc/ssh/sshd_config.d/*.conf'
if [ ! -f "$_sshd_config" ]; then
echo "ERROR: sshd_config missing from live image"
exit 1
fi
if grep -Eq '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/\*\.conf([[:space:]]|$)' "$_sshd_config"; then
return 0
fi
_tmp="${_sshd_config}.tmp.$$"
{
printf '%s\n' "${_include_line}"
cat "$_sshd_config"
} > "$_tmp"
mv "$_tmp" "$_sshd_config"
}
configure_nsswitch_hosts_line() {
_file="$1"
_line='hosts: files mdns_minimal [NOTFOUND=return] dns mdns'
if [ ! -f "$_file" ]; then
echo "ERROR: nsswitch.conf missing from live image"
exit 1
fi
set_fstab_line "$_file" '^[[:space:]]*hosts:' "$_line"
}
install_live_runtime_packages() {
echo " Installing live operator runtime packages into image..."
_pkg_files=""
for _pkg in $(pkg_list_live_installer); do
_archive=$(pkg_archive_for "$_pkg")
if [ -z "${_archive:-}" ]; then
echo "ERROR: missing package archive for ${_pkg}"
exit 1
fi
_pkg_files="${_pkg_files} ${_archive}"
done
for _pkg in $(pkg_list_live_nvidia); do
_archive=$(pkg_archive_for "$_pkg")
if [ -z "${_archive:-}" ]; then
echo "ERROR: missing NVIDIA package archive for ${_pkg}"
exit 1
fi
_pkg_files="${_pkg_files} ${_archive}"
done
mkdir -p "${MOUNT_POINT}/dev" "${MOUNT_POINT}/proc"
_mounted_devfs=0
_mounted_procfs=0
if mount -t devfs devfs "${MOUNT_POINT}/dev"; then
_mounted_devfs=1
fi
if mount -t procfs proc "${MOUNT_POINT}/proc"; then
_mounted_procfs=1
fi
if ! env ASSUME_ALWAYS_YES=yes HANDLE_RC_SCRIPTS=no \
/usr/local/sbin/pkg-static -o PKG_TRIGGERS_ENABLE=false -r "$MOUNT_POINT" add -f ${_pkg_files}; then
[ "$_mounted_procfs" -eq 1 ] && umount "${MOUNT_POINT}/proc" 2>/dev/null || true
[ "$_mounted_devfs" -eq 1 ] && umount "${MOUNT_POINT}/dev" 2>/dev/null || true
echo "ERROR: failed to install live GUI runtime packages into image"
exit 1
fi
if ! chroot "${MOUNT_POINT}" /usr/sbin/pw usershow sddm >/dev/null 2>&1; then
[ "$_mounted_procfs" -eq 1 ] && umount "${MOUNT_POINT}/proc" 2>/dev/null || true
[ "$_mounted_devfs" -eq 1 ] && umount "${MOUNT_POINT}/dev" 2>/dev/null || true
echo "ERROR: sddm user missing from live image after package install"
exit 1
fi
# webcamd creates /dev/videoN with mode 0660 root:webcamd. Add clawdie
# to that group so the operator can open the camera without sudo.
if chroot "${MOUNT_POINT}" /usr/sbin/pw groupshow webcamd >/dev/null 2>&1; then
chroot "${MOUNT_POINT}" /usr/sbin/pw groupmod webcamd -m clawdie 2>/dev/null || true
else
[ "$_mounted_procfs" -eq 1 ] && umount "${MOUNT_POINT}/proc" 2>/dev/null || true
[ "$_mounted_devfs" -eq 1 ] && umount "${MOUNT_POINT}/dev" 2>/dev/null || true
echo "ERROR: webcamd group missing from live image after package install"
exit 1
fi
refresh_live_desktop_caches
[ "$_mounted_procfs" -eq 1 ] && umount "${MOUNT_POINT}/proc" 2>/dev/null || true
[ "$_mounted_devfs" -eq 1 ] && umount "${MOUNT_POINT}/dev" 2>/dev/null || true
}
install_colibri_service() {
[ "${FEATURE_COLIBRI:-NO}" = "YES" ] || {
echo " Colibri service staging disabled (FEATURE_COLIBRI=${FEATURE_COLIBRI:-NO})"
return 0
}
echo " Staging Colibri service..."
resolve_colibri_paths
env \
COLIBRI_REPO="${_resolved_colibri_repo}" \
COLIBRI_ARTIFACT_DIR="${_resolved_colibri_artifact_dir}" \
COLIBRI_STAGE_ENABLE="${COLIBRI_DAEMON_ENABLE:-YES}" \
COLIBRI_STAGE_TEST_AGENT="${COLIBRI_STAGE_TEST_AGENT:-NO}" \
COLIBRI_COST_MODE="${COLIBRI_COST_MODE:-smart}" \
"${SCRIPT_DIR}/scripts/stage-colibri-iso.sh" "${MOUNT_POINT}"
if ! /usr/sbin/pw -R "${MOUNT_POINT}" groupshow colibri >/dev/null 2>&1; then
/usr/sbin/pw -R "${MOUNT_POINT}" groupadd colibri
fi
if ! /usr/sbin/pw -R "${MOUNT_POINT}" usershow colibri >/dev/null 2>&1; then
/usr/sbin/pw -R "${MOUNT_POINT}" useradd colibri \
-g colibri \
-d /var/db/colibri \
-s /usr/sbin/nologin \
-c "Colibri Daemon"
fi
mkdir -p \
"${MOUNT_POINT}/var/db/colibri" \
"${MOUNT_POINT}/var/run/colibri" \
"${MOUNT_POINT}/var/log/colibri"
chroot "${MOUNT_POINT}" chown -R colibri:colibri \
/var/db/colibri \
/var/run/colibri \
/var/log/colibri
# 0750 matches the rc.d prestart (install -d -m 0750); the daemon dirs hold
# the SQLite DB and logs and should not be world-readable. The operator
# reaches them via the colibri group, not "other".
chmod 0750 \
"${MOUNT_POINT}/var/db/colibri" \
"${MOUNT_POINT}/var/run/colibri" \
"${MOUNT_POINT}/var/log/colibri"
# Allow operator to use 'colibri' CLI without root
if /usr/sbin/pw -R "${MOUNT_POINT}" usershow clawdie >/dev/null 2>&1; then
/usr/sbin/pw -R "${MOUNT_POINT}" groupmod colibri -m clawdie
fi
set_config_line "${MOUNT_POINT}/etc/rc.conf" "colibri_daemon_enable=\"${COLIBRI_DAEMON_ENABLE:-YES}\""
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_user="colibri"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_group="colibri"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_data_dir="/var/db/colibri"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_run_dir="/var/run/colibri"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_socket="/var/run/colibri/colibri.sock"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_db_path="/var/db/colibri/colibri.sqlite"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_logfile="/var/log/colibri/daemon.log"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_provider_env="/usr/local/etc/colibri/provider.env"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_host="$(/bin/hostname)"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" "colibri_daemon_cost_mode=\"${COLIBRI_COST_MODE:-smart}\""
if [ ! -x "${MOUNT_POINT}/usr/local/bin/colibri-daemon" ] || \
[ ! -x "${MOUNT_POINT}/usr/local/bin/colibri" ] || \
[ ! -x "${MOUNT_POINT}/usr/local/bin/colibri-mcp" ]; then
echo "ERROR: Colibri binaries missing from live image"
exit 1
fi
if [ ! -x "${MOUNT_POINT}/usr/local/etc/rc.d/colibri_daemon" ]; then
echo "ERROR: Colibri rc.d script missing from live image"
exit 1
fi
if ! /usr/sbin/pw -R "${MOUNT_POINT}" usershow colibri >/dev/null 2>&1; then
echo "ERROR: colibri service user missing from live image"
exit 1
fi
# Operator helper: one-command live rebuild/redeploy of Colibri from source
# (automates docs/LIVE-COLIBRI-REBUILD.md). Only staged with the service.
install -m 0755 "${LIVE_SESSION_DIR}/colibri-live-rebuild" \
"${MOUNT_POINT}/usr/local/bin/colibri-live-rebuild"
# Seed the skills catalog with operator-useful entries. The daemon
# creates the SQLite DB on first start; pre-populate it so skills
# are available immediately without a first-boot script.
_colibri_db="${MOUNT_POINT}/var/db/colibri/colibri.sqlite"
if command -v sqlite3 >/dev/null 2>&1; then
sqlite3 "${_colibri_db}" "CREATE TABLE IF NOT EXISTS skills (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL UNIQUE,
description TEXT,
category TEXT,
created_at TEXT NOT NULL
);" 2>/dev/null || true
_now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
sqlite3 "${_colibri_db}" "INSERT OR IGNORE INTO skills (id, name, description, category, created_at) VALUES
('$(uuidgen || echo 00000000-0000-0000-0000-000000000001)', 'freebsd-live-usb', 'FreeBSD live USB operator workstation procedures', 'freebsd', '${_now}'),
('$(uuidgen || echo 00000000-0000-0000-0000-000000000002)', 'colibri-test', 'Colibri daemon startup check and validation', 'colibri', '${_now}'),
('$(uuidgen || echo 00000000-0000-0000-0000-000000000003)', 'iso-build', 'Clawdie ISO build and staging workflow', 'iso', '${_now}'),
('$(uuidgen || echo 00000000-0000-0000-0000-000000000004)', 'tailscale-join', 'Tailscale mesh join procedure for operator USB', 'networking', '${_now}'),
('$(uuidgen || echo 00000000-0000-0000-0000-000000000005)', 'disk-deploy', 'Deploy from USB live to permanent disk install. Provisions ZFS pool, installs FreeBSD boot environment, migrates config, and prepares for the future deployed-system clawdie service.', 'clawdie', '${_now}'),
('$(uuidgen || echo 00000000-0000-0000-0000-000000000006)', 'deployed-clawdie-health', 'Future post-deploy health check for service clawdie once the deployed-system service implementation lands.', 'clawdie', '${_now}');" 2>/dev/null || true
chroot "${MOUNT_POINT}" chown colibri:colibri /var/db/colibri/colibri.sqlite 2>/dev/null || true
echo " colibri skills seeded: 6 entries"
fi
# Import clawdie-ai skill definitions into the catalog.
# Reads .agent/skills/*/SKILL.md and registers name + description.
resolve_clawdie_ai_repo
_clawdie_ai_dir="${_resolved_clawdie_ai_repo}"
if [ -d "${_clawdie_ai_dir}/.agent/skills" ]; then
"${SCRIPT_DIR}/scripts/import-clawdie-skills.sh" \
"${_clawdie_ai_dir}" "${MOUNT_POINT}"
else
echo " clawdie-ai checkout not found, skipping skill import"
fi
}
install_zot_agent() {
[ "${FEATURE_COLIBRI:-NO}" = "YES" ] || {
echo " Colibri agent staging disabled (FEATURE_COLIBRI=${FEATURE_COLIBRI:-NO})"
return 0
}
[ "${COLIBRI_STAGE_AGENT:-YES}" = "YES" ] || {
echo " Colibri agent staging disabled (COLIBRI_STAGE_AGENT=${COLIBRI_STAGE_AGENT:-YES})"
return 0
}
echo " Staging Colibri agent (${ZOT_VERSION:-})..."
resolve_zot_paths
env \
ZOT_ARTIFACT_DIR="${_resolved_zot_artifact_dir}" \
ZOT_OPERATOR="clawdie" \
ZOT_DEEPSEEK_KEY="${ZOT_DEEPSEEK_KEY:-}" \
"${SCRIPT_DIR}/scripts/stage-zot-iso.sh" "${MOUNT_POINT}"
# zot state lives in the operator's home; own it as the operator.
if /usr/sbin/pw -R "${MOUNT_POINT}" usershow clawdie >/dev/null 2>&1; then
chroot "${MOUNT_POINT}" chown -R clawdie:clawdie /home/clawdie/.local 2>/dev/null || true
fi
if [ ! -x "${MOUNT_POINT}/usr/local/bin/zot" ]; then
echo "ERROR: zot binary missing from live image"
exit 1
fi
}
# Stage an on-image NVIDIA pkg repo (all branches) so clawdie_live_gpu can
# `pkg install` the detected branch at boot (NVIDIA_UNIVERSAL lane).
#
# FreeBSD-build-host step (authored on Linux; runs + must be validated on
# FreeBSD). Verify on the build host: (1) the `pkg fetch -o` layout matches what
# `pkg repo` expects, (2) the dependency closure is complete for offline boot
# install, (3) image size headroom, (4) `pkg install -r clawdie-nvidia` resolves
# from file:// at boot. See doc handoff.
install_nvidia_universal_repo() {
[ "${NVIDIA_UNIVERSAL:-NO}" = "YES" ] || {
echo " NVIDIA universal repo staging disabled (NVIDIA_UNIVERSAL=${NVIDIA_UNIVERSAL:-NO})"
return 0
}
echo " Staging universal NVIDIA repo (all branches) into image..."
_nv_repo="${MOUNT_POINT}/usr/local/share/clawdie/nvidia-repo"
mkdir -p "${_nv_repo}"
# Fetch all three branches + deps into the on-image repo dir, then build repo
# metadata. Uses pkg's own dependency resolution so the closure is complete.
if ! env ASSUME_ALWAYS_YES=yes pkg fetch -y -o "${_nv_repo}" -d \
nvidia-driver-390 nvidia-driver-470 nvidia-driver-580 nvidia-settings nvidia-xconfig; then
echo "ERROR: failed to fetch NVIDIA packages for universal repo"
exit 1
fi
if ! pkg repo "${_nv_repo}"; then
echo "ERROR: failed to generate NVIDIA universal repo metadata"
exit 1
fi
mkdir -p "${MOUNT_POINT}/usr/local/etc/pkg/repos"
cat > "${MOUNT_POINT}/usr/local/etc/pkg/repos/clawdie-nvidia.conf" <<'EOF'
# On-image NVIDIA package repo for the universal GPU lane. Consumed at boot by
# clawdie_live_gpu (nvidia-auto mode) via: pkg install -r clawdie-nvidia ...
clawdie-nvidia: {
url: "file:///usr/local/share/clawdie/nvidia-repo",
enabled: yes
}
EOF
echo " NVIDIA universal repo staged at /usr/local/share/clawdie/nvidia-repo"
}
install_live_npm_globals() {
echo " Installing bundled npm globals into live image..."
if [ ! -d "$NPM_GLOBALS_DIR" ]; then
echo " WARN: ${NPM_GLOBALS_DIR} not found — skipping live npm globals"
return 0
fi
_live_prefix="${MOUNT_POINT}/opt/clawdie/npm-global"
mkdir -p "${_live_prefix}/bin" "${_live_prefix}/lib" "${MOUNT_POINT}/usr/local/bin"
_live_npm_home="${CACHE_DIR}/npm-live-home"
rm -rf "${_live_npm_home}"
mkdir -p "${_live_npm_home}"
for _tgz in "${NPM_GLOBALS_DIR}"/*.tgz; do
[ -f "${_tgz}" ] || continue
# Live bundle policy: ship pi only via npm tarball. codex is covered
# separately via the FreeBSD pkg in pkg-list-live-operator.txt.
case "$(basename "${_tgz}")" in
*claude-code*)
echo " skip $(basename "${_tgz}") (broken on FreeBSD live image)"
continue
;;
*gemini-cli*)
echo " skip $(basename "${_tgz}") (not part of current live CLI policy)"
continue
;;
esac
echo " npm install -g $(basename "${_tgz}")"
env HOME="${_live_npm_home}" npm_config_prefix="${_live_prefix}" npm install -g --ignore-scripts --no-audit --no-fund "${_tgz}" >/dev/null
done
for _bin in "${_live_prefix}/bin"/*; do
[ -e "${_bin}" ] || continue
ln -sf "/opt/clawdie/npm-global/bin/$(basename "${_bin}")" "${MOUNT_POINT}/usr/local/bin/$(basename "${_bin}")"
done
patch_live_pi_footer_hostname "${_live_prefix}"
# npm runs as root on the build host and writes everything as
# root:wheel. configure_live_operator_session() used to do this
# chown but ran BEFORE install_live_npm_globals (caller order at
# bottom of build.sh), so it was chowning two empty directories
# before the package tree was written. Do it here, after the files
# exist, then validate — fail the build if ownership didn't take
# so this regression cannot ship silently again.
if ! chroot "${MOUNT_POINT}" /usr/sbin/pw usershow clawdie >/dev/null 2>&1; then
echo "ERROR: clawdie user missing — cannot chown npm-global tree"
exit 1
fi
chroot "${MOUNT_POINT}" chown -R clawdie:clawdie /opt/clawdie
_npm_owner=$(chroot "${MOUNT_POINT}" /usr/bin/stat -f '%Su:%Sg' /opt/clawdie/npm-global)
if [ "${_npm_owner}" != "clawdie:clawdie" ]; then
echo "ERROR: /opt/clawdie/npm-global ownership is ${_npm_owner}, expected clawdie:clawdie"
exit 1
fi
# Fix pi HTTP/2: the bundled http-dispatcher sets allowH2:false to
# work around a Node 26.0 undici decompression bug. We run Node 24
# with undici 8.3, where H2 works correctly. Enabling HTTP/2 gives
# HPACK header compression and multiplexing for API calls.
_pi_dispatcher="${_live_prefix}/lib/node_modules/@earendil-works/pi-coding-agent/dist/core/http-dispatcher.js"
if [ -f "${_pi_dispatcher}" ]; then
sed -i '' 's/allowH2: false/allowH2: true/' "${_pi_dispatcher}"
echo " pi http-dispatcher: allowH2 → true"
fi
}
patch_live_pi_footer_hostname() {
_npm_prefix="$1"
_patch_script="${SCRIPT_DIR}/firstboot/patch-pi-footer-hostname.js"
_patched=0
if [ ! -f "${_patch_script}" ]; then
echo "ERROR: Pi footer patch script missing: ${_patch_script}"
exit 1
fi
for _footer in \
"${_npm_prefix}/lib/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js" \
"${_npm_prefix}/lib/node_modules/@mariozechner/pi-coding-agent/dist/modes/interactive/components/footer.js"
do
[ -f "${_footer}" ] || continue
echo " patch Pi footer hostname: ${_footer}"
node "${_patch_script}" "${_footer}"
node --check "${_footer}"
_patched=1
done
if [ "${_patched}" -ne 1 ]; then
echo "ERROR: Pi footer.js not found under ${_npm_prefix}"
exit 1
fi
}
seed_live_ai_source_repo() {
_repo_src="$1"
_repo_name="$2"
_repo_dest="${MOUNT_POINT}/home/clawdie/ai/${_repo_name}"
if [ ! -d "${_repo_src}" ]; then
echo " Skipping AI source seed ${_repo_name}: ${_repo_src} not found"
return 0
fi
if ! command -v git >/dev/null 2>&1 || ! git -C "${_repo_src}" rev-parse --git-dir >/dev/null 2>&1; then
echo " Skipping AI source seed ${_repo_name}: not a git worktree"
return 0
fi
_repo_branch=$(git -C "${_repo_src}" symbolic-ref --short -q HEAD 2>/dev/null || echo detached)
_repo_commit=$(git -C "${_repo_src}" rev-parse HEAD 2>/dev/null || echo unknown)
_repo_origin=$(git -C "${_repo_src}" remote get-url origin 2>/dev/null || echo unknown)
_repo_src_real=$(cd "${_repo_src}" && pwd -P)
echo " Seeding AI source checkout: ${_repo_name} (${_repo_commit})"
rm -rf "${_repo_dest}"
if [ "${_repo_branch}" != "detached" ]; then
git clone --quiet --depth 1 --branch "${_repo_branch}" "file://${_repo_src_real}" "${_repo_dest}"
else
git clone --quiet "file://${_repo_src_real}" "${_repo_dest}"
git -C "${_repo_dest}" checkout --quiet --detach "${_repo_commit}"
fi
git -C "${_repo_dest}" remote set-url origin "${_repo_origin}" 2>/dev/null || true
printf '%s\n' '.clawdie-source.json' >> "${_repo_dest}/.git/info/exclude"
_repo_modified=false
if [ -n "$(git -C "${_repo_src}" status --porcelain 2>/dev/null)" ]; then
_repo_modified=true
fi
cat > "${_repo_dest}/.clawdie-source.json" <<EOF
{
"name": "$(json_escape "${_repo_name}")",
"source_path": "$(json_escape "${_repo_src}")",
"origin": "$(json_escape "${_repo_origin}")",
"branch": "$(json_escape "${_repo_branch}")",
"commit": "$(json_escape "${_repo_commit}")",
"modified_at_build": ${_repo_modified},
"iso_version": "$(json_escape "${ISO_VERSION}")",
"build_channel": "$(json_escape "${BUILD_CHANNEL}")",
"snapshot_kind": "shallow git checkout",
"snapshot_note": "shallow git checkout of HEAD; uncommitted changes and ignored/private files are not included"
}
EOF
}
install_live_ai_source_snapshots() {
echo " Installing live AI source checkouts..."
resolve_colibri_paths
mkdir -p "${MOUNT_POINT}/home/clawdie/ai"
cat > "${MOUNT_POINT}/home/clawdie/ai/README.txt" <<'EOF'
Clawdie live AI source checkouts
These directories are included so the operator can inspect the shipped source
beside the running system and fetch follow-up commits from Forgejo when network
access is available.
No API keys, .env files, SSH private keys, build caches, package caches, tmp/
directories, or uncommitted worktree changes are included. Each checkout keeps a
.git directory plus a .clawdie-source.json file recording the source remote,
branch, commit, modified state, ISO version, and build channel at image build time.
clawdie-ai (TypeScript) is no longer included — it is being phased out in favor
of the colibri (Rust) control plane. The `clawdie` name is retained as the brand
and bare-metal installer identity.
EOF
seed_live_ai_source_repo "${SCRIPT_DIR}" "clawdie-iso"
seed_live_ai_source_repo "${_resolved_colibri_repo}" "colibri"
# zot source so the live rebuild lane can rebuild the agent (Go) too, not
# just Colibri (Rust). Skipped automatically if the zot checkout is absent.
resolve_zot_paths
seed_live_ai_source_repo "${_resolved_zot_repo}" "zot"
chroot "${MOUNT_POINT}" chown -R clawdie:clawdie /home/clawdie/ai
}
configure_live_operator_session() {
echo " Configuring live operator session..."
mkdir -p "${MOUNT_POINT}/usr/local/bin"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-bootstrap-launch.sh" \
"${MOUNT_POINT}/usr/local/bin/clawdie-bootstrap-launch.sh"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-noblank-guard.sh" \
"${MOUNT_POINT}/usr/local/bin/clawdie-noblank-guard.sh"
install -m 0755 "${LIVE_SESSION_DIR}/hw-report" \
"${MOUNT_POINT}/usr/local/bin/hw-report"
# Vaultwarden secret bridge (bw -> .env). Always staged: needs only the
# bundled `bw` CLI and a 0600 bootstrap drop; absent bootstrap = no-op, so
# the manual setup wizard stays the floor. See docs/VAULTWARDEN-SETUP.md.
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-vault-fetch" \
"${MOUNT_POINT}/usr/local/bin/clawdie-vault-fetch"
# The stock FreeBSD memstick starts bsdinstall from /etc/rc.local before
# our graphical live session can own the USB workflow. Preserve a copy for
# debugging, but disable the automatic text installer.
if [ -f "${MOUNT_POINT}/etc/rc.local" ]; then
cp "${MOUNT_POINT}/etc/rc.local" "${MOUNT_POINT}/etc/rc.local.freebsd-installer"
rm -f "${MOUNT_POINT}/etc/rc.local"
fi
# The installer memstick may leave /etc/resolv.conf as a symlink into
# /tmp/bsdinstall_etc. The live image mounts /tmp as tmpfs, so that link
# becomes dangling on boot and DNS fails despite working DHCP/routing.
# Ship a real file, then let DHCP/NetworkMgr replace it at runtime.
if [ -L "${MOUNT_POINT}/etc/resolv.conf" ] && \
readlink "${MOUNT_POINT}/etc/resolv.conf" | grep -q '^/tmp/bsdinstall_etc/'; then
rm -f "${MOUNT_POINT}/etc/resolv.conf"
fi
if [ ! -e "${MOUNT_POINT}/etc/resolv.conf" ]; then
{
echo '# Created by the Clawdie live image build.'
echo '# DHCP/NetworkMgr may replace this file after interfaces come up.'
} > "${MOUNT_POINT}/etc/resolv.conf"
chmod 0644 "${MOUNT_POINT}/etc/resolv.conf"
fi
# python3 → 3.11 (FreeBSD's PYTHON_DEFAULT — don't fight it). python312 is
# also installed and stays available as python3.12 for anything needing newer.
# FreeBSD ships no bare python3 symlink; scripts use /usr/bin/env python3.
if [ ! -e "${MOUNT_POINT}/usr/local/bin/python3" ]; then
if [ -x "${MOUNT_POINT}/usr/local/bin/python3.11" ]; then
py_bin=python3.11
else
py_bin=$(ls "${MOUNT_POINT}/usr/local/bin"/python3.* 2>/dev/null \
| sed 's@.*/@@' | grep -E '^python3\.[0-9]+$' | sort -V | head -1)
fi
if [ -n "${py_bin}" ]; then
ln -sf "${py_bin}" "${MOUNT_POINT}/usr/local/bin/python3"
ln -sf "${py_bin}" "${MOUNT_POINT}/usr/local/bin/python"
fi
fi
mkdir -p "${MOUNT_POINT}/usr/local/etc/sddm.conf.d"
install -m 0644 "${LIVE_SESSION_DIR}/sddm.conf" \
"${MOUNT_POINT}/usr/local/etc/sddm.conf.d/50-clawdie-live.conf"
mkdir -p "${MOUNT_POINT}/var/lib/sddm" "${MOUNT_POINT}/var/log"
cat > "${MOUNT_POINT}/var/lib/sddm/state.conf" <<'EOF'
[Last]
Session=clawdie-xfce.desktop
User=clawdie
EOF
chmod 0755 "${MOUNT_POINT}/var/lib/sddm"
chmod 0644 "${MOUNT_POINT}/var/lib/sddm/state.conf"
if chroot "${MOUNT_POINT}" /usr/sbin/pw usershow sddm >/dev/null 2>&1; then
chroot "${MOUNT_POINT}" chown -R sddm:sddm /var/lib/sddm
fi
mkdir -p "${MOUNT_POINT}/usr/local/share/xsessions"
install -m 0644 "${LIVE_SESSION_DIR}/clawdie-xfce.desktop" \
"${MOUNT_POINT}/usr/local/share/xsessions/clawdie-xfce.desktop"
# Strip any Wayland session .desktop files dropped by transitive
# packages. The operator USB has no working Wayland compositor
# (XFCE on FreeBSD is X11-only here), so offering a Wayland entry
# in the SDDM greeter just hands the operator a non-booting session.
if [ -d "${MOUNT_POINT}/usr/local/share/wayland-sessions" ]; then
rm -f "${MOUNT_POINT}/usr/local/share/wayland-sessions/"*.desktop
rmdir "${MOUNT_POINT}/usr/local/share/wayland-sessions" 2>/dev/null || true
fi
mkdir -p "${MOUNT_POINT}/usr/local/etc/xdg/autostart"
install -m 0644 "${LIVE_SESSION_DIR}/autostart/clawdie-bootstrap.desktop" \
"${MOUNT_POINT}/usr/local/etc/xdg/autostart/clawdie-bootstrap.desktop"
install -m 0644 "${LIVE_SESSION_DIR}/autostart/clawdie-noblank-guard.desktop" \
"${MOUNT_POINT}/usr/local/etc/xdg/autostart/clawdie-noblank-guard.desktop"
install -m 0644 "${LIVE_SESSION_DIR}/autostart/volumeicon.desktop" \
"${MOUNT_POINT}/usr/local/etc/xdg/autostart/volumeicon.desktop"
mkdir -p "${MOUNT_POINT}/usr/local/etc/X11/xorg.conf.d"
install -m 0644 "${LIVE_SESSION_DIR}/xorg.conf.d/30-keyboard.conf" \
"${MOUNT_POINT}/usr/local/etc/X11/xorg.conf.d/30-keyboard.conf"
install -m 0644 "${LIVE_SESSION_DIR}/xorg.conf.d/40-clawdie-noblank.conf" \
"${MOUNT_POINT}/usr/local/etc/X11/xorg.conf.d/40-clawdie-noblank.conf"
mkdir -p "${MOUNT_POINT}/usr/local/etc/rc.d"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-live-gpu" \
"${MOUNT_POINT}/usr/local/etc/rc.d/clawdie_live_gpu"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-live-wifi" \
"${MOUNT_POINT}/usr/local/etc/rc.d/clawdie_live_wifi"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-live-seed" \
"${MOUNT_POINT}/usr/local/etc/rc.d/clawdie_live_seed"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-live-resolver" \
"${MOUNT_POINT}/usr/local/etc/rc.d/clawdie_live_resolver"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-live-audio" \
"${MOUNT_POINT}/usr/local/etc/rc.d/clawdie_live_audio"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-live-power" \
"${MOUNT_POINT}/usr/local/etc/rc.d/clawdie_live_power"
mkdir -p "${MOUNT_POINT}/usr/local/etc/polkit-1/rules.d"
install -m 0644 "${LIVE_SESSION_DIR}/49-clawdie-power.rules" \
"${MOUNT_POINT}/usr/local/etc/polkit-1/rules.d/49-clawdie-power.rules"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-xfce-session" \
"${MOUNT_POINT}/usr/local/bin/clawdie-xfce-session"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-xfce-session-inner" \
"${MOUNT_POINT}/usr/local/bin/clawdie-xfce-session-inner"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-live-touchpad-guard" \
"${MOUNT_POINT}/usr/local/bin/clawdie-live-touchpad-guard"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-startx" \
"${MOUNT_POINT}/usr/local/bin/clawdie-startx"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-startx" \
"${MOUNT_POINT}/usr/local/bin/clawdie-gui"
for _xfce_xdg_dir in \
"${MOUNT_POINT}/usr/local/etc" \
"${MOUNT_POINT}/usr/local/etc/xdg" \
"${MOUNT_POINT}/usr/local/etc/xdg/xfce4"; do
if [ ! -d "$_xfce_xdg_dir" ]; then
echo "ERROR: XFCE XDG directory missing from live image: ${_xfce_xdg_dir#${MOUNT_POINT}}"
exit 1
fi
chmod 0755 "$_xfce_xdg_dir"
done
if [ -f "${MOUNT_POINT}/usr/local/etc/xdg/xfce4/xinitrc" ]; then
chmod 0755 "${MOUNT_POINT}/usr/local/etc/xdg/xfce4/xinitrc"
else
echo "ERROR: XFCE xinitrc missing from live image"
exit 1
fi
if [ ! -x "${MOUNT_POINT}/usr/local/bin/xinit" ] || [ ! -x "${MOUNT_POINT}/usr/local/bin/startx" ]; then
echo "ERROR: xinit/startx missing from live image"
exit 1
fi
if [ ! -x "${MOUNT_POINT}/usr/local/bin/clawdie-startx" ]; then
echo "ERROR: clawdie-startx rescue launcher missing from live image"
exit 1
fi
if [ ! -x "${MOUNT_POINT}/usr/local/bin/bash" ]; then
echo "ERROR: bash missing from live image"
exit 1
fi
if [ ! -x "${MOUNT_POINT}/usr/local/bin/zsh" ]; then
echo "ERROR: zsh missing from live image"
exit 1
fi
if ! grep -qx '/usr/local/bin/bash' "${MOUNT_POINT}/etc/shells" 2>/dev/null; then
printf '%s\n' '/usr/local/bin/bash' >> "${MOUNT_POINT}/etc/shells"
fi
if ! grep -qx '/usr/local/bin/zsh' "${MOUNT_POINT}/etc/shells" 2>/dev/null; then
printf '%s\n' '/usr/local/bin/zsh' >> "${MOUNT_POINT}/etc/shells"
fi
if ! /usr/sbin/pw -R "$MOUNT_POINT" usershow clawdie >/dev/null 2>&1; then
/usr/sbin/pw -R "$MOUNT_POINT" useradd clawdie \
-m \
-s /usr/local/bin/bash \
-c "Clawdie Operator"
fi
/usr/sbin/pw -R "$MOUNT_POINT" usermod clawdie -s /usr/local/bin/bash
/usr/sbin/pw -R "$MOUNT_POINT" groupmod wheel -m clawdie
if /usr/sbin/pw -R "$MOUNT_POINT" groupshow video >/dev/null 2>&1; then
/usr/sbin/pw -R "$MOUNT_POINT" groupmod video -m clawdie
fi
CLAWDIE_UID="$(/usr/sbin/pw -R "$MOUNT_POINT" usershow clawdie | cut -d: -f3)"
# NOTE: chown of /opt/clawdie/npm-global was moved into
# install_live_npm_globals (called later in the build). The previous
# chown here ran before npm globals were installed and silently
# chowned two empty directories, leaving the package tree root:wheel
# at runtime. Do not re-introduce a chown here.
if [ -n "${CLAWDIE_USER_PASSWORD:-}" ]; then
printf '%s\n' "${CLAWDIE_USER_PASSWORD}" | /usr/sbin/pw -R "$MOUNT_POINT" usermod clawdie -h 0
fi
# Do not force vendor-specific DRM firmware or KMS drivers through
# loader.conf. The live image must boot Intel, AMD, VMware, NVIDIA and
# fallback framebuffer hosts from the same rootfs. GPU KMS is selected at
# boot by /usr/local/etc/rc.d/clawdie_live_gpu after inspecting display PCI
# vendor IDs.
# Live root is on USB. Avoid USB autosuspend/resume surprises while the
# root filesystem is attached to the same bus.
set_config_line "${MOUNT_POINT}/boot/loader.conf" 'hw.usb.no_suspend="1"'
# The stock installer memstick brands the loader menu as "Installer".
# This artifact is an operator live USB, so use generic FreeBSD branding
# plus an explicit Clawdie live prompt.
set_config_line "${MOUNT_POINT}/boot/loader.conf" 'loader_brand="orb"'
set_config_line "${MOUNT_POINT}/boot/loader.conf" 'loader_menu_title="Clawdie Operator USB"'
set_config_line "${MOUNT_POINT}/boot/loader.conf" 'loader_menu_multi_user_prompt="Start Clawdie Operator USB"'
# Enable FreeBSD mac_do for kernel-enforced privilege escalation.
# Base system only — no sudo package needed. Wheel group members can
# become root via mdo(1). mdo -u root changes the primary gid to wheel,
# so the target rule must explicitly allow gid=0 as well as uid=0.
set_config_line "${MOUNT_POINT}/boot/loader.conf" 'mac_do_load="YES"'
set_config_line "${MOUNT_POINT}/etc/sysctl.conf" 'security.mac.do.rules=gid=0>uid=0,gid=0,+gid=*'
if ! grep -q '^\[clawdie_live=' "${MOUNT_POINT}/etc/devfs.rules" 2>/dev/null; then
cat >> "${MOUNT_POINT}/etc/devfs.rules" <<'EOF'
[clawdie_live=10]
add path 'dri' mode 0755 group video
add path 'dri/*' mode 0660 group video
add path 'drm/*' mode 0660 group video
EOF
fi
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'devfs_system_ruleset="clawdie_live"'
_nm_autostart="${MOUNT_POINT}/usr/local/etc/xdg/autostart/networkmgr.desktop"
if [ ! -f "$_nm_autostart" ]; then
echo "ERROR: networkmgr.desktop missing from live image"
exit 1
fi
if /usr/local/sbin/pkg -r "$MOUNT_POINT" info -e sudo 2>/dev/null; then
echo "ERROR: sudo is installed in the live image"
exit 1
fi
if [ -e "${MOUNT_POINT}/usr/local/etc/sudoers.d/networkmgr" ]; then
echo "ERROR: NetworkMgr sudoers policy is still present in the live image"
exit 1
fi
if ! grep -q 'Exec=mdo -u root networkmgr' "$_nm_autostart"; then
echo "ERROR: networkmgr.desktop does not use mdo -u root networkmgr"
exit 1
fi
if /usr/local/sbin/pkg -r "$MOUNT_POINT" info -d networkmgr 2>/dev/null | grep -q '^.*sudo-'; then
echo "ERROR: networkmgr still depends on sudo in the live image"
exit 1
fi
mkdir -p "${MOUNT_POINT}/home/clawdie" "${MOUNT_POINT}/etc/profile.d" "${MOUNT_POINT}/etc/skel"
# Install the canonical npm-global profile snippet (shared with agent-jail-bootstrap.sh).
# The snippet lives in colibri; resolve its repo the same way every other
# colibri consumer here does so COLIBRI_REPO overrides are honored.
resolve_colibri_paths
_npm_profile_src="${_resolved_colibri_repo}/packaging/freebsd/clawdie-npm-profile.sh"
if [ ! -f "${_npm_profile_src}" ]; then
echo "ERROR: shared npm profile snippet missing: ${_npm_profile_src}"
echo " It ships in colibri packaging/freebsd/. Set COLIBRI_REPO=/path/to/colibri."
exit 1
fi
{
printf 'NPM_PREFIX="/opt/clawdie/npm-global"\n'
cat "${_npm_profile_src}"
} > "${MOUNT_POINT}/etc/profile.d/clawdie-npm.sh"
chmod 0644 "${MOUNT_POINT}/etc/profile.d/clawdie-npm.sh"
# ISO-specific environment (PATH, locale, colibri socket). Sources the
# shared npm snippet rather than duplicating it.
cat > "${MOUNT_POINT}/etc/profile.d/clawdie.sh" <<'EOF'
# Clawdie live operator environment.
_clawdie_base_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
_clawdie_path_has() {
case ":${PATH:-}:" in
*:"$1":*) return 0 ;;
*) return 1 ;;
esac
}
export LANG="${LANG:-en_US.UTF-8}"
export LC_ALL="${LC_ALL:-en_US.UTF-8}"
if [ -z "${PATH:-}" ]; then
PATH="${_clawdie_base_path}"
else
for _clawdie_dir in /usr/local/sbin /usr/local/bin /usr/sbin /usr/bin /sbin /bin; do
_clawdie_path_has "${_clawdie_dir}" || PATH="${PATH}:${_clawdie_dir}"
done
fi
# npm-global bin + npm config — canonical snippet shared with agent jails
[ -r /etc/profile.d/clawdie-npm.sh ] && . /etc/profile.d/clawdie-npm.sh
export PATH
# Colibri daemon socket — needed for 'colibri' CLI without --socket
export COLIBRI_DAEMON_SOCKET="/var/run/colibri/colibri.sock"
unset _clawdie_base_path
unset -f _clawdie_path_has 2>/dev/null || true
EOF
chmod 0644 "${MOUNT_POINT}/etc/profile.d/clawdie.sh"
cat > "${MOUNT_POINT}/home/clawdie/.profile" <<'EOF'
# Clawdie operator POSIX shell profile.
[ -r /etc/profile.d/clawdie.sh ] && . /etc/profile.d/clawdie.sh
EOF
cat > "${MOUNT_POINT}/home/clawdie/.bash_profile" <<'EOF'
# Clawdie operator bash login profile.
[ -r /etc/profile ] && . /etc/profile
[ -L "${HOME}/.cache" ] && {
mkdir -p /tmp/clawdie/cache 2>/dev/null || true
chown "$(id -u):$(id -g)" /tmp/clawdie /tmp/clawdie/cache 2>/dev/null || true
chmod 0700 /tmp/clawdie /tmp/clawdie/cache 2>/dev/null || true
}
[ -r "${HOME}/.bashrc" ] && . "${HOME}/.bashrc"
EOF
cat > "${MOUNT_POINT}/home/clawdie/.bashrc" <<'EOF'
# Clawdie operator interactive bash profile.
[ -r /etc/profile.d/clawdie.sh ] && . /etc/profile.d/clawdie.sh
if [ -n "${PS1:-}" ]; then
export HISTFILE="${HISTFILE:-/tmp/clawdie/bash_history}"
mkdir -p /tmp/clawdie 2>/dev/null || true
fi
EOF
cat > "${MOUNT_POINT}/home/clawdie/.zprofile" <<'EOF'
# Clawdie operator zsh login profile.
[ -r /etc/profile ] && . /etc/profile
[ -L "${HOME}/.cache" ] && {
mkdir -p /tmp/clawdie/cache 2>/dev/null || true
chown "$(id -u):$(id -g)" /tmp/clawdie /tmp/clawdie/cache 2>/dev/null || true
chmod 0700 /tmp/clawdie /tmp/clawdie/cache 2>/dev/null || true
}
[ -r /etc/profile.d/clawdie.sh ] && . /etc/profile.d/clawdie.sh
[ -r "${HOME}/.zshrc" ] && . "${HOME}/.zshrc"
EOF
cat > "${MOUNT_POINT}/home/clawdie/.zshrc" <<'EOF'
# Clawdie operator interactive zsh profile.
[ -r /etc/profile.d/clawdie.sh ] && . /etc/profile.d/clawdie.sh
export HISTFILE="${HISTFILE:-/tmp/clawdie/zsh_history}"
mkdir -p /tmp/clawdie 2>/dev/null || true
# Keep zsh optional, but ready: use packaged oh-my-zsh when present.
if [ -d /usr/local/share/ohmyzsh ]; then
export ZSH="/usr/local/share/ohmyzsh"
elif [ -d /usr/local/share/oh-my-zsh ]; then
export ZSH="/usr/local/share/oh-my-zsh"
fi
if [ -n "${ZSH:-}" ] && [ -r "${ZSH}/oh-my-zsh.sh" ]; then
ZSH_THEME="${ZSH_THEME:-robbyrussell}"
plugins=(git)
source "${ZSH}/oh-my-zsh.sh"
fi
EOF
cat > "${MOUNT_POINT}/home/clawdie/.tmux.conf" <<'EOF'
# Clawdie operator tmux defaults.
# Pi uses modified Enter/key chords; tmux must pass CSI-u extended keys or Pi
# warns that modified Enter keys may not work.
set -g extended-keys on
set -g extended-keys-format csi-u
set -g escape-time 10
set -g mouse on
set -g base-index 1
setw -g pane-base-index 1
set -g renumber-windows on
EOF
mkdir -p "${MOUNT_POINT}/usr/local/etc"
cp "${MOUNT_POINT}/home/clawdie/.tmux.conf" "${MOUNT_POINT}/usr/local/etc/tmux.conf"
cp "${MOUNT_POINT}/home/clawdie/.profile" "${MOUNT_POINT}/etc/skel/.profile"
cp "${MOUNT_POINT}/home/clawdie/.bash_profile" "${MOUNT_POINT}/etc/skel/.bash_profile"
cp "${MOUNT_POINT}/home/clawdie/.bashrc" "${MOUNT_POINT}/etc/skel/.bashrc"
cp "${MOUNT_POINT}/home/clawdie/.zprofile" "${MOUNT_POINT}/etc/skel/.zprofile"
cp "${MOUNT_POINT}/home/clawdie/.zshrc" "${MOUNT_POINT}/etc/skel/.zshrc"
cp "${MOUNT_POINT}/home/clawdie/.tmux.conf" "${MOUNT_POINT}/etc/skel/.tmux.conf"
chmod 0644 \
"${MOUNT_POINT}/home/clawdie/.profile" \
"${MOUNT_POINT}/home/clawdie/.bash_profile" \
"${MOUNT_POINT}/home/clawdie/.bashrc" \
"${MOUNT_POINT}/home/clawdie/.zprofile" \
"${MOUNT_POINT}/home/clawdie/.zshrc" \
"${MOUNT_POINT}/home/clawdie/.tmux.conf" \
"${MOUNT_POINT}/usr/local/etc/tmux.conf" \
"${MOUNT_POINT}/etc/skel/.profile" \
"${MOUNT_POINT}/etc/skel/.bash_profile" \
"${MOUNT_POINT}/etc/skel/.bashrc" \
"${MOUNT_POINT}/etc/skel/.zprofile" \
"${MOUNT_POINT}/etc/skel/.zshrc" \
"${MOUNT_POINT}/etc/skel/.tmux.conf"
install -m 0755 "${LIVE_SESSION_DIR}/xprofile" \
"${MOUNT_POINT}/home/clawdie/.xprofile"
cat > "${MOUNT_POINT}/home/clawdie/.xinitrc" <<'EOF'
#!/bin/sh
exec /usr/local/bin/clawdie-xfce-session
EOF
mkdir -p "${MOUNT_POINT}/home/clawdie/.config/xfce4" "${MOUNT_POINT}/etc/skel/.config/xfce4"
cp "${MOUNT_POINT}/home/clawdie/.xinitrc" "${MOUNT_POINT}/home/clawdie/.config/xfce4/xinitrc"
cp "${MOUNT_POINT}/home/clawdie/.xinitrc" "${MOUNT_POINT}/etc/skel/.xinitrc"
cp "${MOUNT_POINT}/home/clawdie/.xinitrc" "${MOUNT_POINT}/etc/skel/.config/xfce4/xinitrc"
# Pre-stage mother connectivity key if present on the build host.
# Lets the live USB node SSH into the mother server (osa) without
# manual key exchange. Public key is already in mother authorized_keys.
_mother_key_src="/home/clawdie/.ssh/osa-mother-2026"
if [ -f "${_mother_key_src}" ]; then
[ "${BUILD_CHANNEL}" = "release" ] && { echo "ERROR: refusing to bake mother SSH key into a release image"; exit 1; }
mkdir -p "${MOUNT_POINT}/home/clawdie/.ssh"
cp "${_mother_key_src}" "${MOUNT_POINT}/home/clawdie/.ssh/osa-mother-2026"
chmod 0600 "${MOUNT_POINT}/home/clawdie/.ssh/osa-mother-2026"
echo " Staged mother SSH key for USB→mother connectivity."
fi
chmod 0755 \
"${MOUNT_POINT}/home/clawdie/.xinitrc" \
"${MOUNT_POINT}/home/clawdie/.config/xfce4/xinitrc" \
"${MOUNT_POINT}/etc/skel/.xinitrc" \
"${MOUNT_POINT}/etc/skel/.config/xfce4/xinitrc"
if [ -f "${MOUNT_POINT}/usr/local/etc/xdg/tumbler/tumbler.rc" ]; then
mkdir -p "${MOUNT_POINT}/home/clawdie/.config/tumbler" "${MOUNT_POINT}/etc/skel/.config/tumbler"
install -m 0644 "${MOUNT_POINT}/usr/local/etc/xdg/tumbler/tumbler.rc" \
"${MOUNT_POINT}/home/clawdie/.config/tumbler/tumbler.rc"
install -m 0644 "${MOUNT_POINT}/usr/local/etc/xdg/tumbler/tumbler.rc" \
"${MOUNT_POINT}/etc/skel/.config/tumbler/tumbler.rc"
fi
chroot "$MOUNT_POINT" chown -R clawdie:clawdie /home/clawdie
mkdir -p "${MOUNT_POINT}/usr/local/share/applications"
install -m 0644 "${LIVE_SESSION_DIR}/clawdie-bootstrap.desktop" \
"${MOUNT_POINT}/usr/local/share/applications/Clawdie Start Here.desktop"
install -m 0644 "${LIVE_SESSION_DIR}/colibri-dashboard.desktop" \
"${MOUNT_POINT}/usr/local/share/applications/Colibri Dashboard.desktop"
install -m 0644 "${LIVE_SESSION_DIR}/hw-report.desktop" \
"${MOUNT_POINT}/usr/local/share/applications/Clawdie Hardware Report.desktop"
mkdir -p "${MOUNT_POINT}/home/clawdie/Desktop"
install -m 0644 "${LIVE_SESSION_DIR}/clawdie-bootstrap.desktop" \
"${MOUNT_POINT}/home/clawdie/Desktop/Clawdie Start Here.desktop"
install -m 0644 "${LIVE_SESSION_DIR}/colibri-dashboard.desktop" \
"${MOUNT_POINT}/home/clawdie/Desktop/Colibri Dashboard.desktop"
install -m 0644 "${LIVE_SESSION_DIR}/hw-report.desktop" \
"${MOUNT_POINT}/home/clawdie/Desktop/Clawdie Hardware Report.desktop"
mkdir -p "${MOUNT_POINT}/usr/local/share/clawdie-iso"
install -m 0644 "${LIVE_SESSION_DIR}/START-HERE.txt" \
"${MOUNT_POINT}/usr/local/share/clawdie-iso/START-HERE.txt"
install -m 0755 "${LIVE_SESSION_DIR}/colibri-panel-indicator.sh" \
"${MOUNT_POINT}/usr/local/bin/colibri-panel-indicator"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-wallpaper-gen.sh" \
"${MOUNT_POINT}/usr/local/bin/clawdie-wallpaper-gen"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-join-hive.sh" \
"${MOUNT_POINT}/usr/local/bin/clawdie-join-hive"
install -m 0644 "${LIVE_SESSION_DIR}/clawdie-join-hive.desktop" \
"${MOUNT_POINT}/usr/local/share/applications/Clawdie Join Hive.desktop"
install -m 0644 "${LIVE_SESSION_DIR}/clawdie-join-hive.desktop" \
"${MOUNT_POINT}/home/clawdie/Desktop/Join Hive.desktop"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-enable-mother.sh" \
"${MOUNT_POINT}/usr/local/bin/clawdie-enable-mother"
install -m 0644 "${LIVE_SESSION_DIR}/clawdie-enable-mother.desktop" \
"${MOUNT_POINT}/usr/local/share/applications/Clawdie Enable Mother.desktop"
install -m 0644 "${LIVE_SESSION_DIR}/clawdie-enable-mother.desktop" \
"${MOUNT_POINT}/home/clawdie/Desktop/Enable Mother Link.desktop"
mkdir -p "${MOUNT_POINT}/usr/local/share/clawdie-iso/bootstrap"
install -m 0644 "${LIVE_SESSION_DIR}/bootstrap.html" \
"${MOUNT_POINT}/usr/local/share/clawdie-iso/bootstrap/index.html"
mkdir -p "${MOUNT_POINT}/usr/local/share/clawdie-iso/seed"
install -m 0644 "${LIVE_SESSION_DIR}/clawdie-live-seed.README.txt" \
"${MOUNT_POINT}/usr/local/share/clawdie-iso/seed/README.txt"
if [ -d "${LIVE_SESSION_DIR}/mcp-examples" ]; then
mkdir -p "${MOUNT_POINT}/usr/local/share/clawdie-iso/mcp-examples"
cp -R "${LIVE_SESSION_DIR}/mcp-examples/." \
"${MOUNT_POINT}/usr/local/share/clawdie-iso/mcp-examples/"
find "${MOUNT_POINT}/usr/local/share/clawdie-iso/mcp-examples" \
-type f -exec chmod 0644 {} +
fi
chroot "$MOUNT_POINT" chown -R clawdie:clawdie /home/clawdie/Desktop
chmod 0755 "${MOUNT_POINT}/home/clawdie/Desktop"
chmod 0644 \
"${MOUNT_POINT}/home/clawdie/Desktop/Clawdie Start Here.desktop" \
"${MOUNT_POINT}/home/clawdie/Desktop/Colibri Dashboard.desktop" \
"${MOUNT_POINT}/home/clawdie/Desktop/Clawdie Hardware Report.desktop"
install_live_ai_source_snapshots
# The stock FreeBSD install memstick is intentionally read-only. This live
# operator USB needs a writable root so SDDM, Xorg, NetworkMgr, logs, and
# operator diagnostics can create runtime state on the flashed stick.
set_fstab_line "${MOUNT_POINT}/etc/fstab" '^[[:space:]]*/dev/ufs/FreeBSD_Install[[:space:]]+/[[:space:]]+ufs[[:space:]]' '/dev/ufs/FreeBSD_Install / ufs rw,noatime 1 1'
if ! grep -Eq '^[[:space:]]*/dev/ufs/FreeBSD_Install[[:space:]]+/[[:space:]]+ufs[[:space:]]+([^[:space:]]*,)?rw(,|[[:space:]])' "${MOUNT_POINT}/etc/fstab"; then
echo "ERROR: live USB root filesystem is not configured read-write in /etc/fstab"
exit 1
fi
ensure_fstab_line "${MOUNT_POINT}/etc/fstab" '^[[:space:]]*proc[[:space:]]+/proc[[:space:]]+procfs[[:space:]]' 'proc /proc procfs rw 0 0'
ensure_fstab_line "${MOUNT_POINT}/etc/fstab" '^[[:space:]]*tmpfs[[:space:]]+/tmp[[:space:]]+tmpfs[[:space:]]' 'tmpfs /tmp tmpfs rw,mode=1777 0 0'
ensure_fstab_line "${MOUNT_POINT}/etc/fstab" '^[[:space:]]*tmpfs[[:space:]]+/var/log[[:space:]]+tmpfs[[:space:]]' 'tmpfs /var/log tmpfs rw,mode=755 0 0'
# No swap on the live USB. USB write wear, unpredictable behaviour, and
# it would defeat the tmpfs work above (under memory pressure the kernel
# would page tmpfs contents to swap, putting writes back on the stick).
# On an 8 GB RAM baseline, "fail visibly under memory pressure" is the
# right behaviour, not "silently grind the USB". Strip any inherited
# swap entries and assert clean.
sed -i '' '/^[[:space:]]*[^#].*[[:space:]]swap[[:space:]]/d' "${MOUNT_POINT}/etc/fstab"
if grep -qE '^[[:space:]]*[^#].*[[:space:]]swap[[:space:]]' "${MOUNT_POINT}/etc/fstab"; then
echo "ERROR: swap entry remains in live image /etc/fstab"
exit 1
fi
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'root_rw_mount="YES"'
# FreeBSD default dumpdev_enable="AUTO" scans for swap on boot to use as
# the kernel crash-dump device. With no swap and no desire to crash-dump
# to the USB anyway, set NO explicitly so the scan never runs.
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'dumpdev_enable="NO"'
# The stock installer memstick overlays /tmp and /var with tiny tmpfs
# filesystems. That hides image content such as /var/lib/xkb and leaves
# too little runtime space for a graphical desktop. The operator USB has a
# writable root filesystem, so keep /tmp and /var on the root UFS image.
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'tmpmfs="NO"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'varmfs="NO"'
if ! grep -Eq '^root_rw_mount="YES"' "${MOUNT_POINT}/etc/rc.conf"; then
echo "ERROR: live USB root_rw_mount is not enabled in /etc/rc.conf"
exit 1
fi
if ! grep -Eq '^tmpmfs="NO"' "${MOUNT_POINT}/etc/rc.conf" || ! grep -Eq '^varmfs="NO"' "${MOUNT_POINT}/etc/rc.conf"; then
echo "ERROR: live USB tmpmfs/varmfs overlays are not disabled in /etc/rc.conf"
exit 1
fi
# Keep a real hostname so startx/xauth do not derive invalid display names
# such as bare :0 or /unix:0 on the installer-derived "Amnesiac" profile.
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'hostname="usb.clawdie.home.arpa"'
if ! grep -Eq '^hostname="usb.clawdie.home.arpa"' "${MOUNT_POINT}/etc/rc.conf"; then
echo "ERROR: live USB hostname is not configured in /etc/rc.conf"
exit 1
fi
ensure_fstab_line "${MOUNT_POINT}/etc/hosts" '^[[:space:]]*127\.0\.0\.1[[:space:]].*clawdie-live' '127.0.0.1 clawdie-live'
ensure_fstab_line "${MOUNT_POINT}/etc/hosts" '^[[:space:]]*::1[[:space:]].*clawdie-live' '::1 clawdie-live'
configure_nsswitch_hosts_line "${MOUNT_POINT}/etc/nsswitch.conf"
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'dbus_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'sshd_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'tailscaled_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'avahi_daemon_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'sddm_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'display_manager="sddm"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_gpu_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_gpu_mode="auto"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_gpu_nvidia_branch=""'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_wifi_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_seed_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_resolver_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_audio_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_power_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'webcamd_enable="YES"'
# CPU power management. powerdxx is a drop-in replacement for base
# powerd with moving-average load sampling and better multi-core
# behavior (matters on Ryzen 7 5700U-class chips). Do NOT also set
# powerd_enable — both daemons use the same pidfile and one would
# fail to start. Base powerd defaults to NO when unset, so leaving
# it unset is the right way to keep it off.
# The bigger battery lever is the C-state pair. Use C3 rather than Cmax
# on the live USB because root is USB-backed; deeper C6/C7-style states
# can expose USB controller resume bugs on some Ryzen mobile systems.
# Installed systems can choose Cmax once root is on internal storage.
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'powerdxx_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'powerdxx_flags="-a hiadaptive -b adaptive -n adaptive"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'performance_cx_lowest="C3"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'economy_cx_lowest="C3"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'linux_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'zfs_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'pf_enable="YES"'
# GPU KMS is selected at boot by /usr/local/etc/rc.d/clawdie_live_gpu so
# the live USB does not force the Intel driver on non-Intel hardware.
# kld_list breakdown:
# linux / linux64 — Linuxulator binary compatibility.
# zfs — runtime ZFS support (pool import on demand).
# cuse — userspace character devices; required by webcamd.
# hidbus / iichid — HID-over-I²C bridge for modern touchpads / keyboards.
# hms / hmt / hkbd — HID mouse / multitouch / keyboard class drivers.
# acpi_video — backlight control sysctls (hw.acpi.video.lcd0.brightness).
append_rc_list_values "${MOUNT_POINT}/etc/rc.conf" kld_list \
linux linux64 zfs \
cuse \
hidbus iichid hms hmt hkbd \
acpi_video
case "${GPU_DRIVER:-}" in
nvidia-390|nvidia-470|nvidia-590)
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_gpu_mode="nvidia"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" "clawdie_live_gpu_nvidia_branch=\"${GPU_DRIVER#nvidia-}\""
append_rc_list_values "${MOUNT_POINT}/etc/rc.conf" kld_list \
nvidia-modeset nvidia
;;
esac
# Universal NVIDIA lane: no branch is baked; clawdie_live_gpu installs the
# matching one from the on-image repo at boot. Overrides the mode set above.
# kld_list intentionally does NOT preload nvidia (it is not installed yet at
# first boot); the rc.d service kldloads it after install.
if [ "${NVIDIA_UNIVERSAL:-NO}" = "YES" ]; then
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_gpu_mode="nvidia-auto"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_gpu_nvidia_branch=""'
fi
mkdir -p "${MOUNT_POINT}/etc/ssh/sshd_config.d"
install -m 0644 "${LIVE_SESSION_DIR}/sshd-live.conf" \
"${MOUNT_POINT}/etc/ssh/sshd_config.d/clawdie-live.conf"
ensure_sshd_include_line "${MOUNT_POINT}/etc/ssh/sshd_config"
rm -rf "${MOUNT_POINT}/home/clawdie/.ssh"
if [ -n "${SSH_PUBLIC_KEY:-}" ]; then
mkdir -p "${MOUNT_POINT}/home/clawdie/.ssh"
chmod 0700 "${MOUNT_POINT}/home/clawdie/.ssh"
printf '%s\n' "${SSH_PUBLIC_KEY}" > "${MOUNT_POINT}/home/clawdie/.ssh/authorized_keys"
chmod 0600 "${MOUNT_POINT}/home/clawdie/.ssh/authorized_keys"
chroot "${MOUNT_POINT}" chown -R clawdie:clawdie /home/clawdie/.ssh
fi
if grep -q '^sshd_enable="YES"' "${MOUNT_POINT}/etc/rc.conf"; then
_drop_in="${MOUNT_POINT}/etc/ssh/sshd_config.d/clawdie-live.conf"
if [ ! -s "$_drop_in" ]; then
echo "ERROR: sshd_enable=YES but ${_drop_in#${MOUNT_POINT}} missing or empty"
exit 1
fi
if ! grep -qE '^PasswordAuthentication[[:space:]]+no' "$_drop_in" \
|| ! grep -qE '^PermitRootLogin[[:space:]]+no' "$_drop_in" \
|| ! grep -qE '^KbdInteractiveAuthentication[[:space:]]+no' "$_drop_in"; then
echo "ERROR: sshd drop-in does not disable password, keyboard-interactive, and root login"
exit 1
fi
if ! grep -Eq '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/\*\.conf([[:space:]]|$)' "${MOUNT_POINT}/etc/ssh/sshd_config"; then
echo "ERROR: sshd_config does not include /etc/ssh/sshd_config.d/*.conf"
exit 1
fi
fi
install -m 0644 "${LIVE_SESSION_DIR}/pf-live.conf" "${MOUNT_POINT}/etc/pf.conf"
if grep -q '^[^#].*\<log\>' "${MOUNT_POINT}/etc/pf.conf"; then
echo "ERROR: live PF ruleset unexpectedly enables logging"
exit 1
fi
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-tailscale-up" \
"${MOUNT_POINT}/usr/local/etc/rc.d/clawdie_tailscale_up"
rm -f "${MOUNT_POINT}/var/lib/clawdie-iso/tailscale-authkey"
if [ -n "${TAILSCALE_AUTHKEY:-}" ]; then
mkdir -p "${MOUNT_POINT}/var/lib/clawdie-iso"
printf '%s\n' "${TAILSCALE_AUTHKEY}" > "${MOUNT_POINT}/var/lib/clawdie-iso/tailscale-authkey"
chmod 0600 "${MOUNT_POINT}/var/lib/clawdie-iso/tailscale-authkey"
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_tailscale_up_enable="YES"'
else
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_tailscale_up_enable="NO"'
fi
# Keep ZFS tools/modules ready on the live USB, but do not let a copied or
# stale zpool cache auto-import disks from the machine being rescued. The
# operator can still import pools explicitly with mdo when needed.
mkdir -p "${MOUNT_POINT}/etc/zfs" "${MOUNT_POINT}/boot/zfs"
rm -f "${MOUNT_POINT}/etc/zfs/zpool.cache" "${MOUNT_POINT}/boot/zfs/zpool.cache"
# Xorg's XKB compiler must be able to chdir into the XKB root and write a
# compiled keymap cache. The stock memstick/rootless-X path is unforgiving:
# if /tmp, /var/tmp, /var/lib/xkb, or any XKB parent directory is missing or
# not traversable, xkbcomp reports a misleading missing keycodes file.
mkdir -p "${MOUNT_POINT}/tmp" "${MOUNT_POINT}/var/tmp" "${MOUNT_POINT}/var/lib/xkb"
chmod 1777 "${MOUNT_POINT}/tmp" "${MOUNT_POINT}/var/tmp" "${MOUNT_POINT}/var/lib/xkb"
mkdir -p "${MOUNT_POINT}/var/run/user" "${MOUNT_POINT}/var/run/user/${CLAWDIE_UID}"
chroot "${MOUNT_POINT}" chown clawdie:clawdie "/var/run/user/${CLAWDIE_UID}"
chmod 0755 "${MOUNT_POINT}/var/run/user"
chmod 0700 "${MOUNT_POINT}/var/run/user/${CLAWDIE_UID}"
for _xkb_dir in \
"${MOUNT_POINT}/usr" \
"${MOUNT_POINT}/usr/local" \
"${MOUNT_POINT}/usr/local/share" \
"${MOUNT_POINT}/usr/local/share/X11" \
"${MOUNT_POINT}/usr/local/share/X11/xkb" \
"${MOUNT_POINT}/usr/local/share/X11/xkb/keycodes" \
"${MOUNT_POINT}/usr/local/share/X11/xkb/rules" \
"${MOUNT_POINT}/usr/local/share/X11/xkb/symbols" \
"${MOUNT_POINT}/usr/local/share/X11/xkb/types" \
"${MOUNT_POINT}/usr/local/share/X11/xkb/compat"; do
if [ ! -d "$_xkb_dir" ]; then
echo "ERROR: XKB directory missing from live image: ${_xkb_dir#${MOUNT_POINT}}"
exit 1
fi
chmod 0755 "$_xkb_dir"
done
if [ ! -f "${MOUNT_POINT}/usr/local/share/X11/xkb/keycodes/xfree86" ]; then
echo "ERROR: XKB keycodes missing from live image"
exit 1
fi
if [ ! -x "${MOUNT_POINT}/usr/local/bin/xkbcomp" ]; then
echo "ERROR: xkbcomp missing from live image"
exit 1
fi
_xkb_test="${CACHE_DIR}/xkb-default-test.xkb"
_xkb_out="${CACHE_DIR}/xkb-default-test.xkm"
cat > "$_xkb_test" <<'EOF'
xkb_keymap {
xkb_keycodes { include "xfree86+aliases(qwerty)" };
xkb_types { include "complete" };
xkb_compat { include "complete" };
xkb_symbols { include "pc+us+inet(evdev)" };
xkb_geometry { include "pc(pc105)" };
};
EOF
if ! "${MOUNT_POINT}/usr/local/bin/xkbcomp" -w 0 -R"${MOUNT_POINT}/usr/local/share/X11/xkb" "$_xkb_test" "$_xkb_out" >/dev/null 2>&1; then
echo "ERROR: XKB default keymap does not compile against the live image xkeyboard-config"
exit 1
fi
rm -f "$_xkb_test" "$_xkb_out"
_xkb_si_test="${CACHE_DIR}/xkb-si-test.xkb"
_xkb_si_out="${CACHE_DIR}/xkb-si-test.xkm"
cat > "$_xkb_si_test" <<'EOF'
xkb_keymap {
xkb_keycodes { include "xfree86+aliases(qwerty)" };
xkb_types { include "complete" };
xkb_compat { include "complete" };
xkb_symbols { include "pc+si+inet(evdev)" };
xkb_geometry { include "pc(pc105)" };
};
EOF
if ! "${MOUNT_POINT}/usr/local/bin/xkbcomp" -w 0 -R"${MOUNT_POINT}/usr/local/share/X11/xkb" "$_xkb_si_test" "$_xkb_si_out" >/dev/null 2>&1; then
echo "ERROR: Slovenian (si) XKB keymap does not compile — missing xkeyboard-config layout?"
exit 1
fi
rm -f "$_xkb_si_test" "$_xkb_si_out"
# Preseed XFCE panel layout into /etc/skel so the clawdie user gets it
# on first login. pw useradd -m copies /etc/skel into /home/clawdie, but
# the user was already created above, so copy directly into both locations.
PANEL_SKEL="${LIVE_SESSION_DIR}/panel-skel"
if [ -d "$PANEL_SKEL" ]; then
echo " Seeding XFCE panel layout..."
mkdir -p "${MOUNT_POINT}/usr/local/share/clawdie-iso/panel-skel"
cp -R "${PANEL_SKEL}/." "${MOUNT_POINT}/usr/local/share/clawdie-iso/panel-skel/"
mkdir -p "${MOUNT_POINT}/usr/local/etc/xdg/xfce4/xfconf/xfce-perchannel-xml"
cp "${PANEL_SKEL}/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-panel.xml" \
"${MOUNT_POINT}/usr/local/etc/xdg/xfce4/xfconf/xfce-perchannel-xml/xfce4-panel.xml"
for _xfconf_xml in xsettings.xml xfwm4.xml xfce4-desktop.xml xfce4-power-manager.xml displays.xml; do
if [ -f "${PANEL_SKEL}/.config/xfce4/xfconf/xfce-perchannel-xml/${_xfconf_xml}" ]; then
cp "${PANEL_SKEL}/.config/xfce4/xfconf/xfce-perchannel-xml/${_xfconf_xml}" \
"${MOUNT_POINT}/usr/local/etc/xdg/xfce4/xfconf/xfce-perchannel-xml/${_xfconf_xml}"
fi
done
# Install into /etc/skel for any future users
_skel_etc="${MOUNT_POINT}/etc/skel"
mkdir -p "${_skel_etc}/.config"
cp -R "${PANEL_SKEL}/.config/." "${_skel_etc}/.config/"
if [ -d "${PANEL_SKEL}/.local/share/applications" ]; then
mkdir -p "${_skel_etc}/.local/share/applications"
cp "${PANEL_SKEL}/.local/share/applications/mimeapps.list" \
"${_skel_etc}/.local/share/applications/" 2>/dev/null || true
fi
# Also copy directly into clawdie's home (user already exists)
mkdir -p "${MOUNT_POINT}/home/clawdie/.config"
cp -R "${PANEL_SKEL}/.config/." "${MOUNT_POINT}/home/clawdie/.config/"
if [ -d "${PANEL_SKEL}/.local/share/applications" ]; then
mkdir -p "${MOUNT_POINT}/home/clawdie/.local/share/applications"
cp "${PANEL_SKEL}/.local/share/applications/mimeapps.list" \
"${MOUNT_POINT}/home/clawdie/.local/share/applications/" 2>/dev/null || true
fi
chroot "$MOUNT_POINT" chown -R clawdie:clawdie /home/clawdie/.config
chroot "$MOUNT_POINT" chown -R clawdie:clawdie /home/clawdie/.local
chroot "$MOUNT_POINT" chown -R root:wheel /etc/skel
fi
_wallpapers_dir="${LIVE_SESSION_DIR}/wallpapers"
if [ -d "$_wallpapers_dir" ]; then
echo " Installing wallpapers..."
mkdir -p "${MOUNT_POINT}/usr/local/share/clawdie-iso/wallpapers"
cp "${_wallpapers_dir}"/* "${MOUNT_POINT}/usr/local/share/clawdie-iso/wallpapers/" 2>/dev/null || true
fi
# Brand icons (e.g. the Whisker Start-button triangle). Install both the
# raw files for direct diagnostics and hicolor theme names for XFCE plugins
# that do not reliably load absolute icon paths.
_icons_dir="${LIVE_SESSION_DIR}/icons"
if [ -d "$_icons_dir" ]; then
echo " Installing brand icons..."
mkdir -p \
"${MOUNT_POINT}/usr/local/share/clawdie-iso/icons" \
"${MOUNT_POINT}/usr/local/share/icons/hicolor/48x48/apps" \
"${MOUNT_POINT}/usr/local/share/icons/hicolor/64x64/apps" \
"${MOUNT_POINT}/usr/local/share/icons/hicolor/scalable/apps"
cp "${_icons_dir}"/* "${MOUNT_POINT}/usr/local/share/clawdie-iso/icons/" 2>/dev/null || true
if [ -f "${_icons_dir}/clawdie-start-48.png" ]; then
install -m 0644 "${_icons_dir}/clawdie-start-48.png" \
"${MOUNT_POINT}/usr/local/share/icons/hicolor/48x48/apps/clawdie-start.png"
fi
if [ -f "${_icons_dir}/clawdie-start.png" ]; then
install -m 0644 "${_icons_dir}/clawdie-start.png" \
"${MOUNT_POINT}/usr/local/share/icons/hicolor/64x64/apps/clawdie-start.png"
fi
if [ -f "${_icons_dir}/clawdie-start.svg" ]; then
install -m 0644 "${_icons_dir}/clawdie-start.svg" \
"${MOUNT_POINT}/usr/local/share/icons/hicolor/scalable/apps/clawdie-start.svg"
fi
if [ -x "${MOUNT_POINT}/usr/local/bin/gtk-update-icon-cache" ]; then
run_live_chroot /usr/local/bin/gtk-update-icon-cache -f -t /usr/local/share/icons/hicolor || true
fi
fi
rm -rf "${MOUNT_POINT}/home/clawdie/.cache" "${MOUNT_POINT}/etc/skel/.cache"
ln -s /tmp/clawdie/cache "${MOUNT_POINT}/home/clawdie/.cache"
ln -s /tmp/clawdie/cache "${MOUNT_POINT}/etc/skel/.cache"
chroot "${MOUNT_POINT}" chown -h clawdie:clawdie /home/clawdie/.cache
}
if [ "${BUILD_CHANNEL}" = "release" ]; then
check_release_gate
fi
preflight_colibri_artifacts
preflight_zot_artifacts
# --- step 1: fetch FreeBSD memstick ---
MEMSTICK="${CACHE_DIR}/FreeBSD-${FREEBSD_VERSION}-${FREEBSD_ARCH}-memstick.img"
if [ "$SKIP_MEMSTICK_FETCH" -eq 1 ]; then
if [ ! -f "$MEMSTICK" ]; then
echo "ERROR: cached FreeBSD memstick not found: ${MEMSTICK}"
echo "Rerun without --skip-memstick-fetch to download it."
exit 1
fi
if ! verify_memstick_cache; then
echo "ERROR: cached FreeBSD memstick failed checksum verification."
echo "Remove ${MEMSTICK} and rerun without --skip-memstick-fetch."
exit 1
fi
echo "==> [1/7] FreeBSD memstick cached; fetch skipped."
elif [ "$SKIP_FETCH" -eq 0 ] || [ ! -f "$MEMSTICK" ]; then
echo "==> [1/7] Fetching FreeBSD memstick..."
mkdir -p "$CACHE_DIR"
curl -L --progress-bar -o "$MEMSTICK" "$FREEBSD_MEMSTICK_URL"
curl -L -o "${MEMSTICK}.SHA256" "$FREEBSD_MEMSTICK_SHA256_URL"
verify_memstick_cache || { echo "ERROR: checksum mismatch on memstick"; exit 1; }
else
if ! verify_memstick_cache; then
echo "ERROR: cached FreeBSD memstick failed checksum verification."
echo "Remove ${MEMSTICK} and rerun without --skip-fetch."
exit 1
fi
echo "==> [1/7] FreeBSD memstick cached."
fi
# --- step 2: fetch all packages (no root needed) ---
if [ "$SKIP_FETCH" -eq 0 ]; then
echo "==> [2/7] Fetching packages to tmp/packages/..."
mkdir -p "$PKG_REPO_DIR"
# Set pkg repo to configured branch before fetching
# Use temporary user-level config to avoid requiring root
PKG_CONFIG_DIR=$(mktemp -d "${TMP_DIR}/pkg-repo.XXXXXX")
trap "rm -rf $PKG_CONFIG_DIR" EXIT
# Avoid calling pkg config (needs privilege) — hardcode ABI and use cache
ABI="FreeBSD:15:amd64"
PKG_CACHE_HOME="${HOME}/.pkg-cache"
mkdir -p "$PKG_CONFIG_DIR" "$PKG_CACHE_HOME"
cat > "$PKG_CONFIG_DIR/FreeBSD.conf" <<EOF
FreeBSD: {
url: "pkg+https://pkg.FreeBSD.org/${ABI}/${DEFAULT_PKG_BRANCH}",
mirror_type: "srv",
enabled: yes
}
EOF
PKGS=$(pkg_list_all)
PKG_COUNT=$(echo "$PKGS" | wc -l | tr -d ' ')
echo " Fetching ${PKG_COUNT} packages (with dependencies)..."
# pkg fetch downloads .pkg files and all dependencies to tmp/packages/
# Use PKG_REPOS_DIR and PKG_CACHEDIR to minimize privilege requirements
export PKG_REPOS_DIR="$PKG_CONFIG_DIR"
export PKG_CACHEDIR="$PKG_CACHE_HOME"
# Try unprivileged fetch first
_PKG_FETCH_ERROR="${TMP_DIR}/pkg-fetch-error.log"
if ! echo "$PKGS" | xargs pkg fetch --yes --dependencies --output "$PKG_REPO_DIR" 2>"$_PKG_FETCH_ERROR"; then
# If privilege error, offer to re-run with sudo
if grep -q "Insufficient privileges" "$_PKG_FETCH_ERROR" 2>/dev/null; then
echo " ⚠️ pkg fetch requires elevated privileges."
echo " Re-running with sudo..."
sudo sh -c "export PKG_REPOS_DIR='$PKG_CONFIG_DIR' PKG_CACHEDIR='$PKG_CACHE_HOME'; echo '$PKGS' | xargs pkg fetch --yes --dependencies --output '$PKG_REPO_DIR'" || {
echo " ERROR: sudo pkg fetch failed"
exit 1
}
else
cat "$_PKG_FETCH_ERROR"
exit 1
fi
fi
echo " Fetch complete."
# Fetch pinned npm-global CLI tarballs for the live image bundle.
echo "==> [2b/7] Fetching npm-global CLI tarballs..."
OUT_DIR="$NPM_GLOBALS_DIR" "${SCRIPT_DIR}/scripts/fetch-npm-globals.sh" || {
echo " ERROR: fetch-npm-globals.sh failed"
exit 1
}
else
echo "==> [2/7] Skipping package fetch."
fi
if [ -d "${PKG_REPO_DIR}/Hashed" ]; then
mkdir -p "${PKG_REPO_DIR}/All/Hashed"
find "${PKG_REPO_DIR}/Hashed" -maxdepth 1 -type f -name '*.pkg' -exec mv {} "${PKG_REPO_DIR}/All/Hashed/" \;
rmdir "${PKG_REPO_DIR}/Hashed" 2>/dev/null || true
fi
if [ -d "${PKG_REPO_DIR}/All/Hashed" ]; then
override_networkmgr_package
fi
# --- step 3: generate offline pkg repo metadata ---
echo "==> [3/7] Generating offline pkg repo metadata..."
if [ -d "$PKG_REPO_DIR/All" ]; then
# pkg repo may also need privilege — try unprivileged first
_PKG_REPO_ERROR="${TMP_DIR}/pkg-repo-error.log"
if ! pkg repo "$PKG_REPO_DIR" 2>"$_PKG_REPO_ERROR"; then
if grep -q "Insufficient privileges" "$_PKG_REPO_ERROR" 2>/dev/null; then
echo " ⚠️ pkg repo requires elevated privileges. Re-running with sudo..."
sudo pkg repo "$PKG_REPO_DIR" || {
echo " ERROR: sudo pkg repo failed"
exit 1
}
else
cat "$_PKG_REPO_ERROR"
exit 1
fi
fi
echo " Repo metadata written to tmp/packages/"
else
echo " WARN: tmp/packages/All/ not found — run without --skip-fetch first"
fi
# --- step 4: fetch + prepare Clawdie-AI tarball (offline-ready) ---
# Resolve "latest" from Forgejo releases first, then tags.
if [ "${CLAWDIE_REF:-${CLAWDIE_VERSION:-}}" = "latest" ] || [ -z "${CLAWDIE_REF:-}" ]; then
echo "==> [4/7] Resolving latest Clawdie-AI version..."
CLAWDIE_VERSION=$(resolve_latest_clawdie_tag | sed 's/^v//')
if [ -z "$CLAWDIE_VERSION" ]; then
echo "ERROR: could not resolve latest Clawdie-AI release/tag from Forgejo."
echo " Pin --clawdie-ref main or --clawdie-version X.Y.Z explicitly."
exit 1
fi
CLAWDIE_REF="v${CLAWDIE_VERSION}"
echo " Resolved: ${CLAWDIE_REF}"
fi
CLAWDIE_AI_COMMIT=$(resolve_clawdie_commit "$CLAWDIE_REF" | head -n 1)
CLAWDIE_AI_COMMIT="${CLAWDIE_AI_COMMIT:-unknown}"
echo " Clawdie commit: ${CLAWDIE_AI_COMMIT}"
if [ "$CLAWDIE_AI_COMMIT" = "unknown" ] && [ "$SKIP_FETCH" -eq 1 ] && ! is_pinned_clawdie_ref "$CLAWDIE_REF"; then
echo "ERROR: cannot safely use --skip-fetch for moving Clawdie-AI ref '${CLAWDIE_REF}' without resolving its commit."
echo " Run without --skip-fetch or pin --clawdie-version / --clawdie-ref to a commit."
exit 1
fi
if [ "$CLAWDIE_AI_COMMIT" != "unknown" ]; then
CLAWDIE_ARCHIVE_REF="$CLAWDIE_AI_COMMIT"
CLAWDIE_CACHE_KEY="$CLAWDIE_AI_COMMIT"
else
CLAWDIE_ARCHIVE_REF="$CLAWDIE_REF"
CLAWDIE_CACHE_KEY=$(printf '%s' "$CLAWDIE_REF" | tr -c 'A-Za-z0-9._-' '_')
fi
CLAWDIE_TARBALL="${CACHE_DIR}/clawdie-ai-${CLAWDIE_CACHE_KEY}.tar.gz"
CLAWDIE_TARBALL_URL="https://code.smilepowered.org/clawdie/clawdie-ai/archive/${CLAWDIE_ARCHIVE_REF}.tar.gz"
if [ "$SKIP_FETCH" -eq 0 ] || [ ! -f "$CLAWDIE_TARBALL" ]; then
echo "==> [4/7] Fetching Clawdie-AI ${CLAWDIE_REF} (${CLAWDIE_ARCHIVE_REF})..."
mkdir -p "$CACHE_DIR"
curl -L --progress-bar -o "$CLAWDIE_TARBALL" "$CLAWDIE_TARBALL_URL"
else
echo "==> [4/7] Clawdie-AI ${CLAWDIE_REF} (${CLAWDIE_ARCHIVE_REF}) cached."
fi
# Build an ISO-ready tarball that includes node_modules for offline firstboot.
CLAWDIE_TARBALL_ISO="${CACHE_DIR}/clawdie-ai-${CLAWDIE_CACHE_KEY}-iso.tar.gz"
if [ "$SKIP_FETCH" -eq 0 ] || [ ! -f "$CLAWDIE_TARBALL_ISO" ]; then
echo "==> [4/7] Preparing Clawdie-AI offline tarball (node_modules)..."
if ! command -v npm >/dev/null 2>&1; then
echo "ERROR: npm not found on build host."
echo "Install Node.js + npm on the ISO build machine, then rerun build.sh."
echo "Example (FreeBSD): sudo pkg install -y node24 npm-node24"
exit 1
fi
BUILD_AI_DIR="${CACHE_DIR}/clawdie-ai-build"
STAGE_AI_DIR="${CACHE_DIR}/clawdie-ai-stage"
rm -rf "$BUILD_AI_DIR"
rm -rf "$STAGE_AI_DIR"
mkdir -p "$BUILD_AI_DIR"
mkdir -p "$STAGE_AI_DIR"
tar -xzf "$CLAWDIE_TARBALL" -C "$BUILD_AI_DIR"
AI_SRC_DIR="$(find "$BUILD_AI_DIR" -mindepth 1 -maxdepth 1 -type d | head -n 1)"
if [ -z "${AI_SRC_DIR:-}" ]; then
echo "ERROR: Could not locate extracted Clawdie-AI directory."
exit 1
fi
_bundle_script="${CACHE_DIR}/bundle-clawdie-ai.sh"
cat > "$_bundle_script" <<'EOF'
#!/bin/sh
set -eu
cd "$BUILD_AI_SRC_DIR"
# Never let package-manager lifecycle hooks run the Clawdie installer on
# the ISO build host. Older Clawdie-AI refs used an npm "install" script
# as an operator shortcut; remove only that root lifecycle hook in the
# staged copy while still allowing dependency install scripts.
node -e '
const fs = require("fs");
const path = "package.json";
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
if (pkg.scripts?.install) delete pkg.scripts.install;
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
'
if ! npm ci --no-audit --no-fund --legacy-peer-deps; then
echo " WARN: npm ci failed for Clawdie-AI; falling back to npm install."
echo " WARN: This usually means the published release tarball is out of sync with package-lock.json."
npm install --no-audit --no-fund --legacy-peer-deps
fi
EOF
chmod 700 "$_bundle_script"
if [ -n "${BUILD_HOST_USER}" ]; then
chown -R "$BUILD_HOST_USER":"$BUILD_HOST_USER" "$BUILD_AI_DIR" "$STAGE_AI_DIR" "$_bundle_script" 2>/dev/null || true
su -m "$BUILD_HOST_USER" -c "env HOME='${BUILD_HOST_HOME}' PATH='${PATH}' BUILD_AI_SRC_DIR='${AI_SRC_DIR}' sh '${_bundle_script}'"
else
env BUILD_AI_SRC_DIR="${AI_SRC_DIR}" sh "$_bundle_script"
fi
mv "$AI_SRC_DIR" "${STAGE_AI_DIR}/Clawdie-AI"
tar -czf "$CLAWDIE_TARBALL_ISO" -C "$STAGE_AI_DIR" Clawdie-AI
else
echo "==> [4/7] Clawdie-AI offline tarball cached."
fi
# Exit here if --fetch-only (CI package pre-fetch step, no root required)
if [ "$FETCH_ONLY" -eq 1 ]; then
echo ""
echo "==> Fetch complete. Run ./build.sh --skip-fetch to assemble ISO."
exit 0
fi
# --- step 5: prepare working image (requires root) ---
echo "==> [5/7] Preparing working image (${IMAGE_SIZE} for offline packages)..."
if [ "$(id -u)" -ne 0 ]; then
echo "ERROR: steps 5-7 require root (mdconfig/mount)"
exit 1
fi
WORK_IMG="${CACHE_DIR}/work.img"
# Recreate the working image on every assembly. Package/profile changes must not
# inherit stale packages or desktop files from a previous branch build.
rm -f "$WORK_IMG"
# Create image with configured size for offline packages
if [ ! -f "$WORK_IMG" ]; then
echo " Creating ${IMAGE_SIZE} image..."
truncate -s "${IMAGE_SIZE}" "$WORK_IMG"
# Attach to mdconfig and partition
MD=$(mdconfig -a -t vnode -f "$WORK_IMG")
echo " Attached as /dev/${MD}"
# Initialize an MBR layout compatible with modern UEFI removable boot:
# s1: EFI System Partition (FAT, contains /EFI/BOOT/BOOTX64.EFI)
# s2: FreeBSD slice with BSD label + UFS root
# s3: CLAWDIESEED FAT32 partition for operator seed files (SSH keys,
# future hostname / Tailscale / Wi-Fi overrides). Lives at the
# tail of the disk so Linux pre-flash tooling can mount and edit
# it without touching the UFS root. Imported on every boot by
# /usr/local/etc/rc.d/clawdie_live_seed.
_seed_size_m=64
_efi_size_m=64
_image_size_m=$(( $(stat -f %z "$WORK_IMG") / 1024 / 1024 ))
# 4 MiB headroom covers MBR overhead + alignment rounding. Without this
# the trailing 'gpart add -s ${_seed_size_m}M' can fail when the EFI
# slice rounds up past the requested 64M.
_freebsd_size_m=$(( _image_size_m - _efi_size_m - _seed_size_m - 4 ))
gpart create -s MBR /dev/${MD}
gpart add -t efi -s ${_efi_size_m}M /dev/${MD}
gpart add -t freebsd -s ${_freebsd_size_m}M /dev/${MD}
gpart add -t '!12' -s ${_seed_size_m}M /dev/${MD} # !12 = FAT32 LBA (0x0c)
gpart set -a active -i 2 /dev/${MD}
# Create BSD label (partition a) in the FreeBSD slice
gpart create -s BSD /dev/${MD}s2
gpart add -t freebsd-ufs /dev/${MD}s2
# Create UFS filesystem on partition a. Keep the label expected by the
# stock FreeBSD memstick loader configuration.
newfs -U -L FreeBSD_Install /dev/${MD}s2a
# Format and seed the CLAWDIESEED partition. Operators mount this on
# Linux (sudo mount -t vfat /dev/sdX3 /mnt/...) pre-flash to drop
# authorized_keys. The README.txt explains the allowlisted contract.
#
# -c 1 forces 1 sector/cluster (512 B). FAT32 requires >=65525 clusters
# by spec; at default cluster size (4 KiB+) a 64 MiB partition falls
# well below that and newfs_msdos rejects it ("too few clusters for
# FAT32, need 65525"). 512 B clusters give us 131072 clusters with a
# ~512 KiB FAT table — <1% overhead, invisible for the few-KiB text
# files this partition is meant to hold.
newfs_msdos -F 32 -c 1 -L CLAWDIESEED /dev/${MD}s3
_seed_mount="${CACHE_DIR}/seed-mnt"
mkdir -p "${_seed_mount}"
mount_msdosfs /dev/${MD}s3 "${_seed_mount}"
install -m 0644 "${LIVE_SESSION_DIR}/clawdie-live-seed.README.txt" \
"${_seed_mount}/README.txt"
sync
umount "${_seed_mount}"
rmdir "${_seed_mount}" 2>/dev/null || true
# Mount the new filesystem
MOUNT_POINT="${CACHE_DIR}/mnt"
mkdir -p "$MOUNT_POINT"
mount /dev/${MD}s2a "$MOUNT_POINT"
echo " Mounted /dev/${MD}s2a at ${MOUNT_POINT}"
# Mount memstick read-only to extract base system
MEMSTICK_MNT="${CACHE_DIR}/memstick-src"
mkdir -p "$MEMSTICK_MNT"
mount_memstick_rootfs "$MEMSTICK_MNT"
echo " Copying base system from memstick..."
# Copy all files from memstick to new image (excluding package cache)
(
cd "$MEMSTICK_MNT"
pax -rw -pe . "$MOUNT_POINT"
)
# Cleanup memstick mount
cleanup_memstick_rootfs "$MEMSTICK_MNT"
rm -rf "$MEMSTICK_MNT"
install_image_bootcode "$MD" "$MOUNT_POINT" "2"
install_image_uefi_bootcode "$MD" "$MOUNT_POINT" "1"
# Store MD device for later cleanup
echo "$MD" > "${CACHE_DIR}/.md_device"
else
# Reattach existing image (but extract base system if empty)
MD=$(mdconfig -a -t vnode -f "$WORK_IMG")
echo " Reattached as /dev/${MD}"
MOUNT_POINT="${CACHE_DIR}/mnt"
mkdir -p "$MOUNT_POINT"
if [ -e "/dev/${MD}s2a" ]; then
ROOT_PART="/dev/${MD}s2a"
ROOT_SLICE_INDEX="2"
else
ROOT_PART="/dev/${MD}s1a"
ROOT_SLICE_INDEX="1"
fi
mount "$ROOT_PART" "$MOUNT_POINT"
echo " Mounted ${ROOT_PART} at ${MOUNT_POINT}"
# Check if base system is missing and extract if needed
if [ ! -d "$MOUNT_POINT/etc" ]; then
echo " Extracting base system from memstick..."
MEMSTICK_MNT="${CACHE_DIR}/memstick-src"
mkdir -p "$MEMSTICK_MNT"
mount_memstick_rootfs "$MEMSTICK_MNT"
(
cd "$MEMSTICK_MNT"
pax -rw -pe . "$MOUNT_POINT"
)
cleanup_memstick_rootfs "$MEMSTICK_MNT"
rm -rf "$MEMSTICK_MNT"
install_image_bootcode "$MD" "$MOUNT_POINT" "$ROOT_SLICE_INDEX"
if [ "$ROOT_SLICE_INDEX" = "2" ]; then
install_image_uefi_bootcode "$MD" "$MOUNT_POINT" "1"
fi
fi
# Store MD device for later cleanup
echo "$MD" > "${CACHE_DIR}/.md_device"
fi
# --- step 6: inject payload ---
echo "==> [6/7] Injecting payload..."
# Create share directory on USB
USB_SHARE="${MOUNT_POINT}/usr/local/share/clawdie-iso"
mkdir -p "$USB_SHARE"
# Step 1 switches the live boot entrypoint away from auto-running
# /etc/installerconfig. Keep the script in USB_SHARE for later explicit
# bsdinstall invocation, but remove any legacy boot-time auto script.
rm -f "${MOUNT_POINT}/etc/installerconfig"
install_live_runtime_packages
configure_live_operator_session
install_colibri_service
install_zot_agent
install_nvidia_universal_repo
# Copy payload
# Rebuild payload paths from scratch inside the reusable work image. A failed
# prior build can leave a partial package tree behind, and overlaying a new
# hashed pkg repo onto that stale path produces "Not a directory" copy errors.
rm -rf "${USB_SHARE}/firstboot" "${USB_SHARE}/packages" "${USB_SHARE}/npm-globals"
rm -f "${USB_SHARE}/installerconfig" \
"${USB_SHARE}/clawdie-ai.tar.gz" \
"${USB_SHARE}/build.cfg" \
"${USB_SHARE}/build-manifest.json"
cp "${SCRIPT_DIR}/installerconfig" "${USB_SHARE}/installerconfig"
mkdir -p "${USB_SHARE}/firstboot"
(
cd "${SCRIPT_DIR}/firstboot"
pax -rw -pe . "${USB_SHARE}/firstboot"
)
mkdir -p "${USB_SHARE}/packages"
(
cd "${PKG_REPO_DIR}"
pax -rw -pe . "${USB_SHARE}/packages"
)
if [ -d "$NPM_GLOBALS_DIR" ]; then
mkdir -p "${USB_SHARE}/npm-globals"
(
cd "${NPM_GLOBALS_DIR}"
pax -rw -pe . "${USB_SHARE}/npm-globals"
)
NPM_GLOBAL_TARBALL_COUNT=$(find "${NPM_GLOBALS_DIR}" -maxdepth 1 -type f -name '*.tgz' | wc -l | tr -d ' ')
echo " Bundled npm-globals: ${NPM_GLOBAL_TARBALL_COUNT} tarballs"
fi
install_live_npm_globals
cp "${CLAWDIE_TARBALL_ISO}" "${USB_SHARE}/clawdie-ai.tar.gz"
cp "${SCRIPT_DIR}/build.cfg" "${USB_SHARE}/"
write_build_manifest "${USB_SHARE}/build-manifest.json"
# Bake runtime vars so firstboot reads the right target config
{
echo "ISO_VERSION=\"${ISO_VERSION}\""
echo "BUILD_CHANNEL=\"${BUILD_CHANNEL}\""
echo "CLAWDIE_VERSION=\"${CLAWDIE_VERSION}\""
echo "CLAWDIE_REF=\"${CLAWDIE_REF}\""
echo "CLAWDIE_AI_COMMIT=\"${CLAWDIE_AI_COMMIT}\""
echo "TARGET=\"${TARGET:-baremetal}\""
[ -n "${GPU_DRIVER:-}" ] && echo "GPU_DRIVER=\"${GPU_DRIVER}\""
[ -n "${ASSISTANT_NAME:-}" ] && echo "ASSISTANT_NAME=\"${ASSISTANT_NAME}\""
[ -n "${AGENT_GENDER:-}" ] && echo "AGENT_GENDER=\"${AGENT_GENDER}\""
[ -n "${AGENT_DOMAIN:-}" ] && echo "AGENT_DOMAIN=\"${AGENT_DOMAIN}\""
[ -n "${TZ:-}" ] && echo "TZ=\"${TZ}\""
[ -n "${PI_TUI_PROVIDER:-}" ] && echo "PI_TUI_PROVIDER=\"${PI_TUI_PROVIDER}\""
[ -n "${PI_TUI_MODEL:-}" ] && echo "PI_TUI_MODEL=\"${PI_TUI_MODEL}\""
[ -n "${ZAI_API_KEY:-}" ] && echo "ZAI_API_KEY=\"${ZAI_API_KEY}\""
[ -n "${OPENROUTER_API_KEY:-}" ] && echo "OPENROUTER_API_KEY=\"${OPENROUTER_API_KEY}\""
[ -n "${ANTHROPIC_API_KEY:-}" ] && echo "ANTHROPIC_API_KEY=\"${ANTHROPIC_API_KEY}\""
[ -n "${EMBED_BASE_URL:-}" ] && echo "EMBED_BASE_URL=\"${EMBED_BASE_URL}\""
[ -n "${EMBED_MODEL:-}" ] && echo "EMBED_MODEL=\"${EMBED_MODEL}\""
[ -n "${TELEGRAM_BOT_TOKEN:-}" ] && echo "TELEGRAM_BOT_TOKEN=\"${TELEGRAM_BOT_TOKEN}\""
[ -n "${TELEGRAM_ADMIN_IDS:-}" ] && echo "TELEGRAM_ADMIN_IDS=\"${TELEGRAM_ADMIN_IDS}\""
[ -n "${FEATURE_TELEGRAM:-}" ] && echo "FEATURE_TELEGRAM=\"${FEATURE_TELEGRAM}\""
[ -n "${FEATURE_COLIBRI:-}" ] && echo "FEATURE_COLIBRI=\"${FEATURE_COLIBRI}\""
[ -n "${COLIBRI_STAGE_TEST_AGENT:-}" ] && echo "COLIBRI_STAGE_TEST_AGENT=\"${COLIBRI_STAGE_TEST_AGENT}\""
[ -n "${COLIBRI_DAEMON_ENABLE:-}" ] && echo "COLIBRI_DAEMON_ENABLE=\"${COLIBRI_DAEMON_ENABLE}\""
[ -n "${COLIBRI_COST_MODE:-}" ] && echo "COLIBRI_COST_MODE=\"${COLIBRI_COST_MODE}\""
[ -n "${SSH_PUBLIC_KEY:-}" ] && echo "SSH_PUBLIC_KEY=\"${SSH_PUBLIC_KEY}\""
[ -n "${ROOT_PASSWORD:-}" ] && echo "ROOT_PASSWORD=\"${ROOT_PASSWORD}\""
[ -n "${CLAWDIE_USER_PASSWORD:-}" ] && echo "CLAWDIE_USER_PASSWORD=\"${CLAWDIE_USER_PASSWORD}\""
} >> "${USB_SHARE}/build.cfg"
echo " Payload injected."
# Image-size headroom guard. The live filesystem is fixed at IMAGE_SIZE; rust + go
# + offline repo staging + the package cache can fill it. Report usage and fail
# *before* shipping an image that booted with no room to breathe, rather than
# discovering it on the stick. Override the floor with IMAGE_MIN_FREE_MB.
echo "==> Image space report (mounted live filesystem):"
df -h "${MOUNT_POINT}" || true
echo " Largest staged trees:"
du -sh "${MOUNT_POINT}/usr/local" "${MOUNT_POINT}/home" "${MOUNT_POINT}/var" 2>/dev/null || true
_img_free_kb=$(df -k "${MOUNT_POINT}" | awk 'NR==2 {print $4}')
_img_min_free_mb="${IMAGE_MIN_FREE_MB:-1024}"
if [ -n "${_img_free_kb:-}" ] && [ "${_img_free_kb}" -lt $((_img_min_free_mb * 1024)) ]; then
echo "ERROR: only $((_img_free_kb / 1024)) MB free on the live filesystem (< ${_img_min_free_mb} MB floor)."
echo " Raise IMAGE_SIZE in build.cfg (e.g. for a larger USB) or trim staged content,"
echo " or lower the floor with IMAGE_MIN_FREE_MB if you know it fits."
exit 1
fi
echo " Free space OK ($((_img_free_kb / 1024)) MB, floor ${_img_min_free_mb} MB)."
# Unmount and detach
umount "$MOUNT_POINT"
if [ -f "${CACHE_DIR}/.md_device" ]; then
MD=$(cat "${CACHE_DIR}/.md_device")
mdconfig -d -u "$MD"
rm "${CACHE_DIR}/.md_device"
fi
# --- step 7: write output ---
echo "==> [7/7] Writing output image..."
mkdir -p "$OUTPUT_DIR"
cp "$WORK_IMG" "${OUTPUT_DIR}/${IMAGE_NAME}"
sync
echo ""
OUTPUT_IMAGE="${OUTPUT_DIR}/${IMAGE_NAME}"
IMAGE_LOGICAL_SIZE=$(ls -lh "$OUTPUT_IMAGE" | awk '{print $5}')
IMAGE_ALLOCATED_SIZE=$(du -sh "$OUTPUT_IMAGE" | awk '{print $1}')
echo " Done : ${OUTPUT_IMAGE}"
echo " Image size : ${IMAGE_LOGICAL_SIZE}"
echo " Allocated : ${IMAGE_ALLOCATED_SIZE} (sparse on build host)"
echo ""
echo " Write to USB:"
echo " dd if=${OUTPUT_IMAGE} of=/dev/daX bs=1M status=progress conv=fsync && sync"