Infra: require explicit opt-in for prerelease npm installs (#38117)

* Infra: tighten npm registry spec parsing

* Infra: block implicit prerelease npm installs

* Plugins: cover prerelease install policy

* Infra: add npm registry spec tests

* Hooks: cover prerelease install policy

* Docs: clarify plugin guide version policy

* Docs: clarify plugin install version policy

* Docs: clarify hooks install version policy

* Docs: clarify hook pack version policy
This commit is contained in:
Vincent Koc
2026-03-06 11:13:30 -05:00
committed by GitHub
parent a274ef929f
commit f392b81e95
9 changed files with 337 additions and 25 deletions

View File

@@ -8,6 +8,11 @@ import {
type NpmIntegrityDriftPayload,
resolveNpmIntegrityDriftWithDefaultMessage,
} from "./npm-integrity.js";
import {
formatPrereleaseResolutionError,
isPrereleaseResolutionAllowed,
parseRegistryNpmSpec,
} from "./npm-registry-spec.js";
export type NpmSpecArchiveInstallFlowResult<TResult extends { ok: boolean }> =
| {
@@ -94,6 +99,13 @@ export async function installFromNpmSpecArchive<TResult extends { ok: boolean }>
installFromArchive: (params: { archivePath: string }) => Promise<TResult>;
}): Promise<NpmSpecArchiveInstallFlowResult<TResult>> {
return await withTempDir(params.tempDirPrefix, async (tmpDir) => {
const parsedSpec = parseRegistryNpmSpec(params.spec);
if (!parsedSpec) {
return {
ok: false,
error: "unsupported npm spec",
};
}
const packedResult = await packNpmSpecToArchive({
spec: params.spec,
timeoutMs: params.timeoutMs,
@@ -107,6 +119,21 @@ export async function installFromNpmSpecArchive<TResult extends { ok: boolean }>
...packedResult.metadata,
resolvedAt: new Date().toISOString(),
};
if (
npmResolution.version &&
!isPrereleaseResolutionAllowed({
spec: parsedSpec,
resolvedVersion: npmResolution.version,
})
) {
return {
ok: false,
error: formatPrereleaseResolutionError({
spec: parsedSpec,
resolvedVersion: npmResolution.version,
}),
};
}
const driftResult = await resolveNpmIntegrityDriftWithDefaultMessage({
spec: params.spec,

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import {
isPrereleaseResolutionAllowed,
parseRegistryNpmSpec,
validateRegistryNpmSpec,
} from "./npm-registry-spec.js";
describe("npm registry spec validation", () => {
it("accepts bare package names, exact versions, and dist-tags", () => {
expect(validateRegistryNpmSpec("@openclaw/voice-call")).toBeNull();
expect(validateRegistryNpmSpec("@openclaw/voice-call@1.2.3")).toBeNull();
expect(validateRegistryNpmSpec("@openclaw/voice-call@1.2.3-beta.4")).toBeNull();
expect(validateRegistryNpmSpec("@openclaw/voice-call@latest")).toBeNull();
expect(validateRegistryNpmSpec("@openclaw/voice-call@beta")).toBeNull();
});
it("rejects semver ranges", () => {
expect(validateRegistryNpmSpec("@openclaw/voice-call@^1.2.3")).toContain(
"exact version or dist-tag",
);
expect(validateRegistryNpmSpec("@openclaw/voice-call@~1.2.3")).toContain(
"exact version or dist-tag",
);
});
});
describe("npm prerelease resolution policy", () => {
it("blocks prerelease resolutions for bare specs", () => {
const spec = parseRegistryNpmSpec("@openclaw/voice-call");
expect(spec).not.toBeNull();
expect(
isPrereleaseResolutionAllowed({
spec: spec!,
resolvedVersion: "1.2.3-beta.1",
}),
).toBe(false);
});
it("blocks prerelease resolutions for latest", () => {
const spec = parseRegistryNpmSpec("@openclaw/voice-call@latest");
expect(spec).not.toBeNull();
expect(
isPrereleaseResolutionAllowed({
spec: spec!,
resolvedVersion: "1.2.3-rc.1",
}),
).toBe(false);
});
it("allows prerelease resolutions when the user explicitly opted in", () => {
const tagSpec = parseRegistryNpmSpec("@openclaw/voice-call@beta");
const versionSpec = parseRegistryNpmSpec("@openclaw/voice-call@1.2.3-beta.1");
expect(tagSpec).not.toBeNull();
expect(versionSpec).not.toBeNull();
expect(
isPrereleaseResolutionAllowed({
spec: tagSpec!,
resolvedVersion: "1.2.3-beta.4",
}),
).toBe(true);
expect(
isPrereleaseResolutionAllowed({
spec: versionSpec!,
resolvedVersion: "1.2.3-beta.1",
}),
).toBe(true);
});
});

View File

@@ -1,41 +1,141 @@
export function validateRegistryNpmSpec(rawSpec: string): string | null {
const EXACT_SEMVER_VERSION_RE =
/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
const DIST_TAG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
export type ParsedRegistryNpmSpec = {
name: string;
raw: string;
selector?: string;
selectorKind: "none" | "exact-version" | "tag";
selectorIsPrerelease: boolean;
};
function parseRegistryNpmSpecInternal(
rawSpec: string,
): { ok: true; parsed: ParsedRegistryNpmSpec } | { ok: false; error: string } {
const spec = rawSpec.trim();
if (!spec) {
return "missing npm spec";
return { ok: false, error: "missing npm spec" };
}
if (/\s/.test(spec)) {
return "unsupported npm spec: whitespace is not allowed";
return { ok: false, error: "unsupported npm spec: whitespace is not allowed" };
}
// Registry-only: no URLs, git, file, or alias protocols.
// Keep strict: this runs on the gateway host.
if (spec.includes("://")) {
return "unsupported npm spec: URLs are not allowed";
return { ok: false, error: "unsupported npm spec: URLs are not allowed" };
}
if (spec.includes("#")) {
return "unsupported npm spec: git refs are not allowed";
return { ok: false, error: "unsupported npm spec: git refs are not allowed" };
}
if (spec.includes(":")) {
return "unsupported npm spec: protocol specs are not allowed";
return { ok: false, error: "unsupported npm spec: protocol specs are not allowed" };
}
const at = spec.lastIndexOf("@");
const hasVersion = at > 0;
const name = hasVersion ? spec.slice(0, at) : spec;
const version = hasVersion ? spec.slice(at + 1) : "";
const hasSelector = at > 0;
const name = hasSelector ? spec.slice(0, at) : spec;
const selector = hasSelector ? spec.slice(at + 1) : "";
const unscopedName = /^[a-z0-9][a-z0-9-._~]*$/;
const scopedName = /^@[a-z0-9][a-z0-9-._~]*\/[a-z0-9][a-z0-9-._~]*$/;
const isValidName = name.startsWith("@") ? scopedName.test(name) : unscopedName.test(name);
if (!isValidName) {
return "unsupported npm spec: expected <name> or <name>@<version> from the npm registry";
return {
ok: false,
error: "unsupported npm spec: expected <name> or <name>@<version> from the npm registry",
};
}
if (hasVersion) {
if (!version) {
return "unsupported npm spec: missing version/tag after @";
}
if (/[\\/]/.test(version)) {
return "unsupported npm spec: invalid version/tag";
}
if (!hasSelector) {
return {
ok: true,
parsed: {
name,
raw: spec,
selectorKind: "none",
selectorIsPrerelease: false,
},
};
}
return null;
if (!selector) {
return { ok: false, error: "unsupported npm spec: missing version/tag after @" };
}
if (/[\\/]/.test(selector)) {
return { ok: false, error: "unsupported npm spec: invalid version/tag" };
}
const exactVersionMatch = EXACT_SEMVER_VERSION_RE.exec(selector);
if (exactVersionMatch) {
return {
ok: true,
parsed: {
name,
raw: spec,
selector,
selectorKind: "exact-version",
selectorIsPrerelease: Boolean(exactVersionMatch[4]),
},
};
}
if (!DIST_TAG_RE.test(selector)) {
return {
ok: false,
error: "unsupported npm spec: use an exact version or dist-tag (ranges are not allowed)",
};
}
return {
ok: true,
parsed: {
name,
raw: spec,
selector,
selectorKind: "tag",
selectorIsPrerelease: false,
},
};
}
export function parseRegistryNpmSpec(rawSpec: string): ParsedRegistryNpmSpec | null {
const parsed = parseRegistryNpmSpecInternal(rawSpec);
return parsed.ok ? parsed.parsed : null;
}
export function validateRegistryNpmSpec(rawSpec: string): string | null {
const parsed = parseRegistryNpmSpecInternal(rawSpec);
return parsed.ok ? null : parsed.error;
}
export function isExactSemverVersion(value: string): boolean {
return EXACT_SEMVER_VERSION_RE.test(value.trim());
}
export function isPrereleaseSemverVersion(value: string): boolean {
const match = EXACT_SEMVER_VERSION_RE.exec(value.trim());
return Boolean(match?.[4]);
}
export function isPrereleaseResolutionAllowed(params: {
spec: ParsedRegistryNpmSpec;
resolvedVersion?: string;
}): boolean {
if (!params.resolvedVersion || !isPrereleaseSemverVersion(params.resolvedVersion)) {
return true;
}
if (params.spec.selectorKind === "none") {
return false;
}
if (params.spec.selectorKind === "exact-version") {
return params.spec.selectorIsPrerelease;
}
return params.spec.selector?.toLowerCase() !== "latest";
}
export function formatPrereleaseResolutionError(params: {
spec: ParsedRegistryNpmSpec;
resolvedVersion: string;
}): string {
const selectorHint =
params.spec.selectorKind === "none" || params.spec.selector?.toLowerCase() === "latest"
? `Use "${params.spec.name}@beta" (or another prerelease tag) or an exact prerelease version to opt in explicitly.`
: `Use an explicit prerelease tag or exact prerelease version if you want prerelease installs.`;
return `Resolved ${params.spec.raw} to prerelease version ${params.resolvedVersion}, but prereleases are only installed when explicitly requested. ${selectorHint}`;
}