mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 16:38:49 +00:00
feat(extensions): install compatible packages atomically
This commit is contained in:
parent
0cd091ba93
commit
76d7a33b3b
@ -21,6 +21,7 @@ from nanobot.extensions.manifest import (
|
||||
)
|
||||
from nanobot.extensions.native import discover_native_extensions
|
||||
from nanobot.extensions.node_host import NodeSidecar
|
||||
from nanobot.extensions.package_adapter import AdaptedPackage, adapt_package
|
||||
from nanobot.extensions.protocol import (
|
||||
NODE_PROTOCOL_VERSION,
|
||||
NodeLoadResult,
|
||||
@ -42,6 +43,12 @@ from nanobot.extensions.runtime import (
|
||||
ExtensionRuntimeManager,
|
||||
PythonExtensionApi,
|
||||
)
|
||||
from nanobot.extensions.store import (
|
||||
ExtensionSourceKind,
|
||||
ExtensionStore,
|
||||
InstalledExtension,
|
||||
InstallResult,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EXTENSION_API_VERSION",
|
||||
@ -60,6 +67,8 @@ __all__ = [
|
||||
"ExtensionRuntimeManager",
|
||||
"ExtensionScope",
|
||||
"ExtensionSnapshot",
|
||||
"ExtensionSourceKind",
|
||||
"ExtensionStore",
|
||||
"MANIFEST_FILENAME",
|
||||
"ManifestFormatError",
|
||||
"NODE_PROTOCOL_VERSION",
|
||||
@ -68,10 +77,14 @@ __all__ = [
|
||||
"NodeRegistration",
|
||||
"NodeSidecar",
|
||||
"ActivatedExtension",
|
||||
"AdaptedPackage",
|
||||
"ActivationResult",
|
||||
"PythonExtensionApi",
|
||||
"InstalledExtension",
|
||||
"InstallResult",
|
||||
"ResolvedContribution",
|
||||
"build_extension_catalog",
|
||||
"adapt_package",
|
||||
"dump_manifest",
|
||||
"discover_native_extensions",
|
||||
"load_manifest",
|
||||
|
||||
@ -19,6 +19,7 @@ from nanobot.extensions.registry import (
|
||||
ExtensionScope,
|
||||
ExtensionSnapshot,
|
||||
)
|
||||
from nanobot.extensions.store import ExtensionStore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
@ -53,11 +54,10 @@ def build_extension_catalog(
|
||||
)
|
||||
discoveries = [native]
|
||||
if config.extensions.enabled:
|
||||
external_root = user_root or Path.home() / ".nanobot" / "extensions"
|
||||
discoveries.append(ExtensionStore(external_root).discover())
|
||||
discoveries.extend(
|
||||
_external_discoveries(
|
||||
config,
|
||||
user_root=user_root or Path.home() / ".nanobot" / "extensions",
|
||||
)
|
||||
_external_discoveries(config, user_root=external_root)
|
||||
)
|
||||
|
||||
candidates = tuple(
|
||||
@ -102,10 +102,6 @@ def _external_discoveries(
|
||||
*,
|
||||
user_root: Path,
|
||||
) -> Iterable[ExtensionDiscoveryResult]:
|
||||
yield discover_manifest_root(
|
||||
user_root,
|
||||
scope=ExtensionScope.USER,
|
||||
)
|
||||
for raw_path in config.extensions.paths:
|
||||
yield discover_manifest_root(
|
||||
Path(raw_path).expanduser(),
|
||||
|
||||
@ -27,6 +27,7 @@ _MANIFEST_KEYS = frozenset(
|
||||
"version",
|
||||
"runtime",
|
||||
"entry",
|
||||
"entries",
|
||||
"contributions",
|
||||
"description",
|
||||
"dependencies",
|
||||
@ -89,6 +90,7 @@ def manifest_from_mapping(data: object) -> ExtensionManifest:
|
||||
version=mapping["version"],
|
||||
runtime=ExtensionRuntime(mapping["runtime"]),
|
||||
entry=mapping.get("entry", ""),
|
||||
entries=tuple(_sequence(mapping.get("entries", ()), "entries")),
|
||||
contributions=contributions,
|
||||
description=mapping.get("description", ""),
|
||||
dependencies=dependencies,
|
||||
@ -110,6 +112,7 @@ def manifest_to_mapping(manifest: ExtensionManifest) -> dict[str, Any]:
|
||||
"apiVersion": manifest.api_version,
|
||||
"runtime": manifest.runtime.value,
|
||||
"entry": manifest.entry,
|
||||
"entries": list(manifest.entries),
|
||||
"description": manifest.description,
|
||||
"homepage": manifest.homepage,
|
||||
"license": manifest.license,
|
||||
|
||||
@ -118,6 +118,7 @@ class ExtensionManifest:
|
||||
version: str
|
||||
runtime: ExtensionRuntime
|
||||
entry: str = ""
|
||||
entries: tuple[str, ...] = ()
|
||||
contributions: tuple[ExtensionContribution, ...] = ()
|
||||
description: str = ""
|
||||
dependencies: tuple[ExtensionDependency, ...] = ()
|
||||
@ -134,10 +135,18 @@ class ExtensionManifest:
|
||||
raise TypeError("extension runtime must be an ExtensionRuntime")
|
||||
if not isinstance(self.entry, str):
|
||||
raise TypeError("extension entry must be a string")
|
||||
if self.entry and Path(self.entry).is_absolute():
|
||||
raise ValueError("extension entry must be relative to the package root")
|
||||
if self.entry and ".." in Path(self.entry).parts:
|
||||
raise ValueError("extension entry cannot escape the package root")
|
||||
if not isinstance(self.entries, tuple) or not all(
|
||||
isinstance(entry, str) and entry for entry in self.entries
|
||||
):
|
||||
raise TypeError("extension entries must be a tuple of non-empty strings")
|
||||
activation_entries = self.activation_entries
|
||||
if len(set(activation_entries)) != len(activation_entries):
|
||||
raise ValueError("extension entries contains duplicates")
|
||||
for entry in activation_entries:
|
||||
if Path(entry).is_absolute():
|
||||
raise ValueError("extension entry must be relative to the package root")
|
||||
if ".." in Path(entry).parts:
|
||||
raise ValueError("extension entry cannot escape the package root")
|
||||
if self.api_version != EXTENSION_API_VERSION:
|
||||
raise ValueError(
|
||||
f"unsupported extension API version {self.api_version}; "
|
||||
@ -176,6 +185,11 @@ class ExtensionManifest:
|
||||
if len(set(permission_names)) != len(permission_names):
|
||||
raise ValueError("extension manifest contains duplicate permissions")
|
||||
|
||||
@property
|
||||
def activation_entries(self) -> tuple[str, ...]:
|
||||
"""Return every runtime entry while preserving the v1 single-entry form."""
|
||||
return self.entries or ((self.entry,) if self.entry else ())
|
||||
|
||||
|
||||
def _require_text(value: object, label: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
|
||||
@ -69,7 +69,8 @@ class NodeSidecar:
|
||||
self,
|
||||
*,
|
||||
runtime: str,
|
||||
entry: Path,
|
||||
entries: tuple[Path, ...],
|
||||
root: Path,
|
||||
extension_id: str,
|
||||
name: str,
|
||||
version: str,
|
||||
@ -81,7 +82,10 @@ class NodeSidecar:
|
||||
"extension.load",
|
||||
{
|
||||
"runtime": runtime,
|
||||
"entry": str(entry.expanduser().resolve()),
|
||||
"entries": [
|
||||
str(entry.expanduser().resolve()) for entry in entries
|
||||
],
|
||||
"root": str(root.expanduser().resolve()),
|
||||
"identity": {
|
||||
"id": extension_id,
|
||||
"name": name,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import readline from "node:readline";
|
||||
|
||||
const PROTOCOL = 1;
|
||||
@ -175,7 +175,7 @@ function openClawApi() {
|
||||
id: identity.id,
|
||||
name: identity.name,
|
||||
version: identity.version,
|
||||
source: state.entry,
|
||||
source: state.entries[0],
|
||||
rootDir: state.rootDir,
|
||||
registrationMode: "activate",
|
||||
config: state.config,
|
||||
@ -267,25 +267,29 @@ async function loadExtension(params) {
|
||||
state.identity = params.identity;
|
||||
state.workspace = params.workspace;
|
||||
state.config = params.config || {};
|
||||
state.entry = params.entry;
|
||||
state.rootDir = fileURLToPath(new URL(".", pathToFileURL(params.entry)));
|
||||
state.entries = params.entries;
|
||||
state.rootDir = params.root;
|
||||
state.tools.clear();
|
||||
state.commands.clear();
|
||||
state.hooks.clear();
|
||||
state.registrations.length = 0;
|
||||
state.diagnostics.length = 0;
|
||||
|
||||
const loaded = unwrapModule(await importModule(params.entry));
|
||||
const factory =
|
||||
params.runtime === "openclaw" && typeof loaded?.register === "function"
|
||||
? loaded.register
|
||||
: loaded;
|
||||
if (typeof factory !== "function") throw new Error("extension entry does not export a factory");
|
||||
const result = factory(params.runtime === "pi" ? piApi() : openClawApi());
|
||||
if (params.runtime === "openclaw" && result?.then) {
|
||||
throw new Error("OpenClaw plugin register must be synchronous");
|
||||
for (const entry of params.entries) {
|
||||
const loaded = unwrapModule(await importModule(entry));
|
||||
const factory =
|
||||
params.runtime === "openclaw" && typeof loaded?.register === "function"
|
||||
? loaded.register
|
||||
: loaded;
|
||||
if (typeof factory !== "function") {
|
||||
throw new Error(`extension entry does not export a factory: ${entry}`);
|
||||
}
|
||||
const result = factory(params.runtime === "pi" ? piApi() : openClawApi());
|
||||
if (params.runtime === "openclaw" && result?.then) {
|
||||
throw new Error("OpenClaw plugin register must be synchronous");
|
||||
}
|
||||
if (params.runtime === "pi") await result;
|
||||
}
|
||||
if (params.runtime === "pi") await result;
|
||||
return {
|
||||
registrations: state.registrations,
|
||||
diagnostics: state.diagnostics,
|
||||
|
||||
181
nanobot/extensions/package_adapter.py
Normal file
181
nanobot/extensions/package_adapter.py
Normal file
@ -0,0 +1,181 @@
|
||||
"""Translate Pi and OpenClaw package metadata into the nanobot manifest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.extensions.codec import MANIFEST_FILENAME, load_manifest
|
||||
from nanobot.extensions.manifest import (
|
||||
ContributionKind,
|
||||
ExtensionContribution,
|
||||
ExtensionManifest,
|
||||
ExtensionRuntime,
|
||||
)
|
||||
|
||||
_ID_CHARS = re.compile(r"[^a-z0-9._-]+")
|
||||
_OPENCLAW_CONTRACT_KINDS = {
|
||||
"tools": ContributionKind.TOOL,
|
||||
"realtimeTranscriptionProviders": ContributionKind.TRANSCRIPTION_PROVIDER,
|
||||
"imageGenerationProviders": ContributionKind.IMAGE_GENERATION_PROVIDER,
|
||||
"webSearchProviders": ContributionKind.WEB_SEARCH_PROVIDER,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AdaptedPackage:
|
||||
"""Canonical metadata plus honest compatibility diagnostics."""
|
||||
|
||||
manifest: ExtensionManifest
|
||||
diagnostics: tuple[str, ...] = ()
|
||||
generated: bool = False
|
||||
|
||||
|
||||
def adapt_package(root: Path) -> AdaptedPackage:
|
||||
"""Load native metadata or adapt one supported JavaScript package."""
|
||||
root = root.resolve()
|
||||
canonical = root / MANIFEST_FILENAME
|
||||
if canonical.is_file():
|
||||
return AdaptedPackage(load_manifest(canonical))
|
||||
|
||||
package = _read_json(root / "package.json", "package.json")
|
||||
if isinstance(package.get("pi"), dict):
|
||||
return _adapt_pi(package)
|
||||
if isinstance(package.get("openclaw"), dict):
|
||||
return _adapt_openclaw(root, package)
|
||||
raise ValueError(
|
||||
f"{root} is not a nanobot, Pi, or OpenClaw extension package"
|
||||
)
|
||||
|
||||
|
||||
def _adapt_pi(package: dict[str, Any]) -> AdaptedPackage:
|
||||
pi = package["pi"]
|
||||
entries = _string_list(pi.get("extensions"), "pi.extensions")
|
||||
name = str(package.get("name") or "pi-extension")
|
||||
return AdaptedPackage(
|
||||
ExtensionManifest(
|
||||
id=f"pi.{_identifier(name)}",
|
||||
name=str(package.get("displayName") or name),
|
||||
version=str(package.get("version") or "0.0.0"),
|
||||
runtime=ExtensionRuntime.PI,
|
||||
entries=tuple(entries),
|
||||
description=str(package.get("description") or ""),
|
||||
homepage=_homepage(package),
|
||||
license=str(package.get("license") or ""),
|
||||
),
|
||||
generated=True,
|
||||
)
|
||||
|
||||
|
||||
def _adapt_openclaw(root: Path, package: dict[str, Any]) -> AdaptedPackage:
|
||||
openclaw = package["openclaw"]
|
||||
entries = _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")
|
||||
contributions = _openclaw_contributions(plugin)
|
||||
diagnostics: list[str] = []
|
||||
contracts = plugin.get("contracts")
|
||||
if isinstance(contracts, dict):
|
||||
unsupported = sorted(
|
||||
key for key, value in contracts.items()
|
||||
if value and key not in _OPENCLAW_CONTRACT_KINDS
|
||||
)
|
||||
if unsupported:
|
||||
diagnostics.append(
|
||||
"OpenClaw capabilities retained as metadata but not executable: "
|
||||
+ ", ".join(unsupported)
|
||||
)
|
||||
return AdaptedPackage(
|
||||
ExtensionManifest(
|
||||
id=f"openclaw.{_identifier(plugin_id)}",
|
||||
name=str(plugin.get("name") or package.get("name") or plugin_id),
|
||||
version=str(package.get("version") or plugin.get("version") or "0.0.0"),
|
||||
runtime=ExtensionRuntime.OPENCLAW,
|
||||
entries=tuple(entries),
|
||||
contributions=contributions,
|
||||
description=str(
|
||||
plugin.get("description") or package.get("description") or ""
|
||||
),
|
||||
homepage=_homepage(package),
|
||||
license=str(package.get("license") or ""),
|
||||
),
|
||||
diagnostics=tuple(diagnostics),
|
||||
generated=True,
|
||||
)
|
||||
|
||||
|
||||
def _openclaw_contributions(
|
||||
plugin: dict[str, Any],
|
||||
) -> tuple[ExtensionContribution, ...]:
|
||||
rows: list[ExtensionContribution] = []
|
||||
direct = {
|
||||
"channels": ContributionKind.CHANNEL,
|
||||
"providers": ContributionKind.LLM_PROVIDER,
|
||||
"skills": ContributionKind.SKILL,
|
||||
}
|
||||
for field, kind in direct.items():
|
||||
for name in _optional_string_list(plugin.get(field), field):
|
||||
rows.append(ExtensionContribution(kind=kind, name=_identifier(name)))
|
||||
contracts = plugin.get("contracts")
|
||||
if isinstance(contracts, dict):
|
||||
for field, kind in _OPENCLAW_CONTRACT_KINDS.items():
|
||||
for name in _optional_string_list(contracts.get(field), field):
|
||||
rows.append(ExtensionContribution(kind=kind, name=_identifier(name)))
|
||||
for alias in plugin.get("commandAliases", []):
|
||||
if isinstance(alias, dict) and isinstance(alias.get("name"), str):
|
||||
rows.append(
|
||||
ExtensionContribution(
|
||||
kind=ContributionKind.COMMAND,
|
||||
name=_identifier(alias["name"]),
|
||||
)
|
||||
)
|
||||
return tuple(dict.fromkeys(rows))
|
||||
|
||||
|
||||
def _read_json(path: Path, label: str) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"cannot read {label}: {exc}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{label} must contain a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def _string_list(value: object, label: str) -> list[str]:
|
||||
rows = _optional_string_list(value, label)
|
||||
if not rows:
|
||||
raise ValueError(f"{label} must contain at least one entry")
|
||||
return rows
|
||||
|
||||
|
||||
def _optional_string_list(value: object, label: str) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list) or not all(
|
||||
isinstance(item, str) and item for item in value
|
||||
):
|
||||
raise ValueError(f"{label} must be an array of non-empty strings")
|
||||
return value
|
||||
|
||||
|
||||
def _identifier(value: str) -> str:
|
||||
normalized = value.lower().replace("@", "").replace("/", ".")
|
||||
normalized = _ID_CHARS.sub("-", normalized).strip(".-_")
|
||||
return normalized or "extension"
|
||||
|
||||
|
||||
def _homepage(package: dict[str, Any]) -> str:
|
||||
homepage = package.get("homepage")
|
||||
if isinstance(homepage, str):
|
||||
return homepage
|
||||
repository = package.get("repository")
|
||||
if isinstance(repository, str):
|
||||
return repository
|
||||
if isinstance(repository, dict) and isinstance(repository.get("url"), str):
|
||||
return repository["url"]
|
||||
return ""
|
||||
@ -127,9 +127,9 @@ class ExtensionRuntimeManager:
|
||||
runtime = candidate.manifest.runtime.value
|
||||
if runtime == "declarative":
|
||||
return ActivatedExtension(candidate)
|
||||
entry = _resolve_entry(candidate)
|
||||
entries = _resolve_entries(candidate)
|
||||
if runtime == "python":
|
||||
self._activate_python(candidate, entry)
|
||||
self._activate_python(candidate)
|
||||
return ActivatedExtension(candidate)
|
||||
if runtime not in {"pi", "openclaw"}:
|
||||
raise ValueError(f"unsupported extension runtime: {runtime}")
|
||||
@ -139,7 +139,8 @@ class ExtensionRuntimeManager:
|
||||
entry_config = self._config.extensions.entries.get(candidate.manifest.id)
|
||||
result = await host.load(
|
||||
runtime=runtime,
|
||||
entry=entry,
|
||||
entries=entries,
|
||||
root=candidate.location,
|
||||
extension_id=candidate.manifest.id,
|
||||
name=candidate.manifest.name,
|
||||
version=candidate.manifest.version,
|
||||
@ -178,7 +179,9 @@ class ExtensionRuntimeManager:
|
||||
await host.close()
|
||||
raise
|
||||
|
||||
def _activate_python(self, candidate: ExtensionCandidate, entry: Path) -> None:
|
||||
def _activate_python(self, candidate: ExtensionCandidate) -> None:
|
||||
if len(candidate.manifest.activation_entries) != 1:
|
||||
raise ValueError("Python extensions must declare exactly one entry")
|
||||
module_name, separator, attribute = candidate.manifest.entry.partition(":")
|
||||
if not separator:
|
||||
module_name = candidate.manifest.entry
|
||||
@ -232,19 +235,25 @@ class ExtensionRuntimeManager:
|
||||
await active.compatible.close()
|
||||
|
||||
|
||||
def _resolve_entry(candidate: ExtensionCandidate) -> Path:
|
||||
def _resolve_entries(candidate: ExtensionCandidate) -> tuple[Path, ...]:
|
||||
manifest = candidate.manifest
|
||||
location = candidate.location
|
||||
if location is None or not manifest.entry:
|
||||
raise ValueError(f"extension '{manifest.id}' does not declare an entry")
|
||||
entries = manifest.activation_entries
|
||||
if location is None or not entries:
|
||||
raise ValueError(f"extension '{manifest.id}' does not declare any entries")
|
||||
if manifest.runtime.value == "python":
|
||||
return location
|
||||
entry = (location / manifest.entry).resolve()
|
||||
if not entry.is_relative_to(location.resolve()):
|
||||
raise ValueError(f"extension '{manifest.id}' entry escapes its package")
|
||||
if not entry.is_file():
|
||||
raise ValueError(f"extension '{manifest.id}' entry does not exist: {entry}")
|
||||
return entry
|
||||
return (location,)
|
||||
resolved: list[Path] = []
|
||||
for raw_entry in entries:
|
||||
entry = (location / raw_entry).resolve()
|
||||
if not entry.is_relative_to(location.resolve()):
|
||||
raise ValueError(f"extension '{manifest.id}' entry escapes its package")
|
||||
if not entry.is_file():
|
||||
raise ValueError(
|
||||
f"extension '{manifest.id}' entry does not exist: {entry}"
|
||||
)
|
||||
resolved.append(entry)
|
||||
return tuple(resolved)
|
||||
|
||||
|
||||
def _constant_hook_factory(hook: AgentHook, owner: str) -> AgentTurnHookFactory:
|
||||
|
||||
352
nanobot/extensions/store.py
Normal file
352
nanobot/extensions/store.py
Normal file
@ -0,0 +1,352 @@
|
||||
"""Atomic installation store and trust state for external extensions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from nanobot.extensions.codec import MANIFEST_FILENAME, dump_manifest
|
||||
from nanobot.extensions.discovery import (
|
||||
ExtensionDiscoveryResult,
|
||||
discover_manifest_root,
|
||||
)
|
||||
from nanobot.extensions.package_adapter import AdaptedPackage, adapt_package
|
||||
from nanobot.extensions.registry import ExtensionScope
|
||||
|
||||
_REGISTRY_FILENAME = ".registry.json"
|
||||
|
||||
|
||||
class ExtensionSourceKind(str, Enum):
|
||||
LOCAL = "local"
|
||||
GIT = "git"
|
||||
NPM = "npm"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InstalledExtension:
|
||||
"""Persistent installation and policy record."""
|
||||
|
||||
id: str
|
||||
version: str
|
||||
source: ExtensionSourceKind
|
||||
source_ref: str
|
||||
integrity: str
|
||||
installed_at: str
|
||||
enabled: bool = True
|
||||
trusted: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: object) -> InstalledExtension:
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("extension registry record must be an object")
|
||||
return cls(
|
||||
id=str(value["id"]),
|
||||
version=str(value["version"]),
|
||||
source=ExtensionSourceKind(value["source"]),
|
||||
source_ref=str(value["source_ref"]),
|
||||
integrity=str(value["integrity"]),
|
||||
installed_at=str(value["installed_at"]),
|
||||
enabled=bool(value.get("enabled", True)),
|
||||
trusted=bool(value.get("trusted", False)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InstallResult:
|
||||
"""Installed package plus metadata-adapter notices."""
|
||||
|
||||
record: InstalledExtension
|
||||
package: AdaptedPackage
|
||||
|
||||
|
||||
class ExtensionStore:
|
||||
"""Own the user extension directory and its atomic registry."""
|
||||
|
||||
def __init__(self, root: Path | None = None) -> None:
|
||||
self.root = (root or Path.home() / ".nanobot" / "extensions").expanduser()
|
||||
self.registry_path = self.root / _REGISTRY_FILENAME
|
||||
|
||||
def records(self) -> dict[str, InstalledExtension]:
|
||||
if not self.registry_path.is_file():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(self.registry_path.read_text(encoding="utf-8"))
|
||||
rows = data.get("extensions", []) if isinstance(data, dict) else []
|
||||
return {
|
||||
record.id: record
|
||||
for item in rows
|
||||
if (record := InstalledExtension.from_mapping(item))
|
||||
}
|
||||
except (OSError, UnicodeError, json.JSONDecodeError, KeyError, ValueError):
|
||||
return {}
|
||||
|
||||
def discover(self) -> ExtensionDiscoveryResult:
|
||||
"""Discover packages and apply persisted enable/trust state."""
|
||||
result = discover_manifest_root(self.root, scope=ExtensionScope.USER)
|
||||
records = self.records()
|
||||
candidates = tuple(
|
||||
replace(
|
||||
candidate,
|
||||
enabled=records.get(candidate.manifest.id, _DEFAULT_RECORD).enabled,
|
||||
trusted=records.get(candidate.manifest.id, _DEFAULT_RECORD).trusted,
|
||||
)
|
||||
for candidate in result.candidates
|
||||
)
|
||||
return ExtensionDiscoveryResult(candidates, result.diagnostics)
|
||||
|
||||
def install_local(
|
||||
self,
|
||||
source: Path,
|
||||
*,
|
||||
trusted: bool = False,
|
||||
) -> InstallResult:
|
||||
return self._install_from_directory(
|
||||
source.resolve(),
|
||||
source_kind=ExtensionSourceKind.LOCAL,
|
||||
source_ref=str(source.resolve()),
|
||||
trusted=trusted,
|
||||
)
|
||||
|
||||
def install_git(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
ref: str = "",
|
||||
trusted: bool = False,
|
||||
) -> InstallResult:
|
||||
with tempfile.TemporaryDirectory(prefix="nanobot-extension-git-") as raw:
|
||||
checkout = Path(raw) / "checkout"
|
||||
command = ["git", "clone", "--depth", "1"]
|
||||
if ref:
|
||||
command.extend(["--branch", ref])
|
||||
command.extend(["--", url, str(checkout)])
|
||||
_run(command)
|
||||
return self._install_from_directory(
|
||||
checkout,
|
||||
source_kind=ExtensionSourceKind.GIT,
|
||||
source_ref=f"{url}#{ref}" if ref else url,
|
||||
trusted=trusted,
|
||||
)
|
||||
|
||||
def install_npm(
|
||||
self,
|
||||
spec: str,
|
||||
*,
|
||||
trusted: bool = False,
|
||||
) -> InstallResult:
|
||||
with tempfile.TemporaryDirectory(prefix="nanobot-extension-npm-") as raw:
|
||||
temp = Path(raw)
|
||||
output = _run(
|
||||
[
|
||||
"npm",
|
||||
"pack",
|
||||
"--ignore-scripts",
|
||||
"--json",
|
||||
"--pack-destination",
|
||||
str(temp),
|
||||
spec,
|
||||
]
|
||||
)
|
||||
rows = json.loads(output)
|
||||
if not isinstance(rows, list) or not rows:
|
||||
raise ValueError("npm pack did not return a package")
|
||||
archive = temp / rows[0]["filename"]
|
||||
checkout = temp / "checkout"
|
||||
checkout.mkdir()
|
||||
_extract_tar(archive, checkout)
|
||||
package_root = checkout / "package"
|
||||
return self._install_from_directory(
|
||||
package_root,
|
||||
source_kind=ExtensionSourceKind.NPM,
|
||||
source_ref=spec,
|
||||
trusted=trusted,
|
||||
)
|
||||
|
||||
def set_enabled(self, extension_id: str, enabled: bool) -> InstalledExtension:
|
||||
return self._update_record(extension_id, enabled=enabled)
|
||||
|
||||
def set_trusted(self, extension_id: str, trusted: bool) -> InstalledExtension:
|
||||
return self._update_record(extension_id, trusted=trusted)
|
||||
|
||||
def uninstall(self, extension_id: str) -> None:
|
||||
records = self.records()
|
||||
if extension_id not in records:
|
||||
raise KeyError(f"extension '{extension_id}' is not installed")
|
||||
target = self.root / extension_id
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
records.pop(extension_id)
|
||||
self._write_records(records)
|
||||
|
||||
def _install_from_directory(
|
||||
self,
|
||||
source: Path,
|
||||
*,
|
||||
source_kind: ExtensionSourceKind,
|
||||
source_ref: str,
|
||||
trusted: bool,
|
||||
) -> InstallResult:
|
||||
if not source.is_dir():
|
||||
raise ValueError(f"extension source is not a directory: {source}")
|
||||
_reject_unsafe_files(source)
|
||||
package = adapt_package(source)
|
||||
extension_id = package.manifest.id
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
staging = self.root / f".install-{uuid4().hex}"
|
||||
target = self.root / extension_id
|
||||
backup = self.root / f".backup-{uuid4().hex}"
|
||||
records = self.records()
|
||||
previous = records.get(extension_id)
|
||||
try:
|
||||
shutil.copytree(
|
||||
source,
|
||||
staging,
|
||||
ignore=shutil.ignore_patterns(".git", "__pycache__", "*.pyc"),
|
||||
)
|
||||
if package.generated:
|
||||
dump_manifest(package.manifest, staging / MANIFEST_FILENAME)
|
||||
_install_node_dependencies(staging)
|
||||
integrity = _tree_hash(staging)
|
||||
if target.exists():
|
||||
target.rename(backup)
|
||||
staging.rename(target)
|
||||
record = InstalledExtension(
|
||||
id=extension_id,
|
||||
version=package.manifest.version,
|
||||
source=source_kind,
|
||||
source_ref=source_ref,
|
||||
integrity=integrity,
|
||||
installed_at=datetime.now(UTC).isoformat(),
|
||||
enabled=previous.enabled if previous else True,
|
||||
trusted=trusted or bool(previous and previous.trusted),
|
||||
)
|
||||
records[extension_id] = record
|
||||
self._write_records(records)
|
||||
shutil.rmtree(backup, ignore_errors=True)
|
||||
return InstallResult(record, package)
|
||||
except Exception:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
if backup.exists():
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
backup.rename(target)
|
||||
raise
|
||||
|
||||
def _update_record(
|
||||
self,
|
||||
extension_id: str,
|
||||
**changes: Any,
|
||||
) -> InstalledExtension:
|
||||
records = self.records()
|
||||
try:
|
||||
record = replace(records[extension_id], **changes)
|
||||
except KeyError as exc:
|
||||
raise KeyError(f"extension '{extension_id}' is not installed") from exc
|
||||
records[extension_id] = record
|
||||
self._write_records(records)
|
||||
return record
|
||||
|
||||
def _write_records(self, records: dict[str, InstalledExtension]) -> None:
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"version": 1,
|
||||
"extensions": [
|
||||
{
|
||||
**asdict(record),
|
||||
"source": record.source.value,
|
||||
}
|
||||
for record in sorted(records.values(), key=lambda item: item.id)
|
||||
],
|
||||
}
|
||||
temp = self.registry_path.with_suffix(".tmp")
|
||||
temp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
os.replace(temp, self.registry_path)
|
||||
|
||||
|
||||
_DEFAULT_RECORD = InstalledExtension(
|
||||
id="",
|
||||
version="",
|
||||
source=ExtensionSourceKind.LOCAL,
|
||||
source_ref="",
|
||||
integrity="",
|
||||
installed_at="",
|
||||
enabled=True,
|
||||
trusted=False,
|
||||
)
|
||||
|
||||
|
||||
def _install_node_dependencies(root: Path) -> 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,
|
||||
)
|
||||
|
||||
|
||||
def _run(command: list[str], *, cwd: Path | None = None) -> str:
|
||||
try:
|
||||
return subprocess.run(
|
||||
command,
|
||||
cwd=cwd,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
except FileNotFoundError as exc:
|
||||
raise RuntimeError(f"required executable not found: {command[0]}") from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
detail = (exc.stderr or exc.stdout or "").strip()
|
||||
raise RuntimeError(f"{command[0]} failed: {detail}") from exc
|
||||
|
||||
|
||||
def _reject_unsafe_files(root: Path) -> None:
|
||||
for path in root.rglob("*"):
|
||||
if path.is_symlink():
|
||||
raise ValueError(f"extension packages cannot contain symlinks: {path}")
|
||||
if not path.is_file() and not path.is_dir():
|
||||
raise ValueError(f"extension package contains a special file: {path}")
|
||||
|
||||
|
||||
def _tree_hash(root: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
for path in sorted(item for item in root.rglob("*") if item.is_file()):
|
||||
digest.update(path.relative_to(root).as_posix().encode())
|
||||
digest.update(b"\0")
|
||||
with path.open("rb") as handle:
|
||||
while chunk := handle.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return f"sha256:{digest.hexdigest()}"
|
||||
|
||||
|
||||
def _extract_tar(archive: Path, target: Path) -> None:
|
||||
with tarfile.open(archive) as bundle:
|
||||
for member in bundle.getmembers():
|
||||
destination = (target / member.name).resolve()
|
||||
if not destination.is_relative_to(target.resolve()):
|
||||
raise ValueError("npm package archive contains a path traversal")
|
||||
if member.issym() or member.islnk():
|
||||
raise ValueError("npm package archive contains a link")
|
||||
bundle.extractall(target)
|
||||
@ -44,7 +44,8 @@ async def test_pi_extension_loads_tools_commands_and_events(tmp_path: Path) -> N
|
||||
try:
|
||||
result = await host.load(
|
||||
runtime="pi",
|
||||
entry=entry,
|
||||
entries=(entry,),
|
||||
root=tmp_path,
|
||||
extension_id="test.pi",
|
||||
name="Pi test",
|
||||
version="1.0.0",
|
||||
@ -101,7 +102,8 @@ async def test_openclaw_definition_loads_and_invokes_tool(tmp_path: Path) -> Non
|
||||
try:
|
||||
result = await host.load(
|
||||
runtime="openclaw",
|
||||
entry=entry,
|
||||
entries=(entry,),
|
||||
root=tmp_path,
|
||||
extension_id="test.openclaw",
|
||||
name="OpenClaw test",
|
||||
version="1.0.0",
|
||||
@ -121,3 +123,39 @@ async def test_openclaw_definition_loads_and_invokes_tool(tmp_path: Path) -> Non
|
||||
}
|
||||
finally:
|
||||
await host.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pi_package_loads_every_declared_entry(tmp_path: Path) -> None:
|
||||
first = _write(
|
||||
tmp_path / "first.mjs",
|
||||
"""
|
||||
export default function (pi) {
|
||||
pi.registerCommand("first", { handler: async () => "first" });
|
||||
}
|
||||
""",
|
||||
)
|
||||
second = _write(
|
||||
tmp_path / "second.mjs",
|
||||
"""
|
||||
export default function (pi) {
|
||||
pi.registerCommand("second", { handler: async () => "second" });
|
||||
}
|
||||
""",
|
||||
)
|
||||
host = NodeSidecar()
|
||||
try:
|
||||
result = await host.load(
|
||||
runtime="pi",
|
||||
entries=(first, second),
|
||||
root=tmp_path,
|
||||
extension_id="test.multi",
|
||||
name="Pi multi-entry test",
|
||||
version="1.0.0",
|
||||
workspace=tmp_path,
|
||||
)
|
||||
assert {
|
||||
item.name for item in result.registrations if item.kind == "command"
|
||||
} == {"first", "second"}
|
||||
finally:
|
||||
await host.close()
|
||||
|
||||
64
tests/extensions/test_package_adapter.py
Normal file
64
tests/extensions/test_package_adapter.py
Normal file
@ -0,0 +1,64 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.extensions import (
|
||||
ContributionKind,
|
||||
ExtensionRuntime,
|
||||
adapt_package,
|
||||
)
|
||||
|
||||
|
||||
def test_adapts_pi_package_metadata(tmp_path: Path) -> None:
|
||||
(tmp_path / "package.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"name": "@acme/pi-tools",
|
||||
"version": "1.2.3",
|
||||
"description": "Pi tools",
|
||||
"pi": {"extensions": ["./index.ts", "./review.ts"]},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = adapt_package(tmp_path)
|
||||
|
||||
assert result.generated
|
||||
assert result.manifest.id == "pi.acme.pi-tools"
|
||||
assert result.manifest.runtime is ExtensionRuntime.PI
|
||||
assert result.manifest.activation_entries == ("./index.ts", "./review.ts")
|
||||
|
||||
|
||||
def test_adapts_openclaw_contracts_without_loading_code(tmp_path: Path) -> None:
|
||||
(tmp_path / "package.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"name": "@openclaw/search-plugin",
|
||||
"version": "2.0.0",
|
||||
"openclaw": {"extensions": ["./index.ts"]},
|
||||
}
|
||||
)
|
||||
)
|
||||
(tmp_path / "openclaw.plugin.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"id": "search",
|
||||
"name": "Search",
|
||||
"contracts": {
|
||||
"tools": ["search_tool"],
|
||||
"webSearchProviders": ["private-search"],
|
||||
"speechProviders": ["speech"],
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = adapt_package(tmp_path)
|
||||
|
||||
assert result.manifest.id == "openclaw.search"
|
||||
assert {
|
||||
(item.kind, item.name) for item in result.manifest.contributions
|
||||
} == {
|
||||
(ContributionKind.TOOL, "search_tool"),
|
||||
(ContributionKind.WEB_SEARCH_PROVIDER, "private-search"),
|
||||
}
|
||||
assert "speechProviders" in result.diagnostics[0]
|
||||
95
tests/extensions/test_store.py
Normal file
95
tests/extensions/test_store.py
Normal file
@ -0,0 +1,95 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from nanobot.extensions import (
|
||||
ExtensionSourceKind,
|
||||
ExtensionStore,
|
||||
)
|
||||
|
||||
|
||||
def _pi_package(root: Path, *, version: str = "1.0.0") -> Path:
|
||||
root.mkdir()
|
||||
(root / "index.mjs").write_text("export default function () {}")
|
||||
(root / "package.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"name": "store-test",
|
||||
"version": version,
|
||||
"pi": {"extensions": ["./index.mjs"]},
|
||||
}
|
||||
)
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
def test_store_installs_and_applies_trust_state(tmp_path: Path) -> None:
|
||||
source = _pi_package(tmp_path / "source")
|
||||
store = ExtensionStore(tmp_path / "extensions")
|
||||
|
||||
installed = store.install_local(source)
|
||||
|
||||
assert installed.record.source is ExtensionSourceKind.LOCAL
|
||||
assert installed.record.integrity.startswith("sha256:")
|
||||
assert not store.discover().candidates[0].trusted
|
||||
|
||||
store.set_trusted(installed.record.id, True)
|
||||
store.set_enabled(installed.record.id, False)
|
||||
|
||||
candidate = store.discover().candidates[0]
|
||||
assert candidate.trusted
|
||||
assert not candidate.enabled
|
||||
|
||||
|
||||
def test_store_updates_and_uninstalls_atomically(tmp_path: Path) -> None:
|
||||
source = _pi_package(tmp_path / "source")
|
||||
store = ExtensionStore(tmp_path / "extensions")
|
||||
first = store.install_local(source, trusted=True)
|
||||
package_json = source / "package.json"
|
||||
payload = json.loads(package_json.read_text())
|
||||
payload["version"] = "2.0.0"
|
||||
package_json.write_text(json.dumps(payload))
|
||||
|
||||
second = store.install_local(source)
|
||||
|
||||
assert second.record.version == "2.0.0"
|
||||
assert second.record.trusted
|
||||
store.uninstall(first.record.id)
|
||||
assert store.records() == {}
|
||||
assert not (store.root / first.record.id).exists()
|
||||
|
||||
|
||||
def test_store_rejects_symlinked_package_content(tmp_path: Path) -> None:
|
||||
source = _pi_package(tmp_path / "source")
|
||||
(source / "outside").symlink_to(tmp_path)
|
||||
store = ExtensionStore(tmp_path / "extensions")
|
||||
|
||||
try:
|
||||
store.install_local(source)
|
||||
except ValueError as exc:
|
||||
assert "symlink" in str(exc)
|
||||
else:
|
||||
raise AssertionError("symlinked package was accepted")
|
||||
|
||||
|
||||
def test_store_restores_previous_package_when_registry_write_fails(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
source = _pi_package(tmp_path / "source")
|
||||
store = ExtensionStore(tmp_path / "extensions")
|
||||
first = store.install_local(source)
|
||||
installed_package = store.root / first.record.id / "package.json"
|
||||
payload = json.loads((source / "package.json").read_text())
|
||||
payload["version"] = "2.0.0"
|
||||
(source / "package.json").write_text(json.dumps(payload))
|
||||
|
||||
with patch.object(store, "_write_records", side_effect=OSError("disk full")):
|
||||
try:
|
||||
store.install_local(source)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("registry failure did not abort installation")
|
||||
|
||||
restored = json.loads(installed_package.read_text())
|
||||
assert restored["version"] == "1.0.0"
|
||||
Loading…
x
Reference in New Issue
Block a user