diff --git a/docs/design/remote-access-design.md b/docs/design/remote-access-design.md index ab78dfed..87c9d5fb 100644 --- a/docs/design/remote-access-design.md +++ b/docs/design/remote-access-design.md @@ -696,7 +696,7 @@ and it is a two-line change on their side β€” `kind` is already on the wire. ### 5.2 A consumer that drives a duck, and the one line of theirs it has to get past -`spaces/policy-shop` is the second consumer in this repository and the first that *sends* +`spaces/policy-playground` is the second consumer in this repository and the first that *sends* anything: sign in, list the account's ducks, and put a policy from the Hub onto one in a click β€” `policy.fetch`, `robot.setSkill`, `robot.policies`, `robot.do`, which is `robotctl policy add`'s own order over a datachannel instead of over a unix socket. diff --git a/spaces/policy-shop/README.md b/spaces/policy-playground/README.md similarity index 81% rename from spaces/policy-shop/README.md rename to spaces/policy-playground/README.md index e1899e7a..fe0d9383 100644 --- a/spaces/policy-shop/README.md +++ b/spaces/policy-playground/README.md @@ -1,5 +1,5 @@ --- -title: microduck policy shop +title: microduck policy playground emoji: πŸ¦† colorFrom: yellow colorTo: pink @@ -10,13 +10,13 @@ hf_oauth: true short_description: Pick a policy off the Hub, put it on your duck, run it. --- -# microduck policy shop +# microduck policy playground Everything published to the Hub as `microduck-…`, read the way the robot reads it, with a button -that downloads one onto your duck and runs it. +that downloads one onto your duck and runs it β€” and a box to name one the search did not reach. -**Do not edit this Space directly.** The source is `spaces/policy-shop/` in -`pollen-robotics/microduck`, and `scripts/publish-space.sh policy-shop` is what puts it here. +**Do not edit this Space directly.** The source is `spaces/policy-playground/` in +`pollen-robotics/microduck`, and `scripts/publish-space.sh policy-playground` is what puts it here. ## The one click @@ -37,6 +37,32 @@ pipe to the API the robot already serves. can say what a policy claims to be before anybody clicks it, but the skill that gets written comes from `policy.fetch`'s answer, which is about the bytes that were actually downloaded. +## Naming one yourself + +The gallery is `?search=microduck`, which is a convention rather than a rule. A policy on a branch, +in a repo named something else, or published into a search index that has not caught up is +invisible in the list and perfectly fetchable β€” so there is a box above the rows, and what it takes +is `robotctl policy add`'s own spelling: `org/name`, optionally `@branch-tag-or-commit`, optionally +`:file.onnx` for a repo carrying several. A pasted Hub address is the same three fields and is +accepted as it comes, `blob/` and `resolve/` URLs included, because what is in somebody's clipboard +is the page they were just reading. + +`catalogue.parse_spec` is the whole of that, and it repeats `updater/src/policy.rs`'s rule about +what an `org/name` is for one reason: the daemon's refusal is correct and arrives after a round +trip to a robot, and a typo deserves an answer while the cursor is still in the box. A Space URL +gets named as one rather than 404ing, since it is the same shape as a model's and it is the +mistake to expect. + +**Nothing is read here first, and that is the difference from a row.** A row has had its manifest +read, so the page can refuse a ground pick before anything is downloaded; a typed repo has not, and +fetching one to pre-judge it would be this page second-guessing `policy.fetch` β€” which reads the +same file on the robot, refuses on shape before downloading, and is the reading that the skill gets +written from either way. The encoding refusal still lands, one step later, off the fetch answer. + +The line a run finishes on is the spec rather than the row's key β€” `org/name@v2:walk.onnx`, what +was actually fetched, revision and all. It pastes back into the box, and into `robotctl policy add` +on the robot. + ## The picture The session carries the camera as well as the channel, so the newest frame is shown beside the @@ -162,6 +188,12 @@ uv run catalogue.py ``` prints the whole Hub catalogue with what each policy claims and which are refused. Needs no token. +Given arguments, it answers for the box instead β€” what each one parses to, and what the Hub has +there: + +```bash +uv run catalogue.py https://huggingface.co/RemiFabre/microduck-flamingo-cycle +``` ```bash uv run lan.py diff --git a/spaces/policy-shop/app.py b/spaces/policy-playground/app.py similarity index 88% rename from spaces/policy-shop/app.py rename to spaces/policy-playground/app.py index 12e218b4..0bc1a07d 100644 --- a/spaces/policy-shop/app.py +++ b/spaces/policy-playground/app.py @@ -98,7 +98,7 @@ def emit(self, record: logging.LogRecord) -> None: for chatty in ("aioice", "aiortc", "aiohttp", "httpx", "urllib3"): logging.getLogger(chatty).setLevel(logging.DEBUG if LEVEL == "DEBUG" else logging.WARNING) -logger = logging.getLogger("shop") +logger = logging.getLogger("playground") # How long to hold a policy that declares no length of its own. Perpetual means "until told # otherwise", so something has to choose, and `robotctl policy add` refuses rather than guessing β€” @@ -208,7 +208,7 @@ def through_rendezvous(self, token: str, peer_id: str, label: str) -> str: # match would be the place a mini could be handed to a duck's client. peer_id, rpc, - label=f"microduck-policy-shop/{os.environ.get('SPACE_ID', 'local')}", + label=f"microduck-policy-playground/{os.environ.get('SPACE_ID', 'local')}", ), ) @@ -542,12 +542,11 @@ def already_note(result: Any) -> str: def install_and_run(index: int, hold: float) -> str: - """`policy.fetch`, `robot.setSkill`, `robot.policies`, `robot.do` β€” and stop at the first no. + """A row's button: the policy the gallery read, put on the duck. - Every refusal is worth showing verbatim. `policy.fetch` is the one that checks the claims that - matter β€” `obs_len`, `action_len`, `model_api`, `robot.model` β€” and it makes them *before* the - download, so "this policy is 51-D and this robot is 61-D" arrives in a second rather than - after 800 KB and a load failure. + The refusal is made here rather than left to the four calls because the gallery has already + read this one's manifest β€” a policy the page knows is a ground pick should not cost a + download to find that out. """ if index >= len(CATALOGUE): return "that row is stale β€” reload the catalogue." @@ -557,25 +556,59 @@ def install_and_run(index: int, hold: float) -> str: blocked = catalogue.refusal(policy) if blocked: return f"**not installed.** {blocked}" + return put_on_the_duck(policy.repo, None, policy.file, hold) + - params: dict[str, Any] = {"repo": policy.repo} - if policy.file: - params["file"] = policy.file +def install_typed(spec: str, hold: float) -> str: + """The box's button: whatever somebody named, put on the duck. + + **Nothing is read here first, and that is the difference from a row.** The gallery is + `?search=microduck` and this box exists for what that does not reach β€” a branch, a repo named + something else, a policy published ten minutes ago β€” so there is no manifest in hand and + fetching one to pre-judge it would be this page second-guessing `policy.fetch`, which reads + the same file on the robot and refuses on it before downloading anything. + """ + try: + repo, revision, file = catalogue.parse_spec(spec) + except ValueError as why: + return f"**that is not a policy to fetch.** {why}" + logger.info( + "typed: %r β†’ repo=%s revision=%s file=%s (hold %s)", spec, repo, revision, file, hold + ) + return put_on_the_duck(repo, revision, file, hold) + + +def put_on_the_duck(repo: str, revision: str | None, file: str | None, hold: float) -> str: + """`policy.fetch`, `robot.setSkill`, `robot.policies`, `robot.do` β€” and stop at the first no. + + Every refusal is worth showing verbatim. `policy.fetch` is the one that checks the claims that + matter β€” `obs_len`, `action_len`, `model_api`, `robot.model` β€” and it makes them *before* the + download, so "this policy is 51-D and this robot is 61-D" arrives in a second rather than + after 800 KB and a load failure. + """ + params: dict[str, Any] = {"repo": repo} + if revision: + params["revision"] = revision + if file: + params["file"] = file try: fetched = LINK.call("policy.fetch", params, timeout=FETCH_TIMEOUT) or {} except RpcError as e: return f"**`policy.fetch` refused it.** {e.message}" - # The robot's own reading of the manifest wins over this Space's: it downloaded the file and - # parsed the manifest beside it, so its answer is about the bytes that are going to run. + # Everything below is the robot's own reading of the manifest rather than this Space's: it + # downloaded the file and parsed the manifest beside it, so its answer is about the bytes that + # are going to run. For a typed policy it is the *only* reading there has been. + spec = catalogue.spec_of(fetched) + name = fetched.get("name") or spec if not fetched.get("duration_s") and not hold: return ( - f"**{policy.name} holds until it is told otherwise**, so it has no length of its own. " + f"**{name} holds until it is told otherwise**, so it has no length of its own. " "Say how many seconds to hold it, beside the button." ) late_refusal = catalogue.refusal( - Policy(repo=policy.repo, name=policy.name, encoding=fetched.get("encoding")) + Policy(repo=repo, name=name, encoding=fetched.get("encoding")) ) if late_refusal: return f"**downloaded, and not installed.** {late_refusal}" @@ -603,23 +636,25 @@ def install_and_run(index: int, hold: float) -> str: if after.get("change_error"): return f"**added, and the robot could not re-read it:** {after['change_error']}" - name = skill["name"] + skill_name = skill["name"] try: - ran = LINK.call("robot.do", {"skill": name}) + ran = LINK.call("robot.do", {"skill": skill_name}) except RpcError as e: return ( - f"**`{name}` is installed** ({skill['duration']:g}s) **and it would not run:** " + f"**`{skill_name}` is installed** ({skill['duration']:g}s) **and it would not run:** " f"{e.message}" ) said_no = not_accepted(ran) if said_no: return ( - f"**`{name}` is installed** ({skill['duration']:g}s) **and the robot would not run " - f"it:** {said_no}" + f"**`{skill_name}` is installed** ({skill['duration']:g}s) **and the robot would not " + f"run it:** {said_no}" ) + # The spec rather than the row's key: it is what was actually fetched, revision and all, and + # it is a line somebody can paste back into the box or into `robotctl policy add`. return ( - f"**`{name}` installed and running** β€” {skill['duration']:g}s from " - f"`{policy.key}`{already_note(ran)}{unconfirmed}" + f"**`{skill_name}` installed and running** β€” {skill['duration']:g}s from " + f"`{spec}`{already_note(ran)}{unconfirmed}" ) @@ -770,7 +805,7 @@ def open_session(peer_id: str | None, oauth: gr.OAuthToken | None) -> str: return LINK.through_rendezvous(token, peer_id, NAMES.get(peer_id, peer_id)) -with gr.Blocks(title="microduck policy shop") as demo: +with gr.Blocks(title="microduck policy playground") as demo: gr.Markdown( """ # Put a policy on your duck @@ -867,6 +902,32 @@ def open_session(peer_id: str | None, oauth: gr.OAuthToken | None) -> str: scale=2, ) reload_catalogue = gr.Button("reload the catalogue") + + # **The box, above the list rather than under it.** The gallery is `?search=microduck`, which + # is a convention rather than a rule, and the policy somebody actually wants to try is often + # the one that convention has not reached yet β€” a branch, a fork, a repo named something else, + # a file published ten minutes ago. Forty rows below this would be forty rows to scroll past + # to find it. + gr.Markdown( + "**Somewhere else on the Hub?** Name it and it is the same four calls. " + "`robotctl policy add`'s own spelling β€” `org/name`, optionally `@branch-tag-or-commit`, " + "optionally `:file.onnx` for a repo carrying several β€” or paste the address of the " + "repo's page, a `blob/` or a `resolve/` URL included.\n\n" + "**Its manifest is read on the robot and nowhere else.** Unlike a row, nothing here " + "knows what this policy claims to be, so the answer comes back from `policy.fetch` β€” " + "which reads the manifest beside the file and refuses on shape before downloading " + "anything. A public repo: the robot fetches these signed in as nobody, exactly as this " + "page reads the Hub." + ) + with gr.Row(): + typed = gr.Textbox( + value="", + placeholder="RemiFabre/microduck-flamingo-cycle", + label="a policy repo, or the URL of its page on the Hub", + scale=3, + ) + put_typed = gr.Button("put it on the duck and run it", scale=0) + catalogue_note = gr.Markdown("Loading…") rows: list[Any] = [] @@ -901,6 +962,12 @@ def open_session(peer_id: str | None, oauth: gr.OAuthToken | None) -> str: stop.click(lambda: plain("robot.stop"), outputs=status) relax.click(lambda: plain("robot.relax"), outputs=status) run.click(run_installed, inputs=installed, outputs=status) + # The button and the return key both, because a box you typed a repo into is a box you press + # return in. + for press in (put_typed.click, typed.submit): + press(install_typed, inputs=[typed, hold], outputs=status).then( + lambda: robot_state()[0], outputs=robot_panel + ) reload_catalogue.click( lambda: load_catalogue(force=True), outputs=[catalogue_note, *rows] ) @@ -927,4 +994,14 @@ def open_session(peer_id: str | None, oauth: gr.OAuthToken | None) -> str: if __name__ == "__main__": - demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860))) + # **`ssr_mode=False`, and the Space would not stay up without it.** Gradio's server-side + # rendering puts a Node proxy on 7860 in front of Python on 7861, and on this Space it + # started, served nothing, and stopped ten seconds later β€” `Stopping Node.js server...` and no + # traceback, which is a failure with nowhere to look. SSR buys SEO for a page that sits behind + # a sign-in, so the second process is all cost; `vision-demo`'s Dockerfile rules it out with + # `GRADIO_SSR_MODE=false` for the different reason that its image has no Node at all. + demo.launch( + server_name="0.0.0.0", + server_port=int(os.environ.get("PORT", 7860)), + ssr_mode=False, + ) diff --git a/spaces/policy-shop/catalogue.py b/spaces/policy-playground/catalogue.py similarity index 62% rename from spaces/policy-shop/catalogue.py rename to spaces/policy-playground/catalogue.py index 2f0f604d..c46d55ee 100644 --- a/spaces/policy-shop/catalogue.py +++ b/spaces/policy-playground/catalogue.py @@ -18,7 +18,8 @@ `policies`. `policy.fetch` takes a `file`, so an entry out of the set is one click like any other. Runnable on its own β€” `uv run catalogue.py` prints what a duck would be offered β€” which is how -this is checked without a robot, a token or a Space. +this is checked without a robot, a token or a Space. Given arguments it answers for the box +instead: `uv run catalogue.py ` says what that parses to and what the Hub has there. """ from __future__ import annotations @@ -95,6 +96,145 @@ def headline(self) -> str: return " Β· ".join(bits) +# ── naming one the list does not have ──────────────────────────────────────── +# +# The gallery is `?search=microduck`, which is a convention and not a rule: a policy on a branch, +# in a repo named something else, or published an hour ago into a search index that has not caught +# up is invisible here and perfectly fetchable. So there is a box to type into, and this is what it +# accepts. + +# Hosts a Hub URL can arrive under. What is in somebody's clipboard is the address bar of the page +# they were just reading, and asking them to retype it as `org/name` is asking them to do a +# machine's job. +HUB_HOSTS = ("huggingface.co", "www.huggingface.co", "hf.co") +# The path shapes a repo's own pages use: `/org/name/blob/main/policy.onnx` is what the file +# viewer's address bar says, `resolve` is the download link behind it, `tree` is a directory. +REVISIONED = ("blob", "resolve", "tree", "raw") + + +def parse_spec(text: str) -> tuple[str, str | None, str | None]: + """A repo, a revision and a file out of whatever somebody typed or pasted. + + **`robotctl policy add`'s spelling, exactly**: `org/name`, optionally `@revision`, optionally + `:file.onnx`. Somebody who has one of those in their notes or in a README should not have to + translate it to use this page, and a second syntax for the same three fields is a second + syntax to get wrong. + + **And a Hub URL besides**, because that is what a person actually has to hand. Every shape the + Hub's own pages produce resolves to the same three fields β€” the repo page, a `tree`, and the + `blob`/`resolve` of a file, which carries the revision and the file both. + + Raises `ValueError` carrying the sentence to show. The repo rule is `updater/src/policy.rs`'s + own, repeated here rather than relied on: the daemon's refusal is correct and arrives after a + round trip to a robot, and a typo deserves an answer while the cursor is still in the box. + """ + typed = (text or "").strip() + if not typed: + raise ValueError("nothing typed.") + + # A scheme or a hostname means a URL, whatever host it names β€” routing `example.com/org/name` + # through the `org/name` parser instead would answer a pasted address with a complaint about + # the word `https`, which is the least useful true thing that could be said about it. + if "://" in typed or typed.split("/")[0].lower() in HUB_HOSTS: + repo, revision, file = _from_url(typed) + else: + repo, revision, file = _from_spec(typed) + + org, _, name = repo.partition("/") + if not org or not name or "/" in name: + raise ValueError( + f"`{repo}` is not an `org/name` repo β€” that is the whole of what the Hub calls one." + ) + for part in (org, name): + # `updater/src/policy.rs`'s rule, character for character: neither half may hold a dot or + # a backslash, because both halves become a path under the policy library. It costs the + # Hub's dotted model names β€” `org/Phi-3.5-mini` is unfetchable by a duck β€” and a page that + # accepted one would be a page that sends a call guaranteed to come back refused. + if any(character in part for character in "./\\"): + raise ValueError( + f"`{repo}` is not an `org/name` repo β€” a dot or a slash in either half is what " + "the robot refuses, since both halves become a directory on it." + ) + # The daemon's rule again: a revision becomes the last directory under `//`. + if revision is not None and (revision.startswith(".") or any(c in revision for c in "/\\")): + raise ValueError( + f"`{revision}` is not a branch, a tag or a commit β€” it becomes a directory on the " + "robot, so it carries no slash." + ) + if file is not None: + if "/" in file: + raise ValueError( + f"`{file}` is not at the top of the repo, and the robot installs only files that " + "are β€” `docs/policy-manifest.md` is why." + ) + if not file.endswith(".onnx"): + raise ValueError( + f"`{file}` is not a policy. The file is the `.onnx` β€” leave it off entirely and " + "the robot takes the only one in the repo, which is what these repos have." + ) + return repo, revision, file + + +def _from_spec(typed: str) -> tuple[str, str | None, str | None]: + """`org/name[@revision][:file]`, split the way `robotctl` splits it.""" + repo, _, file = typed.partition(":") + repo, _, revision = repo.partition("@") + return repo.strip("/"), revision or None, file or None + + +def _from_url(typed: str) -> tuple[str, str | None, str | None]: + """The three fields out of a Hub address, whichever of its pages it came from.""" + address = typed.split("//")[-1].split("?")[0].split("#")[0] + host, _, path = address.partition("/") + if host.lower() not in HUB_HOSTS: + raise ValueError(f"`{host}` is not the Hub. A policy lives at `huggingface.co/org/name`.") + + parts = [part for part in path.split("/") if part] + # `/api/models/org/name` is the JSON behind the page, and somebody debugging has it open. + if parts[:2] == ["api", "models"]: + parts = parts[2:] + if parts[:1] == ["models"]: + parts = parts[1:] + # A Space and a dataset are not policies, and their URLs are the same shape as a model's β€” so + # this is the one mistake worth naming rather than letting the Hub answer it with a 404. + if parts and parts[0] in ("spaces", "datasets"): + what = parts[0].rstrip("s") + raise ValueError( + f"that is a {what}, not a model repo. A policy is the `.onnx` and the " + "`manifest.json` beside it, published as a model β€” `huggingface.co/org/name`." + ) + if len(parts) < 2: + raise ValueError(f"`{typed}` names no repo. A policy lives at `huggingface.co/org/name`.") + + repo = f"{parts[0]}/{parts[1]}" + rest = parts[2:] + if rest and rest[0] in REVISIONED: + revision = rest[1] if len(rest) > 1 else None + return repo, revision, "/".join(rest[2:]) or None + if rest: + raise ValueError( + f"`{'/'.join(rest)}` is not part of a repo's address β€” the repo itself is " + f"`huggingface.co/{repo}`, and a file in it is under `blob/` or `resolve/`." + ) + return repo, None, None + + +def spec_of(fetched: dict[str, Any]) -> str: + """What the robot fetched, written the way this page would take it back. + + The answer to "what did that button actually install" has to be something a person can act on + β€” paste into the box to run it again, or into `robotctl policy add` on the robot. `main` is + left off because it is what a bare repo means anyway. + """ + spec = fetched.get("repo") or "?" + revision = fetched.get("revision") + if revision and revision != "main": + spec += f"@{revision}" + if fetched.get("file"): + spec += f":{fetched['file']}" + return spec + + def refusal(policy: Policy) -> str | None: """Why this cannot be a one-shot skill, or `None`. @@ -214,13 +354,13 @@ def _integer(value: Any) -> int | None: return int(value) if isinstance(value, int) and not isinstance(value, bool) else None -def _manifest(repo: str) -> dict[str, Any] | None: +def _manifest(repo: str, revision: str = "main") -> dict[str, Any] | None: """A repo's `manifest.json`, or `None` if it has none. No token: these are public repos, and a Space that reads them signed in as whoever is looking would show a different catalogue to each visitor for no reason. """ - url = f"https://huggingface.co/{repo}/resolve/main/manifest.json" + url = f"https://huggingface.co/{repo}/resolve/{revision}/manifest.json" try: answer = requests.get(url, timeout=TIMEOUT) except requests.RequestException: @@ -339,7 +479,52 @@ def skill_for(fetched: dict[str, Any], hold: float | None) -> dict[str, Any]: return params +def _check(typed: str) -> None: + """What the box would make of one line, and what the Hub says about the result.""" + try: + repo, revision, file = parse_spec(typed) + except ValueError as why: + print(f"{typed}\n refused: {why}") + return + print( + f"{typed}\n repo={repo} revision={revision or 'main'} " + f"file={file or '(the only .onnx in it)'}" + ) + manifest = _manifest(repo, revision or "main") + if manifest is None: + print(" no manifest.json there β€” the robot will fetch it anyway and the shape gate at") + print(" load is what decides, but nothing can be said about it first.") + return + + # A set is a repo with several `.onnx` in it, and `policy.fetch` refuses one without a `file` + # rather than guessing which network to run. Saying so here beats learning it from the robot. + entries = manifest.get("policies") + if isinstance(entries, list) and entries: + entry = next((e for e in entries if e.get("file") == file), None) if file else None + if entry is None: + names = ", ".join(str(e.get("file")) for e in entries if e.get("file")) + print(f" a set of {len(entries)} β€” name one with `:file.onnx`: {names}") + return + manifest = _merge(manifest, entry) + one = _policy_from(manifest, repo, file) + print(f" {one.name} β€” {one.headline()}") + if one.description: + print(f" {one.description}") + if refusal(one): + print(f" REFUSED: {refusal(one)}") + + if __name__ == "__main__": + import sys + + # `uv run catalogue.py ` answers, without a robot, a token + # or a Space, the two questions a typed policy raises: does this parse into a repo, and does + # the Hub have anything there. Asked nothing, it prints the whole catalogue as before. + if len(sys.argv) > 1: + for argument in sys.argv[1:]: + _check(argument) + raise SystemExit(0) + found, why = read_hub() if why: print(why) diff --git a/spaces/policy-shop/control.py b/spaces/policy-playground/control.py similarity index 100% rename from spaces/policy-shop/control.py rename to spaces/policy-playground/control.py diff --git a/spaces/policy-shop/lan.py b/spaces/policy-playground/lan.py similarity index 91% rename from spaces/policy-shop/lan.py rename to spaces/policy-playground/lan.py index b207ce6c..4306fe6d 100644 --- a/spaces/policy-shop/lan.py +++ b/spaces/policy-playground/lan.py @@ -39,17 +39,56 @@ from aiortc.mediastreams import MediaStreamError from aiortc.sdp import candidate_from_sdp -# **Their DTLS shim, called out loud.** aiortc's default cipher list shares no cipher with the -# GStreamer `webrtcsink` a duck runs, so DTLS never completes and the peer connection never -# reaches `connected` β€” a failure that looks exactly like a NAT problem and is not one. Their -# module applies it at import; calling it here means this file does not depend on somebody else's -# import having happened first, and it is idempotent by design. -from reachy_mini.media.central_consumer import _patch_aiortc_dtls_ciphers - from control import CONTROL_LABEL, Rpc logger = logging.getLogger(__name__) + +def _patch_aiortc_dtls_ciphers() -> None: + """Let aiortc agree a cipher with the GStreamer `webrtcsink` a duck runs. + + **Without this, DTLS never completes and the peer connection never reaches `connected`** β€” a + failure that looks exactly like a NAT problem and is not one, which is the worst kind of bug + this page can have, since a NAT problem is what the *from anywhere* tab exists to work around. + aiortc's default cipher list and `webrtcsink`'s share nothing; adding + `ECDHE-RSA-AES128-GCM-SHA256` is the whole fix. Upstream is aiortc PR #1392, and the day a + released aiortc negotiates one of these by default this function is a deletion. + + **This is `reachy_mini.media.central_consumer._patch_aiortc_dtls_ciphers`, copied.** It was + imported until the Space would not build: on Linux `reachy_mini` pulls `PyGObject`, which + compiles against headers a Gradio builder has none of, and installing them would have put a + motor controller and ONNX Runtime inside a web page to get thirty lines of cipher list. A copy + of a shim that is itself a monkeypatch of somebody's private method, with the upstream PR + named, is the smaller debt β€” and `requirements.txt` says so at the point of the decision. + + The flag it sets is *their* attribute name, deliberately: whichever of the two runs first, the + other sees it and does nothing, so a process that has both installed patches once. + """ + from aiortc.rtcdtlstransport import RTCCertificate + + if getattr(RTCCertificate, "_reachy_cipher_patched", False): + return + original = RTCCertificate._create_ssl_context + ciphers = ( + b"ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:" + b"ECDHE-ECDSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA:" + b"ECDHE-RSA-AES128-GCM-SHA256" + ) + + def patched(self: Any, srtp_profiles: Any) -> Any: + context = original(self, srtp_profiles) + try: + context.set_cipher_list(ciphers) + except Exception as e: # noqa: BLE001 - a cipher list that will not set is a DTLS that + # fails later with a better message than this one would be + logger.warning("dtls: set_cipher_list refused the list: %r", e) + return context + + RTCCertificate._create_ssl_context = patched + RTCCertificate._reachy_cipher_patched = True + logger.info("dtls: cipher list extended (+ECDHE-RSA-AES128-GCM-SHA256)") + + _patch_aiortc_dtls_ciphers() # What `mediad --port` defaults to, which is `webrtcsink`'s own signaller's default. diff --git a/spaces/policy-shop/rendezvous.py b/spaces/policy-playground/rendezvous.py similarity index 100% rename from spaces/policy-shop/rendezvous.py rename to spaces/policy-playground/rendezvous.py diff --git a/spaces/policy-playground/requirements.txt b/spaces/policy-playground/requirements.txt new file mode 100644 index 00000000..28d14891 --- /dev/null +++ b/spaces/policy-playground/requirements.txt @@ -0,0 +1,39 @@ +# **`reachy_mini` is deliberately not here, and it used to be.** The page asked for +# `reachy_mini[central-consumer]`, whose whole contribution was `aiortc`, `av` and one DTLS cipher +# patch β€” and on Linux the package pulls `PyGObject`, which builds from source against +# `gobject-introspection` headers a Gradio Space's builder does not carry. The Space failed to +# build with a page of `reachy-mini X does not provide the extra 'central-consumer'`, which is pip +# backtracking through every older version after the newest one would not install, and names the +# wrong thing entirely. +# +# Installing the build deps would have worked and would still have been wrong: it would put a +# robot runtime β€” a motor controller, `rustypot`, `libusb`, ONNX Runtime, zeroconf β€” inside a web +# page that drives a robot over HTTP. So the two libraries that page actually uses are named here, +# and `lan.py` carries the cipher patch itself. + +# WebRTC, for the *on this network* tab. `av` comes with `aiortc` and is named anyway, because +# `lan.py`'s own check builds frames with it. +aiortc>=1.9 +av>=12 + +# The signalling client for that tab. It was arriving transitively before, which is not the same +# as being a dependency. +aiohttp + +# **`[oauth]`, not bare `gradio`.** `gr.LoginButton` needs `itsdangerous` and `authlib`, which +# only the extra pulls in, and without them a Space with `hf_oauth: true` fails at *import* β€” +# `Cannot initialize OAuth due to a missing library` β€” rather than at the first click. Nothing +# about the page says which one is missing, so it is worth the four characters. +gradio[oauth]>=5 + +# Frames arrive as arrays and the page rotates them. +numpy + +# Reading the Hub: `huggingface.co/api/models?search=microduck`, and a `manifest.json` per hit. +# The same two requests `updater/src/policy.rs` makes, so the gallery and `policy.search` cannot +# disagree about what exists. `requests` is also the whole of the rendezvous transport. +requests + +# `get_token`, for a local run: the sign-in button is mocked off a Space, so the token comes from +# `HF_TOKEN` or from whatever `hf auth login` stored. +huggingface_hub diff --git a/spaces/policy-shop/wire.py b/spaces/policy-playground/wire.py similarity index 100% rename from spaces/policy-shop/wire.py rename to spaces/policy-playground/wire.py diff --git a/spaces/policy-shop/requirements.txt b/spaces/policy-shop/requirements.txt deleted file mode 100644 index 589dae94..00000000 --- a/spaces/policy-shop/requirements.txt +++ /dev/null @@ -1,23 +0,0 @@ -# The consumer, and the WebRTC stack under it. The `central-consumer` extra is what pulls -# `aiortc` and `av`: a robot never runs the consumer, so the base install leaves them out. -# -# **Unpinned deliberately, for now**, for the reason `vision-demo` gives: the drift that matters -# here is between the consumer and a duck, and pinning a version whose DTLS shim we depend on -# before knowing which versions work would pin the wrong thing. The first time this breaks, pin -# it here with the version that worked. -# -# `control.py` subclasses `ReachyCentralConsumer` and overrides one private method, so this is -# the dependency most likely to break silently across a version β€” the shim's docstring says what -# the failure looks like and what makes it stop being needed. -reachy_mini[central-consumer] - -# **`[oauth]`, not bare `gradio`.** `gr.LoginButton` needs `itsdangerous` and `authlib`, which -# only the extra pulls in, and without them a Space with `hf_oauth: true` fails at *import* β€” -# `Cannot initialize OAuth due to a missing library` β€” rather than at the first click. Nothing -# about the page says which one is missing, so it is worth the four characters. -gradio[oauth]>=5 - -# Reading the Hub: `huggingface.co/api/models?search=microduck`, and a `manifest.json` per hit. -# The same two requests `updater/src/policy.rs` makes, so the gallery and `policy.search` cannot -# disagree about what exists. -requests diff --git a/spaces/shared/wire.py b/spaces/shared/wire.py index 398e9898..92440470 100644 --- a/spaces/shared/wire.py +++ b/spaces/shared/wire.py @@ -72,7 +72,7 @@ class WsConsumer: """ def __init__(self, token: str, peer_id: str, rpc: Any, base: str = DEFAULT_CENTRAL_URL, - label: str = "microduck-policy-shop"): + label: str = "microduck-policy-playground"): self._token = token self._peer_id = peer_id self._rpc = rpc @@ -378,7 +378,9 @@ def connect(token: str, peer_id: str, rpc: Any, label: str) -> WsConsumer: # Shorter than the page's, because a person is watching this one. rpc = Rpc(timeout=15) - consumer = WsConsumer(credential, duck.peer_id, rpc, label="microduck-policy-shop/wire-check") + consumer = WsConsumer( + credential, duck.peer_id, rpc, label="microduck-policy-playground/wire-check" + ) try: consumer.start() except WireError as e: diff --git a/spaces/vision-demo/app.py b/spaces/vision-demo/app.py index dc4f65b8..b3a2e21a 100644 --- a/spaces/vision-demo/app.py +++ b/spaces/vision-demo/app.py @@ -83,7 +83,7 @@ class Ring(logging.Handler): **A Space's logs are on a page only its owner can open**, and reading them needs write access to the Space β€” so "click the button and tell me what it said" was a round trip through somebody's screenshot. The panel shows what the container's stderr shows, to whoever is - already looking at the thing that failed. `policy-shop` has the same handler for the same + already looking at the thing that failed. `policy-playground` has the same handler for the same reason. """