"""Tests for dream-host-agent.py — _parse_mem_value and _iso_now."""

import importlib.util
import io
import json
import subprocess
import sys
import types
from pathlib import Path, PurePosixPath

import pytest

# Import the host agent module from bin/ using importlib.
# The module has an ``if __name__ == "__main__":`` guard so no server starts.
_agent_path = Path(__file__).resolve().parents[4] / "bin" / "dream-host-agent.py"
_spec = importlib.util.spec_from_file_location("dream_host_agent", _agent_path)
_mod = importlib.util.module_from_spec(_spec)
sys.modules["dream_host_agent"] = _mod
_spec.loader.exec_module(_mod)

_parse_mem_value = _mod._parse_mem_value
_iso_now = _mod._iso_now
_to_bash_path = _mod._to_bash_path
resolve_compose_flags = _mod.resolve_compose_flags
validate_core_recreate_ids = _mod.validate_core_recreate_ids
invalidate_compose_cache = _mod.invalidate_compose_cache
_post_install_core_recreate = _mod._post_install_core_recreate
_split_nmcli_terse = _mod._split_nmcli_terse


# --- _split_nmcli_terse — parser for nmcli -t (terse) output ---


class TestSplitNmcliTerse:
    """Reviewer flagged `line.split(':')` as fragile for SSIDs / connection
    names containing ':' (#1159 audit, point 3). `_split_nmcli_terse` is
    the replacement that respects nmcli's documented `\\:` escaping in
    terse mode.
    """

    def test_empty_line(self):
        assert _split_nmcli_terse("") == []

    def test_simple_four_fields(self):
        # SSID:SIGNAL:SECURITY:IN-USE from `nmcli -t -f ... device wifi list`
        assert _split_nmcli_terse("HomeWiFi:88:WPA2:*") == ["HomeWiFi", "88", "WPA2", "*"]

    def test_trailing_empty_field(self):
        # IN-USE is empty when this row isn't the connected network.
        assert _split_nmcli_terse("Guest:50:WPA2:") == ["Guest", "50", "WPA2", ""]

    def test_ssid_with_escaped_colon(self):
        # SSID literally named "Cafe:Lounge" comes back as "Cafe\:Lounge"
        # under default nmcli -t escaping. Naive str.split(':') corrupts it.
        assert _split_nmcli_terse(r"Cafe\:Lounge:67:WPA2:") == ["Cafe:Lounge", "67", "WPA2", ""]

    def test_connection_name_with_escaped_backslash(self):
        # Backslashes also escaped (as '\\\\') in terse mode.
        assert _split_nmcli_terse(r"home\\net:wifi:connected:Home") == ["home\\net", "wifi", "connected", "Home"]

    def test_multiple_escaped_colons_in_one_field(self):
        # SSID containing multiple colons.
        assert _split_nmcli_terse(r"a\:b\:c:1:open:") == ["a:b:c", "1", "open", ""]

    def test_no_unescaped_colons_returns_one_part(self):
        assert _split_nmcli_terse("solo") == ["solo"]


class TestNetworkHandlers:

    @pytest.fixture(autouse=True)
    def _network_env(self, monkeypatch):
        monkeypatch.setattr(_mod, "AGENT_API_KEY", "test-key")
        monkeypatch.setattr(_mod.platform, "system", lambda: "Linux")
        monkeypatch.setattr(_mod.shutil, "which", lambda name: f"/usr/bin/{name}" if name == "nmcli" else None)

    def test_wifi_scan_keeps_strongest_duplicate_ssid(self, monkeypatch):
        def fake_run(cmd, *args, **kwargs):
            if cmd[:4] == ["nmcli", "device", "wifi", "rescan"]:
                return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
            if cmd[:3] == ["nmcli", "-t", "-f"]:
                stdout = "\n".join([
                    "Cafe:20:WPA2:",
                    "Cafe:80:WPA2:*",
                    "Guest:40::",
                ])
                return subprocess.CompletedProcess(args=cmd, returncode=0, stdout=stdout, stderr="")
            raise AssertionError(f"unexpected command: {cmd}")

        monkeypatch.setattr(_mod.subprocess, "run", fake_run)
        handler = _FakeHandler(b"")

        _mod.AgentHandler._handle_network_wifi_scan(handler)

        assert handler.response_code == 200
        body = handler.parse_response()
        assert body["networks"][0] == {
            "ssid": "Cafe",
            "signal": 80,
            "security": "WPA2",
            "in_use": True,
        }
        assert body["networks"][1]["ssid"] == "Guest"

    def test_wifi_forget_refuses_non_wifi_profile(self, monkeypatch):
        calls = []

        def fake_run(cmd, *args, **kwargs):
            calls.append(cmd)
            if cmd[:3] == ["nmcli", "-t", "-f"]:
                return subprocess.CompletedProcess(
                    args=cmd,
                    returncode=0,
                    stdout="connection.type:802-3-ethernet\n",
                    stderr="",
                )
            raise AssertionError(f"unexpected command: {cmd}")

        monkeypatch.setattr(_mod.subprocess, "run", fake_run)
        handler = _FakeHandler(json.dumps({"connection": "Wired"}).encode("utf-8"))

        _mod.AgentHandler._handle_network_wifi_forget(handler)

        assert handler.response_code == 400
        assert "non-Wi-Fi" in handler.parse_response()["error"]
        assert all(cmd[:3] != ["nmcli", "connection", "delete"] for cmd in calls)

    def test_network_status_reports_nmcli_status_failure_as_unsupported(self, monkeypatch):
        def fake_run(cmd, *args, **kwargs):
            if cmd[:3] == ["nmcli", "-t", "-f"]:
                return subprocess.CompletedProcess(
                    args=cmd,
                    returncode=8,
                    stdout="",
                    stderr="NetworkManager is not running",
                )
            raise AssertionError(f"unexpected command: {cmd}")

        monkeypatch.setattr(_mod.subprocess, "run", fake_run)
        handler = _FakeHandler(b"")

        _mod.AgentHandler._handle_network_status(handler)

        assert handler.response_code == 200
        body = handler.parse_response()
        assert body["platform_supported"] is False
        assert "NetworkManager is not running" in body["reason"]


# --- _parse_mem_value ---


class TestParseMemValue:

    def test_mib(self):
        assert _parse_mem_value("256MiB") == 256.0

    def test_gib(self):
        assert _parse_mem_value("4GiB") == 4096.0

    def test_tib(self):
        assert _parse_mem_value("1TiB") == 1024 * 1024

    def test_kib(self):
        assert _parse_mem_value("512KiB") == 0.5

    def test_bytes(self):
        result = _parse_mem_value("1024B")
        assert abs(result - 1024 / (1024 * 1024)) < 1e-9

    def test_fractional_gib(self):
        assert _parse_mem_value("1.5GiB") == 1536.0

    def test_zero_bytes(self):
        assert _parse_mem_value("0B") == 0.0

    def test_dash_dash(self):
        assert _parse_mem_value("--") == 0.0

    def test_empty_string(self):
        assert _parse_mem_value("") == 0.0

    def test_invalid_number(self):
        assert _parse_mem_value("xyzMiB") == 0.0

    def test_whitespace_padding(self):
        assert _parse_mem_value("  256MiB  ") == 256.0


# --- _iso_now ---


class TestIsoNow:

    def test_returns_utc_iso_string(self):
        result = _iso_now()
        assert isinstance(result, str)
        # UTC ISO strings end with +00:00
        assert "+00:00" in result

    def test_contains_t_separator(self):
        result = _iso_now()
        assert "T" in result


class TestToBashPath:

    def test_leaves_posix_paths_unchanged(self, monkeypatch):
        monkeypatch.setattr(_mod.platform, "system", lambda: "Linux")
        assert _to_bash_path(PurePosixPath("/opt/dream-server")) == "/opt/dream-server"

    def test_converts_windows_drive_path(self, monkeypatch):
        monkeypatch.setattr(_mod.platform, "system", lambda: "Windows")
        assert _to_bash_path(Path(r"C:\Users\Gabriel\dream-server")) == "/c/Users/Gabriel/dream-server"


class TestValidateCoreRecreateIds:

    def test_accepts_allowed_core_service(self, monkeypatch):
        monkeypatch.setattr(_mod, "CORE_SERVICE_IDS", {"llama-server", "dashboard-api"})
        ok, error = validate_core_recreate_ids(["llama-server"])
        assert ok is True
        assert error == ""

    def test_rejects_non_core_service(self, monkeypatch):
        monkeypatch.setattr(_mod, "CORE_SERVICE_IDS", {"dashboard-api"})
        ok, error = validate_core_recreate_ids(["llama-server"])
        assert ok is False
        assert "not a core" in error.lower()

    def test_rejects_disallowed_core_service(self, monkeypatch):
        monkeypatch.setattr(_mod, "CORE_SERVICE_IDS", {"dashboard-api"})
        ok, error = validate_core_recreate_ids(["dashboard-api"])
        assert ok is False
        assert "not eligible" in error.lower()


class TestResolveComposeFlags:

    def test_prefers_saved_compose_flags_file(self, tmp_path, monkeypatch):
        install_dir = tmp_path / "dream-server"
        install_dir.mkdir()
        (install_dir / ".compose-flags").write_text("--env-file .env -f docker-compose.base.yml", encoding="utf-8")

        monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)

        assert resolve_compose_flags() == ["--env-file", ".env", "-f", "docker-compose.base.yml"]


class TestComposeCacheInvalidationWire:
    """End-to-end HTTP test: dashboard-api client talks to the real host-agent handler."""

    def test_client_posts_to_host_agent_and_unlinks_cache(self, tmp_path, monkeypatch):
        import threading
        from http.server import HTTPServer

        from routers import extensions as ext_router

        install_dir = tmp_path / "dream-server"
        install_dir.mkdir()
        cache_file = install_dir / ".compose-flags"
        cache_file.write_text("stale-flags", encoding="utf-8")

        monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
        monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")

        server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
        port = server.server_address[1]
        thread = threading.Thread(target=server.serve_forever, daemon=True)
        thread.start()
        try:
            monkeypatch.setattr(ext_router, "AGENT_URL", f"http://127.0.0.1:{port}")
            monkeypatch.setattr(ext_router, "DREAM_AGENT_KEY", "wire-test-secret")

            # Correct key → cache file is unlinked, helper returns without raising.
            ext_router._call_agent_invalidate_compose_cache()
            assert not cache_file.exists()

            # Wrong key → handler rejects with 403, helper logs and returns; cache
            # stays put. Proves the Authorization: Bearer <key> header is checked.
            cache_file.write_text("stale-again", encoding="utf-8")
            monkeypatch.setattr(ext_router, "DREAM_AGENT_KEY", "wrong-secret")
            ext_router._call_agent_invalidate_compose_cache()
            assert cache_file.exists()
        finally:
            server.shutdown()
            server.server_close()
            thread.join(timeout=2)


class TestComposeToggleWire:
    """End-to-end HTTP test for built-in compose toggles via the host agent."""

    def test_client_posts_to_host_agent_and_renames_builtin_compose(
        self, tmp_path, monkeypatch,
    ):
        import threading
        from http.server import HTTPServer

        from routers import extensions as ext_router

        builtin_root = tmp_path / "builtin"
        user_root = tmp_path / "user"
        builtin_root.mkdir()
        user_root.mkdir()
        ext_dir = builtin_root / "fakesvc"
        ext_dir.mkdir()
        (ext_dir / "compose.yaml.disabled").write_text(
            "services:\n  svc:\n    image: test:latest\n",
            encoding="utf-8",
        )

        monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtin_root)
        monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
        monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")

        server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
        port = server.server_address[1]
        thread = threading.Thread(target=server.serve_forever, daemon=True)
        thread.start()
        try:
            monkeypatch.setattr(ext_router, "AGENT_URL", f"http://127.0.0.1:{port}")
            monkeypatch.setattr(ext_router, "DREAM_AGENT_KEY", "wire-test-secret")

            assert ext_router._call_agent_compose_rename("activate", "fakesvc") is True
            assert (ext_dir / "compose.yaml").exists()
            assert not (ext_dir / "compose.yaml.disabled").exists()

            assert ext_router._call_agent_compose_rename("deactivate", "fakesvc") is True
            assert (ext_dir / "compose.yaml.disabled").exists()
            assert not (ext_dir / "compose.yaml").exists()

            monkeypatch.setattr(ext_router, "DREAM_AGENT_KEY", "wrong-secret")
            assert ext_router._call_agent_compose_rename("activate", "fakesvc") is False
            assert (ext_dir / "compose.yaml.disabled").exists()
        finally:
            server.shutdown()
            server.server_close()
            thread.join(timeout=2)


