diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..205d8a3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + # Enable version updates for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "github-actions" + commit-message: + prefix: "ci" + include: "scope" + reviewers: + - "3scale-labs/maintainers" + assignees: + - "3scale-labs/maintainers" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..4557c85 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,149 @@ +name: Tests + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + name: Run Test Suite + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get install -y expect apparmor-profiles + + - name: Get bubblewrap latest commit + id: bwrap-commit + run: | + COMMIT=$(git ls-remote https://github.com/containers/bubblewrap.git HEAD | cut -f1) + echo "commit=$COMMIT" >> $GITHUB_OUTPUT + echo "Bubblewrap latest commit: $COMMIT" + + - name: Restore bubblewrap from cache + id: cache-bwrap + uses: actions/cache/restore@v4 + with: + path: /tmp/bwrap-bin + key: bwrap-${{ runner.os }}-${{ steps.bwrap-commit.outputs.commit }} + + - name: Install build dependencies + if: steps.cache-bwrap.outputs.cache-hit != 'true' + run: | + sudo apt-get install -y \ + meson \ + ninja-build \ + libcap-dev \ + gcc \ + pkg-config + + - name: Build latest bubblewrap + if: steps.cache-bwrap.outputs.cache-hit != 'true' + run: | + # Clone and build latest bubblewrap + cd /tmp + git clone https://github.com/containers/bubblewrap.git + cd bubblewrap + meson setup builddir --prefix=/usr + meson compile -C builddir + + # Prepare binary for caching + mkdir -p /tmp/bwrap-bin + cp builddir/bwrap /tmp/bwrap-bin/ + + - name: Save bubblewrap to cache + if: steps.cache-bwrap.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: /tmp/bwrap-bin + key: bwrap-${{ runner.os }}-${{ steps.bwrap-commit.outputs.commit }} + + - name: Install bubblewrap + run: | + sudo cp /tmp/bwrap-bin/bwrap /usr/bin/bwrap + sudo chmod +x /usr/bin/bwrap + + # Verify version (should be 0.11.0 or later) + bwrap --version + + - name: Setup user namespaces for bubblewrap + run: | + # Configure subuid and subgid for unprivileged user namespaces + USERNAME=$(whoami) + + # Add subuid mapping (100000 UIDs starting from 100000) + # echo "$USERNAME:100000:65536" | sudo tee -a /etc/subuid + + # Add subgid mapping (100000 GIDs starting from 100000) + # echo "$USERNAME:100000:65536" | sudo tee -a /etc/subgid + + # Enable unprivileged user namespaces (should already be enabled on Ubuntu) + # echo "kernel.unprivileged_userns_clone=1" | sudo tee -a /etc/sysctl.conf + # sudo sysctl -p + + # Setup AppArmor profile for bubblewrap user namespaces + sudo ln -s /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap + sudo apparmor_parser /etc/apparmor.d/bwrap + + # Verify setup + cat /etc/subuid + cat /etc/subgid + + - name: Setup test environment + run: | + # Create isolated home for agent + mkdir -p ~/aihome + + # Initialize git config (required for some tests) + git config --global user.email "ci@github.actions" + git config --global user.name "GitHub Actions" + + - name: Run merge-overlay tests + run: | + cd ${{ github.workspace }} + ./tests/test-merge-overlay.expect + + - name: Setup for agent-dev tests + run: | + # Ensure we're in a git repo with proper setup + cd ${{ github.workspace }} + git status || git init + + # Create .claude directory structure if needed + mkdir -p .claude + touch .claude/settings.local.json || true + + - name: Run agent-dev integration tests + run: | + cd ${{ github.workspace }} + export PATH="$PWD:$PATH" + ./tests/test-agent-dev.expect + + - name: Test summary + if: always() + run: | + echo "Test execution completed" + echo "Check logs above for detailed results" + + lint: + name: Shell Script Linting + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install ShellCheck + run: sudo apt-get install -y shellcheck + + - name: Lint shell scripts + run: | + echo "Linting bash scripts..." + shellcheck agent-dev merge-overlay || true + echo "Note: Some warnings may be acceptable for this use case" diff --git a/.gitignore b/.gitignore index e3200e0..212f33b 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ build-iPhoneSimulator/ # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: .rvmrc +.tool-versions # Used by RuboCop. Remote config files pulled in from inherit_from directive. # .rubocop-https?--* diff --git a/README.md b/README.md index a6d2fda..9a241b1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +# Agent Dev Environment + +[![Tests](https://github.com/3scale-labs/agent-dev-env/actions/workflows/tests.yml/badge.svg)](https://github.com/3scale-labs/agent-dev-env/actions/workflows/tests.yml) + # Goal The goal is to easily start your coding agent within a secure sandbox environment that closely resembles your actual development environment and safely merge the results back. @@ -13,6 +17,7 @@ There are existing attempts to sandbox agent processes, the most serious I find # Usage +* requires Bubblewrap 0.11+ for the overlayfs support * add the script(s) to your PATH * `mkdir -p ~/aihome` * agent-dev bash @@ -30,4 +35,19 @@ Now you can just call the script from within a git working tree and feel the fal * also `asdf`, `npm` and other directories from user's real home are monuted read-only inside the sandbox home so that you have access to these installations without ability to install, remove and modify them * note: *this means you need to install all necessary libraries from the host* -TODO: what is possibly unsafe - e.g. gitignored files, careful when merging (if I implement this) +The biggest concern I see is merging the changes after exiting the agent. One has to be careful with merging files that are a potential treat outside the sanbox. + +A lesser but potentially important in some environments is the cocern that a malware may set roots into the sandbox and use local resources or network access for malicious purposes. On signs of weird behavior, I'd suggest investigating and wiping the agent home dir to start over. + +## Running Tests + +```bash +# Install expect if not already installed +sudo apt install expect # Debian/Ubuntu +# or +sudo dnf install expect # Fedora/RHEL + +# Run all tests +./tests/test-merge-overlay.expect # Test the merge workflow +./tests/test-agent-dev.expect # Test isolation and sandbox behavior +``` diff --git a/agent-dev b/agent-dev new file mode 100755 index 0000000..6398b8f --- /dev/null +++ b/agent-dev @@ -0,0 +1,319 @@ +#!/bin/bash +# claude-code-isolated: Secure bubblewrap wrapper for running commands in isolation +# +# Provides isolated environment with: +# - ~/aihome mounted at your actual $HOME path (mostly writable) +# - Current repo mounted with overlayfs (changes isolated for review) +# - All paths preserved to avoid hardcoded path issues +# - No access to sensitive files (~/.ssh, ~/.aws, ~/.gnupg, etc.) +# +# Overlay security model: +# - Repository changes are written to ~/aihome/.overlay//upper +# - Original repository remains untouched (read-only lower layer) +# - After session, review and merge changes with: ./merge-overlay +# +# Setup: +# 1. Create isolated home: mkdir -p ~/aihome +# 2. Login to Claude in the sandbox: +# cd /path/to/your/repo +# ./agent-dev bash +# claude # Follow login prompts +# exit +# 3. (Optional) Disable auto-updates in isolated home: +# mkdir -p ~/aihome/.claude +# echo '{"autoUpdates": false}' > ~/aihome/.claude/settings.local.json +# +# Usage: +# cd /path/to/your/repo +# ./agent-dev # Runs claude +# ./agent-dev bash # Inspect the environment +# ./agent-dev vim file # Run any command +# +# After session - review changes: +# ./merge-overlay /path/to/repo ~/aihome/.overlay/path/to/repo/upper + +set -euo pipefail + +# Configuration +AI_HOME="${AI_HOME:-$HOME/aihome}" +REPO_DIR="$(pwd)" + +# Overlay directories for repository isolation (using full path to avoid conflicts) +OVERLAY_BASE="$AI_HOME/.overlay$(realpath "$REPO_DIR")" +OVERLAY_UPPER="$OVERLAY_BASE/upper" +OVERLAY_WORK="$OVERLAY_BASE/work" + +# ===== Protected paths in repository (read-only if exist, defense-in-depth) ===== +PROTECTED_REPO_FILES=( + ".git/config" + ".git/info/exclude" # Prevent sneaking ignore rules (not visible in git status) + ".gitconfig" + ".gitmodules" + ".ripgreprc" + ".project" + ".classpath" +) + +PROTECTED_REPO_DIRS=( + ".git/hooks" + ".vscode" + ".idea" + ".settings" + ".claude/commands" + ".claude/agents" +) + +# ===== Protected paths in agent home (read-only if exist) ===== +PROTECTED_AGENT_HOME_FILES=( + ".gitconfig" # Safe config created during setup + ".bashrc" + ".bash_profile" + ".zshrc" + ".zprofile" + ".profile" + ".npmrc" + ".gemrc" + ".inputrc" +) + +PROTECTED_AGENT_HOME_DIRS=( + ".ssh" # If accidentally created, keep read-only + ".bashrc.d" # Bash config scripts sourced by .bashrc + ".zshrc.d" # Zsh config scripts (if used) +) + +# ===== Whitelisted paths from real home (read-only mounts) ===== +WHITELISTED_HOME_FILES=( + # Add files from real home that should be readable (if any) +) + +WHITELISTED_HOME_DIRS=( + ".asdf" + ".npm" + ".bundle" + ".gem" + ".cargo" + ".rustup" + ".local" + "bin" +) + +# Validate environment +if [[ ! -d "$AI_HOME" ]]; then + echo "⚠️ AI home directory does not exist: $AI_HOME" + echo "" + echo "This directory will be used as Claude Code's isolated home." + echo "Create it with: mkdir -p $AI_HOME" + echo "" + exit 1 +fi + +if [[ ! -d .git ]]; then + echo "Error: Must run from a git repository root" + exit 1 +fi + +# Create overlay directories for repository isolation +mkdir -p "$OVERLAY_UPPER" "$OVERLAY_WORK" + +# Update Claude before entering sandbox (via npm, not trusting claude update) +echo "🔄 Checking for Claude updates via npm..." +npm update -g @anthropics/claude-code 2>&1 | grep -E "(changed|up to date)" || true +echo "" + +# Prepare Claude settings: create permissive default in temp file +CLAUDE_SETTINGS_TEMP=$(mktemp) + +cat > "$CLAUDE_SETTINGS_TEMP" << 'EOF' +{ + "autoApprove": { + "bash": ["*"], + "read": ["*"], + "write": ["*"], + "edit": ["*"] + }, + "autoUpdates": false +} +EOF + +# Cleanup function: remove temp files +cleanup() { + rm -f "$CLAUDE_SETTINGS_TEMP" +} + +# Register cleanup on exit (handles normal exit, errors, and interrupts) +trap cleanup EXIT + +# Resolve DNS configuration (handles symlinks like /etc/resolv.conf -> /run/systemd/resolve/stub-resolv.conf) +RESOLV_CONF_TARGET=$(readlink -f /etc/resolv.conf) + +# Build bwrap arguments for protected repo paths +BWRAP_REPO_PROTECTIONS=() + +# Protected files: ro-bind if exists (overlayfs will catch any creation attempts in upper) +for file in "${PROTECTED_REPO_FILES[@]}"; do + if [[ -f "$REPO_DIR/$file" ]]; then + BWRAP_REPO_PROTECTIONS+=(--ro-bind "$REPO_DIR/$file" "$REPO_DIR/$file") + fi +done + +# Protected directories: ro-bind if exists (overlayfs will catch any creation attempts in upper) +for dir in "${PROTECTED_REPO_DIRS[@]}"; do + if [[ -d "$REPO_DIR/$dir" ]]; then + BWRAP_REPO_PROTECTIONS+=(--ro-bind "$REPO_DIR/$dir" "$REPO_DIR/$dir") + fi +done + +# Build bwrap arguments for whitelisted real home paths +BWRAP_HOME_WHITELIST=() + +# Whitelisted files from real home: ro-bind if exists +for file in "${WHITELISTED_HOME_FILES[@]}"; do + if [[ -f "$HOME/$file" ]]; then + BWRAP_HOME_WHITELIST+=(--ro-bind "$HOME/$file" "$HOME/$file") + fi +done + +# Whitelisted directories from real home: ro-bind if exists +for dir in "${WHITELISTED_HOME_DIRS[@]}"; do + if [[ -d "$HOME/$dir" ]]; then + BWRAP_HOME_WHITELIST+=(--ro-bind "$HOME/$dir" "$HOME/$dir") + fi +done + +# Build bwrap arguments for protected agent home paths +BWRAP_AGENT_HOME_PROTECTIONS=() + +# Protected files in agent home: ro-bind if exists +for file in "${PROTECTED_AGENT_HOME_FILES[@]}"; do + if [[ -f "$AI_HOME/$file" ]]; then + BWRAP_AGENT_HOME_PROTECTIONS+=(--ro-bind "$AI_HOME/$file" "$HOME/$file") + fi +done + +# Protected directories in agent home: ro-bind if exists +for dir in "${PROTECTED_AGENT_HOME_DIRS[@]}"; do + if [[ -d "$AI_HOME/$dir" ]]; then + BWRAP_AGENT_HOME_PROTECTIONS+=(--ro-bind "$AI_HOME/$dir" "$HOME/$dir") + fi +done + +echo "🔒 Starting Claude Code in isolated environment..." +echo " Home: $HOME → $AI_HOME (isolated, mostly writable)" +echo " Repo: $REPO_DIR (overlayfs - changes go to overlay)" +echo "" +echo "📂 Overlay directories:" +echo " Lower: $REPO_DIR (original, read-only)" +echo " Upper: $OVERLAY_UPPER" +echo " Work: $OVERLAY_WORK" +echo "" + +# Count protections by category +REPO_PROTECTED=$((${#PROTECTED_REPO_FILES[@]} + ${#PROTECTED_REPO_DIRS[@]})) +AGENT_HOME_PROTECTED=$((${#BWRAP_AGENT_HOME_PROTECTIONS[@]})) +WHITELIST_COUNT=$((${#BWRAP_HOME_WHITELIST[@]})) + +echo "🛡️ Protection summary:" +echo " • Repo: $REPO_PROTECTED existing paths (read-only)" +echo " • Agent home: $AGENT_HOME_PROTECTED config files (read-only)" +echo " • Real home: $WHITELIST_COUNT dev tools (read-only)" +echo "" +echo "💡 All repo changes isolated in overlay (review with merge-overlay)" +echo "💡 After session: merge-overlay will run automatically" +echo "💡 To restore home: rm -rf $AI_HOME" +echo "" + +bwrap \ + `# ===== System directories (read-only) =====` \ + --ro-bind /usr /usr \ + --ro-bind /etc /etc \ + --ro-bind-try /opt /opt \ + --ro-bind-try /nix /nix \ + \ + `# Symlinks for compatibility` \ + --symlink /usr/lib /lib \ + --symlink /usr/lib64 /lib64 \ + --symlink /usr/bin /bin \ + --symlink /usr/sbin /sbin \ + \ + `# ===== Pseudo-filesystems =====` \ + --proc /proc \ + --dev /dev \ + --tmpfs /run \ + --tmpfs /sys \ + \ + `# ===== DNS resolution =====` \ + `# Mount the actual resolv.conf file at its real location (handles /etc/resolv.conf -> /run/systemd/... symlinks)` \ + --ro-bind "$RESOLV_CONF_TARGET" "$RESOLV_CONF_TARGET" \ + \ + `# ===== Temporary directories (isolated, writable) =====` \ + --tmpfs /tmp \ + --tmpfs /var/tmp \ + \ + `# ===== AI Home mounted at real home path (WRITABLE) =====` \ + `# This ensures hardcoded paths to $HOME work correctly` \ + `# Agent can write anywhere here - easy to restore if corrupted` \ + --bind "$AI_HOME" "$HOME" \ + \ + `# ===== Protected agent home paths (read-only overlays) =====` \ + `# Config files created during setup - prevent tampering/persistence attacks` \ + "${BWRAP_AGENT_HOME_PROTECTIONS[@]}" \ + \ + `# ===== Whitelisted real home paths (read-only overlays) =====` \ + `# Dev tools and files from real home mounted read-only` \ + "${BWRAP_HOME_WHITELIST[@]}" \ + \ + `# ===== Current repository with overlayfs (isolated writes) =====` \ + `# Lower: original repo (read-only), Upper: changes go to overlay, Work: overlayfs internal` \ + `# If under $HOME, this overlays the aihome mount at this specific location` \ + --overlay-src "$REPO_DIR" \ + --overlay "$OVERLAY_UPPER" "$OVERLAY_WORK" "$REPO_DIR" \ + --chdir "$REPO_DIR" \ + \ + `# ===== Protected repo paths (read-only defense-in-depth) =====` \ + `# Existing protected files/dirs are ro-bind (extra layer over overlayfs)` \ + `# Non-existent paths can be created in overlay and rejected during merge` \ + "${BWRAP_REPO_PROTECTIONS[@]}" \ + \ + `# ===== Claude settings override (permissive, non-persistent) =====` \ + `# Writable temp file overlays repo settings - changes don't persist to host` \ + --bind "$CLAUDE_SETTINGS_TEMP" "$REPO_DIR/.claude/settings.local.json" \ + \ + `# ===== Isolation and security =====` \ + --unshare-all \ + --share-net \ + --die-with-parent \ + --new-session \ + \ + `# ===== Environment variables =====` \ + `# --setenv HOME "$HOME"` \ + `# --setenv USER "$(whoami)"` \ + `# --setenv LOGNAME "$(whoami)"` \ + `# --setenv SHELL "/bin/bash"` \ + `# --setenv TERM "${TERM:-xterm-256color}"` \ + `# --setenv PATH "$HOME/.asdf/shims:$HOME/.asdf/bin:$HOME/bin:$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin"` \ + `# --setenv ASDF_DIR "${HOME}/.asdf"` \ + `# --setenv ASDF_DATA_DIR "${HOME}/.asdf"` \ + \ + `# Clean environment - remove potentially dangerous vars` \ + --unsetenv DBUS_SESSION_BUS_ADDRESS \ + --unsetenv XDG_RUNTIME_DIR \ + --unsetenv SSH_AUTH_SOCK \ + --unsetenv GPG_AGENT_INFO \ + \ + `# ===== Execute command (defaults to claude) =====` \ + "${@:-claude}" + +# After bwrap exits, run merge-overlay to review changes +echo "" +echo "============================================" +echo " Session ended - reviewing changes" +echo "============================================" +echo "" + +# Check if there are any changes in overlay +if [[ -n "$(ls -A "$OVERLAY_UPPER" 2>/dev/null)" ]]; then + merge-overlay "$REPO_DIR" "$OVERLAY_UPPER" +else + echo "✓ No changes in overlay - nothing to merge" +fi diff --git a/merge-overlay b/merge-overlay new file mode 100755 index 0000000..4694f62 --- /dev/null +++ b/merge-overlay @@ -0,0 +1,363 @@ +#!/bin/bash +# merge-overlay: Interactive tool to review and merge overlayfs changes back to host +# +# Usage: +# merge-overlay +# +# Interactive commands: +# y - Accept current file (copy to host) +# r - Discard current file (remove from overlay) +# n - Reject but leave in overlay (skip for now) +# Y - Accept all remaining files +# R - Discard all remaining files +# N - Leave all remaining files in overlay (exit) +# d - Show diff with existing file (if file exists on host) + +set -euo pipefail + +# Check arguments +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " + echo "" + echo "Example: $0 /path/to/repo ~/aihome/.overlay/myrepo/upper" + exit 1 +fi + +REPO_DIR="$1" +OVERLAY_UPPER="$2" + +# Validate directories +if [[ ! -d "$REPO_DIR" ]]; then + echo "Error: Repository directory does not exist: $REPO_DIR" + exit 1 +fi + +if [[ ! -d "$OVERLAY_UPPER" ]]; then + echo "Error: Overlay upper directory does not exist: $OVERLAY_UPPER" + exit 1 +fi + +# Check if overlay is empty +if [[ -z "$(ls -A "$OVERLAY_UPPER")" ]]; then + echo "✓ No changes in overlay - nothing to merge" + exit 0 +fi + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Track statistics +ACCEPTED=0 +DISCARDED=0 +SKIPPED=0 + +# Detect file status (new/modified/deleted) +get_file_status() { + local overlay_file="$1" + local repo_file="$2" + + # Check if it's a whiteout file (deletion marker in overlayfs) + if [[ "$(basename "$overlay_file")" =~ ^\.wh\. ]]; then + echo "deleted" + return + fi + + # Check if it's a directory + if [[ -d "$overlay_file" ]]; then + echo "directory" + return + fi + + # Check if file exists in repo + if [[ -f "$repo_file" ]]; then + echo "modified" + else + echo "new" + fi +} + +# Get the original filename from whiteout marker +get_whiteout_target() { + local whiteout_file="$1" + echo "$(dirname "$whiteout_file")/$(basename "$whiteout_file" | sed 's/^\.wh\.//')" +} + +# Show diff between overlay and repo file +show_diff() { + local overlay_file="$1" + local repo_file="$2" + + if [[ -f "$repo_file" ]]; then + echo "" + echo "====== Diff: $repo_file ======" + diff -u "$repo_file" "$overlay_file" || true + echo "==============================" + echo "" + else + echo "" + echo "⚠️ File does not exist in repository (new file)" + echo "" + fi +} + +# Process a single file +process_file() { + local rel_path="$1" + local overlay_file="$OVERLAY_UPPER/$rel_path" + local repo_file="$REPO_DIR/$rel_path" + local status + + # Get file status + status=$(get_file_status "$overlay_file" "$repo_file") + + # Handle whiteout (deleted) files + if [[ "$status" == "deleted" ]]; then + local original_name=$(get_whiteout_target "$overlay_file") + local original_rel_path="${original_name#$OVERLAY_UPPER/}" + local original_repo_file="$REPO_DIR/$original_rel_path" + + echo "" + echo -e "${RED}[DELETED]${NC} $original_rel_path" + + while true; do + read -p "Action [y=accept delete, r=discard (keep file), n=skip, Y=accept all, R=discard all, N=skip all]? " choice + case "$choice" in + y) + if [[ -e "$original_repo_file" ]]; then + rm -f "$original_repo_file" + echo -e "${GREEN}✓${NC} Deleted from repository" + fi + rm -f "$overlay_file" + ((ACCEPTED++)) + return 0 + ;; + r) + rm -f "$overlay_file" + echo -e "${YELLOW}✓${NC} Discarded deletion (file kept in repo)" + ((DISCARDED++)) + return 0 + ;; + n) + echo -e "${CYAN}○${NC} Skipped" + ((SKIPPED++)) + return 0 + ;; + Y) + if [[ -e "$original_repo_file" ]]; then + rm -f "$original_repo_file" + echo -e "${GREEN}✓${NC} Deleted from repository" + fi + rm -f "$overlay_file" + ((ACCEPTED++)) + return 2 # Signal accept all + ;; + R) + rm -f "$overlay_file" + echo -e "${YELLOW}✓${NC} Discarded deletion" + ((DISCARDED++)) + return 3 # Signal discard all + ;; + N) + echo -e "${CYAN}○${NC} Skipped" + ((SKIPPED++)) + return 4 # Signal skip all + ;; + *) + echo "Invalid choice. Please enter y, r, n, Y, R, or N" + ;; + esac + done + fi + + # Skip directories (they're created automatically when files are copied) + if [[ "$status" == "directory" ]]; then + return 0 + fi + + # Handle regular files (new/modified) + local status_color="$BLUE" + local status_label="NEW" + + if [[ "$status" == "modified" ]]; then + status_color="$YELLOW" + status_label="MODIFIED" + fi + + echo "" + echo -e "${status_color}[$status_label]${NC} $rel_path" + + while true; do + read -p "Action [y=accept, r=discard, n=skip, d=diff, Y=accept all, R=discard all, N=skip all]? " choice + case "$choice" in + y) + mkdir -p "$(dirname "$repo_file")" + cp -a "$overlay_file" "$repo_file" + echo -e "${GREEN}✓${NC} Accepted" + rm -f "$overlay_file" + ((ACCEPTED++)) + return 0 + ;; + r) + rm -f "$overlay_file" + echo -e "${YELLOW}✓${NC} Discarded" + ((DISCARDED++)) + return 0 + ;; + n) + echo -e "${CYAN}○${NC} Skipped" + ((SKIPPED++)) + return 0 + ;; + d) + show_diff "$overlay_file" "$repo_file" + ;; + Y) + mkdir -p "$(dirname "$repo_file")" + cp -a "$overlay_file" "$repo_file" + echo -e "${GREEN}✓${NC} Accepted" + rm -f "$overlay_file" + ((ACCEPTED++)) + return 2 # Signal accept all + ;; + R) + rm -f "$overlay_file" + echo -e "${YELLOW}✓${NC} Discarded" + ((DISCARDED++)) + return 3 # Signal discard all + ;; + N) + echo -e "${CYAN}○${NC} Skipped" + ((SKIPPED++)) + return 4 # Signal skip all + ;; + *) + echo "Invalid choice. Please enter y, r, n, d, Y, R, or N" + ;; + esac + done +} + +# Main merge loop +echo "============================================" +echo " Overlay Merge Tool" +echo "============================================" +echo "" +echo "Repository: $REPO_DIR" +echo "Overlay: $OVERLAY_UPPER" +echo "" +echo "Commands:" +echo " y - Accept current file" +echo " r - Discard current file" +echo " n - Skip (leave in overlay)" +echo " d - Show diff (files only)" +echo " Y - Accept all remaining" +echo " R - Discard all remaining" +echo " N - Skip all remaining" +echo "" + +# Find all files in overlay (including hidden files, excluding . and ..) +# We need to handle whiteout files and regular files +FILES=() +while IFS= read -r -d '' file; do + rel_path="${file#$OVERLAY_UPPER/}" + FILES+=("$rel_path") +done < <(find "$OVERLAY_UPPER" -type f -print0 | sort -z) + +TOTAL=${#FILES[@]} +echo "Found $TOTAL file(s) to review" +echo "" + +# Process each file +BATCH_MODE="" +for rel_path in "${FILES[@]}"; do + overlay_file="$OVERLAY_UPPER/$rel_path" + repo_file="$REPO_DIR/$rel_path" + + # Skip if file was already processed (e.g., by batch operations) + if [[ ! -e "$overlay_file" ]]; then + continue + fi + + # If in batch mode, apply the same action + if [[ -n "$BATCH_MODE" ]]; then + status=$(get_file_status "$overlay_file" "$repo_file") + + case "$BATCH_MODE" in + accept) + if [[ "$status" == "deleted" ]]; then + original_name=$(get_whiteout_target "$overlay_file") + original_rel_path="${original_name#$OVERLAY_UPPER/}" + original_repo_file="$REPO_DIR/$original_rel_path" + if [[ -e "$original_repo_file" ]]; then + rm -f "$original_repo_file" + fi + rm -f "$overlay_file" + echo -e "${GREEN}✓${NC} [DELETED] $original_rel_path" + elif [[ "$status" != "directory" ]]; then + mkdir -p "$(dirname "$repo_file")" + cp -a "$overlay_file" "$repo_file" + rm -f "$overlay_file" + echo -e "${GREEN}✓${NC} [$status] $rel_path" + fi + ((ACCEPTED++)) + ;; + discard) + rm -f "$overlay_file" + echo -e "${YELLOW}✓${NC} [$status] $rel_path" + ((DISCARDED++)) + ;; + skip) + echo -e "${CYAN}○${NC} [$status] $rel_path" + ((SKIPPED++)) + ;; + esac + else + # Interactive mode + process_file "$rel_path" + ret=$? + + case $ret in + 2) + BATCH_MODE="accept" + echo "" + echo "→ Accepting all remaining files..." + ;; + 3) + BATCH_MODE="discard" + echo "" + echo "→ Discarding all remaining files..." + ;; + 4) + BATCH_MODE="skip" + echo "" + echo "→ Skipping all remaining files..." + ;; + esac + fi +done + +# Clean up empty directories in overlay +find "$OVERLAY_UPPER" -type d -empty -delete 2>/dev/null || true + +# Summary +echo "" +echo "============================================" +echo " Merge Complete" +echo "============================================" +echo -e "${GREEN}Accepted:${NC} $ACCEPTED" +echo -e "${YELLOW}Discarded:${NC} $DISCARDED" +echo -e "${CYAN}Skipped:${NC} $SKIPPED" +echo "" + +if [[ $SKIPPED -gt 0 ]]; then + echo "⚠️ $SKIPPED file(s) remain in overlay" + echo " Run this script again to review them" +else + echo "✓ All files processed" +fi +echo "" diff --git a/tests/test-agent-dev.expect b/tests/test-agent-dev.expect new file mode 100755 index 0000000..78a1315 --- /dev/null +++ b/tests/test-agent-dev.expect @@ -0,0 +1,510 @@ +#!/usr/bin/expect -f +# Integration tests for agent-dev script using expect + +set timeout 30 + +# Colors for output +set GREEN "\033\[0;32m" +set RED "\033\[0;31m" +set YELLOW "\033\[0;33m" +set NC "\033\[0m" + +# Test counters +set tests_passed 0 +set tests_failed 0 + +proc log_success {msg} { + global GREEN NC tests_passed + puts "${GREEN}✓${NC} $msg" + incr tests_passed +} + +proc log_failure {msg} { + global RED NC tests_failed + puts "${RED}✗${NC} $msg" + incr tests_failed +} + +proc log_info {msg} { + puts "\n==> $msg" +} + +proc start_test {name} { + puts "\n----------------------------------------" + puts "Test: $name" + puts "----------------------------------------" +} + +# Use isolated test environment instead of real aihome +set test_ai_home "/tmp/agent-dev-test-aihome-[clock seconds]" +set repo_dir [pwd] +set overlay_base "$test_ai_home/.overlay[exec realpath $repo_dir]" +set overlay_upper "$overlay_base/upper" + +# Set AI_HOME environment variable for all spawned processes +set env(AI_HOME) $test_ai_home + +# Setup test environment +proc setup_test_environment {} { + global test_ai_home env + + log_info "Creating isolated test environment at $test_ai_home" + exec mkdir -p $test_ai_home + + log_success "Test environment created" + log_info "AI_HOME set to: $env(AI_HOME)" +} + +# Cleanup test environment +proc cleanup_test_environment {} { + global test_ai_home + + log_info "Cleaning up test environment" + exec rm -rf $test_ai_home + log_success "Test environment cleaned up" +} + +# Check prerequisites +proc check_prerequisites {} { + global repo_dir + + log_info "Checking prerequisites" + + # Check if we're in a git repo + if {![file exists "$repo_dir/.git"]} { + puts "${RED}Error: Must run from git repository root${NC}" + exit 1 + } + + # Check if agent-dev exists and is executable + if {![file executable "./agent-dev"]} { + puts "${RED}Error: ./agent-dev not found or not executable${NC}" + exit 1 + } + + log_success "Prerequisites check passed" +} + +# Test 1: Basic environment setup +proc test_environment_setup {} { + global overlay_upper overlay_base + + start_test "Environment setup and overlay creation" + + # Clean overlay if exists + exec rm -rf $overlay_upper 2>/dev/null || true + + spawn ./agent-dev bash -c "pwd" + + expect { + -re "Starting Claude Code in isolated environment" { + log_success "Isolation banner displayed" + } + timeout { + log_failure "Timeout waiting for isolation banner" + expect eof + catch wait + return + } + } + + expect { + -re "Overlay directories:" { + log_success "Overlay information displayed" + } + timeout { + log_failure "No overlay information shown" + expect eof + catch wait + return + } + } + + # Wait for command to complete and merge-overlay to run + expect { + -re "No changes in overlay" { + log_success "Empty overlay detected" + } + eof { + # Process exited + } + timeout { + log_failure "Timeout waiting for completion" + } + } + + catch wait result + log_info "Process exited with: $result" + + # Verify overlay directories were created + if {[file exists $overlay_upper]} { + log_success "Overlay upper directory created" + } else { + log_failure "Overlay upper directory not created" + } +} + +# Test 2: File creation in overlay +proc test_file_creation_isolation {} { + global repo_dir overlay_upper + + start_test "File creation isolated to overlay" + + set test_file "test-isolation-[clock seconds].txt" + set test_content "This is test content" + + spawn ./agent-dev bash -c "echo '$test_content' > $test_file && cat $test_file" + + expect { + $test_content { + log_success "File created and readable in sandbox" + } + timeout { + log_failure "Timeout or file not created in sandbox" + expect eof + catch wait + return + } + } + + # Handle merge-overlay interaction - discard the test file + expect { + -re "NEW.*$test_file" { + send "r\r" + expect { + "✓ Discarded" { + log_success "Test file discarded from overlay" + } + timeout { + log_failure "Timeout waiting for discard confirmation" + } + } + } + -re "No changes in overlay" { + log_info "No changes to merge (unexpected)" + } + eof { + # Process exited without merge prompt + } + timeout { + log_failure "Timeout waiting for merge prompt" + } + } + + catch wait result + + # Check file was in overlay (before we discarded it) + # Note: file will be gone after discard, so we can't verify this way + # The test passes if merge-overlay detected and handled it + + if {![file exists "$repo_dir/$test_file"]} { + log_success "Original repository not modified" + } else { + log_failure "File leaked to original repository!" + exec rm -f "$repo_dir/$test_file" + } +} + +# Test 3: Protected directory access +proc test_protected_directories {} { + global repo_dir + + start_test "Protected directories are read-only" + + # Try to modify .git/config (should fail or not persist) + spawn ./agent-dev bash -c "echo '# test' >> .git/config 2>&1; echo STATUS:\$?" + + expect { + -re "STATUS:(1|2)" { + log_success "Write to .git/config blocked (permission denied)" + } + "STATUS:0" { + log_failure "Write to .git/config succeeded (should be blocked)" + } + timeout { + log_failure "Timeout testing .git/config protection" + expect eof + catch wait + return + } + } + + # Handle merge-overlay (should be empty) + expect { + -re "No changes in overlay" { + log_success "No changes to merge (expected)" + } + eof { + # Process exited + } + timeout { + log_failure "Timeout waiting for completion" + } + } + + catch wait + + # Verify .git/config wasn't actually modified + if {[catch {exec grep -c "# test" "$repo_dir/.git/config"} result] || $result eq "0"} { + log_success ".git/config remained unmodified" + } else { + log_failure ".git/config was modified!" + } +} + +# Test 4: Whitelisted directory access (read-only) +proc test_whitelisted_access {} { + start_test "Whitelisted directories accessible read-only" + + # Check if we can read from whitelisted dirs (e.g., .asdf if it exists) + spawn ./agent-dev bash -c { + if [ -d ~/.asdf ]; then + ls ~/.asdf >/dev/null 2>&1 && echo "READ_OK" + else + echo "NO_ASDF" + fi + } + + expect { + "READ_OK" { + log_success "Whitelisted directory readable" + } + "NO_ASDF" { + log_info "Skipping test (.asdf not present)" + } + timeout { + log_failure "Timeout testing whitelisted directory access" + expect eof + catch wait + return + } + } + + # Handle merge-overlay + expect { + -re "No changes in overlay" { + # Expected + } + eof { + # Process exited + } + timeout { + log_failure "Timeout waiting for completion" + } + } + + catch wait +} + +# Test 5: Home directory isolation +proc test_home_isolation {} { + global test_ai_home env + + start_test "Home directory mapped to aihome" + + spawn ./agent-dev bash -c { + echo "HOME=$HOME" + echo "PWD=$PWD" + touch ~/test-home-file.txt + ls ~/test-home-file.txt + } + + expect { + -re "test-home-file.txt" { + log_success "Files can be created in isolated home" + } + timeout { + log_failure "Timeout testing home directory" + expect eof + catch wait + return + } + } + + # Handle merge-overlay (should be empty since file is in home, not repo) + expect { + -re "No changes in overlay" { + log_success "No repo changes (file was in home)" + } + eof { + # Process exited + } + timeout { + log_failure "Timeout waiting for completion" + } + } + + catch wait + + # Verify file is in test aihome, not real home + if {[file exists "$test_ai_home/test-home-file.txt"]} { + log_success "File created in isolated test aihome" + exec rm -f "$test_ai_home/test-home-file.txt" + } else { + log_failure "File not found in test aihome" + } + + if {![file exists "$env(HOME)/test-home-file.txt"]} { + log_success "Real home directory not modified" + } else { + log_failure "File leaked to real home!" + exec rm -f "$env(HOME)/test-home-file.txt" + } +} + +# Test 6: Working directory preservation +proc test_working_directory {} { + global repo_dir + + start_test "Working directory preserved in sandbox" + + spawn ./agent-dev bash -c "pwd" + + expect { + $repo_dir { + log_success "Working directory correctly set to repo" + } + timeout { + log_failure "Timeout checking working directory" + expect eof + catch wait + return + } + } + + # Handle merge-overlay + expect { + -re "No changes in overlay" { + # Expected + } + eof { + # Process exited + } + timeout { + log_failure "Timeout waiting for completion" + } + } + + catch wait +} + +# Test 7: Network access +proc test_network_access {} { + start_test "Network access available in sandbox" + + spawn ./agent-dev bash -c "ping -c 1 -W 2 8.8.8.8 >/dev/null 2>&1 && echo 'PING_OK' || echo 'PING_FAIL'" + + expect { + "PING_OK" { + log_success "Network access works" + } + "PING_FAIL" { + log_info "Network unavailable (may be expected in some environments)" + } + timeout { + log_failure "Timeout testing network access" + expect eof + catch wait + return + } + } + + # Handle merge-overlay + expect { + -re "No changes in overlay" { + # Expected + } + eof { + # Process exited + } + timeout { + log_failure "Timeout waiting for completion" + } + } + + catch wait +} + +# Test 8: Overlay cleanup on empty session +proc test_empty_overlay_handling {} { + global overlay_upper + + start_test "Empty overlay handled correctly" + + # Clear overlay + exec rm -rf $overlay_upper + exec mkdir -p $overlay_upper + + # Run agent-dev with no changes + spawn ./agent-dev bash -c "echo 'No changes made'" + + expect { + "No changes made" { + log_success "Command executed" + } + timeout { + log_failure "Timeout waiting for command" + expect eof + catch wait + return + } + } + + expect { + "✓ No changes in overlay - nothing to merge" { + log_success "Empty overlay detected correctly" + } + eof { + # Process exited + } + timeout { + log_failure "Timeout waiting for empty overlay message" + } + } + + catch wait +} + +# Main execution +proc run_all_tests {} { + global tests_passed tests_failed GREEN RED NC + + puts "\n========================================" + puts " Agent-Dev Integration Test Suite" + puts "========================================" + + check_prerequisites + setup_test_environment + + test_environment_setup + test_file_creation_isolation + test_protected_directories + test_whitelisted_access + test_home_isolation + test_working_directory + test_network_access + test_empty_overlay_handling + + cleanup_test_environment + + # Summary + puts "\n========================================" + puts " Test Summary" + puts "========================================" + set total [expr $tests_passed + $tests_failed] + puts "Total assertions: $total" + puts "${GREEN}Passed: $tests_passed${NC}" + puts "${RED}Failed: $tests_failed${NC}" + puts "" + + if {$tests_failed > 0} { + puts "Some tests failed!" + exit 1 + } else { + puts "All tests passed!" + exit 0 + } +} + +# Run the tests +run_all_tests diff --git a/tests/test-merge-overlay.expect b/tests/test-merge-overlay.expect new file mode 100755 index 0000000..b8e47d5 --- /dev/null +++ b/tests/test-merge-overlay.expect @@ -0,0 +1,546 @@ +#!/usr/bin/expect -f +# Comprehensive test suite for merge-overlay script using expect + +# Configuration +set timeout 10 +set test_dir "/tmp/merge-overlay-test-[clock seconds]" +set repo_dir "$test_dir/repo" +set overlay_upper "$test_dir/overlay" + +# Colors for output +set GREEN "\033\[0;32m" +set RED "\033\[0;31m" +set YELLOW "\033\[0;33m" +set NC "\033\[0m" + +# Test counters +set tests_passed 0 +set tests_failed 0 +set test_total 0 + +# Logging procedures +proc log_info {msg} { + puts "\n==> $msg" +} + +proc log_success {msg} { + global GREEN NC tests_passed + puts "${GREEN}✓${NC} $msg" + incr tests_passed +} + +proc log_failure {msg} { + global RED NC tests_failed + puts "${RED}✗${NC} $msg" + incr tests_failed +} + +proc start_test {name} { + global test_total + incr test_total + puts "\n----------------------------------------" + puts "Test $test_total: $name" + puts "----------------------------------------" +} + +# Setup test environment +proc setup_test_env {} { + global test_dir repo_dir overlay_upper + + log_info "Setting up test environment at $test_dir" + + # Create directories + exec mkdir -p $repo_dir + exec mkdir -p $overlay_upper + + # Initialize git repo (merge-overlay checks for .git) + exec sh -c "cd $repo_dir && git init >/dev/null 2>&1" + + # Create some base files in repo + exec sh -c "echo 'original content' > $repo_dir/file1.txt" + exec sh -c "echo 'base content' > $repo_dir/file2.txt" + + log_success "Test environment created" +} + +# Cleanup test environment +proc cleanup_test_env {} { + global test_dir + + log_info "Cleaning up test environment" + exec rm -rf $test_dir + log_success "Cleanup complete" +} + +# Helper to create a file in overlay +proc create_overlay_file {filename content} { + global overlay_upper + exec sh -c "echo '$content' > $overlay_upper/$filename" +} + +# Helper to create a whiteout file (deletion marker) +proc create_whiteout {filename} { + global overlay_upper + set dir [file dirname $filename] + set base [file tail $filename] + if {$dir eq "."} { + exec touch "$overlay_upper/.wh.$base" + } else { + exec mkdir -p "$overlay_upper/$dir" + exec touch "$overlay_upper/$dir/.wh.$base" + } +} + +# Test 1: Accept a new file (y) +proc test_accept_new_file {} { + global repo_dir overlay_upper + + start_test "Accept new file" + create_overlay_file "newfile.txt" "new content" + + spawn ./merge-overlay $repo_dir $overlay_upper + + expect { + -re {NEW.*newfile\.txt} { + send "y\r" + expect { + "✓ Accepted" { + expect eof + + # Verify file was copied to repo + if {[file exists "$repo_dir/newfile.txt"]} { + log_success "New file accepted and copied to repo" + } else { + log_failure "File not found in repo after accepting" + } + + # Verify file removed from overlay + if {![file exists "$overlay_upper/newfile.txt"]} { + log_success "File removed from overlay after accepting" + } else { + log_failure "File still in overlay after accepting" + } + } + timeout { + log_failure "Timeout waiting for acceptance confirmation" + } + } + } + timeout { + log_failure "Timeout waiting for file prompt" + } + } +} + +# Test 2: Discard a new file (r) +proc test_discard_new_file {} { + global repo_dir overlay_upper + + start_test "Discard new file" + create_overlay_file "discard-me.txt" "discard content" + + spawn ./merge-overlay $repo_dir $overlay_upper + + expect { + -re {NEW.*discard-me\.txt} { + send "r\r" + expect { + "✓ Discarded" { + expect eof + + # Verify file was NOT copied to repo + if {![file exists "$repo_dir/discard-me.txt"]} { + log_success "File correctly not copied to repo" + } else { + log_failure "File incorrectly copied to repo" + } + + # Verify file removed from overlay + if {![file exists "$overlay_upper/discard-me.txt"]} { + log_success "File removed from overlay after discarding" + } else { + log_failure "File still in overlay after discarding" + } + } + timeout { + log_failure "Timeout waiting for discard confirmation" + } + } + } + timeout { + log_failure "Timeout waiting for file prompt" + } + } +} + +# Test 3: Skip a file (n) +proc test_skip_file {} { + global repo_dir overlay_upper + + start_test "Skip file (leave in overlay)" + create_overlay_file "skip-me.txt" "skip content" + + spawn ./merge-overlay $repo_dir $overlay_upper + + expect { + -re {NEW.*skip-me\.txt} { + send "n\r" + expect { + "○ Skipped" { + expect eof + + # Verify file was NOT copied to repo + if {![file exists "$repo_dir/skip-me.txt"]} { + log_success "File correctly not copied to repo" + } else { + log_failure "File incorrectly copied to repo" + } + + # Verify file still in overlay + if {[file exists "$overlay_upper/skip-me.txt"]} { + log_success "File kept in overlay after skipping" + } else { + log_failure "File removed from overlay after skipping" + } + } + timeout { + log_failure "Timeout waiting for skip confirmation" + } + } + } + timeout { + log_failure "Timeout waiting for file prompt" + } + } + + # Cleanup for next test + exec rm -f "$overlay_upper/skip-me.txt" +} + +# Test 4: Accept modified file +proc test_accept_modified_file {} { + global repo_dir overlay_upper + + start_test "Accept modified file" + create_overlay_file "file1.txt" "modified content" + + spawn ./merge-overlay $repo_dir $overlay_upper + + expect { + -re {MODIFIED.*file1\.txt} { + send "y\r" + expect { + "✓ Accepted" { + expect eof + + # Verify file was updated in repo + set content [exec cat "$repo_dir/file1.txt"] + if {$content eq "modified content"} { + log_success "Modified file updated in repo" + } else { + log_failure "File content not updated (got: $content)" + } + } + timeout { + log_failure "Timeout waiting for acceptance" + } + } + } + timeout { + log_failure "Timeout waiting for modified file prompt" + } + } +} + +# Test 5: Show diff (d) then accept +proc test_show_diff {} { + global repo_dir overlay_upper + + start_test "Show diff then accept" + create_overlay_file "file2.txt" "changed content" + + spawn ./merge-overlay $repo_dir $overlay_upper + + expect { + -re {MODIFIED.*file2\.txt} { + send "d\r" + expect { + -re {Diff: .*/file2.txt} { + # Diff shown, now accept + expect { + -re {Action.*\?} { + send "y\r" + expect { + "✓ Accepted" { + log_success "Diff shown and file accepted" + expect eof + } + timeout { + log_failure "Timeout after accepting" + } + } + } + timeout { + log_failure "Timeout waiting for action prompt after diff" + } + } + } + timeout { + log_failure "Timeout waiting for diff output" + } + } + } + timeout { + log_failure "Timeout waiting for file prompt" + } + } +} + +# Test 6: Accept deletion (whiteout file) +proc test_accept_deletion {} { + global repo_dir overlay_upper + + start_test "Accept file deletion" + + # Ensure file1.txt exists in repo before testing deletion + exec sh -c "echo 'to be deleted' > $repo_dir/file1.txt" + + create_whiteout "file1.txt" + + spawn ./merge-overlay $repo_dir $overlay_upper + + expect { + -re {DELETED.*file1\.txt} { + send "y\r" + expect { + "✓ Deleted from repository" { + expect eof + + # Verify file was deleted from repo + if {![file exists "$repo_dir/file1.txt"]} { + log_success "File deleted from repository" + } else { + log_failure "File still exists in repository" + } + } + timeout { + log_failure "Timeout waiting for deletion confirmation" + } + } + } + timeout { + log_failure "Timeout waiting for deletion prompt" + } + } +} + +# Test 7: Accept all remaining (Y) +proc test_accept_all {} { + global repo_dir overlay_upper + + start_test "Accept all remaining files" + create_overlay_file "all1.txt" "content 1" + create_overlay_file "all2.txt" "content 2" + create_overlay_file "all3.txt" "content 3" + + spawn ./merge-overlay $repo_dir $overlay_upper + + expect { + -re {NEW.*all1\.txt} { + send "Y\r" + expect { + "→ Accepting all remaining files..." { + expect eof + + # Verify all files were copied + set all_copied 1 + foreach f {all1.txt all2.txt all3.txt} { + if {![file exists "$repo_dir/$f"]} { + set all_copied 0 + } + } + + if {$all_copied} { + log_success "All files accepted and copied to repo" + } else { + log_failure "Not all files were copied" + } + + # Verify all removed from overlay + set all_removed 1 + foreach f {all1.txt all2.txt all3.txt} { + if {[file exists "$overlay_upper/$f"]} { + set all_removed 0 + } + } + + if {$all_removed} { + log_success "All files removed from overlay" + } else { + log_failure "Some files still in overlay" + } + } + timeout { + log_failure "Timeout waiting for accept all confirmation" + } + } + } + timeout { + log_failure "Timeout waiting for first file" + } + } +} + +# Test 8: Discard all remaining (R) +proc test_discard_all {} { + global repo_dir overlay_upper + + start_test "Discard all remaining files" + create_overlay_file "discard1.txt" "content 1" + create_overlay_file "discard2.txt" "content 2" + + spawn ./merge-overlay $repo_dir $overlay_upper + + expect { + -re {NEW.*discard1\.txt} { + send "R\r" + expect { + "→ Discarding all remaining files..." { + expect eof + + # Verify files were NOT copied + set none_copied 1 + foreach f {discard1.txt discard2.txt} { + if {[file exists "$repo_dir/$f"]} { + set none_copied 0 + } + } + + if {$none_copied} { + log_success "All files correctly not copied" + } else { + log_failure "Some files were incorrectly copied" + } + + # Verify all removed from overlay + set all_removed 1 + foreach f {discard1.txt discard2.txt} { + if {[file exists "$overlay_upper/$f"]} { + set all_removed 0 + } + } + + if {$all_removed} { + log_success "All files removed from overlay" + } else { + log_failure "Some files still in overlay" + } + } + timeout { + log_failure "Timeout waiting for discard all confirmation" + } + } + } + timeout { + log_failure "Timeout waiting for first file" + } + } +} + +# Test 9: Empty overlay +proc test_empty_overlay {} { + global repo_dir overlay_upper + + start_test "Handle empty overlay gracefully" + + # Clear overlay to ensure it's truly empty + exec rm -rf "$overlay_upper" + exec mkdir -p "$overlay_upper" + + spawn ./merge-overlay $repo_dir $overlay_upper + + expect { + "✓ No changes in overlay - nothing to merge" { + log_success "Empty overlay handled correctly" + expect eof + } + timeout { + log_failure "Timeout or wrong message for empty overlay" + } + } +} + +# Test 10: Invalid arguments +proc test_invalid_arguments {} { + start_test "Reject invalid arguments" + + spawn ./merge-overlay + + expect { + "Usage:" { + log_success "Usage message shown for missing arguments" + expect eof + } + timeout { + log_failure "No usage message for missing arguments" + } + } +} + +# Helper to clear overlay between tests +proc clear_overlay {} { + global overlay_upper + exec sh -c "rm -rf $overlay_upper/* 2>/dev/null || true" +} + +# Main test execution +proc run_all_tests {} { + global tests_passed tests_failed test_total GREEN RED NC + + puts "\n========================================" + puts " Merge-Overlay Test Suite" + puts "========================================" + + setup_test_env + + # Run all tests with cleanup between each + test_accept_new_file + clear_overlay + test_discard_new_file + clear_overlay + test_skip_file + clear_overlay + test_accept_modified_file + clear_overlay + test_show_diff + clear_overlay + test_accept_deletion + clear_overlay + test_accept_all + clear_overlay + test_discard_all + clear_overlay + test_empty_overlay + test_invalid_arguments + + cleanup_test_env + + # Summary + puts "\n========================================" + puts " Test Summary" + puts "========================================" + puts "Total tests: $test_total" + puts "${GREEN}Passed: $tests_passed${NC}" + puts "${RED}Failed: $tests_failed${NC}" + puts "" + + if {$tests_failed > 0} { + exit 1 + } else { + puts "All tests passed!" + exit 0 + } +} + +# Run the test suite +run_all_tests