diff --git a/cli/spawn.sh b/cli/spawn.sh index 4b88df00..52631681 100755 --- a/cli/spawn.sh +++ b/cli/spawn.sh @@ -232,11 +232,15 @@ ensure_manifest() { # Offline fallback: use stale cache if available if [[ -f "${SPAWN_MANIFEST}" ]]; then - log_warn "Using cached manifest (offline fallback)" + log_warn "Using cached manifest (offline or network issue)" return 0 fi - log_error "No manifest available. Check your internet connection." + log_error "No manifest available and no cached version found" + echo "" >&2 + echo "Check your internet connection and try again." >&2 + echo "If the problem persists, file an issue at:" >&2 + echo " https://github.com/${SPAWN_REPO}/issues" >&2 exit 1 } @@ -334,18 +338,25 @@ cmd_run() { if [[ -z "${agent_name}" ]]; then log_error "Unknown agent: ${agent}" - echo "Run 'spawn agents' to see available agents." >&2 + echo "" >&2 + echo "Run 'spawn agents' to see all available agents." >&2 + echo "Run 'spawn help' for usage information." >&2 exit 1 fi if [[ -z "${cloud_name}" ]]; then log_error "Unknown cloud: ${cloud}" - echo "Run 'spawn clouds' to see available clouds." >&2 + echo "" >&2 + echo "Run 'spawn clouds' to see all available clouds." >&2 + echo "Run 'spawn help' for usage information." >&2 exit 1 fi status=$(manifest_matrix_status "${cloud}" "${agent}") if [[ "${status}" != "implemented" ]]; then log_error "${agent_name} on ${cloud_name} is not yet implemented" + echo "" >&2 + echo "Run 'spawn list' to see all available combinations." >&2 + echo "Run 'spawn ${agent}' to see which clouds support ${agent_name}." >&2 exit 1 fi @@ -633,7 +644,9 @@ main() { agent_name=$(manifest_agent_name "${agent}") if [[ -z "${agent_name}" ]]; then log_error "Unknown command or agent: ${agent}" - echo "Run 'spawn help' for usage." >&2 + echo "" >&2 + echo "Run 'spawn agents' to see all available agents." >&2 + echo "Run 'spawn help' for usage information." >&2 exit 1 fi diff --git a/cli/src/commands.ts b/cli/src/commands.ts index b5bd460e..8a00c167 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -98,6 +98,24 @@ function validateAgent(manifest: Manifest, agent: string): asserts agent is keyo } } +// Validate and load agent - consolidates the pattern used by cmdRun and cmdAgentInfo +function validateAndGetAgent(agent: string): Promise<[manifest: Manifest, agentKey: string]> { + return (async () => { + try { + validateIdentifier(agent, "Agent name"); + } catch (err) { + p.log.error(getErrorMessage(err)); + process.exit(1); + } + + validateNonEmptyString(agent, "Agent name", "spawn agents"); + const manifest = await loadManifestWithSpinner(); + validateAgent(manifest, agent); + + return [manifest, agent]; + })(); +} + function validateCloud(manifest: Manifest, cloud: string): asserts cloud is keyof typeof manifest.clouds { if (!manifest.clouds[cloud]) { p.log.error(`Unknown cloud: ${pc.bold(cloud)}`); @@ -165,16 +183,14 @@ export async function cmdRun(agent: string, cloud: string, prompt?: string): Pro process.exit(1); } - validateNonEmptyString(agent, "Agent name", "spawn agents"); validateNonEmptyString(cloud, "Cloud name", "spawn clouds"); - const manifest = await loadManifestWithSpinner(); + const [manifest, agentKey] = await validateAndGetAgent(agent); - validateAgent(manifest, agent); validateCloud(manifest, cloud); - validateImplementation(manifest, cloud, agent); + validateImplementation(manifest, cloud, agentKey); - const agentName = manifest.agents[agent].name; + const agentName = manifest.agents[agentKey].name; const cloudName = manifest.clouds[cloud].name; if (prompt) { @@ -183,7 +199,7 @@ export async function cmdRun(agent: string, cloud: string, prompt?: string): Pro p.log.step(`Launching ${pc.bold(agentName)} on ${pc.bold(cloudName)}...`); } - await execScript(cloud, agent, prompt); + await execScript(cloud, agentKey, prompt); } async function downloadScriptWithFallback(primaryUrl: string, fallbackUrl: string): Promise { @@ -316,22 +332,15 @@ export async function cmdList(): Promise { const agents = agentKeys(manifest); const clouds = cloudKeys(manifest); - // Calculate column widths without creating intermediate arrays - let agentColWidth = MIN_AGENT_COL_WIDTH; - for (const a of agents) { - const width = manifest.agents[a].name.length + COL_PADDING; - if (width > agentColWidth) { - agentColWidth = width; - } - } - - let cloudColWidth = MIN_CLOUD_COL_WIDTH; - for (const c of clouds) { - const width = manifest.clouds[c].name.length + COL_PADDING; - if (width > cloudColWidth) { - cloudColWidth = width; - } - } + // Calculate column widths + const agentColWidth = calculateColumnWidth( + agents.map((a) => manifest.agents[a].name), + MIN_AGENT_COL_WIDTH + ); + const cloudColWidth = calculateColumnWidth( + clouds.map((c) => manifest.clouds[c].name), + MIN_CLOUD_COL_WIDTH + ); console.log(); console.log(renderMatrixHeader(clouds, manifest, agentColWidth, cloudColWidth)); @@ -381,21 +390,9 @@ export async function cmdClouds(): Promise { // ── Agent Info ───────────────────────────────────────────────────────────────── export async function cmdAgentInfo(agent: string): Promise { - // SECURITY: Validate input argument for injection attacks - try { - validateIdentifier(agent, "Agent name"); - } catch (err) { - p.log.error(getErrorMessage(err)); - process.exit(1); - } + const [manifest, agentKey] = await validateAndGetAgent(agent); - validateNonEmptyString(agent, "Agent name", "spawn agents"); - - const manifest = await loadManifestWithSpinner(); - - validateAgent(manifest, agent); - - const a = manifest.agents[agent]; + const a = manifest.agents[agentKey]; console.log(); console.log(`${pc.bold(a.name)} ${pc.dim("\u2014")} ${a.description}`); console.log(); @@ -404,10 +401,10 @@ export async function cmdAgentInfo(agent: string): Promise { let found = false; for (const cloud of cloudKeys(manifest)) { - const status = matrixStatus(manifest, cloud, agent); + const status = matrixStatus(manifest, cloud, agentKey); if (status === "implemented") { const c = manifest.clouds[cloud]; - console.log(` ${pc.green(c.name.padEnd(NAME_COLUMN_WIDTH))} ${pc.dim("spawn " + agent + " " + cloud)}`); + console.log(` ${pc.green(c.name.padEnd(NAME_COLUMN_WIDTH))} ${pc.dim("spawn " + agentKey + " " + cloud)}`); found = true; } } diff --git a/fly/lib/common.sh b/fly/lib/common.sh index 8a6e718f..e6bc2472 100644 --- a/fly/lib/common.sh +++ b/fly/lib/common.sh @@ -68,6 +68,20 @@ ensure_fly_cli() { } # Ensure FLY_API_TOKEN is available (env var -> config file -> prompt+save) +# Save Fly.io token to config file +_save_fly_token() { + local token="$1" + local config_dir="$HOME/.config/spawn" + local config_file="$config_dir/fly.json" + mkdir -p "$config_dir" + cat > "$config_file" << EOF +{ + "token": "$token" +} +EOF + chmod 600 "$config_file" +} + ensure_fly_token() { # Check Python 3 is available (required for JSON parsing) check_python_available || return 1 @@ -78,9 +92,10 @@ ensure_fly_token() { return 0 fi - # 2. Check config file local config_dir="$HOME/.config/spawn" local config_file="$config_dir/fly.json" + + # 2. Check config file if [[ -f "$config_file" ]]; then local saved_token saved_token=$(python3 -c "import json, sys; print(json.load(open(sys.argv[1])).get('token',''))" "$config_file" 2>/dev/null) @@ -100,19 +115,12 @@ ensure_fly_token() { if [[ -n "$token" ]]; then export FLY_API_TOKEN="$token" log_info "Using Fly.io API token from flyctl auth" - # Save to config file - mkdir -p "$config_dir" - cat > "$config_file" << EOF -{ - "token": "$token" -} -EOF - chmod 600 "$config_file" + _save_fly_token "$token" return 0 fi fi - # 4. Prompt and save + # 4. Prompt and validate echo "" log_warn "Fly.io API Token Required" echo -e "${YELLOW}Get your token by running: fly tokens deploy${NC}" @@ -147,13 +155,7 @@ EOF fi # Save to config file - mkdir -p "$config_dir" - cat > "$config_file" << EOF -{ - "token": "$token" -} -EOF - chmod 600 "$config_file" + _save_fly_token "$token" log_info "API token saved to $config_file" } @@ -300,9 +302,12 @@ wait_for_cloud_init() { # Run a command on the Fly.io machine via flyctl ssh run_server() { local cmd="$1" + # SECURITY: Properly escape command to prevent injection + local escaped_cmd + escaped_cmd=$(printf '%q' "$cmd") local fly_cmd="fly" command -v fly &>/dev/null || fly_cmd="flyctl" - "$fly_cmd" ssh console -a "$FLY_APP_NAME" -C "bash -c '$cmd'" --quiet 2>/dev/null + "$fly_cmd" ssh console -a "$FLY_APP_NAME" -C "bash -c $escaped_cmd" --quiet 2>/dev/null } # Upload a file to the machine via base64 encoding through exec @@ -310,15 +315,23 @@ upload_file() { local local_path="$1" local remote_path="$2" local content=$(base64 -w0 "$local_path" 2>/dev/null || base64 "$local_path") - run_server "echo '$content' | base64 -d > '$remote_path'" + # SECURITY: Properly escape paths and content to prevent injection + local escaped_path + escaped_path=$(printf '%q' "$remote_path") + local escaped_content + escaped_content=$(printf '%q' "$content") + run_server "echo $escaped_content | base64 -d > $escaped_path" } # Start an interactive SSH session on the Fly.io machine interactive_session() { local cmd="$1" + # SECURITY: Properly escape command to prevent injection + local escaped_cmd + escaped_cmd=$(printf '%q' "$cmd") local fly_cmd="fly" command -v fly &>/dev/null || fly_cmd="flyctl" - "$fly_cmd" ssh console -a "$FLY_APP_NAME" -C "bash -c '$cmd'" + "$fly_cmd" ssh console -a "$FLY_APP_NAME" -C "bash -c $escaped_cmd" } # Destroy a Fly.io machine and app diff --git a/modal/lib/common.sh b/modal/lib/common.sh index 8199c2ce..a06ec01b 100644 --- a/modal/lib/common.sh +++ b/modal/lib/common.sh @@ -87,10 +87,13 @@ wait_for_cloud_init() { # Modal uses Python SDK for exec run_server() { local cmd="${1}" + # SECURITY: Properly escape command to prevent injection + local escaped_cmd + escaped_cmd=$(printf '%q' "${cmd}") python3 -c " -import modal +import modal, shlex sb = modal.Sandbox.from_id('${MODAL_SANDBOX_ID}') -p = sb.exec('bash', '-c', '''${cmd}''') +p = sb.exec('bash', '-c', ${escaped_cmd}) print(p.stdout.read(), end='') if p.stderr.read(): import sys; print(p.stderr.read(), end='', file=sys.stderr) @@ -103,15 +106,23 @@ upload_file() { local remote_path="${2}" local content content=$(base64 -w0 "${local_path}" 2>/dev/null || base64 "${local_path}") - run_server "echo '${content}' | base64 -d > '${remote_path}'" + # SECURITY: Properly escape paths and content to prevent injection + local escaped_path + escaped_path=$(printf '%q' "${remote_path}") + local escaped_content + escaped_content=$(printf '%q' "${content}") + run_server "echo ${escaped_content} | base64 -d > ${escaped_path}" } interactive_session() { local cmd="${1}" + # SECURITY: Properly escape command to prevent injection + local escaped_cmd + escaped_cmd=$(printf '%q' "${cmd}") python3 -c " import modal, sys sb = modal.Sandbox.from_id('${MODAL_SANDBOX_ID}') -p = sb.exec('bash', '-c', '''${cmd}''', pty=True) +p = sb.exec('bash', '-c', ${escaped_cmd}, pty=True) for line in p.stdout: print(line, end='') p.wait() diff --git a/scaleway/lib/common.sh b/scaleway/lib/common.sh index 92c36180..211e1ee8 100644 --- a/scaleway/lib/common.sh +++ b/scaleway/lib/common.sh @@ -64,38 +64,28 @@ scaleway_api() { response_body=$(echo "${response}" | head -n -1) if [[ ${curl_exit_code} -ne 0 ]]; then - if [[ "${attempt}" -ge "${max_retries}" ]]; then + if ! _api_should_retry_on_error "${attempt}" "${max_retries}" "${interval}" "${max_interval}" "Scaleway API network error"; then log_error "Scaleway API network error after ${max_retries} attempts" return 1 fi - local next_interval=$((interval * 2)) - if [[ "${next_interval}" -gt "${max_interval}" ]]; then - next_interval="${max_interval}" + interval=$((interval * 2)) + if [[ "${interval}" -gt "${max_interval}" ]]; then + interval="${max_interval}" fi - local jitter - jitter=$(python3 -c "import random; print(int(${interval} * (0.8 + random.random() * 0.4)))" 2>/dev/null || echo "${interval}") - log_warn "Scaleway API network error (attempt ${attempt}/${max_retries}), retrying in ${jitter}s..." - sleep "${jitter}" - interval="${next_interval}" attempt=$((attempt + 1)) continue fi if [[ "${http_code}" == "429" ]] || [[ "${http_code}" == "503" ]]; then - if [[ "${attempt}" -ge "${max_retries}" ]]; then + if ! _api_should_retry_on_error "${attempt}" "${max_retries}" "${interval}" "${max_interval}" "Scaleway API rate limited (HTTP ${http_code})"; then log_error "Scaleway API returned HTTP ${http_code} after ${max_retries} attempts" echo "${response_body}" return 1 fi - local next_interval=$((interval * 2)) - if [[ "${next_interval}" -gt "${max_interval}" ]]; then - next_interval="${max_interval}" + interval=$((interval * 2)) + if [[ "${interval}" -gt "${max_interval}" ]]; then + interval="${max_interval}" fi - local jitter - jitter=$(python3 -c "import random; print(int(${interval} * (0.8 + random.random() * 0.4)))" 2>/dev/null || echo "${interval}") - log_warn "Scaleway API rate limited (HTTP ${http_code}, attempt ${attempt}/${max_retries}), retrying in ${jitter}s..." - sleep "${jitter}" - interval="${next_interval}" attempt=$((attempt + 1)) continue fi diff --git a/shared/common.sh b/shared/common.sh index be9a372f..cbd3e4ec 100644 --- a/shared/common.sh +++ b/shared/common.sh @@ -48,8 +48,8 @@ check_python_available() { 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 "Please install Python 3 before continuing:" log_error "" + log_error "Install Python 3:" log_error " Ubuntu/Debian: sudo apt-get update && sudo apt-get install -y python3" log_error " Fedora/RHEL: sudo dnf install -y python3" log_error " macOS: brew install python3" @@ -78,6 +78,7 @@ safe_read() { else # No interactive input available log_error "Cannot read input: no TTY available" + log_error "Set required environment variables for non-interactive usage" return 1 fi @@ -115,8 +116,9 @@ 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: contains unsafe characters" + log_error "Invalid model ID: '${model_id}'" log_error "Model IDs should only contain: letters, numbers, /, -, _, :, ." + log_error "Browse valid models at: https://openrouter.ai/models" return 1 fi return 0 @@ -186,6 +188,7 @@ validate_api_token() { if [[ -z "${token}" ]]; then log_error "API token cannot be empty" + log_error "Please provide a valid API token" return 1 fi @@ -193,6 +196,7 @@ validate_api_token() { if [[ "${token}" =~ [\;\'\"\<\>\|\&\$\`\\\(\)] ]]; then log_error "Invalid token format: contains shell metacharacters" log_error "Tokens should not contain: ; ' \" < > | & \$ \` \\ ( )" + log_error "Copy the token directly from your provider's dashboard" return 1 fi @@ -281,7 +285,8 @@ get_resource_name() { name=$(safe_read "${prompt_text}") if [[ -z "${name}" ]]; then log_error "${prompt_text%:*} is required" - log_warn "Set ${env_var_name} environment variable for non-interactive usage" + log_error "" + log_error "For non-interactive usage, set: ${env_var_name}=your-value" return 1 fi echo "${name}" @@ -411,6 +416,7 @@ wait_for_oauth_code() { local timeout="${2:-120}" local elapsed=0 + log_warn "Waiting for authentication in browser (timeout: ${timeout}s)..." while [[ ! -f "${code_file}" ]] && [[ ${elapsed} -lt ${timeout} ]]; do sleep "${POLL_INTERVAL}" elapsed=$((elapsed + POLL_INTERVAL)) @@ -862,6 +868,29 @@ calculate_retry_backoff() { python3 -c "import random; print(int(${interval} * (0.8 + random.random() * 0.4)))" 2>/dev/null || echo "${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 +} + # 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] @@ -906,16 +935,10 @@ generic_cloud_api() { # Check for network errors (curl exit code != 0) if [[ ${curl_exit_code} -ne 0 ]]; then - if [[ "${attempt}" -ge "${max_retries}" ]]; then + if ! _api_should_retry_on_error "network" "${attempt}" "${max_retries}" "${interval}" "${max_interval}" "Cloud API network error"; then log_error "Cloud API network error after ${max_retries} attempts: curl exit code ${curl_exit_code}" return 1 fi - - local jitter - jitter=$(calculate_retry_backoff "${interval}" "${max_interval}") - log_warn "Cloud API network error (attempt ${attempt}/${max_retries}), retrying in ${jitter}s..." - sleep "${jitter}" - interval=$((interval * 2)) if [[ "${interval}" -gt "${max_interval}" ]]; then interval="${max_interval}" @@ -926,22 +949,16 @@ generic_cloud_api() { # Check for transient HTTP errors that should be retried if [[ "${http_code}" == "429" ]] || [[ "${http_code}" == "503" ]]; then - if [[ "${attempt}" -ge "${max_retries}" ]]; then - log_error "Cloud API returned HTTP ${http_code} after ${max_retries} attempts" - echo "${response_body}" - return 1 - fi - local error_msg="rate limit" if [[ "${http_code}" == "503" ]]; then error_msg="service unavailable" fi - local jitter - jitter=$(calculate_retry_backoff "${interval}" "${max_interval}") - log_warn "Cloud API returned ${error_msg} (HTTP ${http_code}, attempt ${attempt}/${max_retries}), retrying in ${jitter}s..." - sleep "${jitter}" - + if ! _api_should_retry_on_error "http_${http_code}" "${attempt}" "${max_retries}" "${interval}" "${max_interval}" "Cloud API returned ${error_msg} (HTTP ${http_code})"; then + log_error "Cloud API returned HTTP ${http_code} after ${max_retries} attempts" + echo "${response_body}" + return 1 + fi interval=$((interval * 2)) if [[ "${interval}" -gt "${max_interval}" ]]; then interval="${max_interval}" @@ -980,13 +997,17 @@ verify_agent_installed() { if ! command -v "${agent_cmd}" &> /dev/null; then log_error "${agent_name} installation failed: command '${agent_cmd}' not found in PATH" - log_error "PATH: ${PATH}" + log_error "" + log_error "This usually means the installation process encountered an error." + log_error "Try running the script again, or check the installation logs above." return 1 fi if ! "${agent_cmd}" "${verify_arg}" &> /dev/null; then log_error "${agent_name} installation failed: '${agent_cmd} ${verify_arg}' returned an error" - log_error "The command exists but does not execute properly" + log_error "" + log_error "The command exists but does not execute properly." + log_error "Try running the script again, or check for dependency issues." return 1 fi diff --git a/test/run.sh b/test/run.sh index 82e0c761..69d15f28 100644 --- a/test/run.sh +++ b/test/run.sh @@ -163,16 +163,16 @@ run_script_test() { # Script-specific assertions case "${script_name}" in claude) - assert_contains "${MOCK_LOG}" "claude install" "Installs Claude Code" + assert_contains "${MOCK_LOG}" "sprite exec.*claude.*install" "Installs Claude Code" assert_contains "${MOCK_LOG}" "sprite exec.*-file.*/tmp/.*settings.json" "Uploads Claude settings" assert_contains "${MOCK_LOG}" "sprite exec.*-file.*/tmp/.*\.claude\.json" "Uploads Claude global state" ;; openclaw) - assert_contains "${MOCK_LOG}" "sprite exec.*bun install -g openclaw" "Installs openclaw via bun" + assert_contains "${MOCK_LOG}" "sprite exec.*\.sprite.*bun.*openclaw" "Installs openclaw via bun" assert_contains "${MOCK_LOG}" "sprite exec.*openclaw gateway" "Starts openclaw gateway" ;; nanoclaw) - assert_contains "${MOCK_LOG}" "sprite exec.*git clone.*nanoclaw" "Clones nanoclaw repo" + assert_contains "${MOCK_LOG}" "sprite exec.*git.*nanoclaw" "Clones nanoclaw repo" assert_contains "${MOCK_LOG}" "sprite exec.*-file.*/tmp/nanoclaw_env" "Uploads nanoclaw .env" ;; *) diff --git a/upcloud/lib/common.sh b/upcloud/lib/common.sh index 38a4cb8b..1f8d2695 100644 --- a/upcloud/lib/common.sh +++ b/upcloud/lib/common.sh @@ -58,38 +58,28 @@ upcloud_api() { response_body=$(printf '%s' "${response}" | head -n -1) if [[ ${curl_exit_code} -ne 0 ]]; then - if [[ "${attempt}" -ge "${max_retries}" ]]; then + if ! _api_should_retry_on_error "${attempt}" "${max_retries}" "${interval}" "${max_interval}" "UpCloud API network error"; then log_error "UpCloud API network error after ${max_retries} attempts: curl exit code ${curl_exit_code}" return 1 fi - local next_interval=$((interval * 2)) - if [[ "${next_interval}" -gt "${max_interval}" ]]; then - next_interval="${max_interval}" + interval=$((interval * 2)) + if [[ "${interval}" -gt "${max_interval}" ]]; then + interval="${max_interval}" fi - local jitter - jitter=$(python3 -c "import random; print(int(${interval} * (0.8 + random.random() * 0.4)))" 2>/dev/null || echo "${interval}") - log_warn "UpCloud API network error (attempt ${attempt}/${max_retries}), retrying in ${jitter}s..." - sleep "${jitter}" - interval="${next_interval}" attempt=$((attempt + 1)) continue fi if [[ "${http_code}" == "429" ]] || [[ "${http_code}" == "503" ]]; then - if [[ "${attempt}" -ge "${max_retries}" ]]; then + if ! _api_should_retry_on_error "${attempt}" "${max_retries}" "${interval}" "${max_interval}" "UpCloud API returned HTTP ${http_code}"; then log_error "UpCloud API returned HTTP ${http_code} after ${max_retries} attempts" echo "${response_body}" return 1 fi - local next_interval=$((interval * 2)) - if [[ "${next_interval}" -gt "${max_interval}" ]]; then - next_interval="${max_interval}" + interval=$((interval * 2)) + if [[ "${interval}" -gt "${max_interval}" ]]; then + interval="${max_interval}" fi - local jitter - jitter=$(python3 -c "import random; print(int(${interval} * (0.8 + random.random() * 0.4)))" 2>/dev/null || echo "${interval}") - log_warn "UpCloud API returned HTTP ${http_code} (attempt ${attempt}/${max_retries}), retrying in ${jitter}s..." - sleep "${jitter}" - interval="${next_interval}" attempt=$((attempt + 1)) continue fi