fix(release): validate exact prepared npm package sets (#102759)

* fix(release): validate prepared npm package sets

Forward-port the beta3 package-set preflight, Telegram, and Parallels validation to main while preserving main's existing Bun install smoke. Recompute decoded proxy response lengths and require artifact tarballs to exactly match the verified manifest.

* fix(release): lock fixture registry proxy origin
This commit is contained in:
Vincent Koc 2026-07-09 05:19:24 -07:00 committed by GitHub
parent aaf5ddd832
commit 0535679083
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 957 additions and 43 deletions

View file

@ -247,14 +247,117 @@ jobs:
trap append_telegram_summary EXIT trap append_telegram_summary EXIT
if [[ -n "${PACKAGE_ARTIFACT_NAME// }" ]]; then if [[ -n "${PACKAGE_ARTIFACT_NAME// }" ]]; then
mapfile -t package_tgzs < <(find .artifacts/telegram-package-under-test -type f -name "*.tgz" | sort) package_dir=".artifacts/telegram-package-under-test"
if [[ "${#package_tgzs[@]}" -ne 1 ]]; then manifest="${package_dir}/preflight-manifest.json"
echo "package artifact ${PACKAGE_ARTIFACT_NAME} must contain exactly one .tgz; found ${#package_tgzs[@]}" >&2 if [[ -f "${manifest}" ]]; then
exit 1 package_tgz="$(
node - "${manifest}" "${package_dir}" <<'NODE'
const crypto = require("node:crypto");
const childProcess = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const [manifestPath, packageDir] = process.argv.slice(2);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const entries = [
{
packageName: "openclaw",
packageVersion: manifest.packageVersion,
tarballName: manifest.tarballName,
tarballSha256: manifest.tarballSha256,
},
...(Array.isArray(manifest.dependencyTarballs) ? manifest.dependencyTarballs : []),
];
const packageNames = new Set();
const tarballNames = new Set();
for (const entry of entries) {
if (
!entry.packageName ||
!entry.packageVersion ||
!entry.tarballName ||
!entry.tarballSha256 ||
entry.tarballName !== path.basename(entry.tarballName)
) {
throw new Error("package artifact manifest contains invalid tarball metadata");
}
if (packageNames.has(entry.packageName) || tarballNames.has(entry.tarballName)) {
throw new Error("package artifact manifest contains duplicate package metadata");
}
packageNames.add(entry.packageName);
tarballNames.add(entry.tarballName);
const tarballPath = path.join(packageDir, entry.tarballName);
if (!fs.existsSync(tarballPath)) {
throw new Error(`package artifact is missing ${entry.tarballName}`);
}
const digest = crypto.createHash("sha256").update(fs.readFileSync(tarballPath)).digest("hex");
if (digest !== entry.tarballSha256) {
throw new Error(`package artifact digest mismatch for ${entry.packageName}`);
}
const packageJson = JSON.parse(
childProcess.execFileSync("tar", ["-xOf", tarballPath, "package/package.json"], {
encoding: "utf8",
}),
);
if (packageJson.name !== entry.packageName || packageJson.version !== entry.packageVersion) {
throw new Error(`package artifact metadata mismatch for ${entry.packageName}`);
}
}
const artifactTarballs = fs
.readdirSync(packageDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith(".tgz"))
.map((entry) => entry.name);
if (
artifactTarballs.length !== tarballNames.size ||
artifactTarballs.some((name) => !tarballNames.has(name))
) {
throw new Error("package artifact tarball set does not match preflight manifest");
}
process.stdout.write(path.join(packageDir, manifest.tarballName));
NODE
)"
export OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR="${package_dir}"
else
mapfile -t package_tgzs < <(find "${package_dir}" -type f -name "*.tgz" | sort)
if [[ "${#package_tgzs[@]}" -ne 1 ]]; then
echo "package artifact ${PACKAGE_ARTIFACT_NAME} without a preflight manifest must contain exactly one tgz; found ${#package_tgzs[@]}" >&2
exit 1
fi
package_tgz="${package_tgzs[0]}"
candidate_manifest="${package_dir}/package-candidate.json"
node - "${package_tgz}" "${candidate_manifest}" <<'NODE'
const crypto = require("node:crypto");
const childProcess = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const [tarballPath, manifestPath] = process.argv.slice(2);
const packageJson = JSON.parse(
childProcess.execFileSync("tar", ["-xOf", tarballPath, "package/package.json"], {
encoding: "utf8",
}),
);
if (packageJson.name !== "openclaw" || typeof packageJson.version !== "string") {
throw new Error("package artifact tgz is not a versioned openclaw package");
}
if (fs.existsSync(manifestPath)) {
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const expectedTarballName = path.basename(manifest.tarball || "");
if (
manifest.name !== "openclaw" ||
manifest.version !== packageJson.version ||
expectedTarballName !== path.basename(tarballPath) ||
typeof manifest.sha256 !== "string"
) {
throw new Error("package candidate manifest does not match the OpenClaw tarball");
}
const digest = crypto.createHash("sha256").update(fs.readFileSync(tarballPath)).digest("hex");
if (digest !== manifest.sha256) {
throw new Error("package candidate digest mismatch");
}
}
NODE
fi fi
export OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ="${package_tgzs[0]}" export OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ="${package_tgz}"
if [[ -z "${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL// }" ]]; then if [[ -z "${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL// }" ]]; then
export OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="$(basename "${package_tgzs[0]}")" export OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="$(basename "${package_tgz}")"
fi fi
elif [[ -z "${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL// }" ]]; then elif [[ -z "${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL// }" ]]; then
export OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="${OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC}" export OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="${OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC}"

View file

