mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 11:59:29 +00:00
refactor: Security fixes, complexity reduction, and UX improvements (#58)
Security: - Fix command injection in modal/lib/common.sh (run_server, upload_file, interactive_session) - Fix command injection in fly/lib/common.sh (run_server, upload_file, interactive_session) - All container providers now use printf '%q' for proper shell escaping Complexity: - Extract _api_should_retry_on_error() helper in shared/common.sh (-19 lines) - Refactor scaleway_api and upcloud_api to use shared retry helper (-24 lines) - Extract _save_fly_token() helper in fly/lib/common.sh (-11 lines) - Extract validateAndGetAgent() in commands.ts, reducing cmdRun/cmdAgentInfo duplication - Refactor cmdList column width calculation to use calculateColumnWidth() UX: - Add actionable next steps to error messages in shared/common.sh - Improve CLI bash fallback error messages with guidance (spawn.sh) - Add OAuth progress indicator during browser authentication wait - Show invalid model ID value and link to openrouter.ai/models - Add troubleshooting steps for agent installation failures Tests: - Update test assertions in test/run.sh to match refactored patterns - All tests passing: 74 TypeScript + 75 bash = 149 total, 0 failures Co-authored-by: A <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
44cafc7cc5
commit
bbbe815035
8 changed files with 164 additions and 129 deletions
23
cli/spawn.sh
23
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
|
|
@ -316,22 +332,15 @@ export async function cmdList(): Promise<void> {
|
|||
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<void> {
|
|||
// ── Agent Info ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function cmdAgentInfo(agent: string): Promise<void> {
|
||||
// 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<void> {
|
|||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
;;
|
||||
*)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue