mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
fix(webui): gate bootstrap API token issuance
This commit is contained in:
parent
7204d88a4c
commit
88143a8bf0
@ -241,7 +241,7 @@ Start the browser workbench:
|
|||||||
nanobot webui
|
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
|
## 6. Test One CLI Message
|
||||||
|
|
||||||
|
|||||||
@ -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. |
|
| `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). |
|
| `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). |
|
| `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). |
|
| `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 – 86,400). |
|
||||||
|
|
||||||
### Access Control
|
### 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=...`.
|
3. Client opens WebSocket with `?token=nbwt_aBcDeFg...&client_id=...`.
|
||||||
4. The token is consumed (single use) and cannot be reused.
|
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
|
### Example setup
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@ -19,9 +19,10 @@ nanobot webui
|
|||||||
|
|
||||||
`nanobot webui` creates the config/workspace when needed, checks provider setup,
|
`nanobot webui` creates the config/workspace when needed, checks provider setup,
|
||||||
offers Quick Start when the model provider is not ready, enables the local
|
offers Quick Start when the model provider is not ready, enables the local
|
||||||
WebSocket channel after confirmation, starts the gateway, and opens the browser.
|
WebSocket channel after confirmation, generates a WebUI bootstrap secret when
|
||||||
The first-run path binds the WebUI to `127.0.0.1` by default, so it is not
|
one is missing, starts the gateway, and opens the browser. The first-run path
|
||||||
available from other devices on your LAN.
|
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:
|
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
|
Manage the background gateway with `nanobot gateway status`, `nanobot gateway
|
||||||
logs`, `nanobot gateway restart`, and `nanobot gateway stop`.
|
logs`, `nanobot gateway restart`, and `nanobot gateway stop`.
|
||||||
|
|
||||||
Manual config still works. Set `tokenIssueSecret` when you intentionally expose
|
Manual config still works. Set `tokenIssueSecret` for full WebUI access; it is
|
||||||
the WebUI beyond localhost or want a browser password:
|
required before `/webui/bootstrap` returns a REST API token for session, settings,
|
||||||
|
Apps, Skills, and automation routes:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@ -925,11 +925,30 @@ def _host_for_local_browser(host: str) -> str:
|
|||||||
return host
|
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:
|
def _webui_browser_url(config: Config) -> str:
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
ws_cfg = _webui_config_dict(config)
|
ws_cfg = _webui_config_dict(config)
|
||||||
host = _host_for_local_browser(str(ws_cfg.get("host") or "127.0.0.1"))
|
host = _host_for_local_browser(str(ws_cfg.get("host") or "127.0.0.1"))
|
||||||
port = int(ws_cfg.get("port") or 8765)
|
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:
|
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_enable = not model.enabled
|
||||||
needs_port = port is not None and model.port != port
|
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
|
return False
|
||||||
|
|
||||||
target_port = port if port is not None else model.port
|
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("[bold]Local WebUI setup[/bold]")
|
||||||
console.print(f" URL: [cyan]http://127.0.0.1:{target_port}[/cyan]")
|
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(" 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(
|
console.print(
|
||||||
" LAN access requires an explicit host change plus a WebUI password in config."
|
" 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:
|
if not model.enabled:
|
||||||
model.enabled = True
|
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:
|
if not model.websocket_requires_token:
|
||||||
model.websocket_requires_token = True
|
model.websocket_requires_token = True
|
||||||
changed = 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))
|
setattr(config.channels, "websocket", model.model_dump(by_alias=True, exclude_none=True))
|
||||||
return changed
|
return changed
|
||||||
@ -1250,7 +1275,7 @@ def webui(
|
|||||||
effective_gateway_port = gateway_port if gateway_port is not None else runtime_config.gateway.port
|
effective_gateway_port = gateway_port if gateway_port is not None else runtime_config.gateway.port
|
||||||
|
|
||||||
console.print()
|
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]")
|
console.print(f"Gateway health: [cyan]http://{runtime_config.gateway.host}:{effective_gateway_port}/health[/cyan]")
|
||||||
if no_open:
|
if no_open:
|
||||||
console.print("[dim]Browser opening disabled by --no-open.[/dim]")
|
console.print("[dim]Browser opening disabled by --no-open.[/dim]")
|
||||||
|
|||||||
@ -50,6 +50,12 @@ class GatewayTokenStore:
|
|||||||
self.api_tokens[token_value] = expiry
|
self.api_tokens[token_value] = expiry
|
||||||
return token_value
|
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:
|
def take_issued_token_if_valid(self, token_value: str | None) -> bool:
|
||||||
if not token_value:
|
if not token_value:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@ -306,33 +306,40 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
def _handle_bootstrap(self, connection: Any, request: Any) -> Response:
|
def _handle_bootstrap(self, connection: Any, request: Any) -> Response:
|
||||||
secret = self.config.token_issue_secret.strip() or self.config.token.strip()
|
secret = self.config.token_issue_secret.strip() or self.config.token.strip()
|
||||||
|
api_token_allowed = bool(secret)
|
||||||
if secret:
|
if secret:
|
||||||
if not _issue_route_secret_matches(request.headers, secret):
|
if not _issue_route_secret_matches(request.headers, secret):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
elif not _is_localhost(connection):
|
elif not _is_localhost(connection):
|
||||||
return _http_error(403, "bootstrap is localhost-only")
|
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(
|
return _http_response(
|
||||||
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
|
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
|
||||||
status=429,
|
status=429,
|
||||||
content_type="application/json; charset=utf-8",
|
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)
|
ws_url = self._bootstrap_ws_url(request)
|
||||||
expected_path = _normalize_config_path(self.config.path)
|
expected_path = _normalize_config_path(self.config.path)
|
||||||
return _http_json_response(
|
payload = {
|
||||||
{
|
"token": token,
|
||||||
"token": token,
|
"ws_path": expected_path,
|
||||||
"ws_path": expected_path,
|
"ws_url": ws_url,
|
||||||
"ws_url": ws_url,
|
"expires_in": self.config.token_ttl_s,
|
||||||
"expires_in": self.config.token_ttl_s,
|
"model_name": _resolve_bootstrap_model_name(self.runtime_model_name),
|
||||||
"model_name": _resolve_bootstrap_model_name(self.runtime_model_name),
|
"runtime_surface": self._runtime_surface,
|
||||||
"runtime_surface": self._runtime_surface,
|
"runtime_capabilities": self._capabilities,
|
||||||
"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:
|
def _bootstrap_ws_url(self, request: Any) -> str:
|
||||||
headers = getattr(request, "headers", {}) or {}
|
headers = getattr(request, "headers", {}) or {}
|
||||||
|
|||||||
@ -87,6 +87,7 @@ def _basic_handler(bus: Any, **kw: Any) -> GatewayServices:
|
|||||||
"enabled": True, "allowFrom": ["*"],
|
"enabled": True, "allowFrom": ["*"],
|
||||||
"host": "127.0.0.1", "port": _PORT,
|
"host": "127.0.0.1", "port": _PORT,
|
||||||
"path": "/ws", "websocketRequiresToken": False,
|
"path": "/ws", "websocketRequiresToken": False,
|
||||||
|
"tokenIssueSecret": kw.get("token_issue_secret", ""),
|
||||||
})
|
})
|
||||||
return build_gateway_services(
|
return build_gateway_services(
|
||||||
config=cfg,
|
config=cfg,
|
||||||
@ -2099,7 +2100,12 @@ async def test_bootstrap_exposes_native_surface(bus: MagicMock) -> None:
|
|||||||
"websocketRequiresToken": True,
|
"websocketRequiresToken": True,
|
||||||
},
|
},
|
||||||
bus,
|
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())
|
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_pick_folder"] is True
|
||||||
assert body["runtime_capabilities"]["can_restart_engine"] is True
|
assert body["runtime_capabilities"]["can_restart_engine"] is True
|
||||||
assert body["token"].startswith("nbwt_")
|
assert body["token"].startswith("nbwt_")
|
||||||
|
assert body["api_token"].startswith("nbwt_")
|
||||||
|
assert body["api_token"] != body["token"]
|
||||||
finally:
|
finally:
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
await server_task
|
await server_task
|
||||||
|
|||||||
@ -203,6 +203,7 @@ async def test_bootstrap_returns_token_for_localhost(
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["token"].startswith("nbwt_")
|
assert body["token"].startswith("nbwt_")
|
||||||
|
assert "api_token" not in body
|
||||||
assert body["ws_path"] == "/"
|
assert body["ws_path"] == "/"
|
||||||
assert body["ws_url"] == "ws://127.0.0.1:29901/"
|
assert body["ws_url"] == "ws://127.0.0.1:29901/"
|
||||||
assert body["expires_in"] > 0
|
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")
|
deny = await _http_get("http://127.0.0.1:29902/api/sessions")
|
||||||
assert deny.status_code == 401
|
assert deny.status_code == 401
|
||||||
|
|
||||||
# Mint a token via bootstrap, then call the API with it.
|
# Directly mint an API token for route-level auth checks.
|
||||||
boot = await _http_get("http://127.0.0.1:29902/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
listing = await _http_get("http://127.0.0.1:29902/api/sessions", headers=auth)
|
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
|
assert deny.status_code == 401
|
||||||
|
|
||||||
boot = await _http_get("http://127.0.0.1:29914/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
resp = await _http_get(
|
resp = await _http_get(
|
||||||
"http://127.0.0.1:29914/api/sessions/websocket%3Aabc/automations",
|
"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())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
try:
|
try:
|
||||||
boot = await _http_get("http://127.0.0.1:29917/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
resp = await _http_get(
|
resp = await _http_get(
|
||||||
@ -403,8 +401,7 @@ async def test_session_automations_route_lists_local_triggers(
|
|||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
try:
|
try:
|
||||||
boot = await _http_get(f"{base_url}/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
resp = await _http_get(
|
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")
|
deny_detail = await _http_get("http://127.0.0.1:29920/api/webui/skills/workspace-skill")
|
||||||
assert deny_detail.status_code == 401
|
assert deny_detail.status_code == 401
|
||||||
|
|
||||||
boot = await _http_get("http://127.0.0.1:29920/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
resp = await _http_get(
|
resp = await _http_get(
|
||||||
"http://127.0.0.1:29920/api/webui/skills",
|
"http://127.0.0.1:29920/api/webui/skills",
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
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")
|
deny = await _http_get("http://127.0.0.1:29912/api/settings/cli-apps")
|
||||||
assert deny.status_code == 401
|
assert deny.status_code == 401
|
||||||
|
|
||||||
boot = await _http_get("http://127.0.0.1:29912/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
catalog = await _http_get(
|
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")
|
deny = await _http_get("http://127.0.0.1:29916/api/settings/nanobot-features")
|
||||||
assert deny.status_code == 401
|
assert deny.status_code == 401
|
||||||
|
|
||||||
boot = await _http_get("http://127.0.0.1:29916/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
catalog = await _http_get(
|
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())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
try:
|
try:
|
||||||
boot = await _http_get("http://127.0.0.1:29935/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
catalog_task = asyncio.create_task(
|
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())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
try:
|
try:
|
||||||
boot = await _http_get("http://127.0.0.1:29936/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
resp = await _http_get(
|
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")
|
deny = await _http_get("http://127.0.0.1:29913/api/settings/mcp-presets")
|
||||||
assert deny.status_code == 401
|
assert deny.status_code == 401
|
||||||
|
|
||||||
boot = await _http_get("http://127.0.0.1:29913/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
catalog = await _http_get(
|
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())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
try:
|
try:
|
||||||
boot = await _http_get("http://127.0.0.1:29906/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
listing = await _http_get(
|
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())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
try:
|
try:
|
||||||
boot = await _http_get("http://127.0.0.1:29911/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
initial = await _http_get(
|
initial = await _http_get(
|
||||||
@ -1183,8 +1172,7 @@ async def test_session_delete_removes_file(
|
|||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
try:
|
try:
|
||||||
boot = await _http_get("http://127.0.0.1:29903/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
path = sm._get_session_path("websocket:doomed")
|
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")
|
deny = await _http_get(f"{base_url}/api/webui/automations")
|
||||||
assert deny.status_code == 401, deny.text
|
assert deny.status_code == 401, deny.text
|
||||||
|
|
||||||
boot = await _http_get(f"{base_url}/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
resp = await _http_get(
|
resp = await _http_get(
|
||||||
f"{base_url}/api/webui/automations",
|
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())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
try:
|
try:
|
||||||
boot = await _http_get(f"{base_url}/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
listed = await _http_get(f"{base_url}/api/webui/automations", headers=auth)
|
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())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
try:
|
try:
|
||||||
boot = await _http_get("http://127.0.0.1:29915/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
path = sm._get_session_path("websocket:doomed")
|
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())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
try:
|
try:
|
||||||
boot = await _http_get(f"{base_url}/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
blocked = await _http_get(
|
blocked = await _http_get(
|
||||||
@ -1655,8 +1639,7 @@ async def test_session_delete_can_cascade_bound_automations(
|
|||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
try:
|
try:
|
||||||
boot = await _http_get("http://127.0.0.1:29916/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
path = sm._get_session_path("websocket:doomed")
|
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())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
try:
|
try:
|
||||||
boot = await _http_get("http://127.0.0.1:29918/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
path = sm._get_session_path("websocket:doomed")
|
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())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
try:
|
try:
|
||||||
boot = await _http_get("http://127.0.0.1:29910/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
msgs = await _http_get(
|
msgs = await _http_get(
|
||||||
@ -1794,8 +1775,7 @@ async def test_webui_thread_resigns_assistant_media_urls(
|
|||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
try:
|
try:
|
||||||
boot = await _http_get("http://127.0.0.1:29914/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
resp = await _http_get(
|
resp = await _http_get(
|
||||||
"http://127.0.0.1:29914/api/sessions/websocket:video-replay/webui-thread",
|
"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())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
try:
|
try:
|
||||||
boot = await _http_get("http://127.0.0.1:29909/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
# The webui list already hides non-websocket sessions; handcrafted URLs
|
# 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())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
try:
|
try:
|
||||||
boot = await _http_get("http://127.0.0.1:29904/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
# Invalid characters in the key -> regex match fails -> 404
|
# 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
|
assert resp.status_code == 200
|
||||||
body = json.loads(resp.body)
|
body = json.loads(resp.body)
|
||||||
assert body["token"].startswith("nbwt_")
|
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:
|
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")
|
channel = _ch(bus, host="127.0.0.1")
|
||||||
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _NO_HEADERS)
|
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _NO_HEADERS)
|
||||||
assert resp.status_code == 200
|
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:
|
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())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
try:
|
try:
|
||||||
boot = await _http_get("http://127.0.0.1:29925/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
resp = await _http_get(
|
resp = await _http_get(
|
||||||
"http://127.0.0.1:29925/api/sessions/websocket:media-hydrate/messages",
|
"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())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
try:
|
try:
|
||||||
boot = await _http_get("http://127.0.0.1:29926/webui/bootstrap")
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
token = boot.json()["token"]
|
|
||||||
resp = await _http_get(
|
resp = await _http_get(
|
||||||
"http://127.0.0.1:29926/api/sessions/websocket:vanished/messages",
|
"http://127.0.0.1:29926/api/sessions/websocket:vanished/messages",
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
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["host"] == "127.0.0.1"
|
||||||
assert websocket["port"] == 8899
|
assert websocket["port"] == 8899
|
||||||
assert websocket["websocketRequiresToken"] is True
|
assert websocket["websocketRequiresToken"] is True
|
||||||
|
assert isinstance(websocket["tokenIssueSecret"], str)
|
||||||
|
assert len(websocket["tokenIssueSecret"]) >= 32
|
||||||
assert data["agents"]["defaults"]["workspace"] == str(workspace)
|
assert data["agents"]["defaults"]["workspace"] == str(workspace)
|
||||||
assert seen["templates"] == workspace
|
assert seen["templates"] == workspace
|
||||||
assert seen["gateway_kwargs"] == {"port": 18888, "open_browser_url": None}
|
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.port == 18889
|
||||||
assert options.config_path == str(config_file.resolve(strict=False))
|
assert options.config_path == str(config_file.resolve(strict=False))
|
||||||
assert options.workspace == str(workspace.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:
|
def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -> None:
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import httpx
|
|||||||
import pytest
|
import pytest
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
|
_BOOTSTRAP_SECRET = "smoke-secret"
|
||||||
|
|
||||||
|
|
||||||
def _free_port() -> int:
|
def _free_port() -> int:
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
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",
|
"host": "127.0.0.1",
|
||||||
"port": ws_port,
|
"port": ws_port,
|
||||||
"allowFrom": ["*"],
|
"allowFrom": ["*"],
|
||||||
|
"tokenIssueSecret": _BOOTSTRAP_SECRET,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"gateway": {
|
"gateway": {
|
||||||
@ -95,6 +98,17 @@ def _get_json(url: str, *, token: str | None = None) -> dict:
|
|||||||
return response.json()
|
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:
|
def _wait_for_bootstrap(base_url: str, process: subprocess.Popen[bytes], log_path: Path) -> dict:
|
||||||
deadline = time.monotonic() + 20
|
deadline = time.monotonic() + 20
|
||||||
last_error: Exception | None = None
|
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:
|
if process.poll() is not None:
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
return _get_json(f"{base_url}/webui/bootstrap")
|
return _get_bootstrap(f"{base_url}/webui/bootstrap")
|
||||||
except (httpx.HTTPError, OSError) as exc:
|
except (httpx.HTTPError, OSError) as exc:
|
||||||
last_error = exc
|
last_error = exc
|
||||||
time.sleep(0.2)
|
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"]
|
assert "Current model: `custom/smoke-model`" in answer["text"]
|
||||||
await _recv_until(ws, "turn_end")
|
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)
|
sessions = _get_json(f"{base_url}/api/sessions", token=api_token)
|
||||||
key = f"websocket:{chat_id}"
|
key = f"websocket:{chat_id}"
|
||||||
assert key in {row["key"] for row in sessions["sessions"]}
|
assert key in {row["key"] for row in sessions["sessions"]}
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import { ThemeProvider, useTheme } from "@/hooks/useTheme";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
clearSavedSecret,
|
clearSavedSecret,
|
||||||
|
consumeUrlBootstrapSecret,
|
||||||
deriveWsUrl,
|
deriveWsUrl,
|
||||||
fetchBootstrap,
|
fetchBootstrap,
|
||||||
loadSavedSecret,
|
loadSavedSecret,
|
||||||
@ -361,14 +362,14 @@ export default function App() {
|
|||||||
current.status === "ready" && current.client === client
|
current.status === "ready" && current.client === client
|
||||||
? {
|
? {
|
||||||
...current,
|
...current,
|
||||||
token: boot.token,
|
token: boot.api_token,
|
||||||
tokenExpiresAt,
|
tokenExpiresAt,
|
||||||
modelName: boot.model_name ?? current.modelName,
|
modelName: boot.model_name ?? current.modelName,
|
||||||
runtimeSurface,
|
runtimeSurface,
|
||||||
}
|
}
|
||||||
: current,
|
: current,
|
||||||
);
|
);
|
||||||
return { token: boot.token, url };
|
return { token: boot.api_token, url };
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@ -402,7 +403,7 @@ export default function App() {
|
|||||||
setState({
|
setState({
|
||||||
status: "ready",
|
status: "ready",
|
||||||
client,
|
client,
|
||||||
token: boot.token,
|
token: boot.api_token,
|
||||||
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
|
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
|
||||||
modelName: boot.model_name ?? null,
|
modelName: boot.model_name ?? null,
|
||||||
runtimeSurface,
|
runtimeSurface,
|
||||||
@ -441,7 +442,7 @@ export default function App() {
|
|||||||
}, [refreshReadyClient, state]);
|
}, [refreshReadyClient, state]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = loadSavedSecret();
|
const saved = consumeUrlBootstrapSecret() || loadSavedSecret();
|
||||||
return bootstrapWithSecret(saved);
|
return bootstrapWithSecret(saved);
|
||||||
}, [bootstrapWithSecret]);
|
}, [bootstrapWithSecret]);
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { BootstrapResponse } from "./types";
|
|||||||
import { fetchWithTimeout } from "./http";
|
import { fetchWithTimeout } from "./http";
|
||||||
|
|
||||||
const SECRET_STORAGE_KEY = "nanobot-webui.bootstrap-secret";
|
const SECRET_STORAGE_KEY = "nanobot-webui.bootstrap-secret";
|
||||||
|
const URL_SECRET_PARAM = "bootstrapSecret";
|
||||||
|
|
||||||
/** Read a previously saved bootstrap secret from localStorage. */
|
/** Read a previously saved bootstrap secret from localStorage. */
|
||||||
export function loadSavedSecret(): string {
|
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
|
* Fetch a short-lived token + the WebSocket path from the gateway's
|
||||||
* ``/webui/bootstrap`` endpoint.
|
* ``/webui/bootstrap`` endpoint.
|
||||||
@ -56,6 +80,9 @@ export async function fetchBootstrap(
|
|||||||
if (!body.token || !body.ws_path) {
|
if (!body.token || !body.ws_path) {
|
||||||
throw new Error("bootstrap response missing token or 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;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -296,6 +296,7 @@ export interface SidebarStatePayload {
|
|||||||
|
|
||||||
export interface BootstrapResponse {
|
export interface BootstrapResponse {
|
||||||
token: string;
|
token: string;
|
||||||
|
api_token: string;
|
||||||
ws_path: string;
|
ws_path: string;
|
||||||
ws_url?: string | null;
|
ws_url?: string | null;
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
|
|||||||
@ -177,10 +177,12 @@ vi.mock("@/hooks/useTheme", async () => {
|
|||||||
vi.mock("@/lib/bootstrap", () => ({
|
vi.mock("@/lib/bootstrap", () => ({
|
||||||
fetchBootstrap: vi.fn().mockResolvedValue({
|
fetchBootstrap: vi.fn().mockResolvedValue({
|
||||||
token: "tok",
|
token: "tok",
|
||||||
|
api_token: "api-tok",
|
||||||
ws_path: "/",
|
ws_path: "/",
|
||||||
expires_in: 300,
|
expires_in: 300,
|
||||||
}),
|
}),
|
||||||
deriveWsUrl: vi.fn(() => "ws://test"),
|
deriveWsUrl: vi.fn(() => "ws://test"),
|
||||||
|
consumeUrlBootstrapSecret: vi.fn(() => ""),
|
||||||
loadSavedSecret: vi.fn(() => ""),
|
loadSavedSecret: vi.fn(() => ""),
|
||||||
saveSecret: vi.fn(),
|
saveSecret: vi.fn(),
|
||||||
clearSavedSecret: vi.fn(),
|
clearSavedSecret: vi.fn(),
|
||||||
@ -239,6 +241,7 @@ describe("App layout", () => {
|
|||||||
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
|
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
|
||||||
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
||||||
token: "tok",
|
token: "tok",
|
||||||
|
api_token: "api-tok",
|
||||||
ws_path: "/",
|
ws_path: "/",
|
||||||
expires_in: 300,
|
expires_in: 300,
|
||||||
});
|
});
|
||||||
@ -723,6 +726,7 @@ describe("App layout", () => {
|
|||||||
];
|
];
|
||||||
vi.mocked(fetchBootstrap).mockResolvedValue({
|
vi.mocked(fetchBootstrap).mockResolvedValue({
|
||||||
token: "tok",
|
token: "tok",
|
||||||
|
api_token: "api-tok",
|
||||||
ws_path: "/",
|
ws_path: "/",
|
||||||
expires_in: 300,
|
expires_in: 300,
|
||||||
runtime_surface: "native",
|
runtime_surface: "native",
|
||||||
@ -2147,11 +2151,13 @@ describe("App layout", () => {
|
|||||||
vi.mocked(fetchBootstrap)
|
vi.mocked(fetchBootstrap)
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
token: "tok-1",
|
token: "tok-1",
|
||||||
|
api_token: "api-tok-1",
|
||||||
ws_path: "/",
|
ws_path: "/",
|
||||||
expires_in: 30,
|
expires_in: 30,
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
token: "tok-2",
|
token: "tok-2",
|
||||||
|
api_token: "api-tok-2",
|
||||||
ws_path: "/",
|
ws_path: "/",
|
||||||
expires_in: 300,
|
expires_in: 300,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
|
import { consumeUrlBootstrapSecret, deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
|
||||||
|
|
||||||
describe("bootstrap helpers", () => {
|
describe("bootstrap helpers", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -50,4 +50,29 @@ describe("bootstrap helpers", () => {
|
|||||||
|
|
||||||
await pending;
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user