/ 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) ==="