mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
aaf5ddd832
commit
0535679083
19 changed files with 957 additions and 43 deletions
115
.github/workflows/npm-telegram-beta-e2e.yml
vendored
115
.github/workflows/npm-telegram-beta-e2e.yml
vendored
|
|
@ -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}"
|
||||||
|
|
|
||||||
28
.github/workflows/openclaw-npm-release.yml
vendored
28
.github/workflows/openclaw-npm-release.yml
vendored
|
|
@ -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",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}`,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }');
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue