Ctrl K
ComunidadGuías…

bootstrap.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
# ============================================================
# Iris Bootstrap Script
#
# Modes:
#
#   First-time setup (will ask whether to use Key Vault):
#     bash bootstrap.sh --setup
#
#   First-time setup, secrets in Azure Key Vault:
#     bash bootstrap.sh --setup --keyvault
#
#   First-time setup, secrets stored directly in /iris/.env:
#     bash bootstrap.sh --setup --no-keyvault
#
#   Re-deploy, Key Vault + secrets already exist:
#     KV_NAME=<vault> bash bootstrap.sh
#
#   Re-deploy, /iris/.env already exists:
#     bash bootstrap.sh --no-keyvault
#
#   Install Firecracker + build rootfs (run once on a KVM-capable VM):
#     bash bootstrap.sh --firecracker
#
#   Combine flags:
#     bash bootstrap.sh --setup --no-keyvault --firecracker   # no Azure, isolated microVMs
#     bash bootstrap.sh --setup --keyvault --firecracker      # Azure Key Vault + microVMs
#     bash bootstrap.sh --setup --firecracker --pool          # dynamic pool (fresh VM per channel)
#
# All config can be passed via env vars to skip prompts.
# ============================================================
set -euo pipefail

IRIS_DIR="${IRIS_DIR:-/iris}"
# The real operating user, even when this script is invoked via `sudo bash bootstrap.sh`
# (plain $USER gets reset to "root" by sudo in that case, which would leave iris.service
# and /iris file ownership pointed at root instead of the actual VM user).
TARGET_USER="${SUDO_USER:-$(id -un)}"

# Secret/token env vars, captured before the "Variables written to .env" block
# further down re-declares each of these as a plain "" local — without this
# capture, a value already exported into the process environment (e.g. by a
# CI job or a wrapper script) would be silently wiped before prompt_secrets()
# ever gets a chance to use it.
ENV_ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
ENV_OPENAI_API_KEY="${OPENAI_API_KEY:-}"
ENV_AZURE_FOUNDRY_KEY="${AZURE_FOUNDRY_KEY:-}"
ENV_DEEPSEEK_API_KEY="${DEEPSEEK_API_KEY:-}"
ENV_MISTRAL_API_KEY="${MISTRAL_API_KEY:-}"
ENV_CUSTOM_API_KEY="${CUSTOM_API_KEY:-}"
ENV_CUSTOM_BASE_URL="${CUSTOM_BASE_URL:-}"
ENV_CUSTOM_PROVIDER_NAME="${CUSTOM_PROVIDER_NAME:-}"
ENV_AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID:-}"
ENV_AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY:-}"
ENV_AWS_PROFILE="${AWS_PROFILE:-}"
ENV_AWS_REGION="${AWS_REGION:-}"
ENV_IRIS_SLACK_APP_TOKEN="${IRIS_SLACK_APP_TOKEN:-}"
ENV_IRIS_SLACK_BOT_TOKEN="${IRIS_SLACK_BOT_TOKEN:-}"
ENV_TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"
ENV_GITHUB_TOKEN="${GITHUB_TOKEN:-}"
ENV_RESEND_API_KEY="${RESEND_API_KEY:-}"
ENV_PERPLEXITY_API_KEY="${PERPLEXITY_API_KEY:-}"

REPO_URL="${REPO_URL:-}"
IRIS_CORE_URL="${IRIS_CORE_URL:-https://github.com/irisworks/iris-core.git}"
KV_NAME="${KV_NAME:-}"
KV_RESOURCE_GROUP="${KV_RESOURCE_GROUP:-}"
REPO_DIR="${REPO_DIR:-}"
SETUP_MODE=false
NO_KEYVAULT=false
KEYVAULT_EXPLICIT=false   # true when --keyvault or --no-keyvault was passed
FIRECRACKER_MODE=false
SA_NAME="${SA_NAME:-}"
FIRECRACKER_SANDBOX="${FIRECRACKER_SANDBOX:-static}"
SANDBOX_FLAG=""
FC_GUEST_IP="172.20.1.2"

IRIS_PROVIDER="${IRIS_PROVIDER:-}"
IRIS_MODEL="${IRIS_MODEL:-}"
IRIS_ENV="${IRIS_ENV:-prod}"
SECRETS_MODE="${IRIS_SECRETS_MODE:-}"      # env | store | proxy (resolved below; see docs/secrets.md)
IRIS_BROKER_PORT="${IRIS_BROKER_PORT:-9099}"
IRIS_BASE_DOMAIN="${IRIS_BASE_DOMAIN:-}"
CERTBOT_EMAIL="${CERTBOT_EMAIL:-}"
GIT_USER_EMAIL="${GIT_USER_EMAIL:-}"

log()     { echo "[iris-bootstrap] $*"; }
log_h()   { echo ""; echo "[iris-bootstrap] ── $* ──"; }
die()     { echo "[iris-bootstrap] ERROR: $*" >&2; exit 1; }
confirm() {
  local prompt="$1" default="${2:-y}"
  local yn_hint; [[ "$default" == "y" ]] && yn_hint="[Y/n]" || yn_hint="[y/N]"
  read -r -p "[iris-bootstrap] $prompt $yn_hint " answer
  answer="${answer:-$default}"
  [[  "${answer,,}" == "y" ]]
}
prompt() {
  local question="$1" default="${2:-}" env_value="${3:-}"
  if [[ -n "$env_value" ]]; then
    log "$question → using value from environment" >&2
    echo "$env_value"
    return
  fi
  local hint; [[ -n "$default" ]] && hint=" [$default]" || hint=""
  read -r -p "[iris-bootstrap] $question$hint: " answer
  echo "${answer:-$default}"
}
prompt_secret() {
  local question="$1" default="${2:-}" env_value="${3:-}"
  if [[ -n "$env_value" ]]; then
    log "$question → using value from environment" >&2
    echo "$env_value"
    return
  fi
  local hint=""; [[ -n "$default" ]] && hint=" [Enter to keep existing key on file]"
  read -r -s -p "[iris-bootstrap] $question$hint: " answer
  echo "" >&2
  # Mobile paste (esp. from the Telegram app) commonly drags in a leading/trailing
  # newline or space that a masked prompt gives no visual way to spot.
  answer="$(printf '%s' "$answer" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
  echo "${answer:-$default}"
}

# Accepts a bare Foundry account name OR a full endpoint URL/hostname pasted by
# mistake (e.g. "my-account.cognitiveservices.azure.com" or
# "https://my-account.cognitiveservices.azure.com/openai/v1") and normalizes it
# down to just the account name. Without this, feeding a full hostname into the
# "<your-account>.cognitiveservices.azure.com" template produces a doubled,
# unresolvable host like "my-account.cognitiveservices.cognitiveservices.azure.com" —
# every LLM call then fails DNS resolution with a generic "Connection error."
sanitize_foundry_account() {
  local account="$1"
  account="${account#https://}"
  account="${account#http://}"
  account="${account%%/*}"                       # strip any path/query
  account="${account%.cognitiveservices.azure.com}"
  account="${account%.azure.com}"
  account="${account%.cognitiveservices}"
  echo "$account"
}

# ────────────────────────────────────────────────────────────
# Parse args
# ────────────────────────────────────────────────────────────
for arg in "$@"; do
  case "$arg" in
    --setup)        SETUP_MODE=true ;;
    --no-keyvault)  NO_KEYVAULT=true;  KEYVAULT_EXPLICIT=true ;;
    --keyvault)     NO_KEYVAULT=false; KEYVAULT_EXPLICIT=true ;;
    --firecracker)  FIRECRACKER_MODE=true ;;
    --pool)         FIRECRACKER_SANDBOX="pool" ;;
    --static)       FIRECRACKER_SANDBOX="static" ;;
    --secrets-mode=*) SECRETS_MODE="${arg#--secrets-mode=}" ;;
    *) die "Unknown argument: $arg" ;;
  esac
done

# Default secrets mode is `store` (encrypted local store): plaintext credentials
# in /iris/.env were the most likely leak path in the product — one `env` or
# `cat /iris/.env` tool call exposes every key (docs/secrets.md). Resolution
# order:
#   1. explicit --secrets-mode flag or IRIS_SECRETS_MODE env var
#   2. whatever the existing install's /iris/.env records — a plain re-run never
#      silently migrates an env-mode install; pass --secrets-mode=store once to
#      opt in (import-env is idempotent, so re-runs are safe either way)
#   3. legacy install with credentials but no recorded mode → env (logged;
#      only genuinely fresh installs fall through to store)
if [[ -z "$SECRETS_MODE" && -f "$IRIS_DIR/.env" ]]; then
  SECRETS_MODE=$(grep -m1 '^IRIS_SECRETS_MODE=' "$IRIS_DIR/.env" | cut -d= -f2- || true)
  # Legacy install (predates the secrets-mode feature): .env records no mode.
  # If it still holds credentials, keep env mode — silently defaulting to
  # store would prune those lines and break anything reading .env directly
  # (e.g. env-mode terraform sub-agents). Same list as SENSITIVE_ENV_VARS in
  # iris-runtime/src/engine/secret-store.ts.
  if [[ -z "$SECRETS_MODE" ]]; then
    for var in AZURE_FOUNDRY_KEY FOUNDRY_E2_KEY DEEPSEEK_API_KEY MISTRAL_API_KEY \
               CUSTOM_API_KEY ANTHROPIC_API_KEY OPENAI_API_KEY AWS_ACCESS_KEY_ID \
               AWS_SECRET_ACCESS_KEY IRIS_SLACK_APP_TOKEN IRIS_SLACK_BOT_TOKEN \
               TELEGRAM_BOT_TOKEN GITHUB_TOKEN RESEND_API_KEY PERPLEXITY_API_KEY; do
      val=$(grep -m1 "^${var}=" "$IRIS_DIR/.env" | cut -d= -f2- || true)
      if [[ -n "$val" ]]; then
        SECRETS_MODE="env"
        log "Legacy env-mode install detected ($var set, no recorded secrets mode) — keeping env mode. Pass --secrets-mode=store to migrate."
        break
      fi
    done
  fi
fi
SECRETS_MODE="${SECRETS_MODE:-store}"

case "$SECRETS_MODE" in
  env|store|proxy) ;;
  *) die "--secrets-mode must be env, store, or proxy (got '$SECRETS_MODE')" ;;
esac

# In setup mode, if neither --keyvault nor --no-keyvault was passed,
# ask the user which storage method they want.
if [[ "$SETUP_MODE" == true && "$KEYVAULT_EXPLICIT" == false ]]; then
  echo ""
  echo "  Where should Iris store secrets?"
  echo ""
  echo "  1) Azure Key Vault  — recommended for production; requires Azure account"
  echo "  2) /iris/.env file  — simpler; no Azure required; keep the file secure"
  echo ""
  read -r -p "[iris-bootstrap] Choice [1]: " kv_choice
  case "${kv_choice:-1}" in
    2) NO_KEYVAULT=true  ;;
    *) NO_KEYVAULT=false ;;
  esac
  echo ""
fi

# Re-run (no --setup) without an explicit --keyvault/--no-keyvault: infer the
# storage method from the existing install instead of falling back to the
# Key Vault path (NO_KEYVAULT's hardcoded default). That default previously
# sent a plain re-run like `bootstrap.sh --secrets-mode=proxy` down the Key
# Vault branch, which (a) demands an unwanted az login and (b) — far worse —
# its non-setup "restore mode" only restores IRIS_PROVIDER/IRIS_MODEL, not
# the LLM_API_KEY-derived vars, so re-running it clobbered the real provider
# API key in /iris/.env with an empty string before the secrets-mode
# migration could pick it up. Reading IRIS_KEY_VAULT back from the existing
# .env keeps re-runs on whichever path the install actually uses.
if [[ "$SETUP_MODE" == false && "$KEYVAULT_EXPLICIT" == false && -f "$IRIS_DIR/.env" ]]; then
  EXISTING_KV_NAME=$(grep -m1 '^IRIS_KEY_VAULT=' "$IRIS_DIR/.env" | cut -d= -f2- || true)
  if [[ -z "$EXISTING_KV_NAME" ]]; then
    NO_KEYVAULT=true
    log "Existing install has no Key Vault configured — using /iris/.env (pass --keyvault to opt in)."
  else
    KV_NAME="${KV_NAME:-$EXISTING_KV_NAME}"
    log "Existing install uses Key Vault '$KV_NAME' — continuing with Azure login (pass --no-keyvault to switch to /iris/.env)."
  fi
fi

docker_cmd() {
  if docker info &>/dev/null; then docker "$@"; else sudo docker "$@"; fi
}

resolve_repo_dir() {
  if [[ -n "$REPO_DIR" ]]; then return; fi
  REPO_DIR="${IRIS_DIR}/repo"
}

# ────────────────────────────────────────────────────────────
# 1. System dependencies
# ────────────────────────────────────────────────────────────
log_h "System dependencies"
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update -qq

if ! command -v docker &>/dev/null; then
  log "Installing Docker..."
  curl -fsSL https://get.docker.com | sudo bash
  sudo usermod -aG docker "$TARGET_USER"
fi

# Azure CLI only needed when using Key Vault
if [[ "$NO_KEYVAULT" == false ]] && ! command -v az &>/dev/null; then
  log "Installing Azure CLI..."
  curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
fi

if ! command -v gh &>/dev/null; then
  log "Installing GitHub CLI..."
  sudo apt-get install -y gh
fi

# Terraform only needed for the cloud profile (Key Vault path) — the zero-cloud
# path has no Terraform-managed infrastructure
if [[ "$NO_KEYVAULT" == false ]] && ! command -v terraform &>/dev/null; then
  log "Installing Terraform..."
  wget -qO- https://apt.releases.hashicorp.com/gpg \
    | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
  echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" \
    | sudo tee /etc/apt/sources.list.d/hashicorp.list
  sudo apt-get update -qq && sudo apt-get install -y terraform
