Compare commits

..
Author SHA1 Message Date
minhanhpkproandClaude Opus 5.5 a6b68a0bf8 feat(app): ghi log khi app văng/treo và báo "Đang xử lý…" khi giao diện đứng
App văng ra với mã 0xC0000409 mà không để lại dấu vết: lỗi ở tầng native không in
traceback Python và console đóng theo app. crash_guard ghi vào
~/.cowork_local/logs/:

- crash.log: stack mọi luồng lúc tiến trình chết (faulthandler), cùng exception
  Python không ai bắt ở luồng chính lẫn luồng nền (chỉ ghi, app chạy tiếp).
- qt.log: cảnh báo/lỗi của Qt.
- hang.log: stack mọi luồng mỗi khi luồng giao diện đứng quá 5 giây.

Khi giao diện đứng, một luồng canh mở hộp thoại Windows "Đang xử lý…" (theo ngôn
ngữ đang chọn trong app) và tự đóng khi app phản hồi lại. Cài đặt bọc trong try,
không bao giờ chặn app khởi động.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:40:06 +09:00
minhanhpkproandClaude Opus 5.5 0b6b220bd9 feat(routing): ô định tuyến chỉ còn Auto và Manual
Bỏ hai chế độ Off và Fallback ở ô định tuyến cạnh khung chat và ở mục Định
tuyến trong Cài đặt. Mặc định chuyển sang Auto.

- USER_ROUTING_MODES = ("auto", "manual"); giá trị off/fallback/lạ còn lưu trong
  config hay project đều được hiểu là Auto (routing_mode_for,
  project_routing_mode, set_*). Engine vẫn hiểu "off" khi truyền mode_override
  tường minh.
- RoutingScheduler: định tuyến giờ luôn bật nên việc chấm điểm model định kỳ
  luôn chạy; muốn tắt thì đặt reassess_interval_hours = 0.
- Cập nhật các test đang ghim hành vi "mặc định off".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:40:05 +09:00
minhanhpkproandClaude Opus 5.5 fefc9f94db fix(chat): đổi tên hội thoại thì tiêu đề khung chat đổi theo ngay
Đổi tên một hội thoại ở cột lịch sử (chuột phải → Đổi tên) thì tiêu đề khung
chat bên phải vẫn giữ tên cũ, phải click ra ngoài rồi click lại mới đổi. Tệ hơn,
self.title cũ vẫn nằm trong bộ nhớ nên lượt chat kế tiếp lưu đè tên cũ lên tên
người dùng vừa đặt.

- HistorySidebar phát conversation_renamed(session_id, title); WorkspaceTab nối
  vào apply_renamed_title của Cowork. Hội thoại đang mở thì đổi luôn self.title
  và làm mới thanh tiêu đề.
- Tiêu đề hội thoại chỉ hiện tối đa 10 ký tự, dư thì "…", tên đầy đủ ở tooltip.
  Áp cho cả thanh tiêu đề khung chat lẫn danh sách hội thoại trong project
  (dùng chung clip_chars). Hộp thoại Đổi tên vẫn điền tên đầy đủ.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:40:05 +09:00
duylh19andClaude Opus 5 ddb31f9aff fix(chat): bấm Dừng thì báo rõ là đã dừng, không dội lỗi Python
Một lượt chạy đã 159 giây, bấm Dừng, rồi hoặc không thấy gì đổi, hoặc nhận một
dòng đỏ "'NoneType' object has no attribute 'read'". Cả hai đều không trả lời
được câu người dùng đang hỏi: nó dừng chưa?

Ba chỗ nói sai:

- stop() chỉ đặt cờ huỷ rồi thôi. Huỷ thật mất vài giây (đang chờ gateway, đang
  chạy dở một tool), mà trong khoảng đó chỉ báo vẫn đếm "Đang chạy · 160s".
  Nay đổi nhãn sang "Đang dừng" ngay lúc bấm, và _sync_indicators giữ nhãn đó
  khi chuyển tab.
- Huỷ giữa stream đóng socket, urllib3 ném AttributeError chứ không phải
  requests.RequestException, nên hai vòng đọc stream của provider bắt hụt và
  lỗi chui lên tận giao diện. Nay bắt rộng rồi lọc lại: chỉ nuốt khi thật sự
  đang huỷ, lỗi khác vẫn ném tiếp nguyên vẹn.
- Lượt chạy kết thúc sau khi huỷ vẫn đặt dấu XANH "Đã hoàn thành" (hoặc bong
  bóng lỗi đỏ nếu kết thúc bằng ngoại lệ). Nay đặt "⏹ Đã dừng theo yêu cầu", và
  thanh trạng thái nói cùng một chuyện.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:39:43 +09:00
duylh19andClaude Opus 5 b500b3e57d fix(graphrag): đổi thư mục project thì quét lại đồ thị theo thư mục mới
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.

Cùng một họ sai lầm với hai mảnh trước — câu hỏi "có gì đổi không" trả lời bằng
project id chứ không bằng thứ quyết định kết quả quét:

    project_changed = pid != self._active_project_id

Đổi thư mục 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, trong khi dòng đặt path_edit lại nằm
ngoài khối đó — thanh địa chỉ đúng mà đồ thị đứng yên.

Tách khối "project sandbox lock" sang graph_project_lock.py: graph_renderer.py
đang ở 399/400 dòng, đúng một dòng trước trần của scripts/check_loc.py, và cổng
đó nói rõ cách duy nhất đúng khi chạm trần là tách file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:39:22 +09:00
duylh19andClaude Opus 5 76225aa118 fix(workspace): đổi thư mục project thì GraphRAG trỏ theo thư mục mới
Ở tab Project bấm "Đổi" sang đườ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 giữ nguyên
đườ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 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 — comment ở đó tự nhận hai nơi cùng luật, thật ra thì không.

