diff --git a/scripts/resolve-openclaw-package-candidate.mjs b/scripts/resolve-openclaw-package-candidate.mjs index 210b69aa39a..6336d51bcc6 100644 --- a/scripts/resolve-openclaw-package-candidate.mjs +++ b/scripts/resolve-openclaw-package-candidate.mjs @@ -141,6 +141,21 @@ function validateOutputName(value) { } } +function resolvePackedOpenClawTarballFilename(value) { + const filename = typeof value === "string" ? value.trim() : ""; + if ( + !/^openclaw-[A-Za-z0-9._-]+\.tgz$/u.test(filename) || + filename.includes("\0") || + filename !== path.basename(filename) || + filename !== path.win32.basename(filename) + ) { + throw new Error( + `npm pack reported unsafe OpenClaw tarball filename: ${JSON.stringify(filename)}`, + ); + } + return filename; +} + export function validateOpenClawPackageSpec(spec) { if (!OPENCLAW_PACKAGE_SPEC_RE.test(spec)) { throw new Error( @@ -502,24 +517,41 @@ async function installPackageSourceDeps(sourceDir) { async function moveNewestPackedTarball(outputDir, packOutput, outputName) { let filename = ""; + let parsed; try { - const parsed = JSON.parse(packOutput); - if (Array.isArray(parsed)) { - filename = parsed.find((entry) => typeof entry?.filename === "string")?.filename ?? ""; - } + parsed = JSON.parse(packOutput); } catch {} + if (Array.isArray(parsed)) { + const packedFilename = + parsed.find((entry) => typeof entry?.filename === "string")?.filename ?? ""; + if (packedFilename) { + filename = resolvePackedOpenClawTarballFilename(packedFilename); + } + } if (!filename) { for (const line of packOutput.split(/\r?\n/u)) { const trimmed = line.trim(); - if (/^openclaw-.*\.tgz$/u.test(trimmed)) { - filename = trimmed; + if ( + trimmed.endsWith(".tgz") && + (trimmed.startsWith("openclaw-") || + trimmed.includes(":") || + trimmed.includes("/") || + trimmed.includes("\\")) + ) { + filename = resolvePackedOpenClawTarballFilename(trimmed); } } } if (!filename) { const entries = await fs.readdir(outputDir); filename = entries - .filter((entry) => /^openclaw-.*\.tgz$/u.test(entry)) + .filter((entry) => { + try { + return resolvePackedOpenClawTarballFilename(entry) === entry; + } catch { + return false; + } + }) .toSorted((a, b) => a.localeCompare(b)) .at(-1); } @@ -535,6 +567,8 @@ async function moveNewestPackedTarball(outputDir, packOutput, outputName) { return target; } +export const moveNewestPackedTarballForTest = moveNewestPackedTarball; + function normalizeUrlHostname(hostname) { return hostname.replace(/^\[/u, "").replace(/\]$/u, "").replace(/\.+$/u, "").toLowerCase(); } diff --git a/test/scripts/resolve-openclaw-package-candidate.test.ts b/test/scripts/resolve-openclaw-package-candidate.test.ts index 9df63568713..0caa306ab5c 100644 --- a/test/scripts/resolve-openclaw-package-candidate.test.ts +++ b/test/scripts/resolve-openclaw-package-candidate.test.ts @@ -12,6 +12,7 @@ import { downloadUrl, findSingleTarballForTest, loadTrustedPackageSource, + moveNewestPackedTarballForTest, parseArgs, readArtifactPackageCandidateMetadata, readPackageBuildSourceSha, @@ -211,6 +212,57 @@ describe("resolve-openclaw-package-candidate", () => { }); }); + it("keeps npm pack filenames inside the package candidate output directory", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-npm-pack-")); + tempDirs.push(dir); + await writeFile(path.join(dir, "openclaw-2026.6.17.tgz"), "package"); + + await expect( + moveNewestPackedTarballForTest( + dir, + JSON.stringify([{ filename: "openclaw-2026.6.17.tgz" }]), + "openclaw-current.tgz", + ), + ).resolves.toBe(path.join(dir, "openclaw-current.tgz")); + await expect(readFile(path.join(dir, "openclaw-current.tgz"), "utf8")).resolves.toBe("package"); + }); + + it("rejects path-like npm pack filenames instead of renaming outside the output directory", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-npm-pack-")); + tempDirs.push(dir); + + const unsafeFilenames = [ + "../openclaw-2026.6.17.tgz", + "nested/openclaw-2026.6.17.tgz", + "nested\\openclaw-2026.6.17.tgz", + "/tmp/openclaw-2026.6.17.tgz", + "C:\\temp\\openclaw-2026.6.17.tgz", + "openclaw-2026.6.17.tar.gz", + ]; + + for (const filename of unsafeFilenames) { + await expect( + moveNewestPackedTarballForTest(dir, JSON.stringify([{ filename }]), "openclaw-current.tgz"), + ).rejects.toThrow("npm pack reported unsafe OpenClaw tarball filename"); + } + }); + + it("rejects unsafe text npm pack filenames instead of using loose stdout fallback", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-npm-pack-")); + tempDirs.push(dir); + await writeFile(path.join(dir, "openclaw-2026.6.17.tgz"), "safe fallback"); + + for (const filename of ["../openclaw-2026.6.17.tgz", "C:openclaw-2026.6.17.tgz"]) { + await expect( + moveNewestPackedTarballForTest( + dir, + ["npm notice", filename].join("\n"), + "openclaw-current.tgz", + ), + ).rejects.toThrow("npm pack reported unsafe OpenClaw tarball filename"); + } + }); + it("bounds captured command stderr tails on failures", async () => { await expect( runCommandForTest(