Merge d927a4e55f20a6d3bc505c23c66c85cb2bbda370 into 9fb78453e088cd7b553d7779faa0de5c83708e70

This commit is contained in:
Ruslan Belkin 2026-03-21 01:02:41 -04:00 committed by GitHub
commit 4d09148687
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 896 additions and 66 deletions

View File

@ -3,8 +3,9 @@
# Opt-in extension dependencies at build time (space-separated directory names).
# Example: docker build --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel matrix" .
#
# Multi-stage build produces a minimal runtime image without build tools,
# source code, or Bun. Works with Docker, Buildx, and Podman.
# Multi-stage build keeps source code and Bun out of the runtime image while
# still allowing optional runtime tooling for Docker-hosted workflows.
# Works with Docker, Buildx, and Podman.
# The ext-deps stage extracts only the package.json files we need from
# extensions/, so the main build layer is not invalidated by unrelated
# extension source changes.
@ -55,6 +56,13 @@ ENV PATH="/root/.bun/bin:${PATH}"
RUN corepack enable
ENV PNPM_HOME=/home/node/.local/share/pnpm
ENV NPM_CONFIG_PREFIX=/home/node/.npm-global
ENV GOPATH=/home/node/go
ENV PATH="/usr/local/go/bin:${PNPM_HOME}:${NPM_CONFIG_PREFIX}/bin:${GOPATH}/bin:${PATH}"
RUN mkdir -p "${PNPM_HOME}" "${NPM_CONFIG_PREFIX}/bin" "${GOPATH}/bin" && \
chown -R node:node /home/node/.local /home/node/.npm-global /home/node/go
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
@ -126,17 +134,19 @@ LABEL org.opencontainers.image.source="https://github.com/openclaw/openclaw" \
org.opencontainers.image.description="OpenClaw gateway and CLI runtime container image"
WORKDIR /app
# Install system utilities present in bookworm but missing in bookworm-slim.
# On the full bookworm image these are already installed (apt-get is a no-op).
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
procps hostname curl git lsof openssl
ENV PNPM_HOME=/home/node/.local/share/pnpm
ENV NPM_CONFIG_PREFIX=/home/node/.npm-global
ENV COREPACK_HOME=/usr/local/share/corepack
ENV GOPATH=/home/node/go
ENV HOMEBREW_PREFIX=/home/linuxbrew/.linuxbrew
ENV HOMEBREW_CELLAR=/home/linuxbrew/.linuxbrew/Cellar
ENV HOMEBREW_REPOSITORY=/home/linuxbrew/.linuxbrew/Homebrew
ENV PATH="/usr/local/go/bin:${PNPM_HOME}:${NPM_CONFIG_PREFIX}/bin:${GOPATH}/bin:${HOMEBREW_PREFIX}/bin:${HOMEBREW_PREFIX}/sbin:${PATH}"
RUN chown node:node /app
RUN mkdir -p /home/node/.cache "${PNPM_HOME}" "${NPM_CONFIG_PREFIX}/bin" "${COREPACK_HOME}" \
"${GOPATH}/bin" "${HOMEBREW_REPOSITORY}" "${HOMEBREW_CELLAR}" "${HOMEBREW_PREFIX}/bin" && \
chown -R node:node /home/node/.cache /home/node/.local /home/node/.npm-global /home/node/go /home/linuxbrew "${COREPACK_HOME}"
RUN corepack enable
COPY --from=runtime-assets --chown=node:node /app/dist ./dist
COPY --from=runtime-assets --chown=node:node /app/node_modules ./node_modules
@ -167,15 +177,28 @@ RUN install -d -m 0755 "$COREPACK_HOME" && \
done && \
chmod -R a+rX "$COREPACK_HOME"
# Install additional system packages needed by your skills or extensions.
# Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" .
# Install baseline system packages needed by the slim runtime and common
# Docker workflows. Extra packages can still be layered in via
# OPENCLAW_DOCKER_APT_PACKAGES without reinstalling duplicates.
ARG OPENCLAW_DOCKER_APT_PACKAGES=""
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES; \
fi
set -eux; \
BASE_APT_PACKAGES="\
cron gosu \
git curl wget ca-certificates jq unzip ripgrep procps hostname openssl lsof file \
python3 python3-pip python3-venv \
xvfb xauth \
libgbm1 libnss3 libasound2 libatk-bridge2.0-0 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libxss1 libgtk-3-0"; \
EXTRA_APT_PACKAGES=""; \
for pkg in $OPENCLAW_DOCKER_APT_PACKAGES; do \
case " ${BASE_APT_PACKAGES} " in \
*" ${pkg} "*) ;; \
*) EXTRA_APT_PACKAGES="${EXTRA_APT_PACKAGES} ${pkg}" ;; \
esac; \
done; \
apt-get update; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ${BASE_APT_PACKAGES} ${EXTRA_APT_PACKAGES}
# Optionally install Chromium and Xvfb for browser automation.
# Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ...
@ -185,14 +208,67 @@ ARG OPENCLAW_INSTALL_BROWSER=""
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends xvfb && \
mkdir -p /home/node/.cache/ms-playwright && \
PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright \
node /app/node_modules/playwright-core/cli.js install --with-deps chromium && \
chown -R node:node /home/node/.cache/ms-playwright; \
fi
# ---- Install Go (official) ----
# Fetch the latest stable version from go.dev and install the correct arch.
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) GOARCH=amd64 ;; \
arm64) GOARCH=arm64 ;; \
*) echo "Unsupported arch: $arch" >&2; exit 1 ;; \
esac; \
GOVERSION="$(curl -fsSL 'https://go.dev/dl/?mode=json' | jq -r 'map(select(.stable==true)) | .[0].version')" ; \
echo "Installing Go ${GOVERSION} for linux-${GOARCH}"; \
curl -fsSL "https://go.dev/dl/${GOVERSION}.linux-${GOARCH}.tar.gz" -o /tmp/go.tgz; \
rm -rf /usr/local/go; \
tar -C /usr/local -xzf /tmp/go.tgz; \
rm -f /tmp/go.tgz; \
/usr/local/go/bin/go version
# Ensure Go is first in PATH (no old go ahead of it)
ENV PATH="/usr/local/go/bin:${PATH}"
# ---- Install gog (gogcli) ----
# Pin version by setting GOGCLI_TAG at build time.
# Default stays pinned for reproducible CI builds.
ARG GOGCLI_TAG=v0.11.0
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) GOGARCH=amd64 ;; \
arm64) GOGARCH=arm64 ;; \
*) echo "Unsupported arch: $arch" >&2; exit 1 ;; \
esac; \
tag="$GOGCLI_TAG"; \
if [ "$tag" = "latest" ]; then \
tag="$(curl -fsSI -H 'User-Agent: openclaw-docker-build' https://github.com/steipete/gogcli/releases/latest | awk 'tolower($1)==\"location:\" {print $2}' | tr -d '\r' | awk -F/ '{print $NF}' | tail -n1)"; \
if [ -z "$tag" ]; then \
echo "WARN: Failed to resolve gogcli latest release tag; falling back to v0.11.0" >&2; \
tag="v0.11.0"; \
fi; \
fi; \
ver="${tag#v}"; \
url="https://github.com/steipete/gogcli/releases/download/$tag/gogcli_${ver}_linux_${GOGARCH}.tar.gz"; \
echo "Downloading: $url"; \
curl -fsSL "$url" -o /tmp/gogcli.tgz; \
tar -xzf /tmp/gogcli.tgz -C /tmp; \
install -m 0755 /tmp/gog /usr/local/bin/gog; \
rm -f /tmp/gog /tmp/gogcli.tgz; \
gog --help >/dev/null
# Install Linuxbrew in a node-writable prefix so brew installs work at runtime.
RUN set -eux; \
curl -fsSL https://github.com/Homebrew/brew/tarball/master | tar xz --strip-components=1 -C "${HOMEBREW_REPOSITORY}"; \
ln -sf ../Homebrew/bin/brew "${HOMEBREW_PREFIX}/bin/brew"; \
chown -R node:node /home/linuxbrew
RUN gosu node brew --version >/dev/null
# Optionally install Docker CLI for sandbox container management.
# Build with: docker build --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1 ...
# Adds ~50MB. Only the CLI is installed — no Docker daemon.
@ -225,6 +301,16 @@ RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,shar
docker-ce-cli docker-compose-plugin; \
fi
COPY --from=build --chown=node:node /app/scripts/docker ./scripts/docker
# Normalize extension paths so plugin safety checks do not reject
# world-writable directories inherited from source file modes.
RUN for dir in /app/extensions /app/.agent /app/.agents; do \
if [ -d "$dir" ]; then \
find "$dir" -type d -exec chmod 755 {} +; \
find "$dir" -type f -exec chmod 644 {} +; \
fi; \
done
RUN chmod +x scripts/docker/gateway-entrypoint.sh
# Expose the CLI binary without requiring npm global writes as non-root.
RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \
&& chmod 755 /app/openclaw.mjs

