fix(webui): gate bootstrap API token issuance

This commit is contained in:
chengyongru
2026-07-08 21:01:48 +08:00
committed by Xubin Ren
parent 7204d88a4c
commit 88143a8bf0
16 changed files with 219 additions and 85 deletions
+1 -1
View File
@@ -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
+5 -1
View File
@@ -221,7 +221,7 @@ All fields go under `channels.websocket` in `config.json`.
| `token` | string | `""` | Static shared secret. When set, clients must provide `?token=<value>` 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
+7 -5
View File
@@ -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
{
+30 -5
View File
@@ -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}<redacted>"
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]")
+6
View File
@@ -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
+20 -13
View File
@@ -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 {}
+9 -1
View File
@@ -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
+51 -47
View File
@@ -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:
+2 -4
View File
@@ -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}"},
+7 -1
View File
@@ -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=<redacted>" in compact_output
assert "bootstrapSecret=" in opened_url
def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -> None:
+16 -2
View File
@@ -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"]}
+5 -4
View File
@@ -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]);
+27
View File
@@ -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;
}
+1
View File
@@ -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;
+6
View File
@@ -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,
});
+26 -1
View File
@@ -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&section=models",
);
expect(consumeUrlBootstrapSecret()).toBe("s3cret");
expect(window.location.hash).toBe("#/settings?section=models");
});
});