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:
Xubin Ren
2026-05-29 03:42:53 +08:00
committed by GitHub
parent 84428136e6
commit 3a420136bb
111 changed files with 9972 additions and 1822 deletions
-6
View File
@@ -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:
+4
View File
@@ -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()
+97
View 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([
{
+154
View File
@@ -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"