chore(refactor): mục chung của Team Gamma — khung, hợp đồng, cổng CASAN

Sáu việc trong "mục chung" của bản phân công, làm trước khi ba nhánh tính năng
tách ra.

1. Khung 5 tầng theo đúng đường dẫn plan.md: domain/ application/
   infrastructure/ presentation/ platform/ + tests/fakes/ — 38 __init__.py.
   Trước đó là 0 file, mà mọi task của cả ba người đều ghi vào đây.
   Đã kiểm platform/ không che khuất module platform của stdlib.

2. Hợp đồng SecretStore và ConfigRepository (Protocol, chưa cài đặt) + fake
   chạy trong bộ nhớ. Danh sách thuộc tính không bịa: đếm 156 lời gọi
   ctx.config.* trong 29 file rồi lấy những cái dùng thật, xếp theo số lần.
   Cố ý bỏ config.data (36 lời gọi, nhiều nhất) — bê dict thô sang kiến trúc
   mới là bê nguyên vấn đề cũ.

3. tests/test_contracts.py — bài nghiệm thu, không phải test cho vui. Bài
   chính chạy tiến trình riêng và khẳng định dùng fake KHÔNG kéo theo
   cowork_local.config lẫn PySide6; đó là điều kiện để N2 và N3 code ngay hôm
   nay thay vì đợi bản thật ngày 23 và 26/08.

4. scripts/audit_security.py — CASAN Check 1, Gamma chủ trì (hạn 30/08). Viết
   sớm để kiểm liên tục trong lúc chuyển API key, không đợi tới ngày cổng.
   Lần chạy đầu ra 3 báo động giả (secret_in_output là tên quy tắc, api_key="x"
   là dữ liệu test) nên đã siết: ngưỡng độ dài, hằng liệt kê, hình dạng khoá
   i18n, và dấu "# casan: allow" làm lối thoát chuẩn.
   --self-test cắm 4 credential thật + 5 mẫu vô hại để chứng minh nó còn cắn
   được — một máy quét không tìm thấy gì chỉ có giá trị nếu chứng minh được nó
   biết tìm.

5. Ba check CASAN vào CI, chạy mọi PR thay vì dồn tới 30/08. Check 2 và 3
   thuộc Team Hoa và Team Duy, chưa có script — bước CI bỏ qua nếu file chưa
   tồn tại, để thêm cổng không làm đỏ CI của hai team kia.

6. docs/refactor/GammaTeam_decisions.md — hai quyết định chờ nhóm trưởng chốt:
   provider_conf() còn trả api_key hay không (ảnh hưởng 5 nơi, 3 nằm ngoài
   team), và số phận 24 checker UI sẽ vỡ khi file bị dời.