fi

if ! command -v jq &>/dev/null; then sudo apt-get install -y jq; fi

# poppler-utils' pdftotext backs the core-shipped pdf-text read handler (see
# docs/read-handlers.md) — scanned/image-only PDFs have no text layer and
# read back empty.
if ! command -v pdftotext &>/dev/null; then sudo apt-get install -y poppler-utils; fi

# nginx + certbot are installed in step 5, and only when a public domain
# (IRIS_BASE_DOMAIN) is configured — the default path serves nothing publicly.

if ! command -v node &>/dev/null || ! node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)' 2>/dev/null; then
  log "Installing Node.js 22..."
  curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
  sudo apt-get install -y nodejs
fi

# ────────────────────────────────────────────────────────────
# 1b. Firecracker (optional — only when --firecracker is passed)
# ────────────────────────────────────────────────────────────
if [[ "$FIRECRACKER_MODE" == true ]]; then
  log_h "Firecracker setup"

  # Verify KVM is available (requires nested-virt or bare-metal)
  if [[ ! -e /dev/kvm ]]; then
    log "WARNING: /dev/kvm not found. Firecracker requires KVM."
    log "On Azure, use a Ddsv5-series VM or enable nested virtualisation."
    log "Skipping Firecracker install."
  else
    FC_VERSION="${FC_VERSION:-1.7.0}"
    FC_BIN="/usr/local/bin/firecracker"
    JAILER_BIN="/usr/local/bin/jailer"

    if [[ ! -f "$FC_BIN" ]]; then
      log "Downloading Firecracker v${FC_VERSION}..."
      FC_ARCH=$(uname -m)  # x86_64 or aarch64
      FC_TGZ="/tmp/firecracker-v${FC_VERSION}-${FC_ARCH}.tgz"
      curl -Lo "$FC_TGZ" \
        "https://github.com/firecracker-microvm/firecracker/releases/download/v${FC_VERSION}/firecracker-v${FC_VERSION}-${FC_ARCH}.tgz"
      tar -xzf "$FC_TGZ" -C /tmp
      sudo install "/tmp/release-v${FC_VERSION}-${FC_ARCH}/firecracker-v${FC_VERSION}-${FC_ARCH}" "$FC_BIN"
      sudo install "/tmp/release-v${FC_VERSION}-${FC_ARCH}/jailer-v${FC_VERSION}-${FC_ARCH}" "$JAILER_BIN"
      rm -f "$FC_TGZ"
      log "Firecracker installed: $($FC_BIN --version)"
    else
      log "Firecracker already installed: $($FC_BIN --version)"
    fi

    # Jailer system user (uid/gid 10000)
    if ! id irisjailer &>/dev/null; then
      log "Creating irisjailer system user (uid/gid 10000)..."
      sudo groupadd -g 10000 irisjailer 2>/dev/null || true
      sudo useradd -u 10000 -g 10000 -r -s /usr/sbin/nologin irisjailer
    fi

    # KVM group membership for current user
    sudo usermod -aG kvm "$TARGET_USER" 2>/dev/null || true

    # Kernel image
    VMLINUX="/var/lib/iris/firecracker/vmlinux"
    if [[ ! -f "$VMLINUX" ]]; then
      log "Downloading Firecracker-compatible kernel..."
      sudo mkdir -p "$(dirname "$VMLINUX")"
      FC_ARCH=$(uname -m)
      sudo curl -Lo "$VMLINUX" \
        "https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/${FC_ARCH}/kernels/vmlinux.bin"
      log "Kernel downloaded: $VMLINUX"
    else
      log "Kernel already present: $VMLINUX"
    fi

    # Build rootfs (requires iris-runtime:local Docker image)
    ROOTFS="/var/lib/iris/firecracker/rootfs.ext4"
    if [[ ! -f "$ROOTFS" ]]; then
      log "Building Firecracker rootfs from iris-runtime Docker image..."
      log "(This requires iris-runtime:local to be built first — building now...)"
      # e2fsprogs provides mkfs.ext4, required by build-firecracker-rootfs.sh
      sudo apt-get install -y e2fsprogs
      resolve_repo_dir
      cd "$REPO_DIR/iris-runtime"
      npm ci --prefer-offline 2>/dev/null || npm install
      npm run build
      docker_cmd build -t iris-runtime:local .
      sudo bash "$REPO_DIR/scripts/build-firecracker-rootfs.sh"
    else
      log "Rootfs already present: $ROOTFS"
    fi

    log ""
    log "  Firecracker is ready. Next step:"
    log "  Uncomment a 'module \"public_sandbox\"' block in terraform/agents.tf"
    log "  and run: cd ${REPO_DIR:-/iris/repo}/terraform && terraform apply"
    log ""
  fi
fi

# ────────────────────────────────────────────────────────────
# 2. Azure login
# Skipped entirely when --no-keyvault is set (or user chose /iris/.env).
# When Key Vault is used, tries Managed Identity and service
# principal before falling back to interactive device login.
# ────────────────────────────────────────────────────────────
SUBSCRIPTION_ID=""

if [[ "$NO_KEYVAULT" == false ]]; then
  log_h "Azure login"

  if az account show &>/dev/null 2>&1; then
    log "Authenticated (Managed Identity or existing session)"
  elif [[ -n "${AZURE_CLIENT_ID:-}" && -n "${AZURE_CLIENT_SECRET:-}" && -n "${AZURE_TENANT_ID:-}" ]]; then
    log "Logging in via service principal..."
    az login --service-principal \
      -u "$AZURE_CLIENT_ID" -p "$AZURE_CLIENT_SECRET" --tenant "$AZURE_TENANT_ID" -o none
  else
    log "No existing Azure session detected — starting interactive login..."
    log "(Tip: assign a Managed Identity to this VM to skip this step on future runs)"
    az login
  fi

  SUBSCRIPTION_ID=$(az account show --query id -o tsv)
  SUBSCRIPTION_NAME=$(az account show --query name -o tsv)
  log "Active subscription: $SUBSCRIPTION_NAME ($SUBSCRIPTION_ID)"

  SUBSCRIPTION_COUNT=$(az account list --query "length(@)" -o tsv 2>/dev/null || echo "1")
  if [[ "$SUBSCRIPTION_COUNT" -gt 1 ]]; then
    echo ""
    log "Available subscriptions:"
    az account list --query "[].{Name:name, ID:id, Default:isDefault}" -o table
    echo ""
    chosen=$(prompt "Subscription ID or name to use" "$SUBSCRIPTION_ID")
    if [[ "$chosen" != "$SUBSCRIPTION_ID" ]]; then
      az account set --subscription "$chosen"
      SUBSCRIPTION_ID=$(az account show --query id -o tsv)
      SUBSCRIPTION_NAME=$(az account show --query name -o tsv)
      log "Switched to: $SUBSCRIPTION_NAME ($SUBSCRIPTION_ID)"
    fi
  fi
else
  log_h "Azure (skipped — using /iris/.env for secrets)"
fi

