hosted_dashboard.py python
150 lines 5.7 KB
Raw
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