diff --git a/.github/workflows/openclaw-cross-os-release-checks-reusable.yml b/.github/workflows/openclaw-cross-os-release-checks-reusable.yml index 58017951e3a..6ee07b4c6f1 100644 --- a/.github/workflows/openclaw-cross-os-release-checks-reusable.yml +++ b/.github/workflows/openclaw-cross-os-release-checks-reusable.yml @@ -1,12 +1,67 @@ name: OpenClaw Cross-OS Release Checks (Reusable) on: + workflow_dispatch: + inputs: + ref: + description: Public OpenClaw ref to validate (tag, branch, or full commit SHA) + required: true + default: main + type: string + workflow_ref: + description: Optional openclaw/openclaw ref that provides the reusable workflow harness + required: false + default: "" + type: string + provider: + description: Provider lane to use for onboarding and the end-to-end turn + required: true + default: openai + type: choice + options: + - openai + - anthropic + - minimax + mode: + description: Which release-check lanes to run + required: true + default: both + type: choice + options: + - fresh + - upgrade + - both + previous_version: + description: Optional baseline version for installer/dev-update and packaged upgrade + required: false + default: "" + type: string + ubuntu_runner: + description: Optional Linux runner label override + required: false + default: "" + type: string + windows_runner: + description: Optional Windows runner label override + required: false + default: "" + type: string + macos_runner: + description: Optional macOS runner label override + required: false + default: "" + type: string workflow_call: inputs: ref: description: Public OpenClaw ref to validate (tag, branch, or full commit SHA) required: true type: string + workflow_ref: + description: Optional openclaw/openclaw ref that provides the reusable workflow harness + required: false + default: "" + type: string provider: description: Provider lane to use for onboarding and the end-to-end turn required: true @@ -42,6 +97,14 @@ on: required: false MINIMAX_API_KEY: required: false + OPENCLAW_DISCORD_SMOKE_BOT_TOKEN: + required: false + OPENCLAW_DISCORD_SMOKE_GUILD_ID: + required: false + OPENCLAW_DISCORD_SMOKE_CHANNEL_ID: + required: false + +permissions: read-all concurrency: group: openclaw-cross-os-release-checks-${{ inputs.ref }}-${{ inputs.provider }}-${{ inputs.mode }} @@ -52,12 +115,11 @@ env: NODE_VERSION: "24.x" PNPM_VERSION: "10.32.1" OPENCLAW_REPOSITORY: openclaw/openclaw + TSX_VERSION: "4.21.0" jobs: prepare: runs-on: ubuntu-latest - permissions: - contents: read outputs: baseline_file_name: ${{ steps.baseline_metadata.outputs.file_name }} baseline_spec: ${{ steps.baseline.outputs.value }} @@ -65,6 +127,7 @@ jobs: candidate_version: ${{ steps.candidate_metadata.outputs.version }} matrix: ${{ steps.matrix.outputs.value }} source_sha: ${{ steps.candidate_metadata.outputs.source_sha }} + workflow_ref: ${{ steps.workflow_ref.outputs.value }} steps: - name: Validate provider secret availability env: @@ -90,9 +153,109 @@ jobs: ;; esac - - name: Checkout caller release workflow repo + - name: Resolve workflow ref + id: workflow_ref + env: + INPUT_WORKFLOW_REF: ${{ inputs.workflow_ref }} + CALLER_REPOSITORY: ${{ github.repository }} + CURRENT_SHA: ${{ github.sha }} + WORKFLOW_CONTEXT_REF: ${{ github.workflow_ref }} + WORKFLOW_REPOSITORY: ${{ env.OPENCLAW_REPOSITORY }} + run: | + set -euo pipefail + resolve_unique_remote_ref() { + local remote_url="$1" + shift + local -a refs=("$@") + local -a matches=() + local ref="" + + for ref in "${refs[@]}"; do + [[ -n "${ref}" ]] || continue + mapfile -t matches < <( + git ls-remote "${remote_url}" "${ref}" | awk '{print $1}' | awk '!seen[$0]++' + ) + if [[ "${#matches[@]}" -eq 0 ]]; then + continue + fi + if [[ "${#matches[@]}" -ne 1 ]]; then + return 2 + fi + + printf '%s\n' "${matches[0]}" + return 0 + done + return 1 + } + + if [[ -n "${INPUT_WORKFLOW_REF}" ]]; then + TARGET_REF="${INPUT_WORKFLOW_REF}" + elif [[ "${CALLER_REPOSITORY}" == "${WORKFLOW_REPOSITORY}" ]]; then + TARGET_REF="${CURRENT_SHA}" + elif [[ "${WORKFLOW_CONTEXT_REF}" == "${WORKFLOW_REPOSITORY}/"* ]] && [[ "${WORKFLOW_CONTEXT_REF}" == *"@"* ]]; then + TARGET_REF="${WORKFLOW_CONTEXT_REF##*@}" + else + echo "Failed to infer workflow ref from github.workflow_ref=${WORKFLOW_CONTEXT_REF}" >&2 + exit 1 + fi + + if [[ "${TARGET_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "value=${TARGET_REF}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + REMOTE_URL="https://github.com/${WORKFLOW_REPOSITORY}.git" + if [[ "${TARGET_REF}" == refs/* ]]; then + if [[ "${TARGET_REF}" == refs/tags/* ]]; then + mapfile -t MATCHES < <( + resolve_unique_remote_ref "${REMOTE_URL}" "${TARGET_REF}^{}" "${TARGET_REF}" || true + ) + else + mapfile -t MATCHES < <(resolve_unique_remote_ref "${REMOTE_URL}" "${TARGET_REF}" || true) + fi + else + mapfile -t BRANCH_MATCHES < <( + resolve_unique_remote_ref "${REMOTE_URL}" "refs/heads/${TARGET_REF}" || true + ) + mapfile -t TAG_MATCHES < <( + resolve_unique_remote_ref "${REMOTE_URL}" "refs/tags/${TARGET_REF}^{}" "refs/tags/${TARGET_REF}" || true + ) + + MATCH_COUNT=$(( ${#BRANCH_MATCHES[@]} + ${#TAG_MATCHES[@]} )) + if [[ "${MATCH_COUNT}" -eq 1 ]]; then + if [[ "${#BRANCH_MATCHES[@]}" -eq 1 ]]; then + MATCHES=("${BRANCH_MATCHES[0]}") + else + MATCHES=("${TAG_MATCHES[0]}") + fi + elif [[ "${MATCH_COUNT}" -eq 0 ]]; then + MATCHES=() + else + echo "Workflow ref resolved ambiguously: ${TARGET_REF}" >&2 + exit 1 + fi + fi + + case "${#MATCHES[@]}" in + 1) + echo "value=${MATCHES[0]}" >> "$GITHUB_OUTPUT" + ;; + 0) + echo "Failed to resolve workflow ref: ${TARGET_REF}" >&2 + exit 1 + ;; + *) + echo "Workflow ref resolved ambiguously: ${TARGET_REF}" >&2 + exit 1 + ;; + esac + + - name: Checkout workflow repo uses: actions/checkout@v6 with: + repository: ${{ env.OPENCLAW_REPOSITORY }} + ref: ${{ steps.workflow_ref.outputs.value }} + path: workflow fetch-depth: 1 persist-credentials: false @@ -123,7 +286,7 @@ jobs: env: OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare run: | - node --disable-warning=ExperimentalWarning scripts/openclaw-cross-os-release-checks.ts \ + pnpm dlx "tsx@${TSX_VERSION}" workflow/scripts/openclaw-cross-os-release-checks.ts \ --prepare-only \ --source-dir source \ --output-dir "${OUTPUT_DIR}" @@ -198,6 +361,7 @@ jobs: - name: Resolve runner matrix id: matrix env: + INPUT_REF: ${{ inputs.ref }} INPUT_MODE: ${{ inputs.mode }} INPUT_UBUNTU_RUNNER: ${{ inputs.ubuntu_runner }} INPUT_WINDOWS_RUNNER: ${{ inputs.windows_runner }} @@ -206,53 +370,30 @@ jobs: VAR_WINDOWS_RUNNER: ${{ vars.OPENCLAW_RELEASE_CHECKS_WINDOWS_RUNNER }} VAR_MACOS_RUNNER: ${{ vars.OPENCLAW_RELEASE_CHECKS_MACOS_RUNNER }} run: | - node <<'NODE' >>"$GITHUB_OUTPUT" - const pick = (...values) => values.find((value) => typeof value === "string" && value.trim().length > 0)?.trim(); - const lanes = (process.env.INPUT_MODE ?? "both") === "both" ? ["fresh", "upgrade"] : [process.env.INPUT_MODE ?? "both"]; - const runners = [ - { - os_id: "ubuntu", - display_name: "Linux", - runner: pick(process.env.INPUT_UBUNTU_RUNNER, process.env.VAR_UBUNTU_RUNNER, "ubuntu-latest"), - artifact_name: "linux", - }, - { - os_id: "windows", - display_name: "Windows", - runner: pick( - process.env.INPUT_WINDOWS_RUNNER, - process.env.VAR_WINDOWS_RUNNER, - "blacksmith-32vcpu-windows-2025", - ), - artifact_name: "windows", - }, - { - os_id: "macos", - display_name: "macOS", - runner: pick(process.env.INPUT_MACOS_RUNNER, process.env.VAR_MACOS_RUNNER, "macos-latest-xlarge"), - artifact_name: "macos", - }, - ]; - const matrix = { - include: runners.flatMap((runner) => lanes.map((lane) => ({ ...runner, lane }))), - }; - process.stdout.write(`value=${JSON.stringify(matrix)}\n`); - NODE + MATRIX_JSON="$(pnpm dlx "tsx@${TSX_VERSION}" workflow/scripts/openclaw-cross-os-release-checks.ts \ + --resolve-matrix \ + --ref "${INPUT_REF}" \ + --mode "${INPUT_MODE}" \ + --ubuntu-runner "${INPUT_UBUNTU_RUNNER}" \ + --windows-runner "${INPUT_WINDOWS_RUNNER}" \ + --macos-runner "${INPUT_MACOS_RUNNER}")" + echo "value=${MATRIX_JSON}" >> "$GITHUB_OUTPUT" cross_os_release_checks: - name: "${{ matrix.display_name }} / ${{ matrix.lane }}" + name: "${{ matrix.display_name }} / ${{ matrix.suite_label }}" needs: prepare - permissions: - contents: read strategy: fail-fast: false matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} runs-on: ${{ matrix.runner }} timeout-minutes: 120 steps: - - name: Checkout caller release workflow repo + - name: Checkout workflow repo uses: actions/checkout@v6 with: + repository: ${{ env.OPENCLAW_REPOSITORY }} + ref: ${{ needs.prepare.outputs.workflow_ref }} + path: workflow fetch-depth: 1 persist-credentials: false @@ -274,7 +415,7 @@ jobs: path: ${{ runner.temp }}/openclaw-cross-os-release-checks/candidate - name: Download baseline artifact - if: ${{ matrix.lane == 'upgrade' }} + if: ${{ matrix.suite == 'packaged-upgrade' }} uses: actions/download-artifact@v8 with: name: openclaw-cross-os-release-checks-baseline-${{ github.run_id }} @@ -286,24 +427,35 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }} + OPENCLAW_DISCORD_SMOKE_BOT_TOKEN: ${{ secrets.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN }} + OPENCLAW_DISCORD_SMOKE_GUILD_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_GUILD_ID }} + OPENCLAW_DISCORD_SMOKE_CHANNEL_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID }} OPENCLAW_RELEASE_CHECK_OS: ${{ matrix.os_id }} OPENCLAW_RELEASE_CHECK_RUNNER: ${{ matrix.runner }} run: | - node --disable-warning=ExperimentalWarning scripts/openclaw-cross-os-release-checks.ts \ + DISCORD_ARGS=() + if [[ -n "${OPENCLAW_DISCORD_SMOKE_BOT_TOKEN}" ]] && [[ -n "${OPENCLAW_DISCORD_SMOKE_GUILD_ID}" ]] && [[ -n "${OPENCLAW_DISCORD_SMOKE_CHANNEL_ID}" ]]; then + DISCORD_ARGS+=(--run-discord-roundtrip true) + fi + pnpm dlx "tsx@${TSX_VERSION}" workflow/scripts/openclaw-cross-os-release-checks.ts \ --candidate-tgz "$RUNNER_TEMP/openclaw-cross-os-release-checks/candidate/${{ needs.prepare.outputs.candidate_file_name }}" \ --candidate-version "${{ needs.prepare.outputs.candidate_version }}" \ --source-sha "${{ needs.prepare.outputs.source_sha }}" \ --baseline-spec "${{ needs.prepare.outputs.baseline_spec }}" \ + --previous-version "${{ inputs.previous_version }}" \ --baseline-tgz "$RUNNER_TEMP/openclaw-cross-os-release-checks/baseline/${{ needs.prepare.outputs.baseline_file_name }}" \ --provider "${{ inputs.provider }}" \ --mode "${{ matrix.lane }}" \ - --output-dir "$RUNNER_TEMP/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.lane }}" + --suite "${{ matrix.suite }}" \ + --ref "${{ inputs.ref }}" \ + "${DISCORD_ARGS[@]}" \ + --output-dir "$RUNNER_TEMP/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.suite }}" - name: Summarize release checks if: always() shell: bash env: - SUMMARY_PATH: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.lane }}/summary.md + SUMMARY_PATH: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.suite }}/summary.md run: | if [[ -f "${SUMMARY_PATH}" ]]; then cat "${SUMMARY_PATH}" >> "$GITHUB_STEP_SUMMARY" @@ -315,6 +467,6 @@ jobs: if: always() uses: actions/upload-artifact@v7 with: - name: openclaw-cross-os-release-checks-${{ matrix.artifact_name }}-${{ matrix.lane }}-${{ github.run_id }} - path: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.lane }} + name: openclaw-cross-os-release-checks-${{ matrix.artifact_name }}-${{ matrix.suite }}-${{ github.run_id }} + path: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.suite }} if-no-files-found: error diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml new file mode 100644 index 00000000000..7392b0abed6 --- /dev/null +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -0,0 +1,563 @@ +name: OpenClaw Live And E2E Checks (Reusable) + +on: + workflow_dispatch: + inputs: + ref: + description: Ref, tag, or SHA to validate + required: true + default: main + type: string + include_repo_e2e: + description: Whether to run pnpm test:e2e plus repo-specific extra E2E lanes + required: false + default: true + type: boolean + include_release_path_suites: + description: Whether to run the Docker release-path suites + required: false + default: true + type: boolean + include_openwebui: + description: Whether to run the Open WebUI Docker smoke + required: false + default: true + type: boolean + include_live_suites: + description: Whether to run live-provider coverage + required: false + default: true + type: boolean + workflow_call: + inputs: + ref: + description: Ref, tag, or SHA to validate + required: true + type: string + include_repo_e2e: + description: Whether to run pnpm test:e2e + required: false + default: false + type: boolean + include_release_path_suites: + description: Whether to run the Docker release-path suites + required: false + default: false + type: boolean + include_openwebui: + description: Whether to run the Open WebUI Docker smoke + required: false + default: true + type: boolean + include_live_suites: + description: Whether to run live-provider coverage + required: false + default: true + type: boolean + secrets: + OPENAI_API_KEY: + required: false + OPENAI_BASE_URL: + required: false + ANTHROPIC_API_KEY: + required: false + ANTHROPIC_API_KEY_OLD: + required: false + ANTHROPIC_API_TOKEN: + required: false + BYTEPLUS_API_KEY: + required: false + CEREBRAS_API_KEY: + required: false + DASHSCOPE_API_KEY: + required: false + GROQ_API_KEY: + required: false + KIMI_API_KEY: + required: false + MODELSTUDIO_API_KEY: + required: false + MOONSHOT_API_KEY: + required: false + MISTRAL_API_KEY: + required: false + MINIMAX_API_KEY: + required: false + OPENCODE_API_KEY: + required: false + OPENCODE_ZEN_API_KEY: + required: false + OPENCLAW_LIVE_BROWSER_CDP_URL: + required: false + OPENCLAW_LIVE_SETUP_TOKEN: + required: false + OPENCLAW_LIVE_SETUP_TOKEN_MODEL: + required: false + OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: + required: false + OPENCLAW_LIVE_SETUP_TOKEN_VALUE: + required: false + GEMINI_API_KEY: + required: false + GOOGLE_API_KEY: + required: false + OPENROUTER_API_KEY: + required: false + QWEN_API_KEY: + required: false + FAL_KEY: + required: false + RUNWAY_API_KEY: + required: false + DEEPGRAM_API_KEY: + required: false + TOGETHER_API_KEY: + required: false + VYDRA_API_KEY: + required: false + XAI_API_KEY: + required: false + ZAI_API_KEY: + required: false + Z_AI_API_KEY: + required: false + BYTEPLUS_ACCESS_KEY_ID: + required: false + BYTEPLUS_SECRET_ACCESS_KEY: + required: false + CLAUDE_CODE_OAUTH_TOKEN: + required: false + OPENCLAW_CODEX_AUTH_JSON: + required: false + OPENCLAW_CODEX_CONFIG_TOML: + required: false + OPENCLAW_CLAUDE_JSON: + required: false + OPENCLAW_CLAUDE_CREDENTIALS_JSON: + required: false + OPENCLAW_CLAUDE_SETTINGS_JSON: + required: false + OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: + required: false + OPENCLAW_GEMINI_SETTINGS_JSON: + required: false + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + NODE_VERSION: "24.x" + PNPM_VERSION: "10.32.1" + +jobs: + validate_release_live_cache: + if: inputs.include_live_suites + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: 60 + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENCLAW_LIVE_CACHE_TEST: "1" + OPENCLAW_LIVE_TEST: "1" + steps: + - name: Checkout selected ref + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: "true" + use-sticky-disk: "false" + + - name: Validate live cache credentials + run: | + set -euo pipefail + if [[ -z "${OPENAI_API_KEY:-}" ]]; then + echo "Missing OPENAI_API_KEY secret for live-cache validation." >&2 + exit 1 + fi + if [[ -z "${ANTHROPIC_API_KEY:-}" ]]; then + echo "Missing ANTHROPIC_API_KEY secret for live-cache validation." >&2 + exit 1 + fi + + - name: Verify live prompt cache floors + run: pnpm test:live:cache + + validate_repo_e2e: + if: inputs.include_repo_e2e + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: 90 + env: + OPENCLAW_VITEST_MAX_WORKERS: "2" + steps: + - name: Checkout selected ref + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: "true" + use-sticky-disk: "false" + + - name: Run repo E2E suite + run: pnpm test:e2e + + validate_special_e2e: + if: inputs.include_repo_e2e || inputs.include_live_suites + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: ${{ matrix.timeout_minutes }} + strategy: + fail-fast: false + matrix: + include: + - suite_id: openshell-e2e + label: OpenShell repo E2E + command: pnpm test:e2e:openshell + timeout_minutes: 120 + requires_repo_e2e: true + requires_live_suites: false + - suite_id: openai-ws-stream-live-e2e + label: OpenAI WebSocket live E2E + command: pnpm test:e2e -- src/agents/openai-ws-stream.e2e.test.ts + timeout_minutes: 90 + requires_repo_e2e: false + requires_live_suites: true + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENCLAW_E2E_WORKERS: "1" + OPENCLAW_VITEST_MAX_WORKERS: "1" + steps: + - name: Checkout selected ref + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: "true" + use-sticky-disk: "false" + + - name: Configure suite-specific env + shell: bash + run: | + set -euo pipefail + case "${{ matrix.suite_id }}" in + openai-ws-stream-live-e2e) + echo "OPENAI_LIVE_TEST=1" >> "$GITHUB_ENV" + echo "OPENCLAW_LIVE_TEST=1" >> "$GITHUB_ENV" + ;; + esac + + - name: Validate suite credentials + shell: bash + run: | + set -euo pipefail + case "${{ matrix.suite_id }}" in + openai-ws-stream-live-e2e) + [[ -n "${OPENAI_API_KEY:-}" ]] || { + echo "OPENAI_API_KEY is required for the OpenAI WebSocket live E2E suite." >&2 + exit 1 + } + ;; + esac + + - name: Run ${{ matrix.label }} + if: | + (inputs.include_repo_e2e && matrix.requires_repo_e2e) || + (inputs.include_live_suites && matrix.requires_live_suites) + run: ${{ matrix.command }} + + validate_docker_e2e: + if: inputs.include_release_path_suites || inputs.include_openwebui + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: ${{ matrix.timeout_minutes }} + strategy: + fail-fast: false + matrix: + include: + - suite_id: docker-onboard + label: Onboarding Docker E2E + command: pnpm test:docker:onboard + timeout_minutes: 60 + release_path: true + openwebui_only: false + - suite_id: docker-gateway-network + label: Gateway Network Docker E2E + command: pnpm test:docker:gateway-network + timeout_minutes: 60 + release_path: true + openwebui_only: false + - suite_id: docker-mcp-channels + label: MCP Channels Docker E2E + command: pnpm test:docker:mcp-channels + timeout_minutes: 60 + release_path: true + openwebui_only: false + - suite_id: docker-plugins + label: Plugins Docker E2E + command: pnpm test:docker:plugins + timeout_minutes: 75 + release_path: true + openwebui_only: false + - suite_id: docker-doctor-switch + label: Doctor Install Switch Docker E2E + command: pnpm test:docker:doctor-switch + timeout_minutes: 60 + release_path: true + openwebui_only: false + - suite_id: docker-qr + label: QR Import Docker E2E + command: pnpm test:docker:qr + timeout_minutes: 60 + release_path: true + openwebui_only: false + - suite_id: docker-install-e2e + label: Installer Docker E2E + command: pnpm test:install:e2e + timeout_minutes: 120 + release_path: true + openwebui_only: false + - suite_id: docker-openwebui + label: Open WebUI Docker E2E + command: pnpm test:docker:openwebui + timeout_minutes: 75 + release_path: false + openwebui_only: true + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }} + ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }} + BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }} + CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} + KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }} + MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }} + MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} + MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} + MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }} + OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }} + OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }} + OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }} + OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }} + OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }} + FAL_KEY: ${{ secrets.FAL_KEY }} + RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }} + DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }} + TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }} + VYDRA_API_KEY: ${{ secrets.VYDRA_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 }} + BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }} + BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }} + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }} + OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }} + OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }} + OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }} + OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }} + OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }} + OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }} + steps: + - name: Checkout selected ref + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: "true" + use-sticky-disk: "false" + + - name: Hydrate live auth/profile inputs + run: bash scripts/ci-hydrate-live-auth.sh + + - name: Configure suite-specific env + shell: bash + run: | + set -euo pipefail + case "${{ matrix.suite_id }}" in + docker-install-e2e) + echo "OPENCLAW_E2E_MODELS=both" >> "$GITHUB_ENV" + ;; + esac + + - name: Validate suite credentials + shell: bash + run: | + set -euo pipefail + case "${{ matrix.suite_id }}" in + docker-install-e2e) + [[ -n "${OPENAI_API_KEY:-}" ]] || { + echo "OPENAI_API_KEY is required for installer Docker E2E." >&2 + exit 1 + } + if [[ -z "${ANTHROPIC_API_TOKEN:-}" && -z "${ANTHROPIC_API_KEY:-}" ]]; then + echo "ANTHROPIC_API_TOKEN or ANTHROPIC_API_KEY is required for installer Docker E2E." >&2 + exit 1 + fi + ;; + docker-openwebui) + [[ -n "${OPENAI_API_KEY:-}" ]] || { + echo "OPENAI_API_KEY is required for the Open WebUI Docker smoke." >&2 + exit 1 + } + ;; + esac + + - name: Run ${{ matrix.label }} + if: | + (inputs.include_release_path_suites && matrix.release_path) || + (inputs.include_openwebui && matrix.openwebui_only) + run: ${{ matrix.command }} + + validate_live_provider_suites: + if: inputs.include_live_suites + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: ${{ matrix.timeout_minutes }} + strategy: + fail-fast: false + matrix: + include: + - suite_id: live-all + label: pnpm test:live + command: pnpm test:live + timeout_minutes: 180 + profile_env_only: false + - suite_id: live-models-docker + label: Docker live models + command: pnpm test:docker:live-models + timeout_minutes: 120 + profile_env_only: true + - suite_id: live-gateway-docker + label: Docker live gateway + command: pnpm test:docker:live-gateway + timeout_minutes: 120 + profile_env_only: true + - suite_id: live-cli-backend-docker + label: Docker live CLI backend + command: pnpm test:docker:live-cli-backend + timeout_minutes: 120 + profile_env_only: false + - suite_id: live-acp-bind-docker + label: Docker live ACP bind + command: pnpm test:docker:live-acp-bind + timeout_minutes: 120 + profile_env_only: false + - suite_id: live-codex-harness-docker + label: Docker live Codex harness + command: pnpm test:docker:live-codex-harness + timeout_minutes: 120 + profile_env_only: false + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }} + ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }} + BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }} + CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} + KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }} + MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }} + MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} + MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} + MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }} + OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }} + OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }} + OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }} + OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }} + OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }} + FAL_KEY: ${{ secrets.FAL_KEY }} + RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }} + DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }} + TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }} + VYDRA_API_KEY: ${{ secrets.VYDRA_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 }} + BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }} + BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }} + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }} + OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }} + OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }} + OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }} + OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }} + OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }} + OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }} + OPENCLAW_LIVE_VIDEO_GENERATION_SKIP_PROVIDERS: "" + OPENCLAW_LIVE_VYDRA_VIDEO: "1" + OPENCLAW_VITEST_MAX_WORKERS: "2" + steps: + - name: Checkout selected ref + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: "true" + use-sticky-disk: "false" + + - name: Hydrate live auth/profile inputs + run: bash scripts/ci-hydrate-live-auth.sh + + - name: Configure suite-specific env + shell: bash + run: | + set -euo pipefail + if [[ "${{ matrix.profile_env_only }}" == "true" ]]; then + echo "OPENCLAW_DOCKER_PROFILE_ENV_ONLY=1" >> "$GITHUB_ENV" + fi + case "${{ matrix.suite_id }}" in + live-cli-backend-docker) + echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.4" >> "$GITHUB_ENV" + ;; + live-acp-bind-docker) + echo "OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini" >> "$GITHUB_ENV" + ;; + esac + + - name: Run ${{ matrix.label }} + run: ${{ matrix.command }} diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 3a740ba0231..22c39bd74aa 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -7,6 +7,24 @@ on: description: Existing release tag or current full 40-character main commit SHA to validate (for example v2026.4.12 or 0123456789abcdef0123456789abcdef01234567) required: true type: string + provider: + description: Provider lane for cross-OS onboarding and the end-to-end agent turn + required: false + default: openai + type: choice + options: + - openai + - anthropic + - minimax + mode: + description: Which cross-OS release lanes to run + required: false + default: both + type: choice + options: + - fresh + - upgrade + - both concurrency: group: openclaw-release-checks-${{ inputs.ref }} @@ -14,18 +32,18 @@ concurrency: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - NODE_VERSION: "24.x" - PNPM_VERSION: "10.32.1" jobs: - # THIS WORKFLOW EXISTS SO RELEASE-TIME LIVE CHECKS CAN RUN WITHOUT BLOCKING npm PUBLISH. - # PUT THE SLOWER, EXTERNAL, OR SOMETIMES-FLAKY RELEASE CHECKS HERE INSTEAD OF - # RECOUPLING THEM TO openclaw-npm-release.yml. - validate_release_live_cache: + resolve_target: runs-on: blacksmith-32vcpu-ubuntu-2404 - timeout-minutes: 60 + timeout-minutes: 30 permissions: contents: read + outputs: + ref: ${{ steps.inputs.outputs.ref }} + sha: ${{ steps.ref.outputs.sha }} + provider: ${{ steps.inputs.outputs.provider }} + mode: ${{ steps.inputs.outputs.mode }} steps: - name: Require main workflow ref for release checks env: @@ -73,48 +91,53 @@ jobs: git merge-base --is-ancestor HEAD origin/main fi - - name: Setup Node environment - uses: ./.github/actions/setup-node-env - with: - node-version: ${{ env.NODE_VERSION }} - pnpm-version: ${{ env.PNPM_VERSION }} - install-bun: "true" - use-sticky-disk: "false" - - - name: Validate live cache credentials - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + - name: Capture selected inputs + id: inputs run: | set -euo pipefail - if [[ -z "${OPENAI_API_KEY}" ]]; then - echo "Missing OPENAI_API_KEY secret for release checks." >&2 - exit 1 - fi - if [[ -z "${ANTHROPIC_API_KEY}" ]]; then - echo "Missing ANTHROPIC_API_KEY secret for release checks." >&2 - exit 1 - fi - - # KEEP RELEASE-TIME LIVE COVERAGE HERE SO OPERATORS CAN RUN IT ON DEMAND - # WITHOUT MAKING THE PUBLISH PATH WAIT FOR A SLOW OR FLAKY EXTERNAL CHECK. - - name: Verify live prompt cache floors - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - OPENCLAW_LIVE_CACHE_TEST: "1" - OPENCLAW_LIVE_TEST: "1" - run: pnpm test:live:cache + { + echo "ref=${{ inputs.ref }}" + echo "provider=${{ inputs.provider }}" + echo "mode=${{ inputs.mode }}" + } >> "$GITHUB_OUTPUT" - name: Summarize validated ref env: RELEASE_REF: ${{ inputs.ref }} RELEASE_SHA: ${{ steps.ref.outputs.sha }} + RELEASE_PROVIDER: ${{ inputs.provider }} + RELEASE_MODE: ${{ inputs.mode }} run: | { echo "## Release checks" echo echo "- Requested ref: \`${RELEASE_REF}\`" echo "- Validated SHA: \`${RELEASE_SHA}\`" - echo "- Check: \`pnpm test:live:cache\`" + echo "- Cross-OS provider: \`${RELEASE_PROVIDER}\`" + echo "- Cross-OS mode: \`${RELEASE_MODE}\`" + echo "- This run will execute cross-OS release validation plus the non-Parallels Docker/live/openwebui coverage from the CI migration plan." } >> "$GITHUB_STEP_SUMMARY" + + cross_os_release_checks: + needs: [resolve_target] + permissions: + contents: read + uses: ./.github/workflows/openclaw-cross-os-release-checks-reusable.yml + with: + ref: ${{ needs.resolve_target.outputs.ref }} + provider: ${{ needs.resolve_target.outputs.provider }} + mode: ${{ needs.resolve_target.outputs.mode }} + secrets: inherit + + live_and_e2e_release_checks: + needs: [resolve_target] + permissions: + contents: read + uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml + with: + ref: ${{ needs.resolve_target.outputs.ref }} + include_repo_e2e: true + include_release_path_suites: true + include_openwebui: true + include_live_suites: true + secrets: inherit diff --git a/.github/workflows/openclaw-scheduled-live-checks.yml b/.github/workflows/openclaw-scheduled-live-checks.yml new file mode 100644 index 00000000000..8c0e331e901 --- /dev/null +++ b/.github/workflows/openclaw-scheduled-live-checks.yml @@ -0,0 +1,29 @@ +name: OpenClaw Scheduled Live And E2E Checks + +on: + schedule: + - cron: "23 4 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: openclaw-scheduled-live-checks-${{ github.ref }} + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + live_and_openwebui_checks: + permissions: + contents: read + uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml + with: + ref: ${{ github.sha }} + include_repo_e2e: true + include_release_path_suites: false + include_openwebui: true + include_live_suites: true + secrets: inherit diff --git a/scripts/ci-hydrate-live-auth.sh b/scripts/ci-hydrate-live-auth.sh new file mode 100644 index 00000000000..a1b2123a4f4 --- /dev/null +++ b/scripts/ci-hydrate-live-auth.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +profile_path="${1:-${RUNNER_TEMP:-/tmp}/openclaw-live.profile}" + +mkdir -p "$(dirname "$profile_path")" +: >"$profile_path" +chmod 600 "$profile_path" + +append_profile_env() { + local key="$1" + local value="${!key:-}" + if [[ -z "$value" || "$value" == "undefined" || "$value" == "null" ]]; then + return + fi + printf 'export %s=%q\n' "$key" "$value" >>"$profile_path" +} + +write_secret_file() { + local destination="$1" + local source_env="$2" + local value="${!source_env:-}" + if [[ -z "$value" ]]; then + return + fi + mkdir -p "$(dirname "$destination")" + printf '%s' "$value" >"$destination" + chmod 600 "$destination" +} + +for env_key in \ + OPENAI_API_KEY \ + OPENAI_BASE_URL \ + ANTHROPIC_API_KEY \ + ANTHROPIC_API_KEY_OLD \ + ANTHROPIC_API_TOKEN \ + BYTEPLUS_API_KEY \ + CEREBRAS_API_KEY \ + DASHSCOPE_API_KEY \ + GROQ_API_KEY \ + KIMI_API_KEY \ + MODELSTUDIO_API_KEY \ + MOONSHOT_API_KEY \ + MISTRAL_API_KEY \ + MINIMAX_API_KEY \ + OPENCODE_API_KEY \ + OPENCODE_ZEN_API_KEY \ + OPENCLAW_LIVE_BROWSER_CDP_URL \ + OPENCLAW_LIVE_SETUP_TOKEN \ + OPENCLAW_LIVE_SETUP_TOKEN_MODEL \ + OPENCLAW_LIVE_SETUP_TOKEN_PROFILE \ + OPENCLAW_LIVE_SETUP_TOKEN_VALUE \ + GEMINI_API_KEY \ + GOOGLE_API_KEY \ + OPENROUTER_API_KEY \ + QWEN_API_KEY \ + FAL_KEY \ + RUNWAY_API_KEY \ + DEEPGRAM_API_KEY \ + TOGETHER_API_KEY \ + VYDRA_API_KEY \ + XAI_API_KEY \ + ZAI_API_KEY \ + Z_AI_API_KEY \ + BYTEPLUS_ACCESS_KEY_ID \ + BYTEPLUS_SECRET_ACCESS_KEY \ + CLAUDE_CODE_OAUTH_TOKEN +do + append_profile_env "$env_key" +done + +write_secret_file "$HOME/.codex/auth.json" OPENCLAW_CODEX_AUTH_JSON +write_secret_file "$HOME/.codex/config.toml" OPENCLAW_CODEX_CONFIG_TOML +write_secret_file "$HOME/.claude.json" OPENCLAW_CLAUDE_JSON +write_secret_file "$HOME/.claude/.credentials.json" OPENCLAW_CLAUDE_CREDENTIALS_JSON +write_secret_file "$HOME/.claude/settings.json" OPENCLAW_CLAUDE_SETTINGS_JSON +write_secret_file "$HOME/.claude/settings.local.json" OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON +write_secret_file "$HOME/.gemini/settings.json" OPENCLAW_GEMINI_SETTINGS_JSON + +if [[ -n "${GITHUB_ENV:-}" ]]; then + { + echo "OPENCLAW_PROFILE_FILE=$profile_path" + } >>"$GITHUB_ENV" +fi diff --git a/scripts/install.ps1 b/scripts/install.ps1 index fccf2fec06b..7078c95dc61 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -29,7 +29,7 @@ function Write-Host { "error" { "$ERROR✗$NC $Message" } default { "$MUTED·$NC $Message" } } - Microsoft.PowerShell.Host\Write-Host $msg + Microsoft.PowerShell.Utility\Write-Host $msg } function Write-Banner { @@ -199,6 +199,76 @@ function Ensure-Git { return Install-Git } +function Read-TrimmedFileText { + param([string]$Path) + + if (!(Test-Path -LiteralPath $Path)) { + return "" + } + + return ((Get-Content -LiteralPath $Path -Raw) -replace "(\r?\n)+$", "") +} + +function ConvertTo-PowerShellSingleQuotedLiteral { + param([string]$Value) + + return "'" + ($Value -replace "'", "''") + "'" +} + +function Invoke-NativeCommandCapture { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [string[]]$Arguments = @() + ) + + $stdoutPath = [System.IO.Path]::GetTempFileName() + $stderrPath = [System.IO.Path]::GetTempFileName() + + try { + $startFilePath = $FilePath + $startArguments = $Arguments + + if ($FilePath -match '(?i)\.(cmd|bat)$') { + # Start-Process cannot directly redirect stdio for command shims like + # npm.cmd. Run them inside a nested PowerShell so the shim executes + # normally while stdout/stderr still flow back to these temp files. + $commandParts = @( + ConvertTo-PowerShellSingleQuotedLiteral -Value $FilePath + ) + foreach ($argument in $Arguments) { + $commandParts += ConvertTo-PowerShellSingleQuotedLiteral -Value $argument + } + $commandScript = "& " + ($commandParts -join " ") + "`nexit `$LASTEXITCODE" + $startFilePath = "powershell.exe" + $startArguments = @( + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + $commandScript + ) + } + + $process = Start-Process -FilePath $startFilePath ` + -ArgumentList $startArguments ` + -Wait ` + -PassThru ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath + + return @{ + ExitCode = $process.ExitCode + Stdout = Read-TrimmedFileText -Path $stdoutPath + Stderr = Read-TrimmedFileText -Path $stderrPath + } + } finally { + Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue + } +} + function Install-OpenClawNpm { param([string]$Target = "latest") @@ -207,8 +277,25 @@ function Install-OpenClawNpm { Write-Host "Installing OpenClaw ($installSpec)..." -Level info try { - # Use -ExecutionPolicy Bypass to handle restricted execution policy - npm install -g $installSpec --no-fund --no-audit 2>&1 + # Run npm out-of-process so warning chatter on stderr does not get + # promoted into a terminating PowerShell error while the install succeeds. + $installResult = Invoke-NativeCommandCapture -FilePath "npm.cmd" -Arguments @( + "install", + "-g", + $installSpec, + "--no-fund", + "--no-audit" + ) + if ($installResult.Stdout) { + Microsoft.PowerShell.Utility\Write-Output $installResult.Stdout + } + if ($installResult.Stderr) { + Microsoft.PowerShell.Utility\Write-Output $installResult.Stderr + } + if ($installResult.ExitCode -ne 0) { + Write-Host "npm install failed with exit code $($installResult.ExitCode)" -Level error + return $false + } Write-Host "OpenClaw installed" -Level success return $true } catch { @@ -341,8 +428,13 @@ function Main { # Try to add npm global bin to PATH try { - $npmPrefix = npm config get prefix 2>$null - if ($npmPrefix) { + $prefixResult = Invoke-NativeCommandCapture -FilePath "npm.cmd" -Arguments @( + "config", + "get", + "prefix" + ) + $npmPrefix = $prefixResult.Stdout + if ($prefixResult.ExitCode -eq 0 -and $npmPrefix) { Add-ToPath -Path "$npmPrefix" } } catch { } diff --git a/scripts/install.sh b/scripts/install.sh index f26ff0919e1..cfefbc0755d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1942,6 +1942,11 @@ resolve_beta_version() { echo "$beta" } +to_lowercase_ascii() { + # macOS still ships Bash 3.2, so avoid `${value,,}` here. + printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' +} + is_explicit_package_install_spec() { local value="${1:-}" [[ "$value" == *"://"* || "$value" == *"#"* || "$value" =~ ^(file|github|git\+ssh|git\+https|git\+http|git\+file|npm): ]] @@ -1949,10 +1954,12 @@ is_explicit_package_install_spec() { can_resolve_registry_package_version() { local value="${1:-}" + local normalized_value="" + normalized_value="$(to_lowercase_ascii "$value")" if [[ -z "$value" ]]; then return 0 fi - if [[ "${value,,}" == "main" ]]; then + if [[ "$normalized_value" == "main" ]]; then return 1 fi if is_explicit_package_install_spec "$value"; then @@ -1964,7 +1971,9 @@ can_resolve_registry_package_version() { resolve_package_install_spec() { local package_name="$1" local value="$2" - if [[ "${value,,}" == "main" ]]; then + local normalized_value="" + normalized_value="$(to_lowercase_ascii "$value")" + if [[ "$normalized_value" == "main" ]]; then echo "github:openclaw/openclaw#main" return 0 fi diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts new file mode 100644 index 00000000000..ae8925caba2 --- /dev/null +++ b/scripts/openclaw-cross-os-release-checks.ts @@ -0,0 +1,2975 @@ +#!/usr/bin/env -S node --import tsx + +// Executed directly via Node.js + tsx in the release workflow. + +import { spawn } from "node:child_process"; +import { + chmodSync, + createWriteStream, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { createServer } from "node:http"; +import { createConnection as createNetConnection, createServer as createNetServer } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve, win32 as pathWin32 } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_PATH = fileURLToPath(import.meta.url); +const PUBLISHED_INSTALLER_BASE_URL = "https://openclaw.ai"; + +const SUPPORTED_MODES = new Set(["fresh", "upgrade", "both"]); +const SUPPORTED_SUITES = new Set([ + "packaged-fresh", + "installer-fresh", + "packaged-upgrade", + "dev-update", +]); + +const providerConfig = { + openai: { + extensionId: "openai", + secretEnv: "OPENAI_API_KEY", + authChoice: "openai-api-key", + model: "openai/gpt-5.4", + }, + anthropic: { + extensionId: "anthropic", + secretEnv: "ANTHROPIC_API_KEY", + authChoice: "apiKey", + model: "anthropic/claude-sonnet-4-6", + }, + minimax: { + extensionId: "minimax", + secretEnv: "MINIMAX_API_KEY", + authChoice: "minimax-global-api", + model: "minimax/MiniMax-M2.7", + }, +}; + +const PACKAGE_DIST_INVENTORY_RELATIVE_PATH = "dist/postinstall-inventory.json"; +const PACKAGED_QA_RUNTIME_PATHS = new Set([ + "dist/extensions/qa-channel/runtime-api.js", + "dist/extensions/qa-lab/runtime-api.js", +]); +const OMITTED_QA_EXTENSION_PREFIXES = [ + "dist/extensions/qa-channel/", + "dist/extensions/qa-lab/", + "dist/extensions/qa-matrix/", +]; + +if (isMainModule()) { + try { + await main(process.argv.slice(2)); + } catch (error) { + process.stderr.write(`${formatError(error)}\n`); + process.exit(1); + } +} + +function isMainModule() { + const invokedPath = process.argv[1]?.trim(); + if (!invokedPath) { + return false; + } + return resolve(invokedPath) === SCRIPT_PATH; +} + +export function parseArgs(argv) { + const parsed = {}; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (!token.startsWith("--")) { + continue; + } + const key = token.slice(2); + const next = argv[index + 1]; + if (next === undefined || next.startsWith("--")) { + parsed[key] = "true"; + continue; + } + parsed[key] = next; + index += 1; + } + return parsed; +} + +export function looksLikeReleaseVersionRef(ref) { + const trimmed = normalizeRequestedRef(ref); + return /^v?[0-9]{4}\.[0-9]+\.[0-9]+(?:-(?:[1-9][0-9]*)|[-.](?:beta|rc)[-.]?[0-9]+)?$/iu.test( + trimmed, + ); +} + +export function normalizeRequestedRef(ref) { + const trimmed = ref?.trim() || ""; + if (!trimmed) { + return ""; + } + if (trimmed.startsWith("refs/heads/")) { + return trimmed.slice("refs/heads/".length); + } + if (trimmed.startsWith("refs/tags/")) { + return trimmed.slice("refs/tags/".length); + } + return trimmed; +} + +export function isImmutableReleaseRef(ref) { + const trimmed = ref?.trim() || ""; + return trimmed.startsWith("refs/tags/") || looksLikeReleaseVersionRef(trimmed); +} + +export function resolveRequestedSuites(mode, ref) { + if (!SUPPORTED_MODES.has(mode)) { + throw new Error(`Unsupported mode "${mode}".`); + } + const suites = []; + if (mode === "fresh" || mode === "both") { + suites.push("packaged-fresh", "installer-fresh"); + } + if (mode === "upgrade" || mode === "both") { + suites.push("packaged-upgrade"); + if (shouldRunMainChannelDevUpdate(ref)) { + suites.push("dev-update"); + } + } + return suites; +} + +export function resolveRunnerMatrix(params) { + const pick = (...values) => + values.find((value) => typeof value === "string" && value.trim().length > 0)?.trim(); + const suites = resolveRequestedSuites(params.mode, params.ref); + const runners = [ + { + os_id: "ubuntu", + display_name: "Linux", + runner: pick(params.ubuntuRunner, params.varUbuntuRunner, "ubuntu-latest"), + artifact_name: "linux", + }, + { + os_id: "windows", + display_name: "Windows", + runner: pick(params.windowsRunner, params.varWindowsRunner, "blacksmith-32vcpu-windows-2025"), + artifact_name: "windows", + }, + { + os_id: "macos", + display_name: "macOS", + runner: pick(params.macosRunner, params.varMacosRunner, "macos-latest-xlarge"), + artifact_name: "macos", + }, + ]; + return { + include: runners.flatMap((runner) => + suites.map((suite) => ({ + ...runner, + suite, + suite_label: formatSuiteLabel(suite), + lane: suite.includes("upgrade") || suite === "dev-update" ? "upgrade" : "fresh", + })), + ), + }; +} + +export function readRunnerOverrideEnv(env = process.env) { + const preferNonEmptyEnv = (primary: string | undefined, legacy: string | undefined) => { + const primaryValue = primary?.trim(); + if (primaryValue) { + return primaryValue; + } + const legacyValue = legacy?.trim(); + return legacyValue || ""; + }; + + return { + varUbuntuRunner: preferNonEmptyEnv( + env.VAR_UBUNTU_RUNNER, + env.OPENCLAW_RELEASE_CHECKS_UBUNTU_RUNNER, + ), + varWindowsRunner: preferNonEmptyEnv( + env.VAR_WINDOWS_RUNNER, + env.OPENCLAW_RELEASE_CHECKS_WINDOWS_RUNNER, + ), + varMacosRunner: preferNonEmptyEnv( + env.VAR_MACOS_RUNNER, + env.OPENCLAW_RELEASE_CHECKS_MACOS_RUNNER, + ), + }; +} + +function formatSuiteLabel(suite) { + if (suite === "packaged-fresh") { + return "packaged fresh"; + } + if (suite === "installer-fresh") { + return "installer fresh"; + } + if (suite === "packaged-upgrade") { + return "packaged upgrade"; + } + return "dev update"; +} + +async function main(argv) { + const args = parseArgs(argv); + + if (args["resolve-matrix"] === "true") { + const mode = args["mode"] ?? "both"; + const ref = args["ref"]?.trim() || "main"; + const runnerOverrideEnv = readRunnerOverrideEnv(process.env); + process.stdout.write( + `${JSON.stringify( + resolveRunnerMatrix({ + mode, + ref, + ubuntuRunner: args["ubuntu-runner"], + windowsRunner: args["windows-runner"], + macosRunner: args["macos-runner"], + ...runnerOverrideEnv, + }), + )}\n`, + ); + return; + } + + const outputDir = resolve(requireArg(args, "output-dir")); + const prepareOnly = args["prepare-only"] === "true"; + const sourceDir = args["source-dir"]?.trim() ? resolve(args["source-dir"].trim()) : ""; + const provider = args["provider"]?.trim() || ""; + const suite = args["suite"]?.trim() || ""; + const mode = args["mode"] ?? "both"; + const inputRef = args["ref"]?.trim() || ""; + const previousVersion = args["previous-version"]?.trim() || ""; + const baselineSpec = + args["baseline-spec"]?.trim() || + (previousVersion ? `openclaw@${previousVersion}` : "openclaw@latest"); + const providedBaselineTgz = args["baseline-tgz"]?.trim() + ? resolve(args["baseline-tgz"].trim()) + : ""; + const providedCandidateTgz = args["candidate-tgz"]?.trim() + ? resolve(args["candidate-tgz"].trim()) + : ""; + const providedCandidateVersion = args["candidate-version"]?.trim() || ""; + const providedSourceSha = args["source-sha"]?.trim() || ""; + const runDiscordRoundtrip = args["run-discord-roundtrip"] === "true"; + + mkdirSync(outputDir, { recursive: true }); + const logsDir = join(outputDir, "logs"); + mkdirSync(logsDir, { recursive: true }); + + if (prepareOnly) { + if (!sourceDir) { + throw new Error("--prepare-only requires --source-dir."); + } + const build = await prepareCandidate({ + outputDir, + sourceDir, + logsDir, + }); + writeCandidateManifest(outputDir, build); + return; + } + + if (!SUPPORTED_SUITES.has(suite)) { + throw new Error(`Unsupported suite "${suite}".`); + } + if (!Object.hasOwn(providerConfig, provider)) { + throw new Error(`Unsupported provider "${provider}".`); + } + + const selectedProvider = providerConfig[provider]; + const providerSecretValue = process.env[selectedProvider.secretEnv]?.trim(); + if (!providerSecretValue) { + throw new Error(`Missing ${selectedProvider.secretEnv}.`); + } + + const summary = { + platform: process.platform, + runnerOs: process.env.OPENCLAW_RELEASE_CHECK_OS ?? "", + runnerLabel: process.env.OPENCLAW_RELEASE_CHECK_RUNNER ?? "", + provider, + mode, + suite, + ref: inputRef || null, + previousVersion: previousVersion || null, + sourceDir, + sourceSha: "", + candidateVersion: "", + candidateTgz: "", + baselineSpec, + result: { + status: "pending", + }, + discordRoundtrip: runDiscordRoundtrip, + }; + + let build; + try { + build = sourceDir + ? await prepareCandidate({ + outputDir, + sourceDir, + logsDir, + }) + : readProvidedCandidate({ + candidateTgz: providedCandidateTgz, + candidateVersion: providedCandidateVersion, + sourceSha: providedSourceSha, + }); + summary.sourceSha = build.sourceSha; + summary.candidateVersion = build.candidateVersion; + summary.candidateTgz = build.candidateTgz; + + if (suite === "packaged-fresh") { + summary.result = await runFreshLane({ + build, + logsDir, + providerConfig: selectedProvider, + providerSecretValue, + }); + } else if (suite === "packaged-upgrade") { + const tgzServer = await startStaticFileServer({ + filePath: build.candidateTgz, + logPath: join(logsDir, "candidate-http-server.log"), + }); + try { + summary.result = await runUpgradeLane({ + baselineSpec, + baselineTgz: providedBaselineTgz, + build, + candidateUrl: tgzServer.url, + logsDir, + providerConfig: selectedProvider, + providerSecretValue, + }); + } finally { + await tgzServer.close(); + } + } else if (suite === "installer-fresh") { + summary.result = await runInstallerFreshSuite({ + build, + logsDir, + providerConfig: selectedProvider, + providerSecretValue, + runDiscordRoundtrip, + }); + } else { + summary.result = await runDevUpdateSuite({ + baselineSpec, + logsDir, + providerConfig: selectedProvider, + providerSecretValue, + ref: inputRef || "main", + sourceSha: build.sourceSha, + runDiscordRoundtrip, + }); + } + } catch (error) { + summary.result = { + status: "fail", + error: formatError(error), + }; + } + + writeSummary(outputDir, summary); + + if (summary.result.status !== "pass") { + process.exit(1); + } +} + +async function prepareCandidate(params) { + logPhase("prepare", "resolve-source-sha"); + const packageJson = readPackageJson(params.sourceDir); + const hasUiBuildScript = packageJsonHasScript(packageJson, "ui:build"); + const sourceSha = ( + await runCommand(gitCommand(), ["rev-parse", "HEAD"], { + cwd: params.sourceDir, + logPath: join(params.logsDir, "git-rev-parse.log"), + }) + ).stdout.trim(); + + const buildEnv = { + ...process.env, + NODE_OPTIONS: "--max-old-space-size=6144", + }; + + logPhase("prepare", "pnpm-install"); + await runCommand(pnpmCommand(), ["install", "--frozen-lockfile"], { + cwd: params.sourceDir, + env: buildEnv, + logPath: join(params.logsDir, "pnpm-install.log"), + timeoutMs: 45 * 60 * 1000, + }); + + logPhase("prepare", "pnpm-build"); + await runCommand(pnpmCommand(), ["build"], { + cwd: params.sourceDir, + env: buildEnv, + logPath: join(params.logsDir, "pnpm-build.log"), + timeoutMs: 45 * 60 * 1000, + }); + + if (hasUiBuildScript) { + // pnpm build does not regenerate dist/control-ui, and checked-in bundles can + // otherwise leak into npm pack when a ref changes UI assets. + logPhase("prepare", "pnpm-ui-build"); + await runCommand(pnpmCommand(), ["ui:build"], { + cwd: params.sourceDir, + env: buildEnv, + logPath: join(params.logsDir, "pnpm-ui-build.log"), + timeoutMs: 30 * 60 * 1000, + }); + } + + const packDir = join(params.outputDir, "package"); + mkdirSync(packDir, { recursive: true }); + const packJsonPath = join(packDir, "pack.json"); + logPhase("prepare", "package-dist-inventory"); + await writePackageDistInventoryForCandidate({ + sourceDir: params.sourceDir, + logPath: join(params.logsDir, "npm-pack-dry-run.log"), + }); + logPhase("prepare", "npm-pack"); + const packResult = await runCommand( + npmCommand(), + ["pack", "--ignore-scripts", "--json", "--pack-destination", packDir], + { + cwd: params.sourceDir, + logPath: join(params.logsDir, "npm-pack.log"), + timeoutMs: 10 * 60 * 1000, + }, + ); + writeFileSync(packJsonPath, packResult.stdout, "utf8"); + const parsedPack = JSON.parse(packResult.stdout); + const lastPack = Array.isArray(parsedPack) ? parsedPack.at(-1) : null; + if (!lastPack?.filename) { + throw new Error("npm pack did not report a filename."); + } + + return { + sourceDir: params.sourceDir, + sourceSha, + candidateVersion: String(lastPack.version ?? packageJson.version ?? "").trim(), + candidateTgz: join(packDir, lastPack.filename), + candidateFileName: String(lastPack.filename).trim(), + }; +} + +function normalizeRelativePath(value) { + return value.replace(/\\/gu, "/"); +} + +function isPackagedDistPath(relativePath) { + if (!relativePath.startsWith("dist/")) { + return false; + } + if (relativePath === PACKAGE_DIST_INVENTORY_RELATIVE_PATH) { + return false; + } + if (relativePath.endsWith(".map")) { + return false; + } + if (relativePath === "dist/plugin-sdk/.tsbuildinfo") { + return false; + } + if (OMITTED_QA_EXTENSION_PREFIXES.some((prefix) => relativePath.startsWith(prefix))) { + return PACKAGED_QA_RUNTIME_PATHS.has(relativePath); + } + return true; +} + +async function writePackageDistInventoryForCandidate(params) { + const dryRun = await runCommand( + npmCommand(), + ["pack", "--dry-run", "--ignore-scripts", "--json"], + { + cwd: params.sourceDir, + logPath: params.logPath, + timeoutMs: 5 * 60 * 1000, + }, + ); + const parsedPack = JSON.parse(dryRun.stdout); + const lastPack = Array.isArray(parsedPack) ? parsedPack.at(-1) : null; + const files = Array.isArray(lastPack?.files) ? lastPack.files : []; + if (files.length === 0) { + throw new Error( + "npm pack --dry-run did not report package files for dist inventory generation.", + ); + } + const inventory = files + .flatMap((entry) => { + const relativePath = normalizeRelativePath(String(entry?.path ?? "").trim()); + return isPackagedDistPath(relativePath) ? [relativePath] : []; + }) + .toSorted((left, right) => left.localeCompare(right)); + const inventoryPath = join(params.sourceDir, PACKAGE_DIST_INVENTORY_RELATIVE_PATH); + mkdirSync(dirname(inventoryPath), { recursive: true }); + writeFileSync(inventoryPath, `${JSON.stringify(inventory, null, 2)}\n`, "utf8"); +} + +function readProvidedCandidate(params) { + if (!params.candidateTgz) { + throw new Error("Missing required --candidate-tgz argument when --source-dir is not provided."); + } + if (!existsSync(params.candidateTgz)) { + throw new Error(`Candidate package not found: ${params.candidateTgz}`); + } + if (!params.candidateVersion) { + throw new Error( + "Missing required --candidate-version argument when --source-dir is not provided.", + ); + } + if (!params.sourceSha) { + throw new Error("Missing required --source-sha argument when --source-dir is not provided."); + } + return { + sourceDir: "", + sourceSha: params.sourceSha, + candidateVersion: params.candidateVersion, + candidateTgz: params.candidateTgz, + candidateFileName: params.candidateTgz.split(/[/\\]/u).at(-1) ?? "", + }; +} + +async function runFreshLane(params) { + const lane = createLaneState("fresh"); + const cleanup = []; + try { + const env = buildLaneEnv(lane, params.providerConfig, params.providerSecretValue); + logLanePhase(lane, "install-candidate"); + await installTarballPackage({ + lane, + env, + tgzPath: params.build.candidateTgz, + logPath: join(params.logsDir, "fresh-install.log"), + restoreBundledPluginRuntimeDeps: false, + }); + const installed = readInstalledMetadata(lane.prefixDir); + verifyInstalledCandidate(installed, params.build); + logLanePhase(lane, "restore-bundled-plugin-runtime-deps"); + await runBundledPluginPostinstall({ + lane, + env, + logPath: join(params.logsDir, "fresh-install.log"), + }); + + logLanePhase(lane, "onboard"); + await runOnboard({ + lane, + env, + providerConfig: params.providerConfig, + logPath: join(params.logsDir, "fresh-onboard.log"), + }); + + logLanePhase(lane, "start-gateway"); + const gateway = await startGateway({ + lane, + env, + logPath: join(params.logsDir, "fresh-gateway.log"), + }); + cleanup.push(() => stopGateway(gateway)); + + logLanePhase(lane, "wait-gateway"); + await waitForGateway({ + lane, + env, + logPath: join(params.logsDir, "fresh-gateway-status.log"), + }); + + logLanePhase(lane, "dashboard"); + await runDashboardSmoke({ + lane, + logPath: join(params.logsDir, "fresh-dashboard.log"), + }); + + logLanePhase(lane, "models-set"); + await runModelsSet({ + lane, + env, + providerConfig: params.providerConfig, + logPath: join(params.logsDir, "fresh-models-set.log"), + }); + + logLanePhase(lane, "agent-turn"); + const agent = await runAgentTurn({ + lane, + env, + label: "fresh", + logPath: join(params.logsDir, "fresh-agent.log"), + }); + + return { + status: "pass", + installedVersion: installed.version, + installedCommit: installed.commit, + dashboardStatus: "pass", + gatewayPort: lane.gatewayPort, + agentOutput: trimForSummary(agent.stdout), + }; + } finally { + await runCleanup(cleanup); + } +} + +async function runUpgradeLane(params) { + if (!params.baselineTgz && !params.baselineSpec) { + throw new Error("Missing required --baseline-tgz argument for upgrade mode."); + } + if (!params.candidateUrl) { + throw new Error("Missing candidate package URL for upgrade mode."); + } + const lane = createLaneState("upgrade"); + const cleanup = []; + try { + const env = buildLaneEnv(lane, params.providerConfig, params.providerSecretValue); + logLanePhase(lane, "install-baseline"); + if (!params.baselineTgz && params.baselineSpec) { + await installPackageSpec({ + lane, + env, + packageSpec: params.baselineSpec, + logPath: join(params.logsDir, "upgrade-install-baseline.log"), + }); + } else { + await installTarballPackage({ + lane, + env, + tgzPath: params.baselineTgz, + logPath: join(params.logsDir, "upgrade-install-baseline.log"), + restoreBundledPluginRuntimeDeps: false, + }); + } + logLanePhase(lane, "restore-baseline-bundled-plugin-runtime-deps"); + await runBundledPluginPostinstall({ + lane, + env, + logPath: join(params.logsDir, "upgrade-install-baseline.log"), + }); + + const baseline = { + version: readInstalledVersion(lane.prefixDir), + }; + + logLanePhase(lane, "update"); + const updateEnv = buildRealUpdateEnv(env); + const updateArgs = [ + "update", + "--tag", + params.candidateUrl, + "--yes", + "--json", + "--timeout", + String(updateStepTimeoutSeconds()), + ]; + await runOpenClaw({ + lane, + env: updateEnv, + args: updateArgs, + logPath: join(params.logsDir, "upgrade-update.log"), + timeoutMs: updateTimeoutMs(), + }); + + logLanePhase(lane, "update-status"); + await runOpenClaw({ + lane, + env, + args: ["update", "status", "--json"], + logPath: join(params.logsDir, "upgrade-update-status.log"), + timeoutMs: 2 * 60 * 1000, + }); + logLanePhase(lane, "restore-bundled-plugin-runtime-deps"); + await runBundledPluginPostinstall({ + lane, + env, + logPath: join(params.logsDir, "upgrade-bundled-plugin-postinstall.log"), + }); + + const installed = readInstalledMetadata(lane.prefixDir); + verifyInstalledCandidate(installed, params.build); + + logLanePhase(lane, "onboard"); + await runOnboard({ + lane, + env, + providerConfig: params.providerConfig, + logPath: join(params.logsDir, "upgrade-onboard.log"), + }); + + logLanePhase(lane, "start-gateway"); + const gateway = await startGateway({ + lane, + env, + logPath: join(params.logsDir, "upgrade-gateway.log"), + }); + cleanup.push(() => stopGateway(gateway)); + + logLanePhase(lane, "wait-gateway"); + await waitForGateway({ + lane, + env, + logPath: join(params.logsDir, "upgrade-gateway-status.log"), + }); + + logLanePhase(lane, "dashboard"); + await runDashboardSmoke({ + lane, + logPath: join(params.logsDir, "upgrade-dashboard.log"), + }); + + logLanePhase(lane, "models-set"); + await runModelsSet({ + lane, + env, + providerConfig: params.providerConfig, + logPath: join(params.logsDir, "upgrade-models-set.log"), + }); + + logLanePhase(lane, "agent-turn"); + const agent = await runAgentTurn({ + lane, + env, + label: "upgrade", + logPath: join(params.logsDir, "upgrade-agent.log"), + }); + + return { + status: "pass", + baselineVersion: baseline.version, + installedVersion: installed.version, + installedCommit: installed.commit, + dashboardStatus: "pass", + gatewayPort: lane.gatewayPort, + agentOutput: trimForSummary(agent.stdout), + }; + } finally { + await runCleanup(cleanup); + } +} + +async function runInstallerFreshSuite(params) { + const lane = createLaneState("installer-fresh"); + const cleanup = []; + const usesManagedGateway = shouldUseManagedGatewayService(); + const useManagedGatewayAfterInstall = shouldUseManagedGatewayForInstallerRuntime(); + const manualGateway = { current: null }; + try { + const env = buildInstallerEnv(lane, params.providerConfig, params.providerSecretValue); + // Drive the public installer against the exact candidate artifact built from the requested ref. + const candidateServer = await startStaticFileServer({ + filePath: params.build.candidateTgz, + logPath: join(params.logsDir, "installer-candidate-http-server.log"), + }); + cleanup.push(() => candidateServer.close()); + const installTarget = candidateServer.url; + const installerUrl = resolvePublishedInstallerUrl(); + + logLanePhase(lane, "installer-run"); + await runInstallerSmoke({ + lane, + env, + installerUrl, + installTarget, + logPath: join(params.logsDir, "installer-fresh-install.log"), + }); + + logLanePhase(lane, "fresh-shell"); + const freshShell = await verifyFreshShellCommand({ + lane, + env, + expectedNeedle: params.build.candidateVersion, + logPath: join(params.logsDir, "installer-fresh-shell.log"), + }); + const installed = readInstalledMetadataFromCliPath(freshShell.cliPath); + verifyInstalledCandidate(installed, params.build); + + logLanePhase(lane, "onboard"); + await runOnboardWithInstalledCli({ + lane, + cliPath: freshShell.cliPath, + env, + providerConfig: params.providerConfig, + installDaemon: usesManagedGateway, + logPath: join(params.logsDir, "installer-fresh-onboard.log"), + }); + + if (shouldExerciseManagedGatewayLifecycleAfterInstall()) { + await exerciseManagedGatewayLifecycle({ + lane, + cliPath: freshShell.cliPath, + env, + logPrefix: join(params.logsDir, "installer-fresh-gateway"), + }); + } + + if (!useManagedGatewayAfterInstall) { + // Keep the Windows installer lane validating Scheduled Task registration during + // onboarding and lifecycle commands, but use a manual gateway for the runtime + // checks after that so the installer validation does not depend on the more + // failure-prone managed Windows session state for the remainder of the lane. + if (shouldStopManagedGatewayBeforeManualFallback()) { + logLanePhase(lane, "gateway-stop-managed"); + await runInstalledCli({ + cliPath: freshShell.cliPath, + args: ["gateway", "stop"], + env, + cwd: lane.homeDir, + logPath: join(params.logsDir, "installer-fresh-gateway-stop-managed.log"), + timeoutMs: 2 * 60 * 1000, + check: false, + }); + await waitForInstalledGatewayToStop({ + lane, + cliPath: freshShell.cliPath, + env, + logPath: join(params.logsDir, "installer-fresh-gateway-stop-managed-status.log"), + }); + } + logLanePhase(lane, "gateway-start"); + const gateway = await startManualGatewayFromInstalledCli({ + lane, + cliPath: freshShell.cliPath, + env, + logPath: join(params.logsDir, "installer-fresh-gateway.log"), + }); + manualGateway.current = gateway; + cleanup.push(() => stopGateway(manualGateway.current)); + logLanePhase(lane, "gateway-status"); + await waitForInstalledGateway({ + lane, + cliPath: freshShell.cliPath, + env, + logPath: join(params.logsDir, "installer-fresh-gateway-status.log"), + }); + } + + logLanePhase(lane, "dashboard"); + await runDashboardSmoke({ + lane, + logPath: join(params.logsDir, "installer-fresh-dashboard.log"), + }); + + logLanePhase(lane, "models-set"); + await runInstalledModelsSet({ + cliPath: freshShell.cliPath, + env, + providerConfig: params.providerConfig, + cwd: lane.homeDir, + logPath: join(params.logsDir, "installer-fresh-models-set.log"), + }); + + logLanePhase(lane, "agent-turn"); + const agent = await runInstalledAgentTurn({ + cliPath: freshShell.cliPath, + env, + cwd: lane.homeDir, + label: "installer-fresh", + logPath: join(params.logsDir, "installer-fresh-agent.log"), + }); + + let discordStatus = "skipped"; + if (params.runDiscordRoundtrip && process.platform === "darwin") { + logLanePhase(lane, "discord-roundtrip"); + discordStatus = await maybeRunDiscordRoundtrip({ + lane, + cliPath: freshShell.cliPath, + env, + gatewayHolder: manualGateway, + logPath: join(params.logsDir, "installer-fresh-discord.log"), + }); + } + + return { + status: "pass", + installTarget, + installVersion: installed.version, + cliPath: freshShell.cliPath, + installedVersion: installed.version, + installedCommit: installed.commit, + gatewayPort: lane.gatewayPort, + dashboardStatus: "pass", + discordStatus, + agentOutput: trimForSummary(agent.stdout), + }; + } finally { + await runCleanup(cleanup); + } +} + +async function runDevUpdateSuite(params) { + const lane = createLaneState("dev-update"); + const cleanup = []; + const installTarget = await resolveInstallerTargetVersion({ + baselineSpec: params.baselineSpec, + logsDir: params.logsDir, + suiteName: "dev-update", + }); + const usesManagedGateway = shouldUseManagedGatewayService(); + // Keep dev-update on a manual gateway even on Windows. The packaged lanes + // already cover the Scheduled Task path, while repaired git installs live in + // an ephemeral checkout that has proven flaky as a managed service in CI. + const useManagedGatewayAfterDevUpdate = usesManagedGateway && process.platform !== "win32"; + const requestedRef = resolveExpectedDevUpdateRef(params.ref); + if (!shouldRunMainChannelDevUpdate(requestedRef)) { + throw new Error( + `The dev-update suite only supports main. Received ${normalizeRequestedRef(params.ref) || ""}.`, + ); + } + const verificationRef = resolveDevUpdateVerificationRef(params.ref, params.sourceSha); + const manualGateway = { current: null }; + try { + const env = buildInstallerEnv(lane, params.providerConfig, params.providerSecretValue); + const installerUrl = resolvePublishedInstallerUrl(); + + logLanePhase(lane, "installer-baseline"); + await runInstallerSmoke({ + lane, + env, + installerUrl, + installTarget, + logPath: join(params.logsDir, "dev-update-install.log"), + }); + + logLanePhase(lane, "fresh-shell-baseline"); + const baselineShell = await verifyFreshShellCommand({ + lane, + env, + expectedNeedle: installTarget, + logPath: join(params.logsDir, "dev-update-baseline-shell.log"), + }); + + logLanePhase(lane, "update-dev"); + await runInstalledCli({ + cliPath: baselineShell.cliPath, + args: ["update", "--channel", "dev", "--yes", "--json"], + env: { + ...buildRealUpdateEnv(env), + OPENCLAW_UPDATE_DEV_TARGET_REF: verificationRef, + }, + cwd: lane.homeDir, + logPath: join(params.logsDir, "dev-update.log"), + timeoutMs: updateTimeoutMs(), + }); + + logLanePhase(lane, "fresh-shell-updated"); + const updatedShell = await verifyFreshShellCommand({ + lane, + env, + expectedNeedle: "OpenClaw", + logPath: join(params.logsDir, "dev-update-shell.log"), + }); + + logLanePhase(lane, "update-status"); + const verifiedShell = await ensureDevUpdateGitInstall({ + lane, + env, + cliPath: updatedShell.cliPath, + logsDir: params.logsDir, + requestedRef: verificationRef, + }); + + if (process.platform === "win32") { + logLanePhase(lane, "windows-toolchain"); + await verifyWindowsDevUpdateToolchain({ + lane, + env, + logPath: join(params.logsDir, "dev-update-windows-toolchain.log"), + }); + } + + logLanePhase(lane, "onboard"); + await runOnboardWithInstalledCli({ + lane, + cliPath: verifiedShell.cliPath, + env, + providerConfig: params.providerConfig, + installDaemon: useManagedGatewayAfterDevUpdate, + logPath: join(params.logsDir, "dev-update-onboard.log"), + }); + + if (!useManagedGatewayAfterDevUpdate) { + logLanePhase(lane, "gateway-start"); + const gateway = await startManualGatewayFromInstalledCli({ + lane, + cliPath: verifiedShell.cliPath, + env, + logPath: join(params.logsDir, "dev-update-gateway.log"), + }); + manualGateway.current = gateway; + cleanup.push(() => stopGateway(manualGateway.current)); + logLanePhase(lane, "gateway-status"); + await waitForInstalledGateway({ + lane, + cliPath: verifiedShell.cliPath, + env, + logPath: join(params.logsDir, "dev-update-gateway-status.log"), + }); + } else { + logLanePhase(lane, "gateway-ready"); + await ensureManagedGatewayReady({ + lane, + cliPath: verifiedShell.cliPath, + env, + logPath: join(params.logsDir, "dev-update-gateway-ready.log"), + }); + } + + logLanePhase(lane, "dashboard"); + await runDashboardSmoke({ + lane, + logPath: join(params.logsDir, "dev-update-dashboard.log"), + }); + + logLanePhase(lane, "models-set"); + await runInstalledModelsSet({ + cliPath: verifiedShell.cliPath, + env, + providerConfig: params.providerConfig, + cwd: lane.homeDir, + logPath: join(params.logsDir, "dev-update-models-set.log"), + }); + + logLanePhase(lane, "agent-turn"); + const agent = await runInstalledAgentTurn({ + cliPath: verifiedShell.cliPath, + env, + cwd: lane.homeDir, + label: "dev-update", + logPath: join(params.logsDir, "dev-update-agent.log"), + }); + + let discordStatus = "skipped"; + if (params.runDiscordRoundtrip && process.platform === "darwin") { + logLanePhase(lane, "discord-roundtrip"); + discordStatus = await maybeRunDiscordRoundtrip({ + lane, + cliPath: verifiedShell.cliPath, + env, + gatewayHolder: manualGateway, + logPath: join(params.logsDir, "dev-update-discord.log"), + }); + } + + return { + status: "pass", + installVersion: installTarget, + cliPath: updatedShell.cliPath, + gatewayPort: lane.gatewayPort, + dashboardStatus: "pass", + discordStatus, + agentOutput: trimForSummary(agent.stdout), + }; + } finally { + await runCleanup(cleanup); + } +} + +function createLaneState(name) { + const rootDir = mkdtempSync(join(tmpdir(), `openclaw-${name}-`)); + const prefixDir = join(rootDir, "prefix"); + const homeDir = join(rootDir, "home"); + const stateDir = join(homeDir, ".openclaw"); + const appDataDir = process.platform === "win32" ? join(homeDir, "AppData", "Roaming") : stateDir; + mkdirSync(prefixDir, { recursive: true }); + mkdirSync(homeDir, { recursive: true }); + mkdirSync(stateDir, { recursive: true }); + mkdirSync(appDataDir, { recursive: true }); + if (process.platform !== "win32") { + writeFileSync(join(homeDir, ".bashrc"), "", "utf8"); + writeFileSync(join(homeDir, ".zshrc"), "", "utf8"); + } + return { + name, + rootDir, + prefixDir, + homeDir, + stateDir, + appDataDir, + gatewayPort: 0, + }; +} + +function buildLaneEnv(lane, providerMeta, providerSecretValue) { + ensureLocalNpmShim(lane); + return { + ...process.env, + HOME: lane.homeDir, + USERPROFILE: lane.homeDir, + APPDATA: lane.appDataDir, + LOCALAPPDATA: join(lane.homeDir, "AppData", "Local"), + OPENCLAW_HOME: lane.homeDir, + OPENCLAW_STATE_DIR: lane.stateDir, + OPENCLAW_CONFIG_PATH: join(lane.stateDir, "openclaw.json"), + OPENCLAW_DISABLE_BONJOUR: "1", + OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL: "1", + NPM_CONFIG_PREFIX: lane.prefixDir, + PATH: `${binDirForPrefix(lane.prefixDir)}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`, + [providerMeta.secretEnv]: providerSecretValue, + }; +} + +function buildInstallerEnv(lane, providerMeta, providerSecretValue) { + const localAppData = join(lane.homeDir, "AppData", "Local"); + mkdirSync(localAppData, { recursive: true }); + return { + ...process.env, + HOME: lane.homeDir, + USERPROFILE: lane.homeDir, + APPDATA: lane.appDataDir, + LOCALAPPDATA: localAppData, + OPENCLAW_HOME: lane.homeDir, + OPENCLAW_STATE_DIR: lane.stateDir, + OPENCLAW_CONFIG_PATH: join(lane.stateDir, "openclaw.json"), + OPENCLAW_DISABLE_BONJOUR: "1", + OPENCLAW_NO_ONBOARD: "1", + OPENCLAW_NO_PROMPT: "1", + CI: "1", + NODE_OPTIONS: "--max-old-space-size=6144", + [providerMeta.secretEnv]: providerSecretValue, + }; +} + +export function shouldUseManagedGatewayService(platform = process.platform) { + return platform === "win32"; +} + +export function shouldUseManagedGatewayForInstallerRuntime(platform = process.platform) { + return shouldUseManagedGatewayService(platform) && platform !== "win32"; +} + +export function shouldExerciseManagedGatewayLifecycleAfterInstall(platform = process.platform) { + return shouldUseManagedGatewayService(platform); +} + +export function shouldStopManagedGatewayBeforeManualFallback(platform = process.platform) { + return shouldUseManagedGatewayService(platform); +} + +function shouldRestoreBundledPluginRuntimeDeps() { + return true; +} + +function looksLikeCommitSha(ref) { + return /^[0-9a-f]{7,40}$/iu.test(ref.trim()); +} + +function resolveExpectedDevUpdateRef(ref) { + const trimmed = normalizeRequestedRef(ref) || "main"; + return trimmed || "main"; +} + +export function resolveDevUpdateVerificationRef(ref, sourceSha) { + if (resolveExpectedDevUpdateRef(ref) === "main" && looksLikeCommitSha(sourceSha ?? "")) { + return sourceSha.trim(); + } + return resolveExpectedDevUpdateRef(ref); +} + +export function shouldRunMainChannelDevUpdate(ref) { + if (isImmutableReleaseRef(ref)) { + return false; + } + return resolveExpectedDevUpdateRef(ref) === "main"; +} + +export function shouldSkipInstallerDaemonHealthCheck(platform = process.platform) { + return platform === "win32"; +} + +export function buildRealUpdateEnv(env) { + const updateEnv = { ...env }; + delete updateEnv.OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL; + return updateEnv; +} + +export function resolveExplicitBaselineVersion(baselineSpec) { + const trimmed = baselineSpec.trim(); + if (!trimmed || trimmed === "openclaw@latest") { + return ""; + } + if (trimmed.startsWith("openclaw@")) { + return trimmed.slice("openclaw@".length); + } + return trimmed; +} + +async function resolveInstallerTargetVersion(params) { + const resolvedVersion = resolveExplicitBaselineVersion(params.baselineSpec); + if (resolvedVersion) { + return resolvedVersion; + } + const latestResult = await runCommand(npmCommand(), ["view", "openclaw@latest", "version"], { + logPath: join(params.logsDir, `${params.suiteName}-latest-version.log`), + timeoutMs: 2 * 60 * 1000, + }); + const latestVersion = latestResult.stdout.trim(); + if (!latestVersion) { + throw new Error("npm view openclaw@latest version did not return a version."); + } + return latestVersion; +} + +function powerShellSingleQuote(value) { + return value.replace(/'/gu, "''"); +} + +function readPackageJson(packageRoot) { + return JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")); +} + +function packageJsonHasScript(packageJson, scriptName) { + return typeof packageJson?.scripts?.[scriptName] === "string"; +} + +export function packageHasScript(packageRoot, scriptName) { + try { + return packageJsonHasScript(readPackageJson(packageRoot), scriptName); + } catch { + return false; + } +} + +function parseMarkerLine(output, marker) { + return `${output}` + .split(/\r?\n/gu) + .find((line) => line.startsWith(marker)) + ?.slice(marker.length) + .trim(); +} + +export function normalizeWindowsInstalledCliPath(cliPath) { + return normalizeWindowsCommandShimPath(cliPath); +} + +export function normalizeWindowsCommandShimPath(commandPath) { + if (typeof commandPath !== "string") { + return commandPath; + } + return commandPath.replace(/\.ps1$/iu, ".cmd"); +} + +export function resolveInstalledPrefixDirFromCliPath(cliPath, platform = process.platform) { + const resolvedCliPath = + platform === "win32" ? normalizeWindowsInstalledCliPath(cliPath) : String(cliPath ?? ""); + if (!resolvedCliPath?.trim()) { + throw new Error("Missing installed CLI path."); + } + if (platform === "win32") { + return pathWin32.dirname(resolvedCliPath); + } + return dirname(dirname(resolvedCliPath)); +} + +function readInstalledMetadataFromCliPath(cliPath, platform = process.platform) { + return readInstalledMetadata(resolveInstalledPrefixDirFromCliPath(cliPath, platform)); +} + +function resolveInstalledCliInvocation(cliPath, platform = process.platform) { + if (platform !== "win32") { + return { command: cliPath, argsPrefix: [], shell: false }; + } + const normalizedCliPath = normalizeWindowsInstalledCliPath(cliPath); + if (!/\.cmd$/iu.test(normalizedCliPath)) { + return { command: normalizedCliPath, argsPrefix: [], shell: false }; + } + const entryPath = installedEntryPath( + resolveInstalledPrefixDirFromCliPath(normalizedCliPath, platform), + ); + if (existsSync(entryPath)) { + return { + command: process.execPath, + argsPrefix: [entryPath], + shell: false, + }; + } + return { command: normalizedCliPath, argsPrefix: [], shell: true }; +} + +async function runPosixShellScript(script, options) { + return runCommand("/bin/bash", ["-lc", script], options); +} + +async function runPowerShellScript(script, options) { + return runCommand( + "powershell.exe", + ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], + options, + ); +} + +async function runInstallerSmoke(params) { + if (process.platform === "win32") { + const script = ` +$response = Invoke-WebRequest -UseBasicParsing '${powerShellSingleQuote(params.installerUrl)}' +$content = $response.Content +if ($content -is [byte[]]) { + $content = [System.Text.Encoding]::UTF8.GetString($content) +} +& ([scriptblock]::Create([string]$content)) -Tag '${powerShellSingleQuote(params.installTarget)}' -NoOnboard +`; + await runPowerShellScript(script, { + cwd: params.lane.homeDir, + env: params.env, + logPath: params.logPath, + timeoutMs: installTimeoutMs(), + }); + return; + } + + const script = [ + "set -euo pipefail", + `curl -fsSL '${shellEscapeForSh(params.installerUrl)}' | bash -s -- --version '${shellEscapeForSh(params.installTarget)}' --no-onboard`, + ].join("\n"); + await runPosixShellScript(script, { + cwd: params.lane.homeDir, + env: params.env, + logPath: params.logPath, + timeoutMs: installTimeoutMs(), + }); +} + +export function buildWindowsPathBootstrapScript(options = {}) { + const includeCurrentProcessPath = options.includeCurrentProcessPath !== false; + const pathCandidates = includeCurrentProcessPath + ? "@($userPath, $machinePath, $env:Path)" + : "@($userPath, $machinePath)"; + return ` +$machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') +$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') +$segments = New-Object System.Collections.Generic.List[string] +foreach ($candidate in ${pathCandidates}) { + foreach ($segment in ($candidate -split ';')) { + if ([string]::IsNullOrWhiteSpace($segment)) { + continue + } + if (-not $segments.Contains($segment)) { + $segments.Add($segment) + } + } +} +$env:Path = [string]::Join(';', $segments) +`.trim(); +} + +export function buildWindowsFreshShellVersionCheckScript(params = {}) { + const expectedNeedle = powerShellSingleQuote(params.expectedNeedle ?? ""); + return ` +${buildWindowsPathBootstrapScript()} +$commandPath = $null +$npmCommand = Get-Command npm.cmd -ErrorAction SilentlyContinue +if ($null -eq $npmCommand) { + $npmCommand = Get-Command npm -ErrorAction SilentlyContinue +} +if ($null -ne $npmCommand) { + $npmPrefix = (& $npmCommand.Source config get prefix 2>$null | Out-String).Trim() + if (-not [string]::IsNullOrWhiteSpace($npmPrefix)) { + $env:Path = "$npmPrefix;$env:Path" + foreach ($candidate in @( + (Join-Path $npmPrefix 'openclaw.cmd'), + (Join-Path $npmPrefix 'openclaw.ps1') + )) { + if (Test-Path -LiteralPath $candidate) { + $commandPath = $candidate + break + } + } + } +} +if ([string]::IsNullOrWhiteSpace($commandPath)) { + $cmd = Get-Command openclaw -ErrorAction Stop + $commandPath = $cmd.Source +} +if ($commandPath -match '(?i)\\.ps1$') { + $cmdPath = [System.IO.Path]::ChangeExtension($commandPath, '.cmd') + if (Test-Path -LiteralPath $cmdPath) { + $commandPath = $cmdPath + } +} +$version = (& $commandPath --version 2>&1 | Out-String).Trim() +Write-Output "__OPENCLAW_PATH__=$commandPath" +Write-Output $version +if ('${expectedNeedle}'.Length -gt 0 -and $version -notmatch [regex]::Escape('${expectedNeedle}')) { + throw "version mismatch: expected substring ${expectedNeedle}" +} +`.trim(); +} + +export function buildWindowsDevUpdateToolchainCheckScript() { + return ` +${buildWindowsPathBootstrapScript()} +function Resolve-CommandPath([string]$Name) { + $command = Get-Command $Name -ErrorAction SilentlyContinue + if ($null -eq $command) { + return $null + } + $commandPath = $command.Source + if ($commandPath -match '(?i)\\.ps1$') { + $cmdPath = [System.IO.Path]::ChangeExtension($commandPath, '.cmd') + if (Test-Path -LiteralPath $cmdPath) { + $commandPath = $cmdPath + } + } + return $commandPath +} +$pnpmPath = Resolve-CommandPath 'pnpm' +if ($null -ne $pnpmPath) { + Write-Output "__UPDATE_TOOL__=pnpm" + Write-Output "__UPDATE_TOOL_PATH__=$pnpmPath" + & $pnpmPath --version + return +} +$corepackPath = Resolve-CommandPath 'corepack' +if ($null -ne $corepackPath) { + Write-Output "__UPDATE_TOOL__=corepack" + Write-Output "__UPDATE_TOOL_PATH__=$corepackPath" + & $corepackPath --version + return +} +$npmPath = Resolve-CommandPath 'npm' +if ($null -ne $npmPath) { + Write-Output "__UPDATE_TOOL__=npm" + Write-Output "__UPDATE_TOOL_PATH__=$npmPath" + & $npmPath --version + return +} +throw 'Neither pnpm, corepack, nor npm is discoverable from the reconstructed Windows PATH.' +`.trim(); +} + +async function verifyFreshShellCommand(params) { + if (process.platform === "win32") { + const script = buildWindowsFreshShellVersionCheckScript({ + expectedNeedle: params.expectedNeedle, + }); + const result = await runPowerShellScript(script, { + cwd: params.lane.homeDir, + env: params.env, + logPath: params.logPath, + timeoutMs: 2 * 60 * 1000, + }); + const cliPath = normalizeWindowsInstalledCliPath( + parseMarkerLine(result.stdout, "__OPENCLAW_PATH__="), + ); + if (!cliPath) { + throw new Error("Failed to resolve installed openclaw path from fresh Windows shell."); + } + return { + cliPath, + versionOutput: `${result.stdout}\n${result.stderr}`.trim(), + }; + } + + const script = [ + "set -euo pipefail", + 'if [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi', + "command -v openclaw >/dev/null 2>&1", + 'printf "__OPENCLAW_PATH__=%s\\n" "$(command -v openclaw)"', + "openclaw --version", + ].join("\n"); + const result = await runPosixShellScript(script, { + cwd: params.lane.homeDir, + env: params.env, + logPath: params.logPath, + timeoutMs: 2 * 60 * 1000, + }); + const cliPath = parseMarkerLine(result.stdout, "__OPENCLAW_PATH__="); + const versionOutput = `${result.stdout}\n${result.stderr}`.trim(); + if (!cliPath) { + throw new Error("Failed to resolve installed openclaw path from fresh POSIX shell."); + } + if (params.expectedNeedle && !versionOutput.includes(params.expectedNeedle)) { + throw new Error( + `Installed CLI version did not contain expected substring ${params.expectedNeedle}.`, + ); + } + return { cliPath, versionOutput }; +} + +async function runInstalledCli(params) { + const invocation = resolveInstalledCliInvocation(params.cliPath); + return runCommand(invocation.command, [...invocation.argsPrefix, ...params.args], { + cwd: params.cwd, + env: params.env, + logPath: params.logPath, + timeoutMs: params.timeoutMs, + check: params.check ?? true, + }); +} + +async function readInstalledUpdateStatus(params) { + return runInstalledCli({ + cliPath: params.cliPath, + args: ["update", "status", "--json"], + cwd: params.cwd, + env: params.env, + logPath: params.logPath, + timeoutMs: 2 * 60 * 1000, + }); +} + +async function ensureDevUpdateGitInstall(params) { + const updateStatus = await readInstalledUpdateStatus({ + cliPath: params.cliPath, + cwd: params.lane.homeDir, + env: params.env, + logPath: join(params.logsDir, "dev-update-status.log"), + }); + // The dev-update lane must prove that `openclaw update --channel dev` landed on + // the expected git checkout. Falling back to a manual repair here would hide + // updater regressions and turn the suite into a false green. + verifyDevUpdateStatus(updateStatus.stdout, { ref: params.requestedRef }); + return { cliPath: params.cliPath }; +} + +async function runOnboardWithInstalledCli(params) { + await withAllocatedGatewayPort(params.lane, async () => { + const args = [ + "onboard", + "--non-interactive", + "--mode", + "local", + "--auth-choice", + params.providerConfig.authChoice, + "--secret-input-mode", + "ref", + "--gateway-port", + String(params.lane.gatewayPort), + "--gateway-bind", + "loopback", + "--skip-skills", + "--accept-risk", + "--json", + ]; + if (params.installDaemon) { + args.push("--install-daemon"); + } + if (!params.installDaemon || shouldSkipInstallerDaemonHealthCheck()) { + args.push("--skip-health"); + } + await runInstalledCli({ + cliPath: params.cliPath, + args, + cwd: params.lane.homeDir, + env: params.env, + logPath: params.logPath, + timeoutMs: 10 * 60 * 1000, + }); + }); +} + +async function startManualGatewayFromInstalledCli(params) { + mkdirSync(dirname(params.logPath), { recursive: true }); + const gatewayLog = createWriteStream(params.logPath, { flags: "a" }); + const invocation = resolveInstalledCliInvocation(params.cliPath); + const child = spawn( + invocation.command, + [ + ...invocation.argsPrefix, + "gateway", + "run", + "--bind", + "loopback", + "--port", + String(params.lane.gatewayPort), + "--force", + ], + { + cwd: params.lane.homeDir, + env: params.env, + shell: invocation.shell, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }, + ); + child.stdout?.on("data", (chunk) => { + gatewayLog.write(chunk); + }); + child.stderr?.on("data", (chunk) => { + gatewayLog.write(chunk); + }); + let logClosed = false; + const closeLog = async () => { + if (logClosed) { + return; + } + logClosed = true; + await new Promise((resolvePromise) => { + gatewayLog.once("error", () => resolvePromise()); + gatewayLog.end(() => resolvePromise()); + }); + }; + child.once("close", () => { + void closeLog(); + }); + child.once("error", () => { + void closeLog(); + }); + return { child, closeLog, logPath: params.logPath }; +} + +async function resolveInstalledGatewayStatusArgs(params) { + const requireRpc = params.requireRpc !== false; + const help = await runInstalledCli({ + cliPath: params.cliPath, + args: ["gateway", "status", "--help"], + cwd: params.cwd, + env: params.env, + logPath: params.logPath, + timeoutMs: 15_000, + check: false, + }); + if ( + requireRpc && + (help.stdout.includes("--require-rpc") || help.stderr.includes("--require-rpc")) + ) { + return ["gateway", "status", "--deep", "--require-rpc", "--timeout", "5000"]; + } + return ["gateway", "status", "--deep"]; +} + +export async function canConnectToLoopbackPort(port, timeoutMs = 1_000) { + if (!Number.isInteger(port) || port <= 0) { + return false; + } + return await new Promise((resolvePromise) => { + let settled = false; + const socket = createNetConnection({ + host: "127.0.0.1", + port, + }); + const settle = (value) => { + if (settled) { + return; + } + settled = true; + socket.destroy(); + resolvePromise(value); + }; + socket.setTimeout(timeoutMs); + socket.once("connect", () => settle(true)); + socket.once("timeout", () => settle(false)); + socket.once("error", () => settle(false)); + }); +} + +async function waitForInstalledGateway(params) { + const statusArgs = await resolveInstalledGatewayStatusArgs({ + cliPath: params.cliPath, + cwd: params.lane.homeDir, + env: params.env, + logPath: params.logPath, + }); + const deadline = Date.now() + gatewayReadyDeadlineMs(); + while (Date.now() < deadline) { + const result = await runInstalledCli({ + cliPath: params.cliPath, + args: statusArgs, + cwd: params.lane.homeDir, + env: params.env, + logPath: params.logPath, + timeoutMs: 20_000, + check: false, + }); + if (result.exitCode === 0) { + return; + } + await sleep(2_000); + } + throw new Error(`Gateway did not become ready on port ${params.lane.gatewayPort}.`); +} + +async function waitForInstalledGatewayToStop(params) { + const statusArgs = await resolveInstalledGatewayStatusArgs({ + cliPath: params.cliPath, + cwd: params.lane.homeDir, + env: params.env, + logPath: params.logPath, + requireRpc: false, + }); + const deadline = Date.now() + gatewayReadyDeadlineMs(); + while (Date.now() < deadline) { + await runInstalledCli({ + cliPath: params.cliPath, + args: statusArgs, + cwd: params.lane.homeDir, + env: params.env, + logPath: params.logPath, + timeoutMs: 20_000, + check: false, + }); + const portReachable = await canConnectToLoopbackPort(params.lane.gatewayPort); + if (!portReachable) { + return; + } + await sleep(2_000); + } + throw new Error( + `Managed gateway did not stop on port ${params.lane.gatewayPort} before manual fallback.`, + ); +} + +async function ensureManagedGatewayReady(params) { + try { + await waitForInstalledGateway(params); + return; + } catch { + await runInstalledCli({ + cliPath: params.cliPath, + args: ["gateway", "start"], + cwd: params.lane.homeDir, + env: params.env, + logPath: params.logPath, + timeoutMs: 2 * 60 * 1000, + check: false, + }); + } + await waitForInstalledGateway(params); +} + +async function runInstalledModelsSet(params) { + await runInstalledCli({ + cliPath: params.cliPath, + args: ["models", "set", params.providerConfig.model], + cwd: params.cwd, + env: params.env, + logPath: params.logPath, + timeoutMs: 2 * 60 * 1000, + }); +} + +async function runInstalledAgentTurn(params) { + const sessionId = `cross-os-release-check-${params.label}-${Date.now()}`; + const result = await runInstalledCli({ + cliPath: params.cliPath, + args: [ + "agent", + "--agent", + "main", + "--session-id", + sessionId, + "--message", + "Reply with exact ASCII text OK only.", + "--json", + ], + cwd: params.cwd, + env: params.env, + logPath: params.logPath, + timeoutMs: 10 * 60 * 1000, + }); + const payloadTexts = parseAgentPayloadTexts(result.stdout); + if (!payloadTexts.some((text) => text.trim() === "OK")) { + throw new Error("Agent output did not contain the expected OK marker."); + } + return result; +} + +export function verifyDevUpdateStatus(stdout, options = {}) { + let payload = null; + try { + payload = JSON.parse(stdout); + } catch { + payload = null; + } + const expectedRef = resolveExpectedDevUpdateRef(options.ref); + const update = payload?.update ?? payload; + const installKind = update?.installKind ?? null; + const branch = update?.git?.branch ?? null; + const sha = update?.git?.sha ?? null; + const channelValue = payload?.channel?.value ?? payload?.channel?.channel ?? null; + if (installKind !== "git") { + throw new Error( + `Dev update did not land on a git install. Found ${installKind ?? ""}.`, + ); + } + if (channelValue !== "dev") { + throw new Error( + `Dev update status did not report channel=dev. Found ${channelValue ?? ""}.`, + ); + } + if (looksLikeCommitSha(expectedRef)) { + const normalizedSha = typeof sha === "string" ? sha.toLowerCase() : ""; + const normalizedExpectedRef = expectedRef.toLowerCase(); + if (!normalizedSha || !normalizedSha.startsWith(normalizedExpectedRef)) { + throw new Error( + `Dev update status did not report sha=${expectedRef}. Found ${sha ?? ""}.`, + ); + } + return; + } + if (branch !== expectedRef) { + throw new Error( + `Dev update status did not report branch=${expectedRef}. Found ${branch ?? ""}.`, + ); + } +} + +async function verifyWindowsDevUpdateToolchain(params) { + const script = buildWindowsDevUpdateToolchainCheckScript(); + const result = await runPowerShellScript(script, { + cwd: params.lane.homeDir, + env: params.env, + logPath: params.logPath, + timeoutMs: 2 * 60 * 1000, + }); + if (!parseMarkerLine(result.stdout, "__UPDATE_TOOL__=")) { + throw new Error( + "No Windows update bootstrap tool (pnpm, corepack, or npm) was discoverable after the dev update.", + ); + } +} + +export function buildDiscordSmokeGuildsConfig(guildId, channelId) { + return { + [guildId]: { + channels: { + [channelId]: { + enabled: true, + requireMention: false, + }, + }, + }, + }; +} + +async function configureDiscordSmoke(params) { + const guildsJson = JSON.stringify( + buildDiscordSmokeGuildsConfig(params.guildId, params.channelId), + ); + await runInstalledCli({ + cliPath: params.cliPath, + args: [ + "config", + "set", + "channels.discord.token", + "--ref-provider", + "default", + "--ref-source", + "env", + "--ref-id", + "DISCORD_BOT_TOKEN", + ], + cwd: params.cwd, + env: { ...params.env, DISCORD_BOT_TOKEN: params.token }, + logPath: params.logPath, + timeoutMs: 2 * 60 * 1000, + }); + await runInstalledCli({ + cliPath: params.cliPath, + args: ["config", "set", "channels.discord.enabled", "true"], + cwd: params.cwd, + env: params.env, + logPath: params.logPath, + timeoutMs: 2 * 60 * 1000, + }); + await runInstalledCli({ + cliPath: params.cliPath, + args: ["config", "set", "channels.discord.groupPolicy", "allowlist"], + cwd: params.cwd, + env: params.env, + logPath: params.logPath, + timeoutMs: 2 * 60 * 1000, + }); + await runInstalledCli({ + cliPath: params.cliPath, + args: ["config", "set", "channels.discord.guilds", guildsJson, "--strict-json"], + cwd: params.cwd, + env: params.env, + logPath: params.logPath, + timeoutMs: 2 * 60 * 1000, + }); + if (!shouldUseManagedGatewayService()) { + const gatewayEnv = { ...params.env, DISCORD_BOT_TOKEN: params.token }; + if (params.gatewayHolder?.current) { + await stopGateway(params.gatewayHolder.current); + params.gatewayHolder.current = null; + } + const gateway = await startManualGatewayFromInstalledCli({ + lane: params.lane, + cliPath: params.cliPath, + env: gatewayEnv, + logPath: join(params.cwd, `.openclaw/logs/${params.lane.name}-discord-gateway.log`), + }); + if (params.gatewayHolder) { + params.gatewayHolder.current = gateway; + } + await waitForInstalledGateway({ + lane: params.lane, + cliPath: params.cliPath, + env: gatewayEnv, + logPath: params.logPath, + }); + return; + } + await runInstalledCli({ + cliPath: params.cliPath, + args: ["gateway", "restart"], + cwd: params.cwd, + env: { ...params.env, DISCORD_BOT_TOKEN: params.token }, + logPath: params.logPath, + timeoutMs: 2 * 60 * 1000, + check: false, + }); + await ensureManagedGatewayReady({ + lane: params.lane, + cliPath: params.cliPath, + env: { ...params.env, DISCORD_BOT_TOKEN: params.token }, + logPath: params.logPath, + }); +} + +async function waitForDiscordMessage(params) { + const deadline = Date.now() + 3 * 60 * 1000; + while (Date.now() < deadline) { + const response = await fetch( + `https://discord.com/api/v10/channels/${params.channelId}/messages?limit=20`, + { + headers: { + Authorization: `Bot ${params.token}`, + }, + }, + ); + const text = await response.text(); + if (!response.ok) { + await sleep(2_000); + continue; + } + if (text.includes(params.needle)) { + return; + } + await sleep(2_000); + } + throw new Error(`Discord host-side visibility check timed out for ${params.needle}.`); +} + +async function postDiscordMessage(params) { + const response = await fetch( + `https://discord.com/api/v10/channels/${params.channelId}/messages`, + { + method: "POST", + headers: { + Authorization: `Bot ${params.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: params.content, + flags: 4096, + }), + }, + ); + const text = await response.text(); + if (!response.ok) { + throw new Error(`Failed to post Discord smoke message: ${text}`); + } + try { + return JSON.parse(text)?.id ?? null; + } catch { + return null; + } +} + +async function deleteDiscordMessage(params) { + if (!params.messageId) { + return; + } + await fetch( + `https://discord.com/api/v10/channels/${params.channelId}/messages/${params.messageId}`, + { + method: "DELETE", + headers: { + Authorization: `Bot ${params.token}`, + }, + }, + ).catch(() => undefined); +} + +async function waitForInstalledDiscordReadback(params) { + const deadline = Date.now() + 3 * 60 * 1000; + while (Date.now() < deadline) { + const response = await runInstalledCli({ + cliPath: params.cliPath, + args: [ + "message", + "read", + "--channel", + "discord", + "--target", + `channel:${params.channelId}`, + "--limit", + "20", + "--json", + ], + cwd: params.cwd, + env: params.env, + logPath: params.logPath, + timeoutMs: 60_000, + check: false, + }); + if (response.exitCode === 0 && response.stdout.includes(params.needle)) { + return; + } + await sleep(3_000); + } + throw new Error(`Discord guest readback timed out for ${params.needle}.`); +} + +async function maybeRunDiscordRoundtrip(params) { + const token = + process.env.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN?.trim() || + process.env.DISCORD_BOT_TOKEN?.trim() || + ""; + const guildId = process.env.OPENCLAW_DISCORD_SMOKE_GUILD_ID?.trim() || ""; + const channelId = process.env.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID?.trim() || ""; + if (!token || !guildId || !channelId) { + return "skipped-missing-config"; + } + + const outboundNonce = `native-cross-os-outbound-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + const inboundNonce = `native-cross-os-inbound-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + let sentMessageId = null; + let hostMessageId = null; + try { + await configureDiscordSmoke({ + lane: params.lane, + cliPath: params.cliPath, + cwd: params.lane.homeDir, + env: params.env, + gatewayHolder: params.gatewayHolder, + logPath: params.logPath, + token, + guildId, + channelId, + }); + + const sendResult = await runInstalledCli({ + cliPath: params.cliPath, + args: [ + "message", + "send", + "--channel", + "discord", + "--target", + `channel:${channelId}`, + "--message", + outboundNonce, + "--silent", + "--json", + ], + cwd: params.lane.homeDir, + env: { ...params.env, DISCORD_BOT_TOKEN: token }, + logPath: params.logPath, + timeoutMs: 2 * 60 * 1000, + }); + let parsedSendResult = null; + try { + parsedSendResult = JSON.parse(sendResult.stdout); + } catch { + parsedSendResult = null; + } + sentMessageId = + parsedSendResult?.payload?.messageId ?? parsedSendResult?.payload?.result?.messageId ?? null; + await waitForDiscordMessage({ + token, + channelId, + needle: outboundNonce, + }); + hostMessageId = await postDiscordMessage({ + token, + channelId, + content: inboundNonce, + }); + await waitForInstalledDiscordReadback({ + cliPath: params.cliPath, + cwd: params.lane.homeDir, + env: { ...params.env, DISCORD_BOT_TOKEN: token }, + logPath: params.logPath, + channelId, + needle: inboundNonce, + }); + return "pass"; + } finally { + await deleteDiscordMessage({ token, channelId, messageId: sentMessageId }); + await deleteDiscordMessage({ token, channelId, messageId: hostMessageId }); + } +} + +async function installTarballPackage(params) { + await installPackageSpec({ + lane: params.lane, + env: params.env, + packageSpec: params.tgzPath, + logPath: params.logPath, + timeoutMs: params.timeoutMs, + }); + if ( + params.restoreBundledPluginRuntimeDeps !== false && + shouldRestoreBundledPluginRuntimeDeps({ lane: params.lane }) + ) { + await runBundledPluginPostinstall({ + lane: params.lane, + env: params.env, + logPath: params.logPath, + }); + } +} + +async function installPackageSpec(params) { + const installEnv = { + ...params.env, + npm_config_global: "true", + npm_config_location: "global", + npm_config_prefix: params.lane.prefixDir, + }; + rmSync(installedPackageRoot(params.lane.prefixDir), { force: true, recursive: true }); + await runCommand( + npmCommand(), + [ + "install", + "-g", + params.packageSpec, + "--omit=dev", + "--no-fund", + "--no-audit", + "--loglevel=notice", + ], + { + cwd: params.lane.homeDir, + env: installEnv, + logPath: params.logPath, + timeoutMs: params.timeoutMs ?? installTimeoutMs(), + }, + ); +} + +function installTimeoutMs() { + return process.platform === "win32" ? 45 * 60 * 1000 : 20 * 60 * 1000; +} + +function updateTimeoutMs() { + return process.platform === "win32" ? 30 * 60 * 1000 : 20 * 60 * 1000; +} + +function updateStepTimeoutSeconds() { + return process.platform === "win32" ? 1800 : 1200; +} + +async function runBundledPluginPostinstall(params) { + const packageRoot = installedPackageRoot(params.lane.prefixDir); + const scriptPath = join(packageRoot, "scripts", "postinstall-bundled-plugins.mjs"); + if (!existsSync(scriptPath)) { + return; + } + const installEnv = { + ...params.env, + }; + delete installEnv.OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL; + delete installEnv.NPM_CONFIG_PREFIX; + delete installEnv.npm_config_global; + delete installEnv.npm_config_location; + delete installEnv.npm_config_prefix; + + await runCommand(process.execPath, [scriptPath], { + cwd: packageRoot, + env: installEnv, + logPath: params.logPath, + timeoutMs: 20 * 60 * 1000, + }); +} + +function ensureLocalNpmShim(lane) { + const shimPath = npmShimPath(lane.prefixDir); + if (existsSync(shimPath)) { + return; + } + mkdirSync(dirname(shimPath), { recursive: true }); + const resolvedNpm = resolveCommandPath(npmCommand()); + if (!resolvedNpm) { + throw new Error(`Failed to resolve ${npmCommand()} on PATH.`); + } + if (process.platform === "win32") { + writeFileSync( + shimPath, + `@echo off\r\nset "NPM_CONFIG_PREFIX=${lane.prefixDir}"\r\n"${resolvedNpm}" %*\r\n`, + "utf8", + ); + return; + } + writeFileSync( + shimPath, + `#!/bin/sh\nexport NPM_CONFIG_PREFIX='${shellEscapeForSh(lane.prefixDir)}'\nexec '${shellEscapeForSh(resolvedNpm)}' "$@"\n`, + "utf8", + ); + chmodSync(shimPath, 0o755); +} + +async function runOnboard(params) { + await withAllocatedGatewayPort(params.lane, async () => { + await runOpenClaw({ + lane: params.lane, + env: params.env, + args: [ + "onboard", + "--non-interactive", + "--mode", + "local", + "--auth-choice", + params.providerConfig.authChoice, + "--secret-input-mode", + "ref", + "--gateway-port", + String(params.lane.gatewayPort), + "--gateway-bind", + "loopback", + "--skip-skills", + "--skip-health", + "--accept-risk", + "--json", + ], + logPath: params.logPath, + timeoutMs: 10 * 60 * 1000, + }); + }); +} + +async function exerciseManagedGatewayLifecycle(params) { + logLanePhase(params.lane, "gateway-ready"); + await ensureManagedGatewayReady({ + lane: params.lane, + cliPath: params.cliPath, + env: params.env, + logPath: `${params.logPrefix}-ready.log`, + }); + + logLanePhase(params.lane, "gateway-restart"); + await runInstalledCli({ + cliPath: params.cliPath, + args: ["gateway", "restart"], + env: params.env, + cwd: params.lane.homeDir, + logPath: `${params.logPrefix}-restart.log`, + timeoutMs: 2 * 60 * 1000, + }); + await ensureManagedGatewayReady({ + lane: params.lane, + cliPath: params.cliPath, + env: params.env, + logPath: `${params.logPrefix}-ready-after-restart.log`, + }); + + logLanePhase(params.lane, "gateway-stop"); + await runInstalledCli({ + cliPath: params.cliPath, + args: ["gateway", "stop"], + env: params.env, + cwd: params.lane.homeDir, + logPath: `${params.logPrefix}-stop.log`, + timeoutMs: 2 * 60 * 1000, + }); + + logLanePhase(params.lane, "gateway-start"); + await runInstalledCli({ + cliPath: params.cliPath, + args: ["gateway", "start"], + env: params.env, + cwd: params.lane.homeDir, + logPath: `${params.logPrefix}-start.log`, + timeoutMs: 2 * 60 * 1000, + }); + await ensureManagedGatewayReady({ + lane: params.lane, + cliPath: params.cliPath, + env: params.env, + logPath: `${params.logPrefix}-ready-after-start.log`, + }); +} + +async function startGateway(params) { + const gatewayLog = createWriteStream(params.logPath, { flags: "a" }); + const child = spawn( + process.execPath, + [ + installedEntryPath(params.lane.prefixDir), + "gateway", + "run", + "--bind", + "loopback", + "--port", + String(params.lane.gatewayPort), + "--force", + ], + { + cwd: params.lane.homeDir, + env: params.env, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }, + ); + child.stdout?.on("data", (chunk) => { + gatewayLog.write(chunk); + }); + child.stderr?.on("data", (chunk) => { + gatewayLog.write(chunk); + }); + let logClosed = false; + const closeLog = async () => { + if (logClosed) { + return; + } + logClosed = true; + await new Promise((resolvePromise) => { + gatewayLog.once("error", () => resolvePromise()); + gatewayLog.end(() => resolvePromise()); + }); + }; + child.once("close", () => { + void closeLog(); + }); + child.once("error", () => { + void closeLog(); + }); + return { child, closeLog, logPath: params.logPath }; +} + +async function waitForGateway(params) { + const statusArgs = await resolveGatewayStatusArgs(params.lane, params.env, params.logPath); + const deadline = Date.now() + gatewayReadyDeadlineMs(); + while (Date.now() < deadline) { + let result; + try { + result = await runOpenClaw({ + lane: params.lane, + env: params.env, + args: statusArgs, + logPath: params.logPath, + timeoutMs: 20_000, + check: false, + }); + } catch { + await sleep(2_000); + continue; + } + if (result.exitCode === 0) { + return; + } + await sleep(2_000); + } + throw new Error(`Gateway did not become ready on port ${params.lane.gatewayPort}.`); +} + +function gatewayReadyDeadlineMs() { + return process.platform === "win32" ? 5 * 60 * 1000 : 90_000; +} + +async function resolveGatewayStatusArgs(lane, env, logPath) { + const help = await runOpenClaw({ + lane, + env, + args: ["gateway", "status", "--help"], + logPath, + timeoutMs: 15_000, + check: false, + }); + if (help.stdout.includes("--require-rpc") || help.stderr.includes("--require-rpc")) { + return ["gateway", "status", "--deep", "--require-rpc", "--timeout", "5000"]; + } + return ["gateway", "status", "--deep"]; +} + +async function runModelsSet(params) { + await runOpenClaw({ + lane: params.lane, + env: params.env, + args: ["models", "set", params.providerConfig.model], + logPath: params.logPath, + timeoutMs: 2 * 60 * 1000, + }); +} + +async function runAgentTurn(params) { + const sessionId = `cross-os-release-check-${params.label}-${Date.now()}`; + const result = await runOpenClaw({ + lane: params.lane, + env: params.env, + args: [ + "agent", + "--agent", + "main", + "--session-id", + sessionId, + "--message", + "Reply with exact ASCII text OK only.", + "--json", + ], + logPath: params.logPath, + timeoutMs: 10 * 60 * 1000, + }); + const payloadTexts = parseAgentPayloadTexts(result.stdout); + if (!payloadTexts.some((text) => text.trim() === "OK")) { + throw new Error("Agent output did not contain the expected OK marker."); + } + return result; +} + +function parseAgentPayloadTexts(stdout) { + try { + const payload = JSON.parse(stdout); + const entries = Array.isArray(payload?.payloads) + ? payload.payloads + : Array.isArray(payload?.result?.payloads) + ? payload.result.payloads + : []; + if (!Array.isArray(entries)) { + return []; + } + return entries.flatMap((entry) => (typeof entry?.text === "string" ? [entry.text] : [])); + } catch { + return stdout.trim() ? [stdout] : []; + } +} + +async function runDashboardSmoke(params) { + const dashboardUrl = `http://127.0.0.1:${params.lane.gatewayPort}/`; + const logStream = createWriteStream(params.logPath, { flags: "a" }); + const deadline = Date.now() + 30_000; + let attempt = 0; + try { + while (Date.now() < deadline) { + attempt += 1; + logStream.write(`${new Date().toISOString()} attempt=${attempt} url=${dashboardUrl}\n`); + try { + const response = await fetch(dashboardUrl, { + signal: AbortSignal.timeout(5_000), + }); + const html = await response.text(); + if ( + response.ok && + html.includes("OpenClaw Control") && + html.includes("") + ) { + logStream.write( + `${new Date().toISOString()} dashboard-ready status=${response.status}\n`, + ); + return; + } + logStream.write( + `${new Date().toISOString()} dashboard-not-ready status=${response.status} title=${html.includes("OpenClaw Control")} app=${html.includes("")}\n`, + ); + } catch (error) { + logStream.write( + `${new Date().toISOString()} dashboard-fetch-error ${formatError(error)}\n`, + ); + } + await sleep(1_000); + } + } finally { + logStream.end(); + } + throw new Error(`Dashboard HTML did not become ready at ${dashboardUrl}.`); +} + +async function stopGateway(gateway) { + try { + if (!gateway?.child?.pid) { + return; + } + if (process.platform === "win32") { + await runCommand("taskkill", ["/PID", String(gateway.child.pid), "/T", "/F"], { + logPath: gateway.logPath, + check: false, + timeoutMs: 30_000, + }); + const exited = await waitForChildExit(gateway.child, 10_000); + if (!exited) { + gateway.child.stdout?.destroy(); + gateway.child.stderr?.destroy(); + } + return; + } + if (gateway.child.exitCode !== null) { + return; + } + gateway.child.kill("SIGTERM"); + const exitedAfterTerm = await waitForChildExit(gateway.child, 2_000); + if (!exitedAfterTerm && gateway.child.exitCode === null) { + gateway.child.kill("SIGKILL"); + await waitForChildExit(gateway.child, 5_000); + } + } finally { + await gateway?.closeLog?.(); + } +} + +async function waitForChildExit(child, timeoutMs) { + if (child.exitCode !== null) { + return true; + } + return new Promise((resolvePromise) => { + let settled = false; + const finish = (didExit) => { + if (settled) { + return; + } + settled = true; + if (timer) { + clearTimeout(timer); + } + child.off("exit", onExit); + child.off("close", onClose); + child.off("error", onError); + resolvePromise(didExit); + }; + const onExit = () => finish(true); + const onClose = () => finish(true); + const onError = () => finish(true); + const timer = + timeoutMs > 0 + ? setTimeout(() => { + finish(false); + }, timeoutMs) + : null; + + child.once("exit", onExit); + child.once("close", onClose); + child.once("error", onError); + }); +} + +async function runCleanup(cleanupFns) { + for (const cleanupFn of cleanupFns.toReversed()) { + try { + await cleanupFn(); + } catch { + // Ignore cleanup failures so the main failure surface stays visible. + } + } +} + +async function runOpenClaw(params) { + return runCommand(process.execPath, [installedEntryPath(params.lane.prefixDir), ...params.args], { + cwd: params.lane.homeDir, + env: params.env, + logPath: params.logPath, + timeoutMs: params.timeoutMs, + check: params.check ?? true, + }); +} + +function readInstalledPackageManifest(prefixDir) { + const packageRoot = installedPackageRoot(prefixDir); + const packageJsonPath = join(packageRoot, "package.json"); + if (!existsSync(packageJsonPath)) { + throw new Error(`Installed package manifest missing: ${packageJsonPath}`); + } + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { + version?: unknown; + }; + return { packageJson, packageRoot }; +} + +export function readInstalledVersion(prefixDir) { + const { packageJson } = readInstalledPackageManifest(prefixDir); + return typeof packageJson.version === "string" ? packageJson.version.trim() : ""; +} + +function readInstalledMetadata(prefixDir) { + const { packageJson, packageRoot } = readInstalledPackageManifest(prefixDir); + const buildInfoPath = join(packageRoot, "dist", "build-info.json"); + if (!existsSync(buildInfoPath)) { + throw new Error(`Installed build info missing: ${buildInfoPath}`); + } + const buildInfo = JSON.parse(readFileSync(buildInfoPath, "utf8")) as { + commit?: unknown; + }; + return { + version: typeof packageJson.version === "string" ? packageJson.version.trim() : "", + commit: typeof buildInfo.commit === "string" ? buildInfo.commit.trim() : "", + }; +} + +function verifyInstalledCandidate(installed, build) { + if (installed.version !== build.candidateVersion) { + throw new Error( + `Installed version mismatch. Expected ${build.candidateVersion}, found ${installed.version || ""}.`, + ); + } + if (installed.commit !== build.sourceSha) { + throw new Error( + `Installed build commit mismatch. Expected ${build.sourceSha}, found ${installed.commit || ""}.`, + ); + } +} + +function installedPackageRoot(prefixDir) { + return process.platform === "win32" + ? join(prefixDir, "node_modules", "openclaw") + : join(prefixDir, "lib", "node_modules", "openclaw"); +} + +function installedEntryPath(prefixDir) { + return join(installedPackageRoot(prefixDir), "openclaw.mjs"); +} + +function npmShimPath(prefixDir) { + return process.platform === "win32" ? join(prefixDir, "npm.cmd") : join(prefixDir, "bin", "npm"); +} + +function binDirForPrefix(prefixDir) { + return process.platform === "win32" ? prefixDir : join(prefixDir, "bin"); +} + +function pnpmCommand() { + return process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +} + +function npmCommand() { + return process.platform === "win32" ? "npm.cmd" : "npm"; +} + +function gitCommand() { + return process.platform === "win32" ? "git.exe" : "git"; +} + +async function runCommand(command, args, options) { + return new Promise((resolvePromise, rejectPromise) => { + const useWindowsShell = process.platform === "win32" && /\.(cmd|bat)$/iu.test(command); + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + shell: useWindowsShell, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + const logStream = createWriteStream(options.logPath, { flags: "a" }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + let settled = false; + + const clearTimers = () => { + if (timer) { + clearTimeout(timer); + } + if (killWaitTimer) { + clearTimeout(killWaitTimer); + } + }; + + const finalize = (callback) => { + if (settled) { + return; + } + settled = true; + clearTimers(); + logStream.end(); + callback(); + }; + + const requestKill = () => { + if (process.platform === "win32" && child.pid) { + try { + const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + killer.on("error", () => { + child.kill(); + }); + return; + } catch { + child.kill(); + return; + } + } + child.kill(process.platform === "win32" ? undefined : "SIGKILL"); + }; + + let killWaitTimer = null; + const timer = + options.timeoutMs && Number.isFinite(options.timeoutMs) + ? setTimeout(() => { + timedOut = true; + logStream.write( + `${new Date().toISOString()} timeout command=${command} args=${args.join(" ")}\n`, + ); + requestKill(); + killWaitTimer = setTimeout(() => { + finalize(() => { + rejectPromise( + new Error( + `Command timed out and could not be terminated cleanly: ${command} ${args.join(" ")}`, + ), + ); + }); + }, 15_000); + }, options.timeoutMs) + : null; + + child.stdout?.on("data", (chunk) => { + const text = chunk.toString(); + stdout += text; + logStream.write(text); + }); + child.stderr?.on("data", (chunk) => { + const text = chunk.toString(); + stderr += text; + logStream.write(text); + }); + + child.on("error", (error) => { + finalize(() => rejectPromise(error)); + }); + + child.on("close", (exitCode) => { + finalize(() => { + const result = { + exitCode: exitCode ?? 1, + stdout, + stderr, + }; + if (timedOut) { + rejectPromise(new Error(`Command timed out: ${command} ${args.join(" ")}`)); + return; + } + if ((options.check ?? true) && result.exitCode !== 0) { + rejectPromise( + new Error( + `Command failed (${result.exitCode}): ${command} ${args.join(" ")}\n${trimForSummary( + `${stdout}\n${stderr}`, + )}`, + ), + ); + return; + } + resolvePromise(result); + }); + }); + }); +} + +async function startStaticFileServer(params) { + mkdirSync(dirname(params.logPath), { recursive: true }); + const logStream = createWriteStream(params.logPath, { flags: "a" }); + const fileName = String(params.filePath.split(/[/\\]/u).at(-1) ?? "artifact"); + const fileBytes = readFileSync(params.filePath); + const server = createServer((request, response) => { + logStream.write(`${new Date().toISOString()} ${request.method} ${request.url}\n`); + if (request.url !== `/${fileName}`) { + response.statusCode = 404; + response.end("not found"); + return; + } + response.statusCode = 200; + response.setHeader("content-type", resolveStaticFileContentType(params.filePath)); + response.setHeader("content-length", String(fileBytes.length)); + response.end(fileBytes); + }); + await new Promise((resolvePromise, rejectPromise) => { + server.once("error", rejectPromise); + server.listen(0, "127.0.0.1", resolvePromise); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to bind static file server."); + } + const port = address.port; + return { + url: `http://127.0.0.1:${port}/${fileName}`, + close: () => + new Promise((resolvePromise, rejectPromise) => { + server.close((error) => { + logStream.end(); + if (error) { + rejectPromise(error); + return; + } + resolvePromise(); + }); + }), + }; +} + +export function resolveStaticFileContentType(filePath) { + if (filePath.endsWith(".sh") || filePath.endsWith(".ps1")) { + return "text/plain; charset=utf-8"; + } + return "application/octet-stream"; +} + +export function resolvePublishedInstallerUrl(platform = process.platform) { + if (platform === "win32") { + return `${PUBLISHED_INSTALLER_BASE_URL}/install.ps1`; + } + return `${PUBLISHED_INSTALLER_BASE_URL}/install.sh`; +} + +function writeSummary(baseDir, summaryPayload) { + const summaryJsonPath = join(baseDir, "summary.json"); + const summaryMarkdownPath = join(baseDir, "summary.md"); + writeFileSync(summaryJsonPath, `${JSON.stringify(summaryPayload, null, 2)}\n`, "utf8"); + const result = summaryPayload.result ?? {}; + + const lines = [ + `## ${platformLabel()}`, + "", + `- Provider: \`${summaryPayload.provider}\``, + `- Suite: \`${summaryPayload.suite}\``, + `- Mode: \`${summaryPayload.mode}\``, + `- Source SHA: \`${summaryPayload.sourceSha || "unknown"}\``, + `- Candidate version: \`${summaryPayload.candidateVersion || "unknown"}\``, + `- Baseline spec: \`${summaryPayload.baselineSpec}\``, + result.status ? `- Result: \`${result.status}\`` : "", + result.installTarget ? `- Install target: \`${result.installTarget}\`` : "", + result.installVersion ? `- Install version: \`${result.installVersion}\`` : "", + result.baselineVersion ? `- Baseline version: \`${result.baselineVersion}\`` : "", + result.installedVersion ? `- Installed version: \`${result.installedVersion}\`` : "", + result.installedCommit ? `- Installed commit: \`${result.installedCommit}\`` : "", + result.cliPath ? `- CLI path: \`${result.cliPath}\`` : "", + result.gatewayPort ? `- Gateway port: \`${result.gatewayPort}\`` : "", + result.dashboardStatus ? `- Dashboard: \`${result.dashboardStatus}\`` : "", + result.discordStatus ? `- Discord: \`${result.discordStatus}\`` : "", + result.agentOutput ? `- Agent output: \`${trimForSummary(result.agentOutput)}\`` : "", + result.error ? `- Error: \`${trimForSummary(result.error)}\`` : "", + ].filter(Boolean); + writeFileSync(summaryMarkdownPath, `${lines.join("\n")}\n`, "utf8"); +} + +function writeCandidateManifest(baseDir, build) { + const manifestPath = join(baseDir, "candidate.json"); + writeFileSync( + manifestPath, + `${JSON.stringify( + { + sourceSha: build.sourceSha, + candidateVersion: build.candidateVersion, + candidateFileName: build.candidateFileName, + }, + null, + 2, + )}\n`, + "utf8", + ); +} + +function platformLabel() { + if (process.platform === "darwin") { + return "macOS Release Checks"; + } + if (process.platform === "win32") { + return "Windows Release Checks"; + } + return "Linux Release Checks"; +} + +function requireArg(argsMap, key) { + const value = argsMap[key]?.trim(); + if (!value) { + throw new Error(`Missing required --${key} argument.`); + } + return value; +} + +function resolveCommandPath(command) { + const pathValue = process.env.PATH ?? ""; + const pathEntries = pathValue.split(process.platform === "win32" ? ";" : ":").filter(Boolean); + const candidates = + process.platform === "win32" && !command.toLowerCase().endsWith(".cmd") + ? [`${command}.cmd`, `${command}.exe`, command] + : [command]; + for (const entry of pathEntries) { + for (const candidate of candidates) { + const fullPath = join(entry, candidate); + if (existsSync(fullPath)) { + return fullPath; + } + } + } + return null; +} + +function shellEscapeForSh(value) { + return value.replace(/'/gu, `'"'"'`); +} + +function logPhase(scope, phase) { + process.stdout.write(`[release-checks] ${scope}: ${phase}\n`); +} + +function logLanePhase(lane, phase) { + logPhase(`lane.${lane.name}`, phase); +} + +function trimForSummary(value) { + const trimmed = value.trim(); + if (trimmed.length <= 600) { + return trimmed; + } + return `${trimmed.slice(0, 600)}...`; +} + +function formatError(error) { + if (error instanceof Error) { + return error.stack || error.message; + } + return String(error); +} + +function sleep(ms) { + return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +} + +async function withAllocatedGatewayPort(lane, callback) { + let lastError = null; + for (let attempt = 1; attempt <= 3; attempt += 1) { + const reservation = await reservePort(); + lane.gatewayPort = reservation.port; + await reservation.release(); + try { + return await callback(); + } catch (error) { + lastError = error; + if (!isAddressInUseError(error) || attempt === 3) { + throw error; + } + await sleep(250 * attempt); + } + } + throw lastError ?? new Error("Failed to allocate a gateway port."); +} + +function reservePort() { + return new Promise((resolvePromise, rejectPromise) => { + const server = createNetServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + rejectPromise(new Error("Failed to allocate a TCP port.")); + return; + } + resolvePromise({ + port: address.port, + release: () => + new Promise((releaseResolve, releaseReject) => { + server.close((error) => { + if (error) { + releaseReject(error); + return; + } + releaseResolve(); + }); + }), + }); + }); + server.once("error", rejectPromise); + }); +} + +function isAddressInUseError(error) { + const message = formatError(error); + return message.includes("EADDRINUSE") || /address.+in use/iu.test(message); +} diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 4ca9e958e8f..7750b43efbe 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -441,6 +441,7 @@ async function runGitUpdate(params: { showProgress: boolean; opts: UpdateCommandOptions; stop: () => void; + devTargetRef?: string; }): Promise { const updateRoot = params.switchToGit ? resolveGitInstallDir() : params.root; const effectiveTimeout = params.timeoutMs ?? 20 * 60_000; @@ -477,6 +478,7 @@ async function runGitUpdate(params: { progress: params.progress, channel: params.channel, tag: params.tag, + devTargetRef: params.devTargetRef, }); const steps = [...(cloneStep ? [cloneStep] : []), ...updateResult.steps]; @@ -887,6 +889,8 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { const defaultChannel = updateInstallKind === "git" ? DEFAULT_GIT_CHANNEL : DEFAULT_PACKAGE_CHANNEL; const channel = requestedChannel ?? storedChannel ?? defaultChannel; + const devTargetRef = + channel === "dev" ? process.env.OPENCLAW_UPDATE_DEV_TARGET_REF?.trim() || undefined : undefined; const explicitTag = normalizeTag(opts.tag); let tag = explicitTag ?? channelToNpmTag(channel); @@ -1066,6 +1070,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { showProgress, opts, stop, + devTargetRef, }); stop(); diff --git a/src/daemon/schtasks.startup-fallback.test.ts b/src/daemon/schtasks.startup-fallback.test.ts index c8e84a3376a..daa0012a4a3 100644 --- a/src/daemon/schtasks.startup-fallback.test.ts +++ b/src/daemon/schtasks.startup-fallback.test.ts @@ -20,8 +20,16 @@ const sleepMock = vi.hoisted(() => ); const childUnref = vi.hoisted(() => vi.fn()); const spawn = vi.hoisted(() => vi.fn(() => ({ unref: childUnref }))); +type SpawnSyncResult = { + pid: number; + output: (string | null)[]; + stdout: string; + stderr: string; + status: number; + signal: null; +}; const spawnSync = vi.hoisted(() => - vi.fn(() => ({ + vi.fn<(command: string, args?: readonly string[]) => SpawnSyncResult>(() => ({ pid: 0, output: [null, "", ""], stdout: "", @@ -30,6 +38,9 @@ const spawnSync = vi.hoisted(() => signal: null, })), ); +const findVerifiedGatewayListenerPidsOnPortSync = vi.hoisted(() => + vi.fn<(port: number) => number[]>(() => []), +); vi.mock("../utils.js", async () => { const actual = await vi.importActual("../utils.js"); @@ -47,6 +58,10 @@ vi.mock("node:child_process", async () => { spawnSync, }; }); +vi.mock("../infra/gateway-processes.js", () => ({ + findVerifiedGatewayListenerPidsOnPortSync: (port: number) => + findVerifiedGatewayListenerPidsOnPortSync(port), +})); const { installScheduledTask, @@ -101,8 +116,26 @@ function addStartupFallbackMissingResponses( ...extraResponses, ); } + +function notYetRunTaskQueryOutput() { + return [ + "Status: Ready", + "Last Run Time: 11/30/1999 12:00:00 AM", + "Last Run Result: 267011", + "", + ].join("\r\n"); +} + beforeEach(() => { resetSchtasksBaseMocks(); + findVerifiedGatewayListenerPidsOnPortSync.mockReset(); + findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([]); + inspectPortUsage.mockResolvedValue({ + port: 18789, + status: "free", + listeners: [], + hints: [], + }); spawn.mockClear(); spawnSync.mockClear(); childUnref.mockClear(); @@ -176,6 +209,165 @@ describe("Windows startup fallback", () => { }); }); + it("launches the task script directly when schtasks /Run is accepted but never starts the task", async () => { + await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { + sleepMock.mockImplementationOnce(async () => { + timeState.now += 15_000; + }); + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 1, stdout: "", stderr: "not found" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: notYetRunTaskQueryOutput(), stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: notYetRunTaskQueryOutput(), stderr: "" }, + ); + + await installScheduledTask({ + env, + stdout: new PassThrough(), + programArguments: ["node", "gateway.js", "--port", "18789"], + environment: { OPENCLAW_GATEWAY_PORT: "18789" }, + }); + + expectStartupFallbackSpawn(env); + }); + }); + + it("does not relaunch the task script when schtasks shows startup progress after /Run", async () => { + await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 1, stdout: "", stderr: "not found" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: notYetRunTaskQueryOutput(), stderr: "" }, + { + code: 0, + stdout: [ + "Status: Ready", + "Last Run Time: 4/15/2026 11:42:31 PM", + "Last Run Result: 267011", + "", + ].join("\r\n"), + stderr: "", + }, + ); + + await installScheduledTask({ + env, + stdout: new PassThrough(), + programArguments: ["node", "gateway.js", "--port", "18789"], + environment: { OPENCLAW_GATEWAY_PORT: "18789" }, + }); + + expect(spawn).not.toHaveBeenCalled(); + }); + }); + + it("does not relaunch the task script when the scheduled task process is already starting", async () => { + await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const taskScriptPath = resolveTaskScriptPath(env); + sleepMock.mockImplementationOnce(async () => { + timeState.now += 15_000; + }); + spawnSync.mockImplementation((command, args) => { + if ( + command === "powershell" && + Array.isArray(args) && + args.includes( + "Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress", + ) + ) { + return { + pid: 0, + output: [null, "", ""], + stdout: JSON.stringify([ + { + ProcessId: 4242, + CommandLine: `cmd.exe /d /s /c "${taskScriptPath}"`, + }, + ]), + stderr: "", + status: 0, + signal: null, + }; + } + return { + pid: 0, + output: [null, "", ""], + stdout: "", + stderr: "", + status: 0, + signal: null, + }; + }); + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 1, stdout: "", stderr: "not found" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: notYetRunTaskQueryOutput(), stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: notYetRunTaskQueryOutput(), stderr: "" }, + ); + + await installScheduledTask({ + env, + stdout: new PassThrough(), + programArguments: ["node", "gateway.js", "--port", "18789"], + environment: { OPENCLAW_GATEWAY_PORT: "18789" }, + }); + + expect(spawn).not.toHaveBeenCalled(); + }); + }); + + it("reports a fallback-launched gateway as running even when schtasks still says not-yet-run", async () => { + await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { + await writeGatewayScript(env); + findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4242]); + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: notYetRunTaskQueryOutput(), stderr: "" }, + ); + + await expect(readScheduledTaskRuntime(env)).resolves.toMatchObject({ + status: "running", + pid: 4242, + state: "Ready", + lastRunResult: "267011", + }); + }); + }); + + it("does not trust an unverified busy port when schtasks still says not-yet-run", async () => { + await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { + await writeGatewayScript(env); + inspectPortUsage.mockResolvedValue({ + port: 18789, + status: "busy", + listeners: [{ pid: 4242, command: "node.exe" }], + hints: [], + }); + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: notYetRunTaskQueryOutput(), stderr: "" }, + ); + + await expect(readScheduledTaskRuntime(env)).resolves.toMatchObject({ + status: "stopped", + state: "Ready", + lastRunResult: "267011", + }); + }); + }); + it("treats an installed Startup-folder launcher as loaded", async () => { await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { addStartupFallbackMissingResponses(); @@ -226,6 +418,37 @@ describe("Windows startup fallback", () => { }); }); + it("relaunches the task script when restart sees a scheduled-task run no-op", async () => { + await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { + await writeGatewayScript(env); + sleepMock.mockImplementationOnce(async () => { + timeState.now += 15_000; + }); + inspectPortUsage.mockResolvedValue({ + port: 18789, + status: "free", + listeners: [], + hints: [], + }); + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: notYetRunTaskQueryOutput(), stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: notYetRunTaskQueryOutput(), stderr: "" }, + ); + + await expect(restartScheduledTask({ env, stdout: new PassThrough() })).resolves.toEqual({ + outcome: "completed", + }); + + expectStartupFallbackSpawn(env); + }); + }); + it("kills the Startup fallback runtime even when the CLI env omits the gateway port", async () => { await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { schtasksResponses.push({ code: 0, stdout: "", stderr: "" }); diff --git a/src/daemon/schtasks.stop.test.ts b/src/daemon/schtasks.stop.test.ts index 6cb963c74fd..176f8ab9145 100644 --- a/src/daemon/schtasks.stop.test.ts +++ b/src/daemon/schtasks.stop.test.ts @@ -183,7 +183,7 @@ describe("Scheduled Task stop/restart cleanup", () => { expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(GATEWAY_PORT); expectGatewayTermination(5151); expect(inspectPortUsage).toHaveBeenCalledTimes(2); - expect(schtasksCalls.at(-1)).toEqual(["/Run", "/TN", "OpenClaw Gateway"]); + expect(schtasksCalls).toContainEqual(["/Run", "/TN", "OpenClaw Gateway"]); }); }); diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index 88e3468dd83..2df5875b95d 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -213,8 +213,11 @@ function normalizeTaskResultCode(value?: string): string | null { } const RUNNING_RESULT_CODES = new Set(["0x41301"]); +const NOT_YET_RUN_RESULT_CODES = new Set(["0x41303"]); const UNKNOWN_STATUS_DETAIL = "Task status is locale-dependent and no numeric Last Run Result was available."; +const SCHEDULED_TASK_FALLBACK_POLL_MS = 250; +const SCHEDULED_TASK_FALLBACK_TIMEOUT_MS = 15_000; export function deriveScheduledTaskRuntimeStatus(parsed: ScheduledTaskInfo): { status: GatewayServiceRuntime["status"]; @@ -403,6 +406,24 @@ async function resolveScheduledTaskGatewayListenerPids(port: number): Promise | null> { + const port = await resolveScheduledTaskPort(env); + if (!port) { + return null; + } + const pids = findVerifiedGatewayListenerPidsOnPortSync(port); + if (pids.length === 0) { + return null; + } + return { + status: "running", + pid: pids[0], + detail: `Verified gateway listener detected on port ${port} even though schtasks did not report a running task.`, + }; +} + async function terminateScheduledTaskGatewayListeners(env: GatewayServiceEnv): Promise { const port = await resolveScheduledTaskPort(env); if (!port) { @@ -602,7 +623,11 @@ async function updateExistingScheduledTask(params: { if (change.code !== 0) { return false; } - await runScheduledTaskOrThrow(params.taskName); + await runScheduledTaskOrThrow({ + taskName: params.taskName, + env: params.env, + scriptPath: params.scriptPath, + }); writeFormattedLines( params.stdout, [ @@ -614,11 +639,152 @@ async function updateExistingScheduledTask(params: { return true; } -async function runScheduledTaskOrThrow(taskName: string): Promise { - const run = await execSchtasks(["/Run", "/TN", taskName]); +async function shouldFallbackScheduledTaskLaunch(params: { + env: GatewayServiceEnv; + scriptPath: string; +}): Promise { + const readLaunchObservation = async (): Promise<{ + state: "running" | "not-yet-run" | "other"; + signature: string; + }> => { + const runtime = await readScheduledTaskRuntime(params.env).catch(() => null); + if (runtime?.status === "running") { + return { + state: "running", + signature: [runtime.state, runtime.lastRunTime, runtime.lastRunResult, runtime.detail] + .filter(Boolean) + .join("|"), + }; + } + const normalizedResult = normalizeTaskResultCode(runtime?.lastRunResult); + if (normalizedResult && NOT_YET_RUN_RESULT_CODES.has(normalizedResult)) { + return { + state: "not-yet-run", + signature: [runtime?.state, runtime?.lastRunTime, runtime?.lastRunResult, runtime?.detail] + .filter(Boolean) + .join("|"), + }; + } + return { + state: "other", + signature: [runtime?.state, runtime?.lastRunTime, runtime?.lastRunResult, runtime?.detail] + .filter(Boolean) + .join("|"), + }; + }; + + const hasLaunchEvidence = async (): Promise => { + const port = await resolveScheduledTaskPort(params.env); + if (port) { + const listenerPids = await resolveScheduledTaskGatewayListenerPids(port); + if (listenerPids.length > 0) { + return true; + } + } + + if (process.platform !== "win32") { + return false; + } + + const scriptPathNeedle = normalizeLowercaseStringOrEmpty( + params.scriptPath.replaceAll("/", "\\"), + ); + if (!scriptPathNeedle) { + return false; + } + + const processSnapshot = spawnSync( + "powershell", + [ + "-NoProfile", + "-Command", + "Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress", + ], + { + encoding: "utf8", + timeout: 1_500, + windowsHide: true, + }, + ); + if (processSnapshot.error || processSnapshot.status !== 0) { + return false; + } + + type WindowsProcessSnapshotEntry = { + ProcessId?: number; + CommandLine?: string | null; + }; + + let parsedSnapshot: unknown; + try { + parsedSnapshot = JSON.parse(processSnapshot.stdout.trim() || "[]"); + } catch { + return false; + } + + const entries = (Array.isArray(parsedSnapshot) ? parsedSnapshot : [parsedSnapshot]).filter( + (entry): entry is WindowsProcessSnapshotEntry => typeof entry === "object" && entry !== null, + ); + const matchingTaskScriptProcess = entries.some((entry) => + normalizeLowercaseStringOrEmpty(entry.CommandLine ?? "") + .replaceAll("/", "\\") + .includes(scriptPathNeedle), + ); + if (matchingTaskScriptProcess) { + return true; + } + + if (!port) { + return false; + } + + return entries.some((entry) => { + const commandLine = normalizeLowercaseStringOrEmpty(entry.CommandLine ?? ""); + if (!commandLine) { + return false; + } + const argv = parseCmdScriptCommandLine(entry.CommandLine ?? ""); + if (!isGatewayArgv(argv, { allowGatewayBinary: true })) { + return false; + } + return parsePortFromProgramArguments(argv) === port; + }); + }; + + const initial = await readLaunchObservation(); + if (initial.state !== "not-yet-run") { + return false; + } + + const deadline = Date.now() + SCHEDULED_TASK_FALLBACK_TIMEOUT_MS; + while (Date.now() < deadline) { + await sleep(SCHEDULED_TASK_FALLBACK_POLL_MS); + const current = await readLaunchObservation(); + if (current.state !== "not-yet-run") { + return false; + } + if (current.signature !== initial.signature) { + return false; + } + } + return !(await hasLaunchEvidence()); +} + +async function runScheduledTaskOrThrow(params: { + taskName: string; + env: GatewayServiceEnv; + scriptPath: string; +}): Promise { + const run = await execSchtasks(["/Run", "/TN", params.taskName]); if (run.code !== 0) { throw new Error(`schtasks run failed: ${run.stderr || run.stdout}`.trim()); } + if ( + !(await shouldFallbackScheduledTaskLaunch({ env: params.env, scriptPath: params.scriptPath })) + ) { + return; + } + launchFallbackTaskScript(params.scriptPath); } async function activateScheduledTask(params: { @@ -679,7 +845,11 @@ async function activateScheduledTask(params: { throw new Error(`schtasks create failed: ${detail}`.trim()); } - await runScheduledTaskOrThrow(taskName); + await runScheduledTaskOrThrow({ + taskName, + env: params.env, + scriptPath: params.scriptPath, + }); // Ensure we don't end up writing to a clack spinner line (wizards show progress without a newline). writeFormattedLines( params.stdout, @@ -806,7 +976,11 @@ export async function restartScheduledTask({ } } } - await runScheduledTaskOrThrow(taskName); + await runScheduledTaskOrThrow({ + taskName, + env: effectiveEnv, + scriptPath: resolveTaskScriptPath(effectiveEnv), + }); stdout.write(`${formatLine("Restarted Scheduled Task", taskName)}\n`); return { outcome: "completed" }; } @@ -849,6 +1023,17 @@ export async function readScheduledTaskRuntime( } const parsed = parseSchtasksQuery(res.stdout || ""); const derived = deriveScheduledTaskRuntimeStatus(parsed); + if (derived.status !== "running") { + const observedRuntime = await resolveListenerBackedScheduledTaskRuntime(env); + if (observedRuntime) { + return { + ...observedRuntime, + state: parsed.status, + lastRunTime: parsed.lastRunTime, + lastRunResult: parsed.lastRunResult, + }; + } + } return { status: derived.status, state: parsed.status, diff --git a/src/infra/update-check.test.ts b/src/infra/update-check.test.ts index c5b398223d2..52500cc7350 100644 --- a/src/infra/update-check.test.ts +++ b/src/infra/update-check.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { runCommandWithTimeout } from "../process/exec.js"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { checkDepsStatus, @@ -251,4 +252,34 @@ describe("checkUpdateStatus", () => { }); }); }); + + it("treats symlinked git installs as git roots", async () => { + await withTempDir({ prefix: "openclaw-update-check-git-" }, async (base) => { + const repoRoot = path.join(base, "repo"); + const linkedRoot = path.join(base, "linked-openclaw"); + await fs.mkdir(repoRoot, { recursive: true }); + await fs.writeFile( + path.join(repoRoot, "package.json"), + JSON.stringify({ name: "openclaw", packageManager: "pnpm@10.0.0" }), + "utf8", + ); + await runCommandWithTimeout(["git", "init"], { cwd: repoRoot, timeoutMs: 1000 }); + await fs.symlink(repoRoot, linkedRoot); + + await expect( + checkUpdateStatus({ + root: linkedRoot, + includeRegistry: false, + fetchGit: false, + timeoutMs: 1000, + }), + ).resolves.toMatchObject({ + root: linkedRoot, + installKind: "git", + git: { + root: linkedRoot, + }, + }); + }); + }); }); diff --git a/src/infra/update-check.ts b/src/infra/update-check.ts index fd131aaa33e..83aa3f21340 100644 --- a/src/infra/update-check.ts +++ b/src/infra/update-check.ts @@ -392,12 +392,13 @@ export async function checkUpdateStatus(params: { }; } + const rootRealpath = await fs.realpath(root).catch(() => root); const [pm, gitRoot, registry] = await Promise.all([ detectPackageManager(root), detectGitRoot(root), params.includeRegistry ? fetchNpmLatestVersion({ timeoutMs }) : Promise.resolve(undefined), ]); - const isGit = gitRoot && path.resolve(gitRoot) === root; + const isGit = gitRoot && path.resolve(gitRoot) === path.resolve(rootRealpath); const installKind: UpdateCheckResult["installKind"] = isGit ? "git" : "package"; const [git, deps] = await Promise.all([ diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 3d3c5f1c56d..27548f7de86 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -206,7 +206,12 @@ describe("runGatewayUpdate", () => { argv: string[], options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number }, ) => Promise, - options?: { channel?: "stable" | "beta" | "dev"; tag?: string; cwd?: string }, + options?: { + channel?: "stable" | "beta" | "dev"; + tag?: string; + cwd?: string; + devTargetRef?: string; + }, ) { return runGatewayUpdate({ cwd: options?.cwd ?? tempDir, @@ -214,12 +219,18 @@ describe("runGatewayUpdate", () => { timeoutMs: 5000, ...(options?.channel ? { channel: options.channel } : {}), ...(options?.tag ? { tag: options.tag } : {}), + ...(options?.devTargetRef ? { devTargetRef: options.devTargetRef } : {}), }); } async function runWithRunner( runner: (argv: string[]) => Promise, - options?: { channel?: "stable" | "beta" | "dev"; tag?: string; cwd?: string }, + options?: { + channel?: "stable" | "beta" | "dev"; + tag?: string; + cwd?: string; + devTargetRef?: string; + }, ) { return runWithCommand(runner, options); } @@ -889,6 +900,247 @@ describe("runGatewayUpdate", () => { platformSpy.mockRestore(); } }); + it("pins dev updates to an explicit target ref when requested", async () => { + await setupGitPackageManagerFixture(); + const calls: string[] = []; + const targetSha = "f2fdb9d1253ce3f227ccaa6cb0e3b664a32be4ee"; + const doctorNodePath = await resolveStableNodePath(process.execPath); + const doctorCommand = `${doctorNodePath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive --fix`; + + const runCommand = async ( + argv: string[], + _options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number }, + ) => { + const key = argv.join(" "); + calls.push(key); + + if (key === `git -C ${tempDir} rev-parse --show-toplevel`) { + return { stdout: tempDir, stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse HEAD`) { + return { + stdout: `${calls.includes(`git -C ${tempDir} checkout --detach ${targetSha}`) ? targetSha : "abc123"}\n`, + stderr: "", + code: 0, + }; + } + if (key === `git -C ${tempDir} rev-parse --abbrev-ref HEAD`) { + return { stdout: "main", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} fetch --all --prune --tags`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse ${targetSha}`) { + return { stdout: `${targetSha}\n`, stderr: "", code: 0 }; + } + if ( + key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) && + key.endsWith(` ${targetSha}`) && + preflightPrefixPattern.test(key) + ) { + return { stdout: `HEAD is now at ${targetSha}`, stderr: "", code: 0 }; + } + if ( + key.startsWith("git -C /tmp/") && + key.includes(` checkout --detach ${targetSha}`) && + preflightPrefixPattern.test(key) + ) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm install" || key === "pnpm build" || key === "pnpm lint") { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm ui:build") { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === doctorCommand) { + return { stdout: "", stderr: "", code: 0 }; + } + if ( + key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) && + preflightPrefixPattern.test(key) + ) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} checkout --detach ${targetSha}`) { + return { stdout: "", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }; + + const result = await runWithCommand(runCommand, { channel: "dev", devTargetRef: targetSha }); + + expect(result.status).toBe("ok"); + expect(calls).toContain(`git -C ${tempDir} rev-parse ${targetSha}`); + expect(calls).toContain(`git -C ${tempDir} checkout --detach ${targetSha}`); + expect(calls).not.toContain(`git -C ${tempDir} rev-parse @{upstream}`); + expect(calls).not.toContain(`git -C ${tempDir} rebase ${targetSha}`); + }); + + it("resolves symbolic dev target refs from the fetched remote branch", async () => { + await setupGitPackageManagerFixture(); + const calls: string[] = []; + const targetSha = "2222222222222222222222222222222222222222"; + const doctorNodePath = await resolveStableNodePath(process.execPath); + const doctorCommand = `${doctorNodePath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive --fix`; + + const runCommand = async ( + argv: string[], + _options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number }, + ) => { + const key = argv.join(" "); + calls.push(key); + + if (key === `git -C ${tempDir} rev-parse --show-toplevel`) { + return { stdout: tempDir, stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse HEAD`) { + return { + stdout: `${calls.includes(`git -C ${tempDir} checkout --detach ${targetSha}`) ? targetSha : "abc123"}\n`, + stderr: "", + code: 0, + }; + } + if (key === `git -C ${tempDir} rev-parse --abbrev-ref HEAD`) { + return { stdout: "main", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} fetch --all --prune --tags`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse refs/remotes/origin/main`) { + return { stdout: `${targetSha}\n`, stderr: "", code: 0 }; + } + if ( + key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) && + key.endsWith(` ${targetSha}`) && + preflightPrefixPattern.test(key) + ) { + return { stdout: `HEAD is now at ${targetSha}`, stderr: "", code: 0 }; + } + if ( + key.startsWith("git -C /tmp/") && + key.includes(` checkout --detach ${targetSha}`) && + preflightPrefixPattern.test(key) + ) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm install" || key === "pnpm build" || key === "pnpm lint") { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm ui:build") { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === doctorCommand) { + return { stdout: "", stderr: "", code: 0 }; + } + if ( + key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) && + preflightPrefixPattern.test(key) + ) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} checkout --detach ${targetSha}`) { + return { stdout: "", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }; + + const result = await runWithCommand(runCommand, { channel: "dev", devTargetRef: "main" }); + + expect(result.status).toBe("ok"); + expect(calls).toContain(`git -C ${tempDir} rev-parse refs/remotes/origin/main`); + expect(calls).not.toContain(`git -C ${tempDir} rev-parse main`); + expect(calls).toContain(`git -C ${tempDir} checkout --detach ${targetSha}`); + expect(calls).not.toContain(`git -C ${tempDir} rev-parse @{upstream}`); + expect(calls).not.toContain(`git -C ${tempDir} rebase ${targetSha}`); + }); + + it("falls back to the cloned cwd when git root probing misses a fresh checkout", async () => { + await setupGitPackageManagerFixture(); + await fs.mkdir(path.join(tempDir, ".git"), { recursive: true }); + const calls: string[] = []; + const targetSha = "3333333333333333333333333333333333333333"; + const gitRoot = await fs.realpath(tempDir).catch(() => tempDir); + const doctorNodePath = await resolveStableNodePath(process.execPath); + const doctorCommand = `${doctorNodePath} ${path.join(gitRoot, "openclaw.mjs")} doctor --non-interactive --fix`; + + const runCommand = async ( + argv: string[], + _options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number }, + ) => { + const key = argv.join(" "); + calls.push(key); + + if (key === `git -C ${tempDir} rev-parse --show-toplevel`) { + return { stdout: "", stderr: "fatal: not a git repository", code: 128 }; + } + if (key === `git -C ${gitRoot} rev-parse HEAD`) { + return { + stdout: `${calls.includes(`git -C ${gitRoot} checkout --detach ${targetSha}`) ? targetSha : "abc123"}\n`, + stderr: "", + code: 0, + }; + } + if (key === `git -C ${gitRoot} rev-parse --abbrev-ref HEAD`) { + return { stdout: "main", stderr: "", code: 0 }; + } + if (key === `git -C ${gitRoot} status --porcelain -- :!dist/control-ui/`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${gitRoot} fetch --all --prune --tags`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${gitRoot} rev-parse refs/remotes/origin/main`) { + return { stdout: `${targetSha}\n`, stderr: "", code: 0 }; + } + if ( + key.startsWith(`git -C ${gitRoot} worktree add --detach /tmp/`) && + key.endsWith(` ${targetSha}`) && + preflightPrefixPattern.test(key) + ) { + return { stdout: `HEAD is now at ${targetSha}`, stderr: "", code: 0 }; + } + if ( + key.startsWith("git -C /tmp/") && + key.includes(` checkout --detach ${targetSha}`) && + preflightPrefixPattern.test(key) + ) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm install" || key === "pnpm build" || key === "pnpm lint") { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm ui:build") { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === doctorCommand) { + return { stdout: "", stderr: "", code: 0 }; + } + if ( + key.startsWith(`git -C ${gitRoot} worktree remove --force /tmp/`) && + preflightPrefixPattern.test(key) + ) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${gitRoot} checkout --detach ${targetSha}`) { + return { stdout: "", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }; + + const result = await runWithCommand(runCommand, { channel: "dev", devTargetRef: "main" }); + + expect(result.status).toBe("ok"); + expect(calls).toContain(`git -C ${tempDir} rev-parse --show-toplevel`); + expect(calls).toContain(`git -C ${gitRoot} checkout --detach ${targetSha}`); + expect(calls).not.toContain(`git -C ${gitRoot} rev-parse @{upstream}`); + }); it("does not fall back to npm scripts when a pnpm repo cannot bootstrap pnpm", async () => { await setupGitPackageManagerFixture(); diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 822fa9b9f0f..96d193d53fc 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -87,6 +87,7 @@ type UpdateRunnerOptions = { argv1?: string; tag?: string; channel?: UpdateChannel; + devTargetRef?: string; timeoutMs?: number; runCommand?: CommandRunner; progress?: UpdateStepProgress; @@ -355,6 +356,76 @@ function normalizeTag(tag?: string) { return normalizePackageTagInput(tag, ["openclaw", DEFAULT_PACKAGE_NAME]) ?? "latest"; } +function normalizeDevTargetRef(value?: string | null): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function looksLikeFullCommitSha(value: string): boolean { + return /^[0-9a-f]{40}$/i.test(value.trim()); +} + +function buildDevTargetRefResolutionCandidates(devTargetRef: string): string[] { + const trimmed = devTargetRef.trim(); + const candidates: string[] = []; + const addCandidate = (candidate?: string | null) => { + if (!candidate || candidates.includes(candidate)) { + return; + } + candidates.push(candidate); + }; + + if (looksLikeFullCommitSha(trimmed)) { + addCandidate(trimmed); + return candidates; + } + + if (trimmed.startsWith("refs/remotes/")) { + addCandidate(trimmed); + return candidates; + } + + if (trimmed.startsWith("refs/heads/")) { + addCandidate(`refs/remotes/origin/${trimmed.slice("refs/heads/".length)}`); + return candidates; + } + + if (trimmed.startsWith("origin/")) { + addCandidate(`refs/remotes/${trimmed}`); + return candidates; + } + + if (trimmed.startsWith("refs/tags/")) { + addCandidate(`${trimmed}^{}`); + addCandidate(trimmed); + return candidates; + } + + // Resolve plain branch names from the freshly fetched remote ref instead of + // a possibly stale local branch checkout. + addCandidate(`refs/remotes/origin/${trimmed}`); + addCandidate(`refs/tags/${trimmed}^{}`); + addCandidate(`refs/tags/${trimmed}`); + return candidates; +} + +async function resolveComparablePath(target: string): Promise { + return await fs.realpath(target).catch(() => path.resolve(target)); +} + +async function pathsReferToSameLocation(left: string, right: string): Promise { + return (await resolveComparablePath(left)) === (await resolveComparablePath(right)); +} + +async function looksLikeGitCheckout(root: string): Promise { + try { + await fs.access(path.join(root, ".git")); + return true; + } catch { + return false; + } +} + function shouldRetryWindowsInstallIgnoringScripts(manager: "pnpm" | "bun" | "npm"): boolean { return process.platform === "win32" && manager === "pnpm"; } @@ -476,7 +547,17 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< const pkgRoot = await findPackageRoot(candidates); let gitRoot = await resolveGitRoot(runCommand, candidates, timeoutMs); - if (gitRoot && pkgRoot && path.resolve(gitRoot) !== path.resolve(pkgRoot)) { + if (!gitRoot && pkgRoot) { + const cwdRoot = normalizeDir(opts.cwd); + if ( + cwdRoot && + (await pathsReferToSameLocation(cwdRoot, pkgRoot)) && + (await looksLikeGitCheckout(cwdRoot)) + ) { + gitRoot = await resolveComparablePath(cwdRoot); + } + } + if (gitRoot && pkgRoot && !(await pathsReferToSameLocation(gitRoot, pkgRoot))) { gitRoot = null; } @@ -491,7 +572,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< }; } - if (gitRoot && pkgRoot && path.resolve(gitRoot) === path.resolve(pkgRoot)) { + if (gitRoot && pkgRoot && (await pathsReferToSameLocation(gitRoot, pkgRoot))) { // Get current SHA (not a visible step, no progress) const beforeShaResult = await runCommand(["git", "-C", gitRoot, "rev-parse", "HEAD"], { cwd: gitRoot, @@ -500,8 +581,9 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< const beforeSha = beforeShaResult.stdout.trim() || null; const beforeVersion = await readPackageVersion(gitRoot); const channel: UpdateChannel = opts.channel ?? "dev"; + const devTargetRef = channel === "dev" ? normalizeDevTargetRef(opts.devTargetRef) : null; const branch = channel === "dev" ? await readBranchName(runCommand, gitRoot, timeoutMs) : null; - const needsCheckoutMain = channel === "dev" && branch !== DEV_BRANCH; + const needsCheckoutMain = channel === "dev" && !devTargetRef && branch !== DEV_BRANCH; gitTotalSteps = channel === "dev" ? (needsCheckoutMain ? 11 : 10) : 9; const buildGitErrorResult = (reason: string): UpdateRunResult => ({ status: "error", @@ -557,90 +639,135 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< } } - const upstreamStep = await runStep( - step( - "upstream check", - [ - "git", - "-C", - gitRoot, - "rev-parse", - "--abbrev-ref", - "--symbolic-full-name", - "@{upstream}", - ], - gitRoot, - ), - ); - steps.push(upstreamStep); - if (upstreamStep.exitCode !== 0) { - return { - status: "skipped", - mode: "git", - root: gitRoot, - reason: "no-upstream", - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }; - } - const fetchStep = await runStep( step("git fetch", ["git", "-C", gitRoot, "fetch", "--all", "--prune", "--tags"], gitRoot), ); steps.push(fetchStep); + let preflightBaseSha: string | null = null; + let candidates: string[] = []; + if (devTargetRef) { + let targetSha: string | null = null; + for (const targetRefCandidate of buildDevTargetRefResolutionCandidates(devTargetRef)) { + const targetShaStep = await runStep( + step( + `git rev-parse ${targetRefCandidate}`, + ["git", "-C", gitRoot, "rev-parse", targetRefCandidate], + gitRoot, + ), + ); + steps.push(targetShaStep); + const resolvedTargetSha = targetShaStep.stdoutTail?.trim(); + if (targetShaStep.exitCode === 0 && resolvedTargetSha) { + targetSha = resolvedTargetSha; + break; + } + } + if (!targetSha) { + return { + status: "error", + mode: "git", + root: gitRoot, + reason: "no-target-sha", + before: { sha: beforeSha, version: beforeVersion }, + steps, + durationMs: Date.now() - startedAt, + }; + } + preflightBaseSha = targetSha; + candidates = [targetSha]; + } else { + const upstreamStep = await runStep( + step( + "upstream check", + [ + "git", + "-C", + gitRoot, + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{upstream}", + ], + gitRoot, + ), + ); + steps.push(upstreamStep); + if (upstreamStep.exitCode !== 0) { + return { + status: "skipped", + mode: "git", + root: gitRoot, + reason: "no-upstream", + before: { sha: beforeSha, version: beforeVersion }, + steps, + durationMs: Date.now() - startedAt, + }; + } - const upstreamShaStep = await runStep( - step( - "git rev-parse @{upstream}", - ["git", "-C", gitRoot, "rev-parse", "@{upstream}"], - gitRoot, - ), - ); - steps.push(upstreamShaStep); - const upstreamSha = upstreamShaStep.stdoutTail?.trim(); - if (!upstreamShaStep.stdoutTail || !upstreamSha) { - return { - status: "error", - mode: "git", - root: gitRoot, - reason: "no-upstream-sha", - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }; + const upstreamShaStep = await runStep( + step( + "git rev-parse @{upstream}", + ["git", "-C", gitRoot, "rev-parse", "@{upstream}"], + gitRoot, + ), + ); + steps.push(upstreamShaStep); + const upstreamSha = upstreamShaStep.stdoutTail?.trim(); + if (!upstreamShaStep.stdoutTail || !upstreamSha) { + return { + status: "error", + mode: "git", + root: gitRoot, + reason: "no-upstream-sha", + before: { sha: beforeSha, version: beforeVersion }, + steps, + durationMs: Date.now() - startedAt, + }; + } + + const revListStep = await runStep( + step( + "git rev-list", + ["git", "-C", gitRoot, "rev-list", `--max-count=${PREFLIGHT_MAX_COMMITS}`, upstreamSha], + gitRoot, + ), + ); + steps.push(revListStep); + if (revListStep.exitCode !== 0) { + return { + status: "error", + mode: "git", + root: gitRoot, + reason: "preflight-revlist-failed", + before: { sha: beforeSha, version: beforeVersion }, + steps, + durationMs: Date.now() - startedAt, + }; + } + + candidates = (revListStep.stdoutTail ?? "") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + if (candidates.length === 0) { + return { + status: "error", + mode: "git", + root: gitRoot, + reason: "preflight-no-candidates", + before: { sha: beforeSha, version: beforeVersion }, + steps, + durationMs: Date.now() - startedAt, + }; + } + preflightBaseSha = upstreamSha; } - - const revListStep = await runStep( - step( - "git rev-list", - ["git", "-C", gitRoot, "rev-list", `--max-count=${PREFLIGHT_MAX_COMMITS}`, upstreamSha], - gitRoot, - ), - ); - steps.push(revListStep); - if (revListStep.exitCode !== 0) { + if (!preflightBaseSha) { return { status: "error", mode: "git", root: gitRoot, - reason: "preflight-revlist-failed", - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }; - } - - const candidates = (revListStep.stdoutTail ?? "") - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - if (candidates.length === 0) { - return { - status: "error", - mode: "git", - root: gitRoot, - reason: "preflight-no-candidates", + reason: "preflight-base-missing", before: { sha: beforeSha, version: beforeVersion }, steps, durationMs: Date.now() - startedAt, @@ -670,7 +797,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< const worktreeStep = await runStep( step( "preflight worktree", - ["git", "-C", gitRoot, "worktree", "add", "--detach", worktreeDir, upstreamSha], + ["git", "-C", gitRoot, "worktree", "add", "--detach", worktreeDir, preflightBaseSha], gitRoot, ), ); @@ -815,33 +942,47 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< }; } - const rebaseStep = await runStep( - step("git rebase", ["git", "-C", gitRoot, "rebase", selectedSha], gitRoot), - ); - steps.push(rebaseStep); - if (rebaseStep.exitCode !== 0) { - const abortResult = await runCommand(["git", "-C", gitRoot, "rebase", "--abort"], { - cwd: gitRoot, - timeoutMs, - }); - steps.push({ - name: "git rebase --abort", - command: "git rebase --abort", - cwd: gitRoot, - durationMs: 0, - exitCode: abortResult.code, - stdoutTail: trimLogTail(abortResult.stdout, MAX_LOG_CHARS), - stderrTail: trimLogTail(abortResult.stderr, MAX_LOG_CHARS), - }); - return { - status: "error", - mode: "git", - root: gitRoot, - reason: "rebase-failed", - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }; + if (devTargetRef) { + const failure = await runGitCheckoutOrFail(`git checkout ${selectedSha}`, [ + "git", + "-C", + gitRoot, + "checkout", + "--detach", + selectedSha, + ]); + if (failure) { + return failure; + } + } else { + const rebaseStep = await runStep( + step("git rebase", ["git", "-C", gitRoot, "rebase", selectedSha], gitRoot), + ); + steps.push(rebaseStep); + if (rebaseStep.exitCode !== 0) { + const abortResult = await runCommand(["git", "-C", gitRoot, "rebase", "--abort"], { + cwd: gitRoot, + timeoutMs, + }); + steps.push({ + name: "git rebase --abort", + command: "git rebase --abort", + cwd: gitRoot, + durationMs: 0, + exitCode: abortResult.code, + stdoutTail: trimLogTail(abortResult.stdout, MAX_LOG_CHARS), + stderrTail: trimLogTail(abortResult.stderr, MAX_LOG_CHARS), + }); + return { + status: "error", + mode: "git", + root: gitRoot, + reason: "rebase-failed", + before: { sha: beforeSha, version: beforeVersion }, + steps, + durationMs: Date.now() - startedAt, + }; + } } } else { const fetchStep = await runStep( diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts new file mode 100644 index 00000000000..fc903d57a74 --- /dev/null +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -0,0 +1,486 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer as createNetServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + buildWindowsDevUpdateToolchainCheckScript, + buildWindowsFreshShellVersionCheckScript, + buildWindowsPathBootstrapScript, + canConnectToLoopbackPort, + buildDiscordSmokeGuildsConfig, + buildRealUpdateEnv, + isImmutableReleaseRef, + looksLikeReleaseVersionRef, + normalizeRequestedRef, + normalizeWindowsCommandShimPath, + normalizeWindowsInstalledCliPath, + parseArgs, + packageHasScript, + readInstalledVersion, + readRunnerOverrideEnv, + resolveExplicitBaselineVersion, + resolveDevUpdateVerificationRef, + resolveInstalledPrefixDirFromCliPath, + resolvePublishedInstallerUrl, + resolveRequestedSuites, + resolveRunnerMatrix, + resolveStaticFileContentType, + shouldExerciseManagedGatewayLifecycleAfterInstall, + shouldSkipInstallerDaemonHealthCheck, + shouldStopManagedGatewayBeforeManualFallback, + shouldRunMainChannelDevUpdate, + shouldUseManagedGatewayForInstallerRuntime, + shouldUseManagedGatewayService, + verifyDevUpdateStatus, +} from "../../scripts/openclaw-cross-os-release-checks.ts"; + +describe("scripts/openclaw-cross-os-release-checks", () => { + it("treats explicit empty-string args as values instead of boolean flags", () => { + expect(parseArgs(["--ubuntu-runner", "", "--mode", "both"])).toEqual({ + "ubuntu-runner": "", + mode: "both", + }); + }); + + it("detects release refs and keeps branch refs out of release-only logic", () => { + expect(looksLikeReleaseVersionRef("2026.4.5")).toBe(true); + expect(looksLikeReleaseVersionRef("refs/tags/v2026.4.5-beta.1")).toBe(true); + expect(looksLikeReleaseVersionRef("v2026.4.5-beta.1")).toBe(true); + expect(looksLikeReleaseVersionRef("v2026.4.7-1")).toBe(true); + expect(looksLikeReleaseVersionRef("main")).toBe(false); + expect(looksLikeReleaseVersionRef("codex/cross-os-release-checks")).toBe(false); + }); + + it("normalizes full Git refs before suite and update decisions", () => { + expect(normalizeRequestedRef(" refs/heads/main ")).toBe("main"); + expect(normalizeRequestedRef("refs/tags/v2026.4.14")).toBe("v2026.4.14"); + expect(isImmutableReleaseRef("refs/tags/test-tag")).toBe(true); + expect(resolveRequestedSuites("both", "refs/tags/v2026.4.14")).toEqual([ + "packaged-fresh", + "installer-fresh", + "packaged-upgrade", + ]); + expect(resolveRequestedSuites("both", "refs/tags/test-tag")).toEqual([ + "packaged-fresh", + "installer-fresh", + "packaged-upgrade", + ]); + expect(shouldRunMainChannelDevUpdate("refs/heads/main")).toBe(true); + expect(shouldRunMainChannelDevUpdate("refs/tags/main")).toBe(false); + }); + + it("skips the dev-update suite for immutable release refs", () => { + expect(resolveRequestedSuites("both", "v2026.4.5")).toEqual([ + "packaged-fresh", + "installer-fresh", + "packaged-upgrade", + ]); + }); + + it("skips dev-update for non-main branch validation refs", () => { + expect(resolveRequestedSuites("both", "codex/cross-os-release-checks")).toEqual([ + "packaged-fresh", + "installer-fresh", + "packaged-upgrade", + ]); + }); + + it("keeps dev-update enabled for main validation refs", () => { + expect(resolveRequestedSuites("both", "main")).toEqual([ + "packaged-fresh", + "installer-fresh", + "packaged-upgrade", + "dev-update", + ]); + }); + + it("skips dev-update for pinned commit refs", () => { + expect(resolveRequestedSuites("both", "08753a1d793c040b101c8a26c43445dbbab14995")).toEqual([ + "packaged-fresh", + "installer-fresh", + "packaged-upgrade", + ]); + }); + + it("builds a suite-aware runner matrix with the beefy Windows default", () => { + const matrix = resolveRunnerMatrix({ + mode: "both", + ref: "main", + ubuntuRunner: "", + windowsRunner: "", + macosRunner: "", + varUbuntuRunner: "", + varWindowsRunner: "", + varMacosRunner: "", + }); + + expect(matrix.include).toHaveLength(12); + expect(matrix.include).toContainEqual( + expect.objectContaining({ + os_id: "windows", + runner: "blacksmith-32vcpu-windows-2025", + suite: "dev-update", + lane: "upgrade", + }), + ); + expect(matrix.include).toContainEqual( + expect.objectContaining({ + os_id: "ubuntu", + suite: "installer-fresh", + lane: "fresh", + }), + ); + }); + + it("can rebuild the Windows PATH with or without current-process entries", () => { + expect(buildWindowsPathBootstrapScript()).toContain("@($userPath, $machinePath, $env:Path)"); + const persistedOnlyScript = buildWindowsPathBootstrapScript({ + includeCurrentProcessPath: false, + }); + expect(persistedOnlyScript).toContain("@($userPath, $machinePath)"); + expect(persistedOnlyScript).not.toContain("@($userPath, $machinePath, $env:Path)"); + }); + + it("prefers the freshly installed Windows CLI under npm's prefix before PATH lookup", () => { + const script = buildWindowsFreshShellVersionCheckScript({ + expectedNeedle: "2026.4.14", + }); + expect(script).toContain(buildWindowsPathBootstrapScript()); + expect(script).not.toContain( + buildWindowsPathBootstrapScript({ includeCurrentProcessPath: false }), + ); + expect(script).toContain("Get-Command npm.cmd -ErrorAction SilentlyContinue"); + expect(script).toContain('$env:Path = "$npmPrefix;$env:Path"'); + expect(script).toContain("(Join-Path $npmPrefix 'openclaw.cmd')"); + expect(script).toContain("$cmd = Get-Command openclaw -ErrorAction Stop"); + }); + + it("keeps Windows dev-update toolchain checks compatible with setup-node PATH shims", () => { + const script = buildWindowsDevUpdateToolchainCheckScript(); + expect(script).toContain(buildWindowsPathBootstrapScript()); + expect(script).not.toContain( + buildWindowsPathBootstrapScript({ includeCurrentProcessPath: false }), + ); + expect(script).toContain("$pnpmPath = Resolve-CommandPath 'pnpm'"); + expect(script).toContain("$corepackPath = Resolve-CommandPath 'corepack'"); + expect(script).toContain("$npmPath = Resolve-CommandPath 'npm'"); + }); + + it("prefers workflow-injected runner override env names over legacy ones", () => { + expect( + readRunnerOverrideEnv({ + VAR_UBUNTU_RUNNER: "workflow-linux", + VAR_WINDOWS_RUNNER: "workflow-windows", + VAR_MACOS_RUNNER: "workflow-macos", + OPENCLAW_RELEASE_CHECKS_UBUNTU_RUNNER: "legacy-linux", + OPENCLAW_RELEASE_CHECKS_WINDOWS_RUNNER: "legacy-windows", + OPENCLAW_RELEASE_CHECKS_MACOS_RUNNER: "legacy-macos", + }), + ).toEqual({ + varUbuntuRunner: "workflow-linux", + varWindowsRunner: "workflow-windows", + varMacosRunner: "workflow-macos", + }); + }); + + it("falls back to legacy runner override env names when workflow vars are blank", () => { + expect( + readRunnerOverrideEnv({ + VAR_UBUNTU_RUNNER: "", + VAR_WINDOWS_RUNNER: " ", + VAR_MACOS_RUNNER: "", + OPENCLAW_RELEASE_CHECKS_UBUNTU_RUNNER: "legacy-linux", + OPENCLAW_RELEASE_CHECKS_WINDOWS_RUNNER: "legacy-windows", + OPENCLAW_RELEASE_CHECKS_MACOS_RUNNER: "legacy-macos", + }), + ).toEqual({ + varUbuntuRunner: "legacy-linux", + varWindowsRunner: "legacy-windows", + varMacosRunner: "legacy-macos", + }); + }); + + it("serves installer scripts as UTF-8 text and package payloads as binary", () => { + expect(resolveStaticFileContentType("scripts/install.sh")).toBe("text/plain; charset=utf-8"); + expect(resolveStaticFileContentType("scripts/install.ps1")).toBe("text/plain; charset=utf-8"); + expect(resolveStaticFileContentType("openclaw-2026.4.14.tgz")).toBe("application/octet-stream"); + }); + + it("uses the published installer URLs for native installer lanes", () => { + expect(resolvePublishedInstallerUrl("darwin")).toBe("https://openclaw.ai/install.sh"); + expect(resolvePublishedInstallerUrl("linux")).toBe("https://openclaw.ai/install.sh"); + expect(resolvePublishedInstallerUrl("win32")).toBe("https://openclaw.ai/install.ps1"); + }); + + it("uses managed gateway services only on native Windows runners", () => { + expect(shouldUseManagedGatewayService("win32")).toBe(true); + expect(shouldUseManagedGatewayService("darwin")).toBe(false); + expect(shouldUseManagedGatewayService("linux")).toBe(false); + }); + + it("keeps the Windows installer runtime on the manual gateway after managed lifecycle checks", () => { + expect(shouldExerciseManagedGatewayLifecycleAfterInstall("win32")).toBe(true); + expect(shouldUseManagedGatewayForInstallerRuntime("win32")).toBe(false); + expect(shouldExerciseManagedGatewayLifecycleAfterInstall("darwin")).toBe(false); + expect(shouldUseManagedGatewayForInstallerRuntime("darwin")).toBe(false); + }); + + it("stops the managed gateway before the manual fallback only on Windows", () => { + expect(shouldStopManagedGatewayBeforeManualFallback("win32")).toBe(true); + expect(shouldStopManagedGatewayBeforeManualFallback("darwin")).toBe(false); + expect(shouldStopManagedGatewayBeforeManualFallback("linux")).toBe(false); + }); + + it("skips daemon health during installed onboarding only on native Windows", () => { + expect(shouldSkipInstallerDaemonHealthCheck("win32")).toBe(true); + expect(shouldSkipInstallerDaemonHealthCheck("darwin")).toBe(false); + expect(shouldSkipInstallerDaemonHealthCheck("linux")).toBe(false); + }); + + it("normalizes Windows installed CLI paths to the cmd shim", () => { + expect( + normalizeWindowsInstalledCliPath( + String.raw`C:\Users\runner\AppData\Roaming\npm\openclaw.ps1`, + ), + ).toBe(String.raw`C:\Users\runner\AppData\Roaming\npm\openclaw.cmd`); + expect( + normalizeWindowsInstalledCliPath( + String.raw`C:\Users\runner\AppData\Roaming\npm\openclaw.cmd`, + ), + ).toBe(String.raw`C:\Users\runner\AppData\Roaming\npm\openclaw.cmd`); + }); + + it("normalizes generic Windows PowerShell shims to cmd shims", () => { + expect(normalizeWindowsCommandShimPath(String.raw`C:\Program Files\nodejs\pnpm.ps1`)).toBe( + String.raw`C:\Program Files\nodejs\pnpm.cmd`, + ); + expect(normalizeWindowsCommandShimPath(String.raw`C:\Program Files\nodejs\corepack.ps1`)).toBe( + String.raw`C:\Program Files\nodejs\corepack.cmd`, + ); + expect(normalizeWindowsCommandShimPath(String.raw`C:\Program Files\nodejs\node.exe`)).toBe( + String.raw`C:\Program Files\nodejs\node.exe`, + ); + }); + + it("derives the installed prefix from resolved CLI paths", () => { + expect( + resolveInstalledPrefixDirFromCliPath( + String.raw`C:\Users\runner\AppData\Roaming\npm\openclaw.ps1`, + "win32", + ), + ).toBe(String.raw`C:\Users\runner\AppData\Roaming\npm`); + expect( + resolveInstalledPrefixDirFromCliPath("/Users/runner/.npm-global/bin/openclaw", "darwin"), + ).toBe("/Users/runner/.npm-global"); + }); + + it("detects whether a managed gateway listener is still reachable on loopback", async () => { + const server = createNetServer(); + await new Promise((resolvePromise) => { + server.listen(0, "127.0.0.1", resolvePromise); + }); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + expect(await canConnectToLoopbackPort(port)).toBe(true); + await new Promise((resolvePromise) => { + server.close(resolvePromise); + }); + expect(await canConnectToLoopbackPort(port)).toBe(false); + }); + + it("writes Discord smoke config using the strict guild channel schema", () => { + expect(buildDiscordSmokeGuildsConfig("guild-123", "channel-456")).toEqual({ + "guild-123": { + channels: { + "channel-456": { + enabled: true, + requireMention: false, + }, + }, + }, + }); + }); + + it("keeps the dev-update lane for main only", () => { + expect(shouldRunMainChannelDevUpdate("main")).toBe(true); + expect(shouldRunMainChannelDevUpdate("08753a1d793c040b101c8a26c43445dbbab14995")).toBe(false); + expect(shouldRunMainChannelDevUpdate(" codex/cross-os-release-checks-full-native-e2e ")).toBe( + false, + ); + expect(shouldRunMainChannelDevUpdate("v2026.4.14")).toBe(false); + }); + + it("verifies main dev updates against the prepared source sha when available", () => { + expect(resolveDevUpdateVerificationRef("main")).toBe("main"); + expect( + resolveDevUpdateVerificationRef("main", "08753a1d793c040b101c8a26c43445dbbab14995"), + ).toBe("08753a1d793c040b101c8a26c43445dbbab14995"); + expect( + resolveDevUpdateVerificationRef( + "refs/heads/main", + "08753a1d793c040b101c8a26c43445dbbab14995", + ), + ).toBe("08753a1d793c040b101c8a26c43445dbbab14995"); + expect(resolveDevUpdateVerificationRef("codex/cross-os-release-checks-full-native-e2e")).toBe( + "codex/cross-os-release-checks-full-native-e2e", + ); + }); + + it("drops the bundled plugin postinstall disable flag for real updater calls", () => { + expect( + buildRealUpdateEnv({ + FOO: "bar", + OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL: "1", + }), + ).toEqual({ + FOO: "bar", + }); + }); + + it("only treats pinned baseline specs as exact installer version assertions", () => { + expect(resolveExplicitBaselineVersion("")).toBe(""); + expect(resolveExplicitBaselineVersion("openclaw@latest")).toBe(""); + expect(resolveExplicitBaselineVersion("openclaw@2026.4.10")).toBe("2026.4.10"); + expect(resolveExplicitBaselineVersion("2026.4.10")).toBe("2026.4.10"); + }); + + it("reads an installed baseline version without requiring build metadata", () => { + const prefixDir = mkdtempSync(join(tmpdir(), "openclaw-cross-os-installed-version-")); + try { + const packageRoot = + process.platform === "win32" + ? join(prefixDir, "node_modules", "openclaw") + : join(prefixDir, "lib", "node_modules", "openclaw"); + mkdirSync(packageRoot, { recursive: true }); + writeFileSync( + join(packageRoot, "package.json"), + JSON.stringify({ + name: "openclaw", + version: "2026.4.10", + }), + "utf8", + ); + + expect(readInstalledVersion(prefixDir)).toBe("2026.4.10"); + } finally { + rmSync(prefixDir, { recursive: true, force: true }); + } + }); + + it("treats missing package scripts as optional in older refs", () => { + const packageRoot = mkdtempSync(join(tmpdir(), "openclaw-cross-os-scripts-")); + try { + writeFileSync( + join(packageRoot, "package.json"), + JSON.stringify({ + name: "openclaw", + scripts: { + build: "pnpm build", + }, + }), + "utf8", + ); + + expect(packageHasScript(packageRoot, "build")).toBe(true); + expect(packageHasScript(packageRoot, "ui:build")).toBe(false); + } finally { + rmSync(packageRoot, { recursive: true, force: true }); + } + }); + + it("accepts a git main dev-channel update status payload", () => { + expect(() => + verifyDevUpdateStatus( + JSON.stringify({ + update: { + installKind: "git", + git: { + branch: "main", + }, + }, + channel: { + value: "dev", + }, + }), + ), + ).not.toThrow(); + }); + + it("accepts a git dev-channel payload for a requested non-main branch", () => { + expect(() => + verifyDevUpdateStatus( + JSON.stringify({ + update: { + installKind: "git", + git: { + branch: "codex/cross-os-release-checks-full-native-e2e", + sha: "08753a1d793c040b101c8a26c43445dbbab14995", + }, + }, + channel: { + value: "dev", + }, + }), + { ref: "codex/cross-os-release-checks-full-native-e2e" }, + ), + ).not.toThrow(); + }); + + it("accepts a git dev-channel payload pinned to a prepared source sha", () => { + expect(() => + verifyDevUpdateStatus( + JSON.stringify({ + update: { + installKind: "git", + git: { + branch: "main", + sha: "08753a1d793c040b101c8a26c43445dbbab14995", + }, + }, + channel: { + value: "dev", + }, + }), + { ref: "08753a1d793c040b101c8a26c43445dbbab14995" }, + ), + ).not.toThrow(); + }); + + it("accepts uppercase requested commit shas when update status reports lowercase", () => { + expect(() => + verifyDevUpdateStatus( + JSON.stringify({ + update: { + installKind: "git", + git: { + sha: "08753a1d793c040b101c8a26c43445dbbab14995", + }, + }, + channel: { + value: "dev", + }, + }), + { ref: "08753A1D793C040B101C8A26C43445DBBAB14995" }, + ), + ).not.toThrow(); + }); + + it("rejects update status payloads that are not on dev/main git", () => { + expect(() => + verifyDevUpdateStatus( + JSON.stringify({ + update: { + installKind: "package", + git: { + branch: "release", + }, + }, + channel: { + value: "stable", + }, + }), + ), + ).toThrow("git install"); + }); +});