cctally 1.91.0 → 1.92.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +4 -2
  3. package/bin/_cctally_cache.py +903 -74
  4. package/bin/_cctally_config.py +57 -0
  5. package/bin/_cctally_core.py +94 -14
  6. package/bin/_cctally_dashboard.py +217 -19
  7. package/bin/_cctally_dashboard_conversation.py +170 -20
  8. package/bin/_cctally_dashboard_envelope.py +2 -0
  9. package/bin/_cctally_db.py +481 -19
  10. package/bin/_cctally_doctor.py +18 -1
  11. package/bin/_cctally_journal.py +1156 -21
  12. package/bin/_cctally_journal_repair.py +6 -0
  13. package/bin/_cctally_parser.py +26 -0
  14. package/bin/_cctally_quota.py +171 -55
  15. package/bin/_cctally_record.py +13 -1
  16. package/bin/_cctally_rederive.py +4 -0
  17. package/bin/_cctally_statusline.py +6 -6
  18. package/bin/_cctally_store.py +1061 -40
  19. package/bin/_cctally_transcript.py +32 -2
  20. package/bin/_cctally_tui.py +54 -6
  21. package/bin/_lib_cache_report.py +8 -3
  22. package/bin/_lib_codex_conversation.py +851 -81
  23. package/bin/_lib_codex_conversation_query.py +2031 -96
  24. package/bin/_lib_codex_find_projection.py +517 -0
  25. package/bin/_lib_codex_harness_preamble.py +176 -0
  26. package/bin/_lib_codex_hooks.py +5 -3
  27. package/bin/_lib_codex_js_scan.py +254 -0
  28. package/bin/_lib_codex_landmarks.py +309 -0
  29. package/bin/_lib_codex_title_clean.py +116 -0
  30. package/bin/_lib_conversation_dispatch.py +168 -22
  31. package/bin/_lib_conversation_query.py +62 -2
  32. package/bin/_lib_conversation_watch.py +4 -2
  33. package/bin/_lib_doctor.py +64 -0
  34. package/bin/_lib_quota_alert_axes.py +31 -34
  35. package/bin/_lib_stats_damage.py +523 -0
  36. package/bin/_lib_stats_publish.py +243 -0
  37. package/bin/cctally +17 -3
  38. package/dashboard/static/assets/index-Dat-mza6.js +97 -0
  39. package/dashboard/static/assets/{index-Dwirao3Y.css → index-DnWdv8um.css} +1 -1
  40. package/dashboard/static/dashboard.html +2 -2
  41. package/package.json +8 -1
  42. package/dashboard/static/assets/index-CILAoEja.js +0 -90
@@ -265,12 +265,14 @@ import bisect
265
265
  import contextlib
266
266
  import dataclasses
267
267
  import datetime as dt
268
+ import hmac
268
269
  import io
269
270
  import json
270
271
  import os
271
272
  import pathlib
272
273
  import queue
273
274
  import re
275
+ import secrets
274
276
  import shutil
275
277
  import signal as _signal
276
278
  import socket
@@ -1773,6 +1775,33 @@ def _format_url(host: str, port: int) -> str:
1773
1775
  return f"http://{host}:{port}/"
1774
1776
 
1775
1777
 
1778
+ def _dashboard_auth_url(url: str, token: "str | None") -> str:
1779
+ """Append a per-run token as a fragment, never as a server-visible query."""
1780
+ if token is None:
1781
+ return url
1782
+ return f"{url}#token={urllib.parse.quote(token, safe='')}"
1783
+
1784
+
1785
+ def _dashboard_lan_auth_token(
1786
+ bind_host: str, enabled: bool, *,
1787
+ token_factory=secrets.token_urlsafe) -> "str | None":
1788
+ """Mint one process-local token only for authenticated non-loopback binds."""
1789
+ gate = sys.modules["cctally"]._load_sibling("_lib_transcript_access")
1790
+ if not enabled or gate.is_loopback(gate.authority_host(bind_host)):
1791
+ return None
1792
+ return token_factory(32)
1793
+
1794
+
1795
+ def _dashboard_token_from_cookie(raw_cookie: str) -> "str | None":
1796
+ """Return the sole dashboard session-token cookie, rejecting duplicates."""
1797
+ values = []
1798
+ for part in (raw_cookie or "").split(";"):
1799
+ name, separator, value = part.strip().partition("=")
1800
+ if separator and name == "cctally_dashboard_token":
1801
+ values.append(value)
1802
+ return values[0] if len(values) == 1 and values[0] else None
1803
+
1804
+
1776
1805
  def _discover_lan_ip() -> "str | None":