View File

@ -1,6 +1,8 @@
services:
openclaw-gateway:
image: ${OPENCLAW_IMAGE:-openclaw:local}
user: root
entrypoint: ["/app/scripts/docker/gateway-entrypoint.sh"]
environment:
HOME: /home/node
TERM: xterm-256color
@ -11,6 +13,7 @@ services:
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-}
TZ: ${OPENCLAW_TZ:-UTC}
volumes:
- ./.env:/app/.env:ro
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
## Uncomment the lines below to enable sandbox isolation
@ -68,6 +71,7 @@ services:
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-}
TZ: ${OPENCLAW_TZ:-UTC}
volumes:
- ./.env:/app/.env:ro
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
stdin_open: true

0
docker-setup.sh Executable file → Normal file
View File

View File

@ -50,7 +50,6 @@ Docker is **optional**. Use it only if you want a containerized gateway or to va
<Step title="Complete onboarding">
The setup script runs onboarding automatically. It will:
- prompt for provider API keys
- generate a gateway token and write it to `.env`
- start the gateway via Docker Compose
@ -108,15 +107,121 @@ include it with `-f docker-compose.yml -f docker-compose.extra.yml`.
The setup script accepts these optional environment variables:
| Variable | Purpose |
| ------------------------------ | ---------------------------------------------------------------- |
| `OPENCLAW_IMAGE` | Use a remote image instead of building locally |
| `OPENCLAW_DOCKER_APT_PACKAGES` | Install extra apt packages during build (space-separated) |
| `OPENCLAW_EXTENSIONS` | Pre-install extension deps at build time (space-separated names) |
| `OPENCLAW_EXTRA_MOUNTS` | Extra host bind mounts (comma-separated `source:target[:opts]`) |
| `OPENCLAW_HOME_VOLUME` | Persist `/home/node` in a named Docker volume |
| `OPENCLAW_SANDBOX` | Opt in to sandbox bootstrap (`1`, `true`, `yes`, `on`) |
| `OPENCLAW_DOCKER_SOCKET` | Override Docker socket path |
| Variable | Purpose |
| ------------------------------------ | ---------------------------------------------------------------- |
| `OPENCLAW_IMAGE` | Use a remote image instead of building locally |
| `OPENCLAW_DOCKER_APT_PACKAGES` | Install extra apt packages during build (space-separated) |
| `OPENCLAW_INSTALL_BROWSER` | Install Chromium and Playwright deps during the image build |
| `OPENCLAW_EXTENSIONS` | Pre-install extension deps at build time (space-separated names) |
| `OPENCLAW_EXTRA_MOUNTS` | Extra host bind mounts (comma-separated `source:target[:opts]`) |
| `OPENCLAW_HOME_VOLUME` | Persist `/home/node` in a named Docker volume |
| `OPENCLAW_SANDBOX` | Opt in to sandbox bootstrap (`1`, `true`, `yes`, `on`) |
| `OPENCLAW_DOCKER_SOCKET` | Override Docker socket path |
| `OPENCLAW_INSTALL_DOCKER_CLI` | Install Docker CLI in the image (auto-set for sandbox bootstrap) |
| `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS` | Allow trusted private-network `ws://` client targets |
| `OPENCLAW_TZ` | Set container timezone (for example `America/Los_Angeles`) |
### Use a remote image (skip local build)
Use image name `ghcr.io/openclaw/openclaw` (not similarly named Docker Hub
images).
Common tags:
- `main` — latest build from `main`
- `<version>` — release tag builds (for example `2026.3.12`)
- `latest` — latest stable release tag
```bash
export OPENCLAW_IMAGE="ghcr.io/openclaw/openclaw:latest"
./scripts/docker/setup.sh
```
### Extra mounts (optional)
Set `OPENCLAW_EXTRA_MOUNTS` to add host directories to both containers:
```bash
export OPENCLAW_EXTRA_MOUNTS="$HOME/.codex:/home/node/.codex:ro,$HOME/github:/home/node/github:rw"
./scripts/docker/setup.sh
```
Notes:
- Paths must be shared with Docker Desktop on macOS/Windows.
- Each entry must be `source:target[:options]` with no spaces or control characters.
- Rerun the setup script after changing the value so `docker-compose.extra.yml`
is regenerated.
### Persist the entire container home (optional)
Use `OPENCLAW_HOME_VOLUME` if you want `/home/node` to survive container
recreation:
```bash
export OPENCLAW_HOME_VOLUME="openclaw_home"
./scripts/docker/setup.sh
```
### Install extra apt packages (optional)
`OPENCLAW_DOCKER_APT_PACKAGES` installs additional Debian packages at build time:
```bash
export OPENCLAW_DOCKER_APT_PACKAGES="ffmpeg build-essential"
./scripts/docker/setup.sh
```
The Docker image already includes a baseline for common container workflows:
`cron`, `gosu`, `pnpm`, non-root `npm -g`, `go install`, Linuxbrew, and browser
runtime libraries. Add only the extra packages your environment needs.
### Install browser dependencies at build time (optional)
If you want Chromium baked into the image instead of downloading it later:
```bash
export OPENCLAW_INSTALL_BROWSER=1
./scripts/docker/setup.sh
```
This installs Chromium through Playwright during the image build and stores it
under `/home/node/.cache/ms-playwright`.
### Pre-install extension dependencies (optional)
Extensions with their own `package.json` can be baked into the image:
```bash
export OPENCLAW_EXTENSIONS="diagnostics-otel matrix"
./scripts/docker/setup.sh
```
### Keep macOS awake (optional)
Docker itself does not reliably keep macOS awake. If the gateway must stay
online for Slack, webhooks, or scheduled jobs:
```bash
scripts/openclaw-keepawake.sh on
scripts/openclaw-keepawake.sh status
scripts/openclaw-keepawake.sh off
```
By default this uses `caffeinate -imsu`, which allows the displays to sleep.
Set `OPENCLAW_KEEPAWAKE_FLAGS=-dimsu` if you also want to keep the displays on.
### Base image metadata
The main Docker image uses `node:24-bookworm` and publishes OCI base-image
annotations including:
- `org.opencontainers.image.base.name`
- `org.opencontainers.image.base.digest`
- `org.opencontainers.image.source`
- `org.opencontainers.image.documentation`
Reference: [OCI image annotations](https://github.com/opencontainers/image-spec/blob/main/annotations.md)
### Health checks
@ -279,6 +384,89 @@ See the [`ClawDock` Helper README](https://github.com/openclaw/openclaw/blob/mai
</Accordion>
</AccordionGroup>
## Backup and migration (Intel Mac to Apple Silicon)
For low-disruption host migration, move OpenClaw data and config, then rebuild
the Docker image natively on the new machine.
Use:
- `scripts/migrate/backup-openclaw.sh` on the source host
- `scripts/migrate/restore-openclaw.sh` on the target host
### 1) Create a backup on the source host
From the repo root:
```bash
scripts/migrate/backup-openclaw.sh
```
The archive includes:
- OpenClaw config dir (`OPENCLAW_CONFIG_DIR` or `~/.openclaw`)
- OpenClaw workspace dir (`OPENCLAW_WORKSPACE_DIR` or `~/.openclaw/workspace`)
- `.env` and Docker setup files from the repo root
- metadata and an internal checksum manifest
Output files:
- `backups/openclaw-backup-<timestamp>.tar.gz`
- `backups/openclaw-backup-<timestamp>.tar.gz.sha256`
Optional path overrides:
```bash
scripts/migrate/backup-openclaw.sh \
--config-dir "$HOME/.openclaw" \
--workspace-dir "$HOME/.openclaw/workspace" \
--output-dir "$HOME/openclaw-backups"
```
### 2) Transfer the archive to the target host
Copy the archive and checksum file to the new machine using your normal secure
transfer method.
### 3) Restore on the target host
From the repo root on the target host:
```bash
scripts/migrate/restore-openclaw.sh --archive /path/to/openclaw-backup-<timestamp>.tar.gz
```
Default restore behavior:
- verifies archive checksums
- stops `openclaw-gateway` before restore
- snapshots current config and workspace as `.pre-restore-<timestamp>`
- restores config and workspace from backup
- writes the backup env file as `.env.from-backup` for review
To overwrite `.env` directly:
```bash
scripts/migrate/restore-openclaw.sh \
--archive /path/to/openclaw-backup-<timestamp>.tar.gz \
--apply-env
```
### 4) Rebuild and validate on the target architecture
Always rebuild on Apple Silicon:
```bash
docker compose up -d --build --force-recreate openclaw-gateway
docker compose run --rm openclaw-cli health
docker compose run --rm openclaw-cli channels status --probe
```
### Architecture migration note
Do not carry over architecture-specific binary caches from x86 to arm hosts.
Rebuild containers and reinstall native toolchains on the target host.
### Running on a VPS?
See [Hetzner (Docker VPS)](/install/hetzner) and

View File

@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "gateway-entrypoint requires root (set docker compose service user: root)" >&2
exit 1
fi
if [ "${OPENCLAW_ENABLE_CRON:-1}" = "1" ] && command -v cron >/dev/null 2>&1; then
# Start cron daemon once; jobs are configured by user crontabs via `crontab`.
if ! pgrep -x cron >/dev/null 2>&1; then
cron
fi
fi
ensure_node_writable_dir() {
local dir="$1"
mkdir -p "$dir"
chown -R node:node "$dir"
}
export COREPACK_HOME="${COREPACK_HOME:-/home/node/.cache/node/corepack}"
ensure_node_writable_dir /home/node/.cache
ensure_node_writable_dir "${PNPM_HOME:-/home/node/.local/share/pnpm}"
ensure_node_writable_dir "${NPM_CONFIG_PREFIX:-/home/node/.npm-global}"
ensure_node_writable_dir "${GOPATH:-/home/node/go}"
if command -v gosu >/dev/null 2>&1; then
exec gosu node "$@"
fi
exec su -s /bin/sh node -c "$*"

View File

@ -235,6 +235,7 @@ export OPENCLAW_BRIDGE_PORT="${OPENCLAW_BRIDGE_PORT:-18790}"
export OPENCLAW_GATEWAY_BIND="${OPENCLAW_GATEWAY_BIND:-lan}"
export OPENCLAW_IMAGE="$IMAGE_NAME"
export OPENCLAW_DOCKER_APT_PACKAGES="${OPENCLAW_DOCKER_APT_PACKAGES:-}"
export OPENCLAW_INSTALL_BROWSER="${OPENCLAW_INSTALL_BROWSER:-}"
export OPENCLAW_EXTENSIONS="${OPENCLAW_EXTENSIONS:-}"
export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS"
export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME"
@ -427,6 +428,7 @@ upsert_env "$ENV_FILE" \
DOCKER_GID \
OPENCLAW_INSTALL_DOCKER_CLI \
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS \
OPENCLAW_INSTALL_BROWSER \
OPENCLAW_TZ
if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
@ -435,6 +437,7 @@ if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
--build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \
--build-arg "OPENCLAW_EXTENSIONS=${OPENCLAW_EXTENSIONS}" \
--build-arg "OPENCLAW_INSTALL_DOCKER_CLI=${OPENCLAW_INSTALL_DOCKER_CLI:-}" \
--build-arg "OPENCLAW_INSTALL_BROWSER=${OPENCLAW_INSTALL_BROWSER}" \
-t "$IMAGE_NAME" \
-f "$ROOT_DIR/Dockerfile" \
"$ROOT_DIR"

View File

@ -0,0 +1,208 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/migrate/backup-openclaw.sh [options]
Options:
--repo-root <path> OpenClaw repo root (default: current repo)
--env-file <path> Env file to include (default: <repo-root>/.env)
--config-dir <path> OpenClaw config dir (default: env or ~/.openclaw)
--workspace-dir <path> OpenClaw workspace dir (default: env or ~/.openclaw/workspace)
--output-dir <path> Output directory for backup archive (default: <repo-root>/backups)
--name <name> Backup name prefix (default: openclaw-backup-<timestamp>)
-h, --help Show this help
EOF
}
fail() {
echo "ERROR: $*" >&2
exit 1
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1"
}
strip_quotes() {
local value="$1"
if [[ "${value}" == \"*\" && "${value}" == *\" ]]; then
value="${value:1:${#value}-2}"
elif [[ "${value}" == \'*\' && "${value}" == *\' ]]; then
value="${value:1:${#value}-2}"
fi
printf '%s' "$value"
}
env_value_from_file() {
local file="$1"
local key="$2"
[[ -f "$file" ]] || return 0
local line
line="$(grep -E "^(export[[:space:]]+)?${key}=" "$file" | tail -n 1 || true)"
[[ -n "$line" ]] || return 0
line="${line#export }"
local value="${line#*=}"
strip_quotes "$value"
}
resolve_abs_path() {
local p="$1"
python3 - "$p" <<'PY'
import os
import sys
path = sys.argv[1]
print(os.path.abspath(os.path.expanduser(path)))
PY
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
REPO_ROOT="$ROOT_DIR"
ENV_FILE="$ROOT_DIR/.env"
CONFIG_DIR=""
WORKSPACE_DIR=""
OUTPUT_DIR="$ROOT_DIR/backups"
BACKUP_NAME=""
while [[ $# -gt 0 ]]; do
case "$1" in
--repo-root)
REPO_ROOT="$2"
shift 2
;;
--env-file)
ENV_FILE="$2"
shift 2
;;
--config-dir)
CONFIG_DIR="$2"
shift 2
;;
--workspace-dir)
WORKSPACE_DIR="$2"
shift 2
;;
--output-dir)
OUTPUT_DIR="$2"
shift 2
;;
--name)
BACKUP_NAME="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
require_cmd tar
require_cmd rsync
require_cmd shasum
require_cmd python3
require_cmd date
require_cmd uname
REPO_ROOT="$(resolve_abs_path "$REPO_ROOT")"
ENV_FILE="$(resolve_abs_path "$ENV_FILE")"
OUTPUT_DIR="$(resolve_abs_path "$OUTPUT_DIR")"
if [[ -z "$CONFIG_DIR" ]]; then
CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$(env_value_from_file "$ENV_FILE" OPENCLAW_CONFIG_DIR)}"
fi
if [[ -z "$WORKSPACE_DIR" ]]; then
WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$(env_value_from_file "$ENV_FILE" OPENCLAW_WORKSPACE_DIR)}"
fi
CONFIG_DIR="${CONFIG_DIR:-$HOME/.openclaw}"
WORKSPACE_DIR="${WORKSPACE_DIR:-$HOME/.openclaw/workspace}"
CONFIG_DIR="$(resolve_abs_path "$CONFIG_DIR")"
WORKSPACE_DIR="$(resolve_abs_path "$WORKSPACE_DIR")"
[[ -d "$CONFIG_DIR" ]] || fail "Config directory does not exist: $CONFIG_DIR"
[[ -d "$WORKSPACE_DIR" ]] || fail "Workspace directory does not exist: $WORKSPACE_DIR"
[[ -d "$REPO_ROOT" ]] || fail "Repo root does not exist: $REPO_ROOT"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
BACKUP_NAME="${BACKUP_NAME:-openclaw-backup-${timestamp}}"
mkdir -p "$OUTPUT_DIR"
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
stage="$tmpdir/stage"
mkdir -p "$stage/payload/config" "$stage/payload/workspace" "$stage/payload/repo" "$stage/meta"
echo "==> Copying config directory"
rsync -a "$CONFIG_DIR/" "$stage/payload/config/"
echo "==> Copying workspace directory"
rsync -a "$WORKSPACE_DIR/" "$stage/payload/workspace/"
if [[ -f "$ENV_FILE" ]]; then
echo "==> Including env file: $ENV_FILE"
cp "$ENV_FILE" "$stage/payload/repo/.env"
fi
for file in docker-compose.yml docker-compose.extra.yml Dockerfile docker-setup.sh; do
if [[ -f "$REPO_ROOT/$file" ]]; then
cp "$REPO_ROOT/$file" "$stage/payload/repo/$file"
fi
done
{
echo "timestamp_utc=$timestamp"
echo "source_host=$(hostname -s || hostname)"
echo "source_arch=$(uname -m)"
echo "source_os=$(uname -s)"
echo "repo_root=$REPO_ROOT"
echo "config_dir=$CONFIG_DIR"
echo "workspace_dir=$WORKSPACE_DIR"
} >"$stage/meta/backup.env"
if command -v docker >/dev/null 2>&1; then
{
echo "# docker version"
docker version --format '{{.Server.Version}}' 2>/dev/null || true
echo
echo "# docker compose ps"
docker compose -f "$REPO_ROOT/docker-compose.yml" ps 2>/dev/null || true
} >"$stage/meta/docker.txt"
fi
if command -v git >/dev/null 2>&1 && [[ -d "$REPO_ROOT/.git" ]]; then
{
echo "branch=$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD)"
echo "commit=$(git -C "$REPO_ROOT" rev-parse HEAD)"
echo
echo "# status"
git -C "$REPO_ROOT" status --short
} >"$stage/meta/git.txt"
fi
(
cd "$stage"
find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 shasum -a 256 > SHA256SUMS
)
archive_path="$OUTPUT_DIR/${BACKUP_NAME}.tar.gz"
(
cd "$stage"
tar -czf "$archive_path" .
)
shasum -a 256 "$archive_path" > "${archive_path}.sha256"
echo
echo "Backup created:"
echo " $archive_path"
echo " ${archive_path}.sha256"
echo
echo "Next step on target host:"
echo " scripts/migrate/restore-openclaw.sh --archive \"$archive_path\""

