feat: add PWA support (manifest, service worker, install prompt)#920
feat: add PWA support (manifest, service worker, install prompt)#920nesquena-hermes merged 2 commits intomasterfrom
Conversation
nesquena
left a comment
There was a problem hiding this comment.
Review — end-to-end ✅ (one dead-code offline-fallback bug fixed)
Traced against upstream hermes-agent
Fresh nousresearch/hermes-agent tarball pulled. Confirmed this PR is webui-internal: new manifest.json, new sw.js, index.html meta tags + SW registration, and api/routes.py GET handlers for serving both files. No hermes-agent interaction. No config.yaml writes, no session state.
End-to-end trace
manifest.json (static/manifest.json)
start_url: "./"— relative, works with subpath mounts ✓display: "standalone"— installable PWA ✓icons:static/favicon.svg(any + maskable) +static/favicon-32.png— both files exist in the repo ✓theme_color+background_colormatch the app's dark theme
sw.js (static/sw.js)
Fetch strategy traced for every request class:
- Cross-origin → untouched (line 64:
if (url.origin !== self.location.origin) return;) /api/*,*/stream*,/health*→ always network (line 67-71: early return, no caching)- Shell assets (cache-first with network fallback, cache on success, line 78-88)
- Navigation on offline (cache-first for
'./', then fallback HTML)
Cross-checked the API/stream bypass list against api/routes.py: all SSE streaming endpoints live under /api/* (/api/chat/stream/status, /api/chat/stream, /api/sessions/gateway/stream). The startsWith('/api/') check catches all of them.
Routes (api/routes.py:579-611)
/manifest.json+/manifest.webmanifest→ 200 withContent-Type: application/manifest+json,Cache-Control: no-store✓/sw.js→ 200 withContent-Type: application/javascript,Cache-Control: no-store,Service-Worker-Allowed: /✓- Both use
read_bytes()/read_text()with a resolved path insidestatic/— no path traversal WEBUI_VERSIONfromapi/updates.py:95is thegit describe --tags --always --dirtyoutput or baked-in__version__fromapi/_version.py. Values likev0.50.175,v0.50.175-1-ge91325d, orunknown— all safe for substring replacement intoconst CACHE_NAME = 'hermes-shell-...'(git forbids tag names containing quotes/backslashes/newlines)
index.html (static/index.html:10-36)
<link rel="manifest" href="manifest.json">— relative, works with subpath mount +<base href>pattern already in the page- Apple PWA meta tags present (
apple-mobile-web-app-capable,apple-mobile-web-app-status-bar-style,apple-touch-icon) - SW registration script:
navigator.serviceWorker.register('sw.js')— relative, default scope = directory of sw.js (root at root-mount,/hermes/at subpath mount)
What I caught — offline fallback was dead code
The original fetch handler offline fallback:
return caches.match('./') || new Response('<html>...</html>', ...);caches.match() returns a Promise, which is always truthy in a || check. So new Response(...) was never evaluated — on actual offline, caches.match('./') resolves to undefined (no cache hit for the root), the Promise is returned as-is, the SW's event.respondWith resolves to undefined, and the browser falls back to its own default offline page. The hardcoded "Hermes requires a server connection" HTML was unreachable.
What I pushed — ecf2f93
Threaded the match through .then() so the resolved value feeds the ||:
return caches.match('./').then((cached) => cached || new Response(...));Added 13 regression tests in tests/test_pwa_manifest_sw.py:
test_sw_offline_fallback_awaits_caches_match— explicitly rejects the brokencaches.match() || new Response(...)shape via regex so it can't regress- manifest.json validity + required PWA fields + icon-file existence
- sw.js
__CACHE_VERSION__placeholder + API/stream bypass + no-cache of API responses - Routes serve correct Content-Type / Cache-Control / Service-Worker-Allowed / inject WEBUI_VERSION
- index.html links manifest, registers SW, has iOS PWA meta tags
Security audit
- No secrets in manifest or SW: both are pure client-config files
- Subpath-mount safety: relative paths (
./,manifest.json,sw.js) honor<base href>. SW scope defaults to the directory ofsw.js, so at root mount =/, at subpath =/subpath/— no cross-app SW interception on shared hosts Service-Worker-Allowed: /header: expands the MAX scope the SW can request. Sinceregister()is called without an explicit{scope}option, actual scope stays at the SW file's directory (safe default). The header is a defensive allowance, not a forced broadening- API response caching: explicitly bypassed (
/api/*,*/stream*,/health*) — no chance of stale auth responses, no session-cookie-keyed content ending up in shared cache - Cache-bust on deploy:
__CACHE_VERSION__substitution means every newWEBUI_VERSIONproduces a new cache name. Old caches are deleted in the activate handler. Clean on deploy - Custom offline HTML: hardcoded, no user data interpolation ✓
- Path traversal in routes:
(static_root / "manifest.json").resolve()— the.resolve()normalizes the path, but the match against the literal filename + the fact the requesting path is compared as a hardcoded string (parsed.path == "/manifest.json") means there's no user-controlled path component at all application/javascriptMIME for SW: required by spec, present ✓
Cross-tool (CLI) check
- manifest.json / sw.js are client-only static files
api/routes.pyGET handlers serve them as-is- CLI never hits
/manifest.json,/sw.js, or registers service workers - No state shared with CLI ✓
Edge-case trace
| Scenario | Behaviour |
|---|---|
| First load on Chrome Android | Manifest links, SW registers, "Add to Home Screen" prompt available ✅ |
Subpath mount (/hermes/) |
Relative paths resolve via <base href>; routes match after reverse-proxy path-strip; SW scope = /hermes/ (default) ✅ |
| Deploy new version (WEBUI_VERSION changes) | Every request to /sw.js returns fresh SW with new cache name; activate handler deletes old caches ✅ |
Offline navigation to / |
Before fix: `caches.match('./') |
| Offline navigation to cached shell asset | caches.match(event.request) hits → cached asset served ✅ |
Authenticated user hits /api/chat/stream |
SW early-returns on /api/* → no interception, direct network ✅ |
| Shared device: user A logs in, logs out, user B loads app | Shell is cached (no auth state), client JS checks /api/auth/status (network-bypass) → user B sees login UI ✅ |
| Service worker install fails | addAll(SHELL_ASSETS).catch(...) logs non-fatal, activation proceeds ✅ |
Service worker update with non-matching CACHE_NAME |
Old cache deleted in activate handler (keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) ✅ |
/api/auth/login (POST) offline |
SW doesn't intercept /api/* → browser sends fetch → fails network → app handles normally ✅ |
Tests
- 13/13 pass in new
tests/test_pwa_manifest_sw.py node --check static/sw.js— cleanpython3 -c "import json; json.load(open('static/manifest.json'))"— valid JSON- Full local suite: 1969 passed, 47 skipped, 0 failed
Minor observations (non-blocking)
- The
url.pathname.includes('/stream')check atsw.js:69is a substring match. For today's codebase it's safe because all streaming endpoints are under/api/*(caught by the earlierstartsWith('/api/')check), but a future static asset at a path containing/stream(e.g./static/test/stream.js) would accidentally bypass SW caching. Consider tightening tourl.pathname.startsWith('/api/')alone orstartsWith('/stream'). Non-urgent. - The SW's
.catchblock only handles thenavigatemode; non-navigate requests that fail (e.g. a missed shell asset while offline) return undefined → browser shows the generic fetch error. Acceptable for a non-offline app. SHELL_ASSETShardcodes the list of JS files (boot.js,ui.js,messages.js, etc.). If a new JS file is added later, it won't be pre-cached until someone updates this list. Consider a build-time generation, or a dynamic-discovery pattern. Non-urgent.- No icon file larger than 32px in the manifest. For high-DPI Android / desktop Chrome, a 192px and 512px icon is conventional (and listed in the PWA manifest spec as "recommended"). Could be a polish follow-up.
WEBUI_VERSION == 'unknown'fallback case — if git fails AND noapi/_version.pyexists, the cache name becomeshermes-shell-unknown. Multiple deploys in this state share a cache, so stale shell would survive across deploys. Edge case; production builds should always have one or the other set.
Recommendation
Well-scoped PWA support. One real functional bug caught (dead-code offline fallback) and fixed with regression coverage. Security posture is clean: correct API bypass, no auth-keyed caching, subpath-mount safe, cache-bust on every deploy. Test coverage is thorough enough to lock the key invariants.
✅ Approved after the fix. Ready for merge + v0.50.178 tag.
…tually shows
The offline-navigation fallback was dead code:
return caches.match('./') || new Response('<html>...</html>', ...);
`caches.match()` returns a Promise, and Promise objects are always truthy
in a `||` check — so the `new Response(...)` branch was never taken. On
actual offline, `caches.match('./')` resolves to undefined (no cache hit
for the root), the SW returns undefined, and the browser falls back to
its own default offline page. The custom "Hermes requires a server
connection" HTML was unreachable.
Fix by threading the match through `.then()` so the resolved value (not
the Promise object) feeds the `||`:
return caches.match('./').then((cached) => cached || new Response(...));
Added 13 regression tests in tests/test_pwa_manifest_sw.py covering:
- manifest.json validity + required PWA fields + icon existence
- sw.js cache-version placeholder + API/stream bypass + correct offline
pattern (explicitly rejects the broken `|| new Response` shape so it
can't regress)
- /manifest.json + /sw.js routes serve correct Content-Type,
Cache-Control, Service-Worker-Allowed headers and inject WEBUI_VERSION
- index.html links manifest, registers SW, has iOS PWA meta tags
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
ecf2f93 to
d4bfcf5
Compare
PR nesquena#920 added static/manifest.json and sw.js for PWA support. The CSP in _security_headers() had no explicit manifest-src directive, so browsers fell back to default-src 'self' and emitted a console warning on every page load. The fallback is functionally correct but non-compliant with CSP Level 3 best practice of declaring each directive explicitly. Adds manifest-src 'self' before base-uri. No origin set is changed. Regression test added alongside existing CSP coverage in test_pwa_manifest_csp.py. Co-authored with Claude Sonnet 4.6 / Anthropic.
PR nesquena#920 added static/manifest.json and sw.js for PWA support. The CSP in _security_headers() had no explicit manifest-src directive, so browsers fell back to default-src 'self' and emitted a console warning on every page load. The fallback is functionally correct but non-compliant with CSP Level 3 best practice of declaring each directive explicitly. Adds manifest-src 'self' before base-uri. No origin set is changed. Regression test added alongside existing CSP coverage in test_pwa_manifest_csp.py. Co-authored with Claude Sonnet 4.6 / Anthropic.
PR #920 added static/manifest.json and sw.js for PWA support. The CSP in _security_headers() had no explicit manifest-src directive, so browsers fell back to default-src 'self' and emitted a console warning on every page load. The fallback is functionally correct but non-compliant with CSP Level 3 best practice of declaring each directive explicitly. Adds manifest-src 'self' before base-uri. No origin set is changed. Regression test added alongside existing CSP coverage in test_pwa_manifest_csp.py. Co-authored with Claude Sonnet 4.6 / Anthropic.
…quena#920) * feat: add PWA support (manifest, service worker, install prompt) (v0.50.178, nesquena#911) Co-authored-by: bsgdigital Closes nesquena#685 * fix(sw): await caches.match() before `|| fallback` so offline HTML actually shows The offline-navigation fallback was dead code: return caches.match('./') || new Response('<html>...</html>', ...); `caches.match()` returns a Promise, and Promise objects are always truthy in a `||` check — so the `new Response(...)` branch was never taken. On actual offline, `caches.match('./')` resolves to undefined (no cache hit for the root), the SW returns undefined, and the browser falls back to its own default offline page. The custom "Hermes requires a server connection" HTML was unreachable. Fix by threading the match through `.then()` so the resolved value (not the Promise object) feeds the `||`: return caches.match('./').then((cached) => cached || new Response(...)); Added 13 regression tests in tests/test_pwa_manifest_sw.py covering: - manifest.json validity + required PWA fields + icon existence - sw.js cache-version placeholder + API/stream bypass + correct offline pattern (explicitly rejects the broken `|| new Response` shape so it can't regress) - /manifest.json + /sw.js routes serve correct Content-Type, Cache-Control, Service-Worker-Allowed headers and inject WEBUI_VERSION - index.html links manifest, registers SW, has iOS PWA meta tags Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> --------- Co-authored-by: nesquena-hermes <[email protected]> Co-authored-by: Nathan Esquenazi <[email protected]> Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
PR nesquena#920 added static/manifest.json and sw.js for PWA support. The CSP in _security_headers() had no explicit manifest-src directive, so browsers fell back to default-src 'self' and emitted a console warning on every page load. The fallback is functionally correct but non-compliant with CSP Level 3 best practice of declaring each directive explicitly. Adds manifest-src 'self' before base-uri. No origin set is changed. Regression test added alongside existing CSP coverage in test_pwa_manifest_csp.py. Co-authored with Claude Sonnet 4.6 / Anthropic.
PWA installability for Hermes WebUI. From PR #911 (@bsgdigital), rebased onto current master.
Enables "Add to Home Screen" on Android, iOS, and desktop Chrome:
static/manifest.json— name, standalone display, dark theme color, SVG + 32px iconsstatic/sw.js— cache-first for app shell assets; all/api/*+/stream+/healthalways bypass to network; no offline API caching; cache name injected at request-time with git version for automatic bustingstatic/index.html— manifest link, Apple PWA meta tags, SW registration scriptapi/routes.py— dedicated routes for/manifest.json,/manifest.webmanifest,/sw.jswith correct MIME types andno-storeheadersNo offline mode — the UI requires a live backend. The service worker enables installability and repeat-visit shell caching only.
2003 tests passing.
Closes #685
Co-authored-by: bsgdigital