bootstrap.sh
Rama main · réplica de lectura del repositorio original.
#!/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 ""