alissa-tools-github-revloop 0.13.0__tar.gz → 0.15.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.
- {alissa_tools_github_revloop-0.13.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.15.0}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.15.0}/setup.py +1 -0
- {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.15.0}/src/main/alissa/tools/github/revloop/state.py +155 -1
- alissa_tools_github_revloop-0.15.0/src/main/alissa/tools/github/revloop/version +1 -0
- alissa_tools_github_revloop-0.15.0/src/main/alissa/tools/github/revloop/webui/__init__.py +36 -0
- alissa_tools_github_revloop-0.15.0/src/main/alissa/tools/github/revloop/webui/__main__.py +130 -0
- alissa_tools_github_revloop-0.15.0/src/main/alissa/tools/github/revloop/webui/auth.py +200 -0
- alissa_tools_github_revloop-0.15.0/src/main/alissa/tools/github/revloop/webui/page.py +588 -0
- alissa_tools_github_revloop-0.15.0/src/main/alissa/tools/github/revloop/webui/server.py +355 -0
- alissa_tools_github_revloop-0.15.0/src/main/alissa/tools/github/revloop/webui/sources.py +615 -0
- alissa_tools_github_revloop-0.15.0/src/main/alissa/tools/github/revloop/webui/sysinfo.py +174 -0
- {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.15.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.15.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +7 -0
- {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.15.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +1 -0
- alissa_tools_github_revloop-0.13.0/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.15.0}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.15.0}/README.md +0 -0
- {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.15.0}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.15.0}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.15.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.15.0}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
- {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.15.0}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
- {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.15.0}/src/main/alissa/tools/github/revloop/config.py +0 -0
- {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.15.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.15.0}/src/main/alissa/tools/github/revloop/loop.py +0 -0
- {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.15.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.15.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.15.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.15.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.13.0 → alissa_tools_github_revloop-0.15.0}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -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 @@
|
|
|
1
|
+
0.15.0
|
|
@@ -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)
|