diff --git a/CHANGELOG.md b/CHANGELOG.md index afe8b5aa033a..ddc7d3afd3c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- CI: bound Docker/Bash E2E tarball npm installs with `OPENCLAW_E2E_NPM_INSTALL_TIMEOUT` so package, onboarding, plugin, and upgrade lanes fail instead of hanging on a stuck npm install. - CI: keep `OPENCLAW_TESTBOX=1 pnpm check:changed` delegating to Blacksmith Testbox through Crabbox without forwarding local Testbox or worker env into the remote command. - iMessage: thread current channel/account inbound attachment roots into the image tool so iMessage-saved attachments under `~/Library/Messages/Attachments` (including the wildcard `/Users/*/Library/Messages/Attachments` root) are read through the existing inbound path policy instead of being rejected as `path-not-allowed`. Literal `localRoots` stays workspace-scoped. Fixes #30170. (#86569) - QQ Bot: respect `OPENCLAW_HOME` for outbound media path resolution so `` sends no longer silently fail when `HOME` and `OPENCLAW_HOME` differ (Docker / multi-user hosts). Persisted QQ Bot data (sessions, known users, refs) stays anchored on the OS home for upgrade compatibility. Fixes #83562. Thanks @sliverp. diff --git a/docs/help/testing.md b/docs/help/testing.md index 8e6f381378a6..d4a24e55f606 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -746,6 +746,7 @@ These Docker runners split into two buckets: - Build and release checks run `scripts/check-cli-bootstrap-imports.mjs` after tsdown. The guard walks the static built graph from `dist/entry.js` and `dist/cli/run-main.js` and fails if pre-dispatch startup imports package dependencies such as Commander, prompt UI, undici, or logging before command dispatch; it also keeps the bundled gateway run chunk under budget and rejects static imports of known cold gateway paths. Packaged CLI smoke also covers root help, onboard help, doctor help, status, config schema, and a model-list command. - Package Acceptance legacy compatibility is capped at `2026.4.25` (`2026.4.25-beta.*` included). Through that cutoff, the harness tolerates only shipped-package metadata gaps: omitted private QA inventory entries, missing `gateway install --wrapper`, missing patch files in the tarball-derived git fixture, missing persisted `update.channel`, legacy plugin install-record locations, missing marketplace install-record persistence, and config metadata migration during `plugins update`. For packages after `2026.4.25`, those paths are strict failures. - Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:release-user-journey`, `test:docker:release-typed-onboarding`, `test:docker:release-media-memory`, `test:docker:release-upgrade-user-journey`, `test:docker:release-plugin-marketplace`, `test:docker:skill-install`, `test:docker:update-channel-switch`, `test:docker:upgrade-survivor`, `test:docker:published-upgrade-survivor`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, `test:docker:plugin-lifecycle-matrix`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths. +- Docker/Bash E2E lanes that install the packed OpenClaw tarball through `scripts/lib/openclaw-e2e-instance.sh` cap `npm install` at `OPENCLAW_E2E_NPM_INSTALL_TIMEOUT` (default `600s`; set `0` to disable the wrapper for debugging). The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store: diff --git a/scripts/lib/docker-e2e-package.sh b/scripts/lib/docker-e2e-package.sh index 64a446d5cab8..e7721a830ec9 100644 --- a/scripts/lib/docker-e2e-package.sh +++ b/scripts/lib/docker-e2e-package.sh @@ -81,6 +81,9 @@ docker_e2e_package_mount_args() { local package_tgz="$1" local target="${2:-/tmp/openclaw-current.tgz}" DOCKER_E2E_PACKAGE_ARGS=(-v "$package_tgz:$target:ro" -e "OPENCLAW_CURRENT_PACKAGE_TGZ=$target") + if [ -n "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-}" ]; then + DOCKER_E2E_PACKAGE_ARGS+=(-e "OPENCLAW_E2E_NPM_INSTALL_TIMEOUT=$OPENCLAW_E2E_NPM_INSTALL_TIMEOUT") + fi } docker_e2e_cleanup_package_tgz() { diff --git a/scripts/lib/openclaw-e2e-instance.sh b/scripts/lib/openclaw-e2e-instance.sh index 55de740be340..61510b9c2781 100644 --- a/scripts/lib/openclaw-e2e-instance.sh +++ b/scripts/lib/openclaw-e2e-instance.sh @@ -38,17 +38,53 @@ openclaw_e2e_package_entrypoint() { echo "OpenClaw package entrypoint not found under $root/dist/" >&2 return 1 } +openclaw_e2e_maybe_timeout() { + local timeout_value="$1" + shift + if [ -z "$timeout_value" ] || [ "$timeout_value" = "0" ]; then + "$@" + return + fi + local timeout_bin="" + if command -v timeout >/dev/null 2>&1; then + timeout_bin="timeout" + elif command -v gtimeout >/dev/null 2>&1; then + timeout_bin="gtimeout" + fi + if [ -z "$timeout_bin" ]; then + echo "timeout command not found; running OpenClaw E2E command without timeout $timeout_value" >&2 + "$@" + return + fi + "$timeout_bin" --foreground --kill-after=30s "$timeout_value" "$@" +} openclaw_e2e_install_package() { local log_file="$1" local label="${2:-mounted OpenClaw package}" local prefix="${3:-}" local package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" + local timeout_value="${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" local args=(-g) if [ -n "$prefix" ]; then args+=("--prefix" "$prefix") fi echo "Installing $label..." - if ! npm install "${args[@]}" "$package_tgz" --no-fund --no-audit >"$log_file" 2>&1; then + local had_errexit=0 + case "$-" in + *e*) had_errexit=1 ;; + esac + set +e + openclaw_e2e_maybe_timeout "$timeout_value" npm install "${args[@]}" "$package_tgz" --no-fund --no-audit >"$log_file" 2>&1 + local install_status=$? + if [ "$had_errexit" -eq 1 ]; then + set -e + else + set +e + fi + if [ "$install_status" -ne 0 ]; then + if [ "$install_status" -eq 124 ] || [ "$install_status" -eq 137 ]; then + echo "npm install timed out after $timeout_value for $label" >&2 + fi echo "npm install failed for $label" >&2 cat "$log_file" >&2 || true exit 1 diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index fa0b1cebfd64..4975a33ba1f1 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -24,11 +24,9 @@ const ONBOARD_DOCKER_E2E_PATH = "scripts/e2e/onboard-docker.sh"; const KITCHEN_SINK_PLUGIN_DOCKER_E2E_PATH = "scripts/e2e/kitchen-sink-plugin-docker.sh"; const KITCHEN_SINK_RPC_DOCKER_E2E_PATH = "scripts/e2e/kitchen-sink-rpc-docker.sh"; const CODEX_ON_DEMAND_DOCKER_E2E_PATH = "scripts/e2e/codex-on-demand-docker.sh"; -const CODEX_NPM_PLUGIN_LIVE_DOCKER_E2E_PATH = - "scripts/e2e/codex-npm-plugin-live-docker.sh"; +const CODEX_NPM_PLUGIN_LIVE_DOCKER_E2E_PATH = "scripts/e2e/codex-npm-plugin-live-docker.sh"; const LIVE_PLUGIN_TOOL_DOCKER_E2E_PATH = "scripts/e2e/live-plugin-tool-docker.sh"; -const NPM_ONBOARD_CHANNEL_AGENT_DOCKER_E2E_PATH = - "scripts/e2e/npm-onboard-channel-agent-docker.sh"; +const NPM_ONBOARD_CHANNEL_AGENT_DOCKER_E2E_PATH = "scripts/e2e/npm-onboard-channel-agent-docker.sh"; const SKILL_INSTALL_DOCKER_E2E_PATH = "scripts/e2e/skill-install-docker.sh"; const PLUGIN_BINDING_COMMAND_ESCAPE_DOCKER_E2E_PATH = "scripts/e2e/plugin-binding-command-escape-docker.sh"; @@ -123,9 +121,7 @@ describe("docker build helper", () => { ); expect(cleanupSmoke).not.toContain('docker run --rm --platform "$PLATFORM" -t "$IMAGE_NAME"'); - expect(installE2eSmoke).toContain( - 'source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"', - ); + expect(installE2eSmoke).toContain('source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"'); expect(installE2eSmoke).toContain( 'DOCKER_COMMAND_TIMEOUT="${DOCKER_COMMAND_TIMEOUT:-${OPENCLAW_INSTALL_E2E_DOCKER_TIMEOUT:-2700s}}"', ); @@ -470,6 +466,35 @@ test -f "$external_dir/openclaw-current.tgz" } }); + it("propagates the shared E2E npm install timeout into package-backed containers", () => { + const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-package-timeout-env-")); + + try { + const rootDir = process.cwd(); + const script = ` +set -euo pipefail +ROOT_DIR=${shellQuote(rootDir)} +TMPDIR=${shellQuote(workDir)} +export ROOT_DIR TMPDIR +source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh" + +package="$TMPDIR/openclaw-current.tgz" +printf fixture >"$package" +export OPENCLAW_E2E_NPM_INSTALL_TIMEOUT=42s +docker_e2e_package_mount_args "$package" +printf "%s\\n" "\${DOCKER_E2E_PACKAGE_ARGS[@]}" >"$TMPDIR/package-args" + +grep -qx -- "-e" "$TMPDIR/package-args" +grep -qx -- "OPENCLAW_CURRENT_PACKAGE_TGZ=/tmp/openclaw-current.tgz" "$TMPDIR/package-args" +grep -qx -- "OPENCLAW_E2E_NPM_INSTALL_TIMEOUT=42s" "$TMPDIR/package-args" +`; + + execFileSync("bash", ["-lc", script], { encoding: "utf8" }); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } + }); + it("keeps the harness run wrapper available with pre-sourced Docker command helpers", () => { const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-package-helper-guard-")); @@ -571,7 +596,7 @@ test -f "$TMPDIR/docker-cmd-seen" expect(runner).toContain("OPENCLAW_ONBOARD_MAX_MEMORY_MIB"); expect(runner).toContain("OPENCLAW_ONBOARD_MAX_CPU_PERCENT"); - expect(runner).toContain("--name \"$CONTAINER_NAME\""); + expect(runner).toContain('--name "$CONTAINER_NAME"'); expect(runner).toContain("docker_e2e_docker_cmd stats --no-stream"); expect(runner).toContain("assert-resource-ceiling.mjs"); expect(runner).not.toContain("docker_e2e_run_with_harness -t"); @@ -616,7 +641,9 @@ test -f "$TMPDIR/docker-cmd-seen" expect(runner).toContain( 'DOCKER_COMMAND_TIMEOUT="$CLIENT_TIMEOUT" run_logged gateway-network-client docker_e2e_docker_run_cmd run --rm', ); - expect(runner).not.toContain('run_logged gateway-network-client timeout "$CLIENT_TIMEOUT" docker run --rm'); + expect(runner).not.toContain( + 'run_logged gateway-network-client timeout "$CLIENT_TIMEOUT" docker run --rm', + ); }); it("copies root lifecycle scripts before cleanup-smoke installs dependencies", () => { @@ -924,7 +951,9 @@ test -f "$TMPDIR/docker-cmd-seen" expect(runner).not.toMatch(/(^|\n)docker run --rm/u); expect(runner).toContain("expected focused Vitest summary for exactly 3 passed tests"); expect(dockerfile).toContain("OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL=1"); - expect(dockerfile).toContain("pnpm install --frozen-lockfile --ignore-scripts --filter openclaw"); + expect(dockerfile).toContain( + "pnpm install --frozen-lockfile --ignore-scripts --filter openclaw", + ); }); it("routes QR import Docker smoke through the timeout-aware run helper", () => { diff --git a/test/scripts/openclaw-e2e-instance.test.ts b/test/scripts/openclaw-e2e-instance.test.ts index 7031b1ff29af..3da825dc19cc 100644 --- a/test/scripts/openclaw-e2e-instance.test.ts +++ b/test/scripts/openclaw-e2e-instance.test.ts @@ -1,4 +1,6 @@ import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -51,4 +53,111 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => { expect(result.stdout).not.toContain("value="); expect(result.stderr).toContain("decoded to an empty script"); }); + + it("wraps package installs with the configured timeout", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-e2e-instance-")); + try { + const timeoutArgsPath = path.join(tempDir, "timeout-args.txt"); + const npmArgsPath = path.join(tempDir, "npm-args.txt"); + const logPath = path.join(tempDir, "install.log"); + const packagePath = path.join(tempDir, "openclaw.tgz"); + fs.writeFileSync(packagePath, ""); + fs.writeFileSync( + path.join(tempDir, "timeout"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'printf "%s\\n" "$*" >"$OPENCLAW_TEST_TIMEOUT_ARGS"', + 'while [ "$#" -gt 0 ] && [ "$1" != "npm" ]; do shift; done', + 'exec "$@"', + "", + ].join("\n"), + ); + fs.writeFileSync( + path.join(tempDir, "npm"), + ["#!/bin/sh", "set -eu", 'printf "%s\\n" "$*" >"$OPENCLAW_TEST_NPM_ARGS"', ""].join("\n"), + ); + fs.chmodSync(path.join(tempDir, "timeout"), 0o755); + fs.chmodSync(path.join(tempDir, "npm"), 0o755); + + const result = spawnSync( + "/bin/bash", + [ + "-c", + [ + "set -euo pipefail", + `source ${shellQuote(helperPath)}`, + `openclaw_e2e_install_package ${shellQuote(logPath)} ${shellQuote("fixture package")}`, + ].join("; "), + ], + { + encoding: "utf8", + env: { + ...process.env, + PATH: `${tempDir}:${process.env.PATH ?? ""}`, + OPENCLAW_CURRENT_PACKAGE_TGZ: packagePath, + OPENCLAW_E2E_NPM_INSTALL_TIMEOUT: "42s", + OPENCLAW_TEST_TIMEOUT_ARGS: timeoutArgsPath, + OPENCLAW_TEST_NPM_ARGS: npmArgsPath, + }, + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Installing fixture package..."); + expect(fs.readFileSync(timeoutArgsPath, "utf8").trim()).toBe( + `--foreground --kill-after=30s 42s npm install -g ${packagePath} --no-fund --no-audit`, + ); + expect(fs.readFileSync(npmArgsPath, "utf8").trim()).toBe( + `install -g ${packagePath} --no-fund --no-audit`, + ); + } finally { + fs.rmSync(tempDir, { force: true, recursive: true }); + } + }); + + it("runs package installs without a wrapper when timeout is unavailable", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-e2e-instance-no-timeout-")); + try { + const npmArgsPath = path.join(tempDir, "npm-args.txt"); + const logPath = path.join(tempDir, "install.log"); + const packagePath = path.join(tempDir, "openclaw.tgz"); + fs.writeFileSync(packagePath, ""); + fs.writeFileSync( + path.join(tempDir, "npm"), + ["#!/bin/sh", "set -eu", 'printf "%s\\n" "$*" >"$OPENCLAW_TEST_NPM_ARGS"', ""].join("\n"), + ); + fs.chmodSync(path.join(tempDir, "npm"), 0o755); + + const result = spawnSync( + "/bin/bash", + [ + "-c", + [ + "set -euo pipefail", + `source ${shellQuote(helperPath)}`, + `openclaw_e2e_install_package ${shellQuote(logPath)} ${shellQuote("fixture package")}`, + ].join("; "), + ], + { + encoding: "utf8", + env: { + ...process.env, + PATH: tempDir, + OPENCLAW_CURRENT_PACKAGE_TGZ: packagePath, + OPENCLAW_E2E_NPM_INSTALL_TIMEOUT: "42s", + OPENCLAW_TEST_NPM_ARGS: npmArgsPath, + }, + }, + ); + + expect(result.status).toBe(0); + expect(fs.readFileSync(logPath, "utf8")).toContain("timeout command not found"); + expect(fs.readFileSync(npmArgsPath, "utf8").trim()).toBe( + `install -g ${packagePath} --no-fund --no-audit`, + ); + } finally { + fs.rmSync(tempDir, { force: true, recursive: true }); + } + }); });