diff --git a/CHANGELOG.md b/CHANGELOG.md index f5c95288d36..807f6983b66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Plugins/install: add `npm-pack:` installs so local npm pack artifacts run through the same managed npm-root install, lockfile verification, dependency scan, and install-record path as registry npm plugins. - Plugin skills/Windows: publish plugin-provided skill directories as junctions on Windows so standard users without Developer Mode can register plugin skills without symlink EPERM failures. Fixes #77958. (#77971) Thanks @hclsys and @jarro. - MS Teams: surface blocked Bot Framework egress by logging JWKS fetch network failures and adding a Bot Connector send hint for transport-level reply failures. Fixes #77674. (#78081) Thanks @Beandon13. - Gateway/sessions: fast-path already-qualified model refs while building session-list rows so `openclaw sessions` and Control UI session lists avoid heavyweight model resolution on large stores. (#77902) Thanks @ragesaq. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 2467aad1f61..249cc1315f0 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -74,6 +74,7 @@ openclaw plugins search "calendar" # search ClawHub plugins openclaw plugins install # npm by default openclaw plugins install clawhub: # ClawHub only openclaw plugins install npm: # npm only +openclaw plugins install npm-pack: # local npm pack through npm install semantics openclaw plugins install git:github.com// # git repo openclaw plugins install git:github.com//@ openclaw plugins install --force # overwrite existing install @@ -150,6 +151,12 @@ is available, then fall back to `latest`. Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`. Native OpenClaw plugin archives must contain a valid `openclaw.plugin.json` at the extracted plugin root; archives that only contain `package.json` are rejected before OpenClaw writes install records. + Use `npm-pack:` when the file is an npm-pack tarball and you want + to test the same managed npm-root install path used by registry installs, + including `package-lock.json` verification, hoisted dependency scanning, and + npm install records. Plain archive paths still install as local archives + under the plugin extensions root. + Claude marketplace installs are also supported. diff --git a/docs/plugins/dependency-resolution.md b/docs/plugins/dependency-resolution.md index fd7a4364dfb..a2ac739b634 100644 --- a/docs/plugins/dependency-resolution.md +++ b/docs/plugins/dependency-resolution.md @@ -46,6 +46,13 @@ npm installs run in the npm root with: npm install --prefix ~/.openclaw/npm --omit=dev --ignore-scripts --no-audit --no-fund ``` +`openclaw plugins install npm-pack:` uses that same managed npm root +for a local npm-pack tarball. OpenClaw reads the tarball's npm metadata, adds it +to the managed root as a copied `file:` dependency, runs the normal npm install, +and then verifies the installed lockfile metadata before trusting the plugin. +This is intended for package-acceptance and release-candidate proof where a +local pack artifact should behave like the registry artifact it simulates. + npm may hoist transitive dependencies to `~/.openclaw/npm/node_modules` beside the plugin package. OpenClaw scans the managed npm root before trusting the install and uses npm to remove npm-managed packages during uninstall, so hoisted diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index b9ad8eb6087..3833853915c 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -38,6 +38,7 @@ For copy-paste install, list, uninstall, update, and publishing examples, see # From npm openclaw plugins install npm:@acme/openclaw-plugin + openclaw plugins install npm-pack:./openclaw-plugin-1.2.3.tgz # From git openclaw plugins install git:github.com/acme/openclaw-plugin@v1.0.0 @@ -92,8 +93,8 @@ If you prefer chat-native control, enable `commands.plugins: true` and use: ``` The install path uses the same resolver as the CLI: local path/archive, explicit -`clawhub:`, explicit `npm:`, explicit `git:`, or bare package -spec through npm. +`clawhub:`, explicit `npm:`, explicit `npm-pack:`, +explicit `git:`, or bare package spec through npm. If config is invalid, install normally fails closed and points you at `openclaw doctor --fix`. The only recovery exception is a narrow bundled-plugin diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index c751944caac..27196ca0023 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -81,6 +81,7 @@ export class PromptInputClosedError extends Error { } } export const installPluginFromNpmSpec: AsyncUnknownMock = vi.fn(); +export const installPluginFromNpmPackArchive: AsyncUnknownMock = vi.fn(); export const installPluginFromPath: AsyncUnknownMock = vi.fn(); export const installPluginFromClawHub: AsyncUnknownMock = vi.fn(); export const parseClawHubPluginSpec: Mock = vi.fn(); @@ -485,6 +486,16 @@ vi.mock("../plugins/install.js", () => ({ installPluginFromNpmSpec, ...args, )) as (typeof import("../plugins/install.js"))["installPluginFromNpmSpec"], + installPluginFromNpmPackArchive: (( + ...args: Parameters<(typeof import("../plugins/install.js"))["installPluginFromNpmPackArchive"]> + ) => + invokeMock< + Parameters<(typeof import("../plugins/install.js"))["installPluginFromNpmPackArchive"]>, + ReturnType<(typeof import("../plugins/install.js"))["installPluginFromNpmPackArchive"]> + >( + installPluginFromNpmPackArchive, + ...args, + )) as (typeof import("../plugins/install.js"))["installPluginFromNpmPackArchive"], installPluginFromPath: (( ...args: Parameters<(typeof import("../plugins/install.js"))["installPluginFromPath"]> ) => @@ -650,6 +661,7 @@ export function resetPluginsCliTestState() { installPluginFromGitSpec.mockReset(); parseGitPluginSpec.mockReset(); installPluginFromNpmSpec.mockReset(); + installPluginFromNpmPackArchive.mockReset(); installPluginFromPath.mockReset(); installPluginFromClawHub.mockReset(); parseClawHubPluginSpec.mockReset(); diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index ed9b013fb37..7e097605ed8 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -16,6 +16,7 @@ import { findBundledPluginSourceMock, installHooksFromNpmSpec, installHooksFromPath, + installPluginFromNpmPackArchive, installPluginFromClawHub, installPluginFromGitSpec, installPluginFromMarketplace, @@ -127,6 +128,28 @@ function createNpmPluginInstallResult( }; } +function createNpmPackPluginInstallResult( + pluginId = "demo", +): Awaited> { + return { + ok: true, + pluginId, + targetDir: cliInstallPath(pluginId), + version: "1.2.3", + extensions: ["dist/index.js"], + manifestName: `@openclaw/${pluginId}`, + npmTarballName: `openclaw-${pluginId}-1.2.3.tgz`, + npmResolution: { + name: `@openclaw/${pluginId}`, + version: "1.2.3", + resolvedSpec: `@openclaw/${pluginId}@1.2.3`, + integrity: "sha512-pack-demo", + shasum: "packdemosha", + resolvedAt: "2026-05-06T00:00:00.000Z", + }, + }; +} + function createGitPluginInstallResult( pluginId = "demo", ): Awaited> { @@ -909,6 +932,47 @@ describe("plugins cli install", () => { expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg); }); + it("installs npm-pack archives through npm install semantics", async () => { + const cfg = createEmptyPluginConfig(); + const enabledCfg = createEnabledPluginConfig("demo"); + const archivePath = "/tmp/openclaw-demo-1.2.3.tgz"; + + loadConfig.mockReturnValue(cfg); + installPluginFromNpmPackArchive.mockResolvedValue(createNpmPackPluginInstallResult("demo")); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(enabledCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", `npm-pack:${archivePath}`]); + + expect(installPluginFromNpmPackArchive).toHaveBeenCalledWith( + expect.objectContaining({ + archivePath, + mode: "install", + }), + ); + expect(installPluginFromPath).not.toHaveBeenCalled(); + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({ + demo: expect.objectContaining({ + source: "npm", + spec: "@openclaw/demo@1.2.3", + sourcePath: archivePath, + installPath: cliInstallPath("demo"), + version: "1.2.3", + artifactKind: "npm-pack", + artifactFormat: "tgz", + npmIntegrity: "sha512-pack-demo", + npmShasum: "packdemosha", + npmTarballName: "openclaw-demo-1.2.3.tgz", + }), + }); + expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg); + }); + it("keeps npm-prefixed official plugin ids on explicit npm semantics", async () => { const cfg = createEmptyPluginConfig(); const enabledCfg = createEnabledPluginConfig("brave"); diff --git a/src/cli/plugins-command-helpers.ts b/src/cli/plugins-command-helpers.ts index 1b9efef9162..9ff9d372d73 100644 --- a/src/cli/plugins-command-helpers.ts +++ b/src/cli/plugins-command-helpers.ts @@ -228,3 +228,11 @@ export function parseNpmPrefixSpec(raw: string): string | null { } return trimmed.slice("npm:".length).trim(); } + +export function parseNpmPackPrefixPath(raw: string): string | null { + const trimmed = raw.trim(); + if (!normalizeLowercaseStringOrEmpty(trimmed).startsWith("npm-pack:")) { + return null; + } + return trimmed.slice("npm-pack:".length).trim(); +} diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index 89e33e2a741..8b1af67e06e 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -14,6 +14,7 @@ import { resolveDefaultPluginExtensionsDir } from "../plugins/install-paths.js"; import type { InstallSafetyOverrides } from "../plugins/install-security-scan.js"; import { PLUGIN_INSTALL_ERROR_CODE, + installPluginFromNpmPackArchive, installPluginFromNpmSpec, installPluginFromPath, } from "../plugins/install.js"; @@ -49,6 +50,7 @@ import { createHookPackInstallLogger, createPluginInstallLogger, formatPluginInstallWithHookFallbackError, + parseNpmPackPrefixPath, parseNpmPrefixSpec, } from "./plugins-command-helpers.js"; import { persistHookPackInstall, persistPluginInstall } from "./plugins-install-persist.js"; @@ -379,6 +381,54 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: { return { ok: true }; } +async function tryInstallPluginFromNpmPackArchive(params: { + snapshot: ConfigSnapshotForInstallPersist; + installMode: "install" | "update"; + archivePath: string; + safetyOverrides: InstallSafetyOverrides; + extensionsDir: string; + runtime?: RuntimeEnv; +}): Promise<{ ok: true } | { ok: false }> { + const result = await installPluginFromNpmPackArchive({ + ...params.safetyOverrides, + mode: params.installMode, + archivePath: params.archivePath, + extensionsDir: params.extensionsDir, + logger: createPluginInstallLogger(params.runtime), + }); + if (!result.ok) { + (params.runtime ?? defaultRuntime).error(result.error); + return { ok: false }; + } + + await persistPluginInstall({ + snapshot: params.snapshot, + pluginId: result.pluginId, + install: { + source: "npm", + spec: result.npmResolution?.resolvedSpec ?? result.manifestName ?? result.pluginId, + sourcePath: params.archivePath, + installPath: result.targetDir, + ...(result.version ? { version: result.version } : {}), + ...(result.npmResolution?.name ? { resolvedName: result.npmResolution.name } : {}), + ...(result.npmResolution?.version ? { resolvedVersion: result.npmResolution.version } : {}), + ...(result.npmResolution?.resolvedSpec + ? { resolvedSpec: result.npmResolution.resolvedSpec } + : {}), + ...(result.npmResolution?.integrity ? { integrity: result.npmResolution.integrity } : {}), + ...(result.npmResolution?.shasum ? { shasum: result.npmResolution.shasum } : {}), + ...(result.npmResolution?.resolvedAt ? { resolvedAt: result.npmResolution.resolvedAt } : {}), + artifactKind: "npm-pack", + artifactFormat: "tgz", + ...(result.npmResolution?.integrity ? { npmIntegrity: result.npmResolution.integrity } : {}), + ...(result.npmResolution?.shasum ? { npmShasum: result.npmResolution.shasum } : {}), + ...(result.npmTarballName ? { npmTarballName: result.npmTarballName } : {}), + }, + runtime: params.runtime, + }); + return { ok: true }; +} + async function tryInstallPluginFromGitSpec(params: { snapshot: ConfigSnapshotForInstallPersist; installMode: "install" | "update"; @@ -753,6 +803,26 @@ export async function runPluginInstallCommand(params: { return; } + const npmPackPath = parseNpmPackPrefixPath(raw); + if (npmPackPath !== null) { + if (!npmPackPath) { + runtime.error("unsupported npm-pack: spec: missing pack archive path"); + return runtime.exit(1); + } + const npmPackResult = await tryInstallPluginFromNpmPackArchive({ + snapshot, + installMode, + archivePath: npmPackPath, + safetyOverrides, + extensionsDir, + runtime, + }); + if (!npmPackResult.ok) { + return runtime.exit(1); + } + return; + } + if (gitSpec) { const gitResult = await tryInstallPluginFromGitSpec({ snapshot, diff --git a/src/infra/install-source-utils.ts b/src/infra/install-source-utils.ts index c23f4a9a874..2a0ca488df5 100644 --- a/src/infra/install-source-utils.ts +++ b/src/infra/install-source-utils.ts @@ -321,3 +321,52 @@ export async function packNpmSpecToArchive(params: { metadata: parsedJson?.metadata ?? {}, }; } + +export async function resolveNpmPackArchiveMetadata(params: { + archivePath: string; + timeoutMs?: number; +}): Promise< + | { + ok: true; + archivePath: string; + tarballName: string; + metadata: NpmSpecResolution; + } + | { + ok: false; + error: string; + } +> { + const archivePathResult = await resolveArchiveSourcePath(params.archivePath); + if (!archivePathResult.ok) { + return archivePathResult; + } + const archivePath = archivePathResult.path; + const res = await runCommandWithTimeout( + ["npm", "pack", archivePath, "--ignore-scripts", "--dry-run", "--json"], + { + timeoutMs: Math.max(params.timeoutMs ?? 60_000, 60_000), + env: { + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", + NPM_CONFIG_IGNORE_SCRIPTS: "true", + }, + }, + ); + if (res.code !== 0) { + return { + ok: false, + error: `npm pack metadata read failed: ${res.stderr.trim() || res.stdout.trim()}`, + }; + } + + const parsedJson = parseNpmPackJsonOutput(res.stdout || ""); + if (!parsedJson?.metadata.name || !parsedJson.metadata.version) { + return { ok: false, error: "npm pack metadata read produced incomplete package metadata" }; + } + return { + ok: true, + archivePath, + tarballName: parsedJson.filename ?? path.basename(archivePath), + metadata: parsedJson.metadata, + }; +} diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index 15c12ff4890..8f5e35238e1 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -15,7 +15,8 @@ vi.mock("../process/exec.js", () => ({ vi.resetModules(); -const { installPluginFromNpmSpec, PLUGIN_INSTALL_ERROR_CODE } = await import("./install.js"); +const { installPluginFromNpmPackArchive, installPluginFromNpmSpec, PLUGIN_INSTALL_ERROR_CODE } = + await import("./install.js"); const suiteTempRootTracker = createSuiteTempRootTracker("openclaw-plugin-install-npm-spec"); @@ -38,6 +39,18 @@ function npmViewVersionsArgv(spec: string): string[] { return ["npm", "view", spec, "versions", "--json"]; } +function npmPackArchiveMetadataArgv(archivePath: string): string[] { + return ["npm", "pack", archivePath, "--ignore-scripts", "--dry-run", "--json"]; +} + +function resolveManagedFileDependency(npmRoot: string, dependencySpec: string): string | null { + if (!dependencySpec.startsWith("file:")) { + return null; + } + const rawPath = dependencySpec.slice("file:".length); + return path.isAbsolute(rawPath) ? rawPath : path.resolve(npmRoot, rawPath); +} + function expectNpmInstallIntoRoot(params: { calls: unknown[][]; npmRoot: string }) { const installCalls = params.calls.filter( (call) => Array.isArray(call[0]) && call[0][0] === "npm" && call[0][1] === "install", @@ -126,7 +139,7 @@ function writeInstalledNpmPlugin(params: { } type MockNpmPackage = { - spec: string; + spec?: string; packageName: string; version: string; npmRoot: string; @@ -143,6 +156,8 @@ type MockNpmPackage = { installedIntegrity?: string; materializesRootOpenClaw?: boolean; skipLockfileEntry?: boolean; + packArchivePath?: string; + packTarballName?: string; }; function writeNpmRootPackageLock(params: { @@ -228,8 +243,29 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) { const packagesByName = new Map(packages.map((pkg) => [pkg.packageName, pkg])); runCommandWithTimeoutMock.mockImplementation( async (argv: string[], options?: { cwd?: string }) => { + const packPackage = packages.find( + (pkg) => + pkg.packArchivePath && + JSON.stringify(argv) === JSON.stringify(npmPackArchiveMetadataArgv(pkg.packArchivePath)), + ); + if (packPackage) { + return successfulSpawn( + JSON.stringify([ + { + id: `${packPackage.packageName}@${packPackage.version}`, + name: packPackage.packageName, + version: packPackage.version, + filename: + packPackage.packTarballName ?? + `${packPackage.packageName.replace(/^@/, "").replace("/", "-")}-${packPackage.version}.tgz`, + integrity: packPackage.integrity ?? "sha512-plugin-test", + shasum: packPackage.shasum ?? "pluginshasum", + }, + ]), + ); + } const viewPackage = packages.find( - (pkg) => JSON.stringify(argv) === JSON.stringify(npmViewArgv(pkg.spec)), + (pkg) => pkg.spec && JSON.stringify(argv) === JSON.stringify(npmViewArgv(pkg.spec)), ); if (viewPackage) { return successfulSpawn( @@ -276,6 +312,12 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) { `expected managed npm dependency ${packageName}@${pkg.expectedDependencySpec}, got ${dependencySpec ?? ""}`, ); } + const fileDependencyPath = dependencySpec + ? resolveManagedFileDependency(npmRoot, dependencySpec) + : null; + if (fileDependencyPath && !fs.existsSync(fileDependencyPath)) { + throw new Error(`missing managed npm file dependency: ${fileDependencyPath}`); + } writeInstalledNpmPlugin({ ...pkg, version: pkg.installedVersion ?? pkg.version, @@ -338,6 +380,114 @@ beforeEach(() => { }); describe("installPluginFromNpmSpec", () => { + it("installs npm pack archives through the managed npm root", async () => { + const stateDir = suiteTempRootTracker.makeTempDir(); + const npmRoot = path.join(stateDir, "npm"); + const archivePath = path.join(stateDir, "openclaw-pack-demo-1.2.3.tgz"); + fs.writeFileSync(archivePath, "fixture pack contents", "utf8"); + + mockNpmViewAndInstallMany([ + { + packageName: "@openclaw/pack-demo", + version: "1.2.3", + pluginId: "pack-demo", + npmRoot, + integrity: "sha512-pack-demo", + shasum: "packdemosha", + packArchivePath: archivePath, + }, + { + spec: "@openclaw/voice-call@0.0.1", + packageName: "@openclaw/voice-call", + version: "0.0.1", + pluginId: "voice-call", + npmRoot, + }, + ]); + + const result = await installPluginFromNpmPackArchive({ + archivePath, + npmDir: npmRoot, + logger: { info: () => {}, warn: () => {} }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.pluginId).toBe("pack-demo"); + expect(result.targetDir).toBe(path.join(npmRoot, "node_modules", "@openclaw/pack-demo")); + expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/pack-demo@1.2.3"); + expect(result.npmResolution?.integrity).toBe("sha512-pack-demo"); + expect(result.npmTarballName).toBe("openclaw-pack-demo-1.2.3.tgz"); + expectNpmInstallIntoRoot({ + calls: runCommandWithTimeoutMock.mock.calls, + npmRoot, + }); + const managedManifest = JSON.parse( + await fs.promises.readFile(path.join(npmRoot, "package.json"), "utf8"), + ) as { dependencies?: Record }; + const dependencySpec = managedManifest.dependencies?.["@openclaw/pack-demo"]; + expect(dependencySpec).toMatch(/^file:\.\/_openclaw-pack-archives\/.+\.tgz$/); + expect(dependencySpec).not.toContain(archivePath); + const stagedArchivePath = dependencySpec + ? resolveManagedFileDependency(npmRoot, dependencySpec) + : null; + expect(stagedArchivePath).toBeTruthy(); + if (!stagedArchivePath) { + return; + } + await expect(fs.promises.readFile(stagedArchivePath, "utf8")).resolves.toBe( + "fixture pack contents", + ); + + fs.unlinkSync(archivePath); + const unrelatedResult = await installPluginFromNpmSpec({ + spec: "@openclaw/voice-call@0.0.1", + npmDir: npmRoot, + logger: { info: () => {}, warn: () => {} }, + }); + expect(unrelatedResult.ok).toBe(true); + }); + + it("rejects npm pack archive metadata with traversal package names", async () => { + const stateDir = suiteTempRootTracker.makeTempDir(); + const npmRoot = path.join(stateDir, "npm"); + const victimDir = path.join(stateDir, "victim"); + const archivePath = path.join(stateDir, "evil-pack-1.0.0.tgz"); + fs.mkdirSync(victimDir, { recursive: true }); + fs.writeFileSync(path.join(victimDir, "keep.txt"), "keep", "utf8"); + fs.writeFileSync(archivePath, "fixture pack contents", "utf8"); + + mockNpmViewAndInstallMany([ + { + packageName: "@evil/../../../../victim", + version: "1.0.0", + npmRoot, + packArchivePath: archivePath, + }, + ]); + + const result = await installPluginFromNpmPackArchive({ + archivePath, + npmDir: npmRoot, + logger: { info: () => {}, warn: () => {} }, + mode: "update", + }); + + expect(result).toMatchObject({ + ok: false, + code: PLUGIN_INSTALL_ERROR_CODE.INVALID_NPM_SPEC, + }); + if (!result.ok) { + expect(result.error).toContain("unsupported npm pack package name"); + } + expect(fs.existsSync(path.join(victimDir, "keep.txt"))).toBe(true); + expect(fs.existsSync(path.join(npmRoot, "package.json"))).toBe(false); + expect(fs.existsSync(path.join(npmRoot, "_openclaw-pack-archives"))).toBe(false); + expect(runCommandWithTimeoutMock.mock.calls).toHaveLength(1); + }); + it("installs npm plugins into .openclaw/npm", async () => { const stateDir = suiteTempRootTracker.makeTempDir(); const npmRoot = path.join(stateDir, "npm"); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 7517dc2944b..46396ad64fd 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -1,7 +1,9 @@ +import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { packageNameMatchesId } from "../infra/install-safe-path.js"; import { + resolveNpmPackArchiveMetadata, resolveNpmSpecMetadata, type NpmIntegrityDrift, type NpmSpecResolution, @@ -22,6 +24,7 @@ import { isPrereleaseSemverVersion, isPrereleaseResolutionAllowed, parseRegistryNpmSpec, + validateRegistryNpmSpec, type ParsedRegistryNpmSpec, } from "../infra/npm-registry-spec.js"; import { @@ -81,6 +84,7 @@ const PLUGIN_ARCHIVE_ROOT_MARKERS = [ ".claude-plugin/plugin.json", ".cursor-plugin/plugin.json", ]; +const MANAGED_NPM_PACK_ARCHIVE_DIR = "_openclaw-pack-archives"; export const PLUGIN_INSTALL_ERROR_CODE = { INVALID_NPM_SPEC: "invalid_npm_spec", @@ -383,6 +387,234 @@ function resolveInstalledNpmResolutionMismatch(params: { return null; } +function resolveTrustedNpmPackPackageName(packageName: string | undefined): + | { + ok: true; + packageName: string; + } + | { + ok: false; + error: string; + code: PluginInstallErrorCode; + } { + if (!packageName) { + return { + ok: false, + error: "npm pack metadata missing package name", + code: PLUGIN_INSTALL_ERROR_CODE.INVALID_NPM_SPEC, + }; + } + const specError = validateRegistryNpmSpec(packageName); + const parsedSpec = parseRegistryNpmSpec(packageName); + if (specError || !parsedSpec || parsedSpec.selectorKind !== "none") { + return { + ok: false, + error: `unsupported npm pack package name: ${packageName}`, + code: PLUGIN_INSTALL_ERROR_CODE.INVALID_NPM_SPEC, + }; + } + return { ok: true, packageName: parsedSpec.name }; +} + +async function installPluginFromManagedNpmRoot( + params: InstallSafetyOverrides & { + packageName: string; + dependencySpec: string; + displaySpec: string; + installPolicyRequest: PluginInstallPolicyRequest; + npmResolution: NpmSpecResolution; + extensionsDir?: string; + npmDir?: string; + timeoutMs?: number; + logger?: PluginInstallLogger; + mode?: "install" | "update"; + dryRun?: boolean; + expectedPluginId?: string; + integrityDrift?: NpmIntegrityDrift; + }, +): Promise { + const runtime = await loadPluginInstallRuntime(); + const { logger, timeoutMs, mode, dryRun } = runtime.resolveTimedInstallModeOptions( + params, + defaultLogger, + ); + const expectedPluginId = params.expectedPluginId; + const npmRoot = params.npmDir ? resolveUserPath(params.npmDir) : resolveDefaultPluginNpmDir(); + const installRoot = path.join(npmRoot, "node_modules", params.packageName); + const effectiveMode = await resolveEffectiveInstallMode({ + runtime, + requestedMode: mode, + targetPath: installRoot, + }); + const availability = await ensureInstallTargetAvailableForMode({ + runtime, + targetPath: installRoot, + mode: effectiveMode, + }); + if (!availability.ok) { + return availability; + } + if (dryRun) { + return { + ok: true, + pluginId: expectedPluginId ?? params.packageName, + targetDir: installRoot, + extensions: [], + npmResolution: params.npmResolution, + ...(params.integrityDrift ? { integrityDrift: params.integrityDrift } : {}), + }; + } + + logger.info?.(`Installing ${params.displaySpec} into ${npmRoot}…`); + if (params.packageName !== "openclaw") { + const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({ + npmRoot, + timeoutMs, + logger, + }); + if (repairedOpenClawPeer) { + logger.info?.(`Repaired stale openclaw peer dependency in ${npmRoot}`); + } + } + await upsertManagedNpmRootDependency({ + npmRoot, + packageName: params.packageName, + dependencySpec: params.dependencySpec, + }); + const install = await runCommandWithTimeout( + [ + "npm", + ...createSafeNpmInstallArgs({ + omitDev: true, + loglevel: "error", + noAudit: true, + noFund: true, + }), + "--prefix", + ".", + ], + { + cwd: npmRoot, + timeoutMs: Math.max(timeoutMs, 300_000), + env: createSafeNpmInstallEnv(process.env, { packageLock: true, quiet: true }), + }, + ); + if (install.code !== 0) { + await rollbackManagedNpmPluginInstall({ + npmRoot, + packageName: params.packageName, + targetDir: installRoot, + timeoutMs, + logger, + }); + return { + ok: false, + error: `npm install failed: ${install.stderr.trim() || install.stdout.trim()}`, + }; + } + if (params.packageName !== "openclaw") { + const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({ + npmRoot, + timeoutMs, + logger, + }); + if (repairedOpenClawPeer) { + logger.info?.(`Repaired stale openclaw peer dependency in ${npmRoot} after npm install`); + } + } + await relinkOpenClawPeerDependenciesInManagedNpmRoot({ npmRoot, logger }); + + let installedDependency: ManagedNpmRootInstalledDependency | null; + try { + installedDependency = await readManagedNpmRootInstalledDependency({ + npmRoot, + packageName: params.packageName, + }); + } catch (error) { + await rollbackManagedNpmPluginInstall({ + npmRoot, + packageName: params.packageName, + targetDir: installRoot, + timeoutMs, + logger, + }); + return { + ok: false, + error: `Failed to verify npm install metadata for ${params.packageName}: ${String(error)}`, + }; + } + const resolutionMismatch = resolveInstalledNpmResolutionMismatch({ + packageName: params.packageName, + expected: params.npmResolution, + installed: installedDependency, + }); + if (resolutionMismatch) { + await rollbackManagedNpmPluginInstall({ + npmRoot, + packageName: params.packageName, + targetDir: installRoot, + timeoutMs, + logger, + }); + return { + ok: false, + error: resolutionMismatch, + }; + } + + const result = await installPluginFromInstalledPackageDir({ + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + packageDir: installRoot, + dependencyScanRootDir: npmRoot, + logger, + expectedPluginId, + trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, + mode: effectiveMode, + installPolicyRequest: params.installPolicyRequest, + }); + if (!result.ok) { + await rollbackManagedNpmPluginInstall({ + npmRoot, + packageName: params.packageName, + targetDir: installRoot, + timeoutMs, + logger, + }); + return result; + } + return { + ...result, + npmResolution: params.npmResolution, + ...(params.integrityDrift ? { integrityDrift: params.integrityDrift } : {}), + }; +} + +async function stageNpmPackArchiveInManagedRoot(params: { + archivePath: string; + npmRoot: string; + packageName: string; + version?: string; + integrity?: string; + shasum?: string; + tarballName: string; +}): Promise<{ stableArchivePath: string; dependencySpec: string }> { + const archiveStoreDir = path.join(params.npmRoot, MANAGED_NPM_PACK_ARCHIVE_DIR); + const identity = params.integrity ?? params.shasum ?? params.tarballName; + const identitySlug = createHash("sha256").update(identity).digest("hex").slice(0, 16); + const packageSlug = safePluginInstallFileName(params.packageName) || "plugin"; + const versionSlug = safePluginInstallFileName(params.version ?? "pack") || "pack"; + const archiveFileName = `${packageSlug}-${versionSlug}-${identitySlug}.tgz`; + const stableArchivePath = path.join(archiveStoreDir, archiveFileName); + + await fs.mkdir(archiveStoreDir, { recursive: true }); + await fs.copyFile(params.archivePath, stableArchivePath); + + return { + stableArchivePath, + dependencySpec: `file:./${path.posix.join(MANAGED_NPM_PACK_ARCHIVE_DIR, archiveFileName)}`, + }; +} + type PackageInstallCommonParams = InstallSafetyOverrides & { extensionsDir?: string; npmDir?: string; @@ -1322,159 +1554,122 @@ export async function installPluginFromNpmSpec( return { ok: false, error: driftResult.error }; } - const npmRoot = params.npmDir ? resolveUserPath(params.npmDir) : resolveDefaultPluginNpmDir(); - const installRoot = path.join(npmRoot, "node_modules", parsedSpec.name); - const effectiveMode = await resolveEffectiveInstallMode({ - runtime, - requestedMode: mode, - targetPath: installRoot, - }); - const availability = await ensureInstallTargetAvailableForMode({ - runtime, - targetPath: installRoot, - mode: effectiveMode, - }); - if (!availability.ok) { - return availability; - } - if (dryRun) { - return { - ok: true, - pluginId: expectedPluginId ?? parsedSpec.name, - targetDir: installRoot, - extensions: [], - npmResolution, - ...(driftResult.integrityDrift ? { integrityDrift: driftResult.integrityDrift } : {}), - }; - } - - logger.info?.(`Installing ${spec} into ${npmRoot}…`); - if (parsedSpec.name !== "openclaw") { - const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({ - npmRoot, - timeoutMs, - logger, - }); - if (repairedOpenClawPeer) { - logger.info?.(`Repaired stale openclaw peer dependency in ${npmRoot}`); - } - } - await upsertManagedNpmRootDependency({ - npmRoot, + return await installPluginFromManagedNpmRoot({ + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, packageName: parsedSpec.name, dependencySpec: resolveManagedNpmRootDependencySpec({ parsedSpec, resolution: npmResolution, }), - }); - const install = await runCommandWithTimeout( - [ - "npm", - ...createSafeNpmInstallArgs({ - omitDev: true, - loglevel: "error", - noAudit: true, - noFund: true, - }), - "--prefix", - ".", - ], - { - cwd: npmRoot, - timeoutMs: Math.max(timeoutMs, 300_000), - env: createSafeNpmInstallEnv(process.env, { packageLock: true, quiet: true }), - }, - ); - if (install.code !== 0) { - await rollbackManagedNpmPluginInstall({ - npmRoot, - packageName: parsedSpec.name, - targetDir: installRoot, - timeoutMs, - logger, - }); - return { - ok: false, - error: `npm install failed: ${install.stderr.trim() || install.stdout.trim()}`, - }; - } - if (parsedSpec.name !== "openclaw") { - const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({ - npmRoot, - timeoutMs, - logger, - }); - if (repairedOpenClawPeer) { - logger.info?.(`Repaired stale openclaw peer dependency in ${npmRoot} after npm install`); - } - } - await relinkOpenClawPeerDependenciesInManagedNpmRoot({ npmRoot, logger }); - - let installedDependency: ManagedNpmRootInstalledDependency | null; - try { - installedDependency = await readManagedNpmRootInstalledDependency({ - npmRoot, - packageName: parsedSpec.name, - }); - } catch (error) { - await rollbackManagedNpmPluginInstall({ - npmRoot, - packageName: parsedSpec.name, - targetDir: installRoot, - timeoutMs, - logger, - }); - return { - ok: false, - error: `Failed to verify npm install metadata for ${parsedSpec.name}: ${String(error)}`, - }; - } - const resolutionMismatch = resolveInstalledNpmResolutionMismatch({ - packageName: parsedSpec.name, - expected: npmResolution, - installed: installedDependency, - }); - if (resolutionMismatch) { - await rollbackManagedNpmPluginInstall({ - npmRoot, - packageName: parsedSpec.name, - targetDir: installRoot, - timeoutMs, - logger, - }); - return { - ok: false, - error: resolutionMismatch, - }; - } - - const result = await installPluginFromInstalledPackageDir({ - dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, - packageDir: installRoot, - dependencyScanRootDir: npmRoot, - logger, - expectedPluginId, - trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, - mode: effectiveMode, + displaySpec: spec, installPolicyRequest: { kind: "plugin-npm", requestedSpecifier: spec, }, - }); - if (!result.ok) { - await rollbackManagedNpmPluginInstall({ - npmRoot, - packageName: parsedSpec.name, - targetDir: installRoot, - timeoutMs, - logger, - }); - return result; - } - return { - ...result, + extensionsDir: params.extensionsDir, + npmDir: params.npmDir, + timeoutMs, + logger, + mode, + dryRun, + expectedPluginId, npmResolution, ...(driftResult.integrityDrift ? { integrityDrift: driftResult.integrityDrift } : {}), + }); +} + +export async function installPluginFromNpmPackArchive( + params: InstallSafetyOverrides & { + archivePath: string; + extensionsDir?: string; + npmDir?: string; + timeoutMs?: number; + logger?: PluginInstallLogger; + mode?: "install" | "update"; + dryRun?: boolean; + expectedPluginId?: string; + expectedIntegrity?: string; + onIntegrityDrift?: (params: PluginNpmIntegrityDriftParams) => boolean | Promise; + }, +): Promise { + const runtime = await loadPluginInstallRuntime(); + const { logger, timeoutMs, mode, dryRun } = runtime.resolveTimedInstallModeOptions( + params, + defaultLogger, + ); + const metadataResult = await resolveNpmPackArchiveMetadata({ + archivePath: params.archivePath, + timeoutMs, + }); + if (!metadataResult.ok) { + return metadataResult; + } + const npmResolution: NpmSpecResolution = { + ...metadataResult.metadata, + resolvedAt: new Date().toISOString(), + }; + const driftResult = await resolveNpmIntegrityDriftWithDefaultMessage({ + spec: metadataResult.archivePath, + expectedIntegrity: params.expectedIntegrity, + resolution: npmResolution, + onIntegrityDrift: params.onIntegrityDrift, + warn: (message) => logger.warn?.(message), + }); + if (driftResult.error) { + return { ok: false, error: driftResult.error }; + } + const packageNameResult = resolveTrustedNpmPackPackageName(metadataResult.metadata.name); + if (!packageNameResult.ok) { + return packageNameResult; + } + const packageName = packageNameResult.packageName; + const npmRoot = params.npmDir ? resolveUserPath(params.npmDir) : resolveDefaultPluginNpmDir(); + let dependencySpec = ""; + if (!dryRun) { + try { + dependencySpec = ( + await stageNpmPackArchiveInManagedRoot({ + archivePath: metadataResult.archivePath, + npmRoot, + packageName, + version: metadataResult.metadata.version, + integrity: metadataResult.metadata.integrity, + shasum: metadataResult.metadata.shasum, + tarballName: metadataResult.tarballName, + }) + ).dependencySpec; + } catch (error) { + return { + ok: false, + error: `Failed to stage npm pack archive in managed npm root: ${String(error)}`, + }; + } + } + + const result = await installPluginFromManagedNpmRoot({ + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, + packageName, + dependencySpec, + displaySpec: metadataResult.archivePath, + installPolicyRequest: { + kind: "plugin-npm", + requestedSpecifier: `npm-pack:${metadataResult.archivePath}`, + }, + extensionsDir: params.extensionsDir, + npmDir: npmRoot, + timeoutMs, + logger, + mode, + dryRun, + expectedPluginId: params.expectedPluginId, + npmResolution, + ...(driftResult.integrityDrift ? { integrityDrift: driftResult.integrityDrift } : {}), + }); + return { + ...result, + ...(result.ok ? { npmTarballName: metadataResult.tarballName } : {}), }; }