feat(infra): R02 vào thật — app chạy bằng JsonConfigRepository, khoá rời khỏi config.json
Từ 21/08 tôi đã viết xong 7 file R02 với 46 test xanh, và báo là "xong R02". Báo sai: code mới nằm song song, KHÔNG một dòng nào ngoài infrastructure/ và tests/ gọi tới nó. App vẫn chạy nguyên trên config.py, 29 file dùng nó, và khoá API của người dùng vẫn nằm plaintext trong config.json suốt 4 ngày. Commit này mới là phần refactor thật. Bù 21 thành viên còn thiếu (85 dòng) ------------------------------------ JsonConfigRepository có 18/34 thành viên công khai của AppConfig nên không tráo được. Chép nguyên ngữ nghĩa 21 cái còn lại: load, ms365_*, ext_connectors, connect_external, routing_mode_for, seeded_*, mcp_servers, teams, history, structure, monitoring_visibility, model_label, ca_bundle... Giờ 40/34, không thiếu gì. Không phải thiết kế mới — chừng nào 29 file còn gọi qua ctx.config thì repository phải trả lời được đúng các câu hỏi cũ. ROUTING_MODES lấy theo bản Delta (4 chế độ, có "fallback" từ R03-T03) chứ không theo bản main cũ 3 chế độ. Chép bản cũ là routing "fallback" âm thầm rơi về "off" sau khi Delta merge, không lỗi nào báo. Composition Root (R08-T10, phần đầu) ------------------------------------- presentation/shell/bootstrap.py: một chỗ duy nhất quyết định app dựng bằng mảnh nào. app.py::run giờ gọi build_context() thay cho AppConfig.load(). Đây cũng là chỗ ráp kho bí mật vào; máy không có keyring thì secrets=None và mọi thứ chạy như cũ. Kiểm trên dữ liệu thật ---------------------- Chạy lên máy tôi, migration tự chạy đúng như thiết kế: openai_compat 39 ký tự config.json -> Windows Credential Manager ollama giá trị bù nhìn, để nguyên trong file, không đẩy vào kho schema_version 1 -> 2 sao lưu config.json.v20260825-193206.bak Sau khi bật lại app và để nó ghi cấu hình, config.json vẫn sạch: api_key rỗng, không còn chuỗi nào có hình dạng khoá. scripts/audit_security.py sạch. Tiêu chí nghiệm thu A của plan (dòng 244) — "0 lưu trữ plaintext API Key trong JSON" — tới commit này mới thật sự đạt. 632 test xanh. check_dialogs, check_nav, check_design_parity đều qua. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e4ce9b2f5f
commit
2246d55286
@@ -19,6 +19,7 @@ from PySide6.QtWidgets import (
|
||||
from . import APP_NAME, DISPLAY_NAME, __version__
|
||||
from .config import PROVIDER_LABELS, AppConfig
|
||||
from .i18n import LANGUAGE_SHORT, LANGUAGES, get_language, on_language_changed, set_language, tr
|
||||
from .presentation.shell.bootstrap import build_context
|
||||
from .state import AppContext
|
||||
from .ui.widgets import tidy_popup
|
||||
from .theme import current_palette, set_active_theme, stylesheet
|
||||
@@ -1293,7 +1294,10 @@ def run(argv: List[str] | None = None) -> int:
|
||||
app = QApplication.instance() or QApplication(argv)
|
||||
app.setApplicationName(APP_NAME)
|
||||
app.setWindowIcon(app_icon())
|
||||
ctx = AppContext(AppConfig.load())
|
||||
# Composition Root: presentation/shell/bootstrap.py quyết định app chạy
|
||||
# bằng mảnh nào. Từ R02, đó là JsonConfigRepository + kho bí mật của hệ
|
||||
# điều hành, không còn config.py::AppConfig.
|
||||
ctx = build_context()
|
||||
set_language(ctx.config.language)
|
||||
# Built-in default skills (if any are bundled) are always-on and loaded
|
||||
# straight from the package; tidy away any copy seeded by older versions so they
|
||||
|
||||
@@ -170,6 +170,164 @@ class JsonConfigRepository:
|
||||
disabled.append(name)
|
||||
self.data["tools_disabled"] = disabled
|
||||
|
||||
|
||||
# ---- phần bù để thay được AppConfig ----------------------------------
|
||||
# 21 thành viên dưới đây chép nguyên ngữ nghĩa từ ``config.py::AppConfig``.
|
||||
# Không phải thiết kế mới: chừng nào 29 file còn gọi qua ``ctx.config`` thì
|
||||
# repository phải trả lời được đúng những câu hỏi cũ, nếu không thì không
|
||||
# tráo được. Dọn lại là việc của các R sau, không phải của R02.
|
||||
|
||||
#: Các chế độ định tuyến. Delta thêm "fallback" ở R03-T03. Định nghĩa ở đây
|
||||
#: là bản chính; ``tests/test_config_repository.py`` có bài đối chiếu với
|
||||
#: ``config.py`` để hai bên lệch nhau là đỏ ngay.
|
||||
ROUTING_MODES = ("off", "auto", "manual", "fallback")
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path | None = None, *, secrets: SecretStore | None = None):
|
||||
"""Dựng repository từ đường dẫn mặc định — thay ``AppConfig.load()``."""
|
||||
if path is None:
|
||||
from ... import config as legacy
|
||||
path = legacy.CONFIG_PATH
|
||||
return cls(Path(path), secrets=secrets)
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return self._file.path
|
||||
|
||||
# ---- TLS -------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def ca_bundle(self) -> str:
|
||||
"""Đường dẫn file PEM riêng, hoặc '' để kiểm chứng chỉ như bình thường.
|
||||
|
||||
Dùng làm tham số ``verify=`` của ``requests`` cho mọi lượt gọi HTTPS."""
|
||||
return (self.data.get("tls_ca_bundle") or "").strip()
|
||||
|
||||
@ca_bundle.setter
|
||||
def ca_bundle(self, value: str) -> None:
|
||||
self.data["tls_ca_bundle"] = (value or "").strip()
|
||||
|
||||
# ---- MS365 -----------------------------------------------------------
|
||||
|
||||
@property
|
||||
def ms365(self) -> Dict[str, Any]:
|
||||
return self.data.setdefault("ms365", copy.deepcopy(self._defaults["ms365"]))
|
||||
|
||||
def ms365_try_unlock(self, code: str) -> bool:
|
||||
"""Mở khoá nhóm MS365 trong Cài đặt cho phiên này.
|
||||
|
||||
Đâ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 xác thực Microsoft. Không bao giờ được lưu ở trạng thái đã mở."""
|
||||
if (code or "") and code == self.ms365.get("unlock_code", ""):
|
||||
self.data["ms365"]["unlocked"] = True
|
||||
return True
|
||||
return False
|
||||
|
||||
def ms365_lock(self) -> None:
|
||||
self.data.setdefault("ms365", {})["unlocked"] = False
|
||||
|
||||
# ---- nhóm cấu hình đọc thẳng ------------------------------------------
|
||||
|
||||
@property
|
||||
def code(self) -> Dict[str, Any]:
|
||||
return self.data["code"]
|
||||
|
||||
@property
|
||||
def teams(self) -> Dict[str, Any]:
|
||||
return self.data["teams"]
|
||||
|
||||
@property
|
||||
def history(self) -> Dict[str, Any]:
|
||||
return self.data["history"]
|
||||
|
||||
@property
|
||||
def codebase_memory(self) -> Dict[str, Any]:
|
||||
return self.data["codebase_memory"]
|
||||
|
||||
@property
|
||||
def cowork(self) -> Dict[str, Any]:
|
||||
return self.data["cowork"]
|
||||
|
||||
@property
|
||||
def mcp_servers(self) -> list:
|
||||
return self.data.setdefault("mcp_servers", [])
|
||||
|
||||
@property
|
||||
def structure(self) -> Dict[str, Any]:
|
||||
return self.data.setdefault("structure", {"max_nodes": 400, "max_edges": 400})
|
||||
|
||||
@property
|
||||
def monitoring_visibility(self) -> Dict[str, bool]:
|
||||
return self.data.setdefault(
|
||||
"monitoring_visibility",
|
||||
copy.deepcopy(self._defaults["monitoring_visibility"]))
|
||||
|
||||
@property
|
||||
def ext_connectors(self) -> Dict[str, list]:
|
||||
"""Connector (MCP) gom theo nhóm CAD/CAE/MS365/Other."""
|
||||
d = self.data.setdefault(
|
||||
"ext_connectors", {"cad": [], "cae": [], "ms365": [], "other": []})
|
||||
for cat in ("cad", "cae", "ms365", "other"):
|
||||
d.setdefault(cat, [])
|
||||
return d
|
||||
|
||||
# ---- công tắc tổng cho connector -------------------------------------
|
||||
|
||||
@property
|
||||
def connect_external(self) -> bool:
|
||||
"""Tắt cái này là agent không nối tới connector ngoài nào cả. Mặc định
|
||||
BẬT để cấu hình đang chạy không đổi hành vi."""
|
||||
return bool(self.data.setdefault("tools", {}).get("connect_external", True))
|
||||
|
||||
def set_connect_external(self, enabled: bool) -> None:
|
||||
self.data.setdefault("tools", {})["connect_external"] = bool(enabled)
|
||||
self.save()
|
||||
|
||||
# ---- những thứ đã gieo sẵn -------------------------------------------
|
||||
|
||||
@property
|
||||
def seeded_library_skills(self) -> list:
|
||||
"""Slug của skill thư viện đã gieo — để cái người dùng xoá đi không bị
|
||||
lặng lẽ gieo lại."""
|
||||
return list(self.data.setdefault("seeded_library_skills", []))
|
||||
|
||||
@seeded_library_skills.setter
|
||||
def seeded_library_skills(self, slugs) -> None:
|
||||
self.data["seeded_library_skills"] = list(dict.fromkeys(slugs or []))
|
||||
|
||||
@property
|
||||
def seeded_builtin_flows(self) -> list:
|
||||
"""Id của flow Co4E dựng sẵn đã gieo (cùng quy tắc tôn trọng việc người
|
||||
dùng đã xoá như seeded_library_skills)."""
|
||||
return list(self.data.setdefault("seeded_builtin_flows", []))
|
||||
|
||||
@seeded_builtin_flows.setter
|
||||
def seeded_builtin_flows(self, ids) -> None:
|
||||
self.data["seeded_builtin_flows"] = list(dict.fromkeys(ids or []))
|
||||
|
||||
# ---- định tuyến theo từng bề mặt chat --------------------------------
|
||||
|
||||
def routing_mode_for(self, surface: str) -> str:
|
||||
"""Chế độ có hiệu lực cho một bề mặt chat.
|
||||
|
||||
Đặt riêng cho bề mặt thì thắng; để trống thì lấy ``switch_mode`` chung.
|
||||
Giá trị lạ rơi về "off" — định tuyến luôn là thứ phải bật, kể cả khi
|
||||
có người sửa tay file cấu hình."""
|
||||
routing = self.routing
|
||||
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
|
||||
mode = override or routing.get("switch_mode", "off")
|
||||
return mode if mode in self.ROUTING_MODES else "off"
|
||||
|
||||
def set_routing_mode_for(self, surface: str, mode: str) -> None:
|
||||
mode = mode if mode in self.ROUTING_MODES else "off"
|
||||
self.routing.setdefault("surface_modes", {})[surface] = mode
|
||||
self.save()
|
||||
|
||||
# ---- tiện ích --------------------------------------------------------
|
||||
|
||||
def model_label(self) -> str:
|
||||
return str(self.provider_conf().get("model", "?"))
|
||||
|
||||
# ---- ghi -------------------------------------------------------------
|
||||
def save(self) -> None:
|
||||
"""Ghi nguyên tử. Không bao giờ để lộ trạng thái mở khoá ms365."""
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Composition Root — R08-T10.
|
||||
|
||||
Một chỗ duy nhất quyết định app chạy bằng những mảnh nào. Trước đây quyết định
|
||||
đó nằm rải trong ``app.py::run``, lẫn với việc dựng cửa sổ; tách ra để đổi một
|
||||
mảnh (ví dụ thay kho bí mật) không phải đụng vào mã giao diện.
|
||||
|
||||
Đây cũng là chỗ hoàn tất R02: từ đây app chạy bằng :class:`JsonConfigRepository`
|
||||
chứ không còn ``config.py::AppConfig``. Hai thứ đổi thật sự:
|
||||
|
||||
* ghi cấu hình qua ``AtomicJsonFile`` — mất điện giữa lúc lưu không làm hỏng file
|
||||
* API key nằm trong kho bí mật của hệ điều hành, không nằm trong ``config.json``
|
||||
|
||||
Máy không có kho bí mật (Linux headless, CI, hoặc keyring hỏng) vẫn chạy bình
|
||||
thường: repository nhận ``secrets=None`` và đọc khoá thẳng từ file như cũ. Thà
|
||||
để khoá trong file còn hơn xoá đi rồi người dùng mất khoá mà không hiểu vì sao.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ...infrastructure.config.json_config_repository import JsonConfigRepository
|
||||
from ...infrastructure.secrets.keyring_adapter import KeyringAdapter
|
||||
from ...state import AppContext
|
||||
|
||||
|
||||
def build_secret_store():
|
||||
"""Kho bí mật của hệ điều hành, hoặc None nếu máy này không có.
|
||||
|
||||
``KeyringAdapter`` không bao giờ ném lỗi — nó tự báo ``available``. Trả về
|
||||
None thay vì một adapter chết để chỗ gọi khỏi phải đoán.
|
||||
"""
|
||||
store = KeyringAdapter()
|
||||
return store if store.available else None
|
||||
|
||||
|
||||
def build_config(path: Path | None = None) -> JsonConfigRepository:
|
||||
return JsonConfigRepository.load(path, secrets=build_secret_store())
|
||||
|
||||
|
||||
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))
|
||||
Reference in New Issue
Block a user