diff --git a/dream-server/extensions/services/dashboard-api/routers/extensions.py b/dream-server/extensions/services/dashboard-api/routers/extensions.py index 9179549f6..267f0e00f 100644 --- a/dream-server/extensions/services/dashboard-api/routers/extensions.py +++ b/dream-server/extensions/services/dashboard-api/routers/extensions.py @@ -473,9 +473,15 @@ def install_extension(service_id: str, api_key: str = Depends(verify_api_key)): # Early check (non-authoritative, rechecked under lock) if dest.exists(): - raise HTTPException( - status_code=409, detail=f"Extension already installed: {service_id}", - ) + has_compose = (dest / "compose.yaml").exists() + has_disabled = (dest / "compose.yaml.disabled").exists() + if has_compose or has_disabled: + raise HTTPException( + status_code=409, detail=f"Extension already installed: {service_id}", + ) + # Broken directory (no compose file) — clean up before reinstall + logger.warning("Cleaning up broken extension directory: %s", dest) + shutil.rmtree(dest) # Size check total_size = 0 @@ -492,10 +498,16 @@ def install_extension(service_id: str, api_key: str = Depends(verify_api_key)): with _extensions_lock(): # Re-check under lock to prevent double-install race if dest.exists(): - raise HTTPException( - status_code=409, - detail=f"Extension already installed: {service_id}", - ) + has_compose = (dest / "compose.yaml").exists() + has_disabled = (dest / "compose.yaml.disabled").exists() + if has_compose or has_disabled: + raise HTTPException( + status_code=409, + detail=f"Extension already installed: {service_id}", + ) + # Broken directory (no compose file) — clean up before reinstall + logger.warning("Cleaning up broken extension directory under lock: %s", dest) + shutil.rmtree(dest) USER_EXTENSIONS_DIR.mkdir(parents=True, exist_ok=True) tmpdir = tempfile.mkdtemp(dir=str(USER_EXTENSIONS_DIR.parent)) @@ -574,6 +586,9 @@ def enable_extension(service_id: str, api_key: str = Depends(verify_api_key)): for dep in depends_on: if not isinstance(dep, str) or not _SERVICE_ID_RE.match(dep): continue + # Core services have compose in docker-compose.base.yml, not individual files + if dep in CORE_SERVICE_IDS: + continue # Check built-in extensions if (EXTENSIONS_DIR / dep / "compose.yaml").exists(): continue diff --git a/dream-server/extensions/services/dashboard-api/tests/test_extensions.py b/dream-server/extensions/services/dashboard-api/tests/test_extensions.py index 604ef6fac..9863f3644 100644 --- a/dream-server/extensions/services/dashboard-api/tests/test_extensions.py +++ b/dream-server/extensions/services/dashboard-api/tests/test_extensions.py @@ -395,6 +395,28 @@ def test_install_copies_and_enables(self, test_client, monkeypatch, tmp_path): assert (user_dir / "my-ext").is_dir() assert (user_dir / "my-ext" / "compose.yaml").exists() + def test_install_cleans_broken_directory(self, test_client, monkeypatch, tmp_path): + """Install succeeds when dest dir exists but has no compose files (broken state).""" + lib_dir = _setup_library_ext(tmp_path, "my-ext") + # Create a broken user extension directory (no compose.yaml or compose.yaml.disabled) + user_dir = tmp_path / "user" + user_dir.mkdir(exist_ok=True) + broken_dir = user_dir / "my-ext" + broken_dir.mkdir(exist_ok=True) + (broken_dir / "manifest.yaml").write_text("leftover: true\n") + _patch_mutation_config(monkeypatch, tmp_path, lib_dir=lib_dir, + user_dir=user_dir) + + resp = test_client.post( + "/api/extensions/my-ext/install", + headers=test_client.auth_headers, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["action"] == "installed" + assert (user_dir / "my-ext" / "compose.yaml").exists() + def test_install_already_installed_409(self, test_client, monkeypatch, tmp_path): """409 when extension is already installed.""" lib_dir = _setup_library_ext(tmp_path, "my-ext") @@ -514,6 +536,21 @@ def test_enable_already_enabled_409(self, test_client, monkeypatch, tmp_path): ) assert resp.status_code == 409 + def test_enable_allows_core_service_dependency(self, test_client, monkeypatch, tmp_path): + """Enable succeeds when depends_on includes a core service.""" + manifest = {"service": {"depends_on": ["open-webui"]}} + user_dir = _setup_user_ext(tmp_path, "my-ext", enabled=False, + manifest=manifest) + _patch_mutation_config(monkeypatch, tmp_path, user_dir=user_dir) + + resp = test_client.post( + "/api/extensions/my-ext/enable", + headers=test_client.auth_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["action"] == "enabled" + def test_enable_missing_dependency_400(self, test_client, monkeypatch, tmp_path): """400 when a dependency is not enabled.""" manifest = {"service": {"depends_on": ["missing-dep"]}}