name: Crabbox Hydrate

on:
  workflow_dispatch:
    inputs:
      crabbox_id:
        description: "Crabbox lease ID"
        required: true
        type: string
      ref:
        description: "Git ref to hydrate"
        required: false
        type: string
      crabbox_runner_label:
        description: "Dynamic Crabbox runner label"
        required: true
        type: string
      crabbox_job:
        description: "Hydration job identifier expected by Crabbox"
        required: false
        default: "hydrate"
        type: string
      crabbox_keep_alive_minutes:
        description: "Minutes to keep the hydrated job alive"
        required: false
        default: "90"
        type: string

permissions:
  contents: read

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
  PNPM_CONFIG_CHILD_CONCURRENCY: "1"
  PNPM_CONFIG_MODULES_DIR: "/tmp/openclaw-pnpm-node-modules"
  PNPM_CONFIG_NETWORK_CONCURRENCY: "1"
  PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
  PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false"
  PNPM_CONFIG_VIRTUAL_STORE_DIR: "/tmp/openclaw-pnpm-virtual-store"

jobs:
  hydrate:
    name: hydrate
    if: ${{ inputs.crabbox_job != 'hydrate-github' && inputs.crabbox_job != 'hydrate-windows-daemon' }}
    runs-on: [self-hosted, "${{ inputs.crabbox_runner_label }}"]
    timeout-minutes: 120
    steps:
      - uses: actions/checkout@v6
        with:
          ref: ${{ inputs.ref || github.ref }}

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: "24"

      - name: Setup pnpm and dependencies
        shell: bash
        env:
          CI: "true"
        run: |
          set -euo pipefail

          export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$RUNNER_TEMP/cache}"
          export COREPACK_HOME="${COREPACK_HOME:-$XDG_CACHE_HOME/corepack}"
          export PNPM_HOME="${PNPM_HOME:-$RUNNER_TEMP/pnpm-home}"
          mkdir -p "$XDG_CACHE_HOME" "$COREPACK_HOME" "$PNPM_HOME"
          export PATH="$PNPM_HOME:$PATH"
          {
            echo "XDG_CACHE_HOME=$XDG_CACHE_HOME"
            echo "COREPACK_HOME=$COREPACK_HOME"
            echo "PNPM_HOME=$PNPM_HOME"
          } >> "$GITHUB_ENV"

          package_manager="$(node -e "const fs = require('node:fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); process.stdout.write(pkg.packageManager || '')")"
          case "$package_manager" in
            pnpm@*) ;;
            *)
              echo "::error::Expected packageManager to pin pnpm, got '${package_manager:-<empty>}'"
              exit 1
              ;;
          esac
          corepack enable --install-directory "$PNPM_HOME"
          for attempt in 1 2 3; do
            if corepack prepare "$package_manager" --activate; then
              break
            fi
            if [ "$attempt" = 3 ]; then
              corepack prepare "$package_manager" --activate
            fi
            sleep $((attempt * 5))
          done
          node_bin="$(dirname "$(node -p 'process.execPath')")"
          echo "NODE_BIN=$node_bin" >> "$GITHUB_ENV"
          echo "$node_bin" >> "$GITHUB_PATH"
          export PATH="$node_bin:$PATH"

          node -v
          npm -v
          pnpm -v

          install_args=(
            install
            --prefer-offline
            --ignore-scripts=false
            --config.engine-strict=false
            --config.enable-pre-post-scripts=true
            --config.side-effects-cache=true
            --frozen-lockfile
          )
          append_pnpm_option_arg() {
            local env_name="$1"
            local option_name="$2"
            local value="${!env_name-}"
            if [ -n "$value" ]; then
              install_args+=("--${option_name}=${value}")
            fi
          }
          append_pnpm_option_arg PNPM_CONFIG_CHILD_CONCURRENCY child-concurrency
          append_pnpm_option_arg PNPM_CONFIG_MODULES_DIR modules-dir
          append_pnpm_option_arg PNPM_CONFIG_NETWORK_CONCURRENCY network-concurrency
          append_pnpm_option_arg PNPM_CONFIG_VIRTUAL_STORE_DIR virtual-store-dir
          if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
            mkdir -p "$PNPM_CONFIG_MODULES_DIR"
            ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"
          fi
          pnpm "${install_args[@]}" || pnpm "${install_args[@]}"
          if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
            rm -rf node_modules
            ln -sfn "$PNPM_CONFIG_MODULES_DIR" node_modules
            ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"
          fi

      - name: Fetch main ref
        shell: bash
        run: |
          set -euo pipefail

          if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
            timeout --signal=TERM --kill-after=10s 30s git \
              -c protocol.version=2 \
              fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
              "+refs/heads/main:refs/remotes/origin/main"
          fi

      - name: Prepare Crabbox shell
        shell: bash
        run: |
          set -euo pipefail

          node_bin="$(dirname "$(node -p 'process.execPath')")"
          sudo ln -sf "$node_bin/node" /usr/local/bin/node
          sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
          sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
          sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
          sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
          #!/usr/bin/env bash
          exec /usr/local/bin/corepack pnpm "$@"
          PNPM
          sudo chmod 0755 /usr/local/bin/pnpm

      - name: Ensure Docker is running
        shell: bash
        run: |
          set -euo pipefail

          if ! command -v docker >/dev/null 2>&1; then
            echo "docker not found; installing fallback engine"
            curl --fail --show-error --location \
              --connect-timeout "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_CONNECT_TIMEOUT_SECONDS:-15}" \
              --max-time "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_TIMEOUT_SECONDS:-300}" \
              --retry "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_RETRIES:-3}" \
              --retry-delay "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_RETRY_DELAY_SECONDS:-5}" \
              --retry-all-errors \
              https://get.docker.com | sudo sh
          fi

          if command -v systemctl >/dev/null 2>&1; then
            sudo systemctl start docker || true
          elif command -v service >/dev/null 2>&1; then
            sudo service docker start || true
          fi

          if [ -S /var/run/docker.sock ]; then
            sudo usermod -aG docker "$USER" || true
            # The runner process keeps its original groups; grant this
            # ephemeral runner session access without requiring a relogin.
            sudo chmod 666 /var/run/docker.sock
          fi

          if ! docker buildx version >/dev/null 2>&1; then
            arch="$(uname -m)"
            case "$arch" in
              aarch64|arm64) buildx_arch=arm64 ;;
              x86_64|amd64) buildx_arch=amd64 ;;
              *) echo "unsupported buildx arch: $arch" >&2; exit 2 ;;
            esac
            buildx_version="${DOCKER_BUILDX_VERSION:-v0.15.1}"
            mkdir -p "$HOME/.docker/cli-plugins"
            curl --fail --show-error --location \
              --connect-timeout "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_CONNECT_TIMEOUT_SECONDS:-15}" \
              --max-time "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_TIMEOUT_SECONDS:-300}" \
              --retry "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_RETRIES:-3}" \
              --retry-delay "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_RETRY_DELAY_SECONDS:-5}" \
              --retry-all-errors \
              "https://github.com/docker/buildx/releases/download/${buildx_version}/buildx-${buildx_version}.linux-${buildx_arch}" \
              -o "$HOME/.docker/cli-plugins/docker-buildx"
            chmod 0755 "$HOME/.docker/cli-plugins/docker-buildx"
          fi

          docker version
          docker buildx version
          docker compose version || true

      - name: Ensure SSH is available
        shell: bash
        run: |
          set -euo pipefail
          if command -v systemctl >/dev/null 2>&1; then
            sudo systemctl start ssh || sudo systemctl start sshd || true
          elif command -v service >/dev/null 2>&1; then
            sudo service ssh start || sudo service sshd start || true
          fi

      - name: Hydrate provider env helper
        shell: bash
        run: bash scripts/ci-hydrate-testbox-env.sh

      - name: Mark Crabbox ready
        shell: bash
        env:
          CRABBOX_ID: ${{ inputs.crabbox_id }}
          CRABBOX_JOB: ${{ inputs.crabbox_job }}
        run: |
          set -euo pipefail
          job="${CRABBOX_JOB}"
          if [ -z "$job" ]; then job=hydrate; fi
          case "$CRABBOX_ID" in
            ''|*[!A-Za-z0-9._-]*)
              echo "Invalid crabbox_id" >&2
              exit 2
              ;;
          esac
          mkdir -p "$HOME/.crabbox/actions"
          state="$HOME/.crabbox/actions/${CRABBOX_ID}.env"
          env_file="$HOME/.crabbox/actions/${CRABBOX_ID}.env.sh"
          services_file="$HOME/.crabbox/actions/${CRABBOX_ID}.services"
          write_export() {
            key="$1"
            value="${!key-}"
            if [ -n "$value" ]; then
              printf 'export %s=%q\n' "$key" "$value"
            fi
          }
          {
            for key in CI GITHUB_ACTIONS GITHUB_WORKSPACE GITHUB_REPOSITORY GITHUB_RUN_ID GITHUB_RUN_NUMBER GITHUB_RUN_ATTEMPT GITHUB_REF GITHUB_REF_NAME GITHUB_SHA GITHUB_EVENT_NAME GITHUB_ACTOR RUNNER_OS RUNNER_ARCH RUNNER_TEMP RUNNER_TOOL_CACHE XDG_CACHE_HOME COREPACK_HOME NODE_BIN PNPM_HOME PNPM_CONFIG_CHILD_CONCURRENCY PNPM_CONFIG_MODULES_DIR PNPM_CONFIG_NETWORK_CONCURRENCY PNPM_CONFIG_STORE_DIR PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN PNPM_CONFIG_VIRTUAL_STORE_DIR PATH; do
              write_export "$key"
            done
          } > "${env_file}.tmp"
          mv "${env_file}.tmp" "$env_file"
          {
            echo "# Docker containers visible from the hydrated runner"
            docker ps --format '{{.Names}}\t{{.Image}}\t{{.Ports}}' 2>/dev/null || true
          } > "${services_file}.tmp"
          mv "${services_file}.tmp" "$services_file"
          tmp="${state}.tmp"
          {
            echo "WORKSPACE=${GITHUB_WORKSPACE}"
            echo "RUN_ID=${GITHUB_RUN_ID}"
            echo "JOB=${job}"
            echo "ENV_FILE=${env_file}"
            echo "SERVICES_FILE=${services_file}"
            echo "READY_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
          } > "$tmp"
          mv "$tmp" "$state"

      - name: Keep Crabbox job alive
        shell: bash
        env:
          CRABBOX_ID: ${{ inputs.crabbox_id }}
          CRABBOX_KEEP_ALIVE_MINUTES: ${{ inputs.crabbox_keep_alive_minutes }}
        run: |
          set -euo pipefail
          case "$CRABBOX_ID" in
            ''|*[!A-Za-z0-9._-]*)
              echo "Invalid crabbox_id" >&2
              exit 2
              ;;
          esac
          minutes="${CRABBOX_KEEP_ALIVE_MINUTES}"
          case "$minutes" in
            ''|*[!0-9]*) minutes=90 ;;
          esac
          stop="$HOME/.crabbox/actions/${CRABBOX_ID}.stop"
          deadline=$(( $(date +%s) + minutes * 60 ))
          while [ "$(date +%s)" -lt "$deadline" ]; do
            if [ -f "$stop" ]; then
              exit 0
            fi
            sleep 15
          done

  hydrate-windows-daemon:
    name: hydrate-windows-daemon
    if: ${{ inputs.crabbox_job == 'hydrate-windows-daemon' }}
    runs-on: [self-hosted, "${{ inputs.crabbox_runner_label }}"]
    timeout-minutes: 120
    steps:
      - uses: actions/checkout@v6
        with:
          ref: ${{ inputs.ref || github.ref }}

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: "24"

      - name: Fetch main ref
        shell: powershell
        run: |
          $ErrorActionPreference = "Stop"

          if (git rev-parse --is-inside-work-tree 2>$null) {
            $repo = (Get-Location).Path
            $fetchInfo = New-Object System.Diagnostics.ProcessStartInfo
            $fetchInfo.FileName = "git"
            $fetchInfo.WorkingDirectory = $repo
            $fetchInfo.UseShellExecute = $false
            $fetchInfo.Arguments = '-c protocol.version=2 fetch --no-tags --no-progress --prune --no-recurse-submodules --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"'

            $fetch = New-Object System.Diagnostics.Process
            $fetch.StartInfo = $fetchInfo
            if (-not $fetch.Start()) {
              throw "git fetch failed to start"
            }
            if (-not $fetch.WaitForExit(30000)) {
              $fetch.Kill()
              $fetch.WaitForExit()
              throw "git fetch timed out after 30 seconds"
            }
            if ($fetch.ExitCode -ne 0) {
              throw "git fetch failed with exit code $($fetch.ExitCode)"
            }
          }

      - name: Setup pnpm and dependencies
        shell: powershell
        env:
          CI: "true"
          COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
        run: |
          $ErrorActionPreference = "Stop"

          $workspace = (Get-Location).Path
          $cacheRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() }
          $env:XDG_CACHE_HOME = Join-Path $cacheRoot "cache"
          $env:COREPACK_HOME = Join-Path $env:XDG_CACHE_HOME "corepack"
          $env:PNPM_HOME = Join-Path $cacheRoot "pnpm-home"
          $env:PNPM_CONFIG_STORE_DIR = Join-Path $cacheRoot "openclaw-pnpm-store"
          $env:PNPM_CONFIG_MODULES_DIR = Join-Path $workspace "node_modules"
          $env:PNPM_CONFIG_VIRTUAL_STORE_DIR = Join-Path $workspace "node_modules\.pnpm"
          $env:PNPM_CONFIG_CHILD_CONCURRENCY = "4"
          $env:PNPM_CONFIG_NETWORK_CONCURRENCY = "8"
          $env:PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN = "false"
          $env:PNPM_CONFIG_SIDE_EFFECTS_CACHE = "false"
          function Add-GitHubCommandLine([string]$Path, [string]$Value) {
            $Value | Out-File -FilePath $Path -Encoding utf8 -Append
          }
          New-Item -ItemType Directory -Force `
            $env:XDG_CACHE_HOME, `
            $env:COREPACK_HOME, `
            $env:PNPM_HOME, `
            $env:PNPM_CONFIG_STORE_DIR | Out-Null
          $env:PATH = "$env:PNPM_HOME;$env:PATH"
          @(
            "XDG_CACHE_HOME=$env:XDG_CACHE_HOME"
            "COREPACK_HOME=$env:COREPACK_HOME"
            "PNPM_HOME=$env:PNPM_HOME"
            "PNPM_CONFIG_STORE_DIR=$env:PNPM_CONFIG_STORE_DIR"
            "PNPM_CONFIG_MODULES_DIR=$env:PNPM_CONFIG_MODULES_DIR"
            "PNPM_CONFIG_VIRTUAL_STORE_DIR=$env:PNPM_CONFIG_VIRTUAL_STORE_DIR"
            "PNPM_CONFIG_CHILD_CONCURRENCY=$env:PNPM_CONFIG_CHILD_CONCURRENCY"
            "PNPM_CONFIG_NETWORK_CONCURRENCY=$env:PNPM_CONFIG_NETWORK_CONCURRENCY"
            "PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=$env:PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN"
            "PNPM_CONFIG_SIDE_EFFECTS_CACHE=$env:PNPM_CONFIG_SIDE_EFFECTS_CACHE"
          ) | ForEach-Object { Add-GitHubCommandLine $env:GITHUB_ENV $_ }
          Add-GitHubCommandLine $env:GITHUB_PATH $env:PNPM_HOME

          $packageManager = (Get-Content package.json -Raw | ConvertFrom-Json).packageManager
          if (-not $packageManager -or -not $packageManager.StartsWith("pnpm@")) {
            Write-Error "Expected packageManager to pin pnpm, got '$packageManager'"
          }
          corepack enable --install-directory $env:PNPM_HOME
          for ($attempt = 1; $attempt -le 3; $attempt++) {
            corepack prepare $packageManager --activate
            if ($LASTEXITCODE -eq 0) {
              break
            }
            if ($attempt -eq 3) {
              exit $LASTEXITCODE
            }
            Start-Sleep -Seconds ($attempt * 5)
          }
          $nodeBin = Split-Path -Parent (node -p "process.execPath")
          Add-GitHubCommandLine $env:GITHUB_ENV "NODE_BIN=$nodeBin"
          Add-GitHubCommandLine $env:GITHUB_PATH $nodeBin
          $env:PATH = "$nodeBin;$env:PATH"

          node -v
          npm -v
          pnpm -v

          $installArgs = @(
            "install",
            "--filter",
            "openclaw",
            "--prefer-offline",
            "--ignore-scripts=true",
            "--config.engine-strict=false",
            "--config.enable-pre-post-scripts=false",
            "--config.side-effects-cache=false",
            "--frozen-lockfile",
            "--child-concurrency=$env:PNPM_CONFIG_CHILD_CONCURRENCY",
            "--modules-dir=$env:PNPM_CONFIG_MODULES_DIR",
            "--network-concurrency=$env:PNPM_CONFIG_NETWORK_CONCURRENCY",
            "--store-dir=$env:PNPM_CONFIG_STORE_DIR",
            "--virtual-store-dir=$env:PNPM_CONFIG_VIRTUAL_STORE_DIR"
          )
          pnpm @installArgs
          if ($LASTEXITCODE -ne 0) {
            exit $LASTEXITCODE
          }
          $corepackShimDir = Join-Path $nodeBin "node_modules\corepack\shims"
          if (Test-Path $corepackShimDir) {
            $env:PNPM_HOME = $corepackShimDir
            Add-GitHubCommandLine $env:GITHUB_ENV "PNPM_HOME=$env:PNPM_HOME"
            Add-GitHubCommandLine $env:GITHUB_PATH $env:PNPM_HOME
          }

      - name: Mark Crabbox ready
        shell: powershell
        env:
          CRABBOX_ID: ${{ inputs.crabbox_id }}
          CRABBOX_JOB: ${{ inputs.crabbox_job }}
        run: |
          $ErrorActionPreference = "Stop"
          $job = if ($env:CRABBOX_JOB) { $env:CRABBOX_JOB } else { "hydrate-windows-daemon" }
          if (-not $env:CRABBOX_ID -or $env:CRABBOX_ID -notmatch '^[A-Za-z0-9._-]+$') {
            Write-Error "Invalid crabbox_id"
          }
          $actionsRoot = Join-Path $HOME ".crabbox\actions"
          New-Item -ItemType Directory -Force $actionsRoot | Out-Null
          $state = Join-Path $actionsRoot "$env:CRABBOX_ID.env"
          $envFile = Join-Path $actionsRoot "$env:CRABBOX_ID.env.ps1"
          $servicesFile = Join-Path $actionsRoot "$env:CRABBOX_ID.services"
          $keys = @(
            "CI", "GITHUB_ACTIONS", "GITHUB_WORKSPACE", "GITHUB_REPOSITORY",
            "GITHUB_RUN_ID", "GITHUB_RUN_NUMBER", "GITHUB_RUN_ATTEMPT",
            "GITHUB_REF", "GITHUB_REF_NAME", "GITHUB_SHA", "GITHUB_EVENT_NAME",
            "GITHUB_ACTOR", "RUNNER_OS", "RUNNER_ARCH", "RUNNER_TEMP",
            "RUNNER_TOOL_CACHE", "XDG_CACHE_HOME", "COREPACK_HOME", "NODE_BIN",
            "PNPM_HOME", "PNPM_CONFIG_CHILD_CONCURRENCY", "PNPM_CONFIG_MODULES_DIR",
            "PNPM_CONFIG_NETWORK_CONCURRENCY", "PNPM_CONFIG_STORE_DIR",
            "PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN", "PNPM_CONFIG_VIRTUAL_STORE_DIR",
            "PNPM_CONFIG_SIDE_EFFECTS_CACHE", "PATH"
          )
          $envLines = foreach ($key in $keys) {
            $value = [Environment]::GetEnvironmentVariable($key)
            if ($value) {
              "$key=$value"
            }
          }
          $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
          [System.IO.File]::WriteAllLines("$envFile.tmp", $envLines, $utf8NoBom)
          Move-Item -Force "$envFile.tmp" $envFile
          [System.IO.File]::WriteAllLines(
            "$servicesFile.tmp",
            @("# Docker containers visible from the hydrated runner", "docker not available on native Windows hydration"),
            $utf8NoBom
          )
          Move-Item -Force "$servicesFile.tmp" $servicesFile
          $stateLines = @(
            "WORKSPACE=$env:GITHUB_WORKSPACE",
            "RUN_ID=$env:GITHUB_RUN_ID",
            "JOB=$job",
            "ENV_FILE=$envFile",
            "SERVICES_FILE=$servicesFile",
            "READY_AT=$((Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ"))"
          )
          [System.IO.File]::WriteAllLines("$state.tmp", $stateLines, $utf8NoBom)
          Move-Item -Force "$state.tmp" $state

      - name: Keep Crabbox job alive
        shell: powershell
        env:
          CRABBOX_ID: ${{ inputs.crabbox_id }}
          CRABBOX_KEEP_ALIVE_MINUTES: ${{ inputs.crabbox_keep_alive_minutes }}
        run: |
          $ErrorActionPreference = "Stop"
          if (-not $env:CRABBOX_ID -or $env:CRABBOX_ID -notmatch '^[A-Za-z0-9._-]+$') {
            Write-Error "Invalid crabbox_id"
          }
          $minutes = 90
          if ($env:CRABBOX_KEEP_ALIVE_MINUTES -match '^[0-9]+$') {
            $minutes = [int]$env:CRABBOX_KEEP_ALIVE_MINUTES
          }
          $stop = Join-Path $HOME ".crabbox\actions\$env:CRABBOX_ID.stop"
          $deadline = (Get-Date).AddMinutes($minutes)
          while ((Get-Date) -lt $deadline) {
            if (Test-Path $stop) {
              exit 0
            }
            Start-Sleep -Seconds 15
          }

  hydrate-github:
    name: hydrate-github
    if: ${{ inputs.crabbox_job == 'hydrate-github' }}
    runs-on: [self-hosted, "${{ inputs.crabbox_runner_label }}"]
    timeout-minutes: 120
    steps:
      - uses: actions/checkout@v6
        with:
          ref: ${{ inputs.ref || github.ref }}

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          use-actions-cache: "false"

      - name: Prepare Crabbox shell
        shell: bash
        run: |
          set -euo pipefail

          if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
            timeout --signal=TERM --kill-after=10s 30s git \
              -c protocol.version=2 \
              fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
              "+refs/heads/main:refs/remotes/origin/main"
          fi

          node_bin="$(dirname "$(node -p 'process.execPath')")"
          sudo ln -sf "$node_bin/node" /usr/local/bin/node
          sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
          sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
          sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
          sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
          #!/usr/bin/env bash
          exec /usr/local/bin/corepack pnpm "$@"
          PNPM
          sudo chmod 0755 /usr/local/bin/pnpm

      - name: Ensure Docker is running
        shell: bash
        run: |
          set -euo pipefail

          if ! command -v docker >/dev/null 2>&1; then
            echo "docker not found; installing fallback engine"
            curl --fail --show-error --location \
              --connect-timeout "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_CONNECT_TIMEOUT_SECONDS:-15}" \
              --max-time "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_TIMEOUT_SECONDS:-300}" \
              --retry "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_RETRIES:-3}" \
              --retry-delay "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_RETRY_DELAY_SECONDS:-5}" \
              --retry-all-errors \
              https://get.docker.com | sudo sh
          fi

          if command -v systemctl >/dev/null 2>&1; then
            sudo systemctl start docker || true
          elif command -v service >/dev/null 2>&1; then
            sudo service docker start || true
          fi

          if [ -S /var/run/docker.sock ]; then
            sudo usermod -aG docker "$USER" || true
            # The runner process keeps its original groups; grant this
            # ephemeral runner session access without requiring a relogin.
            sudo chmod 666 /var/run/docker.sock
          fi

          if ! docker buildx version >/dev/null 2>&1; then
            arch="$(uname -m)"
            case "$arch" in
              aarch64|arm64) buildx_arch=arm64 ;;
              x86_64|amd64) buildx_arch=amd64 ;;
              *) echo "unsupported buildx arch: $arch" >&2; exit 2 ;;
            esac
            buildx_version="${DOCKER_BUILDX_VERSION:-v0.15.1}"
            mkdir -p "$HOME/.docker/cli-plugins"
            curl --fail --show-error --location \
              --connect-timeout "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_CONNECT_TIMEOUT_SECONDS:-15}" \
              --max-time "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_TIMEOUT_SECONDS:-300}" \
              --retry "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_RETRIES:-3}" \
              --retry-delay "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_RETRY_DELAY_SECONDS:-5}" \
              --retry-all-errors \
              "https://github.com/docker/buildx/releases/download/${buildx_version}/buildx-${buildx_version}.linux-${buildx_arch}" \
              -o "$HOME/.docker/cli-plugins/docker-buildx"
            chmod 0755 "$HOME/.docker/cli-plugins/docker-buildx"
          fi

          docker version
          docker buildx version
          docker compose version || true

      - name: Ensure SSH is available
        shell: bash
        run: |
          set -euo pipefail
          if command -v systemctl >/dev/null 2>&1; then
            sudo systemctl start ssh || sudo systemctl start sshd || true
          elif command -v service >/dev/null 2>&1; then
            sudo service ssh start || sudo service sshd start || true
          fi

      - name: Hydrate provider env helper
        shell: bash
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
          ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
          CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
          DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
          FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
          FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
          GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
          GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
          KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
          MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
          MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
          MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
          OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
          QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
          TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
          XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
          ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
          Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
        run: bash scripts/ci-hydrate-testbox-env.sh

      - name: Mark Crabbox ready
        shell: bash
        env:
          CRABBOX_ID: ${{ inputs.crabbox_id }}
          CRABBOX_JOB: ${{ inputs.crabbox_job }}
        run: |
          set -euo pipefail
          job="${CRABBOX_JOB}"
          if [ -z "$job" ]; then job=hydrate-github; fi
          case "$CRABBOX_ID" in
            ''|*[!A-Za-z0-9._-]*)
              echo "Invalid crabbox_id" >&2
              exit 2
              ;;
          esac
          mkdir -p "$HOME/.crabbox/actions"
          state="$HOME/.crabbox/actions/${CRABBOX_ID}.env"
          env_file="$HOME/.crabbox/actions/${CRABBOX_ID}.env.sh"
          services_file="$HOME/.crabbox/actions/${CRABBOX_ID}.services"
          write_export() {
            key="$1"
            value="${!key-}"
            if [ -n "$value" ]; then
              printf 'export %s=%q\n' "$key" "$value"
            fi
          }
          {
            for key in CI GITHUB_ACTIONS GITHUB_WORKSPACE GITHUB_REPOSITORY GITHUB_RUN_ID GITHUB_RUN_NUMBER GITHUB_RUN_ATTEMPT GITHUB_REF GITHUB_REF_NAME GITHUB_SHA GITHUB_EVENT_NAME GITHUB_ACTOR RUNNER_OS RUNNER_ARCH RUNNER_TEMP RUNNER_TOOL_CACHE NODE_BIN PNPM_HOME PNPM_CONFIG_CHILD_CONCURRENCY PNPM_CONFIG_MODULES_DIR PNPM_CONFIG_NETWORK_CONCURRENCY PNPM_CONFIG_STORE_DIR PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN PNPM_CONFIG_VIRTUAL_STORE_DIR PATH; do
              write_export "$key"
            done
          } > "${env_file}.tmp"
          mv "${env_file}.tmp" "$env_file"
          {
            echo "# Docker containers visible from the hydrated runner"
            docker ps --format '{{.Names}}\t{{.Image}}\t{{.Ports}}' 2>/dev/null || true
          } > "${services_file}.tmp"
          mv "${services_file}.tmp" "$services_file"
          tmp="${state}.tmp"
          {
            echo "WORKSPACE=${GITHUB_WORKSPACE}"
            echo "RUN_ID=${GITHUB_RUN_ID}"
            echo "JOB=${job}"
            echo "ENV_FILE=${env_file}"
            echo "SERVICES_FILE=${services_file}"
            echo "READY_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
          } > "$tmp"
          mv "$tmp" "$state"

      - name: Keep Crabbox job alive
        shell: bash
        env:
          CRABBOX_ID: ${{ inputs.crabbox_id }}
          CRABBOX_KEEP_ALIVE_MINUTES: ${{ inputs.crabbox_keep_alive_minutes }}
        run: |
          set -euo pipefail
          case "$CRABBOX_ID" in
            ''|*[!A-Za-z0-9._-]*)
              echo "Invalid crabbox_id" >&2
              exit 2
              ;;
          esac
          minutes="${CRABBOX_KEEP_ALIVE_MINUTES}"
          case "$minutes" in
            ''|*[!0-9]*) minutes=90 ;;
          esac
          stop="$HOME/.crabbox/actions/${CRABBOX_ID}.stop"
          deadline=$(( $(date +%s) + minutes * 60 ))
          while [ "$(date +%s)" -lt "$deadline" ]; do
            if [ -f "$stop" ]; then
              exit 0
            fi
            sleep 15
          done
