diff --git a/scripts/docker/install-sh-e2e/run.sh b/scripts/docker/install-sh-e2e/run.sh index b0b2b1e0aa3b..19a2e472b30c 100755 --- a/scripts/docker/install-sh-e2e/run.sh +++ b/scripts/docker/install-sh-e2e/run.sh @@ -150,15 +150,21 @@ preinstall_previous_version() { fi } -run_official_installer() { +run_official_installer() ( + local installer + # time_phase disables errexit, so setup and download failures must return explicitly. + installer="$(mktemp)" || return + trap 'rm -f "$installer"' EXIT + + curl -fsSL --connect-timeout 10 --max-time 120 "$INSTALL_URL" -o "$installer" || return if [[ "$INSTALL_TAG" == "beta" ]]; then - curl -fsSL "$INSTALL_URL" | OPENCLAW_BETA=1 bash + OPENCLAW_BETA=1 bash "$installer" elif [[ "$INSTALL_TAG" != "latest" ]]; then - curl -fsSL "$INSTALL_URL" | OPENCLAW_VERSION="$INSTALL_TAG" bash + OPENCLAW_VERSION="$INSTALL_TAG" bash "$installer" else - curl -fsSL "$INSTALL_URL" | bash + bash "$installer" fi -} +) verify_installed_version() { INSTALLED_VERSION="$(openclaw --version 2>/dev/null | head -n 1 | tr -d '\r')" diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index 4c1e8cfd36c7..4516bc6c3524 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -4348,8 +4348,8 @@ heartbeat_elapsed="\${BASH_REMATCH[1]}" it("passes installer tag env to bash, not curl", () => { const runner = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8"); - expect(runner).toContain('curl -fsSL "$INSTALL_URL" | OPENCLAW_BETA=1 bash'); - expect(runner).toContain('curl -fsSL "$INSTALL_URL" | OPENCLAW_VERSION="$INSTALL_TAG" bash'); + expect(runner).toContain('OPENCLAW_BETA=1 bash "$installer"'); + expect(runner).toContain('OPENCLAW_VERSION="$INSTALL_TAG" bash "$installer"'); expect(runner).not.toContain('OPENCLAW_BETA=1 curl -fsSL "$INSTALL_URL" | bash'); expect(runner).not.toContain( 'OPENCLAW_VERSION="$INSTALL_TAG" curl -fsSL "$INSTALL_URL" | bash', diff --git a/test/scripts/test-install-sh-docker.test.ts b/test/scripts/test-install-sh-docker.test.ts index 91994e8a474e..1b964576bece 100644 --- a/test/scripts/test-install-sh-docker.test.ts +++ b/test/scripts/test-install-sh-docker.test.ts @@ -59,6 +59,87 @@ function extractNonrootNodePreflight(): string { return expectDefined(match[1], "non-root smoke Node preflight capture"); } +function extractInstallE2eInstallerFunction(): string { + const script = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8"); + const startMarker = "run_official_installer() (\n"; + const endMarker = "\n\nverify_installed_version()"; + const start = script.indexOf(startMarker); + const end = script.indexOf(endMarker, start + startMarker.length); + if (start < 0 || end <= start) { + throw new Error("install E2E installer function was not found"); + } + return script.slice(start, end); +} + +function runInstallE2eInstallerFixture(params: { + curlExitCode?: number; + installTag: string; + installerBody: string; +}) { + const root = tempDirs.make("openclaw-install-e2e-download-"); + const binDir = join(root, "bin"); + const curlPath = join(binDir, "curl"); + const curlArgsPath = join(root, "curl-args.txt"); + const installerSourcePath = join(root, "installer-source.sh"); + const markerPath = join(root, "installer-marker.txt"); + const outputPathCapture = join(root, "curl-output-path.txt"); + mkdirSync(binDir, { recursive: true }); + writeFileSync(installerSourcePath, params.installerBody); + writeFileSync( + curlPath, + [ + "#!/bin/sh", + "set -eu", + 'printf \'%s\\n\' "$*" >"$CURL_ARGS_PATH"', + 'output=""', + 'while [ "$#" -gt 0 ]; do', + ' if [ "$1" = "-o" ]; then', + " shift", + ' output="$1"', + " fi", + " shift", + "done", + 'cp "$FAKE_INSTALLER_SOURCE" "$output"', + 'printf "%s" "$output" >"$OUTPUT_PATH_CAPTURE"', + 'exit "$FAKE_CURL_EXIT"', + "", + ].join("\n"), + { mode: 0o755 }, + ); + + const env: NodeJS.ProcessEnv = { + ...process.env, + CURL_ARGS_PATH: curlArgsPath, + FAKE_CURL_EXIT: String(params.curlExitCode ?? 0), + FAKE_INSTALLER_SOURCE: installerSourcePath, + INSTALL_MARKER: markerPath, + OUTPUT_PATH_CAPTURE: outputPathCapture, + PATH: `${binDir}:${process.env.PATH ?? ""}`, + }; + delete env.OPENCLAW_BETA; + delete env.OPENCLAW_VERSION; + + const result = spawnSync( + "/bin/bash", + [ + "-c", + [ + "set -u", + extractInstallE2eInstallerFunction(), + 'INSTALL_URL="https://installer.example.test/install.sh"', + `INSTALL_TAG=${JSON.stringify(params.installTag)}`, + "run_official_installer", + ].join("\n"), + ], + { + encoding: "utf8", + env, + }, + ); + + return { curlArgsPath, markerPath, outputPathCapture, result }; +} + function runNonrootNodePreflight( version: string, options: { sqlite?: boolean; sqliteVersion?: string } = {}, @@ -960,6 +1041,40 @@ printf 'status=%s\\n' "$status" }); describe("install-sh E2E runner", () => { + it("does not execute a partial installer after a bounded download fails", () => { + const fixture = runInstallE2eInstallerFixture({ + curlExitCode: 28, + installerBody: 'touch "$INSTALL_MARKER"\n', + installTag: "latest", + }); + + expect(fixture.result.status).toBe(28); + expect(readFileSync(fixture.curlArgsPath, "utf8")).toContain( + "-fsSL --connect-timeout 10 --max-time 120 https://installer.example.test/install.sh -o", + ); + expect(existsSync(fixture.markerPath)).toBe(false); + expect(existsSync(readFileSync(fixture.outputPathCapture, "utf8"))).toBe(false); + }); + + it.each([ + ["latest", "|"], + ["beta", "1|"], + ["2026.7.1", "|2026.7.1"], + ])( + "executes a complete %s installer with the expected tag environment", + (installTag, expected) => { + const fixture = runInstallE2eInstallerFixture({ + installTag, + installerBody: + 'printf "%s|%s" "${OPENCLAW_BETA-}" "${OPENCLAW_VERSION-}" >"$INSTALL_MARKER"\n', + }); + + expect(fixture.result.status, fixture.result.stderr).toBe(0); + expect(readFileSync(fixture.markerPath, "utf8")).toBe(expected); + expect(existsSync(readFileSync(fixture.outputPathCapture, "utf8"))).toBe(false); + }, + ); + it("normalizes Docker wrapper timing and toggle knobs before forwarding", () => { const wrapper = readFileSync(INSTALL_E2E_DOCKER_PATH, "utf8");