From f392b81e9502e9770360201650bf1d3c29defc68 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 6 Mar 2026 11:13:30 -0500 Subject: [PATCH] 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 --- docs/automation/hooks.md | 7 +- docs/cli/hooks.md | 9 +- docs/cli/plugins.md | 10 +- docs/tools/plugin.md | 8 +- src/hooks/install.test.ts | 22 +++++ src/infra/npm-pack-install.ts | 27 ++++++ src/infra/npm-registry-spec.test.ts | 69 ++++++++++++++ src/infra/npm-registry-spec.ts | 136 ++++++++++++++++++++++++---- src/plugins/install.test.ts | 74 +++++++++++++++ 9 files changed, 337 insertions(+), 25 deletions(-) create mode 100644 src/infra/npm-registry-spec.test.ts diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index d89838f6105..deda79d3db5 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -103,7 +103,12 @@ Hook packs are standard npm packages that export one or more hooks via `openclaw openclaw hooks install ``` -Npm specs are registry-only (package name + optional version/tag). Git/URL/file specs are rejected. +Npm specs are registry-only (package name + optional exact version or dist-tag). +Git/URL/file specs and semver ranges are rejected. + +Bare specs and `@latest` stay on the stable track. If npm resolves either of +those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a +prerelease tag such as `@beta`/`@rc` or an exact prerelease version. Example `package.json`: diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md index 6dadb26970e..8aaaa6fd63d 100644 --- a/docs/cli/hooks.md +++ b/docs/cli/hooks.md @@ -193,8 +193,13 @@ openclaw hooks install --pin Install a hook pack from a local folder/archive or npm. -Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file -specs are rejected. Dependency installs run with `--ignore-scripts` for safety. +Npm specs are **registry-only** (package name + optional **exact version** or +**dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency +installs run with `--ignore-scripts` for safety. + +Bare specs and `@latest` stay on the stable track. If npm resolves either of +those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a +prerelease tag such as `@beta`/`@rc` or an exact prerelease version. **What it does:** diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 0934a0289c6..0b054f5a4aa 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -45,8 +45,14 @@ openclaw plugins install --pin Security note: treat plugin installs like running code. Prefer pinned versions. -Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file -specs are rejected. Dependency installs run with `--ignore-scripts` for safety. +Npm specs are **registry-only** (package name + optional **exact version** or +**dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency +installs run with `--ignore-scripts` for safety. + +Bare specs and `@latest` stay on the stable track. If npm resolves either of +those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a +prerelease tag such as `@beta`/`@rc` or an exact prerelease version such as +`@1.2.3-beta.4`. If a bare install spec matches a bundled plugin id (for example `diffs`), OpenClaw installs the bundled plugin directly. To install an npm package with the same diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index f40a0540a42..d709f9227c8 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -31,8 +31,12 @@ openclaw plugins list openclaw plugins install @openclaw/voice-call ``` -Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file -specs are rejected. +Npm specs are **registry-only** (package name + optional **exact version** or +**dist-tag**). Git/URL/file specs and semver ranges are rejected. + +Bare specs and `@latest` stay on the stable track. If npm resolves either of +those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a +prerelease tag such as `@beta`/`@rc` or an exact prerelease version. 3. Restart the Gateway, then configure under `plugins.entries..config`. diff --git a/src/hooks/install.test.ts b/src/hooks/install.test.ts index ad179d5af21..2dba56b1d3b 100644 --- a/src/hooks/install.test.ts +++ b/src/hooks/install.test.ts @@ -409,6 +409,28 @@ describe("installHooksFromNpmSpec", () => { actualIntegrity: "sha512-new", }); }); + + it("rejects bare npm specs that resolve to prerelease versions", async () => { + const run = vi.mocked(runCommandWithTimeout); + mockNpmPackMetadataResult(run, { + id: "@openclaw/test-hooks@0.0.2-beta.1", + name: "@openclaw/test-hooks", + version: "0.0.2-beta.1", + filename: "test-hooks-0.0.2-beta.1.tgz", + integrity: "sha512-beta", + shasum: "betashasum", + }); + + const result = await installHooksFromNpmSpec({ + spec: "@openclaw/test-hooks", + logger: { info: () => {}, warn: () => {} }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("prerelease version 0.0.2-beta.1"); + expect(result.error).toContain('"@openclaw/test-hooks@beta"'); + } + }); }); describe("gmail watcher", () => { diff --git a/src/infra/npm-pack-install.ts b/src/infra/npm-pack-install.ts index f343653c415..e7c8f97ca84 100644 --- a/src/infra/npm-pack-install.ts +++ b/src/infra/npm-pack-install.ts @@ -8,6 +8,11 @@ import { type NpmIntegrityDriftPayload, resolveNpmIntegrityDriftWithDefaultMessage, } from "./npm-integrity.js"; +import { + formatPrereleaseResolutionError, + isPrereleaseResolutionAllowed, + parseRegistryNpmSpec, +} from "./npm-registry-spec.js"; export type NpmSpecArchiveInstallFlowResult = | { @@ -94,6 +99,13 @@ export async function installFromNpmSpecArchive installFromArchive: (params: { archivePath: string }) => Promise; }): Promise> { 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 ...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, diff --git a/src/infra/npm-registry-spec.test.ts b/src/infra/npm-registry-spec.test.ts new file mode 100644 index 00000000000..8c0b62c5667 --- /dev/null +++ b/src/infra/npm-registry-spec.test.ts @@ -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); + }); +}); diff --git a/src/infra/npm-registry-spec.ts b/src/infra/npm-registry-spec.ts index 5861d301717..622382d05e8 100644 --- a/src/infra/npm-registry-spec.ts +++ b/src/infra/npm-registry-spec.ts @@ -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 or @ from the npm registry"; + return { + ok: false, + error: "unsupported npm spec: expected or @ 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}`; } diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 40ce9b18f99..5f698a8e64b 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -858,4 +858,78 @@ describe("installPluginFromNpmSpec", () => { expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND); } }); + + it("rejects bare npm specs that resolve to prerelease versions", async () => { + const run = vi.mocked(runCommandWithTimeout); + mockNpmPackMetadataResult(run, { + id: "@openclaw/voice-call@0.0.2-beta.1", + name: "@openclaw/voice-call", + version: "0.0.2-beta.1", + filename: "voice-call-0.0.2-beta.1.tgz", + integrity: "sha512-beta", + shasum: "betashasum", + }); + + const result = await installPluginFromNpmSpec({ + spec: "@openclaw/voice-call", + logger: { info: () => {}, warn: () => {} }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("prerelease version 0.0.2-beta.1"); + expect(result.error).toContain('"@openclaw/voice-call@beta"'); + } + }); + + it("allows explicit prerelease npm tags", async () => { + const run = vi.mocked(runCommandWithTimeout); + let packTmpDir = ""; + const packedName = "voice-call-0.0.2-beta.1.tgz"; + const voiceCallArchiveBuffer = VOICE_CALL_ARCHIVE_V1_BUFFER; + run.mockImplementation(async (argv, opts) => { + if (argv[0] === "npm" && argv[1] === "pack") { + packTmpDir = String(typeof opts === "number" ? "" : (opts.cwd ?? "")); + fs.writeFileSync(path.join(packTmpDir, packedName), voiceCallArchiveBuffer); + return { + code: 0, + stdout: JSON.stringify([ + { + id: "@openclaw/voice-call@0.0.2-beta.1", + name: "@openclaw/voice-call", + version: "0.0.2-beta.1", + filename: packedName, + integrity: "sha512-beta", + shasum: "betashasum", + }, + ]), + stderr: "", + signal: null, + killed: false, + termination: "exit", + }; + } + throw new Error(`unexpected command: ${argv.join(" ")}`); + }); + + const { extensionsDir } = await setupVoiceCallArchiveInstall({ + outName: "voice-call-0.0.2-beta.1.tgz", + version: "0.0.1", + }); + const result = await installPluginFromNpmSpec({ + spec: "@openclaw/voice-call@beta", + extensionsDir, + logger: { info: () => {}, warn: () => {} }, + }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.npmResolution?.version).toBe("0.0.2-beta.1"); + expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.2-beta.1"); + expectSingleNpmPackIgnoreScriptsCall({ + calls: run.mock.calls, + expectedSpec: "@openclaw/voice-call@beta", + }); + expect(packTmpDir).not.toBe(""); + }); });