import json import tarfile from io import BytesIO from pathlib import Path from unittest.mock import patch import pytest from nanobot.extensions import ( DependencyKind, ExtensionDependency, ExtensionManifest, ExtensionPermission, ExtensionRuntime, ExtensionSourceKind, ExtensionStore, InstalledExtension, dump_manifest, ) from nanobot.extensions.store import _extract_tar 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"]}, } ) ) dump_manifest( ExtensionManifest( id="pi.store-test", name="store-test", version=version, runtime=ExtensionRuntime.PI, entry="./index.mjs", permissions=( ExtensionPermission(name="network"), ExtensionPermission(name="filesystem.read"), ), ), root / "nanobot.extension.json", ) 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) store.set_permissions(installed.record.id, {"network", "filesystem.read"}) candidate = store.discover().candidates[0] assert candidate.trusted assert not candidate.enabled assert candidate.granted_permissions == frozenset( {"network", "filesystem.read"} ) def test_registry_permissions_must_be_a_string_array() -> None: with pytest.raises(ValueError, match="array of strings"): InstalledExtension.from_mapping( { "id": "sample", "version": "1.0.0", "source": "npm", "source_ref": "sample", "integrity": "sha256:test", "installed_at": "2026-01-01T00:00:00Z", "granted_permissions": "network", } ) @pytest.mark.parametrize( ("field", "value", "message"), [ ("source", 3, "source must be a string"), ("integrity", "sha256:test", "sha256 digest"), ( "granted_permissions", ["network", "network"], "cannot contain duplicates", ), ], ) def test_registry_record_rejects_ambiguous_metadata( field: str, value: object, message: str, ) -> None: payload = { "id": "sample", "version": "1.0.0", "source": "npm", "source_ref": "sample", "integrity": f"sha256:{'0' * 64}", "installed_at": "2026-01-01T00:00:00Z", } payload[field] = value with pytest.raises(ValueError, match=message): InstalledExtension.from_mapping(payload) def test_corrupt_registry_is_diagnostic_and_cannot_be_overwritten( tmp_path: Path, ) -> None: source = _pi_package(tmp_path / "source") store = ExtensionStore(tmp_path / "extensions") installed = store.install_local(source) original = "{broken" store.registry_path.write_text(original) discovery = store.discover() assert discovery.diagnostics[0].code == "invalid_extension_registry" assert not discovery.candidates[0].trusted with pytest.raises(ValueError, match="invalid extension registry"): store.set_trusted(installed.record.id, True) assert store.registry_path.read_text() == original @pytest.mark.parametrize( "payload", [ {}, {"version": 2, "extensions": []}, {"version": 1, "extensions": {}}, { "version": 1, "extensions": [ { "id": "duplicate", "version": "1.0.0", "source": "npm", "source_ref": "duplicate", "integrity": "sha256:test", "installed_at": "2026-01-01T00:00:00Z", }, { "id": "duplicate", "version": "1.0.0", "source": "npm", "source_ref": "duplicate", "integrity": "sha256:test", "installed_at": "2026-01-01T00:00:00Z", }, ], }, ], ) def test_store_rejects_invalid_registry_schema( tmp_path: Path, payload: object, ) -> None: store = ExtensionStore(tmp_path / "extensions") store.registry_path.write_text(json.dumps(payload)) with pytest.raises(ValueError, match="invalid extension registry"): store.records(strict=True) 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)) manifest_path = source / "nanobot.extension.json" manifest = json.loads(manifest_path.read_text()) manifest["version"] = "2.0.0" manifest_path.write_text(json.dumps(manifest)) second = store.install_local(source) assert second.record.version == "2.0.0" assert not second.record.trusted store.uninstall(first.record.id) assert store.records() == {} assert not (store.root / first.record.id).exists() def test_store_preserves_trust_only_for_identical_reinstall(tmp_path: Path) -> None: source = _pi_package(tmp_path / "source") store = ExtensionStore(tmp_path / "extensions") first = store.install_local(source, trusted=True) store.set_permissions(first.record.id, {"network"}) second = store.install_local(source) assert second.record.trusted assert second.record.granted_permissions == ("network",) def test_store_revokes_effective_trust_when_package_changes_on_disk( tmp_path: Path, ) -> None: source = _pi_package(tmp_path / "source") store = ExtensionStore(tmp_path / "extensions") installed = store.install_local(source, trusted=True) target = store.root / installed.record.id (target / "index.mjs").write_text("export default function changed() {}") discovery = store.discover() assert not discovery.candidates[0].trusted assert not discovery.candidates[0].integrity_valid assert any( item.code == "extension_integrity_mismatch" for item in discovery.diagnostics ) def test_store_ignores_interrupted_transaction_directories(tmp_path: Path) -> None: store = ExtensionStore(tmp_path / "extensions") hidden = _pi_package(store.root / ".install-interrupted") discovery = store.discover() assert hidden.is_dir() assert discovery.candidates == () 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_accepts_internal_node_dependency_links(tmp_path: Path) -> None: source = _pi_package(tmp_path / "source") store = ExtensionStore(tmp_path / "extensions") def install_dependencies(root: Path, _manifest) -> None: executable = root / "node_modules" / "tool" / "cli.js" executable.parent.mkdir(parents=True) executable.write_text("export {}") bin_dir = root / "node_modules" / ".bin" bin_dir.mkdir() (bin_dir / "tool").symlink_to("../tool/cli.js") with patch( "nanobot.extensions.store._install_node_dependencies", side_effect=install_dependencies, ): installed = store.install_local(source) candidate = store.discover().candidates[0] assert candidate.manifest.id == installed.record.id assert candidate.integrity_valid def test_store_rejects_node_dependency_links_outside_package(tmp_path: Path) -> None: source = _pi_package(tmp_path / "source") outside = tmp_path / "outside.js" outside.write_text("export {}") store = ExtensionStore(tmp_path / "extensions") def install_dependencies(root: Path, _manifest) -> None: bin_dir = root / "node_modules" / ".bin" bin_dir.mkdir(parents=True) (bin_dir / "tool").symlink_to(outside) with patch( "nanobot.extensions.store._install_node_dependencies", side_effect=install_dependencies, ): with pytest.raises(ValueError, match="symlink"): store.install_local(source) def test_store_only_grants_permissions_requested_by_current_manifest( tmp_path: Path, ) -> None: source = tmp_path / "source" source.mkdir() dump_manifest( ExtensionManifest( id="permission-test", name="Permission test", version="1.0.0", runtime=ExtensionRuntime.DECLARATIVE, permissions=(ExtensionPermission(name="network.http"),), ), source / "nanobot.extension.json", ) store = ExtensionStore(tmp_path / "extensions") store.install_local(source) granted = store.set_permissions("permission-test", {"network.http"}) assert granted.granted_permissions == ("network.http",) with pytest.raises(ValueError, match="not requested"): store.set_permissions("permission-test", {"workspace.write"}) 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" def test_store_preserves_previous_package_when_backup_rename_fails( tmp_path: Path, ) -> None: source = _pi_package(tmp_path / "source") store = ExtensionStore(tmp_path / "extensions") first = store.install_local(source) target = store.root / first.record.id original_rename = Path.rename def fail_backup_rename(path: Path, destination: Path) -> Path: if path == target: raise OSError("rename failed") return original_rename(path, destination) with patch.object(Path, "rename", fail_backup_rename): with pytest.raises(OSError, match="rename failed"): store.install_local(source) assert target.is_dir() assert first.record.id in store.records(strict=True) def test_store_removes_first_package_when_registry_write_fails( tmp_path: Path, ) -> None: source = _pi_package(tmp_path / "source") store = ExtensionStore(tmp_path / "extensions") with patch.object(store, "_write_records", side_effect=OSError("disk full")): with pytest.raises(OSError, match="disk full"): store.install_local(source) assert not (store.root / "pi.store-test").exists() def test_store_restores_package_when_uninstall_registry_write_fails( tmp_path: Path, ) -> None: source = _pi_package(tmp_path / "source") store = ExtensionStore(tmp_path / "extensions") installed = store.install_local(source) target = store.root / installed.record.id with patch.object(store, "_write_records", side_effect=OSError("disk full")): with pytest.raises(OSError, match="disk full"): store.uninstall(installed.record.id) assert target.exists() assert installed.record.id in store.records() def test_store_installs_declared_npm_runtime_dependency(tmp_path: Path) -> None: source = tmp_path / "source" source.mkdir() (source / "index.mjs").write_text("export default function () {}") (source / "package.json").write_text("{}") dump_manifest( ExtensionManifest( id="openclaw.test", name="OpenClaw test", version="1.0.0", runtime=ExtensionRuntime.OPENCLAW, entry="./index.mjs", dependencies=( ExtensionDependency( kind=DependencyKind.NPM, name="openclaw", specifier="2026.7.1", ), ), ), source / "nanobot.extension.json", ) store = ExtensionStore(tmp_path / "extensions") runtime_package: dict[str, object] = {} def capture_runtime_package( _command: list[str], *, cwd: Path | None = None, ) -> str: assert cwd is not None runtime_package.update(json.loads((cwd / "package.json").read_text())) return "" with patch( "nanobot.extensions.store._run", side_effect=capture_runtime_package, ) as run: store.install_local(source) command = run.call_args.args[0] assert "--omit=dev" in command assert "--package-lock=false" in command assert runtime_package["dependencies"] == {"openclaw": "2026.7.1"} assert json.loads((store.root / "openclaw.test" / "package.json").read_text()) == {} def test_git_install_fetches_branch_tag_or_commit_ref(tmp_path: Path) -> None: store = ExtensionStore(tmp_path / "extensions") result = object() with ( patch("nanobot.extensions.store._run") as run, patch.object(store, "_install_from_directory", return_value=result), ): installed = store.install_git( "https://example.com/acme/extension.git", ref="abc123", ) assert installed is result commands = [call.args[0] for call in run.call_args_list] assert commands[0][:4] == ["git", "clone", "--filter=blob:none", "--no-checkout"] assert commands[1][-5:] == ["--depth", "1", "--", "origin", "abc123"] assert commands[2][-3:] == ["checkout", "--detach", "FETCH_HEAD"] @pytest.mark.parametrize( "url", [ "ext::sh -c touch /tmp/owned", "file:///tmp/extension", "/tmp/extension", "https://token@example.com/acme/extension.git", "https://example.com/acme/extension.git?token=secret", "git@example.com:acme/extension.git#main", ], ) def test_git_install_rejects_unsafe_or_local_sources( tmp_path: Path, url: str, ) -> None: store = ExtensionStore(tmp_path / "extensions") with patch("nanobot.extensions.store._run") as run: with pytest.raises(ValueError): store.install_git(url) run.assert_not_called() @pytest.mark.parametrize( "spec", ( "file:/tmp/private", "../local-package", "https://example.com/package.tgz", "git+ssh://git@example.com/package.git", "npm:safe-package@1.0.0", ), ) def test_install_npm_rejects_non_registry_package_source( tmp_path: Path, spec: str, ) -> None: store = ExtensionStore(tmp_path / "extensions") with patch("nanobot.extensions.store._run") as run: with pytest.raises(ValueError, match="registry package name"): store.install_npm(spec) run.assert_not_called() @pytest.mark.parametrize( "spec", ("safe-package", "safe-package@latest", "@scope/safe-package@1.2.3"), ) def test_install_npm_accepts_registry_package_source( tmp_path: Path, spec: str, ) -> None: store = ExtensionStore(tmp_path / "extensions") with patch( "nanobot.extensions.store._run", side_effect=RuntimeError("validation passed"), ) as run: with pytest.raises(RuntimeError, match="validation passed"): store.install_npm(spec) assert run.call_args.args[0][-1] == spec def test_install_npm_rejects_invalid_pack_metadata(tmp_path: Path) -> None: store = ExtensionStore(tmp_path / "extensions") with patch("nanobot.extensions.store._run", return_value="[{}]"): with pytest.raises(ValueError, match="invalid package metadata"): store.install_npm("safe-package") def test_npm_archive_rejects_extracted_size_over_limit(tmp_path: Path) -> None: archive = tmp_path / "package.tgz" payload = b"too-large" with tarfile.open(archive, "w:gz") as bundle: member = tarfile.TarInfo("package/index.mjs") member.size = len(payload) bundle.addfile(member, BytesIO(payload)) with patch("nanobot.extensions.store._MAX_EXTRACTED_BYTES", len(payload) - 1): with pytest.raises(ValueError, match="extracted limit"): _extract_tar(archive, tmp_path / "checkout") def test_node_dependency_install_rejects_non_registry_specs(tmp_path: Path) -> None: source = tmp_path / "source" source.mkdir() (source / "index.mjs").write_text("export default function () {}") (source / "package.json").write_text( json.dumps({"dependencies": {"unsafe": "file:/tmp/package"}}) ) dump_manifest( ExtensionManifest( id="pi.unsafe-dependency", name="Unsafe dependency", version="1.0.0", runtime=ExtensionRuntime.PI, entry="./index.mjs", ), source / "nanobot.extension.json", ) store = ExtensionStore(tmp_path / "extensions") with patch("nanobot.extensions.store._run") as run: with pytest.raises(ValueError, match="npm registry"): store.install_local(source) run.assert_not_called() def test_node_dependency_install_includes_required_peers_only(tmp_path: Path) -> None: source = _pi_package(tmp_path / "source") package_path = source / "package.json" package = json.loads(package_path.read_text()) package.update( { "peerDependencies": { "required-peer": "^1.0.0", "optional-peer": "^2.0.0", }, "peerDependenciesMeta": { "optional-peer": {"optional": True}, }, } ) package_path.write_text(json.dumps(package)) store = ExtensionStore(tmp_path / "extensions") def inspect_install(command: list[str], *, cwd: Path | None = None) -> str: assert command[:2] == ["npm", "install"] assert cwd is not None runtime_package = json.loads((cwd / "package.json").read_text()) assert runtime_package["dependencies"]["required-peer"] == "^1.0.0" assert "optional-peer" not in runtime_package["dependencies"] return "" with patch("nanobot.extensions.store._run", side_effect=inspect_install): store.install_local(source)