personal memory agent
0
fork

Configure Feed

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

Merge branch 'hopper-sxy7ib7v-init-page'

+673 -2
+124 -2
convey/root.py
··· 5 5 6 6 from __future__ import annotations 7 7 8 + import json 8 9 import os 9 10 from datetime import date 11 + from pathlib import Path 10 12 from typing import Any 11 13 12 14 from flask import ( 13 15 Blueprint, 16 + jsonify, 14 17 redirect, 15 18 render_template, 16 19 request, ··· 18 21 session, 19 22 url_for, 20 23 ) 21 - from werkzeug.security import check_password_hash 24 + from werkzeug.security import check_password_hash, generate_password_hash 22 25 23 26 from think.cluster import cluster_segments 24 - from think.utils import day_dirs, get_config 27 + from think.utils import day_dirs, get_config, get_journal 25 28 26 29 27 30 def _get_password_hash() -> str: ··· 34 37 return "" 35 38 36 39 40 + def _save_config_section(section: str, data: dict) -> dict: 41 + """Merge data into a config section and write back to journal.json.""" 42 + config = get_config() 43 + config.setdefault(section, {}).update(data) 44 + config_path = Path(get_journal()) / "config" / "journal.json" 45 + config_path.parent.mkdir(parents=True, exist_ok=True) 46 + with open(config_path, "w", encoding="utf-8") as f: 47 + json.dump(config, f, indent=2, ensure_ascii=False) 48 + f.write("\n") 49 + os.chmod(config_path, 0o600) 50 + return config 51 + 52 + 37 53 bp = Blueprint( 38 54 "root", 39 55 __name__, ··· 45 61 @bp.before_app_request 46 62 def require_login() -> Any: 47 63 if request.endpoint in { 64 + "root.init", 65 + "root.init_password", 66 + "root.init_identity", 67 + "root.init_provider", 68 + "root.init_observers", 69 + "root.init_finalize", 48 70 "root.login", 49 71 "root.static", 50 72 "root.favicon", ··· 72 94 73 95 # Otherwise require session authentication 74 96 if not session.get("logged_in"): 97 + if not _get_password_hash(): 98 + return redirect(url_for("root.init")) 75 99 return redirect(url_for("root.login")) 76 100 77 101 ··· 93 117 return redirect(url_for("root.index")) 94 118 error = "Invalid password" 95 119 return render_template("login.html", error=error, no_password=False) 120 + 121 + 122 + @bp.route("/init") 123 + def init() -> Any: 124 + if _get_password_hash(): 125 + return redirect(url_for("root.index")) 126 + 127 + config_path = str(Path(get_journal()) / "config" / "journal.json") 128 + return render_template("init.html", config_path=config_path) 129 + 130 + 131 + @bp.route("/init/password", methods=["POST"]) 132 + def init_password() -> Any: 133 + if _get_password_hash(): 134 + return jsonify({"error": "Already configured"}), 400 135 + 136 + data = request.get_json(silent=True) or {} 137 + password = data.get("password", "") 138 + if len(password) < 8: 139 + return jsonify({"error": "Password must be at least 8 characters"}), 400 140 + 141 + hashed = generate_password_hash(password) 142 + _save_config_section("convey", {"password_hash": hashed}) 143 + return jsonify({"success": True}) 144 + 145 + 146 + @bp.route("/init/identity", methods=["POST"]) 147 + def init_identity() -> Any: 148 + if not _get_password_hash(): 149 + return jsonify({"error": "Password required first"}), 403 150 + 151 + data = request.get_json(silent=True) or {} 152 + allowed = {k: data[k] for k in ("name", "preferred", "timezone") if k in data} 153 + _save_config_section("identity", allowed) 154 + return jsonify({"success": True}) 155 + 156 + 157 + @bp.route("/init/provider", methods=["POST"]) 158 + def init_provider() -> Any: 159 + if not _get_password_hash(): 160 + return jsonify({"error": "Password required first"}), 403 161 + 162 + data = request.get_json(silent=True) or {} 163 + key = data.get("key", "") 164 + _save_config_section("env", {"GOOGLE_API_KEY": key}) 165 + 166 + from think.providers import validate_key 167 + 168 + try: 169 + result = validate_key("google", key) 170 + except Exception as e: 171 + result = {"valid": False, "error": str(e)} 172 + return jsonify({"success": True, "validation": result}) 173 + 174 + 175 + @bp.route("/init/observers") 176 + def init_observers() -> Any: 177 + if not _get_password_hash(): 178 + return jsonify({"error": "Password required first"}), 403 179 + 180 + from apps.remote.utils import list_remotes 181 + 182 + remotes_list = [] 183 + for remote in list_remotes(): 184 + if remote.get("revoked", False): 185 + continue 186 + remotes_list.append( 187 + { 188 + "key_prefix": remote.get("key", "")[:8], 189 + "name": remote.get("name", ""), 190 + "created_at": remote.get("created_at", 0), 191 + "last_seen": remote.get("last_seen"), 192 + "last_segment": remote.get("last_segment"), 193 + "enabled": remote.get("enabled", True), 194 + "revoked": remote.get("revoked", False), 195 + "revoked_at": remote.get("revoked_at"), 196 + "stats": remote.get("stats", {}), 197 + } 198 + ) 199 + return jsonify(remotes_list) 200 + 201 + 202 + @bp.route("/init/finalize", methods=["POST"]) 203 + def init_finalize() -> Any: 204 + if not _get_password_hash(): 205 + return jsonify({"error": "Password required first"}), 403 206 + 207 + from think.utils import now_ms 208 + 209 + data = request.get_json(silent=True) or {} 210 + coding_agent = data.get("coding_agent", "") 211 + _save_config_section( 212 + "setup", 213 + {"coding_agent": coding_agent, "completed_at": now_ms()}, 214 + ) 215 + session["logged_in"] = True 216 + session.permanent = True 217 + return jsonify({"success": True, "redirect": url_for("root.index")}) 96 218 97 219 98 220 @bp.route("/logout")
+277
convey/templates/init.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"/> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"/> 6 + <title>set up — solstone</title> 7 + <style> 8 + body { 9 + min-height: 100vh; display: flex; align-items: center; justify-content: center; 10 + font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 1em; background: #fff; 11 + } 12 + svg[aria-label="sol logo"] { width: 64px; height: 64px; margin: 0 auto 1rem; display: block; } 13 + .init-container { max-width: 520px; width: 90vw; } 14 + .config-path { font-size: 0.75rem; color: #aaa; text-align: center; margin: 0 0 1.5rem; word-break: break-all; } 15 + h2 { text-align: center; font-weight: 500; margin-bottom: 2rem; } 16 + .init-section { margin-bottom: 2rem; } 17 + .init-section.disabled { opacity: 0.4; pointer-events: none; } 18 + .init-section h3 { font-size: 1rem; font-weight: 500; border-bottom: 1px solid #eee; padding-bottom: 0.5rem; margin-bottom: 0.75rem; } 19 + .section-hint { font-size: 0.85rem; color: #666; margin: 0 0 0.75rem; } 20 + .section-hint a { color: #E8923A; } 21 + .field-group { margin-bottom: 0.75rem; } 22 + .field-group label { display: block; font-size: 0.85rem; color: #444; margin-bottom: 0.25rem; } 23 + .field-group input { 24 + width: 100%; padding: 10px 12px; border: 2px solid #ddd; border-radius: 8px; 25 + font-size: 0.95rem; box-sizing: border-box; 26 + } 27 + .field-group input:focus { outline: none; border-color: #E8923A; } 28 + .field-group input:disabled { background: #f5f5f5; color: #999; } 29 + .field-status { display: block; font-size: 0.8rem; min-height: 1.2em; margin-top: 0.25rem; } 30 + .status-saved { color: #22c55e; } 31 + .status-error { color: #ef4444; } 32 + .status-fade { opacity: 0; transition: opacity 0.3s; } 33 + .timezone-chip { 34 + display: inline-block; background: #f5f5f5; border-radius: 6px; 35 + padding: 6px 12px; font-size: 0.85rem; color: #666; 36 + } 37 + .agent-cards { display: flex; gap: 0.75rem; margin-top: 0.5rem; } 38 + .agent-card { 39 + flex: 1; padding: 1rem; border: 2px solid #ddd; border-radius: 8px; 40 + cursor: pointer; text-align: center; transition: border-color 0.2s, background 0.2s; 41 + display: flex; flex-direction: column; gap: 0.25rem; 42 + } 43 + .agent-card:hover { border-color: #E8923A; } 44 + .agent-card.selected { border-color: #E8923A; background: #fef3e2; } 45 + .agent-card strong { font-size: 0.95rem; } 46 + .agent-card span { font-size: 0.8rem; color: #666; } 47 + .observer-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0; } 48 + .observer-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } 49 + .observer-dot.connected { background: #22c55e; } 50 + .observer-dot.disconnected { background: #d1d5db; } 51 + .observer-state { font-size: 0.8rem; color: #999; margin-left: auto; } 52 + .input-wrap { position: relative; } 53 + .input-wrap input { padding-right: 64px; } 54 + .toggle-btn { 55 + position: absolute; top: 50%; right: 12px; transform: translateY(-50%); 56 + border: none; background: transparent; color: #666; cursor: pointer; font-size: 0.8rem; 57 + padding: 0; 58 + } 59 + .footer-note { font-size: 0.8rem; color: #aaa; margin-top: 1.5rem; text-align: center; } 60 + </style> 61 + </head> 62 + <body> 63 + <div class="init-container"> 64 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="2.5 2.5 27 27" role="img" aria-label="sol logo"> 65 + <title>sol</title> 66 + <!-- Sun rays: 10 floating wedges with curved inner arc matching the ring --> 67 + <path fill="#F5C740" d="M16.0 2.5 L18.6 7.3 A9.1 9.1 0 0 0 13.4 7.3 Z M23.9 5.1 L23.2 10.5 A9.1 9.1 0 0 0 19.0 7.4 Z M28.8 11.8 L25.1 15.8 A9.1 9.1 0 0 0 23.5 10.9 Z M28.8 20.2 L23.5 21.1 A9.1 9.1 0 0 0 25.1 16.2 Z M23.9 26.9 L19.0 24.6 A9.1 9.1 0 0 0 23.2 21.5 Z M16.0 29.5 L13.4 24.7 A9.1 9.1 0 0 0 18.6 24.7 Z M8.1 26.9 L8.8 21.5 A9.1 9.1 0 0 0 13.0 24.6 Z M3.2 20.2 L6.9 16.2 A9.1 9.1 0 0 0 8.5 21.1 Z M3.2 11.8 L8.5 10.9 A9.1 9.1 0 0 0 6.9 15.8 Z M8.1 5.1 L13.0 7.4 A9.1 9.1 0 0 0 8.8 10.5 Z"/> 68 + <!-- Sun ring: open annulus --> 69 + <circle cx="16" cy="16" r="8.0" fill="none" stroke="#E8923A" stroke-width="1.2"/> 70 + <!-- sol — Comfortaa Bold, scale=0.01 SVG/fu, letter-spacing=-100fu, 12.54 wide --> 71 + <path fill="#E8923A" fill-rule="evenodd" d="M12.079 18.795C13.489 18.795 14.229 18.065 14.229 17.155C14.229 16.365 13.729 15.835 12.229 15.535C11.149 15.315 10.939 15.095 10.939 14.725C10.939 14.345 11.399 14.135 11.989 14.135C12.499 14.135 12.859 14.235 13.199 14.555C13.399 14.745 13.729 14.815 13.949 14.665C14.159 14.505 14.169 14.255 13.989 14.035C13.589 13.545 12.889 13.245 12.009 13.245C10.989 13.245 9.959 13.735 9.959 14.755C9.959 15.525 10.529 16.075 11.879 16.335C12.919 16.525 13.249 16.815 13.239 17.215C13.229 17.615 12.809 17.895 12.039 17.895C11.429 17.895 10.889 17.625 10.659 17.375C10.469 17.175 10.189 17.125 9.929 17.335C9.699 17.515 9.659 17.825 9.859 18.035C10.299 18.475 11.149 18.795 12.079 18.795Z M16.999 18.795C18.609 18.795 19.749 17.645 19.749 16.025C19.739 14.395 18.599 13.245 16.999 13.245C15.379 13.245 14.239 14.395 14.239 16.025C14.239 17.645 15.379 18.795 16.999 18.795ZM16.999 17.895C15.959 17.895 15.219 17.125 15.219 16.025C15.219 14.925 15.959 14.145 16.999 14.145C18.039 14.145 18.769 14.925 18.769 16.025C18.769 17.125 18.039 17.895 16.999 17.895Z M21.569 18.755H21.589C21.989 18.755 22.269 18.545 22.269 18.255C22.269 17.965 22.079 17.755 21.819 17.755H21.569C21.279 17.755 21.069 17.405 21.069 16.905V11.445C21.069 11.155 20.859 10.945 20.569 10.945C20.279 10.945 20.069 11.155 20.069 11.445V16.905C20.069 17.985 20.689 18.755 21.569 18.755Z"/> 72 + </svg> 73 + <p class="config-path">{{ config_path }}</p> 74 + <h2>set up solstone</h2> 75 + 76 + <section class="init-section" id="section-password"> 77 + <h3>1. set a password</h3> 78 + <p class="section-hint">Required for remote access. At least 8 characters.</p> 79 + <div class="field-group"> 80 + <label for="password">Password</label> 81 + <div class="input-wrap"> 82 + <input type="password" id="password" autocomplete="new-password"> 83 + <button type="button" class="toggle-btn" id="toggle-password" onclick="togglePassword()">show</button> 84 + </div> 85 + <small class="field-status">&nbsp;</small> 86 + </div> 87 + </section> 88 + 89 + <section class="init-section disabled" id="section-identity"> 90 + <h3>2. who are you?</h3> 91 + <p class="section-hint">Optional — helps sol address you correctly.</p> 92 + <div class="field-group"> 93 + <label for="name">Full name</label> 94 + <input type="text" id="name"> 95 + <small class="field-status">&nbsp;</small> 96 + </div> 97 + <div class="field-group"> 98 + <label for="preferred">Preferred name</label> 99 + <input type="text" id="preferred"> 100 + <small class="field-status">&nbsp;</small> 101 + </div> 102 + <div class="field-group"> 103 + <label>Timezone</label> 104 + <span class="timezone-chip" id="timezone-display">Detecting…</span> 105 + </div> 106 + </section> 107 + 108 + <section class="init-section disabled" id="section-provider"> 109 + <h3>3. connect gemini</h3> 110 + <p class="section-hint">Optional — powers sol's thinking. Get a key at <a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener noreferrer">aistudio.google.com/apikey</a></p> 111 + <div class="field-group"> 112 + <label for="gemini-key">Gemini API key</label> 113 + <input type="password" id="gemini-key"> 114 + <small class="field-status">&nbsp;</small> 115 + </div> 116 + </section> 117 + 118 + <section class="init-section disabled" id="section-finalize"> 119 + <h3>4. get started</h3> 120 + <div id="observer-status" style="display:none"> 121 + <p class="section-hint">Connected observers</p> 122 + <div id="observer-list"></div> 123 + </div> 124 + <div class="agent-cards"> 125 + <div class="agent-card" data-agent="claude-code" onclick="selectAgent(this)"> 126 + <strong>Claude Code</strong> 127 + <span>Using Claude Code</span> 128 + </div> 129 + <div class="agent-card" data-agent="codex" onclick="selectAgent(this)"> 130 + <strong>Codex</strong> 131 + <span>Using Codex</span> 132 + </div> 133 + <div class="agent-card" data-agent="none" onclick="selectAgent(this)"> 134 + <strong>Skip</strong> 135 + <span>Set this up later</span> 136 + </div> 137 + </div> 138 + </section> 139 + 140 + <p class="footer-note">your data stays on your machine</p> 141 + </div> 142 + 143 + <script> 144 + function togglePassword() { 145 + const input = document.getElementById('password'); 146 + const btn = document.getElementById('toggle-password'); 147 + if (input.type === 'password') { input.type = 'text'; btn.textContent = 'hide'; } 148 + else { input.type = 'password'; btn.textContent = 'show'; } 149 + } 150 + 151 + function showFieldStatus(el, type, message) { 152 + const group = el.closest('.field-group'); 153 + if (!group) return; 154 + const small = group.querySelector('.field-status'); 155 + if (!small) return; 156 + small.classList.remove('status-saved', 'status-error', 'status-fade'); 157 + if (type === 'saved') { 158 + small.textContent = message || 'Saved'; 159 + small.classList.add('status-saved'); 160 + setTimeout(() => { 161 + small.classList.add('status-fade'); 162 + setTimeout(() => { small.textContent = '\u00a0'; small.classList.remove('status-saved', 'status-fade'); }, 300); 163 + }, 1500); 164 + } else { 165 + small.textContent = message || 'Error'; 166 + small.classList.add('status-error'); 167 + } 168 + } 169 + 170 + function unlockSections() { 171 + ['section-identity', 'section-provider', 'section-finalize'].forEach(id => 172 + document.getElementById(id).classList.remove('disabled')); 173 + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; 174 + document.getElementById('timezone-display').textContent = tz; 175 + fetch('/init/identity', { 176 + method: 'POST', headers: {'Content-Type': 'application/json'}, 177 + body: JSON.stringify({timezone: tz}) 178 + }); 179 + loadObservers(); 180 + } 181 + 182 + document.getElementById('password').addEventListener('blur', async function() { 183 + const password = this.value; 184 + if (!password) return; 185 + if (password.length < 8) { showFieldStatus(this, 'error', 'At least 8 characters'); return; } 186 + try { 187 + const resp = await fetch('/init/password', { 188 + method: 'POST', headers: {'Content-Type': 'application/json'}, 189 + body: JSON.stringify({password}) 190 + }); 191 + const data = await resp.json(); 192 + if (data.success) { 193 + showFieldStatus(this, 'saved'); 194 + this.disabled = true; 195 + document.getElementById('toggle-password').style.display = 'none'; 196 + unlockSections(); 197 + } else { showFieldStatus(this, 'error', data.error); } 198 + } catch (err) { showFieldStatus(this, 'error', 'Failed to save'); } 199 + }); 200 + 201 + ['name', 'preferred'].forEach(field => { 202 + document.getElementById(field).addEventListener('blur', async function() { 203 + const value = this.value.trim(); 204 + if (!value) return; 205 + try { 206 + const resp = await fetch('/init/identity', { 207 + method: 'POST', headers: {'Content-Type': 'application/json'}, 208 + body: JSON.stringify({[field]: value}) 209 + }); 210 + const data = await resp.json(); 211 + if (data.success) showFieldStatus(this, 'saved'); 212 + else showFieldStatus(this, 'error', data.error); 213 + } catch (err) { showFieldStatus(this, 'error', 'Failed to save'); } 214 + }); 215 + }); 216 + 217 + document.getElementById('gemini-key').addEventListener('blur', async function() { 218 + const key = this.value.trim(); 219 + if (!key) return; 220 + try { 221 + const resp = await fetch('/init/provider', { 222 + method: 'POST', headers: {'Content-Type': 'application/json'}, 223 + body: JSON.stringify({key}) 224 + }); 225 + const data = await resp.json(); 226 + if (data.success) { 227 + if (data.validation && data.validation.valid) showFieldStatus(this, 'saved', 'Connected'); 228 + else showFieldStatus(this, 'error', data.validation?.error || 'Key saved, validation failed'); 229 + } else showFieldStatus(this, 'error', data.error); 230 + } catch (err) { showFieldStatus(this, 'error', 'Failed to save'); } 231 + }); 232 + 233 + function esc(s) { 234 + const d = document.createElement('div'); 235 + d.textContent = s; 236 + return d.innerHTML; 237 + } 238 + 239 + async function loadObservers() { 240 + try { 241 + const resp = await fetch('/init/observers'); 242 + const data = await resp.json(); 243 + if (data.length > 0) { 244 + document.getElementById('observer-status').style.display = 'block'; 245 + const now = Date.now(); 246 + document.getElementById('observer-list').innerHTML = data.map(r => { 247 + const connected = r.last_seen && (now - r.last_seen) < 120000; 248 + const label = esc(r.name || r.key_prefix); 249 + return '<div class="observer-item"><span class="observer-dot ' + 250 + (connected ? 'connected' : 'disconnected') + '"></span><span>' + 251 + label + '</span><span class="observer-state">' + 252 + (connected ? 'connected' : 'disconnected') + '</span></div>'; 253 + }).join(''); 254 + } 255 + } catch (err) {} 256 + } 257 + 258 + let selectedAgent = null; 259 + function selectAgent(card) { 260 + document.querySelectorAll('.agent-card').forEach(c => c.classList.remove('selected')); 261 + card.classList.add('selected'); 262 + selectedAgent = card.dataset.agent; 263 + finalize(); 264 + } 265 + async function finalize() { 266 + try { 267 + const resp = await fetch('/init/finalize', { 268 + method: 'POST', headers: {'Content-Type': 'application/json'}, 269 + body: JSON.stringify({coding_agent: selectedAgent}) 270 + }); 271 + const data = await resp.json(); 272 + if (data.success && data.redirect) window.location.href = data.redirect; 273 + } catch (err) {} 274 + } 275 + </script> 276 + </body> 277 + </html>
+272
tests/test_init.py
··· 1 + import json 2 + import shutil 3 + from pathlib import Path 4 + 5 + import pytest 6 + 7 + from convey import create_app 8 + 9 + 10 + @pytest.fixture 11 + def journal_dir(tmp_path, monkeypatch): 12 + src = Path(__file__).resolve().parent / "fixtures" / "journal" 13 + dst = tmp_path / "journal" 14 + shutil.copytree(src, dst, symlinks=True) 15 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(dst)) 16 + return dst 17 + 18 + 19 + def _read_config(journal_dir): 20 + return json.loads((journal_dir / "config" / "journal.json").read_text()) 21 + 22 + 23 + def _remove_password(journal_dir): 24 + config = _read_config(journal_dir) 25 + config["convey"].pop("password_hash", None) 26 + config["convey"].pop("password", None) 27 + (journal_dir / "config" / "journal.json").write_text(json.dumps(config, indent=2)) 28 + 29 + 30 + @pytest.fixture 31 + def fresh_client(journal_dir): 32 + _remove_password(journal_dir) 33 + app = create_app(str(journal_dir)) 34 + app.config["TESTING"] = True 35 + return app.test_client() 36 + 37 + 38 + @pytest.fixture 39 + def configured_client(journal_dir): 40 + app = create_app(str(journal_dir)) 41 + app.config["TESTING"] = True 42 + return app.test_client() 43 + 44 + 45 + class TestInitDetection: 46 + def test_redirects_to_init_when_no_password(self, fresh_client): 47 + resp = fresh_client.get("/", headers={"X-Forwarded-For": "1.2.3.4"}) 48 + assert resp.status_code == 302 49 + assert "/init" in resp.headers["Location"] 50 + 51 + def test_redirects_to_login_when_password_exists(self, configured_client): 52 + resp = configured_client.get("/", headers={"X-Forwarded-For": "1.2.3.4"}) 53 + assert resp.status_code == 302 54 + assert "/login" in resp.headers["Location"] 55 + 56 + def test_init_page_renders(self, fresh_client): 57 + resp = fresh_client.get("/init") 58 + assert resp.status_code == 200 59 + assert b"set up solstone" in resp.data 60 + 61 + def test_init_redirects_when_configured(self, configured_client): 62 + resp = configured_client.get("/init") 63 + assert resp.status_code == 302 64 + 65 + 66 + class TestInitPassword: 67 + def test_save_password(self, fresh_client, journal_dir): 68 + resp = fresh_client.post( 69 + "/init/password", 70 + json={"password": "securepass123"}, 71 + content_type="application/json", 72 + ) 73 + assert resp.status_code == 200 74 + data = resp.get_json() 75 + assert data["success"] is True 76 + config = _read_config(journal_dir) 77 + assert "password_hash" in config["convey"] 78 + from werkzeug.security import check_password_hash 79 + 80 + assert check_password_hash(config["convey"]["password_hash"], "securepass123") 81 + 82 + def test_password_too_short(self, fresh_client, journal_dir): 83 + resp = fresh_client.post( 84 + "/init/password", 85 + json={"password": "short"}, 86 + content_type="application/json", 87 + ) 88 + assert resp.status_code == 400 89 + config = _read_config(journal_dir) 90 + assert "password_hash" not in config.get("convey", {}) 91 + 92 + def test_password_already_set(self, configured_client): 93 + resp = configured_client.post( 94 + "/init/password", 95 + json={"password": "newpassword123"}, 96 + content_type="application/json", 97 + ) 98 + assert resp.status_code == 400 99 + 100 + 101 + class TestInitIdentity: 102 + def test_save_identity(self, fresh_client, journal_dir): 103 + fresh_client.post( 104 + "/init/password", 105 + json={"password": "securepass123"}, 106 + content_type="application/json", 107 + ) 108 + resp = fresh_client.post( 109 + "/init/identity", 110 + json={"name": "Jane Doe", "preferred": "Jane"}, 111 + content_type="application/json", 112 + ) 113 + assert resp.status_code == 200 114 + config = _read_config(journal_dir) 115 + assert config["identity"]["name"] == "Jane Doe" 116 + assert config["identity"]["preferred"] == "Jane" 117 + 118 + def test_identity_requires_password(self, fresh_client): 119 + resp = fresh_client.post( 120 + "/init/identity", 121 + json={"name": "Jane"}, 122 + content_type="application/json", 123 + ) 124 + assert resp.status_code == 403 125 + 126 + 127 + class TestInitProvider: 128 + def test_save_provider_key(self, fresh_client, journal_dir, monkeypatch): 129 + fresh_client.post( 130 + "/init/password", 131 + json={"password": "securepass123"}, 132 + content_type="application/json", 133 + ) 134 + monkeypatch.setattr( 135 + "think.providers.validate_key", 136 + lambda provider, key: {"valid": True}, 137 + ) 138 + resp = fresh_client.post( 139 + "/init/provider", 140 + json={"key": "test-api-key-123"}, 141 + content_type="application/json", 142 + ) 143 + assert resp.status_code == 200 144 + data = resp.get_json() 145 + assert data["success"] is True 146 + assert data["validation"]["valid"] is True 147 + config = _read_config(journal_dir) 148 + assert config["env"]["GOOGLE_API_KEY"] == "test-api-key-123" 149 + 150 + def test_provider_validation_failure(self, fresh_client, journal_dir, monkeypatch): 151 + fresh_client.post( 152 + "/init/password", 153 + json={"password": "securepass123"}, 154 + content_type="application/json", 155 + ) 156 + monkeypatch.setattr( 157 + "think.providers.validate_key", 158 + lambda provider, key: {"valid": False, "error": "Invalid key"}, 159 + ) 160 + resp = fresh_client.post( 161 + "/init/provider", 162 + json={"key": "bad-key"}, 163 + content_type="application/json", 164 + ) 165 + assert resp.status_code == 200 166 + data = resp.get_json() 167 + assert data["success"] is True 168 + assert data["validation"]["valid"] is False 169 + config = _read_config(journal_dir) 170 + assert config["env"]["GOOGLE_API_KEY"] == "bad-key" 171 + 172 + 173 + class TestInitObservers: 174 + def test_observers_requires_password(self, fresh_client): 175 + resp = fresh_client.get("/init/observers") 176 + assert resp.status_code == 403 177 + 178 + def test_observers_returns_list(self, fresh_client, journal_dir, monkeypatch): 179 + fresh_client.post( 180 + "/init/password", 181 + json={"password": "securepass123"}, 182 + content_type="application/json", 183 + ) 184 + monkeypatch.setattr( 185 + "apps.remote.utils.list_remotes", 186 + lambda: [ 187 + {"key": "abcd1234xxxx", "name": "my-phone", "created_at": 100, 188 + "last_seen": None, "last_segment": None, "enabled": True, 189 + "revoked": False, "revoked_at": None, "stats": {}}, 190 + {"key": "revoked1xxxx", "name": "old-device", "created_at": 50, 191 + "last_seen": None, "last_segment": None, "enabled": False, 192 + "revoked": True, "revoked_at": 90, "stats": {}}, 193 + ], 194 + ) 195 + resp = fresh_client.get("/init/observers") 196 + assert resp.status_code == 200 197 + data = resp.get_json() 198 + assert len(data) == 1 199 + assert data[0]["name"] == "my-phone" 200 + assert data[0]["key_prefix"] == "abcd1234" 201 + 202 + 203 + class TestInitProviderGuard: 204 + def test_provider_requires_password(self, fresh_client): 205 + resp = fresh_client.post( 206 + "/init/provider", 207 + json={"key": "some-key"}, 208 + content_type="application/json", 209 + ) 210 + assert resp.status_code == 403 211 + 212 + 213 + class TestInitFinalizeGuard: 214 + def test_finalize_requires_password(self, fresh_client): 215 + resp = fresh_client.post( 216 + "/init/finalize", 217 + json={"coding_agent": "none"}, 218 + content_type="application/json", 219 + ) 220 + assert resp.status_code == 403 221 + 222 + 223 + class TestInitFinalize: 224 + def test_finalize_sets_session_and_config(self, fresh_client, journal_dir): 225 + fresh_client.post( 226 + "/init/password", 227 + json={"password": "securepass123"}, 228 + content_type="application/json", 229 + ) 230 + resp = fresh_client.post( 231 + "/init/finalize", 232 + json={"coding_agent": "claude-code"}, 233 + content_type="application/json", 234 + ) 235 + assert resp.status_code == 200 236 + data = resp.get_json() 237 + assert data["success"] is True 238 + assert data["redirect"] == "/" 239 + config = _read_config(journal_dir) 240 + assert config["setup"]["coding_agent"] == "claude-code" 241 + assert "completed_at" in config["setup"] 242 + 243 + def test_finalize_auto_login(self, fresh_client, journal_dir): 244 + fresh_client.post( 245 + "/init/password", 246 + json={"password": "securepass123"}, 247 + content_type="application/json", 248 + ) 249 + fresh_client.post( 250 + "/init/finalize", 251 + json={"coding_agent": "claude-code"}, 252 + content_type="application/json", 253 + ) 254 + resp = fresh_client.get("/", headers={"X-Forwarded-For": "1.2.3.4"}) 255 + assert resp.status_code == 302 256 + location = resp.headers["Location"] 257 + assert "/login" not in location 258 + assert "/init" not in location 259 + 260 + def test_post_init_redirect(self, fresh_client, journal_dir): 261 + fresh_client.post( 262 + "/init/password", 263 + json={"password": "securepass123"}, 264 + content_type="application/json", 265 + ) 266 + fresh_client.post( 267 + "/init/finalize", 268 + json={"coding_agent": "none"}, 269 + content_type="application/json", 270 + ) 271 + resp = fresh_client.get("/init") 272 + assert resp.status_code == 302