Compare commits
11
Commits
main
...
ca7ea1479d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca7ea1479d | ||
|
|
d74c052af3 | ||
|
|
8be5ce1bab | ||
|
|
ab0d26761f | ||
|
|
a7e369e46c | ||
|
|
d6dd6a030e | ||
|
|
2627e691ce | ||
|
|
3138856741 | ||
|
|
a164f32bfb | ||
|
|
8a9ee5f875 | ||
|
|
09b1c93624 |
@@ -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
|
||||
|
||||
+11
-5
@@ -28,7 +28,10 @@ bower_components/
|
||||
.env.preview
|
||||
*.pem
|
||||
*.key
|
||||
secrets/
|
||||
# Neo vào gốc repo: mẫu không neo nuốt MỌI thư mục tên secrets ở mọi độ
|
||||
# sâu — nó đã âm thầm chặn infrastructure/secrets/ (mã nguồn, không phải
|
||||
# bí mật) khỏi repo suốt 21-22/08.
|
||||
/secrets/
|
||||
credentials.json
|
||||
.npmrc
|
||||
.yarnrc
|
||||
@@ -36,9 +39,11 @@ credentials.json
|
||||
# =============================================================================
|
||||
# Build & Distribution
|
||||
# =============================================================================
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
# Neo vao goc — mau khong neo se nuot moi thu muc trung ten o moi do sau,
|
||||
# ke ca ma nguon. Da mac dung loi do voi secrets/ (xem khoi Credentials).
|
||||
/dist/
|
||||
/build/
|
||||
/out/
|
||||
.next/
|
||||
.nuxt/
|
||||
.output/
|
||||
@@ -73,7 +78,8 @@ desktop.ini
|
||||
# Logs & Debug
|
||||
# =============================================================================
|
||||
*.log
|
||||
logs/
|
||||
# Neo vao goc: infrastructure/logs/ la ma nguon, khong phai log chay may.
|
||||
/logs/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""adapters/ — Adapter riêng cho Qt (clock, thread, timer).
|
||||
|
||||
Kế hoạch gốc đặt tên thư mục này là ``platform/``. Không dùng được: chạy
|
||||
bất kỳ script nào từ thư mục gốc repo (``python tools/...``,
|
||||
``python scripts/...``) thì ``platform/`` **che khuất module ``platform``
|
||||
của thư viện chuẩn**, và ``import keyring`` chết ngay với
|
||||
``AttributeError: module 'platform' has no attribute 'system'``.
|
||||
Repo có 26 script chạy đúng kiểu đó.
|
||||
|
||||
Đổi tên là cách duy nhất chắc chắn — không thể bắt mọi người nhớ "đừng bao
|
||||
giờ chạy python từ thư mục gốc".
|
||||
"""
|
||||
@@ -0,0 +1 @@
|
||||
"""application/ — Điều phối use-case. KHÔNG import PySide6. Gọi domain + interface hạ tầng."""
|
||||
@@ -0,0 +1,159 @@
|
||||
# Mô hình chính sách an toàn — CoworkLocal
|
||||
|
||||
R09-T01 · Team Gamma · viết 22/08/2026
|
||||
|
||||
Tài liệu này mô tả **hệ thống đang chạy**, không phải hệ thống mong muốn. Mọi
|
||||
khẳng định đều chỉ tới file và dòng cụ thể để đối chiếu được.
|
||||
|
||||
---
|
||||
|
||||
## 1. Câu hỏi quan trọng nhất: đây có phải rào chắn an ninh không
|
||||
|
||||
**Không.** `core/agent_security.py` nói thẳng ngay ở đầu file:
|
||||
|
||||
> *"this is a business productivity tool, not a hard security boundary"*
|
||||
|
||||
Điều đó quyết định mọi thứ còn lại. Cụ thể: **mọi tầng dùng AI đều mở khi
|
||||
hỏng** (`allowed=True` khi không gọi được validator, `core/agent_security.py:150`).
|
||||
Mạng chập chờn hay gateway trục trặc thì agent vẫn chạy, không bị khoá cứng.
|
||||
|
||||
Đánh đổi có chủ đích: chọn *dùng được* thay vì *chặn tuyệt đối*. Ai đọc tài
|
||||
liệu này để đánh giá rủi ro cần hiểu đúng điều đó — đây là lớp giảm tai nạn,
|
||||
không phải lớp chống kẻ tấn công có chủ đích.
|
||||
|
||||
---
|
||||
|
||||
## 2. Hai loại quy tắc, đừng lẫn
|
||||
|
||||
| | Quy tắc xác định | Quy tắc do AI phán |
|
||||
|---|---|---|
|
||||
| Cách hoạt động | So khớp mẫu cố định | Hỏi một model |
|
||||
| Kết quả | Luôn giống nhau | Có thể khác nhau giữa hai lần |
|
||||
| Khi hỏng | Vẫn chạy | **Mở** (cho qua) |
|
||||
| Tắt được không | Không — luôn bật | Có, từng tầng một |
|
||||
| Ở đâu | Bộ phân loại mẫu chặn + sandbox | 3 tầng validate |
|
||||
|
||||
Câu ở `core/agent_security.py:250` nói rõ ranh giới:
|
||||
|
||||
> *"always-on block-pattern classifier + sandbox still apply regardless"*
|
||||
|
||||
Nghĩa là **tắt hết ba tầng AI thì vẫn còn hai lớp xác định**. Đây là điểm dễ
|
||||
hiểu nhầm nhất khi đọc màn Cài đặt: mấy công tắc ở đó **chỉ tắt phần AI**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Ba tầng AI
|
||||
|
||||
Bật/tắt độc lập trong `agent_security` của `config.json`.
|
||||
|
||||
| Tầng | Kiểm cái gì | Khoá cấu hình | Khi nào chạy |
|
||||
|---|---|---|---|
|
||||
| Prompt | Yêu cầu của chính người dùng | `validate_prompt` | Trước khi agent làm gì |
|
||||
| Attachment | Văn bản trích ra từ tệp đính kèm | `validate_attachments` | Trước khi vào ngữ cảnh model |
|
||||
| Command | `run_command` / `install_package` | `validate_commands` | Trước khi thực thi |
|
||||
|
||||
Cả ba đọc chung một bộ luật: file cục bộ `core/security_rules.py` cộng thêm
|
||||
tài liệu quản trị viên đặt trên OneDrive (nếu có cấu hình). Riêng agent Code
|
||||
dùng bộ luật khác — `RULEforCode.md` thay vì `RULEBASE.md`.
|
||||
|
||||
Công tắc tổng `agent_security.enabled` tắt cả ba.
|
||||
|
||||
---
|
||||
|
||||
## 4. Chuyện gì xảy ra khi bị chặn
|
||||
|
||||
Theo đúng thứ tự trong `core/agent_security.py:266-273`:
|
||||
|
||||
1. Hiện thông báo trong khung chat — người dùng thấy ngay, kèm lý do
|
||||
2. Ghi `audit_log.record("security_block", …)` — vào nhật ký kiểm toán
|
||||
3. `notify_admin(...)` — gửi email quản trị viên
|
||||
4. Ném `SecurityBlocked` — dừng lượt chạy
|
||||
|
||||
Ba bước đầu **không được phép ném lỗi**. `audit_log.record()` có ghi rõ trong
|
||||
docstring: *"never raises — audit logging must never break a chat turn"*. Ghi
|
||||
nhật ký hỏng không được kéo theo cả phiên làm việc.
|
||||
|
||||
---
|
||||
|
||||
## 5. Hỏi người dùng: trạng thái thứ ba
|
||||
|
||||
Ngoài cho/chặn còn một trạng thái nữa mà hệ thống hiện tại **có nhưng chưa gọi
|
||||
tên**: hỏi người dùng.
|
||||
|
||||
`ui/chat_panel.py:1312` kiểm `ctx.project_confirm_commands()` rồi bật
|
||||
`PermissionDialog`. Đó là một quyết định chính sách thật, nhưng nằm rải ở tầng
|
||||
giao diện chứ không phải một kết quả chính thức.
|
||||
|
||||
`domain/security/tool_policy.py` (đề xuất, chờ Team Hoa xác nhận) gộp lại
|
||||
thành ba trạng thái:
|
||||
|
||||
| | Nghĩa |
|
||||
|---|---|
|
||||
| `ALLOW` | Chạy |
|
||||
| `DENY` | Không chạy, có lý do |
|
||||
| `ASK` | Hỏi người dùng đã |
|
||||
|
||||
**`ASK` không phải là `allowed`.** Coi ASK như ALLOW nghĩa là tool chạy trước
|
||||
khi có ai đồng ý — bẫy dễ mắc nhất, đã có test riêng chặn.
|
||||
|
||||
Cổng chính sách **không tự bật hộp thoại**. Nó chỉ trả lời; hỏi ai và hỏi thế
|
||||
nào là việc của tầng giao diện. Nhờ vậy Co4E chạy nền mới dùng chung cổng được
|
||||
với Cowork chạy tương tác — Co4E không hỏi được thì đổi `ASK` thành `DENY`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Bí mật
|
||||
|
||||
Từ 21/08 (R02-T05), API key **không còn nằm trong `config.json`**:
|
||||
|
||||
* Lưu trong kho của hệ điều hành qua `KeyringAdapter` — Windows Credential
|
||||
Manager, macOS Keychain, Linux Secret Service
|
||||
* `provider_conf()` đọc từ kho rồi ghép vào dict trả về, nên chỗ gọi không
|
||||
đổi (đường A, `GammaTeam_decisions.md`)
|
||||
* File cũ tự chuyển ở lần mở đầu tiên, có sao lưu trước khi chuyển
|
||||
|
||||
Máy không có kho bí mật (Linux headless, CI) thì **không chuyển** — thà để
|
||||
khoá trong file còn hơn xoá đi rồi người dùng mất khoá.
|
||||
|
||||
Kiểm bằng `python scripts/audit_security.py`, chạy tự động trong CI.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sandbox
|
||||
|
||||
`core/sandbox_manager.py` chạy lệnh trong môi trường hạn chế. Luôn bật, không
|
||||
tắt được, không phụ thuộc công tắc AI nào.
|
||||
|
||||
Năng lực khác nhau theo hệ điều hành — ma trận đầy đủ sẽ nằm ở
|
||||
`infrastructure/sandbox/sandbox_capabilities.py` (R09-T06, Hiệp phụ trách).
|
||||
Chỗ này cập nhật khi task đó xong.
|
||||
|
||||
---
|
||||
|
||||
## 8. Những chỗ đã biết là yếu
|
||||
|
||||
Ghi ra để người sau khỏi tưởng đã kín:
|
||||
|
||||
1. **Mở khi hỏng.** Gateway chết là ba tầng AI cho qua hết. Có chủ đích, nhưng
|
||||
nghĩa là không chống được kẻ tấn công biết cách làm validator ngừng trả lời.
|
||||
2. **Bí mật vẫn đi trong bộ nhớ.** Đường A ghép khoá vào dict `provider_conf()`
|
||||
trả về, nên khoá vẫn có thể lọt vào log gỡ lỗi hay ảnh chụp màn hình. Đường
|
||||
B (bỏ hẳn khỏi dict) đã ghi vào nợ kỹ thuật.
|
||||
3. **Bộ luật lấy từ OneDrive không ký số.** Ai sửa được tài liệu đó là sửa được
|
||||
luật.
|
||||
4. **`ASK` chưa được nối vào Co4E.** Co4E chạy nền, chưa có đường hỏi người
|
||||
dùng — hiện phải chọn giữa cho qua hết hoặc chặn hết.
|
||||
|
||||
---
|
||||
|
||||
## Đối chiếu nhanh
|
||||
|
||||
| Nội dung | Nguồn |
|
||||
|---|---|
|
||||
| Ba tầng AI, mở khi hỏng | `core/agent_security.py:1-25` |
|
||||
| Phân loại mẫu + sandbox luôn bật | `core/agent_security.py:250` |
|
||||
| Thứ tự khi bị chặn | `core/agent_security.py:266-273` |
|
||||
| Nhật ký không được ném lỗi | `core/audit_log.py:46` |
|
||||
| Hỏi người dùng | `ui/chat_panel.py:1312` |
|
||||
| Ba trạng thái chính sách | `domain/security/tool_policy.py` |
|
||||
| Bí mật | `infrastructure/secrets/keyring_adapter.py` |
|
||||
@@ -0,0 +1,768 @@
|
||||
<!doctype html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Phân Việc Refactor Team Gamma</title>
|
||||
</head>
|
||||
<body>
|
||||
<style>
|
||||
:root {
|
||||
--ground: #FAF9FC; --surface: #FFFFFF; --surface-2: #F3F0F8;
|
||||
--ink: #191325; --muted: #665E7C; --line: #E4DEEE;
|
||||
--accent: #6D3A9E;
|
||||
--lead: #6D3A9E; --m1: #14707F; --m2: #A65418;
|
||||
--warn: #A81F1A; --ok: #1B6B40;
|
||||
--lead-wash: #F1E9F9; --m1-wash: #E2F1F3; --m2-wash: #F8EDE2;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--ground: #121020; --surface: #1B1830; --surface-2: #241F3D;
|
||||
--ink: #EFEBF7; --muted: #A69EBD; --line: #2F2848;
|
||||
--accent: #C08CF0;
|
||||
--lead: #C08CF0; --m1: #56C6D8; --m2: #E29A56;
|
||||
--warn: #F08A84; --ok: #6FD69C;
|
||||
--lead-wash: #2A1F42; --m1-wash: #133038; --m2-wash: #38270F;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--ground: #121020; --surface: #1B1830; --surface-2: #241F3D;
|
||||
--ink: #EFEBF7; --muted: #A69EBD; --line: #2F2848;
|
||||
--accent: #C08CF0;
|
||||
--lead: #C08CF0; --m1: #56C6D8; --m2: #E29A56;
|
||||
--warn: #F08A84; --ok: #6FD69C;
|
||||
--lead-wash: #2A1F42; --m1-wash: #133038; --m2-wash: #38270F;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; background: var(--ground); color: var(--ink);
|
||||
font-family: "Segoe UI", -apple-system, system-ui, "Helvetica Neue", sans-serif;
|
||||
font-size: 15px; line-height: 1.62; -webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.wrap { max-width: 1120px; margin: 0 auto; padding: 0 28px 96px; }
|
||||
code, .mono, td.day, th.day, .num {
|
||||
font-family: Consolas, "Cascadia Mono", "SF Mono", ui-monospace, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
header.top { border-bottom: 2px solid var(--ink); padding: 56px 0 22px; margin-bottom: 34px; }
|
||||
.eyebrow { font-size: 12px; letter-spacing: .16em; text-transform: uppercase;
|
||||
color: var(--accent); font-weight: 700; margin: 0 0 14px; }
|
||||
h1 { font-size: clamp(30px, 4.4vw, 44px); line-height: 1.1; margin: 0 0 16px;
|
||||
font-weight: 700; letter-spacing: -.02em; text-wrap: balance; }
|
||||
.lede { font-size: 17px; color: var(--muted); margin: 0; max-width: 64ch; }
|
||||
.alias { margin: 18px 0 0; padding: 12px 16px; max-width: 74ch;
|
||||
background: var(--surface-2); border-left: 3px solid var(--accent);
|
||||
border-radius: 3px; font-size: 14px; color: var(--muted); }
|
||||
.alias b { color: var(--ink); }
|
||||
.facts { display: flex; flex-wrap: wrap; gap: 28px; margin-top: 26px;
|
||||
padding-top: 20px; border-top: 1px solid var(--line); }
|
||||
.fact .k { font-size: 11px; letter-spacing: .13em; text-transform: uppercase;
|
||||
color: var(--muted); display: block; margin-bottom: 3px; }
|
||||
.fact .v { font-size: 15px; font-weight: 600; }
|
||||
|
||||
h2 { font-size: 23px; margin: 52px 0 6px; letter-spacing: -.01em; font-weight: 700; text-wrap: balance; }
|
||||
h2 + .sub { color: var(--muted); margin: 0 0 22px; max-width: 70ch; }
|
||||
h3 { font-size: 17px; margin: 30px 0 10px; font-weight: 700; }
|
||||
|
||||
/* gate = việc phải xong trước khi chia nhánh */
|
||||
.gatebox { background: var(--surface); border: 1px solid var(--line);
|
||||
border-left: 4px solid var(--warn); border-radius: 3px; padding: 4px 26px 22px; }
|
||||
.gatebox h2 { margin-top: 22px; }
|
||||
|
||||
.steps { list-style: none; counter-reset: s; padding: 0; margin: 0; }
|
||||
.steps > li { counter-increment: s; position: relative; padding: 14px 0 14px 46px;
|
||||
border-bottom: 1px solid var(--line); }
|
||||
.steps > li:last-child { border-bottom: none; }
|
||||
.steps > li::before {
|
||||
content: counter(s); position: absolute; left: 0; top: 14px;
|
||||
width: 26px; height: 26px; border-radius: 50%; background: var(--accent);
|
||||
color: #fff; font-size: 13px; font-weight: 700; display: flex;
|
||||
align-items: center; justify-content: center;
|
||||
font-family: Consolas, ui-monospace, monospace;
|
||||
}
|
||||
.steps b { display: block; margin-bottom: 2px; }
|
||||
.steps small { color: var(--muted); font-size: 13.5px; display: block; }
|
||||
.est { float: right; font-size: 12px; color: var(--muted); font-weight: 600;
|
||||
font-family: Consolas, ui-monospace, monospace; }
|
||||
|
||||
.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
|
||||
@media (max-width: 940px) { .cards { grid-template-columns: 1fr; } }
|
||||
.card { background: var(--surface); border: 1px solid var(--line);
|
||||
border-top: 3px solid var(--c); border-radius: 3px; padding: 20px;
|
||||
display: flex; flex-direction: column; }
|
||||
.card.lead { --c: var(--lead); --w: var(--lead-wash); }
|
||||
.card.one { --c: var(--m1); --w: var(--m1-wash); }
|
||||
.card.two { --c: var(--m2); --w: var(--m2-wash); }
|
||||
.card .tag { font-size: 11px; letter-spacing: .13em; text-transform: uppercase;
|
||||
font-weight: 700; color: var(--c); margin-bottom: 6px; }
|
||||
.card h3 { margin: 0 0 4px; font-size: 18px; }
|
||||
.card .who { font-size: 13px; color: var(--muted); margin-bottom: 14px; }
|
||||
.card .branch { font-size: 12.5px; background: var(--w); color: var(--c);
|
||||
padding: 5px 9px; border-radius: 3px; display: inline-block;
|
||||
margin-bottom: 16px; word-break: break-all; font-weight: 600; }
|
||||
.card h4 { font-size: 11px; letter-spacing: .12em; text-transform: uppercase;
|
||||
color: var(--muted); margin: 16px 0 7px; font-weight: 700; }
|
||||
.card ul { margin: 0; padding-left: 17px; font-size: 14px; }
|
||||
.card li { margin-bottom: 6px; }
|
||||
.tid { font-size: 12px; font-weight: 700; color: var(--c);
|
||||
font-family: Consolas, ui-monospace, monospace; }
|
||||
.paths { list-style: none; padding: 0; margin: 0; font-size: 12.5px; }
|
||||
.paths li { padding: 3px 0; border-bottom: 1px dotted var(--line);
|
||||
font-family: Consolas, ui-monospace, monospace; color: var(--muted); word-break: break-all; }
|
||||
.paths li:last-child { border-bottom: none; }
|
||||
.weight { margin-top: auto; padding-top: 16px; font-size: 12.5px; color: var(--muted); }
|
||||
.weight b { color: var(--ink); font-size: 15px; }
|
||||
|
||||
.scroll { overflow-x: auto; border: 1px solid var(--line); border-radius: 3px; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 13.5px; background: var(--surface); }
|
||||
th, td { text-align: left; padding: 11px 14px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||
thead th { background: var(--surface-2); font-size: 11px; letter-spacing: .1em;
|
||||
text-transform: uppercase; color: var(--muted); font-weight: 700; white-space: nowrap; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
td.day, th.day { white-space: nowrap; font-weight: 700; font-size: 13px; }
|
||||
td.cl { border-left: 3px solid var(--lead); }
|
||||
td.c1 { border-left: 3px solid var(--m1); }
|
||||
td.c2 { border-left: 3px solid var(--m2); }
|
||||
tr.mark td { background: var(--surface-2); font-weight: 600; }
|
||||
td small { color: var(--muted); display: block; font-size: 12.5px; }
|
||||
.pill { display: inline-block; font-size: 11px; font-weight: 700; padding: 2px 7px;
|
||||
border-radius: 2px; letter-spacing: .04em; white-space: nowrap; }
|
||||
.pill.cp { background: var(--m1-wash); color: var(--m1); }
|
||||
.pill.gate { background: var(--m2-wash); color: var(--m2); }
|
||||
.pill.ship { background: var(--lead-wash); color: var(--lead); }
|
||||
|
||||
.rules { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
||||
@media (max-width: 760px) { .rules { grid-template-columns: 1fr; } }
|
||||
.rule { background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: 3px; padding: 18px 20px; border-left: 3px solid var(--c, var(--line)); }
|
||||
.rule.hard { --c: var(--warn); }
|
||||
.rule.soft { --c: var(--ok); }
|
||||
.rule h3 { margin: 0 0 8px; font-size: 15px; }
|
||||
.rule p { margin: 0; font-size: 14px; color: var(--muted); }
|
||||
.rule code { color: var(--ink); }
|
||||
|
||||
|
||||
/* tóm tắt: đọc 30 giây là nắm được, trước khi vào chi tiết */
|
||||
.tldr {
|
||||
display: grid; grid-template-columns: 1.35fr 1fr; gap: 0;
|
||||
border: 1px solid var(--line); border-radius: 3px; overflow: hidden;
|
||||
margin-bottom: 8px; background: var(--surface);
|
||||
}
|
||||
@media (max-width: 820px) { .tldr { grid-template-columns: 1fr; } }
|
||||
.tldr > div { padding: 20px 24px; }
|
||||
.tldr .right { background: var(--surface-2); border-left: 1px solid var(--line); }
|
||||
@media (max-width: 820px) { .tldr .right { border-left: none; border-top: 1px solid var(--line); } }
|
||||
.tldr .cap {
|
||||
font-size: 11px; letter-spacing: .14em; text-transform: uppercase;
|
||||
color: var(--muted); font-weight: 700; margin: 0 0 12px;
|
||||
}
|
||||
.flow { list-style: none; padding: 0; margin: 0; font-size: 14px; }
|
||||
.flow li { padding: 7px 0; border-bottom: 1px dotted var(--line); display: flex; gap: 10px; }
|
||||
.flow li:last-child { border-bottom: none; }
|
||||
.flow .b {
|
||||
flex: 0 0 auto; font-size: 11.5px; font-weight: 700; padding: 1px 7px; border-radius: 2px;
|
||||
background: var(--w2); color: var(--c2); height: fit-content; margin-top: 2px;
|
||||
font-family: Consolas, ui-monospace, monospace;
|
||||
}
|
||||
.flow li.f0 { --c2: var(--warn); --w2: var(--surface-2); }
|
||||
.flow li.f1 { --c2: var(--lead); --w2: var(--lead-wash); }
|
||||
.flow li.f2 { --c2: var(--m1); --w2: var(--m1-wash); }
|
||||
.flow li.f3 { --c2: var(--m2); --w2: var(--m2-wash); }
|
||||
.flow .t { flex: 1; }
|
||||
.flow .t b { display: block; }
|
||||
.flow .t small { color: var(--muted); font-size: 12.5px; }
|
||||
.must { margin: 0; padding-left: 18px; font-size: 14px; }
|
||||
.must li { margin-bottom: 8px; }
|
||||
.must li:last-child { margin-bottom: 0; }
|
||||
.must b { color: var(--ink); }
|
||||
|
||||
/* input / output từng người */
|
||||
.io { display: grid; gap: 18px; }
|
||||
.iorow { background: var(--surface); border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--c); border-radius: 3px; overflow: hidden; }
|
||||
.iorow.n1 { --c: var(--lead); --w: var(--lead-wash); }
|
||||
.iorow.n2 { --c: var(--m1); --w: var(--m1-wash); }
|
||||
.iorow.n3 { --c: var(--m2); --w: var(--m2-wash); }
|
||||
.iohead { padding: 14px 20px; background: var(--w); display: flex;
|
||||
align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||
.iohead b { color: var(--c); font-size: 15px; }
|
||||
.iohead span { color: var(--muted); font-size: 13px; }
|
||||
.iogrid { display: grid; grid-template-columns: 1fr 1fr; }
|
||||
@media (max-width: 860px) { .iogrid { grid-template-columns: 1fr; } }
|
||||
.iogrid > div { padding: 16px 20px; }
|
||||
.iogrid > div + div { border-left: 1px solid var(--line); }
|
||||
@media (max-width: 860px) {
|
||||
.iogrid > div + div { border-left: none; border-top: 1px solid var(--line); }
|
||||
}
|
||||
.iocap { font-size: 11px; letter-spacing: .13em; text-transform: uppercase;
|
||||
color: var(--muted); font-weight: 700; margin: 0 0 10px; }
|
||||
.iolist { list-style: none; margin: 0; padding: 0; font-size: 13.5px; }
|
||||
.iolist li { padding: 5px 0; border-bottom: 1px dotted var(--line); }
|
||||
.iolist li:last-child { border-bottom: none; }
|
||||
.iolist code { font-size: 12.5px; }
|
||||
.frm { display: inline-block; font-size: 11px; font-weight: 700; padding: 1px 6px;
|
||||
border-radius: 2px; background: var(--surface-2); color: var(--muted);
|
||||
margin-right: 6px; font-family: Consolas, ui-monospace, monospace; }
|
||||
.frm.done { background: var(--m1-wash); color: var(--m1); }
|
||||
.frm.risk { background: var(--m2-wash); color: var(--m2); }
|
||||
|
||||
footer { margin-top: 64px; padding-top: 20px; border-top: 1px solid var(--line);
|
||||
font-size: 13px; color: var(--muted); }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<header class="top">
|
||||
<p class="eyebrow">Team Gamma · Automation, Workflows & Governance</p>
|
||||
<h1>Một nhánh chung, ba làn không đụng nhau</h1>
|
||||
<p class="lede">
|
||||
Toàn bộ phần việc refactor 10 ngày của Team Gamma — Nam, Hiệp, Lâm. Cả ba đẩy chung vào <code>gamma/refactor</code>. Nam làm thêm một mục chung —
|
||||
khung kiến trúc, hợp đồng dữ liệu, cổng kiểm duyệt — nằm ngoài ba nhánh; xong mục đó thì
|
||||
ba người vào ba nhánh tính năng ngang nhau, không ai phải sửa chung file với ai.
|
||||
</p>
|
||||
<p class="alias">
|
||||
Ba tài liệu refactor gọi team này là <b>“Team Nam”</b> (theo tên lead). Cùng một team, cùng
|
||||
phạm vi R02 · R08 · R09 · R07-T06. Nhánh của team dùng tiền tố <code>gamma/</code>; ba tài liệu refactor viết
|
||||
<code>nam/workflow-governance-*</code> theo tên lead — cùng một thứ.
|
||||
</p>
|
||||
<div class="facts">
|
||||
<div class="fact"><span class="k">Thời hạn</span><span class="v mono">21/08 → 31/08</span></div>
|
||||
<div class="fact"><span class="k">Người</span><span class="v mono">Nam · Hiệp · Lâm</span></div>
|
||||
<div class="fact"><span class="k">Nhánh</span><span class="v mono">gamma/refactor</span></div>
|
||||
<div class="fact"><span class="k">Code phải bóc</span><span class="v mono">~6.500 dòng</span></div>
|
||||
<div class="fact"><span class="k">Cổng phải qua</span><span class="v mono">CASAN Check 1</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
<section class="tldr">
|
||||
<div>
|
||||
<p class="cap">Tóm tắt · thứ tự làm</p>
|
||||
<ul class="flow">
|
||||
<li class="f0">
|
||||
<span class="b">CHUNG</span>
|
||||
<span class="t"><b>Nam làm trước, nửa ngày</b>
|
||||
<small>Dựng khung 5 thư mục (đang là 0 file) · interface + fake cho Config/Secrets ·
|
||||
chốt <code>api_key</code> và báo Team Duy · script CASAN Check 1 · đưa 3 check vào CI ·
|
||||
quyết số phận 24 checker UI. Merge xong mới chia nhánh.</small></span>
|
||||
</li>
|
||||
<li class="f1">
|
||||
<span class="b">N1</span>
|
||||
<span class="t"><b>N1 — Nam · Cấu hình, Bí mật, Vỏ ứng dụng</b>
|
||||
<small>R02 (6 task) · settings 4 widget · bootstrap + MainWindow · policy doc.
|
||||
Giữ luôn <code>app.py</code>, <code>config.py</code>, <code>theme.py</code>,
|
||||
<code>i18n.py</code>. ~2.700 dòng.</small></span>
|
||||
</li>
|
||||
<li class="f2">
|
||||
<span class="b">N2</span>
|
||||
<span class="t"><b>N2 — Hiệp · Giám sát</b>
|
||||
<small>7 tab Monitoring · CanonicalAuditLogger · MonitoringQueryService ·
|
||||
2 vòng lặp import · ma trận Sandbox. ~2.650 dòng.</small></span>
|
||||
</li>
|
||||
<li class="f3">
|
||||
<span class="b">N3</span>
|
||||
<span class="t"><b>N3 — Lâm · Co4E Studio</b>
|
||||
<small>Co4EWorkflowService · tách <code>co4e_tab.py</code> + <code>co4e_canvas.py</code>
|
||||
thành 5 phần. ~2.880 dòng, file to nhất team.</small></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="right">
|
||||
<p class="cap">Ba điều bắt buộc</p>
|
||||
<ol class="must">
|
||||
<li><b>Không chạm file dùng chung.</b> Cần thêm chuỗi hay màu thì nhắn nhóm trưởng, đừng tự sửa.</li>
|
||||
<li><b>Nộp factory, không tự lắp vào <code>app.py</code>.</b> N1 lắp trong <code>bootstrap.py</code> ngày 28/08.</li>
|
||||
<li><b>Bị chặn thì dùng fake, báo ngay trong ngày.</b> Không ngồi đợi ai.</li>
|
||||
</ol>
|
||||
<p class="cap" style="margin-top:20px">Nghiệm thu</p>
|
||||
<p style="margin:0;font-size:14px;color:var(--muted)">
|
||||
Trên <code>gamma/refactor</code>: không file nào được sửa bởi hai người khác nhau.
|
||||
Có là quy ước <b style="color:var(--ink)">số 1</b> đang bị vi phạm.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="gatebox">
|
||||
<h2>Mục chung — Nam làm, xong hai người kia mới bắt đầu</h2>
|
||||
<p class="sub">
|
||||
Sáu việc dưới đây không thuộc làn nào — chúng là thứ cả ba người cùng đụng
|
||||
vào. Nam làm một lần và đẩy lên <code>gamma/refactor</code>, rồi hai người kia
|
||||
mới bắt đầu. Ước tính nửa ngày.
|
||||
</p>
|
||||
<ol class="steps">
|
||||
<li>
|
||||
<span class="est">~30 phút</span>
|
||||
<b>Dựng khung thư mục</b>
|
||||
<small>
|
||||
<code>domain/</code> <code>application/</code> <code>infrastructure/</code>
|
||||
<code>presentation/</code> <code>platform/</code> <code>tests/fakes/</code> —
|
||||
hiện tại <b>chưa tồn tại, 0 file</b>. Mọi task của cả ba người đều ghi vào đây; để ba
|
||||
người tự tạo là đụng nhau ở <code>__init__.py</code> ngay ngày đầu.
|
||||
</small>
|
||||
</li>
|
||||
<li>
|
||||
<span class="est">~45 phút</span>
|
||||
<b>Viết interface + fake cho Config và Secrets</b>
|
||||
<small>
|
||||
<code>SecretStore</code>, <code>ConfigRepository</code>, kèm
|
||||
<code>FakeSecretStore</code> và <code>FakeConfigRepository</code>. Chỉ chữ ký, chưa cần
|
||||
thân hàm. Đây là thứ gỡ chốt cho cả hai người kia — <b>156 lời gọi
|
||||
<code>ctx.config.*</code> trong 29 file</b> đang chờ nó.
|
||||
</small>
|
||||
</li>
|
||||
<li>
|
||||
<span class="est">~20 phút</span>
|
||||
<b>Chốt số phận <code>api_key</code> và báo Team Duy</b>
|
||||
<small>
|
||||
<code>provider_conf()</code> còn trả <code>api_key</code> bên trong, hay tách hẳn sang
|
||||
<code>SecretStore</code>? Có 5 nơi đọc trực tiếp, <b>3 trong số đó nằm trong
|
||||
<code>providers/</code> của Team Duy</b>. Quyết một mình rồi im lặng là làm vỡ code
|
||||
team bạn.
|
||||
</small>
|
||||
</li>
|
||||
<li>
|
||||
<span class="est">~30 phút</span>
|
||||
<b>Viết <code>scripts/audit_security.py</code> (CASAN Check 1)</b>
|
||||
<small>
|
||||
Gamma chủ trì check này ngày 30/08. Viết ngay hôm nay thì lead tự kiểm được trong suốt
|
||||
quá trình chuyển API key, thay vì tới ngày cổng mới chạy lần đầu và phát hiện vấn đề.
|
||||
</small>
|
||||
</li>
|
||||
<li>
|
||||
<span class="est">~20 phút</span>
|
||||
<b>Thêm 3 check CASAN vào CI</b>
|
||||
<small>
|
||||
CI hiện chỉ chạy <code>pytest tests -q</code>. Ba check (secret · ≤400 dòng · import
|
||||
guard) không nằm trong CI, nên tới 30/08 mới biết ai vi phạm. Đưa vào CI thì mỗi PR tự
|
||||
báo.
|
||||
</small>
|
||||
</li>
|
||||
<li>
|
||||
<span class="est">~30 phút</span>
|
||||
<b>Quyết số phận 24 checker UI, rồi thông báo</b>
|
||||
<small>
|
||||
Chúng bám vào <code>cowork_local.config</code> (34 chỗ) và <code>cowork_local.app</code>
|
||||
(16 chỗ) — <b>sẽ chết ngay khi lead đụng <code>config.py</code></b>. Đây là lưới an toàn
|
||||
duy nhất cho phần UI vừa làm xong. Xem mục quy ước bên dưới.
|
||||
</small>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<h2>Ba làn</h2>
|
||||
<p class="sub">
|
||||
Ba làn ngang nhau, mỗi làn khoảng 2.700 dòng phải bóc tách, <b>cùng đẩy vào một
|
||||
nhánh</b> <code>gamma/refactor</code>. Nam nhận làn N1 vì đó là làn chạm tới file
|
||||
dùng chung nhiều nhất. Cột “sở hữu” là danh sách file <em>chỉ</em> người đó được
|
||||
sửa — trên nhánh chung, đây là thứ duy nhất giữ cho ba người không giẫm chân.
|
||||
</p>
|
||||
|
||||
<div class="cards">
|
||||
|
||||
<div class="card lead">
|
||||
<div class="tag">Làn N1 · Nam</div>
|
||||
<h3>Cấu hình, Bí mật & Vỏ ứng dụng</h3>
|
||||
<p class="who">Nam giữ — làn chạm nhiều file dùng chung nhất</p>
|
||||
<div class="branch">gamma/refactor</div>
|
||||
|
||||
<h4>Việc</h4>
|
||||
<ul>
|
||||
<li><span class="tid">R02-T01…T06</span> AtomicJsonFile · ConfigRepository · Typed Settings Facade · SecretStore + Keyring · chuyển API key · schema versioning</li>
|
||||
<li><span class="tid">R08-T07</span> tách <code>settings_dialog.py</code> → 4 section widget</li>
|
||||
<li><span class="tid">R08-T10</span> <code>bootstrap.py</code> + tách <code>MainWindow</code> → shell · tray · lifecycle <em>(cuối sprint, lắp factory của hai người kia)</em></li>
|
||||
<li><span class="tid">R09-T01</span> tài liệu Security Policy Model</li>
|
||||
<li>Chủ trì <b>CASAN Check 1</b> · giữ CI · duyệt PR của hai người</li>
|
||||
</ul>
|
||||
|
||||
<h4>Sở hữu độc quyền</h4>
|
||||
<ul class="paths">
|
||||
<li>config.py</li>
|
||||
<li>app.py → presentation/shell/</li>
|
||||
<li>bootstrap.py</li>
|
||||
<li>theme.py · i18n.py</li>
|
||||
<li>infrastructure/config/ · secrets/ · persistence/</li>
|
||||
<li>ui/settings_dialog.py → presentation/settings/</li>
|
||||
<li>scripts/ · .gitea/workflows/</li>
|
||||
</ul>
|
||||
|
||||
<p class="weight"><b>~2.700 dòng</b> · 727 settings + 1.352 app + 616 config<br>+ mục chung ở trên</p>
|
||||
</div>
|
||||
|
||||
<div class="card one">
|
||||
<div class="tag">Làn N2 · Hiệp</div>
|
||||
<h3>Giám sát & Quan trắc</h3>
|
||||
<p class="who">Hiệp — 7 tab, việc lặp cần kỷ luật</p>
|
||||
<div class="branch">gamma/refactor</div>
|
||||
|
||||
<h4>Việc</h4>
|
||||
<ul>
|
||||
<li><span class="tid">R08-T08</span> tách <code>monitoring_tab.py</code> → 7 tab độc lập</li>
|
||||
<li><span class="tid">R09-T04</span> <code>CanonicalAuditLogger</code></li>
|
||||
<li><span class="tid">R09-T05</span> <code>MonitoringQueryService</code> read-only, phân trang</li>
|
||||
<li><span class="tid">R09-T02</span> gỡ vòng lặp <code>model_pricing</code> ↔ <code>usage_tracker</code></li>
|
||||
<li><span class="tid">R09-T03</span> gỡ vòng lặp <code>agent_security</code> ↔ <code>alert</code></li>
|
||||
<li><span class="tid">R09-T06</span> ma trận Sandbox theo hệ điều hành</li>
|
||||
</ul>
|
||||
|
||||
<h4>Sở hữu độc quyền</h4>
|
||||
<ul class="paths">
|
||||
<li>ui/monitoring_tab.py → presentation/monitoring/</li>
|
||||
<li>application/monitoring/</li>
|
||||
<li>infrastructure/telemetry/ · sandbox/</li>
|
||||
<li>core/audit_log.py</li>
|
||||
<li>core/model_pricing.py · usage_tracker.py</li>
|
||||
<li>core/agent_security*.py</li>
|
||||
</ul>
|
||||
|
||||
<p class="weight"><b>~2.650 dòng</b> · 1.545 monitoring + ~1.100 core</p>
|
||||
</div>
|
||||
|
||||
<div class="card two">
|
||||
<div class="tag">Làn N3 · Lâm</div>
|
||||
<h3>Co4E Studio</h3>
|
||||
<p class="who">Lâm — canvas và luồng chạy workflow</p>
|
||||
<div class="branch">gamma/refactor</div>
|
||||
|
||||
<h4>Việc</h4>
|
||||
<ul>
|
||||
<li><span class="tid">R07-T06</span> <code>Co4EWorkflowService</code> thuần Python</li>
|
||||
<li><span class="tid">R08-T09</span> tách <code>co4e_tab.py</code> + <code>co4e_canvas.py</code> → canvas · node property · run control · chat view · agent list</li>
|
||||
<li>Gọi tool qua <code>ToolPolicyGateway</code> của Team Hoa — dùng fake, không chờ</li>
|
||||
</ul>
|
||||
|
||||
<h4>Sở hữu độc quyền</h4>
|
||||
<ul class="paths">
|
||||
<li>ui/co4e_tab.py → presentation/co4e/</li>
|
||||
<li>ui/co4e_canvas.py</li>
|
||||
<li>ui/co4e_config_panel.py</li>
|
||||
<li>application/workflows/</li>
|
||||
<li>domain/workflows/</li>
|
||||
<li>core/co4e_run_manager.py</li>
|
||||
</ul>
|
||||
|
||||
<p class="weight"><b>~2.880 dòng</b> · file to nhất của cả team</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<h2>Tám quy ước</h2>
|
||||
<p class="sub">
|
||||
Tám điều dưới đây là luật của team, Nam chốt. Bốn điều đầu là bắt buộc — trên một
|
||||
nhánh chung, vi phạm không chỉ hại mình mà chặn cả hai người kia.
|
||||
</p>
|
||||
|
||||
<div class="rules">
|
||||
|
||||
<div class="rule hard">
|
||||
<h3>1 · Không chạm file dùng chung</h3>
|
||||
<p>
|
||||
<code>app.py</code>, <code>theme.py</code>, <code>i18n.py</code>, <code>config.py</code>,
|
||||
<code>bootstrap.py</code> thuộc nhánh N1 của Nam. Cần thêm chuỗi hay token màu thì
|
||||
<b>nhắn, đừng sửa</b> — Nam thêm trong ngày. Đây là ba file duy nhất có thể gây conflict
|
||||
thật, và luật này xoá hẳn khả năng đó.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rule hard">
|
||||
<h3>2 · Nộp factory, không tự lắp vào app</h3>
|
||||
<p>
|
||||
Mỗi nhánh expose một hàm dựng widget với chữ ký chốt từ ngày đầu, ví dụ
|
||||
<code>build_monitoring_tab(ctx, query_service) -> QWidget</code>. Nam gọi nó trong <code>bootstrap.py</code> ngày 28/08. Không ai tự sửa chỗ khởi tạo trong
|
||||
<code>app.py</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rule hard">
|
||||
<h3>3 · Bị chặn thì dùng fake, không ngồi đợi</h3>
|
||||
<p>
|
||||
Chưa có <code>ConfigRepository</code> bản thật thì dùng <code>FakeConfigRepository</code>.
|
||||
Chưa có <code>ToolPolicyGateway</code> của Team Hoa thì đã có fake sẵn. <b>Báo ngay trong ngày</b>
|
||||
nếu thiếu fake nào — đó là việc của nhóm trưởng, không phải lý do dừng tay.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="rule hard">
|
||||
<h3>4 · Nhánh chung: kéo trước khi đẩy, đừng để nhánh đỏ</h3>
|
||||
<p>
|
||||
Cả ba đẩy vào <code>gamma/refactor</code>, nên không còn nhánh riêng làm vùng
|
||||
đệm. Ba việc bắt buộc: <code>git pull --rebase</code> trước mỗi lần đẩy;
|
||||
commit nhỏ và đẩy trong ngày, đừng ôm 500 dòng ba hôm; và
|
||||
<b>không bao giờ đẩy thứ làm <code>pytest tests -q</code> đỏ</b> — nhánh hỏng
|
||||
là hai người kia đứng hình. Lỡ đẩy nhầm thì sửa ngay hoặc
|
||||
<code>git revert</code>, đừng để qua đêm.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rule soft">
|
||||
<h3>5 · Commit nhỏ, mỗi ngày một lần</h3>
|
||||
<p>
|
||||
Một PR cho một sub-widget hoặc một service, không dồn 7 tab vào một PR cuối tuần. Nhóm
|
||||
trưởng duyệt trong ngày. PR càng to thì rủi ro càng dồn về ngày 28/08.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rule soft">
|
||||
<h3>6 · Mỗi commit kèm test, và không làm đỏ 90 test cũ</h3>
|
||||
<p>
|
||||
Baseline hiện tại: <b>102 test xanh trong 3,4 giây</b>. Chạy <code>pytest tests -q</code>
|
||||
trước mỗi lần đẩy. Đây là lưới an toàn cho phần logic — giữ nó xanh suốt 10 ngày.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rule soft">
|
||||
<h3>7 · File mới ≤ 400 dòng, không import PySide6 vào lõi</h3>
|
||||
<p>
|
||||
Hai điều kiện của CASAN Check 2 và 3. Tự kiểm trước khi đẩy — CI sẽ báo, nhưng biết
|
||||
sớm thì đỡ phải tách lại lần hai.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rule soft">
|
||||
<h3>8 · Checker UI thuộc phạm vi ai, người đó cập nhật</h3>
|
||||
<p>
|
||||
24 checker sẽ vỡ khi file bị dời. Ai dời file thì sửa checker tương ứng ngay trong
|
||||
commit đó — tốn thêm khoảng 15% thời gian, đổi lại giữ được lưới an toàn cho phần
|
||||
UI vừa làm xong.
|
||||
<b>Đã chốt 21/08: đường A. Nam chịu trách nhiệm nếu đổi ý.</b>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<h2>Mỗi người nhận gì, giao gì</h2>
|
||||
<p class="sub">
|
||||
Cột trái là thứ phải có trong tay mới làm được, kèm nguồn. Cột phải là thứ bắt
|
||||
buộc giao ra, kèm người nhận. Nhãn <span class="frm done">có rồi</span> nghĩa là
|
||||
mục chung đã làm xong.
|
||||
</p>
|
||||
|
||||
<div class="io">
|
||||
|
||||
<div class="iorow n1">
|
||||
<div class="iohead"><b>N1 · Cấu hình, Bí mật & Vỏ</b><span>Nam · nhóm trưởng</span></div>
|
||||
<div class="iogrid">
|
||||
<div>
|
||||
<p class="iocap">Input — cần có</p>
|
||||
<ul class="iolist">
|
||||
<li><span class="frm">mã cũ</span><code>config.py</code> 616 dòng</li>
|
||||
<li><span class="frm">mã cũ</span><code>ui/settings_dialog.py</code> 727 dòng</li>
|
||||
<li><span class="frm">mã cũ</span><code>app.py</code> 1.352 dòng</li>
|
||||
<li><span class="frm risk">tự chốt</span>Quyết định <code>api_key</code> — trước 26/08</li>
|
||||
<li><span class="frm">từ Hiệp</span>Chữ ký <code>build_monitoring_tab()</code> — trước 28/08</li>
|
||||
<li><span class="frm">từ Lâm</span>Chữ ký <code>build_co4e_tab()</code> — trước 28/08</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="iocap">Output — phải giao</p>
|
||||
<ul class="iolist">
|
||||
<li><span class="frm done">có rồi</span><code>SecretStore</code> · <code>ConfigRepository</code> + fake → <b>cho Hiệp và Lâm</b></li>
|
||||
<li><span class="frm done">có rồi</span><code>scripts/audit_security.py</code> → cho CI</li>
|
||||
<li><code>infrastructure/persistence/json/atomic_json_file.py</code></li>
|
||||
<li><code>infrastructure/config/</code> — cài đặt thật + settings facade</li>
|
||||
<li><code>infrastructure/secrets/keyring_adapter.py</code></li>
|
||||
<li><code>presentation/settings/</code> — 4 widget</li>
|
||||
<li><code>bootstrap.py</code> + <code>presentation/shell/</code> — 3 file</li>
|
||||
<li><code>docs/architecture/security-policy.md</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="iorow n2">
|
||||
<div class="iohead"><b>N2 · Giám sát</b><span>Hiệp</span></div>
|
||||
<div class="iogrid">
|
||||
<div>
|
||||
<p class="iocap">Input — cần có</p>
|
||||
<ul class="iolist">
|
||||
<li><span class="frm">mã cũ</span><code>ui/monitoring_tab.py</code> 1.545 dòng</li>
|
||||
<li><span class="frm">mã cũ</span><code>core/usage_tracker.py</code> 524 · <code>sandbox_manager.py</code> 335</li>
|
||||
<li><span class="frm">mã cũ</span><code>core/model_pricing.py</code> 284 · <code>agent_security.py</code> 272 · <code>audit_log.py</code> 115</li>
|
||||
<li><span class="frm done">từ Nam</span><code>FakeConfigRepository</code> — dùng được ngay</li>
|
||||
<li><span class="frm risk">tự chốt</span>Giữ nguyên 9 trường log, báo Duy và Hoa</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="iocap">Output — phải giao</p>
|
||||
<ul class="iolist">
|
||||
<li><code>build_monitoring_tab()</code> → <b>cho Nam</b>, trước 28/08</li>
|
||||
<li><code>FakeAuditLogger</code> · <code>FakeMonitoringQueryService</code> → <b>cho cả team</b></li>
|
||||
<li><code>presentation/monitoring/</code> — 7 tab + shell</li>
|
||||
<li><code>application/monitoring/monitoring_query_service.py</code></li>
|
||||
<li><code>infrastructure/telemetry/audit_logger.py</code></li>
|
||||
<li><code>infrastructure/sandbox/sandbox_capabilities.py</code></li>
|
||||
<li><b>0 circular import</b> ở pricing ↔ usage và security ↔ alert</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="iorow n3">
|
||||
<div class="iohead"><b>N3 · Co4E Studio</b><span>Lâm</span></div>
|
||||
<div class="iogrid">
|
||||
<div>
|
||||
<p class="iocap">Input — cần có</p>
|
||||
<ul class="iolist">
|
||||
<li><span class="frm">mã cũ</span><code>ui/co4e_tab.py</code> 2.089 dòng</li>
|
||||
<li><span class="frm">mã cũ</span><code>ui/co4e_canvas.py</code> 791 · <code>co4e_config_panel.py</code></li>
|
||||
<li><span class="frm">mã cũ</span><code>core/co4e_run_manager.py</code> 331</li>
|
||||
<li><span class="frm">có sẵn</span><code>core/co4e.py</code> — dataclass Workflow/Node/Edge đã có</li>
|
||||
<li><span class="frm done">từ Nam</span><code>FakeConfigRepository</code></li>
|
||||
<li><span class="frm risk">từ Team Hoa</span>DTO <code>ToolPolicyGateway</code> — <b>rủi ro liên team cao nhất</b>, lấy trong hôm nay</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="iocap">Output — phải giao</p>
|
||||
<ul class="iolist">
|
||||
<li><code>build_co4e_tab()</code> → <b>cho Nam</b>, trước 28/08</li>
|
||||
<li><code>FakeCo4EWorkflowService</code> → <b>cho cả team</b></li>
|
||||
<li><code>domain/workflows/</code> — DTO chốt ngày đầu</li>
|
||||
<li><code>application/workflows/co4e_workflow_service.py</code></li>
|
||||
<li><code>presentation/co4e/</code> — 5 phần</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<h3>Output bắt buộc với cả ba, mỗi lần đẩy</h3>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>Điều kiện</th><th>Ngưỡng</th><th>Tự kiểm bằng</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>File mới sau khi tách</td><td class="mono">≤ 400 dòng</td><td class="mono">wc -l</td></tr>
|
||||
<tr><td><code>domain/</code> và <code>application/</code> import PySide6</td><td class="mono">0</td><td class="mono">grep -r PySide6</td></tr>
|
||||
<tr><td>Test hiện có</td><td class="mono">102 xanh</td><td class="mono">pytest tests -q</td></tr>
|
||||
<tr><td>Credential lộ</td><td class="mono">0</td><td class="mono">python scripts/audit_security.py</td></tr>
|
||||
<tr><td>Checker UI trong phạm vi mình dời</td><td>đã cập nhật</td><td class="mono">python tools/check_<tên>.py</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>Lịch từng ngày</h2>
|
||||
<p class="sub">Ba hàng chạy độc lập. Hàng tô nền là lúc cả ba phải gặp nhau.</p>
|
||||
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="day">Ngày</th>
|
||||
<th>N1 · Nam</th>
|
||||
<th>N2 · Hiệp</th>
|
||||
<th>N3 · Lâm</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="day">21/08<br><small>T6</small></td>
|
||||
<td class="cl"><b>Mục chung</b> · dựng khung · interface + fake · chốt api_key · CASAN script<small>Merge trước khi hai người kia bắt đầu</small></td>
|
||||
<td class="c1">Chốt schema log 9 trường<small>Giữ nguyên định dạng cũ để 24 chỗ gọi không phải sửa</small></td>
|
||||
<td class="c2">Chốt chữ ký <code>Co4EWorkflowService</code><small>Nộp cho lead để lắp bootstrap sau</small></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="day">22–23/08<br><small>T7–CN</small></td>
|
||||
<td class="cl">AtomicJsonFile · ConfigRepository · Typed Settings Facade</td>
|
||||
<td class="c1">CanonicalAuditLogger · gỡ vòng lặp pricing ↔ usage</td>
|
||||
<td class="c2">Co4EWorkflowService — CRUD & validate, test không cần Qt</td>
|
||||
</tr>
|
||||
<tr class="mark">
|
||||
<td class="day">23/08<br><small>17:00</small></td>
|
||||
<td colspan="3"><span class="pill cp">Checkpoint 1</span> 100% DTO và fake xong · <code>pytest</code> xanh · không ai bị chặn</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="day">24/08<br><small>T2</small></td>
|
||||
<td class="cl">Tách settings: provider + connector widget</td>
|
||||
<td class="c1">3 tab đầu: overview · sandbox · security events</td>
|
||||
<td class="c2">node_property_panel · agent_list_panel</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="day">25/08<br><small>T3</small></td>
|
||||
<td class="cl">Tách settings: routing + general widget</td>
|
||||
<td class="c1">4 tab còn lại: MCP · action logs · agent status · security settings</td>
|
||||
<td class="c2">co4e_canvas_widget — thao tác node</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="day">26/08<br><small>T4</small></td>
|
||||
<td class="cl">Chuyển API key sang SecretStore<small>Báo Team Duy trước khi đụng providers/</small></td>
|
||||
<td class="c1">Lắp shell MonitoringTab · query service bản thật</td>
|
||||
<td class="c2">Run control · chat view</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="day">27/08<br><small>T5</small></td>
|
||||
<td class="cl">Schema versioning · recovery policy</td>
|
||||
<td class="c1">Ma trận Sandbox · gỡ vòng lặp agent_security</td>
|
||||
<td class="c2">Lắp container Co4ETab · thay fake bằng service thật</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="day">28/08<br><small>T6</small></td>
|
||||
<td class="cl"><b>bootstrap.py + tách MainWindow</b><small>Nhận factory của Hiệp và Lâm để lắp</small></td>
|
||||
<td class="c1">Nộp factory · dọn file >400 dòng · cập nhật checker</td>
|
||||
<td class="c2">Nộp factory · dọn file >400 dòng · cập nhật checker</td>
|
||||
</tr>
|
||||
<tr class="mark">
|
||||
<td class="day">28/08<br><small>17:00</small></td>
|
||||
<td colspan="3"><span class="pill cp">Checkpoint 2</span> Tách xong 100% god file · 0 circular import</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="day">29/08<br><small>T7</small></td>
|
||||
<td class="cl">Tài liệu Security Policy · integration test Settings</td>
|
||||
<td class="c1">Integration test Monitoring</td>
|
||||
<td class="c2">Integration test luồng Co4E đầu-cuối</td>
|
||||
</tr>
|
||||
<tr class="mark">
|
||||
<td class="day">30/08<br><small>CN 17:00</small></td>
|
||||
<td colspan="3"><span class="pill gate">CASAN Gate</span> <b>Nam chủ trì Check 1</b> — quét toàn bộ config/JSON, phải ra 0 secret plaintext. Hiệp và Lâm sửa ngay phần của mình nếu script bắt được.</td>
|
||||
</tr>
|
||||
<tr class="mark">
|
||||
<td class="day">31/08<br><small>T2 15:00</small></td>
|
||||
<td colspan="3"><span class="pill ship">Bàn giao</span> Fix tồn đọng · cập nhật tài liệu kiến trúc · merge PR cuối · smoke test 5 luồng chính</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>Nghiệm thu: làm sao biết đã thật sự song song</h2>
|
||||
<p class="sub">Không phải “đã họp xong” mà là chạy được. Ba câu hỏi, trả lời bằng lệnh.</p>
|
||||
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Câu hỏi</th><th>Cách trả lời</th><th>Khi nào</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Hiệp có chạy được khi chưa có config bản thật?</td>
|
||||
<td>Dựng một tab Monitoring, chạy test của nó, <b>không import <code>cowork_local.config</code></b> dòng nào — chỉ dùng <code>FakeConfigRepository</code></td>
|
||||
<td class="mono">21/08</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Lâm có chạy được khi Team Hoa chưa xong gateway?</td>
|
||||
<td>Test <code>Co4EWorkflowService</code> xanh với <code>FakeToolPolicyGateway</code></td>
|
||||
<td class="mono">23/08</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Ba người có đụng file nhau không?</td>
|
||||
<td><code>git log --name-only --pretty=%an</code> trên <code>gamma/refactor</code> —
|
||||
không file nào được xuất hiện dưới hai tên khác nhau</td>
|
||||
<td class="mono">mỗi ngày</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Nguồn: <code>docs/refactor/plan.md</code>, <code>Refactoring_Checklist.md</code>,
|
||||
<code>Feature_Architecture_Proposal.md</code>. Số dòng code, số lời gọi và baseline test đo
|
||||
trực tiếp trên nhánh <code>main</code> ngày 21/08.
|
||||
Mục chung, cách chia ba nhánh, bảy quy ước và mục nghiệm thu là đề xuất — không có trong
|
||||
tài liệu gốc.
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,189 @@
|
||||
# Quyết định của Team Gamma
|
||||
|
||||
Team: **Nam** (nhóm trưởng, nhánh N1) · **Hiệp** (N2) · **Lâm** (N3).
|
||||
|
||||
Ghi ở đây thay vì chôn trong comment, vì cả ba đều ảnh hưởng ra ngoài phạm vi
|
||||
một người.
|
||||
|
||||
| # | Việc | Trạng thái |
|
||||
|---|---|---|
|
||||
| 1 | `provider_conf()` còn trả `api_key` | **Chốt 21/08 — đường A** |
|
||||
| 2 | Số phận 24 checker UI | **Chốt 21/08 — đường A** |
|
||||
| 3 | DTO `ToolPolicyGateway` viết hộ Team Hoa | Đã làm, chờ Hoa xác nhận |
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
> **Nam chốt 21/08: đường A.**
|
||||
>
|
||||
> Việc kèm theo: `ConfigRepository` bản thật phải đọc key từ `SecretStore` rồi
|
||||
> ghép vào dict do `provider_conf()` trả về. Năm nơi đọc không đổi một dòng,
|
||||
> nên **không cần báo Duy và Hoa**.
|
||||
>
|
||||
> Nợ kỹ thuật đã ghi: đường B (bỏ `api_key` khỏi dict) để sau sprint nà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 đó.
|
||||
|
||||
> **Chốt 21/08: đường A** — ai dời file thì cập nhật checker tương ứng ngay
|
||||
> trong PR đó.
|
||||
>
|
||||
> Kèm hai ràng buộc, vì rủi ro của đường A là người sửa vội nới lỏng phép kiểm:
|
||||
>
|
||||
> 1. PR nào *sửa* checker phải nói rõ trong mô tả **sửa gì và vì sao**.
|
||||
> 2. Sửa xong chạy `python tools/check_probes_bite.py` — nó cắm lỗi cố ý vào
|
||||
> code rồi kiểm checker có bắt được không. Chính công cụ này đã từng bắt
|
||||
> được một checker bị sửa thành *không thể đỏ*.
|
||||
>
|
||||
> Không đưa 24 checker vào CI trong sprint này: chúng dựng `MainWindow` thật,
|
||||
> mỗi lần chạy tốn hàng chục giây và thỉnh thoảng sập lúc Qt dọn dẹp. Chạy tay
|
||||
> theo phạm vi mình đụng là đủ.
|
||||
|
||||
---
|
||||
|
||||
## Quyết định 3 — Gamma viết hộ DTO `ToolPolicyGateway` cho Team Hoa
|
||||
|
||||
**Đã làm, chờ Hoa xác nhận.** Ngày: 21/08.
|
||||
|
||||
### Vì sao làm thay
|
||||
|
||||
N3 (Co4E) cần gọi tool nhưng Team Hoa chưa bắt đầu. Ba đường:
|
||||
|
||||
| | Hệ quả |
|
||||
|---|---|
|
||||
| N3 ngồi đợi Hoa | Mất mấy ngày, trái nguyên tắc "không team nào chặn team nào" |
|
||||
| N3 tự phỏng đoán | Phỏng đoán của một người, không ai soi, sửa lại chắc chắn |
|
||||
| **Gamma viết bản đề xuất** | N3 chạy ngay, Hoa có cái cụ thể để duyệt hoặc sửa |
|
||||
|
||||
### Ranh giới không lấn
|
||||
|
||||
Sơ đồ phân hệ trong `plan.md` giao `domain/security/` cho **Team Gamma**, còn
|
||||
`application/conversations/tool_policy_gateway.py` cho **Team Hoa**.
|
||||
|
||||
Nên chia đúng như vậy:
|
||||
|
||||
- **Gamma định nghĩa hình dạng** → `domain/security/tool_policy.py`
|
||||
- **Hoa cài đặt gateway** → `application/conversations/tool_policy_gateway.py`,
|
||||
nối vào `core/mcp_client.py` và tool dựng sẵn
|
||||
|
||||
Không đụng file nào của Hoa.
|
||||
|
||||
### Đã bám vào code đang chạy, không bịa
|
||||
|
||||
| Nguồn | Lấy gì |
|
||||
|---|---|
|
||||
| `core/agent_security.py::SecurityVerdict` | `allowed` · `reason` · `layer` |
|
||||
| `ui/permission_dialog.py` + `chat_panel.py:1312` | trạng thái "hỏi người dùng" |
|
||||
|
||||
Khác biệt duy nhất: gộp thành **một câu trả lời ba trạng thái**
|
||||
(`ALLOW` / `DENY` / `ASK`) thay vì bắt chỗ gọi tự nhớ hỏi hai nơi.
|
||||
|
||||
Hai ràng buộc đưa vào có chủ đích:
|
||||
|
||||
1. `DENY` và `ASK` **bắt buộc có `reason`** — người dùng cần biết vì sao, và
|
||||
`audit_log` cần ghi lại. Thiếu là ném lỗi ngay lúc dựng, không phải lúc chạy.
|
||||
2. `ASK` **không phải** `allowed` — bẫy dễ mắc nhất là coi ASK như ALLOW rồi tool
|
||||
chạy mà chưa ai đồng ý. Có test riêng cho chuyện này.
|
||||
|
||||
### Gửi Hoa cái gì
|
||||
|
||||
> Bên mình viết trước bản đề xuất `ToolPolicyGateway` ở
|
||||
> `domain/security/tool_policy.py` vì N3 cần gọi tool mà bên Hoa chưa bắt đầu —
|
||||
> để N3 khỏi phải tự đoán. Ba kiểu: `ToolCallRequest`, `PolicyDecision`,
|
||||
> `ToolPolicyGateway`. Phần cài đặt vẫn để bên Hoa ở
|
||||
> `application/conversations/tool_policy_gateway.py`, bọn mình không đụng.
|
||||
> Thấy chỗ nào không hợp thì sửa thẳng file đó, đừng tạo kiểu thứ hai. Đổi bây
|
||||
> giờ còn rẻ vì mới mình N3 dùng.
|
||||
|
||||
> Đã gửi Hoa: ☐ — ngày ____ Hoa xác nhận: ☐ đồng ý ☐ có sửa
|
||||
@@ -0,0 +1 @@
|
||||
"""domain/ — Quy tắc nghiệp vụ thuần. KHÔNG import PySide6, không chạm đĩa/mạng."""
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Cổng chính sách cho lời gọi tool — hình dạng dữ liệu, chưa phải cài đặt.
|
||||
|
||||
BẢN ĐỀ XUẤT, chờ Team Hoa xác nhận
|
||||
==================================
|
||||
Sơ đồ phân hệ trong ``plan.md`` giao ``domain/security/`` cho Team Gamma và
|
||||
``application/conversations/tool_policy_gateway.py`` cho Team Hoa. Nên Gamma
|
||||
định nghĩa *hình dạng*, Hoa *cài đặt*.
|
||||
|
||||
Viết trước vì N3 (Co4E) cần gọi tool và Team Hoa chưa bắt đầu. Không có nó thì
|
||||
N3 phải tự phỏng đoán rồi sửa lại sau — mà phỏng đoán của một người thì tệ hơn
|
||||
một đề xuất viết ra để cả hai bên soi.
|
||||
|
||||
Nếu Hoa thấy khác, sửa file này chứ đừng đẻ kiểu thứ hai. Đổi sớm rẻ hơn đổi
|
||||
muộn: hiện chỉ N3 dùng.
|
||||
|
||||
Mô hình bám theo code đang chạy, không bịa:
|
||||
* ``core/agent_security.py::SecurityVerdict`` — allowed / reason / layer
|
||||
* ``ui/permission_dialog.py`` — hộp thoại hỏi người dùng khi
|
||||
``ctx.project_confirm_commands()`` bật (``ui/chat_panel.py:1312``)
|
||||
|
||||
Điểm khác biệt duy nhất so với hôm nay: gộp hai thứ đó thành **một câu trả lời
|
||||
ba trạng thái**, thay vì code gọi phải tự nhớ hỏi cả hai nơi.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Protocol, runtime_checkable
|
||||
|
||||
|
||||
class PolicyOutcome(str, Enum):
|
||||
"""Ba trạng thái. ``ASK`` là thứ hệ thống hiện tại đã có (hộp thoại xin
|
||||
phép) nhưng chưa được coi là một kết quả chính thức."""
|
||||
|
||||
ALLOW = "allow"
|
||||
DENY = "deny"
|
||||
ASK = "ask"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallRequest:
|
||||
"""Một lời gọi tool đang chờ được duyệt.
|
||||
|
||||
``surface`` cho biết chỗ phát sinh — ``"cowork"``, ``"code"``, ``"co4e"``,
|
||||
``"task"``. Chính sách khác nhau theo màn: Co4E chạy nền nên không thể bật
|
||||
hộp thoại hỏi giữa chừng như Cowork.
|
||||
"""
|
||||
|
||||
name: str
|
||||
arguments: Dict[str, Any] = field(default_factory=dict)
|
||||
surface: str = "cowork"
|
||||
project_id: str = ""
|
||||
#: True nếu tool đến từ MCP server ngoài, False nếu là tool dựng sẵn.
|
||||
external: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PolicyDecision:
|
||||
"""Câu trả lời của cổng.
|
||||
|
||||
``reason`` bắt buộc có khi DENY hoặc ASK — người dùng phải biết vì sao bị
|
||||
chặn, và ``core/audit_log.py`` cần nó để ghi lại.
|
||||
|
||||
``layer`` giữ đúng từ vựng của ``SecurityVerdict``: ``"prompt"`` |
|
||||
``"attachment"`` | ``"command"``, cộng thêm ``"policy"`` cho quyết định của
|
||||
chính cổng này.
|
||||
"""
|
||||
|
||||
outcome: PolicyOutcome
|
||||
reason: str = ""
|
||||
layer: str = "policy"
|
||||
|
||||
@property
|
||||
def allowed(self) -> bool:
|
||||
"""Tương thích với chỗ đang đọc ``SecurityVerdict.allowed``.
|
||||
|
||||
Chú ý: ``ASK`` KHÔNG phải allowed — còn phải hỏi người dùng đã.
|
||||
"""
|
||||
return self.outcome is PolicyOutcome.ALLOW
|
||||
|
||||
def __post_init__(self):
|
||||
if self.outcome is not PolicyOutcome.ALLOW and not self.reason:
|
||||
raise ValueError("DENY và ASK bắt buộc có reason — người dùng và "
|
||||
"audit log đều cần biết vì sao")
|
||||
|
||||
|
||||
def allow() -> PolicyDecision:
|
||||
return PolicyDecision(PolicyOutcome.ALLOW)
|
||||
|
||||
|
||||
def deny(reason: str, layer: str = "policy") -> PolicyDecision:
|
||||
return PolicyDecision(PolicyOutcome.DENY, reason, layer)
|
||||
|
||||
|
||||
def ask(reason: str, layer: str = "policy") -> PolicyDecision:
|
||||
return PolicyDecision(PolicyOutcome.ASK, reason, layer)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ToolPolicyGateway(Protocol):
|
||||
"""Hỏi trước khi chạy tool. Cài đặt thật: Team Hoa (R07, hạn 29/08)."""
|
||||
|
||||
def check(self, request: ToolCallRequest) -> PolicyDecision:
|
||||
"""Được chạy tool này không.
|
||||
|
||||
KHÔNG được tự bật hộp thoại bên trong — cổng chỉ *trả lời*, còn hỏi ai
|
||||
và hỏi thế nào là việc của tầng giao diện. Có vậy thì Co4E chạy nền mới
|
||||
dùng chung cổng được với Cowork chạy tương tác.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1 @@
|
||||
"""infrastructure/ — Chạm thế giới thật: file, keyring, HTTP, tiến trình. Cài đặt interface."""
|
||||
@@ -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).
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,197 @@
|
||||
"""ConfigRepository chạy trên file JSON — R02-T02.
|
||||
|
||||
Thay cho ``config.py::AppConfig``. Hai khác biệt duy nhất về hành vi, cả hai
|
||||
đều là thứ ta muốn:
|
||||
|
||||
1. Ghi qua :class:`AtomicJsonFile` — mất điện giữa lúc lưu không còn làm hỏng
|
||||
cấu hình (R02-T01).
|
||||
2. API key đọc từ :class:`SecretStore` rồi **ghép vào** dict do
|
||||
``provider_conf()`` trả về — đúng đường A đã chốt 21/08
|
||||
(``docs/refactor/GammaTeam_decisions.md``). Nhờ vậy 5 nơi đang đọc
|
||||
``conf["api_key"]`` không phải sửa dòng nào, trong đó 3 nơi thuộc Team Duy.
|
||||
|
||||
Mọi thứ còn lại giữ nguyên có chủ đích: trộn sâu với mặc định, đọc biến môi
|
||||
trường, ``ms365.unlocked`` không bao giờ chạm đĩa. Đây là refactor — hành vi
|
||||
nhìn từ ngoài phải y hệt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from ..persistence.json.atomic_json_file import AtomicJsonFile
|
||||
from ..secrets.secret_store import SecretStore, provider_key
|
||||
from .schema_migration import CURRENT_VERSION, migrate
|
||||
|
||||
|
||||
class JsonConfigRepository:
|
||||
"""Cấu hình đọc/ghi từ một file JSON, bí mật để trong ``SecretStore``.
|
||||
|
||||
``secrets`` để None nghĩa là không có kho bí mật — mọi thứ vẫn chạy, chỉ
|
||||
là ``api_key`` lấy nguyên từ file như trước. Cần vậy để chuyển dần
|
||||
(R02-T05) chứ không phải đổi một phát cả app.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path, *, secrets: SecretStore | None = None,
|
||||
defaults: Dict[str, Any] | None = None,
|
||||
env_overrides=None):
|
||||
self._file = AtomicJsonFile(path)
|
||||
self._secrets = secrets
|
||||
# Lấy thẳng từ config.py để hai bên không lệch nhau trong lúc chuyển.
|
||||
if defaults is None or env_overrides is None:
|
||||
from ... import config as legacy
|
||||
defaults = defaults if defaults is not None else legacy.DEFAULT_CONFIG
|
||||
env_overrides = env_overrides or legacy._apply_env_overrides
|
||||
self._defaults = defaults
|
||||
self._env_overrides = env_overrides
|
||||
self.data: Dict[str, Any] = self._load()
|
||||
|
||||
# ---- nạp ------------------------------------------------------------
|
||||
def _load(self) -> Dict[str, Any]:
|
||||
merged = copy.deepcopy(self._defaults)
|
||||
stored = self._file.read(default=None)
|
||||
if isinstance(stored, dict):
|
||||
# Nâng cấp TRƯỚC khi trộn với mặc định: bước v1→v2 gỡ api_key khỏi
|
||||
# đĩa, mà mặc định thì không có khoá nào để gỡ.
|
||||
stored, changed = migrate(stored, secrets=self._secrets,
|
||||
path=self._file.path)
|
||||
merged = _deep_merge(merged, stored)
|
||||
if changed:
|
||||
self.data = merged
|
||||
self.save() # ghi ngay, để lần sau khỏi chuyển lại
|
||||
merged = self._env_overrides(merged)
|
||||
# Trạng thái mở khoá ms365 chỉ tồn tại lúc chạy — mỗi lần mở app đều
|
||||
# bắt đầu ở trạng thái khoá, không tin giá trị đọc từ đĩa.
|
||||
merged.setdefault("ms365", {})["unlocked"] = False
|
||||
return merged
|
||||
|
||||
def reload(self) -> None:
|
||||
self.data = self._load()
|
||||
|
||||
# ---- provider --------------------------------------------------------
|
||||
@property
|
||||
def active_provider(self) -> str:
|
||||
return self.data.get("active_provider", "")
|
||||
|
||||
def set_active_provider(self, name: str) -> None:
|
||||
self.data["active_provider"] = name
|
||||
|
||||
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
|
||||
"""Cấu hình provider, có sẵn ``api_key``.
|
||||
|
||||
Trả về BẢN SAO: chỗ gọi sửa dict này thì không được âm thầm ghi ngược
|
||||
vào cấu hình — và quan trọng hơn, khoá vừa ghép vào không được lẫn
|
||||
ngược vào ``self.data`` rồi theo ``save()`` xuống đĩa.
|
||||
"""
|
||||
name = name or self.active_provider
|
||||
conf = dict(self.data.get("providers", {}).get(name, {}))
|
||||
if self._secrets is not None:
|
||||
stored = self._secrets.get(provider_key(name))
|
||||
if stored:
|
||||
conf["api_key"] = stored
|
||||
return conf
|
||||
|
||||
def set_api_key(self, name: str, value: str) -> None:
|
||||
"""Lưu khoá vào kho bí mật, và xoá khỏi cấu hình trên đĩa.
|
||||
|
||||
Đây là nửa còn lại của đường A: dict *đọc ra* vẫn có ``api_key``,
|
||||
nhưng file JSON *trên đĩa* thì không — điều kiện để qua CASAN Check 1.
|
||||
"""
|
||||
if self._secrets is not None:
|
||||
self._secrets.set(provider_key(name), value)
|
||||
self.data.setdefault("providers", {}).setdefault(name, {})["api_key"] = ""
|
||||
else:
|
||||
self.data.setdefault("providers", {}).setdefault(name, {})["api_key"] = value
|
||||
|
||||
# ---- đường dẫn -------------------------------------------------------
|
||||
@property
|
||||
def shared_dir(self) -> str:
|
||||
return self.data.get("shared_dir", "")
|
||||
|
||||
def history_dir(self) -> Path:
|
||||
rt = self.data.get("_project_history_dir")
|
||||
if rt:
|
||||
return Path(rt)
|
||||
custom = (self.data.get("history", {}).get("custom_dir") or "").strip()
|
||||
if custom:
|
||||
return Path(custom).expanduser()
|
||||
from ...config import CONFIG_DIR
|
||||
return CONFIG_DIR / "history"
|
||||
|
||||
def cowork_output_dir(self) -> Path:
|
||||
custom = (self.data.get("cowork", {}).get("output_dir") or "").strip()
|
||||
if custom:
|
||||
return Path(custom).expanduser()
|
||||
from ... import paths
|
||||
from ...config import CONFIG_DIR
|
||||
root = paths.primary_onedrive_root()
|
||||
if root is not None:
|
||||
return root / "CoworkLocal" / "output"
|
||||
return CONFIG_DIR / "output" / "cowork"
|
||||
|
||||
# ---- giao diện -------------------------------------------------------
|
||||
@property
|
||||
def theme(self) -> str:
|
||||
return self.data.get("theme", "dark")
|
||||
|
||||
def set_theme(self, value: str) -> None:
|
||||
self.data["theme"] = value
|
||||
|
||||
@property
|
||||
def language(self) -> str:
|
||||
return self.data.get("language", "vi")
|
||||
|
||||
def set_language(self, value: str) -> None:
|
||||
self.data["language"] = value
|
||||
|
||||
# ---- nhóm cấu hình ---------------------------------------------------
|
||||
@property
|
||||
def routing(self) -> Dict[str, Any]:
|
||||
return self.data.setdefault("routing", {})
|
||||
|
||||
@property
|
||||
def auth(self) -> Dict[str, Any]:
|
||||
return self.data.setdefault("auth", {})
|
||||
|
||||
@property
|
||||
def agent_security(self) -> Dict[str, Any]:
|
||||
return self.data.setdefault("agent_security", {})
|
||||
|
||||
@property
|
||||
def tools_disabled(self) -> list[str]:
|
||||
return list(self.data.get("tools_disabled", []))
|
||||
|
||||
def set_tool_enabled(self, name: str, enabled: bool) -> None:
|
||||
disabled = list(self.data.get("tools_disabled", []))
|
||||
if enabled:
|
||||
disabled = [t for t in disabled if t != name]
|
||||
elif name not in disabled:
|
||||
disabled.append(name)
|
||||
self.data["tools_disabled"] = disabled
|
||||
|
||||
# ---- ghi -------------------------------------------------------------
|
||||
def save(self) -> None:
|
||||
"""Ghi nguyên tử. Không bao giờ để lộ trạng thái mở khoá ms365."""
|
||||
to_write = self.data
|
||||
if self.data.get("ms365", {}).get("unlocked"):
|
||||
to_write = copy.deepcopy(self.data)
|
||||
to_write["ms365"]["unlocked"] = False
|
||||
to_write.pop("_project_history_dir", None)
|
||||
to_write["schema_version"] = CURRENT_VERSION
|
||||
self._file.write(to_write)
|
||||
|
||||
|
||||
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Trộn sâu — giống hệt ``config.py::_deep_merge``.
|
||||
|
||||
Không import lại từ đó vì file này phải sống được sau khi ``config.py``
|
||||
biến mất; giữ bản sao 6 dòng còn hơn giữ một sợi dây phụ thuộc.
|
||||
"""
|
||||
out = copy.deepcopy(base)
|
||||
for key, value in (override or {}).items():
|
||||
if isinstance(value, dict) and isinstance(out.get(key), dict):
|
||||
out[key] = _deep_merge(out[key], value)
|
||||
else:
|
||||
out[key] = value
|
||||
return out
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Đánh số phiên bản và chuyển đổi cấu hình — R02-T06.
|
||||
|
||||
Hôm nay ``config.json`` không có số phiên bản. Nghĩa là không có cách nào biết
|
||||
file trên đĩa thuộc thời nào, và mọi thay đổi hình dạng phải xử lý bằng cách
|
||||
đoán — ``config.py::_migrate_connectors()`` chính là một ví dụ: nó đoán "có
|
||||
khoá ``office`` nghĩa là file cũ".
|
||||
|
||||
Ở đây đặt luật rõ:
|
||||
|
||||
* File có ``schema_version``. Thiếu ⇒ coi là **1** (mọi file đang tồn tại).
|
||||
* Mỗi bước nâng cấp là một hàm ``v1 -> v2``, chạy tuần tự, không nhảy cóc.
|
||||
* **Sao lưu trước khi nâng cấp.** Người dùng lùi về bản app cũ thì bản cũ đọc
|
||||
file mới có thể hỏng — phải còn đường về.
|
||||
* Chỉ nâng, không hạ. File mới hơn app thì báo và dùng nguyên trạng, không cố
|
||||
đoán ngược.
|
||||
|
||||
Bước v1→v2 đầu tiên đi kèm R02-T05: gỡ ``api_key`` khỏi đĩa, đẩy vào
|
||||
``SecretStore``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict
|
||||
|
||||
from ..secrets.secret_store import SecretStore, provider_key
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
#: Phiên bản app hiện đang ghi ra.
|
||||
CURRENT_VERSION = 2
|
||||
|
||||
#: Thiếu ``schema_version`` ⇒ file có từ trước khi đánh số.
|
||||
ASSUMED_VERSION = 1
|
||||
|
||||
|
||||
def read_version(data: Dict[str, Any]) -> int:
|
||||
try:
|
||||
return int(data.get("schema_version", ASSUMED_VERSION))
|
||||
except (TypeError, ValueError):
|
||||
return ASSUMED_VERSION
|
||||
|
||||
|
||||
def _v1_to_v2(data: Dict[str, Any], secrets: SecretStore | None) -> Dict[str, Any]:
|
||||
"""Chuyển API key từ file sang kho bí mật — R02-T05.
|
||||
|
||||
Không có kho bí mật thì **không chuyển**: thà để khoá nằm nguyên trong file
|
||||
còn hơn xoá đi rồi người dùng mất khoá mà không hiểu vì sao. File giữ
|
||||
nguyên phiên bản 1, lần chạy sau trên máy có keyring sẽ chuyển.
|
||||
"""
|
||||
if secrets is None or not getattr(secrets, "available", True):
|
||||
log.info("bỏ qua v1→v2: máy này chưa có kho bí mật dùng được")
|
||||
return data
|
||||
|
||||
out = copy.deepcopy(data)
|
||||
moved = []
|
||||
for name, conf in (out.get("providers") or {}).items():
|
||||
if not isinstance(conf, dict):
|
||||
continue
|
||||
key = (conf.get("api_key") or "").strip()
|
||||
# "ollama" là giá trị bù nhìn — Ollama đòi có api_key nhưng bỏ qua nội
|
||||
# dung. Đẩy nó vào keyring chỉ tổ rác.
|
||||
if not key or key == "ollama":
|
||||
continue
|
||||
secrets.set(provider_key(name), key)
|
||||
conf["api_key"] = ""
|
||||
moved.append(name)
|
||||
|
||||
out["schema_version"] = 2
|
||||
if moved:
|
||||
log.info("đã chuyển API key sang kho bí mật: %s", ", ".join(moved))
|
||||
return out
|
||||
|
||||
|
||||
#: {phiên bản nguồn: hàm nâng lên phiên bản kế tiếp}
|
||||
STEPS: Dict[int, Callable[[Dict[str, Any], SecretStore | None], Dict[str, Any]]] = {
|
||||
1: _v1_to_v2,
|
||||
}
|
||||
|
||||
|
||||
def backup(path: Path) -> Path | None:
|
||||
"""Chép file trước khi nâng cấp. Trả về đường dẫn bản sao."""
|
||||
if not path.exists():
|
||||
return None
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
target = path.with_suffix(path.suffix + f".v{stamp}.bak")
|
||||
try:
|
||||
shutil.copy2(path, target)
|
||||
return target
|
||||
except OSError as exc:
|
||||
log.warning("không sao lưu được %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
|
||||
def migrate(data: Dict[str, Any], *, secrets: SecretStore | None = None,
|
||||
path: Path | None = None) -> tuple[Dict[str, Any], bool]:
|
||||
"""Nâng ``data`` lên :data:`CURRENT_VERSION`.
|
||||
|
||||
Trả về ``(dữ_liệu, có_đổi_không)``. ``có_đổi_không`` là False thì chỗ gọi
|
||||
khỏi phải ghi lại đĩa.
|
||||
"""
|
||||
version = read_version(data)
|
||||
|
||||
if version > CURRENT_VERSION:
|
||||
# App cũ gặp file mới. Đoán ngược là cách nhanh nhất để mất dữ liệu.
|
||||
log.warning("config phiên bản %s mới hơn app (%s) — dùng nguyên trạng",
|
||||
version, CURRENT_VERSION)
|
||||
return data, False
|
||||
|
||||
if version == CURRENT_VERSION:
|
||||
return data, False
|
||||
|
||||
if path is not None:
|
||||
backup(path)
|
||||
|
||||
changed = False
|
||||
while version < CURRENT_VERSION:
|
||||
step = STEPS.get(version)
|
||||
if step is None:
|
||||
log.warning("thiếu bước nâng cấp từ phiên bản %s — dừng", version)
|
||||
break
|
||||
data = step(data, secrets)
|
||||
new_version = read_version(data)
|
||||
if new_version <= version:
|
||||
# Bước không nâng được phiên bản (ví dụ v1→v2 bỏ qua vì chưa có
|
||||
# keyring). Dừng, đừng lặp vô hạn.
|
||||
break
|
||||
version = new_version
|
||||
changed = True
|
||||
|
||||
return data, changed
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Khung nhìn có kiểu cho từng nhóm cấu hình — R02-T03.
|
||||
|
||||
Vấn đề đang có: khắp nơi viết ``ctx.config.routing.get("switch_mode", "off")``.
|
||||
Gõ sai một chữ thì lặng lẽ nhận giá trị mặc định, không ai biết cho tới khi
|
||||
tính năng "không hiểu sao không chạy". Đếm được **156 lời gọi ``ctx.config.*``
|
||||
trong 29 file** kiểu đó.
|
||||
|
||||
Ở đây mỗi nhóm cấu hình có một lớp: gõ sai tên thuộc tính là lỗi ngay, và kiểu
|
||||
dữ liệu ghi rõ ràng nên đọc code là biết ``confirm_timeout_sec`` là số giây
|
||||
chứ không phải mili giây.
|
||||
|
||||
Cố ý KHÔNG dùng dataclass đông cứng: đây là *khung nhìn* lên dict cấu hình
|
||||
sống, sửa qua đây là sửa vào dict rồi ``save()`` là xuống đĩa. Sao chép thành
|
||||
dataclass thì lại sinh chuyện đồng bộ hai chiều.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
class _View:
|
||||
"""Khung nhìn lên một nhánh của dict cấu hình."""
|
||||
|
||||
def __init__(self, data: Dict[str, Any]):
|
||||
self._d = data
|
||||
|
||||
def _get(self, key: str, default: Any) -> Any:
|
||||
value = self._d.get(key, default)
|
||||
return default if value is None else value
|
||||
|
||||
def raw(self) -> Dict[str, Any]:
|
||||
"""Dict gốc — dùng khi cần đọc khoá chưa được đưa vào khung nhìn.
|
||||
|
||||
Có mặt để không ai bị kẹt: thiếu thuộc tính thì dùng tạm ``raw()`` rồi
|
||||
mở issue bổ sung, chứ đừng vòng lại ``ctx.config.data``.
|
||||
"""
|
||||
return self._d
|
||||
|
||||
|
||||
class ProviderSettings(_View):
|
||||
"""Một provider: đi đâu, model nào, khoá nào.
|
||||
|
||||
``api_key`` ở đây là thứ ``JsonConfigRepository.provider_conf()`` đã ghép
|
||||
sẵn từ kho bí mật — xem đường A trong ``GammaTeam_decisions.md``.
|
||||
"""
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return str(self._get("base_url", ""))
|
||||
|
||||
@property
|
||||
def model(self) -> str:
|
||||
return str(self._get("model", ""))
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
return str(self._get("api_key", ""))
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
"""Đủ thông tin để gọi được chưa.
|
||||
|
||||
Ollama chạy cục bộ nên không cần khoá — đó là lý do điều kiện là
|
||||
"có base_url và model", không phải "có api_key".
|
||||
"""
|
||||
return bool(self.base_url and self.model)
|
||||
|
||||
|
||||
class RoutingSettings(_View):
|
||||
"""Định tuyến model tự động (``core/routing/``)."""
|
||||
|
||||
@property
|
||||
def switch_mode(self) -> str:
|
||||
"""``"off"`` | ``"auto"`` | ``"manual"``."""
|
||||
return str(self._get("switch_mode", "off"))
|
||||
|
||||
@switch_mode.setter
|
||||
def switch_mode(self, value: str) -> None:
|
||||
self._d["switch_mode"] = value
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self.switch_mode != "off"
|
||||
|
||||
@property
|
||||
def policy(self) -> str:
|
||||
"""``"balanced"`` | ``"cheap"`` | ``"quality"``…"""
|
||||
return str(self._get("policy", "balanced"))
|
||||
|
||||
@property
|
||||
def min_score_gain(self) -> float:
|
||||
"""Phải hơn model hiện tại bao nhiêu điểm mới đáng đổi."""
|
||||
return float(self._get("min_score_gain", 0.05))
|
||||
|
||||
@property
|
||||
def confirm_timeout_sec(self) -> int:
|
||||
"""GIÂY, không phải mili giây — đọc tên là biết, khỏi phải mò."""
|
||||
return int(self._get("confirm_timeout_sec", 60))
|
||||
|
||||
@property
|
||||
def reassess_interval_hours(self) -> int:
|
||||
return int(self._get("reassess_interval_hours", 24))
|
||||
|
||||
@property
|
||||
def per_provider_concurrency(self) -> int:
|
||||
return int(self._get("per_provider_concurrency", 2))
|
||||
|
||||
@property
|
||||
def judge_provider(self) -> str:
|
||||
return str(self._get("judge_provider", ""))
|
||||
|
||||
@property
|
||||
def judge_model(self) -> str:
|
||||
return str(self._get("judge_model", ""))
|
||||
|
||||
|
||||
class SecuritySettings(_View):
|
||||
"""Chính sách an toàn cho agent (``core/agent_security.py``)."""
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self._get("enabled", True))
|
||||
|
||||
@property
|
||||
def validate_prompt(self) -> bool:
|
||||
return bool(self._get("validate_prompt", True))
|
||||
|
||||
@property
|
||||
def validate_attachments(self) -> bool:
|
||||
return bool(self._get("validate_attachments", True))
|
||||
|
||||
@property
|
||||
def validate_commands(self) -> bool:
|
||||
return bool(self._get("validate_commands", True))
|
||||
|
||||
@property
|
||||
def command_ai_check(self) -> bool:
|
||||
return bool(self._get("command_ai_check", False))
|
||||
|
||||
@property
|
||||
def cowork_confirm_commands(self) -> bool:
|
||||
"""Có hỏi trước khi chạy lệnh không.
|
||||
|
||||
Ứng với ``PolicyOutcome.ASK`` trong
|
||||
``domain/security/tool_policy.py``.
|
||||
"""
|
||||
return bool(self._get("cowork_confirm_commands", True))
|
||||
|
||||
@property
|
||||
def rules_onedrive_url(self) -> str:
|
||||
return str(self._get("rules_onedrive_url", ""))
|
||||
|
||||
@property
|
||||
def admin_email(self) -> str:
|
||||
return str(self._get("admin_email", ""))
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Cửa vào duy nhất cho các nhóm cấu hình có kiểu.
|
||||
|
||||
>>> s = Settings(repo)
|
||||
>>> if s.routing.enabled and s.provider().configured:
|
||||
... ...
|
||||
"""
|
||||
|
||||
def __init__(self, repo):
|
||||
self._repo = repo
|
||||
|
||||
def provider(self, name: str | None = None) -> ProviderSettings:
|
||||
return ProviderSettings(self._repo.provider_conf(name))
|
||||
|
||||
@property
|
||||
def routing(self) -> RoutingSettings:
|
||||
return RoutingSettings(self._repo.routing)
|
||||
|
||||
@property
|
||||
def security(self) -> SecuritySettings:
|
||||
return SecuritySettings(self._repo.agent_security)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Ghi JSON kiểu không-hỏng-file — R02-T01.
|
||||
|
||||
Vấn đề đang có: ``config.py::save()`` gọi thẳng ``path.write_text(...)``. Hàm
|
||||
đó mở file, cắt cụt về 0 byte, rồi mới ghi nội dung mới. Mất điện, tắt máy, hay
|
||||
process bị kill đúng khoảng giữa thì file cấu hình còn lại **rỗng hoặc ghi dở**
|
||||
— và người dùng mất toàn bộ cấu hình.
|
||||
|
||||
Cách làm ở đây theo đúng thứ tự bắt buộc:
|
||||
|
||||
1. Ghi vào file tạm cùng thư mục (phải cùng ổ đĩa thì bước 3 mới nguyên tử)
|
||||
2. ``flush()`` + ``os.fsync()`` — ép dữ liệu xuống đĩa thật, không nằm trong
|
||||
bộ đệm của hệ điều hành
|
||||
3. ``os.replace()`` — nguyên tử trên cả Windows lẫn POSIX
|
||||
|
||||
Bất kỳ lúc nào chết giữa chừng, file đích vẫn là **bản cũ nguyên vẹn**. Không
|
||||
bao giờ có trạng thái ghi dở.
|
||||
|
||||
Phần đọc có chính sách phục hồi: file hỏng thì giữ lại thành ``.bad`` để còn
|
||||
cứu tay, rồi trả về giá trị mặc định — hỏng cấu hình không được chặn khởi động,
|
||||
đúng như ``config.py`` hiện tại đang làm.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AtomicJsonFile:
|
||||
"""Một file JSON, đọc ghi an toàn.
|
||||
|
||||
>>> f = AtomicJsonFile(Path("cau_hinh.json"))
|
||||
>>> f.write({"theme": "dark"})
|
||||
>>> f.read(default={})
|
||||
{'theme': 'dark'}
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path, *, indent: int = 2):
|
||||
self.path = Path(path)
|
||||
self.indent = indent
|
||||
|
||||
# ---- đọc ------------------------------------------------------------
|
||||
def read(self, default: Any = None) -> Any:
|
||||
"""Nội dung file, hoặc ``default`` nếu chưa có / hỏng.
|
||||
|
||||
Không ném lỗi. File hỏng được đổi tên thành ``<tên>.bad-<thời điểm>``
|
||||
rồi mới trả mặc định — hỏng thì cứu được, chứ đừng ghi đè im lặng.
|
||||
"""
|
||||
if not self.path.exists():
|
||||
return default
|
||||
try:
|
||||
return json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
self._quarantine()
|
||||
return default
|
||||
except OSError:
|
||||
# Không đọc được (khoá file, mất quyền) — KHÔNG cách ly, vì file
|
||||
# có thể vẫn tốt nguyên.
|
||||
return default
|
||||
|
||||
def _quarantine(self) -> Path | None:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
target = self.path.with_suffix(self.path.suffix + f".bad-{stamp}")
|
||||
try:
|
||||
os.replace(self.path, target)
|
||||
return target
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
# ---- ghi ------------------------------------------------------------
|
||||
def write(self, data: Any) -> None:
|
||||
"""Ghi ``data``. Hoặc thành công trọn vẹn, hoặc file cũ còn nguyên."""
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
text = json.dumps(data, indent=self.indent, ensure_ascii=False)
|
||||
|
||||
# File tạm phải nằm CÙNG thư mục: os.replace chỉ nguyên tử trong cùng
|
||||
# một hệ thống tệp. Để ở %TEMP% là có thể rơi sang ổ khác và biến
|
||||
# thành copy + delete — mất luôn tính nguyên tử.
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(self.path.parent), prefix=f".{self.path.name}.", suffix=".tmp")
|
||||
tmp = Path(tmp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
f.flush()
|
||||
os.fsync(f.fileno()) # xuống đĩa thật, không chỉ vào bộ đệm
|
||||
os.replace(tmp, self.path) # nguyên tử
|
||||
except BaseException:
|
||||
# Kể cả KeyboardInterrupt/SystemExit cũng phải dọn file tạm, đừng
|
||||
# để rác .tmp nằm lại cạnh file cấu hình.
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
# ---- tiện ích -------------------------------------------------------
|
||||
def exists(self) -> bool:
|
||||
return self.path.exists()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"AtomicJsonFile({self.path})"
|
||||
@@ -0,0 +1,86 @@
|
||||
"""SecretStore chạy trên OS Keyring — R02-T04.
|
||||
|
||||
Windows dùng Credential Manager, macOS dùng Keychain, Linux dùng Secret
|
||||
Service. Người dùng cuối không thấy gì khác, nhưng API key thôi nằm trong
|
||||
``config.json`` — đó là điều kiện để qua CASAN Check 1.
|
||||
|
||||
Không phải máy nào cũng có keyring dùng được: Linux chạy headless không có
|
||||
Secret Service, và CI thì gần như chắc chắn không. Nên adapter này **không bao
|
||||
giờ ném lỗi** — không dùng được thì tự báo ``available = False`` và trả về
|
||||
None, để tầng trên hiển thị "chưa lưu được khoá" thay vì sập cả app.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
#: Tên "dịch vụ" trong keyring — mọi khoá của app nằm dưới đây.
|
||||
SERVICE = "cowork-local"
|
||||
|
||||
|
||||
class KeyringAdapter:
|
||||
"""Cài đặt :class:`SecretStore` bằng thư viện ``keyring``.
|
||||
|
||||
>>> store = KeyringAdapter()
|
||||
>>> if store.available:
|
||||
... store.set("provider:openai", "sk-...")
|
||||
"""
|
||||
|
||||
def __init__(self, service: str = SERVICE):
|
||||
self.service = service
|
||||
self._backend = None
|
||||
self._available = False
|
||||
try:
|
||||
import keyring
|
||||
from keyring.backends.fail import Keyring as FailKeyring
|
||||
|
||||
backend = keyring.get_keyring()
|
||||
# backend "fail" là cái keyring trả về khi không tìm được kho nào
|
||||
# dùng được — gọi vào chỉ tổ ném lỗi.
|
||||
if not isinstance(backend, FailKeyring):
|
||||
self._backend = keyring
|
||||
self._available = True
|
||||
else:
|
||||
log.info("keyring không có kho khả dụng trên máy này")
|
||||
except Exception as exc: # noqa: BLE001 — thiếu thư viện, thiếu DBus…
|
||||
log.info("keyring không dùng được: %s", exc)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Có kho bí mật dùng được không.
|
||||
|
||||
Tầng giao diện đọc cờ này để nói cho người dùng biết vì sao ô API key
|
||||
không lưu được, thay vì im lặng làm mất khoá họ vừa nhập.
|
||||
"""
|
||||
return self._available
|
||||
|
||||
# ---- SecretStore ----------------------------------------------------
|
||||
def get(self, key: str) -> str | None:
|
||||
if not self._available:
|
||||
return None
|
||||
try:
|
||||
return self._backend.get_password(self.service, key)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("đọc khoá %r thất bại: %s", key, exc)
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: str) -> None:
|
||||
if not self._available:
|
||||
log.warning("không lưu được %r: máy này không có kho bí mật", key)
|
||||
return
|
||||
try:
|
||||
self._backend.set_password(self.service, key, value)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("lưu khoá %r thất bại: %s", key, exc)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
if not self._available:
|
||||
return
|
||||
try:
|
||||
self._backend.delete_password(self.service, key)
|
||||
except Exception: # noqa: BLE001 — xoá cái không có: bỏ qua
|
||||
pass
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
return self.get(key) is not None
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Nơi cất credential — interface, chưa phải cài đặt.
|
||||
|
||||
Hợp đồng số 1 của mục chung: chốt hôm nay để N2 và N3 code được ngay, không
|
||||
phải đợi bản Keyring thật (R02-T04, hạn 26/08).
|
||||
|
||||
Vì sao là interface chứ không phải hàm tiện ích: bản thật sẽ gọi OS Keyring —
|
||||
chậm, có thể ném lỗi, và trong test thì không được đụng vào keyring máy thật.
|
||||
Có interface thì test tiêm ``FakeSecretStore`` vào, chạy trong bộ nhớ.
|
||||
|
||||
Quy ước đặt key: ``"provider:<tên>"`` cho API key của provider, ví dụ
|
||||
``"provider:openai"``. Đặt sẵn để không mỗi người tự nghĩ một kiểu.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
def provider_key(name: str) -> str:
|
||||
"""Key chuẩn cho API key của một provider."""
|
||||
return f"provider:{name}"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SecretStore(Protocol):
|
||||
"""Đọc/ghi bí mật. Cài đặt thật: ``KeyringAdapter`` (R02-T04)."""
|
||||
|
||||
def get(self, key: str) -> str | None:
|
||||
"""Giá trị của ``key``, hoặc None nếu chưa có.
|
||||
|
||||
Không được ném lỗi khi thiếu key — thiếu là chuyện bình thường (người
|
||||
dùng chưa nhập API key), không phải sự cố.
|
||||
"""
|
||||
...
|
||||
|
||||
def set(self, key: str, value: str) -> None:
|
||||
"""Lưu ``value``. Ghi đè nếu key đã tồn tại."""
|
||||
...
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""Xoá ``key``. Không có sẵn thì im lặng bỏ qua, không ném lỗi."""
|
||||
...
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
"""Có key này chưa — dùng cho màn Cài đặt hiển thị trạng thái mà không
|
||||
cần đọc chính giá trị bí mật ra."""
|
||||
...
|
||||
@@ -0,0 +1 @@
|
||||
"""presentation/ — Widget Qt. Chỉ gọi xuống application, không gọi thẳng infrastructure."""
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
"""Test double dùng chung cho cả 3 team — không phụ thuộc Qt."""
|
||||
@@ -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
|
||||
@@ -0,0 +1,44 @@
|
||||
"""ToolPolicyGateway giả — để N3 (Co4E) chạy được khi Team Hoa chưa cài đặt.
|
||||
|
||||
Mặc định cho qua hết, vì phần lớn test Co4E quan tâm tới luồng workflow chứ
|
||||
không phải chính sách. Test nào cần kiểm nhánh bị chặn thì lập trình câu trả
|
||||
lời::
|
||||
|
||||
gate = FakeToolPolicyGateway(rules={"run_command": deny("cấm trong Co4E")})
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Dict
|
||||
|
||||
from cowork_local.domain.security.tool_policy import (
|
||||
PolicyDecision, ToolCallRequest, allow,
|
||||
)
|
||||
|
||||
|
||||
class FakeToolPolicyGateway:
|
||||
"""Cổng chính sách trong bộ nhớ, có ghi lại đã hỏi những gì."""
|
||||
|
||||
def __init__(self, rules: Dict[str, PolicyDecision] | None = None,
|
||||
default: PolicyDecision | None = None,
|
||||
decide: Callable[[ToolCallRequest], PolicyDecision] | None = None):
|
||||
#: {tên tool: quyết định} — tra trước default
|
||||
self.rules = dict(rules or {})
|
||||
self.default = default or allow()
|
||||
#: hàm tự quyết, dùng khi cần logic phức tạp hơn tra bảng
|
||||
self._decide = decide
|
||||
#: mọi lời gọi đã đi qua — để test khẳng định "có hỏi cổng không"
|
||||
self.seen: list[ToolCallRequest] = []
|
||||
|
||||
def check(self, request: ToolCallRequest) -> PolicyDecision:
|
||||
self.seen.append(request)
|
||||
if self._decide is not None:
|
||||
return self._decide(request)
|
||||
return self.rules.get(request.name, self.default)
|
||||
|
||||
# ---- tiện cho test --------------------------------------------------
|
||||
def asked_for(self, name: str) -> bool:
|
||||
return any(r.name == name for r in self.seen)
|
||||
|
||||
@property
|
||||
def call_count(self) -> int:
|
||||
return len(self.seen)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""AtomicJsonFile — R02-T01. Test tiêm lỗi, đúng như cột nghiệm thu của plan.md.
|
||||
|
||||
Cách kiểm: cắt ngang giữa lúc ghi rồi khẳng định file cũ **còn nguyên**. Nếu
|
||||
chỉ test "ghi rồi đọc lại thấy đúng" thì `path.write_text()` cũ cũng qua — mà
|
||||
đó chính là thứ ta đang thay.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.infrastructure.persistence.json.atomic_json_file import AtomicJsonFile
|
||||
|
||||
|
||||
def test_ghi_roi_doc_lai(tmp_path):
|
||||
f = AtomicJsonFile(tmp_path / "cau_hinh.json")
|
||||
f.write({"theme": "dark", "ngôn ngữ": "vi"})
|
||||
assert f.read() == {"theme": "dark", "ngôn ngữ": "vi"}
|
||||
|
||||
|
||||
def test_chua_co_file_thi_tra_mac_dinh(tmp_path):
|
||||
f = AtomicJsonFile(tmp_path / "chua-ton-tai.json")
|
||||
assert f.read(default={"theme": "dark"}) == {"theme": "dark"}
|
||||
assert f.exists() is False
|
||||
|
||||
|
||||
def test_chet_giua_luc_ghi_thi_file_cu_con_nguyen(tmp_path, monkeypatch):
|
||||
"""Lõi của R02-T01.
|
||||
|
||||
Giả lập mất điện đúng lúc: cho ``os.replace`` ném lỗi. Đây là bước cuối
|
||||
cùng, tức là dữ liệu mới đã nằm trong file tạm rồi — nếu cài đặt sai theo
|
||||
kiểu ghi đè thẳng, file đích lúc này đã hỏng.
|
||||
"""
|
||||
path = tmp_path / "cau_hinh.json"
|
||||
f = AtomicJsonFile(path)
|
||||
f.write({"phiên bản": 1, "quan trọng": "đừng mất"})
|
||||
|
||||
def no_dien(*args, **kwargs):
|
||||
raise OSError("mô phỏng mất điện")
|
||||
|
||||
monkeypatch.setattr(os, "replace", no_dien)
|
||||
with pytest.raises(OSError):
|
||||
f.write({"phiên bản": 2})
|
||||
|
||||
# bản cũ phải còn y nguyên
|
||||
assert f.read() == {"phiên bản": 1, "quan trọng": "đừng mất"}
|
||||
|
||||
|
||||
def test_khong_de_lai_rac_tmp_khi_ghi_hong(tmp_path, monkeypatch):
|
||||
path = tmp_path / "cau_hinh.json"
|
||||
f = AtomicJsonFile(path)
|
||||
f.write({"a": 1})
|
||||
|
||||
monkeypatch.setattr(os, "replace", lambda *a, **k: (_ for _ in ()).throw(OSError("x")))
|
||||
with pytest.raises(OSError):
|
||||
f.write({"a": 2})
|
||||
|
||||
con_lai = [p.name for p in tmp_path.iterdir()]
|
||||
assert con_lai == ["cau_hinh.json"], f"còn rác: {con_lai}"
|
||||
|
||||
|
||||
def test_file_hong_thi_cach_ly_va_tra_mac_dinh(tmp_path):
|
||||
"""Hỏng cấu hình không được chặn khởi động — giữ đúng hành vi config.py
|
||||
hiện tại, nhưng thêm phần giữ lại bản hỏng để còn cứu."""
|
||||
path = tmp_path / "cau_hinh.json"
|
||||
path.write_text("{ đây không phải json", encoding="utf-8")
|
||||
f = AtomicJsonFile(path)
|
||||
|
||||
assert f.read(default={"theme": "dark"}) == {"theme": "dark"}
|
||||
assert not path.exists(), "file hỏng phải được dời đi"
|
||||
bad = list(tmp_path.glob("*.bad-*"))
|
||||
assert len(bad) == 1, "phải giữ lại bản hỏng để cứu tay"
|
||||
assert "đây không phải json" in bad[0].read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_ghi_de_nhieu_lan_van_dung(tmp_path):
|
||||
f = AtomicJsonFile(tmp_path / "dem.json")
|
||||
for i in range(20):
|
||||
f.write({"lần": i})
|
||||
assert f.read() == {"lần": 19}
|
||||
assert list(tmp_path.glob("*.tmp")) == []
|
||||
|
||||
|
||||
def test_giu_nguyen_tieng_viet_khong_escape(tmp_path):
|
||||
"""config.py hiện dùng ensure_ascii=False — giữ nguyên để file đọc được
|
||||
bằng mắt và git diff không thành một đống \\uXXXX."""
|
||||
path = tmp_path / "vi.json"
|
||||
AtomicJsonFile(path).write({"tên": "Nguyễn Văn Đức"})
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
assert "Nguyễn Văn Đức" in raw
|
||||
assert "\\u" not in raw
|
||||
|
||||
|
||||
def test_tao_thu_muc_cha_neu_chua_co(tmp_path):
|
||||
f = AtomicJsonFile(tmp_path / "sâu" / "hơn" / "nữa" / "c.json")
|
||||
f.write({"ok": True})
|
||||
assert f.read() == {"ok": True}
|
||||
|
||||
|
||||
def test_json_ghi_ra_doc_duoc_bang_thu_vien_chuan(tmp_path):
|
||||
path = tmp_path / "c.json"
|
||||
AtomicJsonFile(path).write({"n": [1, 2, {"m": None}]})
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"n": [1, 2, {"m": None}]}
|
||||
@@ -0,0 +1,155 @@
|
||||
"""JsonConfigRepository — R02-T02.
|
||||
|
||||
Hai nhóm bài:
|
||||
* **round-trip** — ghi rồi nạp lại phải ra đúng thứ đã ghi (cột nghiệm thu
|
||||
của plan.md cho ngày 22-23/08)
|
||||
* **đường A** — ``provider_conf()`` vẫn trả ``api_key``, nhưng file JSON
|
||||
trên đĩa thì không có, để qua CASAN Check 1
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.infrastructure.config.config_repository import ConfigRepository
|
||||
from cowork_local.infrastructure.config.json_config_repository import (
|
||||
JsonConfigRepository,
|
||||
)
|
||||
from cowork_local.tests.fakes.fake_config import FakeSecretStore
|
||||
|
||||
DEFAULTS = {
|
||||
"active_provider": "ollama",
|
||||
"providers": {
|
||||
"ollama": {"base_url": "http://localhost:11434/v1", "model": "llama3",
|
||||
"api_key": "ollama"},
|
||||
"openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini",
|
||||
"api_key": ""},
|
||||
},
|
||||
"theme": "dark", "language": "vi", "shared_dir": "",
|
||||
"routing": {"mode": "off"}, "auth": {}, "agent_security": {},
|
||||
"tools_disabled": [], "history": {}, "cowork": {}, "ms365": {},
|
||||
}
|
||||
|
||||
|
||||
def _repo(tmp_path, secrets=None):
|
||||
return JsonConfigRepository(tmp_path / "config.json", secrets=secrets,
|
||||
defaults=DEFAULTS, env_overrides=lambda d: d)
|
||||
|
||||
|
||||
def test_khop_hop_dong(tmp_path):
|
||||
assert isinstance(_repo(tmp_path), ConfigRepository)
|
||||
|
||||
|
||||
def test_chua_co_file_thi_dung_mac_dinh(tmp_path):
|
||||
cfg = _repo(tmp_path)
|
||||
assert cfg.active_provider == "ollama"
|
||||
assert cfg.theme == "dark"
|
||||
|
||||
|
||||
def test_round_trip(tmp_path):
|
||||
cfg = _repo(tmp_path)
|
||||
cfg.set_theme("light")
|
||||
cfg.set_language("en")
|
||||
cfg.set_active_provider("openai")
|
||||
cfg.set_tool_enabled("run_command", False)
|
||||
cfg.save()
|
||||
|
||||
lai = _repo(tmp_path)
|
||||
assert lai.theme == "light"
|
||||
assert lai.language == "en"
|
||||
assert lai.active_provider == "openai"
|
||||
assert lai.tools_disabled == ["run_command"]
|
||||
|
||||
|
||||
def test_gia_tri_luu_trong_file_trum_len_mac_dinh_nhung_giu_phan_con_thieu(tmp_path):
|
||||
"""Trộn sâu: file cũ thiếu khoá mới thì lấy mặc định, không mất phần cũ."""
|
||||
(tmp_path / "config.json").write_text(
|
||||
json.dumps({"theme": "light", "providers": {"openai": {"model": "gpt-5"}}}),
|
||||
encoding="utf-8")
|
||||
cfg = _repo(tmp_path)
|
||||
assert cfg.theme == "light" # từ file
|
||||
assert cfg.language == "vi" # từ mặc định
|
||||
assert cfg.provider_conf("openai")["model"] == "gpt-5" # từ file
|
||||
assert "api.openai.com" in cfg.provider_conf("openai")["base_url"] # mặc định
|
||||
|
||||
|
||||
# ---- đường A: khoá vào kho bí mật, nhưng dict vẫn có ------------------------
|
||||
|
||||
def test_provider_conf_van_tra_api_key_sau_khi_chuyen_vao_kho(tmp_path):
|
||||
"""Điểm mấu chốt của quyết định A: 5 nơi đọc conf['api_key'] không đổi."""
|
||||
secrets = FakeSecretStore()
|
||||
cfg = _repo(tmp_path, secrets)
|
||||
cfg.set_api_key("openai", "sk-that-bi-mat")
|
||||
|
||||
assert cfg.provider_conf("openai")["api_key"] == "sk-that-bi-mat"
|
||||
|
||||
|
||||
def test_khoa_khong_bao_gio_nam_tren_dia(tmp_path):
|
||||
"""Điều kiện qua CASAN Check 1."""
|
||||
secrets = FakeSecretStore()
|
||||
cfg = _repo(tmp_path, secrets)
|
||||
cfg.set_api_key("openai", "sk-that-bi-mat")
|
||||
cfg.save()
|
||||
|
||||
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
|
||||
assert "sk-that-bi-mat" not in raw
|
||||
assert secrets.get("provider:openai") == "sk-that-bi-mat"
|
||||
|
||||
|
||||
def test_sua_dict_tra_ve_khong_lam_ban_cau_hinh(tmp_path):
|
||||
"""provider_conf trả bản sao — nếu trả tham chiếu thì khoá vừa ghép vào sẽ
|
||||
lẫn ngược vào self.data rồi theo save() xuống đĩa."""
|
||||
secrets = FakeSecretStore()
|
||||
cfg = _repo(tmp_path, secrets)
|
||||
cfg.set_api_key("openai", "sk-bi-mat")
|
||||
|
||||
conf = cfg.provider_conf("openai")
|
||||
conf["model"] = "bị sửa bậy"
|
||||
cfg.save()
|
||||
|
||||
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
|
||||
assert "bị sửa bậy" not in raw
|
||||
assert "sk-bi-mat" not in raw
|
||||
|
||||
|
||||
def test_khong_co_kho_bi_mat_thi_van_chay_nhu_cu(tmp_path):
|
||||
"""Máy không có keyring: hành vi lùi về đúng như config.py hôm nay."""
|
||||
cfg = _repo(tmp_path, secrets=None)
|
||||
cfg.set_api_key("openai", "sk-nam-trong-file")
|
||||
cfg.save()
|
||||
|
||||
assert cfg.provider_conf("openai")["api_key"] == "sk-nam-trong-file"
|
||||
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
|
||||
assert "sk-nam-trong-file" in raw # đúng như cũ, có đánh đổi rõ ràng
|
||||
|
||||
|
||||
# ---- giữ nguyên hành vi cũ --------------------------------------------------
|
||||
|
||||
def test_ms365_unlocked_khong_bao_gio_xuong_dia(tmp_path):
|
||||
cfg = _repo(tmp_path)
|
||||
cfg.data["ms365"]["unlocked"] = True
|
||||
cfg.save()
|
||||
|
||||
raw = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
|
||||
assert raw["ms365"]["unlocked"] is False
|
||||
assert cfg.data["ms365"]["unlocked"] is True # trong bộ nhớ vẫn giữ
|
||||
|
||||
assert _repo(tmp_path).data["ms365"]["unlocked"] is False
|
||||
|
||||
|
||||
def test_ghi_hong_giua_chung_khong_lam_mat_cau_hinh(tmp_path, monkeypatch):
|
||||
"""Thừa hưởng từ AtomicJsonFile — kiểm lại ở tầng này cho chắc."""
|
||||
import os
|
||||
|
||||
cfg = _repo(tmp_path)
|
||||
cfg.set_theme("light")
|
||||
cfg.save()
|
||||
|
||||
monkeypatch.setattr(os, "replace",
|
||||
lambda *a, **k: (_ for _ in ()).throw(OSError("mất điện")))
|
||||
cfg.set_theme("hỏng")
|
||||
with pytest.raises(OSError):
|
||||
cfg.save()
|
||||
|
||||
assert _repo(tmp_path).theme == "light"
|
||||
@@ -0,0 +1,143 @@
|
||||
"""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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ToolPolicyGateway — bản đề xuất Gamma viết hộ, chờ Team Hoa xác nhận.
|
||||
# N3 (Co4E) code dựa vào đây từ hôm nay thay vì tự phỏng đoán.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from cowork_local.domain.security.tool_policy import ( # noqa: E402
|
||||
PolicyOutcome, ToolCallRequest, ToolPolicyGateway, allow, ask, deny,
|
||||
)
|
||||
from cowork_local.tests.fakes.fake_tool_policy import ( # noqa: E402
|
||||
FakeToolPolicyGateway,
|
||||
)
|
||||
|
||||
|
||||
def test_fake_gateway_khop_hop_dong():
|
||||
assert isinstance(FakeToolPolicyGateway(), ToolPolicyGateway)
|
||||
|
||||
|
||||
def test_mac_dinh_cho_qua_va_co_ghi_lai_da_hoi():
|
||||
gate = FakeToolPolicyGateway()
|
||||
d = gate.check(ToolCallRequest(name="read_file", surface="co4e"))
|
||||
assert d.outcome is PolicyOutcome.ALLOW
|
||||
assert d.allowed is True
|
||||
assert gate.asked_for("read_file")
|
||||
assert gate.call_count == 1
|
||||
|
||||
|
||||
def test_chan_theo_ten_tool():
|
||||
gate = FakeToolPolicyGateway(rules={"run_command": deny("cấm trong Co4E")})
|
||||
assert gate.check(ToolCallRequest(name="run_command")).outcome is PolicyOutcome.DENY
|
||||
assert gate.check(ToolCallRequest(name="read_file")).allowed is True
|
||||
|
||||
|
||||
def test_ask_khong_phai_la_duoc_phep():
|
||||
"""Bẫy dễ mắc nhất: coi ASK như ALLOW thì tool chạy mà chưa ai đồng ý."""
|
||||
d = ask("cần người dùng xác nhận")
|
||||
assert d.outcome is PolicyOutcome.ASK
|
||||
assert d.allowed is False
|
||||
|
||||
|
||||
def test_deny_va_ask_bat_buoc_co_ly_do():
|
||||
"""Người dùng phải biết vì sao bị chặn, và audit log cần ghi lại."""
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
deny("")
|
||||
with pytest.raises(ValueError):
|
||||
ask("")
|
||||
allow() # ALLOW thì không cần lý do
|
||||
|
||||
|
||||
def test_chinh_sach_khac_nhau_theo_man():
|
||||
"""Co4E chạy nền nên không bật được hộp thoại — chặn thẳng thay vì hỏi."""
|
||||
def by_surface(req: ToolCallRequest):
|
||||
if req.surface == "co4e" and req.name == "run_command":
|
||||
return deny("Co4E chạy nền, không hỏi được người dùng")
|
||||
return ask("cần xác nhận") if req.name == "run_command" else allow()
|
||||
|
||||
gate = FakeToolPolicyGateway(decide=by_surface)
|
||||
assert gate.check(ToolCallRequest("run_command", surface="co4e")).outcome is PolicyOutcome.DENY
|
||||
assert gate.check(ToolCallRequest("run_command", surface="cowork")).outcome is PolicyOutcome.ASK
|
||||
@@ -0,0 +1,93 @@
|
||||
"""KeyringAdapter — R02-T04.
|
||||
|
||||
Không đụng vào keyring thật của máy chạy test: tiêm một backend giả. Test mà
|
||||
ghi vào Credential Manager thật thì để lại rác trên máy người khác, và trên CI
|
||||
thì không có kho nào để ghi.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.infrastructure.secrets.keyring_adapter import KeyringAdapter
|
||||
from cowork_local.infrastructure.secrets.secret_store import SecretStore, provider_key
|
||||
|
||||
|
||||
class _KeyringGia:
|
||||
"""Đủ giống thư viện keyring để adapter dùng được."""
|
||||
|
||||
def __init__(self, hong: bool = False):
|
||||
self.kho: dict[tuple[str, str], str] = {}
|
||||
self.hong = hong
|
||||
|
||||
def get_password(self, service, key):
|
||||
if self.hong:
|
||||
raise RuntimeError("kho bí mật không phản hồi")
|
||||
return self.kho.get((service, key))
|
||||
|
||||
def set_password(self, service, key, value):
|
||||
if self.hong:
|
||||
raise RuntimeError("kho bí mật không phản hồi")
|
||||
self.kho[(service, key)] = value
|
||||
|
||||
def delete_password(self, service, key):
|
||||
if self.hong:
|
||||
raise RuntimeError("kho bí mật không phản hồi")
|
||||
del self.kho[(service, key)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store():
|
||||
a = KeyringAdapter(service="test-cowork")
|
||||
a._backend = _KeyringGia()
|
||||
a._available = True
|
||||
return a
|
||||
|
||||
|
||||
def test_khop_hop_dong_secret_store(store):
|
||||
assert isinstance(store, SecretStore)
|
||||
|
||||
|
||||
def test_luu_doc_xoa(store):
|
||||
k = provider_key("openai")
|
||||
assert store.get(k) is None
|
||||
assert store.has(k) is False
|
||||
|
||||
store.set(k, "sk-that-la-bi-mat")
|
||||
assert store.get(k) == "sk-that-la-bi-mat"
|
||||
assert store.has(k) is True
|
||||
|
||||
store.delete(k)
|
||||
assert store.get(k) is None
|
||||
|
||||
|
||||
def test_moi_provider_mot_khoa_rieng(store):
|
||||
store.set(provider_key("openai"), "khoa-openai")
|
||||
store.set(provider_key("anthropic"), "khoa-anthropic")
|
||||
assert store.get(provider_key("openai")) == "khoa-openai"
|
||||
assert store.get(provider_key("anthropic")) == "khoa-anthropic"
|
||||
|
||||
|
||||
def test_may_khong_co_kho_thi_im_lang_chu_khong_sap():
|
||||
"""Linux headless và CI không có Secret Service. App vẫn phải chạy."""
|
||||
a = KeyringAdapter(service="test-cowork")
|
||||
a._backend = None
|
||||
a._available = False
|
||||
|
||||
assert a.available is False
|
||||
assert a.get("bat-ky") is None
|
||||
a.set("bat-ky", "gia-tri") # không ném lỗi
|
||||
a.delete("bat-ky") # không ném lỗi
|
||||
assert a.has("bat-ky") is False
|
||||
|
||||
|
||||
def test_kho_loi_giua_chung_thi_khong_lam_sap_app(store):
|
||||
"""Keyring có thể hỏng lúc đang chạy — mất DBus, người dùng khoá máy."""
|
||||
store._backend.hong = True
|
||||
|
||||
assert store.get("x") is None # nuốt lỗi, trả None
|
||||
store.set("x", "y") # nuốt lỗi
|
||||
store.delete("x") # nuốt lỗi
|
||||
|
||||
|
||||
def test_xoa_khoa_khong_ton_tai_thi_bo_qua(store):
|
||||
store.delete(provider_key("chua-bao-gio-luu")) # không ném lỗi
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Không file mã nguồn nào được nằm ngoài repo vì `.gitignore`.
|
||||
|
||||
Bài này sinh ra từ một lỗi thật, mất hai ngày mới lộ:
|
||||
|
||||
``.gitignore`` dòng 31 ghi ``secrets/`` — mẫu **không neo**, nên git bỏ qua
|
||||
mọi thư mục tên ``secrets`` ở mọi độ sâu, kể cả ``infrastructure/secrets/``
|
||||
vốn là **mã nguồn**. Ba file trong đó chưa bao giờ lên repo. Máy người viết
|
||||
vẫn chạy 150 test xanh, nhưng ai clone sạch về thì 4 file test đỏ ngay lúc
|
||||
thu thập.
|
||||
|
||||
Trên máy đã có file thì không cách nào nhận ra: ``pytest`` đọc đĩa, không đọc
|
||||
git. Nên phải hỏi thẳng git.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
#: Thư mục chứa mã nguồn của ứng dụng — file .py ở đây bắt buộc phải vào repo.
|
||||
SOURCE_DIRS = ["domain", "application", "infrastructure", "presentation",
|
||||
"adapters", "core", "ui", "providers", "scripts", "tools", "tests"]
|
||||
|
||||
|
||||
def _git(*args: str) -> str:
|
||||
out = subprocess.run(["git", *args], cwd=REPO, capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace")
|
||||
return out.stdout
|
||||
|
||||
|
||||
def test_khong_file_py_nao_bi_gitignore_nuot():
|
||||
"""File .py có trên đĩa nhưng git không thấy — vừa chưa theo dõi, vừa bị
|
||||
bỏ qua. Đó chính là hình dạng của lỗi ``secrets/``."""
|
||||
existing = []
|
||||
for d in SOURCE_DIRS:
|
||||
root = REPO / d
|
||||
if root.is_dir():
|
||||
existing.append(d)
|
||||
assert existing, "không thấy thư mục mã nguồn nào — kiểm lại SOURCE_DIRS"
|
||||
|
||||
ignored = _git("ls-files", "--others", "--ignored", "--exclude-standard",
|
||||
"--", *existing).splitlines()
|
||||
ignored_py = [p for p in ignored
|
||||
if p.endswith(".py") and "__pycache__" not in p]
|
||||
|
||||
assert not ignored_py, (
|
||||
"File mã nguồn bị .gitignore nuốt — clone sạch sẽ thiếu:\n "
|
||||
+ "\n ".join(ignored_py)
|
||||
+ "\nChạy `git check-ignore -v <file>` để biết dòng nào gây ra."
|
||||
)
|
||||
|
||||
|
||||
def test_khong_file_py_nao_bi_bo_quen_chua_theo_doi():
|
||||
"""Chưa bị ignore nhưng cũng chưa `git add` — quên, không phải cố ý."""
|
||||
untracked = _git("ls-files", "--others", "--exclude-standard").splitlines()
|
||||
forgotten = [p for p in untracked
|
||||
if p.endswith(".py")
|
||||
and p.split("/")[0] in SOURCE_DIRS
|
||||
and "__pycache__" not in p]
|
||||
|
||||
assert not forgotten, (
|
||||
"File mã nguồn chưa được git add — clone sạch sẽ thiếu:\n "
|
||||
+ "\n ".join(forgotten)
|
||||
)
|
||||
|
||||
|
||||
def test_moi_module_duoc_import_deu_co_trong_repo():
|
||||
"""Bắt theo hướng ngược: đi từ những gì code THỰC SỰ import.
|
||||
|
||||
Hai bài trên quét theo thư mục; bài này bắt cả trường hợp file nằm ngoài
|
||||
danh sách đó mà vẫn được import.
|
||||
"""
|
||||
tracked = set(_git("ls-files").splitlines())
|
||||
missing = []
|
||||
for d in ("domain", "application", "infrastructure", "adapters"):
|
||||
root = REPO / d
|
||||
if not root.is_dir():
|
||||
continue
|
||||
for f in root.rglob("*.py"):
|
||||
rel = f.relative_to(REPO).as_posix()
|
||||
if "__pycache__" in rel:
|
||||
continue
|
||||
if rel not in tracked:
|
||||
missing.append(rel)
|
||||
|
||||
assert not missing, (
|
||||
"Module thuộc kiến trúc mới nhưng không có trong repo:\n "
|
||||
+ "\n ".join(missing)
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Không thư mục nào ở gốc repo được trùng tên module thư viện chuẩn.
|
||||
|
||||
Bài này sinh ra từ một lỗi thật: kế hoạch refactor đặt tên một tầng là
|
||||
``platform/``, và ngay khi tạo thư mục đó thì mọi script chạy từ gốc repo —
|
||||
``python tools/check_*.py``, ``python scripts/audit_security.py``, 26 file tất
|
||||
cả — đều nạp nhầm ``platform/`` thay cho ``platform`` của Python. ``keyring``
|
||||
chết ngay với ``AttributeError: module 'platform' has no attribute 'system'``.
|
||||
|
||||
Kiểm bằng tên chứ không phải bằng cách thử import: import chỉ hỏng khi có ai
|
||||
đó thật sự dùng module bị che, nên nó im lặng cho tới lúc muộn.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
#: Không tính: đây là thư mục dữ liệu/tài liệu, không phải package Python.
|
||||
NOT_PACKAGES = {".git", ".gitea", ".vibeflow-preview", "docs", "assets",
|
||||
"__pycache__", ".pytest_cache", "cowork-local-gitea",
|
||||
".cowork_history", ".cowork_local"}
|
||||
|
||||
|
||||
def _top_level_packages() -> list[str]:
|
||||
return [d.name for d in REPO.iterdir()
|
||||
if d.is_dir() and d.name not in NOT_PACKAGES
|
||||
and (d / "__init__.py").exists()]
|
||||
|
||||
|
||||
def test_khong_package_nao_che_khuat_thu_vien_chuan():
|
||||
stdlib = set(sys.stdlib_module_names)
|
||||
clashes = [name for name in _top_level_packages() if name in stdlib]
|
||||
assert not clashes, (
|
||||
"Thư mục ở gốc repo trùng tên module thư viện chuẩn: "
|
||||
+ ", ".join(sorted(clashes))
|
||||
+ ". Chạy script từ gốc repo sẽ nạp nhầm thư mục này. Đổi tên thư mục."
|
||||
)
|
||||
|
||||
|
||||
def test_import_duoc_stdlib_khi_chay_tu_goc_repo():
|
||||
"""Bài trên bắt bằng tên; bài này bắt bằng hành vi thật.
|
||||
|
||||
Chạy tiến trình con với thư mục làm việc là gốc repo — đúng cách 26 script
|
||||
trong ``tools/`` và ``scripts/`` được gọi.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
snippet = (
|
||||
"import platform, json, types, io\n"
|
||||
"assert 'site-packages' not in platform.__file__\n"
|
||||
"assert platform.system(), 'platform.system() phải trả về tên hệ điều hành'\n"
|
||||
"import keyring\n"
|
||||
"print('OK')\n"
|
||||
)
|
||||
out = subprocess.run([sys.executable, "-c", snippet], cwd=REPO,
|
||||
capture_output=True, text=True, timeout=60)
|
||||
assert out.returncode == 0, out.stderr
|
||||
assert "OK" in out.stdout
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Đánh số phiên bản + chuyển API key — R02-T06 và R02-T05."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from cowork_local.infrastructure.config.json_config_repository import (
|
||||
JsonConfigRepository,
|
||||
)
|
||||
from cowork_local.infrastructure.config.schema_migration import (
|
||||
CURRENT_VERSION, migrate, read_version,
|
||||
)
|
||||
from cowork_local.tests.fakes.fake_config import FakeSecretStore
|
||||
|
||||
DEFAULTS = {
|
||||
"active_provider": "openai",
|
||||
"providers": {"openai": {"base_url": "u", "model": "m", "api_key": ""},
|
||||
"ollama": {"base_url": "u", "model": "m", "api_key": "ollama"}},
|
||||
"theme": "dark", "language": "vi", "ms365": {},
|
||||
}
|
||||
|
||||
|
||||
def _repo(tmp_path, secrets=None):
|
||||
return JsonConfigRepository(tmp_path / "config.json", secrets=secrets,
|
||||
defaults=DEFAULTS, env_overrides=lambda d: d)
|
||||
|
||||
|
||||
def test_thieu_so_phien_ban_thi_coi_la_v1():
|
||||
assert read_version({}) == 1
|
||||
assert read_version({"schema_version": 2}) == 2
|
||||
assert read_version({"schema_version": "hỏng"}) == 1
|
||||
|
||||
|
||||
def test_v1_sang_v2_chuyen_khoa_vao_kho_bi_mat():
|
||||
secrets = FakeSecretStore()
|
||||
data = {"providers": {"openai": {"api_key": "sk-cu-nam-trong-file"}}} # casan: allow - du lieu test
|
||||
|
||||
out, changed = migrate(data, secrets=secrets)
|
||||
|
||||
assert changed is True
|
||||
assert out["schema_version"] == 2
|
||||
assert out["providers"]["openai"]["api_key"] == ""
|
||||
assert secrets.get("provider:openai") == "sk-cu-nam-trong-file"
|
||||
|
||||
|
||||
def test_khong_day_gia_tri_bu_nhin_cua_ollama_vao_kho():
|
||||
"""Ollama đòi có api_key nhưng bỏ qua nội dung — đẩy vào keyring chỉ tổ rác."""
|
||||
secrets = FakeSecretStore()
|
||||
out, _ = migrate({"providers": {"ollama": {"api_key": "ollama"}}}, secrets=secrets)
|
||||
assert secrets.get("provider:ollama") is None
|
||||
assert out["providers"]["ollama"]["api_key"] == "ollama"
|
||||
|
||||
|
||||
def test_khong_co_kho_bi_mat_thi_KHONG_chuyen():
|
||||
"""Thà để khoá nằm nguyên trong file còn hơn xoá đi rồi người dùng mất
|
||||
khoá mà không hiểu vì sao."""
|
||||
data = {"providers": {"openai": {"api_key": "sk-quy-gia"}}}
|
||||
out, changed = migrate(data, secrets=None)
|
||||
|
||||
assert changed is False
|
||||
assert out["providers"]["openai"]["api_key"] == "sk-quy-gia"
|
||||
assert read_version(out) == 1 # giữ v1, lần sau có keyring sẽ chuyển
|
||||
|
||||
|
||||
def test_da_v2_thi_khong_lam_gi_them():
|
||||
out, changed = migrate({"schema_version": 2}, secrets=FakeSecretStore())
|
||||
assert changed is False
|
||||
|
||||
|
||||
def test_file_moi_hon_app_thi_dung_nguyen_trang():
|
||||
"""App cũ gặp file mới. Đoán ngược là cách nhanh nhất để mất dữ liệu."""
|
||||
data = {"schema_version": 99, "thu_gi_do_tuong_lai": True}
|
||||
out, changed = migrate(data, secrets=FakeSecretStore())
|
||||
assert changed is False
|
||||
assert out == data
|
||||
|
||||
|
||||
def test_sao_luu_truoc_khi_nang_cap(tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
path.write_text(json.dumps({"providers": {"openai": {"api_key": "sk-x"}}}),
|
||||
encoding="utf-8")
|
||||
|
||||
migrate(json.loads(path.read_text(encoding="utf-8")),
|
||||
secrets=FakeSecretStore(), path=path)
|
||||
|
||||
backups = list(tmp_path.glob("*.bak"))
|
||||
assert len(backups) == 1, "phải có bản sao lưu để còn đường lùi"
|
||||
assert "sk-x" in backups[0].read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---- nối vào repository ----------------------------------------------------
|
||||
|
||||
def test_repository_tu_chuyen_khoa_khi_mo_file_cu(tmp_path):
|
||||
"""Cảnh thật: người dùng cập nhật app, mở lên, khoá cũ tự vào keyring."""
|
||||
(tmp_path / "config.json").write_text(
|
||||
json.dumps({"providers": {"openai": {"api_key": "sk-tu-ban-cu"}}}), # casan: allow - du lieu test
|
||||
encoding="utf-8")
|
||||
|
||||
secrets = FakeSecretStore()
|
||||
cfg = _repo(tmp_path, secrets)
|
||||
|
||||
# đọc ra vẫn thấy khoá...
|
||||
assert cfg.provider_conf("openai")["api_key"] == "sk-tu-ban-cu"
|
||||
# ...nhưng trên đĩa thì hết
|
||||
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
|
||||
assert "sk-tu-ban-cu" not in raw
|
||||
assert json.loads(raw)["schema_version"] == CURRENT_VERSION
|
||||
# và có bản sao lưu
|
||||
assert len(list(tmp_path.glob("*.bak"))) == 1
|
||||
|
||||
|
||||
def test_mo_lai_lan_hai_khong_chuyen_lai(tmp_path):
|
||||
(tmp_path / "config.json").write_text(
|
||||
json.dumps({"providers": {"openai": {"api_key": "sk-x"}}}), encoding="utf-8")
|
||||
secrets = FakeSecretStore()
|
||||
_repo(tmp_path, secrets)
|
||||
so_ban_sao = len(list(tmp_path.glob("*.bak")))
|
||||
|
||||
_repo(tmp_path, secrets)
|
||||
assert len(list(tmp_path.glob("*.bak"))) == so_ban_sao, "không nâng cấp lại"
|
||||
|
||||
|
||||
def test_save_luon_ghi_so_phien_ban(tmp_path):
|
||||
cfg = _repo(tmp_path)
|
||||
cfg.save()
|
||||
raw = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
|
||||
assert raw["schema_version"] == CURRENT_VERSION
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Typed Settings Facade — R02-T03."""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.infrastructure.config.settings_facade import (
|
||||
ProviderSettings, RoutingSettings, SecuritySettings, Settings,
|
||||
)
|
||||
from cowork_local.tests.fakes.fake_config import FakeConfigRepository
|
||||
|
||||
|
||||
def test_provider_doc_duoc_ba_truong():
|
||||
p = ProviderSettings({"base_url": "http://x/v1", "model": "llama3",
|
||||
"api_key": "sk-abc"})
|
||||
assert p.base_url == "http://x/v1"
|
||||
assert p.model == "llama3"
|
||||
assert p.api_key == "sk-abc"
|
||||
assert p.configured is True
|
||||
|
||||
|
||||
def test_ollama_khong_can_khoa_van_tinh_la_da_cau_hinh():
|
||||
"""Điều kiện là có base_url và model, không phải có api_key — Ollama chạy
|
||||
cục bộ nên không cần khoá."""
|
||||
p = ProviderSettings({"base_url": "http://localhost:11434/v1", "model": "llama3"})
|
||||
assert p.api_key == ""
|
||||
assert p.configured is True
|
||||
|
||||
|
||||
def test_thieu_model_thi_chua_cau_hinh():
|
||||
assert ProviderSettings({"base_url": "http://x/v1"}).configured is False
|
||||
assert ProviderSettings({}).configured is False
|
||||
|
||||
|
||||
def test_gia_tri_None_tra_ve_mac_dinh_chu_khong_None():
|
||||
"""File cấu hình cũ hay có khoá để null. Đọc ra None rồi đem so sánh số là
|
||||
vỡ — nên khung nhìn phải nuốt luôn trường hợp này."""
|
||||
r = RoutingSettings({"switch_mode": None, "min_score_gain": None,
|
||||
"confirm_timeout_sec": None})
|
||||
assert r.switch_mode == "off"
|
||||
assert r.min_score_gain == 0.05
|
||||
assert r.confirm_timeout_sec == 60
|
||||
|
||||
|
||||
def test_routing_kieu_du_lieu_dung():
|
||||
r = RoutingSettings({"switch_mode": "auto", "min_score_gain": "0.2",
|
||||
"confirm_timeout_sec": "90"})
|
||||
assert r.enabled is True
|
||||
assert isinstance(r.min_score_gain, float) and r.min_score_gain == 0.2
|
||||
assert isinstance(r.confirm_timeout_sec, int) and r.confirm_timeout_sec == 90
|
||||
|
||||
|
||||
def test_tat_dinh_tuyen():
|
||||
assert RoutingSettings({"switch_mode": "off"}).enabled is False
|
||||
assert RoutingSettings({}).enabled is False
|
||||
|
||||
|
||||
def test_sua_qua_khung_nhin_la_sua_vao_dict_that():
|
||||
"""Khung nhìn, không phải bản sao — sửa xong gọi save() là xuống đĩa."""
|
||||
d = {"switch_mode": "off"}
|
||||
RoutingSettings(d).switch_mode = "auto"
|
||||
assert d["switch_mode"] == "auto"
|
||||
|
||||
|
||||
def test_raw_de_khong_ai_bi_ket():
|
||||
d = {"switch_mode": "auto", "khoa_chua_dua_vao_khung_nhin": 1}
|
||||
assert RoutingSettings(d).raw()["khoa_chua_dua_vao_khung_nhin"] == 1
|
||||
|
||||
|
||||
def test_security_mac_dinh_la_bat():
|
||||
"""Mặc định an toàn: thiếu cấu hình thì bật kiểm tra, không phải tắt."""
|
||||
s = SecuritySettings({})
|
||||
assert s.enabled is True
|
||||
assert s.validate_prompt is True
|
||||
assert s.validate_commands is True
|
||||
assert s.cowork_confirm_commands is True
|
||||
assert s.command_ai_check is False # trừ cái này: gọi AI, tốn tiền
|
||||
|
||||
|
||||
def test_settings_noi_vao_repo():
|
||||
repo = FakeConfigRepository(active_provider="openai",
|
||||
routing={"switch_mode": "auto"},
|
||||
agent_security={"cowork_confirm_commands": False})
|
||||
s = Settings(repo)
|
||||
assert s.provider().model == "gpt-4o-mini"
|
||||
assert s.routing.enabled is True
|
||||
assert s.security.cowork_confirm_commands is False
|
||||
|
||||
|
||||
def test_doi_provider_thi_khung_nhin_theo_ngay():
|
||||
repo = FakeConfigRepository(active_provider="ollama")
|
||||
s = Settings(repo)
|
||||
assert s.provider().model == "llama3"
|
||||
repo.set_active_provider("openai")
|
||||
assert s.provider().model == "gpt-4o-mini"
|
||||
Reference in New Issue
Block a user