@ -396,17 +396,33 @@ jobs:
fi fi
TARBALL_NAME="$PACK_NAME" TARBALL_NAME="$PACK_NAME"
TARBALL_SHA256="$(sha256sum "$PACK_PATH" | awk '{print $1}')" TARBALL_SHA256="$(sha256sum "$PACK_PATH" | awk '{print $1}')"
mapfile -t AI_TARBALLS < <(find "$AI_RUNTIME_TARBALL_DIR" -maxdepth 1 -type f -name 'openclaw-ai-*.tgz' -print | sort)
if [[ "${#AI_TARBALLS[@]}" -ne 1 ]]; then
echo "Expected exactly one packed @openclaw/ai tarball, found ${#AI_TARBALLS[@]}." >&2
exit 1
fi
AI_TARBALL_PATH="${AI_TARBALLS[0]}"
AI_TARBALL_NAME="$(basename "$AI_TARBALL_PATH")"
AI_TARBALL_SHA256="$(sha256sum "$AI_TARBALL_PATH" | awk '{print $1}')"
AI_PACKAGE_VERSION="$(
tar -xOf "$AI_TARBALL_PATH" package/package.json |
node -e 'let raw = ""; process.stdin.on("data", (chunk) => (raw += chunk)); process.stdin.on("end", () => process.stdout.write(JSON.parse(raw).version));'
)"
if [[ "$AI_PACKAGE_VERSION" != "$PACKAGE_VERSION" ]]; then
echo "Prepared @openclaw/ai version ${AI_PACKAGE_VERSION} does not match openclaw ${PACKAGE_VERSION}." >&2
exit 1
fi
ARTIFACT_DIR="$RUNNER_TEMP/openclaw-npm-preflight" ARTIFACT_DIR="$RUNNER_TEMP/openclaw-npm-preflight"
rm -rf "$ARTIFACT_DIR" rm -rf "$ARTIFACT_DIR"
mkdir -p "$ARTIFACT_DIR" mkdir -p "$ARTIFACT_DIR"
cp "$PACK_PATH" "$ARTIFACT_DIR/" cp "$PACK_PATH" "$ARTIFACT_DIR/"
cp "$AI_RUNTIME_TARBALL_DIR"/*.tgz "$ARTIFACT_DIR/" cp "$AI_TARBALL_PATH" "$ARTIFACT_DIR/"
cp "$AI_RUNTIME_TARBALL_DIR/SHA256SUMS" "$ARTIFACT_DIR/ai-runtime-SHA256SUMS" cp "$AI_RUNTIME_TARBALL_DIR/SHA256SUMS" "$ARTIFACT_DIR/ai-runtime-SHA256SUMS"
cp -R "$DEPENDENCY_EVIDENCE_DIR" "$ARTIFACT_DIR/dependency-evidence" cp -R "$DEPENDENCY_EVIDENCE_DIR" "$ARTIFACT_DIR/dependency-evidence"
printf '%s\n' "$RELEASE_TAG" > "$ARTIFACT_DIR/release-tag.txt" printf '%s\n' "$RELEASE_TAG" > "$ARTIFACT_DIR/release-tag.txt"
printf '%s\n' "$RELEASE_SHA" > "$ARTIFACT_DIR/release-sha.txt" printf '%s\n' "$RELEASE_SHA" > "$ARTIFACT_DIR/release-sha.txt"
printf '%s\n' "$RELEASE_NPM_DIST_TAG" > "$ARTIFACT_DIR/release-npm-dist-tag.txt" printf '%s\n' "$RELEASE_NPM_DIST_TAG" > "$ARTIFACT_DIR/release-npm-dist-tag.txt"
ARTIFACT_DIR="$ARTIFACT_DIR" RELEASE_TAG="$RELEASE_TAG" RELEASE_SHA="$RELEASE_SHA" RELEASE_NPM_DIST_TAG="$RELEASE_NPM_DIST_TAG" PACKAGE_VERSION="$PACKAGE_VERSION" TARBALL_NAME="$TARBALL_NAME" TARBALL_SHA256="$TARBALL_SHA256" node <<'NODE' ARTIFACT_DIR="$ARTIFACT_DIR" RELEASE_TAG="$RELEASE_TAG" RELEASE_SHA="$RELEASE_SHA" RELEASE_NPM_DIST_TAG="$RELEASE_NPM_DIST_TAG" PACKAGE_VERSION="$PACKAGE_VERSION" TARBALL_NAME="$TARBALL_NAME" TARBALL_SHA256="$TARBALL_SHA256" AI_PACKAGE_VERSION="$AI_PACKAGE_VERSION" AI_TARBALL_NAME="$AI_TARBALL_NAME" AI_TARBALL_SHA256="$AI_TARBALL_SHA256" node <<'NODE'
const fs = require("node:fs"); const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const manifest = { const manifest = {
@ -418,6 +434,14 @@ jobs:
packageVersion: process.env.PACKAGE_VERSION, packageVersion: process.env.PACKAGE_VERSION,
tarballName: process.env.TARBALL_NAME, tarballName: process.env.TARBALL_NAME,
tarballSha256: process.env.TARBALL_SHA256, tarballSha256: process.env.TARBALL_SHA256,
dependencyTarballs: [
{
packageName: "@openclaw/ai",
packageVersion: process.env.AI_PACKAGE_VERSION,
tarballName: process.env.AI_TARBALL_NAME,
tarballSha256: process.env.AI_TARBALL_SHA256,
},
],
dependencyEvidenceDir: "dependency-evidence", dependencyEvidenceDir: "dependency-evidence",
dependencyEvidenceManifest: "dependency-evidence/dependency-evidence-manifest.json", dependencyEvidenceManifest: "dependency-evidence/dependency-evidence-manifest.json",
}; };

View file

@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
// Fixture npm registry server for plugin E2E scenarios. // Fixture npm registry server for plugin E2E scenarios.
import crypto from "node:crypto"; import crypto from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
@ -5,6 +6,25 @@ import http from "node:http";
import path from "node:path"; import path from "node:path";
const [portFile, ...packageArgs] = process.argv.slice(2); const [portFile, ...packageArgs] = process.argv.slice(2);
function normalizeUpstreamRegistry(raw) {
if (!raw) {
return undefined;
}
const url = new URL(raw);
if (
(url.protocol !== "http:" && url.protocol !== "https:") ||
url.username ||
url.password ||
url.pathname !== "/" ||
url.search ||
url.hash
) {
throw new Error("OPENCLAW_NPM_REGISTRY_UPSTREAM must be an HTTP(S) origin");
}
return url.origin;
}
const upstreamRegistry = normalizeUpstreamRegistry(process.env.OPENCLAW_NPM_REGISTRY_UPSTREAM);
if (!portFile || packageArgs.length === 0 || packageArgs.length % 3 !== 0) { if (!portFile || packageArgs.length === 0 || packageArgs.length % 3 !== 0) {
console.error( console.error(
@ -14,6 +34,25 @@ if (!portFile || packageArgs.length === 0 || packageArgs.length % 3 !== 0) {
} }
const packages = new Map(); const packages = new Map();
function readPackageManifest(tarballPath, packageName) {
try {
const packageJson = JSON.parse(
execFileSync("tar", ["-xOf", tarballPath, "package/package.json"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}),
);
return packageJson && typeof packageJson === "object" && !Array.isArray(packageJson)
? packageJson
: {};
} catch {
return packageName === "@openclaw/demo-plugin-npm"
? { dependencies: { "is-number": "7.0.0" } }
: {};
}
}
for (let index = 0; index < packageArgs.length; index += 3) { for (let index = 0; index < packageArgs.length; index += 3) {
const packageName = packageArgs[index]; const packageName = packageArgs[index];
const version = packageArgs[index + 1]; const version = packageArgs[index + 1];
@ -28,8 +67,8 @@ for (let index = 0; index < packageArgs.length; index += 3) {
existing.latestVersion = version; existing.latestVersion = version;
existing.versions.set(version, { existing.versions.set(version, {
archive, archive,
dependencies: packageName === "@openclaw/demo-plugin-npm" ? { "is-number": "7.0.0" } : {},
integrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`, integrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`,
manifest: readPackageManifest(tarballPath, packageName),
shasum: crypto.createHash("sha1").update(archive).digest("hex"), shasum: crypto.createHash("sha1").update(archive).digest("hex"),
tarballName: path.basename(tarballPath), tarballName: path.basename(tarballPath),
version, version,
@ -44,7 +83,7 @@ const metadataFor = (entry, baseUrl) => ({
[...entry.versions.entries()].map(([version, versionEntry]) => [ [...entry.versions.entries()].map(([version, versionEntry]) => [
version, version,
{ {
dependencies: versionEntry.dependencies, ...versionEntry.manifest,
name: entry.packageName, name: entry.packageName,
version, version,
dist: { dist: {
@ -85,9 +124,46 @@ function findTarballForPath(pathname) {
return undefined; return undefined;
} }
const server = http.createServer((request, response) => { function resolveUpstreamRequestUrl(rawRequestUrl) {
const url = new URL(request.url ?? "/", "http://127.0.0.1"); const raw = rawRequestUrl || "/";
const baseUrl = `http://127.0.0.1:${server.address().port}`; if (!raw.startsWith("/") || raw.startsWith("//") || raw.includes("\\")) {
throw new Error(`refusing non-origin registry request URL: ${JSON.stringify(raw)}`);
}
const requestUrl = new URL(raw, "http://127.0.0.1");
return `${upstreamRegistry}${requestUrl.pathname}${requestUrl.search}`;
}
async function proxyUpstream(rawRequestUrl, response) {
if (!upstreamRegistry) {
return false;
}
try {
const upstreamUrl = resolveUpstreamRequestUrl(rawRequestUrl);
const upstreamResponse = await fetch(upstreamUrl, { redirect: "manual" });
const body = Buffer.from(await upstreamResponse.arrayBuffer());
// Fetch decodes compressed bodies but preserves upstream length metadata.
// Emit the decoded size so npm clients do not truncate proxied responses.
const headers = { "content-length": String(body.length) };
for (const name of ["content-type", "location"]) {
const value = upstreamResponse.headers.get(name);
if (value) {
headers[name] = value;
}
}
response.writeHead(upstreamResponse.status, headers);
response.end(body);
} catch (error) {
response.writeHead(502, { "content-type": "text/plain" });
response.end(`upstream registry request failed: ${String(error)}`);
}
return true;
}
async function handleRequest(request, response) {
const fallbackHost = `127.0.0.1:${server.address().port}`;
const requestHost = request.headers.host || fallbackHost;
const url = new URL(request.url ?? "/", `http://${requestHost}`);
const baseUrl = url.origin;
if (request.method !== "GET") { if (request.method !== "GET") {
response.writeHead(405, { "content-type": "text/plain" }); response.writeHead(405, { "content-type": "text/plain" });
response.end("method not allowed"); response.end("method not allowed");
@ -111,10 +187,27 @@ const server = http.createServer((request, response) => {
return; return;
} }
if (await proxyUpstream(request.url, response)) {
return;
}
response.writeHead(404, { "content-type": "text/plain" }); response.writeHead(404, { "content-type": "text/plain" });
response.end(`not found: ${url.pathname}`); response.end(`not found: ${url.pathname}`);
}
const server = http.createServer((request, response) => {
void handleRequest(request, response).catch((/** @type {unknown} */ error) => {
if (!response.headersSent) {
response.writeHead(500, { "content-type": "text/plain" });
response.end(`registry request failed: ${String(error)}`);
return;
}
response.destroy(error instanceof Error ? error : new Error(String(error)));
});
}); });
server.listen(0, "127.0.0.1", () => { const bindHost = process.env.OPENCLAW_NPM_REGISTRY_BIND_HOST || "127.0.0.1";
const requestedPort = Number(process.env.OPENCLAW_NPM_REGISTRY_PORT || 0);
server.listen(requestedPort, bindHost, () => {
fs.writeFileSync(portFile, String(server.address().port)); fs.writeFileSync(portFile, String(server.address().port));
}); });

View file

