fix(docker): bound smoke installer curl with connect timeout (#109698)

* fix(docker): bound smoke installer curl with connect timeout

Add --connect-timeout 30 and --max-time 300 to the direct installer
one-liner curl in the smoke test runner. The run_installer_for_package_spec
function is already bounded by timeout(1), but this direct invocation had
no curl-level or wrapper-level timeout.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(docker): wrap complete installer pipeline in timeout, not just curl

Replace the stand-alone curl timeout flags on the direct installer one-liner
with a timeout(1) wrapper around the entire curl|bash pipeline, matching the
run_installer_for_package_spec pattern. This bounds the complete pipeline
(download + install) rather than just the network transfer.

The curl-level --connect-timeout 30 and --max-time 300 are retained as
defence-in-depth inside the timeout wrapper.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: centralize installer smoke pipeline bounds

Co-authored-by: LZY3538 <liu.zhenye@xydigit.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
LZY3538
2026-07-17 17:20:40 +08:00
committed by GitHub
parent 708d2020d8
commit 45f36640dc
2 changed files with 198 additions and 15 deletions
+24 -13
View File
@@ -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"
+174 -2
View File
@@ -284,6 +284,108 @@ function validateInstallSmokeUpdateJson(doctorStep?: Record<string, unknown>) {
});
}
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");