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