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
207 lines
9.3 KiB
Python
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}"
|