personal memory agent
0
fork

Configure Feed

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

apps/settings: convey network-access CLI + settings UI block

Add the convey settings CLI surface plus the restart-aware security controls in the settings app. This wires the new network-access, trust-localhost, and host-url flows through one config surface while keeping the password refusal rules and effective host URL responses consistent across CLI and UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+727 -46
+177
apps/settings/call.py
··· 11 11 import subprocess 12 12 from datetime import datetime, timezone 13 13 from pathlib import Path 14 + from urllib.parse import urlparse 14 15 15 16 import typer 16 17 18 + from apps.settings.copy import ( 19 + CONVEY_HOST_URL_CLEARED, 20 + CONVEY_HOST_URL_FLAG_CONFLICT, 21 + CONVEY_HOST_URL_INVALID, 22 + CONVEY_HOST_URL_SET_DONE, 23 + CONVEY_NETWORK_DISABLE_DONE, 24 + CONVEY_NETWORK_DISABLE_PROGRESS, 25 + CONVEY_NETWORK_ENABLE_DONE, 26 + CONVEY_NETWORK_ENABLE_PROGRESS, 27 + CONVEY_REFUSE_NO_PASSWORD_NETWORK, 28 + CONVEY_REFUSE_NO_PASSWORD_TRUST, 29 + CONVEY_RESTART_TIMEOUT, 30 + CONVEY_TRUST_DISABLE_DONE, 31 + CONVEY_TRUST_ENABLE_DONE, 32 + format_convey_status, 33 + ) 34 + from think.pairing.config import get_host_url 35 + from think.service import DEFAULT_SERVICE_PORT 17 36 from think.utils import require_solstone 18 37 19 38 app = typer.Typer( ··· 40 59 app.add_typer(identity_app, name="identity") 41 60 observer_app = typer.Typer(help="Observer capture settings.") 42 61 app.add_typer(observer_app, name="observer") 62 + convey_app = typer.Typer(help="Convey access configuration.") 63 + app.add_typer(convey_app, name="convey") 64 + network_access_app = typer.Typer(help="Convey network exposure.") 65 + convey_app.add_typer(network_access_app, name="network-access") 66 + trust_localhost_app = typer.Typer(help="Localhost password-bypass behavior.") 67 + convey_app.add_typer(trust_localhost_app, name="trust-localhost") 43 68 44 69 45 70 def _get_config(): ··· 61 86 os.chmod(config_path, 0o600) 62 87 63 88 89 + def _convey_password_is_set(config: dict) -> bool: 90 + from apps.settings.routes import _convey_password_is_set as _route_password_is_set 91 + 92 + return _route_password_is_set(config) 93 + 94 + 95 + def _convey_port() -> int: 96 + from think.utils import read_service_port 97 + 98 + return read_service_port("convey") or DEFAULT_SERVICE_PORT 99 + 100 + 101 + def _network_access_enabled(config: dict) -> bool: 102 + return bool(config.get("convey", {}).get("allow_network_access", False)) 103 + 104 + 105 + def _trust_localhost_enabled(config: dict) -> bool: 106 + return bool(config.get("convey", {}).get("trust_localhost", True)) 107 + 108 + 109 + def _host_url_status_value(config: dict) -> str: 110 + pairing_host_url = config.get("pairing", {}).get("host_url") 111 + if isinstance(pairing_host_url, str) and pairing_host_url.strip(): 112 + return f"{get_host_url()} (manual override)" 113 + if _network_access_enabled(config): 114 + return f"{get_host_url()} (auto-detected)" 115 + return f"{get_host_url()} (localhost — network access off)" 116 + 117 + 118 + def _validate_host_url_or_exit(url: str) -> str: 119 + cleaned = url.strip() 120 + parsed = urlparse(cleaned) 121 + if not cleaned or not parsed.scheme or not parsed.netloc: 122 + typer.echo(CONVEY_HOST_URL_INVALID, err=True) 123 + raise typer.Exit(1) 124 + return cleaned 125 + 126 + 127 + def _restart_convey_or_exit() -> None: 128 + from convey.restart import wait_for_convey_restart 129 + 130 + restart_ok, _ = wait_for_convey_restart(timeout=15.0) 131 + if restart_ok: 132 + return 133 + typer.echo(CONVEY_RESTART_TIMEOUT, err=True) 134 + raise typer.Exit(1) 135 + 136 + 64 137 def _provider_for_env_var(env_var: str) -> str | None: 65 138 """Return the provider mapped to an API env var, if any.""" 66 139 from think.providers import PROVIDER_METADATA ··· 71 144 if "env_key" in meta 72 145 } 73 146 return env_to_provider.get(env_var) 147 + 148 + 149 + @network_access_app.command("enable") 150 + def convey_network_access_enable() -> None: 151 + """Enable non-loopback access to Convey and restart it.""" 152 + 153 + config = _get_config() 154 + if not _convey_password_is_set(config): 155 + typer.echo(CONVEY_REFUSE_NO_PASSWORD_NETWORK, err=True) 156 + raise typer.Exit(1) 157 + config.setdefault("convey", {})["allow_network_access"] = True 158 + _write_config(config) 159 + typer.echo(CONVEY_NETWORK_ENABLE_PROGRESS) 160 + _restart_convey_or_exit() 161 + typer.echo(CONVEY_NETWORK_ENABLE_DONE.format(host_url=get_host_url())) 162 + 163 + 164 + @network_access_app.command("disable") 165 + def convey_network_access_disable() -> None: 166 + """Restrict Convey to localhost and restart it.""" 167 + 168 + config = _get_config() 169 + config.setdefault("convey", {})["allow_network_access"] = False 170 + _write_config(config) 171 + typer.echo(CONVEY_NETWORK_DISABLE_PROGRESS) 172 + _restart_convey_or_exit() 173 + typer.echo(CONVEY_NETWORK_DISABLE_DONE.format(port=_convey_port())) 174 + 175 + 176 + @trust_localhost_app.command("enable") 177 + def convey_trust_localhost_enable() -> None: 178 + """Enable localhost password bypass.""" 179 + 180 + config = _get_config() 181 + config.setdefault("convey", {})["trust_localhost"] = True 182 + _write_config(config) 183 + typer.echo(CONVEY_TRUST_ENABLE_DONE) 184 + 185 + 186 + @trust_localhost_app.command("disable") 187 + def convey_trust_localhost_disable() -> None: 188 + """Disable localhost password bypass.""" 189 + 190 + config = _get_config() 191 + if not _convey_password_is_set(config): 192 + typer.echo(CONVEY_REFUSE_NO_PASSWORD_TRUST, err=True) 193 + raise typer.Exit(1) 194 + config.setdefault("convey", {})["trust_localhost"] = False 195 + _write_config(config) 196 + typer.echo(CONVEY_TRUST_DISABLE_DONE) 197 + 198 + 199 + @convey_app.command("host-url") 200 + def convey_host_url( 201 + url: str | None = typer.Argument( 202 + None, help="Absolute URL to advertise to devices." 203 + ), 204 + auto: bool = typer.Option( 205 + False, "--auto", help="Clear the manual host URL override." 206 + ), 207 + show: bool = typer.Option(False, "--show", help="Show the effective host URL."), 208 + ) -> None: 209 + """Manage the host URL advertised to remote devices.""" 210 + 211 + if sum(bool(flag) for flag in (url is not None, auto, show)) != 1: 212 + typer.echo(CONVEY_HOST_URL_FLAG_CONFLICT, err=True) 213 + raise typer.Exit(1) 214 + if show: 215 + typer.echo(get_host_url()) 216 + return 217 + config = _get_config() 218 + config.setdefault("pairing", {}) 219 + if auto: 220 + config["pairing"]["host_url"] = None 221 + _write_config(config) 222 + typer.echo(CONVEY_HOST_URL_CLEARED) 223 + return 224 + assert url is not None 225 + cleaned = _validate_host_url_or_exit(url) 226 + config["pairing"]["host_url"] = cleaned 227 + _write_config(config) 228 + typer.echo(CONVEY_HOST_URL_SET_DONE.format(url=cleaned)) 229 + 230 + 231 + @convey_app.command("status") 232 + def convey_status() -> None: 233 + """Show Convey network and host-URL status.""" 234 + 235 + from convey.cli import _resolve_bind_host 236 + 237 + config = _get_config() 238 + network_access = "on" if _network_access_enabled(config) else "localhost only" 239 + bind_host = _resolve_bind_host() 240 + password = "set" if _convey_password_is_set(config) else "not set" 241 + trust_localhost = "yes" if _trust_localhost_enabled(config) else "no" 242 + typer.echo( 243 + format_convey_status( 244 + bind=f"{bind_host}:{_convey_port()}", 245 + host_url=_host_url_status_value(config), 246 + network_access=network_access, 247 + password=password, 248 + trust_localhost=trust_localhost, 249 + ) 250 + ) 74 251 75 252 76 253 def _validate_env_var_or_exit(env_var: str) -> None:
+82 -38
apps/settings/routes.py
··· 16 16 17 17 from flask import Blueprint, jsonify, request 18 18 19 + from apps.settings import copy as settings_copy 20 + from apps.settings.copy import ( 21 + CONVEY_REFUSE_NO_PASSWORD_NETWORK, 22 + CONVEY_REFUSE_NO_PASSWORD_TRUST, 23 + ) 19 24 from apps.utils import log_app_action 25 + from convey import copy as convey_copy 20 26 from convey import state 27 + from think.pairing.config import get_host_url 21 28 from think.providers.google import validate_vertex_credentials 22 29 from think.retention import ( 23 30 _human_bytes, ··· 69 76 return "unsupported" 70 77 71 78 79 + def _convey_password_is_set(config: dict[str, Any]) -> bool: 80 + password_hash = config.get("convey", {}).get("password_hash", "") 81 + return bool(str(password_hash or "").strip()) 82 + 83 + 84 + def _project_public_config(config: dict[str, Any]) -> dict[str, Any]: 85 + projected = copy.deepcopy(config) 86 + if "env" in projected: 87 + projected["env"] = {k: bool(v) for k, v in projected["env"].items()} 88 + convey_config = projected.setdefault("convey", {}) 89 + convey_config.pop("secret", None) 90 + has_pw = bool(convey_config.pop("password_hash", None)) 91 + convey_config.pop("password", None) 92 + convey_config["has_password"] = has_pw 93 + pairing_config = projected.setdefault("pairing", {}) 94 + pairing_config["effective_host_url"] = get_host_url() 95 + projected["runtime_env"] = {k: bool(os.getenv(k)) for k in API_KEY_ENV_VARS} 96 + return projected 97 + 98 + 99 + @settings_bp.app_context_processor 100 + def _inject_settings_copy() -> dict[str, Any]: 101 + return { 102 + "convey_copy": convey_copy, 103 + "settings_copy": settings_copy, 104 + } 105 + 106 + 72 107 @settings_bp.route("/api/config") 73 108 def get_config() -> Any: 74 109 """Return the journal configuration. ··· 80 115 the process environment (from journal.json via setup_cli). 81 116 """ 82 117 try: 83 - config = get_journal_config() 84 - # Mask env values - return True/False for whether key is set in journal config 85 - if "env" in config: 86 - config["env"] = {k: bool(v) for k, v in config["env"].items()} 87 - 88 - # Strip convey secret from API response — never expose signing keys 89 - if "convey" in config: 90 - config["convey"].pop("secret", None) 91 - has_pw = bool(config["convey"].pop("password_hash", None)) 92 - config["convey"].pop("password", None) 93 - config["convey"]["has_password"] = has_pw 94 - 95 - # Add runtime_env - keys available in the running process 96 - config["runtime_env"] = {k: bool(os.getenv(k)) for k in API_KEY_ENV_VARS} 97 - 98 - return jsonify(config) 118 + return jsonify(_project_public_config(get_journal_config())) 99 119 except Exception: 100 120 logger.exception("error loading config") 101 121 return jsonify( ··· 109 129 def update_config() -> Any: 110 130 """Update the journal configuration. 111 131 112 - Accepts JSON with a 'section' key indicating which config section to update, 113 - and a 'data' key containing the fields to update. Supported sections: 114 - - identity: User profile (name, preferred, bio, pronouns, aliases, etc.) 115 - - transcribe: Transcription settings (device, model, compute_type) 116 - - convey: Web app settings (password) 117 - - env: API keys (GOOGLE_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY, REVAI_ACCESS_TOKEN, PLAUD_ACCESS_TOKEN) 118 - 119 - Note: Model/provider configuration is done via the 'providers' section in 120 - journal.json. See talent/journal/references/config.md for the providers config format. 132 + Accepts JSON with a 'section' key and per-section config fields to update. 133 + Supported writes include identity and transcribe settings, convey security 134 + settings (password, allow_network_access, trust_localhost), pairing.host_url, 135 + and API-key env vars. 121 136 """ 122 137 try: 123 138 request_data = request.get_json() ··· 126 141 127 142 section = request_data.get("section") 128 143 data = request_data.get("data", {}) 144 + request_key = request_data.get("key") 145 + if section and request_key is not None and "value" in request_data and not data: 146 + data = {request_key: request_data.get("value")} 129 147 130 148 # Backward compatibility: if no section specified but identity key exists 131 149 if not section and "identity" in request_data: ··· 148 166 "timezone", 149 167 ], 150 168 "transcribe": ["backend", "enrich", "preserve_all", "noise_upgrade"], 151 - "convey": ["password"], 169 + "convey": ["allow_network_access", "password", "trust_localhost"], 170 + "pairing": ["host_url"], 152 171 "support": ["enabled", "proactive", "anonymous_feedback", "portal_url"], 153 172 "agent": ["name", "name_status", "named_date", "proposal_count"], 154 173 "env": API_KEY_ENV_VARS, ··· 173 192 # Load existing config 174 193 old_config = get_journal_config() 175 194 config = get_journal_config() 195 + has_password = _convey_password_is_set(config) 176 196 177 197 # Ensure section exists 178 198 if section not in config: ··· 182 202 changed_fields = {} 183 203 old_section = old_config.get(section, {}) 184 204 205 + requested_network_access = None 206 + if section == "convey" and "allow_network_access" in data: 207 + requested_network_access = bool(data["allow_network_access"]) 208 + if requested_network_access and not has_password: 209 + return jsonify({"error": CONVEY_REFUSE_NO_PASSWORD_NETWORK}), 400 210 + if ( 211 + section == "convey" 212 + and "trust_localhost" in data 213 + and not bool(data["trust_localhost"]) 214 + and not has_password 215 + ): 216 + return jsonify({"error": CONVEY_REFUSE_NO_PASSWORD_TRUST}), 400 217 + 185 218 # Update only allowed fields 186 219 for key in allowed_sections[section]: 187 220 if key in data: 188 221 new_value = data[key] 222 + if section == "pairing" and key == "host_url": 223 + new_value = ( 224 + new_value.strip() if isinstance(new_value, str) else new_value 225 + ) 226 + if new_value == "": 227 + new_value = None 189 228 old_value = old_section.get(key) 190 229 if old_value != new_value: 191 230 changed_fields[key] = {"old": old_value, "new": new_value} ··· 284 323 f.write("\n") 285 324 os.chmod(config_path, 0o600) 286 325 326 + if section == "convey" and requested_network_access is not None: 327 + from convey.restart import wait_for_convey_restart 328 + 329 + restart_ok, _ = wait_for_convey_restart(timeout=15.0) 330 + return jsonify( 331 + { 332 + "effective_host_url": get_host_url(), 333 + "ok": True, 334 + "restart_timeout": not restart_ok, 335 + } 336 + ) 337 + 287 338 # Log if something changed (don't log sensitive values) 288 339 if changed_fields: 289 340 log_fields = changed_fields ··· 310 361 capture_output=True, 311 362 ) 312 363 313 - # Mask env values in response 314 - if "env" in config: 315 - config["env"] = {k: bool(v) for k, v in config["env"].items()} 316 - 317 - # Strip sensitive convey fields from response 318 - if "convey" in config: 319 - config["convey"].pop("secret", None) 320 - has_pw = bool(config["convey"].pop("password_hash", None)) 321 - config["convey"].pop("password", None) 322 - config["convey"]["has_password"] = has_pw 323 - 324 364 key_validation = config.get("providers", {}).get("key_validation", {}) 325 365 return jsonify( 326 - {"success": True, "config": config, "key_validation": key_validation} 366 + { 367 + "config": _project_public_config(config), 368 + "key_validation": key_validation, 369 + "success": True, 370 + } 327 371 ) 328 372 except Exception: 329 373 logger.exception("error updating config")
+124 -8
apps/settings/workspace.html
··· 2576 2576 <!-- Security Section --> 2577 2577 <section class="settings-section" id="section-security" role="tabpanel" aria-labelledby="tab-security"> 2578 2578 <h2>security</h2> 2579 - <p class="settings-section-desc">Authentication and access control.</p> 2579 + <p class="settings-section-desc">{{ convey_copy.SETTINGS_SECURITY_DESC }}</p> 2580 2580 2581 2581 <form class="settings-form" onsubmit="return false;"> 2582 2582 2583 2583 <div class="settings-field"> 2584 + <label>{{ convey_copy.SETTINGS_NETWORK_MODE_LABEL }}</label> 2585 + <div style="display:flex; align-items:flex-start; justify-content:space-between; gap:1em; flex-wrap:wrap;"> 2586 + <div style="flex:1; min-width:16em;"> 2587 + <div class="field-display" id="conveyNetworkMode">{{ convey_copy.SETTINGS_NETWORK_MODE_OFF }}</div> 2588 + <p id="conveyNetworkDesc" style="margin:0.35em 0 0 0; color:#666; font-size:0.95em;">{{ convey_copy.SETTINGS_NETWORK_DESC_OFF }}</p> 2589 + </div> 2590 + <button type="button" class="setup-btn" id="conveyNetworkButton">{{ convey_copy.SETTINGS_NETWORK_BUTTON_ENABLE }}</button> 2591 + </div> 2592 + <small id="conveyNetworkStatus"></small> 2593 + </div> 2594 + 2595 + <div class="settings-field"> 2596 + <label for="field-host-url">{{ convey_copy.SETTINGS_LAN_URL_LABEL }}</label> 2597 + <div class="field-display" id="conveyLanUrlDisplay">http://localhost:5015</div> 2598 + <input type="text" id="field-host-url" data-section="pairing" data-key="host_url" placeholder=""> 2599 + <small></small> 2600 + </div> 2601 + 2602 + <div class="settings-field"> 2603 + <div class="toggle-container" style="justify-content:flex-start; gap:1em;"> 2604 + <label class="toggle-switch toggle-positive"> 2605 + <input type="checkbox" id="field-trust-localhost" data-section="convey" data-key="trust_localhost"> 2606 + <span class="slider"></span> 2607 + </label> 2608 + <span>trust localhost</span> 2609 + </div> 2610 + <small></small> 2611 + </div> 2612 + 2613 + <div class="settings-field"> 2584 2614 <label for="field-password">web password</label> 2585 2615 <div class="password-wrap"> 2586 2616 <input type="password" id="field-password" data-section="convey" data-key="password" placeholder="Enter password to protect web access"> ··· 2588 2618 <span id="passwordToggleIcon">&#128065;</span> 2589 2619 </button> 2590 2620 </div> 2591 - <small>Set a password to protect the web interface. Leave empty to allow unauthenticated localhost access.</small> 2621 + <small>{{ convey_copy.SETTINGS_PASSWORD_HINT }}</small> 2592 2622 </div> 2593 2623 </form> 2594 2624 </section> ··· 3010 3040 let insightsData = null; 3011 3041 let keyValidationData = {}; 3012 3042 const escapeHtml = window.AppServices.escapeHtml; 3043 + const conveyUiText = { 3044 + networkModeOff: {{ convey_copy.SETTINGS_NETWORK_MODE_OFF | tojson }}, 3045 + networkModeOn: {{ convey_copy.SETTINGS_NETWORK_MODE_ON | tojson }}, 3046 + networkDescOff: {{ convey_copy.SETTINGS_NETWORK_DESC_OFF | tojson }}, 3047 + networkDescOn: {{ convey_copy.SETTINGS_NETWORK_DESC_ON | tojson }}, 3048 + networkButtonEnable: {{ convey_copy.SETTINGS_NETWORK_BUTTON_ENABLE | tojson }}, 3049 + networkButtonDisable: {{ convey_copy.SETTINGS_NETWORK_BUTTON_DISABLE | tojson }}, 3050 + networkNeedsPassword: {{ convey_copy.SETTINGS_NETWORK_NEEDS_PASSWORD | tojson }}, 3051 + networkRestarting: {{ convey_copy.SETTINGS_NETWORK_RESTARTING | tojson }}, 3052 + passwordHint: {{ convey_copy.SETTINGS_PASSWORD_HINT | tojson }}, 3053 + restartTimeout: {{ settings_copy.CONVEY_RESTART_TIMEOUT | tojson }}, 3054 + }; 3013 3055 3014 3056 // ========== MODAL ACCESSIBILITY ========== 3015 3057 function setupModalAccessibility(modalId) { ··· 3363 3405 document.getElementById('field-transcribe-preserve').checked = transcribe.preserve_all || false; 3364 3406 document.getElementById('field-transcribe-noise-upgrade').checked = transcribe.noise_upgrade !== false; 3365 3407 3366 - // Convey (password) 3408 + // Convey / pairing 3367 3409 const convey = config.convey || {}; 3368 - if (convey.has_password) { 3369 - document.getElementById('field-password').placeholder = 'Password set (enter to change)'; 3370 - } else { 3371 - document.getElementById('field-password').placeholder = 'Enter password to protect web access'; 3372 - } 3410 + const pairing = config.pairing || {}; 3411 + document.getElementById('field-trust-localhost').checked = convey.trust_localhost !== false; 3412 + setValue('field-host-url', pairing.host_url || ''); 3413 + renderConveyNetworkState(config); 3373 3414 3374 3415 // Support settings 3375 3416 const support = config.support || {}; ··· 3476 3517 function applySavedConfigResult(result, runtimeEnv) { 3477 3518 configData = result.config; 3478 3519 configData.runtime_env = runtimeEnv; 3520 + renderConveyNetworkState(configData); 3521 + } 3522 + 3523 + function setConveyNetworkStatus(message, isError = false) { 3524 + const el = document.getElementById('conveyNetworkStatus'); 3525 + if (!el) return; 3526 + el.textContent = message || ''; 3527 + el.classList.toggle('status-error', Boolean(isError && message)); 3528 + el.classList.toggle('status-saved', false); 3529 + } 3530 + 3531 + function renderConveyNetworkState(config) { 3532 + const convey = config?.convey || {}; 3533 + const pairing = config?.pairing || {}; 3534 + const networkEnabled = convey.allow_network_access === true; 3535 + const button = document.getElementById('conveyNetworkButton'); 3536 + const mode = document.getElementById('conveyNetworkMode'); 3537 + const desc = document.getElementById('conveyNetworkDesc'); 3538 + const lanUrl = document.getElementById('conveyLanUrlDisplay'); 3539 + const passwordInput = document.getElementById('field-password'); 3540 + 3541 + if (mode) mode.textContent = networkEnabled ? conveyUiText.networkModeOn : conveyUiText.networkModeOff; 3542 + if (desc) desc.textContent = networkEnabled ? conveyUiText.networkDescOn : conveyUiText.networkDescOff; 3543 + if (button) button.textContent = networkEnabled ? conveyUiText.networkButtonDisable : conveyUiText.networkButtonEnable; 3544 + if (lanUrl) lanUrl.textContent = pairing.effective_host_url || 'http://localhost:5015'; 3545 + if (passwordInput) { 3546 + passwordInput.placeholder = convey.has_password 3547 + ? 'Password set (enter to change)' 3548 + : 'Enter password to protect web access'; 3549 + } 3550 + } 3551 + 3552 + async function toggleConveyNetworkAccess() { 3553 + const button = document.getElementById('conveyNetworkButton'); 3554 + if (!button || !configData) return; 3555 + 3556 + const convey = configData.convey || {}; 3557 + const nextValue = convey.allow_network_access !== true; 3558 + if (nextValue && !convey.has_password) { 3559 + setConveyNetworkStatus(conveyUiText.networkNeedsPassword, true); 3560 + return; 3561 + } 3562 + 3563 + button.disabled = true; 3564 + setConveyNetworkStatus(conveyUiText.networkRestarting, false); 3565 + try { 3566 + const result = await window.apiJson('api/config', { 3567 + method: 'PUT', 3568 + headers: { 'Content-Type': 'application/json' }, 3569 + body: JSON.stringify({ 3570 + key: 'allow_network_access', 3571 + section: 'convey', 3572 + value: nextValue, 3573 + }), 3574 + }); 3575 + if (!result.ok) { 3576 + throw new Error('Save failed'); 3577 + } 3578 + configData.convey = configData.convey || {}; 3579 + configData.convey.allow_network_access = nextValue; 3580 + configData.pairing = configData.pairing || {}; 3581 + configData.pairing.effective_host_url = result.effective_host_url; 3582 + renderConveyNetworkState(configData); 3583 + setConveyNetworkStatus(result.restart_timeout ? conveyUiText.restartTimeout : '', result.restart_timeout); 3584 + } catch (err) { 3585 + setConveyNetworkStatus(err?.serverMessage || err?.message || 'Save failed', true); 3586 + window.logError(err, { context: 'settings: toggleConveyNetworkAccess failed' }); 3587 + } finally { 3588 + button.disabled = false; 3589 + } 3479 3590 } 3480 3591 3481 3592 async function saveConfigValue(section, key, value) { ··· 3541 3652 } else { 3542 3653 value = { subject: '', object: '', possessive: '', reflexive: '' }; 3543 3654 } 3655 + } else if (section === 'pairing' && key === 'host_url') { 3656 + value = value.trim(); 3657 + if (!value) value = null; 3544 3658 } 3545 3659 3546 3660 // Skip save if value unchanged (except env fields which are masked) ··· 4062 4176 } 4063 4177 }; 4064 4178 }); 4179 + 4180 + document.getElementById('conveyNetworkButton')?.addEventListener('click', toggleConveyNetworkAccess); 4065 4181 4066 4182 // ========== NOTIFICATIONS ========== 4067 4183 function notifyError(title, message) {
+4
tests/baselines/api/settings/config.json
··· 6 6 "proposal_count": 0 7 7 }, 8 8 "convey": { 9 + "allow_network_access": false, 9 10 "has_password": true, 10 11 "trust_localhost": true 11 12 }, ··· 35 36 "subject": "they" 36 37 }, 37 38 "timezone": "America/Denver" 39 + }, 40 + "pairing": { 41 + "effective_host_url": "http://localhost:5015" 38 42 }, 39 43 "providers": { 40 44 "cogitate": {
+340
tests/test_settings_convey.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import json 7 + from pathlib import Path 8 + from unittest.mock import patch 9 + 10 + from typer.testing import CliRunner 11 + 12 + from apps.settings.copy import ( 13 + CONVEY_HOST_URL_CLEARED, 14 + CONVEY_HOST_URL_SET_DONE, 15 + CONVEY_NETWORK_DISABLE_DONE, 16 + CONVEY_NETWORK_DISABLE_PROGRESS, 17 + CONVEY_NETWORK_ENABLE_DONE, 18 + CONVEY_NETWORK_ENABLE_PROGRESS, 19 + CONVEY_REFUSE_NO_PASSWORD_NETWORK, 20 + CONVEY_REFUSE_NO_PASSWORD_TRUST, 21 + CONVEY_RESTART_TIMEOUT, 22 + CONVEY_TRUST_DISABLE_DONE, 23 + format_convey_status, 24 + ) 25 + from convey import create_app 26 + from think.call import call_app 27 + 28 + runner = CliRunner() 29 + 30 + 31 + def _read_config(journal_dir: Path) -> dict: 32 + return json.loads((journal_dir / "config" / "journal.json").read_text("utf-8")) 33 + 34 + 35 + def _write_config(journal_dir: Path, payload: dict) -> None: 36 + (journal_dir / "config" / "journal.json").write_text( 37 + json.dumps(payload, indent=2) + "\n", 38 + encoding="utf-8", 39 + ) 40 + 41 + 42 + def _clear_password(journal_dir: Path) -> None: 43 + config = _read_config(journal_dir) 44 + config["convey"].pop("password_hash", None) 45 + config["convey"].pop("password", None) 46 + _write_config(journal_dir, config) 47 + 48 + 49 + def _settings_client(journal_dir: Path): 50 + app = create_app(str(journal_dir)) 51 + app.config["TESTING"] = True 52 + return app.test_client() 53 + 54 + 55 + def test_cli_status_exact_output(journal_copy): 56 + result = runner.invoke(call_app, ["settings", "convey", "status"]) 57 + 58 + expected = format_convey_status( 59 + network_access="localhost only", 60 + bind="127.0.0.1:5015", 61 + password="set", 62 + trust_localhost="yes", 63 + host_url="http://localhost:5015 (localhost — network access off)", 64 + ) 65 + assert result.exit_code == 0 66 + assert result.output == expected + "\n" 67 + 68 + 69 + def test_cli_status_reports_manual_host_override(journal_copy): 70 + config = _read_config(journal_copy) 71 + config["pairing"] = {"host_url": "https://manual.example/base"} 72 + _write_config(journal_copy, config) 73 + 74 + result = runner.invoke(call_app, ["settings", "convey", "status"]) 75 + 76 + assert result.exit_code == 0 77 + assert ( 78 + "host url: https://manual.example/base (manual override)" 79 + in result.output 80 + ) 81 + 82 + 83 + def test_cli_status_reports_auto_detected_host(journal_copy): 84 + config = _read_config(journal_copy) 85 + config["convey"]["allow_network_access"] = True 86 + _write_config(journal_copy, config) 87 + health_dir = journal_copy / "health" 88 + health_dir.mkdir(parents=True, exist_ok=True) 89 + (health_dir / "convey.port").write_text("6123", encoding="utf-8") 90 + 91 + with patch( 92 + "apps.settings.call.get_host_url", return_value="http://192.168.1.44:6123" 93 + ): 94 + result = runner.invoke(call_app, ["settings", "convey", "status"]) 95 + 96 + assert result.exit_code == 0 97 + assert ( 98 + result.output 99 + == format_convey_status( 100 + network_access="on", 101 + bind="0.0.0.0:6123", 102 + password="set", 103 + trust_localhost="yes", 104 + host_url="http://192.168.1.44:6123 (auto-detected)", 105 + ) 106 + + "\n" 107 + ) 108 + 109 + 110 + def test_cli_network_access_enable_refuses_without_password(journal_copy): 111 + _clear_password(journal_copy) 112 + 113 + result = runner.invoke(call_app, ["settings", "convey", "network-access", "enable"]) 114 + 115 + assert result.exit_code == 1 116 + assert result.stderr.strip() == CONVEY_REFUSE_NO_PASSWORD_NETWORK 117 + assert _read_config(journal_copy)["convey"]["allow_network_access"] is False 118 + 119 + 120 + def test_cli_network_access_enable_restarts_and_prints_host_url(journal_copy): 121 + with ( 122 + patch( 123 + "convey.restart.wait_for_convey_restart", return_value=(True, []) 124 + ) as restart, 125 + patch( 126 + "apps.settings.call.get_host_url", 127 + return_value="http://192.168.1.44:5015", 128 + ), 129 + ): 130 + result = runner.invoke( 131 + call_app, ["settings", "convey", "network-access", "enable"] 132 + ) 133 + 134 + assert result.exit_code == 0 135 + assert result.stdout == ( 136 + CONVEY_NETWORK_ENABLE_PROGRESS 137 + + "\n" 138 + + CONVEY_NETWORK_ENABLE_DONE.format(host_url="http://192.168.1.44:5015") 139 + + "\n" 140 + ) 141 + restart.assert_called_once_with(timeout=15.0) 142 + assert _read_config(journal_copy)["convey"]["allow_network_access"] is True 143 + 144 + 145 + def test_cli_network_access_disable_timeout_exits_nonzero(journal_copy): 146 + config = _read_config(journal_copy) 147 + config["convey"]["allow_network_access"] = True 148 + _write_config(journal_copy, config) 149 + 150 + with patch("convey.restart.wait_for_convey_restart", return_value=(False, [])): 151 + result = runner.invoke( 152 + call_app, ["settings", "convey", "network-access", "disable"] 153 + ) 154 + 155 + assert result.exit_code == 1 156 + assert result.stdout == CONVEY_NETWORK_DISABLE_PROGRESS + "\n" 157 + assert result.stderr.strip() == CONVEY_RESTART_TIMEOUT 158 + assert _read_config(journal_copy)["convey"]["allow_network_access"] is False 159 + 160 + 161 + def test_cli_network_access_disable_success_uses_localhost_copy(journal_copy): 162 + config = _read_config(journal_copy) 163 + config["convey"]["allow_network_access"] = True 164 + _write_config(journal_copy, config) 165 + 166 + with patch("convey.restart.wait_for_convey_restart", return_value=(True, [])): 167 + result = runner.invoke( 168 + call_app, ["settings", "convey", "network-access", "disable"] 169 + ) 170 + 171 + assert result.exit_code == 0 172 + assert result.stdout == ( 173 + CONVEY_NETWORK_DISABLE_PROGRESS 174 + + "\n" 175 + + CONVEY_NETWORK_DISABLE_DONE.format(port=5015) 176 + + "\n" 177 + ) 178 + 179 + 180 + def test_cli_trust_localhost_disable_refuses_without_password(journal_copy): 181 + _clear_password(journal_copy) 182 + 183 + result = runner.invoke( 184 + call_app, ["settings", "convey", "trust-localhost", "disable"] 185 + ) 186 + 187 + assert result.exit_code == 1 188 + assert result.stderr.strip() == CONVEY_REFUSE_NO_PASSWORD_TRUST 189 + 190 + 191 + def test_cli_trust_localhost_disable_does_not_restart(journal_copy): 192 + with patch("convey.restart.wait_for_convey_restart") as restart: 193 + result = runner.invoke( 194 + call_app, ["settings", "convey", "trust-localhost", "disable"] 195 + ) 196 + 197 + assert result.exit_code == 0 198 + assert result.stdout == CONVEY_TRUST_DISABLE_DONE + "\n" 199 + restart.assert_not_called() 200 + assert _read_config(journal_copy)["convey"]["trust_localhost"] is False 201 + 202 + 203 + def test_cli_host_url_set_auto_and_show(journal_copy): 204 + set_result = runner.invoke( 205 + call_app, 206 + ["settings", "convey", "host-url", "https://manual.example/base"], 207 + ) 208 + assert set_result.exit_code == 0 209 + assert set_result.stdout == ( 210 + CONVEY_HOST_URL_SET_DONE.format(url="https://manual.example/base") + "\n" 211 + ) 212 + 213 + show_result = runner.invoke(call_app, ["settings", "convey", "host-url", "--show"]) 214 + assert show_result.exit_code == 0 215 + assert show_result.stdout == "https://manual.example/base\n" 216 + 217 + auto_result = runner.invoke(call_app, ["settings", "convey", "host-url", "--auto"]) 218 + assert auto_result.exit_code == 0 219 + assert auto_result.stdout == CONVEY_HOST_URL_CLEARED + "\n" 220 + assert _read_config(journal_copy)["pairing"]["host_url"] is None 221 + 222 + 223 + def test_cli_host_url_rejects_relative_url(journal_copy): 224 + result = runner.invoke(call_app, ["settings", "convey", "host-url", "/bad"]) 225 + 226 + assert result.exit_code == 1 227 + assert result.stderr.strip() == "error: host url must be an absolute URL" 228 + 229 + 230 + def test_api_get_config_masks_password_and_includes_effective_host_url(journal_copy): 231 + client = _settings_client(journal_copy) 232 + 233 + response = client.get("/app/settings/api/config") 234 + 235 + assert response.status_code == 200 236 + payload = response.get_json() 237 + assert payload["convey"]["allow_network_access"] is False 238 + assert payload["convey"]["has_password"] is True 239 + assert "password_hash" not in payload["convey"] 240 + assert payload["pairing"]["effective_host_url"] == "http://localhost:5015" 241 + 242 + 243 + def test_api_put_network_access_refuses_without_password(journal_copy): 244 + _clear_password(journal_copy) 245 + client = _settings_client(journal_copy) 246 + 247 + response = client.put( 248 + "/app/settings/api/config", 249 + json={"section": "convey", "key": "allow_network_access", "value": True}, 250 + content_type="application/json", 251 + ) 252 + 253 + assert response.status_code == 400 254 + assert response.get_json() == {"error": CONVEY_REFUSE_NO_PASSWORD_NETWORK} 255 + 256 + 257 + def test_api_put_trust_localhost_refuses_without_password(journal_copy): 258 + _clear_password(journal_copy) 259 + client = _settings_client(journal_copy) 260 + 261 + response = client.put( 262 + "/app/settings/api/config", 263 + json={"section": "convey", "data": {"trust_localhost": False}}, 264 + content_type="application/json", 265 + ) 266 + 267 + assert response.status_code == 400 268 + assert response.get_json() == {"error": CONVEY_REFUSE_NO_PASSWORD_TRUST} 269 + 270 + 271 + def test_api_put_network_access_returns_restart_payload(journal_copy): 272 + client = _settings_client(journal_copy) 273 + 274 + with ( 275 + patch( 276 + "convey.restart.wait_for_convey_restart", return_value=(True, []) 277 + ) as restart, 278 + patch( 279 + "apps.settings.routes.get_host_url", 280 + return_value="http://192.168.1.44:5015", 281 + ), 282 + ): 283 + response = client.put( 284 + "/app/settings/api/config", 285 + json={"section": "convey", "key": "allow_network_access", "value": True}, 286 + content_type="application/json", 287 + ) 288 + 289 + assert response.status_code == 200 290 + assert response.get_json() == { 291 + "effective_host_url": "http://192.168.1.44:5015", 292 + "ok": True, 293 + "restart_timeout": False, 294 + } 295 + restart.assert_called_once_with(timeout=15.0) 296 + assert _read_config(journal_copy)["convey"]["allow_network_access"] is True 297 + 298 + 299 + def test_api_put_network_access_timeout_still_saves(journal_copy): 300 + client = _settings_client(journal_copy) 301 + 302 + with ( 303 + patch("convey.restart.wait_for_convey_restart", return_value=(False, [])), 304 + patch( 305 + "apps.settings.routes.get_host_url", return_value="http://localhost:5015" 306 + ), 307 + ): 308 + response = client.put( 309 + "/app/settings/api/config", 310 + json={"section": "convey", "data": {"allow_network_access": True}}, 311 + content_type="application/json", 312 + ) 313 + 314 + assert response.status_code == 200 315 + assert response.get_json() == { 316 + "effective_host_url": "http://localhost:5015", 317 + "ok": True, 318 + "restart_timeout": True, 319 + } 320 + assert _read_config(journal_copy)["convey"]["allow_network_access"] is True 321 + 322 + 323 + def test_api_put_pairing_host_url_blank_clears_override(journal_copy): 324 + client = _settings_client(journal_copy) 325 + config = _read_config(journal_copy) 326 + config["pairing"] = {"host_url": "https://manual.example/base"} 327 + _write_config(journal_copy, config) 328 + 329 + response = client.put( 330 + "/app/settings/api/config", 331 + json={"section": "pairing", "data": {"host_url": " "}}, 332 + content_type="application/json", 333 + ) 334 + 335 + assert response.status_code == 200 336 + payload = response.get_json() 337 + assert payload["success"] is True 338 + assert payload["config"]["pairing"]["host_url"] is None 339 + assert payload["config"]["pairing"]["effective_host_url"] == "http://localhost:5015" 340 + assert _read_config(journal_copy)["pairing"]["host_url"] is None