feat(webui): add guided setup flows

* feat(channels): add guided setup flows

* test(channels): preserve setup config values

* fix(channels): reflect saved setup state

* refactor(channels): simplify setup state metadata

* fix(channels): harden setup lifecycle

* refactor(channels): centralize setup contracts

* fix(channels): route setup actions through webui shim

* fix(channels): adapt settings for compact screens

* fix(models): preserve default preset display

* feat(models): add curated Codex catalog

* fix(webui): stop attached gateway on interrupt

* fix(webui): simplify apps catalog

* docs(webui): clarify apps and runtime features

* feat(settings): add guided capability setup

* fix(webui): harden setup and managed services

* test: keep managed runtime checks portable

* test: scope POSIX runtime coverage

* fix(webui): simplify file settings

* feat(files): bundle document reading

* fix(webui): harden setup request boundaries

* fix(webui): prevent channel setup status squeeze

* fix(settings): group provider compatibility aliases

* refactor(settings): remove redundant setup surfaces

* fix(webui): harden guided setup lifecycle

* fix(webui): preserve channel setup compatibility
This commit is contained in:
Xubin Ren
2026-07-13 13:11:46 +08:00
committed by GitHub
parent 791c7fd505
commit fe0717b385
92 changed files with 15058 additions and 1311 deletions
@@ -76,6 +76,7 @@ def _make_handler(
local_trigger_store: LocalTriggerStore | None = None,
cron_pending_job_ids: Any | None = None,
local_trigger_pending_ids: Any | None = None,
channel_feature_action: Any | None = None,
) -> GatewayServices:
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
workspace = workspace_path or Path.cwd()
@@ -93,6 +94,7 @@ def _make_handler(
local_trigger_store=local_trigger_store,
cron_pending_job_ids=cron_pending_job_ids,
local_trigger_pending_ids=local_trigger_pending_ids,
channel_feature_action=channel_feature_action,
)
@@ -108,6 +110,7 @@ def _ch(
local_trigger_store: LocalTriggerStore | None = None,
cron_pending_job_ids: Any | None = None,
local_trigger_pending_ids: Any | None = None,
channel_feature_action: Any | None = None,
**extra: Any,
) -> WebSocketChannel:
cfg: dict[str, Any] = {
@@ -129,6 +132,7 @@ def _ch(
local_trigger_store=local_trigger_store,
cron_pending_job_ids=cron_pending_job_ids,
local_trigger_pending_ids=local_trigger_pending_ids,
channel_feature_action=channel_feature_action,
)
return WebSocketChannel(cfg, bus, gateway=gateway)
@@ -650,6 +654,128 @@ async def test_nanobot_feature_routes_require_token_and_enable(
await server_task
@pytest.mark.asyncio
async def test_pairing_routes_require_token_and_approve_or_deny(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
pending = [
{
"code": "ABCD-EFGH",
"channel": "feishu",
"sender_id": "ou_123",
"created_at": 1_000.0,
"expires_at": 1_600.0,
}
]
approved: list[str] = []
denied: list[str] = []
monkeypatch.setattr("nanobot.webui.settings_routes.list_pending", lambda: list(pending))
monkeypatch.setattr(
"nanobot.webui.settings_routes.approve_code",
lambda code: approved.append(code) or ("feishu", "ou_123") if code == "ABCD-EFGH" else None,
)
monkeypatch.setattr(
"nanobot.webui.settings_routes.deny_code",
lambda code: denied.append(code) or code == "ABCD-EFGH",
)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_api_token(300)
denied_response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(path="/api/settings/pairing"),
"/api/settings/pairing",
)
assert denied_response is not None
assert denied_response.status_code == 401
auth = {"Authorization": f"Bearer {token}"}
listed = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(auth, path="/api/settings/pairing"),
"/api/settings/pairing",
)
assert listed is not None
assert listed.status_code == 200
body = json.loads(listed.body.decode())
assert body["requests"][0]["code"] == "ABCD-EFGH"
assert body["requests"][0]["channel"] == "feishu"
assert body["requests"][0]["sender_id"] == "ou_123"
assert body["requests"][0]["created_at_ms"] == 1_000_000
assert body["requests"][0]["expires_at_ms"] == 1_600_000
approved_response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(auth, path="/api/settings/pairing/approve?code=ABCD-EFGH"),
"/api/settings/pairing/approve",
)
assert approved_response is not None
assert approved_response.status_code == 200
body = json.loads(approved_response.body.decode())
assert body["last_action"]["action"] == "approve"
assert body["last_action"]["sender_id"] == "ou_123"
assert approved == ["ABCD-EFGH"]
denied_action = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(auth, path="/api/settings/pairing/deny?code=ABCD-EFGH"),
"/api/settings/pairing/deny",
)
assert denied_action is not None
assert denied_action.status_code == 200
assert json.loads(denied_action.body.decode())["last_action"]["action"] == "deny"
assert denied == ["ABCD-EFGH"]
missing_code = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(auth, path="/api/settings/pairing/approve"),
"/api/settings/pairing/approve",
)
assert missing_code is not None
assert missing_code.status_code == 400
assert "Missing pairing code" in missing_code.body.decode()
def test_api_service_settings_read_api_key_from_private_header(bus: MagicMock) -> None:
channel = _ch(bus)
request = _FakeReq(
{"X-Nanobot-API-Service-Values": json.dumps({"api_key": "secret-token"})},
path="/api/settings/api-service/start?host=0.0.0.0&port=8900&timeout=120",
)
query = channel.gateway.http.settings_routes._parse_api_service_settings_query(request)
assert query == {
"host": ["0.0.0.0"],
"port": ["8900"],
"timeout": ["120"],
"api_key": ["secret-token"],
}
def test_api_service_settings_reject_invalid_private_header(bus: MagicMock) -> None:
from nanobot.webui.settings_api import WebUISettingsError
channel = _ch(bus)
request = _FakeReq(
{"X-Nanobot-API-Service-Values": json.dumps({"api_key": 123})},
path="/api/settings/api-service/start?host=127.0.0.1",
)
with pytest.raises(WebUISettingsError, match="API key must be a string"):
channel.gateway.http.settings_routes._parse_api_service_settings_query(request)
query_secret = _FakeReq(
path="/api/settings/api-service/start?host=127.0.0.1&api_key=secret-token",
)
with pytest.raises(WebUISettingsError, match="private header"):
channel.gateway.http.settings_routes._parse_api_service_settings_query(query_secret)
@pytest.mark.asyncio
async def test_nanobot_feature_remote_install_requires_opt_in(
bus: MagicMock,
@@ -733,6 +859,506 @@ async def test_nanobot_feature_local_install_allowed_by_default(
] is True
@pytest.mark.asyncio
async def test_nanobot_feature_channel_action_can_apply_without_restart(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
_stub_matrix_feature(monkeypatch, config_path, deps=["matrix-nio>=0.25.2"])
calls: list[tuple[str, str]] = []
async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
calls.append((action, name))
return {
"handled": True,
"ok": True,
"requires_restart": False,
"message": "Matrix channel applied without restart.",
}
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
port=_free_port(),
channel_feature_action=channel_feature_action,
)
token = channel.gateway.tokens.issue_api_token(300)
request = _FakeReq(
{"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"},
path="/api/settings/nanobot-features/enable?name=matrix",
)
response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
request,
"/api/settings/nanobot-features/enable",
)
assert response is not None
assert response.status_code == 200
body = json.loads(response.body.decode())
assert calls == [("enable", "matrix")]
assert body["requires_restart"] is False
assert body["restart_required_sections"] == []
assert body["last_action"]["hot_reload"] is True
assert body["last_action"]["message"].endswith("Matrix channel applied without restart.")
@pytest.mark.asyncio
async def test_feishu_connect_routes_write_config_and_hot_reload(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.channels import feishu as feishu_module
from nanobot.config import loader
from nanobot.config.schema import Config
config_path = tmp_path / "config.json"
loader.save_config(Config(), config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(feishu_module, "_init_registration", lambda _domain: None)
monkeypatch.setattr(
feishu_module,
"_begin_registration",
lambda _domain: {
"device_code": "device",
"qr_url": "https://accounts.feishu.cn/login?device_code=device",
"interval": 2,
"expire_in": 600,
},
)
monkeypatch.setattr(
feishu_module,
"poll_registration_once",
lambda *, device_code, domain: {
"status": "succeeded",
"app_id": "cli_app",
"app_secret": "secret",
"domain": "feishu",
},
)
monkeypatch.setattr(
feishu_module,
"fetch_feishu_app_identity",
lambda app_id, app_secret, domain: {
"displayName": "Voraflare Bot",
"avatarUrl": "https://example.com/feishu.png",
"identityFetchedAt": "2026-07-06T00:00:00Z",
},
)
monkeypatch.setattr(
"nanobot.webui.settings_routes.nanobot_features_action",
lambda _action, _query, *, allow_install=True: {
"features": [{
"name": "feishu",
"display_name": "Feishu",
"type": "channel",
"enabled": True,
"installed": True,
"ready": True,
"status": "enabled",
"install_supported": True,
"requires_restart": True,
}],
"enabled_count": 1,
"requires_restart": True,
"last_action": {"ok": True, "message": "Enabled channel 'feishu'", "enabled": True},
},
)
calls: list[tuple[str, str]] = []
async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
calls.append((action, name))
return {
"handled": True,
"ok": True,
"requires_restart": False,
"message": "Feishu channel applied without restart.",
}
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
port=_free_port(),
channel_feature_action=channel_feature_action,
)
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"}
started = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
auth,
path="/api/settings/channels/feishu/connect/start?domain=feishu&instance_id=default",
),
"/api/settings/channels/feishu/connect/start",
)
assert started is not None
assert started.status_code == 200
start_body = json.loads(started.body.decode())
assert start_body["status"] == "pending"
assert start_body["instance_id"] == "default"
assert start_body["qr_url"].startswith("https://accounts.feishu.cn/")
polled = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
auth,
path=f"/api/settings/channels/feishu/connect/poll?session_id={start_body['session_id']}",
),
"/api/settings/channels/feishu/connect/poll",
)
assert polled is not None
assert polled.status_code == 200
body = json.loads(polled.body.decode())
assert body["status"] == "succeeded"
assert body["instance_id"] == "default"
assert "app_secret" not in body
assert calls == [("enable", "feishu")]
assert body["nanobot_features"]["requires_restart"] is False
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["feishu"]["instances"][0]["id"] == "default"
assert data["channels"]["feishu"]["instances"][0]["appId"] == "cli_app"
assert data["channels"]["feishu"]["instances"][0]["appSecret"] == "secret"
assert data["channels"]["feishu"]["instances"][0]["enabled"] is True
assert data["channels"]["feishu"]["instances"][0]["displayName"] == "Voraflare Bot"
assert data["channels"]["feishu"]["instances"][0]["avatarUrl"] == "https://example.com/feishu.png"
def test_feishu_connect_create_appends_instance(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.channels import feishu as feishu_module
from nanobot.config import loader
from nanobot.webui.channel_connect import FeishuConnectStore
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({
"channels": {
"feishu": {
"instances": [{
"id": "default",
"name": "nanobot",
"enabled": True,
"appId": "cli_default",
"appSecret": "default-secret",
}]
}
}
}),
encoding="utf-8",
)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(feishu_module, "_init_registration", lambda _domain: None)
monkeypatch.setattr(
feishu_module,
"_begin_registration",
lambda _domain: {
"device_code": "device",
"qr_url": "https://accounts.feishu.cn/login?device_code=device",
"interval": 2,
"expire_in": 600,
},
)
monkeypatch.setattr(
feishu_module,
"poll_registration_once",
lambda *, device_code, domain: {
"status": "succeeded",
"app_id": "cli_new",
"app_secret": "new-secret",
"domain": "feishu",
},
)
monkeypatch.setattr(
feishu_module,
"fetch_feishu_app_identity",
lambda app_id, app_secret, domain: {
"displayName": f"Assistant {app_id}",
"avatarUrl": f"https://example.com/{app_id}.png",
"identityFetchedAt": "2026-07-06T00:00:00Z",
},
)
store = FeishuConnectStore()
started = store.start(mode="create")
polled = store.poll(started["session_id"])
assert polled["status"] == "succeeded"
assert polled["instance_id"] != "default"
data = json.loads(config_path.read_text(encoding="utf-8"))
instances = data["channels"]["feishu"]["instances"]
assert [item["id"] for item in instances] == ["default", polled["instance_id"]]
assert instances[0]["appId"] == "cli_default"
assert instances[1]["appId"] == "cli_new"
assert instances[0].get("displayName") is None
assert instances[1]["displayName"] == "Assistant cli_new"
assert instances[1]["avatarUrl"] == "https://example.com/cli_new.png"
@pytest.mark.asyncio
async def test_channel_configure_route_saves_discord_config_and_hot_reloads(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.config import loader
from nanobot.config.schema import Config
config_path = tmp_path / "config.json"
loader.save_config(Config(), config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
def fake_feature_action(
action: str,
query: dict[str, list[str]],
*,
allow_install: bool = True,
) -> dict[str, Any]:
assert action == "enable"
assert query == {"name": ["discord"]}
cfg = loader.load_config()
section = dict(getattr(cfg.channels, "discord", {}) or {})
section["enabled"] = True
setattr(cfg.channels, "discord", section)
loader.save_config(cfg)
return {
"features": [{
"name": "discord",
"display_name": "Discord",
"type": "channel",
"enabled": True,
"installed": True,
"ready": True,
"status": "enabled",
"install_supported": True,
"requires_restart": True,
}],
"enabled_count": 1,
"requires_restart": True,
"last_action": {"ok": True, "message": "Enabled channel 'discord'", "enabled": True},
}
monkeypatch.setattr("nanobot.webui.settings_routes.nanobot_features_action", fake_feature_action)
calls: list[tuple[str, str]] = []
async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
calls.append((action, name))
cfg = loader.load_config()
assert getattr(cfg.channels, "discord")["token"] == "discord-token"
return {
"handled": True,
"ok": True,
"requires_restart": False,
"message": "Discord channel applied without restart.",
}
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
port=_free_port(),
channel_feature_action=channel_feature_action,
)
token = channel.gateway.tokens.issue_api_token(300)
response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
{
"Authorization": f"Bearer {token}",
"Host": "127.0.0.1:8765",
"X-Nanobot-Channel-Values": json.dumps(
{
"channels.discord.token": "discord-token",
"channels.discord.allowChannels": "123, 456",
"channels.discord.groupPolicy": "open",
}
),
},
path="/api/settings/channels/configure?name=discord&enable=true",
),
"/api/settings/channels/configure",
)
assert response is not None
assert response.status_code == 200
body = json.loads(response.body.decode())
assert body["saved"] is True
assert body["name"] == "discord"
assert "discord-token" not in response.body.decode()
assert calls == [("enable", "discord")]
assert body["nanobot_features"]["requires_restart"] is False
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["discord"] == {
"token": "discord-token",
"allowChannels": ["123", "456"],
"groupPolicy": "open",
"enabled": True,
}
@pytest.mark.asyncio
async def test_channel_configure_route_preserves_existing_channel_values(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.config import loader
from nanobot.config.schema import Config
config_path = tmp_path / "config.json"
config = Config()
setattr(
config.channels,
"discord",
{
"enabled": True,
"token": "old-discord-token",
"allowChannels": ["old-channel"],
"groupPolicy": "mention",
"customExtra": "keep-me",
"nested": {"value": 42},
},
)
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_api_token(300)
response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
{
"Authorization": f"Bearer {token}",
"Host": "127.0.0.1:8765",
"X-Nanobot-Channel-Values": json.dumps(
{
"channels.discord.token": "",
"channels.discord.allowChannels": "new-channel",
}
),
},
path="/api/settings/channels/configure?name=discord",
),
"/api/settings/channels/configure",
)
assert response is not None
assert response.status_code == 200
body = json.loads(response.body.decode())
assert body["saved_keys"] == ["channels.discord.allowChannels"]
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["discord"] == {
"enabled": True,
"token": "old-discord-token",
"allowChannels": ["new-channel"],
"groupPolicy": "mention",
"customExtra": "keep-me",
"nested": {"value": 42},
}
@pytest.mark.asyncio
async def test_channel_configure_route_saves_matrix_device_id_without_replacing_token(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.config import loader
from nanobot.config.schema import Config
config_path = tmp_path / "config.json"
config = Config()
setattr(
config.channels,
"matrix",
{
"enabled": False,
"homeserver": "https://matrix.example",
"userId": "@nanobot:matrix.example",
"accessToken": "saved-token",
},
)
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_api_token(300)
response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
{
"Authorization": f"Bearer {token}",
"Host": "127.0.0.1:8765",
"X-Nanobot-Channel-Values": json.dumps(
{
"channels.matrix.accessToken": "",
"channels.matrix.deviceId": "DEVICE-ID",
}
),
},
path="/api/settings/channels/configure?name=matrix",
),
"/api/settings/channels/configure",
)
assert response is not None
assert response.status_code == 200
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["matrix"]["accessToken"] == "saved-token"
assert data["channels"]["matrix"]["deviceId"] == "DEVICE-ID"
@pytest.mark.asyncio
async def test_channel_configure_route_saves_mattermost_setup(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.config import loader
from nanobot.config.schema import Config
config_path = tmp_path / "config.json"
loader.save_config(Config(), config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_api_token(300)
response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
{
"Authorization": f"Bearer {token}",
"Host": "127.0.0.1:8765",
"X-Nanobot-Channel-Values": json.dumps(
{
"channels.mattermost.serverUrl": "https://chat.example.com",
"channels.mattermost.token": "mattermost-token",
"channels.mattermost.teamId": "platform",
}
),
},
path="/api/settings/channels/configure?name=mattermost",
),
"/api/settings/channels/configure",
)
assert response is not None
assert response.status_code == 200
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["mattermost"] == {
"serverUrl": "https://chat.example.com",
"token": "mattermost-token",
"teamId": "platform",
}
@pytest.mark.asyncio
async def test_nanobot_feature_loopback_reverse_proxy_install_requires_opt_in(
bus: MagicMock,