@ -10,6 +10,7 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-npm-telegram-live-e2e" OPENCLAW
DOCKER_TARGET="${OPENCLAW_NPM_TELEGRAM_DOCKER_TARGET:-build}" DOCKER_TARGET="${OPENCLAW_NPM_TELEGRAM_DOCKER_TARGET:-build}"
PACKAGE_SPEC="${OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC:-openclaw@beta}" PACKAGE_SPEC="${OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC:-openclaw@beta}"
PACKAGE_TGZ="${OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ:-${OPENCLAW_CURRENT_PACKAGE_TGZ:-}}" PACKAGE_TGZ="${OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ:-${OPENCLAW_CURRENT_PACKAGE_TGZ:-}}"
PACKAGE_DIR="${OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR:-}"
PACKAGE_LABEL="${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL:-}" PACKAGE_LABEL="${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL:-}"
RUN_ID="${OPENCLAW_NPM_TELEGRAM_RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}" RUN_ID="${OPENCLAW_NPM_TELEGRAM_RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}"
OUTPUT_DIR="${OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR:-.artifacts/qa-e2e/npm-telegram-live/$RUN_ID}" OUTPUT_DIR="${OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR:-.artifacts/qa-e2e/npm-telegram-live/$RUN_ID}"
@ -77,11 +78,58 @@ resolve_package_tgz() {
printf "%s/%s" "$dir" "$base" printf "%s/%s" "$dir" "$base"
} }
resolve_package_dir() {
local candidate="$1"
if [ -z "$candidate" ]; then
return 0
fi
if [ ! -d "$candidate" ]; then
echo "OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR must point to an existing directory; got: $candidate" >&2
exit 1
fi
(cd "$candidate" && pwd)
}
read_package_version() {
tar -xOf "$1" package/package.json |
node -e '
let raw = "";
process.stdin.on("data", (chunk) => (raw += chunk));
process.stdin.on("end", () => {
const version = JSON.parse(raw).version;
if (typeof version !== "string" || !version) {
throw new Error("package tarball is missing a version");
}
process.stdout.write(version);
});
'
}
package_mount_args=() package_mount_args=()
registry_helper_mount_args=()
package_install_source="$PACKAGE_SPEC" package_install_source="$PACKAGE_SPEC"
package_source_kind="npm-package" package_source_kind="npm-package"
resolved_package_tgz="$(resolve_package_tgz "$PACKAGE_TGZ")" resolved_package_tgz="$(resolve_package_tgz "$PACKAGE_TGZ")"
if [ -n "$resolved_package_tgz" ]; then resolved_package_dir="$(resolve_package_dir "$PACKAGE_DIR")"
if [ -n "$resolved_package_dir" ]; then
if [ -z "$resolved_package_tgz" ]; then
echo "OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR requires OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ" >&2
exit 1
fi
case "$resolved_package_tgz" in
"$resolved_package_dir"/*) ;;
*)
echo "OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ must be inside OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR" >&2
exit 1
;;
esac
package_install_source="openclaw@$(read_package_version "$resolved_package_tgz")"
package_source_kind="prepared-package-set"
package_mount_args=(-v "$resolved_package_dir:/package-under-test:ro")
registry_helper_mount_args=(
-v "$ROOT_DIR/scripts/e2e/lib/plugins/npm-registry-server.mjs:/tmp/openclaw-npm-registry-server.mjs:ro"
)
elif [ -n "$resolved_package_tgz" ]; then
package_install_source="/package-under-test/$(basename "$resolved_package_tgz")" package_install_source="/package-under-test/$(basename "$resolved_package_tgz")"
package_source_kind="packed-tarball" package_source_kind="packed-tarball"
package_mount_args=(-v "$resolved_package_tgz:$package_install_source:ro") package_mount_args=(-v "$resolved_package_tgz:$package_install_source:ro")
@ -248,7 +296,9 @@ run_logged docker_e2e_docker_run_cmd run --rm \
-e OPENCLAW_E2E_NPM_INSTALL_TIMEOUT="${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" \ -e OPENCLAW_E2E_NPM_INSTALL_TIMEOUT="${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" \
-e OPENCLAW_NPM_TELEGRAM_INSTALL_SOURCE="$package_install_source" \ -e OPENCLAW_NPM_TELEGRAM_INSTALL_SOURCE="$package_install_source" \
-e OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="$PACKAGE_LABEL" \ -e OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="$PACKAGE_LABEL" \
-e OPENCLAW_NPM_TELEGRAM_PACKAGE_SET="$([ -n "$resolved_package_dir" ] && printf 1 || printf 0)" \
${package_mount_args[@]+"${package_mount_args[@]}"} \ ${package_mount_args[@]+"${package_mount_args[@]}"} \
${registry_helper_mount_args[@]+"${registry_helper_mount_args[@]}"} \
-v "$npm_prefix_host:/npm-global" \ -v "$npm_prefix_host:/npm-global" \
-i "$IMAGE_NAME" bash -s <<'EOF' -i "$IMAGE_NAME" bash -s <<'EOF'
set -euo pipefail set -euo pipefail
@ -261,6 +311,74 @@ install_source="${OPENCLAW_NPM_TELEGRAM_INSTALL_SOURCE:?missing OPENCLAW_NPM_TEL
package_label="${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL:-$install_source}" package_label="${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL:-$install_source}"
echo "Installing ${package_label} from ${install_source}..." echo "Installing ${package_label} from ${install_source}..."
registry_pid=""
registry_log=""
cleanup_registry() {
if [ -n "$registry_pid" ]; then
kill "$registry_pid" >/dev/null 2>&1 || true
wait "$registry_pid" >/dev/null 2>&1 || true
fi
if [ -n "$registry_log" ]; then
rm -f "$registry_log"
fi
}
trap cleanup_registry EXIT
if [ "${OPENCLAW_NPM_TELEGRAM_PACKAGE_SET:-0}" = "1" ]; then
shopt -s nullglob
package_tgzs=(/package-under-test/*.tgz)
shopt -u nullglob
if [ "${#package_tgzs[@]}" -eq 0 ]; then
echo "prepared package set contains no tgz files" >&2
exit 1
fi
registry_args=()
for package_tgz in "${package_tgzs[@]}"; do
package_metadata="$(
tar -xOf "$package_tgz" package/package.json |
node -e '
let raw = "";
process.stdin.on("data", (chunk) => (raw += chunk));
process.stdin.on("end", () => {
const pkg = JSON.parse(raw);
if (typeof pkg.name !== "string" || !pkg.name || typeof pkg.version !== "string" || !pkg.version) {
throw new Error("package tarball is missing name or version");
}
process.stdout.write(`${pkg.name}\n${pkg.version}\n`);
});
'
)"
mapfile -t package_fields <<<"$package_metadata"
registry_args+=("${package_fields[0]}" "${package_fields[1]}" "$package_tgz")
done
registry_port_file="$(mktemp)"
registry_log="$(mktemp)"
OPENCLAW_NPM_REGISTRY_UPSTREAM=https://registry.npmjs.org \
node /tmp/openclaw-npm-registry-server.mjs \
"$registry_port_file" \
"${registry_args[@]}" >"$registry_log" 2>&1 &
registry_pid=$!
for _ in $(seq 1 100); do
if [ -s "$registry_port_file" ]; then
break
fi
if ! kill -0 "$registry_pid" >/dev/null 2>&1; then
cat "$registry_log" >&2
exit 1
fi
sleep 0.1
done
if [ ! -s "$registry_port_file" ]; then
cat "$registry_log" >&2
echo "prepared package registry did not start" >&2
exit 1
fi
registry_url="http://127.0.0.1:$(cat "$registry_port_file")"
rm -f "$registry_port_file"
export NPM_CONFIG_REGISTRY="$registry_url"
export npm_config_registry="$registry_url"
fi
npm_install_timeout="${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm_install_timeout="${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}"
run_npm_install() { run_npm_install() {
if [ -z "$npm_install_timeout" ] || [ "$npm_install_timeout" = "0" ]; then if [ -z "$npm_install_timeout" ] || [ "$npm_install_timeout" = "0" ]; then

View file

@ -1,11 +1,14 @@
// Host Server script supports OpenClaw repository automation. // Host Server script supports OpenClaw repository automation.
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto";
import { rm } from "node:fs/promises";
import { createServer } from "node:http"; import { createServer } from "node:http";
import { createConnection } from "node:net"; import { createConnection } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { sleep as delay } from "../../lib/sleep.mjs"; import { sleep as delay } from "../../lib/sleep.mjs";
import { die, run, say, sh, warn } from "./host-command.ts"; import { die, run, say, sh, warn } from "./host-command.ts";
import type { HostServer } from "./types.ts"; import type { HostServer, NpmRegistryPackage, NpmRegistryServer } from "./types.ts";
const HOST_SERVER_STDERR_LIMIT_BYTES = 64 * 1024; const HOST_SERVER_STDERR_LIMIT_BYTES = 64 * 1024;
const HOST_SERVER_STDERR_DRAIN_MS = 5_000; const HOST_SERVER_STDERR_DRAIN_MS = 5_000;
@ -90,6 +93,44 @@ export async function startHostServer(input: {
}; };
} }
export async function startNpmRegistryServer(input: {
hostIp: string;
packages: NpmRegistryPackage[];
}): Promise<NpmRegistryServer> {
if (input.packages.length === 0) {
die("npm registry server requires at least one package");
}
const port = allocateHostPort();
const portFile = path.join(tmpdir(), `openclaw-npm-registry-${randomUUID()}.port`);
const packageArgs = input.packages.flatMap((pkg) => [pkg.name, pkg.version, pkg.tarballPath]);
const child = spawn(
process.execPath,
["scripts/e2e/lib/plugins/npm-registry-server.mjs", portFile, ...packageArgs],
{
env: {
...process.env,
OPENCLAW_NPM_REGISTRY_BIND_HOST: "0.0.0.0",
OPENCLAW_NPM_REGISTRY_PORT: String(port),
OPENCLAW_NPM_REGISTRY_UPSTREAM: "https://registry.npmjs.org",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
await waitForHostServer(child, port);
const url = `http://${input.hostIp}:${port}`;
say(`Serve prepared npm package set on ${url}`);
return {
url,
stop: async () => {
try {
await stopHostServerChild(child);
} finally {
await rm(portFile, { force: true });
}
},
};
}
async function stopHostServerChild( async function stopHostServerChild(
child: ChildProcessWithoutNullStreams, child: ChildProcessWithoutNullStreams,
terminateTimeoutMs = 2_000, terminateTimeoutMs = 2_000,

View file

@ -130,6 +130,7 @@ const defaultOptions = (): LinuxOptions => ({
latestVersion: "", latestVersion: "",
mode: "both", mode: "both",
modelId: undefined, modelId: undefined,
npmRegistry: undefined,
provider: "openai", provider: "openai",
snapshotHint: "fresh", snapshotHint: "fresh",
targetPackageSpec: "", targetPackageSpec: "",
@ -157,6 +158,7 @@ Options:
--install-version <ver> Pin site-installer version/dist-tag for the baseline lane. --install-version <ver> Pin site-installer version/dist-tag for the baseline lane.
--target-package-spec <npm-spec> --target-package-spec <npm-spec>
Install this npm package tarball instead of packing current main. Install this npm package tarball instead of packing current main.
--npm-registry <url> Registry used for target package installs.
--keep-server Leave temp host HTTP server running. --keep-server Leave temp host HTTP server running.
--json Print machine-readable JSON summary. --json Print machine-readable JSON summary.
-h, --help Show help. -h, --help Show help.
@ -222,6 +224,10 @@ export function parseArgs(argv: string[]): LinuxOptions {
options.targetPackageSpec = ensureValue(args, i, arg); options.targetPackageSpec = ensureValue(args, i, arg);
i++; i++;
break; break;
case "--npm-registry":
options.npmRegistry = ensureValue(args, i, arg);
i++;
break;
case "--keep-server": case "--keep-server":
options.keepServer = true; options.keepServer = true;
break; break;
@ -524,7 +530,17 @@ fi`);
} }
const tgzUrl = this.server.urlFor(this.artifact.path); const tgzUrl = this.server.urlFor(this.artifact.path);
this.downloadGuestFile(tgzUrl, `/tmp/${tempName}`); this.downloadGuestFile(tgzUrl, `/tmp/${tempName}`);
this.guestExec(["npm", "install", "-g", `/tmp/${tempName}`, "--no-fund", "--no-audit"]); const npmArgs = ["npm", "install", "-g", `/tmp/${tempName}`, "--no-fund", "--no-audit"];
this.guestExec(
this.options.npmRegistry
? [
"/usr/bin/env",
`NPM_CONFIG_REGISTRY=${this.options.npmRegistry}`,
`npm_config_registry=${this.options.npmRegistry}`,
...npmArgs,
]
: npmArgs,
);
this.guestExec(["openclaw", "--version"]); this.guestExec(["openclaw", "--version"]);
} }

View file

@ -65,6 +65,7 @@ interface MacosOptions {
hostIp?: string; hostIp?: string;
latestVersion?: string; latestVersion?: string;
installVersion?: string; installVersion?: string;
npmRegistry?: string;
targetPackageSpec?: string; targetPackageSpec?: string;
skipLatestRefCheck: boolean; skipLatestRefCheck: boolean;
keepServer: boolean; keepServer: boolean;
@ -128,6 +129,7 @@ const defaultOptions = (): MacosOptions => ({
latestVersion: "", latestVersion: "",
mode: "both", mode: "both",
modelId: undefined, modelId: undefined,
npmRegistry: undefined,
provider: "openai", provider: "openai",
skipLatestRefCheck: false, skipLatestRefCheck: false,
snapshotHint: "macOS 26.5 latest", snapshotHint: "macOS 26.5 latest",
@ -155,6 +157,7 @@ Options:
--install-version <ver> Pin site-installer version/dist-tag for the baseline lane. --install-version <ver> Pin site-installer version/dist-tag for the baseline lane.
--target-package-spec <npm-spec> --target-package-spec <npm-spec>
Install this npm package tarball instead of packing current main. Install this npm package tarball instead of packing current main.
--npm-registry <url> Registry used for target package installs.
--skip-latest-ref-check Skip the known latest-release ref-mode precheck in upgrade lane. --skip-latest-ref-check Skip the known latest-release ref-mode precheck in upgrade lane.
--keep-server Leave temp host HTTP server running. --keep-server Leave temp host HTTP server running.
--discord-token-env <var> Host env var name for Discord bot token. --discord-token-env <var> Host env var name for Discord bot token.
@ -224,6 +227,10 @@ export function parseArgs(argv: string[]): MacosOptions {
options.targetPackageSpec = ensureValue(args, i, arg); options.targetPackageSpec = ensureValue(args, i, arg);
i++; i++;
break; break;
case "--npm-registry":
options.npmRegistry = ensureValue(args, i, arg);
i++;
break;
case "--skip-latest-ref-check": case "--skip-latest-ref-check":
options.skipLatestRefCheck = true; options.skipLatestRefCheck = true;
break; break;
@ -818,11 +825,14 @@ ${guestOpenClaw} --version`,
} }
private installMain(tempName: string): void { private installMain(tempName: string): void {
const npmRegistryEnv = this.options.npmRegistry
? `NPM_CONFIG_REGISTRY=${shellQuote(this.options.npmRegistry)} npm_config_registry=${shellQuote(this.options.npmRegistry)} `
: "";
if (this.targetInstallsDirectly()) { if (this.targetInstallsDirectly()) {
this this
.guestSh(`printf 'install-source: registry-spec %s\\n' ${shellQuote(this.options.targetPackageSpec || "")} .guestSh(`printf 'install-source: registry-spec %s\\n' ${shellQuote(this.options.targetPackageSpec || "")}
for attempt in 1 2; do for attempt in 1 2; do
if ${guestNpm} install -g ${shellQuote(this.options.targetPackageSpec || "")}; then if ${npmRegistryEnv}${guestNpm} install -g ${shellQuote(this.options.targetPackageSpec || "")}; then
break break
fi fi
if [ "$attempt" -eq 2 ]; then if [ "$attempt" -eq 2 ]; then
@ -842,7 +852,7 @@ ${guestOpenClaw} --version`);
curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${shellQuote( curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${shellQuote(
tgzUrl, tgzUrl,
)} -o /tmp/${tempName} )} -o /tmp/${tempName}
${guestNpm} install -g /tmp/${tempName} ${npmRegistryEnv}${guestNpm} install -g /tmp/${tempName}
${guestOpenClaw} --version`); ${guestOpenClaw} --version`);
} }

View file

@ -21,6 +21,7 @@ import type { Platform, ProviderAuth } from "./types.ts";
interface NpmUpdateScriptInput { interface NpmUpdateScriptInput {
auth: ProviderAuth; auth: ProviderAuth;
expectedNeedle: string; expectedNeedle: string;
npmRegistry?: string;
updateTarget: string; updateTarget: string;
} }
@ -29,6 +30,14 @@ const macosGuestPath =
"/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/usr/local/bin:/usr/local/sbin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin"; "/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/usr/local/bin:/usr/local/sbin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin";
const macosOpenClawCommand = '"$OPENCLAW_BIN"'; const macosOpenClawCommand = '"$OPENCLAW_BIN"';
function posixNpmRegistryEnv(registry: string | undefined): string {
if (!registry) {
return "";
}
const quoted = shellQuote(registry);
return `NPM_CONFIG_REGISTRY=${quoted} npm_config_registry=${quoted} `;
}
function posixModelProviderConfigCommands( function posixModelProviderConfigCommands(
command: string, command: string,
modelId: string, modelId: string,
@ -120,8 +129,11 @@ fi`;
} }
function windowsUpdateWithBundledPluginsDisabled(input: NpmUpdateScriptInput): string { function windowsUpdateWithBundledPluginsDisabled(input: NpmUpdateScriptInput): string {
const registryEntry = input.npmRegistry
? `; NPM_CONFIG_REGISTRY = ${psSingleQuote(input.npmRegistry)}`
: "";
return `$script:OpenClawUpdateExit = 0 return `$script:OpenClawUpdateExit = 0
$updateOutput = Invoke-WithScopedEnv @{ OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'; OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS = '1' } { $updateOutput = Invoke-WithScopedEnv @{ OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'; OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS = '1'${registryEntry} } {
Invoke-OpenClaw update --tag ${psSingleQuote(input.updateTarget)} --yes --json --no-restart 2>&1 Invoke-OpenClaw update --tag ${psSingleQuote(input.updateTarget)} --yes --json --no-restart 2>&1
$script:OpenClawUpdateExit = $LASTEXITCODE $script:OpenClawUpdateExit = $LASTEXITCODE
} }
@ -255,7 +267,7 @@ wait_for_gateway() {
} }
scrub_future_plugin_entries scrub_future_plugin_entries
stop_openclaw_gateway_processes stop_openclaw_gateway_processes
OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1 OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 "$OPENCLAW_BIN" update --tag ${shellQuote(input.updateTarget)} --yes --json --no-restart ${posixNpmRegistryEnv(input.npmRegistry)}OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1 OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 "$OPENCLAW_BIN" update --tag ${shellQuote(input.updateTarget)} --yes --json --no-restart
${posixVersionCheck(macosOpenClawCommand, input.expectedNeedle)} ${posixVersionCheck(macosOpenClawCommand, input.expectedNeedle)}
start_openclaw_gateway start_openclaw_gateway
wait_for_gateway wait_for_gateway
@ -395,7 +407,7 @@ wait_for_gateway() {
} }
scrub_future_plugin_entries scrub_future_plugin_entries
stop_openclaw_gateway_processes stop_openclaw_gateway_processes
OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1 OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update --tag ${shellQuote(input.updateTarget)} --yes --json --no-restart ${posixNpmRegistryEnv(input.npmRegistry)}OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1 OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update --tag ${shellQuote(input.updateTarget)} --yes --json --no-restart
${posixVersionCheck("openclaw", input.expectedNeedle)} ${posixVersionCheck("openclaw", input.expectedNeedle)}
start_openclaw_gateway start_openclaw_gateway
wait_for_gateway wait_for_gateway

View file

@ -14,12 +14,12 @@ import {
import { import {
die, die,
ensureValue, ensureValue,
extractPackageJsonFromTgz,
extractLastOpenClawVersionFromLog, extractLastOpenClawVersionFromLog,
isLikelyMacosDesktopHome, isLikelyMacosDesktopHome,
makeTempDir, makeTempDir,
packOpenClaw, packOpenClaw,
packageBuildCommitFromTgz, packageBuildCommitFromTgz,
packageVersionFromTgz,
parseMacosDsclUserHomeLine, parseMacosDsclUserHomeLine,
parsePlatformList, parsePlatformList,
parseProvider, parseProvider,
@ -34,10 +34,13 @@ import {
say, say,
shellQuote, shellQuote,
startHostServer, startHostServer,
startNpmRegistryServer,
withProgressOnStderr, withProgressOnStderr,
writeSummaryMarkdown, writeSummaryMarkdown,
writeJson, writeJson,
type HostServer, type HostServer,
type NpmRegistryPackage,
type NpmRegistryServer,
type PackageArtifact, type PackageArtifact,
type Platform, type Platform,
type Provider, type Provider,
@ -53,6 +56,7 @@ const LOGGED_POST_FORCE_KILL_WAIT_MS = 1_000;
interface NpmUpdateOptions { interface NpmUpdateOptions {
betaValidation?: string; betaValidation?: string;
dependencyTarballs: string[];
freshTargetSpec?: string; freshTargetSpec?: string;
hostIp?: string; hostIp?: string;
macosVm?: string; macosVm?: string;
@ -373,6 +377,7 @@ Options:
--update-target <target> Target passed to guest 'openclaw update --tag'. --update-target <target> Target passed to guest 'openclaw update --tag'.
Default: host-served tgz packed from current checkout. Default: host-served tgz packed from current checkout.
--target-tarball <path> Host-serve this prepared tgz for update and fresh install. --target-tarball <path> Host-serve this prepared tgz for update and fresh install.
--dependency-tarball <path> Companion package tgz required by the target. Repeatable.
--fresh-target <npm-spec> Also run fresh install smoke for this package after update lanes. --fresh-target <npm-spec> Also run fresh install smoke for this package after update lanes.
--beta-validation [target] Resolve a beta tag/alias/version, then run latest->target update --beta-validation [target] Resolve a beta tag/alias/version, then run latest->target update
plus fresh target install. Default target when flag is bare: beta. plus fresh target install. Default target when flag is bare: beta.
@ -395,6 +400,7 @@ export function parseArgs(argv: string[]): NpmUpdateOptions {
const options: NpmUpdateOptions = { const options: NpmUpdateOptions = {
apiKeyEnv: undefined, apiKeyEnv: undefined,
betaValidation: undefined, betaValidation: undefined,
dependencyTarballs: [],
freshTargetSpec: undefined, freshTargetSpec: undefined,
json: false, json: false,
macosVm: undefined, macosVm: undefined,
@ -422,6 +428,10 @@ export function parseArgs(argv: string[]): NpmUpdateOptions {
options.targetTarball = ensureValue(args, i, arg); options.targetTarball = ensureValue(args, i, arg);
i++; i++;
break; break;
case "--dependency-tarball":
options.dependencyTarballs.push(ensureValue(args, i, arg));
i++;
break;
case "--fresh-target": case "--fresh-target":
options.freshTargetSpec = ensureValue(args, i, arg); options.freshTargetSpec = ensureValue(args, i, arg);
i++; i++;
@ -481,6 +491,9 @@ export function parseArgs(argv: string[]): NpmUpdateOptions {
"--target-tarball cannot be combined with --beta-validation, --update-target, or --fresh-target", "--target-tarball cannot be combined with --beta-validation, --update-target, or --fresh-target",
); );
} }
if (options.dependencyTarballs.length > 0 && !options.targetTarball) {
throw new Error("--dependency-tarball requires --target-tarball");
}
return options; return options;
} }
@ -564,6 +577,7 @@ export class NpmUpdateSmoke {
private harnessTargetFamily = ""; private harnessTargetFamily = "";
private hostIp = ""; private hostIp = "";
protected server: HostServer | null = null; protected server: HostServer | null = null;
private registryServer: NpmRegistryServer | null = null;
private artifact: PackageArtifact | null = null; private artifact: PackageArtifact | null = null;
private freshTargetSpec = ""; private freshTargetSpec = "";
private startedAt = Date.now(); private startedAt = Date.now();
@ -574,7 +588,9 @@ export class NpmUpdateSmoke {
private updateTargetTarball = ""; private updateTargetTarball = "";
private targetTarballPath = ""; private targetTarballPath = "";
private targetTarballBuildCommit = ""; private targetTarballBuildCommit = "";
private targetDependencyPackages: NpmRegistryPackage[] = [];
private targetTarballVersion = ""; private targetTarballVersion = "";
private targetRegistryUrl = "";
private macosVm = macosVmDefault; private macosVm = macosVmDefault;
private linuxVm = linuxVmDefault; private linuxVm = linuxVmDefault;
@ -605,6 +621,7 @@ export class NpmUpdateSmoke {
await this.runSteps(); await this.runSteps();
} finally { } finally {
await this.server?.stop().catch(() => undefined); await this.server?.stop().catch(() => undefined);
await this.registryServer?.stop().catch(() => undefined);
await rm(this.tgzDir, { force: true, recursive: true }).catch(() => undefined); await rm(this.tgzDir, { force: true, recursive: true }).catch(() => undefined);
} }
} }
@ -756,6 +773,9 @@ export class NpmUpdateSmoke {
auth.apiKeyEnv, auth.apiKeyEnv,
"--target-package-spec", "--target-package-spec",
packageSpec, packageSpec,
...(phase === "fresh-target" && this.targetRegistryUrl
? ["--npm-registry", this.targetRegistryUrl]
: []),
"--json", "--json",
...extraArgs, ...extraArgs,
]; ];
@ -799,6 +819,31 @@ export class NpmUpdateSmoke {
path: hostedTarballPath, path: hostedTarballPath,
version: this.targetTarballVersion, version: this.targetTarballVersion,
}; };
if (this.targetDependencyPackages.length > 0) {
// Prepared sibling packages publish before core, so pre-publish VM installs need
// a local registry that serves the exact package set without touching public npm.
this.registryServer = await startNpmRegistryServer({
hostIp: this.hostIp,
packages: [
{
name: "openclaw",
version: this.targetTarballVersion,
tarballPath: hostedTarballPath,
},
...this.targetDependencyPackages,
],
});
this.targetRegistryUrl = this.registryServer.url;
this.updateTargetTarball = `${this.registryServer.url}/openclaw/-/${path.basename(
hostedTarballPath,
)}`;
this.updateTargetEffective = this.targetTarballVersion;
this.freshTargetSpec = this.updateTargetTarball;
this.updateExpectedNeedle = this.targetTarballVersion;
this.updateTargetPackageVersion = this.targetTarballVersion;
this.updateTargetBuildCommit = this.artifact.buildCommitShort ?? "";
return;
}
this.server = await startHostServer({ this.server = await startHostServer({
artifactPath: this.artifact.path, artifactPath: this.artifact.path,
dir: this.tgzDir, dir: this.tgzDir,
@ -979,6 +1024,7 @@ export class NpmUpdateSmoke {
const input = { const input = {
auth: this.authForPlatform(platform), auth: this.authForPlatform(platform),
expectedNeedle: this.updateExpectedNeedle, expectedNeedle: this.updateExpectedNeedle,
npmRegistry: this.targetRegistryUrl,
updateTarget: this.updateTargetEffective, updateTarget: this.updateTargetEffective,
}; };
switch (platform) { switch (platform) {
@ -1385,10 +1431,42 @@ export class NpmUpdateSmoke {
throw new Error(`target tarball does not exist: ${targetTarballPath}`); throw new Error(`target tarball does not exist: ${targetTarballPath}`);
} }
this.targetTarballPath = targetTarballPath; this.targetTarballPath = targetTarballPath;
[this.targetTarballVersion, this.targetTarballBuildCommit] = await Promise.all([ const [targetPackageJson, targetBuildCommit] = await Promise.all([
packageVersionFromTgz(targetTarballPath), extractPackageJsonFromTgz<{
dependencies?: Record<string, string>;
version?: string;
}>(targetTarballPath, "package/package.json"),
packageBuildCommitFromTgz(targetTarballPath), packageBuildCommitFromTgz(targetTarballPath),
]); ]);
this.targetTarballVersion = targetPackageJson.version ?? "";
this.targetTarballBuildCommit = targetBuildCommit;
this.targetDependencyPackages = await Promise.all(
this.options.dependencyTarballs.map(async (dependencyTarball) => {
const tarballPath = path.resolve(dependencyTarball);
if (!existsSync(tarballPath)) {
throw new Error(`dependency tarball does not exist: ${tarballPath}`);
}
const dependencyPackage = await extractPackageJsonFromTgz<{
name?: string;
version?: string;
}>(tarballPath, "package/package.json");
const name = dependencyPackage.name ?? "";
const version = dependencyPackage.version ?? "";
if (!name || !version || name === "openclaw") {
throw new Error(`dependency tarball has invalid package metadata: ${tarballPath}`);
}
if (targetPackageJson.dependencies?.[name] !== version) {
throw new Error(
`target tarball requires ${name}@${targetPackageJson.dependencies?.[name] ?? "<missing>"}, but companion tarball provides ${version}`,
);
}
return { name, version, tarballPath };
}),
);
const dependencyNames = new Set(this.targetDependencyPackages.map((pkg) => pkg.name));
if (dependencyNames.size !== this.targetDependencyPackages.length) {
throw new Error("dependency tarballs must have unique package names");
}
if (!this.targetTarballVersion || !this.targetTarballBuildCommit) { if (!this.targetTarballVersion || !this.targetTarballBuildCommit) {
throw new Error( throw new Error(
`target tarball is missing package or build metadata: ${targetTarballPath}`, `target tarball is missing package or build metadata: ${targetTarballPath}`,

View file

@ -24,6 +24,7 @@ export interface SmokeRunOptions {
json: boolean; json: boolean;
keepServer: boolean; keepServer: boolean;
mode: Mode; mode: Mode;
npmRegistry?: string;
provider: Provider; provider: Provider;
snapshotHint: string; snapshotHint: string;
targetPackageSpec?: string; targetPackageSpec?: string;

View file

@ -45,3 +45,14 @@ export interface HostServer {
urlFor(filePath: string): string; urlFor(filePath: string): string;
stop(): Promise<void>; stop(): Promise<void>;
} }
export interface NpmRegistryPackage {
name: string;
version: string;
tarballPath: string;
}
export interface NpmRegistryServer {
url: string;
stop(): Promise<void>;
}

View file

@ -108,6 +108,7 @@ const defaultOptions = (): WindowsOptions => ({
latestVersion: "", latestVersion: "",
mode: "both", mode: "both",
modelId: undefined, modelId: undefined,
npmRegistry: undefined,
provider: "openai", provider: "openai",
skipLatestRefCheck: false, skipLatestRefCheck: false,
snapshotHint: "pre-openclaw-native-e2e-2026-03-12", snapshotHint: "pre-openclaw-native-e2e-2026-03-12",
@ -142,6 +143,7 @@ Options:
then run openclaw update --channel dev. then run openclaw update --channel dev.
--target-package-spec <npm-spec> --target-package-spec <npm-spec>
Install this npm package tarball instead of packing current main. Install this npm package tarball instead of packing current main.
--npm-registry <url> Registry used for target package installs.
--skip-latest-ref-check Skip latest-release ref-mode precheck. --skip-latest-ref-check Skip latest-release ref-mode precheck.
--keep-server Leave temp host HTTP server running. --keep-server Leave temp host HTTP server running.
--json Print machine-readable JSON summary. --json Print machine-readable JSON summary.
@ -175,6 +177,9 @@ export function parseArgs(argv: string[]): WindowsOptions {
"--model": (value) => { "--model": (value) => {
options.modelId = value; options.modelId = value;
}, },
"--npm-registry": (value) => {
options.npmRegistry = value;
},
"--openai-api-key-env": (value) => { "--openai-api-key-env": (value) => {
options.apiKeyEnv = value; options.apiKeyEnv = value;
}, },
@ -567,11 +572,15 @@ Invoke-OpenClaw --version
die("package artifact/server missing"); die("package artifact/server missing");
} }
const tgzUrl = this.server.urlFor(this.artifact.path); const tgzUrl = this.server.urlFor(this.artifact.path);
const registryScript = this.options.npmRegistry
? `$env:NPM_CONFIG_REGISTRY = ${psSingleQuote(this.options.npmRegistry)}`
: "";
return this.guestPowerShellBackground( return this.guestPowerShellBackground(
`install-main-${tempName.replaceAll(/[^A-Za-z0-9_-]/g, "-")}`, `install-main-${tempName.replaceAll(/[^A-Za-z0-9_-]/g, "-")}`,
`$ErrorActionPreference = 'Stop' `$ErrorActionPreference = 'Stop'
$tgz = Join-Path $env:TEMP ${psSingleQuote(tempName)} $tgz = Join-Path $env:TEMP ${psSingleQuote(tempName)}
curl.exe -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${psSingleQuote(tgzUrl)} -o $tgz curl.exe -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${psSingleQuote(tgzUrl)} -o $tgz
${registryScript}
npm.cmd install -g $tgz --no-fund --no-audit --loglevel=error npm.cmd install -g $tgz --no-fund --no-audit --loglevel=error
if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE" } if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE" }
Invoke-OpenClaw --version Invoke-OpenClaw --version

View file

@ -625,7 +625,7 @@ export function buildPublishCommand(options) {
.join(" "); .join(" ");
} }
function validatePreflightManifest(manifest, params) { export function validatePreflightManifest(manifest, params) {
if (manifest.releaseTag !== params.tag) { if (manifest.releaseTag !== params.tag) {
throw new Error( throw new Error(
`npm preflight tag mismatch: expected ${params.tag}, got ${manifest.releaseTag}`, `npm preflight tag mismatch: expected ${params.tag}, got ${manifest.releaseTag}`,
@ -644,6 +644,20 @@ function validatePreflightManifest(manifest, params) {
if (!manifest.tarballName || !manifest.tarballSha256) { if (!manifest.tarballName || !manifest.tarballSha256) {
throw new Error("npm preflight manifest missing tarball metadata"); throw new Error("npm preflight manifest missing tarball metadata");
} }
if (!Array.isArray(manifest.dependencyTarballs)) {
throw new Error("npm preflight manifest missing dependency tarball metadata");
}
for (const dependency of manifest.dependencyTarballs) {
if (
!dependency?.packageName ||
!dependency.packageVersion ||
!dependency.tarballName ||
!dependency.tarballSha256 ||
dependency.tarballName !== basename(dependency.tarballName)
) {
throw new Error("npm preflight manifest contains invalid dependency tarball metadata");
}
}
} }
export function validateFullManifest(manifest, params) { export function validateFullManifest(manifest, params) {
@ -676,11 +690,22 @@ export function validateFullManifest(manifest, params) {
} }
} }
export function candidateParallelsArgs(tarballPath) { export function candidateParallelsArgs(tarballPath, dependencyTarballPaths = []) {
return ["test:parallels:npm-update", "--", "--target-tarball", tarballPath, "--json"]; return [
"test:parallels:npm-update",
"--",
"--target-tarball",
tarballPath,
...dependencyTarballPaths.flatMap((dependency) => ["--dependency-tarball", dependency]),
"--json",
];
} }
export function candidateParallelsShellCommand(tarballPath, timeoutBin) { export function candidateParallelsShellCommand(
tarballPath,
timeoutBin,
dependencyTarballPaths = [],
) {
return [ return [
'set -a; source "$HOME/.profile" >/dev/null 2>&1 || true; set +a;', 'set -a; source "$HOME/.profile" >/dev/null 2>&1 || true; set +a;',
"exec", "exec",
@ -688,18 +713,18 @@ export function candidateParallelsShellCommand(tarballPath, timeoutBin) {
"--foreground", "--foreground",
"150m", "150m",
"pnpm", "pnpm",
...candidateParallelsArgs(tarballPath).map(shellQuote), ...candidateParallelsArgs(tarballPath, dependencyTarballPaths).map(shellQuote),
].join(" "); ].join(" ");
} }
async function runParallelsIfNeeded(options, tarballPath) { async function runParallelsIfNeeded(options, tarballPath, dependencyTarballPaths) {
if (options.skipParallels) { if (options.skipParallels) {
return { status: "skipped", reason: "operator skipped --skip-parallels" }; return { status: "skipped", reason: "operator skipped --skip-parallels" };
} }
const timeoutBin = run("bash", ["-lc", "command -v gtimeout || command -v timeout"], { const timeoutBin = run("bash", ["-lc", "command -v gtimeout || command -v timeout"], {
capture: true, capture: true,
}).trim(); }).trim();
const command = candidateParallelsShellCommand(tarballPath, timeoutBin); const command = candidateParallelsShellCommand(tarballPath, timeoutBin, dependencyTarballPaths);
run("bash", ["-lc", command]); run("bash", ["-lc", command]);
return { return {
status: "passed", status: "passed",
@ -827,8 +852,21 @@ async function main() {
`prepared tarball digest mismatch: expected ${npmManifest.tarballSha256}, got ${actualTarballSha}`, `prepared tarball digest mismatch: expected ${npmManifest.tarballSha256}, got ${actualTarballSha}`,
); );
} }
const dependencyTarballPaths = npmManifest.dependencyTarballs.map((dependency) => {
const dependencyPath = join(npmDir, dependency.tarballName);
if (!existsSync(dependencyPath)) {
throw new Error(`prepared dependency tarball missing: ${dependencyPath}`);
}
const actualDependencySha = sha256(dependencyPath);
if (actualDependencySha !== dependency.tarballSha256) {
throw new Error(
`prepared dependency tarball digest mismatch for ${dependency.packageName}: expected ${dependency.tarballSha256}, got ${actualDependencySha}`,
);
}
return dependencyPath;
});
const parallels = await runParallelsIfNeeded(options, tarballPath); const parallels = await runParallelsIfNeeded(options, tarballPath, dependencyTarballPaths);
const npmTelegram = await runTelegramIfNeeded(options, npmArtifactName); const npmTelegram = await runTelegramIfNeeded(options, npmArtifactName);
options.npmTelegramRunId = npmTelegram.runId ?? ""; options.npmTelegramRunId = npmTelegram.runId ?? "";
const pluginNpmPlan = await collectPluginPlanWithRetry( const pluginNpmPlan = await collectPluginPlanWithRetry(

View file

@ -130,6 +130,22 @@ describe("package Telegram live Docker E2E", () => {
); );
}); });
it("installs prepared root and companion tarballs through an exact local registry", () => {
const script = readFileSync(DOCKER_SCRIPT_PATH, "utf8");
expect(script).toContain("OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR");
expect(script).toContain('package_source_kind="prepared-package-set"');
expect(script).toContain('package_install_source="openclaw@$(read_package_version');
expect(script).toContain('-v "$resolved_package_dir:/package-under-test:ro"');
expect(script).toContain(
'-v "$ROOT_DIR/scripts/e2e/lib/plugins/npm-registry-server.mjs:/tmp/openclaw-npm-registry-server.mjs:ro"',
);
expect(script).toContain("OPENCLAW_NPM_TELEGRAM_PACKAGE_SET");
expect(script).toContain("node /tmp/openclaw-npm-registry-server.mjs");
expect(script).toContain("OPENCLAW_NPM_REGISTRY_UPSTREAM=https://registry.npmjs.org");
expect(script).toContain('export NPM_CONFIG_REGISTRY="$registry_url"');
});
it("keeps live Docker artifacts isolated by default", () => { it("keeps live Docker artifacts isolated by default", () => {
const script = readFileSync(DOCKER_SCRIPT_PATH, "utf8"); const script = readFileSync(DOCKER_SCRIPT_PATH, "utf8");

View file

@ -1782,7 +1782,15 @@ describe("package artifact reuse", () => {
"package_spec must be openclaw@alpha", "package_spec must be openclaw@alpha",
]); ]);
expectTextToIncludeAll(runStep.run, [ expectTextToIncludeAll(runStep.run, [
'export OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ="${package_tgzs[0]}"', 'manifest="${package_dir}/preflight-manifest.json"',
'candidate_manifest="${package_dir}/package-candidate.json"',
'find "${package_dir}" -type f -name "*.tgz"',
"package artifact manifest contains duplicate package metadata",
"package artifact tarball set does not match preflight manifest",
"package candidate manifest does not match the OpenClaw tarball",
"package candidate digest mismatch",
'export OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR="${package_dir}"',
'export OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ="${package_tgz}"',
]); ]);
}); });
@ -1962,6 +1970,14 @@ describe("package artifact reuse", () => {
"Workflow-dispatched real publish requires release_publish_run_id", "Workflow-dispatched real publish requires release_publish_run_id",
); );
expect(npmWorkflow).toContain("tarballSha256"); expect(npmWorkflow).toContain("tarballSha256");
expect(npmWorkflow).toContain("dependencyTarballs");
expect(npmWorkflow).toContain('packageName: "@openclaw/ai"');
expect(npmWorkflow).toContain("AI_TARBALL_SHA256");
expect(npmWorkflow).toContain("does not match openclaw");
const npmTelegramWorkflow = readFileSync(NPM_TELEGRAM_WORKFLOW, "utf8");
expect(npmTelegramWorkflow).toContain("preflight-manifest.json");
expect(npmTelegramWorkflow).toContain("OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR");
expect(npmTelegramWorkflow).toContain("package artifact digest mismatch");
expect(workflow).toContain("Checkout release SHA"); expect(workflow).toContain("Checkout release SHA");
expect(workflow).toContain('git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}"'); expect(workflow).toContain('git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}"');
expect(workflow).toContain('$0 == "## Unreleased" { in_section = 1; next }'); expect(workflow).toContain('$0 == "## Unreleased" { in_section = 1; next }');

View file

@ -106,7 +106,15 @@ afterEach(() => {
describe("parallels npm update smoke", () => { describe("parallels npm update smoke", () => {
it("accepts one prepared tarball target for update and fresh install", () => { it("accepts one prepared tarball target for update and fresh install", () => {
expect(parseArgs(["--target-tarball", "/tmp/openclaw-candidate.tgz"])).toMatchObject({ expect(
parseArgs([
"--target-tarball",
"/tmp/openclaw-candidate.tgz",
"--dependency-tarball",
"/tmp/openclaw-ai-candidate.tgz",
]),
).toMatchObject({
dependencyTarballs: ["/tmp/openclaw-ai-candidate.tgz"],
targetTarball: "/tmp/openclaw-candidate.tgz", targetTarball: "/tmp/openclaw-candidate.tgz",
updateTarget: "", updateTarget: "",
freshTargetSpec: undefined, freshTargetSpec: undefined,
@ -114,6 +122,9 @@ describe("parallels npm update smoke", () => {
expect(() => expect(() =>
parseArgs(["--target-tarball", "/tmp/openclaw-candidate.tgz", "--update-target", "beta"]), parseArgs(["--target-tarball", "/tmp/openclaw-candidate.tgz", "--update-target", "beta"]),
).toThrow("--target-tarball cannot be combined"); ).toThrow("--target-tarball cannot be combined");
expect(() => parseArgs(["--dependency-tarball", "/tmp/openclaw-ai-candidate.tgz"])).toThrow(
"--dependency-tarball requires --target-tarball",
);
}); });
it("stops the host artifact server when the wrapper fails mid-run", async () => { it("stops the host artifact server when the wrapper fails mid-run", async () => {
@ -231,18 +242,33 @@ exit 1
expect(script).toContain("freshTargetStatus"); expect(script).toContain("freshTargetStatus");
}); });
it("host-serves a prepared candidate tarball for both proof phases", () => { it("serves a prepared package set for both proof phases", () => {
const script = readFileSync(SCRIPT_PATH, "utf8"); const script = readFileSync(SCRIPT_PATH, "utf8");
expect(script).toContain("--target-tarball <path>"); expect(script).toContain("--target-tarball <path>");
expect(script).toContain("--dependency-tarball <path>");
expect(script).toContain('label: "prepared candidate tgz"'); expect(script).toContain('label: "prepared candidate tgz"');
expect(script).toContain("await copyFile(this.targetTarballPath, hostedTarballPath)"); expect(script).toContain("await copyFile(this.targetTarballPath, hostedTarballPath)");
expect(script).toContain("dir: this.tgzDir"); expect(script).toContain("startNpmRegistryServer");
expect(script).toContain("this.updateTargetEffective = targetUrl"); expect(script).toContain("this.updateTargetEffective = this.targetTarballVersion");
expect(script).toContain("this.freshTargetSpec = targetUrl"); expect(script).toContain("this.freshTargetSpec = this.updateTargetTarball");
expect(script).toContain("this.updateExpectedNeedle = this.targetTarballVersion"); expect(script).toContain("this.updateExpectedNeedle = this.targetTarballVersion");
}); });
it("routes update installs through the prepared package registry", () => {
const registry = "http://192.0.2.2:48123";
const input = {
auth: TEST_AUTH,
expectedNeedle: "2026.7.1-beta.3",
npmRegistry: registry,
updateTarget: "2026.7.1-beta.3",
};
expect(macosUpdateScript(input)).toContain(`NPM_CONFIG_REGISTRY='${registry}'`);
expect(linuxUpdateScript(input)).toContain(`NPM_CONFIG_REGISTRY='${registry}'`);
expect(windowsUpdateScript(input)).toContain(`NPM_CONFIG_REGISTRY = '${registry}'`);
});
it("accepts keyed and nested npm metadata for published update targets", () => { it("accepts keyed and nested npm metadata for published update targets", () => {
const script = readFileSync(SCRIPT_PATH, "utf8"); const script = readFileSync(SCRIPT_PATH, "utf8");

View file

@ -282,6 +282,11 @@ describe("Parallels smoke model selection", () => {
expect(parseMacosSmokeArgs(["--host-port", "65535"]).hostPort).toBe(65535); expect(parseMacosSmokeArgs(["--host-port", "65535"]).hostPort).toBe(65535);
expect(parseLinuxSmokeArgs(["--host-port", "65535"]).hostPort).toBe(65535); expect(parseLinuxSmokeArgs(["--host-port", "65535"]).hostPort).toBe(65535);
expect(parseWindowsSmokeArgs(["--host-port", "65535"]).hostPort).toBe(65535); expect(parseWindowsSmokeArgs(["--host-port", "65535"]).hostPort).toBe(65535);
for (const parseArgs of [parseMacosSmokeArgs, parseLinuxSmokeArgs, parseWindowsSmokeArgs]) {
expect(parseArgs(["--npm-registry", "http://192.0.2.2:48123"]).npmRegistry).toBe(
"http://192.0.2.2:48123",
);
}
expect(parseNpmUpdateSmokeArgs(["--", "--package-spec", "openclaw@2026.5.1"]).packageSpec).toBe( expect(parseNpmUpdateSmokeArgs(["--", "--package-spec", "openclaw@2026.5.1"]).packageSpec).toBe(
"openclaw@2026.5.1", "openclaw@2026.5.1",
); );
@ -406,6 +411,8 @@ describe("Parallels smoke model selection", () => {
expect(parallelsVm).toContain("export function resolveMacosVmName"); expect(parallelsVm).toContain("export function resolveMacosVmName");
expect(parallelsVm).toContain("export function waitForVmStatus"); expect(parallelsVm).toContain("export function waitForVmStatus");
expect(hostServer).toContain("export async function startHostServer"); expect(hostServer).toContain("export async function startHostServer");
expect(hostServer).toContain("export async function startNpmRegistryServer");
expect(hostServer).toContain('OPENCLAW_NPM_REGISTRY_UPSTREAM: "https://registry.npmjs.org"');
expect(hostServer).toContain("http.server"); expect(hostServer).toContain("http.server");
expect(snapshots).toContain("export function resolveSnapshot"); expect(snapshots).toContain("export function resolveSnapshot");
expect(smokeCommon).toContain("runSmokeLane"); expect(smokeCommon).toContain("runSmokeLane");

View file

@ -12,6 +12,7 @@ import {
import { createServer, request as httpRequest } from "node:http"; import { createServer, request as httpRequest } from "node:http";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { gzipSync } from "node:zlib";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createBoundedChildOutput } from "../helpers/bounded-child-output.js"; import { createBoundedChildOutput } from "../helpers/bounded-child-output.js";
import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js"; import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js";
@ -159,10 +160,11 @@ async function waitForPortFile(portFile: string): Promise<number> {
function requestFixtureRegistry( function requestFixtureRegistry(
port: number, port: number,
requestPath: string, requestPath: string,
): Promise<{ body: string; statusCode: number | undefined }> { headers: Record<string, string> = {},
): Promise<{ body: string; contentLength: string | undefined; statusCode: number | undefined }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = httpRequest( const request = httpRequest(
{ host: "127.0.0.1", method: "GET", path: requestPath, port }, { headers, host: "127.0.0.1", method: "GET", path: requestPath, port },
(response) => { (response) => {
let body = ""; let body = "";
response.setEncoding("utf8"); response.setEncoding("utf8");
@ -170,7 +172,11 @@ function requestFixtureRegistry(
body += chunk; body += chunk;
}); });
response.on("end", () => { response.on("end", () => {
resolve({ body, statusCode: response.statusCode }); resolve({
body,
contentLength: response.headers["content-length"],
statusCode: response.statusCode,
});
}); });
}, },
); );
@ -602,6 +608,235 @@ test -d "$OPENCLAW_PLUGINS_TMP_DIR"
} }
}); });
it("serves tarball dependencies using the request-visible registry origin", async () => {
const tempDirs: string[] = [];
const root = makeTempDir(tempDirs, "openclaw-plugin-npm-fixture-package-");
const packageDir = path.join(root, "package");
const portFile = path.join(root, "port");
const tarballPath = path.join(root, "openclaw.tgz");
mkdirSync(packageDir);
writeJson(path.join(packageDir, "package.json"), {
name: "openclaw",
version: "2026.7.1-beta.3",
dependencies: {
"@openclaw/ai": "2026.7.1-beta.3",
zod: "4.3.6",
},
optionalDependencies: {
"sqlite-vec": "0.1.7-alpha.2",
},
});
const packed = spawnSync("tar", ["-czf", tarballPath, "-C", root, "package"], {
encoding: "utf8",
});
expect(packed.status, packed.stderr).toBe(0);
const child = spawn(
process.execPath,
[
"scripts/e2e/lib/plugins/npm-registry-server.mjs",
portFile,
"openclaw",
"2026.7.1-beta.3",
tarballPath,
],
{
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
},
);
try {
const port = await waitForPortFile(portFile);
const response = await requestFixtureRegistry(port, "/openclaw", {
host: `192.0.2.2:${port}`,
});
const metadata = JSON.parse(response.body);
expect(response.statusCode).toBe(200);
expect(metadata.versions["2026.7.1-beta.3"].dependencies).toEqual({
"@openclaw/ai": "2026.7.1-beta.3",
zod: "4.3.6",
});
expect(metadata.versions["2026.7.1-beta.3"].optionalDependencies).toEqual({
"sqlite-vec": "0.1.7-alpha.2",
});
expect(metadata.versions["2026.7.1-beta.3"].dist.tarball).toBe(
`http://192.0.2.2:${port}/openclaw/-/openclaw.tgz`,
);
} finally {
if (child.exitCode === null) {
child.kill();
await new Promise((resolve) => {
child.once("close", resolve);
});
}
cleanupTempDirs(tempDirs);
}
});
it("recomputes proxied content length after fetch decodes the response", async () => {
const tempDirs: string[] = [];
const root = makeTempDir(tempDirs, "openclaw-plugin-npm-fixture-proxy-");
const portFile = path.join(root, "port");
const tarballPath = path.join(root, "demo-plugin.tgz");
const upstreamBody = JSON.stringify({ payload: "x".repeat(1_000) });
const compressedBody = gzipSync(upstreamBody);
writeFileSync(tarballPath, "fixture package archive", "utf8");
const upstream = createServer((_request, response) => {
response.writeHead(200, {
"content-encoding": "gzip",
"content-length": String(compressedBody.length),
"content-type": "application/json",
});
response.end(compressedBody);
});
await new Promise<void>((resolve) => {
upstream.listen(0, "127.0.0.1", resolve);
});
const upstreamAddress = upstream.address();
if (!upstreamAddress || typeof upstreamAddress === "string") {
throw new Error("expected upstream registry address");
}
const child = spawn(
process.execPath,
[
"scripts/e2e/lib/plugins/npm-registry-server.mjs",
portFile,
"@openclaw/demo-plugin-npm",
"1.0.0",
tarballPath,
],
{
cwd: process.cwd(),
env: {
...process.env,
OPENCLAW_NPM_REGISTRY_UPSTREAM: `http://127.0.0.1:${upstreamAddress.port}`,
},
stdio: ["ignore", "pipe", "pipe"],
},
);
try {
const port = await waitForPortFile(portFile);
const response = await requestFixtureRegistry(port, "/upstream-package");
expect(response.statusCode).toBe(200);
expect(response.body).toBe(upstreamBody);
expect(response.contentLength).toBe(String(Buffer.byteLength(upstreamBody)));
} finally {
if (child.exitCode === null) {
child.kill();
await new Promise((resolve) => {
child.once("close", resolve);
});
}
await new Promise<void>((resolve) => {
upstream.close(() => resolve());
});
cleanupTempDirs(tempDirs);
}
});
it("does not let absolute-form request targets escape the configured upstream", async () => {
const tempDirs: string[] = [];
const root = makeTempDir(tempDirs, "openclaw-plugin-npm-fixture-proxy-origin-");
const portFile = path.join(root, "port");
const tarballPath = path.join(root, "demo-plugin.tgz");
let configuredUpstreamHits = 0;
let escapeServerHits = 0;
let configuredUpstreamTarget: string | undefined;
writeFileSync(tarballPath, "fixture package archive", "utf8");
const configuredUpstream = createServer((request, response) => {
configuredUpstreamHits += 1;
configuredUpstreamTarget = request.url;
response.writeHead(200, { "content-type": "text/plain" });
response.end("configured upstream");
});
const escapeServer = createServer((_request, response) => {
escapeServerHits += 1;
response.writeHead(200, { "content-type": "text/plain" });
response.end("escaped upstream");
});
await Promise.all([
new Promise<void>((resolve) => {
configuredUpstream.listen(0, "127.0.0.1", resolve);
}),
new Promise<void>((resolve) => {
escapeServer.listen(0, "127.0.0.1", resolve);
}),
]);
const configuredAddress = configuredUpstream.address();
const escapeAddress = escapeServer.address();
if (
!configuredAddress ||
typeof configuredAddress === "string" ||
!escapeAddress ||
typeof escapeAddress === "string"
) {
throw new Error("expected upstream registry addresses");
}
const child = spawn(
process.execPath,
[
"scripts/e2e/lib/plugins/npm-registry-server.mjs",
portFile,
"@openclaw/demo-plugin-npm",
"1.0.0",
tarballPath,
],
{
cwd: process.cwd(),
env: {
...process.env,
OPENCLAW_NPM_REGISTRY_UPSTREAM: `http://127.0.0.1:${configuredAddress.port}`,
},
stdio: ["ignore", "pipe", "pipe"],
},
);
try {
const port = await waitForPortFile(portFile);
const escaped = await requestFixtureRegistry(
port,
`http://registry.invalid//127.0.0.1:${escapeAddress.port}/probe`,
);
expect(escaped.statusCode).toBe(502);
expect(escaped.body).toContain("refusing non-origin registry request URL");
expect(configuredUpstreamHits).toBe(0);
expect(escapeServerHits).toBe(0);
const valid = await requestFixtureRegistry(port, "/pkg?x=1");
expect(valid.statusCode).toBe(200);
expect(valid.body).toBe("configured upstream");
expect(configuredUpstreamHits).toBe(1);
expect(configuredUpstreamTarget).toBe("/pkg?x=1");
expect(escapeServerHits).toBe(0);
} finally {
if (child.exitCode === null) {
child.kill();
await new Promise((resolve) => {
child.once("close", resolve);
});
}
await Promise.all([
new Promise<void>((resolve) => {
configuredUpstream.close(() => resolve());
}),
new Promise<void>((resolve) => {
escapeServer.close(() => resolve());
}),
]);
cleanupTempDirs(tempDirs);
}
});
it("rejects invalid plugin fixture log byte limits before npm fixture setup", () => { it("rejects invalid plugin fixture log byte limits before npm fixture setup", () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-npm-fixture-log-invalid-")); const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-npm-fixture-log-invalid-"));
try { try {

View file

@ -10,6 +10,7 @@ import {
resolveArtifactName, resolveArtifactName,
requireRunIdFromDispatchOutput, requireRunIdFromDispatchOutput,
validateFullManifest, validateFullManifest,
validatePreflightManifest,
validateWindowsSourceRelease, validateWindowsSourceRelease,
} from "../../scripts/release-candidate-checklist.mjs"; } from "../../scripts/release-candidate-checklist.mjs";
@ -69,8 +70,64 @@ describe("release candidate checklist", () => {
candidateParallelsShellCommand( candidateParallelsShellCommand(
".artifacts/preflight/openclaw candidate.tgz", ".artifacts/preflight/openclaw candidate.tgz",
"/opt/homebrew/bin/gtimeout", "/opt/homebrew/bin/gtimeout",
[".artifacts/preflight/openclaw-ai candidate.tgz"],
), ),
).toContain("'--target-tarball' '.artifacts/preflight/openclaw candidate.tgz'"); ).toContain("'--target-tarball' '.artifacts/preflight/openclaw candidate.tgz'");
expect(
candidateParallelsArgs(".artifacts/preflight/openclaw.tgz", [
".artifacts/preflight/openclaw-ai.tgz",
]),
).toEqual([
"test:parallels:npm-update",
"--",
"--target-tarball",
".artifacts/preflight/openclaw.tgz",
"--dependency-tarball",
".artifacts/preflight/openclaw-ai.tgz",
"--json",
]);
});
it("requires exact dependency tarball metadata in npm preflight manifests", () => {
const manifest = {
releaseTag: "v2026.7.1-beta.3",
releaseSha: "candidate-sha",
npmDistTag: "beta",
tarballName: "openclaw-2026.7.1-beta.3.tgz",
tarballSha256: "root-sha",
dependencyTarballs: [
{
packageName: "@openclaw/ai",
packageVersion: "2026.7.1-beta.3",
tarballName: "openclaw-ai-2026.7.1-beta.3.tgz",
tarballSha256: "ai-sha",
},
],
};
const params = {
tag: "v2026.7.1-beta.3",
targetSha: "candidate-sha",
npmDistTag: "beta",
};
expect(() => validatePreflightManifest(manifest, params)).not.toThrow();
expect(() =>
validatePreflightManifest({ ...manifest, dependencyTarballs: undefined }, params),
).toThrow("missing dependency tarball metadata");
expect(() =>
validatePreflightManifest(
{
...manifest,
dependencyTarballs: [
{
...manifest.dependencyTarballs[0],
tarballName: "../openclaw-ai.tgz",
},
],
},
params,
),
).toThrow("invalid dependency tarball metadata");
}); });
it("requires run ids when dispatch is disabled", () => { it("requires run ids when dispatch is disabled", () => {
@ -87,7 +144,10 @@ describe("release candidate checklist", () => {
secondValue: string, secondValue: string,
prefix = requiredArgs, prefix = requiredArgs,
): [string, string[]] => [flag, [...prefix, flag, firstValue, flag, secondValue]]; ): [string, string[]] => [flag, [...prefix, flag, firstValue, flag, secondValue]];
const duplicateFlag = (flag: string): [string, string[]] => [flag, [...requiredArgs, flag, flag]]; const duplicateFlag = (flag: string): [string, string[]] => [
flag,
[...requiredArgs, flag, flag],
];
const duplicateCases = [ const duplicateCases = [
duplicateOption("--tag", "v2026.5.14-beta.3", "v2026.5.14-beta.4", []), duplicateOption("--tag", "v2026.5.14-beta.3", "v2026.5.14-beta.4", []),
duplicateOption("--workflow-ref", "release/a", "release/b"), duplicateOption("--workflow-ref", "release/a", "release/b"),