alissa-tools-github-revloop 0.13.0__tar.gz → 0.14.0__tar.gz

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 (30) hide show
  1. {alissa_tools_github_revloop-0.13.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.14.0}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.14.0}/setup.py +1 -0
  3. {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.14.0}/src/main/alissa/tools/github/revloop/state.py +155 -1
  4. alissa_tools_github_revloop-0.14.0/src/main/alissa/tools/github/revloop/version +1 -0
  5. alissa_tools_github_revloop-0.14.0/src/main/alissa/tools/github/revloop/webui/__init__.py +36 -0
  6. alissa_tools_github_revloop-0.14.0/src/main/alissa/tools/github/revloop/webui/__main__.py +130 -0
  7. alissa_tools_github_revloop-0.14.0/src/main/alissa/tools/github/revloop/webui/auth.py +200 -0
  8. alissa_tools_github_revloop-0.14.0/src/main/alissa/tools/github/revloop/webui/page.py +588 -0
  9. alissa_tools_github_revloop-0.14.0/src/main/alissa/tools/github/revloop/webui/server.py +355 -0
  10. alissa_tools_github_revloop-0.14.0/src/main/alissa/tools/github/revloop/webui/sources.py +615 -0
  11. alissa_tools_github_revloop-0.14.0/src/main/alissa/tools/github/revloop/webui/sysinfo.py +174 -0
  12. {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.14.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  13. {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.14.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +7 -0
  14. {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.14.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +1 -0
  15. alissa_tools_github_revloop-0.13.0/src/main/alissa/tools/github/revloop/version +0 -1
  16. {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.14.0}/MANIFEST.in +0 -0
  17. {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.14.0}/README.md +0 -0
  18. {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.14.0}/requirements.txt +0 -0
  19. {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.14.0}/setup.cfg +0 -0
  20. {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.14.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  21. {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.14.0}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  22. {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.14.0}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  23. {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.14.0}/src/main/alissa/tools/github/revloop/config.py +0 -0
  24. {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.14.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  25. {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.14.0}/src/main/alissa/tools/github/revloop/loop.py +0 -0
  26. {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.14.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  27. {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.14.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  28. {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.14.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
  29. {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.14.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  30. {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.14.0}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.13.0
3
+ Version: 0.14.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -50,6 +50,7 @@ setup(
50
50
  "console_scripts": [
51
51
  "alissa-revloop=alissa.tools.github.revloop.__main__:main",
52
52
  "alissa-pr-review=alissa.tools.github.revloop.prreview:main",
53
+ "alissa-revloop-ui=alissa.tools.github.revloop.webui.__main__:main",
53
54
  ]
54
55
  },
55
56
  install_requires=requirements,
@@ -27,6 +27,7 @@ from __future__ import annotations
27
27
  import json
28
28
  import sqlite3
29
29
  import time
30
+ from collections.abc import Iterable
30
31
  from pathlib import Path
31
32
 
32
33
  # Poll-snapshot retention: the newest N rows are kept, older ones pruned on
@@ -94,8 +95,35 @@ CREATE TABLE IF NOT EXISTS poll_snapshots (
94
95
 
95
96
 
96
97
  class State:
97
- def __init__(self, path: Path):
98
+ def __init__(self, path: Path, *, read_only: bool = False):
99
+ """Open the ledger. The daemon opens it read-write (creating the file,
100
+ applying the schema, migrating an old `spawns` key); a read-only
101
+ CONSUMER -- the console sidecar -- must not do any of that.
102
+
103
+ `read_only` connects through the `mode=ro` URI, which cannot create the
104
+ database and raises `sqlite3.OperationalError` when it does not exist.
105
+ That is the point: a console pointed at a workspace with no daemon
106
+ state must report "no state here", not silently CREATE a state.db (and
107
+ thereby render an empty, healthy-looking dashboard the operator cannot
108
+ tell from an idle daemon), and must never run the migration on a
109
+ database the daemon owns.
110
+
111
+ The URI is built with `as_uri()`, never by interpolating the path into
112
+ an f-string: sqlite parses a `file:` URI, so an unescaped `#` truncates
113
+ the filename at a fragment, `?` at the query, and `%XX` is
114
+ percent-decoded. Any of the three in `--workspace-root` would yield a
115
+ DIFFERENT file and -- for `#` and `?` -- one with no `mode` parameter
116
+ left, silently falling back to `rwc`: read-only mode creating a
117
+ database, at a path that is not even the one asked for. `as_uri()`
118
+ percent-encodes everything but the separator, so the guarantee holds
119
+ for any path an operator can type.
120
+ """
98
121
  path = Path(path).expanduser()
122
+ if read_only:
123
+ uri = Path(path).absolute().as_uri() + "?mode=ro"
124
+ self._db = sqlite3.connect(uri, uri=True)
125
+ self._db.row_factory = sqlite3.Row
126
+ return
99
127
  path.parent.mkdir(parents=True, exist_ok=True)
100
128
  self._db = sqlite3.connect(str(path))
101
129
  self._db.row_factory = sqlite3.Row
@@ -332,3 +360,129 @@ class State:
332
360
  record["stages"] = json.loads(record.pop("stages_json"))
333
361
  out.append(record)
334
362
  return out
363
+
364
+ # -- console ledger bridge (read-only lists + the retry-now UPDATE) -----
365
+ #
366
+ # The console sidecar (webui) renders the spawn ledger, the escalation
367
+ # table and the ping ledger directly -- the operator inbox, the session ->
368
+ # PR mapping, and the retry-now action -- the way it reads poll_snapshots
369
+ # through read_snapshots: no GitHub call, just the local tables.
370
+ #
371
+ # Rows come back newest-first, and every reader takes the same optional
372
+ # `limit` read_snapshots does. Unlike poll_snapshots, `escalations` and
373
+ # `pings` are NEVER pruned (they are per-head / per-episode dedupe keys the
374
+ # daemon must keep), so a reader that returned all of them would grow an
375
+ # operator inbox that never clears. Bounding belongs here, in SQL, not in
376
+ # the caller's slice -- and, for `pings`, AFTER the kind filter rather than
377
+ # before it, or the noisier telemetry kind evicts the operator pages.
378
+ #
379
+ # `spawns` is bounded differently on purpose: it is a lookup table, not a
380
+ # display list, so it is selected by key rather than truncated by recency.
381
+
382
+ def read_spawns(
383
+ self,
384
+ limit: int | None = None,
385
+ *,
386
+ sessions: "Iterable[str] | None" = None,
387
+ ) -> list[dict]:
388
+ """Spawn rows (one per enqueued reviewer round), newest first.
389
+
390
+ `sessions` restricts the read to those session names. That is the
391
+ bound the console's session->round pairing wants, and a recency bound
392
+ (row count or time window) is NOT: a stale round whose session is
393
+ still alive is deferred indefinitely (see loop._defer_stale_round --
394
+ only an operator kill unblocks the respawn), so a live session's spawn
395
+ row can be arbitrarily old. Truncating by recency therefore drops the
396
+ pairing for exactly the wedged session an operator is looking for.
397
+ Selecting by name bounds the read by the live-session count instead,
398
+ at any row age. An empty collection reads nothing.
399
+ """
400
+ sql = (
401
+ "SELECT repo, number, round, head_sha, session, task_ref, spawned_at "
402
+ "FROM spawns"
403
+ )
404
+ params: tuple = ()
405
+ if sessions is not None:
406
+ names = tuple(sessions)
407
+ if not names:
408
+ return []
409
+ sql += " WHERE session IN (%s)" % ",".join("?" * len(names))
410
+ params = names
411
+ sql += " ORDER BY spawned_at DESC, number DESC, round DESC"
412
+ return self._read_rows(sql, limit, params)
413
+
414
+ def read_escalations(self, limit: int | None = None) -> list[dict]:
415
+ """Cap-out escalations (the terminal half of the operator inbox),
416
+ newest first. Keyed per head_sha: a fresh push after a cap-out is a new
417
+ row, so the console can show that the PR capped out again."""
418
+ return self._read_rows(
419
+ "SELECT repo, number, head_sha, escalated_at "
420
+ "FROM escalations ORDER BY escalated_at DESC, number DESC",
421
+ limit,
422
+ )
423
+
424
+ def read_pings(
425
+ self,
426
+ limit: int | None = None,
427
+ *,
428
+ kind_prefix: str | None = None,
429
+ ) -> list[dict]:
430
+ """Operator-ping rows, newest first. `kind` is free-form and carries
431
+ the episode identity (`stalled:<session>`,
432
+ `activity-deferred:<session>`).
433
+
434
+ `kind_prefix` selects one kind IN SQL, so `limit` bounds the rows the
435
+ caller actually wants. Filtering after the limit instead would let the
436
+ other kind evict them: only a deferral episode that outlasts
437
+ STALLED_DEFER_MULTIPLE stale windows writes a `stalled:` row, while
438
+ EVERY episode writes an `activity-deferred:` one, so the kind the
439
+ console does not page on is structurally the more numerous. Matched
440
+ with `substr`, not `LIKE`: the prefix is a literal, and `LIKE` would
441
+ need `%`/`_` escaped to keep it one.
442
+ """
443
+ sql = "SELECT repo, number, kind, pinged_at FROM pings"
444
+ params: tuple = ()
445
+ if kind_prefix is not None:
446
+ sql += " WHERE substr(kind, 1, ?) = ?"
447
+ params = (len(kind_prefix), kind_prefix)
448
+ sql += " ORDER BY pinged_at DESC, number DESC"
449
+ return self._read_rows(sql, limit, params)
450
+
451
+ def _read_rows(
452
+ self, sql: str, limit: int | None, params: tuple = ()
453
+ ) -> list[dict]:
454
+ if limit is not None:
455
+ sql += " LIMIT ?"
456
+ params = params + (limit,)
457
+ return [dict(row) for row in self._db.execute(sql, params).fetchall()]
458
+
459
+ def age_out_spawn(self, repo: str, number: int, round_: int, new_ts: int) -> bool:
460
+ """Retry-now: stamp the NEWEST spawn row of THIS round back to
461
+ `new_ts` (the console passes a time just past the stale window), so
462
+ `spawn_age` reads as stale and the daemon's own re-enqueue path can
463
+ respawn the round on its next pass. An UPDATE, never a DELETE: the
464
+ spawn history stays intact (the reap sweep still maps the old session
465
+ name back to its round), only the newest row's clock moves. Keyed per
466
+ round, like `get_spawn` -- a retry re-arms only the round the console
467
+ named. Returns False when there is no row to age (the console then
468
+ reports nothing to retry).
469
+
470
+ Aging is necessary but not sufficient for a respawn: the daemon defers
471
+ a stale round whose session still shows signs of life (loop's liveness
472
+ signal, which exists to stop double-spending a round). Kill the wedged
473
+ session first, then retry -- that is exactly the operator sequence the
474
+ stalled ping asks for.
475
+ """
476
+ row = self._db.execute(
477
+ "SELECT session FROM spawns WHERE repo=? AND number=? AND round=? "
478
+ "ORDER BY spawned_at DESC, rowid DESC LIMIT 1",
479
+ (repo, number, round_),
480
+ ).fetchone()
481
+ if row is None:
482
+ return False
483
+ self._db.execute(
484
+ "UPDATE spawns SET spawned_at=? WHERE session=?",
485
+ (int(new_ts), row["session"]),
486
+ )
487
+ self._db.commit()
488
+ return True
@@ -0,0 +1,36 @@
1
+ """The reviewer console: a stdlib-only sidecar (`alissa-revloop-ui`) that
2
+ renders live review-daemon state for an operator, spending **zero** GitHub API
3
+ budget of its own beyond two cached checks.
4
+
5
+ Ported from the devloop's worker console (`alissa.tools.github.devloop.webui`,
6
+ its PR #38) and adapted to reviewer semantics: the unit of work is a PR round,
7
+ not an issue attempt, so the pipeline board is PR-centric (PR ref → round k of
8
+ the cap → session → stage), the operator inbox pages cap-outs and stalled
9
+ deferrals, and there is no worker-tasks panel (reviewers create no tasks) and
10
+ no maintenance edge. Module shapes are copied deliberately -- family precedent
11
+ is copy-adapt per repo; a shared-webui package is a separate, deferred lane.
12
+
13
+ The daemon (loop.py) already persists everything the console needs: every poll
14
+ pass writes one `poll_snapshots` row (UI-1, PR #35) carrying the pass timing,
15
+ the candidate count, the decision-summary counts, and the compact per-item
16
+ stage list. The sidecar reads that table through `State.read_snapshots`, plus
17
+ the spawn ledger, the escalation table and the ping ledger (the operator
18
+ inbox), all read-only. Its only live signals are local (`alissa tmux ls`, a
19
+ `/proc` walk of each session's pane-PID tree) or cached (`gh api rate_limit`,
20
+ 60s; the PyPI version JSON, 10m) -- so a fleet of operators refreshing the
21
+ dashboard never moves the daemon's rate budget.
22
+
23
+ Layout:
24
+ auth.py -- fail-closed passcode, HMAC-signed sessions, CSRF, login throttle
25
+ sysinfo.py -- /proc process-tree CPU%/RSS (sample-free, vanished-PID tolerant)
26
+ sources.py -- the read-only data layer + the retry-now UPDATE, cached checks
27
+ page.py -- the single static HTML page (studio design system, both themes)
28
+ server.py -- ThreadingHTTPServer wiring, routing, auth/CSRF gating, actions
29
+ __main__.py -- the `alissa-revloop-ui` console entry point
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from .auth import Auth, LoginThrottle, PasscodeUnset, require_passcode
35
+
36
+ __all__ = ["Auth", "LoginThrottle", "PasscodeUnset", "require_passcode"]
@@ -0,0 +1,130 @@
1
+ """CLI entry point: `alissa-revloop-ui` (or `python -m ...revloop.webui`).
2
+
3
+ The sidecar reads the SAME config the daemon resolved (so its echo panel is
4
+ truthful) but never runs the daemon's preflight -- it makes no `gh api user`
5
+ identity call, holds no GitHub token of its own, and spends GitHub budget only
6
+ through the two cached checks in `sources`. It is fail-closed on the passcode:
7
+ `ALISSA_UI_PASSCODE` unset -> exit before the socket ever binds.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from ..config import Config, load_config_file, resolve_config_path
18
+ from ..version import version
19
+ from .auth import Auth, PasscodeUnset, require_passcode
20
+ from .server import App, make_server
21
+ from .sources import Sources
22
+
23
+ # The default bind port. Deliberately NOT the devloop console's 8787: the two
24
+ # daemons routinely run on one machine (they are the two halves of the same
25
+ # loop), and two sidecars fighting over one port is a boot failure an operator
26
+ # would have to debug.
27
+ DEFAULT_PORT = 8788
28
+
29
+ # The log file to tail when `--log-file` is not given.
30
+ ENV_LOG = "ALISSA_REVLOOP_LOG"
31
+
32
+
33
+ def _env_flag(value: "str | None") -> bool:
34
+ """A truthy env flag: 1/true/yes/on (case-insensitive). Unset or anything
35
+ else is False -- fail-safe, so a stray value never silently flips a knob."""
36
+ return (value or "").strip().lower() in ("1", "true", "yes", "on")
37
+
38
+
39
+ def build_parser() -> argparse.ArgumentParser:
40
+ p = argparse.ArgumentParser(
41
+ prog="alissa-revloop-ui",
42
+ description="Reviewer console: a stdlib-only operator dashboard sidecar "
43
+ "for the alissa-revloop daemon. Reads the daemon's local state (poll "
44
+ "snapshots, spawn ledger, escalations, pings), tmux/proc, and two cached "
45
+ "checks; renders live state and offers kill/retry-now actions.",
46
+ epilog="Requires ALISSA_UI_PASSCODE (fail-closed). Reads the same "
47
+ "config file the daemon uses so its echo panel is truthful.",
48
+ )
49
+ p.add_argument("--version", action="version",
50
+ version=f"%(prog)s {version.value}")
51
+ p.add_argument("--host", default="127.0.0.1", metavar="ADDR",
52
+ help="bind address (default: 127.0.0.1 -- localhost only)")
53
+ p.add_argument("--port", type=int, default=DEFAULT_PORT, metavar="PORT",
54
+ help=f"bind port (default: {DEFAULT_PORT})")
55
+ p.add_argument("--workspace-root", type=Path, default=None, metavar="PATH",
56
+ help="the workspace whose daemon state to render "
57
+ "(default: current directory)")
58
+ p.add_argument("-c", "--config-path", "--config", dest="config_path",
59
+ type=Path, default=None, metavar="PATH",
60
+ help="config file; without it, ./revloop.config.json then "
61
+ "<workspace-root>/revloop.config.json, else defaults only")
62
+ p.add_argument("--state-path", type=Path, default=None, metavar="PATH",
63
+ help="override the state.db location (default: from config)")
64
+ p.add_argument("--log-file", type=Path, default=None, metavar="PATH",
65
+ help="daemon log file to tail in the console "
66
+ f"(default: ${ENV_LOG}, else none)")
67
+ return p
68
+
69
+
70
+ def resolve_config(args: argparse.Namespace) -> Config:
71
+ """Config exactly as the daemon resolves it (discovery + layers), minus the
72
+ preflight -- no network, no gh identity."""
73
+ workspace_root = args.workspace_root or Path.cwd()
74
+ path = resolve_config_path(args.config_path, workspace_root)
75
+ file_data = load_config_file(path) if path else {}
76
+ overrides = {"state_path": args.state_path}
77
+ return Config.build(workspace_root, file_data, overrides)
78
+
79
+
80
+ def main(argv: "list[str] | None" = None) -> int:
81
+ args = build_parser().parse_args(argv)
82
+
83
+ try:
84
+ passcode = require_passcode(os.environ)
85
+ except PasscodeUnset as exc:
86
+ print(f"refusing to start: {exc}", file=sys.stderr)
87
+ return 2
88
+
89
+ try:
90
+ config = resolve_config(args)
91
+ except (FileNotFoundError, ValueError) as exc:
92
+ print(f"config error: {exc}", file=sys.stderr)
93
+ return 2
94
+
95
+ log_file = args.log_file
96
+ if log_file is None:
97
+ env_log = os.environ.get(ENV_LOG)
98
+ log_file = Path(env_log) if env_log else None
99
+
100
+ auth = Auth(passcode)
101
+ sources = Sources(
102
+ config=config, running_version=version.value, log_path=log_file
103
+ )
104
+ # Add `Secure` to the session cookie when the console sits behind TLS
105
+ # termination (reverse proxy). Off by default for the localhost-HTTP posture.
106
+ secure_cookie = _env_flag(os.environ.get("ALISSA_UI_SECURE_COOKIE"))
107
+ app = App(
108
+ auth=auth, sources=sources, version=version.value,
109
+ secure_cookie=secure_cookie,
110
+ )
111
+ server = make_server(app, args.host, args.port)
112
+
113
+ print(
114
+ f"alissa-revloop-ui {version.value} -- serving on "
115
+ f"http://{args.host}:{args.port} (passcode required); "
116
+ f"watching {config.workspace_root}",
117
+ flush=True,
118
+ )
119
+ try:
120
+ server.serve_forever()
121
+ except KeyboardInterrupt:
122
+ pass
123
+ finally:
124
+ server.shutdown()
125
+ server.server_close()
126
+ return 0
127
+
128
+
129
+ if __name__ == "__main__":
130
+ raise SystemExit(main())
@@ -0,0 +1,200 @@
1
+ """Fail-closed authentication for the reviewer console.
2
+
3
+ The whole surface is a single operator passcode (`ALISSA_UI_PASSCODE`). There
4
+ is no user store: the sidecar is a personal operator tool, not a multi-tenant
5
+ service. The security posture (identical to the devloop console's contract --
6
+ one operator learns one console):
7
+
8
+ * **Fail-closed.** No passcode in the environment -> the server refuses to
9
+ start (`require_passcode` raises). There is deliberately no "unauthenticated"
10
+ fallback -- an operator console with kill/retry actions must never boot open.
11
+ * **Constant-time passcode compare.** `hmac.compare_digest`, so a wrong guess
12
+ leaks no timing signal about how many leading characters matched.
13
+ * **HMAC-signed session cookie.** The signing key is derived from the passcode
14
+ AND a per-boot nonce, so (a) a stolen cookie cannot be forged without the
15
+ passcode, and (b) every restart invalidates all outstanding sessions (the
16
+ nonce changes) -- a cheap, deliberate "reboot logs everyone out".
17
+ * **CSRF token bound to the session.** Every state-changing POST must present a
18
+ token that is itself an HMAC over the session cookie, so a cross-site form
19
+ post (which cannot read the cookie to derive the token) is rejected even
20
+ though the browser would attach the cookie.
21
+ * **Login throttle.** A small sliding-window lockout blunts online guessing.
22
+
23
+ Nothing here is a substitute for network isolation -- the sidecar is meant to
24
+ sit behind localhost / a private network -- but it means an exposed port is not
25
+ an instant takeover.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import hashlib
31
+ import hmac
32
+ import secrets
33
+ import time
34
+ from typing import Callable, Mapping
35
+
36
+ # The environment variable that carries the operator passcode. Unset or
37
+ # whitespace-only means "no passcode" -> fail-closed (see require_passcode).
38
+ # Deliberately the same name the devloop console uses: an operator running both
39
+ # sidecars on one machine sets one secret, and the two are separate processes
40
+ # with separate boot nonces regardless.
41
+ ENV_PASSCODE = "ALISSA_UI_PASSCODE"
42
+
43
+ # The session cookie name. Namespaced per console so a browser holding both
44
+ # sidecars open (same host, different ports -- cookies are NOT port-scoped)
45
+ # does not have one console's cookie overwrite the other's.
46
+ SESSION_COOKIE = "alissa_revloop_ui"
47
+
48
+ # Sessions live half a day by default; a signed cookie past its issue age is
49
+ # rejected even though its signature still verifies.
50
+ DEFAULT_SESSION_TTL = 12 * 60 * 60
51
+
52
+
53
+ class PasscodeUnset(RuntimeError):
54
+ """Raised when ALISSA_UI_PASSCODE is missing or whitespace-only. The server
55
+ turns this into a refusal to start (fail-closed), never a warning."""
56
+
57
+
58
+ def require_passcode(environ: Mapping[str, str]) -> str:
59
+ """The passcode, or raise PasscodeUnset. `environ` is passed in (not read
60
+ from os.environ here) so the boot check is a pure function the tests drive
61
+ directly. Whitespace-only counts as unset: a stray `ALISSA_UI_PASSCODE=` in
62
+ a compose file must fail closed, not boot with an empty secret."""
63
+ raw = environ.get(ENV_PASSCODE)
64
+ if raw is None or not raw.strip():
65
+ raise PasscodeUnset(
66
+ f"{ENV_PASSCODE} is unset -- the reviewer console refuses to start "
67
+ f"without a passcode (fail-closed). Set {ENV_PASSCODE} and retry."
68
+ )
69
+ return raw
70
+
71
+
72
+ class LoginThrottle:
73
+ """A sliding-window login-attempt throttle. After `max_attempts` failures
74
+ inside `window_seconds`, logins are locked for `lockout_seconds`. A success
75
+ clears the record. Deliberately global (one operator, one console), so it
76
+ is a lockout, not a per-IP heuristic that a proxy could smear."""
77
+
78
+ def __init__(
79
+ self,
80
+ *,
81
+ max_attempts: int = 5,
82
+ window_seconds: float = 300.0,
83
+ lockout_seconds: float = 60.0,
84
+ clock: Callable[[], float] = time.monotonic,
85
+ ) -> None:
86
+ self._max = max_attempts
87
+ self._window = window_seconds
88
+ self._lockout = lockout_seconds
89
+ self._clock = clock
90
+ self._failures: list[float] = []
91
+ self._locked_until = 0.0
92
+
93
+ def locked(self) -> bool:
94
+ return self._clock() < self._locked_until
95
+
96
+ def retry_after(self) -> float:
97
+ """Seconds until logins are accepted again (0.0 when not locked)."""
98
+ return max(0.0, self._locked_until - self._clock())
99
+
100
+ def record_failure(self) -> None:
101
+ now = self._clock()
102
+ self._failures = [t for t in self._failures if now - t < self._window]
103
+ self._failures.append(now)
104
+ if len(self._failures) >= self._max:
105
+ self._locked_until = now + self._lockout
106
+ self._failures = []
107
+
108
+ def record_success(self) -> None:
109
+ self._failures = []
110
+ self._locked_until = 0.0
111
+
112
+
113
+ class Auth:
114
+ """Passcode verification, session issue/verify, and CSRF binding.
115
+
116
+ The signing key is `sha256(boot_nonce : passcode)`; changing either
117
+ invalidates every outstanding session, which is exactly the reboot/rotate
118
+ behaviour we want. All comparisons are constant-time.
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ passcode: str,
124
+ *,
125
+ boot_nonce: str | None = None,
126
+ session_ttl: float = DEFAULT_SESSION_TTL,
127
+ clock: Callable[[], float] = time.time,
128
+ throttle: LoginThrottle | None = None,
129
+ ) -> None:
130
+ self._passcode = passcode
131
+ self.boot_nonce = boot_nonce or secrets.token_hex(16)
132
+ self._key = hashlib.sha256(
133
+ f"{self.boot_nonce}:{passcode}".encode()
134
+ ).digest()
135
+ self._ttl = session_ttl
136
+ self._clock = clock
137
+ self.throttle = throttle if throttle is not None else LoginThrottle()
138
+
139
+ # -- passcode / login --------------------------------------------------
140
+
141
+ def verify_passcode(self, submitted: str) -> bool:
142
+ return hmac.compare_digest(
143
+ (submitted or "").encode(), self._passcode.encode()
144
+ )
145
+
146
+ def attempt_login(self, submitted: str) -> tuple[bool, str]:
147
+ """Throttle-aware login. Returns (ok, reason) where reason is one of
148
+ "ok", "locked", "bad". A locked console never even compares the
149
+ passcode, so a lockout is not a timing oracle either."""
150
+ if self.throttle.locked():
151
+ return False, "locked"
152
+ if self.verify_passcode(submitted):
153
+ self.throttle.record_success()
154
+ return True, "ok"
155
+ self.throttle.record_failure()
156
+ return False, "bad"
157
+
158
+ # -- session cookie ----------------------------------------------------
159
+
160
+ def _mac(self, payload: str) -> str:
161
+ return hmac.new(self._key, payload.encode(), hashlib.sha256).hexdigest()
162
+
163
+ def issue_session(self) -> str:
164
+ """A fresh signed session token: `<sid>.<issued_at>.<mac>`."""
165
+ sid = secrets.token_hex(16)
166
+ issued = int(self._clock())
167
+ return self._sign(f"{sid}.{issued}")
168
+
169
+ def _sign(self, payload: str) -> str:
170
+ return f"{payload}.{self._mac(payload)}"
171
+
172
+ def verify_session(self, token: str | None) -> bool:
173
+ """True iff `token` is a well-formed, correctly-signed, unexpired
174
+ session cookie. Any malformed input returns False, never raises."""
175
+ if not token:
176
+ return False
177
+ payload, _, mac = token.rpartition(".")
178
+ if not payload or not mac:
179
+ return False
180
+ if not hmac.compare_digest(self._mac(payload), mac):
181
+ return False
182
+ _, _, issued_str = payload.rpartition(".")
183
+ try:
184
+ issued = int(issued_str)
185
+ except ValueError:
186
+ return False
187
+ return 0 <= (self._clock() - issued) <= self._ttl
188
+
189
+ # -- CSRF binding ------------------------------------------------------
190
+
191
+ def csrf_token(self, session_token: str) -> str:
192
+ """A CSRF token bound to a specific session cookie. A cross-site form
193
+ cannot read the cookie, so it cannot compute this -- the token is the
194
+ proof that the request originated from a page WE served."""
195
+ return self._mac(f"csrf:{session_token}")
196
+
197
+ def verify_csrf(self, session_token: str | None, csrf: str | None) -> bool:
198
+ if not session_token or not csrf:
199
+ return False
200
+ return hmac.compare_digest(self.csrf_token(session_token), csrf)