class TestSyncExtensionConfigWire:
    """End-to-end HTTP test: dashboard-api client + real host-agent handler.

    Proves that ${INSTALL_DIR}/config/<svc>/ ends up populated even though
    the dashboard-api side only does an HTTP call (not filesystem work).
    """

    def _make_extension(self, user_root, sid):
        ext = user_root / sid
        cfg = ext / "config" / sid
        cfg.mkdir(parents=True)
        (cfg / "settings.yaml").write_text("server: ok\n", encoding="utf-8")
        (cfg / "entrypoint.sh").write_text("#!/bin/sh\necho run\n", encoding="utf-8")
        return ext

    def test_client_posts_and_host_agent_copies_config(self, tmp_path, monkeypatch):
        import threading
        from http.server import HTTPServer

        from routers import extensions as ext_router

        install_dir = tmp_path / "install"
        user_root = install_dir / "data" / "user-extensions"
        install_dir.mkdir()
        user_root.mkdir(parents=True)
        self._make_extension(user_root, "fakesvc")

        monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
        monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
        monkeypatch.setattr(_mod, "EXTENSIONS_DIR", tmp_path / "builtin-empty")
        monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")

        server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
        port = server.server_address[1]
        thread = threading.Thread(target=server.serve_forever, daemon=True)
        thread.start()
        try:
            monkeypatch.setattr(ext_router, "AGENT_URL", f"http://127.0.0.1:{port}")
            monkeypatch.setattr(ext_router, "DREAM_AGENT_KEY", "wire-test-secret")

            assert ext_router._call_agent_sync_config("fakesvc") is True

            target = install_dir / "config" / "fakesvc"
            assert (target / "settings.yaml").read_text(encoding="utf-8") == "server: ok\n"
            # .sh files become executable
            import stat as _s
            mode = (target / "entrypoint.sh").stat().st_mode
            assert mode & _s.S_IXUSR
        finally:
            server.shutdown()
            server.server_close()
            thread.join(timeout=2)

    def test_noop_when_extension_has_no_config_subdir(self, tmp_path, monkeypatch):
        import threading
        from http.server import HTTPServer

        from routers import extensions as ext_router

        install_dir = tmp_path / "install"
        user_root = install_dir / "data" / "user-extensions"
        install_dir.mkdir()
        user_root.mkdir(parents=True)
        (user_root / "noconfig").mkdir()  # no config/ subdir

        monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
        monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
        monkeypatch.setattr(_mod, "EXTENSIONS_DIR", tmp_path / "builtin-empty")
        monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")

        server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
        port = server.server_address[1]
        thread = threading.Thread(target=server.serve_forever, daemon=True)
        thread.start()
        try:
            monkeypatch.setattr(ext_router, "AGENT_URL", f"http://127.0.0.1:{port}")
            monkeypatch.setattr(ext_router, "DREAM_AGENT_KEY", "wire-test-secret")

            # No config/ → server returns 200 with empty synced list, helper True.
            assert ext_router._call_agent_sync_config("noconfig") is True
            # No INSTALL_DIR/config/noconfig should have been created.
            assert not (install_dir / "config" / "noconfig").exists()
        finally:
            server.shutdown()
            server.server_close()
            thread.join(timeout=2)

    def test_rejects_wrong_auth(self, tmp_path, monkeypatch):
        import threading
        from http.server import HTTPServer

        from routers import extensions as ext_router

        install_dir = tmp_path / "install"
        user_root = install_dir / "data" / "user-extensions"
        install_dir.mkdir()
        user_root.mkdir(parents=True)
        self._make_extension(user_root, "fakesvc")

        monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
        monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
        monkeypatch.setattr(_mod, "EXTENSIONS_DIR", tmp_path / "builtin-empty")
        monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")

        server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
        port = server.server_address[1]
        thread = threading.Thread(target=server.serve_forever, daemon=True)
        thread.start()
        try:
            monkeypatch.setattr(ext_router, "AGENT_URL", f"http://127.0.0.1:{port}")
            monkeypatch.setattr(ext_router, "DREAM_AGENT_KEY", "wrong-secret")

            assert ext_router._call_agent_sync_config("fakesvc") is False
            # Nothing copied — auth was rejected before any work.
            assert not (install_dir / "config" / "fakesvc").exists()
        finally:
            server.shutdown()
            server.server_close()
            thread.join(timeout=2)

    @staticmethod
    def _post(port, sid, *, key="wire-test-secret"):
        """Direct HTTP POST to /v1/extension/sync_config so callers can
        assert on the raw status code (the dashboard-api helper masks
        4xx as a generic False, which is too coarse for these tests)."""
        import json as _json
        import urllib.request
        import urllib.error
        url = f"http://127.0.0.1:{port}/v1/extension/sync_config"
        req = urllib.request.Request(
            url,
            data=_json.dumps({"service_id": sid}).encode(),
            headers={
                "Content-Type": "application/json",
                "Authorization": f"Bearer {key}",
            },
            method="POST",
        )
        try:
            with urllib.request.urlopen(req, timeout=5) as resp:
                return resp.status, _json.loads(resp.read() or b"{}")
        except urllib.error.HTTPError as exc:
            try:
                body = _json.loads(exc.read() or b"{}")
            except ValueError:
                body = {}
            return exc.code, body

    def test_rejects_symlink_in_config_tree(self, tmp_path, monkeypatch):
        """Symlinks (file or directory, top-level or nested) must be rejected
        outright. _copytree_safe strips symlinks at install time so legitimate
        user extensions never have any; one here implies tampering."""
        import threading
        from http.server import HTTPServer

        install_dir = tmp_path / "install"
        user_root = install_dir / "data" / "user-extensions"
        install_dir.mkdir()
        user_root.mkdir(parents=True)
        secret_dir = tmp_path / "secret"
        secret_dir.mkdir()
        (secret_dir / "private.key").write_text("EXFIL ME", encoding="utf-8")

        # Build an extension whose config/ contains a symlinked directory
        # pointing OUTSIDE the extension.  Pre-fix this got dereferenced by
        # shutil.copytree(symlinks=False) and the secret leaked into
        # INSTALL_DIR/config/leak/private.key.
        ext = user_root / "fakesvc"
        cfg = ext / "config"
        cfg.mkdir(parents=True)
        # Plus a normal file in the same tree to prove nothing was copied.
        (cfg / "ok.yaml").write_text("ok: true\n", encoding="utf-8")
        (cfg / "leak").symlink_to(secret_dir)

        monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
        monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
        monkeypatch.setattr(_mod, "EXTENSIONS_DIR", tmp_path / "builtin-empty")
        monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")

        server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
        port = server.server_address[1]
        thread = threading.Thread(target=server.serve_forever, daemon=True)
        thread.start()
        try:
            status, body = self._post(port, "fakesvc")
            assert status == 400, f"expected 400, got {status}: {body}"
            assert "symlink" in body.get("error", "").lower()
            # Crucially: the secret file did NOT make it into INSTALL_DIR.
            assert not (install_dir / "config" / "fakesvc").exists()
            assert not (install_dir / "config" / "leak").exists()
        finally:
            server.shutdown()
            server.server_close()
            thread.join(timeout=2)

    def test_rejects_symlinked_file_in_config_tree(self, tmp_path, monkeypatch):
        """Symlinked files (not just directories) are also rejected."""
        import threading
        from http.server import HTTPServer

        install_dir = tmp_path / "install"
        user_root = install_dir / "data" / "user-extensions"
        install_dir.mkdir()
        user_root.mkdir(parents=True)
        secret = tmp_path / "secret.txt"
        secret.write_text("nope", encoding="utf-8")

        ext = user_root / "fakesvc"
        cfg = ext / "config" / "fakesvc"
        cfg.mkdir(parents=True)
        (cfg / "leak.txt").symlink_to(secret)

        monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
        monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
        monkeypatch.setattr(_mod, "EXTENSIONS_DIR", tmp_path / "builtin-empty")
        monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")

        server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
        port = server.server_address[1]
        thread = threading.Thread(target=server.serve_forever, daemon=True)
        thread.start()
        try:
            status, body = self._post(port, "fakesvc")
            assert status == 400
            assert "symlink" in body.get("error", "").lower()
            assert not (install_dir / "config" / "fakesvc").exists()
        finally:
            server.shutdown()
            server.server_close()
            thread.join(timeout=2)

    def test_rejects_when_config_dir_itself_is_symlink(self, tmp_path, monkeypatch):
        """Top-level `config/` itself a symlink — covers the upfront
        ext_config.is_symlink() guard, separate from the dirs+files walk."""
        import threading
        from http.server import HTTPServer

        install_dir = tmp_path / "install"
        user_root = install_dir / "data" / "user-extensions"
        install_dir.mkdir()
        user_root.mkdir(parents=True)
        secret_dir = tmp_path / "secret"
        secret_dir.mkdir()
        (secret_dir / "private.key").write_text("EXFIL ME", encoding="utf-8")

        # Build an extension whose `config` IS the symlink (not a child of it).
        ext = user_root / "fakesvc"
        ext.mkdir()
        (ext / "config").symlink_to(secret_dir)

        monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
        monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
        monkeypatch.setattr(_mod, "EXTENSIONS_DIR", tmp_path / "builtin-empty")
        monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")

        server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
        port = server.server_address[1]
        thread = threading.Thread(target=server.serve_forever, daemon=True)
        thread.start()
        try:
            status, body = self._post(port, "fakesvc")
            assert status == 400, f"expected 400, got {status}: {body}"
            assert "symlink" in body.get("error", "").lower()
            assert not (install_dir / "config" / "fakesvc").exists()
        finally:
            server.shutdown()
            server.server_close()
            thread.join(timeout=2)

    def test_rejects_invalid_service_id(self, tmp_path, monkeypatch):
        """SERVICE_ID_RE rejection — match the auth/symlink reject style."""
        import threading
        from http.server import HTTPServer

        install_dir = tmp_path / "install"
        user_root = install_dir / "data" / "user-extensions"
        install_dir.mkdir()
        user_root.mkdir(parents=True)

        monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
        monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
        monkeypatch.setattr(_mod, "EXTENSIONS_DIR", tmp_path / "builtin-empty")
        monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")

        server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
        port = server.server_address[1]
        thread = threading.Thread(target=server.serve_forever, daemon=True)
        thread.start()
        try:
            for bad in ("../escape", "Bad-ID", "with space", "", "..", "FAKE"):
                status, body = self._post(port, bad)
                assert status == 400, f"bad={bad!r} -> {status}: {body}"
                assert "service_id" in body.get("error", "").lower()
        finally:
            server.shutdown()
            server.server_close()
            thread.join(timeout=2)

    def test_noop_for_builtin_only_service(self, tmp_path, monkeypatch):
        """Built-in extensions (not in USER_EXTENSIONS_DIR) get a 200 no-op.

        Pins the deliberate decision NOT to overwrite installer-managed
        configs when a built-in's compose toggle re-enables it.
        """
        import threading
        from http.server import HTTPServer

        install_dir = tmp_path / "install"
        user_root = install_dir / "data" / "user-extensions"
        builtin_root = tmp_path / "builtin"
        install_dir.mkdir()
        user_root.mkdir(parents=True)
        builtin_root.mkdir()
        # Built-in present, with a config/ subdir that should NOT be touched.
        builtin_ext = builtin_root / "core-svc" / "config" / "core-svc"
        builtin_ext.mkdir(parents=True)
        (builtin_ext / "should_not_be_synced.yaml").write_text("x: 1\n", encoding="utf-8")

        monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
        monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
        monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtin_root)
        monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")

        server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
        port = server.server_address[1]
        thread = threading.Thread(target=server.serve_forever, daemon=True)
        thread.start()
        try:
            status, body = self._post(port, "core-svc")
            assert status == 200
            assert body.get("synced") == []
            # No file should have been written into INSTALL_DIR/config/.
            assert not (install_dir / "config" / "core-svc").exists()
        finally:
            server.shutdown()
            server.server_close()
            thread.join(timeout=2)

    def test_default_contract_only_copies_own_service_subdir(
        self, tmp_path, monkeypatch,
    ):
        """Strict regression for the audit-flagged copy-contract weakness.

        An extension shipping `<ext>/config/open-webui/` (or any other
        sibling directory) must NOT have that tree copied into
        `INSTALL_DIR/config/open-webui/` — that would let an extension
        overwrite installer-managed core-service config (open-webui,
        litellm, etc.) or another extension's config tree.

        Default contract: only `<ext>/config/<service_id>/` is synced.
        Sibling entries are logged as out-of-scope and reported in the
        response's `skipped` array, but never written to INSTALL_DIR.
        """
        import threading
        from http.server import HTTPServer

        install_dir = tmp_path / "install"
        user_root = install_dir / "data" / "user-extensions"
        install_dir.mkdir()
        user_root.mkdir(parents=True)

        # Build a malicious-shape extension: `evil-ext` that ships its OWN
        # legitimate config subdir AND tries to overwrite open-webui's.
        ext = user_root / "evil-ext"
        own = ext / "config" / "evil-ext"
        own.mkdir(parents=True)
        (own / "settings.yaml").write_text("ok: true\n", encoding="utf-8")

        clobber_target = ext / "config" / "open-webui"
        clobber_target.mkdir(parents=True)
        (clobber_target / "config.json").write_text(
            "OVERWRITTEN", encoding="utf-8",
        )
        # Also a file directly under config/ (not in any subdir), proving the
        # contract restriction applies to file siblings too.
        (ext / "config" / "stray.txt").write_text("stray", encoding="utf-8")

        # Pre-create open-webui core config so the test can prove byte-for-byte
        # that it was NOT touched by the sync call.
        existing_owui = install_dir / "config" / "open-webui"
        existing_owui.mkdir(parents=True)
        (existing_owui / "config.json").write_text(
            "ORIGINAL", encoding="utf-8",
        )

        monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
        monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
        monkeypatch.setattr(_mod, "EXTENSIONS_DIR", tmp_path / "builtin-empty")
        monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")

        server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
        port = server.server_address[1]
        thread = threading.Thread(target=server.serve_forever, daemon=True)
        thread.start()
        try:
            status, body = self._post(port, "evil-ext")
            assert status == 200
            # In-scope copy succeeded.
            assert body.get("synced") == ["evil-ext"]
            assert (install_dir / "config" / "evil-ext" / "settings.yaml").read_text(
                encoding="utf-8",
            ) == "ok: true\n"
            # Out-of-scope entries reported (order not guaranteed).
            skipped = set(body.get("skipped", []))
            assert "open-webui" in skipped
            assert "stray.txt" in skipped
            # Crucially: open-webui core config remains BYTE-FOR-BYTE untouched.
            assert (existing_owui / "config.json").read_text(
                encoding="utf-8",
            ) == "ORIGINAL"
            # The malicious overwrite payload did NOT escape into INSTALL_DIR.
            assert not (install_dir / "config" / "stray.txt").exists()
        finally:
            server.shutdown()
            server.server_close()
            thread.join(timeout=2)


class TestInvalidateComposeCache:

    def test_unlinks_existing_cache_file(self, tmp_path, monkeypatch):
        install_dir = tmp_path / "dream-server"
        install_dir.mkdir()
        cache_file = install_dir / ".compose-flags"
        cache_file.write_text("--env-file .env", encoding="utf-8")
        monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)

        invalidate_compose_cache()

        assert not cache_file.exists()

    def test_missing_cache_file_is_noop(self, tmp_path, monkeypatch):
        install_dir = tmp_path / "dream-server"
        install_dir.mkdir()
        monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)

        invalidate_compose_cache()  # must not raise


# --- Install setup-hook env allowlist (regression) ---
#
# Locks in the fix that strips host-agent secrets from the env passed to
# extension setup hooks. The env-construction + subprocess.run call lives in
# the shared `_run_post_install_hook` helper (used by both _handle_install
# and _enable_retry_work). A source-level check is used because the helper
# is invoked from a nested closure on a daemon thread, which makes dynamic
# mocking fragile.


class TestInstallHookEnvAllowlist:

    def _hook_helper_source(self):
        import inspect
        return inspect.getsource(_mod._run_post_install_hook)

    def _install_source(self):
        import inspect
        return inspect.getsource(_mod.AgentHandler._handle_install)

    def test_setup_hook_subprocess_run_passes_env_kwarg(self):
        src = self._hook_helper_source()
        assert "env=hook_env" in src, (
            "setup_hook subprocess.run must pass env=hook_env "
            "(regression: do not fall back to inheriting os.environ)"
        )

    def test_setup_hook_env_excludes_host_agent_secrets(self):
        # Both the helper and the call-site must stay secret-free.
        for src in (self._hook_helper_source(), self._install_source()):
            for secret in ("AGENT_API_KEY", "DREAM_AGENT_KEY", "DASHBOARD_API_KEY"):
                assert secret not in src, (
                    f"setup_hook code path must not reference {secret}; "
                    "extension setup hooks must not receive host-agent secrets"
                )

    def test_setup_hook_env_contains_allowlist_keys(self):
        src = self._hook_helper_source()
        for key in (
            "PATH", "HOME", "SERVICE_ID", "SERVICE_PORT",
            "SERVICE_DATA_DIR", "DREAM_VERSION", "GPU_BACKEND", "HOOK_NAME",
        ):
            assert f'"{key}"' in src, (
                f"setup_hook env allowlist missing required key {key}"
            )

    def test_setup_hook_uses_resolve_hook_with_post_install(self):
        src = self._hook_helper_source()
        assert '_resolve_hook(ext_dir, "post_install")' in src, (
            "setup_hook must use _resolve_hook(..., 'post_install'); "
            "the legacy _resolve_setup_hook has been removed"
        )


# --- Install "up -d" must not use --no-deps (regression) ---
#
# _handle_install previously passed --no-deps to `docker compose up -d`, which
# prevented an extension's private sidecar services (declared in its own
# compose fragment) from starting — including cross-extension depends_on
# relationships like perplexica -> searxng. The fix removes --no-deps from
# the install path only; docker_compose_recreate (used for core-service
# force-recreate after a model swap) intentionally keeps --no-deps.


class TestInstallStartCommandNoDeps:

    def _install_source(self):
        import inspect
        return inspect.getsource(_mod.AgentHandler._handle_install)

    def _recreate_source(self):
        import inspect
        return inspect.getsource(_mod.docker_compose_recreate)

    def test_install_up_command_does_not_pass_no_deps(self):
        src = self._install_source()
        assert '"--no-deps"' not in src and "'--no-deps'" not in src, (
            "_handle_install must not pass --no-deps to `docker compose up -d`; "
            "extensions with private sidecars or cross-extension depends_on "
            "need compose to bring dependencies up."
        )

    def test_docker_compose_recreate_still_uses_no_deps(self):
        src = self._recreate_source()
        assert '"--no-deps"' in src or "'--no-deps'" in src, (
            "docker_compose_recreate must keep --no-deps; "
            "core-service recreation (e.g. after a model swap) is intentionally "
            "scoped to the named services only."
        )


# --- _post_install_core_recreate ---
#
# openclaw's compose.yaml adds OPENAI_API_BASE_URLS to open-webui as an overlay;
# `docker compose up -d openclaw` (used by _handle_install) won't pick up
# overlay changes targeting already-running core services without
# `--force-recreate`. Hence the post-install recreate of open-webui whenever
# openclaw is installed.


class TestPostInstallCoreRecreate:

    def test_openclaw_triggers_open_webui_recreate(self, monkeypatch):
        calls = []

        def _fake_recreate(ids):
            calls.append(list(ids))
            return True, ""

        monkeypatch.setattr(_mod, "docker_compose_recreate", _fake_recreate)
        _post_install_core_recreate("openclaw")
        assert calls == [["open-webui"]]

    def test_non_openclaw_service_is_noop(self, monkeypatch):
        calls = []

        def _fake_recreate(ids):
            calls.append(list(ids))
            return True, ""

        monkeypatch.setattr(_mod, "docker_compose_recreate", _fake_recreate)
        for svc in ("litellm", "n8n", "perplexica", "whisper", "comfyui"):
            _post_install_core_recreate(svc)
        assert calls == []

    def test_recreate_failure_is_swallowed(self, monkeypatch):
        """Install must not fail if the post-install recreate errors — openclaw
        is already running; the overlay just won't take effect until a manual
        core restart."""

        def _fake_recreate(_ids):
            return False, "docker compose exploded"

        monkeypatch.setattr(_mod, "docker_compose_recreate", _fake_recreate)
        # Must not raise
        _post_install_core_recreate("openclaw")


class TestRunInstallCallsPostInstallRecreate:
    """Source-level check that the install closure calls
    _post_install_core_recreate after the "started" progress write.

    The dynamic flow runs in a daemon thread + nested closure, which makes
    runtime mocking fragile (see TestInstallHookEnvAllowlist for the same
    reasoning). Source-level assertion is sufficient to lock the wiring."""

    def _install_source(self):
        import inspect
        return inspect.getsource(_mod.AgentHandler._handle_install)

    def test_install_calls_post_install_core_recreate(self):
        src = self._install_source()
        assert "_post_install_core_recreate(service_id)" in src, (
            "_run_install must invoke _post_install_core_recreate(service_id) "
            "after emitting the 'started' progress record"
        )

    def test_recreate_is_after_started_progress_write(self):
        src = self._install_source()
        started_idx = src.find('"started"')
        recreate_idx = src.find("_post_install_core_recreate(")
        assert started_idx != -1, "expected 'started' progress write in _handle_install"
        assert recreate_idx != -1, "expected _post_install_core_recreate call in _handle_install"
        assert started_idx < recreate_idx, (
            "_post_install_core_recreate must run AFTER the 'started' progress "
            "write so the client sees success even if the recreate fails"
        )


# --- _handle_env_update ---


class _FakeHandler:
    """Minimal stand-in for BaseHTTPRequestHandler used by _handle_env_update."""

    def __init__(self, body: bytes, headers=None):
        merged = {
            "Authorization": "Bearer test-key",
            "Content-Length": str(len(body)),
        }
        if headers:
            merged.update(headers)
        self.headers = merged
        self.rfile = io.BytesIO(body)
        self.wfile = io.BytesIO()
        self.client_address = ("127.0.0.1", 12345)
        self.response_code = None
        self.response_headers = []

    def send_response(self, code):
        self.response_code = code

    def send_header(self, name, value):
        self.response_headers.append((name, value))

    def end_headers(self):
        pass

    def parse_response(self):
        # json_response writes the JSON body via wfile.write()
        return json.loads(self.wfile.getvalue().decode("utf-8"))


