Compare commits

..
Author SHA1 Message Date
duylh19andClaude Opus 5 57af508971 fix(sandbox): chặn mạng thật sự cho mọi tool NETWORK của agent
CI / test (pull_request) Canceled after 0s
block_network trước đây chỉ được run_command đọc tới, nên fetch_url,
jira_search, jira_get_issue và install_package vẫn ra internet bình thường
trong khi Monitoring báo Mạng - Bị chặn. Thêm cổng chặn ngay đầu bốn tool
đó, trước mọi lời gọi mạng, kèm test hồi quy và guardrail theo
BUILT_IN_CAPABILITIES.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 08:33:26 +09:00
duylh19andClaude Opus 5 d0df96c726 fix(workspace): Cowork/GraphRAG chỉ hiện khi đã chọn project
Mở app lên là hai mục đã hiện sẵn dù người dùng chưa bấm chọn project nào.
Cổng này có hai mặt và cả hai đều hở:

1. refresh() mặc định row_to_select = 0, nên lúc mở app (_current_id rỗng)
   danh sách tự chọn hộ project đầu tiên -> _update_tab_visibility(True).
   Cùng đường đó còn làm app nhảy sang project khác ngay sau khi xoá project
   đang mở. Đổi mặc định thành -1; có keep khớp thì vẫn giữ đúng dòng cũ.

2. NavRailMixin._rebuild_nav dựng hàng bị cổng đóng ở dạng mờ kèm tooltip
   ("shown instead of hidden") nên chúng vẫn nằm trên menu trái. Nay bỏ hẳn
   hàng cho tới khi chọn project.

Mặt 1 làm selected_project_id() trả rỗng, khiến bộ chọn project trên rail rơi
về mục 0 và chỉ vào project đầu danh sách trong khi cổng vẫn đóng — hai chỗ
nói hai đằng. Thêm mục "Chọn project…" (khoá app.nav.pick_project, khác
no_project: đã có project, chỉ chưa chọn) để rail nói đúng trạng thái.

tools/check_nav.py và một bài trong test_startup_landing.py đang chốt thiết
kế cũ; cập nhật để chốt hành vi mới. Bài startup giữ nguyên từng assert, chỉ
đổi cách mở cổng từ cửa sau setTabVisible sang chọn project thật.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:32:08 +09:00
duylh19andClaude Opus 5 10799dc67b feat(settings): bỏ khoá mật khẩu ở Sandbox Security Layer
Bốn công tắc của nhóm này (xác nhận lệnh, chặn mạng, bật lớp bảo mật agent,
AI kiểm tra lệnh) dựng ra ở trạng thái setEnabled(False) và chỉ mở khi nhập
đúng mật khẩu. Theo yêu cầu, bỏ hẳn bước đó: form luôn bật/tắt được.

Gỡ ô nhập, nút Mở khoá, nhãn trạng thái khoá, _sandbox_unlock() và
_sandbox_password_matches(); dọn 10 khoá i18n thành chết và 2 field trong
tools/check_dialogs.py. Giữ nguyên agent_security.sandbox_pw ở config.py —
yêu cầu chỉ nói tới màn hình, không nói tới tầng cấu hình.

Khoá này vốn không phải rào bảo mật: docstring của _sandbox_unlock() đã tự
ghi "khoá phía giao diện để chặn bấm nhầm ... 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. Vùng này thuộc diện SECURITY.md
yêu cầu Cowork Team soát thêm.

10 bài test cũ (SEC-20260907-01) chốt các đường không được mở khoá nay mất
đối tượng kiểm, thay bằng 12 bài chốt hành vi mới: bốn công tắc sửa được
ngay, không còn widget mật khẩu, kèm guardrail quét mã nguồn chặn khoá lại.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:31:49 +09:00
1b8429e33a Delta team/fix comment ui (#10)
CI / test (push) Canceled after 0s
fix các bug theo yêu cầu
https://fptsoftware362-my.sharepoint.com/❌/g/personal/nampdt_fpt_com/IQAHBJ4A9xqDTLgvt2bhukJEAdRB5LRz2hbJpTivvIiBSYM?wdExp=TEAMS-TREATMENT&web=1&isSPOFile=1&ovuser=f01e930a-b52e-42b1-b70f-a8882b5d043b%2CAnhTNM1%40fpt.com&clickparams=eyJBcHBOYW1lIjoiVGVhbXMtRGVza3RvcCIsIkFwcFZlcnNpb24iOiI0OS8yNjA4MTMxOTMxNyIsIkhhc0ZlZGVyYXRlZFVzZXIiOmZhbHNlfQ%3D%3D

---------

Co-authored-by: Duy Le Huu <duylh19@fpt.com>
Reviewed-on: #10
Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
2026-09-09 16:46:15 +00:00
17 changed files with 448 additions and 358 deletions
+8 -4
View File
@@ -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
# Cut the agent off the network: proxy env pointed at a black hole for
# agent-run shell commands, PLUS a flat refusal from every tool tagged
# ToolCapability.NETWORK (fetch_url, jira_*, install_package) — those
# reach the net in-process, where the proxy trick has nothing to act on.
"block_network": True,
# 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
-6
View File
@@ -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 がコマンドを実行する前に確認する",
+3
View File
@@ -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…"},
-22
View File
@@ -45,28 +45,6 @@ STRINGS: Dict[str, Dict[str, str]] = {
"ファイアウォールではありません。上のコマンドホワイトリストと併用してください。",
"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á."},
"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ớ"},
@@ -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}."
+25 -1
View File
@@ -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", "")))
+8 -4
View File
@@ -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"].
+7 -9
View File
@@ -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)
+6
View File
@@ -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)
+125
View File
@@ -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")
+141
View File
@@ -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}"
+62 -197
View File
@@ -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"
+11 -6
View File
@@ -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()
+1 -1
View File
@@ -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
View File
@@ -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()
+6 -83
View File
@@ -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,29 +86,10 @@ 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"))
@@ -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
+11 -7
View File
@@ -63,10 +63,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 +77,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))
@@ -610,7 +609,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