mirror of
https://github.com/unslothai/unsloth.git
synced 2026-10-19 08:54:21 +00:00
* fix(studio): give every LAN address its own QR and copy actions The LAN panel listed every network address but put a single QR button in its header, and that button always encoded `urls[0]`. On a machine with more than one interface the second address could be copied but never scanned, and nothing on screen said which address the QR belonged to. Actions now follow the addresses. A lone address keeps them in the header, matching the remote access panel exactly. Several addresses each carry their own QR and copy button at the end of the row, and the QR trigger names its address for screen readers. * refactor(studio): drop the settings-managed owner label Both access panels appended "Settings managed" to their status whenever the session was owned by settings, which is the ordinary case and the one a reader already assumes. It cost a line of text to say nothing. Settings now maps to no label, so the status reads just the state. Launch and Colab ownership still name themselves, since those are the cases where the owner explains why the controls behave as they do. * fix(studio): name the real bind host when a launch already owns LAN access `configure_lan_access` marks a launch as owning LAN access for a wildcard bind or for any external host, so `--host 10.1.1.144` and `--host 0.0.0.0` both reach the same `launch_managed` block. The settings panel answered both with one fixed sentence claiming the launch binds every network interface, which told a user who bound a single address something untrue about their own launch. It also named the short `-H` alias rather than the flag as documented. The launch bind host now travels with the status, and the message reads it back: a wildcard is described as every network interface, any other host is named directly, and a backend that predates the field falls back to a sentence that promises nothing about the flag. `LanAccessResponse` declares the field, without which Pydantic drops it before the browser ever sees it. * fix(studio): keep the copy button's accessible name and the wildcard rule in one place Three fixes from review of the LAN access panel. The copy button carried an `aria-label` built from the URL and the word "Copy", which overrides the name computed from content. Once the visible label could read "Copy URL", the visible text was no longer contained in the accessible name, so speech input could not address the button (WCAG 2.5.3 Label in Name). The same held for the "Copied" state that follows a successful copy. The visible text and the accessible name now come from one value. The launch-managed message re-derived wildcard-ness in TypeScript from its own copy of the wildcard host set, which the backend already computes as `lan_access_wildcard_bind`. That flag now travels with the bind host and the message follows it, so a later change to what counts as a wildcard reaches the panel without a second edit. `lanAccessBlockMessage` takes the status instead of a growing list of extracted fields. Passing those fields separately had also pushed `LanAccessSection` past the cognitive complexity limit biome enforces; taking the status directly puts the file back to the warnings it had before this branch. * remove duplicate bind host assignment * restore access owner labels * centralize wildcard bind detection * classify effective lan wildcard binds * describe empty wildcard binds accurately * reuse wildcard policy in startup paths * reuse wildcard policy in cli startup * recognize legacy wildcard ipv4 forms * update cli backend test contract * cover per-address lan actions in browser * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * reject empty studio bind hosts * normalize mapped wildcard bind families * reuse wildcard policy for pid collisions * refresh wildcard lan addresses * preserve lan mock in delayed browser tests * cover mapped wildcard tunnel gating * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * canonicalize wildcard bind aliases * detect ipv6 lan addresses * advertise reachable ipv6 lan urls * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * preserve dual-stack wildcard binds * cache wsl networking mode * apply repository formatting * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * route cli requests through the bind host * probe every resolved bind address * reject ambiguous ephemeral binds * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * bypass proxies for cli self-requests * count scoped bind endpoints * resolve ephemeral hostname policy * canonicalize mapped ipv4 binds * canonicalize resolved mapped binds * route tunnels through the bound family * encode scoped tunnel origins * pin cli requests to bound listeners * pin tunnel origins to bound listeners * select an available display family --------- Co-authored-by: mahiatlinux <110882203+mahiatlinux@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
181 lines
6 KiB
Python
181 lines
6 KiB
Python
# Copyright 2025-present the Unsloth AI Inc. team. All rights reserved.
|
|
|
|
"""Pure resolver for `unsloth studio [run] --enable-tools/--disable-tools`.
|
|
|
|
Kept as a standalone module so the truth table can be unit-tested
|
|
without spinning up Typer or the studio venv.
|
|
"""
|
|
|
|
import ipaddress
|
|
import socket
|
|
from typing import Callable, Optional
|
|
|
|
import typer
|
|
|
|
# loopback aliases mirror the self-contained backend bind policy
|
|
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
|
|
|
|
|
def is_external_host(host: str) -> bool:
|
|
"""True when `host` is reachable from beyond loopback."""
|
|
return host.lower() not in _LOOPBACK_HOSTS
|
|
|
|
|
|
def _normalized_ip(address: str):
|
|
try:
|
|
parsed = ipaddress.ip_address(address)
|
|
except ValueError:
|
|
return None
|
|
if isinstance(parsed, ipaddress.IPv6Address) and parsed.ipv4_mapped is not None:
|
|
parsed = parsed.ipv4_mapped
|
|
return parsed
|
|
|
|
|
|
def _literal_ip_address(host: str):
|
|
if not isinstance(host, str) or not host:
|
|
return None
|
|
literal = _normalized_ip(host)
|
|
if literal is not None:
|
|
return literal
|
|
try:
|
|
return ipaddress.IPv4Address(socket.inet_aton(host))
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _resolved_host_ip_addresses(host: str):
|
|
if not isinstance(host, str) or not host:
|
|
return ()
|
|
try:
|
|
addresses = socket.getaddrinfo(host, 0, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
|
except OSError:
|
|
return ()
|
|
resolved = []
|
|
for _family, _kind, _protocol, _name, sockaddr in addresses:
|
|
try:
|
|
parsed = ipaddress.ip_address(sockaddr[0])
|
|
except (IndexError, ValueError):
|
|
continue
|
|
if parsed not in resolved:
|
|
resolved.append(parsed)
|
|
return tuple(resolved)
|
|
|
|
|
|
def _resolved_ip_addresses(host: str):
|
|
literal = _literal_ip_address(host)
|
|
if literal is not None:
|
|
return (literal,)
|
|
resolved = []
|
|
for parsed in _resolved_host_ip_addresses(host):
|
|
if isinstance(parsed, ipaddress.IPv6Address) and parsed.ipv4_mapped is not None:
|
|
parsed = parsed.ipv4_mapped
|
|
if parsed not in resolved:
|
|
resolved.append(parsed)
|
|
return tuple(resolved)
|
|
|
|
|
|
def wildcard_ip_versions(host: str) -> tuple[int, ...]:
|
|
"""IP versions for every unspecified address this host resolves to."""
|
|
versions = {
|
|
address.version for address in _resolved_ip_addresses(host) if address.is_unspecified
|
|
}
|
|
return tuple(version for version in (4, 6) if version in versions)
|
|
|
|
|
|
def resolved_bind_address_count(host: str) -> int:
|
|
"""Number of distinct socket addresses this host resolves to."""
|
|
if _literal_ip_address(host) is not None:
|
|
return 1
|
|
if not isinstance(host, str) or not host:
|
|
return 0
|
|
try:
|
|
addresses = socket.getaddrinfo(host, 0, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
|
except OSError:
|
|
return 0
|
|
endpoints = {
|
|
(family, tuple(sockaddr))
|
|
for family, _kind, _protocol, _name, sockaddr in addresses
|
|
if sockaddr
|
|
}
|
|
return len(endpoints)
|
|
|
|
|
|
def is_wildcard_host(host: str) -> bool:
|
|
"""True when the host resolves to an unspecified bind address."""
|
|
return bool(wildcard_ip_versions(host))
|
|
|
|
|
|
def normalize_wildcard_bind_host(host: str) -> str:
|
|
"""Return a safe canonical bind for an effective wildcard host."""
|
|
if isinstance(host, str):
|
|
try:
|
|
parsed_literal = ipaddress.ip_address(host)
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
if (
|
|
isinstance(parsed_literal, ipaddress.IPv6Address)
|
|
and parsed_literal.ipv4_mapped is not None
|
|
):
|
|
return str(parsed_literal.ipv4_mapped)
|
|
literal = _literal_ip_address(host)
|
|
if literal is not None:
|
|
if not literal.is_unspecified:
|
|
return host
|
|
return "::" if literal.version == 6 else "0.0.0.0"
|
|
|
|
raw_addresses = _resolved_host_ip_addresses(host)
|
|
addresses = []
|
|
has_mapped_address = False
|
|
for address in raw_addresses:
|
|
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None:
|
|
address = address.ipv4_mapped
|
|
has_mapped_address = True
|
|
if address not in addresses:
|
|
addresses.append(address)
|
|
if has_mapped_address:
|
|
if len(addresses) == 1:
|
|
return str(addresses[0])
|
|
raise ValueError(
|
|
f"--host {host!r} resolves to ambiguous IPv4-mapped addresses; "
|
|
"use an explicit bind address."
|
|
)
|
|
wildcard_versions = {address.version for address in addresses if address.is_unspecified}
|
|
if not wildcard_versions:
|
|
return host
|
|
specific_versions = {address.version for address in addresses if not address.is_unspecified}
|
|
if len(wildcard_versions) == 2 and not specific_versions:
|
|
return host
|
|
if specific_versions - wildcard_versions or (len(wildcard_versions) == 2 and specific_versions):
|
|
raise ValueError(
|
|
f"--host {host!r} mixes wildcard and specific address families; "
|
|
"use an explicit bind address."
|
|
)
|
|
return "::" if 6 in wildcard_versions else "0.0.0.0"
|
|
|
|
|
|
def wildcard_loopback_host(host: str) -> Optional[str]:
|
|
"""The loopback address reachable through a wildcard bind."""
|
|
versions = wildcard_ip_versions(host)
|
|
if 4 in versions:
|
|
return "127.0.0.1"
|
|
return "::1" if 6 in versions else None
|
|
|
|
|
|
def resolve_tool_policy(
|
|
host: str,
|
|
flag: Optional[bool],
|
|
yes: bool,
|
|
silent: bool,
|
|
prompt: Callable[[str], bool] = typer.confirm,
|
|
) -> Optional[bool]:
|
|
"""Resolve the process-wide server-side tool OVERRIDE.
|
|
|
|
An explicit --enable-tools/--disable-tools (`flag`) forces tools on/off for
|
|
every request. With no flag the result is None: tools still default on for
|
|
every bind (the backend installs that default in `_apply_cli_tool_policy`),
|
|
but as a default rather than an override, so a request's own
|
|
`enable_tools: false` is honored -- which is what the Unsloth UI sends with
|
|
its tool pills off. `host`, `yes`, `silent`, `prompt` are kept for signature
|
|
compatibility; no bind prompts."""
|
|
return flag
|