View File

@ -0,0 +1,201 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/migrate/restore-openclaw.sh --archive <path> [options]
Options:
--archive <path> Backup archive created by backup-openclaw.sh (required)
--repo-root <path> OpenClaw repo root (default: current repo)
--env-file <path> Env file path (default: <repo-root>/.env)
--config-dir <path> OpenClaw config dir (default: env or ~/.openclaw)
--workspace-dir <path> OpenClaw workspace dir (default: env or ~/.openclaw/workspace)
--apply-env Overwrite --env-file with backup .env (default: false)
--no-stop Do not stop gateway container before restore
-h, --help Show this help
EOF
}
fail() {
echo "ERROR: $*" >&2
exit 1
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1"
}
strip_quotes() {
local value="$1"
if [[ "${value}" == \"*\" && "${value}" == *\" ]]; then
value="${value:1:${#value}-2}"
elif [[ "${value}" == \'*\' && "${value}" == *\' ]]; then
value="${value:1:${#value}-2}"
fi
printf '%s' "$value"
}
env_value_from_file() {
local file="$1"
local key="$2"
[[ -f "$file" ]] || return 0
local line
line="$(grep -E "^(export[[:space:]]+)?${key}=" "$file" | tail -n 1 || true)"
[[ -n "$line" ]] || return 0
line="${line#export }"
local value="${line#*=}"
strip_quotes "$value"
}
resolve_abs_path() {
local p="$1"
python3 - "$p" <<'PY'
import os
import sys
path = sys.argv[1]
print(os.path.abspath(os.path.expanduser(path)))
PY
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
REPO_ROOT="$ROOT_DIR"
ENV_FILE="$ROOT_DIR/.env"
ARCHIVE_PATH=""
CONFIG_DIR=""
WORKSPACE_DIR=""
APPLY_ENV=0
STOP_FIRST=1
while [[ $# -gt 0 ]]; do
case "$1" in
--archive)
ARCHIVE_PATH="$2"
shift 2
;;
--repo-root)
REPO_ROOT="$2"
shift 2
;;
--env-file)
ENV_FILE="$2"
shift 2
;;
--config-dir)
CONFIG_DIR="$2"
shift 2
;;
--workspace-dir)
WORKSPACE_DIR="$2"
shift 2
;;
--apply-env)
APPLY_ENV=1
shift
;;
--no-stop)
STOP_FIRST=0
shift
;;
-h|--help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
[[ -n "$ARCHIVE_PATH" ]] || fail "--archive is required"
require_cmd tar
require_cmd rsync
require_cmd shasum
require_cmd python3
require_cmd date
ARCHIVE_PATH="$(resolve_abs_path "$ARCHIVE_PATH")"
REPO_ROOT="$(resolve_abs_path "$REPO_ROOT")"
ENV_FILE="$(resolve_abs_path "$ENV_FILE")"
[[ -f "$ARCHIVE_PATH" ]] || fail "Archive not found: $ARCHIVE_PATH"
[[ -d "$REPO_ROOT" ]] || fail "Repo root does not exist: $REPO_ROOT"
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
echo "==> Extracting archive"
tar -xzf "$ARCHIVE_PATH" -C "$tmpdir"
[[ -f "$tmpdir/SHA256SUMS" ]] || fail "Archive missing SHA256SUMS"
(
cd "$tmpdir"
shasum -a 256 -c SHA256SUMS
)
if [[ -z "$CONFIG_DIR" ]]; then
CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$(env_value_from_file "$ENV_FILE" OPENCLAW_CONFIG_DIR)}"
fi
if [[ -z "$WORKSPACE_DIR" ]]; then
WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$(env_value_from_file "$ENV_FILE" OPENCLAW_WORKSPACE_DIR)}"
fi
CONFIG_DIR="${CONFIG_DIR:-$HOME/.openclaw}"
WORKSPACE_DIR="${WORKSPACE_DIR:-$HOME/.openclaw/workspace}"
CONFIG_DIR="$(resolve_abs_path "$CONFIG_DIR")"
WORKSPACE_DIR="$(resolve_abs_path "$WORKSPACE_DIR")"
if [[ $STOP_FIRST -eq 1 ]] && command -v docker >/dev/null 2>&1; then
echo "==> Stopping gateway container"
docker compose -f "$REPO_ROOT/docker-compose.yml" stop openclaw-gateway >/dev/null 2>&1 || true
fi
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$(dirname "$CONFIG_DIR")" "$(dirname "$WORKSPACE_DIR")"
if [[ -d "$CONFIG_DIR" ]]; then
mv "$CONFIG_DIR" "${CONFIG_DIR}.pre-restore-${timestamp}"
fi
if [[ -d "$WORKSPACE_DIR" ]]; then
mv "$WORKSPACE_DIR" "${WORKSPACE_DIR}.pre-restore-${timestamp}"
fi
mkdir -p "$CONFIG_DIR" "$WORKSPACE_DIR"
echo "==> Restoring config"
rsync -a "$tmpdir/payload/config/" "$CONFIG_DIR/"
echo "==> Restoring workspace"
rsync -a "$tmpdir/payload/workspace/" "$WORKSPACE_DIR/"
if [[ -f "$tmpdir/payload/repo/.env" ]]; then
if [[ $APPLY_ENV -eq 1 ]]; then
mkdir -p "$(dirname "$ENV_FILE")"
if [[ -f "$ENV_FILE" ]]; then
cp "$ENV_FILE" "${ENV_FILE}.pre-restore-${timestamp}"
fi
cp "$tmpdir/payload/repo/.env" "$ENV_FILE"
echo "==> Applied backed up env file to $ENV_FILE"
else
cp "$tmpdir/payload/repo/.env" "${ENV_FILE}.from-backup"
echo "==> Wrote env candidate to ${ENV_FILE}.from-backup"
fi
fi
source_arch="$(grep -E '^source_arch=' "$tmpdir/meta/backup.env" | cut -d= -f2- || true)"
target_arch="$(uname -m)"
if [[ -n "$source_arch" && "$source_arch" != "$target_arch" ]]; then
echo
echo "NOTE: source arch (${source_arch}) differs from target arch (${target_arch})."
echo "Rebuild the Docker image on this host; do not reuse old binary caches or volumes."
fi
echo
echo "Restore completed."
echo "Next steps:"
echo " 1) docker compose -f \"$REPO_ROOT/docker-compose.yml\" up -d --build --force-recreate openclaw-gateway"
echo " 2) docker compose -f \"$REPO_ROOT/docker-compose.yml\" run --rm openclaw-cli health"
echo " 3) docker compose -f \"$REPO_ROOT/docker-compose.yml\" run --rm openclaw-cli channels status --probe"

