From f9f9cec392e21a451cceee05678c26cb568d239d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 14 Aug 2026 22:56:24 -0400 Subject: [PATCH 01/11] Add CI, a reproducible OpenAPI exporter, and a docs drift guard The repo shipped pytest, Node and ruff guards that only ever ran by hand, and docs/api/ could drift from the adapters unnoticed. scripts/export_openapi.py regenerates every worker document from one entry point; adapters import their model libraries lazily, so it needs no engine venv. --check turns it into a drift gate, and pytest asserts the committed documents still match. CI runs ruff + pytest (with the fake engine venv installed so the worker integration test runs instead of skipping) and the Node WebUI tests on push and pull request. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G6DQkv8vHsu7CotHM7Pa8s --- .github/workflows/ci.yml | 42 +++++++++++++++ README.md | 8 ++- README_CN.md | 7 ++- hub/tests/test_openapi_export.py | 51 ++++++++++++++++++ scripts/export_openapi.py | 89 ++++++++++++++++++++++++++++++++ 5 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 hub/tests/test_openapi_export.py create mode 100644 scripts/export_openapi.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6d33d83 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + hub: + name: hub (ruff + pytest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install the fake engine venv + # Its only dependency is the local SDK, so the hub's integration test + # can spawn a real worker instead of skipping itself. + run: uv sync --python 3.12 + working-directory: engines/fake + - name: Install the hub venv + run: uv sync --python 3.12 --group dev + working-directory: hub + - name: Lint + run: uv run ruff check ../hub ../sdk ../engines ../scripts --exclude ../engines/gpt_sovits/vendor + working-directory: hub + - name: Test + # Includes the docs/api drift guard (tests/test_openapi_export.py). + run: uv run pytest -q + working-directory: hub + + webui: + name: webui (node) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - run: npm run test:web + working-directory: hub diff --git a/README.md b/README.md index fc384c6..f17c5eb 100644 --- a/README.md +++ b/README.md @@ -161,10 +161,14 @@ schema as the `/capabilities` response example. Regenerate after changing an adapter: ```bash -cd engines/ && uv run --no-sync python -m tts_hub_sdk.export_openapi \ - "$(grep '^module' engine.toml | cut -d'"' -f2)" > ../../docs/api/.openapi.json +uv run --project hub python scripts/export_openapi.py # rewrite every engine's doc +uv run --project hub python scripts/export_openapi.py --check # what CI enforces ``` +Adapters import their model libraries lazily, so this needs no engine venv. +A single worker can still be exported by hand from its own directory with +`uv run --no-sync python -m tts_hub_sdk.export_openapi adapter:MyEngine`. + Engines with legacy clients also mount dialect routes — MOSS-TTS-Nano keeps the upstream multipart `POST /api/generate` (base64 JSON response), included in its OpenAPI doc. diff --git a/README_CN.md b/README_CN.md index af433e7..94269ee 100644 --- a/README_CN.md +++ b/README_CN.md @@ -151,10 +151,13 @@ curl -X POST localhost:5077/clone \ 的真实参数表。改了适配器后重新生成: ```bash -cd engines/ && uv run --no-sync python -m tts_hub_sdk.export_openapi \ - "$(grep '^module' engine.toml | cut -d'"' -f2)" > ../../docs/api/.openapi.json +uv run --project hub python scripts/export_openapi.py # 重新生成全部引擎文档 +uv run --project hub python scripts/export_openapi.py --check # CI 执行的漂移检查 ``` +适配器都是懒加载模型库,所以这条命令不需要任何引擎 venv。也可以在单个引擎 +目录里手动导出:`uv run --no-sync python -m tts_hub_sdk.export_openapi adapter:MyEngine`。 + 有历史客户端的引擎还挂了方言路由——MOSS-TTS-Nano 保留上游的 multipart `POST /api/generate`(base64 JSON 响应),也包含在它的 OpenAPI 文档里。 diff --git a/hub/tests/test_openapi_export.py b/hub/tests/test_openapi_export.py new file mode 100644 index 0000000..2cbe2d2 --- /dev/null +++ b/hub/tests/test_openapi_export.py @@ -0,0 +1,51 @@ +"""Guards for scripts/export_openapi.py, the source of docs/api/.""" +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] + + +def _load_exporter(): + spec = importlib.util.spec_from_file_location( + "aviary_export_openapi", ROOT / "scripts" / "export_openapi.py") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +exporter = _load_exporter() + + +def test_every_engine_directory_is_exported(): + exported = {d.name for d in exporter.engine_dirs()} + on_disk = {d.name for d in (ROOT / "engines").iterdir() + if d.is_dir() and (d / "engine.toml").exists()} + assert exported == on_disk + + +def test_render_is_deterministic_and_describes_the_worker_contract(): + fake = ROOT / "engines" / "fake" + first = exporter.serialize(exporter.render(fake)) + second = exporter.serialize(exporter.render(fake)) + assert first == second, "export must be byte-stable to be drift-checkable" + + doc = json.loads(first) + assert set(doc["paths"]) >= {"/", "/capabilities", "/v1/audio/speech", "/clone"} + + +@pytest.mark.parametrize("engine_id", sorted( + d.name for d in (ROOT / "engines").iterdir() + if d.is_dir() and (d / "engine.toml").exists())) +def test_committed_docs_match_the_adapters(engine_id: str): + committed = ROOT / "docs" / "api" / f"{engine_id}.openapi.json" + assert committed.exists(), f"missing docs/api/{engine_id}.openapi.json" + fresh = exporter.serialize(exporter.render(ROOT / "engines" / engine_id)) + assert committed.read_text() == fresh, ( + "docs/api is stale — run: uv run --project hub python scripts/export_openapi.py") diff --git a/scripts/export_openapi.py b/scripts/export_openapi.py new file mode 100644 index 0000000..45b6a64 --- /dev/null +++ b/scripts/export_openapi.py @@ -0,0 +1,89 @@ +"""Regenerate (or drift-check) the worker OpenAPI documents in docs/api/. + + python scripts/export_openapi.py # rewrite docs/api/*.openapi.json + python scripts/export_openapi.py --check # fail if the committed docs are stale + +Adapters import their model libraries lazily inside ``load()``, so building a +worker app only needs the SDK's own dependencies — no engine venv required. +""" +from __future__ import annotations + +import argparse +import importlib +import json +import sys +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ENGINES_DIR = ROOT / "engines" +DEFAULT_OUT = ROOT / "docs" / "api" + +sys.path.insert(0, str(ROOT / "sdk")) + + +def render(engine_dir: Path) -> dict: + """Build one worker app off its adapter and return its OpenAPI document.""" + manifest = tomllib.loads((engine_dir / "engine.toml").read_text()) + spec = manifest.get("engine", {}).get("module", "adapter:Engine") + mod_name, _, cls_name = spec.partition(":") + + sys.path.insert(0, str(engine_dir)) + try: + # Every engine names its module "adapter"; drop the previous one so + # each directory is imported fresh instead of hitting the cache. + for cached in [m for m in sys.modules + if m == mod_name or m.startswith(mod_name + ".")]: + del sys.modules[cached] + module = importlib.import_module(mod_name) + engine = getattr(module, cls_name)() + + from tts_hub_sdk.server import build_app + dialects = getattr(module, "DIALECTS", None) + app = build_app(engine, dialects=list(dialects) if dialects else None) + return app.openapi() + finally: + sys.path.remove(str(engine_dir)) + + +def serialize(doc: dict) -> str: + return json.dumps(doc, indent=2, ensure_ascii=False) + "\n" + + +def engine_dirs() -> list[Path]: + return [d for d in sorted(ENGINES_DIR.iterdir()) + if d.is_dir() and (d / "engine.toml").exists()] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, default=DEFAULT_OUT, + help="output directory (default: docs/api)") + parser.add_argument("--check", action="store_true", + help="compare only; exit 1 when the docs are stale") + args = parser.parse_args() + args.out.mkdir(parents=True, exist_ok=True) + + stale: list[str] = [] + for engine_dir in engine_dirs(): + payload = serialize(render(engine_dir)) + target = args.out / f"{engine_dir.name}.openapi.json" + current = target.read_text() if target.exists() else None + if args.check: + if current != payload: + stale.append(target.name) + continue + if current != payload: + target.write_text(payload) + print(f"updated {target.relative_to(ROOT)}") + + if stale: + print("OpenAPI docs are stale: " + ", ".join(stale), file=sys.stderr) + print("Run: python scripts/export_openapi.py", file=sys.stderr) + return 1 + print("OpenAPI docs are up to date" if args.check else "done") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From e51387ef8809cc5e98d15cf61169057dc2a1dbba Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 14 Aug 2026 22:57:29 -0400 Subject: [PATCH 02/11] Keep detached lifecycle tasks alive with a spawn helper Engine start/stop/restart, rescan-disable, autostart and idle-unload all ran through a bare asyncio.create_task. The loop holds only a weak reference to a task, so work nobody awaits can be collected mid-flight, and a failure inside it was swallowed entirely. spawn() holds a strong reference until completion, names the task for the logs, and reports exceptions. Tasks already stored on an object keep their own reference and are unchanged; a pytest walks the AST to make sure no new detached create_task creeps back in. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G6DQkv8vHsu7CotHM7Pa8s --- hub/tests/test_background_tasks.py | 84 ++++++++++++++++++++++++++++++ hub/tts_hub/aio.py | 37 +++++++++++++ hub/tts_hub/api.py | 7 +-- hub/tts_hub/supervisor.py | 7 +-- 4 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 hub/tests/test_background_tasks.py create mode 100644 hub/tts_hub/aio.py diff --git a/hub/tests/test_background_tasks.py b/hub/tests/test_background_tasks.py new file mode 100644 index 0000000..ab30077 --- /dev/null +++ b/hub/tests/test_background_tasks.py @@ -0,0 +1,84 @@ +"""Detached background work must outlive garbage collection and report failures.""" +from __future__ import annotations + +import ast +import asyncio +import gc +import logging +from pathlib import Path + +from tts_hub.aio import pending, spawn + + +def test_spawned_task_survives_gc_and_is_released_when_done(): + async def scenario(): + started = asyncio.Event() + + async def work(): + started.set() + await asyncio.sleep(0.05) + return "finished" + + task = spawn(work(), name="unit") + assert task in pending() + del task # the caller drops its reference immediately + await started.wait() + gc.collect() # the loop alone would not keep it alive + assert len(pending()) == 1 + survivor = next(iter(pending())) + assert await survivor == "finished" + await asyncio.sleep(0) + assert pending() == set() + + asyncio.run(scenario()) + + +def test_spawned_failure_is_logged_instead_of_swallowed(caplog): + async def boom(): + raise RuntimeError("worker exploded") + + with caplog.at_level(logging.ERROR, logger="hub.aio"): + async def scenario(): + task = spawn(boom(), name="explode") + await asyncio.gather(task, return_exceptions=True) + await asyncio.sleep(0) + + asyncio.run(scenario()) + + assert "background task explode failed" in caplog.text + assert "worker exploded" in caplog.text + assert pending() == set() + + +def test_cancelled_task_is_not_reported_as_a_failure(caplog): + with caplog.at_level(logging.ERROR, logger="hub.aio"): + async def scenario(): + task = spawn(asyncio.sleep(5), name="cancelled") + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await asyncio.sleep(0) + + asyncio.run(scenario()) + + assert caplog.text == "" + assert pending() == set() + + +def test_no_detached_create_task_calls_remain(): + """Lifecycle work must go through spawn(), or it can vanish mid-flight. + + A create_task whose result is stored (``self._tasks = [...]``) keeps its + own strong reference; only the discarded ones are the hazard. + """ + package = Path(__file__).resolve().parents[1] / "tts_hub" + offenders = [] + for source in sorted(package.glob("*.py")): + if source.name == "aio.py": + continue + tree = ast.parse(source.read_text()) + for node in ast.walk(tree): + if not isinstance(node, ast.Expr) or not isinstance(node.value, ast.Call): + continue + if ast.unparse(node.value.func).endswith("asyncio.create_task"): + offenders.append(f"{source.name}:{node.lineno}") + assert offenders == [], f"use spawn() instead: {offenders}" diff --git a/hub/tts_hub/aio.py b/hub/tts_hub/aio.py new file mode 100644 index 0000000..c034632 --- /dev/null +++ b/hub/tts_hub/aio.py @@ -0,0 +1,37 @@ +"""Fire-and-forget task helper. + +The event loop keeps only a weak reference to a task, so a bare +``asyncio.create_task`` for work nobody awaits can be garbage collected +mid-flight. Everything here holds a strong reference until the task +finishes, and surfaces failures that would otherwise be swallowed. +""" +from __future__ import annotations + +import asyncio +import logging + +log = logging.getLogger("hub.aio") + +_BACKGROUND: set[asyncio.Task] = set() + + +def spawn(coro, *, name: str | None = None) -> asyncio.Task: + """Run ``coro`` detached, keeping it alive and logging any failure.""" + task = asyncio.create_task(coro, name=name) + _BACKGROUND.add(task) + task.add_done_callback(_finished) + return task + + +def _finished(task: asyncio.Task) -> None: + _BACKGROUND.discard(task) + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + log.error("background task %s failed", task.get_name(), exc_info=exc) + + +def pending() -> set[asyncio.Task]: + """Tasks still running (tests and diagnostics).""" + return set(_BACKGROUND) diff --git a/hub/tts_hub/api.py b/hub/tts_hub/api.py index c1dcc46..10e0e9b 100644 --- a/hub/tts_hub/api.py +++ b/hub/tts_hub/api.py @@ -13,6 +13,7 @@ from sse_starlette.sse import EventSourceResponse from . import config as C +from .aio import spawn from .events import sse_dict @@ -38,13 +39,13 @@ def _engine_or_404(engine_id: str): @r.post("/engines/{engine_id}/start") async def engine_start(engine_id: str): eng = _engine_or_404(engine_id) - asyncio.create_task(eng.start()) + spawn(eng.start(), name=f"start:{engine_id}") return {"ok": True} @r.post("/engines/{engine_id}/stop") async def engine_stop(engine_id: str): eng = _engine_or_404(engine_id) - asyncio.create_task(eng.stop()) + spawn(eng.stop(), name=f"stop:{engine_id}") return {"ok": True} @r.post("/engines/{engine_id}/restart") @@ -54,7 +55,7 @@ async def engine_restart(engine_id: str): async def _restart(): await eng.stop() await eng.start() - asyncio.create_task(_restart()) + spawn(_restart(), name=f"restart:{engine_id}") return {"ok": True} @r.get("/engines/{engine_id}/logs") diff --git a/hub/tts_hub/supervisor.py b/hub/tts_hub/supervisor.py index 286e7f7..64524f8 100644 --- a/hub/tts_hub/supervisor.py +++ b/hub/tts_hub/supervisor.py @@ -25,6 +25,7 @@ import httpx import psutil +from .aio import spawn from .config import EngineManifest, HubConfig from .events import Broadcaster @@ -363,7 +364,7 @@ def rescan(self, manifests: dict[str, EngineManifest]): cur.m.idle_unload_min = m.idle_unload_min cur.m.enabled = m.enabled if not m.enabled: - asyncio.create_task(cur.stop()) + spawn(cur.stop(), name=f"rescan-disable:{mid}") self.events.publish("engine", cur.snapshot()) else: self.engines[mid] = EngineProc(m, self.cfg, self.events) @@ -411,7 +412,7 @@ async def startup(self): self._bg = asyncio.create_task(self._housekeeping()) for e in self.engines.values(): if e.m.autostart: - asyncio.create_task(e.start()) + spawn(e.start(), name=f"autostart:{e.m.id}") async def shutdown(self): if self._bg: @@ -432,7 +433,7 @@ async def _housekeeping(self): if (e.status == "ready" and idle_min > 0 and now - e.last_used > idle_min * 60): e._log_line(f"[hub] idle for {idle_min} min; unloading") - asyncio.create_task(e.stop()) + spawn(e.stop(), name=f"idle-unload:{e.m.id}") def snapshots(self, with_caps: bool = True) -> list[dict]: return [e.snapshot(with_caps) for e in self.engines.values()] From 06109f271ae9fc31814420f7b143b2bfadb057db Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 14 Aug 2026 22:58:33 -0400 Subject: [PATCH 03/11] Validate hub settings before applying them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUT /api/config coerced with a bare int(), so a non-numeric port answered 500, and nothing checked ranges — port 0 or a negative retention was accepted and written to config.toml. Worse, fields were applied one by one as they were parsed, so a later failure left the hub half-configured. Everything is now validated up front and rejected with a 422 the WebUI already surfaces as a toast; cfg is mutated only once the whole payload is known good. Bounds live in config.HUB_INT_BOUNDS beside the defaults. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G6DQkv8vHsu7CotHM7Pa8s --- hub/tests/test_config_api.py | 78 ++++++++++++++++++++++++++++++++++++ hub/tts_hub/api.py | 34 +++++++++++++--- hub/tts_hub/config.py | 8 ++++ 3 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 hub/tests/test_config_api.py diff --git a/hub/tests/test_config_api.py b/hub/tests/test_config_api.py new file mode 100644 index 0000000..02a7be2 --- /dev/null +++ b/hub/tests/test_config_api.py @@ -0,0 +1,78 @@ +"""PUT /api/config rejects bad input instead of 500-ing or half-applying it.""" +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from tts_hub import config as config_module +from tts_hub.main import create_app + +BASELINE = "[hub]\nhost = '0.0.0.0'\nport = 5050\ndata_dir = 'data'\n" \ + "retention_days = 7\nmax_upload_mb = 100\n" + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch): + monkeypatch.setattr(config_module, "ROOT", tmp_path) + monkeypatch.setattr(config_module, "CONFIG_PATH", tmp_path / "config.toml") + monkeypatch.setattr(config_module, "ENGINES_DIR", tmp_path / "engines") + (tmp_path / "config.toml").write_text(BASELINE) + with TestClient(create_app()) as test_client: + test_client.config_path = tmp_path / "config.toml" # type: ignore[attr-defined] + yield test_client + + +@pytest.mark.parametrize("payload, expected", [ + ({"hub": {"port": "not-a-number"}}, "port must be a whole number"), + ({"hub": {"port": 0}}, "port must be between 1 and 65535"), + ({"hub": {"port": 70000}}, "port must be between 1 and 65535"), + ({"hub": {"retention_days": -1}}, "retention_days must be between 0 and 3650"), + ({"hub": {"max_upload_mb": 0}}, "max_upload_mb must be between 1 and 2048"), + ({"hub": {"host": " "}}, "host must not be empty"), + ({"hub": {"data_dir": ""}}, "data_dir must not be empty"), + ({"hub": {}, "engines": "melotts"}, "engines must be an object"), + ({"hub": "0.0.0.0"}, "hub must be an object"), +]) +def test_invalid_settings_are_rejected_without_touching_the_file(client, payload, expected): + before = client.config_path.read_text() + response = client.put("/api/config", json=payload) + assert response.status_code == 422 + assert response.text == expected + assert client.config_path.read_text() == before + + +def test_one_bad_field_does_not_apply_the_valid_fields_beside_it(client): + response = client.put("/api/config", json={ + "hub": {"retention_days": 30, "max_upload_mb": 99999}}) + assert response.status_code == 422 + assert "retention_days = 7" in client.config_path.read_text() + + config = client.get("/api/config").json() + assert config["hub"]["retention_days"] == 7 + + +def test_valid_settings_are_persisted_and_report_restart_fields(client): + response = client.put("/api/config", json={ + "hub": {"port": 5051, "retention_days": 30, "max_upload_mb": 50, + "host": "127.0.0.1"}, + "engines": {"melotts": {"idle_unload_min": 15}}}) + assert response.status_code == 200 + assert sorted(response.json()["restart_required_fields"]) == ["host", "port"] + + saved = client.config_path.read_text() + assert "port = 5051" in saved + assert "retention_days = 30" in saved + assert 'host = "127.0.0.1"' in saved + + config = client.get("/api/config").json() + assert config["hub"]["max_upload_mb"] == 50 + assert config["engines"] == {"melotts": {"idle_unload_min": 15}} + + +def test_boundary_values_are_accepted(client): + response = client.put("/api/config", json={ + "hub": {"port": 65535, "retention_days": 0, "max_upload_mb": 1}}) + assert response.status_code == 200 + assert "retention_days = 0" in client.config_path.read_text() diff --git a/hub/tts_hub/api.py b/hub/tts_hub/api.py index 10e0e9b..2e7959f 100644 --- a/hub/tts_hub/api.py +++ b/hub/tts_hub/api.py @@ -331,14 +331,36 @@ async def config_get(): async def config_put(request: Request): body = await request.json() hub = body.get("hub") or {} + if not isinstance(hub, dict): + return PlainTextResponse("hub must be an object", status_code=422) + # Validate everything before touching cfg: a half-applied config that + # then fails to save would leave the hub disagreeing with its own file. + parsed: dict = {} for k in ("host", "data_dir"): if k in hub: - setattr(cfg, k, str(hub[k])) - for k in ("port", "retention_days", "max_upload_mb"): - if k in hub: - setattr(cfg, k, int(hub[k])) - if isinstance(body.get("engines"), dict): - cfg.engines = body["engines"] + value = str(hub[k]).strip() + if not value: + return PlainTextResponse(f"{k} must not be empty", status_code=422) + parsed[k] = value + for k, (low, high) in C.HUB_INT_BOUNDS.items(): + if k not in hub: + continue + try: + value = int(hub[k]) + except (TypeError, ValueError): + return PlainTextResponse(f"{k} must be a whole number", status_code=422) + if not low <= value <= high: + return PlainTextResponse( + f"{k} must be between {low} and {high}", status_code=422) + parsed[k] = value + engines_override = body.get("engines") + if engines_override is not None and not isinstance(engines_override, dict): + return PlainTextResponse("engines must be an object", status_code=422) + + for k, value in parsed.items(): + setattr(cfg, k, value) + if isinstance(engines_override, dict): + cfg.engines = engines_override C.save_config(cfg) sup.rescan(C.scan_manifests(cfg)) restart_required = [k for k in ("host", "port", "data_dir") if k in hub] diff --git a/hub/tts_hub/config.py b/hub/tts_hub/config.py index 54228b4..d9b0fca 100644 --- a/hub/tts_hub/config.py +++ b/hub/tts_hub/config.py @@ -14,6 +14,14 @@ ENGINES_DIR = ROOT / "engines" +# Accepted ranges for the numeric hub settings the Settings page writes. +HUB_INT_BOUNDS = { + "port": (1, 65535), + "retention_days": (0, 3650), + "max_upload_mb": (1, 2048), +} + + @dataclasses.dataclass class HubConfig: host: str = "0.0.0.0" From 06944bdb01f5b9c9826b173986a6d8255571d1c0 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 14 Aug 2026 23:05:14 -0400 Subject: [PATCH 04/11] Add an opt-in access token for the hub API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hub binds 0.0.0.0 by default with no authentication, so any device on the LAN could rewrite config.toml through PUT /api/config — including data_dir and per-engine env vars, which land in a spawned worker process. Setting [hub] auth_token now gates every /api/* call behind a bearer token compared with compare_digest. Leaving it unset changes nothing. Streams that cannot send a header (SSE,