class TestServiceRestart:
    """Direct host-agent tests for POST /v1/service/restart behavior."""

    def _write_manifest(self, ext_root: Path, service_id: str, service_block: str):
        pytest.importorskip("yaml")
        ext_dir = ext_root / service_id
        ext_dir.mkdir(parents=True, exist_ok=True)
        (ext_dir / "manifest.yaml").write_text(
            "schema_version: dream.services.v1\n"
            "service:\n"
            f"  id: {service_id}\n"
            f"{service_block}",
            encoding="utf-8",
        )

    def _configure(self, tmp_path, monkeypatch):
        builtin_root = tmp_path / "builtin"
        user_root = tmp_path / "user"
        builtin_root.mkdir()
        user_root.mkdir()
        monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtin_root)
        monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
        monkeypatch.setattr(_mod, "CORE_SERVICE_IDS", set())
        monkeypatch.setattr(_mod, "AGENT_API_KEY", "test-key")
        return builtin_root, user_root

    def _body(self, service_id: str, **extra) -> bytes:
        return json.dumps({"service_id": service_id, **extra}).encode("utf-8")

    def test_valid_restart_calls_docker_restart(self, tmp_path, monkeypatch):
        builtin_root, _ = self._configure(tmp_path, monkeypatch)
        self._write_manifest(
            builtin_root,
            "ape",
            "  name: APE\n"
            "  container_name: dream-ape\n",
        )
        _mod._service_locks.pop("ape", None)
        monkeypatch.setattr(_mod, "_resolve_container_name", lambda sid: "dream-ape")

        calls = []

        def fake_run(cmd, *args, **kwargs):
            calls.append(cmd)
            return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")

        monkeypatch.setattr(_mod.subprocess, "run", fake_run)

        handler = _FakeHandler(self._body("ape"))
        _mod.AgentHandler._handle_service_restart(handler)

        assert handler.response_code == 200
        assert handler.parse_response()["action"] == "restart"
        assert calls == [["docker", "restart", "dream-ape"]]

    def test_restart_requires_auth(self, tmp_path, monkeypatch):
        self._configure(tmp_path, monkeypatch)
        handler = _FakeHandler(self._body("ape"), headers={"Authorization": "Bearer wrong"})

        _mod.AgentHandler._handle_service_restart(handler)

        assert handler.response_code == 403

    def test_invalid_service_id_rejected(self, tmp_path, monkeypatch):
        self._configure(tmp_path, monkeypatch)
        handler = _FakeHandler(self._body("../ape"))

        _mod.AgentHandler._handle_service_restart(handler)

        assert handler.response_code == 400
        assert handler.parse_response()["error"] == "Invalid service_id"

    def test_unknown_service_rejected(self, tmp_path, monkeypatch):
        self._configure(tmp_path, monkeypatch)
        handler = _FakeHandler(self._body("not-installed"))

        _mod.AgentHandler._handle_service_restart(handler)

        assert handler.response_code == 404
        assert "not-installed" in handler.parse_response()["error"]

    def test_host_systemd_service_rejected_before_docker(self, tmp_path, monkeypatch):
        builtin_root, _ = self._configure(tmp_path, monkeypatch)
        self._write_manifest(
            builtin_root,
            "opencode",
            "  name: OpenCode\n"
            "  type: host-systemd\n"
            "  container_name: \"\"\n",
        )

        def fake_run(*args, **kwargs):
            pytest.fail("host-systemd service must not run docker restart")

        monkeypatch.setattr(_mod.subprocess, "run", fake_run)

        handler = _FakeHandler(self._body("opencode"))
        _mod.AgentHandler._handle_service_restart(handler)

        assert handler.response_code == 400
        assert "host-level" in handler.parse_response()["error"].lower()

    def test_extension_without_manifest_is_not_restartable(self, tmp_path, monkeypatch):
        builtin_root, _ = self._configure(tmp_path, monkeypatch)
        (builtin_root / "broken").mkdir()

        def fake_run(*args, **kwargs):
            pytest.fail("missing manifest service must not run docker restart")

        monkeypatch.setattr(_mod.subprocess, "run", fake_run)

        handler = _FakeHandler(self._body("broken"))
        _mod.AgentHandler._handle_service_restart(handler)

        assert handler.response_code == 400
        assert "manifest" in handler.parse_response()["error"].lower()

    def test_concurrent_restart_returns_409(self, tmp_path, monkeypatch):
        builtin_root, _ = self._configure(tmp_path, monkeypatch)
        self._write_manifest(
            builtin_root,
            "ape",
            "  name: APE\n"
            "  container_name: dream-ape\n",
        )
        lock = _mod._service_locks["ape"]
        assert lock.acquire(blocking=False) is True
        try:
            handler = _FakeHandler(self._body("ape"))
            _mod.AgentHandler._handle_service_restart(handler)
        finally:
            lock.release()
            _mod._service_locks.pop("ape", None)

        assert handler.response_code == 409

    def test_delayed_restart_returns_accepted_and_releases_lock(self, tmp_path, monkeypatch):
        builtin_root, _ = self._configure(tmp_path, monkeypatch)
        self._write_manifest(
            builtin_root,
            "dashboard-api",
            "  name: Dashboard API\n"
            "  container_name: dream-dashboard-api\n",
        )
        _mod._service_locks.pop("dashboard-api", None)
        monkeypatch.setattr(_mod, "_resolve_container_name", lambda sid: "dream-dashboard-api")
        monkeypatch.setattr(_mod.time, "sleep", lambda *_args: None)

        class ImmediateThread:
            def __init__(self, target=None, daemon=None, **kwargs):
                self._target = target

            def start(self):
                self._target()

        monkeypatch.setattr(_mod.threading, "Thread", ImmediateThread)

        calls = []

        def fake_run(cmd, *args, **kwargs):
            calls.append(cmd)
            return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")

        monkeypatch.setattr(_mod.subprocess, "run", fake_run)

        handler = _FakeHandler(self._body("dashboard-api", delay_seconds=1))
        _mod.AgentHandler._handle_service_restart(handler)

        assert handler.response_code == 202
        assert handler.parse_response()["status"] == "accepted"
        assert calls == [["docker", "restart", "dream-dashboard-api"]]
        assert _mod._service_locks["dashboard-api"].acquire(blocking=False) is True
        _mod._service_locks["dashboard-api"].release()


@pytest.fixture
def env_update_env(tmp_path, monkeypatch):
    """Wire up INSTALL_DIR/DATA_DIR/AGENT_API_KEY for _handle_env_update tests."""
    install_dir = tmp_path / "install"
    install_dir.mkdir()
    data_dir = tmp_path / "data"
    data_dir.mkdir()

    schema = {
        "properties": {
            "DREAM_AGENT_KEY": {"type": "string"},
            "GGUF_FILE": {"type": "string"},
        }
    }
    (install_dir / ".env.schema.json").write_text(json.dumps(schema), encoding="utf-8")
    (install_dir / ".env").write_text("DREAM_AGENT_KEY=existing\n", encoding="utf-8")

    monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
    monkeypatch.setattr(_mod, "DATA_DIR", data_dir)
    monkeypatch.setattr(_mod, "AGENT_API_KEY", "test-key")
    return install_dir, data_dir


def _make_body(raw_text: str, backup: bool = True) -> bytes:
    return json.dumps({"raw_text": raw_text, "backup": backup}).encode("utf-8")


class TestHandleEnvUpdate:

    def test_happy_path_writes_file_and_returns_backup(self, env_update_env):
        install_dir, data_dir = env_update_env
        body = _make_body("DREAM_AGENT_KEY=newvalue\nGGUF_FILE=/models/foo.gguf\n")
        handler = _FakeHandler(body)

        _mod.AgentHandler._handle_env_update(handler)

        assert handler.response_code == 200
        resp = handler.parse_response()
        assert resp["status"] == "ok"
        assert resp["backup_path"].startswith("data/config-backups/.env.backup.")
        env_text = (install_dir / ".env").read_text(encoding="utf-8")
        assert "DREAM_AGENT_KEY=newvalue" in env_text
        assert "GGUF_FILE=/models/foo.gguf" in env_text
        # backup file actually exists where the response says it does
        backup_files = list((data_dir / "config-backups").glob(".env.backup.*"))
        assert len(backup_files) == 1

    def test_413_oversize_body(self, env_update_env):
        # Construct headers claiming body is too large; rfile content is irrelevant.
        handler = _FakeHandler(b"x", headers={"Content-Length": str(_mod.MAX_BODY + 999999) if hasattr(_mod, "MAX_BODY") else "100000"})
        # MAX_ENV_BODY is hard-coded to 65536 inside the handler.
        handler.headers["Content-Length"] = "70000"

        _mod.AgentHandler._handle_env_update(handler)

        assert handler.response_code == 413
        assert "too large" in handler.parse_response()["error"].lower()

    def test_accepts_unknown_key_with_warning(self, env_update_env):
        """Non-schema keys are accepted (warn, not reject) so extension-added
        keys (e.g. JWT_SECRET from LibreChat) don't break Settings save."""
        install_dir, data_dir = env_update_env
        (install_dir / ".env").write_text("DREAM_AGENT_KEY=old\n", encoding="utf-8")
        body = _make_body("DREAM_AGENT_KEY=old\nNOT_IN_SCHEMA=foo\n")
        handler = _FakeHandler(body)

        _mod.AgentHandler._handle_env_update(handler)

        assert handler.response_code == 200
        env_text = (install_dir / ".env").read_text(encoding="utf-8")
        assert "NOT_IN_SCHEMA=foo" in env_text

    def test_400_malformed_line(self, env_update_env):
        body = _make_body("THIS_LINE_HAS_NO_EQUALS\n")
        handler = _FakeHandler(body)

        _mod.AgentHandler._handle_env_update(handler)

        assert handler.response_code == 400
        assert "Malformed line" in handler.parse_response()["error"]

    def test_400_control_char_in_value(self, env_update_env):
        body = _make_body("DREAM_AGENT_KEY=foo\x00bar\n")
        handler = _FakeHandler(body)

        _mod.AgentHandler._handle_env_update(handler)

        assert handler.response_code == 400
        assert "control characters" in handler.parse_response()["error"]

    def test_400_control_char_escape_sequence(self, env_update_env):
        # ESC (0x1b) — common in injected ANSI sequences
        body = _make_body("DREAM_AGENT_KEY=foo\x1b[31mbar\n")
        handler = _FakeHandler(body)

        _mod.AgentHandler._handle_env_update(handler)

        assert handler.response_code == 400

    def test_tab_in_value_is_allowed(self, env_update_env):
        # Tab is the only sub-32 char that should pass through.
        body = _make_body("DREAM_AGENT_KEY=foo\tbar\n")
        handler = _FakeHandler(body)

        _mod.AgentHandler._handle_env_update(handler)

        assert handler.response_code == 200

    def test_409_lock_contention(self, env_update_env):
        body = _make_body("DREAM_AGENT_KEY=newvalue\n")
        handler = _FakeHandler(body)

        assert _mod._model_activate_lock.acquire(blocking=False)
        try:
            _mod.AgentHandler._handle_env_update(handler)
        finally:
            _mod._model_activate_lock.release()

        assert handler.response_code == 409
        assert "in progress" in handler.parse_response()["error"]

    def test_500_missing_schema(self, env_update_env):
        install_dir, _ = env_update_env
        (install_dir / ".env.schema.json").unlink()
        body = _make_body("DREAM_AGENT_KEY=newvalue\n")
        handler = _FakeHandler(body)

        _mod.AgentHandler._handle_env_update(handler)

        assert handler.response_code == 500
        assert ".env.schema.json not found" in handler.parse_response()["error"]


class TestHandleModelDownloadCancel:

    def test_returns_no_download_when_idle(self, monkeypatch):
        handler = _FakeHandler(b"")
        monkeypatch.setattr(_mod, "AGENT_API_KEY", "test-key")
        monkeypatch.setattr(_mod, "_model_download_thread", None)
        _mod._model_download_cancel.clear()

        _mod.AgentHandler._handle_model_download_cancel(handler)

        assert handler.response_code == 200
        assert handler.parse_response()["status"] == "no_download"
        assert _mod._model_download_cancel.is_set() is False

    def test_sets_cancel_flag_and_kills_active_proc(self, monkeypatch):
        class _AliveThread:
            def is_alive(self):
                return True

        class _FakeProc:
            def __init__(self):
                self.killed = False

            def kill(self):
                self.killed = True

        handler = _FakeHandler(b"")
        proc = _FakeProc()
        monkeypatch.setattr(_mod, "AGENT_API_KEY", "test-key")
        monkeypatch.setattr(_mod, "_model_download_thread", _AliveThread())
        monkeypatch.setattr(_mod, "_model_download_proc", proc)
        _mod._model_download_cancel.clear()

        _mod.AgentHandler._handle_model_download_cancel(handler)

        assert handler.response_code == 200
        assert handler.parse_response()["status"] == "cancelling"
        assert _mod._model_download_cancel.is_set() is True
        assert proc.killed is True


class TestNarrowInstallPullFlags:
    """Filter flags used by the install pull step.

    Audit follow-up on PR #1057: narrowing must drop -f entries
    pointing at OTHER extensions, but keep base/GPU overlay and the
    target extension's own fragments.
    """

    def _ext_dirs(self, tmp_path):
        builtins = tmp_path / "extensions" / "services"
        users = tmp_path / "user-extensions"
        builtins.mkdir(parents=True)
        users.mkdir(parents=True)
        return builtins, users

    def test_drops_other_extension_compose(self, tmp_path, monkeypatch):
        builtins, users = self._ext_dirs(tmp_path)
        target_dir = builtins / "perplexica"
        other_dir = builtins / "searxng"
        target_dir.mkdir()
        other_dir.mkdir()
        target_compose = target_dir / "compose.yaml"
        other_compose = other_dir / "compose.yaml"
        target_compose.write_text("services: {}\n")
        other_compose.write_text("services: {}\n")

        monkeypatch.setattr(_mod, "INSTALL_DIR", tmp_path)
        monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtins)
        monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", users)

        flags = [
            "-f", str(tmp_path / "docker-compose.base.yml"),
            "-f", str(tmp_path / "docker-compose.nvidia.yml"),
            "-f", str(target_compose),
            "-f", str(other_compose),
        ]
        narrowed = _mod._narrow_install_pull_flags(flags, "perplexica")

        assert "-f" in narrowed
        assert str(other_compose) not in narrowed
        assert str(target_compose) in narrowed

    def test_keeps_base_and_gpu_overlay(self, tmp_path, monkeypatch):
        builtins, users = self._ext_dirs(tmp_path)
        target_dir = builtins / "perplexica"
        target_dir.mkdir()
        target_compose = target_dir / "compose.yaml"
        target_compose.write_text("services: {}\n")

        monkeypatch.setattr(_mod, "INSTALL_DIR", tmp_path)
        monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtins)
        monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", users)

        base = str(tmp_path / "docker-compose.base.yml")
        gpu = str(tmp_path / "docker-compose.nvidia.yml")
        flags = ["-f", base, "-f", gpu, "-f", str(target_compose)]
        narrowed = _mod._narrow_install_pull_flags(flags, "perplexica")

        assert base in narrowed
        assert gpu in narrowed
        assert str(target_compose) in narrowed

    def test_user_extension_target_is_kept(self, tmp_path, monkeypatch):
        builtins, users = self._ext_dirs(tmp_path)
        target_dir = users / "my-ext"
        other_dir = builtins / "searxng"
        target_dir.mkdir()
        other_dir.mkdir()
        target_compose = target_dir / "compose.yaml"
        other_compose = other_dir / "compose.yaml"
        target_compose.write_text("services: {}\n")
        other_compose.write_text("services: {}\n")

        monkeypatch.setattr(_mod, "INSTALL_DIR", tmp_path)
        monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtins)
        monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", users)

        flags = ["-f", str(target_compose), "-f", str(other_compose)]
        narrowed = _mod._narrow_install_pull_flags(flags, "my-ext")

        assert str(target_compose) in narrowed
        assert str(other_compose) not in narrowed


class TestNarrowedComposeSetResolves:
    """Validate that the narrowed compose set parses and contains the
    target service. Audit follow-up on PR #1057.
    """

    def test_returns_false_when_config_exits_nonzero(self, monkeypatch):
        recorded = []

        def fake_run(cmd, **kwargs):
            recorded.append(cmd)
            return _SubprocessResult(returncode=1, stdout="", stderr="depends on undefined service")

        monkeypatch.setattr(_mod.subprocess, "run", fake_run)
        ok = _mod._narrowed_compose_set_resolves(
            ["-f", "/tmp/base.yml"], "perplexica", "/tmp", 60,
        )
        assert ok is False
        assert recorded[0][:2] == ["docker", "compose"]
        assert "config" in recorded[0] and "--services" in recorded[0]

    def test_returns_false_when_target_service_missing_from_output(self, monkeypatch):
        def fake_run(cmd, **kwargs):
            return _SubprocessResult(returncode=0, stdout="searxng\nllama-server\n", stderr="")

        monkeypatch.setattr(_mod.subprocess, "run", fake_run)
        ok = _mod._narrowed_compose_set_resolves([], "perplexica", "/tmp", 60)
        assert ok is False

    def test_returns_true_when_target_service_listed(self, monkeypatch):
        def fake_run(cmd, **kwargs):
            return _SubprocessResult(returncode=0, stdout="perplexica\nsearxng\n", stderr="")

        monkeypatch.setattr(_mod.subprocess, "run", fake_run)
        ok = _mod._narrowed_compose_set_resolves([], "perplexica", "/tmp", 60)
        assert ok is True

    def test_returns_false_on_subprocess_error(self, monkeypatch):
        def fake_run(cmd, **kwargs):
            raise OSError("docker not found")

        monkeypatch.setattr(_mod.subprocess, "run", fake_run)
        ok = _mod._narrowed_compose_set_resolves([], "perplexica", "/tmp", 60)
        assert ok is False


class TestInstallPullFallsBackOnUnresolvedNarrow:
    """Source-level wire-up checks for the install pull fallback.

    These tests assert only that `_handle_install` *references* the
    helpers and contains the fallback assignment token; behavioural
    correctness of the narrow filter and the validator is covered by
    `TestNarrowInstallPullFlags` and `TestNarrowedComposeSetResolves`
    above. The token-presence pattern matches the established
    `TestInstallStartCommandNoDeps` convention in this file.
    """

    def test_install_references_narrow_helpers(self):
        import inspect
        src = inspect.getsource(_mod.AgentHandler._handle_install)
        assert "_narrowed_compose_set_resolves" in src, (
            "_handle_install source must reference _narrowed_compose_set_resolves"
        )
        assert "_narrow_install_pull_flags" in src, (
            "_handle_install source must reference _narrow_install_pull_flags"
        )

    def test_install_source_contains_full_flags_fallback_token(self):
        import inspect
        src = inspect.getsource(_mod.AgentHandler._handle_install)
        # Token-only check: confirms a `pull_flags = flags` assignment
        # exists somewhere in the handler. Does not verify control flow.
        assert "pull_flags = flags" in src, (
            "_handle_install source must contain a `pull_flags = flags` "
            "assignment (the fallback token)"
        )


class _SubprocessResult:
    """Minimal stand-in for subprocess.CompletedProcess."""

    def __init__(self, returncode: int, stdout: str, stderr: str):
        self.returncode = returncode
        self.stdout = stdout
        self.stderr = stderr
# --- _precreate_data_dirs + install flow (PR 2A regressions) ---
#
# Defect 1/2: _run_install and _precreate_data_dirs must use _find_ext_dir()
# so built-in extensions (under EXTENSIONS_DIR) are found — the old
# USER_EXTENSIONS_DIR-only path silently no-op'd for every built-in.
#
# Defect 3: _precreate_data_dirs must create dirs for any relative bind
# source (not just "./data/..."), so extensions with "./upload:/..." style
# mounts also get their dirs pre-created. Anchored on INSTALL_DIR because
# Docker Compose v2 resolves relative bind paths against the project
# directory (the first -f file's parent = INSTALL_DIR), not against the
# individual fragment's directory.
#
# Defect 5: _handle_install must verify the container reached "running"
# state before reporting success — compose `up -d` returns 0 even for
# Created/Exited/Restarting containers.


class TestPrecreateDataDirs:

    def _write_compose(self, ext_dir: Path, volumes: list[str]):
        vol_yaml = "\n".join(f"      - {v}" for v in volumes)
        ext_dir.mkdir(parents=True, exist_ok=True)
        (ext_dir / "compose.yaml").write_text(
            "services:\n"
            "  svc:\n"
            "    image: test:latest\n"
            "    volumes:\n" + vol_yaml + "\n",
            encoding="utf-8",
        )

    def test_creates_dirs_for_builtin_ext_via_find_ext_dir(self, tmp_path, monkeypatch):
        """Defect 1/2: built-in extensions resolved via _find_ext_dir, not USER_EXTENSIONS_DIR."""
        pytest.importorskip("yaml")
        builtin_root = tmp_path / "builtin"
        user_root = tmp_path / "user"
        install_dir = tmp_path / "install"
        builtin_root.mkdir()
        user_root.mkdir()
        install_dir.mkdir()
        ext_dir = builtin_root / "svc-b"
        self._write_compose(ext_dir, ["./data/state:/state"])

        monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtin_root)
        monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
        monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)

        _mod._precreate_data_dirs("svc-b")

        # Dir lives under INSTALL_DIR (the Compose project directory),
        # NOT under ext_dir — matching where Compose actually mounts.
        assert (install_dir / "data" / "state").is_dir()
        assert not (ext_dir / "data" / "state").exists()

    def test_creates_dirs_for_non_data_prefix(self, tmp_path, monkeypatch):
        """Defect 3: relative bind sources outside './data/' must still be created."""
        pytest.importorskip("yaml")
        user_root = tmp_path / "user"
        builtin_root = tmp_path / "builtin"
        install_dir = tmp_path / "install"
        user_root.mkdir()
        builtin_root.mkdir()
        install_dir.mkdir()
        ext_dir = user_root / "svc-u"
        self._write_compose(ext_dir, ["./upload:/upload", "./data/state:/state"])

        monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtin_root)
        monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
        monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)

        _mod._precreate_data_dirs("svc-u")

        # Both non-"./data/" and "./data/..." mounts must materialise under
        # INSTALL_DIR (the Compose project directory).
        assert (install_dir / "upload").is_dir()
        assert (install_dir / "data" / "state").is_dir()

    def test_skips_named_volumes(self, tmp_path, monkeypatch):
        """Named volumes (no '/') must not trigger filesystem creation."""
        pytest.importorskip("yaml")
        user_root = tmp_path / "user"
        builtin_root = tmp_path / "builtin"
        install_dir = tmp_path / "install"
        user_root.mkdir()
        builtin_root.mkdir()
        install_dir.mkdir()
        ext_dir = user_root / "svc-n"
        self._write_compose(ext_dir, ["named_vol:/var/lib/data"])

        monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtin_root)
        monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
        monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)

        _mod._precreate_data_dirs("svc-n")

        # Named volume must not materialize as a directory anywhere we own.
        assert not (ext_dir / "named_vol").exists()
        assert not (install_dir / "named_vol").exists()


class TestInstallRunningStateVerification:
    """Defect 5: `_handle_install` must poll container state before reporting success."""

    def _install_source(self):
        import inspect
        return inspect.getsource(_mod.AgentHandler._handle_install)

    def test_install_uses_find_ext_dir(self):
        """Defect 1: _run_install resolves ext_dir via _find_ext_dir, not USER_EXTENSIONS_DIR."""
        src = self._install_source()
        assert "_find_ext_dir(service_id)" in src
        assert "USER_EXTENSIONS_DIR / service_id" not in src

    def test_install_polls_docker_inspect_state(self):
        """State-poll loop must run `docker inspect` and check running state."""
        src = self._install_source()
        assert "docker" in src and "inspect" in src
        assert "{{.State.Status}}" in src
        assert 'state == "running"' in src

    def test_install_writes_error_when_state_not_running(self):
        """Failed state-poll must surface as progress error, not 'started'.

        The error message is built via f-string with the
        manifest-driven `startup_timeout` rather than the literal "15s",
        so we assert the constant prefix and the timeout reference, not
        a hardcoded duration.
        """
        src = self._install_source()
        # Error path uses the existing _write_progress("error", ...) API.
        assert '_write_progress(service_id, "error"' in src
        # Error message template carries the dynamic startup_timeout.
        assert "did not reach running state within" in src
        assert "{startup_timeout}s" in src

    def test_install_supports_startup_check_opt_out(self):
        """One-shot / setup-only extensions can set
        `service.startup_check: false` in their manifest to skip the
        running-state poll. The install completes after `compose up -d`
        returns 0; the inspect loop is gated on `if startup_check:`.
        """
        src = self._install_source()
        # Manifest field is read with True default for back-compat.
        assert 'startup_check = install_service_def.get("startup_check", True)' in src
        # The state-poll loop is conditionally entered.
        assert "if startup_check:" in src


# --- Enable-retry (PR 3A regression) ---
#
# When /v1/extension/start is called against a service whose extension-progress
# file shows status=error (prior failed install), the host agent must:
#   * re-run the post_install hook if declared (env vars populated by the hook
#     may be missing from the previous failure),
#   * write progress transitions (starting → setup_hook → started/error) so the
#     dashboard UI updates instead of displaying the stale error, and
#   * fall back to the existing synchronous compose path for any service that
#     isn't in an error state.
#
# Pre-fix, _handle_extension hit docker_compose_action directly without writing
# progress or re-running the hook, leaving the UI permanently stuck.


class _ImmediateThread:
    """Run thread targets synchronously so tests can assert on results."""
    def __init__(self, target=None, daemon=None, **kwargs):
        self._target = target

    def start(self):
        self._target()


class TestEnableRetry:

    def _write_manifest(self, ext_dir: Path, with_hook: bool = True,
                        service_lines: str = ""):
        ext_dir.mkdir(parents=True, exist_ok=True)
        service_block = "service:\n  port: 1234\n"
        if service_lines:
            service_block += service_lines
        if with_hook:
            hook = ext_dir / "setup.sh"
            hook.write_text("#!/bin/bash\nexit 0\n", encoding="utf-8")
            hook.chmod(0o755)
            (ext_dir / "manifest.yaml").write_text(
                service_block +
                "  hooks:\n"
                "    post_install: setup.sh\n",
                encoding="utf-8",
            )
        else:
            (ext_dir / "manifest.yaml").write_text(
                service_block,
                encoding="utf-8",
            )

    def _write_progress_file(self, data_dir: Path, service_id: str, status: str):
        progress_dir = data_dir / "extension-progress"
        progress_dir.mkdir(parents=True, exist_ok=True)
        (progress_dir / f"{service_id}.json").write_text(
            json.dumps({"service_id": service_id, "status": status}),
            encoding="utf-8",
        )

    def _progress(self, data_dir: Path, service_id: str):
        pf = data_dir / "extension-progress" / f"{service_id}.json"
        if not pf.exists():
            return None
        return json.loads(pf.read_text(encoding="utf-8"))

    def _body(self, service_id: str) -> bytes:
        return json.dumps({"service_id": service_id}).encode("utf-8")

    @pytest.fixture
    def retry_env(self, tmp_path, monkeypatch):
        pytest.importorskip("yaml")
        install_dir = tmp_path / "install"
        install_dir.mkdir()
        data_dir = tmp_path / "data"
        data_dir.mkdir()
        builtin_root = tmp_path / "builtin"
        user_root = tmp_path / "user"
        builtin_root.mkdir()
        user_root.mkdir()

        monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
        monkeypatch.setattr(_mod, "DATA_DIR", data_dir)
        monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtin_root)
        monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
        monkeypatch.setattr(_mod, "AGENT_API_KEY", "test-key")

        # Drop the per-service lock so retries across tests don't deadlock.
        _mod._service_locks.pop("fakesvc", None)

        # Force threading.Thread to run targets synchronously so the assertions
        # below execute after the retry worker completes.
        monkeypatch.setattr(_mod.threading, "Thread", _ImmediateThread)

        return install_dir, data_dir, builtin_root, user_root

    def test_retry_after_error_runs_hook_and_writes_started(self, retry_env, monkeypatch):
        _, data_dir, builtin_root, _ = retry_env
        ext_dir = builtin_root / "fakesvc"
        self._write_manifest(ext_dir, with_hook=True)
        self._write_progress_file(data_dir, "fakesvc", "error")

        hook_cmds = []

        def fake_run(cmd, *args, **kwargs):
            if cmd[:3] == ["docker", "inspect", "--format"]:
                return subprocess.CompletedProcess(args=cmd, returncode=0,
                                                   stdout="running|", stderr="")
            hook_cmds.append(cmd)
            return subprocess.CompletedProcess(args=cmd, returncode=0,
                                               stdout="", stderr="")

        monkeypatch.setattr(_mod.subprocess, "run", fake_run)

        compose_calls = []

        def fake_compose(sid, action):
            compose_calls.append((sid, action))
            return True, ""

        monkeypatch.setattr(_mod, "docker_compose_action", fake_compose)

        handler = _FakeHandler(self._body("fakesvc"))
        _mod.AgentHandler._handle_extension(handler, "start")

        assert handler.response_code == 202
        assert handler.parse_response()["status"] == "retrying"
        # post_install hook was invoked via bash against setup.sh
        assert any(
            len(c) >= 2 and c[0] == "bash" and c[1].endswith("setup.sh")
            for c in hook_cmds
        ), f"expected setup.sh bash invocation, saw {hook_cmds}"
        # docker compose start was called after the hook
        assert ("fakesvc", "start") in compose_calls
        # Progress landed on 'started'
        progress = self._progress(data_dir, "fakesvc")
        assert progress is not None
        assert progress["status"] == "started"

    def test_retry_startup_check_failure_writes_error(self, retry_env, monkeypatch):
        _, data_dir, builtin_root, _ = retry_env
        ext_dir = builtin_root / "fakesvc"
        self._write_manifest(
            ext_dir, with_hook=False,
            service_lines="  startup_timeout: 1\n",
        )
        self._write_progress_file(data_dir, "fakesvc", "error")

        monkeypatch.setattr(_mod, "docker_compose_action",
                            lambda sid, act: (True, ""))
        ticks = iter([0, 0, 2])
        monkeypatch.setattr(_mod.time, "monotonic", lambda: next(ticks, 2))
        monkeypatch.setattr(_mod.time, "sleep", lambda *_args: None)

        inspect_calls = []

        def fake_run(cmd, *args, **kwargs):
            inspect_calls.append(cmd)
            return subprocess.CompletedProcess(args=cmd, returncode=0,
                                               stdout="exited|boom", stderr="")

        monkeypatch.setattr(_mod.subprocess, "run", fake_run)

        handler = _FakeHandler(self._body("fakesvc"))
        _mod.AgentHandler._handle_extension(handler, "start")

        assert handler.response_code == 202
        assert any(cmd[:3] == ["docker", "inspect", "--format"]
                   for cmd in inspect_calls)
        progress = self._progress(data_dir, "fakesvc")
        assert progress is not None
        assert progress["status"] == "error"
        assert "state=exited" in (progress["error"] or "")
        assert "boom" in (progress["error"] or "")

    def test_retry_startup_check_opt_out_writes_started(self, retry_env, monkeypatch):
        _, data_dir, builtin_root, _ = retry_env
        ext_dir = builtin_root / "fakesvc"
        self._write_manifest(
            ext_dir, with_hook=False,
            service_lines="  startup_check: false\n",
        )
        self._write_progress_file(data_dir, "fakesvc", "error")

        monkeypatch.setattr(_mod, "docker_compose_action",
                            lambda sid, act: (True, ""))

        def fake_run(*args, **kwargs):
            pytest.fail("startup_check false should not inspect container state")

        monkeypatch.setattr(_mod.subprocess, "run", fake_run)

        handler = _FakeHandler(self._body("fakesvc"))
        _mod.AgentHandler._handle_extension(handler, "start")

        assert handler.response_code == 202
        progress = self._progress(data_dir, "fakesvc")
        assert progress is not None
        assert progress["status"] == "started"

    def test_retry_hook_failure_writes_error_and_skips_compose(self, retry_env, monkeypatch):
        _, data_dir, builtin_root, _ = retry_env
        ext_dir = builtin_root / "fakesvc"
        self._write_manifest(ext_dir, with_hook=True)
        self._write_progress_file(data_dir, "fakesvc", "error")

        def fake_run(cmd, *args, **kwargs):
            return subprocess.CompletedProcess(args=cmd, returncode=1,
                                               stdout="", stderr="hook boom")

        monkeypatch.setattr(_mod.subprocess, "run", fake_run)

        compose_calls = []
        monkeypatch.setattr(
            _mod, "docker_compose_action",
            lambda sid, act: (compose_calls.append((sid, act)) or (True, "")),
        )

        handler = _FakeHandler(self._body("fakesvc"))
        _mod.AgentHandler._handle_extension(handler, "start")

        assert handler.response_code == 202
        progress = self._progress(data_dir, "fakesvc")
        assert progress is not None
        assert progress["status"] == "error"
        assert "hook boom" in (progress["error"] or "")
        # Hook failure must NOT proceed to compose start
        assert compose_calls == []

    def test_no_progress_file_uses_sync_path_without_progress_write(self, retry_env, monkeypatch):
        _, data_dir, builtin_root, _ = retry_env
        ext_dir = builtin_root / "fakesvc"
        self._write_manifest(ext_dir, with_hook=True)
        # Deliberately no progress file.

        hook_cmds = []

        def fake_run(cmd, *args, **kwargs):
            hook_cmds.append(cmd)
            return subprocess.CompletedProcess(args=cmd, returncode=0,
                                               stdout="", stderr="")

        monkeypatch.setattr(_mod.subprocess, "run", fake_run)
        monkeypatch.setattr(_mod, "docker_compose_action",
                            lambda sid, act: (True, ""))

        handler = _FakeHandler(self._body("fakesvc"))
        _mod.AgentHandler._handle_extension(handler, "start")

        # Synchronous success → 200, not 202
        assert handler.response_code == 200
        assert handler.parse_response()["status"] == "ok"
        # Sync path must not re-run the hook
        assert not any(
            len(c) >= 2 and c[0] == "bash" and c[1].endswith("setup.sh")
            for c in hook_cmds
        )
        # Sync path must not write a progress file
        assert self._progress(data_dir, "fakesvc") is None

    def test_progress_status_started_uses_sync_path(self, retry_env, monkeypatch):
        _, data_dir, builtin_root, _ = retry_env
        ext_dir = builtin_root / "fakesvc"
        self._write_manifest(ext_dir, with_hook=True)
        self._write_progress_file(data_dir, "fakesvc", "started")

        hook_cmds = []

        def fake_run(cmd, *args, **kwargs):
            hook_cmds.append(cmd)
            return subprocess.CompletedProcess(args=cmd, returncode=0,
                                               stdout="", stderr="")

        monkeypatch.setattr(_mod.subprocess, "run", fake_run)
        monkeypatch.setattr(_mod, "docker_compose_action",
                            lambda sid, act: (True, ""))

        handler = _FakeHandler(self._body("fakesvc"))
        _mod.AgentHandler._handle_extension(handler, "start")

        assert handler.response_code == 200
        # Sync path must not re-run the hook
        assert not any(
            len(c) >= 2 and c[0] == "bash" and c[1].endswith("setup.sh")
            for c in hook_cmds
        )
        # Progress must be unchanged
        assert self._progress(data_dir, "fakesvc")["status"] == "started"


