feat(extensions): install compatible packages atomically

This commit is contained in:
Xubin Ren 2026-07-26 16:56:02 +08:00
parent 0cd091ba93
commit 76d7a33b3b
12 changed files with 817 additions and 44 deletions

View File

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

View File

@ -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(),

View File

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

View File

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

View File

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

View File

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

View 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 ""

View File

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

View File

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

View 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]

View 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"