mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
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:
committed by
GitHub
parent
64113d0fdc
commit
fc2724d831
@@ -14,8 +14,9 @@ scenario:
|
||||
successCriteria:
|
||||
- The canonical Docker package helper builds and validates the real npm tarball.
|
||||
- The scheduler prepares the package and bare target image through the shared planner and catalog.
|
||||
- Clean target containers install the same tarball globally with npm, pnpm, and Bun.
|
||||
- Clean target containers install the same tarball globally with npm (as root), pnpm, and Bun.
|
||||
- Each package manager creates a PATH-resolved OpenClaw executable that serves help and the candidate version.
|
||||
- A non-root user executes the root-installed npm global CLI, proving every packaged entry is world-readable.
|
||||
- Every installed package version matches the tarball metadata.
|
||||
- Evidence records the package digest and version, bare image ID, manager container IDs, package roots, executable paths, and CLI versions.
|
||||
docsRefs:
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user