Files
openclaw/scripts/plugin-publication-artifact.mjs
Vincent Koc 022dd9dc27 fix(release): unblock Meta npm bootstrap publication (#103951)
* fix(release): isolate Meta npm bootstrap

* test(release): assert OIDC npm version guard
2026-07-10 14:50:00 -07:00

1294 lines
47 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import { lstatSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { gunzipSync, inflateRawSync } from "node:zlib";
import {
downloadActionsArtifactArchive,
describeActionsArtifactFiles,
inspectActionsArtifactZip,
inspectActionsArtifactZipWithPolicy,
readBoundedRegularFile,
readPublicationArtifactArchive,
sha256Digest,
validateActionsArtifactBinding,
validateActionsArtifactProducerJob,
} from "./lib/actions-artifact-archive.mjs";
import { resolveNpmPublishPlan } from "./lib/npm-publish-plan.mjs";
export {
downloadActionsArtifactArchive,
describeActionsArtifactFiles,
inspectActionsArtifactZip,
inspectActionsArtifactZipWithPolicy,
readBoundedRegularFile,
readPublicationArtifactArchive,
validateActionsArtifactBinding,
validateActionsArtifactProducerJob,
};
const MANIFEST_FILENAME = "plugin-publication-manifest.json";
const MANIFEST_SCHEMA = "openclaw.plugin-publication-artifact/v1";
const TAR_BLOCK_BYTES = 512;
const TAR_END_MARKER_BYTES = TAR_BLOCK_BYTES * 2;
const TAR_USTAR_MAGIC = Buffer.from("ustar\0", "ascii");
const TAR_USTAR_VERSION = Buffer.from("00", "ascii");
const MAX_ARCHIVE_BYTES = 256 * 1024 * 1024;
const MAX_EXPANDED_BYTES = 512 * 1024 * 1024;
const MAX_MANIFEST_BYTES = 2 * 1024 * 1024;
const MAX_PLUGIN_MANIFEST_BYTES = 2 * 1024 * 1024;
const MAX_TAR_ENTRIES = 10_000;
const MAX_TAR_PATH_BYTES = 4 * 1024 * 1024;
const MAX_TAR_TOTAL_FILE_BYTES = 512 * 1024 * 1024;
export const CLAWHUB_PUBLICATION_TAR_LIMITS = Object.freeze({
maxArchiveBytes: 120 * 1024 * 1024,
maxEntries: 10_000,
maxEntryBytes: 50 * 1024 * 1024,
maxExpandedBytes: 64 * 1024 * 1024,
maxPathBytes: 4 * 1024 * 1024,
maxTotalFileBytes: 50 * 1024 * 1024,
});
const SHA_RE = /^[0-9a-f]{40}$/u;
const SHA256_RE = /^[0-9a-f]{64}$/u;
const ARTIFACT_DIGEST_RE = /^sha256:[0-9a-f]{64}$/u;
const ARTIFACT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/u;
const PACKAGE_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u;
const VERSION_RE =
/^[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(?:-(?:alpha|beta)\.[1-9][0-9]*|-[1-9][0-9]*)?$/u;
const NPM_ROUTE_POLICIES = new Map([
["npm-oidc", { authMode: "trusted-publisher", capability: "trusted-publisher" }],
["npm-token-bootstrap", { authMode: "token-bootstrap", capability: "first-publication" }],
[
"npm-token-placeholder-recovery",
{ authMode: "token-bootstrap", capability: "placeholder-recovery" },
],
["npm-mirror", { authMode: "release-token", capability: "dist-tag-mirror" }],
["npm-tag-repair", { authMode: "release-token", capability: "dist-tag-repair" }],
["npm-readback", { authMode: "none", capability: "registry-readback" }],
]);
const ROUTES = new Set([
...NPM_ROUTE_POLICIES.keys(),
"clawhub-token-release",
"clawhub-token-bootstrap",
"clawhub-readback",
]);
const NPM_TAGS = new Set(["latest", "alpha", "beta", "extended-stable"]);
const CLAWHUB_TAGS = new Set(["latest", "alpha", "beta"]);
const META_PACKAGE = "@openclaw/meta-provider";
const META_PACKAGE_DIR = "extensions/meta";
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function npmIntegrity(bytes) {
return `sha512-${createHash("sha512").update(bytes).digest("base64")}`;
}
function npmShasum(bytes) {
return createHash("sha1").update(bytes).digest("hex");
}
function compareCodeUnits(left, right) {
return left < right ? -1 : left > right ? 1 : 0;
}
function assertString(value, label) {
if (typeof value !== "string" || value.trim() !== value || value.length === 0) {
throw new Error(`${label} must be a non-empty trimmed string.`);
}
return value;
}
function assertPositiveInteger(value, label) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${label} must be a safe positive integer.`);
}
return value;
}
function assertBooleanString(value, label) {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
throw new Error(`${label} must be true or false.`);
}
function hasControlCharacters(value) {
for (const character of value) {
const codePoint = character.codePointAt(0);
if (codePoint <= 0x1f || codePoint === 0x7f) {
return true;
}
}
return false;
}
function normalizeManualOverrideReason(value) {
if (value === undefined || value === null || value === "") {
return null;
}
const reason = assertString(value, "manual override reason");
if (reason.length > 500 || hasControlCharacters(reason)) {
throw new Error(
"Manual override reason must be at most 500 characters and contain no control characters.",
);
}
return reason;
}
function assertSafePackageDir(value) {
const packageDir = assertString(value, "package dir");
if (!/^extensions\/[a-z0-9][a-z0-9._-]*$/u.test(packageDir) || packageDir.includes("\\")) {
throw new Error(`Unsafe plugin package dir: ${packageDir}`);
}
return packageDir;
}
function assertSafeArtifactName(value) {
const artifactName = assertString(value, "artifact name");
if (!ARTIFACT_NAME_RE.test(artifactName)) {
throw new Error(`Unsafe plugin publication artifact name: ${artifactName}`);
}
return artifactName;
}
function assertSafeArchivePath(value, label) {
const raw = assertString(value, label);
if (
raw.startsWith("/") ||
raw.includes("\\") ||
raw.includes("\0") ||
raw.normalize("NFC") !== raw ||
hasControlCharacters(raw)
) {
throw new Error(`Unsafe ${label}: ${JSON.stringify(raw)}`);
}
const withoutTrailingSlash = raw.endsWith("/") ? raw.slice(0, -1) : raw;
const parts = withoutTrailingSlash.split("/");
if (
withoutTrailingSlash.length === 0 ||
parts.some((part) => part.length === 0 || part === "." || part === "..")
) {
throw new Error(`Unsafe ${label}: ${JSON.stringify(raw)}`);
}
return withoutTrailingSlash;
}
function normalizePublicationReason(value) {
const reason = assertString(value, "publication reason");
if (reason.length > 500 || hasControlCharacters(reason)) {
throw new Error(
"Publication reason must be at most 500 characters and contain no control characters.",
);
}
return reason;
}
function normalizePublisherPolicy(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Publisher policy must be an object.");
}
const keys = Object.keys(value).toSorted();
if (JSON.stringify(keys) !== JSON.stringify(["policyId", "schema", "sha256"])) {
throw new Error("Publisher policy must contain exactly schema, policyId, and sha256.");
}
const schema = assertString(value.schema, "publisher policy schema");
const policyId = assertString(value.policyId, "publisher policy id");
const policySha256 = assertString(value.sha256, "publisher policy SHA-256");
if (
schema.length > 200 ||
policyId.length > 200 ||
hasControlCharacters(schema) ||
hasControlCharacters(policyId) ||
!SHA256_RE.test(policySha256)
) {
throw new Error("Publisher policy identity is invalid.");
}
return { policyId, schema, sha256: policySha256 };
}
function boundedTarLimit(value, fallback, label) {
if (value === undefined) {
return fallback;
}
if (!Number.isSafeInteger(value) || value <= 0 || value > fallback) {
throw new Error(`${label} must be a positive safe integer no larger than ${fallback}.`);
}
return value;
}
function normalizeTarInspectionOptions(options = {}) {
const maxArchiveBytes = boundedTarLimit(
options.maxArchiveBytes,
MAX_ARCHIVE_BYTES,
"Plugin tarball byte limit",
);
const maxExpandedBytes = boundedTarLimit(
options.maxExpandedBytes,
MAX_EXPANDED_BYTES,
"Plugin tarball expanded-byte limit",
);
const maxEntryBytes = boundedTarLimit(
options.maxEntryBytes,
maxExpandedBytes,
"Plugin tarball per-entry byte limit",
);
const maxTotalFileBytes = boundedTarLimit(
options.maxTotalFileBytes,
Math.min(MAX_TAR_TOTAL_FILE_BYTES, maxExpandedBytes),
"Plugin tarball total-file byte limit",
);
const maxEntries = boundedTarLimit(
options.maxEntries,
MAX_TAR_ENTRIES,
"Plugin tarball entry-count limit",
);
const maxPathBytes = boundedTarLimit(
options.maxPathBytes,
MAX_TAR_PATH_BYTES,
"Plugin tarball path-byte limit",
);
return {
maxArchiveBytes,
maxEntries,
maxEntryBytes,
maxExpandedBytes,
maxPathBytes,
maxTotalFileBytes,
};
}
function decodeTarString(bytes, label = "tar string field") {
const nul = bytes.indexOf(0);
if (nul !== -1 && bytes.subarray(nul + 1).some((byte) => byte !== 0)) {
throw new Error(`${label} has non-zero bytes after its NUL terminator.`);
}
const value = bytes.subarray(0, nul === -1 ? bytes.length : nul);
try {
return new TextDecoder("utf-8", { fatal: true }).decode(value);
} catch {
throw new Error(`${label} is not valid UTF-8.`);
}
}
function decodeConsumerTarPathField(bytes, label) {
const raw = decodeTarString(bytes, label);
const consumerValue = raw.trim();
if (raw !== consumerValue) {
throw new Error(`${label} changes under the pinned ClawHub path normalization.`);
}
return consumerValue;
}
function normalizeConsumerTarPath(value, options = {}) {
const rawPath = options.directory === true && value.endsWith("/") ? value.slice(0, -1) : value;
const normalized = rawPath.replaceAll("\\", "/").replace(/^\.\/+/u, "");
const segments = normalized.split("/").filter(Boolean);
const consumerPath = segments.join("/");
if (
consumerPath !== rawPath ||
segments.length === 0 ||
segments.some((segment) => segment === "." || segment === "..")
) {
throw new Error(
`Tar entry path changes under the pinned ClawHub normalization: ${JSON.stringify(value)}.`,
);
}
return assertSafeArchivePath(consumerPath, "tar entry path");
}
function parseCanonicalTarNumber(bytes, label, options = {}) {
const { allowEmpty = false } = options;
if ((bytes[0] & 0x80) !== 0) {
throw new Error(`${label} must not use base-256 encoding.`);
}
if (bytes.every((byte) => byte === 0)) {
if (allowEmpty) {
return 0;
}
throw new Error(`${label} must not be empty.`);
}
const raw = bytes.toString("ascii");
const canonicalDigits = new RegExp(`^[0-7]{${bytes.length - 2}}$`, "u");
const canonical = canonicalDigits.test(raw.slice(0, -2)) && raw.endsWith(" \0");
if (!canonical) {
throw new Error(`${label} is not canonically encoded.`);
}
const octal = raw.slice(0, -2);
const value = Number.parseInt(octal, 8);
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error(`Invalid ${label}: ${JSON.stringify(octal)}`);
}
return value;
}
function verifyTarChecksum(header) {
const expected = parseCanonicalTarNumber(header.subarray(148, 156), "tar checksum");
let actual = 0;
for (let index = 0; index < header.length; index += 1) {
actual += index >= 148 && index < 156 ? 0x20 : header[index];
}
if (actual !== expected) {
throw new Error(`Tar header checksum mismatch: expected ${expected}, got ${actual}.`);
}
}
function verifyCanonicalTarHeader(header) {
if (
!header.subarray(257, 263).equals(TAR_USTAR_MAGIC) ||
!header.subarray(263, 265).equals(TAR_USTAR_VERSION)
) {
throw new Error("Plugin tarball entries must use canonical POSIX USTAR headers.");
}
decodeTarString(header.subarray(0, 100), "tar entry name");
parseCanonicalTarNumber(header.subarray(100, 108), "tar entry mode");
parseCanonicalTarNumber(header.subarray(108, 116), "tar entry uid", { allowEmpty: true });
parseCanonicalTarNumber(header.subarray(116, 124), "tar entry gid", { allowEmpty: true });
parseCanonicalTarNumber(header.subarray(124, 136), "tar entry size");
parseCanonicalTarNumber(header.subarray(136, 148), "tar entry mtime");
decodeTarString(header.subarray(157, 257), "tar entry link path");
decodeTarString(header.subarray(265, 297), "tar entry user name");
decodeTarString(header.subarray(297, 329), "tar entry group name");
parseCanonicalTarNumber(header.subarray(329, 337), "tar entry device major");
parseCanonicalTarNumber(header.subarray(337, 345), "tar entry device minor");
if (header[475] === 0) {
decodeTarString(header.subarray(345, 475), "tar entry prefix");
parseCanonicalTarNumber(header.subarray(476, 488), "tar entry access time", {
allowEmpty: true,
});
parseCanonicalTarNumber(header.subarray(488, 500), "tar entry change time", {
allowEmpty: true,
});
} else {
decodeTarString(header.subarray(345, 500), "tar entry prefix");
}
}
function isZeroTarBlock(bytes) {
return bytes.length === TAR_BLOCK_BYTES && bytes.every((byte) => byte === 0);
}
function parsePackedJson(bytes, label) {
let value;
try {
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
} catch (error) {
throw new Error(
`${label} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be an object.`);
}
return value;
}
function firstGzipMemberEnd(bytes, maxOutputLength) {
if (
bytes.length < 18 ||
bytes[0] !== 0x1f ||
bytes[1] !== 0x8b ||
bytes[2] !== 0x08 ||
(bytes[3] & 0xe0) !== 0
) {
throw new Error("Invalid gzip header.");
}
const flags = bytes[3];
let offset = 10;
if ((flags & 0x04) !== 0) {
if (offset + 2 > bytes.length) {
throw new Error("Truncated gzip extra-field length.");
}
const extraLength = bytes.readUInt16LE(offset);
offset += 2 + extraLength;
if (offset > bytes.length) {
throw new Error("Truncated gzip extra field.");
}
}
for (const flag of [0x08, 0x10]) {
if ((flags & flag) === 0) {
continue;
}
const terminator = bytes.indexOf(0, offset);
if (terminator === -1) {
throw new Error("Unterminated gzip header string.");
}
offset = terminator + 1;
}
if ((flags & 0x02) !== 0) {
offset += 2;
}
if (offset + 8 > bytes.length) {
throw new Error("Truncated gzip member.");
}
const expanded = inflateRawSync(bytes.subarray(offset), {
info: true,
maxOutputLength,
});
return offset + expanded.engine.bytesWritten + 8;
}
export function inspectPackageTarballBytes(inputBytes, options = {}) {
if (!(inputBytes instanceof Uint8Array)) {
throw new Error("Plugin tarball bytes must be a Uint8Array.");
}
const tarballBytes = Buffer.from(inputBytes.buffer, inputBytes.byteOffset, inputBytes.byteLength);
const limits = normalizeTarInspectionOptions(options);
if (tarballBytes.length === 0 || tarballBytes.length > limits.maxArchiveBytes) {
throw new Error(`Plugin tarball size is outside the allowed range: ${tarballBytes.length}.`);
}
let memberEnd;
try {
memberEnd = firstGzipMemberEnd(tarballBytes, limits.maxExpandedBytes);
} catch (error) {
throw new Error(
`Plugin tarball is not canonical gzip or expands beyond ${limits.maxExpandedBytes} bytes.`,
{ cause: error },
);
}
if (memberEnd !== tarballBytes.length) {
throw new Error("Plugin tarball must contain exactly one gzip member.");
}
let tarBytes;
try {
const expanded = gunzipSync(tarballBytes, {
info: true,
maxOutputLength: limits.maxExpandedBytes,
});
if (expanded.engine.bytesWritten !== tarballBytes.length) {
throw new Error("gzip stream does not consume the full plugin tarball");
}
tarBytes = expanded.buffer;
} catch (error) {
throw new Error(
`Plugin tarball is not canonical gzip or expands beyond ${limits.maxExpandedBytes} bytes.`,
{ cause: error },
);
}
const inventory = [];
const seenPaths = new Set();
const seenAliases = new Set();
let packageManifestBytes;
let pluginManifestBytes;
let offset = 0;
let sawEndMarker = false;
let entryCount = 0;
let totalFileBytes = 0;
let totalPathBytes = 0;
while (offset + TAR_BLOCK_BYTES <= tarBytes.length) {
const header = tarBytes.subarray(offset, offset + TAR_BLOCK_BYTES);
if (isZeroTarBlock(header)) {
const secondEndBlock = tarBytes.subarray(
offset + TAR_BLOCK_BYTES,
offset + TAR_END_MARKER_BYTES,
);
if (
!isZeroTarBlock(secondEndBlock) ||
!tarBytes.subarray(offset + TAR_END_MARKER_BYTES).every((byte) => byte === 0)
) {
throw new Error(
"Plugin tarball must end with two zero blocks and contain no trailing entries.",
);
}
sawEndMarker = true;
break;
}
entryCount += 1;
if (entryCount > limits.maxEntries) {
throw new Error(`Plugin tarball exceeds the ${limits.maxEntries} entry limit.`);
}
verifyTarChecksum(header);
verifyCanonicalTarHeader(header);
const headerName = decodeConsumerTarPathField(header.subarray(0, 100), "tar entry name");
const headerPrefix =
header[475] === 0
? decodeConsumerTarPathField(header.subarray(345, 475), "tar entry prefix")
: decodeConsumerTarPathField(header.subarray(345, 500), "tar entry prefix");
const headerPath = headerPrefix ? `${headerPrefix}/${headerName}` : headerName;
const headerSize = parseCanonicalTarNumber(header.subarray(124, 136), "tar entry size");
const typeFlag = String.fromCharCode(header[156] || 0x30);
const linkPath = decodeTarString(header.subarray(157, 257), "tar entry link path");
if (typeFlag === "x" || typeFlag === "g" || typeFlag === "L" || typeFlag === "K") {
throw new Error("PAX and GNU tar metadata are not supported for plugin artifacts.");
}
if (typeFlag !== "0" && typeFlag !== "5") {
const suffix = linkPath ? ` -> ${linkPath}` : "";
throw new Error(
`Unsupported plugin tar entry type ${JSON.stringify(typeFlag)}: ${headerPath}${suffix}`,
);
}
if (typeFlag === "5" && headerSize !== 0) {
throw new Error(`Directory tar entry ${JSON.stringify(headerPath)} must have size zero.`);
}
if (linkPath) {
throw new Error(
`Plugin tar entries must not carry link targets: ${headerPath} -> ${linkPath}`,
);
}
if (typeFlag !== "5" && headerPath.endsWith("/")) {
throw new Error(
`Non-directory tar entry must not end with a slash: ${JSON.stringify(headerPath)}.`,
);
}
const safePath = normalizeConsumerTarPath(headerPath, { directory: typeFlag === "5" });
if (safePath !== "package" && !safePath.startsWith("package/")) {
throw new Error(`Plugin tar entry must stay under package/: ${safePath}`);
}
const alias = safePath.toLocaleLowerCase("en-US");
if (seenPaths.has(safePath) || seenAliases.has(alias)) {
throw new Error(`Duplicate or aliased plugin tar entry: ${safePath}`);
}
seenPaths.add(safePath);
seenAliases.add(alias);
totalPathBytes += Buffer.byteLength(safePath, "utf8");
if (totalPathBytes > limits.maxPathBytes) {
throw new Error(`Plugin tarball paths exceed the ${limits.maxPathBytes} byte limit.`);
}
if (headerSize > limits.maxEntryBytes) {
throw new Error(`Plugin tar entry ${safePath} exceeds ${limits.maxEntryBytes} bytes.`);
}
const dataOffset = offset + TAR_BLOCK_BYTES;
const dataEnd = dataOffset + headerSize;
const nextOffset = dataOffset + Math.ceil(headerSize / TAR_BLOCK_BYTES) * TAR_BLOCK_BYTES;
if (dataEnd > tarBytes.length || nextOffset > tarBytes.length) {
throw new Error(`Tar entry ${JSON.stringify(safePath)} exceeds the archive boundary.`);
}
const content = tarBytes.subarray(dataOffset, dataEnd);
offset = nextOffset;
if (typeFlag === "5") {
inventory.push({ path: safePath, sizeBytes: 0, type: "directory" });
continue;
}
totalFileBytes += content.length;
if (totalFileBytes > limits.maxTotalFileBytes) {
throw new Error(`Plugin tarball file payload exceeds ${limits.maxTotalFileBytes} bytes.`);
}
const entry = {
path: safePath,
sha256: sha256(content),
sizeBytes: content.length,
type: "file",
};
inventory.push(entry);
if (safePath === "package/package.json") {
if (content.length === 0 || content.length > MAX_MANIFEST_BYTES) {
throw new Error(
`Packed package.json size is outside the allowed range: ${content.length}.`,
);
}
packageManifestBytes = Buffer.from(content);
} else if (safePath === "package/openclaw.plugin.json") {
if (content.length === 0 || content.length > MAX_PLUGIN_MANIFEST_BYTES) {
throw new Error(
`Packed openclaw.plugin.json size is outside the allowed range: ${content.length}.`,
);
}
pluginManifestBytes = Buffer.from(content);
}
}
if (!sawEndMarker) {
throw new Error("Plugin tarball is missing its end marker.");
}
if (!packageManifestBytes) {
throw new Error("Plugin tarball must contain exactly one package/package.json.");
}
if (!pluginManifestBytes) {
throw new Error("Plugin tarball must contain exactly one package/openclaw.plugin.json.");
}
inventory.sort((left, right) => compareCodeUnits(left.path, right.path));
const packageManifest = parsePackedJson(packageManifestBytes, "Packed package.json");
const pluginManifest = parsePackedJson(pluginManifestBytes, "Packed openclaw.plugin.json");
return {
inventory,
packageManifest,
packageManifestSha256: sha256(packageManifestBytes),
pluginManifest,
pluginManifestSha256: sha256(pluginManifestBytes),
tarballSizeBytes: tarballBytes.byteLength,
tarballSha256: sha256(tarballBytes),
totalFileBytes,
};
}
export function validatePluginPackageManifest(params, packageManifest) {
if (packageManifest.name !== params.packageName || packageManifest.version !== params.version) {
throw new Error(
`Packed plugin identity ${String(packageManifest.name)}@${String(packageManifest.version)} does not match ${params.packageName}@${params.version}.`,
);
}
if (Object.hasOwn(packageManifest, "tag")) {
throw new Error(
`${params.packageName}: packed package.json must not override the approved publication tag.`,
);
}
const release = packageManifest.openclaw?.release;
const referencesMetaIdentity =
params.packageName === META_PACKAGE || params.packageDir === META_PACKAGE_DIR;
if (
referencesMetaIdentity &&
(params.packageName !== META_PACKAGE ||
params.packageDir !== META_PACKAGE_DIR ||
release?.publishToNpm !== true ||
release?.publishToClawHub !== true)
) {
throw new Error(
`${META_PACKAGE}: Meta publication requires ${META_PACKAGE_DIR} with npm and ClawHub enabled.`,
);
}
if (params.route.startsWith("npm-") && release?.publishToNpm !== true) {
throw new Error(`${params.packageName}: packed plugin is not marked publishToNpm.`);
}
if (params.route.startsWith("clawhub-") && release?.publishToClawHub !== true) {
throw new Error(`${params.packageName}: packed plugin is not marked publishToClawHub.`);
}
if (packageManifest.publishConfig !== undefined) {
throw new Error(
`${params.packageName}: packed package.json must not override publication through publishConfig.`,
);
}
}
function normalizePublicationParams(params) {
const targetSha = assertString(params.targetSha, "target SHA");
if (!SHA_RE.test(targetSha)) {
throw new Error(`Target SHA must be a full lowercase commit SHA: ${targetSha}`);
}
const packageDir = assertSafePackageDir(params.packageDir);
const packageName = assertString(params.packageName, "package name");
if (!PACKAGE_NAME_RE.test(packageName)) {
throw new Error(`Invalid plugin package name: ${packageName}`);
}
const version = assertString(params.version, "package version");
if (!VERSION_RE.test(version)) {
throw new Error(`Invalid plugin package version: ${version}`);
}
const route = assertString(params.route, "publication route");
if (!ROUTES.has(route)) {
throw new Error(`Unsupported plugin publication route: ${route}`);
}
const npmRoutePolicy = NPM_ROUTE_POLICIES.get(route);
let publicationReason = null;
let publisherPolicy = null;
if (npmRoutePolicy) {
if (params.authMode !== undefined && params.authMode !== npmRoutePolicy.authMode) {
throw new Error(`${route} auth mode must be ${npmRoutePolicy.authMode}.`);
}
if (params.capability !== undefined && params.capability !== npmRoutePolicy.capability) {
throw new Error(`${route} capability must be ${npmRoutePolicy.capability}.`);
}
publicationReason = normalizePublicationReason(params.publicationReason);
publisherPolicy = normalizePublisherPolicy(params.publisherPolicy);
} else if (
params.authMode !== undefined ||
params.capability !== undefined ||
params.publicationReason !== undefined ||
params.publisherPolicy !== undefined
) {
throw new Error(`${route} must not carry npm publisher-policy controls.`);
}
const publishTag = assertString(params.publishTag, "publish tag");
const allowedTags = route.startsWith("npm-") ? NPM_TAGS : CLAWHUB_TAGS;
if (!allowedTags.has(publishTag)) {
throw new Error(`Unsupported ${route} publish tag: ${publishTag}`);
}
if (route.startsWith("npm-")) {
const override = publishTag === "extended-stable" ? publishTag : undefined;
const publishPlan = resolveNpmPublishPlan(version, undefined, override);
if (publishPlan.publishTag !== publishTag) {
throw new Error(
`${packageName}@${version}: npm publish tag ${publishTag} does not match release channel ${publishPlan.channel}.`,
);
}
} else {
const expectedTag = version.includes("-alpha.")
? "alpha"
: version.includes("-beta.")
? "beta"
: "latest";
if (publishTag !== expectedTag) {
throw new Error(
`${packageName}@${version}: ClawHub publish tag ${publishTag} must be ${expectedTag}.`,
);
}
}
const artifactName = assertSafeArtifactName(params.artifactName);
let bootstrapMode = null;
let requiresManualOverride = false;
const manualOverrideReason = normalizeManualOverrideReason(params.manualOverrideReason);
if (route === "clawhub-token-bootstrap") {
bootstrapMode = assertString(params.bootstrapMode, "bootstrap mode");
if (bootstrapMode !== "publish" && bootstrapMode !== "configure-only") {
throw new Error(`Unsupported ClawHub bootstrap mode: ${bootstrapMode}`);
}
requiresManualOverride = params.requiresManualOverride === true;
} else if (route === "clawhub-token-release") {
requiresManualOverride = params.requiresManualOverride === true;
} else if (
params.bootstrapMode !== undefined ||
params.requiresManualOverride === true ||
manualOverrideReason !== null
) {
throw new Error(`${route} must not carry ClawHub bootstrap controls.`);
}
if (requiresManualOverride !== (manualOverrideReason !== null)) {
throw new Error(
`${route} must bind a manual override reason exactly when a manual override is required.`,
);
}
let sourcePackageJsonSha256;
if (params.sourcePackageJsonSha256 !== undefined) {
sourcePackageJsonSha256 = assertString(
params.sourcePackageJsonSha256,
"source package.json SHA-256",
);
if (!SHA256_RE.test(sourcePackageJsonSha256)) {
throw new Error(
`Source package.json SHA-256 must be 64 lowercase hex characters: ${sourcePackageJsonSha256}`,
);
}
}
return {
artifactName,
authMode: npmRoutePolicy?.authMode ?? null,
bootstrapMode,
capability: npmRoutePolicy?.capability ?? null,
manualOverrideReason,
packageDir,
packageName,
publicationReason,
publishTag,
publisherPolicy,
requiresManualOverride,
route,
sourcePackageJsonSha256,
targetSha,
version,
};
}
function buildManifest(params, tarballName, tarballBytes, inspection) {
validatePluginPackageManifest(params, inspection.packageManifest);
const publication = params.authMode
? {
route: params.route,
authMode: params.authMode,
capability: params.capability,
reason: params.publicationReason,
tag: params.publishTag,
publisherPolicy: params.publisherPolicy,
}
: {
route: params.route,
tag: params.publishTag,
bootstrapMode: params.bootstrapMode,
manualOverrideReason: params.manualOverrideReason,
requiresManualOverride: params.requiresManualOverride,
};
return {
schema: MANIFEST_SCHEMA,
schemaVersion: 1,
targetSha: params.targetSha,
package: {
dir: params.packageDir,
name: params.packageName,
version: params.version,
author: inspection.packageManifest.author ?? null,
contributors: inspection.packageManifest.contributors ?? null,
repository: inspection.packageManifest.repository ?? null,
packageJsonSha256: inspection.packageManifestSha256,
pluginManifestSha256: inspection.pluginManifestSha256,
sourcePackageJsonSha256: params.sourcePackageJsonSha256,
},
publication,
artifact: {
name: params.artifactName,
tarball: tarballName,
npmIntegrity: npmIntegrity(tarballBytes),
npmShasum: npmShasum(tarballBytes),
sha256: inspection.tarballSha256,
sizeBytes: tarballBytes.length,
inventory: inspection.inventory,
},
};
}
function tarInspectionOptionsForRoute(route) {
return route.startsWith("clawhub-") ? CLAWHUB_PUBLICATION_TAR_LIMITS : undefined;
}
function normalizeExpectedInventory(value) {
if (!Array.isArray(value)) {
throw new Error("Expected plugin tarball inventory must be an array.");
}
const paths = new Set();
const aliases = new Set();
const inventory = value.map((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
throw new Error("Expected plugin tarball inventory entries must be objects.");
}
const expectedKeys =
entry.type === "directory"
? ["path", "sizeBytes", "type"]
: ["path", "sha256", "sizeBytes", "type"];
if (JSON.stringify(Object.keys(entry).toSorted()) !== JSON.stringify(expectedKeys)) {
throw new Error("Expected plugin tarball inventory entry shape is invalid.");
}
const path = assertSafeArchivePath(entry.path, "expected tar entry path");
if (path !== entry.path) {
throw new Error(`Expected plugin tarball path is not canonical: ${entry.path}`);
}
const alias = path.toLocaleLowerCase("en-US");
if (paths.has(path) || aliases.has(alias)) {
throw new Error(`Expected plugin tarball inventory contains an alias: ${path}`);
}
paths.add(path);
aliases.add(alias);
if (
!Number.isSafeInteger(entry.sizeBytes) ||
entry.sizeBytes < 0 ||
(entry.type === "directory" && entry.sizeBytes !== 0)
) {
throw new Error(`Expected plugin tarball size is invalid for ${path}.`);
}
if (entry.type === "directory") {
return { path, sizeBytes: 0, type: "directory" };
}
if (
entry.type !== "file" ||
typeof entry.sha256 !== "string" ||
!SHA256_RE.test(entry.sha256)
) {
throw new Error(`Expected plugin tarball file identity is invalid for ${path}.`);
}
return {
path,
sha256: entry.sha256,
sizeBytes: entry.sizeBytes,
type: "file",
};
});
return inventory.toSorted((left, right) => compareCodeUnits(left.path, right.path));
}
function canonicalManifestText(manifest) {
return `${JSON.stringify(manifest, null, 2)}\n`;
}
function createFreshOutputDirectory(path, label) {
try {
lstatSync(path);
throw new Error(`${label} must not already exist.`);
} catch (error) {
if (!error || typeof error !== "object" || error.code !== "ENOENT") {
throw error;
}
}
mkdirSync(path, { recursive: true, mode: 0o700 });
const created = lstatSync(path);
if (!created.isDirectory() || created.isSymbolicLink()) {
throw new Error(`${label} must be a newly created directory.`);
}
}
export function createPluginPublicationArtifact(params) {
const normalized = normalizePublicationParams(params);
if (!normalized.sourcePackageJsonSha256) {
throw new Error("Plugin publication creation requires source package.json SHA-256.");
}
const artifactDir = resolve(params.artifactDir);
const entries = readdirSync(artifactDir, { withFileTypes: true });
const tarballs = entries.filter(
(entry) => entry.isFile() && entry.name.endsWith(".tgz") && entry.name !== MANIFEST_FILENAME,
);
const unexpected = entries.filter((entry) => !(entry.isFile() && entry.name.endsWith(".tgz")));
if (tarballs.length !== 1 || unexpected.length !== 0) {
throw new Error(
`Plugin publication staging dir must contain exactly one .tgz and no other entries; found ${entries.map((entry) => entry.name).join(", ") || "<empty>"}.`,
);
}
const tarballName = assertSafeArchivePath(tarballs[0].name, "tarball filename");
if (basename(tarballName) !== tarballName) {
throw new Error(`Plugin tarball must be at the artifact root: ${tarballName}`);
}
const tarballPath = join(artifactDir, tarballName);
const tarballBytes = readBoundedRegularFile(tarballPath, {
label: "Plugin tarball",
maxBytes: MAX_ARCHIVE_BYTES,
});
const inspection = inspectPackageTarballBytes(
tarballBytes,
tarInspectionOptionsForRoute(normalized.route),
);
const manifest = buildManifest(normalized, tarballName, tarballBytes, inspection);
const manifestPath = join(artifactDir, MANIFEST_FILENAME);
writeFileSync(manifestPath, canonicalManifestText(manifest), { mode: 0o600 });
return { manifest, manifestPath, tarballPath };
}
function parseBoundedJsonFile(path, label, maxBytes = MAX_MANIFEST_BYTES) {
let value;
try {
value = JSON.parse(
new TextDecoder("utf-8", { fatal: true }).decode(
readBoundedRegularFile(path, {
label,
maxBytes,
}),
),
);
} catch (error) {
throw new Error(
`${label} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${label} must be an object.`);
}
return value;
}
function inspectPluginPublicationArtifactZip(zipBytes) {
return inspectActionsArtifactZipWithPolicy(zipBytes, {
minEntries: 2,
maxEntries: 2,
maxArchiveBytes: MAX_ARCHIVE_BYTES,
maxExpandedBytes: MAX_ARCHIVE_BYTES + MAX_MANIFEST_BYTES,
allowPath: (name) =>
name === MANIFEST_FILENAME || (basename(name) === name && name.endsWith(".tgz")),
maxEntryBytes: (name) => (name === MANIFEST_FILENAME ? MAX_MANIFEST_BYTES : MAX_ARCHIVE_BYTES),
});
}
export function verifyPluginPublicationArtifact(params) {
const normalized = normalizePublicationParams(params);
let expectedTarballSha256;
if (params.expectedTarballSha256 !== undefined) {
expectedTarballSha256 = assertString(params.expectedTarballSha256, "expected tarball SHA-256");
if (!SHA256_RE.test(expectedTarballSha256)) {
throw new Error("Expected tarball SHA-256 must be 64 lowercase hex characters.");
}
}
const expectedTarballSizeBytes =
params.expectedTarballSizeBytes === undefined
? undefined
: assertPositiveInteger(params.expectedTarballSizeBytes, "expected tarball size");
const expectedInventory =
params.expectedInventory === undefined
? undefined
: normalizeExpectedInventory(params.expectedInventory);
const artifactId = assertPositiveInteger(params.artifactId, "artifact ID");
const artifactSizeBytes = assertPositiveInteger(params.artifactSizeBytes, "artifact size");
const runId = assertPositiveInteger(
params.producerRunId ?? params.runId,
"producer workflow run ID",
);
const runAttempt = assertPositiveInteger(
params.producerRunAttempt ?? params.runAttempt,
"producer workflow run attempt",
);
const workflowSha = assertString(params.workflowSha, "workflow SHA");
if (!SHA_RE.test(workflowSha)) {
throw new Error(`Workflow SHA must be a full lowercase commit SHA: ${workflowSha}`);
}
const expectedArtifactDigest = assertString(params.artifactDigest, "artifact digest");
if (!ARTIFACT_DIGEST_RE.test(expectedArtifactDigest)) {
throw new Error(`Invalid Actions artifact digest: ${expectedArtifactDigest}`);
}
const metadata = parseBoundedJsonFile(params.artifactMetadataPath, "Actions artifact metadata");
const workflowRun = parseBoundedJsonFile(
params.workflowRunMetadataPath,
"Actions workflow run metadata",
);
const expectedBinding = {
artifactDigest: expectedArtifactDigest,
artifactId,
artifactName: normalized.artifactName,
artifactSizeBytes,
consumerRunAttempt: params.consumerRunAttempt,
producerJobName: params.producerJobName,
repository: params.repository,
runStatePolicy: params.runStatePolicy ?? "completed-success",
runAttempt,
runId,
workflowEvent: params.workflowEvent,
workflowHeadBranch: params.workflowHeadBranch,
workflowPath: params.workflowPath,
workflowSha,
};
validateActionsArtifactBinding({
artifactMetadata: metadata,
expected: expectedBinding,
workflowRun,
});
if (expectedBinding.runStatePolicy === "same-run-producer-success") {
const workflowJobs = parseBoundedJsonFile(
params.workflowJobsMetadataPath,
"Actions workflow jobs metadata",
);
validateActionsArtifactProducerJob({ expected: expectedBinding, workflowJobs });
}
const zipBytes = readBoundedRegularFile(params.artifactZipPath, {
label: "Actions artifact ZIP",
maxBytes: MAX_ARCHIVE_BYTES,
});
if (zipBytes.length !== artifactSizeBytes) {
throw new Error("Actions artifact ZIP size does not match the immutable publish tuple.");
}
const actualArtifactDigest = sha256Digest(zipBytes);
if (actualArtifactDigest !== expectedArtifactDigest) {
throw new Error(
`Actions artifact ZIP digest ${actualArtifactDigest} does not match ${expectedArtifactDigest}.`,
);
}
const files = inspectPluginPublicationArtifactZip(zipBytes);
const manifestBytes = files.get(MANIFEST_FILENAME);
const tarballNames = [...files.keys()].filter((name) => name.endsWith(".tgz"));
if (!manifestBytes || tarballNames.length !== 1) {
throw new Error(
`Plugin publication artifact must contain ${MANIFEST_FILENAME} and exactly one .tgz.`,
);
}
if (manifestBytes.length === 0 || manifestBytes.length > MAX_MANIFEST_BYTES) {
throw new Error(`Plugin publication manifest size is outside the allowed range.`);
}
let manifest;
try {
manifest = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(manifestBytes));
} catch (error) {
throw new Error(
`Plugin publication manifest is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
const manifestSourcePackageJsonSha256 = manifest?.package?.sourcePackageJsonSha256;
if (
typeof manifestSourcePackageJsonSha256 !== "string" ||
!SHA256_RE.test(manifestSourcePackageJsonSha256)
) {
throw new Error("Plugin publication manifest source package.json SHA-256 is invalid.");
}
if (
normalized.sourcePackageJsonSha256 !== undefined &&
normalized.sourcePackageJsonSha256 !== manifestSourcePackageJsonSha256
) {
throw new Error(
"Plugin publication source package.json SHA-256 does not match the approved target source.",
);
}
const tarballName = tarballNames[0];
const tarballBytes = files.get(tarballName);
const inspection = inspectPackageTarballBytes(
tarballBytes,
tarInspectionOptionsForRoute(normalized.route),
);
if (
expectedTarballSizeBytes !== undefined &&
inspection.tarballSizeBytes !== expectedTarballSizeBytes
) {
throw new Error("Plugin tarball size does not match the approved publication tuple.");
}
if (expectedTarballSha256 !== undefined && inspection.tarballSha256 !== expectedTarballSha256) {
throw new Error("Plugin tarball SHA-256 does not match the approved publication tuple.");
}
if (
expectedInventory !== undefined &&
JSON.stringify(inspection.inventory) !== JSON.stringify(expectedInventory)
) {
throw new Error("Plugin tarball inventory does not match the approved publication tuple.");
}
const expectedManifest = buildManifest(
{
...normalized,
sourcePackageJsonSha256: manifestSourcePackageJsonSha256,
},
tarballName,
tarballBytes,
inspection,
);
const expectedManifestBytes = Buffer.from(canonicalManifestText(expectedManifest), "utf8");
if (!manifestBytes.equals(expectedManifestBytes)) {
throw new Error(
"Plugin publication manifest does not canonically bind the approved package and tarball.",
);
}
const outputDir = resolve(params.outputDir);
createFreshOutputDirectory(outputDir, "Plugin publication output directory");
const outputPath = join(outputDir, tarballName);
writeFileSync(outputPath, tarballBytes, { flag: "wx", mode: 0o600 });
if (!statSync(outputPath).isFile()) {
throw new Error(`Verified plugin tarball was not written: ${outputPath}`);
}
return {
artifactDigest: expectedArtifactDigest,
artifactId,
artifactName: normalized.artifactName,
artifactSizeBytes,
artifactZipSha256: actualArtifactDigest.slice("sha256:".length),
manifest: expectedManifest,
npmIntegrity: expectedManifest.artifact.npmIntegrity,
npmShasum: expectedManifest.artifact.npmShasum,
packageJsonSha256: expectedManifest.package.packageJsonSha256,
pluginManifestSha256: expectedManifest.package.pluginManifestSha256,
producerRunAttempt: runAttempt,
producerRunId: runId,
sourcePackageJsonSha256: expectedManifest.package.sourcePackageJsonSha256,
tarballInventory: inspection.inventory,
tarballName,
tarballPath: outputPath,
tarballSizeBytes: inspection.tarballSizeBytes,
tarballSha256: inspection.tarballSha256,
};
}
function parseCliArgs(argv) {
const [command, ...rest] = argv;
if (command !== "create" && command !== "verify") {
throw new Error(
"Usage: plugin-publication-artifact.mjs <create|verify> --artifact-name <name> ...",
);
}
const values = {};
for (let index = 0; index < rest.length; index += 2) {
const key = rest[index];
const value = rest[index + 1];
if (!key?.startsWith("--") || value === undefined || value.startsWith("--")) {
throw new Error(`Invalid ${command} argument near ${key ?? "<missing>"}.`);
}
const name = key.slice(2).replace(/-([a-z])/gu, (_, letter) => letter.toUpperCase());
if (values[name] !== undefined) {
throw new Error(`Duplicate ${command} option: ${key}`);
}
values[name] = value;
}
return { command, values };
}
function commonCliParams(values) {
return {
artifactName: values.artifactName,
bootstrapMode: values.bootstrapMode,
manualOverrideReason: values.manualOverrideReason,
packageDir: values.packageDir,
packageName: values.packageName,
publishTag: values.publishTag,
requiresManualOverride:
values.requiresManualOverride === undefined
? false
: assertBooleanString(values.requiresManualOverride, "requires-manual-override"),
route: values.route,
publicationReason: values.publicationReason,
publisherPolicy:
values.publisherPolicySchema === undefined &&
values.publisherPolicyId === undefined &&
values.publisherPolicySha256 === undefined
? undefined
: {
policyId: values.publisherPolicyId,
schema: values.publisherPolicySchema,
sha256: values.publisherPolicySha256,
},
sourcePackageJsonSha256: values.sourcePackageJsonSha256,
targetSha: values.targetSha,
version: values.packageVersion,
};
}
function appendGithubOutput(path, values) {
const lines = Object.entries(values).map(([name, value]) => `${name}=${String(value)}`);
writeFileSync(path, `${lines.join("\n")}\n`, { flag: "a" });
}
export function main(argv = process.argv.slice(2)) {
const { command, values } = parseCliArgs(argv);
const common = commonCliParams(values);
if (command === "create") {
const result = createPluginPublicationArtifact({
...common,
artifactDir: values.artifactDir,
});
console.log(`Created canonical plugin publication manifest: ${result.manifestPath}`);
console.log(`Prepared plugin tarball: ${result.tarballPath}`);
return;
}
const result = verifyPluginPublicationArtifact({
...common,
artifactDigest: values.artifactDigest,
artifactId: Number(values.artifactId),
artifactMetadataPath: values.artifactMetadata,
artifactSizeBytes: Number(values.artifactSizeBytes),
artifactZipPath: values.artifactZip,
expectedTarballSizeBytes:
values.expectedTarballSizeBytes === undefined
? undefined
: Number(values.expectedTarballSizeBytes),
expectedTarballSha256: values.expectedTarballSha256,
outputDir: values.outputDir,
consumerRunAttempt:
values.consumerRunAttempt === undefined ? undefined : Number(values.consumerRunAttempt),
producerJobName: values.producerJobName,
producerRunAttempt: Number(values.producerRunAttempt),
producerRunId: Number(values.producerRunId),
repository: values.repository,
workflowEvent: values.workflowEvent,
workflowHeadBranch: values.workflowHeadBranch,
workflowPath: values.workflowPath,
workflowJobsMetadataPath: values.workflowJobsMetadata,
workflowRunMetadataPath: values.workflowRunMetadata,
runStatePolicy: values.runStatePolicy,
workflowSha: values.workflowSha,
});
if (values.githubOutput) {
appendGithubOutput(values.githubOutput, {
artifact_digest: result.artifactDigest,
artifact_id: result.artifactId,
artifact_name: result.artifactName,
artifact_size_bytes: result.artifactSizeBytes,
artifact_zip_sha256: result.artifactZipSha256,
bootstrap_mode: result.manifest.publication.bootstrapMode ?? "",
manual_override_reason: result.manifest.publication.manualOverrideReason ?? "",
package_name: result.manifest.package.name,
package_json_sha256: result.manifest.package.packageJsonSha256,
package_version: result.manifest.package.version,
publish_route: result.manifest.publication.route,
publish_tag: result.manifest.publication.tag,
producer_run_attempt: result.producerRunAttempt,
producer_run_id: result.producerRunId,
requires_manual_override: result.manifest.publication.requiresManualOverride,
source_package_json_sha256: result.manifest.package.sourcePackageJsonSha256,
tarball_path: result.tarballPath,
tarball_sha256: result.tarballSha256,
});
}
console.log(
`Verified ${result.manifest.package.name}@${result.manifest.package.version} plugin artifact ${result.artifactId} (${result.artifactDigest}).`,
);
}
const entrypoint = process.argv[1] ? pathToFileURL(process.argv[1]).href : undefined;
if (entrypoint === import.meta.url) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}