#!/usr/bin/env bash set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" if [ -f "${ROOT}/metadata/target.env" ]; then # shellcheck disable=SC1091 source "${ROOT}/metadata/target.env" fi APT_DIR="${ROOT}/packages/apt" BIN_DIR="${ROOT}/packages/bin" COMPONENT_DIR="${ROOT}/packages/components" TARGET_ARCH="${ARCH:-}" CONSOLE_RUNTIME_ASSET="${COMPONENT_DIR}/xworkspace-console-runtime-linux-${TARGET_ARCH}.tar.gz" BRIDGE_RUNTIME_ASSET="${COMPONENT_DIR}/xworkmate-bridge-linux-${TARGET_ARCH}.tar.gz" QMD_RUNTIME_ASSET="${COMPONENT_DIR}/qmd-runtime-linux-${TARGET_ARCH}.tar.gz" LITELLM_RUNTIME_ASSET="${COMPONENT_DIR}/litellm-runtime-${DISTRO_ID:-}-${DISTRO_VERSION:-}-${TARGET_ARCH}.tar.gz" IMAGE_DIR="${ROOT}/packages/images" NPM_CACHE_DIR="${ROOT}/packages/npm-cache" NPM_RUNTIME_CACHE_DIR="${AI_WORKSPACE_NPM_CACHE_DIR:-/var/cache/ai-workspace/npm}" PIP_WHEEL_DIR="${ROOT}/packages/pip" PLAYWRIGHT_BROWSER_DIR="${ROOT}/packages/playwright-browsers" PLAYWRIGHT_BROWSER_INSTALL_DIR="${AI_WORKSPACE_PLAYWRIGHT_BROWSER_DIR:-/opt/ai-workspace/playwright-browsers}" PORTABLE_PYTHON_DIR="${ROOT}/packages/python" PORTABLE_PYTHON_INSTALL_DIR="${AI_WORKSPACE_PORTABLE_PYTHON_DIR:-/opt/ai-workspace/python}" STATE_DIR="${AI_WORKSPACE_OFFLINE_STATE_DIR:-/var/lib/ai-workspace/offline}" AI_WORKSPACE_DEPLOYMENT_LOCK_TIMEOUT="${AI_WORKSPACE_DEPLOYMENT_LOCK_TIMEOUT:-1800}" AI_WORKSPACE_APT_LOCK_TIMEOUT="${AI_WORKSPACE_APT_LOCK_TIMEOUT:-900}" APT_SOURCE_FILE="/etc/apt/sources.list.d/ai-workspace-offline.list" APT_CONFIG_FILE="/etc/apt/apt.conf.d/99ai-workspace-offline" BROWSER_APT_PACKAGE_FILE="${ROOT}/metadata/apt/browser-deb-packages.txt" SAFE_GIT_DIRS=() APT_LOCAL_OPTIONS=( -o "Dir::Etc::sourcelist=sources.list.d/ai-workspace-offline.list" -o "Dir::Etc::sourceparts=-" -o "APT::Get::List-Cleanup=0" ) info() { printf '\033[1;34m[INFO]\033[0m %s\n' "$*" >&2 } warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*" >&2 } cleanup() { rm -f "${APT_SOURCE_FILE}" "${APT_CONFIG_FILE}" local git_dir for git_dir in "${SAFE_GIT_DIRS[@]}"; do git config --system --unset-all safe.directory "${git_dir}" >/dev/null 2>&1 || true done } require_root() { if [ "$(id -u)" -ne 0 ]; then echo "Run this installer as root or with sudo." >&2 exit 1 fi } acquire_deployment_lock() { if [ "${AI_WORKSPACE_DEPLOYMENT_LOCK_HELD:-false}" = "true" ]; then return fi command -v flock >/dev/null 2>&1 || { warn "flock is unavailable; continuing without the deployment serialization lock." return } local lock_file="${AI_WORKSPACE_DEPLOYMENT_LOCK_FILE:-/var/lock/ai-workspace-all-in-one.lock}" mkdir -p "$(dirname "${lock_file}")" exec 9>"${lock_file}" info "Waiting for the AI Workspace deployment lock: ${lock_file}" flock -w "${AI_WORKSPACE_DEPLOYMENT_LOCK_TIMEOUT}" 9 || { echo "Timed out waiting for another AI Workspace deployment to finish." >&2 exit 1 } export AI_WORKSPACE_DEPLOYMENT_LOCK_HELD=true } wait_for_apt_locks() { local waited=0 local busy while true; do busy=false if pgrep -x apt-get >/dev/null 2>&1 || pgrep -x apt >/dev/null 2>&1 || pgrep -x dpkg >/dev/null 2>&1 || pgrep -x unattended-upgrade >/dev/null 2>&1; then busy=true fi local lock for lock in \ /var/lib/dpkg/lock-frontend \ /var/lib/dpkg/lock \ /var/lib/apt/lists/lock \ /var/cache/apt/archives/lock; do if [ "${busy}" = "false" ] && command -v fuser >/dev/null 2>&1 && [ -e "${lock}" ] && fuser "${lock}" >/dev/null 2>&1; then busy=true fi if [ "${busy}" = "false" ] && command -v lslocks >/dev/null 2>&1 && lslocks -rn -o PATH 2>/dev/null | grep -Fxq "${lock}"; then busy=true fi done if [ "${busy}" = "false" ]; then return fi if [ "${waited}" -ge "${AI_WORKSPACE_APT_LOCK_TIMEOUT}" ]; then echo "Timed out after ${AI_WORKSPACE_APT_LOCK_TIMEOUT}s waiting for APT/dpkg locks." >&2 exit 1 fi if [ $((waited % 30)) -eq 0 ]; then info "Another package manager is active; waiting for APT/dpkg locks (${waited}s/${AI_WORKSPACE_APT_LOCK_TIMEOUT}s)..." fi sleep 5 waited=$((waited + 5)) done } configure_local_apt_repo() { if [ ! -d "${APT_DIR}" ] || ! compgen -G "${APT_DIR}/*.deb" >/dev/null; then warn "No offline .deb cache found at ${APT_DIR}; skipping local APT repo setup." return fi info "Configuring local APT repository from ${APT_DIR}" cat > "${APT_SOURCE_FILE}" < "${APT_CONFIG_FILE}" <<'EOF' Dir::Etc::sourcelist "sources.list.d/ai-workspace-offline.list"; Dir::Etc::sourceparts "-"; APT::Get::List-Cleanup "0"; Acquire::Languages "none"; EOF apt-get "${APT_LOCAL_OPTIONS[@]}" update } configure_local_git_sources() { local repo_dir for repo_dir in \ "${ROOT}/repos/xworkspace-console" \ "${ROOT}/repos/xworkspace-core-skills" \ "${ROOT}/repos/xworkmate-bridge" \ "${ROOT}/repos/playbooks"; do [ -d "${repo_dir}/.git" ] || continue if ! git config --system --get-all safe.directory 2>/dev/null | grep -Fxq "${repo_dir}"; then git config --system --add safe.directory "${repo_dir}" SAFE_GIT_DIRS+=("${repo_dir}") fi done } append_available_package() { local -n package_list=$1 local package=$2 if apt-cache "${APT_LOCAL_OPTIONS[@]}" show "${package}" >/dev/null 2>&1; then package_list+=("${package}") fi } install_offline_prerequisites() { local packages=(git curl ansible ca-certificates unzip) local browser_package if [ -d "${PLAYWRIGHT_BROWSER_DIR}" ] && [ ! -s "${BROWSER_APT_PACKAGE_FILE}" ]; then echo "Bundled Playwright Chromium dependency manifest is missing." >&2 exit 1 fi if [ -d "${PLAYWRIGHT_BROWSER_DIR}" ]; then while IFS= read -r browser_package; do [ -n "${browser_package}" ] && packages+=("${browser_package}") done < "${BROWSER_APT_PACKAGE_FILE}" fi if ! command -v docker >/dev/null 2>&1; then if apt-cache "${APT_LOCAL_OPTIONS[@]}" show docker-ce >/dev/null 2>&1; then packages+=(docker-ce docker-ce-cli containerd.io) append_available_package packages docker-buildx-plugin append_available_package packages docker-compose-plugin elif apt-cache "${APT_LOCAL_OPTIONS[@]}" show docker.io >/dev/null 2>&1; then packages+=(docker.io) append_available_package packages docker-compose-plugin fi fi wait_for_apt_locks info "Installing bootstrap prerequisites from the bundled APT repository" DEBIAN_FRONTEND=noninteractive apt-get "${APT_LOCAL_OPTIONS[@]}" install \ -y --no-install-recommends "${packages[@]}" } install_bundled_binaries() { if compgen -G "${BIN_DIR}/vault_*_linux_*.zip" >/dev/null; then info "Installing bundled Vault binary" tmp="$(mktemp -d)" unzip -o "${BIN_DIR}"/vault_*_linux_*.zip -d "${tmp}" install -m 0755 "${tmp}/vault" /usr/local/bin/vault rm -rf "${tmp}" fi if [ -f "${BRIDGE_RUNTIME_ASSET}" ]; then local bridge_extract bridge_extract="$(mktemp -d)" tar -xzf "${BRIDGE_RUNTIME_ASSET}" -C "${bridge_extract}" install -m 0755 "${bridge_extract}/xworkmate-bridge/bin/xworkmate-go-core" \ "${BIN_DIR}/xworkmate-go-core.${TARGET_ARCH}" rm -rf "${bridge_extract}" fi case "$(uname -m)" in x86_64|amd64) ttyd_arch=x86_64 ;; aarch64|arm64) ttyd_arch=aarch64 ;; *) ttyd_arch="" ;; esac if [ -n "${ttyd_arch}" ] && [ -f "${BIN_DIR}/ttyd.${ttyd_arch}" ]; then info "Installing bundled ttyd binary" install -m 0755 "${BIN_DIR}/ttyd.${ttyd_arch}" /usr/local/bin/ttyd fi case "$(uname -m)" in x86_64|amd64) bridge_arch=amd64 ;; aarch64|arm64) bridge_arch=arm64 ;; *) bridge_arch="" ;; esac if [ -n "${bridge_arch}" ] && [ -f "${BIN_DIR}/xworkmate-go-core.${bridge_arch}" ]; then info "Installing bundled XWorkmate Bridge binary" local bridge_immutable=false if command -v lsattr >/dev/null 2>&1 && lsattr /usr/local/bin/xworkmate-go-core 2>/dev/null | awk '{print $1}' | grep -q 'i'; then bridge_immutable=true chattr -i /usr/local/bin/xworkmate-go-core fi install -m 0755 "${BIN_DIR}/xworkmate-go-core.${bridge_arch}" /usr/local/bin/xworkmate-go-core if [ "${bridge_immutable}" = "true" ]; then chattr +i /usr/local/bin/xworkmate-go-core fi fi } install_bundled_playwright_browser() { if [ ! -d "${PLAYWRIGHT_BROWSER_DIR}" ]; then return fi local seed_checksum marker browser_binary seed_checksum="$(sha256sum "${ROOT}/metadata/manifest.json" | awk '{print $1}')" marker="${PLAYWRIGHT_BROWSER_INSTALL_DIR}/.ai-workspace-seed-sha256" if [ "$(cat "${marker}" 2>/dev/null || true)" != "${seed_checksum}" ]; then info "Installing bundled Playwright Chromium runtime" rm -rf "${PLAYWRIGHT_BROWSER_INSTALL_DIR}" mkdir -p "${PLAYWRIGHT_BROWSER_INSTALL_DIR}" cp -a "${PLAYWRIGHT_BROWSER_DIR}/." "${PLAYWRIGHT_BROWSER_INSTALL_DIR}/" printf '%s\n' "${seed_checksum}" > "${marker}" else info "Reusing bundled Playwright Chromium runtime" fi chown -R root:root "${PLAYWRIGHT_BROWSER_INSTALL_DIR}" chmod -R a+rX "${PLAYWRIGHT_BROWSER_INSTALL_DIR}" browser_binary="$( find "${PLAYWRIGHT_BROWSER_INSTALL_DIR}" -type f \ \( -path '*/chrome-linux/chrome' -o -path '*/chrome-linux64/chrome' \) \ -print -quit )" if [ -z "${browser_binary}" ] || [ ! -x "${browser_binary}" ]; then echo "Bundled Playwright Chromium executable is missing." >&2 exit 1 fi ln -sfn "${browser_binary}" /usr/local/bin/chromium } install_bundled_python_runtime() { local packaged_python installed_python marker seed_checksum packaged_python="$( find -L "${PORTABLE_PYTHON_DIR}" -type f -path '*/bin/python3.13' -perm /111 -print -quit 2>/dev/null || true )" if [ -z "${packaged_python}" ]; then return fi seed_checksum="$(sha256sum "${ROOT}/metadata/manifest.json" | awk '{print $1}')" marker="${PORTABLE_PYTHON_INSTALL_DIR}/.ai-workspace-seed-sha256" if [ "$(cat "${marker}" 2>/dev/null || true)" != "${seed_checksum}" ]; then info "Installing bundled portable Python runtime" rm -rf "${PORTABLE_PYTHON_INSTALL_DIR}" mkdir -p "${PORTABLE_PYTHON_INSTALL_DIR}" cp -a "${PORTABLE_PYTHON_DIR}/." "${PORTABLE_PYTHON_INSTALL_DIR}/" printf '%s\n' "${seed_checksum}" > "${marker}" else info "Reusing bundled portable Python runtime" fi chown -R root:root "${PORTABLE_PYTHON_INSTALL_DIR}" chmod -R a+rX "${PORTABLE_PYTHON_INSTALL_DIR}" installed_python="$( find -L "${PORTABLE_PYTHON_INSTALL_DIR}" -type f -path '*/bin/python3.13' -perm /111 -print -quit )" if [ -z "${installed_python}" ]; then echo "Installed portable Python executable is missing." >&2 exit 1 fi ln -sfn "${installed_python}" /usr/local/bin/ai-workspace-python cat > /usr/local/bin/ai-workspace-pip <<'EOF' #!/usr/bin/env bash exec /usr/local/bin/ai-workspace-python -m pip "$@" EOF chmod 0755 /usr/local/bin/ai-workspace-pip export LITELLM_PYTHON_EXECUTABLE=/usr/local/bin/ai-workspace-python export LITELLM_PIP_EXECUTABLE=/usr/local/bin/ai-workspace-pip } load_container_images() { if [ ! -d "${IMAGE_DIR}" ] || ! compgen -G "${IMAGE_DIR}/*.tar" >/dev/null; then return fi if command -v docker >/dev/null 2>&1; then mkdir -p "${STATE_DIR}/images" systemctl start docker >/dev/null 2>&1 || true for image_tar in "${IMAGE_DIR}"/*.tar; do local checksum marker checksum="$(sha256sum "${image_tar}" | awk '{print $1}')" marker="${STATE_DIR}/images/$(basename "${image_tar}").sha256" if [ "$(cat "${marker}" 2>/dev/null || true)" = "${checksum}" ]; then info "Container image already loaded from ${image_tar}; skipping." continue fi info "Loading container image ${image_tar}" docker load -i "${image_tar}" printf '%s\n' "${checksum}" > "${marker}" done else warn "Docker is not installed yet; bundled image tarballs remain in ${IMAGE_DIR}." fi } configure_language_package_caches() { if [ -d "${NPM_CACHE_DIR}" ]; then local cache_group="ai-workspace-cache" local cache_user="${AI_WORKSPACE_RUNTIME_USER:-ubuntu}" local seed_checksum marker getent group "${cache_group}" >/dev/null 2>&1 || groupadd --system "${cache_group}" if [ "${cache_user}" != "root" ] && ! id "${cache_user}" >/dev/null 2>&1; then useradd --create-home --shell /bin/bash "${cache_user}" fi if [ "${cache_user}" != "root" ]; then usermod --append --groups "${cache_group}" "${cache_user}" fi seed_checksum="$(sha256sum "${ROOT}/metadata/manifest.json" | awk '{print $1}')" marker="${NPM_RUNTIME_CACHE_DIR}/.ai-workspace-seed-sha256" if [ "$(cat "${marker}" 2>/dev/null || true)" != "${seed_checksum}" ]; then info "Seeding shared npm cache from the offline package" rm -rf "${NPM_RUNTIME_CACHE_DIR}" mkdir -p "${NPM_RUNTIME_CACHE_DIR}" cp -a "${NPM_CACHE_DIR}/." "${NPM_RUNTIME_CACHE_DIR}/" printf '%s\n' "${seed_checksum}" > "${marker}" else info "Reusing shared npm cache at ${NPM_RUNTIME_CACHE_DIR}" fi chown -R "root:${cache_group}" "${NPM_RUNTIME_CACHE_DIR}" find "${NPM_RUNTIME_CACHE_DIR}" -type d -exec chmod 2770 {} + find "${NPM_RUNTIME_CACHE_DIR}" -type f -exec chmod 0660 {} + info "Configuring npm to prefer bundled cache" touch /etc/npmrc local npmrc_tmp npmrc_tmp="$(mktemp)" awk -F= '$1 != "cache" && $1 != "prefer-offline" { print }' /etc/npmrc > "${npmrc_tmp}" printf 'cache=%s\nprefer-offline=true\n' "${NPM_RUNTIME_CACHE_DIR}" >> "${npmrc_tmp}" install -m 0644 "${npmrc_tmp}" /etc/npmrc rm -f "${npmrc_tmp}" if command -v npm >/dev/null 2>&1; then npm config set cache "${NPM_RUNTIME_CACHE_DIR}" --global || true npm config set prefer-offline true --global || true fi fi if [ -d "${PIP_WHEEL_DIR}" ]; then info "Configuring pip to prefer bundled wheelhouse" export PIP_FIND_LINKS="${PIP_WHEEL_DIR}" export PIP_PREFER_BINARY=true export PIP_NO_INDEX=true if command -v python3 >/dev/null 2>&1 && python3 -m pip --version >/dev/null 2>&1; then python3 -m pip config --global set global.find-links "${PIP_WHEEL_DIR}" python3 -m pip config --global set global.prefer-binary true python3 -m pip config --global set global.no-index true fi elif [ "${AI_WORKSPACE_PREBUILT_COMPONENTS_REQUIRED:-true}" = "true" ]; then echo "Bundled LiteLLM wheelhouse is required but missing: ${PIP_WHEEL_DIR}" >&2 exit 1 fi } run_bootstrap() { local setup_script="${ROOT}/repos/xworkspace-console/scripts/setup-ai-workspace-all-in-one.sh" if [ ! -x "${setup_script}" ]; then chmod +x "${setup_script}" fi export PLAYBOOK_DIR="${PLAYBOOK_DIR:-${ROOT}/repos/playbooks}" export XWORKSPACE_CONSOLE_DIR="${XWORKSPACE_CONSOLE_DIR:-${ROOT}/repos/xworkspace-console}" export XWORKSPACE_CORE_SKILLS_DIR="${XWORKSPACE_CORE_SKILLS_DIR:-${ROOT}/repos/xworkspace-core-skills}" export XWORKSPACE_CONSOLE_RUNTIME_ARCHIVE="${XWORKSPACE_CONSOLE_RUNTIME_ARCHIVE:-${CONSOLE_RUNTIME_ASSET}}" export QMD_RUNTIME_ARCHIVE="${QMD_RUNTIME_ARCHIVE:-${QMD_RUNTIME_ASSET}}" [ -f "${LITELLM_RUNTIME_ASSET}" ] || { echo "Exact LiteLLM runtime asset is missing: ${LITELLM_RUNTIME_ASSET}" >&2; exit 1; } if [ -f "${ROOT}/metadata/litellm-runtime.env" ]; then # shellcheck disable=SC1091 source "${ROOT}/metadata/litellm-runtime.env" export LITELLM_PACKAGE_SPEC fi export XWORKSPACE_CONSOLE_PUBLIC_ACCESS="${XWORKSPACE_CONSOLE_PUBLIC_ACCESS:-false}" export XWORKMATE_BRIDGE_PUBLIC_ACCESS="${XWORKMATE_BRIDGE_PUBLIC_ACCESS:-true}" export GATEWAY_OPENCLAW_PUBLIC_ACCESS="${GATEWAY_OPENCLAW_PUBLIC_ACCESS:-false}" export VAULT_PUBLIC_ACCESS="${VAULT_PUBLIC_ACCESS:-false}" export AI_WORKSPACE_OFFLINE_ACTIVE=true export AI_WORKSPACE_PREBUILT_COMPONENTS_REQUIRED=true export AI_WORKSPACE_USE_PREBUILT_BRIDGE=true export AI_WORKSPACE_RUNTIME_PREBUILD_ENABLED=false export AI_WORKSPACE_DEPLOYMENT_LOCK_HELD=true export AI_WORKSPACE_APT_LOCK_TIMEOUT info "Running packaged AI Workspace all-in-one bootstrap" bash "${setup_script}" } main() { require_root trap cleanup EXIT acquire_deployment_lock wait_for_apt_locks configure_local_apt_repo install_offline_prerequisites configure_local_git_sources install_bundled_binaries install_bundled_playwright_browser install_bundled_python_runtime load_container_images configure_language_package_caches run_bootstrap } main "$@"