Files
hermes-agent/hermes_cli/release_channels.py
teknium1 d1484ab44b fix(update): only an explicit hermes update retries channel reads
Passive checks (`hermes --version`, the banner, the Desktop/dashboard
update check) now make one channel-read attempt again. Offline usually
surfaces as DNS EAI_AGAIN or ENETUNREACH, which pm.network.is_transient
classes as transient, so wrapping every read in retry_network added 7 s
of backoff to the synchronous version line, stretched a hung CDN from
30 s to ~127 s, and logged a WARNING to stderr on every retry.

`release_channels.retrying_reads()` opts a block in; update_cmd wraps
the channel resolution of `hermes update` in it. Tests: keep the
Retry-After retry test (now scoped to the update path) and replace the
budget-exhaustion test with one pinning that passive reads and 404s make
exactly one attempt.
2026-09-27 03:41:42 -07:00

401 lines
19 KiB
Python

"""R2 channel wire protocol. Names are data; no local channel registry exists."""
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
import hashlib
import json
import re
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener
MAX_SEQUENCE = 2**32 - 1
MAX_METADATA = 4 * 1024 * 1024
_POLICIES = ("preview", "stable-release", "canary-release", "source-branch")
_RESERVED = {"con", "prn", "aux", "nul", *(f"com{i}" for i in range(1, 10)),
*(f"lpt{i}" for i in range(1, 10))}
class ChannelError(ValueError):
"""Invalid, unavailable, or untrusted channel metadata (never a fallback)."""
class ChannelNotFound(ChannelError):
pass
def _match(pattern: str, value: object, label: str) -> str:
if not isinstance(value, str) or re.fullmatch(pattern, value, re.ASCII) is None:
raise ChannelError(f"Invalid {label}")
return value
def validate_name(value: object) -> str:
name = _match(r"[a-z0-9]+(?:-[a-z0-9]+)*", value, "channel name")
if len(name) > 32 or name in _RESERVED:
raise ChannelError("Invalid channel name")
return name
def validate_repository(value: object) -> str:
return _match(r"[A-Za-z0-9][A-Za-z0-9_-]*/[A-Za-z0-9][A-Za-z0-9_.-]*", value, "repository")
def require_sha256(value: object) -> str:
return _match(r"[a-f0-9]{64}", value, "SHA256")
def require_commit(value: object) -> str:
return _match(r"[a-f0-9]{40}", value, "commit")
def build_prefix(build_id: object) -> str:
return "releases/channel-builds/" + _match(r"[a-f0-9]{32}", build_id, "build ID") + "/"
def channel_key(name: object) -> str:
return f"releases/channels/{validate_name(name)}.json"
def canonical_json(value: object) -> bytes:
return (json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False) + "\n").encode()
def decode_json(body: bytes) -> dict:
def pairs(items):
result = {}
for key, value in items:
if key in result:
raise ChannelError("Duplicate JSON key")
result[key] = value
return result
try:
value = json.loads(body, object_pairs_hook=pairs)
except (ValueError, UnicodeError) as exc:
raise ChannelError("Invalid channel JSON") from exc
if not isinstance(value, dict):
raise ChannelError("Channel metadata must be an object")
return value
def public_base(value: str) -> str:
parsed = urlsplit(value)
if (not parsed.hostname or parsed.username is not None or parsed.password is not None
or parsed.query or parsed.fragment or any(c in value for c in "%\\;")
or any(ord(c) <= 32 for c in value)
or any(p in (".", "..") for p in parsed.path.split("/"))
or not (parsed.scheme == "https" or (parsed.scheme == "http"
and parsed.hostname in ("127.0.0.1", "localhost", "::1")))):
raise ChannelError("Invalid channel archive authority")
return value.rstrip("/")
def artifact_key(value: object) -> str:
key = _match(r"[A-Za-z0-9_./+-]+", value, "artifact key")
if not key.startswith("releases/") or any(p in ("", ".", "..") for p in key.split("/")):
raise ChannelError("Invalid artifact key")
if any(p.split(".")[0].lower() in _RESERVED or p.endswith(".") for p in key.split("/")):
raise ChannelError("Invalid artifact key")
return key
def package_versions(sequence: int) -> tuple[str, str]:
_integer(sequence, "sequence", 1, MAX_SEQUENCE)
return f"0.0.{sequence}", f"0.{sequence // 65536}.{sequence % 65536}.0"
def _integer(value: object, label: str, minimum: int = 1, maximum: int = MAX_SEQUENCE + 1) -> int:
if type(value) is not int or not minimum <= value <= maximum:
raise ChannelError(f"Invalid {label}")
return value
def _schema(value: object) -> dict:
if not isinstance(value, dict) or type(value.get("schema")) is not int or value["schema"] != 1:
raise ChannelError("Unsupported channel schema")
return value
def validate_identity(value: object) -> dict:
if not isinstance(value, dict):
raise ChannelError("Invalid channel identity")
_match(r"[a-f0-9]{16}", value.get("token"), "identity token")
_match(r"[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}", value.get("displayName"), "identity display name")
for field in ("appNamePascal", "artifactNamePascal"):
_match(r"[A-Za-z][A-Za-z0-9]{0,63}", value.get(field), field)
_match(r"[a-z][a-z0-9.-]{2,127}", value.get("appId"), "app ID")
_match(r"[A-Za-z][A-Za-z0-9.-]{2,49}", value.get("msixAppIdWithOrg"), "MSIX identity")
_match(r"[a-z][a-z0-9-]{0,63}", value.get("cliName"), "CLI name")
_match(r"[A-Za-z][A-Za-z0-9 ._-]{0,79}", value.get("windowsExecutableName"), "Windows executable")
return value
def validate_request(value: object, *, repository: str | None = None,
base_url: str | None = None, policy: str = "preview") -> dict:
request = _schema(value)
build_prefix(request.get("buildId"))
validate_name(request.get("channel"))
actual_repo = validate_repository(request.get("repository"))
if repository is not None and actual_repo.casefold() != repository.casefold():
raise ChannelError("Channel repository authority mismatch")
require_commit(request.get("commit"))
if "controllerCommit" in request:
require_commit(request["controllerCommit"])
_match(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", request.get("sourceVersion"), "source version")
versions = package_versions(request.get("sequence"))
if policy == "preview":
if (request.get("version"), request.get("windowsVersion")) != versions:
raise ChannelError("Channel package version does not match sequence")
else:
_match(
r"v[0-9]+\.[0-9]+\.[0-9]+(?:\+canary\.20[0-9]{6}T[0-9]{6}Z)?",
request.get("releaseTag"),
"release tag",
)
if request.get("version") != request["releaseTag"][1:]:
raise ChannelError("Release package version mismatch")
archive_ref = request.get("archiveRef")
if archive_ref is not None:
from scripts.releases.versioning import parse_attempt_ref
parsed = parse_attempt_ref(archive_ref) if isinstance(archive_ref, str) else None
if parsed is None:
if archive_ref != request["releaseTag"]:
raise ChannelError("Invalid archive ref")
elif parsed[0] != request["version"]:
raise ChannelError("Archive ref does not name the release version")
# Stable uses the Store quad with revision zero; canary uses its UTC
# yy.mmdd.hh.mmss package version.
pattern = r"[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" if policy == "canary-release" else r"[0-9]+\.[0-9]+\.[0-9]+\.0"
quad = _match(pattern, request.get("windowsVersion"), "Windows version")
if any(int(part) > 65535 for part in quad.split(".")):
raise ChannelError("Invalid Windows version")
validate_identity(request.get("identity"))
values = request.get("bundleEnv")
if not isinstance(values, dict):
raise ChannelError("Invalid bundle environment")
for key, item in values.items():
_match(r"[A-Za-z_][A-Za-z0-9_]*", key, "bundle environment name")
if item is not None and (not isinstance(item, str) or "\0" in item):
raise ChannelError("Invalid bundle environment value")
base = request.get("publicBase")
if not isinstance(base, str) or public_base(base) != base:
raise ChannelError("Invalid request archive authority")
if base_url is not None and base != public_base(base_url):
raise ChannelError("Request archive authority mismatch")
return request
def _head(value: object) -> dict | None:
if value is None:
return None
if not isinstance(value, dict):
raise ChannelError("Invalid channel head")
prefix = build_prefix(value.get("buildId"))
_integer(value.get("sequence"), "head sequence", maximum=MAX_SEQUENCE)
if value.get("manifestKey") != prefix + "build.json":
raise ChannelError("Invalid channel manifest key")
require_sha256(value.get("sha256"))
return value
def validate_record(value: object, *, name: str | None = None, repository: str | None = None) -> dict:
record = _schema(value)
if not {"name", "repository", "policy", "state", "revision", "nextSequence", "identity", "head"} <= record.keys():
raise ChannelError("Missing channel record fields")
validate_name(record.get("name"))
if name is not None and record["name"] != name:
raise ChannelError("Channel name does not match object key")
actual_repo = validate_repository(record.get("repository"))
if repository is not None and actual_repo.casefold() != repository.casefold():
raise ChannelError("Channel repository authority mismatch")
if record.get("policy") not in _POLICIES or record.get("state") not in ("active", "retired"):
raise ChannelError("Invalid channel policy or state")
_integer(record.get("revision"), "channel revision", maximum=2**53 - 1)
_integer(record.get("nextSequence"), "next sequence")
head = _head(record.get("head"))
if head is not None and head["sequence"] >= record["nextSequence"]:
raise ChannelError("Channel head exceeds allocation high-water")
if record["policy"] == "source-branch":
delivery = record.get("delivery")
if record.get("identity") is not None or head is not None or not isinstance(delivery, dict) or delivery.get("kind") != "source-branch":
raise ChannelError("Source branch cannot carry native artifacts")
branch = _match(r"[A-Za-z0-9][A-Za-z0-9_./-]*", delivery.get("branch"), "source branch")
if ".." in branch or "//" in branch or branch.endswith(("/", ".", ".lock")):
raise ChannelError("Invalid source branch")
else:
validate_identity(record.get("identity"))
if record["state"] == "retired":
validate_name(record.get("destination"))
_match(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", record.get("minimumVersion"), "retirement minimum version")
if _head(record.get("destinationHead")) is None:
raise ChannelError("Retirement requires a pinned destination head")
if type(record.get("receiverProtocol")) is not int or record["receiverProtocol"] != 1:
raise ChannelError("Unsupported retirement receiver protocol")
receiver = record.get("receiver")
if not isinstance(receiver, dict) or receiver.get("kind") not in ("in-place", "discontinued"):
raise ChannelError("Invalid retirement receiver kind")
if _head(record.get("lastHead")) != head:
raise ChannelError("Retirement last head mismatch")
return record
@dataclass(frozen=True)
class ChannelResolution:
requested: dict
terminal: dict
manifest: dict | None
class _NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise ChannelError("Channel archive redirects are not permitted")
_RETRY_READS: ContextVar[bool] = ContextVar("release_channel_read_retries", default=False)
@contextmanager
def retrying_reads():
"""Let channel reads inside the block wait out a CDN blip with PM's bounded retries.
Only an explicit ``hermes update`` opts in. Passive checks (``hermes --version``, the
banner, the Desktop/dashboard update check) make one attempt: offline also reads as
transient (DNS EAI_AGAIN, ENETUNREACH), so retrying there adds backoff to a synchronous
status line, stretches a hung CDN from 30 s to ~2 min and logs a WARNING per retry.
"""
token = _RETRY_READS.set(True)
try:
yield
finally:
_RETRY_READS.reset(token)
class ChannelReader:
def __init__(self, base_url: str, repository: str | None = None, opener=None):
self.base_url = public_base(base_url)
self.repository = validate_repository(repository) if repository is not None else None
self.opener = opener or build_opener(_NoRedirect()).open
def read_bytes(self, key: str, sha256: str | None = None) -> bytes:
url = self.base_url + "/" + artifact_key(key)
if sha256 is not None:
require_sha256(sha256)
# Channel reads are idempotent and precede the PM download path, so an explicit update
# uses the same bounded transient-failure policy instead of a second retry classifier.
from pm.network import retry_network
def read() -> bytes:
with self.opener(Request(url, headers={"Cache-Control": "no-cache"}), timeout=30) as response:
if response.geturl() != url:
raise ChannelError("Channel archive redirects are not permitted")
return response.read(MAX_METADATA + 1)
try:
body = retry_network(read) if _RETRY_READS.get() else read()
except HTTPError as exc:
if exc.code == 404:
raise ChannelNotFound(f"Channel object not found: {key}") from exc
raise ChannelError(f"Channel read unavailable: HTTP {exc.code}") from exc
except (OSError, URLError) as exc:
raise ChannelError("Channel read unavailable") from exc
if len(body) > MAX_METADATA:
raise ChannelError("Channel metadata exceeds size limit")
if sha256 is not None and hashlib.sha256(body).hexdigest() != sha256:
raise ChannelError("Channel metadata SHA256 mismatch")
return body
def read_record(self, name: str) -> dict:
return validate_record(decode_json(self.read_bytes(channel_key(name))), name=name, repository=self.repository)
def resolve(self, name: str) -> ChannelResolution:
requested = record = self.read_record(name)
if record["state"] == "retired":
if record["destination"] == name:
raise ChannelError("Channel retirement cycle")
record = self.read_record(record["destination"])
if record["repository"].casefold() != requested["repository"].casefold():
raise ChannelError("Retirement repository authority mismatch")
if record["state"] != "active" or record["policy"] != "stable-release":
raise ChannelError("Retirement requires a directly active stable-release destination")
head = record["head"]
if requested["state"] == "retired":
# The first receiver stays pinned even when stable advances offline.
head = requested["destinationHead"]
if head is None or record["head"] is None or head["sequence"] > record["head"]["sequence"]:
raise ChannelError("Invalid retirement destination head")
manifest = None if head is None else validate_manifest(
decode_json(self.read_bytes(head["manifestKey"], head["sha256"])), {**record, "head": head}, self.base_url)
if requested["state"] == "retired":
assert manifest is not None
if manifest.get("receiverProtocol") != requested["receiverProtocol"]:
raise ChannelError("Stable build has no supported retirement receiver")
actual = tuple(map(int, manifest["request"]["sourceVersion"].split(".")))
floor = tuple(map(int, requested["minimumVersion"].split(".")))
if actual < floor:
raise ChannelError("Destination does not meet retirement minimum version")
return ChannelResolution(requested, record, manifest)
def validate_manifest(value: object, record: dict, base_url: str) -> dict:
manifest = _schema(value)
if "receiverProtocol" in manifest:
_integer(manifest["receiverProtocol"], "receiver protocol", maximum=2**53 - 1)
request = validate_request(manifest.get("request"), repository=record["repository"],
base_url=base_url, policy=record["policy"])
if request["identity"] != record["identity"]:
raise ChannelError("Manifest channel identity mismatch")
if request["channel"] != record["name"]:
raise ChannelError("Manifest channel mismatch")
head = record.get("head")
if head is not None and any(request[field] != head[field] for field in ("buildId", "sequence")):
raise ChannelError("Manifest does not match channel head")
packages = manifest.get("packages")
if not isinstance(packages, list) or not packages:
raise ChannelError("Build manifest has no packages")
prefixes = [build_prefix(request["buildId"])]
if record["policy"] in {"stable-release", "canary-release"}:
# The archive prefix is the attempt ref when the request names one; a
# bare releaseTag fallback never holds attempt artifacts (fail closed).
prefixes.append(f"releases/tag/{request.get('archiveRef') or request['releaseTag']}/")
seen = set()
for package in packages:
if not isinstance(package, dict):
raise ChannelError("Invalid native package")
target = tuple(package.get(field) for field in ("platform", "arch", "variant"))
if target[0] not in ("darwin", "win32") or target[1] not in ("arm64", "x64") or target[2] != "bundled":
raise ChannelError("Unsupported channel package target")
if target in seen:
raise ChannelError("Duplicate package target")
seen.add(target)
artifact = package.get("artifact")
if not isinstance(artifact, dict):
raise ChannelError("Invalid package artifact")
key = artifact_key(artifact.get("key"))
if not any(key.startswith(prefix) for prefix in prefixes):
raise ChannelError("Artifact outside admitted build namespace")
require_sha256(artifact.get("sha256"))
_integer(artifact.get("size"), "artifact size", maximum=2**53 - 1)
identity_field, version_field, signing_field = (
("appId", "version", "teamId") if target[0] == "darwin"
else ("msixAppIdWithOrg", "windowsVersion", "publisher"))
if package.get("identity") != request["identity"][identity_field] or package.get("version") != request[version_field]:
raise ChannelError("Native package identity or version mismatch")
signing = package.get(signing_field)
if not isinstance(signing, str) or not signing or any(ord(c) < 32 for c in signing):
raise ChannelError("Missing native signing identity")
if target[0] == "darwin":
_match(r"[A-Z0-9]{10}", signing, "Apple team ID")
feed = package.get("feed")
if not isinstance(feed, dict) or feed.get("channel") != "stable":
raise ChannelError("Channel native metadata requires a neutral stable feed")
feed_key = artifact_key(feed.get("key"))
if not any(feed_key.startswith(prefix) for prefix in prefixes):
raise ChannelError("Native metadata outside admitted build namespace")
return manifest