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

  1. 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.
  2. Ensure the URL path decodes to <origin-path>/<bucket>/skill-files/<object-key> with no '..' segments or control characters.
  3. 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).
  4. 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

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


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)