cctally 1.90.1 → 1.92.0

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 (44) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +2 -2
  3. package/bin/_cctally_cache.py +863 -74
  4. package/bin/_cctally_config.py +57 -0
  5. package/bin/_cctally_core.py +53 -8
  6. package/bin/_cctally_dashboard.py +146 -5
  7. package/bin/_cctally_dashboard_conversation.py +164 -18
  8. package/bin/_cctally_dashboard_envelope.py +69 -12
  9. package/bin/_cctally_dashboard_sources.py +27 -1
  10. package/bin/_cctally_db.py +372 -10
  11. package/bin/_cctally_doctor.py +18 -1
  12. package/bin/_cctally_journal.py +535 -13
  13. package/bin/_cctally_journal_repair.py +6 -0
  14. package/bin/_cctally_parser.py +6 -0
  15. package/bin/_cctally_quota.py +171 -55
  16. package/bin/_cctally_record.py +13 -1
  17. package/bin/_cctally_rederive.py +4 -0
  18. package/bin/_cctally_store.py +311 -6
  19. package/bin/_cctally_transcript.py +32 -2
  20. package/bin/_lib_cache_report.py +8 -3
  21. package/bin/_lib_cache_report_wire.py +8 -20
  22. package/bin/_lib_codex_conversation.py +959 -81
  23. package/bin/_lib_codex_conversation_query.py +2792 -167
  24. package/bin/_lib_codex_find_projection.py +370 -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_reasoning_headings.py +73 -0
  30. package/bin/_lib_codex_segments.py +259 -0
  31. package/bin/_lib_codex_title_clean.py +116 -0
  32. package/bin/_lib_conversation_dispatch.py +153 -21
  33. package/bin/_lib_conversation_watch.py +4 -2
  34. package/bin/_lib_dashboard_sources.py +33 -32
  35. package/bin/_lib_doctor.py +64 -0
  36. package/bin/_lib_quota_alert_axes.py +31 -34
  37. package/bin/_lib_stats_damage.py +523 -0
  38. package/bin/cctally +5 -0
  39. package/dashboard/static/assets/index-BEzzJtUd.js +97 -0
  40. package/dashboard/static/assets/index-DnWdv8um.css +1 -0
  41. package/dashboard/static/dashboard.html +2 -2
  42. package/package.json +9 -1
  43. package/dashboard/static/assets/index-Bar8-S1i.css +0 -1
  44. package/dashboard/static/assets/index-CRogVlEC.js +0 -92
@@ -307,6 +307,7 @@ ALLOWED_CONFIG_KEYS = (
307
307
  "dashboard.expose_transcripts",
308
308
  "dashboard.cache_failure_markers",
309
309
  "dashboard.live_tail",
310
+ "dashboard.lan_auth",
310
311
  "update.check.enabled",
311
312
  "update.check.ttl_hours",
312
313
  "update.channel",
@@ -1028,6 +1029,22 @@ def _config_known_value(config: dict, key: str) -> "object":
1028
1029
  except ValueError:
1029
1030
  return True
1030
1031
  return True
1032
+ if key == "dashboard.lan_auth":
1033
+ # Boolean opt-OUT (issue #282). Default TRUE — non-loopback dashboard
1034
+ # runs require their per-run access token unless the user explicitly
1035
+ # disables the gate. Invalid hand edits fail safe to authentication ON.
1036
+ block = config.get("dashboard") if isinstance(config, dict) else None
1037
+ if not isinstance(block, dict):
1038
+ block = {}
1039
+ stored = block.get("lan_auth")
1040
+ if stored is None:
1041
+ return True
1042
+ if isinstance(stored, bool):
1043
+ return stored
1044
+ # The config command normalizes accepted text spellings before write.
1045
+ # A persisted string is therefore a hand edit, not an explicit opt-out;
1046
+ # accept only the JSON boolean false at this security boundary.
1047
+ return True
1031
1048
  if key == "telemetry.enabled":
1032
1049
  # Boolean opt-OUT (anonymous install-count telemetry, spec 2026-07-07).
1033
1050
  # Default TRUE — absence is ON. A hand-edited junk value surfaces the
@@ -1623,6 +1640,34 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
1623
1640
  else:
1624
1641
  print(f"dashboard.live_tail={'true' if canonical else 'false'}")
1625
1642
  return 0
1643
+ if key == "dashboard.lan_auth":
1644
+ try:
1645
+ canonical = c._normalize_alerts_enabled_value(raw)
1646
+ except ValueError:
1647
+ print(
1648
+ f"cctally: invalid boolean value for dashboard.lan_auth: "
1649
+ f"{raw!r} (expected true|false|yes|no|1|0|on|off)",
1650
+ file=sys.stderr,
1651
+ )
1652
+ return 2
1653
+ with config_writer_lock():
1654
+ config = _load_config_unlocked()
1655
+ existing = config.get("dashboard")
1656
+ if existing is not None and not isinstance(existing, dict):
1657
+ print(
1658
+ "cctally: dashboard config error: dashboard must be an object",
1659
+ file=sys.stderr,
1660
+ )
1661
+ return 2
1662
+ block = dict(existing or {})
1663
+ block["lan_auth"] = canonical
1664
+ config["dashboard"] = block
1665
+ save_config(config)
1666
+ if getattr(args, "emit_json", False):
1667
+ print(json.dumps({"dashboard": {"lan_auth": canonical}}, indent=2))
1668
+ else:
1669
+ print(f"dashboard.lan_auth={'true' if canonical else 'false'}")
1670
+ return 0
1626
1671
  if key == "telemetry.enabled":
1627
1672
  # Anonymous install-count telemetry opt-out (spec 2026-07-07). Mirror
1628
1673
  # dashboard.live_tail exactly: validate the bool first, then
@@ -2237,6 +2282,18 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
2237
2282
  save_config(config)
2238
2283
  # idempotent: silent on missing key
2239
2284
  return 0
2285
+ if key == "dashboard.lan_auth":
2286
+ # Restart-only opt-out. Removing the leaf restores the safe True
2287
+ # default while preserving all sibling dashboard preferences.
2288
+ with config_writer_lock():
2289
+ config = _load_config_unlocked()
2290
+ block = config.get("dashboard")
2291
+ if isinstance(block, dict) and "lan_auth" in block:
2292
+ del block["lan_auth"]
2293
+ if not block:
2294
+ config.pop("dashboard", None)
2295
+ save_config(config)
2296
+ return 0
2240
2297
  if key == "telemetry.enabled":
2241
2298
  # Mirror the dashboard.live_tail unset branch: drop only the enabled
2242
2299
  # leaf; if the telemetry block ends up empty, drop the parent too.
@@ -364,9 +364,26 @@ STATS_WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 # 16777216
364
364
  # full pass is measured against. Same mechanical reason as 1005 — an
365
365
  # epoch-current open returns before any schema work — so it is a second bump
366
366
  # rather than an amendment to the first.
367
- STATS_INDEX_EPOCH = 1006
367
+ # 1006 -> 1007 (#460): scheduled quota-alert ownership. Adds the per-root
368
+ # future-capture schedule that lets a matured boundary widen to its owning root
369
+ # instead of deferring forever on a quiet hook-only install.
370
+ STATS_INDEX_EPOCH = 1007
368
371
  LEGACY_STATS_HEAD = 13
369
372
 
373
+ #: #496 S1 F1. A NEW branch, for a state that cannot occur before the
374
+ #: publication transaction exists: a replacement index was published and then
375
+ #: failed validation on a fresh connection. The existing corrupt-stats text
376
+ #: says the database was "Not auto-recreated", which would be false here, so
377
+ #: this path gets its own wording. It does not alter the heal message or any
378
+ #: other corruption path.
379
+ STATS_PUBLICATION_FAILED_MSG = (
380
+ "stats.db published a rebuilt index that then FAILED validation, so the "
381
+ "live index is known bad and cctally refuses to use it. path: {path}. "
382
+ "The rebuild record naming the failing check is at {record}. The damaged "
383
+ "predecessor was preserved under quarantine/ with a forensics bundle in "
384
+ "logs/. Recovery: run `cctally db rebuild --db stats`."
385
+ )
386
+
370
387
 
371
388
  # === Telemetry constants (non-path; see spec 2026-07-07) =============
372
389
  #
@@ -1369,6 +1386,9 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
1369
1386
  -- that are not functions of window dirtiness — a delivery-gate
1370
1387
  -- transition, and a future-clocked observation that becomes eligible
1371
1388
  -- when wall time passes it with no row mutation at all.
1389
+ -- `next_evaluation_by_root_json` owns that scalar minimum: one earliest
1390
+ -- future capture per root, so a due hook tick can reconcile only the
1391
+ -- roots whose instants matured.
1372
1392
  --
1373
1393
  -- `last_full_pass_at` is the periodic verification's deadline. Two
1374
1394
  -- cases a scoped sweep structurally cannot see — a block whose physical
@@ -1386,6 +1406,7 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
1386
1406
  alerts_enabled INTEGER,
1387
1407
  next_evaluation_at_utc TEXT,
1388
1408
  last_full_pass_at TEXT,
1409
+ next_evaluation_by_root_json TEXT NOT NULL DEFAULT '{}',
1389
1410
  PRIMARY KEY(source)
1390
1411
  );
