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/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/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/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/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