diff --git a/api/routers/extensions.py b/api/routers/extensions.py index 2313d3b3..d826a923 100644 --- a/api/routers/extensions.py +++ b/api/routers/extensions.py @@ -1,19 +1,24 @@ import asyncio import subprocess import sys -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Body, HTTPException router = APIRouter(tags=["extensions"]) @router.post("/reload") -async def reload_extensions(): +async def reload_extensions(payload: dict | None = Body(default=None)): """ Re-scans the extensions/ folder and reloads the registry without restarting FastAPI. Unloads all currently loaded generators before reloading. """ from services.generator_registry import generator_registry - generator_registry.reload() + validation_capability = None + if isinstance(payload, dict): + candidate = payload.get("validationCapability") + if isinstance(candidate, dict): + validation_capability = candidate + generator_registry.reload(validation_capability) return { "reloaded": True, "models": list(generator_registry._generators.keys()), diff --git a/api/services/extension_process.py b/api/services/extension_process.py index 60ce6787..341df6d6 100644 --- a/api/services/extension_process.py +++ b/api/services/extension_process.py @@ -372,14 +372,24 @@ def stop(self) -> None: next load() starts a fresh subprocess. """ proc = self._proc - self._proc = None self._loaded = False if proc and proc.poll() is None: try: proc.kill() proc.wait(timeout=5) - except Exception: - pass + except Exception as exc: + # Keep the process reference so callers can verify that the + # worker may still be alive and refuse to mutate its venv. + self._proc = proc + raise RuntimeError( + f"[{self.MODEL_ID}] Could not stop extension subprocess" + ) from exc + if proc.poll() is None: + self._proc = proc + raise RuntimeError( + f"[{self.MODEL_ID}] Extension subprocess is still running" + ) + self._proc = None self._drain_queue() def _drain_queue(self) -> None: diff --git a/api/services/generator_registry.py b/api/services/generator_registry.py index 6c07ca66..cbf70063 100644 --- a/api/services/generator_registry.py +++ b/api/services/generator_registry.py @@ -7,12 +7,21 @@ - generator.py (class extending BaseGenerator) No other file needs to be modified. """ +import importlib import importlib.util +import hmac import json import os +import re +import secrets +import stat import sys +import threading +from contextlib import contextmanager +from functools import wraps from pathlib import Path -from typing import Dict, Optional, Tuple +from types import ModuleType +from typing import Dict, Iterator, List, Optional, Set, Tuple from services.generators.base import BaseGenerator from services.extension_process import ExtensionProcess, _venv_python @@ -32,6 +41,13 @@ # extensions/ folder — in userData (passed by Electron via EXTENSIONS_DIR) _extensions_dir_raw = os.environ.get("EXTENSIONS_DIR", "") EXTENSIONS_DIR = Path(_extensions_dir_raw) if _extensions_dir_raw else None +_REGISTRATION_PENDING_PREFIX = ".modly-registration-pending-" +_EXTENSION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$") +_REGISTRATION_PENDING_NAME = re.compile( + rf"^{re.escape(_REGISTRATION_PENDING_PREFIX)}" + r"(?P[a-z0-9][a-z0-9._-]*)-(?P\d+)$" +) +_REGISTRATION_CAPABILITY_LOCK = threading.Lock() print(f"[Registry] MODELS_DIR = {MODELS_DIR}") print(f"[Registry] WORKSPACE_DIR = {WORKSPACE_DIR}") @@ -42,18 +58,336 @@ # Extension loader # ------------------------------------------------------------------ # -def _discover_extensions() -> Dict[str, Tuple[type, dict]]: +def _path_belongs_to(path: object, root: Path) -> bool: + if not isinstance(path, (str, os.PathLike)): + return False + try: + Path(path).resolve().relative_to(root) + return True + except (OSError, ValueError): + return False + + +class _LegacyImportContext: + def __init__(self, root: Path) -> None: + self.root = root.resolve() + self.modules: Dict[str, ModuleType] = {} + self.local_names: Set[str] = set() + for child in self.root.iterdir(): + if child.is_file() and child.suffix == ".py" and child.stem != "__init__": + self.local_names.add(child.stem) + elif child.is_dir() and (child / "__init__.py").is_file(): + self.local_names.add(child.name) + + +class _LegacyImportManager: + """Isolates plain sibling imports used by legacy direct extensions.""" + + def __init__(self) -> None: + self._contexts: Dict[Path, _LegacyImportContext] = {} + self._lock = threading.RLock() + + def register(self, root: Path) -> _LegacyImportContext: + resolved = root.resolve() + context = self._contexts.get(resolved) + if context is None: + context = _LegacyImportContext(resolved) + self._contexts[resolved] = context + return context + + def _owner(self, module: ModuleType) -> Optional[_LegacyImportContext]: + module_file = getattr(module, "__file__", None) + for context in self._contexts.values(): + if _path_belongs_to(module_file, context.root): + return context + return None + + def _capture_and_evict_owned_modules(self) -> None: + for name, module in list(sys.modules.items()): + if not isinstance(module, ModuleType): + continue + owner = self._owner(module) + if owner is None: + continue + owner.modules[name] = module + sys.modules.pop(name, None) + + @staticmethod + def _matches_local_name(module_name: str, local_names: Set[str]) -> bool: + return any( + module_name == local_name or module_name.startswith(f"{local_name}.") + for local_name in local_names + ) + + @contextmanager + def activate(self, context: _LegacyImportContext) -> Iterator[None]: + # sys.path and sys.modules are process-global. Serializing direct legacy + # calls prevents concurrent generators from observing each other's + # same-named sibling modules. + with self._lock: + original_path = list(sys.path) + shadowed: Dict[str, ModuleType] = {} + self._capture_and_evict_owned_modules() + + for name, module in list(sys.modules.items()): + if self._matches_local_name(name, context.local_names) and isinstance(module, ModuleType): + shadowed[name] = module + sys.modules.pop(name, None) + + sys.modules.update(context.modules) + managed_roots = {str(item.root) for item in self._contexts.values()} + sys.path[:] = [str(context.root)] + [ + entry for entry in original_path if entry not in managed_roots + ] + importlib.invalidate_caches() + + try: + yield + finally: + self._capture_and_evict_owned_modules() + for name in list(sys.modules): + if self._matches_local_name(name, context.local_names): + sys.modules.pop(name, None) + sys.modules.update(shadowed) + sys.path[:] = original_path + importlib.invalidate_caches() + + def clear(self) -> None: + with self._lock: + self._capture_and_evict_owned_modules() + for context in self._contexts.values(): + for module in context.modules.values(): + cached = getattr(module, "__cached__", None) + if _path_belongs_to(cached, context.root): + try: + Path(cached).unlink(missing_ok=True) + except OSError: + pass + context.modules.clear() + self._contexts.clear() + importlib.invalidate_caches() + + +class _LegacyGeneratorProxy: + def __init__( + self, + target: BaseGenerator, + manager: _LegacyImportManager, + context: _LegacyImportContext, + ) -> None: + object.__setattr__(self, "_target", target) + object.__setattr__(self, "_manager", manager) + object.__setattr__(self, "_context", context) + + def __getattr__(self, name: str): + manager = object.__getattribute__(self, "_manager") + context = object.__getattribute__(self, "_context") + target = object.__getattribute__(self, "_target") + with manager.activate(context): + value = getattr(target, name) + if not callable(value): + return value + + @wraps(value) + def call_in_context(*args, **kwargs): + with manager.activate(context): + return value(*args, **kwargs) + + return call_in_context + + def __setattr__(self, name: str, value: object) -> None: + manager = object.__getattribute__(self, "_manager") + context = object.__getattribute__(self, "_context") + target = object.__getattribute__(self, "_target") + with manager.activate(context): + setattr(target, name, value) + + +def _manifest_model_ids(manifest: dict, fallback_id: str) -> List[str]: + """Returns the registry keys a readable model manifest is expected to add.""" + ext_id = manifest.get("id") + if not isinstance(ext_id, str) or not ext_id: + return [fallback_id] + + raw_nodes = manifest.get("nodes", []) + if not isinstance(raw_nodes, list): + raw_nodes = [] + nodes = [ + node for node in raw_nodes + if isinstance(node, dict) and isinstance(node.get("id"), str) and node["id"] + ] + return [f"{ext_id}/{node['id']}" for node in nodes] or [ext_id] + + +def _record_discovery_error( + errors: Dict[str, str], + message: str, + *, + manifest: Optional[dict] = None, + fallback_id: str, +) -> None: + keys = _manifest_model_ids(manifest, fallback_id) if manifest is not None else [fallback_id] + for key in keys: + errors[key] = message + + +def _registration_pending(extension_id: str) -> bool: + if EXTENSIONS_DIR is None: + return False + prefix = f"{_REGISTRATION_PENDING_PREFIX}{extension_id}-" + try: + return any( + child.name.startswith(prefix) + and child.name[len(prefix):].isdigit() + for child in EXTENSIONS_DIR.iterdir() + ) + except OSError: + return False + + +RegistrationValidationAuthorization = Tuple[str, Path, Path] + + +def _consume_registration_validation_capability( + capability: object, +) -> Optional[RegistrationValidationAuthorization]: + """Validates and atomically consumes one exact root-sidecar capability.""" + if EXTENSIONS_DIR is None or not isinstance(capability, dict): + return None + + extension_id = capability.get("extensionId") + destination_name = capability.get("destinationName") + state_name = capability.get("stateName") + token = capability.get("token") + if not all(isinstance(value, str) and value for value in ( + extension_id, + destination_name, + state_name, + token, + )): + return None + if len(token) < 32: + return None + if ( + _EXTENSION_ID.fullmatch(extension_id) is None + or destination_name != extension_id + ): + return None + + name_match = _REGISTRATION_PENDING_NAME.fullmatch(state_name) + if name_match is None or name_match.group("extension_id") != extension_id: + return None + + with _REGISTRATION_CAPABILITY_LOCK: + try: + root = EXTENSIONS_DIR.resolve() + state_path = EXTENSIONS_DIR / state_name + if state_path.parent.resolve() != root: + return None + destination_path = Path(os.path.abspath(root / destination_name)) + if destination_path.parent != root: + return None + matching_state_names = sorted( + child.name + for child in root.iterdir() + if ( + (match := _REGISTRATION_PENDING_NAME.fullmatch(child.name)) + is not None + and match.group("extension_id") == extension_id + ) + ) + if matching_state_names != [state_name]: + return None + state_stat = state_path.lstat() + if ( + stat.S_ISLNK(state_stat.st_mode) + or not stat.S_ISREG(state_stat.st_mode) + or state_stat.st_nlink != 1 + ): + return None + if ( + os.name != "nt" + and ( + stat.S_IMODE(state_stat.st_mode) & 0o077 + or ( + hasattr(os, "getuid") + and state_stat.st_uid != os.getuid() + ) + ) + ): + return None + + state = json.loads(state_path.read_text(encoding="utf-8")) + if ( + not isinstance(state, dict) + or state.get("version") != 1 + or state.get("extensionId") != extension_id + or state.get("destinationName") != destination_name + or state.get("consumed") is not False + or not isinstance(state.get("token"), str) + or not hmac.compare_digest(state["token"], token) + ): + return None + + # Replace the exact sidecar atomically while retaining its pending + # filename. Normal reloads therefore keep quarantining the + # extension, while this capability cannot be replayed. + consumed_state = { + "version": 1, + "extensionId": extension_id, + "destinationName": destination_name, + "consumed": True, + } + temporary_path = root / ( + f".modly-registration-capability-{secrets.token_hex(16)}" + ) + try: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(temporary_path, flags, 0o600) + try: + if os.name != "nt": + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + descriptor = -1 + json.dump(consumed_state, stream) + stream.flush() + os.fsync(stream.fileno()) + finally: + if descriptor >= 0: + os.close(descriptor) + os.replace(temporary_path, state_path) + finally: + try: + temporary_path.unlink() + except FileNotFoundError: + pass + except (OSError, TypeError, ValueError, json.JSONDecodeError): + return None + + return extension_id, state_path, destination_path + + +def _discover_extensions( + legacy_imports: _LegacyImportManager, + registration_authorization: Optional[RegistrationValidationAuthorization] = None, +) -> Tuple[ + Dict[str, Tuple[type, dict, Path, Optional[_LegacyImportContext]]], + Dict[str, str], +]: """ Scans EXTENSIONS_DIR to find valid extensions. Each extension must have manifest.json + generator.py. - Returns {full_id: (GeneratorClass, node_manifest, ext_dir)} + Returns ({full_id: (GeneratorClass, node_manifest, ext_dir)}, errors) where full_id is "ext_id/node_id". """ - result: Dict[str, Tuple[type, dict]] = {} + result: Dict[str, Tuple[type, dict, Path, Optional[_LegacyImportContext]]] = {} + errors: Dict[str, str] = {} if EXTENSIONS_DIR is None or not EXTENSIONS_DIR.exists(): print(f"[Registry] WARNING: EXTENSIONS_DIR not set or not found: {EXTENSIONS_DIR}") - return result + return result, errors for ext_dir in sorted(EXTENSIONS_DIR.iterdir()): if not ext_dir.is_dir(): @@ -61,25 +395,29 @@ def _discover_extensions() -> Dict[str, Tuple[type, dict]]: # Dot-dirs are install machinery (staging/backup), never extensions if ext_dir.name.startswith("."): continue - # Marker left by the installer while setup runs (or after a crash): - # the folder is not ready to be loaded - if (ext_dir / ".modly-incomplete").exists(): - print(f"[Registry] Skipping '{ext_dir.name}': install has not completed") - continue - manifest_path = ext_dir / "manifest.json" generator_path = ext_dir / "generator.py" if not manifest_path.exists(): - print(f"[Registry] Skipping '{ext_dir.name}': missing manifest.json") - continue - if not generator_path.exists(): - print(f"[Registry] Skipping '{ext_dir.name}': missing generator.py") + message = f"Extension '{ext_dir.name}' is missing manifest.json." + print(f"[Registry] ERROR: {message}") + _record_discovery_error(errors, message, fallback_id=ext_dir.name) continue try: manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except Exception as exc: + message = f"Extension '{ext_dir.name}' has an invalid manifest.json: {exc}" + print(f"[Registry] ERROR: {message}") + _record_discovery_error(errors, message, fallback_id=ext_dir.name) + continue + if not isinstance(manifest, dict): + message = f"Extension '{ext_dir.name}' has an invalid manifest.json: expected an object." + print(f"[Registry] ERROR: {message}") + _record_discovery_error(errors, message, fallback_id=ext_dir.name) + continue + try: # Process extensions run via Electron's process runner, not this # registry — skip them even when their entry file is generator.py. if manifest.get("type", "model") != "model": @@ -90,26 +428,98 @@ def _discover_extensions() -> Dict[str, Tuple[type, dict]]: ext_id = manifest["id"] class_name = manifest["generator_class"] - nodes = [n for n in manifest.get("nodes", []) if n.get("id")] + if ext_id != ext_dir.name: + message = ( + f"Extension folder '{ext_dir.name}' declares mismatched " + f"manifest id '{ext_id}'. Folder and manifest IDs must match." + ) + print(f"[Registry] ERROR: {message}") + _record_discovery_error( + errors, + message, + fallback_id=ext_dir.name, + ) + continue + + raw_nodes = manifest.get("nodes", []) + if not isinstance(raw_nodes, list): + raw_nodes = [] + nodes = [ + node for node in raw_nodes + if isinstance(node, dict) and node.get("id") + ] + + # Markers left while setup or runtime registration is unfinished: + # the folder is not ready to be loaded. The readable manifest lets + # the UI attach this error to each expected model node even when + # startup could not immediately restore a locked backup. + install_interrupted = ( + (ext_dir / ".modly-incomplete").exists() + or (ext_dir / ".modly-registration-pending").exists() + or ( + _registration_pending(ext_id) + and not ( + registration_authorization is not None + and registration_authorization[0] == ext_id + and registration_authorization[1].exists() + and registration_authorization[2] + == Path(os.path.abspath(ext_dir)) + ) + ) + ) + if install_interrupted: + message = ( + f"Extension '{ext_id}' has an incomplete installation " + "or interrupted runtime registration. " + "Restart Modly to recover it, or click 'Repair' on the Models page." + ) + print(f"[Registry] ERROR: {message}") + _record_discovery_error( + errors, + message, + manifest=manifest, + fallback_id=ext_dir.name, + ) + continue + + if not generator_path.exists(): + message = f"Extension '{ext_id}' is missing generator.py." + print(f"[Registry] ERROR: {message}") + _record_discovery_error( + errors, + message, + manifest=manifest, + fallback_id=ext_dir.name, + ) + continue # --- Subprocess mode (new): venv present → use ExtensionProcess --- # Also force subprocess mode for extensions that ship a build_vendor.py # but whose vendor/ directory hasn't been built yet: this surfaces a # loadError in the UI (Repair button) so the user can run setup.py. - has_venv = _venv_python(ext_dir).exists() + has_venv = _venv_python(ext_dir).is_file() + has_setup = (ext_dir / "setup.py").is_file() has_build_vendor = (ext_dir / "build_vendor.py").exists() vendor_built = (ext_dir / "vendor").exists() - subprocess_mode = has_venv or (has_build_vendor and not vendor_built) + needs_setup = (has_setup and not has_venv) or (has_build_vendor and not vendor_built) + subprocess_mode = has_venv or needs_setup cls_or_None = None + legacy_context = None if not subprocess_mode: # --- Direct mode (legacy): no venv → load generator.py directly --- - module_name = f"extensions.{ext_id}.generator" - spec = importlib.util.spec_from_file_location(module_name, generator_path) - module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - spec.loader.exec_module(module) - cls_or_None = getattr(module, class_name) + # Plain sibling imports are exposed only inside the serialized + # import context. Never leave extension roots on host sys.path. + legacy_context = legacy_imports.register(ext_dir) + with legacy_imports.activate(legacy_context): + module_name = f"extensions.{ext_id}.generator" + spec = importlib.util.spec_from_file_location(module_name, generator_path) + if spec is None or spec.loader is None: + raise ImportError(f"Could not create an import spec for {generator_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + cls_or_None = getattr(module, class_name) if nodes: for node in nodes: @@ -128,7 +538,7 @@ def _discover_extensions() -> Dict[str, Tuple[type, dict]]: "output": node.get("output", "mesh"), } full_id = f"{ext_id}/{node['id']}" - result[full_id] = (cls_or_None, node_manifest, ext_dir) + result[full_id] = (cls_or_None, node_manifest, ext_dir, legacy_context) if subprocess_mode: if has_venv: print(f"[Registry] Loaded subprocess node: {full_id}") @@ -138,7 +548,7 @@ def _discover_extensions() -> Dict[str, Tuple[type, dict]]: print(f"[Registry] Loaded node: {full_id} ({class_name})") else: # No nodes defined — register by ext_id as fallback - result[ext_id] = (cls_or_None, manifest, ext_dir) + result[ext_id] = (cls_or_None, manifest, ext_dir, legacy_context) if subprocess_mode: if has_venv: print(f"[Registry] Loaded subprocess extension: {ext_id}") @@ -148,9 +558,16 @@ def _discover_extensions() -> Dict[str, Tuple[type, dict]]: print(f"[Registry] Loaded extension: {ext_id} ({class_name})") except Exception as exc: - print(f"[Registry] ERROR loading extension '{ext_dir.name}': {exc}") + message = f"Failed to discover extension '{ext_dir.name}': {exc}" + print(f"[Registry] ERROR: {message}") + _record_discovery_error( + errors, + message, + manifest=manifest, + fallback_id=ext_dir.name, + ) - return result + return result, errors # ------------------------------------------------------------------ # @@ -162,18 +579,26 @@ def __init__(self) -> None: self._generators: Dict[str, BaseGenerator] = {} self._manifests: Dict[str, dict] = {} self._errors: Dict[str, str] = {} + self._legacy_imports = _LegacyImportManager() self._active_id: str = os.environ.get("SELECTED_MODEL_ID", "sf3d") - def initialize(self) -> None: + def initialize( + self, + registration_authorization: Optional[RegistrationValidationAuthorization] = None, + ) -> None: """Discovers and instantiates all extensions. Call at startup.""" - extensions = _discover_extensions() + extensions, discovery_errors = _discover_extensions( + self._legacy_imports, + registration_authorization, + ) + self._errors.update(discovery_errors) for model_id, entry in extensions.items(): - cls, manifest, ext_dir = entry + cls, manifest, ext_dir, legacy_context = entry try: if cls is None: # Subprocess mode: venv must exist - if not _venv_python(ext_dir).exists(): + if not _venv_python(ext_dir).is_file(): raise RuntimeError( "venv not found — extension needs setup. " "Click 'Repair' on the Models page to run setup.py." @@ -184,7 +609,15 @@ def initialize(self) -> None: gen.outputs_dir = WORKSPACE_DIR else: # Legacy direct mode - gen = cls(MODELS_DIR / model_id, WORKSPACE_DIR) + if legacy_context is None: + raise RuntimeError("legacy import context is missing") + with self._legacy_imports.activate(legacy_context): + direct_gen = cls(MODELS_DIR / model_id, WORKSPACE_DIR) + gen = _LegacyGeneratorProxy( + direct_gen, + self._legacy_imports, + legacy_context, + ) gen.hf_repo = manifest.get("hf_repo", "") gen.hf_skip_prefixes = manifest.get("hf_skip_prefixes", []) gen.download_check = manifest.get("download_check", "") @@ -213,33 +646,58 @@ def initialize(self) -> None: print(f"[Registry] Active model : {self._active_id}") print(f"[Registry] All models : {list(self._generators.keys())}") - def reload(self) -> None: + def reload(self, validation_capability: object = None) -> None: """ Re-scans extensions and updates the registry without restarting FastAPI. Unloads all current generators before reloading. """ + registration_authorization = _consume_registration_validation_capability( + validation_capability, + ) print("[Registry] Reloading extensions…") for gen in self._generators.values(): - try: - gen.unload() - except Exception: - pass + if isinstance(gen, ExtensionProcess): + gen.stop() + if gen._proc is not None: + raise RuntimeError( + "Extension subprocess remained attached after stop()" + ) + else: + try: + gen.unload() + except Exception: + pass self._generators.clear() self._manifests.clear() self._errors.clear() - self.initialize() + self._remove_legacy_paths() + self.initialize(registration_authorization) print("[Registry] Reload complete.") def load_errors(self) -> Dict[str, str]: """Returns extension loading errors.""" return dict(self._errors) + def _remove_legacy_paths(self) -> None: + """Clears direct-extension import state owned by this registry.""" + self._legacy_imports.clear() + # ------------------------------------------------------------------ # # Generator access # ------------------------------------------------------------------ # + @staticmethod + def _assert_not_quarantined(model_id: str) -> None: + extension_id = model_id.split("/", 1)[0] + if _registration_pending(extension_id): + raise ValueError( + f"Model ID '{model_id}' belongs to an extension with pending " + "runtime registration. Repair or reload the extension first." + ) + def get_active(self) -> BaseGenerator: """Returns the active generator. Downloads and loads if necessary.""" + self._assert_not_quarantined(self._active_id) gen = self._generators[self._active_id] if not gen.is_loaded(): if not gen.is_downloaded(): @@ -254,6 +712,7 @@ def get_active(self) -> BaseGenerator: return gen def get_generator(self, model_id: str) -> BaseGenerator: + self._assert_not_quarantined(model_id) if model_id not in self._generators: raise ValueError( f"Unknown model ID: '{model_id}'. " @@ -269,6 +728,7 @@ def get_manifest(self, model_id: str) -> dict: def switch_model(self, model_id: str) -> None: """Switches the active model. Unloads the previous one if different.""" + self._assert_not_quarantined(model_id) if model_id not in self._generators: raise ValueError( f"Unknown model ID: '{model_id}'. " diff --git a/api/tests/test_extension_process.py b/api/tests/test_extension_process.py index 1cabb06d..9cd5c5d6 100644 --- a/api/tests/test_extension_process.py +++ b/api/tests/test_extension_process.py @@ -26,6 +26,60 @@ def test_read_loop_writes_sentinel_to_own_queue_only(self) -> None: self.assertFalse(old_queue.empty()) self.assertTrue(new_queue.empty()) + def test_stop_kills_and_verifies_subprocess_exit(self) -> None: + proc = _make_proc() + + class FakeProcess: + def __init__(self) -> None: + self.alive = True + self.kill_called = False + self.wait_called = False + + def poll(self): + return None if self.alive else -9 + + def kill(self) -> None: + self.kill_called = True + self.alive = False + + def wait(self, timeout: float): + self.wait_called = True + return -9 + + child = FakeProcess() + proc._proc = child # type: ignore[assignment] + proc._loaded = True + + proc.stop() + + self.assertTrue(child.kill_called) + self.assertTrue(child.wait_called) + self.assertIsNone(proc._proc) + self.assertFalse(proc._loaded) + + def test_stop_failure_keeps_live_process_reference_and_raises(self) -> None: + proc = _make_proc() + + class StuckProcess: + def poll(self): + return None + + def kill(self) -> None: + raise PermissionError("cannot kill") + + def wait(self, timeout: float): + raise AssertionError("wait must not run after kill failure") + + child = StuckProcess() + proc._proc = child # type: ignore[assignment] + proc._loaded = True + + with self.assertRaisesRegex(RuntimeError, "Could not stop"): + proc.stop() + + self.assertIs(proc._proc, child) + self.assertFalse(proc._loaded) + class VenvPythonTests(unittest.TestCase): def test_resolves_interpreter_path_for_current_platform(self) -> None: diff --git a/api/tests/test_extensions_router.py b/api/tests/test_extensions_router.py new file mode 100644 index 00000000..8eae631d --- /dev/null +++ b/api/tests/test_extensions_router.py @@ -0,0 +1,60 @@ +import asyncio +import unittest + +import services.generator_registry as registry_module +from routers.extensions import reload_extensions + + +class _FakeRegistry: + def __init__(self) -> None: + self._generators = {"healthy/generate": object()} + self.reload_calls: list[object] = [] + + def reload(self, validation_capability: object = None) -> None: + self.reload_calls.append(validation_capability) + + def load_errors(self) -> dict[str, str]: + return {"pending/generate": "quarantined"} + + +class ExtensionReloadRouteTests(unittest.TestCase): + def setUp(self) -> None: + self.previous_registry = registry_module.generator_registry + self.registry = _FakeRegistry() + registry_module.generator_registry = self.registry + + def tearDown(self) -> None: + registry_module.generator_registry = self.previous_registry + + def test_predictable_extension_id_is_ignored_and_response_shape_is_stable(self) -> None: + response = asyncio.run(reload_extensions({ + "validatingExtensionId": "pending", + })) + + self.assertEqual(self.registry.reload_calls, [None]) + self.assertEqual( + response, + { + "reloaded": True, + "models": ["healthy/generate"], + "errors": {"pending/generate": "quarantined"}, + }, + ) + + def test_only_nested_validation_capability_is_forwarded(self) -> None: + capability = { + "extensionId": "pending", + "destinationName": "pending", + "stateName": ".modly-registration-pending-pending-100", + "token": "t" * 43, + } + + asyncio.run(reload_extensions({ + "validationCapability": capability, + })) + + self.assertEqual(self.registry.reload_calls, [capability]) + + +if __name__ == "__main__": + unittest.main() diff --git a/api/tests/test_generator_registry.py b/api/tests/test_generator_registry.py new file mode 100644 index 00000000..7340b72c --- /dev/null +++ b/api/tests/test_generator_registry.py @@ -0,0 +1,521 @@ +import json +import importlib +import inspect +import os +import sys +import tempfile +import unittest +from pathlib import Path + +import services.generator_registry as registry_module +from services.extension_process import ExtensionProcess +from services.generator_registry import GeneratorRegistry + + +class GeneratorRegistryDiscoveryTests(unittest.TestCase): + def setUp(self) -> None: + self._tempdir = tempfile.TemporaryDirectory(prefix="modly-registry-test-") + self.root = Path(self._tempdir.name) + self.extensions_dir = self.root / "extensions" + self.models_dir = self.root / "models" + self.workspace_dir = self.root / "workspace" + self.extensions_dir.mkdir() + self.models_dir.mkdir() + self.workspace_dir.mkdir() + + self._old_extensions_dir = registry_module.EXTENSIONS_DIR + self._old_models_dir = registry_module.MODELS_DIR + self._old_workspace_dir = registry_module.WORKSPACE_DIR + registry_module.EXTENSIONS_DIR = self.extensions_dir + registry_module.MODELS_DIR = self.models_dir + registry_module.WORKSPACE_DIR = self.workspace_dir + self.registry = GeneratorRegistry() + + def tearDown(self) -> None: + self.registry._remove_legacy_paths() + registry_module.EXTENSIONS_DIR = self._old_extensions_dir + registry_module.MODELS_DIR = self._old_models_dir + registry_module.WORKSPACE_DIR = self._old_workspace_dir + for module_name in [ + "registry_eager_helper", + "registry_lazy_helper", + "extensions.class-failure.generator", + "extensions.host-owned-path.generator", + "extensions.legacy-imports.generator", + ]: + sys.modules.pop(module_name, None) + self._tempdir.cleanup() + + def _write_manifest( + self, + directory: Path, + *, + extension_id: str, + extension_type: str = "model", + node_ids: tuple[str, ...] = ("generate",), + ) -> None: + manifest = { + "id": extension_id, + "name": extension_id, + "type": extension_type, + "nodes": [{"id": node_id} for node_id in node_ids], + } + if extension_type == "model": + manifest["generator_class"] = "TestGenerator" + else: + manifest["entry"] = "processor.py" + (directory / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + + def _make_extension(self, extension_id: str) -> Path: + directory = self.extensions_dir / extension_id + directory.mkdir() + return directory + + def _write_registration_capability( + self, + extension_id: str, + *, + suffix: str = "100", + token: str = "t" * 43, + ) -> dict[str, str]: + state_name = ( + f".modly-registration-pending-{extension_id}-{suffix}" + ) + state_path = self.extensions_dir / state_name + state_path.write_text( + json.dumps({ + "version": 1, + "extensionId": extension_id, + "destinationName": extension_id, + "token": token, + "consumed": False, + }), + encoding="utf-8", + ) + state_path.chmod(0o600) + return { + "extensionId": extension_id, + "destinationName": extension_id, + "stateName": state_name, + "token": token, + } + + def _make_loadable_pending_extension(self, extension_id: str) -> dict[str, str]: + extension = self._make_extension(extension_id) + self._write_manifest(extension, extension_id=extension_id) + (extension / "generator.py").write_text( + "\n".join( + [ + "from services.generators.base import BaseGenerator", + "class TestGenerator(BaseGenerator):", + " def load(self): pass", + " def generate(self, image_bytes, params, progress_cb=None, cancel_event=None):", + " return self.outputs_dir / 'result.glb'", + ] + ), + encoding="utf-8", + ) + return self._write_registration_capability(extension_id) + + def test_legacy_generator_supports_eager_and_lazy_sibling_imports(self) -> None: + extension = self._make_extension("legacy-imports") + self._write_manifest(extension, extension_id="legacy-imports") + (extension / "registry_eager_helper.py").write_text("VALUE = 'eager'\n", encoding="utf-8") + (extension / "registry_lazy_helper.py").write_text("VALUE = 'lazy'\n", encoding="utf-8") + (extension / "generator.py").write_text( + "\n".join( + [ + "from services.generators.base import BaseGenerator", + "from registry_eager_helper import VALUE as EAGER_VALUE", + "", + "class TestGenerator(BaseGenerator):", + " def load(self):", + " self._model = object()", + "", + " def generate(self, image_bytes, params, progress_cb=None, cancel_event=None):", + " return self.outputs_dir / 'result.glb'", + "", + " def sibling_values(self):", + " from registry_lazy_helper import VALUE as lazy_value", + " return EAGER_VALUE, lazy_value", + ] + ), + encoding="utf-8", + ) + + self.registry.initialize() + + generator = self.registry.get_generator("legacy-imports/generate") + self.assertEqual(generator.sibling_values(), ("eager", "lazy")) + self.assertNotIn(str(extension.resolve()), sys.path) + self.assertIn("cancel_event", inspect.signature(generator.generate).parameters) + + registry_module.EXTENSIONS_DIR = self.root / "empty-extensions" + registry_module.EXTENSIONS_DIR.mkdir() + self.registry.reload() + self.assertNotIn(str(extension.resolve()), sys.path) + + def test_reload_preserves_legacy_path_owned_by_the_host(self) -> None: + extension = self._make_extension("host-owned-path") + self._write_manifest(extension, extension_id="host-owned-path") + (extension / "generator.py").write_text( + "\n".join( + [ + "from services.generators.base import BaseGenerator", + "class TestGenerator(BaseGenerator):", + " def load(self): pass", + " def generate(self, image_bytes, params, progress_cb=None, cancel_event=None):", + " return self.outputs_dir / 'result.glb'", + ] + ), + encoding="utf-8", + ) + host_path = str(extension.resolve()) + sys.path.insert(0, host_path) + try: + self.registry.initialize() + registry_module.EXTENSIONS_DIR = self.root / "empty-host-owned" + registry_module.EXTENSIONS_DIR.mkdir() + self.registry.reload() + self.assertIn(host_path, sys.path) + finally: + sys.path.remove(host_path) + + def test_reload_stops_subprocess_runtime_instead_of_only_unloading_model(self) -> None: + process = ExtensionProcess( + self.extensions_dir / "subprocess-runtime", + {"id": "subprocess-runtime"}, + ) + calls: list[str] = [] + process.stop = lambda: calls.append("stop") # type: ignore[method-assign] + process.unload = lambda: calls.append("unload") # type: ignore[method-assign] + self.registry._generators["subprocess-runtime/generate"] = process + + self.registry.reload() + + self.assertEqual(calls, ["stop"]) + + def test_reload_evicts_owned_helper_modules_and_reads_updated_source(self) -> None: + extension = self._make_extension("reload-helper") + self._write_manifest(extension, extension_id="reload-helper") + helper_path = extension / "reload_owned_helper.py" + helper_path.write_text("VALUE = 1\n", encoding="utf-8") + (extension / "generator.py").write_text( + "\n".join( + [ + "from services.generators.base import BaseGenerator", + "class TestGenerator(BaseGenerator):", + " def load(self): pass", + " def generate(self, image_bytes, params, progress_cb=None, cancel_event=None):", + " return self.outputs_dir / 'result.glb'", + " def helper_value(self):", + " import reload_owned_helper", + " return reload_owned_helper.VALUE", + ] + ), + encoding="utf-8", + ) + + self.registry.initialize() + self.assertEqual( + self.registry.get_generator("reload-helper/generate").helper_value(), + 1, + ) + + # Same-size edit in the same timestamp window exercises stale .pyc and + # sys.modules eviction rather than relying on filesystem mtime changes. + helper_path.write_text("VALUE = 2\n", encoding="utf-8") + self.registry.reload() + + self.assertEqual( + self.registry.get_generator("reload-helper/generate").helper_value(), + 2, + ) + self.assertNotIn("reload_owned_helper", sys.modules) + + def test_legacy_extensions_with_same_helper_name_remain_isolated(self) -> None: + for extension_id, value in (("collision-a", "alpha"), ("collision-b", "beta")): + extension = self._make_extension(extension_id) + self._write_manifest(extension, extension_id=extension_id) + (extension / "shared_collision_helper.py").write_text( + f"VALUE = {value!r}\n", + encoding="utf-8", + ) + (extension / "generator.py").write_text( + "\n".join( + [ + "from services.generators.base import BaseGenerator", + "class TestGenerator(BaseGenerator):", + " def load(self): pass", + " def generate(self, image_bytes, params, progress_cb=None, cancel_event=None):", + " return self.outputs_dir / 'result.glb'", + " def helper_value(self):", + " import shared_collision_helper", + " return shared_collision_helper.VALUE", + ] + ), + encoding="utf-8", + ) + + self.registry.initialize() + first = self.registry.get_generator("collision-a/generate") + second = self.registry.get_generator("collision-b/generate") + + self.assertEqual(first.helper_value(), "alpha") + self.assertEqual(second.helper_value(), "beta") + self.assertEqual(first.helper_value(), "alpha") + self.assertNotIn("shared_collision_helper", sys.modules) + with self.assertRaises(ModuleNotFoundError): + importlib.import_module("shared_collision_helper") + + def test_setup_script_without_platform_venv_surfaces_repair_error_per_node(self) -> None: + extension = self._make_extension("needs-setup") + self._write_manifest( + extension, + extension_id="needs-setup", + node_ids=("fast", "quality"), + ) + (extension / "setup.py").write_text("raise SystemExit('must not run in registry')\n", encoding="utf-8") + (extension / "generator.py").write_text( + "raise AssertionError('generator.py must not be imported before setup')\n", + encoding="utf-8", + ) + + self.registry.initialize() + + self.assertEqual(self.registry._generators, {}) + errors = self.registry.load_errors() + self.assertEqual(set(errors), {"needs-setup/fast", "needs-setup/quality"}) + self.assertTrue(all("Repair" in message and "venv not found" in message for message in errors.values())) + + def test_discovery_failures_remain_visible_under_actionable_keys(self) -> None: + missing_manifest = self._make_extension("missing-manifest") + (missing_manifest / "generator.py").write_text("", encoding="utf-8") + + invalid_manifest = self._make_extension("invalid-manifest") + (invalid_manifest / "manifest.json").write_text("{invalid", encoding="utf-8") + + non_object_manifest = self._make_extension("non-object-manifest") + (non_object_manifest / "manifest.json").write_text("[]", encoding="utf-8") + + missing_generator = self._make_extension("missing-generator") + self._write_manifest(missing_generator, extension_id="missing-generator") + + import_failure = self._make_extension("import-failure") + self._write_manifest(import_failure, extension_id="import-failure") + (import_failure / "generator.py").write_text( + "raise RuntimeError('intentional discovery failure')\n", + encoding="utf-8", + ) + + class_failure = self._make_extension("class-failure") + self._write_manifest(class_failure, extension_id="class-failure") + (class_failure / "generator.py").write_text( + "class DifferentGenerator:\n pass\n", + encoding="utf-8", + ) + + self.registry.initialize() + + errors = self.registry.load_errors() + self.assertIn("missing-manifest", errors) + self.assertIn("invalid-manifest", errors) + self.assertIn("non-object-manifest", errors) + self.assertIn("missing-generator/generate", errors) + self.assertIn("import-failure/generate", errors) + self.assertIn("class-failure/generate", errors) + self.assertIn("missing manifest.json", errors["missing-manifest"]) + self.assertIn("invalid manifest.json", errors["invalid-manifest"]) + self.assertIn("expected an object", errors["non-object-manifest"]) + self.assertIn("missing generator.py", errors["missing-generator/generate"]) + self.assertIn("intentional discovery failure", errors["import-failure/generate"]) + self.assertIn("TestGenerator", errors["class-failure/generate"]) + + def test_incomplete_model_install_is_reported_for_each_node(self) -> None: + extension = self._make_extension("interrupted") + self._write_manifest(extension, extension_id="interrupted", node_ids=("one", "two")) + (extension / "generator.py").write_text("", encoding="utf-8") + (extension / ".modly-incomplete").write_text("installing", encoding="utf-8") + + self.registry.initialize() + + errors = self.registry.load_errors() + self.assertEqual(set(errors), {"interrupted/one", "interrupted/two"}) + self.assertTrue(all("incomplete installation" in message for message in errors.values())) + + def test_pending_registration_is_not_loaded_when_startup_restore_cannot_finish(self) -> None: + extension = self._make_extension("pending-registration") + self._write_manifest(extension, extension_id="pending-registration") + (extension / "generator.py").write_text( + "class TestGenerator:\n" + " def __init__(self, *args, **kwargs):\n" + " raise AssertionError('pending extension must not be loaded')\n", + encoding="utf-8", + ) + (self.extensions_dir / ".modly-registration-pending-pending-registration-100").write_text( + "validating", + encoding="utf-8", + ) + + self.registry.initialize() + + errors = self.registry.load_errors() + self.assertEqual(set(errors), {"pending-registration/generate"}) + self.assertIn("interrupted runtime registration", next(iter(errors.values()))) + + def test_pending_sidecar_blocks_generators_that_were_already_loaded(self) -> None: + extension = self._make_extension("live-before-repair") + self._write_manifest(extension, extension_id="live-before-repair") + (extension / "generator.py").write_text( + "\n".join( + [ + "from services.generators.base import BaseGenerator", + "class TestGenerator(BaseGenerator):", + " def load(self): pass", + " def generate(self, image_bytes, params, progress_cb=None, cancel_event=None):", + " return self.outputs_dir / 'result.glb'", + ] + ), + encoding="utf-8", + ) + self.registry.initialize() + model_id = "live-before-repair/generate" + self.assertIn(model_id, self.registry._generators) + + self._write_registration_capability("live-before-repair") + + with self.assertRaisesRegex(ValueError, "pending runtime registration"): + self.registry.get_generator(model_id) + with self.assertRaisesRegex(ValueError, "pending runtime registration"): + self.registry.switch_model(model_id) + with self.assertRaisesRegex(ValueError, "pending runtime registration"): + self.registry.get_active() + + def test_valid_capability_authorizes_exact_pending_extension_once(self) -> None: + capability = self._make_loadable_pending_extension("pending-update") + + self.registry.reload(capability) + self.assertIn("pending-update/generate", self.registry._generators) + self.assertEqual(self.registry.load_errors(), {}) + consumed = json.loads( + (self.extensions_dir / capability["stateName"]).read_text(encoding="utf-8") + ) + self.assertEqual( + consumed, + { + "version": 1, + "extensionId": "pending-update", + "destinationName": "pending-update", + "consumed": True, + }, + ) + + self.registry.reload(capability) + self.assertNotIn("pending-update/generate", self.registry._generators) + self.assertIn("pending-update/generate", self.registry.load_errors()) + + def test_public_reload_and_predictable_id_cannot_bypass_pending_state(self) -> None: + self._make_loadable_pending_extension("pending-public") + + self.registry.reload() + self.assertNotIn("pending-public/generate", self.registry._generators) + self.assertIn("pending-public/generate", self.registry.load_errors()) + + self.registry.reload({"validatingExtensionId": "pending-public"}) + self.assertNotIn("pending-public/generate", self.registry._generators) + self.assertIn("pending-public/generate", self.registry.load_errors()) + + def test_wrong_capability_token_cannot_bypass_pending_state(self) -> None: + capability = self._make_loadable_pending_extension("pending-token") + capability["token"] = "x" * 43 + + self.registry.reload(capability) + + self.assertNotIn("pending-token/generate", self.registry._generators) + self.assertIn("pending-token/generate", self.registry.load_errors()) + + def test_capability_id_and_sidecar_path_must_match(self) -> None: + capability = self._make_loadable_pending_extension("pending-path") + capability["extensionId"] = "another-extension" + + self.registry.reload(capability) + + self.assertNotIn("pending-path/generate", self.registry._generators) + self.assertIn("pending-path/generate", self.registry.load_errors()) + + def test_capability_destination_folder_must_match(self) -> None: + capability = self._make_loadable_pending_extension("pending-destination") + capability["destinationName"] = "another-extension" + + self.registry.reload(capability) + + self.assertNotIn("pending-destination/generate", self.registry._generators) + self.assertIn("pending-destination/generate", self.registry.load_errors()) + + def test_authorization_rejects_duplicate_folder_declaring_same_manifest_id(self) -> None: + capability = self._make_loadable_pending_extension("pixal3d") + duplicate = self._make_extension("zzz-duplicate") + self._write_manifest(duplicate, extension_id="pixal3d") + (duplicate / "generator.py").write_text( + "raise AssertionError('mismatched folder must not be imported')\n", + encoding="utf-8", + ) + + self.registry.reload(capability) + + self.assertIn("pixal3d/generate", self.registry._generators) + self.assertIn("zzz-duplicate", self.registry.load_errors()) + self.assertIn("must match", self.registry.load_errors()["zzz-duplicate"]) + + @unittest.skipIf(os.name == "nt", "POSIX mode bits are not enforced on Windows") + def test_capability_sidecar_with_group_or_other_permissions_is_rejected(self) -> None: + capability = self._make_loadable_pending_extension("pending-mode") + state_path = self.extensions_dir / capability["stateName"] + state_path.chmod(0o644) + + self.registry.reload(capability) + + self.assertNotIn("pending-mode/generate", self.registry._generators) + self.assertIn("pending-mode/generate", self.registry.load_errors()) + + def test_hard_linked_capability_sidecar_is_rejected(self) -> None: + capability = self._make_loadable_pending_extension("pending-hardlink") + state_path = self.extensions_dir / capability["stateName"] + os.link(state_path, self.extensions_dir / "capability-hardlink-copy") + + self.registry.reload(capability) + + self.assertNotIn("pending-hardlink/generate", self.registry._generators) + self.assertIn("pending-hardlink/generate", self.registry.load_errors()) + + @unittest.skipIf(os.name == "nt", "symlink creation may require privileges on Windows") + def test_symlinked_capability_sidecar_is_rejected(self) -> None: + capability = self._make_loadable_pending_extension("pending-symlink") + state_path = self.extensions_dir / capability["stateName"] + target = self.extensions_dir / "capability-symlink-target" + state_path.rename(target) + state_path.symlink_to(target) + + self.registry.reload(capability) + + self.assertNotIn("pending-symlink/generate", self.registry._generators) + self.assertIn("pending-symlink/generate", self.registry.load_errors()) + + def test_process_extension_is_skipped_without_model_errors(self) -> None: + extension = self._make_extension("process-only") + self._write_manifest( + extension, + extension_id="process-only", + extension_type="process", + ) + (extension / "processor.py").write_text("print('ok')\n", encoding="utf-8") + (extension / ".modly-incomplete").write_text("installing", encoding="utf-8") + + self.registry.initialize() + + self.assertEqual(self.registry._generators, {}) + self.assertEqual(self.registry.load_errors(), {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/electron/main/extension-install-recovery.test.mjs b/electron/main/extension-install-recovery.test.mjs new file mode 100644 index 00000000..9a317d9f --- /dev/null +++ b/electron/main/extension-install-recovery.test.mjs @@ -0,0 +1,674 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildSync } from 'esbuild' +import { createRequire } from 'node:module' +import { + chmodSync, + existsSync, + linkSync, + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +function loadModule() { + const outfile = join(mkdtempSync(join(tmpdir(), 'modly-recovery-module-')), 'extension-install-recovery.cjs') + const require = createRequire(import.meta.url) + const result = buildSync({ + entryPoints: [resolve('electron/main/extension-install-recovery.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + }) + writeFileSync(outfile, result.outputFiles[0].text, 'utf8') + return require(outfile) +} + +test('startup reconciliation removes all orphan incomplete folders, including corrupted names', async () => { + const root = mkdtempSync(join(tmpdir(), 'modly-recovery-test-')) + const corrupted = join(root, 'Broken Extension') + const valid = join(root, 'later-extension') + mkdirSync(corrupted) + mkdirSync(valid) + writeFileSync(join(corrupted, '.modly-incomplete'), 'installing', 'utf8') + writeFileSync(join(valid, '.modly-incomplete'), 'installing', 'utf8') + + try { + const mod = loadModule() + await mod.reconcileInterruptedExtensionInstalls(root) + + assert.equal(existsSync(corrupted), false) + assert.equal(existsSync(valid), false) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('failed update rollback replaces the unvalidated destination with its backup', async () => { + const root = mkdtempSync(join(tmpdir(), 'modly-rollback-test-')) + const destination = join(root, 'pixal3d') + const backup = join(root, '.modly-backup-pixal3d-100') + mkdirSync(destination) + mkdirSync(backup) + writeFileSync(join(destination, 'version.txt'), 'new-broken', 'utf8') + writeFileSync(join(destination, '.modly-registration-pending'), 'pending', 'utf8') + writeFileSync(join(backup, 'version.txt'), 'old-working', 'utf8') + + try { + const mod = loadModule() + const result = await mod.restoreExtensionBackup(destination, backup) + + assert.deepEqual(result, { ok: true }) + assert.equal(readFileSync(join(destination, 'version.txt'), 'utf8'), 'old-working') + assert.equal(existsSync(join(destination, '.modly-registration-pending')), false) + assert.equal(existsSync(backup), false) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('startup restores a parked version when registration was interrupted', async () => { + const root = mkdtempSync(join(tmpdir(), 'modly-pending-recovery-test-')) + const destination = join(root, 'pixal3d') + const backup = join(root, '.modly-backup-pixal3d-101') + mkdirSync(destination) + mkdirSync(backup) + writeFileSync(join(destination, 'version.txt'), 'new-unvalidated', 'utf8') + writeFileSync(join(backup, 'version.txt'), 'old-working', 'utf8') + writeFileSync(join(root, '.modly-registration-pending-pixal3d-101'), 'pending', 'utf8') + + try { + const mod = loadModule() + await mod.reconcileInterruptedExtensionInstalls(root) + + assert.equal(readFileSync(join(destination, 'version.txt'), 'utf8'), 'old-working') + assert.equal(existsSync(backup), false) + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-101')), + false, + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('update validation keeps pending state hidden from the destination until commit', async () => { + const root = mkdtempSync(join(tmpdir(), 'modly-update-validation-test-')) + const destination = join(root, 'pixal3d') + const backup = join(root, '.modly-backup-pixal3d-104') + mkdirSync(destination) + mkdirSync(backup) + writeFileSync(join(destination, '.modly-incomplete'), 'setup', 'utf8') + + try { + const mod = loadModule() + const transaction = await mod.beginExtensionRegistrationTransaction( + root, + 'pixal3d', + '104', + ) + assert.equal(transaction.ok, true) + assert.equal(transaction.validationCapability.extensionId, 'pixal3d') + assert.equal(transaction.validationCapability.destinationName, 'pixal3d') + assert.equal( + transaction.validationCapability.stateName, + '.modly-registration-pending-pixal3d-104', + ) + assert.ok(transaction.validationCapability.token.length >= 32) + const capabilityStat = statSync( + join(root, '.modly-registration-pending-pixal3d-104'), + ) + assert.equal(capabilityStat.nlink, 1) + if (process.platform !== 'win32') { + assert.equal(capabilityStat.mode & 0o077, 0) + } + + let validatorCalled = false + await mod.validateExtensionDestinationRegistration( + destination, + async () => { + validatorCalled = true + assert.equal(existsSync(join(destination, '.modly-incomplete')), false) + assert.equal(existsSync(join(destination, '.modly-registration-pending')), false) + assert.equal(existsSync(join(backup, '.modly-registration-pending')), false) + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-104')), + true, + ) + }, + 'test-update', + ) + + assert.equal(validatorCalled, true) + assert.deepEqual( + await mod.cleanupValidatedExtensionBackups(root, 'pixal3d'), + { ok: true }, + ) + assert.equal(existsSync(backup), false) + assert.equal(existsSync(join(destination, '.modly-registration-validated')), false) + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-104')), + false, + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('Repair transaction starts before setup and keeps state outside the destination', async () => { + const root = mkdtempSync(join(tmpdir(), 'modly-repair-validation-test-')) + const destination = join(root, 'pixal3d') + const backup = join(root, '.modly-backup-pixal3d-105') + mkdirSync(destination) + mkdirSync(backup) + + try { + const mod = loadModule() + const order = [] + let runtimeLoaded = true + let setupCalled = false + let validatorCalled = false + await mod.runExtensionRepairTransaction({ + extensionsDir: root, + extensionId: 'pixal3d', + destinationDir: destination, + suffix: '105', + quarantine: async () => { + order.push('quarantine') + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-105')), + true, + ) + runtimeLoaded = false + }, + setup: async () => { + order.push('setup') + setupCalled = true + assert.equal(runtimeLoaded, false) + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-105')), + true, + ) + assert.equal(existsSync(join(destination, '.modly-registration-pending')), false) + assert.equal(existsSync(join(backup, '.modly-registration-pending')), false) + }, + validate: async (validationCapability) => { + order.push('validate') + validatorCalled = true + assert.equal(validationCapability.extensionId, 'pixal3d') + assert.equal( + validationCapability.stateName, + '.modly-registration-pending-pixal3d-105', + ) + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-105')), + true, + ) + }, + }) + + assert.equal(setupCalled, true) + assert.equal(validatorCalled, true) + assert.deepEqual(order, ['quarantine', 'setup', 'validate']) + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-105')), + false, + ) + assert.equal(existsSync(backup), false) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('Repair setup failure retains pending quarantine and never validates runtime', async () => { + const root = mkdtempSync(join(tmpdir(), 'modly-repair-setup-failure-test-')) + const destination = join(root, 'pixal3d') + mkdirSync(destination) + + try { + const mod = loadModule() + let runtimeLoaded = true + let validatorCalled = false + + await assert.rejects( + mod.runExtensionRepairTransaction({ + extensionsDir: root, + extensionId: 'pixal3d', + destinationDir: destination, + suffix: '109', + quarantine: async () => { + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-109')), + true, + ) + runtimeLoaded = false + }, + setup: async () => { + assert.equal(runtimeLoaded, false) + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-109')), + true, + ) + throw new Error('setup exploded') + }, + validate: async () => { + validatorCalled = true + }, + }), + /setup exploded/, + ) + + assert.equal(validatorCalled, false) + assert.equal(runtimeLoaded, false) + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-109')), + true, + ) + assert.equal(existsSync(join(destination, '.modly-registration-pending')), false) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('Repair validation failure re-evicts partially registered runtime state', async () => { + const root = mkdtempSync(join(tmpdir(), 'modly-repair-validation-failure-test-')) + const destination = join(root, 'pixal3d') + mkdirSync(destination) + + try { + const mod = loadModule() + let runtimeLoaded = true + let quarantineCount = 0 + + await assert.rejects( + mod.runExtensionRepairTransaction({ + extensionsDir: root, + extensionId: 'pixal3d', + destinationDir: destination, + suffix: '113', + quarantine: async () => { + quarantineCount += 1 + runtimeLoaded = false + }, + setup: async () => { + assert.equal(runtimeLoaded, false) + }, + validate: async () => { + runtimeLoaded = true + throw new Error('one node failed registration') + }, + }), + /one node failed registration/, + ) + + assert.equal(quarantineCount, 2) + assert.equal(runtimeLoaded, false) + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-113')), + true, + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('fresh-install crash keeps a complete destination externally quarantined', async () => { + const root = mkdtempSync(join(tmpdir(), 'modly-fresh-crash-test-')) + const destination = join(root, 'pixal3d') + mkdirSync(destination) + writeFileSync(join(destination, 'version.txt'), 'unvalidated', 'utf8') + + try { + const mod = loadModule() + const transaction = await mod.beginExtensionRegistrationTransaction( + root, + 'pixal3d', + '106', + ) + assert.equal(transaction.ok, true) + + await mod.reconcileInterruptedExtensionInstalls(root) + + assert.equal(readFileSync(join(destination, 'version.txt'), 'utf8'), 'unvalidated') + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-106')), + true, + ) + assert.equal(existsSync(join(destination, '.modly-registration-pending')), false) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('new registration capability replaces older pending attempts without a quarantine gap', async () => { + const root = mkdtempSync(join(tmpdir(), 'modly-capability-rotation-test-')) + const destination = join(root, 'pixal3d') + mkdirSync(destination) + + try { + const mod = loadModule() + const first = await mod.beginExtensionRegistrationTransaction( + root, + 'pixal3d', + '111', + ) + const second = await mod.beginExtensionRegistrationTransaction( + root, + 'pixal3d', + '112', + ) + + assert.equal(first.ok, true) + assert.equal(second.ok, true) + assert.notEqual( + first.validationCapability.token, + second.validationCapability.token, + ) + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-111')), + false, + ) + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-112')), + true, + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('registration capability creation never overwrites a pre-existing sidecar', async () => { + const root = mkdtempSync(join(tmpdir(), 'modly-capability-exclusive-test-')) + const statePath = join(root, '.modly-registration-pending-pixal3d-114') + writeFileSync(statePath, 'pre-existing', 'utf8') + if (process.platform !== 'win32') chmodSync(statePath, 0o644) + + try { + const mod = loadModule() + const result = await mod.beginExtensionRegistrationTransaction( + root, + 'pixal3d', + '114', + ) + + assert.equal(result.ok, false) + assert.equal(readFileSync(statePath, 'utf8'), 'pre-existing') + if (process.platform !== 'win32') { + assert.equal(statSync(statePath).mode & 0o777, 0o644) + } + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('registration capability creation rejects a hard-linked target', async () => { + const root = mkdtempSync(join(tmpdir(), 'modly-capability-hardlink-test-')) + const original = join(root, 'original') + const statePath = join(root, '.modly-registration-pending-pixal3d-115') + writeFileSync(original, 'pre-existing', 'utf8') + linkSync(original, statePath) + + try { + const mod = loadModule() + const result = await mod.beginExtensionRegistrationTransaction( + root, + 'pixal3d', + '115', + ) + + assert.equal(result.ok, false) + assert.equal(readFileSync(original, 'utf8'), 'pre-existing') + assert.equal(statSync(original).nlink, 2) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('no-backup Repair failure preserves external quarantine state', async () => { + const root = mkdtempSync(join(tmpdir(), 'modly-repair-failure-test-')) + const destination = join(root, 'pixal3d') + mkdirSync(destination) + + try { + const mod = loadModule() + assert.deepEqual( + await mod.quarantineExtensionRegistrationFailure(root, 'pixal3d'), + { ok: true }, + ) + + const pendingNames = readdirSync(root).filter((name) => + name.startsWith('.modly-registration-pending-pixal3d-'), + ) + assert.equal(pendingNames.length, 1) + assert.equal(existsSync(join(destination, '.modly-registration-pending')), false) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('failed local registration validation persists quarantine for restart', async () => { + const root = mkdtempSync(join(tmpdir(), 'modly-local-validation-test-')) + const destination = join(root, 'pixal3d') + mkdirSync(destination) + + try { + const mod = loadModule() + let receivedCapability = null + let runtimeLoaded = true + let quarantineCount = 0 + + await assert.rejects( + mod.runExtensionRegistrationValidationTransaction({ + extensionsDir: root, + extensionId: 'pixal3d', + suffix: '110', + quarantine: async () => { + quarantineCount += 1 + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-110')), + true, + ) + runtimeLoaded = false + }, + activate: async () => { + assert.equal(runtimeLoaded, false) + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-110')), + true, + ) + }, + validate: async (validationCapability) => { + assert.equal(runtimeLoaded, false) + receivedCapability = validationCapability + runtimeLoaded = true + throw new Error('venv not found') + }, + }), + /venv not found/, + ) + + assert.equal(receivedCapability.extensionId, 'pixal3d') + assert.equal(runtimeLoaded, false) + assert.equal(quarantineCount, 2) + assert.equal( + receivedCapability.stateName, + '.modly-registration-pending-pixal3d-110', + ) + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-110')), + true, + ) + + await mod.reconcileInterruptedExtensionInstalls(root) + assert.equal(existsSync(destination), true) + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-110')), + true, + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('local link transaction creates quarantine before any destination mutation', async () => { + const root = mkdtempSync(join(tmpdir(), 'modly-local-ordering-test-')) + + try { + const mod = loadModule() + const order = [] + let validatorCalled = false + + await assert.rejects( + mod.runExtensionRegistrationValidationTransaction({ + extensionsDir: root, + extensionId: 'pixal3d', + suffix: '116', + quarantine: async () => { + order.push('quarantine') + }, + activate: async () => { + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-116')), + true, + ) + order.push('mutate-link') + throw new Error('simulated crash during link replacement') + }, + validate: async () => { + validatorCalled = true + }, + }), + /simulated crash during link replacement/, + ) + + assert.deepEqual(order, ['quarantine', 'mutate-link', 'quarantine']) + assert.equal(validatorCalled, false) + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-116')), + true, + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('uninstall removes backups before the destination so restart cannot resurrect it', async () => { + const root = mkdtempSync(join(tmpdir(), 'modly-uninstall-recovery-test-')) + const destination = join(root, 'pixal3d') + const backup = join(root, '.modly-backup-pixal3d-102') + mkdirSync(destination) + mkdirSync(backup) + writeFileSync(join(destination, 'version.txt'), 'current', 'utf8') + writeFileSync(join(backup, 'version.txt'), 'previous', 'utf8') + writeFileSync(join(root, '.modly-registration-pending-pixal3d-102'), 'pending', 'utf8') + + try { + const mod = loadModule() + const result = await mod.removeExtensionWithBackups(root, 'pixal3d') + + assert.deepEqual(result, { ok: true }) + assert.equal(existsSync(destination), false) + assert.equal(existsSync(backup), false) + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-102')), + false, + ) + + await mod.reconcileInterruptedExtensionInstalls(root) + assert.equal(existsSync(destination), false) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('startup completes validated backup cleanup without replacing the destination', async () => { + const root = mkdtempSync(join(tmpdir(), 'modly-validated-cleanup-test-')) + const destination = join(root, 'pixal3d') + const backup = join(root, '.modly-backup-pixal3d-103') + mkdirSync(destination) + mkdirSync(backup) + writeFileSync(join(destination, 'version.txt'), 'new-working', 'utf8') + writeFileSync(join(backup, 'version.txt'), 'old-working', 'utf8') + writeFileSync(join(root, '.modly-registration-pending-pixal3d-103'), 'pending', 'utf8') + writeFileSync(join(root, '.modly-registration-validated-pixal3d-104'), 'validated', 'utf8') + + try { + const mod = loadModule() + await mod.reconcileInterruptedExtensionInstalls(root) + + assert.equal(readFileSync(join(destination, 'version.txt'), 'utf8'), 'new-working') + assert.equal(existsSync(backup), false) + assert.equal(existsSync(join(destination, '.modly-registration-validated')), false) + assert.equal( + readdirSync(root).some((name) => name.startsWith('.modly-registration-')), + false, + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('destination validated marker cannot authorize deletion of an uncommitted backup', async () => { + const root = mkdtempSync(join(tmpdir(), 'modly-forged-validated-test-')) + const destination = join(root, 'pixal3d') + const backup = join(root, '.modly-backup-pixal3d-107') + mkdirSync(destination) + mkdirSync(backup) + writeFileSync(join(destination, 'version.txt'), 'unvalidated', 'utf8') + writeFileSync(join(destination, '.modly-registration-validated'), 'packaged', 'utf8') + writeFileSync(join(backup, 'version.txt'), 'old-working', 'utf8') + + try { + const mod = loadModule() + await mod.reconcileInterruptedExtensionInstalls(root) + + assert.equal(readFileSync(join(destination, 'version.txt'), 'utf8'), 'unvalidated') + assert.equal(readFileSync(join(backup, 'version.txt'), 'utf8'), 'old-working') + assert.equal(existsSync(join(destination, '.modly-registration-validated')), false) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('registration transaction state never writes through a symlinked backup', async () => { + const root = mkdtempSync(join(tmpdir(), 'modly-symlink-state-test-')) + const source = mkdtempSync(join(tmpdir(), 'modly-linked-source-')) + const destination = join(root, 'pixal3d') + const backup = join(root, '.modly-backup-pixal3d-108') + mkdirSync(destination) + symlinkSync(source, backup, 'dir') + + try { + const mod = loadModule() + const transaction = await mod.beginExtensionRegistrationTransaction( + root, + 'pixal3d', + '108', + ) + assert.equal(transaction.ok, true) + + assert.equal(existsSync(join(source, '.modly-registration-pending')), false) + assert.equal( + existsSync(join(root, '.modly-registration-pending-pixal3d-108')), + true, + ) + + assert.deepEqual( + await mod.cleanupValidatedExtensionBackups(root, 'pixal3d'), + { ok: true }, + ) + assert.equal(existsSync(source), true) + assert.equal(existsSync(join(source, '.modly-registration-pending')), false) + } finally { + rmSync(root, { recursive: true, force: true }) + rmSync(source, { recursive: true, force: true }) + } +}) diff --git a/electron/main/extension-install-recovery.ts b/electron/main/extension-install-recovery.ts new file mode 100644 index 00000000..58add1ba --- /dev/null +++ b/electron/main/extension-install-recovery.ts @@ -0,0 +1,762 @@ +import { existsSync } from 'fs' +import { open, readdir, rename, rm as rmAsync, writeFile } from 'fs/promises' +import { randomBytes } from 'crypto' +import { basename, join } from 'path' +import { + EXT_BACKUP_PREFIX, + EXT_INCOMPLETE_MARKER, + EXT_REGISTRATION_PENDING_MARKER, + EXT_STAGING_PREFIX, + EXT_VALIDATED_MARKER, + assertSafeExtensionId, + isInternalExtensionDirName, + parseExtensionBackupName, + resolveExtensionPathWithinRoot, + resolvePathWithinRoot, +} from './extension-path-guard' +import { incompleteInstallRecoveryAction } from './extension-install-utils' + +const FS_RETRY_DELAYS_MS = [200, 500, 1500, 2500] +const REGISTRATION_PENDING_PREFIX = `${EXT_REGISTRATION_PENDING_MARKER}-` +const REGISTRATION_VALIDATED_PREFIX = `${EXT_VALIDATED_MARKER}-` + +export interface InstallRecoveryLogger { + info: (message: string) => void + warn: (message: string) => void +} + +const consoleLogger: InstallRecoveryLogger = { + info: (message) => console.log(message), + warn: (message) => console.warn(message), +} + +function isLockedFsError(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException).code + return code === 'EBUSY' || code === 'EPERM' || code === 'EACCES' +} + +export type FsRetryResult = + | { ok: true } + | { ok: false; locked: boolean; error: unknown } + +export type ExtensionMutationResult = + | { ok: true } + | { + ok: false + stage: 'scan' | 'backup' | 'destination' | 'marker' + locked: boolean + error: unknown + } + +export interface ExtensionRegistrationValidationCapability { + extensionId: string + destinationName: string + stateName: string + token: string +} + +export type ExtensionRegistrationTransactionResult = + | { + ok: true + validationCapability: ExtensionRegistrationValidationCapability + } + | Exclude + +async function fsWithRetry( + op: () => Promise, + label: string, + target: string, + log: InstallRecoveryLogger, +): Promise { + for (let attempt = 0; ; attempt++) { + try { + await op() + return { ok: true } + } catch (err) { + if (!isLockedFsError(err) || attempt === FS_RETRY_DELAYS_MS.length) { + log.warn(`[${label}] ${target}: ${err}`) + return { ok: false, locked: isLockedFsError(err), error: err } + } + await new Promise((resolve) => setTimeout(resolve, FS_RETRY_DELAYS_MS[attempt])) + } + } +} + +export const rmWithRetry = ( + path: string, + label: string, + log: InstallRecoveryLogger = consoleLogger, +): Promise => + fsWithRetry(() => rmAsync(path, { recursive: true, force: true }), label, path, log) + +export const renameWithRetry = ( + from: string, + to: string, + label: string, + log: InstallRecoveryLogger = consoleLogger, +): Promise => + fsWithRetry(() => rename(from, to), label, `${from} -> ${to}`, log) + +const writeMarkerWithRetry = ( + path: string, + label: string, + log: InstallRecoveryLogger, + content = new Date().toISOString(), +): Promise => + fsWithRetry( + () => writeFile(path, content, { encoding: 'utf-8', mode: 0o600 }), + label, + path, + log, + ) + +const writeCapabilityMarkerWithRetry = ( + path: string, + content: string, + log: InstallRecoveryLogger, +): Promise => + fsWithRetry( + async () => { + const handle = await open(path, 'wx', 0o600) + try { + if (process.platform !== 'win32') await handle.chmod(0o600) + await handle.writeFile(content, { encoding: 'utf-8' }) + await handle.sync() + const fileStat = await handle.stat() + if (!fileStat.isFile() || fileStat.nlink !== 1) { + throw new Error('Registration capability sidecar must be a single regular file') + } + if ( + process.platform !== 'win32' + && ( + (fileStat.mode & 0o077) !== 0 + || (typeof process.getuid === 'function' && fileStat.uid !== process.getuid()) + ) + ) { + throw new Error('Registration capability sidecar permissions are unsafe') + } + } finally { + await handle.close() + } + }, + 'ext-install', + path, + log, + ) + +function failedMutation( + stage: Exclude['stage'], + result: Exclude, +): Exclude { + return { + ok: false, + stage, + locked: result.locked, + error: result.error, + } +} + +async function extensionBackupPaths( + extensionsDir: string, + extensionId: string, +): Promise< + | { ok: true; paths: string[] } + | { ok: false; stage: 'scan'; locked: boolean; error: unknown } +> { + let names: string[] + try { + names = await readdir(extensionsDir) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { ok: true, paths: [] } + } + return { + ok: false, + stage: 'scan', + locked: isLockedFsError(error), + error, + } + } + + return { + ok: true, + paths: names + .filter((name) => parseExtensionBackupName(name)?.extensionId === extensionId) + .sort() + .reverse() + .map((name) => join(extensionsDir, name)), + } +} + +function parseRegistrationStateName( + name: string, + prefix: string, +): { extensionId: string } | null { + if (!name.startsWith(prefix)) return null + const match = name.slice(prefix.length).match(/^(.+)-\d+$/) + if (!match) return null + try { + return { extensionId: assertSafeExtensionId(match[1]) } + } catch { + return null + } +} + +export const parseExtensionRegistrationPendingName = ( + name: string, +): { extensionId: string } | null => + parseRegistrationStateName(name, REGISTRATION_PENDING_PREFIX) + +function buildRegistrationStatePath( + extensionsDir: string, + extensionId: string, + suffix: string, + prefix: string, +): string { + const safeId = assertSafeExtensionId(extensionId) + if (!/^\d+$/.test(suffix)) throw new Error('Registration state suffix must be numeric') + return resolvePathWithinRoot(extensionsDir, `${prefix}${safeId}-${suffix}`) +} + +async function registrationStatePaths( + extensionsDir: string, + extensionId: string, + prefix: string, +): Promise< + | { ok: true; paths: string[] } + | { ok: false; stage: 'scan'; locked: boolean; error: unknown } +> { + let names: string[] + try { + names = await readdir(extensionsDir) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { ok: true, paths: [] } + } + return { + ok: false, + stage: 'scan', + locked: isLockedFsError(error), + error, + } + } + + return { + ok: true, + paths: names + .filter((name) => parseRegistrationStateName(name, prefix)?.extensionId === extensionId) + .sort() + .reverse() + .map((name) => join(extensionsDir, name)), + } +} + +async function removeRegistrationState( + extensionsDir: string, + extensionId: string, + log: InstallRecoveryLogger, +): Promise { + for (const prefix of [REGISTRATION_PENDING_PREFIX, REGISTRATION_VALIDATED_PREFIX]) { + const states = await registrationStatePaths(extensionsDir, extensionId, prefix) + if (!states.ok) return states + for (const statePath of states.paths) { + const removed = await rmWithRetry(statePath, 'ext-cleanup', log) + if (!removed.ok) return failedMutation('marker', removed) + } + } + return { ok: true } +} + +export const clearExtensionRegistrationTransaction = ( + extensionsDir: string, + extensionId: string, + log: InstallRecoveryLogger = consoleLogger, +): Promise => + removeRegistrationState(extensionsDir, extensionId, log) + +export async function restoreExtensionBackup( + destinationDir: string, + backupDir: string, + log: InstallRecoveryLogger = consoleLogger, +): Promise { + const removed = await rmWithRetry(destinationDir, 'ext-restore', log) + if (!removed.ok) return failedMutation('destination', removed) + + const restored = await renameWithRetry(backupDir, destinationDir, 'ext-restore', log) + if (!restored.ok) return failedMutation('backup', restored) + + return { ok: true } +} + +export async function beginExtensionRegistrationTransaction( + extensionsDir: string, + extensionId: string, + suffix: string, + log: InstallRecoveryLogger = consoleLogger, +): Promise { + const safeExtensionId = assertSafeExtensionId(extensionId) + const pendingPath = buildRegistrationStatePath( + extensionsDir, + safeExtensionId, + suffix, + REGISTRATION_PENDING_PREFIX, + ) + const validationCapability: ExtensionRegistrationValidationCapability = { + extensionId: safeExtensionId, + destinationName: safeExtensionId, + stateName: basename(pendingPath), + token: randomBytes(32).toString('base64url'), + } + const marked = await writeCapabilityMarkerWithRetry( + pendingPath, + JSON.stringify({ + version: 1, + extensionId: safeExtensionId, + destinationName: safeExtensionId, + token: validationCapability.token, + consumed: false, + }), + log, + ) + if (!marked.ok) return failedMutation('marker', marked) + + // The capability authorizes exactly one root sidecar. Keep the new marker in + // place while removing older attempts so there is never an unquarantined + // window, and a capability cannot implicitly bypass another transaction. + const pendingStates = await registrationStatePaths( + extensionsDir, + safeExtensionId, + REGISTRATION_PENDING_PREFIX, + ) + if (!pendingStates.ok) return pendingStates + for (const statePath of pendingStates.paths) { + if (statePath === pendingPath) continue + const removed = await rmWithRetry(statePath, 'ext-install', log) + if (!removed.ok) return failedMutation('marker', removed) + } + + return { ok: true, validationCapability } +} + +export async function quarantineExtensionRegistrationFailure( + extensionsDir: string, + extensionId: string, + log: InstallRecoveryLogger = consoleLogger, +): Promise { + const pending = await registrationStatePaths( + extensionsDir, + extensionId, + REGISTRATION_PENDING_PREFIX, + ) + if (!pending.ok) return pending + if (pending.paths.length > 0) return { ok: true } + const started = await beginExtensionRegistrationTransaction( + extensionsDir, + extensionId, + String(Date.now()), + log, + ) + return started.ok ? { ok: true } : started +} + +export async function runExtensionRepairTransaction( + options: { + extensionsDir: string + extensionId: string + destinationDir: string + suffix: string + quarantine: () => Promise + setup: () => Promise + validate: (capability: ExtensionRegistrationValidationCapability) => Promise + }, + log: InstallRecoveryLogger = consoleLogger, +): Promise { + // Repair can mutate an existing environment before runtime validation. Mark + // it pending first so a setup failure or process crash leaves the extension + // externally quarantined for the next startup. + const pendingRegistration = await beginExtensionRegistrationTransaction( + options.extensionsDir, + options.extensionId, + options.suffix, + log, + ) + if (!pendingRegistration.ok) { + throw new Error( + `Could not preserve the extension rollback state during ${pendingRegistration.stage}: ` + + `${String(pendingRegistration.error)}`, + ) + } + + // Evict any already-loaded generator before setup mutates its environment. + // Failure here is also fail-closed: pending state remains for restart/UI + // recovery, and setup never begins while stale runtime code is live. + await options.quarantine() + + // Intentionally do not clear pending state when quarantine or setup fails. A + // partially modified environment has not been proven safe to discover or + // execute. + await options.setup() + + try { + await validateExtensionDestinationRegistration( + options.destinationDir, + () => options.validate(pendingRegistration.validationCapability), + 'ext-repair', + log, + ) + } catch (registrationError) { + const quarantined = await quarantineExtensionRegistrationFailure( + options.extensionsDir, + options.extensionId, + log, + ) + if (!quarantined.ok) { + throw new Error( + `Runtime registration failed, and Modly could not preserve quarantine ` + + `during ${quarantined.stage}: ${String(quarantined.error)}. ` + + `Original failure: ${String(registrationError)}`, + ) + } + try { + await options.quarantine() + } catch (runtimeQuarantineError) { + throw new Error( + `Runtime registration failed, and Modly preserved filesystem quarantine but ` + + `could not evict partially registered runtime state: ` + + `${String(runtimeQuarantineError)}. Original failure: ${String(registrationError)}`, + ) + } + throw registrationError + } + + const cleaned = await cleanupValidatedExtensionBackups( + options.extensionsDir, + options.extensionId, + log, + ) + if (!cleaned.ok) { + throw new Error( + `Runtime registration succeeded, but Modly could not finish removing the previous ` + + `extension backup during ${cleaned.stage}: ${String(cleaned.error)}. ` + + `Restart Modly to retry the validated cleanup.`, + ) + } +} + +export async function runExtensionRegistrationValidationTransaction( + options: { + extensionsDir: string + extensionId: string + suffix: string + quarantine: () => Promise + activate: () => Promise + validate: (capability: ExtensionRegistrationValidationCapability) => Promise + }, + log: InstallRecoveryLogger = consoleLogger, +): Promise { + const pendingRegistration = await beginExtensionRegistrationTransaction( + options.extensionsDir, + options.extensionId, + options.suffix, + log, + ) + if (!pendingRegistration.ok) { + throw new Error( + `Could not preserve extension quarantine during ${pendingRegistration.stage}: ` + + `${String(pendingRegistration.error)}`, + ) + } + + await options.quarantine() + + try { + await options.activate() + await options.validate(pendingRegistration.validationCapability) + } catch (registrationError) { + const quarantined = await quarantineExtensionRegistrationFailure( + options.extensionsDir, + options.extensionId, + log, + ) + if (!quarantined.ok) { + throw new Error( + `Runtime registration failed, and Modly could not preserve quarantine ` + + `during ${quarantined.stage}: ${String(quarantined.error)}. ` + + `Original failure: ${String(registrationError)}`, + ) + } + try { + await options.quarantine() + } catch (runtimeQuarantineError) { + throw new Error( + `Runtime registration failed, and Modly preserved filesystem quarantine but ` + + `could not evict partially registered runtime state: ` + + `${String(runtimeQuarantineError)}. Original failure: ${String(registrationError)}`, + ) + } + throw registrationError + } + + const cleaned = await cleanupValidatedExtensionBackups( + options.extensionsDir, + options.extensionId, + log, + ) + if (!cleaned.ok) { + throw new Error( + `Runtime registration succeeded, but Modly could not finish transaction cleanup ` + + `during ${cleaned.stage}: ${String(cleaned.error)}. ` + + `Restart Modly to retry the validated cleanup.`, + ) + } +} + +export async function validateExtensionDestinationRegistration( + destinationDir: string, + validate: () => Promise, + label: string, + log: InstallRecoveryLogger = consoleLogger, +): Promise { + const removed = await rmWithRetry(join(destinationDir, EXT_INCOMPLETE_MARKER), label, log) + if (!removed.ok) { + throw new Error( + `Could not clear ${EXT_INCOMPLETE_MARKER} before runtime registration: ` + + `${String(removed.error)}`, + ) + } + await validate() +} + +export async function removeExtensionWithBackups( + extensionsDir: string, + extensionId: string, + log: InstallRecoveryLogger = consoleLogger, +): Promise { + let destinationLeaf = extensionId + try { + destinationLeaf = assertSafeExtensionId(extensionId) + } catch { + // Manual/corrupted folders may not conform to extension IDs. They still + // remain uninstallable under the root-confinement rule, but can never own + // a valid internal backup name. + } + + // Resolve before mutating anything. Corrupted folder names are allowed, but + // traversal outside extensionsDir is never allowed. + const destinationDir = resolvePathWithinRoot(extensionsDir, destinationLeaf) + const backups = await extensionBackupPaths(extensionsDir, destinationLeaf) + if (!backups.ok) return backups + + const stateRemoved = await removeRegistrationState(extensionsDir, destinationLeaf, log) + if (!stateRemoved.ok) return stateRemoved + + // Backups must go first. If destination deletion later fails, the visible + // extension remains uninstallable on retry, but startup cannot resurrect it. + for (const backupDir of backups.paths) { + const removed = await rmWithRetry(backupDir, 'ext-uninstall', log) + if (!removed.ok) return failedMutation('backup', removed) + } + + const removed = await rmWithRetry(destinationDir, 'ext-uninstall', log) + if (!removed.ok) return failedMutation('destination', removed) + return { ok: true } +} + +export async function cleanupValidatedExtensionBackups( + extensionsDir: string, + extensionId: string, + log: InstallRecoveryLogger = consoleLogger, +): Promise { + const backups = await extensionBackupPaths(extensionsDir, extensionId) + if (!backups.ok) return backups + + // Commit registration outside the extension tree before deleting pending + // state or rollback copies. Repository contents and linked source trees can + // never forge or retain this transaction marker. + const validatedPath = buildRegistrationStatePath( + extensionsDir, + extensionId, + String(Date.now()), + REGISTRATION_VALIDATED_PREFIX, + ) + const marked = await writeMarkerWithRetry(validatedPath, 'ext-cleanup', log) + if (!marked.ok) return failedMutation('marker', marked) + + for (const backupDir of backups.paths) { + const removed = await rmWithRetry(backupDir, 'ext-cleanup', log) + if (!removed.ok) return failedMutation('backup', removed) + } + + return removeRegistrationState(extensionsDir, extensionId, log) +} + +export async function reconcileInterruptedExtensionInstalls( + extensionsDir: string, + log: InstallRecoveryLogger = consoleLogger, +): Promise { + if (!existsSync(extensionsDir)) return + + const entries = await readdir(extensionsDir, { withFileTypes: true }) + const names = entries.map((entry) => entry.name) + + await Promise.all( + names + .filter((name) => name.startsWith(EXT_STAGING_PREFIX)) + .map((name) => rmWithRetry(join(extensionsDir, name), 'ext-cleanup', log)), + ) + + // Transaction markers live beside extension folders, never inside them. + // This keeps validation markers invisible to Python discovery and prevents a + // linked extension from writing recovery state into a developer source tree. + const backups = names.filter((name) => name.startsWith(EXT_BACKUP_PREFIX)).sort().reverse() + const extensionIdsWithBackups = new Set() + const backupsByExtension = new Map() + const pendingExtensionIds = new Set() + const validatedExtensionIds = new Set() + + for (const name of backups) { + const backupPath = join(extensionsDir, name) + const parsed = parseExtensionBackupName(name) + if (!parsed) { + await rmWithRetry(backupPath, 'ext-cleanup', log) + continue + } + extensionIdsWithBackups.add(parsed.extensionId) + const extensionBackups = backupsByExtension.get(parsed.extensionId) ?? [] + extensionBackups.push(backupPath) + backupsByExtension.set(parsed.extensionId, extensionBackups) + } + + for (const [prefix, target] of [ + [REGISTRATION_PENDING_PREFIX, pendingExtensionIds], + [REGISTRATION_VALIDATED_PREFIX, validatedExtensionIds], + ] as const) { + for (const name of names.filter((candidate) => candidate.startsWith(prefix))) { + const parsed = parseRegistrationStateName(name, prefix) + if (!parsed) { + await rmWithRetry(join(extensionsDir, name), 'ext-cleanup', log) + continue + } + target.add(parsed.extensionId) + } + } + + const transactionExtensionIds = new Set([ + ...backupsByExtension.keys(), + ...pendingExtensionIds, + ...validatedExtensionIds, + ]) + + for (const extensionId of transactionExtensionIds) { + const extensionBackups = backupsByExtension.get(extensionId) ?? [] + const destDir = resolveExtensionPathWithinRoot(extensionsDir, extensionId) + const destinationExists = existsSync(destDir) + const destinationIncomplete = existsSync(join(destDir, EXT_INCOMPLETE_MARKER)) + const destinationPending = existsSync(join(destDir, EXT_REGISTRATION_PENDING_MARKER)) + + // Destination commit markers are never trusted: they may have arrived in a + // repository or persisted from an older implementation. Only root-level, + // transaction-scoped validated state may authorize backup deletion. + await rmWithRetry(join(destDir, EXT_VALIDATED_MARKER), 'ext-cleanup', log) + + if (validatedExtensionIds.has(extensionId) && destinationExists) { + const cleaned = await cleanupValidatedExtensionBackups(extensionsDir, extensionId, log) + if (!cleaned.ok) { + log.warn( + `[ext-cleanup] could not finish validated backup cleanup for "${extensionId}" ` + + `during ${cleaned.stage}: ${cleaned.error}`, + ) + } + continue + } + if ( + validatedExtensionIds.has(extensionId) + && !destinationExists + && extensionBackups.length === 0 + ) { + await removeRegistrationState(extensionsDir, extensionId, log) + continue + } + + if (pendingExtensionIds.has(extensionId)) { + if (extensionBackups.length > 0) { + const backupPath = extensionBackups[0] + const restored = await restoreExtensionBackup(destDir, backupPath, log) + if (restored.ok) { + await removeRegistrationState(extensionsDir, extensionId, log) + log.info(`[ext-restore] restored "${extensionId}" from ${backupPath}`) + } + } else if (destinationIncomplete) { + const removed = await rmWithRetry(destDir, 'ext-cleanup', log) + if (removed.ok) await removeRegistrationState(extensionsDir, extensionId, log) + } else if (destinationExists) { + // Leave the root-level pending state in place. Discovery and the + // Extensions UI both honor it without writing into a possibly linked + // destination tree. + continue + } else { + await removeRegistrationState(extensionsDir, extensionId, log) + } + continue + } + + const destinationInterrupted = ( + destinationIncomplete + || destinationPending + ) + const action = incompleteInstallRecoveryAction({ + destinationExists, + destinationIncomplete: destinationInterrupted, + backupExists: extensionBackups.length > 0, + }) + if (action === 'restore-backup') { + const backupPath = extensionBackups[0] + const restored = await restoreExtensionBackup(destDir, backupPath, log) + if (restored.ok) { + if (validatedExtensionIds.has(extensionId)) { + await removeRegistrationState(extensionsDir, extensionId, log) + } + log.info(`[ext-restore] restored "${extensionId}" from ${backupPath}`) + } + continue + } + + // Complete destination + unmarked backup: preserve the rollback copy. Its + // registration state is unknown, so deleting or restoring would both be + // destructive guesses. + } + + // A fresh install has no backup. If it died while marked incomplete, there + // is no valid extension to load or restore, so remove only that orphan. + for (const name of names) { + if (isInternalExtensionDirName(name) || extensionIdsWithBackups.has(name)) continue + // Corrupted/manual folders may not satisfy the extension-id pattern. They + // are still safe to reconcile when their literal directory entry remains + // confined to extensionsDir (same rule used by uninstall). + const destDir = resolvePathWithinRoot(extensionsDir, name) + const destinationExists = existsSync(destDir) + const destinationIncomplete = existsSync(join(destDir, EXT_INCOMPLETE_MARKER)) + const destinationRegistrationPending = existsSync( + join(destDir, EXT_REGISTRATION_PENDING_MARKER), + ) + const action = incompleteInstallRecoveryAction({ + destinationExists, + destinationIncomplete, + backupExists: false, + }) + if (action === 'remove-incomplete') { + await rmWithRetry(destDir, 'ext-cleanup', log) + continue + } + + // A pending destination without a rollback copy cannot be proven valid or + // safely discarded. Leave it quarantined for Models → Repair; both the + // Electron list and Python registry honor this marker. + if (destinationRegistrationPending) continue + + const staleValidatedMarker = join(destDir, EXT_VALIDATED_MARKER) + if (existsSync(staleValidatedMarker)) { + await rmWithRetry(staleValidatedMarker, 'ext-cleanup', log) + } + } +} diff --git a/electron/main/extension-install-utils.test.mjs b/electron/main/extension-install-utils.test.mjs index 5a7e10d8..d5d1a389 100644 --- a/electron/main/extension-install-utils.test.mjs +++ b/electron/main/extension-install-utils.test.mjs @@ -60,3 +60,260 @@ test('python process setup failures are treated as fatal', () => { assert.equal(mod.isSetupFailureFatal({ isProcess: true, isPythonProcess: false }), false) assert.equal(mod.isSetupFailureFatal({ isProcess: false, isPythonProcess: false }), true) }) + +test('extension updates reject model/process type changes that would leave stale registration', () => { + const mod = loadModule() + + assert.doesNotThrow(() => mod.assertCompatibleExtensionUpdateType( + { id: 'pixal3d' }, + { id: 'pixal3d', type: 'model' }, + )) + assert.doesNotThrow(() => mod.assertCompatibleExtensionUpdateType( + { id: 'mesh-tool', type: 'process' }, + { id: 'mesh-tool', type: 'process' }, + )) + assert.throws( + () => mod.assertCompatibleExtensionUpdateType( + { id: 'pixal3d', type: 'model' }, + { id: 'pixal3d', type: 'process' }, + ), + /Uninstall the existing extension first/, + ) +}) + +test('existing extension replacement fails closed on unreadable or invalid manifests', () => { + const mod = loadModule() + const nextManifest = { + id: 'pixal3d', + type: 'model', + generator_class: 'Generator', + } + const files = { + hasEntryFile: () => false, + hasGeneratorFile: () => true, + } + + assert.throws( + () => mod.validateExistingExtensionReplacement( + '{broken json', + nextManifest, + files, + 'existing extension folder', + ), + /manifest\.json is unreadable or invalid.*Uninstall it first/, + ) + assert.throws( + () => mod.validateExistingExtensionReplacement( + JSON.stringify({ id: 'pixal3d', type: 'model' }), + nextManifest, + { ...files, hasGeneratorFile: () => false }, + 'existing extension folder', + ), + /referenced runtime files are invalid.*Uninstall it first.*generator\.py missing/, + ) + assert.throws( + () => mod.validateExistingExtensionReplacement( + JSON.stringify({ + id: 'another-extension', + type: 'model', + generator_class: 'Generator', + }), + nextManifest, + files, + 'existing extension folder', + ), + /existing manifest identifies "another-extension".*Uninstall it first/, + ) +}) + +test('existing extension replacement accepts a validated type-compatible manifest', () => { + const mod = loadModule() + + assert.deepEqual( + mod.validateExistingExtensionReplacement( + JSON.stringify({ + id: 'pixal3d', + type: 'model', + generator_class: 'OldGenerator', + }), + { + id: 'pixal3d', + type: 'model', + generator_class: 'NewGenerator', + }, + { + hasEntryFile: () => false, + hasGeneratorFile: () => true, + }, + 'existing extension folder', + ), + { + id: 'pixal3d', + isProcess: false, + isPythonProcess: false, + entryFile: 'processor.js', + hasNodes: false, + }, + ) +}) + +test('interrupted list entries preserve safe manifest metadata while becoming corrupted', () => { + const mod = loadModule() + const extension = { + type: 'process', + id: 'mesh-tool', + name: 'Mesh Tool', + entry: 'processor.js', + nodes: [{ id: 'simplify' }], + } + + assert.deepEqual( + mod.markExtensionInstallationInterrupted(extension, true), + { + ...extension, + corrupted: true, + manifestError: 'incomplete', + }, + ) + assert.equal(mod.markExtensionInstallationInterrupted(extension, false), extension) +}) + +test('expectedModelIds derives composite IDs and preserves legacy flat IDs', () => { + const mod = loadModule() + + assert.deepEqual( + mod.expectedModelIds({ + id: 'pixal3d', + type: 'model', + nodes: [{ id: 'generate' }, { id: 'preview' }, { id: 'generate' }], + }), + ['pixal3d/generate', 'pixal3d/preview'], + ) + assert.deepEqual(mod.expectedModelIds({ id: 'legacy-model' }), ['legacy-model']) + assert.deepEqual( + mod.expectedModelIds({ id: 'mesh-process', type: 'process', nodes: [{ id: 'run' }] }), + [], + ) +}) + +test('validateExtensionReloadPayload accepts a complete compatible response', () => { + const mod = loadModule() + const payload = { + reloaded: true, + models: ['other/generate', 'pixal3d/generate'], + errors: { 'other/broken': 'unrelated failure' }, + } + + assert.deepEqual( + mod.validateExtensionReloadPayload(payload, 'pixal3d', ['pixal3d/generate']), + payload, + ) +}) + +test('validateExtensionReloadPayload rejects missing expected model IDs', () => { + const mod = loadModule() + + assert.throws( + () => mod.validateExtensionReloadPayload( + { reloaded: true, models: ['other/generate'], errors: {} }, + 'pixal3d', + ['pixal3d/generate'], + ), + /missing model ID pixal3d\/generate/, + ) +}) + +test('validateExtensionReloadPayload rejects extension and node errors', () => { + const mod = loadModule() + + assert.throws( + () => mod.validateExtensionReloadPayload( + { reloaded: true, models: [], errors: { pixal3d: 'manifest invalid' } }, + 'pixal3d', + ['pixal3d/generate'], + ), + /pixal3d: manifest invalid/, + ) + assert.throws( + () => mod.validateExtensionReloadPayload( + { reloaded: true, models: [], errors: { 'pixal3d/generate': 'venv not found' } }, + 'pixal3d', + ['pixal3d/generate'], + ), + /pixal3d\/generate: venv not found/, + ) +}) + +test('validateExtensionReloadPayload rejects malformed responses', () => { + const mod = loadModule() + const malformed = [ + null, + {}, + { reloaded: false, models: [], errors: {} }, + { reloaded: true, models: 'pixal3d/generate', errors: {} }, + { reloaded: true, models: [], errors: [] }, + { reloaded: true, models: [], errors: { pixal3d: 123 } }, + ] + + for (const payload of malformed) { + assert.throws( + () => mod.validateExtensionReloadPayload(payload, 'pixal3d', ['pixal3d/generate']), + /malformed response/, + ) + } +}) + +test('validateExtensionQuarantinePayload requires target model IDs to be absent', () => { + const mod = loadModule() + const quarantined = { + reloaded: true, + models: ['other/generate'], + errors: { 'pixal3d/generate': 'interrupted runtime registration' }, + } + + assert.deepEqual( + mod.validateExtensionQuarantinePayload( + quarantined, + 'pixal3d', + ['pixal3d/generate'], + ), + quarantined, + ) + assert.throws( + () => mod.validateExtensionQuarantinePayload( + { + reloaded: true, + models: ['pixal3d/generate'], + errors: {}, + }, + 'pixal3d', + ['pixal3d/generate'], + ), + /Runtime quarantine failed.*pixal3d\/generate/, + ) +}) + +test('incompleteInstallRecoveryAction chooses restore, removal, or no-op', () => { + const mod = loadModule() + + assert.equal(mod.incompleteInstallRecoveryAction({ + destinationExists: true, + destinationIncomplete: true, + backupExists: true, + }), 'restore-backup') + assert.equal(mod.incompleteInstallRecoveryAction({ + destinationExists: true, + destinationIncomplete: true, + backupExists: false, + }), 'remove-incomplete') + assert.equal(mod.incompleteInstallRecoveryAction({ + destinationExists: false, + destinationIncomplete: false, + backupExists: true, + }), 'restore-backup') + assert.equal(mod.incompleteInstallRecoveryAction({ + destinationExists: true, + destinationIncomplete: false, + backupExists: true, + }), 'none') +}) diff --git a/electron/main/extension-install-utils.ts b/electron/main/extension-install-utils.ts index 73e5cfa1..4195cdca 100644 --- a/electron/main/extension-install-utils.ts +++ b/electron/main/extension-install-utils.ts @@ -14,6 +14,17 @@ export interface ValidatedInstallManifest { hasNodes: boolean } +export interface ExtensionReloadPayload { + reloaded: true + models: string[] + errors: Record +} + +export type IncompleteInstallRecoveryAction = + | 'none' + | 'remove-incomplete' + | 'restore-backup' + export function validateInstallManifest( manifest: InstallManifest, opts: { @@ -52,3 +63,182 @@ export function isSetupFailureFatal(kind: { }): boolean { return !kind.isProcess || kind.isPythonProcess } + +export function assertCompatibleExtensionUpdateType( + currentManifest: InstallManifest, + nextManifest: InstallManifest, +): void { + const currentType = currentManifest.type === 'process' ? 'process' : 'model' + const nextType = nextManifest.type === 'process' ? 'process' : 'model' + if (currentType !== nextType) { + throw new Error( + `Cannot update an extension from ${currentType} to ${nextType}. ` + + 'Uninstall the existing extension first, then install the new type.', + ) + } +} + +export function validateExistingExtensionReplacement( + currentManifestJson: string, + nextManifest: InstallManifest, + opts: { + hasEntryFile: (entryFile: string) => boolean + hasGeneratorFile: () => boolean + }, + sourceLabel: string, +): ValidatedInstallManifest { + let currentManifest: unknown + try { + currentManifest = JSON.parse(currentManifestJson) + } catch { + throw new Error( + 'Cannot safely replace the existing extension because its manifest.json ' + + 'is unreadable or invalid. Uninstall it first.', + ) + } + + if ( + typeof currentManifest !== 'object' + || currentManifest === null + || Array.isArray(currentManifest) + ) { + throw new Error( + 'Cannot safely replace the existing extension because its manifest.json ' + + 'is unreadable or invalid. Uninstall it first.', + ) + } + + let validated: ValidatedInstallManifest + try { + validated = validateInstallManifest( + currentManifest as InstallManifest, + opts, + sourceLabel, + ) + } catch (error) { + throw new Error( + 'Cannot safely replace the existing extension because its manifest.json ' + + `or referenced runtime files are invalid. Uninstall it first. ${String(error)}`, + ) + } + + if (validated.id !== nextManifest.id) { + throw new Error( + `Cannot safely replace extension "${nextManifest.id ?? ''}" because the existing ` + + `manifest identifies "${validated.id}". Uninstall it first.`, + ) + } + assertCompatibleExtensionUpdateType(currentManifest as InstallManifest, nextManifest) + return validated +} + +export function markExtensionInstallationInterrupted( + extension: T, + interrupted: boolean, +): T | (T & { corrupted: true; manifestError: 'incomplete' }) { + if (!interrupted) return extension + return { + ...extension, + corrupted: true, + manifestError: 'incomplete', + } +} + +export function expectedModelIds(manifest: InstallManifest): string[] { + if (manifest.type === 'process') return [] + if (typeof manifest.id !== 'string' || !manifest.id) { + throw new Error('manifest.json: required field "id" missing') + } + + const nodeIds = Array.isArray(manifest.nodes) + ? manifest.nodes + .map((node) => node?.id) + .filter((id): id is string => typeof id === 'string' && id.length > 0) + : [] + + return nodeIds.length > 0 + ? [...new Set(nodeIds.map((nodeId) => `${manifest.id}/${nodeId}`))] + : [manifest.id] +} + +function isStringRecord(value: unknown): value is Record { + return typeof value === 'object' + && value !== null + && !Array.isArray(value) + && Object.values(value).every((entry) => typeof entry === 'string') +} + +function parseExtensionReloadPayload(payload: unknown): ExtensionReloadPayload { + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { + throw new Error('Extension reload returned a malformed response') + } + + const candidate = payload as Record + if (candidate.reloaded !== true + || !Array.isArray(candidate.models) + || !candidate.models.every((modelId) => typeof modelId === 'string') + || !isStringRecord(candidate.errors)) { + throw new Error('Extension reload returned a malformed response') + } + + return { + reloaded: true, + models: candidate.models as string[], + errors: candidate.errors, + } +} + +export function validateExtensionReloadPayload( + payload: unknown, + extensionId: string, + expectedIds: string[], +): ExtensionReloadPayload { + const parsed = parseExtensionReloadPayload(payload) + const matchingErrors = Object.entries(parsed.errors).filter(([key]) => + key === extensionId || key.startsWith(`${extensionId}/`), + ) + if (matchingErrors.length > 0) { + const detail = matchingErrors.map(([key, message]) => `${key}: ${message}`).join('; ') + throw new Error(`Runtime registration failed for extension "${extensionId}": ${detail}`) + } + + const registered = new Set(parsed.models) + const missing = expectedIds.filter((modelId) => !registered.has(modelId)) + if (missing.length > 0) { + throw new Error( + `Runtime registration failed for extension "${extensionId}": ` + + `missing model ${missing.length === 1 ? 'ID' : 'IDs'} ${missing.join(', ')}`, + ) + } + + return parsed +} + +export function validateExtensionQuarantinePayload( + payload: unknown, + extensionId: string, + forbiddenIds: string[], +): ExtensionReloadPayload { + const parsed = parseExtensionReloadPayload(payload) + const registered = new Set(parsed.models) + const stillRegistered = forbiddenIds.filter((modelId) => registered.has(modelId)) + if (stillRegistered.length > 0) { + throw new Error( + `Runtime quarantine failed for extension "${extensionId}": ` + + `model ${stillRegistered.length === 1 ? 'ID is' : 'IDs are'} still registered ` + + stillRegistered.join(', '), + ) + } + return parsed +} + +export function incompleteInstallRecoveryAction(state: { + destinationExists: boolean + destinationIncomplete: boolean + backupExists: boolean +}): IncompleteInstallRecoveryAction { + if (!state.destinationExists || state.destinationIncomplete) { + return state.backupExists ? 'restore-backup' : state.destinationIncomplete ? 'remove-incomplete' : 'none' + } + return 'none' +} diff --git a/electron/main/extension-path-guard.ts b/electron/main/extension-path-guard.ts index 24ac2e88..427b03e6 100644 --- a/electron/main/extension-path-guard.ts +++ b/electron/main/extension-path-guard.ts @@ -59,6 +59,12 @@ export const EXT_STAGING_PREFIX = '.modly-staging-' // Marker file inside an extension folder while its setup is still running — // presence after a crash means the install never completed. export const EXT_INCOMPLETE_MARKER = '.modly-incomplete' +// Reserved basename for registration-pending state. Active transactions append +// "--" and live beside extension folders so linked +// source trees are never mutated. +export const EXT_REGISTRATION_PENDING_MARKER = '.modly-registration-pending' +// Reserved basename for the matching validated transaction commit marker. +export const EXT_VALIDATED_MARKER = '.modly-registration-validated' export function isInternalExtensionDirName(name: string): boolean { return name.startsWith('.') diff --git a/electron/main/index.ts b/electron/main/index.ts index 8cf9c810..0cbd5e5a 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -6,6 +6,8 @@ import { PythonBridge } from './python-bridge' import { logger, archiveCurrentSession } from './logger' import { initAutoUpdater } from './updater' import { syncBuiltinExtensions } from './builtin-sync' +import { reconcileInterruptedExtensionInstalls } from './extension-install-recovery' +import { getSettings } from './settings-store' let mainWindow: BrowserWindow | null = null let pythonBridge: PythonBridge | null = null @@ -101,6 +103,15 @@ app.whenReady().then(async () => { // Sync built-in extensions from app resources to userData syncBuiltinExtensions() + // Finish or roll back interrupted extension swaps before the backend scans + // the extensions directory. This must complete before PythonBridge starts. + const extensionsDir = getSettings(app.getPath('userData')).extensionsDir + try { + await reconcileInterruptedExtensionInstalls(extensionsDir, logger) + } catch (err) { + logger.error(`[ext-recovery] Startup reconciliation failed: ${String(err)}`) + } + // Start Python FastAPI backend pythonBridge = new PythonBridge() pythonBridge.setWindowGetter(() => mainWindow) diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 14fc984a..c814dd4f 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -21,18 +21,39 @@ import { getProcessRunner, getPythonProcessRunner, getExtPythonExe, terminatePro import { getBuiltinExtensionsDir } from './builtin-sync' import { spawn, execFile } from 'child_process' import { - EXT_BACKUP_PREFIX, EXT_INCOMPLETE_MARKER, - EXT_STAGING_PREFIX, + EXT_REGISTRATION_PENDING_MARKER, + EXT_VALIDATED_MARKER, assertSafeExtensionId, buildExtensionBackupPath, buildExtensionStagingPath, isInternalExtensionDirName, - parseExtensionBackupName, resolveExtensionPathWithinRoot, - resolvePathWithinRoot, } from './extension-path-guard' -import { validateInstallManifest } from './extension-install-utils' +import { + assertCompatibleExtensionUpdateType, + expectedModelIds, + markExtensionInstallationInterrupted, + validateExtensionQuarantinePayload, + validateExtensionReloadPayload, + validateExistingExtensionReplacement, + validateInstallManifest, +} from './extension-install-utils' +import { + beginExtensionRegistrationTransaction, + clearExtensionRegistrationTransaction, + cleanupValidatedExtensionBackups, + quarantineExtensionRegistrationFailure, + parseExtensionRegistrationPendingName, + removeExtensionWithBackups, + renameWithRetry as renameExtensionWithRetry, + restoreExtensionBackup, + rmWithRetry as removeExtensionWithRetry, + runExtensionRegistrationValidationTransaction, + runExtensionRepairTransaction, + type ExtensionRegistrationValidationCapability, + validateExtensionDestinationRegistration, +} from './extension-install-recovery' import { registerWorkspaceAssetLibraryIpcHandlers } from './artifact-registry-service' import { updatesSupported } from './updater' @@ -234,91 +255,16 @@ runpy.run_path(setup_py, run_name="__main__") }) } -// ─── Robust directory removal / rename ──────────────────────────────────────── -// On Windows the first attempt routinely fails with EBUSY/EPERM while the -// antivirus or a dying python process still holds files open — which used to -// leave half-deleted extension folders behind. Locked errors are retried with -// a progressive backoff; any other error fails immediately. - -const FS_RETRY_DELAYS_MS = [200, 500, 1500, 2500] - -function isLockedFsError(err: unknown): boolean { - const code = (err as NodeJS.ErrnoException).code - return code === 'EBUSY' || code === 'EPERM' || code === 'EACCES' -} - -type FsRetryResult = { ok: true } | { ok: false; locked: boolean; error: unknown } - -async function fsWithRetry(op: () => Promise, label: string, target: string): Promise { - for (let attempt = 0; ; attempt++) { - try { - await op() - return { ok: true } - } catch (err) { - if (!isLockedFsError(err) || attempt === FS_RETRY_DELAYS_MS.length) { - logger.warn(`[${label}] ${target}: ${err}`) - return { ok: false, locked: isLockedFsError(err), error: err } - } - await new Promise((r) => setTimeout(r, FS_RETRY_DELAYS_MS[attempt])) - } - } -} - -const rmWithRetry = (path: string, label: string): Promise => - fsWithRetry(() => rmAsync(path, { recursive: true, force: true }), label, path) - -const renameWithRetry = (from: string, to: string, label: string): Promise => - fsWithRetry(() => rename(from, to), label, `${from} -> ${to}`) - // Extension ids with an install currently in flight. Their folder carries the // incomplete marker during setup — extensions:list must not report it as // corrupted while the install is legitimately running. const activeExtensionInstalls = new Set() +const rmWithRetry = (path: string, label: string) => + removeExtensionWithRetry(path, label, logger) +const renameWithRetry = (from: string, to: string, label: string) => + renameExtensionWithRetry(from, to, label, logger) export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGetter): void { - // Reconcile leftovers of interrupted installs. No install can be in flight - // this early in the app's life, so anything matching is stale: - // - staging dirs → discard (never the only copy of anything) - // - extension dir still carrying the incomplete marker + a backup exists - // → the install crashed mid-setup: put the previous version back - // - backup dirs → restore if the extension folder is gone, else discard - void (async () => { - try { - const extensionsDir = getSettings(app.getPath('userData')).extensionsDir - const entries = await readdir(extensionsDir, { withFileTypes: true }) - const names = entries.map((e) => e.name) - - await Promise.allSettled( - names - .filter((n) => n.startsWith(EXT_STAGING_PREFIX)) - .map((n) => rmWithRetry(join(extensionsDir, n), 'ext-cleanup')), - ) - - // Newest backup first, so the most recent good version wins a restore - const backups = names.filter((n) => n.startsWith(EXT_BACKUP_PREFIX)).sort().reverse() - for (const name of backups) { - const backupPath = join(extensionsDir, name) - const parsed = parseExtensionBackupName(name) - if (!parsed) { await rmWithRetry(backupPath, 'ext-cleanup'); continue } - - const destDir = join(extensionsDir, parsed.extensionId) - const destIncomplete = existsSync(join(destDir, EXT_INCOMPLETE_MARKER)) - if (existsSync(destDir) && !destIncomplete) { - // Install completed; only the backup's own cleanup had failed - await rmWithRetry(backupPath, 'ext-cleanup') - continue - } - // Crash mid-swap or mid-setup: this backup is the last good copy - if (destIncomplete) { - const removed = await rmWithRetry(destDir, 'ext-restore') - if (!removed.ok) continue // keep the backup; retried next launch - } - const restored = await renameWithRetry(backupPath, destDir, 'ext-restore') - if (restored.ok) logger.info(`[ext-restore] restored "${parsed.extensionId}" from ${name}`) - } - } catch { /* best-effort; extensionsDir may not exist yet */ } - })() - const activeDownloads = new Map() // Logging from renderer ipcMain.on('log:error', (_event, message: string) => logger.error(`[Renderer] ${message}`)) @@ -942,6 +888,119 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe return { ...common, type: 'model' as const, nodes } } + async function reloadAndValidateModelExtension( + manifest: ParsedManifest, + extensionId: string, + validationCapability?: ExtensionRegistrationValidationCapability, + ): Promise { + const expectedIds = expectedModelIds({ ...manifest, id: extensionId }) + if (expectedIds.length === 0) return + + let payload: unknown + try { + const response = await axios.post( + `${API_BASE_URL}/extensions/reload`, + validationCapability ? { validationCapability } : {}, + { timeout: 10_000 }, + ) + payload = response.data + } catch (err) { + throw new Error( + `Could not validate runtime registration for extension "${extensionId}": ${String(err)}`, + ) + } + validateExtensionReloadPayload(payload, extensionId, expectedIds) + } + + async function quarantineModelExtensionRuntime( + manifest: ParsedManifest, + extensionId: string, + ): Promise { + const forbiddenIds = expectedModelIds({ ...manifest, id: extensionId }) + if (forbiddenIds.length === 0) return + + let payload: unknown + try { + const response = await axios.post( + `${API_BASE_URL}/extensions/reload`, + {}, + { timeout: 10_000 }, + ) + payload = response.data + } catch (err) { + throw new Error( + `Could not quarantine runtime registration for extension "${extensionId}": ` + + `${String(err)}`, + ) + } + validateExtensionQuarantinePayload(payload, extensionId, forbiddenIds) + } + + async function rollbackFailedExtensionUpdate( + extensionsDir: string, + destinationDir: string, + backupDir: string, + extensionId: string, + originalFailure: unknown, + ): Promise { + const restored = await restoreExtensionBackup(destinationDir, backupDir, logger) + if (!restored.ok) { + throw new Error( + `Extension update failed, and Modly could not restore the previous version ` + + `during ${restored.stage}: ${String(restored.error)}. ` + + `Restart Modly to retry recovery. Original failure: ${String(originalFailure)}`, + ) + } + + const stateCleared = await clearExtensionRegistrationTransaction( + extensionsDir, + extensionId, + logger, + ) + if (!stateCleared.ok) { + throw new Error( + `Extension update failed and the previous version was restored, but Modly ` + + `could not clear recovery state during ${stateCleared.stage}: ` + + `${String(stateCleared.error)}. Original failure: ${String(originalFailure)}`, + ) + } + + try { + const previousManifest = JSON.parse( + await readFile(join(destinationDir, 'manifest.json'), 'utf-8'), + ) as ParsedManifest + if (previousManifest.type !== 'process') { + await reloadAndValidateModelExtension(previousManifest, extensionId) + } + } catch (reloadError) { + throw new Error( + `Extension update failed and the previous version was restored on disk, ` + + `but Modly could not reload it: ${String(reloadError)}. ` + + `Original failure: ${String(originalFailure)}`, + ) + } + + throw new Error( + `Extension update failed before runtime registration could be committed, ` + + `so the previous version was restored. ` + + `${String(originalFailure)}`, + ) + } + + async function cleanupValidatedBackupsOrThrow( + extensionsDir: string, + extensionId: string, + ): Promise { + const cleaned = await cleanupValidatedExtensionBackups(extensionsDir, extensionId, logger) + if (!cleaned.ok) { + throw new Error( + `Runtime registration succeeded, but Modly could not finish removing the previous ` + + `extension backup during ${cleaned.stage}: ${String(cleaned.error)}. ` + + `Restart Modly to retry the validated cleanup.`, + ) + } + } + // Extensions — reads user extensions directory + built-in extensions directory ipcMain.handle('extensions:list', async () => { const userData = app.getPath('userData') @@ -954,6 +1013,13 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe if (!existsSync(dir)) return [] try { const entries = await readdir(dir, { withFileTypes: true }) + const pendingRegistrationIds = new Set( + isBuiltin + ? [] + : entries + .map((entry) => parseExtensionRegistrationPendingName(entry.name)?.extensionId) + .filter((extensionId): extensionId is string => Boolean(extensionId)), + ) // On Windows, junction points are reported by Node.js as isSymbolicLink()=true, // isDirectory()=false. Use statSync (which follows links) as the authoritative check. const dirs = entries.filter(e => { @@ -969,12 +1035,15 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe const entryPath = join(dir, entry.name) const skeleton = { type: 'model' as const, id: entry.name, name: entry.name, trusted: isBuiltin, builtin: isBuiltin, nodes: [], corrupted: true } - // Marker still present → an install of this folder never completed. - // Hidden entirely while that install is still in flight. - if (existsSync(join(entryPath, EXT_INCOMPLETE_MARKER))) { - if (activeExtensionInstalls.has(entry.name)) return null - return { ...skeleton, manifestError: 'incomplete' as const } - } + // A setup/registration marker still present means this folder is not + // safe to expose. Hide it only while the current install owns it; + // after a failed startup recovery, surface it as Repairable instead. + const installInterrupted = ( + existsSync(join(entryPath, EXT_INCOMPLETE_MARKER)) + || existsSync(join(entryPath, EXT_REGISTRATION_PENDING_MARKER)) + || pendingRegistrationIds.has(entry.name) + ) + if (installInterrupted && activeExtensionInstalls.has(entry.name)) return null // Detect local extensions: check for .modly-local sentinel let localSourcePath: string | undefined @@ -998,11 +1067,19 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe const parsed = JSON.parse(raw) as ParsedManifest // Inject local:// source so the UI shows the Local badge if (localSourcePath) parsed.source = `local://${localSourcePath}` - return parseExtensionManifest(parsed, entry.name, trustedRepos, isBuiltin) + const extension = parseExtensionManifest( + parsed, + entry.name, + trustedRepos, + isBuiltin, + ) + return markExtensionInstallationInterrupted(extension, installInterrupted) } catch { manifestError = 'invalid' } } } - return { ...skeleton, manifestError } + return installInterrupted + ? markExtensionInstallationInterrupted(skeleton, true) + : { ...skeleton, manifestError } })) return results.filter((e): e is Exclude => e !== null) } catch { @@ -1099,11 +1176,44 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe // previous version back. const extensionsDir = getSettings(app.getPath('userData')).extensionsDir await mkdir(extensionsDir, { recursive: true }) + const installSuffix = String(Date.now()) const destDir = resolveExtensionPathWithinRoot(extensionsDir, extensionId) - const stagingDir = buildExtensionStagingPath(extensionsDir, extensionId, String(Date.now())) + const stagingDir = buildExtensionStagingPath(extensionsDir, extensionId, installSuffix) + + if (existsSync(destDir)) { + const currentManifestPath = join(destDir, 'manifest.json') + let currentManifestJson: string + try { + currentManifestJson = await readFile(currentManifestPath, 'utf-8') + } catch (error) { + throw new Error( + `Cannot safely replace extension "${extensionId}" because its existing ` + + `manifest.json is missing or unreadable. Uninstall it first. ${String(error)}`, + ) + } + validateExistingExtensionReplacement( + currentManifestJson, + manifest, + { + hasEntryFile: (candidate) => existsSync(join(destDir, candidate)), + hasGeneratorFile: () => existsSync(join(destDir, 'generator.py')), + }, + 'existing extension folder', + ) + } try { await cp(extractDir, stagingDir, { recursive: true }) + // Reserved recovery files are host-owned transaction state, never + // repository content. Strip packaged/stale copies before activation. + await rmAsync( + join(stagingDir, EXT_REGISTRATION_PENDING_MARKER), + { recursive: true, force: true }, + ) + await rmAsync( + join(stagingDir, EXT_VALIDATED_MARKER), + { recursive: true, force: true }, + ) await writeFile(join(stagingDir, EXT_INCOMPLETE_MARKER), new Date().toISOString(), 'utf-8') // Compile TypeScript entry to JS at install time (once, no runtime overhead) @@ -1128,7 +1238,9 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe // 6. Swap into place (backup the previous version first) terminateProcessRunner(extensionId) - const backupDir = existsSync(destDir) ? buildExtensionBackupPath(extensionsDir, extensionId, String(Date.now())) : null + const backupDir = existsSync(destDir) + ? buildExtensionBackupPath(extensionsDir, extensionId, installSuffix) + : null if (backupDir) { const parked = await renameWithRetry(destDir, backupDir, 'ext-install') if (!parked.ok) { @@ -1143,6 +1255,29 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe throw new Error('Could not move the staged extension into place — the folder is locked. Try again.') } + // Persist transaction state beside extension folders, never inside a + // destination or backup that may be a symlink to developer source. + const pending = await beginExtensionRegistrationTransaction( + extensionsDir, + extensionId, + installSuffix, + logger, + ) + if (!pending.ok) { + if (backupDir) { + await rollbackFailedExtensionUpdate( + extensionsDir, + destDir, + backupDir, + extensionId, + `Could not mark the update as pending: ${String(pending.error)}`, + ) + } else { + await rmWithRetry(destDir, 'ext-install') + } + throw new Error(`Could not mark extension registration as pending: ${String(pending.error)}`) + } + // 7. Setup runs in the final folder; a failure restores the previous version try { if (isPythonProcess) { @@ -1196,25 +1331,105 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe // Discard the half-set-up folder and put the previous version back. If // the folder is locked, the marker keeps it flagged corrupted and the // startup reconciler restores the backup on next launch. - const discarded = await rmWithRetry(destDir, 'ext-install') - if (discarded.ok && backupDir) await renameWithRetry(backupDir, destDir, 'ext-install') + if (backupDir) { + const restored = await restoreExtensionBackup(destDir, backupDir, logger) + if (!restored.ok) { + throw new Error( + `Extension setup failed, and Modly could not restore the previous version ` + + `during ${restored.stage}: ${String(restored.error)}. ` + + `Restart Modly to retry recovery. Original failure: ${String(setupErr)}`, + ) + } + const cleared = await clearExtensionRegistrationTransaction( + extensionsDir, + extensionId, + logger, + ) + if (!cleared.ok) { + throw new Error( + `Extension setup failed and the previous version was restored, but Modly ` + + `could not clear recovery state during ${cleared.stage}: ${String(cleared.error)}.`, + ) + } + } else { + const removed = await rmWithRetry(destDir, 'ext-install') + if (removed.ok) { + const cleared = await clearExtensionRegistrationTransaction( + extensionsDir, + extensionId, + logger, + ) + if (!cleared.ok) { + throw new Error( + `Extension setup failed and its incomplete folder was removed, but Modly ` + + `could not clear recovery state during ${cleared.stage}: ${String(cleared.error)}.`, + ) + } + } + } throw setupErr } - // 8. Success: clear the marker, then drop the backup. The backup is only - // discarded once the marker is confirmed gone — a still-marked folder - // would make the startup reconciler restore the backup over this - // freshly completed install. - const markerGone = await rmWithRetry(join(destDir, EXT_INCOMPLETE_MARKER), 'ext-install') - if (backupDir && markerGone.ok) void rmWithRetry(backupDir, 'ext-install') - - // Hot-reload Python so it picks up the new/updated model extension - if (!isProcess) { - try { - await axios.post(`${API_BASE_URL}/extensions/reload`, {}, { timeout: 10_000 }) - } catch { /* Python might not be running yet */ } + // 8. Setup succeeded. Root-level transaction state remains pending while + // the new destination is explicitly validated. A crash or failure in + // this window restores a backup or quarantines a fresh destination. + try { + await validateExtensionDestinationRegistration( + destDir, + async () => { + if (!isProcess) { + await reloadAndValidateModelExtension( + manifest, + extensionId, + pending.validationCapability, + ) + } + }, + 'ext-install', + logger, + ) + } catch (registrationError) { + if (backupDir) { + await rollbackFailedExtensionUpdate( + extensionsDir, + destDir, + backupDir, + extensionId, + registrationError, + ) + } else { + const quarantined = await quarantineExtensionRegistrationFailure( + extensionsDir, + extensionId, + logger, + ) + if (!quarantined.ok) { + throw new Error( + `Runtime registration failed, and Modly could not quarantine the extension ` + + `during ${quarantined.stage}: ${String(quarantined.error)}. ` + + `Restart Modly to retry recovery. Original failure: ${String(registrationError)}`, + ) + } + if (!isProcess) { + try { + await quarantineModelExtensionRuntime(manifest, extensionId) + } catch (runtimeQuarantineError) { + throw new Error( + `Runtime registration failed and filesystem quarantine was preserved, ` + + `but Modly could not evict partially registered model state: ` + + `${String(runtimeQuarantineError)}. ` + + `Original failure: ${String(registrationError)}`, + ) + } + } + } + throw registrationError } + // Runtime validation passed. Mark cleanup as validated before deleting + // rollback copies so startup can safely retry an interrupted deletion. + await cleanupValidatedBackupsOrThrow(extensionsDir, extensionId) + emit({ step: 'done', extensionId }) const trustedRepos = await fetchTrustedRepos() @@ -1240,7 +1455,6 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe // strict id pattern still guards the built-in check — a non-conforming // name can never be a built-in. const extensionsDir = getSettings(app.getPath('userData')).extensionsDir - const extPath = resolvePathWithinRoot(extensionsDir, String(extensionId)) try { const safeExtensionId = assertSafeExtensionId(extensionId) @@ -1252,13 +1466,19 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe // Terminate process runner if it's a process extension terminateProcessRunner(extensionId) - const removed = await rmWithRetry(extPath, 'ext-uninstall') + const removed = await removeExtensionWithBackups( + extensionsDir, + String(extensionId), + logger, + ) if (!removed.ok) { return { success: false, error: removed.locked - ? 'Could not delete the extension folder — a file inside it is locked (antivirus or a running process). Close what might be using it and try again.' - : String(removed.error), + ? `Could not delete the extension ${removed.stage === 'backup' ? 'backup' : 'folder'} ` + + '— a file inside it is locked (antivirus or a running process). ' + + 'Close what might be using it and try again.' + : `Could not uninstall the extension during ${removed.stage}: ${String(removed.error)}`, } } // Hot-reload Python so it stops using the deleted model extension @@ -1275,15 +1495,52 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe ipcMain.handle('extensions:repair', async (_, extensionId: string) => { try { const safeExtensionId = assertSafeExtensionId(extensionId) - const extDir = resolveExtensionPathWithinRoot(getSettings(app.getPath('userData')).extensionsDir, safeExtensionId) + const extensionsDir = getSettings(app.getPath('userData')).extensionsDir + const extDir = resolveExtensionPathWithinRoot(extensionsDir, safeExtensionId) if (!existsSync(join(extDir, 'setup.py'))) { return { success: false, error: 'setup.py is missing from the extension folder — the install looks incomplete. Uninstall the extension and install it again.' } } + const manifestRaw = await readFile(join(extDir, 'manifest.json'), 'utf-8') + const manifest = JSON.parse(manifestRaw) as ParsedManifest + const { isProcess } = validateInstallManifest( + manifest, + { + hasEntryFile: (candidate) => existsSync(join(extDir, candidate)), + hasGeneratorFile: () => existsSync(join(extDir, 'generator.py')), + }, + 'extension folder', + ) const { sm: gpuSm, cudaVersion } = await detectGpuInfo() - await runExtensionSetup(extDir, gpuSm, cudaVersion, (line) => logger.info(`[ext-repair] ${line}`)) - try { - await axios.post(`${API_BASE_URL}/extensions/reload`, {}, { timeout: 10_000 }) - } catch { /* ignore if Python is not running yet */ } + await runExtensionRepairTransaction( + { + extensionsDir, + extensionId: safeExtensionId, + destinationDir: extDir, + suffix: String(Date.now()), + quarantine: async () => { + terminateProcessRunner(safeExtensionId) + if (!isProcess) { + await quarantineModelExtensionRuntime(manifest, safeExtensionId) + } + }, + setup: () => runExtensionSetup( + extDir, + gpuSm, + cudaVersion, + (line) => logger.info(`[ext-repair] ${line}`), + ), + validate: async (validationCapability) => { + if (!isProcess) { + await reloadAndValidateModelExtension( + manifest, + safeExtensionId, + validationCapability, + ) + } + }, + }, + logger, + ) return { success: true } } catch (err: any) { return { success: false, error: `Repair failed: ${err?.message ?? err}` } @@ -1316,7 +1573,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe const manifestRaw = await readFile(manifestPath, 'utf-8') const manifest = JSON.parse(manifestRaw) as ParsedManifest - const { id: rawManifestId } = validateInstallManifest( + const { id: rawManifestId, isProcess } = validateInstallManifest( manifest, { hasEntryFile: (candidate) => existsSync(join(localPath, candidate)), @@ -1332,49 +1589,121 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe await mkdir(extensionsDir, { recursive: true }) const linkPath = resolveExtensionPathWithinRoot(extensionsDir, extensionId) + const installSuffix = String(Date.now()) + let backupDir: string | null = null - // Remove any existing symlink/dir at that location + // Inspect an existing local link without mutating it. The host-side + // pending transaction is created before the link is ever parked or + // replaced, so a crash cannot expose the new target as validated. if (existsSync(linkPath)) { - // Check if it's already linked to the same path - try { - const stat = await lstat(linkPath) - if (stat.isSymbolicLink() || (process.platform === 'win32' && stat.isDirectory())) { - await rmAsync(linkPath, { recursive: true, force: true }) - } else { - throw new Error(`A non-symlink folder named "${extensionId}" already exists in extensionsDir. Remove it first.`) - } - } catch (e: any) { - if (e.message?.includes('already exists')) throw e - await rmAsync(linkPath, { recursive: true, force: true }) + const currentManifestPath = join(linkPath, 'manifest.json') + if (!existsSync(currentManifestPath)) { + throw new Error( + `Cannot safely replace local extension "${extensionId}" because its existing ` + + 'manifest.json is missing. Uninstall it first.', + ) } + const currentManifest = JSON.parse( + await readFile(currentManifestPath, 'utf-8'), + ) as ParsedManifest + assertCompatibleExtensionUpdateType(currentManifest, manifest) + + const linkStat = await lstat(linkPath) + if (!linkStat.isSymbolicLink() && !(process.platform === 'win32' && linkStat.isDirectory())) { + throw new Error( + `A non-symlink folder named "${extensionId}" already exists in ` + + 'extensionsDir. Remove it first.', + ) + } + backupDir = buildExtensionBackupPath(extensionsDir, extensionId, installSuffix) } - emit({ step: 'setting_up', message: 'Linking local folder…' }) + const trustedRepos = await fetchTrustedRepos() + // Build manifest with localPath marker so the UI can identify local extensions + const annotatedManifest = { ...manifest, source: `local://${localPath}` } + const ext = parseExtensionManifest(annotatedManifest, extensionId, trustedRepos) - if (process.platform === 'win32') { - // Windows: junction (no elevation needed, works for directories) - await symlink(localPath, linkPath, 'junction') - } else { - // macOS / Linux: standard symlink - await symlink(localPath, linkPath) - } + try { + await runExtensionRegistrationValidationTransaction( + { + extensionsDir, + extensionId, + suffix: installSuffix, + quarantine: async () => { + terminateProcessRunner(extensionId) + if (!isProcess) { + await quarantineModelExtensionRuntime(manifest, extensionId) + } + }, + activate: async () => { + emit({ step: 'setting_up', message: 'Linking local folder…' }) + if (backupDir) { + const parked = await renameWithRetry(linkPath, backupDir, 'ext-local') + if (!parked.ok) { + throw new Error( + `Could not preserve the previous local extension: ` + + `${String(parked.error)}`, + ) + } + } - // Write sentinel so extensions:list can detect this as a local extension - // The sentinel lives in the linked folder (= the original local folder), so - // it persists even if Modly is restarted. The content is the absolute path. - await writeFile(join(linkPath, '.modly-local'), localPath, 'utf-8') + if (process.platform === 'win32') { + // Windows: junction (no elevation needed, works for directories) + await symlink(localPath, linkPath, 'junction') + } else { + // macOS / Linux: standard symlink + await symlink(localPath, linkPath) + } - // 4. Hot-reload Python registry so it picks up the new extension - try { - await axios.post(`${API_BASE_URL}/extensions/reload`, {}, { timeout: 10_000 }) - } catch { /* Python might not be running yet */ } + // The sentinel lives in the linked source folder. Runtime + // registration is still pending externally until validation. + await writeFile(join(linkPath, '.modly-local'), localPath, 'utf-8') + }, + validate: async (validationCapability) => { + if (!isProcess) { + await reloadAndValidateModelExtension( + manifest, + extensionId, + validationCapability, + ) + } + }, + }, + logger, + ) + } catch (err) { + if (backupDir && existsSync(backupDir)) { + await rollbackFailedExtensionUpdate( + extensionsDir, + linkPath, + backupDir, + extensionId, + err, + ) + } - emit({ step: 'done', extensionId }) + // Local links intentionally do not run setup. A fresh model that + // cannot register remains linked, externally quarantined, and visible + // only as a Repairable entry. + if (!isProcess && existsSync(linkPath)) { + const error = ( + `The local extension was linked, but it is not runtime-ready. ` + + `Click 'Repair' on its extension card, then retry. ${String(err)}` + ) + emit({ step: 'error', extensionId, message: error }) + return { + success: false, + needsRepair: true, + error, + extensionId, + extension: markExtensionInstallationInterrupted(ext, true), + localPath, + } + } + throw err + } - const trustedRepos = await fetchTrustedRepos() - // Build manifest with localPath marker so the UI can identify local extensions - const annotatedManifest = { ...manifest, source: `local://${localPath}` } - const ext = parseExtensionManifest(annotatedManifest, extensionId, trustedRepos) + emit({ step: 'done', extensionId }) return { success: true, extensionId, extension: ext, localPath } } catch (err) { diff --git a/electron/preload/electron-api.ts b/electron/preload/electron-api.ts index 4f351b49..af211f0d 100644 --- a/electron/preload/electron-api.ts +++ b/electron/preload/electron-api.ts @@ -209,11 +209,13 @@ export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFra installFromLocal: (): Promise<{ success: boolean; error?: string; cancelled?: boolean + needsRepair?: boolean extensionId?: string extension?: unknown localPath?: string }> => ipcRenderer.invoke('extensions:installFromLocal') as Promise<{ success: boolean; error?: string; cancelled?: boolean + needsRepair?: boolean extensionId?: string extension?: unknown localPath?: string diff --git a/src/areas/models/components/ExtensionDrawer.tsx b/src/areas/models/components/ExtensionDrawer.tsx index e3946f17..54adab9c 100644 --- a/src/areas/models/components/ExtensionDrawer.tsx +++ b/src/areas/models/components/ExtensionDrawer.tsx @@ -11,6 +11,7 @@ import { formatBytes, getNodeState, } from './extensionShared' +import { finishExtensionRepair, isExtensionRepairable } from '../utils' interface Props { ext: AnyExtension @@ -24,7 +25,7 @@ interface Props { onCancelDownload: (fullId: string) => void onUninstallNode: (fullId: string) => void onUninstall: (extId: string) => void - onRepaired: () => void + onRepaired: () => void | Promise onSynced: () => void onClose: () => void } @@ -44,11 +45,12 @@ export function ExtensionDrawer({ // Built-ins are corrupted-flagged too (builtin-sync repairs them on restart), // but they can't be deleted — show the banner without the delete action. const isCorrupted = !!ext.corrupted + const canRepair = isExtensionRepairable(ext) const corruptedMsg = ext.manifestError === 'invalid' ? 'This extension folder has a manifest that cannot be parsed. Fix the JSON syntax in its manifest.json, or delete the folder and reinstall.' : ext.manifestError === 'incomplete' - ? 'A previous install of this extension never completed. Delete the folder, then install the extension again.' + ? 'Setup or runtime registration was interrupted. Click Repair to rebuild and validate the extension, or delete it and reinstall.' : 'This extension folder is incomplete (its manifest is missing) — usually the leftover of an interrupted install. Delete the folder, then install the extension again.' const isLocal = typeof ext.source === 'string' && ext.source.startsWith('local://') const localPath = isLocal ? ext.source!.replace('local://', '') : null @@ -64,9 +66,8 @@ export function ExtensionDrawer({ setRepairing(true) setRepairError(null) const result = await window.electron.extensions.repair(ext.id) + setRepairError(await finishExtensionRepair(result, onRepaired)) setRepairing(false) - if (result.success) onRepaired() - else setRepairError(result.error ?? 'Repair failed') } async function handleSync() { @@ -196,7 +197,7 @@ export function ExtensionDrawer({
onInstall(node, fullId)} onPause={() => onPauseDownload(fullId)} onResume={() => onInstall(node, fullId)} @@ -205,7 +206,7 @@ export function ExtensionDrawer({ {state.kind === 'installed' && ( )} - {isModel && !isCorrupted && ( + {canRepair && (