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:
@@ -86,13 +86,10 @@ def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) ->
|
||||
(tmp_path / "src").mkdir()
|
||||
existing = tmp_path / "src" / "existing.py"
|
||||
existing.write_text("old\nkeep\n", encoding="utf-8")
|
||||
delete_me = tmp_path / "src" / "delete_me.py"
|
||||
delete_me.write_text("gone\n", encoding="utf-8")
|
||||
|
||||
edits = [
|
||||
{"path": "src/new.py", "action": "add", "new_text": "fresh"},
|
||||
{"path": "src/existing.py", "action": "replace", "old_text": "old", "new_text": "new"},
|
||||
{"path": "src/delete_me.py", "action": "delete", "old_text": "gone\n"},
|
||||
]
|
||||
|
||||
trackers = prepare_file_edit_trackers(
|
||||
@@ -106,18 +103,15 @@ def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) ->
|
||||
assert [tracker.display_path for tracker in trackers] == [
|
||||
"src/new.py",
|
||||
"src/existing.py",
|
||||
"src/delete_me.py",
|
||||
]
|
||||
|
||||
(tmp_path / "src" / "new.py").write_text("fresh\n", encoding="utf-8")
|
||||
existing.write_text("new\nkeep\n", encoding="utf-8")
|
||||
delete_me.unlink()
|
||||
|
||||
events = [build_file_edit_end_event(tracker, {"edits": edits}) for tracker in trackers]
|
||||
by_path = {event["path"]: event for event in events}
|
||||
assert (by_path["src/new.py"]["added"], by_path["src/new.py"]["deleted"]) == (1, 0)
|
||||
assert (by_path["src/existing.py"]["added"], by_path["src/existing.py"]["deleted"]) == (1, 1)
|
||||
assert (by_path["src/delete_me.py"]["added"], by_path["src/delete_me.py"]["deleted"]) == (0, 1)
|
||||
|
||||
|
||||
def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path) -> None:
|
||||
|
||||
@@ -27,6 +27,7 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
|
||||
"pinned_keys": ["websocket:a", "websocket:a", "", 123],
|
||||
"archived_keys": ["websocket:b"],
|
||||
"title_overrides": {"websocket:a": " Release notes ", "bad": ""},
|
||||
"project_name_overrides": {"/repo": " Core ", "bad": ""},
|
||||
"tags_by_key": {"websocket:a": ["work", "work", ""]},
|
||||
"collapsed_groups": {"Earlier": 1},
|
||||
"view": {"density": "tiny", "show_archived": True, "sort": "nope"},
|
||||
@@ -41,6 +42,7 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
|
||||
assert state["pinned_keys"] == ["websocket:a"]
|
||||
assert state["archived_keys"] == ["websocket:b"]
|
||||
assert state["title_overrides"] == {"websocket:a": "Release notes"}
|
||||
assert state["project_name_overrides"] == {"/repo": "Core"}
|
||||
assert state["tags_by_key"] == {"websocket:a": ["work"]}
|
||||
assert state["collapsed_groups"] == {"Earlier": True}
|
||||
assert state["view"] == {
|
||||
@@ -60,6 +62,7 @@ def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch)
|
||||
"pinned_keys": ["websocket:a"],
|
||||
"archived_keys": ["websocket:b"],
|
||||
"title_overrides": {"websocket:a": "Release"},
|
||||
"project_name_overrides": {"/repo": "Core"},
|
||||
"view": {"density": "compact", "show_previews": True},
|
||||
}
|
||||
)
|
||||
@@ -67,6 +70,7 @@ def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch)
|
||||
assert state["pinned_keys"] == ["websocket:a"]
|
||||
assert state["archived_keys"] == ["websocket:b"]
|
||||
assert state["title_overrides"] == {"websocket:a": "Release"}
|
||||
assert state["project_name_overrides"] == {"/repo": "Core"}
|
||||
assert state["view"]["density"] == "compact"
|
||||
assert state["view"]["show_previews"] is True
|
||||
assert webui_sidebar_state_path().is_file()
|
||||
|
||||
@@ -122,6 +122,103 @@ def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) ->
|
||||
assert msgs[2]["activitySegmentId"] != msgs[1]["activitySegmentId"]
|
||||
|
||||
|
||||
def test_replay_file_edit_absorbs_matching_write_tool_event() -> None:
|
||||
msgs = replay_transcript_to_ui_messages([
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "t-file",
|
||||
"text": 'write_file({"path":"foo.txt"})',
|
||||
"kind": "tool_hint",
|
||||
"tool_events": [
|
||||
{
|
||||
"phase": "start",
|
||||
"call_id": "call-write",
|
||||
"name": "write_file",
|
||||
"arguments": {"path": "foo.txt", "content": "hello\n"},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"event": "file_edit",
|
||||
"chat_id": "t-file",
|
||||
"edits": [
|
||||
{
|
||||
"version": 1,
|
||||
"call_id": "call-write",
|
||||
"tool": "write_file",
|
||||
"path": "foo.txt",
|
||||
"phase": "start",
|
||||
"added": 1,
|
||||
"deleted": 0,
|
||||
"approximate": True,
|
||||
"status": "editing",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "t-file",
|
||||
"text": "",
|
||||
"kind": "progress",
|
||||
"tool_events": [
|
||||
{
|
||||
"phase": "end",
|
||||
"call_id": "call-write",
|
||||
"name": "write_file",
|
||||
"arguments": {"path": "foo.txt", "content": "hello\n"},
|
||||
"result": "ok",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0]["kind"] == "trace"
|
||||
assert msgs[0]["traces"] == []
|
||||
assert "toolEvents" not in msgs[0]
|
||||
assert msgs[0]["fileEdits"] == [
|
||||
{
|
||||
"version": 1,
|
||||
"call_id": "call-write",
|
||||
"tool": "write_file",
|
||||
"path": "foo.txt",
|
||||
"phase": "start",
|
||||
"added": 1,
|
||||
"deleted": 0,
|
||||
"approximate": True,
|
||||
"status": "editing",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_replay_keeps_interrupted_pre_tool_text_in_activity() -> None:
|
||||
msgs = replay_transcript_to_ui_messages([
|
||||
{"event": "delta", "chat_id": "t-stream", "text": "I will inspect first."},
|
||||
{"event": "stream_end", "chat_id": "t-stream"},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "t-stream",
|
||||
"text": 'exec({"cmd":"ls"})',
|
||||
"kind": "tool_hint",
|
||||
},
|
||||
{
|
||||
"event": "stream_end",
|
||||
"chat_id": "t-stream",
|
||||
"text": "Done. Open index.html to play.",
|
||||
},
|
||||
])
|
||||
|
||||
assert len(msgs) == 3
|
||||
assert msgs[0]["role"] == "assistant"
|
||||
assert msgs[0]["content"] == ""
|
||||
assert msgs[0]["reasoning"] == "I will inspect first."
|
||||
assert "isStreaming" not in msgs[0]
|
||||
assert msgs[1]["kind"] == "trace"
|
||||
assert msgs[1]["traces"] == ['exec({"cmd":"ls"})']
|
||||
assert msgs[2]["role"] == "assistant"
|
||||
assert msgs[2]["content"] == "Done. Open index.html to play."
|
||||
|
||||
|
||||
def test_replay_tool_events_dedupes_finish_after_start() -> None:
|
||||
msgs = replay_transcript_to_ui_messages([
|
||||
{
|
||||
|
||||
@@ -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