From fc2724d831c8aaf5cb1d71fef1e81a67c1911826 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 13:24:13 -0700 Subject: [PATCH] fix(release): normalize package tarball modes and prove non-root install (#130335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../runtime/docker-package-install.yaml | 3 +- scripts/check-openclaw-package-tarball.mts | 40 +++++++++++++ scripts/e2e/docker-package-install.sh | 36 +++++++----- scripts/package-openclaw-for-docker.mts | 51 ++++++++++++++++ .../package-openclaw-for-docker.e2e.test.ts | 53 +++++++++++++++++ .../check-openclaw-package-tarball.test.ts | 45 ++++++++++++++ test/scripts/docker-build-helper.test.ts | 58 ++++++++++++++++++- 7 files changed, 266 insertions(+), 20 deletions(-) diff --git a/qa/scenarios/runtime/docker-package-install.yaml b/qa/scenarios/runtime/docker-package-install.yaml index e2cb122c3f59..584be2636f53 100644 --- a/qa/scenarios/runtime/docker-package-install.yaml +++ b/qa/scenarios/runtime/docker-package-install.yaml @@ -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: diff --git a/scripts/check-openclaw-package-tarball.mts b/scripts/check-openclaw-package-tarball.mts index fea9b027da43..f8f5e3499374 100644 --- a/scripts/check-openclaw-package-tarball.mts +++ b/scripts/check-openclaw-package-tarball.mts @@ -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"); } diff --git a/scripts/e2e/docker-package-install.sh b/scripts/e2e/docker-package-install.sh index acfb4c2997c3..a2aef6fb5813 100755 --- a/scripts/e2e/docker-package-install.sh +++ b/scripts/e2e/docker-package-install.sh @@ -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" \ diff --git a/scripts/package-openclaw-for-docker.mts b/scripts/package-openclaw-for-docker.mts index 335e3a7f69ac..01f3fcf9b536 100644 --- a/scripts/package-openclaw-for-docker.mts +++ b/scripts/package-openclaw-for-docker.mts @@ -67,6 +67,7 @@ type PackageManifestLifecycle = { type PackageOptions = RunOptions & { allowUnreleasedChangelog?: unknown; extractAiRuntime?: (tarballPath: string, destination: string) => Promise; + normalizeTarballModes?: (tarballPath: string) => Promise; 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 => { + 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, @@ -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) { diff --git a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts index 4b10ac239dfe..78dd4b4f6f23 100644 --- a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts @@ -24,6 +24,9 @@ import { import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js"; const skipBundledAiRuntime = async (): Promise<() => Promise> => async () => {}; +// Fake-tarball tests write placeholder bytes that the real mode normalizer +// could not parse as a gzip archive. +const skipTarballModeNormalization = { normalizeTarballModes: async (): Promise => {} }; const skipDocsMapLifecycle = { prepareDocsMap: async (): Promise => {}, restoreDocsMap: async (): Promise => {}, @@ -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(); + 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 () => {}, diff --git a/test/scripts/check-openclaw-package-tarball.test.ts b/test/scripts/check-openclaw-package-tarball.test.ts index 8cc55ae7d509..59b2ce94fbe2 100644 --- a/test/scripts/check-openclaw-package-tarball.test.ts +++ b/test/scripts/check-openclaw-package-tarball.test.ts @@ -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, @@ -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( diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index 97716aad8ff9..9f74aba02a13 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -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(); + 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',