fix(release): normalize package tarball modes and prove non-root install (#130335)

npm/pnpm pack copy on-disk file modes into the tarball, and node-tar's
portable mode-fix only strips group/other write bits — it never adds
read bits. A restrictive-umask build host therefore ships owner-only
(0600/0700) tarball entries, which breaks the CLI for non-root users
after `sudo npm install -g` under mode-preserving consumers such as
system tar.

- Normalize every packed entry to 0644/0755 (a+rX, exec bits kept) as
  the last step of packOpenClawPackageForDocker.
- Add a tar -tvf mode gate to check-openclaw-package-tarball that
  rejects any non-world-readable entry.
- Run the docker-package-install npm lane as root and execute the
  installed CLI as a non-root user to prove the fix live.
- Fix the docker-package-install bun proof, broken on main since
  #129552 wired the bun smoke into the shared openclaw-e2e-instance
  library: replace the drift-prone per-file harness copy list with
  directory copies, and add a closure-walking guard test that fails
  on missing harness dependencies.
This commit is contained in:
Peter Steinberger
2026-08-26 13:24:13 -07:00
committed by GitHub
parent 64113d0fdc
commit fc2724d831
7 changed files with 266 additions and 20 deletions
@@ -24,6 +24,9 @@ import {
import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js";
const skipBundledAiRuntime = async (): Promise<() => Promise<void>> => async () => {};
// Fake-tarball tests write placeholder bytes that the real mode normalizer
// could not parse as a gzip archive.
const skipTarballModeNormalization = { normalizeTarballModes: async (): Promise<void> => {} };
const skipDocsMapLifecycle = {
prepareDocsMap: async (): Promise<void> => {},
restoreDocsMap: async (): Promise<void> => {},
@@ -493,6 +496,47 @@ describe("package-openclaw-for-docker", () => {
expect(entries).not.toContain("package/dist/runtime-OLDHASH.js");
});
it.skipIf(process.platform === "win32")(
"normalizes owner-only packed modes to world-readable entries",
async () => {
const sourceDir = tempDirs.make("openclaw-package-modes-source-");
const outputDir = tempDirs.make("openclaw-package-modes-output-");
const distDir = path.join(sourceDir, "dist");
// A restrictive-umask build host leaves owner-only sources on disk;
// npm pack copies these modes verbatim into the tarball.
fs.mkdirSync(distDir, { mode: 0o700 });
fs.writeFileSync(path.join(distDir, "index.js"), "export {};\n", { mode: 0o600 });
fs.writeFileSync(path.join(sourceDir, "openclaw.mjs"), "#!/usr/bin/env node\n", {
mode: 0o700,
});
fs.writeFileSync(
path.join(sourceDir, "package.json"),
`${JSON.stringify({
bin: { openclaw: "openclaw.mjs" },
files: ["dist", "openclaw.mjs"],
name: "openclaw",
version: "2026.8.26",
})}\n`,
);
const tarball = await packOpenClawPackageForDocker(sourceDir, outputDir, {
...skipDocsMapLifecycle,
prepareBundledAiRuntime: skipBundledAiRuntime,
prepareChangelog: async () => {},
restoreChangelog: async () => {},
});
const entryModes = new Map<string, number>();
await tar.t({
file: tarball,
onentry: (entry) => entryModes.set(entry.path, (entry.mode ?? 0) & 0o777),
});
expect(entryModes.get("package/dist/index.js")).toBe(0o644);
expect(entryModes.get("package/openclaw.mjs")).toBe(0o755);
expect(entryModes.get("package/package.json")).toBe(0o644);
},
);
it("rejects loose package artifact timeout env values", async () => {
const previousTimeout = process.env.OPENCLAW_DOCKER_PACKAGE_BUILD_TIMEOUT_MS;
try {
@@ -698,6 +742,7 @@ describe("package-openclaw-for-docker", () => {
try {
const tarball = await packOpenClawPackageForDocker(sourceDir, outputDir, {
...skipDocsMapLifecycle,
...skipTarballModeNormalization,
prepareBundledAiRuntime: async (_source, _output, _runCapture, options) => {
const aiDir = path.dirname(aiPackageJsonPath);
expect(options).toBeDefined();
@@ -800,6 +845,7 @@ describe("package-openclaw-for-docker", () => {
it("trims and restores the changelog around ignore-scripts package artifacts", async () => {
const calls: string[] = [];
const tarball = await packOpenClawPackageForDocker("/repo", "/out", {
...skipTarballModeNormalization,
prepareBundledAiRuntime: skipBundledAiRuntime,
prepareChangelog: async (cwd: string) => {
calls.push(`prepare:${cwd}`);
@@ -905,6 +951,7 @@ describe("package-openclaw-for-docker", () => {
try {
const tarball = await packOpenClawPackageForDocker(sourceDir, outputDir, {
...skipTarballModeNormalization,
allowUnreleasedChangelog: true,
prepareBundledAiRuntime: skipBundledAiRuntime,
runCaptureImpl: async () => {
@@ -946,6 +993,7 @@ describe("package-openclaw-for-docker", () => {
try {
const tarball = await packOpenClawPackageForDocker(sourceDir, outputDir, {
...skipTarballModeNormalization,
prepareBundledAiRuntime: skipBundledAiRuntime,
prepareChangelog: async () => {},
restoreChangelog: async () => {},
@@ -973,6 +1021,7 @@ describe("package-openclaw-for-docker", () => {
try {
const tarball = await packOpenClawPackageForDocker("/repo", outputDir, {
...skipDocsMapLifecycle,
...skipTarballModeNormalization,
pnpmPack: true,
prepareBundledAiRuntime: skipBundledAiRuntime,
prepareChangelog: async () => {},
@@ -1018,6 +1067,7 @@ describe("package-openclaw-for-docker", () => {
try {
const tarball = await packOpenClawPackageForDocker(sourceDir, outputDir, {
...skipDocsMapLifecycle,
...skipTarballModeNormalization,
outputName: "openclaw-current.tgz",
packJsonPath,
prepareBundledAiRuntime: skipBundledAiRuntime,
@@ -1071,6 +1121,7 @@ describe("package-openclaw-for-docker", () => {
try {
const packPromise = packOpenClawPackageForDocker("/repo", outputDir, {
...skipDocsMapLifecycle,
...skipTarballModeNormalization,
packJsonPath: path.join(outputDir, "pack.json"),
prepareBundledAiRuntime: skipBundledAiRuntime,
prepareChangelog: async () => {},
@@ -1149,6 +1200,7 @@ describe("package-openclaw-for-docker", () => {
await expect(
packOpenClawPackageForDocker("/repo", outputDir, {
...skipDocsMapLifecycle,
...skipTarballModeNormalization,
prepareBundledAiRuntime: skipBundledAiRuntime,
prepareChangelog: async () => {},
restoreChangelog: async () => {},
@@ -1171,6 +1223,7 @@ describe("package-openclaw-for-docker", () => {
await expect(
packOpenClawPackageForDocker("/repo", outputDir, {
...skipDocsMapLifecycle,
...skipTarballModeNormalization,
prepareBundledAiRuntime: skipBundledAiRuntime,
prepareChangelog: async () => {},
restoreChangelog: async () => {},
@@ -6,6 +6,7 @@ import {
mkdtempSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
@@ -53,6 +54,18 @@ function usesLegacyShrinkwrapByDefault(version: string): boolean {
return year < 2026 || (year === 2026 && (month < 7 || (month === 7 && patch < 2)));
}
function chmodTreeWorldReadable(dir: string) {
chmodSync(dir, 0o755);
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const entryPath = join(dir, entry.name);
if (entry.isDirectory()) {
chmodTreeWorldReadable(entryPath);
} else {
chmodSync(entryPath, 0o644);
}
}
}
function withTarball(
inventory: string[],
files: Record<string, string>,
@@ -148,6 +161,9 @@ function withTarball(
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, body);
}
// The tarball mode gate requires world-readable entries; pin the fixture
// against restrictive host umasks the way the packer normalizes artifacts.
chmodTreeWorldReadable(packageRoot);
const tarball = join(root, "openclaw.tgz");
const pack = spawnSync("tar", ["-czf", tarball, "-C", root, "package"], {
@@ -187,6 +203,35 @@ describe("check-openclaw-package-tarball", () => {
expect(extra.stderr).not.toContain("OpenClaw package tarball does not exist");
});
it.skipIf(process.platform === "win32")("rejects owner-only tar entry modes", () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-package-tarball-modes-"));
try {
const packageRoot = join(root, "package");
mkdirSync(join(packageRoot, "dist"), { recursive: true });
writeFileSync(
join(packageRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.8.26" }),
);
writeFileSync(join(packageRoot, "dist", "index.js"), "export {};\n");
chmodTreeWorldReadable(packageRoot);
chmodSync(join(packageRoot, "dist", "index.js"), 0o600);
const tarball = join(root, "openclaw.tgz");
const pack = spawnSync("tar", ["-czf", tarball, "-C", root, "package"], {
encoding: "utf8",
});
expect(pack.status, pack.stderr).toBe(0);
const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" });
expect(result.status).toBe(1);
expect(result.stderr).toContain(
"tar entry is not world-readable (-rw-------): package/dist/index.js",
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("accepts tarballs whose entry list exceeds Node's default spawn buffer", () => {
const longNameSuffix = "x".repeat(80);
const largeEntryList = Object.fromEntries(
+55 -3
View File
@@ -1,7 +1,7 @@
// Docker Build Helper tests cover docker build helper script behavior.
import { type ChildProcess, execFileSync, spawn, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
@@ -4532,17 +4532,69 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
}
});
it("copies the complete bun harness closure into the package-install lane", () => {
const packageRunner = readFileSync(DOCKER_PACKAGE_INSTALL_E2E_PATH, "utf8");
const listMatch = /for harness_path in \\\n([^;]*); do/u.exec(packageRunner);
expect(listMatch, "bun harness copy list").toBeTruthy();
const copiedRoots = [...(listMatch?.[1] ?? "").matchAll(/[^\s\\]+/gu)].map((match) => match[0]);
expect(copiedRoots.length).toBeGreaterThan(0);
for (const root of copiedRoots) {
expect(existsSync(root), `${root} missing from repo`).toBe(true);
}
const isCopied = (file: string) =>
copiedRoots.some((root) => file === root || file.startsWith(`${root}/`));
// Walk every source/import/spawn reachable from the bun smoke entrypoint.
// Anything outside the copied roots crashes the bun proof container at
// runtime on its /repo mount, the way the #129552 e2e-instance drift did.
const pending = ["scripts/e2e/bun-global-install-smoke.sh"];
const visited = new Set<string>();
while (pending.length > 0) {
const file = pending.pop() ?? "";
if (visited.has(file)) {
continue;
}
visited.add(file);
expect(existsSync(file), `${file} referenced by the bun harness is missing`).toBe(true);
const body = readFileSync(file, "utf8");
const requirements: string[] = [];
for (const match of body.matchAll(/source "\$ROOT_DIR\/([^"]+)"/gu)) {
requirements.push(match[1] ?? "");
}
for (const match of body.matchAll(/source "\$[0-9A-Z_]+_LIB_DIR\/([^"]+)"/gu)) {
requirements.push(join(dirname(file), match[1] ?? ""));
}
for (const match of body.matchAll(/from "(\.\.?\/[^"]+)"/gu)) {
requirements.push(join(dirname(file), match[1] ?? ""));
}
for (const match of body.matchAll(/\bnode (scripts\/[^\s"']+\.(?:mjs|ts))/gu)) {
requirements.push(match[1] ?? "");
}
for (const requirement of requirements) {
expect(
isCopied(requirement),
`${file} needs ${requirement} inside the bun harness copy roots`,
).toBe(true);
pending.push(requirement);
}
}
expect(visited.size).toBeGreaterThan(3);
});
it("executes each CLI distribution boundary instead of promoting metadata", () => {
const installerRunner = readFileSync(CLI_INSTALLER_DISTRIBUTION_E2E_PATH, "utf8");
const packageRunner = readFileSync(DOCKER_PACKAGE_INSTALL_E2E_PATH, "utf8");
const updateRunner = readFileSync(UPDATE_CHANNEL_SWITCH_DOCKER_E2E_PATH, "utf8");
expectTextToIncludeAll(packageRunner, [
"npm install -g --prefix /tmp/openclaw-proof",
"--user root",
"npm install -g /tmp/openclaw-current.tgz",
"runuser -u appuser -- openclaw --version",
"runuser -u appuser -- openclaw --help",
"corepack prepare pnpm@11.22.0 --activate",
"pnpm add --global --allow-build=openclaw",
"bun@1.4.0",
'test "$(command -v openclaw)" = "/tmp/openclaw-proof/bin/openclaw"',
'test "$(command -v openclaw)" = "/usr/local/bin/openclaw"',
'test "$(command -v openclaw)" = "$PNPM_HOME/openclaw"',
"OPENCLAW_BUN_GLOBAL_SMOKE_PROOF_PATH",
'BUN_HARNESS_DIR="$(mktemp -d',