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

207 lines
9.3 KiB
Python

"""Best-effort URL preview for task/attachment links.
A link that points at a real document (PDF/Office/OpenDocument — by
Content-Type or, failing that, the URL's own extension) is downloaded in
full and run through ``doc_extract.extract_text`` — the SAME parser local
file attachments already use — so a link to a file behaves like an actual
attached file, not garbled text. Only when the link is NOT a recognized
document does this fall back to fetching a bounded preview and, for HTML,
stripping tags with a lightweight regex (no heavy dependency for that path).
Never raises: network failures, non-HTML/non-document content, oversized
pages and unparseable documents all degrade to a short explanatory note so a
bad link never breaks a task run.
"""
from __future__ import annotations
import re
import tempfile
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
_TIMEOUT = (10, 20) # (connect, read) seconds
_MAX_FETCH_BYTES = 2_000_000 # generic text/HTML preview cap (~2MB)
_MAX_DOC_FETCH_BYTES = 20_000_000 # a real document is downloaded in full, up to this cap
_MAX_PREVIEW_CHARS = 8_000
_SCRIPT_STYLE_RE = re.compile(r"<(script|style)\b[^>]*>.*?</\1>", re.IGNORECASE | re.DOTALL)
_TAG_RE = re.compile(r"<[^>]+>")
_WS_RE = re.compile(r"[ \t]+")
_BLANK_LINES_RE = re.compile(r"\n{3,}")
# Content-Type → the suffix doc_extract.extract_text() dispatches on.
_CONTENT_TYPE_SUFFIX = {
"application/pdf": ".pdf",
"application/msword": ".doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/vnd.ms-excel": ".xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"application/vnd.ms-powerpoint": ".ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
"application/vnd.oasis.opendocument.text": ".odt",
"application/vnd.oasis.opendocument.spreadsheet": ".ods",
"application/vnd.oasis.opendocument.presentation": ".odp",
}
_DOC_SUFFIXES = {".pdf", ".doc", ".docx", ".docm", ".xls", ".xlsx", ".xlsm",
".ppt", ".pptx", ".odt", ".ods", ".odp"}
def _html_to_text(html: str) -> str:
"""Rút văn bản đọc được từ HTML: bỏ script/style, đổi thẻ thành xuống dòng rồi
gộp khoảng trắng thừa.
"""
text = _SCRIPT_STYLE_RE.sub(" ", html)
text = _TAG_RE.sub("\n", text)
text = _WS_RE.sub(" ", text)
text = _BLANK_LINES_RE.sub("\n\n", text)
return text.strip()
def _doc_suffix_for(url: str, content_type: str, disposition: str = "") -> str:
"""The doc_extract-recognized suffix for this response, or "" when it
isn't a document at all. Checks Content-Type first, then the URL's own
extension, then the Content-Disposition filename — share-link downloads
(SharePoint/OneDrive) have extension-less URLs and often ship as
application/octet-stream, so the disposition filename is the only tell."""
suffix = _CONTENT_TYPE_SUFFIX.get(content_type, "")
if suffix:
return suffix
path_suffix = Path(urlparse(url).path).suffix.lower()
if path_suffix in _DOC_SUFFIXES:
return path_suffix
m = re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)', disposition or "")
if m:
disp_suffix = Path(m.group(1).strip()).suffix.lower()
if disp_suffix in _DOC_SUFFIXES:
return disp_suffix
return ""
# ---- SharePoint / OneDrive share links --------------------------------------
_SHAREPOINT_HOST_RE = re.compile(r"(^|\.)sharepoint\.com$", re.IGNORECASE)
_ONEDRIVE_HOSTS = {"1drv.ms", "onedrive.live.com"}
def _is_share_link(url: str) -> bool:
"""Link này có phải link chia sẻ SharePoint/OneDrive không.
Loại link đó cần đi qua đường xác thực MS365 thay vì tải HTTP thường.
"""
host = (urlparse(url).hostname or "").lower()
return bool(_SHAREPOINT_HOST_RE.search(host)) or host in _ONEDRIVE_HOSTS
def _share_download_url(url: str) -> Optional[str]:
"""Turn a SharePoint / OneDrive SHARE link into a direct-download URL, or
None when ``url`` isn't a share link. No auth is used — this works for
links shared as "Anyone with the link"; an access-protected link comes
back as an HTML sign-in page, which the caller detects and explains.
- ``https://<tenant>.sharepoint.com/:x:/...`` (and /personal/, /sites/
Shared Documents file links) → same URL + ``download=1``.
- ``https://1drv.ms/...`` / ``onedrive.live.com`` → the public OneDrive
shares API: ``https://api.onedrive.com/v1.0/shares/u!<b64url>/root/content``.
"""
parsed = urlparse(url)
host = (parsed.hostname or "").lower()
if host in _ONEDRIVE_HOSTS:
import base64
token = base64.urlsafe_b64encode(url.encode("utf-8")).decode("ascii").rstrip("=")
return f"https://api.onedrive.com/v1.0/shares/u!{token}/root/content"
if _SHAREPOINT_HOST_RE.search(host):
sep = "&" if parsed.query else "?"
if "download=1" in (parsed.query or ""):
return url
return f"{url}{sep}download=1"
return None
def _extract_document(raw: bytes, suffix: str):
"""``(text, note)`` via doc_extract.extract_text() on a temp copy of
``raw`` — mirrors how a local file attachment of the same type is read."""
from . import doc_extract
tmp_path = None
try:
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
f.write(raw)
tmp_path = f.name
return doc_extract.extract_text(tmp_path)
except OSError as exc:
return None, str(exc)
finally:
if tmp_path:
try:
Path(tmp_path).unlink()
except OSError:
pass
def fetch_link_preview(url: str) -> str:
"""A short text preview of ``url``'s content, or a note explaining why
none is available. Always returns a string, never raises."""
url = (url or "").strip()
if not url:
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.
is_share = _is_share_link(url)
fetch_target = _share_download_url(url) or url
try:
from . import tls_trust
# Same TLS auto-recovery the LLM provider calls already get: a
# corporate gateway that terminates TLS with its own certificate used
# to break fetch_url outright (SSLCertVerificationError) even when
# "Allow the agent to fetch URLs" was on and network wasn't blocked —
# this call site just never had the same self-signed-cert recovery.
resp = tls_trust.request("get", fetch_target, timeout=_TIMEOUT, stream=True,
headers={"User-Agent": "Mozilla/5.0 (CoworkLocal)"})
resp.raise_for_status()
content_type = resp.headers.get("Content-Type", "").split(";")[0].strip().lower()
doc_suffix = _doc_suffix_for(url, content_type,
resp.headers.get("Content-Disposition", ""))
cap = _MAX_DOC_FETCH_BYTES if doc_suffix else _MAX_FETCH_BYTES
raw = resp.raw.read(cap, decode_content=True) or b""
except Exception as exc: # noqa: BLE001 — a bad link must never break a task
if is_share:
return (f"[Link: {url}] (SharePoint/OneDrive share link — could not download: {exc}. "
"If the file needs sign-in, share it as 'Anyone with the link', or use the "
"locally-synced OneDrive folder path instead.)")
return f"[Link: {url}] (could not fetch: {exc})"
# A share link answered with an HTML page = an auth wall: a real shared
# file downloads as the document itself, so an HTML response means a
# sign-in/redirect page (even when the URL's own suffix looks like a doc).
# Say that instead of dumping the login page's text into the prompt.
if is_share and "html" in content_type:
return (f"[Link: {url}] (SharePoint/OneDrive share link requires sign-in — the link "
"returned a login page, not the file. Share it as 'Anyone with the link', or "
"attach the file from the locally-synced OneDrive folder instead.)")
if doc_suffix:
text, note = _extract_document(raw, doc_suffix)
if text is None:
return f"[Link: {url}] (file: {doc_suffix}; could not read it: {note or 'unknown error'})"
preview = text[:_MAX_PREVIEW_CHARS]
trunc = "…" if len(text) > _MAX_PREVIEW_CHARS else ""
return f"[Link: {url}] (file: {doc_suffix})\n{preview}{trunc}"
text = raw.decode(resp.encoding or "utf-8", errors="replace")
if "html" in content_type or "<html" in text[:500].lower():
text = _html_to_text(text)
preview = text[:_MAX_PREVIEW_CHARS]
suffix = "…" if len(text) > _MAX_PREVIEW_CHARS else ""
return f"[Link: {url}]\n{preview}{suffix}"