mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 22:34:06 +00:00
* 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
155 lines
5.5 KiB
Python
155 lines
5.5 KiB
Python
import json
|
|
|
|
from nanobot.security.workspace_access import default_workspace_scope
|
|
from nanobot.session.manager import SessionManager
|
|
from nanobot.webui.workspaces import (
|
|
WebUIWorkspaceController,
|
|
read_webui_default_access_mode,
|
|
read_webui_workspace_state,
|
|
webui_workspace_state_path,
|
|
write_webui_default_access_mode,
|
|
workspaces_payload,
|
|
)
|
|
|
|
|
|
def test_workspace_state_defaults_when_file_missing(tmp_path, monkeypatch) -> None:
|
|
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
|
|
|
state = read_webui_workspace_state()
|
|
|
|
assert state["default_access_mode"] == "default"
|
|
assert webui_workspace_state_path() == tmp_path / "webui" / "workspace-state.json"
|
|
|
|
|
|
def test_workspace_state_ignores_legacy_project_history(tmp_path, monkeypatch) -> None:
|
|
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
|
project = tmp_path / "project"
|
|
project.mkdir()
|
|
path = webui_workspace_state_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(
|
|
json.dumps(
|
|
{
|
|
"recent_projects": [
|
|
{"project_path": str(project)},
|
|
{"project_path": str(tmp_path / "missing")},
|
|
],
|
|
"last_scope": {
|
|
"project_path": str(project),
|
|
"access_mode": "full",
|
|
},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
state = read_webui_workspace_state()
|
|
|
|
assert "recent_projects" not in state
|
|
assert "last_scope" not in state
|
|
assert state["default_access_mode"] == "default"
|
|
|
|
|
|
def test_workspace_payload_is_config_data_dir_scoped(tmp_path, monkeypatch) -> None:
|
|
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
|
default = tmp_path / "default"
|
|
default.mkdir()
|
|
|
|
payload = workspaces_payload(
|
|
default_workspace=default,
|
|
default_restrict_to_workspace=False,
|
|
controls_available=True,
|
|
)
|
|
|
|
assert payload["default_scope"]["project_path"] == str(default.resolve())
|
|
assert payload["default_scope"]["access_mode"] == "full"
|
|
assert payload["default_access_mode"] == "default"
|
|
assert payload["controls"]["can_change_project"] is True
|
|
|
|
|
|
def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
|
|
tmp_path,
|
|
monkeypatch,
|
|
) -> None:
|
|
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
|
default = tmp_path / "default"
|
|
default.mkdir()
|
|
|
|
payload = workspaces_payload(
|
|
default_workspace=default,
|
|
default_restrict_to_workspace=False,
|
|
controls_available=False,
|
|
)
|
|
|
|
assert payload["default_scope"]["project_path"] == str(default.resolve())
|
|
assert payload["controls"]["can_change_project"] is False
|
|
assert payload["controls"]["can_use_full_access"] is False
|
|
|
|
|
|
def test_workspace_payload_uses_webui_default_access_mode(tmp_path, monkeypatch) -> None:
|
|
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
|
default = tmp_path / "default"
|
|
default.mkdir()
|
|
|
|
assert write_webui_default_access_mode("full") is True
|
|
assert write_webui_default_access_mode("full") is False
|
|
|
|
payload = workspaces_payload(
|
|
default_workspace=default,
|
|
default_restrict_to_workspace=True,
|
|
controls_available=True,
|
|
)
|
|
|
|
assert payload["default_access_mode"] == "full"
|
|
assert payload["default_scope"]["project_path"] == str(default.resolve())
|
|
assert payload["default_scope"]["access_mode"] == "full"
|
|
|
|
|
|
def test_legacy_restricted_webui_default_access_mode_maps_to_default(tmp_path, monkeypatch) -> None:
|
|
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
|
|
|
assert write_webui_default_access_mode("restricted") is False
|
|
assert read_webui_default_access_mode() == "default"
|
|
|
|
|
|
def test_webui_default_access_applies_to_unscoped_old_sessions(tmp_path, monkeypatch) -> None:
|
|
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
|
default = tmp_path / "default"
|
|
default.mkdir()
|
|
sessions = SessionManager(tmp_path / "sessions")
|
|
sessions.save(sessions.get_or_create("websocket:old-chat"))
|
|
write_webui_default_access_mode("full")
|
|
controller = WebUIWorkspaceController(
|
|
session_manager=sessions,
|
|
default_workspace=default,
|
|
default_restrict_to_workspace=True,
|
|
)
|
|
|
|
scope = controller.scope_for_session_key("websocket:old-chat")
|
|
new_scope = controller.scope_for_new_chat({}, controls_available=True)
|
|
|
|
assert scope.project_path == default.resolve()
|
|
assert scope.access_mode == "full"
|
|
assert new_scope.access_mode == "full"
|
|
|
|
|
|
def test_webui_default_access_does_not_override_explicit_session_scope(tmp_path, monkeypatch) -> None:
|
|
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
|
default = tmp_path / "default"
|
|
project = tmp_path / "project"
|
|
default.mkdir()
|
|
project.mkdir()
|
|
sessions = SessionManager(tmp_path / "sessions")
|
|
controller = WebUIWorkspaceController(
|
|
session_manager=sessions,
|
|
default_workspace=default,
|
|
default_restrict_to_workspace=True,
|
|
)
|
|
explicit = default_workspace_scope(project, restrict_to_workspace=False)
|
|
controller.persist_scope("explicit-chat", explicit)
|
|
|
|
scope = controller.scope_for_session_key("websocket:explicit-chat")
|
|
|
|
assert scope.project_path == project.resolve()
|
|
assert scope.access_mode == "full"
|