spawn/modal/lib/common.sh
A bbbe815035
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>
2026-02-08 17:09:27 -08:00

148 lines
4.6 KiB
Bash

#!/bin/bash
# Common bash functions for Modal sandbox spawn scripts
# Uses Modal CLI + Python SDK — https://modal.com
# Sandboxes are secure containers with sub-second cold starts
# No SSH — uses `modal sandbox exec` for commands
# Bash safety flags
set -eo pipefail
# ============================================================
# Provider-agnostic functions
# ============================================================
# Source shared provider-agnostic functions (local or remote fallback)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
if [[ -n "${SCRIPT_DIR}" && -f "${SCRIPT_DIR}/../../shared/common.sh" ]]; then
source "${SCRIPT_DIR}/../../shared/common.sh"
else
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/shared/common.sh)"
fi
# Note: Provider-agnostic functions (logging, OAuth, browser, nc_listen) are now in shared/common.sh
# ============================================================
# Modal specific functions
# ============================================================
ensure_modal_cli() {
if ! command -v modal &>/dev/null; then
log_warn "Installing Modal CLI..."
pip install modal 2>/dev/null || pip3 install modal || {
log_error "Failed to install Modal. Install manually: pip install modal"
return 1
}
fi
# Check if authenticated
if ! modal profile current &>/dev/null; then
log_warn "Modal not authenticated. Running setup..."
modal setup
fi
log_info "Modal CLI ready"
}
get_server_name() {
get_resource_name "MODAL_SANDBOX_NAME" "Enter sandbox name: "
}
create_server() {
local name="${1}"
local image="${MODAL_IMAGE:-debian_slim}"
log_warn "Creating Modal sandbox '${name}'..."
# Create sandbox via Python SDK (Modal CLI doesn't have direct sandbox create)
MODAL_SANDBOX_ID=$(python3 -c "
import modal
app = modal.App.lookup('spawn-${name}', create_if_missing=True)
sb = modal.Sandbox.create(
app=app,
name='${name}',
image=modal.Image.${image}().apt_install('curl', 'unzip', 'git', 'zsh'),
timeout=3600,
)
print(sb.object_id)
" 2>/dev/null)
if [[ -z "${MODAL_SANDBOX_ID}" ]]; then
log_error "Failed to create Modal sandbox"
return 1
fi
export MODAL_SANDBOX_ID
export MODAL_APP_NAME="spawn-${name}"
export MODAL_SANDBOX_NAME_ACTUAL="${name}"
log_info "Sandbox created: ID=${MODAL_SANDBOX_ID}"
}
wait_for_cloud_init() {
log_warn "Installing tools in sandbox..."
run_server "curl -fsSL https://bun.sh/install | bash" >/dev/null 2>&1 || true
run_server "curl -fsSL https://claude.ai/install.sh | bash" >/dev/null 2>&1 || true
run_server 'echo "export PATH=\"${HOME}/.claude/local/bin:${HOME}/.bun/bin:${PATH}\"" >> ~/.bashrc' >/dev/null 2>&1 || true
run_server 'echo "export PATH=\"${HOME}/.claude/local/bin:${HOME}/.bun/bin:${PATH}\"" >> ~/.zshrc' >/dev/null 2>&1 || true
log_info "Tools installed"
}
# 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, shlex
sb = modal.Sandbox.from_id('${MODAL_SANDBOX_ID}')
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)
p.wait()
"
}
upload_file() {
local local_path="${1}"
local remote_path="${2}"
local content
content=$(base64 -w0 "${local_path}" 2>/dev/null || base64 "${local_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', ${escaped_cmd}, pty=True)
for line in p.stdout:
print(line, end='')
p.wait()
"
}
destroy_server() {
local sandbox_id="${1:-${MODAL_SANDBOX_ID}}"
log_warn "Terminating sandbox..."
python3 -c "
import modal
sb = modal.Sandbox.from_id('${sandbox_id}')
sb.terminate()
" 2>/dev/null || true
log_info "Sandbox terminated"
}
list_servers() {
python3 -c "
import modal
for sb in modal.Sandbox.list():
print(f'{sb.object_id} {sb.name or \"unnamed\"}')" 2>/dev/null || echo "No sandboxes found"
}