diff --git a/CHANGELOG.md b/CHANGELOG.md index 27cf547be0a..5866889223c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - CLI/update: honor `OPENCLAW_NO_AUTO_UPDATE=1` as a gateway startup kill-switch for configured background package auto-updates, so operators can hold a deliberate downgrade during incident recovery without editing config first. Fixes #72715. Thanks @Xivi08. - Agents/Claude CLI: force live-session launches to include `--output-format stream-json` whenever OpenClaw adds `--input-format stream-json`, so new Claude CLI sessions no longer fail immediately while reusable sessions keep working. Fixes #72206. Thanks @kwangwonkoh and @Xivi08. - CLI/plugins: accept ClawHub plugin API wildcard ranges such as `*` without rejecting compatible plugin installs, while still requiring a valid runtime API version. Fixes #56446; supersedes #56466. Thanks @darconada and @claygeo. +- CLI/plugins: add an explicit `npm:` install prefix that skips ClawHub lookup for known npm packages while keeping bare package specs ClawHub-first. Fixes #55805; supersedes #54377. Thanks @Zeoy2020 and @vagusX. - CLI/plugins: let config-gated bundled plugins install without persisting invalid placeholder config entries, so install/uninstall sweeps can cover plugins such as memory-lancedb before the user configures credentials. Thanks @vincentkoc. - CLI/plugins: reject malformed ClawHub plugin specs with trailing `@` before registry lookup, so empty-version typos report as invalid specs instead of package-not-found errors. Fixes #56579; supersedes #56582. Thanks @Kansodata. - Agents/sessions: acquire the session write lock only after cold bootstrap, plugin, and tool setup so fallback runs are not blocked by stalled pre-model startup work. Thanks @codex. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index d6365037d36..7b1bbdb909e 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -61,6 +61,7 @@ Native OpenClaw plugins must ship `openclaw.plugin.json` with an inline JSON Sch ```bash openclaw plugins install # ClawHub first, then npm openclaw plugins install clawhub: # ClawHub only +openclaw plugins install npm: # npm only openclaw plugins install --force # overwrite existing install openclaw plugins install --pin # pin version openclaw plugins install --dangerously-force-unsafe-install @@ -101,6 +102,8 @@ Bare package names are checked against ClawHub first, then npm. Treat plugin ins 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 project-local with `--ignore-scripts` for safety, even when your shell has global npm install settings. + Use `npm:` when you want to skip ClawHub lookup and install directly from npm. Bare package specs still prefer ClawHub and only fall back to npm when ClawHub does not have that package or version. + 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 name, use an explicit scoped spec (for example `@scope/diffs`). @@ -127,6 +130,13 @@ OpenClaw now also prefers ClawHub for bare npm-safe plugin specs. It only falls openclaw plugins install openclaw-codex-app-server ``` +Use `npm:` to force npm-only resolution, for example when ClawHub is unreachable or you know the package exists only on npm: + +```bash +openclaw plugins install npm:openclaw-codex-app-server +openclaw plugins install npm:@scope/plugin-name@1.0.1 +``` + OpenClaw downloads the package archive from ClawHub, checks the advertised plugin API / minimum gateway compatibility, then installs it through the normal archive path. Recorded installs keep their ClawHub source metadata for later updates. #### Marketplace shorthand diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index f3ac362fd12..7774a1db9ca 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -54,7 +54,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:`, or bare package spec (ClawHub first, then npm fallback). +`clawhub:`, explicit `npm:`, or bare package spec (ClawHub first, then +npm fallback). If config is invalid, install normally fails closed and points you at `openclaw doctor --fix`. The only recovery exception is a narrow bundled-plugin @@ -343,6 +344,7 @@ openclaw doctor --fix # repair plugin registry state openclaw plugins install # install (ClawHub first, then npm) openclaw plugins install clawhub: # install from ClawHub only +openclaw plugins install npm: # install from npm only openclaw plugins install --force # overwrite existing install openclaw plugins install # install from local path openclaw plugins install -l # link (no copy) for dev diff --git a/src/cli/plugin-install-config-policy.ts b/src/cli/plugin-install-config-policy.ts index 4e7034da8f3..6066a5b20b1 100644 --- a/src/cli/plugin-install-config-policy.ts +++ b/src/cli/plugin-install-config-policy.ts @@ -4,7 +4,7 @@ import type { Command } from "commander"; import { findBundledPluginSource } from "../plugins/bundled-sources.js"; import { loadPluginManifest } from "../plugins/manifest.js"; import { resolveUserPath } from "../utils.js"; -import { resolveFileNpmSpecToLocalPath } from "./plugins-command-helpers.js"; +import { parseNpmPrefixSpec, resolveFileNpmSpecToLocalPath } from "./plugins-command-helpers.js"; export type PluginInstallInvalidConfigPolicy = "deny" | "allow-bundled-recovery"; @@ -73,7 +73,14 @@ function resolveBundledInstallRecoveryMetadata( return direct; } } - for (const value of [request.rawSpec.trim(), request.normalizedSpec.trim()]) { + const rawNpmPrefixSpec = parseNpmPrefixSpec(request.rawSpec); + const normalizedNpmPrefixSpec = parseNpmPrefixSpec(request.normalizedSpec); + for (const value of [ + request.rawSpec.trim(), + request.normalizedSpec.trim(), + rawNpmPrefixSpec ?? "", + normalizedNpmPrefixSpec ?? "", + ]) { if (!value) { continue; } diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 5b9a917f77f..3b8939dd74a 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -549,6 +549,120 @@ describe("plugins cli install", () => { ); }); + it("installs directly from npm when npm: prefix is used", async () => { + const cfg = createEmptyPluginConfig(); + const enabledCfg = createEnabledPluginConfig("demo"); + + loadConfig.mockReturnValue(cfg); + installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("demo")); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(enabledCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "npm:demo"]); + + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "demo", + mode: "install", + }), + ); + expect(installPluginFromClawHub).not.toHaveBeenCalled(); + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({ + demo: expect.objectContaining({ + source: "npm", + spec: "demo", + installPath: cliInstallPath("demo"), + }), + }); + expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg); + }); + + it("passes npm: prefix installs through npm options without ClawHub lookup", async () => { + const cfg = createEmptyPluginConfig(); + const enabledCfg = createEnabledPluginConfig("demo"); + + loadConfig.mockReturnValue(cfg); + installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("demo")); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(enabledCfg); + + await runPluginsCommand([ + "plugins", + "install", + "npm:demo", + "--force", + "--dangerously-force-unsafe-install", + ]); + + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "demo", + mode: "update", + dangerouslyForceUnsafeInstall: true, + }), + ); + expect(installPluginFromClawHub).not.toHaveBeenCalled(); + }); + + it("reports npm install failures without trying ClawHub when npm: prefix is used", async () => { + loadConfig.mockReturnValue({} as OpenClawConfig); + installPluginFromNpmSpec.mockResolvedValue({ + ok: false, + error: "npm install failed", + }); + installHooksFromNpmSpec.mockResolvedValue({ + ok: false, + error: "package.json missing openclaw.hooks", + }); + + await expect(runPluginsCommand(["plugins", "install", "npm:demo"])).rejects.toThrow( + "__exit__:1", + ); + + expect(installPluginFromClawHub).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain("npm install failed"); + }); + + it("does not resolve npm: prefixed bundled plugin ids through bundled installs", async () => { + loadConfig.mockReturnValue({ plugins: { load: { paths: [] } } } as OpenClawConfig); + installPluginFromNpmSpec.mockResolvedValue({ + ok: false, + error: "Package not found on npm: memory-lancedb.", + code: "npm_package_not_found", + }); + installHooksFromNpmSpec.mockResolvedValue({ + ok: false, + error: "package.json missing openclaw.hooks", + }); + + await expect(runPluginsCommand(["plugins", "install", "npm:memory-lancedb"])).rejects.toThrow( + "__exit__:1", + ); + + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "memory-lancedb", + }), + ); + expect(installPluginFromClawHub).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain("Package not found on npm: memory-lancedb."); + }); + + it("rejects empty npm: prefix installs before resolver lookup", async () => { + loadConfig.mockReturnValue({} as OpenClawConfig); + + await expect(runPluginsCommand(["plugins", "install", "npm:"])).rejects.toThrow("__exit__:1"); + + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(installPluginFromClawHub).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain("unsupported npm: spec: missing package"); + }); + it("passes dangerous force unsafe install to marketplace installs", async () => { await expect( runPluginsCommand([ diff --git a/src/cli/plugins-command-helpers.ts b/src/cli/plugins-command-helpers.ts index 55e7bfc4a11..e0ce101951a 100644 --- a/src/cli/plugins-command-helpers.ts +++ b/src/cli/plugins-command-helpers.ts @@ -130,6 +130,14 @@ export function buildPreferredClawHubSpec(raw: string): string | null { return `clawhub:${parsed.name}${parsed.selector ? `@${parsed.selector}` : ""}`; } +export function parseNpmPrefixSpec(raw: string): string | null { + const trimmed = raw.trim(); + if (!normalizeLowercaseStringOrEmpty(trimmed).startsWith("npm:")) { + return null; + } + return trimmed.slice("npm:".length).trim(); +} + export const PREFERRED_CLAWHUB_FALLBACK_DECISION = { FALLBACK_TO_NPM: "fallback_to_npm", STOP: "stop", diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index 6f748c090df..18749473608 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -40,6 +40,7 @@ import { createPluginInstallLogger, decidePreferredClawHubFallback, formatPluginInstallWithHookFallbackError, + parseNpmPrefixSpec, } from "./plugins-command-helpers.js"; import { persistHookPackInstall, persistPluginInstall } from "./plugins-install-persist.js"; import type { ConfigSnapshotForInstallPersist } from "./plugins-install-persist.js"; @@ -263,6 +264,74 @@ async function tryInstallHookPackFromNpmSpec(params: { return { ok: true }; } +async function tryInstallPluginOrHookPackFromNpmSpec(params: { + snapshot: ConfigSnapshotForInstallPersist; + installMode: "install" | "update"; + spec: string; + pin?: boolean; + safetyOverrides: InstallSafetyOverrides; + allowBundledFallback: boolean; +}): Promise<{ ok: true } | { ok: false }> { + const result = await installPluginFromNpmSpec({ + ...params.safetyOverrides, + mode: params.installMode, + spec: params.spec, + logger: createPluginInstallLogger(), + }); + if (!result.ok) { + if (isTerminalPluginInstallSecurityFailure(result.code)) { + defaultRuntime.error(result.error); + return { ok: false }; + } + if (params.allowBundledFallback) { + const bundledFallbackPlan = resolveBundledInstallPlanForNpmFailure({ + rawSpec: params.spec, + code: result.code, + findBundledSource: (lookup) => findBundledPluginSource({ lookup }), + }); + if (bundledFallbackPlan) { + await installBundledPluginSource({ + snapshot: params.snapshot, + rawSpec: params.spec, + bundledSource: bundledFallbackPlan.bundledSource, + warning: bundledFallbackPlan.warning, + }); + return { ok: true }; + } + } + const hookFallback = await tryInstallHookPackFromNpmSpec({ + snapshot: params.snapshot, + installMode: params.installMode, + spec: params.spec, + pin: params.pin, + }); + if (hookFallback.ok) { + return { ok: true }; + } + defaultRuntime.error( + formatPluginInstallWithHookFallbackError(result.error, hookFallback.error), + ); + return { ok: false }; + } + + clearPluginManifestRegistryCache(); + const installRecord = resolvePinnedNpmInstallRecordForCli( + params.spec, + Boolean(params.pin), + result.targetDir, + result.version, + result.npmResolution, + defaultRuntime.log, + theme.warn, + ); + await persistPluginInstall({ + snapshot: params.snapshot, + pluginId: result.pluginId, + install: installRecord, + }); + return { ok: true }; +} + function isTerminalPluginInstallSecurityFailure(code?: string): boolean { return ( code === PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED || @@ -534,6 +603,26 @@ export async function runPluginInstallCommand(params: { return defaultRuntime.exit(1); } + const npmPrefixSpec = parseNpmPrefixSpec(raw); + if (npmPrefixSpec !== null) { + if (!npmPrefixSpec) { + defaultRuntime.error("unsupported npm: spec: missing package"); + return defaultRuntime.exit(1); + } + const npmPrefixResult = await tryInstallPluginOrHookPackFromNpmSpec({ + snapshot, + installMode, + spec: npmPrefixSpec, + pin: opts.pin, + safetyOverrides, + allowBundledFallback: false, + }); + if (!npmPrefixResult.ok) { + return defaultRuntime.exit(1); + } + return; + } + if ( looksLikeLocalInstallSpec(raw, [ ".ts", @@ -637,60 +726,15 @@ export async function runPluginInstallCommand(params: { } } - const result = await installPluginFromNpmSpec({ - ...safetyOverrides, - mode: installMode, - spec: raw, - logger: createPluginInstallLogger(), - }); - if (!result.ok) { - if (isTerminalPluginInstallSecurityFailure(result.code)) { - defaultRuntime.error(result.error); - return defaultRuntime.exit(1); - } - const bundledFallbackPlan = resolveBundledInstallPlanForNpmFailure({ - rawSpec: raw, - code: result.code, - findBundledSource: (lookup) => findBundledPluginSource({ lookup }), - }); - if (!bundledFallbackPlan) { - const hookFallback = await tryInstallHookPackFromNpmSpec({ - snapshot, - installMode, - spec: raw, - pin: opts.pin, - }); - if (hookFallback.ok) { - return; - } - defaultRuntime.error( - formatPluginInstallWithHookFallbackError(result.error, hookFallback.error), - ); - return defaultRuntime.exit(1); - } - - await installBundledPluginSource({ - snapshot, - rawSpec: raw, - bundledSource: bundledFallbackPlan.bundledSource, - warning: bundledFallbackPlan.warning, - }); - return; - } - - clearPluginManifestRegistryCache(); - const installRecord = resolvePinnedNpmInstallRecordForCli( - raw, - Boolean(opts.pin), - result.targetDir, - result.version, - result.npmResolution, - defaultRuntime.log, - theme.warn, - ); - await persistPluginInstall({ + const npmResult = await tryInstallPluginOrHookPackFromNpmSpec({ snapshot, - pluginId: result.pluginId, - install: installRecord, + installMode, + spec: raw, + pin: opts.pin, + safetyOverrides, + allowBundledFallback: true, }); + if (!npmResult.ok) { + return defaultRuntime.exit(1); + } } diff --git a/src/cli/plugins-install-config.test.ts b/src/cli/plugins-install-config.test.ts index 8e1447d2c1f..fbae58b3c22 100644 --- a/src/cli/plugins-install-config.test.ts +++ b/src/cli/plugins-install-config.test.ts @@ -120,6 +120,37 @@ describe("loadConfigForInstall", () => { expect(result).toEqual({ config: snapshotCfg, baseHash: "abc" }); }); + it("allows npm:-prefixed bundled-plugin reinstall recovery", async () => { + const snapshotCfg = { + plugins: { installs: { matrix: { source: "path", installPath: "/gone" } } }, + } as unknown as OpenClawConfig; + readConfigFileSnapshotMock.mockResolvedValue( + makeSnapshot({ + parsed: { plugins: { installs: { matrix: {} } } }, + config: snapshotCfg, + issues: [ + { path: "channels.matrix", message: "unknown channel id: matrix" }, + { path: "plugins.load.paths", message: "plugin: plugin path not found: /gone" }, + ], + }), + ); + + const request = resolvePluginInstallRequestContext({ + rawSpec: "npm:@openclaw/matrix", + }); + if (!request.ok) { + throw new Error(request.error); + } + + expect(request.request).toMatchObject({ + bundledPluginId: "matrix", + allowInvalidConfigRecovery: true, + }); + const result = await loadConfigForInstall(request.request); + expect(collectChannelDoctorStaleConfigMutationsMock).toHaveBeenCalledWith(snapshotCfg); + expect(result).toEqual({ config: snapshotCfg, baseHash: "abc" }); + }); + it("allows explicit repo-checkout bundled-plugin reinstall recovery", async () => { const snapshotCfg = { plugins: {} } as OpenClawConfig; readConfigFileSnapshotMock.mockResolvedValue(