hosted_dashboard.py
python
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1
docs: MuseHub-first before ISR #74 — staging solidify NEXT
Human
1 day ago
| 1 | """``ok hosted-dashboard`` command (§HGD.10.1).""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import os |
| 6 | import sys |
| 7 | import webbrowser |
| 8 | from argparse import Namespace |
| 9 | from pathlib import Path |
| 10 | |
| 11 | import yaml |
| 12 | |
| 13 | from cli.context import CliContext |
| 14 | from cli.paths import PathEscapeError, resolve_repo_root |
| 15 | from tools.hosted_dashboard.adapters.github import GitHubAdapters |
| 16 | from tools.hosted_dashboard.auth import generate_viewer_token |
| 17 | from tools.hosted_dashboard.bind import DEFAULT_PORT, port_is_available, validate_bind_address |
| 18 | from tools.hosted_dashboard.config import ( |
| 19 | HostedDashboardConfigError, |
| 20 | default_hosted_dashboard_config, |
| 21 | parse_hosted_dashboard_config, |
| 22 | ) |
| 23 | from tools.hosted_dashboard.handlers import DashboardService |
| 24 | from tools.hosted_dashboard.http_client import UpstreamClient |
| 25 | from tools.hosted_dashboard.scopes import refuse_write_scopes |
| 26 | from tools.hosted_dashboard.server import HostedServerConfig, run_hosted_server |
| 27 | |
| 28 | VIEWER_TOKEN_ENV = "OVERSEER_HOSTED_DASHBOARD_VIEWER_TOKEN" |
| 29 | UPSTREAM_TOKEN_ENV = "OVERSEER_HOSTED_DASHBOARD_TOKEN" |
| 30 | # Documented synonym |
| 31 | UPSTREAM_TOKEN_SYNONYM = "OVERSEER_HOSTED_DASHBOARD_GITHUB_TOKEN" |
| 32 | |
| 33 | |
| 34 | def _load_dashboard_config(config_path: Path | None) -> tuple[object, Path | None]: |
| 35 | if config_path is None: |
| 36 | return default_hosted_dashboard_config(), None |
| 37 | if not config_path.is_file(): |
| 38 | raise HostedDashboardConfigError(f"config file missing: {config_path}") |
| 39 | raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) |
| 40 | if not isinstance(raw, dict): |
| 41 | raise HostedDashboardConfigError("config root must be a mapping") |
| 42 | return parse_hosted_dashboard_config(raw.get("hosted_dashboard"), path=str(config_path)), config_path |
| 43 | |
| 44 | |
| 45 | def _resolve_config_path(args: Namespace, ctx: CliContext) -> Path | None: |
| 46 | if getattr(args, "config", None): |
| 47 | return Path(args.config).expanduser().resolve() |
| 48 | # Prefer cwd .overseer/config.yaml when present. |
| 49 | candidate = (ctx.cwd / ".overseer" / "config.yaml").resolve() |
| 50 | if candidate.is_file(): |
| 51 | return candidate |
| 52 | # Also try --repo root if provided. |
| 53 | if getattr(args, "repo", None): |
| 54 | try: |
| 55 | repo_root = resolve_repo_root(cwd=ctx.cwd, repo_arg=args.repo, command="hosted-dashboard") |
| 56 | except PathEscapeError: |
| 57 | return None |
| 58 | alt = repo_root / ".overseer" / "config.yaml" |
| 59 | if alt.is_file(): |
| 60 | return alt.resolve() |
| 61 | return None |
| 62 | |
| 63 | |
| 64 | def _print_startup_banner(bind: str, port: int, viewer_token: str, *, ephemeral: bool) -> None: |
| 65 | host = "127.0.0.1" if bind in {"127.0.0.1", "localhost"} else bind |
| 66 | if host == "::1": |
| 67 | host = "[::1]" |
| 68 | url = f"http://{host}:{port}/" |
| 69 | print("ok hosted-dashboard listening", file=sys.stderr) |
| 70 | print(f"url: {url}", file=sys.stderr) |
| 71 | print(f"mode: hosted-read-only", file=sys.stderr) |
| 72 | label = "viewer_token (ephemeral — copy once)" if ephemeral else "viewer_token" |
| 73 | print(f"{label}: {viewer_token}", file=sys.stderr) |
| 74 | |
| 75 | |
| 76 | def run_hosted_dashboard(args: Namespace, ctx: CliContext) -> int: |
| 77 | """Start the hosted governance dashboard preview server.""" |
| 78 | port = int(args.port) if args.port is not None else DEFAULT_PORT |
| 79 | if port < 1 or port > 65535: |
| 80 | ctx.output.error("refused: port must be between 1 and 65535") |
| 81 | return 1 |
| 82 | |
| 83 | try: |
| 84 | config_path = _resolve_config_path(args, ctx) |
| 85 | dashboard, _ = _load_dashboard_config(config_path) |
| 86 | except HostedDashboardConfigError as exc: |
| 87 | ctx.output.error(f"refused: {exc}") |
| 88 | return 2 |
| 89 | |
| 90 | bind = validate_bind_address(args.bind, allow_non_loopback=dashboard.allow_non_loopback) |
| 91 | if bind is None: |
| 92 | ctx.output.error( |
| 93 | "refused: non-loopback bind requires hosted_dashboard.allow_non_loopback: true" |
| 94 | ) |
| 95 | return 2 |
| 96 | |
| 97 | if not port_is_available(bind, port): |
| 98 | ctx.output.error(f"refused: port {port} is already in use") |
| 99 | return 2 |
| 100 | |
| 101 | # Optional introspection of write scopes via env JSON list (operator/tests). |
| 102 | scopes_env = os.environ.get("OVERSEER_HOSTED_DASHBOARD_SCOPES") |
| 103 | advertised_scopes = None |
| 104 | if scopes_env: |
| 105 | advertised_scopes = [s.strip() for s in scopes_env.split(",") if s.strip()] |
| 106 | write_refused = refuse_write_scopes(advertised_scopes) is not None |
| 107 | if write_refused and os.environ.get("OVERSEER_HOSTED_DASHBOARD_REFUSE_ON_WRITE_SCOPE", "1") == "1": |
| 108 | ctx.output.error("refused: write_scope_refused") |
| 109 | return 2 |
| 110 | |
| 111 | upstream = os.environ.get(UPSTREAM_TOKEN_ENV) or os.environ.get(UPSTREAM_TOKEN_SYNONYM) |
| 112 | viewer = os.environ.get(VIEWER_TOKEN_ENV) |
| 113 | ephemeral = False |
| 114 | if not viewer: |
| 115 | viewer = generate_viewer_token() |
| 116 | ephemeral = True |
| 117 | |
| 118 | client = UpstreamClient( |
| 119 | token=upstream, |
| 120 | extra_allowed_hosts=dashboard.musehub_hosts, |
| 121 | ) |
| 122 | adapters = GitHubAdapters( |
| 123 | client, |
| 124 | checks_advisory=dashboard.sources.github_checks_advisory, |
| 125 | enumeration_cap=dashboard.enumeration_cap, |
| 126 | max_doc_bytes=dashboard.max_doc_bytes, |
| 127 | ) |
| 128 | service = DashboardService(adapters, dashboard) |
| 129 | server_config = HostedServerConfig( |
| 130 | bind=bind, |
| 131 | port=port, |
| 132 | viewer_token=viewer, |
| 133 | upstream_token=upstream, |
| 134 | dashboard=dashboard, |
| 135 | service=service, |
| 136 | require_loopback_peer=bind in {"127.0.0.1", "localhost", "::1"}, |
| 137 | write_scope_refused=write_refused, |
| 138 | ) |
| 139 | |
| 140 | open_browser = bool(args.open) |
| 141 | |
| 142 | def on_ready(config: HostedServerConfig, _addr: str) -> None: |
| 143 | _print_startup_banner(config.bind, config.port, config.viewer_token, ephemeral=ephemeral) |
| 144 | if open_browser: |
| 145 | host = "127.0.0.1" if config.bind in {"127.0.0.1", "localhost"} else config.bind |
| 146 | if host == "::1": |
| 147 | host = "[::1]" |
| 148 | webbrowser.open(f"http://{host}:{config.port}/") |
| 149 | |
| 150 | return run_hosted_server(server_config, on_ready=on_ready) |
File History
1 commit
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1
docs: MuseHub-first before ISR #74 — staging solidify NEXT
Human
1 day ago