From 7607f4403060078d0b6c290ec8ce7a7d86bb86c1 Mon Sep 17 00:00:00 2001 From: thanhnv Date: Wed, 16 Sep 2026 00:05:34 +0900 Subject: [PATCH 01/15] chore: checkpoint current performance and UI changes --- core/doc_extract.py | 27 ++++++--- core/history.py | 31 +++++++++- docs/PERFORMANCE_CHECKPOINT.md | 23 ++++++++ performance.py | 31 ++++++++++ presentation/chat/chat_agents.py | 21 +++++-- presentation/chat/chat_history_widget.py | 13 ++++- presentation/monitoring/monitoring_tab.py | 14 +++-- presentation/shell/main_window.py | 7 +-- presentation/shell/session_events.py | 12 +++- ui/workspace_tab.py | 69 ++++++++++++++++++----- 10 files changed, 205 insertions(+), 43 deletions(-) create mode 100644 docs/PERFORMANCE_CHECKPOINT.md create mode 100644 performance.py diff --git a/core/doc_extract.py b/core/doc_extract.py index 04ffd74..1cbadb1 100644 --- a/core/doc_extract.py +++ b/core/doc_extract.py @@ -94,17 +94,28 @@ def find_input_files(folder: Path, exts: set[str] | None = None, capped at ``max_files`` (0 = unlimited), ``total_matched`` is the count before that cap, so a caller can report how many were skipped.""" exts = exts or INPUT_EXTS + # Do not sort an unbounded recursive tree merely to return a small prefix. + # The caller receives a stable lexical order for the bounded result, while + # traversal stops as soon as the configured file budget is reached. + files: list[Path] = [] + total = 0 try: - matched = sorted( - f for f in folder.rglob("*") - if f.is_file() - and not any(part.startswith(".") for part in f.relative_to(folder).parts) - and f.suffix.lower() in exts - ) + for f in folder.rglob("*"): + if not f.is_file(): + continue + try: + relative = f.relative_to(folder) + except ValueError: + continue + if any(part.startswith(".") for part in relative.parts) or f.suffix.lower() not in exts: + continue + total += 1 + if max_files <= 0 or len(files) < max_files: + files.append(f) except OSError: return [], 0 - files = matched if max_files <= 0 else matched[:max_files] - return files, len(matched) + files.sort(key=lambda p: str(p).lower()) + return files, total def find_soffice() -> str | None: diff --git a/core/history.py b/core/history.py index 5d14fc6..a9e31a3 100644 --- a/core/history.py +++ b/core/history.py @@ -16,6 +16,17 @@ from datetime import datetime from pathlib import Path from typing import Any, Dict, List +from ..performance import span + +_LIST_CACHE: dict[tuple[str, str, int], List[Dict[str, Any]]] = {} + + +def _invalidate_history_cache(directory: Path) -> None: + prefix = str(Path(directory).resolve()) + for key in list(_LIST_CACHE): + if key[0] == prefix: + _LIST_CACHE.pop(key, None) + def new_session_id() -> str: """Id phiên mới theo mốc thời gian, chính xác tới mili giây.""" @@ -78,6 +89,7 @@ def save_conversation( # R06-T02: atomic write - see infrastructure/persistence/json/atomic_write.py. from ..infrastructure.persistence.json.atomic_write import write_json write_json(path, payload) + _invalidate_history_cache(directory) return path @@ -85,6 +97,7 @@ def delete_conversation(path) -> None: """Xoá file hội thoại; không có thì bỏ qua.""" try: Path(path).unlink() + _invalidate_history_cache(Path(path).parent) except OSError: pass @@ -96,6 +109,7 @@ def rename_conversation(path, new_title: str) -> None: data = load_conversation(path) data["title"] = new_title write_json(Path(path), data) + _invalidate_history_cache(Path(path).parent) def set_pinned(path, pinned: bool) -> None: @@ -105,6 +119,7 @@ def set_pinned(path, pinned: bool) -> None: data = load_conversation(path) data["pinned"] = bool(pinned) write_json(Path(path), data) + _invalidate_history_cache(Path(path).parent) def load_conversation(path: Path) -> Dict[str, Any]: @@ -197,8 +212,16 @@ def list_conversations(directory: Optional[Path] = None, query: str = "") -> Lis if not directory or not directory.exists(): return [] q = (query or "").strip().lower() + try: + cache_key = (str(directory.resolve()), q, directory.stat().st_mtime_ns) + except OSError: + return [] + cached = _LIST_CACHE.get(cache_key) + if cached is not None: + return [dict(item) for item in cached] items: List[Dict[str, Any]] = [] - for path in directory.glob("*.json"): + with span("history.list", query=bool(q)): + for path in directory.glob("*.json"): try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): @@ -221,4 +244,10 @@ def list_conversations(directory: Optional[Path] = None, query: str = "") -> Lis }) # pinned first, then most recent items.sort(key=lambda d: (not d["pinned"], -d["mtime"])) + _LIST_CACHE[cache_key] = [dict(item) for item in items] + # Keep this bounded; old directory signatures become unreachable after a + # write and should not grow process memory forever. + if len(_LIST_CACHE) > 256: + for old in list(_LIST_CACHE)[:64]: + _LIST_CACHE.pop(old, None) return items diff --git a/docs/PERFORMANCE_CHECKPOINT.md b/docs/PERFORMANCE_CHECKPOINT.md new file mode 100644 index 0000000..5d3311d --- /dev/null +++ b/docs/PERFORMANCE_CHECKPOINT.md @@ -0,0 +1,23 @@ +Source HEAD: db80289 (preserved) +Branch: perf/fsg-performance + +Baseline: app import 1517ms; config 934ms; MainWindow 1742ms (offscreen, local machine). + +Packets completed: +- P0: measured constructor with cProfile; dominant cost was provider model discovery (~0.7s network worker) and eager Workspace composition. +- P2: cache history listing by directory mtime/query and coalesce sidebar refresh bursts. +- P3: batch streaming Markdown/layout renders at 40ms; final content remains intact. +- P4: bounded attachment discovery avoids sorting a full recursive tree when a cap is set. +- P5: instrument monitoring log refresh; existing 30-day bounded window retained. +- P6: defer provider model discovery to the first Qt event-loop turn. + +After: config 452ms; MainWindow 599ms in the same offscreen smoke benchmark (discovery no longer blocks construction). +Streaming render count is now bounded by batch cadence rather than token count. +Representative history benchmark: 1,000 files 383.6ms cold / 0.8ms cached on this machine. + +Relevant commits: c5cb258 (perf: defer discovery and reduce UI refresh work). +Remaining bottleneck: eager Workspace/Co4E/Folder widget construction and import-time PySide6 overhead. + +Closure pass (starting HEAD 58b5220): Workspace now keeps Co4E, Folder, and GraphRAG as tab placeholders and creates each once on first selection. MainWindow benchmark: 357.6ms; first opens Co4E 143.1ms, Folder 148.0ms, GraphRAG 270.6ms; repeat opens 0.0–2.4ms. Focused lazy navigation/project tests: 15 passed. Pytest temp failures were ACL/path setup issues, not production assertions; a pre-created writable repository-local temp base allowed the focused gates to pass. +Remaining startup cost is base PySide6/application import and eager Cowork shell; further lazy work is not justified without broader architectural risk. +Performance initiative status: closed for this pass. diff --git a/performance.py b/performance.py new file mode 100644 index 0000000..1a5884b --- /dev/null +++ b/performance.py @@ -0,0 +1,31 @@ +"""Tiny opt-in performance tracing helpers. + +Tracing is disabled by default and emits only timings/counts, never prompts, +credentials, file contents, or provider payloads. +""" +from __future__ import annotations + +import logging +import os +import time +from contextlib import contextmanager + +_LOG = logging.getLogger("cowork.performance") + + +def enabled() -> bool: + return os.environ.get("COWORK_PERF_TRACE", "").strip().lower() in {"1", "true", "yes"} + + +@contextmanager +def span(name: str, **fields): + if not enabled(): + yield + return + started = time.perf_counter() + try: + yield + finally: + elapsed = (time.perf_counter() - started) * 1000.0 + safe = " ".join(f"{k}={v}" for k, v in fields.items()) + _LOG.info("perf %s %.1fms%s", name, elapsed, f" {safe}" if safe else "") diff --git a/presentation/chat/chat_agents.py b/presentation/chat/chat_agents.py index d280edb..a9a8ae0 100644 --- a/presentation/chat/chat_agents.py +++ b/presentation/chat/chat_agents.py @@ -115,10 +115,23 @@ class ChatAgentsMixin: if err: self.status_message.emit(tr("chatpanel.agent_list_error", err=err)) - w = AgentWorker(job) - w.finished_ok.connect(done) - self._agent_worker = w - w.start() + # Model discovery can involve a provider/network request. Constructing + # the chat panel during startup must not wait for it; schedule it after + # the first event-loop turn so the initial shell can paint immediately. + from PySide6.QtCore import QTimer + + if getattr(self, "_agent_refresh_pending", False): + return + self._agent_refresh_pending = True + + def start_worker() -> None: + self._agent_refresh_pending = False + w = AgentWorker(job) + w.finished_ok.connect(done) + self._agent_worker = w + w.start() + + QTimer.singleShot(0, start_worker) def _populate_agents(self, models, keep: str) -> None: """Đổ danh sách vào bộ chọn Agent. diff --git a/presentation/chat/chat_history_widget.py b/presentation/chat/chat_history_widget.py index b1898ee..223377d 100644 --- a/presentation/chat/chat_history_widget.py +++ b/presentation/chat/chat_history_widget.py @@ -51,6 +51,8 @@ class MessageBubble(QFrame): super().__init__() self.role = role self._text = "" + self._stream_pending = False + self._render_count = 0 self._collapsible = collapsible self._title = title self._head = None @@ -164,12 +166,20 @@ class MessageBubble(QFrame): def append_delta(self, delta: str) -> None: """Nối thêm một mẩu văn bản đang phát dần từ model rồi vẽ lại dạng markdown.""" self._text += delta - self.set_markdown(self._text) + if not self._stream_pending: + self._stream_pending = True + QTimer.singleShot(40, self.flush_stream) + + def flush_stream(self) -> None: + if self._stream_pending: + self._stream_pending = False + self.set_markdown(self._text) def set_markdown(self, text: str) -> None: """Đặt toàn bộ nội dung, hiển thị dạng markdown, rồi co giãn lại chiều cao.""" self._text = text self.body.setMarkdown(text) + self._render_count += 1 self._autosize() if self._collapsible: self._update_head() @@ -393,4 +403,3 @@ class ChatView(QScrollArea): ChatHistoryWidget = ChatView __all__ = ["ChatView", "ChatHistoryWidget", "MessageBubble"] - diff --git a/presentation/monitoring/monitoring_tab.py b/presentation/monitoring/monitoring_tab.py index 1427c39..d808f65 100644 --- a/presentation/monitoring/monitoring_tab.py +++ b/presentation/monitoring/monitoring_tab.py @@ -19,6 +19,7 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel, QTabWidget, QVBoxLayout, QWid from ...core import audit_log from ...i18n import on_language_changed, tr from ...state import AppContext +from ...performance import span from .tabs.action_logs_tab import ActionLogsTab from .tabs.agent_status_tab import AgentStatusTab from .tabs.mcp_tab import McpTab @@ -282,12 +283,13 @@ class MonitoringTab(QWidget): """ start = date.today() - timedelta(days=_LOG_WINDOW_DAYS) shared_dir = self.ctx.config.shared_dir - if shared_dir: - from ...core import telemetry_shared - shared_events = telemetry_shared.load_shared_audit_events(shared_dir, start=start) - if shared_events: - return shared_events - return audit_log.load_events(start=start) + with span("monitoring.load_events", window_days=_LOG_WINDOW_DAYS): + if shared_dir: + from ...core import telemetry_shared + shared_events = telemetry_shared.load_shared_audit_events(shared_dir, start=start) + if shared_events: + return shared_events + return audit_log.load_events(start=start) def _apply_events_to_event_tabs(self, events: List[dict]) -> None: """Filters the ALREADY-LOADED event list (see ``_load_events`` — one diff --git a/presentation/shell/main_window.py b/presentation/shell/main_window.py index 4573bae..92fb1b9 100644 --- a/presentation/shell/main_window.py +++ b/presentation/shell/main_window.py @@ -32,9 +32,7 @@ from .rail_metrics import _NAV_MIN_WIDTH from .tray_manager import TrayManager from ...state import AppContext from ...core.task_scheduler import TaskScheduler -from ...ui.cowork_tab import CoworkTab from ...ui.sidebar import HistorySidebar -from ..graph.structure_graph_view import StructureGraphView from ...ui.workspace_tab import WorkspaceTab @@ -111,10 +109,9 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin, # Workspace screen (per selected project). GraphRAG's heavy # QtWebEngine is still built lazily on first display # (StructureGraphView._ensure_web). + from ...ui.cowork_tab import CoworkTab self.cowork = CoworkTab(ctx) - self.structure = StructureGraphView(ctx) - self.structure.status_message.connect(self.statusBar().showMessage) - self.cowork.output_changed.connect(self.structure.schedule_rescan) + self.structure = None self.cowork.status_message.connect(self.statusBar().showMessage) # Refresh History (list + running markers + current highlight) whenever a # conversation is created/updated or a turn finishes. diff --git a/presentation/shell/session_events.py b/presentation/shell/session_events.py index 8897747..d115749 100644 --- a/presentation/shell/session_events.py +++ b/presentation/shell/session_events.py @@ -42,7 +42,14 @@ class SessionEventsMixin: self.sidebar.refresh() self._refresh_rail_recents() # the rail shortcut follows the panel - QTimer.singleShot(0, _do) + # Coalesce bursts from turn/tool/history signals into one sidebar read. + timer = getattr(self, "_history_refresh_timer", None) + if timer is None: + timer = QTimer(self) + timer.setSingleShot(True) + timer.timeout.connect(_do) + self._history_refresh_timer = timer + timer.start(0) def _on_scheduled_task_done(self, task_id: str, ok: bool) -> None: """Desktop notification for a finished scheduled task (toast always, tray balloon when the window isn't focused), then refresh History — @@ -106,4 +113,5 @@ class SessionEventsMixin: """Project được tạo/sửa/xoá: gom nhóm lại cột lịch sử và cập nhật nhãn thư mục.""" self.sidebar.refresh() # History regroups by project self.cowork._apply_output_folder_label() # project may have been renamed - self.structure._refresh_project_combo() # GraphRAG's project lock list follows too + if getattr(self, "structure", None) is not None: + self.structure._refresh_project_combo() # GraphRAG's project lock list follows too diff --git a/ui/workspace_tab.py b/ui/workspace_tab.py index a929d92..e966929 100644 --- a/ui/workspace_tab.py +++ b/ui/workspace_tab.py @@ -202,27 +202,26 @@ class WorkspaceTab(ProjectEditingMixin, QWidget): cpl.addWidget(self._cowork) self._cowork_tab_idx = self.tabs.addTab(cowork_page, tr("workspace.tab_cowork")) - # Co4E — node-graph workflow studio (built-in flows, agents, skills, a - # runner + chat). Always available (not project-gated): its workflows - # live globally under ~/.cowork_local/co4e, not inside one project. - # Placed BEFORE GraphRAG in the tab order (user request). - from .co4e_tab import Co4ETab - - self._co4e = Co4ETab(self.ctx) - self._co4e_tab_idx = self.tabs.addTab(self._co4e, tr("workspace.tab_co4e")) + # Co4E and Folder are intentionally placeholders at startup. Their + # widget trees pull in a large amount of Qt/UI code, but neither is on + # the initial Project surface. The real page is created exactly once + # when its tab is first selected (see _ensure_heavy_tab). + self._co4e = None + self._folder = None + self._co4e_placeholder = QWidget() + self._folder_placeholder = QWidget() + self._co4e_tab_idx = self.tabs.addTab(self._co4e_placeholder, tr("workspace.tab_co4e")) self.tabs.setTabToolTip(self._co4e_tab_idx, tr("workspace.tab_co4e_tooltip")) # Folder — a two-pane file explorer (tree + view/edit) placed right below # Co4E. Always available (not project-gated); its root follows the # selected project's workspace folder when one is chosen. - from ..presentation.folder.folder_tab import FolderTab + self._folder_tab_idx = self.tabs.addTab(self._folder_placeholder, tr("workspace.tab_folder")) - self._folder = FolderTab(self.ctx, cowork=self._cowork) - self._folder.status_message.connect(self.status_message) - self._folder_tab_idx = self.tabs.addTab(self._folder, tr("workspace.tab_folder")) - - if self._structure is not None: - self._graphrag_tab_idx = self.tabs.addTab(self._structure, tr("workspace.tab_graphrag")) + self._graph_placeholder = QWidget() + self._graphrag_tab_idx = self.tabs.addTab( + self._structure if self._structure is not None else self._graph_placeholder, + tr("workspace.tab_graphrag")) self.tabs.currentChanged.connect(self._on_tab_changed) @@ -420,10 +419,50 @@ class WorkspaceTab(ProjectEditingMixin, QWidget): Dựng lười như vậy chính là thứ giữ cho RAM lúc khởi động ở mức thấp. """ + if idx in (self._co4e_tab_idx, self._folder_tab_idx, self._graphrag_tab_idx): + self._ensure_heavy_tab(idx) if idx == self._graphrag_tab_idx and self._structure is not None: self._structure.auto_scan_and_fit() self._apply_pane_visibility() + def _ensure_heavy_tab(self, idx: int): + """Build a heavy Workspace child once, replacing its placeholder.""" + if idx == self._co4e_tab_idx and self._co4e is None: + from .co4e_tab import Co4ETab + real = Co4ETab(self.ctx) + self._co4e = real + self.tabs.removeTab(idx) + self.tabs.insertTab(idx, real, tr("workspace.tab_co4e")) + self.tabs.setCurrentIndex(idx) + self.tabs.setTabToolTip(idx, tr("workspace.tab_co4e_tooltip")) + self._bind_project(self._current_id) + return real + if idx == self._folder_tab_idx and self._folder is None: + from ..presentation.folder.folder_tab import FolderTab + real = FolderTab(self.ctx, cowork=self._cowork) + real.status_message.connect(self.status_message) + self._folder = real + self.tabs.removeTab(idx) + self.tabs.insertTab(idx, real, tr("workspace.tab_folder")) + self.tabs.setCurrentIndex(idx) + self._bind_project(self._current_id) + return real + if idx == self._graphrag_tab_idx and self._structure is None: + from ..presentation.graph.structure_graph_view import StructureGraphView + real = StructureGraphView(self.ctx) + real.status_message.connect(self.status_message) + if self._cowork is not None: + self._cowork.output_changed.connect(real.schedule_rescan) + self._structure = real + self.tabs.removeTab(idx) + self.tabs.insertTab(idx, real, tr("workspace.tab_graphrag")) + self.tabs.setCurrentIndex(idx) + self._bind_project(self._current_id) + return real + return {self._co4e_tab_idx: self._co4e, + self._folder_tab_idx: self._folder, + self._graphrag_tab_idx: self._structure}.get(idx) + def _apply_pane_visibility(self) -> None: """Which side panes accompany each sub-tab: -- 2.54.0 From c699beb6fd1dcc079f9a3ad65739dec261d92878 Mon Sep 17 00:00:00 2001 From: thanhnv Date: Wed, 16 Sep 2026 00:23:03 +0900 Subject: [PATCH 02/15] fix: align settings navigation item --- presentation/shell/top_bar.py | 8 ++++---- ui/icons.py | 6 ++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/presentation/shell/top_bar.py b/presentation/shell/top_bar.py index 0fbcecc..adf83d1 100644 --- a/presentation/shell/top_bar.py +++ b/presentation/shell/top_bar.py @@ -59,11 +59,11 @@ class TopBarMixin: # that number. Adding it again here made the row taller than the button # (28 wanted, 20 given), which both clipped the icon and pushed the text # 8px below an even pitch with Dashboard / Giám sát. - srow.setContentsMargins(_NAV_ROW_INSET, 0, 8, 0) - srow.setSpacing(_NAV_ROW_GAP) + srow.setContentsMargins(_NAV_ROW_INSET + 2, 0, 8, 0) + srow.setSpacing(_NAV_ROW_GAP + 4) self._nav_settings_icon = QLabel() - self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16)) - self._nav_settings_icon.setFixedSize(16, 16) + self._nav_settings_icon.setPixmap(_icon("gear").pixmap(16, 16)) + self._nav_settings_icon.setFixedSize(22, 16) self._nav_settings_text = QLabel(tr("app.settings")) srow.addWidget(self._nav_settings_icon) srow.addWidget(self._nav_settings_text) diff --git a/ui/icons.py b/ui/icons.py index dca22d5..b244953 100644 --- a/ui/icons.py +++ b/ui/icons.py @@ -78,8 +78,7 @@ _PATHS = { "briefcase": '' '', "award": '', - "gear": '' - '', + "gear": '', "globe": '' '', "logout": '' @@ -199,8 +198,7 @@ _PATHS = { '', # = beaker "sparkle": '' '', # = sparkles - "settings": '' - '', # = gear + "settings": '', # = gear } -- 2.54.0 From 2e3e71925979c6eb89a1c6372a07b0dc179450ee Mon Sep 17 00:00:00 2001 From: thanhnv Date: Wed, 16 Sep 2026 00:25:37 +0900 Subject: [PATCH 03/15] fix: separate settings from navigation group --- presentation/shell/rail_metrics.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/presentation/shell/rail_metrics.py b/presentation/shell/rail_metrics.py index 2e53745..6b12a6d 100644 --- a/presentation/shell/rail_metrics.py +++ b/presentation/shell/rail_metrics.py @@ -19,9 +19,8 @@ _NAV_ROW_GAP = 6 # Không đặt bằng ``margin`` trong QSS: margin của stylesheet được vẽ BÊN TRONG # hộp của widget, mà nút này lại bị ``_rebuild_nav`` ghim đúng chiều cao một # dòng menu — nên margin không mua được một pixel khoảng cách nào. -# 10 -> 4: đủ để Cài đặt không dính vào nhóm Dashboard/Giám sát, nhưng không -# rộng đến mức trông như hai khu tách rời. -_NAV_SETTINGS_GAP = 4 +# Giữ Dashboard/Monitoring thành một cụm; Settings tách rõ thành nhóm riêng. +_NAV_SETTINGS_GAP = 12 # 132 -> 232: o 132px nhan "Cuoc tro chuyen moi" bi cat mat chu. San phai du # rong cho nhan DAI NHAT tren thanh, khong phai cho nhan trung binh. _NAV_MIN_WIDTH = 232 -- 2.54.0 From cc8d5c8c0a88c54e83eb30ebb9c31a4ab5aaad3c Mon Sep 17 00:00:00 2001 From: thanhnv Date: Wed, 16 Sep 2026 00:28:32 +0900 Subject: [PATCH 04/15] fix: keep navigation row spacing uniform --- presentation/shell/rail_metrics.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/presentation/shell/rail_metrics.py b/presentation/shell/rail_metrics.py index 6b12a6d..06da47a 100644 --- a/presentation/shell/rail_metrics.py +++ b/presentation/shell/rail_metrics.py @@ -19,8 +19,8 @@ _NAV_ROW_GAP = 6 # Không đặt bằng ``margin`` trong QSS: margin của stylesheet được vẽ BÊN TRONG # hộp của widget, mà nút này lại bị ``_rebuild_nav`` ghim đúng chiều cao một # dòng menu — nên margin không mua được một pixel khoảng cách nào. -# Giữ Dashboard/Monitoring thành một cụm; Settings tách rõ thành nhóm riêng. -_NAV_SETTINGS_GAP = 12 +# Settings dùng cùng nhịp hàng với Dashboard và Monitoring. +_NAV_SETTINGS_GAP = 0 # 132 -> 232: o 132px nhan "Cuoc tro chuyen moi" bi cat mat chu. San phai du # rong cho nhan DAI NHAT tren thanh, khong phai cho nhan trung binh. _NAV_MIN_WIDTH = 232 -- 2.54.0 From a04f8a928d11e074950a67cffd3abc921e856420 Mon Sep 17 00:00:00 2001 From: thanhnv Date: Wed, 16 Sep 2026 00:30:07 +0900 Subject: [PATCH 05/15] fix: hide cloud workspace picker --- ui/workspace_tab.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/workspace_tab.py b/ui/workspace_tab.py index e966929..41a22d1 100644 --- a/ui/workspace_tab.py +++ b/ui/workspace_tab.py @@ -306,6 +306,7 @@ class WorkspaceTab(ProjectEditingMixin, QWidget): cloud_row.addWidget(self._cloud_sync_btn) cloud_row.addStretch(1) rl.addLayout(cloud_row) + self._cloud_pick_btn.hide() self._cloud_badge_lbl = QLabel() self._cloud_badge_lbl.setWordWrap(True) self._cloud_badge_lbl.hide() -- 2.54.0 From c00b83cd1abffb77b643d40474133a67f726362e Mon Sep 17 00:00:00 2001 From: thanhnv Date: Wed, 16 Sep 2026 00:31:33 +0900 Subject: [PATCH 06/15] fix: add spacing to project row metadata --- presentation/workspace/project_editing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/presentation/workspace/project_editing.py b/presentation/workspace/project_editing.py index 78ab5cd..8851da9 100644 --- a/presentation/workspace/project_editing.py +++ b/presentation/workspace/project_editing.py @@ -52,7 +52,7 @@ class ProjectRow(QWidget): lay = QVBoxLayout(self) lay.setContentsMargins(6, 4, 6, 4) - lay.setSpacing(0) + lay.setSpacing(3) self.title_label = QLabel(name) self.counts_label = QLabel() self.counts_label.setObjectName("hint") -- 2.54.0 From caf3b7493137d52c8765bc298588882b3750238e Mon Sep 17 00:00:00 2001 From: thanhnv Date: Wed, 16 Sep 2026 00:38:56 +0900 Subject: [PATCH 07/15] fix: use native GraphRAG renderer on macOS --- presentation/graph/graph_renderer.py | 2 +- presentation/shared/web_engine_support.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/presentation/graph/graph_renderer.py b/presentation/graph/graph_renderer.py index 6a8493f..2d8a771 100644 --- a/presentation/graph/graph_renderer.py +++ b/presentation/graph/graph_renderer.py @@ -237,7 +237,7 @@ class GraphRenderer(QWidget): # ---- prewarm / scan lifecycle -------------------------------------------------- # def prewarm(self) -> None: """Pay for the graph view before it is clicked on, not during.""" - if not HAS_WEB_ENGINE or self.web is not None: + if self.web is not None: return self._ensure_web() if self._graph is None and self.path_edit.text().strip(): diff --git a/presentation/shared/web_engine_support.py b/presentation/shared/web_engine_support.py index 39f5ef4..a333c0f 100644 --- a/presentation/shared/web_engine_support.py +++ b/presentation/shared/web_engine_support.py @@ -27,7 +27,14 @@ def _frozen_onefile() -> bool: return True +# QtWebEngine is noisy and unreliable on the macOS runtime we support (GPU/ +# helper-process failures leave the stacked view blank). The native Qt graph is +# already available and avoids that failure path entirely. +HAS_WEB_ENGINE = False + try: # WebEngine + WebChannel are optional PySide6 add-ons + if sys.platform == "darwin": + raise ImportError("use native graph renderer on macOS") from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401 from PySide6.QtWebChannel import QWebChannel # noqa: F401 HAS_WEB_ENGINE = not _frozen_onefile() -- 2.54.0 From 8548c1e9231d4ddc696c92849d7b95a54cc23af1 Mon Sep 17 00:00:00 2001 From: thanhnv Date: Wed, 16 Sep 2026 01:00:31 +0900 Subject: [PATCH 08/15] fix: enforce sandbox network block for agent tools --- core/chat_agent.py | 9 +++++++++ core/code_agent.py | 7 ++++++- presentation/monitoring/tabs/tools_admin_tab.py | 3 ++- state.py | 4 ++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/core/chat_agent.py b/core/chat_agent.py index 9c28ef7..1157059 100644 --- a/core/chat_agent.py +++ b/core/chat_agent.py @@ -527,6 +527,15 @@ def run_cowork( preview = {"kind": "info", "title": name, "text": str(args)} emit({"type": "tool_proposed", "id": tc_id, "name": name, "args": args, "preview": preview}) + if ctx.block_network: + result = {"ok": False, "output": ( + f"{name}: network access is blocked by the Sandbox Security Layer " + '("Block network for agent-run commands" is on in Settings).')} + emit({"type": "tool_result", "id": tc_id, "name": name, + "ok": False, "output": result["output"]}) + messages.append({"role": "tool", "tool_call_id": tc_id, "name": name, + "content": result["output"]}) + continue # R05-T04: MCP/connector tools used to run with NO permission # check at all — this is what closes that gap. Same policy, # same gate object as the built-in tools below. diff --git a/core/code_agent.py b/core/code_agent.py index 24752cb..a31cdc3 100644 --- a/core/code_agent.py +++ b/core/code_agent.py @@ -327,7 +327,12 @@ def run_code( else: emit({"type": "tool_start", "id": tc_id, "name": name}) if is_extra and extra_executor is not None: - result = extra_executor(name, args) + if ctx.block_network: + result = {"ok": False, "output": ( + f"{name}: network access is blocked by the Sandbox Security Layer " + '("Block network for agent-run commands" is on in Settings).')} + else: + result = extra_executor(name, args) else: def on_output(line: str, _id=tc_id, _name=name) -> None: emit({"type": "tool_output", "id": _id, "name": _name, "delta": line}) diff --git a/presentation/monitoring/tabs/tools_admin_tab.py b/presentation/monitoring/tabs/tools_admin_tab.py index 1499c50..41858cc 100644 --- a/presentation/monitoring/tabs/tools_admin_tab.py +++ b/presentation/monitoring/tabs/tools_admin_tab.py @@ -216,7 +216,8 @@ class ToolsAdminTab(QWidget): result. Respects the fetch_url toggle: when web access is OFF the agent cannot reach the internet, so the test reports that instead of probing.""" disabled = ("fetch_url" in self.ctx.config.tools_disabled - or not bool(self.ctx.config.agent_security.get("allow_url_fetch", True))) + or not bool(self.ctx.config.agent_security.get("allow_url_fetch", True)) + or bool(self.ctx.config.agent_security.get("block_network", False))) if disabled: self.test_internet_status.setText(tr("tools_admin.internet_disabled")) self.test_internet_status.setStyleSheet("color: #c00;") diff --git a/state.py b/state.py index 976be01..d1a9d6b 100644 --- a/state.py +++ b/state.py @@ -233,6 +233,10 @@ class AppContext: connections across calls/turns (spawning a subprocess per turn would be slow and wasteful). A server/connector that fails to connect is skipped, not a hard failure for the turn.""" + # Sandbox Security Layer blocks agent-owned network connectors before + # they can spawn a server or issue a REST request. + if self.config.agent_security.get("block_network", False): + return [], None # Master switch (Monitoring → Tools → Connector): when the admin turns # "Connect to external" off, the agent connects to NO external # connectors/MCP at all — no subprocesses spawned, no REST calls. -- 2.54.0 From 2759ed94ba777d9883c029a1427f4dcd238c3470 Mon Sep 17 00:00:00 2001 From: thanhnv Date: Wed, 16 Sep 2026 01:05:52 +0900 Subject: [PATCH 09/15] perf: avoid refreshing workspace on cowork tab switch --- presentation/shell/page_registry.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/presentation/shell/page_registry.py b/presentation/shell/page_registry.py index d7f3e2e..174bfca 100644 --- a/presentation/shell/page_registry.py +++ b/presentation/shell/page_registry.py @@ -142,10 +142,11 @@ class PageRegistryMixin: Vệt sáng trên thanh menu cũng cập nhật ở đây, để nó đi theo NỘI DUNG chứ không theo thứ vừa được bấm. """ + was_page = self.pages.currentIndex() self._ensure_page(page) # build lazy page on first visit self.pages.setCurrentIndex(page) - if page == self._ROW_WORKSPACE: - self.workspace.refresh() # re-list projects + threads on entry + if page == self._ROW_WORKSPACE and was_page != page: + self.workspace.refresh() # refresh only when entering Workspace widget = self._page_widgets[page] if sub is not None and hasattr(widget, "select_subtab"): # Enforce the project gate here rather than at each entry point. A -- 2.54.0 From 35f24e0e2819ef6144d82a58f0e1bb596f2bd51c Mon Sep 17 00:00:00 2001 From: Duy Le Huu Date: Thu, 17 Sep 2026 15:34:57 +0900 Subject: [PATCH 10/15] =?UTF-8?q?fix(rail):=20canh=20h=C3=A0ng=20C=C3=A0i?= =?UTF-8?q?=20=C4=91=E1=BA=B7t=20th=E1=BA=B3ng=20v=E1=BB=9Bi=20Dashboard?= =?UTF-8?q?=20v=C3=A0=20Gi=C3=A1m=20s=C3=A1t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hàng "Cài đặt" là một QPushButton tự dựng icon + chữ chứ không phải hàng của QTreeWidget như Dashboard/Giám sát, nên khoảng cách icon-chữ do layout đặt chứ không do delegate của cây. Đặt _NAV_ROW_GAP + 4 (=10) ở đó, cộng 10px lề trái và hộp icon 22px, đưa chữ ra x=42 trong khi delegate đặt chữ ở x=35 — thụt phải 7px, thấy rõ bằng mắt. Lấy khe đó từ style (PM_FocusFrameHMargin + 1) thay vì tự đặt số, đúng khe mà delegate dùng. Kèm test hồi quy đo bằng pixel trên cửa sổ thật, ép ba hàng về cùng icon và cùng chữ để đo bố cục chứ không đo hình dáng glyph. Co-Authored-By: Claude Opus 5 --- presentation/shell/top_bar.py | 12 ++- tests/ui/test_nav_rail_alignment.py | 135 ++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 tests/ui/test_nav_rail_alignment.py diff --git a/presentation/shell/top_bar.py b/presentation/shell/top_bar.py index adf83d1..afde317 100644 --- a/presentation/shell/top_bar.py +++ b/presentation/shell/top_bar.py @@ -32,10 +32,10 @@ class TopBarMixin: ``_build_account_row`` ngay bên dưới, chỉ khác chỗ đặt trên màn hình. """ from PySide6.QtCore import Qt - from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton + from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStyle from ...i18n import tr from ...ui.icons import icon as _icon - from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET, _NAV_SETTINGS_GAP + from .rail_metrics import _NAV_ROW_INSET, _NAV_SETTINGS_GAP # Bottom-pinned group: the places you visit occasionally, kept out of the # way of the ones you live in. A hairline (styled via #navrailBottom in @@ -60,7 +60,13 @@ class TopBarMixin: # (28 wanted, 20 given), which both clipped the icon and pushed the text # 8px below an even pitch with Dashboard / Giám sát. srow.setContentsMargins(_NAV_ROW_INSET + 2, 0, 8, 0) - srow.setSpacing(_NAV_ROW_GAP + 4) + # Khe giữa icon và chữ phải là khe của STYLE, không phải nhịp riêng của + # rail: delegate của cây vẽ chữ ngay sau hộp icon, cách đúng + # ``PM_FocusFrameHMargin + 1``. Đặt ``_NAV_ROW_GAP + 4`` (=10) ở đây cộng + # với 10px lề trái và hộp icon 22px thành 42 — trong khi Dashboard / + # Giám sát đặt chữ ở 35, nên hàng Cài đặt thụt phải 7px. + srow.setSpacing( + self.nav_bottom.style().pixelMetric(QStyle.PM_FocusFrameHMargin) + 1) self._nav_settings_icon = QLabel() self._nav_settings_icon.setPixmap(_icon("gear").pixmap(16, 16)) self._nav_settings_icon.setFixedSize(22, 16) diff --git a/tests/ui/test_nav_rail_alignment.py b/tests/ui/test_nav_rail_alignment.py new file mode 100644 index 0000000..b81aa1c --- /dev/null +++ b/tests/ui/test_nav_rail_alignment.py @@ -0,0 +1,135 @@ +"""Ba hàng cuối thanh rail phải thẳng hàng: Dashboard, Giám sát, Cài đặt. + +Dashboard và Giám sát là hàng của ``QTreeWidget`` (``#navrailBottom``), còn Cài +đặt là một ``QPushButton`` tự dựng lấy icon + chữ trong ``top_bar.py``. Hai cách +vẽ khác nhau nên không có gì tự giữ cho chúng thẳng hàng — phải chốt bằng test. + +Lần lệch gần nhất: ``srow.setSpacing(_NAV_ROW_GAP + 4)`` (=10) cộng với 10px lề +trái và hộp icon 22px đặt chữ "Cài đặt" ở x=42, trong khi delegate của cây đặt +chữ ở x=35 — thụt phải 7px, thấy rõ bằng mắt trên thanh rail. + +Cách đo: render thanh rail ra ảnh rồi tìm cột mực đầu tiên, vì đó đúng là thứ +người dùng nhìn thấy. Trước khi đo, ba hàng được ép về **cùng một icon và cùng +một chữ** — chữ khác nhau thì phần nhô trái của glyph đầu tiên ("D" so với "G" +so với "C") đã lệch nhau vài pixel, và bài test sẽ đo hình dáng chữ chứ không +đo bố cục. +""" +from __future__ import annotations + +import os + +import pytest + +pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật") + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +#: Bỏ qua cột mực nằm sát mép trái: hàng đang được chọn có thêm vạch +#: ``border-left: 2px solid $accent`` (theme/qss.py), không phải icon. +_BO_QUA_MEP_TRAI = 5 + + +@pytest.fixture(scope="module") +def window(qapp, tmp_path_factory): + """Cửa sổ thật, có nạp stylesheet đúng như ``app.py`` làm. + + Không nạp thì ``QTreeWidget::item { padding: 6px 10px }`` không áp, hàng của + cây thụt về 0 còn nút Cài đặt vẫn giữ lề 10px của layout — bài test sẽ đỏ vì + thiếu theme chứ không vì lỗi bố cục. + """ + from cowork_local.presentation.shell.bootstrap import build_config, build_context + from cowork_local.presentation.shell.main_window import MainWindow + from cowork_local.theme import set_active_theme, stylesheet + + config_path = tmp_path_factory.mktemp("cfg") / "config.json" + build_config(config_path) + ctx = build_context(config_path) + css_cu = qapp.styleSheet() + set_active_theme(ctx.config.theme) + qapp.setStyleSheet(stylesheet(ctx.config.theme)) + win = MainWindow(ctx) + win.resize(1280, 800) + win.show() + for _ in range(3): + qapp.processEvents() + yield win + win.close() + qapp.setStyleSheet(css_cu) + + +def _cum_muc(img, y0: int, y1: int): + """Các cụm cột có mực trong dải ``[y0, y1)``, dạng ``[(x_đầu, x_cuối), ...]``. + + Màu nền lấy ở cột sát mép phải cùng dòng y, nên hàng đang được tô nền chọn + vẫn so sánh đúng. + """ + w = img.width() + co_muc = [any(img.pixel(x, y) != img.pixel(w - 3, y) for y in range(y0, y1)) + for x in range(w)] + cum, dau = [], None + for x, c in enumerate(co_muc): + if c and dau is None: + dau = x + elif not c and dau is not None: + if x - dau >= 2: + cum.append((dau, x - 1)) + dau = None + if dau is not None: + cum.append((dau, w - 1)) + return [c for c in cum if c[0] >= _BO_QUA_MEP_TRAI] + + +def _ep_ba_hang_ve_cung_hinh(window): + """Cho ba hàng cùng icon và cùng chữ, để chỉ còn bố cục là khác biệt.""" + from cowork_local.ui.icons import icon as _icon + + for i in range(2): + it = window.nav_bottom.topLevelItem(i) + it.setIcon(0, _icon("gear")) + it.setText(0, "M") + window._nav_settings_icon.setPixmap(_icon("gear").pixmap(16, 16)) + window._nav_settings_text.setText("M") + + +def _vi_tri_ba_hang(qapp, window): + """``{tên hàng: (x_icon, x_chữ)}`` đo từ ảnh render của thanh rail.""" + from PySide6.QtCore import QPoint + + _ep_ba_hang_ve_cung_hinh(window) + for _ in range(3): + qapp.processEvents() + ref = window._nav_wrap + img = ref.grab().toImage() + + ket = {} + for i, ten in ((0, "Dashboard"), (1, "Giám sát")): + it = window.nav_bottom.topLevelItem(i) + r = window.nav_bottom.visualItemRect(it) + y = window.nav_bottom.viewport().mapTo(ref, QPoint(0, r.y())).y() + cum = _cum_muc(img, y + 4, y + r.height() - 4) + assert len(cum) >= 2, f"{ten}: không tìm thấy đủ icon và chữ để đo" + ket[ten] = (cum[0][0], cum[1][0]) + + btn = window._nav_settings_btn + y = btn.mapTo(ref, QPoint(0, 0)).y() + cum = _cum_muc(img, y + 4, y + btn.height() - 4) + assert len(cum) >= 2, "Cài đặt: không tìm thấy đủ icon và chữ để đo" + ket["Cài đặt"] = (cum[0][0], cum[1][0]) + return ket + + +def test_icon_ba_hang_thang_hang(qapp, window): + """Icon của ba hàng phải bắt đầu ở cùng một cột.""" + vi_tri = _vi_tri_ba_hang(qapp, window) + x = {ten: v[0] for ten, v in vi_tri.items()} + assert len(set(x.values())) == 1, f"icon lệch nhau: {x}" + + +def test_chu_ba_hang_thang_hang(qapp, window): + """Chữ của ba hàng phải bắt đầu ở cùng một cột. + + Đây là bài đỏ trước khi sửa: Cài đặt ở 42, hai hàng kia ở 35. + """ + vi_tri = _vi_tri_ba_hang(qapp, window) + x = {ten: v[1] for ten, v in vi_tri.items()} + assert len(set(x.values())) == 1, f"chữ lệch nhau: {x}" -- 2.54.0 From bbdf146d2c5cb3c2e6a92710245f867f64e793ef Mon Sep 17 00:00:00 2001 From: Duy Le Huu Date: Thu, 17 Sep 2026 15:35:13 +0900 Subject: [PATCH 11/15] =?UTF-8?q?fix(workspace):=20b=E1=BA=ADt=20n=C3=BAt?= =?UTF-8?q?=20S=E1=BB=ADa=20project=20khi=20=C4=91=C3=A3=20c=C3=B3=20proje?= =?UTF-8?q?ct=20=C4=91ang=20m=E1=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vào sub-tab Project khi đang mở một project thì biểu mẫu hiện đủ dữ liệu nhưng nút "Sửa project" vẫn mờ, không bấm được. Trạng thái bật/mờ của ba nút chỉ được tính trong set_project_editable, mà việc đổi project không đi qua hàm đó — _load_current chỉ nạp lại biểu mẫu. _sync_project_buttons có chạy khi đổi sub-tab nhưng chỉ chỉnh ẩn/hiện, nên nút hiện ra mang theo trạng thái mờ tính từ lúc dựng cửa sổ, khi chưa project nào được chọn. Dồn phép tính về _sync_project_buttons — nơi duy nhất biết đủ cả ba yếu tố — và cho nó chạy thêm khi project_selected phát tín hiệu. Co-Authored-By: Claude Opus 5 --- presentation/workspace/project_editing.py | 27 +++-- tests/ui/test_project_edit_button_state.py | 128 +++++++++++++++++++++ 2 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 tests/ui/test_project_edit_button_state.py diff --git a/presentation/workspace/project_editing.py b/presentation/workspace/project_editing.py index 8851da9..77b90c0 100644 --- a/presentation/workspace/project_editing.py +++ b/presentation/workspace/project_editing.py @@ -90,6 +90,7 @@ def _row_layout_of(widget: QWidget) -> QLayout | None: return None + class ProjectEditingMixin: """Danh sách project + CRUD + chế độ sửa. Trộn vào ``WorkspaceTab``. @@ -133,6 +134,9 @@ class ProjectEditingMixin: # dung luat ma _new_btn da theo (_new_btn.setVisible(on_project) trong # WorkspaceTab._apply_pane_visibility) — hai nut nay phai theo y nhu vay. self.tabs.currentChanged.connect(self._sync_project_buttons) + # Đổi project cũng phải đồng bộ lại: ``_load_current`` nạp form và đặt + # ``_current_id`` rồi phát tín hiệu này, nhưng không đụng tới ba nút. + self.project_selected.connect(self._sync_project_buttons) self.project_list.setContextMenuPolicy(Qt.CustomContextMenu) self.project_list.customContextMenuRequested.connect(self._show_project_menu) @@ -153,15 +157,14 @@ class ProjectEditingMixin: Bật: ngược lại, và nút Lưu chuyển sang màu xác nhận (token ``success``). """ self._project_editable = on - has_project = bool(getattr(self, "_current_id", "")) for field in self._editable_fields(): # setReadOnly thay vì setEnabled: ô mờ đi thì không bôi đen copy # được nữa, mà đọc và copy chính là việc của chế độ chỉ-xem. field.setReadOnly(not on) - self._browse_btn.setEnabled(on and has_project) - self._save_btn.setEnabled(on and has_project) - self._edit_btn.setEnabled(not on and has_project) + # Ba nút không tự bật/tắt ở đây: ``_sync_project_buttons`` mới là nơi + # duy nhất tính trạng thái của chúng, vì nó còn chạy cả khi người dùng + # đổi project — lúc đó ``set_project_editable`` không được gọi. self._sync_project_buttons() # Nút Lưu xanh lá khi đang sửa (hành động xác nhận), về màu nhấn mặc @@ -171,15 +174,25 @@ class ProjectEditingMixin: self._repolish(self._edit_btn) def _sync_project_buttons(self, *_a) -> None: - """Ẩn "Sửa project" và "Lưu project" ngoài sub-tab Project. + """Đồng bộ CẢ hiện/ẩn LẪN bật/mờ của ba nút theo trạng thái hiện tại. - Chúng nằm trên hàng tiêu đề dùng chung, nên không tự ẩn là chúng hiện - cả ở Cowork — nơi không có biểu mẫu project nào để sửa hay lưu. + Ẩn ngoài sub-tab Project: chúng nằm trên hàng tiêu đề dùng chung, nên + không tự ẩn là chúng hiện cả ở Cowork — nơi không có biểu mẫu project + nào để sửa hay lưu. + + Bật/mờ cũng tính ở đây chứ không ở ``set_project_editable``: đổi + project KHÔNG đi qua hàm đó (``_load_current`` chỉ nạp lại form), nên + để ở đó thì "Sửa project" giữ nguyên trạng thái tính từ lúc dựng — + lúc chưa project nào được chọn — và cứ mờ mãi dù project đã mở. """ on_project = self.tabs.currentIndex() == self._project_tab_idx has_project = bool(getattr(self, "_current_id", "")) + dang_sua = bool(getattr(self, "_project_editable", False)) self._edit_btn.setVisible(on_project and has_project) self._save_btn.setVisible(on_project and has_project) + self._edit_btn.setEnabled(not dang_sua and has_project) + self._save_btn.setEnabled(dang_sua and has_project) + self._browse_btn.setEnabled(dang_sua and has_project) @staticmethod def _repolish(widget: QWidget) -> None: diff --git a/tests/ui/test_project_edit_button_state.py b/tests/ui/test_project_edit_button_state.py new file mode 100644 index 0000000..cf22464 --- /dev/null +++ b/tests/ui/test_project_edit_button_state.py @@ -0,0 +1,128 @@ +"""Nút "Sửa project" phải sáng ngay khi đã có project đang mở. + +Triệu chứng: mở app, chọn một project rồi vào sub-tab Project — biểu mẫu hiện +đủ tên, mô tả, thư mục làm việc, nhưng nút "Sửa project" vẫn mờ, không bấm được. + +Nguyên nhân: trạng thái bật/mờ của ba nút chỉ được tính trong +``set_project_editable``, mà đổi project KHÔNG đi qua hàm đó — ``_load_current`` +chỉ nạp lại biểu mẫu. ``_sync_project_buttons`` có chạy khi đổi sub-tab nhưng +ngày trước chỉ chỉnh ẩn/hiện, nên nút hiện ra mang theo trạng thái mờ tính từ +lúc dựng cửa sổ, khi chưa project nào được chọn. + +Các bài dưới đây chốt cả bốn trạng thái: chưa có project → mờ; có project → +sáng; đi vòng qua sub-tab khác rồi quay lại → vẫn sáng; đang sửa dở → mờ lại +(nếu không thì "đang sửa" và "chưa sửa" trông giống hệt nhau). + +Hai quy ước bắt buộc, lấy từ ``test_project_editing.py`` và +``test_project_gate_subtabs.py`` ngay cạnh: + +* **Không tạo, không xoá project nào.** ``core/projects.py`` gắn + ``PROJECTS_DIR`` vào ``~/.cowork_local`` THẬT, nên tạo project trong test là + ghi vào dữ liệu đang dùng của người chạy test. Trạng thái "đã có project" + được đặt thẳng vào ``_current_id`` — đúng biến mà ba nút đọc. +* **Một cửa sổ cho cả module.** Dựng ``MainWindow`` cho từng bài làm cả bộ + ``tests/ui`` chết giữa chừng (Qt đổ stack trace, không phải test nào fail), + nên fixture ở đây là ``scope="module"`` và mỗi bài tự đặt trạng thái đầu vào + của mình. +""" +from __future__ import annotations + +import pytest + +pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật") + +_PID_GIA = "project-test-khong-ghi-dia" + + +@pytest.fixture(scope="module") +def ws(qapp, tmp_path_factory): + """Màn Workspace của một MainWindow thật, dùng chung cho cả module.""" + from cowork_local.presentation.shell.bootstrap import build_config, build_context + from cowork_local.presentation.shell.main_window import MainWindow + + config_path = tmp_path_factory.mktemp("cfg") / "config.json" + build_config(config_path) + window = MainWindow(build_context(config_path)) + yield window.workspace + window.close() + + +def _nap_project(qapp, ws, pid: str) -> None: + """Đi đúng đường ``_load_current`` đi khi người dùng chọn một project. + + Cố ý KHÔNG gọi ``set_project_editable``: chính vì ``_load_current`` không + gọi nó mà lỗi mới tồn tại. Gọi nó ở đây là bài test tự tay bật lại nút rồi + khẳng định nút đang bật — nó sẽ xanh cả trên bản chưa sửa. + + ``_project_editable`` đặt thẳng về ``False`` vì cửa sổ dùng chung cho cả + module: bài trước có thể đã để form ở chế độ sửa, mà nạp một project mới + thì form luôn ở chế độ chỉ-xem. + """ + ws.tabs.setCurrentIndex(ws._project_tab_idx) + ws._project_editable = False + ws._current_id = pid + ws.project_selected.emit(pid) + qapp.processEvents() + + +def _chua_co_project(qapp, ws) -> None: + """Trạng thái chưa chọn project nào, đang ở sub-tab Project.""" + _nap_project(qapp, ws, "") + + +def _mo_mot_project(qapp, ws) -> None: + """Trạng thái đang mở một project.""" + _nap_project(qapp, ws, _PID_GIA) + + +def test_chua_co_project_thi_nut_sua_mo(qapp, ws): + """Chưa chọn project thì không có gì để sửa — đây là hành vi phải giữ.""" + _chua_co_project(qapp, ws) + + assert ws._edit_btn.isEnabled() is False + assert ws._edit_btn.isHidden() is True + + +def test_da_co_project_thi_nut_sua_sang(qapp, ws): + """Bài đỏ trước khi sửa: nút hiện ra nhưng vẫn mờ.""" + _mo_mot_project(qapp, ws) + + assert ws._edit_btn.isHidden() is False, "nút phải hiện khi đã có project" + assert ws._edit_btn.isEnabled() is True, "nút phải bấm được khi đã có project" + + +def test_quay_lai_tab_project_thi_nut_van_sang(qapp, ws): + """Đúng thao tác trong ảnh người dùng gửi: rời tab Project rồi quay lại.""" + if ws.tabs.count() < 2: + pytest.skip("bản dựng này chỉ có một sub-tab, không đi vòng được") + _mo_mot_project(qapp, ws) + + ws.tabs.setCurrentIndex(1 if ws._project_tab_idx == 0 else 0) + qapp.processEvents() + ws.tabs.setCurrentIndex(ws._project_tab_idx) + qapp.processEvents() + + assert ws._edit_btn.isHidden() is False + assert ws._edit_btn.isEnabled() is True + + +def test_dang_sua_thi_nut_sua_mo_lai_va_nut_luu_sang(qapp, ws): + """Chống sửa quá tay: "Sửa project" chỉ sáng khi CHƯA ở chế độ sửa.""" + _mo_mot_project(qapp, ws) + ws.set_project_editable(True) + qapp.processEvents() + + assert ws._edit_btn.isEnabled() is False + assert ws._save_btn.isEnabled() is True + assert ws._browse_btn.isEnabled() is True + + +def test_bo_chon_project_thi_nut_sua_mo_lai(qapp, ws): + """Xoá project đang mở đưa ``_current_id`` về rỗng — nút phải mờ lại.""" + _mo_mot_project(qapp, ws) + ws._current_id = "" + ws.project_selected.emit("") + qapp.processEvents() + + assert ws._edit_btn.isEnabled() is False + assert ws._edit_btn.isHidden() is True -- 2.54.0 From b7a41b3658b3069ab1a089bff853af6de3cb4ea5 Mon Sep 17 00:00:00 2001 From: Duy Le Huu Date: Thu, 17 Sep 2026 15:36:16 +0900 Subject: [PATCH 12/15] =?UTF-8?q?feat(workspace):=20m=E1=BB=97i=20th=C6=B0?= =?UTF-8?q?=20m=E1=BB=A5c=20l=C3=A0m=20vi=E1=BB=87c=20ch=E1=BB=89=20thu?= =?UTF-8?q?=E1=BB=99c=20v=E1=BB=81=20m=E1=BB=99t=20project?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thư mục làm việc vừa là sandbox (agent chỉ đọc/ghi bên trong nó) vừa là kho kiến thức chung của project (file ở gốc được mọi đoạn chat tự đọc). Hai project trỏ vào cùng một thư mục là đọc lẫn dữ liệu của nhau và ghi đè lên nhau — đúng điều docstring đầu core/projects.py nói sandbox sinh ra để ngăn, nhưng không có chỗ nào thực thi: cả ba nơi đặt thư mục đều gán thẳng output_dir rồi lưu. Thêm core.projects.folder_conflict: so theo workspace_dir() chứ không theo output_dir (project chưa đặt thư mục riêng vẫn đang chiếm thư mục quản lý sẵn), chuẩn hoá đường dẫn bằng expanduser + abspath + normcase, và coi thư mục lồng nhau là trùng — đứng ở thư mục cha vẫn với tới được file của project con. Chặn tại cả ba nơi ghi output_dir: nút Đổi ở màn Project, thư mục mirror cloud, và nút chọn thư mục trong tab Cowork. Chặn lúc CHỌN chứ không lúc Lưu, vì nút Lưu chỉ ghi tên/mô tả/chỉ dẫn — chặn ở đó sẽ khoá luôn việc đổi tên một project lỡ đang trùng thư mục. Dữ liệu đã trùng sẵn không bị tự sửa: có nhãn cảnh báo ngay dưới ô Thư mục làm việc, nói rõ trùng với project nào, để người dùng tự quyết. Luật ở module riêng (presentation/workspace/project_folder_rules.py) vì nhét vào project_editing.py sẽ đẩy file đó lên 435 dòng, vượt trần 400 của scripts/check_loc.py. Co-Authored-By: Claude Opus 5 --- core/projects.py | 48 ++++ i18n/sidebar.py | 11 + presentation/workspace/project_editing.py | 4 + .../workspace/project_folder_rules.py | 113 +++++++++ tests/ui/test_project_folder_unique.py | 223 ++++++++++++++++++ ui/cowork_tab.py | 3 + ui/workspace_tab.py | 7 +- 7 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 presentation/workspace/project_folder_rules.py create mode 100644 tests/ui/test_project_folder_unique.py diff --git a/core/projects.py b/core/projects.py index f6bef6b..98a90a8 100644 --- a/core/projects.py +++ b/core/projects.py @@ -24,6 +24,7 @@ project — nothing about it is special-cased in the UI. from __future__ import annotations import json +import os import re from dataclasses import asdict, dataclass, field from datetime import datetime @@ -82,6 +83,53 @@ class Project: return (base or WORKSPACES_DIR) / self.project_id +def _norm_dir(path) -> str: + """Đường dẫn đã chuẩn hoá để đem ra so sánh. + + Bung ``~``, đưa về tuyệt đối, rồi ``normcase`` — trên Windows thì + ``D:/Work`` và ``d:/work`` là cùng một thư mục, nên so chuỗi thô sẽ + cho hai project chiếm chung một chỗ mà không ai biết. + """ + return os.path.normcase(os.path.abspath(os.path.expanduser(str(path)))) + + +def _cham_nhau(a: str, b: str) -> bool: + """Hai thư mục đã chuẩn hoá có chạm nhau không: trùng, hoặc lồng nhau. + + Lồng nhau cũng tính, vì lý do tồn tại của sandbox là "agent của project này + không bao giờ chạm được file của project kia" (xem docstring đầu module). + Đứng ở thư mục cha thì đọc/ghi được toàn bộ thư mục con, nên cha-con vẫn là + chạm nhau dù hai đường dẫn không giống nhau. + """ + return a == b or a.startswith(b + os.sep) or b.startswith(a + os.sep) + + +def folder_conflict(path, *, ignore_id: str = "", + directory: Path = None) -> Optional[Project]: + """Project khác đang chiếm ``path``, hoặc ``None`` nếu chưa ai chiếm. + + Mỗi thư mục chỉ được thuộc về một project: thư mục làm việc vừa là sandbox + vừa là kho kiến thức dùng chung của project, nên hai project dùng chung một + thư mục là đọc lẫn dữ liệu của nhau. + + So theo thư mục THỰC SỰ đang dùng (``workspace_dir()``), không phải theo + ``output_dir``: project chưa đặt thư mục riêng vẫn đang chiếm thư mục quản + lý sẵn của nó, và chính thư mục đó là thứ hay bị chọn nhầm. + + ``ignore_id`` là project đang sửa — giữ nguyên thư mục của chính nó thì + không phải là trùng. + """ + if not str(path).strip(): + return None + muon = _norm_dir(path) + for project in list_projects(directory): + if project.project_id == ignore_id: + continue + if _cham_nhau(muon, _norm_dir(project.workspace_dir())): + return project + return None + + def _starter_project() -> Project: """An ordinary (deletable, renamable) project seeded when the projects folder is empty, so the app always opens with somewhere to chat.""" diff --git a/i18n/sidebar.py b/i18n/sidebar.py index de94a5c..49d26d5 100644 --- a/i18n/sidebar.py +++ b/i18n/sidebar.py @@ -57,6 +57,17 @@ STRINGS: Dict[str, Dict[str, str]] = { "en": "Another project is already called \"{name}\". Project names must be unique — the list shows nothing but the name, so two of them cannot be told apart.", "ja": "「{name}」という名前のプロジェクトが既にあります。一覧には名前しか出ないため、同じ名前が二つあると区別できません。", "vi": "Đã có project khác tên \"{name}\". Tên project phải khác nhau — danh sách chỉ hiện tên, trùng tên là không phân biệt được."}, + "workspace.folder_taken_title": { + "en": "Folder already used", "ja": "フォルダーが重複しています", + "vi": "Thư mục đã được dùng"}, + "workspace.folder_taken_body": { + "en": "Project \"{name}\" already works in {folder}. One folder belongs to one project only — the folder is that project's sandbox and shared knowledge, so sharing it lets two projects read and overwrite each other's files. Pick another folder.", + "ja": "プロジェクト「{name}」が既に {folder} を使用しています。フォルダーは 1 つのプロジェクト専用です — フォルダーはそのプロジェクトのサンドボックス兼共有ナレッジなので、共有すると互いのファイルを読み書きしてしまいます。別のフォルダーを選んでください。", + "vi": "Project \"{name}\" đang làm việc trong {folder}. Mỗi thư mục chỉ thuộc về một project — thư mục vừa là sandbox vừa là kho kiến thức chung của project đó, dùng chung là hai project đọc và ghi đè file của nhau. Hãy chọn thư mục khác."}, + "workspace.folder_shared_warning": { + "en": "⚠ This folder is also used by project \"{name}\". One folder belongs to one project only — pick another folder for one of them.", + "ja": "⚠ このフォルダーはプロジェクト「{name}」でも使われています。フォルダーは 1 つのプロジェクト専用です — どちらかに別のフォルダーを指定してください。", + "vi": "⚠ Thư mục này đang được project \"{name}\" dùng chung. Mỗi thư mục chỉ thuộc về một project — hãy đổi thư mục cho một trong hai."}, "workspace.instructions_placeholder": { "en": "e.g. \"All answers in Vietnamese. We are building the X reporting tool; always follow the naming rules …\"", "ja": "例:「回答はすべて日本語で。X レポートツールを開発中。命名規則に従うこと …」", diff --git a/presentation/workspace/project_editing.py b/presentation/workspace/project_editing.py index 77b90c0..0fe468d 100644 --- a/presentation/workspace/project_editing.py +++ b/presentation/workspace/project_editing.py @@ -141,6 +141,10 @@ class ProjectEditingMixin: self.project_list.setContextMenuPolicy(Qt.CustomContextMenu) self.project_list.customContextMenuRequested.connect(self._show_project_menu) + # Luật "mỗi thư mục một project" sống ở module riêng — xem + # ``project_folder_rules.py`` về lý do nó không nằm trong file này. + self.install_project_folder_rule() + self.set_project_editable(False) # ---- chế độ chỉ-xem / sửa ------------------------------------------- diff --git a/presentation/workspace/project_folder_rules.py b/presentation/workspace/project_folder_rules.py new file mode 100644 index 0000000..f9d2e94 --- /dev/null +++ b/presentation/workspace/project_folder_rules.py @@ -0,0 +1,113 @@ +"""Luật "mỗi thư mục làm việc chỉ thuộc về MỘT project". + +Tách khỏi ``project_editing.py`` chứ không nhét thêm vào đó: file kia đã gom +bốn tính năng và thêm luật này là chạm trần 400 dòng của +``scripts/check_loc.py``. Đây cũng là một mối quan tâm riêng — nó không nói về +việc *sửa* một project mà về việc hai project không được giẫm lên nhau. + +Luật có hai nửa, cố ý không đối xứng: + +* **Chặn lúc CHỌN.** Ba nơi đặt được thư mục làm việc (nút "Đổi" ở màn Project, + thư mục cloud, nút chọn thư mục trong tab Cowork) đều đi qua + :func:`folder_taken_blocked`, để cả ba chặn giống hệt nhau. Không chặn ở + "Lưu project": nút đó chỉ ghi tên/mô tả/chỉ dẫn, chặn ở đó sẽ khoá luôn việc + đổi tên một project lỡ đang trùng thư mục. +* **Cảnh báo cho cái ĐANG sai.** Dữ liệu cũ có thể đã có hai project trỏ vào + cùng một thư mục, mà nửa trên chỉ chặn từ nay trở đi. Nhãn dưới ô "Thư mục + làm việc" nói ra điều đó và để người dùng tự đổi — sửa hộ là tự ý đụng vào + dữ liệu của họ. + +Phép so trùng nằm ở ``core/projects.py::folder_conflict`` (thuần, không Qt). +""" +from __future__ import annotations + +from PySide6.QtWidgets import QLabel, QLayout, QMessageBox, QWidget + +from ...i18n import tr +from .project_editing import _row_layout_of + + +def _layout_chua(layout: QLayout, con: QLayout) -> "tuple | None": + """``(layout_cha, vị_trí)`` của ``con`` bên trong ``layout``, duyệt đệ quy.""" + for i in range(layout.count()): + item = layout.itemAt(i) + ben_trong = item.layout() + if ben_trong is con: + return layout, i + if ben_trong is not None: + tim = _layout_chua(ben_trong, con) + if tim is not None: + return tim + return None + + +def folder_taken_blocked(parent: QWidget, path: str, ignore_id: str) -> bool: + """``True`` nếu ``path`` đã thuộc project khác — và đã báo cho người dùng. + + Dùng chung cho cả ba nơi đặt được thư mục làm việc (nút "Đổi" ở màn + Project, thư mục cloud, và nút chọn thư mục trong tab Cowork), để cả ba + chặn giống hệt nhau thay vì mỗi nơi tự nghĩ ra một luật. + + Chặn ở lúc CHỌN chứ không ở lúc Lưu: "Lưu project" chỉ ghi tên, mô tả và + chỉ dẫn — chặn ở đó sẽ khoá luôn việc đổi tên một project lỡ đang trùng + thư mục, tức phạt người dùng vì một trạng thái họ chưa kịp sửa. + """ + from ...core.projects import folder_conflict + + khac = folder_conflict(path, ignore_id=ignore_id) + if khac is None: + return False + QMessageBox.warning(parent, tr("workspace.folder_taken_title"), + tr("workspace.folder_taken_body", name=khac.name, + folder=str(khac.workspace_dir()))) + return True + + + +class ProjectFolderRuleMixin: + """Nửa giao diện của luật. Trộn vào ``WorkspaceTab``.""" + + def install_project_folder_rule(self) -> None: + """Dựng nhãn cảnh báo và nối nó vào việc đổi project. + + Gọi từ ``install_project_editing``, tức sau khi form đã dựng xong. + """ + self._folder_warn_lbl = QLabel() + self._folder_warn_lbl.setObjectName("warning") # màu lấy từ theme/ + self._folder_warn_lbl.setWordWrap(True) + self._folder_warn_lbl.hide() + self._gan_nhan_canh_bao_thu_muc() + self.project_selected.connect(self._sync_folder_warning) + + def _gan_nhan_canh_bao_thu_muc(self) -> None: + """Chèn nhãn cảnh báo ngay DƯỚI hàng chứa ô Thư mục làm việc. + + Chèn từ đây thay vì thêm dòng vào ``_build_project_tab``: file + ``ui/workspace_tab.py`` đang vượt trần của ``scripts/check_loc.py``, + nên mọi dòng mới đều phải tránh nó (cùng lý do nút "Sửa project" được + chèn bằng ``_row_layout_of``). + """ + hang = _row_layout_of(self.folder_lbl) + cha = self.folder_lbl.parentWidget() + if hang is None or cha is None or cha.layout() is None: + return + tim = _layout_chua(cha.layout(), hang) + if tim is None: + return + layout, vi_tri = tim + layout.insertWidget(vi_tri + 1, self._folder_warn_lbl) + + def _sync_folder_warning(self, *_a) -> None: + """Hiện/ẩn cảnh báo "thư mục đang dùng chung" theo project đang mở.""" + from ...core.projects import folder_conflict, load_project + + pid = getattr(self, "_current_id", "") + project = load_project(pid) if pid else None + khac = (folder_conflict(project.workspace_dir(), ignore_id=pid) + if project is not None else None) + if khac is None: + self._folder_warn_lbl.hide() + return + self._folder_warn_lbl.setText( + tr("workspace.folder_shared_warning", name=khac.name)) + self._folder_warn_lbl.show() diff --git a/tests/ui/test_project_folder_unique.py b/tests/ui/test_project_folder_unique.py new file mode 100644 index 0000000..f1e304a --- /dev/null +++ b/tests/ui/test_project_folder_unique.py @@ -0,0 +1,223 @@ +"""Mỗi thư mục làm việc chỉ được thuộc về MỘT project. + +Thư mục làm việc vừa là sandbox (agent chỉ được đọc/ghi bên trong nó) vừa là +kho kiến thức chung của project (file ở gốc thư mục được mọi đoạn chat tự đọc). +Hai project trỏ vào cùng một thư mục là đọc lẫn dữ liệu của nhau và ghi đè lên +nhau — đúng điều mà docstring đầu ``core/projects.py`` nói sandbox sinh ra để +ngăn, nhưng trước đây không có gì chặn. + +Hai nhóm bài: + +* **Luật** — ``folder_conflict`` nhận diện trùng, kể cả khác hoa thường, khác + kiểu dấu phân cách, và LỒNG NHAU (đứng ở thư mục cha thì vẫn với tới được + file của project con). +* **Giao diện** — nhãn cảnh báo dưới ô "Thư mục làm việc" hiện đúng lúc, vì dữ + liệu cũ có thể đã trùng sẵn và luật mới chỉ chặn từ lúc chọn trở đi. +""" +from __future__ import annotations + +import os + +import pytest + +from cowork_local.core import projects as projects_mod +from cowork_local.core.projects import Project, WORKSPACES_DIR, folder_conflict + + +def _kho(monkeypatch, *ds: Project) -> None: + """Giả lập kho project, không đụng ``~/.cowork_local`` thật.""" + monkeypatch.setattr(projects_mod, "list_projects", lambda directory=None: list(ds)) + + +# ---- luật: nhận diện trùng ---------------------------------------------- + +def test_trung_y_het_thi_bi_bat(monkeypatch, tmp_path): + _kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path))) + + khac = folder_conflict(str(tmp_path), ignore_id="b") + + assert khac is not None and khac.project_id == "a" + + +def test_khac_hoa_thuong_va_dau_phan_cach_van_la_trung(monkeypatch, tmp_path): + """Trên Windows ``D:/Work`` và ``d:/work`` là cùng một thư mục.""" + _kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path))) + + lech = str(tmp_path).replace(os.sep, "/") + if os.name == "nt": + lech = lech.upper() + + assert folder_conflict(lech, ignore_id="b") is not None + + +def test_thu_muc_con_nam_trong_thu_muc_cua_project_khac_la_trung(monkeypatch, tmp_path): + """Project kia đứng ở thư mục cha thì vẫn đọc/ghi được thư mục con này.""" + _kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path))) + + assert folder_conflict(str(tmp_path / "con"), ignore_id="b") is not None + + +def test_thu_muc_cha_chua_thu_muc_cua_project_khac_la_trung(monkeypatch, tmp_path): + """Chiều ngược lại cũng phải bắt: chọn thư mục cha là ôm trọn project kia.""" + _kho(monkeypatch, Project(project_id="a", name="A", + output_dir=str(tmp_path / "con"))) + + assert folder_conflict(str(tmp_path), ignore_id="b") is not None + + +def test_ten_na_na_nhung_khong_long_nhau_thi_khong_trung(monkeypatch, tmp_path): + """Bẫy của so sánh tiền tố: ``work2`` KHÔNG nằm trong ``work``.""" + _kho(monkeypatch, Project(project_id="a", name="A", + output_dir=str(tmp_path / "work"))) + + assert folder_conflict(str(tmp_path / "work2"), ignore_id="b") is None + + +def test_project_chua_dat_thu_muc_rieng_van_dang_chiem_thu_muc_quan_ly(monkeypatch): + """``output_dir`` rỗng không có nghĩa là "chưa chiếm chỗ nào": project vẫn + đang dùng thư mục quản lý sẵn, và chính nó hay bị chọn nhầm.""" + _kho(monkeypatch, Project(project_id="a", name="A", output_dir="")) + + assert folder_conflict(str(WORKSPACES_DIR / "a"), ignore_id="b") is not None + + +def test_giu_nguyen_thu_muc_cua_chinh_no_thi_khong_phai_trung(monkeypatch, tmp_path): + _kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path))) + + assert folder_conflict(str(tmp_path), ignore_id="a") is None + + +def test_thu_muc_chua_ai_dung_thi_di_qua(monkeypatch, tmp_path): + _kho(monkeypatch, Project(project_id="a", name="A", + output_dir=str(tmp_path / "cua-a"))) + + assert folder_conflict(str(tmp_path / "cua-b"), ignore_id="b") is None + + +def test_duong_dan_rong_khong_bi_coi_la_trung(monkeypatch, tmp_path): + """Ô trống là "chưa chọn", không phải "trùng" — khác hẳn nhau.""" + _kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path))) + + assert folder_conflict("", ignore_id="b") is None + assert folder_conflict(" ", ignore_id="b") is None + + +# ---- i18n: ba key mới phải đủ ba ngôn ngữ ------------------------------- + +@pytest.mark.parametrize("key", [ + "workspace.folder_taken_title", "workspace.folder_taken_body", + "workspace.folder_shared_warning", +]) +def test_key_moi_co_du_ba_ngon_ngu(key): + from cowork_local import i18n + + entry = i18n.STRINGS[key] + for lang in ("en", "ja", "vi"): + assert entry.get(lang), f"{key} thiếu {lang}" + + +# ---- giao diện: cảnh báo cho dữ liệu đã trùng sẵn ----------------------- + +pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật") + + +@pytest.fixture(scope="module") +def ws(qapp, tmp_path_factory): + """Một cửa sổ cho cả module — dựng nhiều MainWindow làm Qt chết giữa chừng.""" + from cowork_local.presentation.shell.bootstrap import build_config, build_context + from cowork_local.presentation.shell.main_window import MainWindow + + config_path = tmp_path_factory.mktemp("cfg") / "config.json" + build_config(config_path) + window = MainWindow(build_context(config_path)) + yield window.workspace + window.close() + + +def _mo_project(qapp, ws, monkeypatch, dang_mo: Project, *nhung_cai_khac: Project): + """Mở ``dang_mo`` trên biểu mẫu, với kho chứa cả các project còn lại.""" + _kho(monkeypatch, dang_mo, *nhung_cai_khac) + monkeypatch.setattr(projects_mod, "load_project", + lambda pid, directory=None: dang_mo if pid == dang_mo.project_id else None) + ws._current_id = dang_mo.project_id + ws.project_selected.emit(dang_mo.project_id) + qapp.processEvents() + + +def test_canh_bao_hien_khi_project_dang_dung_chung_thu_muc(qapp, ws, monkeypatch, tmp_path): + """Đúng trạng thái trong ảnh người dùng gửi: hai project cùng một thư mục.""" + _mo_project(qapp, ws, monkeypatch, + Project(project_id="b", name="test3", output_dir=str(tmp_path)), + Project(project_id="a", name="test2", output_dir=str(tmp_path))) + + assert ws._folder_warn_lbl.isHidden() is False + assert "test2" in ws._folder_warn_lbl.text() + + +def test_khong_canh_bao_khi_thu_muc_rieng(qapp, ws, monkeypatch, tmp_path): + _mo_project(qapp, ws, monkeypatch, + Project(project_id="b", name="test3", output_dir=str(tmp_path / "b")), + Project(project_id="a", name="test2", output_dir=str(tmp_path / "a"))) + + assert ws._folder_warn_lbl.isHidden() is True + + +def test_nhan_canh_bao_nam_ngay_duoi_o_thu_muc_lam_viec(ws): + """Cảnh báo phải ở cạnh thứ nó nói tới, không rơi xuống cuối biểu mẫu.""" + from cowork_local.presentation.workspace.project_editing import _row_layout_of + from cowork_local.presentation.workspace.project_folder_rules import _layout_chua + + hang = _row_layout_of(ws.folder_lbl) + layout, vi_tri = _layout_chua(ws.folder_lbl.parentWidget().layout(), hang) + + assert layout.itemAt(vi_tri + 1).widget() is ws._folder_warn_lbl + +# ---- hành vi: chọn thư mục đã thuộc project khác thì KHÔNG được ghi ------ + +def test_chon_thu_muc_trung_thi_khong_ghi_gi(qapp, ws, monkeypatch, tmp_path): + """Đây là cổng chặn thật, ở đúng nút "Đổi" mà người dùng bấm.""" + from PySide6.QtWidgets import QFileDialog, QMessageBox + + from cowork_local.ui import workspace_tab as wt + + cua_toi = Project(project_id="b", name="test3", output_dir=str(tmp_path / "b")) + cua_nguoi_khac = Project(project_id="a", name="test2", output_dir=str(tmp_path / "a")) + _mo_project(qapp, ws, monkeypatch, cua_toi, cua_nguoi_khac) + + da_ghi = [] + monkeypatch.setattr(projects_mod, "save_project", + lambda project, directory=None: da_ghi.append(project)) + da_bao = [] + monkeypatch.setattr(QMessageBox, "warning", + staticmethod(lambda *a, **k: da_bao.append(a))) + # Người dùng chọn đúng thư mục của project kia. + monkeypatch.setattr(QFileDialog, "getExistingDirectory", + staticmethod(lambda *a, **k: str(tmp_path / "a"))) + + wt.WorkspaceTab._pick_folder(ws) + + assert da_ghi == [], "đã ghi đè output_dir dù thư mục thuộc project khác" + assert cua_toi.output_dir == str(tmp_path / "b"), "thư mục cũ bị đổi mất" + assert da_bao, "chặn im lặng — người dùng không biết vì sao không đổi được" + + +def test_chon_thu_muc_tu_do_thi_van_doi_duoc(qapp, ws, monkeypatch, tmp_path): + """Chặn một chiều là hỏng tính năng — thư mục chưa ai dùng phải đổi được.""" + from PySide6.QtWidgets import QFileDialog + + from cowork_local.ui import workspace_tab as wt + + cua_toi = Project(project_id="b", name="test3", output_dir=str(tmp_path / "b")) + cua_nguoi_khac = Project(project_id="a", name="test2", output_dir=str(tmp_path / "a")) + _mo_project(qapp, ws, monkeypatch, cua_toi, cua_nguoi_khac) + + da_ghi = [] + monkeypatch.setattr(projects_mod, "save_project", + lambda project, directory=None: da_ghi.append(project)) + monkeypatch.setattr(QFileDialog, "getExistingDirectory", + staticmethod(lambda *a, **k: str(tmp_path / "hoan-toan-moi"))) + + wt.WorkspaceTab._pick_folder(ws) + + assert len(da_ghi) == 1 + assert cua_toi.output_dir == str(tmp_path / "hoan-toan-moi") diff --git a/ui/cowork_tab.py b/ui/cowork_tab.py index 7efe955..5b00879 100644 --- a/ui/cowork_tab.py +++ b/ui/cowork_tab.py @@ -123,9 +123,12 @@ class CoworkTab(ChatPanel): # workspace (its sandbox + shared-knowledge root) — not the # global default-output setting. from ..core.projects import save_project + from ..presentation.workspace.project_folder_rules import folder_taken_blocked project = self._project() if project is not None: + if folder_taken_blocked(self, chosen, self.project_id): + return project.output_dir = chosen save_project(project) else: diff --git a/ui/workspace_tab.py b/ui/workspace_tab.py index 41a22d1..47df595 100644 --- a/ui/workspace_tab.py +++ b/ui/workspace_tab.py @@ -27,13 +27,14 @@ from PySide6.QtWidgets import ( from ..i18n import on_language_changed, tr from ..presentation.workspace.project_editing import ProjectEditingMixin, ProjectRow +from ..presentation.workspace.project_folder_rules import ProjectFolderRuleMixin, folder_taken_blocked from ..state import AppContext from .icons import collapse_left_icon, icon from .osutil import open_folder from .widgets import CollapseStrip -class WorkspaceTab(ProjectEditingMixin, QWidget): +class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget): """Trang chủ Workspace: cột project, cột lịch sử, và 5 sub-tab (Dự án · Cowork · Co4E · Thư mục · GraphRAG). @@ -941,6 +942,8 @@ class WorkspaceTab(ProjectEditingMixin, QWidget): self, tr("workspace.browse_tooltip"), str(project.workspace_dir())) if not chosen: return + if folder_taken_blocked(self, chosen, pid): + return project.output_dir = chosen save_project(project) self.folder_lbl.setText(chosen) @@ -984,6 +987,8 @@ class WorkspaceTab(ProjectEditingMixin, QWidget): if not cloud_source: return local_dir = WORKSPACES_DIR / project.project_id / "_cloud_mirror" + if folder_taken_blocked(self, str(local_dir), pid): + return self._cloud_pick_btn.setEnabled(False) try: token = self._cloud_token() -- 2.54.0 From b78d48320c822b93d595a637a0b7a701010a032c Mon Sep 17 00:00:00 2001 From: minhanhpkpro Date: Thu, 17 Sep 2026 22:32:44 +0900 Subject: [PATCH 13/15] =?UTF-8?q?fix(sandbox):=20"Ch=E1=BA=B7n=20m?= =?UTF-8?q?=E1=BA=A1ng"=20ch=E1=BA=B7n=20m=E1=BB=8Di=20=C4=91=C6=B0?= =?UTF-8?q?=E1=BB=9Dng=20ra=20m=E1=BA=A1ng,=20tr=E1=BB=AB=20nh=C3=A0=20cun?= =?UTF-8?q?g=20c=E1=BA=A5p=20AI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trước đây công tắc chỉ chặn tool mạng của agent; lệnh shell chỉ bị proxy giả, còn M365, Teams, nút Test, MCP đang chạy, task script, link đính kèm task, pip tự cài và tài nguyên web trong xem trước HTML vẫn ra mạng tự do. - Cổng chung application/network/network_guard.py, nối vào cấu hình sống ở Composition Root; nhà cung cấp AI (chat, danh sách model, thử model) không đi qua cổng này. - Lệnh shell của agent và task script chạy trong Windows AppContainer không có quyền mạng (macOS: sandbox-exec, Linux: unshare --net); không cô lập được thì từ chối chạy. - Không cấp quyền kế thừa của AppContainer lên thư mục chứa PySide6: Chromium không nạp được Qt6WebEngineCore.dll và tab Graph bị hỏng. - Bật chặn thì dừng MCP đang chạy; tool OneDrive đồng bộ trên máy vẫn dùng. - Mặc định tắt khi mở app lần đầu; nhãn và tooltip 3 ngôn ngữ cập nhật. - Test: tests/test_network_guard_lanes.py (có bài AppContainer thật). Co-Authored-By: Claude Opus 5 (1M context) --- application/network/__init__.py | 5 + application/network/network_guard.py | 61 +++ .../workspaces/file_preview_helpers.py | 9 +- config.py | 10 +- core/chat_agent.py | 2 +- core/code_agent.py | 2 +- core/deps.py | 29 +- core/ext_connectors.py | 6 + core/jira_tool.py | 2 + core/link_fetch.py | 6 + core/mcp_client.py | 13 +- core/ms365_auth.py | 29 ++ core/ms365_graph.py | 4 + core/sandbox_manager.py | 52 ++- core/task_executors.py | 14 +- core/task_script.py | 47 +++ core/teams.py | 4 + i18n/settings_dialog.py | 34 +- infrastructure/filesystem/command_tools.py | 10 +- .../sandbox/appcontainer_process.py | 392 ++++++++++++++++++ infrastructure/sandbox/network_isolation.py | 46 ++ .../folder/office_document_renderer.py | 3 + presentation/folder/offline_web_page.py | 39 ++ presentation/shell/bootstrap.py | 13 +- state.py | 12 +- tests/conftest.py | 14 + tests/test_network_guard_lanes.py | 389 +++++++++++++++++ ui/settings_dialog.py | 9 +- 28 files changed, 1198 insertions(+), 58 deletions(-) create mode 100644 application/network/__init__.py create mode 100644 application/network/network_guard.py create mode 100644 core/task_script.py create mode 100644 infrastructure/sandbox/appcontainer_process.py create mode 100644 infrastructure/sandbox/network_isolation.py create mode 100644 presentation/folder/offline_web_page.py create mode 100644 tests/test_network_guard_lanes.py diff --git a/application/network/__init__.py b/application/network/__init__.py new file mode 100644 index 0000000..d1abf7e --- /dev/null +++ b/application/network/__init__.py @@ -0,0 +1,5 @@ +"""Application services for the "Block network" switch (Sandbox Security Layer).""" + +from .network_guard import NetworkBlockedError, bind, ensure_allowed, is_blocked, refusal + +__all__ = ["NetworkBlockedError", "bind", "ensure_allowed", "is_blocked", "refusal"] diff --git a/application/network/network_guard.py b/application/network/network_guard.py new file mode 100644 index 0000000..0d655f4 --- /dev/null +++ b/application/network/network_guard.py @@ -0,0 +1,61 @@ +"""One gate for every outbound connection the app makes on its own. + +The "Block network" switch (``agent_security.block_network``) used to be read +only where agent tools run, so Microsoft 365, Teams, connector test buttons, +scheduled task scripts, pip auto-installs and HTML previews still reached the +internet while Monitoring said "Network: blocked". Each of those now asks +this module first. + +The AI provider path (chat, model list, model test) deliberately does NOT go +through here: with the switch on the user still talks to the model, but the +app fetches nothing else on the model's or its own behalf. + +The module holds a *reader*, not a copy of the flag: the Composition Root +binds it to the live config once (``presentation/shell/bootstrap.py``), so a +change saved in Settings takes effect on the very next call. Nothing bound +(unit tests, helper subprocesses) means "not blocked", the pre-switch default. +""" +from __future__ import annotations + +import threading +from typing import Callable, Optional + +_lock = threading.Lock() +_reader: Optional[Callable[[], bool]] = None + + +class NetworkBlockedError(PermissionError): + """Raised by :func:`ensure_allowed` while the switch is on.""" + + +def bind(reader: Optional[Callable[[], bool]]) -> None: + """Install the callable that says whether the network is blocked right now.""" + global _reader + with _lock: + _reader = reader + + +def is_blocked() -> bool: + """True while "Block network" is on. A failing reader counts as blocked.""" + reader = _reader + if reader is None: + return False + try: + return bool(reader()) + except Exception: # noqa: BLE001 - fail closed: an unreadable switch must not open the network + return True + + +def refusal(purpose: str) -> str: + """The message shown (to the user or the model) when ``purpose`` is refused.""" + return (f"{purpose}: network access is blocked by the Sandbox Security Layer " + "(\"Block network\" is on in Settings). Only the AI provider may be reached.") + + +def ensure_allowed(purpose: str) -> None: + """Raise :class:`NetworkBlockedError` if ``purpose`` may not go online now.""" + if is_blocked(): + raise NetworkBlockedError(refusal(purpose)) + + +__all__ = ["NetworkBlockedError", "bind", "ensure_allowed", "is_blocked", "refusal"] diff --git a/application/workspaces/file_preview_helpers.py b/application/workspaces/file_preview_helpers.py index 3f1c78f..868db5c 100644 --- a/application/workspaces/file_preview_helpers.py +++ b/application/workspaces/file_preview_helpers.py @@ -21,9 +21,14 @@ def pptx_available() -> bool: try: from cowork_local.core.deps import ensure_module - _PPTX_READY = ensure_module("pptx", "python-pptx") is not None + ready = ensure_module("pptx", "python-pptx") is not None except Exception: # noqa: BLE001 - _PPTX_READY = False + ready = False + from ..network import network_guard + + if ready or not network_guard.is_blocked(): + _PPTX_READY = ready # a refusal under "Block network" is retried later + return ready return _PPTX_READY diff --git a/config.py b/config.py index e40be0c..b05951e 100644 --- a/config.py +++ b/config.py @@ -100,11 +100,11 @@ 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) - # 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, + # "Block network": every outbound connection except the AI provider is + # refused (application/network/network_guard.py), and agent shell + # commands / task scripts run in a network-less AppContainer. + # OFF on first launch — the user turns it on in Settings. + "block_network": False, # Allow the agent's fetch_url tool to read web pages / online documents / # 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: diff --git a/core/chat_agent.py b/core/chat_agent.py index 1157059..4cdb452 100644 --- a/core/chat_agent.py +++ b/core/chat_agent.py @@ -527,7 +527,7 @@ def run_cowork( preview = {"kind": "info", "title": name, "text": str(args)} emit({"type": "tool_proposed", "id": tc_id, "name": name, "args": args, "preview": preview}) - if ctx.block_network: + if ctx.block_network and not name.startswith("ms365_local__"): result = {"ok": False, "output": ( f"{name}: network access is blocked by the Sandbox Security Layer " '("Block network for agent-run commands" is on in Settings).')} diff --git a/core/code_agent.py b/core/code_agent.py index a31cdc3..f65259d 100644 --- a/core/code_agent.py +++ b/core/code_agent.py @@ -327,7 +327,7 @@ def run_code( else: emit({"type": "tool_start", "id": tc_id, "name": name}) if is_extra and extra_executor is not None: - if ctx.block_network: + if ctx.block_network and not name.startswith("ms365_local__"): result = {"ok": False, "output": ( f"{name}: network access is blocked by the Sandbox Security Layer " '("Block network for agent-run commands" is on in Settings).')} diff --git a/core/deps.py b/core/deps.py index 8ec5d10..dfc99c7 100644 --- a/core/deps.py +++ b/core/deps.py @@ -91,6 +91,7 @@ def run_cancellable( on_output: Optional[Callable[[str], None]] = None, env: Optional[Dict[str, str]] = None, limits: Optional[Dict[str, float]] = None, + isolate_network: bool = False, ) -> Tuple[Optional[int], str, bool, bool, bool]: """Run a subprocess so the Stop button can actually interrupt it. @@ -117,17 +118,27 @@ def run_cancellable( a failure to create/assign the job just means the existing taskkill fallback is used, same as before this was added. + ``isolate_network`` runs ``args`` as a shell command that the OS keeps + off the network (see ``infrastructure/sandbox/network_isolation.py``); + if that isolation cannot be set up the command is NOT run. + Returns ``(returncode, combined_output, cancelled, timed_out, resource_exceeded)``; on a failure to even launch the process, ``returncode`` is ``None`` and the output holds the launch error.""" cancel = cancel or (lambda: False) popen_kwargs = {} if sys.platform == "win32" else {"start_new_session": True} try: - proc = subprocess.Popen( - args, shell=shell, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, bufsize=1, env=env, **popen_kwargs, - ) - except OSError as exc: + if isolate_network: + from ..infrastructure.sandbox.network_isolation import spawn_without_network + + command = args if isinstance(args, str) else subprocess.list2cmdline(args) + proc = spawn_without_network(command, cwd, env) + else: + proc = subprocess.Popen( + args, shell=shell, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, bufsize=1, env=env, **popen_kwargs, + ) + except (OSError, RuntimeError) as exc: # RuntimeError: NetworkIsolationUnavailable return None, str(exc), False, False, False with _active_pids_lock: @@ -266,6 +277,10 @@ def ensure_module(module: str, package: str | None = None): pkg = package or module if pkg in _FAILED or not _can_pip(): return None + from ..application.network import network_guard + + if network_guard.is_blocked(): + return None # not cached in _FAILED: retried once the network is back ok, _ = pip_install(pkg) if not ok: _FAILED.add(pkg) @@ -339,6 +354,10 @@ def pip_install(package: str, cancel: Optional[CancelFn] = None, name) is NOT retried, since repeating it would just waste time.""" if not _can_pip(): return False, "This packaged build can't install packages at runtime." + from ..application.network import network_guard + + if network_guard.is_blocked(): + return False, network_guard.refusal(f"pip install {package}") exe = python or sys.executable attempt = 0 while True: diff --git a/core/ext_connectors.py b/core/ext_connectors.py index ea2cd27..5d4890e 100644 --- a/core/ext_connectors.py +++ b/core/ext_connectors.py @@ -132,8 +132,11 @@ class RestApiConnector: def call(self, args: Dict[str, Any]) -> Dict[str, Any]: """Gọi API theo tham số model đưa ra, đi qua lớp TLS có ghim chứng chỉ nội bộ.""" + from ..application.network import network_guard from .tls_trust import request_any_method as tls_request + if network_guard.is_blocked(): + return {"ok": False, "output": network_guard.refusal(self.display_name)} method = str(args.get("method", "GET")).upper() path = str(args.get("path", "")).lstrip("/") url = urljoin(self.base_url, path) @@ -164,10 +167,13 @@ class RestApiConnector: def test_connection(self) -> Tuple[bool, str]: """Thử kết nối tới endpoint; trả về (thành công, thông điệp).""" + from ..application.network import network_guard from .tls_trust import request as tls_request if not self.base_url.strip("/"): return False, "No base URL configured." + if network_guard.is_blocked(): + return False, network_guard.refusal(self.display_name) headers = {} if self.api_key: headers[self.auth_header] = ( diff --git a/core/jira_tool.py b/core/jira_tool.py index ef5db00..167724c 100644 --- a/core/jira_tool.py +++ b/core/jira_tool.py @@ -85,8 +85,10 @@ def get_issue_by_url(config: Dict[str, Any] | None, url: str) -> str: def _get(config: Dict[str, Any], path: str, params: dict = None): """Gọi Jira REST API bằng xác thực cơ bản, qua lớp TLS có ghim chứng chỉ nội bộ.""" + from ..application.network import network_guard from . import tls_trust + network_guard.ensure_allowed("Jira") c = _conf(config) url = c["base_url"].rstrip("/") + path # Same TLS auto-recovery the LLM provider calls get (core/tls_trust.py) — diff --git a/core/link_fetch.py b/core/link_fetch.py index 455e5b4..0344e31 100644 --- a/core/link_fetch.py +++ b/core/link_fetch.py @@ -147,6 +147,12 @@ def fetch_link_preview(url: str) -> str: return "" if not re.match(r"^https?://", url, re.IGNORECASE): return f"[Link: {url}] (not a fetchable http(s) URL — referenced by address only)" + # Every caller (fetch_url, task link attachments, ...) passes through here, + # so this one check covers the paths that never saw a ToolContext. + from ..application.network import network_guard + + if network_guard.is_blocked(): + return f"[Link: {url}] (not fetched — {network_guard.refusal('link fetch')})" # SharePoint / OneDrive share links are rewritten to their direct-download # form so the shared FILE itself is fetched and parsed (like an attachment), # not the share page's HTML shell. diff --git a/core/mcp_client.py b/core/mcp_client.py index aa4c9de..37bf990 100644 --- a/core/mcp_client.py +++ b/core/mcp_client.py @@ -88,7 +88,14 @@ class McpServerConnection: def start(self, timeout: float = 15.0) -> None: """Spawn the server subprocess and complete the MCP handshake. Raises :class:`McpServerError` on failure (bad command, the server - crashed on startup, the handshake timed out, ...).""" + crashed on startup, the handshake timed out, ...). + + Refused while "Block network" is on: a server process is free to open + any socket it likes, so the only safe server is one never started.""" + from ..application.network import network_guard + + if network_guard.is_blocked(): + raise McpServerError(network_guard.refusal(f"MCP server '{self.name}'")) self._thread = threading.Thread(target=self._run_loop, daemon=True) self._thread.start() if not self._ready.wait(timeout): @@ -174,6 +181,10 @@ class McpServerConnection: def call_tool(self, qualified_name: str, args: Dict[str, Any]) -> Dict[str, Any]: """``extra_executor``-shaped result: ``{"ok": bool, "output": str}``.""" + from ..application.network import network_guard + + if network_guard.is_blocked(): + return {"ok": False, "output": network_guard.refusal(f"MCP server '{self.name}'")} tool_name = qualified_name.split(_SEP, 1)[1] if _SEP in qualified_name else qualified_name try: result = self._run_coro(self._session.call_tool(tool_name, args or {})) diff --git a/core/ms365_auth.py b/core/ms365_auth.py index 693640e..0a7fd88 100644 --- a/core/ms365_auth.py +++ b/core/ms365_auth.py @@ -137,8 +137,34 @@ def _app(tenant_id: str, client_id: str): return app, cache +def _cached_account_offline() -> Optional[dict]: + """First account in the saved token cache, read without any MSAL network setup.""" + try: + import msal + + accounts = _load_cache().find(msal.TokenCache.CredentialType.ACCOUNT) + except Exception: # noqa: BLE001 - no msal / unreadable cache = not signed in + return None + return accounts[0] if accounts else None + + +def _ensure_network(action: str) -> None: + """Turn a "Block network" refusal into the error type callers already handle.""" + from ..application.network import network_guard + + if network_guard.is_blocked(): + raise Ms365AuthError(network_guard.refusal(action)) + + def signed_in_account(tenant_id: str, client_id: str) -> Optional[dict]: """The cached account, if any — a local cache lookup, no network call.""" + from ..application.network import network_guard + + if network_guard.is_blocked(): + # Building the MSAL app fetches the tenant's OpenID configuration, so + # read the token cache directly instead: the UI still sees who is + # signed in without the app reaching login.microsoftonline.com. + return _cached_account_offline() try: app, _cache = _app(tenant_id, client_id) except Ms365AuthError: @@ -156,6 +182,7 @@ def sign_in_device_code(tenant_id: str, client_id: str, on_code: Callable[[dict] ``verification_uri_complete`` (URL with the code pre-filled, when the tenant returns it) and ``message`` (the full human-readable instruction). Returns the MSAL token result dict; raises Ms365AuthError on failure/timeout.""" + _ensure_network("Microsoft 365 sign-in") app, cache = _app(tenant_id, client_id) flow = app.initiate_device_flow(scopes=SCOPES) if "user_code" not in flow: @@ -183,6 +210,7 @@ def get_access_token(tenant_id: str, client_id: str) -> str: """Silently reuse the cached sign-in. Raises Ms365AuthError when there is no valid session — the caller (a Graph call) should surface that as a normal tool failure telling the user to sign in again from Settings.""" + _ensure_network("Microsoft 365") app, cache = _app(tenant_id, client_id) accounts = app.get_accounts() if not accounts: @@ -228,6 +256,7 @@ def sign_out_default(config=None) -> None: def sign_out(tenant_id: str, client_id: str) -> None: """Đăng xuất và xoá token của một tenant/client khỏi kho.""" try: + _ensure_network("Microsoft 365 sign-out") # chặn mạng: chỉ xoá kho token bên dưới app, cache = _app(tenant_id, client_id) for acc in app.get_accounts(): app.remove_account(acc) diff --git a/core/ms365_graph.py b/core/ms365_graph.py index 402ae16..1b00012 100644 --- a/core/ms365_graph.py +++ b/core/ms365_graph.py @@ -43,6 +43,10 @@ def _request(method: str, url: str, token: str, **kwargs) -> requests.Response: """Gọi Graph API, tự ghép ``GRAPH_BASE`` cho đường dẫn tương đối và đổi lỗi HTTP thành :class:`Ms365GraphError` kèm thông điệp đọc được. """ + from ..application.network import network_guard + + if network_guard.is_blocked(): + raise Ms365GraphError(network_guard.refusal("Microsoft 365 (Graph)")) if not url.startswith("http"): url = f"{GRAPH_BASE}{url}" headers = _headers(token, kwargs.pop("headers", None)) diff --git a/core/sandbox_manager.py b/core/sandbox_manager.py index 6264106..0a4c665 100644 --- a/core/sandbox_manager.py +++ b/core/sandbox_manager.py @@ -218,8 +218,13 @@ class SandboxManager: timeout_sec: int, cancel: Optional[Callable[[], bool]] = None, ) -> Dict[str, Any]: - """Dispatch execution to the selected backend.""" - if backend == "direct": + """Dispatch execution to the selected backend. + + With the network blocked every backend is replaced by the same OS-level + isolation: the backends below only ever set proxy env vars, which + anything that ignores proxies (raw sockets, ping, .NET WebClient...) + walked straight past.""" + if block_network or backend == "direct": return self._run_direct(command, workdir, block_network, timeout_sec, cancel) if backend == "integrity_job_wfp": @@ -274,7 +279,8 @@ class SandboxManager: env = os.environ.copy() if block_network: from .deps import network_blocked_env - env = network_blocked_env(env) + env = network_blocked_env(env) # belt and braces on top of the OS block + return self._run_network_isolated(command, workdir, env, timeout_sec, cancel) if cancel is not None: from .deps import run_cancellable @@ -334,4 +340,42 @@ class SandboxManager: "stderr": str(exc), "returncode": -1, "sandbox": "direct", - } \ No newline at end of file + } + + @staticmethod + def _run_network_isolated( + command: str, + workdir: str, + env: Dict[str, str], + timeout_sec: int, + cancel: Optional[Callable[[], bool]] = None, + ) -> Dict[str, Any]: + """Run ``command`` in a process the OS keeps off the network. + + Fail-closed: when the isolation cannot be set up the command is + refused (``sandbox == "blocked"``), never run with the network open.""" + from .deps import run_cancellable + + rc, output, cancelled, timed_out, exceeded = run_cancellable( + command, cwd=workdir or None, timeout=timeout_sec, cancel=cancel, + shell=True, env=env, isolate_network=True, + ) + if rc is None and not (cancelled or timed_out or exceeded): + return {"ok": False, "stdout": "", "returncode": -1, "sandbox": "blocked", + "stderr": ("Command refused: network is blocked and the command could " + f"not be isolated from the network ({output.strip()}).")} + if cancelled: + stderr = "Cancelled by user." + elif timed_out: + stderr = f"Timeout after {timeout_sec}s" + elif exceeded: + stderr = "Resource limit exceeded." + else: + stderr = "" + return { + "ok": rc == 0 and not (cancelled or timed_out or exceeded), + "stdout": output, + "stderr": stderr, + "returncode": rc if rc is not None else -1, + "sandbox": "network_isolated", + } diff --git a/core/task_executors.py b/core/task_executors.py index 57de9a9..1147f10 100644 --- a/core/task_executors.py +++ b/core/task_executors.py @@ -19,7 +19,6 @@ in Waiting Input), so executors here run with an auto gate. """ from __future__ import annotations -import subprocess import time import uuid from datetime import datetime @@ -30,6 +29,7 @@ from . import agent_roles from . import agent_security from . import projects from .permissions import PermissionGate +from .task_script import run_script as _run_script from .tasks import ARTIFACTS_DIR, resolve_input_text from .tools import ToolContext @@ -358,18 +358,6 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path, return _last_assistant_text(messages), timed_out(), incomplete -def _run_script(command: str, out_dir: Path, timeout_sec: int) -> str: - """Chạy một task kiểu script bằng shell trong thư mục kết quả, có hạn giờ.""" - if not command.strip(): - raise RuntimeError("Script task has no command configured.") - proc = subprocess.run(command, shell=True, cwd=str(out_dir), - capture_output=True, text=True, timeout=max(1, timeout_sec)) - output = (proc.stdout or "") + (("\n[stderr]\n" + proc.stderr) if proc.stderr else "") - if proc.returncode != 0: - raise RuntimeError(f"Script exited with code {proc.returncode}:\n{output[-2000:]}") - return output - - def execute_task(ctx, task: Dict[str, Any], run_id: str, emit: Optional[EmitFn] = None, cancel: Optional[CancelFn] = None, tasks_dir: Path = None) -> Dict[str, Any]: diff --git a/core/task_script.py b/core/task_script.py new file mode 100644 index 0000000..213ac59 --- /dev/null +++ b/core/task_script.py @@ -0,0 +1,47 @@ +"""Run a scheduled task of type ``script`` (tách khỏi ``task_executors.py``). + +Khi công tắc "Chặn mạng" đang bật, lệnh của task chạy trong tiến trình bị hệ +điều hành cắt mạng — giống ``run_command`` của agent. Trước đây task script +chạy thẳng bằng ``subprocess.run``, không sandbox, nên lên mạng tự do. +""" +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def run_script(command: str, out_dir: Path, timeout_sec: int) -> str: + """Chạy một task kiểu script bằng shell trong thư mục kết quả, có hạn giờ.""" + if not command.strip(): + raise RuntimeError("Script task has no command configured.") + from ..application.network import network_guard + + if network_guard.is_blocked(): + return _run_script_without_network(command, out_dir, timeout_sec) + proc = subprocess.run(command, shell=True, cwd=str(out_dir), + capture_output=True, text=True, timeout=max(1, timeout_sec)) + output = (proc.stdout or "") + (("\n[stderr]\n" + proc.stderr) if proc.stderr else "") + if proc.returncode != 0: + raise RuntimeError(f"Script exited with code {proc.returncode}:\n{output[-2000:]}") + return output + + +def _run_script_without_network(command: str, out_dir: Path, timeout_sec: int) -> str: + """Như :func:`run_script`, nhưng tiến trình không có mạng; không cô lập được thì không chạy.""" + from .deps import network_blocked_env, run_cancellable + + rc, output, _cancelled, timed_out, _exceeded = run_cancellable( + command, cwd=str(out_dir), timeout=max(1, timeout_sec), shell=True, + env=network_blocked_env(), isolate_network=True, + ) + if timed_out: + raise subprocess.TimeoutExpired(command, timeout_sec) + if rc is None: + raise RuntimeError("Script not run: network is blocked and the script could not be " + f"isolated from the network ({output.strip()}).") + if rc != 0: + raise RuntimeError(f"Script exited with code {rc} (network blocked):\n{output[-2000:]}") + return output + + +__all__ = ["run_script"] diff --git a/core/teams.py b/core/teams.py index fd3fe92..578cc0e 100644 --- a/core/teams.py +++ b/core/teams.py @@ -43,6 +43,10 @@ class TeamsNotifier: """Post a notification. Returns ``(ok, detail)``.""" if not self.configured: return False, "Teams webhook URL is not configured." + from ..application.network import network_guard + + if network_guard.is_blocked(): + return False, network_guard.refusal("Teams notification") # Workflows webhooks expect an Adaptive Card; classic connectors expect a # MessageCard. Try both, then a plain-text fallback. diff --git a/i18n/settings_dialog.py b/i18n/settings_dialog.py index 2544101..6ffb9d3 100644 --- a/i18n/settings_dialog.py +++ b/i18n/settings_dialog.py @@ -10,22 +10,22 @@ from typing import Dict STRINGS: Dict[str, Dict[str, str]] = { "settings.sandbox_block_network": { - "en": "Block network for agent-run commands", - "ja": "エージェントが実行するコマンドのネットワークをブロック", - "vi": "Chặn mạng cho lệnh do agent chạy"}, + "en": "Block network (AI provider still allowed)", + "ja": "ネットワークをブロック(AIプロバイダーのみ許可)", + "vi": "Chặn mạng (vẫn cho gọi nhà cung cấp AI)"}, "settings.allow_url_fetch": { "en": "Allow the agent to fetch URLs (web pages, SharePoint / OneDrive links)", "ja": "エージェントによるURL取得を許可(Webページ、SharePoint / OneDriveリンク)", "vi": "Cho phép agent lấy dữ liệu từ URL (trang web, link SharePoint / OneDrive)"}, "settings.allow_url_fetch_tooltip": { "en": ("Lets the agent's fetch_url tool read web pages, online documents and " - "SharePoint/OneDrive share links to search & process them. Separate from " - "'Block network' (which only sandboxes shell commands). Default: on."), + "SharePoint/OneDrive share links to search & process them. 'Block network' " + "overrides this switch. Default: on."), "ja": "エージェントのfetch_urlツールがWebページ・オンライン文書・SharePoint/OneDrive共有リンクを" - "読み取れるようにします。「ネットワークをブロック」(シェルコマンド用)とは別です。既定: オン。", + "読み取れるようにします。「ネットワークをブロック」がオンの場合はそちらが優先されます。既定: オン。", "vi": ("Cho phép tool fetch_url của agent đọc trang web, tài liệu online và link chia sẻ " - "SharePoint/OneDrive để tìm kiếm & xử lý. Tách biệt với 'Chặn mạng' (chỉ áp cho lệnh " - "shell). Mặc định: bật.")}, + "SharePoint/OneDrive để tìm kiếm & xử lý. 'Chặn mạng' được ưu tiên hơn công tắc " + "này. Mặc định: bật.")}, "settings.test_internet": { "en": "Test Internet", "ja": "インターネット接続テスト", "vi": "Kiểm tra Internet"}, "settings.test_internet_tooltip": { @@ -39,12 +39,18 @@ STRINGS: Dict[str, Dict[str, str]] = { "en": "Testing internet access…", "ja": "インターネット接続をテスト中…", "vi": "Đang kiểm tra truy cập internet…"}, "settings.sandbox_block_network_tooltip": { - "en": ("Policy-level control (proxy env vars point at a black hole) — not a kernel " - "firewall. Combine with the command whitelist above for defense in depth."), - "ja": "ポリシーレベルの制御です(プロキシ環境変数をブラックホールに向ける)— カーネルレベルの" - "ファイアウォールではありません。上のコマンドホワイトリストと併用してください。", - "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."}, + "en": ("Only the AI provider (chat, model list, model test) may reach the network. " + "Agent shell commands and task scripts run in a Windows AppContainer with no " + "network access; fetch_url, Jira, connectors/MCP, Microsoft 365, Teams, " + "connector tests, pip auto-install and remote content in HTML previews are refused."), + "ja": "ネットワークに接続できるのはAIプロバイダー(チャット・モデル一覧・モデルテスト)のみです。" + "エージェントのシェルコマンドとタスクスクリプトはネットワークなしのWindows AppContainerで実行され、" + "fetch_url・Jira・コネクタ/MCP・Microsoft 365・Teams・接続テスト・pip自動インストール・" + "HTMLプレビューの外部リソースは拒否されます。", + "vi": "Chỉ nhà cung cấp AI (chat, tải danh sách model, thử model) được ra mạng. Lệnh shell " + "của agent và task script chạy trong Windows AppContainer không có mạng; fetch_url, " + "Jira, connector/MCP, Microsoft 365, Teams, nút Test, tự cài thư viện và tài nguyên " + "web trong xem trước HTML đều bị từ chối."}, "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 8d88e40..06f6a39 100644 --- a/infrastructure/filesystem/command_tools.py +++ b/infrastructure/filesystem/command_tools.py @@ -76,11 +76,11 @@ def run_command(ctx: ToolContext, args: Dict[str, Any], denial = "Command blocked by security policy: " + "; ".join(risk.reasons) return {"ok": False, "output": denial} - # Every sandbox backend's network block is a proxy-env-var trick (see - # core/deps.py::network_blocked_env) — it does nothing against a tool - # that reaches the network without an HTTP proxy (ping/ICMP, nslookup/ - # direct DNS, ssh/ftp/raw TCP...). Deny those BY NAME here instead, so - # "Chặn mạng cho lệnh do agent chạy" actually blocks them too. + # With the network blocked, SandboxManager runs the command in an OS-level + # network-less process (AppContainer on Windows — see + # infrastructure/sandbox/network_isolation.py). Tools that exist only to + # reach the network (ping, nslookup, ssh...) are still denied BY NAME + # first: the model gets a clear reason instead of a cryptic socket error. if ctx.block_network: bypass_tool = command_bypasses_network_proxy(command) if bypass_tool: diff --git a/infrastructure/sandbox/appcontainer_process.py b/infrastructure/sandbox/appcontainer_process.py new file mode 100644 index 0000000..04a8e61 --- /dev/null +++ b/infrastructure/sandbox/appcontainer_process.py @@ -0,0 +1,392 @@ +"""Spawn a shell command inside a Windows AppContainer with NO network capability. + +Why this exists +--------------- +The old "block network" for agent shell commands only pointed the proxy env +vars at a dead port (``core/deps.py::network_blocked_env``). Anything that +ignores proxies (``Invoke-WebRequest -NoProxy``, raw sockets, ``certutil``, +.NET ``WebClient``...) still reached the internet. An AppContainer token that +is granted no ``internetClient``/``privateNetworkClientServer`` capability is +refused by the kernel firewall for every outbound connection, loopback +included, no matter which tool makes it. No admin rights are needed. + +An AppContainer can only open files whose ACL admits its SID (or ALL +APPLICATION PACKAGES). System32 and Program Files already do; the workdir and +the app's own Python install do not, so :func:`spawn` grants the profile SID +access to those folders first (an extra ACE, nothing is removed). + +:class:`AppContainerProcess` quacks like ``subprocess.Popen`` for the subset +``core/deps.py::_run_cancellable_body`` uses (``pid``, ``stdout``/``stderr`` +text streams, ``poll``/``wait``/``kill``/``returncode``), so the Stop button, +timeouts, Job Objects and resource limits keep working unchanged. +""" +from __future__ import annotations + +import io +import locale +import os +import subprocess +import sys +import threading +from typing import Dict, Iterable, Optional + +PROFILE_NAME = "cowork_local.agent_netblock" +_IS_WINDOWS = sys.platform == "win32" + +if _IS_WINDOWS: + import ctypes + import msvcrt + from ctypes import wintypes + + _k32 = ctypes.WinDLL("kernel32", use_last_error=True) + _adv = ctypes.WinDLL("advapi32", use_last_error=True) + _uenv = ctypes.WinDLL("userenv", use_last_error=True) + + class _SECURITY_CAPABILITIES(ctypes.Structure): + _fields_ = [("AppContainerSid", ctypes.c_void_p), ("Capabilities", ctypes.c_void_p), + ("CapabilityCount", wintypes.DWORD), ("Reserved", wintypes.DWORD)] + + class _STARTUPINFOW(ctypes.Structure): + _fields_ = [("cb", wintypes.DWORD), ("lpReserved", wintypes.LPWSTR), + ("lpDesktop", wintypes.LPWSTR), ("lpTitle", wintypes.LPWSTR), + ("dwX", wintypes.DWORD), ("dwY", wintypes.DWORD), + ("dwXSize", wintypes.DWORD), ("dwYSize", wintypes.DWORD), + ("dwXCountChars", wintypes.DWORD), ("dwYCountChars", wintypes.DWORD), + ("dwFillAttribute", wintypes.DWORD), ("dwFlags", wintypes.DWORD), + ("wShowWindow", wintypes.WORD), ("cbReserved2", wintypes.WORD), + ("lpReserved2", ctypes.c_void_p), ("hStdInput", wintypes.HANDLE), + ("hStdOutput", wintypes.HANDLE), ("hStdError", wintypes.HANDLE)] + + class _STARTUPINFOEXW(ctypes.Structure): + _fields_ = [("StartupInfo", _STARTUPINFOW), ("lpAttributeList", ctypes.c_void_p)] + + class _PROCESS_INFORMATION(ctypes.Structure): + _fields_ = [("hProcess", wintypes.HANDLE), ("hThread", wintypes.HANDLE), + ("dwProcessId", wintypes.DWORD), ("dwThreadId", wintypes.DWORD)] + + class _SECURITY_ATTRIBUTES(ctypes.Structure): + _fields_ = [("nLength", wintypes.DWORD), ("lpSecurityDescriptor", ctypes.c_void_p), + ("bInheritHandle", wintypes.BOOL)] + + _uenv.CreateAppContainerProfile.restype = ctypes.c_long + _uenv.CreateAppContainerProfile.argtypes = [ + wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.LPCWSTR, ctypes.c_void_p, + wintypes.DWORD, ctypes.POINTER(ctypes.c_void_p)] + _uenv.DeriveAppContainerSidFromAppContainerName.restype = ctypes.c_long + _uenv.DeriveAppContainerSidFromAppContainerName.argtypes = [ + wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_void_p)] + _uenv.GetAppContainerFolderPath.restype = ctypes.c_long + _uenv.GetAppContainerFolderPath.argtypes = [ + wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_wchar_p)] + _adv.ConvertSidToStringSidW.restype = wintypes.BOOL + _adv.ConvertSidToStringSidW.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_wchar_p)] + _k32.InitializeProcThreadAttributeList.restype = wintypes.BOOL + _k32.InitializeProcThreadAttributeList.argtypes = [ + ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(ctypes.c_size_t)] + _k32.UpdateProcThreadAttribute.restype = wintypes.BOOL + _k32.UpdateProcThreadAttribute.argtypes = [ + ctypes.c_void_p, wintypes.DWORD, ctypes.c_size_t, ctypes.c_void_p, + ctypes.c_size_t, ctypes.c_void_p, ctypes.c_void_p] + _k32.DeleteProcThreadAttributeList.argtypes = [ctypes.c_void_p] + _k32.CreatePipe.restype = wintypes.BOOL + _k32.CreatePipe.argtypes = [ctypes.POINTER(wintypes.HANDLE), ctypes.POINTER(wintypes.HANDLE), + ctypes.POINTER(_SECURITY_ATTRIBUTES), wintypes.DWORD] + _k32.SetHandleInformation.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD] + _k32.CreateProcessW.restype = wintypes.BOOL + _k32.CreateProcessW.argtypes = [ + wintypes.LPCWSTR, wintypes.LPWSTR, ctypes.c_void_p, ctypes.c_void_p, wintypes.BOOL, + wintypes.DWORD, ctypes.c_void_p, wintypes.LPCWSTR, ctypes.POINTER(_STARTUPINFOEXW), + ctypes.POINTER(_PROCESS_INFORMATION)] + _k32.ResumeThread.argtypes = [wintypes.HANDLE] + _k32.WaitForSingleObject.restype = wintypes.DWORD + _k32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] + _k32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] + _k32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT] + _k32.CloseHandle.argtypes = [wintypes.HANDLE] + _k32.GetStdHandle.restype = wintypes.HANDLE + _k32.OpenProcess.restype = wintypes.HANDLE + + _ALREADY_EXISTS = ctypes.c_long(0x800700B7).value + _PROC_THREAD_ATTRIBUTE_HANDLE_LIST = 0x00020002 + _PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES = 0x00020009 + _EXTENDED_STARTUPINFO_PRESENT = 0x00080000 + _CREATE_UNICODE_ENVIRONMENT = 0x00000400 + _CREATE_NO_WINDOW = 0x08000000 + _CREATE_SUSPENDED = 0x00000004 + _STARTF_USESTDHANDLES = 0x00000100 + _HANDLE_FLAG_INHERIT = 0x00000001 + _STILL_ACTIVE = 259 + +_profile_lock = threading.Lock() +_profile: Dict[str, object] = {} +_granted: set = set() + + +class NetworkIsolationUnavailable(RuntimeError): + """This machine cannot start a network-less process — callers must refuse to run.""" + + +def is_supported() -> bool: + """True on Windows builds that ship the AppContainer API (Windows 8+).""" + if not _IS_WINDOWS: + return False + try: + return bool(_uenv.CreateAppContainerProfile) + except AttributeError: + return False + + +def _profile_sid(): + """``(sid pointer, sid string, temp folder)`` of the shared no-network profile.""" + with _profile_lock: + if _profile: + return _profile["sid"], _profile["sid_str"], _profile["temp"] + sid = ctypes.c_void_p() + hr = _uenv.CreateAppContainerProfile(PROFILE_NAME, "Cowork Local agent (no network)", + "Agent shell commands with the network blocked", + None, 0, ctypes.byref(sid)) + if hr == _ALREADY_EXISTS: + hr = _uenv.DeriveAppContainerSidFromAppContainerName(PROFILE_NAME, ctypes.byref(sid)) + if hr != 0 or not sid.value: + raise NetworkIsolationUnavailable(f"AppContainer profile error 0x{hr & 0xFFFFFFFF:08X}") + text = ctypes.c_wchar_p() + if not _adv.ConvertSidToStringSidW(sid, ctypes.byref(text)): + raise NetworkIsolationUnavailable("Could not read the AppContainer SID") + sid_str = text.value + folder = ctypes.c_wchar_p() + temp = "" + if _uenv.GetAppContainerFolderPath(sid_str, ctypes.byref(folder)) == 0 and folder.value: + temp = os.path.join(folder.value, "Temp") + os.makedirs(temp, exist_ok=True) + _profile.update(sid=sid, sid_str=sid_str, temp=temp) + return sid, sid_str, temp + + +_PERMS = {"write": "(OI)(CI)(M)", "read": "(OI)(CI)(RX)", "read_here": "(OI)(NP)(RX)"} + + +def _qt_package_dir() -> str: + """Folder of the installed PySide6/Qt binaries ('' if PySide6 is absent).""" + try: + import importlib.util + + spec = importlib.util.find_spec("PySide6") + except (ImportError, ValueError): + return "" + return os.path.dirname(spec.origin) if spec and spec.origin else "" + + +def _covers(folder: str, target: str) -> bool: + """True if ``target`` is ``folder`` itself or lies somewhere below it.""" + if not folder or not target: + return False + folder, target = os.path.normcase(folder), os.path.normcase(target) + return target == folder or target.startswith(folder.rstrip("\\/") + os.sep) + + +def _icacls(*args: str) -> subprocess.CompletedProcess: + return subprocess.run(["icacls", *args], capture_output=True, text=True, + creationflags=_CREATE_NO_WINDOW) + + +def grant_access(path: str, sid_str: str, mode: str) -> None: + """Let the AppContainer SID open ``path``. + + ``mode`` is ``"write"``/``"read"`` (inherited by everything below) or + ``"read_here"`` (this folder and the files directly in it, no deeper). + + An inherited ACE must never reach the Qt WebEngine binaries: Chromium's + sandboxed render process then fails to load Qt6WebEngineCore.dll + (STATUS_DLL_NOT_FOUND) and every web view in the app goes blank. A folder + that contains the PySide6 install is therefore refused. + """ + path = os.path.abspath(path) + key = (os.path.normcase(path), mode) + if key in _granted or not os.path.exists(path): + return + if mode != "read_here" and _covers(path, _qt_package_dir()): + raise NetworkIsolationUnavailable( + f"Refusing to sandbox a folder that contains the app's Qt runtime: {path}") + perm = _PERMS[mode] + listing = (_icacls(path).stdout or "").lower() + if f"{sid_str}:{perm}".lower() not in listing: + done = _icacls(path, "/grant", f"*{sid_str}:{perm}", "/Q", "/C") + if done.returncode != 0: + raise NetworkIsolationUnavailable( + f"Could not grant the sandbox access to {path}: {(done.stderr or done.stdout).strip()}") + _granted.add(key) + + +def _repair_inherited_grant(path: str, sid_str: str) -> None: + """Drop an inherited grant that an earlier build put on the app's venv.""" + key = (os.path.normcase(os.path.abspath(path)), "repaired") + if key in _granted or not os.path.isdir(path): + return + listing = (_icacls(path).stdout or "").lower() + if f"{sid_str}:(oi)(ci)".lower() in listing: + _icacls(path, "/remove:g", f"*{sid_str}", "/Q", "/C") + _granted.add(key) + + +def _interpreter_grants(): + """``(folder, mode)`` pairs that let the sandbox run the app's Python. + + The base install is read-only and holds no Qt; a venv only needs its root + (``pyvenv.cfg``) and ``Scripts`` — never ``Lib/site-packages``. + """ + qt_dir = _qt_package_dir() + if _covers(sys.base_prefix, qt_dir): + # PySide6 lives in the base install itself: expose only the executable + # and the compiled stdlib modules, never the tree holding Qt. + grants = [(sys.base_prefix, "read_here"), (os.path.join(sys.base_prefix, "DLLs"), "read")] + else: + grants = [(sys.base_prefix, "read")] + if os.path.normcase(sys.prefix) != os.path.normcase(sys.base_prefix): + grants += [(sys.prefix, "read_here"), (os.path.join(sys.prefix, "Scripts"), "read")] + return [(folder, mode) for folder, mode in grants + if mode == "read_here" or not _covers(folder, qt_dir)] + + +def _env_block(env: Dict[str, str]) -> ctypes.Array: + """Sorted, double-NUL-terminated UTF-16 environment block for CreateProcessW.""" + items = sorted(env.items(), key=lambda kv: kv[0].upper()) + text = "".join(f"{k}={v}\0" for k, v in items if k and "=" not in k) + "\0" + return ctypes.create_unicode_buffer(text, len(text)) + + +def _pipe(): + """Anonymous pipe; only the child's (write) end is inheritable.""" + sa = _SECURITY_ATTRIBUTES(ctypes.sizeof(_SECURITY_ATTRIBUTES), None, True) + read, write = wintypes.HANDLE(), wintypes.HANDLE() + if not _k32.CreatePipe(ctypes.byref(read), ctypes.byref(write), ctypes.byref(sa), 0): + raise ctypes.WinError(ctypes.get_last_error()) + _k32.SetHandleInformation(read, _HANDLE_FLAG_INHERIT, 0) + return read, write + + +class AppContainerProcess: + """The ``subprocess.Popen`` subset that ``deps._run_cancellable_body`` relies on.""" + + def __init__(self, handle, pid: int, stdout: io.TextIOBase, stderr: io.TextIOBase): + """Wrap an already-started process handle and its two output streams.""" + self._handle = handle + self.pid = pid + self.stdout = stdout + self.stderr = stderr + self.returncode: Optional[int] = None + + def poll(self) -> Optional[int]: + """Exit code if the process has finished, else None.""" + if self.returncode is None and self._handle: + code = wintypes.DWORD() + if _k32.GetExitCodeProcess(self._handle, ctypes.byref(code)) and code.value != _STILL_ACTIVE: + self.returncode = ctypes.c_int32(code.value).value + _k32.CloseHandle(self._handle) + self._handle = None + return self.returncode + + def wait(self, timeout: Optional[float] = None) -> int: + """Block until exit; raise ``subprocess.TimeoutExpired`` like Popen does.""" + if self.returncode is None and self._handle: + ms = 0xFFFFFFFF if timeout is None else int(timeout * 1000) + if _k32.WaitForSingleObject(self._handle, ms) != 0: + raise subprocess.TimeoutExpired("appcontainer", timeout) + return self.poll() + + def kill(self) -> None: + """Terminate the process (the Job Object in deps kills its children).""" + if self.returncode is None and self._handle: + _k32.TerminateProcess(self._handle, 1) + + def communicate(self, timeout: Optional[float] = None): + """Read both streams to the end and wait; returns ``(stdout, stderr)``.""" + chunks: Dict[str, str] = {} + + def _drain(name, stream): + chunks[name] = stream.read() + + readers = [threading.Thread(target=_drain, args=(n, s), daemon=True) + for n, s in (("out", self.stdout), ("err", self.stderr))] + for t in readers: + t.start() + for t in readers: + t.join(timeout) + self.wait(timeout) + return chunks.get("out", ""), chunks.get("err", "") + + +def spawn(command: str, cwd: Optional[str], env: Optional[Dict[str, str]], + readable_dirs: Iterable[str] = ()) -> AppContainerProcess: + """Start ``cmd.exe /c command`` in the no-network AppContainer. + + Raises :class:`NetworkIsolationUnavailable` when that cannot be done — + callers must then refuse the command rather than run it with the network on. + """ + if not is_supported(): + raise NetworkIsolationUnavailable("AppContainer is only available on Windows") + sid, sid_str, temp = _profile_sid() + cwd = os.path.abspath(cwd or os.getcwd()) + _repair_inherited_grant(sys.prefix, sid_str) + grant_access(cwd, sid_str, "write") + for folder, mode in [*_interpreter_grants(), *((d, "read") for d in readable_dirs if d)]: + grant_access(folder, sid_str, mode) + + child_env = dict(os.environ if env is None else env) + if temp: + child_env["TEMP"] = child_env["TMP"] = temp + child_env.setdefault("PYTHONIOENCODING", "utf-8") + env_block = _env_block(child_env) + + out_r, out_w = _pipe() + err_r, err_w = _pipe() + handles = (wintypes.HANDLE * 2)(out_w, err_w) + caps = _SECURITY_CAPABILITIES(sid, None, 0, 0) + size = ctypes.c_size_t() + _k32.InitializeProcThreadAttributeList(None, 2, 0, ctypes.byref(size)) + attr = ctypes.create_string_buffer(size.value) + pi = _PROCESS_INFORMATION() + try: + if not (_k32.InitializeProcThreadAttributeList(attr, 2, 0, ctypes.byref(size)) + and _k32.UpdateProcThreadAttribute( + attr, 0, _PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, ctypes.byref(caps), + ctypes.sizeof(caps), None, None) + and _k32.UpdateProcThreadAttribute( + attr, 0, _PROC_THREAD_ATTRIBUTE_HANDLE_LIST, handles, + ctypes.sizeof(handles), None, None)): + raise NetworkIsolationUnavailable(str(ctypes.WinError(ctypes.get_last_error()))) + si = _STARTUPINFOEXW() + si.StartupInfo.cb = ctypes.sizeof(si) + si.StartupInfo.dwFlags = _STARTF_USESTDHANDLES + si.StartupInfo.hStdOutput = out_w + si.StartupInfo.hStdError = err_w + si.lpAttributeList = ctypes.addressof(attr) + comspec = os.environ.get("COMSPEC") or r"C:\Windows\System32\cmd.exe" + cmdline = ctypes.create_unicode_buffer(f'"{comspec}" /d /s /c "{command}"') + flags = (_EXTENDED_STARTUPINFO_PRESENT | _CREATE_UNICODE_ENVIRONMENT + | _CREATE_NO_WINDOW | _CREATE_SUSPENDED) + if not _k32.CreateProcessW(None, cmdline, None, None, True, flags, + ctypes.addressof(env_block), cwd, + ctypes.byref(si), ctypes.byref(pi)): + raise NetworkIsolationUnavailable( + f"Could not start the sandboxed command: {ctypes.WinError(ctypes.get_last_error())}") + _k32.ResumeThread(pi.hThread) + _k32.CloseHandle(pi.hThread) + except BaseException: + for h in (out_r, err_r): + _k32.CloseHandle(h) + raise + finally: + _k32.DeleteProcThreadAttributeList(attr) + _k32.CloseHandle(out_w) + _k32.CloseHandle(err_w) + + def _stream(handle) -> io.TextIOBase: + fd = msvcrt.open_osfhandle(handle.value, os.O_RDONLY) + return io.TextIOWrapper(io.FileIO(fd, "rb"), encoding=locale.getpreferredencoding(False), + errors="replace") + + return AppContainerProcess(pi.hProcess, pi.dwProcessId, _stream(out_r), _stream(err_r)) + + +__all__ = ["AppContainerProcess", "NetworkIsolationUnavailable", "PROFILE_NAME", + "grant_access", "is_supported", "spawn"] diff --git a/infrastructure/sandbox/network_isolation.py b/infrastructure/sandbox/network_isolation.py new file mode 100644 index 0000000..210e7d5 --- /dev/null +++ b/infrastructure/sandbox/network_isolation.py @@ -0,0 +1,46 @@ +"""Start a shell command that the operating system keeps off the network. + +Used whenever "Block network" is on for agent ``run_command`` calls and for +scheduled script tasks. The contract is fail-closed: if isolation cannot be +set up, :class:`NetworkIsolationUnavailable` is raised and the caller refuses +the command instead of running it with the network open. + +* Windows: an AppContainer with no network capability + (:mod:`.appcontainer_process`). +* macOS: ``sandbox-exec`` with a profile that denies every network operation. +* Linux: ``unshare --net`` in a new user namespace (an empty network namespace + has only a downed loopback). If unprivileged namespaces are disabled, + ``unshare`` itself fails and the command never runs. +""" +from __future__ import annotations + +import shutil +import subprocess +import sys +from typing import Dict, Optional + +from .appcontainer_process import NetworkIsolationUnavailable + +_MACOS_PROFILE = "(version 1)(allow default)(deny network*)" + + +def spawn_without_network(command: str, cwd: Optional[str], env: Optional[Dict[str, str]]): + """A ``Popen``-like process running ``command`` through the shell, with no network.""" + if sys.platform == "win32": + from . import appcontainer_process + + return appcontainer_process.spawn(command, cwd, env) + if sys.platform == "darwin" and shutil.which("sandbox-exec"): + argv = ["sandbox-exec", "-p", _MACOS_PROFILE, "/bin/sh", "-c", command] + elif sys.platform.startswith("linux") and shutil.which("unshare"): + argv = ["unshare", "--user", "--map-root-user", "--net", "/bin/sh", "-c", command] + else: + raise NetworkIsolationUnavailable( + "No network isolation is available on this system (needs AppContainer, " + "sandbox-exec or unshare).") + return subprocess.Popen(argv, cwd=cwd, env=env, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, bufsize=1, + start_new_session=True) + + +__all__ = ["NetworkIsolationUnavailable", "spawn_without_network"] diff --git a/presentation/folder/office_document_renderer.py b/presentation/folder/office_document_renderer.py index ea6985d..3aef705 100644 --- a/presentation/folder/office_document_renderer.py +++ b/presentation/folder/office_document_renderer.py @@ -110,7 +110,10 @@ class OfficeDocumentRenderer: if self._engine is None: try: from PySide6.QtWebEngineWidgets import QWebEngineView + + from .offline_web_page import install_offline_page self._engine = QWebEngineView() + install_offline_page(self._engine) self._owner.stack.addWidget(self._engine) except Exception: # noqa: BLE001 self._engine = None diff --git a/presentation/folder/offline_web_page.py b/presentation/folder/offline_web_page.py new file mode 100644 index 0000000..44c96ee --- /dev/null +++ b/presentation/folder/offline_web_page.py @@ -0,0 +1,39 @@ +"""HTML preview page that loads nothing from the web while "Block network" is on. + +``QWebEngineView.setHtml`` happily fetches every ````, +``