1391
1412
 
@@ -1417,14 +1438,19 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
1417
1438
  }
1418
1439
  if "account_key" not in _proj_cols:
1419
1440
  conn.execute("DROP TABLE quota_projection_state")
1441
+ # SQLite stores `sqlite_schema.sql` verbatim apart from stripping
1442
+ # `IF NOT EXISTS`, and `_stats_schema_fingerprint` hashes that text, so
1443
+ # this body must stay character-for-character equal to the fresh-path
1444
+ # definition above (#496 S1 F18).
1420
1445
  conn.execute(
1421
- "CREATE TABLE quota_projection_state ("
1422
- " source_root_key TEXT NOT NULL,"
1423
- " account_key TEXT NOT NULL DEFAULT 'unattributed',"
1424
- " generation TEXT NOT NULL,"
1425
- " physical_signature TEXT NOT NULL,"
1426
- " completed_at_utc TEXT NOT NULL,"
1427
- " PRIMARY KEY(source_root_key, account_key))"
1446
+ "CREATE TABLE quota_projection_state (\n"
1447
+ " source_root_key TEXT NOT NULL,\n"
1448
+ " account_key TEXT NOT NULL DEFAULT 'unattributed',\n"
1449
+ " generation TEXT NOT NULL,\n"
1450
+ " physical_signature TEXT NOT NULL,\n"
1451
+ " completed_at_utc TEXT NOT NULL,\n"
1452
+ " PRIMARY KEY(source_root_key, account_key)\n"
1453
+ " )"
1428
1454
  )
1429
1455
  if add_column_if_missing is not None:
