fix: keep frontend routes under subpath mounts
This commit is contained in:
@@ -256,7 +256,7 @@ def check_auth(handler, parsed) -> bool:
|
||||
# safe='/' keeps path separators readable; everything else (including
|
||||
# `?`, `&`, `=`) gets percent-encoded.
|
||||
_next = _urlparse.quote(_path_with_query, safe='/')
|
||||
handler.send_header('Location', '/login?next=' + _next)
|
||||
handler.send_header('Location', 'login?next=' + _next)
|
||||
handler.end_headers()
|
||||
return False
|
||||
|
||||
|
||||
@@ -1287,7 +1287,7 @@ function applyBotName(){
|
||||
// ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards)
|
||||
const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';
|
||||
if(_testUpdates||(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){
|
||||
const _checkUrl='/api/updates/check'+(_testUpdates?'?simulate=1':'');
|
||||
const _checkUrl='api/updates/check'+(_testUpdates?'?simulate=1':'');
|
||||
api(_checkUrl).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{});
|
||||
}
|
||||
// Fetch active profile
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<!-- ES module imports do not support the integrity= attribute (W3C limitation); -->
|
||||
<!-- version is pinned in the vendored file path; hash documented above for audit. -->
|
||||
<script type="module">
|
||||
import * as smd from '/static/vendor/smd.min.js';
|
||||
import * as smd from 'static/vendor/smd.min.js';
|
||||
// SRI verification happens at the ES module level via importmap or SW; pinning version in URL.
|
||||
// sha384 of smd.min.js @0.2.15: sha384-T6r95ocN9t3W8tUK2Fa6FPaO7bJryyjyW0WCalrUnpgtm2qXr5xcN4vwPYEJ6vHa
|
||||
window.smd = smd;
|
||||
|
||||
@@ -1393,7 +1393,7 @@ function startApprovalPolling(sid) {
|
||||
stopApprovalPolling();
|
||||
// ── SSE (preferred): long-lived connection, server pushes instantly ──
|
||||
try {
|
||||
const es = new EventSource('/api/approval/stream?session_id=' + encodeURIComponent(sid));
|
||||
const es = new EventSource(new URL('api/approval/stream?session_id=' + encodeURIComponent(sid), document.baseURI || location.href).href);
|
||||
let _fallbackActive = false;
|
||||
|
||||
es.addEventListener('initial', e => {
|
||||
@@ -1755,7 +1755,7 @@ function startClarifyPolling(sid) {
|
||||
|
||||
// SSE primary path: long-lived connection pushes events instantly.
|
||||
try {
|
||||
_clarifyEventSource = new EventSource('/api/clarify/stream?session_id=' + encodeURIComponent(sid));
|
||||
_clarifyEventSource = new EventSource(new URL('api/clarify/stream?session_id=' + encodeURIComponent(sid), document.baseURI || location.href).href);
|
||||
} catch(e) {
|
||||
_startClarifyFallbackPoll(sid);
|
||||
return;
|
||||
@@ -1873,7 +1873,7 @@ function sendBrowserNotification(title,body){
|
||||
|
||||
function attachBtwStream(parentSid, streamId, question){
|
||||
if(!parentSid||!streamId) return;
|
||||
const src=new EventSource('/api/chat/stream?stream_id='+encodeURIComponent(streamId));
|
||||
const src=new EventSource(new URL('api/chat/stream?stream_id='+encodeURIComponent(streamId), document.baseURI||location.href).href);
|
||||
let answer='';
|
||||
let btwRow=null;
|
||||
let _streamDone=false;
|
||||
|
||||
@@ -1437,7 +1437,7 @@ async function probeGatewaySSEStatus(){
|
||||
if(_gatewayProbeInFlight || !window._showCliSessions) return;
|
||||
_gatewayProbeInFlight = true;
|
||||
try{
|
||||
const resp = await fetch('/api/sessions/gateway/stream?probe=1', { credentials:'same-origin' });
|
||||
const resp = await fetch(new URL('api/sessions/gateway/stream?probe=1', location.href).href, { credentials:'same-origin' });
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if(resp.ok && data.watcher_running){
|
||||
stopGatewayPollFallback();
|
||||
|
||||
10
static/ui.js
10
static/ui.js
@@ -9,10 +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;}
|
||||
// Redirect to login when the server responds with 401 (auth session expired).
|
||||
// Handles iOS PWA standalone mode and keeps subpath mounts like /hermes/ from
|
||||
// escaping to the personal site root /login.
|
||||
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]=[];
|
||||
@@ -2976,7 +2976,7 @@ async function _waitForServerThenReload(opts){
|
||||
await new Promise(r=>setTimeout(r, interval));
|
||||
while(Date.now()<deadline){
|
||||
try{
|
||||
const r=await fetch('/health',{cache:'no-store'});
|
||||
const r=await fetch(new URL('health', document.baseURI||location.href).href,{cache:'no-store'});
|
||||
if(r.ok){
|
||||
let data={};
|
||||
try{ data=await r.json(); }catch(_){}
|
||||
|
||||
@@ -9,10 +9,10 @@ async function api(path,opts={}){
|
||||
try{
|
||||
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
|
||||
// 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;}
|
||||
// and for subpath mounts like /hermes/, where /login escapes to the site root.
|
||||
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."}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
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'.
|
||||
to login rather than showing a raw error toast. On iOS PWA standalone mode a
|
||||
server-side 302→login can break out of the PWA shell into Safari, so the fix is
|
||||
client-side: workspace.js api() intercepts 401 before throwing and calls a
|
||||
relative login URL that also works under subpath mounts like /hermes/.
|
||||
|
||||
These are static regression tests that verify the JS source contains the
|
||||
correct guard patterns.
|
||||
@@ -27,13 +27,15 @@ def _ui_js() -> str:
|
||||
|
||||
class TestPWAAuthRedirect:
|
||||
def test_workspace_js_has_401_redirect(self):
|
||||
"""api() in workspace.js must redirect to /login on 401."""
|
||||
"""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"
|
||||
assert "window.location.href='login" in src or 'window.location.href="login' in src, \
|
||||
"workspace.js api() must redirect to login on 401"
|
||||
assert "window.location.href='/login" not in src and 'window.location.href="/login' not in src, \
|
||||
"workspace.js api() must not escape subpath mounts by redirecting to root /login"
|
||||
|
||||
def test_workspace_js_401_before_throw(self):
|
||||
"""The 401 redirect must come before any error throw."""
|
||||
|
||||
@@ -150,9 +150,11 @@ class TestFrontendSSEImplementation:
|
||||
"startApprovalPolling must create an EventSource for SSE"
|
||||
|
||||
def test_sse_url_matches_backend(self):
|
||||
"""Frontend SSE URL must match backend /api/approval/stream route."""
|
||||
assert "/api/approval/stream" in MESSAGES_JS, \
|
||||
"EventSource must connect to /api/approval/stream"
|
||||
"""Frontend SSE URL must match backend approval stream route."""
|
||||
assert "api/approval/stream" in MESSAGES_JS, \
|
||||
"EventSource must connect to the approval stream endpoint"
|
||||
assert "EventSource('/api/approval/stream" not in MESSAGES_JS, \
|
||||
"EventSource URL must stay relative for subpath mounts"
|
||||
|
||||
def test_initial_event_listener(self):
|
||||
"""Frontend must listen for 'initial' SSE events."""
|
||||
|
||||
@@ -79,7 +79,8 @@ class TestClarifySSEFrontendCode:
|
||||
|
||||
def test_uses_event_source(self):
|
||||
assert "new EventSource" in self.js
|
||||
assert "/api/clarify/stream" in self.js
|
||||
assert "api/clarify/stream" in self.js
|
||||
assert "EventSource('/api/clarify/stream" not in self.js
|
||||
|
||||
def test_frontend_listens_initial_event(self):
|
||||
assert "'initial'" in self.js or '"initial"' in self.js
|
||||
|
||||
@@ -119,7 +119,7 @@ def test_extension_route_remains_behind_webui_auth(monkeypatch):
|
||||
# when constructing the redirect Location header.
|
||||
assert check_auth(extension, SimpleNamespace(path="/extensions/app.js", query="")) is False
|
||||
assert extension.status == 302
|
||||
assert extension.header("Location") == "/login?next=/extensions/app.js"
|
||||
assert extension.header("Location") == "login?next=/extensions/app.js"
|
||||
|
||||
# Existing core static assets remain public; extension assets intentionally
|
||||
# do not share that exemption because they are administrator-supplied code.
|
||||
|
||||
@@ -50,12 +50,14 @@ class TestApiRetryOnNetworkError:
|
||||
"api() must limit to 3 attempts max (attempt < 2)"
|
||||
|
||||
def test_api_preserves_401_redirect(self):
|
||||
"""api() must still redirect to /login on 401 (auth expired)."""
|
||||
"""api() must still redirect to login on 401 without escaping subpath mounts."""
|
||||
src = _src()
|
||||
assert "res.status===401" in src, \
|
||||
"api() must still check for 401 status"
|
||||
assert "/login?next=" in src, \
|
||||
"api() must still redirect to /login on 401"
|
||||
assert "login?next=" in src, \
|
||||
"api() must still redirect to login on 401"
|
||||
assert "/login?next=" not in src, \
|
||||
"api() must not escape subpath mounts by redirecting to root /login"
|
||||
|
||||
def test_api_preserves_error_parsing(self):
|
||||
"""api() must still parse JSON error bodies for non-200 responses."""
|
||||
|
||||
61
tests/test_subpath_frontend_routes.py
Normal file
61
tests/test_subpath_frontend_routes.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Regression tests for frontend routing under subpath mounts like /hermes/."""
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_workspace_api_401_redirect_uses_relative_login_path():
|
||||
src = read("static/workspace.js")
|
||||
assert "res.status===401" in src
|
||||
assert "window.location.href='login?next='" in src, (
|
||||
"workspace api() must redirect to relative login?next= so /hermes/ "
|
||||
"does not escape to the personal site root /login."
|
||||
)
|
||||
assert "window.location.href='/login?next='" not in src
|
||||
|
||||
|
||||
def test_ui_401_redirect_helper_uses_relative_login_path():
|
||||
src = read("static/ui.js")
|
||||
assert "function _redirectIfUnauth" in src
|
||||
assert "window.location.href='login?next='" in src, (
|
||||
"UI auth-expiry redirect must stay under the current subpath mount."
|
||||
)
|
||||
assert "window.location.href='/login?next='" not in src
|
||||
|
||||
|
||||
def test_server_auth_redirect_uses_relative_login_path_with_encoded_next():
|
||||
src = read("api/auth.py")
|
||||
assert "handler.send_header('Location', 'login?next=' + _next)" in src
|
||||
assert "handler.send_header('Location', '/login?next='" not in src
|
||||
assert "safe='/'" in src, "the relative redirect must keep the existing next= encoding fix"
|
||||
|
||||
|
||||
def test_direct_frontend_fetches_are_relative_to_current_mount():
|
||||
for path in ("static/boot.js", "static/sessions.js", "static/ui.js"):
|
||||
src = read(path)
|
||||
assert "fetch('/api/" not in src, (
|
||||
f"{path} must not fetch root /api/* because /hermes/ is subpath mounted."
|
||||
)
|
||||
assert 'fetch("/api/' not in src
|
||||
assert "fetch('/health'" not in read("static/ui.js")
|
||||
assert "new URL('health'" in read("static/ui.js")
|
||||
|
||||
|
||||
def test_direct_frontend_event_sources_are_relative_to_current_mount():
|
||||
src = read("static/messages.js")
|
||||
assert "EventSource('/api/" not in src
|
||||
assert 'EventSource("/api/' not in src
|
||||
for endpoint in ("api/approval/stream", "api/clarify/stream", "api/chat/stream"):
|
||||
assert endpoint in src
|
||||
assert "new URL(" in src
|
||||
|
||||
|
||||
def test_static_vendor_import_is_relative_to_current_mount():
|
||||
src = read("static/index.html")
|
||||
assert "import * as smd from 'static/vendor/smd.min.js'" in src
|
||||
assert "import * as smd from '/static/vendor/smd.min.js'" not in src
|
||||
@@ -349,13 +349,14 @@ class TestUiJsUpdateBanner:
|
||||
)
|
||||
|
||||
def test_wait_for_server_polls_health(self):
|
||||
"""_waitForServerThenReload() must fetch /health to determine readiness."""
|
||||
"""_waitForServerThenReload() must fetch health to determine readiness."""
|
||||
src = read('static/ui.js')
|
||||
m = re.search(r'function\s+_waitForServerThenReload\b.*?\n\}', src, re.DOTALL)
|
||||
assert m, "_waitForServerThenReload() not found"
|
||||
fn = m.group(0)
|
||||
assert '/health' in fn, (
|
||||
"_waitForServerThenReload must poll /health to detect server readiness"
|
||||
assert "new URL('health'" in fn, (
|
||||
"_waitForServerThenReload must poll the mount-relative health endpoint "
|
||||
"to detect server readiness"
|
||||
)
|
||||
assert 'location.reload' in fn, (
|
||||
"_waitForServerThenReload must call location.reload() once the server is ready"
|
||||
|
||||
@@ -6,7 +6,7 @@ initial implementation built the outer `next` parameter via:
|
||||
_next = quote(path, safe='/:@!$&\'()*+,;=')
|
||||
if query:
|
||||
_next += '?' + query
|
||||
location = '/login?next=' + quote(_next, safe='/:@!$&\'()*+,;=?')
|
||||
location = 'login?next=' + quote(_next, safe='/:@!$&\'()*+,;=?')
|
||||
|
||||
Two problems with this shape:
|
||||
|
||||
@@ -45,7 +45,7 @@ def test_login_redirect_uses_path_only_safe_encoding():
|
||||
original `safe='/:@!$&\'()*+,;=?'` shape."""
|
||||
src = (REPO / "api" / "auth.py").read_text(encoding="utf-8")
|
||||
|
||||
redirect_idx = src.find("/login?next=")
|
||||
redirect_idx = src.find("login?next=")
|
||||
assert redirect_idx != -1, "login redirect missing"
|
||||
block = src[max(0, redirect_idx - 1200) : redirect_idx + 600]
|
||||
|
||||
@@ -73,7 +73,7 @@ def _build_redirect_like_check_auth(path: str, query: str) -> str:
|
||||
if query:
|
||||
_path_with_query += "?" + query
|
||||
_next = _urlparse.quote(_path_with_query, safe="/")
|
||||
return "/login?next=" + _next
|
||||
return "login?next=" + _next
|
||||
|
||||
|
||||
def _browser_searchparams_get_next(location: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user