diff --git a/.github/workflows/offline-package-ai-workspace-installer.yaml b/.github/workflows/offline-package-ai-workspace-installer.yaml index 1493dd7..c448999 100644 --- a/.github/workflows/offline-package-ai-workspace-installer.yaml +++ b/.github/workflows/offline-package-ai-workspace-installer.yaml @@ -2,6 +2,8 @@ name: Build Offline AI Workspace All-in-One Package on: push: + branches: + - main paths: - 'scripts/create-ai-workspace-offline-package.sh' - 'scripts/ai-workspace-offline-install.sh' @@ -97,7 +99,7 @@ jobs: DISTRO_VERSION: ${{ matrix.version }} ARCH: ${{ matrix.arch }} PLAYBOOKS_REF: ${{ github.event.inputs.playbooks_ref || 'main' }} - CONSOLE_REF: ${{ github.event.inputs.console_ref || 'main' }} + CONSOLE_REF: ${{ github.event_name == 'push' && github.sha || github.event.inputs.console_ref || 'main' }} CORE_SKILLS_REF: ${{ github.event.inputs.core_skills_ref || 'main' }} PACKAGE_VERSION: ${{ github.run_number }} run: | @@ -126,6 +128,35 @@ jobs: version: "24.04" arch: amd64 steps: + - uses: actions/checkout@v4 + + - name: Verify online bootstrap hands off to offline installer + run: | + set -euo pipefail + fixture="${RUNNER_TEMP}/offline-handoff" + mkdir -p "${fixture}/scripts" "${fixture}/metadata" + cat > "${fixture}/metadata/target.env" < "${fixture}/scripts/ai-workspace-offline-install.sh" <<'EOF' + #!/usr/bin/env bash + set -euo pipefail + test "${AI_WORKSPACE_OFFLINE_ACTIVE:-}" = "true" + printf 'offline-handoff-ok\n' + EOF + chmod +x "${fixture}/scripts/ai-workspace-offline-install.sh" + output="$( + AI_WORKSPACE_OFFLINE_MODE=force \ + AI_WORKSPACE_OFFLINE_PACKAGE="${fixture}" \ + AI_WORKSPACE_OFFLINE_DISTRO_ID=${{ matrix.distro }} \ + AI_WORKSPACE_OFFLINE_DISTRO_VERSION=${{ matrix.version }} \ + AI_WORKSPACE_OFFLINE_ARCH=${{ matrix.arch }} \ + bash scripts/setup-ai-workspace-all-in-one.sh + )" + grep -q 'offline-handoff-ok' <<<"${output}" + - name: Download artifact uses: actions/download-artifact@v4 with: @@ -139,6 +170,7 @@ jobs: tar -tzf ai-workspace-all-in-one-offline-${{ matrix.distro }}-${{ matrix.version }}-${{ matrix.arch }}.tar.gz > contents.txt grep -q 'scripts/ai-workspace-offline-install.sh' contents.txt grep -q 'metadata/manifest.json' contents.txt + grep -q 'metadata/target.env' contents.txt grep -q 'repos/playbooks' contents.txt grep -q 'repos/xworkspace-console' contents.txt grep -q 'packages/apt/Packages.gz' contents.txt diff --git a/docs/OFFLINE_AI_WORKSPACE_INSTALLER.md b/docs/OFFLINE_AI_WORKSPACE_INSTALLER.md index 1a7b062..c77c05a 100644 --- a/docs/OFFLINE_AI_WORKSPACE_INSTALLER.md +++ b/docs/OFFLINE_AI_WORKSPACE_INSTALLER.md @@ -32,12 +32,50 @@ Ubuntu Pro/ESM. ## Runtime Usage -Extract the target package on the host and run: +The online bootstrap prefers the matching offline package from the +`ai-workspace-lab/xworkspace-console` GitHub releases when it is available: + +```bash +curl -sfL https://raw.githubusercontent.com/ai-workspace-lab/xworkspace-console/main/scripts/setup-ai-workspace-all-in-one.sh | bash - +``` + +Set `AI_WORKSPACE_OFFLINE_MODE=off` to force the legacy online-only path, or +`AI_WORKSPACE_OFFLINE_MODE=force` to fail when no matching offline package can be +prepared. + +The default package source is: + +```text +https://github.com/ai-workspace-lab/xworkspace-console/releases/latest/download/ai-workspace-all-in-one-offline---.tar.gz +``` + +The latest release tag follows the `offline-ai-workspace-*` pattern. + +For private mirrors or pinned releases, use: + +```bash +AI_WORKSPACE_OFFLINE_PACKAGE_BASE_URL=https://mirror.example/offline-package/ai-workspace/offline-ai-workspace- \ + bash scripts/setup-ai-workspace-all-in-one.sh + +AI_WORKSPACE_OFFLINE_RELEASE_TAG=offline-ai-workspace- \ + bash scripts/setup-ai-workspace-all-in-one.sh +``` + +You can also extract the target package on the host and run: ```bash sudo ./scripts/ai-workspace-offline-install.sh ``` +Pass deployment settings explicitly through `sudo env` when needed: + +```bash +sudo env \ + XWORKMATE_BRIDGE_DOMAIN=acp-bridge.onwalk.net \ + AI_WORKSPACE_SECURITY_LEVEL=strict \ + ./scripts/ai-workspace-offline-install.sh +``` + The script configures a local APT repository, installs bundled binaries, loads packaged container images when Docker is available, and runs the packaged all-in-one bootstrap with local source directories. diff --git a/docs/ai-workspace-runtime-delivery-plan.md b/docs/ai-workspace-runtime-delivery-plan.md index 4ca5c69..4a267b6 100644 --- a/docs/ai-workspace-runtime-delivery-plan.md +++ b/docs/ai-workspace-runtime-delivery-plan.md @@ -11,9 +11,10 @@ ## TODO -- [ ] 等待并核对 `xworkspace-console` 的离线包 GitHub Actions 发布链路,确认 `publish-release` 完整结束且 release 产物上传成功。 +- [x] 等待并核对 `xworkspace-console` 的离线包 GitHub Actions 发布链路,确认 `publish-release` 完整结束且 release 产物上传成功。 - [ ] 继续核对 `root@acp-bridge.onwalk.net` 的远程部署进度,确认 `setup-ai-workspace-all-in-one.sh` 最终完成并输出统一摘要。 -- [ ] `setup-ai-workspace-all-in-one.sh` 在目标主机上优先使用离线安装包加速部署,减少在线拉取与安装耗时。 +- [x] `setup-ai-workspace-all-in-one.sh` 在目标主机上优先使用离线安装包加速部署,减少在线拉取与安装耗时。 +- [ ] 验证 `setup-ai-workspace-all-in-one.sh` 幂等性:同一主机连续执行两次均成功,复用凭据、离线包缓存与已导入镜像,并安全等待部署/APT 锁。 - [ ] 完成最终验收核对:Bridge 对外可达、其余服务默认仅本地监听、`acp-codex` / `opencode` / `gemini` / `hermes` / `qmd` / `litellm` 状态正常。 - [ ] 记录最终提交哈希与远端验证结果,回填到本计划的交付结果部分。 @@ -38,6 +39,7 @@ - [ ] Bridge 对外使用 `acp-bridge.onwalk.net`,其余服务默认不公开。 - [ ] 脚本结束输出统一部署摘要:访问入口、一次性凭据、各服务运行状态、可用 Agent CLI。 - [ ] `xfce_desktop / NodeJS / Playwright` 版本均可在单一来源(role defaults)查到并被固定。 +- [ ] 同一主机连续执行两次安装均成功,第二次执行不生成新凭据、不重复下载同一 release 包,并等待而非破坏并发 APT/dpkg 操作。 --- diff --git a/scripts/ai-workspace-offline-install.sh b/scripts/ai-workspace-offline-install.sh index 31bf4de..16e99d4 100755 --- a/scripts/ai-workspace-offline-install.sh +++ b/scripts/ai-workspace-offline-install.sh @@ -7,6 +7,15 @@ BIN_DIR="${ROOT}/packages/bin" IMAGE_DIR="${ROOT}/packages/images" NPM_CACHE_DIR="${ROOT}/packages/npm-cache" PIP_WHEEL_DIR="${ROOT}/packages/pip" +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_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 @@ -23,6 +32,72 @@ require_root() { 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." @@ -30,18 +105,44 @@ configure_local_apt_repo() { fi info "Configuring local APT repository from ${APT_DIR}" - cat > /etc/apt/sources.list.d/ai-workspace-offline.list < "${APT_SOURCE_FILE}" </dev/null 2>&1; then + package_list+=("${package}") + fi +} + +install_offline_prerequisites() { + local packages=(git curl ansible ca-certificates unzip) + + 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" - apt-get install -y unzip || true tmp="$(mktemp -d)" unzip -o "${BIN_DIR}"/vault_*_linux_*.zip -d "${tmp}" install -m 0755 "${tmp}/vault" /usr/local/bin/vault @@ -64,9 +165,19 @@ load_container_images() { 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}" || true + 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}." @@ -76,18 +187,28 @@ load_container_images() { configure_language_package_caches() { if [ -d "${NPM_CACHE_DIR}" ]; then info "Configuring npm to prefer bundled cache" - npm config set cache "${NPM_CACHE_DIR}" --global || true - npm config set prefer-offline true --global || true + 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_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_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" - mkdir -p /etc/pip.conf.d - cat > /etc/pip.conf </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 + fi fi } @@ -107,6 +228,9 @@ run_bootstrap() { 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_DEPLOYMENT_LOCK_HELD=true + export AI_WORKSPACE_APT_LOCK_TIMEOUT info "Running packaged AI Workspace all-in-one bootstrap" bash "${setup_script}" @@ -114,7 +238,10 @@ run_bootstrap() { main() { require_root + acquire_deployment_lock + wait_for_apt_locks configure_local_apt_repo + install_offline_prerequisites install_bundled_binaries load_container_images configure_language_package_caches diff --git a/scripts/create-ai-workspace-offline-package.sh b/scripts/create-ai-workspace-offline-package.sh index 2288156..68aeaa2 100755 --- a/scripts/create-ai-workspace-offline-package.sh +++ b/scripts/create-ai-workspace-offline-package.sh @@ -117,18 +117,45 @@ fi apt-get update -y || true -packages=( +required_packages=( ansible git curl ca-certificates gnupg jq rsync unzip wget +) +optional_packages=( caddy xfce4 python3 python3-pip python3-venv python3-dev python3-setuptools build-essential pkg-config python-is-python3 pandoc fonts-noto-cjk fonts-noto-cjk-extra fonts-wqy-zenhei fonts-wqy-microhei google-chrome-stable nodejs yarn ) +packages=() + +for package in "${required_packages[@]}"; do + if ! apt-cache show "${package}" >/dev/null 2>&1; then + echo "Required APT package is unavailable for this target: ${package}" >&2 + exit 1 + fi + packages+=("${package}") +done + +for package in "${optional_packages[@]}"; do + if apt-cache show "${package}" >/dev/null 2>&1; then + packages+=("${package}") + else + echo "Skipping unavailable optional APT package: ${package}" >&2 + fi +done if apt-cache show docker-ce >/dev/null 2>&1; then - packages+=(docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin) + packages+=(docker-ce docker-ce-cli containerd.io) + for package in docker-buildx-plugin docker-compose-plugin; do + if apt-cache show "${package}" >/dev/null 2>&1; then + packages+=("${package}") + fi + done elif apt-cache show docker.io >/dev/null 2>&1; then - packages+=(docker.io docker-compose-plugin) + packages+=(docker.io) + if apt-cache show docker-compose-plugin >/dev/null 2>&1; then + packages+=(docker-compose-plugin) + fi fi if apt-cache show golang-go >/dev/null 2>&1; then @@ -138,7 +165,7 @@ if apt-cache show texlive-xetex >/dev/null 2>&1; then packages+=(texlive-xetex texlive-latex-extra texlive-fonts-recommended texlive-lang-chinese latexmk) fi -apt-get install --download-only -y --no-install-recommends "${packages[@]}" || true +apt-get install --download-only -y --no-install-recommends "${packages[@]}" apt-get download "nodejs=${NODEJS_22_VERSION}-1nodesource1" || true apt-get download "nodejs=${NODEJS_24_VERSION}-1nodesource1" || true cp -n ./*.deb /offline-apt/ 2>/dev/null || true @@ -215,6 +242,11 @@ export_container_images() { write_manifest() { local manifest="${WORKDIR}/metadata/manifest.json" mkdir -p "$(dirname "${manifest}")" + cat > "${WORKDIR}/metadata/target.env" < "${manifest}" < "${WORKDIR}/README.md" <<'README' # AI Workspace All-in-One Offline Package +The standard online bootstrap can use matching packages published from +`https://github.com/ai-workspace-lab/xworkspace-console/releases` automatically: + +```bash +curl -sfL https://raw.githubusercontent.com/ai-workspace-lab/xworkspace-console/main/scripts/setup-ai-workspace-all-in-one.sh | bash - +``` + Extract this archive on the target host, then run: ```bash @@ -284,8 +323,14 @@ container images when Docker is available, and runs the packaged directories for playbooks, console, core skills, and bridge code. Set the same environment variables supported by the online installer before -running the script, for example `TOKEN`, `XWORKMATE_BRIDGE_DOMAIN`, or -`AI_WORKSPACE_SECURITY_LEVEL`. +running the script, for example: + +```bash +sudo env \ + XWORKMATE_BRIDGE_DOMAIN=acp-bridge.onwalk.net \ + AI_WORKSPACE_SECURITY_LEVEL=strict \ + ./scripts/ai-workspace-offline-install.sh +``` README tar -czf "${OUT}" -C "$(dirname "${WORKDIR}")" "$(basename "${WORKDIR}")" diff --git a/scripts/setup-ai-workspace-all-in-one.sh b/scripts/setup-ai-workspace-all-in-one.sh index a4c6485..8b0a8c4 100755 --- a/scripts/setup-ai-workspace-all-in-one.sh +++ b/scripts/setup-ai-workspace-all-in-one.sh @@ -24,6 +24,16 @@ set -euo pipefail # PLAYBOOK_DIR (optional local playbooks checkout; useful for macOS validation) # XWORKSPACE_CONSOLE_DIR (optional local xworkspace-console checkout for macOS) # QMD_SOURCE_REPO / LITELLM_SOURCE_REPO (optional local git sources for offline installs) +# AI_WORKSPACE_OFFLINE_MODE=auto (default) | force | off +# AI_WORKSPACE_OFFLINE_PACKAGE (local tarball/directory or URL) +# AI_WORKSPACE_OFFLINE_PACKAGE_URL (direct tarball URL) +# AI_WORKSPACE_OFFLINE_PACKAGE_BASE_URL (mirror directory containing target tarball) +# AI_WORKSPACE_OFFLINE_RELEASE_TAG=latest (GitHub release tag or latest) +# AI_WORKSPACE_OFFLINE_REPO=ai-workspace-lab/xworkspace-console +# AI_WORKSPACE_OFFLINE_AUTO_DOWNLOAD=true (download matching GitHub release package in auto mode) +# AI_WORKSPACE_OFFLINE_WORK_DIR=/tmp/ai-workspace-offline +# AI_WORKSPACE_DEPLOYMENT_LOCK_TIMEOUT=1800 +# AI_WORKSPACE_APT_LOCK_TIMEOUT=900 # AI_WORKSPACE_DARWIN_MODE=local (default on macOS) | ansible # ============================================================================== @@ -40,6 +50,12 @@ XWORKMATE_BRIDGE_BRANCH=${XWORKMATE_BRIDGE_BRANCH:-"release/v1.1.4"} XWORKMATE_BRIDGE_SOURCE_DIR=${XWORKMATE_BRIDGE_SOURCE_DIR:-"/tmp/xworkmate-bridge"} AUTH_TOKEN_FILE=${AI_WORKSPACE_AUTH_TOKEN_FILE:-"$HOME/.ai_workspace_auth_token"} VAULT_FILE=${AI_WORKSPACE_VAULT_PASSWORD_FILE:-"$HOME/.vault_password"} +AI_WORKSPACE_OFFLINE_MODE=${AI_WORKSPACE_OFFLINE_MODE:-"auto"} +AI_WORKSPACE_OFFLINE_REPO=${AI_WORKSPACE_OFFLINE_REPO:-"ai-workspace-lab/xworkspace-console"} +AI_WORKSPACE_OFFLINE_RELEASE_TAG=${AI_WORKSPACE_OFFLINE_RELEASE_TAG:-"latest"} +AI_WORKSPACE_OFFLINE_WORK_DIR=${AI_WORKSPACE_OFFLINE_WORK_DIR:-"/tmp/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"} # Function: Output messages info() { @@ -48,6 +64,9 @@ info() { success() { echo -e "\033[1;32m[SUCCESS]\033[0m $*" >&2 } +warn() { + echo -e "\033[1;33m[WARN]\033[0m $*" >&2 +} error() { echo -e "\033[1;31m[ERROR]\033[0m $*" >&2 exit 1 @@ -72,6 +91,77 @@ detect_os() { esac } +acquire_deployment_lock() { + if [ "${AI_WORKSPACE_DEPLOYMENT_LOCK_HELD:-false}" = "true" ]; then + return + fi + if ! command -v flock >/dev/null 2>&1; then + warn "flock is unavailable; continuing without the deployment serialization lock." + return + fi + + local lock_file="${AI_WORKSPACE_DEPLOYMENT_LOCK_FILE:-/var/lock/ai-workspace-all-in-one.lock}" + if [ "$(id -u)" -ne 0 ]; then + lock_file="${AI_WORKSPACE_DEPLOYMENT_LOCK_FILE:-${TMPDIR:-/tmp}/ai-workspace-all-in-one-${UID}.lock}" + fi + mkdir -p "$(dirname "$lock_file")" + exec 9>"$lock_file" + info "Waiting for the AI Workspace deployment lock: $lock_file" + if ! flock -w "$AI_WORKSPACE_DEPLOYMENT_LOCK_TIMEOUT" 9; then + error "Timed out waiting for another AI Workspace deployment to finish." + fi + export AI_WORKSPACE_DEPLOYMENT_LOCK_HELD=true +} + +wait_for_apt_locks() { + if [ ! -f /etc/debian_version ]; then + return + fi + + local timeout="${AI_WORKSPACE_APT_LOCK_TIMEOUT:-900}" + 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 "$timeout" ]; then + error "Timed out after ${timeout}s waiting for APT/dpkg locks." + fi + if [ $((waited % 30)) -eq 0 ]; then + info "Another package manager is active; waiting for APT/dpkg locks (${waited}s/${timeout}s)..." + fi + sleep 5 + waited=$((waited + 5)) + done +} + install_prerequisites() { local os="$1" info "Installing required dependencies (git, ansible)..." @@ -103,6 +193,369 @@ install_prerequisites() { success "Dependencies installed." } +offline_mode_is_force() { + case "$(printf '%s' "${AI_WORKSPACE_OFFLINE_MODE:-auto}" | tr '[:upper:]' '[:lower:]')" in + force|required|true|1|yes) return 0 ;; + *) return 1 ;; + esac +} + +offline_mode_is_off() { + case "$(printf '%s' "${AI_WORKSPACE_OFFLINE_MODE:-auto}" | tr '[:upper:]' '[:lower:]')" in + off|disabled|false|0|no) return 0 ;; + *) return 1 ;; + esac +} + +offline_fail_or_fallback() { + local message=$1 + if offline_mode_is_force; then + error "$message" + fi + warn "$message Falling back to online bootstrap." + return 1 +} + +detect_offline_arch() { + case "$(uname -m)" in + x86_64|amd64) echo "amd64" ;; + aarch64|arm64) echo "arm64" ;; + *) return 1 ;; + esac +} + +detect_offline_target() { + if [ ! -f /etc/os-release ]; then + return 1 + fi + + # shellcheck disable=SC1091 + . /etc/os-release + local distro="${AI_WORKSPACE_OFFLINE_DISTRO_ID:-${ID:-}}" + local version="${AI_WORKSPACE_OFFLINE_DISTRO_VERSION:-${VERSION_ID:-}}" + local arch="${AI_WORKSPACE_OFFLINE_ARCH:-}" + + if [ -z "$arch" ]; then + arch="$(detect_offline_arch)" || return 1 + fi + + case "${distro}:${version}" in + debian:11|debian:12|debian:13|ubuntu:22.04|ubuntu:24.04|ubuntu:26.04) ;; + *) return 1 ;; + esac + + printf '%s %s %s\n' "$distro" "$version" "$arch" +} + +offline_package_filename() { + local target=$1 + # shellcheck disable=SC2086 + set -- $target + printf 'ai-workspace-all-in-one-offline-%s-%s-%s.tar.gz\n' "$1" "$2" "$3" +} + +offline_release_url() { + local filename=$1 + local tag="${AI_WORKSPACE_OFFLINE_RELEASE_TAG:-latest}" + if [ "$tag" = "latest" ]; then + printf 'https://github.com/%s/releases/latest/download/%s\n' "$AI_WORKSPACE_OFFLINE_REPO" "$filename" + else + printf 'https://github.com/%s/releases/download/%s/%s\n' "$AI_WORKSPACE_OFFLINE_REPO" "$tag" "$filename" + fi +} + +offline_package_source() { + local filename=$1 + if [ -n "${AI_WORKSPACE_OFFLINE_PACKAGE:-}" ]; then + printf '%s\n' "$AI_WORKSPACE_OFFLINE_PACKAGE" + return + fi + if [ -n "${AI_WORKSPACE_OFFLINE_PACKAGE_URL:-}" ]; then + printf '%s\n' "$AI_WORKSPACE_OFFLINE_PACKAGE_URL" + return + fi + if [ -n "${AI_WORKSPACE_OFFLINE_PACKAGE_BASE_URL:-}" ]; then + printf '%s/%s\n' "${AI_WORKSPACE_OFFLINE_PACKAGE_BASE_URL%/}" "$filename" + return + fi + if [ "${AI_WORKSPACE_OFFLINE_AUTO_DOWNLOAD:-true}" = "true" ]; then + offline_release_url "$filename" + fi +} + +resolve_offline_source_url() { + local source=$1 + local location + + case "$source" in + http://*|https://*) + location="$( + curl -sSI --connect-timeout 15 --max-time 30 "$source" 2>/dev/null | + tr -d '\r' | + awk 'tolower($1) == "location:" { print $2; exit }' + )" + if [ -n "$location" ]; then + printf '%s\n' "$location" + else + printf '%s\n' "$source" + fi + ;; + *) printf '%s\n' "$source" ;; + esac +} + +source_cache_key() { + local source=$1 + printf '%s' "$source" | sha256_stream | cut -c1-16 +} + +sha256_stream() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | awk '{print $1}' + return + fi + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 | awk '{print $1}' + return + fi + return 1 +} + +offline_root_from_dir() { + local dir=$1 + if [ -f "$dir/scripts/ai-workspace-offline-install.sh" ]; then + cd "$dir" + pwd + return + fi + return 1 +} + +extract_offline_package() { + local package=$1 + local extract_dir="$AI_WORKSPACE_OFFLINE_WORK_DIR/extracted" + local installer archive_checksum cached_checksum cached_root + + validate_offline_archive "$package" || return 1 + archive_checksum="$(sha256_file "$package")" || return 1 + cached_checksum="$(cat "$extract_dir/.archive-sha256" 2>/dev/null || true)" + cached_root="$(cat "$extract_dir/.package-root" 2>/dev/null || true)" + if [ "$cached_checksum" = "$archive_checksum" ] && + [ -n "$cached_root" ] && + [ -f "$cached_root/scripts/ai-workspace-offline-install.sh" ]; then + info "Reusing extracted AI Workspace offline package: $cached_root" + printf '%s\n' "$cached_root" + return + fi + + rm -rf "$extract_dir" + mkdir -p "$extract_dir" + tar -xzf "$package" -C "$extract_dir" + installer="$(find "$extract_dir" -mindepth 2 -maxdepth 3 -type f -path '*/scripts/ai-workspace-offline-install.sh' -print -quit)" + if [ -z "$installer" ]; then + return 1 + fi + cached_root="$(cd "$(dirname "$installer")/.." && pwd)" + printf '%s\n' "$archive_checksum" > "$extract_dir/.archive-sha256" + printf '%s\n' "$cached_root" > "$extract_dir/.package-root" + printf '%s\n' "$cached_root" +} + +sha256_file() { + local file=$1 + sha256_stream < "$file" +} + +validate_offline_archive() { + local package=$1 + local contents="$AI_WORKSPACE_OFFLINE_WORK_DIR/archive-contents.txt" + + [ -f "$package" ] || return 1 + tar -tzf "$package" > "$contents" || return 1 + if awk ' + /^\/+/ { exit 1 } + /(^|\/)\.\.(\/|$)/ { exit 1 } + ' "$contents"; then + return 0 + fi + warn "Offline package contains an unsafe archive path: $package" + return 1 +} + +prepare_offline_package_root() { + local source=$1 + local filename=$2 + local resolved_source cache_key package + resolved_source="$(resolve_offline_source_url "$source")" + cache_key="$(source_cache_key "$resolved_source")" || return 1 + package="$AI_WORKSPACE_OFFLINE_WORK_DIR/${cache_key}-${filename}" + local partial="${package}.part" + + mkdir -p "$AI_WORKSPACE_OFFLINE_WORK_DIR" + + case "$source" in + http://*|https://*) + command -v curl >/dev/null 2>&1 || return 1 + if validate_offline_archive "$package" 2>/dev/null; then + info "Reusing cached AI Workspace offline package: $package" + else + info "Downloading AI Workspace offline package: $resolved_source" + if ! curl -fL --retry 3 --retry-delay 5 --continue-at - -o "$partial" "$resolved_source"; then + rm -f "$partial" + curl -fL --retry 3 --retry-delay 5 -o "$partial" "$resolved_source" || return 1 + fi + validate_offline_archive "$partial" || return 1 + mv "$partial" "$package" + fi + extract_offline_package "$package" + ;; + *) + if offline_root_from_dir "$source" 2>/dev/null; then + return + fi + if [ ! -f "$source" ]; then + return 1 + fi + extract_offline_package "$source" + ;; + esac +} + +manifest_target_value() { + local manifest=$1 + local key=$2 + awk -v key="$key" ' + /"target"[[:space:]]*:/ { in_target=1; next } + in_target && /^[[:space:]]*}/ { exit } + in_target && $0 ~ "\"" key "\"[[:space:]]*:" { + value=$0 + sub(/^.*:[[:space:]]*"/, "", value) + sub(/".*$/, "", value) + print value + exit + } + ' "$manifest" +} + +validate_offline_package_target() { + local root=$1 + local target=$2 + local expected_distro expected_version expected_arch + local package_distro package_version package_arch + + # shellcheck disable=SC2086 + set -- $target + expected_distro=$1 + expected_version=$2 + expected_arch=$3 + + if [ -f "$root/metadata/target.env" ]; then + package_distro="$(sed -n 's/^DISTRO_ID=//p' "$root/metadata/target.env" | head -n 1)" + package_version="$(sed -n 's/^DISTRO_VERSION=//p' "$root/metadata/target.env" | head -n 1)" + package_arch="$(sed -n 's/^ARCH=//p' "$root/metadata/target.env" | head -n 1)" + elif [ -f "$root/metadata/manifest.json" ]; then + package_distro="$(manifest_target_value "$root/metadata/manifest.json" distro)" + package_version="$(manifest_target_value "$root/metadata/manifest.json" version)" + package_arch="$(manifest_target_value "$root/metadata/manifest.json" arch)" + elif [ "${AI_WORKSPACE_OFFLINE_ALLOW_UNVERIFIED_TARGET:-false}" = "true" ]; then + warn "Offline package has no target metadata; proceeding by explicit override." + return + else + error "Offline package is missing metadata/target.env and metadata/manifest.json." + fi + + if [ "$package_distro" != "$expected_distro" ] || + [ "$package_version" != "$expected_version" ] || + [ "$package_arch" != "$expected_arch" ]; then + error "Offline package target ${package_distro}:${package_version}:${package_arch} does not match host ${expected_distro}:${expected_version}:${expected_arch}." + fi +} + +run_offline_installer() { + local root=$1 + local target=$2 + local installer="$root/scripts/ai-workspace-offline-install.sh" + local env_args=( + "AI_WORKSPACE_OFFLINE_ACTIVE=true" + "AI_WORKSPACE_DEPLOYMENT_LOCK_HELD=true" + ) + local env_name + + [ -f "$installer" ] || return 1 + validate_offline_package_target "$root" "$target" + chmod +x "$installer" + + for env_name in \ + AI_WORKSPACE_SECURITY_LEVEL \ + LITELLM_API_CADDY_STRICT_WHITELIST \ + XWORKSPACE_CONSOLE_PUBLIC_ACCESS \ + XWORKMATE_BRIDGE_PUBLIC_ACCESS \ + XWORKMATE_BRIDGE_DOMAIN \ + GATEWAY_OPENCLAW_PUBLIC_ACCESS \ + VAULT_PUBLIC_ACCESS \ + XWORKSPACE_CONSOLE_ENABLE_XRDP \ + AI_WORKSPACE_RUNTIME_MODES \ + POSTGRESQL_DEPLOY_MODE \ + AI_WORKSPACE_AUTH_TOKEN \ + XWORKSPACE_CONSOLE_AUTH_TOKEN \ + XWORKMATE_BRIDGE_AUTH_TOKEN \ + BRIDGE_AUTH_TOKEN \ + INTERNAL_SERVICE_TOKEN \ + DEPLOY_TOKEN \ + ANSIBLE_VAULT_PASSWORD \ + AI_WORKSPACE_AUTH_TOKEN_FILE \ + AI_WORKSPACE_VAULT_PASSWORD_FILE \ + AI_WORKSPACE_APT_LOCK_TIMEOUT; do + if [ -n "${!env_name+x}" ]; then + env_args+=("$env_name=${!env_name}") + fi + done + + info "Using offline AI Workspace package at $root" + if [ "$(id -u)" -eq 0 ]; then + env "${env_args[@]}" bash "$installer" + elif command -v sudo >/dev/null 2>&1; then + sudo env "${env_args[@]}" bash "$installer" + else + return 1 + fi +} + +try_bootstrap_from_offline_package() { + if [ "${AI_WORKSPACE_OFFLINE_ACTIVE:-false}" = "true" ]; then + return 1 + fi + if offline_mode_is_off; then + info "Offline package bootstrap disabled by AI_WORKSPACE_OFFLINE_MODE=$AI_WORKSPACE_OFFLINE_MODE" + return 1 + fi + if [ "$(detect_os)" != "linux" ]; then + return 1 + fi + + local target filename source root + target="$(detect_offline_target)" || { + offline_fail_or_fallback "No supported offline package target detected for this host." + return 1 + } + filename="$(offline_package_filename "$target")" + source="$(offline_package_source "$filename")" + if [ -z "$source" ]; then + offline_fail_or_fallback "No offline package source is configured." + return 1 + fi + + root="$(prepare_offline_package_root "$source" "$filename")" || { + offline_fail_or_fallback "Unable to prepare offline package from $source." + return 1 + } + if run_offline_installer "$root" "$target"; then + return 0 + fi + + error "Offline package installer failed after making deployment changes; online fallback was not started." +} + resolve_unified_auth_token() { local token="${AI_WORKSPACE_AUTH_TOKEN:-}" if [ -z "$token" ]; then token="${XWORKSPACE_CONSOLE_AUTH_TOKEN:-}"; fi @@ -686,7 +1139,8 @@ deploy_launch_agent() { local stderr_log=$5 local plist_dir="$HOME/Library/LaunchAgents" local plist="$plist_dir/$label.plist" - local domain="gui/$(id -u)" + local domain + domain="gui/$(id -u)" mkdir -p "$plist_dir" "$(dirname "$stdout_log")" "$(dirname "$stderr_log")" cat > "$plist" </dev/null 2>&1 || ! command -v git >/dev/null 2>&1; then install_prerequisites "$OS_NAME" fi @@ -996,6 +1458,7 @@ append_var "VAULT_DEPLOY_MODE" "vault_deploy_mode" append_var "XWORKSPACE_CONSOLE_ENABLE_XRDP" "xworkspace_console_enable_xrdp" append_var "AI_WORKSPACE_RUNTIME_MODES" "ai_workspace_runtime_modes" append_var "POSTGRESQL_DEPLOY_MODE" "postgresql_deploy_mode" +append_var "AI_WORKSPACE_APT_LOCK_TIMEOUT" "ai_workspace_apt_lock_timeout" append_var "QMD_SOURCE_REPO" "qmd_source_repo" append_var "QMD_VERSION" "qmd_version" append_var "LITELLM_SOURCE_REPO" "litellm_source_repo" @@ -1048,6 +1511,7 @@ fi chmod 600 "$VAULT_FILE" # 6. Run Ansible Playbook locally +wait_for_apt_locks info "Running Ansible Playbook locally..." ansible-playbook -i '127.0.0.1,' -c local setup-ai-workspace-all-in-one.yml \ --vault-password-file "$VAULT_FILE" \