# ────────────────────────────────────────────────────────────
# Shared: prompt for all secrets
# Called in --setup mode (both paths) and in --no-keyvault
# restore mode when /iris/.env does not yet exist.
# Sets: IRIS_PROVIDER, IRIS_MODEL, LLM_API_KEY, FOUNDRY_ACCOUNT,
#       AWS_ACCESS_KEY_INPUT, AWS_SECRET_KEY_INPUT, AWS_REGION_INPUT,
#       AWS_PROFILE_INPUT, SLACK_APP_TOKEN, SLACK_BOT_TOKEN,
#       GITHUB_TOKEN, IRIS_GITHUB_ORG, IRIS_GITHUB_REPO, RESEND_API_KEY,
#       PERPLEXITY_API_KEY, IRIS_BASE_DOMAIN, CERTBOT_EMAIL, GIT_USER_EMAIL
# ────────────────────────────────────────────────────────────
prompt_secrets() {
  # ── LLM Provider ──
  if [[ -z "$IRIS_PROVIDER" ]]; then
    echo "[iris-bootstrap] Choose LLM provider:"
    echo "  1) anthropic       — Claude Sonnet / Opus (recommended)"
    echo "  2) openai          — GPT-5.6 Luna / GPT-5.6 Terra / GPT-5.4 mini"
    echo "  3) azure-foundry   — Azure AI Foundry (Azure OpenAI, Kimi)"
    echo "  4) amazon-bedrock  — AWS Bedrock (Claude, Llama, Nova)"
    echo "  5) deepseek        — DeepSeek V3 / R1"
    echo "  6) mistral         — Codestral / Mistral Large / Medium"
    echo "  7) custom          — any other OpenAI-compatible endpoint (Kimi/Moonshot direct, self-hosted, etc.)"
    read -r -p "[iris-bootstrap] Choice [1]: " provider_choice
    case "${provider_choice:-1}" in
      1) IRIS_PROVIDER="anthropic" ;;
      2) IRIS_PROVIDER="openai" ;;
      3) IRIS_PROVIDER="azure-foundry" ;;
      4) IRIS_PROVIDER="amazon-bedrock" ;;
      5) IRIS_PROVIDER="deepseek" ;;
      6) IRIS_PROVIDER="mistral" ;;
      7) IRIS_PROVIDER="custom" ;;
      *) IRIS_PROVIDER="anthropic" ;;
    esac
  fi

  # ── Model ──
  if [[ -z "$IRIS_MODEL" ]]; then
    case "$IRIS_PROVIDER" in
      anthropic)      default_model="claude-sonnet-4-5" ;;
      openai)         default_model="gpt-5.6-luna" ;;
      azure-foundry)  default_model="Kimi-K2.6" ;;
      amazon-bedrock) default_model="us.anthropic.claude-sonnet-4-6" ;;
      deepseek)       default_model="deepseek-chat" ;;
      mistral)        default_model="codestral-2508" ;;
      custom)         default_model="" ;;
      *)              default_model="gpt-5.6-luna" ;;
    esac
    if [[ "$IRIS_PROVIDER" == "custom" ]]; then
      IRIS_MODEL=$(prompt "Model id (exact string the endpoint expects, e.g. kimi-k2-0905-preview)" "")
      [[ -z "$IRIS_MODEL" ]] && die "Model id is required for a custom provider."
    else
      IRIS_MODEL=$(prompt "Model" "$default_model")
    fi
  fi
  log "Provider: $IRIS_PROVIDER / $IRIS_MODEL"

  # ── API Key / Credentials ──
  LLM_API_KEY=""
  FOUNDRY_ACCOUNT=""
  CUSTOM_BASE_URL=""
  CUSTOM_PROVIDER_NAME=""
  AWS_REGION_INPUT=""
  AWS_ACCESS_KEY_INPUT=""
  AWS_SECRET_KEY_INPUT=""
  AWS_PROFILE_INPUT=""

  case "$IRIS_PROVIDER" in
    anthropic)
      LLM_API_KEY=$(prompt_secret "Anthropic API key (sk-ant-...)" "" "$ENV_ANTHROPIC_API_KEY")
      [[ -z "$LLM_API_KEY" ]] && die "Anthropic API key is required."
      ;;
    openai)
      LLM_API_KEY=$(prompt_secret "OpenAI API key (sk-...)" "" "$ENV_OPENAI_API_KEY")
      [[ -z "$LLM_API_KEY" ]] && die "OpenAI API key is required."
      ;;
    azure-foundry)
      # Reuse credentials already on file instead of forcing re-entry on every re-run.
      EXISTING_FOUNDRY_KEY=""
      EXISTING_FOUNDRY_ACCOUNT=""
      if [[ -f "$IRIS_DIR/.env" ]]; then
        EXISTING_FOUNDRY_KEY=$(grep -E '^AZURE_FOUNDRY_KEY=' "$IRIS_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2- || true)
      fi
      if [[ -z "$EXISTING_FOUNDRY_KEY" && "$NO_KEYVAULT" == false && -n "$KV_NAME" ]]; then
        EXISTING_FOUNDRY_KEY=$(az keyvault secret show --vault-name "$KV_NAME" --name "AZURE-FOUNDRY-KEY" --query value -o tsv 2>/dev/null || true)
      fi
      if [[ -f "$IRIS_DIR/data/models.json" ]]; then
        EXISTING_FOUNDRY_ACCOUNT=$(grep -oE 'https://[a-zA-Z0-9-]+\.cognitiveservices' "$IRIS_DIR/data/models.json" 2>/dev/null \
          | head -1 | sed -E 's#https://##; s#\.cognitiveservices##' || true)
      fi
      [[ -n "$EXISTING_FOUNDRY_KEY" ]] && log "Found an existing Foundry API key on this machine — reusing it."
      LLM_API_KEY=$(prompt_secret "Azure AI Foundry API key" "$EXISTING_FOUNDRY_KEY" "$ENV_AZURE_FOUNDRY_KEY")
      [[ -z "$LLM_API_KEY" ]] && die "Foundry API key is required."
      FOUNDRY_ACCOUNT=$(prompt "Azure AI Foundry account name — bare name only, not the full URL (e.g. my-account-eastus2)" "$EXISTING_FOUNDRY_ACCOUNT")
      [[ -z "$FOUNDRY_ACCOUNT" ]] && die "Foundry account name is required."
      FOUNDRY_ACCOUNT_RAW="$FOUNDRY_ACCOUNT"
      FOUNDRY_ACCOUNT=$(sanitize_foundry_account "$FOUNDRY_ACCOUNT")
      [[ "$FOUNDRY_ACCOUNT" != "$FOUNDRY_ACCOUNT_RAW" ]] && \
        log "Note: trimmed '$FOUNDRY_ACCOUNT_RAW' down to account name '$FOUNDRY_ACCOUNT' (looked like a full hostname/URL, not a bare account name)."
      ;;
    deepseek)
      LLM_API_KEY=$(prompt_secret "DeepSeek API key (sk-...)" "" "$ENV_DEEPSEEK_API_KEY")
      [[ -z "$LLM_API_KEY" ]] && die "DeepSeek API key is required."
      ;;
    mistral)
      LLM_API_KEY=$(prompt_secret "Mistral API key" "" "$ENV_MISTRAL_API_KEY")
      [[ -z "$LLM_API_KEY" ]] && die "Mistral API key is required."
      ;;
    custom)
      # Reuse settings already on file instead of forcing re-entry on every re-run.
      EXISTING_CUSTOM_KEY=""
      EXISTING_CUSTOM_BASE_URL=""
      EXISTING_CUSTOM_NAME=""
      if [[ -f "$IRIS_DIR/.env" ]]; then
        EXISTING_CUSTOM_KEY=$(grep -E '^CUSTOM_API_KEY=' "$IRIS_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2- || true)
        EXISTING_CUSTOM_BASE_URL=$(grep -E '^CUSTOM_BASE_URL=' "$IRIS_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2- || true)
        EXISTING_CUSTOM_NAME=$(grep -E '^CUSTOM_PROVIDER_NAME=' "$IRIS_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2- || true)
      fi
      if [[ -z "$EXISTING_CUSTOM_KEY" && "$NO_KEYVAULT" == false && -n "$KV_NAME" ]]; then
        EXISTING_CUSTOM_KEY=$(az keyvault secret show --vault-name "$KV_NAME" --name "CUSTOM-API-KEY" --query value -o tsv 2>/dev/null || true)
      fi
      [[ -n "$EXISTING_CUSTOM_KEY" ]] && log "Found an existing custom-provider API key on this machine — reusing it."
      CUSTOM_PROVIDER_NAME=$(prompt "Short name for this provider (lowercase, used as the models.json key, e.g. kimi)" "${EXISTING_CUSTOM_NAME:-kimi}" "$ENV_CUSTOM_PROVIDER_NAME")
      [[ -z "$CUSTOM_PROVIDER_NAME" ]] && die "Provider name is required."
      CUSTOM_BASE_URL=$(prompt "Base URL (OpenAI-compatible, e.g. https://api.moonshot.ai/v1)" "$EXISTING_CUSTOM_BASE_URL" "$ENV_CUSTOM_BASE_URL")
      [[ -z "$CUSTOM_BASE_URL" ]] && die "Base URL is required."
      LLM_API_KEY=$(prompt_secret "API key for $CUSTOM_PROVIDER_NAME" "$EXISTING_CUSTOM_KEY" "$ENV_CUSTOM_API_KEY")
      [[ -z "$LLM_API_KEY" ]] && die "API key is required."
      ;;
    amazon-bedrock)
      echo ""
      echo "  ┌─ AWS Bedrock Credentials ───────────────────────────────────────┐"
      echo "  │  1) IAM Role  — instance profile, no keys needed              │"
      echo "  │  2) Access key + secret                                        │"
      echo "  │  3) Named AWS profile (~/.aws/config)                          │"
      echo "  └────────────────────────────────────────────────────────────────────┘"
      bedrock_cred_choice=""
      if [[ -n "$ENV_AWS_ACCESS_KEY_ID" ]]; then
        bedrock_cred_choice=2
      elif [[ -n "$ENV_AWS_PROFILE" ]]; then
        bedrock_cred_choice=3
      else
        read -r -p "[iris-bootstrap] Credential method [1]: " bedrock_cred_choice
      fi
      case "${bedrock_cred_choice:-1}" in
        2)
          AWS_ACCESS_KEY_INPUT=$(prompt_secret "AWS Access Key ID (AKIA...)" "" "$ENV_AWS_ACCESS_KEY_ID")
          AWS_SECRET_KEY_INPUT=$(prompt_secret "AWS Secret Access Key" "" "$ENV_AWS_SECRET_ACCESS_KEY")
          [[ -z "$AWS_ACCESS_KEY_INPUT" ]] && die "AWS Access Key ID is required."
          [[ -z "$AWS_SECRET_KEY_INPUT" ]] && die "AWS Secret Access Key is required."
          ;;
        3)
          AWS_PROFILE_INPUT=$(prompt "AWS profile name" "default" "$ENV_AWS_PROFILE")
          ;;
        *) log "Using IAM instance role — no credentials needed." ;;
      esac
      AWS_REGION_INPUT=$(prompt "AWS region for Bedrock" "us-east-1" "$ENV_AWS_REGION")
      ;;
  esac

  # ── Slack ──
  echo ""
  SLACK_APP_TOKEN=""
  SLACK_BOT_TOKEN=""
  if [[ -n "$ENV_IRIS_SLACK_APP_TOKEN" && -n "$ENV_IRIS_SLACK_BOT_TOKEN" ]]; then
    log "IRIS_SLACK_APP_TOKEN / IRIS_SLACK_BOT_TOKEN already set in the environment — using them."
    SLACK_APP_TOKEN="$ENV_IRIS_SLACK_APP_TOKEN"
    SLACK_BOT_TOKEN="$ENV_IRIS_SLACK_BOT_TOKEN"
    [[ "$SLACK_APP_TOKEN" != xapp-* ]] && die "IRIS_SLACK_APP_TOKEN must start with 'xapp-'. Got: ${SLACK_APP_TOKEN:0:10}..."
    [[ "$SLACK_BOT_TOKEN" != xoxb-* ]] && die "IRIS_SLACK_BOT_TOKEN must start with 'xoxb-'. Got: ${SLACK_BOT_TOKEN:0:10}..."
  elif confirm "Set up Slack integration?"; then
    echo ""
    echo "  ┌─ Slack App Setup ────────────────────────────────────────────┐"
    echo "  │                                                               │"
    echo "  │  1. Go to https://api.slack.com/apps → Create New App        │"
    echo "  │     → From scratch → name it 'Iris' → pick your workspace    │"
    echo "  │                                                               │"
    echo "  │  2. Socket Mode (left sidebar)                                │"
    echo "  │     → Enable Socket Mode → generate App-Level Token          │"
    echo "  │     → name it 'iris-socket' → scope: connections:write       │"
    echo "  │     → copy the  xapp-...  token  (App Token)                 │"
    echo "  │                                                               │"
    echo "  │  3. OAuth & Permissions (left sidebar)                        │"
    echo "  │     → Bot Token Scopes → Add:                                 │"
    echo "  │         app_mentions:read  channels:history  channels:read    │"
    echo "  │         chat:write         groups:history    groups:read      │"
    echo "  │         im:history         im:read           im:write         │"
    echo "  │         mpim:history       reactions:write   users:read       │"
    echo "  │     → Install to Workspace → copy the  xoxb-...  token       │"
    echo "  │                                                               │"
    echo "  │  4. Event Subscriptions → Enable → subscribe to bot events:   │"
    echo "  │         app_mention  message.channels  message.groups        │"
    echo "  │         message.im   message.mpim                            │"
    echo "  │                                                               │"
    echo "  │  5. App Home → enable Messages Tab                           │"
    echo "  │                                                               │"
    echo "  │  6. Optional — Slash Commands → Create New Command for each   │"
    echo "  │     of  /stop  /compact  /clear  /reset  /verbose             │"
    echo "  │     (leave Request URL blank; Socket Mode delivers them)      │"
    echo "  │                                                               │"
    echo "  │  Note: Make sure to store the xapp and xoxb tokens securely  │"
    echo "  │  as they will be required in the next steps.                 │"
    echo "  └───────────────────────────────────────────────────────────────────┘"
    echo ""
    read -r -p "[iris-bootstrap] Press Enter when your app is created and tokens are ready..."
    SLACK_APP_TOKEN=$(prompt_secret "Slack App token (xapp-...)")
    SLACK_BOT_TOKEN=$(prompt_secret "Slack Bot token (xoxb-...)")
    [[ -z "$SLACK_APP_TOKEN" ]] && die "Slack App token is required."
    [[ -z "$SLACK_BOT_TOKEN" ]] && die "Slack Bot token is required."
    [[ "$SLACK_APP_TOKEN" != xapp-* ]] && die "Slack App token must start with 'xapp-'. Got: ${SLACK_APP_TOKEN:0:10}..."
    [[ "$SLACK_BOT_TOKEN" != xoxb-* ]] && die "Slack Bot token must start with 'xoxb-'. Got: ${SLACK_BOT_TOKEN:0:10}..."
  else
    log "Skipping Slack — you can add IRIS_SLACK_APP_TOKEN / IRIS_SLACK_BOT_TOKEN to /iris/.env later."
  fi

  # ── Telegram ──
  echo ""
  TELEGRAM_BOT_TOKEN=""
  if [[ -n "$ENV_TELEGRAM_BOT_TOKEN" ]] || confirm "Set up Telegram integration?"; then
    if [[ -n "$ENV_TELEGRAM_BOT_TOKEN" ]]; then
      log "TELEGRAM_BOT_TOKEN already set in the environment — using it."
    else
      echo ""
      echo "  ┌─ Telegram Bot Setup ────────────────────────────────────────────┐"
      echo "  │                                                                 │"
      echo "  │  1. Open Telegram, message  @BotFather                        │"
      echo "  │     → confirm the handle is exactly @BotFather                │"
      echo "  │                                                                 │"
      echo "  │  2. Send  /newbot  and follow the prompts:                    │"
      echo "  │     → display name (anything)                                 │"
      echo "  │     → username (must end in 'bot' or '_bot')                  │"
      echo "  │                                                                 │"
      echo "  │  3. BotFather replies with your token — copy it               │"
      echo "  │     (looks like  123456789:AAbecomeF-...)                     │"
      echo "  │                                                                 │"
      echo "  │  Tip: do this at web.telegram.org (or the desktop app) in a   │"
      echo "  │  browser tab on THIS machine — Telegram syncs the BotFather    │"
      echo "  │  chat to every device, so you can copy-paste locally instead   │"
      echo "  │  of moving the token off your phone.                          │"
      echo "  │                                                                 │"
      echo "  │  Full walkthrough incl. claiming/reclaiming the bot:           │"
      echo "  │  docs/SETUP.md#telegram-setup                                  │"
      echo "  └─────────────────────────────────────────────────────────────────┘"
      echo ""
      read -r -p "[iris-bootstrap] Press Enter when your bot is created and token is ready..."
    fi
    while true; do
      TELEGRAM_BOT_TOKEN=$(prompt_secret "Telegram Bot Token" "" "$ENV_TELEGRAM_BOT_TOKEN")
      [[ -z "$TELEGRAM_BOT_TOKEN" ]] && die "Telegram Bot Token is required."
      # BotFather tokens are "<numeric bot id>:<35-char hash>"; if stray text
      # landed on the same line (e.g. a label was pasted along with it), pull
      # just the token out of it. (Only the pasted line is ever seen here —
      # `read` stops at the first newline — so this can't recover a token
      # buried further down in a multi-line paste of BotFather's whole reply.)
      if [[ "$TELEGRAM_BOT_TOKEN" != *:* ]]; then
        EXTRACTED=$(grep -oE '[0-9]{6,}:[A-Za-z0-9_-]{30,}' <<< "$TELEGRAM_BOT_TOKEN" | head -n1)
        [[ -n "$EXTRACTED" ]] && TELEGRAM_BOT_TOKEN="$EXTRACTED"
      fi
      [[ "$TELEGRAM_BOT_TOKEN" != *:* ]] && die "Telegram Bot Token should look like '123456789:AA...'. Got: ${TELEGRAM_BOT_TOKEN:0:10}..."
      log "  ✓ Got Telegram token: ${TELEGRAM_BOT_TOKEN:0:10}…${TELEGRAM_BOT_TOKEN: -4} (${#TELEGRAM_BOT_TOKEN} chars)"
      log "Verifying token with Telegram..."
      # Masked pastes give mobile users no feedback if the copy got mangled
      # (truncated, extra newline, autocorrect) — hit getMe live so a bad
      # token is caught here instead of surfacing as a runtime crash later.
      TG_ME=$(curl -s --max-time 10 "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe" 2>/dev/null || true)
      if [[ -n "$TG_ME" ]] && [[ "$(echo "$TG_ME" | jq -r '.ok // false' 2>/dev/null)" == "true" ]]; then
        TG_USERNAME=$(echo "$TG_ME" | jq -r '.result.username // "unknown"')
        log "✓ Verified: connected as @${TG_USERNAME}"
        break
      fi
      TG_ERR=$(echo "${TG_ME:-}" | jq -r '.description // empty' 2>/dev/null || true)
      log "⚠ Could not verify this token with Telegram${TG_ERR:+ (${TG_ERR})}."
      # A token sourced from the environment can't be retyped — retrying would
      # just re-fetch the same value from prompt_secret and loop forever.
      [[ -n "$ENV_TELEGRAM_BOT_TOKEN" ]] && die "TELEGRAM_BOT_TOKEN from the environment could not be verified."
      confirm "Try a different token?" || die "Telegram Bot Token could not be verified."
    done
  else
    log "Skipping Telegram — you can add TELEGRAM_BOT_TOKEN to /iris/.env later."
  fi

  # ── GitHub token ──
  GITHUB_TOKEN=""
  IRIS_GITHUB_ORG=""
  IRIS_GITHUB_REPO=""
  if [[ -n "$ENV_GITHUB_TOKEN" ]] || confirm "Add GitHub token for repo access?" "n"; then
    if [[ -n "$ENV_GITHUB_TOKEN" ]]; then
      log "GITHUB_TOKEN already set in the environment — using it."
    else
      echo ""
      echo "  ┌─ GitHub Token Setup ────────────────────────────────────────────┐"
      echo "  │  1. https://github.com/settings/tokens                        │"
      echo "  │     → Fine-grained personal access tokens → Generate new      │"
      echo "  │  2. Permissions: Contents, Pull requests, Issues (read/write) │"
      echo "  │  3. Copy the  github_pat_...  value                           │"
      echo "  └────────────────────────────────────────────────────────────────────┘"
      echo ""
      read -r -p "[iris-bootstrap] Press Enter when your token is ready..."
    fi
    GITHUB_TOKEN=$(prompt_secret "GitHub token (github_pat_... or ghp_...)" "" "$ENV_GITHUB_TOKEN")

    echo ""
    echo "  This token needs a repo to push to: the one Iris commits her own"
    echo "  skills, sub-agents, and self-edits to (GitHub is her long-term"
    echo "  memory — see docs/overlay.md). Use your own private overlay repo,"
    echo "  or a private mirror of iris-core — not this upstream checkout, and"
    echo "  not a public fork: what Iris commits here is yours, keep it private."
    echo ""
    while true; do
      GH_ORG_REPO=$(prompt "GitHub org/repo Iris commits to (e.g. yourname/iris-core)" "")
      if [[ -z "$GH_ORG_REPO" ]]; then
        log "Warning: no org/repo set — Iris can't push skill commits until"
        log "  IRIS_GITHUB_ORG / IRIS_GITHUB_REPO are set in /iris/.env."
        break
      fi
      CANDIDATE_ORG="${GH_ORG_REPO%%/*}"
      CANDIDATE_REPO="${GH_ORG_REPO#*/}"
      IRIS_CORE_ORG_REPO=$(echo "$IRIS_CORE_URL" | sed -E 's#^.*github\.com[:/]##; s#\.git$##')
      if [[ "${CANDIDATE_ORG,,}/${CANDIDATE_REPO,,}" == "$(echo "$IRIS_CORE_ORG_REPO" | tr '[:upper:]' '[:lower:]')" ]]; then
        log "Refusing: ${GH_ORG_REPO} is the iris-core upstream you cloned from."
        log "  This must be your own private overlay repo or a private mirror —"
        log "  see docs/overlay.md."
        continue
      fi
      if command -v gh &>/dev/null && [[ -n "$GITHUB_TOKEN" ]]; then
        if ! GH_VISIBILITY=$(GH_TOKEN="$GITHUB_TOKEN" gh repo view "$GH_ORG_REPO" --json visibility -q .visibility 2>&1); then
          log "Warning: could not verify ${GH_ORG_REPO}'s visibility (gh error:"
          log "  ${GH_VISIBILITY}). Proceeding, but double check it's private —"
          log "  run 'gh repo view ${GH_ORG_REPO}' manually if unsure."
          GH_VISIBILITY=""
        fi
        if [[ "$GH_VISIBILITY" == "PUBLIC" ]]; then
          log "Refusing: ${GH_ORG_REPO} is a public repo. Iris commits her own"
          log "  memory (MEMORY.md), skills, and business context here — it must"
          log "  be private. Never a public fork; see docs/overlay.md."
          continue
        fi
      fi
      IRIS_GITHUB_ORG="$CANDIDATE_ORG"
      IRIS_GITHUB_REPO="$CANDIDATE_REPO"
      break
    done
  fi

  # ── Email (optional) ──
  RESEND_API_KEY=""
  if [[ -n "$ENV_RESEND_API_KEY" ]]; then
    log "RESEND_API_KEY already set in the environment — using it."
    RESEND_API_KEY="$ENV_RESEND_API_KEY"
  elif confirm "Set up email sending (Resend.com)?" "n"; then
    RESEND_API_KEY=$(prompt_secret "Resend API key (re_...)")
  fi

  # ── Web search (optional) ──
  PERPLEXITY_API_KEY=""
  if [[ -n "$ENV_PERPLEXITY_API_KEY" ]]; then
    log "PERPLEXITY_API_KEY already set in the environment — using it."
    PERPLEXITY_API_KEY="$ENV_PERPLEXITY_API_KEY"
  elif confirm "Set up web search (Perplexity)?" "n"; then
    PERPLEXITY_API_KEY=$(prompt_secret "Perplexity API key (pplx-...)")
  fi

  # ── Domain (optional) ──
  if [[ -z "$IRIS_BASE_DOMAIN" ]]; then
    if confirm "Set up public domain (e.g. iris.example.com)?" "n"; then
      IRIS_BASE_DOMAIN=$(prompt "Base domain" "")
      CERTBOT_EMAIL=$(prompt "Certbot email" "admin@$(echo "$IRIS_BASE_DOMAIN" | sed 's/^[^.]*\.//')")
    fi
  fi

  # ── Git email ──
  if [[ -z "$GIT_USER_EMAIL" ]]; then
    GIT_USER_EMAIL=$(prompt "Git author email for Iris commits" "iris@example.com")
  fi

  # ── Terraform state storage (Firecracker + Azure path only) ──
  if [[ "$FIRECRACKER_MODE" == true && "$NO_KEYVAULT" == false && -z "$SA_NAME" ]]; then
    suggested_sa="iristfstate$(hostname -s | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9' | cut -c1-10)"
    SA_NAME=$(prompt "Terraform state storage account name (lowercase + numbers, max 24 chars)" "$suggested_sa")
  fi
}