class TestInstallStatePollBehavior:
    """End-to-end behavioral tests for the running-state poll inside
    ``AgentHandler._handle_install``.

    The existing :class:`TestInstallRunningStateVerification` class above is
    100% source-inspection (asserts substrings appear in the function body).
    This class drives the real handler over HTTP with a mocked
    ``subprocess.run`` so that a refactor that preserves the substrings but
    breaks the runtime behavior would still get caught.

    Pattern matches :class:`TestComposeCacheInvalidationWire` and
    :class:`TestComposeToggleWire` above:
      * Spin up an in-process ``HTTPServer`` bound to ``AgentHandler``.
      * POST to ``/v1/extension/install`` with the bearer token.
      * Wait for the install thread to write a terminal status to the
        progress file (``status in {'started', 'error'}``).
      * Assert on the progress payload + on the recorded subprocess calls.

    Time is virtualised — ``time.sleep`` and ``time.monotonic`` are
    monkeypatched on the host-agent module so a 15-second deadline elapses
    instantly. Tests must not actually wait wall-clock seconds.
    """

    PROGRESS_WAIT_SECONDS = 5.0

    def _make_extension(self, user_root, sid, *, startup_check=True,
                        startup_timeout=None, container_name=None):
        """Create a minimal user-extension dir with manifest only.

        No ``compose.yaml`` is written — that keeps ``_precreate_data_dirs``
        an early-return no-op so the only ``subprocess.run`` invocations are
        the ones the install path itself issues (compose pull / compose up /
        docker inspect).
        """
        import yaml  # PyYAML is a hard dep of dashboard-api; if missing the
                    # whole test module would already have failed at import.
        ext_dir = user_root / sid
        ext_dir.mkdir(parents=True)
        service_def = {}
        if startup_check is False:
            service_def["startup_check"] = False
        if startup_timeout is not None:
            service_def["startup_timeout"] = startup_timeout
        if container_name is not None:
            service_def["container_name"] = container_name
        manifest = {
            "schema_version": "dream.services.v1",
            "id": sid,
            "service": service_def,
        }
        (ext_dir / "manifest.yaml").write_text(yaml.safe_dump(manifest), encoding="utf-8")
        return ext_dir

    def _post_install(self, port, key, sid):
        import urllib.request
        req = urllib.request.Request(
            f"http://127.0.0.1:{port}/v1/extension/install",
            data=json.dumps({"service_id": sid}).encode("utf-8"),
            headers={
                "Authorization": f"Bearer {key}",
                "Content-Type": "application/json",
            },
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=5) as resp:
            assert resp.status == 202
            return json.loads(resp.read())

    def _wait_for_terminal(self, progress_file, timeout=None):
        import time as _real_time
        timeout = timeout if timeout is not None else self.PROGRESS_WAIT_SECONDS
        deadline = _real_time.monotonic() + timeout
        while _real_time.monotonic() < deadline:
            if progress_file.exists():
                try:
                    payload = json.loads(progress_file.read_text(encoding="utf-8"))
                except (json.JSONDecodeError, OSError):
                    payload = None
                if payload and payload.get("status") in {"started", "error"}:
                    return payload
            _real_time.sleep(0.05)
        raise AssertionError(
            f"Install thread did not reach a terminal status within {timeout}s; "
            f"last seen: {progress_file.read_text(encoding='utf-8') if progress_file.exists() else '<missing>'}"
        )

    def _setup_agent(self, tmp_path, monkeypatch, *, sid, startup_check=True,
                     startup_timeout=None, container_name=None):
        """Common scaffolding: temp dirs, manifest, monkeypatched module
        constants, virtual clock, returns ``(install_dir, progress_file, ext_dir)``."""
        install_dir = tmp_path / "install"
        data_dir = tmp_path / "data"
        user_root = tmp_path / "user-extensions"
        builtin_root = tmp_path / "builtin-empty"
        install_dir.mkdir()
        data_dir.mkdir()
        user_root.mkdir()
        builtin_root.mkdir()
        # Pre-populate compose flags so resolve_compose_flags() doesn't
        # shell out to resolve-compose-stack.sh (which doesn't exist here).
        (install_dir / ".compose-flags").write_text(
            "--env-file .env -f docker-compose.base.yml", encoding="utf-8",
        )

        ext_dir = self._make_extension(
            user_root, sid,
            startup_check=startup_check,
            startup_timeout=startup_timeout,
            container_name=container_name,
        )

        monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
        monkeypatch.setattr(_mod, "DATA_DIR", data_dir)
        monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
        monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtin_root)
        monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")

        # Virtual clock so the 15s startup_timeout deadline elapses
        # instantly. ``time.sleep(1)`` advances the fake clock by 1.0 and
        # returns immediately; ``time.monotonic()`` returns the current value.
        # Replace ``_mod.time`` with a stub namespace rather than mutating
        # attributes on the real ``time`` module — otherwise the test helper
        # itself (which uses real ``time.sleep`` to wait for the install
        # thread) ends up calling the no-op fake and busy-loops in zero
        # wall-clock time, racing the install thread.
        import time as _real_time
        clock = [0.0]
        def fake_monotonic():
            return clock[0]
        def fake_sleep(seconds):
            clock[0] += float(seconds)
        fake_time = types.SimpleNamespace(
            monotonic=fake_monotonic,
            sleep=fake_sleep,
            time=_real_time.time,
        )
        monkeypatch.setattr(_mod, "time", fake_time)

        progress_file = data_dir / "extension-progress" / f"{sid}.json"
        return install_dir, progress_file, ext_dir

    def _start_server(self, monkeypatch):
        import threading
        from http.server import HTTPServer
        server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
        port = server.server_address[1]
        thread = threading.Thread(target=server.serve_forever, daemon=True)
        thread.start()
        return server, thread, port

    @staticmethod
    def _is_inspect_call(call):
        """Return True if ``call`` is the docker-inspect state probe."""
        argv = call["argv"]
        return (
            len(argv) >= 2
            and argv[0] == "docker"
            and argv[1] == "inspect"
        )

    @staticmethod
    def _is_compose_call(call, verb):
        """Return True if ``call`` is a ``docker compose <verb> ...`` call."""
        argv = call["argv"]
        if len(argv) < 3 or argv[0] != "docker" or argv[1] != "compose":
            return False
        return verb in argv

    def _install_subprocess_mock(self, monkeypatch, inspect_responses):
        """Install a ``subprocess.run`` patch on the host-agent module.

        ``inspect_responses`` is a list of items consumed in order for each
        ``docker inspect`` call. Each item is either:
          * a tuple ``(state, error)`` -> return rc=0 with ``"<state>|<error>"``
          * the exception class ``subprocess.TimeoutExpired`` (or an instance) ->
            raise it for that call
          * a callable ``(argv) -> CompletedProcess`` for full custom control
        Compose ``pull`` and ``up`` always succeed (rc=0).
        Returns a ``calls`` list (each entry: ``{'argv': [...], 'kwargs': {...}}``).
        """
        calls = []
        responses = list(inspect_responses)

        class _CP:  # minimal stand-in for subprocess.CompletedProcess
            def __init__(self, returncode, stdout="", stderr=""):
                self.returncode = returncode
                self.stdout = stdout
                self.stderr = stderr

        def fake_run(argv, **kwargs):
            calls.append({"argv": list(argv), "kwargs": dict(kwargs)})

            # docker inspect ... -> consume next scripted response
            if (len(argv) >= 2 and argv[0] == "docker" and argv[1] == "inspect"):
                if not responses:
                    return _CP(0, "running|", "")
                resp = responses.pop(0)
                if isinstance(resp, type) and issubclass(resp, BaseException):
                    raise resp(cmd=argv, timeout=5)
                if isinstance(resp, BaseException):
                    raise resp
                if callable(resp):
                    return resp(argv)
                state, err = resp
                return _CP(0, f"{state}|{err}", "")

            # docker compose ... -> always success.
            if (len(argv) >= 2 and argv[0] == "docker" and argv[1] == "compose"):
                return _CP(0, "", "")

            # Anything else: refuse so the test fails loudly rather than
            # silently shelling out.
            raise AssertionError(f"unexpected subprocess.run argv: {argv}")

        monkeypatch.setattr(_mod.subprocess, "run", fake_run)
        return calls

    # ------------------------------------------------------------------
    # Test cases
    # ------------------------------------------------------------------

    def test_install_writes_error_progress_when_state_never_running(
        self, tmp_path, monkeypatch,
    ):
        """Container stuck in ``created`` for the whole startup window must
        surface as ``status=error`` with a ``did not reach running state``
        message — not as a false ``started``."""
        sid = "fakesvc"
        install_dir, progress_file, _ = self._setup_agent(
            tmp_path, monkeypatch, sid=sid, startup_timeout=3,
        )
        # All inspect calls report 'created'; deadline must elapse.
        # 3s timeout / 1s fake sleep -> 3 inspect calls.
        calls = self._install_subprocess_mock(
            monkeypatch,
            inspect_responses=[("created", "")] * 10,
        )

        server, thread, port = self._start_server(monkeypatch)
        try:
            self._post_install(port, "wire-test-secret", sid)
            payload = self._wait_for_terminal(progress_file)
        finally:
            server.shutdown()
            server.server_close()
            thread.join(timeout=2)

        assert payload["status"] == "error", payload
        assert "did not reach running state" in (payload.get("error") or "")
        # At least one inspect call happened (otherwise the gate isn't running).
        assert any(self._is_inspect_call(c) for c in calls), calls

    def test_install_skips_state_poll_when_startup_check_false(
        self, tmp_path, monkeypatch,
    ):
        """``service.startup_check: false`` must skip the docker-inspect
        poll entirely and report ``started`` as soon as ``compose up``
        returns 0."""
        sid = "fakesvc"
        install_dir, progress_file, _ = self._setup_agent(
            tmp_path, monkeypatch, sid=sid, startup_check=False,
        )
        calls = self._install_subprocess_mock(
            monkeypatch,
            # Empty list: any inspect call would still get a default
            # "running|" response — but the test asserts none happened.
            inspect_responses=[],
        )

        server, thread, port = self._start_server(monkeypatch)
        try:
            self._post_install(port, "wire-test-secret", sid)
            payload = self._wait_for_terminal(progress_file)
        finally:
            server.shutdown()
            server.server_close()
            thread.join(timeout=2)

        assert payload["status"] == "started", payload
        inspect_calls = [c for c in calls if self._is_inspect_call(c)]
        assert inspect_calls == [], (
            f"docker inspect must NOT be called when startup_check is false; "
            f"saw: {inspect_calls}"
        )
        # Sanity: compose up did happen.
        assert any(self._is_compose_call(c, "up") for c in calls), calls

    def test_install_records_started_on_state_transition(
        self, tmp_path, monkeypatch,
    ):
        """First inspect returns ``starting``, second returns ``running`` —
        the loop must break on the transition and report ``started``."""
        sid = "fakesvc"
        install_dir, progress_file, _ = self._setup_agent(
            tmp_path, monkeypatch, sid=sid, startup_timeout=10,
        )
        calls = self._install_subprocess_mock(
            monkeypatch,
            inspect_responses=[("starting", ""), ("running", "")],
        )

        server, thread, port = self._start_server(monkeypatch)
        try:
            self._post_install(port, "wire-test-secret", sid)
            payload = self._wait_for_terminal(progress_file)
        finally:
            server.shutdown()
            server.server_close()
            thread.join(timeout=2)

        assert payload["status"] == "started", payload
        inspect_calls = [c for c in calls if self._is_inspect_call(c)]
        # Exactly two inspect calls: starting -> running, then break.
        # Allow >=2 to accept any future implementation that double-checks.
        assert len(inspect_calls) >= 2, inspect_calls

    def test_install_tolerates_docker_inspect_timeout(
        self, tmp_path, monkeypatch,
    ):
        """A single ``subprocess.TimeoutExpired`` from docker inspect must
        be absorbed by the poll loop — the install thread must NOT abort
        the whole install on a one-off probe failure."""
        import subprocess as _real_subprocess
        sid = "fakesvc"
        install_dir, progress_file, _ = self._setup_agent(
            tmp_path, monkeypatch, sid=sid, startup_timeout=10,
        )
        # First inspect call raises TimeoutExpired; second returns "running".
        # If the install thread propagates the timeout up to the outer
        # try/except, _write_progress would be called with status="error"
        # and message "timed out (...)". The test asserts "started" instead.
        calls = self._install_subprocess_mock(
            monkeypatch,
            inspect_responses=[
                _real_subprocess.TimeoutExpired,
                ("running", ""),
            ],
        )

        server, thread, port = self._start_server(monkeypatch)
        try:
            self._post_install(port, "wire-test-secret", sid)
            payload = self._wait_for_terminal(progress_file)
        finally:
            server.shutdown()
            server.server_close()
            thread.join(timeout=2)

        assert payload["status"] == "started", payload
        inspect_calls = [c for c in calls if self._is_inspect_call(c)]
        # At least the timeout + the success call.
        assert len(inspect_calls) >= 2, inspect_calls
