# Copyright (c) 2026 Progralink. Licensed under the DevCage License — see LICENSE.
# shellcheck shell=bash
#
# volumes.sh — Volume construction module for the DevCage bash launcher.
# Sourced by devcage.sh after config resolution.
#
# Depends on (must be set by orchestrator before calling these functions):
#   AGENT_CONFIGS        — array from constants.sh (_load_agent_configs)
#   AGENT_CONFIG_RO      — "0" (rw), "1" (ro), or "skip"
#   HOST_AGENTS_CONFIG   — "ro", "rw", "no", or "disabled"
#   HOME                 — user home directory
#   WORKSPACE            — resolved workspace path
#   WORKSPACE_SLUG       — slug derived from workspace path
#   GLOBAL_CONFIG_DIR    — path to global config directory
#   USER_CONFIG_DIR      — path to user config directory (may be empty)
#   PROJECT_CONFIG_DIR   — path to project config directory (may be empty)
#   ACTIVE_PROFILES      — array of active profile names
#   EXTRA_SCRIPT_SCRIPT  — extra init script path (may be empty)
#   NO_CACHE_VOLUMES     — "0" or "1"
#   CACHE_DIRS           — comma-separated dir names, or ""
#   CONTAINER_ENGINE     — "docker" or "podman"
#   CUSTOM_VOLUMES       — array of SOURCE:DEST[:MODE] specs
#   GIT_AUTH             — "1" to mount git config
#   _versions_files      — array of versions.conf paths (may be empty)
#   _merged_versions     — merged versions.conf content
#   SANDBOX_TMPDIR       — per-session temp dir (cleaned on exit)
#   GIT_AUTH             — "1" to passthrough git env vars
#   GH_TOKEN             — optional GitHub token
#   GITHUB_TOKEN         — optional GitHub token
#   _ENV_BLOCKED_NAMES   — regex pattern for blocked env var names
#   _ENV_BLOCKED_PREFIXES — regex pattern for blocked env var prefixes
#   RUN_ARGS             — array to append --env-file/-e args to (_load_env_files)
#
# Populates (each function documents its outputs):
#   VOLUME_ARGS, INIT_VOLUME_ARGS, CRONTAB_VOLUME_ARGS, AGENTS_VOLUME_ARGS,
#   VERSIONS_VOLUME_ARGS, CACHE_VOLUME_ARGS, DIR_CACHE_VOLUME_ARGS,
#   CUSTOM_VOLUME_ARGS, DIR_CACHE_PATHS, _DIR_CACHE_COUNT,
#   _SANDBOX_PROJECT_INIT, _SANDBOX_PROJECT_CRONTAB

# ── Generic profile-file mount helper ────────────────────────────
# Mounts profile-specific config files from config.PROFILE/ subdirectories
# alongside a scope's base config directory.
#
# Usage: _mount_profile_files PARENT PREFIX FILENAME MOUNT_DIR SUFFIX OUT_ARRAY [IS_PROJECT]
#
#   PARENT        — parent dir containing config.PROFILE/ subdirectories
#   PREFIX        — numeric scope prefix (01, 02, 03)
#   FILENAME      — config filename to find (init.sh, crontab, AGENTS.md)
#   MOUNT_DIR     — container target dir (/sandbox-init.d, /sandbox-crontab.d, /sandbox-agents.d)
#   SUFFIX        — mounted filename suffix (.sh, .crontab, .md)
#   OUT_ARRAY     — name of the array to append -v args to (nameref)
#   IS_PROJECT    — name of a flag variable to set to 1 when prefix is "03" (nameref, optional)
_mount_profile_files() {
    local _parent="$1" _prefix="$2" _filename="$3" _mount_dir="$4" _suffix="$5"
    local _out_array_name="$6" _is_project_name="${7:-}"
    [ ${#ACTIVE_PROFILES[@]} -gt 0 ] || return 0
    declare -n _out_arr="$_out_array_name"
    local _pidx=0
    while IFS= read -r _pdir; do
        _pidx=$(( _pidx + 1 ))
        local _pfile="${_pdir}/${_filename}"
        if [ -f "$_pfile" ]; then
            local _pname
            _pname=$(basename "$_pdir")
            _pname="${_pname#config.}"
            _pname=$(echo "$_pname" | tr '.' '-')
            _out_arr+=(-v "${_pfile}:${_mount_dir}/${_prefix}p$(printf '%02d' "$_pidx")-${_pname}${_suffix}:ro")
            if [ "$_prefix" = "03" ] && [ "$_is_project_name" != "" ]; then
                declare -n _is_proj_ref="$_is_project_name"
                _is_proj_ref=1
                unset -n _is_proj_ref
            fi
        fi
    done < <(_profile_dirs "$_parent")
    unset -n _out_arr
}

# ── Specific profile-file helpers (thin wrappers around generic) ──

# _mount_profile_inits PARENT PREFIX
# Mounts profile init.sh files into INIT_VOLUME_ARGS.
# Sets _SANDBOX_PROJECT_INIT=1 for prefix "03".
_mount_profile_inits() {
    _mount_profile_files "$1" "$2" "init.sh" "/sandbox-init.d" ".sh" INIT_VOLUME_ARGS _SANDBOX_PROJECT_INIT
}

# _mount_profile_crontabs PARENT PREFIX
# Mounts profile crontab files into CRONTAB_VOLUME_ARGS.
# Sets _SANDBOX_PROJECT_CRONTAB=1 for prefix "03".
_mount_profile_crontabs() {
    _mount_profile_files "$1" "$2" "crontab" "/sandbox-crontab.d" ".crontab" CRONTAB_VOLUME_ARGS _SANDBOX_PROJECT_CRONTAB
}

# _mount_profile_agents PARENT PREFIX
# Mounts profile AGENTS.md files into AGENTS_VOLUME_ARGS.
_mount_profile_agents() {
    _mount_profile_files "$1" "$2" "AGENTS.md" "/sandbox-agents.d" ".md" AGENTS_VOLUME_ARGS
}

# ── Agent config volumes ──────────────────────────────────────────
# Builds VOLUME_ARGS for agent config directories / volumes.
# Also handles macOS Keychain credential extraction and git auth mounts.
#
# Reads:  AGENT_CONFIGS, HOST_AGENTS_CONFIG, HOME, SANDBOX_TMPDIR, GIT_AUTH
# Writes: VOLUME_ARGS (initialized fresh), AGENT_CONFIG_RO (exported)
_build_agent_config_volumes() {
    VOLUME_ARGS=()

    case "$HOST_AGENTS_CONFIG" in
        rw)          export AGENT_CONFIG_RO=0 ;;
        no|disabled) export AGENT_CONFIG_RO=skip ;;
        *)           export AGENT_CONFIG_RO=1 ;;  # ro (default)
    esac

    if [ "$HOST_AGENTS_CONFIG" != "no" ] && [ "$HOST_AGENTS_CONFIG" != "disabled" ]; then
        for _cfg in "${AGENT_CONFIGS[@]}"; do
            IFS=':' read -r _host_dir _ro_mount _rw_mount _vol_name <<< "$_cfg"
            if [ -d "$_host_dir" ]; then
                if [ "$HOST_AGENTS_CONFIG" = "rw" ]; then
                    VOLUME_ARGS+=(-v "${_host_dir}:${_rw_mount}")
                else
                    VOLUME_ARGS+=(-v "${_host_dir}:${_ro_mount}:ro")
                fi
            else
                if [ "$HOST_AGENTS_CONFIG" = "rw" ]; then
                    # RW mode: mount fallback volume directly at home path
                    VOLUME_ARGS+=(-v "${_vol_name}:${_rw_mount}")
                else
                    # RO mode: mount under /agent-config/ so entrypoint can
                    # persist state in workspace via symlinks
                    VOLUME_ARGS+=(-v "${_vol_name}:${_ro_mount}:ro")
                fi
            fi
        done

        # Claude: global config file (~/.claude.json) — stores onboarding state, theme, etc.
        local _claude_json="${HOME}/.claude.json"
        if [ -f "$_claude_json" ]; then
            if [ "$HOST_AGENTS_CONFIG" = "rw" ]; then
                VOLUME_ARGS+=(-v "${_claude_json}:/home/devcage/.claude.json")
            else
                VOLUME_ARGS+=(-v "${_claude_json}:/agent-config/claude.json:ro")
            fi
        fi

        # Aider: single file, not directory
        local _aider_conf="${HOME}/.aider.conf.yml"
        if [ -f "$_aider_conf" ]; then
            if [ "$HOST_AGENTS_CONFIG" = "rw" ]; then
                VOLUME_ARGS+=(-v "${_aider_conf}:/home/devcage/.aider.conf.yml")
            else
                VOLUME_ARGS+=(-v "${_aider_conf}:/agent-config/aider.conf.yml:ro")
            fi
        fi
    fi  # HOST_AGENTS_CONFIG != no/disabled

    # ── macOS Keychain credential extraction for Claude Code ──────
    # On macOS, Claude Code stores OAuth credentials in the Keychain
    # (service "Claude Code-credentials"), not as a file in ~/.claude/.
    # Extract and mount as a temp file so the entrypoint overlay picks it up.
    if [ "$(uname -s)" = "Darwin" ] && \
       [ "$HOST_AGENTS_CONFIG" = "ro" ] && \
       [ "${ANTHROPIC_API_KEY:-}" = "" ] && \
       [ "${CLAUDE_CODE_OAUTH_TOKEN:-}" = "" ] && \
       [ ! -f "${HOME}/.claude/.credentials.json" ]; then
        local _keychain_cred
        _keychain_cred="$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null || true)"
        if [ "$_keychain_cred" != "" ]; then
            local _cred_file="${SANDBOX_TMPDIR}/.credentials.json"
            if ( umask 077; printf '%s' "$_keychain_cred" > "$_cred_file" ); then
                VOLUME_ARGS+=(-v "${_cred_file}:/agent-config/claude-credentials.json:ro")
            else
                echo "Warning: failed to write Keychain credentials to temp file — Claude may require re-authentication" >&2
            fi
            unset _keychain_cred
        fi
    fi

    # ── Git config & credentials (--git-auth, volumes) ────────────────
    if [ "$GIT_AUTH" = "1" ]; then
        if [ -f "${HOME}/.gitconfig" ]; then
            VOLUME_ARGS+=(-v "${HOME}/.gitconfig:/agent-config/gitconfig:ro")
        fi
        if [ -f "${HOME}/.git-credentials" ]; then
            VOLUME_ARGS+=(-v "${HOME}/.git-credentials:/agent-config/git-credentials:ro")
        fi
    fi
}