# ────────────────────────────────────────────────────────────
# 3. Secret configuration
# ────────────────────────────────────────────────────────────
GENERATED_MODELS_JSON=""

# Variables written to .env — initialise so step 8 always has them
ANTHROPIC_API_KEY=""
OPENAI_API_KEY=""
AZURE_FOUNDRY_KEY=""
DEEPSEEK_API_KEY=""
MISTRAL_API_KEY=""
CUSTOM_API_KEY=""
CUSTOM_BASE_URL=""
CUSTOM_PROVIDER_NAME=""
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
AWS_REGION=""
AWS_PROFILE=""
SLACK_APP_TOKEN=""
SLACK_BOT_TOKEN=""
TELEGRAM_BOT_TOKEN=""
GITHUB_TOKEN=""
IRIS_GITHUB_ORG=""
IRIS_GITHUB_REPO=""
RESEND_API_KEY=""
PERPLEXITY_API_KEY=""
LLM_API_KEY=""
FOUNDRY_ACCOUNT=""
AWS_ACCESS_KEY_INPUT=""
AWS_SECRET_KEY_INPUT=""
AWS_REGION_INPUT=""
AWS_PROFILE_INPUT=""

if [[ "$NO_KEYVAULT" == false ]]; then
  # ── Key Vault path ───────────────────────────────────────────────
  if [[ "$SETUP_MODE" == true ]]; then
    log_h "First-time setup"
    echo ""
    echo "  Secrets will be stored in Azure Key Vault — never on disk."
    echo ""

    prompt_secrets

    # ── Key Vault creation ──
    echo ""
    log_h "Key Vault setup"

    if [[ -z "$KV_NAME" ]]; then
      suggested="iris-kv-$(hostname -s | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9-' | cut -c1-12 | sed 's/-*$//')"
      KV_NAME=$(prompt "Key Vault name (must be globally unique)" "$suggested")
    fi

    if [[ -z "$KV_RESOURCE_GROUP" ]]; then
      VM_RG=$(curl -sf -H Metadata:true \
        "http://169.254.169.254/metadata/instance/compute/resourceGroupName?api-version=2021-02-01&format=text" 2>/dev/null || echo "")
      if [[ -n "$VM_RG" ]]; then
        KV_RESOURCE_GROUP=$(prompt "Resource group for Key Vault" "$VM_RG")
      else
        KV_RESOURCE_GROUP=$(prompt "Resource group for Key Vault" "iris-rg")
      fi
    fi

    VM_LOCATION=$(curl -sf -H Metadata:true \
      "http://169.254.169.254/metadata/instance/compute/location?api-version=2021-02-01&format=text" 2>/dev/null || echo "eastus2")

    if az keyvault show --name "$KV_NAME" &>/dev/null 2>&1; then
      log "Key Vault '$KV_NAME' already exists — reusing."
    else
      log "Creating Key Vault '$KV_NAME' in '$KV_RESOURCE_GROUP'..."
      if ! az group show -n "$KV_RESOURCE_GROUP" &>/dev/null 2>&1; then
        log "Creating resource group '$KV_RESOURCE_GROUP' in $VM_LOCATION..."
        az group create -n "$KV_RESOURCE_GROUP" -l "$VM_LOCATION" -o none
      fi
      az keyvault create \
        --name "$KV_NAME" \
        --resource-group "$KV_RESOURCE_GROUP" \
        --location "$VM_LOCATION" \
        --enable-rbac-authorization false \
        --retention-days 7 \
        -o none
      log "✓ Key Vault created: $KV_NAME"
    fi

    CURRENT_USER_ID=$(az ad signed-in-user show --query id -o tsv 2>/dev/null || true)
    if [[ -n "$CURRENT_USER_ID" ]]; then
      az keyvault set-policy \
        --name "$KV_NAME" \
        --object-id "$CURRENT_USER_ID" \
        --secret-permissions get list set delete \
        -o none 2>/dev/null || true
    fi

    log "Seeding secrets into Key Vault..."
    seed_secret() {
      local name="$1" value="$2"
      [[ -z "$value" ]] && return
      az keyvault secret set --vault-name "$KV_NAME" --name "$name" --value "$value" -o none
      log "  ✓ $name"
    }
    case "$IRIS_PROVIDER" in
      anthropic)      seed_secret "ANTHROPIC-API-KEY"     "$LLM_API_KEY" ;;
      openai)         seed_secret "OPENAI-API-KEY"        "$LLM_API_KEY" ;;
      azure-foundry)  seed_secret "AZURE-FOUNDRY-KEY"     "$LLM_API_KEY" ;;
      deepseek)       seed_secret "DEEPSEEK-API-KEY"      "$LLM_API_KEY" ;;
      mistral)        seed_secret "MISTRAL-API-KEY"       "$LLM_API_KEY" ;;
      custom)         seed_secret "CUSTOM-API-KEY"        "$LLM_API_KEY" ;;
      amazon-bedrock) seed_secret "AWS-ACCESS-KEY-ID"     "${AWS_ACCESS_KEY_INPUT:-}"
                      seed_secret "AWS-SECRET-ACCESS-KEY" "${AWS_SECRET_KEY_INPUT:-}"
                      seed_secret "AWS-REGION"            "${AWS_REGION_INPUT:-us-east-1}"
                      seed_secret "AWS-PROFILE"           "${AWS_PROFILE_INPUT:-}" ;;
    esac
    seed_secret "SLACK-APP-TOKEN"    "$SLACK_APP_TOKEN"
    seed_secret "SLACK-BOT-TOKEN"    "$SLACK_BOT_TOKEN"
    [[ -n "${TELEGRAM_BOT_TOKEN:-}" ]] && seed_secret "TELEGRAM-BOT-TOKEN" "$TELEGRAM_BOT_TOKEN"
    seed_secret "GITHUB-TOKEN"       "$GITHUB_TOKEN"
    seed_secret "GITHUB-ORG"         "$IRIS_GITHUB_ORG"
    seed_secret "GITHUB-REPO"        "$IRIS_GITHUB_REPO"
    seed_secret "RESEND-API-KEY"     "$RESEND_API_KEY"
    seed_secret "PERPLEXITY-API-KEY" "$PERPLEXITY_API_KEY"
    log "✓ Secrets seeded."
  else
    # Restore mode — defaults only
    [[ -z "$IRIS_PROVIDER" ]] && IRIS_PROVIDER="azure-foundry"
    [[ -z "$IRIS_MODEL" ]]    && IRIS_MODEL="Kimi-K2.6"
    # Pre-rename installs may still have this set from an old .env/keyvault seed.
    [[ "$IRIS_PROVIDER" == "foundry-e2" ]] && IRIS_PROVIDER="azure-foundry"
  fi

else
  # ── No-Key-Vault path ───────────────────────────────────────────────
  if [[ "$SETUP_MODE" == true ]] || [[ ! -f "$IRIS_DIR/.env" ]]; then
    log_h "Secret configuration (stored in /iris/.env)"
    echo ""
    echo "  Secrets will be written directly to /iris/.env (chmod 600)."
    echo "  No Azure Key Vault will be used."
    echo "  Keep /iris/.env secure — it contains your API keys."
    echo ""
    prompt_secrets
  else
    log_h "Using existing /iris/.env — skipping secret prompts"
    log "  Re-run with --setup --no-keyvault to update secrets."
    # Source existing .env so step 8 can rewrite it with any new env vars
    set +u
    # shellcheck disable=SC1090
    source "$IRIS_DIR/.env" 2>/dev/null || true
    set -u
    IRIS_PROVIDER="${IRIS_PROVIDER:-azure-foundry}"
    IRIS_MODEL="${IRIS_MODEL:-Kimi-K2.6}"
    # Pre-rename installs (provider was "foundry-e2") — migrate in place.
    if [[ "$IRIS_PROVIDER" == "foundry-e2" ]]; then
      IRIS_PROVIDER="azure-foundry"
      log "Migrated IRIS_PROVIDER: foundry-e2 → azure-foundry (renamed; harmless local naming cleanup)"
    fi
    [[ -z "${AZURE_FOUNDRY_KEY:-}" && -n "${FOUNDRY_E2_KEY:-}" ]] && AZURE_FOUNDRY_KEY="$FOUNDRY_E2_KEY"
    SLACK_APP_TOKEN="${IRIS_SLACK_APP_TOKEN:-}"
    SLACK_BOT_TOKEN="${IRIS_SLACK_BOT_TOKEN:-}"
    if [[ -n "$SLACK_APP_TOKEN" && "$SLACK_APP_TOKEN" != xapp-* ]]; then
      die "IRIS_SLACK_APP_TOKEN in /iris/.env looks wrong (expected xapp-... prefix). Fix it and re-run."
    fi
    if [[ -n "$SLACK_BOT_TOKEN" && "$SLACK_BOT_TOKEN" != xoxb-* ]]; then
      die "IRIS_SLACK_BOT_TOKEN in /iris/.env looks wrong (expected xoxb-... prefix). Fix it and re-run."
    fi
    AWS_ACCESS_KEY_INPUT="${AWS_ACCESS_KEY_ID:-}"
    AWS_SECRET_KEY_INPUT="${AWS_SECRET_ACCESS_KEY:-}"
    AWS_REGION_INPUT="${AWS_REGION:-}"
    AWS_PROFILE_INPUT="${AWS_PROFILE:-}"
  fi
