iflytek/astron-agent · error · RuntimeError
Skill resource is unavailable
Error message
Skill resource is unavailable
What it means
validate_skill_resource_url is the trust boundary for server-derived Skill resource URLs. It raises the sentinel RuntimeError('Skill resource is unavailable') whenever the candidate URL fails any check: basic shape (non-empty, <=8192 chars, no CR/LF/TAB), exact-match origin (scheme, host, port from SKILL_RESOURCE_TRUSTED_ORIGIN), path under <origin-path>/<bucket>/skill-files/, or SigV4 presigned query parameters. The generic message deliberately hides which check failed so attackers get no oracle.
Solutions
- Set SKILL_RESOURCE_TRUSTED_ORIGIN and SKILL_RESOURCE_TRUSTED_BUCKET env vars correctly and confirm the URL's scheme/host/effective port match the origin exactly.
- Ensure the URL path decodes to <origin-path>/<bucket>/skill-files/<object-key> with no '..' segments or control characters.
- Regenerate the SigV4 presigned URL so the query contains all six x-amz-* parameters with valid formats (AWS4-HMAC-SHA256, ISO8601 date, 64-hex signature, expires 1..604800).
- Compare the URL against the origin with a script (scheme, hostname, effective port) to find the mismatched component before calling the API.
Example fix
// before (origin mismatch: URL for bucket B against configured bucket A) download_skill_resource(session, "https://cdn.example.com/other-bucket/skill-files/x?...", limit) // after (env: SKILL_RESOURCE_TRUSTED_BUCKET=other-bucket or a URL for the configured bucket) os.environ["SKILL_RESOURCE_TRUSTED_BUCKET"] = "other-bucket" download_skill_resource(session, "https://cdn.example.com/other-bucket/skill-files/x?...", limit)
Defensive patterns
Strategy: validation
Validate before calling
from urllib.parse import urlsplit
import os, re
def can_pass_skill_url_validation(url: str) -> bool:
origin_value = (os.getenv("SKILL_RESOURCE_TRUSTED_ORIGIN") or "").strip()
bucket = (os.getenv("SKILL_RESOURCE_TRUSTED_BUCKET") or "").strip()
c, o = urlsplit(url.strip()), urlsplit(origin_value)
port = lambda p: p.port or (443 if p.scheme == "https" else 80)
path = c.path
return bool(
url and len(url) <= 8192 and not any(ch in url for ch in "\r\n\t")
and re.fullmatch(r"[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]", bucket)
and c.scheme == o.scheme and c.hostname == o.hostname
and port(c) == port(o) and c.username is None and c.password is None
and not c.fragment
and path.startswith(f"{o.path.rstrip('/')}/{bucket}/skill-files/")
and len(path) > len(f"{o.path.rstrip('/')}/{bucket}/skill-files/")
and all(k in c.query for k in ("X-Amz-Algorithm", "X-Amz-Credential", "X-Amz-Date", "X-Amz-Expires", "X-Amz-SignedHeaders", "X-Amz-Signature"))
) Type guard
def is_valid_skill_resource_url(url: object) -> bool:
return isinstance(url, str) and bool(url.strip()) and can_pass_skill_url_validation(url) Try / catch
try:
data = await download_skill_resource(session, url, limit)
except RuntimeError as exc:
if str(exc) == "Skill resource is unavailable":
logger.warning("skill resource rejected by URL validation", extra={"host": urlsplit(url).hostname})
raise SkillResourceUnavailable(url) from exc
raise Prevention
- Always mint presigned URLs against the exact SKILL_RESOURCE_TRUSTED_ORIGIN and bucket path prefix skill-files/.
- Validate env vars SKILL_RESOURCE_TRUSTED_ORIGIN/SKILL_RESOURCE_TRUSTED_BUCKET at service startup, fail fast if empty.
- Keep presign TTL short and download immediately after issuing the URL.
- Never re-encode/decode the signed URL (percent-decoding breaks the signature and path checks).
When it happens
Trigger: A URL is passed whose host/port/scheme differs from SKILL_RESOURCE_TRUSTED_ORIGIN, whose decoded path does not start with <origin-path>/<SKILL_RESOURCE_TRUSTED_BUCKET>/skill-files/ followed by a non-empty key, which contains userinfo, a fragment, invalid percent-escapes, path segments '.'/'..', or whose query lacks/malforms any required SigV4 parameter (x-amz-algorithm!=AWS4-HMAC-SHA256, bad x-amz-date/x-amz-signature format, x-amz-expires outside 1..604800, duplicate or empty query keys). Also raised when the env vars SKILL_RESOURCE_TRUSTED_ORIGIN / SKILL_RESOURCE_TRUSTED_BUCKET are unset/empty/malformed (bucket must match [a-z0-9][a-z0-9.-]{1,61}[a-z0-9]).
Common situations: SKILL_RESOURCE_TRUSTED_ORIGIN or SKILL_RESOURCE_TRUSTED_BUCKET not configured in the deployment environment; presigned URLs generated for a different bucket or path prefix than skill-files/; URLs that expired and were re-signed with a different algorithm or truncated query; a stored URL that was URL-decoded/re-encoded losing the signature; redirect targets (e.g. S3 regional endpoints) whose host differs from the trusted origin.
Understand the failure class
Background: "Invalid URL" / "URL cannot be empty": fix the malformed or missing URL behind request-construction failures — this error's family across 50 libraries.
Related errors
- Skill resource URL is not allowed
- MODEL_URL_CHECK_FAILED
- MODEL_URL_CHECK_FAILED
- TOOLBOX_URL_HTTP_HTTPS_ONLY
- MODEL_URL_ILLEGAL_FAILED
AI-assisted analysis of iflytek/astron-agent@5e758547a8 (2026-09-12).
Data as JSON: /api/errors/6babb8a021bb3fbc.
Report an issue: GitHub.
Appendix: source
Thrown at core/agent/service/plugin/skill_resource_security.py:145
expiry = int(parameters["x-amz-expires"])
if not 1 <= expiry <= 604800:
raise ValueError
def validate_skill_resource_url(url: str) -> str:
"""Require the configured origin, console bucket, Skill prefix, and SigV4 shape."""
candidate_value = str(url or "").strip()
origin_value = (os.getenv(SKILL_RESOURCE_TRUSTED_ORIGIN_ENV) or "").strip()
bucket = (os.getenv(SKILL_RESOURCE_TRUSTED_BUCKET_ENV) or "").strip()
try:
_validate_resource_inputs(candidate_value, origin_value, bucket)
candidate = urlsplit(candidate_value)
origin = urlsplit(origin_value)
_validate_resource_origin(candidate, origin)
_validate_resource_path(candidate, origin, bucket)
_validate_sigv4_parameters(_parse_sigv4_parameters(candidate.query))
except (TypeError, ValueError):
raise RuntimeError(SKILL_RESOURCE_ERROR) from None
return candidate_value
async def read_bounded_response(response: aiohttp.ClientResponse, limit: int) -> bytes:
"""Stream at most ``limit + 1`` bytes so absent Content-Length stays bounded."""
if limit < 1 or limit > MAX_SKILL_RESOURCE_BYTES:
raise RuntimeError(SKILL_RESOURCE_ERROR)
content_length = response.content_length
if content_length is not None and content_length > limit:
raise RuntimeError(SKILL_RESOURCE_ERROR)
value = bytearray()
async for chunk in response.content.iter_chunked(64 * 1024):
remaining = limit + 1 - len(value)
if remaining <= 0:
break
value.extend(chunk[:remaining])
if len(value) > limit:
raise RuntimeError(SKILL_RESOURCE_ERROR)View on GitHub (pinned to 5e758547a8)