# ── Init script volumes ───────────────────────────────────────────
# Builds INIT_VOLUME_ARGS for /sandbox-init.d/*.sh mounts.
# Sets _SANDBOX_PROJECT_INIT=1 if any project-level init script is found.
#
# Reads:  GLOBAL_CONFIG_DIR, USER_CONFIG_DIR, PROJECT_CONFIG_DIR,
#         ACTIVE_PROFILES, EXTRA_SCRIPT_SCRIPT
# Writes: INIT_VOLUME_ARGS, _SANDBOX_PROJECT_INIT
_build_init_volumes() {
    INIT_VOLUME_ARGS=()
    _SANDBOX_PROJECT_INIT=0

    if [ -f "${GLOBAL_CONFIG_DIR}/init.sh" ]; then
        INIT_VOLUME_ARGS+=(-v "${GLOBAL_CONFIG_DIR}/init.sh:/sandbox-init.d/01-global.sh:ro")
    fi
    _mount_profile_inits "$(dirname "$GLOBAL_CONFIG_DIR")" "01"

    if [ "$USER_CONFIG_DIR" != "" ]; then
        if [ -f "${USER_CONFIG_DIR}/init.sh" ]; then
            INIT_VOLUME_ARGS+=(-v "${USER_CONFIG_DIR}/init.sh:/sandbox-init.d/02-user.sh:ro")
        fi
        _mount_profile_inits "$(dirname "$USER_CONFIG_DIR")" "02"
    fi

    if [ "$PROJECT_CONFIG_DIR" != "" ]; then
        if [ -f "${PROJECT_CONFIG_DIR}/init.sh" ]; then
            INIT_VOLUME_ARGS+=(-v "${PROJECT_CONFIG_DIR}/init.sh:/sandbox-init.d/03-project.sh:ro")
            _SANDBOX_PROJECT_INIT=1
        fi
        _mount_profile_inits "$(dirname "$PROJECT_CONFIG_DIR")" "03"
    fi

    # Extra init script (--script flag or EXTRA_SCRIPT key) — sourced last
    if [ "${EXTRA_SCRIPT_SCRIPT:-}" != "" ]; then
        if [[ "$EXTRA_SCRIPT_SCRIPT" != /* ]]; then
            EXTRA_SCRIPT_SCRIPT="$(cd "$(dirname "$EXTRA_SCRIPT_SCRIPT")" 2>/dev/null && pwd)/$(basename "$EXTRA_SCRIPT_SCRIPT")"
        fi
        if [ -f "$EXTRA_SCRIPT_SCRIPT" ]; then
            INIT_VOLUME_ARGS+=(-v "${EXTRA_SCRIPT_SCRIPT}:/sandbox-init.d/04-custom.sh:ro")
        fi
    fi
}

# ── Crontab file volumes ──────────────────────────────────────────
# Builds CRONTAB_VOLUME_ARGS for /sandbox-crontab.d/*.crontab mounts.
# Sets _SANDBOX_PROJECT_CRONTAB=1 if any project-level crontab is found.
#
# Reads:  GLOBAL_CONFIG_DIR, USER_CONFIG_DIR, PROJECT_CONFIG_DIR, ACTIVE_PROFILES
# Writes: CRONTAB_VOLUME_ARGS, _SANDBOX_PROJECT_CRONTAB
_build_crontab_volumes() {
    CRONTAB_VOLUME_ARGS=()
    _SANDBOX_PROJECT_CRONTAB=0

    if [ -f "${GLOBAL_CONFIG_DIR}/crontab" ]; then
        CRONTAB_VOLUME_ARGS+=(-v "${GLOBAL_CONFIG_DIR}/crontab:/sandbox-crontab.d/01-global.crontab:ro")
    fi
    _mount_profile_crontabs "$(dirname "$GLOBAL_CONFIG_DIR")" "01"

    if [ "$USER_CONFIG_DIR" != "" ]; then
        if [ -f "${USER_CONFIG_DIR}/crontab" ]; then
            CRONTAB_VOLUME_ARGS+=(-v "${USER_CONFIG_DIR}/crontab:/sandbox-crontab.d/02-user.crontab:ro")
        fi
        _mount_profile_crontabs "$(dirname "$USER_CONFIG_DIR")" "02"
    fi

    if [ "$PROJECT_CONFIG_DIR" != "" ]; then
        if [ -f "${PROJECT_CONFIG_DIR}/crontab" ]; then
            CRONTAB_VOLUME_ARGS+=(-v "${PROJECT_CONFIG_DIR}/crontab:/sandbox-crontab.d/03-project.crontab:ro")
            _SANDBOX_PROJECT_CRONTAB=1
        fi
        _mount_profile_crontabs "$(dirname "$PROJECT_CONFIG_DIR")" "03"
    fi
}

# ── AGENTS.md file volumes ────────────────────────────────────────
# Builds AGENTS_VOLUME_ARGS for /sandbox-agents.d/*.md mounts.
# No confirmation required (agent instructions, not executable code).
#
# Reads:  GLOBAL_CONFIG_DIR, USER_CONFIG_DIR, PROJECT_CONFIG_DIR, ACTIVE_PROFILES
# Writes: AGENTS_VOLUME_ARGS
_build_agents_volumes() {
    AGENTS_VOLUME_ARGS=()

    if [ -f "${GLOBAL_CONFIG_DIR}/AGENTS.md" ]; then
        AGENTS_VOLUME_ARGS+=(-v "${GLOBAL_CONFIG_DIR}/AGENTS.md:/sandbox-agents.d/01-global.md:ro")
    fi
    _mount_profile_agents "$(dirname "$GLOBAL_CONFIG_DIR")" "01"

    if [ "$USER_CONFIG_DIR" != "" ]; then
        if [ -f "${USER_CONFIG_DIR}/AGENTS.md" ]; then
            AGENTS_VOLUME_ARGS+=(-v "${USER_CONFIG_DIR}/AGENTS.md:/sandbox-agents.d/02-user.md:ro")
        fi
        _mount_profile_agents "$(dirname "$USER_CONFIG_DIR")" "02"
    fi

    if [ "$PROJECT_CONFIG_DIR" != "" ]; then
        if [ -f "${PROJECT_CONFIG_DIR}/AGENTS.md" ]; then
            AGENTS_VOLUME_ARGS+=(-v "${PROJECT_CONFIG_DIR}/AGENTS.md:/sandbox-agents.d/03-project.md:ro")
        fi
        _mount_profile_agents "$(dirname "$PROJECT_CONFIG_DIR")" "03"
    fi
}

# ── Versions config volume ────────────────────────────────────────
# Writes merged versions.conf to WORKSPACE/.devcage/ and builds VERSIONS_VOLUME_ARGS.
# Written to workspace (not SANDBOX_TMPDIR) so the bind mount survives past this
# launcher process (SANDBOX_TMPDIR is deleted on exit, which breaks the mount in
# containers that outlive the launcher on Docker Desktop).
#
# Reads:  _versions_files (array), _merged_versions (string), WORKSPACE
# Writes: VERSIONS_VOLUME_ARGS
_build_versions_volume() {
    # shellcheck disable=SC2034  # VERSIONS_VOLUME_ARGS consumed by the sourcing script
    VERSIONS_VOLUME_ARGS=()
    # shellcheck disable=SC2154  # _versions_files and _merged_versions set by config.sh
    if [ ${#_versions_files[@]} -gt 0 ]; then
        local _devcage_dir="${WORKSPACE}/.devcage"
        mkdir -p "$_devcage_dir" 2>/dev/null || true
        local _merged_versions_file="${_devcage_dir}/merged-versions.conf"
        printf '%s\n' "$_merged_versions" > "$_merged_versions_file"
        # Gitignore this generated file so it doesn't appear as untracked
        if ! grep -qxF 'merged-versions.conf' "${_devcage_dir}/.gitignore" 2>/dev/null; then
            printf 'merged-versions.conf\n' >> "${_devcage_dir}/.gitignore" 2>/dev/null || true
        fi
        # shellcheck disable=SC2034  # consumed by the sourcing script
        VERSIONS_VOLUME_ARGS=(-v "${_merged_versions_file}:/etc/devcage/versions.conf:ro")
    fi
}

# ── Package cache volumes ─────────────────────────────────────────
# Builds CACHE_VOLUME_ARGS for the per-workspace /cache and ~/.local volumes.
# A single per-workspace volume is mounted at /cache; the entrypoint creates
# subdirectories and symlinks from standard cache locations.
#
# Reads:  NO_CACHE_VOLUMES, WORKSPACE_SLUG
# Writes: CACHE_VOLUME_ARGS
_build_cache_volumes() {
    # shellcheck disable=SC2034  # CACHE_VOLUME_ARGS consumed by the sourcing script
    CACHE_VOLUME_ARGS=()
    if [ "$NO_CACHE_VOLUMES" = "0" ]; then
        # shellcheck disable=SC2034  # consumed by the sourcing script
        CACHE_VOLUME_ARGS=(
            -v "devcage_${WORKSPACE_SLUG:-default}_cache:/cache"
            -v "devcage_${WORKSPACE_SLUG:-default}_local:/home/devcage/.local"
        )
    fi
}

# ── Directory cache volumes ───────────────────────────────────────
# Mounts Docker volumes on top of heavy-I/O directories inside /workspace
# for native ext4 performance on WSL2/VirtioFS (5-20x faster for node_modules).
# Overlaid dirs are cached on Docker volumes — fast but volatile (lost on --clean).
#
# Reads:  NO_CACHE_VOLUMES, CACHE_DIRS, CONTAINER_ENGINE, WORKSPACE, WORKSPACE_SLUG
# Writes: DIR_CACHE_VOLUME_ARGS, DIR_CACHE_PATHS, _DIR_CACHE_COUNT
_build_dir_cache_volumes() {
    DIR_CACHE_VOLUME_ARGS=()
    DIR_CACHE_PATHS=""
    _DIR_CACHE_COUNT=0
    _has_subpath=0  # module-scope global; read by _precreate_dircache_dirs
    _dc_paths=()    # module-scope global; read by _precreate_dircache_dirs

    if [ "$NO_CACHE_VOLUMES" = "0" ] && [ "$CACHE_DIRS" != "" ]; then
        # Detect engine version for volume-subpath support (Docker >= 26, Podman >= 5)
        local _engine_major=0
        local _engine_ver_raw
        _engine_ver_raw="$("$CONTAINER_ENGINE" version --format '{{.Server.Version}}' 2>/dev/null || echo "0")"
        if [[ "$_engine_ver_raw" =~ ^([0-9]+) ]]; then
            _engine_major="${BASH_REMATCH[1]}"
        fi
        _has_subpath=0
        if [ "$CONTAINER_ENGINE" = "docker" ] && [ "$_engine_major" -ge 26 ]; then
            _has_subpath=1
        elif [ "$CONTAINER_ENGINE" = "podman" ] && [ "$_engine_major" -ge 5 ]; then
            _has_subpath=1
        fi

        local -a _cache_dir_names
        IFS=',' read -ra _cache_dir_names <<< "$CACHE_DIRS"
        _dc_paths=()
        local _dc_name
        for _dc_name in "${_cache_dir_names[@]}"; do
            _dc_name="$(echo "$_dc_name" | tr -d '[:space:]')"
            [ "$_dc_name" = "" ] && continue
            # Validate: only allow safe characters (alphanumeric, dot, hyphen, underscore)
            if [[ ! "$_dc_name" =~ ^[a-zA-Z0-9._-]+$ ]]; then
                echo "Warning: ignoring invalid cache-dirs name '${_dc_name}' — only [a-zA-Z0-9._-] allowed" >&2
                continue
            fi
            if [ "$_dc_name" = "node_modules" ]; then
                # Special case: scan for package.json to discover all locations
                local _pjson _pdir _reldir
                while IFS= read -r _pjson; do
                    _pdir="$(dirname "$_pjson")"
                    _reldir="${_pdir#"${WORKSPACE}"/}"
                    if [ "$_reldir" = "$WORKSPACE" ] || [ "$_reldir" = "$_pdir" ]; then
                        # Root package.json
                        _dc_paths+=("node_modules")
                    else
                        _dc_paths+=("${_reldir}/node_modules")
                    fi
                done < <(find "$WORKSPACE" -maxdepth 5 -name 'package.json' -not -path '*/node_modules/*' -not -path '*/.devcage/*' 2>/dev/null | sort)
            else
                # Generic: scan for existing directories with that name
                local _found_dir _reldir
                while IFS= read -r _found_dir; do
                    _reldir="${_found_dir#"${WORKSPACE}"/}"
                    if [ "$_reldir" != "$WORKSPACE" ] && [ "$_reldir" != "$_found_dir" ]; then
                        _dc_paths+=("$_reldir")
                    else
                        _dc_paths+=("$_dc_name")
                    fi
                done < <(find "$WORKSPACE" -maxdepth 3 -type d -name "$_dc_name" -not -path '*/node_modules/*' -not -path '*/.devcage/*' 2>/dev/null | sort)
            fi
        done

        # Build volume args for each detected path
        local _dc_path _sanitized
        for _dc_path in "${_dc_paths[@]+"${_dc_paths[@]}"}"; do
            if [ "$_has_subpath" = "1" ]; then
                DIR_CACHE_VOLUME_ARGS+=(--mount "type=volume,source=devcage_${WORKSPACE_SLUG:-default}_cache,target=/workspace/${_dc_path},volume-subpath=dircache/${_dc_path}")
            else
                # Fallback: separate named volume per path
                _sanitized="$(printf '%s' "$_dc_path" | tr '/' '-' | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g')"
                DIR_CACHE_VOLUME_ARGS+=(-v "devcage_${WORKSPACE_SLUG:-default}_dircache_${_sanitized}:/workspace/${_dc_path}")
            fi
        done

        _DIR_CACHE_COUNT=${#_dc_paths[@]}
        if [ "$_DIR_CACHE_COUNT" -gt 0 ]; then
            # Join paths as comma-separated list for entrypoint seeding
            printf -v DIR_CACHE_PATHS '%s,' "${_dc_paths[@]+"${_dc_paths[@]}"}"
            DIR_CACHE_PATHS="${DIR_CACHE_PATHS%,}"
            # Mount workspace read-only for entrypoint seeding
            DIR_CACHE_VOLUME_ARGS+=(-v "${WORKSPACE}:/workspace-host:ro")
            DIR_CACHE_VOLUME_ARGS+=(-e "DIR_CACHE_PATHS=${DIR_CACHE_PATHS}")
        fi
    fi
}

# ── Custom volume mounts ──────────────────────────────────────────
# Validates and builds CUSTOM_VOLUME_ARGS from CUSTOM_VOLUMES array.
# Calls _validate_volume_spec (from utils.sh) for each entry; exits on error.
#
# Reads:  CUSTOM_VOLUMES (array)
# Writes: CUSTOM_VOLUME_ARGS
_build_custom_volumes() {
    CUSTOM_VOLUME_ARGS=()
    local _vol_spec _validated
    for _vol_spec in "${CUSTOM_VOLUMES[@]+"${CUSTOM_VOLUMES[@]}"}"; do
        _validated="$(_validate_volume_spec "$_vol_spec")" || exit 1
        CUSTOM_VOLUME_ARGS+=(-v "$_validated")
    done
}

# ── Env file loading ──────────────────────────────────────────────
# Processes .env files from the 3 config scopes (global, user, project).
# Project-level files are filtered to block dangerous variable names and prefixes.
# Git auth token file is written and appended last when GIT_AUTH=1.
# Appends --env-file and -e args directly to RUN_ARGS.
#
# Reads:  GLOBAL_CONFIG_DIR, USER_CONFIG_DIR, PROJECT_CONFIG_DIR, ACTIVE_PROFILES,
#         _ENV_BLOCKED_NAMES, _ENV_BLOCKED_PREFIXES,
#         SANDBOX_TMPDIR, GIT_AUTH, GH_TOKEN, GITHUB_TOKEN, ENV_VARS
# Writes: RUN_ARGS (appended)
_load_env_files() {
    # Collect .env files per scope (base + profiles)
    local -a _all_env_files=()
    local _f
    while IFS= read -r _f; do _all_env_files+=("global:$_f"); done < <(_scope_files "$GLOBAL_CONFIG_DIR" ".env")
    if [ "$USER_CONFIG_DIR" != "" ]; then
        while IFS= read -r _f; do _all_env_files+=("user:$_f"); done < <(_scope_files "$USER_CONFIG_DIR" ".env")
    fi
    if [ "$PROJECT_CONFIG_DIR" != "" ]; then
        while IFS= read -r _f; do _all_env_files+=("project:$_f"); done < <(_scope_files "$PROJECT_CONFIG_DIR" ".env")
    fi

    local _project_env_counter=0
    local _env_tagged _env_scope _env_file_path
    for _env_tagged in "${_all_env_files[@]+"${_all_env_files[@]}"}"; do
        _env_scope="${_env_tagged%%:*}"
        _env_file_path="${_env_tagged#*:}"
        if [ -f "$_env_file_path" ]; then
            # Check if file has at least one non-comment, non-blank line
            if grep -qE '^[^#[:space:]]' "$_env_file_path" 2>/dev/null; then
                if [ "$_env_scope" = "project" ]; then
                    # Filter project-level .env to prevent privilege escalation via env vars
                    _project_env_counter=$(( _project_env_counter + 1 ))
                    local _filtered="${SANDBOX_TMPDIR}/.project-env-filtered-${_project_env_counter}"
                    local _dropped=0
                    local _line
                    while IFS= read -r _line || [ "$_line" != "" ]; do
                        _line="${_line%$'\r'}"  # strip Windows CR
                        # Pass through comments, blank lines, and safe variables
                        case "$_line" in
                            '#'*|'') printf '%s\n' "$_line" >> "$_filtered"; continue ;;
                        esac
                        if echo "$_line" | grep -qiE "$_ENV_BLOCKED_NAMES" || \
                           echo "$_line" | grep -qiE "$_ENV_BLOCKED_PREFIXES"; then
                            _dropped=$(( _dropped + 1 ))
                        else
                            printf '%s\n' "$_line" >> "$_filtered"
                        fi
                    done < "$_env_file_path"
                    if [ "$_dropped" -gt 0 ]; then
                        echo "Warning: filtered ${_dropped} blocked variable(s) from project .env ($(basename "$(dirname "$_env_file_path")"))" >&2
                    fi
                    if grep -qE '^[^#[:space:]]' "$_filtered" 2>/dev/null; then
                        RUN_ARGS+=(--env-file "$_filtered")
                    fi
                else
                    RUN_ARGS+=(--env-file "$_env_file_path")
                fi
            fi
        fi
    done

    # Git auth env vars (--git-auth, token passthrough via env-file to avoid ps aux exposure)
    if [ "$GIT_AUTH" = "1" ]; then
        local _git_env_file="${SANDBOX_TMPDIR}/.git-env"
        ( umask 077; : > "$_git_env_file" )
        [ "${GH_TOKEN:-}" != "" ] && printf 'GH_TOKEN=%s\n' "$GH_TOKEN" >> "$_git_env_file"
        [ "${GITHUB_TOKEN:-}" != "" ] && printf 'GITHUB_TOKEN=%s\n' "$GITHUB_TOKEN" >> "$_git_env_file"
        if [ -s "$_git_env_file" ]; then
            RUN_ARGS+=(--env-file "$_git_env_file")
        fi
    fi

    # Custom environment variables (--env KEY=VALUE, repeatable)
    # Placed after --env-file so CLI --env overrides file-based values.
    local _env
    for _env in "${ENV_VARS[@]+"${ENV_VARS[@]}"}"; do
        RUN_ARGS+=(-e "$_env")
    done
}

# ── Dircache pre-creation ─────────────────────────────────────────
# Pre-create dircache subdirectories inside the cache volume so Docker
# volume-subpath mounts can find the target directory.
# Must be called AFTER _build_dir_cache_volumes (which sets _has_subpath
# and _dc_paths) and AFTER the image build (image must exist to run mkdir).
#
# Reads:  _has_subpath, _dc_paths, WORKSPACE_SLUG, CONTAINER_ENGINE, DEVCAGE_IMAGE
# Effect: creates /vol/dircache/* inside the cache volume
_precreate_dircache_dirs() {
    [ "$_has_subpath" = "1" ] || return 0
    [ ${#_dc_paths[@]} -gt 0 ] || return 0
    local _dircache_dirs=() _dc_path
    for _dc_path in "${_dc_paths[@]+"${_dc_paths[@]}"}"; do
        _dircache_dirs+=("/vol/dircache/${_dc_path}")
    done
    local _cache_vol="devcage_${WORKSPACE_SLUG:-default}_cache"
    # Ensure volume exists before running mkdir
    "$CONTAINER_ENGINE" volume create "$_cache_vol" >/dev/null 2>&1 || true
    if ! printf '%s\n' "${_dircache_dirs[@]}" | "$CONTAINER_ENGINE" run --rm -i \
        --entrypoint /bin/sh \
        --user root \
        -v "${_cache_vol}:/vol" \
        "${DEVCAGE_IMAGE:-devcage-workspace}" -c 'xargs -d"\n" mkdir -p' 2>/dev/null; then
        echo "Warning: failed to pre-create dircache dirs in cache volume; directory caches may not work" >&2
    fi
}

# ── All-volumes orchestrator ──────────────────────────────────────
# Calls all individual volume builders in sequence.
# Must be called after config resolution (WORKSPACE, WORKSPACE_SLUG, etc. are set)
# and before _make_build_decision and _run_container.
_build_all_volumes() {
    _build_agent_config_volumes
    _build_init_volumes
    _build_crontab_volumes
    _build_agents_volumes
    _build_versions_volume
    _build_cache_volumes
    _build_dir_cache_volumes
    _build_custom_volumes
    # Note: _load_env_files is NOT called here because it appends to RUN_ARGS,
    # which must first be initialized by _build_run_args (container.sh).
    # The orchestrator calls _load_env_files() after _build_run_args().
}
