#!/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= 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 # ".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 ":<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||${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 </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=." 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 " 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 ""` 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 " 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 ""