diff --git a/nanobot/extensions/__init__.py b/nanobot/extensions/__init__.py index 0f23a9309..8fae39e13 100644 --- a/nanobot/extensions/__init__.py +++ b/nanobot/extensions/__init__.py @@ -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", diff --git a/nanobot/extensions/catalog.py b/nanobot/extensions/catalog.py index ada7f0551..493b97efa 100644 --- a/nanobot/extensions/catalog.py +++ b/nanobot/extensions/catalog.py @@ -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(), diff --git a/nanobot/extensions/codec.py b/nanobot/extensions/codec.py index 97a84f2d5..9b5cea11a 100644 --- a/nanobot/extensions/codec.py +++ b/nanobot/extensions/codec.py @@ -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, diff --git a/nanobot/extensions/manifest.py b/nanobot/extensions/manifest.py index d13b711d7..6e81c7faf 100644 --- a/nanobot/extensions/manifest.py +++ b/nanobot/extensions/manifest.py @@ -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(): diff --git a/nanobot/extensions/node_host.py b/nanobot/extensions/node_host.py index ff09a150b..783ac57bc 100644 --- a/nanobot/extensions/node_host.py +++ b/nanobot/extensions/node_host.py @@ -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, diff --git a/nanobot/extensions/node_sidecar.mjs b/nanobot/extensions/node_sidecar.mjs index 348d66214..1066f7996 100644 --- a/nanobot/extensions/node_sidecar.mjs +++ b/nanobot/extensions/node_sidecar.mjs @@ -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, diff --git a/nanobot/extensions/package_adapter.py b/nanobot/extensions/package_adapter.py new file mode 100644 index 000000000..5dca0a556 --- /dev/null +++ b/nanobot/extensions/package_adapter.py @@ -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 "" diff --git a/nanobot/extensions/runtime.py b/nanobot/extensions/runtime.py index 0870f430d..3aa3afb14 100644 --- a/nanobot/extensions/runtime.py +++ b/nanobot/extensions/runtime.py @@ -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: diff --git a/nanobot/extensions/store.py b/nanobot/extensions/store.py new file mode 100644 index 000000000..f46c812e9 --- /dev/null +++ b/nanobot/extensions/store.py @@ -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) diff --git a/tests/extensions/test_node_compatibility.py b/tests/extensions/test_node_compatibility.py index e0a734de0..50e6f0561 100644 --- a/tests/extensions/test_node_compatibility.py +++ b/tests/extensions/test_node_compatibility.py @@ -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() diff --git a/tests/extensions/test_package_adapter.py b/tests/extensions/test_package_adapter.py new file mode 100644 index 000000000..e78a95414 --- /dev/null +++ b/tests/extensions/test_package_adapter.py @@ -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] diff --git a/tests/extensions/test_store.py b/tests/extensions/test_store.py new file mode 100644 index 000000000..7023c0734 --- /dev/null +++ b/tests/extensions/test_store.py @@ -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"