96 test xanh (90 cũ + 6 mới). CASAN Check 1: 0 credential lộ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-21 20:58:35 +09:00
co-authored by Claude Opus 5
parent 09b1c93624
commit 8a9ee5f875
43 changed files with 669 additions and 0 deletions
+29
View File
@@ -39,3 +39,32 @@ jobs:
- name: Run tests
run: python -m pytest tests -q
# --- CASAN Verification Gate -------------------------------------
# Ba check này là điều kiện của cổng ngày 30/08. Chạy trên MỌI PR để
# biết vi phạm ngay hôm phát sinh, thay vì dồn tới ngày cổng.
#
# Check 1 do Team Gamma sở hữu và đã có. Check 2 (Team Hoa) và Check 3
# (Team Duy) chưa viết — bước dưới bỏ qua nếu script chưa tồn tại, để
# thêm cổng không làm đỏ CI của hai team kia.
- name: "CASAN Check 1 — không có credential lộ (Team Gamma)"
run: |
python scripts/audit_security.py --self-test
python scripts/audit_security.py
- name: "CASAN Check 2 — file production ≤ 400 dòng (Team Hoa)"
run: |
if [ -f scripts/check_loc.py ]; then
python scripts/check_loc.py
else
echo "scripts/check_loc.py chưa có — Team Hoa viết, hạn 30/08. Bỏ qua."
fi
- name: "CASAN Check 3 — domain/ và application/ không import PySide6 (Team Duy)"
run: |
if [ -f scripts/check_imports.py ]; then
python scripts/check_imports.py
else
echo "scripts/check_imports.py chưa có — Team Duy viết, hạn 30/08. Bỏ qua."
fi
+1
View File
@@ -0,0 +1 @@
"""application/ — Điều phối use-case. KHÔNG import PySide6. Gọi domain + interface hạ tầng."""
View File
View File
View File
View File
View File
+107
View File
@@ -0,0 +1,107 @@
# Hai quyết định chờ nhóm trưởng chốt — Team Gamma
Hai việc này không code được cho tới khi có người quyết. Cả hai đều ảnh hưởng
ra ngoài phạm vi một người, nên để đây thay vì chôn trong comment.
Trạng thái: **chưa chốt**. Hạn: trước khi N1 bắt đầu R02-T05 (26/08).
---
## Quyết định 1 — `provider_conf()` còn trả `api_key` hay không
### Vì sao phải quyết trước khi code
R02-T05 chuyển API key sang Keyring. Câu hỏi là sau khi chuyển, dict do
`provider_conf()` trả về **còn chứa `api_key` không**.
Có 5 nơi đang đọc trực tiếp — đo trên `main` ngày 21/08:
| Nơi đọc | Thuộc |
|---|---|
| `providers/anthropic.py:26` | **Team Duy** |
| `providers/openai_compat.py:36` | **Team Duy** |
| `core/image_gen.py:50` | Team Duy (routing/model) |
| `core/ext_connectors.py:98` | Team Hoa |
| `ui/ext_connector_dialog.py:87` | Team Gamma |
Ba trong năm nằm ngoài team. Quyết một mình rồi im lặng là làm vỡ code người khác.
### Hai đường
**A. Giữ `api_key` trong dict, `ConfigRepository` tự lấy từ `SecretStore` rồi ghép vào**
- 5 nơi đọc **không phải sửa dòng nào**
- Không cần báo team khác, không cần đồng bộ lịch
- Đổi lại: bí mật vẫn đi lang thang trong dict, dễ lọt vào log hoặc màn hình debug
- CASAN Check 1 vẫn PASS vì nó quét **file trên đĩa**, không quét bộ nhớ
**B. Bỏ `api_key` khỏi dict, ai cần thì gọi `secrets.get(provider_key(name))`**
- Sạch về nguyên tắc: bí mật chỉ xuất hiện đúng chỗ cần
- Đổi lại: **5 nơi phải sửa**, 3 trong đó phải chờ team khác xếp lịch
- Rủi ro: quên một chỗ thì mất API key lúc chạy thật, mà test có fake nên không bắt được
### Đề xuất
**Đường A cho sprint này, đường B ghi vào nợ kỹ thuật.**
Lý do: mục tiêu của cổng CASAN là *không còn secret nằm trên đĩa*, và đường A
đạt được điều đó. Đường B giải quyết thêm chuyện secret trong bộ nhớ — đúng
nhưng không phải việc của 10 ngày này, và nó kéo hai team khác vào một thay đổi
họ không lên kế hoạch.
Nếu chọn B thì **phải báo Team Duy và Team Hoa trong hôm nay**, không phải lúc
đã sửa xong.
> Nhóm trưởng chốt: ☐ A ☐ B — ngày ____
---
## Quyết định 2 — số phận 24 checker UI
### Vấn đề
`tools/check_*.py` là bộ kiểm tra giao diện viết trong 2 tuần vừa rồi, hiện
**24 file**. Chúng bám vào đường dẫn cũ:
| Import | Số chỗ |
|---|---|
| `cowork_local.config` | 34 |
| `cowork_local.app` | 16 |
| `cowork_local.state` | 22 |
| `cowork_local.ui.*` | ~12 |
R08 dời hết những module đó sang `presentation/`. Nghĩa là **cả 24 checker chết
ngay ngày N1 đụng `config.py`** — và đó là lưới an toàn duy nhất cho phần giao
diện, vì `pytest` không kiểm giao diện (90 test hiện tại là logic).
### Ba đường
**A. Ai dời file thì cập nhật checker tương ứng, ngay trong PR đó**
- Giữ được lưới suốt 10 ngày
- Tốn thêm ~15% thời gian mỗi PR
- Rủi ro: người sửa vội có thể nới lỏng phép kiểm cho nó xanh — đã xảy ra một
lần trong quá trình làm UI, khi một checker được sửa thành *không thể đỏ*
**B. Đóng băng: bỏ khỏi CI, sửa một lượt ngày 31/08**
- Nhanh nhất trong 10 ngày
- Đổi lại: **không có gì canh hồi quy giao diện** suốt cả sprint. Refactor là lúc
dễ vỡ giao diện nhất
- Rủi ro cuối sprint: sửa 24 file cùng lúc, không ai nhớ cái nào đo gì
**C. Bỏ hẳn**
Không khuyến nghị. Vứt đi hai tuần công sức kiểm chứng, và ba tài liệu refactor
không có gì thay thế cho phần giao diện.
### Đề xuất
**Đường A**, kèm một ràng buộc: PR nào *sửa* checker phải nói rõ trong mô tả
**sửa gì và vì sao** — để việc nới lỏng phép kiểm không lọt qua review.
`tools/check_probes_bite.py` đã có sẵn cơ chế chứng minh checker còn cắn được;
chạy nó sau mỗi đợt sửa là bắt được ngay chuyện đó.
> Nhóm trưởng chốt: ☐ A ☐ B ☐ C — ngày ____
+1
View File
@@ -0,0 +1 @@
"""domain/ — Quy tắc nghiệp vụ thuần. KHÔNG import PySide6, không chạm đĩa/mạng."""
View File
View File
View File
View File
View File
View File
+1
View File
@@ -0,0 +1 @@
"""infrastructure/ — Chạm thế giới thật: file, keyring, HTTP, tiến trình. Cài đặt interface."""
View File
+104
View File
@@ -0,0 +1,104 @@
"""Cấu hình ứng dụng — interface, chưa phải cài đặt.
Hợp đồng số 2 của mục chung. Đây là thứ gỡ chốt lớn nhất: **156 lời gọi
``ctx.config.*`` nằm rải trong 29 file**, nên nếu N2 và N3 phải đợi
``ConfigRepository`` bản thật (R02-T02, hạn 23/08) thì hai người mất mấy ngày
đầu ngồi không.
Danh sách thuộc tính dưới đây không bịa ra: đếm trực tiếp chỗ đang gọi trong
``core/``, ``ui/``, ``providers/`` và ``app.py`` rồi lấy những cái được dùng
thật, xếp theo số lần gọi.
Một chỗ cố ý KHÔNG đưa vào: ``config.data`` (36 lần gọi, nhiều nhất). Đó là
đống dict thô — cho nó vào interface là bê nguyên vấn đề cũ sang kiến trúc mới.
Ai đang cần ``data`` thì mở issue để bổ sung một thuộc tính có kiểu rõ ràng.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, Protocol, runtime_checkable
@runtime_checkable
class ConfigRepository(Protocol):
"""Đọc/ghi cấu hình. Cài đặt thật dùng ``AtomicJsonFile`` (R02-T01/T02)."""
# ---- provider ------------------------------------------------------
@property
def active_provider(self) -> str:
"""Tên provider đang chọn (24 lời gọi)."""
...
def set_active_provider(self, name: str) -> None:
...
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
"""Cấu hình của một provider (9 lời gọi).
CHÚ Ý — điểm còn bỏ ngỏ, xem ``docs/refactor/GammaTeam_decisions.md``:
dict này còn chứa ``api_key`` hay không là quyết định chưa chốt. Có 5
nơi đang đọc trực tiếp, 3 trong số đó thuộc ``providers/`` của Team Duy.
"""
...
# ---- đường dẫn -----------------------------------------------------
@property
def shared_dir(self) -> str:
"""Thư mục dùng chung cho telemetry nhiều máy (10 lời gọi)."""
...
def history_dir(self) -> Path:
"""Thư mục lịch sử chat của project đang chọn (7 lời gọi)."""
...
def cowork_output_dir(self) -> Path:
"""Thư mục Cowork ghi kết quả ra (6 lời gọi)."""
...
# ---- giao diện -----------------------------------------------------
@property
def theme(self) -> str:
"""``"dark"`` | ``"light"`` | ``"system"`` (8 lời gọi)."""
...
def set_theme(self, value: str) -> None:
...
@property
def language(self) -> str:
"""``"vi"`` | ``"en"`` | ``"ja"`` (4 lời gọi)."""
...
def set_language(self, value: str) -> None:
...
# ---- các nhóm cấu hình còn lại -------------------------------------
@property
def routing(self) -> Dict[str, Any]:
"""Cấu hình định tuyến model (7 lời gọi)."""
...
@property
def auth(self) -> Dict[str, Any]:
"""Cấu hình đăng nhập (6 lời gọi)."""
...
@property
def agent_security(self) -> Dict[str, Any]:
"""Chính sách an toàn cho agent (5 lời gọi)."""
...
@property
def tools_disabled(self) -> list[str]:
"""Tool bị tắt (2 lời gọi)."""
...
def set_tool_enabled(self, name: str, enabled: bool) -> None:
...
# ---- ghi ------------------------------------------------------------
def save(self) -> None:
"""Ghi xuống đĩa. Bản thật ghi atomic — tạm + fsync + thay thế —
nên tắt máy giữa chừng không làm hỏng file (R02-T01).
"""
...
View File
View File
+1
View File
@@ -0,0 +1 @@
"""platform/ — Adapter riêng cho Qt (clock, thread, timer)."""
View File
+1
View File
@@ -0,0 +1 @@
"""presentation/ — Widget Qt. Chỉ gọi xuống application, không gọi thẳng infrastructure."""
View File
View File
View File
View File
View File
View File
View File
View File
View File
+204
View File
@@ -0,0 +1,204 @@
"""CASAN Check 1 — không được có credential nào nằm phơi trong repo.
Team Gamma chủ trì check này (hạn: 30/08). Viết sẵn từ 21/08 để chạy được liên
tục trong lúc chuyển API key sang Keyring (R02-T05), thay vì tới ngày cổng mới
chạy lần đầu rồi mới biết còn sót.
Quét gì:
* file cấu hình đã commit: ``*.json`` ``*.jsonl`` ``*.yaml`` ``*.yml`` ``*.env``
* mã nguồn Python — chỗ gán chuỗi cho biến tên như api_key / token / secret
Tìm hai loại:
1. Chuỗi có hình dạng credential thật (sk-…, ghp_…, xoxb-…, AKIA…, JWT…)
2. Trường tên nhạy cảm mà giá trị không rỗng và không phải placeholder
Bỏ qua: chuỗi rỗng, placeholder ("your-key-here", "changeme"…), giá trị hằng
không phải bí mật (Ollama đòi có api_key nhưng bỏ qua nội dung).
Chạy: python scripts/audit_security.py [--json]
Mã thoát: 0 = sạch, 1 = có phát hiện.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
# console Windows hay là cp932/cp1258; ép UTF-8 để không chết giữa báo cáo
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", "build",
"dist", ".pytest_cache", ".mypy_cache", "cowork-local-gitea"}
CONFIG_SUFFIX = {".json", ".jsonl", ".yaml", ".yml", ".env"}
# tên trường coi là nhạy cảm
SENSITIVE = re.compile(
r"(api[_-]?key|secret|token|password|passwd|client[_-]?secret|"
r"access[_-]?key|private[_-]?key|credential)", re.I)
# hình dạng credential thật — bắt được kể cả khi tên trường vô hại
SHAPES = [
("OpenAI", re.compile(r"\bsk-[A-Za-z0-9_\-]{20,}")),
("Anthropic", re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{20,}")),
("GitHub", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{30,}")),
("Slack", re.compile(r"\bxox[abprs]-[A-Za-z0-9\-]{10,}")),
("AWS", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
("Google", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")),
("JWT", re.compile(r"\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.")),
("Private key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
]
#: Dòng có dấu này được bỏ qua — lối thoát chuẩn cho mẫu thử, tài liệu, hằng
#: đặt tên chứa "secret". Bắt buộc ghi lý do sau dấu hai chấm.
ALLOW_MARK = re.compile(r"#\s*casan:\s*allow")
#: Giá trị là KHOÁ i18n / tên hằng, không phải bí mật. Bắt bằng hình dạng
#: "a.b.c" hoặc "a_b_c" chứ không phải bằng danh sách đen từng chữ.
LOOKS_LIKE_KEY = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$")
#: Credential thật gần như luôn dài hơn thế này. Ngưỡng để loại dữ liệu test
#: kiểu api_key="x" — báo động giả làm cả đội thôi đọc báo cáo.
MIN_SECRET_LEN = 12
#: Giá trị là hằng liệt kê, không phải bí mật: mức độ cảnh báo, bật/tắt…
ENUMISH = {"warning", "warn", "error", "info", "debug", "critical", "on", "off",
"true", "false", "yes", "no", "allow", "deny", "block", "ask",
"always", "never", "auto", "default", "disabled", "enabled"}
# giá trị vô hại — không tính là phát hiện
PLACEHOLDER = re.compile(
r"^(|ollama|none|null|changeme|your[_\- ]?(api[_\- ]?)?key([_\- ]?here)?|"
r"<[^>]*>|\{\{.*\}\}|\$\{.*\}|xxx+|\*+|placeholder|todo|example|test|dummy|"
r"sk-\.\.\.|\.\.\.)$", re.I)
# gán chuỗi trong Python: api_key = "..."
PY_ASSIGN = re.compile(
r"""["']?(\w*(?:api[_-]?key|secret|token|password|credential)\w*)["']?\s*[:=]\s*"""
r"""["']([^"']*)["']""", re.I)
def _is_placeholder(value: str) -> bool:
v = value.strip()
if PLACEHOLDER.match(v) or v.lower() in ENUMISH:
return True
if LOOKS_LIKE_KEY.match(v): # "monitoring.action_secret_in_output"
return True
# quá ngắn để là credential thật
return len(v) < MIN_SECRET_LEN
def _walk():
for path in REPO.rglob("*"):
if not path.is_file():
continue
if any(part in SKIP_DIRS for part in path.parts):
continue
if path.suffix in CONFIG_SUFFIX or path.suffix == ".py":
yield path
def scan() -> list[dict]:
findings: list[dict] = []
for path in _walk():
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
rel = path.relative_to(REPO).as_posix()
for lineno, line in enumerate(text.splitlines(), 1):
if ALLOW_MARK.search(line):
continue
# 1. hình dạng credential thật
for label, pattern in SHAPES:
m = pattern.search(line)
if m:
findings.append({
"file": rel, "line": lineno, "kind": f"{label} credential",
"evidence": m.group(0)[:12] + "…",
})
# 2. trường nhạy cảm có giá trị
for m in PY_ASSIGN.finditer(line):
field, value = m.group(1), m.group(2)
if not SENSITIVE.search(field) or _is_placeholder(value):
continue
findings.append({
"file": rel, "line": lineno,
"kind": f"trường '{field}' có giá trị",
"evidence": value[:6] + "…" if len(value) > 6 else value,
})
return findings
def _self_test() -> int:
"""Một máy quét không tìm thấy gì chỉ có giá trị nếu chứng minh được nó
biết tìm. Cắm mẫu xấu và mẫu vô hại, xem có phân biệt đúng không."""
import tempfile
bad = {
"OpenAI": '"api_key": "sk-proj-abc123def456ghi789jkl012mno"', # casan: allow - mau thu cua chinh script
"GitHub": 'token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"', # casan: allow - mau thu cua chinh script
"AWS": 'aws = "AKIAIOSFODNN7EXAMPLE"', # casan: allow - mau thu cua chinh script
"Anthropic": '"api_key": "sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxx"', # casan: allow - mau thu cua chinh script
}
ok = {
"rỗng": '"api_key": ""',
"placeholder": '"api_key": "your-key-here"',
"ollama": '"api_key": "ollama"',
"test ngắn": 'api_key = "x"',
"hằng liệt kê": '"secret_in_output": "warning"',
}
global REPO
keep = REPO
passed = True
with tempfile.TemporaryDirectory() as tmp:
REPO = Path(tmp)
for label, line in {**bad, **ok}.items():
(REPO / "probe.py").write_text(line + "\n", encoding="utf-8")
found = bool(scan())
want = label in bad
mark = "OK " if found == want else "SAI"
if found != want:
passed = False
verb = "bắt được" if found else "bỏ qua"
print(f" [{mark}] {label:14} -> {verb}")
REPO = keep
print()
print("Tự kiểm: " + ("script phân biệt đúng." if passed
else "*** script phân biệt SAI ***"))
return 0 if passed else 1
def main() -> int:
ap = argparse.ArgumentParser(description="CASAN Check 1 — quét credential lộ")
ap.add_argument("--json", action="store_true", help="in kết quả dạng JSON")
ap.add_argument("--self-test", action="store_true",
help="cắm credential giả vào file tạm, kiểm script có bắt được")
args = ap.parse_args()
if args.self_test:
return _self_test()
findings = scan()
if args.json:
print(json.dumps(findings, ensure_ascii=False, indent=2))
else:
n_files = sum(1 for _ in _walk())
print(f"CASAN Check 1 — quét {n_files} file trong {REPO.name}/")
if not findings:
print("\n0 credential lưu plaintext. PASS.")
else:
print(f"\n*** {len(findings)} phát hiện ***\n")
for f in findings:
print(f" {f['file']}:{f['line']}")
print(f" {f['kind']} — {f['evidence']}")
return 1 if findings else 0
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -0,0 +1 @@
"""Test double dùng chung cho cả 3 team — không phụ thuộc Qt."""
+138
View File
@@ -0,0 +1,138 @@
"""Bản giả của ConfigRepository và SecretStore — chạy trong bộ nhớ.
Dùng để N2 (Giám sát) và N3 (Co4E) code và test ngay từ 21/08, không phải đợi
bản thật xong ngày 23/08 và 26/08.
Không chạm đĩa, không chạm keyring, không cần Qt. Test dùng nó chạy trong vài
mili giây.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict
class FakeSecretStore:
"""SecretStore trong bộ nhớ.
>>> s = FakeSecretStore({"provider:openai": "sk-test"})
>>> s.get("provider:openai")
'sk-test'
>>> s.get("provider:chua-co") is None
True
"""
def __init__(self, seed: Dict[str, str] | None = None):
self._items: Dict[str, str] = dict(seed or {})
def get(self, key: str) -> str | None:
return self._items.get(key)
def set(self, key: str, value: str) -> None:
self._items[key] = value
def delete(self, key: str) -> None:
self._items.pop(key, None)
def has(self, key: str) -> bool:
return key in self._items
class FakeConfigRepository:
"""ConfigRepository trong bộ nhớ, có sẵn giá trị mặc định hợp lý.
Mọi thứ ghi đè được qua tham số khởi tạo, nên test dựng đúng tình huống
mình cần::
cfg = FakeConfigRepository(theme="light", shared_dir="/tmp/chung")
"""
def __init__(self, *, active_provider: str = "ollama",
providers: Dict[str, Dict[str, Any]] | None = None,
shared_dir: str = "", theme: str = "dark", language: str = "vi",
routing: Dict[str, Any] | None = None,
auth: Dict[str, Any] | None = None,
agent_security: Dict[str, Any] | None = None,
tools_disabled: list[str] | None = None,
history_dir: Path | None = None,
output_dir: Path | None = None):
self._active_provider = active_provider
self._providers = providers or {
"ollama": {"base_url": "http://localhost:11434/v1", "model": "llama3"},
"openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini"},
}
self._shared_dir = shared_dir
self._theme = theme
self._language = language
self._routing = routing or {"mode": "off"}
self._auth = auth or {}
self._agent_security = agent_security or {"cowork_confirm_commands": True}
self._tools_disabled = list(tools_disabled or [])
self._history_dir = history_dir or Path("/fake/history")
self._output_dir = output_dir or Path("/fake/workspace")
#: số lần save() được gọi — để test khẳng định "có ghi" mà không cần đĩa
self.saves = 0
# ---- provider ------------------------------------------------------
@property
def active_provider(self) -> str:
return self._active_provider
def set_active_provider(self, name: str) -> None:
self._active_provider = name
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
return dict(self._providers.get(name or self._active_provider, {}))
# ---- đường dẫn -----------------------------------------------------
@property
def shared_dir(self) -> str:
return self._shared_dir
def history_dir(self) -> Path:
return self._history_dir
def cowork_output_dir(self) -> Path:
return self._output_dir
# ---- giao diện -----------------------------------------------------
@property
def theme(self) -> str:
return self._theme
def set_theme(self, value: str) -> None:
self._theme = value
@property
def language(self) -> str:
return self._language
def set_language(self, value: str) -> None:
self._language = value
# ---- nhóm cấu hình --------------------------------------------------
@property
def routing(self) -> Dict[str, Any]:
return self._routing
@property
def auth(self) -> Dict[str, Any]:
return self._auth
@property
def agent_security(self) -> Dict[str, Any]:
return self._agent_security
@property
def tools_disabled(self) -> list[str]:
return list(self._tools_disabled)
def set_tool_enabled(self, name: str, enabled: bool) -> None:
if enabled:
self._tools_disabled = [t for t in self._tools_disabled if t != name]
elif name not in self._tools_disabled:
self._tools_disabled.append(name)
# ---- ghi ------------------------------------------------------------
def save(self) -> None:
self.saves += 1
+81
View File
@@ -0,0 +1,81 @@
"""Hợp đồng của mục chung có thật sự gỡ chốt cho N2 và N3 không.
Đây là bài nghiệm thu, không phải test cho vui: nếu ba bài dưới đây xanh thì
hai nhánh kia code được ngay hôm nay mà không cần chờ ``ConfigRepository`` hay
``KeyringAdapter`` bản thật.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from cowork_local.infrastructure.config.config_repository import ConfigRepository
from cowork_local.infrastructure.secrets.secret_store import SecretStore, provider_key
from cowork_local.tests.fakes.fake_config import FakeConfigRepository, FakeSecretStore
REPO_PARENT = Path(__file__).resolve().parents[2]
def test_fake_config_khop_hop_dong():
"""Fake phải cài đủ interface — thiếu một hàm là hai nhánh kia gọi vào sẽ vỡ."""
assert isinstance(FakeConfigRepository(), ConfigRepository)
def test_fake_secret_store_khop_hop_dong():
assert isinstance(FakeSecretStore(), SecretStore)
def test_secret_store_thieu_key_thi_tra_none_chu_khong_nem_loi():
"""Thiếu API key là chuyện thường (người dùng chưa nhập), không phải sự cố."""
store = FakeSecretStore()
assert store.get(provider_key("openai")) is None
assert store.has(provider_key("openai")) is False
store.delete(provider_key("openai")) # xoá cái không có: im lặng
store.set(provider_key("openai"), "sk-test")
assert store.get(provider_key("openai")) == "sk-test"
assert store.has(provider_key("openai")) is True
def test_config_gia_ghi_nhan_save_ma_khong_cham_dia():
cfg = FakeConfigRepository(theme="light")
assert cfg.theme == "light"
cfg.set_theme("dark")
cfg.save()
assert cfg.theme == "dark"
assert cfg.saves == 1
def test_bat_duoc_tool_bi_tat():
cfg = FakeConfigRepository(tools_disabled=["run_command"])
assert cfg.tools_disabled == ["run_command"]
cfg.set_tool_enabled("run_command", True)
assert cfg.tools_disabled == []
cfg.set_tool_enabled("write_file", False)
assert cfg.tools_disabled == ["write_file"]
def test_dung_duoc_fake_ma_khong_hề_nap_config_that():
"""Bài nghiệm thu chính của mục chung.
N2 và N3 phải dựng được màn hình và chạy test của mình mà KHÔNG kéo theo
``cowork_local.config`` — module nặng, đọc đĩa, và đang bị N1 viết lại.
Kiểm bằng tiến trình riêng để không dính module đã nạp sẵn ở test khác.
"""
snippet = (
"import sys\n"
"from cowork_local.tests.fakes.fake_config import "
"FakeConfigRepository, FakeSecretStore\n"
"cfg = FakeConfigRepository(active_provider='openai')\n"
"assert cfg.provider_conf()['model'] == 'gpt-4o-mini'\n"
"assert FakeSecretStore().get('x') is None\n"
"assert 'cowork_local.config' not in sys.modules, "
"'fake keo theo config that -> van con phu thuoc'\n"
"assert 'PySide6' not in sys.modules, 'fake keo theo Qt -> test se cham'\n"
"print('OK')\n"
)
out = subprocess.run([sys.executable, "-c", snippet], cwd=REPO_PARENT,
capture_output=True, text=True, timeout=60)
assert out.returncode == 0, out.stderr
assert "OK" in out.stdout