diff --git a/CHANGELOG.md b/CHANGELOG.md index e42347fe..5e98daa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,18 @@ ## [Unreleased] +## v0.50.210 — 2026-04-25 + +### Added +- **gpt-5.5 and gpt-5.5-mini in model picker** — available for openai, openai-codex, and copilot providers. (`api/config.py`) [#1052 @aliceisjustplaying] +- **Login redirects back to original URL after re-login** — the iOS PWA auth redirect now passes `?next=` with the current path; `login.js` honors it via a `_safeNextPath()` helper that guards against open-redirect (rejects `//`, backslash, and non-path-absolute inputs). (`static/login.js`, `static/ui.js`, `static/workspace.js`) [#1053] + ### Fixed +- **Non-standard provider first-run experience** — agent dir discovery now searches XDG_DATA_HOME, `/opt`, `/usr/local` paths; onboarding wizard auto-completes for non-wizard providers (ollama-cloud, deepseek, xai, kimi-k2.6) with `provider_configured=True`; wizard model field no longer hardcodes `gpt-5.4-mini` literal; session model resolver correctly handles unlisted active providers. (`api/config.py`, `api/onboarding.py`, `api/routes.py`) Closes #1019–#1023 [#1049] +- **Cron session titles in sidebar** — cron-launched sessions now display the human-friendly job name (from `~/.hermes/cron/jobs.json`) instead of a generic "Cron Session" label. (`api/models.py`, `api/routes.py`) [#1050 @waldmanz] +- **AIAgent reused per session — fixes Honcho first-turn injection** — `AIAgent` is now cached per `session_id` so the agent's turn counter increments correctly across messages. Cache is evicted on session delete/clear. (`api/config.py`, `api/routes.py`, `api/streaming.py`) Closes #1039 [#1051 @qxxaa] +- **Mermaid Google Fonts CSP violation suppressed** — `fontFamily:'inherit'` in Mermaid themeVariables prevents `@import url('fonts.googleapis.com')` from being injected into diagram SVGs. (`static/ui.js`) Closes #1044 [#1054] +- **bfcache layout and dropdown restore** — `pageshow+event.persisted` handler re-syncs topbar, workspace panel, session list, and gateway SSE; also closes open composer dropdowns frozen by bfcache. `_initResizePanels()` removed from pageshow (bfcache preserves listeners). (`static/boot.js`) Closes #1045 [#1055] ## v0.50.209 — 2026-04-25 diff --git a/TESTING.md b/TESTING.md index 7eddf6e7..8b32e6e3 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,7 +8,7 @@ > Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser. > Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}. > -> Automated coverage: 2212 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation. +> Automated coverage: 2239 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation. > Run: `pytest tests/ -v --timeout=60` > > Local regression focus: verify that a previously closed workspace panel stays visually closed from first paint through boot completion on desktop refresh; there should be no brief open-then-close flash. diff --git a/api/config.py b/api/config.py index 141b915d..c56ff9e1 100644 --- a/api/config.py +++ b/api/config.py @@ -91,6 +91,14 @@ def _discover_agent_dir() -> Path: # 6. ~/hermes-agent candidates.append(HOME / "hermes-agent") + # 7. XDG_DATA_HOME / hermes-agent (e.g. ~/.local/share/hermes-agent) + xdg_data = Path(os.getenv("XDG_DATA_HOME", str(HOME / ".local" / "share"))) + candidates.append(xdg_data.expanduser() / "hermes-agent") + + # 8. System-wide install paths (e.g. /opt/hermes-agent, /usr/local/hermes-agent) + for sys_prefix in ("/opt", "/usr/local", "/usr/local/share"): + candidates.append(Path(sys_prefix) / "hermes-agent") + for path in candidates: if path.exists() and (path / "run_agent.py").exists(): return path.resolve() @@ -597,10 +605,14 @@ _PROVIDER_MODELS = { {"id": "claude-haiku-4-5", "label": "Claude Haiku 4.5"}, ], "openai": [ + {"id": "gpt-5.5", "label": "GPT-5.5"}, + {"id": "gpt-5.5-mini", "label": "GPT-5.5 Mini"}, {"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"}, {"id": "gpt-5.4", "label": "GPT-5.4"}, ], "openai-codex": [ + {"id": "gpt-5.5", "label": "GPT-5.5"}, + {"id": "gpt-5.5-mini", "label": "GPT-5.5 Mini"}, {"id": "gpt-5.4", "label": "GPT-5.4"}, {"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"}, {"id": "gpt-5.3-codex", "label": "GPT-5.3 Codex"}, @@ -650,6 +662,8 @@ _PROVIDER_MODELS = { ], # GitHub Copilot — model IDs served via the Copilot API "copilot": [ + {"id": "gpt-5.5", "label": "GPT-5.5"}, + {"id": "gpt-5.5-mini", "label": "GPT-5.5 Mini"}, {"id": "gpt-5.4", "label": "GPT-5.4"}, {"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"}, {"id": "gpt-4o", "label": "GPT-4o"}, @@ -1704,6 +1718,18 @@ AGENT_INSTANCES: dict = {} # stream_id -> AIAgent instance for interrupt propag STREAM_PARTIAL_TEXT: dict = {} # stream_id -> partial assistant text accumulated during streaming SERVER_START_TIME = time.time() +# Agent cache: reuse AIAgent across messages in the same WebUI session so that +# _user_turn_count survives between turns. This mirrors the gateway's +# _agent_cache pattern and is required for injectionFrequency: "first-turn". +SESSION_AGENT_CACHE: dict = {} # session_id -> (AIAgent, config_sig) +SESSION_AGENT_CACHE_LOCK = threading.Lock() + + +def _evict_session_agent(session_id: str) -> None: + """Remove a cached agent for a session (on delete, clear, or model switch).""" + with SESSION_AGENT_CACHE_LOCK: + SESSION_AGENT_CACHE.pop(session_id, None) + # ── Thread-local env context ───────────────────────────────────────────────── _thread_ctx = threading.local() diff --git a/api/models.py b/api/models.py index f99a66c2..c71dcbbf 100644 --- a/api/models.py +++ b/api/models.py @@ -691,7 +691,25 @@ def get_cli_sessions() -> list: profile = _cli_profile # CLI DB has no profile column; use active profile _source = row['source'] or 'cli' - _display_title = row['title'] or f'{_source.title()} Session' + _title = row['title'] + if not _title and _source == 'cron' and sid.startswith('cron_'): + # Extract job_id from session ID (cron_{job_id}_{timestamp}) + # and look up the human-friendly job name from jobs.json + parts = sid.split('_') + if len(parts) >= 3: + _job_id = parts[1] + try: + _jobs_path = hermes_home / 'cron' / 'jobs.json' + if _jobs_path.exists(): + import json as _json + _jobs_data = _json.loads(_jobs_path.read_text()) + for _j in _jobs_data.get('jobs', []): + if _j.get('id') == _job_id: + _title = _j.get('name') or _title + break + except Exception: + pass # degrade gracefully + _display_title = _title or f'{_source.title()} Session' cli_sessions.append({ 'session_id': sid, 'title': _display_title, diff --git a/api/onboarding.py b/api/onboarding.py index 89e15648..05cc9b4b 100644 --- a/api/onboarding.py +++ b/api/onboarding.py @@ -518,11 +518,33 @@ def get_onboarding_status() -> dict: auto_completed = skip_requested # unconditional: operator says skip, we skip # Auto-complete for existing Hermes users: if config.yaml already exists - # AND the system is chat_ready, treat onboarding as done. These users - # configured Hermes via the CLI before the Web UI existed; they must never - # be shown the first-run wizard — it would silently overwrite their config. + # AND the provider is configured (or the system is chat_ready), treat onboarding + # as done. These users configured Hermes via the CLI before the Web UI existed; + # they must never be shown the first-run wizard — it would silently overwrite their + # config. We use provider_configured (not chat_ready) so that users with + # non-wizard providers (ollama-cloud, deepseek, xai, kimi, etc.) are not forced + # through the wizard just because their provider doesn't have a detectable API key + # — the wizard cannot represent their provider and would overwrite their config + # with whichever wizard-supported provider they accidentally select. config_exists = Path(_get_config_path()).exists() - config_auto_completed = config_exists and bool(runtime.get("chat_ready")) + + # For providers not in the wizard's quick-setup list (e.g. ollama-cloud, deepseek, + # xai, kimi-k2.6), the wizard can never help — it only knows how to configure + # openrouter/anthropic/openai/google/custom. If such a user has a configured + # provider + model in config.yaml, showing the wizard would only confuse them + # (or worse, let them accidentally overwrite their config with gpt-5.4-mini). + _current_provider = str( + (cfg.get("model", {}) or {}).get("provider", "") if isinstance(cfg.get("model"), dict) + else "" + ).strip().lower() + _is_non_wizard_provider = bool( + _current_provider and _current_provider not in _SUPPORTED_PROVIDER_SETUPS + ) + + config_auto_completed = config_exists and ( + bool(runtime.get("chat_ready")) + or (_is_non_wizard_provider and bool(runtime.get("provider_configured"))) + ) # Persist the flag so it survives future transient import failures (e.g. after # a git branch switch in the hermes-agent repo). Without this, a CLI-configured diff --git a/api/routes.py b/api/routes.py index 2515de63..ecdcbd39 100644 --- a/api/routes.py +++ b/api/routes.py @@ -232,7 +232,13 @@ def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]: return default_model, bool(default_model) active_provider = _normalize_provider_id(catalog.get("active_provider")) - if not active_provider: + # Also keep the raw active_provider slug for cross-provider detection with + # non-listed providers (ollama-cloud, deepseek, xai, etc.) that _normalize_provider_id + # returns "" for. If the raw provider is set but normalization returned "", we still + # want to detect that a session model from a known provider (e.g. openai/gpt-5.4-mini) + # is stale relative to this unknown active provider. (#1023) + raw_active_provider = str(catalog.get("active_provider") or "").strip().lower() + if not active_provider and not raw_active_provider: return model, False slash = model.find("/") @@ -275,7 +281,12 @@ def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]: # Skip normalization for models on custom/openrouter namespaces — these are # user-controlled and should never be silently replaced. - if model_provider and model_provider not in {"", "custom", "openrouter"} and model_provider != active_provider and default_model: + # Also normalize when the model is from a known provider but the active provider + # is an unlisted one (e.g. ollama-cloud) — active_provider is "" in that case + # but raw_active_provider is set. If model_provider doesn't start with the raw + # active provider name, the session model is stale. (#1023) + _active_for_compare = active_provider or raw_active_provider + if model_provider and model_provider not in {"", "custom", "openrouter"} and model_provider != _active_for_compare and default_model: return default_model, True return model, False @@ -1224,6 +1235,9 @@ def handle_post(handler, parsed) -> bool: # Delete from WebUI session store with LOCK: SESSIONS.pop(sid, None) + # Evict cached agent so turn count doesn't leak into a recycled session + from api.config import _evict_session_agent + _evict_session_agent(sid) try: p = (SESSION_DIR / f"{sid}.json").resolve() p.relative_to(SESSION_DIR.resolve()) @@ -1264,6 +1278,9 @@ def handle_post(handler, parsed) -> bool: s.tool_calls = [] s.title = "Untitled" s.save() + # Evict cached agent — cleared session is a fresh conversation + from api.config import _evict_session_agent + _evict_session_agent(body["session_id"]) return j(handler, {"ok": True, "session": s.compact()}) if parsed.path == "/api/session/truncate": @@ -3552,22 +3569,23 @@ def _handle_session_import_cli(handler, body): if not msgs: return bad(handler, "Session not found in CLI store", 404) - # Derive title from first user message - title = title_from(msgs, "CLI Session") - model = "unknown" - - # Get profile, model, and timestamps from CLI session metadata + # Get profile, model, timestamps, and title from CLI session metadata profile = None created_at = None updated_at = None + cli_title = None for cs in get_cli_sessions(): if cs["session_id"] == sid: profile = cs.get("profile") model = cs.get("model", "unknown") created_at = cs.get("created_at") updated_at = cs.get("updated_at") + cli_title = cs.get("title") break + # Use the CLI session title if available (e.g., cron job name), otherwise derive from messages + title = cli_title or title_from(msgs, "CLI Session") + s = import_cli_session( sid, title, diff --git a/api/streaming.py b/api/streaming.py index a999e4c6..633e08c7 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -1398,7 +1398,56 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta if 'gateway_session_key' in _agent_params: _agent_kwargs['gateway_session_key'] = session_id - agent = _AIAgent(**_agent_kwargs) + # ── Agent cache: reuse across messages in the same session ── + # Mirrors gateway _agent_cache. Keeps _user_turn_count alive so + # injectionFrequency: "first-turn" actually suppresses after turn 1. + if ephemeral: + agent = _AIAgent(**_agent_kwargs) + logger.debug('[webui] Created ephemeral agent for session %s', session_id) + else: + import hashlib as _hashlib + import json as _json + from api.config import SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK + _sig_blob = _json.dumps([ + resolved_model or '', + _hashlib.sha256((resolved_api_key or '').encode()).hexdigest()[:16], + resolved_base_url or '', + resolved_provider or '', + sorted(_toolsets) if _toolsets else [], + ], sort_keys=True) + _agent_sig = _hashlib.sha256(_sig_blob.encode()).hexdigest()[:16] + + agent = None + with SESSION_AGENT_CACHE_LOCK: + _cached = SESSION_AGENT_CACHE.get(session_id) + if _cached and _cached[1] == _agent_sig: + agent = _cached[0] + logger.debug('[webui] Reusing cached agent for session %s', session_id) + + if agent is not None: + # Refresh per-turn callbacks — these close over request-scoped + # objects (put queue, cancel_event) that are new each request. + agent.stream_delta_callback = _agent_kwargs.get('stream_delta_callback') + agent.tool_progress_callback = _agent_kwargs.get('tool_progress_callback') + if hasattr(agent, 'reasoning_callback'): + agent.reasoning_callback = _agent_kwargs.get('reasoning_callback') + if hasattr(agent, 'clarify_callback'): + agent.clarify_callback = _agent_kwargs.get('clarify_callback') + if _session_db is not None: + agent._session_db = _session_db + if hasattr(agent, '_api_call_count'): + agent._api_call_count = 0 + # Reset interrupt state from a prior cancel so the reused + # agent does not think it is still interrupted. + if hasattr(agent, '_interrupted'): + agent._interrupted = False + if hasattr(agent, '_interrupt_message'): + agent._interrupt_message = None + else: + agent = _AIAgent(**_agent_kwargs) + with SESSION_AGENT_CACHE_LOCK: + SESSION_AGENT_CACHE[session_id] = (agent, _agent_sig) + logger.debug('[webui] Created new agent for session %s', session_id) # Store agent instance for cancel/interrupt propagation with STREAMS_LOCK: @@ -1648,6 +1697,13 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta with SESSION_AGENT_LOCKS_LOCK: SESSION_AGENT_LOCKS[new_sid] = _agent_lock SESSION_AGENT_LOCKS.pop(old_sid, None) + # Migrate cached agent to the new session ID so the turn + # count survives context compression. + from api.config import SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK + with SESSION_AGENT_CACHE_LOCK: + _cached_entry = SESSION_AGENT_CACHE.pop(old_sid, None) + if _cached_entry: + SESSION_AGENT_CACHE[new_sid] = _cached_entry if old_path.exists() and not new_path.exists(): try: old_path.rename(new_path) diff --git a/static/boot.js b/static/boot.js index dcda6ecf..f5fc013c 100644 --- a/static/boot.js +++ b/static/boot.js @@ -898,13 +898,27 @@ function applyBotName(){ // back-forward cache, the async boot IIFE above does NOT re-run, but the // DOM — including any stale value in #sessionSearch — IS restored. A // prior search string would silently hide all sessions via the filter in -// renderSessionListFromCache(). Clear the field and re-render whenever -// the page is restored from cache (`event.persisted === true`). +// renderSessionListFromCache(). Clear the field and re-run the full layout +// sync whenever the page is restored from cache (`event.persisted === true`). +// Fix #1045: also re-run topbar/workspace/panel state so the rail and layout +// chrome aren't left in the stale bfcache snapshot. window.addEventListener('pageshow', (event) => { if (!event.persisted) return; // fresh loads are handled by the IIFE above const _srch = document.getElementById('sessionSearch'); if (_srch) _srch.value = ''; + // Close any dropdowns/popovers that were open when the user navigated away. + // bfcache freezes DOM state, so a dropdown left open remains open on restore. + if (typeof closeModelDropdown === 'function') try { closeModelDropdown(); } catch (_) {} + if (typeof closeReasoningDropdown === 'function') try { closeReasoningDropdown(); } catch (_) {} + if (typeof closeWsDropdown === 'function') try { closeWsDropdown(); } catch (_) {} + if (typeof closeProfileDropdown === 'function') try { closeProfileDropdown(); } catch (_) {} + // Re-synchronise layout chrome that the boot IIFE sets up but bfcache + // doesn't re-run. Each call is guarded so missing helpers degrade silently. + if (typeof syncTopbar === 'function') try { syncTopbar(); } catch (_) {} + if (typeof syncWorkspacePanelState === 'function') try { syncWorkspacePanelState(); } catch (_) {} if (typeof renderSessionListFromCache === 'function') { try { renderSessionListFromCache(); } catch (_) {} } + // Restart the gateway SSE watcher — the persisted connection is dead after bfcache + if (typeof startGatewaySSE === 'function') try { startGatewaySSE(); } catch (_) {} }); diff --git a/static/login.js b/static/login.js index 9345e455..bba29b50 100644 --- a/static/login.js +++ b/static/login.js @@ -21,6 +21,20 @@ document.addEventListener('DOMContentLoaded', function () { if (err) { err.style.display = 'none'; } } + // Return the ?next= redirect path if present and safe, otherwise './' + // Guards against open-redirect: rejects protocol-relative (//evil.com), + // absolute URLs, backslash variants, and control characters. + function _safeNextPath() { + try { + var raw = new URL(window.location.href).searchParams.get('next'); + if (!raw) return './'; + if (raw.charAt(0) !== '/') return './'; // must be path-absolute + if (raw.charAt(1) === '/' || raw.charAt(1) === '\\') return './'; // reject // and \\ + if (/[\x00-\x1f\x7f\s]/.test(raw)) return './'; // reject control chars / whitespace + return raw; + } catch (_) { return './'; } + } + async function doLogin(e) { e.preventDefault(); var pw = input.value; @@ -35,7 +49,7 @@ document.addEventListener('DOMContentLoaded', function () { var data = {}; try { data = await res.json(); } catch (_) {} if (res.ok && data.ok) { - window.location.href = './'; + window.location.href = _safeNextPath(); } else { showErr(data.error || invalidPw); } diff --git a/static/onboarding.js b/static/onboarding.js index 955c2d23..08f4a153 100644 --- a/static/onboarding.js +++ b/static/onboarding.js @@ -286,7 +286,7 @@ async function loadOnboardingWizard(){ const current=((status.setup||{}).current)||{}; ONBOARDING.form.provider=current.provider||'openrouter'; ONBOARDING.form.workspace=(status.workspaces&&status.workspaces.last)||status.settings.default_workspace||''; - ONBOARDING.form.model=status.settings.default_model||current.model||'openai/gpt-5.4-mini'; + ONBOARDING.form.model=status.settings.default_model||current.model||''; ONBOARDING.form.password=''; ONBOARDING.form.apiKey=''; ONBOARDING.form.baseUrl=current.base_url||''; diff --git a/static/ui.js b/static/ui.js index 0d31922e..77d842c4 100644 --- a/static/ui.js +++ b/static/ui.js @@ -9,6 +9,10 @@ const SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns // single-threaded so only one done event fires at a time in practice. let _queueDrainSid=null; const $=id=>document.getElementById(id); +// Redirect to /login when the server responds with 401 (auth session expired). +// Handles iOS PWA standalone mode where a server-side 302→/login would break +// out of the PWA shell into Safari instead of navigating within it. +function _redirectIfUnauth(res){if(res&&res.status===401){window.location.href='/login?next='+encodeURIComponent(window.location.pathname+window.location.search);return true;}return false;} function _getSessionQueue(sid, create=false){ if(!sid) return []; if(!SESSION_QUEUES[sid]&&create) SESSION_QUEUES[sid]=[]; @@ -87,7 +91,9 @@ async function populateModelDropdown(){ const sel=$('modelSelect'); if(!sel) return; try{ - const data=await fetch(new URL('api/models',location.href).href,{credentials:'include'}).then(r=>r.json()); + const _modelsRes=await fetch(new URL('api/models',location.href).href,{credentials:'include'}); + if(_redirectIfUnauth(_modelsRes)) return; + const data=await _modelsRes.json(); if(!data.groups||!data.groups.length) return; // keep HTML defaults // Store active provider globally so the send path can warn on mismatch window._activeProvider=data.active_provider||null; @@ -191,7 +197,9 @@ async function _fetchLiveModels(provider, sel){ try{ const url=new URL('api/models/live',location.href); url.searchParams.set('provider',provider); - const data=await fetch(url.href,{credentials:'include'}).then(r=>r.json()); + const _liveRes=await fetch(url.href,{credentials:'include'}); + if(_redirectIfUnauth(_liveRes)) return; + const data=await _liveRes.json(); if(!data.models||!data.models.length) return; _liveModelCache[provider]=data.models; const added=_addLiveModelsToSelect(provider,data.models,sel); @@ -2682,6 +2690,7 @@ function renderMermaidBlocks(){ script.onload=()=>{ if(typeof mermaid!=='undefined'){ mermaid.initialize({startOnLoad:false,theme:document.documentElement.classList.contains('dark')?'dark':'default',themeVariables:{ + fontFamily:'inherit',fontSize:'14px', primaryColor:'#4a6fa5',primaryTextColor:'#e2e8f0',lineColor:'#718096', secondaryColor:'#2d3748',tertiaryColor:'#1a202c',primaryBorderColor:'#4a5568', }}); @@ -3125,6 +3134,7 @@ async function uploadPendingFiles(){ fd.append('session_id',S.session.session_id);fd.append('file',f,f.name); try{ const res=await fetch(new URL('api/upload',location.href).href,{method:'POST',credentials:'include',body:fd}); + if(_redirectIfUnauth(res)) return; if(!res.ok){const err=await res.text();throw new Error(err);} const data=await res.json(); if(data.error)throw new Error(data.error); diff --git a/static/workspace.js b/static/workspace.js index 4c726d55..6d3a27a8 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -4,6 +4,10 @@ async function api(path,opts={}){ const url=new URL(rel,location.href); const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...opts}); if(!res.ok){ + // 401 means the auth session expired. Redirect to /login so the user can + // re-authenticate. This is especially important for iOS PWA (standalone mode) + // where a server-side 302 → /login opens in Safari instead of within the PWA. + if(res.status===401){window.location.href='/login?next='+encodeURIComponent(window.location.pathname+window.location.search);return;} const text=await res.text(); // Parse JSON error body and surface the human-readable message, // rather than showing raw JSON like {"error":"Profile 'x' does not exist."} diff --git a/tests/test_1038_pwa_auth_redirect.py b/tests/test_1038_pwa_auth_redirect.py new file mode 100644 index 00000000..7dbcf79f --- /dev/null +++ b/tests/test_1038_pwa_auth_redirect.py @@ -0,0 +1,106 @@ +""" +Tests for issue #1038 — iOS PWA auth-expiry redirect. + +When a 401 is returned by any API endpoint, the client-side JS should redirect +to /login rather than showing a raw error toast. On iOS PWA standalone mode a +server-side 302→/login breaks out of the PWA shell into Safari, so the fix is +client-side: workspace.js api() intercepts 401 before throwing and calls +window.location.href = '/login'. + +These are static regression tests that verify the JS source contains the +correct guard patterns. +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).parent.parent + + +def _workspace_js() -> str: + return (ROOT / "static" / "workspace.js").read_text(encoding="utf-8") + + +def _ui_js() -> str: + return (ROOT / "static" / "ui.js").read_text(encoding="utf-8") + + +class TestPWAAuthRedirect: + def test_workspace_js_has_401_redirect(self): + """api() in workspace.js must redirect to /login on 401.""" + src = _workspace_js() + # Guard must appear inside the !res.ok block, before throwing + assert "res.status===401" in src, \ + "workspace.js api() must check res.status===401" + assert "window.location.href='/login" in src or 'window.location.href="/login' in src, \ + "workspace.js api() must redirect to /login on 401" + + def test_workspace_js_401_before_throw(self): + """The 401 redirect must come before the generic error throw.""" + src = _workspace_js() + idx_401 = src.find("res.status===401") + idx_throw = src.find("throw new Error") + assert idx_401 != -1, "401 guard not found in workspace.js" + assert idx_throw != -1, "throw not found in workspace.js" + assert idx_401 < idx_throw, \ + "401 redirect must appear before the generic throw in workspace.js" + + def test_ui_js_has_redirect_helper(self): + """ui.js must define _redirectIfUnauth helper.""" + src = _ui_js() + assert "_redirectIfUnauth" in src, \ + "ui.js must define _redirectIfUnauth helper function" + + def test_ui_js_models_fetch_uses_redirect(self): + """populateModelDropdown() must call _redirectIfUnauth on the api/models response.""" + src = _ui_js() + # The helper must be called after the api/models fetch + assert "_redirectIfUnauth(_modelsRes)" in src, \ + "populateModelDropdown() must check 401 on api/models fetch" + + def test_ui_js_live_models_fetch_uses_redirect(self): + """loadLiveModels() must call _redirectIfUnauth on the api/models/live response.""" + src = _ui_js() + assert "_redirectIfUnauth(_liveRes)" in src, \ + "loadLiveModels() must check 401 on api/models/live fetch" + + def test_ui_js_upload_fetch_uses_redirect(self): + """File upload must call _redirectIfUnauth on the api/upload response.""" + src = _ui_js() + assert "_redirectIfUnauth(res)" in src, \ + "upload fetch must call _redirectIfUnauth" + + +class TestLoginJsSafeNextPath: + """login.js _safeNextPath() must honor ?next= but reject open-redirect payloads.""" + + @staticmethod + def _login_js(): + return (Path(__file__).parent.parent / "static" / "login.js").read_text(encoding="utf-8") + + def test_safe_next_path_function_exists(self): + """login.js must define _safeNextPath() to honor the ?next= redirect.""" + assert "_safeNextPath" in self._login_js(), ( + "login.js must define _safeNextPath() to use the ?next= redirect after login" + ) + + def test_login_uses_safe_next_path(self): + """doLogin success handler must redirect to _safeNextPath(), not hardcoded './'.""" + src = self._login_js() + assert "_safeNextPath()" in src, ( + "doLogin must call _safeNextPath() instead of hardcoding './'" + ) + + def test_safe_next_path_rejects_protocol_relative(self): + """_safeNextPath guard must reject '//' prefix (protocol-relative open-redirect).""" + src = self._login_js() + assert "charAt(1) === '/'" in src or "startsWith('//')" in src, ( + "_safeNextPath must reject protocol-relative paths like //evil.com" + ) + + def test_safe_next_path_rejects_non_path_absolute(self): + """_safeNextPath guard must require path starts with '/'.""" + src = self._login_js() + assert "charAt(0) !== '/'" in src or "startsWith('/')" in src, ( + "_safeNextPath must reject non-path-absolute inputs (e.g. 'http://...')" + ) diff --git a/tests/test_1044_mermaid_csp_font.py b/tests/test_1044_mermaid_csp_font.py new file mode 100644 index 00000000..2f6a9ffd --- /dev/null +++ b/tests/test_1044_mermaid_csp_font.py @@ -0,0 +1,51 @@ +""" +Tests for issue #1044 — Mermaid CSP font violation. + +Mermaid's built-in themes inject an @import for Google Fonts (Manrope) at +render time, which is blocked by the CSP's style-src directive. Fix: pass +fontFamily:'inherit' in themeVariables so Mermaid never requests an external +font URL. +""" + +from pathlib import Path + +ROOT = Path(__file__).parent.parent + + +def _ui_js() -> str: + return (ROOT / "static" / "ui.js").read_text(encoding="utf-8") + + +class TestMermaidCSPFont: + def test_mermaid_init_has_font_family_inherit(self): + """themeVariables in mermaid.initialize() must set fontFamily to 'inherit'.""" + src = _ui_js() + assert "fontFamily:'inherit'" in src, ( + "mermaid.initialize() themeVariables must set fontFamily:'inherit' " + "to suppress the Google Fonts (Manrope) import that violates CSP" + ) + + def test_mermaid_init_no_google_fonts_url(self): + """ui.js must not contain a hardcoded fonts.googleapis.com URL.""" + src = _ui_js() + assert "fonts.googleapis.com" not in src, ( + "ui.js must not reference fonts.googleapis.com — use fontFamily:'inherit'" + ) + + def test_mermaid_font_family_inside_theme_variables_block(self): + """fontFamily:'inherit' must be inside the themeVariables block of mermaid.initialize().""" + src = _ui_js() + init_idx = src.find("mermaid.initialize(") + assert init_idx != -1, "mermaid.initialize() call not found in ui.js" + # Find the themeVariables block after the initialize call + tv_idx = src.find("themeVariables", init_idx) + assert tv_idx != -1, "themeVariables not found inside mermaid.initialize()" + font_idx = src.find("fontFamily:'inherit'", tv_idx) + assert font_idx != -1, ( + "fontFamily:'inherit' must appear inside themeVariables in mermaid.initialize()" + ) + # The closing brace of themeVariables should come after fontFamily + close_brace = src.find("})", tv_idx) + assert font_idx < close_brace, ( + "fontFamily:'inherit' must be inside the themeVariables block (before })" + ) diff --git a/tests/test_1045_bfcache_layout_restore.py b/tests/test_1045_bfcache_layout_restore.py new file mode 100644 index 00000000..400a77f5 --- /dev/null +++ b/tests/test_1045_bfcache_layout_restore.py @@ -0,0 +1,116 @@ +""" +Tests for issue #1045 — bfcache layout broken on tab restore. + +When the browser restores a page from bfcache (event.persisted === true), +the async boot IIFE does not re-run. The existing pageshow handler (added for +#822) only cleared the session search field and re-rendered the session list. +This left the rail, topbar, workspace panel, and resize handles in the stale +bfcache DOM state, producing a broken layout. + +Fix: extend the pageshow handler to also call syncTopbar, syncWorkspacePanelState, +_initResizePanels, and startGatewaySSE — all guarded so missing helpers degrade. +""" + +from pathlib import Path + +ROOT = Path(__file__).parent.parent + + +def _boot_js() -> str: + return (ROOT / "static" / "boot.js").read_text(encoding="utf-8") + + +class TestBfcacheLayoutRestore: + def test_pageshow_calls_sync_topbar(self): + """pageshow handler must call syncTopbar() on bfcache restore.""" + src = _boot_js() + # Find the pageshow listener block + ps_idx = src.find("window.addEventListener('pageshow'") + assert ps_idx != -1, "pageshow listener not found in boot.js" + handler_body = src[ps_idx:ps_idx + 1600] + assert "syncTopbar" in handler_body, ( + "pageshow handler must call syncTopbar() to restore topbar state after bfcache" + ) + + def test_pageshow_calls_sync_workspace_panel_state(self): + """pageshow handler must call syncWorkspacePanelState().""" + src = _boot_js() + ps_idx = src.find("window.addEventListener('pageshow'") + handler_body = src[ps_idx:ps_idx + 1600] + assert "syncWorkspacePanelState" in handler_body, ( + "pageshow handler must call syncWorkspacePanelState() on bfcache restore" + ) + + + def test_pageshow_calls_start_gateway_sse(self): + """pageshow handler must call startGatewaySSE() to reconnect the dead SSE connection.""" + src = _boot_js() + ps_idx = src.find("window.addEventListener('pageshow'") + handler_body = src[ps_idx:ps_idx + 1600] + assert "startGatewaySSE" in handler_body, ( + "pageshow handler must restart gateway SSE (bfcache-persisted connections are dead)" + ) + + def test_pageshow_still_clears_session_search(self): + """pageshow handler must still clear #sessionSearch (original #822 fix preserved).""" + src = _boot_js() + ps_idx = src.find("window.addEventListener('pageshow'") + handler_body = src[ps_idx:ps_idx + 1600] + assert "sessionSearch" in handler_body, ( + "pageshow handler must still clear #sessionSearch (regression: #822 fix must be preserved)" + ) + + def test_pageshow_still_calls_render_session_list_from_cache(self): + """pageshow handler must still call renderSessionListFromCache().""" + src = _boot_js() + ps_idx = src.find("window.addEventListener('pageshow'") + handler_body = src[ps_idx:ps_idx + 1600] + assert "renderSessionListFromCache" in handler_body, ( + "pageshow handler must still call renderSessionListFromCache() (regression: #822 fix)" + ) + + def test_pageshow_does_not_call_init_resize_panels(self): + """pageshow handler must NOT call _initResizePanels() — bfcache + preserves event listeners so re-attaching them stacks duplicates.""" + src = _boot_js() + ps_idx = src.find("window.addEventListener('pageshow'") + handler_body = src[ps_idx:ps_idx + 1600] + assert "_initResizePanels" not in handler_body, ( + "pageshow handler must not call _initResizePanels() — it stacks " + "duplicate mousedown listeners on every bfcache restore" + ) + + def test_new_calls_are_guarded_with_typeof(self): + """New calls in the pageshow handler must be typeof-guarded for safe degradation.""" + src = _boot_js() + ps_idx = src.find("window.addEventListener('pageshow'") + handler_body = src[ps_idx:ps_idx + 1600] + # Each of the new calls must be guarded + for fn in ("syncTopbar", "syncWorkspacePanelState", "startGatewaySSE", + "closeModelDropdown", "closeReasoningDropdown", "closeWsDropdown", "closeProfileDropdown"): + assert f"typeof {fn} === 'function'" in handler_body, ( + f"{fn}() call in pageshow handler must be guarded with typeof === 'function'" + ) + + def test_pageshow_closes_all_dropdowns(self): + """pageshow handler must close all known dropdowns to reset frozen bfcache popover state.""" + src = _boot_js() + ps_idx = src.find("window.addEventListener('pageshow'") + handler_body = src[ps_idx:ps_idx + 1600] + for fn in ("closeModelDropdown", "closeReasoningDropdown", "closeWsDropdown", "closeProfileDropdown"): + assert fn in handler_body, ( + f"pageshow handler must call {fn}() to dismiss any dropdown left open by bfcache" + ) + + def test_dropdowns_closed_before_layout_sync(self): + """Dropdown closes must come before layout sync calls (clean state first).""" + src = _boot_js() + ps_idx = src.find("window.addEventListener('pageshow'") + handler_body = src[ps_idx:ps_idx + 1600] + close_idx = handler_body.find("closeModelDropdown") + sync_idx = handler_body.find("syncTopbar") + assert close_idx != -1 and sync_idx != -1, "Both close and sync calls must be present" + assert close_idx < sync_idx, ( + "Dropdown close calls must appear before layout sync calls in the pageshow handler" + ) + diff --git a/tests/test_cron_session_title.py b/tests/test_cron_session_title.py new file mode 100644 index 00000000..78b9f384 --- /dev/null +++ b/tests/test_cron_session_title.py @@ -0,0 +1,148 @@ +"""Tests for cron session title fallback in get_cli_sessions(). + +When a CLI session originates from cron and has no title in state.db, the +WebUI sidebar should display the human-friendly job name from cron/jobs.json +instead of a generic "Cron Session" label. + +Session ID format produced by hermes-agent: cron___ +""" +import json +import sqlite3 + +import pytest + +import api.models as models + + +def _make_state_db(path, sessions): + """Create a state.db with the schema get_cli_sessions() expects. + + `sessions` is a list of (id, title, source) tuples. + """ + conn = sqlite3.connect(str(path)) + conn.execute(""" + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + title TEXT, + model TEXT, + message_count INTEGER, + started_at REAL, + source TEXT + ) + """) + conn.execute(""" + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, + timestamp REAL + ) + """) + for sid, title, source in sessions: + conn.execute( + "INSERT INTO sessions (id, title, model, message_count, started_at, source) " + "VALUES (?, ?, ?, ?, ?, ?)", + (sid, title, "gpt-x", 1, 1700000000.0, source), + ) + conn.execute( + "INSERT INTO messages (session_id, timestamp) VALUES (?, ?)", + (sid, 1700000001.0), + ) + conn.commit() + conn.close() + + +def _write_jobs_json(hermes_home, jobs): + """Write cron/jobs.json with the given jobs list.""" + cron_dir = hermes_home / "cron" + cron_dir.mkdir(parents=True, exist_ok=True) + (cron_dir / "jobs.json").write_text( + json.dumps({"jobs": jobs}), encoding="utf-8" + ) + + +@pytest.fixture +def fake_hermes_home(tmp_path, monkeypatch): + """Point get_cli_sessions() at a temporary HERMES_HOME and disable + profile lookups so the test runs hermetically.""" + home = tmp_path / "hermes" + home.mkdir() + + # Both profile helpers are imported lazily inside get_cli_sessions(), + # so patching the api.profiles module reaches them. + import api.profiles as profiles + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: home) + monkeypatch.setattr(profiles, "get_active_profile_name", lambda: None) + + return home + + +def test_cron_session_uses_job_name_when_title_missing(fake_hermes_home): + """A cron session with no title should display the friendly job name.""" + _write_jobs_json(fake_hermes_home, [ + {"id": "cd65df6fc1a8", "name": "wiki-auto-ingest"}, + ]) + _make_state_db(fake_hermes_home / "state.db", [ + ("cron_cd65df6fc1a8_20260417_191049", None, "cron"), + ]) + + sessions = models.get_cli_sessions() + + assert len(sessions) == 1 + assert sessions[0]["title"] == "wiki-auto-ingest" + + +def test_cron_session_falls_back_when_jobs_json_missing(fake_hermes_home): + """No jobs.json should not crash; title falls back to 'Cron Session'.""" + _make_state_db(fake_hermes_home / "state.db", [ + ("cron_abc123_20260417_191049", None, "cron"), + ]) + + sessions = models.get_cli_sessions() + + assert sessions[0]["title"] == "Cron Session" + + +def test_cron_session_falls_back_when_job_id_not_in_jobs_json(fake_hermes_home): + """Stale session whose job has been deleted falls back gracefully.""" + _write_jobs_json(fake_hermes_home, [ + {"id": "different_job", "name": "Some Other Job"}, + ]) + _make_state_db(fake_hermes_home / "state.db", [ + ("cron_orphan_20260417_191049", None, "cron"), + ]) + + sessions = models.get_cli_sessions() + + assert sessions[0]["title"] == "Cron Session" + + +def test_explicit_title_is_preserved(fake_hermes_home): + """If state.db already has a title, the cron job lookup should not + override it.""" + _write_jobs_json(fake_hermes_home, [ + {"id": "cd65df6fc1a8", "name": "wiki-auto-ingest"}, + ]) + _make_state_db(fake_hermes_home / "state.db", [ + ("cron_cd65df6fc1a8_20260417_191049", "User-edited title", "cron"), + ]) + + sessions = models.get_cli_sessions() + + assert sessions[0]["title"] == "User-edited title" + + +def test_non_cron_sessions_unaffected(fake_hermes_home): + """The cron-name lookup must not run for cli-source sessions, so the + generic 'Cli Session' fallback still applies when title is empty.""" + _write_jobs_json(fake_hermes_home, [ + {"id": "cd65df6fc1a8", "name": "wiki-auto-ingest"}, + ]) + # A 'cli' session whose ID coincidentally starts with 'cron_' must not + # pick up the job name — the source check guards against this. + _make_state_db(fake_hermes_home / "state.db", [ + ("cron_cd65df6fc1a8_xx", None, "cli"), + ]) + + sessions = models.get_cli_sessions() + + assert sessions[0]["title"] == "Cli Session" diff --git a/tests/test_issue572.py b/tests/test_issue572.py index 137ab8ae..755f4f46 100644 --- a/tests/test_issue572.py +++ b/tests/test_issue572.py @@ -192,10 +192,22 @@ class TestOnboardingStatusUnsupportedProvider: "config.yaml + chat_ready=True must auto-complete onboarding regardless of provider." ) - def test_minimax_cn_not_ready_shows_wizard(self): - """minimax-cn + chat_ready=False → wizard fires so user can fix it.""" + def test_minimax_cn_not_ready_skips_wizard(self): + """minimax-cn + chat_ready=False → wizard still skipped for non-wizard providers. + + The onboarding wizard has no minimax-cn option — showing it would only confuse + the user or let them accidentally overwrite their config with an OpenAI/Anthropic + provider. For any provider not in _SUPPORTED_PROVIDER_SETUPS, onboarding is + auto-completed as long as provider_configured is True, regardless of chat_ready. + Users on non-wizard providers with no API key should fix credentials via + Settings → Providers, not via the first-run wizard. (#1020) + """ result = self._make_status(chat_ready=False) - assert result["completed"] is False + assert result["completed"] is True, ( + "Wizard fired for minimax-cn user with provider_configured=True! " + "Non-wizard providers must auto-complete onboarding because the wizard " + "cannot configure them and would silently overwrite their config." + ) def test_current_is_oauth_set_for_unsupported_provider(self): """setup.current_is_oauth must be True for minimax-cn (not in quick-setup list)."""