/ run.sh
run.sh
1 #!/usr/bin/env bash 2 set -euo pipefail 3 4 ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 5 cd "${ROOT_DIR}" 6 7 # Arrays for mount options (format: "path:name" or "path:name:mode" for dynamic) 8 MOUNTS_RW=() 9 MOUNTS_RO=() 10 MOUNTS_USER_RW=() 11 MOUNTS_USER_RO=() 12 MOUNTS_DYNAMIC=() # Dynamic mount bases (format: "path:name:mode") 13 MOUNTS_ORIGINAL=() # Original-path mounts (format: "path:encoded:mode") 14 15 # Track used mount names to detect duplicates (space-separated string for Bash 3 compat) 16 USED_MOUNT_NAMES="" 17 18 # Configuration 19 IMAGE_PREFIX="ag3ntum" # Image name prefix 20 CONTAINER_UID="45045" # UID of ag3ntum_api user inside container 21 22 # Config file registry: "relative_path:tier" 23 # REQUIRED_SECRET = fail with instructions if missing (contains credentials) 24 # REQUIRED_SAFE = auto-create from .example template if missing 25 CONFIG_REGISTRY=( 26 "config/secrets.yaml:REQUIRED_SECRET" 27 "config/agent.yaml:REQUIRED_SAFE" 28 "config/api.yaml:REQUIRED_SAFE" 29 "config/external-mounts.yaml:REQUIRED_SAFE" 30 "config/llm-api-proxy.yaml:REQUIRED_SAFE" 31 ) 32 33 # Reserved mount names that cannot be used 34 RESERVED_NAMES=("persistent" "ro" "rw" "external" "dynamic") 35 36 # Safely remove a file that may be owned by container UID (from a previous build). 37 # Uses Docker to remove files that the host user cannot — no sudo needed. 38 function safe_remove_file() { 39 local file="$1" 40 [[ ! -e "${file}" ]] && return 0 41 42 # Try normal rm first (covers macOS, Windows, and Linux files owned by current user) 43 if rm -f "${file}" 2>/dev/null; then 44 return 0 45 fi 46 47 # Fall back to Docker for container-owned files (no sudo needed) 48 local dir 49 dir="$(dirname "${file}")" 50 local base 51 base="$(basename "${file}")" 52 docker run --rm -v "$(pwd)/${dir}:/work" alpine rm -f "/work/${base}" 2>/dev/null || { 53 echo "Warning: Cannot remove ${file} (owned by container UID ${CONTAINER_UID}). Continuing." 54 return 0 55 } 56 } 57 58 # Directories that container needs to WRITE to (ownership managed by container entrypoint) 59 # Note: node_modules uses a named Docker volume at /app/node_modules (outside /src:ro mount) 60 WRITABLE_DIRS=("logs" "data" "users") 61 62 # Directories that container only READS from (just need to exist) 63 READABLE_DIRS=("config" "src" "prompts" "skills" "tools" "tests" "auto-generated") 64 65 function show_usage() { 66 cat <<EOF 67 Usage: ./run.sh <command> [OPTIONS] 68 69 Commands: 70 setup Install dev tools (Python venv + Node deps + pre-commit hooks) 71 build Build and deploy the containers 72 cleanup Stop containers and remove images (full cleanup) 73 restart Restart containers to reload code (preserves data) 74 rebuild Full cleanup + build (equivalent to: cleanup && build) 75 test Run tests inside the Docker container 76 lint Run linters (flake8, bandit, mypy, eslint, tsc, structural) 77 audit Run dependency vulnerability scan (pip-audit) 78 shell Open a shell inside the API container 79 create-user Create a new user account (uses AG3NTUM_UID_MODE setting) 80 delete-user Delete a user account 81 cleanup-test-users Remove test users created during testing 82 83 UID Security Modes: 84 AG3NTUM_UID_MODE=isolated (default) UIDs 50000-60000, multi-tenant safe 85 AG3NTUM_UID_MODE=direct UIDs map to host (1000-65533), dev/single-tenant 86 See docs/UID-SECURITY.md for details 87 88 Options: 89 --dev Development mode (Vite dev server with HMR on separate port) 90 --mount-rw=PATH:NAME Mount host PATH as read-write (accessible at ./external/rw/NAME) 91 --mount-rw=PATH Mount host PATH as read-write (name defaults to basename) 92 --mount-ro=PATH:NAME Mount host PATH as read-only (accessible at ./external/ro/NAME) 93 --mount-ro=PATH Mount host PATH as read-only (name defaults to basename) 94 --no-cache Force rebuild without Docker cache (for build/rebuild) 95 --help Show this help message 96 97 Deployment Modes: 98 prod (default) Web container serves pre-built static bundle (fast startup) 99 dev (--dev) Web container runs Vite dev server with HMR (hot-reload) 100 101 Test Options (for 'test' command): 102 (no args) Run ALL tests (backend + security + E2E + UI) 103 --quick Run only quick tests (exclude E2E and slow tests) 104 --backend Run only backend tests (Python/pytest) 105 --ui Run only UI tests (React/vitest) 106 --core Run only core agent tests (orchestration, hooks, patterns) 107 108 External Mount Configuration: 109 Mounts can be configured via: 110 1. CLI arguments (--mount-ro, --mount-rw) - highest priority 111 2. YAML config file (config/external-mounts.yaml) - for persistent config 112 113 To use YAML config: 114 cp config/external-mounts.yaml.example config/external-mounts.yaml 115 # Edit the file with your mounts 116 ./run.sh build 117 118 External Mount Examples (CLI): 119 # Mount Downloads folder as read-only, accessible at ./external/ro/downloads/ 120 ./run.sh build --mount-ro=/Users/greg/Downloads:downloads 121 122 # Mount projects folder as read-write, accessible at ./external/rw/projects/ 123 ./run.sh build --mount-rw=/home/user/projects:projects 124 125 # Multiple mounts with custom names 126 ./run.sh build \\ 127 --mount-ro=/data/datasets:ml-data \\ 128 --mount-rw=/home/user/code:workspace 129 130 # Auto-named mounts (uses basename of path) 131 ./run.sh build --mount-ro=/Users/greg/Downloads # -> ./external/ro/Downloads/ 132 133 Mount Structure in Agent Sessions: 134 /workspace/ 135 ├── external/ 136 │ ├── ro/ # Read-only mounts (agent cannot write) 137 │ │ └── {name}/ # Your mounted folders 138 │ ├── rw/ # Read-write mounts (agent can modify) 139 │ │ └── {name}/ # Your mounted folders 140 │ └── persistent/ # Per-user storage (survives across sessions) 141 └── (session files) 142 143 General Examples: 144 ./run.sh build 145 ./run.sh build --no-cache 146 ./run.sh cleanup 147 ./run.sh restart 148 ./run.sh rebuild --no-cache 149 ./run.sh test # Run ALL tests (backend + UI) 150 ./run.sh test --quick # Run quick tests only (no E2E/slow) 151 ./run.sh test --backend # Run backend tests only 152 ./run.sh test --ui # Run UI/React tests only 153 ./run.sh test --core # Run core agent tests only 154 ./run.sh shell # Open shell in container 155 156 Multi-Instance (Worktrees): 157 ./worktree.sh create <branch> # Create worktree with isolated Docker stack 158 ./worktree.sh list # List all instances with ports and status 159 ./worktree.sh destroy <name> # Stop stack and remove worktree 160 See: ./worktree.sh help 161 162 CLI Hints: 163 View logs: docker compose logs -f ag3ntum-api 164 API health: curl http://localhost:40080/api/v1/health 165 Redis CLI: docker compose exec redis redis-cli 166 Shell: docker compose exec ag3ntum-api bash 167 Stop all: docker compose down 168 EOF 169 } 170 171 # Setup directories — just ensure they exist. 172 # Permissions are managed by the container entrypoint (runs as root before dropping to 45045). 173 function setup_directories() { 174 echo "=== Setting up directories ===" 175 176 for dir in "${WRITABLE_DIRS[@]}" "${READABLE_DIRS[@]}"; do 177 if [[ ! -d "${dir}" ]]; then 178 echo " Creating ${dir}/" 179 mkdir -p "${dir}" 180 fi 181 done 182 183 # Ensure skills directory is world-readable so sandbox users (running as 184 # unprivileged UIDs in bubblewrap) can read skill files via "other" permissions 185 if [[ -d "skills" ]]; then 186 chmod -R o+rX skills 2>/dev/null || true 187 fi 188 189 echo " Directories ready (permissions managed by container entrypoint)" 190 } 191 192 # Validate config files from CONFIG_REGISTRY. 193 # Auto-creates REQUIRED_SAFE configs from .example templates. 194 # Fails with instructions for missing REQUIRED_SECRET configs. 195 function validate_and_provision_configs() { 196 local missing_secrets=() 197 198 for entry in "${CONFIG_REGISTRY[@]}"; do 199 local cfg="${entry%%:*}" 200 local tier="${entry##*:}" 201 202 # Already exists — nothing to do 203 if [[ -f "${cfg}" ]]; then 204 continue 205 fi 206 207 if [[ "${tier}" == "REQUIRED_SAFE" ]]; then 208 # Auto-create from .example template 209 if [[ "${cfg}" == "config/external-mounts.yaml" ]]; then 210 # Special case: .example contains sample paths that fail mount validation. 211 # Create a minimal empty config instead. 212 cat > "${cfg}" <<'EXTMOUNTS' 213 _version: "0.2.0" 214 215 # External mounts configuration 216 # See external-mounts.yaml.example for documentation and examples. 217 original_paths: 218 ro: [] 219 rw: [] 220 EXTMOUNTS 221 echo "INFO: Created ${cfg} (minimal empty config)" 222 elif [[ -f "${cfg}.example" ]]; then 223 cp "${cfg}.example" "${cfg}" 224 echo "INFO: Created ${cfg} from ${cfg}.example — review and adjust as needed" 225 else 226 echo "WARNING: ${cfg} is missing and no .example template found" 227 fi 228 elif [[ "${tier}" == "REQUIRED_SECRET" ]]; then 229 missing_secrets+=("${cfg}") 230 fi 231 done 232 233 if [[ ${#missing_secrets[@]} -gt 0 ]]; then 234 echo "" 235 echo "ERROR: Required secret configuration files are missing:" 236 for cfg in "${missing_secrets[@]}"; do 237 echo " - ${cfg}" 238 if [[ -f "${cfg}.example" ]]; then 239 echo " Create from example: cp ${cfg}.example ${cfg}" 240 fi 241 done 242 echo "" 243 echo "These files contain credentials and cannot be auto-generated." 244 echo "Run install.sh for guided setup, or create them manually from .example files." 245 exit 1 246 fi 247 } 248 249 # Validate and process a mount specification 250 # Usage: validate_mount "path" "name" "mode" 251 # Returns: validated "real_path:safe_name" or exits on error 252 function validate_mount() { 253 local path="$1" 254 local name="$2" 255 local mode="$3" # "ro" or "rw" 256 257 # Check path exists 258 if [[ ! -e "$path" ]]; then 259 echo "ERROR: Mount path does not exist: $path" >&2 260 exit 1 261 fi 262 263 # Resolve symlinks and get real path 264 local real_path 265 real_path="$(cd "$path" 2>/dev/null && pwd)" || { 266 # If cd fails, try realpath (for files) 267 real_path="$(realpath "$path" 2>/dev/null)" || { 268 echo "ERROR: Cannot resolve path: $path" >&2 269 exit 1 270 } 271 } 272 273 # Warn if original path was a symlink (security audit) 274 # Compare the user-provided path with the resolved real path 275 local user_realpath 276 user_realpath="$(realpath "$path" 2>/dev/null || echo "$path")" 277 if [[ -L "$path" ]] || [[ "$user_realpath" != "$path" && "$user_realpath" != "$real_path" ]]; then 278 echo "WARNING: Mount path is/contains symlink: $path -> $real_path" >&2 279 echo " Using resolved path for security" >&2 280 fi 281 282 # Validate name - alphanumeric, dash, underscore only 283 if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then 284 echo "ERROR: Invalid mount name '$name' - only alphanumeric, dash, underscore allowed" >&2 285 exit 1 286 fi 287 288 # Check name length 289 if [[ ${#name} -gt 64 ]]; then 290 echo "ERROR: Mount name too long (max 64 chars): $name" >&2 291 exit 1 292 fi 293 294 # Check reserved names (case-insensitive, Bash 3 compat) 295 local name_lower 296 name_lower=$(echo "$name" | tr '[:upper:]' '[:lower:]') 297 for reserved in "${RESERVED_NAMES[@]}"; do 298 local reserved_lower 299 reserved_lower=$(echo "$reserved" | tr '[:upper:]' '[:lower:]') 300 if [[ "$name_lower" == "$reserved_lower" ]]; then 301 echo "ERROR: Reserved mount name cannot be used: $name" >&2 302 exit 1 303 fi 304 done 305 306 # Check for duplicate names (using string matching for Bash 3 compat) 307 if [[ " ${USED_MOUNT_NAMES} " == *" ${name} "* ]]; then 308 echo "ERROR: Duplicate mount name: $name" >&2 309 exit 1 310 fi 311 USED_MOUNT_NAMES="${USED_MOUNT_NAMES} ${name}" 312 313 # Warn about potentially sensitive paths 314 local sensitive_patterns=( 315 "/etc" 316 "/var/log" 317 "/root" 318 "/.ssh" 319 "/private/etc" 320 ) 321 for pattern in "${sensitive_patterns[@]}"; do 322 if [[ "$real_path" == *"$pattern"* ]]; then 323 echo "WARNING: Mounting potentially sensitive path: $real_path" >&2 324 break 325 fi 326 done 327 328 echo "${real_path}:${name}" 329 } 330 331 # Load mounts from YAML configuration file 332 function load_mounts_from_yaml() { 333 local config_file="config/external-mounts.yaml" 334 335 if [[ ! -f "${config_file}" ]]; then 336 # No YAML config file - that's OK, use CLI args only 337 return 0 338 fi 339 340 echo "Loading mounts from ${config_file}..." 341 342 # Parse YAML config using Python helper script 343 local mounts_output 344 mounts_output=$(python3 scripts/parse_mounts_config.py --config "${config_file}" 2>&1) || { 345 echo "ERROR: Failed to parse ${config_file}:" >&2 346 echo "${mounts_output}" >&2 347 exit 1 348 } 349 350 # Process each mount line 351 while IFS= read -r line; do 352 if [[ -z "${line}" ]]; then 353 continue 354 fi 355 356 # Format: MOUNT_RO:path:name or MOUNT_RW:path:name 357 local mount_type="${line%%:*}" 358 local rest="${line#*:}" 359 local mount_path="${rest%%:*}" 360 local mount_name="${rest##*:}" 361 362 if [[ "${mount_type}" == "MOUNT_RO" ]]; then 363 # Validate and add global RO mount 364 local validated 365 validated="$(validate_mount "$mount_path" "$mount_name" "ro")" || exit 1 366 MOUNTS_RO+=("$validated") 367 echo " Added global RO mount: ${mount_name} -> ${mount_path}" 368 elif [[ "${mount_type}" == "MOUNT_RW" ]]; then 369 # Validate and add global RW mount 370 local validated 371 validated="$(validate_mount "$mount_path" "$mount_name" "rw")" || exit 1 372 MOUNTS_RW+=("$validated") 373 echo " Added global RW mount: ${mount_name} -> ${mount_path}" 374 elif [[ "${mount_type}" == "MOUNT_USER_RO" ]]; then 375 # Validate and add per-user RO mount (mounted at /mounts/user-ro/{name}) 376 local validated 377 validated="$(validate_mount "$mount_path" "$mount_name" "user-ro")" || exit 1 378 MOUNTS_USER_RO+=("$validated") 379 echo " Added per-user RO mount: ${mount_name} -> ${mount_path}" 380 elif [[ "${mount_type}" == "MOUNT_USER_RW" ]]; then 381 # Validate and add per-user RW mount (mounted at /mounts/user-rw/{name}) 382 local validated 383 validated="$(validate_mount "$mount_path" "$mount_name" "user-rw")" || exit 1 384 MOUNTS_USER_RW+=("$validated") 385 echo " Added per-user RW mount: ${mount_name} -> ${mount_path}" 386 elif [[ "${mount_type}" == "MOUNT_DYNAMIC" ]]; then 387 # Dynamic mount base (format: MOUNT_DYNAMIC:path:name:mode) 388 # The mode comes from the third field 389 local mount_mode="${rest##*:}" 390 # Re-extract name without mode 391 rest="${line#*:}" 392 rest="${rest#*:}" 393 mount_name="${rest%%:*}" 394 mount_mode="${rest##*:}" 395 396 # Handle paths with {username} placeholder - skip validation for those 397 if [[ "${mount_path}" == *"{username}"* ]]; then 398 # Skip path existence validation for user-templated paths 399 MOUNTS_DYNAMIC+=("${mount_path}:${mount_name}:${mount_mode}") 400 echo " Added dynamic base (templated): ${mount_name} -> ${mount_path} [${mount_mode}]" 401 else 402 local validated 403 validated="$(validate_mount "$mount_path" "$mount_name" "dynamic")" || exit 1 404 MOUNTS_DYNAMIC+=("${validated}:${mount_mode}") 405 echo " Added dynamic base: ${mount_name} -> ${mount_path} [${mount_mode}]" 406 fi 407 elif [[ "${mount_type}" == "MOUNT_ORIGINAL" ]]; then 408 # Original-path mount (format: MOUNT_ORIGINAL:path:encoded:mode) 409 # These mount host paths at /mounts/paths/{encoded} for access at original locations 410 rest="${line#*:}" 411 local orig_path="${rest%%:*}" 412 rest="${rest#*:}" 413 local encoded="${rest%%:*}" 414 local mount_mode="${rest##*:}" 415 416 # Validate the original path exists 417 if [[ -e "${orig_path}" ]]; then 418 MOUNTS_ORIGINAL+=("${orig_path}:${encoded}:${mount_mode}") 419 echo " Added original-path mount: ${orig_path} -> /mounts/paths/${encoded} [${mount_mode}]" 420 else 421 echo " Skipping original-path mount (path not found): ${orig_path}" 422 fi 423 fi 424 done <<< "${mounts_output}" 425 } 426 427 # Parse arguments 428 ACTION="" 429 NO_CACHE="" 430 TEST_ARGS=() 431 while [[ $# -gt 0 ]]; do 432 case "$1" in 433 setup|build|cleanup|restart|rebuild|test|lint|audit|shell|create-user|delete-user|cleanup-test-users) 434 ACTION="$1" 435 shift 436 # For test command, collect remaining args 437 if [[ "${ACTION}" == "test" ]]; then 438 while [[ $# -gt 0 ]]; do 439 TEST_ARGS+=("$1") 440 shift 441 done 442 fi 443 # For create-user command, collect remaining args 444 if [[ "${ACTION}" == "create-user" ]]; then 445 while [[ $# -gt 0 ]]; do 446 TEST_ARGS+=("$1") 447 shift 448 done 449 fi 450 # For delete-user command, collect remaining args 451 if [[ "${ACTION}" == "delete-user" ]]; then 452 while [[ $# -gt 0 ]]; do 453 TEST_ARGS+=("$1") 454 shift 455 done 456 fi 457 # For cleanup-test-users command, collect remaining args 458 if [[ "${ACTION}" == "cleanup-test-users" ]]; then 459 while [[ $# -gt 0 ]]; do 460 TEST_ARGS+=("$1") 461 shift 462 done 463 fi 464 ;; 465 --mount-rw=*) 466 mount_spec="${1#--mount-rw=}" 467 if [[ "$mount_spec" == *:* ]]; then 468 mount_path="${mount_spec%%:*}" 469 mount_name="${mount_spec##*:}" 470 else 471 mount_path="$mount_spec" 472 mount_name="$(basename "$mount_path")" 473 fi 474 validated="$(validate_mount "$mount_path" "$mount_name" "rw")" 475 MOUNTS_RW+=("$validated") 476 shift 477 ;; 478 --mount-ro=*) 479 mount_spec="${1#--mount-ro=}" 480 if [[ "$mount_spec" == *:* ]]; then 481 mount_path="${mount_spec%%:*}" 482 mount_name="${mount_spec##*:}" 483 else 484 mount_path="$mount_spec" 485 mount_name="$(basename "$mount_path")" 486 fi 487 validated="$(validate_mount "$mount_path" "$mount_name" "ro")" 488 MOUNTS_RO+=("$validated") 489 shift 490 ;; 491 --dev) 492 AG3NTUM_MODE="dev" 493 shift 494 ;; 495 --no-cache) 496 NO_CACHE="--no-cache" 497 shift 498 ;; 499 --help|-h) 500 show_usage 501 exit 0 502 ;; 503 *) 504 echo "Unknown option: $1" 505 show_usage 506 exit 1 507 ;; 508 esac 509 done 510 511 if [[ -z "${ACTION}" ]]; then 512 show_usage 513 exit 1 514 fi 515 516 # ============================================================================= 517 # Mode Detection (prod vs dev) 518 # ============================================================================= 519 # Priority: CLI --dev flag > AG3NTUM_MODE env var > .env file > default (prod) 520 # 521 # Both modes serve Web UI on WEB_PORT (50080) and API on API_PORT (40080). 522 # 523 # prod (default): Web container serves pre-built static bundle (fast startup, 524 # no node_modules, no npm install). 525 # dev: Web container runs Vite dev server with HMR and hot-reload. 526 if [[ -z "${AG3NTUM_MODE:-}" ]]; then 527 if [[ -f .env ]] && grep -q '^AG3NTUM_MODE=' .env; then 528 AG3NTUM_MODE="$(grep '^AG3NTUM_MODE=' .env | cut -d= -f2)" 529 else 530 AG3NTUM_MODE="prod" 531 fi 532 fi 533 534 # Compose command varies by mode 535 if [[ "${AG3NTUM_MODE}" == "dev" ]]; then 536 COMPOSE_CMD="docker compose -f docker-compose.yml -f docker-compose.dev.yml" 537 else 538 COMPOSE_CMD="docker compose" 539 fi 540 541 # Compose command that always includes the web service with dev overlay (for UI tests). 542 # In prod mode, docker-compose.yml already includes the web service, but UI tests need 543 # the dev overlay for node_modules volume and Vite dev server. 544 COMPOSE_WITH_WEB="docker compose -f docker-compose.yml -f docker-compose.dev.yml" 545 546 function read_config_value() { 547 local key="$1" 548 local default="${2:-}" 549 local config_file="config/api.yaml" 550 551 # Return default if config file doesn't exist 552 if [[ ! -f "$config_file" ]]; then 553 echo "$default" 554 return 555 fi 556 557 # Split key into section and field (e.g., "api.external_port" -> "api" "external_port") 558 local section="${key%%.*}" 559 local field="${key##*.}" 560 561 # Parse simple nested YAML without external dependencies 562 # Handles format: section: 563 # field: value 564 local value 565 value=$(awk -v section="$section" -v field="$field" ' 566 BEGIN { in_section = 0 } 567 # Match section header (starts at column 0, ends with colon) 568 /^[a-zA-Z_][a-zA-Z0-9_]*:/ { 569 gsub(/:.*/, "", $0) 570 in_section = ($0 == section) ? 1 : 0 571 next 572 } 573 # Match field within section (indented, has colon) 574 in_section && /^[[:space:]]+[a-zA-Z_][a-zA-Z0-9_]*:/ { 575 # Extract field name (remove leading whitespace and trailing colon) 576 fname = $0 577 gsub(/^[[:space:]]+/, "", fname) 578 gsub(/:.*/, "", fname) 579 if (fname == field) { 580 # Extract value (everything after first colon, trimmed) 581 val = $0 582 sub(/^[^:]*:[[:space:]]*/, "", val) 583 gsub(/^["'\'']|["'\'']$/, "", val) # Remove quotes 584 print val 585 exit 586 } 587 } 588 ' "$config_file") 589 590 # Return value if found, otherwise default 591 if [[ -n "$value" ]]; then 592 echo "$value" 593 else 594 echo "$default" 595 fi 596 } 597 598 function render_ui_config() { 599 # Read server configuration with defaults 600 local HOSTNAME 601 local PROTOCOL 602 HOSTNAME="$(read_config_value 'server.hostname' 'localhost')" 603 PROTOCOL="$(read_config_value 'server.protocol' 'http')" 604 605 local target="src/web_terminal_client/public/config.yaml" 606 607 # Remove existing file first (may be owned by container user from previous build) 608 safe_remove_file "${target}" 609 610 cat > "${target}" <<EOF 611 server: 612 port: ${WEB_PORT} 613 host: "0.0.0.0" 614 615 api: 616 # API URL derived from server.hostname and server.protocol in api.yaml 617 # Frontend will replace "localhost" with browser hostname if accessed remotely 618 base_url: "${PROTOCOL}://${HOSTNAME}:${API_PORT}" 619 620 ui: 621 max_output_lines: 1000 622 auto_scroll: true 623 EOF 624 625 echo " Frontend config: ${PROTOCOL}://${HOSTNAME}:${API_PORT}" 626 } 627 628 function generate_compose_override() { 629 # Generate docker-compose.override.yml with extra mounts if any were specified 630 local override_file="docker-compose.override.yml" 631 local manifest_file="auto-generated/auto-generated-mounts.yaml" 632 633 # Ensure the auto-generated directory exists 634 mkdir -p "auto-generated" 2>/dev/null || true 635 636 # Remove existing generated files (may be owned by root from previous container build) 637 safe_remove_file "${override_file}" 638 safe_remove_file "${manifest_file}" 639 640 if [[ ${#MOUNTS_RW[@]} -eq 0 && ${#MOUNTS_RO[@]} -eq 0 && ${#MOUNTS_USER_RW[@]} -eq 0 && ${#MOUNTS_USER_RO[@]} -eq 0 && ${#MOUNTS_DYNAMIC[@]} -eq 0 ]]; then 641 # No mounts specified, create empty manifest (override already removed above) 642 cat > "${manifest_file}" <<EOF 643 # ============================================================================= 644 # AUTO-GENERATED FILE - DO NOT EDIT 645 # ============================================================================= 646 # This file is automatically generated by run.sh from config/external-mounts.yaml 647 # Any manual changes will be overwritten on the next deployment. 648 # 649 # To configure mounts, edit: config/external-mounts.yaml 650 # Then run: ./run.sh build 651 # 652 # Purpose: This manifest maps Docker container paths to host filesystem paths, 653 # enabling symlink resolution when running outside Docker (development mode). 654 # ============================================================================= 655 mounts: 656 ro: [] 657 rw: [] 658 EOF 659 return 660 fi 661 662 # Generate docker-compose override for volume mounts 663 cat > "${override_file}" <<EOF 664 # Auto-generated by run.sh - do not edit manually 665 # External mounts are available in agent sessions at: 666 # Read-only: /workspace/external/ro/{name}/ 667 # Read-write: /workspace/external/rw/{name}/ 668 # Persistent: /workspace/persistent/ 669 services: 670 ag3ntum-api: 671 volumes: 672 EOF 673 674 # Start manifest file - write header only 675 cat > "${manifest_file}" <<EOF 676 # ============================================================================= 677 # AUTO-GENERATED FILE - DO NOT EDIT 678 # ============================================================================= 679 # This file is automatically generated by run.sh from config/external-mounts.yaml 680 # Any manual changes will be overwritten on the next deployment. 681 # 682 # To configure mounts, edit: config/external-mounts.yaml 683 # Then run: ./run.sh build 684 # 685 # Purpose: This manifest maps Docker container paths to host filesystem paths, 686 # enabling symlink resolution when running outside Docker (development mode). 687 # These mounts are available in agent sessions at /workspace/external/ 688 # ============================================================================= 689 mounts: 690 EOF 691 692 # Write RO section (flattened: /mounts/{name} instead of /mounts/ro/{name}) 693 if [[ ${#MOUNTS_RO[@]} -gt 0 ]]; then 694 echo " ro:" >> "${manifest_file}" 695 for mount in "${MOUNTS_RO[@]}"; do 696 local abs_path="${mount%%:*}" 697 local name="${mount##*:}" 698 echo " - ${abs_path}:/mounts/${name}:ro" >> "${override_file}" 699 echo " - name: \"${name}\"" >> "${manifest_file}" 700 echo " host_path: \"${abs_path}\"" >> "${manifest_file}" 701 echo " container_path: \"/mounts/${name}\"" >> "${manifest_file}" 702 echo " workspace_path: \"./external/ro/${name}\"" >> "${manifest_file}" 703 echo " mount_type: \"global_ro\"" >> "${manifest_file}" 704 echo " mode: \"ro\"" >> "${manifest_file}" 705 done 706 else 707 echo " ro: []" >> "${manifest_file}" 708 fi 709 710 # Write RW section (flattened: /mounts/{name} instead of /mounts/rw/{name}) 711 if [[ ${#MOUNTS_RW[@]} -gt 0 ]]; then 712 echo " rw:" >> "${manifest_file}" 713 for mount in "${MOUNTS_RW[@]}"; do 714 local abs_path="${mount%%:*}" 715 local name="${mount##*:}" 716 echo " - ${abs_path}:/mounts/${name}:rw" >> "${override_file}" 717 echo " - name: \"${name}\"" >> "${manifest_file}" 718 echo " host_path: \"${abs_path}\"" >> "${manifest_file}" 719 echo " container_path: \"/mounts/${name}\"" >> "${manifest_file}" 720 echo " workspace_path: \"./external/rw/${name}\"" >> "${manifest_file}" 721 echo " mount_type: \"global_rw\"" >> "${manifest_file}" 722 echo " mode: \"rw\"" >> "${manifest_file}" 723 done 724 else 725 echo " rw: []" >> "${manifest_file}" 726 fi 727 728 # Write per-user RO section (flattened: /mounts/{name} instead of /mounts/user-ro/{name}) 729 if [[ ${#MOUNTS_USER_RO[@]} -gt 0 ]]; then 730 echo " user-ro:" >> "${manifest_file}" 731 for mount in "${MOUNTS_USER_RO[@]}"; do 732 local abs_path="${mount%%:*}" 733 local name="${mount##*:}" 734 echo " - ${abs_path}:/mounts/${name}:ro" >> "${override_file}" 735 echo " - name: \"${name}\"" >> "${manifest_file}" 736 echo " host_path: \"${abs_path}\"" >> "${manifest_file}" 737 echo " container_path: \"/mounts/${name}\"" >> "${manifest_file}" 738 echo " workspace_path: \"./external/user-ro/${name}\"" >> "${manifest_file}" 739 echo " mount_type: \"user_ro\"" >> "${manifest_file}" 740 echo " mode: \"ro\"" >> "${manifest_file}" 741 done 742 else 743 echo " user-ro: []" >> "${manifest_file}" 744 fi 745 746 # Write per-user RW section (flattened: /mounts/{name} instead of /mounts/user-rw/{name}) 747 if [[ ${#MOUNTS_USER_RW[@]} -gt 0 ]]; then 748 echo " user-rw:" >> "${manifest_file}" 749 for mount in "${MOUNTS_USER_RW[@]}"; do 750 local abs_path="${mount%%:*}" 751 local name="${mount##*:}" 752 echo " - ${abs_path}:/mounts/${name}:rw" >> "${override_file}" 753 echo " - name: \"${name}\"" >> "${manifest_file}" 754 echo " host_path: \"${abs_path}\"" >> "${manifest_file}" 755 echo " container_path: \"/mounts/${name}\"" >> "${manifest_file}" 756 echo " workspace_path: \"./external/user-rw/${name}\"" >> "${manifest_file}" 757 echo " mount_type: \"user_rw\"" >> "${manifest_file}" 758 echo " mode: \"rw\"" >> "${manifest_file}" 759 done 760 else 761 echo " user-rw: []" >> "${manifest_file}" 762 fi 763 764 # Write dynamic mount bases section (flattened: /mounts/{name} instead of /mounts/dynamic/{name}) 765 if [[ ${#MOUNTS_DYNAMIC[@]} -gt 0 ]]; then 766 echo " dynamic:" >> "${manifest_file}" 767 for mount in "${MOUNTS_DYNAMIC[@]}"; do 768 # Format: path:name:mode 769 local abs_path="${mount%%:*}" 770 local rest="${mount#*:}" 771 local name="${rest%%:*}" 772 local mode="${rest##*:}" 773 774 # Skip paths with {username} placeholder for Docker volume (validated at session time) 775 if [[ "${abs_path}" == *"{username}"* ]]; then 776 echo " - name: \"${name}\"" >> "${manifest_file}" 777 echo " host_path: \"${abs_path}\"" >> "${manifest_file}" 778 echo " container_path: \"/mounts/${name}\"" >> "${manifest_file}" 779 echo " max_mode: \"${mode}\"" >> "${manifest_file}" 780 echo " mount_type: \"dynamic\"" >> "${manifest_file}" 781 echo " has_placeholder: true" >> "${manifest_file}" 782 echo " Note: Dynamic base '${name}' has {username} placeholder - Docker volume skipped" 783 else 784 # Regular path - add Docker volume 785 local docker_mode="ro" 786 if [[ "${mode}" == "rw" ]]; then 787 docker_mode="rw" 788 fi 789 echo " - ${abs_path}:/mounts/${name}:${docker_mode}" >> "${override_file}" 790 echo " - name: \"${name}\"" >> "${manifest_file}" 791 echo " host_path: \"${abs_path}\"" >> "${manifest_file}" 792 echo " container_path: \"/mounts/${name}\"" >> "${manifest_file}" 793 echo " max_mode: \"${mode}\"" >> "${manifest_file}" 794 echo " mount_type: \"dynamic\"" >> "${manifest_file}" 795 echo " has_placeholder: false" >> "${manifest_file}" 796 fi 797 done 798 else 799 echo " dynamic: []" >> "${manifest_file}" 800 fi 801 802 # Generate original-path mounts (if any) 803 # These mount paths at /mounts/paths/{encoded} for access at original locations 804 if [[ ${#MOUNTS_ORIGINAL[@]} -gt 0 ]]; then 805 echo " original_paths:" >> "${manifest_file}" 806 for mount in "${MOUNTS_ORIGINAL[@]}"; do 807 # Format: path:encoded:mode 808 local orig_path="${mount%%:*}" 809 local rest="${mount#*:}" 810 local encoded="${rest%%:*}" 811 local mode="${rest##*:}" 812 813 # Get absolute path 814 local abs_path 815 abs_path="$(cd "$(dirname "${orig_path}")" 2>/dev/null && pwd)/$(basename "${orig_path}")" 816 817 # Add Docker volume at /mounts/paths/{encoded} 818 echo " - ${abs_path}:/mounts/paths/${encoded}:${mode}" >> "${override_file}" 819 820 # Add to manifest 821 echo " - path: \"${orig_path}\"" >> "${manifest_file}" 822 echo " encoded: \"${encoded}\"" >> "${manifest_file}" 823 echo " host_path: \"${abs_path}\"" >> "${manifest_file}" 824 echo " container_path: \"/mounts/paths/${encoded}\"" >> "${manifest_file}" 825 echo " mode: \"${mode}\"" >> "${manifest_file}" 826 echo " mount_type: \"original_path\"" >> "${manifest_file}" 827 done 828 else 829 echo " original_paths: []" >> "${manifest_file}" 830 fi 831 832 echo "" 833 echo "=== External Mounts Configured ===" 834 echo "Generated ${override_file}" 835 echo "Generated ${manifest_file}" 836 echo "" 837 if [[ ${#MOUNTS_RO[@]} -gt 0 ]]; then 838 echo "Read-only mounts (agent cannot modify):" 839 for mount in "${MOUNTS_RO[@]}"; do 840 local name="${mount##*:}" 841 echo " ./external/ro/${name}/" 842 done 843 fi 844 if [[ ${#MOUNTS_RW[@]} -gt 0 ]]; then 845 echo "Read-write mounts (agent can modify):" 846 for mount in "${MOUNTS_RW[@]}"; do 847 local name="${mount##*:}" 848 echo " ./external/rw/${name}/" 849 done 850 fi 851 if [[ ${#MOUNTS_DYNAMIC[@]} -gt 0 ]]; then 852 echo "Dynamic mount bases (user-selectable per session):" 853 for mount in "${MOUNTS_DYNAMIC[@]}"; do 854 local rest="${mount#*:}" 855 local name="${rest%%:*}" 856 local mode="${rest##*:}" 857 echo " ./${name}/ [max: ${mode}]" 858 done 859 fi 860 if [[ ${#MOUNTS_ORIGINAL[@]} -gt 0 ]]; then 861 echo "Original-path mounts (accessible at original locations):" 862 for mount in "${MOUNTS_ORIGINAL[@]}"; do 863 local orig_path="${mount%%:*}" 864 local rest="${mount#*:}" 865 local mode="${rest##*:}" 866 echo " ${orig_path} [${mode}]" 867 done 868 fi 869 echo "Persistent storage (always available):" 870 echo " ./persistent/" 871 echo "" 872 } 873 874 function check_services() { 875 local missing=0 876 local running 877 running="$(${COMPOSE_CMD} ps --status running --services || true)" 878 if ! grep -q "ag3ntum-api" <<<"${running}"; then 879 echo "Service not running: ag3ntum-api" 880 missing=1 881 fi 882 if ! grep -q "ag3ntum-web" <<<"${running}"; then 883 echo "Service not running: ag3ntum-web" 884 missing=1 885 fi 886 return "${missing}" 887 } 888 889 function do_cleanup() { 890 # Determine project name for scoped cleanup 891 local project_name="" 892 if [[ -f .env ]] && grep -q '^COMPOSE_PROJECT_NAME=' .env; then 893 project_name="$(grep '^COMPOSE_PROJECT_NAME=' .env | cut -d= -f2)" 894 else 895 project_name="$(basename "$(pwd)" | tr '[:upper:]' '[:lower:]')" 896 fi 897 898 echo "=== Starting cleanup for instance: ${project_name} ===" 899 900 # Step 1: Stop and remove project containers (scoped by COMPOSE_PROJECT_NAME in .env) 901 # Use --remove-orphans to also clean up dev-mode web containers when in prod mode 902 echo "Stopping containers..." 903 ${COMPOSE_CMD} down --remove-orphans --timeout 10 2>/dev/null || true 904 905 # Step 2: Remove project-specific volumes 906 echo "Removing project volumes..." 907 docker volume ls --filter "name=${project_name}_" -q 2>/dev/null | xargs -r docker volume rm 2>/dev/null || true 908 909 # Step 3: Remove project-specific networks 910 echo "Removing project networks..." 911 docker network ls --filter "name=${project_name}_" -q 2>/dev/null | xargs -r docker network rm 2>/dev/null || true 912 913 # Step 3.5: Reclaim ownership of container-owned directories (no sudo needed) 914 local os_type 915 os_type="$(uname -s)" 916 if [[ "${os_type}" == "Linux" ]] && [[ "$(id -u)" != "0" ]]; then 917 echo "Reclaiming directory ownership..." 918 local me 919 me="$(id -u):$(id -g)" 920 # Use any available ag3ntum image (still exists at this point, before image removal) 921 local reclaim_image 922 reclaim_image=$(docker images --format '{{.Repository}}:{{.Tag}}' | grep "^${IMAGE_PREFIX}:" | head -1 || true) 923 if [[ -z "${reclaim_image}" ]]; then 924 reclaim_image="alpine" 925 fi 926 local mount_args="" 927 for dir in logs data users; do 928 if [[ -d "$dir" ]]; then 929 mount_args="${mount_args} -v $(pwd)/${dir}:/${dir}" 930 fi 931 done 932 if [[ -n "${mount_args}" ]]; then 933 docker run --rm ${mount_args} "${reclaim_image}" chown -R "${me}" /logs /data /users 2>/dev/null || true 934 fi 935 # Also reclaim config/secrets.yaml 936 if [[ -f "config/secrets.yaml" ]] && [[ ! -w "config/secrets.yaml" ]]; then 937 docker run --rm -v "$(pwd)/config:/config" "${reclaim_image}" chown "${me}" /config/secrets.yaml 2>/dev/null || true 938 fi 939 fi 940 941 # Step 4: Remove ag3ntum images only if no other ag3ntum instances are running 942 # After docker compose down above, our containers are stopped. Check if any 943 # still-running containers use ag3ntum images (= other worktree instances). 944 # This avoids false positives from unrelated compose projects (postgres, etc.). 945 local other_ag3ntum 946 other_ag3ntum=$(docker ps --format '{{.Image}}' 2>/dev/null | grep "^${IMAGE_PREFIX}:" || true) 947 if [[ -z "${other_ag3ntum}" ]]; then 948 echo "No other instances running. Removing ${IMAGE_PREFIX} images..." 949 local images 950 images=$(docker images --format '{{.Repository}}:{{.Tag}}' | grep "^${IMAGE_PREFIX}:" || true) 951 if [[ -n "${images}" ]]; then 952 echo " Removing: ${images}" 953 echo "${images}" | xargs -r docker rmi -f 2>/dev/null || true 954 fi 955 956 # Remove third-party images pulled by this compose file (e.g. redis) 957 # only if no other containers still reference them 958 local compose_images 959 compose_images=$(${COMPOSE_CMD} config --images 2>/dev/null || true) 960 for img in ${compose_images}; do 961 # Skip ag3ntum images — already handled above 962 [[ "${img}" == "${IMAGE_PREFIX}:"* ]] && continue 963 if docker images --format '{{.Repository}}:{{.Tag}}' | grep -qF "${img}"; then 964 local users 965 users=$(docker ps -a --filter "ancestor=${img}" -q 2>/dev/null || true) 966 if [[ -z "${users}" ]]; then 967 echo " Removing ${img}..." 968 docker rmi "${img}" 2>/dev/null || true 969 else 970 echo " Preserving ${img} — still used by other containers." 971 fi 972 fi 973 done 974 975 # Also remove any dangling images 976 local dangling 977 dangling=$(docker images -q --filter "dangling=true" 2>/dev/null || true) 978 if [[ -n "${dangling}" ]]; then 979 echo " Removing dangling images..." 980 echo "${dangling}" | xargs -r docker rmi -f 2>/dev/null || true 981 fi 982 else 983 echo "Other ag3ntum instances still running — preserving shared images." 984 fi 985 986 # Step 5: Remove generated files 987 echo "Removing generated files..." 988 rm -f docker-compose.override.yml 989 rm -f .env.bak 990 rm -f src/web_terminal_client/public/config.yaml 2>/dev/null || true 991 992 # Step 6: Check for orphaned processes on configured ports 993 echo "Checking for orphaned processes on configured ports..." 994 local api_port="${1:-40080}" 995 local web_port="${2:-50080}" 996 997 # Check if ports are in use by non-docker processes 998 for port in "${api_port}" "${web_port}"; do 999 local pid 1000 pid=$(lsof -ti ":${port}" 2>/dev/null || true) 1001 if [[ -n "${pid}" ]]; then 1002 # Check if it's a docker process - if not, warn (don't kill) 1003 local proc_name 1004 proc_name=$(ps -p "${pid}" -o comm= 2>/dev/null || true) 1005 if [[ "${proc_name}" != *"docker"* && "${proc_name}" != *"com.docker"* ]]; then 1006 echo " WARNING: Port ${port} is in use by non-Docker process: ${proc_name} (PID ${pid})" 1007 echo " You may need to kill it manually: kill ${pid}" 1008 fi 1009 fi 1010 done 1011 1012 echo "=== Cleanup complete ===" 1013 } 1014 1015 function do_restart() { 1016 echo "=== Restarting containers to reload code (mode: ${AG3NTUM_MODE}) ===" 1017 1018 # Restart both containers 1019 echo "Restarting ag3ntum-api..." 1020 ${COMPOSE_CMD} restart ag3ntum-api 1021 1022 echo "Restarting ag3ntum-web..." 1023 ${COMPOSE_CMD} restart ag3ntum-web 1024 1025 # Wait for services to be healthy 1026 sleep 2 1027 1028 if check_services; then 1029 echo "=== Restart complete - services running ===" 1030 else 1031 echo "=== WARNING: Some services may not be running ===" 1032 ${COMPOSE_CMD} ps 1033 fi 1034 } 1035 1036 function create_user() { 1037 USERNAME="" 1038 EMAIL="" 1039 PASSWORD="" 1040 ADMIN="" 1041 1042 # Parse arguments 1043 for arg in "$@"; do 1044 case "$arg" in 1045 --username=*) USERNAME="${arg#--username=}" ;; 1046 --email=*) EMAIL="${arg#--email=}" ;; 1047 --password=*) PASSWORD="${arg#--password=}" ;; 1048 --admin) ADMIN="--admin" ;; 1049 esac 1050 done 1051 1052 # Validate required arguments 1053 if [[ -z "$USERNAME" || -z "$EMAIL" || -z "$PASSWORD" ]]; then 1054 echo "Error: Missing required arguments" 1055 echo "Usage: ./run.sh create-user --username=USER --email=EMAIL --password=PASS [--admin]" 1056 echo "" 1057 echo "UID Security Mode (set via environment or docker-compose.yml):" 1058 echo " AG3NTUM_UID_MODE=isolated (default) UIDs 50000-60000, multi-tenant safe" 1059 echo " AG3NTUM_UID_MODE=direct UIDs map to host (1000-65533), dev/single-tenant" 1060 exit 1 1061 fi 1062 1063 # Check if container is running 1064 if ! ${COMPOSE_CMD} ps --status running --services 2>/dev/null | grep -q "ag3ntum-api"; then 1065 echo "Error: ag3ntum-api container is not running." 1066 echo "Start it first with: ./run.sh build" 1067 exit 1 1068 fi 1069 1070 # Get current UID mode from container 1071 local uid_mode 1072 uid_mode=$(${COMPOSE_CMD} exec -T ag3ntum-api printenv AG3NTUM_UID_MODE 2>/dev/null | tr -d '\r' || echo "isolated") 1073 1074 echo "=== Creating user: $USERNAME ===" 1075 echo " UID Security Mode: ${uid_mode:-isolated}" 1076 1077 # Run create_user.py inside container as root (avoids sudo prompts) 1078 ${COMPOSE_CMD} exec -T -u root ag3ntum-api \ 1079 python3 src/cli/create_user.py \ 1080 --username="$USERNAME" \ 1081 --email="$EMAIL" \ 1082 --password="$PASSWORD" \ 1083 $ADMIN 1084 1085 # Restart API so the process inherits the new user's group (Gotcha #12). 1086 # Without this, session directory access fails with PermissionError because 1087 # setpriv --init-groups only reads /etc/group at process start. 1088 echo "" 1089 echo "Restarting API to activate user access..." 1090 ${COMPOSE_CMD} restart ag3ntum-api 1091 } 1092 1093 # Function to delete a user 1094 function delete_user() { 1095 USERNAME="" 1096 FORCE="" 1097 1098 # Parse arguments 1099 for arg in "$@"; do 1100 case "$arg" in 1101 --username=*) USERNAME="${arg#--username=}" ;; 1102 --force) FORCE="--force" ;; 1103 esac 1104 done 1105 1106 # Validate required arguments 1107 if [[ -z "$USERNAME" ]]; then 1108 echo "Error: Missing required argument --username" 1109 echo "Usage: ./run.sh delete-user --username=USER [--force]" 1110 echo "" 1111 echo "Options:" 1112 echo " --username=USER Username to delete (required)" 1113 echo " --force Confirm deletion (required to actually delete)" 1114 echo "" 1115 echo "Note: This removes the user from Ag3ntum database and cleans up their" 1116 echo " user directory. The Linux user account is preserved." 1117 exit 1 1118 fi 1119 1120 # Check if container is running 1121 if ! ${COMPOSE_CMD} ps --status running --services 2>/dev/null | grep -q "ag3ntum-api"; then 1122 echo "Error: ag3ntum-api container is not running." 1123 echo "Start it first with: ./run.sh build" 1124 exit 1 1125 fi 1126 1127 if [[ -z "$FORCE" ]]; then 1128 echo "=== User deletion preview ===" 1129 else 1130 echo "=== Deleting user: $USERNAME ===" 1131 fi 1132 1133 # Run delete_user.py inside container as root (needs elevated permissions) 1134 ${COMPOSE_CMD} exec -T -u root ag3ntum-api \ 1135 python3 src/cli/delete_user.py \ 1136 --username="$USERNAME" \ 1137 $FORCE 1138 } 1139 1140 # Handle cleanup action 1141 if [[ "${ACTION}" == "cleanup" ]]; then 1142 do_cleanup 1143 exit 0 1144 fi 1145 1146 # Handle restart action 1147 if [[ "${ACTION}" == "restart" ]]; then 1148 do_restart 1149 exit 0 1150 fi 1151 1152 # Function to run UI/React tests 1153 run_ui_tests() { 1154 echo "=== Running UI/React tests ===" 1155 1156 # UI tests always need the web container in dev mode (with node_modules). 1157 # COMPOSE_WITH_WEB includes docker-compose.dev.yml for Vite + node_modules. 1158 # Always call up -d: if web is running in prod mode (from ./run.sh build), 1159 # compose detects the config change and recreates it with the dev overlay. 1160 echo "Ensuring ag3ntum-web container is running (dev mode)..." 1161 ${COMPOSE_WITH_WEB} up -d ag3ntum-web 1162 1163 # Wait for entrypoint to complete (npm install + vite startup). 1164 # The entrypoint installs packages as ag3ntum_api (UID 45045). We must NOT 1165 # exec npm commands as root while it's running — that creates root-owned 1166 # /tmp/.npm cache files that cause EACCES on the next entrypoint npm install. 1167 echo "Waiting for Vite dev server to be ready..." 1168 local attempts=0 1169 local max_attempts=60 1170 while [[ $attempts -lt $max_attempts ]]; do 1171 if ! ${COMPOSE_WITH_WEB} ps --status running --services 2>/dev/null | grep -q "ag3ntum-web"; then 1172 echo "Error: ag3ntum-web container failed to start." 1173 echo "Check logs with: ${COMPOSE_WITH_WEB} logs ag3ntum-web" 1174 return 1 1175 fi 1176 # Check if vite is ready by looking for "VITE.*ready" in container logs 1177 if ${COMPOSE_WITH_WEB} logs ag3ntum-web 2>&1 | grep -q "VITE.*ready"; then 1178 echo " Vite dev server ready." 1179 break 1180 fi 1181 attempts=$((attempts + 1)) 1182 sleep 2 1183 done 1184 if [[ $attempts -ge $max_attempts ]]; then 1185 echo "Error: Vite dev server did not start within ${max_attempts}s." 1186 echo "Check logs with: ${COMPOSE_WITH_WEB} logs ag3ntum-web" 1187 return 1 1188 fi 1189 1190 # Check if node_modules needs reinstalling (platform mismatch between host and container). 1191 # The bind-mounted node_modules may have wrong platform binaries (darwin vs linux). 1192 # Run as ag3ntum_api (45045) to match entrypoint's ownership of /tmp/.npm cache. 1193 echo "Checking node_modules platform compatibility..." 1194 NEEDS_REINSTALL=$(${COMPOSE_WITH_WEB} exec -T -u 45045:45045 ag3ntum-web sh -c ' 1195 if [ ! -d /app/node_modules ]; then 1196 echo "missing" 1197 elif [ ! -d /app/node_modules/@rollup ]; then 1198 echo "missing_rollup" 1199 elif ! ls /app/node_modules/@rollup/rollup-linux-* >/dev/null 2>&1; then 1200 echo "wrong_platform" 1201 else 1202 echo "ok" 1203 fi 1204 ' 2>/dev/null | tr -d '\r') 1205 1206 if [[ "${NEEDS_REINSTALL}" != "ok" ]]; then 1207 echo "Reinstalling node_modules for Linux platform (reason: ${NEEDS_REINSTALL})..." 1208 ${COMPOSE_WITH_WEB} exec -T -u 45045:45045 ag3ntum-web sh -c ' 1209 cp /src/web_terminal_client/package.json /app/package.json && \ 1210 cd /app && \ 1211 rm -rf node_modules/* node_modules/.[!.]* 2>/dev/null; \ 1212 npm install --no-fund --no-audit --no-package-lock 1213 ' 1214 fi 1215 1216 # Run vite build first to catch Babel transpilation errors 1217 # (Vitest uses esbuild which is more permissive than Babel) 1218 echo "Running vite build to verify transpilation..." 1219 if ! ${COMPOSE_WITH_WEB} exec -T -u 45045:45045 ag3ntum-web sh -c 'cd /src/web_terminal_client && vite build --config /tmp/vite-${AG3NTUM_WEB_PORT:-50080}/vite.config.mjs'; then 1220 echo "" 1221 echo "ERROR: Vite build failed. Fix transpilation errors before running tests." 1222 return 1 1223 fi 1224 echo "Build successful." 1225 echo "" 1226 1227 # Run vitest inside the Docker container 1228 echo "Running vitest in Docker container..." 1229 ${COMPOSE_WITH_WEB} exec -T -u 45045:45045 -e FORCE_COLOR=1 ag3ntum-web sh -c 'cd /src/web_terminal_client && vitest run --config /tmp/vite-${AG3NTUM_WEB_PORT:-50080}/vitest.config.mjs' 1230 return $? 1231 } 1232 1233 # Handle test action 1234 if [[ "${ACTION}" == "test" ]]; then 1235 echo "=== Running tests ===" 1236 1237 # Prevent concurrent test runs — two ./run.sh test invocations sharing the 1238 # same container race on container lifecycle (test mode → production restore). 1239 # When one finishes and restores the container, it kills the other mid-flight. 1240 TEST_LOCK_FILE="${ROOT_DIR}/.test.lock" 1241 1242 # Try to acquire lock (atomic via mkdir — works across Linux and macOS) 1243 cleanup_test_lock() { 1244 rm -f "${TEST_LOCK_FILE}" 2>/dev/null || true 1245 } 1246 1247 if [[ -f "${TEST_LOCK_FILE}" ]]; then 1248 LOCK_PID=$(cat "${TEST_LOCK_FILE}" 2>/dev/null || echo "") 1249 if [[ -n "${LOCK_PID}" ]] && kill -0 "${LOCK_PID}" 2>/dev/null; then 1250 echo "Error: Another test run is already in progress (PID ${LOCK_PID})." 1251 echo "If this is stale, remove ${TEST_LOCK_FILE} and retry." 1252 exit 1 1253 else 1254 # Stale lock from a crashed run — clean it up 1255 if [[ -n "${LOCK_PID}" ]]; then 1256 echo "Removing stale test lock (PID ${LOCK_PID} is not running)." 1257 fi 1258 cleanup_test_lock 1259 fi 1260 fi 1261 1262 # Write our PID to the lock file 1263 echo $$ > "${TEST_LOCK_FILE}" 1264 trap cleanup_test_lock EXIT 1265 1266 # Set up test logging - output goes to both console and log file 1267 TEST_LOG_FILE="logs/latest-test-results.log" 1268 mkdir -p logs 2>/dev/null || true 1269 1270 # Remove stale log file that may be owned by container user from previous build 1271 safe_remove_file "$TEST_LOG_FILE" 1272 1273 # Check if we can write to the log file (directory may be owned by container UID) 1274 if touch "$TEST_LOG_FILE" 2>/dev/null; then 1275 # Initialize log file with header 1276 { 1277 echo "========================================" 1278 echo "Test Run: $(date '+%Y-%m-%d %H:%M:%S')" 1279 echo "========================================" 1280 echo "" 1281 } > "$TEST_LOG_FILE" 1282 CAN_LOG=1 1283 elif ${COMPOSE_CMD} ps --status running --services 2>/dev/null | grep -q "ag3ntum-api"; then 1284 # logs/ directory is owned by container UID (45045). Use the running container 1285 # to create a world-writable log file so tee -a can append without sudo. 1286 ${COMPOSE_CMD} exec -T ag3ntum-api sh -c " 1287 echo '========================================' > /logs/latest-test-results.log && 1288 echo 'Test Run: $(date '+%Y-%m-%d %H:%M:%S')' >> /logs/latest-test-results.log && 1289 echo '========================================' >> /logs/latest-test-results.log && 1290 echo '' >> /logs/latest-test-results.log && 1291 chmod 646 /logs/latest-test-results.log 1292 " 1293 CAN_LOG=1 1294 else 1295 echo "Warning: Cannot write to ${TEST_LOG_FILE} (permission denied)" 1296 echo "Test output will only be shown in console." 1297 TEST_LOG_FILE="/dev/null" 1298 CAN_LOG=0 1299 fi 1300 1301 # Helper function to run commands with tee (preserves exit code) 1302 run_with_log() { 1303 # Run command, tee to log file, preserve exit code 1304 "$@" 2>&1 | tee -a "$TEST_LOG_FILE" 1305 return "${PIPESTATUS[0]}" 1306 } 1307 1308 # Use test compose override for test runs 1309 # This mounts test sudoers and uses test entrypoint 1310 COMPOSE_TEST="docker compose -f docker-compose.yml -f docker-compose.test.yml" 1311 1312 # Exec options for running commands as ag3ntum_api (required because container starts as root) 1313 # The test container starts as root to install sudoers, then drops to ag3ntum_api for uvicorn. 1314 # But docker exec defaults to root, so we need to specify the user explicitly. 1315 EXEC_OPTS="-T -u ag3ntum_api" 1316 1317 # Check if test configuration files exist 1318 if [[ ! -f "docker-compose.test.yml" ]]; then 1319 echo "Error: docker-compose.test.yml not found" 1320 echo "This file is required for running tests with proper permissions." 1321 exit 1 1322 fi 1323 1324 if [[ ! -f "config/test/sudoers-test" ]]; then 1325 echo "Error: config/test/sudoers-test not found" 1326 echo "This file is required for integration tests that need elevated permissions." 1327 exit 1 1328 fi 1329 1330 if [[ ! -f "entrypoint-test.sh" ]]; then 1331 echo "Error: entrypoint-test.sh not found" 1332 echo "This script is required to inject test sudoers at runtime." 1333 exit 1 1334 fi 1335 1336 # Ensure container is running with test configuration 1337 # This restarts the API container with test volumes and entrypoint 1338 echo "Configuring container for test mode..." 1339 ${COMPOSE_TEST} up -d ag3ntum-api 1340 1341 # Wait for container to be ready 1342 echo "Waiting for container to be ready..." 1343 sleep 2 1344 1345 # Verify container is running 1346 if ! ${COMPOSE_TEST} ps --status running --services 2>/dev/null | grep -q "ag3ntum-api"; then 1347 echo "Error: Failed to start ag3ntum-api container in test mode." 1348 echo "Check logs with: ${COMPOSE_TEST} logs ag3ntum-api" 1349 exit 1 1350 fi 1351 1352 echo "" 1353 1354 # Build pytest command (--color=yes forces colors even when piped through tee) 1355 PYTEST_CMD="python -m pytest --color=yes" 1356 1357 # Parse test arguments 1358 # Default: run ALL tests (backend+e2e+security+sandboxing+UI) 1359 # Specific flags run only that subset 1360 QUICK_MODE="" 1361 BACKEND_ONLY="" 1362 UI_ONLY="" 1363 SECURITY_ONLY="" 1364 CORE_ONLY="" 1365 E2E_ONLY="" 1366 SANDBOXING_ONLY="" 1367 ALL_MODE="" 1368 1369 ARGS_ARRAY=(${TEST_ARGS[@]+"${TEST_ARGS[@]}"}) 1370 i=0 1371 while [[ $i -lt ${#ARGS_ARRAY[@]} ]]; do 1372 arg="${ARGS_ARRAY[$i]}" 1373 case "${arg}" in 1374 --quick) 1375 QUICK_MODE="1" 1376 ;; 1377 --backend) 1378 BACKEND_ONLY="1" 1379 ;; 1380 --ui|--frontend) 1381 UI_ONLY="1" 1382 ;; 1383 --security) 1384 SECURITY_ONLY="1" 1385 ;; 1386 --all) 1387 ALL_MODE="1" 1388 ;; 1389 --only-e2e) 1390 E2E_ONLY="1" 1391 ;; 1392 --e2e) 1393 E2E_ONLY="1" 1394 ;; 1395 --sandboxing) 1396 SANDBOXING_ONLY="1" 1397 ;; 1398 --core) 1399 CORE_ONLY="1" 1400 ;; 1401 *) 1402 echo "Unknown test option: ${arg}" 1403 echo "" 1404 echo "Usage: ./run.sh test [OPTIONS]" 1405 echo "" 1406 echo "Options (default: run tests excluding E2E):" 1407 echo " --all Run ALL tests including end-to-end" 1408 echo " --backend Run only backend tests (no e2e)" 1409 echo " --security Run only security tests" 1410 echo " --core Run only core agent tests" 1411 echo " --only-e2e Run only e2e tests" 1412 echo " --e2e Alias for --only-e2e" 1413 echo " --sandboxing Run only sandboxing tests" 1414 echo " --ui Run only UI/frontend tests" 1415 echo " --quick Run fast tests only (no e2e/slow)" 1416 exit 1 1417 ;; 1418 esac 1419 i=$((i + 1)) 1420 done 1421 1422 # Handle UI-only mode 1423 if [[ -n "${UI_ONLY}" ]]; then 1424 run_ui_tests 1425 exit $? 1426 fi 1427 1428 # For backend tests, verify container is still running 1429 if ! ${COMPOSE_TEST} ps --status running --services 2>/dev/null | grep -q "ag3ntum-api"; then 1430 echo "Error: ag3ntum-api container is not running." 1431 echo "Start it first with: ./run.sh build" 1432 exit 1 1433 fi 1434 1435 # Handle specific test suite modes 1436 if [[ -n "${SECURITY_ONLY}" ]]; then 1437 echo "=== Running security tests only ===" | tee -a "$TEST_LOG_FILE" 1438 run_with_log ${COMPOSE_TEST} exec ${EXEC_OPTS} ag3ntum-api ${PYTEST_CMD} tests/security/ -v --tb=short 1439 TEST_RESULT=$? 1440 echo "" | tee -a "$TEST_LOG_FILE" 1441 echo "Restoring container to normal mode..." 1442 ${COMPOSE_CMD} up -d ag3ntum-api ag3ntum-web 1443 exit ${TEST_RESULT} 1444 fi 1445 1446 if [[ -n "${E2E_ONLY}" ]]; then 1447 echo "=== Running e2e tests only ===" | tee -a "$TEST_LOG_FILE" 1448 run_with_log ${COMPOSE_TEST} exec ${EXEC_OPTS} ag3ntum-api ${PYTEST_CMD} tests/backend/ --run-e2e -v --tb=short -m "e2e" 1449 TEST_RESULT=$? 1450 echo "" | tee -a "$TEST_LOG_FILE" 1451 echo "Restoring container to normal mode..." 1452 ${COMPOSE_CMD} up -d ag3ntum-api ag3ntum-web 1453 exit ${TEST_RESULT} 1454 fi 1455 1456 if [[ -n "${SANDBOXING_ONLY}" ]]; then 1457 echo "=== Running sandboxing tests only ===" | tee -a "$TEST_LOG_FILE" 1458 # Look for sandboxing tests in various locations 1459 SANDBOX_DIRS="" 1460 if ${COMPOSE_TEST} exec ${EXEC_OPTS} ag3ntum-api test -d /tests/sandboxing 2>/dev/null; then 1461 SANDBOX_DIRS="/tests/sandboxing/" 1462 fi 1463 # Also run sandbox-related tests in backend 1464 run_with_log ${COMPOSE_TEST} exec ${EXEC_OPTS} ag3ntum-api ${PYTEST_CMD} ${SANDBOX_DIRS} tests/backend/test_sandbox*.py -v --tb=short 1465 TEST_RESULT=$? 1466 echo "" | tee -a "$TEST_LOG_FILE" 1467 echo "Restoring container to normal mode..." 1468 ${COMPOSE_CMD} up -d ag3ntum-api ag3ntum-web 1469 exit ${TEST_RESULT} 1470 fi 1471 1472 if [[ -n "${BACKEND_ONLY}" ]]; then 1473 echo "=== Running backend tests only (E2E skipped — use --all for E2E) ===" | tee -a "$TEST_LOG_FILE" 1474 run_with_log ${COMPOSE_TEST} exec ${EXEC_OPTS} ag3ntum-api ${PYTEST_CMD} tests/backend/ -v --tb=short 1475 TEST_RESULT=$? 1476 echo "" | tee -a "$TEST_LOG_FILE" 1477 echo "Restoring container to normal mode..." 1478 ${COMPOSE_CMD} up -d ag3ntum-api ag3ntum-web 1479 exit ${TEST_RESULT} 1480 fi 1481 1482 if [[ -n "${CORE_ONLY}" ]]; then 1483 echo "=== Running core agent tests only ===" | tee -a "$TEST_LOG_FILE" 1484 run_with_log ${COMPOSE_TEST} exec ${EXEC_OPTS} ag3ntum-api ${PYTEST_CMD} tests/core-tests/ -v --tb=short 1485 TEST_RESULT=$? 1486 echo "" | tee -a "$TEST_LOG_FILE" 1487 echo "Restoring container to normal mode..." 1488 ${COMPOSE_CMD} up -d ag3ntum-api ag3ntum-web 1489 exit ${TEST_RESULT} 1490 fi 1491 1492 # Build test arguments 1493 PYTEST_ARGS=() 1494 1495 # Run all tests - need separate runs for backend (with --run-e2e) and others 1496 if [[ -n "${QUICK_MODE}" ]]; then 1497 # Quick mode: exclude E2E and slow tests (all tests at once, no --run-e2e) 1498 echo "Running quick tests (excluding E2E and slow tests)..." 1499 PYTEST_ARGS+=("tests/" "-v" "--tb=short") 1500 1501 echo "Running: ${PYTEST_CMD} ${PYTEST_ARGS[*]}" 1502 echo "" 1503 1504 # Run backend tests in container with logging 1505 # Use || true to prevent set -e from exiting on test failures 1506 BACKEND_RESULT=0 1507 if [[ -z "${UI_ONLY}" ]]; then 1508 run_with_log ${COMPOSE_TEST} exec ${EXEC_OPTS} ag3ntum-api ${PYTEST_CMD} "${PYTEST_ARGS[@]}" || BACKEND_RESULT=$? 1509 fi 1510 1511 # Run UI tests unless backend-only 1512 UI_RESULT=0 1513 if [[ -z "${BACKEND_ONLY}" ]]; then 1514 echo "" | tee -a "$TEST_LOG_FILE" 1515 run_ui_tests 2>&1 | tee -a "$TEST_LOG_FILE" 1516 UI_RESULT=${PIPESTATUS[0]} 1517 fi 1518 1519 # Print summary for quick mode (to console and log) 1520 { 1521 echo "" 1522 echo "========================================" 1523 echo "=== QUICK TEST SUMMARY ===" 1524 echo "========================================" 1525 if [[ -z "${UI_ONLY}" ]]; then 1526 if [[ ${BACKEND_RESULT} -eq 0 ]]; then 1527 echo " ✓ Backend tests: PASSED" 1528 else 1529 echo " ✗ Backend tests: FAILED" 1530 fi 1531 fi 1532 if [[ -z "${BACKEND_ONLY}" ]]; then 1533 if [[ ${UI_RESULT} -eq 0 ]]; then 1534 echo " ✓ UI tests: PASSED" 1535 else 1536 echo " ✗ UI tests: FAILED" 1537 fi 1538 fi 1539 echo "========================================" 1540 echo "" 1541 echo "Test results saved to: ${TEST_LOG_FILE}" 1542 } | tee -a "$TEST_LOG_FILE" 1543 1544 # Restore container to production mode 1545 echo "" 1546 echo "Restoring container to normal mode..." 1547 ${COMPOSE_CMD} up -d ag3ntum-api ag3ntum-web 1548 1549 if [[ ${BACKEND_RESULT} -ne 0 || ${UI_RESULT} -ne 0 ]]; then 1550 echo "" | tee -a "$TEST_LOG_FILE" 1551 echo "Some tests failed!" | tee -a "$TEST_LOG_FILE" 1552 exit 1 1553 fi 1554 exit 0 1555 else 1556 # Full mode: run all tests. E2E only included with --all flag. 1557 if [[ -n "${ALL_MODE}" ]]; then 1558 echo "Running ALL tests (backend with E2E + security + other tests)..." | tee -a "$TEST_LOG_FILE" 1559 else 1560 echo "Running tests (E2E skipped — use --all or --only-e2e for E2E)..." | tee -a "$TEST_LOG_FILE" 1561 fi 1562 echo "" | tee -a "$TEST_LOG_FILE" 1563 1564 # First run: backend tests (with --run-e2e only if --all) 1565 if [[ -n "${ALL_MODE}" ]]; then 1566 echo "=== Running backend tests (with E2E) ===" | tee -a "$TEST_LOG_FILE" 1567 else 1568 echo "=== Running backend tests ===" | tee -a "$TEST_LOG_FILE" 1569 fi 1570 BACKEND_RESULT=0 1571 if [[ -n "${ALL_MODE}" ]]; then 1572 run_with_log ${COMPOSE_TEST} exec ${EXEC_OPTS} ag3ntum-api ${PYTEST_CMD} tests/backend/ --run-e2e -v --tb=short || BACKEND_RESULT=$? 1573 else 1574 run_with_log ${COMPOSE_TEST} exec ${EXEC_OPTS} ag3ntum-api ${PYTEST_CMD} tests/backend/ -v --tb=short || BACKEND_RESULT=$? 1575 fi 1576 1577 # Second run: security tests 1578 echo "" | tee -a "$TEST_LOG_FILE" 1579 echo "=== Running security tests ===" | tee -a "$TEST_LOG_FILE" 1580 SECURITY_RESULT=0 1581 run_with_log ${COMPOSE_TEST} exec ${EXEC_OPTS} ag3ntum-api ${PYTEST_CMD} tests/security/ -v --tb=short || SECURITY_RESULT=$? 1582 1583 # Third run: core agent tests 1584 echo "" | tee -a "$TEST_LOG_FILE" 1585 echo "=== Running core agent tests ===" | tee -a "$TEST_LOG_FILE" 1586 CORE_RESULT=0 1587 run_with_log ${COMPOSE_TEST} exec ${EXEC_OPTS} ag3ntum-api ${PYTEST_CMD} tests/core-tests/ -v --tb=short || CORE_RESULT=$? 1588 1589 # Fourth run: sandboxing tests 1590 echo "" | tee -a "$TEST_LOG_FILE" 1591 echo "=== Running sandboxing tests ===" | tee -a "$TEST_LOG_FILE" 1592 SANDBOXING_RESULT=0 1593 SANDBOX_DIRS="" 1594 if ${COMPOSE_TEST} exec ${EXEC_OPTS} ag3ntum-api test -d /tests/sandboxing 2>/dev/null; then 1595 SANDBOX_DIRS="/tests/sandboxing/" 1596 fi 1597 run_with_log ${COMPOSE_TEST} exec ${EXEC_OPTS} ag3ntum-api ${PYTEST_CMD} ${SANDBOX_DIRS} tests/backend/test_sandbox*.py -v --tb=short || SANDBOXING_RESULT=$? 1598 1599 # Run UI tests if not backend-only mode 1600 UI_RESULT=0 1601 if [[ -z "${BACKEND_ONLY}" ]]; then 1602 echo "" | tee -a "$TEST_LOG_FILE" 1603 run_ui_tests 2>&1 | tee -a "$TEST_LOG_FILE" 1604 UI_RESULT=${PIPESTATUS[0]} 1605 fi 1606 1607 # Print combined summary (to console and log) 1608 { 1609 echo "" 1610 echo "========================================" 1611 echo "=== COMBINED TEST SUMMARY ===" 1612 echo "========================================" 1613 if [[ ${BACKEND_RESULT} -eq 0 ]]; then 1614 echo " ✓ Backend tests: PASSED" 1615 else 1616 echo " ✗ Backend tests: FAILED" 1617 fi 1618 if [[ ${SECURITY_RESULT} -eq 0 ]]; then 1619 echo " ✓ Security tests: PASSED" 1620 else 1621 echo " ✗ Security tests: FAILED" 1622 fi 1623 if [[ ${CORE_RESULT} -eq 0 ]]; then 1624 echo " ✓ Core tests: PASSED" 1625 else 1626 echo " ✗ Core tests: FAILED" 1627 fi 1628 if [[ ${SANDBOXING_RESULT} -eq 0 ]]; then 1629 echo " ✓ Sandboxing tests: PASSED" 1630 else 1631 echo " ✗ Sandboxing tests: FAILED" 1632 fi 1633 if [[ ${UI_RESULT} -eq 0 ]]; then 1634 echo " ✓ UI tests: PASSED" 1635 else 1636 echo " ✗ UI tests: FAILED" 1637 fi 1638 echo "========================================" 1639 if [[ -z "${ALL_MODE}" ]]; then 1640 echo "" 1641 echo " NOTE: E2E tests were skipped. Use '--all' or '--only-e2e' to run them." 1642 fi 1643 echo "" 1644 echo "Test results saved to: ${TEST_LOG_FILE}" 1645 } | tee -a "$TEST_LOG_FILE" 1646 1647 # Restore container to production mode 1648 echo "" 1649 echo "Restoring container to normal mode..." 1650 ${COMPOSE_CMD} up -d ag3ntum-api ag3ntum-web 1651 1652 # Exit with error if any test suite failed 1653 if [[ ${BACKEND_RESULT} -ne 0 || ${SECURITY_RESULT} -ne 0 || ${CORE_RESULT} -ne 0 || ${SANDBOXING_RESULT} -ne 0 || ${UI_RESULT} -ne 0 ]]; then 1654 echo "" | tee -a "$TEST_LOG_FILE" 1655 echo "Some tests failed!" | tee -a "$TEST_LOG_FILE" 1656 exit 1 1657 fi 1658 echo "" | tee -a "$TEST_LOG_FILE" 1659 echo "All tests passed!" | tee -a "$TEST_LOG_FILE" 1660 exit 0 1661 fi 1662 fi 1663 1664 # --------------------------------------------------------------------------- 1665 # Dev environment helper: activate .venv if it exists (for lint/audit/setup) 1666 # --------------------------------------------------------------------------- 1667 VENV_DIR="${ROOT_DIR}/.venv" 1668 1669 activate_venv() { 1670 if [[ -d "${VENV_DIR}" ]]; then 1671 # shellcheck disable=SC1091 1672 source "${VENV_DIR}/bin/activate" 1673 return 0 1674 fi 1675 return 1 1676 } 1677 1678 # Handle setup action — install all dev tools (Python venv + Node + pre-commit) 1679 if [[ "${ACTION}" == "setup" ]]; then 1680 echo "=== Setting up development environment ===" 1681 1682 # 1. Check prerequisites 1683 if ! command -v python3 &>/dev/null; then 1684 echo "Error: python3 not found. Install Python 3.12+ first." 1685 exit 1 1686 fi 1687 if ! command -v node &>/dev/null; then 1688 echo "Error: node not found. Install Node.js 20+ first." 1689 exit 1 1690 fi 1691 1692 PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') 1693 NODE_VERSION=$(node --version) 1694 echo " Python: ${PYTHON_VERSION}" 1695 echo " Node: ${NODE_VERSION}" 1696 1697 # 2. Create Python venv and install lint/audit tools 1698 echo "" 1699 echo "--- Python dev tools (.venv/) ---" 1700 if [[ ! -d "${VENV_DIR}" ]]; then 1701 echo " Creating virtual environment..." 1702 python3 -m venv "${VENV_DIR}" 1703 else 1704 echo " Virtual environment already exists." 1705 fi 1706 source "${VENV_DIR}/bin/activate" 1707 1708 echo " Installing Python dev tools..." 1709 pip install --quiet --upgrade pip 1710 pip install --quiet \ 1711 flake8==7.3.0 \ 1712 bandit==1.8.3 \ 1713 mypy==1.14.1 \ 1714 pip-audit==2.9.0 \ 1715 pytest==9.0.2 1716 echo " Installed: flake8, bandit, mypy, pip-audit, pytest" 1717 1718 # 3. Install pre-commit hooks 1719 echo "" 1720 echo "--- Pre-commit hooks ---" 1721 pip install --quiet pre-commit 1722 if [[ -f ".pre-commit-config.yaml" ]]; then 1723 pre-commit install 1724 echo " Pre-commit hooks installed." 1725 else 1726 echo " Warning: .pre-commit-config.yaml not found, skipping hooks." 1727 fi 1728 1729 # 4. Install frontend dependencies 1730 echo "" 1731 echo "--- Frontend dependencies (npm ci) ---" 1732 if [[ -f "src/web_terminal_client/package.json" ]]; then 1733 (cd src/web_terminal_client && npm ci --legacy-peer-deps 2>&1 | tail -3) 1734 echo " Frontend dependencies installed." 1735 else 1736 echo " Warning: src/web_terminal_client/package.json not found." 1737 fi 1738 1739 echo "" 1740 echo "=== Dev environment ready ===" 1741 echo "" 1742 echo "Commands available:" 1743 echo " ./run.sh lint — run all linters" 1744 echo " ./run.sh audit — check dependency vulnerabilities" 1745 echo " ./run.sh build — build and start Docker containers" 1746 echo " ./run.sh test — run tests (requires Docker)" 1747 exit 0 1748 fi 1749 1750 # Handle lint action (no Docker needed — runs on host) 1751 if [[ "${ACTION}" == "lint" ]]; then 1752 # Auto-activate venv if available 1753 if ! activate_venv; then 1754 echo "Warning: .venv/ not found. Run './run.sh setup' first for full linting." 1755 echo " Falling back to system tools..." 1756 echo "" 1757 fi 1758 1759 echo "=== Running linters ===" 1760 LINT_EXIT=0 1761 1762 # Python — flake8 1763 echo "" 1764 echo "--- flake8 (style) ---" 1765 if command -v flake8 &>/dev/null; then 1766 flake8 src/ tools/ tests/ --config=.flake8 || LINT_EXIT=1 1767 else 1768 echo "SKIPPED: flake8 not found. Run: ./run.sh setup" 1769 LINT_EXIT=1 1770 fi 1771 1772 # Python — bandit (security) 1773 echo "" 1774 echo "--- bandit (security) ---" 1775 if command -v bandit &>/dev/null; then 1776 bandit -r src/ tools/ -c bandit.yaml -ll -ii || LINT_EXIT=1 1777 else 1778 echo "SKIPPED: bandit not found. Run: ./run.sh setup" 1779 LINT_EXIT=1 1780 fi 1781 1782 # Structural tests 1783 echo "" 1784 echo "--- structural tests ---" 1785 if command -v pytest &>/dev/null || python3 -m pytest --version &>/dev/null 2>&1; then 1786 python3 -m pytest tests/structural/ -v --tb=long || LINT_EXIT=1 1787 else 1788 echo "SKIPPED: pytest not found. Run: ./run.sh setup" 1789 LINT_EXIT=1 1790 fi 1791 1792 # Python — mypy (type checking, informational — not blocking until baseline established) 1793 echo "" 1794 echo "--- mypy (type checking — informational) ---" 1795 if command -v mypy &>/dev/null; then 1796 mypy src/core/ src/api/ src/services/ --config-file mypy.ini || echo " (mypy found issues — informational, not blocking)" 1797 else 1798 echo "SKIPPED: mypy not found. Run: ./run.sh setup" 1799 fi 1800 1801 # Frontend — TypeScript type check + ESLint 1802 if [ -d "src/web_terminal_client/node_modules" ]; then 1803 echo "" 1804 echo "--- TypeScript (tsc --noEmit — informational) ---" 1805 # Use tsconfig.lint.json — excludes test files (they need Docker node_modules) 1806 # Informational until pre-existing type errors are cleaned up (20 errors in existing code) 1807 (cd src/web_terminal_client && npx tsc --noEmit -p tsconfig.lint.json) || echo " (tsc found type errors — informational, not blocking)" 1808 1809 echo "" 1810 echo "--- ESLint (React/TypeScript) ---" 1811 (cd src/web_terminal_client && npx eslint src/ --max-warnings 55) || LINT_EXIT=1 1812 else 1813 echo "" 1814 echo "SKIPPED: Frontend linting (node_modules missing). Run: ./run.sh setup" 1815 LINT_EXIT=1 1816 fi 1817 1818 echo "" 1819 if [[ ${LINT_EXIT} -eq 0 ]]; then 1820 echo "=== All lint checks passed ===" 1821 else 1822 echo "=== Some lint checks failed (exit code ${LINT_EXIT}) ===" 1823 fi 1824 exit ${LINT_EXIT} 1825 fi 1826 1827 # Handle audit action (no Docker needed — runs on host) 1828 if [[ "${ACTION}" == "audit" ]]; then 1829 # Auto-activate venv if available 1830 if ! activate_venv; then 1831 echo "Warning: .venv/ not found. Run './run.sh setup' first." 1832 echo "" 1833 fi 1834 1835 echo "=== Running dependency audit ===" 1836 AUDIT_EXIT=0 1837 1838 if command -v pip-audit &>/dev/null; then 1839 pip-audit --requirement requirements-base.txt --desc || AUDIT_EXIT=1 1840 else 1841 echo "SKIPPED: pip-audit not found. Run: ./run.sh setup" 1842 AUDIT_EXIT=1 1843 fi 1844 1845 echo "" 1846 if [[ ${AUDIT_EXIT} -eq 0 ]]; then 1847 echo "=== Dependency audit passed ===" 1848 else 1849 echo "=== Dependency audit found issues (exit code ${AUDIT_EXIT}) ===" 1850 fi 1851 exit ${AUDIT_EXIT} 1852 fi 1853 1854 # Handle shell action 1855 if [[ "${ACTION}" == "shell" ]]; then 1856 echo "=== Opening shell in Docker container ===" 1857 1858 # Check if container is running 1859 if ! ${COMPOSE_CMD} ps --status running --services 2>/dev/null | grep -q "ag3ntum-api"; then 1860 echo "Error: ag3ntum-api container is not running." 1861 echo "Start it first with: ./run.sh build" 1862 exit 1 1863 fi 1864 1865 # Shell requires TTY 1866 if [ -t 0 ]; then 1867 ${COMPOSE_CMD} exec ag3ntum-api /bin/bash 1868 else 1869 echo "Error: Shell requires an interactive terminal." 1870 exit 1 1871 fi 1872 exit 0 1873 fi 1874 1875 # Handle create-user action 1876 if [[ "${ACTION}" == "create-user" ]]; then 1877 create_user ${TEST_ARGS[@]+"${TEST_ARGS[@]}"} 1878 exit 0 1879 fi 1880 1881 # Handle delete-user action 1882 if [[ "${ACTION}" == "delete-user" ]]; then 1883 delete_user ${TEST_ARGS[@]+"${TEST_ARGS[@]}"} 1884 exit 0 1885 fi 1886 1887 # Handle cleanup-test-users action 1888 if [[ "${ACTION}" == "cleanup-test-users" ]]; then 1889 echo "=== Cleaning up test users ===" 1890 1891 # Use test compose override for cleanup (needs elevated permissions) 1892 COMPOSE_TEST="docker compose -f docker-compose.yml -f docker-compose.test.yml" 1893 EXEC_OPTS="-T -u ag3ntum_api" 1894 1895 # Check if test configuration files exist 1896 if [[ ! -f "docker-compose.test.yml" ]] || [[ ! -f "config/test/sudoers-test" ]]; then 1897 echo "Warning: Test configuration files not found, using standard compose." 1898 echo "Some cleanup operations may fail without test permissions." 1899 COMPOSE_TEST="${COMPOSE_CMD}" 1900 EXEC_OPTS="-T" # No user override needed for standard compose 1901 fi 1902 1903 # Ensure container is running with test configuration for cleanup 1904 echo "Configuring container for cleanup..." 1905 ${COMPOSE_TEST} up -d ag3ntum-api 1906 sleep 2 1907 1908 # Check if container is running 1909 if ! ${COMPOSE_TEST} ps --status running --services 2>/dev/null | grep -q "ag3ntum-api"; then 1910 echo "Error: ag3ntum-api container is not running." 1911 echo "Start it first with: ./run.sh build" 1912 exit 1 1913 fi 1914 1915 # Run cleanup script inside container (run as ag3ntum_api to use test sudoers) 1916 ${COMPOSE_TEST} exec ${EXEC_OPTS} ag3ntum-api \ 1917 python3 -m src.cli.cleanup_test_users ${TEST_ARGS[@]+"${TEST_ARGS[@]}"} 1918 1919 # Restore container to production mode 1920 echo "" 1921 echo "Restoring container to normal mode..." 1922 ${COMPOSE_CMD} up -d ag3ntum-api ag3ntum-web 1923 1924 exit 0 1925 fi 1926 1927 # Handle rebuild action (cleanup + build) 1928 if [[ "${ACTION}" == "rebuild" ]]; then 1929 do_cleanup 1930 ACTION="build" 1931 # Fall through to build 1932 fi 1933 1934 # Validate and auto-provision config files before reading any config values 1935 validate_and_provision_configs 1936 1937 API_PORT="$(read_config_value 'api.external_port' '40080')" 1938 WEB_PORT="$(read_config_value 'web.external_port' '50080')" 1939 REDIS_PORT="${AG3NTUM_REDIS_PORT:-46379}" 1940 1941 # Derive project name: preserve COMPOSE_PROJECT_NAME from .env if set (worktree instances), 1942 # otherwise use directory basename (backward compatible — "project" for main) 1943 if [[ -f .env ]] && grep -q '^COMPOSE_PROJECT_NAME=' .env; then 1944 PROJECT_NAME="$(grep '^COMPOSE_PROJECT_NAME=' .env | cut -d= -f2)" 1945 else 1946 PROJECT_NAME="${COMPOSE_PROJECT_NAME:-$(basename "$(pwd)" | tr '[:upper:]' '[:lower:]')}" 1947 fi 1948 1949 # Setup directories with proper ownership before starting containers 1950 # This ensures bind-mounted volumes are writable by the container user 1951 setup_directories 1952 1953 # Load mounts from YAML config (before CLI args which can override) 1954 load_mounts_from_yaml 1955 1956 render_ui_config 1957 generate_compose_override 1958 1959 if [[ -f "${ROOT_DIR}/VERSION" ]]; then 1960 APP_VERSION="$(tr -d '[:space:]' < "${ROOT_DIR}/VERSION")" 1961 else 1962 APP_VERSION="dev" 1963 fi 1964 1965 # Warn if installed version doesn't match codebase 1966 if [[ -f ".ag3ntum-version" ]]; then 1967 INSTALLED_VERSION="$(tr -d '[:space:]' < .ag3ntum-version)" 1968 if [[ "${INSTALLED_VERSION}" != "${APP_VERSION}" ]]; then 1969 echo "" 1970 echo "WARNING: Version mismatch detected:" 1971 echo " Installed: ${INSTALLED_VERSION}" 1972 echo " Codebase: ${APP_VERSION}" 1973 echo "" 1974 echo " Recommended: Run ./upgrade.sh for a safe upgrade with backup and migration." 1975 if [[ -z "${CI:-}" ]]; then 1976 echo " Continuing with build in 5 seconds... (Ctrl+C to cancel)" 1977 echo "" 1978 sleep 5 1979 else 1980 echo " (CI detected — skipping countdown)" 1981 echo "" 1982 fi 1983 fi 1984 fi 1985 1986 IMAGE_TAG="${APP_VERSION}-$(date +%Y%m%d%H%M%S)" 1987 BACKUP_ENV="$(mktemp)" 1988 ROLLBACK_ENV=0 1989 1990 cleanup() { 1991 if [[ "${ROLLBACK_ENV}" -eq 1 && -s "${BACKUP_ENV}" ]]; then 1992 cp "${BACKUP_ENV}" .env 1993 ${COMPOSE_CMD} up -d --remove-orphans || true 1994 fi 1995 rm -f "${BACKUP_ENV}" 1996 } 1997 1998 trap cleanup EXIT 1999 2000 if [[ -f .env ]]; then 2001 cp .env "${BACKUP_ENV}" 2002 fi 2003 2004 echo "Building image ag3ntum:${IMAGE_TAG}..." 2005 if [[ -n "${NO_CACHE}" ]]; then 2006 echo " (Using --no-cache for fresh build)" 2007 fi 2008 docker build ${NO_CACHE} --build-arg APP_VERSION="${APP_VERSION}" -t "ag3ntum:${IMAGE_TAG}" . 2009 docker tag "ag3ntum:${IMAGE_TAG}" "ag3ntum:${APP_VERSION}" 2010 docker tag "ag3ntum:${IMAGE_TAG}" "ag3ntum:latest" 2011 2012 ROLLBACK_ENV=1 2013 cat > .env <<EOF 2014 AG3NTUM_IMAGE_TAG=${IMAGE_TAG} 2015 AG3NTUM_API_PORT=${API_PORT} 2016 AG3NTUM_WEB_PORT=${WEB_PORT} 2017 AG3NTUM_REDIS_PORT=${REDIS_PORT} 2018 AG3NTUM_MODE=${AG3NTUM_MODE} 2019 COMPOSE_PROJECT_NAME=${PROJECT_NAME} 2020 EOF 2021 2022 echo "Starting containers with tag ${IMAGE_TAG} (mode: ${AG3NTUM_MODE})..." 2023 # Use --force-recreate to ensure fresh containers with new code 2024 ${COMPOSE_CMD} up -d --remove-orphans --force-recreate 2025 2026 if ! check_services; then 2027 echo "Deployment failed, rolling back." 2028 exit 1 2029 fi 2030 2031 ROLLBACK_ENV=0 2032 2033 # Track installed version 2034 echo "${APP_VERSION}" > .ag3ntum-version 2035 2036 # Validate frontend build (catches module resolution failures early) 2037 echo "" 2038 if [[ "${AG3NTUM_MODE}" == "dev" ]]; then 2039 # Dev mode: validate via web container's Vite build 2040 if ${COMPOSE_CMD} ps --status running --services 2>/dev/null | grep -q "ag3ntum-web"; then 2041 echo "Validating frontend build (dev mode)..." 2042 if ${COMPOSE_CMD} exec -T ag3ntum-web sh -c \ 2043 'cd /src/web_terminal_client && vite build --config /tmp/vite-${AG3NTUM_WEB_PORT:-50080}/vite.config.mjs' \ 2044 >/dev/null 2>&1; then 2045 echo " Frontend build validation passed" 2046 else 2047 echo " WARNING: Frontend build validation failed. Check web container logs." 2048 echo " ${COMPOSE_CMD} logs ag3ntum-web" 2049 fi 2050 fi 2051 else 2052 # Prod mode: verify the static bundle exists in the web container 2053 echo "Validating production frontend bundle..." 2054 if ${COMPOSE_CMD} exec -T ag3ntum-web sh -c 'test -f /web_dist/index.html' 2>/dev/null; then 2055 echo " Production frontend bundle verified" 2056 else 2057 echo " WARNING: Production frontend bundle missing. Check Dockerfile build stage." 2058 fi 2059 fi 2060 2061 # Verify fresh containers 2062 echo "" 2063 echo "=== Deployment Verification ===" 2064 echo "Instance: ${PROJECT_NAME}" 2065 echo "Mode: ${AG3NTUM_MODE}" 2066 echo "Image tag: ${IMAGE_TAG}" 2067 echo "Web Port: ${WEB_PORT}" 2068 echo "API Port: ${API_PORT}" 2069 echo "Redis Port: ${REDIS_PORT}" 2070 echo "" 2071 echo "Container status:" 2072 ${COMPOSE_CMD} ps 2073 echo "" 2074 echo "Web UI: http://localhost:${WEB_PORT}" 2075 echo "API: http://localhost:${API_PORT}" 2076 echo "" 2077 echo "=== Deployment complete at $(date) ==="