From 07287c7f27a4136d21ee62a30a901ff28f40529c Mon Sep 17 00:00:00 2001 From: Ruslan Belkin Date: Fri, 27 Feb 2026 18:06:50 -0800 Subject: [PATCH] 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