116
scripts/openclaw-keepawake.sh Executable file
View File

@ -0,0 +1,116 @@
#!/usr/bin/env bash
set -euo pipefail
PID_FILE="${OPENCLAW_KEEPAWAKE_PID_FILE:-$HOME/.openclaw/run/keepawake.pid}"
KEEPAWAKE_FLAGS="${OPENCLAW_KEEPAWAKE_FLAGS:--imsu}"
usage() {
cat <<'USAGE'
Usage: scripts/openclaw-keepawake.sh <on|off|status|restart>
Keeps macOS awake using `caffeinate` for long-running OpenClaw Docker sessions.
Commands:
on Start keep-awake background process
off Stop keep-awake background process
status Show current keep-awake status
restart Restart keep-awake background process
Environment:
OPENCLAW_KEEPAWAKE_FLAGS caffeinate flags (default: -imsu)
Use -dimsu to keep displays awake too.
USAGE
}
ensure_caffeinate() {
if ! command -v caffeinate >/dev/null 2>&1; then
echo "caffeinate not found (this helper is for macOS)." >&2
exit 1
fi
}
read_pid() {
if [[ -f "$PID_FILE" ]]; then
tr -d '[:space:]' <"$PID_FILE"
fi
}
is_caffeinate_pid() {
local pid="$1"
[[ -n "$pid" ]] || return 1
kill -0 "$pid" >/dev/null 2>&1 || return 1
local comm
comm="$(ps -p "$pid" -o comm= 2>/dev/null | tr -d '[:space:]')"
[[ "$comm" == "caffeinate" ]]
}
start_awake() {
ensure_caffeinate
mkdir -p "$(dirname "$PID_FILE")"
local pid
pid="$(read_pid || true)"
if is_caffeinate_pid "$pid"; then
echo "keep-awake already on (pid $pid)"
return 0
fi
local -a flags
# Allow override for special cases (for example keeping displays on).
read -r -a flags <<<"$KEEPAWAKE_FLAGS"
caffeinate "${flags[@]}" >/dev/null 2>&1 &
pid="$!"
echo "$pid" >"$PID_FILE"
echo "keep-awake on (pid $pid, flags: $KEEPAWAKE_FLAGS)"
}
stop_awake() {
local pid
pid="$(read_pid || true)"
if [[ -z "$pid" ]]; then
echo "keep-awake already off"
return 0
fi
if is_caffeinate_pid "$pid"; then
kill "$pid" >/dev/null 2>&1 || true
echo "keep-awake off (stopped pid $pid)"
else
echo "keep-awake off (stale pid file removed)"
fi
rm -f "$PID_FILE"
}
status_awake() {
local pid
pid="$(read_pid || true)"
if is_caffeinate_pid "$pid"; then
echo "keep-awake is on (pid $pid)"
else
echo "keep-awake is off"
if [[ -n "$pid" ]]; then
rm -f "$PID_FILE"
fi
fi
}
cmd="${1:-}"
case "$cmd" in
on)
start_awake
;;
off)
stop_awake
;;
status)
status_awake
;;
restart)
stop_awake
start_awake
;;
*)
usage
exit 2
;;
esac

