From 07287c7f27a4136d21ee62a30a901ff28f40529c Mon Sep 17 00:00:00 2001 From: Ruslan Belkin Date: Fri, 27 Feb 2026 18:06:50 -0800 Subject: [PATCH 01/12] Docker: add cron-enabled gateway runtime and non-root global tool paths --- Dockerfile | 99 ++++++++++++++++++++---- docker-compose.yml | 4 + docker-setup.sh | 5 +- scripts/docker/gateway-entrypoint.sh | 20 +++++ scripts/openclaw-keepawake.sh | 108 +++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 15 deletions(-) create mode 100644 scripts/docker/gateway-entrypoint.sh create mode 100755 scripts/openclaw-keepawake.sh diff --git a/Dockerfile b/Dockerfile index b314ca3283d..3e848f1bccd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,16 +19,32 @@ 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="${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 + +# Install runtime helpers used by gateway entrypoint: +# - cron: enables `crontab` + cron daemon for scheduled jobs in containerized setups +# - gosu: drop privileges back to `node` after starting root-only services +# - jq: required by later build steps (Go/gogcli version discovery) +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends cron gosu jq && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* + WORKDIR /app RUN chown node:node /app ARG OPENCLAW_DOCKER_APT_PACKAGES="" RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \ - apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ - fi + apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ + fi COPY --chown=node:node package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ COPY --chown=node:node ui/package.json ./ui/package.json @@ -47,15 +63,68 @@ RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile USER root ARG OPENCLAW_INSTALL_BROWSER="" RUN 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 && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ - fi + 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 && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ + 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, or default to latest release tag. +ARG GOGCLI_TAG=latest +RUN set -eux; \ + arch="$(dpkg --print-architecture)"; \ + case "$arch" in \ + amd64) GOGARCH=amd64 ;; \ + arm64) GOGARCH=arm64 ;; \ + *) echo "Unsupported arch: $arch" >&2; exit 1 ;; \ + esac; \ + if [ "$GOGCLI_TAG" = "latest" ]; then \ + GOGCLI_TAG="$(curl -fsSL https://api.github.com/repos/steipete/gogcli/releases/latest | jq -r .tag_name)"; \ + fi; \ + ver="${GOGCLI_TAG#v}"; \ + url="https://github.com/steipete/gogcli/releases/download/${GOGCLI_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. +ENV HOMEBREW_PREFIX=/home/linuxbrew/.linuxbrew +ENV HOMEBREW_CELLAR=/home/linuxbrew/.linuxbrew/Cellar +ENV HOMEBREW_REPOSITORY=/home/linuxbrew/.linuxbrew/Homebrew +RUN set -eux; \ + mkdir -p "${HOMEBREW_REPOSITORY}" "${HOMEBREW_CELLAR}" "${HOMEBREW_PREFIX}/bin"; \ + 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 +ENV PATH="${HOMEBREW_PREFIX}/bin:${HOMEBREW_PREFIX}/sbin:${PATH}" # Optionally install Docker CLI for sandbox container management. # Build with: docker build --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1 ... @@ -90,6 +159,7 @@ RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \ fi USER node +RUN brew --version COPY --chown=node:node . . # Normalize copied plugin/agent paths so plugin safety checks do not reject # world-writable directories inherited from source file modes. @@ -99,6 +169,7 @@ RUN for dir in /app/extensions /app/.agent /app/.agents; do \ find "$dir" -type f -exec chmod 644 {} +; \ fi; \ done +RUN chmod +x scripts/docker/gateway-entrypoint.sh RUN pnpm build # Force pnpm for UI build (Bun may fail on ARM/Synology architectures) ENV OPENCLAW_PREFER_PNPM=1 diff --git a/docker-compose.yml b/docker-compose.yml index a17558157f7..9c519a00eeb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 @@ -10,6 +12,7 @@ services: CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-} CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-} 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 @@ -66,6 +69,7 @@ services: CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-} CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-} volumes: + - ./.env:/app/.env:ro - ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw - ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace stdin_open: true diff --git a/docker-setup.sh b/docker-setup.sh index ce5e6a08f3d..cc2bc90ce3d 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -200,6 +200,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_EXTRA_MOUNTS="$EXTRA_MOUNTS" export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME" export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}" @@ -382,13 +383,15 @@ upsert_env "$ENV_FILE" \ OPENCLAW_DOCKER_SOCKET \ DOCKER_GID \ OPENCLAW_INSTALL_DOCKER_CLI \ - OPENCLAW_ALLOW_INSECURE_PRIVATE_WS + OPENCLAW_ALLOW_INSECURE_PRIVATE_WS \ + OPENCLAW_INSTALL_BROWSER if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then echo "==> Building Docker image: $IMAGE_NAME" docker build \ --build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \ --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" diff --git a/scripts/docker/gateway-entrypoint.sh b/scripts/docker/gateway-entrypoint.sh new file mode 100644 index 00000000000..7e5350346ed --- /dev/null +++ b/scripts/docker/gateway-entrypoint.sh @@ -0,0 +1,20 @@ +#!/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 + +if command -v gosu >/dev/null 2>&1; then + exec gosu node "$@" +fi + +exec su -s /bin/sh node -c "$*" diff --git a/scripts/openclaw-keepawake.sh b/scripts/openclaw-keepawake.sh new file mode 100755 index 00000000000..444d8487a1a --- /dev/null +++ b/scripts/openclaw-keepawake.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -euo pipefail + +PID_FILE="${OPENCLAW_KEEPAWAKE_PID_FILE:-$HOME/.openclaw/run/keepawake.pid}" + +usage() { + cat <<'USAGE' +Usage: scripts/openclaw-keepawake.sh + +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 +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 + + caffeinate -dimsu >/dev/null 2>&1 & + pid="$!" + echo "$pid" >"$PID_FILE" + echo "keep-awake on (pid $pid)" +} + +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 From 780465739b0a79bc6eb1bcda224cfd81ebad11e7 Mon Sep 17 00:00:00 2001 From: Ruslan Belkin Date: Fri, 27 Feb 2026 18:06:52 -0800 Subject: [PATCH 02/12] Docs: add Docker troubleshooting notes for cron and global installs --- docs/install/docker.md | 122 ++++++++++++++++++++++++++++++++++++++ skills/summarize/SKILL.md | 2 + 2 files changed, 124 insertions(+) diff --git a/docs/install/docker.md b/docs/install/docker.md index 8d376fb06a1..cc5febe1487 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -57,6 +57,7 @@ Optional env vars: - `OPENCLAW_IMAGE` — use a remote image instead of building locally (e.g. `ghcr.io/openclaw/openclaw:latest`) - `OPENCLAW_DOCKER_APT_PACKAGES` — install extra apt packages during build +- `OPENCLAW_INSTALL_BROWSER` — set to `1` to install browser deps at build time - `OPENCLAW_EXTRA_MOUNTS` — add extra host bind mounts - `OPENCLAW_HOME_VOLUME` — persist `/home/node` in a named volume - `OPENCLAW_SANDBOX` — opt in to Docker gateway sandbox bootstrap. Only explicit truthy values enable it: `1`, `true`, `yes`, `on` @@ -216,6 +217,24 @@ Then use `clawdock-start`, `clawdock-stop`, `clawdock-dashboard`, etc. Run `claw See [`ClawDock` Helper README](https://github.com/openclaw/openclaw/blob/main/scripts/shell-helpers/README.md) for details. +### Keep macOS awake (optional) + +Running Docker does not reliably prevent macOS sleep. If your OpenClaw gateway +must stay online (for Slack/Discord/webhooks), use the keep-awake helper from +repo root: + +```bash +scripts/openclaw-keepawake.sh on +scripts/openclaw-keepawake.sh status +scripts/openclaw-keepawake.sh off +``` + +Details: + +- Uses `caffeinate -dimsu` in the background. +- Stores PID at `~/.openclaw/run/keepawake.pid`. +- Requires macOS (`caffeinate` command). + ### Manual flow (compose) ```bash @@ -352,6 +371,109 @@ docker compose run --rm openclaw-cli \ If you need Playwright to install system deps, rebuild the image with `OPENCLAW_DOCKER_APT_PACKAGES` instead of using `--with-deps` at runtime. +## Docker troubleshooting notes (real-world) + +### CLI cannot connect to gateway (`gateway closed (1006)`) + +When `docker compose run --rm openclaw-cli ...` cannot reach the gateway, ensure +the CLI service uses the gateway network namespace and starts after the gateway: + +- `openclaw-cli` uses `network_mode: "service:openclaw-gateway"` +- `openclaw-cli` has `depends_on: [openclaw-gateway]` + +Then restart: + +```bash +docker compose up -d openclaw-gateway +``` + +### `ERR_PNPM_NO_GLOBAL_BIN_DIR` while installing skills + +If skill install fails with: + +- `ERR_PNPM_NO_GLOBAL_BIN_DIR` +- `Run "pnpm setup" ... or set PNPM_HOME` + +set `PNPM_HOME` in the image and include it in `PATH` so global binaries resolve +for the non-root `node` user. + +### Global installs fail (`EACCES` / command not found) + +For non-root runtime installs from skills/UI, ensure these paths are configured +in the image and on `PATH`: + +- `PNPM_HOME=/home/node/.local/share/pnpm` +- `NPM_CONFIG_PREFIX=/home/node/.npm-global` +- `GOPATH=/home/node/go` (or explicit `GOBIN`) + +And include: + +- `$PNPM_HOME` +- `$NPM_CONFIG_PREFIX/bin` +- `$GOPATH/bin` + +This avoids root-owned global install paths and keeps `pnpm`, `npm -g`, and +`go install` binaries discoverable for the `node` user. + +### Slack Socket Mode connected but no replies + +If Slack probe is healthy but `lastInboundAt` stays `null`: + +1. In Slack app settings, enable **Socket Mode**. +2. Enable **Event Subscriptions**. +3. Add bot events (at minimum `message.im`; add `app_mention` if you use mentions). +4. Enable **App Home → Messages Tab**. +5. Reinstall the app to workspace after scope/event changes. + +If Slack asks for pairing, approve the code from the gateway container: + +```bash +docker compose exec openclaw-gateway node dist/index.js pairing approve slack +``` + +### Duplicate Slack replies (`edited` + final duplicate) + +If you see duplicated Slack responses, disable streaming: + +```bash +docker compose exec openclaw-gateway node dist/index.js config set channels.slack.streaming off +docker compose exec openclaw-gateway node dist/index.js config set channels.slack.nativeStreaming false +docker compose restart openclaw-gateway +``` + +If duplicates still happen, verify only one OpenClaw gateway instance is connected +to that Slack app/token pair. + +### `crontab: Permission denied` from agent tasks + +If an agent cannot manage cron jobs in Docker, your image likely lacks cron +support (or the gateway is not started with the cron-capable entrypoint). + +Checklist: + +1. Rebuild the image so cron support from `Dockerfile` is included: + +```bash +docker build -t openclaw:local -f Dockerfile . +``` + +2. Recreate gateway so `docker-compose.yml` gateway entrypoint/user changes apply: + +```bash +docker compose up -d --force-recreate openclaw-gateway +``` + +3. Verify from inside gateway container: + +```bash +docker compose exec openclaw-gateway sh -lc 'id && which crontab && crontab -l || true' +``` + +Notes: + +- Gateway starts as root only to launch cron daemon, then drops to `node`. +- Set `OPENCLAW_ENABLE_CRON=0` to disable cron daemon startup. + 4. **Persist Playwright browser downloads**: - Set `PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright` in diff --git a/skills/summarize/SKILL.md b/skills/summarize/SKILL.md index 0f42e0bfe45..32c8dbc2cd9 100644 --- a/skills/summarize/SKILL.md +++ b/skills/summarize/SKILL.md @@ -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)", From 49c7fba483dfa272e3789e56271e1cf405ac9458 Mon Sep 17 00:00:00 2001 From: Ruslan Belkin Date: Fri, 27 Feb 2026 18:12:23 -0800 Subject: [PATCH 03/12] Docker: always install critical apt baseline and dedupe extras --- Dockerfile | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3e848f1bccd..33688fe3125 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,25 +26,30 @@ ENV PATH="${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 -# Install runtime helpers used by gateway entrypoint: -# - cron: enables `crontab` + cron daemon for scheduled jobs in containerized setups -# - gosu: drop privileges back to `node` after starting root-only services -# - jq: required by later build steps (Go/gogcli version discovery) -RUN apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends cron gosu jq && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* - WORKDIR /app RUN chown node:node /app ARG OPENCLAW_DOCKER_APT_PACKAGES="" -RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \ - apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ - fi +# Always install baseline packages needed for Docker runtime reliability. +# Extra packages can still be provided via OPENCLAW_DOCKER_APT_PACKAGES. +RUN set -eux; \ + BASE_APT_PACKAGES="\ +cron gosu \ +git curl wget ca-certificates jq unzip ripgrep procps 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}; \ + apt-get clean; \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* COPY --chown=node:node package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ COPY --chown=node:node ui/package.json ./ui/package.json @@ -63,14 +68,11 @@ RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile USER root ARG OPENCLAW_INSTALL_BROWSER="" RUN 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 && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ + apt-get clean; \ fi # ---- Install Go (official) ---- From a9ce49c5f3df45a33b105c61e3ab91b8f2290dfd Mon Sep 17 00:00:00 2001 From: Ruslan Belkin Date: Fri, 27 Feb 2026 18:18:45 -0800 Subject: [PATCH 04/12] Docs: add Docker backup and cross-arch migration scripts --- docs/install/docker.md | 83 +++++++++++ scripts/migrate/backup-openclaw.sh | 208 ++++++++++++++++++++++++++++ scripts/migrate/restore-openclaw.sh | 201 +++++++++++++++++++++++++++ 3 files changed, 492 insertions(+) create mode 100755 scripts/migrate/backup-openclaw.sh create mode 100755 scripts/migrate/restore-openclaw.sh diff --git a/docs/install/docker.md b/docs/install/docker.md index cc5febe1487..61be00a81bf 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -624,6 +624,89 @@ docker compose run --rm openclaw-cli devices list --url ws://127.0.0.1:18789 - Dockerfile CMD uses `--allow-unconfigured`; mounted config with `gateway.mode` not `local` will still start. Override CMD to enforce the guard. - The gateway container is the source of truth for sessions (`~/.openclaw/agents//sessions/`). +## Backup and migration (Intel Mac to Apple Silicon) + +For low-disruption host migration, move OpenClaw data/config and rebuild Docker +images natively on the new machine. + +Use: + +- `scripts/migrate/backup-openclaw.sh` on source host +- `scripts/migrate/restore-openclaw.sh` on target host + +### 1) Create backup on source host + +From 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 repo root +- metadata + internal checksum manifest + +Output files: + +- `backups/openclaw-backup-.tar.gz` +- `backups/openclaw-backup-.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 archive to target host + +Copy archive + checksum file to the new Mac using your normal secure transfer +method (scp, encrypted disk, etc.). + +### 3) Restore on target host + +From repo root on target host: + +```bash +scripts/migrate/restore-openclaw.sh --archive /path/to/openclaw-backup-.tar.gz +``` + +Default restore behavior: + +- verifies archive checksums +- stops `openclaw-gateway` container first +- snapshots current config/workspace as `.pre-restore-` +- restores config/workspace from backup +- writes backup env file as `.env.from-backup` for review + +To overwrite `.env` directly: + +```bash +scripts/migrate/restore-openclaw.sh \ + --archive /path/to/openclaw-backup-.tar.gz \ + --apply-env +``` + +### 4) Rebuild and validate on target arch + +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. + ## Agent Sandbox (host gateway + Docker tools) Deep dive: [Sandboxing](/gateway/sandboxing) diff --git a/scripts/migrate/backup-openclaw.sh b/scripts/migrate/backup-openclaw.sh new file mode 100755 index 00000000000..c1d6383aaca --- /dev/null +++ b/scripts/migrate/backup-openclaw.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + scripts/migrate/backup-openclaw.sh [options] + +Options: + --repo-root OpenClaw repo root (default: current repo) + --env-file Env file to include (default: /.env) + --config-dir OpenClaw config dir (default: env or ~/.openclaw) + --workspace-dir OpenClaw workspace dir (default: env or ~/.openclaw/workspace) + --output-dir Output directory for backup archive (default: /backups) + --name Backup name prefix (default: openclaw-backup-) + -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\"" diff --git a/scripts/migrate/restore-openclaw.sh b/scripts/migrate/restore-openclaw.sh new file mode 100755 index 00000000000..9853e93af9a --- /dev/null +++ b/scripts/migrate/restore-openclaw.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + scripts/migrate/restore-openclaw.sh --archive [options] + +Options: + --archive Backup archive created by backup-openclaw.sh (required) + --repo-root OpenClaw repo root (default: current repo) + --env-file Env file path (default: /.env) + --config-dir OpenClaw config dir (default: env or ~/.openclaw) + --workspace-dir 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" From d413684f030c981931d6cfd0c0b9cf84c2dca170 Mon Sep 17 00:00:00 2001 From: Ruslan Belkin Date: Fri, 27 Feb 2026 19:01:10 -0800 Subject: [PATCH 05/12] test: update Dockerfile apt assertion for base package install --- src/dockerfile.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index 4600e446a61..d5585940758 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -11,6 +11,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); @@ -18,7 +19,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("normalizes plugin and agent paths permissions in image layers", async () => { From 1580fe1018a0b14ceb3f7edf30ff1a771c0271d5 Mon Sep 17 00:00:00 2001 From: Ruslan Belkin Date: Mon, 2 Mar 2026 17:55:51 -0800 Subject: [PATCH 06/12] scripts: default keepawake to allow display sleep --- docs/install/docker.md | 3 ++- scripts/openclaw-keepawake.sh | 12 ++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/install/docker.md b/docs/install/docker.md index 61be00a81bf..1e1b9ca3fca 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -231,7 +231,8 @@ scripts/openclaw-keepawake.sh off Details: -- Uses `caffeinate -dimsu` in the background. +- Uses `caffeinate -imsu` in the background (allows display sleep). +- Set `OPENCLAW_KEEPAWAKE_FLAGS=-dimsu` if you want displays to stay on. - Stores PID at `~/.openclaw/run/keepawake.pid`. - Requires macOS (`caffeinate` command). diff --git a/scripts/openclaw-keepawake.sh b/scripts/openclaw-keepawake.sh index 444d8487a1a..72bfab5a1c8 100755 --- a/scripts/openclaw-keepawake.sh +++ b/scripts/openclaw-keepawake.sh @@ -2,6 +2,7 @@ set -euo pipefail PID_FILE="${OPENCLAW_KEEPAWAKE_PID_FILE:-$HOME/.openclaw/run/keepawake.pid}" +KEEPAWAKE_FLAGS="${OPENCLAW_KEEPAWAKE_FLAGS:--imsu}" usage() { cat <<'USAGE' @@ -14,6 +15,10 @@ Commands: 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 } @@ -50,10 +55,13 @@ start_awake() { return 0 fi - caffeinate -dimsu >/dev/null 2>&1 & + 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)" + echo "keep-awake on (pid $pid, flags: $KEEPAWAKE_FLAGS)" } stop_awake() { From 0864151c9d286358e592ee98b7167e4f46c2d140 Mon Sep 17 00:00:00 2001 From: Ruslan Belkin Date: Mon, 2 Mar 2026 18:37:16 -0800 Subject: [PATCH 07/12] docker: resolve gogcli latest tag without GitHub API --- Dockerfile | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 33688fe3125..ff754930632 100644 --- a/Dockerfile +++ b/Dockerfile @@ -96,7 +96,8 @@ RUN set -eux; \ ENV PATH="/usr/local/go/bin:${PATH}" # ---- Install gog (gogcli) ---- -# Pin version by setting GOGCLI_TAG at build time, or default to latest release tag. +# Pin version by setting GOGCLI_TAG at build time, or default to the latest release. +# Resolve "latest" via GitHub releases redirect (no API call) to avoid API rate limits in CI. ARG GOGCLI_TAG=latest RUN set -eux; \ arch="$(dpkg --print-architecture)"; \ @@ -106,7 +107,11 @@ RUN set -eux; \ *) echo "Unsupported arch: $arch" >&2; exit 1 ;; \ esac; \ if [ "$GOGCLI_TAG" = "latest" ]; then \ - GOGCLI_TAG="$(curl -fsSL https://api.github.com/repos/steipete/gogcli/releases/latest | jq -r .tag_name)"; \ + GOGCLI_TAG="$(curl -fsSIL https://github.com/steipete/gogcli/releases/latest | awk -F': ' 'tolower($1)==\"location\" {gsub(\"\\r\", \"\", $2); print $2}' | awk -F/ '{print $NF}' | tail -n1)"; \ + if [ -z "$GOGCLI_TAG" ]; then \ + echo "ERROR: Failed to resolve gogcli latest release tag" >&2; \ + exit 1; \ + fi; \ fi; \ ver="${GOGCLI_TAG#v}"; \ url="https://github.com/steipete/gogcli/releases/download/${GOGCLI_TAG}/gogcli_${ver}_linux_${GOGARCH}.tar.gz"; \ From b19635f9d0ad22fac41da5607c01612303e22d79 Mon Sep 17 00:00:00 2001 From: Ruslan Belkin Date: Mon, 2 Mar 2026 18:45:09 -0800 Subject: [PATCH 08/12] fix(ci): unblock check and install-smoke on latest main --- Dockerfile | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index ff754930632..a14c097d0ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -96,9 +96,9 @@ RUN set -eux; \ ENV PATH="/usr/local/go/bin:${PATH}" # ---- Install gog (gogcli) ---- -# Pin version by setting GOGCLI_TAG at build time, or default to the latest release. -# Resolve "latest" via GitHub releases redirect (no API call) to avoid API rate limits in CI. -ARG GOGCLI_TAG=latest +# 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 \ @@ -107,10 +107,10 @@ RUN set -eux; \ *) echo "Unsupported arch: $arch" >&2; exit 1 ;; \ esac; \ if [ "$GOGCLI_TAG" = "latest" ]; then \ - GOGCLI_TAG="$(curl -fsSIL https://github.com/steipete/gogcli/releases/latest | awk -F': ' 'tolower($1)==\"location\" {gsub(\"\\r\", \"\", $2); print $2}' | awk -F/ '{print $NF}' | tail -n1)"; \ + GOGCLI_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 "$GOGCLI_TAG" ]; then \ - echo "ERROR: Failed to resolve gogcli latest release tag" >&2; \ - exit 1; \ + echo "WARN: Failed to resolve gogcli latest release tag; falling back to v0.11.0" >&2; \ + GOGCLI_TAG="v0.11.0"; \ fi; \ fi; \ ver="${GOGCLI_TAG#v}"; \ From d2a25b28e58846c9814541904b0081c81d6ebd15 Mon Sep 17 00:00:00 2001 From: Ruslan Belkin Date: Mon, 2 Mar 2026 18:59:36 -0800 Subject: [PATCH 09/12] Docker: fix gog latest fallback + typecheck drift --- Dockerfile | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index a14c097d0ae..33a66848570 100644 --- a/Dockerfile +++ b/Dockerfile @@ -106,15 +106,16 @@ RUN set -eux; \ arm64) GOGARCH=arm64 ;; \ *) echo "Unsupported arch: $arch" >&2; exit 1 ;; \ esac; \ - if [ "$GOGCLI_TAG" = "latest" ]; then \ - GOGCLI_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 "$GOGCLI_TAG" ]; then \ + 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; \ - GOGCLI_TAG="v0.11.0"; \ + tag="v0.11.0"; \ fi; \ fi; \ - ver="${GOGCLI_TAG#v}"; \ - url="https://github.com/steipete/gogcli/releases/download/${GOGCLI_TAG}/gogcli_${ver}_linux_${GOGARCH}.tar.gz"; \ + 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; \ From 0ac57054fa7c929ac31e5f6c03da2250cf1496fe Mon Sep 17 00:00:00 2001 From: Ruslan Belkin Date: Sat, 7 Mar 2026 12:37:57 -0800 Subject: [PATCH 10/12] Docker: fix node cache ownership for runtime tool installs --- Dockerfile | 7 ++++--- scripts/docker/gateway-entrypoint.sh | 12 ++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index b0494881106..6e96412f24a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -103,15 +103,16 @@ LABEL org.opencontainers.image.source="https://github.com/openclaw/openclaw" \ WORKDIR /app ENV PNPM_HOME=/home/node/.local/share/pnpm ENV NPM_CONFIG_PREFIX=/home/node/.npm-global +ENV COREPACK_HOME=/home/node/.cache/node/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="${PNPM_HOME}:${NPM_CONFIG_PREFIX}/bin:${GOPATH}/bin:${HOMEBREW_PREFIX}/bin:${HOMEBREW_PREFIX}/sbin:${PATH}" RUN chown node:node /app -RUN mkdir -p "${PNPM_HOME}" "${NPM_CONFIG_PREFIX}/bin" "${GOPATH}/bin" \ +RUN mkdir -p "${PNPM_HOME}" "${NPM_CONFIG_PREFIX}/bin" "${COREPACK_HOME}" "${GOPATH}/bin" \ "${HOMEBREW_REPOSITORY}" "${HOMEBREW_CELLAR}" "${HOMEBREW_PREFIX}/bin" && \ - chown -R node:node /home/node/.local /home/node/.npm-global /home/node/go /home/linuxbrew + chown -R node:node /home/node/.cache /home/node/.local /home/node/.npm-global /home/node/go /home/linuxbrew RUN corepack enable COPY --from=build --chown=node:node /app/dist ./dist @@ -154,7 +155,7 @@ RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \ 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 && \ + chown -R node:node /home/node/.cache && \ apt-get clean; \ fi diff --git a/scripts/docker/gateway-entrypoint.sh b/scripts/docker/gateway-entrypoint.sh index 7e5350346ed..9d59ec70e89 100644 --- a/scripts/docker/gateway-entrypoint.sh +++ b/scripts/docker/gateway-entrypoint.sh @@ -13,6 +13,18 @@ if [ "${OPENCLAW_ENABLE_CRON:-1}" = "1" ] && command -v cron >/dev/null 2>&1; th 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 From 6642fe4c8bb974ab7f56097ad38d246a4bb19773 Mon Sep 17 00:00:00 2001 From: Ruslan Belkin Date: Thu, 19 Mar 2026 17:16:43 -0700 Subject: [PATCH 11/12] Docker: expose go path and align plugin-sdk bundle test --- Dockerfile | 4 +-- src/plugin-sdk/index.bundle.test.ts | 40 +++++------------------------ 2 files changed, 8 insertions(+), 36 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7da884d562a..a022f90f225 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,7 +59,7 @@ 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="${PNPM_HOME}:${NPM_CONFIG_PREFIX}/bin:${GOPATH}/bin:${PATH}" +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 @@ -141,7 +141,7 @@ 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="${PNPM_HOME}:${NPM_CONFIG_PREFIX}/bin:${GOPATH}/bin:${HOMEBREW_PREFIX}/bin:${HOMEBREW_PREFIX}/sbin:${PATH}" +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" && \ diff --git a/src/plugin-sdk/index.bundle.test.ts b/src/plugin-sdk/index.bundle.test.ts index 1f3afc8ab3a..ac61433eb26 100644 --- a/src/plugin-sdk/index.bundle.test.ts +++ b/src/plugin-sdk/index.bundle.test.ts @@ -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"), From 77b51a768ad290378d0f174fd5589e6435b84d9d Mon Sep 17 00:00:00 2001 From: Ruslan Belkin Date: Thu, 19 Mar 2026 17:55:40 -0700 Subject: [PATCH 12/12] Tests: stabilize merged CI fixtures --- src/cron/isolated-agent.model-formatting.test.ts | 8 +++++++- src/secrets/runtime.integration.test.ts | 1 + test/git-hooks-pre-commit.test.ts | 12 ++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/cron/isolated-agent.model-formatting.test.ts b/src/cron/isolated-agent.model-formatting.test.ts index 5232d1349a6..4a7677fd01e 100644 --- a/src/cron/isolated-agent.model-formatting.test.ts +++ b/src/cron/isolated-agent.model-formatting.test.ts @@ -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", () => { diff --git a/src/secrets/runtime.integration.test.ts b/src/secrets/runtime.integration.test.ts index f39607cbe80..8c16ca0761f 100644 --- a/src/secrets/runtime.integration.test.ts +++ b/src/secrets/runtime.integration.test.ts @@ -289,6 +289,7 @@ describe("secrets runtime snapshot integration", () => { plugins: { entries: { google: { + enabled: true, config: { webSearch: { apiKey: { diff --git a/test/git-hooks-pre-commit.test.ts b/test/git-hooks-pre-commit.test.ts index 018fcce7090..778256e15a0 100644 --- a/test/git-hooks-pre-commit.test.ts +++ b/test/git-hooks-pre-commit.test.ts @@ -30,6 +30,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", @@ -49,6 +54,13 @@ describe("git-hooks/pre-commit (integration)", () => { encoding: "utf8", mode: 0o755, }); + writeFileSync(path.join(fakeBinDir, "pnpm"), "#!/usr/bin/env bash\nexit 0\n", { + encoding: "utf8", + mode: 0o755, + }); + 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");