feat: prefer idempotent offline runtime installs

This commit is contained in:
Haitao Pan 2026-06-15 14:32:36 +08:00
parent 18d2c69271
commit 3b6b03da95
6 changed files with 733 additions and 25 deletions

View File

@ -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" <<EOF
DISTRO_ID=${{ matrix.distro }}
DISTRO_VERSION=${{ matrix.version }}
ARCH=${{ matrix.arch }}
EOF
cat > "${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

View File

@ -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-<distro>-<version>-<arch>.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-<run_number> \
bash scripts/setup-ai-workspace-all-in-one.sh
AI_WORKSPACE_OFFLINE_RELEASE_TAG=offline-ai-workspace-<run_number> \
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.

View File

@ -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 操作。
---

View File

@ -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 <<EOF
cat > "${APT_SOURCE_FILE}" <<EOF
deb [trusted=yes] file:${APT_DIR} ./
EOF
apt-get update -o Dir::Etc::sourcelist="sources.list.d/ai-workspace-offline.list" \
-o Dir::Etc::sourceparts="-" \
-o APT::Get::List-Cleanup="0"
trap 'rm -f "${APT_SOURCE_FILE}"' EXIT
apt-get "${APT_LOCAL_OPTIONS[@]}" update
}
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)
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 <<EOF
[global]
find-links = ${PIP_WHEEL_DIR}
prefer-binary = true
EOF
export PIP_FIND_LINKS="${PIP_WHEEL_DIR}"
export PIP_PREFER_BINARY=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
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

View File

@ -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" <<EOF
DISTRO_ID=${DISTRO_ID}
DISTRO_VERSION=${DISTRO_VERSION}
ARCH=${ARCH}
EOF
cat > "${manifest}" <<JSON
{
"name": "ai-workspace-all-in-one-offline",
@ -272,6 +304,13 @@ main() {
cat > "${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}")"

View File

@ -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" <<EOF
@ -930,6 +1384,14 @@ if [ "$OS_NAME" = "darwin" ] && [ "${AI_WORKSPACE_DARWIN_MODE:-local}" = "local"
exit 0
fi
if [ "$OS_NAME" = "linux" ]; then
acquire_deployment_lock
wait_for_apt_locks
if try_bootstrap_from_offline_package; then
exit 0
fi
fi
if ! command -v ansible-playbook >/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" \