diff --git a/scripts/test-install-sh-docker.sh b/scripts/test-install-sh-docker.sh index ff08b3313f8..1f9e6699620 100755 --- a/scripts/test-install-sh-docker.sh +++ b/scripts/test-install-sh-docker.sh @@ -151,6 +151,28 @@ console.log( ' "$baseline_pack_json_file" "$update_pack_json_file" } +read_pack_tarball_filename() { + local pack_json_file="$1" + node -e ' +const fs = require("node:fs"); +const path = require("node:path"); +const raw = fs.readFileSync(process.argv[1], "utf8") || "[]"; +const parsed = JSON.parse(raw); +const last = Array.isArray(parsed) ? parsed.at(-1) : null; +const filename = typeof last?.filename === "string" ? last.filename.trim() : ""; +if ( + !filename.endsWith(".tgz") || + filename.includes("\0") || + filename !== path.basename(filename) || + filename !== path.win32.basename(filename) +) { + console.error(`ERROR: npm pack reported unsafe tarball filename ${JSON.stringify(filename)}`); + process.exit(1); +} +process.stdout.write(filename); +' "$pack_json_file" +} + SMOKE_IMAGE="${OPENCLAW_INSTALL_SMOKE_IMAGE:-openclaw-install-smoke:local}" NONROOT_IMAGE="${OPENCLAW_INSTALL_NONROOT_IMAGE:-openclaw-install-nonroot:local}" SMOKE_PLATFORM="$(resolve_default_smoke_platform)" @@ -298,17 +320,7 @@ prepare_update_tarball() { node scripts/check-package-dist-imports.mjs "$ROOT_DIR" quiet_npm pack --ignore-scripts --json --pack-destination "$UPDATE_DIR" >"$pack_json_file" fi - UPDATE_TGZ_FILE="$( - node -e ' -const raw = require("node:fs").readFileSync(process.argv[1], "utf8") || "[]"; -const parsed = JSON.parse(raw); -const last = Array.isArray(parsed) ? parsed.at(-1) : null; -if (!last || typeof last.filename !== "string" || last.filename.length === 0) { - process.exit(1); -} -process.stdout.write(last.filename); -' "$pack_json_file" - )" + UPDATE_TGZ_FILE="$(read_pack_tarball_filename "$pack_json_file")" if [[ -z "$UPDATE_PACKAGE_SPEC" ]]; then node scripts/check-openclaw-package-tarball.mjs "${UPDATE_DIR}/${UPDATE_TGZ_FILE}" fi @@ -334,17 +346,7 @@ process.stdout.write(last.version); echo "==> Pack baseline tgz: ${PACKAGE_NAME}@${UPDATE_BASELINE_VERSION}" quiet_npm pack "${PACKAGE_NAME}@${UPDATE_BASELINE_VERSION}" --json --pack-destination "$UPDATE_DIR" >"$baseline_pack_json_file" - BASELINE_TGZ_FILE="$( - node -e ' -const raw = require("node:fs").readFileSync(process.argv[1], "utf8") || "[]"; -const parsed = JSON.parse(raw); -const last = Array.isArray(parsed) ? parsed.at(-1) : null; -if (!last || typeof last.filename !== "string" || last.filename.length === 0) { - process.exit(1); -} -process.stdout.write(last.filename); -' "$baseline_pack_json_file" - )" + BASELINE_TGZ_FILE="$(read_pack_tarball_filename "$baseline_pack_json_file")" UPDATE_BASELINE_VERSION="$( node -e ' const raw = require("node:fs").readFileSync(process.argv[1], "utf8") || "[]"; diff --git a/test/scripts/test-install-sh-docker.test.ts b/test/scripts/test-install-sh-docker.test.ts index 4a87baabfd6..c90b9d20942 100644 --- a/test/scripts/test-install-sh-docker.test.ts +++ b/test/scripts/test-install-sh-docker.test.ts @@ -93,6 +93,39 @@ function runDefaultSmokePlatform(env: Record, hostArch: string): return result.stdout; } +function extractReadPackTarballFilename(): string { + const script = readFileSync(SCRIPT_PATH, "utf8"); + const match = script.match(/(read_pack_tarball_filename\(\) \{[\s\S]*?\n\})\n\nSMOKE_IMAGE/u); + if (!match) { + throw new Error("read_pack_tarball_filename helper was not found"); + } + return match[1]; +} + +function runReadPackTarballFilename(filename: string) { + return spawnSync( + "bash", + [ + "--noprofile", + "--norc", + "-c", + `${extractReadPackTarballFilename()} +pack_json_file="$(mktemp)" +trap 'rm -f "$pack_json_file"' EXIT +printf '%s' "$PACK_JSON" >"$pack_json_file" +read_pack_tarball_filename "$pack_json_file"`, + ], + { + encoding: "utf8", + env: { + HOME: "/tmp", + PACK_JSON: JSON.stringify([{ filename }]), + PATH: process.env.PATH ?? "", + }, + }, + ); +} + describe("test-install-sh-docker", () => { it("defaults ARM hosts to native arm64 while keeping x64 CI on amd64", () => { expect(runDefaultSmokePlatform({ CI: "true" }, "aarch64")).toBe("linux/arm64"); @@ -334,6 +367,42 @@ describe("test-install-sh-docker", () => { expect(script).toContain("install smoke cannot verify pack budget"); }); + it("keeps npm pack tarball filenames local before serving update artifacts", () => { + const script = readFileSync(SCRIPT_PATH, "utf8"); + + expect(script).toContain("read_pack_tarball_filename()"); + expect(script).toContain('UPDATE_TGZ_FILE="$(read_pack_tarball_filename "$pack_json_file")"'); + expect(script).toContain( + 'BASELINE_TGZ_FILE="$(read_pack_tarball_filename "$baseline_pack_json_file")"', + ); + expect(script).toContain("filename !== path.basename(filename)"); + expect(script).toContain("filename !== path.win32.basename(filename)"); + expect(script).toContain("npm pack reported unsafe tarball filename"); + }); + + it("rejects path-like npm pack tarball filenames in update smoke metadata", () => { + expect(runReadPackTarballFilename("openclaw-2026.6.17.tgz")).toMatchObject({ + status: 0, + stdout: "openclaw-2026.6.17.tgz", + }); + + const unsafeFilenames = [ + "../openclaw.tgz", + "nested/openclaw.tgz", + "nested\\openclaw.tgz", + "/tmp/openclaw.tgz", + "C:\\temp\\openclaw.tgz", + "openclaw.tar.gz", + ]; + + for (const filename of unsafeFilenames) { + const result = runReadPackTarballFilename(filename); + + expect(result.status, filename).not.toBe(0); + expect(result.stderr, filename).toContain("npm pack reported unsafe tarball filename"); + } + }); + it("writes the package dist inventory before packing ignore-scripts tarballs", () => { const script = readFileSync(SCRIPT_PATH, "utf8");