Files
cowork-local/core/deps.py
T
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

383 lines
16 KiB
Python

"""Runtime dependency helper.
Tasks and document extraction should never ask the user to install support
libraries by hand — when something is missing we try to ``pip install`` it into
the running interpreter automatically. In a packaged (frozen) build pip isn't
available, so callers must still degrade gracefully if this returns None/False.
"""
from __future__ import annotations
import importlib
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Callable, Dict, List, Optional, Tuple
CancelFn = Callable[[], bool]
_POLL_SECS = 0.2
_FAILED: set[str] = set() # packages we already tried and couldn't install
# 🔒 Sandbox Security Layer — Resource Usage: every subprocess spawned via
# run_cancellable registers its pid here for the duration of the run, so the
# Monitoring Dashboard can show live psutil stats without its own tracking.
_active_pids_lock = threading.Lock()
_ACTIVE_PIDS: set[int] = set()
def active_pids() -> List[int]:
"""Pid của các tiến trình con đang chạy — dùng để dọn sạch khi thoát app."""
with _active_pids_lock:
return sorted(_ACTIVE_PIDS)
# Substrings (lower-cased) in pip's output that mark a TRANSIENT failure (flaky
# network) worth silently retrying, as opposed to a deterministic one (bad
# package name, no matching version, syntax error in a requirement) where
# retrying would just waste time and reproduce the same error.
_TRANSIENT_MARKERS = (
"connection reset", "connection aborted", "connection refused",
"read timed out", "timed out", "temporary failure", "getaddrinfo failed",
"could not fetch url", "network is unreachable", "max retries exceeded",
"remote end closed connection", "econnreset",
)
def _kill_tree(proc: "subprocess.Popen", job_handle: Optional[int] = None) -> None:
"""Kill a subprocess AND any children it spawned (e.g. a shell wrapping the
real command, or a build tool that forks workers) — plain ``proc.kill()``
only kills the direct child and would leave the real work running.
``job_handle`` (Windows only), when the process was successfully assigned
to one at spawn time, is tried FIRST — a Job Object catches reparented/
detached processes that ``taskkill /T``'s PID-tree walk can miss (see
win_job.py). Falls back to ``taskkill /T`` if there's no job handle."""
if sys.platform == "win32" and job_handle:
from .win_job import terminate_job
if terminate_job(job_handle):
try:
proc.kill()
except Exception: # noqa: BLE001
pass
return
try:
if sys.platform == "win32":
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
capture_output=True, timeout=10,
)
else:
import os
import signal
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
pass
except Exception: # noqa: BLE001 - killing must never itself raise
pass
finally:
try:
proc.kill()
except Exception: # noqa: BLE001
pass
def run_cancellable(
args, *, cwd: str | None = None, timeout: float | None = None,
cancel: Optional[CancelFn] = None, shell: bool = False,
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.
``subprocess.run(..., timeout=...)`` blocks the calling thread until the
process exits or the timeout fires — the cooperative cancel flag checked
elsewhere in the agent loop has no chance to run, so Stop appears to do
nothing while a command (or ``pip install``) is executing. This polls
``cancel()`` every ``_POLL_SECS`` instead and kills the whole process tree
the moment the user stops, or the timeout is hit.
``on_output``, if given, is called with each line of stdout/stderr AS IT
ARRIVES (not just at the end) so a caller can stream live progress to the
UI for long-running commands — purely a side channel; the return value is
unaffected.
``limits`` (Sandbox Security Layer — see ``resource_limits.py``), if
given, is a dict of any subset of ``cpu_percent``/``memory_mb``/
``disk_mb``: the process TREE's usage is polled on the same cadence as
cancel/timeout, and the tree is killed the moment a cap is exceeded.
On Windows, the process is additionally assigned to a Job Object at spawn
time (see ``win_job.py``) — a stronger tree-kill than ``taskkill /T``
alone, since it also catches reparented/detached children. Best-effort:
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:
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:
_ACTIVE_PIDS.add(proc.pid)
try:
return _run_cancellable_body(proc, cancel, timeout, on_output, limits)
finally:
with _active_pids_lock:
_ACTIVE_PIDS.discard(proc.pid)
def _run_cancellable_body(
proc: "subprocess.Popen", cancel: CancelFn, timeout: Optional[float],
on_output: Optional[Callable[[str], None]], limits: Optional[Dict[str, float]],
) -> Tuple[Optional[int], str, bool, bool, bool]:
"""Chạy một tiến trình con có thể huỷ giữa chừng, có hạn giờ và có giới hạn tài nguyên.
Trên Windows gắn tiến trình vào một Job Object để khi giết là giết cả cây
tiến trình con — giết mỗi tiến trình cha sẽ để lại đám con mồ côi.
"""
job_handle = None
if sys.platform == "win32":
from .win_job import assign_process, create_job_object
job_handle = create_job_object()
if job_handle is not None:
assign_process(job_handle, proc.pid)
if limits:
from .resource_limits import prime_cpu_counter
prime_cpu_counter(proc.pid)
collected: Dict[str, list] = {"out": [], "err": []}
def _read_stream(stream, key: str) -> None:
"""Đọc một luồng đầu ra theo từng dòng ở luồng riêng.
Phải đọc song song stdout và stderr: đọc lần lượt sẽ kẹt khi tiến trình con
làm đầy bộ đệm của luồng còn lại.
"""
try:
for line in iter(stream.readline, ""):
collected[key].append(line)
if on_output is not None:
try:
on_output(line)
except Exception: # noqa: BLE001 - a UI callback must never kill the tool
pass
except Exception: # noqa: BLE001
pass
finally:
try:
stream.close()
except Exception: # noqa: BLE001
pass
out_thread = threading.Thread(target=_read_stream, args=(proc.stdout, "out"), daemon=True)
err_thread = threading.Thread(target=_read_stream, args=(proc.stderr, "err"), daemon=True)
out_thread.start()
err_thread.start()
start = time.monotonic()
cancelled = timed_out = resource_exceeded = False
resource_reason = ""
while out_thread.is_alive() or err_thread.is_alive():
if cancel():
cancelled = True
_kill_tree(proc, job_handle)
break
if timeout is not None and (time.monotonic() - start) > timeout:
timed_out = True
_kill_tree(proc, job_handle)
break
if limits:
from .resource_limits import check_limits
resource_reason = check_limits(proc.pid, limits) or ""
if resource_reason:
resource_exceeded = True
_kill_tree(proc, job_handle)
break
time.sleep(_POLL_SECS)
out_thread.join(timeout=5)
err_thread.join(timeout=5)
try:
proc.wait(timeout=5) # reap so returncode is populated
except subprocess.TimeoutExpired:
pass
out, err = "".join(collected["out"]), "".join(collected["err"])
combined = out + (("\n[stderr]\n" + err) if err else "")
if resource_exceeded:
combined += f"\n[resource limit] {resource_reason}\n"
return proc.returncode, combined, cancelled, timed_out, resource_exceeded
def network_blocked_env(base_env: Optional[Dict[str, str]] = None) -> Dict[str, str]:
"""Env vars that make well-behaved HTTP clients refuse to reach the
network — proxy vars pointed at a black-hole loopback port nothing
listens on (connection refused instantly, no hang).
This is a POLICY-level control (Sandbox Security Layer — "Network
Control"), not a kernel firewall: it stops the vast majority of scripted
network calls (``requests``/``curl``/``wget``/``npm``/``pip`` all honor
these standard proxy env vars) without requiring admin rights or a
bundled driver — a tool that ignores proxy env vars entirely (rare, but
possible) would still get through. Combine with the Agent Security
command whitelist for defense in depth."""
import os
env = dict(base_env if base_env is not None else os.environ)
blackhole = "http://127.0.0.1:1"
for key in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"):
env[key] = blackhole
env["NO_PROXY"] = ""
env["no_proxy"] = ""
return env
def _can_pip() -> bool:
# A PyInstaller/py2exe build has no usable pip; don't attempt installs there.
"""Bản đóng gói (PyInstaller) không có pip dùng được — đừng thử cài gì ở đó."""
return not getattr(sys, "frozen", False)
def ensure_module(module: str, package: str | None = None):
"""Import ``module``, auto-installing ``package`` (pip) first if needed.
Returns the imported module, or None if it isn't available and can't be
installed (offline, no pip, frozen build, …)."""
try:
return importlib.import_module(module)
except ImportError:
pass
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)
return None
try:
importlib.invalidate_caches()
return importlib.import_module(module)
except ImportError:
_FAILED.add(pkg)
return None
def venv_python_path(venv_dir: Path) -> Path:
"""Đường dẫn tới ``python`` trong một virtualenv, khác nhau giữa Windows và POSIX."""
return venv_dir / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python")
def ensure_project_venv(workdir: Path, cancel: Optional[CancelFn] = None,
on_output: Optional[Callable[[str], None]] = None) -> Optional[Path]:
"""Create (if missing) and return the interpreter of a per-project sandbox
virtual environment at ``<workdir>/.venv`` — so packages the Code agent
installs for one project never leak into another project's runs or into
the app's own environment. Returns None (caller should fall back to the
app's own interpreter) when a venv can't be created (offline, no pip, a
packaged/frozen build, ...) — sandboxing is a nice-to-have, never a hard
requirement for the agent to keep working."""
if not _can_pip():
return None
venv_dir = workdir / ".venv"
py = venv_python_path(venv_dir)
if py.exists():
return py
if on_output is not None:
on_output("[sandbox] creating project virtual environment (.venv)…\n")
returncode, out, cancelled, _, _ = run_cancellable(
[sys.executable, "-m", "venv", str(venv_dir)],
timeout=120, cancel=cancel, on_output=on_output,
)
if returncode == 0 and py.exists():
return py
if on_output is not None:
on_output(f"[sandbox] could not create .venv, using the app's own environment ({out.strip()[-300:]})\n")
return None
def sandbox_env(python_path: Path) -> Dict[str, str]:
"""Env vars that make a subprocess behave as if this venv were activated —
bare ``python``/``pip`` in a shell command then resolve to the sandbox."""
import os
env = dict(os.environ)
bin_dir = str(Path(python_path).parent)
env["PATH"] = bin_dir + os.pathsep + env.get("PATH", "")
env["VIRTUAL_ENV"] = str(Path(python_path).parent.parent)
env.pop("PYTHONHOME", None)
return env
def pip_install(package: str, cancel: Optional[CancelFn] = None,
on_output: Optional[Callable[[str], None]] = None,
python: Optional[str] = None, retries: int = 2) -> tuple[bool, str]:
"""Install a pip package into ``python`` (default: the app's own interpreter).
Returns (ok, output).
Cancellable (see :func:`run_cancellable`) so hitting Stop while a package is
installing actually kills pip instead of blocking until it finishes.
A failure that looks like a flaky network blip (connection reset, timeout,
DNS failure...) is retried automatically up to ``retries`` times with a
short backoff — a deterministic failure (no matching version, bad package
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:
attempt += 1
returncode, out, cancelled, timed_out, _ = run_cancellable(
[exe, "-m", "pip", "install", "--disable-pip-version-check", package],
timeout=600, cancel=cancel, on_output=on_output,
)
if returncode is None:
return False, f"pip failed to run: {out}"
if cancelled:
return False, "Installation cancelled by user."
if timed_out:
return False, "pip install timed out (600s) and was cancelled."
if returncode == 0:
return True, (out.strip()[-4000:] or "(no output)")
transient = any(marker in out.lower() for marker in _TRANSIENT_MARKERS)
if not transient or attempt > retries or (cancel and cancel()):
return False, (out.strip()[-4000:] or "(no output)")
if on_output is not None:
on_output(f"\n[retry] transient network error — retrying ({attempt}/{retries})…\n")
time.sleep(1.5 * attempt)