Thêm ProjectFolderRuleMixin.rebind_workspace_folder để trỏ lại những màn đang
bám vào thư mục, gọi từ cả hai đường đổi thư mục ở màn Project. Cố ý không gọi
_load_current: hàm đó nạp lại cả biểu mẫu từ đĩa, gọi lúc đang sửa dở Tên/Mô tả
là xoá mất phần chưa lưu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:39:21 +09:00
duylh19andClaude Opus 5 35334274eb fix(chat): rút gọn tiêu đề hội thoại không cắt vào giữa từ
Thanh tiêu đề màn Cowork hiện "…tóm tắt từng tệ…" cho một câu dài 61 ký tự:
mốc cắt cứng ở 60 rơi đúng giữa chữ "tệp" và bỏ mất một chữ cái, đọc ra như lỗi
gõ chứ không như một câu bị rút gọn. Dấu ba chấm ở đó còn nói dối — nó báo còn
nhiều chữ nữa trong khi chỉ thiếu đúng một ký tự.

Thêm core.history.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ỏ. Từ dài bất thường (đường dẫn,
URL) thì lùi về ranh giới từ trước đó để một token dài không kéo tiêu đề dài ra
tuỳ ý.

Dùng chung cho cả hai nơi sinh tiêu đề (derive_title và ChatTurnRunnerMixin) để
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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:38:59 +09:00
minhanhpkproandClaude Opus 5 10b8379824 fix(settings): giữ tên nút chặn mạng cũ và nới cột mục lục
CI / test (pull_request) Canceled after 0s
- Trả nhãn công tắc về "Block network for agent-run commands"
  (vi/ja như cũ).
- Cột mục lục bên trái tính độ rộng theo kiểu chữ của mục đang chọn
  (in đậm + lề 10px), trước đây "Sandbox Security Layer" bị cắt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:19:26 +09:00
minhanhpkproandClaude Opus 5 8c497cf50a fix(folder): xem trước HTML hiện ảnh web và không làm văng app
CI / test (pull_request) Canceled after 0s
- Trang file:// cần LocalContentCanAccessRemoteUrls mới tải được ảnh,
  CSS, JS trên web; khi bật "Chặn mạng" bộ chặn request vẫn chặn.
- Dùng một QWebEngineProfile chung thuộc QApplication: profile do view
  sở hữu bị huỷ trước trang, Qt báo "Release of profile requested but
  WebEnginePage still not deleted" và app văng (0xc0000409 trong
  Qt6Core.dll) khi chuyển tab Graph sang Folder.
- Giữ tham chiếu Python tới bộ chặn request để nó không bị thu gom.
- Thêm CLAUDE.md hướng dẫn làm việc trong repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:09:49 +09:00
minhanhpkproandClaude Opus 5 b78d48320c fix(sandbox): "Chặn mạng" chặn mọi đường ra mạng, trừ nhà cung cấp AI
Trước đây công tắc chỉ chặn tool mạng của agent; lệnh shell chỉ bị proxy
giả, còn M365, Teams, nút Test, MCP đang chạy, task script, link đính kèm
task, pip tự cài và tài nguyên web trong xem trước HTML vẫn ra mạng tự do.

- Cổng chung application/network/network_guard.py, nối vào cấu hình sống
  ở Composition Root; nhà cung cấp AI (chat, danh sách model, thử model)
  không đi qua cổng này.
- Lệnh shell của agent và task script chạy trong Windows AppContainer
  không có quyền mạng (macOS: sandbox-exec, Linux: unshare --net);
  không cô lập được thì từ chối chạy.
- Không cấp quyền kế thừa của AppContainer lên thư mục chứa PySide6:
  Chromium không nạp được Qt6WebEngineCore.dll và tab Graph bị hỏng.
- Bật chặn thì dừng MCP đang chạy; tool OneDrive đồng bộ trên máy vẫn dùng.
- Mặc định tắt khi mở app lần đầu; nhãn và tooltip 3 ngôn ngữ cập nhật.
- Test: tests/test_network_guard_lanes.py (có bài AppContainer thật).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:32:44 +09:00
66 changed files with 2884 additions and 196 deletions
+82
View File
@@ -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.
+12
View File
@@ -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()
+5
View File
@@ -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"]
+61
View File
@@ -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
+7 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+6
View File
@@ -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
View File
@@ -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)"
+2
View File
@@ -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) —
+6
View File
@@ -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
View File
@@ -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 {}))
+29
View File
@@ -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)
+4
View File
@@ -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))
+6 -16
View File
@@ -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
View File
@@ -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
View File
@@ -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]:
+47
View File
@@ -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"]
+4
View File
@@ -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.
+2
View File
@@ -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.",
+6
View File
@@ -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
View File
@@ -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ớ"},
+5
View File
@@ -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()
+5 -5
View File
@@ -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"]
+7
View File
@@ -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:
+7 -1
View File
@@ -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
+17
View File
@@ -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."""
+38 -4
View File
@@ -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
+66
View File
@@ -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"]
+83
View File
@@ -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()
+4 -58
View File
@@ -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."""
+21 -2
View File
@@ -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()
+27
View File
@@ -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"]
+11 -2
View File
@@ -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
+171
View File
@@ -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.
+8 -1
View File
@@ -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")
+8 -2
View File
@@ -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:
+12 -8
View File
@@ -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),
+14
View File
@@ -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()
+96
View File
@@ -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"
+26 -9
View File
@@ -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):
+15 -6
View File
@@ -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
+389
View File
@@ -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)
+116
View File
@@ -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()
+42
View File
@@ -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"]
+273
View File
@@ -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
View File
@@ -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
View File
@@ -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
+8 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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: