diff --git a/scripts/docker/install-sh-smoke/run.sh b/scripts/docker/install-sh-smoke/run.sh index 8727df3bed9d..c5f31860b3aa 100755 --- a/scripts/docker/install-sh-smoke/run.sh +++ b/scripts/docker/install-sh-smoke/run.sh @@ -252,13 +252,16 @@ resolve_update_baseline_version() { UPDATE_BASELINE_VERSION="$resolved_version" } -run_installer_for_package_spec() { +run_installer_pipeline() { local install_url="$1" - local package_spec="$2" + shift + # Keep both pipeline processes under one timeout, and preserve download failures + # even when the installer shell consumes a partial response and exits cleanly. timeout --kill-after=30s "${INSTALL_COMMAND_TIMEOUT}s" \ - bash -c "curl -fsSL \"\$1\" | bash -s -- --install-method npm --version \"\$2\" --no-prompt --no-onboard" \ - _ "$install_url" "$package_spec" + bash -o pipefail -c \ + 'curl -fsSL --connect-timeout 30 --max-time 300 -- "$1" | bash -s -- "${@:2}"' \ + _ "$install_url" "$@" } run_install_smoke() { @@ -267,7 +270,12 @@ run_install_smoke() { echo "==> Run official installer one-liner for latest release tarball" OPENCLAW_NO_ONBOARD=1 OPENCLAW_NO_PROMPT=1 \ run_with_heartbeat "installer latest release tarball" \ - run_installer_for_package_spec "$INSTALL_URL" "$FRESH_TAG_URL" + run_installer_pipeline \ + "$INSTALL_URL" \ + --install-method npm \ + --version "$FRESH_TAG_URL" \ + --no-prompt \ + --no-onboard print_install_audit "fresh install" echo "==> Verify installed version" @@ -335,7 +343,7 @@ NODE fi echo "==> Run official installer one-liner" - curl -fsSL "$INSTALL_URL" | bash -s -- --no-prompt + run_installer_pipeline "$INSTALL_URL" --no-prompt echo "==> Verify installed version" if [[ -n "${OPENCLAW_INSTALL_LATEST_OUT:-}" ]]; then @@ -594,13 +602,16 @@ run_freshness_smoke() { fi echo "==> Run installer with same npm freshness policy" - env \ - HOME="$policy_home" \ - NPM_CONFIG_USERCONFIG="${policy_home}/.npmrc" \ - OPENCLAW_NO_ONBOARD=1 \ - OPENCLAW_NO_PROMPT=1 \ - bash -c 'curl -fsSL "$1" | bash -s -- --install-method npm --version "$2" --no-prompt --no-onboard' \ - _ "$INSTALL_URL" "$FRESHNESS_VERSION" + HOME="$policy_home" \ + NPM_CONFIG_USERCONFIG="${policy_home}/.npmrc" \ + OPENCLAW_NO_ONBOARD=1 \ + OPENCLAW_NO_PROMPT=1 \ + run_installer_pipeline \ + "$INSTALL_URL" \ + --install-method npm \ + --version "$FRESHNESS_VERSION" \ + --no-prompt \ + --no-onboard echo "==> Verify installed version" print_install_audit "freshness install" diff --git a/test/scripts/test-install-sh-docker.test.ts b/test/scripts/test-install-sh-docker.test.ts index 1b964576bece..3c0438707821 100644 --- a/test/scripts/test-install-sh-docker.test.ts +++ b/test/scripts/test-install-sh-docker.test.ts @@ -284,6 +284,108 @@ function validateInstallSmokeUpdateJson(doctorStep?: Record) { }); } +function extractInstallSmokeInstallerPipeline(): string { + const script = readFileSync(SMOKE_RUNNER_PATH, "utf8"); + const match = script.match( + /(run_installer_pipeline\(\) \{[\s\S]*?\n\})\n\nrun_install_smoke/u, + ); + if (!match) { + throw new Error("install smoke installer pipeline helper was not found"); + } + return expectDefined(match[1], "install smoke installer pipeline helper capture"); +} + +function readNulSeparatedArgs(filePath: string): string[] { + return readFileSync(filePath, "utf8").split("\0").filter(Boolean); +} + +function runInstallSmokeInstallerPipelineFixture(params: { + curlExitCode?: number; + installerArgs: string[]; +}) { + const root = tempDirs.make("openclaw-install-smoke-pipeline-"); + const binDir = join(root, "bin"); + const curlArgsPath = join(root, "curl-args.txt"); + const installerArgsPath = join(root, "installer-args.txt"); + const installerMarkerPath = join(root, "installer-ran"); + const installerSourcePath = join(root, "installer.sh"); + const timeoutArgsPath = join(root, "timeout-args.txt"); + const installUrl = "https://installer.example.test/install.sh?channel=beta&trace=1"; + mkdirSync(binDir, { recursive: true }); + writeFileSync( + join(binDir, "timeout"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `printf '%s\\0' "$@" >"$TIMEOUT_ARGS_PATH"`, + "shift 2", + 'exec "$@"', + "", + ].join("\n"), + { mode: 0o755 }, + ); + writeFileSync( + join(binDir, "curl"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `printf '%s\\0' "$@" >"$CURL_ARGS_PATH"`, + 'cat "$FAKE_INSTALLER_SOURCE"', + 'exit "$FAKE_CURL_EXIT"', + "", + ].join("\n"), + { mode: 0o755 }, + ); + writeFileSync( + installerSourcePath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `printf '%s\\0' "$@" >"$INSTALLER_ARGS_PATH"`, + 'touch "$INSTALLER_MARKER_PATH"', + "", + ].join("\n"), + ); + + const result = spawnSync( + "/bin/bash", + [ + "--noprofile", + "--norc", + "-c", + `set -euo pipefail +${extractInstallSmokeInstallerPipeline()} +run_installer_pipeline "$INSTALL_URL" "$@"`, + "_", + ...params.installerArgs, + ], + { + encoding: "utf8", + env: { + ...process.env, + CURL_ARGS_PATH: curlArgsPath, + FAKE_CURL_EXIT: String(params.curlExitCode ?? 0), + FAKE_INSTALLER_SOURCE: installerSourcePath, + INSTALLER_ARGS_PATH: installerArgsPath, + INSTALLER_MARKER_PATH: installerMarkerPath, + INSTALL_COMMAND_TIMEOUT: "17", + INSTALL_URL: installUrl, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + TIMEOUT_ARGS_PATH: timeoutArgsPath, + }, + }, + ); + + return { + curlArgsPath, + installUrl, + installerArgsPath, + installerMarkerPath, + result, + timeoutArgsPath, + }; +} + function expectInstallDockerfileContract( dockerfilePath: string, runnerPath: string, @@ -1024,11 +1126,27 @@ printf 'status=%s\\n' "$status" expect(wrapper).toContain('-v "$ROOT_DIR/scripts/install.sh:/tmp/openclaw-install.sh:ro"'); expect(runner).toContain("Run official installer one-liner for latest release tarball"); - expect(runner).toContain("run_installer_for_package_spec"); - expect(runner).toContain('bash -c "curl -fsSL \\"\\$1\\" | bash -s --'); + expect(runner).toContain("run_installer_pipeline"); + expect(runner).toContain('--version "$FRESH_TAG_URL"'); expect(runner).not.toContain('npm_install_global "install latest release tarball"'); }); + it("uses one bounded installer pipeline for candidate, default, and freshness smoke", () => { + const runner = readFileSync(SMOKE_RUNNER_PATH, "utf8"); + + expect(runner.match(/^\s*run_installer_pipeline\b/gmu)).toHaveLength(4); + expect(runner).toContain("bash -o pipefail -c"); + expect( + runner.match(/curl -fsSL --connect-timeout 30 --max-time 300 --/gu), + ).toHaveLength(1); + expect(runner).toContain('run_installer_pipeline "$INSTALL_URL" --no-prompt'); + expect(runner).toContain('--version "$FRESH_TAG_URL"'); + expect(runner).toContain('--version "$FRESHNESS_VERSION"'); + expect(runner).toMatch( + /HOME="\$policy_home" \\\n\s*NPM_CONFIG_USERCONFIG="\$\{policy_home\}\/\.npmrc" \\\n\s*OPENCLAW_NO_ONBOARD=1 \\\n\s*OPENCLAW_NO_PROMPT=1 \\\n\s*run_installer_pipeline/u, + ); + }); + it("uses public npm latest as the non-root installer expectation", () => { const wrapper = readFileSync(SCRIPT_PATH, "utf8"); @@ -1200,6 +1318,60 @@ describe("install-sh E2E runner", () => { }); describe("install-sh smoke runner", () => { + it("passes the URL and installer arguments through the timed pipeline unchanged", () => { + const installerArgs = [ + "--install-method", + "npm", + "--version", + "https://packages.example.test/openclaw.tgz?x=1&y=2", + "--no-prompt", + ]; + const fixture = runInstallSmokeInstallerPipelineFixture({ installerArgs }); + + expect(fixture.result.status, fixture.result.stderr).toBe(0); + expect(readNulSeparatedArgs(fixture.timeoutArgsPath)).toEqual([ + "--kill-after=30s", + "17s", + "bash", + "-o", + "pipefail", + "-c", + 'curl -fsSL --connect-timeout 30 --max-time 300 -- "$1" | bash -s -- "${@:2}"', + "_", + fixture.installUrl, + ...installerArgs, + ]); + expect(readNulSeparatedArgs(fixture.curlArgsPath)).toEqual([ + "-fsSL", + "--connect-timeout", + "30", + "--max-time", + "300", + "--", + fixture.installUrl, + ]); + expect(readNulSeparatedArgs(fixture.installerArgsPath)).toEqual(installerArgs); + expect(existsSync(fixture.installerMarkerPath)).toBe(true); + }); + + it("propagates curl exit 28 even when the piped installer exits successfully", () => { + const fixture = runInstallSmokeInstallerPipelineFixture({ + curlExitCode: 28, + installerArgs: ["--no-prompt"], + }); + + expect(fixture.result.status, fixture.result.stderr).toBe(28); + expect(existsSync(fixture.installerMarkerPath)).toBe(true); + expect(readNulSeparatedArgs(fixture.timeoutArgsPath).slice(0, 6)).toEqual([ + "--kill-after=30s", + "17s", + "bash", + "-o", + "pipefail", + "-c", + ]); + }); + it("wraps long npm/update operations with heartbeat and install-size audits", () => { const script = readFileSync(SMOKE_RUNNER_PATH, "utf8");