fi

# ── Map prompted values to .env variable names ────────────────────
# (only needed for --no-keyvault path; Key Vault path populates
#  these from fetch_secret in step 7)
if [[ "$NO_KEYVAULT" == true ]]; then
  case "$IRIS_PROVIDER" in
    anthropic)      ANTHROPIC_API_KEY="${LLM_API_KEY:-}" ;;
    openai)         OPENAI_API_KEY="${LLM_API_KEY:-}" ;;
    azure-foundry)  AZURE_FOUNDRY_KEY="${LLM_API_KEY:-}" ;;
    deepseek)       DEEPSEEK_API_KEY="${LLM_API_KEY:-}" ;;
    mistral)        MISTRAL_API_KEY="${LLM_API_KEY:-}" ;;
    custom)         CUSTOM_API_KEY="${LLM_API_KEY:-}" ;;
    amazon-bedrock) AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_INPUT:-}"
                    AWS_SECRET_ACCESS_KEY="${AWS_SECRET_KEY_INPUT:-}"
                    AWS_REGION="${AWS_REGION_INPUT:-}"
                    AWS_PROFILE="${AWS_PROFILE_INPUT:-}" ;;
  esac
fi

# ── Generate models.json (setup mode only) ────────────────────────────
if [[ "$SETUP_MODE" == true ]]; then
  log_h "Generating models.json"
  TEMPLATE="$( cd "$(dirname "$0")" && pwd )/data/models.json.template"

  if [[ "$IRIS_PROVIDER" == "azure-foundry" && -n "${FOUNDRY_ACCOUNT:-}" ]]; then
    sed "s|<your-account>|${FOUNDRY_ACCOUNT}|g" "$TEMPLATE" > /tmp/iris-models.json

    # Hard guard: never let a malformed baseUrl (e.g. a doubled "cognitiveservices"
    # segment from an un-sanitized account name) reach the live config. This is the
    # exact bug class that caused every LLM call to fail DNS resolution in prod —
    # catch it here at generation time, not at message-send time.
    # NOTE: bash's [[ =~ ]] uses POSIX ERE, which has no backreferences, so we
    # detect "adjacent duplicate label" by splitting the host on "." in awk instead.
    GENERATED_FOUNDRY_URL=$(grep -oE 'https://[^"]+' /tmp/iris-models.json | head -1)
    FOUNDRY_HOST=$(echo "$GENERATED_FOUNDRY_URL" | sed -E 's#https://##; s#/.*##')
    if echo "$FOUNDRY_HOST" | awk -F. '{ for (i=2;i<=NF;i++) if ($i==$(i-1)) exit 0; exit 1 }'; then
      die "Generated Foundry baseUrl looks malformed (repeated '.$(echo "$FOUNDRY_HOST" | awk -F. '{for(i=2;i<=NF;i++) if($i==$(i-1)) print $i}')' segment): $GENERATED_FOUNDRY_URL — check the account name you entered ('$FOUNDRY_ACCOUNT') isn't a full hostname/URL."
    fi
    if ! getent hosts "$FOUNDRY_HOST" >/dev/null 2>&1; then
      log "⚠ Warning: '$FOUNDRY_HOST' does not currently resolve in DNS."
      log "  Double-check the Foundry account name — Iris will fail to reach the LLM until this resolves."
    fi
    log "✓ models.json generated for Foundry account: $FOUNDRY_ACCOUNT"
  elif [[ "$IRIS_PROVIDER" == "anthropic" ]]; then
    cat > /tmp/iris-models.json << 'MODELJSON'
{
  "providers": {
    "anthropic": {
      "baseUrl": "https://api.anthropic.com/v1",
      "api": "anthropic",
      "apiKey": "ANTHROPIC_API_KEY",
      "models": [
        { "id": "claude-sonnet-4",   "name": "Claude Sonnet 4",   "reasoning": false, "input": ["text","image"], "contextWindow": 200000, "maxTokens": 16000, "cost": {"input":0,"output":0,"cacheRead":0,"cacheWrite":0} },
        { "id": "claude-opus-4",     "name": "Claude Opus 4",     "reasoning": true,  "input": ["text","image"], "contextWindow": 200000, "maxTokens": 32000, "cost": {"input":0,"output":0,"cacheRead":0,"cacheWrite":0} },
        { "id": "claude-haiku-4-5",  "name": "Claude Haiku 4.5",  "reasoning": false, "input": ["text","image"], "contextWindow": 200000, "maxTokens": 8096,  "cost": {"input":0,"output":0,"cacheRead":0,"cacheWrite":0} }
      ]
    }
  }
}
MODELJSON
    log "✓ models.json generated for Anthropic"
  elif [[ "$IRIS_PROVIDER" == "openai" ]]; then
    cat > /tmp/iris-models.json << 'MODELJSON'
{
  "providers": {
    "openai": {
      "baseUrl": "https://api.openai.com/v1",
      "api": "openai-responses",
      "apiKey": "OPENAI_API_KEY",
      "models": [
        { "id": "gpt-5.6-luna",  "name": "GPT-5.6 Luna",  "reasoning": true, "input": ["text","image"], "contextWindow": 272000, "maxTokens": 32768, "cost": {"input":0,"output":0,"cacheRead":0,"cacheWrite":0} },
        { "id": "gpt-5.4-mini",  "name": "GPT-5.4 mini",  "reasoning": true, "input": ["text","image"], "contextWindow": 400000, "maxTokens": 32768, "cost": {"input":0,"output":0,"cacheRead":0,"cacheWrite":0} },
        { "id": "gpt-5.6-terra", "name": "GPT-5.6 Terra", "reasoning": true, "input": ["text","image"], "contextWindow": 400000, "maxTokens": 32768, "cost": {"input":0,"output":0,"cacheRead":0,"cacheWrite":0} }
      ]
    }
  }
}
MODELJSON
    log "✓ models.json generated for OpenAI"
  elif [[ "$IRIS_PROVIDER" == "deepseek" ]]; then
    cat > /tmp/iris-models.json << 'MODELJSON'
{
  "providers": {
    "deepseek": {
      "baseUrl": "https://api.deepseek.com/v1",
      "api": "openai-completions",
      "apiKey": "DEEPSEEK_API_KEY",
      "models": [
        { "id": "deepseek-chat",     "name": "DeepSeek V3 (Chat)",     "reasoning": false, "input": ["text"], "contextWindow": 64000, "maxTokens": 8192, "cost": {"input":0,"output":0,"cacheRead":0,"cacheWrite":0} },
        { "id": "deepseek-reasoner", "name": "DeepSeek R1 (Reasoner)", "reasoning": true,  "input": ["text"], "contextWindow": 64000, "maxTokens": 8192, "cost": {"input":0,"output":0,"cacheRead":0,"cacheWrite":0} }
      ]
    }
  }
}
MODELJSON
    log "✓ models.json generated for DeepSeek"
  elif [[ "$IRIS_PROVIDER" == "mistral" ]]; then
    cat > /tmp/iris-models.json << 'MODELJSON'
{
  "providers": {
    "mistral": {
      "baseUrl": "https://api.mistral.ai/v1",
      "api": "openai-completions",
      "apiKey": "MISTRAL_API_KEY",
      "compat": { "supportsStore": false },
      "models": [
        { "id": "codestral-2508",         "name": "Codestral 25.08",    "reasoning": false, "input": ["text"],          "contextWindow": 256000, "maxTokens": 16384, "cost": {"input":0,"output":0,"cacheRead":0,"cacheWrite":0} },
        { "id": "mistral-large-latest",   "name": "Mistral Large",      "reasoning": false, "input": ["text","image"], "contextWindow": 128000, "maxTokens": 16384, "cost": {"input":0,"output":0,"cacheRead":0,"cacheWrite":0} },
        { "id": "mistral-medium-latest",  "name": "Mistral Medium 3.5", "reasoning": false, "input": ["text","image"], "contextWindow": 256000, "maxTokens": 16384, "cost": {"input":0,"output":0,"cacheRead":0,"cacheWrite":0} }
      ]
    }
  }
}
MODELJSON
    log "✓ models.json generated for Mistral"
  elif [[ "$IRIS_PROVIDER" == "custom" && -n "${CUSTOM_BASE_URL:-}" ]]; then
    CUSTOM_PROVIDER_KEY=$(echo "${CUSTOM_PROVIDER_NAME:-custom}" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9-' '-' | sed 's/^-*//; s/-*$//')
    [[ -z "$CUSTOM_PROVIDER_KEY" ]] && CUSTOM_PROVIDER_KEY="custom"
    cat > /tmp/iris-models.json << MODELJSON
{
  "providers": {
    "${CUSTOM_PROVIDER_KEY}": {
      "baseUrl": "${CUSTOM_BASE_URL}",
      "api": "openai-completions",
      "apiKey": "CUSTOM_API_KEY",
      "models": [
        { "id": "${IRIS_MODEL}", "name": "${IRIS_MODEL}", "reasoning": false, "input": ["text"], "contextWindow": 128000, "maxTokens": 16384, "cost": {"input":0,"output":0,"cacheRead":0,"cacheWrite":0} }
      ]
    }
  }
}
MODELJSON
    IRIS_PROVIDER="$CUSTOM_PROVIDER_KEY"
    log "✓ models.json generated for custom provider '$CUSTOM_PROVIDER_KEY' ($CUSTOM_BASE_URL)"
  elif [[ "$IRIS_PROVIDER" == "amazon-bedrock" ]]; then
    BEDROCK_REGION="${AWS_REGION_INPUT:-us-east-1}"
    cat > /tmp/iris-models.json << MODELJSON
{
  "providers": {
    "amazon-bedrock": {
      "baseUrl": "bedrock://${BEDROCK_REGION}",
      "api": "bedrock-converse-stream",
      "apiKey": "AWS_PROFILE",
      "models": [
        { "id": "us.anthropic.claude-sonnet-4-6",            "name": "Claude Sonnet 4.6 (Bedrock)", "reasoning": false, "input": ["text","image"], "contextWindow": 200000, "maxTokens": 16000, "cost": {"input":0,"output":0,"cacheRead":0,"cacheWrite":0} },
        { "id": "us.anthropic.claude-opus-4-6-v1",           "name": "Claude Opus 4.6 (Bedrock)",   "reasoning": true,  "input": ["text","image"], "contextWindow": 200000, "maxTokens": 32000, "cost": {"input":0,"output":0,"cacheRead":0,"cacheWrite":0} },
        { "id": "us.anthropic.claude-sonnet-4-5-20250929-v1:0", "name": "Claude Sonnet 4.5 (Bedrock)", "reasoning": false, "input": ["text","image"], "contextWindow": 200000, "maxTokens": 16000, "cost": {"input":0,"output":0,"cacheRead":0,"cacheWrite":0} },
        { "id": "us.anthropic.claude-haiku-4-5-20251001-v1:0",  "name": "Claude Haiku 4.5 (Bedrock)",  "reasoning": false, "input": ["text","image"], "contextWindow": 200000, "maxTokens": 8096,  "cost": {"input":0,"output":0,"cacheRead":0,"cacheWrite":0} },
        { "id": "amazon.nova-pro-v1:0",                       "name": "Amazon Nova Pro",             "reasoning": false, "input": ["text","image"], "contextWindow": 300000, "maxTokens": 5120,  "cost": {"input":0,"output":0,"cacheRead":0,"cacheWrite":0} }
      ]
    }
  }
}
MODELJSON
    log "✓ models.json generated for Amazon Bedrock (region: $BEDROCK_REGION)"
  else
    cp "$TEMPLATE" /tmp/iris-models.json
    log "⚠ Using template models.json — edit $IRIS_DIR/data/models.json manually if needed"
  fi
  GENERATED_MODELS_JSON=/tmp/iris-models.json
fi

# ────────────────────────────────────────────────────────────
# 4. Public networking (only if IRIS_BASE_DOMAIN set)
#    nginx/certbot install + base config for any domain;
#    DNS + NSG automation is Azure-only.
# ────────────────────────────────────────────────────────────
if [[ -n "$IRIS_BASE_DOMAIN" ]]; then
  log_h "Public networking ($IRIS_BASE_DOMAIN)"

  # nginx + certbot are only needed when serving a public domain, so they are
  # installed here rather than with the base system dependencies.
  if ! command -v nginx &>/dev/null; then
    log "Installing nginx..."
    sudo apt-get install -y nginx
    sudo systemctl enable nginx
  fi
  if ! command -v certbot &>/dev/null; then
    log "Installing certbot..."
    sudo apt-get install -y certbot python3-certbot-nginx
  fi

  PARENT_ZONE=$(echo "$IRIS_BASE_DOMAIN" | sed 's/^[^.]*\.//')

  if [[ "$NO_KEYVAULT" == false ]]; then
    VM_NAME=$(curl -sf -H Metadata:true \
      "http://169.254.169.254/metadata/instance/compute/name?api-version=2021-02-01&format=text" || echo "")
    VM_RG=$(curl -sf -H Metadata:true \
      "http://169.254.169.254/metadata/instance/compute/resourceGroupName?api-version=2021-02-01&format=text" || echo "")

    if [[ -n "$VM_NAME" && -n "$VM_RG" ]]; then
      PUBLIC_IP=$(az network public-ip list -g "$VM_RG" \
        --query "[0].ipAddress" -o tsv 2>/dev/null || echo "")

      if [[ -n "$PUBLIC_IP" ]]; then
        log "VM: $VM_NAME ($VM_RG) — public IP: $PUBLIC_IP"

        DNS_PREFIX=$(echo "$IRIS_BASE_DOMAIN" | cut -d. -f1)
        DNS_ZONE_RG=$(az network dns zone list \
          --query "[?name=='$PARENT_ZONE'].resourceGroup | [0]" -o tsv 2>/dev/null || echo "")

        if [[ -n "$DNS_ZONE_RG" ]]; then
          log "DNS zone: $PARENT_ZONE (rg: $DNS_ZONE_RG)"
          for record in "$DNS_PREFIX" "*.$DNS_PREFIX"; do
            az network dns record-set a create \
              --zone-name "$PARENT_ZONE" -g "$DNS_ZONE_RG" \
              -n "$record" --ttl 300 2>/dev/null || true
            az network dns record-set a remove-record \
              --zone-name "$PARENT_ZONE" -g "$DNS_ZONE_RG" \
              -n "$record" --ipv4-address "$PUBLIC_IP" 2>/dev/null || true
            az network dns record-set a add-record \
              --zone-name "$PARENT_ZONE" -g "$DNS_ZONE_RG" \
              -n "$record" --ipv4-address "$PUBLIC_IP" 2>/dev/null
            log "DNS: $record.$PARENT_ZONE → $PUBLIC_IP"
          done
        else
          log "Warning: DNS zone '$PARENT_ZONE' not found — set DNS records manually"
        fi

        NSG_NAME=$(az network nsg list -g "$VM_RG" --query "[0].name" -o tsv 2>/dev/null || echo "")
        if [[ -n "$NSG_NAME" ]]; then
          for rule in "AllowHTTP:80:100" "AllowHTTPS:443:101"; do
            rname=$(echo "$rule" | cut -d: -f1)
            rport=$(echo "$rule" | cut -d: -f2)
            rprio=$(echo "$rule" | cut -d: -f3)
            existing=$(az network nsg rule show --nsg-name "$NSG_NAME" -g "$VM_RG" -n "$rname" 2>/dev/null || echo "")
            if [[ -z "$existing" ]]; then
              az network nsg rule create --nsg-name "$NSG_NAME" -g "$VM_RG" \
                -n "$rname" --priority "$rprio" \
                --destination-port-ranges "$rport" \
                --access Allow --protocol Tcp --direction Inbound -o none
              log "NSG: opened port $rport"
            else
              log "NSG: port $rport already open"
            fi
          done
        fi
      fi
    fi
  else
    log "Note: DNS/NSG setup skipped (requires Azure CLI). Point your domain's DNS records at this machine manually."
  fi

  [[ -z "$CERTBOT_EMAIL" ]] && CERTBOT_EMAIL="admin@${PARENT_ZONE}"

  log "Writing base nginx config..."
  sudo tee /etc/nginx/sites-available/iris-default > /dev/null <<NGINX
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;
    return 404;
}
NGINX
  sudo ln -sfn /etc/nginx/sites-available/iris-default /etc/nginx/sites-enabled/iris-default
  sudo rm -f /etc/nginx/sites-enabled/default
  sudo nginx -t 2>/dev/null && sudo systemctl reload nginx
  log "nginx ready"
fi

# ────────────────────────────────────────────────────────────
# 5. Resolve Key Vault (skipped when --no-keyvault)
# ────────────────────────────────────────────────────────────
if [[ "$NO_KEYVAULT" == false ]]; then
  log_h "Key Vault"
  if [[ -n "$KV_NAME" ]]; then
    log "Using Key Vault: $KV_NAME"
  elif [[ -n "$KV_RESOURCE_GROUP" ]]; then
    log "Looking up Key Vault in '$KV_RESOURCE_GROUP'..."
    KV_NAME=$(az keyvault list \
      --resource-group "$KV_RESOURCE_GROUP" \
      --query "[0].name" -o tsv 2>/dev/null || true)
  fi
  [[ -z "$KV_NAME" ]] && die "No Key Vault found. Run with --setup, or set KV_NAME=<vault-name>."
  log "Key Vault: $KV_NAME"
fi

# ────────────────────────────────────────────────────────────
# 6. Fetch secrets from Key Vault (skipped when --no-keyvault)
# ────────────────────────────────────────────────────────────
if [[ "$NO_KEYVAULT" == false ]]; then
  log_h "Fetching secrets"

  fetch_secret() {
    az keyvault secret show --vault-name "$KV_NAME" --name "$1" --query value -o tsv 2>/dev/null || true
  }

  ANTHROPIC_API_KEY=$(fetch_secret "ANTHROPIC-API-KEY")
  OPENAI_API_KEY=$(fetch_secret "OPENAI-API-KEY")
  AZURE_FOUNDRY_KEY=$(fetch_secret "AZURE-FOUNDRY-KEY")
  # Pre-rename Key Vaults still have the secret under the old name.
  [[ -z "$AZURE_FOUNDRY_KEY" ]] && AZURE_FOUNDRY_KEY=$(fetch_secret "FOUNDRY-E2-KEY")
  DEEPSEEK_API_KEY=$(fetch_secret "DEEPSEEK-API-KEY")
  MISTRAL_API_KEY=$(fetch_secret "MISTRAL-API-KEY")
  CUSTOM_API_KEY=$(fetch_secret "CUSTOM-API-KEY")
  GITHUB_TOKEN=$(fetch_secret "GITHUB-TOKEN")
  IRIS_GITHUB_ORG=$(fetch_secret "GITHUB-ORG")
  IRIS_GITHUB_REPO=$(fetch_secret "GITHUB-REPO")
  SLACK_APP_TOKEN=$(fetch_secret "SLACK-APP-TOKEN")
  SLACK_BOT_TOKEN=$(fetch_secret "SLACK-BOT-TOKEN")
  TELEGRAM_BOT_TOKEN=$(fetch_secret "TELEGRAM-BOT-TOKEN")
  RESEND_API_KEY=$(fetch_secret "RESEND-API-KEY")
  PERPLEXITY_API_KEY=$(fetch_secret "PERPLEXITY-API-KEY")
  AWS_ACCESS_KEY_ID=$(fetch_secret "AWS-ACCESS-KEY-ID")
  AWS_SECRET_ACCESS_KEY=$(fetch_secret "AWS-SECRET-ACCESS-KEY")
  AWS_REGION=$(fetch_secret "AWS-REGION")
  AWS_PROFILE=$(fetch_secret "AWS-PROFILE")

  if [[ -z "$ANTHROPIC_API_KEY" && -z "$OPENAI_API_KEY" && -z "$AZURE_FOUNDRY_KEY" && -z "$DEEPSEEK_API_KEY" && -z "$MISTRAL_API_KEY" && -z "$CUSTOM_API_KEY" && -z "$AWS_ACCESS_KEY_ID" && -z "$AWS_PROFILE" && "$IRIS_PROVIDER" != "amazon-bedrock" ]]; then
    die "No LLM API key found in Key Vault '$KV_NAME'. Run --setup or seed the key manually."
  fi

  if [[ -z "$SLACK_APP_TOKEN" || -z "$SLACK_BOT_TOKEN" ]]; then
    log "Warning: Slack tokens not found — Iris will start but won't connect to Slack."
    log "  Add SLACK-APP-TOKEN and SLACK-BOT-TOKEN to Key Vault when ready."
  fi
fi

# ────────────────────────────────────────────────────────────
# 7. Workspace setup
# ────────────────────────────────────────────────────────────
log_h "Workspace"
resolve_repo_dir

sudo mkdir -p "$IRIS_DIR"
sudo chown -R "$TARGET_USER:$TARGET_USER" "$IRIS_DIR"

if [[ "$REPO_DIR" == "${IRIS_DIR}/repo" ]]; then
  if [[ ! -d "$REPO_DIR/.git" ]]; then
    [[ -z "$REPO_URL" ]] && REPO_URL=$(git -C "$(dirname "$0")" remote get-url origin 2>/dev/null || true)
    [[ -z "$REPO_URL" ]] && die "Cannot determine repo URL. Set REPO_URL=https://github.com/your-org/iris-core.git"

    if [[ -n "$GITHUB_TOKEN" ]]; then
      git config --global credential.helper store
      REPO_HOST=$(echo "$REPO_URL" | sed 's|https://||' | cut -d/ -f1)
      echo "https://${GITHUB_TOKEN}:x-oauth-basic@${REPO_HOST}" > ~/.git-credentials
      chmod 600 ~/.git-credentials
      log "GitHub credentials stored"
    fi

    log "Cloning $REPO_URL → $REPO_DIR..."
    CURRENT_BRANCH=$(git -C "$(dirname "$0")" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "main")
    git clone --branch "$CURRENT_BRANCH" "$REPO_URL" "$REPO_DIR"
  else
    log "Updating repo at $REPO_DIR..."
    git -C "$REPO_DIR" pull --ff-only 2>/dev/null || log "Warning: could not pull latest — continuing with current checkout"
  fi

  CLEAN_URL=$(git -C "$REPO_DIR" remote get-url origin | sed 's|https://[^@]*@|https://|')
  git -C "$REPO_DIR" remote set-url origin "$CLEAN_URL"

  ORIGIN_URL=$(git -C "$REPO_DIR" remote get-url origin)
  if [[ "$ORIGIN_URL" != "$IRIS_CORE_URL" ]]; then
    if git -C "$REPO_DIR" remote get-url upstream &>/dev/null 2>&1; then
      git -C "$REPO_DIR" remote set-url upstream "$IRIS_CORE_URL"
      log "upstream remote updated → $IRIS_CORE_URL"
    else
      git -C "$REPO_DIR" remote add upstream "$IRIS_CORE_URL"
      log "upstream remote added → $IRIS_CORE_URL"
    fi
    git -C "$REPO_DIR" fetch upstream --tags --quiet 2>/dev/null || log "Warning: could not fetch upstream — continuing (you can run 'git fetch upstream --tags' manually later)"
    log "To take future iris-core updates: git fetch upstream --tags && git rebase <tag>"
    log "  (pick a release tag with 'git tag -l' — not upstream/main; the rebase"
    log "   rewrites history, so push back with --force-with-lease. See docs/overlay.md)"
  fi
else
  [[ -d "$REPO_DIR/.git" ]] || die "REPO_DIR '$REPO_DIR' is not a git checkout."
  log "Using local repo at $REPO_DIR"
  ln -sfn "$REPO_DIR" "$IRIS_DIR/repo"
fi

# Repo-local identity (not global) so Iris's commits are attributed correctly
# without touching any other git config on the box.
git -C "$REPO_DIR" config user.name "Iris"
git -C "$REPO_DIR" config user.email "${GIT_USER_EMAIL:-iris@example.com}"

mkdir -p "$IRIS_DIR/data"
ln -sfn "$REPO_DIR/data/MEMORY.md"       "$IRIS_DIR/data/MEMORY.md"
ln -sfn "$REPO_DIR/data/CONSTITUTION.md" "$IRIS_DIR/data/CONSTITUTION.md"
ln -sfn "$REPO_DIR/skills"          "$IRIS_DIR/data/skills"
ln -sfn "$REPO_DIR/read-handlers"   "$IRIS_DIR/data/read-handlers"

# Skill scripts are documented and invoked by other skills as bare commands,
# but nothing else puts a skill's own directory on PATH — the sandbox executor
# spawns `sh -c "<cmd>"` with the plain inherited PATH. Symlinking them into
# /usr/local/bin (same as iris-secret above) makes the documented bare-command
# usage actually work, instead of relying on the model discovering the "not
# found" failure and falling back to the skill's absolute path every time.
# search-web/transcribe-audio's documented command name doesn't match their
# script's filename (search.sh / transcribe-audio.sh), so those two symlinks
# are named after the documented command, not the source file.
sudo ln -sfn "$REPO_DIR/skills/get-secret/get-secret"     /usr/local/bin/get-secret
sudo ln -sfn "$REPO_DIR/skills/set-secret/set-secret"     /usr/local/bin/set-secret
sudo ln -sfn "$REPO_DIR/skills/schedule/schedule"         /usr/local/bin/schedule
sudo ln -sfn "$REPO_DIR/skills/self-heal/self-heal"       /usr/local/bin/self-heal
sudo ln -sfn "$REPO_DIR/skills/send-email/send-email"     /usr/local/bin/send-email
sudo ln -sfn "$REPO_DIR/skills/serve-public/serve-public" /usr/local/bin/serve-public
sudo ln -sfn "$REPO_DIR/skills/status/status"             /usr/local/bin/status
sudo ln -sfn "$REPO_DIR/skills/upgrade-iris/upgrade-iris" /usr/local/bin/upgrade-iris
sudo ln -sfn "$REPO_DIR/skills/search-web/search.sh"           /usr/local/bin/search-web
sudo ln -sfn "$REPO_DIR/skills/transcribe-audio/transcribe-audio.sh" /usr/local/bin/transcribe-audio

if [[ -n "$GENERATED_MODELS_JSON" && -f "$GENERATED_MODELS_JSON" ]]; then
  cp "$GENERATED_MODELS_JSON" "$IRIS_DIR/data/models.json"
elif [[ -f "$REPO_DIR/data/models.json" ]]; then
  cp "$REPO_DIR/data/models.json" "$IRIS_DIR/data/models.json"
elif [[ -f "$REPO_DIR/data/models.json.template" ]]; then
  cp "$REPO_DIR/data/models.json.template" "$IRIS_DIR/data/models.json"
  log "Warning: using models.json.template — edit $IRIS_DIR/data/models.json to configure your provider"
