Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6b68a0bf8 | ||
|
|
0b6b220bd9 | ||
|
|
fefc9f94db | ||
|
|
ddb31f9aff | ||
|
|
b500b3e57d | ||
|
|
76225aa118 | ||
|
|
35334274eb | ||
|
|
10b8379824 | ||
|
|
8c497cf50a | ||
|
|
b78d48320c |
@@ -0,0 +1,82 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project
|
||||
|
||||
Cowork Local (user-facing brand "Cowork-Local BamBOO") is a local-first PySide6 desktop app: multi-turn AI agents, per-project workspaces with GraphRAG, scheduled agent tasks (Kanban), MCP connectors, a sandbox security layer, model routing, and a monitoring dashboard. User config lives in `~/.cowork_local`. The internal name `cowork_local` / `APP_NAME` must not be rebranded. Only `DISPLAY_NAME` is the brand.
|
||||
|
||||
## The package-name quirk (read first)
|
||||
|
||||
The repository root **is** the `cowork_local` package: `__init__.py` and `__main__.py` sit at the root, and code imports itself as `cowork_local.*` or through relative imports. This checkout's folder is not named `cowork_local`, so:
|
||||
|
||||
- **Running the app:** `python -m cowork_local` works only from the parent of a folder literally named `cowork_local`. On Windows, `install.bat` (once; add `--dev` for test deps, `--system` to skip the venv) builds a venv under `%LOCALAPPDATA%\CoworkLocal` and creates a junction `%LOCALAPPDATA%\CoworkLocal\launcher\<key>\cowork_local` pointing at this checkout. After that, use `run.bat`. The MS365 MCP server is spawned as `python -m cowork_local.mcp_servers.ms365_server`, so the junction is needed for subprocesses too.
|
||||
- **Never create a `.venv` inside the repo.** The quality gates walk the whole tree.
|
||||
- **Tests:** the root `conftest.py` and `tests/conftest.py` bind `sys.modules["cowork_local"]` to this checkout, so pytest works whatever the folder is named. Tests use both import styles: top-level (`from providers.base import ...`) and qualified (`from cowork_local.core... import ...`). Characterization tests that spawn `python -c "from cowork_local..."` subprocesses still need a real `cowork_local` directory on `PYTHONPATH`. CI checks out into `cowork_local/` for this reason.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
python -m pip install -r requirements.txt # single requirements file (includes pytest)
|
||||
python -m pytest tests -q # full suite (what CI runs)
|
||||
python -m pytest tests/unit/test_schedule_calculator.py -q # one file
|
||||
python -m pytest tests/unit/test_schedule_calculator.py -k name -q # one test
|
||||
python -m pytest tests/e2e/test_smoke.py -v # release smoke test
|
||||
|
||||
python scripts/run_quality_gate.py # all CASAN gates + pytest
|
||||
python scripts/run_quality_gate.py --skip-tests # static gates only
|
||||
python scripts/check_imports.py # domain/ + application/ must not import PySide6/PyQt/ui/app
|
||||
python scripts/audit_security.py # no plaintext credentials (CI also runs --self-test)
|
||||
python scripts/check_loc.py # <= 400 lines per production file
|
||||
python scripts/check_orphan_modules.py # every production module must be reachable by import
|
||||
```
|
||||
|
||||
Widget tests run headless with `QT_QPA_PLATFORM=offscreen`. `tools/check_*.py` are standalone offscreen smoke checkers against a real `MainWindow` built on a copy of `~/.cowork_local` (for example `python tools/check_nav.py`). Some of them still import private names re-exported from `app.py`, so keep those re-exports. Set `COWORK_PERF_TRACE=1` to log timing spans from `performance.py`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Target design is 4-tier Clean Architecture (`docs/architecture/ADR-001-layered-architecture.md`):
|
||||
|
||||
- `domain/`: pure stdlib entities, frozen request snapshots (`ConversationExecutionRequest`), `AgentEvent`, tool/provider descriptors, `ScheduleCalculator`.
|
||||
- `application/`: pure-Python use-case services (conversations, model_routing, scheduling, workspaces, monitoring, workflows). **No Qt.** Must run headless.
|
||||
- `infrastructure/`: adapters: config (`JsonConfigRepository` over `AtomicJsonFile`), OS-keyring `SecretStore`, providers, MCP (`McpToolSourceManager`), sandbox, filesystem, telemetry, and Qt bridges (`infrastructure/qt`).
|
||||
- `presentation/`: PySide6 widgets by feature (`shell`, `chat`, `co4e`, `dashboard`, `scheduling`, `workspace`, `monitoring`, `graph`, `settings`, ...). Widgets call `application/` services. They don't touch persistence or run LLM calls on the GUI thread. Agent work runs in worker threads and reaches the UI as `AgentEvent`s through Qt signal bridges.
|
||||
|
||||
The refactor is **incomplete**. Legacy top-level packages are still live and imported by the app:
|
||||
- `ui/`: older tabs such as `cowork_tab`, `workspace_tab`, `monitoring_tab`, `settings_dialog`, `chat_panel`, and `co4e_*`.
|
||||
- `core/`: agents, the Co4E flow runner, task scheduler, skills, tools, audit/usage tracking, routing.
|
||||
- `providers/`: `base`, `anthropic`, `openai_compat`, `factory`.
|
||||
- `security/`: validators, command risk classifier.
|
||||
- Root modules: `config.py`, `state.py`.
|
||||
|
||||
`docs/architecture/dormant-code.md` lists deprecated pieces, such as `state.py::active_project_id` and the monolithic `core/tools.py`. New layers must not import dormant code.
|
||||
|
||||
Wiring:
|
||||
- `__main__.py` → `app.run()`.
|
||||
- `presentation/shell/bootstrap.py` is the **Composition Root**. It builds `AppContext` (`state.py`) around `JsonConfigRepository` plus `KeyringAdapter`, falling back to the config file when no keyring exists.
|
||||
- `run()` then seeds the built-in skills (`skill_library/*.skill`) and the Co4E flows, applies the theme, and opens `presentation/shell/main_window.MainWindow`.
|
||||
- Pages are registered in `presentation/shell/page_registry.py`.
|
||||
|
||||
Other cross-cutting pieces:
|
||||
- `i18n/`: `tr(key, **kw)` with en/ja/vi (default `vi`). Long-lived widgets must use `bind_text(...)` or `on_language_changed(...)` so a language switch re-applies their text. Transient dialogs just call `tr()` at construction.
|
||||
- `theme/`: palettes and QSS. Use theme tokens, not hard-coded colors.
|
||||
- `mcp_servers/`: bundled MCP servers (MS365, project_context).
|
||||
- `agent/`: a markdown instruction library for UI/UX bug-fix agents, not runtime code.
|
||||
|
||||
Step-by-step recipes for adding a provider, a tool or MCP server, or a screen are in `docs/governance/contributor-recipes.md`.
|
||||
|
||||
## Rules enforced by gates and review
|
||||
|
||||
- **400-line limit** for every production file. This covers `domain`, `application`, `infrastructure`, `presentation`, `ui`, `core`, `providers`, `security`, `mcp_servers`, `i18n`, `theme`, and root `.py` files. Legacy oversized files have per-file caps in `scripts/check_loc.py` that may only go down. Split files; never raise a cap.
|
||||
- **No orphan modules.** `check_orphan_modules.py` has an `ALLOWLIST` that may only shrink. Wire up or delete a module instead of allowlisting it.
|
||||
- **Secrets** belong in the OS keyring, never in `config.json` or the code. `.env.example` is not auto-loaded. Tests never use live provider credentials (use `tests/fakes/`: `FakeProvider`, `FakeToolExecutor`, `FakeToolPolicyGateway`, `FakeConfigRepository`/`FakeSecretStore`, `FakeClock`, ...).
|
||||
- **Test layout:** `tests/unit` (fast, no I/O), `tests/contracts`, `tests/integration` (Qt offscreen), `tests/characterization` (pinned legacy behavior during refactors), `tests/e2e`, `tests/ui`, plus flat `tests/test_*.py`.
|
||||
- **Startup housekeeping** (seeding, pruning) is wrapped in `try/except` and must never block app launch.
|
||||
- **Comments:** the ADR asks for English comments. Existing code mixes English and Vietnamese docstrings and comments, so match the surrounding file.
|
||||
|
||||
## Git workflow
|
||||
|
||||
- Branches: `feat/`, `fix/`, `test/`, `docs/`, `perf/`, `refactor/<short-desc>`. Core AI work uses `core-ai/<task-id>-<name>`.
|
||||
- Commits use Conventional Commit prefixes (`feat:`, `fix:`, `test:`, `docs:`, `refactor:`, `perf:`, `chore:`).
|
||||
- One logical change per PR, using `.gitea/PULL_REQUEST_TEMPLATE.md`. The remote is a Gitea instance, and CI (`.gitea/workflows/ci.yaml`, Python 3.11) runs on PRs to `main`.
|
||||
- Critical areas listed in `SECURITY.md` get extra review.
|
||||
@@ -72,6 +72,12 @@ def run(argv: List[str] | None = None) -> int:
|
||||
app = QApplication.instance() or QApplication(argv)
|
||||
app.setApplicationName(APP_NAME)
|
||||
app.setWindowIcon(app_icon())
|
||||
try:
|
||||
from .config import CONFIG_DIR
|
||||
from .presentation.shell import crash_guard
|
||||
crash_guard.install(Path(CONFIG_DIR) / "logs")
|
||||
except Exception: # noqa: BLE001 - crash logging must never block startup
|
||||
crash_guard = None
|
||||
# Composition Root: presentation/shell/bootstrap.py quyết định app chạy
|
||||
# bằng mảnh nào. Từ R02, đó là JsonConfigRepository + kho bí mật của hệ
|
||||
# điều hành, không còn config.py::AppConfig.
|
||||
@@ -132,4 +138,10 @@ def run(argv: List[str] | None = None) -> int:
|
||||
pass
|
||||
|
||||
win.show()
|
||||
if crash_guard is not None:
|
||||
try:
|
||||
watchdog = crash_guard.HangWatchdog(DISPLAY_NAME, win)
|
||||
app.aboutToQuit.connect(watchdog.stop)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return app.exec()
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Application services for the "Block network" switch (Sandbox Security Layer)."""
|
||||
|
||||
from .network_guard import NetworkBlockedError, bind, ensure_allowed, is_blocked, refusal
|
||||
|
||||
__all__ = ["NetworkBlockedError", "bind", "ensure_allowed", "is_blocked", "refusal"]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""One gate for every outbound connection the app makes on its own.
|
||||
|
||||
The "Block network" switch (``agent_security.block_network``) used to be read
|
||||
only where agent tools run, so Microsoft 365, Teams, connector test buttons,
|
||||
scheduled task scripts, pip auto-installs and HTML previews still reached the
|
||||
internet while Monitoring said "Network: blocked". Each of those now asks
|
||||
this module first.
|
||||
|
||||
The AI provider path (chat, model list, model test) deliberately does NOT go
|
||||
through here: with the switch on the user still talks to the model, but the
|
||||
app fetches nothing else on the model's or its own behalf.
|
||||
|
||||
The module holds a *reader*, not a copy of the flag: the Composition Root
|
||||
binds it to the live config once (``presentation/shell/bootstrap.py``), so a
|
||||
change saved in Settings takes effect on the very next call. Nothing bound
|
||||
(unit tests, helper subprocesses) means "not blocked", the pre-switch default.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Callable, Optional
|
||||
|
||||
_lock = threading.Lock()
|
||||
_reader: Optional[Callable[[], bool]] = None
|
||||
|
||||
|
||||
class NetworkBlockedError(PermissionError):
|
||||
"""Raised by :func:`ensure_allowed` while the switch is on."""
|
||||
|
||||
|
||||
def bind(reader: Optional[Callable[[], bool]]) -> None:
|
||||
"""Install the callable that says whether the network is blocked right now."""
|
||||
global _reader
|
||||
with _lock:
|
||||
_reader = reader
|
||||
|
||||
|
||||
def is_blocked() -> bool:
|
||||
"""True while "Block network" is on. A failing reader counts as blocked."""
|
||||
reader = _reader
|
||||
if reader is None:
|
||||
return False
|
||||
try:
|
||||
return bool(reader())
|
||||
except Exception: # noqa: BLE001 - fail closed: an unreadable switch must not open the network
|
||||
return True
|
||||
|
||||
|
||||
def refusal(purpose: str) -> str:
|
||||
"""The message shown (to the user or the model) when ``purpose`` is refused."""
|
||||
return (f"{purpose}: network access is blocked by the Sandbox Security Layer "
|
||||
"(\"Block network\" is on in Settings). Only the AI provider may be reached.")
|
||||
|
||||
|
||||
def ensure_allowed(purpose: str) -> None:
|
||||
"""Raise :class:`NetworkBlockedError` if ``purpose`` may not go online now."""
|
||||
if is_blocked():
|
||||
raise NetworkBlockedError(refusal(purpose))
|
||||
|
||||
|
||||
__all__ = ["NetworkBlockedError", "bind", "ensure_allowed", "is_blocked", "refusal"]
|
||||
@@ -21,9 +21,14 @@ def pptx_available() -> bool:
|
||||
try:
|
||||
from cowork_local.core.deps import ensure_module
|
||||
|
||||
_PPTX_READY = ensure_module("pptx", "python-pptx") is not None
|
||||
ready = ensure_module("pptx", "python-pptx") is not None
|
||||
except Exception: # noqa: BLE001
|
||||
_PPTX_READY = False
|
||||
ready = False
|
||||
from ..network import network_guard
|
||||
|
||||
if ready or not network_guard.is_blocked():
|
||||
_PPTX_READY = ready # a refusal under "Block network" is retried later
|
||||
return ready
|
||||
return _PPTX_READY
|
||||
|
||||
|
||||
|
||||
@@ -100,11 +100,11 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
||||
"resource_limit_cpu_percent": 80, # 0 = unlimited; caps a run_command/install_package process TREE's total CPU%
|
||||
"resource_limit_memory_mb": 2048, # 0 = unlimited; caps total RSS memory (MB)
|
||||
"resource_limit_disk_mb": 512, # 0 = unlimited; caps total disk read+write (MB)
|
||||
# Cut the agent off the network: proxy env pointed at a black hole for
|
||||
# agent-run shell commands, PLUS a flat refusal from every tool tagged
|
||||
# ToolCapability.NETWORK (fetch_url, jira_*, install_package) — those
|
||||
# reach the net in-process, where the proxy trick has nothing to act on.
|
||||
"block_network": True,
|
||||
# "Block network": every outbound connection except the AI provider is
|
||||
# refused (application/network/network_guard.py), and agent shell
|
||||
# commands / task scripts run in a network-less AppContainer.
|
||||
# OFF on first launch — the user turns it on in Settings.
|
||||
"block_network": False,
|
||||
# Allow the agent's fetch_url tool to read web pages / online documents /
|
||||
# SharePoint-OneDrive share links. Its own toggle — reading a URL for info
|
||||
# is safe and useful, so this defaults ON — but block_network outranks it:
|
||||
@@ -236,7 +236,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
||||
# file (~/.cowork_local/assessments.json + assessments_history/), not here —
|
||||
# this section is only the behaviour config the user edits.
|
||||
"routing": {
|
||||
"switch_mode": "off", # global default: "off" | "auto" | "manual"
|
||||
"switch_mode": "auto", # global default: "auto" | "manual"
|
||||
"policy": "balanced", # "quality" | "cost" | "latency" | "balanced"
|
||||
"min_score_gain": 0.05, # only switch if the new model beats current by ≥ this
|
||||
"confirm_timeout_sec": 60, # (manual) auto-keep current if the user doesn't confirm in time
|
||||
@@ -246,7 +246,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
||||
"judge_model": "", # fixed cheap judge model ("" → a per-provider default)
|
||||
"candidates": [], # explicit [{provider, model_id, tier}]; empty → discover from providers
|
||||
"auto_reassess_on_add": True, # reassess a newly-added model as soon as it's added
|
||||
# Per-surface Off/Auto/Manual toggle state (the chat-screen toggle). An
|
||||
# Per-surface Auto/Manual toggle state (the chat-screen toggle). An
|
||||
# empty string means "follow the global switch_mode above".
|
||||
"surface_modes": {
|
||||
"cowork": "",
|
||||
|
||||
+1
-1
@@ -527,7 +527,7 @@ def run_cowork(
|
||||
preview = {"kind": "info", "title": name, "text": str(args)}
|
||||
emit({"type": "tool_proposed", "id": tc_id, "name": name, "args": args,
|
||||
"preview": preview})
|
||||
if ctx.block_network:
|
||||
if ctx.block_network and not name.startswith("ms365_local__"):
|
||||
result = {"ok": False, "output": (
|
||||
f"{name}: network access is blocked by the Sandbox Security Layer "
|
||||
'("Block network for agent-run commands" is on in Settings).')}
|
||||
|
||||
+1
-1
@@ -327,7 +327,7 @@ def run_code(
|
||||
else:
|
||||
emit({"type": "tool_start", "id": tc_id, "name": name})
|
||||
if is_extra and extra_executor is not None:
|
||||
if ctx.block_network:
|
||||
if ctx.block_network and not name.startswith("ms365_local__"):
|
||||
result = {"ok": False, "output": (
|
||||
f"{name}: network access is blocked by the Sandbox Security Layer "
|
||||
'("Block network for agent-run commands" is on in Settings).')}
|
||||
|
||||
+24
-5
@@ -91,6 +91,7 @@ def run_cancellable(
|
||||
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.
|
||||
|
||||
@@ -117,17 +118,27 @@ def run_cancellable(
|
||||
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:
|
||||
proc = subprocess.Popen(
|
||||
args, shell=shell, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, bufsize=1, env=env, **popen_kwargs,
|
||||
)
|
||||
except OSError as exc:
|
||||
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:
|
||||
@@ -266,6 +277,10 @@ def ensure_module(module: str, package: str | None = None):
|
||||
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)
|
||||
@@ -339,6 +354,10 @@ def pip_install(package: str, cancel: Optional[CancelFn] = None,
|
||||
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:
|
||||
|
||||
@@ -132,8 +132,11 @@ class RestApiConnector:
|
||||
|
||||
def call(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Gọi API theo tham số model đưa ra, đi qua lớp TLS có ghim chứng chỉ nội bộ."""
|
||||
from ..application.network import network_guard
|
||||
from .tls_trust import request_any_method as tls_request
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return {"ok": False, "output": network_guard.refusal(self.display_name)}
|
||||
method = str(args.get("method", "GET")).upper()
|
||||
path = str(args.get("path", "")).lstrip("/")
|
||||
url = urljoin(self.base_url, path)
|
||||
@@ -164,10 +167,13 @@ class RestApiConnector:
|
||||
|
||||
def test_connection(self) -> Tuple[bool, str]:
|
||||
"""Thử kết nối tới endpoint; trả về (thành công, thông điệp)."""
|
||||
from ..application.network import network_guard
|
||||
from .tls_trust import request as tls_request
|
||||
|
||||
if not self.base_url.strip("/"):
|
||||
return False, "No base URL configured."
|
||||
if network_guard.is_blocked():
|
||||
return False, network_guard.refusal(self.display_name)
|
||||
headers = {}
|
||||
if self.api_key:
|
||||
headers[self.auth_header] = (
|
||||
|
||||
+41
-2
@@ -12,6 +12,7 @@ sort by recency. History can live locally or in a OneDrive folder (resolved by
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
@@ -33,6 +34,45 @@ def new_session_id() -> str:
|
||||
return datetime.now().strftime("%Y%m%d-%H%M%S-%f")[:-3]
|
||||
|
||||
|
||||
#: Độ dài mong muốn của một tiêu đề hội thoại, tính bằng ký tự.
|
||||
TITLE_MAX_CHARS = 60
|
||||
#: Số ký tự được phép vượt ``TITLE_MAX_CHARS`` để viết nốt từ đang bị cắt dở.
|
||||
#: Cỡ một từ tiếng Việt — đủ để cứu chữ cuối, không đủ để kéo dài tiêu đề.
|
||||
_TITLE_SLACK = 12
|
||||
|
||||
_KHOANG_TRANG = re.compile(r"\s")
|
||||
|
||||
|
||||
def shorten_title(text: str, limit: int = TITLE_MAX_CHARS) -> str:
|
||||
"""Rút gọn tiêu đề mà KHÔNG cắt vào giữa một từ.
|
||||
|
||||
Cắt cứng ở ký tự thứ ``limit`` đọc rất khó chịu khi mốc đó rơi vào giữa từ:
|
||||
"…tóm tắt từng tệp" thành "…tóm tắt từng tệ…" — trông như lỗi gõ chứ không
|
||||
như một câu bị rút gọn. Nên khi mốc cắt rơi vào giữa từ thì viết nốt từ đó.
|
||||
|
||||
Ba lối ra, theo thứ tự ưu tiên:
|
||||
|
||||
* Viết nốt từ đang dở, nếu chỉ phải vượt thêm tối đa ``_TITLE_SLACK`` ký tự.
|
||||
Viết nốt mà vừa hết chuỗi thì **không** thêm dấu ba chấm — không còn chữ
|
||||
nào bị bỏ thì dấu ba chấm là nói dối.
|
||||
* Từ dài bất thường (đường dẫn, URL) thì lùi về ranh giới từ ngay trước mốc,
|
||||
để một token dài không kéo tiêu đề dài ra tuỳ ý.
|
||||
* Cả tiêu đề chỉ là một từ dài thì đành cắt cứng — không còn ranh giới nào.
|
||||
"""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
if text[limit].isspace(): # mốc cắt vốn đã nằm giữa hai từ
|
||||
return text[:limit].rstrip() + "…"
|
||||
sau = _KHOANG_TRANG.search(text, limit)
|
||||
het_tu = sau.start() if sau is not None else len(text)
|
||||
if het_tu - limit <= _TITLE_SLACK:
|
||||
return text if het_tu == len(text) else text[:het_tu] + "…"
|
||||
truoc = [m.start() for m in _KHOANG_TRANG.finditer(text, 0, limit)]
|
||||
if truoc:
|
||||
return text[:truoc[-1]] + "…"
|
||||
return text[:limit] + "…"
|
||||
|
||||
|
||||
def derive_title(messages: List[Dict[str, Any]]) -> str:
|
||||
"""Suy tiêu đề hội thoại từ tin nhắn đầu tiên của người dùng.
|
||||
|
||||
@@ -40,8 +80,7 @@ def derive_title(messages: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
for m in messages:
|
||||
if m.get("role") == "user" and m.get("content"):
|
||||
text = " ".join(m["content"].split())
|
||||
return text[:60] + ("…" if len(text) > 60 else "")
|
||||
return shorten_title(" ".join(m["content"].split()))
|
||||
return "(empty)"
|
||||
|
||||
|
||||
|
||||
@@ -85,8 +85,10 @@ def get_issue_by_url(config: Dict[str, Any] | None, url: str) -> str:
|
||||
|
||||
def _get(config: Dict[str, Any], path: str, params: dict = None):
|
||||
"""Gọi Jira REST API bằng xác thực cơ bản, qua lớp TLS có ghim chứng chỉ nội bộ."""
|
||||
from ..application.network import network_guard
|
||||
from . import tls_trust
|
||||
|
||||
network_guard.ensure_allowed("Jira")
|
||||
c = _conf(config)
|
||||
url = c["base_url"].rstrip("/") + path
|
||||
# Same TLS auto-recovery the LLM provider calls get (core/tls_trust.py) —
|
||||
|
||||
@@ -147,6 +147,12 @@ def fetch_link_preview(url: str) -> str:
|
||||
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.
|
||||
|
||||
+12
-1
@@ -88,7 +88,14 @@ class McpServerConnection:
|
||||
def start(self, timeout: float = 15.0) -> None:
|
||||
"""Spawn the server subprocess and complete the MCP handshake.
|
||||
Raises :class:`McpServerError` on failure (bad command, the server
|
||||
crashed on startup, the handshake timed out, ...)."""
|
||||
crashed on startup, the handshake timed out, ...).
|
||||
|
||||
Refused while "Block network" is on: a server process is free to open
|
||||
any socket it likes, so the only safe server is one never started."""
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
raise McpServerError(network_guard.refusal(f"MCP server '{self.name}'"))
|
||||
self._thread = threading.Thread(target=self._run_loop, daemon=True)
|
||||
self._thread.start()
|
||||
if not self._ready.wait(timeout):
|
||||
@@ -174,6 +181,10 @@ class McpServerConnection:
|
||||
|
||||
def call_tool(self, qualified_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""``extra_executor``-shaped result: ``{"ok": bool, "output": str}``."""
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return {"ok": False, "output": network_guard.refusal(f"MCP server '{self.name}'")}
|
||||
tool_name = qualified_name.split(_SEP, 1)[1] if _SEP in qualified_name else qualified_name
|
||||
try:
|
||||
result = self._run_coro(self._session.call_tool(tool_name, args or {}))
|
||||
|
||||
@@ -137,8 +137,34 @@ def _app(tenant_id: str, client_id: str):
|
||||
return app, cache
|
||||
|
||||
|
||||
def _cached_account_offline() -> Optional[dict]:
|
||||
"""First account in the saved token cache, read without any MSAL network setup."""
|
||||
try:
|
||||
import msal
|
||||
|
||||
accounts = _load_cache().find(msal.TokenCache.CredentialType.ACCOUNT)
|
||||
except Exception: # noqa: BLE001 - no msal / unreadable cache = not signed in
|
||||
return None
|
||||
return accounts[0] if accounts else None
|
||||
|
||||
|
||||
def _ensure_network(action: str) -> None:
|
||||
"""Turn a "Block network" refusal into the error type callers already handle."""
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
raise Ms365AuthError(network_guard.refusal(action))
|
||||
|
||||
|
||||
def signed_in_account(tenant_id: str, client_id: str) -> Optional[dict]:
|
||||
"""The cached account, if any — a local cache lookup, no network call."""
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
# Building the MSAL app fetches the tenant's OpenID configuration, so
|
||||
# read the token cache directly instead: the UI still sees who is
|
||||
# signed in without the app reaching login.microsoftonline.com.
|
||||
return _cached_account_offline()
|
||||
try:
|
||||
app, _cache = _app(tenant_id, client_id)
|
||||
except Ms365AuthError:
|
||||
@@ -156,6 +182,7 @@ def sign_in_device_code(tenant_id: str, client_id: str, on_code: Callable[[dict]
|
||||
``verification_uri_complete`` (URL with the code pre-filled, when the tenant
|
||||
returns it) and ``message`` (the full human-readable instruction). Returns
|
||||
the MSAL token result dict; raises Ms365AuthError on failure/timeout."""
|
||||
_ensure_network("Microsoft 365 sign-in")
|
||||
app, cache = _app(tenant_id, client_id)
|
||||
flow = app.initiate_device_flow(scopes=SCOPES)
|
||||
if "user_code" not in flow:
|
||||
@@ -183,6 +210,7 @@ def get_access_token(tenant_id: str, client_id: str) -> str:
|
||||
"""Silently reuse the cached sign-in. Raises Ms365AuthError when there is
|
||||
no valid session — the caller (a Graph call) should surface that as a
|
||||
normal tool failure telling the user to sign in again from Settings."""
|
||||
_ensure_network("Microsoft 365")
|
||||
app, cache = _app(tenant_id, client_id)
|
||||
accounts = app.get_accounts()
|
||||
if not accounts:
|
||||
@@ -228,6 +256,7 @@ def sign_out_default(config=None) -> None:
|
||||
def sign_out(tenant_id: str, client_id: str) -> None:
|
||||
"""Đăng xuất và xoá token của một tenant/client khỏi kho."""
|
||||
try:
|
||||
_ensure_network("Microsoft 365 sign-out") # chặn mạng: chỉ xoá kho token bên dưới
|
||||
app, cache = _app(tenant_id, client_id)
|
||||
for acc in app.get_accounts():
|
||||
app.remove_account(acc)
|
||||
|
||||
@@ -43,6 +43,10 @@ def _request(method: str, url: str, token: str, **kwargs) -> requests.Response:
|
||||
"""Gọi Graph API, tự ghép ``GRAPH_BASE`` cho đường dẫn tương đối và đổi lỗi HTTP
|
||||
thành :class:`Ms365GraphError` kèm thông điệp đọc được.
|
||||
"""
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
raise Ms365GraphError(network_guard.refusal("Microsoft 365 (Graph)"))
|
||||
if not url.startswith("http"):
|
||||
url = f"{GRAPH_BASE}{url}"
|
||||
headers = _headers(token, kwargs.pop("headers", None))
|
||||
|
||||
@@ -75,26 +75,16 @@ class RoutingScheduler(QObject):
|
||||
return None
|
||||
|
||||
def _routing_enabled_anywhere(self) -> bool:
|
||||
"""Is routing actually in use? True if the global mode is auto/manual OR
|
||||
any chat surface overrides to auto/manual. When everything is Off, the
|
||||
assessment scores would never be consulted — so we don't spend tokens
|
||||
probing for them (no surprise cost on a fresh install)."""
|
||||
try:
|
||||
routing = self.ctx.config.routing
|
||||
if (routing.get("switch_mode") or "off") in ("auto", "manual"):
|
||||
return True
|
||||
for m in (routing.get("surface_modes") or {}).values():
|
||||
if m in ("auto", "manual"):
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return False
|
||||
"""Is routing actually in use? Always, now: the Off mode was removed and
|
||||
every stored value resolves to Auto or Manual (see
|
||||
``config.user_routing_mode``). Paid probing is switched off through
|
||||
``reassess_interval_hours = 0`` instead."""
|
||||
return True
|
||||
|
||||
def is_due(self) -> bool:
|
||||
"""Đã đến lúc chấm điểm lại chưa.
|
||||
|
||||
Tắt định tuyến ở mọi bề mặt thì KHÔNG dò — dò model là lượt gọi có tính phí,
|
||||
không được tiêu tiền cho một tính năng người dùng đã tắt.
|
||||
Dò model là lượt gọi có tính phí: đặt chu kỳ chấm lại = 0 thì không dò.
|
||||
"""
|
||||
if not self._routing_enabled_anywhere():
|
||||
return False # routing off everywhere → don't probe (would be wasted cost)
|
||||
|
||||
+48
-4
@@ -218,8 +218,13 @@ class SandboxManager:
|
||||
timeout_sec: int,
|
||||
cancel: Optional[Callable[[], bool]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Dispatch execution to the selected backend."""
|
||||
if backend == "direct":
|
||||
"""Dispatch execution to the selected backend.
|
||||
|
||||
With the network blocked every backend is replaced by the same OS-level
|
||||
isolation: the backends below only ever set proxy env vars, which
|
||||
anything that ignores proxies (raw sockets, ping, .NET WebClient...)
|
||||
walked straight past."""
|
||||
if block_network or backend == "direct":
|
||||
return self._run_direct(command, workdir, block_network, timeout_sec, cancel)
|
||||
|
||||
if backend == "integrity_job_wfp":
|
||||
@@ -274,7 +279,8 @@ class SandboxManager:
|
||||
env = os.environ.copy()
|
||||
if block_network:
|
||||
from .deps import network_blocked_env
|
||||
env = network_blocked_env(env)
|
||||
env = network_blocked_env(env) # belt and braces on top of the OS block
|
||||
return self._run_network_isolated(command, workdir, env, timeout_sec, cancel)
|
||||
|
||||
if cancel is not None:
|
||||
from .deps import run_cancellable
|
||||
@@ -334,4 +340,42 @@ class SandboxManager:
|
||||
"stderr": str(exc),
|
||||
"returncode": -1,
|
||||
"sandbox": "direct",
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _run_network_isolated(
|
||||
command: str,
|
||||
workdir: str,
|
||||
env: Dict[str, str],
|
||||
timeout_sec: int,
|
||||
cancel: Optional[Callable[[], bool]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run ``command`` in a process the OS keeps off the network.
|
||||
|
||||
Fail-closed: when the isolation cannot be set up the command is
|
||||
refused (``sandbox == "blocked"``), never run with the network open."""
|
||||
from .deps import run_cancellable
|
||||
|
||||
rc, output, cancelled, timed_out, exceeded = run_cancellable(
|
||||
command, cwd=workdir or None, timeout=timeout_sec, cancel=cancel,
|
||||
shell=True, env=env, isolate_network=True,
|
||||
)
|
||||
if rc is None and not (cancelled or timed_out or exceeded):
|
||||
return {"ok": False, "stdout": "", "returncode": -1, "sandbox": "blocked",
|
||||
"stderr": ("Command refused: network is blocked and the command could "
|
||||
f"not be isolated from the network ({output.strip()}).")}
|
||||
if cancelled:
|
||||
stderr = "Cancelled by user."
|
||||
elif timed_out:
|
||||
stderr = f"Timeout after {timeout_sec}s"
|
||||
elif exceeded:
|
||||
stderr = "Resource limit exceeded."
|
||||
else:
|
||||
stderr = ""
|
||||
return {
|
||||
"ok": rc == 0 and not (cancelled or timed_out or exceeded),
|
||||
"stdout": output,
|
||||
"stderr": stderr,
|
||||
"returncode": rc if rc is not None else -1,
|
||||
"sandbox": "network_isolated",
|
||||
}
|
||||
|
||||
+1
-13
@@ -19,7 +19,6 @@ in Waiting Input), so executors here run with an auto gate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
@@ -30,6 +29,7 @@ from . import agent_roles
|
||||
from . import agent_security
|
||||
from . import projects
|
||||
from .permissions import PermissionGate
|
||||
from .task_script import run_script as _run_script
|
||||
from .tasks import ARTIFACTS_DIR, resolve_input_text
|
||||
from .tools import ToolContext
|
||||
|
||||
@@ -358,18 +358,6 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
|
||||
return _last_assistant_text(messages), timed_out(), incomplete
|
||||
|
||||
|
||||
def _run_script(command: str, out_dir: Path, timeout_sec: int) -> str:
|
||||
"""Chạy một task kiểu script bằng shell trong thư mục kết quả, có hạn giờ."""
|
||||
if not command.strip():
|
||||
raise RuntimeError("Script task has no command configured.")
|
||||
proc = subprocess.run(command, shell=True, cwd=str(out_dir),
|
||||
capture_output=True, text=True, timeout=max(1, timeout_sec))
|
||||
output = (proc.stdout or "") + (("\n[stderr]\n" + proc.stderr) if proc.stderr else "")
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"Script exited with code {proc.returncode}:\n{output[-2000:]}")
|
||||
return output
|
||||
|
||||
|
||||
def execute_task(ctx, task: Dict[str, Any], run_id: str,
|
||||
emit: Optional[EmitFn] = None, cancel: Optional[CancelFn] = None,
|
||||
tasks_dir: Path = None) -> Dict[str, Any]:
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Run a scheduled task of type ``script`` (tách khỏi ``task_executors.py``).
|
||||
|
||||
Khi công tắc "Chặn mạng" đang bật, lệnh của task chạy trong tiến trình bị hệ
|
||||
điều hành cắt mạng — giống ``run_command`` của agent. Trước đây task script
|
||||
chạy thẳng bằng ``subprocess.run``, không sandbox, nên lên mạng tự do.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_script(command: str, out_dir: Path, timeout_sec: int) -> str:
|
||||
"""Chạy một task kiểu script bằng shell trong thư mục kết quả, có hạn giờ."""
|
||||
if not command.strip():
|
||||
raise RuntimeError("Script task has no command configured.")
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return _run_script_without_network(command, out_dir, timeout_sec)
|
||||
proc = subprocess.run(command, shell=True, cwd=str(out_dir),
|
||||
capture_output=True, text=True, timeout=max(1, timeout_sec))
|
||||
output = (proc.stdout or "") + (("\n[stderr]\n" + proc.stderr) if proc.stderr else "")
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"Script exited with code {proc.returncode}:\n{output[-2000:]}")
|
||||
return output
|
||||
|
||||
|
||||
def _run_script_without_network(command: str, out_dir: Path, timeout_sec: int) -> str:
|
||||
"""Như :func:`run_script`, nhưng tiến trình không có mạng; không cô lập được thì không chạy."""
|
||||
from .deps import network_blocked_env, run_cancellable
|
||||
|
||||
rc, output, _cancelled, timed_out, _exceeded = run_cancellable(
|
||||
command, cwd=str(out_dir), timeout=max(1, timeout_sec), shell=True,
|
||||
env=network_blocked_env(), isolate_network=True,
|
||||
)
|
||||
if timed_out:
|
||||
raise subprocess.TimeoutExpired(command, timeout_sec)
|
||||
if rc is None:
|
||||
raise RuntimeError("Script not run: network is blocked and the script could not be "
|
||||
f"isolated from the network ({output.strip()}).")
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Script exited with code {rc} (network blocked):\n{output[-2000:]}")
|
||||
return output
|
||||
|
||||
|
||||
__all__ = ["run_script"]
|
||||
@@ -43,6 +43,10 @@ class TeamsNotifier:
|
||||
"""Post a notification. Returns ``(ok, detail)``."""
|
||||
if not self.configured:
|
||||
return False, "Teams webhook URL is not configured."
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return False, network_guard.refusal("Teams notification")
|
||||
|
||||
# Workflows webhooks expect an Adaptive Card; classic connectors expect a
|
||||
# MessageCard. Try both, then a plain-text fallback.
|
||||
|
||||
@@ -114,6 +114,8 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"chatpanel.working": {"en": "{name}: working…", "ja": "{name}: 処理中…", "vi": "{name}: đang xử lý…"},
|
||||
"chatpanel.done": {"en": "{name}: done.", "ja": "{name}: 完了。", "vi": "{name}: xong."},
|
||||
"chatpanel.failed": {"en": "{name}: error.", "ja": "{name}: エラー。", "vi": "{name}: lỗi."},
|
||||
"chatpanel.stopped": {"en": "{name}: stopped.", "ja": "{name}: 停止しました。",
|
||||
"vi": "{name}: đã dừng."},
|
||||
"chatpanel.stopping": {"en": "{name}: stopping…", "ja": "{name}: 停止中…", "vi": "{name}: đang dừng…"},
|
||||
"chatpanel.attach_limit": {
|
||||
"en": "Max {n} attachments — extra files were skipped.",
|
||||
|
||||
@@ -155,6 +155,12 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"app.lang.switching": {
|
||||
"en": "Switching language…", "ja": "言語を切り替えています…",
|
||||
"vi": "Đang đổi ngôn ngữ…"},
|
||||
# Popup do luồng canh treo mở khi giao diện đứng quá vài giây
|
||||
# (presentation/shell/crash_guard.py).
|
||||
"app.hang.message": {
|
||||
"en": "Processing, please wait…\n\nThis window closes by itself once the app responds again.",
|
||||
"ja": "処理中です。しばらくお待ちください…\n\nアプリが応答を再開すると、このウィンドウは自動的に閉じます。",
|
||||
"vi": "Đang xử lý, vui lòng đợi…\n\nCửa sổ này tự đóng khi app phản hồi lại."},
|
||||
"app.tab.dashboard": {"en": "Dashboard", "ja": "ダッシュボード", "vi": "Dashboard"},
|
||||
"app.tab.schedule": {"en": "Schedule Task", "ja": "タスクスケジュール", "vi": "Schedule Task"},
|
||||
"app.tab.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
|
||||
|
||||
+17
-11
@@ -19,13 +19,13 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"vi": "Cho phép agent lấy dữ liệu từ URL (trang web, link SharePoint / OneDrive)"},
|
||||
"settings.allow_url_fetch_tooltip": {
|
||||
"en": ("Lets the agent's fetch_url tool read web pages, online documents and "
|
||||
"SharePoint/OneDrive share links to search & process them. Separate from "
|
||||
"'Block network' (which only sandboxes shell commands). Default: on."),
|
||||
"SharePoint/OneDrive share links to search & process them. 'Block network' "
|
||||
"overrides this switch. Default: on."),
|
||||
"ja": "エージェントのfetch_urlツールがWebページ・オンライン文書・SharePoint/OneDrive共有リンクを"
|
||||
"読み取れるようにします。「ネットワークをブロック」(シェルコマンド用)とは別です。既定: オン。",
|
||||
"読み取れるようにします。「ネットワークをブロック」がオンの場合はそちらが優先されます。既定: オン。",
|
||||
"vi": ("Cho phép tool fetch_url của agent đọc trang web, tài liệu online và link chia sẻ "
|
||||
"SharePoint/OneDrive để tìm kiếm & xử lý. Tách biệt với 'Chặn mạng' (chỉ áp cho lệnh "
|
||||
"shell). Mặc định: bật.")},
|
||||
"SharePoint/OneDrive để tìm kiếm & xử lý. 'Chặn mạng' được ưu tiên hơn công tắc "
|
||||
"này. Mặc định: bật.")},
|
||||
"settings.test_internet": {
|
||||
"en": "Test Internet", "ja": "インターネット接続テスト", "vi": "Kiểm tra Internet"},
|
||||
"settings.test_internet_tooltip": {
|
||||
@@ -39,12 +39,18 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"en": "Testing internet access…", "ja": "インターネット接続をテスト中…",
|
||||
"vi": "Đang kiểm tra truy cập internet…"},
|
||||
"settings.sandbox_block_network_tooltip": {
|
||||
"en": ("Policy-level control (proxy env vars point at a black hole) — not a kernel "
|
||||
"firewall. Combine with the command whitelist above for defense in depth."),
|
||||
"ja": "ポリシーレベルの制御です(プロキシ環境変数をブラックホールに向ける)— カーネルレベルの"
|
||||
"ファイアウォールではありません。上のコマンドホワイトリストと併用してください。",
|
||||
"vi": "Kiểm soát ở tầng chính sách (trỏ biến môi trường proxy vào hố đen) — không phải "
|
||||
"firewall tầng kernel. Kết hợp với whitelist lệnh ở trên để phòng thủ nhiều lớp."},
|
||||
"en": ("Only the AI provider (chat, model list, model test) may reach the network. "
|
||||
"Agent shell commands and task scripts run in a Windows AppContainer with no "
|
||||
"network access; fetch_url, Jira, connectors/MCP, Microsoft 365, Teams, "
|
||||
"connector tests, pip auto-install and remote content in HTML previews are refused."),
|
||||
"ja": "ネットワークに接続できるのはAIプロバイダー(チャット・モデル一覧・モデルテスト)のみです。"
|
||||
"エージェントのシェルコマンドとタスクスクリプトはネットワークなしのWindows AppContainerで実行され、"
|
||||
"fetch_url・Jira・コネクタ/MCP・Microsoft 365・Teams・接続テスト・pip自動インストール・"
|
||||
"HTMLプレビューの外部リソースは拒否されます。",
|
||||
"vi": "Chỉ nhà cung cấp AI (chat, tải danh sách model, thử model) được ra mạng. Lệnh shell "
|
||||
"của agent và task script chạy trong Windows AppContainer không có mạng; fetch_url, "
|
||||
"Jira, connector/MCP, Microsoft 365, Teams, nút Test, tự cài thư viện và tài nguyên "
|
||||
"web trong xem trước HTML đều bị từ chối."},
|
||||
"settings.sandbox_unlimited": {"en": "Unlimited", "ja": "無制限", "vi": "Không giới hạn"},
|
||||
"settings.sandbox_cpu_label": {"en": "CPU limit", "ja": "CPU 制限", "vi": "Giới hạn CPU"},
|
||||
"settings.sandbox_memory_label": {"en": "Memory limit", "ja": "メモリ制限", "vi": "Giới hạn bộ nhớ"},
|
||||
|
||||
@@ -229,6 +229,11 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"chat.open_folder": {"en": "Open folder", "ja": "フォルダを開く", "vi": "Mở thư mục"},
|
||||
"chat.open_output_folder": {"en": "Open output folder", "ja": "出力フォルダを開く", "vi": "Mở thư mục output"},
|
||||
"chat.done_marker": {"en": "Done", "ja": "完了しました", "vi": "Đã hoàn thành"},
|
||||
"chat.stopping": {"en": "Stopping", "ja": "停止中", "vi": "Đang dừng"},
|
||||
"chat.stopped_marker": {
|
||||
"en": "⏹ Stopped at your request",
|
||||
"ja": "⏹ リクエストにより停止しました",
|
||||
"vi": "⏹ Đã dừng theo yêu cầu"},
|
||||
"chat.session_folder_marker": {
|
||||
"en": "This conversation's output folder", "ja": "この会話の出力フォルダ",
|
||||
"vi": "Thư mục output của hội thoại này"},
|
||||
|
||||
@@ -258,6 +258,14 @@ class JsonConfigRepository(ConfigSectionsMixin):
|
||||
#: là bản chính; ``tests/test_config_repository.py`` có bài đối chiếu với
|
||||
#: ``config.py`` để hai bên lệch nhau là đỏ ngay.
|
||||
ROUTING_MODES = ("off", "auto", "manual", "fallback")
|
||||
#: Chế độ người dùng còn chọn được. "off"/"fallback" đã bỏ khỏi giao diện;
|
||||
#: giá trị cũ còn lưu trong config/project (hoặc giá trị lạ) đều hiểu là "auto".
|
||||
USER_ROUTING_MODES = ("auto", "manual")
|
||||
|
||||
@classmethod
|
||||
def user_routing_mode(cls, mode: str) -> str:
|
||||
"""Quy một giá trị đã lưu về chế độ người dùng chọn được (mặc định "auto")."""
|
||||
return mode if mode in cls.USER_ROUTING_MODES else "auto"
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path | None = None, *, secrets: SecretStore | None = None):
|
||||
@@ -313,16 +321,14 @@ class JsonConfigRepository(ConfigSectionsMixin):
|
||||
"""Chế độ có hiệu lực cho một bề mặt chat.
|
||||
|
||||
Đặt riêng cho bề mặt thì thắng; để trống thì lấy ``switch_mode`` chung.
|
||||
Giá trị lạ rơi về "off" — định tuyến luôn là thứ phải bật, kể cả khi
|
||||
có người sửa tay file cấu hình."""
|
||||
Chỉ còn Auto/Manual: "off", "fallback" cũ hay giá trị lạ đều hiểu là "auto"."""
|
||||
routing = self.routing
|
||||
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
|
||||
mode = override or routing.get("switch_mode", "off")
|
||||
return mode if mode in self.ROUTING_MODES else "off"
|
||||
return self.user_routing_mode(override or routing.get("switch_mode", ""))
|
||||
|
||||
def set_routing_mode_for(self, surface: str, mode: str) -> None:
|
||||
"""Đặt chế độ định tuyến riêng cho một bề mặt chat, ghi đĩa ngay."""
|
||||
mode = mode if mode in self.ROUTING_MODES else "off"
|
||||
mode = self.user_routing_mode(mode)
|
||||
self.routing.setdefault("surface_modes", {})[surface] = mode
|
||||
self.save()
|
||||
|
||||
|
||||
@@ -76,11 +76,11 @@ def run_command(ctx: ToolContext, args: Dict[str, Any],
|
||||
denial = "Command blocked by security policy: " + "; ".join(risk.reasons)
|
||||
return {"ok": False, "output": denial}
|
||||
|
||||
# Every sandbox backend's network block is a proxy-env-var trick (see
|
||||
# core/deps.py::network_blocked_env) — it does nothing against a tool
|
||||
# that reaches the network without an HTTP proxy (ping/ICMP, nslookup/
|
||||
# direct DNS, ssh/ftp/raw TCP...). Deny those BY NAME here instead, so
|
||||
# "Chặn mạng cho lệnh do agent chạy" actually blocks them too.
|
||||
# With the network blocked, SandboxManager runs the command in an OS-level
|
||||
# network-less process (AppContainer on Windows — see
|
||||
# infrastructure/sandbox/network_isolation.py). Tools that exist only to
|
||||
# reach the network (ping, nslookup, ssh...) are still denied BY NAME
|
||||
# first: the model gets a clear reason instead of a cryptic socket error.
|
||||
if ctx.block_network:
|
||||
bypass_tool = command_bypasses_network_proxy(command)
|
||||
if bypass_tool:
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
"""Spawn a shell command inside a Windows AppContainer with NO network capability.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
The old "block network" for agent shell commands only pointed the proxy env
|
||||
vars at a dead port (``core/deps.py::network_blocked_env``). Anything that
|
||||
ignores proxies (``Invoke-WebRequest -NoProxy``, raw sockets, ``certutil``,
|
||||
.NET ``WebClient``...) still reached the internet. An AppContainer token that
|
||||
is granted no ``internetClient``/``privateNetworkClientServer`` capability is
|
||||
refused by the kernel firewall for every outbound connection, loopback
|
||||
included, no matter which tool makes it. No admin rights are needed.
|
||||
|
||||
An AppContainer can only open files whose ACL admits its SID (or ALL
|
||||
APPLICATION PACKAGES). System32 and Program Files already do; the workdir and
|
||||
the app's own Python install do not, so :func:`spawn` grants the profile SID
|
||||
access to those folders first (an extra ACE, nothing is removed).
|
||||
|
||||
:class:`AppContainerProcess` quacks like ``subprocess.Popen`` for the subset
|
||||
``core/deps.py::_run_cancellable_body`` uses (``pid``, ``stdout``/``stderr``
|
||||
text streams, ``poll``/``wait``/``kill``/``returncode``), so the Stop button,
|
||||
timeouts, Job Objects and resource limits keep working unchanged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import locale
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from typing import Dict, Iterable, Optional
|
||||
|
||||
PROFILE_NAME = "cowork_local.agent_netblock"
|
||||
_IS_WINDOWS = sys.platform == "win32"
|
||||
|
||||
if _IS_WINDOWS:
|
||||
import ctypes
|
||||
import msvcrt
|
||||
from ctypes import wintypes
|
||||
|
||||
_k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
_adv = ctypes.WinDLL("advapi32", use_last_error=True)
|
||||
_uenv = ctypes.WinDLL("userenv", use_last_error=True)
|
||||
|
||||
class _SECURITY_CAPABILITIES(ctypes.Structure):
|
||||
_fields_ = [("AppContainerSid", ctypes.c_void_p), ("Capabilities", ctypes.c_void_p),
|
||||
("CapabilityCount", wintypes.DWORD), ("Reserved", wintypes.DWORD)]
|
||||
|
||||
class _STARTUPINFOW(ctypes.Structure):
|
||||
_fields_ = [("cb", wintypes.DWORD), ("lpReserved", wintypes.LPWSTR),
|
||||
("lpDesktop", wintypes.LPWSTR), ("lpTitle", wintypes.LPWSTR),
|
||||
("dwX", wintypes.DWORD), ("dwY", wintypes.DWORD),
|
||||
("dwXSize", wintypes.DWORD), ("dwYSize", wintypes.DWORD),
|
||||
("dwXCountChars", wintypes.DWORD), ("dwYCountChars", wintypes.DWORD),
|
||||
("dwFillAttribute", wintypes.DWORD), ("dwFlags", wintypes.DWORD),
|
||||
("wShowWindow", wintypes.WORD), ("cbReserved2", wintypes.WORD),
|
||||
("lpReserved2", ctypes.c_void_p), ("hStdInput", wintypes.HANDLE),
|
||||
("hStdOutput", wintypes.HANDLE), ("hStdError", wintypes.HANDLE)]
|
||||
|
||||
class _STARTUPINFOEXW(ctypes.Structure):
|
||||
_fields_ = [("StartupInfo", _STARTUPINFOW), ("lpAttributeList", ctypes.c_void_p)]
|
||||
|
||||
class _PROCESS_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [("hProcess", wintypes.HANDLE), ("hThread", wintypes.HANDLE),
|
||||
("dwProcessId", wintypes.DWORD), ("dwThreadId", wintypes.DWORD)]
|
||||
|
||||
class _SECURITY_ATTRIBUTES(ctypes.Structure):
|
||||
_fields_ = [("nLength", wintypes.DWORD), ("lpSecurityDescriptor", ctypes.c_void_p),
|
||||
("bInheritHandle", wintypes.BOOL)]
|
||||
|
||||
_uenv.CreateAppContainerProfile.restype = ctypes.c_long
|
||||
_uenv.CreateAppContainerProfile.argtypes = [
|
||||
wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.LPCWSTR, ctypes.c_void_p,
|
||||
wintypes.DWORD, ctypes.POINTER(ctypes.c_void_p)]
|
||||
_uenv.DeriveAppContainerSidFromAppContainerName.restype = ctypes.c_long
|
||||
_uenv.DeriveAppContainerSidFromAppContainerName.argtypes = [
|
||||
wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_void_p)]
|
||||
_uenv.GetAppContainerFolderPath.restype = ctypes.c_long
|
||||
_uenv.GetAppContainerFolderPath.argtypes = [
|
||||
wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_wchar_p)]
|
||||
_adv.ConvertSidToStringSidW.restype = wintypes.BOOL
|
||||
_adv.ConvertSidToStringSidW.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_wchar_p)]
|
||||
_k32.InitializeProcThreadAttributeList.restype = wintypes.BOOL
|
||||
_k32.InitializeProcThreadAttributeList.argtypes = [
|
||||
ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(ctypes.c_size_t)]
|
||||
_k32.UpdateProcThreadAttribute.restype = wintypes.BOOL
|
||||
_k32.UpdateProcThreadAttribute.argtypes = [
|
||||
ctypes.c_void_p, wintypes.DWORD, ctypes.c_size_t, ctypes.c_void_p,
|
||||
ctypes.c_size_t, ctypes.c_void_p, ctypes.c_void_p]
|
||||
_k32.DeleteProcThreadAttributeList.argtypes = [ctypes.c_void_p]
|
||||
_k32.CreatePipe.restype = wintypes.BOOL
|
||||
_k32.CreatePipe.argtypes = [ctypes.POINTER(wintypes.HANDLE), ctypes.POINTER(wintypes.HANDLE),
|
||||
ctypes.POINTER(_SECURITY_ATTRIBUTES), wintypes.DWORD]
|
||||
_k32.SetHandleInformation.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD]
|
||||
_k32.CreateProcessW.restype = wintypes.BOOL
|
||||
_k32.CreateProcessW.argtypes = [
|
||||
wintypes.LPCWSTR, wintypes.LPWSTR, ctypes.c_void_p, ctypes.c_void_p, wintypes.BOOL,
|
||||
wintypes.DWORD, ctypes.c_void_p, wintypes.LPCWSTR, ctypes.POINTER(_STARTUPINFOEXW),
|
||||
ctypes.POINTER(_PROCESS_INFORMATION)]
|
||||
_k32.ResumeThread.argtypes = [wintypes.HANDLE]
|
||||
_k32.WaitForSingleObject.restype = wintypes.DWORD
|
||||
_k32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
||||
_k32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
|
||||
_k32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT]
|
||||
_k32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
_k32.GetStdHandle.restype = wintypes.HANDLE
|
||||
_k32.OpenProcess.restype = wintypes.HANDLE
|
||||
|
||||
_ALREADY_EXISTS = ctypes.c_long(0x800700B7).value
|
||||
_PROC_THREAD_ATTRIBUTE_HANDLE_LIST = 0x00020002
|
||||
_PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES = 0x00020009
|
||||
_EXTENDED_STARTUPINFO_PRESENT = 0x00080000
|
||||
_CREATE_UNICODE_ENVIRONMENT = 0x00000400
|
||||
_CREATE_NO_WINDOW = 0x08000000
|
||||
_CREATE_SUSPENDED = 0x00000004
|
||||
_STARTF_USESTDHANDLES = 0x00000100
|
||||
_HANDLE_FLAG_INHERIT = 0x00000001
|
||||
_STILL_ACTIVE = 259
|
||||
|
||||
_profile_lock = threading.Lock()
|
||||
_profile: Dict[str, object] = {}
|
||||
_granted: set = set()
|
||||
|
||||
|
||||
class NetworkIsolationUnavailable(RuntimeError):
|
||||
"""This machine cannot start a network-less process — callers must refuse to run."""
|
||||
|
||||
|
||||
def is_supported() -> bool:
|
||||
"""True on Windows builds that ship the AppContainer API (Windows 8+)."""
|
||||
if not _IS_WINDOWS:
|
||||
return False
|
||||
try:
|
||||
return bool(_uenv.CreateAppContainerProfile)
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
|
||||
def _profile_sid():
|
||||
"""``(sid pointer, sid string, temp folder)`` of the shared no-network profile."""
|
||||
with _profile_lock:
|
||||
if _profile:
|
||||
return _profile["sid"], _profile["sid_str"], _profile["temp"]
|
||||
sid = ctypes.c_void_p()
|
||||
hr = _uenv.CreateAppContainerProfile(PROFILE_NAME, "Cowork Local agent (no network)",
|
||||
"Agent shell commands with the network blocked",
|
||||
None, 0, ctypes.byref(sid))
|
||||
if hr == _ALREADY_EXISTS:
|
||||
hr = _uenv.DeriveAppContainerSidFromAppContainerName(PROFILE_NAME, ctypes.byref(sid))
|
||||
if hr != 0 or not sid.value:
|
||||
raise NetworkIsolationUnavailable(f"AppContainer profile error 0x{hr & 0xFFFFFFFF:08X}")
|
||||
text = ctypes.c_wchar_p()
|
||||
if not _adv.ConvertSidToStringSidW(sid, ctypes.byref(text)):
|
||||
raise NetworkIsolationUnavailable("Could not read the AppContainer SID")
|
||||
sid_str = text.value
|
||||
folder = ctypes.c_wchar_p()
|
||||
temp = ""
|
||||
if _uenv.GetAppContainerFolderPath(sid_str, ctypes.byref(folder)) == 0 and folder.value:
|
||||
temp = os.path.join(folder.value, "Temp")
|
||||
os.makedirs(temp, exist_ok=True)
|
||||
_profile.update(sid=sid, sid_str=sid_str, temp=temp)
|
||||
return sid, sid_str, temp
|
||||
|
||||
|
||||
_PERMS = {"write": "(OI)(CI)(M)", "read": "(OI)(CI)(RX)", "read_here": "(OI)(NP)(RX)"}
|
||||
|
||||
|
||||
def _qt_package_dir() -> str:
|
||||
"""Folder of the installed PySide6/Qt binaries ('' if PySide6 is absent)."""
|
||||
try:
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.find_spec("PySide6")
|
||||
except (ImportError, ValueError):
|
||||
return ""
|
||||
return os.path.dirname(spec.origin) if spec and spec.origin else ""
|
||||
|
||||
|
||||
def _covers(folder: str, target: str) -> bool:
|
||||
"""True if ``target`` is ``folder`` itself or lies somewhere below it."""
|
||||
if not folder or not target:
|
||||
return False
|
||||
folder, target = os.path.normcase(folder), os.path.normcase(target)
|
||||
return target == folder or target.startswith(folder.rstrip("\\/") + os.sep)
|
||||
|
||||
|
||||
def _icacls(*args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["icacls", *args], capture_output=True, text=True,
|
||||
creationflags=_CREATE_NO_WINDOW)
|
||||
|
||||
|
||||
def grant_access(path: str, sid_str: str, mode: str) -> None:
|
||||
"""Let the AppContainer SID open ``path``.
|
||||
|
||||
``mode`` is ``"write"``/``"read"`` (inherited by everything below) or
|
||||
``"read_here"`` (this folder and the files directly in it, no deeper).
|
||||
|
||||
An inherited ACE must never reach the Qt WebEngine binaries: Chromium's
|
||||
sandboxed render process then fails to load Qt6WebEngineCore.dll
|
||||
(STATUS_DLL_NOT_FOUND) and every web view in the app goes blank. A folder
|
||||
that contains the PySide6 install is therefore refused.
|
||||
"""
|
||||
path = os.path.abspath(path)
|
||||
key = (os.path.normcase(path), mode)
|
||||
if key in _granted or not os.path.exists(path):
|
||||
return
|
||||
if mode != "read_here" and _covers(path, _qt_package_dir()):
|
||||
raise NetworkIsolationUnavailable(
|
||||
f"Refusing to sandbox a folder that contains the app's Qt runtime: {path}")
|
||||
perm = _PERMS[mode]
|
||||
listing = (_icacls(path).stdout or "").lower()
|
||||
if f"{sid_str}:{perm}".lower() not in listing:
|
||||
done = _icacls(path, "/grant", f"*{sid_str}:{perm}", "/Q", "/C")
|
||||
if done.returncode != 0:
|
||||
raise NetworkIsolationUnavailable(
|
||||
f"Could not grant the sandbox access to {path}: {(done.stderr or done.stdout).strip()}")
|
||||
_granted.add(key)
|
||||
|
||||
|
||||
def _repair_inherited_grant(path: str, sid_str: str) -> None:
|
||||
"""Drop an inherited grant that an earlier build put on the app's venv."""
|
||||
key = (os.path.normcase(os.path.abspath(path)), "repaired")
|
||||
if key in _granted or not os.path.isdir(path):
|
||||
return
|
||||
listing = (_icacls(path).stdout or "").lower()
|
||||
if f"{sid_str}:(oi)(ci)".lower() in listing:
|
||||
_icacls(path, "/remove:g", f"*{sid_str}", "/Q", "/C")
|
||||
_granted.add(key)
|
||||
|
||||
|
||||
def _interpreter_grants():
|
||||
"""``(folder, mode)`` pairs that let the sandbox run the app's Python.
|
||||
|
||||
The base install is read-only and holds no Qt; a venv only needs its root
|
||||
(``pyvenv.cfg``) and ``Scripts`` — never ``Lib/site-packages``.
|
||||
"""
|
||||
qt_dir = _qt_package_dir()
|
||||
if _covers(sys.base_prefix, qt_dir):
|
||||
# PySide6 lives in the base install itself: expose only the executable
|
||||
# and the compiled stdlib modules, never the tree holding Qt.
|
||||
grants = [(sys.base_prefix, "read_here"), (os.path.join(sys.base_prefix, "DLLs"), "read")]
|
||||
else:
|
||||
grants = [(sys.base_prefix, "read")]
|
||||
if os.path.normcase(sys.prefix) != os.path.normcase(sys.base_prefix):
|
||||
grants += [(sys.prefix, "read_here"), (os.path.join(sys.prefix, "Scripts"), "read")]
|
||||
return [(folder, mode) for folder, mode in grants
|
||||
if mode == "read_here" or not _covers(folder, qt_dir)]
|
||||
|
||||
|
||||
def _env_block(env: Dict[str, str]) -> ctypes.Array:
|
||||
"""Sorted, double-NUL-terminated UTF-16 environment block for CreateProcessW."""
|
||||
items = sorted(env.items(), key=lambda kv: kv[0].upper())
|
||||
text = "".join(f"{k}={v}\0" for k, v in items if k and "=" not in k) + "\0"
|
||||
return ctypes.create_unicode_buffer(text, len(text))
|
||||
|
||||
|
||||
def _pipe():
|
||||
"""Anonymous pipe; only the child's (write) end is inheritable."""
|
||||
sa = _SECURITY_ATTRIBUTES(ctypes.sizeof(_SECURITY_ATTRIBUTES), None, True)
|
||||
read, write = wintypes.HANDLE(), wintypes.HANDLE()
|
||||
if not _k32.CreatePipe(ctypes.byref(read), ctypes.byref(write), ctypes.byref(sa), 0):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
_k32.SetHandleInformation(read, _HANDLE_FLAG_INHERIT, 0)
|
||||
return read, write
|
||||
|
||||
|
||||
class AppContainerProcess:
|
||||
"""The ``subprocess.Popen`` subset that ``deps._run_cancellable_body`` relies on."""
|
||||
|
||||
def __init__(self, handle, pid: int, stdout: io.TextIOBase, stderr: io.TextIOBase):
|
||||
"""Wrap an already-started process handle and its two output streams."""
|
||||
self._handle = handle
|
||||
self.pid = pid
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
self.returncode: Optional[int] = None
|
||||
|
||||
def poll(self) -> Optional[int]:
|
||||
"""Exit code if the process has finished, else None."""
|
||||
if self.returncode is None and self._handle:
|
||||
code = wintypes.DWORD()
|
||||
if _k32.GetExitCodeProcess(self._handle, ctypes.byref(code)) and code.value != _STILL_ACTIVE:
|
||||
self.returncode = ctypes.c_int32(code.value).value
|
||||
_k32.CloseHandle(self._handle)
|
||||
self._handle = None
|
||||
return self.returncode
|
||||
|
||||
def wait(self, timeout: Optional[float] = None) -> int:
|
||||
"""Block until exit; raise ``subprocess.TimeoutExpired`` like Popen does."""
|
||||
if self.returncode is None and self._handle:
|
||||
ms = 0xFFFFFFFF if timeout is None else int(timeout * 1000)
|
||||
if _k32.WaitForSingleObject(self._handle, ms) != 0:
|
||||
raise subprocess.TimeoutExpired("appcontainer", timeout)
|
||||
return self.poll()
|
||||
|
||||
def kill(self) -> None:
|
||||
"""Terminate the process (the Job Object in deps kills its children)."""
|
||||
if self.returncode is None and self._handle:
|
||||
_k32.TerminateProcess(self._handle, 1)
|
||||
|
||||
def communicate(self, timeout: Optional[float] = None):
|
||||
"""Read both streams to the end and wait; returns ``(stdout, stderr)``."""
|
||||
chunks: Dict[str, str] = {}
|
||||
|
||||
def _drain(name, stream):
|
||||
chunks[name] = stream.read()
|
||||
|
||||
readers = [threading.Thread(target=_drain, args=(n, s), daemon=True)
|
||||
for n, s in (("out", self.stdout), ("err", self.stderr))]
|
||||
for t in readers:
|
||||
t.start()
|
||||
for t in readers:
|
||||
t.join(timeout)
|
||||
self.wait(timeout)
|
||||
return chunks.get("out", ""), chunks.get("err", "")
|
||||
|
||||
|
||||
def spawn(command: str, cwd: Optional[str], env: Optional[Dict[str, str]],
|
||||
readable_dirs: Iterable[str] = ()) -> AppContainerProcess:
|
||||
"""Start ``cmd.exe /c command`` in the no-network AppContainer.
|
||||
|
||||
Raises :class:`NetworkIsolationUnavailable` when that cannot be done —
|
||||
callers must then refuse the command rather than run it with the network on.
|
||||
"""
|
||||
if not is_supported():
|
||||
raise NetworkIsolationUnavailable("AppContainer is only available on Windows")
|
||||
sid, sid_str, temp = _profile_sid()
|
||||
cwd = os.path.abspath(cwd or os.getcwd())
|
||||
_repair_inherited_grant(sys.prefix, sid_str)
|
||||
grant_access(cwd, sid_str, "write")
|
||||
for folder, mode in [*_interpreter_grants(), *((d, "read") for d in readable_dirs if d)]:
|
||||
grant_access(folder, sid_str, mode)
|
||||
|
||||
child_env = dict(os.environ if env is None else env)
|
||||
if temp:
|
||||
child_env["TEMP"] = child_env["TMP"] = temp
|
||||
child_env.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
env_block = _env_block(child_env)
|
||||
|
||||
out_r, out_w = _pipe()
|
||||
err_r, err_w = _pipe()
|
||||
handles = (wintypes.HANDLE * 2)(out_w, err_w)
|
||||
caps = _SECURITY_CAPABILITIES(sid, None, 0, 0)
|
||||
size = ctypes.c_size_t()
|
||||
_k32.InitializeProcThreadAttributeList(None, 2, 0, ctypes.byref(size))
|
||||
attr = ctypes.create_string_buffer(size.value)
|
||||
pi = _PROCESS_INFORMATION()
|
||||
try:
|
||||
if not (_k32.InitializeProcThreadAttributeList(attr, 2, 0, ctypes.byref(size))
|
||||
and _k32.UpdateProcThreadAttribute(
|
||||
attr, 0, _PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, ctypes.byref(caps),
|
||||
ctypes.sizeof(caps), None, None)
|
||||
and _k32.UpdateProcThreadAttribute(
|
||||
attr, 0, _PROC_THREAD_ATTRIBUTE_HANDLE_LIST, handles,
|
||||
ctypes.sizeof(handles), None, None)):
|
||||
raise NetworkIsolationUnavailable(str(ctypes.WinError(ctypes.get_last_error())))
|
||||
si = _STARTUPINFOEXW()
|
||||
si.StartupInfo.cb = ctypes.sizeof(si)
|
||||
si.StartupInfo.dwFlags = _STARTF_USESTDHANDLES
|
||||
si.StartupInfo.hStdOutput = out_w
|
||||
si.StartupInfo.hStdError = err_w
|
||||
si.lpAttributeList = ctypes.addressof(attr)
|
||||
comspec = os.environ.get("COMSPEC") or r"C:\Windows\System32\cmd.exe"
|
||||
cmdline = ctypes.create_unicode_buffer(f'"{comspec}" /d /s /c "{command}"')
|
||||
flags = (_EXTENDED_STARTUPINFO_PRESENT | _CREATE_UNICODE_ENVIRONMENT
|
||||
| _CREATE_NO_WINDOW | _CREATE_SUSPENDED)
|
||||
if not _k32.CreateProcessW(None, cmdline, None, None, True, flags,
|
||||
ctypes.addressof(env_block), cwd,
|
||||
ctypes.byref(si), ctypes.byref(pi)):
|
||||
raise NetworkIsolationUnavailable(
|
||||
f"Could not start the sandboxed command: {ctypes.WinError(ctypes.get_last_error())}")
|
||||
_k32.ResumeThread(pi.hThread)
|
||||
_k32.CloseHandle(pi.hThread)
|
||||
except BaseException:
|
||||
for h in (out_r, err_r):
|
||||
_k32.CloseHandle(h)
|
||||
raise
|
||||
finally:
|
||||
_k32.DeleteProcThreadAttributeList(attr)
|
||||
_k32.CloseHandle(out_w)
|
||||
_k32.CloseHandle(err_w)
|
||||
|
||||
def _stream(handle) -> io.TextIOBase:
|
||||
fd = msvcrt.open_osfhandle(handle.value, os.O_RDONLY)
|
||||
return io.TextIOWrapper(io.FileIO(fd, "rb"), encoding=locale.getpreferredencoding(False),
|
||||
errors="replace")
|
||||
|
||||
return AppContainerProcess(pi.hProcess, pi.dwProcessId, _stream(out_r), _stream(err_r))
|
||||
|
||||
|
||||
__all__ = ["AppContainerProcess", "NetworkIsolationUnavailable", "PROFILE_NAME",
|
||||
"grant_access", "is_supported", "spawn"]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Start a shell command that the operating system keeps off the network.
|
||||
|
||||
Used whenever "Block network" is on for agent ``run_command`` calls and for
|
||||
scheduled script tasks. The contract is fail-closed: if isolation cannot be
|
||||
set up, :class:`NetworkIsolationUnavailable` is raised and the caller refuses
|
||||
the command instead of running it with the network open.
|
||||
|
||||
* Windows: an AppContainer with no network capability
|
||||
(:mod:`.appcontainer_process`).
|
||||
* macOS: ``sandbox-exec`` with a profile that denies every network operation.
|
||||
* Linux: ``unshare --net`` in a new user namespace (an empty network namespace
|
||||
has only a downed loopback). If unprivileged namespaces are disabled,
|
||||
``unshare`` itself fails and the command never runs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .appcontainer_process import NetworkIsolationUnavailable
|
||||
|
||||
_MACOS_PROFILE = "(version 1)(allow default)(deny network*)"
|
||||
|
||||
|
||||
def spawn_without_network(command: str, cwd: Optional[str], env: Optional[Dict[str, str]]):
|
||||
"""A ``Popen``-like process running ``command`` through the shell, with no network."""
|
||||
if sys.platform == "win32":
|
||||
from . import appcontainer_process
|
||||
|
||||
return appcontainer_process.spawn(command, cwd, env)
|
||||
if sys.platform == "darwin" and shutil.which("sandbox-exec"):
|
||||
argv = ["sandbox-exec", "-p", _MACOS_PROFILE, "/bin/sh", "-c", command]
|
||||
elif sys.platform.startswith("linux") and shutil.which("unshare"):
|
||||
argv = ["unshare", "--user", "--map-root-user", "--net", "/bin/sh", "-c", command]
|
||||
else:
|
||||
raise NetworkIsolationUnavailable(
|
||||
"No network isolation is available on this system (needs AppContainer, "
|
||||
"sandbox-exec or unshare).")
|
||||
return subprocess.Popen(argv, cwd=cwd, env=env, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, bufsize=1,
|
||||
start_new_session=True)
|
||||
|
||||
|
||||
__all__ = ["NetworkIsolationUnavailable", "spawn_without_network"]
|
||||
@@ -9,6 +9,13 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
_PLAN_ICONS = {"pending": "○", "running": "▶", "done": "✓", "error": "✗"}
|
||||
|
||||
HEADER_TITLE_MAX_CHARS = 10 # thanh tiêu đề khung chat chỉ hiện tối đa ngần này ký tự
|
||||
|
||||
|
||||
def clip_chars(text: str, limit: int = HEADER_TITLE_MAX_CHARS) -> str:
|
||||
"""Giữ tối đa ``limit`` ký tự, dư thì cắt và thêm "…"."""
|
||||
return text if len(text) <= limit else text[:limit].rstrip() + "…"
|
||||
|
||||
|
||||
|
||||
def _format_plan_steps(steps) -> str:
|
||||
|
||||
@@ -99,6 +99,9 @@ class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin,
|
||||
# can run concurrently. Each value is a turn-context dict — see _start_turn.
|
||||
self.worker: AgentWorker | None = None
|
||||
self._active: Dict[AgentWorker, Dict[str, Any]] = {}
|
||||
# Người dùng đã bấm Dừng cho lượt đang chạy chưa. Cờ này chỉ đổi thứ
|
||||
# MÀN HÌNH nói, không đổi việc huỷ: huỷ vẫn là cờ trên worker.
|
||||
self._stop_requested = False
|
||||
self._turn_seq: int = 0
|
||||
# session_id -> its live messages list, for every conversation that still has
|
||||
# a turn running. Lets you start a new chat / reopen an old one WHILE work
|
||||
@@ -337,7 +340,10 @@ class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin,
|
||||
Switching chats, or hitting History → Refresh, shows whether THIS chat is
|
||||
still processing (a background turn) or idle."""
|
||||
if self._view_busy():
|
||||
self.thinking.start("chat.running") # this conversation is still working
|
||||
# Đã bấm Dừng mà lượt chưa kết thúc: giữ nhãn "đang dừng", đừng kéo
|
||||
# ngược về "đang chạy" — người dùng vừa bấm xong mà thấy chữ cũ thì
|
||||
# đọc ra là nút không ăn.
|
||||
self.thinking.start("chat.stopping" if self._stop_requested else "chat.running")
|
||||
else:
|
||||
self.thinking.stop()
|
||||
self.composer.set_running(bool(self._active)) # Stop shows while anything runs
|
||||
|
||||
@@ -224,6 +224,23 @@ class ChatSessionMixin:
|
||||
if getattr(self, "_usage_total_lbl", None) is not None:
|
||||
self.refresh_usage()
|
||||
|
||||
def set_title_label(self, lbl, fallback: str) -> None:
|
||||
"""Đặt tiêu đề hội thoại lên nhãn: tối đa 10 ký tự, bản đầy đủ ở tooltip."""
|
||||
from .chat_helpers import clip_chars
|
||||
|
||||
full = self.title or fallback
|
||||
lbl.setText(clip_chars(full))
|
||||
lbl.setToolTip(full)
|
||||
|
||||
def apply_renamed_title(self, session_id: str, title: str) -> None:
|
||||
"""Hội thoại đang mở vừa được đổi tên ở cột lịch sử: đổi luôn ``self.title``.
|
||||
|
||||
Không chỉ để thanh tiêu đề cập nhật ngay — lượt chat kế tiếp lưu bằng
|
||||
``self.title``, giữ tên cũ sẽ ghi đè mất tên người dùng vừa đặt."""
|
||||
if session_id and session_id == self.session_id:
|
||||
self.title = title
|
||||
self._notify_title()
|
||||
|
||||
def load_conversation(self, conv: Dict[str, Any]) -> None:
|
||||
"""Switch the view to a stored conversation. Allowed while work is running —
|
||||
the current turns keep going in the background."""
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from ...core.history import shorten_title
|
||||
from ...core.worker import AgentWorker
|
||||
from ...i18n import tr
|
||||
from ...state import AppContext
|
||||
@@ -88,7 +89,11 @@ class ChatTurnRunnerMixin:
|
||||
prefix = f"{prefix}\n\n{agent_prefix}" if prefix else agent_prefix
|
||||
if not self.title:
|
||||
base = text or (Path(attachments[0]).name if attachments else "(attachment)")
|
||||
self.title = (base[:60] + "…") if len(base) > 60 else base
|
||||
# Rút gọn mà không cắt vào giữa từ: xem shorten_title trong
|
||||
# core/history.py. Dùng chung với derive_title để tiêu đề trên
|
||||
# thanh tiêu đề và tiêu đề lưu vào lịch sử không rút gọn theo
|
||||
# hai kiểu khác nhau.
|
||||
self.title = shorten_title(base)
|
||||
self._notify_title()
|
||||
|
||||
# Reset the Plan panel so each message starts from a clean checklist (the
|
||||
@@ -212,6 +217,7 @@ class ChatTurnRunnerMixin:
|
||||
if self._view_busy() or len(self._active) >= self._max_parallel():
|
||||
self.composer.set_busy(True)
|
||||
self.status_message.emit(tr("chatpanel.working", name=tr(f"app.tab.{self.kind}")))
|
||||
self._stop_requested = False # lượt mới: xoá dấu vết lần Dừng trước
|
||||
self.thinking.start("chat.running")
|
||||
worker.start()
|
||||
|
||||
@@ -254,7 +260,7 @@ class ChatTurnRunnerMixin:
|
||||
self._show_usage(ctx) # per-turn + conversation token/cost
|
||||
except Exception: # noqa: BLE001 — usage display must never break a turn
|
||||
pass
|
||||
done = self.chat_view.add_success(tr("chat.done_marker")) # green done marker in the chat box
|
||||
done = self._dau_ket_thuc()
|
||||
folder = self.workspace_dir()
|
||||
if folder:
|
||||
done.add_folder_link(str(folder), tr("chat.open_output_folder"))
|
||||
@@ -268,12 +274,32 @@ class ChatTurnRunnerMixin:
|
||||
self._maybe_notify_teams(result)
|
||||
self._drain_queue()
|
||||
|
||||
def _dau_ket_thuc(self):
|
||||
"""Dấu kết thúc đặt vào khung chat khi một lượt vừa xong.
|
||||
|
||||
Dừng theo yêu cầu KHÔNG phải là hoàn thành: dấu xanh "Đã hoàn thành" ở
|
||||
đó nói ngược hẳn với thứ người dùng vừa làm, và là lý do người dùng báo
|
||||
"bấm Dừng mà không biết nó đã dừng hay chưa".
|
||||
|
||||
Tách khỏi ``_on_finished`` để nhánh này kiểm được bằng test mà không
|
||||
phải dựng cả một ``ChatPanel``.
|
||||
"""
|
||||
if self._stop_requested:
|
||||
return self.chat_view.add_status(tr("chat.stopped_marker"))
|
||||
return self.chat_view.add_success(tr("chat.done_marker"))
|
||||
|
||||
def _on_failed(self, ctx: Dict[str, Any], err: str) -> None:
|
||||
"""Lượt chạy lỗi: huỷ thư mục kết quả tạm và hiện lỗi (nếu hội thoại còn đang mở)."""
|
||||
live = self._turn_is_live(ctx)
|
||||
self._end_turn(ctx)
|
||||
self._cleanup_turn(ctx, False) # discard this turn's output sandbox
|
||||
if live:
|
||||
if live and self._stop_requested:
|
||||
# Người dùng vừa bấm Dừng: mọi lỗi phát sinh trong lúc huỷ là hệ quả
|
||||
# của chính việc huỷ (đóng socket giữa stream, tool bị cắt ngang).
|
||||
# Dội một traceback đỏ vào mặt họ là trả lời sai câu hỏi "nó dừng
|
||||
# chưa?" — thứ họ cần là một dòng nói rõ là đã dừng.
|
||||
self._dau_ket_thuc()
|
||||
elif live:
|
||||
self.chat_view.add_error(err)
|
||||
self.graph_event.emit(self.session_name, {"type": "error", "content": err})
|
||||
from ...providers.base import is_model_not_found_error
|
||||
@@ -286,7 +312,10 @@ class ChatTurnRunnerMixin:
|
||||
self.composer.set_text(ctx["display_text"])
|
||||
else:
|
||||
self._persist_session(ctx)
|
||||
self.status_message.emit(tr("chatpanel.failed", name=tr(f"app.tab.{self.kind}")))
|
||||
# Thanh trạng thái phải nói cùng một chuyện với khung chat: dừng theo
|
||||
# yêu cầu thì không phải "lỗi".
|
||||
key = "chatpanel.stopped" if self._stop_requested else "chatpanel.failed"
|
||||
self.status_message.emit(tr(key, name=tr(f"app.tab.{self.kind}")))
|
||||
self.turn_finished.emit({"error": err})
|
||||
self._drain_queue()
|
||||
|
||||
@@ -311,6 +340,11 @@ class ChatTurnRunnerMixin:
|
||||
"""Dừng mọi lượt đang chạy của hội thoại này và xoá sạch hàng đợi."""
|
||||
if not self._active:
|
||||
return
|
||||
# Báo NGAY trên màn: huỷ thật có thể mất vài giây (đang chờ gateway trả
|
||||
# lời, đang chạy dở một tool), mà trong lúc đó chỉ báo vẫn đếm "Đang
|
||||
# chạy · 160s" — người dùng đọc ra là nút Dừng không ăn.
|
||||
self._stop_requested = True
|
||||
self.thinking.set_label("chat.stopping")
|
||||
for w in list(self._active):
|
||||
if w.isRunning():
|
||||
w.request_stop()
|
||||
|
||||
@@ -110,7 +110,10 @@ class OfficeDocumentRenderer:
|
||||
if self._engine is None:
|
||||
try:
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
from .offline_web_page import install_offline_page
|
||||
self._engine = QWebEngineView()
|
||||
install_offline_page(self._engine)
|
||||
self._owner.stack.addWidget(self._engine)
|
||||
except Exception: # noqa: BLE001
|
||||
self._engine = None
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""HTML preview page that loads nothing from the web while "Block network" is on.
|
||||
|
||||
``QWebEngineView.setHtml`` happily fetches every ``<img src="https://...">``,
|
||||
``<script src>`` and stylesheet the previewed file references — an outbound
|
||||
connection the user never asked for, made by the app itself. The preview gets
|
||||
its own off-the-record profile (so the interceptor below touches no other web
|
||||
view, e.g. the GraphRAG renderer) and every remote request is refused while
|
||||
the switch is on. Local files, ``data:`` and ``qrc:`` URLs still load.
|
||||
|
||||
Lifetime rule: a ``QWebEngineProfile`` must outlive every page that uses it.
|
||||
A profile owned by the view is destroyed *before* the page (children die in
|
||||
creation order) — Qt then warns "Release of profile requested but
|
||||
WebEnginePage still not deleted" and the app can abort later (0xc0000409 in
|
||||
Qt6Core.dll, seen when switching tabs). So there is ONE profile for the whole
|
||||
app, owned by the QApplication, and each page is owned by its view.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtWebEngineCore import (
|
||||
QWebEnginePage, QWebEngineProfile, QWebEngineSettings, QWebEngineUrlRequestInterceptor,
|
||||
)
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from cowork_local.application.network import network_guard
|
||||
|
||||
_REMOTE_SCHEMES = frozenset({"http", "https", "ws", "wss", "ftp"})
|
||||
_profile: Optional[QWebEngineProfile] = None
|
||||
_blocker: Optional["RemoteRequestBlocker"] = None # Python must hold it, or it is collected
|
||||
|
||||
|
||||
class RemoteRequestBlocker(QWebEngineUrlRequestInterceptor):
|
||||
"""Refuses remote URLs whenever the network guard says the network is blocked."""
|
||||
|
||||
def interceptRequest(self, info) -> None: # noqa: N802 - Qt override
|
||||
"""Called by WebEngine for every request the page makes."""
|
||||
if info.requestUrl().scheme().lower() in _REMOTE_SCHEMES and network_guard.is_blocked():
|
||||
info.block(True)
|
||||
|
||||
|
||||
def preview_profile() -> QWebEngineProfile:
|
||||
"""The app-wide off-the-record profile shared by every HTML preview."""
|
||||
global _profile, _blocker
|
||||
if _profile is None:
|
||||
_profile = QWebEngineProfile(QApplication.instance()) # no storage name = off the record
|
||||
_blocker = RemoteRequestBlocker(_profile)
|
||||
_profile.setUrlRequestInterceptor(_blocker)
|
||||
_profile.settings().setAttribute(
|
||||
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, True)
|
||||
return _profile
|
||||
|
||||
|
||||
def install_offline_page(view) -> None:
|
||||
"""Give ``view`` a page on the shared preview profile.
|
||||
|
||||
The previewed file is loaded with a ``file://`` base URL, and Qt refuses
|
||||
every remote resource of such a page unless
|
||||
``LocalContentCanAccessRemoteUrls`` is on — so web images never showed,
|
||||
even with the network open. The switch is turned on for the profile; the
|
||||
interceptor is what keeps remote content out while "Block network" is on.
|
||||
"""
|
||||
view.setPage(QWebEnginePage(preview_profile(), view))
|
||||
|
||||
|
||||
__all__ = ["RemoteRequestBlocker", "install_offline_page", "preview_profile"]
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Khoá phạm vi quét của màn GraphRAG vào một project.
|
||||
|
||||
Tách khỏi ``graph_renderer.py``: file đó đã ở 399/400 dòng — đúng một dòng
|
||||
trước trần của ``scripts/check_loc.py``, và cổng ấy nói rõ cách duy nhất đúng
|
||||
khi chạm trần là tách file, không phải nới con số. Khối này là chỗ tự nhiên
|
||||
để cắt: ba phương thức dưới đây chỉ nói về một việc — project nào đang khoá,
|
||||
và thư mục nào đi theo nó — còn phần còn lại của renderer lo việc vẽ.
|
||||
|
||||
Là mixin chứ không phải đối tượng rời, cùng lý do như ``NavRailMixin``: ba
|
||||
phương thức này đọc/ghi state của chính renderer (``project_combo``,
|
||||
``path_edit``, ``_needs_scan``…). Biến thành đối tượng cộng tác thì phải viết
|
||||
lại từng chỗ ``self.X`` thành ``self.renderer.X`` mà không đổi hành vi gì.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
|
||||
|
||||
class GraphProjectLockMixin:
|
||||
"""Ba phương thức khoá-theo-project. Trộn vào ``GraphRenderer``."""
|
||||
|
||||
def _refresh_project_combo(self) -> None:
|
||||
"""Nạp lại danh sách project vào bộ chọn, giữ nguyên project đang chọn."""
|
||||
from cowork_local.core.projects import list_projects
|
||||
|
||||
keep = self._active_project_id
|
||||
self.project_combo.blockSignals(True)
|
||||
self.project_combo.clear()
|
||||
self.project_combo.addItem(tr("structure.project_none"), "")
|
||||
row_to_select = 0
|
||||
for i, p in enumerate(list_projects(), start=1):
|
||||
self.project_combo.addItem(p.name, p.project_id)
|
||||
if p.project_id == keep:
|
||||
row_to_select = i
|
||||
self.project_combo.setCurrentIndex(row_to_select)
|
||||
self.project_combo.blockSignals(False)
|
||||
|
||||
def set_project(self, project_id: str) -> None:
|
||||
"""Khoá phạm vi quét vào một project (chuỗi rỗng là bỏ khoá)."""
|
||||
pid = project_id or ""
|
||||
self._refresh_project_combo()
|
||||
target = self.project_combo.findData(pid)
|
||||
if target < 0:
|
||||
target = 0
|
||||
if self.project_combo.currentIndex() == target:
|
||||
self._on_project_changed(target)
|
||||
else:
|
||||
self.project_combo.setCurrentIndex(target)
|
||||
|
||||
def _on_project_changed(self, _idx: int) -> None:
|
||||
"""Áp trạng thái khoá: đường dẫn chuyển sang chỉ đọc và trỏ vào thư mục"""
|
||||
from cowork_local.core.projects import load_project
|
||||
|
||||
pid = self.project_combo.currentData() or ""
|
||||
project_changed = pid != self._active_project_id
|
||||
self._active_project_id = pid
|
||||
locked = bool(pid)
|
||||
self.path_edit.setReadOnly(locked)
|
||||
self._pick_btn.setEnabled(not locked)
|
||||
if locked:
|
||||
project = load_project(pid)
|
||||
if project is not None:
|
||||
self.path_edit.setText(str(project.workspace_dir()))
|
||||
# Changing the FOLDER changes what we scan just as much as changing the
|
||||
# project does. Keying this off the id alone left the path in the bar
|
||||
# updated while the graph in the middle still showed the old folder's
|
||||
# nodes: "Đổi" in the Project screen moves the folder, never the id.
|
||||
duong_dan = self.path_edit.text().strip()
|
||||
doi_muc_tieu = project_changed or duong_dan != self._active_path
|
||||
self._active_path = duong_dan
|
||||
if doi_muc_tieu:
|
||||
self.project_changed.emit() # GraphQaWidget drops its temp extraction cache
|
||||
# Mark it and scan on the next visit rather than now — see
|
||||
# auto_scan_and_fit()'s docstring for why.
|
||||
self._needs_scan = True
|
||||
# ...except when this screen is the one on show. The picker lives HERE,
|
||||
# so a user changing project is already looking at the graph: there is
|
||||
# no "next visit" to defer to, and they had to press Scan by hand.
|
||||
# Deferring still applies when the change came from the Workspace
|
||||
# screen while this one is hidden, which is what it was for.
|
||||
if self.isVisible() and duong_dan:
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
@@ -27,6 +27,7 @@ from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.graph import graph_export
|
||||
from cowork_local.presentation.graph.graph_messages_view import GraphMessagesView
|
||||
from cowork_local.presentation.graph.graph_project_lock import GraphProjectLockMixin
|
||||
from cowork_local.presentation.graph.graph_scene_builder import build_scene
|
||||
from cowork_local.presentation.graph.graph_scene_items import _Bridge, _Edge, _GraphView, _Node
|
||||
from cowork_local.presentation.shared import HAS_WEB_ENGINE
|
||||
@@ -35,7 +36,7 @@ from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class GraphRenderer(QWidget):
|
||||
class GraphRenderer(GraphProjectLockMixin, QWidget):
|
||||
"""Nửa "đồ thị" của màn GraphRAG: thanh công cụ, khung xem và vòng đời quét."""
|
||||
status_message = Signal(str)
|
||||
node_selected = Signal(object) # a node's .data, whenever the scene selection changes
|
||||
@@ -60,6 +61,8 @@ class GraphRenderer(QWidget):
|
||||
self._needs_scan = False
|
||||
self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite)
|
||||
self._active_project_id = "" # "" = free path; set = scan locked to that project's sandbox
|
||||
# The folder last scanned — see graph_project_lock.py.
|
||||
self._active_path = ""
|
||||
|
||||
self._rescan_timer = QTimer(self)
|
||||
self._rescan_timer.setSingleShot(True)
|
||||
@@ -154,63 +157,6 @@ class GraphRenderer(QWidget):
|
||||
"""
|
||||
return [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)]
|
||||
|
||||
# ---- project sandbox lock ------------------------------------------------- #
|
||||
def _refresh_project_combo(self) -> None:
|
||||
"""Nạp lại danh sách project vào bộ chọn, giữ nguyên project đang chọn."""
|
||||
from cowork_local.core.projects import list_projects
|
||||
|
||||
keep = self._active_project_id
|
||||
self.project_combo.blockSignals(True)
|
||||
self.project_combo.clear()
|
||||
self.project_combo.addItem(tr("structure.project_none"), "")
|
||||
row_to_select = 0
|
||||
for i, p in enumerate(list_projects(), start=1):
|
||||
self.project_combo.addItem(p.name, p.project_id)
|
||||
if p.project_id == keep:
|
||||
row_to_select = i
|
||||
self.project_combo.setCurrentIndex(row_to_select)
|
||||
self.project_combo.blockSignals(False)
|
||||
|
||||
def set_project(self, project_id: str) -> None:
|
||||
"""Khoá phạm vi quét vào một project (chuỗi rỗng là bỏ khoá)."""
|
||||
pid = project_id or ""
|
||||
self._refresh_project_combo()
|
||||
target = self.project_combo.findData(pid)
|
||||
if target < 0:
|
||||
target = 0
|
||||
if self.project_combo.currentIndex() == target:
|
||||
self._on_project_changed(target)
|
||||
else:
|
||||
self.project_combo.setCurrentIndex(target)
|
||||
|
||||
def _on_project_changed(self, _idx: int) -> None:
|
||||
"""Áp trạng thái khoá: đường dẫn chuyển sang chỉ đọc và trỏ vào thư mục"""
|
||||
from cowork_local.core.projects import load_project
|
||||
|
||||
pid = self.project_combo.currentData() or ""
|
||||
project_changed = pid != self._active_project_id
|
||||
self._active_project_id = pid
|
||||
locked = bool(pid)
|
||||
self.path_edit.setReadOnly(locked)
|
||||
self._pick_btn.setEnabled(not locked)
|
||||
if locked:
|
||||
project = load_project(pid)
|
||||
if project is not None:
|
||||
self.path_edit.setText(str(project.workspace_dir()))
|
||||
if project_changed:
|
||||
self.project_changed.emit() # GraphQaWidget drops its temp extraction cache
|
||||
# Mark it and scan on the next visit rather than now — see
|
||||
# auto_scan_and_fit()'s docstring for why.
|
||||
self._needs_scan = True
|
||||
# ...except when this screen is the one on show. The picker lives HERE,
|
||||
# so a user changing project is already looking at the graph: there is
|
||||
# no "next visit" to defer to, and they had to press Scan by hand.
|
||||
# Deferring still applies when the change came from the Workspace
|
||||
# screen while this one is hidden, which is what it was for.
|
||||
if self.isVisible() and self.path_edit.text().strip():
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
|
||||
# ---- helpers ---------------------------------------------------------------- #
|
||||
def _pick(self) -> None:
|
||||
"""Mở hộp thoại chọn thư mục gốc để quét."""
|
||||
|
||||
@@ -40,6 +40,9 @@ class StructureGraphView(QWidget):
|
||||
# Project ma man Workspace da ap xuong lan gan nhat. None = chua ap lan
|
||||
# nao, de lan goi dau tien khong bi bo qua ke ca khi pid la chuoi rong.
|
||||
self._workspace_project = None
|
||||
# Thư mục của project đó lúc áp gần nhất. Chốt theo CẢ đường dẫn chứ
|
||||
# không chỉ theo id — xem set_workspace_project.
|
||||
self._workspace_dir = None
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
self.renderer = GraphRenderer(ctx)
|
||||
@@ -208,13 +211,29 @@ class StructureGraphView(QWidget):
|
||||
|
||||
Đổi sang project khác ở màn Workspace thì vẫn áp — cùng luật với tab Thư
|
||||
mục (``FolderTab.set_project_root``). Chỉ lần refresh trong CÙNG một
|
||||
project là không được đụng.
|
||||
project VÀ cùng một thư mục là không được đụng.
|
||||
|
||||
Chốt theo cả đường dẫn chứ không chỉ theo id: đổi thư mục làm việc ở
|
||||
màn Project không làm id đổi, nên chốt theo mỗi id thì màn này giữ
|
||||
nguyên đường dẫn cũ và quét nhầm thư mục. Tab Thư mục vốn đã chốt theo
|
||||
đường dẫn — đây là đưa hai nơi về đúng cùng một luật như comment này
|
||||
vẫn nói.
|
||||
"""
|
||||
if project_id == self._workspace_project:
|
||||
thu_muc = self._thu_muc_cua(project_id)
|
||||
if project_id == self._workspace_project and thu_muc == self._workspace_dir:
|
||||
return
|
||||
self._workspace_project = project_id
|
||||
self._workspace_dir = thu_muc
|
||||
self.set_project(project_id)
|
||||
|
||||
@staticmethod
|
||||
def _thu_muc_cua(project_id: str) -> str:
|
||||
"""Thư mục làm việc hiện tại của project, chuỗi rỗng nếu không có."""
|
||||
from cowork_local.core.projects import load_project
|
||||
|
||||
project = load_project(project_id) if project_id else None
|
||||
return str(project.workspace_dir()) if project is not None else ""
|
||||
|
||||
def prewarm(self) -> None:
|
||||
"""Dựng sẵn khung đồ thị trước khi người dùng bấm vào, để lần mở đầu không giật."""
|
||||
self.renderer.prewarm()
|
||||
|
||||
@@ -12,11 +12,9 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from ...i18n import tr
|
||||
|
||||
#: Các chế độ định tuyến. Danh sách này phải khớp ``config.py::AppConfig
|
||||
#: .ROUTING_MODES`` — Delta thêm "fallback" ở R03-T03 và nếu quên đồng bộ
|
||||
#: chỗ này thì người dùng không chọn được chế độ đó, mà không có lỗi nào báo.
|
||||
MODE_KEYS = (("off", "routing.mode_off"), ("auto", "routing.mode_auto"),
|
||||
("manual", "routing.mode_manual"))
|
||||
#: Các chế độ người dùng chọn được — khớp ``AppConfig.USER_ROUTING_MODES``.
|
||||
#: Off/Fallback đã bỏ; giá trị cũ còn lưu được hiểu là Auto.
|
||||
MODE_KEYS = (("auto", "routing.mode_auto"), ("manual", "routing.mode_manual"))
|
||||
|
||||
POLICY_KEYS = (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"),
|
||||
("latency", "routing.policy_latency"),
|
||||
@@ -37,7 +35,7 @@ class RoutingSettingsWidget(QGroupBox):
|
||||
self.mode = QComboBox()
|
||||
for value, key in MODE_KEYS:
|
||||
self.mode.addItem(tr(key), value)
|
||||
_select(self.mode, routing.get("switch_mode", "off"))
|
||||
_select(self.mode, routing.get("switch_mode", "auto")) # off/fallback cũ → mục đầu (Auto)
|
||||
form.addRow(tr("routing.settings_mode"), self.mode)
|
||||
|
||||
self.policy = QComboBox()
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Width of a navigation list whose current row is drawn bold.
|
||||
|
||||
The theme draws the selected ``sectionIndex`` row with ``font-weight: 600``
|
||||
and 10px padding each side (``theme/qss.py``). Sizing the list from the plain
|
||||
font left the selected label too wide for its row, so "Sandbox Security
|
||||
Layer" was cut off in Settings as soon as it was picked.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from PySide6.QtGui import QFont, QFontMetrics
|
||||
|
||||
#: Item padding (10px x 2) + item radius + list frame, with a little slack.
|
||||
ROW_CHROME_PX = 48
|
||||
|
||||
|
||||
def selected_label_width(widget, labels: Iterable[str]) -> int:
|
||||
"""Pixels needed to show the widest of ``labels`` in the bold selected style."""
|
||||
widget.ensurePolished()
|
||||
bold = QFont(widget.font())
|
||||
bold.setWeight(QFont.Weight.DemiBold)
|
||||
metrics = QFontMetrics(bold)
|
||||
return max((metrics.horizontalAdvance(label) for label in labels), default=0) + ROW_CHROME_PX
|
||||
|
||||
|
||||
__all__ = ["ROW_CHROME_PX", "selected_label_width"]
|
||||
@@ -42,5 +42,14 @@ def build_config(path: Path | None = None) -> JsonConfigRepository:
|
||||
|
||||
|
||||
def build_context(path: Path | None = None) -> AppContext:
|
||||
"""Dựng AppContext hoàn chỉnh — điểm vào cho ``app.run()`` và cho checker."""
|
||||
return AppContext(build_config(path))
|
||||
"""Dựng AppContext hoàn chỉnh — điểm vào cho ``app.run()`` và cho checker.
|
||||
|
||||
Nối luôn cổng mạng chung vào cấu hình SỐNG của context: đổi công tắc
|
||||
"Chặn mạng" trong Settings là có hiệu lực ngay ở lời gọi mạng kế tiếp.
|
||||
"""
|
||||
from ...application.network import network_guard
|
||||
from ...core.agent_security import sandbox_settings
|
||||
|
||||
ctx = AppContext(build_config(path))
|
||||
network_guard.bind(lambda: sandbox_settings(ctx.config)[1])
|
||||
return ctx
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Ghi lại mọi lần app chết hoặc treo, và báo "Đang xử lý…" khi giao diện đứng.
|
||||
|
||||
Trước đây app văng ra (mã 0xC0000409) mà không để lại dấu vết gì: lỗi ở tầng
|
||||
native không in traceback Python, còn cửa sổ console thì đóng ngay theo app. Mô-đun
|
||||
này ghi mọi thứ vào ``~/.cowork_local/logs/``:
|
||||
|
||||
* ``crash.log``: stack của mọi luồng lúc tiến trình chết (``faulthandler``), kèm
|
||||
exception Python không ai bắt, ở luồng chính lẫn luồng nền. Exception Python
|
||||
chỉ được ghi lại, app vẫn chạy tiếp.
|
||||
* ``qt.log``: cảnh báo, lỗi và lỗi nghiêm trọng của Qt (cũng vẫn in ra console).
|
||||
* ``hang.log``: stack của mọi luồng mỗi khi luồng giao diện đứng quá
|
||||
``HANG_SECONDS`` giây, để biết app đang kẹt ở dòng nào.
|
||||
|
||||
Luồng giao diện đang đứng thì không thể tự vẽ popup, nên popup "Đang xử lý…" do một
|
||||
luồng canh riêng mở bằng hộp thoại gốc của Windows. Giao diện chạy lại thì hộp
|
||||
thoại tự đóng.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import faulthandler
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QTimer, QtMsgType, qInstallMessageHandler
|
||||
|
||||
HANG_SECONDS = 5.0 # luồng giao diện đứng quá ngần này giây thì coi là treo
|
||||
_BEAT_MS = 500 # nhịp luồng giao diện báo "vẫn sống"
|
||||
|
||||
_crash_file = None # giữ file mở suốt đời app: faulthandler ghi vào lúc chết
|
||||
_log_dir: Optional[Path] = None
|
||||
|
||||
|
||||
def _stamp() -> str:
|
||||
"""Mốc giờ hiện tại, dùng làm tiêu đề mỗi mục trong log."""
|
||||
return datetime.datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _append(name: str, text: str) -> None:
|
||||
"""Ghi thêm vào một file log. Ghi log hỏng thì bỏ qua, không kéo app theo."""
|
||||
if _log_dir is None:
|
||||
return
|
||||
try:
|
||||
with open(_log_dir / name, "a", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _log_exception(where: str, exc_type, exc, tb) -> None:
|
||||
"""Ghi một exception không ai bắt vào crash.log và in ra console."""
|
||||
body = "".join(traceback.format_exception(exc_type, exc, tb))
|
||||
_append("crash.log", f"\n=== {_stamp()} exception chưa bắt ({where}) ===\n{body}")
|
||||
if sys.__stderr__:
|
||||
sys.__stderr__.write(body)
|
||||
|
||||
|
||||
def _qt_message(mode, context, message) -> None:
|
||||
"""Chuyển thông báo của Qt vào qt.log, vẫn in ra console như trước."""
|
||||
level = {QtMsgType.QtWarningMsg: "WARNING", QtMsgType.QtCriticalMsg: "CRITICAL",
|
||||
QtMsgType.QtFatalMsg: "FATAL"}.get(mode)
|
||||
if sys.__stderr__:
|
||||
sys.__stderr__.write(message + "\n")
|
||||
if level is None:
|
||||
return # debug/info: chỉ in, không ghi file
|
||||
_append("qt.log", f"{_stamp()} {level}: {message}\n")
|
||||
if mode == QtMsgType.QtFatalMsg and _crash_file is not None:
|
||||
faulthandler.dump_traceback(_crash_file, all_threads=True)
|
||||
|
||||
|
||||
def install(log_dir: Path) -> None:
|
||||
"""Bật ghi log crash. Gọi một lần, sớm nhất có thể sau khi có QApplication."""
|
||||
global _crash_file, _log_dir
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
_log_dir = log_dir
|
||||
_crash_file = open(log_dir / "crash.log", "a", encoding="utf-8") # noqa: SIM115
|
||||
_crash_file.write(f"\n=== {_stamp()} app khởi động ===\n")
|
||||
_crash_file.flush()
|
||||
faulthandler.enable(file=_crash_file, all_threads=True)
|
||||
sys.excepthook = lambda t, e, tb: _log_exception("luồng chính", t, e, tb)
|
||||
threading.excepthook = lambda a: _log_exception(
|
||||
f"luồng {a.thread.name if a.thread else '?'}", a.exc_type, a.exc_value, a.exc_traceback)
|
||||
qInstallMessageHandler(_qt_message)
|
||||
|
||||
|
||||
class HangWatchdog:
|
||||
"""Canh luồng giao diện: đứng quá ``HANG_SECONDS`` giây thì ghi stack vào
|
||||
hang.log và mở popup "Đang xử lý…", chạy lại thì đóng popup.
|
||||
|
||||
Luồng giao diện đều đặn cập nhật nhịp qua một ``QTimer``; một luồng nền kiểm tra
|
||||
nhịp đó. Luồng nền không đụng vào widget Qt nào.
|
||||
"""
|
||||
|
||||
def __init__(self, title: str, parent=None) -> None:
|
||||
self._title = title
|
||||
self._last_beat = time.monotonic()
|
||||
self._timer = QTimer(parent)
|
||||
self._timer.timeout.connect(self._beat)
|
||||
self._timer.start(_BEAT_MS)
|
||||
self._stop = threading.Event()
|
||||
self._popup: Optional[threading.Thread] = None
|
||||
self._popup_title = title
|
||||
threading.Thread(target=self._watch, name="hang-watchdog", daemon=True).start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Dừng canh (khi app thoát)."""
|
||||
self._stop.set()
|
||||
self._timer.stop()
|
||||
|
||||
def _beat(self) -> None:
|
||||
self._last_beat = time.monotonic()
|
||||
|
||||
def _watch(self) -> None:
|
||||
stalled = False
|
||||
while not self._stop.wait(0.5):
|
||||
lag = time.monotonic() - self._last_beat
|
||||
if lag >= HANG_SECONDS and not stalled:
|
||||
stalled = True
|
||||
self._dump_stacks(lag)
|
||||
self._show_popup()
|
||||
elif lag < HANG_SECONDS and stalled:
|
||||
stalled = False
|
||||
self._close_popup()
|
||||
|
||||
def _dump_stacks(self, lag: float) -> None:
|
||||
if _log_dir is None:
|
||||
return
|
||||
try:
|
||||
with open(_log_dir / "hang.log", "a", encoding="utf-8") as f:
|
||||
f.write(f"\n=== {_stamp()} giao diện đứng {lag:.1f}s ===\n")
|
||||
f.flush()
|
||||
faulthandler.dump_traceback(f, all_threads=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _show_popup(self) -> None:
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
import ctypes
|
||||
|
||||
from ...i18n import tr
|
||||
|
||||
# Đọc chữ NGAY LÚC hiện popup: luôn khớp ngôn ngữ đang chọn trong app,
|
||||
# kể cả khi người dùng vừa đổi ngôn ngữ.
|
||||
title, message = self._title, tr("app.hang.message")
|
||||
# MB_ICONINFORMATION | MB_TOPMOST | MB_SETFOREGROUND. MessageBoxW có vòng
|
||||
# thông điệp riêng trên luồng này, nên hiện được dù luồng giao diện đang đứng.
|
||||
flags = 0x40 | 0x40000 | 0x10000
|
||||
self._popup_title = title
|
||||
self._popup = threading.Thread(
|
||||
target=lambda: ctypes.windll.user32.MessageBoxW(None, message, title, flags),
|
||||
name="hang-popup", daemon=True)
|
||||
self._popup.start()
|
||||
|
||||
def _close_popup(self) -> None:
|
||||
if self._popup is None or sys.platform != "win32":
|
||||
return
|
||||
import ctypes
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
for _ in range(10): # hộp thoại có thể chưa kịp hiện ra
|
||||
hwnd = user32.FindWindowW(None, self._popup_title)
|
||||
if hwnd:
|
||||
user32.PostMessageW(hwnd, 0x0010, 0, 0) # WM_CLOSE
|
||||
break
|
||||
time.sleep(0.05)
|
||||
self._popup = None
|
||||
@@ -79,6 +79,32 @@ class ProjectFolderRuleMixin:
|
||||
self._gan_nhan_canh_bao_thu_muc()
|
||||
self.project_selected.connect(self._sync_folder_warning)
|
||||
|
||||
def rebind_workspace_folder(self) -> None:
|
||||
"""Thư mục làm việc vừa đổi — trỏ lại những màn đang bám vào nó.
|
||||
|
||||
Đổi thư mục KHÔNG làm project id đổi, nên không có gì trong luồng
|
||||
chọn project chạy lại: ``_pick_folder`` ghi ``output_dir`` rồi dừng.
|
||||
Tab Thư mục và màn GraphRAG vì thế giữ nguyên đường dẫn cũ — tên
|
||||
project vẫn đúng nên nhìn qua tưởng ổn, nhưng GraphRAG quét nhầm
|
||||
thư mục.
|
||||
|
||||
Cố ý KHÔNG gọi ``_load_current``: hàm đó nạp lại cả biểu mẫu từ đĩa,
|
||||
nên gọi nó lúc người dùng đang sửa dở Tên/Mô tả là xoá mất phần chưa
|
||||
lưu.
|
||||
"""
|
||||
from ...core.projects import load_project
|
||||
|
||||
pid = getattr(self, "_current_id", "")
|
||||
project = load_project(pid) if pid else None
|
||||
if project is None:
|
||||
return
|
||||
if getattr(self, "_folder", None) is not None:
|
||||
self._folder.set_project_root(str(project.workspace_dir()))
|
||||
if getattr(self, "_structure", None) is not None:
|
||||
self._structure.set_workspace_project(pid)
|
||||
# Thư mục mới có thể vừa gỡ bỏ (hoặc tạo ra) một cảnh báo dùng chung.
|
||||
self._sync_folder_warning()
|
||||
|
||||
def _gan_nhan_canh_bao_thu_muc(self) -> None:
|
||||
"""Chèn nhãn cảnh báo ngay DƯỚI hàng chứa ô Thư mục làm việc.
|
||||
|
||||
|
||||
@@ -278,10 +278,17 @@ class AnthropicProvider(Provider):
|
||||
raise ProviderError(f"Anthropic: {evt.get('error', {}).get('message', 'error')}")
|
||||
resp.close()
|
||||
break # stream finished normally (or cancelled)
|
||||
except requests.RequestException as exc:
|
||||
except Exception as exc: # noqa: BLE001 — lọc lại ngay bên dưới
|
||||
resp.close()
|
||||
# Huỷ giữa chừng đóng socket, và urllib3 ném AttributeError
|
||||
# ("'NoneType' object has no attribute 'read'") chứ KHÔNG phải
|
||||
# RequestException — bắt hẹp là lỗi đó lọt ra ngoài và người dùng
|
||||
# thấy một lỗi Python đỏ thay vì "đã dừng". Chỉ nuốt khi thật sự
|
||||
# đang huỷ; lỗi khác vẫn ném tiếp nguyên vẹn.
|
||||
if self._is_cancelled(cancel):
|
||||
break
|
||||
if not isinstance(exc, requests.RequestException):
|
||||
raise
|
||||
if text_parts or blocks:
|
||||
if on_text:
|
||||
on_text("\n⚠ Kết nối bị ngắt giữa chừng — hiển thị phần đã nhận được.\n")
|
||||
|
||||
@@ -254,13 +254,19 @@ class OpenAICompatProvider(Provider):
|
||||
slot["args"] += fn["arguments"]
|
||||
resp.close()
|
||||
break # stream finished normally (or cancelled)
|
||||
except requests.RequestException as exc:
|
||||
except Exception as exc: # noqa: BLE001 — lọc lại ngay bên dưới
|
||||
resp.close()
|
||||
# If cancel was requested, close cleanly without retry
|
||||
# Huỷ giữa chừng đóng socket, và urllib3 ném AttributeError
|
||||
# ("'NoneType' object has no attribute 'read'") chứ KHÔNG phải
|
||||
# RequestException — bắt hẹp là lỗi đó lọt ra ngoài và người dùng
|
||||
# thấy một lỗi Python đỏ thay vì "đã dừng". Chỉ nuốt khi thật sự
|
||||
# đang huỷ; lỗi khác vẫn ném tiếp nguyên vẹn.
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
break
|
||||
if self._is_cancelled(cancel):
|
||||
break
|
||||
if not isinstance(exc, requests.RequestException):
|
||||
raise
|
||||
if text_parts or tool_acc:
|
||||
# Partial answer already on screen — keep it, note the cut.
|
||||
if on_text:
|
||||
|
||||
@@ -90,7 +90,7 @@ class AppContext:
|
||||
return load_project(pid)
|
||||
|
||||
def project_routing_mode(self, surface: str) -> str:
|
||||
"""Effective Off/Auto/Manual/Fallback routing mode for a chat ``surface``
|
||||
"""Effective Auto/Manual 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.
|
||||
@@ -101,15 +101,15 @@ class AppContext:
|
||||
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
|
||||
if mode in self.config.ROUTING_MODES: # old off/fallback → auto
|
||||
return self.config.user_routing_mode(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"
|
||||
mode = self.config.user_routing_mode(mode)
|
||||
project = self._current_project()
|
||||
if project is None:
|
||||
self.config.set_routing_mode_for(surface, mode)
|
||||
@@ -234,14 +234,19 @@ class AppContext:
|
||||
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.
|
||||
if self.config.agent_security.get("block_network", False):
|
||||
return [], None
|
||||
# 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
|
||||
@@ -276,7 +281,6 @@ class AppContext:
|
||||
|
||||
# Locally-synced OneDrive/SharePoint (no sign-in) — reads/writes the
|
||||
# OneDrive-desktop-synced folders directly, gated on ms365.connectors.
|
||||
from .core.ms365_local import build_ms365_local_tools
|
||||
local_tools, local_executor = build_ms365_local_tools(self.config)
|
||||
|
||||
return combine_tool_sources((mcp_tools, mcp_executor), (ext_tools, ext_executor),
|
||||
|
||||
@@ -73,3 +73,17 @@ def _bind_checkout_as_package() -> None:
|
||||
|
||||
|
||||
_bind_checkout_as_package()
|
||||
|
||||
|
||||
import pytest # noqa: E402 - after the package binding above
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_network_guard():
|
||||
"""``build_context()`` binds the process-wide "Block network" gate to that
|
||||
context's config (default: blocked). Unbind after every test so one test
|
||||
that built a real context cannot silently block the network for the rest."""
|
||||
yield
|
||||
from cowork_local.application.network import network_guard
|
||||
|
||||
network_guard.bind(None)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Đổi tên hội thoại ở cột lịch sử thì thanh tiêu đề Cowork đổi theo ngay.
|
||||
|
||||
Trước đây nhãn tiêu đề giữ tên cũ cho tới khi người dùng mở lại hội thoại, và
|
||||
``self.title`` cũ còn ghi đè tên mới ở lượt chat kế tiếp.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.config import AppConfig # noqa: E402
|
||||
from cowork_local.presentation.chat.chat_helpers import clip_chars # noqa: E402
|
||||
from cowork_local.state import AppContext # noqa: E402
|
||||
|
||||
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cowork(qt_app, tmp_path: Path):
|
||||
from cowork_local.ui.cowork_tab import CoworkTab
|
||||
|
||||
tab = CoworkTab(AppContext(AppConfig.load(tmp_path / "config.json")))
|
||||
yield tab
|
||||
tab.deleteLater()
|
||||
|
||||
|
||||
def test_clip_chars_keeps_ten_characters():
|
||||
assert clip_chars("Tên ngắn") == "Tên ngắn"
|
||||
assert clip_chars("0123456789") == "0123456789"
|
||||
assert clip_chars("0123456789AB") == "0123456789…"
|
||||
assert clip_chars("Dự án mới toanh") == "Dự án mới…" # không để khoảng trắng trước "…"
|
||||
|
||||
|
||||
def test_rename_of_open_conversation_updates_header(cowork):
|
||||
cowork.load_conversation({"session_id": "s1", "title": "Tên cũ", "messages": []})
|
||||
assert cowork._title_lbl.text() == "Tên cũ"
|
||||
|
||||
cowork.apply_renamed_title("s1", "Tên mới")
|
||||
|
||||
assert cowork.title == "Tên mới" # lượt chat sau lưu bằng tên mới
|
||||
assert cowork._title_lbl.text() == "Tên mới"
|
||||
|
||||
|
||||
def test_rename_of_other_conversation_is_ignored(cowork):
|
||||
cowork.load_conversation({"session_id": "s1", "title": "Đang mở", "messages": []})
|
||||
cowork.apply_renamed_title("s2", "Khác")
|
||||
assert cowork._title_lbl.text() == "Đang mở"
|
||||
|
||||
|
||||
def test_long_title_is_clipped_with_full_tooltip(cowork):
|
||||
long_title = "Báo cáo doanh thu quý 3"
|
||||
cowork.load_conversation({"session_id": "s1", "title": "x", "messages": []})
|
||||
cowork.apply_renamed_title("s1", long_title)
|
||||
assert cowork._title_lbl.text() == "Báo cáo do…"
|
||||
assert cowork._title_lbl.toolTip() == long_title
|
||||
|
||||
|
||||
def test_history_list_clips_title_to_ten_chars(qt_app, tmp_path):
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from cowork_local.core.history import save_conversation
|
||||
from cowork_local.ui.sidebar import HistorySidebar
|
||||
|
||||
ctx = AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
ctx.config.data.setdefault("history", {})["custom_dir"] = str(tmp_path / "history")
|
||||
assert ctx.config.history_dir() == tmp_path / "history" # không đọc lịch sử thật
|
||||
title = "Báo cáo doanh thu quý 3"
|
||||
save_conversation(tmp_path / "history", "cowork", "s1",
|
||||
[{"role": "user", "content": "hi"}], title, project_id="p-test")
|
||||
sb = HistorySidebar(ctx)
|
||||
sb._project_filter = "p-test" # chỉ đọc history_dir() tạm, không gộp các project thật
|
||||
sb.refresh()
|
||||
items = []
|
||||
stack = [sb.tree.topLevelItem(i) for i in range(sb.tree.topLevelItemCount())]
|
||||
while stack:
|
||||
it = stack.pop()
|
||||
if it.data(0, Qt.UserRole):
|
||||
items.append(it)
|
||||
stack.extend(it.child(i) for i in range(it.childCount()))
|
||||
assert len(items) == 1
|
||||
assert items[0].text(0).splitlines()[0] == "Báo cáo do…"
|
||||
assert items[0].toolTip(0) == title
|
||||
assert items[0].data(0, Qt.UserRole + 3) == title # đổi tên vẫn điền tên đầy đủ
|
||||
sb.deleteLater()
|
||||
@@ -0,0 +1,96 @@
|
||||
"""crash_guard ghi lại exception chưa bắt và phát hiện giao diện bị treo."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from cowork_local.presentation.shell import crash_guard # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guard(tmp_path, monkeypatch):
|
||||
import faulthandler
|
||||
import sys
|
||||
|
||||
from PySide6.QtCore import qInstallMessageHandler
|
||||
|
||||
monkeypatch.setattr(sys, "excepthook", sys.excepthook)
|
||||
monkeypatch.setattr(threading, "excepthook", threading.excepthook)
|
||||
crash_guard.install(tmp_path)
|
||||
yield tmp_path
|
||||
qInstallMessageHandler(None)
|
||||
faulthandler.disable()
|
||||
crash_guard._crash_file.close()
|
||||
crash_guard._crash_file = None
|
||||
crash_guard._log_dir = None
|
||||
|
||||
|
||||
def test_uncaught_thread_exception_is_logged_not_fatal(guard):
|
||||
def boom():
|
||||
raise ValueError("lỗi luồng nền")
|
||||
|
||||
t = threading.Thread(target=boom, name="worker-x")
|
||||
t.start()
|
||||
t.join()
|
||||
log = (guard / "crash.log").read_text(encoding="utf-8")
|
||||
assert "ValueError: lỗi luồng nền" in log
|
||||
assert "worker-x" in log
|
||||
|
||||
|
||||
def test_watchdog_dumps_stacks_and_pops_up_while_gui_is_stalled(guard, qt_app, monkeypatch):
|
||||
monkeypatch.setattr(crash_guard, "HANG_SECONDS", 0.6)
|
||||
events = []
|
||||
monkeypatch.setattr(crash_guard.HangWatchdog, "_show_popup", lambda self: events.append("show"))
|
||||
monkeypatch.setattr(crash_guard.HangWatchdog, "_close_popup", lambda self: events.append("close"))
|
||||
dog = crash_guard.HangWatchdog("t")
|
||||
try:
|
||||
qt_app.processEvents()
|
||||
time.sleep(1.5) # luồng giao diện "đứng"
|
||||
assert events == ["show"]
|
||||
end = time.time() + 2
|
||||
while "close" not in events and time.time() < end:
|
||||
qt_app.processEvents() # giao diện chạy lại
|
||||
time.sleep(0.05)
|
||||
assert events == ["show", "close"]
|
||||
finally:
|
||||
dog.stop()
|
||||
assert "giao diện đứng" in (guard / "hang.log").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lang, expected", [
|
||||
("vi", "Đang xử lý"), ("en", "Processing"), ("ja", "処理中"),
|
||||
])
|
||||
def test_popup_text_follows_the_current_app_language(qt_app, monkeypatch, lang, expected):
|
||||
import sys
|
||||
import types
|
||||
|
||||
from cowork_local.i18n import get_language, set_language
|
||||
|
||||
shown = []
|
||||
fake_user32 = types.SimpleNamespace(MessageBoxW=lambda h, msg, title, f: shown.append(msg))
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
monkeypatch.setitem(sys.modules, "ctypes", types.SimpleNamespace(
|
||||
windll=types.SimpleNamespace(user32=fake_user32)))
|
||||
before = get_language()
|
||||
dog = crash_guard.HangWatchdog("t")
|
||||
try:
|
||||
set_language(lang)
|
||||
dog._show_popup()
|
||||
dog._popup.join(1)
|
||||
finally:
|
||||
dog.stop()
|
||||
set_language(before)
|
||||
assert shown and expected in shown[0]
|
||||
@@ -234,16 +234,16 @@ def test_mode_comes_from_the_workspace_when_not_pinned(ctx, app_service) -> None
|
||||
assert outcome.switched is True
|
||||
|
||||
|
||||
def test_fallback_mode_survives_a_round_trip_through_config(ctx) -> None:
|
||||
"""The new mode must be persistable, or the toggle could never select it."""
|
||||
ctx.config.set_routing_mode_for("cowork", "fallback")
|
||||
|
||||
assert ctx.config.routing_mode_for("cowork") == "fallback"
|
||||
assert ctx.project_routing_mode("cowork") == "fallback"
|
||||
def test_removed_modes_resolve_to_auto(ctx) -> None:
|
||||
"""Off/Fallback were removed from the UI: a stored value resolves to Auto."""
|
||||
for legacy in ("off", "fallback"):
|
||||
ctx.config.set_routing_mode_for("cowork", legacy)
|
||||
assert ctx.config.routing_mode_for("cowork") == "auto"
|
||||
assert ctx.project_routing_mode("cowork") == "auto"
|
||||
|
||||
|
||||
def test_unknown_persisted_mode_degrades_to_off(ctx) -> None:
|
||||
"""A hand-edited config must not enable routing by accident."""
|
||||
def test_unknown_persisted_mode_degrades_to_auto(ctx) -> None:
|
||||
"""A hand-edited config resolves to the default mode (Auto)."""
|
||||
ctx.config.routing["surface_modes"]["cowork"] = "turbo"
|
||||
|
||||
assert ctx.config.routing_mode_for("cowork") == "off"
|
||||
assert ctx.config.routing_mode_for("cowork") == "auto"
|
||||
|
||||
@@ -32,16 +32,29 @@ def _mk(ctx, name):
|
||||
def test_defaults_follow_global_when_no_override(ctx):
|
||||
a = _mk(ctx, "Alpha")
|
||||
ctx.active_project_id = a.project_id
|
||||
# Global default switch_mode is "off".
|
||||
assert ctx.project_routing_mode("cowork") == "off"
|
||||
# Global default switch_mode is "auto" (Off was removed).
|
||||
assert ctx.project_routing_mode("cowork") == "auto"
|
||||
# Change the GLOBAL default → project with no override follows it.
|
||||
ctx.config.data["routing"]["switch_mode"] = "auto"
|
||||
ctx.config.data["routing"]["switch_mode"] = "manual"
|
||||
assert ctx.project_routing_mode("cowork") == "manual"
|
||||
|
||||
|
||||
def test_legacy_off_and_fallback_resolve_to_auto(ctx):
|
||||
a = _mk(ctx, "Alpha")
|
||||
ctx.active_project_id = a.project_id
|
||||
ctx.config.data["routing"]["switch_mode"] = "manual"
|
||||
for legacy in ("off", "fallback"):
|
||||
a.routing_modes = {"cowork": legacy}
|
||||
save_project(a)
|
||||
assert ctx.project_routing_mode("cowork") == "auto"
|
||||
ctx.set_project_routing_mode("cowork", "off")
|
||||
assert ctx.project_routing_mode("cowork") == "auto"
|
||||
|
||||
|
||||
def test_per_workspace_routing_is_isolated(ctx):
|
||||
a = _mk(ctx, "Alpha")
|
||||
b = _mk(ctx, "Beta")
|
||||
ctx.config.data["routing"]["switch_mode"] = "manual"
|
||||
|
||||
ctx.active_project_id = a.project_id
|
||||
ctx.set_project_routing_mode("cowork", "auto")
|
||||
@@ -49,16 +62,19 @@ def test_per_workspace_routing_is_isolated(ctx):
|
||||
|
||||
# Switching to workspace B must NOT see A's override (falls back to global).
|
||||
ctx.active_project_id = b.project_id
|
||||
assert ctx.project_routing_mode("cowork") == "off"
|
||||
|
||||
# B sets its own, independently.
|
||||
ctx.set_project_routing_mode("cowork", "manual")
|
||||
assert ctx.project_routing_mode("cowork") == "manual"
|
||||
|
||||
# A is unchanged.
|
||||
# B sets its own, independently.
|
||||
ctx.set_project_routing_mode("cowork", "auto")
|
||||
ctx.active_project_id = a.project_id
|
||||
ctx.set_project_routing_mode("cowork", "manual")
|
||||
ctx.active_project_id = b.project_id
|
||||
assert ctx.project_routing_mode("cowork") == "auto"
|
||||
|
||||
# A keeps its own.
|
||||
ctx.active_project_id = a.project_id
|
||||
assert ctx.project_routing_mode("cowork") == "manual"
|
||||
|
||||
|
||||
def test_per_surface_isolated_within_a_workspace(ctx):
|
||||
a = _mk(ctx, "Alpha")
|
||||
@@ -68,7 +84,8 @@ def test_per_surface_isolated_within_a_workspace(ctx):
|
||||
# co4e untouched → global default.
|
||||
assert ctx.project_routing_mode("cowork") == "auto"
|
||||
assert ctx.project_routing_mode("ai_edit") == "manual"
|
||||
assert ctx.project_routing_mode("co4e") == "off"
|
||||
ctx.config.data["routing"]["switch_mode"] = "manual"
|
||||
assert ctx.project_routing_mode("co4e") == "manual"
|
||||
|
||||
|
||||
def test_routing_mode_persists_to_disk(ctx):
|
||||
|
||||
@@ -88,12 +88,21 @@ def test_best_for_returns_strong(service):
|
||||
assert ranking.best.assessment.metadata.model_id == "strong-model"
|
||||
|
||||
|
||||
def test_route_off_never_switches(service):
|
||||
def test_route_off_explicit_override_never_switches(service):
|
||||
# "off" is no longer user-selectable, but the engine still honours it
|
||||
# when passed explicitly.
|
||||
service.reassess()
|
||||
r = service.route("cowork", "Write a Python function", "anthropic", "weak-model",
|
||||
mode_override="off")
|
||||
assert r.mode == SwitchMode.OFF
|
||||
assert r.should_switch is False
|
||||
|
||||
|
||||
def test_legacy_off_in_config_routes_as_auto(service):
|
||||
service.reassess()
|
||||
service.ctx.config.data["routing"]["switch_mode"] = "off"
|
||||
r = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
|
||||
assert r.mode == SwitchMode.OFF
|
||||
assert r.should_switch is False
|
||||
assert r.mode == SwitchMode.AUTO
|
||||
|
||||
|
||||
def test_route_auto_switches_to_strong(service):
|
||||
@@ -147,12 +156,12 @@ def test_route_never_raises_on_broken_store(ctx, tmp_path):
|
||||
|
||||
def test_per_surface_mode_override(service):
|
||||
service.reassess()
|
||||
service.ctx.config.data["routing"]["switch_mode"] = "off"
|
||||
service.ctx.config.data["routing"]["switch_mode"] = "manual"
|
||||
service.ctx.config.data["routing"]["surface_modes"]["co4e"] = "auto"
|
||||
# cowork follows global (off); co4e overridden to auto
|
||||
# cowork follows global (manual); co4e overridden to auto
|
||||
r_cowork = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
|
||||
r_co4e = service.route("co4e", "Write a Python function", "anthropic", "weak-model")
|
||||
assert r_cowork.mode == SwitchMode.OFF
|
||||
assert r_cowork.mode == SwitchMode.MANUAL
|
||||
assert r_co4e.mode == SwitchMode.AUTO
|
||||
assert r_co4e.should_switch is True
|
||||
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
"""Công tắc "Chặn mạng" phải chặn mọi đường ra mạng của app, TRỪ nhà cung cấp AI.
|
||||
|
||||
Bản đồ ``infura/network-map.html`` liệt kê các làn trước đây "không kiểm soát"
|
||||
hoặc chỉ chặn một phần: M365, Teams và nút Test, connector/MCP đang chạy,
|
||||
task script và link đính kèm task, tự cài thư viện, xem trước HTML, lệnh shell
|
||||
chỉ bị proxy giả. Mỗi làn có ít nhất một bài ở đây:
|
||||
|
||||
* **bật** — làn từ chối TRƯỚC khi chạm mạng (mọi lời gọi HTTP thật đều nổ);
|
||||
* **tắt** — đường cũ giữ nguyên, vì chặn một chiều là hỏng tính năng.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.application.network import network_guard
|
||||
|
||||
|
||||
def _no_network(*args, **kwargs):
|
||||
raise AssertionError("đã chạm mạng dù 'Chặn mạng' đang bật")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chan_mang(monkeypatch):
|
||||
"""Bật công tắc qua cổng chung và làm nổ mọi lời gọi HTTP thật."""
|
||||
import requests
|
||||
|
||||
from cowork_local.core import tls_trust
|
||||
|
||||
for name in ("request", "get", "post", "put", "patch", "delete"):
|
||||
monkeypatch.setattr(requests, name, _no_network)
|
||||
monkeypatch.setattr(tls_trust, "request", _no_network)
|
||||
monkeypatch.setattr(tls_trust, "request_any_method", _no_network)
|
||||
network_guard.bind(lambda: True)
|
||||
yield
|
||||
network_guard.bind(None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _go_cong_sau_moi_bai():
|
||||
yield
|
||||
network_guard.bind(None)
|
||||
|
||||
|
||||
# ---- cổng chung ------------------------------------------------------------
|
||||
|
||||
def test_chua_noi_day_thi_khong_chan():
|
||||
network_guard.bind(None)
|
||||
assert network_guard.is_blocked() is False
|
||||
network_guard.ensure_allowed("x")
|
||||
|
||||
|
||||
def test_doc_cong_tac_loi_thi_coi_nhu_dang_chan():
|
||||
"""Không đọc được công tắc thì đóng cửa, không mở toang."""
|
||||
def hong():
|
||||
raise KeyError("agent_security")
|
||||
|
||||
network_guard.bind(hong)
|
||||
with pytest.raises(network_guard.NetworkBlockedError):
|
||||
network_guard.ensure_allowed("Teams")
|
||||
|
||||
|
||||
def test_cong_doc_cau_hinh_song_khong_chup_lai(tmp_path):
|
||||
"""Đổi công tắc trong Settings có hiệu lực ngay, không cần mở lại app."""
|
||||
from cowork_local.presentation.shell.bootstrap import build_context
|
||||
|
||||
ctx = build_context(tmp_path / "config.json")
|
||||
ctx.config.agent_security["block_network"] = True
|
||||
assert network_guard.is_blocked() is True
|
||||
ctx.config.agent_security["block_network"] = False
|
||||
assert network_guard.is_blocked() is False
|
||||
|
||||
|
||||
# ---- Microsoft 365 -----------------------------------------------------------
|
||||
|
||||
def test_graph_tu_choi_bang_loi_ma_noi_goi_da_bat(chan_mang):
|
||||
from cowork_local.core import ms365_graph
|
||||
|
||||
with pytest.raises(ms365_graph.Ms365GraphError, match="Sandbox Security Layer"):
|
||||
ms365_graph.list_onedrive_files("token")
|
||||
|
||||
|
||||
def test_dang_nhap_va_lay_token_tu_choi(chan_mang, monkeypatch):
|
||||
from cowork_local.core import ms365_auth
|
||||
|
||||
monkeypatch.setattr(ms365_auth, "_app", _no_network)
|
||||
with pytest.raises(ms365_auth.Ms365AuthError, match="blocked"):
|
||||
ms365_auth.get_access_token("", "")
|
||||
with pytest.raises(ms365_auth.Ms365AuthError, match="blocked"):
|
||||
ms365_auth.sign_in_device_code("", "", lambda flow: None)
|
||||
|
||||
|
||||
def test_kiem_tra_da_dang_nhap_khong_dung_msal_khi_chan(chan_mang, monkeypatch):
|
||||
"""Dựng app MSAL là tải cấu hình OpenID của tenant — phải đọc kho token trực tiếp."""
|
||||
from cowork_local.core import ms365_auth
|
||||
|
||||
monkeypatch.setattr(ms365_auth, "_app", _no_network)
|
||||
monkeypatch.setattr(ms365_auth, "_cached_account_offline",
|
||||
lambda: {"username": "a@b.c"})
|
||||
assert ms365_auth.signed_in_account("", "") == {"username": "a@b.c"}
|
||||
|
||||
|
||||
def test_mail_canh_bao_khong_gui_khi_chan(chan_mang, monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from cowork_local.core import agent_security_alert, ms365_auth
|
||||
from cowork_local.core.agent_security_types import SecurityVerdict
|
||||
|
||||
monkeypatch.setattr(ms365_auth, "_app", _no_network)
|
||||
config = SimpleNamespace(data={"agent_security": {"admin_email": "admin@x.y"}}, ms365={})
|
||||
verdict = SecurityVerdict(allowed=False, layer="command", reason="test")
|
||||
sent, note = agent_security_alert.notify_admin(config, verdict)
|
||||
assert sent is False
|
||||
assert "blocked" in note
|
||||
|
||||
|
||||
# ---- Teams, connector REST, Jira, nút Test ---------------------------------
|
||||
|
||||
def test_teams_tu_choi(chan_mang):
|
||||
from cowork_local.core.teams import TeamsNotifier
|
||||
|
||||
ok, detail = TeamsNotifier("https://x.webhook.office.com/hook").send("t", "b")
|
||||
assert ok is False
|
||||
assert "blocked" in detail
|
||||
|
||||
|
||||
def test_connector_rest_va_nut_test_tu_choi(chan_mang):
|
||||
from cowork_local.core.ext_connectors import RestApiConnector
|
||||
|
||||
rc = RestApiConnector({"id": "erp", "name": "ERP", "base_url": "https://erp.example"})
|
||||
assert rc.call({"method": "GET", "path": "items"})["ok"] is False
|
||||
ok, message = rc.test_connection()
|
||||
assert ok is False and "blocked" in message
|
||||
|
||||
|
||||
def test_nut_test_jira_tu_choi(chan_mang):
|
||||
from cowork_local.core import jira_tool
|
||||
|
||||
cfg = {"base_url": "https://jira.example", "email": "a@b.c", "api_token": "t"}
|
||||
out = jira_tool.search(cfg, "order by created DESC", 1)
|
||||
assert out.startswith("Jira search failed") and "blocked" in out
|
||||
|
||||
|
||||
# ---- MCP -------------------------------------------------------------------
|
||||
|
||||
def test_mcp_khong_khoi_dong_va_khong_goi_khi_chan(chan_mang):
|
||||
from cowork_local.core.mcp_client import McpServerConnection, McpServerError
|
||||
|
||||
conn = McpServerConnection("srv", "definitely-not-run")
|
||||
with pytest.raises(McpServerError, match="blocked"):
|
||||
conn.start(timeout=1)
|
||||
assert conn.call_tool("srv__search", {})["ok"] is False
|
||||
|
||||
|
||||
def _context(tmp_path, block: bool):
|
||||
from cowork_local.config import AppConfig
|
||||
from cowork_local.state import AppContext
|
||||
|
||||
ctx = AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
ctx.config.agent_security["block_network"] = block
|
||||
return ctx
|
||||
|
||||
|
||||
def test_chuan_bi_connector_dung_mcp_dang_chay_va_giu_onedrive_cuc_bo(tmp_path, monkeypatch):
|
||||
from cowork_local.core import ms365_local
|
||||
|
||||
ctx = _context(tmp_path, block=True)
|
||||
stopped = []
|
||||
monkeypatch.setattr(ctx, "stop_mcp_connections", lambda: stopped.append(True))
|
||||
local = (["ms365_local__onedrive_list"], object())
|
||||
monkeypatch.setattr(ms365_local, "build_ms365_local_tools", lambda config: local)
|
||||
monkeypatch.setattr(ctx._mcp_manager, "ensure", _no_network)
|
||||
|
||||
assert ctx.build_mcp_tools() == local
|
||||
assert stopped == [True]
|
||||
|
||||
|
||||
# ---- task lập lịch -----------------------------------------------------------
|
||||
|
||||
def test_link_dinh_kem_task_khong_tai(chan_mang):
|
||||
from cowork_local.core.tasks import resolve_input_text
|
||||
|
||||
text = resolve_input_text({"input": {"mode": "empty", "links": ["https://example.com/a"]}})
|
||||
assert "not fetched" in text
|
||||
|
||||
|
||||
def test_task_script_chay_trong_tien_trinh_khong_mang(chan_mang, monkeypatch, tmp_path):
|
||||
from cowork_local.core import deps, task_script
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return 0, "done", False, False, False
|
||||
|
||||
monkeypatch.setattr(deps, "run_cancellable", fake_run)
|
||||
assert task_script.run_script("echo hi", tmp_path, 30) == "done"
|
||||
assert calls and calls[0]["isolate_network"] is True
|
||||
|
||||
|
||||
def test_task_script_khong_co_lap_duoc_thi_khong_chay(chan_mang, monkeypatch, tmp_path):
|
||||
from cowork_local.core import deps, task_script
|
||||
|
||||
monkeypatch.setattr(deps, "run_cancellable",
|
||||
lambda command, **kw: (None, "no AppContainer", False, False, False))
|
||||
with pytest.raises(RuntimeError, match="could not be isolated"):
|
||||
task_script.run_script("echo hi", tmp_path, 30)
|
||||
|
||||
|
||||
def test_tat_chan_mang_thi_task_script_chay_nhu_cu(tmp_path):
|
||||
from cowork_local.core import task_script
|
||||
|
||||
assert "hi" in task_script.run_script("echo hi", tmp_path, 30)
|
||||
|
||||
|
||||
# ---- tự cài thư viện ---------------------------------------------------------
|
||||
|
||||
def test_khong_tu_cai_thu_vien_khi_chan(chan_mang, monkeypatch):
|
||||
from cowork_local.core import deps
|
||||
|
||||
monkeypatch.setattr(deps, "run_cancellable", _no_network)
|
||||
assert deps.ensure_module("khong_ton_tai_xyz_123") is None
|
||||
assert "khong_ton_tai_xyz_123" not in deps._FAILED # thử lại khi mở mạng
|
||||
ok, detail = deps.pip_install("requests")
|
||||
assert ok is False and "blocked" in detail
|
||||
|
||||
|
||||
def test_pptx_khong_bi_nho_la_thieu_khi_chi_do_chan_mang(chan_mang, monkeypatch):
|
||||
from cowork_local.application.workspaces import file_preview_helpers as fph
|
||||
from cowork_local.core import deps
|
||||
|
||||
monkeypatch.setattr(fph, "_PPTX_READY", None)
|
||||
monkeypatch.setattr(deps, "ensure_module", lambda *a, **k: None)
|
||||
assert fph.pptx_available() is False
|
||||
assert fph._PPTX_READY is None
|
||||
|
||||
|
||||
# ---- lệnh shell của agent ----------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("backend", ["direct", "integrity_job_wfp", "appcontainer", "windows_sandbox"])
|
||||
def test_moi_backend_deu_chay_co_lap_mang(monkeypatch, tmp_path, backend):
|
||||
"""Các backend cũ chỉ đặt biến proxy — khi chặn mạng, tất cả đi qua lớp cô lập của OS."""
|
||||
from cowork_local.core import deps
|
||||
from cowork_local.core.sandbox_manager import SandboxManager
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return 0, "ok", False, False, False
|
||||
|
||||
monkeypatch.setattr(deps, "run_cancellable", fake_run)
|
||||
result = SandboxManager()._execute_with_backend(backend, "echo hi", str(tmp_path), True, 30)
|
||||
assert calls and calls[0]["isolate_network"] is True
|
||||
assert result["ok"] is True and result["sandbox"] == "network_isolated"
|
||||
|
||||
|
||||
def test_lenh_khong_co_lap_duoc_thi_bi_tu_choi(monkeypatch, tmp_path):
|
||||
from cowork_local.core import deps
|
||||
from cowork_local.core.sandbox_manager import SandboxManager
|
||||
|
||||
monkeypatch.setattr(deps, "run_cancellable",
|
||||
lambda command, **kw: (None, "no isolation", False, False, False))
|
||||
result = SandboxManager()._execute_with_backend("direct", "echo hi", str(tmp_path), True, 30)
|
||||
assert result["ok"] is False and result["sandbox"] == "blocked"
|
||||
|
||||
|
||||
def test_he_dieu_hanh_khong_ho_tro_thi_bao_loi_khong_chay(monkeypatch):
|
||||
from cowork_local.infrastructure.sandbox import network_isolation
|
||||
|
||||
monkeypatch.setattr(network_isolation.sys, "platform", "sunos5")
|
||||
with pytest.raises(network_isolation.NetworkIsolationUnavailable):
|
||||
network_isolation.spawn_without_network("echo hi", None, None)
|
||||
|
||||
|
||||
def _listening_socket():
|
||||
server = socket.socket()
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(4)
|
||||
def serve():
|
||||
try:
|
||||
for _ in range(4):
|
||||
server.accept()
|
||||
except OSError:
|
||||
pass # socket closed at the end of the test
|
||||
|
||||
threading.Thread(target=serve, daemon=True).start()
|
||||
return server
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="AppContainer chỉ có trên Windows")
|
||||
def test_appcontainer_that_su_cat_mang_nhung_van_ghi_duoc_thu_muc(tmp_path):
|
||||
"""Bài thật, không giả lập: cùng một lệnh Python nối tới một cổng đang nghe,
|
||||
ngoài sandbox thì được, trong sandbox thì bị kernel từ chối."""
|
||||
from cowork_local.core.deps import run_cancellable
|
||||
|
||||
server = _listening_socket()
|
||||
port = server.getsockname()[1]
|
||||
probe = (f'"{sys.executable}" -c "import socket;'
|
||||
"print('PYTHON'+'_RAN');"
|
||||
f"socket.create_connection(('127.0.0.1',{port}),5);print('CONN'+'ECTED')\"")
|
||||
try:
|
||||
rc, out, *_ = run_cancellable(probe, cwd=str(tmp_path), timeout=60, shell=True)
|
||||
assert rc == 0 and "CONNECTED" in out
|
||||
|
||||
rc, out, *_ = run_cancellable(probe + " & echo written> marker.txt",
|
||||
cwd=str(tmp_path), timeout=60, isolate_network=True)
|
||||
assert "PYTHON_RAN" in out, out # Python itself starts in the sandbox
|
||||
assert "CONNECTED" not in out, out
|
||||
assert (Path(tmp_path) / "marker.txt").read_text().strip() == "written"
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
|
||||
# ---- xem trước HTML ------------------------------------------------------------
|
||||
|
||||
class _FakeUrl:
|
||||
def __init__(self, scheme):
|
||||
self._scheme = scheme
|
||||
|
||||
def scheme(self):
|
||||
return self._scheme
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
def __init__(self, scheme):
|
||||
self._url = _FakeUrl(scheme)
|
||||
self.blocked = False
|
||||
|
||||
def requestUrl(self): # noqa: N802 - Qt name
|
||||
return self._url
|
||||
|
||||
def block(self, flag):
|
||||
self.blocked = flag
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scheme,expected", [
|
||||
("https", True), ("http", True), ("wss", True), ("file", False), ("data", False),
|
||||
])
|
||||
def test_xem_truoc_html_chan_tai_nguyen_web(chan_mang, scheme, expected):
|
||||
pytest.importorskip("PySide6.QtWebEngineCore")
|
||||
from cowork_local.presentation.folder.offline_web_page import RemoteRequestBlocker
|
||||
|
||||
req = _FakeRequest(scheme)
|
||||
RemoteRequestBlocker().interceptRequest(req)
|
||||
assert req.blocked is expected
|
||||
|
||||
|
||||
def test_xem_truoc_html_tai_binh_thuong_khi_tat():
|
||||
pytest.importorskip("PySide6.QtWebEngineCore")
|
||||
from cowork_local.presentation.folder.offline_web_page import RemoteRequestBlocker
|
||||
|
||||
req = _FakeRequest("https")
|
||||
RemoteRequestBlocker().interceptRequest(req)
|
||||
assert req.blocked is False
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="AppContainer chỉ có trên Windows")
|
||||
def test_khong_cap_quyen_ke_thua_len_thu_muc_chua_qt(tmp_path, monkeypatch):
|
||||
"""Quyền AppContainer kế thừa xuống Qt6WebEngineCore.dll làm tiến trình render
|
||||
của WebEngine không nạp được DLL — tab Graph và xem trước HTML trắng trơn."""
|
||||
from cowork_local.infrastructure.sandbox import appcontainer_process as ac
|
||||
|
||||
qt_dir = tmp_path / "venv" / "Lib" / "site-packages" / "PySide6"
|
||||
qt_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(ac, "_qt_package_dir", lambda: str(qt_dir))
|
||||
monkeypatch.setattr(ac, "_icacls", _no_network)
|
||||
|
||||
with pytest.raises(ac.NetworkIsolationUnavailable, match="Qt runtime"):
|
||||
ac.grant_access(str(tmp_path / "venv"), "S-1-15-2-1", "read")
|
||||
with pytest.raises(ac.NetworkIsolationUnavailable, match="Qt runtime"):
|
||||
ac.grant_access(str(tmp_path), "S-1-15-2-1", "write")
|
||||
|
||||
|
||||
def test_quyen_cho_python_khong_bao_gio_phu_len_thu_vien_qt(monkeypatch, tmp_path):
|
||||
from cowork_local.infrastructure.sandbox import appcontainer_process as ac
|
||||
|
||||
venv, base = tmp_path / "venv", tmp_path / "base"
|
||||
monkeypatch.setattr(ac.sys, "prefix", str(venv))
|
||||
monkeypatch.setattr(ac.sys, "base_prefix", str(base))
|
||||
for qt_dir in (venv / "Lib" / "site-packages" / "PySide6",
|
||||
base / "Lib" / "site-packages" / "PySide6"):
|
||||
monkeypatch.setattr(ac, "_qt_package_dir", lambda d=qt_dir: str(d))
|
||||
for folder, mode in ac._interpreter_grants():
|
||||
assert mode == "read_here" or not ac._covers(folder, str(qt_dir)), (folder, mode)
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Tiêu đề hội thoại không được cắt vào giữa một từ.
|
||||
|
||||
Triệu chứng người dùng báo: thanh tiêu đề màn Cowork hiện
|
||||
|
||||
Đọc các tệp trong thư mục của project này và tóm tắt từng tệ…
|
||||
|
||||
Câu gốc dài 61 ký tự, mốc cắt cứng ở 60 rơi đúng vào giữa chữ "tệp" và bỏ mất
|
||||
đúng một chữ cái. Người đọc thấy "tệ…" chứ không thấy "tệp", nên nó đọc ra như
|
||||
lỗi gõ chứ không như một câu bị rút gọn.
|
||||
|
||||
``shorten_title`` viết nốt từ đang dở thay vì cắt ngang nó, và chỉ thêm dấu ba
|
||||
chấm khi thật sự có chữ bị bỏ đi.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.core.history import (
|
||||
TITLE_MAX_CHARS, _TITLE_SLACK, derive_title, shorten_title,
|
||||
)
|
||||
|
||||
#: Đúng câu trong ảnh người dùng gửi — 61 ký tự, vượt giới hạn đúng 1.
|
||||
CAU_TRONG_ANH = "Đọc các tệp trong thư mục của project này và tóm tắt từng tệp"
|
||||
|
||||
|
||||
def test_dung_ca_nguoi_dung_bao():
|
||||
"""Bài đỏ trước khi sửa: cắt cứng cho ra "…từng tệ…"."""
|
||||
assert len(CAU_TRONG_ANH) == TITLE_MAX_CHARS + 1
|
||||
|
||||
ket_qua = shorten_title(CAU_TRONG_ANH)
|
||||
|
||||
assert ket_qua.endswith("tệp"), ket_qua
|
||||
assert "tệ…" not in ket_qua
|
||||
# Không chữ nào bị bỏ thì không được thêm dấu ba chấm — dấu đó là nói dối.
|
||||
assert ket_qua == CAU_TRONG_ANH
|
||||
|
||||
|
||||
def test_ngan_hon_gioi_han_thi_giu_nguyen():
|
||||
assert shorten_title("Tiêu đề ngắn") == "Tiêu đề ngắn"
|
||||
|
||||
|
||||
def test_dung_bang_gioi_han_thi_giu_nguyen():
|
||||
text = "x" * TITLE_MAX_CHARS
|
||||
assert shorten_title(text) == text
|
||||
|
||||
|
||||
def test_moc_cat_roi_dung_giua_hai_tu_thi_cat_ngay_do():
|
||||
text = "x" * TITLE_MAX_CHARS + " còn nữa"
|
||||
|
||||
assert shorten_title(text) == "x" * TITLE_MAX_CHARS + "…"
|
||||
|
||||
|
||||
def test_viet_not_tu_roi_van_con_chu_phia_sau_thi_co_ba_cham():
|
||||
text = "x" * 57 + " abcdefgh ijk"
|
||||
|
||||
ket_qua = shorten_title(text)
|
||||
|
||||
assert ket_qua == "x" * 57 + " abcdefgh…"
|
||||
|
||||
|
||||
def test_tu_dai_bat_thuong_thi_lui_ve_ranh_gioi_truoc():
|
||||
"""Một đường dẫn hay URL dài không được kéo tiêu đề dài ra tuỳ ý."""
|
||||
text = "x" * 57 + " " + "y" * 40 + " z"
|
||||
|
||||
ket_qua = shorten_title(text)
|
||||
|
||||
assert ket_qua == "x" * 57 + "…"
|
||||
assert len(ket_qua) <= TITLE_MAX_CHARS + 1
|
||||
|
||||
|
||||
def test_ca_tieu_de_chi_la_mot_tu_dai_thi_danh_cat_cung():
|
||||
"""Không còn ranh giới từ nào để bám — cắt cứng là lối ra duy nhất."""
|
||||
text = "y" * 100
|
||||
|
||||
assert shorten_title(text) == "y" * TITLE_MAX_CHARS + "…"
|
||||
|
||||
|
||||
def test_khong_bao_gio_vuot_qua_gioi_han_cong_slack():
|
||||
text = "x" * 55 + " " + "y" * 11 + " phần đuôi còn dài nữa"
|
||||
|
||||
assert len(shorten_title(text)) <= TITLE_MAX_CHARS + _TITLE_SLACK + 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
CAU_TRONG_ANH,
|
||||
"Phân tích bảng tính doanh thu quý bốn và lập báo cáo tổng hợp cho ban giám đốc",
|
||||
"Tóm tắt toàn bộ tài liệu kỹ thuật trong thư mục rồi xuất ra một tệp markdown",
|
||||
"a bb ccc dddd eeeee ffffff ggggggg hhhhhhhh iiiiiiiii jjjjjjjjjj kkkkkkkkkkk",
|
||||
])
|
||||
def test_ket_qua_luon_ket_thuc_o_ranh_gioi_tu(text):
|
||||
"""Bất biến của cả hàm: phần chữ giữ lại phải là một tiền tố kết thúc đúng
|
||||
chỗ một từ kết thúc trong câu gốc — không bao giờ là nửa từ."""
|
||||
ket_qua = shorten_title(text)
|
||||
giu_lai = ket_qua[:-1] if ket_qua.endswith("…") else ket_qua
|
||||
|
||||
assert text.startswith(giu_lai), "kết quả không còn là tiền tố của câu gốc"
|
||||
assert len(giu_lai) == len(text) or text[len(giu_lai)].isspace(), (
|
||||
f"cắt vào giữa từ: ...{giu_lai[-12:]!r} | còn lại {text[len(giu_lai):][:8]!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_derive_title_dung_cung_mot_luat():
|
||||
"""Tiêu đề lưu vào lịch sử và tiêu đề trên thanh tiêu đề phải khớp nhau."""
|
||||
messages = [{"role": "user", "content": CAU_TRONG_ANH}]
|
||||
|
||||
assert derive_title(messages) == shorten_title(CAU_TRONG_ANH)
|
||||
|
||||
|
||||
def test_derive_title_van_gom_khoang_trang_thua():
|
||||
"""Hành vi cũ phải giữ: xuống dòng và khoảng trắng thừa gộp về một dấu cách."""
|
||||
tin_nhan = """ Dòng một
|
||||
|
||||
Dòng hai """
|
||||
messages = [{"role": "user", "content": tin_nhan}]
|
||||
|
||||
assert derive_title(messages) == "Dòng một Dòng hai"
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Đổi thư mục làm việc thì màn GraphRAG phải trỏ theo thư mục mới.
|
||||
|
||||
Triệu chứng: ở tab Project bấm "Đổi" sang một đường dẫn khác — ô Thư mục làm
|
||||
việc cập nhật ngay, tên project vẫn đúng, nên nhìn qua tưởng xong. Nhưng màn
|
||||
GraphRAG vẫn giữ đường dẫn cũ và quét nhầm thư mục.
|
||||
|
||||
Nguyên nhân có hai mảnh, thiếu mảnh nào cũng vẫn hỏng:
|
||||
|
||||
* ``_pick_folder`` ghi ``output_dir`` rồi dừng — đổi thư mục không làm project
|
||||
id đổi nên không có gì trong luồng chọn project chạy lại.
|
||||
* Kể cả có chạy lại, ``StructureGraphView.set_workspace_project`` ngày trước
|
||||
chốt theo MỖI project id, nên cùng một project là nó thoát ra ngay. Tab Thư
|
||||
mục vốn đã chốt theo ĐƯỜNG DẪN — hai nơi tưởng cùng luật mà thật ra không.
|
||||
|
||||
Giống ``test_graphrag_project_persists.py``: KHÔNG dựng renderer thật, vì nó
|
||||
kéo theo QtWebEngine — dựng nó trong bộ ``tests/ui`` làm cả bộ chết giữa chừng.
|
||||
Thứ cần chốt ở đây là luồng điều khiển, không phải phần vẽ.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6", reason="cần PySide6 để dựng widget thật")
|
||||
|
||||
from cowork_local.core import projects as projects_mod
|
||||
from cowork_local.core.projects import Project
|
||||
|
||||
|
||||
class _RendererGhi:
|
||||
"""Thay GraphRenderer — chỉ ghi lại nó bị áp project mấy lần."""
|
||||
|
||||
def __init__(self):
|
||||
self.lan_ap = []
|
||||
|
||||
def set_project(self, project_id):
|
||||
self.lan_ap.append(project_id)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def view(qapp):
|
||||
"""``StructureGraphView`` với renderer bị thay, dựng qua ``__new__``."""
|
||||
from cowork_local.presentation.graph.structure_graph_view import StructureGraphView
|
||||
|
||||
v = StructureGraphView.__new__(StructureGraphView)
|
||||
v._workspace_project = None
|
||||
v._workspace_dir = None
|
||||
v.renderer = _RendererGhi()
|
||||
return v
|
||||
|
||||
|
||||
def _kho_mot_project(monkeypatch, thu_muc) -> Project:
|
||||
"""Kho project giả gồm đúng một project trỏ vào ``thu_muc``."""
|
||||
du_an = Project(project_id="p1", name="Mynt4Project1", output_dir=str(thu_muc))
|
||||
monkeypatch.setattr(projects_mod, "list_projects", lambda directory=None: [du_an])
|
||||
monkeypatch.setattr(projects_mod, "load_project",
|
||||
lambda pid, directory=None: du_an if pid == "p1" else None)
|
||||
monkeypatch.setattr(projects_mod, "save_project", lambda p, directory=None: None)
|
||||
return du_an
|
||||
|
||||
|
||||
# ---- mảnh 1: chốt của GraphRAG phải nhìn cả đường dẫn -------------------
|
||||
|
||||
def test_cung_project_nhung_thu_muc_moi_thi_ap_lai(view, monkeypatch, tmp_path):
|
||||
"""Đây là chỗ chốt cũ bỏ lọt: id giống nhau nhưng đường dẫn đã khác."""
|
||||
du_an = _kho_mot_project(monkeypatch, tmp_path / "cu")
|
||||
view.set_workspace_project("p1")
|
||||
assert view.renderer.lan_ap == ["p1"]
|
||||
|
||||
du_an.output_dir = str(tmp_path / "moi")
|
||||
view.set_workspace_project("p1")
|
||||
|
||||
assert view.renderer.lan_ap == ["p1", "p1"], (
|
||||
"đổi thư mục xong mà GraphRAG không được áp lại — sẽ quét nhầm chỗ")
|
||||
|
||||
|
||||
def test_cung_project_cung_thu_muc_thi_khong_ap_lai(view, monkeypatch, tmp_path):
|
||||
"""Chốt cũ phải giữ nguyên: mỗi lần vào lại màn Workspace,
|
||||
``_bind_project`` chạy lại — áp vô điều kiện là kéo bộ chọn project của
|
||||
chính màn GraphRAG về theo, chọn xong chuyển tab là mất."""
|
||||
_kho_mot_project(monkeypatch, tmp_path / "yen")
|
||||
view.set_workspace_project("p1")
|
||||
|
||||
view.set_workspace_project("p1")
|
||||
view.set_workspace_project("p1")
|
||||
|
||||
assert view.renderer.lan_ap == ["p1"]
|
||||
|
||||
|
||||
def test_doi_sang_project_khac_van_ap(view, monkeypatch, tmp_path):
|
||||
"""Hành vi vốn có: đổi sang project khác thì vẫn phải áp."""
|
||||
_kho_mot_project(monkeypatch, tmp_path / "a")
|
||||
view.set_workspace_project("p1")
|
||||
|
||||
view.set_workspace_project("p2")
|
||||
|
||||
assert view.renderer.lan_ap == ["p1", "p2"]
|
||||
|
||||
|
||||
# ---- mảnh 2: đổi thư mục phải kích hoạt việc trỏ lại --------------------
|
||||
|
||||
class _StructureGhi:
|
||||
"""Thay cả màn GraphRAG — ghi lại nó được trỏ lại vào project nào."""
|
||||
|
||||
def __init__(self):
|
||||
self.lan_tro = []
|
||||
|
||||
def set_workspace_project(self, project_id):
|
||||
self.lan_tro.append(project_id)
|
||||
|
||||
|
||||
class _FolderGhi:
|
||||
"""Thay tab Thư mục — ghi lại nó được trỏ vào đường dẫn nào."""
|
||||
|
||||
def __init__(self):
|
||||
self.lan_tro = []
|
||||
|
||||
def set_project_root(self, path):
|
||||
self.lan_tro.append(path)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ws(qapp, tmp_path_factory):
|
||||
"""Màn Workspace thật, nhưng KHÔNG mở tab GraphRAG (xem docstring đầu file)."""
|
||||
from cowork_local.presentation.shell.bootstrap import build_config, build_context
|
||||
from cowork_local.presentation.shell.main_window import MainWindow
|
||||
|
||||
config_path = tmp_path_factory.mktemp("cfg") / "config.json"
|
||||
build_config(config_path)
|
||||
window = MainWindow(build_context(config_path))
|
||||
yield window.workspace
|
||||
window.close()
|
||||
|
||||
|
||||
def test_bam_doi_thu_muc_thi_graphrag_duoc_tro_lai(qapp, ws, monkeypatch, tmp_path):
|
||||
"""Đúng thao tác người dùng báo: bấm "Đổi" ở tab Project."""
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from cowork_local.ui import workspace_tab as wt
|
||||
|
||||
cu, moi = tmp_path / "thu-muc-cu", tmp_path / "thu-muc-moi"
|
||||
cu.mkdir()
|
||||
moi.mkdir()
|
||||
du_an = _kho_mot_project(monkeypatch, cu)
|
||||
structure, folder = _StructureGhi(), _FolderGhi()
|
||||
monkeypatch.setattr(ws, "_structure", structure, raising=False)
|
||||
monkeypatch.setattr(ws, "_folder", folder, raising=False)
|
||||
ws._current_id = "p1"
|
||||
|
||||
monkeypatch.setattr(QFileDialog, "getExistingDirectory",
|
||||
staticmethod(lambda *a, **k: str(moi)))
|
||||
wt.WorkspaceTab._pick_folder(ws)
|
||||
|
||||
assert du_an.output_dir == str(moi), "chưa ghi thư mục mới"
|
||||
assert ws.folder_lbl.text() == str(moi)
|
||||
assert structure.lan_tro == ["p1"], (
|
||||
"GraphRAG không được trỏ lại — sẽ giữ đường dẫn cũ và quét nhầm")
|
||||
assert folder.lan_tro == [str(moi)], "tab Thư mục cũng phải trỏ theo"
|
||||
@@ -31,6 +31,7 @@ def view(qapp, monkeypatch):
|
||||
|
||||
v = StructureGraphView.__new__(StructureGraphView)
|
||||
v._workspace_project = None
|
||||
v._workspace_dir = None # chốt còn theo cả đường dẫn, không chỉ id
|
||||
v.renderer = _Ghi()
|
||||
return v
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Đổi thư mục project thì đồ thị giữa màn GraphRAG phải quét lại.
|
||||
|
||||
Nối tiếp ``test_graphrag_follows_folder_change.py``. Sau khi đường dẫn trên
|
||||
thanh đã trỏ đúng thư mục mới, các node ở giữa màn vẫn là của thư mục cũ: không
|
||||
có lệnh quét lại nào được phát ra.
|
||||
|
||||
Nguyên nhân cùng một họ với hai mảnh trước — câu hỏi "có gì đổi không" được trả
|
||||
lời bằng project id chứ không bằng thứ thật sự quyết định kết quả quét:
|
||||
|
||||
project_changed = pid != self._active_project_id
|
||||
|
||||
Đổi thư mục làm việc ở màn Project giữ nguyên id, nên ``project_changed`` là
|
||||
False và cả khối phát tín hiệu lẫn khối gọi ``_scan()`` đều bị bỏ qua.
|
||||
|
||||
Cố ý KHÔNG dựng ``GraphRenderer`` thật: nó kéo theo QtWebEngine, dựng trong bộ
|
||||
``tests/ui`` làm cả bộ chết giữa chừng (xem docstring của
|
||||
``test_graphrag_follows_folder_change.py``). ``_on_project_changed`` là Python
|
||||
thuần trên các thuộc tính của chính nó, nên gọi thẳng với một ``self`` giả là đủ
|
||||
và đúng hơn — bài test chốt luồng quyết định, không chốt phần vẽ.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6", reason="cần PySide6 để nạp module renderer")
|
||||
|
||||
from cowork_local.core import projects as projects_mod
|
||||
from cowork_local.core.projects import Project
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
|
||||
|
||||
class _O:
|
||||
"""Vật thể ghi lại lời gọi, thay cho một widget Qt."""
|
||||
|
||||
def __init__(self, **thuoc_tinh):
|
||||
self.__dict__.update(thuoc_tinh)
|
||||
self.da_goi = []
|
||||
|
||||
def __getattr__(self, ten):
|
||||
def ghi(*args, **kwargs):
|
||||
self.da_goi.append((ten, args))
|
||||
return ghi
|
||||
|
||||
|
||||
class _ComboGia:
|
||||
def __init__(self, pid):
|
||||
self._pid = pid
|
||||
|
||||
def currentData(self):
|
||||
return self._pid
|
||||
|
||||
|
||||
class _OGia:
|
||||
"""Ô nhập đường dẫn: giữ được chữ, và ghi lại việc bị khoá."""
|
||||
|
||||
def __init__(self, text=""):
|
||||
self._text = text
|
||||
self.read_only = False
|
||||
|
||||
def text(self):
|
||||
return self._text
|
||||
|
||||
def setText(self, value):
|
||||
self._text = value
|
||||
|
||||
def setReadOnly(self, value):
|
||||
self.read_only = value
|
||||
|
||||
|
||||
class _Renderer:
|
||||
"""``self`` giả cho ``GraphRenderer._on_project_changed``."""
|
||||
|
||||
def __init__(self, pid, active_id="", active_path="", hien=True):
|
||||
self.project_combo = _ComboGia(pid)
|
||||
self.path_edit = _OGia()
|
||||
self._pick_btn = _O()
|
||||
self.project_changed = _O()
|
||||
self._active_project_id = active_id
|
||||
self._active_path = active_path
|
||||
self._needs_scan = False
|
||||
self._hien = hien
|
||||
self.lan_quet = 0
|
||||
|
||||
def isVisible(self):
|
||||
return self._hien
|
||||
|
||||
def _scan(self):
|
||||
self.lan_quet += 1
|
||||
|
||||
# -- tiện cho khẳng định --------------------------------------------- #
|
||||
@property
|
||||
def so_lan_bao_doi(self):
|
||||
"""Số lần phát tín hiệu "đã đổi mục tiêu"."""
|
||||
return sum(1 for ten, _ in self.project_changed.da_goi if ten == "emit")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def du_an(monkeypatch, tmp_path):
|
||||
"""Một project duy nhất trong kho giả, trỏ vào ``tmp_path/cu``."""
|
||||
p = Project(project_id="p1", name="test", output_dir=str(tmp_path / "cu"))
|
||||
monkeypatch.setattr(projects_mod, "load_project",
|
||||
lambda pid, directory=None: p if pid == "p1" else None)
|
||||
return p
|
||||
|
||||
|
||||
def _chay(renderer):
|
||||
"""Gọi đúng hàm thật với ``self`` giả."""
|
||||
GraphRenderer._on_project_changed(renderer, 0)
|
||||
|
||||
|
||||
def test_cung_project_thu_muc_moi_thi_quet_lai(du_an, tmp_path):
|
||||
"""Đây là chỗ hỏng người dùng báo: đường dẫn đổi mà node giữa màn thì không."""
|
||||
r = _Renderer("p1")
|
||||
_chay(r) # lần đầu: khoá vào project
|
||||
assert r.lan_quet == 1
|
||||
|
||||
du_an.output_dir = str(tmp_path / "moi") # người dùng bấm "Đổi" ở tab Project
|
||||
_chay(r)
|
||||
|
||||
assert r.path_edit.text() == str(tmp_path / "moi")
|
||||
assert r.lan_quet == 2, "đổi thư mục xong nhưng không quét lại — node vẫn của thư mục cũ"
|
||||
assert r.so_lan_bao_doi == 2, (
|
||||
"phải báo đổi để khung hỏi-đáp bỏ phần trích xuất của thư mục cũ")
|
||||
|
||||
|
||||
def test_cung_project_cung_thu_muc_thi_khong_quet_lai(du_an):
|
||||
"""Bảo vệ sẵn có: ``_bind_project`` chạy lại mỗi lần vào lại màn Workspace,
|
||||
quét lại vô cớ là vừa giật vừa tốn."""
|
||||
r = _Renderer("p1")
|
||||
_chay(r)
|
||||
assert r.lan_quet == 1
|
||||
|
||||
_chay(r)
|
||||
_chay(r)
|
||||
|
||||
assert r.lan_quet == 1
|
||||
|
||||
|
||||
def test_doi_sang_project_khac_van_quet_lai(du_an, tmp_path, monkeypatch):
|
||||
"""Hành vi vốn có, không được mất."""
|
||||
khac = Project(project_id="p2", name="khac", output_dir=str(tmp_path / "cua-p2"))
|
||||
monkeypatch.setattr(projects_mod, "load_project",
|
||||
lambda pid, directory=None: du_an if pid == "p1" else khac)
|
||||
r = _Renderer("p1")
|
||||
_chay(r)
|
||||
|
||||
r.project_combo._pid = "p2"
|
||||
_chay(r)
|
||||
|
||||
assert r.lan_quet == 2
|
||||
assert r.path_edit.text() == str(tmp_path / "cua-p2")
|
||||
|
||||
|
||||
def test_man_dang_an_thi_hoan_quet_chu_khong_quet_ngay(du_an, tmp_path):
|
||||
"""Đổi thư mục từ màn Project trong khi GraphRAG đang ẩn: đánh dấu để quét
|
||||
ở lần vào sau, đúng luật hoãn mà ``auto_scan_and_fit`` dựa vào."""
|
||||
r = _Renderer("p1", hien=False)
|
||||
_chay(r)
|
||||
# Lần khoá đầu tiên đã đặt cờ rồi; xoá đi để bài này thật sự kiểm được
|
||||
# lần ĐỔI THƯ MỤC, chứ không xanh nhờ cờ còn sót của lần trước.
|
||||
r._needs_scan = False
|
||||
|
||||
du_an.output_dir = str(tmp_path / "moi")
|
||||
_chay(r)
|
||||
|
||||
assert r.lan_quet == 0
|
||||
assert r._needs_scan is True
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Xem trước HTML trong tab Folder phải hiện được ảnh lấy từ web khi mạng mở.
|
||||
|
||||
Tệp được nạp với base URL ``file://``; Qt mặc định cấm trang cục bộ tải bất kỳ
|
||||
tài nguyên web nào nếu ``LocalContentCanAccessRemoteUrls`` tắt, nên ảnh
|
||||
``<img src="https://...">`` không bao giờ hiện, kể cả khi đã mở Internet.
|
||||
Việc chặn khi bật "Chặn mạng" là của bộ chặn request, không phải của cờ này.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6.QtWebEngineWidgets", reason="cần Qt WebEngine")
|
||||
|
||||
from cowork_local.presentation.shared import HAS_WEB_ENGINE # noqa: E402
|
||||
|
||||
pytestmark = pytest.mark.skipif(not HAS_WEB_ENGINE, reason="WebEngine không dùng được ở đây")
|
||||
|
||||
|
||||
def test_trang_xem_truoc_cho_phep_tai_anh_tu_web(qapp):
|
||||
from PySide6.QtWebEngineCore import QWebEngineSettings
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
from cowork_local.presentation.folder import offline_web_page as owp
|
||||
|
||||
view = QWebEngineView()
|
||||
try:
|
||||
owp.install_offline_page(view)
|
||||
profile = view.page().profile()
|
||||
assert profile is owp.preview_profile()
|
||||
assert profile.settings().testAttribute(
|
||||
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls)
|
||||
assert isinstance(owp._blocker, owp.RemoteRequestBlocker)
|
||||
finally:
|
||||
view.deleteLater()
|
||||
|
||||
|
||||
def test_profile_song_lau_hon_moi_trang(qapp):
|
||||
"""Profile do view sở hữu bị huỷ TRƯỚC trang: Qt cảnh báo "Release of
|
||||
profile requested but WebEnginePage still not deleted" rồi app có thể văng
|
||||
(0xc0000409 trong Qt6Core.dll) khi chuyển tab. Profile phải thuộc về app."""
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
from cowork_local.presentation.folder import offline_web_page as owp
|
||||
|
||||
view = QWebEngineView()
|
||||
try:
|
||||
owp.install_offline_page(view)
|
||||
assert view.page().parent() is view
|
||||
assert owp.preview_profile().parent() is qapp
|
||||
finally:
|
||||
view.deleteLater()
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Ô định tuyến chỉ còn Auto và Manual; giá trị off/fallback cũ hiện thành Auto."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _toggle(stored: str):
|
||||
from cowork_local.ui.routing_toggle import RoutingToggle
|
||||
|
||||
saved = []
|
||||
t = RoutingToggle(None, "cowork", get_mode=lambda: stored, set_mode=saved.append)
|
||||
return t, saved
|
||||
|
||||
|
||||
def test_only_auto_and_manual_are_offered(qt_app):
|
||||
t, _ = _toggle("auto")
|
||||
items = [t._combo.itemData(i) for i in range(t._combo.count())]
|
||||
assert items == ["auto", "manual"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stored", ["off", "fallback", "", "turbo"])
|
||||
def test_legacy_values_show_as_auto(qt_app, stored):
|
||||
t, _ = _toggle(stored)
|
||||
assert t.current_mode() == "auto"
|
||||
|
||||
|
||||
def test_choosing_manual_persists_it(qt_app):
|
||||
t, saved = _toggle("auto")
|
||||
t._combo.setCurrentIndex(t._combo.findData("manual"))
|
||||
assert saved == ["manual"]
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Bấm "Dừng" phải thấy được là nó đã ăn.
|
||||
|
||||
Triệu chứng người dùng báo: một lượt chạy đã 159 giây, bấm Dừng, rồi không có
|
||||
gì đổi trên màn hình — chỉ báo vẫn đếm "Đang chạy · 160s", nên không biết nút
|
||||
có tác dụng hay không.
|
||||
|
||||
Hai chỗ nói sai, cả hai đều kiểm được:
|
||||
|
||||
* Lúc bấm — ``stop()`` chỉ đặt cờ huỷ trên worker rồi thôi. Huỷ thật có thể mất
|
||||
vài giây (đang chờ gateway trả lời, đang chạy dở một tool), mà trong khoảng đó
|
||||
màn hình vẫn nói "Đang chạy".
|
||||
* Lúc kết thúc — ``_on_finished`` luôn đặt dấu XANH "Đã hoàn thành", kể cả khi
|
||||
lượt chạy vừa bị người dùng dừng. Dấu đó nói ngược hẳn với thứ vừa xảy ra.
|
||||
|
||||
Gọi thẳng phương thức với một ``self`` giả, không dựng ``ChatPanel`` thật: những
|
||||
hàm này là Python thuần trên vài thuộc tính của chính nó, và dựng cả màn chat
|
||||
trong ``tests/ui`` kéo theo QtWebEngine (xem
|
||||
``test_graphrag_follows_folder_change.py``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6", reason="cần PySide6 để nạp module")
|
||||
|
||||
from cowork_local.presentation.chat.chat_panel import ChatPanel
|
||||
from cowork_local.presentation.chat.chat_turn_runner import ChatTurnRunnerMixin
|
||||
|
||||
|
||||
class _ChiBaoGia:
|
||||
"""Thay ``ThinkingIndicator`` — ghi lại nhãn nó được yêu cầu hiện."""
|
||||
|
||||
def __init__(self):
|
||||
self.nhan = []
|
||||
|
||||
def set_label(self, key):
|
||||
self.nhan.append(key)
|
||||
|
||||
def start(self, key="chat.running"):
|
||||
self.nhan.append(key)
|
||||
|
||||
def stop(self):
|
||||
self.nhan.append(None)
|
||||
|
||||
|
||||
class _KhungChatGia:
|
||||
"""Thay ``chat_view`` — ghi lại loại dấu kết thúc được đặt vào."""
|
||||
|
||||
def __init__(self):
|
||||
self.dau = []
|
||||
|
||||
def add_error(self, text):
|
||||
self.dau.append(("error", text))
|
||||
return object()
|
||||
|
||||
def add_status(self, text):
|
||||
self.dau.append(("status", text))
|
||||
return object()
|
||||
|
||||
def add_success(self, text):
|
||||
self.dau.append(("success", text))
|
||||
return object()
|
||||
|
||||
|
||||
class _WorkerGia:
|
||||
def __init__(self, dang_chay=True):
|
||||
self._dang_chay = dang_chay
|
||||
self.da_yeu_cau_dung = False
|
||||
|
||||
def isRunning(self):
|
||||
return self._dang_chay
|
||||
|
||||
def request_stop(self):
|
||||
self.da_yeu_cau_dung = True
|
||||
|
||||
|
||||
class _ComposerGia:
|
||||
def __init__(self):
|
||||
self.da_xoa_hang_doi = False
|
||||
|
||||
def clear_queue(self):
|
||||
self.da_xoa_hang_doi = True
|
||||
|
||||
def set_running(self, _v):
|
||||
pass
|
||||
|
||||
def set_busy(self, _v):
|
||||
pass
|
||||
|
||||
def set_text(self, _v):
|
||||
pass
|
||||
|
||||
|
||||
class _TinNhanGia:
|
||||
def __init__(self):
|
||||
self.da_phat = []
|
||||
|
||||
def emit(self, *args):
|
||||
self.da_phat.append(args[0] if len(args) == 1 else args)
|
||||
|
||||
|
||||
class _Panel:
|
||||
"""``self`` giả cho các phương thức đang kiểm."""
|
||||
|
||||
kind = "cowork"
|
||||
|
||||
def __init__(self, dang_chay=True):
|
||||
self.worker = _WorkerGia(dang_chay)
|
||||
self._active = {self.worker: {}} if dang_chay else {}
|
||||
self.composer = _ComposerGia()
|
||||
self.status_message = _TinNhanGia()
|
||||
self.turn_finished = _TinNhanGia()
|
||||
self.graph_event = _TinNhanGia()
|
||||
self.session_name = "test"
|
||||
self.thinking = _ChiBaoGia()
|
||||
self.chat_view = _KhungChatGia()
|
||||
self._stop_requested = False
|
||||
self._ban_ron = dang_chay
|
||||
|
||||
def _view_busy(self):
|
||||
return self._ban_ron
|
||||
|
||||
def _max_parallel(self):
|
||||
return 2
|
||||
|
||||
# -- những thứ _on_failed cần, đều là no-op ------------------------- #
|
||||
def _turn_is_live(self, _ctx):
|
||||
return True
|
||||
|
||||
def _end_turn(self, _ctx):
|
||||
pass
|
||||
|
||||
def _cleanup_turn(self, _ctx, _ok):
|
||||
pass
|
||||
|
||||
def _drain_queue(self):
|
||||
pass
|
||||
|
||||
def _persist_session(self, _ctx):
|
||||
pass
|
||||
|
||||
def _dau_ket_thuc(self):
|
||||
"""Gọi phương thức THẬT — đây là thứ đang được kiểm, không giả lập.
|
||||
|
||||
Tra tên lúc GỌI chứ không lúc dựng lớp: tra lúc dựng thì trên bản
|
||||
chưa sửa cả file test đổ ngay ở khâu thu thập, và một lỗi thu thập
|
||||
không nói được gì về hành vi.
|
||||
"""
|
||||
return ChatTurnRunnerMixin._dau_ket_thuc(self)
|
||||
|
||||
|
||||
|
||||
# ---- lúc bấm Dừng -------------------------------------------------------
|
||||
|
||||
def test_bam_dung_thi_chi_bao_doi_sang_dang_dung():
|
||||
"""Đây là chỗ hỏng người dùng thấy: bấm xong màn hình không đổi gì."""
|
||||
p = _Panel()
|
||||
|
||||
ChatTurnRunnerMixin.stop(p)
|
||||
|
||||
assert p._stop_requested is True
|
||||
assert "chat.stopping" in p.thinking.nhan, (
|
||||
"chỉ báo vẫn nói 'Đang chạy' — người dùng đọc ra là nút Dừng không ăn")
|
||||
|
||||
|
||||
def test_bam_dung_van_yeu_cau_worker_dung_va_xoa_hang_doi():
|
||||
"""Hành vi vốn có, không được mất khi thêm phần hiển thị."""
|
||||
p = _Panel()
|
||||
|
||||
ChatTurnRunnerMixin.stop(p)
|
||||
|
||||
assert p.worker.da_yeu_cau_dung is True
|
||||
assert p.composer.da_xoa_hang_doi is True
|
||||
|
||||
|
||||
def test_khong_co_luot_nao_chay_thi_bam_dung_khong_lam_gi():
|
||||
"""Không có gì để dừng thì đừng nói dối là đang dừng."""
|
||||
p = _Panel(dang_chay=False)
|
||||
|
||||
ChatTurnRunnerMixin.stop(p)
|
||||
|
||||
assert p._stop_requested is False
|
||||
assert p.thinking.nhan == []
|
||||
|
||||
|
||||
# ---- lúc lượt chạy kết thúc --------------------------------------------
|
||||
|
||||
def test_da_dung_thi_dat_dau_da_dung_chu_khong_phai_hoan_thanh():
|
||||
p = _Panel()
|
||||
p._stop_requested = True
|
||||
|
||||
ChatTurnRunnerMixin._dau_ket_thuc(p)
|
||||
|
||||
loai, text = p.chat_view.dau[0]
|
||||
assert loai == "status", "dừng theo yêu cầu mà vẫn đặt dấu xanh 'Đã hoàn thành'"
|
||||
assert "dừng" in text.lower() or "stop" in text.lower(), text
|
||||
|
||||
|
||||
def test_ket_thuc_binh_thuong_van_dat_dau_hoan_thanh():
|
||||
"""Chặn một chiều là hỏng tính năng — lượt chạy xong xuôi vẫn phải xanh."""
|
||||
p = _Panel()
|
||||
|
||||
ChatTurnRunnerMixin._dau_ket_thuc(p)
|
||||
|
||||
assert p.chat_view.dau[0][0] == "success"
|
||||
|
||||
|
||||
# ---- đồng bộ lại chỉ báo (đổi tab, Refresh History) --------------------
|
||||
|
||||
def test_dong_bo_lai_khong_keo_nhan_ve_dang_chay():
|
||||
"""``_sync_indicators`` chạy lại khi chuyển tab; nó mà đặt lại
|
||||
"chat.running" là nhãn "đang dừng" bị xoá ngay sau khi bấm."""
|
||||
p = _Panel()
|
||||
p._stop_requested = True
|
||||
|
||||
ChatPanel._sync_indicators(p)
|
||||
|
||||
assert p.thinking.nhan[-1] == "chat.stopping"
|
||||
|
||||
|
||||
def test_dong_bo_lai_khi_chua_bam_dung_thi_van_la_dang_chay():
|
||||
p = _Panel()
|
||||
|
||||
ChatPanel._sync_indicators(p)
|
||||
|
||||
assert p.thinking.nhan[-1] == "chat.running"
|
||||
|
||||
|
||||
|
||||
# ---- lượt chạy kết thúc bằng NGOẠI LỆ vì vừa bị huỷ --------------------
|
||||
|
||||
def test_da_bam_dung_thi_loi_luc_huy_hien_ra_la_da_dung():
|
||||
"""Đúng thứ người dùng chụp lại: bấm Dừng xong nhận một dòng đỏ
|
||||
"'NoneType' object has no attribute 'read'" — đó là hệ quả của chính việc
|
||||
huỷ (đóng socket giữa stream), không phải một lỗi cần báo."""
|
||||
p = _Panel()
|
||||
p._stop_requested = True
|
||||
|
||||
ChatTurnRunnerMixin._on_failed(p, {}, "'NoneType' object has no attribute 'read'")
|
||||
|
||||
loai = [l for l, _ in p.chat_view.dau]
|
||||
assert "error" not in loai, "vẫn dội lỗi Python ra màn hình sau khi người dùng bấm Dừng"
|
||||
assert loai == ["status"]
|
||||
assert "dừng" in p.chat_view.dau[0][1].lower()
|
||||
|
||||
|
||||
def test_loi_that_khi_chua_bam_dung_van_bao_loi_nhu_cu():
|
||||
"""Chặn một chiều là nuốt mất lỗi thật."""
|
||||
p = _Panel()
|
||||
|
||||
ChatTurnRunnerMixin._on_failed(p, {}, "gateway 500")
|
||||
|
||||
assert p.chat_view.dau == [("error", "gateway 500")]
|
||||
|
||||
|
||||
def test_thanh_trang_thai_noi_cung_mot_chuyen_voi_khung_chat():
|
||||
p = _Panel()
|
||||
p._stop_requested = True
|
||||
|
||||
ChatTurnRunnerMixin._on_failed(p, {}, "bat ky loi gi")
|
||||
|
||||
assert any("dừng" in t.lower() for t in p.status_message.da_phat), p.status_message.da_phat
|
||||
|
||||
# ---- i18n ---------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("key", ["chat.stopping", "chat.stopped_marker",
|
||||
"chatpanel.stopped"])
|
||||
def test_key_moi_co_du_ba_ngon_ngu(key):
|
||||
from cowork_local import i18n
|
||||
|
||||
entry = i18n.STRINGS[key]
|
||||
for lang in ("en", "ja", "vi"):
|
||||
assert entry.get(lang), f"{key} thiếu {lang}"
|
||||
+1
-1
@@ -95,7 +95,7 @@ class CoworkTab(ChatPanel):
|
||||
lbl = getattr(self, "_title_lbl", None)
|
||||
if lbl is None:
|
||||
return # ChatPanel.__init__ sets self.title before we exist
|
||||
lbl.setText(getattr(self, "title", "") or tr("cowork.title"))
|
||||
self.set_title_label(lbl, tr("cowork.title"))
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề và các nút trên thanh công cụ."""
|
||||
|
||||
+7
-10
@@ -40,7 +40,7 @@ class RoutingToggle(QWidget):
|
||||
Emits :attr:`mode_changed`; call :meth:`refresh` after the workspace switches.
|
||||
"""
|
||||
|
||||
mode_changed = Signal(str) # "off" | "auto" | "manual" | "fallback"
|
||||
mode_changed = Signal(str) # "auto" | "manual"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -76,14 +76,11 @@ class RoutingToggle(QWidget):
|
||||
# it the width stays frozen at the language the widget was built in and
|
||||
# the longer translation is cut off.
|
||||
self._combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
||||
# (data value, i18n key) — data is the persisted mode string. Order is
|
||||
# least-to-most autonomous, with Fallback (R03-T03) last because it is
|
||||
# the "only when something breaks" mode rather than a stronger Auto.
|
||||
# (data value, i18n key) — data is the persisted mode string. Off and
|
||||
# Fallback were dropped: only Auto and Manual remain selectable.
|
||||
self._modes = [
|
||||
("off", "routing.mode_off"),
|
||||
("auto", "routing.mode_auto"),
|
||||
("manual", "routing.mode_manual"),
|
||||
("fallback", "routing.mode_fallback"),
|
||||
]
|
||||
for value, key in self._modes:
|
||||
self._combo.addItem(tr(key), value)
|
||||
@@ -100,16 +97,16 @@ class RoutingToggle(QWidget):
|
||||
on_language_changed(self.retranslate)
|
||||
|
||||
def current_mode(self) -> str:
|
||||
"""Chế độ định tuyến đang chọn; 'off' nếu chưa đặt."""
|
||||
return self._combo.currentData() or "off"
|
||||
"""Chế độ định tuyến đang chọn; 'auto' nếu chưa đặt."""
|
||||
return self._combo.currentData() or "auto"
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Re-read the backing mode (e.g. after switching workspace) and show it
|
||||
without emitting a spurious change."""
|
||||
try:
|
||||
mode = self._get_mode() or "off"
|
||||
mode = self._get_mode() or "auto"
|
||||
except Exception: # noqa: BLE001
|
||||
mode = "off"
|
||||
mode = "auto"
|
||||
idx = self._combo.findData(mode)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
|
||||
@@ -96,7 +96,7 @@ class SettingsDialog(QDialog):
|
||||
sbl.addWidget(self.sandbox_confirm)
|
||||
|
||||
self.sandbox_block_network = ToggleSwitch(tr("settings.sandbox_block_network"))
|
||||
self.sandbox_block_network.setChecked(bool(sec.get("block_network", True)))
|
||||
self.sandbox_block_network.setChecked(bool(sec.get("block_network", False)))
|
||||
self.sandbox_block_network.setToolTip(tr("settings.sandbox_block_network_tooltip"))
|
||||
sbl.addWidget(self.sandbox_block_network)
|
||||
|
||||
@@ -277,4 +277,11 @@ class SettingsDialog(QDialog):
|
||||
self.ctx.config._data = None # invalidate cache
|
||||
self.ctx.config._agent_security = None
|
||||
|
||||
stop_mcp = getattr(self.ctx, "stop_mcp_connections", None)
|
||||
if self.sandbox_block_network.isChecked() and stop_mcp is not None:
|
||||
# A running MCP server is its own process and may keep using the
|
||||
# network — stop them now, off the UI thread (each stop may wait).
|
||||
import threading
|
||||
threading.Thread(target=stop_mcp, daemon=True).start()
|
||||
|
||||
self.accept()
|
||||
+7
-1
@@ -14,6 +14,7 @@ from ..core.history import (
|
||||
rename_conversation, set_pinned,
|
||||
)
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..presentation.chat.chat_helpers import clip_chars
|
||||
from .dialog_buttons import ask_text, confirm
|
||||
from .icons import collapse_left_icon, dot_icon, DOT_BLUE, icon
|
||||
from .widgets import CollapseStrip
|
||||
@@ -81,6 +82,7 @@ class HistorySidebar(QWidget):
|
||||
expand_requested = Signal() # strip clicked: re-expand
|
||||
refresh_requested = Signal() # Refresh button: re-list + re-sync agent status
|
||||
history_changed = Signal() # a conversation was deleted — other views (Project tab) should re-sync
|
||||
conversation_renamed = Signal(str, str) # session_id, new title — the open chat retitles itself
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
"""Cột lịch sử hội thoại.
|
||||
@@ -273,7 +275,9 @@ class HistorySidebar(QWidget):
|
||||
is_current = bool(sid) and sid == self.current_session_id
|
||||
is_running = sid in self.running_ids
|
||||
suffix = tr("sidebar.running_suffix") if is_running else ""
|
||||
item = QTreeWidgetItem([f"{title}{suffix}\n{created}"])
|
||||
# Tối đa 10 ký tự như tiêu đề khung chat; tên đầy đủ ở tooltip.
|
||||
item = QTreeWidgetItem([f"{clip_chars(title)}{suffix}\n{created}"])
|
||||
item.setToolTip(0, title)
|
||||
# A running turn (blue LED) takes visual priority over the pin icon.
|
||||
if is_running:
|
||||
item.setIcon(0, dot_icon(DOT_BLUE))
|
||||
@@ -352,6 +356,8 @@ class HistorySidebar(QWidget):
|
||||
if ok and new.strip():
|
||||
rename_conversation(path, new.strip())
|
||||
self.refresh()
|
||||
sid = load_conversation(path).get("session_id", "")
|
||||
self.conversation_renamed.emit(sid, new.strip())
|
||||
elif chosen == del_act:
|
||||
if confirm(self, tr("sidebar.delete.title"),
|
||||
tr("sidebar.delete.confirm", title=title)):
|
||||
|
||||
+3
-2
@@ -490,8 +490,9 @@ def section_panels(sections, width: int = 260):
|
||||
index.currentRowChanged.connect(stack.setCurrentIndex)
|
||||
index.setCurrentRow(0)
|
||||
|
||||
natural = max(index.fontMetrics().horizontalAdvance(lab) for lab, _w in sections) + 36
|
||||
index.setFixedWidth(max(120, min(width, natural)))
|
||||
# Sized for the SELECTED look (bold + padding), or the picked label is cut.
|
||||
from ..presentation.shared.list_sizing import selected_label_width
|
||||
index.setFixedWidth(max(120, min(width, selected_label_width(index, [lab for lab, _w in sections]))))
|
||||
return index, stack
|
||||
|
||||
|
||||
|
||||
@@ -339,6 +339,8 @@ class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
|
||||
sb.expand_requested.connect(lambda: self._on_history_fold(False))
|
||||
sb.refresh_requested.connect(self._on_sidebar_refresh)
|
||||
sb.history_changed.connect(self._reload_threads)
|
||||
if self._cowork is not None:
|
||||
sb.conversation_renamed.connect(self._cowork.apply_renamed_title)
|
||||
|
||||
def _on_history_fold(self, collapsed: bool) -> None:
|
||||
"""Its chevron closes the panel away, back to the drawn layout."""
|
||||
@@ -947,6 +949,7 @@ class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
|
||||
project.output_dir = chosen
|
||||
save_project(project)
|
||||
self.folder_lbl.setText(chosen)
|
||||
self.rebind_workspace_folder()
|
||||
self.status_message.emit(tr("workspace.saved", name=project.name))
|
||||
|
||||
def _open_workspace(self) -> None:
|
||||
@@ -1002,6 +1005,7 @@ class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
|
||||
project.cloud_source = cloud_source
|
||||
save_project(project)
|
||||
self.folder_lbl.setText(str(local_dir))
|
||||
self.rebind_workspace_folder()
|
||||
self._refresh_cloud_badge(project)
|
||||
self.projects_changed.emit()
|
||||
if report.errors:
|
||||
|
||||
Reference in New Issue
Block a user