1777
1806
  """Return the kernel's chosen IPv4 source address for off-host traffic.
1778
1807
 
@@ -4324,12 +4353,40 @@ def _debug_source_state_wire(bundle, source: str) -> dict:
4324
4353
  }
4325
4354
 
4326
4355
 
4327
- def _debug_source_counts(cache_conn, bundle) -> dict:
4356
+ def _debug_stats_fault(exc: BaseException, leg: str) -> dict:
4357
+ """The wire form of a stats attribution these debug reads may report.
4358
+
4359
+ #496 S3 §8 records a DELIBERATE deviation from F16's literal wording. F16
4360
+ asks that every path meeting a corrupt index produce a typed attribution
4361
+ AND reach the heal. The two debug helpers deliver the attribution WITHOUT
4362
+ the heal: they are short-lived diagnostic reads that bypass the corruption
4363
+ boundary for cost, and making a debug endpoint able to trigger a rebuild is
4364
+ a worse outcome than making it honest. The dashboard's main build path
4365
+ already reaches the heal, so a fault attributed here is healed on the next
4366
+ tick.
4367
+
4368
+ The shape is exactly the mapping `_sync_failure_envelope`'s attribution
4369
+ reader already accepts, so this travels on the established vocabulary
4370
+ rather than inventing a second one. No database probe is needed: the
4371
+ connection these helpers were opening is the stats index by construction.
4372
+ """
4373
+ return {
4374
+ "leg": leg,
4375
+ "database": "stats",
4376
+ "corruption": bool(_cctally()._is_sqlite_corruption_error(exc)),
4377
+ }
4378
+
4379
+
4380
+ def _debug_source_counts(cache_conn, bundle, *, faults=None) -> dict:
4328
4381
  """Bounded, source-owned counts and opaque state for the debug endpoint.
4329
4382
 
4330
4383
  Every table and predicate is fixed here. This deliberately reports no
4331
4384
  values from rows: roots, paths, logical limits, conversation IDs, and
4332
4385
  project labels never cross the diagnostic boundary.
4386
+
4387
+ ``faults`` collects `_debug_stats_fault` mappings for a stats read this
4388
+ diagnostic could not complete, so the endpoint reports the fault instead of
4389
+ silently degrading a stats failure into a missing count.
4333
4390
  """
4334
4391
  result = {
4335
4392
  source: _debug_source_state_wire(bundle, source)
@@ -4359,8 +4416,9 @@ def _debug_source_counts(cache_conn, bundle) -> dict:
4359
4416
  result[source]["tables"][table] = int(row[0])
4360
4417
  except sqlite3.Error:
4361
4418
  pass
4362
- except sqlite3.Error:
4363
- pass
4419
+ except sqlite3.Error as exc:
4420
+ if faults is not None:
4421
+ faults.append(_debug_stats_fault(exc, "debug-source-counts"))
4364
4422
  finally:
4365
4423
  if stats_conn is not None:
4366
4424
  stats_conn.close()
@@ -4390,7 +4448,7 @@ def _stats_ro_guarded():
4390
4448
  )
4391
4449
 
4392
4450
 
4393
- def _debug_cache_state(cache_conn) -> dict:
4451
+ def _debug_cache_state(cache_conn, *, faults=None) -> dict:
4394
4452
  """On-demand signature legs + pending-reingest flags + generation.
4395
4453
 
4396
4454
  The signature legs are the canonical ``compute_signature`` fields (ints /
@@ -4404,8 +4462,10 @@ def _debug_cache_state(cache_conn) -> dict:
4404
4462
  stats_conn = None
4405
4463
  try:
4406
4464
  stats_conn = _stats_ro_guarded()
4407
- except sqlite3.Error:
4465
+ except sqlite3.Error as exc:
4408
4466
  stats_conn = None
4467
+ if faults is not None:
4468
+ faults.append(_debug_stats_fault(exc, "debug-cache-state"))
4409
4469
  try:
4410
4470
  if stats_conn is not None:
4411
4471
  sig = sc.compute_signature(
@@ -4530,6 +4590,7 @@ _GET_ROUTES = (
4530
4590
  )
4531
4591
 
4532
4592
  _POST_ROUTES = (
4593
+ ("exact", "/api/auth", "_handle_post_auth", None, False),
4533
4594
  ("exact", "/api/sync", "_handle_post_sync", None, False),
4534
4595
  ("exact", "/api/settings", "_handle_post_settings", None, False),
4535
4596
  ("exact", "/api/alerts/test", "_handle_post_alerts_test", None, False),
@@ -4599,6 +4660,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4599
4660
  # cmd_dashboard before serve_forever; the per-request gate
4600
4661
  # (`_require_transcripts_allowed`) ANDs this with the request Host.
4601
4662
  cctally_expose_transcripts: bool = False
4663
+ # Per-process LAN API token. None preserves the byte-stable loopback and
4664
+ # explicit opt-out behavior; a value gates every /api/* request.
4665
+ cctally_api_token: "str | None" = None
4602
4666
 
4603
4667
  # Access log stays silent (deliberate — noisy in the parent terminal),
4604
4668
  # but server errors are REAL as of #279 S2: log_error routes through
@@ -4678,7 +4742,43 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4678
4742
  return True
4679
4743
  return False
4680
4744
 
4745
+ def _require_api_auth(self) -> bool:
4746
+ """Authenticate an API request before any route or method dispatch."""
4747
+ if not self.path.split("?", 1)[0].startswith("/api/"):
4748
+ return True
4749
+ token = type(self).cctally_api_token
4750
+ if token is None:
4751
+ return True
4752
+
4753
+ authorization = self.headers.get_all("Authorization", [])
4754
+ supplied = None
4755
+ if authorization:
4756
+ if (len(authorization) == 1
4757
+ and authorization[0].startswith("Bearer ")):
4758
+ candidate = authorization[0][len("Bearer "):]
4759
+ if candidate:
4760
+ supplied = candidate
4761
+ else:
4762
+ supplied = _dashboard_token_from_cookie(
4763
+ self.headers.get("Cookie", "")
4764
+ )
4765
+ if (supplied is not None and supplied.isascii()
4766
+ and hmac.compare_digest(supplied, token)):
4767
+ return True
4768
+
4769
+ encoded = encode_dashboard_json_bytes({"error": "unauthorized"})
4770
+ self.send_response(401)
4771
+ self.send_header("WWW-Authenticate", "Bearer")
4772
+ self.send_header("Content-Type", "application/json")
4773
+ self.send_header("Content-Length", str(len(encoded)))
4774
+ self.end_headers()
4775
+ if self.command != "HEAD":
4776
+ self.wfile.write(encoded)
4777
+ return False
4778
+
4681
4779
  def do_GET(self) -> None: # noqa: N802 — stdlib API
4780
+ if not self._require_api_auth():
4781
+ return
4682
4782
  if self._method_not_allowed_for_settings():
4683
4783
  return
4684
4784
  path = self.path.split("?", 1)[0]
@@ -4716,6 +4816,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4716
4816
  self.send_error(404, "not found")
4717
4817
 
4718
4818
  def do_POST(self) -> None: # noqa: N802 — stdlib API
4819
+ if not self._require_api_auth():
4820
+ return
4719
4821
  # No _method_not_allowed_for_settings() guard here (gate P1-3): POST
4720
4822
  # /api/settings must route to _handle_post_settings, not 405.
4721
4823
  if not self._dispatch(_POST_ROUTES):
@@ -4728,21 +4830,55 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4728
4830
  # For other paths we fall through to send_error(501) so the rest of
4729
4831
  # the surface keeps stdlib semantics.
4730
4832
  def do_PUT(self) -> None: # noqa: N802 — stdlib API
4833
+ if not self._require_api_auth():
4834
+ return
4731
4835
  if self._method_not_allowed_for_settings():
4732
4836
  return
4733
4837
  self.send_error(501, "Unsupported method ('PUT')")
4734
4838
 
4735
4839
  def do_DELETE(self) -> None: # noqa: N802 — stdlib API
4840
+ if not self._require_api_auth():
4841
+ return
4736
4842
  if self._method_not_allowed_for_settings():
4737
4843
  return
4738
4844
  if not self._dispatch(_DELETE_ROUTES):
4739
4845
  self.send_error(501, "Unsupported method ('DELETE')")
4740
4846
 
4741
4847
  def do_PATCH(self) -> None: # noqa: N802 — stdlib API
4848
+ if not self._require_api_auth():
4849
+ return
4742
4850
  if self._method_not_allowed_for_settings():
4743
4851
  return
4744
4852
  self.send_error(501, "Unsupported method ('PATCH')")
4745
4853
 
4854
+ def do_HEAD(self) -> None: # noqa: N802 — stdlib API
4855
+ if not self._require_api_auth():
4856
+ return
4857
+ if self._method_not_allowed_for_settings():
4858
+ return
4859
+ self.send_error(501, "Unsupported method ('HEAD')")
4860
+
4861
+ def do_OPTIONS(self) -> None: # noqa: N802 — stdlib API
4862
+ if not self._require_api_auth():
4863
+ return
4864
+ if self._method_not_allowed_for_settings():
4865
+ return
4866
+ self.send_error(501, "Unsupported method ('OPTIONS')")
4867
+
4868
+ def _handle_post_auth(self) -> None:
4869
+ """Bridge an accepted Bearer token to a browser-native session cookie."""
4870
+ token = type(self).cctally_api_token
4871
+ if token is None:
4872
+ self.send_error(404, "not found")
4873
+ return
4874
+ self.send_response(204)
4875
+ self.send_header(
4876
+ "Set-Cookie",
4877
+ f"cctally_dashboard_token={token}; Path=/api; "
4878
+ "HttpOnly; SameSite=Strict",
4879
+ )
4880
+ self.end_headers()
4881
+
4746
4882
  def _handle_post_sync(self) -> None:
4747
4883
  """Trigger refresh-usage + snapshot rebuild on user demand.
4748
4884
 
@@ -4956,13 +5092,16 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4956
5092
  source_bundle = self.snapshot_ref.get().source_bundle
4957
5093
  except Exception: # noqa: BLE001 -- diagnostics fail closed.
4958
5094
  source_bundle = None
4959
- sources = _debug_source_counts(None, source_bundle)
5095
+ stats_faults: list[dict] = []
5096
+ sources = _debug_source_counts(None, source_bundle, faults=stats_faults)
4960
5097
  try:
4961
5098
  conn = open_cache_db()
4962
5099
  try:
4963
5100
  dataset = _debug_cache_table_counts(conn)
4964
- cache_state = _debug_cache_state(conn)
4965
- sources = _debug_source_counts(conn, source_bundle)
5101
+ cache_state = _debug_cache_state(conn, faults=stats_faults)
5102
+ sources = _debug_source_counts(
5103
+ conn, source_bundle, faults=stats_faults
5104
+ )
4966
5105
  finally:
4967
5106
  conn.close()
4968
5107
  except Exception: # noqa: BLE001 -- a diagnostic must not expose raw errors.
@@ -4975,6 +5114,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4975
5114
  "phases": (last or {}).get("phases"),
4976
5115
  "cache_state": cache_state,
4977
5116
  "sources": sources,
5117
+ # Additive, and named rather than folded into `cache_state`: a
5118
+ # stats fault is not cache state, and #496 S3 §8 exists because a
5119
+ # stats failure reported as a cache one sends the user to
5120
+ # `cctally cache-sync --rebuild`.
5121
+ "stats_faults": stats_faults,
4978
5122
  }
4979
5123
  if body["phases"] is None:
4980
5124
  body["note"] = "tracing_disabled"
@@ -5256,7 +5400,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5256
5400
  "field": f"dashboard.{leaf}"},
5257
5401
  )
5258
5402
  return
5259
- if leaf not in ("cache_failure_markers", "live_tail"):
5403
+ if leaf not in (
5404
+ "cache_failure_markers", "live_tail", "lan_auth"
5405
+ ):
5260
5406
  self._respond_json(
5261
5407
  400,
5262
5408
  {"error": f"unknown dashboard settings key: {leaf}",
@@ -5264,7 +5410,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5264
5410
  )
5265
5411
  return
5266
5412
  dashboard_validated = {}
5267
- for _leaf in ("cache_failure_markers", "live_tail"):
5413
+ for _leaf in ("cache_failure_markers", "live_tail", "lan_auth"):
5268
5414
  if _leaf in dashboard_block:
5269
5415
  if not isinstance(dashboard_block[_leaf], bool):
5270
5416
  self._respond_json(
@@ -5659,12 +5805,20 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5659
5805
  # + live_tail) so the SettingsOverlay can repaint without a
5660
5806
  # follow-up GET. Default true (opt-out) when nothing is persisted.
5661
5807
  persisted_dash = merged.get("dashboard") or {}
5808
+ persisted_lan_auth = persisted_dash.get("lan_auth", True)
5662
5809
  out["dashboard"] = {
5663
5810
  "cache_failure_markers": bool(
5664
5811
  persisted_dash.get("cache_failure_markers", True)
5665
5812
  ),
5666
5813
  "live_tail": bool(persisted_dash.get("live_tail", True)),
5814
+ "lan_auth": (
5815
+ persisted_lan_auth
5816
+ if isinstance(persisted_lan_auth, bool)
5817
+ else True
5818
+ ),
5667
5819
  }
5820
+ if "lan_auth" in dashboard_validated:
5821
+ out["restart_required"] = ["dashboard.lan_auth"]
5668
5822
  out["saved_at"] = (
5669
5823
  dt.datetime.now(dt.timezone.utc)
5670
5824
  .strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -6918,15 +7072,28 @@ def _dashboard_initial_snapshot(args, *, pinned_now, display_tz_pref_override):
6918
7072
  display_tz_pref_override=display_tz_pref_override,
6919
7073
  stats_heal_attempted=False,
6920
7074
  )
6921
- except c.StatsEpochRebuildDeferred as exc:
6922
- return _dashboard_stats_epoch_deferred_snapshot(
7075
+ except c.StatsRebuildDeferred as exc:
7076
+ return _dashboard_stats_deferred_snapshot(
6923
7077
  args,
6924
7078
  pinned_now=pinned_now,
6925
7079
  exc=exc,
6926
7080
  )
6927
7081
  except tui._StatsSnapshotCorruption as fault:
6928
7082
  # The once-builder's finally has closed the cheap-seed stats handle.
6929
- tui._tui_heal_post_query_stats(fault.cause)
7083
+ #
7084
+ # #496 S3 §6: this call is made from INSIDE an `except` handler, so a
7085
+ # `BaseException` raised by the heal would escape past the sibling
7086
+ # `except` above and fail dashboard startup instead of hydrating it.
7087
+ # The shared deferral base is therefore caught AT the heal-call
7088
+ # boundary, and the correctly typed degraded frame is built directly.
7089
+ try:
7090
+ tui._tui_heal_post_query_stats(fault.cause)
7091
+ except c.StatsRebuildDeferred as deferred:
7092
+ return _dashboard_stats_deferred_snapshot(
7093
+ args,
7094
+ pinned_now=pinned_now,
7095
+ exc=deferred,
7096
+ )
6930
7097
  return _dashboard_initial_snapshot_once(
6931
7098
  args,
6932
7099
  pinned_now=pinned_now,
@@ -6935,8 +7102,13 @@ def _dashboard_initial_snapshot(args, *, pinned_now, display_tz_pref_override):
6935
7102
  )
6936
7103
 
6937
7104
 
6938
- def _dashboard_stats_epoch_deferred_snapshot(args, *, pinned_now, exc):
6939
- """Bind promptly with a typed degraded frame while replay runs detached."""
7105
+ def _dashboard_stats_deferred_snapshot(args, *, pinned_now, exc):
7106
+ """Bind promptly with a typed degraded frame while a rebuild runs detached.
7107
+
7108
+ Serves both deferral classes and keeps them distinct: a wrong EPOCH is a
7109
+ readable index, while a deferred corruption heal is an index that could not
7110
+ be read, and the attribution must say which (#496 S3 §6).
7111
+ """
6940
7112
 
6941
7113
  import time as _time
6942
7114
 
@@ -6970,7 +7142,7 @@ def _dashboard_stats_epoch_deferred_snapshot(args, *, pinned_now, exc):
6970
7142
  tui.SyncFailureAttribution(
6971
7143
  leg="stats-open",
6972
7144
  database="stats",
6973
- corruption=False,
7145
+ corruption=isinstance(exc, c.StatsHealDeferred),
6974
7146
  ),
6975
7147
  ),
6976
7148
  "doctor_payload": doctor_payload,
@@ -7266,6 +7438,10 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
7266
7438
  stored = "loopback"
7267
7439
  resolved_bind_stored = stored
7268
7440
  args.host = _resolve_dashboard_bind_for_runtime(resolved_bind_stored)
7441
+ lan_auth_enabled = bool(
7442
+ _config_known_value(config, "dashboard.lan_auth")
7443
+ )
7444
+ api_token = _dashboard_lan_auth_token(args.host, lan_auth_enabled)
7269
7445
 
7270
7446
  # F3: capture the canonical tz token from `--tz` (NOT the ZoneInfo
7271
7447
  # — the override needs to flow through `load_config()`-style readers
@@ -7365,6 +7541,7 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
7365
7541
  # in the doctor SSE block + /api/doctor reflects the actual --host
7366
7542
  # the process is serving, not just the config-only view the CLI sees.
7367
7543
  DashboardHTTPHandler.cctally_host = args.host
7544
+ DashboardHTTPHandler.cctally_api_token = api_token
7368
7545
  # Conversation viewer (Plan 2, spec §5): the resolved
7369
7546
  # `dashboard.expose_transcripts` opt-in. Read off the already-loaded
7370
7547
  # `config` the same way `dashboard.bind` is resolved above (the
@@ -7490,20 +7667,40 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
7490
7667
  is_loopback = bind_host in ("127.0.0.1", "localhost", "::1")
7491
7668
 
7492
7669
  if is_all_interfaces:
7493
- local_url = _format_url("localhost", bind_port)
7670
+ local_url = _dashboard_auth_url(
7671
+ _format_url("localhost", bind_port), api_token
7672
+ )
7494
7673
  lan_ip = _discover_lan_ip()
7495
7674
  print("dashboard: serving on all interfaces:", flush=True)
7496
7675
  print(f" - {local_url} (this machine)", flush=True)
7497
7676
  if lan_ip:
7498
- lan_url = _format_url(lan_ip, bind_port)
7677
+ lan_url = _dashboard_auth_url(
7678
+ _format_url(lan_ip, bind_port), api_token
7679
+ )
7499
7680
  print(f" - {lan_url} (LAN)", flush=True)
7681
+ if api_token is not None:
7682
+ print(f"dashboard: LAN access token: {api_token}", flush=True)
7683
+ elif not lan_auth_enabled:
7684
+ print(
7685
+ "warning: LAN authentication disabled by "
7686
+ "dashboard.lan_auth=false; restart after enabling it",
7687
+ flush=True,
7688
+ )
7500
7689
  print("Ctrl-C to stop", flush=True)
7501
7690
  elif is_loopback:
7502
7691
  url = _format_url("localhost", bind_port)
7503
7692
  print(f"dashboard: serving {url} — Ctrl-C to stop", flush=True)
7504
7693
  else:
7505
- url = _format_url(bind_host, bind_port)
7694
+ url = _dashboard_auth_url(_format_url(bind_host, bind_port), api_token)
7506
7695
  print(f"dashboard: serving {url} — Ctrl-C to stop", flush=True)
7696
+ if api_token is not None:
7697
+ print(f"dashboard: LAN access token: {api_token}", flush=True)
7698
+ elif not lan_auth_enabled:
7699
+ print(
7700
+ "warning: LAN authentication disabled by "
7701
+ "dashboard.lan_auth=false; restart after enabling it",
7702
+ flush=True,
7703
+ )
7507
7704
 
7508
7705
  http_thread = threading.Thread(target=srv.serve_forever, daemon=True,
7509
7706
  name="dashboard-http")
@@ -7528,6 +7725,7 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
7528
7725
  browser_url = _format_url("localhost", bind_port)
7529
7726
  else:
7530
7727
  browser_url = _format_url(bind_host, bind_port)
7728
+ browser_url = _dashboard_auth_url(browser_url, api_token)
7531
7729
  try:
7532
7730
  _wb.open(browser_url)
7533
7731
  except Exception as exc: