feat(webui): add project workspaces and access controls (#4007)
* feat(webui): add project workspaces and access controls * feat(webui): add project workspaces and access controls * refactor(tools): centralize workspace access resolution * refactor(webui): remove unused workspace host state * fix(webui): hide estimated file edit label * fix(webui): clarify file edit deletion feedback * fix(webui): label deleted file activity * fix(webui): flatten file edit activity rows * fix(core): remove path-only patch deletion * fix(core): keep apply patch non-destructive * refactor(webui): trim workspace host plumbing * fix(tools): register exec with tools config
This commit is contained in:
@@ -1,10 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.webui.settings_api import WebUISettingsError, create_model_configuration
|
||||
from nanobot.config.schema import Config, ModelPresetConfig
|
||||
from nanobot.webui.settings_api import (
|
||||
WebUISettingsError,
|
||||
_oauth_provider_status,
|
||||
create_model_configuration,
|
||||
settings_payload,
|
||||
update_model_configuration,
|
||||
update_network_safety_settings,
|
||||
)
|
||||
from nanobot.providers.registry import find_by_name
|
||||
|
||||
|
||||
def test_create_model_configuration_writes_label_and_selects(
|
||||
@@ -65,3 +75,237 @@ def test_create_model_configuration_rejects_unconfigured_provider(
|
||||
"model": ["openai/gpt-4.1"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_update_model_configuration_edits_named_preset_and_selects(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
config.providers.openai.api_key = "sk-test"
|
||||
config.model_presets["codex"] = ModelPresetConfig(
|
||||
label="Old Codex",
|
||||
provider="openai",
|
||||
model="openai/gpt-4.1",
|
||||
)
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_api._oauth_provider_status",
|
||||
lambda spec: {
|
||||
"configured": spec.name == "openai_codex",
|
||||
"account": "acct-test",
|
||||
"expires_at": 123,
|
||||
"login_supported": True,
|
||||
},
|
||||
)
|
||||
|
||||
payload = update_model_configuration(
|
||||
{
|
||||
"name": ["codex"],
|
||||
"label": ["Codex"],
|
||||
"provider": ["openai_codex"],
|
||||
"model": ["openai-codex/gpt-5.5"],
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["agent"]["model_preset"] == "codex"
|
||||
assert payload["agent"]["model"] == "openai-codex/gpt-5.5"
|
||||
saved = load_config(config_path)
|
||||
assert saved.agents.defaults.model_preset == "codex"
|
||||
assert saved.model_presets["codex"].label == "Codex"
|
||||
assert saved.model_presets["codex"].provider == "openai_codex"
|
||||
assert saved.model_presets["codex"].model == "openai-codex/gpt-5.5"
|
||||
|
||||
|
||||
def test_update_model_configuration_rejects_default_preset(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
with pytest.raises(WebUISettingsError, match="model configuration is required"):
|
||||
update_model_configuration({"name": ["default"], "model": ["openai/gpt-4.1"]})
|
||||
|
||||
|
||||
def test_settings_payload_includes_oauth_provider_status(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
def fake_oauth_status(spec):
|
||||
if spec.name == "openai_codex":
|
||||
return {
|
||||
"configured": True,
|
||||
"account": "acct-test",
|
||||
"expires_at": 123,
|
||||
"login_supported": True,
|
||||
}
|
||||
return {
|
||||
"configured": False,
|
||||
"account": None,
|
||||
"expires_at": None,
|
||||
"login_supported": True,
|
||||
}
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.settings_api._oauth_provider_status", fake_oauth_status)
|
||||
|
||||
payload = settings_payload()
|
||||
providers = {row["name"]: row for row in payload["providers"]}
|
||||
|
||||
assert providers["openai_codex"]["auth_type"] == "oauth"
|
||||
assert providers["openai_codex"]["configured"] is True
|
||||
assert providers["openai_codex"]["oauth_account"] == "acct-test"
|
||||
|
||||
|
||||
def test_settings_payload_includes_network_safety_fields(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
config.tools.webui_allow_local_service_access = False
|
||||
config.tools.ssrf_whitelist = ["100.64.0.0/10"]
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||
|
||||
payload = settings_payload()
|
||||
|
||||
assert payload["advanced"]["webui_allow_local_service_access"] is False
|
||||
assert payload["advanced"]["allow_local_preview_access"] is False
|
||||
assert payload["advanced"]["webui_default_access_mode"] == "default"
|
||||
assert payload["advanced"]["private_service_protection_enabled"] is True
|
||||
assert payload["advanced"]["ssrf_whitelist_count"] == 1
|
||||
|
||||
|
||||
def test_update_network_safety_settings_writes_local_service_flag(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||
|
||||
payload = update_network_safety_settings(
|
||||
{
|
||||
"webui_allow_local_service_access": ["false"],
|
||||
"webui_default_access_mode": ["full"],
|
||||
}
|
||||
)
|
||||
|
||||
saved = load_config(config_path)
|
||||
saved_raw = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert saved.tools.webui_allow_local_service_access is False
|
||||
assert saved_raw["tools"]["webuiAllowLocalServiceAccess"] is False
|
||||
assert "allowLocalPreviewAccess" not in saved_raw["tools"]
|
||||
assert payload["advanced"]["webui_allow_local_service_access"] is False
|
||||
assert payload["advanced"]["webui_default_access_mode"] == "full"
|
||||
assert payload["requires_restart"] is True
|
||||
|
||||
|
||||
def test_update_network_safety_settings_accepts_legacy_restricted_default_access(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||
|
||||
payload = update_network_safety_settings({"webui_default_access_mode": ["restricted"]})
|
||||
|
||||
assert payload["advanced"]["webui_default_access_mode"] == "default"
|
||||
|
||||
|
||||
def test_update_network_safety_settings_default_access_is_webui_only(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
before = config_path.read_text(encoding="utf-8")
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||
|
||||
payload = update_network_safety_settings({"webui_default_access_mode": ["full"]})
|
||||
|
||||
saved = load_config(config_path)
|
||||
assert config_path.read_text(encoding="utf-8") == before
|
||||
assert saved.tools.restrict_to_workspace is False
|
||||
assert payload["advanced"]["webui_default_access_mode"] == "full"
|
||||
assert payload["requires_restart"] is False
|
||||
|
||||
|
||||
def test_openai_codex_oauth_status_uses_available_token(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_get_token():
|
||||
return type(
|
||||
"Token",
|
||||
(),
|
||||
{
|
||||
"access": "access-token",
|
||||
"refresh": "refresh-token",
|
||||
"expires": 2_000_000_000_000,
|
||||
"account_id": "acct-codex",
|
||||
},
|
||||
)()
|
||||
|
||||
monkeypatch.setattr("oauth_cli_kit.get_token", fake_get_token)
|
||||
|
||||
status = _oauth_provider_status(find_by_name("openai_codex"))
|
||||
|
||||
assert status["configured"] is True
|
||||
assert status["account"] == "acct-codex"
|
||||
|
||||
|
||||
def test_openai_codex_oauth_status_rejects_unavailable_token(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_get_token():
|
||||
raise RuntimeError("refresh failed")
|
||||
|
||||
monkeypatch.setattr("oauth_cli_kit.get_token", fake_get_token)
|
||||
|
||||
status = _oauth_provider_status(find_by_name("openai_codex"))
|
||||
|
||||
assert status["configured"] is False
|
||||
assert status["account"] is None
|
||||
|
||||
|
||||
def test_create_model_configuration_accepts_configured_oauth_provider(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_api._oauth_provider_status",
|
||||
lambda spec: {
|
||||
"configured": spec.name == "openai_codex",
|
||||
"account": "acct-test",
|
||||
"expires_at": 123,
|
||||
"login_supported": True,
|
||||
},
|
||||
)
|
||||
|
||||
payload = create_model_configuration(
|
||||
{
|
||||
"label": ["Codex"],
|
||||
"provider": ["openai_codex"],
|
||||
"model": ["openai-codex/gpt-5.1-codex"],
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["agent"]["model_preset"] == "codex"
|
||||
saved = load_config(config_path)
|
||||
assert saved.model_presets["codex"].provider == "openai_codex"
|
||||
|
||||
Reference in New Issue
Block a user