mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +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.native import discover_native_extensions
|
||||||
from nanobot.extensions.node_host import NodeSidecar
|
from nanobot.extensions.node_host import NodeSidecar
|
||||||
|
from nanobot.extensions.package_adapter import AdaptedPackage, adapt_package
|
||||||
from nanobot.extensions.protocol import (
|
from nanobot.extensions.protocol import (
|
||||||
NODE_PROTOCOL_VERSION,
|
NODE_PROTOCOL_VERSION,
|
||||||
NodeLoadResult,
|
NodeLoadResult,
|
||||||
@ -42,6 +43,12 @@ from nanobot.extensions.runtime import (
|
|||||||
ExtensionRuntimeManager,
|
ExtensionRuntimeManager,
|
||||||
PythonExtensionApi,
|
PythonExtensionApi,
|
||||||
)
|
)
|
||||||
|
from nanobot.extensions.store import (
|
||||||
|
ExtensionSourceKind,
|
||||||
|
ExtensionStore,
|
||||||
|
InstalledExtension,
|
||||||
|
InstallResult,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"EXTENSION_API_VERSION",
|
"EXTENSION_API_VERSION",
|
||||||
@ -60,6 +67,8 @@ __all__ = [
|
|||||||
"ExtensionRuntimeManager",
|
"ExtensionRuntimeManager",
|
||||||
"ExtensionScope",
|
"ExtensionScope",
|
||||||
"ExtensionSnapshot",
|
"ExtensionSnapshot",
|
||||||
|
"ExtensionSourceKind",
|
||||||
|
"ExtensionStore",
|
||||||
"MANIFEST_FILENAME",
|
"MANIFEST_FILENAME",
|
||||||
"ManifestFormatError",
|
"ManifestFormatError",
|
||||||
"NODE_PROTOCOL_VERSION",
|
"NODE_PROTOCOL_VERSION",
|
||||||
@ -68,10 +77,14 @@ __all__ = [
|
|||||||
"NodeRegistration",
|
"NodeRegistration",
|
||||||
"NodeSidecar",
|
"NodeSidecar",
|
||||||
"ActivatedExtension",
|
"ActivatedExtension",
|
||||||
|
"AdaptedPackage",
|
||||||
"ActivationResult",
|
"ActivationResult",
|
||||||
"PythonExtensionApi",
|
"PythonExtensionApi",
|
||||||
|
"InstalledExtension",
|
||||||
|
"InstallResult",
|
||||||
"ResolvedContribution",
|
"ResolvedContribution",
|
||||||
"build_extension_catalog",
|
"build_extension_catalog",
|
||||||
|
"adapt_package",
|
||||||
"dump_manifest",
|
"dump_manifest",
|
||||||
"discover_native_extensions",
|
"discover_native_extensions",
|
||||||
"load_manifest",
|
"load_manifest",
|
||||||
|
|||||||
@ -19,6 +19,7 @@ from nanobot.extensions.registry import (
|
|||||||
ExtensionScope,
|
ExtensionScope,
|
||||||
ExtensionSnapshot,
|
ExtensionSnapshot,
|
||||||
)
|
)
|
||||||
|
from nanobot.extensions.store import ExtensionStore
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.agent.skills import SkillsLoader
|
from nanobot.agent.skills import SkillsLoader
|
||||||
@ -53,11 +54,10 @@ def build_extension_catalog(
|
|||||||
)
|
)
|
||||||
discoveries = [native]
|
discoveries = [native]
|
||||||
if config.extensions.enabled:
|
if config.extensions.enabled:
|
||||||
|
external_root = user_root or Path.home() / ".nanobot" / "extensions"
|
||||||
|
discoveries.append(ExtensionStore(external_root).discover())
|
||||||
discoveries.extend(
|
discoveries.extend(
|
||||||
_external_discoveries(
|
_external_discoveries(config, user_root=external_root)
|
||||||
config,
|
|
||||||
user_root=user_root or Path.home() / ".nanobot" / "extensions",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
candidates = tuple(
|
candidates = tuple(
|
||||||
@ -102,10 +102,6 @@ def _external_discoveries(
|
|||||||
*,
|
*,
|
||||||
user_root: Path,
|
user_root: Path,
|
||||||
) -> Iterable[ExtensionDiscoveryResult]:
|
) -> Iterable[ExtensionDiscoveryResult]:
|
||||||
yield discover_manifest_root(
|
|
||||||
user_root,
|
|
||||||
scope=ExtensionScope.USER,
|
|
||||||
)
|
|
||||||
for raw_path in config.extensions.paths:
|
for raw_path in config.extensions.paths:
|
||||||
yield discover_manifest_root(
|
yield discover_manifest_root(
|
||||||
Path(raw_path).expanduser(),
|
Path(raw_path).expanduser(),
|
||||||
|
|||||||
@ -27,6 +27,7 @@ _MANIFEST_KEYS = frozenset(
|
|||||||
"version",
|
"version",
|
||||||
"runtime",
|
"runtime",
|
||||||
"entry",
|
"entry",
|
||||||
|
"entries",
|
||||||
"contributions",
|
"contributions",
|
||||||
"description",
|
"description",
|
||||||
"dependencies",
|
"dependencies",
|
||||||
@ -89,6 +90,7 @@ def manifest_from_mapping(data: object) -> ExtensionManifest:
|
|||||||
version=mapping["version"],
|
version=mapping["version"],
|
||||||
runtime=ExtensionRuntime(mapping["runtime"]),
|
runtime=ExtensionRuntime(mapping["runtime"]),
|
||||||
entry=mapping.get("entry", ""),
|
entry=mapping.get("entry", ""),
|
||||||
|
entries=tuple(_sequence(mapping.get("entries", ()), "entries")),
|
||||||
contributions=contributions,
|
contributions=contributions,
|
||||||
description=mapping.get("description", ""),
|
description=mapping.get("description", ""),
|
||||||
dependencies=dependencies,
|
dependencies=dependencies,
|
||||||
@ -110,6 +112,7 @@ def manifest_to_mapping(manifest: ExtensionManifest) -> dict[str, Any]:
|
|||||||
"apiVersion": manifest.api_version,
|
"apiVersion": manifest.api_version,
|
||||||
"runtime": manifest.runtime.value,
|
"runtime": manifest.runtime.value,
|
||||||
"entry": manifest.entry,
|
"entry": manifest.entry,
|
||||||
|
"entries": list(manifest.entries),
|
||||||
"description": manifest.description,
|
"description": manifest.description,
|
||||||
"homepage": manifest.homepage,
|
"homepage": manifest.homepage,
|
||||||
"license": manifest.license,
|
"license": manifest.license,
|
||||||
|
|||||||
@ -118,6 +118,7 @@ class ExtensionManifest:
|
|||||||
version: str
|
version: str
|
||||||
runtime: ExtensionRuntime
|
runtime: ExtensionRuntime
|
||||||
entry: str = ""
|
entry: str = ""
|
||||||
|
entries: tuple[str, ...] = ()
|
||||||
contributions: tuple[ExtensionContribution, ...] = ()
|
contributions: tuple[ExtensionContribution, ...] = ()
|
||||||
description: str = ""
|
description: str = ""
|
||||||
dependencies: tuple[ExtensionDependency, ...] = ()
|
dependencies: tuple[ExtensionDependency, ...] = ()
|
||||||
@ -134,10 +135,18 @@ class ExtensionManifest:
|
|||||||
raise TypeError("extension runtime must be an ExtensionRuntime")
|
raise TypeError("extension runtime must be an ExtensionRuntime")
|
||||||
if not isinstance(self.entry, str):
|
if not isinstance(self.entry, str):
|
||||||
raise TypeError("extension entry must be a string")
|
raise TypeError("extension entry must be a string")
|
||||||
if self.entry and Path(self.entry).is_absolute():
|
if not isinstance(self.entries, tuple) or not all(
|
||||||
raise ValueError("extension entry must be relative to the package root")
|
isinstance(entry, str) and entry for entry in self.entries
|
||||||
if self.entry and ".." in Path(self.entry).parts:
|
):
|
||||||
raise ValueError("extension entry cannot escape the package root")
|
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:
|
if self.api_version != EXTENSION_API_VERSION:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"unsupported extension API version {self.api_version}; "
|
f"unsupported extension API version {self.api_version}; "
|
||||||
@ -176,6 +185,11 @@ class ExtensionManifest:
|
|||||||
if len(set(permission_names)) != len(permission_names):
|
if len(set(permission_names)) != len(permission_names):
|
||||||
raise ValueError("extension manifest contains duplicate permissions")
|
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:
|
def _require_text(value: object, label: str) -> str:
|
||||||
if not isinstance(value, str) or not value.strip():
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
|||||||
@ -69,7 +69,8 @@ class NodeSidecar:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
runtime: str,
|
runtime: str,
|
||||||
entry: Path,
|
entries: tuple[Path, ...],
|
||||||
|
root: Path,
|
||||||
extension_id: str,
|
extension_id: str,
|
||||||
name: str,
|
name: str,
|
||||||
version: str,
|
version: str,
|
||||||
@ -81,7 +82,10 @@ class NodeSidecar:
|
|||||||
"extension.load",
|
"extension.load",
|
||||||
{
|
{
|
||||||
"runtime": runtime,
|
"runtime": runtime,
|
||||||
"entry": str(entry.expanduser().resolve()),
|
"entries": [
|
||||||
|
str(entry.expanduser().resolve()) for entry in entries
|
||||||
|
],
|
||||||
|
"root": str(root.expanduser().resolve()),
|
||||||
"identity": {
|
"identity": {
|
||||||
"id": extension_id,
|
"id": extension_id,
|
||||||
"name": name,
|
"name": name,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { AsyncLocalStorage } from "node:async_hooks";
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import readline from "node:readline";
|
import readline from "node:readline";
|
||||||
|
|
||||||
const PROTOCOL = 1;
|
const PROTOCOL = 1;
|
||||||
@ -175,7 +175,7 @@ function openClawApi() {
|
|||||||
id: identity.id,
|
id: identity.id,
|
||||||
name: identity.name,
|
name: identity.name,
|
||||||
version: identity.version,
|
version: identity.version,
|
||||||
source: state.entry,
|
source: state.entries[0],
|
||||||
rootDir: state.rootDir,
|
rootDir: state.rootDir,
|
||||||
registrationMode: "activate",
|
registrationMode: "activate",
|
||||||
config: state.config,
|
config: state.config,
|
||||||
@ -267,25 +267,29 @@ async function loadExtension(params) {
|
|||||||
state.identity = params.identity;
|
state.identity = params.identity;
|
||||||
state.workspace = params.workspace;
|
state.workspace = params.workspace;
|
||||||
state.config = params.config || {};
|
state.config = params.config || {};
|
||||||
state.entry = params.entry;
|
state.entries = params.entries;
|
||||||
state.rootDir = fileURLToPath(new URL(".", pathToFileURL(params.entry)));
|
state.rootDir = params.root;
|
||||||
state.tools.clear();
|
state.tools.clear();
|
||||||
state.commands.clear();
|
state.commands.clear();
|
||||||
state.hooks.clear();
|
state.hooks.clear();
|
||||||
state.registrations.length = 0;
|
state.registrations.length = 0;
|
||||||
state.diagnostics.length = 0;
|
state.diagnostics.length = 0;
|
||||||
|
|
||||||
const loaded = unwrapModule(await importModule(params.entry));
|
for (const entry of params.entries) {
|
||||||
const factory =
|
const loaded = unwrapModule(await importModule(entry));
|
||||||
params.runtime === "openclaw" && typeof loaded?.register === "function"
|
const factory =
|
||||||
? loaded.register
|
params.runtime === "openclaw" && typeof loaded?.register === "function"
|
||||||
: loaded;
|
? loaded.register
|
||||||
if (typeof factory !== "function") throw new Error("extension entry does not export a factory");
|
: loaded;
|
||||||
const result = factory(params.runtime === "pi" ? piApi() : openClawApi());
|
if (typeof factory !== "function") {
|
||||||
if (params.runtime === "openclaw" && result?.then) {
|
throw new Error(`extension entry does not export a factory: ${entry}`);
|
||||||
throw new Error("OpenClaw plugin register must be synchronous");
|
}
|
||||||
|
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 {
|
return {
|
||||||
registrations: state.registrations,
|
registrations: state.registrations,
|
||||||
diagnostics: state.diagnostics,
|
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
|
runtime = candidate.manifest.runtime.value
|
||||||
if runtime == "declarative":
|
if runtime == "declarative":
|
||||||
return ActivatedExtension(candidate)
|
return ActivatedExtension(candidate)
|
||||||
entry = _resolve_entry(candidate)
|
entries = _resolve_entries(candidate)
|
||||||
if runtime == "python":
|
if runtime == "python":
|
||||||
self._activate_python(candidate, entry)
|
self._activate_python(candidate)
|
||||||
return ActivatedExtension(candidate)
|
return ActivatedExtension(candidate)
|
||||||
if runtime not in {"pi", "openclaw"}:
|
if runtime not in {"pi", "openclaw"}:
|
||||||
raise ValueError(f"unsupported extension runtime: {runtime}")
|
raise ValueError(f"unsupported extension runtime: {runtime}")
|
||||||
@ -139,7 +139,8 @@ class ExtensionRuntimeManager:
|
|||||||
entry_config = self._config.extensions.entries.get(candidate.manifest.id)
|
entry_config = self._config.extensions.entries.get(candidate.manifest.id)
|
||||||
result = await host.load(
|
result = await host.load(
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
entry=entry,
|
entries=entries,
|
||||||
|
root=candidate.location,
|
||||||
extension_id=candidate.manifest.id,
|
extension_id=candidate.manifest.id,
|
||||||
name=candidate.manifest.name,
|
name=candidate.manifest.name,
|
||||||
version=candidate.manifest.version,
|
version=candidate.manifest.version,
|
||||||
@ -178,7 +179,9 @@ class ExtensionRuntimeManager:
|
|||||||
await host.close()
|
await host.close()
|
||||||
raise
|
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(":")
|
module_name, separator, attribute = candidate.manifest.entry.partition(":")
|
||||||
if not separator:
|
if not separator:
|
||||||
module_name = candidate.manifest.entry
|
module_name = candidate.manifest.entry
|
||||||
@ -232,19 +235,25 @@ class ExtensionRuntimeManager:
|
|||||||
await active.compatible.close()
|
await active.compatible.close()
|
||||||
|
|
||||||
|
|
||||||
def _resolve_entry(candidate: ExtensionCandidate) -> Path:
|
def _resolve_entries(candidate: ExtensionCandidate) -> tuple[Path, ...]:
|
||||||
manifest = candidate.manifest
|
manifest = candidate.manifest
|
||||||
location = candidate.location
|
location = candidate.location
|
||||||
if location is None or not manifest.entry:
|
entries = manifest.activation_entries
|
||||||
raise ValueError(f"extension '{manifest.id}' does not declare an entry")
|
if location is None or not entries:
|
||||||
|
raise ValueError(f"extension '{manifest.id}' does not declare any entries")
|
||||||
if manifest.runtime.value == "python":
|
if manifest.runtime.value == "python":
|
||||||
return location
|
return (location,)
|
||||||
entry = (location / manifest.entry).resolve()
|
resolved: list[Path] = []
|
||||||
if not entry.is_relative_to(location.resolve()):
|
for raw_entry in entries:
|
||||||
raise ValueError(f"extension '{manifest.id}' entry escapes its package")
|
entry = (location / raw_entry).resolve()
|
||||||
if not entry.is_file():
|
if not entry.is_relative_to(location.resolve()):
|
||||||
raise ValueError(f"extension '{manifest.id}' entry does not exist: {entry}")
|
raise ValueError(f"extension '{manifest.id}' entry escapes its package")
|
||||||
return entry
|
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:
|
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:
|
try:
|
||||||
result = await host.load(
|
result = await host.load(
|
||||||
runtime="pi",
|
runtime="pi",
|
||||||
entry=entry,
|
entries=(entry,),
|
||||||
|
root=tmp_path,
|
||||||
extension_id="test.pi",
|
extension_id="test.pi",
|
||||||
name="Pi test",
|
name="Pi test",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
@ -101,7 +102,8 @@ async def test_openclaw_definition_loads_and_invokes_tool(tmp_path: Path) -> Non
|
|||||||
try:
|
try:
|
||||||
result = await host.load(
|
result = await host.load(
|
||||||
runtime="openclaw",
|
runtime="openclaw",
|
||||||
entry=entry,
|
entries=(entry,),
|
||||||
|
root=tmp_path,
|
||||||
extension_id="test.openclaw",
|
extension_id="test.openclaw",
|
||||||
name="OpenClaw test",
|
name="OpenClaw test",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
@ -121,3 +123,39 @@ async def test_openclaw_definition_loads_and_invokes_tool(tmp_path: Path) -> Non
|
|||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
await host.close()
|
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