From 83ba3ece98ed388514de3b29cba96bfc6e7211c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?jiusheng=5F=E6=AF=8D=E5=B7=A2?= Date: Thu, 22 Oct 2026 08:51:44 +0800 Subject: [PATCH] [TUI] Add explicit host overrides to strata launch Carry the requested IPv4 bind address through launch presets, private run configs, profiles, status, Connect and monitoring. Preserve the existing access-mode binding when no host override is supplied. --- docs/TERMINAL.md | 2 +- docs/TERMINAL_LAUNCH.md | 8 ++ strata_cli/client.py | 30 +++- strata_cli/launch/access.py | 12 ++ strata_cli/launch/app.py | 4 +- strata_cli/launch/cmd.py | 29 +++- strata_cli/launch/runner.py | 8 +- strata_cli/launch/screens.py | 18 ++- strata_cli/launch/settings.py | 36 ++++- tests/terminal/test_launch_host.py | 221 +++++++++++++++++++++++++++++ 10 files changed, 347 insertions(+), 21 deletions(-) create mode 100644 tests/terminal/test_launch_host.py diff --git a/docs/TERMINAL.md b/docs/TERMINAL.md index e67a489ee61..935b4620d4c 100644 --- a/docs/TERMINAL.md +++ b/docs/TERMINAL.md @@ -41,7 +41,7 @@ strata monitor --connection work strata logs --connection work --source runtime --follow --output jsonl ``` -`config show` reports the source of each effective setting and whether the referenced credential is present, without printing its value or connecting to the server. An explicit `--url` or `--connection` wins over `STRATA_CONNECTION`, then `STRATA_URL`, then the local default. `--credential-env` overrides the selected reference. Direct URL use retains the legacy `STRATA_API_KEY` default; new saved connections default to `STRATA_TELEMETRY_KEY` so a read-only key can be used. Remote roots require HTTPS, and a saved proxy prefix accepts only simple path segments. The source CLI and portable monitor bundle use the same owner-private connection files in `${XDG_CONFIG_HOME:-~/.config}/strata/connections` or `%APPDATA%/Strata/connections`; `STRATA_CONNECTION_DIR` can select an absolute directory. On POSIX, the directory and records must be owner-only. Windows ACL qualification remains open. +`config show` reports the source of each effective setting and whether the referenced credential is present, without printing its value or connecting to the server. An explicit `--url` or `--connection` wins over `STRATA_CONNECTION`, then `STRATA_URL`, then the local default. `--credential-env` overrides the selected reference. Direct URL use retains the legacy `STRATA_API_KEY` default; new saved connections default to `STRATA_TELEMETRY_KEY` so a read-only key can be used. Remote roots require HTTPS. HTTP is allowed for loopback and numeric addresses reported on this computer's own network interfaces; those local requests bypass system proxies. An address on another LAN computer still requires HTTPS. A saved proxy prefix accepts only simple path segments. The source CLI and portable monitor bundle use the same owner-private connection files in `${XDG_CONFIG_HOME:-~/.config}/strata/connections` or `%APPDATA%/Strata/connections`; `STRATA_CONNECTION_DIR` can select an absolute directory. On POSIX, the directory and records must be owner-only. Windows ACL qualification remains open. Operator profiles are separate from setup's run config. Save one from an installed config, preview edits, and use its name for serving: diff --git a/docs/TERMINAL_LAUNCH.md b/docs/TERMINAL_LAUNCH.md index 3d5834d9dde..759b8329c02 100644 --- a/docs/TERMINAL_LAUNCH.md +++ b/docs/TERMINAL_LAUNCH.md @@ -78,6 +78,7 @@ strata launch --preset "IQ3_XXS 128K" # a saved strata launch --family qwen --model iq3_xxs --context 128k --parallel 4 --vision off --tune quick \ --access lan --port 8080 --save-preset "Office" --no-tui strata launch --model-path /data/models/IQ3_XXS --no-tui # files you already have +strata launch --family qwen --model iq3_s --host 192.168.1.20 --port 8080 --context 128k --vision gpu --no-tui strata launch --preset Office --set prefill=65536 --set STRATA_PIN_BY_NEED=1 --no-tui strata launch --family qwen --model q2_0 --context 64k --restart --no-tui strata launch --list-presets @@ -101,6 +102,7 @@ strata launch --show-key | `--set KEY=VALUE` | An advanced engine setting or `STRATA_*` variable (repeatable). Unknown names get a warning. | | `--pool-cores LIST` | Experimental: pin CPU workers to these logical processors, for example `2-14:2` ([DETAILS.md](DETAILS.md)). | | `--access local\|lan\|cloudflare\|tailscale`, `--port N` | Who can reach the server. | +| `--host ADDRESS` | Bind to an IPv4 address this computer owns, or `localhost`, instead of the access default. | | `--api-key KEY`, `--api-key-env NAME`, `--new-key`, `--no-api-key` | The key clients must send. | | `--save-preset NAME`, `--list-presets`, `--delete-preset NAME` | Presets. | | `--no-tui` | No screens: serve, print the address and return. | @@ -115,6 +117,12 @@ strata launch --show-key Presets are small files in `launch-presets/` beside the profiles folder. A run uses the shared profile `launch-` and the shared tuning presets (`strata presets`), so the app sees them too. +`--host` takes an address without `http://` or a port. Without `--access`, a loopback address selects local access and another +address selects LAN access, keeping the existing API-key behavior. The address is saved with `--save-preset`; `--host` beside +`--preset` overrides it for that run. Choosing `--access` without `--host` clears a saved bind override. With neither, the access +mode's existing bind address is unchanged. Cloudflare needs a bind reachable at `127.0.0.1` (`127.0.0.1`, `localhost` or +`0.0.0.0`), and Tailscale needs `0.0.0.0`; incompatible overrides are rejected before starting. + **Launching while a model is serving.** The same choices restart nothing and print the running addresses. Different choices (another context, pictures, access, or another model on the same port) make the screens ask once, "Restart the model with these settings? Answers in progress stop." With `--no-tui` the command refuses with one line naming the running model, its port and settings. Add diff --git a/strata_cli/client.py b/strata_cli/client.py index 90c9a7c4fef..21c215e979c 100644 --- a/strata_cli/client.py +++ b/strata_cli/client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import ipaddress import json import math import re @@ -42,6 +43,28 @@ def __init__(self, message: str, exit_code: int = 3, *, http_status: int | None self.http_status = http_status +def _local_address(host: str) -> bool: + """Permit HTTP to an explicitly bound interface on this computer, never an arbitrary LAN peer.""" + if host == "localhost": + return True + try: + address = ipaddress.ip_address(host) + except ValueError: + return False + if address.is_loopback: + return True + if address.is_unspecified or address.is_multicast: + return False + try: + import psutil + except ImportError: + return False + try: + return any(info.address == host for infos in psutil.net_if_addrs().values() for info in infos) + except (OSError, psutil.Error): + return False + + def server_url(raw: str) -> str: """Require a server root and prevent credential/path ambiguity.""" if not isinstance(raw, str): @@ -56,7 +79,7 @@ def server_url(raw: str) -> str: except ValueError as e: raise ValueError("invalid server port") from e host = u.hostname.lower() - local = host in ("localhost", "127.0.0.1", "::1") + local = _local_address(host) if u.scheme != "https" and not local: raise ValueError("remote monitoring requires HTTPS or a local SSH tunnel") authority = f"[{host}]" if ":" in host else host @@ -105,7 +128,10 @@ def get(self, path: str) -> dict[str, Any]: if self.credential: headers["Authorization"] = "Bearer " + self.credential req = urllib.request.Request(self.base_url + path, headers=headers) - opener = urllib.request.build_opener(_NoRedirect) + handlers = [_NoRedirect] + if _local_address(urllib.parse.urlsplit(self.root).hostname or ""): + handlers.append(urllib.request.ProxyHandler({})) + opener = urllib.request.build_opener(*handlers) try: with opener.open(req, timeout=self.timeout) as response: content_type = response.headers.get("Content-Type", "") diff --git a/strata_cli/launch/access.py b/strata_cli/launch/access.py index f39202e9190..cc5f763d7a4 100644 --- a/strata_cli/launch/access.py +++ b/strata_cli/launch/access.py @@ -32,6 +32,15 @@ def host_for(kind: str) -> str: return "127.0.0.1" if kind in ("local", "cloudflare") else "0.0.0.0" +def bind_host(s: LaunchSettings) -> str: + return s.host if s.host is not None else host_for(s.access) + + +def client_url(host: str, port: int) -> str: + """A wildcard is a bind address, never a destination for clients.""" + return f"http://{'127.0.0.1' if host == '0.0.0.0' else host}:{port}" + + def lan_ips() -> list[str]: """This computer's private IPv4 addresses, the one used for the default route first.""" found: list[str] = [] @@ -284,6 +293,9 @@ def missing_helper(kind: str) -> str | None: def urls_for(s: LaunchSettings, *, tunnel_url: str | None = None) -> list[str]: """The addresses a client can use for these choices (the first is the main one).""" + if s.host is not None and s.host != "0.0.0.0": + base = [client_url(s.host, s.port)] + return [tunnel_url] + base if s.access == "cloudflare" and tunnel_url else base base = [f"http://127.0.0.1:{s.port}"] if s.access == "lan": base += [f"http://{ip}:{s.port}" for ip in lan_ips()[:1]] diff --git a/strata_cli/launch/app.py b/strata_cli/launch/app.py index f27dab0ec8b..85fdfcb444e 100644 --- a/strata_cli/launch/app.py +++ b/strata_cli/launch/app.py @@ -78,7 +78,7 @@ def warnings(self) -> list[str]: out.append(f"{r.title}: {r.words}.") if self.computer.other_vendor_note: out.append(self.computer.other_vendor_note) - missing = acc.missing_helper(s.access) + missing = None if s.host is not None and s.access == "lan" else acc.missing_helper(s.access) if missing: out.append(missing) return out @@ -416,7 +416,7 @@ def run_monitor(info: dict, *, keep_default: bool = True, color: str = "auto") - """Open the monitor for a running launch. q asks whether to keep serving or stop.""" from strata_cli.client import Endpoint from strata_cli.launch.monitor_ext import LaunchMonitor - local = next((u for u in info.get("urls", []) if "127.0.0.1" in u), None) or f"http://127.0.0.1:{info.get('port', S.DEFAULT_PORT)}" + local = next((u for u in info.get("urls", []) if "127.0.0.1" in u), None) or info.get("url") or f"http://127.0.0.1:{info.get('port', S.DEFAULT_PORT)}" endpoint = Endpoint(local, info.get("key"), 3.0, "") app = LaunchMonitor(endpoint, None, info=info, color=color) app.run() diff --git a/strata_cli/launch/cmd.py b/strata_cli/launch/cmd.py index 99b49a2d808..43e0463b5c0 100644 --- a/strata_cli/launch/cmd.py +++ b/strata_cli/launch/cmd.py @@ -62,6 +62,13 @@ def _port(value: str) -> int: return p +def _host(value: str) -> str: + try: + return S.parse_host(value) + except ValueError as e: + raise argparse.ArgumentTypeError(str(e)) from None + + def _parallel(value: str) -> int: try: p = int(value) @@ -124,6 +131,9 @@ def add_parsers(sp: argparse._SubParsersAction) -> list[argparse.ArgumentParser] a.add_argument("--access", type=_choice("a way to share Strata", S.ACCESS_KINDS), metavar="local|lan|cloudflare|tailscale", help="local: this computer only; lan: your network; cloudflare: a quick tunnel; tailscale: your tailnet") a.add_argument("--port", type=_port, metavar="N", help=f"port (default {S.DEFAULT_PORT})") + a.add_argument("--host", type=_host, metavar="ADDRESS", + help="bind to this IPv4 address or localhost instead of the access default; without --access, " + "loopback selects local and any other address selects lan") a.add_argument("--api-key", metavar="KEY", help="the key clients must send (made for you when you share beyond this computer)") a.add_argument("--api-key-env", metavar="NAME", help="read the key from this environment variable") a.add_argument("--new-key", action="store_true", help="make a new API key") @@ -261,6 +271,12 @@ def settings_from_args(a: argparse.Namespace, catalog=None) -> tuple[S.LaunchSet s.tune_allow_small = True if a.access: s.access = a.access + if a.host is not None: + s.host = a.host + if not a.access and s.access in ("local", "lan"): + s.access = "local" if S.loopback_host(s.host) else "lan" + elif a.access: + s.host = None # choosing another access mode resets a saved bind override if a.port: s.port = a.port if a.api_key: @@ -351,7 +367,7 @@ def apply_defaults(s: S.LaunchSettings, catalog) -> tuple[S.LaunchSettings, list notices.append(S.Notice("warn", "--speed and --full-quality are for DeepSeek; this model uses all its experts, so they are not used.", "")) s.speed = None notices += S.check_context(s, max_fit_tokens=None) - if s.access != "local" and not s.api_key and not s.no_key: + if S.shared_access(s) and not s.api_key and not s.no_key: from strata_cli.launch import access kept = access.kept_key(s.name or f"{s.model or 'model'} {S.context_label(s.context)}") s.api_key = kept or access.new_key() @@ -609,8 +625,10 @@ def running_launches() -> list[dict]: except Exception: # noqa: BLE001 ov = {} port = ov.get("port", S.DEFAULT_PORT) + from strata_cli.launch import access + host = ov.get("host", "127.0.0.1") out.append({"name": p["id"][7:], "profile": p["id"], "state": st.get("state"), "port": port, - "url": f"http://127.0.0.1:{port}", "host": ov.get("host", "127.0.0.1"), "config": prof.get("source_config")}) + "url": access.client_url(host, port), "host": host, "config": prof.get("source_config")}) return out @@ -626,7 +644,7 @@ def _cfg_context(cfg: dict) -> int | None: def launch_info(name: str | None = None) -> dict | None: """Address, key and model name of a launch that is running (or the named preset), for `connect` and the monitor's Connect view.""" - from strata_cli.launch import store + from strata_cli.launch import access, store running = running_launches() pick = None if name: @@ -644,7 +662,8 @@ def launch_info(name: str | None = None) -> dict | None: if pick is None and preset is None: return None port = (pick or {}).get("port") or (preset.port if preset else S.DEFAULT_PORT) - return {"url": (pick or {}).get("url") or f"http://127.0.0.1:{port}", "key": (cfg.get("api_key") if str(cfg.get("api_key") or "").strip().lower() != "none" else None) or (preset.api_key if preset else None), + fallback_host = access.bind_host(preset) if preset else "127.0.0.1" + return {"url": (pick or {}).get("url") or access.client_url(fallback_host, port), "key": (cfg.get("api_key") if str(cfg.get("api_key") or "").strip().lower() != "none" else None) or (preset.api_key if preset else None), "model": cfg.get("model_name") or "strata", "context": _cfg_context(cfg), "running": bool(pick), "name": (pick or {}).get("name") or (preset.name if preset else None), "port": port, "access": preset.access if preset else None} @@ -707,7 +726,7 @@ def fail(text: str, code: int = 2) -> int: s, notices = settings_from_args(a, catalog) if not machine: _interrupted_move_notice() - have_choices = bool(a.preset or a.model or a.model_path or a.context or a.vision or a.parallel or a.access or a.tune or a.drafting or getattr(a, "speed", None) or getattr(a, "full_quality", False)) + have_choices = bool(a.preset or a.model or a.model_path or a.context or a.vision or a.parallel or a.access or a.host or a.tune or a.drafting or getattr(a, "speed", None) or getattr(a, "full_quality", False)) interactive = sys.stdin.isatty() and sys.stdout.isatty() and not a.no_tui and not machine and not a.plan plain_questions = False no_screens_package = None diff --git a/strata_cli/launch/runner.py b/strata_cli/launch/runner.py index 588534f96d9..23d44bc03c1 100644 --- a/strata_cli/launch/runner.py +++ b/strata_cli/launch/runner.py @@ -494,11 +494,11 @@ def derive_config(base: dict, s: S.LaunchSettings, *, bool_flags: frozenset[str] """The private run config of this launch: the installed one plus the choices that are not setup's (host, port, key, cards, advanced).""" from strata_control import tune_knobs cfg = json.loads(json.dumps(base)) - cfg["host"] = acc.host_for(s.access) + cfg["host"] = acc.bind_host(s) cfg["port"] = int(s.port) if s.api_key: cfg["api_key"] = s.api_key - elif s.no_key and s.access != "local": + elif s.no_key and S.shared_access(s): cfg["api_key"] = "none" # served without a key on purpose (the server warns once at start) else: cfg.pop("api_key", None) @@ -1406,7 +1406,7 @@ def ensure_profile(self, config: Path) -> None: from strata_control import profiles name = self.profile parallel = self.s.parallel if self.s.parallel else self.catalog_parallel() - sets = [f"host={acc.host_for(self.s.access)}", f"port={int(self.s.port)}"] + ([f"parallel={parallel}"] if parallel else []) + sets = [f"host={acc.bind_host(self.s)}", f"port={int(self.s.port)}"] + ([f"parallel={parallel}"] if parallel else []) try: current, _rev = profiles.load(name) except profiles.ProfileError: @@ -1833,7 +1833,7 @@ def report_drafting(self) -> None: # ---- 6. access def open_access(self) -> str | None: s = self.s - missing = acc.missing_helper(s.access) + missing = None if s.host is not None and s.access == "lan" else acc.missing_helper(s.access) tunnel = None if s.access == "cloudflare": if missing: diff --git a/strata_cli/launch/screens.py b/strata_cli/launch/screens.py index 78a524f5e95..c5254724c4f 100644 --- a/strata_cli/launch/screens.py +++ b/strata_cli/launch/screens.py @@ -1424,8 +1424,10 @@ def build(self) -> None: def refresh_body(self) -> None: s = self.ctx.settings on = bool(s.api_key) - beyond = s.access != "local" + beyond = S.shared_access(s) t = Text() + if s.host is not None: + t.append(f"\n Listen {s.host}\n") t.append(f"\n Port [ {s.port} ] press p to change\n") t.append(f" API key [ {'on' if on else 'off'} ] {acc.mask_key(s.api_key)} ", style=FG if on else WARN) t.append(("(recommended for anything beyond this computer; t = no key)" if on else "(t = make a key)") if beyond else "(not needed on this computer)", style=MUTE) @@ -1454,11 +1456,13 @@ def explain(self) -> Text: def on_cursor(self) -> None: kind = self.menu().current.key s = self.ctx.settings + if kind != s.access: + s.host = None s.access = kind - if kind != "local" and not s.api_key and not s.no_key: + if S.shared_access(s) and not s.api_key and not s.no_key: s.api_key = acc.kept_key() or acc.new_key() self.say("An API key was made for you (recommended beyond this computer). Press t if you want no key.") - elif kind != "local" and s.no_key: + elif S.shared_access(s) and s.no_key: self.say(S.NO_KEY_PUBLIC if kind == "cloudflare" else S.NO_KEY_NETWORK, True) def action_port(self) -> None: @@ -1488,8 +1492,8 @@ def action_keytoggle(self) -> None: s = self.ctx.settings if s.api_key: s.api_key = None - s.no_key = s.access != "local" - self.say("The API key is off." + ((" " + (S.NO_KEY_PUBLIC if s.access == "cloudflare" else S.NO_KEY_NETWORK)) if s.access != "local" else ""), s.access != "local") + s.no_key = S.shared_access(s) + self.say("The API key is off." + ((" " + (S.NO_KEY_PUBLIC if s.access == "cloudflare" else S.NO_KEY_NETWORK)) if S.shared_access(s) else ""), S.shared_access(s)) else: s.api_key, s.no_key = acc.new_key(), False self.say("The API key is on.") @@ -1503,8 +1507,10 @@ def action_next(self) -> None: def commit(self) -> None: s = self.ctx.settings + if s.access != self.menu().current.key: + s.host = None s.access = self.menu().current.key - if s.access != "local" and not s.api_key and not s.no_key: + if S.shared_access(s) and not s.api_key and not s.no_key: s.api_key = acc.kept_key() or acc.new_key() def action_back(self) -> None: diff --git a/strata_cli/launch/settings.py b/strata_cli/launch/settings.py index 0345ce1b81a..742e2ec8d16 100644 --- a/strata_cli/launch/settings.py +++ b/strata_cli/launch/settings.py @@ -11,6 +11,7 @@ from __future__ import annotations import difflib +import ipaddress import re from dataclasses import asdict, dataclass, field, fields from typing import Any @@ -114,6 +115,7 @@ class LaunchSettings: tune: str = "skip" # quick | thorough | skip tune_allow_small: bool = False # the "answer-changing" tick, off by default, as in the app access: str = "local" + host: str | None = None # explicit IPv4 bind address or localhost; None follows access port: int = DEFAULT_PORT api_key: str | None = None # never shown whole; stored only in the private preset file no_key: bool = False # served beyond this computer without a key, on purpose (--no-api-key); a warning, never a refusal @@ -132,6 +134,8 @@ def is_tuned(self) -> bool: def to_dict(self, *, with_key: bool = True) -> dict[str, Any]: d = {"schema": SCHEMA, **asdict(self)} + if self.host is None: + d.pop("host") # presets and plans without an override keep their existing bytes if not with_key: d["api_key"] = "set" if self.api_key else None return d @@ -189,6 +193,24 @@ def check_port(port: Any) -> Notice | None: "(they can read and change files on this computer).") +def parse_host(value: str) -> str: + """The managed HTTP server accepts IPv4 addresses and localhost, without a URL or port.""" + if value == "localhost": + return value + try: + return str(ipaddress.IPv4Address(value)) + except (ValueError, TypeError): + raise ValueError("Use an IPv4 address or localhost for --host, without http:// or a port.") from None + + +def loopback_host(host: str) -> bool: + return host == "localhost" or ipaddress.IPv4Address(host).is_loopback + + +def shared_access(s: LaunchSettings) -> bool: + return s.access != "local" or (s.host is not None and not loopback_host(s.host)) + + def check_access(s: LaunchSettings) -> list[Notice]: out: list[Notice] = [] if s.access not in ACCESS_KINDS: @@ -196,10 +218,20 @@ def check_access(s: LaunchSettings) -> list[Notice]: out.append(Notice("error", f"'{s.access}' is not a way to share Strata.", f"Choose one of: {', '.join(ACCESS_KINDS)}." + (f" Did you mean '{near}'?" if near else ""))) return out + if s.host is not None: + try: + parse_host(s.host) + except ValueError as e: + return [Notice("error", str(e))] + # These helpers connect to fixed local addresses; a different bind would leave the advertised tunnel unreachable. + if s.access == "cloudflare" and s.host not in ("127.0.0.1", "localhost", "0.0.0.0"): + return [Notice("error", "Cloudflare connects to 127.0.0.1.", "Leave --host out, or use --host 127.0.0.1.")] + if s.access == "tailscale" and s.host != "0.0.0.0": + return [Notice("error", "Tailscale needs the server to listen on its network address.", "Leave --host out, or use --host 0.0.0.0.")] port = check_port(s.port) if port: out.append(port) - if s.access != "local" and not s.api_key: + if shared_access(s) and not s.api_key: if s.access == "cloudflare": out.append(Notice("warn", NO_KEY_PUBLIC, "Leave --no-api-key out to get a key.")) elif s.no_key: @@ -325,6 +357,8 @@ def flags_text(s: LaunchSettings, *, preset: str | None = None) -> str: parts.append("--gpu-roles " + ",".join(f"{k}={v}" for k, v in sorted(s.gpu_roles.items(), key=lambda kv: int(kv[0])))) elif s.gpus: parts.append("--gpu " + ",".join(str(g) for g in s.gpus)) + if s.host is not None: + parts.append(f"--host {s.host}") for k, v in sorted(s.extra.items()): parts.append(f"--set {k[2:] if k.startswith('--') else k}={v}" if v else f"--set {k[2:] if k.startswith('--') else k}") return " ".join(parts) diff --git a/tests/terminal/test_launch_host.py b/tests/terminal/test_launch_host.py new file mode 100644 index 00000000000..763bd413e0a --- /dev/null +++ b/tests/terminal/test_launch_host.py @@ -0,0 +1,221 @@ +"""Explicit launch bindings: CLI choices, private configs, profiles and client addresses.""" + +import asyncio +import http.server +import json +import os +import shlex +import threading +from types import SimpleNamespace +from unittest import mock + +import pytest + +from strata_cli.cli import parser +from strata_cli.launch import access, cmd, settings as S, store +from strata_cli.launch.runner import LaunchError, Runner, derive_config +from strata_control import profiles, runtime +from tests.terminal.launch_support import Home, HW_12, catalog, run_cli + + +def choices(*args): + a = parser().parse_args(["launch", *args]) + return cmd.settings_from_args(a, catalog(HW_12))[0] + + +def test_host_reaches_config_profile_status_and_connect_without_editing_install(): + with Home() as home: + s = choices("--model", "iq3_s", "--host", "127.0.0.2", "--port", "9090", + "--context", "131072", "--vision", "gpu", "--kv", "int8") + base = {"exe": "component:strata-engine", "args": ["--max-context", "131072", "--kv", "int8"], + "vision": {"exe": "reader", "gpu": True}, "tokenizer": "synthetic-tokenizer", "model_name": "synthetic"} + installed = home.base / "installed.json" + before = json.dumps(base).encode() + installed.write_bytes(before) + runner = Runner(s, catalog=catalog(HW_12)) + config = runner.build_config(installed) + runner.ensure_profile(config) + cfg = json.loads(config.read_text()) + assert cfg["host"] == "127.0.0.2" + assert cfg["port"] == 9090 + assert cfg["vision"] == base["vision"] + assert cfg["args"] == base["args"] + assert installed.read_bytes() == before + profile, _ = profiles.load(runner.profile) + assert profile["overrides"]["host"] == "127.0.0.2" + assert access.urls_for(s) == ["http://127.0.0.2:9090"] + with mock.patch.object(runtime, "status", return_value={"state": "ready"}): + assert cmd.running_launches()[0]["url"] == "http://127.0.0.2:9090" + assert cmd.launch_info()["url"] == "http://127.0.0.2:9090" + + +@pytest.mark.parametrize("host,kind,key", [("127.0.0.2", "local", False), ("localhost", "local", False), + ("192.0.2.10", "lan", True), ("0.0.0.0", "lan", True)]) +def test_host_uses_existing_access_and_key_policy(host, kind, key): + with Home(): + s = choices("--model", "q2_0", "--host", host) + s, notes = cmd.apply_defaults(s, catalog(HW_12)) + assert s.access == kind + assert bool(s.api_key) is key + assert not [n for n in notes if n.level == "error"] + assert derive_config({"args": []}, s, bool_flags=frozenset())["host"] == host + + +def test_explicit_keyless_and_local_access_do_not_bypass_shared_host_checks(): + with Home(): + s = choices("--model", "q2_0", "--host", "192.0.2.10", "--no-api-key") + s, notes = cmd.apply_defaults(s, catalog(HW_12)) + assert s.api_key is None + assert derive_config({"args": []}, s, bool_flags=frozenset())["api_key"] == "none" + assert any(S.NO_KEY_NETWORK in n.text for n in notes) + s = choices("--model", "q2_0", "--host", "192.0.2.10", "--access", "local") + s, _ = cmd.apply_defaults(s, catalog(HW_12)) + assert s.api_key + + +def test_saved_host_overrides_round_trip_and_access_can_reset_it(): + with Home(): + original = S.LaunchSettings(name="Office", family="qwen", model="Q2_0", access="lan", host="192.0.2.10") + store.save(original) + s = choices("--preset", "Office", "--host", "127.0.0.2") + assert (s.host, s.access) == ("127.0.0.2", "local") + assert store.load("Office").host == "192.0.2.10" + assert choices(*shlex.split(S.flags_text(s))[2:]).host == s.host + assert choices(*shlex.split(S.flags_text(s, preset="Office"))[2:]).host == s.host + assert choices("--preset", "Office", "--access", "local").host is None + assert cmd.launch_info("Office")["url"] == "http://192.0.2.10:8080" + + +@pytest.mark.parametrize("value", ["", "http://127.0.0.1", "localhost:8080", "192.0.2.999", "::1", "example.com", "127.0.0.1 "]) +def test_bad_host_is_rejected_before_hardware_or_downloads(value): + with mock.patch.object(Runner, "run", side_effect=AssertionError("must not run")): + code, out, err = run_cli(["launch", "--host", value, "--no-tui"]) + assert code == 2 + assert "IPv4 address or localhost" in err + assert "Traceback" not in out + err + + +@pytest.mark.parametrize("kind,host", [("cloudflare", "192.0.2.10"), ("tailscale", "127.0.0.1")]) +def test_host_cannot_make_a_fixed_address_helper_unreachable(kind, host): + notes = S.check_access(S.LaunchSettings(access=kind, host=host)) + assert notes[0].level == "error" + assert "--host" in notes[0].line() + + +def test_cloudflare_can_still_reach_a_wildcard_bind(): + s = S.LaunchSettings(access="cloudflare", host="0.0.0.0", api_key="synthetic-key") + assert S.check_access(s) == [] + assert access.urls_for(s, tunnel_url="https://example.trycloudflare.com") == [ + "https://example.trycloudflare.com", "http://127.0.0.1:8080"] + + +def test_wildcard_is_never_advertised_as_a_client_destination(): + s = S.LaunchSettings(access="lan", host="0.0.0.0", port=9090) + with mock.patch.object(access, "lan_ips", return_value=["192.0.2.10"]): + assert access.urls_for(s) == ["http://127.0.0.1:9090", "http://192.0.2.10:9090"] + + +def test_omitted_host_keeps_legacy_serialization_flags_and_bind_defaults(): + for kind, host in (("local", "127.0.0.1"), ("lan", "0.0.0.0"), + ("cloudflare", "127.0.0.1"), ("tailscale", "0.0.0.0")): + s = S.LaunchSettings(access=kind) + assert "host" not in s.to_dict() + assert "--host" not in S.flags_text(s) + assert derive_config({"args": []}, s, bool_flags=frozenset())["host"] == host + + +def test_same_host_reuses_the_server_but_a_changed_host_requires_restart(): + with Home(): + s = S.LaunchSettings(name="binding", family="qwen", model="Q2_0", host="127.0.0.2") + runner = Runner(s, catalog=catalog(HW_12)) + row = {"profile": runner.profile, "name": runner.name, "port": s.port, "choices": runner.choices()} + with mock.patch.object(Runner, "serving_launches", return_value=[row]), \ + mock.patch.object(runtime, "stop") as stop: + result = runner.check_serving() + assert result.reused + assert result.urls == ["http://127.0.0.2:8080"] + runner.s.host = "127.0.0.3" + with pytest.raises(LaunchError) as error: + runner.check_serving() + assert error.value.code == 6 + assert "--restart" in error.value.next + stop.assert_not_called() + runner.restart = True + assert runner.check_serving() is None + + +def test_explicit_shared_host_keeps_an_existing_key(): + with Home(), mock.patch.object(access, "kept_key", return_value="synthetic-existing-key"), \ + mock.patch.object(access, "new_key", side_effect=AssertionError("must keep the key")): + s = choices("--model", "q2_0", "--host", "192.0.2.10") + s, _ = cmd.apply_defaults(s, catalog(HW_12)) + assert s.api_key == "synthetic-existing-key" + + +def test_monitor_uses_the_explicit_address(): + from strata_cli.launch import app, monitor_ext + with mock.patch.object(monitor_ext, "LaunchMonitor") as monitor: + monitor.return_value.stop_requested = False + info = {"url": "http://127.0.0.2:9090", "urls": ["http://127.0.0.2:9090"]} + assert app.run_monitor(info) == 0 + assert monitor.call_args.args[0].root == "http://127.0.0.2:9090" + + +def test_access_screen_keeps_a_saved_bind_until_the_access_choice_changes(): + from strata_cli.launch.app import Ctx, LaunchApp + from strata_cli.launch.screens import AccessScreen + from tests.terminal.launch_support import text_of + + async def exercise(): + cat = catalog(HW_12) + s = S.LaunchSettings(access="local", host="127.0.0.2") + ctx = Ctx(catalog=cat, computer=cat.computer(), rows=cat.rows(), settings=s) + app = LaunchApp(ctx) + async with app.run_test(size=(110, 34)) as pilot: + app.show(AccessScreen()) + await pilot.pause() + assert s.host == "127.0.0.2" + assert "127.0.0.2" in text_of(app) + await pilot.press("down") + await pilot.pause() + assert s.access == "lan" + assert s.host is None + + with Home(), mock.patch.object(access, "lan_ips", return_value=[]): + asyncio.run(exercise()) + + +def test_http_monitor_accepts_owned_interfaces_but_not_other_lan_hosts(): + from strata_cli.client import Endpoint + with mock.patch("psutil.net_if_addrs", return_value={"test": [SimpleNamespace(address="192.0.2.10")]}): + assert Endpoint("http://192.0.2.10:9090").root == "http://192.0.2.10:9090" + for host in ("192.0.2.11", "example.com", "0.0.0.0", "224.0.0.1"): + with pytest.raises(ValueError, match="requires HTTPS"): + Endpoint(f"http://{host}:9090") + + +def test_monitor_reaches_a_nondefault_loopback_bind_without_the_system_proxy(): + from strata_cli.client import Endpoint + + class Health(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"status":"ok"}') + + def log_message(self, *args): + pass + + server = http.server.ThreadingHTTPServer(("127.0.0.2", 0), Health) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + url = access.urls_for(S.LaunchSettings(host="127.0.0.2", port=server.server_port))[0] + env = {"HTTP_PROXY": "http://127.0.0.1:9", "http_proxy": "http://127.0.0.1:9", "NO_PROXY": "", "no_proxy": ""} + with mock.patch.dict(os.environ, env): + assert Endpoint(url).get("/health") == {"status": "ok"} + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5)