Files
a03a740ea1
CI / test (push) Canceled after 0s
Feature/perf ui logic (#13)
## 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
2026-09-20 12:26:03 +00:00

338 lines
17 KiB
Python

"""Shared application context passed to the UI widgets."""
from __future__ import annotations
import threading
import time
from typing import TYPE_CHECKING, Optional, Tuple
from .config import AppConfig
from .infrastructure.mcp import McpToolSourceManager
def resolve_agent_default(
active_provider: str,
setting_model: str,
current_model: str,
model_provider: Optional[str],
user_override: bool,
) -> Tuple[str, bool]:
"""Decide which model a tab's **Agent** selector should default to.
Rule: the default always follows Settings (the active provider's configured
model). A per-tab model the user picked by hand survives only while the active
provider is unchanged — so a fresh launch, or switching the active provider in
Settings, snaps every tab back to the Settings model, while a deliberate
runtime override keeps working until then.
Returns ``(model, keep_override)`` — ``model`` is the model to select
(``''`` means "use the provider's own default") and ``keep_override`` says
whether the user's manual override is still in effect.
"""
if user_override and model_provider == active_provider and current_model:
return current_model, True
return setting_model, False
class AppContext:
"""Holds the live config and small convenience factories."""
def __init__(self, config: AppConfig):
"""Dựng ngữ cảnh dùng chung cho cả ứng dụng: cấu hình, mốc khởi động và các
nguồn tool.
Vòng đời kết nối MCP nằm ở ``McpToolSourceManager`` chứ không ở đây — xem
docstring của nó về việc vì sao kiểm-rồi-tạo phải có khoá.
"""
self.config = config
self.started_at = time.time() # for Monitoring's Sandbox Details "Created"/"Uptime"
# Admin-configured MCP servers + the built-in MS365 server (R05-T05):
# connection caching/lifecycle (check-then-create, restart, shutdown)
# now lives in McpToolSourceManager, extracted so it is testable
# without an AppContext/Qt. See its docstring for why the check-then-
# create race matters — several turns (multiple Cowork tabs, parallel
# Co4E flows, scheduled tasks) can call build_mcp_tools() at once.
self._mcp_manager = McpToolSourceManager()
self._ext_connections: dict = {} # connector id -> McpServerConnection (mcp_stdio mode only)
# Guards ``_ext_connections`` only now — unified Connectors (CAD/CAE/
# MS365/Other) aren't covered by McpToolSourceManager (R05-T05 scoped
# to MCP servers), so this cache still needs its own check-then-create
# lock, the same race McpToolSourceManager guards against internally.
self._conn_lock = threading.Lock()
self._routing_service = None # lazy RoutingService (Auto Model Routing)
# Lazy RoutingApplicationService (R03-T03) — the Qt-free decision layer
# every chat surface now routes through. Wraps _routing_service, which
# stays the scoring/ranking engine underneath.
self._routing_application = None
self._routing_lock = threading.Lock()
# A SEPARATE lock for the application service: building it calls
# routing(), which takes _routing_lock. threading.Lock is not
# reentrant, so sharing one lock across both accessors deadlocks the
# first caller instead of just serialising them.
self._routing_app_lock = threading.Lock()
# The workspace (project) currently selected in the Workspace screen.
# Per-workspace modes (routing + auto-run) resolve against THIS project
# so each workspace keeps its own modes. Updated by WorkspaceTab on
# project switch; "default" is the auto-seeded starter workspace.
self.active_project_id = "default"
@property
def role(self) -> str:
"""Vai trò người dùng. Bản này không có lớp xác thực nên luôn là 'admin'."""
return "admin" # no authentication layer, always full access
# ---- Per-workspace modes (Auto Model Routing + Auto-run) ----------------
def _current_project(self):
"""The workspace currently selected in the Workspace screen, or None."""
pid = getattr(self, "active_project_id", "") or ""
if not pid:
return None
from .core.projects import load_project
return load_project(pid)
def project_routing_mode(self, surface: str) -> str:
"""Effective Off/Auto/Manual/Fallback routing mode for a chat ``surface``
in the ACTIVE workspace: the workspace's own override wins; otherwise the
global default (``config.routing_mode_for``). This is what makes each
workspace keep its own routing mode.
The accepted set is taken from ``AppConfig.ROUTING_MODES`` rather than
repeated here, so adding a mode (as R03-T03 did with "fallback") stays a
one-line change instead of a hunt through every validation site."""
project = self._current_project()
if project is not None:
mode = (project.routing_modes or {}).get(surface, "")
if mode in self.config.ROUTING_MODES:
return mode
return self.config.routing_mode_for(surface)
def set_project_routing_mode(self, surface: str, mode: str) -> None:
"""Persist a surface's routing mode for the ACTIVE workspace. With no
workspace selected, falls back to the global setting so behaviour
outside a project stays global."""
mode = mode if mode in self.config.ROUTING_MODES else "off"
project = self._current_project()
if project is None:
self.config.set_routing_mode_for(surface, mode)
return
from .core.projects import save_project
modes = dict(project.routing_modes or {})
modes[surface] = mode
project.routing_modes = modes
save_project(project)
def project_confirm_commands(self) -> bool:
"""Whether to CONFIRM before running a command in the ACTIVE workspace
(True → show the Approve/Reject dialog; False → auto-run). The
workspace's own ``auto_run`` override wins; otherwise the global
``agent_security.cowork_confirm_commands``."""
project = self._current_project()
if project is not None and project.auto_run is not None:
return not bool(project.auto_run) # auto_run True → no confirm (auto-approve)
return bool(self.config.agent_security.get("cowork_confirm_commands"))
def project_auto_run(self) -> bool:
"""Convenience inverse of :meth:`project_confirm_commands` — True means
commands auto-approve (no confirm dialog) in the active workspace."""
return not self.project_confirm_commands()
def set_project_auto_run(self, auto_run: Optional[bool]) -> None:
"""Persist the ACTIVE workspace's auto-run override. ``None`` → follow
the global setting. With no workspace selected, writes the global
confirm flag instead (``auto_run True`` ⇒ no confirm)."""
project = self._current_project()
if project is None:
if auto_run is not None:
self.config.agent_security["cowork_confirm_commands"] = (not auto_run)
self.save()
return
from .core.projects import save_project
project.auto_run = auto_run
save_project(project)
def routing(self):
"""The shared :class:`~cowork_local.core.routing.service.RoutingService`
for Auto Model Assessment & Routing — created on first use so importing
state.py never pulls in the routing stack (and its deps) at startup.
One instance per app: it owns the assessment store + the in-memory
pending-switch registry, both of which must be shared across every chat
surface (Cowork / Co4E / AI-Edit) so a switch confirmed on one screen
and the scores probed by the scheduler are visible everywhere."""
if self._routing_service is None:
with self._routing_lock:
if self._routing_service is None:
from .core.routing.service import RoutingService
self._routing_service = RoutingService(self)
return self._routing_service
def routing_application(self):
"""The shared :class:`RoutingApplicationService` (R03-T03).
This is what UI code should call: it owns the Off/Auto/Manual/Fallback
policy, the confirm handshake and the never-raise guarantee, while
:meth:`routing` remains the scoring engine underneath. Chat, Co4E and
AI-Edit all go through this one object, so a change to routing policy is
made once instead of three times.
Built lazily and memoised for the same reason as :meth:`routing`: the
pending-switch registry and assessment store must be shared app-wide."""
if self._routing_application is None:
# Resolve the engine BEFORE taking this lock: routing() takes
# _routing_lock, and nesting the two acquisitions is what makes the
# ordering fragile in the first place.
engine = self.routing()
with self._routing_app_lock:
if self._routing_application is None:
from .application.model_routing import RoutingApplicationService
self._routing_application = RoutingApplicationService(
engine,
# Per-workspace mode lookup, so each workspace keeps its
# own routing behaviour (see project_routing_mode).
mode_reader=self.project_routing_mode,
)
return self._routing_application
def build_active_provider(self):
"""Construct the currently selected provider (called inside workers)."""
return self.build_provider_for(self.config.active_provider)
def build_provider_for(self, name: str, model: str | None = None):
"""Construct a provider by key, optionally overriding the model (per-tab
agent/model selection)."""
from .providers import build_provider
name = name or self.config.active_provider
conf = dict(self.config.provider_conf(name))
if model:
conf["model"] = model
# A self-signed/internal-CA gateway is handled automatically by each
# provider (see providers.base.Provider._request / core.tls_trust) —
# this is only an explicit override for advanced/IT-managed setups
# (COWORK_CA_BUNDLE env var), no longer exposed in Settings.
conf["ca_bundle"] = self.config.ca_bundle
return build_provider(name, conf)
def teams_notifier(self):
"""Bộ gửi thông báo Teams, dựng theo webhook trong cấu hình."""
from .core.teams import TeamsNotifier
return TeamsNotifier(self.config.teams.get("webhook_url", ""), ca_bundle=self.config.ca_bundle)
def save(self) -> None:
"""Ghi cấu hình xuống đĩa."""
self.config.save()
# ---- 🔌 MCP Layer — external MCP servers this app connects to as a client
def build_mcp_tools(self):
"""``(tools, executor)`` for every enabled, successfully-connected MCP
server in Settings, PLUS the built-in MS365 server when Microsoft 365
is signed in with a connector enabled (``_ms365_builtin_connection``),
PLUS every enabled unified Connector (CAD/CAE/MS365/Other — Settings →
"Connectors (MCP)", see ``core/ext_connectors.py``). Reuses
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 — and stops the ones
# already running, which could otherwise keep talking to the network.
blocked = bool(self.config.agent_security.get("block_network", False))
if blocked:
self.stop_mcp_connections()
# 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.
if not self.config.connect_external:
return [], None
from .core.ms365_local import build_ms365_local_tools
if blocked: # the locally-synced OneDrive folders need no network
return build_ms365_local_tools(self.config)
from .core.ext_connectors import build_ext_connector_tools
from .core.mcp_client import build_mcp_tools as _merge_mcp_tools
from .core.tools import combine_tool_sources
# R05-T05: connection caching/check-then-create for admin-configured
# servers + the MS365 builtin now lives in McpToolSourceManager (its
# own lock guards the race — see its docstring).
active = []
for entry in self.config.mcp_servers:
if not entry.get("enabled", True):
continue
name = entry.get("name", "")
command = entry.get("command", "")
if not name or not command:
continue
conn = self._mcp_manager.ensure(name, command, entry.get("args") or [],
entry.get("env") or None)
if conn is not None:
active.append(conn)
builtin = self._ms365_builtin_connection(skip={c.name for c in active})
if builtin is not None:
active.append(builtin)
mcp_tools, mcp_executor = _merge_mcp_tools(active)
# ``_ext_connections`` isn't covered by McpToolSourceManager (T05
# scoped to MCP servers) — still serialized under ``_conn_lock``.
with self._conn_lock:
ext = self.config.ext_connectors
all_connectors = [*ext.get("cad", []), *ext.get("cae", []),
*ext.get("ms365", []), *ext.get("other", [])]
ext_tools, ext_executor = build_ext_connector_tools(all_connectors, self._ext_connections)
# Locally-synced OneDrive/SharePoint (no sign-in) — reads/writes the
# OneDrive-desktop-synced folders directly, gated on ms365.connectors.
local_tools, local_executor = build_ms365_local_tools(self.config)
return combine_tool_sources((mcp_tools, mcp_executor), (ext_tools, ext_executor),
(local_tools, local_executor))
# ---- built-in MS365 MCP server (mcp_servers/ms365_server.py) ---------
_MS365_BUILTIN = "ms365"
def _ms365_available(self) -> bool:
"""Should the built-in MS365 MCP server exist right now? Mirrors the
gate ``ms365_tools.build_ms365_tools`` enforces internally: external
internet allowed + at least one connector on + signed in."""
ms365 = self.config.ms365
if not ms365.get("allow_external_internet"):
return False
if not any((ms365.get("connectors") or {}).values()):
return False
from .core.ms365_auth import signed_in_account
return signed_in_account(ms365.get("tenant_id", ""),
ms365.get("client_id", "")) is not None
def _ms365_builtin_connection(self, skip=frozenset()):
"""Connection to the built-in MS365 MCP server — spawned on demand,
stopped again when the user signs out / disables every connector.
``skip`` lets a user-configured server named 'ms365' take precedence."""
import os
import sys
from pathlib import Path
name = self._MS365_BUILTIN
if name in skip:
return None
if not self._ms365_available():
self._mcp_manager.stop(name)
return None
# The subprocess must import cowork_local even in a from-source run
# (PYTHONPATH=src) — prepend this package's parent dir explicitly.
env = dict(os.environ)
src_root = str(Path(__file__).resolve().parent.parent)
env["PYTHONPATH"] = (src_root + os.pathsep + env["PYTHONPATH"]
if env.get("PYTHONPATH") else src_root)
return self._mcp_manager.ensure(
name, sys.executable, ["-m", "cowork_local.mcp_servers.ms365_server"], env)
def stop_mcp_connections(self) -> None:
"""Terminate every connected MCP server's subprocess (incl. External
Connectors in mcp_stdio mode) — called on app shutdown so none of
them linger as orphan processes."""
from .core.ext_connectors import stop_ext_connections
self._mcp_manager.stop_all()
with self._conn_lock:
stop_ext_connections(self._ext_connections)