View File

@ -7,12 +7,14 @@ metadata:
"openclaw":
{
"emoji": "🧾",
"os": ["darwin"],
"requires": { "bins": ["summarize"] },
"install":
[
{
"id": "brew",
"kind": "brew",
"os": ["darwin"],
"formula": "steipete/tap/summarize",
"bins": ["summarize"],
"label": "Install summarize (brew)",

View File

@ -1,5 +1,5 @@
import "./isolated-agent.mocks.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loadModelCatalog } from "../agents/model-catalog.js";
import * as modelSelection from "../agents/model-selection.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
@ -11,6 +11,7 @@ import {
withTempCronHome,
writeSessionStoreEntries,
} from "./isolated-agent.test-harness.js";
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
import type { CronJob } from "./types.js";
const withTempHome = withTempCronHome;
@ -126,11 +127,16 @@ async function expectInvalidModel(home: string, model: string) {
describe("cron model formatting and precedence edge cases", () => {
beforeEach(() => {
setupIsolatedAgentTurnMocks({ fast: true });
vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue("off");
vi.mocked(runEmbeddedPiAgent).mockClear();
vi.mocked(loadModelCatalog).mockResolvedValue([]);
});
afterEach(() => {
vi.unstubAllEnvs();
});
// ------ provider/model string splitting ------
describe("parseModelRef formatting", () => {

View File

@ -27,6 +27,7 @@ describe("Dockerfile", () => {
const dockerfile = await readFile(dockerfilePath, "utf8");
const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile");
const browserArgIndex = dockerfile.indexOf("ARG OPENCLAW_INSTALL_BROWSER");
const baseAptPackagesMatch = dockerfile.match(/BASE_APT_PACKAGES="([\s\S]*?)";/);
expect(installIndex).toBeGreaterThan(-1);
expect(browserArgIndex).toBeGreaterThan(-1);
@ -34,7 +35,9 @@ describe("Dockerfile", () => {
expect(dockerfile).toContain(
"node /app/node_modules/playwright-core/cli.js install --with-deps chromium",
);
expect(dockerfile).toContain("apt-get install -y --no-install-recommends xvfb");
expect(baseAptPackagesMatch).not.toBeNull();
expect(baseAptPackagesMatch?.[1]).toContain("xvfb");
expect(dockerfile).toContain("apt-get install -y --no-install-recommends ${BASE_APT_PACKAGES}");
});
it("prunes runtime dependencies after the build stage", async () => {

View File

@ -1,22 +1,14 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import {
buildPluginSdkEntrySources,
buildPluginSdkPackageExports,
buildPluginSdkSpecifiers,
pluginSdkEntrypoints,
} from "./entrypoints.js";
import { buildPluginSdkPackageExports, buildPluginSdkSpecifiers } from "./entrypoints.js";
const pluginSdkSpecifiers = buildPluginSdkSpecifiers();
const execFileAsync = promisify(execFile);
const require = createRequire(import.meta.url);
const tsdownModuleUrl = pathToFileURL(require.resolve("tsdown")).href;
describe("plugin-sdk bundled exports", () => {
it("emits importable bundled subpath entries", { timeout: 240_000 }, async () => {
@ -24,24 +16,9 @@ describe("plugin-sdk bundled exports", () => {
const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-consumer-"));
try {
const buildScriptPath = path.join(fixtureDir, "build-plugin-sdk.mjs");
await fs.writeFile(
buildScriptPath,
`import { build } from ${JSON.stringify(tsdownModuleUrl)};
await build(${JSON.stringify({
clean: true,
config: false,
dts: false,
entry: buildPluginSdkEntrySources(),
env: { NODE_ENV: "production" },
fixedExtension: false,
logLevel: "error",
outDir,
platform: "node",
})});
`,
);
await execFileAsync(process.execPath, [buildScriptPath], {
// Reuse the repo's tsdown config so plugin-sdk bundle checks stay aligned
// with the production build graph and singleton boundaries.
await execFileAsync(process.execPath, ["scripts/tsdown-build.mjs", "--outDir", outDir], {
cwd: process.cwd(),
});
await fs.symlink(
@ -50,17 +27,12 @@ await build(${JSON.stringify({
"dir",
);
for (const entry of pluginSdkEntrypoints) {
const module = await import(pathToFileURL(path.join(outDir, `${entry}.js`)).href);
expect(module).toBeTypeOf("object");
}
const packageDir = path.join(fixtureDir, "openclaw");
const consumerDir = path.join(fixtureDir, "consumer");
const consumerEntry = path.join(consumerDir, "import-plugin-sdk.mjs");
await fs.mkdir(path.join(packageDir, "dist"), { recursive: true });
await fs.symlink(outDir, path.join(packageDir, "dist", "plugin-sdk"), "dir");
await fs.mkdir(packageDir, { recursive: true });
await fs.symlink(outDir, path.join(packageDir, "dist"), "dir");
// Mirror the installed package layout so subpaths can resolve root deps.
await fs.symlink(
path.join(process.cwd(), "node_modules"),

View File

@ -289,6 +289,7 @@ describe("secrets runtime snapshot integration", () => {
plugins: {
entries: {
google: {
enabled: true,
config: {
webSearch: {
apiKey: {

View File

@ -37,6 +37,11 @@ describe("git-hooks/pre-commit (integration)", () => {
path.join(process.cwd(), "git-hooks", "pre-commit"),
path.join(dir, "git-hooks", "pre-commit"),
);
writeFileSync(
path.join(dir, "package.json"),
`${JSON.stringify({ name: "openclaw-pre-commit-test", private: true }, null, 2)}\n`,
"utf8",
);
writeFileSync(
path.join(dir, "scripts", "pre-commit", "run-node-tool.sh"),
"#!/usr/bin/env bash\nexit 0\n",
@ -56,6 +61,9 @@ describe("git-hooks/pre-commit (integration)", () => {
// The hook ends with `pnpm check`, but this fixture is only exercising staged-file handling.
// Stub pnpm too so Windows CI does not invoke a real package-manager command in the temp repo.
writeExecutable(fakeBinDir, "pnpm", "#!/usr/bin/env bash\nexit 0\n");
writeFileSync(path.join(fakeBinDir, "pnpm.cmd"), "@echo off\r\nexit /b 0\r\n", {
encoding: "utf8",
});
// Create an untracked file that should NOT be staged by the hook.
writeFileSync(path.join(dir, "secret.txt"), "do-not-stage\n", "utf8");