mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-08 10:09:30 +00:00
* fix: add error logging to empty catch blocks in test helpers Previously, test helper functions had 14 empty catch blocks that silently swallowed all errors during cleanup operations (reading and deleting temporary stderr files). This change adds error logging that: - Allows expected errors (ENOENT for missing files, exit code 1 for cat) - Logs unexpected errors to console for debugging This improves test reliability by surfacing unexpected filesystem or permission errors that could indicate real problems, while still allowing the intended best-effort cleanup behavior. Fixes: Empty catch blocks in 6 test files Impact: Better test debugging and error visibility Agent: code-health Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: improve error handling in Python fallback and directory deletion 1. Python arithmetic fallback (shared/common.sh:713): - Changed from: || echo "$((elapsed + 1))" - Changed to: explicit if/else with error detection - Impact: Python errors are now properly caught instead of masked by || 2. Unvalidated directory deletion (cli/install.sh:142): - Added path validation before rm -rf - Checks: path is within dest directory AND directory exists - Impact: Prevents accidental deletion if variables are malformed Both changes improve safety and error visibility without breaking existing functionality. Agent: code-health Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: spawn-bot <bot@openrouter.ai> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
3344 lines
116 KiB
Bash
3344 lines
116 KiB
Bash
#!/bin/bash
|
|
# shellcheck disable=SC2154
|
|
# Shared bash functions used across all spawn scripts
|
|
# Provider-agnostic utilities for logging, input, OAuth, etc.
|
|
#
|
|
# This file is meant to be sourced by cloud provider-specific common.sh files.
|
|
# It does not set bash flags (like set -eo pipefail) as those should be set
|
|
# by the scripts that source this file.
|
|
|
|
# ============================================================
|
|
# Debug mode
|
|
# ============================================================
|
|
|
|
# Enable debug output if SPAWN_DEBUG is set
|
|
if [[ -n "${SPAWN_DEBUG:-}" ]]; then
|
|
set -x
|
|
fi
|
|
|
|
# ============================================================
|
|
# Color definitions and logging
|
|
# ============================================================
|
|
|
|
# Use non-readonly vars to avoid errors if sourced multiple times
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
CYAN='\033[0;36m'
|
|
NC='\033[0m' # No Color
|
|
|
|
# Print colored messages (to stderr so they don't pollute command substitution output)
|
|
log_info() {
|
|
printf '%b\n' "${GREEN}${1}${NC}" >&2
|
|
}
|
|
|
|
log_warn() {
|
|
printf '%b\n' "${YELLOW}${1}${NC}" >&2
|
|
}
|
|
|
|
log_error() {
|
|
printf '%b\n' "${RED}${1}${NC}" >&2
|
|
}
|
|
|
|
# Progress/status messages (use instead of log_warn for non-warning status updates)
|
|
log_step() {
|
|
printf '%b\n' "${CYAN}${1}${NC}" >&2
|
|
}
|
|
|
|
# Print a structured diagnostic: header, possible causes, and how-to-fix steps.
|
|
# Arguments: HEADER CAUSE... --- FIX...
|
|
# The literal "---" separates causes from fixes.
|
|
_log_diagnostic() {
|
|
local header="${1}"; shift
|
|
log_error "${header}"
|
|
log_error ""
|
|
log_error "Possible causes:"
|
|
while [[ $# -gt 0 && "${1}" != "---" ]]; do
|
|
log_error " - ${1}"; shift
|
|
done
|
|
if [[ $# -gt 0 ]]; then
|
|
shift # skip ---
|
|
log_error ""
|
|
log_error "How to fix:"
|
|
local i=1
|
|
while [[ $# -gt 0 ]]; do
|
|
log_error " ${i}. ${1}"; shift
|
|
i=$((i + 1))
|
|
done
|
|
fi
|
|
}
|
|
|
|
# Log actionable guidance when agent installation verification fails.
|
|
# Usage: log_install_failed AGENT_NAME [INSTALL_CMD] [SERVER_IP]
|
|
# Example: log_install_failed "Claude Code" "curl -fsSL https://claude.ai/install.sh | bash" "$IP"
|
|
log_install_failed() {
|
|
local agent_name="${1}"
|
|
local install_cmd="${2:-}"
|
|
local server_ip="${3:-}"
|
|
|
|
log_error "${agent_name} installation failed"
|
|
log_error ""
|
|
log_error "The agent could not be installed or verified on the server."
|
|
log_error ""
|
|
printf '%b\n' "${YELLOW}Common causes:${NC}" >&2
|
|
log_error " • Network timeout downloading packages (npm, pip, etc.)"
|
|
log_error " • Insufficient disk space or memory on the server"
|
|
log_error " • Missing system dependencies for ${agent_name}"
|
|
log_error " • Cloud provider's package mirror temporarily unavailable"
|
|
log_error ""
|
|
printf '%b\n' "${YELLOW}Next steps:${NC}" >&2
|
|
if [[ -n "${server_ip}" ]]; then
|
|
log_error " 1. SSH into the server to investigate:"
|
|
log_error " ${CYAN}ssh root@${server_ip}${NC}"
|
|
log_error " ${CYAN}df -h${NC} # Check disk space"
|
|
log_error " ${CYAN}free -h${NC} # Check memory"
|
|
fi
|
|
if [[ -n "${install_cmd}" ]]; then
|
|
log_error " 2. Try manual installation:"
|
|
log_error " ${CYAN}${install_cmd}${NC}"
|
|
fi
|
|
log_error " 3. Retry with a fresh server (many failures are transient)"
|
|
log_error " ${CYAN}spawn <agent> <cloud>${NC}"
|
|
}
|
|
|
|
# ============================================================
|
|
# Configurable timing constants
|
|
# ============================================================
|
|
|
|
# Polling interval for OAuth code waiting and other wait loops
|
|
# Set SPAWN_POLL_INTERVAL=0.1 for faster testing, or higher for slow networks
|
|
POLL_INTERVAL="${SPAWN_POLL_INTERVAL:-1}"
|
|
|
|
# ============================================================
|
|
# Dependency checks
|
|
# ============================================================
|
|
|
|
# Check if Python 3 is available (required for JSON parsing throughout Spawn)
|
|
check_python_available() {
|
|
if ! command -v python3 &> /dev/null; then
|
|
log_error "Python 3 is required but not installed"
|
|
log_error ""
|
|
log_error "Spawn uses Python 3 for JSON parsing and API interactions."
|
|
log_error ""
|
|
printf '%b\n' "${YELLOW}Install Python 3:${NC}" >&2
|
|
log_error " ${CYAN}# Ubuntu/Debian${NC}"
|
|
log_error " sudo apt-get update && sudo apt-get install -y python3"
|
|
log_error ""
|
|
log_error " ${CYAN}# Fedora/RHEL${NC}"
|
|
log_error " sudo dnf install -y python3"
|
|
log_error ""
|
|
log_error " ${CYAN}# macOS${NC}"
|
|
log_error " brew install python3"
|
|
log_error ""
|
|
log_error " ${CYAN}# Arch Linux${NC}"
|
|
log_error " sudo pacman -S python"
|
|
log_error ""
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Install jq if not already present (required by some cloud providers)
|
|
# Platform-specific jq install helpers
|
|
_install_jq_brew() {
|
|
if command -v brew &>/dev/null; then
|
|
brew install jq || { log_error "Failed to install jq via Homebrew. Run 'brew install jq' manually."; return 1; }
|
|
else
|
|
log_error "jq is required but not installed"
|
|
log_error "Install it with: brew install jq"
|
|
log_error "If Homebrew is not available: https://jqlang.github.io/jq/download/"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
_install_jq_apt() {
|
|
sudo apt-get update -qq && sudo apt-get install -y jq || {
|
|
log_error "Failed to install jq via apt. Run 'sudo apt-get install -y jq' manually."
|
|
return 1
|
|
}
|
|
}
|
|
|
|
_install_jq_dnf() {
|
|
sudo dnf install -y jq || {
|
|
log_error "Failed to install jq via dnf. Run 'sudo dnf install -y jq' manually."
|
|
return 1
|
|
}
|
|
}
|
|
|
|
_install_jq_apk() {
|
|
sudo apk add jq || {
|
|
log_error "Failed to install jq via apk. Run 'sudo apk add jq' manually."
|
|
return 1
|
|
}
|
|
}
|
|
|
|
_report_jq_not_found() {
|
|
log_error "jq is required but not installed"
|
|
log_error ""
|
|
printf '%b\n' "${YELLOW}Install jq for your system:${NC}" >&2
|
|
log_error " ${CYAN}# Ubuntu/Debian${NC}"
|
|
log_error " sudo apt-get install -y jq"
|
|
log_error ""
|
|
log_error " ${CYAN}# Fedora/RHEL${NC}"
|
|
log_error " sudo dnf install -y jq"
|
|
log_error ""
|
|
log_error " ${CYAN}# macOS${NC}"
|
|
log_error " brew install jq"
|
|
log_error ""
|
|
log_error " ${CYAN}# Other systems${NC}"
|
|
log_error " https://jqlang.github.io/jq/download/"
|
|
}
|
|
|
|
ensure_jq() {
|
|
if command -v jq &>/dev/null; then
|
|
return 0
|
|
fi
|
|
|
|
log_step "Installing jq..."
|
|
|
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
|
_install_jq_brew || return 1
|
|
elif command -v apt-get &>/dev/null; then
|
|
_install_jq_apt || return 1
|
|
elif command -v dnf &>/dev/null; then
|
|
_install_jq_dnf || return 1
|
|
elif command -v apk &>/dev/null; then
|
|
_install_jq_apk || return 1
|
|
else
|
|
_report_jq_not_found
|
|
return 1
|
|
fi
|
|
|
|
if ! command -v jq &>/dev/null; then
|
|
log_error "jq was installed but is not found in PATH"
|
|
log_error "Try opening a new terminal or run: hash -r"
|
|
return 1
|
|
fi
|
|
|
|
log_info "jq installed"
|
|
}
|
|
|
|
# ============================================================
|
|
# Input handling
|
|
# ============================================================
|
|
|
|
# Safe read function that works in both interactive and non-interactive modes
|
|
safe_read() {
|
|
local prompt="${1}"
|
|
local result=""
|
|
|
|
if [[ -t 0 ]]; then
|
|
# stdin is a terminal - read directly
|
|
read -r -p "${prompt}" result || return 1
|
|
elif echo -n "" > /dev/tty 2>/dev/null; then
|
|
# /dev/tty is functional - use it
|
|
read -r -p "${prompt}" result < /dev/tty || return 1
|
|
else
|
|
# No interactive input available
|
|
log_error "Cannot prompt for input: no interactive terminal available"
|
|
log_error ""
|
|
log_error "You're running spawn in non-interactive mode (piped input, background job, or CI/CD)."
|
|
log_error "Set all required environment variables before launching spawn."
|
|
log_error ""
|
|
log_error "Example:"
|
|
log_error " export OPENROUTER_API_KEY=sk-or-v1-..."
|
|
log_error " export CLOUD_API_TOKEN=..."
|
|
log_error " spawn <agent> <cloud>"
|
|
log_error ""
|
|
log_error "Or use inline variables:"
|
|
log_error " OPENROUTER_API_KEY=sk-or-v1-... spawn <agent> <cloud>"
|
|
return 1
|
|
fi
|
|
|
|
echo "${result}"
|
|
}
|
|
|
|
# ============================================================
|
|
# Network utilities
|
|
# ============================================================
|
|
|
|
# Listen on a port with netcat (handles busybox/Termux nc requiring -p flag)
|
|
# Find a working Node.js runtime (bun preferred, then node)
|
|
find_node_runtime() {
|
|
if command -v bun &>/dev/null; then echo "bun"; return 0; fi
|
|
if command -v node &>/dev/null; then echo "node"; return 0; fi
|
|
return 1
|
|
}
|
|
|
|
# Open browser to URL (supports macOS, Linux, Termux)
|
|
open_browser() {
|
|
local url=${1}
|
|
if command -v termux-open-url &> /dev/null; then
|
|
termux-open-url "${url}" </dev/null
|
|
elif command -v open &> /dev/null; then
|
|
open "${url}" </dev/null
|
|
elif command -v xdg-open &> /dev/null; then
|
|
xdg-open "${url}" </dev/null
|
|
else
|
|
log_step "Please open: ${url}"
|
|
fi
|
|
}
|
|
|
|
# Validate model ID to prevent command injection
|
|
validate_model_id() {
|
|
local model_id="${1}"
|
|
if [[ -z "${model_id}" ]]; then return 0; fi
|
|
if [[ ! "${model_id}" =~ ^[a-zA-Z0-9/_:.-]+$ ]]; then
|
|
log_error "Invalid model ID: '${model_id}'"
|
|
log_error ""
|
|
log_error "Model IDs can only contain:"
|
|
log_error " - Letters (a-z, A-Z)"
|
|
log_error " - Numbers (0-9)"
|
|
log_error " - Special characters: / - _ : ."
|
|
log_error ""
|
|
log_error "Examples of valid model IDs:"
|
|
log_error " - anthropic/claude-3.5-sonnet"
|
|
log_error " - openai/gpt-4-turbo"
|
|
log_error " - openrouter/auto"
|
|
log_error ""
|
|
log_error "Browse all models at: https://openrouter.ai/models"
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Helper to show server name validation requirements
|
|
show_server_name_requirements() {
|
|
log_error ""
|
|
log_error "Server name requirements:"
|
|
log_error " - Length: 3-63 characters"
|
|
log_error " - Characters: letters (a-z, A-Z), numbers (0-9), dashes (-)"
|
|
log_error " - No leading or trailing dashes"
|
|
log_error ""
|
|
log_error "Examples of valid names:"
|
|
log_error " - my-server"
|
|
log_error " - dev-box-01"
|
|
log_error " - spawn-agent"
|
|
}
|
|
|
|
# Validate server/sprite name to prevent injection and ensure cloud provider compatibility
|
|
# Server names must be 3-63 characters, alphanumeric + dash, no leading/trailing dash
|
|
validate_server_name() {
|
|
local server_name="${1}"
|
|
|
|
if [[ -z "${server_name}" ]]; then
|
|
log_error "Server name cannot be empty"
|
|
return 1
|
|
fi
|
|
|
|
local name_length=${#server_name}
|
|
|
|
# Check length (3-63 characters)
|
|
if [[ ${name_length} -lt 3 ]] || [[ ${name_length} -gt 63 ]]; then
|
|
local constraint
|
|
if [[ ${name_length} -lt 3 ]]; then
|
|
constraint="too short (minimum 3)"
|
|
else
|
|
constraint="too long (maximum 63)"
|
|
fi
|
|
log_error "Server name ${constraint}: '${server_name}'"
|
|
show_server_name_requirements
|
|
return 1
|
|
fi
|
|
|
|
# Check for valid characters (alphanumeric + dash only)
|
|
if [[ ! "${server_name}" =~ ^[a-zA-Z0-9-]+$ ]]; then
|
|
log_error "Invalid server name: '${server_name}' (must contain only alphanumeric characters and dashes)"
|
|
show_server_name_requirements
|
|
return 1
|
|
fi
|
|
|
|
# Check no leading or trailing dash
|
|
if [[ "${server_name}" =~ ^- ]] || [[ "${server_name}" =~ -$ ]]; then
|
|
log_error "Invalid server name: '${server_name}' (cannot start or end with dash)"
|
|
show_server_name_requirements
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# Validate API token to prevent command injection
|
|
# Allows alphanumeric, dashes, underscores, and common token separators
|
|
# Blocks shell metacharacters: ; ' " < > | & $ ` \ ( )
|
|
validate_api_token() {
|
|
local token="${1}"
|
|
|
|
if [[ -z "${token}" ]]; then
|
|
log_error "API token cannot be empty"
|
|
log_error "Please provide a valid API token"
|
|
return 1
|
|
fi
|
|
|
|
# Block shell metacharacters that could enable command injection
|
|
if [[ "${token}" =~ [\;\'\"\<\>\|\&\$\`\\\(\)] ]]; then
|
|
log_error "Invalid token format: contains special characters"
|
|
log_error "API tokens should only contain letters, numbers, dashes, and underscores."
|
|
log_error "Copy the token directly from your provider's dashboard without extra characters."
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# Validate region/location name (cloud provider regions, datacenters, zones)
|
|
# Alphanumeric, hyphens, underscores only, 1-63 chars
|
|
validate_region_name() {
|
|
local region="${1}"
|
|
|
|
if [[ -z "${region}" ]]; then
|
|
log_error "Region name cannot be empty"
|
|
return 1
|
|
fi
|
|
|
|
if [[ ! "${region}" =~ ^[a-zA-Z0-9_-]{1,63}$ ]]; then
|
|
log_error "Invalid region name: '${region}'"
|
|
log_error "Region names must be 1-63 characters: alphanumeric, hyphens, underscores only"
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# Validate resource name (generic: server types, sizes, plans, etc.)
|
|
# Alphanumeric, hyphens, underscores, dots, 1-63 chars
|
|
validate_resource_name() {
|
|
local name="${1}"
|
|
|
|
if [[ -z "${name}" ]]; then
|
|
log_error "Resource name cannot be empty"
|
|
return 1
|
|
fi
|
|
|
|
if [[ ! "${name}" =~ ^[a-zA-Z0-9_.-]{1,63}$ ]]; then
|
|
log_error "Invalid resource name: '${name}'"
|
|
log_error "Resource names must be 1-63 characters: alphanumeric, hyphens, underscores, dots only"
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# Validated read wrapper - reads input and validates it with a validator function
|
|
# Usage: validated_read "prompt" validator_function_name
|
|
# Returns: Validated input via stdout, or exits on error/empty input
|
|
# Example: api_key=$(validated_read "Enter API key: " validate_api_token)
|
|
validated_read() {
|
|
local prompt="${1}"
|
|
local validator="${2}"
|
|
local value
|
|
|
|
while true; do
|
|
value=$(safe_read "${prompt}") || return 1
|
|
|
|
if [[ -z "${value}" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
if "${validator}" "${value}"; then
|
|
echo "${value}"
|
|
return 0
|
|
fi
|
|
|
|
log_warn "Please try again."
|
|
done
|
|
}
|
|
|
|
# Generic function to get resource name from environment or prompt
|
|
# Usage: get_resource_name ENV_VAR_NAME PROMPT_TEXT
|
|
# Returns: Resource name via stdout
|
|
# Example: get_resource_name "LIGHTSAIL_SERVER_NAME" "Enter Lightsail instance name: "
|
|
get_resource_name() {
|
|
local env_var_name="${1}"
|
|
local prompt_text="${2}"
|
|
local resource_value="${!env_var_name}"
|
|
|
|
if [[ -n "${resource_value}" ]]; then
|
|
log_info "Using ${prompt_text%:*} from environment: ${resource_value}"
|
|
echo "${resource_value}"
|
|
return 0
|
|
fi
|
|
|
|
local name
|
|
name=$(safe_read "${prompt_text}")
|
|
if [[ -z "${name}" ]]; then
|
|
log_error "${prompt_text%:*} is required but not provided"
|
|
log_error ""
|
|
log_error "For non-interactive usage, set the environment variable:"
|
|
log_error " ${env_var_name}=your-value spawn ..."
|
|
return 1
|
|
fi
|
|
echo "${name}"
|
|
}
|
|
|
|
# Get server name from environment or prompt, with validation
|
|
# Usage: get_validated_server_name ENV_VAR_NAME PROMPT_TEXT
|
|
# Returns: Validated server name via stdout
|
|
# Example: get_validated_server_name "HETZNER_SERVER_NAME" "Enter server name: "
|
|
get_validated_server_name() {
|
|
local server_name
|
|
server_name=$(get_resource_name "$1" "$2") || return 1
|
|
|
|
if ! validate_server_name "$server_name"; then
|
|
return 1
|
|
fi
|
|
|
|
echo "$server_name"
|
|
}
|
|
|
|
# Interactively prompt for model ID with validation
|
|
# Usage: get_model_id_interactive [default_model] [agent_name]
|
|
# Returns: Model ID via stdout
|
|
# Example: MODEL_ID=$(get_model_id_interactive "openrouter/auto" "Aider")
|
|
get_model_id_interactive() {
|
|
local default_model="${1:-openrouter/auto}"
|
|
local agent_name="${2:-}"
|
|
|
|
# If MODEL_ID is already set in the environment, validate and use it without prompting
|
|
if [[ -n "${MODEL_ID:-}" ]]; then
|
|
if ! validate_model_id "${MODEL_ID}"; then
|
|
log_error "MODEL_ID environment variable contains invalid characters"
|
|
return 1
|
|
fi
|
|
echo "${MODEL_ID}"
|
|
return 0
|
|
fi
|
|
|
|
echo "" >&2
|
|
log_info "Browse models at: https://openrouter.ai/models"
|
|
if [[ -n "${agent_name}" ]]; then
|
|
log_info "Which model would you like to use with ${agent_name}?"
|
|
else
|
|
log_info "Which model would you like to use?"
|
|
fi
|
|
|
|
local model_id=""
|
|
model_id=$(safe_read "Enter model ID [${default_model}]: ") || model_id=""
|
|
model_id="${model_id:-${default_model}}"
|
|
|
|
if ! validate_model_id "${model_id}"; then
|
|
log_error "Exiting due to invalid model ID"
|
|
return 1
|
|
fi
|
|
|
|
echo "${model_id}"
|
|
}
|
|
|
|
# ============================================================
|
|
# OpenRouter authentication
|
|
# ============================================================
|
|
|
|
# Manually prompt for API key
|
|
# Prompt user for API key with format validation (max 3 attempts)
|
|
# Returns: API key via stdout on success, exits with 1 on failure
|
|
_prompt_and_validate_api_key() {
|
|
local api_key=""
|
|
local attempts=0
|
|
local max_attempts=3
|
|
|
|
while [[ -z "${api_key}" ]]; do
|
|
attempts=$((attempts + 1))
|
|
if [[ ${attempts} -gt ${max_attempts} ]]; then
|
|
log_error "Too many failed attempts."
|
|
log_error ""
|
|
log_error "How to fix:"
|
|
log_error " 1. Get your key from: https://openrouter.ai/settings/keys"
|
|
log_error " 2. Set it before running spawn: export OPENROUTER_API_KEY=sk-or-v1-..."
|
|
log_error " 3. Then re-run: spawn <agent> <cloud>"
|
|
return 1
|
|
fi
|
|
|
|
api_key=$(safe_read "Enter your OpenRouter API key: ") || return 1
|
|
[[ -n "${api_key}" ]] || { log_error "API key cannot be empty"; continue; }
|
|
|
|
# Validate format and confirm if invalid
|
|
if [[ ! "${api_key}" =~ ^sk-or-v1-[a-f0-9]{64}$ ]]; then
|
|
log_warn "This doesn't look like an OpenRouter API key (expected format: sk-or-v1-...)"
|
|
local confirm
|
|
confirm=$(safe_read "Use this key anyway? (y/N): ") || return 1
|
|
[[ "${confirm}" =~ ^[Yy]$ ]] && break
|
|
api_key=""
|
|
fi
|
|
done
|
|
|
|
echo "${api_key}"
|
|
}
|
|
|
|
get_openrouter_api_key_manual() {
|
|
echo ""
|
|
log_info "Manual API Key Entry"
|
|
printf '%b\n' "${GREEN}Get your API key from: https://openrouter.ai/settings/keys${NC}"
|
|
echo ""
|
|
|
|
_prompt_and_validate_api_key
|
|
}
|
|
|
|
# Validate port number for OAuth server
|
|
# SECURITY: Prevents injection attacks via port parameter
|
|
validate_oauth_port() {
|
|
local port="${1}"
|
|
|
|
# Ensure port is a valid integer
|
|
if [[ ! "${port}" =~ ^[0-9]+$ ]]; then
|
|
log_error "Invalid port number: '${port}' (must be numeric)"
|
|
return 1
|
|
fi
|
|
|
|
# Ensure port is in valid range (1024-65535, avoiding privileged ports)
|
|
if [[ "${port}" -lt 1024 ]] || [[ "${port}" -gt 65535 ]]; then
|
|
log_error "Invalid port number: ${port} (must be between 1024-65535)"
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# Generate OAuth callback HTML pages (success and error)
|
|
# Sets OAUTH_SUCCESS_HTML and OAUTH_ERROR_HTML variables
|
|
_generate_oauth_html() {
|
|
local css='*{margin:0;padding:0;box-sizing:border-box}body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;background:#fff;color:#090a0b}@media(prefers-color-scheme:dark){body{background:#090a0b;color:#fafafa}}.card{text-align:center;max-width:400px;padding:2rem}.icon{font-size:2.5rem;margin-bottom:1rem}h1{font-size:1.25rem;font-weight:600;margin-bottom:.5rem}p{font-size:.875rem;color:#6b7280}@media(prefers-color-scheme:dark){p{color:#9ca3af}}'
|
|
OAUTH_SUCCESS_HTML="<html><head><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><style>${css}</style></head><body><div class=\"card\"><div class=\"icon\">✓</div><h1>Authentication Successful</h1><p>You can close this tab and return to your terminal.</p></div><script>setTimeout(function(){try{window.close()}catch(e){}},3000)</script></body></html>"
|
|
OAUTH_ERROR_HTML="<html><head><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><style>${css}h1{color:#dc2626}@media(prefers-color-scheme:dark){h1{color:#ef4444}}</style></head><body><div class=\"card\"><div class=\"icon\">✗</div><h1>Authentication Failed</h1><p>Invalid or missing state parameter (CSRF protection). Please try again.</p></div></body></html>"
|
|
}
|
|
|
|
# Validate OAuth server prerequisites (port, state token, runtime)
|
|
# Sets OAUTH_RUNTIME and OAUTH_STATE variables on success
|
|
# $1=starting_port $2=state_file
|
|
_validate_oauth_server_args() {
|
|
local starting_port="${1}"
|
|
local state_file="${2}"
|
|
|
|
OAUTH_RUNTIME=$(find_node_runtime) || { log_warn "No Node.js runtime found"; return 1; }
|
|
|
|
# SECURITY: Validate port number to prevent injection
|
|
if ! validate_oauth_port "${starting_port}"; then
|
|
log_error "OAuth server port validation failed"
|
|
return 1
|
|
fi
|
|
|
|
# SECURITY: Read CSRF state token for validation
|
|
OAUTH_STATE=$(cat "${state_file}" 2>/dev/null || echo "")
|
|
if [[ -z "${OAUTH_STATE}" ]]; then
|
|
log_error "CSRF state token file is missing or empty"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Generate the Node.js script for the OAuth callback server
|
|
# $1=expected_state $2=success_html $3=error_html $4=code_file $5=port_file $6=starting_port
|
|
_generate_oauth_server_script() {
|
|
local expected_state="${1}" success_html="${2}" error_html="${3}"
|
|
local code_file="${4}" port_file="${5}" starting_port="${6}"
|
|
|
|
# SECURITY: Escape single quotes in all parameters to prevent injection
|
|
# When parameters are embedded in the Node.js script string, unescaped quotes
|
|
# could break out of the string context and execute arbitrary code
|
|
expected_state="${expected_state//\'/\\\'}"
|
|
success_html="${success_html//\'/\\\'}"
|
|
error_html="${error_html//\'/\\\'}"
|
|
code_file="${code_file//\'/\\\'}"
|
|
port_file="${port_file//\'/\\\'}"
|
|
|
|
printf '%s' "
|
|
const http = require('http');
|
|
const fs = require('fs');
|
|
const url = require('url');
|
|
const expectedState = '${expected_state}';
|
|
const html = '${success_html}';
|
|
const errorHtml = '${error_html}';
|
|
const server = http.createServer((req, res) => {
|
|
const parsed = url.parse(req.url, true);
|
|
if (parsed.pathname === '/callback' && parsed.query.code) {
|
|
if (!parsed.query.state || parsed.query.state !== expectedState) {
|
|
res.writeHead(403, {'Content-Type':'text/html','Connection':'close'});
|
|
res.end(errorHtml);
|
|
setTimeout(() => { server.close(); process.exit(1); }, 500);
|
|
return;
|
|
}
|
|
fs.writeFileSync('${code_file}', parsed.query.code);
|
|
res.writeHead(200, {'Content-Type':'text/html','Connection':'close'});
|
|
res.end(html);
|
|
setTimeout(() => { server.close(); process.exit(0); }, 500);
|
|
} else {
|
|
res.writeHead(200, {'Content-Type':'text/html'});
|
|
res.end('<html><body>Waiting for OAuth callback...</body></html>');
|
|
}
|
|
});
|
|
let currentPort = ${starting_port};
|
|
const maxPort = ${starting_port} + 10;
|
|
function tryListen() {
|
|
server.listen(currentPort, '127.0.0.1', () => {
|
|
fs.writeFileSync('${port_file}', currentPort.toString());
|
|
fs.writeFileSync('/dev/fd/1', '');
|
|
});
|
|
}
|
|
server.on('error', (err) => {
|
|
if (err.code === 'EADDRINUSE' && currentPort < maxPort) {
|
|
currentPort++;
|
|
tryListen();
|
|
} else {
|
|
process.exit(1);
|
|
}
|
|
});
|
|
setTimeout(() => process.exit(0), 300000);
|
|
tryListen();
|
|
"
|
|
}
|
|
|
|
# Start OAuth callback server using Node.js/Bun HTTP server
|
|
# Proper HTTP server — handles multiple connections, favicon requests, etc.
|
|
# Tries a range of ports if the initial port is busy
|
|
# $1=starting_port $2=code_file $3=port_file (writes actual port used) $4=state_file (CSRF token)
|
|
# Returns: server PID
|
|
# SECURITY: Validates port number and CSRF state parameter
|
|
start_oauth_server() {
|
|
local starting_port="${1}"
|
|
local code_file="${2}"
|
|
local port_file="${3}"
|
|
local state_file="${4}"
|
|
|
|
_validate_oauth_server_args "${starting_port}" "${state_file}" || return 1
|
|
|
|
_generate_oauth_html
|
|
local script
|
|
script=$(_generate_oauth_server_script "${OAUTH_STATE}" "${OAUTH_SUCCESS_HTML}" "${OAUTH_ERROR_HTML}" \
|
|
"${code_file}" "${port_file}" "${starting_port}")
|
|
|
|
"${OAUTH_RUNTIME}" -e "${script}" </dev/null >/dev/null 2>&1 &
|
|
|
|
echo $!
|
|
}
|
|
|
|
# Wait for OAuth code with timeout, returns 0 if code received
|
|
wait_for_oauth_code() {
|
|
local code_file="${1}"
|
|
local timeout="${2:-120}"
|
|
local elapsed=0
|
|
|
|
log_step "Waiting for authentication in browser (this usually takes 10-30 seconds, timeout: ${timeout}s)..."
|
|
while [[ ! -f "${code_file}" ]] && [[ ${elapsed} -lt ${timeout} ]]; do
|
|
sleep "${POLL_INTERVAL}"
|
|
# Use python3 for float addition since bash arithmetic only handles integers
|
|
# If POLL_INTERVAL is 0.5, bash $(( )) would fail. Fallback to integer increment.
|
|
if ! elapsed=$(python3 -c "print(int(${elapsed} + ${POLL_INTERVAL}))" 2>/dev/null); then
|
|
# Python failed (not installed or syntax error) - fallback to integer increment
|
|
elapsed=$((elapsed + 1))
|
|
fi
|
|
done
|
|
|
|
[[ -f "${code_file}" ]]
|
|
}
|
|
|
|
# Exchange OAuth code for API key
|
|
exchange_oauth_code() {
|
|
local oauth_code="${1}"
|
|
|
|
# SECURITY: Use json_escape to prevent JSON injection via crafted OAuth codes
|
|
local escaped_code
|
|
escaped_code=$(json_escape "${oauth_code}")
|
|
|
|
local key_response curl_exit
|
|
key_response=$(curl -s --max-time 30 -X POST "https://openrouter.ai/api/v1/auth/keys" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"code\": ${escaped_code}}" 2>&1)
|
|
curl_exit=$?
|
|
|
|
if [[ ${curl_exit} -ne 0 ]]; then
|
|
log_error "Failed to contact OpenRouter API (curl exit code: ${curl_exit})"
|
|
log_warn "This may indicate a network issue or temporary service outage"
|
|
log_warn "Please check your internet connection and try again"
|
|
return 1
|
|
fi
|
|
|
|
local api_key
|
|
api_key=$(echo "${key_response}" | grep -o '"key":"[^"]*"' | sed 's/"key":"//;s/"$//')
|
|
|
|
if [[ -z "${api_key}" ]]; then
|
|
log_error "Failed to exchange OAuth code for API key"
|
|
log_warn "Server response: ${key_response}"
|
|
log_warn "This may indicate the OAuth code expired or was already used"
|
|
log_warn "Please try again, or set OPENROUTER_API_KEY manually"
|
|
return 1
|
|
fi
|
|
|
|
echo "${api_key}"
|
|
}
|
|
|
|
# Clean up OAuth session resources
|
|
cleanup_oauth_session() {
|
|
local server_pid="${1}"
|
|
local oauth_dir="${2}"
|
|
|
|
if [[ -n "${server_pid}" ]]; then
|
|
# Kill process group to catch any child processes (netcat listeners, etc)
|
|
kill -TERM "-${server_pid}" 2>/dev/null || kill "${server_pid}" 2>/dev/null || true
|
|
# Give it time to shut down gracefully
|
|
sleep 0.5
|
|
# Force kill if still running
|
|
kill -KILL "-${server_pid}" 2>/dev/null || true
|
|
wait "${server_pid}" 2>/dev/null || true
|
|
fi
|
|
|
|
# SAFETY: Validate path before rm -rf to prevent accidental deletion of system directories
|
|
# Only delete if:
|
|
# 1. Variable is non-empty
|
|
# 2. Directory exists
|
|
# 3. Path starts with /tmp/ (mktemp always creates in /tmp)
|
|
# 4. Path contains more than just /tmp (prevent rm -rf /tmp)
|
|
if [[ -n "${oauth_dir}" && -d "${oauth_dir}" && "${oauth_dir}" == /tmp/* && "${oauth_dir}" != "/tmp" && "${oauth_dir}" != "/tmp/" ]]; then
|
|
rm -rf "${oauth_dir}"
|
|
fi
|
|
}
|
|
|
|
# Check network connectivity to OpenRouter
|
|
# Returns 0 if reachable, 1 if network is unreachable
|
|
check_openrouter_connectivity() {
|
|
local host="openrouter.ai"
|
|
local port="443"
|
|
local timeout=5
|
|
|
|
# Try curl with short timeout if available
|
|
if command -v curl &> /dev/null; then
|
|
if curl -s --connect-timeout "${timeout}" --max-time "${timeout}" "https://${host}" -o /dev/null 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
# Fallback to nc/telnet test
|
|
if command -v nc &> /dev/null; then
|
|
if timeout "${timeout}" nc -z "${host}" "${port}" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
elif command -v timeout &> /dev/null && command -v bash &> /dev/null; then
|
|
# Bash TCP socket test as last resort
|
|
if timeout "${timeout}" bash -c "exec 3<>/dev/tcp/${host}/${port}" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
# Start OAuth server and wait for it to be ready
|
|
# Returns: "port_number" on success, "" on failure (cleanup handled by caller)
|
|
start_and_verify_oauth_server() {
|
|
local callback_port="${1}"
|
|
local code_file="${2}"
|
|
local port_file="${3}"
|
|
local state_file="${4}"
|
|
local server_pid="${5}"
|
|
|
|
sleep "${POLL_INTERVAL}"
|
|
if ! kill -0 "${server_pid}" 2>/dev/null; then
|
|
log_warn "Failed to start OAuth server - ports ${callback_port}-$((callback_port + 10)) may be in use"
|
|
log_warn "Try closing other dev servers or set OPENROUTER_API_KEY to skip OAuth"
|
|
return 1
|
|
fi
|
|
|
|
# Wait for port file to be created (server successfully bound to a port)
|
|
local wait_count=0
|
|
while [[ ! -f "${port_file}" ]] && [[ ${wait_count} -lt 10 ]]; do
|
|
sleep 0.2
|
|
wait_count=$((wait_count + 1))
|
|
done
|
|
|
|
if [[ ! -f "${port_file}" ]]; then
|
|
log_warn "OAuth server failed to allocate a port after 2 seconds"
|
|
log_warn "Another process may be using ports ${callback_port}-$((callback_port + 10))"
|
|
return 1
|
|
fi
|
|
|
|
cat "${port_file}"
|
|
}
|
|
|
|
# Validate OAuth prerequisites (network, Node.js runtime)
|
|
# Returns 0 if all checks pass, 1 otherwise
|
|
_check_oauth_prerequisites() {
|
|
if ! check_openrouter_connectivity; then
|
|
log_warn "Cannot reach openrouter.ai - network may be unavailable"
|
|
log_warn "Please check your internet connection and try again"
|
|
log_warn "Alternatively, set OPENROUTER_API_KEY in your environment to skip OAuth"
|
|
return 1
|
|
fi
|
|
|
|
local runtime
|
|
runtime=$(find_node_runtime)
|
|
if [[ -z "${runtime}" ]]; then
|
|
log_warn "No Node.js runtime (bun/node) found - required for the OAuth callback server"
|
|
log_warn "Install one with: brew install node OR curl -fsSL https://bun.sh/install | bash"
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# Start OAuth server and return actual port, cleanup on failure
|
|
# Sets server_pid and returns 0 on success, 1 on failure
|
|
_setup_oauth_server() {
|
|
local callback_port="${1}"
|
|
local code_file="${2}"
|
|
local port_file="${3}"
|
|
local state_file="${4}"
|
|
local pid_file="${5}"
|
|
|
|
log_step "Starting local OAuth server (trying ports ${callback_port}-$((callback_port + 10)))..."
|
|
local server_pid
|
|
server_pid=$(start_oauth_server "${callback_port}" "${code_file}" "${port_file}" "${state_file}")
|
|
|
|
# Persist server PID to file for reliable retrieval
|
|
if [[ -n "${pid_file}" && -n "${server_pid}" ]]; then
|
|
printf '%s' "${server_pid}" > "${pid_file}"
|
|
fi
|
|
|
|
local actual_port
|
|
actual_port=$(start_and_verify_oauth_server "${callback_port}" "${code_file}" "${port_file}" "${state_file}" "${server_pid}")
|
|
if [[ -z "${actual_port}" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
log_info "OAuth server listening on port ${actual_port}"
|
|
echo "${actual_port}"
|
|
return 0
|
|
}
|
|
|
|
# Wait for OAuth code with timeout and cleanup on failure
|
|
# Returns 0 on success, 1 on failure
|
|
_wait_for_oauth() {
|
|
local code_file="${1}"
|
|
|
|
if ! wait_for_oauth_code "${code_file}" 120; then
|
|
log_warn "OAuth timeout - no response received"
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Try OAuth flow (orchestrates the helper functions above)
|
|
# SECURITY: Generates CSRF state token to prevent OAuth code interception
|
|
_generate_csrf_state() {
|
|
if command -v openssl &>/dev/null; then
|
|
openssl rand -hex 16
|
|
elif [[ -r /dev/urandom ]]; then
|
|
od -An -N16 -tx1 /dev/urandom | tr -d ' \n'
|
|
else
|
|
log_error "Cannot generate secure CSRF token: neither openssl nor /dev/urandom available"
|
|
log_error "Install openssl or ensure /dev/urandom is readable"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Create temp directory with OAuth session files and CSRF state
|
|
_init_oauth_session() {
|
|
local oauth_dir
|
|
oauth_dir=$(mktemp -d) || {
|
|
log_error "Failed to create temporary directory for OAuth session"
|
|
log_error "Check disk space and /tmp permissions"
|
|
return 1
|
|
}
|
|
|
|
# SAFETY: Verify mktemp succeeded before proceeding
|
|
if [[ -z "${oauth_dir}" || ! -d "${oauth_dir}" ]]; then
|
|
log_error "Failed to create temporary directory for OAuth session"
|
|
log_error "Check disk space and /tmp permissions"
|
|
return 1
|
|
fi
|
|
|
|
# SECURITY: Generate random CSRF state token (32 hex chars = 128 bits)
|
|
local csrf_state
|
|
csrf_state=$(_generate_csrf_state)
|
|
printf '%s' "${csrf_state}" > "${oauth_dir}/state" || {
|
|
rm -rf "${oauth_dir}"
|
|
log_error "Failed to write OAuth state file"
|
|
return 1
|
|
}
|
|
chmod 600 "${oauth_dir}/state"
|
|
|
|
echo "${oauth_dir}"
|
|
}
|
|
|
|
# Open browser and wait for OAuth callback, returning the auth code
|
|
# Outputs the OAuth code on success, returns 1 on timeout
|
|
_await_oauth_callback() {
|
|
local code_file="${1}"
|
|
local server_pid="${2}"
|
|
local oauth_dir="${3}"
|
|
local actual_port="${4}"
|
|
local csrf_state="${5}"
|
|
|
|
local callback_url="http://localhost:${actual_port}/callback"
|
|
local auth_url="https://openrouter.ai/auth?callback_url=${callback_url}&state=${csrf_state}"
|
|
log_step "Opening browser to authenticate with OpenRouter..."
|
|
open_browser "${auth_url}"
|
|
|
|
if ! _wait_for_oauth "${code_file}"; then
|
|
cleanup_oauth_session "${server_pid}" "${oauth_dir}"
|
|
log_error "OAuth authentication timed out after 120 seconds"
|
|
log_error ""
|
|
log_error "The authentication flow was not completed in time."
|
|
log_error ""
|
|
log_error "Troubleshooting:"
|
|
log_error " 1. Check if your browser opened to openrouter.ai"
|
|
log_error " 2. Complete the authentication and allow the redirect"
|
|
log_error " 3. Ensure port ${actual_port} is not blocked by firewall/proxy"
|
|
log_error ""
|
|
log_error "Alternative: Use a manual API key instead"
|
|
log_error " export OPENROUTER_API_KEY=sk-or-v1-..."
|
|
log_error " Get a key at: https://openrouter.ai/settings/keys"
|
|
return 1
|
|
fi
|
|
|
|
cat "${code_file}"
|
|
}
|
|
|
|
# Helper: Start OAuth server and get session details
|
|
# Returns: "port|pid|oauth_dir" on success, "" on failure
|
|
_start_oauth_session_with_server() {
|
|
local callback_port="${1}"
|
|
|
|
local oauth_dir
|
|
oauth_dir=$(_init_oauth_session)
|
|
local code_file="${oauth_dir}/code"
|
|
local pid_file="${oauth_dir}/server_pid"
|
|
|
|
local actual_port
|
|
actual_port=$(_setup_oauth_server "${callback_port}" "${code_file}" "${oauth_dir}/port" "${oauth_dir}/state" "${pid_file}") || {
|
|
cleanup_oauth_session "" "${oauth_dir}"
|
|
return 1
|
|
}
|
|
|
|
local server_pid
|
|
server_pid=$(cat "${pid_file}" 2>/dev/null || echo "")
|
|
if [[ -z "${server_pid}" ]]; then
|
|
log_error "Failed to retrieve OAuth server PID"
|
|
cleanup_oauth_session "" "${oauth_dir}"
|
|
return 1
|
|
fi
|
|
|
|
echo "${actual_port}|${server_pid}|${oauth_dir}"
|
|
}
|
|
|
|
try_oauth_flow() {
|
|
local callback_port=${1:-5180}
|
|
|
|
log_step "Attempting OAuth authentication..."
|
|
|
|
if ! _check_oauth_prerequisites; then
|
|
return 1
|
|
fi
|
|
|
|
local session_info
|
|
session_info=$(_start_oauth_session_with_server "${callback_port}") || return 1
|
|
|
|
local actual_port server_pid oauth_dir
|
|
IFS='|' read -r actual_port server_pid oauth_dir <<< "${session_info}"
|
|
|
|
local csrf_state
|
|
csrf_state=$(cat "${oauth_dir}/state")
|
|
|
|
# Open browser and wait for callback
|
|
local oauth_code
|
|
oauth_code=$(_await_oauth_callback "${oauth_dir}/code" "${server_pid}" "${oauth_dir}" "${actual_port}" "${csrf_state}") || return 1
|
|
cleanup_oauth_session "${server_pid}" "${oauth_dir}"
|
|
|
|
# Exchange code for API key
|
|
log_step "Exchanging OAuth code for API key..."
|
|
local api_key
|
|
api_key=$(exchange_oauth_code "${oauth_code}") || return 1
|
|
|
|
log_info "Successfully obtained OpenRouter API key via OAuth!"
|
|
echo "${api_key}"
|
|
}
|
|
|
|
# Main function: Try OAuth, fallback to manual entry
|
|
get_openrouter_api_key_oauth() {
|
|
local callback_port=${1:-5180}
|
|
|
|
# Try OAuth flow first
|
|
local api_key
|
|
api_key=$(try_oauth_flow "${callback_port}")
|
|
|
|
if [[ -n "${api_key}" ]]; then
|
|
echo "${api_key}"
|
|
return 0
|
|
fi
|
|
|
|
# OAuth failed, offer manual entry
|
|
echo ""
|
|
log_warn "Browser-based OAuth login was not completed."
|
|
log_warn "This is normal on remote servers, SSH sessions, or headless environments."
|
|
log_info "You can paste an API key instead. Create one at: https://openrouter.ai/settings/keys"
|
|
echo ""
|
|
local manual_choice
|
|
manual_choice=$(safe_read "Paste your API key manually? (Y/n): ") || {
|
|
log_error "Cannot prompt for manual entry in non-interactive mode"
|
|
log_warn "Set OPENROUTER_API_KEY environment variable before running spawn"
|
|
return 1
|
|
}
|
|
|
|
if [[ "${manual_choice}" =~ ^[Nn]$ ]]; then
|
|
log_error "Authentication cancelled. An OpenRouter API key is required to use spawn."
|
|
log_warn "To authenticate, either:"
|
|
log_warn " - Re-run this command and complete the OAuth flow in your browser"
|
|
log_warn " - Set OPENROUTER_API_KEY=sk-or-v1-... before running spawn"
|
|
log_warn " - Create a key at: https://openrouter.ai/settings/keys"
|
|
return 1
|
|
fi
|
|
|
|
api_key=$(get_openrouter_api_key_manual)
|
|
echo "${api_key}"
|
|
}
|
|
|
|
# ============================================================
|
|
# Environment injection helpers
|
|
# ============================================================
|
|
|
|
# Generate environment variable config content
|
|
# Usage: generate_env_config KEY1=val1 KEY2=val2 ...
|
|
# Outputs the env config to stdout
|
|
# SECURITY: Values are single-quoted to prevent shell injection when sourced.
|
|
# Single quotes prevent all interpretation of special characters ($, `, \, etc.)
|
|
generate_env_config() {
|
|
echo ""
|
|
echo "# [spawn:env]"
|
|
# All spawn environments are disposable cloud VMs — mark as sandbox
|
|
echo "export IS_SANDBOX='1'"
|
|
for env_pair in "$@"; do
|
|
local key="${env_pair%%=*}"
|
|
local value="${env_pair#*=}"
|
|
|
|
# SECURITY: Validate environment variable names to prevent injection
|
|
# Only allow uppercase letters, numbers, and underscores (standard env var format)
|
|
if [[ ! "${key}" =~ ^[A-Z_][A-Z0-9_]*$ ]]; then
|
|
log_error "SECURITY: Invalid environment variable name rejected: ${key}"
|
|
continue
|
|
fi
|
|
|
|
# Escape any single quotes in the value: replace ' with '\''
|
|
local escaped_value="${value//\'/\'\\\'\'}"
|
|
echo "export ${key}='${escaped_value}'"
|
|
done
|
|
}
|
|
|
|
# Inject environment variables into remote server's shell config (SSH-based clouds)
|
|
# Usage: inject_env_vars_ssh SERVER_IP UPLOAD_FUNC RUN_FUNC KEY1=val1 KEY2=val2 ...
|
|
# Example: inject_env_vars_ssh "$DO_SERVER_IP" upload_file run_server \
|
|
# "OPENROUTER_API_KEY=$OPENROUTER_API_KEY" \
|
|
# "ANTHROPIC_BASE_URL=https://openrouter.ai/api"
|
|
inject_env_vars_ssh() {
|
|
local server_ip="${1}"
|
|
local upload_func="${2}"
|
|
local run_func="${3}"
|
|
shift 3
|
|
|
|
local env_temp
|
|
env_temp=$(mktemp)
|
|
chmod 600 "${env_temp}"
|
|
track_temp_file "${env_temp}"
|
|
|
|
generate_env_config "$@" > "${env_temp}"
|
|
|
|
# SECURITY: Use unpredictable temp file name to prevent race condition
|
|
# Attacker could create symlink at /tmp/env_config to exfiltrate credentials
|
|
local rand_suffix
|
|
rand_suffix=$(basename "${env_temp}")
|
|
local temp_remote="/tmp/spawn_env_${rand_suffix}"
|
|
|
|
# Append to .bashrc and .zshrc only — do NOT write to .profile or .bash_profile
|
|
"${upload_func}" "${server_ip}" "${env_temp}" "${temp_remote}"
|
|
"${run_func}" "${server_ip}" "cat '${temp_remote}' >> ~/.bashrc && cat '${temp_remote}' >> ~/.zshrc && rm '${temp_remote}'"
|
|
|
|
# Note: temp file will be cleaned up by trap handler
|
|
|
|
# Offer optional GitHub CLI setup
|
|
offer_github_auth "${run_func} ${server_ip}"
|
|
}
|
|
|
|
# Inject environment variables for providers without SSH (modal, e2b, sprite)
|
|
# For providers where upload_file and run_server don't take server_ip as first arg
|
|
# Usage: inject_env_vars_local upload_file run_server KEY1=VAL1 KEY2=VAL2 ...
|
|
# Example: inject_env_vars_local upload_file run_server \
|
|
# "OPENROUTER_API_KEY=$OPENROUTER_API_KEY" \
|
|
# "ANTHROPIC_BASE_URL=https://openrouter.ai/api"
|
|
inject_env_vars_local() {
|
|
local upload_func="${1}"
|
|
local run_func="${2}"
|
|
shift 2
|
|
|
|
local env_temp
|
|
env_temp=$(mktemp)
|
|
chmod 600 "${env_temp}"
|
|
track_temp_file "${env_temp}"
|
|
|
|
generate_env_config "$@" > "${env_temp}"
|
|
|
|
# SECURITY: Use unpredictable temp file name to prevent race condition
|
|
local rand_suffix
|
|
rand_suffix=$(basename "${env_temp}")
|
|
local temp_remote="/tmp/spawn_env_${rand_suffix}"
|
|
|
|
# Append to .bashrc and .zshrc only
|
|
"${upload_func}" "${env_temp}" "${temp_remote}"
|
|
"${run_func}" "cat '${temp_remote}' >> ~/.bashrc && cat '${temp_remote}' >> ~/.zshrc && rm '${temp_remote}'"
|
|
|
|
# Note: temp file will be cleaned up by trap handler
|
|
|
|
# Offer optional GitHub CLI setup
|
|
offer_github_auth "${run_func}"
|
|
}
|
|
|
|
# Prompt user about GitHub CLI setup BEFORE provisioning.
|
|
# Stores the answer so the actual install can happen later (after the
|
|
# server is up) without re-prompting.
|
|
# Usage: prompt_github_auth (call before create_server)
|
|
prompt_github_auth() {
|
|
SPAWN_GITHUB_AUTH_PROMPTED=1
|
|
|
|
# Skip in non-interactive or if user opted out
|
|
if [[ -n "${SPAWN_SKIP_GITHUB_AUTH:-}" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
printf '\n'
|
|
local choice
|
|
choice=$(safe_read "Set up GitHub CLI (gh) on this machine? (y/N): ") || return 0
|
|
if [[ "${choice}" =~ ^[Yy]$ ]]; then
|
|
SPAWN_GITHUB_AUTH_REQUESTED=1
|
|
fi
|
|
}
|
|
|
|
# Run GitHub CLI setup on remote VM if previously requested via prompt_github_auth.
|
|
# If prompt_github_auth was never called, falls back to prompting interactively.
|
|
# Usage (SSH clouds): offer_github_auth "run_server SERVER_IP"
|
|
# Usage (local): offer_github_auth "run_server"
|
|
offer_github_auth() {
|
|
local run_callback="${1}"
|
|
|
|
# Skip if user opted out via env var
|
|
if [[ -n "${SPAWN_SKIP_GITHUB_AUTH:-}" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
# If prompt_github_auth was already called, use its stored answer
|
|
if [[ "${SPAWN_GITHUB_AUTH_PROMPTED:-}" == "1" ]]; then
|
|
if [[ "${SPAWN_GITHUB_AUTH_REQUESTED:-}" == "1" ]]; then
|
|
log_step "Installing and authenticating GitHub CLI..."
|
|
${run_callback} "curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/shared/github-auth.sh | bash"
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
# Fallback: prompt_github_auth was never called, ask now
|
|
printf '\n'
|
|
local choice
|
|
choice=$(safe_read "Set up GitHub CLI (gh) on this machine? (y/N): ") || return 0
|
|
if [[ ! "${choice}" =~ ^[Yy]$ ]]; then
|
|
return 0
|
|
fi
|
|
|
|
log_step "Installing and authenticating GitHub CLI..."
|
|
${run_callback} "curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/shared/github-auth.sh | bash"
|
|
}
|
|
|
|
# ============================================================
|
|
# Resource cleanup trap handlers
|
|
# ============================================================
|
|
|
|
# Array to track temporary files for cleanup
|
|
CLEANUP_TEMP_FILES=()
|
|
|
|
# Track a temporary file for cleanup on exit
|
|
# Usage: track_temp_file PATH
|
|
track_temp_file() {
|
|
local temp_file="${1}"
|
|
CLEANUP_TEMP_FILES+=("${temp_file}")
|
|
}
|
|
|
|
# Cleanup function for temporary files
|
|
# Called automatically on EXIT, INT, TERM signals
|
|
cleanup_temp_files() {
|
|
local exit_code=$?
|
|
|
|
for temp_file in "${CLEANUP_TEMP_FILES[@]}"; do
|
|
if [[ -f "${temp_file}" ]]; then
|
|
# Securely remove temp files (may contain credentials)
|
|
shred -f -u "${temp_file}" 2>/dev/null || rm -f "${temp_file}"
|
|
fi
|
|
done
|
|
|
|
return "${exit_code}"
|
|
}
|
|
|
|
# Register cleanup trap handler
|
|
# Call this at the start of scripts that create temp files
|
|
register_cleanup_trap() {
|
|
trap cleanup_temp_files EXIT INT TERM
|
|
}
|
|
|
|
# ============================================================
|
|
# Agent setup helpers (composable, callback-based)
|
|
# ============================================================
|
|
# These helpers accept pre-applied RUN/UPLOAD/SESSION callbacks,
|
|
# following the same callback pattern used by offer_github_auth
|
|
# and setup_claude_code_config.
|
|
#
|
|
# Usage pattern in agent scripts:
|
|
# RUN="run_server ${SERVER_IP}"
|
|
# UPLOAD="upload_file ${SERVER_IP}"
|
|
# SESSION="interactive_session ${SERVER_IP}"
|
|
#
|
|
# install_agent "Aider" "pip install aider-chat" "$RUN"
|
|
# verify_agent "Aider" "command -v aider && aider --version" "pip install aider-chat" "$RUN"
|
|
# get_or_prompt_api_key
|
|
# inject_env_vars_cb "$RUN" "$UPLOAD" "OPENROUTER_API_KEY=${OPENROUTER_API_KEY}"
|
|
# launch_session "Hetzner server" "$SESSION" "source ~/.zshrc && aider"
|
|
|
|
# Run an agent's install command on the target machine
|
|
# Usage: install_agent AGENT_NAME INSTALL_CMD RUN_CB
|
|
install_agent() {
|
|
local agent_name="$1" install_cmd="$2" run_cb="$3"
|
|
log_step "Installing ${agent_name}..."
|
|
${run_cb} "${install_cmd}"
|
|
}
|
|
|
|
# Verify an agent installed correctly; exit 1 on failure
|
|
# Usage: verify_agent AGENT_NAME VERIFY_CMD INSTALL_CMD RUN_CB
|
|
verify_agent() {
|
|
local agent_name="$1" verify_cmd="$2" install_cmd="$3" run_cb="$4"
|
|
if ! ${run_cb} "${verify_cmd}" >/dev/null 2>&1; then
|
|
log_install_failed "${agent_name}" "${install_cmd}"
|
|
exit 1
|
|
fi
|
|
log_info "${agent_name} installation verified successfully"
|
|
}
|
|
|
|
# Install Claude Code with multi-method fallback and detailed error reporting.
|
|
# Tries: 1) curl installer (standalone binary) 2) bun 3) npm
|
|
# The curl installer bundles its own runtime. npm/bun install a Node.js package
|
|
# whose shebang needs 'node', so we ensure a node runtime exists after those.
|
|
# Usage: install_claude_code RUN_CB
|
|
_finalize_claude_install() {
|
|
local run_cb="$1"
|
|
local claude_path="$2"
|
|
log_step "Setting up Claude Code shell integration..."
|
|
${run_cb} "${claude_path} && claude install --force" >/dev/null 2>&1 || true
|
|
# Write claude PATH to .bashrc and .zshrc
|
|
${run_cb} "for rc in ~/.bashrc ~/.zshrc; do grep -q '.claude/local/bin' \"\$rc\" 2>/dev/null || printf '\\n# Claude Code PATH\\nexport PATH=\"\$HOME/.claude/local/bin:\$HOME/.local/bin:\$HOME/.bun/bin:\$PATH\"\\n' >> \"\$rc\"; done" >/dev/null 2>&1 || true
|
|
}
|
|
|
|
_verify_claude_installed() {
|
|
local run_cb="$1"
|
|
local claude_path="$2"
|
|
${run_cb} "${claude_path} && command -v claude" >/dev/null 2>&1
|
|
}
|
|
|
|
_install_via_curl() {
|
|
local run_cb="$1"
|
|
local claude_path="$2"
|
|
log_step "Installing Claude Code (method 1/2: curl installer)..."
|
|
if ${run_cb} "curl -fsSL https://claude.ai/install.sh | bash" 2>&1; then
|
|
if _verify_claude_installed "$run_cb" "$claude_path"; then
|
|
log_info "Claude Code installed via curl installer"
|
|
_finalize_claude_install "$run_cb" "$claude_path"
|
|
return 0
|
|
fi
|
|
log_warn "curl installer exited 0 but claude not found on PATH"
|
|
else
|
|
log_warn "curl installer failed (site may be temporarily unavailable)"
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
_ensure_nodejs_runtime() {
|
|
local run_cb="$1"
|
|
local claude_path="$2"
|
|
if ! ${run_cb} "${claude_path} && command -v node" >/dev/null 2>&1; then
|
|
log_step "Installing Node.js runtime (required for claude package)..."
|
|
if ${run_cb} "curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && apt-get install -y nodejs" >/dev/null 2>&1; then
|
|
log_info "Node.js installed via nodesource"
|
|
else
|
|
log_warn "Could not install Node.js - bun method may fail"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
_install_via_bun() {
|
|
local run_cb="$1"
|
|
local claude_path="$2"
|
|
log_step "Installing Claude Code (method 2/2: bun)..."
|
|
if ${run_cb} "${claude_path} && bun i -g @anthropic-ai/claude-code 2>&1" 2>&1; then
|
|
if _verify_claude_installed "$run_cb" "$claude_path"; then
|
|
log_info "Claude Code installed via bun"
|
|
_finalize_claude_install "$run_cb" "$claude_path"
|
|
return 0
|
|
fi
|
|
log_warn "bun install exited 0 but claude binary not found"
|
|
else
|
|
log_warn "bun install failed"
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
install_claude_code() {
|
|
local run_cb="$1"
|
|
local claude_path='export PATH=$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH'
|
|
|
|
# Clean up ~/.bash_profile if it was created by a previous broken deployment.
|
|
${run_cb} "if [ -f ~/.bash_profile ] && grep -q 'spawn:env\|Claude Code PATH\|spawn:path' ~/.bash_profile 2>/dev/null; then rm -f ~/.bash_profile; fi" >/dev/null 2>&1 || true
|
|
|
|
# Already installed?
|
|
if _verify_claude_installed "$run_cb" "$claude_path"; then
|
|
log_info "Claude Code already installed"
|
|
_finalize_claude_install "$run_cb" "$claude_path"
|
|
return 0
|
|
fi
|
|
|
|
# Try curl installer first
|
|
if _install_via_curl "$run_cb" "$claude_path"; then
|
|
return 0
|
|
fi
|
|
|
|
# Ensure Node.js runtime for bun method
|
|
_ensure_nodejs_runtime "$run_cb" "$claude_path"
|
|
|
|
# Try bun installer
|
|
if _install_via_bun "$run_cb" "$claude_path"; then
|
|
return 0
|
|
fi
|
|
|
|
# All methods failed
|
|
log_install_failed "Claude Code" "curl -fsSL https://claude.ai/install.sh | bash"
|
|
exit 1
|
|
}
|
|
|
|
# Get OpenRouter API key from environment or prompt via OAuth
|
|
# Sets the global OPENROUTER_API_KEY variable
|
|
get_or_prompt_api_key() {
|
|
echo ""
|
|
if [[ -n "${OPENROUTER_API_KEY:-}" ]]; then
|
|
log_info "Using OpenRouter API key from environment"
|
|
else
|
|
OPENROUTER_API_KEY=$(get_openrouter_api_key_oauth 5180)
|
|
fi
|
|
}
|
|
|
|
# Inject environment variables using pre-applied callbacks
|
|
# Usage: inject_env_vars_cb RUN_CB UPLOAD_CB KEY1=val1 KEY2=val2 ...
|
|
# Example: inject_env_vars_cb "$RUN" "$UPLOAD" \
|
|
# "OPENROUTER_API_KEY=$OPENROUTER_API_KEY" \
|
|
# "ANTHROPIC_BASE_URL=https://openrouter.ai/api"
|
|
inject_env_vars_cb() {
|
|
local run_cb="$1" upload_cb="$2"
|
|
shift 2
|
|
|
|
log_step "Setting up environment variables..."
|
|
|
|
local env_temp
|
|
env_temp=$(mktemp)
|
|
chmod 600 "${env_temp}"
|
|
track_temp_file "${env_temp}"
|
|
|
|
generate_env_config "$@" > "${env_temp}"
|
|
|
|
# SECURITY: Use unpredictable temp file name to prevent race condition
|
|
local rand_suffix
|
|
rand_suffix=$(basename "${env_temp}")
|
|
local temp_remote="/tmp/spawn_env_${rand_suffix}"
|
|
|
|
${upload_cb} "${env_temp}" "${temp_remote}"
|
|
${run_cb} "cat '${temp_remote}' >> ~/.bashrc && cat '${temp_remote}' >> ~/.zshrc && rm '${temp_remote}'"
|
|
|
|
# Offer optional GitHub CLI setup
|
|
offer_github_auth "${run_cb}"
|
|
}
|
|
|
|
# Print success message and launch an interactive agent session
|
|
# Usage: launch_session CLOUD_MSG SESSION_CB LAUNCH_CMD
|
|
launch_session() {
|
|
local cloud_msg="$1" session_cb="$2" launch_cmd="$3"
|
|
echo ""
|
|
log_info "${cloud_msg} setup completed successfully!"
|
|
echo ""
|
|
log_step "Starting agent..."
|
|
sleep 1
|
|
clear 2>/dev/null || true
|
|
${session_cb} "${launch_cmd}"
|
|
}
|
|
|
|
# ============================================================
|
|
# Cloud adapter runner (spawn_agent)
|
|
# ============================================================
|
|
# Orchestrates the standard agent deployment flow using cloud_* adapter
|
|
# functions. Agent scripts define hooks (agent_install, agent_env_vars,
|
|
# agent_launch_cmd, etc.) and call spawn_agent to run them.
|
|
#
|
|
# Required cloud_* functions (defined in {cloud}/lib/common.sh):
|
|
# cloud_authenticate, cloud_provision, cloud_wait_ready,
|
|
# cloud_run, cloud_upload, cloud_interactive, cloud_label
|
|
#
|
|
# Required agent hooks:
|
|
# agent_env_vars — print env config lines to stdout (via generate_env_config)
|
|
# agent_launch_cmd — print the shell command to launch the agent
|
|
#
|
|
# Optional agent hooks:
|
|
# agent_pre_provision — run before provisioning (e.g., prompt_github_auth)
|
|
# agent_install — install the agent on the server
|
|
# agent_configure — agent-specific config (settings files, etc.)
|
|
# agent_save_connection — save connection info for `spawn list`
|
|
# agent_pre_launch — run before launching (e.g., start daemon)
|
|
#
|
|
# Optional agent variables:
|
|
# AGENT_MODEL_PROMPT — if set, prompt for model selection
|
|
# AGENT_MODEL_DEFAULT — default model ID (default: openrouter/auto)
|
|
|
|
# Check if a function is defined (bash 3.2 compatible)
|
|
_fn_exists() { type "$1" 2>/dev/null | head -1 | grep -q 'function'; }
|
|
|
|
# Inject env vars using cloud_* adapter functions
|
|
_spawn_inject_env_vars() {
|
|
log_step "Setting up environment variables..."
|
|
local env_temp
|
|
env_temp=$(mktemp)
|
|
chmod 600 "${env_temp}"
|
|
track_temp_file "${env_temp}"
|
|
|
|
agent_env_vars > "${env_temp}"
|
|
|
|
cloud_upload "${env_temp}" "/tmp/env_config"
|
|
|
|
# Write env vars to ~/.spawnrc instead of inlining into .bashrc/.zshrc.
|
|
# Ubuntu's default .bashrc has an interactive-shell guard that exits early —
|
|
# anything appended after the guard is never loaded when SSH runs a command string.
|
|
cloud_run "cp /tmp/env_config ~/.spawnrc && chmod 600 ~/.spawnrc && rm /tmp/env_config"
|
|
|
|
# Hook .spawnrc into .bashrc and .zshrc so interactive shells pick up the vars too
|
|
cloud_run "grep -q 'source ~/.spawnrc' ~/.bashrc 2>/dev/null || echo '[ -f ~/.spawnrc ] && source ~/.spawnrc' >> ~/.bashrc"
|
|
cloud_run "grep -q 'source ~/.spawnrc' ~/.zshrc 2>/dev/null || echo '[ -f ~/.spawnrc ] && source ~/.spawnrc' >> ~/.zshrc"
|
|
|
|
offer_github_auth cloud_run
|
|
}
|
|
|
|
# Main orchestration runner for agent deployment
|
|
# Usage: spawn_agent AGENT_DISPLAY_NAME
|
|
spawn_agent() {
|
|
local agent_name="$1"
|
|
|
|
# 1. Authenticate with cloud provider
|
|
cloud_authenticate
|
|
|
|
# 2. Pre-provision hooks (e.g., prompt for GitHub auth)
|
|
if _fn_exists agent_pre_provision; then agent_pre_provision; fi
|
|
|
|
# 3. Provision server
|
|
local server_name
|
|
server_name=$(get_server_name)
|
|
cloud_provision "${server_name}"
|
|
|
|
# 4. Wait for readiness
|
|
cloud_wait_ready
|
|
|
|
# 5. Install agent
|
|
if _fn_exists agent_install; then agent_install; fi
|
|
|
|
# 6. Get API key
|
|
get_or_prompt_api_key
|
|
|
|
# 7. Model selection (if agent needs it)
|
|
if [[ -n "${AGENT_MODEL_PROMPT:-}" ]]; then
|
|
MODEL_ID=$(get_model_id_interactive "${AGENT_MODEL_DEFAULT:-openrouter/auto}" "${agent_name}") || exit 1
|
|
fi
|
|
|
|
# 8. Inject environment variables
|
|
_spawn_inject_env_vars
|
|
|
|
# 9. Agent-specific configuration
|
|
if _fn_exists agent_configure; then agent_configure; fi
|
|
|
|
# 10. Save connection info
|
|
if _fn_exists agent_save_connection; then agent_save_connection; fi
|
|
|
|
# 11. Pre-launch hooks (e.g., start gateway daemon)
|
|
if _fn_exists agent_pre_launch; then agent_pre_launch; fi
|
|
|
|
# 12. Launch interactive session
|
|
local launch_cmd
|
|
launch_cmd=$(agent_launch_cmd)
|
|
launch_session "$(cloud_label)" cloud_interactive "${launch_cmd}"
|
|
}
|
|
|
|
# ============================================================
|
|
# SSH configuration
|
|
# ============================================================
|
|
|
|
# Validate SSH_OPTS to prevent command injection
|
|
# Only allow safe SSH option patterns (dash-prefixed flags and values)
|
|
_validate_ssh_opts() {
|
|
local opts="${1}"
|
|
# Allow empty
|
|
if [[ -z "${opts}" ]]; then
|
|
return 0
|
|
fi
|
|
# Pattern: SSH opts must start with dash and contain only safe characters
|
|
# Allows: -o Option=value -i /path/to/key -p 22 etc.
|
|
# Blocks: semicolons, pipes, backticks, $() and other shell metacharacters
|
|
if [[ "${opts}" =~ [\;\|\&\`\$\(\)\<\>] ]]; then
|
|
log_error "SECURITY: SSH_OPTS contains shell metacharacters"
|
|
log_error "Rejected value: ${opts}"
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Default SSH options for all cloud providers
|
|
# Clouds can override this if they need provider-specific settings
|
|
if [[ -z "${SSH_OPTS:-}" ]]; then
|
|
SSH_OPTS="-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i ${HOME}/.ssh/id_ed25519"
|
|
else
|
|
# Validate user-provided SSH_OPTS for security
|
|
if ! _validate_ssh_opts "${SSH_OPTS}"; then
|
|
log_error "Invalid SSH_OPTS provided. Using secure defaults."
|
|
SSH_OPTS="-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i ${HOME}/.ssh/id_ed25519"
|
|
fi
|
|
fi
|
|
|
|
# ============================================================
|
|
# SSH key management helpers
|
|
# ============================================================
|
|
|
|
# Generate SSH key if it doesn't exist
|
|
# Usage: generate_ssh_key_if_missing KEY_PATH
|
|
generate_ssh_key_if_missing() {
|
|
local key_path="${1}"
|
|
if [[ -f "${key_path}" ]]; then
|
|
return 0
|
|
fi
|
|
log_step "Generating SSH key at ${key_path}..."
|
|
mkdir -p "$(dirname "${key_path}")" || {
|
|
log_error "Failed to create SSH key directory: $(dirname "${key_path}")"
|
|
log_error "Check that you have write permissions to this directory."
|
|
return 1
|
|
}
|
|
ssh-keygen -t ed25519 -f "${key_path}" -N "" -q || {
|
|
log_error "Failed to generate SSH key at ${key_path}"
|
|
log_error ""
|
|
log_error "How to fix:"
|
|
log_error " 1. Check disk space: df -h $(dirname "${key_path}")"
|
|
log_error " 2. Check permissions: ls -la $(dirname "${key_path}")"
|
|
log_error " 3. Generate manually: ssh-keygen -t ed25519 -f ${key_path}"
|
|
return 1
|
|
}
|
|
log_info "SSH key generated at ${key_path}"
|
|
}
|
|
|
|
# Get MD5 fingerprint of SSH public key
|
|
# Usage: get_ssh_fingerprint PUB_KEY_PATH
|
|
get_ssh_fingerprint() {
|
|
local pub_path="${1}"
|
|
if [[ ! -f "${pub_path}" ]]; then
|
|
log_error "SSH public key not found: ${pub_path}"
|
|
log_error "Expected a public key file alongside your private key."
|
|
log_error "Regenerate with: ssh-keygen -t ed25519 -f ${pub_path%.pub}"
|
|
return 1
|
|
fi
|
|
local fingerprint
|
|
fingerprint=$(ssh-keygen -lf "${pub_path}" -E md5 2>/dev/null | awk '{print $2}' | sed 's/MD5://')
|
|
if [[ -z "${fingerprint}" ]]; then
|
|
log_error "Failed to read SSH public key fingerprint from ${pub_path}"
|
|
log_error "The key file may be corrupted or in an unsupported format."
|
|
log_error "Regenerate with: ssh-keygen -t ed25519 -f ${pub_path%.pub}"
|
|
return 1
|
|
fi
|
|
echo "${fingerprint}"
|
|
}
|
|
|
|
# JSON-escape a string (for embedding in JSON bodies)
|
|
# Usage: json_escape STRING
|
|
json_escape() {
|
|
local string="${1}"
|
|
python3 -c "import json, sys; print(json.dumps(sys.stdin.read().rstrip('\n')))" <<< "${string}" 2>/dev/null || {
|
|
# Fallback: manually escape quotes and backslashes
|
|
local escaped="${string//\\/\\\\}"
|
|
escaped="${escaped//\"/\\\"}"
|
|
echo "\"${escaped}\""
|
|
}
|
|
}
|
|
|
|
# Extract SSH key IDs from cloud provider API response
|
|
# Usage: extract_ssh_key_ids API_RESPONSE KEY_FIELD
|
|
# KEY_FIELD: "ssh_keys" (DigitalOcean/Vultr) or "data" (Linode)
|
|
extract_ssh_key_ids() {
|
|
local api_response="${1}"
|
|
local key_field="${2:-ssh_keys}"
|
|
python3 -c "
|
|
import json, sys
|
|
data = json.loads(sys.stdin.read())
|
|
ids = [k['id'] for k in data.get('${key_field}', [])]
|
|
print(json.dumps(ids))
|
|
" <<< "${api_response}" 2>/dev/null || {
|
|
log_error "Failed to parse SSH key IDs from API response"
|
|
log_error "The API response may be malformed or python3 is unavailable"
|
|
return 1
|
|
}
|
|
}
|
|
|
|
# ============================================================
|
|
# Cloud provisioning helpers
|
|
# ============================================================
|
|
|
|
# Generate cloud-init userdata YAML for server provisioning
|
|
# This is the default userdata used by all cloud providers
|
|
# Clouds can override this function if they need provider-specific cloud-init config
|
|
get_cloud_init_userdata() {
|
|
cat << 'CLOUD_INIT_EOF'
|
|
#cloud-config
|
|
package_update: true
|
|
packages:
|
|
- curl
|
|
- unzip
|
|
- git
|
|
- zsh
|
|
|
|
runcmd:
|
|
# Install Bun
|
|
- su - root -c 'curl -fsSL https://bun.sh/install | bash'
|
|
# Install Claude Code
|
|
- su - root -c 'curl -fsSL https://claude.ai/install.sh | bash'
|
|
# Mark as sandbox environment (disposable cloud VM)
|
|
- echo 'export IS_SANDBOX=1' >> /root/.bashrc
|
|
- echo 'export IS_SANDBOX=1' >> /root/.zshrc
|
|
# Configure PATH in .bashrc and .zshrc (include claude installer path)
|
|
- echo 'export PATH="${HOME}/.claude/local/bin:${HOME}/.local/bin:${HOME}/.bun/bin:${PATH}"' >> /root/.bashrc
|
|
- echo 'export PATH="${HOME}/.claude/local/bin:${HOME}/.local/bin:${HOME}/.bun/bin:${PATH}"' >> /root/.zshrc
|
|
# Signal completion
|
|
- touch /root/.cloud-init-complete
|
|
CLOUD_INIT_EOF
|
|
}
|
|
|
|
# ============================================================
|
|
# Cloud API helpers
|
|
# ============================================================
|
|
|
|
# Calculate exponential backoff with jitter for retry logic
|
|
# Usage: calculate_retry_backoff CURRENT_INTERVAL MAX_INTERVAL
|
|
# Returns: backoff interval with ±20% jitter
|
|
calculate_retry_backoff() {
|
|
local interval="${1}"
|
|
local max_interval="${2}"
|
|
|
|
# Validate inputs to prevent empty or invalid intervals
|
|
if [[ -z "${interval}" ]] || [[ "${interval}" -lt 1 ]]; then
|
|
echo "1"
|
|
return 0
|
|
fi
|
|
|
|
# Calculate next interval with exponential backoff
|
|
local next_interval=$((interval * 2))
|
|
if [[ "${next_interval}" -gt "${max_interval}" ]]; then
|
|
next_interval="${max_interval}"
|
|
fi
|
|
|
|
# Add jitter: ±20% randomization to prevent thundering herd
|
|
# Fallback to no-jitter interval if python3 is unavailable
|
|
python3 -c "import random; print(int(${interval} * (0.8 + random.random() * 0.4)))" 2>/dev/null || printf '%s' "${interval}"
|
|
}
|
|
|
|
# Handle API retry decision with backoff - extracted to reduce duplication across API wrappers
|
|
# Usage: _api_should_retry_on_error ATTEMPT MAX_RETRIES INTERVAL MAX_INTERVAL MESSAGE
|
|
# Returns: 0 to continue/retry, 1 to fail
|
|
# Caller updates interval and attempt variables after success
|
|
_api_should_retry_on_error() {
|
|
local attempt="${1}"
|
|
local max_retries="${2}"
|
|
local interval="${3}"
|
|
local max_interval="${4}"
|
|
local message="${5}"
|
|
|
|
if [[ "${attempt}" -ge "${max_retries}" ]]; then
|
|
return 1 # Don't retry - max attempts exhausted
|
|
fi
|
|
|
|
local jitter
|
|
jitter=$(calculate_retry_backoff "${interval}" "${max_interval}")
|
|
log_warn "${message} (attempt ${attempt}/${max_retries}), retrying in ${jitter}s..."
|
|
sleep "${jitter}"
|
|
|
|
return 0 # Do retry
|
|
}
|
|
|
|
# Helper to update retry interval with backoff
|
|
# Usage: _update_retry_interval INTERVAL_VAR MAX_INTERVAL_VAR
|
|
# This eliminates repeated interval update logic across API wrappers
|
|
_update_retry_interval() {
|
|
local interval_var="${1}"
|
|
local max_interval_var="${2}"
|
|
|
|
local current_interval=${!interval_var}
|
|
local max_interval=${!max_interval_var}
|
|
|
|
current_interval=$((current_interval * 2))
|
|
if [[ "${current_interval}" -gt "${max_interval}" ]]; then
|
|
current_interval="${max_interval}"
|
|
fi
|
|
|
|
printf -v "${interval_var}" '%s' "${current_interval}"
|
|
}
|
|
|
|
# Helper to extract HTTP status code and response body from curl output
|
|
# Curl is called with "-w \n%{http_code}" so last line is the code
|
|
# Returns: http_code on stdout, response_body via global variable
|
|
_parse_api_response() {
|
|
local response="${1}"
|
|
local http_code
|
|
http_code=$(echo "${response}" | tail -1)
|
|
local response_body
|
|
response_body=$(echo "${response}" | sed '$d')
|
|
|
|
API_HTTP_CODE="${http_code}"
|
|
API_RESPONSE_BODY="${response_body}"
|
|
}
|
|
|
|
# Core curl wrapper for API requests - builds args, executes, parses response
|
|
# Usage: _curl_api URL METHOD BODY AUTH_ARGS...
|
|
# Returns: 0 on curl success, 1 on curl failure
|
|
# Sets: API_HTTP_CODE and API_RESPONSE_BODY globals
|
|
_curl_api() {
|
|
local url="${1}"
|
|
local method="${2}"
|
|
local body="${3:-}"
|
|
shift 3
|
|
|
|
local args=(
|
|
-s
|
|
-w "\n%{http_code}"
|
|
-X "${method}"
|
|
-H "Content-Type: application/json"
|
|
"$@"
|
|
)
|
|
|
|
if [[ -n "${body}" ]]; then
|
|
args+=(-d "${body}")
|
|
fi
|
|
|
|
local response
|
|
response=$(curl "${args[@]}" "${url}" 2>&1)
|
|
local curl_exit_code=$?
|
|
|
|
_parse_api_response "${response}"
|
|
|
|
return ${curl_exit_code}
|
|
}
|
|
|
|
# Helper to handle a single API request attempt with Bearer auth
|
|
# Returns: 0 on curl success, 1 on curl failure
|
|
# Sets: API_HTTP_CODE and API_RESPONSE_BODY globals
|
|
_make_api_request() {
|
|
local base_url="${1}"
|
|
local auth_token="${2}"
|
|
local method="${3}"
|
|
local endpoint="${4}"
|
|
local body="${5:-}"
|
|
|
|
_curl_api "${base_url}${endpoint}" "${method}" "${body}" -H "Authorization: Bearer ${auth_token}"
|
|
}
|
|
|
|
# Generic cloud API wrapper - centralized curl wrapper for all cloud providers
|
|
# Includes automatic retry logic with exponential backoff for transient failures
|
|
# Usage: generic_cloud_api BASE_URL AUTH_TOKEN METHOD ENDPOINT [BODY] [MAX_RETRIES]
|
|
# Example: generic_cloud_api "$DO_API_BASE" "$DO_API_TOKEN" GET "/account"
|
|
# Example: generic_cloud_api "$DO_API_BASE" "$DO_API_TOKEN" POST "/droplets" "$body"
|
|
# Example: generic_cloud_api "$DO_API_BASE" "$DO_API_TOKEN" GET "/account" "" 5
|
|
# Retries on: 429 (rate limit), 503 (service unavailable), network errors
|
|
# Internal retry loop shared by generic_cloud_api and generic_cloud_api_custom_auth
|
|
# Usage: _cloud_api_retry_loop REQUEST_FUNC MAX_RETRIES API_DESCRIPTION [REQUEST_FUNC_ARGS...]
|
|
# Classify the result of an API request attempt.
|
|
# Returns a retry reason string on stdout if the request failed with a retryable error,
|
|
# or empty string on success. Caller checks the return string.
|
|
_classify_api_result() {
|
|
local curl_ok="${1}"
|
|
if [[ "${curl_ok}" != "0" ]]; then
|
|
echo "Cloud API network error"
|
|
elif [[ "${API_HTTP_CODE}" == "429" ]]; then
|
|
echo "Cloud API returned rate limit (HTTP 429)"
|
|
elif [[ "${API_HTTP_CODE}" == "503" ]]; then
|
|
echo "Cloud API returned service unavailable (HTTP 503)"
|
|
fi
|
|
}
|
|
|
|
# Report a final API failure after retries are exhausted
|
|
_report_api_failure() {
|
|
local retry_reason="${1}"
|
|
local max_retries="${2}"
|
|
log_error "${retry_reason} after ${max_retries} attempts"
|
|
if [[ "${retry_reason}" == "Cloud API network error" ]]; then
|
|
log_warn "Could not reach the cloud provider's API."
|
|
log_warn ""
|
|
log_warn "How to fix:"
|
|
log_warn " 1. Check your internet connection: curl -s https://httpbin.org/ip"
|
|
log_warn " 2. Check DNS resolution: nslookup the provider's API hostname"
|
|
log_warn " 3. If behind a proxy or firewall, ensure HTTPS traffic is allowed"
|
|
log_warn " 4. Try again in a few moments (the API may be temporarily down)"
|
|
else
|
|
log_warn "This is usually caused by rate limiting or temporary provider issues."
|
|
log_warn "Wait a minute and try again, or check the provider's status page."
|
|
echo "${API_RESPONSE_BODY}"
|
|
fi
|
|
}
|
|
|
|
_cloud_api_retry_loop() {
|
|
local request_func="${1}"
|
|
local max_retries="${2}"
|
|
local api_description="${3}"
|
|
shift 3
|
|
|
|
local attempt=1
|
|
local interval=2
|
|
local max_interval=30
|
|
|
|
while [[ "${attempt}" -le "${max_retries}" ]]; do
|
|
local curl_ok=0
|
|
"${request_func}" "$@" || curl_ok=$?
|
|
|
|
local retry_reason
|
|
retry_reason=$(_classify_api_result "${curl_ok}")
|
|
|
|
if [[ -z "${retry_reason}" ]]; then
|
|
echo "${API_RESPONSE_BODY}"
|
|
return 0
|
|
fi
|
|
|
|
if ! _api_should_retry_on_error "${attempt}" "${max_retries}" "${interval}" "${max_interval}" "${retry_reason}"; then
|
|
_report_api_failure "${retry_reason}" "${max_retries}"
|
|
return 1
|
|
fi
|
|
_update_retry_interval interval max_interval
|
|
attempt=$((attempt + 1))
|
|
done
|
|
|
|
log_error "Cloud API request failed after ${max_retries} attempts (${api_description})"
|
|
log_warn "This is usually caused by rate limiting or temporary provider issues."
|
|
log_warn "Wait a minute and try again, or check the provider's status page."
|
|
return 1
|
|
}
|
|
|
|
generic_cloud_api() {
|
|
local base_url="${1}"
|
|
local auth_token="${2}"
|
|
local method="${3}"
|
|
local endpoint="${4}"
|
|
local body="${5:-}"
|
|
local max_retries="${6:-3}"
|
|
|
|
_cloud_api_retry_loop _make_api_request "${max_retries}" "${method} ${endpoint}" "${base_url}" "${auth_token}" "${method}" "${endpoint}" "${body}"
|
|
}
|
|
|
|
# Helper to make API request with custom curl auth args (e.g., Basic Auth, custom headers)
|
|
# Returns: 0 on curl success, 1 on curl failure
|
|
# Sets: API_HTTP_CODE and API_RESPONSE_BODY globals
|
|
_make_api_request_custom_auth() {
|
|
local url="${1}"
|
|
local method="${2}"
|
|
local body="${3:-}"
|
|
shift 3
|
|
|
|
_curl_api "${url}" "${method}" "${body}" "$@"
|
|
}
|
|
|
|
# Generic cloud API wrapper with custom curl auth args
|
|
# Like generic_cloud_api but accepts arbitrary curl flags for authentication
|
|
# Usage: generic_cloud_api_custom_auth BASE_URL METHOD ENDPOINT BODY MAX_RETRIES AUTH_ARGS...
|
|
# Example: generic_cloud_api_custom_auth "$API_BASE" GET "/account" "" 3 -H "X-Auth-Token: $TOKEN"
|
|
# Example: generic_cloud_api_custom_auth "$API_BASE" POST "/servers" "$body" 3 -u "$USER:$PASS"
|
|
generic_cloud_api_custom_auth() {
|
|
local base_url="${1}"
|
|
local method="${2}"
|
|
local endpoint="${3}"
|
|
local body="${4:-}"
|
|
local max_retries="${5:-3}"
|
|
shift 5
|
|
# Remaining args are custom curl auth flags
|
|
|
|
_cloud_api_retry_loop _make_api_request_custom_auth "${max_retries}" "${method} ${endpoint}" "${base_url}${endpoint}" "${method}" "${body}" "$@"
|
|
}
|
|
|
|
# ============================================================
|
|
# Agent verification helpers
|
|
# ============================================================
|
|
|
|
# Check if agent command exists in PATH
|
|
_check_agent_in_path() {
|
|
local agent_cmd="$1"
|
|
local agent_name="$2"
|
|
if ! command -v "${agent_cmd}" &> /dev/null; then
|
|
_log_diagnostic \
|
|
"${agent_name} installation failed: command '${agent_cmd}' not found in PATH" \
|
|
"The installation script encountered an error (check logs above)" \
|
|
"The binary was installed to a directory not in PATH" \
|
|
"Network issues prevented the download from completing" \
|
|
--- \
|
|
"Re-run the script to retry the installation" \
|
|
"Install ${agent_name} manually and ensure it is in PATH"
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Check if agent command executes without error
|
|
_check_agent_runs() {
|
|
local agent_cmd="$1"
|
|
local verify_arg="$2"
|
|
local agent_name="$3"
|
|
if ! "${agent_cmd}" "${verify_arg}" &> /dev/null; then
|
|
_log_diagnostic \
|
|
"${agent_name} verification failed: '${agent_cmd} ${verify_arg}' returned an error" \
|
|
"Missing runtime dependencies (Python, Node.js, etc.)" \
|
|
"Incompatible system architecture or OS version" \
|
|
--- \
|
|
"Check ${agent_name}'s installation docs for prerequisites" \
|
|
"Run '${agent_cmd} ${verify_arg}' manually to see the error"
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Verify that an agent is properly installed by checking if its command exists
|
|
# Usage: verify_agent_installed AGENT_COMMAND [VERIFICATION_ARG] [ERROR_MESSAGE]
|
|
# Examples:
|
|
# verify_agent_installed "claude" "--version" "Claude Code"
|
|
# verify_agent_installed "aider" "--help" "Aider"
|
|
# verify_agent_installed "goose" "--version" "Goose"
|
|
# Returns 0 if agent is installed and working, 1 otherwise
|
|
verify_agent_installed() {
|
|
local agent_cmd="${1}"
|
|
local verify_arg="${2:---version}"
|
|
local agent_name="${3:-${agent_cmd}}"
|
|
|
|
log_step "Verifying ${agent_name} installation..."
|
|
|
|
_check_agent_in_path "${agent_cmd}" "${agent_name}" || return 1
|
|
_check_agent_runs "${agent_cmd}" "${verify_arg}" "${agent_name}" || return 1
|
|
|
|
log_info "${agent_name} installation verified successfully"
|
|
return 0
|
|
}
|
|
|
|
# ============================================================
|
|
# Non-interactive agent execution
|
|
# ============================================================
|
|
|
|
# Execute an agent in non-interactive mode with a prompt
|
|
# Usage: execute_agent_non_interactive SPRITE_NAME AGENT_NAME AGENT_FLAGS PROMPT
|
|
# Arguments:
|
|
# SPRITE_NAME - Name of the sprite/server to execute on
|
|
# AGENT_NAME - Name of the agent command (e.g., "claude", "aider")
|
|
# AGENT_FLAGS - Agent-specific flags for non-interactive execution (e.g., "-p" for claude, "-m" for aider)
|
|
# PROMPT - User prompt to execute
|
|
# EXEC_CALLBACK - Function to execute commands: func(sprite_name, command)
|
|
#
|
|
# Example (Sprite):
|
|
# execute_agent_non_interactive "$SPRITE_NAME" "claude" "-p" "$PROMPT" "sprite_exec"
|
|
#
|
|
# Example (SSH):
|
|
# execute_agent_non_interactive "$SERVER_IP" "aider" "-m" "$PROMPT" "ssh_exec"
|
|
execute_agent_non_interactive() {
|
|
local sprite_name="${1}"
|
|
local agent_name="${2}"
|
|
local agent_flags="${3}"
|
|
local prompt="${4}"
|
|
local exec_callback="${5}"
|
|
|
|
log_step "Executing ${agent_name} with prompt in non-interactive mode..."
|
|
|
|
# Escape the prompt for safe shell execution
|
|
# We use printf %q which properly escapes special characters for bash
|
|
local escaped_prompt
|
|
escaped_prompt=$(printf '%q' "${prompt}")
|
|
|
|
# Build the command based on exec callback type
|
|
if [[ "${exec_callback}" == *"sprite"* ]]; then
|
|
# Sprite execution (no -tty flag for non-interactive)
|
|
sprite exec -s "${sprite_name}" -- zsh -c "source ~/.zshrc && ${agent_name} ${agent_flags} ${escaped_prompt}"
|
|
else
|
|
# Generic SSH execution
|
|
${exec_callback} "${sprite_name}" "source ~/.zshrc && ${agent_name} ${agent_flags} ${escaped_prompt}"
|
|
fi
|
|
}
|
|
|
|
# ============================================================
|
|
# SSH connectivity helpers
|
|
# ============================================================
|
|
|
|
# Generic SSH wait function - polls until a remote command succeeds with exponential backoff
|
|
# Usage: generic_ssh_wait USERNAME IP SSH_OPTS TEST_CMD DESCRIPTION MAX_ATTEMPTS [INITIAL_INTERVAL]
|
|
# Implements exponential backoff: starts at INITIAL_INTERVAL (default 5s), doubles up to max 30s
|
|
# Adds jitter (±20%) to prevent thundering herd when multiple instances retry simultaneously
|
|
# Log progress message based on elapsed time
|
|
_log_ssh_wait_progress() {
|
|
local description="${1}"
|
|
local elapsed_time="${2}"
|
|
|
|
if [[ ${elapsed_time} -lt 60 ]]; then
|
|
log_step "Waiting for ${description}... (${elapsed_time}s elapsed, still within normal range)"
|
|
elif [[ ${elapsed_time} -lt 120 ]]; then
|
|
log_step "Waiting for ${description}... (${elapsed_time}s elapsed, taking longer than usual)"
|
|
else
|
|
log_warn "Still waiting for ${description}... (${elapsed_time}s elapsed, this is unusually slow)"
|
|
fi
|
|
}
|
|
|
|
# Log timeout error message with troubleshooting steps
|
|
_log_ssh_wait_timeout_error() {
|
|
local description="${1}"
|
|
local elapsed_time="${2}"
|
|
local username="${3}"
|
|
local ip="${4}"
|
|
|
|
log_error "${description} timed out after ${elapsed_time}s (server: ${ip})"
|
|
log_error ""
|
|
log_error "The server failed to become ready within the expected timeframe."
|
|
log_error ""
|
|
log_error "Common causes:"
|
|
log_error " - Server is still booting (some cloud providers take 2-3 minutes)"
|
|
log_error " - Cloud provider API delays or maintenance"
|
|
log_error " - Firewall blocking SSH on port 22"
|
|
log_error " - Network connectivity issues"
|
|
log_error ""
|
|
log_error "Troubleshooting steps:"
|
|
log_error " 1. Test SSH manually: ssh ${username}@${ip}"
|
|
log_error " 2. Check firewall rules in your cloud provider dashboard"
|
|
if [[ -n "${SPAWN_DASHBOARD_URL:-}" ]]; then
|
|
log_error " Dashboard: ${SPAWN_DASHBOARD_URL}"
|
|
fi
|
|
log_error " 3. Re-run this command to retry (the server may need more time)"
|
|
if [[ -n "${SPAWN_RETRY_CMD:-}" ]]; then
|
|
log_error " ${SPAWN_RETRY_CMD}"
|
|
fi
|
|
}
|
|
|
|
generic_ssh_wait() {
|
|
local username="${1}"
|
|
local ip="${2}"
|
|
local ssh_opts="${3}"
|
|
local test_cmd="${4}"
|
|
local description="${5}"
|
|
local max_attempts="${6:-30}"
|
|
local initial_interval="${7:-5}"
|
|
|
|
local attempt=1
|
|
local interval="${initial_interval}"
|
|
local max_interval=30
|
|
local elapsed_time=0
|
|
|
|
log_step "Waiting for ${description} to ${ip} (this usually takes 30-90 seconds)..."
|
|
while [[ "${attempt}" -le "${max_attempts}" ]]; do
|
|
# shellcheck disable=SC2086
|
|
if ssh ${ssh_opts} "${username}@${ip}" "${test_cmd}" >/dev/null 2>&1; then
|
|
log_info "${description} ready (took ${elapsed_time}s)"
|
|
return 0
|
|
fi
|
|
|
|
local jitter
|
|
jitter=$(calculate_retry_backoff "${interval}" "${max_interval}")
|
|
|
|
_log_ssh_wait_progress "${description}" "${elapsed_time}"
|
|
sleep "${jitter}"
|
|
|
|
elapsed_time=$((elapsed_time + jitter))
|
|
_update_retry_interval interval max_interval
|
|
attempt=$((attempt + 1))
|
|
done
|
|
|
|
_log_ssh_wait_timeout_error "${description}" "${elapsed_time}" "${username}" "${ip}"
|
|
return 1
|
|
}
|
|
|
|
# Wait for cloud-init to complete on a server
|
|
# Usage: wait_for_cloud_init <ip> [max_attempts]
|
|
# Default max_attempts is 60 (~5 minutes with exponential backoff)
|
|
wait_for_cloud_init() {
|
|
local ip="${1}"
|
|
local max_attempts=${2:-60}
|
|
generic_ssh_wait "root" "${ip}" "${SSH_OPTS}" "test -f /root/.cloud-init-complete" "cloud-init" "${max_attempts}" 5
|
|
}
|
|
|
|
# ============================================================
|
|
# Standard SSH server operations
|
|
# ============================================================
|
|
|
|
# Most SSH-based cloud providers share identical implementations for
|
|
# run_server, upload_file, interactive_session, and verify_server_connectivity.
|
|
# These helpers let providers set SSH_USER (default: root) and get all four
|
|
# functions automatically, eliminating ~20 lines of copy-paste per provider.
|
|
|
|
# Run a command on a remote server via SSH
|
|
# Usage: ssh_run_server IP COMMAND
|
|
# Requires: SSH_USER (default: root), SSH_OPTS
|
|
# SECURITY: Command is properly quoted to prevent shell injection
|
|
ssh_run_server() {
|
|
local ip="${1}"
|
|
local cmd="${2}"
|
|
if [[ -n "${SPAWN_DEBUG:-}" ]]; then
|
|
cmd="set -x; ${cmd}"
|
|
fi
|
|
# shellcheck disable=SC2086
|
|
ssh $SSH_OPTS "${SSH_USER:-root}@${ip}" -- "${cmd}"
|
|
}
|
|
|
|
# Upload a file to a remote server via SCP
|
|
# Usage: ssh_upload_file IP LOCAL_PATH REMOTE_PATH
|
|
# Requires: SSH_USER (default: root), SSH_OPTS
|
|
ssh_upload_file() {
|
|
local ip="${1}"
|
|
local local_path="${2}"
|
|
local remote_path="${3}"
|
|
# shellcheck disable=SC2086
|
|
scp $SSH_OPTS "${local_path}" "${SSH_USER:-root}@${ip}:${remote_path}"
|
|
}
|
|
|
|
# Show a post-session summary reminding the user their server is still running.
|
|
# Called automatically by ssh_interactive_session after the SSH session ends.
|
|
# Uses optional env vars for richer output:
|
|
# SPAWN_DASHBOARD_URL - Cloud provider dashboard URL for managing servers
|
|
# SERVER_NAME - Server name (set by individual cloud scripts)
|
|
# Arguments: IP
|
|
_show_post_session_summary() {
|
|
local ip="${1}"
|
|
local dashboard_url="${SPAWN_DASHBOARD_URL:-}"
|
|
local server_name="${SERVER_NAME:-}"
|
|
|
|
printf '\n'
|
|
if [[ -n "${server_name}" ]]; then
|
|
log_warn "Session ended. Your server '${server_name}' is still running at ${ip}."
|
|
else
|
|
log_warn "Session ended. Your server is still running at ${ip}."
|
|
fi
|
|
log_warn "Remember to delete it when you're done to avoid ongoing charges."
|
|
log_warn ""
|
|
if [[ -n "${dashboard_url}" ]]; then
|
|
log_warn "Manage or delete it in your dashboard:"
|
|
log_warn " ${dashboard_url}"
|
|
else
|
|
log_warn "Check your cloud provider dashboard to stop or delete the server."
|
|
fi
|
|
log_warn ""
|
|
log_info "To reconnect:"
|
|
log_info " ssh ${SSH_USER:-root}@${ip}"
|
|
}
|
|
|
|
# Show a post-session summary for exec-based (non-SSH) cloud providers.
|
|
# These use CLI exec commands instead of direct SSH, so the reconnect
|
|
# hint differs from the SSH variant.
|
|
# Uses optional env vars for richer output:
|
|
# SPAWN_DASHBOARD_URL - Cloud provider dashboard URL for managing services
|
|
# SERVER_NAME - Service/sandbox name
|
|
# SPAWN_RECONNECT_CMD - CLI command to reconnect (shown as reconnect hint)
|
|
_show_exec_post_session_summary() {
|
|
local dashboard_url="${SPAWN_DASHBOARD_URL:-}"
|
|
local server_name="${SERVER_NAME:-}"
|
|
local reconnect_cmd="${SPAWN_RECONNECT_CMD:-}"
|
|
|
|
printf '\n'
|
|
if [[ -n "${server_name}" ]]; then
|
|
log_warn "Session ended. Your service '${server_name}' is still running."
|
|
else
|
|
log_warn "Session ended. Your service is still running."
|
|
fi
|
|
log_warn "Remember to delete it when you're done to avoid ongoing charges."
|
|
log_warn ""
|
|
if [[ -n "${dashboard_url}" ]]; then
|
|
log_warn "Manage or delete it in your dashboard:"
|
|
log_warn " ${dashboard_url}"
|
|
else
|
|
log_warn "Check your cloud provider dashboard to stop or delete the service."
|
|
fi
|
|
if [[ -n "${reconnect_cmd}" ]]; then
|
|
log_warn ""
|
|
log_info "To reconnect:"
|
|
log_info " ${reconnect_cmd}"
|
|
fi
|
|
}
|
|
|
|
# Start an interactive SSH session
|
|
# Usage: ssh_interactive_session IP COMMAND
|
|
# Requires: SSH_USER (default: root), SSH_OPTS
|
|
# SECURITY: Command is properly quoted to prevent shell injection
|
|
ssh_interactive_session() {
|
|
local ip="${1}"
|
|
local cmd="${2}"
|
|
local ssh_exit=0
|
|
# shellcheck disable=SC2086
|
|
ssh -t $SSH_OPTS "${SSH_USER:-root}@${ip}" -- "${cmd}" || ssh_exit=$?
|
|
_show_post_session_summary "${ip}"
|
|
return "${ssh_exit}"
|
|
}
|
|
|
|
# Wait for SSH connectivity to a server
|
|
# Usage: ssh_verify_connectivity IP [MAX_ATTEMPTS] [INITIAL_INTERVAL]
|
|
# Requires: SSH_USER (default: root), SSH_OPTS
|
|
ssh_verify_connectivity() {
|
|
local ip="${1}"
|
|
local max_attempts=${2:-30}
|
|
local initial_interval=${3:-5}
|
|
# shellcheck disable=SC2154
|
|
generic_ssh_wait "${SSH_USER:-root}" "${ip}" "$SSH_OPTS -o ConnectTimeout=5" "echo ok" "SSH connectivity" "${max_attempts}" "${initial_interval}"
|
|
}
|
|
|
|
# Extract a value from a JSON response using a Python expression
|
|
# Usage: _extract_json_field JSON_STRING PYTHON_EXPR [DEFAULT]
|
|
# The Python expression receives 'd' as the parsed JSON dict.
|
|
# Returns DEFAULT (or empty string) on parse failure.
|
|
_extract_json_field() {
|
|
local json="${1}"
|
|
local py_expr="${2}"
|
|
local default="${3:-}"
|
|
|
|
printf '%s' "${json}" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(${py_expr})" 2>/dev/null || echo "${default}"
|
|
}
|
|
|
|
# Extract an error message from a JSON API response.
|
|
# Tries common error field patterns used by cloud provider APIs:
|
|
# message, error, error.message, error.error_message, reason
|
|
# Falls back to the raw response if no known field matches.
|
|
# Usage: extract_api_error_message JSON_STRING [FALLBACK]
|
|
extract_api_error_message() {
|
|
local json="${1}"
|
|
local fallback="${2:-Unknown error}"
|
|
|
|
printf '%s' "${json}" | python3 -c "
|
|
import json, sys
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
e = d.get('error', '')
|
|
msg = (
|
|
(isinstance(e, dict) and (e.get('message') or e.get('error_message')))
|
|
or d.get('message')
|
|
or d.get('reason')
|
|
or (isinstance(e, str) and e)
|
|
or ''
|
|
)
|
|
if msg:
|
|
print(msg)
|
|
else:
|
|
sys.exit(1)
|
|
except:
|
|
sys.exit(1)
|
|
" 2>/dev/null || echo "${fallback}"
|
|
}
|
|
|
|
# Generic instance status polling loop
|
|
# Polls an API endpoint until the instance reaches the target status, then extracts the IP.
|
|
# Usage: generic_wait_for_instance API_FUNC ENDPOINT TARGET_STATUS STATUS_PY IP_PY IP_VAR DESCRIPTION [MAX_ATTEMPTS]
|
|
#
|
|
# Arguments:
|
|
# API_FUNC - Cloud API function name (e.g., "vultr_api", "do_api")
|
|
# ENDPOINT - API endpoint path (e.g., "/instances/$id")
|
|
# TARGET_STATUS - Status value that means "ready" (e.g., "active", "running")
|
|
# STATUS_PY - Python expression to extract status from JSON (receives 'd' as parsed dict)
|
|
# IP_PY - Python expression to extract IP from JSON (receives 'd' as parsed dict)
|
|
# IP_VAR - Environment variable name to export with the IP (e.g., "VULTR_SERVER_IP")
|
|
# DESCRIPTION - Human-readable label for logging (e.g., "Vultr instance")
|
|
# MAX_ATTEMPTS - Optional, defaults to 60
|
|
#
|
|
# Example:
|
|
# generic_wait_for_instance vultr_api "/instances/$id" "active" \
|
|
# "d['instance']['status']" "d['instance']['main_ip']" \
|
|
# VULTR_SERVER_IP "Instance" 60
|
|
# Single polling attempt: fetch status, check readiness, log progress.
|
|
# Returns 0 if instance is ready (IP exported), 1 to keep polling, 2 on status mismatch.
|
|
# Arguments: API_FUNC ENDPOINT TARGET_STATUS STATUS_PY IP_PY IP_VAR DESCRIPTION ATTEMPT POLL_DELAY
|
|
_poll_instance_once() {
|
|
local api_func="${1}" endpoint="${2}" target_status="${3}"
|
|
local status_py="${4}" ip_py="${5}" ip_var="${6}"
|
|
local description="${7}" attempt="${8}" poll_delay="${9}"
|
|
|
|
local response
|
|
response=$("${api_func}" GET "${endpoint}" 2>/dev/null) || true
|
|
|
|
local status
|
|
status=$(_extract_json_field "${response}" "${status_py}" "unknown")
|
|
|
|
if [[ "${status}" != "${target_status}" ]]; then
|
|
log_step "${description} status: ${status} ($((attempt * poll_delay))s elapsed)"
|
|
return 2
|
|
fi
|
|
|
|
local ip
|
|
ip=$(_extract_json_field "${response}" "${ip_py}")
|
|
if [[ -n "${ip}" ]]; then
|
|
# SECURITY: Validate ip_var to prevent command injection
|
|
if [[ ! "${ip_var}" =~ ^[A-Z_][A-Z0-9_]*$ ]]; then
|
|
log_error "SECURITY: Invalid env var name rejected: ${ip_var}"
|
|
return 1
|
|
fi
|
|
export "${ip_var}=${ip}"
|
|
log_info "${description} ready (IP: ${ip})"
|
|
return 0
|
|
fi
|
|
|
|
log_step "${description} status: ${status} ($((attempt * poll_delay))s elapsed)"
|
|
return 1
|
|
}
|
|
|
|
# Report timeout when instance polling exhausts all attempts.
|
|
_report_instance_timeout() {
|
|
local description="${1}" target_status="${2}" total_time="${3}"
|
|
log_error "${description} did not become ${target_status} within ${total_time}s"
|
|
log_error ""
|
|
log_error "The cloud provider API reported the instance is not yet ready."
|
|
log_error ""
|
|
log_error "This usually means:"
|
|
log_error " - Cloud provider is experiencing delays (high load, maintenance)"
|
|
log_error " - The region or instance type has limited capacity"
|
|
log_error " - The instance failed to provision but the API hasn't reported it yet"
|
|
log_error ""
|
|
log_error "Next steps:"
|
|
log_error " 1. Check your cloud dashboard for instance status and error messages"
|
|
if [[ -n "${SPAWN_DASHBOARD_URL:-}" ]]; then
|
|
log_error " ${SPAWN_DASHBOARD_URL}"
|
|
fi
|
|
log_error " 2. Wait 2-3 minutes and retry the spawn command"
|
|
log_error " 3. Try a different region or instance size if this persists"
|
|
}
|
|
|
|
generic_wait_for_instance() {
|
|
local api_func="${1}" endpoint="${2}" target_status="${3}"
|
|
local status_py="${4}" ip_py="${5}" ip_var="${6}"
|
|
local description="${7}" max_attempts="${8:-60}"
|
|
local poll_delay="${INSTANCE_STATUS_POLL_DELAY:-5}"
|
|
|
|
local attempt=1
|
|
log_step "Waiting for ${description} to become ${target_status}..."
|
|
|
|
while [[ "${attempt}" -le "${max_attempts}" ]]; do
|
|
_poll_instance_once "${api_func}" "${endpoint}" "${target_status}" \
|
|
"${status_py}" "${ip_py}" "${ip_var}" \
|
|
"${description}" "${attempt}" "${poll_delay}" && return 0
|
|
sleep "${poll_delay}"
|
|
attempt=$((attempt + 1))
|
|
done
|
|
|
|
_report_instance_timeout "${description}" "${target_status}" "$((max_attempts * poll_delay))"
|
|
return 1
|
|
}
|
|
|
|
# ============================================================
|
|
# API token management helpers
|
|
# ============================================================
|
|
|
|
# Try to load API token from environment variable
|
|
# Returns 0 if found and sets env var, 1 otherwise
|
|
_load_token_from_env() {
|
|
local env_var_name="${1}"
|
|
local provider_name="${2}"
|
|
|
|
local env_value="${!env_var_name}"
|
|
if [[ -n "${env_value}" ]]; then
|
|
log_info "Using ${provider_name} API token from environment"
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# Try to load API token from config file
|
|
# Returns 0 if found and exports env var, 1 otherwise
|
|
_load_token_from_config() {
|
|
local config_file="${1}"
|
|
local env_var_name="${2}"
|
|
local provider_name="${3}"
|
|
|
|
# SECURITY: Validate env_var_name to prevent command injection
|
|
if [[ ! "${env_var_name}" =~ ^[A-Z_][A-Z0-9_]*$ ]]; then
|
|
log_error "SECURITY: Invalid env var name rejected: ${env_var_name}"
|
|
return 1
|
|
fi
|
|
|
|
if [[ ! -f "${config_file}" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
local saved_token
|
|
saved_token=$(python3 -c "import json, sys; data=json.load(open(sys.argv[1])); print(data.get('api_key','') or data.get('token',''))" "${config_file}" 2>/dev/null)
|
|
if [[ -z "${saved_token}" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
export "${env_var_name}=${saved_token}"
|
|
log_info "Using ${provider_name} API token from ${config_file}"
|
|
return 0
|
|
}
|
|
|
|
# Validate token with provider API if test function provided
|
|
# Returns 0 on success, 1 on validation failure
|
|
_validate_token_with_provider() {
|
|
local test_func="${1}"
|
|
local env_var_name="${2}"
|
|
local provider_name="${3}"
|
|
local help_url="${4:-}"
|
|
|
|
if [[ -z "${test_func}" ]]; then
|
|
return 0 # No validation needed
|
|
fi
|
|
|
|
if ! "${test_func}"; then
|
|
log_error "Authentication failed: Invalid ${provider_name} API token"
|
|
log_error "The token may be expired, revoked, or incorrectly copied."
|
|
log_error ""
|
|
log_error "How to fix:"
|
|
if [[ -n "${help_url}" ]]; then
|
|
log_error " 1. Get a new token from: ${help_url}"
|
|
log_error " 2. Re-run the command and paste the new token"
|
|
log_error " 3. Or set it directly: ${env_var_name}=your-token spawn ..."
|
|
else
|
|
log_error " 1. Re-run the command to enter a new token"
|
|
log_error " 2. Or set it directly: ${env_var_name}=your-token spawn ..."
|
|
fi
|
|
unset "${env_var_name}"
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Save API token to config file
|
|
_save_token_to_config() {
|
|
local config_file="${1}"
|
|
local token="${2}"
|
|
|
|
local config_dir
|
|
config_dir=$(dirname "${config_file}")
|
|
mkdir -p "${config_dir}"
|
|
|
|
local escaped_token
|
|
escaped_token=$(json_escape "${token}")
|
|
printf '{\n "api_key": %s,\n "token": %s\n}\n' "${escaped_token}" "${escaped_token}" > "${config_file}"
|
|
chmod 600 "${config_file}"
|
|
log_info "API token saved to ${config_file}"
|
|
}
|
|
|
|
# Generic ensure API token function - eliminates duplication across providers
|
|
# Usage: ensure_api_token_with_provider PROVIDER_NAME ENV_VAR_NAME CONFIG_FILE HELP_URL TEST_FUNC
|
|
# Example: ensure_api_token_with_provider "Lambda" "LAMBDA_API_KEY" "$HOME/.config/spawn/lambda.json" \
|
|
# "https://cloud.lambdalabs.com/api-keys" test_lambda_token
|
|
# TEST_FUNC should be a function that validates the token and returns 0 on success, 1 on failure
|
|
# TEST_FUNC is optional - if empty, no validation is performed
|
|
_prompt_for_api_token() {
|
|
local provider_name="${1}"
|
|
local help_url="${2}"
|
|
|
|
echo ""
|
|
log_step "${provider_name} API Token Required"
|
|
log_step "Get your token from: ${help_url}"
|
|
echo ""
|
|
|
|
validated_read "Enter your ${provider_name} API token: " validate_api_token
|
|
}
|
|
|
|
_validate_env_var_name() {
|
|
local env_var_name="${1}"
|
|
if [[ ! "${env_var_name}" =~ ^[A-Z_][A-Z0-9_]*$ ]]; then
|
|
log_error "SECURITY: Invalid env var name rejected: ${env_var_name}"
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
ensure_api_token_with_provider() {
|
|
local provider_name="${1}"
|
|
local env_var_name="${2}"
|
|
local config_file="${3}"
|
|
local help_url="${4}"
|
|
local test_func="${5:-}"
|
|
|
|
check_python_available || return 1
|
|
|
|
# Try environment variable
|
|
if _load_token_from_env "${env_var_name}" "${provider_name}"; then
|
|
return 0
|
|
fi
|
|
|
|
# Try config file
|
|
if _load_token_from_config "${config_file}" "${env_var_name}" "${provider_name}"; then
|
|
return 0
|
|
fi
|
|
|
|
# Prompt for new token
|
|
local token
|
|
token=$(_prompt_for_api_token "${provider_name}" "${help_url}") || return 1
|
|
|
|
# SECURITY: Validate env_var_name to prevent command injection
|
|
_validate_env_var_name "${env_var_name}" || return 1
|
|
|
|
export "${env_var_name}=${token}"
|
|
|
|
# Validate with provider API
|
|
if ! _validate_token_with_provider "${test_func}" "${env_var_name}" "${provider_name}" "${help_url}"; then
|
|
return 1
|
|
fi
|
|
|
|
# Save to config file
|
|
_save_token_to_config "${config_file}" "${token}"
|
|
return 0
|
|
}
|
|
|
|
# ============================================================
|
|
# Multi-credential configuration helpers
|
|
# ============================================================
|
|
|
|
# Load multiple fields from a JSON config file in a single python3 call.
|
|
# Outputs each field value on a separate line. Returns 1 if file missing or parse fails.
|
|
# Usage: local creds; creds=$(_load_json_config_fields CONFIG_FILE field1 field2 ...)
|
|
# Then: { read -r var1; read -r var2; ... } <<< "${creds}"
|
|
_load_json_config_fields() {
|
|
local config_file="${1}"; shift
|
|
[[ -f "${config_file}" ]] || return 1
|
|
|
|
local py_fields=""
|
|
for field in "$@"; do
|
|
py_fields="${py_fields}print(d.get('${field}', ''));"
|
|
done
|
|
|
|
python3 -c "
|
|
import json, sys
|
|
d = json.load(open(sys.argv[1]))
|
|
${py_fields}
|
|
" "${config_file}" 2>/dev/null || return 1
|
|
}
|
|
|
|
# Save key-value pairs to a JSON config file using json_escape for safe encoding.
|
|
# Usage: _save_json_config CONFIG_FILE key1 val1 key2 val2 ...
|
|
_save_json_config() {
|
|
local config_file="${1}"; shift
|
|
|
|
mkdir -p "$(dirname "${config_file}")"
|
|
|
|
# Build JSON object from key=value pairs
|
|
local json="{"
|
|
local first=true
|
|
while [[ $# -ge 2 ]]; do
|
|
local key="${1}"; shift
|
|
local val="${1}"; shift
|
|
if [[ "${first}" == "true" ]]; then
|
|
first=false
|
|
else
|
|
json="${json},"
|
|
fi
|
|
json="${json}
|
|
\"${key}\": $(json_escape "${val}")"
|
|
done
|
|
json="${json}
|
|
}
|
|
"
|
|
|
|
printf '%s\n' "${json}" > "${config_file}"
|
|
chmod 600 "${config_file}"
|
|
log_info "Credentials saved to ${config_file}"
|
|
}
|
|
|
|
# Check if all env vars in a list are set (non-empty)
|
|
# Returns 0 if all set, 1 if any missing
|
|
_multi_creds_all_env_set() {
|
|
local var
|
|
for var in "$@"; do
|
|
if [[ -z "${!var:-}" ]]; then
|
|
return 1
|
|
fi
|
|
done
|
|
return 0
|
|
}
|
|
|
|
# Load multi-credentials from a JSON config file into env vars.
|
|
# Returns 0 if all fields loaded, 1 if any missing.
|
|
# Usage: _multi_creds_load_config CONFIG_FILE env_vars[@] config_keys[@]
|
|
_multi_creds_load_config() {
|
|
local config_file="${1}"
|
|
shift
|
|
local env_count="${1}"
|
|
shift
|
|
local env_vars=("${@:1:$env_count}")
|
|
shift "${env_count}"
|
|
local config_keys=("$@")
|
|
|
|
local creds
|
|
creds=$(_load_json_config_fields "${config_file}" "${config_keys[@]}") || return 1
|
|
|
|
local i=0
|
|
while IFS= read -r value; do
|
|
if [[ -z "${value}" ]]; then
|
|
return 1
|
|
fi
|
|
# SECURITY: Validate env var name before export
|
|
if [[ ! "${env_vars[$i]}" =~ ^[A-Z_][A-Z0-9_]*$ ]]; then
|
|
log_error "SECURITY: Invalid env var name rejected: ${env_vars[$i]}"
|
|
return 1
|
|
fi
|
|
export "${env_vars[$i]}=${value}"
|
|
i=$((i + 1))
|
|
done <<< "${creds}"
|
|
|
|
[[ "${i}" -eq "${#env_vars[@]}" ]] || return 1
|
|
return 0
|
|
}
|
|
|
|
# Prompt user for each credential interactively.
|
|
# Returns 1 if any input is empty or read fails.
|
|
_multi_creds_prompt() {
|
|
local provider_name="${1}"
|
|
local help_url="${2}"
|
|
shift 2
|
|
local env_count="${1}"
|
|
shift
|
|
local env_vars=("${@:1:$env_count}")
|
|
shift "${env_count}"
|
|
local labels=("$@")
|
|
|
|
echo ""
|
|
log_step "${provider_name} API Credentials Required"
|
|
log_step "Get your credentials from: ${help_url}"
|
|
echo ""
|
|
|
|
local idx
|
|
for idx in $(seq 0 $((${#env_vars[@]} - 1))); do
|
|
# SECURITY: Validate env var name before export
|
|
if [[ ! "${env_vars[$idx]}" =~ ^[A-Z_][A-Z0-9_]*$ ]]; then
|
|
log_error "SECURITY: Invalid env var name rejected: ${env_vars[$idx]}"
|
|
return 1
|
|
fi
|
|
local val
|
|
val=$(safe_read "Enter ${provider_name} ${labels[$idx]}: ") || return 1
|
|
if [[ -z "${val}" ]]; then
|
|
log_error "${labels[$idx]} is required"
|
|
return 1
|
|
fi
|
|
export "${env_vars[$idx]}=${val}"
|
|
done
|
|
return 0
|
|
}
|
|
|
|
# Validate multi-credentials using a test function.
|
|
# Unsets all env vars on failure.
|
|
_multi_creds_validate() {
|
|
local test_func="${1}"
|
|
local provider_name="${2}"
|
|
shift 2
|
|
|
|
if [[ -z "${test_func}" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
log_step "Testing ${provider_name} credentials..."
|
|
if ! "${test_func}"; then
|
|
log_error "Invalid ${provider_name} credentials"
|
|
log_error "The credentials may be expired, revoked, or incorrectly copied."
|
|
log_error ""
|
|
log_error "How to fix:"
|
|
log_error " 1. Get new credentials from: ${help_url}"
|
|
log_error " 2. Re-run the command and enter the new credentials"
|
|
local v
|
|
for v in "$@"; do
|
|
unset "${v}"
|
|
done
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Generic multi-credential ensure function
|
|
# Eliminates duplicated env-var/config/prompt/test/save logic across providers
|
|
# that need more than one credential (username+password, client_id+secret, etc.)
|
|
#
|
|
# Usage: ensure_multi_credentials PROVIDER_NAME CONFIG_FILE HELP_URL TEST_FUNC \
|
|
# "ENV_VAR:config_key:Prompt Label" ...
|
|
#
|
|
# Each credential spec is a colon-delimited triple:
|
|
# ENV_VAR - Environment variable name (e.g., CONTABO_CLIENT_ID)
|
|
# config_key - JSON key in the config file (e.g., client_id)
|
|
# Prompt Label - Human-readable label for prompting (e.g., "Client ID")
|
|
ensure_multi_credentials() {
|
|
local provider_name="${1}"
|
|
local config_file="${2}"
|
|
local help_url="${3}"
|
|
local test_func="${4:-}"
|
|
shift 4
|
|
|
|
check_python_available || return 1
|
|
|
|
# Parse credential specs into parallel arrays
|
|
local env_vars=() config_keys=() labels=()
|
|
local spec
|
|
for spec in "$@"; do
|
|
env_vars+=("${spec%%:*}")
|
|
local rest="${spec#*:}"
|
|
config_keys+=("${rest%%:*}")
|
|
labels+=("${rest#*:}")
|
|
done
|
|
|
|
local n="${#env_vars[@]}"
|
|
|
|
# 1. All env vars already set?
|
|
if _multi_creds_all_env_set "${env_vars[@]}"; then
|
|
log_info "Using ${provider_name} credentials from environment"
|
|
return 0
|
|
fi
|
|
|
|
# 2. Try loading from config file
|
|
if _multi_creds_load_config "${config_file}" "${n}" "${env_vars[@]}" "${config_keys[@]}"; then
|
|
log_info "Using ${provider_name} credentials from ${config_file}"
|
|
return 0
|
|
fi
|
|
|
|
# 3. Prompt for each credential
|
|
_multi_creds_prompt "${provider_name}" "${help_url}" "${n}" "${env_vars[@]}" "${labels[@]}" || return 1
|
|
|
|
# 4. Validate credentials
|
|
_multi_creds_validate "${test_func}" "${provider_name}" "${env_vars[@]}" || return 1
|
|
|
|
# 5. Save to config file
|
|
local save_args=()
|
|
local idx
|
|
for idx in $(seq 0 $((n - 1))); do
|
|
save_args+=("${config_keys[$idx]}" "${!env_vars[$idx]}")
|
|
done
|
|
_save_json_config "${config_file}" "${save_args[@]}"
|
|
return 0
|
|
}
|
|
|
|
# ============================================================
|
|
# Configuration file helpers
|
|
# ============================================================
|
|
|
|
# Helper to create, upload, and install a config file from a heredoc or string
|
|
# Usage: upload_config_file UPLOAD_CALLBACK RUN_CALLBACK CONTENT REMOTE_PATH
|
|
# Example: upload_config_file "$upload_func" "$run_func" "$json_content" "\$HOME/.config/app.json"
|
|
upload_config_file() {
|
|
local upload_callback="${1}"
|
|
local run_callback="${2}"
|
|
local content="${3}"
|
|
local remote_path="${4}"
|
|
|
|
local temp_file
|
|
temp_file=$(mktemp)
|
|
chmod 600 "${temp_file}"
|
|
track_temp_file "${temp_file}"
|
|
|
|
printf '%s\n' "${content}" > "${temp_file}"
|
|
|
|
# Use mktemp-derived randomness for the remote temp path to avoid predictable names
|
|
local rand_suffix
|
|
rand_suffix=$(basename "${temp_file}")
|
|
local temp_remote="/tmp/spawn_config_${rand_suffix}"
|
|
${upload_callback} "${temp_file}" "${temp_remote}"
|
|
# SECURITY: remote_path must be double-quoted to prevent injection via spaces/metacharacters
|
|
# Note: Callers should use $HOME instead of ~ since tilde does not expand inside double quotes
|
|
${run_callback} "mkdir -p \$(dirname \"${remote_path}\") && chmod 600 '${temp_remote}' && mv '${temp_remote}' \"${remote_path}\""
|
|
}
|
|
|
|
# ============================================================
|
|
# Claude Code configuration setup
|
|
# ============================================================
|
|
|
|
# Setup Claude Code configuration files (settings.json, .claude.json, CLAUDE.md)
|
|
# This consolidates the config setup pattern used by all claude.sh scripts
|
|
# Usage: setup_claude_code_config OPENROUTER_KEY UPLOAD_CALLBACK RUN_CALLBACK
|
|
#
|
|
# Arguments:
|
|
# OPENROUTER_KEY - OpenRouter API key to inject into config
|
|
# UPLOAD_CALLBACK - Function to upload files: func(local_path, remote_path)
|
|
# RUN_CALLBACK - Function to run commands: func(command)
|
|
#
|
|
# Example (SSH-based clouds):
|
|
# setup_claude_code_config "$OPENROUTER_API_KEY" \
|
|
# "upload_file $SERVER_IP" \
|
|
# "run_server $SERVER_IP"
|
|
#
|
|
# Example (Sprite):
|
|
# setup_claude_code_config "$OPENROUTER_API_KEY" \
|
|
# "upload_file_sprite $SPRITE_NAME" \
|
|
# "run_sprite $SPRITE_NAME"
|
|
|
|
# Generate Claude Code settings.json with API key
|
|
_generate_claude_code_settings() {
|
|
local openrouter_key="${1}"
|
|
local escaped_key
|
|
escaped_key=$(json_escape "${openrouter_key}")
|
|
cat << EOF
|
|
{
|
|
"theme": "dark",
|
|
"editor": "vim",
|
|
"env": {
|
|
"CLAUDE_CODE_ENABLE_TELEMETRY": "0",
|
|
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
|
"ANTHROPIC_AUTH_TOKEN": ${escaped_key}
|
|
},
|
|
"permissions": {
|
|
"defaultMode": "bypassPermissions",
|
|
"dangerouslySkipPermissions": true
|
|
}
|
|
}
|
|
EOF
|
|
}
|
|
|
|
# Generate Claude Code global state JSON
|
|
_generate_claude_code_state() {
|
|
cat << EOF
|
|
{
|
|
"hasCompletedOnboarding": true,
|
|
"bypassPermissionsModeAccepted": true
|
|
}
|
|
EOF
|
|
}
|
|
|
|
setup_claude_code_config() {
|
|
local openrouter_key="${1}"
|
|
local upload_callback="${2}"
|
|
local run_callback="${3}"
|
|
|
|
log_step "Configuring Claude Code..."
|
|
|
|
# Create ~/.claude directory
|
|
${run_callback} "mkdir -p ~/.claude"
|
|
|
|
# Create settings.json
|
|
local settings_json
|
|
settings_json=$(_generate_claude_code_settings "${openrouter_key}")
|
|
upload_config_file "${upload_callback}" "${run_callback}" "${settings_json}" "\$HOME/.claude/settings.json"
|
|
|
|
# Create .claude.json global state
|
|
local global_state_json
|
|
global_state_json=$(_generate_claude_code_state)
|
|
upload_config_file "${upload_callback}" "${run_callback}" "${global_state_json}" "\$HOME/.claude.json"
|
|
|
|
# Create empty CLAUDE.md
|
|
${run_callback} "touch ~/.claude/CLAUDE.md"
|
|
}
|
|
|
|
# ============================================================
|
|
# OpenClaw configuration setup
|
|
# ============================================================
|
|
|
|
# Setup OpenClaw configuration files (openclaw.json)
|
|
# This consolidates the config setup pattern used by all openclaw.sh scripts
|
|
# Usage: setup_openclaw_config OPENROUTER_KEY MODEL_ID UPLOAD_CALLBACK RUN_CALLBACK
|
|
#
|
|
# Arguments:
|
|
# OPENROUTER_KEY - OpenRouter API key to inject into config
|
|
# MODEL_ID - Model ID to use (e.g., "openrouter/auto", "anthropic/claude-3.5-sonnet")
|
|
# UPLOAD_CALLBACK - Function to upload files: func(local_path, remote_path)
|
|
# RUN_CALLBACK - Function to run commands: func(command)
|
|
#
|
|
# Example (SSH-based clouds):
|
|
# setup_openclaw_config "$OPENROUTER_API_KEY" "$MODEL_ID" \
|
|
# "upload_file $SERVER_IP" \
|
|
# "run_server $SERVER_IP"
|
|
#
|
|
# Example (Sprite):
|
|
# setup_openclaw_config "$OPENROUTER_API_KEY" "$MODEL_ID" \
|
|
# "upload_file_sprite $SPRITE_NAME" \
|
|
# "run_sprite $SPRITE_NAME"
|
|
# Generate openclaw.json configuration with escaped credentials
|
|
_generate_openclaw_json() {
|
|
local openrouter_key="${1}"
|
|
local model_id="${2}"
|
|
local gateway_token="${3}"
|
|
|
|
local escaped_key escaped_token
|
|
escaped_key=$(json_escape "${openrouter_key}")
|
|
escaped_token=$(json_escape "${gateway_token}")
|
|
|
|
cat << EOF
|
|
{
|
|
"env": {
|
|
"OPENROUTER_API_KEY": ${escaped_key}
|
|
},
|
|
"gateway": {
|
|
"mode": "local",
|
|
"auth": {
|
|
"token": ${escaped_token}
|
|
}
|
|
},
|
|
"agents": {
|
|
"defaults": {
|
|
"model": {
|
|
"primary": "openrouter/${model_id}"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
EOF
|
|
}
|
|
|
|
setup_openclaw_config() {
|
|
local openrouter_key="${1}"
|
|
local model_id="${2}"
|
|
local upload_callback="${3}"
|
|
local run_callback="${4}"
|
|
|
|
log_step "Configuring openclaw..."
|
|
|
|
# Create ~/.openclaw directory
|
|
${run_callback} "rm -rf ~/.openclaw && mkdir -p ~/.openclaw"
|
|
|
|
# Generate a random gateway token
|
|
local gateway_token
|
|
gateway_token=$(openssl rand -hex 16)
|
|
|
|
# Create and upload openclaw.json config
|
|
local openclaw_json
|
|
openclaw_json=$(_generate_openclaw_json "${openrouter_key}" "${model_id}" "${gateway_token}")
|
|
upload_config_file "${upload_callback}" "${run_callback}" "${openclaw_json}" "\$HOME/.openclaw/openclaw.json"
|
|
}
|
|
|
|
# ============================================================
|
|
# Continue configuration setup
|
|
# ============================================================
|
|
|
|
# Setup Continue configuration files (config.json)
|
|
# This consolidates the config setup pattern used by all continue.sh scripts
|
|
# Usage: setup_continue_config OPENROUTER_KEY UPLOAD_CALLBACK RUN_CALLBACK
|
|
#
|
|
# Arguments:
|
|
# OPENROUTER_KEY - OpenRouter API key to inject into config
|
|
# UPLOAD_CALLBACK - Function to upload files: func(local_path, remote_path)
|
|
# RUN_CALLBACK - Function to run commands: func(command)
|
|
#
|
|
# Example (SSH-based clouds):
|
|
# setup_continue_config "$OPENROUTER_API_KEY" \
|
|
# "upload_file $SERVER_IP" \
|
|
# "run_server $SERVER_IP"
|
|
#
|
|
# Example (container clouds):
|
|
# setup_continue_config "$OPENROUTER_API_KEY" \
|
|
# "upload_file" \
|
|
# "run_server"
|
|
setup_continue_config() {
|
|
local openrouter_key="${1}"
|
|
local upload_callback="${2}"
|
|
local run_callback="${3}"
|
|
|
|
log_step "Configuring Continue..."
|
|
|
|
# Create ~/.continue directory
|
|
${run_callback} "mkdir -p ~/.continue"
|
|
|
|
# Create config.json with json_escape to prevent injection
|
|
local escaped_key
|
|
escaped_key=$(json_escape "${openrouter_key}")
|
|
local continue_json
|
|
continue_json=$(cat << EOF
|
|
{
|
|
"models": [
|
|
{
|
|
"title": "OpenRouter",
|
|
"provider": "openrouter",
|
|
"model": "openrouter/auto",
|
|
"apiBase": "https://openrouter.ai/api/v1",
|
|
"apiKey": ${escaped_key}
|
|
}
|
|
]
|
|
}
|
|
EOF
|
|
)
|
|
upload_config_file "${upload_callback}" "${run_callback}" "${continue_json}" "\$HOME/.continue/config.json"
|
|
}
|
|
|
|
# ============================================================
|
|
# Interactive selection helpers
|
|
# ============================================================
|
|
|
|
# Generic interactive picker for numbered menu selection
|
|
# Eliminates duplicate _pick_location/_pick_server_type patterns across providers
|
|
#
|
|
# Usage: interactive_pick ENV_VAR_NAME DEFAULT_VALUE PROMPT_TEXT LIST_CALLBACK [FORMAT_CALLBACK]
|
|
#
|
|
# Arguments:
|
|
# ENV_VAR_NAME - Environment variable to check first (e.g., "HETZNER_LOCATION")
|
|
# DEFAULT_VALUE - Default value if env var unset and list is empty or choice invalid
|
|
# PROMPT_TEXT - Label shown above the menu (e.g., "locations", "server types")
|
|
# LIST_CALLBACK - Function that outputs pipe-delimited lines (first field = ID)
|
|
# DEFAULT_ID - Optional: ID to pre-select as default (e.g., "cpx11")
|
|
#
|
|
# LIST_CALLBACK must output pipe-delimited lines where the first field is the selectable ID.
|
|
# Example output: "fsn1|Falkenstein|DE" or "cpx11|2 vCPU|4 GB RAM|40 GB disk"
|
|
#
|
|
# Display a numbered list and read user selection
|
|
# Pipe-delimited items: "id|label". Returns selected id via stdout.
|
|
# Usage: _display_and_select PROMPT_TEXT DEFAULT_VALUE DEFAULT_ID <<< "$items"
|
|
_fzf_select() {
|
|
local prompt_text="${1}"
|
|
local default_value="${2}"
|
|
local default_id="${3}"
|
|
local fzf_input="${4}"
|
|
local default_line="${5}"
|
|
|
|
log_step "Select ${prompt_text%s} (type to filter):"
|
|
|
|
# Run fzf with default selection
|
|
local selected
|
|
if [[ -n "${default_line}" ]]; then
|
|
selected=$(printf '%s' "${fzf_input}" | fzf --height=~50% --reverse --prompt="Select > " --query="" --select-1 --exit-0 --header="Press ESC to use default (${default_id})" --print-query --query="${default_line%%$'\t'*}" | tail -1)
|
|
else
|
|
selected=$(printf '%s' "${fzf_input}" | fzf --height=~50% --reverse --prompt="Select > " --select-1 --exit-0)
|
|
fi
|
|
|
|
# If fzf was cancelled or returned nothing, use default
|
|
if [[ -z "${selected}" ]]; then
|
|
log_info "Using default: ${default_value}"
|
|
echo "${default_value}"
|
|
return
|
|
fi
|
|
|
|
# Extract ID from selected line
|
|
local selected_id="${selected%%$'\t'*}"
|
|
echo "${selected_id}"
|
|
}
|
|
|
|
_prepare_fzf_input() {
|
|
local default_id="${1}"
|
|
shift
|
|
local items_array=("$@")
|
|
|
|
local fzf_input=""
|
|
local default_line=""
|
|
for line in "${items_array[@]}"; do
|
|
local id="${line%%|*}"
|
|
local display
|
|
display=$(echo "${line}" | tr '|' '\t')
|
|
fzf_input+="${display}"$'\n'
|
|
if [[ -n "${default_id}" && "${id}" == "${default_id}" ]]; then
|
|
default_line="${display}"
|
|
fi
|
|
done
|
|
|
|
# Return via globals (bash doesn't have good multi-value returns)
|
|
FZF_INPUT="${fzf_input}"
|
|
FZF_DEFAULT_LINE="${default_line}"
|
|
}
|
|
|
|
_numbered_list_select() {
|
|
local prompt_text="${1}"
|
|
local default_value="${2}"
|
|
local default_id="${3}"
|
|
shift 3
|
|
local items_array=("$@")
|
|
|
|
log_step "Available ${prompt_text}:"
|
|
local i=1
|
|
local ids=()
|
|
local default_idx=1
|
|
for line in "${items_array[@]}"; do
|
|
local id="${line%%|*}"
|
|
printf " %2d) %s\n" "${i}" "$(echo "${line}" | tr '|' '\t')" >&2
|
|
ids+=("${id}")
|
|
if [[ -n "${default_id}" && "${id}" == "${default_id}" ]]; then
|
|
default_idx=${i}
|
|
fi
|
|
i=$((i + 1))
|
|
done
|
|
|
|
local choice
|
|
printf "\n" >&2
|
|
choice=$(safe_read "Select ${prompt_text%s} [${default_idx}]: ") || choice=""
|
|
choice="${choice:-${default_idx}}"
|
|
|
|
if [[ "${choice}" -ge 1 && "${choice}" -le "${#ids[@]}" ]] 2>/dev/null; then
|
|
echo "${ids[$((choice - 1))]}"
|
|
else
|
|
log_warn "Invalid selection '${choice}' (enter a number between 1 and ${#ids[@]}). Using default: ${default_value}"
|
|
echo "${default_value}"
|
|
fi
|
|
}
|
|
|
|
_display_and_select() {
|
|
local prompt_text="${1}"
|
|
local default_value="${2}"
|
|
local default_id="${3:-}"
|
|
|
|
# Read all items into array
|
|
local items_array=()
|
|
while IFS= read -r line; do
|
|
items_array+=("${line}")
|
|
done
|
|
|
|
if [[ "${#items_array[@]}" -eq 0 ]]; then
|
|
log_warn "No ${prompt_text} available, using default: ${default_value}"
|
|
echo "${default_value}"
|
|
return
|
|
fi
|
|
|
|
# Try to use fzf for interactive filtering if available and stdin is a TTY
|
|
if command -v fzf >/dev/null 2>&1 && [[ -t 0 ]]; then
|
|
_prepare_fzf_input "${default_id}" "${items_array[@]}"
|
|
_fzf_select "${prompt_text}" "${default_value}" "${default_id}" "${FZF_INPUT}" "${FZF_DEFAULT_LINE}"
|
|
return
|
|
fi
|
|
|
|
# Fallback to numbered list when fzf is not available
|
|
_numbered_list_select "${prompt_text}" "${default_value}" "${default_id}" "${items_array[@]}"
|
|
}
|
|
|
|
# Returns: selected ID via stdout
|
|
interactive_pick() {
|
|
local env_var_name="${1}"
|
|
local default_value="${2}"
|
|
local prompt_text="${3}"
|
|
local list_callback="${4}"
|
|
local default_id="${5:-}"
|
|
|
|
# Check environment variable first
|
|
local env_value="${!env_var_name:-}"
|
|
if [[ -n "${env_value}" ]]; then
|
|
echo "${env_value}"
|
|
return
|
|
fi
|
|
|
|
log_step "Fetching available ${prompt_text}..."
|
|
local items
|
|
items=$("${list_callback}")
|
|
|
|
if [[ -z "${items}" ]]; then
|
|
log_warn "Could not fetch ${prompt_text}, using default: ${default_value}"
|
|
echo "${default_value}"
|
|
return
|
|
fi
|
|
|
|
_display_and_select "${prompt_text}" "${default_value}" "${default_id}" <<< "${items}"
|
|
}
|
|
|
|
# ============================================================
|
|
# SSH key registration helpers
|
|
# ============================================================
|
|
|
|
# Generic SSH key check: queries the provider's API and greps for the fingerprint.
|
|
# Most providers follow this exact pattern. Use this to avoid duplicating 5-line
|
|
# check functions across every cloud lib.
|
|
# Usage: check_ssh_key_by_fingerprint API_FUNC ENDPOINT FINGERPRINT
|
|
# Example: check_ssh_key_by_fingerprint hetzner_api "/ssh_keys" "$fingerprint"
|
|
check_ssh_key_by_fingerprint() {
|
|
local api_func="${1}"
|
|
local endpoint="${2}"
|
|
local fingerprint="${3}"
|
|
|
|
local existing_keys
|
|
existing_keys=$("${api_func}" GET "${endpoint}")
|
|
echo "${existing_keys}" | grep -q "${fingerprint}"
|
|
}
|
|
|
|
# Generic SSH key registration pattern used by all cloud providers
|
|
# Eliminates ~220 lines of duplicate code across 5 provider libraries
|
|
#
|
|
# Usage: ensure_ssh_key_with_provider \
|
|
# CHECK_CALLBACK \
|
|
# REGISTER_CALLBACK \
|
|
# PROVIDER_NAME \
|
|
# [KEY_PATH]
|
|
#
|
|
# Arguments:
|
|
# CHECK_CALLBACK - Function that checks if SSH key exists with provider
|
|
# Should return 0 if key exists, 1 if not
|
|
# Function receives: fingerprint, pub_key_path
|
|
# REGISTER_CALLBACK - Function that registers SSH key with provider
|
|
# Should return 0 on success, 1 on error
|
|
# Function receives: key_name, pub_key_path
|
|
# PROVIDER_NAME - Display name of the provider (for logging)
|
|
# KEY_PATH - Optional: Path to SSH private key (default: $HOME/.ssh/id_ed25519)
|
|
#
|
|
# Example:
|
|
# ensure_ssh_key_with_provider \
|
|
# hetzner_check_ssh_key \
|
|
# hetzner_register_ssh_key \
|
|
# "Hetzner"
|
|
#
|
|
# Callback implementations should use provider-specific API calls but follow
|
|
# this contract to enable shared logic for key generation and registration flow.
|
|
ensure_ssh_key_with_provider() {
|
|
local check_callback="${1}"
|
|
local register_callback="${2}"
|
|
local provider_name="${3}"
|
|
local key_path="${4:-${HOME}/.ssh/id_ed25519}"
|
|
local pub_path="${key_path}.pub"
|
|
|
|
# Generate key if needed (shared function)
|
|
generate_ssh_key_if_missing "${key_path}"
|
|
|
|
# Get fingerprint (shared function)
|
|
local fingerprint
|
|
fingerprint=$(get_ssh_fingerprint "${pub_path}")
|
|
|
|
# Check if already registered (provider-specific)
|
|
if "${check_callback}" "${fingerprint}" "${pub_path}"; then
|
|
log_info "SSH key already registered with ${provider_name}"
|
|
return 0
|
|
fi
|
|
|
|
# Register the key (provider-specific)
|
|
log_step "Registering SSH key with ${provider_name}..."
|
|
local key_name
|
|
key_name="spawn-$(hostname)-$(date +%s)"
|
|
|
|
if "${register_callback}" "${key_name}" "${pub_path}"; then
|
|
log_info "SSH key registered with ${provider_name}"
|
|
return 0
|
|
else
|
|
log_error "Failed to register SSH key with ${provider_name}"
|
|
log_error "The API may have rejected the key format or the token lacks write permissions."
|
|
log_error "Verify your API token has SSH key management permissions, then try again."
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# ============================================================
|
|
# Agent install commands (run remotely on provisioned servers)
|
|
# ============================================================
|
|
|
|
# Robust OpenCode install command that downloads to a file first instead of
|
|
# piping curl|tar, which breaks in container exec environments (Sprite, E2B,
|
|
# Modal, Daytona) where the binary stream can get corrupted through the exec
|
|
# layer. The upstream installer's "curl -#" flag also interferes in non-TTY
|
|
# environments.
|
|
opencode_install_cmd() {
|
|
printf '%s' 'OC_ARCH=$(uname -m); if [ "$OC_ARCH" = "aarch64" ]; then OC_ARCH=arm64; fi; OC_OS=$(uname -s | tr A-Z a-z); if [ "$OC_OS" = "darwin" ]; then OC_OS=mac; fi; mkdir -p /tmp/opencode-install "$HOME/.opencode/bin" && curl -fsSL -o /tmp/opencode-install/oc.tar.gz "https://github.com/opencode-ai/opencode/releases/latest/download/opencode-${OC_OS}-${OC_ARCH}.tar.gz" && tar xzf /tmp/opencode-install/oc.tar.gz -C /tmp/opencode-install && mv /tmp/opencode-install/opencode "$HOME/.opencode/bin/" && rm -rf /tmp/opencode-install && grep -q ".opencode/bin" "$HOME/.bashrc" 2>/dev/null || echo '"'"'export PATH="$HOME/.opencode/bin:$PATH"'"'"' >> "$HOME/.bashrc"; grep -q ".opencode/bin" "$HOME/.zshrc" 2>/dev/null || echo '"'"'export PATH="$HOME/.opencode/bin:$PATH"'"'"' >> "$HOME/.zshrc" 2>/dev/null; export PATH="$HOME/.opencode/bin:$PATH"'
|
|
}
|
|
|
|
# ============================================================
|
|
# VM Connection Tracking
|
|
# ============================================================
|
|
|
|
# Save VM connection info for spawn list reconnect functionality.
|
|
# This allows users to reconnect to previously spawned VMs via `spawn list`.
|
|
# Usage: save_vm_connection IP USER [SERVER_ID] [SERVER_NAME] [CLOUD] [METADATA_JSON]
|
|
# Example: save_vm_connection "$DO_SERVER_IP" "root" "$DO_DROPLET_ID" "$DROPLET_NAME" "digitalocean"
|
|
# Example: save_vm_connection "$GCP_IP" "root" "" "$NAME" "gcp" '{"zone":"us-central1-a"}'
|
|
save_vm_connection() {
|
|
local ip="${1}"
|
|
local user="${2}"
|
|
local server_id="${3:-}"
|
|
local server_name="${4:-}"
|
|
local cloud="${5:-}"
|
|
local metadata="${6:-}"
|
|
|
|
local spawn_dir="${HOME}/.spawn"
|
|
mkdir -p "${spawn_dir}"
|
|
|
|
local conn_file="${spawn_dir}/last-connection.json"
|
|
|
|
# Build JSON (handle optional fields)
|
|
local json="{\"ip\":\"${ip}\",\"user\":\"${user}\""
|
|
if [[ -n "${server_id}" ]]; then
|
|
json="${json},\"server_id\":\"${server_id}\""
|
|
fi
|
|
if [[ -n "${server_name}" ]]; then
|
|
json="${json},\"server_name\":\"${server_name}\""
|
|
fi
|
|
if [[ -n "${cloud}" ]]; then
|
|
json="${json},\"cloud\":\"${cloud}\""
|
|
fi
|
|
if [[ -n "${metadata}" ]]; then
|
|
json="${json},\"metadata\":${metadata}"
|
|
fi
|
|
json="${json}}"
|
|
|
|
printf '%s\n' "${json}" > "${conn_file}"
|
|
}
|
|
|
|
# ============================================================
|
|
# Auto-initialization
|
|
# ============================================================
|
|
|
|
# Auto-register cleanup trap when this file is sourced
|
|
register_cleanup_trap
|