mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-29 20:39:29 +00:00
PR #2064 fixed _exec_long shell injection for DigitalOcean and Sprite but missed the same bash -c '${cmd}' pattern in Hetzner, GCP, AWS, and Daytona. Apply the same base64-encoding fix to all four. Agent: security-auditor Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
380 lines
12 KiB
Bash
380 lines
12 KiB
Bash
#!/bin/bash
|
|
# e2e/lib/clouds/daytona.sh — Daytona cloud driver for multi-cloud E2E
|
|
#
|
|
# Implements the standard cloud driver interface (_daytona_* prefixed functions).
|
|
# Sourced by common.sh's load_cloud_driver() which wires these to generic names.
|
|
#
|
|
# Depends on: log_step, log_ok, log_err, log_warn, log_info, format_duration,
|
|
# untrack_app (provided by common.sh)
|
|
set -eo pipefail
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
_DAYTONA_API_BASE="https://app.daytona.io/api"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _daytona_validate_env
|
|
#
|
|
# Check that DAYTONA_API_KEY is set and valid (test list endpoint).
|
|
# Returns 0 on success, 1 on failure.
|
|
# ---------------------------------------------------------------------------
|
|
_daytona_validate_env() {
|
|
if [ -z "${DAYTONA_API_KEY:-}" ]; then
|
|
log_err "DAYTONA_API_KEY is not set"
|
|
return 1
|
|
fi
|
|
|
|
# Validate the key by hitting the sandbox list endpoint
|
|
if ! curl -sf \
|
|
-H "Authorization: Bearer ${DAYTONA_API_KEY}" \
|
|
"${_DAYTONA_API_BASE}/sandbox?page=1&limit=1" >/dev/null 2>&1; then
|
|
log_err "DAYTONA_API_KEY is invalid or Daytona API is unreachable"
|
|
return 1
|
|
fi
|
|
|
|
log_ok "Daytona API key validated"
|
|
return 0
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _daytona_headless_env APP AGENT
|
|
#
|
|
# Print export lines to stdout for headless provisioning.
|
|
# These are eval'd by the provisioning harness before invoking the CLI.
|
|
# ---------------------------------------------------------------------------
|
|
_daytona_headless_env() {
|
|
local app="$1"
|
|
# local agent="$2" # unused but part of the interface
|
|
|
|
printf 'export DAYTONA_SANDBOX_NAME="%s"\n' "${app}"
|
|
printf 'export DAYTONA_SANDBOX_SIZE="%s"\n' "${DAYTONA_SANDBOX_SIZE:-small}"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _daytona_provision_verify APP LOG_DIR
|
|
#
|
|
# After provisioning, find the sandbox by name, obtain SSH credentials via
|
|
# the ssh-access endpoint, and write metadata files for downstream steps.
|
|
#
|
|
# Writes:
|
|
# $LOG_DIR/$APP.ip — sentinel value "token-auth" (no traditional IP)
|
|
# $LOG_DIR/$APP.meta — JSON with id, sshToken, sshHost, sshPort
|
|
# ---------------------------------------------------------------------------
|
|
_daytona_provision_verify() {
|
|
local app="$1"
|
|
local log_dir="$2"
|
|
|
|
# List sandboxes and find the one matching our app name.
|
|
# The API may return a JSON array directly or an object with items/sandboxes.
|
|
local sandboxes_json
|
|
sandboxes_json=$(curl -sf \
|
|
-H "Authorization: Bearer ${DAYTONA_API_KEY}" \
|
|
"${_DAYTONA_API_BASE}/sandbox" 2>/dev/null || true)
|
|
|
|
if [ -z "${sandboxes_json}" ]; then
|
|
log_err "Failed to list Daytona sandboxes"
|
|
return 1
|
|
fi
|
|
|
|
# Extract sandbox ID by matching on name.
|
|
# Handle both array response and object-with-items response.
|
|
local sandbox_id
|
|
sandbox_id=$(printf '%s' "${sandboxes_json}" | jq -r \
|
|
'(if type == "array" then . else (.items // .sandboxes // []) end)
|
|
| map(select(.name == "'"${app}"'"))
|
|
| first
|
|
| .id // empty' 2>/dev/null || true)
|
|
|
|
if [ -z "${sandbox_id}" ]; then
|
|
log_err "Sandbox '${app}' not found after provisioning"
|
|
return 1
|
|
fi
|
|
|
|
log_ok "Sandbox found: ${sandbox_id}"
|
|
|
|
# Request SSH access credentials
|
|
local ssh_json
|
|
ssh_json=$(curl -sf -X POST \
|
|
-H "Authorization: Bearer ${DAYTONA_API_KEY}" \
|
|
"${_DAYTONA_API_BASE}/sandbox/${sandbox_id}/ssh-access?expiresInMinutes=480" 2>/dev/null || true)
|
|
|
|
if [ -z "${ssh_json}" ]; then
|
|
log_err "Failed to get SSH access for sandbox ${sandbox_id}"
|
|
return 1
|
|
fi
|
|
|
|
local ssh_token
|
|
ssh_token=$(printf '%s' "${ssh_json}" | jq -r '.token // empty' 2>/dev/null || true)
|
|
|
|
if [ -z "${ssh_token}" ]; then
|
|
log_err "SSH token not found in ssh-access response"
|
|
return 1
|
|
fi
|
|
|
|
# Parse host and port from sshCommand (e.g., "ssh -p 2222 TOKEN@HOST" or "ssh TOKEN@HOST")
|
|
local ssh_command
|
|
ssh_command=$(printf '%s' "${ssh_json}" | jq -r '.sshCommand // empty' 2>/dev/null || true)
|
|
|
|
local ssh_host="ssh.app.daytona.io"
|
|
local ssh_port=""
|
|
|
|
if [ -n "${ssh_command}" ]; then
|
|
# Extract host: last token after @ in the sshCommand
|
|
local host_part
|
|
host_part=$(printf '%s' "${ssh_command}" | sed 's/.*@//')
|
|
if [ -n "${host_part}" ]; then
|
|
ssh_host="${host_part}"
|
|
fi
|
|
|
|
# Extract port if -p flag is present
|
|
local port_part
|
|
port_part=$(printf '%s' "${ssh_command}" | sed -n 's/.*-p[[:space:]]\{1,\}\([0-9]\{1,\}\).*/\1/p')
|
|
if [ -n "${port_part}" ]; then
|
|
ssh_port="${port_part}"
|
|
fi
|
|
fi
|
|
|
|
log_ok "SSH access ready (host: ${ssh_host}${ssh_port:+, port: ${ssh_port}})"
|
|
|
|
# Write sentinel IP file (Daytona uses token-based SSH, not traditional IP)
|
|
printf 'token-auth' > "${log_dir}/${app}.ip"
|
|
|
|
# Write metadata file with SSH connection details
|
|
printf '{"id":"%s","sshToken":"%s","sshHost":"%s","sshPort":"%s"}\n' \
|
|
"${sandbox_id}" "${ssh_token}" "${ssh_host}" "${ssh_port}" \
|
|
> "${log_dir}/${app}.meta"
|
|
|
|
return 0
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _daytona_read_meta APP
|
|
#
|
|
# Internal helper: read SSH connection details from the .meta file.
|
|
# Sets _DT_ID, _DT_TOKEN, _DT_HOST, _DT_PORT variables.
|
|
# Returns 1 if the meta file is missing or unreadable.
|
|
# ---------------------------------------------------------------------------
|
|
_daytona_read_meta() {
|
|
local app="$1"
|
|
|
|
local meta_file="${LOG_DIR:-/tmp}/${app}.meta"
|
|
if [ ! -f "${meta_file}" ]; then
|
|
log_err "Meta file not found: ${meta_file}"
|
|
return 1
|
|
fi
|
|
|
|
_DT_ID=$(jq -r '.id // empty' "${meta_file}" 2>/dev/null || true)
|
|
_DT_TOKEN=$(jq -r '.sshToken // empty' "${meta_file}" 2>/dev/null || true)
|
|
_DT_HOST=$(jq -r '.sshHost // empty' "${meta_file}" 2>/dev/null || true)
|
|
_DT_PORT=$(jq -r '.sshPort // empty' "${meta_file}" 2>/dev/null || true)
|
|
|
|
if [ -z "${_DT_TOKEN}" ] || [ -z "${_DT_HOST}" ]; then
|
|
log_err "Incomplete SSH credentials in meta file for ${app}"
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _daytona_exec APP CMD
|
|
#
|
|
# Run CMD on the Daytona sandbox via SSH using token-based authentication.
|
|
# The token serves as the SSH username; PubkeyAuthentication is disabled.
|
|
# Returns the exit code of the remote command.
|
|
# ---------------------------------------------------------------------------
|
|
_daytona_exec() {
|
|
local app="$1"
|
|
local cmd="$2"
|
|
|
|
_daytona_read_meta "${app}" || return 1
|
|
|
|
local ssh_args=""
|
|
ssh_args="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
|
ssh_args="${ssh_args} -o PubkeyAuthentication=no -o ConnectTimeout=10"
|
|
ssh_args="${ssh_args} -o LogLevel=ERROR"
|
|
|
|
if [ -n "${_DT_PORT}" ]; then
|
|
ssh_args="${ssh_args} -o Port=${_DT_PORT}"
|
|
fi
|
|
|
|
# shellcheck disable=SC2086
|
|
ssh ${ssh_args} "${_DT_TOKEN}@${_DT_HOST}" "${cmd}"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _daytona_exec_long APP CMD TIMEOUT
|
|
#
|
|
# Same as _daytona_exec but with ServerAliveInterval keep-alives and the
|
|
# remote command wrapped in `timeout` for long-running operations.
|
|
# ---------------------------------------------------------------------------
|
|
_daytona_exec_long() {
|
|
local app="$1"
|
|
local cmd="$2"
|
|
local timeout="${3:-120}"
|
|
|
|
_daytona_read_meta "${app}" || return 1
|
|
|
|
local alive_count=$((timeout / 15 + 1))
|
|
|
|
local ssh_args=""
|
|
ssh_args="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
|
ssh_args="${ssh_args} -o PubkeyAuthentication=no -o ConnectTimeout=10"
|
|
ssh_args="${ssh_args} -o LogLevel=ERROR"
|
|
ssh_args="${ssh_args} -o ServerAliveInterval=15 -o ServerAliveCountMax=${alive_count}"
|
|
|
|
if [ -n "${_DT_PORT}" ]; then
|
|
ssh_args="${ssh_args} -o Port=${_DT_PORT}"
|
|
fi
|
|
|
|
# Base64-encode the command to avoid shell injection via single-quote breakout
|
|
local encoded_cmd
|
|
encoded_cmd=$(printf '%s' "${cmd}" | base64 | tr -d '\n')
|
|
|
|
# shellcheck disable=SC2086
|
|
ssh ${ssh_args} "${_DT_TOKEN}@${_DT_HOST}" "timeout ${timeout} bash -c \"\$(printf '%s' '${encoded_cmd}' | base64 -d)\""
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _daytona_teardown APP
|
|
#
|
|
# Delete the Daytona sandbox by ID (read from .meta file) and untrack it.
|
|
# ---------------------------------------------------------------------------
|
|
_daytona_teardown() {
|
|
local app="$1"
|
|
|
|
log_step "Tearing down ${app}..."
|
|
|
|
_daytona_read_meta "${app}" || {
|
|
log_warn "Could not read meta for ${app} — attempting name-based lookup"
|
|
# Fall back to listing sandboxes by name
|
|
local sandboxes_json
|
|
sandboxes_json=$(curl -sf \
|
|
-H "Authorization: Bearer ${DAYTONA_API_KEY}" \
|
|
"${_DAYTONA_API_BASE}/sandbox" 2>/dev/null || true)
|
|
|
|
if [ -n "${sandboxes_json}" ]; then
|
|
_DT_ID=$(printf '%s' "${sandboxes_json}" | jq -r \
|
|
'(if type == "array" then . else (.items // .sandboxes // []) end)
|
|
| map(select(.name == "'"${app}"'"))
|
|
| first
|
|
| .id // empty' 2>/dev/null || true)
|
|
fi
|
|
|
|
if [ -z "${_DT_ID:-}" ]; then
|
|
log_err "Cannot find sandbox ID for ${app}"
|
|
untrack_app "${app}"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Delete the sandbox via API
|
|
curl -sf -X DELETE \
|
|
-H "Authorization: Bearer ${DAYTONA_API_KEY}" \
|
|
"${_DAYTONA_API_BASE}/sandbox/${_DT_ID}" >/dev/null 2>&1 || true
|
|
|
|
# Brief wait for deletion to propagate
|
|
sleep 2
|
|
|
|
# Verify deletion — check if sandbox still exists
|
|
local check_json
|
|
check_json=$(curl -sf \
|
|
-H "Authorization: Bearer ${DAYTONA_API_KEY}" \
|
|
"${_DAYTONA_API_BASE}/sandbox/${_DT_ID}" 2>/dev/null || true)
|
|
|
|
if [ -n "${check_json}" ]; then
|
|
local state
|
|
state=$(printf '%s' "${check_json}" | jq -r '.state // empty' 2>/dev/null || true)
|
|
if [ -n "${state}" ] && [ "${state}" != "deleted" ] && [ "${state}" != "destroyed" ]; then
|
|
log_warn "Sandbox ${app} (${_DT_ID}) may still exist (state: ${state})"
|
|
else
|
|
log_ok "Sandbox ${app} torn down"
|
|
fi
|
|
else
|
|
log_ok "Sandbox ${app} torn down"
|
|
fi
|
|
|
|
untrack_app "${app}"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _daytona_cleanup_stale
|
|
#
|
|
# List all Daytona sandboxes, filter for e2e-* names, and destroy any
|
|
# older than 30 minutes (based on the unix timestamp embedded in the name).
|
|
# ---------------------------------------------------------------------------
|
|
_daytona_cleanup_stale() {
|
|
local now
|
|
now=$(date +%s)
|
|
local max_age=1800 # 30 minutes in seconds
|
|
|
|
# Fetch all sandboxes (handle pagination by requesting a large limit)
|
|
local sandboxes_json
|
|
sandboxes_json=$(curl -sf \
|
|
-H "Authorization: Bearer ${DAYTONA_API_KEY}" \
|
|
"${_DAYTONA_API_BASE}/sandbox?page=1&limit=100" 2>/dev/null || true)
|
|
|
|
if [ -z "${sandboxes_json}" ]; then
|
|
log_info "Could not list sandboxes or no sandboxes found — skipping cleanup"
|
|
return 0
|
|
fi
|
|
|
|
# Extract names and IDs of e2e-* sandboxes as "name:id" pairs
|
|
local e2e_entries
|
|
e2e_entries=$(printf '%s' "${sandboxes_json}" | jq -r \
|
|
'(if type == "array" then . else (.items // .sandboxes // []) end)
|
|
| map(select(.name // "" | startswith("e2e-")))
|
|
| .[]
|
|
| "\(.name):\(.id)"' 2>/dev/null || true)
|
|
|
|
if [ -z "${e2e_entries}" ]; then
|
|
log_ok "No stale e2e sandboxes found"
|
|
return 0
|
|
fi
|
|
|
|
local cleaned=0
|
|
local skipped=0
|
|
|
|
for entry in ${e2e_entries}; do
|
|
local sandbox_name
|
|
sandbox_name=$(printf '%s' "${entry}" | cut -d: -f1)
|
|
local sandbox_id
|
|
sandbox_id=$(printf '%s' "${entry}" | cut -d: -f2-)
|
|
|
|
# Extract timestamp from name: e2e-AGENT-TIMESTAMP
|
|
# The timestamp is the last dash-separated segment
|
|
local ts
|
|
ts=$(printf '%s' "${sandbox_name}" | sed 's/.*-//')
|
|
|
|
# Validate it looks like a unix timestamp (all digits, 10 chars)
|
|
if ! printf '%s' "${ts}" | grep -qE '^[0-9]{10}$'; then
|
|
log_warn "Skipping ${sandbox_name} — cannot parse timestamp"
|
|
skipped=$((skipped + 1))
|
|
continue
|
|
fi
|
|
|
|
local age=$((now - ts))
|
|
if [ "${age}" -gt "${max_age}" ]; then
|
|
local age_str
|
|
age_str=$(format_duration "${age}")
|
|
log_step "Destroying stale sandbox ${sandbox_name} (age: ${age_str})"
|
|
|
|
curl -sf -X DELETE \
|
|
-H "Authorization: Bearer ${DAYTONA_API_KEY}" \
|
|
"${_DAYTONA_API_BASE}/sandbox/${sandbox_id}" >/dev/null 2>&1 || \
|
|
log_warn "Failed to delete sandbox ${sandbox_name} (${sandbox_id})"
|
|
|
|
cleaned=$((cleaned + 1))
|
|
else
|
|
skipped=$((skipped + 1))
|
|
fi
|
|
done
|
|
|
|
if [ "${cleaned}" -gt 0 ]; then
|
|
log_ok "Cleaned ${cleaned} stale sandbox(es)"
|
|
fi
|
|
if [ "${skipped}" -gt 0 ]; then
|
|
log_info "Skipped ${skipped} recent sandbox(es)"
|
|
fi
|
|
}
|