# --- Enable-retry edge cases (fork issue #493) ---
#
# Companion coverage to PR #1039's TestEnableRetry. These tests pin the
# three dispatch edge cases that #1039's contract introduces but does not
# directly exercise:
#   a. retry path with no post_install hook → must reach 'started' without
#      writing a 'setup_hook' transition;
#   b. malformed progress JSON when /v1/extension/start arrives → handler
#      must fall back to the synchronous compose path (not retry);
#   c. progress.status == "setup_hook" (mid-install) → also falls back to
#      the sync path; only "error" is the retry trigger.
#
# The retry helper from PR #1039 is now on main. These tests keep the
# dispatch edge cases pinned so future host-agent changes do not regress
# retry-versus-sync routing.


class TestEnableRetryEdgeCases:

    def _setup_env(self, tmp_path, monkeypatch):
        install_dir = tmp_path / "install"
        install_dir.mkdir()
        data_dir = tmp_path / "data"
        data_dir.mkdir()
        builtin_root = tmp_path / "builtin"
        user_root = tmp_path / "user"
        builtin_root.mkdir()
        user_root.mkdir()
        monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
        monkeypatch.setattr(_mod, "DATA_DIR", data_dir)
        monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtin_root)
        monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
        monkeypatch.setattr(_mod, "AGENT_API_KEY", "test-key")
        # Drop the per-service lock so retries across tests don't deadlock.
        _mod._service_locks.pop("fakesvc", None)
        return data_dir, builtin_root

    def _write_progress_raw(self, data_dir, sid, raw_text):
        d = data_dir / "extension-progress"
        d.mkdir(parents=True, exist_ok=True)
        (d / f"{sid}.json").write_text(raw_text, encoding="utf-8")

    def _read_progress(self, data_dir, sid):
        f = data_dir / "extension-progress" / f"{sid}.json"
        if not f.exists():
            return None
        return json.loads(f.read_text(encoding="utf-8"))

    def _body(self, sid):
        return json.dumps({"service_id": sid}).encode("utf-8")

    def test_no_hook_retry_completes_with_started_progress(
        self, tmp_path, monkeypatch,
    ):
        """error progress + manifest without post_install hook → retry skips
        the setup_hook step and lands on 'started'.

        Pins the no-hook branch in _enable_retry_work: when
        _resolve_hook(ext_dir, "post_install") returns None, no
        setup_hook progress write occurs and no subprocess is spawned;
        the worker proceeds straight to docker_compose_action.
        """
        # Run the retry worker thread synchronously so we can assert on
        # final progress state from the test thread.
        class _SyncThread:
            def __init__(self, target=None, daemon=None, **kwargs):
                self._target = target

            def start(self):
                self._target()

        monkeypatch.setattr(_mod.threading, "Thread", _SyncThread)

        data_dir, builtin_root = self._setup_env(tmp_path, monkeypatch)
        ext_dir = builtin_root / "fakesvc"
        ext_dir.mkdir()
        # Manifest with NO post_install hook.
        (ext_dir / "manifest.yaml").write_text(
            "service:\n  port: 1234\n", encoding="utf-8",
        )
        self._write_progress_raw(
            data_dir, "fakesvc",
            json.dumps({"service_id": "fakesvc", "status": "error",
                        "error": "prior failure"}),
        )

        hook_cmds: list = []

        def fake_run(cmd, *a, **k):
            hook_cmds.append(cmd)
            import subprocess as _sp
            # Startup_check (PR #1039) polls `docker inspect --format
            # '{{.State.Status}}|{{.State.Error}}' dream-<svc>` after the
            # compose action and reads stdout. Empty output keeps the poll
            # in 'not running' forever and the retry path eventually writes
            # progress='error'. Return a clean 'running|' for those calls
            # so the poll satisfies and the worker reaches 'started';
            # every other subprocess (which there shouldn't be in the
            # no-hook branch) still gets an empty response.
            is_docker_inspect = (
                isinstance(cmd, list) and len(cmd) >= 2
                and cmd[0] == "docker" and cmd[1] == "inspect"
            )
            stdout = "running|" if is_docker_inspect else ""
            return _sp.CompletedProcess(args=cmd, returncode=0,
                                        stdout=stdout, stderr="")

        monkeypatch.setattr(_mod.subprocess, "run", fake_run)
        monkeypatch.setattr(
            _mod, "docker_compose_action",
            lambda sid, action: (True, ""),
        )

        handler = _FakeHandler(self._body("fakesvc"))
        _mod.AgentHandler._handle_extension(handler, "start")

        # Retry path engaged → 202 (accept-then-thread).
        assert handler.response_code == 202
        # No hook-script invocation: there is no hook to run. (The startup_check
        # path from PR #1039 still calls subprocess.run for `docker inspect`
        # to poll container state — filter those out and only assert no hook ran.)
        hook_invocations = [
            c for c in hook_cmds
            if not (isinstance(c, list) and len(c) >= 2 and c[0] == "docker" and c[1] == "inspect")
        ]
        assert hook_invocations == []
        # Worker landed on 'started' (not 'setup_hook' or 'error').
        progress = self._read_progress(data_dir, "fakesvc")
        assert progress is not None
        assert progress["status"] == "started"

    def test_corrupted_progress_json_falls_back_to_sync_path(
        self, tmp_path, monkeypatch,
    ):
        """Malformed JSON in progress file → _read_progress_status returns
        None → retry trigger is NOT engaged; the synchronous compose
        path runs and the corrupted file is left untouched.
        """
        data_dir, builtin_root = self._setup_env(tmp_path, monkeypatch)
        ext_dir = builtin_root / "fakesvc"
        ext_dir.mkdir()
        (ext_dir / "manifest.yaml").write_text(
            "service:\n  port: 1234\n", encoding="utf-8",
        )
        self._write_progress_raw(data_dir, "fakesvc", "{not-json")

        # Pre-condition: helper added by PR #1039 must report None for
        # malformed JSON so the dispatch in _handle_extension cannot
        # treat the corrupt state as "error" and trigger a retry.
        assert _mod._read_progress_status("fakesvc") is None

        compose_calls: list = []

        def fake_compose(sid, act):
            compose_calls.append((sid, act))
            return True, ""

        monkeypatch.setattr(_mod, "docker_compose_action", fake_compose)

        handler = _FakeHandler(self._body("fakesvc"))
        _mod.AgentHandler._handle_extension(handler, "start")

        # Synchronous success → 200, not 202.
        assert handler.response_code == 200
        assert compose_calls == [("fakesvc", "start")]
        # Corrupted progress file left exactly as it was — the retry
        # path would have rewritten it.
        raw = (data_dir / "extension-progress" / "fakesvc.json").read_text(
            encoding="utf-8",
        )
        assert raw == "{not-json"

    def test_mid_install_setup_hook_status_uses_sync_path(
        self, tmp_path, monkeypatch,
    ):
        """status='setup_hook' is a mid-install state, not a terminal
        error. The retry trigger only fires for status='error', so a
        'start' against a service mid-install must use the sync path
        and leave progress untouched.
        """
        data_dir, builtin_root = self._setup_env(tmp_path, monkeypatch)
        ext_dir = builtin_root / "fakesvc"
        ext_dir.mkdir()
        (ext_dir / "manifest.yaml").write_text(
            "service:\n  port: 1234\n", encoding="utf-8",
        )
        self._write_progress_raw(
            data_dir, "fakesvc",
            json.dumps({"service_id": "fakesvc", "status": "setup_hook"}),
        )

        # Pre-condition: the PR #1039 helper reports the in-flight status
        # exactly so the dispatch can compare against the literal "error".
        assert _mod._read_progress_status("fakesvc") == "setup_hook"

        compose_calls: list = []

        def fake_compose(sid, act):
            compose_calls.append((sid, act))
            return True, ""

        monkeypatch.setattr(_mod, "docker_compose_action", fake_compose)

        handler = _FakeHandler(self._body("fakesvc"))
        _mod.AgentHandler._handle_extension(handler, "start")

        # Sync path used → 200, not 202.
        assert handler.response_code == 200
        assert compose_calls == [("fakesvc", "start")]
        # Sync path must not rewrite the in-flight progress.
        progress = self._read_progress(data_dir, "fakesvc")
        assert progress is not None
        assert progress["status"] == "setup_hook"


# --- Model download catalog unavailability (fork issue #512) ---
#
# Tests _handle_model_download's response shape when the model-library.json
# catalog is missing or corrupt. Pre-PR #1057 the handler conflates these
# real install-corruption cases with the policy denial "Model not in
# library catalog", returning 403 in all three. PR #1057 distinguishes
# unreadable/missing (500) from genuinely-not-listed (403).
#
# Cases (a) and (b) cover the post-#1057 corruption/error distinction.
# Case (c) is the existing-behaviour-preserved baseline for a clean catalog
# that simply does not list the requested model.


class TestModelDownloadCatalogUnavailable:

    def _setup_env(self, tmp_path, monkeypatch):
        install_dir = tmp_path / "install"
        (install_dir / "config").mkdir(parents=True)
        (install_dir / "data" / "models").mkdir(parents=True)
        monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
        monkeypatch.setattr(_mod, "AGENT_API_KEY", "test-key")
        return install_dir

    def _body(self):
        return json.dumps({
            "gguf_file": "test-model.gguf",
            "gguf_url": "https://example.com/test-model.gguf",
        }).encode("utf-8")

    def test_missing_catalog_returns_500(self, tmp_path, monkeypatch):
        """No model-library.json → 500 'Model catalog unavailable'.

        Pre-#1057 returns 403 (the catalog check sees library_path
        missing, leaves allowed=False, falls through to the 'not in
        library catalog' branch).
        """
        install_dir = self._setup_env(tmp_path, monkeypatch)
        assert not (install_dir / "config" / "model-library.json").exists()

        handler = _FakeHandler(self._body())
        _mod.AgentHandler._handle_model_download(handler)

        assert handler.response_code == 500
        body = handler.parse_response()
        assert body["error"] == "Model catalog unavailable"

    def test_corrupt_catalog_returns_500(self, tmp_path, monkeypatch):
        """Catalog file exists but is malformed JSON → 500.

        Pre-#1057 the JSONDecodeError is swallowed by a bare ``pass``,
        leaving allowed=False and returning 403 — masking the corrupt
        install as a policy denial.
        """
        install_dir = self._setup_env(tmp_path, monkeypatch)
        (install_dir / "config" / "model-library.json").write_text(
            "{not-json", encoding="utf-8",
        )

        handler = _FakeHandler(self._body())
        _mod.AgentHandler._handle_model_download(handler)

        assert handler.response_code == 500
        body = handler.parse_response()
        assert body["error"] == "Model catalog unavailable"

    def test_model_not_in_clean_catalog_still_returns_403(
        self, tmp_path, monkeypatch,
    ):
        """Catalog parses cleanly and lists other models but not the one
        being requested → 403 'Model not in library catalog' (the
        existing policy-denial behaviour, preserved across #1057).
        """
        install_dir = self._setup_env(tmp_path, monkeypatch)
        (install_dir / "config" / "model-library.json").write_text(
            json.dumps({"models": [{
                "gguf_file": "different-model.gguf",
                "gguf_url": "https://example.com/different.gguf",
            }]}),
            encoding="utf-8",
        )

        handler = _FakeHandler(self._body())
        _mod.AgentHandler._handle_model_download(handler)

        assert handler.response_code == 403
        body = handler.parse_response()
        assert body["error"] == "Model not in library catalog"