fi

# Secrets-mode tokens: preserved across re-runs (read back from the existing
# .env), generated once otherwise. Env mode gets neither.
IRIS_API_TOKEN="${IRIS_API_TOKEN:-}"
BROKER_TOKEN="${BROKER_TOKEN:-}"
if [[ "$SECRETS_MODE" != "env" ]]; then
  if [[ -f "$IRIS_DIR/.env" ]]; then
    [[ -z "$IRIS_API_TOKEN" ]] && IRIS_API_TOKEN=$(grep -m1 '^IRIS_API_TOKEN=' "$IRIS_DIR/.env" | cut -d= -f2- || true)
    [[ -z "$BROKER_TOKEN" ]] && BROKER_TOKEN=$(grep -m1 '^IRIS_SECRET_BROKER_TOKEN=' "$IRIS_DIR/.env" | cut -d= -f2- || true)
  fi
  [[ -z "$IRIS_API_TOKEN" ]] && IRIS_API_TOKEN=$(openssl rand -hex 24)
  [[ -z "$BROKER_TOKEN" ]] && BROKER_TOKEN=$(openssl rand -hex 24)
fi

# Write /iris/.env
log "Writing /iris/.env..."
e() { printf '%s' "${1:-}" | tr -d '\n\r'; }  # strip newlines from a value
{
  echo "IRIS_PROVIDER=$(e "$IRIS_PROVIDER")"
  echo "IRIS_MODEL=$(e "$IRIS_MODEL")"
  echo "IRIS_ENV=$(e "$IRIS_ENV")"
  echo ""
  echo "AZURE_FOUNDRY_KEY=$(e "${AZURE_FOUNDRY_KEY:-}")"
  echo "DEEPSEEK_API_KEY=$(e "${DEEPSEEK_API_KEY:-}")"
  echo "MISTRAL_API_KEY=$(e "${MISTRAL_API_KEY:-}")"
  echo "CUSTOM_API_KEY=$(e "${CUSTOM_API_KEY:-}")"
  echo "CUSTOM_BASE_URL=$(e "${CUSTOM_BASE_URL:-}")"
  echo "CUSTOM_PROVIDER_NAME=$(e "${CUSTOM_PROVIDER_NAME:-}")"
  echo "ANTHROPIC_API_KEY=$(e "${ANTHROPIC_API_KEY:-}")"
  echo "OPENAI_API_KEY=$(e "${OPENAI_API_KEY:-}")"
  echo "AWS_ACCESS_KEY_ID=$(e "${AWS_ACCESS_KEY_ID:-}")"
  echo "AWS_SECRET_ACCESS_KEY=$(e "${AWS_SECRET_ACCESS_KEY:-}")"
  echo "AWS_REGION=$(e "${AWS_REGION:-}")"
  echo "AWS_PROFILE=$(e "${AWS_PROFILE:-}")"
  echo ""
  echo "IRIS_SLACK_APP_TOKEN=$(e "${SLACK_APP_TOKEN:-}")"
  echo "IRIS_SLACK_BOT_TOKEN=$(e "${SLACK_BOT_TOKEN:-}")"
  echo "TELEGRAM_BOT_TOKEN=$(e "${TELEGRAM_BOT_TOKEN:-}")"
  echo ""
  echo "GITHUB_TOKEN=$(e "${GITHUB_TOKEN:-}")"
  echo "IRIS_GITHUB_ORG=$(e "${IRIS_GITHUB_ORG:-}")"
  echo "IRIS_GITHUB_REPO=$(e "${IRIS_GITHUB_REPO:-}")"
  echo "RESEND_API_KEY=$(e "${RESEND_API_KEY:-}")"
  echo "PERPLEXITY_API_KEY=$(e "${PERPLEXITY_API_KEY:-}")"
  echo ""
  echo "AZURE_SUBSCRIPTION_ID=$(e "${SUBSCRIPTION_ID:-}")"
  echo "IRIS_KEY_VAULT=$(e "${KV_NAME:-}")"
  echo "IRIS_TF_STORAGE_ACCOUNT=$(e "${SA_NAME:-}")"
  echo "IRIS_REPO_DIR=$(e "$REPO_DIR")"
  echo "IRIS_STORAGE_ROOT=${IRIS_DIR}/data"
  echo ""
  echo "IRIS_BASE_DOMAIN=$(e "${IRIS_BASE_DOMAIN:-}")"
  echo "CERTBOT_EMAIL=$(e "${CERTBOT_EMAIL:-}")"
  echo "GIT_USER_EMAIL=$(e "${GIT_USER_EMAIL:-iris@example.com}")"
  echo ""
  echo "IRIS_SECRETS_MODE=$(e "$SECRETS_MODE")"
  if [[ "$SECRETS_MODE" == "store" ]]; then
    echo "IRIS_SECRET_KEY_FILE=${IRIS_DIR}/secret.key"
    echo "IRIS_SECRET_STORE_FILE=${IRIS_DIR}/secrets.json.enc"
  fi
  if [[ "$SECRETS_MODE" == "proxy" ]]; then
    echo "IRIS_SECRET_BROKER_URL=http://127.0.0.1:${IRIS_BROKER_PORT}"
    echo "IRIS_SECRET_BROKER_TOKEN=$(e "$BROKER_TOKEN")"
  fi
  if [[ "$SECRETS_MODE" != "env" ]]; then
    echo "IRIS_API_TOKEN=$(e "$IRIS_API_TOKEN")"
  fi
} | sudo tee "$IRIS_DIR/.env" > /dev/null
# sudo tee creates the file as root; iris.service runs as User=$TARGET_USER
# and reads this file itself via dotenv/config at process startup (there's no
# systemd EnvironmentFile= — the unit never touches it, so ownership matters).
# Without this chown, iris.service can't read its own config: dotenv silently
# loads nothing, every var falls back to hardcoded defaults, and anything
# resolved through the broker (IRIS_SECRET_BROKER_URL) or Telegram/Slack
# breaks with no obvious error tying it back to a permissions problem.
sudo chown "$TARGET_USER:$TARGET_USER" "$IRIS_DIR/.env"
sudo chmod 600 "$IRIS_DIR/.env"
log "✓ /iris/.env written"

# ────────────────────────────────────────────────────────────
# 8. Build iris-runtime
# ────────────────────────────────────────────────────────────
log_h "Building iris-runtime"
RUNTIME_DIR="$REPO_DIR/iris-runtime"
cd "$RUNTIME_DIR"
npm install --prefer-offline 2>&1 | tail -3
npm run build 2>&1 | tail -5
cd - > /dev/null

# ────────────────────────────────────────────────────────────
# 9b. Secrets mode (store / proxy) — encrypted store, broker daemon, CLI,
#     .env migration. Skipped entirely in env mode (the legacy opt-out;
#     store is the default). See docs/secrets.md.
# ────────────────────────────────────────────────────────────
if [[ "$SECRETS_MODE" != "env" ]]; then
  log_h "Setting up secrets mode: $SECRETS_MODE"

  # iris-secret CLI wrapper — same dotenv preload as the runtime so it reads
  # the store/broker config from /iris/.env.
  sudo tee /usr/local/bin/iris-secret > /dev/null << WRAPPER
#!/usr/bin/env bash
export DOTENV_CONFIG_PATH=${IRIS_DIR}/.env
exec "$(which node)" --require "$REPO_DIR/iris-runtime/node_modules/dotenv/config" "$REPO_DIR/iris-runtime/dist/cli/iris-secret.js" "\$@"
WRAPPER
  sudo chmod 755 /usr/local/bin/iris-secret

  if [[ "$SECRETS_MODE" == "store" ]]; then
    # Key + store live with the runtime user. Same-uid agents can technically
    # read the key — store mode stops accidental leakage, proxy mode adds the
    # uid boundary (docs/secrets.md threat model).
    if [[ ! -f "$IRIS_DIR/secret.key" ]]; then
      openssl rand -hex 32 | sudo tee "$IRIS_DIR/secret.key" > /dev/null
      sudo chown "$TARGET_USER" "$IRIS_DIR/secret.key"
      sudo chmod 600 "$IRIS_DIR/secret.key"
      log "✓ generated $IRIS_DIR/secret.key"
    fi
  else
    # Proxy mode: dedicated system user owns the key/store; the agent and the
    # runtime can only reach secrets through the broker's HTTP API.
    if ! id iris-broker &>/dev/null; then
      sudo useradd --system --home-dir /nonexistent --shell /usr/sbin/nologin iris-broker
      log "✓ created system user iris-broker"
    fi
    sudo mkdir -p "$IRIS_DIR/broker"
    if [[ ! -f "$IRIS_DIR/broker/secret.key" ]]; then
      openssl rand -hex 32 | sudo tee "$IRIS_DIR/broker/secret.key" > /dev/null
      log "✓ generated $IRIS_DIR/broker/secret.key"
    fi
    sudo tee "$IRIS_DIR/broker/broker.env" > /dev/null << BROKERENV
IRIS_BROKER_PORT=${IRIS_BROKER_PORT}
IRIS_SECRET_BROKER_TOKEN=${BROKER_TOKEN}
IRIS_SECRET_KEY_FILE=${IRIS_DIR}/broker/secret.key
IRIS_SECRET_STORE_FILE=${IRIS_DIR}/broker/secrets.json.enc
IRIS_BROKER_SERVICES_FILE=${IRIS_DIR}/broker/services.json
BROKERENV
    if [[ "$IRIS_PROVIDER" == "azure-foundry" && -n "${FOUNDRY_ACCOUNT:-}" ]]; then
      echo "AZURE_FOUNDRY_ACCOUNT=${FOUNDRY_ACCOUNT}" | sudo tee -a "$IRIS_DIR/broker/broker.env" > /dev/null
    fi
    sudo chown -R iris-broker:iris-broker "$IRIS_DIR/broker"
    sudo chmod 700 "$IRIS_DIR/broker"
    sudo chmod 600 "$IRIS_DIR/broker/secret.key" "$IRIS_DIR/broker/broker.env"

    sudo tee /etc/systemd/system/iris-broker.service > /dev/null << UNIT
[Unit]
Description=Iris Credential Broker
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=iris-broker
EnvironmentFile=${IRIS_DIR}/broker/broker.env
ExecStart=$(which node) $REPO_DIR/iris-runtime/dist/broker/main.js
Restart=always
RestartSec=5
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=${IRIS_DIR}/broker
SyslogIdentifier=iris-broker

[Install]
WantedBy=multi-user.target
UNIT
    sudo systemctl daemon-reload
    sudo systemctl enable iris-broker
    sudo systemctl restart iris-broker
    sleep 2
    if ! curl -sf "http://127.0.0.1:${IRIS_BROKER_PORT}/health" > /dev/null; then
      sudo journalctl -u iris-broker -n 30 --no-pager || true
      die "iris-broker.service failed its health check."
    fi
    log "✓ iris-broker running on 127.0.0.1:${IRIS_BROKER_PORT}"
  fi

  # Move known credential vars out of /iris/.env into the store (idempotent;
  # prune rewrites .env without those lines). The runtime resolves them via
  # the store/broker from here on.
  if ! sudo /usr/local/bin/iris-secret import-env "$IRIS_DIR/.env" --prune; then
    # Not fatal: the runtime's store provider falls back to env vars, so any
    # credential import-env didn't reach keeps resolving from .env as before.
    die "iris-secret import-env failed — credentials remain in /iris/.env and keep working via the env fallback; fix the error above and re-run bootstrap."
  fi
  sudo chmod 600 "$IRIS_DIR/.env"
  if [[ "$SECRETS_MODE" == "store" && -f "$IRIS_DIR/secrets.json.enc" ]]; then
    # import-env ran as root; the runtime (TARGET_USER) owns the store file.
    sudo chown "$TARGET_USER" "$IRIS_DIR/secrets.json.enc"
    sudo chmod 600 "$IRIS_DIR/secrets.json.enc"
  fi
  log "✓ secret migration complete"
fi

# ────────────────────────────────────────────────────────────
# 10. Systemd service
# ────────────────────────────────────────────────────────────
log_h "Installing systemd service"
NODE_BIN="$(which node)"
IRIS_RUNTIME_BIN="$REPO_DIR/iris-runtime/dist/main.js"
DOTENV_CONFIG="$RUNTIME_DIR/node_modules/dotenv/config"

# Remove any drop-in overrides that may have been created by previous sessions
# (e.g. switching sandbox to firecracker-pool without Firecracker installed)
sudo rm -rf /etc/systemd/system/iris.service.d

sudo tee /etc/systemd/system/iris.service > /dev/null << UNIT
[Unit]
Description=Iris Meta-Agent (iris-runtime)
After=network-online.target docker.service
Wants=network-online.target

[Service]
Type=simple
User=${TARGET_USER}
WorkingDirectory=${IRIS_DIR}
ExecStart=${NODE_BIN} --require ${DOTENV_CONFIG} ${IRIS_RUNTIME_BIN} --sandbox=host ${IRIS_DIR}/data
Restart=always
RestartSec=10
StandardOutput=append:${IRIS_DIR}/iris-runtime.log
StandardError=append:${IRIS_DIR}/iris-runtime.log
SyslogIdentifier=iris

[Install]
WantedBy=multi-user.target
UNIT

sudo systemctl daemon-reload
sudo systemctl enable iris
sudo systemctl restart iris
sleep 3

if ! sudo systemctl is-active --quiet iris; then
  sudo journalctl -u iris -n 50 --no-pager || true
  die "iris.service failed to start. Check logs above."
fi

# Docker sub-agents service (calls agents/start-all.sh on boot)
sudo tee /etc/systemd/system/iris-agents.service > /dev/null << UNIT
[Unit]
Description=Iris Docker Sub-Agents
After=network-online.target docker.service
Wants=network-online.target docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/bash ${REPO_DIR}/agents/start-all.sh

