diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index 298cd8f7f..ef6627b2a 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -717,7 +717,7 @@ async def test_token_issue_route_requires_secret_when_static_token_configured(bu bus, port=port, token="static-token", - tokenIssuePath="/auth/token", + tokenIssuePath="/custom-token", websocketRequiresToken=True, ) @@ -725,15 +725,16 @@ async def test_token_issue_route_requires_secret_when_static_token_configured(bu await asyncio.sleep(0.3) try: - denied = await _http_get(f"http://127.0.0.1:{port}/auth/token") + denied = await _http_get(f"http://127.0.0.1:{port}/custom-token") assert denied.status_code == 401 allowed = await _http_get( - f"http://127.0.0.1:{port}/auth/token", + f"http://127.0.0.1:{port}/custom-token", headers={"Authorization": "Bearer static-token"}, ) assert allowed.status_code == 200 assert allowed.json()["token"].startswith("nbwt_") + assert allowed.headers["Cache-Control"] == "no-store" finally: await channel.stop() await server_task @@ -3803,6 +3804,7 @@ async def test_token_issue_rejects_when_at_capacity(bus: MagicMock) -> None: headers={"Authorization": "Bearer s"}, ) assert resp.status_code == 429 + assert resp.headers["Cache-Control"] == "no-store" data = resp.json() assert "error" in data finally: diff --git a/nanobot/channels/websocket/tests/test_websocket_http_routes.py b/nanobot/channels/websocket/tests/test_websocket_http_routes.py index 4f77b455f..59a1dd31d 100644 --- a/nanobot/channels/websocket/tests/test_websocket_http_routes.py +++ b/nanobot/channels/websocket/tests/test_websocket_http_routes.py @@ -227,6 +227,7 @@ async def test_bootstrap_returns_token_for_localhost( try: resp = await _http_get("http://127.0.0.1:29901/webui/bootstrap") assert resp.status_code == 200 + assert resp.headers["Cache-Control"] == "no-store" body = resp.json() assert body["token"].startswith("nbwt_") assert channel.gateway.tokens.issued_token_audiences[body["token"]] == "webui" diff --git a/nanobot/webui/http_utils.py b/nanobot/webui/http_utils.py index ed17c60ee..f2c9ecbd2 100644 --- a/nanobot/webui/http_utils.py +++ b/nanobot/webui/http_utils.py @@ -100,6 +100,7 @@ def http_json_response( *, status: int = 200, accept_encoding: str | None = None, + extra_headers: list[tuple[str, str]] | None = None, ) -> Response: body = json.dumps(data, ensure_ascii=False).encode("utf-8") headers = [ @@ -112,6 +113,8 @@ def http_json_response( if len(body) >= _JSON_GZIP_MIN_BYTES and accepts_gzip(accept_encoding): body = gzip.compress(body, compresslevel=_JSON_GZIP_LEVEL, mtime=0) headers.append(("Content-Encoding", "gzip")) + if extra_headers: + headers.extend(extra_headers) headers.append(("Content-Length", str(len(body)))) reason = http.HTTPStatus(status).phrase return Response(status, reason, Headers(headers), body) diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 363db6d3f..3b95a0e23 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -121,6 +121,7 @@ from nanobot.webui.workspaces import WebUIWorkspaceController _SLOW_WEBUI_HTTP_LOG_MS = 1_000 _WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload" _WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request" +_NO_STORE_HEADERS = [("Cache-Control", "no-store")] _WEBUI_MUTATION_PATHS = { "automation.enable": "/api/webui/automations/enable", @@ -547,9 +548,16 @@ class GatewayHTTPHandler: "too many outstanding issued tokens ({}), rejecting issuance", len(self.tokens.issued_tokens), ) - return _http_json_response({"error": "too many outstanding tokens"}, status=429) + return _http_json_response( + {"error": "too many outstanding tokens"}, + status=429, + extra_headers=_NO_STORE_HEADERS, + ) token_value = self.tokens.issue_token(self.config.token_ttl_s) - return _http_json_response(token_response_payload(token_value, self.config.token_ttl_s)) + return _http_json_response( + token_response_payload(token_value, self.config.token_ttl_s), + extra_headers=_NO_STORE_HEADERS, + ) # -- Bootstrap ---------------------------------------------------------- @@ -579,7 +587,7 @@ class GatewayHTTPHandler: "runtime_surface": self._runtime_surface, "runtime_capabilities": self._capabilities, } - return _http_json_response(payload) + return _http_json_response(payload, extra_headers=_NO_STORE_HEADERS) api_token_allowed = bool(secret) or is_local_browser if not self.tokens.can_issue(include_api_token=api_token_allowed): @@ -587,6 +595,7 @@ class GatewayHTTPHandler: json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"), status=429, content_type="application/json; charset=utf-8", + extra_headers=_NO_STORE_HEADERS, ) token = self.tokens.issue_token(self.config.token_ttl_s, audience="webui") api_token = ( @@ -611,7 +620,7 @@ class GatewayHTTPHandler: } if api_token is not None: payload["api_token"] = api_token - return _http_json_response(payload) + return _http_json_response(payload, extra_headers=_NO_STORE_HEADERS) def _bootstrap_ws_url(self, request: Any) -> str: headers = getattr(request, "headers", {}) or {} diff --git a/webui/public/sw.js b/webui/public/sw.js index bcbb7c29c..3daab9cee 100644 --- a/webui/public/sw.js +++ b/webui/public/sw.js @@ -1,6 +1,23 @@ -const CACHE_NAME = "nanobot-static-v1"; +const CACHE_PREFIX = "nanobot-static-"; +const CACHE_NAME = `${CACHE_PREFIX}v2`; const ASSET_MANIFEST_PATH = "/asset-manifest.json"; const PRECACHE = ["/", "/manifest.json", ASSET_MANIFEST_PATH]; +const NETWORK_FIRST_STATIC_PATHS = new Set([ + "/", + "/manifest.json", + ASSET_MANIFEST_PATH, + "/brand/nanobot_apple_touch.png", + "/brand/nanobot_favicon_32.png", + "/brand/nanobot_icon_192.png", + "/brand/nanobot_icon_512.png", + "/brand/nanobot_icon_maskable.png", + "/brand/nanobot_mark.svg", +]); + +function responseMayBeCached(response) { + const cacheControl = response.headers?.get("Cache-Control") ?? ""; + return response.ok && !/(?:^|,)\s*(?:private|no-cache|no-store)\b/i.test(cacheControl); +} self.addEventListener("install", (event) => { event.waitUntil( @@ -91,7 +108,7 @@ self.addEventListener("activate", (event) => { .then((keys) => Promise.all( keys - .filter((k) => k !== CACHE_NAME) + .filter((k) => k.startsWith(CACHE_PREFIX) && k !== CACHE_NAME) .map((k) => caches.delete(k)) ) ) @@ -107,28 +124,13 @@ self.addEventListener("fetch", (event) => { // (never reconstructed), so their credentials mode is preserved and gateway // auth cookies flow through on every path we touch. WebSocket upgrades are // never dispatched to a service worker's fetch handler, so the WS endpoint - // cannot be cached; the /__nanobot exclusion below still protects its HTTP - // polling/socket bootstrap endpoints. + // cannot be cached. Unknown HTTP endpoints are passed through below. if (request.method !== "GET") return; if (new URL(request.url).origin !== self.location.origin) return; const url = new URL(request.url); const path = url.pathname; - // Never cache API, auth, WebSocket, HMR, or WebUI endpoint paths. In - // particular /webui/bootstrap issues fresh gateway credentials on every page - // load and must never be cached or replayed offline. The /auth prefix covers - // the default token endpoint; custom token_issue_path values should be kept - // under one of these prefixes. - if ( - path.startsWith("/api") || - path.startsWith("/auth") || - path.startsWith("/__nanobot") || - path.startsWith("/webui") - ) { - return; - } - // Static assets: cache-first. Only files under /assets/ carry content hashes // (the gateway serves them immutable); brand icons, the favicon and other // un-hashed files can change between releases and stay on the network-first @@ -138,7 +140,7 @@ self.addEventListener("fetch", (event) => { caches.match(request).then((cached) => { if (cached) return cached; return fetch(request).then((response) => { - if (response.ok) { + if (responseMayBeCached(response)) { const clone = response.clone(); caches.open(CACHE_NAME).then((c) => c.put(request, clone)); } @@ -149,22 +151,28 @@ self.addEventListener("fetch", (event) => { return; } - // Everything else: network-first (index.html, manifest, brand assets, etc.) + // Cache only explicit public files and browser navigations. Dynamic routes + // are deliberately passed through because token_issue_path and extension + // endpoints are configurable and cannot be safely identified by prefixes. + const isNavigation = request.mode === "navigate"; + if (!isNavigation && !NETWORK_FIRST_STATIC_PATHS.has(path)) return; + + // App shell and public files: network-first with an offline fallback. const networkResponse = fetch(request); event.waitUntil( networkResponse .then(async (response) => { - if (!response.ok) return; + if (!responseMayBeCached(response)) return; // Clone before the first await. The original response is also handed // to respondWith(), which may lock its body as soon as this callback // yields to the event loop. const cachedResponse = response.clone(); const cache = await caches.open(CACHE_NAME); - await cache.put(request, cachedResponse); + await cache.put(isNavigation ? "/" : request, cachedResponse); // Refresh the complete build graph before pruning. A deployment can // change index.html without changing sw.js, so this cannot rely only // on the manifest cached when the worker was installed. - if (path === "/") { + if (isNavigation || path === "/") { if (await refreshAssetManifest(cache)) await pruneStaleEntries(); } }) diff --git a/webui/src/tests/sw.test.ts b/webui/src/tests/sw.test.ts index ec3b07b4e..c76fc9cca 100644 --- a/webui/src/tests/sw.test.ts +++ b/webui/src/tests/sw.test.ts @@ -7,7 +7,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const SW_SCRIPT = readFileSync(resolve(process.cwd(), "public/sw.js"), "utf8"); const ORIGIN = "https://nanobot.test"; -const CACHE_NAME = "nanobot-static-v1"; +const CACHE_NAME = "nanobot-static-v2"; /** Minimal Cache-compatible in-memory store with SW-style URL normalization. */ class FakeCacheStore { @@ -66,7 +66,7 @@ function loadSw(): LoadedSw { const caches = { open: vi.fn(async () => store), match: vi.fn((input: Request | string) => store.match(input)), - keys: vi.fn(async () => [CACHE_NAME, "nanobot-static-v0"]), + keys: vi.fn(async () => [CACHE_NAME, "nanobot-static-v1", "other-app-cache"]), delete: vi.fn(async (name: string) => { deletedCacheNames.push(name); return true; @@ -149,7 +149,7 @@ describe("service worker", () => { await sw.fire("activate"); - expect(sw.deletedCacheNames).toEqual(["nanobot-static-v0"]); + expect(sw.deletedCacheNames).toEqual(["nanobot-static-v1"]); expect(sw.claimMock).toHaveBeenCalledTimes(1); expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true); expect(sw.store.entries.has(`${ORIGIN}/manifest.json`)).toBe(true); @@ -161,7 +161,7 @@ describe("service worker", () => { expect(sw.store.entries.has(`${ORIGIN}/assets/index-v1.css`)).toBe(false); }); - it("does not intercept API, auth, WebSocket, or WebUI endpoint requests", async () => { + it("does not intercept dynamic or unknown endpoint requests", async () => { const sw = loadSw(); const paths = [ "/api/v1/models", @@ -171,6 +171,8 @@ describe("service worker", () => { "/api/chat/completions", "/webui/bootstrap", "/webui/session/list", + "/custom-token", + "/mcp-oauth/callback", ]; for (const path of paths) { @@ -229,6 +231,30 @@ describe("service worker", () => { expect(sw.store.entries.has(assetUrl)).toBe(true); }); + it.each(["private", "no-cache", "no-store"])( + "does not cache a %s response even under the static asset prefix", + async (cacheControl) => { + const sw = loadSw(); + const tokenUrl = `${ORIGIN}/assets/custom-token`; + const originalRequest = new Request(tokenUrl); + sw.fetchMock.mockResolvedValue( + new Response('{"token":"short-lived"}', { + headers: { + "Cache-Control": cacheControl, + "Content-Type": "application/json", + }, + }), + ); + + const event = { request: originalRequest, respondWith: vi.fn() }; + await sw.fire("fetch", event); + + const response = (await event.respondWith.mock.calls[0][0]) as Response; + expect(await response.json()).toEqual({ token: "short-lived" }); + expect(sw.store.entries.has(tokenUrl)).toBe(false); + }, + ); + it("keeps un-hashed brand assets on the network-first path", async () => { const sw = loadSw(); const iconUrl = `${ORIGIN}/brand/nanobot_icon_192.png`;