Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddb31f9aff | ||
|
|
b500b3e57d | ||
|
|
76225aa118 | ||
|
|
35334274eb | ||
|
|
10b8379824 | ||
|
|
8c497cf50a | ||
|
|
b78d48320c | ||
|
|
b7a41b3658 | ||
|
|
bbdf146d2c | ||
|
|
35f24e0e28 | ||
|
|
2759ed94ba | ||
|
|
8548c1e923 | ||
|
|
caf3b74931 | ||
|
|
c00b83cd1a | ||
|
|
a04f8a928d | ||
|
|
cc8d5c8c0a | ||
|
|
2e3e719259 | ||
|
|
c699beb6fd | ||
|
|
7607f44030 | ||
|
|
cbae2604db | ||
|
|
b71a622227 | ||
|
|
1b8429e33a |
@@ -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.
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
"""Application services for the "Block network" switch (Sandbox Security Layer)."""
|
||||
|
||||
from .network_guard import NetworkBlockedError, bind, ensure_allowed, is_blocked, refusal
|
||||
|
||||
__all__ = ["NetworkBlockedError", "bind", "ensure_allowed", "is_blocked", "refusal"]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""One gate for every outbound connection the app makes on its own.
|
||||
|
||||
The "Block network" switch (``agent_security.block_network``) used to be read
|
||||
only where agent tools run, so Microsoft 365, Teams, connector test buttons,
|
||||
scheduled task scripts, pip auto-installs and HTML previews still reached the
|
||||
internet while Monitoring said "Network: blocked". Each of those now asks
|
||||
this module first.
|
||||
|
||||
The AI provider path (chat, model list, model test) deliberately does NOT go
|
||||
through here: with the switch on the user still talks to the model, but the
|
||||
app fetches nothing else on the model's or its own behalf.
|
||||
|
||||
The module holds a *reader*, not a copy of the flag: the Composition Root
|
||||
binds it to the live config once (``presentation/shell/bootstrap.py``), so a
|
||||
change saved in Settings takes effect on the very next call. Nothing bound
|
||||
(unit tests, helper subprocesses) means "not blocked", the pre-switch default.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Callable, Optional
|
||||
|
||||
_lock = threading.Lock()
|
||||
_reader: Optional[Callable[[], bool]] = None
|
||||
|
||||
|
||||
class NetworkBlockedError(PermissionError):
|
||||
"""Raised by :func:`ensure_allowed` while the switch is on."""
|
||||
|
||||
|
||||
def bind(reader: Optional[Callable[[], bool]]) -> None:
|
||||
"""Install the callable that says whether the network is blocked right now."""
|
||||
global _reader
|
||||
with _lock:
|
||||
_reader = reader
|
||||
|
||||
|
||||
def is_blocked() -> bool:
|
||||
"""True while "Block network" is on. A failing reader counts as blocked."""
|
||||
reader = _reader
|
||||
if reader is None:
|
||||
return False
|
||||
try:
|
||||
return bool(reader())
|
||||
except Exception: # noqa: BLE001 - fail closed: an unreadable switch must not open the network
|
||||
return True
|
||||
|
||||
|
||||
def refusal(purpose: str) -> str:
|
||||
"""The message shown (to the user or the model) when ``purpose`` is refused."""
|
||||
return (f"{purpose}: network access is blocked by the Sandbox Security Layer "
|
||||
"(\"Block network\" is on in Settings). Only the AI provider may be reached.")
|
||||
|
||||
|
||||
def ensure_allowed(purpose: str) -> None:
|
||||
"""Raise :class:`NetworkBlockedError` if ``purpose`` may not go online now."""
|
||||
if is_blocked():
|
||||
raise NetworkBlockedError(refusal(purpose))
|
||||
|
||||
|
||||
__all__ = ["NetworkBlockedError", "bind", "ensure_allowed", "is_blocked", "refusal"]
|
||||
@@ -21,9 +21,14 @@ def pptx_available() -> bool:
|
||||
try:
|
||||
from cowork_local.core.deps import ensure_module
|
||||
|
||||
_PPTX_READY = ensure_module("pptx", "python-pptx") is not None
|
||||
ready = ensure_module("pptx", "python-pptx") is not None
|
||||
except Exception: # noqa: BLE001
|
||||
_PPTX_READY = False
|
||||
ready = False
|
||||
from ..network import network_guard
|
||||
|
||||
if ready or not network_guard.is_blocked():
|
||||
_PPTX_READY = ready # a refusal under "Block network" is retried later
|
||||
return ready
|
||||
return _PPTX_READY
|
||||
|
||||
|
||||
|
||||
@@ -100,11 +100,15 @@ 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)
|
||||
"block_network": True, # strip proxy env / point at a black-hole address for agent-run commands
|
||||
# "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. SEPARATE from block_network (that only
|
||||
# sandboxes agent-run shell commands) — reading a URL for info is safe and
|
||||
# useful, so this defaults ON. Toggle in Settings → Security.
|
||||
# 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:
|
||||
# with the network blocked the tool is refused either way.
|
||||
"allow_url_fetch": True,
|
||||
"sandbox_pw": "", # set through COWORK_SANDBOX_PASSWORD
|
||||
"rulebase_path": "", # custom RULEBASE.md — attached to every agent execution
|
||||
|
||||
@@ -527,6 +527,15 @@ 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 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).')}
|
||||
emit({"type": "tool_result", "id": tc_id, "name": name,
|
||||
"ok": False, "output": result["output"]})
|
||||
messages.append({"role": "tool", "tool_call_id": tc_id, "name": name,
|
||||
"content": result["output"]})
|
||||
continue
|
||||
# R05-T04: MCP/connector tools used to run with NO permission
|
||||
# check at all — this is what closes that gap. Same policy,
|
||||
# same gate object as the built-in tools below.
|
||||
|
||||
+6
-1
@@ -327,7 +327,12 @@ def run_code(
|
||||
else:
|
||||
emit({"type": "tool_start", "id": tc_id, "name": name})
|
||||
if is_extra and extra_executor is not None:
|
||||
result = extra_executor(name, args)
|
||||
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).')}
|
||||
else:
|
||||
result = extra_executor(name, args)
|
||||
else:
|
||||
def on_output(line: str, _id=tc_id, _name=name) -> None:
|
||||
emit({"type": "tool_output", "id": _id, "name": _name, "delta": line})
|
||||
|
||||
+24
-5
@@ -91,6 +91,7 @@ def run_cancellable(
|
||||
on_output: Optional[Callable[[str], None]] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
limits: Optional[Dict[str, float]] = None,
|
||||
isolate_network: bool = False,
|
||||
) -> Tuple[Optional[int], str, bool, bool, bool]:
|
||||
"""Run a subprocess so the Stop button can actually interrupt it.
|
||||
|
||||
@@ -117,17 +118,27 @@ def run_cancellable(
|
||||
a failure to create/assign the job just means the existing taskkill
|
||||
fallback is used, same as before this was added.
|
||||
|
||||
``isolate_network`` runs ``args`` as a shell command that the OS keeps
|
||||
off the network (see ``infrastructure/sandbox/network_isolation.py``);
|
||||
if that isolation cannot be set up the command is NOT run.
|
||||
|
||||
Returns ``(returncode, combined_output, cancelled, timed_out,
|
||||
resource_exceeded)``; on a failure to even launch the process,
|
||||
``returncode`` is ``None`` and the output holds the launch error."""
|
||||
cancel = cancel or (lambda: False)
|
||||
popen_kwargs = {} if sys.platform == "win32" else {"start_new_session": True}
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
args, shell=shell, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, bufsize=1, env=env, **popen_kwargs,
|
||||
)
|
||||
except OSError as exc:
|
||||
if isolate_network:
|
||||
from ..infrastructure.sandbox.network_isolation import spawn_without_network
|
||||
|
||||
command = args if isinstance(args, str) else subprocess.list2cmdline(args)
|
||||
proc = spawn_without_network(command, cwd, env)
|
||||
else:
|
||||
proc = subprocess.Popen(
|
||||
args, shell=shell, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, bufsize=1, env=env, **popen_kwargs,
|
||||
)
|
||||
except (OSError, RuntimeError) as exc: # RuntimeError: NetworkIsolationUnavailable
|
||||
return None, str(exc), False, False, False
|
||||
|
||||
with _active_pids_lock:
|
||||
@@ -266,6 +277,10 @@ def ensure_module(module: str, package: str | None = None):
|
||||
pkg = package or module
|
||||
if pkg in _FAILED or not _can_pip():
|
||||
return None
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return None # not cached in _FAILED: retried once the network is back
|
||||
ok, _ = pip_install(pkg)
|
||||
if not ok:
|
||||
_FAILED.add(pkg)
|
||||
@@ -339,6 +354,10 @@ def pip_install(package: str, cancel: Optional[CancelFn] = None,
|
||||
name) is NOT retried, since repeating it would just waste time."""
|
||||
if not _can_pip():
|
||||
return False, "This packaged build can't install packages at runtime."
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return False, network_guard.refusal(f"pip install {package}")
|
||||
exe = python or sys.executable
|
||||
attempt = 0
|
||||
while True:
|
||||
|
||||
+19
-8
@@ -94,17 +94,28 @@ def find_input_files(folder: Path, exts: set[str] | None = None,
|
||||
capped at ``max_files`` (0 = unlimited), ``total_matched`` is the count
|
||||
before that cap, so a caller can report how many were skipped."""
|
||||
exts = exts or INPUT_EXTS
|
||||
# Do not sort an unbounded recursive tree merely to return a small prefix.
|
||||
# The caller receives a stable lexical order for the bounded result, while
|
||||
# traversal stops as soon as the configured file budget is reached.
|
||||
files: list[Path] = []
|
||||
total = 0
|
||||
try:
|
||||
matched = sorted(
|
||||
f for f in folder.rglob("*")
|
||||
if f.is_file()
|
||||
and not any(part.startswith(".") for part in f.relative_to(folder).parts)
|
||||
and f.suffix.lower() in exts
|
||||
)
|
||||
for f in folder.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
try:
|
||||
relative = f.relative_to(folder)
|
||||
except ValueError:
|
||||
continue
|
||||
if any(part.startswith(".") for part in relative.parts) or f.suffix.lower() not in exts:
|
||||
continue
|
||||
total += 1
|
||||
if max_files <= 0 or len(files) < max_files:
|
||||
files.append(f)
|
||||
except OSError:
|
||||
return [], 0
|
||||
files = matched if max_files <= 0 else matched[:max_files]
|
||||
return files, len(matched)
|
||||
files.sort(key=lambda p: str(p).lower())
|
||||
return files, total
|
||||
|
||||
|
||||
def find_soffice() -> str | None:
|
||||
|
||||
@@ -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] = (
|
||||
|
||||
+71
-3
@@ -12,16 +12,67 @@ 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
|
||||
|
||||
from ..performance import span
|
||||
|
||||
_LIST_CACHE: dict[tuple[str, str, int], List[Dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def _invalidate_history_cache(directory: Path) -> None:
|
||||
prefix = str(Path(directory).resolve())
|
||||
for key in list(_LIST_CACHE):
|
||||
if key[0] == prefix:
|
||||
_LIST_CACHE.pop(key, None)
|
||||
|
||||
|
||||
def new_session_id() -> str:
|
||||
"""Id phiên mới theo mốc thời gian, chính xác tới mili giây."""
|
||||
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.
|
||||
|
||||
@@ -29,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)"
|
||||
|
||||
|
||||
@@ -78,6 +128,7 @@ def save_conversation(
|
||||
# R06-T02: atomic write - see infrastructure/persistence/json/atomic_write.py.
|
||||
from ..infrastructure.persistence.json.atomic_write import write_json
|
||||
write_json(path, payload)
|
||||
_invalidate_history_cache(directory)
|
||||
return path
|
||||
|
||||
|
||||
@@ -85,6 +136,7 @@ def delete_conversation(path) -> None:
|
||||
"""Xoá file hội thoại; không có thì bỏ qua."""
|
||||
try:
|
||||
Path(path).unlink()
|
||||
_invalidate_history_cache(Path(path).parent)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -96,6 +148,7 @@ def rename_conversation(path, new_title: str) -> None:
|
||||
data = load_conversation(path)
|
||||
data["title"] = new_title
|
||||
write_json(Path(path), data)
|
||||
_invalidate_history_cache(Path(path).parent)
|
||||
|
||||
|
||||
def set_pinned(path, pinned: bool) -> None:
|
||||
@@ -105,6 +158,7 @@ def set_pinned(path, pinned: bool) -> None:
|
||||
data = load_conversation(path)
|
||||
data["pinned"] = bool(pinned)
|
||||
write_json(Path(path), data)
|
||||
_invalidate_history_cache(Path(path).parent)
|
||||
|
||||
|
||||
def load_conversation(path: Path) -> Dict[str, Any]:
|
||||
@@ -197,8 +251,16 @@ def list_conversations(directory: Optional[Path] = None, query: str = "") -> Lis
|
||||
if not directory or not directory.exists():
|
||||
return []
|
||||
q = (query or "").strip().lower()
|
||||
try:
|
||||
cache_key = (str(directory.resolve()), q, directory.stat().st_mtime_ns)
|
||||
except OSError:
|
||||
return []
|
||||
cached = _LIST_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
return [dict(item) for item in cached]
|
||||
items: List[Dict[str, Any]] = []
|
||||
for path in directory.glob("*.json"):
|
||||
with span("history.list", query=bool(q)):
|
||||
for path in directory.glob("*.json"):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
@@ -221,4 +283,10 @@ def list_conversations(directory: Optional[Path] = None, query: str = "") -> Lis
|
||||
})
|
||||
# pinned first, then most recent
|
||||
items.sort(key=lambda d: (not d["pinned"], -d["mtime"]))
|
||||
_LIST_CACHE[cache_key] = [dict(item) for item in items]
|
||||
# Keep this bounded; old directory signatures become unreachable after a
|
||||
# write and should not grow process memory forever.
|
||||
if len(_LIST_CACHE) > 256:
|
||||
for old in list(_LIST_CACHE)[:64]:
|
||||
_LIST_CACHE.pop(old, None)
|
||||
return items
|
||||
|
||||
@@ -85,8 +85,10 @@ def get_issue_by_url(config: Dict[str, Any] | None, url: str) -> str:
|
||||
|
||||
def _get(config: Dict[str, Any], path: str, params: dict = None):
|
||||
"""Gọi Jira REST API bằng xác thực cơ bản, qua lớp TLS có ghim chứng chỉ nội bộ."""
|
||||
from ..application.network import network_guard
|
||||
from . import tls_trust
|
||||
|
||||
network_guard.ensure_allowed("Jira")
|
||||
c = _conf(config)
|
||||
url = c["base_url"].rstrip("/") + path
|
||||
# Same TLS auto-recovery the LLM provider calls get (core/tls_trust.py) —
|
||||
|
||||
@@ -147,6 +147,12 @@ def fetch_link_preview(url: str) -> str:
|
||||
return ""
|
||||
if not re.match(r"^https?://", url, re.IGNORECASE):
|
||||
return f"[Link: {url}] (not a fetchable http(s) URL — referenced by address only)"
|
||||
# Every caller (fetch_url, task link attachments, ...) passes through here,
|
||||
# so this one check covers the paths that never saw a ToolContext.
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return f"[Link: {url}] (not fetched — {network_guard.refusal('link fetch')})"
|
||||
# SharePoint / OneDrive share links are rewritten to their direct-download
|
||||
# form so the shared FILE itself is fetched and parsed (like an attachment),
|
||||
# not the share page's HTML shell.
|
||||
|
||||
+12
-1
@@ -88,7 +88,14 @@ class McpServerConnection:
|
||||
def start(self, timeout: float = 15.0) -> None:
|
||||
"""Spawn the server subprocess and complete the MCP handshake.
|
||||
Raises :class:`McpServerError` on failure (bad command, the server
|
||||
crashed on startup, the handshake timed out, ...)."""
|
||||
crashed on startup, the handshake timed out, ...).
|
||||
|
||||
Refused while "Block network" is on: a server process is free to open
|
||||
any socket it likes, so the only safe server is one never started."""
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
raise McpServerError(network_guard.refusal(f"MCP server '{self.name}'"))
|
||||
self._thread = threading.Thread(target=self._run_loop, daemon=True)
|
||||
self._thread.start()
|
||||
if not self._ready.wait(timeout):
|
||||
@@ -174,6 +181,10 @@ class McpServerConnection:
|
||||
|
||||
def call_tool(self, qualified_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""``extra_executor``-shaped result: ``{"ok": bool, "output": str}``."""
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return {"ok": False, "output": network_guard.refusal(f"MCP server '{self.name}'")}
|
||||
tool_name = qualified_name.split(_SEP, 1)[1] if _SEP in qualified_name else qualified_name
|
||||
try:
|
||||
result = self._run_coro(self._session.call_tool(tool_name, args or {}))
|
||||
|
||||
@@ -137,8 +137,34 @@ def _app(tenant_id: str, client_id: str):
|
||||
return app, cache
|
||||
|
||||
|
||||
def _cached_account_offline() -> Optional[dict]:
|
||||
"""First account in the saved token cache, read without any MSAL network setup."""
|
||||
try:
|
||||
import msal
|
||||
|
||||
accounts = _load_cache().find(msal.TokenCache.CredentialType.ACCOUNT)
|
||||
except Exception: # noqa: BLE001 - no msal / unreadable cache = not signed in
|
||||
return None
|
||||
return accounts[0] if accounts else None
|
||||
|
||||
|
||||
def _ensure_network(action: str) -> None:
|
||||
"""Turn a "Block network" refusal into the error type callers already handle."""
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
raise Ms365AuthError(network_guard.refusal(action))
|
||||
|
||||
|
||||
def signed_in_account(tenant_id: str, client_id: str) -> Optional[dict]:
|
||||
"""The cached account, if any — a local cache lookup, no network call."""
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
# Building the MSAL app fetches the tenant's OpenID configuration, so
|
||||
# read the token cache directly instead: the UI still sees who is
|
||||
# signed in without the app reaching login.microsoftonline.com.
|
||||
return _cached_account_offline()
|
||||
try:
|
||||
app, _cache = _app(tenant_id, client_id)
|
||||
except Ms365AuthError:
|
||||
@@ -156,6 +182,7 @@ def sign_in_device_code(tenant_id: str, client_id: str, on_code: Callable[[dict]
|
||||
``verification_uri_complete`` (URL with the code pre-filled, when the tenant
|
||||
returns it) and ``message`` (the full human-readable instruction). Returns
|
||||
the MSAL token result dict; raises Ms365AuthError on failure/timeout."""
|
||||
_ensure_network("Microsoft 365 sign-in")
|
||||
app, cache = _app(tenant_id, client_id)
|
||||
flow = app.initiate_device_flow(scopes=SCOPES)
|
||||
if "user_code" not in flow:
|
||||
@@ -183,6 +210,7 @@ def get_access_token(tenant_id: str, client_id: str) -> str:
|
||||
"""Silently reuse the cached sign-in. Raises Ms365AuthError when there is
|
||||
no valid session — the caller (a Graph call) should surface that as a
|
||||
normal tool failure telling the user to sign in again from Settings."""
|
||||
_ensure_network("Microsoft 365")
|
||||
app, cache = _app(tenant_id, client_id)
|
||||
accounts = app.get_accounts()
|
||||
if not accounts:
|
||||
@@ -228,6 +256,7 @@ def sign_out_default(config=None) -> None:
|
||||
def sign_out(tenant_id: str, client_id: str) -> None:
|
||||
"""Đăng xuất và xoá token của một tenant/client khỏi kho."""
|
||||
try:
|
||||
_ensure_network("Microsoft 365 sign-out") # chặn mạng: chỉ xoá kho token bên dưới
|
||||
app, cache = _app(tenant_id, client_id)
|
||||
for acc in app.get_accounts():
|
||||
app.remove_account(acc)
|
||||
|
||||
@@ -43,6 +43,10 @@ def _request(method: str, url: str, token: str, **kwargs) -> requests.Response:
|
||||
"""Gọi Graph API, tự ghép ``GRAPH_BASE`` cho đường dẫn tương đối và đổi lỗi HTTP
|
||||
thành :class:`Ms365GraphError` kèm thông điệp đọc được.
|
||||
"""
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
raise Ms365GraphError(network_guard.refusal("Microsoft 365 (Graph)"))
|
||||
if not url.startswith("http"):
|
||||
url = f"{GRAPH_BASE}{url}"
|
||||
headers = _headers(token, kwargs.pop("headers", None))
|
||||
|
||||
@@ -24,6 +24,7 @@ project — nothing about it is special-cased in the UI.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
@@ -82,6 +83,53 @@ class Project:
|
||||
return (base or WORKSPACES_DIR) / self.project_id
|
||||
|
||||
|
||||
def _norm_dir(path) -> str:
|
||||
"""Đường dẫn đã chuẩn hoá để đem ra so sánh.
|
||||
|
||||
Bung ``~``, đưa về tuyệt đối, rồi ``normcase`` — trên Windows thì
|
||||
``D:/Work`` và ``d:/work`` là cùng một thư mục, nên so chuỗi thô sẽ
|
||||
cho hai project chiếm chung một chỗ mà không ai biết.
|
||||
"""
|
||||
return os.path.normcase(os.path.abspath(os.path.expanduser(str(path))))
|
||||
|
||||
|
||||
def _cham_nhau(a: str, b: str) -> bool:
|
||||
"""Hai thư mục đã chuẩn hoá có chạm nhau không: trùng, hoặc lồng nhau.
|
||||
|
||||
Lồng nhau cũng tính, vì lý do tồn tại của sandbox là "agent của project này
|
||||
không bao giờ chạm được file của project kia" (xem docstring đầu module).
|
||||
Đứng ở thư mục cha thì đọc/ghi được toàn bộ thư mục con, nên cha-con vẫn là
|
||||
chạm nhau dù hai đường dẫn không giống nhau.
|
||||
"""
|
||||
return a == b or a.startswith(b + os.sep) or b.startswith(a + os.sep)
|
||||
|
||||
|
||||
def folder_conflict(path, *, ignore_id: str = "",
|
||||
directory: Path = None) -> Optional[Project]:
|
||||
"""Project khác đang chiếm ``path``, hoặc ``None`` nếu chưa ai chiếm.
|
||||
|
||||
Mỗi thư mục chỉ được thuộc về một project: thư mục làm việc vừa là sandbox
|
||||
vừa là kho kiến thức dùng chung của project, nên hai project dùng chung một
|
||||
thư mục là đọc lẫn dữ liệu của nhau.
|
||||
|
||||
So theo thư mục THỰC SỰ đang dùng (``workspace_dir()``), không phải theo
|
||||
``output_dir``: project chưa đặt thư mục riêng vẫn đang chiếm thư mục quản
|
||||
lý sẵn của nó, và chính thư mục đó là thứ hay bị chọn nhầm.
|
||||
|
||||
``ignore_id`` là project đang sửa — giữ nguyên thư mục của chính nó thì
|
||||
không phải là trùng.
|
||||
"""
|
||||
if not str(path).strip():
|
||||
return None
|
||||
muon = _norm_dir(path)
|
||||
for project in list_projects(directory):
|
||||
if project.project_id == ignore_id:
|
||||
continue
|
||||
if _cham_nhau(muon, _norm_dir(project.workspace_dir())):
|
||||
return project
|
||||
return None
|
||||
|
||||
|
||||
def _starter_project() -> Project:
|
||||
"""An ordinary (deletable, renamable) project seeded when the projects
|
||||
folder is empty, so the app always opens with somewhere to chat."""
|
||||
|
||||
+48
-4
@@ -218,8 +218,13 @@ class SandboxManager:
|
||||
timeout_sec: int,
|
||||
cancel: Optional[Callable[[], bool]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Dispatch execution to the selected backend."""
|
||||
if backend == "direct":
|
||||
"""Dispatch execution to the selected backend.
|
||||
|
||||
With the network blocked every backend is replaced by the same OS-level
|
||||
isolation: the backends below only ever set proxy env vars, which
|
||||
anything that ignores proxies (raw sockets, ping, .NET WebClient...)
|
||||
walked straight past."""
|
||||
if block_network or backend == "direct":
|
||||
return self._run_direct(command, workdir, block_network, timeout_sec, cancel)
|
||||
|
||||
if backend == "integrity_job_wfp":
|
||||
@@ -274,7 +279,8 @@ class SandboxManager:
|
||||
env = os.environ.copy()
|
||||
if block_network:
|
||||
from .deps import network_blocked_env
|
||||
env = network_blocked_env(env)
|
||||
env = network_blocked_env(env) # belt and braces on top of the OS block
|
||||
return self._run_network_isolated(command, workdir, env, timeout_sec, cancel)
|
||||
|
||||
if cancel is not None:
|
||||
from .deps import run_cancellable
|
||||
@@ -334,4 +340,42 @@ class SandboxManager:
|
||||
"stderr": str(exc),
|
||||
"returncode": -1,
|
||||
"sandbox": "direct",
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _run_network_isolated(
|
||||
command: str,
|
||||
workdir: str,
|
||||
env: Dict[str, str],
|
||||
timeout_sec: int,
|
||||
cancel: Optional[Callable[[], bool]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run ``command`` in a process the OS keeps off the network.
|
||||
|
||||
Fail-closed: when the isolation cannot be set up the command is
|
||||
refused (``sandbox == "blocked"``), never run with the network open."""
|
||||
from .deps import run_cancellable
|
||||
|
||||
rc, output, cancelled, timed_out, exceeded = run_cancellable(
|
||||
command, cwd=workdir or None, timeout=timeout_sec, cancel=cancel,
|
||||
shell=True, env=env, isolate_network=True,
|
||||
)
|
||||
if rc is None and not (cancelled or timed_out or exceeded):
|
||||
return {"ok": False, "stdout": "", "returncode": -1, "sandbox": "blocked",
|
||||
"stderr": ("Command refused: network is blocked and the command could "
|
||||
f"not be isolated from the network ({output.strip()}).")}
|
||||
if cancelled:
|
||||
stderr = "Cancelled by user."
|
||||
elif timed_out:
|
||||
stderr = f"Timeout after {timeout_sec}s"
|
||||
elif exceeded:
|
||||
stderr = "Resource limit exceeded."
|
||||
else:
|
||||
stderr = ""
|
||||
return {
|
||||
"ok": rc == 0 and not (cancelled or timed_out or exceeded),
|
||||
"stdout": output,
|
||||
"stderr": stderr,
|
||||
"returncode": rc if rc is not None else -1,
|
||||
"sandbox": "network_isolated",
|
||||
}
|
||||
|
||||
+1
-13
@@ -19,7 +19,6 @@ in Waiting Input), so executors here run with an auto gate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
@@ -30,6 +29,7 @@ from . import agent_roles
|
||||
from . import agent_security
|
||||
from . import projects
|
||||
from .permissions import PermissionGate
|
||||
from .task_script import run_script as _run_script
|
||||
from .tasks import ARTIFACTS_DIR, resolve_input_text
|
||||
from .tools import ToolContext
|
||||
|
||||
@@ -358,18 +358,6 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
|
||||
return _last_assistant_text(messages), timed_out(), incomplete
|
||||
|
||||
|
||||
def _run_script(command: str, out_dir: Path, timeout_sec: int) -> str:
|
||||
"""Chạy một task kiểu script bằng shell trong thư mục kết quả, có hạn giờ."""
|
||||
if not command.strip():
|
||||
raise RuntimeError("Script task has no command configured.")
|
||||
proc = subprocess.run(command, shell=True, cwd=str(out_dir),
|
||||
capture_output=True, text=True, timeout=max(1, timeout_sec))
|
||||
output = (proc.stdout or "") + (("\n[stderr]\n" + proc.stderr) if proc.stderr else "")
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"Script exited with code {proc.returncode}:\n{output[-2000:]}")
|
||||
return output
|
||||
|
||||
|
||||
def execute_task(ctx, task: Dict[str, Any], run_id: str,
|
||||
emit: Optional[EmitFn] = None, cancel: Optional[CancelFn] = None,
|
||||
tasks_dir: Path = None) -> Dict[str, Any]:
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Run a scheduled task of type ``script`` (tách khỏi ``task_executors.py``).
|
||||
|
||||
Khi công tắc "Chặn mạng" đang bật, lệnh của task chạy trong tiến trình bị hệ
|
||||
điều hành cắt mạng — giống ``run_command`` của agent. Trước đây task script
|
||||
chạy thẳng bằng ``subprocess.run``, không sandbox, nên lên mạng tự do.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_script(command: str, out_dir: Path, timeout_sec: int) -> str:
|
||||
"""Chạy một task kiểu script bằng shell trong thư mục kết quả, có hạn giờ."""
|
||||
if not command.strip():
|
||||
raise RuntimeError("Script task has no command configured.")
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return _run_script_without_network(command, out_dir, timeout_sec)
|
||||
proc = subprocess.run(command, shell=True, cwd=str(out_dir),
|
||||
capture_output=True, text=True, timeout=max(1, timeout_sec))
|
||||
output = (proc.stdout or "") + (("\n[stderr]\n" + proc.stderr) if proc.stderr else "")
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"Script exited with code {proc.returncode}:\n{output[-2000:]}")
|
||||
return output
|
||||
|
||||
|
||||
def _run_script_without_network(command: str, out_dir: Path, timeout_sec: int) -> str:
|
||||
"""Như :func:`run_script`, nhưng tiến trình không có mạng; không cô lập được thì không chạy."""
|
||||
from .deps import network_blocked_env, run_cancellable
|
||||
|
||||
rc, output, _cancelled, timed_out, _exceeded = run_cancellable(
|
||||
command, cwd=str(out_dir), timeout=max(1, timeout_sec), shell=True,
|
||||
env=network_blocked_env(), isolate_network=True,
|
||||
)
|
||||
if timed_out:
|
||||
raise subprocess.TimeoutExpired(command, timeout_sec)
|
||||
if rc is None:
|
||||
raise RuntimeError("Script not run: network is blocked and the script could not be "
|
||||
f"isolated from the network ({output.strip()}).")
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Script exited with code {rc} (network blocked):\n{output[-2000:]}")
|
||||
return output
|
||||
|
||||
|
||||
__all__ = ["run_script"]
|
||||
@@ -43,6 +43,10 @@ class TeamsNotifier:
|
||||
"""Post a notification. Returns ``(ok, detail)``."""
|
||||
if not self.configured:
|
||||
return False, "Teams webhook URL is not configured."
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return False, network_guard.refusal("Teams notification")
|
||||
|
||||
# Workflows webhooks expect an Adaptive Card; classic connectors expect a
|
||||
# MessageCard. Try both, then a plain-text fallback.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
Source HEAD: db80289 (preserved)
|
||||
Branch: perf/fsg-performance
|
||||
|
||||
Baseline: app import 1517ms; config 934ms; MainWindow 1742ms (offscreen, local machine).
|
||||
|
||||
Packets completed:
|
||||
- P0: measured constructor with cProfile; dominant cost was provider model discovery (~0.7s network worker) and eager Workspace composition.
|
||||
- P2: cache history listing by directory mtime/query and coalesce sidebar refresh bursts.
|
||||
- P3: batch streaming Markdown/layout renders at 40ms; final content remains intact.
|
||||
- P4: bounded attachment discovery avoids sorting a full recursive tree when a cap is set.
|
||||
- P5: instrument monitoring log refresh; existing 30-day bounded window retained.
|
||||
- P6: defer provider model discovery to the first Qt event-loop turn.
|
||||
|
||||
After: config 452ms; MainWindow 599ms in the same offscreen smoke benchmark (discovery no longer blocks construction).
|
||||
Streaming render count is now bounded by batch cadence rather than token count.
|
||||
Representative history benchmark: 1,000 files 383.6ms cold / 0.8ms cached on this machine.
|
||||
|
||||
Relevant commits: c5cb258 (perf: defer discovery and reduce UI refresh work).
|
||||
Remaining bottleneck: eager Workspace/Co4E/Folder widget construction and import-time PySide6 overhead.
|
||||
|
||||
Closure pass (starting HEAD 58b5220): Workspace now keeps Co4E, Folder, and GraphRAG as tab placeholders and creates each once on first selection. MainWindow benchmark: 357.6ms; first opens Co4E 143.1ms, Folder 148.0ms, GraphRAG 270.6ms; repeat opens 0.0–2.4ms. Focused lazy navigation/project tests: 15 passed. Pytest temp failures were ACL/path setup issues, not production assertions; a pre-created writable repository-local temp base allowed the focused gates to pass.
|
||||
Remaining startup cost is base PySide6/application import and eager Cowork shell; further lazy work is not justified without broader architectural risk.
|
||||
Performance initiative status: closed for this pass.
|
||||
@@ -234,6 +234,12 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"monitoring.ai_filter_btn": {"en": "AI", "ja": "AI", "vi": "AI"},
|
||||
"monitoring.page_size_label": {
|
||||
"en": "Rows/page:", "ja": "1ページの行数:", "vi": "Số dòng/trang:"},
|
||||
"monitoring.page_indicator": {
|
||||
"en": "Page {page}/{total}", "ja": "{page}/{total} ページ", "vi": "Trang {page}/{total}"},
|
||||
"monitoring.page_prev": {
|
||||
"en": "Previous page", "ja": "前のページ", "vi": "Trang trước"},
|
||||
"monitoring.page_next": {
|
||||
"en": "Next page", "ja": "次のページ", "vi": "Trang sau"},
|
||||
"monitoring.pricing_title": {
|
||||
"en": "Model pricing (USD / 1M tokens)", "ja": "モデル価格表 (USD / 100万トークン)",
|
||||
"vi": "Bảng giá model (USD / 1 triệu token)"},
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -344,12 +344,6 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"en": "Let the control agent review a command with AI before it runs.",
|
||||
"ja": "実行前に制御エージェントがAIでコマンドを確認します。",
|
||||
"vi": "Cho control-agent dùng AI xét lệnh trước khi chạy."},
|
||||
"settings.sandbox_pw_unset_title": {
|
||||
"en": "Sandbox Security", "ja": "サンドボックスセキュリティ", "vi": "Bảo mật Sandbox"},
|
||||
"settings.sandbox_pw_unset_body": {
|
||||
"en": "No sandbox password is set yet, so these settings stay locked. Set COWORK_SANDBOX_PASSWORD, or ask your administrator.",
|
||||
"ja": "サンドボックスのパスワードが未設定のため、この設定はロックされたままです。COWORK_SANDBOX_PASSWORD を設定するか、管理者にお問い合わせください。",
|
||||
"vi": "Chưa đặt mật khẩu sandbox nên nhóm thiết lập này vẫn khóa. Hãy đặt COWORK_SANDBOX_PASSWORD, hoặc liên hệ quản trị viên."},
|
||||
"settings.sandbox_confirm_commands": {
|
||||
"en": "Confirm before Cowork runs a command",
|
||||
"ja": "Cowork がコマンドを実行する前に確認する",
|
||||
|
||||
@@ -177,6 +177,9 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"vi": "Project cho đoạn chat mới"},
|
||||
"app.nav.no_project": {
|
||||
"en": "No project yet", "ja": "プロジェクトなし", "vi": "Chưa có project"},
|
||||
# KHAC no_project: đã có project, chỉ là người dùng chưa chọn cái nào.
|
||||
"app.nav.pick_project": {
|
||||
"en": "Select a project…", "ja": "プロジェクトを選択…", "vi": "Chọn project…"},
|
||||
"app.nav.recents": {"en": "RECENTS", "ja": "最近", "vi": "GẦN ĐÂY"},
|
||||
"app.nav.all_projects": {
|
||||
"en": "All projects…", "ja": "すべてのプロジェクト…", "vi": "Tất cả project…"},
|
||||
|
||||
+17
-33
@@ -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,34 +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."},
|
||||
"settings.sandbox_pw_label": {
|
||||
"en": "Sandbox Security Password", "ja": "サンドボックスセキュリティのパスワード",
|
||||
"vi": "Mật khẩu Bảo mật Sandbox"},
|
||||
"settings.sandbox_pw_placeholder": {
|
||||
"en": "Enter password to edit sandbox settings",
|
||||
"ja": "サンドボックス設定を変更するにはパスワードを入力してください",
|
||||
"vi": "Nhập mật khẩu để sửa thiết lập sandbox"},
|
||||
"settings.sandbox_unlock_btn": {"en": "Unlock", "ja": "ロック解除", "vi": "Mở khoá"},
|
||||
"settings.sandbox_locked": {
|
||||
"en": "Locked (changes disabled)", "ja": "ロック中(変更できません)",
|
||||
"vi": "Đang khoá (không sửa được)"},
|
||||
"settings.sandbox_unlocked": {
|
||||
"en": "Unlocked", "ja": "ロック解除済み", "vi": "Đã mở khoá"},
|
||||
"settings.sandbox_unlocked_body": {
|
||||
"en": "Sandbox settings unlocked.", "ja": "サンドボックス設定のロックを解除しました。",
|
||||
"vi": "Đã mở khoá thiết lập sandbox."},
|
||||
"settings.sandbox_pw_wrong_title": {
|
||||
"en": "Wrong Password", "ja": "パスワードが違います", "vi": "Sai mật khẩu"},
|
||||
"settings.sandbox_pw_wrong_body": {
|
||||
"en": "Password incorrect. Sandbox settings remain locked.",
|
||||
"ja": "パスワードが正しくありません。サンドボックス設定はロックされたままです。",
|
||||
"vi": "Mật khẩu không đúng. Thiết lập sandbox vẫn bị khoá."},
|
||||
"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ớ"},
|
||||
|
||||
@@ -57,6 +57,17 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"en": "Another project is already called \"{name}\". Project names must be unique — the list shows nothing but the name, so two of them cannot be told apart.",
|
||||
"ja": "「{name}」という名前のプロジェクトが既にあります。一覧には名前しか出ないため、同じ名前が二つあると区別できません。",
|
||||
"vi": "Đã có project khác tên \"{name}\". Tên project phải khác nhau — danh sách chỉ hiện tên, trùng tên là không phân biệt được."},
|
||||
"workspace.folder_taken_title": {
|
||||
"en": "Folder already used", "ja": "フォルダーが重複しています",
|
||||
"vi": "Thư mục đã được dùng"},
|
||||
"workspace.folder_taken_body": {
|
||||
"en": "Project \"{name}\" already works in {folder}. One folder belongs to one project only — the folder is that project's sandbox and shared knowledge, so sharing it lets two projects read and overwrite each other's files. Pick another folder.",
|
||||
"ja": "プロジェクト「{name}」が既に {folder} を使用しています。フォルダーは 1 つのプロジェクト専用です — フォルダーはそのプロジェクトのサンドボックス兼共有ナレッジなので、共有すると互いのファイルを読み書きしてしまいます。別のフォルダーを選んでください。",
|
||||
"vi": "Project \"{name}\" đang làm việc trong {folder}. Mỗi thư mục chỉ thuộc về một project — thư mục vừa là sandbox vừa là kho kiến thức chung của project đó, dùng chung là hai project đọc và ghi đè file của nhau. Hãy chọn thư mục khác."},
|
||||
"workspace.folder_shared_warning": {
|
||||
"en": "⚠ This folder is also used by project \"{name}\". One folder belongs to one project only — pick another folder for one of them.",
|
||||
"ja": "⚠ このフォルダーはプロジェクト「{name}」でも使われています。フォルダーは 1 つのプロジェクト専用です — どちらかに別のフォルダーを指定してください。",
|
||||
"vi": "⚠ Thư mục này đang được project \"{name}\" dùng chung. Mỗi thư mục chỉ thuộc về một project — hãy đổi thư mục cho một trong hai."},
|
||||
"workspace.instructions_placeholder": {
|
||||
"en": "e.g. \"All answers in Vietnamese. We are building the X reporting tool; always follow the naming rules …\"",
|
||||
"ja": "例:「回答はすべて日本語で。X レポートツールを開発中。命名規則に従うこと …」",
|
||||
@@ -218,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"},
|
||||
|
||||
@@ -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:
|
||||
@@ -128,6 +128,15 @@ def install_package(ctx: ToolContext, args: Dict[str, Any],
|
||||
package = str(args.get("package", "")).strip()
|
||||
if not package:
|
||||
return {"ok": False, "output": "No package specified."}
|
||||
# ``pip install`` bắt buộc phải ra internet, mà ``deps.pip_install`` chạy
|
||||
# subprocess với ``os.environ`` nguyên vẹn — biến proxy hố đen của
|
||||
# ``network_blocked_env`` không chạm tới nó. Từ chối thẳng ở đây (giống cách
|
||||
# run_command chặn theo tên các công cụ không đi qua proxy) thay vì để pip
|
||||
# thử 600 giây rồi báo một lỗi proxy khó hiểu.
|
||||
if ctx.block_network:
|
||||
return {"ok": False, "output": (
|
||||
"install_package: network access is blocked by the Sandbox Security Layer "
|
||||
"(\"Block network for agent-run commands\" is on in Settings).")}
|
||||
python = _sandbox_python(ctx, cancel, on_output)
|
||||
ok, detail = pip_install(package, cancel=cancel, on_output=on_output, python=python)
|
||||
head = f"Installed {package}." if ok else f"Could not install {package}."
|
||||
|
||||
@@ -6,11 +6,26 @@ tag added in R05-T01/domain/tools/tool_registry.py describes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .tool_context import ToolContext
|
||||
|
||||
|
||||
def _network_refusal(ctx: ToolContext, tool: str) -> Optional[Dict[str, Any]]:
|
||||
"""Lời từ chối khi Sandbox Security Layer đang chặn mạng; None nếu được đi.
|
||||
|
||||
``block_network`` trước đây chỉ được đọc ở ``command_tools.py`` (lệnh shell),
|
||||
nên ba tool mang ``ToolCapability.NETWORK`` ở file này vẫn ra internet bình
|
||||
thường trong khi Monitoring báo "Mạng: Bị chặn". Kiểm ở đây, TRƯỚC mọi lời
|
||||
gọi mạng, để công tắc chặn đúng thứ nó nói là chặn.
|
||||
"""
|
||||
if not ctx.block_network:
|
||||
return None
|
||||
return {"ok": False, "output": (
|
||||
f"{tool}: network access is blocked by the Sandbox Security Layer "
|
||||
"(\"Block network for agent-run commands\" is on in Settings).")}
|
||||
|
||||
|
||||
def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Fetch a URL's text content (web page / online document / SharePoint-
|
||||
OneDrive share link) via link_fetch — the same parser task-link attachments
|
||||
@@ -20,6 +35,9 @@ def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"ok": False, "output": "fetch_url: 'url' is required."}
|
||||
if not url.lower().startswith(("http://", "https://")):
|
||||
return {"ok": False, "output": f"fetch_url: not an http(s) URL: {url}"}
|
||||
blocked = _network_refusal(ctx, "fetch_url")
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
if not ctx.allow_url_fetch:
|
||||
return {"ok": False,
|
||||
"output": ("fetch_url: URL fetching is turned off in Settings → Security "
|
||||
@@ -37,6 +55,9 @@ def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Tìm issue trên Jira bằng JQL."""
|
||||
blocked = _network_refusal(ctx, "jira_search")
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
from cowork_local.core import jira_tool
|
||||
|
||||
out = jira_tool.search(ctx.jira, str(args.get("jql", "")),
|
||||
@@ -47,6 +68,9 @@ def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
def jira_get_issue(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Lấy chi tiết một issue Jira theo mã."""
|
||||
blocked = _network_refusal(ctx, "jira_get_issue")
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
from cowork_local.core import jira_tool
|
||||
|
||||
out = jira_tool.get_issue(ctx.jira, str(args.get("key", "")))
|
||||
|
||||
@@ -37,12 +37,16 @@ class ToolContext:
|
||||
# None (default) = no limits, matching pre-existing behavior.
|
||||
resource_limits: Optional[Dict[str, float]] = None
|
||||
# Sandbox Security Layer — Settings' "Block network for agent commands"
|
||||
# (policy-level, see deps.py::network_blocked_env). False (default) =
|
||||
# — the proxy-env block for shell commands (deps.py::network_blocked_env)
|
||||
# AND a flat refusal from every NETWORK-capability tool, which reaches the
|
||||
# net in-process where proxy env vars mean nothing. False (default) =
|
||||
# unrestricted, matching pre-existing behavior.
|
||||
block_network: bool = False
|
||||
# Whether the fetch_url tool may read URLs — SEPARATE from block_network
|
||||
# (reading a web page/share link for info is safe; running networked shell
|
||||
# commands is the risk). Defaults True; set from agent_security.allow_url_fetch.
|
||||
# Whether the fetch_url tool may read URLs. Its own toggle, but NOT a way
|
||||
# around block_network: with the network blocked every NETWORK-capability
|
||||
# tool is refused first (fetch_tools.py::_network_refusal), so this flag only
|
||||
# decides anything while the network is open. Defaults True; set from
|
||||
# agent_security.allow_url_fetch.
|
||||
allow_url_fetch: bool = True
|
||||
# Jira read connector config (base_url/email/api_token) — None disables the
|
||||
# jira_* tools' ability to connect. Populated from config.data["jira"].
|
||||
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Tiny opt-in performance tracing helpers.
|
||||
|
||||
Tracing is disabled by default and emits only timings/counts, never prompts,
|
||||
credentials, file contents, or provider payloads.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
|
||||
_LOG = logging.getLogger("cowork.performance")
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return os.environ.get("COWORK_PERF_TRACE", "").strip().lower() in {"1", "true", "yes"}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def span(name: str, **fields):
|
||||
if not enabled():
|
||||
yield
|
||||
return
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
elapsed = (time.perf_counter() - started) * 1000.0
|
||||
safe = " ".join(f"{k}={v}" for k, v in fields.items())
|
||||
_LOG.info("perf %s %.1fms%s", name, elapsed, f" {safe}" if safe else "")
|
||||
@@ -115,10 +115,23 @@ class ChatAgentsMixin:
|
||||
if err:
|
||||
self.status_message.emit(tr("chatpanel.agent_list_error", err=err))
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
self._agent_worker = w
|
||||
w.start()
|
||||
# Model discovery can involve a provider/network request. Constructing
|
||||
# the chat panel during startup must not wait for it; schedule it after
|
||||
# the first event-loop turn so the initial shell can paint immediately.
|
||||
from PySide6.QtCore import QTimer
|
||||
|
||||
if getattr(self, "_agent_refresh_pending", False):
|
||||
return
|
||||
self._agent_refresh_pending = True
|
||||
|
||||
def start_worker() -> None:
|
||||
self._agent_refresh_pending = False
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
self._agent_worker = w
|
||||
w.start()
|
||||
|
||||
QTimer.singleShot(0, start_worker)
|
||||
|
||||
def _populate_agents(self, models, keep: str) -> None:
|
||||
"""Đổ danh sách vào bộ chọn Agent.
|
||||
|
||||
@@ -51,6 +51,8 @@ class MessageBubble(QFrame):
|
||||
super().__init__()
|
||||
self.role = role
|
||||
self._text = ""
|
||||
self._stream_pending = False
|
||||
self._render_count = 0
|
||||
self._collapsible = collapsible
|
||||
self._title = title
|
||||
self._head = None
|
||||
@@ -164,12 +166,20 @@ class MessageBubble(QFrame):
|
||||
def append_delta(self, delta: str) -> None:
|
||||
"""Nối thêm một mẩu văn bản đang phát dần từ model rồi vẽ lại dạng markdown."""
|
||||
self._text += delta
|
||||
self.set_markdown(self._text)
|
||||
if not self._stream_pending:
|
||||
self._stream_pending = True
|
||||
QTimer.singleShot(40, self.flush_stream)
|
||||
|
||||
def flush_stream(self) -> None:
|
||||
if self._stream_pending:
|
||||
self._stream_pending = False
|
||||
self.set_markdown(self._text)
|
||||
|
||||
def set_markdown(self, text: str) -> None:
|
||||
"""Đặt toàn bộ nội dung, hiển thị dạng markdown, rồi co giãn lại chiều cao."""
|
||||
self._text = text
|
||||
self.body.setMarkdown(text)
|
||||
self._render_count += 1
|
||||
self._autosize()
|
||||
if self._collapsible:
|
||||
self._update_head()
|
||||
@@ -393,4 +403,3 @@ class ChatView(QScrollArea):
|
||||
ChatHistoryWidget = ChatView
|
||||
|
||||
__all__ = ["ChatView", "ChatHistoryWidget", "MessageBubble"]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -10,13 +10,13 @@ from typing import List, Optional
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtWidgets import QMenu
|
||||
from ...core import co4e
|
||||
from ...core.co4e import STEP_DONE, STEP_RUNNING
|
||||
from ...core.co4e import STEP_RUNNING
|
||||
from ...i18n import tr
|
||||
from ...ui.dialog_buttons import ask_text
|
||||
from ...ui.icons import icon
|
||||
from ...presentation.co4e.co4e_chat_view import _skill_names
|
||||
|
||||
_LOCKED_NODE_STATUSES = (STEP_RUNNING, STEP_DONE)
|
||||
_LOCKED_NODE_STATUSES = (STEP_RUNNING,)
|
||||
|
||||
|
||||
class Co4EWorkflowCrudMixin:
|
||||
@@ -169,8 +169,8 @@ class Co4EWorkflowCrudMixin:
|
||||
def _on_node_selected(self, node_id: str) -> None:
|
||||
"""Chọn một node thì nạp bước đó vào bảng thuộc tính, tự mở bảng nếu đang gập.
|
||||
|
||||
Bước đang chạy hoặc đã chạy xong thì khoá ô nhập liệu ngay khi nạp —
|
||||
tránh sửa nhầm cấu hình của lần chạy đang xem kết quả.
|
||||
Chỉ khoá ô nhập liệu khi bước ĐANG chạy (DF-002) — chạy xong rồi thì
|
||||
vẫn sửa lại được bình thường.
|
||||
"""
|
||||
for n in self.canvas.nodes():
|
||||
if n.id == node_id:
|
||||
|
||||
@@ -294,11 +294,11 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
def set_locked(self, locked: bool) -> None:
|
||||
"""Khoá/mở khoá các trường chỉnh sửa theo trạng thái chạy của bước.
|
||||
|
||||
Bước đang chạy hoặc đã chạy xong thì khoá lại — tránh sửa nhầm cấu
|
||||
hình trong lúc đang xem kết quả của chính lần chạy đó (sửa xong
|
||||
không rõ là áp dụng cho lần chạy đã xong hay lần chạy tiếp theo).
|
||||
Nút Chạy/Chạy từ đây/Xoá bước vẫn hoạt động bình thường khi khoá —
|
||||
chỉ ô nhập liệu bị khoá, không phải cả panel.
|
||||
Chỉ khoá khi bước ĐANG chạy — tránh sửa nhầm cấu hình trong lúc chưa
|
||||
biết kết quả (DF-002: trước đây còn khoá cả bước đã chạy xong, khiến
|
||||
không sửa lại được sau khi run xong). Nút Chạy/Chạy từ đây/Xoá bước
|
||||
vẫn hoạt động bình thường khi khoá — chỉ ô nhập liệu bị khoá, không
|
||||
phải cả panel.
|
||||
"""
|
||||
self._locked = locked
|
||||
editable = not locked
|
||||
|
||||
@@ -110,7 +110,10 @@ class OfficeDocumentRenderer:
|
||||
if self._engine is None:
|
||||
try:
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
from .offline_web_page import install_offline_page
|
||||
self._engine = QWebEngineView()
|
||||
install_offline_page(self._engine)
|
||||
self._owner.stack.addWidget(self._engine)
|
||||
except Exception: # noqa: BLE001
|
||||
self._engine = None
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""HTML preview page that loads nothing from the web while "Block network" is on.
|
||||
|
||||
``QWebEngineView.setHtml`` happily fetches every ``<img src="https://...">``,
|
||||
``<script src>`` and stylesheet the previewed file references — an outbound
|
||||
connection the user never asked for, made by the app itself. The preview gets
|
||||
its own off-the-record profile (so the interceptor below touches no other web
|
||||
view, e.g. the GraphRAG renderer) and every remote request is refused while
|
||||
the switch is on. Local files, ``data:`` and ``qrc:`` URLs still load.
|
||||
|
||||
Lifetime rule: a ``QWebEngineProfile`` must outlive every page that uses it.
|
||||
A profile owned by the view is destroyed *before* the page (children die in
|
||||
creation order) — Qt then warns "Release of profile requested but
|
||||
WebEnginePage still not deleted" and the app can abort later (0xc0000409 in
|
||||
Qt6Core.dll, seen when switching tabs). So there is ONE profile for the whole
|
||||
app, owned by the QApplication, and each page is owned by its view.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtWebEngineCore import (
|
||||
QWebEnginePage, QWebEngineProfile, QWebEngineSettings, QWebEngineUrlRequestInterceptor,
|
||||
)
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from cowork_local.application.network import network_guard
|
||||
|
||||
_REMOTE_SCHEMES = frozenset({"http", "https", "ws", "wss", "ftp"})
|
||||
_profile: Optional[QWebEngineProfile] = None
|
||||
_blocker: Optional["RemoteRequestBlocker"] = None # Python must hold it, or it is collected
|
||||
|
||||
|
||||
class RemoteRequestBlocker(QWebEngineUrlRequestInterceptor):
|
||||
"""Refuses remote URLs whenever the network guard says the network is blocked."""
|
||||
|
||||
def interceptRequest(self, info) -> None: # noqa: N802 - Qt override
|
||||
"""Called by WebEngine for every request the page makes."""
|
||||
if info.requestUrl().scheme().lower() in _REMOTE_SCHEMES and network_guard.is_blocked():
|
||||
info.block(True)
|
||||
|
||||
|
||||
def preview_profile() -> QWebEngineProfile:
|
||||
"""The app-wide off-the-record profile shared by every HTML preview."""
|
||||
global _profile, _blocker
|
||||
if _profile is None:
|
||||
_profile = QWebEngineProfile(QApplication.instance()) # no storage name = off the record
|
||||
_blocker = RemoteRequestBlocker(_profile)
|
||||
_profile.setUrlRequestInterceptor(_blocker)
|
||||
_profile.settings().setAttribute(
|
||||
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, True)
|
||||
return _profile
|
||||
|
||||
|
||||
def install_offline_page(view) -> None:
|
||||
"""Give ``view`` a page on the shared preview profile.
|
||||
|
||||
The previewed file is loaded with a ``file://`` base URL, and Qt refuses
|
||||
every remote resource of such a page unless
|
||||
``LocalContentCanAccessRemoteUrls`` is on — so web images never showed,
|
||||
even with the network open. The switch is turned on for the profile; the
|
||||
interceptor is what keeps remote content out while "Block network" is on.
|
||||
"""
|
||||
view.setPage(QWebEnginePage(preview_profile(), view))
|
||||
|
||||
|
||||
__all__ = ["RemoteRequestBlocker", "install_offline_page", "preview_profile"]
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Khoá phạm vi quét của màn GraphRAG vào một project.
|
||||
|
||||
Tách khỏi ``graph_renderer.py``: file đó đã ở 399/400 dòng — đúng một dòng
|
||||
trước trần của ``scripts/check_loc.py``, và cổng ấy nói rõ cách duy nhất đúng
|
||||
khi chạm trần là tách file, không phải nới con số. Khối này là chỗ tự nhiên
|
||||
để cắt: ba phương thức dưới đây chỉ nói về một việc — project nào đang khoá,
|
||||
và thư mục nào đi theo nó — còn phần còn lại của renderer lo việc vẽ.
|
||||
|
||||
Là mixin chứ không phải đối tượng rời, cùng lý do như ``NavRailMixin``: ba
|
||||
phương thức này đọc/ghi state của chính renderer (``project_combo``,
|
||||
``path_edit``, ``_needs_scan``…). Biến thành đối tượng cộng tác thì phải viết
|
||||
lại từng chỗ ``self.X`` thành ``self.renderer.X`` mà không đổi hành vi gì.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
|
||||
|
||||
class GraphProjectLockMixin:
|
||||
"""Ba phương thức khoá-theo-project. Trộn vào ``GraphRenderer``."""
|
||||
|
||||
def _refresh_project_combo(self) -> None:
|
||||
"""Nạp lại danh sách project vào bộ chọn, giữ nguyên project đang chọn."""
|
||||
from cowork_local.core.projects import list_projects
|
||||
|
||||
keep = self._active_project_id
|
||||
self.project_combo.blockSignals(True)
|
||||
self.project_combo.clear()
|
||||
self.project_combo.addItem(tr("structure.project_none"), "")
|
||||
row_to_select = 0
|
||||
for i, p in enumerate(list_projects(), start=1):
|
||||
self.project_combo.addItem(p.name, p.project_id)
|
||||
if p.project_id == keep:
|
||||
row_to_select = i
|
||||
self.project_combo.setCurrentIndex(row_to_select)
|
||||
self.project_combo.blockSignals(False)
|
||||
|
||||
def set_project(self, project_id: str) -> None:
|
||||
"""Khoá phạm vi quét vào một project (chuỗi rỗng là bỏ khoá)."""
|
||||
pid = project_id or ""
|
||||
self._refresh_project_combo()
|
||||
target = self.project_combo.findData(pid)
|
||||
if target < 0:
|
||||
target = 0
|
||||
if self.project_combo.currentIndex() == target:
|
||||
self._on_project_changed(target)
|
||||
else:
|
||||
self.project_combo.setCurrentIndex(target)
|
||||
|
||||
def _on_project_changed(self, _idx: int) -> None:
|
||||
"""Áp trạng thái khoá: đường dẫn chuyển sang chỉ đọc và trỏ vào thư mục"""
|
||||
from cowork_local.core.projects import load_project
|
||||
|
||||
pid = self.project_combo.currentData() or ""
|
||||
project_changed = pid != self._active_project_id
|
||||
self._active_project_id = pid
|
||||
locked = bool(pid)
|
||||
self.path_edit.setReadOnly(locked)
|
||||
self._pick_btn.setEnabled(not locked)
|
||||
if locked:
|
||||
project = load_project(pid)
|
||||
if project is not None:
|
||||
self.path_edit.setText(str(project.workspace_dir()))
|
||||
# Changing the FOLDER changes what we scan just as much as changing the
|
||||
# project does. Keying this off the id alone left the path in the bar
|
||||
# updated while the graph in the middle still showed the old folder's
|
||||
# nodes: "Đổi" in the Project screen moves the folder, never the id.
|
||||
duong_dan = self.path_edit.text().strip()
|
||||
doi_muc_tieu = project_changed or duong_dan != self._active_path
|
||||
self._active_path = duong_dan
|
||||
if doi_muc_tieu:
|
||||
self.project_changed.emit() # GraphQaWidget drops its temp extraction cache
|
||||
# Mark it and scan on the next visit rather than now — see
|
||||
# auto_scan_and_fit()'s docstring for why.
|
||||
self._needs_scan = True
|
||||
# ...except when this screen is the one on show. The picker lives HERE,
|
||||
# so a user changing project is already looking at the graph: there is
|
||||
# no "next visit" to defer to, and they had to press Scan by hand.
|
||||
# Deferring still applies when the change came from the Workspace
|
||||
# screen while this one is hidden, which is what it was for.
|
||||
if self.isVisible() and duong_dan:
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
@@ -27,6 +27,7 @@ from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.graph import graph_export
|
||||
from cowork_local.presentation.graph.graph_messages_view import GraphMessagesView
|
||||
from cowork_local.presentation.graph.graph_project_lock import GraphProjectLockMixin
|
||||
from cowork_local.presentation.graph.graph_scene_builder import build_scene
|
||||
from cowork_local.presentation.graph.graph_scene_items import _Bridge, _Edge, _GraphView, _Node
|
||||
from cowork_local.presentation.shared import HAS_WEB_ENGINE
|
||||
@@ -35,7 +36,7 @@ from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class GraphRenderer(QWidget):
|
||||
class GraphRenderer(GraphProjectLockMixin, QWidget):
|
||||
"""Nửa "đồ thị" của màn GraphRAG: thanh công cụ, khung xem và vòng đời quét."""
|
||||
status_message = Signal(str)
|
||||
node_selected = Signal(object) # a node's .data, whenever the scene selection changes
|
||||
@@ -60,6 +61,8 @@ class GraphRenderer(QWidget):
|
||||
self._needs_scan = False
|
||||
self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite)
|
||||
self._active_project_id = "" # "" = free path; set = scan locked to that project's sandbox
|
||||
# The folder last scanned — see graph_project_lock.py.
|
||||
self._active_path = ""
|
||||
|
||||
self._rescan_timer = QTimer(self)
|
||||
self._rescan_timer.setSingleShot(True)
|
||||
@@ -154,63 +157,6 @@ class GraphRenderer(QWidget):
|
||||
"""
|
||||
return [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)]
|
||||
|
||||
# ---- project sandbox lock ------------------------------------------------- #
|
||||
def _refresh_project_combo(self) -> None:
|
||||
"""Nạp lại danh sách project vào bộ chọn, giữ nguyên project đang chọn."""
|
||||
from cowork_local.core.projects import list_projects
|
||||
|
||||
keep = self._active_project_id
|
||||
self.project_combo.blockSignals(True)
|
||||
self.project_combo.clear()
|
||||
self.project_combo.addItem(tr("structure.project_none"), "")
|
||||
row_to_select = 0
|
||||
for i, p in enumerate(list_projects(), start=1):
|
||||
self.project_combo.addItem(p.name, p.project_id)
|
||||
if p.project_id == keep:
|
||||
row_to_select = i
|
||||
self.project_combo.setCurrentIndex(row_to_select)
|
||||
self.project_combo.blockSignals(False)
|
||||
|
||||
def set_project(self, project_id: str) -> None:
|
||||
"""Khoá phạm vi quét vào một project (chuỗi rỗng là bỏ khoá)."""
|
||||
pid = project_id or ""
|
||||
self._refresh_project_combo()
|
||||
target = self.project_combo.findData(pid)
|
||||
if target < 0:
|
||||
target = 0
|
||||
if self.project_combo.currentIndex() == target:
|
||||
self._on_project_changed(target)
|
||||
else:
|
||||
self.project_combo.setCurrentIndex(target)
|
||||
|
||||
def _on_project_changed(self, _idx: int) -> None:
|
||||
"""Áp trạng thái khoá: đường dẫn chuyển sang chỉ đọc và trỏ vào thư mục"""
|
||||
from cowork_local.core.projects import load_project
|
||||
|
||||
pid = self.project_combo.currentData() or ""
|
||||
project_changed = pid != self._active_project_id
|
||||
self._active_project_id = pid
|
||||
locked = bool(pid)
|
||||
self.path_edit.setReadOnly(locked)
|
||||
self._pick_btn.setEnabled(not locked)
|
||||
if locked:
|
||||
project = load_project(pid)
|
||||
if project is not None:
|
||||
self.path_edit.setText(str(project.workspace_dir()))
|
||||
if project_changed:
|
||||
self.project_changed.emit() # GraphQaWidget drops its temp extraction cache
|
||||
# Mark it and scan on the next visit rather than now — see
|
||||
# auto_scan_and_fit()'s docstring for why.
|
||||
self._needs_scan = True
|
||||
# ...except when this screen is the one on show. The picker lives HERE,
|
||||
# so a user changing project is already looking at the graph: there is
|
||||
# no "next visit" to defer to, and they had to press Scan by hand.
|
||||
# Deferring still applies when the change came from the Workspace
|
||||
# screen while this one is hidden, which is what it was for.
|
||||
if self.isVisible() and self.path_edit.text().strip():
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
|
||||
# ---- helpers ---------------------------------------------------------------- #
|
||||
def _pick(self) -> None:
|
||||
"""Mở hộp thoại chọn thư mục gốc để quét."""
|
||||
@@ -237,7 +183,7 @@ class GraphRenderer(QWidget):
|
||||
# ---- prewarm / scan lifecycle -------------------------------------------------- #
|
||||
def prewarm(self) -> None:
|
||||
"""Pay for the graph view before it is clicked on, not during."""
|
||||
if not HAS_WEB_ENGINE or self.web is not None:
|
||||
if self.web is not None:
|
||||
return
|
||||
self._ensure_web()
|
||||
if self._graph is None and self.path_edit.text().strip():
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -19,6 +19,7 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel, QTabWidget, QVBoxLayout, QWid
|
||||
from ...core import audit_log
|
||||
from ...i18n import on_language_changed, tr
|
||||
from ...state import AppContext
|
||||
from ...performance import span
|
||||
from .tabs.action_logs_tab import ActionLogsTab
|
||||
from .tabs.agent_status_tab import AgentStatusTab
|
||||
from .tabs.mcp_tab import McpTab
|
||||
@@ -39,8 +40,9 @@ _UNBOUNDED_PAGE_SIZE = 100_000
|
||||
# day-sharded JSONL — unbounded start/end means EVERY day file ever written
|
||||
# gets re-read and re-parsed on EVERY tick, which is what actually made
|
||||
# Monitoring "gây nặng khi log lớn" (see DF-006): the slowness was never in
|
||||
# rendering (EventTable already caps display at 300 rows — see
|
||||
# shared/event_table.py::_MAX_ROWS), it was this repeated full-history read.
|
||||
# rendering (EventTable paginates client-side, 5-100 rows/page — see
|
||||
# shared/event_table.py::_DEFAULT_PAGE_SIZE), it was this repeated full-history
|
||||
# read.
|
||||
# 30 days is a live-monitoring window, not a hard retention limit — nothing
|
||||
# is deleted, older days are simply not re-read on every 3s tick.
|
||||
_LOG_WINDOW_DAYS = 30
|
||||
@@ -281,12 +283,13 @@ class MonitoringTab(QWidget):
|
||||
"""
|
||||
start = date.today() - timedelta(days=_LOG_WINDOW_DAYS)
|
||||
shared_dir = self.ctx.config.shared_dir
|
||||
if shared_dir:
|
||||
from ...core import telemetry_shared
|
||||
shared_events = telemetry_shared.load_shared_audit_events(shared_dir, start=start)
|
||||
if shared_events:
|
||||
return shared_events
|
||||
return audit_log.load_events(start=start)
|
||||
with span("monitoring.load_events", window_days=_LOG_WINDOW_DAYS):
|
||||
if shared_dir:
|
||||
from ...core import telemetry_shared
|
||||
shared_events = telemetry_shared.load_shared_audit_events(shared_dir, start=start)
|
||||
if shared_events:
|
||||
return shared_events
|
||||
return audit_log.load_events(start=start)
|
||||
|
||||
def _apply_events_to_event_tabs(self, events: List[dict]) -> None:
|
||||
"""Filters the ALREADY-LOADED event list (see ``_load_events`` — one
|
||||
|
||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt
|
||||
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt, Signal
|
||||
from PySide6.QtGui import QBrush, QColor
|
||||
from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
|
||||
|
||||
@@ -17,8 +17,8 @@ from ....ui.icons import DOT_GREEN, DOT_RED, icon
|
||||
from .badges import action_label
|
||||
from .formatters import agent_avatar_icon, fmt_event_time
|
||||
|
||||
_MAX_ROWS = 300
|
||||
PAGE_SIZE_OPTIONS = (50, 100, 300, 500, 1000)
|
||||
_DEFAULT_PAGE_SIZE = 20
|
||||
PAGE_SIZE_OPTIONS = (5, 10, 20, 50, 100)
|
||||
|
||||
|
||||
class _TimeItem(QTableWidgetItem):
|
||||
@@ -63,6 +63,12 @@ class EventTable(QTableWidget):
|
||||
"secret_in_output": "warning",
|
||||
}
|
||||
|
||||
# Emitted whenever the rendered page changes (new data, page-size change,
|
||||
# or prev/next navigation) — args are (current_page, page_count), both
|
||||
# 1-based-friendly in that current_page is 0-indexed but page_count is a
|
||||
# plain count. filter_scaffold.py's pager label/buttons listen to this.
|
||||
page_changed = Signal(int, int)
|
||||
|
||||
def __init__(self, show_result: bool = True):
|
||||
# Security Events drops the result column entirely (see _ACTION_TINTS).
|
||||
"""Bảng sự kiện dùng chung của các tab Giám sát.
|
||||
@@ -71,8 +77,10 @@ class EventTable(QTableWidget):
|
||||
là thất bại nên cột ấy chỉ tốn chỗ.
|
||||
"""
|
||||
self._show_result = show_result
|
||||
self._page_size = _MAX_ROWS
|
||||
self._page_size = _DEFAULT_PAGE_SIZE
|
||||
self._current_page = 0
|
||||
self._last_events: List[dict] = []
|
||||
self._sorted_events: List[dict] = []
|
||||
super().__init__(0, 7 if show_result else 6)
|
||||
self.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
self.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
@@ -105,21 +113,56 @@ class EventTable(QTableWidget):
|
||||
"""Số dòng đang hiển thị mỗi trang."""
|
||||
return self._page_size
|
||||
|
||||
def page_count(self) -> int:
|
||||
"""Tổng số trang với dữ liệu và số dòng/trang hiện tại (tối thiểu 1)."""
|
||||
if not self._sorted_events:
|
||||
return 1
|
||||
return -(-len(self._sorted_events) // self._page_size) # ceil div
|
||||
|
||||
def current_page(self) -> int:
|
||||
"""Trang đang hiển thị, đánh số từ 0."""
|
||||
return self._current_page
|
||||
|
||||
def go_to_page(self, page: int) -> None:
|
||||
"""Nhảy tới một trang cụ thể (đánh số từ 0), tự kẹp trong khoảng hợp lệ."""
|
||||
self._current_page = page
|
||||
self._render_current_page()
|
||||
|
||||
def next_page(self) -> None:
|
||||
"""Sang trang kế — không làm gì nếu đã ở trang cuối."""
|
||||
self.go_to_page(self._current_page + 1)
|
||||
|
||||
def prev_page(self) -> None:
|
||||
"""Về trang trước — không làm gì nếu đã ở trang đầu."""
|
||||
self.go_to_page(self._current_page - 1)
|
||||
|
||||
def set_page_size(self, n: int) -> None:
|
||||
"""Đổi số dòng hiển thị mỗi trang rồi vẽ lại với dữ liệu đã có sẵn
|
||||
(không cần refresh lại từ nguồn — set_events() đã lưu lại lần đổ gần nhất)."""
|
||||
"""Đổi số dòng hiển thị mỗi trang, quay về trang đầu, rồi vẽ lại với dữ
|
||||
liệu đã có sẵn (không cần refresh lại từ nguồn)."""
|
||||
self._page_size = n
|
||||
self.set_events(self._last_events)
|
||||
self._current_page = 0
|
||||
self._render_current_page()
|
||||
|
||||
def set_events(self, events: List[dict]) -> None:
|
||||
"""Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, cắt ở ``self._page_size``
|
||||
(đổi được qua ``set_page_size`` — control "Số dòng/trang" ở filter_scaffold.py).
|
||||
"""Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, chia trang theo
|
||||
``self._page_size`` — xem qua trang khác bằng ``next_page``/``prev_page``
|
||||
(nút tiến/lùi ở filter_scaffold.py), không còn bị cắt bỏ vĩnh viễn như
|
||||
trước (DF-006)."""
|
||||
self._last_events = events
|
||||
self._sorted_events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)
|
||||
self._current_page = 0
|
||||
self._render_current_page()
|
||||
|
||||
def _render_current_page(self) -> None:
|
||||
"""Vẽ đúng một trang (theo ``self._current_page``/``self._page_size``)
|
||||
từ ``self._sorted_events`` đã sắp sẵn.
|
||||
|
||||
Tắt sắp xếp trong lúc đổ dữ liệu — để bật, Qt sắp lại sau mỗi dòng và việc
|
||||
nạp chậm đi theo bậc hai.
|
||||
"""
|
||||
self._last_events = events
|
||||
events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:self._page_size]
|
||||
self._current_page = max(0, min(self._current_page, self.page_count() - 1))
|
||||
start = self._current_page * self._page_size
|
||||
events = self._sorted_events[start:start + self._page_size]
|
||||
self.setSortingEnabled(False)
|
||||
self.setRowCount(len(events))
|
||||
for row, ev in enumerate(events):
|
||||
@@ -164,6 +207,7 @@ class EventTable(QTableWidget):
|
||||
self.setItem(row, col, item)
|
||||
self.setSortingEnabled(True)
|
||||
self.apply_filter(getattr(self, "_filter_needle", ""))
|
||||
self.page_changed.emit(self._current_page, self.page_count())
|
||||
|
||||
def apply_filter(self, needle: str) -> None:
|
||||
"""Ẩn/hiện dòng theo từ khoá tìm kiếm (không phân biệt hoa thường)."""
|
||||
|
||||
@@ -95,20 +95,52 @@ def build_filter_scaffold(
|
||||
if with_page_size and isinstance(table, EventTable):
|
||||
# DF-006: the item-per-page count was never surfaced anywhere in
|
||||
# the UI (design called for it) — EventTable already trims to a
|
||||
# page size internally (default 300), this just makes that
|
||||
# number visible AND user-choosable instead of a fixed constant.
|
||||
# page size internally, this just makes that number visible AND
|
||||
# user-choosable instead of a fixed constant.
|
||||
page_size_lbl = QLabel(tr("monitoring.page_size_label"))
|
||||
page_size_combo = QComboBox()
|
||||
for n in PAGE_SIZE_OPTIONS:
|
||||
page_size_combo.addItem(str(n), n)
|
||||
current = table.page_size()
|
||||
page_size_combo.setCurrentIndex(
|
||||
PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 2)
|
||||
PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 0)
|
||||
page_size_combo.currentIndexChanged.connect(
|
||||
lambda i: table.set_page_size(page_size_combo.itemData(i)))
|
||||
row.addWidget(page_size_lbl)
|
||||
row.addWidget(page_size_combo)
|
||||
parts.update(page_size_label=page_size_lbl, page_size_combo=page_size_combo)
|
||||
|
||||
# DF-006 follow-up: trimming to a page size alone silently dropped
|
||||
# every row past it with no way back to see them — prev/next
|
||||
# buttons plus a "trang X/Y" indicator make the rest reachable.
|
||||
page_prev_btn = QPushButton()
|
||||
page_prev_btn.setIcon(icon("chevron-left"))
|
||||
page_prev_btn.setCursor(Qt.PointingHandCursor)
|
||||
bind_tip(page_prev_btn, "monitoring.page_prev")
|
||||
page_next_btn = QPushButton()
|
||||
page_next_btn.setIcon(icon("chevron-right"))
|
||||
page_next_btn.setCursor(Qt.PointingHandCursor)
|
||||
bind_tip(page_next_btn, "monitoring.page_next")
|
||||
page_indicator_lbl = QLabel()
|
||||
|
||||
def _refresh_pager(cur: int = None, total: int = None) -> None:
|
||||
if cur is None or total is None:
|
||||
cur, total = table.current_page(), table.page_count()
|
||||
page_indicator_lbl.setText(tr("monitoring.page_indicator", page=cur + 1, total=total))
|
||||
page_prev_btn.setEnabled(cur > 0)
|
||||
page_next_btn.setEnabled(cur < total - 1)
|
||||
|
||||
page_prev_btn.clicked.connect(table.prev_page)
|
||||
page_next_btn.clicked.connect(table.next_page)
|
||||
table.page_changed.connect(_refresh_pager)
|
||||
_refresh_pager()
|
||||
|
||||
row.addWidget(page_prev_btn)
|
||||
row.addWidget(page_indicator_lbl)
|
||||
row.addWidget(page_next_btn)
|
||||
parts.update(
|
||||
page_size_label=page_size_lbl, page_size_combo=page_size_combo,
|
||||
page_prev_btn=page_prev_btn, page_next_btn=page_next_btn,
|
||||
page_indicator_label=page_indicator_lbl, page_pager_refresh=_refresh_pager)
|
||||
lay.addLayout(row)
|
||||
parts.update(filter_edit=search, ai_filter_btn=ai_btn)
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ class ActionLogsTab(QWidget):
|
||||
self.ai_filter_btn = parts["ai_filter_btn"]
|
||||
self.detail_panel = parts["detail_panel"]
|
||||
self.page_size_label = parts["page_size_label"]
|
||||
self.page_pager_refresh = parts["page_pager_refresh"]
|
||||
|
||||
def set_events(self, events: List[dict]) -> None:
|
||||
"""Đổ danh sách sự kiện vào bảng."""
|
||||
@@ -47,6 +48,7 @@ class ActionLogsTab(QWidget):
|
||||
self.title_lbl.setText(tr(self.title_key))
|
||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
||||
self.page_pager_refresh()
|
||||
|
||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
||||
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
||||
|
||||
@@ -34,6 +34,7 @@ class McpTab(QWidget):
|
||||
self.ai_filter_btn = parts["ai_filter_btn"]
|
||||
self.detail_panel = parts["detail_panel"]
|
||||
self.page_size_label = parts["page_size_label"]
|
||||
self.page_pager_refresh = parts["page_pager_refresh"]
|
||||
|
||||
def set_events(self, events: List[dict]) -> None:
|
||||
"""Đổ danh sách sự kiện vào bảng."""
|
||||
@@ -47,6 +48,7 @@ class McpTab(QWidget):
|
||||
self.title_lbl.setText(tr(self.title_key))
|
||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
||||
self.page_pager_refresh()
|
||||
|
||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
||||
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
||||
|
||||
@@ -40,6 +40,7 @@ class SecurityEventsTab(QWidget):
|
||||
self.ai_filter_btn = parts["ai_filter_btn"]
|
||||
self.detail_panel = parts["detail_panel"]
|
||||
self.page_size_label = parts["page_size_label"]
|
||||
self.page_pager_refresh = parts["page_pager_refresh"]
|
||||
|
||||
def set_events(self, events: List[dict]) -> None:
|
||||
"""Đổ danh sách sự kiện vào bảng."""
|
||||
@@ -53,6 +54,7 @@ class SecurityEventsTab(QWidget):
|
||||
self.title_lbl.setText(tr(self.title_key))
|
||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
||||
self.page_pager_refresh()
|
||||
|
||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
||||
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
||||
|
||||
@@ -216,7 +216,8 @@ class ToolsAdminTab(QWidget):
|
||||
result. Respects the fetch_url toggle: when web access is OFF the agent
|
||||
cannot reach the internet, so the test reports that instead of probing."""
|
||||
disabled = ("fetch_url" in self.ctx.config.tools_disabled
|
||||
or not bool(self.ctx.config.agent_security.get("allow_url_fetch", True)))
|
||||
or not bool(self.ctx.config.agent_security.get("allow_url_fetch", True))
|
||||
or bool(self.ctx.config.agent_security.get("block_network", False)))
|
||||
if disabled:
|
||||
self.test_internet_status.setText(tr("tools_admin.internet_disabled"))
|
||||
self.test_internet_status.setStyleSheet("color: #c00;")
|
||||
|
||||
@@ -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"]
|
||||
@@ -27,7 +27,14 @@ def _frozen_onefile() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# QtWebEngine is noisy and unreliable on the macOS runtime we support (GPU/
|
||||
# helper-process failures leave the stacked view blank). The native Qt graph is
|
||||
# already available and avoids that failure path entirely.
|
||||
HAS_WEB_ENGINE = False
|
||||
|
||||
try: # WebEngine + WebChannel are optional PySide6 add-ons
|
||||
if sys.platform == "darwin":
|
||||
raise ImportError("use native graph renderer on macOS")
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401
|
||||
from PySide6.QtWebChannel import QWebChannel # noqa: F401
|
||||
HAS_WEB_ENGINE = not _frozen_onefile()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,9 +32,7 @@ from .rail_metrics import _NAV_MIN_WIDTH
|
||||
from .tray_manager import TrayManager
|
||||
from ...state import AppContext
|
||||
from ...core.task_scheduler import TaskScheduler
|
||||
from ...ui.cowork_tab import CoworkTab
|
||||
from ...ui.sidebar import HistorySidebar
|
||||
from ..graph.structure_graph_view import StructureGraphView
|
||||
from ...ui.workspace_tab import WorkspaceTab
|
||||
|
||||
|
||||
@@ -111,10 +109,9 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
|
||||
# Workspace screen (per selected project). GraphRAG's heavy
|
||||
# QtWebEngine is still built lazily on first display
|
||||
# (StructureGraphView._ensure_web).
|
||||
from ...ui.cowork_tab import CoworkTab
|
||||
self.cowork = CoworkTab(ctx)
|
||||
self.structure = StructureGraphView(ctx)
|
||||
self.structure.status_message.connect(self.statusBar().showMessage)
|
||||
self.cowork.output_changed.connect(self.structure.schedule_rescan)
|
||||
self.structure = None
|
||||
self.cowork.status_message.connect(self.statusBar().showMessage)
|
||||
# Refresh History (list + running markers + current highlight) whenever a
|
||||
# conversation is created/updated or a turn finishes.
|
||||
|
||||
@@ -197,9 +197,9 @@ class NavRailMixin:
|
||||
def _nav_rows(self):
|
||||
"""(tree, page, sub, label, icon, enabled) for every row, rail order.
|
||||
|
||||
Workspace contributes all five of its sub-views — including the two the
|
||||
project gate currently disables — so the rail never changes shape while
|
||||
the user is looking at it.
|
||||
Workspace contributes all five of its sub-views; ``_rebuild_nav`` bỏ
|
||||
những hàng mà cổng project đang đóng (Cowork, GraphRAG) thay vì hiện
|
||||
chúng ở dạng mờ.
|
||||
"""
|
||||
rows = [(self.nav, self._ROW_WORKSPACE, sub, label, ic, on)
|
||||
for label, sub, ic, on in self.workspace.nav_entries()]
|
||||
@@ -238,15 +238,13 @@ class NavRailMixin:
|
||||
tree.clear()
|
||||
tree.blockSignals(blocked)
|
||||
for tree, page, sub, label, icon_name, enabled in spec:
|
||||
if not enabled:
|
||||
# Cổng project đóng → bỏ hẳn hàng, không hiện dạng mờ nữa.
|
||||
continue
|
||||
it = QTreeWidgetItem([""] if self._nav_collapsed else [label])
|
||||
it.setIcon(0, _icon(icon_name))
|
||||
it.setData(0, Qt.UserRole, {"page": page, "sub": sub})
|
||||
if not enabled:
|
||||
# Same gate as before, shown instead of hidden: the row stays
|
||||
# in place, greyed, and says why it cannot be opened.
|
||||
it.setDisabled(True)
|
||||
it.setToolTip(0, tr("app.nav.needs_project"))
|
||||
elif self._nav_collapsed:
|
||||
if self._nav_collapsed:
|
||||
it.setToolTip(0, label)
|
||||
blocked = tree.blockSignals(True)
|
||||
tree.addTopLevelItem(it)
|
||||
|
||||
@@ -142,10 +142,11 @@ class PageRegistryMixin:
|
||||
Vệt sáng trên thanh menu cũng cập nhật ở đây, để nó đi theo NỘI DUNG chứ
|
||||
không theo thứ vừa được bấm.
|
||||
"""
|
||||
was_page = self.pages.currentIndex()
|
||||
self._ensure_page(page) # build lazy page on first visit
|
||||
self.pages.setCurrentIndex(page)
|
||||
if page == self._ROW_WORKSPACE:
|
||||
self.workspace.refresh() # re-list projects + threads on entry
|
||||
if page == self._ROW_WORKSPACE and was_page != page:
|
||||
self.workspace.refresh() # refresh only when entering Workspace
|
||||
widget = self._page_widgets[page]
|
||||
if sub is not None and hasattr(widget, "select_subtab"):
|
||||
# Enforce the project gate here rather than at each entry point. A
|
||||
|
||||
@@ -19,9 +19,8 @@ _NAV_ROW_GAP = 6
|
||||
# Không đặt bằng ``margin`` trong QSS: margin của stylesheet được vẽ BÊN TRONG
|
||||
# hộp của widget, mà nút này lại bị ``_rebuild_nav`` ghim đúng chiều cao một
|
||||
# dòng menu — nên margin không mua được một pixel khoảng cách nào.
|
||||
# 10 -> 4: đủ để Cài đặt không dính vào nhóm Dashboard/Giám sát, nhưng không
|
||||
# rộng đến mức trông như hai khu tách rời.
|
||||
_NAV_SETTINGS_GAP = 4
|
||||
# Settings dùng cùng nhịp hàng với Dashboard và Monitoring.
|
||||
_NAV_SETTINGS_GAP = 0
|
||||
# 132 -> 232: o 132px nhan "Cuoc tro chuyen moi" bi cat mat chu. San phai du
|
||||
# rong cho nhan DAI NHAT tren thanh, khong phai cho nhan trung binh.
|
||||
_NAV_MIN_WIDTH = 232
|
||||
|
||||
@@ -42,6 +42,12 @@ class RailProjectMixin:
|
||||
# No project yet: say so, and say what to do about it, instead of
|
||||
# leaving an empty box and a button that silently does nothing.
|
||||
self.nav_project.addItem(tr("app.nav.no_project"), "")
|
||||
elif not current:
|
||||
# Có project nhưng CHƯA chọn cái nào (mở app lên, hoặc vừa xoá
|
||||
# project đang mở). Không có mục này thì combo rơi về mục 0 và
|
||||
# chỉ bừa vào project đầu danh sách, trong khi cổng
|
||||
# Cowork/GraphRAG vẫn đóng — hai chỗ nói hai đằng.
|
||||
self.nav_project.insertItem(0, tr("app.nav.pick_project"), "")
|
||||
idx = self.nav_project.findData(current)
|
||||
if idx >= 0:
|
||||
self.nav_project.setCurrentIndex(idx)
|
||||
|
||||
@@ -42,7 +42,14 @@ class SessionEventsMixin:
|
||||
self.sidebar.refresh()
|
||||
self._refresh_rail_recents() # the rail shortcut follows the panel
|
||||
|
||||
QTimer.singleShot(0, _do)
|
||||
# Coalesce bursts from turn/tool/history signals into one sidebar read.
|
||||
timer = getattr(self, "_history_refresh_timer", None)
|
||||
if timer is None:
|
||||
timer = QTimer(self)
|
||||
timer.setSingleShot(True)
|
||||
timer.timeout.connect(_do)
|
||||
self._history_refresh_timer = timer
|
||||
timer.start(0)
|
||||
def _on_scheduled_task_done(self, task_id: str, ok: bool) -> None:
|
||||
"""Desktop notification for a finished scheduled task (toast always,
|
||||
tray balloon when the window isn't focused), then refresh History —
|
||||
@@ -106,4 +113,5 @@ class SessionEventsMixin:
|
||||
"""Project được tạo/sửa/xoá: gom nhóm lại cột lịch sử và cập nhật nhãn thư mục."""
|
||||
self.sidebar.refresh() # History regroups by project
|
||||
self.cowork._apply_output_folder_label() # project may have been renamed
|
||||
self.structure._refresh_project_combo() # GraphRAG's project lock list follows too
|
||||
if getattr(self, "structure", None) is not None:
|
||||
self.structure._refresh_project_combo() # GraphRAG's project lock list follows too
|
||||
|
||||
@@ -32,10 +32,10 @@ class TopBarMixin:
|
||||
``_build_account_row`` ngay bên dưới, chỉ khác chỗ đặt trên màn hình.
|
||||
"""
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStyle
|
||||
from ...i18n import tr
|
||||
from ...ui.icons import icon as _icon
|
||||
from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET, _NAV_SETTINGS_GAP
|
||||
from .rail_metrics import _NAV_ROW_INSET, _NAV_SETTINGS_GAP
|
||||
|
||||
# Bottom-pinned group: the places you visit occasionally, kept out of the
|
||||
# way of the ones you live in. A hairline (styled via #navrailBottom in
|
||||
@@ -59,11 +59,17 @@ class TopBarMixin:
|
||||
# that number. Adding it again here made the row taller than the button
|
||||
# (28 wanted, 20 given), which both clipped the icon and pushed the text
|
||||
# 8px below an even pitch with Dashboard / Giám sát.
|
||||
srow.setContentsMargins(_NAV_ROW_INSET, 0, 8, 0)
|
||||
srow.setSpacing(_NAV_ROW_GAP)
|
||||
srow.setContentsMargins(_NAV_ROW_INSET + 2, 0, 8, 0)
|
||||
# Khe giữa icon và chữ phải là khe của STYLE, không phải nhịp riêng của
|
||||
# rail: delegate của cây vẽ chữ ngay sau hộp icon, cách đúng
|
||||
# ``PM_FocusFrameHMargin + 1``. Đặt ``_NAV_ROW_GAP + 4`` (=10) ở đây cộng
|
||||
# với 10px lề trái và hộp icon 22px thành 42 — trong khi Dashboard /
|
||||
# Giám sát đặt chữ ở 35, nên hàng Cài đặt thụt phải 7px.
|
||||
srow.setSpacing(
|
||||
self.nav_bottom.style().pixelMetric(QStyle.PM_FocusFrameHMargin) + 1)
|
||||
self._nav_settings_icon = QLabel()
|
||||
self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16))
|
||||
self._nav_settings_icon.setFixedSize(16, 16)
|
||||
self._nav_settings_icon.setPixmap(_icon("gear").pixmap(16, 16))
|
||||
self._nav_settings_icon.setFixedSize(22, 16)
|
||||
self._nav_settings_text = QLabel(tr("app.settings"))
|
||||
srow.addWidget(self._nav_settings_icon)
|
||||
srow.addWidget(self._nav_settings_text)
|
||||
|
||||
@@ -52,7 +52,7 @@ class ProjectRow(QWidget):
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(6, 4, 6, 4)
|
||||
lay.setSpacing(0)
|
||||
lay.setSpacing(3)
|
||||
self.title_label = QLabel(name)
|
||||
self.counts_label = QLabel()
|
||||
self.counts_label.setObjectName("hint")
|
||||
@@ -90,6 +90,7 @@ def _row_layout_of(widget: QWidget) -> QLayout | None:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
class ProjectEditingMixin:
|
||||
"""Danh sách project + CRUD + chế độ sửa. Trộn vào ``WorkspaceTab``.
|
||||
|
||||
@@ -133,10 +134,17 @@ class ProjectEditingMixin:
|
||||
# dung luat ma _new_btn da theo (_new_btn.setVisible(on_project) trong
|
||||
# WorkspaceTab._apply_pane_visibility) — hai nut nay phai theo y nhu vay.
|
||||
self.tabs.currentChanged.connect(self._sync_project_buttons)
|
||||
# Đổi project cũng phải đồng bộ lại: ``_load_current`` nạp form và đặt
|
||||
# ``_current_id`` rồi phát tín hiệu này, nhưng không đụng tới ba nút.
|
||||
self.project_selected.connect(self._sync_project_buttons)
|
||||
|
||||
self.project_list.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.project_list.customContextMenuRequested.connect(self._show_project_menu)
|
||||
|
||||
# Luật "mỗi thư mục một project" sống ở module riêng — xem
|
||||
# ``project_folder_rules.py`` về lý do nó không nằm trong file này.
|
||||
self.install_project_folder_rule()
|
||||
|
||||
self.set_project_editable(False)
|
||||
|
||||
# ---- chế độ chỉ-xem / sửa -------------------------------------------
|
||||
@@ -153,15 +161,14 @@ class ProjectEditingMixin:
|
||||
Bật: ngược lại, và nút Lưu chuyển sang màu xác nhận (token ``success``).
|
||||
"""
|
||||
self._project_editable = on
|
||||
has_project = bool(getattr(self, "_current_id", ""))
|
||||
|
||||
for field in self._editable_fields():
|
||||
# setReadOnly thay vì setEnabled: ô mờ đi thì không bôi đen copy
|
||||
# được nữa, mà đọc và copy chính là việc của chế độ chỉ-xem.
|
||||
field.setReadOnly(not on)
|
||||
self._browse_btn.setEnabled(on and has_project)
|
||||
self._save_btn.setEnabled(on and has_project)
|
||||
self._edit_btn.setEnabled(not on and has_project)
|
||||
# Ba nút không tự bật/tắt ở đây: ``_sync_project_buttons`` mới là nơi
|
||||
# duy nhất tính trạng thái của chúng, vì nó còn chạy cả khi người dùng
|
||||
# đổi project — lúc đó ``set_project_editable`` không được gọi.
|
||||
self._sync_project_buttons()
|
||||
|
||||
# Nút Lưu xanh lá khi đang sửa (hành động xác nhận), về màu nhấn mặc
|
||||
@@ -171,15 +178,25 @@ class ProjectEditingMixin:
|
||||
self._repolish(self._edit_btn)
|
||||
|
||||
def _sync_project_buttons(self, *_a) -> None:
|
||||
"""Ẩn "Sửa project" và "Lưu project" ngoài sub-tab Project.
|
||||
"""Đồng bộ CẢ hiện/ẩn LẪN bật/mờ của ba nút theo trạng thái hiện tại.
|
||||
|
||||
Chúng nằm trên hàng tiêu đề dùng chung, nên không tự ẩn là chúng hiện
|
||||
cả ở Cowork — nơi không có biểu mẫu project nào để sửa hay lưu.
|
||||
Ẩn ngoài sub-tab Project: chúng nằm trên hàng tiêu đề dùng chung, nên
|
||||
không tự ẩn là chúng hiện cả ở Cowork — nơi không có biểu mẫu project
|
||||
nào để sửa hay lưu.
|
||||
|
||||
Bật/mờ cũng tính ở đây chứ không ở ``set_project_editable``: đổi
|
||||
project KHÔNG đi qua hàm đó (``_load_current`` chỉ nạp lại form), nên
|
||||
để ở đó thì "Sửa project" giữ nguyên trạng thái tính từ lúc dựng —
|
||||
lúc chưa project nào được chọn — và cứ mờ mãi dù project đã mở.
|
||||
"""
|
||||
on_project = self.tabs.currentIndex() == self._project_tab_idx
|
||||
has_project = bool(getattr(self, "_current_id", ""))
|
||||
dang_sua = bool(getattr(self, "_project_editable", False))
|
||||
self._edit_btn.setVisible(on_project and has_project)
|
||||
self._save_btn.setVisible(on_project and has_project)
|
||||
self._edit_btn.setEnabled(not dang_sua and has_project)
|
||||
self._save_btn.setEnabled(dang_sua and has_project)
|
||||
self._browse_btn.setEnabled(dang_sua and has_project)
|
||||
|
||||
@staticmethod
|
||||
def _repolish(widget: QWidget) -> None:
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Luật "mỗi thư mục làm việc chỉ thuộc về MỘT project".
|
||||
|
||||
Tách khỏi ``project_editing.py`` chứ không nhét thêm vào đó: file kia đã gom
|
||||
bốn tính năng và thêm luật này là chạm trần 400 dòng của
|
||||
``scripts/check_loc.py``. Đây cũng là một mối quan tâm riêng — nó không nói về
|
||||
việc *sửa* một project mà về việc hai project không được giẫm lên nhau.
|
||||
|
||||
Luật có hai nửa, cố ý không đối xứng:
|
||||
|
||||
* **Chặn lúc CHỌN.** Ba nơi đặt được thư mục làm việc (nút "Đổi" ở màn Project,
|
||||
thư mục cloud, nút chọn thư mục trong tab Cowork) đều đi qua
|
||||
:func:`folder_taken_blocked`, để cả ba chặn giống hệt nhau. Không chặn ở
|
||||
"Lưu project": nút đó chỉ ghi tên/mô tả/chỉ dẫn, chặn ở đó sẽ khoá luôn việc
|
||||
đổi tên một project lỡ đang trùng thư mục.
|
||||
* **Cảnh báo cho cái ĐANG sai.** Dữ liệu cũ có thể đã có hai project trỏ vào
|
||||
cùng một thư mục, mà nửa trên chỉ chặn từ nay trở đi. Nhãn dưới ô "Thư mục
|
||||
làm việc" nói ra điều đó và để người dùng tự đổi — sửa hộ là tự ý đụng vào
|
||||
dữ liệu của họ.
|
||||
|
||||
Phép so trùng nằm ở ``core/projects.py::folder_conflict`` (thuần, không Qt).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import QLabel, QLayout, QMessageBox, QWidget
|
||||
|
||||
from ...i18n import tr
|
||||
from .project_editing import _row_layout_of
|
||||
|
||||
|
||||
def _layout_chua(layout: QLayout, con: QLayout) -> "tuple | None":
|
||||
"""``(layout_cha, vị_trí)`` của ``con`` bên trong ``layout``, duyệt đệ quy."""
|
||||
for i in range(layout.count()):
|
||||
item = layout.itemAt(i)
|
||||
ben_trong = item.layout()
|
||||
if ben_trong is con:
|
||||
return layout, i
|
||||
if ben_trong is not None:
|
||||
tim = _layout_chua(ben_trong, con)
|
||||
if tim is not None:
|
||||
return tim
|
||||
return None
|
||||
|
||||
|
||||
def folder_taken_blocked(parent: QWidget, path: str, ignore_id: str) -> bool:
|
||||
"""``True`` nếu ``path`` đã thuộc project khác — và đã báo cho người dùng.
|
||||
|
||||
Dùng chung cho cả ba nơi đặt được thư mục làm việc (nút "Đổi" ở màn
|
||||
Project, thư mục cloud, và nút chọn thư mục trong tab Cowork), để cả ba
|
||||
chặn giống hệt nhau thay vì mỗi nơi tự nghĩ ra một luật.
|
||||
|
||||
Chặn ở lúc CHỌN chứ không ở lúc Lưu: "Lưu project" chỉ ghi tên, mô tả và
|
||||
chỉ dẫn — chặn ở đó sẽ khoá luôn việc đổi tên một project lỡ đang trùng
|
||||
thư mục, tức phạt người dùng vì một trạng thái họ chưa kịp sửa.
|
||||
"""
|
||||
from ...core.projects import folder_conflict
|
||||
|
||||
khac = folder_conflict(path, ignore_id=ignore_id)
|
||||
if khac is None:
|
||||
return False
|
||||
QMessageBox.warning(parent, tr("workspace.folder_taken_title"),
|
||||
tr("workspace.folder_taken_body", name=khac.name,
|
||||
folder=str(khac.workspace_dir())))
|
||||
return True
|
||||
|
||||
|
||||
|
||||
class ProjectFolderRuleMixin:
|
||||
"""Nửa giao diện của luật. Trộn vào ``WorkspaceTab``."""
|
||||
|
||||
def install_project_folder_rule(self) -> None:
|
||||
"""Dựng nhãn cảnh báo và nối nó vào việc đổi project.
|
||||
|
||||
Gọi từ ``install_project_editing``, tức sau khi form đã dựng xong.
|
||||
"""
|
||||
self._folder_warn_lbl = QLabel()
|
||||
self._folder_warn_lbl.setObjectName("warning") # màu lấy từ theme/
|
||||
self._folder_warn_lbl.setWordWrap(True)
|
||||
self._folder_warn_lbl.hide()
|
||||
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.
|
||||
|
||||
Chèn từ đây thay vì thêm dòng vào ``_build_project_tab``: file
|
||||
``ui/workspace_tab.py`` đang vượt trần của ``scripts/check_loc.py``,
|
||||
nên mọi dòng mới đều phải tránh nó (cùng lý do nút "Sửa project" được
|
||||
chèn bằng ``_row_layout_of``).
|
||||
"""
|
||||
hang = _row_layout_of(self.folder_lbl)
|
||||
cha = self.folder_lbl.parentWidget()
|
||||
if hang is None or cha is None or cha.layout() is None:
|
||||
return
|
||||
tim = _layout_chua(cha.layout(), hang)
|
||||
if tim is None:
|
||||
return
|
||||
layout, vi_tri = tim
|
||||
layout.insertWidget(vi_tri + 1, self._folder_warn_lbl)
|
||||
|
||||
def _sync_folder_warning(self, *_a) -> None:
|
||||
"""Hiện/ẩn cảnh báo "thư mục đang dùng chung" theo project đang mở."""
|
||||
from ...core.projects import folder_conflict, load_project
|
||||
|
||||
pid = getattr(self, "_current_id", "")
|
||||
project = load_project(pid) if pid else None
|
||||
khac = (folder_conflict(project.workspace_dir(), ignore_id=pid)
|
||||
if project is not None else None)
|
||||
if khac is None:
|
||||
self._folder_warn_lbl.hide()
|
||||
return
|
||||
self._folder_warn_lbl.setText(
|
||||
tr("workspace.folder_shared_warning", name=khac.name))
|
||||
self._folder_warn_lbl.show()
|
||||
@@ -278,10 +278,17 @@ class AnthropicProvider(Provider):
|
||||
raise ProviderError(f"Anthropic: {evt.get('error', {}).get('message', 'error')}")
|
||||
resp.close()
|
||||
break # stream finished normally (or cancelled)
|
||||
except requests.RequestException as exc:
|
||||
except Exception as exc: # noqa: BLE001 — lọc lại ngay bên dưới
|
||||
resp.close()
|
||||
# Huỷ giữa chừng đóng socket, và urllib3 ném AttributeError
|
||||
# ("'NoneType' object has no attribute 'read'") chứ KHÔNG phải
|
||||
# RequestException — bắt hẹp là lỗi đó lọt ra ngoài và người dùng
|
||||
# thấy một lỗi Python đỏ thay vì "đã dừng". Chỉ nuốt khi thật sự
|
||||
# đang huỷ; lỗi khác vẫn ném tiếp nguyên vẹn.
|
||||
if self._is_cancelled(cancel):
|
||||
break
|
||||
if not isinstance(exc, requests.RequestException):
|
||||
raise
|
||||
if text_parts or blocks:
|
||||
if on_text:
|
||||
on_text("\n⚠ Kết nối bị ngắt giữa chừng — hiển thị phần đã nhận được.\n")
|
||||
|
||||
@@ -254,13 +254,19 @@ class OpenAICompatProvider(Provider):
|
||||
slot["args"] += fn["arguments"]
|
||||
resp.close()
|
||||
break # stream finished normally (or cancelled)
|
||||
except requests.RequestException as exc:
|
||||
except Exception as exc: # noqa: BLE001 — lọc lại ngay bên dưới
|
||||
resp.close()
|
||||
# If cancel was requested, close cleanly without retry
|
||||
# Huỷ giữa chừng đóng socket, và urllib3 ném AttributeError
|
||||
# ("'NoneType' object has no attribute 'read'") chứ KHÔNG phải
|
||||
# RequestException — bắt hẹp là lỗi đó lọt ra ngoài và người dùng
|
||||
# thấy một lỗi Python đỏ thay vì "đã dừng". Chỉ nuốt khi thật sự
|
||||
# đang huỷ; lỗi khác vẫn ném tiếp nguyên vẹn.
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
break
|
||||
if self._is_cancelled(cancel):
|
||||
break
|
||||
if not isinstance(exc, requests.RequestException):
|
||||
raise
|
||||
if text_parts or tool_acc:
|
||||
# Partial answer already on screen — keep it, note the cut.
|
||||
if on_text:
|
||||
|
||||
@@ -19,6 +19,14 @@ set "APPHOME=%LOCALAPPDATA%\CoworkLocal"
|
||||
set "VENV=%APPHOME%\venv"
|
||||
set "LAUNCHER=%APPHOME%\launcher"
|
||||
|
||||
rem An cua so console NGAY TU DAU, ke ca trong luc kiem tra ben duoi — khong
|
||||
rem chi truoc luc chay app. Moi cho bao loi (echo + pause) ben duoi tu hien
|
||||
rem lai cua so truoc khi in, de thong bao van doc duoc.
|
||||
set "CONSOLE_VIS=%REPO%\scripts\console_visibility.ps1"
|
||||
if exist "%CONSOLE_VIS%" (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 0 >nul 2>&1
|
||||
)
|
||||
|
||||
rem --------------------------------------------------------------------------
|
||||
rem 1. Chon trinh thong dich
|
||||
rem
|
||||
@@ -38,6 +46,7 @@ if exist "%VENV%\Scripts\python.exe" (
|
||||
)
|
||||
|
||||
if not defined RUNPY (
|
||||
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||
echo.
|
||||
echo [LỖI] Không tìm thấy Python. Chạy install.bat trước đã.
|
||||
echo.
|
||||
@@ -51,6 +60,7 @@ rem biet la phai chay install.bat.
|
||||
if not exist "%VENV%\Scripts\python.exe" (
|
||||
!RUNPY! -c "import PySide6" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||
echo.
|
||||
echo [LỖI] Thư viện chưa được cài. Chạy install.bat trước đã.
|
||||
echo.
|
||||
@@ -94,6 +104,7 @@ if /I "%REPO_NAME%"=="cowork_local" (
|
||||
if exist "!PKGPATH!\cowork_local" rmdir "!PKGPATH!\cowork_local" >nul 2>&1
|
||||
mklink /J "!PKGPATH!\cowork_local" "%REPO%" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||
echo.
|
||||
echo [LOI] Khong tao duoc lien ket thu muc:
|
||||
echo "!PKGPATH!\cowork_local" -> "%REPO%"
|
||||
@@ -112,6 +123,7 @@ rem Chot lai: goi phai THAT SU nhin thay duoc qua duong dan vua dung. Khong co
|
||||
rem buoc nay thi mot junction hong chi hien ra duoi dang loi Python kho hieu
|
||||
rem ("'cowork_local' is a package and cannot be directly executed").
|
||||
if not exist "!PKGPATH!\cowork_local\__main__.py" (
|
||||
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||
echo.
|
||||
echo [LOI] Khong tim thay cowork_local\__main__.py qua duong dan:
|
||||
echo "!PKGPATH!"
|
||||
@@ -139,18 +151,12 @@ set "PYTHONIOENCODING=utf-8"
|
||||
cd /d "%REPO%"
|
||||
|
||||
rem --------------------------------------------------------------------------
|
||||
rem 4. An cua so console trong luc chay
|
||||
rem 4. Chay app
|
||||
rem
|
||||
rem App la GUI (Qt), khong can console — nhung no chia se console cua chinh
|
||||
rem cmd nay (khong tu mo cua so rieng), nen cua so den cua run.bat cu the
|
||||
rem hien suot phien lam viec neu khong lam gi. An no ngay truoc khi chay, roi
|
||||
rem chi hien lai NEU app thoat loi, de thong bao loi ben duoi van doc duoc.
|
||||
rem App la GUI (Qt), khong can console — cua so console da bi an tu dau file
|
||||
rem roi (xem khoi CONSOLE_VIS phia tren), chi hien lai NEU app thoat loi, de
|
||||
rem thong bao loi ben duoi van doc duoc.
|
||||
rem --------------------------------------------------------------------------
|
||||
set "CONSOLE_VIS=%REPO%\scripts\console_visibility.ps1"
|
||||
if exist "%CONSOLE_VIS%" (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 0 >nul 2>&1
|
||||
)
|
||||
|
||||
!RUNPY! -m cowork_local %*
|
||||
set "RC=%ERRORLEVEL%"
|
||||
|
||||
|
||||
@@ -233,11 +233,20 @@ class AppContext:
|
||||
connections across calls/turns (spawning a subprocess per turn would
|
||||
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 — 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
|
||||
@@ -272,7 +281,6 @@ class AppContext:
|
||||
|
||||
# Locally-synced OneDrive/SharePoint (no sign-in) — reads/writes the
|
||||
# OneDrive-desktop-synced folders directly, gated on ms365.connectors.
|
||||
from .core.ms365_local import build_ms365_local_tools
|
||||
local_tools, local_executor = build_ms365_local_tools(self.config)
|
||||
|
||||
return combine_tool_sources((mcp_tools, mcp_executor), (ext_tools, ext_executor),
|
||||
|
||||
@@ -73,3 +73,17 @@ def _bind_checkout_as_package() -> None:
|
||||
|
||||
|
||||
_bind_checkout_as_package()
|
||||
|
||||
|
||||
import pytest # noqa: E402 - after the package binding above
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_network_guard():
|
||||
"""``build_context()`` binds the process-wide "Block network" gate to that
|
||||
context's config (default: blocked). Unbind after every test so one test
|
||||
that built a real context cannot silently block the network for the rest."""
|
||||
yield
|
||||
from cowork_local.application.network import network_guard
|
||||
|
||||
network_guard.bind(None)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""DF-002 (phần b) — node đang chạy HOẶC đã chạy xong không cho edit thông
|
||||
tin trong Node. Trước khi sửa, ``_LOCKED_NODE_STATUSES`` khoá cả STEP_RUNNING
|
||||
lẫn STEP_DONE, nên một bước đã chạy xong không bao giờ sửa lại được nữa.
|
||||
|
||||
Fix: chỉ khoá khi bước ĐANG chạy (STEP_RUNNING) — chạy xong rồi thì mở khoá
|
||||
trở lại. Test này chốt cả nguồn sự thật (tuple
|
||||
``co4e_workflow_crud._LOCKED_NODE_STATUSES``) lẫn hành vi ở widget
|
||||
(``StepConfigPanel.set_locked``), để không bị hồi quy về hành vi cũ.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
|
||||
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
|
||||
|
||||
from cowork_local.core.co4e import (
|
||||
STEP_DONE, STEP_ERROR, STEP_IDLE, STEP_PLANNED, STEP_RUNNING,
|
||||
)
|
||||
from cowork_local.presentation.co4e.co4e_workflow_crud import _LOCKED_NODE_STATUSES
|
||||
from cowork_local.presentation.co4e.node_property_panel import StepConfigPanel
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
def test_only_running_status_is_locked() -> None:
|
||||
"""Một bước đã chạy xong (STEP_DONE) phải sửa lại được — chỉ bước đang
|
||||
thực sự chạy (STEP_RUNNING) mới bị khoá."""
|
||||
assert _LOCKED_NODE_STATUSES == (STEP_RUNNING,)
|
||||
assert STEP_DONE not in _LOCKED_NODE_STATUSES
|
||||
assert STEP_IDLE not in _LOCKED_NODE_STATUSES
|
||||
assert STEP_ERROR not in _LOCKED_NODE_STATUSES
|
||||
assert STEP_PLANNED not in _LOCKED_NODE_STATUSES
|
||||
|
||||
|
||||
def test_set_locked_disables_then_reenables_edit_fields(qapp) -> None:
|
||||
panel = StepConfigPanel()
|
||||
panel.setEnabled(True) # panel starts disabled until a step is loaded
|
||||
|
||||
panel.set_locked(True)
|
||||
assert not panel.label_edit.isEnabled()
|
||||
assert not panel.instructions_edit.isEnabled()
|
||||
assert not panel.model_combo.isEnabled()
|
||||
|
||||
panel.set_locked(False)
|
||||
assert panel.label_edit.isEnabled()
|
||||
assert panel.instructions_edit.isEnabled()
|
||||
assert panel.model_combo.isEnabled()
|
||||
@@ -1,9 +1,11 @@
|
||||
"""DF-006 — the "Số dòng/trang" (rows per page) control: EventTable's
|
||||
page-size state (presentation/monitoring/shared/event_table.py) and its
|
||||
QComboBox wiring in build_filter_scaffold (.../shared/filter_scaffold.py).
|
||||
No dedicated test existed for this before — the design called for a
|
||||
user-visible/choosable item-per-page control, and this exercises it end to
|
||||
end (combo selection -> EventTable actually re-trimming its rows)."""
|
||||
"""DF-006 — the "Số dòng/trang" (rows per page) control plus real prev/next
|
||||
pagination: EventTable's page-size/page-index state
|
||||
(presentation/monitoring/shared/event_table.py) and its QComboBox + pager
|
||||
button wiring in build_filter_scaffold (.../shared/filter_scaffold.py).
|
||||
|
||||
Options are 5/10/20/50/100 with a next/prev pager, per the QA follow-up on
|
||||
DF-006 — the earlier fix only trimmed to a page size (dropping every row past
|
||||
it with no way back); this exercises the real paging end to end."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
@@ -32,19 +34,62 @@ def _events(n: int):
|
||||
"name": f"e{i}", "ok": True, "detail": ""} for i in range(n)]
|
||||
|
||||
|
||||
def test_default_page_size_matches_old_max_rows(qapp) -> None:
|
||||
table = EventTable()
|
||||
assert table.page_size() == 300
|
||||
table.set_events(_events(500))
|
||||
assert table.rowCount() == 300
|
||||
def test_page_size_options_are_5_10_20_50_100() -> None:
|
||||
assert PAGE_SIZE_OPTIONS == (5, 10, 20, 50, 100)
|
||||
|
||||
|
||||
def test_set_page_size_retrims_without_reloading(qapp) -> None:
|
||||
def test_default_page_size(qapp) -> None:
|
||||
table = EventTable()
|
||||
table.set_events(_events(500))
|
||||
assert table.page_size() == 20
|
||||
table.set_events(_events(45))
|
||||
assert table.rowCount() == 20
|
||||
assert table.page_count() == 3
|
||||
assert table.current_page() == 0
|
||||
|
||||
|
||||
def test_set_page_size_resets_to_first_page(qapp) -> None:
|
||||
table = EventTable()
|
||||
table.set_events(_events(45))
|
||||
table.next_page()
|
||||
assert table.current_page() == 1
|
||||
table.set_page_size(50)
|
||||
assert table.page_size() == 50
|
||||
assert table.rowCount() == 50
|
||||
assert table.current_page() == 0
|
||||
assert table.rowCount() == 45 # only 45 events total, fits in one page of 50
|
||||
|
||||
|
||||
def test_next_prev_page_navigate_without_dropping_rows(qapp) -> None:
|
||||
table = EventTable()
|
||||
table.set_events(_events(45))
|
||||
table.set_page_size(20)
|
||||
assert table.rowCount() == 20
|
||||
|
||||
table.next_page()
|
||||
assert table.current_page() == 1
|
||||
assert table.rowCount() == 20
|
||||
|
||||
table.next_page()
|
||||
assert table.current_page() == 2
|
||||
assert table.rowCount() == 5 # last page: remainder
|
||||
|
||||
table.next_page() # already on last page — stays put
|
||||
assert table.current_page() == 2
|
||||
|
||||
table.prev_page()
|
||||
assert table.current_page() == 1
|
||||
table.prev_page()
|
||||
table.prev_page() # already on first page — stays put
|
||||
assert table.current_page() == 0
|
||||
|
||||
|
||||
def test_page_changed_signal_reports_current_and_total(qapp) -> None:
|
||||
table = EventTable()
|
||||
seen = []
|
||||
table.page_changed.connect(lambda cur, total: seen.append((cur, total)))
|
||||
table.set_events(_events(45))
|
||||
table.set_page_size(20)
|
||||
table.next_page()
|
||||
assert seen[-1] == (1, 3)
|
||||
|
||||
|
||||
def test_page_size_combo_is_only_added_when_requested(qapp) -> None:
|
||||
@@ -52,19 +97,44 @@ def test_page_size_combo_is_only_added_when_requested(qapp) -> None:
|
||||
table = EventTable()
|
||||
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=False)
|
||||
assert "page_size_combo" not in parts
|
||||
assert "page_prev_btn" not in parts
|
||||
|
||||
|
||||
def test_page_size_combo_changes_the_table(qapp) -> None:
|
||||
page = QWidget()
|
||||
table = EventTable()
|
||||
table.set_events(_events(500))
|
||||
table.set_events(_events(45))
|
||||
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True)
|
||||
combo = parts["page_size_combo"]
|
||||
assert combo.count() == len(PAGE_SIZE_OPTIONS)
|
||||
assert combo.currentData() == 300 # matches EventTable's current page_size
|
||||
assert combo.currentData() == 20 # matches EventTable's current page_size
|
||||
|
||||
idx = PAGE_SIZE_OPTIONS.index(50)
|
||||
idx = PAGE_SIZE_OPTIONS.index(10)
|
||||
combo.setCurrentIndex(idx)
|
||||
|
||||
assert table.page_size() == 50
|
||||
assert table.rowCount() == 50
|
||||
assert table.page_size() == 10
|
||||
assert table.rowCount() == 10
|
||||
|
||||
|
||||
def test_pager_buttons_disable_at_bounds_and_indicator_updates(qapp) -> None:
|
||||
page = QWidget()
|
||||
table = EventTable()
|
||||
table.set_events(_events(45))
|
||||
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True)
|
||||
table.set_page_size(20)
|
||||
prev_btn, next_btn = parts["page_prev_btn"], parts["page_next_btn"]
|
||||
indicator = parts["page_indicator_label"]
|
||||
|
||||
assert not prev_btn.isEnabled()
|
||||
assert next_btn.isEnabled()
|
||||
assert indicator.text() == "Trang 1/3"
|
||||
|
||||
next_btn.click()
|
||||
assert prev_btn.isEnabled()
|
||||
assert next_btn.isEnabled()
|
||||
assert indicator.text() == "Trang 2/3"
|
||||
|
||||
next_btn.click()
|
||||
assert prev_btn.isEnabled()
|
||||
assert not next_btn.isEnabled()
|
||||
assert indicator.text() == "Trang 3/3"
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
"""Công tắc "Chặn mạng" phải chặn mọi đường ra mạng của app, TRỪ nhà cung cấp AI.
|
||||
|
||||
Bản đồ ``infura/network-map.html`` liệt kê các làn trước đây "không kiểm soát"
|
||||
hoặc chỉ chặn một phần: M365, Teams và nút Test, connector/MCP đang chạy,
|
||||
task script và link đính kèm task, tự cài thư viện, xem trước HTML, lệnh shell
|
||||
chỉ bị proxy giả. Mỗi làn có ít nhất một bài ở đây:
|
||||
|
||||
* **bật** — làn từ chối TRƯỚC khi chạm mạng (mọi lời gọi HTTP thật đều nổ);
|
||||
* **tắt** — đường cũ giữ nguyên, vì chặn một chiều là hỏng tính năng.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.application.network import network_guard
|
||||
|
||||
|
||||
def _no_network(*args, **kwargs):
|
||||
raise AssertionError("đã chạm mạng dù 'Chặn mạng' đang bật")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chan_mang(monkeypatch):
|
||||
"""Bật công tắc qua cổng chung và làm nổ mọi lời gọi HTTP thật."""
|
||||
import requests
|
||||
|
||||
from cowork_local.core import tls_trust
|
||||
|
||||
for name in ("request", "get", "post", "put", "patch", "delete"):
|
||||
monkeypatch.setattr(requests, name, _no_network)
|
||||
monkeypatch.setattr(tls_trust, "request", _no_network)
|
||||
monkeypatch.setattr(tls_trust, "request_any_method", _no_network)
|
||||
network_guard.bind(lambda: True)
|
||||
yield
|
||||
network_guard.bind(None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _go_cong_sau_moi_bai():
|
||||
yield
|
||||
network_guard.bind(None)
|
||||
|
||||
|
||||
# ---- cổng chung ------------------------------------------------------------
|
||||
|
||||
def test_chua_noi_day_thi_khong_chan():
|
||||
network_guard.bind(None)
|
||||
assert network_guard.is_blocked() is False
|
||||
network_guard.ensure_allowed("x")
|
||||
|
||||
|
||||
def test_doc_cong_tac_loi_thi_coi_nhu_dang_chan():
|
||||
"""Không đọc được công tắc thì đóng cửa, không mở toang."""
|
||||
def hong():
|
||||
raise KeyError("agent_security")
|
||||
|
||||
network_guard.bind(hong)
|
||||
with pytest.raises(network_guard.NetworkBlockedError):
|
||||
network_guard.ensure_allowed("Teams")
|
||||
|
||||
|
||||
def test_cong_doc_cau_hinh_song_khong_chup_lai(tmp_path):
|
||||
"""Đổi công tắc trong Settings có hiệu lực ngay, không cần mở lại app."""
|
||||
from cowork_local.presentation.shell.bootstrap import build_context
|
||||
|
||||
ctx = build_context(tmp_path / "config.json")
|
||||
ctx.config.agent_security["block_network"] = True
|
||||
assert network_guard.is_blocked() is True
|
||||
ctx.config.agent_security["block_network"] = False
|
||||
assert network_guard.is_blocked() is False
|
||||
|
||||
|
||||
# ---- Microsoft 365 -----------------------------------------------------------
|
||||
|
||||
def test_graph_tu_choi_bang_loi_ma_noi_goi_da_bat(chan_mang):
|
||||
from cowork_local.core import ms365_graph
|
||||
|
||||
with pytest.raises(ms365_graph.Ms365GraphError, match="Sandbox Security Layer"):
|
||||
ms365_graph.list_onedrive_files("token")
|
||||
|
||||
|
||||
def test_dang_nhap_va_lay_token_tu_choi(chan_mang, monkeypatch):
|
||||
from cowork_local.core import ms365_auth
|
||||
|
||||
monkeypatch.setattr(ms365_auth, "_app", _no_network)
|
||||
with pytest.raises(ms365_auth.Ms365AuthError, match="blocked"):
|
||||
ms365_auth.get_access_token("", "")
|
||||
with pytest.raises(ms365_auth.Ms365AuthError, match="blocked"):
|
||||
ms365_auth.sign_in_device_code("", "", lambda flow: None)
|
||||
|
||||
|
||||
def test_kiem_tra_da_dang_nhap_khong_dung_msal_khi_chan(chan_mang, monkeypatch):
|
||||
"""Dựng app MSAL là tải cấu hình OpenID của tenant — phải đọc kho token trực tiếp."""
|
||||
from cowork_local.core import ms365_auth
|
||||
|
||||
monkeypatch.setattr(ms365_auth, "_app", _no_network)
|
||||
monkeypatch.setattr(ms365_auth, "_cached_account_offline",
|
||||
lambda: {"username": "a@b.c"})
|
||||
assert ms365_auth.signed_in_account("", "") == {"username": "a@b.c"}
|
||||
|
||||
|
||||
def test_mail_canh_bao_khong_gui_khi_chan(chan_mang, monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from cowork_local.core import agent_security_alert, ms365_auth
|
||||
from cowork_local.core.agent_security_types import SecurityVerdict
|
||||
|
||||
monkeypatch.setattr(ms365_auth, "_app", _no_network)
|
||||
config = SimpleNamespace(data={"agent_security": {"admin_email": "admin@x.y"}}, ms365={})
|
||||
verdict = SecurityVerdict(allowed=False, layer="command", reason="test")
|
||||
sent, note = agent_security_alert.notify_admin(config, verdict)
|
||||
assert sent is False
|
||||
assert "blocked" in note
|
||||
|
||||
|
||||
# ---- Teams, connector REST, Jira, nút Test ---------------------------------
|
||||
|
||||
def test_teams_tu_choi(chan_mang):
|
||||
from cowork_local.core.teams import TeamsNotifier
|
||||
|
||||
ok, detail = TeamsNotifier("https://x.webhook.office.com/hook").send("t", "b")
|
||||
assert ok is False
|
||||
assert "blocked" in detail
|
||||
|
||||
|
||||
def test_connector_rest_va_nut_test_tu_choi(chan_mang):
|
||||
from cowork_local.core.ext_connectors import RestApiConnector
|
||||
|
||||
rc = RestApiConnector({"id": "erp", "name": "ERP", "base_url": "https://erp.example"})
|
||||
assert rc.call({"method": "GET", "path": "items"})["ok"] is False
|
||||
ok, message = rc.test_connection()
|
||||
assert ok is False and "blocked" in message
|
||||
|
||||
|
||||
def test_nut_test_jira_tu_choi(chan_mang):
|
||||
from cowork_local.core import jira_tool
|
||||
|
||||
cfg = {"base_url": "https://jira.example", "email": "a@b.c", "api_token": "t"}
|
||||
out = jira_tool.search(cfg, "order by created DESC", 1)
|
||||
assert out.startswith("Jira search failed") and "blocked" in out
|
||||
|
||||
|
||||
# ---- MCP -------------------------------------------------------------------
|
||||
|
||||
def test_mcp_khong_khoi_dong_va_khong_goi_khi_chan(chan_mang):
|
||||
from cowork_local.core.mcp_client import McpServerConnection, McpServerError
|
||||
|
||||
conn = McpServerConnection("srv", "definitely-not-run")
|
||||
with pytest.raises(McpServerError, match="blocked"):
|
||||
conn.start(timeout=1)
|
||||
assert conn.call_tool("srv__search", {})["ok"] is False
|
||||
|
||||
|
||||
def _context(tmp_path, block: bool):
|
||||
from cowork_local.config import AppConfig
|
||||
from cowork_local.state import AppContext
|
||||
|
||||
ctx = AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
ctx.config.agent_security["block_network"] = block
|
||||
return ctx
|
||||
|
||||
|
||||
def test_chuan_bi_connector_dung_mcp_dang_chay_va_giu_onedrive_cuc_bo(tmp_path, monkeypatch):
|
||||
from cowork_local.core import ms365_local
|
||||
|
||||
ctx = _context(tmp_path, block=True)
|
||||
stopped = []
|
||||
monkeypatch.setattr(ctx, "stop_mcp_connections", lambda: stopped.append(True))
|
||||
local = (["ms365_local__onedrive_list"], object())
|
||||
monkeypatch.setattr(ms365_local, "build_ms365_local_tools", lambda config: local)
|
||||
monkeypatch.setattr(ctx._mcp_manager, "ensure", _no_network)
|
||||
|
||||
assert ctx.build_mcp_tools() == local
|
||||
assert stopped == [True]
|
||||
|
||||
|
||||
# ---- task lập lịch -----------------------------------------------------------
|
||||
|
||||
def test_link_dinh_kem_task_khong_tai(chan_mang):
|
||||
from cowork_local.core.tasks import resolve_input_text
|
||||
|
||||
text = resolve_input_text({"input": {"mode": "empty", "links": ["https://example.com/a"]}})
|
||||
assert "not fetched" in text
|
||||
|
||||
|
||||
def test_task_script_chay_trong_tien_trinh_khong_mang(chan_mang, monkeypatch, tmp_path):
|
||||
from cowork_local.core import deps, task_script
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return 0, "done", False, False, False
|
||||
|
||||
monkeypatch.setattr(deps, "run_cancellable", fake_run)
|
||||
assert task_script.run_script("echo hi", tmp_path, 30) == "done"
|
||||
assert calls and calls[0]["isolate_network"] is True
|
||||
|
||||
|
||||
def test_task_script_khong_co_lap_duoc_thi_khong_chay(chan_mang, monkeypatch, tmp_path):
|
||||
from cowork_local.core import deps, task_script
|
||||
|
||||
monkeypatch.setattr(deps, "run_cancellable",
|
||||
lambda command, **kw: (None, "no AppContainer", False, False, False))
|
||||
with pytest.raises(RuntimeError, match="could not be isolated"):
|
||||
task_script.run_script("echo hi", tmp_path, 30)
|
||||
|
||||
|
||||
def test_tat_chan_mang_thi_task_script_chay_nhu_cu(tmp_path):
|
||||
from cowork_local.core import task_script
|
||||
|
||||
assert "hi" in task_script.run_script("echo hi", tmp_path, 30)
|
||||
|
||||
|
||||
# ---- tự cài thư viện ---------------------------------------------------------
|
||||
|
||||
def test_khong_tu_cai_thu_vien_khi_chan(chan_mang, monkeypatch):
|
||||
from cowork_local.core import deps
|
||||
|
||||
monkeypatch.setattr(deps, "run_cancellable", _no_network)
|
||||
assert deps.ensure_module("khong_ton_tai_xyz_123") is None
|
||||
assert "khong_ton_tai_xyz_123" not in deps._FAILED # thử lại khi mở mạng
|
||||
ok, detail = deps.pip_install("requests")
|
||||
assert ok is False and "blocked" in detail
|
||||
|
||||
|
||||
def test_pptx_khong_bi_nho_la_thieu_khi_chi_do_chan_mang(chan_mang, monkeypatch):
|
||||
from cowork_local.application.workspaces import file_preview_helpers as fph
|
||||
from cowork_local.core import deps
|
||||
|
||||
monkeypatch.setattr(fph, "_PPTX_READY", None)
|
||||
monkeypatch.setattr(deps, "ensure_module", lambda *a, **k: None)
|
||||
assert fph.pptx_available() is False
|
||||
assert fph._PPTX_READY is None
|
||||
|
||||
|
||||
# ---- lệnh shell của agent ----------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("backend", ["direct", "integrity_job_wfp", "appcontainer", "windows_sandbox"])
|
||||
def test_moi_backend_deu_chay_co_lap_mang(monkeypatch, tmp_path, backend):
|
||||
"""Các backend cũ chỉ đặt biến proxy — khi chặn mạng, tất cả đi qua lớp cô lập của OS."""
|
||||
from cowork_local.core import deps
|
||||
from cowork_local.core.sandbox_manager import SandboxManager
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return 0, "ok", False, False, False
|
||||
|
||||
monkeypatch.setattr(deps, "run_cancellable", fake_run)
|
||||
result = SandboxManager()._execute_with_backend(backend, "echo hi", str(tmp_path), True, 30)
|
||||
assert calls and calls[0]["isolate_network"] is True
|
||||
assert result["ok"] is True and result["sandbox"] == "network_isolated"
|
||||
|
||||
|
||||
def test_lenh_khong_co_lap_duoc_thi_bi_tu_choi(monkeypatch, tmp_path):
|
||||
from cowork_local.core import deps
|
||||
from cowork_local.core.sandbox_manager import SandboxManager
|
||||
|
||||
monkeypatch.setattr(deps, "run_cancellable",
|
||||
lambda command, **kw: (None, "no isolation", False, False, False))
|
||||
result = SandboxManager()._execute_with_backend("direct", "echo hi", str(tmp_path), True, 30)
|
||||
assert result["ok"] is False and result["sandbox"] == "blocked"
|
||||
|
||||
|
||||
def test_he_dieu_hanh_khong_ho_tro_thi_bao_loi_khong_chay(monkeypatch):
|
||||
from cowork_local.infrastructure.sandbox import network_isolation
|
||||
|
||||
monkeypatch.setattr(network_isolation.sys, "platform", "sunos5")
|
||||
with pytest.raises(network_isolation.NetworkIsolationUnavailable):
|
||||
network_isolation.spawn_without_network("echo hi", None, None)
|
||||
|
||||
|
||||
def _listening_socket():
|
||||
server = socket.socket()
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(4)
|
||||
def serve():
|
||||
try:
|
||||
for _ in range(4):
|
||||
server.accept()
|
||||
except OSError:
|
||||
pass # socket closed at the end of the test
|
||||
|
||||
threading.Thread(target=serve, daemon=True).start()
|
||||
return server
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="AppContainer chỉ có trên Windows")
|
||||
def test_appcontainer_that_su_cat_mang_nhung_van_ghi_duoc_thu_muc(tmp_path):
|
||||
"""Bài thật, không giả lập: cùng một lệnh Python nối tới một cổng đang nghe,
|
||||
ngoài sandbox thì được, trong sandbox thì bị kernel từ chối."""
|
||||
from cowork_local.core.deps import run_cancellable
|
||||
|
||||
server = _listening_socket()
|
||||
port = server.getsockname()[1]
|
||||
probe = (f'"{sys.executable}" -c "import socket;'
|
||||
"print('PYTHON'+'_RAN');"
|
||||
f"socket.create_connection(('127.0.0.1',{port}),5);print('CONN'+'ECTED')\"")
|
||||
try:
|
||||
rc, out, *_ = run_cancellable(probe, cwd=str(tmp_path), timeout=60, shell=True)
|
||||
assert rc == 0 and "CONNECTED" in out
|
||||
|
||||
rc, out, *_ = run_cancellable(probe + " & echo written> marker.txt",
|
||||
cwd=str(tmp_path), timeout=60, isolate_network=True)
|
||||
assert "PYTHON_RAN" in out, out # Python itself starts in the sandbox
|
||||
assert "CONNECTED" not in out, out
|
||||
assert (Path(tmp_path) / "marker.txt").read_text().strip() == "written"
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
|
||||
# ---- xem trước HTML ------------------------------------------------------------
|
||||
|
||||
class _FakeUrl:
|
||||
def __init__(self, scheme):
|
||||
self._scheme = scheme
|
||||
|
||||
def scheme(self):
|
||||
return self._scheme
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
def __init__(self, scheme):
|
||||
self._url = _FakeUrl(scheme)
|
||||
self.blocked = False
|
||||
|
||||
def requestUrl(self): # noqa: N802 - Qt name
|
||||
return self._url
|
||||
|
||||
def block(self, flag):
|
||||
self.blocked = flag
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scheme,expected", [
|
||||
("https", True), ("http", True), ("wss", True), ("file", False), ("data", False),
|
||||
])
|
||||
def test_xem_truoc_html_chan_tai_nguyen_web(chan_mang, scheme, expected):
|
||||
pytest.importorskip("PySide6.QtWebEngineCore")
|
||||
from cowork_local.presentation.folder.offline_web_page import RemoteRequestBlocker
|
||||
|
||||
req = _FakeRequest(scheme)
|
||||
RemoteRequestBlocker().interceptRequest(req)
|
||||
assert req.blocked is expected
|
||||
|
||||
|
||||
def test_xem_truoc_html_tai_binh_thuong_khi_tat():
|
||||
pytest.importorskip("PySide6.QtWebEngineCore")
|
||||
from cowork_local.presentation.folder.offline_web_page import RemoteRequestBlocker
|
||||
|
||||
req = _FakeRequest("https")
|
||||
RemoteRequestBlocker().interceptRequest(req)
|
||||
assert req.blocked is False
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="AppContainer chỉ có trên Windows")
|
||||
def test_khong_cap_quyen_ke_thua_len_thu_muc_chua_qt(tmp_path, monkeypatch):
|
||||
"""Quyền AppContainer kế thừa xuống Qt6WebEngineCore.dll làm tiến trình render
|
||||
của WebEngine không nạp được DLL — tab Graph và xem trước HTML trắng trơn."""
|
||||
from cowork_local.infrastructure.sandbox import appcontainer_process as ac
|
||||
|
||||
qt_dir = tmp_path / "venv" / "Lib" / "site-packages" / "PySide6"
|
||||
qt_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(ac, "_qt_package_dir", lambda: str(qt_dir))
|
||||
monkeypatch.setattr(ac, "_icacls", _no_network)
|
||||
|
||||
with pytest.raises(ac.NetworkIsolationUnavailable, match="Qt runtime"):
|
||||
ac.grant_access(str(tmp_path / "venv"), "S-1-15-2-1", "read")
|
||||
with pytest.raises(ac.NetworkIsolationUnavailable, match="Qt runtime"):
|
||||
ac.grant_access(str(tmp_path), "S-1-15-2-1", "write")
|
||||
|
||||
|
||||
def test_quyen_cho_python_khong_bao_gio_phu_len_thu_vien_qt(monkeypatch, tmp_path):
|
||||
from cowork_local.infrastructure.sandbox import appcontainer_process as ac
|
||||
|
||||
venv, base = tmp_path / "venv", tmp_path / "base"
|
||||
monkeypatch.setattr(ac.sys, "prefix", str(venv))
|
||||
monkeypatch.setattr(ac.sys, "base_prefix", str(base))
|
||||
for qt_dir in (venv / "Lib" / "site-packages" / "PySide6",
|
||||
base / "Lib" / "site-packages" / "PySide6"):
|
||||
monkeypatch.setattr(ac, "_qt_package_dir", lambda d=qt_dir: str(d))
|
||||
for folder, mode in ac._interpreter_grants():
|
||||
assert mode == "read_here" or not ac._covers(folder, str(qt_dir)), (folder, mode)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Công tắc "Chặn mạng cho lệnh do agent chạy" phải chặn MỌI đường ra mạng của
|
||||
agent, không riêng ``run_command``.
|
||||
|
||||
Trước đây ``block_network`` chỉ được đọc ở đúng một chỗ —
|
||||
``infrastructure/filesystem/command_tools.py`` trong ``run_command`` — nên bốn
|
||||
tool mang ``ToolCapability.NETWORK`` (``fetch_url``, ``jira_search``,
|
||||
``jira_get_issue``, ``install_package``) vẫn ra internet bình thường trong khi
|
||||
màn Monitoring báo "Mạng: Bị chặn" và docstring của ``fetch_tools`` tự nhận là
|
||||
*"Honors the Sandbox Security Layer's Block network policy"*. Người dùng bật
|
||||
công tắc rồi thấy agent vẫn search web được — đúng triệu chứng được báo.
|
||||
|
||||
Hai nhóm bài:
|
||||
|
||||
* **hành vi** — bật thì mọi tool NETWORK từ chối TRƯỚC khi chạm mạng, tắt thì
|
||||
đường cũ giữ nguyên (chặn một chiều là hỏng tính năng);
|
||||
* **guardrail** — thêm tool mạng mới mà quên chặn thì bài ở đây đỏ ngay.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.core.tools import ToolContext
|
||||
from cowork_local.domain.tools import BUILT_IN_CAPABILITIES, ToolCapability
|
||||
from cowork_local.infrastructure.filesystem import command_tools, fetch_tools
|
||||
|
||||
# tên tool -> (handler, args hợp lệ tối thiểu). Args phải hợp lệ, nếu không bài
|
||||
# test sẽ đỏ vì lỗi thiếu tham số chứ không vì cổng chặn mạng.
|
||||
_TOOL_MANG: Dict[str, tuple] = {
|
||||
"fetch_url": (fetch_tools.fetch_url, {"url": "https://example.com/"}),
|
||||
"jira_search": (fetch_tools.jira_search, {"jql": "project = ABC"}),
|
||||
"jira_get_issue": (fetch_tools.jira_get_issue, {"key": "ABC-1"}),
|
||||
"install_package": (command_tools.install_package, {"package": "requests"}),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cam_ra_mang(monkeypatch):
|
||||
"""Mọi đường ra mạng thật đều nổ.
|
||||
|
||||
Vừa giữ cho bộ test không chạm internet, vừa làm lộ tool nào lọt qua cổng
|
||||
chặn: nó sẽ đỏ ngay tại lời gọi mạng thay vì im lặng đi ra ngoài.
|
||||
"""
|
||||
def no_ra_mang(*args, **kwargs):
|
||||
raise AssertionError("tool đã chạm mạng dù 'Chặn mạng' đang bật")
|
||||
|
||||
from cowork_local.core import deps, jira_tool, link_fetch
|
||||
|
||||
monkeypatch.setattr(link_fetch, "fetch_link_preview", no_ra_mang)
|
||||
monkeypatch.setattr(jira_tool, "search", no_ra_mang)
|
||||
monkeypatch.setattr(jira_tool, "get_issue", no_ra_mang)
|
||||
monkeypatch.setattr(jira_tool, "get_issue_by_url", no_ra_mang)
|
||||
monkeypatch.setattr(deps, "pip_install", no_ra_mang)
|
||||
|
||||
|
||||
# ---- hành vi: bật công tắc thì mọi tool mạng đều bị chặn -----------------
|
||||
|
||||
@pytest.mark.parametrize("ten", sorted(_TOOL_MANG))
|
||||
def test_bat_chan_mang_thi_tool_tu_choi_truoc_khi_cham_mang(tmp_path, cam_ra_mang, ten):
|
||||
"""Đây là chính triệu chứng người dùng báo: bật rồi mà vẫn ra được web."""
|
||||
handler, args = _TOOL_MANG[ten]
|
||||
ctx = ToolContext(tmp_path, block_network=True)
|
||||
|
||||
ket_qua = handler(ctx, args)
|
||||
|
||||
assert ket_qua["ok"] is False, f"{ten} vẫn chạy khi đang chặn mạng"
|
||||
assert "Sandbox Security Layer" in ket_qua["output"], ket_qua["output"]
|
||||
|
||||
|
||||
def test_allow_url_fetch_khong_lach_duoc_chan_mang(tmp_path, cam_ra_mang):
|
||||
"""Hai công tắc vẫn độc lập, nhưng "Chặn mạng" là cái mạnh hơn: bật nó thì
|
||||
"Cho phép agent lấy dữ liệu từ URL" không mở lại đường được."""
|
||||
ctx = ToolContext(tmp_path, block_network=True, allow_url_fetch=True)
|
||||
|
||||
ket_qua = fetch_tools.fetch_url(ctx, {"url": "https://example.com/"})
|
||||
|
||||
assert ket_qua["ok"] is False
|
||||
|
||||
|
||||
# ---- hành vi: tắt công tắc thì đường cũ giữ nguyên -----------------------
|
||||
|
||||
def test_tat_chan_mang_thi_fetch_url_van_doc_duoc(tmp_path, monkeypatch):
|
||||
"""Chặn một chiều là hỏng tính năng — cổng phải mở lại được."""
|
||||
from cowork_local.core import link_fetch
|
||||
|
||||
monkeypatch.setattr(link_fetch, "fetch_link_preview",
|
||||
lambda url: f"nội dung của {url}")
|
||||
ctx = ToolContext(tmp_path, block_network=False)
|
||||
|
||||
ket_qua = fetch_tools.fetch_url(ctx, {"url": "https://example.com/"})
|
||||
|
||||
assert ket_qua["ok"] is True
|
||||
assert "example.com" in ket_qua["output"]
|
||||
|
||||
|
||||
def test_tat_chan_mang_thi_install_package_van_chay(tmp_path, monkeypatch):
|
||||
from cowork_local.core import deps
|
||||
|
||||
da_goi = []
|
||||
|
||||
def gia_lap_pip(package, **kwargs):
|
||||
da_goi.append(package)
|
||||
return True, "ok"
|
||||
|
||||
monkeypatch.setattr(deps, "pip_install", gia_lap_pip)
|
||||
ctx = ToolContext(tmp_path, block_network=False)
|
||||
|
||||
ket_qua = command_tools.install_package(ctx, {"package": "requests"})
|
||||
|
||||
assert da_goi == ["requests"]
|
||||
assert ket_qua["ok"] is True
|
||||
|
||||
|
||||
# ---- guardrail: danh sách tool mạng không được lệch ----------------------
|
||||
|
||||
def test_moi_tool_mang_deu_co_bai_o_day():
|
||||
"""``BUILT_IN_CAPABILITIES`` là nơi duy nhất khai báo tool nào chạm mạng.
|
||||
Thêm một tool NETWORK mới mà quên chặn thì bài này đỏ ngay."""
|
||||
tag_mang = {ten for ten, cap in BUILT_IN_CAPABILITIES.items()
|
||||
if cap & ToolCapability.NETWORK}
|
||||
|
||||
assert tag_mang == set(_TOOL_MANG), (
|
||||
"danh sách tool mạng đã đổi — chặn tool mới ở cổng block_network "
|
||||
"rồi bổ sung vào _TOOL_MANG")
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Tiêu đề hội thoại không được cắt vào giữa một từ.
|
||||
|
||||
Triệu chứng người dùng báo: thanh tiêu đề màn Cowork hiện
|
||||
|
||||
Đọc các tệp trong thư mục của project này và tóm tắt từng tệ…
|
||||
|
||||
Câu gốc dài 61 ký tự, mốc cắt cứng ở 60 rơi đúng vào giữa chữ "tệp" và bỏ mất
|
||||
đúng một chữ cái. Người đọc thấy "tệ…" chứ không thấy "tệp", nên nó đọc ra như
|
||||
lỗi gõ chứ không như một câu bị rút gọn.
|
||||
|
||||
``shorten_title`` viết nốt từ đang dở thay vì cắt ngang nó, và chỉ thêm dấu ba
|
||||
chấm khi thật sự có chữ bị bỏ đi.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.core.history import (
|
||||
TITLE_MAX_CHARS, _TITLE_SLACK, derive_title, shorten_title,
|
||||
)
|
||||
|
||||
#: Đúng câu trong ảnh người dùng gửi — 61 ký tự, vượt giới hạn đúng 1.
|
||||
CAU_TRONG_ANH = "Đọc các tệp trong thư mục của project này và tóm tắt từng tệp"
|
||||
|
||||
|
||||
def test_dung_ca_nguoi_dung_bao():
|
||||
"""Bài đỏ trước khi sửa: cắt cứng cho ra "…từng tệ…"."""
|
||||
assert len(CAU_TRONG_ANH) == TITLE_MAX_CHARS + 1
|
||||
|
||||
ket_qua = shorten_title(CAU_TRONG_ANH)
|
||||
|
||||
assert ket_qua.endswith("tệp"), ket_qua
|
||||
assert "tệ…" not in ket_qua
|
||||
# Không chữ nào bị bỏ thì không được thêm dấu ba chấm — dấu đó là nói dối.
|
||||
assert ket_qua == CAU_TRONG_ANH
|
||||
|
||||
|
||||
def test_ngan_hon_gioi_han_thi_giu_nguyen():
|
||||
assert shorten_title("Tiêu đề ngắn") == "Tiêu đề ngắn"
|
||||
|
||||
|
||||
def test_dung_bang_gioi_han_thi_giu_nguyen():
|
||||
text = "x" * TITLE_MAX_CHARS
|
||||
assert shorten_title(text) == text
|
||||
|
||||
|
||||
def test_moc_cat_roi_dung_giua_hai_tu_thi_cat_ngay_do():
|
||||
text = "x" * TITLE_MAX_CHARS + " còn nữa"
|
||||
|
||||
assert shorten_title(text) == "x" * TITLE_MAX_CHARS + "…"
|
||||
|
||||
|
||||
def test_viet_not_tu_roi_van_con_chu_phia_sau_thi_co_ba_cham():
|
||||
text = "x" * 57 + " abcdefgh ijk"
|
||||
|
||||
ket_qua = shorten_title(text)
|
||||
|
||||
assert ket_qua == "x" * 57 + " abcdefgh…"
|
||||
|
||||
|
||||
def test_tu_dai_bat_thuong_thi_lui_ve_ranh_gioi_truoc():
|
||||
"""Một đường dẫn hay URL dài không được kéo tiêu đề dài ra tuỳ ý."""
|
||||
text = "x" * 57 + " " + "y" * 40 + " z"
|
||||
|
||||
ket_qua = shorten_title(text)
|
||||
|
||||
assert ket_qua == "x" * 57 + "…"
|
||||
assert len(ket_qua) <= TITLE_MAX_CHARS + 1
|
||||
|
||||
|
||||
def test_ca_tieu_de_chi_la_mot_tu_dai_thi_danh_cat_cung():
|
||||
"""Không còn ranh giới từ nào để bám — cắt cứng là lối ra duy nhất."""
|
||||
text = "y" * 100
|
||||
|
||||
assert shorten_title(text) == "y" * TITLE_MAX_CHARS + "…"
|
||||
|
||||
|
||||
def test_khong_bao_gio_vuot_qua_gioi_han_cong_slack():
|
||||
text = "x" * 55 + " " + "y" * 11 + " phần đuôi còn dài nữa"
|
||||
|
||||
assert len(shorten_title(text)) <= TITLE_MAX_CHARS + _TITLE_SLACK + 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
CAU_TRONG_ANH,
|
||||
"Phân tích bảng tính doanh thu quý bốn và lập báo cáo tổng hợp cho ban giám đốc",
|
||||
"Tóm tắt toàn bộ tài liệu kỹ thuật trong thư mục rồi xuất ra một tệp markdown",
|
||||
"a bb ccc dddd eeeee ffffff ggggggg hhhhhhhh iiiiiiiii jjjjjjjjjj kkkkkkkkkkk",
|
||||
])
|
||||
def test_ket_qua_luon_ket_thuc_o_ranh_gioi_tu(text):
|
||||
"""Bất biến của cả hàm: phần chữ giữ lại phải là một tiền tố kết thúc đúng
|
||||
chỗ một từ kết thúc trong câu gốc — không bao giờ là nửa từ."""
|
||||
ket_qua = shorten_title(text)
|
||||
giu_lai = ket_qua[:-1] if ket_qua.endswith("…") else ket_qua
|
||||
|
||||
assert text.startswith(giu_lai), "kết quả không còn là tiền tố của câu gốc"
|
||||
assert len(giu_lai) == len(text) or text[len(giu_lai)].isspace(), (
|
||||
f"cắt vào giữa từ: ...{giu_lai[-12:]!r} | còn lại {text[len(giu_lai):][:8]!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_derive_title_dung_cung_mot_luat():
|
||||
"""Tiêu đề lưu vào lịch sử và tiêu đề trên thanh tiêu đề phải khớp nhau."""
|
||||
messages = [{"role": "user", "content": CAU_TRONG_ANH}]
|
||||
|
||||
assert derive_title(messages) == shorten_title(CAU_TRONG_ANH)
|
||||
|
||||
|
||||
def test_derive_title_van_gom_khoang_trang_thua():
|
||||
"""Hành vi cũ phải giữ: xuống dòng và khoảng trắng thừa gộp về một dấu cách."""
|
||||
tin_nhan = """ Dòng một
|
||||
|
||||
Dòng hai """
|
||||
messages = [{"role": "user", "content": tin_nhan}]
|
||||
|
||||
assert derive_title(messages) == "Dòng một Dòng hai"
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Đổi thư mục làm việc thì màn GraphRAG phải trỏ theo thư mục mới.
|
||||
|
||||
Triệu chứng: ở tab Project bấm "Đổi" sang một đường dẫn khác — ô Thư mục làm
|
||||
việc cập nhật ngay, tên project vẫn đúng, nên nhìn qua tưởng xong. Nhưng màn
|
||||
GraphRAG vẫn giữ đường dẫn cũ và quét nhầm thư mục.
|
||||
|
||||
Nguyên nhân có hai mảnh, thiếu mảnh nào cũng vẫn hỏng:
|
||||
|
||||
* ``_pick_folder`` ghi ``output_dir`` rồi dừng — đổi thư mục không làm project
|
||||
id đổi nên không có gì trong luồng chọn project chạy lại.
|
||||
* Kể cả có chạy lại, ``StructureGraphView.set_workspace_project`` ngày trước
|
||||
chốt theo MỖI project id, nên cùng một project là nó thoát ra ngay. Tab Thư
|
||||
mục vốn đã chốt theo ĐƯỜNG DẪN — hai nơi tưởng cùng luật mà thật ra không.
|
||||
|
||||
Giống ``test_graphrag_project_persists.py``: KHÔNG dựng renderer thật, vì nó
|
||||
kéo theo QtWebEngine — dựng nó trong bộ ``tests/ui`` làm cả bộ chết giữa chừng.
|
||||
Thứ cần chốt ở đây là luồng điều khiển, không phải phần vẽ.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6", reason="cần PySide6 để dựng widget thật")
|
||||
|
||||
from cowork_local.core import projects as projects_mod
|
||||
from cowork_local.core.projects import Project
|
||||
|
||||
|
||||
class _RendererGhi:
|
||||
"""Thay GraphRenderer — chỉ ghi lại nó bị áp project mấy lần."""
|
||||
|
||||
def __init__(self):
|
||||
self.lan_ap = []
|
||||
|
||||
def set_project(self, project_id):
|
||||
self.lan_ap.append(project_id)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def view(qapp):
|
||||
"""``StructureGraphView`` với renderer bị thay, dựng qua ``__new__``."""
|
||||
from cowork_local.presentation.graph.structure_graph_view import StructureGraphView
|
||||
|
||||
v = StructureGraphView.__new__(StructureGraphView)
|
||||
v._workspace_project = None
|
||||
v._workspace_dir = None
|
||||
v.renderer = _RendererGhi()
|
||||
return v
|
||||
|
||||
|
||||
def _kho_mot_project(monkeypatch, thu_muc) -> Project:
|
||||
"""Kho project giả gồm đúng một project trỏ vào ``thu_muc``."""
|
||||
du_an = Project(project_id="p1", name="Mynt4Project1", output_dir=str(thu_muc))
|
||||
monkeypatch.setattr(projects_mod, "list_projects", lambda directory=None: [du_an])
|
||||
monkeypatch.setattr(projects_mod, "load_project",
|
||||
lambda pid, directory=None: du_an if pid == "p1" else None)
|
||||
monkeypatch.setattr(projects_mod, "save_project", lambda p, directory=None: None)
|
||||
return du_an
|
||||
|
||||
|
||||
# ---- mảnh 1: chốt của GraphRAG phải nhìn cả đường dẫn -------------------
|
||||
|
||||
def test_cung_project_nhung_thu_muc_moi_thi_ap_lai(view, monkeypatch, tmp_path):
|
||||
"""Đây là chỗ chốt cũ bỏ lọt: id giống nhau nhưng đường dẫn đã khác."""
|
||||
du_an = _kho_mot_project(monkeypatch, tmp_path / "cu")
|
||||
view.set_workspace_project("p1")
|
||||
assert view.renderer.lan_ap == ["p1"]
|
||||
|
||||
du_an.output_dir = str(tmp_path / "moi")
|
||||
view.set_workspace_project("p1")
|
||||
|
||||
assert view.renderer.lan_ap == ["p1", "p1"], (
|
||||
"đổi thư mục xong mà GraphRAG không được áp lại — sẽ quét nhầm chỗ")
|
||||
|
||||
|
||||
def test_cung_project_cung_thu_muc_thi_khong_ap_lai(view, monkeypatch, tmp_path):
|
||||
"""Chốt cũ phải giữ nguyên: mỗi lần vào lại màn Workspace,
|
||||
``_bind_project`` chạy lại — áp vô điều kiện là kéo bộ chọn project của
|
||||
chính màn GraphRAG về theo, chọn xong chuyển tab là mất."""
|
||||
_kho_mot_project(monkeypatch, tmp_path / "yen")
|
||||
view.set_workspace_project("p1")
|
||||
|
||||
view.set_workspace_project("p1")
|
||||
view.set_workspace_project("p1")
|
||||
|
||||
assert view.renderer.lan_ap == ["p1"]
|
||||
|
||||
|
||||
def test_doi_sang_project_khac_van_ap(view, monkeypatch, tmp_path):
|
||||
"""Hành vi vốn có: đổi sang project khác thì vẫn phải áp."""
|
||||
_kho_mot_project(monkeypatch, tmp_path / "a")
|
||||
view.set_workspace_project("p1")
|
||||
|
||||
view.set_workspace_project("p2")
|
||||
|
||||
assert view.renderer.lan_ap == ["p1", "p2"]
|
||||
|
||||
|
||||
# ---- mảnh 2: đổi thư mục phải kích hoạt việc trỏ lại --------------------
|
||||
|
||||
class _StructureGhi:
|
||||
"""Thay cả màn GraphRAG — ghi lại nó được trỏ lại vào project nào."""
|
||||
|
||||
def __init__(self):
|
||||
self.lan_tro = []
|
||||
|
||||
def set_workspace_project(self, project_id):
|
||||
self.lan_tro.append(project_id)
|
||||
|
||||
|
||||
class _FolderGhi:
|
||||
"""Thay tab Thư mục — ghi lại nó được trỏ vào đường dẫn nào."""
|
||||
|
||||
def __init__(self):
|
||||
self.lan_tro = []
|
||||
|
||||
def set_project_root(self, path):
|
||||
self.lan_tro.append(path)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ws(qapp, tmp_path_factory):
|
||||
"""Màn Workspace thật, nhưng KHÔNG mở tab GraphRAG (xem docstring đầu file)."""
|
||||
from cowork_local.presentation.shell.bootstrap import build_config, build_context
|
||||
from cowork_local.presentation.shell.main_window import MainWindow
|
||||
|
||||
config_path = tmp_path_factory.mktemp("cfg") / "config.json"
|
||||
build_config(config_path)
|
||||
window = MainWindow(build_context(config_path))
|
||||
yield window.workspace
|
||||
window.close()
|
||||
|
||||
|
||||
def test_bam_doi_thu_muc_thi_graphrag_duoc_tro_lai(qapp, ws, monkeypatch, tmp_path):
|
||||
"""Đúng thao tác người dùng báo: bấm "Đổi" ở tab Project."""
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from cowork_local.ui import workspace_tab as wt
|
||||
|
||||
cu, moi = tmp_path / "thu-muc-cu", tmp_path / "thu-muc-moi"
|
||||
cu.mkdir()
|
||||
moi.mkdir()
|
||||
du_an = _kho_mot_project(monkeypatch, cu)
|
||||
structure, folder = _StructureGhi(), _FolderGhi()
|
||||
monkeypatch.setattr(ws, "_structure", structure, raising=False)
|
||||
monkeypatch.setattr(ws, "_folder", folder, raising=False)
|
||||
ws._current_id = "p1"
|
||||
|
||||
monkeypatch.setattr(QFileDialog, "getExistingDirectory",
|
||||
staticmethod(lambda *a, **k: str(moi)))
|
||||
wt.WorkspaceTab._pick_folder(ws)
|
||||
|
||||
assert du_an.output_dir == str(moi), "chưa ghi thư mục mới"
|
||||
assert ws.folder_lbl.text() == str(moi)
|
||||
assert structure.lan_tro == ["p1"], (
|
||||
"GraphRAG không được trỏ lại — sẽ giữ đường dẫn cũ và quét nhầm")
|
||||
assert folder.lan_tro == [str(moi)], "tab Thư mục cũng phải trỏ theo"
|
||||
@@ -31,6 +31,7 @@ def view(qapp, monkeypatch):
|
||||
|
||||
v = StructureGraphView.__new__(StructureGraphView)
|
||||
v._workspace_project = None
|
||||
v._workspace_dir = None # chốt còn theo cả đường dẫn, không chỉ id
|
||||
v.renderer = _Ghi()
|
||||
return v
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Đổi thư mục project thì đồ thị giữa màn GraphRAG phải quét lại.
|
||||
|
||||
Nối tiếp ``test_graphrag_follows_folder_change.py``. Sau khi đường dẫn trên
|
||||
thanh đã trỏ đúng thư mục mới, các node ở giữa màn vẫn là của thư mục cũ: không
|
||||
có lệnh quét lại nào được phát ra.
|
||||
|
||||
Nguyên nhân cùng một họ với hai mảnh trước — câu hỏi "có gì đổi không" được trả
|
||||
lời bằng project id chứ không bằng thứ thật sự quyết định kết quả quét:
|
||||
|
||||
project_changed = pid != self._active_project_id
|
||||
|
||||
Đổi thư mục làm việc ở màn Project giữ nguyên id, nên ``project_changed`` là
|
||||
False và cả khối phát tín hiệu lẫn khối gọi ``_scan()`` đều bị bỏ qua.
|
||||
|
||||
Cố ý KHÔNG dựng ``GraphRenderer`` thật: nó kéo theo QtWebEngine, dựng trong bộ
|
||||
``tests/ui`` làm cả bộ chết giữa chừng (xem docstring của
|
||||
``test_graphrag_follows_folder_change.py``). ``_on_project_changed`` là Python
|
||||
thuần trên các thuộc tính của chính nó, nên gọi thẳng với một ``self`` giả là đủ
|
||||
và đúng hơn — bài test chốt luồng quyết định, không chốt phần vẽ.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6", reason="cần PySide6 để nạp module renderer")
|
||||
|
||||
from cowork_local.core import projects as projects_mod
|
||||
from cowork_local.core.projects import Project
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
|
||||
|
||||
class _O:
|
||||
"""Vật thể ghi lại lời gọi, thay cho một widget Qt."""
|
||||
|
||||
def __init__(self, **thuoc_tinh):
|
||||
self.__dict__.update(thuoc_tinh)
|
||||
self.da_goi = []
|
||||
|
||||
def __getattr__(self, ten):
|
||||
def ghi(*args, **kwargs):
|
||||
self.da_goi.append((ten, args))
|
||||
return ghi
|
||||
|
||||
|
||||
class _ComboGia:
|
||||
def __init__(self, pid):
|
||||
self._pid = pid
|
||||
|
||||
def currentData(self):
|
||||
return self._pid
|
||||
|
||||
|
||||
class _OGia:
|
||||
"""Ô nhập đường dẫn: giữ được chữ, và ghi lại việc bị khoá."""
|
||||
|
||||
def __init__(self, text=""):
|
||||
self._text = text
|
||||
self.read_only = False
|
||||
|
||||
def text(self):
|
||||
return self._text
|
||||
|
||||
def setText(self, value):
|
||||
self._text = value
|
||||
|
||||
def setReadOnly(self, value):
|
||||
self.read_only = value
|
||||
|
||||
|
||||
class _Renderer:
|
||||
"""``self`` giả cho ``GraphRenderer._on_project_changed``."""
|
||||
|
||||
def __init__(self, pid, active_id="", active_path="", hien=True):
|
||||
self.project_combo = _ComboGia(pid)
|
||||
self.path_edit = _OGia()
|
||||
self._pick_btn = _O()
|
||||
self.project_changed = _O()
|
||||
self._active_project_id = active_id
|
||||
self._active_path = active_path
|
||||
self._needs_scan = False
|
||||
self._hien = hien
|
||||
self.lan_quet = 0
|
||||
|
||||
def isVisible(self):
|
||||
return self._hien
|
||||
|
||||
def _scan(self):
|
||||
self.lan_quet += 1
|
||||
|
||||
# -- tiện cho khẳng định --------------------------------------------- #
|
||||
@property
|
||||
def so_lan_bao_doi(self):
|
||||
"""Số lần phát tín hiệu "đã đổi mục tiêu"."""
|
||||
return sum(1 for ten, _ in self.project_changed.da_goi if ten == "emit")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def du_an(monkeypatch, tmp_path):
|
||||
"""Một project duy nhất trong kho giả, trỏ vào ``tmp_path/cu``."""
|
||||
p = Project(project_id="p1", name="test", output_dir=str(tmp_path / "cu"))
|
||||
monkeypatch.setattr(projects_mod, "load_project",
|
||||
lambda pid, directory=None: p if pid == "p1" else None)
|
||||
return p
|
||||
|
||||
|
||||
def _chay(renderer):
|
||||
"""Gọi đúng hàm thật với ``self`` giả."""
|
||||
GraphRenderer._on_project_changed(renderer, 0)
|
||||
|
||||
|
||||
def test_cung_project_thu_muc_moi_thi_quet_lai(du_an, tmp_path):
|
||||
"""Đây là chỗ hỏng người dùng báo: đường dẫn đổi mà node giữa màn thì không."""
|
||||
r = _Renderer("p1")
|
||||
_chay(r) # lần đầu: khoá vào project
|
||||
assert r.lan_quet == 1
|
||||
|
||||
du_an.output_dir = str(tmp_path / "moi") # người dùng bấm "Đổi" ở tab Project
|
||||
_chay(r)
|
||||
|
||||
assert r.path_edit.text() == str(tmp_path / "moi")
|
||||
assert r.lan_quet == 2, "đổi thư mục xong nhưng không quét lại — node vẫn của thư mục cũ"
|
||||
assert r.so_lan_bao_doi == 2, (
|
||||
"phải báo đổi để khung hỏi-đáp bỏ phần trích xuất của thư mục cũ")
|
||||
|
||||
|
||||
def test_cung_project_cung_thu_muc_thi_khong_quet_lai(du_an):
|
||||
"""Bảo vệ sẵn có: ``_bind_project`` chạy lại mỗi lần vào lại màn Workspace,
|
||||
quét lại vô cớ là vừa giật vừa tốn."""
|
||||
r = _Renderer("p1")
|
||||
_chay(r)
|
||||
assert r.lan_quet == 1
|
||||
|
||||
_chay(r)
|
||||
_chay(r)
|
||||
|
||||
assert r.lan_quet == 1
|
||||
|
||||
|
||||
def test_doi_sang_project_khac_van_quet_lai(du_an, tmp_path, monkeypatch):
|
||||
"""Hành vi vốn có, không được mất."""
|
||||
khac = Project(project_id="p2", name="khac", output_dir=str(tmp_path / "cua-p2"))
|
||||
monkeypatch.setattr(projects_mod, "load_project",
|
||||
lambda pid, directory=None: du_an if pid == "p1" else khac)
|
||||
r = _Renderer("p1")
|
||||
_chay(r)
|
||||
|
||||
r.project_combo._pid = "p2"
|
||||
_chay(r)
|
||||
|
||||
assert r.lan_quet == 2
|
||||
assert r.path_edit.text() == str(tmp_path / "cua-p2")
|
||||
|
||||
|
||||
def test_man_dang_an_thi_hoan_quet_chu_khong_quet_ngay(du_an, tmp_path):
|
||||
"""Đổi thư mục từ màn Project trong khi GraphRAG đang ẩn: đánh dấu để quét
|
||||
ở lần vào sau, đúng luật hoãn mà ``auto_scan_and_fit`` dựa vào."""
|
||||
r = _Renderer("p1", hien=False)
|
||||
_chay(r)
|
||||
# Lần khoá đầu tiên đã đặt cờ rồi; xoá đi để bài này thật sự kiểm được
|
||||
# lần ĐỔI THƯ MỤC, chứ không xanh nhờ cờ còn sót của lần trước.
|
||||
r._needs_scan = False
|
||||
|
||||
du_an.output_dir = str(tmp_path / "moi")
|
||||
_chay(r)
|
||||
|
||||
assert r.lan_quet == 0
|
||||
assert r._needs_scan is True
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Xem trước HTML trong tab Folder phải hiện được ảnh lấy từ web khi mạng mở.
|
||||
|
||||
Tệp được nạp với base URL ``file://``; Qt mặc định cấm trang cục bộ tải bất kỳ
|
||||
tài nguyên web nào nếu ``LocalContentCanAccessRemoteUrls`` tắt, nên ảnh
|
||||
``<img src="https://...">`` không bao giờ hiện, kể cả khi đã mở Internet.
|
||||
Việc chặn khi bật "Chặn mạng" là của bộ chặn request, không phải của cờ này.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6.QtWebEngineWidgets", reason="cần Qt WebEngine")
|
||||
|
||||
from cowork_local.presentation.shared import HAS_WEB_ENGINE # noqa: E402
|
||||
|
||||
pytestmark = pytest.mark.skipif(not HAS_WEB_ENGINE, reason="WebEngine không dùng được ở đây")
|
||||
|
||||
|
||||
def test_trang_xem_truoc_cho_phep_tai_anh_tu_web(qapp):
|
||||
from PySide6.QtWebEngineCore import QWebEngineSettings
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
from cowork_local.presentation.folder import offline_web_page as owp
|
||||
|
||||
view = QWebEngineView()
|
||||
try:
|
||||
owp.install_offline_page(view)
|
||||
profile = view.page().profile()
|
||||
assert profile is owp.preview_profile()
|
||||
assert profile.settings().testAttribute(
|
||||
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls)
|
||||
assert isinstance(owp._blocker, owp.RemoteRequestBlocker)
|
||||
finally:
|
||||
view.deleteLater()
|
||||
|
||||
|
||||
def test_profile_song_lau_hon_moi_trang(qapp):
|
||||
"""Profile do view sở hữu bị huỷ TRƯỚC trang: Qt cảnh báo "Release of
|
||||
profile requested but WebEnginePage still not deleted" rồi app có thể văng
|
||||
(0xc0000409 trong Qt6Core.dll) khi chuyển tab. Profile phải thuộc về app."""
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
from cowork_local.presentation.folder import offline_web_page as owp
|
||||
|
||||
view = QWebEngineView()
|
||||
try:
|
||||
owp.install_offline_page(view)
|
||||
assert view.page().parent() is view
|
||||
assert owp.preview_profile().parent() is qapp
|
||||
finally:
|
||||
view.deleteLater()
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Ba hàng cuối thanh rail phải thẳng hàng: Dashboard, Giám sát, Cài đặt.
|
||||
|
||||
Dashboard và Giám sát là hàng của ``QTreeWidget`` (``#navrailBottom``), còn Cài
|
||||
đặt là một ``QPushButton`` tự dựng lấy icon + chữ trong ``top_bar.py``. Hai cách
|
||||
vẽ khác nhau nên không có gì tự giữ cho chúng thẳng hàng — phải chốt bằng test.
|
||||
|
||||
Lần lệch gần nhất: ``srow.setSpacing(_NAV_ROW_GAP + 4)`` (=10) cộng với 10px lề
|
||||
trái và hộp icon 22px đặt chữ "Cài đặt" ở x=42, trong khi delegate của cây đặt
|
||||
chữ ở x=35 — thụt phải 7px, thấy rõ bằng mắt trên thanh rail.
|
||||
|
||||
Cách đo: render thanh rail ra ảnh rồi tìm cột mực đầu tiên, vì đó đúng là thứ
|
||||
người dùng nhìn thấy. Trước khi đo, ba hàng được ép về **cùng một icon và cùng
|
||||
một chữ** — chữ khác nhau thì phần nhô trái của glyph đầu tiên ("D" so với "G"
|
||||
so với "C") đã lệch nhau vài pixel, và bài test sẽ đo hình dáng chữ chứ không
|
||||
đo bố cục.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
#: Bỏ qua cột mực nằm sát mép trái: hàng đang được chọn có thêm vạch
|
||||
#: ``border-left: 2px solid $accent`` (theme/qss.py), không phải icon.
|
||||
_BO_QUA_MEP_TRAI = 5
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def window(qapp, tmp_path_factory):
|
||||
"""Cửa sổ thật, có nạp stylesheet đúng như ``app.py`` làm.
|
||||
|
||||
Không nạp thì ``QTreeWidget::item { padding: 6px 10px }`` không áp, hàng của
|
||||
cây thụt về 0 còn nút Cài đặt vẫn giữ lề 10px của layout — bài test sẽ đỏ vì
|
||||
thiếu theme chứ không vì lỗi bố cục.
|
||||
"""
|
||||
from cowork_local.presentation.shell.bootstrap import build_config, build_context
|
||||
from cowork_local.presentation.shell.main_window import MainWindow
|
||||
from cowork_local.theme import set_active_theme, stylesheet
|
||||
|
||||
config_path = tmp_path_factory.mktemp("cfg") / "config.json"
|
||||
build_config(config_path)
|
||||
ctx = build_context(config_path)
|
||||
css_cu = qapp.styleSheet()
|
||||
set_active_theme(ctx.config.theme)
|
||||
qapp.setStyleSheet(stylesheet(ctx.config.theme))
|
||||
win = MainWindow(ctx)
|
||||
win.resize(1280, 800)
|
||||
win.show()
|
||||
for _ in range(3):
|
||||
qapp.processEvents()
|
||||
yield win
|
||||
win.close()
|
||||
qapp.setStyleSheet(css_cu)
|
||||
|
||||
|
||||
def _cum_muc(img, y0: int, y1: int):
|
||||
"""Các cụm cột có mực trong dải ``[y0, y1)``, dạng ``[(x_đầu, x_cuối), ...]``.
|
||||
|
||||
Màu nền lấy ở cột sát mép phải cùng dòng y, nên hàng đang được tô nền chọn
|
||||
vẫn so sánh đúng.
|
||||
"""
|
||||
w = img.width()
|
||||
co_muc = [any(img.pixel(x, y) != img.pixel(w - 3, y) for y in range(y0, y1))
|
||||
for x in range(w)]
|
||||
cum, dau = [], None
|
||||
for x, c in enumerate(co_muc):
|
||||
if c and dau is None:
|
||||
dau = x
|
||||
elif not c and dau is not None:
|
||||
if x - dau >= 2:
|
||||
cum.append((dau, x - 1))
|
||||
dau = None
|
||||
if dau is not None:
|
||||
cum.append((dau, w - 1))
|
||||
return [c for c in cum if c[0] >= _BO_QUA_MEP_TRAI]
|
||||
|
||||
|
||||
def _ep_ba_hang_ve_cung_hinh(window):
|
||||
"""Cho ba hàng cùng icon và cùng chữ, để chỉ còn bố cục là khác biệt."""
|
||||
from cowork_local.ui.icons import icon as _icon
|
||||
|
||||
for i in range(2):
|
||||
it = window.nav_bottom.topLevelItem(i)
|
||||
it.setIcon(0, _icon("gear"))
|
||||
it.setText(0, "M")
|
||||
window._nav_settings_icon.setPixmap(_icon("gear").pixmap(16, 16))
|
||||
window._nav_settings_text.setText("M")
|
||||
|
||||
|
||||
def _vi_tri_ba_hang(qapp, window):
|
||||
"""``{tên hàng: (x_icon, x_chữ)}`` đo từ ảnh render của thanh rail."""
|
||||
from PySide6.QtCore import QPoint
|
||||
|
||||
_ep_ba_hang_ve_cung_hinh(window)
|
||||
for _ in range(3):
|
||||
qapp.processEvents()
|
||||
ref = window._nav_wrap
|
||||
img = ref.grab().toImage()
|
||||
|
||||
ket = {}
|
||||
for i, ten in ((0, "Dashboard"), (1, "Giám sát")):
|
||||
it = window.nav_bottom.topLevelItem(i)
|
||||
r = window.nav_bottom.visualItemRect(it)
|
||||
y = window.nav_bottom.viewport().mapTo(ref, QPoint(0, r.y())).y()
|
||||
cum = _cum_muc(img, y + 4, y + r.height() - 4)
|
||||
assert len(cum) >= 2, f"{ten}: không tìm thấy đủ icon và chữ để đo"
|
||||
ket[ten] = (cum[0][0], cum[1][0])
|
||||
|
||||
btn = window._nav_settings_btn
|
||||
y = btn.mapTo(ref, QPoint(0, 0)).y()
|
||||
cum = _cum_muc(img, y + 4, y + btn.height() - 4)
|
||||
assert len(cum) >= 2, "Cài đặt: không tìm thấy đủ icon và chữ để đo"
|
||||
ket["Cài đặt"] = (cum[0][0], cum[1][0])
|
||||
return ket
|
||||
|
||||
|
||||
def test_icon_ba_hang_thang_hang(qapp, window):
|
||||
"""Icon của ba hàng phải bắt đầu ở cùng một cột."""
|
||||
vi_tri = _vi_tri_ba_hang(qapp, window)
|
||||
x = {ten: v[0] for ten, v in vi_tri.items()}
|
||||
assert len(set(x.values())) == 1, f"icon lệch nhau: {x}"
|
||||
|
||||
|
||||
def test_chu_ba_hang_thang_hang(qapp, window):
|
||||
"""Chữ của ba hàng phải bắt đầu ở cùng một cột.
|
||||
|
||||
Đây là bài đỏ trước khi sửa: Cài đặt ở 42, hai hàng kia ở 35.
|
||||
"""
|
||||
vi_tri = _vi_tri_ba_hang(qapp, window)
|
||||
x = {ten: v[1] for ten, v in vi_tri.items()}
|
||||
assert len(set(x.values())) == 1, f"chữ lệch nhau: {x}"
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Nút "Sửa project" phải sáng ngay khi đã có project đang mở.
|
||||
|
||||
Triệu chứng: mở app, chọn một project rồi vào sub-tab Project — biểu mẫu hiện
|
||||
đủ tên, mô tả, thư mục làm việc, nhưng nút "Sửa project" vẫn mờ, không bấm được.
|
||||
|
||||
Nguyên nhân: trạng thái bật/mờ của ba nút chỉ được tính trong
|
||||
``set_project_editable``, mà đổi project KHÔNG đi qua hàm đó — ``_load_current``
|
||||
chỉ nạp lại biểu mẫu. ``_sync_project_buttons`` có chạy khi đổi sub-tab nhưng
|
||||
ngày trước chỉ chỉnh ẩn/hiện, nên nút hiện ra mang theo trạng thái mờ tính từ
|
||||
lúc dựng cửa sổ, khi chưa project nào được chọn.
|
||||
|
||||
Các bài dưới đây chốt cả bốn trạng thái: chưa có project → mờ; có project →
|
||||
sáng; đi vòng qua sub-tab khác rồi quay lại → vẫn sáng; đang sửa dở → mờ lại
|
||||
(nếu không thì "đang sửa" và "chưa sửa" trông giống hệt nhau).
|
||||
|
||||
Hai quy ước bắt buộc, lấy từ ``test_project_editing.py`` và
|
||||
``test_project_gate_subtabs.py`` ngay cạnh:
|
||||
|
||||
* **Không tạo, không xoá project nào.** ``core/projects.py`` gắn
|
||||
``PROJECTS_DIR`` vào ``~/.cowork_local`` THẬT, nên tạo project trong test là
|
||||
ghi vào dữ liệu đang dùng của người chạy test. Trạng thái "đã có project"
|
||||
được đặt thẳng vào ``_current_id`` — đúng biến mà ba nút đọc.
|
||||
* **Một cửa sổ cho cả module.** Dựng ``MainWindow`` cho từng bài làm cả bộ
|
||||
``tests/ui`` chết giữa chừng (Qt đổ stack trace, không phải test nào fail),
|
||||
nên fixture ở đây là ``scope="module"`` và mỗi bài tự đặt trạng thái đầu vào
|
||||
của mình.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
|
||||
|
||||
_PID_GIA = "project-test-khong-ghi-dia"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ws(qapp, tmp_path_factory):
|
||||
"""Màn Workspace của một MainWindow thật, dùng chung cho cả module."""
|
||||
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 _nap_project(qapp, ws, pid: str) -> None:
|
||||
"""Đi đúng đường ``_load_current`` đi khi người dùng chọn một project.
|
||||
|
||||
Cố ý KHÔNG gọi ``set_project_editable``: chính vì ``_load_current`` không
|
||||
gọi nó mà lỗi mới tồn tại. Gọi nó ở đây là bài test tự tay bật lại nút rồi
|
||||
khẳng định nút đang bật — nó sẽ xanh cả trên bản chưa sửa.
|
||||
|
||||
``_project_editable`` đặt thẳng về ``False`` vì cửa sổ dùng chung cho cả
|
||||
module: bài trước có thể đã để form ở chế độ sửa, mà nạp một project mới
|
||||
thì form luôn ở chế độ chỉ-xem.
|
||||
"""
|
||||
ws.tabs.setCurrentIndex(ws._project_tab_idx)
|
||||
ws._project_editable = False
|
||||
ws._current_id = pid
|
||||
ws.project_selected.emit(pid)
|
||||
qapp.processEvents()
|
||||
|
||||
|
||||
def _chua_co_project(qapp, ws) -> None:
|
||||
"""Trạng thái chưa chọn project nào, đang ở sub-tab Project."""
|
||||
_nap_project(qapp, ws, "")
|
||||
|
||||
|
||||
def _mo_mot_project(qapp, ws) -> None:
|
||||
"""Trạng thái đang mở một project."""
|
||||
_nap_project(qapp, ws, _PID_GIA)
|
||||
|
||||
|
||||
def test_chua_co_project_thi_nut_sua_mo(qapp, ws):
|
||||
"""Chưa chọn project thì không có gì để sửa — đây là hành vi phải giữ."""
|
||||
_chua_co_project(qapp, ws)
|
||||
|
||||
assert ws._edit_btn.isEnabled() is False
|
||||
assert ws._edit_btn.isHidden() is True
|
||||
|
||||
|
||||
def test_da_co_project_thi_nut_sua_sang(qapp, ws):
|
||||
"""Bài đỏ trước khi sửa: nút hiện ra nhưng vẫn mờ."""
|
||||
_mo_mot_project(qapp, ws)
|
||||
|
||||
assert ws._edit_btn.isHidden() is False, "nút phải hiện khi đã có project"
|
||||
assert ws._edit_btn.isEnabled() is True, "nút phải bấm được khi đã có project"
|
||||
|
||||
|
||||
def test_quay_lai_tab_project_thi_nut_van_sang(qapp, ws):
|
||||
"""Đúng thao tác trong ảnh người dùng gửi: rời tab Project rồi quay lại."""
|
||||
if ws.tabs.count() < 2:
|
||||
pytest.skip("bản dựng này chỉ có một sub-tab, không đi vòng được")
|
||||
_mo_mot_project(qapp, ws)
|
||||
|
||||
ws.tabs.setCurrentIndex(1 if ws._project_tab_idx == 0 else 0)
|
||||
qapp.processEvents()
|
||||
ws.tabs.setCurrentIndex(ws._project_tab_idx)
|
||||
qapp.processEvents()
|
||||
|
||||
assert ws._edit_btn.isHidden() is False
|
||||
assert ws._edit_btn.isEnabled() is True
|
||||
|
||||
|
||||
def test_dang_sua_thi_nut_sua_mo_lai_va_nut_luu_sang(qapp, ws):
|
||||
"""Chống sửa quá tay: "Sửa project" chỉ sáng khi CHƯA ở chế độ sửa."""
|
||||
_mo_mot_project(qapp, ws)
|
||||
ws.set_project_editable(True)
|
||||
qapp.processEvents()
|
||||
|
||||
assert ws._edit_btn.isEnabled() is False
|
||||
assert ws._save_btn.isEnabled() is True
|
||||
assert ws._browse_btn.isEnabled() is True
|
||||
|
||||
|
||||
def test_bo_chon_project_thi_nut_sua_mo_lai(qapp, ws):
|
||||
"""Xoá project đang mở đưa ``_current_id`` về rỗng — nút phải mờ lại."""
|
||||
_mo_mot_project(qapp, ws)
|
||||
ws._current_id = ""
|
||||
ws.project_selected.emit("")
|
||||
qapp.processEvents()
|
||||
|
||||
assert ws._edit_btn.isEnabled() is False
|
||||
assert ws._edit_btn.isHidden() is True
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Mỗi thư mục làm việc chỉ được thuộc về MỘT project.
|
||||
|
||||
Thư mục làm việc vừa là sandbox (agent chỉ được đọc/ghi bên trong nó) vừa là
|
||||
kho kiến thức chung của project (file ở gốc thư mục được mọi đoạn chat tự đọc).
|
||||
Hai project trỏ vào cùng một thư mục là đọc lẫn dữ liệu của nhau và ghi đè lên
|
||||
nhau — đúng điều mà docstring đầu ``core/projects.py`` nói sandbox sinh ra để
|
||||
ngăn, nhưng trước đây không có gì chặn.
|
||||
|
||||
Hai nhóm bài:
|
||||
|
||||
* **Luật** — ``folder_conflict`` nhận diện trùng, kể cả khác hoa thường, khác
|
||||
kiểu dấu phân cách, và LỒNG NHAU (đứng ở thư mục cha thì vẫn với tới được
|
||||
file của project con).
|
||||
* **Giao diện** — nhãn cảnh báo dưới ô "Thư mục làm việc" hiện đúng lúc, vì dữ
|
||||
liệu cũ có thể đã trùng sẵn và luật mới chỉ chặn từ lúc chọn trở đi.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.core import projects as projects_mod
|
||||
from cowork_local.core.projects import Project, WORKSPACES_DIR, folder_conflict
|
||||
|
||||
|
||||
def _kho(monkeypatch, *ds: Project) -> None:
|
||||
"""Giả lập kho project, không đụng ``~/.cowork_local`` thật."""
|
||||
monkeypatch.setattr(projects_mod, "list_projects", lambda directory=None: list(ds))
|
||||
|
||||
|
||||
# ---- luật: nhận diện trùng ----------------------------------------------
|
||||
|
||||
def test_trung_y_het_thi_bi_bat(monkeypatch, tmp_path):
|
||||
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path)))
|
||||
|
||||
khac = folder_conflict(str(tmp_path), ignore_id="b")
|
||||
|
||||
assert khac is not None and khac.project_id == "a"
|
||||
|
||||
|
||||
def test_khac_hoa_thuong_va_dau_phan_cach_van_la_trung(monkeypatch, tmp_path):
|
||||
"""Trên Windows ``D:/Work`` và ``d:/work`` là cùng một thư mục."""
|
||||
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path)))
|
||||
|
||||
lech = str(tmp_path).replace(os.sep, "/")
|
||||
if os.name == "nt":
|
||||
lech = lech.upper()
|
||||
|
||||
assert folder_conflict(lech, ignore_id="b") is not None
|
||||
|
||||
|
||||
def test_thu_muc_con_nam_trong_thu_muc_cua_project_khac_la_trung(monkeypatch, tmp_path):
|
||||
"""Project kia đứng ở thư mục cha thì vẫn đọc/ghi được thư mục con này."""
|
||||
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path)))
|
||||
|
||||
assert folder_conflict(str(tmp_path / "con"), ignore_id="b") is not None
|
||||
|
||||
|
||||
def test_thu_muc_cha_chua_thu_muc_cua_project_khac_la_trung(monkeypatch, tmp_path):
|
||||
"""Chiều ngược lại cũng phải bắt: chọn thư mục cha là ôm trọn project kia."""
|
||||
_kho(monkeypatch, Project(project_id="a", name="A",
|
||||
output_dir=str(tmp_path / "con")))
|
||||
|
||||
assert folder_conflict(str(tmp_path), ignore_id="b") is not None
|
||||
|
||||
|
||||
def test_ten_na_na_nhung_khong_long_nhau_thi_khong_trung(monkeypatch, tmp_path):
|
||||
"""Bẫy của so sánh tiền tố: ``work2`` KHÔNG nằm trong ``work``."""
|
||||
_kho(monkeypatch, Project(project_id="a", name="A",
|
||||
output_dir=str(tmp_path / "work")))
|
||||
|
||||
assert folder_conflict(str(tmp_path / "work2"), ignore_id="b") is None
|
||||
|
||||
|
||||
def test_project_chua_dat_thu_muc_rieng_van_dang_chiem_thu_muc_quan_ly(monkeypatch):
|
||||
"""``output_dir`` rỗng không có nghĩa là "chưa chiếm chỗ nào": project vẫn
|
||||
đang dùng thư mục quản lý sẵn, và chính nó hay bị chọn nhầm."""
|
||||
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=""))
|
||||
|
||||
assert folder_conflict(str(WORKSPACES_DIR / "a"), ignore_id="b") is not None
|
||||
|
||||
|
||||
def test_giu_nguyen_thu_muc_cua_chinh_no_thi_khong_phai_trung(monkeypatch, tmp_path):
|
||||
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path)))
|
||||
|
||||
assert folder_conflict(str(tmp_path), ignore_id="a") is None
|
||||
|
||||
|
||||
def test_thu_muc_chua_ai_dung_thi_di_qua(monkeypatch, tmp_path):
|
||||
_kho(monkeypatch, Project(project_id="a", name="A",
|
||||
output_dir=str(tmp_path / "cua-a")))
|
||||
|
||||
assert folder_conflict(str(tmp_path / "cua-b"), ignore_id="b") is None
|
||||
|
||||
|
||||
def test_duong_dan_rong_khong_bi_coi_la_trung(monkeypatch, tmp_path):
|
||||
"""Ô trống là "chưa chọn", không phải "trùng" — khác hẳn nhau."""
|
||||
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path)))
|
||||
|
||||
assert folder_conflict("", ignore_id="b") is None
|
||||
assert folder_conflict(" ", ignore_id="b") is None
|
||||
|
||||
|
||||
# ---- i18n: ba key mới phải đủ ba ngôn ngữ -------------------------------
|
||||
|
||||
@pytest.mark.parametrize("key", [
|
||||
"workspace.folder_taken_title", "workspace.folder_taken_body",
|
||||
"workspace.folder_shared_warning",
|
||||
])
|
||||
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}"
|
||||
|
||||
|
||||
# ---- giao diện: cảnh báo cho dữ liệu đã trùng sẵn -----------------------
|
||||
|
||||
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ws(qapp, tmp_path_factory):
|
||||
"""Một cửa sổ cho cả module — dựng nhiều MainWindow làm Qt chết giữa chừng."""
|
||||
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 _mo_project(qapp, ws, monkeypatch, dang_mo: Project, *nhung_cai_khac: Project):
|
||||
"""Mở ``dang_mo`` trên biểu mẫu, với kho chứa cả các project còn lại."""
|
||||
_kho(monkeypatch, dang_mo, *nhung_cai_khac)
|
||||
monkeypatch.setattr(projects_mod, "load_project",
|
||||
lambda pid, directory=None: dang_mo if pid == dang_mo.project_id else None)
|
||||
ws._current_id = dang_mo.project_id
|
||||
ws.project_selected.emit(dang_mo.project_id)
|
||||
qapp.processEvents()
|
||||
|
||||
|
||||
def test_canh_bao_hien_khi_project_dang_dung_chung_thu_muc(qapp, ws, monkeypatch, tmp_path):
|
||||
"""Đúng trạng thái trong ảnh người dùng gửi: hai project cùng một thư mục."""
|
||||
_mo_project(qapp, ws, monkeypatch,
|
||||
Project(project_id="b", name="test3", output_dir=str(tmp_path)),
|
||||
Project(project_id="a", name="test2", output_dir=str(tmp_path)))
|
||||
|
||||
assert ws._folder_warn_lbl.isHidden() is False
|
||||
assert "test2" in ws._folder_warn_lbl.text()
|
||||
|
||||
|
||||
def test_khong_canh_bao_khi_thu_muc_rieng(qapp, ws, monkeypatch, tmp_path):
|
||||
_mo_project(qapp, ws, monkeypatch,
|
||||
Project(project_id="b", name="test3", output_dir=str(tmp_path / "b")),
|
||||
Project(project_id="a", name="test2", output_dir=str(tmp_path / "a")))
|
||||
|
||||
assert ws._folder_warn_lbl.isHidden() is True
|
||||
|
||||
|
||||
def test_nhan_canh_bao_nam_ngay_duoi_o_thu_muc_lam_viec(ws):
|
||||
"""Cảnh báo phải ở cạnh thứ nó nói tới, không rơi xuống cuối biểu mẫu."""
|
||||
from cowork_local.presentation.workspace.project_editing import _row_layout_of
|
||||
from cowork_local.presentation.workspace.project_folder_rules import _layout_chua
|
||||
|
||||
hang = _row_layout_of(ws.folder_lbl)
|
||||
layout, vi_tri = _layout_chua(ws.folder_lbl.parentWidget().layout(), hang)
|
||||
|
||||
assert layout.itemAt(vi_tri + 1).widget() is ws._folder_warn_lbl
|
||||
|
||||
# ---- hành vi: chọn thư mục đã thuộc project khác thì KHÔNG được ghi ------
|
||||
|
||||
def test_chon_thu_muc_trung_thi_khong_ghi_gi(qapp, ws, monkeypatch, tmp_path):
|
||||
"""Đây là cổng chặn thật, ở đúng nút "Đổi" mà người dùng bấm."""
|
||||
from PySide6.QtWidgets import QFileDialog, QMessageBox
|
||||
|
||||
from cowork_local.ui import workspace_tab as wt
|
||||
|
||||
cua_toi = Project(project_id="b", name="test3", output_dir=str(tmp_path / "b"))
|
||||
cua_nguoi_khac = Project(project_id="a", name="test2", output_dir=str(tmp_path / "a"))
|
||||
_mo_project(qapp, ws, monkeypatch, cua_toi, cua_nguoi_khac)
|
||||
|
||||
da_ghi = []
|
||||
monkeypatch.setattr(projects_mod, "save_project",
|
||||
lambda project, directory=None: da_ghi.append(project))
|
||||
da_bao = []
|
||||
monkeypatch.setattr(QMessageBox, "warning",
|
||||
staticmethod(lambda *a, **k: da_bao.append(a)))
|
||||
# Người dùng chọn đúng thư mục của project kia.
|
||||
monkeypatch.setattr(QFileDialog, "getExistingDirectory",
|
||||
staticmethod(lambda *a, **k: str(tmp_path / "a")))
|
||||
|
||||
wt.WorkspaceTab._pick_folder(ws)
|
||||
|
||||
assert da_ghi == [], "đã ghi đè output_dir dù thư mục thuộc project khác"
|
||||
assert cua_toi.output_dir == str(tmp_path / "b"), "thư mục cũ bị đổi mất"
|
||||
assert da_bao, "chặn im lặng — người dùng không biết vì sao không đổi được"
|
||||
|
||||
|
||||
def test_chon_thu_muc_tu_do_thi_van_doi_duoc(qapp, ws, monkeypatch, tmp_path):
|
||||
"""Chặn một chiều là hỏng tính năng — thư mục chưa ai dùng phải đổi được."""
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from cowork_local.ui import workspace_tab as wt
|
||||
|
||||
cua_toi = Project(project_id="b", name="test3", output_dir=str(tmp_path / "b"))
|
||||
cua_nguoi_khac = Project(project_id="a", name="test2", output_dir=str(tmp_path / "a"))
|
||||
_mo_project(qapp, ws, monkeypatch, cua_toi, cua_nguoi_khac)
|
||||
|
||||
da_ghi = []
|
||||
monkeypatch.setattr(projects_mod, "save_project",
|
||||
lambda project, directory=None: da_ghi.append(project))
|
||||
monkeypatch.setattr(QFileDialog, "getExistingDirectory",
|
||||
staticmethod(lambda *a, **k: str(tmp_path / "hoan-toan-moi")))
|
||||
|
||||
wt.WorkspaceTab._pick_folder(ws)
|
||||
|
||||
assert len(da_ghi) == 1
|
||||
assert cua_toi.output_dir == str(tmp_path / "hoan-toan-moi")
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Cổng project: Cowork và GraphRAG chỉ hiện khi đã chọn một project cụ thể.
|
||||
|
||||
Cổng có hai mặt và trước đây chỉ mặt thứ nhất làm đúng:
|
||||
|
||||
* **Sub-tab trong màn Workspace** — ``_update_tab_visibility`` vốn đã ẩn/hiện
|
||||
đúng. Chỗ hỏng nằm ở ``refresh()``: nó mặc định ``row_to_select = 0`` nên lúc
|
||||
mở app (chưa ai bấm gì) danh sách tự chọn hộ project đầu tiên, mở cổng cho một
|
||||
project người dùng chưa hề chọn.
|
||||
* **Hàng trên menu trái** — ``NavRailMixin._rebuild_nav`` từng dựng hàng ở dạng
|
||||
mờ kèm tooltip thay vì bỏ đi ("shown instead of hidden"), nên người dùng vẫn
|
||||
thấy Cowork/GraphRAG trên menu dù cổng đang đóng.
|
||||
|
||||
Các bài dưới đây chốt cả hai mặt, ở cả ba trạng thái: chưa chọn → ẩn, chọn rồi →
|
||||
hiện, bỏ chọn → ẩn lại.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def win(qapp, tmp_path):
|
||||
"""MainWindow thật — cần cả cửa sổ vì phải kiểm cả menu trái.
|
||||
|
||||
Đọc project từ ``~/.cowork_local`` như bản cài thật (``core/projects.py``
|
||||
gắn ``PROJECTS_DIR`` vào đó) nên các bài này KHÔNG tạo/xoá project nào.
|
||||
Bài nào cần cổng MỞ thì gọi thẳng ``_update_tab_visibility(True)`` thay vì
|
||||
tạo project trên đĩa của người chạy test.
|
||||
"""
|
||||
from cowork_local.presentation.shell.bootstrap import build_config, build_context
|
||||
from cowork_local.presentation.shell.main_window import MainWindow
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
build_config(config_path)
|
||||
window = MainWindow(build_context(config_path))
|
||||
yield window
|
||||
window.close()
|
||||
|
||||
|
||||
def _cong(ws):
|
||||
"""Hai sub-tab nằm sau cổng project, bỏ qua bản dựng không có chúng."""
|
||||
return [(ten, idx) for ten, idx in
|
||||
(("Cowork", ws._cowork_tab_idx), ("GraphRAG", ws._graphrag_tab_idx))
|
||||
if idx >= 0]
|
||||
|
||||
|
||||
def _hang_menu(win):
|
||||
"""Nhãn của mọi hàng đang có trên cột menu trái."""
|
||||
return [win.nav.topLevelItem(i).text(0) for i in range(win.nav.topLevelItemCount())]
|
||||
|
||||
|
||||
# ---- mặt 1: không tự chọn hộ project ------------------------------------
|
||||
|
||||
def test_mo_app_len_chua_chon_thi_khong_tu_chon_ho(win):
|
||||
"""Đây là nguyên nhân gốc: ``refresh()`` từng mặc định chọn dòng 0."""
|
||||
ws = win.workspace
|
||||
|
||||
assert ws._current_id == ""
|
||||
assert ws.project_list.currentRow() == -1
|
||||
|
||||
|
||||
def test_chua_chon_project_thi_hai_sub_tab_deu_an(win):
|
||||
ws = win.workspace
|
||||
|
||||
for ten, idx in _cong(ws):
|
||||
assert ws.tabs.isTabVisible(idx) is False, f"{ten} hiện khi chưa chọn project"
|
||||
assert ws.subtab_available(idx) is False, f"{ten} vẫn mở cổng"
|
||||
|
||||
|
||||
def test_chua_chon_project_thi_dung_o_tab_project(win):
|
||||
"""Ẩn hai tab kia mà lại đứng ở một tab đã ẩn thì màn hình trống trơn."""
|
||||
ws = win.workspace
|
||||
|
||||
assert ws.current_subtab() == ws._project_tab_idx
|
||||
|
||||
|
||||
def test_refresh_giu_nguyen_project_dang_chon(win):
|
||||
"""Sửa cổng không được làm mất lựa chọn hiện có: ``keep`` vẫn phải thắng."""
|
||||
ws = win.workspace
|
||||
if ws.project_list.count() == 0:
|
||||
pytest.skip("máy chạy test chưa có project nào để chọn")
|
||||
|
||||
ws.project_list.setCurrentRow(0)
|
||||
dang_chon = ws._current_id
|
||||
|
||||
ws.refresh()
|
||||
|
||||
assert ws._current_id == dang_chon
|
||||
assert ws.project_list.currentRow() >= 0
|
||||
|
||||
|
||||
# ---- mặt 2: menu trái bỏ hẳn hàng, không hiện dạng mờ -------------------
|
||||
|
||||
def test_chua_chon_project_thi_menu_trai_khong_co_hai_hang(win):
|
||||
"""Đây là thứ người dùng nhìn thấy — trước đây hai hàng vẫn nằm đó, chỉ mờ."""
|
||||
nhan = _hang_menu(win)
|
||||
|
||||
assert "Cowork" not in nhan, f"Cowork vẫn trên menu: {nhan}"
|
||||
assert "GraphRAG" not in nhan, f"GraphRAG vẫn trên menu: {nhan}"
|
||||
|
||||
|
||||
def test_mo_cong_thi_hai_hang_quay_lai_menu_trai(win):
|
||||
"""Bỏ hàng phải đảo ngược được, nếu không thì chọn project xong vẫn kẹt."""
|
||||
ws = win.workspace
|
||||
ws._current_id = "gia-lap"
|
||||
ws._update_tab_visibility(True)
|
||||
|
||||
nhan = _hang_menu(win)
|
||||
assert "Cowork" in nhan, f"Cowork không quay lại: {nhan}"
|
||||
assert "GraphRAG" in nhan, f"GraphRAG không quay lại: {nhan}"
|
||||
|
||||
|
||||
def test_mo_cong_thi_hai_sub_tab_cung_hien_lai(win):
|
||||
ws = win.workspace
|
||||
ws._current_id = "gia-lap"
|
||||
ws._update_tab_visibility(True)
|
||||
|
||||
for ten, idx in _cong(ws):
|
||||
assert ws.tabs.isTabVisible(idx) is True, f"{ten} vẫn ẩn khi cổng đã mở"
|
||||
|
||||
|
||||
def test_dong_cong_lai_thi_hai_hang_bien_mat(win):
|
||||
"""Cổng phải đóng lại được, không chỉ mở một chiều."""
|
||||
ws = win.workspace
|
||||
ws._current_id = "gia-lap"
|
||||
ws._update_tab_visibility(True)
|
||||
ws._current_id = ""
|
||||
ws._update_tab_visibility(False)
|
||||
|
||||
nhan = _hang_menu(win)
|
||||
assert "Cowork" not in nhan and "GraphRAG" not in nhan, nhan
|
||||
|
||||
|
||||
def test_cac_hang_khac_khong_bi_anh_huong(win):
|
||||
"""Chỉ hai hàng sau cổng bị bỏ — phần còn lại của menu giữ nguyên."""
|
||||
nhan = _hang_menu(win)
|
||||
|
||||
for bat_buoc in ("Project", "Co4E"):
|
||||
assert bat_buoc in nhan, f"{bat_buoc} biến mất khỏi menu: {nhan}"
|
||||
@@ -1,23 +1,21 @@
|
||||
"""Sandbox Security unlock — chốt các đường KHÔNG được mở khoá (SEC-20260907-01).
|
||||
"""Sandbox Security Layer: bốn công tắc luôn sửa được, không còn khoá mật khẩu.
|
||||
|
||||
``DEFAULT_CONFIG`` ship ``agent_security.sandbox_pw = ""`` kể từ commit
|
||||
``3827552 fix(security): remove shared unlock defaults``, và cấu hình đưa tới
|
||||
dialog LUÔN được deep-merge với defaults đó
|
||||
(``infrastructure/config/json_config_repository.py``). Nghĩa là trên mọi bản cài
|
||||
không đặt ``COWORK_SANDBOX_PASSWORD``, mật khẩu đã lưu là chuỗi rỗng — và phép so
|
||||
sánh ``pw == self._sandbox_pw`` nhận luôn ô nhập trống.
|
||||
Trước đây nhóm này bị khoá: bốn công tắc dựng ra ở trạng thái ``setEnabled(False)``
|
||||
và chỉ mở khi nhập đúng mật khẩu qua ``_sandbox_unlock()``. Bộ bài cũ ở file này
|
||||
(SEC-20260907-01) chốt các đường KHÔNG được mở khoá — chúng mất đối tượng kiểm khi
|
||||
tính năng khoá bị bỏ theo yêu cầu, nên được thay bằng các bài dưới đây.
|
||||
|
||||
Ba nhóm bài ở đây:
|
||||
Docstring của ``_sandbox_unlock()`` cũ đã tự nói rõ nó là gì: *"khoá phía giao diện
|
||||
để chặn bấm nhầm vào một mục nhạy cảm, KHÔNG phải cơ chế bảo mật thật"*. Rào thật
|
||||
nằm ở tầng sandbox lúc chạy lệnh, không ở hộp thoại Cài đặt.
|
||||
|
||||
* **đường tấn công** — chốt đúng lỗ trên;
|
||||
* **đường đi đúng** — bản vá không được phá, kể cả với mật khẩu có dấu;
|
||||
* **chặn cả lớp lỗi** — commit ``3827552`` sửa ``config.py`` nhưng bỏ sót bản sao
|
||||
thứ hai của literal trong ``ui/settings_dialog.py``. Bài cuối quét chéo mọi thư
|
||||
mục nguồn để lần sau không sót kiểu đó nữa.
|
||||
Hai nhóm bài:
|
||||
|
||||
* **hành vi mới** — mở hộp thoại là bật/tắt được ngay, không qua bước nào;
|
||||
* **guardrail** — quét mã nguồn để lần sau không ai lặng lẽ khoá lại.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -27,209 +25,76 @@ import pytest
|
||||
from .test_settings_dialog_dac_ta import _Ctx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shown(monkeypatch):
|
||||
"""Ghi lại mọi QMessageBox thay vì bật modal thật (modal sẽ treo test).
|
||||
|
||||
Trả về list các ``(loại, tiêu_đề, nội_dung)`` — cần thiết để phân biệt
|
||||
"chưa cấu hình mật khẩu" với "sai mật khẩu"; nếu chỉ nuốt hộp thoại đi thì
|
||||
hai nhánh gộp lại làm một mà test vẫn xanh.
|
||||
"""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
calls: list[tuple[str, str, str]] = []
|
||||
|
||||
def _record(kind):
|
||||
def _fn(_parent, title, text, *a, **k):
|
||||
calls.append((kind, title, text))
|
||||
return staticmethod(_fn)
|
||||
|
||||
monkeypatch.setattr(QMessageBox, "warning", _record("warning"))
|
||||
monkeypatch.setattr(QMessageBox, "information", _record("information"))
|
||||
return calls
|
||||
|
||||
|
||||
def _dialog(stored_pw: str):
|
||||
"""SettingsDialog với ``sandbox_pw`` đúng như bản cài thật: key CÓ mặt."""
|
||||
def _dialog():
|
||||
"""SettingsDialog dựng đúng như bản cài thật."""
|
||||
from cowork_local.ui.settings_dialog import SettingsDialog
|
||||
ctx = _Ctx()
|
||||
ctx.config.data["agent_security"]["sandbox_pw"] = stored_pw
|
||||
return SettingsDialog(ctx)
|
||||
return SettingsDialog(_Ctx())
|
||||
|
||||
|
||||
# ---- đường tấn công ------------------------------------------------------
|
||||
|
||||
def test_o_trong_khong_mo_duoc_khoa(qapp, shown):
|
||||
"""Chưa đặt mật khẩu (sandbox_pw == "") thì ô nhập trống KHÔNG được mở khoá."""
|
||||
dlg = _dialog("")
|
||||
dlg.sandbox_pw_edit.setText("")
|
||||
|
||||
dlg._sandbox_unlock()
|
||||
|
||||
assert dlg._sandbox_unlocked is False
|
||||
dlg.deleteLater()
|
||||
_CONG_TAC = ("sandbox_confirm", "sandbox_block_network", "sec_enabled", "ai_check")
|
||||
|
||||
|
||||
def test_go_bua_khi_chua_dat_mat_khau_cung_khong_mo_duoc(qapp, shown):
|
||||
"""Mật khẩu lưu rỗng thì KHÔNG chuỗi nào mở được, kể cả chuỗi khác rỗng."""
|
||||
dlg = _dialog("")
|
||||
dlg.sandbox_pw_edit.setText("bat ky")
|
||||
# ---- hành vi mới: sửa được ngay, không cần mật khẩu ----------------------
|
||||
|
||||
dlg._sandbox_unlock()
|
||||
@pytest.mark.parametrize("ten", _CONG_TAC)
|
||||
def test_cong_tac_sua_duoc_ngay_khi_mo_hop_thoai(qapp, ten):
|
||||
"""Đây là chính yêu cầu: không còn bước nhập mật khẩu nào chắn ở giữa."""
|
||||
dlg = _dialog()
|
||||
|
||||
assert dlg._sandbox_unlocked is False
|
||||
dlg.deleteLater()
|
||||
assert getattr(dlg, ten).isEnabled() is True, f"{ten} vẫn bị khoá"
|
||||
|
||||
|
||||
def test_mat_khau_sai_khong_mo_duoc(qapp, shown):
|
||||
"""Đã đặt mật khẩu thì gõ sai vẫn khoá."""
|
||||
dlg = _dialog("K7MNP2QRSTVW")
|
||||
dlg.sandbox_pw_edit.setText("K7MNP2QRSTVX")
|
||||
@pytest.mark.parametrize("ten", _CONG_TAC)
|
||||
def test_bat_tat_duoc_va_luu_dung_gia_tri(qapp, ten):
|
||||
"""Bật/tắt phải ăn vào widget — khoá cũ chặn đúng ở bước này."""
|
||||
dlg = _dialog()
|
||||
w = getattr(dlg, ten)
|
||||
|
||||
dlg._sandbox_unlock()
|
||||
|
||||
assert dlg._sandbox_unlocked is False
|
||||
dlg.deleteLater()
|
||||
truoc = w.isChecked()
|
||||
w.setChecked(not truoc)
|
||||
assert w.isChecked() is (not truoc)
|
||||
w.setChecked(truoc)
|
||||
assert w.isChecked() is truoc
|
||||
|
||||
|
||||
# ---- thông báo phải phân biệt được hai tình huống -------------------------
|
||||
def test_khong_con_widget_mat_khau_nao(qapp):
|
||||
"""Ô nhập, nút Mở khoá và nhãn "Đang khoá" phải biến mất khỏi hộp thoại."""
|
||||
dlg = _dialog()
|
||||
|
||||
def test_chua_cau_hinh_bao_khac_voi_sai_mat_khau(qapp, shown):
|
||||
"""Hai nhánh phải nói hai chuyện khác nhau.
|
||||
|
||||
Người chưa từng đặt mật khẩu mà nhận "Password incorrect" sẽ gõ lại mãi một
|
||||
thứ không tồn tại. Không có bài này thì gộp hai nhánh về một thông báo chung
|
||||
vẫn xanh hết.
|
||||
"""
|
||||
from cowork_local.i18n import tr
|
||||
|
||||
dlg = _dialog("")
|
||||
dlg.sandbox_pw_edit.setText("")
|
||||
dlg._sandbox_unlock()
|
||||
chua_cau_hinh = list(shown)
|
||||
dlg.deleteLater()
|
||||
|
||||
shown.clear()
|
||||
dlg2 = _dialog("K7MNP2QRSTVW")
|
||||
dlg2.sandbox_pw_edit.setText("sai roi")
|
||||
dlg2._sandbox_unlock()
|
||||
sai_mat_khau = list(shown)
|
||||
dlg2.deleteLater()
|
||||
|
||||
assert len(chua_cau_hinh) == 1, "phải hiện đúng một thông báo"
|
||||
assert len(sai_mat_khau) == 1
|
||||
assert chua_cau_hinh[0][2] == tr("settings.sandbox_pw_unset_body")
|
||||
assert chua_cau_hinh[0][2] != sai_mat_khau[0][2], (
|
||||
"chưa cấu hình mật khẩu và sai mật khẩu phải là hai thông báo khác nhau")
|
||||
for ten in ("sandbox_pw_edit", "sandbox_unlock_btn", "sandbox_locked_status",
|
||||
"sandbox_pw_label"):
|
||||
assert not hasattr(dlg, ten), f"{ten} vẫn còn trên hộp thoại"
|
||||
|
||||
|
||||
# ---- đường đi đúng vẫn phải chạy ----------------------------------------
|
||||
def test_khong_con_duong_mo_khoa_trong_ma(qapp):
|
||||
"""Hàm mở khoá và cờ trạng thái khoá không còn tồn tại."""
|
||||
import cowork_local.ui.settings_dialog as mod
|
||||
|
||||
def test_mat_khau_dung_van_mo_duoc(qapp, shown):
|
||||
"""Bản vá không được phá đường đi hợp lệ."""
|
||||
dlg = _dialog("K7MNP2QRSTVW")
|
||||
dlg.sandbox_pw_edit.setText("K7MNP2QRSTVW")
|
||||
|
||||
dlg._sandbox_unlock()
|
||||
|
||||
assert dlg._sandbox_unlocked is True
|
||||
dlg.deleteLater()
|
||||
dlg = _dialog()
|
||||
assert not hasattr(dlg, "_sandbox_unlock")
|
||||
assert not hasattr(dlg, "_sandbox_unlocked")
|
||||
assert not hasattr(dlg, "_sandbox_widgets")
|
||||
assert not hasattr(mod, "_sandbox_password_matches")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pw", ["mật khẩu", "パスワード", "sénhà-2026"])
|
||||
def test_mat_khau_co_dau_khong_lam_crash(qapp, shown, pw):
|
||||
"""``secrets.compare_digest`` ném TypeError nếu str có ký tự ngoài ASCII.
|
||||
# ---- guardrail: không ai khoá lại mà không sửa bài test này --------------
|
||||
|
||||
App mặc định tiếng Việt và phục vụ khách Nhật, nên chữ có dấu trong ô mật
|
||||
khẩu là input bình thường. Phải so sánh trên bytes.
|
||||
"""
|
||||
dlg = _dialog(pw)
|
||||
dlg.sandbox_pw_edit.setText(pw)
|
||||
def test_ma_nguon_khong_con_khoa_nhom_sandbox():
|
||||
"""Chặn cả lớp lỗi: lần sau ai thêm lại ``setEnabled(False)`` cho nhóm này
|
||||
thì bài này đỏ ngay, không đợi có người mở app mới thấy."""
|
||||
src = (Path(__file__).resolve().parents[2]
|
||||
/ "ui" / "settings_dialog.py").read_text(encoding="utf-8")
|
||||
code = "\n".join(l for l in src.splitlines() if not l.strip().startswith("#"))
|
||||
|
||||
dlg._sandbox_unlock() # không được ném TypeError
|
||||
|
||||
assert dlg._sandbox_unlocked is True
|
||||
dlg.deleteLater()
|
||||
for dau_hieu in ("_sandbox_unlock", "_sandbox_widgets", "_sandbox_unlocked",
|
||||
"sandbox_pw"):
|
||||
assert dau_hieu not in code, f"khoá sandbox đã quay lại: {dau_hieu}"
|
||||
|
||||
|
||||
def test_mat_khau_co_dau_sai_thi_van_khoa(qapp, shown):
|
||||
"""Chữ có dấu không được biến thành đường mở khoá dễ dãi."""
|
||||
dlg = _dialog("mật khẩu")
|
||||
dlg.sandbox_pw_edit.setText("mat khau")
|
||||
def test_phep_quet_thuc_su_doc_duoc_file():
|
||||
"""Lưới an toàn: đổi tên file làm bài trên quét rỗng mà vẫn xanh."""
|
||||
src = (Path(__file__).resolve().parents[2]
|
||||
/ "ui" / "settings_dialog.py").read_text(encoding="utf-8")
|
||||
|
||||
dlg._sandbox_unlock()
|
||||
|
||||
assert dlg._sandbox_unlocked is False
|
||||
dlg.deleteLater()
|
||||
|
||||
|
||||
# ---- hàm so khớp, gọi thẳng ----------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("entered,stored,expected", [
|
||||
("", "", False), # cả hai rỗng
|
||||
("", "K7MNP2QRSTVW", False), # ô nhập rỗng
|
||||
("K7MNP2QRSTVW", "", False), # chưa đặt mật khẩu — nhánh phòng thủ
|
||||
("K7MNP2QRSTVW", "K7MNP2QRSTVW", True),
|
||||
("mật khẩu", "mật khẩu", True), # ngoài ASCII
|
||||
("mật khẩu", "mat khau", False),
|
||||
])
|
||||
def test_ham_so_khop(entered, stored, expected):
|
||||
"""Gọi thẳng ``_sandbox_password_matches`` — phủ cả nhánh mà call site đã
|
||||
chặn trước bằng return sớm."""
|
||||
from cowork_local.ui.settings_dialog import _sandbox_password_matches
|
||||
assert _sandbox_password_matches(entered, stored) is expected
|
||||
|
||||
|
||||
# ---- chặn cả lớp lỗi -----------------------------------------------------
|
||||
|
||||
#: ``.get("<khoá kiểu credential>", "<literal khác rỗng>")`` — mặc định trông có
|
||||
#: vẻ an toàn nhưng thực ra là credential nằm trong mã nguồn. Nó cũng là code
|
||||
#: chết: cấu hình đã deep-merge với DEFAULT_CONFIG nên key luôn tồn tại.
|
||||
#:
|
||||
#: Cố ý KHÔNG bắt ``key`` và ``code`` trần: ``it.get("key", "?")`` của Jira
|
||||
#: (``core/jira_tool.py``) là mã issue, không phải credential. Danh sách dưới đây
|
||||
#: chỉ gồm tên đã mang nghĩa bí mật.
|
||||
_CREDENTIAL_FALLBACK = re.compile(
|
||||
r'\.get\(\s*["\'][a-z_]*'
|
||||
r'(?:pw|passwd|password|secret|token|api_key|unlock_code|access_code)'
|
||||
r'[a-z_]*["\']\s*,\s*["\'][^"\']+["\']'
|
||||
)
|
||||
|
||||
#: Quét CHÉO mọi thư mục nguồn, không chỉ tầng giao diện. Sai sót gốc của commit
|
||||
#: ``3827552`` là sửa ``config.py`` mà quên bản sao trong ``ui/`` — tức là lỗi đi
|
||||
#: xuyên thư mục, nên phép quét cũng phải đi xuyên thư mục.
|
||||
_SCANNED = (
|
||||
"ui", "presentation", "core", "infrastructure", "application", "domain",
|
||||
"mcp_servers", "providers", "security", "theme", "config.py", "state.py",
|
||||
)
|
||||
|
||||
|
||||
def test_khong_con_fallback_credential_trong_ma_nguon():
|
||||
"""Không file nguồn nào được đặt credential làm giá trị mặc định của ``.get()``."""
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
offenders = []
|
||||
for name in _SCANNED:
|
||||
target = root / name
|
||||
if target.is_file():
|
||||
files = [target]
|
||||
elif target.is_dir():
|
||||
files = [p for p in target.rglob("*.py") if "__pycache__" not in p.parts]
|
||||
else: # thư mục bị đổi tên/xoá
|
||||
continue
|
||||
for path in files:
|
||||
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
if _CREDENTIAL_FALLBACK.search(line):
|
||||
offenders.append(
|
||||
f"{path.relative_to(root).as_posix()}:{lineno}: {line.strip()}")
|
||||
|
||||
assert not offenders, "credential nằm trong mã nguồn:\n " + "\n ".join(offenders)
|
||||
|
||||
|
||||
def test_phep_quet_thuc_su_nhin_thay_file():
|
||||
"""Lưới an toàn cho bài trên: đổi tên thư mục làm nó quét rỗng mà vẫn xanh."""
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
seen = sum(
|
||||
1 for name in _SCANNED
|
||||
for _ in ([root / name] if (root / name).is_file()
|
||||
else (root / name).rglob("*.py") if (root / name).is_dir() else [])
|
||||
)
|
||||
assert seen > 200, f"chỉ quét được {seen} file — phạm vi quét đã hỏng"
|
||||
assert "class SettingsDialog" in src
|
||||
assert len(src) > 2000, f"chỉ đọc được {len(src)} ký tự — đường dẫn đã hỏng"
|
||||
|
||||
@@ -128,19 +128,24 @@ def test_bam_project_tren_thanh_menu_an_ngay_lan_dau(window):
|
||||
|
||||
|
||||
def test_khi_cong_project_MO_thi_ha_canh_o_cowork_va_vet_sang_theo(window):
|
||||
"""Nhánh của người dùng ĐÃ có project — nhánh mà bug được báo.
|
||||
"""Nhánh của người dùng ĐÃ chọn một project — nhánh mà bug được báo.
|
||||
|
||||
Môi trường test không có project nào (cố ý: ``core/projects.py`` ghi vào
|
||||
``~/.cowork_local`` thật). Mở cổng bằng tay để đi đúng nhánh đó mà không
|
||||
phải tạo project trên đĩa.
|
||||
Trước đây bài này mở cổng bằng cửa sau ``setTabVisible(True)`` vì môi trường
|
||||
test không có project nào (``core/projects.py`` ghi vào ``~/.cowork_local``
|
||||
thật, nên test không tạo project). Cửa sau đó hết tác dụng từ khi cổng được
|
||||
điều khiển bằng ``_current_id``: ``refresh()``/``goto_all_projects()`` đóng
|
||||
lại ngay. Giờ mở cổng bằng đúng đường thật — chọn một project — và bỏ qua
|
||||
bài này trên máy chưa có project nào.
|
||||
"""
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
ws = window.workspace
|
||||
if ws._cowork_tab_idx < 0:
|
||||
pytest.skip("bản dựng này không có sub-tab Cowork")
|
||||
if ws.project_list.count() == 0:
|
||||
pytest.skip("máy chạy test chưa có project nào — cổng không mở được")
|
||||
|
||||
ws.tabs.setTabVisible(ws._cowork_tab_idx, True)
|
||||
ws.project_list.setCurrentRow(0)
|
||||
try:
|
||||
window.goto_all_projects()
|
||||
|
||||
@@ -154,5 +159,5 @@ def test_khi_cong_project_MO_thi_ha_canh_o_cowork_va_vet_sang_theo(window):
|
||||
f"nội dung ở Cowork ({ws._cowork_tab_idx}) "
|
||||
f"nhưng thanh menu sáng ở {data.get('sub')}")
|
||||
finally:
|
||||
ws.tabs.setTabVisible(ws._cowork_tab_idx, False)
|
||||
ws.project_list.setCurrentRow(-1) # đóng cổng lại đúng đường thật
|
||||
window.goto_all_projects()
|
||||
|
||||
@@ -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}"
|
||||
@@ -26,7 +26,7 @@ from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E4
|
||||
SETTINGS_FIELDS = [
|
||||
"language_combo", "theme_combo", "tray_chk", "notify_chk",
|
||||
"provider_combo", "prov_base", "prov_key", "prov_model",
|
||||
"sandbox_pw_edit", "sandbox_unlock_btn", "sandbox_confirm",
|
||||
"sandbox_confirm",
|
||||
"sandbox_block_network", "sec_enabled", "ai_check",
|
||||
]
|
||||
TASK_FIELDS = [
|
||||
|
||||
+25
-18
@@ -74,22 +74,30 @@ def main() -> int:
|
||||
|
||||
main_rows, bottom_rows = rows(win.nav), rows(win.nav_bottom)
|
||||
n_total = len(main_rows) + len(bottom_rows)
|
||||
# Five Workspace sub-views + Schedule, then Dashboard + Monitoring.
|
||||
if len(main_rows) != 6:
|
||||
fails.append(f"thanh chinh co {len(main_rows)} dong, cho 6")
|
||||
# Workspace gop cac sub-view DANG MO CONG + Schedule, roi Dashboard +
|
||||
# Monitoring. Cowork/GraphRAG chi co mat khi da chon mot project, nen so
|
||||
# dong doi theo cong thay vi co dinh 6.
|
||||
mo_cong = sum(1 for _l, _i, _ic, on in win.workspace.nav_entries() if on)
|
||||
cho_chinh = mo_cong + 1
|
||||
if len(main_rows) != cho_chinh:
|
||||
fails.append(f"thanh chinh co {len(main_rows)} dong, cho {cho_chinh}")
|
||||
if len(bottom_rows) != 2:
|
||||
fails.append(f"nhom day co {len(bottom_rows)} dong, cho 2")
|
||||
if any(sub is not None for _l, _p, sub, _o in bottom_rows):
|
||||
fails.append("nhom day khong duoc mang sub-tab")
|
||||
|
||||
# The two gated rows must be PRESENT (that is the point) — greyed is fine.
|
||||
# Hang bi cong project dong thi BO HAN khoi menu; hang dang mo phai co mat.
|
||||
labels = [r[0] for r in main_rows]
|
||||
ws_labels = [lab for lab, _i, _ic, _on in win.workspace.nav_entries()]
|
||||
for lab in ws_labels:
|
||||
ws_mo = [lab for lab, _i, _ic, on in win.workspace.nav_entries() if on]
|
||||
ws_dong = [lab for lab, _i, _ic, on in win.workspace.nav_entries() if not on]
|
||||
for lab in ws_mo:
|
||||
if lab not in labels:
|
||||
fails.append(f"mat dong Workspace: {lab}")
|
||||
print(f"du 5 man Workspace tren thanh menu: {all(l in labels for l in ws_labels)}"
|
||||
f" ({', '.join(ws_labels)})")
|
||||
fails.append(f"mat dong Workspace dang mo cong: {lab}")
|
||||
for lab in ws_dong:
|
||||
if lab in labels:
|
||||
fails.append(f"dong Workspace dang dong cong van tren menu: {lab}")
|
||||
print(f"man Workspace dang mo cong tren menu: {all(l in labels for l in ws_mo)}"
|
||||
f" ({', '.join(ws_mo) or 'khong co'})")
|
||||
|
||||
# Highlight must follow the content for every row, both ways round.
|
||||
# Re-fetch items by index every time: navigating can rebuild the rail, which
|
||||
@@ -145,19 +153,18 @@ def main() -> int:
|
||||
if ws_strip:
|
||||
fails.append("dai tab Workspace hien lai — trung voi thanh menu")
|
||||
|
||||
# The whole point of the change: with no project selected the two gated rows
|
||||
# must stay in place, greyed — not vanish and resize the menu.
|
||||
# Yeu cau: chua chon project thi Cowork/GraphRAG khong duoc hien tren menu.
|
||||
win.workspace._update_tab_visibility(False)
|
||||
app.processEvents()
|
||||
gated = rows(win.nav)
|
||||
off = [lab for lab, _p, _s, on in gated if not on]
|
||||
nhan_gated = [lab for lab, _p, _s, _on in gated]
|
||||
print()
|
||||
print(f"chua chon project : van du {len(gated)} dong, mo: {off or 'khong'}")
|
||||
if len(gated) != len(main_rows):
|
||||
fails.append(f"chua chon project thi thanh menu con {len(gated)} dong "
|
||||
f"(truoc {len(main_rows)}) — item van bien mat")
|
||||
if len(off) != 2:
|
||||
fails.append(f"cho 2 dong bi mo (Cowork, GraphRAG), thay {len(off)}")
|
||||
print(f"chua chon project : con {len(gated)} dong ({', '.join(nhan_gated)})")
|
||||
for lab in ("Cowork", "GraphRAG"):
|
||||
if lab in nhan_gated:
|
||||
fails.append(f"chua chon project ma {lab} van tren menu")
|
||||
if any(not on for _l, _p, _s, on in gated):
|
||||
fails.append("con dong bi mo tren menu — dang le phai bo han")
|
||||
|
||||
# --- rail header: project picker + new chat (Phase A) ------------------
|
||||
print()
|
||||
|
||||
@@ -123,9 +123,12 @@ class CoworkTab(ChatPanel):
|
||||
# workspace (its sandbox + shared-knowledge root) — not the
|
||||
# global default-output setting.
|
||||
from ..core.projects import save_project
|
||||
from ..presentation.workspace.project_folder_rules import folder_taken_blocked
|
||||
|
||||
project = self._project()
|
||||
if project is not None:
|
||||
if folder_taken_blocked(self, chosen, self.project_id):
|
||||
return
|
||||
project.output_dir = chosen
|
||||
save_project(project)
|
||||
else:
|
||||
|
||||
+2
-4
@@ -78,8 +78,7 @@ _PATHS = {
|
||||
"briefcase": '<rect x="2" y="7" width="20" height="14" rx="2"/>'
|
||||
'<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>',
|
||||
"award": '<circle cx="12" cy="8" r="7"/><polyline points="8.2 13.9 7 22 12 19 17 22 15.8 13.9"/>',
|
||||
"gear": '<circle cx="12" cy="12" r="3"/>'
|
||||
'<path d="M12 1v4M12 19v4M4.2 4.2l2.8 2.8M17 17l2.8 2.8M1 12h4M19 12h4M4.2 19.8L7 17M17 7l2.8-2.8"/>',
|
||||
"gear": '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0L6.2 6.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.5a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
"globe": '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>'
|
||||
'<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
||||
"logout": '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>'
|
||||
@@ -199,8 +198,7 @@ _PATHS = {
|
||||
'<line x1="7" y1="15" x2="17" y2="15"/>', # = beaker
|
||||
"sparkle": '<path d="M12 3l1.8 4.8L18.5 9.5 13.8 11.2 12 16l-1.8-4.8L5.5 9.5l4.7-1.7z"/>'
|
||||
'<path d="M19 15l.7 1.9L21.5 17.5l-1.8.7L19 20l-.7-1.8L16.5 17.5l1.8-.6z"/>', # = sparkles
|
||||
"settings": '<circle cx="12" cy="12" r="3"/>'
|
||||
'<path d="M12 1v4M12 19v4M4.2 4.2l2.8 2.8M17 17l2.8 2.8M1 12h4M19 12h4M4.2 19.8L7 17M17 7l2.8-2.8"/>', # = gear
|
||||
"settings": '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0L6.2 6.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.5a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>', # = gear
|
||||
}
|
||||
|
||||
|
||||
|
||||
+14
-84
@@ -13,20 +13,17 @@ chưa từng được gán nên gọi vào là AttributeError.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout,
|
||||
QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
|
||||
QMessageBox, QPushButton, QScrollArea, QSpinBox,
|
||||
QGroupBox, QHBoxLayout, QListWidget, QListWidgetItem,
|
||||
QScrollArea, QSpinBox,
|
||||
QTreeWidgetItem, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..i18n import tr
|
||||
from .dialog_buttons import dialog_buttons
|
||||
from .icons import IconLabel
|
||||
from .widgets import ToggleSwitch
|
||||
|
||||
|
||||
@@ -37,25 +34,6 @@ from ..presentation.settings.routing_settings_widget import RoutingSettingsWidge
|
||||
from ..presentation.settings.about_widget import AboutSettingsWidget
|
||||
|
||||
|
||||
def _sandbox_password_matches(entered: str, stored: str) -> bool:
|
||||
"""Whether ``entered`` unlocks the Sandbox Security group.
|
||||
|
||||
An empty ``stored`` must never match. ``DEFAULT_CONFIG`` ships
|
||||
``agent_security.sandbox_pw = ""`` and the config handed to this dialog is
|
||||
always deep-merged with those defaults, so a plain ``entered == stored``
|
||||
accepts an empty field on every install that never set a password. The MS365
|
||||
unlock guards the same way — see ``json_config_repository.unlock_ms365``.
|
||||
|
||||
Both sides are compared as UTF-8 bytes, not as ``str``:
|
||||
``compare_digest`` raises ``TypeError`` on ``str`` holding anything outside
|
||||
ASCII, and this app defaults to Vietnamese and ships to Japanese customers,
|
||||
so an accented password is ordinary input rather than an edge case.
|
||||
"""
|
||||
if not entered or not stored:
|
||||
return False
|
||||
return secrets.compare_digest(entered.encode("utf-8"), stored.encode("utf-8"))
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
"""Hộp thoại Cài đặt: cột mục lục bên trái, các trang bên phải
|
||||
(Nhà cung cấp · Connectors · Định tuyến · Tham số · Chung).
|
||||
@@ -108,36 +86,17 @@ class SettingsDialog(QDialog):
|
||||
self.sandbox_group = QGroupBox(tr("settings.group.sandbox"))
|
||||
sbl = QVBoxLayout(self.sandbox_group)
|
||||
|
||||
# --- Password protection for Sandbox Security (at top) ---
|
||||
self.sandbox_pw_label = IconLabel("lock", tr("settings.sandbox_pw_label"))
|
||||
sbl.addWidget(self.sandbox_pw_label)
|
||||
|
||||
pw_row = QHBoxLayout()
|
||||
self.sandbox_pw_edit = QLineEdit("")
|
||||
self.sandbox_pw_edit.setPlaceholderText(tr("settings.sandbox_pw_placeholder"))
|
||||
self.sandbox_pw_edit.setEchoMode(QLineEdit.Password)
|
||||
pw_row.addWidget(self.sandbox_pw_edit, 1)
|
||||
self.sandbox_unlock_btn = QPushButton(tr("settings.sandbox_unlock_btn"))
|
||||
self.sandbox_unlock_btn.clicked.connect(self._sandbox_unlock)
|
||||
pw_row.addWidget(self.sandbox_unlock_btn)
|
||||
self.sandbox_locked_status = IconLabel("lock", tr("settings.sandbox_locked"), color="#c00")
|
||||
self.sandbox_locked_status.text_label().setStyleSheet("color: #c00; font-weight: bold;")
|
||||
pw_row.addWidget(self.sandbox_locked_status)
|
||||
sbl.addLayout(pw_row)
|
||||
self._sandbox_unlocked = False # Start LOCKED — must enter password first
|
||||
self._sandbox_pw = sec.get("sandbox_pw", "")
|
||||
|
||||
# Separator line between pw section and sandbox settings
|
||||
pw_sep = QLabel("────────────────")
|
||||
sbl.addWidget(pw_sep)
|
||||
|
||||
# Nhóm này KHÔNG còn khoá bằng mật khẩu: bốn công tắc dưới đây bật/tắt
|
||||
# tự do. Khoá cũ chỉ là rào chống bấm nhầm ở phía giao diện, không phải
|
||||
# cơ chế bảo mật thật (rào thật nằm ở sandbox lúc chạy lệnh), nên bỏ đi
|
||||
# theo yêu cầu thay vì giữ một bước nhập mật khẩu không bảo vệ được gì.
|
||||
self.sandbox_confirm = ToggleSwitch(tr("settings.sandbox_confirm_commands"))
|
||||
self.sandbox_confirm.setChecked(bool(sec.get("cowork_confirm_commands", False)))
|
||||
self.sandbox_confirm.setToolTip(tr("settings.sandbox_confirm_commands_tooltip"))
|
||||
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)
|
||||
|
||||
@@ -160,14 +119,6 @@ class SettingsDialog(QDialog):
|
||||
# Resource limits (CPU/Memory/Disk I/O) moved to the Parameter group
|
||||
# below — see _param_section("settings.group.sandbox_limits").
|
||||
|
||||
# Collect all sandbox-editable widgets and lock them until unlocked
|
||||
self._sandbox_widgets = [
|
||||
self.sandbox_confirm, self.sandbox_block_network,
|
||||
self.ai_check, self.sec_enabled,
|
||||
]
|
||||
for _w in self._sandbox_widgets:
|
||||
_w.setEnabled(False)
|
||||
|
||||
root.addWidget(self.sandbox_group)
|
||||
|
||||
# Connectors (MCP / REST API) are managed entirely in Monitoring → Tools
|
||||
@@ -299,34 +250,6 @@ class SettingsDialog(QDialog):
|
||||
|
||||
|
||||
|
||||
def _sandbox_unlock(self) -> None:
|
||||
"""Mở khoá nhóm cài đặt sandbox bằng mật khẩu.
|
||||
|
||||
Đây là khoá phía giao diện để chặn bấm nhầm vào một mục nhạy cảm, KHÔNG
|
||||
phải cơ chế bảo mật thật.
|
||||
"""
|
||||
pw = self.sandbox_pw_edit.text()
|
||||
if not self._sandbox_pw:
|
||||
# No password configured. Refusing with "wrong password" would be a
|
||||
# dead end — the user would keep retrying a password that cannot
|
||||
# exist — so name the actual state instead.
|
||||
QMessageBox.warning(self, tr("settings.sandbox_pw_unset_title"),
|
||||
tr("settings.sandbox_pw_unset_body"))
|
||||
return
|
||||
if _sandbox_password_matches(pw, self._sandbox_pw):
|
||||
self._sandbox_unlocked = True
|
||||
self.sandbox_locked_status.setText(tr("settings.sandbox_unlocked"))
|
||||
self.sandbox_locked_status.set_icon("unlock", "#090")
|
||||
self.sandbox_locked_status.text_label().setStyleSheet("color: #090; font-weight: bold;")
|
||||
# Enable all sandbox widgets
|
||||
for w in self._sandbox_widgets:
|
||||
w.setEnabled(True)
|
||||
QMessageBox.information(self, tr("settings.group.sandbox"),
|
||||
tr("settings.sandbox_unlocked_body"))
|
||||
else:
|
||||
QMessageBox.warning(self, tr("settings.sandbox_pw_wrong_title"),
|
||||
tr("settings.sandbox_pw_wrong_body"))
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Gom cấu hình từ mọi trang con rồi ghi xuống đĩa."""
|
||||
data = self.ctx.config.data
|
||||
@@ -354,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()
|
||||
+3
-2
@@ -490,8 +490,9 @@ def section_panels(sections, width: int = 260):
|
||||
index.currentRowChanged.connect(stack.setCurrentIndex)
|
||||
index.setCurrentRow(0)
|
||||
|
||||
natural = max(index.fontMetrics().horizontalAdvance(lab) for lab, _w in sections) + 36
|
||||
index.setFixedWidth(max(120, min(width, natural)))
|
||||
# Sized for the SELECTED look (bold + padding), or the picked label is cut.
|
||||
from ..presentation.shared.list_sizing import selected_label_width
|
||||
index.setFixedWidth(max(120, min(width, selected_label_width(index, [lab for lab, _w in sections]))))
|
||||
return index, stack
|
||||
|
||||
|
||||
|
||||
+74
-23
@@ -27,13 +27,14 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..presentation.workspace.project_editing import ProjectEditingMixin, ProjectRow
|
||||
from ..presentation.workspace.project_folder_rules import ProjectFolderRuleMixin, folder_taken_blocked
|
||||
from ..state import AppContext
|
||||
from .icons import collapse_left_icon, icon
|
||||
from .osutil import open_folder
|
||||
from .widgets import CollapseStrip
|
||||
|
||||
|
||||
class WorkspaceTab(ProjectEditingMixin, QWidget):
|
||||
class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
|
||||
"""Trang chủ Workspace: cột project, cột lịch sử, và 5 sub-tab
|
||||
(Dự án · Cowork · Co4E · Thư mục · GraphRAG).
|
||||
|
||||
@@ -63,10 +64,9 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
|
||||
"""(label, index, icon_name, enabled) for EVERY sub-tab, hidden ones
|
||||
included.
|
||||
|
||||
The rail lists all five all the time and greys out the ones the project
|
||||
gate is currently closing (Cowork, GraphRAG) instead of removing them —
|
||||
same gate, shown rather than hidden, so the menu stops changing shape
|
||||
under the user's hand. See nav_subtabs() for the visible-only view.
|
||||
Cột ``enabled`` là trạng thái cổng project; ``NavRailMixin._rebuild_nav``
|
||||
bỏ hẳn những hàng đang đóng (Cowork, GraphRAG) khỏi menu trái cho tới khi
|
||||
người dùng chọn một project. See nav_subtabs() for the visible-only view.
|
||||
"""
|
||||
icons = {self._project_tab_idx: "folder", self._cowork_tab_idx: "chat",
|
||||
self._co4e_tab_idx: "flow", self._folder_tab_idx: "folder",
|
||||
@@ -78,8 +78,8 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
|
||||
def subtab_available(self, index: int) -> bool:
|
||||
"""False while the project gate is holding this sub-tab shut.
|
||||
|
||||
The rail greys those rows out, but that only guards the rail. This lets
|
||||
every other route ask the same question of the same state.
|
||||
Rail bỏ hẳn những hàng đó khỏi menu, nhưng đó chỉ chắn được đường vào
|
||||
qua rail. Hàm này để mọi đường vào khác hỏi cùng một trạng thái.
|
||||
"""
|
||||
return bool(0 <= index < self.tabs.count() and self.tabs.isTabVisible(index))
|
||||
|
||||
@@ -203,27 +203,26 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
|
||||
cpl.addWidget(self._cowork)
|
||||
self._cowork_tab_idx = self.tabs.addTab(cowork_page, tr("workspace.tab_cowork"))
|
||||
|
||||
# Co4E — node-graph workflow studio (built-in flows, agents, skills, a
|
||||
# runner + chat). Always available (not project-gated): its workflows
|
||||
# live globally under ~/.cowork_local/co4e, not inside one project.
|
||||
# Placed BEFORE GraphRAG in the tab order (user request).
|
||||
from .co4e_tab import Co4ETab
|
||||
|
||||
self._co4e = Co4ETab(self.ctx)
|
||||
self._co4e_tab_idx = self.tabs.addTab(self._co4e, tr("workspace.tab_co4e"))
|
||||
# Co4E and Folder are intentionally placeholders at startup. Their
|
||||
# widget trees pull in a large amount of Qt/UI code, but neither is on
|
||||
# the initial Project surface. The real page is created exactly once
|
||||
# when its tab is first selected (see _ensure_heavy_tab).
|
||||
self._co4e = None
|
||||
self._folder = None
|
||||
self._co4e_placeholder = QWidget()
|
||||
self._folder_placeholder = QWidget()
|
||||
self._co4e_tab_idx = self.tabs.addTab(self._co4e_placeholder, tr("workspace.tab_co4e"))
|
||||
self.tabs.setTabToolTip(self._co4e_tab_idx, tr("workspace.tab_co4e_tooltip"))
|
||||
|
||||
# Folder — a two-pane file explorer (tree + view/edit) placed right below
|
||||
# Co4E. Always available (not project-gated); its root follows the
|
||||
# selected project's workspace folder when one is chosen.
|
||||
from ..presentation.folder.folder_tab import FolderTab
|
||||
self._folder_tab_idx = self.tabs.addTab(self._folder_placeholder, tr("workspace.tab_folder"))
|
||||
|
||||
self._folder = FolderTab(self.ctx, cowork=self._cowork)
|
||||
self._folder.status_message.connect(self.status_message)
|
||||
self._folder_tab_idx = self.tabs.addTab(self._folder, tr("workspace.tab_folder"))
|
||||
|
||||
if self._structure is not None:
|
||||
self._graphrag_tab_idx = self.tabs.addTab(self._structure, tr("workspace.tab_graphrag"))
|
||||
self._graph_placeholder = QWidget()
|
||||
self._graphrag_tab_idx = self.tabs.addTab(
|
||||
self._structure if self._structure is not None else self._graph_placeholder,
|
||||
tr("workspace.tab_graphrag"))
|
||||
|
||||
self.tabs.currentChanged.connect(self._on_tab_changed)
|
||||
|
||||
@@ -308,6 +307,7 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
|
||||
cloud_row.addWidget(self._cloud_sync_btn)
|
||||
cloud_row.addStretch(1)
|
||||
rl.addLayout(cloud_row)
|
||||
self._cloud_pick_btn.hide()
|
||||
self._cloud_badge_lbl = QLabel()
|
||||
self._cloud_badge_lbl.setWordWrap(True)
|
||||
self._cloud_badge_lbl.hide()
|
||||
@@ -421,10 +421,50 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
|
||||
|
||||
Dựng lười như vậy chính là thứ giữ cho RAM lúc khởi động ở mức thấp.
|
||||
"""
|
||||
if idx in (self._co4e_tab_idx, self._folder_tab_idx, self._graphrag_tab_idx):
|
||||
self._ensure_heavy_tab(idx)
|
||||
if idx == self._graphrag_tab_idx and self._structure is not None:
|
||||
self._structure.auto_scan_and_fit()
|
||||
self._apply_pane_visibility()
|
||||
|
||||
def _ensure_heavy_tab(self, idx: int):
|
||||
"""Build a heavy Workspace child once, replacing its placeholder."""
|
||||
if idx == self._co4e_tab_idx and self._co4e is None:
|
||||
from .co4e_tab import Co4ETab
|
||||
real = Co4ETab(self.ctx)
|
||||
self._co4e = real
|
||||
self.tabs.removeTab(idx)
|
||||
self.tabs.insertTab(idx, real, tr("workspace.tab_co4e"))
|
||||
self.tabs.setCurrentIndex(idx)
|
||||
self.tabs.setTabToolTip(idx, tr("workspace.tab_co4e_tooltip"))
|
||||
self._bind_project(self._current_id)
|
||||
return real
|
||||
if idx == self._folder_tab_idx and self._folder is None:
|
||||
from ..presentation.folder.folder_tab import FolderTab
|
||||
real = FolderTab(self.ctx, cowork=self._cowork)
|
||||
real.status_message.connect(self.status_message)
|
||||
self._folder = real
|
||||
self.tabs.removeTab(idx)
|
||||
self.tabs.insertTab(idx, real, tr("workspace.tab_folder"))
|
||||
self.tabs.setCurrentIndex(idx)
|
||||
self._bind_project(self._current_id)
|
||||
return real
|
||||
if idx == self._graphrag_tab_idx and self._structure is None:
|
||||
from ..presentation.graph.structure_graph_view import StructureGraphView
|
||||
real = StructureGraphView(self.ctx)
|
||||
real.status_message.connect(self.status_message)
|
||||
if self._cowork is not None:
|
||||
self._cowork.output_changed.connect(real.schedule_rescan)
|
||||
self._structure = real
|
||||
self.tabs.removeTab(idx)
|
||||
self.tabs.insertTab(idx, real, tr("workspace.tab_graphrag"))
|
||||
self.tabs.setCurrentIndex(idx)
|
||||
self._bind_project(self._current_id)
|
||||
return real
|
||||
return {self._co4e_tab_idx: self._co4e,
|
||||
self._folder_tab_idx: self._folder,
|
||||
self._graphrag_tab_idx: self._structure}.get(idx)
|
||||
|
||||
def _apply_pane_visibility(self) -> None:
|
||||
"""Which side panes accompany each sub-tab:
|
||||
|
||||
@@ -610,7 +650,12 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
|
||||
counts = self._project_counts()
|
||||
self.project_list.blockSignals(True)
|
||||
self.project_list.clear()
|
||||
row_to_select = 0
|
||||
# -1 chứ không phải 0: chưa chọn gì thì KHÔNG tự chọn hộ project đầu
|
||||
# danh sách. Chọn hộ là mở luôn cổng Cowork/GraphRAG (xem
|
||||
# _update_tab_visibility) cho một project người dùng chưa hề bấm vào —
|
||||
# lúc mở app, và cả sau khi xoá project đang mở. Có ``keep`` khớp thì
|
||||
# vẫn giữ đúng dòng cũ như trước.
|
||||
row_to_select = -1
|
||||
for i, p in enumerate(list_projects()):
|
||||
chats, tasks = counts.get(p.project_id, (0, 0))
|
||||
# No text on the item: the row widget paints the name, and setting
|
||||
@@ -897,9 +942,12 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
|
||||
self, tr("workspace.browse_tooltip"), str(project.workspace_dir()))
|
||||
if not chosen:
|
||||
return
|
||||
if folder_taken_blocked(self, chosen, pid):
|
||||
return
|
||||
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:
|
||||
@@ -940,6 +988,8 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
|
||||
if not cloud_source:
|
||||
return
|
||||
local_dir = WORKSPACES_DIR / project.project_id / "_cloud_mirror"
|
||||
if folder_taken_blocked(self, str(local_dir), pid):
|
||||
return
|
||||
self._cloud_pick_btn.setEnabled(False)
|
||||
try:
|
||||
token = self._cloud_token()
|
||||
@@ -953,6 +1003,7 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
|
||||
project.cloud_source = cloud_source
|
||||
save_project(project)
|
||||
self.folder_lbl.setText(str(local_dir))
|
||||
self.rebind_workspace_folder()
|
||||
self._refresh_cloud_badge(project)
|
||||
self.projects_changed.emit()
|
||||
if report.errors:
|
||||
|
||||
Reference in New Issue
Block a user