diff --git a/config.py b/config.py index 9eda41b..e40be0c 100644 --- a/config.py +++ b/config.py @@ -100,11 +100,15 @@ DEFAULT_CONFIG: Dict[str, Any] = { "resource_limit_cpu_percent": 80, # 0 = unlimited; caps a run_command/install_package process TREE's total CPU% "resource_limit_memory_mb": 2048, # 0 = unlimited; caps total RSS memory (MB) "resource_limit_disk_mb": 512, # 0 = unlimited; caps total disk read+write (MB) - "block_network": True, # strip proxy env / point at a black-hole address for agent-run commands + # Cut the agent off the network: proxy env pointed at a black hole for + # agent-run shell commands, PLUS a flat refusal from every tool tagged + # ToolCapability.NETWORK (fetch_url, jira_*, install_package) — those + # reach the net in-process, where the proxy trick has nothing to act on. + "block_network": True, # Allow the agent's fetch_url tool to read web pages / online documents / - # SharePoint-OneDrive share links. SEPARATE from block_network (that only - # sandboxes agent-run shell commands) — reading a URL for info is safe and - # useful, so this defaults ON. Toggle in Settings → Security. + # SharePoint-OneDrive share links. Its own toggle — reading a URL for info + # is safe and useful, so this defaults ON — but block_network outranks it: + # with the network blocked the tool is refused either way. "allow_url_fetch": True, "sandbox_pw": "", # set through COWORK_SANDBOX_PASSWORD "rulebase_path": "", # custom RULEBASE.md — attached to every agent execution diff --git a/i18n/cowork_tab.py b/i18n/cowork_tab.py index c01f387..6424e21 100644 --- a/i18n/cowork_tab.py +++ b/i18n/cowork_tab.py @@ -344,12 +344,6 @@ STRINGS: Dict[str, Dict[str, str]] = { "en": "Let the control agent review a command with AI before it runs.", "ja": "実行前に制御エージェントがAIでコマンドを確認します。", "vi": "Cho control-agent dùng AI xét lệnh trước khi chạy."}, - "settings.sandbox_pw_unset_title": { - "en": "Sandbox Security", "ja": "サンドボックスセキュリティ", "vi": "Bảo mật Sandbox"}, - "settings.sandbox_pw_unset_body": { - "en": "No sandbox password is set yet, so these settings stay locked. Set COWORK_SANDBOX_PASSWORD, or ask your administrator.", - "ja": "サンドボックスのパスワードが未設定のため、この設定はロックされたままです。COWORK_SANDBOX_PASSWORD を設定するか、管理者にお問い合わせください。", - "vi": "Chưa đặt mật khẩu sandbox nên nhóm thiết lập này vẫn khóa. Hãy đặt COWORK_SANDBOX_PASSWORD, hoặc liên hệ quản trị viên."}, "settings.sandbox_confirm_commands": { "en": "Confirm before Cowork runs a command", "ja": "Cowork がコマンドを実行する前に確認する", diff --git a/i18n/login_dialog.py b/i18n/login_dialog.py index 9351670..c5db3ae 100644 --- a/i18n/login_dialog.py +++ b/i18n/login_dialog.py @@ -177,6 +177,9 @@ STRINGS: Dict[str, Dict[str, str]] = { "vi": "Project cho đoạn chat mới"}, "app.nav.no_project": { "en": "No project yet", "ja": "プロジェクトなし", "vi": "Chưa có project"}, + # KHAC no_project: đã có project, chỉ là người dùng chưa chọn cái nào. + "app.nav.pick_project": { + "en": "Select a project…", "ja": "プロジェクトを選択…", "vi": "Chọn project…"}, "app.nav.recents": {"en": "RECENTS", "ja": "最近", "vi": "GẦN ĐÂY"}, "app.nav.all_projects": { "en": "All projects…", "ja": "すべてのプロジェクト…", "vi": "Tất cả project…"}, diff --git a/i18n/settings_dialog.py b/i18n/settings_dialog.py index aa4ef0c..2544101 100644 --- a/i18n/settings_dialog.py +++ b/i18n/settings_dialog.py @@ -45,28 +45,6 @@ STRINGS: Dict[str, Dict[str, str]] = { "ファイアウォールではありません。上のコマンドホワイトリストと併用してください。", "vi": "Kiểm soát ở tầng chính sách (trỏ biến môi trường proxy vào hố đen) — không phải " "firewall tầng kernel. Kết hợp với whitelist lệnh ở trên để phòng thủ nhiều lớp."}, - "settings.sandbox_pw_label": { - "en": "Sandbox Security Password", "ja": "サンドボックスセキュリティのパスワード", - "vi": "Mật khẩu Bảo mật Sandbox"}, - "settings.sandbox_pw_placeholder": { - "en": "Enter password to edit sandbox settings", - "ja": "サンドボックス設定を変更するにはパスワードを入力してください", - "vi": "Nhập mật khẩu để sửa thiết lập sandbox"}, - "settings.sandbox_unlock_btn": {"en": "Unlock", "ja": "ロック解除", "vi": "Mở khoá"}, - "settings.sandbox_locked": { - "en": "Locked (changes disabled)", "ja": "ロック中(変更できません)", - "vi": "Đang khoá (không sửa được)"}, - "settings.sandbox_unlocked": { - "en": "Unlocked", "ja": "ロック解除済み", "vi": "Đã mở khoá"}, - "settings.sandbox_unlocked_body": { - "en": "Sandbox settings unlocked.", "ja": "サンドボックス設定のロックを解除しました。", - "vi": "Đã mở khoá thiết lập sandbox."}, - "settings.sandbox_pw_wrong_title": { - "en": "Wrong Password", "ja": "パスワードが違います", "vi": "Sai mật khẩu"}, - "settings.sandbox_pw_wrong_body": { - "en": "Password incorrect. Sandbox settings remain locked.", - "ja": "パスワードが正しくありません。サンドボックス設定はロックされたままです。", - "vi": "Mật khẩu không đúng. Thiết lập sandbox vẫn bị khoá."}, "settings.sandbox_unlimited": {"en": "Unlimited", "ja": "無制限", "vi": "Không giới hạn"}, "settings.sandbox_cpu_label": {"en": "CPU limit", "ja": "CPU 制限", "vi": "Giới hạn CPU"}, "settings.sandbox_memory_label": {"en": "Memory limit", "ja": "メモリ制限", "vi": "Giới hạn bộ nhớ"}, diff --git a/infrastructure/filesystem/command_tools.py b/infrastructure/filesystem/command_tools.py index 37aa4c4..8d88e40 100644 --- a/infrastructure/filesystem/command_tools.py +++ b/infrastructure/filesystem/command_tools.py @@ -128,6 +128,15 @@ def install_package(ctx: ToolContext, args: Dict[str, Any], package = str(args.get("package", "")).strip() if not package: return {"ok": False, "output": "No package specified."} + # ``pip install`` bắt buộc phải ra internet, mà ``deps.pip_install`` chạy + # subprocess với ``os.environ`` nguyên vẹn — biến proxy hố đen của + # ``network_blocked_env`` không chạm tới nó. Từ chối thẳng ở đây (giống cách + # run_command chặn theo tên các công cụ không đi qua proxy) thay vì để pip + # thử 600 giây rồi báo một lỗi proxy khó hiểu. + if ctx.block_network: + return {"ok": False, "output": ( + "install_package: network access is blocked by the Sandbox Security Layer " + "(\"Block network for agent-run commands\" is on in Settings).")} python = _sandbox_python(ctx, cancel, on_output) ok, detail = pip_install(package, cancel=cancel, on_output=on_output, python=python) head = f"Installed {package}." if ok else f"Could not install {package}." diff --git a/infrastructure/filesystem/fetch_tools.py b/infrastructure/filesystem/fetch_tools.py index 7e3f73d..4c5534d 100644 --- a/infrastructure/filesystem/fetch_tools.py +++ b/infrastructure/filesystem/fetch_tools.py @@ -6,11 +6,26 @@ tag added in R05-T01/domain/tools/tool_registry.py describes. """ from __future__ import annotations -from typing import Any, Dict +from typing import Any, Dict, Optional from .tool_context import ToolContext +def _network_refusal(ctx: ToolContext, tool: str) -> Optional[Dict[str, Any]]: + """Lời từ chối khi Sandbox Security Layer đang chặn mạng; None nếu được đi. + + ``block_network`` trước đây chỉ được đọc ở ``command_tools.py`` (lệnh shell), + nên ba tool mang ``ToolCapability.NETWORK`` ở file này vẫn ra internet bình + thường trong khi Monitoring báo "Mạng: Bị chặn". Kiểm ở đây, TRƯỚC mọi lời + gọi mạng, để công tắc chặn đúng thứ nó nói là chặn. + """ + if not ctx.block_network: + return None + return {"ok": False, "output": ( + f"{tool}: network access is blocked by the Sandbox Security Layer " + "(\"Block network for agent-run commands\" is on in Settings).")} + + def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]: """Fetch a URL's text content (web page / online document / SharePoint- OneDrive share link) via link_fetch — the same parser task-link attachments @@ -20,6 +35,9 @@ def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]: return {"ok": False, "output": "fetch_url: 'url' is required."} if not url.lower().startswith(("http://", "https://")): return {"ok": False, "output": f"fetch_url: not an http(s) URL: {url}"} + blocked = _network_refusal(ctx, "fetch_url") + if blocked is not None: + return blocked if not ctx.allow_url_fetch: return {"ok": False, "output": ("fetch_url: URL fetching is turned off in Settings → Security " @@ -37,6 +55,9 @@ def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]: def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]: """Tìm issue trên Jira bằng JQL.""" + blocked = _network_refusal(ctx, "jira_search") + if blocked is not None: + return blocked from cowork_local.core import jira_tool out = jira_tool.search(ctx.jira, str(args.get("jql", "")), @@ -47,6 +68,9 @@ def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]: def jira_get_issue(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]: """Lấy chi tiết một issue Jira theo mã.""" + blocked = _network_refusal(ctx, "jira_get_issue") + if blocked is not None: + return blocked from cowork_local.core import jira_tool out = jira_tool.get_issue(ctx.jira, str(args.get("key", ""))) diff --git a/infrastructure/filesystem/tool_context.py b/infrastructure/filesystem/tool_context.py index ad681d9..eddbf94 100644 --- a/infrastructure/filesystem/tool_context.py +++ b/infrastructure/filesystem/tool_context.py @@ -37,12 +37,16 @@ class ToolContext: # None (default) = no limits, matching pre-existing behavior. resource_limits: Optional[Dict[str, float]] = None # Sandbox Security Layer — Settings' "Block network for agent commands" - # (policy-level, see deps.py::network_blocked_env). False (default) = + # — the proxy-env block for shell commands (deps.py::network_blocked_env) + # AND a flat refusal from every NETWORK-capability tool, which reaches the + # net in-process where proxy env vars mean nothing. False (default) = # unrestricted, matching pre-existing behavior. block_network: bool = False - # Whether the fetch_url tool may read URLs — SEPARATE from block_network - # (reading a web page/share link for info is safe; running networked shell - # commands is the risk). Defaults True; set from agent_security.allow_url_fetch. + # Whether the fetch_url tool may read URLs. Its own toggle, but NOT a way + # around block_network: with the network blocked every NETWORK-capability + # tool is refused first (fetch_tools.py::_network_refusal), so this flag only + # decides anything while the network is open. Defaults True; set from + # agent_security.allow_url_fetch. allow_url_fetch: bool = True # Jira read connector config (base_url/email/api_token) — None disables the # jira_* tools' ability to connect. Populated from config.data["jira"]. diff --git a/presentation/shell/nav_rail.py b/presentation/shell/nav_rail.py index 32fdfb8..f1ac0af 100644 --- a/presentation/shell/nav_rail.py +++ b/presentation/shell/nav_rail.py @@ -197,9 +197,9 @@ class NavRailMixin: def _nav_rows(self): """(tree, page, sub, label, icon, enabled) for every row, rail order. - Workspace contributes all five of its sub-views — including the two the - project gate currently disables — so the rail never changes shape while - the user is looking at it. + Workspace contributes all five of its sub-views; ``_rebuild_nav`` bỏ + những hàng mà cổng project đang đóng (Cowork, GraphRAG) thay vì hiện + chúng ở dạng mờ. """ rows = [(self.nav, self._ROW_WORKSPACE, sub, label, ic, on) for label, sub, ic, on in self.workspace.nav_entries()] @@ -238,15 +238,13 @@ class NavRailMixin: tree.clear() tree.blockSignals(blocked) for tree, page, sub, label, icon_name, enabled in spec: + if not enabled: + # Cổng project đóng → bỏ hẳn hàng, không hiện dạng mờ nữa. + continue it = QTreeWidgetItem([""] if self._nav_collapsed else [label]) it.setIcon(0, _icon(icon_name)) it.setData(0, Qt.UserRole, {"page": page, "sub": sub}) - if not enabled: - # Same gate as before, shown instead of hidden: the row stays - # in place, greyed, and says why it cannot be opened. - it.setDisabled(True) - it.setToolTip(0, tr("app.nav.needs_project")) - elif self._nav_collapsed: + if self._nav_collapsed: it.setToolTip(0, label) blocked = tree.blockSignals(True) tree.addTopLevelItem(it) diff --git a/presentation/shell/rail_project.py b/presentation/shell/rail_project.py index 706accc..77442dd 100644 --- a/presentation/shell/rail_project.py +++ b/presentation/shell/rail_project.py @@ -42,6 +42,12 @@ class RailProjectMixin: # No project yet: say so, and say what to do about it, instead of # leaving an empty box and a button that silently does nothing. self.nav_project.addItem(tr("app.nav.no_project"), "") + elif not current: + # Có project nhưng CHƯA chọn cái nào (mở app lên, hoặc vừa xoá + # project đang mở). Không có mục này thì combo rơi về mục 0 và + # chỉ bừa vào project đầu danh sách, trong khi cổng + # Cowork/GraphRAG vẫn đóng — hai chỗ nói hai đằng. + self.nav_project.insertItem(0, tr("app.nav.pick_project"), "") idx = self.nav_project.findData(current) if idx >= 0: self.nav_project.setCurrentIndex(idx) diff --git a/tests/test_sandbox_block_network.py b/tests/test_sandbox_block_network.py new file mode 100644 index 0000000..d94b353 --- /dev/null +++ b/tests/test_sandbox_block_network.py @@ -0,0 +1,125 @@ +"""Công tắc "Chặn mạng cho lệnh do agent chạy" phải chặn MỌI đường ra mạng của +agent, không riêng ``run_command``. + +Trước đây ``block_network`` chỉ được đọc ở đúng một chỗ — +``infrastructure/filesystem/command_tools.py`` trong ``run_command`` — nên bốn +tool mang ``ToolCapability.NETWORK`` (``fetch_url``, ``jira_search``, +``jira_get_issue``, ``install_package``) vẫn ra internet bình thường trong khi +màn Monitoring báo "Mạng: Bị chặn" và docstring của ``fetch_tools`` tự nhận là +*"Honors the Sandbox Security Layer's Block network policy"*. Người dùng bật +công tắc rồi thấy agent vẫn search web được — đúng triệu chứng được báo. + +Hai nhóm bài: + +* **hành vi** — bật thì mọi tool NETWORK từ chối TRƯỚC khi chạm mạng, tắt thì + đường cũ giữ nguyên (chặn một chiều là hỏng tính năng); +* **guardrail** — thêm tool mạng mới mà quên chặn thì bài ở đây đỏ ngay. +""" +from __future__ import annotations + +from typing import Dict + +import pytest + +from cowork_local.core.tools import ToolContext +from cowork_local.domain.tools import BUILT_IN_CAPABILITIES, ToolCapability +from cowork_local.infrastructure.filesystem import command_tools, fetch_tools + +# tên tool -> (handler, args hợp lệ tối thiểu). Args phải hợp lệ, nếu không bài +# test sẽ đỏ vì lỗi thiếu tham số chứ không vì cổng chặn mạng. +_TOOL_MANG: Dict[str, tuple] = { + "fetch_url": (fetch_tools.fetch_url, {"url": "https://example.com/"}), + "jira_search": (fetch_tools.jira_search, {"jql": "project = ABC"}), + "jira_get_issue": (fetch_tools.jira_get_issue, {"key": "ABC-1"}), + "install_package": (command_tools.install_package, {"package": "requests"}), +} + + +@pytest.fixture +def cam_ra_mang(monkeypatch): + """Mọi đường ra mạng thật đều nổ. + + Vừa giữ cho bộ test không chạm internet, vừa làm lộ tool nào lọt qua cổng + chặn: nó sẽ đỏ ngay tại lời gọi mạng thay vì im lặng đi ra ngoài. + """ + def no_ra_mang(*args, **kwargs): + raise AssertionError("tool đã chạm mạng dù 'Chặn mạng' đang bật") + + from cowork_local.core import deps, jira_tool, link_fetch + + monkeypatch.setattr(link_fetch, "fetch_link_preview", no_ra_mang) + monkeypatch.setattr(jira_tool, "search", no_ra_mang) + monkeypatch.setattr(jira_tool, "get_issue", no_ra_mang) + monkeypatch.setattr(jira_tool, "get_issue_by_url", no_ra_mang) + monkeypatch.setattr(deps, "pip_install", no_ra_mang) + + +# ---- hành vi: bật công tắc thì mọi tool mạng đều bị chặn ----------------- + +@pytest.mark.parametrize("ten", sorted(_TOOL_MANG)) +def test_bat_chan_mang_thi_tool_tu_choi_truoc_khi_cham_mang(tmp_path, cam_ra_mang, ten): + """Đây là chính triệu chứng người dùng báo: bật rồi mà vẫn ra được web.""" + handler, args = _TOOL_MANG[ten] + ctx = ToolContext(tmp_path, block_network=True) + + ket_qua = handler(ctx, args) + + assert ket_qua["ok"] is False, f"{ten} vẫn chạy khi đang chặn mạng" + assert "Sandbox Security Layer" in ket_qua["output"], ket_qua["output"] + + +def test_allow_url_fetch_khong_lach_duoc_chan_mang(tmp_path, cam_ra_mang): + """Hai công tắc vẫn độc lập, nhưng "Chặn mạng" là cái mạnh hơn: bật nó thì + "Cho phép agent lấy dữ liệu từ URL" không mở lại đường được.""" + ctx = ToolContext(tmp_path, block_network=True, allow_url_fetch=True) + + ket_qua = fetch_tools.fetch_url(ctx, {"url": "https://example.com/"}) + + assert ket_qua["ok"] is False + + +# ---- hành vi: tắt công tắc thì đường cũ giữ nguyên ----------------------- + +def test_tat_chan_mang_thi_fetch_url_van_doc_duoc(tmp_path, monkeypatch): + """Chặn một chiều là hỏng tính năng — cổng phải mở lại được.""" + from cowork_local.core import link_fetch + + monkeypatch.setattr(link_fetch, "fetch_link_preview", + lambda url: f"nội dung của {url}") + ctx = ToolContext(tmp_path, block_network=False) + + ket_qua = fetch_tools.fetch_url(ctx, {"url": "https://example.com/"}) + + assert ket_qua["ok"] is True + assert "example.com" in ket_qua["output"] + + +def test_tat_chan_mang_thi_install_package_van_chay(tmp_path, monkeypatch): + from cowork_local.core import deps + + da_goi = [] + + def gia_lap_pip(package, **kwargs): + da_goi.append(package) + return True, "ok" + + monkeypatch.setattr(deps, "pip_install", gia_lap_pip) + ctx = ToolContext(tmp_path, block_network=False) + + ket_qua = command_tools.install_package(ctx, {"package": "requests"}) + + assert da_goi == ["requests"] + assert ket_qua["ok"] is True + + +# ---- guardrail: danh sách tool mạng không được lệch ---------------------- + +def test_moi_tool_mang_deu_co_bai_o_day(): + """``BUILT_IN_CAPABILITIES`` là nơi duy nhất khai báo tool nào chạm mạng. + Thêm một tool NETWORK mới mà quên chặn thì bài này đỏ ngay.""" + tag_mang = {ten for ten, cap in BUILT_IN_CAPABILITIES.items() + if cap & ToolCapability.NETWORK} + + assert tag_mang == set(_TOOL_MANG), ( + "danh sách tool mạng đã đổi — chặn tool mới ở cổng block_network " + "rồi bổ sung vào _TOOL_MANG") diff --git a/tests/ui/test_project_gate_subtabs.py b/tests/ui/test_project_gate_subtabs.py new file mode 100644 index 0000000..c6fca99 --- /dev/null +++ b/tests/ui/test_project_gate_subtabs.py @@ -0,0 +1,141 @@ +"""Cổng project: Cowork và GraphRAG chỉ hiện khi đã chọn một project cụ thể. + +Cổng có hai mặt và trước đây chỉ mặt thứ nhất làm đúng: + +* **Sub-tab trong màn Workspace** — ``_update_tab_visibility`` vốn đã ẩn/hiện + đúng. Chỗ hỏng nằm ở ``refresh()``: nó mặc định ``row_to_select = 0`` nên lúc + mở app (chưa ai bấm gì) danh sách tự chọn hộ project đầu tiên, mở cổng cho một + project người dùng chưa hề chọn. +* **Hàng trên menu trái** — ``NavRailMixin._rebuild_nav`` từng dựng hàng ở dạng + mờ kèm tooltip thay vì bỏ đi ("shown instead of hidden"), nên người dùng vẫn + thấy Cowork/GraphRAG trên menu dù cổng đang đóng. + +Các bài dưới đây chốt cả hai mặt, ở cả ba trạng thái: chưa chọn → ẩn, chọn rồi → +hiện, bỏ chọn → ẩn lại. +""" +from __future__ import annotations + +import pytest + +pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật") + + +@pytest.fixture +def win(qapp, tmp_path): + """MainWindow thật — cần cả cửa sổ vì phải kiểm cả menu trái. + + Đọc project từ ``~/.cowork_local`` như bản cài thật (``core/projects.py`` + gắn ``PROJECTS_DIR`` vào đó) nên các bài này KHÔNG tạo/xoá project nào. + Bài nào cần cổng MỞ thì gọi thẳng ``_update_tab_visibility(True)`` thay vì + tạo project trên đĩa của người chạy test. + """ + from cowork_local.presentation.shell.bootstrap import build_config, build_context + from cowork_local.presentation.shell.main_window import MainWindow + + config_path = tmp_path / "config.json" + build_config(config_path) + window = MainWindow(build_context(config_path)) + yield window + window.close() + + +def _cong(ws): + """Hai sub-tab nằm sau cổng project, bỏ qua bản dựng không có chúng.""" + return [(ten, idx) for ten, idx in + (("Cowork", ws._cowork_tab_idx), ("GraphRAG", ws._graphrag_tab_idx)) + if idx >= 0] + + +def _hang_menu(win): + """Nhãn của mọi hàng đang có trên cột menu trái.""" + return [win.nav.topLevelItem(i).text(0) for i in range(win.nav.topLevelItemCount())] + + +# ---- mặt 1: không tự chọn hộ project ------------------------------------ + +def test_mo_app_len_chua_chon_thi_khong_tu_chon_ho(win): + """Đây là nguyên nhân gốc: ``refresh()`` từng mặc định chọn dòng 0.""" + ws = win.workspace + + assert ws._current_id == "" + assert ws.project_list.currentRow() == -1 + + +def test_chua_chon_project_thi_hai_sub_tab_deu_an(win): + ws = win.workspace + + for ten, idx in _cong(ws): + assert ws.tabs.isTabVisible(idx) is False, f"{ten} hiện khi chưa chọn project" + assert ws.subtab_available(idx) is False, f"{ten} vẫn mở cổng" + + +def test_chua_chon_project_thi_dung_o_tab_project(win): + """Ẩn hai tab kia mà lại đứng ở một tab đã ẩn thì màn hình trống trơn.""" + ws = win.workspace + + assert ws.current_subtab() == ws._project_tab_idx + + +def test_refresh_giu_nguyen_project_dang_chon(win): + """Sửa cổng không được làm mất lựa chọn hiện có: ``keep`` vẫn phải thắng.""" + ws = win.workspace + if ws.project_list.count() == 0: + pytest.skip("máy chạy test chưa có project nào để chọn") + + ws.project_list.setCurrentRow(0) + dang_chon = ws._current_id + + ws.refresh() + + assert ws._current_id == dang_chon + assert ws.project_list.currentRow() >= 0 + + +# ---- mặt 2: menu trái bỏ hẳn hàng, không hiện dạng mờ ------------------- + +def test_chua_chon_project_thi_menu_trai_khong_co_hai_hang(win): + """Đây là thứ người dùng nhìn thấy — trước đây hai hàng vẫn nằm đó, chỉ mờ.""" + nhan = _hang_menu(win) + + assert "Cowork" not in nhan, f"Cowork vẫn trên menu: {nhan}" + assert "GraphRAG" not in nhan, f"GraphRAG vẫn trên menu: {nhan}" + + +def test_mo_cong_thi_hai_hang_quay_lai_menu_trai(win): + """Bỏ hàng phải đảo ngược được, nếu không thì chọn project xong vẫn kẹt.""" + ws = win.workspace + ws._current_id = "gia-lap" + ws._update_tab_visibility(True) + + nhan = _hang_menu(win) + assert "Cowork" in nhan, f"Cowork không quay lại: {nhan}" + assert "GraphRAG" in nhan, f"GraphRAG không quay lại: {nhan}" + + +def test_mo_cong_thi_hai_sub_tab_cung_hien_lai(win): + ws = win.workspace + ws._current_id = "gia-lap" + ws._update_tab_visibility(True) + + for ten, idx in _cong(ws): + assert ws.tabs.isTabVisible(idx) is True, f"{ten} vẫn ẩn khi cổng đã mở" + + +def test_dong_cong_lai_thi_hai_hang_bien_mat(win): + """Cổng phải đóng lại được, không chỉ mở một chiều.""" + ws = win.workspace + ws._current_id = "gia-lap" + ws._update_tab_visibility(True) + ws._current_id = "" + ws._update_tab_visibility(False) + + nhan = _hang_menu(win) + assert "Cowork" not in nhan and "GraphRAG" not in nhan, nhan + + +def test_cac_hang_khac_khong_bi_anh_huong(win): + """Chỉ hai hàng sau cổng bị bỏ — phần còn lại của menu giữ nguyên.""" + nhan = _hang_menu(win) + + for bat_buoc in ("Project", "Co4E"): + assert bat_buoc in nhan, f"{bat_buoc} biến mất khỏi menu: {nhan}" diff --git a/tests/ui/test_sandbox_unlock_security.py b/tests/ui/test_sandbox_unlock_security.py index 3f89e9e..d1b59b7 100644 --- a/tests/ui/test_sandbox_unlock_security.py +++ b/tests/ui/test_sandbox_unlock_security.py @@ -1,23 +1,21 @@ -"""Sandbox Security unlock — chốt các đường KHÔNG được mở khoá (SEC-20260907-01). +"""Sandbox Security Layer: bốn công tắc luôn sửa được, không còn khoá mật khẩu. -``DEFAULT_CONFIG`` ship ``agent_security.sandbox_pw = ""`` kể từ commit -``3827552 fix(security): remove shared unlock defaults``, và cấu hình đưa tới -dialog LUÔN được deep-merge với defaults đó -(``infrastructure/config/json_config_repository.py``). Nghĩa là trên mọi bản cài -không đặt ``COWORK_SANDBOX_PASSWORD``, mật khẩu đã lưu là chuỗi rỗng — và phép so -sánh ``pw == self._sandbox_pw`` nhận luôn ô nhập trống. +Trước đây nhóm này bị khoá: bốn công tắc dựng ra ở trạng thái ``setEnabled(False)`` +và chỉ mở khi nhập đúng mật khẩu qua ``_sandbox_unlock()``. Bộ bài cũ ở file này +(SEC-20260907-01) chốt các đường KHÔNG được mở khoá — chúng mất đối tượng kiểm khi +tính năng khoá bị bỏ theo yêu cầu, nên được thay bằng các bài dưới đây. -Ba nhóm bài ở đây: +Docstring của ``_sandbox_unlock()`` cũ đã tự nói rõ nó là gì: *"khoá phía giao diện +để chặn bấm nhầm vào một mục nhạy cảm, KHÔNG phải cơ chế bảo mật thật"*. Rào thật +nằm ở tầng sandbox lúc chạy lệnh, không ở hộp thoại Cài đặt. -* **đường tấn công** — chốt đúng lỗ trên; -* **đường đi đúng** — bản vá không được phá, kể cả với mật khẩu có dấu; -* **chặn cả lớp lỗi** — commit ``3827552`` sửa ``config.py`` nhưng bỏ sót bản sao - thứ hai của literal trong ``ui/settings_dialog.py``. Bài cuối quét chéo mọi thư - mục nguồn để lần sau không sót kiểu đó nữa. +Hai nhóm bài: + +* **hành vi mới** — mở hộp thoại là bật/tắt được ngay, không qua bước nào; +* **guardrail** — quét mã nguồn để lần sau không ai lặng lẽ khoá lại. """ from __future__ import annotations -import re from pathlib import Path import pytest @@ -27,209 +25,76 @@ import pytest from .test_settings_dialog_dac_ta import _Ctx -@pytest.fixture -def shown(monkeypatch): - """Ghi lại mọi QMessageBox thay vì bật modal thật (modal sẽ treo test). - - Trả về list các ``(loại, tiêu_đề, nội_dung)`` — cần thiết để phân biệt - "chưa cấu hình mật khẩu" với "sai mật khẩu"; nếu chỉ nuốt hộp thoại đi thì - hai nhánh gộp lại làm một mà test vẫn xanh. - """ - from PySide6.QtWidgets import QMessageBox - calls: list[tuple[str, str, str]] = [] - - def _record(kind): - def _fn(_parent, title, text, *a, **k): - calls.append((kind, title, text)) - return staticmethod(_fn) - - monkeypatch.setattr(QMessageBox, "warning", _record("warning")) - monkeypatch.setattr(QMessageBox, "information", _record("information")) - return calls - - -def _dialog(stored_pw: str): - """SettingsDialog với ``sandbox_pw`` đúng như bản cài thật: key CÓ mặt.""" +def _dialog(): + """SettingsDialog dựng đúng như bản cài thật.""" from cowork_local.ui.settings_dialog import SettingsDialog - ctx = _Ctx() - ctx.config.data["agent_security"]["sandbox_pw"] = stored_pw - return SettingsDialog(ctx) + return SettingsDialog(_Ctx()) -# ---- đường tấn công ------------------------------------------------------ - -def test_o_trong_khong_mo_duoc_khoa(qapp, shown): - """Chưa đặt mật khẩu (sandbox_pw == "") thì ô nhập trống KHÔNG được mở khoá.""" - dlg = _dialog("") - dlg.sandbox_pw_edit.setText("") - - dlg._sandbox_unlock() - - assert dlg._sandbox_unlocked is False - dlg.deleteLater() +_CONG_TAC = ("sandbox_confirm", "sandbox_block_network", "sec_enabled", "ai_check") -def test_go_bua_khi_chua_dat_mat_khau_cung_khong_mo_duoc(qapp, shown): - """Mật khẩu lưu rỗng thì KHÔNG chuỗi nào mở được, kể cả chuỗi khác rỗng.""" - dlg = _dialog("") - dlg.sandbox_pw_edit.setText("bat ky") +# ---- hành vi mới: sửa được ngay, không cần mật khẩu ---------------------- - dlg._sandbox_unlock() +@pytest.mark.parametrize("ten", _CONG_TAC) +def test_cong_tac_sua_duoc_ngay_khi_mo_hop_thoai(qapp, ten): + """Đây là chính yêu cầu: không còn bước nhập mật khẩu nào chắn ở giữa.""" + dlg = _dialog() - assert dlg._sandbox_unlocked is False - dlg.deleteLater() + assert getattr(dlg, ten).isEnabled() is True, f"{ten} vẫn bị khoá" -def test_mat_khau_sai_khong_mo_duoc(qapp, shown): - """Đã đặt mật khẩu thì gõ sai vẫn khoá.""" - dlg = _dialog("K7MNP2QRSTVW") - dlg.sandbox_pw_edit.setText("K7MNP2QRSTVX") +@pytest.mark.parametrize("ten", _CONG_TAC) +def test_bat_tat_duoc_va_luu_dung_gia_tri(qapp, ten): + """Bật/tắt phải ăn vào widget — khoá cũ chặn đúng ở bước này.""" + dlg = _dialog() + w = getattr(dlg, ten) - dlg._sandbox_unlock() - - assert dlg._sandbox_unlocked is False - dlg.deleteLater() + truoc = w.isChecked() + w.setChecked(not truoc) + assert w.isChecked() is (not truoc) + w.setChecked(truoc) + assert w.isChecked() is truoc -# ---- thông báo phải phân biệt được hai tình huống ------------------------- +def test_khong_con_widget_mat_khau_nao(qapp): + """Ô nhập, nút Mở khoá và nhãn "Đang khoá" phải biến mất khỏi hộp thoại.""" + dlg = _dialog() -def test_chua_cau_hinh_bao_khac_voi_sai_mat_khau(qapp, shown): - """Hai nhánh phải nói hai chuyện khác nhau. - - Người chưa từng đặt mật khẩu mà nhận "Password incorrect" sẽ gõ lại mãi một - thứ không tồn tại. Không có bài này thì gộp hai nhánh về một thông báo chung - vẫn xanh hết. - """ - from cowork_local.i18n import tr - - dlg = _dialog("") - dlg.sandbox_pw_edit.setText("") - dlg._sandbox_unlock() - chua_cau_hinh = list(shown) - dlg.deleteLater() - - shown.clear() - dlg2 = _dialog("K7MNP2QRSTVW") - dlg2.sandbox_pw_edit.setText("sai roi") - dlg2._sandbox_unlock() - sai_mat_khau = list(shown) - dlg2.deleteLater() - - assert len(chua_cau_hinh) == 1, "phải hiện đúng một thông báo" - assert len(sai_mat_khau) == 1 - assert chua_cau_hinh[0][2] == tr("settings.sandbox_pw_unset_body") - assert chua_cau_hinh[0][2] != sai_mat_khau[0][2], ( - "chưa cấu hình mật khẩu và sai mật khẩu phải là hai thông báo khác nhau") + for ten in ("sandbox_pw_edit", "sandbox_unlock_btn", "sandbox_locked_status", + "sandbox_pw_label"): + assert not hasattr(dlg, ten), f"{ten} vẫn còn trên hộp thoại" -# ---- đường đi đúng vẫn phải chạy ---------------------------------------- +def test_khong_con_duong_mo_khoa_trong_ma(qapp): + """Hàm mở khoá và cờ trạng thái khoá không còn tồn tại.""" + import cowork_local.ui.settings_dialog as mod -def test_mat_khau_dung_van_mo_duoc(qapp, shown): - """Bản vá không được phá đường đi hợp lệ.""" - dlg = _dialog("K7MNP2QRSTVW") - dlg.sandbox_pw_edit.setText("K7MNP2QRSTVW") - - dlg._sandbox_unlock() - - assert dlg._sandbox_unlocked is True - dlg.deleteLater() + dlg = _dialog() + assert not hasattr(dlg, "_sandbox_unlock") + assert not hasattr(dlg, "_sandbox_unlocked") + assert not hasattr(dlg, "_sandbox_widgets") + assert not hasattr(mod, "_sandbox_password_matches") -@pytest.mark.parametrize("pw", ["mật khẩu", "パスワード", "sénhà-2026"]) -def test_mat_khau_co_dau_khong_lam_crash(qapp, shown, pw): - """``secrets.compare_digest`` ném TypeError nếu str có ký tự ngoài ASCII. +# ---- guardrail: không ai khoá lại mà không sửa bài test này -------------- - App mặc định tiếng Việt và phục vụ khách Nhật, nên chữ có dấu trong ô mật - khẩu là input bình thường. Phải so sánh trên bytes. - """ - dlg = _dialog(pw) - dlg.sandbox_pw_edit.setText(pw) +def test_ma_nguon_khong_con_khoa_nhom_sandbox(): + """Chặn cả lớp lỗi: lần sau ai thêm lại ``setEnabled(False)`` cho nhóm này + thì bài này đỏ ngay, không đợi có người mở app mới thấy.""" + src = (Path(__file__).resolve().parents[2] + / "ui" / "settings_dialog.py").read_text(encoding="utf-8") + code = "\n".join(l for l in src.splitlines() if not l.strip().startswith("#")) - dlg._sandbox_unlock() # không được ném TypeError - - assert dlg._sandbox_unlocked is True - dlg.deleteLater() + for dau_hieu in ("_sandbox_unlock", "_sandbox_widgets", "_sandbox_unlocked", + "sandbox_pw"): + assert dau_hieu not in code, f"khoá sandbox đã quay lại: {dau_hieu}" -def test_mat_khau_co_dau_sai_thi_van_khoa(qapp, shown): - """Chữ có dấu không được biến thành đường mở khoá dễ dãi.""" - dlg = _dialog("mật khẩu") - dlg.sandbox_pw_edit.setText("mat khau") +def test_phep_quet_thuc_su_doc_duoc_file(): + """Lưới an toàn: đổi tên file làm bài trên quét rỗng mà vẫn xanh.""" + src = (Path(__file__).resolve().parents[2] + / "ui" / "settings_dialog.py").read_text(encoding="utf-8") - dlg._sandbox_unlock() - - assert dlg._sandbox_unlocked is False - dlg.deleteLater() - - -# ---- hàm so khớp, gọi thẳng ---------------------------------------------- - -@pytest.mark.parametrize("entered,stored,expected", [ - ("", "", False), # cả hai rỗng - ("", "K7MNP2QRSTVW", False), # ô nhập rỗng - ("K7MNP2QRSTVW", "", False), # chưa đặt mật khẩu — nhánh phòng thủ - ("K7MNP2QRSTVW", "K7MNP2QRSTVW", True), - ("mật khẩu", "mật khẩu", True), # ngoài ASCII - ("mật khẩu", "mat khau", False), -]) -def test_ham_so_khop(entered, stored, expected): - """Gọi thẳng ``_sandbox_password_matches`` — phủ cả nhánh mà call site đã - chặn trước bằng return sớm.""" - from cowork_local.ui.settings_dialog import _sandbox_password_matches - assert _sandbox_password_matches(entered, stored) is expected - - -# ---- chặn cả lớp lỗi ----------------------------------------------------- - -#: ``.get("", "")`` — mặc định trông có -#: vẻ an toàn nhưng thực ra là credential nằm trong mã nguồn. Nó cũng là code -#: chết: cấu hình đã deep-merge với DEFAULT_CONFIG nên key luôn tồn tại. -#: -#: Cố ý KHÔNG bắt ``key`` và ``code`` trần: ``it.get("key", "?")`` của Jira -#: (``core/jira_tool.py``) là mã issue, không phải credential. Danh sách dưới đây -#: chỉ gồm tên đã mang nghĩa bí mật. -_CREDENTIAL_FALLBACK = re.compile( - r'\.get\(\s*["\'][a-z_]*' - r'(?:pw|passwd|password|secret|token|api_key|unlock_code|access_code)' - r'[a-z_]*["\']\s*,\s*["\'][^"\']+["\']' -) - -#: Quét CHÉO mọi thư mục nguồn, không chỉ tầng giao diện. Sai sót gốc của commit -#: ``3827552`` là sửa ``config.py`` mà quên bản sao trong ``ui/`` — tức là lỗi đi -#: xuyên thư mục, nên phép quét cũng phải đi xuyên thư mục. -_SCANNED = ( - "ui", "presentation", "core", "infrastructure", "application", "domain", - "mcp_servers", "providers", "security", "theme", "config.py", "state.py", -) - - -def test_khong_con_fallback_credential_trong_ma_nguon(): - """Không file nguồn nào được đặt credential làm giá trị mặc định của ``.get()``.""" - root = Path(__file__).resolve().parents[2] - offenders = [] - for name in _SCANNED: - target = root / name - if target.is_file(): - files = [target] - elif target.is_dir(): - files = [p for p in target.rglob("*.py") if "__pycache__" not in p.parts] - else: # thư mục bị đổi tên/xoá - continue - for path in files: - for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): - if _CREDENTIAL_FALLBACK.search(line): - offenders.append( - f"{path.relative_to(root).as_posix()}:{lineno}: {line.strip()}") - - assert not offenders, "credential nằm trong mã nguồn:\n " + "\n ".join(offenders) - - -def test_phep_quet_thuc_su_nhin_thay_file(): - """Lưới an toàn cho bài trên: đổi tên thư mục làm nó quét rỗng mà vẫn xanh.""" - root = Path(__file__).resolve().parents[2] - seen = sum( - 1 for name in _SCANNED - for _ in ([root / name] if (root / name).is_file() - else (root / name).rglob("*.py") if (root / name).is_dir() else []) - ) - assert seen > 200, f"chỉ quét được {seen} file — phạm vi quét đã hỏng" + assert "class SettingsDialog" in src + assert len(src) > 2000, f"chỉ đọc được {len(src)} ký tự — đường dẫn đã hỏng" diff --git a/tests/ui/test_startup_landing.py b/tests/ui/test_startup_landing.py index ed391b1..a0f0f6d 100644 --- a/tests/ui/test_startup_landing.py +++ b/tests/ui/test_startup_landing.py @@ -128,19 +128,24 @@ def test_bam_project_tren_thanh_menu_an_ngay_lan_dau(window): def test_khi_cong_project_MO_thi_ha_canh_o_cowork_va_vet_sang_theo(window): - """Nhánh của người dùng ĐÃ có project — nhánh mà bug được báo. + """Nhánh của người dùng ĐÃ chọn một project — nhánh mà bug được báo. - Môi trường test không có project nào (cố ý: ``core/projects.py`` ghi vào - ``~/.cowork_local`` thật). Mở cổng bằng tay để đi đúng nhánh đó mà không - phải tạo project trên đĩa. + Trước đây bài này mở cổng bằng cửa sau ``setTabVisible(True)`` vì môi trường + test không có project nào (``core/projects.py`` ghi vào ``~/.cowork_local`` + thật, nên test không tạo project). Cửa sau đó hết tác dụng từ khi cổng được + điều khiển bằng ``_current_id``: ``refresh()``/``goto_all_projects()`` đóng + lại ngay. Giờ mở cổng bằng đúng đường thật — chọn một project — và bỏ qua + bài này trên máy chưa có project nào. """ from PySide6.QtCore import Qt ws = window.workspace if ws._cowork_tab_idx < 0: pytest.skip("bản dựng này không có sub-tab Cowork") + if ws.project_list.count() == 0: + pytest.skip("máy chạy test chưa có project nào — cổng không mở được") - ws.tabs.setTabVisible(ws._cowork_tab_idx, True) + ws.project_list.setCurrentRow(0) try: window.goto_all_projects() @@ -154,5 +159,5 @@ def test_khi_cong_project_MO_thi_ha_canh_o_cowork_va_vet_sang_theo(window): f"nội dung ở Cowork ({ws._cowork_tab_idx}) " f"nhưng thanh menu sáng ở {data.get('sub')}") finally: - ws.tabs.setTabVisible(ws._cowork_tab_idx, False) + ws.project_list.setCurrentRow(-1) # đóng cổng lại đúng đường thật window.goto_all_projects() diff --git a/tools/check_dialogs.py b/tools/check_dialogs.py index 47ad800..0d80bd5 100644 --- a/tools/check_dialogs.py +++ b/tools/check_dialogs.py @@ -26,7 +26,7 @@ from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E4 SETTINGS_FIELDS = [ "language_combo", "theme_combo", "tray_chk", "notify_chk", "provider_combo", "prov_base", "prov_key", "prov_model", - "sandbox_pw_edit", "sandbox_unlock_btn", "sandbox_confirm", + "sandbox_confirm", "sandbox_block_network", "sec_enabled", "ai_check", ] TASK_FIELDS = [ diff --git a/tools/check_nav.py b/tools/check_nav.py index 8fe0a41..626aeb5 100644 --- a/tools/check_nav.py +++ b/tools/check_nav.py @@ -74,22 +74,30 @@ def main() -> int: main_rows, bottom_rows = rows(win.nav), rows(win.nav_bottom) n_total = len(main_rows) + len(bottom_rows) - # Five Workspace sub-views + Schedule, then Dashboard + Monitoring. - if len(main_rows) != 6: - fails.append(f"thanh chinh co {len(main_rows)} dong, cho 6") + # Workspace gop cac sub-view DANG MO CONG + Schedule, roi Dashboard + + # Monitoring. Cowork/GraphRAG chi co mat khi da chon mot project, nen so + # dong doi theo cong thay vi co dinh 6. + mo_cong = sum(1 for _l, _i, _ic, on in win.workspace.nav_entries() if on) + cho_chinh = mo_cong + 1 + if len(main_rows) != cho_chinh: + fails.append(f"thanh chinh co {len(main_rows)} dong, cho {cho_chinh}") if len(bottom_rows) != 2: fails.append(f"nhom day co {len(bottom_rows)} dong, cho 2") if any(sub is not None for _l, _p, sub, _o in bottom_rows): fails.append("nhom day khong duoc mang sub-tab") - # The two gated rows must be PRESENT (that is the point) — greyed is fine. + # Hang bi cong project dong thi BO HAN khoi menu; hang dang mo phai co mat. labels = [r[0] for r in main_rows] - ws_labels = [lab for lab, _i, _ic, _on in win.workspace.nav_entries()] - for lab in ws_labels: + ws_mo = [lab for lab, _i, _ic, on in win.workspace.nav_entries() if on] + ws_dong = [lab for lab, _i, _ic, on in win.workspace.nav_entries() if not on] + for lab in ws_mo: if lab not in labels: - fails.append(f"mat dong Workspace: {lab}") - print(f"du 5 man Workspace tren thanh menu: {all(l in labels for l in ws_labels)}" - f" ({', '.join(ws_labels)})") + fails.append(f"mat dong Workspace dang mo cong: {lab}") + for lab in ws_dong: + if lab in labels: + fails.append(f"dong Workspace dang dong cong van tren menu: {lab}") + print(f"man Workspace dang mo cong tren menu: {all(l in labels for l in ws_mo)}" + f" ({', '.join(ws_mo) or 'khong co'})") # Highlight must follow the content for every row, both ways round. # Re-fetch items by index every time: navigating can rebuild the rail, which @@ -145,19 +153,18 @@ def main() -> int: if ws_strip: fails.append("dai tab Workspace hien lai — trung voi thanh menu") - # The whole point of the change: with no project selected the two gated rows - # must stay in place, greyed — not vanish and resize the menu. + # Yeu cau: chua chon project thi Cowork/GraphRAG khong duoc hien tren menu. win.workspace._update_tab_visibility(False) app.processEvents() gated = rows(win.nav) - off = [lab for lab, _p, _s, on in gated if not on] + nhan_gated = [lab for lab, _p, _s, _on in gated] print() - print(f"chua chon project : van du {len(gated)} dong, mo: {off or 'khong'}") - if len(gated) != len(main_rows): - fails.append(f"chua chon project thi thanh menu con {len(gated)} dong " - f"(truoc {len(main_rows)}) — item van bien mat") - if len(off) != 2: - fails.append(f"cho 2 dong bi mo (Cowork, GraphRAG), thay {len(off)}") + print(f"chua chon project : con {len(gated)} dong ({', '.join(nhan_gated)})") + for lab in ("Cowork", "GraphRAG"): + if lab in nhan_gated: + fails.append(f"chua chon project ma {lab} van tren menu") + if any(not on for _l, _p, _s, on in gated): + fails.append("con dong bi mo tren menu — dang le phai bo han") # --- rail header: project picker + new chat (Phase A) ------------------ print() diff --git a/ui/settings_dialog.py b/ui/settings_dialog.py index 0771de3..8e14b06 100644 --- a/ui/settings_dialog.py +++ b/ui/settings_dialog.py @@ -13,20 +13,17 @@ chưa từng được gán nên gọi vào là AttributeError. """ from __future__ import annotations -import secrets - from PySide6.QtCore import Qt from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import ( QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, - QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, - QMessageBox, QPushButton, QScrollArea, QSpinBox, + QGroupBox, QHBoxLayout, QListWidget, QListWidgetItem, + QScrollArea, QSpinBox, QTreeWidgetItem, QVBoxLayout, QWidget, ) from ..i18n import tr from .dialog_buttons import dialog_buttons -from .icons import IconLabel from .widgets import ToggleSwitch @@ -37,25 +34,6 @@ from ..presentation.settings.routing_settings_widget import RoutingSettingsWidge from ..presentation.settings.about_widget import AboutSettingsWidget -def _sandbox_password_matches(entered: str, stored: str) -> bool: - """Whether ``entered`` unlocks the Sandbox Security group. - - An empty ``stored`` must never match. ``DEFAULT_CONFIG`` ships - ``agent_security.sandbox_pw = ""`` and the config handed to this dialog is - always deep-merged with those defaults, so a plain ``entered == stored`` - accepts an empty field on every install that never set a password. The MS365 - unlock guards the same way — see ``json_config_repository.unlock_ms365``. - - Both sides are compared as UTF-8 bytes, not as ``str``: - ``compare_digest`` raises ``TypeError`` on ``str`` holding anything outside - ASCII, and this app defaults to Vietnamese and ships to Japanese customers, - so an accented password is ordinary input rather than an edge case. - """ - if not entered or not stored: - return False - return secrets.compare_digest(entered.encode("utf-8"), stored.encode("utf-8")) - - class SettingsDialog(QDialog): """Hộp thoại Cài đặt: cột mục lục bên trái, các trang bên phải (Nhà cung cấp · Connectors · Định tuyến · Tham số · Chung). @@ -108,29 +86,10 @@ class SettingsDialog(QDialog): self.sandbox_group = QGroupBox(tr("settings.group.sandbox")) sbl = QVBoxLayout(self.sandbox_group) - # --- Password protection for Sandbox Security (at top) --- - self.sandbox_pw_label = IconLabel("lock", tr("settings.sandbox_pw_label")) - sbl.addWidget(self.sandbox_pw_label) - - pw_row = QHBoxLayout() - self.sandbox_pw_edit = QLineEdit("") - self.sandbox_pw_edit.setPlaceholderText(tr("settings.sandbox_pw_placeholder")) - self.sandbox_pw_edit.setEchoMode(QLineEdit.Password) - pw_row.addWidget(self.sandbox_pw_edit, 1) - self.sandbox_unlock_btn = QPushButton(tr("settings.sandbox_unlock_btn")) - self.sandbox_unlock_btn.clicked.connect(self._sandbox_unlock) - pw_row.addWidget(self.sandbox_unlock_btn) - self.sandbox_locked_status = IconLabel("lock", tr("settings.sandbox_locked"), color="#c00") - self.sandbox_locked_status.text_label().setStyleSheet("color: #c00; font-weight: bold;") - pw_row.addWidget(self.sandbox_locked_status) - sbl.addLayout(pw_row) - self._sandbox_unlocked = False # Start LOCKED — must enter password first - self._sandbox_pw = sec.get("sandbox_pw", "") - - # Separator line between pw section and sandbox settings - pw_sep = QLabel("────────────────") - sbl.addWidget(pw_sep) - + # Nhóm này KHÔNG còn khoá bằng mật khẩu: bốn công tắc dưới đây bật/tắt + # tự do. Khoá cũ chỉ là rào chống bấm nhầm ở phía giao diện, không phải + # cơ chế bảo mật thật (rào thật nằm ở sandbox lúc chạy lệnh), nên bỏ đi + # theo yêu cầu thay vì giữ một bước nhập mật khẩu không bảo vệ được gì. self.sandbox_confirm = ToggleSwitch(tr("settings.sandbox_confirm_commands")) self.sandbox_confirm.setChecked(bool(sec.get("cowork_confirm_commands", False))) self.sandbox_confirm.setToolTip(tr("settings.sandbox_confirm_commands_tooltip")) @@ -160,14 +119,6 @@ class SettingsDialog(QDialog): # Resource limits (CPU/Memory/Disk I/O) moved to the Parameter group # below — see _param_section("settings.group.sandbox_limits"). - # Collect all sandbox-editable widgets and lock them until unlocked - self._sandbox_widgets = [ - self.sandbox_confirm, self.sandbox_block_network, - self.ai_check, self.sec_enabled, - ] - for _w in self._sandbox_widgets: - _w.setEnabled(False) - root.addWidget(self.sandbox_group) # Connectors (MCP / REST API) are managed entirely in Monitoring → Tools @@ -299,34 +250,6 @@ class SettingsDialog(QDialog): - def _sandbox_unlock(self) -> None: - """Mở khoá nhóm cài đặt sandbox bằng mật khẩu. - - Đây là khoá phía giao diện để chặn bấm nhầm vào một mục nhạy cảm, KHÔNG - phải cơ chế bảo mật thật. - """ - pw = self.sandbox_pw_edit.text() - if not self._sandbox_pw: - # No password configured. Refusing with "wrong password" would be a - # dead end — the user would keep retrying a password that cannot - # exist — so name the actual state instead. - QMessageBox.warning(self, tr("settings.sandbox_pw_unset_title"), - tr("settings.sandbox_pw_unset_body")) - return - if _sandbox_password_matches(pw, self._sandbox_pw): - self._sandbox_unlocked = True - self.sandbox_locked_status.setText(tr("settings.sandbox_unlocked")) - self.sandbox_locked_status.set_icon("unlock", "#090") - self.sandbox_locked_status.text_label().setStyleSheet("color: #090; font-weight: bold;") - # Enable all sandbox widgets - for w in self._sandbox_widgets: - w.setEnabled(True) - QMessageBox.information(self, tr("settings.group.sandbox"), - tr("settings.sandbox_unlocked_body")) - else: - QMessageBox.warning(self, tr("settings.sandbox_pw_wrong_title"), - tr("settings.sandbox_pw_wrong_body")) - def _save(self) -> None: """Gom cấu hình từ mọi trang con rồi ghi xuống đĩa.""" data = self.ctx.config.data diff --git a/ui/workspace_tab.py b/ui/workspace_tab.py index 2900a88..a929d92 100644 --- a/ui/workspace_tab.py +++ b/ui/workspace_tab.py @@ -63,10 +63,9 @@ class WorkspaceTab(ProjectEditingMixin, QWidget): """(label, index, icon_name, enabled) for EVERY sub-tab, hidden ones included. - The rail lists all five all the time and greys out the ones the project - gate is currently closing (Cowork, GraphRAG) instead of removing them — - same gate, shown rather than hidden, so the menu stops changing shape - under the user's hand. See nav_subtabs() for the visible-only view. + Cột ``enabled`` là trạng thái cổng project; ``NavRailMixin._rebuild_nav`` + bỏ hẳn những hàng đang đóng (Cowork, GraphRAG) khỏi menu trái cho tới khi + người dùng chọn một project. See nav_subtabs() for the visible-only view. """ icons = {self._project_tab_idx: "folder", self._cowork_tab_idx: "chat", self._co4e_tab_idx: "flow", self._folder_tab_idx: "folder", @@ -78,8 +77,8 @@ class WorkspaceTab(ProjectEditingMixin, QWidget): def subtab_available(self, index: int) -> bool: """False while the project gate is holding this sub-tab shut. - The rail greys those rows out, but that only guards the rail. This lets - every other route ask the same question of the same state. + Rail bỏ hẳn những hàng đó khỏi menu, nhưng đó chỉ chắn được đường vào + qua rail. Hàm này để mọi đường vào khác hỏi cùng một trạng thái. """ return bool(0 <= index < self.tabs.count() and self.tabs.isTabVisible(index)) @@ -610,7 +609,12 @@ class WorkspaceTab(ProjectEditingMixin, QWidget): counts = self._project_counts() self.project_list.blockSignals(True) self.project_list.clear() - row_to_select = 0 + # -1 chứ không phải 0: chưa chọn gì thì KHÔNG tự chọn hộ project đầu + # danh sách. Chọn hộ là mở luôn cổng Cowork/GraphRAG (xem + # _update_tab_visibility) cho một project người dùng chưa hề bấm vào — + # lúc mở app, và cả sau khi xoá project đang mở. Có ``keep`` khớp thì + # vẫn giữ đúng dòng cũ như trước. + row_to_select = -1 for i, p in enumerate(list_projects()): chats, tasks = counts.get(p.project_id, (0, 0)) # No text on the item: the row widget paints the name, and setting