feat(plugins): support npm pack installs

This commit is contained in:
Peter Steinberger
2026-05-06 09:16:39 +01:00
parent 54e23b6d11
commit 2eaf8ad712
11 changed files with 711 additions and 147 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Plugins/install: add `npm-pack:<path.tgz>` 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.

View File

@@ -74,6 +74,7 @@ openclaw plugins search "calendar" # search ClawHub plugins
openclaw plugins install <package> # npm by default
openclaw plugins install clawhub:<package> # ClawHub only
openclaw plugins install npm:<package> # npm only
openclaw plugins install npm-pack:<path.tgz> # local npm pack through npm install semantics
openclaw plugins install git:github.com/<owner>/<repo> # git repo
openclaw plugins install git:github.com/<owner>/<repo>@<ref>
openclaw plugins install <package> --force # overwrite existing install
@@ -150,6 +151,12 @@ is available, then fall back to `latest`.
<Accordion title="Archives">
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:<path.tgz>` 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.
</Accordion>

View File

@@ -46,6 +46,13 @@ npm installs run in the npm root with:
npm install --prefix ~/.openclaw/npm <spec> --omit=dev --ignore-scripts --no-audit --no-fund
```
`openclaw plugins install npm-pack:<path.tgz>` 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

View File

@@ -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:<pkg>`, explicit `npm:<pkg>`, explicit `git:<repo>`, or bare package
spec through npm.
`clawhub:<pkg>`, explicit `npm:<pkg>`, explicit `npm-pack:<path.tgz>`,
explicit `git:<repo>`, 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

View File

@@ -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<ParseClawHubPluginSpecFn> = 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();

View File

@@ -16,6 +16,7 @@ import {
findBundledPluginSourceMock,
installHooksFromNpmSpec,
installHooksFromPath,
installPluginFromNpmPackArchive,
installPluginFromClawHub,
installPluginFromGitSpec,
installPluginFromMarketplace,
@@ -127,6 +128,28 @@ function createNpmPluginInstallResult(
};
}
function createNpmPackPluginInstallResult(
pluginId = "demo",
): Awaited<ReturnType<typeof installPluginFromNpmPackArchive>> {
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<ReturnType<typeof installPluginFromGitSpec>> {
@@ -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");

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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,
};
}

View File

@@ -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<string, string> };
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");

View File

@@ -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<InstallPluginResult> {
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<boolean>;
},
): Promise<InstallPluginResult & { npmTarballName?: string }> {
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 } : {}),
};
}