diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c2931b3 --- /dev/null +++ b/CLAUDE.md @@ -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\\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/`. Core AI work uses `core-ai/-`. +- 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. diff --git a/presentation/folder/offline_web_page.py b/presentation/folder/offline_web_page.py index 44c96ee..31ba6b3 100644 --- a/presentation/folder/offline_web_page.py +++ b/presentation/folder/offline_web_page.py @@ -6,16 +6,28 @@ 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, QWebEngineUrlRequestInterceptor, + 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): @@ -27,13 +39,28 @@ class RemoteRequestBlocker(QWebEngineUrlRequestInterceptor): 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 private profile whose remote requests obey the guard.""" - profile = QWebEngineProfile(view) # no storage name = off the record - blocker = RemoteRequestBlocker(profile) - profile.setUrlRequestInterceptor(blocker) - view.setPage(QWebEnginePage(profile, view)) - view._remote_blocker = blocker # keep the interceptor alive with the view + """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"] +__all__ = ["RemoteRequestBlocker", "install_offline_page", "preview_profile"] diff --git a/tests/ui/test_html_preview_remote_images.py b/tests/ui/test_html_preview_remote_images.py new file mode 100644 index 0000000..144d50a --- /dev/null +++ b/tests/ui/test_html_preview_remote_images.py @@ -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 +```` 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()