fix(webui): gate bootstrap API token issuance
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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]")
|
||||
|
||||
@@ -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
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user