feat(extensions): load published OpenClaw runtimes

This commit is contained in:
Xubin Ren 2026-07-26 17:04:29 +08:00
parent 76d7a33b3b
commit 012c7ce034
4 changed files with 107 additions and 17 deletions

View File

@ -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, ...]:

View File

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

View File

@ -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
} == {

View File

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