personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'hopper-krcsslej-init-guard-auth'

+339 -27
+15
apps/health/tests/conftest.py
··· 5 5 6 6 from __future__ import annotations 7 7 8 + import json 9 + 8 10 import pytest 9 11 10 12 ··· 18 20 ): 19 21 journal = tmp_path / "journal" 20 22 journal.mkdir(exist_ok=True) 23 + 24 + config_dir = journal / "config" 25 + config_dir.mkdir(parents=True, exist_ok=True) 26 + config_file = config_dir / "journal.json" 27 + config_file.write_text( 28 + json.dumps( 29 + { 30 + "convey": {"trust_localhost": True}, 31 + "setup": {"completed_at": 1700000000000}, 32 + }, 33 + indent=2, 34 + ) 35 + ) 21 36 22 37 # Create sample log file 23 38 log_file = journal / log_path
+15
apps/observer/tests/conftest.py
··· 9 9 10 10 from __future__ import annotations 11 11 12 + import json 13 + 12 14 import pytest 13 15 14 16 ··· 23 25 def _create(): 24 26 journal = tmp_path / "journal" 25 27 journal.mkdir() 28 + 29 + config_dir = journal / "config" 30 + config_dir.mkdir(parents=True, exist_ok=True) 31 + config_file = config_dir / "journal.json" 32 + config_file.write_text( 33 + json.dumps( 34 + { 35 + "convey": {"trust_localhost": True}, 36 + "setup": {"completed_at": 1700000000000}, 37 + }, 38 + indent=2, 39 + ) 40 + ) 26 41 27 42 # Set environment 28 43 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal))
+25
convey/__init__.py
··· 77 77 os.chmod(config_path, 0o600) 78 78 79 79 80 + def _migrate_setup_completed() -> None: 81 + """Infer setup.completed_at and set trust_localhost for existing installs.""" 82 + from think.utils import get_config, get_journal 83 + 84 + config = get_config() 85 + 86 + if not config.get("convey", {}).get("password_hash"): 87 + return 88 + if config.get("setup", {}).get("completed_at"): 89 + return 90 + 91 + from think.utils import now_ms 92 + 93 + config.setdefault("setup", {})["completed_at"] = now_ms() 94 + config.setdefault("convey", {})["trust_localhost"] = True 95 + 96 + config_path = Path(get_journal()) / "config" / "journal.json" 97 + config_path.parent.mkdir(parents=True, exist_ok=True) 98 + with open(config_path, "w", encoding="utf-8") as f: 99 + json.dump(config, f, indent=2, ensure_ascii=False) 100 + f.write("\n") 101 + os.chmod(config_path, 0o600) 102 + 103 + 80 104 def create_app(journal: str = "") -> Flask: 81 105 """Create and configure the Convey Flask application.""" 82 106 app = Flask( ··· 98 122 99 123 app.secret_key = _get_or_create_secret() 100 124 _migrate_password_hash() 125 + _migrate_setup_completed() 101 126 app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30) 102 127 103 128 # Register root blueprint (login, logout, /, favicon)
+54 -19
convey/root.py
··· 37 37 return "" 38 38 39 39 40 + def _is_setup_complete() -> bool: 41 + """Check if initial setup has been completed.""" 42 + try: 43 + config = get_config() 44 + return bool(config.get("setup", {}).get("completed_at")) 45 + except Exception: 46 + return False 47 + 48 + 49 + def _check_basic_auth() -> bool: 50 + """Check Basic Auth credentials against stored password hash.""" 51 + auth = request.authorization 52 + if not auth or auth.type != "basic": 53 + return False 54 + password_hash = _get_password_hash() 55 + if not password_hash: 56 + return False 57 + return check_password_hash(password_hash, auth.password or "") 58 + 59 + 40 60 def _save_config_section(section: str, data: dict) -> dict: 41 61 """Merge data into a config section and write back to journal.json.""" 42 62 config = get_config() ··· 77 97 }: 78 98 return None 79 99 80 - # Auto-bypass for localhost requests WITHOUT proxy headers 81 - remote_addr = request.remote_addr 82 - is_localhost = remote_addr in ("127.0.0.1", "::1", "localhost") 100 + # Session cookie 101 + if session.get("logged_in"): 102 + return None 103 + 104 + # Basic Auth (per-request, no session creation) 105 + if _check_basic_auth(): 106 + return None 83 107 84 - # Detect proxy headers that might indicate forwarded external request 85 - proxy_headers = ( 86 - request.headers.get("X-Forwarded-For") 87 - or request.headers.get("X-Real-IP") 88 - or request.headers.get("X-Forwarded-Host") 89 - ) 108 + # Check setup state 109 + setup_complete = _is_setup_complete() 90 110 91 - if is_localhost and not proxy_headers: 92 - # Genuine localhost request - auto-bypass 93 - return None 111 + # Opt-in localhost bypass (requires completed setup + trust_localhost flag) 112 + if setup_complete: 113 + config = get_config() 114 + if config.get("convey", {}).get("trust_localhost", False): 115 + remote_addr = request.remote_addr 116 + is_localhost = remote_addr in ("127.0.0.1", "::1", "localhost") 117 + proxy_headers = ( 118 + request.headers.get("X-Forwarded-For") 119 + or request.headers.get("X-Real-IP") 120 + or request.headers.get("X-Forwarded-Host") 121 + ) 122 + if is_localhost and not proxy_headers: 123 + return None 94 124 95 - # Otherwise require session authentication 96 - if not session.get("logged_in"): 97 - if not _get_password_hash(): 98 - return redirect(url_for("root.init")) 99 - return redirect(url_for("root.login")) 125 + # Not authenticated — redirect based on setup state 126 + if not setup_complete: 127 + return redirect(url_for("root.init")) 128 + return redirect(url_for("root.login")) 100 129 101 130 102 131 @bp.route("/login", methods=["GET", "POST"]) ··· 121 150 122 151 @bp.route("/init") 123 152 def init() -> Any: 124 - if _get_password_hash(): 153 + if _is_setup_complete(): 125 154 return redirect(url_for("root.index")) 126 155 127 156 config_path = str(Path(get_journal()) / "config" / "journal.json") 128 157 repo_path = str(Path(__file__).resolve().parent.parent) 129 - return render_template("init.html", config_path=config_path, repo_path=repo_path) 158 + has_password = bool(_get_password_hash()) 159 + return render_template( 160 + "init.html", 161 + config_path=config_path, 162 + repo_path=repo_path, 163 + has_password=has_password, 164 + ) 130 165 131 166 132 167 @bp.route("/init/password", methods=["POST"])
+11 -1
convey/screenshot.py
··· 34 34 script: str | None = None, 35 35 facet: str | None = None, 36 36 delay: int = 0, 37 + password: str | None = None, 37 38 ) -> None: 38 39 """ 39 40 Capture screenshot of a Convey view. ··· 48 49 script: Optional JavaScript to execute before taking screenshot 49 50 facet: Optional facet to select (use "all" for all-facet mode) 50 51 delay: Milliseconds to wait after page load before screenshot (default: 0) 52 + password: Optional password for Basic Auth 51 53 """ 52 54 # Ensure route has leading slash 53 55 if not route.startswith("/"): ··· 60 62 61 63 with sync_playwright() as p: 62 64 browser = p.chromium.launch() 63 - context = browser.new_context(viewport={"width": width, "height": height}) 65 + context_kwargs: dict = {"viewport": {"width": width, "height": height}} 66 + if password: 67 + context_kwargs["http_credentials"] = {"username": "", "password": password} 68 + context = browser.new_context(**context_kwargs) 64 69 65 70 # Set facet selection cookie if specified 66 71 if facet: ··· 158 163 default=None, 159 164 help="Delay in ms after page load (default: 500 if route has #fragment, else 0)", 160 165 ) 166 + parser.add_argument( 167 + "--password", 168 + help="Password for Basic Auth", 169 + ) 161 170 162 171 args = setup_cli(parser) 163 172 ··· 191 200 script=args.script, 192 201 facet=args.facet, 193 202 delay=args.delay, 203 + password=args.password, 194 204 ) 195 205 196 206
+11
convey/templates/init.html
··· 307 307 if (data.success && data.redirect) window.location.href = data.redirect; 308 308 } catch (err) {} 309 309 } 310 + 311 + {% if has_password %} 312 + document.addEventListener('DOMContentLoaded', function() { 313 + const pwField = document.getElementById('password'); 314 + pwField.value = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'; 315 + pwField.disabled = true; 316 + const toggle = document.getElementById('toggle-password'); 317 + if (toggle) toggle.style.display = 'none'; 318 + unlockSections(); 319 + }); 320 + {% endif %} 310 321 </script> 311 322 </body> 312 323 </html>
+5 -1
tests/baselines/api/settings/config.json
··· 6 6 "proposal_count": 0 7 7 }, 8 8 "convey": { 9 - "has_password": true 9 + "has_password": true, 10 + "trust_localhost": true 10 11 }, 11 12 "describe": { 12 13 "redact": [ ··· 87 88 "OPENAI_API_KEY": false, 88 89 "PLAUD_ACCESS_TOKEN": false, 89 90 "REVAI_ACCESS_TOKEN": false 91 + }, 92 + "setup": { 93 + "completed_at": 1700000000000 90 94 }, 91 95 "transcribe": { 92 96 "backend": "whisper",
+5 -1
tests/fixtures/journal/config/journal.json
··· 15 15 }, 16 16 "convey": { 17 17 "password_hash": "scrypt:32768:8:1$ceTJLGRcxYTqVQ4n$74a88b364046ab7ca627df875f27b1fb50994d4311ff8c86393bd7d32eaac303c81f165e79a99164931457846b4e702ac6cb38b877871cb4fadf1f70937bbfba", 18 - "secret": "test-fixture-secret-do-not-use-in-production" 18 + "secret": "test-fixture-secret-do-not-use-in-production", 19 + "trust_localhost": true 20 + }, 21 + "setup": { 22 + "completed_at": 1700000000000 19 23 }, 20 24 "transcribe": { 21 25 "backend": "whisper",
+186
tests/test_init.py
··· 1 + import base64 1 2 import json 2 3 3 4 import pytest ··· 13 14 config = _read_config(journal_dir) 14 15 config["convey"].pop("password_hash", None) 15 16 config["convey"].pop("password", None) 17 + config["convey"].pop("trust_localhost", None) 18 + config.pop("setup", None) 16 19 (journal_dir / "config" / "journal.json").write_text(json.dumps(config, indent=2)) 17 20 18 21 ··· 259 262 ) 260 263 resp = fresh_client.get("/init") 261 264 assert resp.status_code == 302 265 + 266 + 267 + class TestLocalhostBypass: 268 + """Tests for the opt-in trust_localhost bypass.""" 269 + 270 + def test_localhost_fresh_install_redirects_to_init(self, fresh_client): 271 + """Plain localhost with no config → redirect to /init.""" 272 + resp = fresh_client.get("/") 273 + assert resp.status_code == 302 274 + assert "/init" in resp.headers["Location"] 275 + 276 + def test_localhost_trust_bypass(self, journal_copy): 277 + """Localhost + trust_localhost + setup.completed_at → pass through.""" 278 + config = _read_config(journal_copy) 279 + config["convey"]["trust_localhost"] = True 280 + config["setup"] = {"completed_at": 1700000000000} 281 + (journal_copy / "config" / "journal.json").write_text( 282 + json.dumps(config, indent=2) 283 + ) 284 + app = create_app(str(journal_copy)) 285 + app.config["TESTING"] = True 286 + client = app.test_client() 287 + resp = client.get("/") 288 + assert resp.status_code == 302 289 + # Should redirect to home app, not login or init 290 + assert "/login" not in resp.headers["Location"] 291 + assert "/init" not in resp.headers["Location"] 292 + 293 + def test_localhost_trust_without_setup_redirects_to_init(self, journal_copy): 294 + """trust_localhost set but no setup.completed_at → redirect to /init.""" 295 + config = _read_config(journal_copy) 296 + config["convey"]["trust_localhost"] = True 297 + config.pop("setup", None) 298 + config["convey"].pop("password_hash", None) 299 + (journal_copy / "config" / "journal.json").write_text( 300 + json.dumps(config, indent=2) 301 + ) 302 + app = create_app(str(journal_copy)) 303 + app.config["TESTING"] = True 304 + client = app.test_client() 305 + resp = client.get("/") 306 + assert resp.status_code == 302 307 + assert "/init" in resp.headers["Location"] 308 + 309 + def test_localhost_no_trust_redirects_to_login(self, journal_copy): 310 + """Localhost + setup.completed_at but no trust_localhost → redirect to /login.""" 311 + config = _read_config(journal_copy) 312 + config["convey"].pop("trust_localhost", None) 313 + config["setup"] = {"completed_at": 1700000000000} 314 + (journal_copy / "config" / "journal.json").write_text( 315 + json.dumps(config, indent=2) 316 + ) 317 + app = create_app(str(journal_copy)) 318 + app.config["TESTING"] = True 319 + client = app.test_client() 320 + resp = client.get("/") 321 + assert resp.status_code == 302 322 + assert "/login" in resp.headers["Location"] 323 + 324 + def test_proxy_header_defeats_trust_localhost(self, configured_client): 325 + """Proxy headers prevent trust_localhost bypass.""" 326 + resp = configured_client.get("/", headers={"X-Forwarded-For": "1.2.3.4"}) 327 + assert resp.status_code == 302 328 + assert "/login" in resp.headers["Location"] 329 + 330 + 331 + class TestBasicAuth: 332 + """Tests for Basic Auth support.""" 333 + 334 + def test_basic_auth_correct_password(self, configured_client): 335 + """Basic Auth with correct password → authenticated.""" 336 + creds = base64.b64encode(b":test123").decode() 337 + resp = configured_client.get( 338 + "/", 339 + headers={ 340 + "Authorization": f"Basic {creds}", 341 + "X-Forwarded-For": "1.2.3.4", 342 + }, 343 + ) 344 + assert resp.status_code == 302 345 + # Should redirect to home app, not login or init 346 + assert "/login" not in resp.headers["Location"] 347 + assert "/init" not in resp.headers["Location"] 348 + 349 + def test_basic_auth_wrong_password(self, configured_client): 350 + """Basic Auth with wrong password → redirect to /login.""" 351 + creds = base64.b64encode(b":wrongpassword").decode() 352 + resp = configured_client.get( 353 + "/", 354 + headers={ 355 + "Authorization": f"Basic {creds}", 356 + "X-Forwarded-For": "1.2.3.4", 357 + }, 358 + ) 359 + assert resp.status_code == 302 360 + assert "/login" in resp.headers["Location"] 361 + 362 + def test_basic_auth_no_session(self, configured_client): 363 + """Basic Auth does not create a session — next request without header fails.""" 364 + creds = base64.b64encode(b":test123").decode() 365 + # First request with Basic Auth succeeds 366 + resp1 = configured_client.get( 367 + "/", 368 + headers={ 369 + "Authorization": f"Basic {creds}", 370 + "X-Forwarded-For": "1.2.3.4", 371 + }, 372 + ) 373 + assert "/login" not in resp1.headers["Location"] 374 + 375 + # Second request without Basic Auth → should redirect to login 376 + resp2 = configured_client.get("/", headers={"X-Forwarded-For": "1.2.3.4"}) 377 + assert resp2.status_code == 302 378 + assert "/login" in resp2.headers["Location"] 379 + 380 + 381 + class TestPartialInit: 382 + """Tests for partial init resumption.""" 383 + 384 + def test_partial_init_redirects_to_init(self, fresh_client, journal_copy): 385 + """password_hash set but no setup.completed_at → redirect to /init.""" 386 + # Set password through the init flow 387 + fresh_client.post( 388 + "/init/password", 389 + json={"password": "securepass123"}, 390 + content_type="application/json", 391 + ) 392 + # Now password_hash exists but no setup.completed_at 393 + resp = fresh_client.get("/", headers={"X-Forwarded-For": "1.2.3.4"}) 394 + assert resp.status_code == 302 395 + assert "/init" in resp.headers["Location"] 396 + 397 + def test_partial_init_renders_with_has_password(self, fresh_client, journal_copy): 398 + """Partial init: init page renders with sections unlocked.""" 399 + fresh_client.post( 400 + "/init/password", 401 + json={"password": "securepass123"}, 402 + content_type="application/json", 403 + ) 404 + resp = fresh_client.get("/init") 405 + assert resp.status_code == 200 406 + # The has_password template var triggers unlockSections() 407 + assert b"unlockSections()" in resp.data 408 + 409 + 410 + class TestSetupMigration: 411 + """Tests for the _migrate_setup_completed migration.""" 412 + 413 + def test_migration_writes_setup_and_trust(self, journal_copy): 414 + """App startup with password_hash but no setup.completed_at writes both.""" 415 + config = _read_config(journal_copy) 416 + config.pop("setup", None) 417 + config["convey"].pop("trust_localhost", None) 418 + (journal_copy / "config" / "journal.json").write_text( 419 + json.dumps(config, indent=2) 420 + ) 421 + 422 + # create_app triggers the migration 423 + create_app(str(journal_copy)) 424 + 425 + config = _read_config(journal_copy) 426 + assert "completed_at" in config.get("setup", {}) 427 + assert config["convey"].get("trust_localhost") is True 428 + 429 + def test_migration_idempotent(self, journal_copy): 430 + """Running migration twice is a no-op.""" 431 + config = _read_config(journal_copy) 432 + config.pop("setup", None) 433 + config["convey"].pop("trust_localhost", None) 434 + (journal_copy / "config" / "journal.json").write_text( 435 + json.dumps(config, indent=2) 436 + ) 437 + 438 + # First run triggers migration 439 + create_app(str(journal_copy)) 440 + config1 = _read_config(journal_copy) 441 + ts1 = config1["setup"]["completed_at"] 442 + 443 + # Second run should be a no-op 444 + create_app(str(journal_copy)) 445 + config2 = _read_config(journal_copy) 446 + assert config2["setup"]["completed_at"] == ts1 447 + assert config2["convey"]["trust_localhost"] is True
+12 -5
tests/verify_api.py
··· 550 550 class _HttpClient: 551 551 """Minimal requests-like object for endpoint fetching.""" 552 552 553 - def __init__(self, base_url: str) -> None: 553 + def __init__(self, base_url: str, password: str | None = None) -> None: 554 554 self.base_url = base_url.rstrip("/") 555 + self._auth = ("", password) if password else None 555 556 556 557 def get(self, path: str, query_string: dict[str, Any] | None = None): 557 558 import requests 558 559 559 - return requests.get(f"{self.base_url}{path}", params=query_string) 560 + return requests.get( 561 + f"{self.base_url}{path}", params=query_string, auth=self._auth 562 + ) 560 563 561 564 562 565 def _resolve_journal_path() -> str: ··· 590 593 "--base-url", 591 594 help="Use HTTP mode against this base URL instead of Flask test client", 592 595 ) 596 + parser.add_argument( 597 + "--password", 598 + help="Password for Basic Auth in HTTP mode", 599 + ) 593 600 return parser.parse_args(argv) 594 601 595 602 596 - def make_client(base_url: str | None) -> Any: 603 + def make_client(base_url: str | None, password: str | None = None) -> Any: 597 604 if base_url: 598 - return _HttpClient(base_url) 605 + return _HttpClient(base_url, password=password) 599 606 journal_path = _resolve_journal_path() 600 607 app = create_app(journal_path) 601 608 app.config["TESTING"] = True ··· 615 622 616 623 def main(argv: list[str] | None = None) -> int: 617 624 args = parse_args(argv) 618 - client = make_client(args.base_url) 625 + client = make_client(args.base_url, password=args.password) 619 626 journal_path = resolve_journal_for_mode(args.base_url) 620 627 621 628 if args.command == "verify":