CI / test (push) Canceled after 0s
## Summary Nhánh `feature/perf-ui-logic`: tối ưu hiệu năng/UI, sửa lỗi workspace và điều hướng, và làm cho công tắc **"Block network for agent-run commands"** chặn thật mọi đường ra mạng của app, **trừ nhà cung cấp AI**. **Chặn mạng (b78d483,8c497cf,10b8379)** - Bộ kiểm tra chung `application/network/network_guard.py`, nối vào cấu hình đang chạy ở Composition Root: đổi công tắc trong Settings là có hiệu lực ngay. - Lệnh shell của agent và task script chạy trong **Windows AppContainer không có quyền mạng**: kernel chặn socket, ping, DNS, Invoke-WebRequest… Không cần quyền admin. Không cô lập được thì lệnh bị từ chối, không chạy khi mạng còn mở. macOS dùng `sandbox-exec`, Linux dùng `unshare --net`. - Bật chặn thì: dừng MCP đang chạy, không khởi động server mới, từ chối lời gọi connector; Microsoft 365 (đăng nhập, Graph, đồng bộ cloud, rules, mail), Teams, nút Test REST/Jira/MCP, link đính kèm task, pip tự cài và tài nguyên web trong xem trước HTML đều bị từ chối. - Vẫn dùng được: chat, tải danh sách model, thử model; tool OneDrive đã đồng bộ trên máy. - Công tắc **mặc định tắt** khi mở app lần đầu; nhãn giữ nguyên như cũ. - Xem trước HTML trong tab Folder giờ hiện được ảnh/CSS/JS từ web khi mạng mở (trước đây trang `file://` không tải được). - Sửa lỗi app văng khi chuyển tab Graph → Folder: profile WebEngine của trang xem trước bị huỷ trước trang (`0xc0000409` trong Qt6Core.dll); giờ dùng một profile chung thuộc QApplication. - Không cấp quyền AppContainer kế thừa lên thư mục chứa PySide6 (nếu có, Chromium không nạp được `Qt6WebEngineCore.dll` và tab Graph trắng). - Cột mục lục trong Settings tính độ rộng theo kiểu chữ của mục đang chọn, "Sandbox Security Layer" không còn bị cắt. **Các commit khác trong nhánh** - `b7a41b3` mỗi thư mục làm việc chỉ thuộc về một project · `bbdf146` bật nút Sửa project khi đã có project đang mở - `35f24e0`, `cc8d5c8`, `2e3e719`, `c699beb` canh hàng / khoảng cách thanh điều hướng - `2759ed9` không refresh workspace khi chuyển tab Cowork · `7607f44` checkpoint hiệu năng và UI - `8548c1e` chặn tool mạng của agent · `caf3b74` renderer GraphRAG native trên macOS · `c00b83c` khoảng cách metadata hàng project · `a04f8a9` ẩn picker workspace cloud ## Change Type - [x] Cowork feature - [x] Bug fix - [ ] Core AI contribution - [x] Test / hardening - [x] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? - Mọi đường ra mạng do app tự mở, trừ nhà cung cấp AI (xem Summary). - Test: `tests/test_network_guard_lanes.py` (có bài chạy AppContainer thật trên Windows), `tests/ui/test_html_preview_remote_images.py`. What is intentionally NOT included? - Chặn cả nhà cung cấp AI / chạy model trên máy (Phương án 2). - Terminal người dùng tự gõ trong tab Folder, sinh ảnh, cơ chế tự tin chứng chỉ lạ (`tls_trust`). - Huy hiệu trạng thái "đang chặn" trên thanh trên cùng. ## Validation - [x] Unit tests - [x] Integration tests - [x] Manual verification - [x] Regression check Commands / evidence: - `python -m pytest tests/test_network_guard_lanes.py tests/test_sandbox_block_network.py tests/ui -q` → chỉ còn 1 bài fail, fail cả trên `b7a41b3` (nhãn `ProjectRow` 'Project' chưa dịch, `tests/ui/test_i18n_khong_con_chu_cu.py`). - `python -m pytest tests -q --ignore=tests/ui` → 4 bài fail, cả 4 cũng fail trên `b7a41b3` (`test_canonical_audit_logger`, 2 bài `test_mcp_audit_security`, `test_monitoring_tab_container`). - Chạy cả `tests` trong một lượt thì treo ở các test dựng MainWindow trong `tests/ui`; `b7a41b3` cũng treo đúng chỗ đó. - `check_imports.py` và `check_orphan_modules.py` PASS. `check_loc.py` báo 9 file quá dài, giống hệt trước khi sửa (không file nào do nhánh này làm dài thêm). - Kiểm tra tay trên Windows 11: trong AppContainer, Python báo `WinError 10013`, ping/nslookup/PowerShell/curl đều không ra được mạng; cmd, git, python chạy bình thường. - Kiểm tra tay trên Windows 11: xem trước HTML tải được 4/4 tài nguyên web khi mạng mở, 0/4 khi bật chặn; tab Graph hoạt động; tạo/huỷ trang xem trước nhiều lần không còn cảnh báo profile của Qt. ## Security Impact Permission / credential / network / customer data impact: - Network: khi bật công tắc, chỉ nhà cung cấp AI còn ra mạng; nội dung chat vẫn gửi tới nhà cung cấp AI. - Permission: lần đầu chạy lệnh trong sandbox, app **thêm quyền (ACE) cho SID AppContainer** trên thư mục làm việc (ghi), thư mục cài Python gốc (đọc), gốc venv và `Scripts` (đọc). Không xoá quyền nào. Thư mục chứa PySide6 không bao giờ nhận quyền kế thừa; một quyền kế thừa sai trên venv (từ bản dev trước) được tự gỡ. - Credential: không đổi. Khi chặn, trạng thái đăng nhập M365 được đọc thẳng từ kho token trên máy, không dựng MSAL. ## Compatibility - [x] No breaking change - [ ] Breaking change documented Ghi chú: `block_network` mặc định đổi từ bật sang tắt cho cấu hình mới; máy đã lưu `true` thì giữ nguyên. Khi đang chặn, lệnh dùng công cụ cài trong thư mục người dùng (ngoài Program Files) có thể báo Access denied; thư viện trong venv của app không dùng được trong sandbox. ## Reviewer Notes - `infrastructure/sandbox/appcontainer_process.py` gọi Win32 bằng ctypes (CreateAppContainerProfile, CreateProcessW với SECURITY_CAPABILITIES) và dùng `icacls` để cấp quyền: nên xem kỹ phần cấp quyền. - `tests/conftest.py` thêm fixture autouse gỡ `network_guard` sau mỗi test, vì `build_context()` gắn cổng này ở mức process. - `core/task_executors.py` đang đúng bằng trần LOC nên `_run_script` được tách sang `core/task_script.py`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: minhanhpkpro <minhanhpkpro@gmail.com> Co-authored-by: Duy Le Huu <duylh19@fpt.com> Co-authored-by: thanhnv <thanhnv.ip@gmail.com> Reviewed-on: #13
287 lines
13 KiB
Python
287 lines
13 KiB
Python
"""Hộp thoại Cài đặt — khung lắp ráp.
|
|
|
|
Năm mục, mỗi mục một trang: Chung, AI Provider, Bảo mật sandbox, Tham số,
|
|
Auto Model Routing. Bốn mục đầu... đúng hơn: bốn trong năm mục đã bóc sang
|
|
``presentation/settings/`` (R08-T07); file này còn giữ mục Bảo mật sandbox,
|
|
phần lắp ráp danh sách mục bên trái, và ``_save`` gọi ``apply_to`` của từng
|
|
widget con.
|
|
|
|
Không còn phần Connector nào ở đây: nó đã dời sang Monitoring → Tools →
|
|
Connector từ trước. Ngày 25/08 dọn nốt 108 dòng MS365 chết còn sót lại của
|
|
lần dời đó — năm hàm gọi lẫn nhau, không đường vào, và đọc ba thuộc tính
|
|
chưa từng được gán nên gọi vào là AttributeError.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from PySide6.QtCore import Qt
|
|
from PySide6.QtGui import QGuiApplication
|
|
from PySide6.QtWidgets import (
|
|
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout,
|
|
QGroupBox, QHBoxLayout, QListWidget, QListWidgetItem,
|
|
QScrollArea, QSpinBox,
|
|
QTreeWidgetItem, QVBoxLayout, QWidget,
|
|
)
|
|
|
|
from ..i18n import tr
|
|
from .dialog_buttons import dialog_buttons
|
|
from .widgets import ToggleSwitch
|
|
|
|
|
|
from ..presentation.settings.general_settings_widget import GeneralSettingsWidget
|
|
from ..presentation.settings.provider_settings_widget import ProviderSettingsWidget
|
|
from ..presentation.settings.parameter_settings_widget import ParameterSettingsWidget
|
|
from ..presentation.settings.routing_settings_widget import RoutingSettingsWidget
|
|
from ..presentation.settings.about_widget import AboutSettingsWidget
|
|
|
|
|
|
class SettingsDialog(QDialog):
|
|
"""Hộp thoại Cài đặt: cột mục lục bên trái, các trang bên phải
|
|
(Nhà cung cấp · Connectors · Định tuyến · Tham số · Chung).
|
|
"""
|
|
def __init__(self, ctx, parent=None):
|
|
"""Hộp thoại Cài đặt, ghép các nhóm thiết lập.
|
|
|
|
Có nút thu nhỏ (không phải mặc định của hộp thoại Qt) vì màn này hay được để
|
|
mở trong lúc người dùng làm việc ở cửa sổ chính.
|
|
"""
|
|
super().__init__()
|
|
self.ctx = ctx
|
|
self.setWindowTitle(tr("settings.title"))
|
|
self.setMinimumWidth(560)
|
|
self.setWindowFlags(
|
|
self.windowFlags()
|
|
| Qt.WindowMinimizeButtonHint
|
|
| Qt.WindowMaximizeButtonHint
|
|
)
|
|
self.setSizeGripEnabled(True)
|
|
# Group boxes are styled app-wide (see theme._TEMPLATE); this dialog
|
|
# used to re-declare them and drifted out of sync with the rest.
|
|
data = ctx.config.data
|
|
|
|
outer = QVBoxLayout(self)
|
|
scroll = QScrollArea()
|
|
scroll.setWidgetResizable(True)
|
|
# Never sideways: the content must fit the width it is given and scroll
|
|
# only downwards. Anything too wide has to shrink (see _model_combo and
|
|
# _with_load), not push a second scrollbar onto the user.
|
|
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
self._content = QWidget()
|
|
root = QVBoxLayout(self._content)
|
|
|
|
# --- Chung: ngôn ngữ, giao diện, khay ---
|
|
# Đã bóc sang presentation/settings/general_settings_widget.py (R08-T07).
|
|
self._general_box = GeneralSettingsWidget(self.ctx)
|
|
root.addWidget(self._general_box)
|
|
|
|
self._load_workers = []
|
|
|
|
# --- AI Provider ---
|
|
# Đã bóc sang presentation/settings/provider_settings_widget.py (R08-T07).
|
|
prov_group = ProviderSettingsWidget(self.ctx)
|
|
self._provider_page = prov_group
|
|
root.addWidget(prov_group)
|
|
|
|
# --- Sandbox Security Layer ---
|
|
sec = ctx.config.agent_security
|
|
self.sandbox_group = QGroupBox(tr("settings.group.sandbox"))
|
|
sbl = QVBoxLayout(self.sandbox_group)
|
|
|
|
# Nhóm này KHÔNG còn khoá bằng mật khẩu: bốn công tắc dưới đây bật/tắt
|
|
# tự do. Khoá cũ chỉ là rào chống bấm nhầm ở phía giao diện, không phải
|
|
# cơ chế bảo mật thật (rào thật nằm ở sandbox lúc chạy lệnh), nên bỏ đi
|
|
# theo yêu cầu thay vì giữ một bước nhập mật khẩu không bảo vệ được gì.
|
|
self.sandbox_confirm = ToggleSwitch(tr("settings.sandbox_confirm_commands"))
|
|
self.sandbox_confirm.setChecked(bool(sec.get("cowork_confirm_commands", False)))
|
|
self.sandbox_confirm.setToolTip(tr("settings.sandbox_confirm_commands_tooltip"))
|
|
sbl.addWidget(self.sandbox_confirm)
|
|
|
|
self.sandbox_block_network = ToggleSwitch(tr("settings.sandbox_block_network"))
|
|
self.sandbox_block_network.setChecked(bool(sec.get("block_network", False)))
|
|
self.sandbox_block_network.setToolTip(tr("settings.sandbox_block_network_tooltip"))
|
|
sbl.addWidget(self.sandbox_block_network)
|
|
|
|
# "Allow the agent to fetch URLs" + the live "Test Internet" self-test
|
|
# moved to Monitoring → Tools → Tool (they govern a tool capability, so
|
|
# they belong with the other tool toggles — see ToolsAdminTab).
|
|
|
|
# --- Enable/Disable Agent Security ---
|
|
self.sec_enabled = ToggleSwitch(tr("settings.sec_enabled"))
|
|
self.sec_enabled.setChecked(bool(sec.get("enabled", True)))
|
|
self.sec_enabled.setToolTip(tr("settings.sec_enabled_tooltip"))
|
|
sbl.addWidget(self.sec_enabled)
|
|
|
|
# --- AI Command Check toggle ---
|
|
self.ai_check = ToggleSwitch(tr("settings.ai_check"))
|
|
self.ai_check.setChecked(bool(sec.get("command_ai_check", False)))
|
|
self.ai_check.setToolTip(tr("settings.ai_check_tooltip"))
|
|
sbl.addWidget(self.ai_check)
|
|
|
|
# Resource limits (CPU/Memory/Disk I/O) moved to the Parameter group
|
|
# below — see _param_section("settings.group.sandbox_limits").
|
|
|
|
root.addWidget(self.sandbox_group)
|
|
|
|
# Connectors (MCP / REST API) are managed entirely in Monitoring → Tools
|
|
# for the dead-but-retained MS365 OAuth sign-in handlers below.)
|
|
|
|
# --- Parameter ---
|
|
# Đã bóc sang presentation/settings/parameter_settings_widget.py (R08-T07).
|
|
param_group = ParameterSettingsWidget(self.ctx)
|
|
self._param_page = param_group
|
|
root.addWidget(param_group)
|
|
|
|
# ---- Auto Model Routing ------------------------------------------
|
|
# Đã bóc sang presentation/settings/routing_settings_widget.py (R08-T07).
|
|
routing_group = RoutingSettingsWidget(self.ctx)
|
|
self._routing_page = routing_group
|
|
root.addWidget(routing_group)
|
|
|
|
|
|
# Left list + right panel: one group on screen at a time, the way the
|
|
# audit page's mock-up shows it. The five rows are the five real group
|
|
# boxes, so "which group am I in, how many left" is answerable at a
|
|
# glance instead of by scrolling to find out.
|
|
from .widgets import section_panels
|
|
|
|
|
|
# Giới thiệu đứng CUỐI: nó không có thiết lập nào để đổi, nên đặt trước
|
|
# các mục thao tác được sẽ đẩy chúng xuống mà không được gì.
|
|
self._about_page = AboutSettingsWidget(self.ctx)
|
|
root.addWidget(self._about_page)
|
|
|
|
pages = []
|
|
for label, widget in ((tr("settings.group.general"), self._general_box),
|
|
(tr("settings.group.provider"), prov_group),
|
|
(tr("settings.group.sandbox"), self.sandbox_group),
|
|
(tr("settings.group.parameter"), param_group),
|
|
(tr("routing.settings_group"), routing_group),
|
|
(tr("settings.group.about"), self._about_page)):
|
|
root.removeWidget(widget)
|
|
page = QWidget()
|
|
pv = QVBoxLayout(page)
|
|
pv.setContentsMargins(4, 4, 4, 4)
|
|
pv.addWidget(widget)
|
|
pv.addStretch(1)
|
|
wrap = QScrollArea()
|
|
wrap.setWidgetResizable(True)
|
|
wrap.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
wrap.setWidget(page)
|
|
pages.append((label, wrap))
|
|
self.section_list, self.section_stack = section_panels(pages)
|
|
scroll.setParent(None)
|
|
body = QHBoxLayout()
|
|
body.setSpacing(10)
|
|
body.addWidget(self.section_list)
|
|
body.addWidget(self.section_stack, 1)
|
|
outer.addLayout(body, 1)
|
|
self._content = self._general_box # kept for other callers
|
|
# Floor the dialog at the width its WIDEST page needs, at the current
|
|
# font. On a 125%/150% display everything is wider, and without this the
|
|
# form was simply cut off instead of the window refusing to get smaller.
|
|
widest = max(w.widget().sizeHint().width() for _lab, w in pages)
|
|
self.setMinimumWidth(self.section_list.width() + widest + 60)
|
|
|
|
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
|
buttons.accepted.connect(self._save)
|
|
buttons.rejected.connect(self.reject)
|
|
outer.addWidget(buttons)
|
|
|
|
from .widgets import guard_wheel
|
|
guard_wheel(self)
|
|
|
|
screen = QGuiApplication.primaryScreen()
|
|
if screen:
|
|
avail = screen.availableGeometry()
|
|
self.resize(640, min(740, avail.height() - 80))
|
|
self.setMaximumHeight(avail.height())
|
|
|
|
# ---- cầu tương thích sau khi bóc Routing -----------------------------
|
|
# Năm checker trong tools/ và bài đặc tả đọc thẳng self.routing_*. Giữ tên
|
|
# cũ trỏ vào widget mới để việc bóc không kéo theo sửa chỗ khác — đây là
|
|
# đổi chỗ ở, không đổi hành vi. Bỏ được khi tools/ chuyển sang đọc
|
|
# self._routing_page.
|
|
provider_combo = property(lambda self: self._provider_page.provider_combo)
|
|
prov_base = property(lambda self: self._provider_page.prov_base)
|
|
prov_key = property(lambda self: self._provider_page.prov_key)
|
|
prov_model = property(lambda self: self._provider_page.prov_model)
|
|
prov_status = property(lambda self: self._provider_page.prov_status)
|
|
language_combo = property(lambda self: self._general_box.language_combo)
|
|
theme_combo = property(lambda self: self._general_box.theme_combo)
|
|
tray_chk = property(lambda self: self._general_box.tray_chk)
|
|
notify_chk = property(lambda self: self._general_box.notify_chk)
|
|
attach_files = property(lambda self: self._param_page.attach_files)
|
|
attach_tokens = property(lambda self: self._param_page.attach_tokens)
|
|
struct_nodes = property(lambda self: self._param_page.struct_nodes)
|
|
struct_edges = property(lambda self: self._param_page.struct_edges)
|
|
sandbox_cpu = property(lambda self: self._param_page.sandbox_cpu)
|
|
sandbox_memory = property(lambda self: self._param_page.sandbox_memory)
|
|
sandbox_disk = property(lambda self: self._param_page.sandbox_disk)
|
|
routing_mode = property(lambda self: self._routing_page.mode)
|
|
routing_policy = property(lambda self: self._routing_page.policy)
|
|
routing_min_gain = property(lambda self: self._routing_page.min_gain)
|
|
routing_timeout = property(lambda self: self._routing_page.timeout)
|
|
routing_interval = property(lambda self: self._routing_page.interval)
|
|
routing_concurrency = property(lambda self: self._routing_page.concurrency)
|
|
routing_judge = property(lambda self: self._routing_page.judge)
|
|
routing_reassess_btn = property(lambda self: self._routing_page.reassess_btn)
|
|
|
|
# ---- helpers -----------------------------------------------------
|
|
|
|
|
|
@staticmethod
|
|
def _group(title: str, rows) -> QGroupBox:
|
|
"""Dựng một nhóm có tiêu đề chứa các hàng nhãn–điều khiển."""
|
|
box = QGroupBox(title)
|
|
form = QFormLayout(box)
|
|
for label, widget in rows:
|
|
form.addRow(label, widget)
|
|
return box
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- MS365 zero-config sign-in ("connect like Claude") ---------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _save(self) -> None:
|
|
"""Gom cấu hình từ mọi trang con rồi ghi xuống đĩa."""
|
|
data = self.ctx.config.data
|
|
self._provider_page.apply_to(data)
|
|
self._general_box.apply_to(data)
|
|
|
|
|
|
# NOTE: allow_url_fetch is managed in Monitoring → Tools → Tool now
|
|
# (persisted there directly), so it is intentionally not written here.
|
|
data.setdefault("agent_security", {}).update({
|
|
"enabled": self.sec_enabled.isChecked(),
|
|
"cowork_confirm_commands": self.sandbox_confirm.isChecked(),
|
|
"block_network": self.sandbox_block_network.isChecked(),
|
|
"command_ai_check": self.ai_check.isChecked(),
|
|
"command_whitelist": [],
|
|
})
|
|
self._param_page.apply_limits_to(data["agent_security"])
|
|
self._param_page.apply_to(data)
|
|
|
|
self._routing_page.apply_to(data)
|
|
|
|
self.ctx.save()
|
|
|
|
# Force-reload config so all parts of the app pick up the new settings immediately
|
|
self.ctx.config._data = None # invalidate cache
|
|
self.ctx.config._agent_security = None
|
|
|
|
stop_mcp = getattr(self.ctx, "stop_mcp_connections", None)
|
|
if self.sandbox_block_network.isChecked() and stop_mcp is not None:
|
|
# A running MCP server is its own process and may keep using the
|
|
# network — stop them now, off the UI thread (each stop may wait).
|
|
import threading
|
|
threading.Thread(target=stop_mcp, daemon=True).start()
|
|
|
|
self.accept() |