#!/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--.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.47} && \\" echo " ZOT_BUILD_VERSION=\"\${ZOT_VERSION:-v0.2.47}\" && 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/mother-mcp" ]; then echo "ERROR: mother SSH key present on build host (/home/clawdie/.ssh/mother-mcp) — 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 # Pi version provenance. Pi is fetched as @latest (see packages/npm-globals.txt), # so the concrete version floats — derive it from the bundled tarball name # (earendil-works-pi-coding-agent-.tgz) and record it. This runs after # fetch-npm-globals.sh + install_live_npm_globals, so the tarball is present. _pi_version="unknown" _pi_tgz=$(ls "${NPM_GLOBALS_DIR}"/earendil-works-pi-coding-agent-*.tgz 2>/dev/null | head -1) if [ -n "${_pi_tgz}" ]; then _pi_version=$(basename "${_pi_tgz}" .tgz | sed 's/^earendil-works-pi-coding-agent-//') fi mkdir -p "$(dirname "$_manifest_path")" cat > "$_manifest_path" </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 # Lock the repacked networkmgr package so pkg upgrade doesn't replace it # with the upstream version that depends on sudo. The ISO uses mdo/mac_do # instead of sudo; see override_networkmgr_package() above. if ! chroot "${MOUNT_POINT}" /usr/local/sbin/pkg lock -y networkmgr; 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 lock networkmgr package on live image" 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_firefox_extensions() { # Pre-stage uBlock Origin and Bitwarden, and apply enterprise policies # (no default-browser check — Firefox is the only browser on the USB). local _ff_dist="${MOUNT_POINT}/usr/local/lib/firefox/distribution" local _ext_dir="${_ff_dist}/extensions" local _policies="${_ff_dist}/policies.json" echo " Configuring Firefox..." mkdir -p "${_ext_dir}" || { echo "ERROR: failed to create Firefox distribution directory" exit 1 } # Disable "make default browser" popup — Firefox is the only browser. cat > "${_policies}" <<'POLICIES' { "policies": { "DontCheckDefaultBrowser": true } } POLICIES chmod 0644 "${_policies}" # Download a Firefox extension XPI if not already cached. # Usage: _fetch_xpi