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:
@@ -0,0 +1,154 @@
|
||||
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"
|
||||
Reference in New Issue
Block a user