[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable iris-agents
log "✓ iris-agents.service installed and enabled"

# ────────────────────────────────────────────────────────────
# 11. Provision Firecracker sandbox VM
# ────────────────────────────────────────────────────────────
if [[ "$FIRECRACKER_MODE" == true ]]; then
  log_h "Provisioning Firecracker sandbox VM"

  ROOTFS="/var/lib/iris/firecracker/rootfs.ext4"
  [[ -f "$ROOTFS" ]] || die "Rootfs not found at $ROOTFS — Firecracker build may have failed above."

  if [[ "$NO_KEYVAULT" == true ]]; then
    # ── Direct bash path (no Terraform) ──────────────────────────────
    FC_AGENT_NAME="public-sandbox"
    FC_SLOT=1
    FC_AGENT_DIR="/var/lib/iris/firecracker/agents/${FC_AGENT_NAME}"
    FC_HOST_IP="172.20.${FC_SLOT}.1"
    FC_TAP="vmtap${FC_SLOT}"
    FC_MAC=$(printf "AA:FC:00:00:%02X:02" "$FC_SLOT")
    FC_CONFIG="${FC_AGENT_DIR}/vm-config.json"
    FC_LOG="/var/log/iris-fc-${FC_AGENT_NAME}.log"
    FC_SERVICE="iris-fc-${FC_AGENT_NAME}"

    sudo mkdir -p "$FC_AGENT_DIR"

    if [[ ! -f "${FC_AGENT_DIR}/rootfs.ext4" ]]; then
      log "Copying base rootfs for ${FC_AGENT_NAME}..."
      sudo cp --sparse=always "$ROOTFS" "${FC_AGENT_DIR}/rootfs.ext4"
    fi

    log "Writing vm-config.json..."
    sudo tee "$FC_CONFIG" > /dev/null << JSON
{
  "boot-source": {
    "kernel_image_path": "/var/lib/iris/firecracker/vmlinux",
    "boot_args": "console=ttyS0 reboot=k panic=1 pci=off guestip=172.20.${FC_SLOT}.2 hostip=${FC_HOST_IP}"
  },
  "drives": [
    {
      "drive_id": "rootfs",
      "path_on_host": "${FC_AGENT_DIR}/rootfs.ext4",
      "is_root_device": true,
      "is_read_only": false
    }
  ],
  "machine-config": {
    "vcpu_count": 2,
    "mem_size_mib": 512
  },
  "network-interfaces": [
    {
      "iface_id": "eth0",
      "guest_mac": "${FC_MAC}",
      "host_dev_name": "${FC_TAP}"
    }
  ]
}
JSON

    log "Writing ${FC_SERVICE}.service..."
    sudo tee "/etc/systemd/system/${FC_SERVICE}.service" > /dev/null << UNIT
[Unit]
Description=Iris Firecracker Agent: ${FC_AGENT_NAME}
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
Restart=on-failure
RestartSec=5

ExecStartPre=/bin/bash -c "\
  rm -f /run/iris-fc-${FC_AGENT_NAME}.socket; \
  ip link show ${FC_TAP} &>/dev/null || ip tuntap add dev ${FC_TAP} mode tap; \
  ip addr flush dev ${FC_TAP} 2>/dev/null || true; \
  ip addr add ${FC_HOST_IP}/30 dev ${FC_TAP}; \
  ip link set ${FC_TAP} up; \
  sysctl -w net.ipv4.conf.${FC_TAP}.proxy_arp=1 > /dev/null; \
  sysctl -w net.ipv6.conf.${FC_TAP}.disable_ipv6=1 > /dev/null"

ExecStart=/usr/local/bin/firecracker \
  --api-sock /run/iris-fc-${FC_AGENT_NAME}.socket \
  --config-file ${FC_CONFIG} \
  --log-path ${FC_LOG} \
  --level Info

ExecStopPost=/bin/bash -c "\
  ip link set ${FC_TAP} down 2>/dev/null || true; \
  ip tuntap del dev ${FC_TAP} mode tap 2>/dev/null || true"

StandardOutput=append:${FC_LOG}
StandardError=append:${FC_LOG}

[Install]
WantedBy=multi-user.target
UNIT

    sudo systemctl daemon-reload
    sudo systemctl enable "$FC_SERVICE"
    sudo systemctl restart "$FC_SERVICE"
    log "✓ ${FC_SERVICE} started"

  else
    # ── Terraform path (Azure backend) ───────────────────────────────
    [[ -z "$SA_NAME" ]] && die "SA_NAME is required for Firecracker + Azure path. Re-run with --setup."

    log "Creating Terraform state storage (${SA_NAME})..."
    az group create -n iris-tfstate-rg -l eastus -o none 2>/dev/null || true
    az storage account create \
      -n "$SA_NAME" -g iris-tfstate-rg \
      -l eastus --sku Standard_LRS \
      --min-tls-version TLS1_2 \
      --allow-blob-public-access false \
      -o none 2>/dev/null || true
    az storage container create \
      -n tfstate --account-name "$SA_NAME" --auth-mode login \
      -o none 2>/dev/null || true
    log "✓ Terraform state storage: ${SA_NAME}"

    log "Uncommenting module public_sandbox in agents.tf..."
    AGENTS_TF="${REPO_DIR}/terraform/agents.tf"
    python3 - "$AGENTS_TF" << 'PYEOF'
import sys, re
path = sys.argv[1]
with open(path) as f:
    txt = f.read()
if re.search(r'^module "public_sandbox"', txt, re.M):
    print("  already uncommented")
    sys.exit(0)
marker = 'Uncomment the block below'
idx = txt.rfind(marker)
if idx < 0:
    print("  marker not found — edit terraform/agents.tf manually")
    sys.exit(0)
start = txt.find('# module "public_sandbox"', idx)
end_brace = txt.find('# }', start)
end = txt.find('\n', end_brace) + 1
block = txt[start:end]
uncommented = re.sub(r'^# ?', '', block, flags=re.M)
with open(path, 'w') as f:
    f.write(txt[:start] + uncommented + txt[end:])
print("  ✓ uncommented")
PYEOF

    log "Running terraform init..."
    terraform -chdir="${REPO_DIR}/terraform" init \
      -backend-config="resource_group_name=iris-tfstate-rg" \
      -backend-config="storage_account_name=${SA_NAME}" \
      -backend-config="container_name=tfstate" \
      -backend-config="key=iris-dynamic.terraform.tfstate" \
      -backend-config="use_azuread_auth=true" \
      -reconfigure

    log "Running terraform apply..."
    TF_VAR_subscription_id="$SUBSCRIPTION_ID" \
      terraform -chdir="${REPO_DIR}/terraform" apply -auto-approve
    log "✓ Terraform apply complete"
  fi

  # ────────────────────────────────────────────────────────────
  # 12. Health check + switch iris.service to Firecracker
  # ────────────────────────────────────────────────────────────
  log_h "Firecracker health check"
  log "Waiting for VM at http://${FC_GUEST_IP}:8080/health (up to 20s)..."
  FC_HEALTHY=false
  for i in $(seq 1 20); do
    if curl -sf --max-time 2 "http://${FC_GUEST_IP}:8080/health" > /dev/null 2>&1; then
      log "✓ VM is healthy (${i}s)"
      FC_HEALTHY=true
      break
    fi
    sleep 1
  done

  if [[ "$FC_HEALTHY" == false ]]; then
    sudo journalctl -u iris-fc-public-sandbox -n 30 --no-pager || true
    die "VM did not respond after 20s — check logs above."
  fi

  log_h "Switching Iris to Firecracker sandbox"
  if [[ "$FIRECRACKER_SANDBOX" == "pool" ]]; then
    SANDBOX_FLAG="--sandbox=firecracker-pool"
  else
    SANDBOX_FLAG="--sandbox=firecracker:${FC_GUEST_IP}"
  fi

  sudo mkdir -p /etc/systemd/system/iris.service.d
  sudo tee /etc/systemd/system/iris.service.d/sandbox.conf > /dev/null << DROPIN
[Unit]
After=iris-fc-public-sandbox.service

[Service]
ExecStart=
ExecStart=${NODE_BIN} --require ${DOTENV_CONFIG} ${IRIS_RUNTIME_BIN} ${SANDBOX_FLAG} ${IRIS_DIR}/data
DROPIN

  sudo systemctl daemon-reload
  sudo systemctl restart iris
  sleep 2

  if ! sudo systemctl is-active --quiet iris; then
    sudo journalctl -u iris -n 30 --no-pager || true
    die "iris.service failed to restart in Firecracker mode — check logs above."
  fi
  log "✓ Iris switched to Firecracker mode (${SANDBOX_FLAG})"
fi

# ────────────────────────────────────────────────────────────
# Done
# ────────────────────────────────────────────────────────────
log_h "Done"
log ""
log "  ✓ Iris is running!"
log ""
log "  Status:    sudo systemctl status iris"
log "  Logs:      sudo journalctl -u iris -f"
log "  Provider:  ${IRIS_PROVIDER}/${IRIS_MODEL}"
if [[ "$NO_KEYVAULT" == false ]]; then
log "  Key Vault: ${KV_NAME}"
else
log "  Secrets:   /iris/.env  (edit and restart to update)"
fi
log "  Workspace: ${IRIS_DIR}"
log ""
if [[ -n "${SLACK_APP_TOKEN:-}" ]]; then
  log "  Slack:     @iris in any channel"
else
  log "  Slack:     not configured (add tokens to /iris/.env and restart)"
fi
if [[ -n "${TELEGRAM_BOT_TOKEN:-}" ]]; then
  # Poll for the claim state file — Iris needs a few seconds to connect to Telegram
  # and write it, so a single fixed sleep was flaky. Retry for up to 30s.
  CLAIM_FILE="${IRIS_DIR}/data/meta/telegram-owner.json"
  CLAIM_TOKEN=""
  CLAIM_STATUS="false"
  for _ in $(seq 1 15); do
    sleep 2
    if [[ -r "$CLAIM_FILE" ]]; then
      CLAIM_TOKEN=$(jq -r '.pendingToken // empty' "$CLAIM_FILE" 2>/dev/null || true)
      CLAIM_STATUS=$(jq -r '.claimed // false' "$CLAIM_FILE" 2>/dev/null || echo false)
      [[ -n "$CLAIM_TOKEN" || "$CLAIM_STATUS" == "true" ]] && break
    fi
  done

  if [[ -n "$CLAIM_TOKEN" ]]; then
    log ""
    log "  ┌─ Telegram Claim Token ──────────────────────────────────────────┐"
    log "  │                                                                 │"
    log "  │  Send this token to your bot on Telegram to claim it:          │"
    log "  │                                                                 │"
    log "  │  ${CLAIM_TOKEN}  │"
    log "  │                                                                 │"
    log "  │  Token expires in 10 minutes.                                  │"
    log "  └─────────────────────────────────────────────────────────────────┘"

    # Resolve the bot's @username so the QR code can be a t.me deep link
    # (scan -> chat opens with "/start <token>" pre-filled -> tap Send) instead
    # of just the raw token (scan -> manually copy/paste).
    BOT_USERNAME=$(curl -sf "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe" | jq -r '.result.username // empty' 2>/dev/null || true)
    if [[ -n "$BOT_USERNAME" ]]; then
      CLAIM_QR_PAYLOAD="https://t.me/${BOT_USERNAME}?start=${CLAIM_TOKEN}"
      QR_HINT="scan to open the bot with the token pre-filled"
    else
      CLAIM_QR_PAYLOAD="$CLAIM_TOKEN"
      QR_HINT="scan and paste the token into the chat"
    fi

    if [[ -d "${RUNTIME_DIR}/node_modules/qrcode-terminal" ]]; then
      log ""
      log "  (${QR_HINT})"
      while IFS= read -r qr_line; do log "  $qr_line"; done < <(
        cd "$RUNTIME_DIR" && node -e "require('qrcode-terminal').generate(process.argv[1], { small: true })" "$CLAIM_QR_PAYLOAD"
      )
    else
      log ""
      log "  Tip: run 'sudo journalctl -u iris -n 50' to see this token as a scannable QR code."
    fi
  elif [[ "$CLAIM_STATUS" == "true" ]]; then
    log ""
    log "  ⚠ Telegram bot is already claimed from a previous run (state file: $CLAIM_FILE)."
    log "    If this is a different bot token or a new owner, force a re-claim with:"
    log "    IRIS_TELEGRAM_FORCE_RECLAIM=true sudo systemctl restart iris"
  elif [[ ! -r "$CLAIM_FILE" ]]; then
    log ""
    log "  ⚠ Could not read $CLAIM_FILE after 30s — either Telegram never connected, or"
    log "    iris.service (User=${TARGET_USER}) can't read its own /iris/.env (dotenv fails"
    log "    silently, so every setting falls back to hardcoded defaults, including a"
    log "    disabled Telegram transport)."
    log "    Check both with: ls -la ${IRIS_DIR}/.env ${IRIS_DIR}/data/meta/ && sudo journalctl -u iris -n 30"
  else
    log ""
    log "  ⚠ No Telegram claim token appeared after 30s. Check: sudo journalctl -u iris -n 50"
  fi
fi
if [[ "$FIRECRACKER_MODE" == true ]]; then
  log "  Firecracker: iris-fc-public-sandbox → ${FC_GUEST_IP}"
  log "  Sandbox:     ${SANDBOX_FLAG}"
  [[ "$NO_KEYVAULT" == false ]] && log "  Terraform:   iris-tfstate-rg / ${SA_NAME}"
  log "  VM logs:     journalctl -u iris-fc-public-sandbox -f"
  log "  Test:        @iris run: uname -a"
fi
log ""