Trước đây công tắc chỉ chặn tool mạng của agent; lệnh shell chỉ bị proxy giả, còn M365, Teams, nút Test, MCP đang chạy, task script, link đính kèm task, pip tự cài và tài nguyên web trong xem trước HTML vẫn ra mạng tự do. - Cổng chung application/network/network_guard.py, nối vào cấu hình sống ở Composition Root; nhà cung cấp AI (chat, danh sách model, thử model) không đi qua cổng này. - Lệnh shell của agent và task script chạy trong Windows AppContainer không có quyền mạng (macOS: sandbox-exec, Linux: unshare --net); không cô lập được thì từ chối chạy. - Không cấp quyền kế thừa của AppContainer lên thư mục chứa PySide6: Chromium không nạp được Qt6WebEngineCore.dll và tab Graph bị hỏng. - Bật chặn thì dừng MCP đang chạy; tool OneDrive đồng bộ trên máy vẫn dùng. - Mặc định tắt khi mở app lần đầu; nhãn và tooltip 3 ngôn ngữ cập nhật. - Test: tests/test_network_guard_lanes.py (có bài AppContainer thật). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
383 lines
16 KiB
Python
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)
|