mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +00:00
feat(extensions): enforce dependencies and permission grants
This commit is contained in:
parent
012c7ce034
commit
6e0950833a
@ -412,6 +412,7 @@ class ExtensionEntryConfig(Base):
|
|||||||
|
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
trusted: bool = False
|
trusted: bool = False
|
||||||
|
permissions: list[str] = Field(default_factory=list)
|
||||||
config: dict[str, Any] = Field(default_factory=dict)
|
config: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from nanobot.extensions.discovery import (
|
|||||||
discover_manifest_root,
|
discover_manifest_root,
|
||||||
)
|
)
|
||||||
from nanobot.extensions.native import discover_native_extensions
|
from nanobot.extensions.native import discover_native_extensions
|
||||||
|
from nanobot.extensions.preflight import evaluate_dependencies
|
||||||
from nanobot.extensions.registry import (
|
from nanobot.extensions.registry import (
|
||||||
ExtensionCandidate,
|
ExtensionCandidate,
|
||||||
ExtensionDiagnostic,
|
ExtensionDiagnostic,
|
||||||
@ -65,6 +66,7 @@ def build_extension_catalog(
|
|||||||
for result in discoveries
|
for result in discoveries
|
||||||
for candidate in result.candidates
|
for candidate in result.candidates
|
||||||
)
|
)
|
||||||
|
candidates, dependency_diagnostics = evaluate_dependencies(candidates)
|
||||||
discovery_diagnostics = tuple(
|
discovery_diagnostics = tuple(
|
||||||
diagnostic
|
diagnostic
|
||||||
for result in discoveries
|
for result in discoveries
|
||||||
@ -91,6 +93,7 @@ def build_extension_catalog(
|
|||||||
snapshot = registry.snapshot()
|
snapshot = registry.snapshot()
|
||||||
diagnostics = (
|
diagnostics = (
|
||||||
discovery_diagnostics
|
discovery_diagnostics
|
||||||
|
+ dependency_diagnostics
|
||||||
+ tuple(registry_diagnostics)
|
+ tuple(registry_diagnostics)
|
||||||
+ snapshot.diagnostics
|
+ snapshot.diagnostics
|
||||||
)
|
)
|
||||||
@ -129,4 +132,7 @@ def _apply_entry_config(
|
|||||||
candidate,
|
candidate,
|
||||||
enabled=entry.enabled,
|
enabled=entry.enabled,
|
||||||
trusted=candidate.trusted or entry.trusted,
|
trusted=candidate.trusted or entry.trusted,
|
||||||
|
granted_permissions=(
|
||||||
|
candidate.granted_permissions | frozenset(entry.permissions)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
120
nanobot/extensions/preflight.py
Normal file
120
nanobot/extensions/preflight.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"""Activation preflight for extension dependencies."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.metadata
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||||
|
from packaging.version import InvalidVersion, Version
|
||||||
|
|
||||||
|
from nanobot.extensions.manifest import DependencyKind, ExtensionDependency
|
||||||
|
from nanobot.extensions.registry import ExtensionCandidate, ExtensionDiagnostic
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_dependencies(
|
||||||
|
candidates: tuple[ExtensionCandidate, ...],
|
||||||
|
) -> tuple[tuple[ExtensionCandidate, ...], tuple[ExtensionDiagnostic, ...]]:
|
||||||
|
"""Disable candidates with missing hard dependencies and explain why."""
|
||||||
|
available = {
|
||||||
|
candidate.manifest.id: candidate.manifest.version
|
||||||
|
for candidate in candidates
|
||||||
|
}
|
||||||
|
checked: list[ExtensionCandidate] = []
|
||||||
|
diagnostics: list[ExtensionDiagnostic] = []
|
||||||
|
for candidate in candidates:
|
||||||
|
failures = [
|
||||||
|
message
|
||||||
|
for dependency in candidate.manifest.dependencies
|
||||||
|
if not dependency.optional
|
||||||
|
if (
|
||||||
|
message := _dependency_failure(
|
||||||
|
dependency,
|
||||||
|
location=candidate.location,
|
||||||
|
extensions=available,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if failures:
|
||||||
|
candidate = replace(candidate, enabled=False)
|
||||||
|
diagnostics.extend(
|
||||||
|
ExtensionDiagnostic(
|
||||||
|
code="dependency_missing",
|
||||||
|
extension_id=candidate.manifest.id,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
for message in failures
|
||||||
|
)
|
||||||
|
checked.append(candidate)
|
||||||
|
return tuple(checked), tuple(diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
def _dependency_failure(
|
||||||
|
dependency: ExtensionDependency,
|
||||||
|
*,
|
||||||
|
location: Path | None,
|
||||||
|
extensions: dict[str, str],
|
||||||
|
) -> str:
|
||||||
|
if dependency.kind is DependencyKind.EXECUTABLE:
|
||||||
|
if shutil.which(dependency.name) is None:
|
||||||
|
return f"Required executable is not installed: {dependency.name}"
|
||||||
|
return ""
|
||||||
|
if dependency.kind is DependencyKind.ENVIRONMENT:
|
||||||
|
if not os.getenv(dependency.name):
|
||||||
|
return f"Required environment variable is not set: {dependency.name}"
|
||||||
|
return ""
|
||||||
|
if dependency.kind is DependencyKind.PYTHON:
|
||||||
|
try:
|
||||||
|
version = importlib.metadata.version(dependency.name)
|
||||||
|
except importlib.metadata.PackageNotFoundError:
|
||||||
|
return f"Required Python package is not installed: {dependency.name}"
|
||||||
|
return _version_failure(dependency, version, "Python package")
|
||||||
|
if dependency.kind is DependencyKind.NPM:
|
||||||
|
version = _npm_version(location, dependency.name)
|
||||||
|
if version is None:
|
||||||
|
return f"Required npm package is not installed: {dependency.name}"
|
||||||
|
return _version_failure(dependency, version, "npm package")
|
||||||
|
if dependency.kind is DependencyKind.EXTENSION:
|
||||||
|
version = extensions.get(dependency.name)
|
||||||
|
if version is None:
|
||||||
|
return f"Required extension is not installed: {dependency.name}"
|
||||||
|
return _version_failure(dependency, version, "extension")
|
||||||
|
return f"Unsupported dependency kind: {dependency.kind.value}"
|
||||||
|
|
||||||
|
|
||||||
|
def _npm_version(location: Path | None, name: str) -> str | None:
|
||||||
|
if location is None:
|
||||||
|
return None
|
||||||
|
package = location / "node_modules" / Path(*name.split("/")) / "package.json"
|
||||||
|
try:
|
||||||
|
value = json.loads(package.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
version = value.get("version") if isinstance(value, dict) else None
|
||||||
|
return version if isinstance(version, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _version_failure(
|
||||||
|
dependency: ExtensionDependency,
|
||||||
|
version: str,
|
||||||
|
label: str,
|
||||||
|
) -> str:
|
||||||
|
if not dependency.specifier:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
matches = Version(version) in SpecifierSet(dependency.specifier)
|
||||||
|
except (InvalidSpecifier, InvalidVersion):
|
||||||
|
return (
|
||||||
|
f"{label} {dependency.name} has an unsupported version constraint: "
|
||||||
|
f"{dependency.specifier}"
|
||||||
|
)
|
||||||
|
if matches:
|
||||||
|
return ""
|
||||||
|
return (
|
||||||
|
f"{label} {dependency.name} {version} does not satisfy "
|
||||||
|
f"{dependency.specifier}"
|
||||||
|
)
|
||||||
@ -30,6 +30,7 @@ class ExtensionCandidate:
|
|||||||
location: Path | None = None
|
location: Path | None = None
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
trusted: bool = False
|
trusted: bool = False
|
||||||
|
granted_permissions: frozenset[str] = frozenset()
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if not isinstance(self.manifest, ExtensionManifest):
|
if not isinstance(self.manifest, ExtensionManifest):
|
||||||
@ -38,6 +39,8 @@ class ExtensionCandidate:
|
|||||||
raise TypeError("extension candidate scope must be an ExtensionScope")
|
raise TypeError("extension candidate scope must be an ExtensionScope")
|
||||||
if self.location is not None and not isinstance(self.location, Path):
|
if self.location is not None and not isinstance(self.location, Path):
|
||||||
raise TypeError("extension candidate location must be a Path or None")
|
raise TypeError("extension candidate location must be a Path or None")
|
||||||
|
if not isinstance(self.granted_permissions, frozenset):
|
||||||
|
raise TypeError("extension granted permissions must be a frozenset")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@ -60,9 +63,18 @@ class ExtensionPolicy:
|
|||||||
|
|
||||||
def permits(self, candidate: ExtensionCandidate) -> bool:
|
def permits(self, candidate: ExtensionCandidate) -> bool:
|
||||||
extension_id = candidate.manifest.id
|
extension_id = candidate.manifest.id
|
||||||
|
requested = {
|
||||||
|
permission.name for permission in candidate.manifest.permissions
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
candidate.enabled
|
candidate.enabled
|
||||||
and (candidate.scope is ExtensionScope.BUILTIN or candidate.trusted)
|
and (
|
||||||
|
candidate.scope is ExtensionScope.BUILTIN
|
||||||
|
or (
|
||||||
|
candidate.trusted
|
||||||
|
and requested <= candidate.granted_permissions
|
||||||
|
)
|
||||||
|
)
|
||||||
and extension_id not in self.deny
|
and extension_id not in self.deny
|
||||||
and (not self.allow or extension_id in self.allow)
|
and (not self.allow or extension_id in self.allow)
|
||||||
)
|
)
|
||||||
@ -123,8 +135,8 @@ class ExtensionRegistry:
|
|||||||
self._candidates[key] = candidate
|
self._candidates[key] = candidate
|
||||||
|
|
||||||
def snapshot(self) -> ExtensionSnapshot:
|
def snapshot(self) -> ExtensionSnapshot:
|
||||||
active = self._select_active_extensions()
|
active, selection_diagnostics = self._select_active_extensions()
|
||||||
resolved, diagnostics = self._resolve_contributions(active)
|
resolved, resolution_diagnostics = self._resolve_contributions(active)
|
||||||
return ExtensionSnapshot(
|
return ExtensionSnapshot(
|
||||||
extensions=tuple(sorted(active.values(), key=lambda item: item.manifest.id)),
|
extensions=tuple(sorted(active.values(), key=lambda item: item.manifest.id)),
|
||||||
contributions=tuple(
|
contributions=tuple(
|
||||||
@ -136,18 +148,43 @@ class ExtensionRegistry:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
diagnostics=tuple(diagnostics),
|
diagnostics=tuple(selection_diagnostics + resolution_diagnostics),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _select_active_extensions(self) -> dict[str, ExtensionCandidate]:
|
def _select_active_extensions(
|
||||||
|
self,
|
||||||
|
) -> tuple[dict[str, ExtensionCandidate], list[ExtensionDiagnostic]]:
|
||||||
active: dict[str, ExtensionCandidate] = {}
|
active: dict[str, ExtensionCandidate] = {}
|
||||||
|
diagnostics: list[ExtensionDiagnostic] = []
|
||||||
for candidate in sorted(
|
for candidate in sorted(
|
||||||
self._candidates.values(),
|
self._candidates.values(),
|
||||||
key=lambda item: (item.scope, item.manifest.id),
|
key=lambda item: (item.scope, item.manifest.id),
|
||||||
):
|
):
|
||||||
if self._policy.permits(candidate):
|
if self._policy.permits(candidate):
|
||||||
active[candidate.manifest.id] = candidate
|
active[candidate.manifest.id] = candidate
|
||||||
return active
|
continue
|
||||||
|
if (
|
||||||
|
candidate.enabled
|
||||||
|
and candidate.trusted
|
||||||
|
and candidate.scope is not ExtensionScope.BUILTIN
|
||||||
|
):
|
||||||
|
requested = {
|
||||||
|
permission.name
|
||||||
|
for permission in candidate.manifest.permissions
|
||||||
|
}
|
||||||
|
missing = sorted(requested - candidate.granted_permissions)
|
||||||
|
if missing:
|
||||||
|
diagnostics.append(
|
||||||
|
ExtensionDiagnostic(
|
||||||
|
code="permission_required",
|
||||||
|
extension_id=candidate.manifest.id,
|
||||||
|
message=(
|
||||||
|
"Grant required extension permissions: "
|
||||||
|
+ ", ".join(missing)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return active, diagnostics
|
||||||
|
|
||||||
def _resolve_contributions(
|
def _resolve_contributions(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@ -46,6 +46,7 @@ class InstalledExtension:
|
|||||||
installed_at: str
|
installed_at: str
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
trusted: bool = False
|
trusted: bool = False
|
||||||
|
granted_permissions: tuple[str, ...] = ()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_mapping(cls, value: object) -> InstalledExtension:
|
def from_mapping(cls, value: object) -> InstalledExtension:
|
||||||
@ -60,6 +61,7 @@ class InstalledExtension:
|
|||||||
installed_at=str(value["installed_at"]),
|
installed_at=str(value["installed_at"]),
|
||||||
enabled=bool(value.get("enabled", True)),
|
enabled=bool(value.get("enabled", True)),
|
||||||
trusted=bool(value.get("trusted", False)),
|
trusted=bool(value.get("trusted", False)),
|
||||||
|
granted_permissions=tuple(value.get("granted_permissions", ())),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -101,6 +103,12 @@ class ExtensionStore:
|
|||||||
candidate,
|
candidate,
|
||||||
enabled=records.get(candidate.manifest.id, _DEFAULT_RECORD).enabled,
|
enabled=records.get(candidate.manifest.id, _DEFAULT_RECORD).enabled,
|
||||||
trusted=records.get(candidate.manifest.id, _DEFAULT_RECORD).trusted,
|
trusted=records.get(candidate.manifest.id, _DEFAULT_RECORD).trusted,
|
||||||
|
granted_permissions=frozenset(
|
||||||
|
records.get(
|
||||||
|
candidate.manifest.id,
|
||||||
|
_DEFAULT_RECORD,
|
||||||
|
).granted_permissions
|
||||||
|
),
|
||||||
)
|
)
|
||||||
for candidate in result.candidates
|
for candidate in result.candidates
|
||||||
)
|
)
|
||||||
@ -180,6 +188,16 @@ class ExtensionStore:
|
|||||||
def set_trusted(self, extension_id: str, trusted: bool) -> InstalledExtension:
|
def set_trusted(self, extension_id: str, trusted: bool) -> InstalledExtension:
|
||||||
return self._update_record(extension_id, trusted=trusted)
|
return self._update_record(extension_id, trusted=trusted)
|
||||||
|
|
||||||
|
def set_permissions(
|
||||||
|
self,
|
||||||
|
extension_id: str,
|
||||||
|
permissions: set[str] | frozenset[str],
|
||||||
|
) -> InstalledExtension:
|
||||||
|
return self._update_record(
|
||||||
|
extension_id,
|
||||||
|
granted_permissions=tuple(sorted(permissions)),
|
||||||
|
)
|
||||||
|
|
||||||
def uninstall(self, extension_id: str) -> None:
|
def uninstall(self, extension_id: str) -> None:
|
||||||
records = self.records()
|
records = self.records()
|
||||||
if extension_id not in records:
|
if extension_id not in records:
|
||||||
@ -231,6 +249,9 @@ class ExtensionStore:
|
|||||||
installed_at=datetime.now(UTC).isoformat(),
|
installed_at=datetime.now(UTC).isoformat(),
|
||||||
enabled=previous.enabled if previous else True,
|
enabled=previous.enabled if previous else True,
|
||||||
trusted=trusted or bool(previous and previous.trusted),
|
trusted=trusted or bool(previous and previous.trusted),
|
||||||
|
granted_permissions=(
|
||||||
|
previous.granted_permissions if previous else ()
|
||||||
|
),
|
||||||
)
|
)
|
||||||
records[extension_id] = record
|
records[extension_id] = record
|
||||||
self._write_records(records)
|
self._write_records(records)
|
||||||
@ -283,6 +304,7 @@ _DEFAULT_RECORD = InstalledExtension(
|
|||||||
installed_at="",
|
installed_at="",
|
||||||
enabled=True,
|
enabled=True,
|
||||||
trusted=False,
|
trusted=False,
|
||||||
|
granted_permissions=(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
67
tests/extensions/test_preflight.py
Normal file
67
tests/extensions/test_preflight.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from nanobot.extensions import (
|
||||||
|
DependencyKind,
|
||||||
|
ExtensionCandidate,
|
||||||
|
ExtensionDependency,
|
||||||
|
ExtensionManifest,
|
||||||
|
ExtensionRuntime,
|
||||||
|
ExtensionScope,
|
||||||
|
)
|
||||||
|
from nanobot.extensions.preflight import evaluate_dependencies
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate(
|
||||||
|
dependency: ExtensionDependency,
|
||||||
|
*,
|
||||||
|
location: Path | None = None,
|
||||||
|
) -> ExtensionCandidate:
|
||||||
|
return ExtensionCandidate(
|
||||||
|
manifest=ExtensionManifest(
|
||||||
|
id="preflight.test",
|
||||||
|
name="Preflight test",
|
||||||
|
version="1.0.0",
|
||||||
|
runtime=ExtensionRuntime.DECLARATIVE,
|
||||||
|
dependencies=(dependency,),
|
||||||
|
),
|
||||||
|
scope=ExtensionScope.USER,
|
||||||
|
location=location,
|
||||||
|
trusted=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_environment_dependency_disables_extension(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("NANOBOT_EXTENSION_TEST_KEY", raising=False)
|
||||||
|
candidate = _candidate(
|
||||||
|
ExtensionDependency(
|
||||||
|
kind=DependencyKind.ENVIRONMENT,
|
||||||
|
name="NANOBOT_EXTENSION_TEST_KEY",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates, diagnostics = evaluate_dependencies((candidate,))
|
||||||
|
|
||||||
|
assert not candidates[0].enabled
|
||||||
|
assert diagnostics[0].code == "dependency_missing"
|
||||||
|
assert "NANOBOT_EXTENSION_TEST_KEY" in diagnostics[0].message
|
||||||
|
|
||||||
|
|
||||||
|
def test_installed_npm_dependency_satisfies_preflight(tmp_path: Path) -> None:
|
||||||
|
package = tmp_path / "node_modules" / "openclaw"
|
||||||
|
package.mkdir(parents=True)
|
||||||
|
(package / "package.json").write_text('{"version":"2026.7.1"}')
|
||||||
|
candidate = _candidate(
|
||||||
|
ExtensionDependency(
|
||||||
|
kind=DependencyKind.NPM,
|
||||||
|
name="openclaw",
|
||||||
|
specifier=">=2026.7.0",
|
||||||
|
),
|
||||||
|
location=tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates, diagnostics = evaluate_dependencies((candidate,))
|
||||||
|
|
||||||
|
assert candidates[0].enabled
|
||||||
|
assert diagnostics == ()
|
||||||
@ -3,6 +3,7 @@ from nanobot.extensions import (
|
|||||||
ExtensionCandidate,
|
ExtensionCandidate,
|
||||||
ExtensionContribution,
|
ExtensionContribution,
|
||||||
ExtensionManifest,
|
ExtensionManifest,
|
||||||
|
ExtensionPermission,
|
||||||
ExtensionPolicy,
|
ExtensionPolicy,
|
||||||
ExtensionRegistry,
|
ExtensionRegistry,
|
||||||
ExtensionRuntime,
|
ExtensionRuntime,
|
||||||
@ -94,6 +95,35 @@ def test_untrusted_external_extension_is_visible_to_discovery_but_not_active() -
|
|||||||
assert snapshot.contributions == ()
|
assert snapshot.contributions == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_extension_requires_every_requested_permission() -> None:
|
||||||
|
candidate = ExtensionCandidate(
|
||||||
|
manifest=ExtensionManifest(
|
||||||
|
id="permission.test",
|
||||||
|
name="Permission test",
|
||||||
|
version="1.0.0",
|
||||||
|
runtime=ExtensionRuntime.DECLARATIVE,
|
||||||
|
permissions=(
|
||||||
|
ExtensionPermission(name="network", reason="Fetch data."),
|
||||||
|
ExtensionPermission(
|
||||||
|
name="filesystem.read",
|
||||||
|
reason="Read input.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
scope=ExtensionScope.USER,
|
||||||
|
trusted=True,
|
||||||
|
granted_permissions=frozenset({"network"}),
|
||||||
|
)
|
||||||
|
registry = ExtensionRegistry()
|
||||||
|
registry.register(candidate)
|
||||||
|
|
||||||
|
snapshot = registry.snapshot()
|
||||||
|
|
||||||
|
assert snapshot.extensions == ()
|
||||||
|
assert snapshot.diagnostics[0].code == "permission_required"
|
||||||
|
assert "filesystem.read" in snapshot.diagnostics[0].message
|
||||||
|
|
||||||
|
|
||||||
def test_conflicting_contribution_does_not_silently_replace_owner() -> None:
|
def test_conflicting_contribution_does_not_silently_replace_owner() -> None:
|
||||||
registry = ExtensionRegistry()
|
registry = ExtensionRegistry()
|
||||||
registry.register(
|
registry.register(
|
||||||
|
|||||||
@ -40,10 +40,14 @@ def test_store_installs_and_applies_trust_state(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
store.set_trusted(installed.record.id, True)
|
store.set_trusted(installed.record.id, True)
|
||||||
store.set_enabled(installed.record.id, False)
|
store.set_enabled(installed.record.id, False)
|
||||||
|
store.set_permissions(installed.record.id, {"network", "filesystem.read"})
|
||||||
|
|
||||||
candidate = store.discover().candidates[0]
|
candidate = store.discover().candidates[0]
|
||||||
assert candidate.trusted
|
assert candidate.trusted
|
||||||
assert not candidate.enabled
|
assert not candidate.enabled
|
||||||
|
assert candidate.granted_permissions == frozenset(
|
||||||
|
{"network", "filesystem.read"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_store_updates_and_uninstalls_atomically(tmp_path: Path) -> None:
|
def test_store_updates_and_uninstalls_atomically(tmp_path: Path) -> None:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user