/ install.sh
install.sh
1 #!/usr/bin/env bash 2 # 3 # Ag3ntum One-Command Installer 4 # https://github.com/extractumio/ag3ntum 5 # 6 # Usage: 7 # curl -fsSL https://raw.githubusercontent.com/extractumio/ag3ntum/release/install.sh | bash 8 # 9 # Or download and run: 10 # chmod +x install.sh && ./install.sh 11 # 12 # Options: 13 # --dev Development mode (Vite dev server, clones 'main' branch) 14 # --branch B Clone specific branch (default: 'release') 15 # --help Show help 16 # 17 # This script: 18 # 1. Checks prerequisites (Docker, Git) 19 # 2. Clones the repository (if needed) 20 # 3. Prompts for configuration (API key, admin credentials) 21 # 4. Generates configuration files 22 # 5. Builds and starts containers 23 # 6. Creates admin user 24 # 25 26 set -euo pipefail 27 28 # ============================================================================= 29 # CONSTANTS 30 # ============================================================================= 31 32 REPO_URL="https://github.com/extractumio/ag3ntum.git" 33 MIN_DOCKER_VERSION="20.10" 34 DEFAULT_BRANCH="release" 35 36 # Braille spinner frames (same as web terminal) 37 SPINNER_FRAMES=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') 38 39 # Colors (ANSI escape codes) 40 # Use $'...' syntax so escape sequences are interpreted at assignment time. 41 # Single quotes ('\033[...') store the literal backslash sequence, which 42 # only works with echo -e or printf — plain echo leaves them unrendered. 43 RED=$'\033[0;31m' 44 GREEN=$'\033[0;32m' 45 YELLOW=$'\033[0;33m' 46 BLUE=$'\033[0;34m' 47 CYAN=$'\033[0;36m' 48 WHITE=$'\033[0;37m' 49 BOLD=$'\033[1m' 50 DIM=$'\033[2m' 51 NC=$'\033[0m' # No Color 52 53 # Configuration defaults 54 DEFAULT_API_PORT="40080" 55 DEFAULT_WEB_PORT="50080" 56 DEFAULT_HOSTNAME="localhost" 57 58 # Global state 59 DETECTED_OS="" 60 IN_REPO=0 61 62 # ============================================================================= 63 # UTILITY FUNCTIONS 64 # ============================================================================= 65 66 print_info() { 67 printf "${CYAN}ℹ${NC} %s\n" "$1" 68 } 69 70 print_success() { 71 printf "${GREEN}✓${NC} %s\n" "$1" 72 } 73 74 print_warning() { 75 printf "${YELLOW}⚠${NC} %s\n" "$1" 76 } 77 78 print_error() { 79 printf "${RED}✗${NC} %s\n" "$1" >&2 80 } 81 82 print_step() { 83 printf "\n${BOLD}${BLUE}▶${NC} ${BOLD}%s${NC}\n" "$1" 84 } 85 86 print_dim() { 87 printf "${DIM}%s${NC}\n" "$1" 88 } 89 90 # Animated spinner for long-running operations 91 # Usage: long_command & spinner $! "Message" 92 spinner() { 93 local pid=$1 94 local message=$2 95 local i=0 96 local frame_count=${#SPINNER_FRAMES[@]} 97 98 # Hide cursor 99 printf "\033[?25l" 100 101 while kill -0 "$pid" 2>/dev/null; do 102 printf "\r${CYAN}%s${NC} %s" "${SPINNER_FRAMES[$i]}" "$message" 103 i=$(( (i + 1) % frame_count )) 104 sleep 0.1 105 done 106 107 # Show cursor and clear line 108 printf "\033[?25h" 109 printf "\r\033[K" 110 111 # Check exit status 112 wait "$pid" 113 return $? 114 } 115 116 # Prompt for text input with default value 117 prompt_text() { 118 local prompt=$1 119 local default=$2 120 local var_name=$3 121 local result 122 123 if [[ -n "$default" ]]; then 124 printf "${CYAN}?${NC} %s [${WHITE}%s${NC}]: " "$prompt" "$default" 125 else 126 printf "${CYAN}?${NC} %s: " "$prompt" 127 fi 128 129 read -r result < /dev/tty 130 result="${result:-$default}" 131 eval "$var_name='$result'" 132 } 133 134 # Prompt for password (no echo, with confirmation) 135 prompt_password() { 136 local prompt=$1 137 local var_name=$2 138 local result 139 local confirm 140 141 while true; do 142 printf "${CYAN}?${NC} %s: " "$prompt" 143 read -rs result < /dev/tty 144 printf "\n" 145 146 if [[ ${#result} -lt 8 ]]; then 147 print_warning "Password must be at least 8 characters" 148 continue 149 fi 150 151 printf "${CYAN}?${NC} Confirm password: " 152 read -rs confirm < /dev/tty 153 printf "\n" 154 155 if [[ "$result" != "$confirm" ]]; then 156 print_warning "Passwords do not match" 157 continue 158 fi 159 160 break 161 done 162 163 eval "$var_name='$result'" 164 } 165 166 # Prompt for yes/no answer 167 prompt_yesno() { 168 local prompt=$1 169 local default=${2:-y} 170 local result 171 172 local hint="[Y/n]" 173 [[ "$default" == "n" ]] && hint="[y/N]" 174 175 printf "${CYAN}?${NC} %s %s: " "$prompt" "$hint" 176 read -r result < /dev/tty 177 result="${result:-$default}" 178 179 [[ "$result" =~ ^[Yy] ]] 180 } 181 182 # Validate API key format 183 validate_api_key() { 184 local key=$1 185 if [[ ! "$key" =~ ^sk-ant- ]]; then 186 return 1 187 fi 188 if [[ ${#key} -lt 20 ]]; then 189 return 1 190 fi 191 return 0 192 } 193 194 # Validate port number 195 validate_port() { 196 local port=$1 197 if [[ ! "$port" =~ ^[0-9]+$ ]]; then 198 return 1 199 fi 200 if [[ "$port" -lt 1024 ]] || [[ "$port" -gt 65535 ]]; then 201 return 1 202 fi 203 return 0 204 } 205 206 # Check if a port is available 207 check_port_available() { 208 local port=$1 209 210 if command -v lsof &>/dev/null; then 211 ! lsof -i ":$port" &>/dev/null 212 elif command -v ss &>/dev/null; then 213 ! ss -tuln 2>/dev/null | grep -q ":$port " 214 elif command -v netstat &>/dev/null; then 215 ! netstat -tuln 2>/dev/null | grep -q ":$port " 216 else 217 # Cannot check, assume available 218 return 0 219 fi 220 } 221 222 # Reclaim ownership of files left by a previous build using Docker (no sudo needed). 223 # Container entrypoint chowns data dirs and secrets to UID 45045. On Linux, re-running 224 # the installer without this would fail on file writes. Uses Docker to fix ownership. 225 # macOS/Windows use Docker Desktop which handles permissions transparently. 226 reclaim_previous_build_ownership() { 227 local os_type 228 os_type="$(uname -s)" 229 230 # Only needed on Linux, and only as a non-root user 231 if [[ "${os_type}" != "Linux" ]] || [[ "$(id -u)" == "0" ]]; then 232 return 0 233 fi 234 235 local needs_reclaim=0 236 237 # Check config files (secrets.yaml gets chowned to 45045 by container entrypoint) 238 for f in config/*; do 239 if [[ -f "$f" ]] && [[ ! -w "$f" ]]; then 240 needs_reclaim=1 241 break 242 fi 243 done 244 245 if [[ "$needs_reclaim" == "1" ]]; then 246 print_info "Reclaiming config file ownership from previous build..." 247 docker run --rm -v "$(pwd)/config:/config" alpine chown -R "$(id -u):$(id -g)" /config 248 print_success "Ownership reclaimed" 249 fi 250 } 251 252 # ============================================================================= 253 # PREREQUISITE CHECKS 254 # ============================================================================= 255 256 detect_os() { 257 local os="" 258 local version="" 259 260 case "$(uname -s)" in 261 Darwin) 262 os="macos" 263 version=$(sw_vers -productVersion 2>/dev/null || echo "unknown") 264 ;; 265 Linux) 266 if [[ -f /etc/os-release ]]; then 267 # shellcheck source=/dev/null 268 . /etc/os-release 269 os="${ID:-linux}" 270 version="${VERSION_ID:-unknown}" 271 else 272 os="linux" 273 version="unknown" 274 fi 275 ;; 276 MINGW*|MSYS*|CYGWIN*) 277 os="windows" 278 version="git-bash" 279 ;; 280 *) 281 os="unknown" 282 version="unknown" 283 ;; 284 esac 285 286 DETECTED_OS="$os:$version" 287 } 288 289 check_docker() { 290 if ! command -v docker &>/dev/null; then 291 print_error "Docker is not installed" 292 echo "" 293 case "$DETECTED_OS" in 294 macos*) 295 echo "Install Docker Desktop for Mac:" 296 echo " ${CYAN}https://docs.docker.com/desktop/install/mac-install/${NC}" 297 ;; 298 ubuntu*|debian*) 299 echo "Install Docker on Ubuntu/Debian:" 300 echo " ${CYAN}curl -fsSL https://get.docker.com | sh${NC}" 301 echo " ${CYAN}sudo usermod -aG docker \$USER${NC}" 302 echo "" 303 echo "Then log out and back in (or reboot) for group changes to take effect." 304 ;; 305 *) 306 echo "Install Docker: ${CYAN}https://docs.docker.com/get-docker/${NC}" 307 ;; 308 esac 309 return 1 310 fi 311 312 # Check Docker daemon is running 313 if ! docker info &>/dev/null; then 314 print_error "Docker daemon is not running" 315 echo "" 316 case "$DETECTED_OS" in 317 macos*) 318 echo "Start Docker Desktop and try again." 319 ;; 320 *) 321 echo "Start the Docker service:" 322 echo " ${CYAN}sudo systemctl start docker${NC}" 323 ;; 324 esac 325 return 1 326 fi 327 328 # Get and check version 329 local docker_version 330 docker_version=$(docker version --format '{{.Server.Version}}' 2>/dev/null || echo "0.0") 331 332 # Version comparison 333 local major minor 334 IFS='.' read -r major minor _ <<< "$docker_version" 335 local min_major min_minor 336 IFS='.' read -r min_major min_minor <<< "$MIN_DOCKER_VERSION" 337 338 if [[ "$major" -lt "$min_major" ]] || { [[ "$major" -eq "$min_major" ]] && [[ "$minor" -lt "$min_minor" ]]; }; then 339 print_warning "Docker version $docker_version is older than recommended $MIN_DOCKER_VERSION" 340 fi 341 342 print_success "Docker $docker_version" 343 return 0 344 } 345 346 check_docker_compose() { 347 local compose_cmd="" 348 local compose_version="" 349 350 # Check for docker compose (v2) first 351 if docker compose version &>/dev/null 2>&1; then 352 compose_cmd="docker compose" 353 compose_version=$(docker compose version --short 2>/dev/null || echo "unknown") 354 elif command -v docker-compose &>/dev/null; then 355 compose_cmd="docker-compose" 356 compose_version=$(docker-compose version --short 2>/dev/null || echo "unknown") 357 else 358 print_error "Docker Compose is not installed" 359 echo "" 360 echo "Docker Compose v2 is included with Docker Desktop." 361 echo "For standalone installation:" 362 echo " ${CYAN}https://docs.docker.com/compose/install/${NC}" 363 return 1 364 fi 365 366 print_success "Docker Compose $compose_version ($compose_cmd)" 367 return 0 368 } 369 370 check_git() { 371 if ! command -v git &>/dev/null; then 372 print_error "Git is not installed" 373 echo "" 374 case "$DETECTED_OS" in 375 macos*) 376 echo "Install Git with Xcode Command Line Tools:" 377 echo " ${CYAN}xcode-select --install${NC}" 378 ;; 379 ubuntu*|debian*) 380 echo "Install Git:" 381 echo " ${CYAN}sudo apt-get update && sudo apt-get install -y git${NC}" 382 ;; 383 *) 384 echo "Install Git: ${CYAN}https://git-scm.com/downloads${NC}" 385 ;; 386 esac 387 return 1 388 fi 389 390 local git_version 391 git_version=$(git --version | awk '{print $3}') 392 print_success "Git $git_version" 393 return 0 394 } 395 396 check_curl() { 397 if ! command -v curl &>/dev/null; then 398 print_error "curl is not installed" 399 return 1 400 fi 401 print_success "curl available" 402 return 0 403 } 404 405 run_prerequisite_checks() { 406 print_step "Checking Prerequisites" 407 408 local failed=0 409 410 check_docker || failed=1 411 check_docker_compose || failed=1 412 check_git || failed=1 413 check_curl || failed=1 414 415 if [[ $failed -eq 1 ]]; then 416 echo "" 417 print_error "Please install missing prerequisites and run the installer again." 418 exit 1 419 fi 420 421 print_success "All prerequisites satisfied" 422 } 423 424 # ============================================================================= 425 # REPOSITORY SETUP 426 # ============================================================================= 427 428 setup_repository() { 429 print_step "Setting Up Repository" 430 431 # Check if we're already in the ag3ntum directory (run.sh exists) 432 if [[ -f "run.sh" ]] && [[ -f "docker-compose.yml" ]] && [[ -d "config" ]]; then 433 print_success "Already in Ag3ntum directory" 434 IN_REPO=1 435 return 0 436 fi 437 438 # Check if ag3ntum directory exists in current location 439 if [[ -d "ag3ntum" ]]; then 440 print_info "Found existing ag3ntum directory" 441 cd ag3ntum || exit 1 442 443 if [[ -f "run.sh" ]] && [[ -f "docker-compose.yml" ]]; then 444 print_success "Using existing repository" 445 IN_REPO=1 446 return 0 447 else 448 # Directory exists but is incomplete - fail with clear message 449 print_error "ag3ntum directory exists but is incomplete (missing run.sh or docker-compose.yml)" 450 print_info "Please remove the directory and try again:" 451 echo " rm -rf ag3ntum" 452 exit 1 453 fi 454 fi 455 456 # Clone the repository 457 print_info "Cloning Ag3ntum repository (branch: ${AG3NTUM_BRANCH})..." 458 459 # Clone with progress indicator 460 local clone_output 461 clone_output=$(mktemp) 462 463 (git clone --depth 1 --branch "$AG3NTUM_BRANCH" "$REPO_URL" ag3ntum > "$clone_output" 2>&1) & 464 local clone_pid=$! 465 466 if ! spinner $clone_pid "Cloning repository"; then 467 print_error "Failed to clone repository" 468 cat "$clone_output" 469 rm -f "$clone_output" 470 exit 1 471 fi 472 rm -f "$clone_output" 473 474 cd ag3ntum || exit 1 475 IN_REPO=1 476 print_success "Repository cloned successfully" 477 } 478 479 # ============================================================================= 480 # CONFIGURATION GATHERING 481 # ============================================================================= 482 483 gather_configuration() { 484 print_step "Configuration" 485 486 echo "" 487 print_info "Please provide the following configuration values." 488 print_info "Press Enter to accept defaults shown in [brackets]." 489 echo "" 490 491 # API Key (required, sensitive) 492 while true; do 493 printf "${CYAN}?${NC} Anthropic API Key: " 494 read -rs ANTHROPIC_API_KEY < /dev/tty 495 printf "\n" 496 497 if [[ -z "$ANTHROPIC_API_KEY" ]]; then 498 print_warning "API key is required" 499 print_dim " Get yours at: https://console.anthropic.com/settings/keys" 500 continue 501 fi 502 503 if ! validate_api_key "$ANTHROPIC_API_KEY"; then 504 print_warning "Invalid API key format (should start with 'sk-ant-')" 505 continue 506 fi 507 508 break 509 done 510 print_success "API key configured" 511 512 echo "" 513 514 # Admin credentials 515 print_info "Create admin account" 516 517 prompt_text "Admin username" "admin" ADMIN_USERNAME 518 519 # Validate username 520 while [[ ! "$ADMIN_USERNAME" =~ ^[a-zA-Z0-9_]{3,32}$ ]]; do 521 print_warning "Username must be 3-32 alphanumeric characters or underscore" 522 prompt_text "Admin username" "admin" ADMIN_USERNAME 523 done 524 525 while true; do 526 prompt_text "Admin email" "" ADMIN_EMAIL 527 if [[ "$ADMIN_EMAIL" == *"@"*"."* ]]; then 528 break 529 fi 530 print_warning "Please enter a valid email address" 531 done 532 533 prompt_password "Admin password" ADMIN_PASSWORD 534 535 print_success "Admin credentials configured" 536 537 echo "" 538 539 # Server configuration 540 print_info "Server configuration" 541 542 prompt_text "Public hostname or IP" "$DEFAULT_HOSTNAME" SERVER_HOSTNAME 543 544 # API port 545 while true; do 546 prompt_text "API port" "$DEFAULT_API_PORT" API_PORT 547 if validate_port "$API_PORT"; then 548 if ! check_port_available "$API_PORT"; then 549 print_warning "Port $API_PORT appears to be in use" 550 if ! prompt_yesno "Continue anyway?" "n"; then 551 continue 552 fi 553 fi 554 break 555 fi 556 print_warning "Invalid port number (must be 1024-65535)" 557 done 558 559 # Web UI port (used in both prod and dev modes) 560 while true; do 561 prompt_text "Web UI port" "$DEFAULT_WEB_PORT" WEB_PORT 562 if validate_port "$WEB_PORT"; then 563 if [[ "$WEB_PORT" == "$API_PORT" ]]; then 564 print_warning "Web port must be different from API port" 565 continue 566 fi 567 if ! check_port_available "$WEB_PORT"; then 568 print_warning "Port $WEB_PORT appears to be in use" 569 if ! prompt_yesno "Continue anyway?" "n"; then 570 continue 571 fi 572 fi 573 break 574 fi 575 print_warning "Invalid port number (must be 1024-65535)" 576 done 577 578 print_success "Server configuration complete" 579 } 580 581 # ============================================================================= 582 # CONFIGURATION FILE GENERATION 583 # ============================================================================= 584 585 generate_fernet_key() { 586 # Generate a Fernet-compatible key (32 bytes, base64-encoded) 587 # Fernet requires exactly 32 bytes of URL-safe base64-encoded key 588 if command -v python3 &> /dev/null; then 589 python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" 2>/dev/null 590 elif command -v openssl &> /dev/null; then 591 # Generate 32 random bytes and encode as URL-safe base64 592 openssl rand -base64 32 | tr '+/' '-_' 593 else 594 # Fallback using /dev/urandom 595 head -c 32 /dev/urandom | base64 | tr '+/' '-_' 596 fi 597 } 598 599 generate_secrets_yaml() { 600 print_info "Generating secrets.yaml..." 601 602 # Generate Fernet key for encryption 603 local fernet_key 604 fernet_key=$(generate_fernet_key) 605 606 cat > config/secrets.yaml << EOF 607 _version: "0.2.0" 608 609 # Ag3ntum Secrets Configuration 610 # Generated by install.sh on $(date -Iseconds 2>/dev/null || date) 611 # IMPORTANT: Keep this file secure and never commit to version control 612 613 # Anthropic API Key 614 anthropic_api_key: ${ANTHROPIC_API_KEY} 615 616 # Fernet encryption key for encrypting user API keys in the database 617 # DO NOT change this after users have stored encrypted tokens 618 fernet_key: ${fernet_key} 619 EOF 620 621 # Clear the variable from memory 622 unset ANTHROPIC_API_KEY 623 624 # Container entrypoint will chown 45045 + chmod 600 at startup 625 print_success "secrets.yaml created (container entrypoint will secure permissions)" 626 } 627 628 generate_api_yaml() { 629 print_info "Generating api.yaml..." 630 631 # Copy from example if it exists 632 if [[ -f "config/api.yaml.example" ]]; then 633 cp config/api.yaml.example config/api.yaml 634 else 635 print_warning "api.yaml.example not found, creating minimal config" 636 cat > config/api.yaml << EOF 637 _version: "0.2.0" 638 639 server: 640 hostname: "${SERVER_HOSTNAME}" 641 protocol: "http" 642 trusted_proxies: [] 643 644 api: 645 host: "0.0.0.0" 646 port: 40080 647 external_port: ${API_PORT} 648 reload: false 649 650 web: 651 host: "0.0.0.0" 652 port: 50080 653 external_port: ${WEB_PORT} 654 655 security: 656 enable_security_headers: true 657 validate_host_header: true 658 content_security_policy: "strict" 659 additional_allowed_hosts: [] 660 661 database: 662 path: "./data/ag3ntum.db" 663 664 jwt: 665 algorithm: "HS256" 666 expiry_hours: 168 667 668 redis: 669 url: "redis://redis:6379/0" 670 max_connections: 50 671 socket_timeout: 5.0 672 socket_connect_timeout: 5.0 673 decode_responses: false 674 675 task_queue: 676 auto_resume: 677 enabled: true 678 max_session_age_hours: 6 679 max_resume_attempts: 3 680 resume_delay_seconds: 5 681 queue: 682 enabled: true 683 processing_interval_ms: 500 684 max_queue_size: 1000 685 task_timeout_minutes: 30 686 quotas: 687 global_max_concurrent: 4 688 per_user_max_concurrent: 2 689 per_user_daily_limit: 50 690 EOF 691 print_success "api.yaml created" 692 return 693 fi 694 695 # Update values using sed 696 # macOS and GNU sed have different -i syntax - use function to handle 697 sed_inplace() { 698 if [[ "$(uname)" == "Darwin" ]]; then 699 sed -i '' "$@" 700 else 701 sed -i "$@" 702 fi 703 } 704 705 # Update hostname 706 sed_inplace "s|hostname: \"localhost\"|hostname: \"${SERVER_HOSTNAME}\"|" config/api.yaml 707 708 # Add localhost to additional_allowed_hosts if using a different hostname 709 if [[ "${SERVER_HOSTNAME}" != "localhost" ]]; then 710 sed_inplace "s|additional_allowed_hosts: \[\]|additional_allowed_hosts: [\"localhost\", \"127.0.0.1\"]|" config/api.yaml 711 fi 712 713 # Update API external port (appears in api section) 714 sed_inplace "s|external_port: 40080|external_port: ${API_PORT}|" config/api.yaml 715 716 # Update Web external port (appears in web section) - need to be careful not to change api port 717 # This is a bit tricky since both are "external_port" - we'll use a two-step approach 718 if [[ "$WEB_PORT" != "$DEFAULT_WEB_PORT" ]]; then 719 # Create temp file to track section 720 awk -v api_port="$API_PORT" -v web_port="$WEB_PORT" ' 721 /^api:/ { in_api=1; in_web=0 } 722 /^web:/ { in_api=0; in_web=1 } 723 /^[a-z]/ && !/^api:/ && !/^web:/ { in_api=0; in_web=0 } 724 /external_port:/ { 725 if (in_web) { 726 sub(/external_port: [0-9]+/, "external_port: " web_port) 727 } 728 } 729 { print } 730 ' config/api.yaml > config/api.yaml.tmp && mv config/api.yaml.tmp config/api.yaml 731 fi 732 733 # Clean up any backup files created by sed (e.g., .bak files) 734 rm -f config/api.yaml.bak 2>/dev/null || true 735 736 print_success "api.yaml created" 737 } 738 739 generate_agent_yaml() { 740 print_info "Generating agent.yaml..." 741 742 if [[ -f "config/agent.yaml.example" ]]; then 743 cp config/agent.yaml.example config/agent.yaml 744 print_success "agent.yaml created (using defaults)" 745 else 746 print_warning "agent.yaml.example not found, creating minimal config" 747 cat > config/agent.yaml << 'EOF' 748 _version: "0.2.0" 749 750 # Ag3ntum Agent Configuration 751 agent: 752 default_model: claude-haiku-4-5-20251001 753 models_available: 754 - claude-haiku-4-5-20251001 755 - claude-sonnet-4-5-20250929 756 - claude-opus-4-5-20251101 757 max_turns: 100 758 timeout_seconds: 1800 759 role: default 760 EOF 761 print_success "agent.yaml created (minimal)" 762 fi 763 } 764 765 generate_external_mounts_yaml() { 766 print_info "Generating external-mounts.yaml..." 767 768 if [[ -f "config/external-mounts.yaml.example" ]]; then 769 # Create a minimal version (no external mounts by default) 770 cat > config/external-mounts.yaml << 'EOF' 771 _version: "0.2.0" 772 773 # ============================================================================= 774 # External Mounts Configuration 775 # ============================================================================= 776 # Configure external folder mounts for agent sessions. 777 # See external-mounts.yaml.example for full documentation. 778 # 779 # Mount Types: 780 # - global: Available to ALL users 781 # - per_user: Available only to specified users 782 # 783 # Access Modes: 784 # - ro: Read-only (agent cannot write) 785 # - rw: Read-write (agent can modify) 786 # ============================================================================= 787 788 # Global mounts - available to ALL users 789 global: 790 ro: [] 791 rw: [] 792 793 # Per-user mounts - available only to specified users 794 per_user: 795 ro: [] 796 rw: [] 797 EOF 798 print_success "external-mounts.yaml created (empty - configure as needed)" 799 else 800 print_warning "external-mounts.yaml.example not found, creating minimal config" 801 cat > config/external-mounts.yaml << 'EOF' 802 _version: "0.2.0" 803 804 global: 805 ro: [] 806 rw: [] 807 per_user: 808 ro: [] 809 rw: [] 810 EOF 811 print_success "external-mounts.yaml created (minimal)" 812 fi 813 } 814 815 generate_llm_api_proxy_yaml() { 816 print_info "Generating llm-api-proxy.yaml..." 817 818 if [[ -f "config/llm-api-proxy.yaml.example" ]]; then 819 cp config/llm-api-proxy.yaml.example config/llm-api-proxy.yaml 820 print_success "llm-api-proxy.yaml created (using defaults)" 821 else 822 print_warning "llm-api-proxy.yaml.example not found, creating minimal config" 823 cat > config/llm-api-proxy.yaml << 'EOF' 824 _version: "0.2.0" 825 826 proxy: 827 host: 0.0.0.0 828 port: 8082 829 log_level: INFO 830 enable_streaming: true 831 832 providers: 833 anthropic: 834 type: anthropic 835 base_url: https://api.anthropic.com 836 api_key_env: ANTHROPIC_API_KEY 837 838 models: {} 839 840 routing: 841 default_provider: anthropic 842 allow_unmapped_models: false 843 EOF 844 print_success "llm-api-proxy.yaml created (minimal)" 845 fi 846 } 847 848 generate_configuration() { 849 print_step "Generating Configuration Files" 850 851 # Ensure config directory exists and is writable 852 mkdir -p config 853 if [[ ! -w "config" ]]; then 854 print_error "Config directory is not writable" 855 exit 1 856 fi 857 858 # Reclaim ownership of files left by a previous build. 859 # run.sh build chowns logs/, data/, users/ to UID 45045. install.sh itself 860 # chowns config/secrets.yaml to 45045. On re-install these files become 861 # unwritable by the current user. Reclaim everything we need to touch. 862 reclaim_previous_build_ownership 863 864 # Generate all configuration files 865 generate_secrets_yaml 866 generate_api_yaml 867 generate_agent_yaml 868 generate_external_mounts_yaml 869 generate_llm_api_proxy_yaml 870 871 # Verify all required config files were created 872 local missing_files=() 873 [[ ! -f "config/secrets.yaml" ]] && missing_files+=("secrets.yaml") 874 [[ ! -f "config/api.yaml" ]] && missing_files+=("api.yaml") 875 [[ ! -f "config/agent.yaml" ]] && missing_files+=("agent.yaml") 876 [[ ! -f "config/external-mounts.yaml" ]] && missing_files+=("external-mounts.yaml") 877 [[ ! -f "config/llm-api-proxy.yaml" ]] && missing_files+=("llm-api-proxy.yaml") 878 879 if [[ ${#missing_files[@]} -gt 0 ]]; then 880 print_error "Failed to create config files: ${missing_files[*]}" 881 exit 1 882 fi 883 884 print_success "All configuration files generated successfully" 885 } 886 887 # ============================================================================= 888 # BUILD AND DEPLOY 889 # ============================================================================= 890 891 run_build() { 892 print_step "Building AG3NTUM" 893 894 # Ensure run.sh is executable 895 if [[ ! -x "./run.sh" ]]; then 896 chmod +x ./run.sh 2>/dev/null || { 897 print_error "Cannot make run.sh executable" 898 exit 1 899 } 900 fi 901 902 print_info "This may take 5-10 minutes on first build..." 903 echo "" 904 905 # Export port variables and mode for run.sh 906 export AG3NTUM_API_PORT="$API_PORT" 907 export AG3NTUM_WEB_PORT="$WEB_PORT" 908 export AG3NTUM_MODE="$AG3NTUM_MODE" 909 910 # Build command: add --dev flag if in dev mode 911 local build_flags="--no-cache" 912 if [[ "$AG3NTUM_MODE" == "dev" ]]; then 913 build_flags="--no-cache --dev" 914 fi 915 916 # Run the build with output (use 'build' not 'rebuild' to preserve generated config) 917 if ! ./run.sh build ${build_flags}; then 918 print_error "Build failed" 919 echo "" 920 echo "Check the output above for errors." 921 echo "You can also check Docker logs:" 922 echo " ${CYAN}docker compose logs${NC}" 923 exit 1 924 fi 925 926 print_success "Build completed successfully" 927 928 # Track installed version 929 if [[ -f "VERSION" ]]; then 930 cp VERSION .ag3ntum-version 931 print_success "Version file created (.ag3ntum-version)" 932 fi 933 } 934 935 wait_for_api() { 936 local max_wait=60 937 local interval=2 938 local elapsed=0 939 local health_url="http://localhost:${API_PORT:-40080}/api/v1/health" 940 941 print_info "Waiting for API to be ready..." 942 while [ "$elapsed" -lt "$max_wait" ]; do 943 if curl -sf "$health_url" >/dev/null 2>&1; then 944 print_success "API is ready" 945 return 0 946 fi 947 sleep "$interval" 948 elapsed=$((elapsed + interval)) 949 done 950 951 print_warning "API health check timed out after ${max_wait}s (continuing anyway)" 952 return 0 953 } 954 955 create_admin_user() { 956 print_step "Creating Admin User" 957 958 # Wait for API to be fully initialized (DB migrations, user sync, etc.) 959 # before attempting to create a user via docker exec 960 wait_for_api 961 962 print_info "Creating user: $ADMIN_USERNAME ($ADMIN_EMAIL)" 963 964 # Attempt to create the user, capturing output to detect "already exists" 965 local create_output 966 create_output=$(mktemp) 967 968 if ./run.sh create-user \ 969 --username="$ADMIN_USERNAME" \ 970 --email="$ADMIN_EMAIL" \ 971 --password="$ADMIN_PASSWORD" \ 972 --admin > "$create_output" 2>&1; then 973 cat "$create_output" 974 rm -f "$create_output" 975 print_success "Admin user created" 976 elif grep -qi "already exists" "$create_output"; then 977 # User or Linux account already exists — ask whether to replace or keep 978 rm -f "$create_output" 979 echo "" 980 print_warning "User '$ADMIN_USERNAME' already exists." 981 echo "" 982 echo " ${BOLD}Replace${NC} — Delete and recreate with the new credentials you just entered." 983 echo " ${BOLD}Keep${NC} — Keep the existing account (login with your previous password)." 984 echo "" 985 986 if prompt_yesno "Replace existing user with new credentials?" "n"; then 987 print_info "Replacing user (delete + recreate in single transaction)..." 988 989 # Use a single Python process to delete-if-exists then create. 990 # This avoids the race condition / DB mismatch that occurs when 991 # delete_user.py and create_user.py run as separate processes 992 # while the API server also holds the database open. 993 # Credentials are passed via env vars to avoid shell injection from 994 # special characters in passwords. 995 if ! docker compose exec -T -u root \ 996 -e "_INSTALL_USERNAME=$ADMIN_USERNAME" \ 997 -e "_INSTALL_EMAIL=$ADMIN_EMAIL" \ 998 -e "_INSTALL_PASSWORD=$ADMIN_PASSWORD" \ 999 ag3ntum-api \ 1000 python3 -c ' 1001 import asyncio, sys, os 1002 sys.path.insert(0, "/") 1003 os.environ.setdefault("AG3NTUM_ROOT", "/") 1004 1005 from src.db.database import AsyncSessionLocal, init_db, engine 1006 from src.db import models 1007 from src.services.user_service import user_service 1008 from sqlalchemy import select 1009 1010 username = os.environ["_INSTALL_USERNAME"] 1011 email = os.environ["_INSTALL_EMAIL"] 1012 password = os.environ["_INSTALL_PASSWORD"] 1013 1014 async def replace_user(): 1015 await init_db() 1016 # Step 1: Delete any DB records that would conflict (by username OR email, 1017 # matching the same uniqueness check that create_user uses). 1018 async with AsyncSessionLocal() as db: 1019 result = await db.execute( 1020 select(models.User).where( 1021 (models.User.username == username) | (models.User.email == email) 1022 ) 1023 ) 1024 conflicts = result.scalars().all() 1025 if conflicts: 1026 for row in conflicts: 1027 print(f"Deleting conflicting user: {row.username} ({row.email})") 1028 await user_service.delete_user(db=db, username=row.username, delete_linux_user=True) 1029 else: 1030 print("No conflicting DB records (Linux user may exist, proceeding).") 1031 1032 # Step 2: Create fresh 1033 async with AsyncSessionLocal() as db: 1034 user = await user_service.create_user( 1035 db=db, 1036 username=username, 1037 email=email, 1038 password=password, 1039 role="admin", 1040 ) 1041 print(f"User created: {user.username} (UID {user.linux_uid})") 1042 1043 await engine.dispose() 1044 1045 asyncio.run(replace_user()) 1046 '; then 1047 print_error "Failed to replace admin user" 1048 echo "" 1049 echo "Try manually:" 1050 echo " ${CYAN}./run.sh delete-user --username=$ADMIN_USERNAME --force${NC}" 1051 echo " ${CYAN}./run.sh create-user --username=$ADMIN_USERNAME --email=$ADMIN_EMAIL --password=YOUR_PASSWORD --admin${NC}" 1052 exit 1 1053 fi 1054 print_success "Admin user replaced successfully" 1055 else 1056 print_success "Keeping existing admin user '$ADMIN_USERNAME'" 1057 print_info "Login with your previous credentials" 1058 ADMIN_EMAIL="(previous email)" 1059 fi 1060 else 1061 # Some other failure 1062 cat "$create_output" 1063 rm -f "$create_output" 1064 print_warning "Failed to create admin user automatically" 1065 echo "" 1066 echo "You can create it manually:" 1067 echo " ${CYAN}./run.sh create-user --username=$ADMIN_USERNAME --email=$ADMIN_EMAIL --password=YOUR_PASSWORD --admin${NC}" 1068 fi 1069 1070 # Restart API so the process inherits the new user's group (Gotcha #12). 1071 # Without this, session directory access fails with PermissionError. 1072 print_info "Restarting API to activate user access..." 1073 docker compose restart ag3ntum-api 2>/dev/null || true 1074 wait_for_api 1075 1076 # Clear password from memory 1077 unset ADMIN_PASSWORD 1078 } 1079 1080 # ============================================================================= 1081 # COMPLETION 1082 # ============================================================================= 1083 1084 show_banner() { 1085 printf "${CYAN}" 1086 cat << 'EOF' 1087 1088 _ ____ _____ _ 1089 / \ / ___|___ / _ __ | |_ _ _ _ __ ___ 1090 / _ \| | _ |_ \| '_ \| __| | | | '_ ` _ \ 1091 / ___ \ |_| |___) | | | | |_| |_| | | | | | | 1092 /_/ \_\____|____/|_| |_|\__|\__,_|_| |_| |_| 1093 1094 EOF 1095 printf "${NC}" 1096 printf "${WHITE}Self-hosted Claude Code execution platform${NC}\n" 1097 echo "" 1098 } 1099 1100 show_completion() { 1101 echo "" 1102 printf "${GREEN}" 1103 cat << 'EOF' 1104 ___ _ _ _ _ _ ____ _ _ _ 1105 |_ _|_ __ ___| |_ __ _| | | __ _| |_(_) ___ _ __ / ___|___ _ __ ___ _ __ | | ___| |_ ___| | 1106 | || '_ \/ __| __/ _` | | |/ _` | __| |/ _ \| '_ \ | | / _ \| '_ ` _ \| '_ \| |/ _ \ __/ _ \ | 1107 | || | | \__ \ || (_| | | | (_| | |_| | (_) | | | | | |__| (_) | | | | | | |_) | | __/ || __/_| 1108 |___|_| |_|___/\__\__,_|_|_|\__,_|\__|_|\___/|_| |_| \____\___/|_| |_| |_| .__/|_|\___|\__\___(_) 1109 |_| 1110 EOF 1111 printf "${NC}" 1112 echo "" 1113 1114 local protocol="http" 1115 local api_url="${protocol}://${SERVER_HOSTNAME}:${API_PORT}" 1116 local web_url="${protocol}://${SERVER_HOSTNAME}:${WEB_PORT}" 1117 1118 printf "${BOLD}Web Interface:${NC} ${CYAN}%s${NC}\n" "$web_url" 1119 printf "${BOLD}API Endpoint:${NC} ${CYAN}%s/api/v1${NC}\n" "$api_url" 1120 if [[ "$AG3NTUM_MODE" == "dev" ]]; then 1121 print_dim " (Dev mode: Vite dev server with HMR)" 1122 else 1123 print_dim " (Prod mode: pre-built static bundle)" 1124 fi 1125 echo "" 1126 printf "${BOLD}Login with:${NC}\n" 1127 printf " Email: ${WHITE}%s${NC}\n" "$ADMIN_EMAIL" 1128 printf " Password: ${WHITE}(the password you entered)${NC}\n" 1129 echo "" 1130 printf "${YELLOW}Useful commands:${NC}\n" 1131 echo " ./run.sh restart # Restart after code changes" 1132 echo " ./run.sh cleanup # Stop and remove containers" 1133 echo " ./run.sh create-user # Create additional users" 1134 echo " ./run.sh shell # Shell into container" 1135 echo " docker compose logs -f # View logs" 1136 echo "" 1137 1138 if [[ "$SERVER_HOSTNAME" != "localhost" ]] && [[ "$SERVER_HOSTNAME" != "127.0.0.1" ]]; then 1139 printf "${YELLOW}Firewall note:${NC} Ensure ports ${WEB_PORT} (Web) and ${API_PORT} (API) are open.\n" 1140 echo "" 1141 fi 1142 1143 print_success "Ag3ntum is ready to use!" 1144 } 1145 1146 # ============================================================================= 1147 # CLEANUP HANDLER 1148 # ============================================================================= 1149 1150 cleanup_on_exit() { 1151 # Clear any sensitive variables that might still be set 1152 unset ANTHROPIC_API_KEY 2>/dev/null || true 1153 unset ADMIN_PASSWORD 2>/dev/null || true 1154 1155 # Show cursor (in case hidden by spinner) 1156 printf "\033[?25h" 2>/dev/null || true 1157 } 1158 1159 trap cleanup_on_exit EXIT INT TERM 1160 1161 # ============================================================================= 1162 # MAIN 1163 # ============================================================================= 1164 1165 show_install_usage() { 1166 echo "Ag3ntum Installer" 1167 echo "" 1168 echo "Usage: install.sh [OPTIONS]" 1169 echo "" 1170 echo "Options:" 1171 echo " --dev Development mode (Vite dev server, clones 'main' branch)" 1172 echo " --branch B Clone specific branch (default: '${DEFAULT_BRANCH}')" 1173 echo " --help, -h Show this help" 1174 echo "" 1175 echo "Default: production mode from '${DEFAULT_BRANCH}' branch" 1176 echo "" 1177 echo "Examples:" 1178 echo " install.sh # Production from 'release' branch" 1179 echo " install.sh --dev # Development from 'main' branch" 1180 echo " install.sh --branch main # Production from 'main' branch" 1181 } 1182 1183 main() { 1184 # Parse arguments 1185 AG3NTUM_BRANCH="${DEFAULT_BRANCH}" 1186 AG3NTUM_MODE="prod" 1187 1188 while [[ $# -gt 0 ]]; do 1189 case "$1" in 1190 --dev) 1191 AG3NTUM_MODE="dev" 1192 AG3NTUM_BRANCH="main" 1193 shift 1194 ;; 1195 --branch) 1196 AG3NTUM_BRANCH="${2:?'--branch requires a value'}" 1197 shift 2 1198 ;; 1199 --branch=*) 1200 AG3NTUM_BRANCH="${1#*=}" 1201 shift 1202 ;; 1203 --help|-h) 1204 show_install_usage 1205 exit 0 1206 ;; 1207 *) 1208 print_error "Unknown argument: $1" 1209 show_install_usage 1210 exit 1 1211 ;; 1212 esac 1213 done 1214 1215 # Show welcome banner 1216 show_banner 1217 1218 # Detect operating system 1219 detect_os 1220 1221 print_dim "Detected OS: $DETECTED_OS" 1222 print_dim "Mode: ${AG3NTUM_MODE} | Branch: ${AG3NTUM_BRANCH}" 1223 echo "" 1224 1225 # Check prerequisites 1226 run_prerequisite_checks 1227 1228 # Setup repository (clone if needed) 1229 setup_repository 1230 1231 # Gather configuration from user 1232 gather_configuration 1233 1234 # Generate configuration files 1235 generate_configuration 1236 1237 # Build and deploy 1238 run_build 1239 1240 # Create admin user 1241 create_admin_user 1242 1243 # Show completion message 1244 show_completion 1245 } 1246 1247 # Run main function with all script arguments 1248 main "$@"