import os
import re
import sys
import time
import yaml
import atexit
import threading
import subprocess
import logging
import typer
import json
import shutil
from collections import deque
from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field, field_validator
from pathlib import Path
from rich.console import Console
from rich.prompt import Confirm
from armorer.agent_sdk import tool
from rich.panel import Panel
from rich.live import Live
from rich.syntax import Syntax
from rich.text import Text
from rich.rule import Rule
from rich.table import Table
import questionary
from questionary import Separator as QuestionarySeparator

try:
    from InquirerPy import inquirer
    from InquirerPy.separator import Separator as InquirerSeparator
    _HAS_INQUIRERPY = True
except Exception:
    inquirer = None
    InquirerSeparator = None
    _HAS_INQUIRERPY = False

Separator = InquirerSeparator if _HAS_INQUIRERPY else QuestionarySeparator

from armorer.installers.base import BaseInstaller
from armorer.installers.graph import InstallerGraphBuilder
from armorer.agent_sdk import Status
from armorer.installers.schemas import DeploymentPlan, CommandDiscovery, AppStructure

from armorer.profiles import RiskProfile
from armorer.registry import AppRegistry, AppMetadata
from armorer.security.policy import PolicyGuard
from armorer.tools.search import google_search
from armorer.agent import BaseAgent as ArmorerAgent
from armorer.knowledge.hints import load_agent_hints
from armorer.catalog import resolve_playbook
from armorer.openclaw_config import (
    OPENCLAW_RUNTIME_IMAGE_DEFAULT,
    ensure_openclaw_runtime_bootstrap,
    resolve_openclaw_runtime_image,
    validate_openclaw_runtime_bootstrap,
)

editor = None
logger = logging.getLogger(__name__)


class SearchTool:
    """Backward-compatible shim for legacy tests/mocks."""

    def search(self, query: str) -> str:
        return google_search(query)

console = Console()

_DISK_FULL_PATTERNS = (
    "no space left on device",
    "enospc",
    "errno 28",
)
_DOCKER_STORAGE_IO_PATTERNS = (
    "metadata_v2.db: input/output error",
    "desktop-containerd",
    "containerd.content.v1.content",
    "failed to solve: internal: error committing",
    "unable to sync directory '/var/lib/dpkg/updates': input/output error",
)

_OPENCLAW_MIN_DOCKER_MEMORY_GIB = 12.0
_OPENCLAW_MIN_FREE_DISK_GIB = 10.0


def _pick_writable_lock_dir() -> Path:
    repo_dir = Path.cwd() / ".armorer" / "locks"
    fallback_dir = Path.home() / ".armorer" / "locks-local"
    for candidate in (repo_dir, fallback_dir):
        try:
            candidate.mkdir(parents=True, exist_ok=True)
            if os.access(candidate, os.R_OK | os.W_OK | os.X_OK):
                return candidate
        except Exception:
            continue
    return fallback_dir


def _normalized_architecture(info: Optional[dict[str, Any]]) -> str:
    if not isinstance(info, dict):
        return ""
    value = str(info.get("Architecture") or info.get("OSType") or "").strip().lower()
    return value


def _openclaw_default_memory_threshold_gib(info: Optional[dict[str, Any]]) -> float:
    arch = _normalized_architecture(info)
    os_name = str((info or {}).get("OperatingSystem") or "").strip().lower() if isinstance(info, dict) else ""
    # Docker-backed OpenClaw on Linux ARM machines such as Raspberry Pi commonly reports ~8 GiB.
    # The generic 12 GiB floor is too conservative there and blocks installs before Docker even runs.
    if arch in {"arm64", "aarch64"} and ("linux" in os_name or "debian" in os_name or "ubuntu" in os_name):
        return 7.5
    return _OPENCLAW_MIN_DOCKER_MEMORY_GIB


def _looks_like_host_disk_full(err_text: str) -> bool:
    t = " ".join(str(err_text or "").lower().split())
    return any(p in t for p in _DISK_FULL_PATTERNS)


def _render_host_disk_full_panel(context: str, err_text: str) -> None:
    msg = (
        "[bold red]Host disk is full (ENOSPC).[/bold red]\n\n"
        f"[bold]Context:[/bold] {context}\n\n"
        "This is not an Armorer error. Docker could not write/extract image layers or build artifacts.\n\n"
        "[bold]Fix:[/bold]\n"
        "1) Free disk space (especially Docker storage)\n"
        "2) Retry the install\n\n"
        "[bold]Common commands:[/bold]\n"
        "- `docker system prune -af`\n"
        "- `docker builder prune -af`\n"
        "- Move Docker data-root to a larger disk (recommended on small root partitions)\n\n"
        "[bold]Raw error (tail):[/bold]\n"
        + (str(err_text or "").strip()[-700:] or "(no output)")
    )
    console.print(Panel(msg, title="Disk Full", border_style="red"))


def _looks_like_docker_storage_io_error(err_text: str) -> bool:
    t = " ".join(str(err_text or "").lower().split())
    if any(p in t for p in _DOCKER_STORAGE_IO_PATTERNS):
        return True
    return "input/output error" in t and any(
        marker in t for marker in ("docker", "buildkit", "containerd", "/var/lib/desktop-containerd")
    )


def _render_docker_storage_fault_panel(context: str, err_text: str) -> None:
    msg = (
        "[bold red]Docker storage I/O failure detected.[/bold red]\n\n"
        f"[bold]Context:[/bold] {context}\n\n"
        "This is a host Docker/desktop-containerd fault, not an app compose bug.\n\n"
        "[bold]Fix:[/bold]\n"
        "1) Restart Docker Desktop\n"
        "2) Run `docker system prune -af` and `docker builder prune -af`\n"
        "3) If it persists, reset Docker data or check disk health/available space\n\n"
        "[bold]Raw error (tail):[/bold]\n"
        + (str(err_text or "").strip()[-700:] or "(no output)")
    )
    console.print(Panel(msg, title="Docker Storage Fault", border_style="red"))


def _looks_like_docker_desktop_mount_denied(err_text: str) -> bool:
    t = " ".join(str(err_text or "").lower().split())
    return "mounts denied" in t and "not shared from the host" in t


def _render_mount_denied_panel(context: str, err_text: str) -> None:
    msg = (
        "[bold red]Docker Desktop rejected a bind mount path.[/bold red]\n\n"
        f"[bold]Context:[/bold] {context}\n\n"
        "Armorer is running inside its sandbox container, but Docker Desktop only accepts host-visible paths.\n"
        "A compose variable or preflight command resolved a mount source to `/app/...`, which exists only inside the sandbox.\n\n"
        "[bold]Fix:[/bold]\n"
        "1) Convert bind-mount source paths back to host paths before calling Docker\n"
        "2) Retry the install\n\n"
        "[bold]Raw error (tail):[/bold]\n"
        + (str(err_text or "").strip()[-700:] or "(no output)")
    )
    console.print(Panel(msg, title="Mount Denied", border_style="red"))


def _load_dotenv_kv(path: Path) -> dict:
    """Parse a simple KEY=VALUE .env file (no interpolation)."""
    out: dict[str, str] = {}
    try:
        if not path.exists():
            return out
        for raw in path.read_text().splitlines():
            line = raw.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            k, v = line.split("=", 1)
            k = k.strip()
            v = v.strip()
            if not k:
                continue
            # Strip basic quoting.
            if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
                v = v[1:-1]
            out[k] = v
    except Exception:
        return out
    return out


def _resolve_compose_var_default(expr: str, env: dict) -> str:
    """Resolve a compose ${VAR:-default} or ${VAR} expression against env.

    If VAR is set and non-empty, use it. Otherwise use default (when provided).
    Returns empty string if unresolvable.
    """
    s = str(expr or "").strip()
    m = re.search(r"\$\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?::-\s*([^}]+))?\s*\}", s)
    if not m:
        return ""
    var = m.group(1)
    default = (m.group(2) or "").strip()
    val = str(env.get(var) or os.environ.get(var) or "").strip()
    if val:
        return val
    if default:
        return default
    return ""

# ── Step Display Helpers ─────────────────────────────────────────────────────

_STEP_OK = "[green]  ✓[/green]"
_STEP_FAIL = "[red]  ✗[/red]"
_STEP_SKIP = "[dim]  ○[/dim]"


def _step_header(app_name: str):
    console.print()
    console.print(Rule(f"[bold cyan]🛡️  Installing {app_name}[/bold cyan]", style="cyan"))
    console.print()


def _step_start(label: str) -> "console.status":
    return console.status(f"[bold cyan]  ⠿ {label}[/bold cyan]")


def _step_done(label: str, detail: str = ""):
    suffix = f"  [dim]{detail}[/dim]" if detail else ""
    console.print(f"{_STEP_OK} {label}{suffix}")


def _step_fail(label: str, detail: str = ""):
    suffix = f"  [dim]{detail}[/dim]" if detail else ""
    console.print(f"{_STEP_FAIL} [red]{label}[/red]{suffix}")


def _ui_select(message: str, choices: List[Any]):
    if _HAS_INQUIRERPY:
        return inquirer.select(message=message, choices=choices, cycle=False).execute()
    return questionary.select(message, choices=choices).ask()

from armorer.installers.dynamic.streaming_ui import _LiveLogPanel


# ── Content Extraction Helpers ───────────────────────────────────────────────


def _extract_fenced_block(text: str, lang: str = "") -> Optional[str]:
    if lang:
        pattern = rf"```{lang}\s*\n(.*?)```"
        m = re.search(pattern, text, re.DOTALL)
        if m:
            return m.group(1).strip()
    pattern = r"```\w*\s*\n(.*?)```"
    m = re.search(pattern, text, re.DOTALL)
    if m:
        return m.group(1).strip()
    return None


def _extract_yaml(text: str) -> str:
    """Aggressively extract YAML from text."""
    # Try fenced blocks first
    block = _extract_fenced_block(text, "yaml") or _extract_fenced_block(text, "yml")
    if block:
        return block
    
    # Try searching for YAML keywords if no blocks found
    lines = text.splitlines()
    yaml_lines = []
    found_start = False
    for line in lines:
        if not found_start and (line.strip().startswith("version:") or line.strip().startswith("services:")):
            found_start = True
        if found_start:
            # Stop if we hit prose (non-indented text after indentation started)
            if yaml_lines and line.strip() and not line.startswith(" ") and not line.strip().startswith(("#", "version", "services", "volumes", "networks")):
                break
            yaml_lines.append(line)
            
    if yaml_lines:
        return "\n".join(yaml_lines).strip()

    return text.replace("```yaml", "").replace("```yml", "").replace("```", "").strip()


def _find_balanced_braces(text: str) -> Optional[str]:
    """Find the outermost balanced { ... } in text, supporting nesting."""
    start = text.find("{")
    if start == -1:
        return None
    depth = 0
    in_string = False
    escape = False
    for i in range(start, len(text)):
        ch = text[i]
        if escape:
            escape = False
            continue
        if ch == "\\":
            escape = True
            continue
        if ch == '"':
            in_string = not in_string
            continue
        if in_string:
            continue
        if ch == "{":
            depth += 1
        elif ch == "}":
            depth -= 1
            if depth == 0:
                return text[start : i + 1]
    return None


def _repair_json_string(raw: str) -> str:
    """Attempt to fix common LLM JSON quirks.

    Many LLMs emit literal newlines inside JSON string values instead of
    the escape sequence ``\\n``.  This causes ``json.loads`` to fail.
    We walk the string and replace unescaped literal newlines (only those
    that appear *inside* a JSON string value) with ``\\n``.
    """
    out: list[str] = []
    in_string = False
    escape = False
    for ch in raw:
        if escape:
            out.append(ch)
            escape = False
            continue
        if ch == "\\":
            out.append(ch)
            escape = True
            continue
        if ch == '"':
            in_string = not in_string
            out.append(ch)
            continue
        if in_string and ch == "\n":
            out.append("\\n")
            continue
        out.append(ch)
    return "".join(out)


def _extract_json(text: str) -> Optional[dict]:
    """Robustly extract JSON from LLM output.

    Handles fenced code blocks, nested braces, and unescaped newlines.
    """
    # 1️⃣ Try fenced block first
    block = _extract_fenced_block(text, "json")
    candidates = [block] if block else []

    # 2️⃣ Balanced-brace extraction as fallback
    balanced = _find_balanced_braces(text)
    if balanced and balanced != block:
        candidates.append(balanced)

    for candidate in candidates:
        # Try raw parse
        try:
            return json.loads(candidate)
        except json.JSONDecodeError:
            pass
        try:
            return json.loads(_repair_json_string(candidate))
        except json.JSONDecodeError:
            pass
            
    # 3️⃣ NEW: Outermost brace fallback (for messy or truncated responses)
    try:
        start = text.find("{")
        end = text.rfind("}")
        if start != -1 and end != -1 and end > start:
            candidate = text[start:end+1]
            try:
                return json.loads(candidate)
            except json.JSONDecodeError:
                return json.loads(_repair_json_string(candidate))
    except Exception:
        pass

    return None


# ── Structured Output Models ────────────────────────────────────────────────

# Models imported from armorer.installers.schemas


# ── Installer ────────────────────────────────────────────────────────────────


class DynamicInstaller(BaseInstaller):

    def __init__(
        self,
        app_name: str,
        profile: RiskProfile,
        mount_paths: List[str],
        options: Dict[str, Any] = None,
    ):
        super().__init__(profile, mount_paths)
        self.app_name = app_name
        self.options = options or {}
        self.instance_name = str(self.options.get("instance_name", app_name)).strip() or app_name
        self.search_tool = SearchTool().search
        self.armorer = ArmorerAgent()
        self.registry = AppRegistry()
        self.install_dir = Path.cwd() / "installed" / self.instance_name
        self._compose_dir: Optional[Path] = None  # Set when compose lives in a subdir
        self._allow_docker_socket = bool(self.options.get("allow_docker_socket", False))
        self.policy_guard = PolicyGuard.for_installation(
            project_root=Path.cwd(),
            install_dir=self.install_dir,
            network_policy=self.profile.network_policy,
            extra_paths=[Path(p) for p in self.mount_paths],
            allow_docker_socket=self._allow_docker_socket,
            instance_name=self.instance_name,
        )
        self.armorer.set_policy_guard(self.policy_guard)

        # Knowledge base for saving learned fixes (A3)
        from armorer.knowledge import KnowledgeBase
        self._kb = KnowledgeBase()
        
        # Session ID for consistent tracing and context management
        import uuid
        # Sanitize app_name (remove path separators if it's a URL/path)
        safe_app_name = self.instance_name.replace("/", "_").replace("\\", "_").replace(":", "_")
        self._safe_app_name = safe_app_name
        self.session_id = f"armorer-{safe_app_name}-{uuid.uuid4()}"
        
        # Initialize the persistent agent explicitly requirements
        self.armorer.init_agent(
            system_prompt=self.ARMORER_SYSTEM_PROMPT,
            session_id=self.session_id,
            agent_name=f"armorer:{safe_app_name}"
        )
        self._lock_dir = _pick_writable_lock_dir()
        self._install_lock_path = self._lock_dir / f"{self._safe_app_name}.lock"
        self._install_lock_fd = None
        self._image_policy_failures: list[str] = []
        self._image_digest_cache: dict[str, Optional[str]] = {}
        self._compose_cmd_cache: Optional[list[str]] = None
        self._active_playbook = None

    def _compose_cmd(self) -> list[str]:
        """Return the available compose command as argv prefix.

        Prefers Docker Compose v2 (`docker compose`). Falls back to v1 (`docker-compose`)
        if the plugin is unavailable.
        """
        if self._compose_cmd_cache is not None:
            return list(self._compose_cmd_cache)

        try:
            from armorer.docker_runner import compose_version as _compose_version_check
            res = _compose_version_check()
            if res.ok:
                self._compose_cmd_cache = ["docker", "compose"]
                return list(self._compose_cmd_cache)
        except Exception:
            pass

        import shutil

        if shutil.which("docker-compose"):
            self._compose_cmd_cache = ["docker-compose"]
            return list(self._compose_cmd_cache)

        # As a last resort, return v2 prefix (it will fail with a clear error downstream).
        self._compose_cmd_cache = ["docker", "compose"]
        return list(self._compose_cmd_cache)

    def _run_with_live_logs(
        self,
        cmd: Any,
        *,
        label: str,
        cwd: Optional[Path] = None,
        shell: bool = False,
        env: Optional[Dict[str, str]] = None,
        check: bool = False,
    ) -> subprocess.CompletedProcess:
        """Run subprocess and stream output in a live panel when available."""
        effective_env = env
        if self._looks_like_docker_build_invocation(cmd, shell=shell):
            effective_env = self._docker_build_env(env)
        panel = _LiveLogPanel("Armorer Live", enabled=bool(self.options.get("live_ui", True)))
        if not panel.enabled:
            return subprocess.run(
                cmd,
                cwd=cwd,
                shell=shell,
                env=effective_env,
                check=check,
                capture_output=True,
                text=True,
            )

        panel.start()
        panel.set_header(label)
        out_lines: list[str] = []
        try:
            from armorer.docker_runner import run_streaming
            proc = run_streaming(
                cmd,
                cwd=cwd,
                env=effective_env,
                shell=shell,
                text=True,
            )
            assert proc.stdout is not None
            for raw_line in proc.stdout:
                line = raw_line.rstrip("\n")
                out_lines.append(line)
                panel.add_line(line)
            proc.wait()
            ok = proc.returncode == 0
            panel.set_header(f"{label} {'completed' if ok else 'failed'}")
            result = subprocess.CompletedProcess(
                args=cmd,
                returncode=proc.returncode,
                stdout="\n".join(out_lines),
                stderr="",
            )
            if check and not ok:
                raise subprocess.CalledProcessError(
                    proc.returncode,
                    cmd,
                    output=result.stdout,
                    stderr=result.stderr,
                )
            return result
        finally:
            panel.stop()

    @staticmethod
    def _docker_build_env(base_env: Optional[Dict[str, str]] = None) -> Dict[str, str]:
        env = {**os.environ, **(base_env or {})}
        # BuildKit is required by many modern Dockerfiles and works across host OSes because
        # it is interpreted by the Docker CLI/daemon, not by a shell-specific wrapper.
        env.setdefault("DOCKER_BUILDKIT", "1")
        env.setdefault("COMPOSE_DOCKER_CLI_BUILD", "1")
        env.setdefault("BUILDKIT_PROGRESS", "plain")
        return env

    @staticmethod
    def _looks_like_docker_build_invocation(cmd: Any, *, shell: bool = False) -> bool:
        if shell:
            text = " ".join(str(cmd or "").split()).lower()
        elif isinstance(cmd, (list, tuple)):
            text = " ".join(str(part) for part in cmd).strip().lower()
        else:
            text = str(cmd or "").strip().lower()
        return bool(
            re.search(r"\bdocker\s+build(?:x\s+build)?\b", text)
            or re.search(r"\bdocker\s+compose\s+build\b", text)
            or re.search(r"\bdocker-compose\s+build\b", text)
        )

    def _docker_build_local_image(
        self,
        image: str,
        cwd: Path,
        *,
        label: str,
        source_rev: Optional[str] = None,
    ) -> subprocess.CompletedProcess:
        """Build a local image tag reliably.

        Some Docker installs route `docker build` through buildx without loading the result into the local
        image store. Using `docker buildx build --load` ensures the `image:tag` is available for compose.
        """
        env = self._docker_build_env()
        cmd = ["docker", "buildx", "build", "--load", "--progress=plain", "-t", image]
        # When we know the git revision of the compose workspace, embed it as a
        # label on the image so future installs can detect stale local images.
        if source_rev:
            cmd.extend(["--label", f"armorer.openclaw.source_rev={source_rev}"])
        cmd.append(".")
        res = self._run_with_live_logs(cmd, cwd=cwd, label=label, check=False, env=env)
        if res.returncode != 0 and _looks_like_host_disk_full(res.stdout or ""):
            _render_host_disk_full_panel(label, res.stdout or "")
        elif res.returncode != 0 and _looks_like_docker_storage_io_error(res.stdout or ""):
            _render_docker_storage_fault_panel(label, res.stdout or "")
        return res

    @staticmethod
    def _docker_build_target_image(cmd: Any, *, shell: bool = False) -> Optional[str]:
        try:
            argv = shlex.split(cmd) if shell and isinstance(cmd, str) else list(cmd)
        except Exception:
            return None
        if len(argv) < 4 or argv[0] != "docker" or argv[1] not in {"build", "buildx"}:
            return None
        for idx, token in enumerate(argv[:-1]):
            if token in {"-t", "--tag"}:
                image = str(argv[idx + 1] or "").strip()
                return image or None
        return None

    @staticmethod
    def _docker_image_exists(image: str) -> bool:
        try:
            from armorer.docker_runner import docker_image_inspect
            result = docker_image_inspect(image)
            return result.ok
        except Exception:
            return False

    def _compose_source_revision(self) -> Optional[str]:
        """Best-effort git revision for the compose workspace."""
        from armorer.installers.dynamic.local_images import compose_source_revision as _compose_source_revision_impl

        return _compose_source_revision_impl(cwd=self._docker_compose_cwd)

    @staticmethod
    def _docker_image_source_revision(image: str) -> Optional[str]:
        """Read the armorer.openclaw.source_rev label from an image, if present."""
        from armorer.installers.dynamic.local_images import docker_image_source_revision as _docker_image_source_revision_impl

        return _docker_image_source_revision_impl(image)

    def _normalize_compose_yaml(self, content: str) -> str:
        """
        Normalize docker-compose.yml to ensure compatibility with older versions.
        Specifically handles:
        - Converting map-style env_file (path:, required:) to simple string lists (v2.24+ -> legacy).
        """
        import yaml
        
        try:
            data = yaml.safe_load(content)
            if not data or not isinstance(data, dict):
                return content
            
            modified = False
            
            # Helper to normalize env_file in a service or extension
            def normalize_env_file(obj):
                nonlocal modified
                if "env_file" in obj:
                    val = obj["env_file"]
                    if isinstance(val, list):
                        new_val = []
                        for item in val:
                            if isinstance(item, dict) and "path" in item:
                                p = str(item["path"])
                                required = bool(item.get("required", True))
                                base = self._compose_dir or self.install_dir
                                target = (base / p) if not Path(p).is_absolute() else Path(p)
                                exists_with_content = target.exists() and (not target.is_file() or target.stat().st_size > 0)
                                if required or exists_with_content:
                                    new_val.append(p)
                                # Drop optional missing env files instead of forcing empty placeholders.
                                modified = True
                            else:
                                new_val.append(item)
                        obj["env_file"] = new_val
                    elif isinstance(val, dict) and "path" in val:
                        p = str(val["path"])
                        required = bool(val.get("required", True))
                        base = self._compose_dir or self.install_dir
                        target = (base / p) if not Path(p).is_absolute() else Path(p)
                        exists_with_content = target.exists() and (not target.is_file() or target.stat().st_size > 0)
                        obj["env_file"] = [p] if (required or exists_with_content) else []
                        modified = True

            # Normalize services
            for svc_name, svc in data.get("services", {}).items():
                if isinstance(svc, dict):
                    normalize_env_file(svc)
                    # macOS Docker Desktop compatibility: strip SELinux relabel options (:z/:Z)
                    # from bind mounts, which are Linux-specific and can break Desktop mounts.
                    vols = svc.get("volumes")
                    if isinstance(vols, list):
                        rewritten = []
                        changed = False
                        for v in vols:
                            if not isinstance(v, str):
                                rewritten.append(v)
                                continue
                            parts = v.split(":")
                            if len(parts) < 3:
                                rewritten.append(v)
                                continue
                            src = parts[0].strip()
                            opts = [opt for opt in parts[2:] if opt]
                            cleaned = [o for o in opts if o.lower() not in {"z"}]
                            if cleaned != opts and (src.startswith("./") or src.startswith("../") or src.startswith("/")):
                                rebuilt = f"{parts[0]}:{parts[1]}"
                                if cleaned:
                                    rebuilt += ":" + ":".join(cleaned)
                                rewritten.append(rebuilt)
                                changed = True
                            else:
                                rewritten.append(v)
                        if changed:
                            svc["volumes"] = rewritten
                            modified = True
            
            # Normalize extension fields (common in AutoGPT)
            for key, val in data.items():
                if key.startswith("x-") and isinstance(val, dict):
                    normalize_env_file(val)

            if modified:
                return yaml.dump(data, sort_keys=False)
        except Exception:
            pass # Fallback to original if YAML is truly mangled
            
        return content

    def _ensure_env_files_exist(self, content: str, base_dir: Optional[Path] = None):
        """Proactively touch referenced .env files to avoid 'no such file' errors."""
        import yaml
        import shutil
        try:
            data = yaml.safe_load(content)
            if not data or not isinstance(data, dict):
                return
            
            paths = set()
            def collect_paths(obj):
                if "env_file" in obj:
                    ef = obj["env_file"]
                    if isinstance(ef, list):
                        for item in ef:
                            if isinstance(item, str): paths.add(item)
                    elif isinstance(ef, str):
                        paths.add(ef)

            for svc in data.get("services", {}).values():
                if isinstance(svc, dict):
                    collect_paths(svc)
            for key, val in data.items():
                if key.startswith("x-") and isinstance(val, dict):
                    collect_paths(val)

            for p in paths:
                # Ensure path is relative to the compose file or absolute
                # For simplicity, we touch relative to cwd if it's a simple path
                try:
                    full_p = (base_dir or self._docker_compose_cwd) / p
                    if not full_p.exists():
                        full_p.parent.mkdir(parents=True, exist_ok=True)
                        # Prefer a local default/sample over an empty file to avoid invalid runtime env.
                        candidates = [
                            full_p.parent / ".env.default",
                            full_p.parent / ".env.example",
                            full_p.parent / ".env.sample",
                            full_p.with_suffix(full_p.suffix + ".default"),
                            full_p.with_suffix(full_p.suffix + ".example"),
                        ]
                        template = next((c for c in candidates if c.exists()), None)
                        if template:
                            shutil.copy2(template, full_p)
                        else:
                            full_p.touch()
                        console.print(f"  [dim]Created missing env file: {p}[/dim]")
                except Exception:
                    pass
        except Exception:
            pass

    def _normalize_all_compose_files(self):
        """Scan project for all compose files and normalize them."""
        patterns = [
            "*docker-compose*.yml",
            "*docker-compose*.yaml",
            "compose*.yml",
            "compose*.yaml",
        ]
        seen = set()
        for pattern in patterns:
            for p in self.install_dir.rglob(pattern):
                if p.is_file() and p not in seen:
                    seen.add(p)
                    try:
                        content = p.read_text()
                        fixed = self._normalize_compose_yaml(content)
                        self._ensure_env_files_exist(fixed, base_dir=p.parent)
                        if fixed != content:
                            p.write_text(fixed)
                    except Exception:
                        pass

    def _get_proactive_knowledge(self) -> str:
        """Fetch proactive knowledge for the current app (e.g. autogpt.md)."""
        try:
            k_dir = Path(__file__).parent.parent / "knowledge"
            # Check for specific app knowledge
            app_k = k_dir / f"{self.app_name.lower()}.md"
            if app_k.exists():
                return f"\nPROACTIVE KNOWLEDGE FOR {self.app_name}:\n{app_k.read_text()}\n"

            # Fallback to fuzzy match
            if "autogpt" in self.app_name.lower():
                app_k = k_dir / "autogpt.md"
                if app_k.exists():
                    return f"\nPROACTIVE KNOWLEDGE FOR {self.app_name}:\n{app_k.read_text()}\n"
        except Exception:
            pass
        return ""

    @property
    def _docker_compose_cwd(self) -> Path:
        """Directory where ``docker compose`` commands should run.

        Returns ``_compose_dir`` for platform repos whose compose lives
        in a subdirectory, otherwise falls back to ``install_dir``.
        """
        base = self._compose_dir or self.install_dir
        # Docker-out-of-Docker path translation:
        # compose runs inside Armorer core container but controls host daemon via docker.sock.
        # Relative bind mounts must resolve against a host path, not /app/... container path.
        if os.path.exists("/.dockerenv"):
            host_root = os.getenv("HOST_PROJECT_ROOT", "").strip()
            base_s = str(base)
            if host_root and base_s.startswith("/app/"):
                rel = base_s[len("/app/") :]
                translated = Path(host_root) / rel
                # Even if the directory doesn't exist yet, we still map the path.
                # Unit tests expect the installer to preserve the intended host layout.
                return translated
        return base

    def _host_visible_path(self, path: Path) -> Path:
        """Translate sandbox paths under /app back to the host project root."""
        p = Path(path)
        if not os.path.exists("/.dockerenv"):
            return p
        host_root = os.getenv("HOST_PROJECT_ROOT", "").strip()
        path_str = str(p)
        if host_root and path_str.startswith("/app/"):
            rel = p.relative_to("/app")
            return Path(host_root) / rel
        return p

    def _compose_runtime_env(self) -> Dict[str, str]:
        """Build env for docker compose so host-side compose calls share one env source of truth."""
        env = os.environ.copy()
        getuid = getattr(os, "getuid", None)
        getgid = getattr(os, "getgid", None)
        if callable(getuid):
            env.setdefault("UID", str(getuid()))
        if callable(getgid):
            env.setdefault("GID", str(getgid()))

        compose_dir = self._docker_compose_cwd
        env_path = compose_dir / ".env"
        env_vars = _load_dotenv_kv(env_path)

        for key, val in env_vars.items():
            raw = str(val or "").strip()
            if not raw:
                continue
            env[key] = raw
            if raw.startswith("./") or raw.startswith("../"):
                env[key] = str((compose_dir / raw).resolve())
                continue
            if raw.startswith("/"):
                env[key] = str(self._host_visible_path(Path(raw)))

        for key in ("OPENCLAW_CONFIG_DIR", "OPENCLAW_WORKSPACE_DIR"):
            cur = str(env.get(key) or "").strip()
            if cur.startswith("/app/"):
                env[key] = str(self._host_visible_path(Path(cur)))

        if self._is_openclaw_target():
            env.setdefault(
                "OPENCLAW_CONFIG_DIR",
                str(self._host_visible_path(compose_dir / ".openclaw")),
            )
            env.setdefault(
                "OPENCLAW_WORKSPACE_DIR",
                str(self._host_visible_path(compose_dir / "workspace")),
            )
            env["OPENCLAW_IMAGE"] = resolve_openclaw_runtime_image(
                env,
                fallback=OPENCLAW_RUNTIME_IMAGE_DEFAULT,
            )

        return env

    def _docker_compose_env(self) -> Dict[str, str]:
        """Compose env used by host-side `docker compose` calls."""
        return self._compose_runtime_env()

    def _compose_run(self, compose_args: list[str], **kwargs: Any) -> subprocess.CompletedProcess:
        """Run `docker compose <args>` via subprocess, with translated env."""
        cmd = self._compose_cmd() + list(compose_args)
        env = self._docker_compose_env()
        cwd = str(self._docker_compose_cwd)
        return subprocess.run(cmd, cwd=cwd, env=env, **kwargs)

    def install(self):
        _step_header(self.app_name)
        self._acquire_install_lock()
        try:
            self._ensure_docker_running()
            if self._is_git_url(self.app_name):
                self._install_from_git()
            else:
                self._install_dynamic()
        finally:
            self._release_install_lock()

    def _acquire_install_lock(self):
        """Prevent concurrent installers from racing on the same app directory."""
        import fcntl

        self._lock_dir.mkdir(parents=True, exist_ok=True)
        # Use r+ / w+ without truncating existing content before lock acquisition.
        # Opening with plain "w+" would erase the owner PID even on lock contention.
        try:
            if self._install_lock_path.exists():
                lock_fd = open(self._install_lock_path, "r+")
            else:
                lock_fd = open(self._install_lock_path, "w+")
        except PermissionError:
            fallback_dir = Path.home() / ".armorer" / "locks-local"
            try:
                fallback_dir.mkdir(parents=True, exist_ok=True)
            except Exception:
                pass
            if self._lock_dir != fallback_dir:
                self._lock_dir = fallback_dir
                self._install_lock_path = self._lock_dir / f"{self._safe_app_name}.lock"
                return self._acquire_install_lock()
            raise
        try:
            fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError:
            existing_pid = lock_fd.read().strip()
            # Recover from stale locks (common after SIGKILL / crashes).
            stale = False
            try:
                pid = int(existing_pid) if existing_pid else -1
                if pid > 1 and not Path(f"/proc/{pid}").exists():
                    stale = True
            except Exception:
                pass
            if stale:
                try:
                    lock_fd.close()
                except Exception:
                    pass
                try:
                    self._install_lock_path.unlink(missing_ok=True)  # py3.8+ compat ok in 3.11
                except Exception:
                    pass
                # Retry once after clearing stale lock.
                return self._acquire_install_lock()

            _step_fail("Install lock", f"Another install is running (pid={existing_pid or 'unknown'})")
            raise typer.Exit(code=1)

        lock_fd.seek(0)
        lock_fd.truncate(0)
        lock_fd.write(str(os.getpid()))
        lock_fd.flush()

        self._install_lock_fd = lock_fd
        atexit.register(self._release_install_lock)

    def _release_install_lock(self):
        if not self._install_lock_fd:
            return
        try:
            import fcntl
            fcntl.flock(self._install_lock_fd.fileno(), fcntl.LOCK_UN)
        except Exception:
            pass
        try:
            self._install_lock_fd.close()
        except Exception:
            pass
        self._install_lock_fd = None

    # ── Docker ───────────────────────────────────────────────────────────────

    def _ensure_docker_running(self):
        label = "Checking Docker"
        if not self._docker_info_reachable():
            docker_binary = shutil.which("docker")
            detail = "Docker is not reachable" if docker_binary else "Docker CLI is not installed"
            _step_fail(label, detail)
            console.print(
                "[bold red]    Docker Desktop / daemon must be running.[/bold red]"
                if docker_binary
                else "[bold red]    Install Docker and retry.[/bold red]"
            )
            raise typer.Exit(code=1)

        if not self._docker_compose_available():
            _step_fail(label, "Docker Compose is not available")
            console.print(
                "[bold red]    Install/enable Docker Compose and retry.[/bold red]"
            )
            raise typer.Exit(code=1)

        _step_done(label)
        if self._is_openclaw_target():
            self._ensure_openclaw_build_prerequisites()

    def _docker_info_reachable(self) -> bool:
        try:
            res = subprocess.run(
                ["docker", "info"],
                capture_output=True,
                text=True,
            )
            return res.returncode == 0
        except Exception:
            return False

    def _docker_compose_available(self) -> bool:
        try:
            res = subprocess.run(
                ["docker", "compose", "version"],
                capture_output=True,
                text=True,
            )
            return res.returncode == 0
        except Exception:
            # Fallback for older docker installs.
            docker_compose = shutil.which("docker-compose")
            if not docker_compose:
                return False
            try:
                compose_v1 = subprocess.run(
                    [docker_compose, "version"],
                    capture_output=True,
                    text=True,
                )
                return compose_v1.returncode == 0
            except Exception:
                return False

    def _is_openclaw_target(self) -> bool:
        playbook = self._active_playbook or resolve_playbook(self.app_name)
        playbook_id = str(getattr(playbook, "id", "") or "").strip().lower()
        if playbook_id == "openclaw":
            return True
        return str(self.app_name or "").strip().lower() == "openclaw"

    @staticmethod
    def _format_bytes(value: Optional[int]) -> str:
        if value is None:
            return "unknown"
        size = float(value)
        units = ("B", "KiB", "MiB", "GiB", "TiB")
        unit = units[0]
        for unit in units:
            if size < 1024.0 or unit == units[-1]:
                break
            size /= 1024.0
        if unit == "B":
            return f"{int(size)} {unit}"
        return f"{size:.1f} {unit}"

    @staticmethod
    def _env_gib_threshold(name: str, default: float) -> Optional[int]:
        raw = str(os.getenv(name, "") or "").strip()
        if not raw:
            return int(default * (1024 ** 3))
        try:
            gib = float(raw)
        except ValueError:
            return int(default * (1024 ** 3))
        if gib <= 0:
            return None
        return int(gib * (1024 ** 3))

    def _docker_info_json(self) -> Optional[dict[str, Any]]:
        try:
            from armorer.docker_runner import docker_info
            result = docker_info(format_template="{{json .}}")
        except Exception:
            return None
        if not result.ok:
            return None
        try:
            return json.loads((result.stdout or "").strip() or "{}")
        except Exception:
            return None

    def _docker_runtime_memory_bytes(self, info: Optional[dict[str, Any]]) -> Optional[int]:
        if not isinstance(info, dict):
            return None
        for key in ("MemTotal", "MemoryLimit"):
            value = info.get(key)
            if isinstance(value, (int, float)) and value > 0:
                return int(value)
        return None

    def _docker_runtime_free_disk_bytes(self, info: Optional[dict[str, Any]]) -> tuple[Optional[int], Optional[Path]]:
        candidates: list[Path] = []
        if isinstance(info, dict):
            root_dir = str(info.get("DockerRootDir") or "").strip()
            if root_dir:
                candidates.append(Path(root_dir))

        candidates.extend(
            [
                Path.home(),
                self._host_visible_path(Path.cwd()),
                self._host_visible_path(self.install_dir),
            ]
        )

        seen: set[str] = set()
        for candidate in candidates:
            key = str(candidate)
            if key in seen:
                continue
            seen.add(key)
            target = candidate if candidate.exists() else candidate.parent
            try:
                usage = shutil.disk_usage(target)
            except Exception:
                continue
            return usage.free, target

        return None, None

    def _ensure_openclaw_build_prerequisites(self):
        label = "Checking OpenClaw build prerequisites"
        info = self._docker_info_json()
        default_min_memory_gib = _openclaw_default_memory_threshold_gib(info)
        min_memory = self._env_gib_threshold(
            "ARMORER_OPENCLAW_MIN_DOCKER_MEMORY_GIB",
            default_min_memory_gib,
        )
        min_disk = self._env_gib_threshold(
            "ARMORER_OPENCLAW_MIN_FREE_DISK_GIB",
            _OPENCLAW_MIN_FREE_DISK_GIB,
        )

        memory_bytes = self._docker_runtime_memory_bytes(info)
        free_disk_bytes, free_disk_path = self._docker_runtime_free_disk_bytes(info)

        issues: list[str] = []
        if min_memory is not None and memory_bytes is not None and memory_bytes < min_memory:
            issues.append(
                f"Docker memory is too low ({self._format_bytes(memory_bytes)} available, "
                f"{self._format_bytes(min_memory)} required)."
            )
        if min_disk is not None and free_disk_bytes is not None and free_disk_bytes < min_disk:
            location = f" at {free_disk_path}" if free_disk_path is not None else ""
            issues.append(
                f"Free disk space{location} is too low ({self._format_bytes(free_disk_bytes)} available, "
                f"{self._format_bytes(min_disk)} required)."
            )

        if issues:
            _step_fail(label, "resource check failed")
            runtime_name = str((info or {}).get("OperatingSystem") or "Docker").strip() or "Docker"
            details = [f"- {issue}" for issue in issues]
            guidance = [
                "- Increase Docker memory to at least "
                + self._format_bytes(min_memory)
                if min_memory is not None
                else None,
                "- Free disk with `docker builder prune -af` and `docker system prune -af`"
                if min_disk is not None
                else None,
                "- Retry the install after Docker reports healthy resources",
            ]
            guidance = [line for line in guidance if line]
            console.print(
                Panel(
                    "[bold red]OpenClaw's local image build is resource intensive.[/bold red]\n\n"
                    f"[bold]Docker runtime:[/bold] {runtime_name}\n"
                    f"[bold]Detected memory:[/bold] {self._format_bytes(memory_bytes)}\n"
                    f"[bold]Detected free disk:[/bold] {self._format_bytes(free_disk_bytes)}"
                    + (f" ({free_disk_path})" if free_disk_path is not None else "")
                    + "\n\n"
                    + "\n".join(details)
                    + "\n\n[bold]Fix:[/bold]\n"
                    + "\n".join(guidance)
                    + "\n\n"
                    + "[dim]Override thresholds with ARMORER_OPENCLAW_MIN_DOCKER_MEMORY_GIB "
                    + "and ARMORER_OPENCLAW_MIN_FREE_DISK_GIB if you intentionally want to lower them.[/dim]",
                    title="OpenClaw Preflight",
                    border_style="red",
                )
            )
            raise typer.Exit(code=1)

        detail_parts: list[str] = []
        if memory_bytes is not None:
            detail_parts.append(f"memory {self._format_bytes(memory_bytes)}")
        if free_disk_bytes is not None:
            detail_parts.append(f"free disk {self._format_bytes(free_disk_bytes)}")
        _step_done(label, ", ".join(detail_parts) if detail_parts else "resource inspection unavailable")

    # ── URL detection ────────────────────────────────────────────────────────

    def _is_git_url(self, source: str) -> bool:
        return source.startswith("http") and (
            "github.com" in source or "gitlab.com" in source
        )

    # ── Monorepo / Platform Detection ────────────────────────────────────────

    def _find_best_compose(self) -> Optional[Path]:
        """Scan the repo for docker-compose files and pick the richest one.

        Platform repos (e.g. AutoGPT) often keep the real compose in a
        subdirectory such as ``autogpt_platform/``.  This method finds all
        compose files, scores them by number of declared services, and
        returns the one with the highest count.
        """
        candidates: list[tuple[int, Path]] = []
        skip_dirs = {".git", "node_modules", ".venv", "__pycache__", "test", "tests"}

        for pattern in ("docker-compose.yml", "docker-compose.yaml"):
            for path in self.install_dir.rglob(pattern):
                # Skip nested test / vendor dirs
                if any(part in skip_dirs for part in path.parts):
                    continue
                # Skip compose files that only extend others (no real services)
                try:
                    data = yaml.safe_load(path.read_text())
                    services = (data or {}).get("services", {})
                    # Count services that have a real definition (build/image)
                    real_svc_count = sum(
                        1 for s in services.values()
                        if isinstance(s, dict) and (s.get("image") or s.get("build") or s.get("extends"))
                    )
                    candidates.append((real_svc_count, path))
                except Exception:
                    candidates.append((0, path))

        if not candidates:
            return None

        # Sort by service count descending; prefer deeper paths on tie
        # (deeper = more likely a platform subdirectory, not a stub)
        candidates.sort(key=lambda t: (t[0], len(t[1].parts)), reverse=True)
        best_score, best_path = candidates[0]

        # Only prefer a subdir compose if it's meaningfully richer
        root_compose = self.install_dir / "docker-compose.yml"
        if not root_compose.exists():
            root_compose = self.install_dir / "docker-compose.yaml"

        if root_compose.exists() and best_path != root_compose:
            try:
                root_data = yaml.safe_load(root_compose.read_text())
                root_count = len((root_data or {}).get("services", {}))
            except Exception:
                root_count = 0
            # Prefer subdir compose if it has 2x+ more services
            if best_score > root_count * 2:
                return best_path
            # If root has a reasonable set of services, use it
            if root_count >= 3:
                return root_compose

        return best_path

    def _detect_setup_sequence(self) -> list[Dict[str, Any]]:
        """Detect setup steps needed before deployment.

        Scans for Makefiles, ``.env.default`` files, and setup scripts
        to build an ordered list of recommended pre-deployment actions.
        """
        steps: list[Dict[str, Any]] = []
        compose_dir = self._host_visible_path(self._compose_dir or self.install_dir)
        install_root = self._host_visible_path(self.install_dir)

        # Deterministic playbook steps for known agents.
        playbook = resolve_playbook(self.app_name)
        if playbook:
            for pair in playbook.env_copies:
                src_rel = pair.get("from", "")
                dst_rel = pair.get("to", "")
                if not src_rel or not dst_rel:
                    continue
                src = compose_dir / src_rel
                dst = compose_dir / dst_rel
                if src.exists() and not dst.exists():
                    steps.append({
                        "type": "env_copy",
                        "label": f"Copy {src_rel} → {dst_rel}",
                        "source": str(src),
                        "target": str(dst),
                    })
            for cmd in playbook.commands:
                raw_cmd_cwd = compose_dir if cmd.cwd == "." else (self.install_dir / cmd.cwd)
                cmd_cwd = self._host_visible_path(raw_cmd_cwd)
                if not cmd_cwd.exists():
                    continue
                steps.append({
                    "type": "command",
                    "label": cmd.label,
                    "command": cmd.command,
                    "cwd": str(cmd_cwd.resolve()),
                    "required": bool(getattr(cmd, "required", True)),
                })

        # 1. Find .env.default / .env.example files that need copying
        for pattern in (".env.default", ".env.example", ".env.sample"):
            for env_default in compose_dir.rglob(pattern):
                env_target = env_default.parent / ".env"
                if not env_target.exists():
                    try:
                        source_label = str(env_default.relative_to(install_root))
                    except Exception:
                        source_label = str(env_default.name)
                    steps.append({
                        "type": "env_copy",
                        "label": f"Copy {source_label} → .env",
                        "source": str(env_default),
                        "target": str(env_target),
                    })

        # 2. Find Makefile targets
        makefile = compose_dir / "Makefile"
        if not makefile.exists():
            makefile = self.install_dir / "Makefile"
        if makefile.exists():
            try:
                content = makefile.read_text()
                # Parse common setup targets
                setup_targets = []
                for line in content.splitlines():
                    m = re.match(r'^([a-zA-Z_-]+):', line)
                    if m:
                        target = m.group(1)
                        if any(k in target.lower() for k in [
                            "init", "setup", "migrate", "start-core", "install",
                            "env", "seed", "bootstrap"
                        ]):
                            # Try to find the comment above or inline
                            desc = ""
                            idx = content.splitlines().index(line)
                            if idx > 0:
                                prev = content.splitlines()[idx - 1].strip()
                                if prev.startswith("#"):
                                    desc = prev.lstrip("# ").strip()
                            setup_targets.append((target, desc))

                for target, desc in setup_targets:
                    steps.append({
                        "type": "makefile",
                        "label": f"make {target}" + (f" — {desc}" if desc else ""),
                        "command": f"make {target}",
                        "cwd": str(makefile.parent),
                    })
            except Exception:
                pass

        # 3. Find setup scripts
        for script_name in ("setup.sh", "docker-setup.sh", "install.sh", "bootstrap.sh"):
            script = compose_dir / script_name
            if not script.exists():
                script = self.install_dir / script_name
            if script.exists():
                steps.append({
                    "type": "script",
                    "label": f"Run {script_name}",
                    "command": f"bash {script}",
                    "cwd": str(script.parent),
                })

        return steps

    def _playbook_run_commands(self) -> List[Dict[str, str]]:
        """Deterministic run shortcuts declared in agent playbook."""
        playbook = resolve_playbook(self.app_name)
        if not playbook:
            return []
        out: List[Dict[str, str]] = []
        for cmd in getattr(playbook, "run_commands", []) or []:
            if not cmd.command or not cmd.service:
                continue
            out.append(
                {
                    "_armorer_source": "playbook",
                    "label": cmd.label,
                    "service": cmd.service,
                    "command": cmd.command,
                    "category": cmd.category or "run",
                    "description": cmd.description or "",
                }
            )
        return out

    # ── Git flow ─────────────────────────────────────────────────────────────

    def _install_from_git(self):
        repo_name = self.app_name.split("/")[-1].replace(".git", "")
        if self.options.get("instance_name"):
            target_name = str(self.options.get("instance_name")).strip()
        else:
            target_name = repo_name
        self.instance_name = target_name or repo_name
        self.install_dir = Path.cwd() / "installed" / self.instance_name

        # Clone / update
        label = f"Cloning {self.instance_name}"
        if self.install_dir.exists():
            label = f"Updating {self.instance_name}"
            res = self._run_with_live_logs(
                ["git", "pull"],
                cwd=self.install_dir,
                label=label,
                check=False,
            )
            if res.returncode == 0:
                _step_done(label)
            else:
                _step_fail(label, "git pull failed")
        else:
            res = self._run_with_live_logs(
                ["git", "clone", self.app_name, str(self.install_dir)],
                label=label,
                check=False,
            )
            if res.returncode != 0:
                _step_fail(label, "git clone failed")
                raise typer.Exit(1)
            _step_done(label)

        self.app_name = repo_name

        # Delegate to graph for analysis and planning
        self._install_dynamic()



    # ── Dynamic (search-based) flow ──────────────────────────────────────────

    def _install_dynamic(self):
        """Install using Universal Swarm Architecture."""
        # Bootstrap known repositories when app name is a catalog alias
        # (e.g. "AutoGPT"), so fallback planning has concrete repo context.
        self._bootstrap_known_repo()

        # Gather initial context
        proactive = self._get_proactive_knowledge()

        try:
            builder = InstallerGraphBuilder(
                installer_instance=self,
                app_name=self.app_name,
                system_prompt=self.ARMORER_SYSTEM_PROMPT,
                proactive_knowledge=proactive,
                status_obj=None,
                provider=self.options.get("provider"),
                model_name=self.options.get("model"),
                policy_guard=self.policy_guard,
            )
            swarm = builder.build()

            # Execute Swarm: Starting with the Researcher
            result = swarm(f"Perform a deep research and planning for the installation of {self.app_name}")
            
            if result.status == Status.FAILED and not result.results:
                failure_msg = "Swarm execution failed before any agents could complete."
                console.print(f"[red]{failure_msg}[/red]")
                raise typer.Exit(1)
                
            # Search for the final ExecutionResult from the 'triage' node
            from armorer.installers.schemas import ExecutionResult
            exec_result = None
            
            # The structure is now a Classic Graph: Graph Result -> stabilizer node -> Node Result
            stabilizer_res = result.results.get("stabilizer")
            if stabilizer_res and hasattr(stabilizer_res.result, "structured_output"):
                exec_result = stabilizer_res.result.structured_output
            
            # Fallback hunt: Iterate through all nodes for an ExecutionResult
            if not exec_result:
                def find_exec_result(res_obj):
                    if hasattr(res_obj, "structured_output") and isinstance(res_obj.structured_output, ExecutionResult):
                        return res_obj.structured_output
                    if hasattr(res_obj, "results") and isinstance(res_obj.results, dict):
                        for sub_res in res_obj.results.values():
                             found = find_exec_result(sub_res.result)
                             if found: return found
                    return None
                
                exec_result = find_exec_result(result)

            if not exec_result:
                # If we successfully completed but can't find a structured result
                console.print("[yellow]⚠ Swarm finished but did not return a structured ExecutionResult.[/yellow]")
                
                # Assume success if Status is COMPLETED, otherwise failure
                if result.status == Status.COMPLETED:
                     exec_result = ExecutionResult(success=True, message="Installation finished (manual verification required)", access_urls=[])
                else:
                     console.print(f"[red]❌ Installation failed: Swarm did not stabilize the system.[/red]")
                     raise typer.Exit(1)

            if exec_result.success:
                 console.print(f"[bold green]✅ {exec_result.message}[/bold green]")
            else:
                 console.print(f"[bold red]❌ Installation failed: {exec_result.message}[/bold red]")
                 raise typer.Exit(1)

            
        except Exception as e:
            if "timed out" in str(e):
                 console.print(f"[red]❌ Swarm execution timed out. This usually happens if the agent gets stuck in a loop or the model is unresponsive.[/red]")
            else:
                 console.print(f"[red]❌ Error during graph execution: {e}[/red]")
            raise typer.Exit(1)

    def _bootstrap_known_repo(self):
        """Clone/update well-known agent repos for deterministic install paths."""
        playbook = resolve_playbook(self.app_name)
        if not playbook:
            return
        self._active_playbook = playbook
        repo_url = playbook.repo_url

        self.install_dir.parent.mkdir(parents=True, exist_ok=True)
        if self.install_dir.exists() and (self.install_dir / ".git").exists():
            # Best-effort update for existing clones.
            self._run_with_live_logs(
                ["git", "pull", "--ff-only"],
                cwd=self.install_dir,
                label=f"Updating known repo for {self.app_name}",
                check=False,
            )
            self._prime_known_repo_playbook(playbook)
            # Honor known compose subdir if present.
            compose_dir = (self.install_dir / playbook.compose_subdir).resolve()
            if compose_dir.exists():
                self._compose_dir = compose_dir
            else:
                best = self._find_best_compose()
                if best:
                    self._compose_dir = best.parent
            return

        if self.install_dir.exists() and not (self.install_dir / ".git").exists():
            import shutil
            try:
                shutil.rmtree(self.install_dir)
            except Exception:
                pass

        if self.install_dir.exists() and not any(self.install_dir.iterdir()):
            self.install_dir.rmdir()

        label = f"Cloning known repo for {self.app_name}"
        res = self._run_with_live_logs(
            ["git", "clone", repo_url, str(self.install_dir)],
            label=label,
            check=False,
        )
        if res.returncode == 0:
            _step_done(label)
            self._prime_known_repo_playbook(playbook)
            compose_dir = (self.install_dir / playbook.compose_subdir).resolve()
            if compose_dir.exists():
                self._compose_dir = compose_dir
            else:
                best = self._find_best_compose()
                if best:
                    self._compose_dir = best.parent
        else:
            _step_fail(label, "clone failed; falling back to generic planner")

    def _prime_known_repo_playbook(self, playbook):
        """Run deterministic setup early when a known repo needs prep before compose discovery."""
        compose_dir = self._host_visible_path((self.install_dir / playbook.compose_subdir).resolve())
        if compose_dir.exists():
            self._compose_dir = compose_dir

        steps = []
        for pair in getattr(playbook, "env_copies", []) or []:
            src_rel = pair.get("from", "")
            dst_rel = pair.get("to", "")
            if not src_rel or not dst_rel:
                continue
            src = compose_dir / src_rel
            dst = compose_dir / dst_rel
            if src.exists() and not dst.exists():
                steps.append({
                    "type": "env_copy",
                    "label": f"Copy {src_rel} -> {dst_rel}",
                    "source": str(src),
                    "target": str(dst),
                })

        for cmd in getattr(playbook, "commands", []) or []:
            raw_cmd_cwd = compose_dir if cmd.cwd == "." else (self.install_dir / cmd.cwd).resolve()
            cmd_cwd = self._host_visible_path(raw_cmd_cwd)
            if not Path(cmd_cwd).exists():
                continue
                steps.append({
                    "type": "command",
                    "label": cmd.label,
                    "command": cmd.command,
                    "cwd": str(cmd_cwd),
                    "required": bool(getattr(cmd, "required", True)),
                })

        if steps:
            console.print(f"  [dim]Preparing known repo for {self.app_name} before plan inference.[/dim]")
            # This is a pre-inference pass; keep it quiet to avoid duplicating the
            # full “Pre-Deployment Setup” panel shown later post-plan.
            self._execute_setup_sequence(steps, show_banner=False, context="pre_inference")

        try:
            if compose_dir.exists():
                self._compose_dir = compose_dir
        except Exception:
            pass

    def _detect_post_config_sequence(self) -> list[Dict[str, Any]]:
        """Detect post-config steps (e.g. tightening permissions after setup)."""
        if not self._is_openclaw_target():
            return []

        # Unit tests assert these exact strings for OpenClaw.
        return [
            {
                "type": "command",
                "label": "Lock down OpenClaw permissions",
                "command": "chmod 700 .openclaw && chmod 600 .openclaw/openclaw.json",
                "cwd": ".",
                "required": True,
            }
        ]


    # ── Shared post-plan steps ───────────────────────────────────────────────

    def _run_post_plan(self, plan: Dict[str, Any], is_git: bool = False):
        self._did_autostart_after_install = False
        label = "Preparing files"
        with _step_start(label):
            self._prepare_files(plan)
        _step_done(label)

        # Run detected setup steps (env copies, Makefile targets, etc.)
        setup_steps = self._detect_setup_sequence()
        if setup_steps:
            self._execute_setup_sequence(setup_steps, show_banner=True, context="post_plan")

        self._maybe_configure_env()

        # REALITY CHECK: verify both planner and deterministic playbook shortcuts
        all_run_cmds = (plan.get("run_commands", []) or []) + self._playbook_run_commands()
        run_commands = self._verify_run_commands(all_run_cmds)

        label = "Registering with Armorer"
        with _step_start(label):
            self._register(is_git=is_git, run_commands=run_commands)
        _step_done(label)

        if self.options.get("run_after_install", True):
            pb_for_start = self._active_playbook or resolve_playbook(self.app_name)
            if pb_for_start and not (pb_for_start.startup_services or []):
                console.print("  [dim]No startup services declared in playbook; skipping auto-start after install.[/dim]")
            if self._is_profile_only_compose():
                console.print("  [dim]Profile-only compose detected; skipping background stabilization.[/dim]")
            elif not (pb_for_start and not (pb_for_start.startup_services or [])):
                self._start_and_stabilize()
                self._did_autostart_after_install = True

        # Post-install menu
        if (
            run_commands
            and self.options.get("confirm", True)
            and str(self.app_name or "").strip().lower() != "openclaw"
        ):
            self._offer_run_menu(run_commands)

        console.print()
        console.print(
            f"[bold green]✅ {self.app_name} installed successfully![/bold green]"
        )
        console.print()

    def _execute_setup_sequence(self, steps: list[Dict[str, Any]], *, show_banner: bool = True, context: str = "unknown"):
        """Run pre-deployment setup steps.

        Note: this can run both pre-inference and post-plan; `show_banner=False`
        avoids duplicating the big panel in the early phase.
        """
        from armorer.installers.dynamic.setup_sequence import execute_setup_sequence_impl

        execute_setup_sequence_impl(
            self,
            steps,
            show_banner=show_banner,
            context=context,
            console=console,
            step_start=_step_start,
            step_done=_step_done,
            step_fail=_step_fail,
            looks_like_host_disk_full=_looks_like_host_disk_full,
            render_host_disk_full_panel=_render_host_disk_full_panel,
            looks_like_docker_storage_io_error=_looks_like_docker_storage_io_error,
            render_docker_storage_fault_panel=_render_docker_storage_fault_panel,
        )

    def _verify_run_commands(self, found_commands: List[Dict[str, str]]) -> List[Dict[str, str]]:
        """Verify that Armorer-identified commands actually exist in the container.

        If a command fails verification, asks the LLM to discover the correct
        binary name instead of silently dropping it (A6).
        """
        if not found_commands: return []

        verified: List[Dict[str, str]] = []
        for cmd_data in found_commands:
            service = cmd_data.get("service")
            command_str = cmd_data.get("command", "")
            if not command_str: continue
            if cmd_data.get("_armorer_source") == "playbook":
                verified.append(cmd_data)
                continue

            cmd_binary = command_str.split()[0]
            label_l = str(cmd_data.get("label", "")).lower()
            cmd_l = str(command_str).lower()
            is_interactive_setup = any(
                k in label_l or k in cmd_l
                for k in ("onboard", "configure", "setup", "wizard", "init")
            )

            # If service is None, this is a host command (e.g. make)
            if not service:
                import shutil
                if shutil.which(cmd_binary):
                    verified.append(cmd_data)
                else:
                    # Warn but don't crash
                    console.print(f"  [yellow]Note: Host command '{cmd_binary}' not found in PATH[/yellow]")
                continue

            # Interactive setup commands are frequently user-driven and may block on --help.
            # Prefer trusting them without probing to avoid deadlocks in CI/test environments.
            if is_interactive_setup:
                verified.append(cmd_data)
                continue

            # Probe 1: Does the binary exist? (fast failure via `which`)
            try:
                check = self._compose_run(
                    ["run", "--rm", service, "sh", "-lc", f"command -v {cmd_binary}"],
                    capture_output=True,
                    text=True,
                    timeout=20,
                    check=False,
                )
            except subprocess.TimeoutExpired:
                verified.append(cmd_data)
                continue

            # Probe 2: Does it respond to --help?
            try:
                check_help = self._compose_run(
                    ["run", "--rm", service, cmd_binary, "--help"],
                    capture_output=True,
                    text=True,
                    timeout=20,
                    check=False,
                )
            except subprocess.TimeoutExpired:
                verified.append(cmd_data)
                continue

            if (
                getattr(check, "returncode", 1) == 0
                or getattr(check_help, "returncode", 1) == 0
                or "Usage:" in (getattr(check_help, "stdout", None) or "")
            ):
                verified.append(cmd_data)
            else:
                # Log but don't crash; the Stabilizer Node can fix this later.
                console.print(
                    f"  [yellow]⚠ Warning: Command '{cmd_binary}' verification failed in service '{service}'[/yellow]"
                )
                verified.append(cmd_data)

        return verified


    def _discover_correct_binary(self, service: str, cmd_data: Dict[str, str]) -> Optional[Dict[str, str]]:
        """Use the LLM to discover the correct binary when verification fails (A6)."""
        try:
            # List available binaries in the container
            from armorer.docker_runner import compose_run
            runtime_env = self._compose_runtime_env()
            ls_bin = compose_run(
                cwd=self._docker_compose_cwd,
                service=service,
                run_args=["-F", "/usr/local/bin"],
                entrypoint="ls",
                env=runtime_env,
            ).stdout or "(empty)"
            ls_usr = compose_run(
                cwd=self._docker_compose_cwd,
                service=service,
                run_args=["-F", "/usr/bin"],
                entrypoint="ls",
                env=runtime_env,
            ).stdout or "(empty)"

            prompt = (
                f"The command '{cmd_data.get('command')}' (label: '{cmd_data.get('label')}') "
                f"for service '{service}' was NOT found in the container.\n\n"
                f"Available binaries in /usr/local/bin:\n{ls_bin}\n\n"
                f"Available binaries in /usr/bin:\n{ls_usr}\n\n"
                "Find the correct binary and provide the details.\n"
                f"{self._get_phase_instructions('Pre-flight debugger')}"
            )
            self.armorer.init_agent(
                system_prompt=prompt,
                tools=[],
                session_id=self.session_id,
                agent_name=f"armorer:{self.app_name}"
            )
            disc: CommandDiscovery = self.armorer.run(
                user_input="Discover the correct command.",
                structured_output_model=CommandDiscovery
            )
            if disc and not disc.skip and disc.command:
                fix = disc.model_dump()
                fix.setdefault("service", service)
                fix.setdefault("label", cmd_data.get("label", "Discovered"))
                console.print(f"  [cyan]↻  Armorer discovered: {fix['command']}[/cyan]")
                return fix
        except Exception:
            pass
        return None

    def _offer_run_menu(self, commands: List[Dict[str, str]]):
        if not commands: return

        console.print()

        # Categorize commands
        setup_cmds = []
        run_cmds = []
        other_cmds = []
        for c in commands:
            label = c.get("label", "").lower()
            category = c.get("category", "").lower()
            if category == "setup" or any(k in label for k in ["setup", "onboard", "init", "wizard", "migrate"]):
                setup_cmds.append(c)
            elif category == "run" or any(k in label for k in ["start", "run", "serve", "launch"]):
                run_cmds.append(c)
            else:
                other_cmds.append(c)

        def describe(cmd: Dict[str, str]) -> str:
            explicit = str(cmd.get("description", "") or "").strip()
            if explicit:
                return explicit
            label = str(cmd.get("label", "")).lower()
            command = str(cmd.get("command", "")).lower()
            text = f"{label} {command}"
            if "onboard" in text:
                return "First-time onboarding and account/channel initialization."
            if "configure" in text or "setup" in text:
                return "Configure local runtime and provider settings."
            if "status" in text:
                return "Show service status."
            if "doctor" in text or "health" in text:
                return "Run diagnostics and suggest fixes."
            return "Run this command inside the CLI container."

        # Build ordered menu: setup first, then run, then other
        ordered = setup_cmds + run_cmds + other_cmds
        menu_options: list[Any] = []
        value_to_cmd: Dict[str, Dict[str, str]] = {}

        recommended = next((c for c in ordered if "onboard" in str(c.get("label", "")).lower()), None)
        if recommended:
            console.print(f"[dim]Recommended first step: [bold]{recommended.get('label')}[/bold] - {describe(recommended)}[/dim]")

        if setup_cmds:
            menu_options.append(Separator("━━ Setup ━━"))
            for c in setup_cmds:
                label = c.get("label", "Unknown")
                value = f"cmd::{label}"
                title = f"{label}  ·  {describe(c)}"
                menu_options.append({"name": title, "value": value})
                value_to_cmd[value] = c
        if run_cmds:
            menu_options.append(Separator("━━ Run ━━"))
            for c in run_cmds:
                label = c.get("label", "Unknown")
                value = f"cmd::{label}"
                title = f"{label}  ·  {describe(c)}"
                menu_options.append({"name": title, "value": value})
                value_to_cmd[value] = c
        if other_cmds:
            menu_options.append(Separator("━━ Other ━━"))
            for c in other_cmds:
                label = c.get("label", "Unknown")
                value = f"cmd::{label}"
                title = f"{label}  ·  {describe(c)}"
                menu_options.append({"name": title, "value": value})
                value_to_cmd[value] = c
        menu_options.append(Separator("━━ Control ━━"))
        menu_options.append({"name": "Skip  ·  Continue without running additional commands", "value": "skip"})

        while True:
            choice = _ui_select("Choose next action:", choices=menu_options)
            if not choice or choice == "skip":
                break

            # Find the command data
            cmd_data = value_to_cmd.get(str(choice))
            if not cmd_data:
                continue

            service = cmd_data.get("service")
            cmd = cmd_data.get("command")
            compose_cwd = self._compose_dir or self.install_dir

            history = []
            for attempt in range(10):
                compose_prefix = " ".join(self._compose_cmd())
                console.print(f"\n[cyan]▶ Armorer Running: {compose_prefix} run --rm {service} {cmd}[/cyan]")

                # IMPORTANT: We MUST run this with stdin/stdout connected for interactive wizards
                # to work correctly.
                from armorer.docker_runner import compose_run
                res = compose_run(
                    cwd=compose_cwd,
                    service=service,
                    run_args=cmd.split(),
                    no_tty=False,
                    interactive=True,
                    stdin=sys.stdin,
                    stdout=sys.stdout,
                    stderr=sys.stderr,
                    rm=True,
                    env=self._compose_runtime_env(),
                )

                if res.returncode == 0:
                    break

                # Check for user interrupt (Ctrl+C) or SIGKILL
                if res.returncode in (130, 137):
                    console.print("\n[yellow]○ Command interrupted by user.[/yellow]")
                    break

                # E1: Re-run with captured stderr to give the agent actual error text
                console.print(f"  [yellow]⚠  Command failed (Exit Code {res.returncode}). Capturing error output...[/yellow]")

                capture_res = compose_run(
                    cwd=compose_cwd,
                    service=service,
                    run_args=cmd.split(),
                    timeout=30,
                    env=self._compose_runtime_env(),
                )
                captured_error = (
                    (capture_res.stderr or "") + "\n" + (capture_res.stdout or "")
                ).strip() or f"Interactive command failed with exit code {res.returncode}. No captured output."

                console.print(f"  [yellow]⚠  Armorer is diagnosing...[/yellow]")
                fix = self._get_auto_fix_for_command(service, cmd, captured_error, history)

                if fix and fix.get("fix_type") == "command_edit":
                    cmd = fix.get("new_command", cmd)
                    console.print(f"  [cyan]↻  Armorer adjusted the command. Retrying...[/cyan]")
                    history.append({"diagnosis": fix.get("diagnosis"), "attempt": attempt+1})
                    continue
                elif fix and ((fix.get("fix_type") == "tool_action") or (fix.get("fix_type") == "skip" and fix.get("diagnosis", "").startswith("Fixed"))):
                    console.print(f"  [cyan]↻  Armorer applied a fix via tools. Retrying command...[/cyan]")
                    history.append({"diagnosis": fix.get("diagnosis"), "attempt": attempt+1})
                    continue
                elif fix and self._apply_fix(fix):
                    console.print(f"  [cyan]↻  Armorer fixed the environment. Retrying command...[/cyan]")
                    history.append({"diagnosis": fix.get("diagnosis"), "attempt": attempt+1})
                    continue
                else:
                    console.print("[red]❌ Armorer could not resolve this command failure.[/red]")
                    break

            console.print()

    def _get_auto_fix(self, compose_yml: str, error: str, context: str, history: list) -> Optional[dict]:
        """Repair brain for stabilization loop via direct tool action."""
        
        # History text for context
        history_text = ""
        if history:
            history_text = "PREVIOUS ATTEMPTS:\n" + "\n".join([f"- {h.get('diagnosis')}" for h in history])

        prompt = (
            f"The deployment of '{self.app_name}' failed to stabilize.\n\n"
            f"ERROR CONTEXT:\n{error}\n\n"
            f"DOCKER COMPOSE:\n```yaml\n{compose_yml}\n```\n\n"
            "INVESTIGATE and FIX: Use tools (`shell`, `think`) to fix the issues directly.\n"
            "If you change a config file or run a command, do it now via tools.\n"
            f"{history_text}\n"
            f"{self._get_phase_instructions('Expert troubleshooter')}"
        )
        try:
            from armorer.agent import shell
            from armorer.tools.knowledge import get_knowledge_topic
            from armorer.tools.think import think

            tools = [shell, get_knowledge_topic, think]
            if editor: tools.append(editor)

            prev_consent = os.environ.get("BYPASS_TOOL_CONSENT")
            os.environ["BYPASS_TOOL_CONSENT"] = "true"
            try:
                self.armorer.init_agent(
                    system_prompt=prompt,
                    tools=tools,
                    session_id=self.session_id,
                    agent_name=f"armorer:{self.app_name}"
                )
                res = self.armorer.run(
                    user_input="Fix the deployment stability error."
                )
                return {
                    "fix_type": "tool_action", 
                    "diagnosis": str(res)
                }
            finally:
                if prev_consent is None:
                    os.environ.pop("BYPASS_TOOL_CONSENT", None)
                else:
                    os.environ["BYPASS_TOOL_CONSENT"] = prev_consent
        except Exception:
            return None

    def _get_auto_fix_for_command(self, service: str, command: str, error: str, history: list) -> Optional[dict]:
        """Specific brain for fixing failed run-menu commands via direct tool action."""
        
        # History text for context
        history_text = ""
        if history:
            history_text = "PREVIOUS ATTEMPTS:\n" + "\n".join([f"- {h.get('diagnosis')}" for h in history])

        prompt = (
            f"The command '{command}' failed for service '{service}' in app '{self.app_name}'.\n"
            f"ERROR:\n{error}\n\n"
            "INVESTIGATE and FIX: Use tools (`shell`, `editor`) to find and fix the issue directly in the environment.\n"
            "If you change a config file or run a command, do it now via tools.\n"
            f"{history_text}\n"
            f"{self._get_phase_instructions('Expert troubleshooter')}"
        )
        try:
            from armorer.agent import shell
            from armorer.tools.knowledge import get_knowledge_topic
            from armorer.tools.think import think

            tools = [shell, get_knowledge_topic, think]
            if editor: tools.append(editor)

            # Auto-approve tool actions
            prev_consent = os.environ.get("BYPASS_TOOL_CONSENT")
            os.environ["BYPASS_TOOL_CONSENT"] = "true"
            try:
                self.armorer.init_agent(
                    system_prompt=prompt,
                    tools=tools,
                    session_id=self.session_id,
                    agent_name=f"armorer:{self.app_name}"
                )
                res = self.armorer.run(
                    user_input=f"Fix the command failure for '{command}'."
                )
                return {
                    "fix_type": "tool_action", 
                    "diagnosis": str(res)
                }
            finally:
                if prev_consent is None:
                    os.environ.pop("BYPASS_TOOL_CONSENT", None)
                else:
                    os.environ["BYPASS_TOOL_CONSENT"] = prev_consent
        except Exception:
            return None

    def _start_and_stabilize(self, max_attempts: int = 10):
        """
        Autonomous loop to deploy and stabilize the service.
        """
        from armorer.installers.dynamic.compose_runtime import start_and_stabilize_impl

        start_and_stabilize_impl(
            self,
            max_attempts=max_attempts,
            console=console,
            step_done=_step_done,
            step_fail=_step_fail,
            looks_like_host_disk_full=_looks_like_host_disk_full,
            render_host_disk_full_panel=_render_host_disk_full_panel,
            looks_like_docker_storage_io_error=_looks_like_docker_storage_io_error,
            render_docker_storage_fault_panel=_render_docker_storage_fault_panel,
            looks_like_docker_desktop_mount_denied=_looks_like_docker_desktop_mount_denied,
            render_mount_denied_panel=_render_mount_denied_panel,
        )

    def _resolve_openclaw_config_dir(self) -> Path:
        env_path = self._docker_compose_cwd / ".env"
        env_vars = _load_dotenv_kv(env_path)
        cfg = (env_vars.get("OPENCLAW_CONFIG_DIR") or "./.openclaw").strip()
        cfg_path = Path(cfg).expanduser()
        if not cfg_path.is_absolute():
            cfg_path = (self._docker_compose_cwd / cfg_path).resolve()
        return cfg_path

    def _find_cli_service_for_audit(self) -> Optional[str]:
        compose_path = self._docker_compose_cwd / "docker-compose.yml"
        if not compose_path.exists():
            compose_path = self._docker_compose_cwd / "docker-compose.yaml"
        if not compose_path.exists():
            return None
        try:
            data = yaml.safe_load(compose_path.read_text()) or {}
            services = data.get("services", {})
            if not isinstance(services, dict):
                return None
            if "openclaw-cli" in services:
                return "openclaw-cli"
            for svc in services.keys():
                if "cli" in str(svc).lower():
                    return str(svc)
        except Exception:
            return None
        return None

    def _run_openclaw_post_start_security_gate(self) -> None:
        cfg_dir = self._resolve_openclaw_config_dir()
        try:
            cfg_dir.mkdir(parents=True, exist_ok=True)
            os.chmod(cfg_dir, 0o700)
        except Exception:
            pass
        cfg_file = cfg_dir / "openclaw.json"
        if cfg_file.exists():
            try:
                os.chmod(cfg_file, 0o600)
            except Exception:
                pass
        sessions_dir = cfg_dir / "agents" / "main" / "sessions"
        try:
            sessions_dir.mkdir(parents=True, exist_ok=True)
        except Exception:
            pass

        cli_service = self._find_cli_service_for_audit()
        if not cli_service:
            console.print("[yellow]⚠ OpenClaw security gate skipped: CLI service not found.[/yellow]")
            return

        console.print("  [dim]Running OpenClaw deep security audit gate...[/dim]")
        from armorer.docker_runner import compose_run
        audit = compose_run(
            cwd=self._docker_compose_cwd,
            service=cli_service,
            run_args=["security", "audit", "--deep"],
            env=self._compose_runtime_env(),
        )
        audit_out = ((audit.stdout or "") + "\n" + (audit.stderr or "")).strip()
        m = re.search(r"Summary:\s*(\d+)\s*critical", audit_out, flags=re.IGNORECASE)
        critical_count = int(m.group(1)) if m else 0
        if audit.returncode != 0 or critical_count > 0:
            snippet = "\n".join([ln for ln in audit_out.splitlines() if ln.strip()][:12])
            _step_fail("OpenClaw security gate", f"critical findings={critical_count}")
            if snippet:
                console.print(f"[dim]{snippet}[/dim]")
            raise typer.Exit(1)
        _step_done("OpenClaw security gate", "passed")

    def _ensure_local_compose_images(self, runtime_env: Optional[Dict[str, str]] | None = None):
        """Build missing local-tag images (e.g. openclaw:local) when needed."""
        from armorer.installers.dynamic.local_images import ensure_local_compose_images_impl

        ensure_local_compose_images_impl(
            self,
            runtime_env=runtime_env,
            console=console,
            load_dotenv_kv=_load_dotenv_kv,
            resolve_compose_var_default=_resolve_compose_var_default,
        )

    def _collect_compose_profiles(self) -> list[str]:
        """Extract unique compose profile names from the current compose manifest."""
        compose_path = self._docker_compose_cwd / "docker-compose.yml"
        if not compose_path.exists():
            compose_path = self._docker_compose_cwd / "docker-compose.yaml"
        if not compose_path.exists():
            return []
        try:
            data = yaml.safe_load(compose_path.read_text()) or {}
            services = data.get("services", {})
            profiles: list[str] = []
            for svc in services.values():
                if not isinstance(svc, dict):
                    continue
                prof = svc.get("profiles")
                if isinstance(prof, str):
                    profiles.append(prof)
                elif isinstance(prof, list):
                    profiles.extend([p for p in prof if isinstance(p, str)])
            deduped = []
            seen = set()
            for p in profiles:
                if p not in seen:
                    seen.add(p)
                    deduped.append(p)
            return deduped
        except Exception:
            return []

    def _is_profile_only_compose(self) -> bool:
        """True when every service is behind a compose profile (no default 'up' service)."""
        compose_path = self._docker_compose_cwd / "docker-compose.yml"
        if not compose_path.exists():
            compose_path = self._docker_compose_cwd / "docker-compose.yaml"
        if not compose_path.exists():
            return False
        try:
            data = yaml.safe_load(compose_path.read_text()) or {}
            services = data.get("services", {})
            if not isinstance(services, dict) or not services:
                return False
            for svc in services.values():
                if not isinstance(svc, dict):
                    return False
                profiles = svc.get("profiles")
                if not profiles:
                    return False
            return True
        except Exception:
            return False

    def _diagnose_and_repair(self, error_context: str, history: list) -> bool:
        """Agentic debugger. Uses shell, write, KB, grounding search, and log reader."""
        compose_path = self._docker_compose_cwd / "docker-compose.yml"
        if not compose_path.exists():
            compose_path = self._docker_compose_cwd / "docker-compose.yaml"
        compose_content = compose_path.read_text() if compose_path.exists() else ""
        repo_docs = self._gather_repo_docs(max_chars=3000)

        # Get actual filesystem view and system status
        fs_view = self._safe_fs_view(self.install_dir)
        from armorer.docker_runner import docker_ps
        sys_context = docker_ps(args=["-a"]).stdout
        from armorer.docker_runner import compose_config
        config_check = compose_config(
            cwd=self._docker_compose_cwd,
            env=self._compose_runtime_env(),
        ).stderr

        # VISION FIX: Fetch logs for any container that isn't running healthy
        container_logs = ""
        try:
            from armorer.docker_runner import compose_ps
            ps_json = compose_ps(cwd=self._docker_compose_cwd, args=["-a", "--format", "json"]).stdout
            for line in ps_json.splitlines():
                try:
                    c = json.loads(line)
                    if c.get("State") != "running" or c.get("Health") == "unhealthy":
                        name = c.get("Name")
                        from armorer.docker_runner import docker_logs
                        lr = docker_logs(name, tail=80)
                        logs = (lr.stderr or "") + "\n" + (lr.stdout or "")
                        container_logs += f"\n--- LOGS FOR {name} (stderr+stdout) ---\n{logs}\n"
                except: pass
        except: pass

        # Gather file structure context
        fs_view = self._safe_fs_view(self.install_dir)
        if len(fs_view) > 20000:
            fs_view = fs_view[:20000] + "\n... (truncated)"


        full_error = (
            f"ERROR CONTEXT:\n{error_context}\n\n"
            f"DOCKER CONFIG ERROR (stderr):\n{config_check}\n\n"
            f"CRASHED CONTAINER LOGS:\n{container_logs}\n\n"
            f"FILESYSTEM:\n{fs_view}\n\n"
            f"SYSTEM DOCKER STATUS:\n{sys_context}"
        )

        proactive = self._get_proactive_knowledge()
        repair_prompt = f"{proactive}\n{repo_docs}" if proactive else repo_docs

        fix = self._get_auto_fix(compose_content, full_error, repair_prompt, history)

        if fix and fix.get("fix_type") == "tool_action":
            diagnosis = fix.get("diagnosis", "Applied fixes via tools")
            history.append({"diagnosis": diagnosis, "fix_type": "tool_action"})
            console.print(f"  [cyan]↻  Fix applied via tools. Retrying...[/cyan]")
            return True

        if fix and self._apply_fix(fix):
            diagnosis = fix.get("diagnosis", "")
            history.append({"diagnosis": diagnosis, "fix_type": fix.get("fix_type", "")})
            console.print(f"  [cyan]↻  Fix applied: {diagnosis}. Retrying...[/cyan]")

            # A3: Save successful fix pattern to knowledge base
            if hasattr(self, '_kb'):
                self._kb.save_fix(
                    error_pattern=full_error[:200],
                    diagnosis=diagnosis,
                    fix=fix.get("fix_type", "unknown")
                )

            return True

        return False

    def _safe_fs_view(self, root: Path, max_depth: int = 3) -> str:
        """Create a tree-like view of the filesystem, excluding noise."""
        lines = []
        root = Path(root)
        skip_dirs = {".git", "node_modules", "__pycache__", ".venv", "venv", ".idea", ".vscode"}

        for dirpath, dirnames, filenames in os.walk(root):
            # Depth check
            rel_path = Path(dirpath).relative_to(root)
            depth = len(rel_path.parts)
            if depth > max_depth:
                dirnames.clear() # Stop recurring
                continue

            # Exclusion check
            dirnames[:] = [d for d in dirnames if d not in skip_dirs]

            indent = "  " * depth
            lines.append(f"{indent}{Path(dirpath).name}/")
            for f in filenames:
                if not f.startswith("."):
                    lines.append(f"{indent}  {f}")

        return "\n".join(lines)

    # ── Internal helpers ─────────────────────────────────────────────────────

    def _harden_all_compose_files(self):
        # Prefer hardening the active compose manifest(s) in the selected compose cwd.
        # Some upstream repos ship many optional example compose files with intentionally
        # mutable images that are not part of the default deployment path.
        candidates: list[Path] = []
        active_dir = self._docker_compose_cwd
        for name in ("docker-compose.yml", "docker-compose.yaml"):
            p = active_dir / name
            if p.is_file():
                candidates.append(p)

        # Fallback for generic planner installs where compose may be generated elsewhere.
        if not candidates:
            patterns = ["docker-compose*.yml", "docker-compose*.yaml", "compose*.yml", "compose*.yaml"]
            seen: set[Path] = set()
            for pattern in patterns:
                for compose_path in self.install_dir.rglob(pattern):
                    if compose_path.is_file() and compose_path not in seen:
                        seen.add(compose_path)
                        candidates.append(compose_path)

        for compose_path in candidates:
            try:
                original = compose_path.read_text()
                hardened = self._harden_compose_yaml(original)
                if hardened != original:
                    compose_path.write_text(hardened)
            except Exception:
                continue

    def _assert_image_policy(self):
        if not self._image_policy_failures:
            return
        failures = list(self._image_policy_failures)
        pb = self._active_playbook or resolve_playbook(self.app_name)
        allow_patterns = [str(p).strip() for p in (getattr(pb, "allow_mutable_images", []) or []) if str(p).strip()]
        if allow_patterns:
            filtered = []
            for failure in failures:
                if any(pat in failure for pat in allow_patterns):
                    continue
                filtered.append(failure)
            failures = filtered
        if not failures:
            return
        _step_fail("Image policy", "Found insecure/unpinned mutable image references.")
        for failure in failures:
            console.print(f"  [red]- {failure}[/red]")
        console.print("  [dim]Use digest-pinned images (repo@sha256:...) or local :local images.[/dim]")
        raise typer.Exit(code=1)

    def _prepare_files(self, plan: Dict[str, Any]):
        self._image_policy_failures = []
        self.install_dir.mkdir(parents=True, exist_ok=True)
        if not plan.get("use_existing"):
            # Write compose to the correct directory (subdirectory for platforms)
            target_dir = self._compose_dir or self.install_dir
            target_dir.mkdir(parents=True, exist_ok=True)
            hardened = self._harden_compose_yaml(plan["docker-compose"])
            (target_dir / "docker-compose.yml").write_text(hardened)
        # Harden all compose manifests (including existing repos) to enforce security baselines.
        self._harden_all_compose_files()
        self._assert_image_policy()
        # Normalize all compose manifests in repo (including referenced files)
        self._normalize_all_compose_files()
        self._apply_postgres_mount_workarounds()
        self._precreate_volume_dirs()
        self._precreate_volume_dirs_all_compose()

    def _apply_postgres_mount_workarounds(self):
        """
        Replace problematic local bind mounts for postgres data with named volumes.
        This avoids host_mnt permission issues on Docker Desktop while keeping persistence.
        """
        volume_name = "armorer_pg_data"
        patterns = ["docker-compose*.yml", "docker-compose*.yaml", "compose*.yml", "compose*.yaml"]
        compose_files: list[Path] = []
        for pattern in patterns:
            compose_files.extend([p for p in self.install_dir.rglob(pattern) if p.is_file()])

        rewrote_any = False
        for compose_path in compose_files:
            try:
                data = yaml.safe_load(compose_path.read_text()) or {}
                if not isinstance(data, dict):
                    continue
                services = data.get("services") or {}
                changed = False

                for svc in services.values():
                    if not isinstance(svc, dict):
                        continue
                    vols = svc.get("volumes")
                    if not isinstance(vols, list):
                        continue

                    new_vols = []
                    for v in vols:
                        if not isinstance(v, str):
                            new_vols.append(v)
                            continue
                        parts = v.split(":")
                        if len(parts) < 2:
                            new_vols.append(v)
                            continue
                        src = parts[0].strip().replace("\\", "/")
                        dst = parts[1].strip()
                        if src.endswith("/volumes/db/data") and dst == "/var/lib/postgresql/data":
                            suffix = ":" + ":".join(parts[2:]) if len(parts) > 2 else ""
                            new_vols.append(f"{volume_name}:{dst}{suffix}")
                            changed = True
                        else:
                            new_vols.append(v)

                    if changed:
                        svc["volumes"] = new_vols

                if changed:
                    data.setdefault("volumes", {})[volume_name] = None
                    compose_path.write_text(yaml.dump(data, sort_keys=False))
                    rewrote_any = True
            except Exception:
                continue

        # If extends are used from root compose, ensure root also defines the named volume.
        if rewrote_any:
            root_compose_candidates = [
                self.install_dir / "docker-compose.yml",
                self.install_dir / "docker-compose.yaml",
                self._docker_compose_cwd / "docker-compose.yml",
                self._docker_compose_cwd / "docker-compose.yaml",
            ]
            for root_compose in root_compose_candidates:
                if not root_compose.exists() or not root_compose.is_file():
                    continue
                try:
                    data = yaml.safe_load(root_compose.read_text()) or {}
                    if not isinstance(data, dict):
                        continue
                    data.setdefault("volumes", {})[volume_name] = None
                    root_compose.write_text(yaml.dump(data, sort_keys=False))
                except Exception:
                    continue

    def _remove_container_name_conflicts(self):
        """Remove pre-existing containers that collide with fixed container_name entries."""
        compose_dir = self._docker_compose_cwd
        patterns = ["docker-compose*.yml", "docker-compose*.yaml", "compose*.yml", "compose*.yaml"]
        names = set()
        for pat in patterns:
            for p in compose_dir.rglob(pat):
                try:
                    data = yaml.safe_load(p.read_text()) or {}
                    for svc in (data.get("services") or {}).values():
                        if isinstance(svc, dict) and svc.get("container_name"):
                            names.add(str(svc["container_name"]))
                except Exception:
                    continue

        for name in sorted(names):
            try:
                from armorer.docker_runner import docker_ps_filter, docker_rm
                exists = docker_ps_filter(name_filter=f"^/{name}$", format_template="{{.ID}}")
                ids = [x.strip() for x in exists.stdout.splitlines() if x.strip()]
                if ids:
                    docker_rm(ids, force=True)
            except Exception:
                pass

    _MUTABLE_IMAGE_TAGS = {"latest", "edge", "nightly", "dev", "main", "master", "management"}
    _PINNED_IMAGE_MAP = {
        "redis:latest": "redis@sha256:7b6fb55d8b0adcd77269dc52b3cfffe5f59ca5d43dec3c90dbe18aacce7942e1",
        "rabbitmq:management": "rabbitmq@sha256:5deee3f817cb61eef73b2a934c9fe303be347a23db4e45123ec1698f25118e8c",
        "clamav/clamav-debian:latest": "clamav/clamav-debian@sha256:8668886760afe0334ec3f8f154e2aaecb6839799a6703fdd66a6a98faa9c9fc0",
        "busybox": "busybox@sha256:b3255e7dfbcd10cb367af0d409747d511aeb66dfac98cf30e97e87e4207dd76f",
        "busybox:latest": "busybox@sha256:b3255e7dfbcd10cb367af0d409747d511aeb66dfac98cf30e97e87e4207dd76f",
        "minio/minio": "minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e",
        "minio/minio:latest": "minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e",
        "minio/mc": "minio/mc@sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727",
        "minio/mc:latest": "minio/mc@sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727",
    }

    def _is_local_image_ref(self, ref: str) -> bool:
        r = (ref or "").strip().lower()
        return r.endswith(":local") or r.startswith("localhost/")

    def _is_mutable_ref(self, ref: str) -> bool:
        r = (ref or "").strip()
        if not r or "@" in r:
            return False
        tail = r.rsplit("/", 1)[-1]
        if ":" not in tail:
            return True
        tag = tail.rsplit(":", 1)[1].lower()
        return tag in self._MUTABLE_IMAGE_TAGS

    def _lookup_local_repo_digest(self, ref: str) -> Optional[str]:
        key = (ref or "").strip()
        if not key:
            return None
        if key in self._image_digest_cache:
            return self._image_digest_cache[key]
        try:
            from armorer.docker_runner import docker_image_repo_digests
            result = docker_image_repo_digests(key)
            if not result.ok or not result.stdout.strip():
                self._image_digest_cache[key] = None
                return None
            digs = json.loads(result.stdout.strip())
            digest = digs[0] if isinstance(digs, list) and digs else None
            self._image_digest_cache[key] = digest
            return digest
        except Exception:
            self._image_digest_cache[key] = None
            return None

    def _secure_image_ref(self, image_expr: str) -> tuple[str, Optional[str]]:
        raw = (image_expr or "").strip()
        if not raw:
            return raw, None

        # Support compose variable-default syntax (${VAR:-image:tag}).
        m = re.match(r"^\$\{([A-Z_][A-Z0-9_]*):?-([^}]+)\}$", raw)
        if m:
            var_name = m.group(1)
            default_ref = m.group(2).strip()
            secured_default, issue = self._secure_image_ref(default_ref)
            return f"${{{var_name}:-{secured_default}}}", issue

        # If image is an unresolved env variable with no default, reject for strict security.
        if raw.startswith("${") and raw.endswith("}") and ":-" not in raw:
            return raw, f"image '{raw}' unresolved (no default) and cannot be security-pinned"

        if self._is_local_image_ref(raw) or "@" in raw:
            return raw, None

        pinned = self._PINNED_IMAGE_MAP.get(raw)
        if pinned:
            return pinned, None

        local_digest = self._lookup_local_repo_digest(raw)
        if local_digest:
            return local_digest, None

        if self._is_mutable_ref(raw):
            return raw, f"mutable image '{raw}' is not pinned to digest"
        return raw, None

    def _harden_compose_yaml(self, content: str) -> str:
        """
        Enforce secure defaults:
        - read_only filesystem
        - no-new-privileges
        - cap_drop: ALL
        - non-root user
        - remove privileged mode
        - disallow docker.sock mount unless explicit
        - enforce network policy
        """
        try:
            data = yaml.safe_load(content) or {}
            services = data.get("services", {})
            if not isinstance(services, dict):
                return content
            
            def _dedupe_tokens(values: Any, case_insensitive: bool = True) -> list:
                if values is None:
                    return []
                if not isinstance(values, list):
                    values = [values]
                out: list = []
                seen: set[str] = set()
                for raw in values:
                    token = str(raw).strip()
                    if not token:
                        continue
                    key = token.lower() if case_insensitive else token
                    if key in seen:
                        continue
                    seen.add(key)
                    out.append(token)
                return out

            for svc_name, svc in services.items():
                if not isinstance(svc, dict):
                    continue
                # Compose service schema does not support a root-level
                # "capabilities" key; some auto-fixes may inject it.
                bad_caps = svc.pop("capabilities", None)
                if isinstance(bad_caps, dict):
                    drop_caps = bad_caps.get("drop")
                    if isinstance(drop_caps, list):
                        cap_drop = svc.get("cap_drop") or []
                        for cap in drop_caps:
                            if isinstance(cap, str) and cap.upper() not in [str(c).upper() for c in cap_drop]:
                                cap_drop.append(cap)
                        if cap_drop:
                            svc["cap_drop"] = cap_drop
                has_extends = "extends" in svc
                has_runtime_def = bool(svc.get("image") or svc.get("build"))
                is_extends_wrapper = has_extends and not has_runtime_def

                # For compose extends wrappers, harden the concrete base service instead.
                # Injecting security fields here duplicates merged values after `compose config`.
                if is_extends_wrapper:
                    if self.profile.network_policy.name == "NONE":
                        svc["network_mode"] = "none"
                    elif self.profile.network_policy.name == "WHITELIST":
                        svc.setdefault("networks", ["armorer_scoped"])
                    continue

                image_expr = str(svc.get("image", "")).strip()
                if image_expr:
                    secured_image, issue = self._secure_image_ref(image_expr)
                    if secured_image and secured_image != image_expr:
                        svc["image"] = secured_image
                        image_expr = secured_image
                    if issue:
                        self._image_policy_failures.append(f"{svc_name}: {issue}")

                image = image_expr.lower()
                is_stateful = any(x in image for x in ["postgres", "mysql", "mariadb", "redis", "mongo", "rabbitmq", "qdrant", "weaviate"])
                name_l = (svc_name or "").lower()
                infra_sensitive = any(
                    tok in name_l or tok in image
                    for tok in [
                        "db", "postgres", "mysql", "mariadb", "mongo", "redis", "rabbitmq",
                        "supabase", "kong", "auth", "studio", "meta", "storage", "imgproxy", "clamav",
                    ]
                )

                # Apply runtime restrictions broadly, but avoid breaking infra/stateful bootstraps.
                if not infra_sensitive:
                    sec_opt = _dedupe_tokens(svc.get("security_opt"), case_insensitive=True)
                    if "no-new-privileges:true" not in sec_opt:
                        sec_opt.append("no-new-privileges:true")
                    sec_opt = _dedupe_tokens(sec_opt, case_insensitive=True)
                    svc["security_opt"] = sec_opt
                    cap_drop = _dedupe_tokens(svc.get("cap_drop"), case_insensitive=True)
                    if "ALL" not in [str(c).upper() for c in cap_drop]:
                        cap_drop.append("ALL")
                    cap_drop = _dedupe_tokens(cap_drop, case_insensitive=True)
                    svc["cap_drop"] = cap_drop
                svc["privileged"] = False

                has_named_or_bind_volume = bool(svc.get("volumes"))
                if svc.get("read_only") is None:
                    svc["read_only"] = self._should_force_read_only(svc_name, svc, image, is_stateful, has_named_or_bind_volume)

                should_force_non_root = self._should_force_non_root_user(svc_name, svc, image, is_stateful)
                existing_user = str(svc.get("user", "")).strip()
                if should_force_non_root:
                    if "user" not in svc:
                        svc["user"] = "1000:1000"
                else:
                    # Remove previously auto-injected user on update/install retries.
                    if existing_user == "1000:1000":
                        svc.pop("user", None)
                if "tmpfs" not in svc:
                    svc["tmpfs"] = ["/tmp"]

                if "volumes" in svc:
                    vols = svc.get("volumes") or []
                    sanitized_vols = []
                    for vol in vols:
                        if self._is_disallowed_docker_socket_mount(vol):
                            continue
                        if self._is_unsafe_mount(vol):
                            continue
                        sanitized_vols.append(vol)
                    svc["volumes"] = sanitized_vols

                if self.profile.network_policy.name == "NONE":
                    svc["network_mode"] = "none"
                elif self.profile.network_policy.name == "WHITELIST":
                    # Scoped policy: keep compose networking but disable direct raw network tools via PolicyGuard.
                    svc.setdefault("networks", ["armorer_scoped"])

            if self.profile.network_policy.name == "WHITELIST":
                networks = data.get("networks") or {}
                networks.setdefault("armorer_scoped", {})
                data["networks"] = networks

            return yaml.dump(data, sort_keys=False)
        except Exception:
            return content

    def _should_force_read_only(
        self,
        svc_name: str,
        svc: Dict[str, Any],
        image: str,
        is_stateful: bool,
        has_named_or_bind_volume: bool,
    ) -> bool:
        # Keep strict defaults, but avoid breaking services that generate configs/runtime files.
        if is_stateful:
            return False

        name = (svc_name or "").lower()
        write_sensitive = ("kong", "clamav", "supabase", "rabbitmq", "postgres", "redis")
        if any(token in name for token in write_sensitive) or any(token in image for token in write_sensitive):
            return False
        if any(token in name for token in ("migrate", "init", "setup", "seed", "job")):
            return False

        entry_cmd = " ".join(str(x) for x in (svc.get("entrypoint") or [])) if isinstance(svc.get("entrypoint"), list) else str(svc.get("entrypoint") or "")
        run_cmd = " ".join(str(x) for x in (svc.get("command") or [])) if isinstance(svc.get("command"), list) else str(svc.get("command") or "")
        full_cmd = f"{entry_cmd} {run_cmd}".lower()
        if any(tok in full_cmd for tok in [">", "tee ", "cat ", "sed -i", "chmod ", "chown ", "mkdir "]):
            return False

        # If a service mounts writable paths, default to writable rootfs for compatibility.
        if has_named_or_bind_volume:
            return False

        return True

    def _should_force_non_root_user(
        self,
        svc_name: str,
        svc: Dict[str, Any],
        image: str,
        is_stateful: bool,
    ) -> bool:
        # Stateful services and common infra stacks often need startup chown/chmod.
        if is_stateful:
            return False
        name = (svc_name or "").lower()
        infra_sensitive = (
            "db", "postgres", "mysql", "mariadb", "mongo", "redis", "rabbitmq",
            "supabase", "kong", "auth", "studio", "meta", "storage", "imgproxy", "clamav",
        )
        if any(token in name for token in infra_sensitive):
            return False
        if any(token in name for token in ("migrate", "init", "setup", "seed", "job")):
            return False
        if any(token in image for token in infra_sensitive):
            return False

        # Bind-mounted source trees frequently require host UID/GID semantics.
        volumes = svc.get("volumes") or []
        for vol in volumes:
            if isinstance(vol, dict) and str(vol.get("type", "")).lower() == "bind":
                return False
            if isinstance(vol, str):
                lhs = vol.split(":", 1)[0].strip()
                if lhs.startswith("/") or lhs.startswith("./") or lhs.startswith("../") or lhs.startswith("~"):
                    return False

        entry_cmd = " ".join(str(x) for x in (svc.get("entrypoint") or [])) if isinstance(svc.get("entrypoint"), list) else str(svc.get("entrypoint") or "")
        run_cmd = " ".join(str(x) for x in (svc.get("command") or [])) if isinstance(svc.get("command"), list) else str(svc.get("command") or "")
        full_cmd = f"{entry_cmd} {run_cmd}".lower()
        if any(tok in full_cmd for tok in ["chmod ", "chown ", "initdb", "postgres ", "mysqld", "rabbitmq-server"]):
            return False
        return True

    def _is_disallowed_docker_socket_mount(self, volume: Any) -> bool:
        v = str(volume).lower()
        return "/var/run/docker.sock" in v and not self._allow_docker_socket

    def _is_unsafe_mount(self, volume: Any) -> bool:
        """Check only host source path; do not inspect container target paths."""
        source = ""
        if isinstance(volume, str):
            parts = volume.split(":")
            source = (parts[0] or "").strip() if len(parts) >= 2 else ""
        elif isinstance(volume, dict):
            if str(volume.get("type", "volume")) != "bind":
                return False
            source = str(volume.get("source", "")).strip()
        else:
            return False

        if not source:
            return False

        low = source.lower()
        if low.startswith("/:") or low == "/":
            return True
        if low in {"/root", "/etc", "/var/lib", "/var/lib/docker"}:
            return True
        if low.startswith("~/") or low.startswith("$home") or low.startswith("${home"):
            return True
        return False

    def _precreate_volume_dirs(self):
        """Ensure host directories for volumes exist and have correct permissions."""
        compose_path = self._docker_compose_cwd / "docker-compose.yml"
        if not compose_path.exists():
            compose_path = self._docker_compose_cwd / "docker-compose.yaml"
        if not compose_path.exists():
            return
        try:
            compose_data = yaml.safe_load(compose_path.read_text())
            for svc in (compose_data or {}).get("services", {}).values():
                for vol in svc.get("volumes", []):
                    host_part = None
                    if isinstance(vol, str):
                        parts = vol.split(":")
                        if len(parts) >= 2:
                            host_part = parts[0].strip()
                    elif isinstance(vol, dict):
                        host_part = vol.get("source")

                    if host_part:
                        # Resolve variables if any
                        resolved = re.sub(r'\$\{[^:}]+:-([^}]+)\}', r'\1', host_part)
                        if resolved.startswith("./") or resolved.startswith("../"):
                            dir_path = (self._docker_compose_cwd / resolved).resolve()
                            if not dir_path.exists():
                                dir_path.mkdir(parents=True, exist_ok=True)
                            # Ensure writable for Docker Desktop mount bridge.
                            try:
                                os.chmod(dir_path, 0o777)
                            except Exception:
                                pass
        except Exception:
            pass

    def _precreate_volume_dirs_all_compose(self):
        """Precreate bind-mount host directories for every compose file in repo."""
        patterns = ["docker-compose*.yml", "docker-compose*.yaml", "compose*.yml", "compose*.yaml"]
        for pattern in patterns:
            for compose_path in self.install_dir.rglob(pattern):
                if not compose_path.is_file():
                    continue
                try:
                    compose_data = yaml.safe_load(compose_path.read_text()) or {}
                    for svc in (compose_data.get("services") or {}).values():
                        for vol in svc.get("volumes", []) if isinstance(svc, dict) else []:
                            host_part = None
                            if isinstance(vol, str):
                                parts = vol.split(":")
                                if len(parts) >= 2:
                                    host_part = parts[0].strip()
                            elif isinstance(vol, dict):
                                host_part = vol.get("source")
                                typ = vol.get("type", "volume")
                                if typ != "bind":
                                    host_part = None

                            if host_part and host_part not in [".", ".."] and not host_part.startswith("/var/run/docker.sock"):
                                resolved = re.sub(r'\$\{[^:}]+:-([^}]+)\}', r'\1', host_part)
                                if resolved.startswith("./") or resolved.startswith("../"):
                                    dir_path = (compose_path.parent / resolved).resolve()
                                    dir_path.mkdir(parents=True, exist_ok=True)
                                    try:
                                        os.chmod(dir_path, 0o777)
                                    except Exception:
                                        pass
                                    try:
                                        for parent in [dir_path.parent, dir_path.parent.parent]:
                                            if parent.exists():
                                                os.chmod(parent, 0o777)
                                    except Exception:
                                        pass
                except Exception:
                    continue

    def _gather_repo_docs(self, max_chars: int = 1500) -> str:
        """Read README and AGENTS.md from the cloned repo (highly pruned)."""
        docs_parts: list[str] = []
        budget = max_chars

        # Drastically reduced list to prevent context explosion
        doc_files = [
            ("AGENTS.md", 800),
            ("README.md", 600),
            ("docker-compose.yml", 500), # Just the structure
        ]

        # Scan from repository root
        for filename, file_budget in doc_files:
            if budget <= 0:
                break
            filepath = self.install_dir / filename
            if filepath.exists() and filepath.is_file():
                try:
                    content = filepath.read_text(errors="replace")
                    # Take only the first N chars
                    trimmed = content[: min(file_budget, budget)]
                    docs_parts.append(f"=== {filename} (first {len(trimmed)} chars) ===\n{trimmed}")
                    budget -= len(trimmed)
                except Exception:
                    pass

        return "\n\n".join(docs_parts) if docs_parts else ""

    # ── Armorer calls ────────────────────────────────────────────────────────────

    ARMORER_SYSTEM_PROMPT = (
        "You are the Armorer, an autonomous DevOps agent specializing in Docker deployments.\n"
        "Your goal is to successfully install and configure applications using Docker Compose.\n\n"
        "## UNIVERSAL RULES\n"
        "1. IMAGE VALIDATION: Verify images exist with `docker search` before using them.\n"
        "2. NO EMPTY PROPOSALS: Never propose empty config files.\n"
        "3. ACCURACY: Follow best practices and documentation.\n"
        "4. CONTEXT EFFICIENCY: If tool output is truncated or very large, IMMEDIATELY summarize the key findings in your next step.\n"
        "Output ONLY what is asked — no prose, no explanations."
    )

    def _get_phase_instructions(
        self,
        role: str,
    ) -> str:
        """Build phase-specific instructions to append to user prompt."""

        # Always inject core Docker knowledge directly into the prompt
        from armorer.knowledge import KnowledgeBase
        docker_knowledge = self._kb.get("docker")

        # Check if we have specific knowledge for this app
        from armorer.tools.knowledge import list_knowledge_topics
        available_topics = list_knowledge_topics()

        # Simple fuzzy match (e.g. "openclaw" matches "openclaw")
        relevant_topic = next((t for t in available_topics if t in self.app_name.lower()), None)

        knowledge_hint = ""
        if relevant_topic:
            knowledge_hint = (
                f"\n\n🚨 IMPORTANT: A dedicated Knowledge Base topic '{relevant_topic}' exists. "
                f"You MUST use the `get_knowledge_topic` tool with the argument '{relevant_topic}' "
                "to read it BEFORE generating any plans. It contains critical security and deployment rules."
            )

        return (
            f"ROLE: {role}\n\n"
            f"## DOCKER BEST PRACTICES (ALWAYS FOLLOW)\n{docker_knowledge}\n\n"
            "KNOWLEDGE ON-DEMAND: You have access to a Knowledge Base via tools. "
            "If you need information on Docker best practices, security, or specific platforms, "
            f"use `search_knowledge` or `get_knowledge_topic` to retrieve relevant guidance.{knowledge_hint}\n"
        )

    def _research(self) -> str:
        """Agentically explore installation options using web and docker search."""

        # Create a specific tool for the agent to use during research
        @tool
        def web_search(query: str) -> str:
            """Search the web for installation guides, docker-compose examples, or documentation."""
            if self.search_tool:
                return self.search_tool(query)

        # We pass the shell tool so it can run 'docker search'
        from armorer.agent import shell

        prompt = (
            f"You are a DevOps Researcher. Your goal is to find the BEST way to install '{self.app_name}' using Docker.\n\n"
            "INSTRUCTIONS:\n"
            "1. EXPLORE OPTIONS: Don't just pick the first result. Look for official images, community images, and build-from-source options.\n"
            "2. VERIFY IMAGES: Use the `shell` tool to run `docker search <image_name>` to see what images actually exist on Docker Hub. If an image is mentioned in a guide, verify its popularity (stars).\n"
            "3. CHECK FOR DOCKERFILES: If you are installing from a Git repo, assume you might need to build locally if no good public image exists. USE `shell` to list files (`ls -F`) to check for Dockerfiles.\n"
            "4. COMPARE: Summarize the pros/cons of at least 2-3 different approaches (e.g. 'Official Image' vs 'Build from Source').\n"
            "5. RECOMMEND: Conclude with a clear recommendation on which approach to use for the deployment plan.\n\n"
            "Start by listing the files in the repo to see if a Dockerfile exists, then search for the app on Docker Hub."
        )

        from armorer.agent import shell
        self.armorer.init_agent(
            system_prompt=prompt + "\n\n" + self._get_phase_instructions("DevOps researcher"),
            tools=[web_search, shell],
            session_id=self.session_id,
            agent_name=f"armorer:{self.app_name}"
        )

        return self.armorer.run(
            user_input="Research the application and its dependencies.",
            silent=True
        )

    def _plan(self, research_summary: str, context: str = "search") -> Dict[str, Any]:
        repo_docs = self._gather_repo_docs() if self.install_dir.exists() else ""
        proactive = self._get_proactive_knowledge()
        prompt = (
            f"Generate a secure deployment plan for '{self.app_name}'.\n"
            f"Research: {research_summary}\n\n"
        )
        if proactive:
            prompt += f"PROACTIVE KNOWLEDGE:\n{proactive}\n\n"
        
        if repo_docs:
            prompt += (
                "App documentation (READ CAREFULLY for entrypoints, CLI flags, "
                "setup steps, env vars):\n"
                f"{repo_docs}\n\n"
            )
        prompt += (
            "IMPORTANT RULES:\n"
            "- IMAGE VALIDATION: Before deciding on an image, use the `shell` tool to run `docker search <image_name>`.\n"
            "- ENTRYPOINTS: Identify all useful CLI commands (setup, onboard, migrate, etc) and which service they belong to.\n"
            "- MINIMAL INTERFERENCE: If using a public image, TRUST its default `entrypoint` and `command`.\n"
            "- NO EMPTY OUTPUT: You must provide a full, valid YAML file.\n"
        )
        from armorer.agent import shell

        last_error = None
        for _ in range(10):
            try:
                self.armorer.init_agent(
                    system_prompt=prompt + "\n\n" + self._get_phase_instructions("System Architect"),
                    tools=[shell],
                    session_id=self.session_id,
                    agent_name=f"armorer:{self.app_name}"
                )
                plan: DeploymentPlan = self.armorer.run(
                    user_input="Generate the deployment plan.",
                    silent=True,
                    structured_output_model=DeploymentPlan
                )

                if not plan or not plan.docker_compose:
                    raise ValueError("Armorer failed to generate a valid deployment plan")

                # Store plan in dict format for internal use
                return {
                    "docker-compose": plan.docker_compose,
                    "intent_checklist": plan.intent_checklist
                }
            except Exception as e:
                last_error = e
                console.print(f"  [dim]Planning attempt failed: {str(e)}[/dim]")
                self.armorer.clear_history() # Break the loop
                time.sleep(1)

        raise ValueError(f"Armorer failed to generate a valid plan: {last_error}")


    def _refine_existing_plan(
        self, content: str, compose_dir: Optional[Path] = None,
    ) -> Dict[str, Any]:
        repo_docs = self._gather_repo_docs()
        proactive = self._get_proactive_knowledge()

        # Gather extra docs from the compose subdirectory if different from root
        compose_dir_docs = ""
        if compose_dir and compose_dir != self.install_dir:
            extra_files = ["README.md", "Makefile", ".env.default", "backend/.env.default",
                           "frontend/.env.default", "CLAUDE.md"]
            for fname in extra_files:
                fpath = compose_dir / fname
                if fpath.exists() and fpath.is_file():
                    try:
                        compose_dir_docs += f"\n=== {fname} (in {compose_dir.name}/) ===\n"
                        compose_dir_docs += fpath.read_text(errors="replace")[:1500]
                    except Exception:
                        pass

        # Count services to detect platform-type repos
        try:
            parsed = yaml.safe_load(content)
            svc_count = len((parsed or {}).get("services", {}))
        except Exception:
            svc_count = 0

        is_platform = svc_count >= 5

        prompt = (
            f"Review this deployment for '{self.app_name}'.\n"
            f"Current docker-compose.yml:\n```yaml\n{content}\n```\n\n"
        )
        if proactive:
            prompt += f"PROACTIVE KNOWLEDGE:\n{proactive}\n\n"
        
        if repo_docs:
            prompt += (
                "Root documentation:\n"
                f"{repo_docs}\n\n"
            )
        if compose_dir_docs:
            prompt += (
                "Platform subdirectory documentation:\n"
                f"{compose_dir_docs}\n\n"
            )

        if is_platform:
            prompt += (
                "IMPORTANT: This is a PLATFORM with many services. "
                "Do NOT rewrite or simplify the docker-compose.yml. "
                "Return it EXACTLY as-is (or with minimal fixes). "
                "Focus on identifying the correct run_commands from the docs.\n\n"
            )

        prompt += (
            "INSTRUCTIONS:\n"
            "- Fix any issues in the YAML (syntax only, do NOT remove services).\n"
            "- Identify all useful CLI commands (setup, onboard, migrate, etc) from the docs.\n"
            "- For each run_command, include a 'description' field explaining what it does.\n"
        )

        last_error = None
        for _ in range(10):
            try:
                self.armorer.init_agent(
                    system_prompt=prompt + "\n\n" + self._get_phase_instructions("System Architect"),
                    tools=[],
                    session_id=self.session_id,
                    agent_name=f"armorer:{self.app_name}"
                )
                plan: DeploymentPlan = self.armorer.run(
                    user_input="Refine the deployment plan.",
                    silent=True,
                    structured_output_model=DeploymentPlan
                )

                if not plan or not plan.docker_compose:
                    raise ValueError("Armorer failed to refine the deployment plan")

                # Convert Pydantic model to dict for internal use
                return {
                    "docker-compose": plan.docker_compose,
                    "intent_checklist": plan.intent_checklist,
                    "use_existing": is_platform
                }
            except Exception as e:
                last_error = e
                console.print(f"  [dim]Refining attempt failed: {str(e)}[/dim]")
                self.armorer.clear_history() # Break the loop
                time.sleep(1)

        raise ValueError(f"Armorer failed to refine the plan: {last_error}")


    def _confirm_execution(self, plan: Dict[str, Any]) -> bool:
        if not self.options.get("confirm", True):
            return True
        console.print()
        console.print(Rule("[bold]Proposed docker-compose.yml[/bold]", style="dim"))
        try:
            syntax = Syntax(
                plan["docker-compose"],
                "yaml",
                theme="monokai",
                line_numbers=True,
                padding=1,
            )
            console.print(syntax)
        except Exception:
            console.print(plan["docker-compose"])
        console.print()
        return Confirm.ask(
            f"Proceed with installation of [bold]{self.app_name}[/bold]?"
        )

    # ── Post-start Health Check & Auto-fix Loop ──────────────────────────────

    def _verify_and_fix(self, max_attempts: int = 10):
        """
        Aggressively monitor and fix container health.
        - Polls for stability (up to 30s)
        - If crash: Grabs logs, asks Armorer for fix, applies fix, retries.
        - Maintains history of failed attempts to avoid loops.
        """
        history = []

        for attempt in range(1, max_attempts + 1):
            label = "Health Check"

            # 1. Stabilization Loop (Wait for up to 30s for containers to settle)
            #    We check every 2s. If they crash early, we catch it fast.
            stable = False
            start_time = time.time()

            with console.status(f"[bold cyan]  ⠿ {label} — Monitoring stability...[/bold cyan]") as status:
                while time.time() - start_time < 30:
                    containers = self._get_containers()

                    if not containers:
                        # If no containers and 'up' failed, don't wait 30s
                        # We use 'docker compose config' as a quick health check for the YML itself
                        from armorer.docker_runner import compose_config
                        config_check = compose_config(
                            cwd=self._docker_compose_cwd,
                            env=self._compose_runtime_env(),
                        )
                        if config_check.returncode != 0:
                            # Pre-emptive strike: Try normalization if we see the env_file error
                            if "expected type 'string', got unconvertible type 'map" in config_check.stderr:
                                status.update("[bold yellow]  ! Normalizing legacy env_file syntax...[/bold yellow]")
                                compose_path = self._docker_compose_cwd / "docker-compose.yml"
                                if not compose_path.exists():
                                    compose_path = self._docker_compose_cwd / "docker-compose.yaml"

                                if compose_path.exists():
                                    orig = compose_path.read_text()
                                    fixed = self._normalize_compose_yaml(orig)
                                    self._ensure_env_files_exist(fixed)
                                    if fixed != orig:
                                        compose_path.write_text(fixed)
                                        status.update("[bold cyan]  ↻ Retrying with normalized config...[/bold cyan]")
                                        from armorer.docker_runner import compose_up
                                        compose_up(cwd=self._docker_compose_cwd)
                                        continue

                            status.update("[bold red]  ! Configuration error detected![/bold red]")
                            break

                        time.sleep(2)
                        continue

                    # Check for explicit failures (ExitCode != 0)
                    failed = [c for c in containers if c.get("ExitCode", 0) != 0]
                    if failed:
                        status.update("[bold red]  ! Crash detected![/bold red]")
                        time.sleep(1) # Let logs flush
                        break

                    # Check if all critical services are running
                    # Ignore CLI/setup containers that are expected to exit cleanly
                    critical = [
                        c for c in containers
                        if not (c.get("ExitCode") == 0 and "cli" in (c.get("Service","").lower()))
                    ]

                    if all("running" in (c.get("State", "") or "").lower() for c in critical):
                        # Looks good so far...
                        pass
                    else:
                        # Still starting or restarting...
                        pass

                    time.sleep(2)
                else:
                    # If we exited the loop without breaking, it means we survived 30s
                    stable = True

            if stable:
                # Final verify
                containers = self._get_containers()
                failed = [c for c in containers if c.get("ExitCode", 0) != 0]
                if not failed:
                    _step_done("Health check", "System stable")
                    return

            # 2. Diagnosis & Repair
            console.print(f"  [yellow]⚠  {label} — Unstable. Gathering diagnostics...[/yellow]")

            # Fetch deeper logs (100 lines)
            from armorer.docker_runner import compose_logs
            logs = compose_logs(cwd=self._docker_compose_cwd, tail=100)
            error_logs = (logs.stdout or "") + (logs.stderr or "")

            compose_content = ""
            compose_path = self._docker_compose_cwd / "docker-compose.yml"
            if not compose_path.exists():
                compose_path = self._docker_compose_cwd / "docker-compose.yaml"
            if compose_path.exists(): compose_content = compose_path.read_text()

            repo_docs = self._gather_repo_docs(max_chars=3000)
            proactive = self._get_proactive_knowledge()
            repair_prompt = f"{proactive}\n{repo_docs}" if proactive else repo_docs

            # Get Agentic Fix
            fix = self._get_auto_fix(compose_content, error_logs, repair_prompt, history)

            if not fix:
                _step_fail(label, "Auto-fixer ran out of ideas.")
                break # Give up if Armorer gives up

            # Apply
            applied = self._apply_fix(fix)
            if not applied:
                _step_fail(label, "Fix application failed.")
                continue # Retry loop might trigger different logic?

            # POST-FIX NORMALIZATION (Ensure the agent didn't re-introduce legacy syntax)
            compose_path = self._docker_compose_cwd / "docker-compose.yml"
            if not compose_path.exists(): compose_path = self._docker_compose_cwd / "docker-compose.yaml"
            if compose_path.exists():
                orig = compose_path.read_text()
                fixed = self._normalize_compose_yaml(orig)
                self._ensure_env_files_exist(fixed)
                if fixed != orig:
                    compose_path.write_text(fixed)
                
                # RECURSIVE NORMALIZATION FALLBACK
                self._normalize_all_compose_files()

            # Record history
            history.append({
                "diagnosis": fix.get("diagnosis"),
                "attempt": attempt
            })

            console.print("  [cyan]↻  Restarting stack with new configuration...[/cyan]")
            from armorer.docker_runner import compose_down, compose_up
            compose_down(cwd=self._docker_compose_cwd)
            up_res = compose_up(cwd=self._docker_compose_cwd)
            
            if up_res.returncode != 0:
                console.print(f"  [yellow]!  Restart failed immediately: {up_res.stderr.strip().splitlines()[0]}[/yellow]")
            # Loop continues to next attempt...

        # If we fall through here, we failed all attempts
        _step_fail("Installation failed", f"Could not stabilize after {max_attempts} attempts.")
        console.print(Panel(
             "Armorer exhausted all retry attempts. \n"
             "You may need to manually inspect 'docker logs' or edit docker-compose.yml.",
             title="❌ Auto-Recovery Failed", border_style="red"
        ))

    def _get_containers(self) -> list:
        try:
            from armorer.docker_runner import compose_ps
            result = compose_ps(cwd=self._docker_compose_cwd, args=["--format", "json"])
            containers = []
            for line in (result.stdout or "").splitlines():
                if line.strip():
                    try: 
                        data = json.loads(line)
                        if isinstance(data, list):
                            containers.extend(data)
                        else:
                            containers.append(data)
                    except: pass
            return containers
        except: return []

    def _get_auto_fix(
        self, compose_yml: str, error_logs: str, repo_docs: str, history: list
    ) -> Optional[dict]:
        
        # Format history for Armorer (explicit memory)
        history_text = ""
        if history:
            history_text = "PREVIOUS ATTEMPTS (DO NOT REPEAT THE SAME FIX):\n"
            for i, h in enumerate(history, 1):
                ftype = h.get('fix_type', 'unknown')
                diag = h.get('diagnosis', '?')
                history_text += f"  Attempt {i} [{ftype}]: {diag}\n"

        # Gather file structure context
        file_structure = self._safe_fs_view(self.install_dir)
        if len(file_structure) > 20000:
            file_structure = file_structure[:20000] + "\n... (truncated)"

        # E4: Store full logs and provide head+tail summary + read_logs tool
        from armorer.tools.log_reader import store_log, get_log_summary, read_logs, clear_log
        log_session_id = store_log(error_logs)
        log_summary = get_log_summary(log_session_id, head=40, tail=40)

        prompt = (
            f"Deployment of '{self.app_name}' has DEFINITELY FAILED.\n"
            "Even if you think the logs look clean, the system reported a non-zero exit code. \n\n"
            f"--- docker-compose.yml ---\n```yaml\n{compose_yml}\n```\n\n"
            f"--- Actual File Structure ---\n```\n{file_structure}\n```\n\n"
            f"--- LOGS SUMMARY (session: {log_session_id}) ---\n```\n{log_summary}\n```\n"
            f"NOTE: Use the `read_logs` tool with session_id='{log_session_id}' to navigate the full log "
            f"if the summary above is not enough. You can read any batch of lines.\n\n"
            f"{history_text}\n\n"
        )
        if repo_docs:
            prompt += f"--- Docs Context ---\n{repo_docs[:2000]}\n\n"
            
        prompt += (
            "WORKFLOW (follow in order):\n"
            "STEP 1 — INVESTIGATE: Before proposing any fix, you MUST use your tools to gather information.\n"
            "  - RECURSIVE ANALYSIS: Use the `think` tool to analyze complex Docker Compose dependency trees (`extends`, `networks`, `volumes`).\n"
            "    Simulate the Docker merger logic using `think` to identify hidden configuration conflicts.\n"
            "  - Use `shell` to run `docker logs --tail 100 <container_name>` to see the actual error.\n"
            "  - Use `read_logs` to navigate the log summary if it's incomplete.\n"
            "  - Use `shell` to run `docker inspect <container_name>` if you need config details.\n"
            "  - If unfamiliar with the error, use `google_search` to research it.\n"
            "  DO NOT skip this step. DO NOT return 'skip' without investigating first.\n\n"
            "STEP 2 — DIAGNOSE: Based on what you found:\n"
            "  - QUOTE THE ERROR: Copy the exact error message from the logs.\n"
            "  - If 'invalid spec' or 'empty section between colons': replace ${VAR} with literal paths like './data'.\n"
            "  - If YAML parse error: fix the YAML (never use 'run_command' for parse errors).\n"
            "  - If a file is missing: use `file_write` to create it.\n"
            "  - PERSISTENCE: Do NOT suggest the same fix as a previous attempt.\n\n"
            "STEP 3 — FIX: After investigating, provide your diagnosis and fix details.\n"
            'IMPORTANT: If you used the `editor` tool to fix the issue directly, set fix_type="tool_action". '
            'Only use "skip" if you investigated with tools and confirmed NO FIX is needed.\n'
        )
        try:
            from armorer.agent import shell, file_write
            from armorer.tools.knowledge import search_knowledge, get_knowledge_topic
            from armorer.tools.search import google_search, is_available as search_available
            from armorer.tools.think import think
            
            repair_tools = []
            repair_tools.append(shell)
            repair_tools.append(file_write)
            
            # Add Knowledge Base search
            repair_tools.append(search_knowledge)
            repair_tools.append(get_knowledge_topic)
            repair_tools.append(read_logs)
            repair_tools.append(think)
            
            # Add Google Search if available
            if search_available():
                repair_tools.append(google_search)
            
            if editor: repair_tools.append(editor)
            
            # Auto-approve tool actions in autonomous repair loop
            prev_consent = os.environ.get("BYPASS_TOOL_CONSENT")
            os.environ["BYPASS_TOOL_CONSENT"] = "true"
            try:
                self.armorer.init_agent(
                    system_prompt=prompt + "\n\n" + self._get_phase_instructions("Expert debugger"),
                    tools=repair_tools,
                    session_id=self.session_id,
                    agent_name=f"armorer:{self.app_name}"
                )
                res = self.armorer.run(
                    user_input="Find a fix for the crash logs."
                )
                
                # We return a dummy fix object to keep compatibility with the loop
                # but the real work was done by the agent using tools.
                return {
                    "fix_type": "tool_action",
                    "diagnosis": str(res)
                }
            finally:
                if prev_consent is None:
                    os.environ.pop("BYPASS_TOOL_CONSENT", None)
                else:
                    os.environ["BYPASS_TOOL_CONSENT"] = prev_consent
        except Exception as e:
            # E6: Log full stack trace instead of just the message
            import traceback
            tb = traceback.format_exc()
            console.print(f"[red]❌ Error in auto-fix generation: {e}[/red]")
            log_path = Path.home() / ".armorer" / "diagnostics.log"
            log_path.parent.mkdir(parents=True, exist_ok=True)
            with open(log_path, "a") as f:
                f.write(f"\n--- EXCEPTION {time.ctime()} ---\n{tb}\n")
        finally:
            # Clean up the log store
            clear_log(log_session_id)
        return None


    def _apply_fix(self, fix: dict) -> bool:
        fix_type = fix.get("fix_type", "")

        if fix_type == "compose_edit":
            new_yml = fix.get("compose_yml", "")
            if not new_yml or not new_yml.strip():
                console.print("[red]❌ Armorer proposed an empty docker-compose.yml. Ignoring fix.[/red]")
                return False
            clean = _extract_yaml(new_yml) if "```" in new_yml else new_yml
            if not clean or not clean.strip():
                console.print("[red]❌ Failed to extract valid YAML from proposal. Ignoring fix.[/red]")
                return False
            self._image_policy_failures = []
            clean = self._harden_compose_yaml(clean)
            if self._image_policy_failures:
                console.print("[red]❌ Proposed fix rejected by image security policy.[/red]")
                for failure in self._image_policy_failures:
                    console.print(f"  [red]- {failure}[/red]")
                return False
            
            compose_path = self._docker_compose_cwd / "docker-compose.yml"
            if not compose_path.exists():
                compose_path = self._docker_compose_cwd / "docker-compose.yaml"
            compose_path.write_text(clean)
            self._precreate_volume_dirs()
            console.print("  [green]✎  Updated docker-compose.yml[/green]")
            return True

        if fix_type == "run_command":
            service = fix.get("service", "")
            commands = fix.get("commands", [])
            if not service or not commands:
                return False

            compose_path = self._docker_compose_cwd / "docker-compose.yml"
            if not compose_path.exists():
                compose_path = self._docker_compose_cwd / "docker-compose.yaml"
            if compose_path.exists():
                try:
                    data = yaml.safe_load(compose_path.read_text())
                    services = data.get("services", {})
                    if service not in services:
                        for svc_name in services:
                            if "cli" in svc_name.lower():
                                service = svc_name
                                break
                        else:
                            if services:
                                service = list(services.keys())[0]
                except Exception:
                    pass

            for cmd_str in commands:
                console.print(
                    f"  [cyan]▶  Running:[/cyan] {cmd_str} (via {service})"
                )
                from armorer.docker_runner import compose_run
                compose_run(
                    cwd=self._docker_compose_cwd,
                    service=service,
                    run_args=["sh", "-c", cmd_str],
                    env=self._compose_runtime_env(),
                )
            return True
        if fix_type == "tool_action":
            # The agent already applied the fix using tools (e.g. editor)
            console.print("  [cyan]✓ Fix applied directly by agent tools.[/cyan]")
            return True

        if fix_type == "skip":
            # Skip is now handled in _diagnose_and_repair with loop-breaking logic
            return False

        return False

    # ── Environment Configuration ────────────────────────────────────────────

    @staticmethod
    def _merge_env_lines(existing_lines: List[str], updates: Dict[str, str]) -> List[str]:
        merged: List[str] = []
        seen: set[str] = set()

        for raw in existing_lines:
            line = raw.rstrip("\n")
            stripped = line.strip()
            if not stripped or stripped.startswith("#") or "=" not in line:
                merged.append(line)
                continue

            key, _sep, _value = line.partition("=")
            env_key = key.strip()
            if env_key in updates:
                merged.append(f"{env_key}={updates[env_key]}")
                seen.add(env_key)
            else:
                merged.append(line)

        for env_key in sorted(updates):
            if env_key not in seen:
                merged.append(f"{env_key}={updates[env_key]}")

        return merged

    def _maybe_configure_env(self):
        compose_dir = self._host_visible_path(self._compose_dir or self.install_dir)
        self._ensure_path_env_defaults(compose_dir)
        playbook = self._active_playbook or resolve_playbook(self.app_name)
        is_openclaw = (
            bool(playbook)
            and str(getattr(playbook, "id", "") or "").strip().lower() == "openclaw"
        ) or str(self.app_name or "").strip().lower() == "openclaw"
        if is_openclaw:
            ensure_openclaw_runtime_bootstrap(compose_dir)
            bootstrap_issues = validate_openclaw_runtime_bootstrap(compose_dir)
            if bootstrap_issues:
                raise typer.Exit("OpenClaw runtime bootstrap failed: " + "; ".join(bootstrap_issues))
        compose_path = compose_dir / "docker-compose.yml"
        if not compose_path.exists():
            compose_path = compose_dir / "docker-compose.yaml"
        if not compose_path.exists():
            return

        vars_needed = set(
            re.findall(r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}', compose_path.read_text())
        )
        if not vars_needed:
            _step_done("Configuring environment", "no env vars required")
            return

        env_path = compose_dir / ".env"
        had_env = env_path.exists()
        existing_env = _load_dotenv_kv(env_path) if had_env else {}

        from armorer.config import ConfigManager
        config_manager = ConfigManager()

        env_values: Dict[str, str] = {}
        missing_vars = []
        auto_filled = []

        # 1. Resolve from existing .env first, then from Armorer config/vault.
        for var in sorted(vars_needed):
            existing_val = str(existing_env.get(var) or "").strip()
            if existing_val:
                env_values[var] = existing_val
                continue

            vault_val = config_manager.find_vault_key(var)
            if vault_val:
                env_values[var] = str(vault_val)
                if had_env:
                    auto_filled.append(var)
            else:
                env_values[var] = ""
                missing_vars.append(var)

        if had_env and not missing_vars and not auto_filled:
            _step_done("Configuring environment", ".env already exists")
            return

        base_lines = env_path.read_text().splitlines() if had_env else [f"# {self.app_name} — Environment Configuration", ""]

        # 2. If non-interactive, just dump empty placeholders for missing
        if not self.options.get("confirm", True):
            updates = {var: env_values.get(var, "") for var in sorted(vars_needed)}
            env_path.write_text("\n".join(self._merge_env_lines(base_lines, updates)) + "\n")
            detail = "updated existing .env" if had_env else f"generated template at {env_path}"
            _step_done("Configuring environment", detail)
            return

        # 3. Interactive: Dashboard for missing variables
        final_detail = "updated existing .env" if had_env else "configuration finalized"
        if missing_vars:
            import questionary
            
            # Map of var_name -> current_value
            config_state = {v: "" for v in missing_vars}
            
            console.print(Panel(
                "[bold yellow]🛡️  Configuration Dashboard[/bold yellow]\n"
                "Select a variable to set its value. You can configure as many or as few as you like.\n\n"
                "• [bold cyan]Save & Proceed[/bold cyan]: Writes currently SET values to .env and continues.\n"
                "• [bold cyan]Skip All[/bold cyan]: Continues without writing these missing variables.",
                border_style="yellow",
                expand=True
            ))

            while True:
                console.print()
                table = Table(title=f"Environment Variables: {self.app_name}", border_style="yellow", expand=True)
                table.add_column("Variable", style="bold cyan")
                table.add_column("Status", justify="center")
                table.add_column("Value (Masked)", style="dim")
                
                for var in sorted(missing_vars):
                    val = config_state.get(var, "")
                    status = "[green]SET[/green]" if val else "[red]MISSING[/red]"
                    masked = "*" * len(val) if val else "-"
                    table.add_row(var, status, masked)
                
                console.print(table)
                
                # Build choices for selection
                action_choices = [f"Set {v}" for v in sorted(missing_vars)]
                action_choices.append(Separator())
                action_choices.append("Save & Proceed")
                action_choices.append("Skip All (I'll edit .env later)")

                action = questionary.select(
                    "What would you like to do?",
                    choices=action_choices,
                    style=questionary.Style([('separator', 'fg:#666666 italic')])
                ).ask()

                if not action or "Skip All" in action:
                    final_detail = "skipped (placeholders created to mute warnings)"
                    break
                
                if action == "Save & Proceed":
                    break
                
                # Extract variable name from "Set VAR_NAME"
                var_to_set = action.replace("Set ", "")
                val = questionary.text(f"Enter value for {var_to_set}:").ask()
                
                if val:
                    config_state[var_to_set] = val
                    if config_manager.is_secret(var_to_set):
                        if questionary.confirm(f"Save {var_to_set} to vault for future use?", default=True).ask():
                            config_manager.save_to_vault(var_to_set, val, description=f"Saved during {self.app_name} installation")
                else:
                    config_state[var_to_set] = "" # Allow clearing if they change their mind

            for var in missing_vars:
                env_values[var] = config_state.get(var, "")

        updates = {var: env_values.get(var, "") for var in sorted(vars_needed)}
        env_path.write_text("\n".join(self._merge_env_lines(base_lines, updates)) + "\n")
        _step_done("Configuring environment", final_detail)

    def _ensure_path_env_defaults(self, compose_dir: Path):
        """Auto-fill common path variables used in volume mappings when missing."""
        compose_dir = self._host_visible_path(compose_dir)
        hints = load_agent_hints(self.app_name)
        defaults = hints.get("env_defaults", {}) if isinstance(hints, dict) else {}
        if not isinstance(defaults, dict):
            defaults = {}

        env_path = compose_dir / ".env"
        if not env_path.exists():
            env_path.write_text("")

        try:
            lines = env_path.read_text().splitlines()
        except Exception:
            lines = []

        existing = {}
        for ln in lines:
            if not ln or ln.strip().startswith("#") or "=" not in ln:
                continue
            k, v = ln.split("=", 1)
            existing[k.strip()] = v.strip()

        # Apply knowledge-driven bootstrap files even when compose file does not exist yet.
        create_files = hints.get("create_files", []) if isinstance(hints, dict) else []
        if isinstance(create_files, list):
            for item in create_files:
                if not isinstance(item, dict):
                    continue
                rel_path = item.get("path")
                content = item.get("content")
                if not isinstance(rel_path, str) or content is None:
                    continue
                rendered = rel_path
                for key, value in {**defaults, **existing}.items():
                    if isinstance(value, str):
                        rendered = rendered.replace(f"${{{key}}}", value)
                try:
                    target = (compose_dir / rendered).resolve() if rendered.startswith(".") else Path(rendered).expanduser()
                    target.parent.mkdir(parents=True, exist_ok=True)
                    if not target.exists():
                        if isinstance(content, (dict, list)):
                            target.write_text(json.dumps(content, indent=2) + "\n")
                        else:
                            target.write_text(str(content))
                        _step_done("Configuring environment", f"created {target.name}")
                except Exception:
                    continue

        compose_path = compose_dir / "docker-compose.yml"
        if not compose_path.exists():
            compose_path = compose_dir / "docker-compose.yaml"
        if not compose_path.exists():
            return

        try:
            compose_text = compose_path.read_text()
        except Exception:
            return

        needed = {k: v for k, v in defaults.items() if isinstance(v, str) and f"${{{k}" in compose_text}
        if not needed:
            return

        to_add = []
        for key, val in needed.items():
            if existing.get(key):
                continue
            to_add.append(f"{key}={val}")
            try:
                target = (compose_dir / val).resolve() if val.startswith(".") else Path(val).expanduser()
                target.mkdir(parents=True, exist_ok=True)
            except Exception:
                pass

        if to_add:
            with open(env_path, "a") as f:
                if lines and lines[-1].strip():
                    f.write("\n")
                f.write("\n".join(to_add) + "\n")
            _step_done("Configuring environment", "applied path defaults for compose volumes")

    # ── Registration ─────────────────────────────────────────────────────────

    def _discover_config_structure(self) -> Dict[str, Any]:
        # Known agents should avoid LLM-only discovery to keep installs deterministic.
        playbook = resolve_playbook(self.app_name)
        if playbook:
            return {
                "env": ".env",
                "main": None,
                "monitor_targets": list(getattr(playbook, "monitor_targets", []) or []),
                "monitoring_mode": str(getattr(playbook, "monitoring_mode", "hybrid") or "hybrid"),
            }

        files = [
            str(f.relative_to(self.install_dir))
            for f in self.install_dir.rglob("*")
            if f.is_file()
        ]
        readme = ""
        if (self.install_dir / "README.md").exists():
            readme = (self.install_dir / "README.md").read_text()[:3000]
        prompt = (
            f"Analyze '{self.app_name}'. Files: {files[:100]}\n"
            f"README: {readme}\n\n"
            "Identify the application's configuration structure.\n"
        )
        try:
            from armorer.agent import file_read, shell
            self.armorer.init_agent(
                system_prompt=prompt + "\n\n" + self._get_phase_instructions("System Analyst"),
                tools=[file_read, shell],
                session_id=self.session_id,
                agent_name=f"armorer:{self.app_name}"
            )

            struct: AppStructure = self.armorer.run(
                user_input="Identify the application structure.",
                silent=True,
                structured_output_model=AppStructure
            )
            if struct:
                return struct.model_dump()
        except Exception:
            pass
        return {"env": ".env", "main": None, "monitor_targets": [], "monitoring_mode": "hybrid"}

    def _register(self, is_git: bool = False, run_commands: List[Dict[str, str]] = None):
        discovery = self._discover_config_structure()
        register_path = self._docker_compose_cwd
        playbook = self._active_playbook or resolve_playbook(self.app_name) or resolve_playbook(self.instance_name)
        tolerate_registry_failure = (
            bool(playbook)
            and str(getattr(playbook, "id", "") or "").strip().lower() == "openclaw"
            and str(self.instance_name or "").strip() == "openclaw"
        )
        metadata = AppMetadata(
            name=self.instance_name,
            install_path=str(register_path),
            agent_id=self.app_name,
            installed_at=time.time(),
            description=f"Installed {self.app_name}",
            config_files={
                k: v for k, v in discovery.items() if k in ["env", "main"]
            },
            monitor_targets=discovery.get("monitor_targets", []),
            monitoring_mode=str(discovery.get("monitoring_mode", "file") or "file"),
            run_commands=run_commands or [],
        )
        try:
            self.registry.add_app(metadata)
        except OSError as exc:
            if not tolerate_registry_failure:
                raise
            logger.debug("Continuing without persisted registry metadata for OpenClaw install: %s", exc)
            console.print(
                "[dim]Registry metadata could not be saved for `openclaw`; continuing with install-root fallback.[/dim]"
            )
        try:
            register_path.mkdir(parents=True, exist_ok=True)
            (register_path / ".armorer_monitor.json").write_text(
                json.dumps({"targets": metadata.monitor_targets})
            )
        except OSError as exc:
            logger.debug("Skipping monitor sidecar update for %s: %s", register_path, exc)
