diff --git a/Task Tracking Template.xlsx b/Task Tracking Template.xlsx index 2456d3e..2671c07 100644 Binary files a/Task Tracking Template.xlsx and b/Task Tracking Template.xlsx differ 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/agents_admin_tab.py b/i18n/agents_admin_tab.py index 261908c..85a4320 100644 --- a/i18n/agents_admin_tab.py +++ b/i18n/agents_admin_tab.py @@ -234,6 +234,12 @@ STRINGS: Dict[str, Dict[str, str]] = { "monitoring.ai_filter_btn": {"en": "AI", "ja": "AI", "vi": "AI"}, "monitoring.page_size_label": { "en": "Rows/page:", "ja": "1ページの行数:", "vi": "Số dòng/trang:"}, + "monitoring.page_indicator": { + "en": "Page {page}/{total}", "ja": "{page}/{total} ページ", "vi": "Trang {page}/{total}"}, + "monitoring.page_prev": { + "en": "Previous page", "ja": "前のページ", "vi": "Trang trước"}, + "monitoring.page_next": { + "en": "Next page", "ja": "次のページ", "vi": "Trang sau"}, "monitoring.pricing_title": { "en": "Model pricing (USD / 1M tokens)", "ja": "モデル価格表 (USD / 100万トークン)", "vi": "Bảng giá model (USD / 1 triệu token)"}, 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/co4e/co4e_workflow_crud.py b/presentation/co4e/co4e_workflow_crud.py index f7b46b7..195ef28 100644 --- a/presentation/co4e/co4e_workflow_crud.py +++ b/presentation/co4e/co4e_workflow_crud.py @@ -10,13 +10,13 @@ from typing import List, Optional from PySide6.QtCore import QSize, Qt from PySide6.QtWidgets import QMenu from ...core import co4e -from ...core.co4e import STEP_DONE, STEP_RUNNING +from ...core.co4e import STEP_RUNNING from ...i18n import tr from ...ui.dialog_buttons import ask_text from ...ui.icons import icon from ...presentation.co4e.co4e_chat_view import _skill_names -_LOCKED_NODE_STATUSES = (STEP_RUNNING, STEP_DONE) +_LOCKED_NODE_STATUSES = (STEP_RUNNING,) class Co4EWorkflowCrudMixin: @@ -169,8 +169,8 @@ class Co4EWorkflowCrudMixin: def _on_node_selected(self, node_id: str) -> None: """Chọn một node thì nạp bước đó vào bảng thuộc tính, tự mở bảng nếu đang gập. - Bước đang chạy hoặc đã chạy xong thì khoá ô nhập liệu ngay khi nạp — - tránh sửa nhầm cấu hình của lần chạy đang xem kết quả. + Chỉ khoá ô nhập liệu khi bước ĐANG chạy (DF-002) — chạy xong rồi thì + vẫn sửa lại được bình thường. """ for n in self.canvas.nodes(): if n.id == node_id: diff --git a/presentation/co4e/node_property_panel.py b/presentation/co4e/node_property_panel.py index 8d40176..bd319e7 100644 --- a/presentation/co4e/node_property_panel.py +++ b/presentation/co4e/node_property_panel.py @@ -294,11 +294,11 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea): def set_locked(self, locked: bool) -> None: """Khoá/mở khoá các trường chỉnh sửa theo trạng thái chạy của bước. - Bước đang chạy hoặc đã chạy xong thì khoá lại — tránh sửa nhầm cấu - hình trong lúc đang xem kết quả của chính lần chạy đó (sửa xong - không rõ là áp dụng cho lần chạy đã xong hay lần chạy tiếp theo). - Nút Chạy/Chạy từ đây/Xoá bước vẫn hoạt động bình thường khi khoá — - chỉ ô nhập liệu bị khoá, không phải cả panel. + Chỉ khoá khi bước ĐANG chạy — tránh sửa nhầm cấu hình trong lúc chưa + biết kết quả (DF-002: trước đây còn khoá cả bước đã chạy xong, khiến + không sửa lại được sau khi run xong). Nút Chạy/Chạy từ đây/Xoá bước + vẫn hoạt động bình thường khi khoá — chỉ ô nhập liệu bị khoá, không + phải cả panel. """ self._locked = locked editable = not locked diff --git a/presentation/monitoring/monitoring_tab.py b/presentation/monitoring/monitoring_tab.py index 5302ed6..1427c39 100644 --- a/presentation/monitoring/monitoring_tab.py +++ b/presentation/monitoring/monitoring_tab.py @@ -39,8 +39,9 @@ _UNBOUNDED_PAGE_SIZE = 100_000 # day-sharded JSONL — unbounded start/end means EVERY day file ever written # gets re-read and re-parsed on EVERY tick, which is what actually made # Monitoring "gây nặng khi log lớn" (see DF-006): the slowness was never in -# rendering (EventTable already caps display at 300 rows — see -# shared/event_table.py::_MAX_ROWS), it was this repeated full-history read. +# rendering (EventTable paginates client-side, 5-100 rows/page — see +# shared/event_table.py::_DEFAULT_PAGE_SIZE), it was this repeated full-history +# read. # 30 days is a live-monitoring window, not a hard retention limit — nothing # is deleted, older days are simply not re-read on every 3s tick. _LOG_WINDOW_DAYS = 30 diff --git a/presentation/monitoring/shared/event_table.py b/presentation/monitoring/shared/event_table.py index 147e18b..d84c9f9 100644 --- a/presentation/monitoring/shared/event_table.py +++ b/presentation/monitoring/shared/event_table.py @@ -6,7 +6,7 @@ from __future__ import annotations from typing import List, Optional -from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt +from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt, Signal from PySide6.QtGui import QBrush, QColor from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget @@ -17,8 +17,8 @@ from ....ui.icons import DOT_GREEN, DOT_RED, icon from .badges import action_label from .formatters import agent_avatar_icon, fmt_event_time -_MAX_ROWS = 300 -PAGE_SIZE_OPTIONS = (50, 100, 300, 500, 1000) +_DEFAULT_PAGE_SIZE = 20 +PAGE_SIZE_OPTIONS = (5, 10, 20, 50, 100) class _TimeItem(QTableWidgetItem): @@ -63,6 +63,12 @@ class EventTable(QTableWidget): "secret_in_output": "warning", } + # Emitted whenever the rendered page changes (new data, page-size change, + # or prev/next navigation) — args are (current_page, page_count), both + # 1-based-friendly in that current_page is 0-indexed but page_count is a + # plain count. filter_scaffold.py's pager label/buttons listen to this. + page_changed = Signal(int, int) + def __init__(self, show_result: bool = True): # Security Events drops the result column entirely (see _ACTION_TINTS). """Bảng sự kiện dùng chung của các tab Giám sát. @@ -71,8 +77,10 @@ class EventTable(QTableWidget): là thất bại nên cột ấy chỉ tốn chỗ. """ self._show_result = show_result - self._page_size = _MAX_ROWS + self._page_size = _DEFAULT_PAGE_SIZE + self._current_page = 0 self._last_events: List[dict] = [] + self._sorted_events: List[dict] = [] super().__init__(0, 7 if show_result else 6) self.setEditTriggers(QTableWidget.NoEditTriggers) self.setSelectionBehavior(QTableWidget.SelectRows) @@ -105,21 +113,56 @@ class EventTable(QTableWidget): """Số dòng đang hiển thị mỗi trang.""" return self._page_size + def page_count(self) -> int: + """Tổng số trang với dữ liệu và số dòng/trang hiện tại (tối thiểu 1).""" + if not self._sorted_events: + return 1 + return -(-len(self._sorted_events) // self._page_size) # ceil div + + def current_page(self) -> int: + """Trang đang hiển thị, đánh số từ 0.""" + return self._current_page + + def go_to_page(self, page: int) -> None: + """Nhảy tới một trang cụ thể (đánh số từ 0), tự kẹp trong khoảng hợp lệ.""" + self._current_page = page + self._render_current_page() + + def next_page(self) -> None: + """Sang trang kế — không làm gì nếu đã ở trang cuối.""" + self.go_to_page(self._current_page + 1) + + def prev_page(self) -> None: + """Về trang trước — không làm gì nếu đã ở trang đầu.""" + self.go_to_page(self._current_page - 1) + def set_page_size(self, n: int) -> None: - """Đổi số dòng hiển thị mỗi trang rồi vẽ lại với dữ liệu đã có sẵn - (không cần refresh lại từ nguồn — set_events() đã lưu lại lần đổ gần nhất).""" + """Đổi số dòng hiển thị mỗi trang, quay về trang đầu, rồi vẽ lại với dữ + liệu đã có sẵn (không cần refresh lại từ nguồn).""" self._page_size = n - self.set_events(self._last_events) + self._current_page = 0 + self._render_current_page() def set_events(self, events: List[dict]) -> None: - """Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, cắt ở ``self._page_size`` - (đổi được qua ``set_page_size`` — control "Số dòng/trang" ở filter_scaffold.py). + """Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, chia trang theo + ``self._page_size`` — xem qua trang khác bằng ``next_page``/``prev_page`` + (nút tiến/lùi ở filter_scaffold.py), không còn bị cắt bỏ vĩnh viễn như + trước (DF-006).""" + self._last_events = events + self._sorted_events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True) + self._current_page = 0 + self._render_current_page() + + def _render_current_page(self) -> None: + """Vẽ đúng một trang (theo ``self._current_page``/``self._page_size``) + từ ``self._sorted_events`` đã sắp sẵn. Tắt sắp xếp trong lúc đổ dữ liệu — để bật, Qt sắp lại sau mỗi dòng và việc nạp chậm đi theo bậc hai. """ - self._last_events = events - events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:self._page_size] + self._current_page = max(0, min(self._current_page, self.page_count() - 1)) + start = self._current_page * self._page_size + events = self._sorted_events[start:start + self._page_size] self.setSortingEnabled(False) self.setRowCount(len(events)) for row, ev in enumerate(events): @@ -164,6 +207,7 @@ class EventTable(QTableWidget): self.setItem(row, col, item) self.setSortingEnabled(True) self.apply_filter(getattr(self, "_filter_needle", "")) + self.page_changed.emit(self._current_page, self.page_count()) def apply_filter(self, needle: str) -> None: """Ẩn/hiện dòng theo từ khoá tìm kiếm (không phân biệt hoa thường).""" diff --git a/presentation/monitoring/shared/filter_scaffold.py b/presentation/monitoring/shared/filter_scaffold.py index 4196749..9b32f19 100644 --- a/presentation/monitoring/shared/filter_scaffold.py +++ b/presentation/monitoring/shared/filter_scaffold.py @@ -95,20 +95,52 @@ def build_filter_scaffold( if with_page_size and isinstance(table, EventTable): # DF-006: the item-per-page count was never surfaced anywhere in # the UI (design called for it) — EventTable already trims to a - # page size internally (default 300), this just makes that - # number visible AND user-choosable instead of a fixed constant. + # page size internally, this just makes that number visible AND + # user-choosable instead of a fixed constant. page_size_lbl = QLabel(tr("monitoring.page_size_label")) page_size_combo = QComboBox() for n in PAGE_SIZE_OPTIONS: page_size_combo.addItem(str(n), n) current = table.page_size() page_size_combo.setCurrentIndex( - PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 2) + PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 0) page_size_combo.currentIndexChanged.connect( lambda i: table.set_page_size(page_size_combo.itemData(i))) row.addWidget(page_size_lbl) row.addWidget(page_size_combo) - parts.update(page_size_label=page_size_lbl, page_size_combo=page_size_combo) + + # DF-006 follow-up: trimming to a page size alone silently dropped + # every row past it with no way back to see them — prev/next + # buttons plus a "trang X/Y" indicator make the rest reachable. + page_prev_btn = QPushButton() + page_prev_btn.setIcon(icon("chevron-left")) + page_prev_btn.setCursor(Qt.PointingHandCursor) + bind_tip(page_prev_btn, "monitoring.page_prev") + page_next_btn = QPushButton() + page_next_btn.setIcon(icon("chevron-right")) + page_next_btn.setCursor(Qt.PointingHandCursor) + bind_tip(page_next_btn, "monitoring.page_next") + page_indicator_lbl = QLabel() + + def _refresh_pager(cur: int = None, total: int = None) -> None: + if cur is None or total is None: + cur, total = table.current_page(), table.page_count() + page_indicator_lbl.setText(tr("monitoring.page_indicator", page=cur + 1, total=total)) + page_prev_btn.setEnabled(cur > 0) + page_next_btn.setEnabled(cur < total - 1) + + page_prev_btn.clicked.connect(table.prev_page) + page_next_btn.clicked.connect(table.next_page) + table.page_changed.connect(_refresh_pager) + _refresh_pager() + + row.addWidget(page_prev_btn) + row.addWidget(page_indicator_lbl) + row.addWidget(page_next_btn) + parts.update( + page_size_label=page_size_lbl, page_size_combo=page_size_combo, + page_prev_btn=page_prev_btn, page_next_btn=page_next_btn, + page_indicator_label=page_indicator_lbl, page_pager_refresh=_refresh_pager) lay.addLayout(row) parts.update(filter_edit=search, ai_filter_btn=ai_btn) diff --git a/presentation/monitoring/tabs/action_logs_tab.py b/presentation/monitoring/tabs/action_logs_tab.py index fd52b11..99d3166 100644 --- a/presentation/monitoring/tabs/action_logs_tab.py +++ b/presentation/monitoring/tabs/action_logs_tab.py @@ -34,6 +34,7 @@ class ActionLogsTab(QWidget): self.ai_filter_btn = parts["ai_filter_btn"] self.detail_panel = parts["detail_panel"] self.page_size_label = parts["page_size_label"] + self.page_pager_refresh = parts["page_pager_refresh"] def set_events(self, events: List[dict]) -> None: """Đổ danh sách sự kiện vào bảng.""" @@ -47,6 +48,7 @@ class ActionLogsTab(QWidget): self.title_lbl.setText(tr(self.title_key)) self.title_refresh_btn.setText(tr("monitoring.refresh")) self.page_size_label.setText(tr("monitoring.page_size_label")) + self.page_pager_refresh() def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None: """Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc.""" diff --git a/presentation/monitoring/tabs/mcp_tab.py b/presentation/monitoring/tabs/mcp_tab.py index 5038da2..03b86b4 100644 --- a/presentation/monitoring/tabs/mcp_tab.py +++ b/presentation/monitoring/tabs/mcp_tab.py @@ -34,6 +34,7 @@ class McpTab(QWidget): self.ai_filter_btn = parts["ai_filter_btn"] self.detail_panel = parts["detail_panel"] self.page_size_label = parts["page_size_label"] + self.page_pager_refresh = parts["page_pager_refresh"] def set_events(self, events: List[dict]) -> None: """Đổ danh sách sự kiện vào bảng.""" @@ -47,6 +48,7 @@ class McpTab(QWidget): self.title_lbl.setText(tr(self.title_key)) self.title_refresh_btn.setText(tr("monitoring.refresh")) self.page_size_label.setText(tr("monitoring.page_size_label")) + self.page_pager_refresh() def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None: """Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc.""" diff --git a/presentation/monitoring/tabs/security_events_tab.py b/presentation/monitoring/tabs/security_events_tab.py index 4c961e7..6485fea 100644 --- a/presentation/monitoring/tabs/security_events_tab.py +++ b/presentation/monitoring/tabs/security_events_tab.py @@ -40,6 +40,7 @@ class SecurityEventsTab(QWidget): self.ai_filter_btn = parts["ai_filter_btn"] self.detail_panel = parts["detail_panel"] self.page_size_label = parts["page_size_label"] + self.page_pager_refresh = parts["page_pager_refresh"] def set_events(self, events: List[dict]) -> None: """Đổ danh sách sự kiện vào bảng.""" @@ -53,6 +54,7 @@ class SecurityEventsTab(QWidget): self.title_lbl.setText(tr(self.title_key)) self.title_refresh_btn.setText(tr("monitoring.refresh")) self.page_size_label.setText(tr("monitoring.page_size_label")) + self.page_pager_refresh() def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None: """Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc.""" 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/run.bat b/run.bat index 09b22ea..71111ca 100644 --- a/run.bat +++ b/run.bat @@ -19,6 +19,14 @@ set "APPHOME=%LOCALAPPDATA%\CoworkLocal" set "VENV=%APPHOME%\venv" set "LAUNCHER=%APPHOME%\launcher" +rem An cua so console NGAY TU DAU, ke ca trong luc kiem tra ben duoi — khong +rem chi truoc luc chay app. Moi cho bao loi (echo + pause) ben duoi tu hien +rem lai cua so truoc khi in, de thong bao van doc duoc. +set "CONSOLE_VIS=%REPO%\scripts\console_visibility.ps1" +if exist "%CONSOLE_VIS%" ( + powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 0 >nul 2>&1 +) + rem -------------------------------------------------------------------------- rem 1. Chon trinh thong dich rem @@ -38,6 +46,7 @@ if exist "%VENV%\Scripts\python.exe" ( ) if not defined RUNPY ( + if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1 echo. echo [LỖI] Không tìm thấy Python. Chạy install.bat trước đã. echo. @@ -51,6 +60,7 @@ rem biet la phai chay install.bat. if not exist "%VENV%\Scripts\python.exe" ( !RUNPY! -c "import PySide6" >nul 2>&1 if errorlevel 1 ( + if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1 echo. echo [LỖI] Thư viện chưa được cài. Chạy install.bat trước đã. echo. @@ -94,6 +104,7 @@ if /I "%REPO_NAME%"=="cowork_local" ( if exist "!PKGPATH!\cowork_local" rmdir "!PKGPATH!\cowork_local" >nul 2>&1 mklink /J "!PKGPATH!\cowork_local" "%REPO%" >nul 2>&1 if errorlevel 1 ( + if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1 echo. echo [LOI] Khong tao duoc lien ket thu muc: echo "!PKGPATH!\cowork_local" -> "%REPO%" @@ -112,6 +123,7 @@ rem Chot lai: goi phai THAT SU nhin thay duoc qua duong dan vua dung. Khong co rem buoc nay thi mot junction hong chi hien ra duoi dang loi Python kho hieu rem ("'cowork_local' is a package and cannot be directly executed"). if not exist "!PKGPATH!\cowork_local\__main__.py" ( + if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1 echo. echo [LOI] Khong tim thay cowork_local\__main__.py qua duong dan: echo "!PKGPATH!" @@ -139,18 +151,12 @@ set "PYTHONIOENCODING=utf-8" cd /d "%REPO%" rem -------------------------------------------------------------------------- -rem 4. An cua so console trong luc chay +rem 4. Chay app rem -rem App la GUI (Qt), khong can console — nhung no chia se console cua chinh -rem cmd nay (khong tu mo cua so rieng), nen cua so den cua run.bat cu the -rem hien suot phien lam viec neu khong lam gi. An no ngay truoc khi chay, roi -rem chi hien lai NEU app thoat loi, de thong bao loi ben duoi van doc duoc. +rem App la GUI (Qt), khong can console — cua so console da bi an tu dau file +rem roi (xem khoi CONSOLE_VIS phia tren), chi hien lai NEU app thoat loi, de +rem thong bao loi ben duoi van doc duoc. rem -------------------------------------------------------------------------- -set "CONSOLE_VIS=%REPO%\scripts\console_visibility.ps1" -if exist "%CONSOLE_VIS%" ( - powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 0 >nul 2>&1 -) - !RUNPY! -m cowork_local %* set "RC=%ERRORLEVEL%" diff --git a/tests/test_co4e_edit_lock.py b/tests/test_co4e_edit_lock.py new file mode 100644 index 0000000..12a4145 --- /dev/null +++ b/tests/test_co4e_edit_lock.py @@ -0,0 +1,55 @@ +"""DF-002 (phần b) — node đang chạy HOẶC đã chạy xong không cho edit thông +tin trong Node. Trước khi sửa, ``_LOCKED_NODE_STATUSES`` khoá cả STEP_RUNNING +lẫn STEP_DONE, nên một bước đã chạy xong không bao giờ sửa lại được nữa. + +Fix: chỉ khoá khi bước ĐANG chạy (STEP_RUNNING) — chạy xong rồi thì mở khoá +trở lại. Test này chốt cả nguồn sự thật (tuple +``co4e_workflow_crud._LOCKED_NODE_STATUSES``) lẫn hành vi ở widget +(``StepConfigPanel.set_locked``), để không bị hồi quy về hành vi cũ. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + +QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication + +from cowork_local.core.co4e import ( + STEP_DONE, STEP_ERROR, STEP_IDLE, STEP_PLANNED, STEP_RUNNING, +) +from cowork_local.presentation.co4e.co4e_workflow_crud import _LOCKED_NODE_STATUSES +from cowork_local.presentation.co4e.node_property_panel import StepConfigPanel + + +@pytest.fixture(scope="module") +def qapp(): + app = QApplication.instance() or QApplication([]) + yield app + + +def test_only_running_status_is_locked() -> None: + """Một bước đã chạy xong (STEP_DONE) phải sửa lại được — chỉ bước đang + thực sự chạy (STEP_RUNNING) mới bị khoá.""" + assert _LOCKED_NODE_STATUSES == (STEP_RUNNING,) + assert STEP_DONE not in _LOCKED_NODE_STATUSES + assert STEP_IDLE not in _LOCKED_NODE_STATUSES + assert STEP_ERROR not in _LOCKED_NODE_STATUSES + assert STEP_PLANNED not in _LOCKED_NODE_STATUSES + + +def test_set_locked_disables_then_reenables_edit_fields(qapp) -> None: + panel = StepConfigPanel() + panel.setEnabled(True) # panel starts disabled until a step is loaded + + panel.set_locked(True) + assert not panel.label_edit.isEnabled() + assert not panel.instructions_edit.isEnabled() + assert not panel.model_combo.isEnabled() + + panel.set_locked(False) + assert panel.label_edit.isEnabled() + assert panel.instructions_edit.isEnabled() + assert panel.model_combo.isEnabled() diff --git a/tests/test_monitoring_page_size.py b/tests/test_monitoring_page_size.py index 1df786a..74edf8e 100644 --- a/tests/test_monitoring_page_size.py +++ b/tests/test_monitoring_page_size.py @@ -1,9 +1,11 @@ -"""DF-006 — the "Số dòng/trang" (rows per page) control: EventTable's -page-size state (presentation/monitoring/shared/event_table.py) and its -QComboBox wiring in build_filter_scaffold (.../shared/filter_scaffold.py). -No dedicated test existed for this before — the design called for a -user-visible/choosable item-per-page control, and this exercises it end to -end (combo selection -> EventTable actually re-trimming its rows).""" +"""DF-006 — the "Số dòng/trang" (rows per page) control plus real prev/next +pagination: EventTable's page-size/page-index state +(presentation/monitoring/shared/event_table.py) and its QComboBox + pager +button wiring in build_filter_scaffold (.../shared/filter_scaffold.py). + +Options are 5/10/20/50/100 with a next/prev pager, per the QA follow-up on +DF-006 — the earlier fix only trimmed to a page size (dropping every row past +it with no way back); this exercises the real paging end to end.""" from __future__ import annotations import os @@ -32,19 +34,62 @@ def _events(n: int): "name": f"e{i}", "ok": True, "detail": ""} for i in range(n)] -def test_default_page_size_matches_old_max_rows(qapp) -> None: - table = EventTable() - assert table.page_size() == 300 - table.set_events(_events(500)) - assert table.rowCount() == 300 +def test_page_size_options_are_5_10_20_50_100() -> None: + assert PAGE_SIZE_OPTIONS == (5, 10, 20, 50, 100) -def test_set_page_size_retrims_without_reloading(qapp) -> None: +def test_default_page_size(qapp) -> None: table = EventTable() - table.set_events(_events(500)) + assert table.page_size() == 20 + table.set_events(_events(45)) + assert table.rowCount() == 20 + assert table.page_count() == 3 + assert table.current_page() == 0 + + +def test_set_page_size_resets_to_first_page(qapp) -> None: + table = EventTable() + table.set_events(_events(45)) + table.next_page() + assert table.current_page() == 1 table.set_page_size(50) assert table.page_size() == 50 - assert table.rowCount() == 50 + assert table.current_page() == 0 + assert table.rowCount() == 45 # only 45 events total, fits in one page of 50 + + +def test_next_prev_page_navigate_without_dropping_rows(qapp) -> None: + table = EventTable() + table.set_events(_events(45)) + table.set_page_size(20) + assert table.rowCount() == 20 + + table.next_page() + assert table.current_page() == 1 + assert table.rowCount() == 20 + + table.next_page() + assert table.current_page() == 2 + assert table.rowCount() == 5 # last page: remainder + + table.next_page() # already on last page — stays put + assert table.current_page() == 2 + + table.prev_page() + assert table.current_page() == 1 + table.prev_page() + table.prev_page() # already on first page — stays put + assert table.current_page() == 0 + + +def test_page_changed_signal_reports_current_and_total(qapp) -> None: + table = EventTable() + seen = [] + table.page_changed.connect(lambda cur, total: seen.append((cur, total))) + table.set_events(_events(45)) + table.set_page_size(20) + table.next_page() + assert seen[-1] == (1, 3) def test_page_size_combo_is_only_added_when_requested(qapp) -> None: @@ -52,19 +97,44 @@ def test_page_size_combo_is_only_added_when_requested(qapp) -> None: table = EventTable() parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=False) assert "page_size_combo" not in parts + assert "page_prev_btn" not in parts def test_page_size_combo_changes_the_table(qapp) -> None: page = QWidget() table = EventTable() - table.set_events(_events(500)) + table.set_events(_events(45)) parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True) combo = parts["page_size_combo"] assert combo.count() == len(PAGE_SIZE_OPTIONS) - assert combo.currentData() == 300 # matches EventTable's current page_size + assert combo.currentData() == 20 # matches EventTable's current page_size - idx = PAGE_SIZE_OPTIONS.index(50) + idx = PAGE_SIZE_OPTIONS.index(10) combo.setCurrentIndex(idx) - assert table.page_size() == 50 - assert table.rowCount() == 50 + assert table.page_size() == 10 + assert table.rowCount() == 10 + + +def test_pager_buttons_disable_at_bounds_and_indicator_updates(qapp) -> None: + page = QWidget() + table = EventTable() + table.set_events(_events(45)) + parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True) + table.set_page_size(20) + prev_btn, next_btn = parts["page_prev_btn"], parts["page_next_btn"] + indicator = parts["page_indicator_label"] + + assert not prev_btn.isEnabled() + assert next_btn.isEnabled() + assert indicator.text() == "Trang 1/3" + + next_btn.click() + assert prev_btn.isEnabled() + assert next_btn.isEnabled() + assert indicator.text() == "Trang 2/3" + + next_btn.click() + assert prev_btn.isEnabled() + assert not next_btn.isEnabled() + assert indicator.text() == "Trang 3/3" 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