diff --git a/docs/quick-start.md b/docs/quick-start.md index f1752048..12d06f4f 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -241,7 +241,7 @@ Start the browser workbench: nanobot webui ``` -`nanobot webui` prepares the local WebSocket channel if needed, starts the gateway, and opens `http://127.0.0.1:8765`. First-run WebUI setup binds to `127.0.0.1` by default, so it is not exposed to your LAN. Use `nanobot webui --background` when you want the gateway to keep running without an open terminal. +`nanobot webui` prepares the local WebSocket channel and WebUI bootstrap secret if needed, starts the gateway, and opens `http://127.0.0.1:8765`. First-run WebUI setup binds to `127.0.0.1` by default, so it is not exposed to your LAN. Use `nanobot webui --background` when you want the gateway to keep running without an open terminal. ## 6. Test One CLI Message diff --git a/docs/websocket.md b/docs/websocket.md index 4bb08576..0d708da9 100644 --- a/docs/websocket.md +++ b/docs/websocket.md @@ -221,7 +221,7 @@ All fields go under `channels.websocket` in `config.json`. | `token` | string | `""` | Static shared secret. When set, clients must provide `?token=` matching this secret (timing-safe comparison). Issued tokens are also accepted as a fallback. | | `websocketRequiresToken` | bool | `true` | When `true` and no static `token` is configured, clients must still present a valid issued token. Set to `false` to allow unauthenticated connections (only safe for local/trusted networks). | | `tokenIssuePath` | string | `""` | HTTP path for issuing short-lived tokens. Must differ from `path`. See [Token Issuance](#token-issuance). | -| `tokenIssueSecret` | string | `""` | Secret required to obtain tokens via the issue endpoint. If empty, any client can obtain tokens (logged as a warning). | +| `tokenIssueSecret` | string | `""` | Secret required to obtain tokens via the issue endpoint. If empty, any client can obtain WebSocket connection tokens from `tokenIssuePath` (logged as a warning), and `/webui/bootstrap` will not return a WebUI REST API token. | | `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 – 86,400). | ### Access Control @@ -266,6 +266,10 @@ For production deployments where `websocketRequiresToken: true`, use short-lived 3. Client opens WebSocket with `?token=nbwt_aBcDeFg...&client_id=...`. 4. The token is consumed (single use) and cannot be reused. +The embedded WebUI's `/webui/bootstrap` route also returns a WebSocket token. +It returns a separate `api_token` for REST routes only after the request proves +knowledge of `tokenIssueSecret` or the static `token`. + ### Example setup ```json diff --git a/docs/webui.md b/docs/webui.md index edda9cbd..deb8090d 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -19,9 +19,10 @@ nanobot webui `nanobot webui` creates the config/workspace when needed, checks provider setup, offers Quick Start when the model provider is not ready, enables the local -WebSocket channel after confirmation, starts the gateway, and opens the browser. -The first-run path binds the WebUI to `127.0.0.1` by default, so it is not -available from other devices on your LAN. +WebSocket channel after confirmation, generates a WebUI bootstrap secret when +one is missing, starts the gateway, and opens the browser. The first-run path +binds the WebUI to `127.0.0.1` by default, so it is not available from other +devices on your LAN. Run it in the background when you do not want to keep a terminal open: @@ -32,8 +33,9 @@ nanobot webui --background Manage the background gateway with `nanobot gateway status`, `nanobot gateway logs`, `nanobot gateway restart`, and `nanobot gateway stop`. -Manual config still works. Set `tokenIssueSecret` when you intentionally expose -the WebUI beyond localhost or want a browser password: +Manual config still works. Set `tokenIssueSecret` for full WebUI access; it is +required before `/webui/bootstrap` returns a REST API token for session, settings, +Apps, Skills, and automation routes: ```json { diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 18e0977f..fb7c36ec 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -925,11 +925,30 @@ def _host_for_local_browser(host: str) -> str: return host +def _webui_bootstrap_secret(config: Config) -> str: + ws_cfg = _webui_config_dict(config) + return str(ws_cfg.get("tokenIssueSecret") or ws_cfg.get("token") or "").strip() + + def _webui_browser_url(config: Config) -> str: + from urllib.parse import quote + ws_cfg = _webui_config_dict(config) host = _host_for_local_browser(str(ws_cfg.get("host") or "127.0.0.1")) port = int(ws_cfg.get("port") or 8765) - return f"http://{host}:{port}" + base_url = f"http://{host}:{port}" + secret = _webui_bootstrap_secret(config) + if not secret: + return base_url + return f"{base_url}/#/?bootstrapSecret={quote(secret, safe='')}" + + +def _webui_display_url(url: str) -> str: + marker = "bootstrapSecret=" + if marker not in url: + return url + prefix, _ = url.split(marker, 1) + return f"{prefix}{marker}" def _ensure_local_webui_channel(config: Config, *, port: int | None, yes: bool) -> bool: @@ -942,7 +961,8 @@ def _ensure_local_webui_channel(config: Config, *, port: int | None, yes: bool) needs_enable = not model.enabled needs_port = port is not None and model.port != port - if not needs_enable and not needs_port: + needs_secret = not model.token_issue_secret.strip() and not model.token.strip() + if not needs_enable and not needs_port and not needs_secret: return False target_port = port if port is not None else model.port @@ -950,11 +970,11 @@ def _ensure_local_webui_channel(config: Config, *, port: int | None, yes: bool) console.print("[bold]Local WebUI setup[/bold]") console.print(f" URL: [cyan]http://127.0.0.1:{target_port}[/cyan]") console.print(" Bind: [cyan]127.0.0.1 only[/cyan] (not exposed to your LAN)") - console.print(" Auth: localhost bootstrap issues short-lived WebSocket tokens") + console.print(" Auth: generated WebUI bootstrap secret stored in config") console.print( " LAN access requires an explicit host change plus a WebUI password in config." ) - _confirm_webui_action("Enable the local WebUI channel in this config?", yes=yes) + _confirm_webui_action("Update the local WebUI channel in this config?", yes=yes) if not model.enabled: model.enabled = True @@ -968,6 +988,11 @@ def _ensure_local_webui_channel(config: Config, *, port: int | None, yes: bool) if not model.websocket_requires_token: model.websocket_requires_token = True changed = True + if needs_secret: + import secrets + + model.token_issue_secret = secrets.token_urlsafe(32) + changed = True setattr(config.channels, "websocket", model.model_dump(by_alias=True, exclude_none=True)) return changed @@ -1250,7 +1275,7 @@ def webui( effective_gateway_port = gateway_port if gateway_port is not None else runtime_config.gateway.port console.print() - console.print(f"WebUI: [cyan]{webui_url}[/cyan]") + console.print(f"WebUI: [cyan]{_webui_display_url(webui_url)}[/cyan]") console.print(f"Gateway health: [cyan]http://{runtime_config.gateway.host}:{effective_gateway_port}/health[/cyan]") if no_open: console.print("[dim]Browser opening disabled by --no-open.[/dim]") diff --git a/nanobot/webui/gateway_tokens.py b/nanobot/webui/gateway_tokens.py index a7a5b590..925ae81a 100644 --- a/nanobot/webui/gateway_tokens.py +++ b/nanobot/webui/gateway_tokens.py @@ -50,6 +50,12 @@ class GatewayTokenStore: self.api_tokens[token_value] = expiry return token_value + def issue_api_token(self, ttl_s: int | float) -> str: + token_value = f"nbwt_{secrets.token_urlsafe(32)}" + expiry = time.monotonic() + float(ttl_s) + self.api_tokens[token_value] = expiry + return token_value + def take_issued_token_if_valid(self, token_value: str | None) -> bool: if not token_value: return False diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 24b3b6ce..1eeb192c 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -306,33 +306,40 @@ class GatewayHTTPHandler: def _handle_bootstrap(self, connection: Any, request: Any) -> Response: secret = self.config.token_issue_secret.strip() or self.config.token.strip() + api_token_allowed = bool(secret) if secret: if not _issue_route_secret_matches(request.headers, secret): return _http_error(401, "Unauthorized") elif not _is_localhost(connection): return _http_error(403, "bootstrap is localhost-only") - if not self.tokens.can_issue(include_api_token=True): + if not self.tokens.can_issue(include_api_token=api_token_allowed): return _http_response( json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"), status=429, content_type="application/json; charset=utf-8", ) - token = self.tokens.issue_token(self.config.token_ttl_s, api_token=True) + token = self.tokens.issue_token(self.config.token_ttl_s) + api_token = ( + self.tokens.issue_api_token(self.config.token_ttl_s) + if api_token_allowed + else None + ) ws_url = self._bootstrap_ws_url(request) expected_path = _normalize_config_path(self.config.path) - return _http_json_response( - { - "token": token, - "ws_path": expected_path, - "ws_url": ws_url, - "expires_in": self.config.token_ttl_s, - "model_name": _resolve_bootstrap_model_name(self.runtime_model_name), - "runtime_surface": self._runtime_surface, - "runtime_capabilities": self._capabilities, - } - ) + payload = { + "token": token, + "ws_path": expected_path, + "ws_url": ws_url, + "expires_in": self.config.token_ttl_s, + "model_name": _resolve_bootstrap_model_name(self.runtime_model_name), + "runtime_surface": self._runtime_surface, + "runtime_capabilities": self._capabilities, + } + if api_token is not None: + payload["api_token"] = api_token + return _http_json_response(payload) def _bootstrap_ws_url(self, request: Any) -> str: headers = getattr(request, "headers", {}) or {} diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 4225b465..b8da34e7 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -87,6 +87,7 @@ def _basic_handler(bus: Any, **kw: Any) -> GatewayServices: "enabled": True, "allowFrom": ["*"], "host": "127.0.0.1", "port": _PORT, "path": "/ws", "websocketRequiresToken": False, + "tokenIssueSecret": kw.get("token_issue_secret", ""), }) return build_gateway_services( config=cfg, @@ -2099,7 +2100,12 @@ async def test_bootstrap_exposes_native_surface(bus: MagicMock) -> None: "websocketRequiresToken": True, }, bus, - gateway=_basic_handler(bus, runtime_surface="native", runtime_capabilities_overrides={"can_pick_folder": True}), + gateway=_basic_handler( + bus, + token_issue_secret="native-secret", + runtime_surface="native", + runtime_capabilities_overrides={"can_pick_folder": True}, + ), ) server_task = asyncio.create_task(channel.start()) @@ -2116,6 +2122,8 @@ async def test_bootstrap_exposes_native_surface(bus: MagicMock) -> None: assert body["runtime_capabilities"]["can_pick_folder"] is True assert body["runtime_capabilities"]["can_restart_engine"] is True assert body["token"].startswith("nbwt_") + assert body["api_token"].startswith("nbwt_") + assert body["api_token"] != body["token"] finally: await channel.stop() await server_task diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 2d5f42d1..a0500468 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -203,6 +203,7 @@ async def test_bootstrap_returns_token_for_localhost( assert resp.status_code == 200 body = resp.json() assert body["token"].startswith("nbwt_") + assert "api_token" not in body assert body["ws_path"] == "/" assert body["ws_url"] == "ws://127.0.0.1:29901/" assert body["expires_in"] > 0 @@ -225,9 +226,8 @@ async def test_sessions_routes_require_bearer_token( deny = await _http_get("http://127.0.0.1:29902/api/sessions") assert deny.status_code == 401 - # Mint a token via bootstrap, then call the API with it. - boot = await _http_get("http://127.0.0.1:29902/webui/bootstrap") - token = boot.json()["token"] + # Directly mint an API token for route-level auth checks. + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} listing = await _http_get("http://127.0.0.1:29902/api/sessions", headers=auth) @@ -303,8 +303,7 @@ async def test_session_automations_route_filters_by_webui_session( ) assert deny.status_code == 401 - boot = await _http_get("http://127.0.0.1:29914/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} resp = await _http_get( "http://127.0.0.1:29914/api/sessions/websocket%3Aabc/automations", @@ -356,8 +355,7 @@ async def test_session_automations_route_ignores_unified_owner( server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) try: - boot = await _http_get("http://127.0.0.1:29917/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} resp = await _http_get( @@ -403,8 +401,7 @@ async def test_session_automations_route_lists_local_triggers( server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) try: - boot = await _http_get(f"{base_url}/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} resp = await _http_get( @@ -469,8 +466,7 @@ async def test_webui_skills_route_requires_token_and_hides_paths( deny_detail = await _http_get("http://127.0.0.1:29920/api/webui/skills/workspace-skill") assert deny_detail.status_code == 401 - boot = await _http_get("http://127.0.0.1:29920/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) resp = await _http_get( "http://127.0.0.1:29920/api/webui/skills", headers={"Authorization": f"Bearer {token}"}, @@ -566,8 +562,7 @@ async def test_cli_apps_routes_require_token_and_return_payload( deny = await _http_get("http://127.0.0.1:29912/api/settings/cli-apps") assert deny.status_code == 401 - boot = await _http_get("http://127.0.0.1:29912/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} catalog = await _http_get( @@ -603,8 +598,7 @@ async def test_nanobot_feature_routes_require_token_and_enable( deny = await _http_get("http://127.0.0.1:29916/api/settings/nanobot-features") assert deny.status_code == 401 - boot = await _http_get("http://127.0.0.1:29916/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} catalog = await _http_get( @@ -875,8 +869,7 @@ async def test_cli_apps_catalog_does_not_block_other_webui_http_routes( server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) try: - boot = await _http_get("http://127.0.0.1:29935/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} catalog_task = asyncio.create_task( @@ -917,8 +910,7 @@ async def test_cli_apps_route_supports_installed_only_payload( server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) try: - boot = await _http_get("http://127.0.0.1:29936/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} resp = await _http_get( @@ -1014,8 +1006,7 @@ async def test_mcp_presets_routes_require_token_and_return_payload( deny = await _http_get("http://127.0.0.1:29913/api/settings/mcp-presets") assert deny.status_code == 401 - boot = await _http_get("http://127.0.0.1:29913/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} catalog = await _http_get( @@ -1104,8 +1095,7 @@ async def test_sessions_list_only_returns_websocket_sessions_by_default( server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) try: - boot = await _http_get("http://127.0.0.1:29906/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} listing = await _http_get( @@ -1131,8 +1121,7 @@ async def test_webui_sidebar_state_routes_are_config_dir_scoped( server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) try: - boot = await _http_get("http://127.0.0.1:29911/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} initial = await _http_get( @@ -1183,8 +1172,7 @@ async def test_session_delete_removes_file( server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) try: - boot = await _http_get("http://127.0.0.1:29903/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} path = sm._get_session_path("websocket:doomed") @@ -1267,8 +1255,7 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( deny = await _http_get(f"{base_url}/api/webui/automations") assert deny.status_code == 401, deny.text - boot = await _http_get(f"{base_url}/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} resp = await _http_get( f"{base_url}/api/webui/automations", @@ -1480,8 +1467,7 @@ async def test_webui_automations_route_manages_local_triggers( server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) try: - boot = await _http_get(f"{base_url}/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} listed = await _http_get(f"{base_url}/api/webui/automations", headers=auth) @@ -1559,8 +1545,7 @@ async def test_session_delete_blocks_when_bound_automation_exists( server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) try: - boot = await _http_get("http://127.0.0.1:29915/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} path = sm._get_session_path("websocket:doomed") @@ -1605,8 +1590,7 @@ async def test_session_delete_blocks_and_cascades_local_triggers( server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) try: - boot = await _http_get(f"{base_url}/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} blocked = await _http_get( @@ -1655,8 +1639,7 @@ async def test_session_delete_can_cascade_bound_automations( server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) try: - boot = await _http_get("http://127.0.0.1:29916/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} path = sm._get_session_path("websocket:doomed") @@ -1699,8 +1682,7 @@ async def test_session_delete_blocks_origin_automation_when_unified_enabled( server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) try: - boot = await _http_get("http://127.0.0.1:29918/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} path = sm._get_session_path("websocket:doomed") @@ -1732,8 +1714,7 @@ async def test_session_routes_accept_percent_encoded_websocket_keys( server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) try: - boot = await _http_get("http://127.0.0.1:29910/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} msgs = await _http_get( @@ -1794,8 +1775,7 @@ async def test_webui_thread_resigns_assistant_media_urls( server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) try: - boot = await _http_get("http://127.0.0.1:29914/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} resp = await _http_get( "http://127.0.0.1:29914/api/sessions/websocket:video-replay/webui-thread", @@ -1833,8 +1813,7 @@ async def test_session_routes_reject_non_websocket_keys( server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) try: - boot = await _http_get("http://127.0.0.1:29909/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} # The webui list already hides non-websocket sessions; handcrafted URLs @@ -1867,8 +1846,7 @@ async def test_session_routes_reject_invalid_key( server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) try: - boot = await _http_get("http://127.0.0.1:29904/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} # Invalid characters in the key -> regex match fails -> 404 @@ -2074,6 +2052,8 @@ def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None: assert resp.status_code == 200 body = json.loads(resp.body) assert body["token"].startswith("nbwt_") + assert body["api_token"].startswith("nbwt_") + assert body["api_token"] != body["token"] def test_bootstrap_ws_url_uses_forwarded_https_host(bus: MagicMock) -> None: @@ -2091,6 +2071,30 @@ def test_localhost_without_auth_is_valid(bus: MagicMock) -> None: channel = _ch(bus, host="127.0.0.1") resp = channel.gateway.http._handle_bootstrap(_LOCAL, _NO_HEADERS) assert resp.status_code == 200 + body = json.loads(resp.body) + assert body["token"].startswith("nbwt_") + assert "api_token" not in body + assert not channel.gateway.tokens.check_api_token( + _FakeReq({"Authorization": f"Bearer {body['token']}"}) + ) + + +def test_authenticated_bootstrap_returns_distinct_api_token(bus: MagicMock) -> None: + channel = _ch(bus, host="127.0.0.1", tokenIssueSecret="s3cret") + resp = channel.gateway.http._handle_bootstrap( + _LOCAL, _FakeReq({"Authorization": "Bearer s3cret"}) + ) + assert resp.status_code == 200 + body = json.loads(resp.body) + assert body["token"].startswith("nbwt_") + assert body["api_token"].startswith("nbwt_") + assert body["api_token"] != body["token"] + assert not channel.gateway.tokens.check_api_token( + _FakeReq({"Authorization": f"Bearer {body['token']}"}) + ) + assert channel.gateway.tokens.check_api_token( + _FakeReq({"Authorization": f"Bearer {body['api_token']}"}) + ) def test_bootstrap_prefers_runtime_model_name(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/channels/test_websocket_media_route.py b/tests/channels/test_websocket_media_route.py index 0b8b7cd6..037e420e 100644 --- a/tests/channels/test_websocket_media_route.py +++ b/tests/channels/test_websocket_media_route.py @@ -517,8 +517,7 @@ async def test_session_messages_exposes_signed_media_urls( server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) try: - boot = await _http_get("http://127.0.0.1:29925/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) auth = {"Authorization": f"Bearer {token}"} resp = await _http_get( "http://127.0.0.1:29925/api/sessions/websocket:media-hydrate/messages", @@ -562,8 +561,7 @@ async def test_session_messages_skips_vanished_media( server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) try: - boot = await _http_get("http://127.0.0.1:29926/webui/bootstrap") - token = boot.json()["token"] + token = channel.gateway.tokens.issue_api_token(300) resp = await _http_get( "http://127.0.0.1:29926/api/sessions/websocket:vanished/messages", headers={"Authorization": f"Bearer {token}"}, diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 160835c8..e5b6a0a7 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -1667,6 +1667,8 @@ def test_webui_yes_creates_config_and_enables_local_websocket( assert websocket["host"] == "127.0.0.1" assert websocket["port"] == 8899 assert websocket["websocketRequiresToken"] is True + assert isinstance(websocket["tokenIssueSecret"], str) + assert len(websocket["tokenIssueSecret"]) >= 32 assert data["agents"]["defaults"]["workspace"] == str(workspace) assert seen["templates"] == workspace assert seen["gateway_kwargs"] == {"port": 18888, "open_browser_url": None} @@ -1744,7 +1746,11 @@ def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path assert options.port == 18889 assert options.config_path == str(config_file.resolve(strict=False)) assert options.workspace == str(workspace.resolve(strict=False)) - assert seen["opened_url"] == "http://127.0.0.1:8765" + opened_url = seen["opened_url"] + assert isinstance(opened_url, str) + assert opened_url.startswith("http://127.0.0.1:8765/#/?bootstrapSecret=") + assert "bootstrapSecret=" in compact_output + assert "bootstrapSecret=" in opened_url def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -> None: diff --git a/tests/webui/test_gateway_webui_smoke.py b/tests/webui/test_gateway_webui_smoke.py index 56e304a9..e0cf1614 100644 --- a/tests/webui/test_gateway_webui_smoke.py +++ b/tests/webui/test_gateway_webui_smoke.py @@ -15,6 +15,8 @@ import httpx import pytest import websockets +_BOOTSTRAP_SECRET = "smoke-secret" + def _free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: @@ -45,6 +47,7 @@ def _write_smoke_config(path: Path, *, workspace: Path, ws_port: int, gateway_po "host": "127.0.0.1", "port": ws_port, "allowFrom": ["*"], + "tokenIssueSecret": _BOOTSTRAP_SECRET, } }, "gateway": { @@ -95,6 +98,17 @@ def _get_json(url: str, *, token: str | None = None) -> dict: return response.json() +def _get_bootstrap(url: str) -> dict: + response = httpx.get( + url, + headers={"X-Nanobot-Auth": _BOOTSTRAP_SECRET}, + timeout=5.0, + trust_env=False, + ) + response.raise_for_status() + return response.json() + + def _wait_for_bootstrap(base_url: str, process: subprocess.Popen[bytes], log_path: Path) -> dict: deadline = time.monotonic() + 20 last_error: Exception | None = None @@ -102,7 +116,7 @@ def _wait_for_bootstrap(base_url: str, process: subprocess.Popen[bytes], log_pat if process.poll() is not None: break try: - return _get_json(f"{base_url}/webui/bootstrap") + return _get_bootstrap(f"{base_url}/webui/bootstrap") except (httpx.HTTPError, OSError) as exc: last_error = exc time.sleep(0.2) @@ -162,7 +176,7 @@ async def test_gateway_webui_bootstrap_message_and_thread_hydration(tmp_path: Pa assert "Current model: `custom/smoke-model`" in answer["text"] await _recv_until(ws, "turn_end") - api_token = _wait_for_bootstrap(base_url, process, log_path)["token"] + api_token = _wait_for_bootstrap(base_url, process, log_path)["api_token"] sessions = _get_json(f"{base_url}/api/sessions", token=api_token) key = f"websocket:{chat_id}" assert key in {row["key"] for row in sessions["sessions"]} diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 13beb114..724a74ca 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -24,6 +24,7 @@ import { ThemeProvider, useTheme } from "@/hooks/useTheme"; import { cn } from "@/lib/utils"; import { clearSavedSecret, + consumeUrlBootstrapSecret, deriveWsUrl, fetchBootstrap, loadSavedSecret, @@ -361,14 +362,14 @@ export default function App() { current.status === "ready" && current.client === client ? { ...current, - token: boot.token, + token: boot.api_token, tokenExpiresAt, modelName: boot.model_name ?? current.modelName, runtimeSurface, } : current, ); - return { token: boot.token, url }; + return { token: boot.api_token, url }; }, [], ); @@ -402,7 +403,7 @@ export default function App() { setState({ status: "ready", client, - token: boot.token, + token: boot.api_token, tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in), modelName: boot.model_name ?? null, runtimeSurface, @@ -441,7 +442,7 @@ export default function App() { }, [refreshReadyClient, state]); useEffect(() => { - const saved = loadSavedSecret(); + const saved = consumeUrlBootstrapSecret() || loadSavedSecret(); return bootstrapWithSecret(saved); }, [bootstrapWithSecret]); diff --git a/webui/src/lib/bootstrap.ts b/webui/src/lib/bootstrap.ts index 483e4740..5a9053fb 100644 --- a/webui/src/lib/bootstrap.ts +++ b/webui/src/lib/bootstrap.ts @@ -2,6 +2,7 @@ import type { BootstrapResponse } from "./types"; import { fetchWithTimeout } from "./http"; const SECRET_STORAGE_KEY = "nanobot-webui.bootstrap-secret"; +const URL_SECRET_PARAM = "bootstrapSecret"; /** Read a previously saved bootstrap secret from localStorage. */ export function loadSavedSecret(): string { @@ -31,6 +32,29 @@ export function clearSavedSecret(): void { } } +export function consumeUrlBootstrapSecret(): string { + if (typeof window === "undefined") return ""; + const hash = window.location.hash || ""; + const queryStart = hash.indexOf("?"); + if (queryStart < 0) return ""; + + const path = hash.slice(0, queryStart) || "#/"; + const query = hash.slice(queryStart + 1); + const params = new URLSearchParams(query); + const secret = params.get(URL_SECRET_PARAM)?.trim() || ""; + if (!secret) return ""; + + params.delete(URL_SECRET_PARAM); + const nextQuery = params.toString(); + const nextHash = `${path}${nextQuery ? `?${nextQuery}` : ""}`; + window.history.replaceState( + null, + "", + `${window.location.pathname}${window.location.search}${nextHash}`, + ); + return secret; +} + /** * Fetch a short-lived token + the WebSocket path from the gateway's * ``/webui/bootstrap`` endpoint. @@ -56,6 +80,9 @@ export async function fetchBootstrap( if (!body.token || !body.ws_path) { throw new Error("bootstrap response missing token or ws_path"); } + if (!body.api_token) { + throw new Error("bootstrap response missing api_token"); + } return body; } diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index ad61ddc8..f9ca7308 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -296,6 +296,7 @@ export interface SidebarStatePayload { export interface BootstrapResponse { token: string; + api_token: string; ws_path: string; ws_url?: string | null; expires_in: number; diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 16253e3e..f4940daa 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -177,10 +177,12 @@ vi.mock("@/hooks/useTheme", async () => { vi.mock("@/lib/bootstrap", () => ({ fetchBootstrap: vi.fn().mockResolvedValue({ token: "tok", + api_token: "api-tok", ws_path: "/", expires_in: 300, }), deriveWsUrl: vi.fn(() => "ws://test"), + consumeUrlBootstrapSecret: vi.fn(() => ""), loadSavedSecret: vi.fn(() => ""), saveSecret: vi.fn(), clearSavedSecret: vi.fn(), @@ -239,6 +241,7 @@ describe("App layout", () => { localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1"); vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({ token: "tok", + api_token: "api-tok", ws_path: "/", expires_in: 300, }); @@ -723,6 +726,7 @@ describe("App layout", () => { ]; vi.mocked(fetchBootstrap).mockResolvedValue({ token: "tok", + api_token: "api-tok", ws_path: "/", expires_in: 300, runtime_surface: "native", @@ -2147,11 +2151,13 @@ describe("App layout", () => { vi.mocked(fetchBootstrap) .mockResolvedValueOnce({ token: "tok-1", + api_token: "api-tok-1", ws_path: "/", expires_in: 30, }) .mockResolvedValueOnce({ token: "tok-2", + api_token: "api-tok-2", ws_path: "/", expires_in: 300, }); diff --git a/webui/src/tests/bootstrap.test.ts b/webui/src/tests/bootstrap.test.ts index 8bac034e..c2240583 100644 --- a/webui/src/tests/bootstrap.test.ts +++ b/webui/src/tests/bootstrap.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap"; +import { consumeUrlBootstrapSecret, deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap"; describe("bootstrap helpers", () => { afterEach(() => { @@ -50,4 +50,29 @@ describe("bootstrap helpers", () => { await pending; }); + + it("rejects bootstrap responses without an API token", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ token: "ws-token", ws_path: "/", expires_in: 300 }), + })), + ); + + await expect(fetchBootstrap()).rejects.toThrow( + "bootstrap response missing api_token", + ); + }); + + it("consumes bootstrap secrets from the URL fragment", () => { + window.history.replaceState( + null, + "", + "/#/settings?bootstrapSecret=s3cret§ion=models", + ); + + expect(consumeUrlBootstrapSecret()).toBe("s3cret"); + expect(window.location.hash).toBe("#/settings?section=models"); + }); });