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
@@ -341,6 +341,44 @@ if (list.status !== 0) {
fail(`tar -tf failed for ${tarball}: ${list.stderr || list.error?.message || list.status}`);
}
const verboseList = runPhase("tar mode list", () =>
spawnSync("tar", ["-tvf", tarball], {
encoding: "utf8",
maxBuffer: TAR_LIST_MAX_BUFFER_BYTES,
stdio: ["ignore", "pipe", "pipe"],
}),
);
if (verboseList.status !== 0) {
fail(
`tar -tvf failed for ${tarball}: ${verboseList.stderr || verboseList.error?.message || verboseList.status}`,
);
}
// System tar and mode-preserving installers extract entry modes verbatim, so
// an owner-only (0600/0700) entry packed on a restrictive-umask host can leave
// a root-installed CLI unreadable for non-root users. Require a+rX everywhere.
function collectTarballEntryModeErrors(verboseListing: string): string[] {
const modeErrors: string[] = [];
for (const line of verboseListing.split(/\r?\n/u)) {
const modeString = line.trimStart().split(/\s+/u, 1)[0] ?? "";
// Symlinks and hardlinks carry no install-mode contract of their own.
if (!/^[-d][rwxsStT-]{9}$/u.test(modeString)) {
continue;
}
// Lowercase x/s/t mean the exec bit is set; uppercase S/T mean it is not.
const execAt = (index: number) => /^[xst]$/u.test(modeString.charAt(index));
const needsExec = modeString.startsWith("d") || execAt(3) || execAt(6) || execAt(9);
const worldReadable = modeString.charAt(4) === "r" && modeString.charAt(7) === "r";
const worldExecutable = execAt(6) && execAt(9);
if (!worldReadable || (needsExec && !worldExecutable)) {
modeErrors.push(
`tar entry is not world-readable (${modeString}): ${line.trim().split(/\s+/u).at(-1)}`,
);
}
}
return modeErrors;
}
const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-package-tarball-"));
try {
const extract = runPhase("tar extract", () =>
@@ -480,6 +518,8 @@ for (const entry of normalized) {
}
}
errors.push(...collectTarballEntryModeErrors(verboseList.stdout));
if (!entrySet.has("package.json")) {
errors.push("missing package.json");
}
+20 -16
View File
@@ -25,32 +25,35 @@ trap cleanup EXIT
docker_e2e_build_or_reuse "$IMAGE_NAME" docker-package-install "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" bare
# The bun smoke runs the shared openclaw-e2e-instance library (mock provider
# servers included), so copy its whole script roots instead of a per-file list
# that silently drifts when the library grows a dependency. The repo checkout
# itself stays unmounted: the lane proves the packaged artifact, not sources.
for harness_path in \
scripts/e2e/bun-global-install-smoke.sh \
scripts/e2e/lib/bun-global-install/assertions.mjs \
scripts/lib/docker-e2e-container.sh \
scripts/lib/docker-e2e-logs.sh \
scripts/lib/docker-e2e-package.sh \
scripts/lib/docker-e2e-resource-diagnostics.sh; do
packages/normalization-core/src \
scripts; do
mkdir -p "$BUN_HARNESS_DIR/$(dirname "$harness_path")"
cp "$ROOT_DIR/$harness_path" "$BUN_HARNESS_DIR/$harness_path"
cp -R "$ROOT_DIR/$harness_path" "$BUN_HARNESS_DIR/$harness_path"
done
chmod -R a+rX "$BUN_HARNESS_DIR"
echo "Installing the real OpenClaw package artifact with npm..."
echo "Installing the real OpenClaw package artifact with npm as root..."
DOCKER_COMMAND_TIMEOUT="$DOCKER_RUN_TIMEOUT" docker_e2e_docker_run_cmd run -d \
--name "$NPM_PROOF_CONTAINER" \
--user root \
-v "$PACKAGE_TGZ:/tmp/openclaw-current.tgz:ro" \
"$IMAGE_NAME" \
bash -lc '
set -euo pipefail
npm install -g --prefix /tmp/openclaw-proof /tmp/openclaw-current.tgz --no-fund --no-audit
export PATH="/tmp/openclaw-proof/bin:$PATH"
package_root=/tmp/openclaw-proof/lib/node_modules/openclaw
test "$(command -v openclaw)" = "/tmp/openclaw-proof/bin/openclaw"
openclaw --version > /tmp/openclaw-version
openclaw --help > /tmp/openclaw-help
npm install -g /tmp/openclaw-current.tgz --no-fund --no-audit
test "$(command -v openclaw)" = "/usr/local/bin/openclaw"
# Root installed the global package; a non-root user must still be able to
# run it. A same-user install can never catch an installed tree that ends
# up owner-only readable, which is how sudo-install breakage ships.
runuser -u appuser -- openclaw --version > /tmp/openclaw-version
runuser -u appuser -- openclaw --help > /tmp/openclaw-help
test -s /tmp/openclaw-help
chmod 644 /tmp/openclaw-version /tmp/openclaw-help
touch /tmp/openclaw-proof-ready
exec sleep infinity
' >/dev/null
@@ -117,7 +120,7 @@ for container_name in "$NPM_PROOF_CONTAINER" "$PNPM_PROOF_CONTAINER" "$BUN_PROOF
wait_for_proof "$container_name"
done
NPM_PACKAGE_ROOT="/tmp/openclaw-proof/lib/node_modules/openclaw"
NPM_PACKAGE_ROOT="/usr/local/lib/node_modules/openclaw"
NPM_INSTALLED_VERSION="$(docker exec "$NPM_PROOF_CONTAINER" cat /tmp/openclaw-version | tr -d '\r\n')"
PNPM_PACKAGE_ROOT="$(docker exec "$PNPM_PROOF_CONTAINER" cat /tmp/openclaw-package-root | tr -d '\r\n')"
PNPM_INSTALLED_VERSION="$(docker exec "$PNPM_PROOF_CONTAINER" cat /tmp/openclaw-version | tr -d '\r\n')"
@@ -148,8 +151,9 @@ node --import tsx "$ROOT_DIR/scripts/e2e/lib/docker-artifact-proof/write-identit
--detail "npm:installedPackageRoot=$NPM_PACKAGE_ROOT" \
--detail "npm:installedPackageVersion=$PACKAGE_VERSION" \
--detail "npm:openclawVersion=$NPM_INSTALLED_VERSION" \
--detail "npm:openclawPath=/tmp/openclaw-proof/bin/openclaw" \
--detail "npm:openclawPath=/usr/local/bin/openclaw" \
--detail "npm:helpCommand=passed" \
--detail "npm:nonRootExecution=passed" \
--detail "pnpm:installedPackageRoot=$PNPM_PACKAGE_ROOT" \
--detail "pnpm:installedPackageVersion=$PACKAGE_VERSION" \
--detail "pnpm:openclawVersion=$PNPM_INSTALLED_VERSION" \
+51
View File
@@ -67,6 +67,7 @@ type PackageManifestLifecycle = {
type PackageOptions = RunOptions & {
allowUnreleasedChangelog?: unknown;
extractAiRuntime?: (tarballPath: string, destination: string) => Promise<unknown>;
normalizeTarballModes?: (tarballPath: string) => Promise<unknown>;
outputName?: string;
packJsonPath?: string;
pnpmPack?: boolean;
@@ -816,6 +817,55 @@ export async function prepareBundledAiRuntimePackage(
}
}
async function normalizeOpenClawTarballModes(tarballPath: string) {
// npm/pnpm pack copy on-disk modes into the tarball (node-tar's portable
// mode-fix never adds read bits), so a restrictive-umask build host ships
// owner-only 0600/0700 entries that leave a root-installed CLI unreadable
// for non-root users under system tar and mode-preserving installers.
// Rewrite every entry to 0644/0755 the way a umask-022 host would have
// packed it, keeping executable bits. Stays on the system tar contract like
// the bundled AI runtime extraction above.
const timeoutMs = resolveTimeoutMs(
"OPENCLAW_DOCKER_PACKAGE_PACK_TIMEOUT_MS",
DEFAULT_PACKAGE_PACK_TIMEOUT_MS,
);
const stageDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-package-modes-"));
try {
await run("tar", ["-xzf", tarballPath, "-C", stageDir], stageDir, { timeoutMs });
let stagedFileCount = 0;
const normalizeStagedModes = async (dir: string): Promise<void> => {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await fs.chmod(entryPath, 0o755);
await normalizeStagedModes(entryPath);
} else if (entry.isFile()) {
// Umask masking on extraction only clears group/other bits, so the
// owner exec bit still says whether the packed entry was executable.
const executable = ((await fs.stat(entryPath)).mode & 0o100) !== 0;
await fs.chmod(entryPath, executable ? 0o755 : 0o644);
stagedFileCount += 1;
}
}
};
await normalizeStagedModes(stageDir);
if (stagedFileCount === 0) {
throw new Error(`packed OpenClaw tarball has no file entries: ${tarballPath}`);
}
const stageRootEntries = await fs.readdir(stageDir);
const normalizedPath = `${tarballPath}.modes-tmp`;
await fs.rm(normalizedPath, { force: true });
await run("tar", ["-czf", normalizedPath, "-C", stageDir, ...stageRootEntries], stageDir, {
// macOS bsdtar must not add AppleDouble (._*) sidecar entries.
env: { ...process.env, COPYFILE_DISABLE: "1" },
timeoutMs,
});
await fs.rename(normalizedPath, tarballPath);
} finally {
await fs.rm(stageDir, { force: true, recursive: true });
}
}
async function restorePackageSourceArtifacts(
sourceDir: string,
restoreDocsMap: (cwd: string) => Promise<unknown>,
@@ -1002,6 +1052,7 @@ export async function packOpenClawPackageForDocker(
tarball = target;
}
}
await (packageOptions.normalizeTarballModes ?? normalizeOpenClawTarballModes)(tarball);
await writePackJson(packOutput, tarball, packageOptions.packJsonPath, sourcePath);
return tarball;
} catch (error) {