🔄 synced local 'skyvern/' with remote 'skyvern/'

This commit is contained in:
Shuchang Zheng 2026-07-08 00:24:02 +00:00
parent 5142883ed6
commit c61ffb509d
8 changed files with 433 additions and 109 deletions

View file

@ -6,6 +6,7 @@ import re
import shutil
import tempfile
import zipfile
from http import HTTPStatus
from pathlib import Path
from typing import TYPE_CHECKING
from urllib.parse import parse_qsl, unquote, urlparse
@ -17,9 +18,20 @@ from yarl import URL
from skyvern.config import settings
from skyvern.constants import BROWSER_DOWNLOAD_TIMEOUT, BROWSER_DOWNLOADING_SUFFIX, REPO_ROOT_DIR
from skyvern.exceptions import DownloadFileMaxSizeExceeded, DownloadFileMaxWaitingTime
from skyvern.exceptions import DownloadFileMaxSizeExceeded, DownloadFileMaxWaitingTime, SkyvernHTTPException
from skyvern.forge import app
from skyvern.utils.url_validators import encode_url
from skyvern.forge.sdk.core.aiohttp_helper import (
SSRFGuardedResolver,
ssrf_guarded_tcp_connector,
strip_cross_origin_redirect_credentials,
validate_and_pin_fetch_url,
validate_and_pin_redirect_url,
)
from skyvern.utils.url_validators import (
MAX_SAFE_REDIRECTS,
SAFE_REDIRECT_STATUS_CODES,
encode_url,
)
if TYPE_CHECKING:
from skyvern.forge.sdk.core.skyvern_context import SkyvernContext
@ -209,70 +221,90 @@ async def download_file(
LOG.info("Downloading file from local file system", url=url)
return local_path
async with aiohttp.ClientSession(raise_for_status=True) as session:
resolver = SSRFGuardedResolver()
current_url = await validate_and_pin_fetch_url(url, resolver)
request_headers = dict(headers or {})
async with aiohttp.ClientSession(
raise_for_status=True, connector=ssrf_guarded_tcp_connector(resolver)
) as session:
LOG.info("Starting to download file", url=url)
encoded_url = encode_url(url)
async with session.get(URL(encoded_url, encoded=True), headers=headers) as response:
# Check the content length if available
if max_size_mb and response.content_length and response.content_length > max_size_mb * 1024 * 1024:
# todo: move to root exception.py
raise DownloadFileMaxSizeExceeded(max_size_mb)
for _ in range(MAX_SAFE_REDIRECTS + 1):
encoded_url = encode_url(current_url)
async with session.get(
URL(encoded_url, encoded=True), headers=request_headers, allow_redirects=False
) as response:
if response.status in SAFE_REDIRECT_STATUS_CODES and response.headers.get("Location"):
next_url = await validate_and_pin_redirect_url(
current_url, response.headers["Location"], resolver
)
request_headers, _ = strip_cross_origin_redirect_credentials(
request_headers, None, current_url, next_url
)
current_url = next_url
continue
# Get the file name
if output_dir:
download_dir_path = Path(output_dir)
download_dir_path.mkdir(parents=True, exist_ok=True)
else:
download_dir_path = Path(make_temp_directory(prefix="skyvern_downloads_"))
# Check the content length if available
if max_size_mb and response.content_length and response.content_length > max_size_mb * 1024 * 1024:
# todo: move to root exception.py
raise DownloadFileMaxSizeExceeded(max_size_mb)
download_dir_resolved = download_dir_path.resolve()
# response.headers stays a CIMultiDictProxy: dict() would make header
# lookups case-sensitive and miss lowercase wire headers.
file_name = _determine_download_filename(filename, response.headers, url)
allowed_dir = os.path.realpath(download_dir_resolved)
resolved_final_path = os.path.realpath(os.path.join(allowed_dir, file_name))
# sanitize_filename strips separators but keeps dots, so a dots-only name can
# still resolve outside the download dir; require a direct child. Checked
# before streaming so a rejected name downloads zero bytes.
if (
resolved_final_path == allowed_dir
or not resolved_final_path.startswith(allowed_dir + os.sep)
or os.path.dirname(resolved_final_path) != allowed_dir
):
raise ValueError(f"Unsafe filename derived from download: {file_name!r}")
final_path = Path(resolved_final_path)
# Get the file name
if output_dir:
download_dir_path = Path(output_dir)
download_dir_path.mkdir(parents=True, exist_ok=True)
else:
download_dir_path = Path(make_temp_directory(prefix="skyvern_downloads_"))
temp_file = tempfile.NamedTemporaryFile(mode="wb", dir=download_dir_resolved, delete=False)
file_path = Path(temp_file.name).resolve()
if file_path != download_dir_resolved and not file_path.is_relative_to(download_dir_resolved):
temp_file.close()
raise ValueError("Unsafe temporary file path created for download")
download_dir_resolved = download_dir_path.resolve()
# response.headers stays a CIMultiDictProxy: dict() would make header
# lookups case-sensitive and miss lowercase wire headers.
file_name = _determine_download_filename(filename, response.headers, url)
final_path = (download_dir_resolved / file_name).resolve()
# sanitize_filename strips separators but keeps dots, so a dots-only name can
# still resolve outside the download dir; require a direct child. Checked
# before streaming so a rejected name downloads zero bytes.
if (
not final_path.is_relative_to(download_dir_resolved)
or final_path.parent != download_dir_resolved
):
raise ValueError(f"Unsafe filename derived from download: {file_name!r}")
LOG.info("Downloading file to temporary path", file_path=str(file_path))
try:
with temp_file as f:
# Write the content of the request into the file
total_bytes_downloaded = 0
async for chunk in response.content.iter_chunked(1024):
f.write(chunk)
total_bytes_downloaded += len(chunk)
if max_size_mb and total_bytes_downloaded > max_size_mb * 1024 * 1024:
raise DownloadFileMaxSizeExceeded(max_size_mb)
temp_file = tempfile.NamedTemporaryFile(mode="wb", dir=download_dir_resolved, delete=False)
file_path = Path(temp_file.name).resolve()
if file_path != download_dir_resolved and not file_path.is_relative_to(download_dir_resolved):
temp_file.close()
raise ValueError("Unsafe temporary file path created for download")
file_path.replace(final_path)
except BaseException:
# An orphaned temp file in a run download dir would get synced to storage
# under the tmpXXXX name; drop it on any failure (incl. cancellation).
file_path.unlink(missing_ok=True)
raise
LOG.info("Downloading file to temporary path", file_path=str(file_path))
try:
with temp_file as f:
# Write the content of the request into the file
total_bytes_downloaded = 0
async for chunk in response.content.iter_chunked(1024):
f.write(chunk)
total_bytes_downloaded += len(chunk)
if max_size_mb and total_bytes_downloaded > max_size_mb * 1024 * 1024:
raise DownloadFileMaxSizeExceeded(max_size_mb)
LOG.info(f"File downloaded successfully to {final_path}")
return str(final_path)
file_path.replace(final_path)
except BaseException:
# An orphaned temp file in a run download dir would get synced to storage
# under the tmpXXXX name; drop it on any failure (incl. cancellation).
file_path.unlink(missing_ok=True)
raise
LOG.info(f"File downloaded successfully to {final_path}")
return str(final_path)
raise SkyvernHTTPException(
message=f"Too many redirects while downloading file: {current_url}",
status_code=HTTPStatus.BAD_REQUEST,
)
except aiohttp.ClientResponseError as e:
LOG.exception(f"Failed to download file, status code: {e.status}")
# Re-raised and handled at the action/block boundary; server rejections are external.
LOG.warning(f"Failed to download file, status code: {e.status}", exc_info=True)
raise
except DownloadFileMaxSizeExceeded as e:
LOG.exception(f"Failed to download file, max size exceeded: {e.max_size}")
LOG.warning(f"Failed to download file, max size exceeded: {e.max_size}", exc_info=True)
raise
except PermissionError as e:
LOG.warning(
@ -282,6 +314,10 @@ async def download_file(
reason=str(e),
)
raise
except aiohttp.InvalidURL:
# Malformed customer-provided URL - a client-data error, not a platform fault.
LOG.warning("Failed to download file, invalid URL", exc_info=True)
raise
except Exception:
LOG.exception("Failed to download file")
raise

View file

@ -1,15 +1,124 @@
import asyncio
import ipaddress
import os
import socket
from typing import Any
from urllib.parse import urlparse
import aiofiles
import aiohttp
import structlog
from aiohttp.abc import AbstractResolver, ResolveResult
from aiohttp.resolver import DefaultResolver
from skyvern.exceptions import HttpException
from skyvern.exceptions import HttpException, InvalidUrl
from skyvern.utils.url_validators import (
MAX_SAFE_REDIRECTS,
SAFE_REDIRECT_STATUS_CODES,
resolve_fetch_host_ips,
validate_fetch_url_with_resolved_ips,
validate_redirect_url_with_resolved_ips,
)
LOG = structlog.get_logger()
DEFAULT_REQUEST_TIMEOUT = 30
_REDIRECT_CREDENTIAL_HEADERS = {"authorization", "cookie"}
def _url_origin(url: str) -> tuple[str, str, int | None]:
parsed = urlparse(url)
scheme = parsed.scheme.lower()
port = parsed.port
if port is None:
port = 443 if scheme == "https" else 80 if scheme == "http" else None
return scheme, (parsed.hostname or "").lower().rstrip("."), port
def strip_cross_origin_redirect_credentials(
headers: dict[str, str],
cookies: dict[str, str] | None,
current_url: str,
next_url: str,
) -> tuple[dict[str, str], dict[str, str] | None]:
if _url_origin(current_url) == _url_origin(next_url):
return headers, cookies
return {key: value for key, value in headers.items() if key.lower() not in _REDIRECT_CREDENTIAL_HEADERS}, None
class SSRFGuardedResolver(AbstractResolver):
def __init__(self) -> None:
self._pinned_host_ips: dict[str, tuple[str, ...]] = {}
self._trusted_proxy_hosts: set[str] = set()
self._default_resolver = DefaultResolver()
@staticmethod
def _host_key(host: str) -> str:
return host.strip().lower().rstrip(".")
def pin_host_ips(self, host: str, ips: tuple[str, ...]) -> None:
if not ips:
raise OSError(f"No safe addresses resolved for host: {host}")
self._pinned_host_ips[self._host_key(host)] = ips
def pin_url_ips(self, url: str, ips: tuple[str, ...]) -> None:
host = urlparse(url).hostname
if not host:
raise InvalidUrl(url=url)
self.pin_host_ips(host, ips)
def trust_proxy_url(self, proxy: str) -> None:
host = urlparse(proxy).hostname
if host:
self._trusted_proxy_hosts.add(self._host_key(host))
async def resolve(
self, host: str, port: int = 0, family: socket.AddressFamily = socket.AF_UNSPEC
) -> list[ResolveResult]:
results: list[ResolveResult] = []
host_key = self._host_key(host)
if host_key in self._trusted_proxy_hosts:
return await self._default_resolver.resolve(host, port, family)
ips = self._pinned_host_ips.get(host_key)
resolved_ips = await asyncio.to_thread(resolve_fetch_host_ips, host) if ips is None else ips
for ip in resolved_ips:
ip_family = (
socket.AF_INET6 if isinstance(ipaddress.ip_address(ip), ipaddress.IPv6Address) else socket.AF_INET
)
if family not in (socket.AF_UNSPEC, ip_family):
continue
results.append(
{
"hostname": host,
"host": ip,
"port": port,
"family": ip_family,
"proto": 0,
"flags": 0,
}
)
if not results:
raise OSError(f"No safe addresses resolved for host: {host}")
return results
async def close(self) -> None:
await self._default_resolver.close()
async def validate_and_pin_fetch_url(url: str, resolver: SSRFGuardedResolver) -> str:
validated_url, ips = await asyncio.to_thread(validate_fetch_url_with_resolved_ips, url)
resolver.pin_url_ips(validated_url, ips)
return validated_url
async def validate_and_pin_redirect_url(url: str, location: str, resolver: SSRFGuardedResolver) -> str:
validated_url, ips = await asyncio.to_thread(validate_redirect_url_with_resolved_ips, url, location)
resolver.pin_url_ips(validated_url, ips)
return validated_url
def ssrf_guarded_tcp_connector(resolver: SSRFGuardedResolver | None = None) -> aiohttp.TCPConnector:
return aiohttp.TCPConnector(resolver=resolver or SSRFGuardedResolver(), use_dns_cache=False)
async def aiohttp_request(
@ -44,64 +153,86 @@ async def aiohttp_request(
Tuple of (status_code, response_headers, response_body)
where response_body can be dict (for JSON) or str (for text)
"""
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session:
# Ensure headers is always a dict for type safety
headers_dict: dict[str, str] = headers or {}
request_kwargs: dict[str, Any] = {
"url": url,
"headers": headers_dict,
"cookies": cookies,
"proxy": proxy,
"allow_redirects": follow_redirects,
}
resolver = SSRFGuardedResolver()
if proxy:
resolver.trust_proxy_url(proxy)
current_url = await validate_and_pin_fetch_url(url, resolver)
request_method = method.upper()
request_headers = dict(headers or {})
request_cookies = cookies
strip_body_headers = False
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=timeout), connector=ssrf_guarded_tcp_connector(resolver)
) as session:
async def build_request_kwargs() -> dict[str, Any]:
headers_dict = dict(request_headers)
if strip_body_headers:
headers_dict.pop("Content-Length", None)
headers_dict.pop("content-length", None)
request_kwargs: dict[str, Any] = {
"headers": headers_dict,
"cookies": request_cookies,
"proxy": proxy,
"allow_redirects": False,
}
if request_method == "GET":
return request_kwargs
# Handle body based on content type and method
if method.upper() != "GET":
# If files are provided, use multipart/form-data
if files:
form = aiohttp.FormData()
# Add files to form
for field_name, file_path in files.items():
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
filename = os.path.basename(file_path)
async with aiofiles.open(file_path, "rb") as f:
file_content = await f.read()
form.add_field(field_name, file_content, filename=filename)
form.add_field(field_name, await f.read(), filename=filename)
# Add data fields to form if provided
if data is not None and isinstance(data, dict):
for key, value in data.items():
form.add_field(key, str(value))
request_kwargs["data"] = form
# Remove Content-Type header if present, let aiohttp set it for multipart
# headers_dict is already typed as dict[str, str] from initialization
if "Content-Type" in headers_dict:
del headers_dict["Content-Type"]
if "content-type" in headers_dict:
del headers_dict["content-type"]
# Explicit overrides first
headers_dict.pop("Content-Type", None)
headers_dict.pop("content-type", None)
elif json_data is not None:
request_kwargs["json"] = json_data
elif data is not None:
content_type = headers_dict.get("Content-Type") or headers_dict.get("content-type") or ""
if "application/json" in content_type.lower():
request_kwargs["json"] = data
else:
request_kwargs["data"] = data
request_kwargs["json" if "application/json" in content_type.lower() else "data"] = data
return request_kwargs
async with session.request(method.upper(), **request_kwargs) as response:
response_headers = dict(response.headers)
for _ in range(MAX_SAFE_REDIRECTS + 1):
request_kwargs = await build_request_kwargs()
request_kwargs["url"] = current_url
async with session.request(request_method, **request_kwargs) as response:
if (
follow_redirects
and response.status in SAFE_REDIRECT_STATUS_CODES
and response.headers.get("Location")
):
next_url = await validate_and_pin_redirect_url(current_url, response.headers["Location"], resolver)
request_headers, request_cookies = strip_cross_origin_redirect_credentials(
request_headers, request_cookies, current_url, next_url
)
current_url = next_url
if response.status == 303 or (response.status in (301, 302) and request_method == "POST"):
request_method = "GET"
strip_body_headers = True
continue
try:
response_body = await response.json()
except (aiohttp.ContentTypeError, Exception):
response_body = await response.text()
response_headers = dict(response.headers)
return response.status, response_headers, response_body
try:
response_body = await response.json()
except (aiohttp.ContentTypeError, Exception):
response_body = await response.text()
return response.status, response_headers, response_body
raise HttpException(400, current_url, "Too many redirects while making HTTP request")
async def aiohttp_get_json(

View file

@ -364,7 +364,8 @@ async def resolve_org_from_api_key(
else:
normalized_api_key, normalization_flags = _normalize_api_key_with_flags(x_api_key)
api_key_debug_fields = _get_api_key_debug_fields(x_api_key, normalized_api_key, normalization_flags)
LOG.error(
# Malformed client key rejected with 403 — a client error, not a server fault.
LOG.warning(
"Error decoding JWT",
error_type=type(exc).__name__,
error_reason=_get_safe_auth_error_reason(exc),

View file

@ -4388,7 +4388,8 @@ class WorkflowService:
workflow_run = await self.mark_workflow_run_as_canceled(workflow_run_id=workflow_run_id)
return workflow_run, True
if block_result.status == BlockStatus.failed:
LOG.error(
# Run-level outcome, recorded as the run's failure_reason below; not a platform fault.
LOG.warning(
f"Block with type {block.block_type} at index {block_idx}/{blocks_cnt - 1} failed for workflow run {workflow_run_id}",
block_type=block.block_type,
workflow_run_id=workflow_run_id,

View file

@ -1372,7 +1372,8 @@ async def handle_block_result(
)
elif block_result.status == BlockStatus.failed:
LOG.error(
# Run-level outcome, surfaced via the run's failure handling below; not a platform fault.
LOG.warning(
f"Block with type {block.block_type} failed for workflow run {workflow_run_id}",
block_type=block.block_type,
workflow_run_id=workflow_run.workflow_run_id,

View file

@ -1,12 +1,19 @@
import ipaddress
import socket
from http import HTTPStatus
from urllib.parse import quote, urlparse, urlsplit, urlunsplit
from urllib.parse import quote, urljoin, urlparse, urlsplit, urlunsplit
from pydantic import HttpUrl, ValidationError
from skyvern.config import settings
from skyvern.exceptions import BlockedHost, InvalidUrl, SkyvernHTTPException
SAFE_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
MAX_SAFE_REDIRECTS = 10
_BLOCKED_INTERNAL_HOSTNAMES = frozenset({"localhost", "metadata.google.internal", "kubernetes.default.svc"})
_BLOCKED_INTERNAL_SUFFIXES = (".local", ".localhost", ".internal", ".cluster.local")
def strip_query_params(url: str) -> str:
"""Return scheme://host/path with query string, fragment, and userinfo removed.
@ -65,32 +72,146 @@ def prepend_scheme_and_validate_url(url: str) -> str:
return url
def is_blocked_host(host: str) -> bool:
def _normalize_host(host: str) -> str:
# RFC 3986 wraps IPv6 literals in [...]; ip_address() only accepts the bare form.
bare = host[1:-1] if host.startswith("[") and host.endswith("]") else host
return (host[1:-1] if host.startswith("[") and host.endswith("]") else host).strip().lower().rstrip(".")
def _normalize_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
return ip.ipv4_mapped
return ip
def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
ip = _normalize_ip(ip)
return bool(
ip.is_private or ip.is_link_local or ip.is_loopback or ip.is_reserved or ip.is_multicast or ip.is_unspecified
)
def _is_allowed_host(host: str) -> bool:
normalized = _normalize_host(host)
ip: ipaddress.IPv4Address | ipaddress.IPv6Address | None
try:
ip = ipaddress.ip_address(bare)
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
ip = ip.ipv4_mapped
ip = _normalize_ip(ipaddress.ip_address(normalized))
except ValueError:
ip = None
except Exception:
return False
candidate_forms = {host.lower(), bare.lower()}
candidate_forms = {host.lower(), normalized}
if ip is not None:
candidate_forms.add(str(ip).lower())
allowed = {h.lower() for h in settings.ALLOWED_HOSTS}
if candidate_forms & allowed:
return bool(candidate_forms & allowed)
def _is_internal_hostname(host: str) -> bool:
normalized = _normalize_host(host)
if normalized in _BLOCKED_INTERNAL_HOSTNAMES:
return True
if normalized.endswith(_BLOCKED_INTERNAL_SUFFIXES):
return True
return normalized.endswith(".svc")
def is_blocked_host(host: str, *, resolve_dns: bool = False) -> bool:
normalized = _normalize_host(host)
if not normalized:
return True
if _is_allowed_host(host):
return False
if ip is not None:
return ip.is_private or ip.is_link_local or ip.is_loopback or ip.is_reserved
blocked = {b.lower().rstrip(".") for b in settings.BLOCKED_HOSTS}
if normalized in blocked or _is_internal_hostname(normalized):
return True
blocked = {b.lower() for b in settings.BLOCKED_HOSTS}
return host.lower() in blocked
ip: ipaddress.IPv4Address | ipaddress.IPv6Address | None
try:
ip = ipaddress.ip_address(normalized)
except ValueError:
ip = None
except Exception:
return True
if ip is not None:
return _is_blocked_ip(ip)
if not resolve_dns:
return False
try:
infos = socket.getaddrinfo(normalized, None, type=socket.SOCK_STREAM)
except OSError:
return True
resolved_any = False
for info in infos:
sockaddr = info[4]
ip_str = sockaddr[0] if sockaddr else None
if not ip_str:
continue
try:
resolved_ip = ipaddress.ip_address(ip_str)
except ValueError:
continue
resolved_any = True
if _is_blocked_ip(resolved_ip):
return True
return not resolved_any
def resolve_fetch_host_ips(host: str) -> tuple[str, ...]:
normalized = _normalize_host(host)
if not normalized:
raise BlockedHost(host=host)
allowed = _is_allowed_host(host)
if not allowed and (normalized in {b.lower().rstrip(".") for b in settings.BLOCKED_HOSTS}):
raise BlockedHost(host=host)
if not allowed and _is_internal_hostname(normalized):
raise BlockedHost(host=host)
try:
ip = ipaddress.ip_address(normalized)
except ValueError:
ip = None
except Exception:
raise BlockedHost(host=host)
if ip is not None:
normalized_ip = _normalize_ip(ip)
if not allowed and _is_blocked_ip(normalized_ip):
raise BlockedHost(host=host)
return (str(normalized_ip),)
try:
infos = socket.getaddrinfo(normalized, None, type=socket.SOCK_STREAM)
except OSError:
raise BlockedHost(host=host)
resolved_ips: list[str] = []
for info in infos:
sockaddr = info[4]
ip_str = sockaddr[0] if sockaddr else None
if not ip_str:
continue
try:
resolved_ip = _normalize_ip(ipaddress.ip_address(ip_str))
except ValueError:
continue
if not allowed and _is_blocked_ip(resolved_ip):
raise BlockedHost(host=host)
resolved_ip_str = str(resolved_ip)
if resolved_ip_str not in resolved_ips:
resolved_ips.append(resolved_ip_str)
if not resolved_ips:
raise BlockedHost(host=host)
return tuple(resolved_ips)
def validate_url(url: str) -> str | None:
@ -109,6 +230,30 @@ def validate_url(url: str) -> str | None:
return str(v)
def validate_fetch_url_with_resolved_ips(url: str) -> tuple[str, tuple[str, ...]]:
try:
url = prepend_scheme_and_validate_url(url=url)
v = HttpUrl(url=url)
except Exception as e:
raise SkyvernHTTPException(message=str(e), status_code=HTTPStatus.BAD_REQUEST)
if not v.host:
raise InvalidUrl(url=url)
return str(v), resolve_fetch_host_ips(v.host)
def validate_fetch_url(url: str) -> str:
return validate_fetch_url_with_resolved_ips(url)[0]
def validate_redirect_url_with_resolved_ips(url: str, location: str) -> tuple[str, tuple[str, ...]]:
return validate_fetch_url_with_resolved_ips(urljoin(url, location))
def validate_redirect_url(url: str, location: str) -> str:
return validate_redirect_url_with_resolved_ips(url, location)[0]
def encode_url(url: str) -> str:
parts = list(urlsplit(url))
# Encode the path while preserving "/" and "%"

View file

@ -61,6 +61,7 @@ from skyvern.exceptions import (
NoSuitableAutoCompleteOption,
OptionIndexOutOfBound,
PhoneNumberInputMismatch,
SkyvernException,
)
from skyvern.experimentation.wait_utils import get_or_create_wait_config, get_wait_time
from skyvern.forge import app
@ -3748,6 +3749,12 @@ async def handle_select_option_action(
return results
suggested_value = result.value
except SkyvernException as e:
# Expected selection outcomes on non-standard dropdowns (no matching option,
# no incremental elements); recorded as ActionFailure like any other miss.
LOG.warning("Custom select error", exc_info=True)
results.append(ActionFailure(exception=e))
return results
except Exception as e:
LOG.exception("Custom select error")
results.append(ActionFailure(exception=e))

View file

@ -27,10 +27,12 @@ async def download_file(
try:
return await download_file_api(file_url, organization_id=organization_id)
except Exception:
LOG.exception(
# Fully self-recovering: the action proceeds without the file.
LOG.warning(
"Failed to download file, continuing without it",
action=action,
file_url=file_url,
exc_info=True,
)
return []