From 012c7ce034a063604c6d3dd6e0954e97da0fb7c0 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:04:29 +0800 Subject: [PATCH] feat(extensions): load published OpenClaw runtimes --- nanobot/extensions/package_adapter.py | 25 +++++++++++- nanobot/extensions/store.py | 52 +++++++++++++++++------- tests/extensions/test_package_adapter.py | 10 ++++- tests/extensions/test_store.py | 37 +++++++++++++++++ 4 files changed, 107 insertions(+), 17 deletions(-) diff --git a/nanobot/extensions/package_adapter.py b/nanobot/extensions/package_adapter.py index 5dca0a556..8760b9b6c 100644 --- a/nanobot/extensions/package_adapter.py +++ b/nanobot/extensions/package_adapter.py @@ -11,7 +11,9 @@ from typing import Any from nanobot.extensions.codec import MANIFEST_FILENAME, load_manifest from nanobot.extensions.manifest import ( ContributionKind, + DependencyKind, ExtensionContribution, + ExtensionDependency, ExtensionManifest, ExtensionRuntime, ) @@ -72,7 +74,10 @@ def _adapt_pi(package: dict[str, Any]) -> AdaptedPackage: def _adapt_openclaw(root: Path, package: dict[str, Any]) -> AdaptedPackage: openclaw = package["openclaw"] - entries = _string_list(openclaw.get("extensions"), "openclaw.extensions") + entries = _optional_string_list( + openclaw.get("runtimeExtensions"), + "openclaw.runtimeExtensions", + ) or _string_list(openclaw.get("extensions"), "openclaw.extensions") plugin_path = root / "openclaw.plugin.json" plugin = _read_json(plugin_path, "openclaw.plugin.json") if plugin_path.is_file() else {} plugin_id = str(plugin.get("id") or package.get("name") or "openclaw-plugin") @@ -97,6 +102,7 @@ def _adapt_openclaw(root: Path, package: dict[str, Any]) -> AdaptedPackage: runtime=ExtensionRuntime.OPENCLAW, entries=tuple(entries), contributions=contributions, + dependencies=_openclaw_dependencies(package, openclaw), description=str( plugin.get("description") or package.get("description") or "" ), @@ -108,6 +114,23 @@ def _adapt_openclaw(root: Path, package: dict[str, Any]) -> AdaptedPackage: ) +def _openclaw_dependencies( + package: dict[str, Any], + openclaw: dict[str, Any], +) -> tuple[ExtensionDependency, ...]: + build = openclaw.get("build") + version = build.get("openclawVersion") if isinstance(build, dict) else None + peers = package.get("peerDependencies") + peer_version = peers.get("openclaw") if isinstance(peers, dict) else None + return ( + ExtensionDependency( + kind=DependencyKind.NPM, + name="openclaw", + specifier=str(version or peer_version or "latest"), + ), + ) + + def _openclaw_contributions( plugin: dict[str, Any], ) -> tuple[ExtensionContribution, ...]: diff --git a/nanobot/extensions/store.py b/nanobot/extensions/store.py index f46c812e9..5331f9e13 100644 --- a/nanobot/extensions/store.py +++ b/nanobot/extensions/store.py @@ -21,6 +21,7 @@ from nanobot.extensions.discovery import ( ExtensionDiscoveryResult, discover_manifest_root, ) +from nanobot.extensions.manifest import DependencyKind, ExtensionManifest from nanobot.extensions.package_adapter import AdaptedPackage, adapt_package from nanobot.extensions.registry import ExtensionScope @@ -216,7 +217,7 @@ class ExtensionStore: ) if package.generated: dump_manifest(package.manifest, staging / MANIFEST_FILENAME) - _install_node_dependencies(staging) + _install_node_dependencies(staging, package.manifest) integrity = _tree_hash(staging) if target.exists(): target.rename(backup) @@ -285,25 +286,46 @@ _DEFAULT_RECORD = InstalledExtension( ) -def _install_node_dependencies(root: Path) -> None: +def _install_node_dependencies(root: Path, manifest: ExtensionManifest) -> None: package_path = root / "package.json" if not package_path.is_file(): return package = json.loads(package_path.read_text(encoding="utf-8")) dependencies = package.get("dependencies") if isinstance(package, dict) else None - if not dependencies: - return - _run( - [ - "npm", - "install", - "--omit=dev", - "--ignore-scripts", - "--no-audit", - "--no-fund", - ], - cwd=root, - ) + if dependencies: + _run( + [ + "npm", + "install", + "--omit=dev", + "--ignore-scripts", + "--no-audit", + "--no-fund", + ], + cwd=root, + ) + for dependency in manifest.dependencies: + if dependency.kind is not DependencyKind.NPM or dependency.optional: + continue + spec = ( + f"{dependency.name}@{dependency.specifier}" + if dependency.specifier + else dependency.name + ) + _run( + [ + "npm", + "install", + "--save-prod", + "--save-exact", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--", + spec, + ], + cwd=root, + ) def _run(command: list[str], *, cwd: Path | None = None) -> str: diff --git a/tests/extensions/test_package_adapter.py b/tests/extensions/test_package_adapter.py index e78a95414..441159622 100644 --- a/tests/extensions/test_package_adapter.py +++ b/tests/extensions/test_package_adapter.py @@ -3,6 +3,7 @@ from pathlib import Path from nanobot.extensions import ( ContributionKind, + DependencyKind, ExtensionRuntime, adapt_package, ) @@ -34,7 +35,11 @@ def test_adapts_openclaw_contracts_without_loading_code(tmp_path: Path) -> None: { "name": "@openclaw/search-plugin", "version": "2.0.0", - "openclaw": {"extensions": ["./index.ts"]}, + "peerDependencies": {"openclaw": ">=2.0.0"}, + "openclaw": { + "extensions": ["./index.ts"], + "runtimeExtensions": ["./dist/index.js"], + }, } ) ) @@ -55,6 +60,9 @@ def test_adapts_openclaw_contracts_without_loading_code(tmp_path: Path) -> None: result = adapt_package(tmp_path) assert result.manifest.id == "openclaw.search" + assert result.manifest.activation_entries == ("./dist/index.js",) + assert result.manifest.dependencies[0].kind is DependencyKind.NPM + assert result.manifest.dependencies[0].specifier == ">=2.0.0" assert { (item.kind, item.name) for item in result.manifest.contributions } == { diff --git a/tests/extensions/test_store.py b/tests/extensions/test_store.py index 7023c0734..7415b1978 100644 --- a/tests/extensions/test_store.py +++ b/tests/extensions/test_store.py @@ -3,8 +3,13 @@ from pathlib import Path from unittest.mock import patch from nanobot.extensions import ( + DependencyKind, + ExtensionDependency, + ExtensionManifest, + ExtensionRuntime, ExtensionSourceKind, ExtensionStore, + dump_manifest, ) @@ -93,3 +98,35 @@ def test_store_restores_previous_package_when_registry_write_fails( restored = json.loads(installed_package.read_text()) assert restored["version"] == "1.0.0" + + +def test_store_installs_declared_npm_runtime_dependency(tmp_path: Path) -> None: + source = tmp_path / "source" + source.mkdir() + (source / "index.mjs").write_text("export default function () {}") + (source / "package.json").write_text("{}") + dump_manifest( + ExtensionManifest( + id="openclaw.test", + name="OpenClaw test", + version="1.0.0", + runtime=ExtensionRuntime.OPENCLAW, + entry="./index.mjs", + dependencies=( + ExtensionDependency( + kind=DependencyKind.NPM, + name="openclaw", + specifier="2026.7.1", + ), + ), + ), + source / "nanobot.extension.json", + ) + store = ExtensionStore(tmp_path / "extensions") + + with patch("nanobot.extensions.store._run") as run: + store.install_local(source) + + command = run.call_args.args[0] + assert "--save-prod" in command + assert "openclaw@2026.7.1" in command