1430
1456
  for _tbl in ("quota_window_blocks", "quota_percent_milestones",
@@ -1445,6 +1471,11 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
1445
1471
  # no-op over the table it already has.
1446
1472
  add_column_if_missing(
1447
1473
  conn, "quota_projection_ledger_state", "last_full_pass_at", "TEXT")
1474
+ # Epoch 1007 / #460: same legacy-cutover seam. Current-epoch indexes
1475
+ # rebuild; a legacy index cuts over in place and needs the column added.
1476
+ add_column_if_missing(
1477
+ conn, "quota_projection_ledger_state",
1478
+ "next_evaluation_by_root_json", "TEXT NOT NULL DEFAULT '{}'")
1448
1479
 
1449
1480
 
1450
1481
  def open_db(*, _target_path=None) -> sqlite3.Connection:
@@ -1648,6 +1679,20 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1648
1679
  # at `_uv == STATS_INDEX_EPOCH` above and never reaches here, so the hot
1649
1680
  # path takes no exclusive lock at all.
1650
1681
  with _cctally_store.stats_open_time_guard(live=_target_path is None):
1682
+ # Another opener can win the exclusive guard after this connection's
1683
+ # pre-lock epoch read, initialize/cut over the index, and stamp the
1684
+ # current epoch while this opener waits. Recheck under the guard before
1685
+ # acting on that stale legacy/fresh decision; otherwise the loser enters
1686
+ # the frozen migration dispatcher with user_version=1006 and reports a
1687
+ # false downgrade against legacy head 13.
1688
+ if _epoch_engaged and conn.execute(
1689
+ "PRAGMA user_version"
1690
+ ).fetchone()[0] == STATS_INDEX_EPOCH:
1691
+ if _target_path is None:
1692
+ _reconcile_durable_applied_migration_errors(
1693
+ conn, _STATS_MIGRATIONS, "stats.db"
1694
+ )
1695
+ return conn
1651
1696
  _fixups_current = _cctally_store.stats_open_fixups_current(conn)
1652
1697
  conn.execute(
1653
1698
  """
@@ -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
 
@@ -4530,6 +4559,7 @@ _GET_ROUTES = (
4530
4559
  )
4531
4560
 
4532
4561
  _POST_ROUTES = (
4562
+ ("exact", "/api/auth", "_handle_post_auth", None, False),
4533
4563
  ("exact", "/api/sync", "_handle_post_sync", None, False),
4534
4564
  ("exact", "/api/settings", "_handle_post_settings", None, False),
4535
4565
  ("exact", "/api/alerts/test", "_handle_post_alerts_test", None, False),
@@ -4599,6 +4629,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4599
4629
  # cmd_dashboard before serve_forever; the per-request gate
4600
4630
  # (`_require_transcripts_allowed`) ANDs this with the request Host.
4601
4631
  cctally_expose_transcripts: bool = False
4632
+ # Per-process LAN API token. None preserves the byte-stable loopback and
4633
+ # explicit opt-out behavior; a value gates every /api/* request.
4634
+ cctally_api_token: "str | None" = None
4602
4635
 
4603
4636
  # Access log stays silent (deliberate — noisy in the parent terminal),
4604
4637
  # but server errors are REAL as of #279 S2: log_error routes through
@@ -4678,7 +4711,43 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4678
4711
  return True
4679
4712
  return False
4680
4713
 
4714
+ def _require_api_auth(self) -> bool:
4715
+ """Authenticate an API request before any route or method dispatch."""
4716
+ if not self.path.split("?", 1)[0].startswith("/api/"):
4717
+ return True
4718
+ token = type(self).cctally_api_token
4719
+ if token is None:
4720
+ return True
4721
+
4722
+ authorization = self.headers.get_all("Authorization", [])
4723
+ supplied = None
4724
+ if authorization:
4725
+ if (len(authorization) == 1
4726
+ and authorization[0].startswith("Bearer ")):
4727
+ candidate = authorization[0][len("Bearer "):]
4728
+ if candidate:
4729
+ supplied = candidate
4730
+ else:
4731
+ supplied = _dashboard_token_from_cookie(
4732
+ self.headers.get("Cookie", "")
4733
+ )
4734
+ if (supplied is not None and supplied.isascii()
4735
+ and hmac.compare_digest(supplied, token)):
4736
+ return True
4737
+
4738
+ encoded = encode_dashboard_json_bytes({"error": "unauthorized"})
4739
+ self.send_response(401)
4740
+ self.send_header("WWW-Authenticate", "Bearer")
4741
+ self.send_header("Content-Type", "application/json")
4742
+ self.send_header("Content-Length", str(len(encoded)))
4743
+ self.end_headers()
4744
+ if self.command != "HEAD":
4745
+ self.wfile.write(encoded)
4746
+ return False
4747
+
4681
4748
  def do_GET(self) -> None: # noqa: N802 — stdlib API
4749
+ if not self._require_api_auth():
4750
+ return
4682
4751
  if self._method_not_allowed_for_settings():
4683
4752
  return
4684
4753
  path = self.path.split("?", 1)[0]
@@ -4716,6 +4785,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4716
4785
  self.send_error(404, "not found")
4717
4786
 
4718
4787
  def do_POST(self) -> None: # noqa: N802 — stdlib API
4788
+ if not self._require_api_auth():
4789
+ return
4719
4790
  # No _method_not_allowed_for_settings() guard here (gate P1-3): POST
4720
4791
  # /api/settings must route to _handle_post_settings, not 405.
4721
4792
  if not self._dispatch(_POST_ROUTES):
@@ -4728,21 +4799,55 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4728
4799
  # For other paths we fall through to send_error(501) so the rest of
4729
4800
  # the surface keeps stdlib semantics.
4730
4801
  def do_PUT(self) -> None: # noqa: N802 — stdlib API
4802
+ if not self._require_api_auth():
4803
+ return
4731
4804
  if self._method_not_allowed_for_settings():
4732
4805
  return
4733
4806
  self.send_error(501, "Unsupported method ('PUT')")
4734
4807
 
4735
4808
  def do_DELETE(self) -> None: # noqa: N802 — stdlib API
4809
+ if not self._require_api_auth():
4810
+ return
4736
4811
  if self._method_not_allowed_for_settings():
4737
4812
  return
4738
4813
  if not self._dispatch(_DELETE_ROUTES):
4739
4814
  self.send_error(501, "Unsupported method ('DELETE')")
4740
4815
 
4741
4816
  def do_PATCH(self) -> None: # noqa: N802 — stdlib API
4817
+ if not self._require_api_auth():
4818
+ return
4742
4819
  if self._method_not_allowed_for_settings():
4743
4820
  return
4744
4821
  self.send_error(501, "Unsupported method ('PATCH')")
4745
4822
 
4823
+ def do_HEAD(self) -> None: # noqa: N802 — stdlib API
4824
+ if not self._require_api_auth():
4825
+ return
4826
+ if self._method_not_allowed_for_settings():
4827
+ return
4828
+ self.send_error(501, "Unsupported method ('HEAD')")
4829
+
4830
+ def do_OPTIONS(self) -> None: # noqa: N802 — stdlib API
4831
+ if not self._require_api_auth():
4832
+ return
4833
+ if self._method_not_allowed_for_settings():
4834
+ return
4835
+ self.send_error(501, "Unsupported method ('OPTIONS')")
4836
+
4837
+ def _handle_post_auth(self) -> None:
4838
+ """Bridge an accepted Bearer token to a browser-native session cookie."""
4839
+ token = type(self).cctally_api_token
4840
+ if token is None:
4841
+ self.send_error(404, "not found")
4842
+ return
4843
+ self.send_response(204)
4844
+ self.send_header(
4845
+ "Set-Cookie",
4846
+ f"cctally_dashboard_token={token}; Path=/api; "
4847
+ "HttpOnly; SameSite=Strict",
4848
+ )
4849
+ self.end_headers()
4850
+
4746
4851
  def _handle_post_sync(self) -> None:
4747
4852
  """Trigger refresh-usage + snapshot rebuild on user demand.
4748
4853
 
@@ -5256,7 +5361,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5256
5361
  "field": f"dashboard.{leaf}"},
5257
5362
  )
5258
5363
  return
5259
- if leaf not in ("cache_failure_markers", "live_tail"):
5364
+ if leaf not in (
5365
+ "cache_failure_markers", "live_tail", "lan_auth"
5366
+ ):
5260
5367
  self._respond_json(
5261
5368
  400,
5262
5369
  {"error": f"unknown dashboard settings key: {leaf}",
@@ -5264,7 +5371,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5264
5371
  )
5265
5372
  return
5266
5373
  dashboard_validated = {}
5267
- for _leaf in ("cache_failure_markers", "live_tail"):
5374
+ for _leaf in ("cache_failure_markers", "live_tail", "lan_auth"):
5268
5375
  if _leaf in dashboard_block:
5269
5376
  if not isinstance(dashboard_block[_leaf], bool):
5270
5377
  self._respond_json(
@@ -5659,12 +5766,20 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5659
5766
  # + live_tail) so the SettingsOverlay can repaint without a
5660
5767
  # follow-up GET. Default true (opt-out) when nothing is persisted.
5661
5768
  persisted_dash = merged.get("dashboard") or {}
5769
+ persisted_lan_auth = persisted_dash.get("lan_auth", True)
5662
5770
  out["dashboard"] = {
5663
5771
  "cache_failure_markers": bool(
5664
5772
  persisted_dash.get("cache_failure_markers", True)
5665
5773
  ),
5666
5774
  "live_tail": bool(persisted_dash.get("live_tail", True)),
5775
+ "lan_auth": (
5776
+ persisted_lan_auth
5777
+ if isinstance(persisted_lan_auth, bool)
5778
+ else True
5779
+ ),
5667
5780
  }
5781
+ if "lan_auth" in dashboard_validated:
5782
+ out["restart_required"] = ["dashboard.lan_auth"]
5668
5783
  out["saved_at"] = (
5669
5784
  dt.datetime.now(dt.timezone.utc)
5670
5785
  .strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -7266,6 +7381,10 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
7266
7381
  stored = "loopback"
7267
7382
  resolved_bind_stored = stored
7268
7383
  args.host = _resolve_dashboard_bind_for_runtime(resolved_bind_stored)
7384
+ lan_auth_enabled = bool(
7385
+ _config_known_value(config, "dashboard.lan_auth")
7386
+ )
7387
+ api_token = _dashboard_lan_auth_token(args.host, lan_auth_enabled)
7269
7388
 
7270
7389
  # F3: capture the canonical tz token from `--tz` (NOT the ZoneInfo
7271
7390
  # — the override needs to flow through `load_config()`-style readers
@@ -7365,6 +7484,7 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
7365
7484
  # in the doctor SSE block + /api/doctor reflects the actual --host
7366
7485
  # the process is serving, not just the config-only view the CLI sees.
7367
7486
  DashboardHTTPHandler.cctally_host = args.host
7487
+ DashboardHTTPHandler.cctally_api_token = api_token
7368
7488
  # Conversation viewer (Plan 2, spec §5): the resolved
7369
7489
  # `dashboard.expose_transcripts` opt-in. Read off the already-loaded
7370
7490
  # `config` the same way `dashboard.bind` is resolved above (the
@@ -7490,20 +7610,40 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
7490
7610
  is_loopback = bind_host in ("127.0.0.1", "localhost", "::1")
7491
7611
 
7492
7612
  if is_all_interfaces:
7493
- local_url = _format_url("localhost", bind_port)
7613
+ local_url = _dashboard_auth_url(
7614
+ _format_url("localhost", bind_port), api_token
7615
+ )
7494
7616
  lan_ip = _discover_lan_ip()
7495
7617
  print("dashboard: serving on all interfaces:", flush=True)
7496
7618
  print(f" - {local_url} (this machine)", flush=True)
7497
7619
  if lan_ip:
7498
- lan_url = _format_url(lan_ip, bind_port)
7620
+ lan_url = _dashboard_auth_url(
7621
+ _format_url(lan_ip, bind_port), api_token
7622
+ )
7499
7623
  print(f" - {lan_url} (LAN)", flush=True)
7624
+ if api_token is not None:
7625
+ print(f"dashboard: LAN access token: {api_token}", flush=True)
7626
+ elif not lan_auth_enabled:
7627
+ print(
7628
+ "warning: LAN authentication disabled by "
7629
+ "dashboard.lan_auth=false; restart after enabling it",
7630
+ flush=True,
7631
+ )
7500
7632
  print("Ctrl-C to stop", flush=True)
7501
7633
  elif is_loopback:
7502
7634
  url = _format_url("localhost", bind_port)
7503
7635
  print(f"dashboard: serving {url} — Ctrl-C to stop", flush=True)
7504
7636
  else:
7505
- url = _format_url(bind_host, bind_port)
7637
+ url = _dashboard_auth_url(_format_url(bind_host, bind_port), api_token)
7506
7638
  print(f"dashboard: serving {url} — Ctrl-C to stop", flush=True)
7639
+ if api_token is not None:
7640
+ print(f"dashboard: LAN access token: {api_token}", flush=True)
7641
+ elif not lan_auth_enabled:
7642
+ print(
7643
+ "warning: LAN authentication disabled by "
7644
+ "dashboard.lan_auth=false; restart after enabling it",
7645
+ flush=True,
7646
+ )
7507
7647
 
7508
7648
  http_thread = threading.Thread(target=srv.serve_forever, daemon=True,
7509
7649
  name="dashboard-http")
@@ -7528,6 +7668,7 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
7528
7668
  browser_url = _format_url("localhost", bind_port)
7529
7669
  else:
7530
7670
  browser_url = _format_url(bind_host, bind_port)
7671
+ browser_url = _dashboard_auth_url(browser_url, api_token)
7531
7672
  try:
7532
7673
  _wb.open(browser_url)
7533
7674
  except Exception as exc: