Hồi quy đã vá
-------------
F-12 Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
`chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
`composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
gắn 1 tệp, khớp bản trước refactor.
F-01 Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
`folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
`ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
tiên hỏng thì đổi provider chính là lúc phải thử lại.
F-07 Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
`check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.
F-11 Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
và không bao giờ chạy.
Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:
graph_renderer.py -> graph_scene_builder.py + graph_export.py
co4e_workflow_service.py -> co4e_run_history.py
json_config_repository.py -> config_sections.py
agents_admin_tab.py -> shared/agent_kind_visuals.py
File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.
Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.
Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.
859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
192 lines
8.2 KiB
Python
192 lines
8.2 KiB
Python
"""Automatic recovery from self-signed / internal-CA TLS certificate errors.
|
|
|
|
Corporate gateways (an internal LLM proxy, for example) often present a
|
|
self-signed or internally-issued certificate that isn't in the OS/certifi
|
|
trust store — every outbound HTTPS call to it would otherwise fail with
|
|
``SSLCertVerificationError: self-signed certificate in certificate chain``.
|
|
|
|
Rather than asking the user to track down and browse to a ``.pem`` file in
|
|
Settings, this captures the EXACT certificate the server presents on first
|
|
contact (TOFU — trust on first use) and pins that specific certificate for
|
|
that host from then on. This is materially safer than disabling verification
|
|
globally: a different host (or a later attacker-in-the-middle presenting a
|
|
different certificate for the same host) still fails verification — only the
|
|
one certificate actually seen and saved for that host is trusted.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import socket
|
|
import ssl
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
from ..config import CONFIG_DIR
|
|
|
|
TRUST_DIR = CONFIG_DIR / "trusted_certs"
|
|
|
|
# Substrings (lowercased) that indicate a TLS TRUST-CHAIN failure we can
|
|
# plausibly recover from by pinning the server's own certificate — NOT other
|
|
# TLS errors (expired certificate, hostname mismatch, bad protocol version)
|
|
# where silently trusting a captured certificate could hide a real problem.
|
|
_TRUST_ERROR_MARKERS = (
|
|
"self-signed certificate",
|
|
"self signed certificate",
|
|
"unable to get local issuer certificate",
|
|
"certificate verify failed",
|
|
"unable to get issuer certificate",
|
|
)
|
|
|
|
|
|
def looks_like_cert_trust_error(exc: BaseException) -> bool:
|
|
"""True if ``exc`` (or any exception it wraps, via ``__cause__``/
|
|
``__context__``) is a TLS trust-chain failure."""
|
|
text_parts = []
|
|
seen = set()
|
|
cur: BaseException | None = exc
|
|
while cur is not None and id(cur) not in seen:
|
|
seen.add(id(cur))
|
|
text_parts.append(str(cur).lower())
|
|
cur = cur.__cause__ or cur.__context__
|
|
text = " ".join(text_parts)
|
|
return any(marker in text for marker in _TRUST_ERROR_MARKERS)
|
|
|
|
|
|
def _host_port(url: str) -> tuple[str, int]:
|
|
"""Cặp (host, port) rút từ URL; không có port thì mặc định 443."""
|
|
parsed = urlparse(url)
|
|
return parsed.hostname or "", parsed.port or 443
|
|
|
|
|
|
def _slug(host: str) -> str:
|
|
"""Tên file an toàn suy từ host."""
|
|
return re.sub(r"[^a-zA-Z0-9.-]", "_", host) or "host"
|
|
|
|
|
|
def trusted_cert_path(url: str) -> Path:
|
|
"""Đường dẫn file PEM ghim chứng chỉ cho một host.
|
|
|
|
Mỗi host một file: gateway nội bộ dùng chứng chỉ tự ký, ghim đúng chứng chỉ
|
|
đã thấy lần đầu (trust on first use) thay vì tắt kiểm chứng chỉ.
|
|
"""
|
|
host, _port = _host_port(url)
|
|
return TRUST_DIR / f"{_slug(host)}.pem"
|
|
|
|
|
|
def capture_and_trust(url: str, timeout: float = 10.0) -> str:
|
|
"""Fetch the certificate chain the server presents right now and save it
|
|
as a locally-trusted PEM for this exact host. Returns '' if the TCP/TLS
|
|
handshake itself couldn't even be attempted (host down, wrong port,
|
|
firewall...) — nothing to pin in that case."""
|
|
host, port = _host_port(url)
|
|
if not host:
|
|
return ""
|
|
try:
|
|
pem = ssl.get_server_certificate((host, port), timeout=timeout)
|
|
except (socket.error, ssl.SSLError, OSError):
|
|
return ""
|
|
TRUST_DIR.mkdir(parents=True, exist_ok=True)
|
|
path = trusted_cert_path(url)
|
|
path.write_text(pem, encoding="utf-8")
|
|
return str(path)
|
|
|
|
|
|
def verify_for(url: str, configured) -> object:
|
|
"""The ``requests`` ``verify=`` value for a call to ``url``: an
|
|
explicitly configured CA bundle (env var / advanced override) always
|
|
wins; otherwise a previously-pinned certificate for this host if one
|
|
exists; otherwise normal certifi verification (``True``)."""
|
|
if configured:
|
|
return configured
|
|
path = trusted_cert_path(url)
|
|
return str(path) if path.exists() else True
|
|
|
|
|
|
def request(method: str, url: str, ca_bundle=None, **kwargs):
|
|
"""Like ``requests.get``/``requests.post``/... (dispatched by ``method``),
|
|
with automatic self-signed/internal-CA recovery: if the server presents a
|
|
certificate that fails normal verification, this captures and pins that
|
|
EXACT certificate (trust on first use) and retries once — instead of the
|
|
call failing outright with ``SSLCertVerificationError``. Skipped when an
|
|
explicit CA bundle is already configured (a deliberate choice).
|
|
|
|
Used by every outbound HTTPS call in the app (LLM providers, fetch_url's
|
|
link fetcher, ...) so a corporate gateway/proxy that terminates TLS with
|
|
its own certificate doesn't silently break internet access everywhere
|
|
except the one call site that happened to handle it.
|
|
|
|
Dispatches via ``requests.<method>`` (not ``requests.request``) so
|
|
tests/callers that patch ``requests.get``/``requests.post`` directly keep
|
|
working."""
|
|
import requests
|
|
|
|
call = getattr(requests, method.lower())
|
|
kwargs["verify"] = verify_for(url, ca_bundle)
|
|
try:
|
|
return call(url, **kwargs)
|
|
except requests.exceptions.SSLError as exc:
|
|
if ca_bundle or not looks_like_cert_trust_error(exc):
|
|
raise
|
|
pinned = capture_and_trust(url)
|
|
if not pinned:
|
|
raise
|
|
kwargs["verify"] = pinned
|
|
return call(url, **kwargs)
|
|
|
|
|
|
def diagnose_internet(test_url: str = "https://www.google.com/generate_204",
|
|
timeout: float = 8.0) -> tuple[bool, str]:
|
|
"""Live check of the app's OWN outbound-HTTPS path (via :func:`request`, so
|
|
the self-signed/internal-CA recovery is exercised too). Returns
|
|
``(ok, human_message)`` and never raises — for a "Test Internet Access"
|
|
button so a user on a locked-down corporate network can see the CONCRETE
|
|
reason a fetch fails instead of a silent dead end."""
|
|
try:
|
|
import requests
|
|
except Exception as exc: # noqa: BLE001
|
|
return False, f"'requests' library unavailable: {exc}"
|
|
try:
|
|
resp = request("get", test_url, timeout=timeout)
|
|
pinned = trusted_cert_path(test_url).exists()
|
|
note = " (via a pinned corporate-gateway certificate)" if pinned else ""
|
|
return True, f"Internet reachable — HTTP {resp.status_code}{note}."
|
|
except requests.exceptions.SSLError as exc:
|
|
if looks_like_cert_trust_error(exc):
|
|
return False, ("TLS certificate not trusted and could not be captured "
|
|
"automatically. Your gateway may require a corporate root "
|
|
f"CA installed in Windows. Detail: {exc}")
|
|
return False, (f"TLS error (not an untrusted-CA case — e.g. expired cert / "
|
|
f"hostname mismatch): {exc}")
|
|
except requests.exceptions.ProxyError as exc:
|
|
return False, (f"Blocked by a proxy. The company gateway is refusing the "
|
|
f"connection: {exc}")
|
|
except requests.exceptions.ConnectTimeout as exc:
|
|
return False, (f"Connection timed out — a firewall/gateway is likely dropping "
|
|
f"outbound traffic: {exc}")
|
|
except requests.exceptions.ConnectionError as exc:
|
|
return False, (f"Could not connect — DNS block, firewall, or no route to the "
|
|
f"internet: {exc}")
|
|
except Exception as exc: # noqa: BLE001
|
|
return False, f"Internet test failed: {type(exc).__name__}: {exc}"
|
|
|
|
|
|
def request_any_method(method: str, url: str, ca_bundle=None, **kwargs):
|
|
"""Same TLS auto-recovery as :func:`request`, for a caller whose HTTP verb
|
|
is only known at runtime (e.g. a REST connector where the user configures
|
|
GET/POST/PUT/... per call). Dispatches via ``requests.request(method, url,
|
|
...)`` — the single generic entry point — rather than ``requests.<method>``,
|
|
so a caller/test that patches ``requests.request`` directly keeps working."""
|
|
import requests
|
|
|
|
kwargs["verify"] = verify_for(url, ca_bundle)
|
|
try:
|
|
return requests.request(method, url, **kwargs)
|
|
except requests.exceptions.SSLError as exc:
|
|
if ca_bundle or not looks_like_cert_trust_error(exc):
|
|
raise
|
|
pinned = capture_and_trust(url)
|
|
if not pinned:
|
|
raise
|
|
kwargs["verify"] = pinned
|
|
return requests.request(method, url, **kwargs)
|