From 340f480a7b7d3615b1f47cc733b309368423c36f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 27 May 2026 05:49:02 +0200 Subject: [PATCH] fix(installer): tighten nonroot smoke node preflight --- scripts/docker/install-sh-nonroot/run.sh | 8 +- test/scripts/test-install-sh-docker.test.ts | 91 +++++++++++++++++---- 2 files changed, 84 insertions(+), 15 deletions(-) diff --git a/scripts/docker/install-sh-nonroot/run.sh b/scripts/docker/install-sh-nonroot/run.sh index 1abcc392d413..42ee1edaa4b9 100644 --- a/scripts/docker/install-sh-nonroot/run.sh +++ b/scripts/docker/install-sh-nonroot/run.sh @@ -20,11 +20,17 @@ node -e ' const version = process.versions.node.split(".").map(Number); const ok = version.length >= 2 && - (version[0] > 22 || (version[0] === 22 && version[1] >= 16)); + (version[0] > 22 || (version[0] === 22 && version[1] >= 19)); if (!ok) { process.stderr.write(`unsupported node ${process.versions.node}\n`); process.exit(1); } + try { + require("node:sqlite"); + } catch { + process.stderr.write(`unsupported node ${process.versions.node}: missing node:sqlite\n`); + process.exit(1); + } ' command -v npm >/dev/null diff --git a/test/scripts/test-install-sh-docker.test.ts b/test/scripts/test-install-sh-docker.test.ts index 06117d1b77b9..69894063bf1a 100644 --- a/test/scripts/test-install-sh-docker.test.ts +++ b/test/scripts/test-install-sh-docker.test.ts @@ -1,5 +1,6 @@ import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; import { describe, expect, it } from "vitest"; const SCRIPT_PATH = "scripts/test-install-sh-docker.sh"; @@ -7,12 +8,59 @@ const DOCKER_SETUP_PATH = "scripts/docker/setup.sh"; const PODMAN_SETUP_PATH = "scripts/podman/setup.sh"; const PODMAN_RUN_PATH = "scripts/run-openclaw-podman.sh"; const SMOKE_RUNNER_PATH = "scripts/docker/install-sh-smoke/run.sh"; +const NONROOT_RUNNER_PATH = "scripts/docker/install-sh-nonroot/run.sh"; const BUN_GLOBAL_SMOKE_PATH = "scripts/e2e/bun-global-install-smoke.sh"; const BUN_GLOBAL_ASSERTIONS_PATH = "scripts/e2e/lib/bun-global-install/assertions.mjs"; const INSTALL_SMOKE_WORKFLOW_PATH = ".github/workflows/install-smoke.yml"; const RELEASE_CHECKS_WORKFLOW_PATH = ".github/workflows/openclaw-release-checks.yml"; const LIVE_E2E_WORKFLOW_PATH = ".github/workflows/openclaw-live-and-e2e-checks-reusable.yml"; +class ScriptExit extends Error { + constructor(readonly status: number) { + super(`script exited ${String(status)}`); + } +} + +function extractNonrootNodePreflight(): string { + const script = readFileSync(NONROOT_RUNNER_PATH, "utf8"); + const match = script.match(/node -e '\n([\s\S]*?)\n'\ncommand -v npm/u); + if (!match) { + throw new Error("non-root smoke Node preflight was not found"); + } + return match[1]; +} + +function runNonrootNodePreflight(version: string, options: { sqlite?: boolean } = {}) { + const stderr: string[] = []; + try { + runInNewContext(extractNonrootNodePreflight(), { + process: { + versions: { node: version }, + stderr: { + write(message: string) { + stderr.push(message); + }, + }, + exit(status: number) { + throw new ScriptExit(status); + }, + }, + require(specifier: string) { + if (specifier === "node:sqlite" && options.sqlite === false) { + throw new Error("missing node:sqlite"); + } + return {}; + }, + }); + return { status: 0, stderr: stderr.join("") }; + } catch (error) { + if (error instanceof ScriptExit) { + return { status: error.status, stderr: stderr.join("") }; + } + throw error; + } +} + describe("test-install-sh-docker", () => { it("defaults local Apple Silicon smoke runs to native arm64 while keeping CI on amd64", () => { const script = readFileSync(SCRIPT_PATH, "utf8"); @@ -96,6 +144,25 @@ describe("test-install-sh-docker", () => { expect(script).not.toContain("docker run --rm -t \\"); }); + it("rejects stale non-root smoke Node runtimes below the runtime floor", () => { + const result = runNonrootNodePreflight("22.18.0"); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("unsupported node 22.18.0"); + }); + + it("rejects non-root smoke Node runtimes without node:sqlite", () => { + const result = runNonrootNodePreflight("22.19.0", { sqlite: false }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("unsupported node 22.19.0: missing node:sqlite"); + }); + + it("accepts non-root smoke Node runtimes that match the installer runtime floor", () => { + expect(runNonrootNodePreflight("22.19.0").status).toBe(0); + expect(runNonrootNodePreflight("24.16.0").status).toBe(0); + }); + it("runs the root Dockerfile build with the CI heap limit", () => { const dockerfile = readFileSync("Dockerfile", "utf8"); @@ -128,7 +195,9 @@ describe("test-install-sh-docker", () => { expect(script).toContain('DOCKER_PULL_TIMEOUT="${OPENCLAW_DOCKER_SETUP_PULL_TIMEOUT:-600s}"'); expect(script).toContain("run_docker_pull()"); expect(script).toContain("timeout --kill-after=1s 1s true"); - expect(script).toContain('timeout --kill-after=30s "$DOCKER_PULL_TIMEOUT" docker pull "$image"'); + expect(script).toContain( + 'timeout --kill-after=30s "$DOCKER_PULL_TIMEOUT" docker pull "$image"', + ); expect(script).toContain('timeout "$DOCKER_PULL_TIMEOUT" docker pull "$image"'); expect(script).toContain('run_docker_pull "$IMAGE_NAME"'); expect(script).not.toContain('docker pull "$IMAGE_NAME"'); @@ -137,12 +206,12 @@ describe("test-install-sh-docker", () => { it("bounds Podman setup image pulls", () => { const script = readFileSync(PODMAN_SETUP_PATH, "utf8"); - expect(script).toContain( - 'PODMAN_PULL_TIMEOUT="${OPENCLAW_PODMAN_SETUP_PULL_TIMEOUT:-600s}"', - ); + expect(script).toContain('PODMAN_PULL_TIMEOUT="${OPENCLAW_PODMAN_SETUP_PULL_TIMEOUT:-600s}"'); expect(script).toContain("run_podman_pull()"); expect(script).toContain("timeout --kill-after=1s 1s true"); - expect(script).toContain('timeout --kill-after=30s "$PODMAN_PULL_TIMEOUT" podman pull "$image"'); + expect(script).toContain( + 'timeout --kill-after=30s "$PODMAN_PULL_TIMEOUT" podman pull "$image"', + ); expect(script).toContain('timeout "$PODMAN_PULL_TIMEOUT" podman pull "$image"'); expect(script).toContain('run_podman_pull "$OPENCLAW_IMAGE"'); expect(script).not.toContain('podman pull "$OPENCLAW_IMAGE"'); @@ -314,13 +383,9 @@ describe("install-sh smoke runner", () => { ]) { expect(script).toContain(envName); } - expect(script).toMatch( - /Run installer smoke test[\s\S]*"\$\{SMOKE_RUNNER_ENV_ARGS\[@\]\}"/u, - ); + expect(script).toMatch(/Run installer smoke test[\s\S]*"\$\{SMOKE_RUNNER_ENV_ARGS\[@\]\}"/u); expect(script).toMatch(/Run update smoke[\s\S]*"\$\{SMOKE_RUNNER_ENV_ARGS\[@\]\}"/u); - expect(script).toMatch( - /Run direct npm global smoke[\s\S]*"\$\{SMOKE_RUNNER_ENV_ARGS\[@\]\}"/u, - ); + expect(script).toMatch(/Run direct npm global smoke[\s\S]*"\$\{SMOKE_RUNNER_ENV_ARGS\[@\]\}"/u); expect(script).toMatch( /Run installer npm freshness smoke[\s\S]*"\$\{SMOKE_RUNNER_ENV_ARGS\[@\]\}"/u, ); @@ -396,9 +461,7 @@ describe("bun global install smoke", () => { expect(workflow).toContain("timeout --kill-after=30s 45m docker buildx build"); expect(workflow).toContain('timeout --kill-after=30s 600s docker pull "$IMAGE_REF"'); expect(workflow).not.toContain('timeout 300s docker pull "$IMAGE_REF"'); - expect(workflow.match(/timeout --kill-after=30s 20m docker run --rm/g)?.length).toBe( - 6, - ); + expect(workflow.match(/timeout --kill-after=30s 20m docker run --rm/g)?.length).toBe(6); expect(workflow).not.toMatch(/(^|\n)\s+docker run --rm --entrypoint sh/u); expect(workflow).toContain("--progress=plain"); expect(workflow).toContain("--load");