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.
- package/CHANGELOG.md +74 -0
- package/README.md +2 -2
- package/bin/_cctally_cache.py +863 -74
- package/bin/_cctally_config.py +57 -0
- package/bin/_cctally_core.py +53 -8
- package/bin/_cctally_dashboard.py +146 -5
- package/bin/_cctally_dashboard_conversation.py +164 -18
- package/bin/_cctally_dashboard_envelope.py +69 -12
- package/bin/_cctally_dashboard_sources.py +27 -1
- package/bin/_cctally_db.py +372 -10
- package/bin/_cctally_doctor.py +18 -1
- package/bin/_cctally_journal.py +535 -13
- package/bin/_cctally_journal_repair.py +6 -0
- package/bin/_cctally_parser.py +6 -0
- package/bin/_cctally_quota.py +171 -55
- package/bin/_cctally_record.py +13 -1
- package/bin/_cctally_rederive.py +4 -0
- package/bin/_cctally_store.py +311 -6
- package/bin/_cctally_transcript.py +32 -2
- package/bin/_lib_cache_report.py +8 -3
- package/bin/_lib_cache_report_wire.py +8 -20
- package/bin/_lib_codex_conversation.py +959 -81
- package/bin/_lib_codex_conversation_query.py +2792 -167
- package/bin/_lib_codex_find_projection.py +370 -0
- package/bin/_lib_codex_harness_preamble.py +176 -0
- package/bin/_lib_codex_hooks.py +5 -3
- package/bin/_lib_codex_js_scan.py +254 -0
- package/bin/_lib_codex_landmarks.py +309 -0
- package/bin/_lib_codex_reasoning_headings.py +73 -0
- package/bin/_lib_codex_segments.py +259 -0
- package/bin/_lib_codex_title_clean.py +116 -0
- package/bin/_lib_conversation_dispatch.py +153 -21
- package/bin/_lib_conversation_watch.py +4 -2
- package/bin/_lib_dashboard_sources.py +33 -32
- package/bin/_lib_doctor.py +64 -0
- package/bin/_lib_quota_alert_axes.py +31 -34
- package/bin/_lib_stats_damage.py +523 -0
- package/bin/cctally +5 -0
- package/dashboard/static/assets/index-BEzzJtUd.js +97 -0
- package/dashboard/static/assets/index-DnWdv8um.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +9 -1
- package/dashboard/static/assets/index-Bar8-S1i.css +0 -1
- package/dashboard/static/assets/index-CRogVlEC.js +0 -92
package/bin/_cctally_config.py
CHANGED
|
@@ -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.
|
package/bin/_cctally_core.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
"
|
|
1423
|
-
"
|
|
1424
|
-
"
|
|
1425
|
-
"
|
|
1426
|
-
"
|
|
1427
|
-
"
|
|
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 (
|
|
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 =
|
|
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 =
|
|
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:
|