Files
cowork-local/config.py
T
duylh19andClaude Sonnet 5 98cee81a61 merge: cập nhật từ origin/feature/delta-team/epic-R04 (dọn dead code, gộp i18n/theme, CASAN Gate O, launcher)
Kéo 7 commit mới từ remote — chủ yếu dọn dẹp và siết chất lượng, không đổi
API đang dùng:
- Xoá 1.400 dòng mã chết + 5 gói rỗng còn sót sau các lần merge trước.
- Thêm CASAN Gate O (LOC) áp cho toàn cây mã.
- Sửa 5 checker UI hỏng sau đợt tách widget R08, vá 4 hồi quy.
- install.bat/run.bat, gộp requirements-test.txt vào requirements.txt.
- Gom i18n_*.py / theme_*.py rời rạc thành gói i18n/ và theme/.

Merge sạch, không có conflict marker nào (git tự resolve toàn bộ). Đã kiểm
tra lại 4 điểm đã vá ở 2 lần merge trước (config.py circular import,
ai_edit_model_resolver.py dùng resolve() thay route_turn(),
_confirm_routing_switch nhận timeout, RoutingApplicationService.resolve())
— cả 4 vẫn nguyên vẹn sau merge này.

pytest tests/: 793 passed — giống hệt số liệu trước khi merge, không phát
sinh fail/error mới (8 fail còn lại vẫn là do môi trường sandbox: thiếu
keyring, tên thư mục cowork-local vs cowork_local).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 12:20:17 +09:00

394 lines
19 KiB
Python

"""Application configuration.
Stored as JSON at ``~/.cowork_local/config.json``. Environment variables
override stored values so the app can run immediately in locked-down setups:
OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL
ANTHROPIC_API_KEY, ANTHROPIC_MODEL
COWORK_TEAMS_WEBHOOK
COWORK_ACTIVE_PROVIDER
COWORK_CA_BUNDLE
"""
from __future__ import annotations
import copy
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List
CONFIG_DIR = Path.home() / ".cowork_local"
CONFIG_PATH = CONFIG_DIR / "config.json"
HISTORY_DIR = CONFIG_DIR / "history"
DEFAULT_CONFIG: Dict[str, Any] = {
"active_provider": "openai_compat",
"theme": "dark",
"language": "vi", # "en" | "ja" | "vi" — UI display language
# Advanced/IT-managed override only (no Settings UI): path to a PEM file
# with a corporate/internal gateway's certificate (or its issuing CA), set
# via the COWORK_CA_BUNDLE env var. Normally unnecessary — a self-signed
# gateway certificate (e.g. "SSLCertVerificationError: self-signed
# certificate in certificate chain") is detected and trusted automatically
# per-host on first contact; see core/tls_trust.py.
"tls_ca_bundle": "",
"providers": {
"openai_compat": {
"base_url": "https://your-internal-gateway/v1",
"api_key": "",
"model": "gpt-4o-mini",
},
"anthropic": {
"base_url": "https://api.anthropic.com",
"api_key": "",
"model": "claude-sonnet-4-6",
},
# Local models via Ollama's OpenAI-compatible server (no key needed).
"ollama": {
"base_url": "http://localhost:11434/v1",
"api_key": "ollama", # Ollama ignores it, but some clients require a value
"model": "llama3.1",
},
# GitHub Copilot chat (OpenAI-compatible endpoint; paste a Copilot token).
"github_copilot": {
"base_url": "https://api.githubcopilot.com",
"api_key": "",
"model": "gpt-4o",
},
# OpenAI (Codex / GPT models) — OpenAI-compatible; paste an OpenAI API key.
"codex": {
"base_url": "https://api.openai.com/v1",
"api_key": "",
"model": "gpt-4o-mini",
},
},
"code": {
"mode": "confirm", # "confirm" | "auto"
"default_workdir": "",
},
"teams": {
"webhook_url": "",
"notify_on_complete": True,
},
"history": {
"location": "local", # "local" | "onedrive"
"custom_dir": "", # optional explicit folder; overrides location
"autosave": True,
},
"codebase_memory": {
"enabled": False,
"binary_path": "", # empty -> resolved from PATH (codebase-memory-mcp)
"auto_index": True, # index the workdir automatically before the first turn
},
# AI-assisted agent security guardrails — configured in its own Settings
# group next to Microsoft 365 (same screen area, but never touches the
# ms365 dict/rules above). Each layer is independently toggleable; a
# blocked action always notifies the admin (see core/agent_security_alert.py)
# via the SAME signed-in Microsoft 365 account as everything else.
"agent_security": {
"enabled": True, # master switch — ON by default ("chọn hết"); editing the Settings group requires an admin-account unlock
"validate_prompt": True, # AI reviews the user's own request against the rules below
"validate_attachments": True, # AI scans attachment/file content for malicious payloads
"validate_commands": True, # whitelist + optional AI control-agent gate on run_command/install_package
"command_ai_check": False, # extra AI judgement for commands not covered by the whitelist (default: off)
"rules_onedrive_url": "", # optional OneDrive/SharePoint SHARE LINK to a .md rules doc (admin-authored)
"admin_email": "", # violation alerts are emailed here via the signed-in MS365 account
# ---- Sandbox Security Layer ----
"cowork_confirm_commands": False, # show the Approve/Reject dialog before Cowork runs a command (default: off)
"resource_limit_cpu_percent": 80, # 0 = unlimited; caps a run_command/install_package process TREE's total CPU%
"resource_limit_memory_mb": 2048, # 0 = unlimited; caps total RSS memory (MB)
"resource_limit_disk_mb": 512, # 0 = unlimited; caps total disk read+write (MB)
"block_network": True, # strip proxy env / point at a black-hole address for agent-run commands
# Allow the agent's fetch_url tool to read web pages / online documents /
# SharePoint-OneDrive share links. SEPARATE from block_network (that only
# sandboxes agent-run shell commands) — reading a URL for info is safe and
# useful, so this defaults ON. Toggle in Settings → Security.
"allow_url_fetch": True,
"sandbox_pw": "", # set through COWORK_SANDBOX_PASSWORD
"rulebase_path": "", # custom RULEBASE.md — attached to every agent execution
},
# Legacy generic-MCP-server list. MERGED into ext_connectors["other"] as of
# the unified "Connectors (MCP)" section — kept here only so config.load()
# can migrate any pre-existing entries; the UI no longer writes it.
"mcp_servers": [],
# Unified "Connectors (MCP)" (Settings). One system for every external tool
# source — grouped by category CAD / CAE / MS365 / Other. Each entry:
# {"id", "name", "category", "enabled", "mode": "mcp_stdio"|"rest_api", plus
# mode-specific fields — see core/ext_connectors.py}. No vendor SDK bundled:
# a mcp_stdio entry points at a real MCP server the user/IT already has; a
# rest_api entry calls a REST endpoint the app/vendor exposes. "Other" is
# the home for generic MCP servers (what used to be the separate "MCP
# Servers" section); MS365 additionally auto-wires the built-in MS365 MCP
# server (see state.py::_ms365_builtin_connection).
"ext_connectors": {
"cad": [],
"cae": [],
"ms365": [],
"other": [],
},
"cowork": {
"output_dir": "", # where Cowork saves generated files; empty -> OneDrive/CoworkLocal/output
"max_parallel": 5, # max messages running at once per tab; extras wait in the queue
},
"context": { # auto-compress long conversations (Cowork + Co4E)
"auto_compact": True, # summarize old turns when near the memory quota
"compact_threshold": 0.8, # trigger at 80% of the context window
"limit_tokens": 0, # 0 = auto per model; else a fixed token budget
},
"jira": { # Jira read connector (agent tool: jira_search / jira_get_issue)
"base_url": "", # e.g. https://your-domain.atlassian.net
"email": "", # Atlassian account email (Basic auth user)
"api_token": "", # Atlassian API token (id.atlassian.com → Security → API tokens)
},
"attachments": {
"max_tokens": 500000, # per attached file; content beyond this is truncated (~4 chars/token)
"max_files": 10, # max number of files attachable to one message
},
"structure": { # Structure (RAG) graph performance caps (0 = unlimited)
"max_nodes": 400,
"max_edges": 400,
},
# Dashboard tab: unit prices (USD per 1M tokens) + display currency.
# Editable right on the Dashboard; rates are static conversions.
"usage": {
"price_per_mtok_in_usd": 0.5,
"price_per_mtok_out_usd": 1.5,
"price_per_mtok_cache_usd": 0.1,
"currency": "USD", # USD | VND | JPY
"usd_to_vnd": 25000.0,
"usd_to_jpy": 150.0,
"model_prices": {}, # per-model USD/1M rates: {model: {"in","out","cache"}}
"pricing_url": "", # reference price-list link (informational)
},
"auth": {
"shared_dir": "", # shared folder path (network share or synced OneDrive folder) holding
# accounts/groups + cross-machine telemetry — plain file I/O, no Graph API
"last_account": "", # last successfully logged-in username, for prefill only — never the code
"last_department": "", # last-typed optional Department at login, for prefill only
},
# Microsoft 365 connections (Settings → "Kết nối Microsoft 365"). This gate
# (unlock_code) is a LOCAL SETTINGS-PANEL LOCK ONLY — it stops someone from
# casually flipping these switches, it is NOT how the app authenticates to
# Microsoft. Real Outlook/Teams/OneDrive/SharePoint access still requires a
# proper OAuth sign-in (not implemented yet) using tenant_id/client_id below.
"ms365": {
"unlock_code": "", # set through COWORK_MS365_UNLOCK_CODE
"unlocked": False, # runtime-only — never persisted as True, see save()
# Auto-connect MS365/OneDrive/SharePoint: the built-in MS365 MCP server
# launches automatically once the user is signed in (OAuth tenant/client
# is still required for real Graph access — this only pre-arms the wiring
# so it "just works" after sign-in, per the unified Connectors design).
"allow_external_internet": True,
# TEMPORARY: only OneDrive + SharePoint are enabled, and they connect via
# the LOCALLY-SYNCED OneDrive folders (core/ms365_local.py) — no OAuth /
# tenant / sign-in. Outlook / Teams / Meeting-transcript are OFF for now
# because they need cloud Graph access (OAuth); re-enable them once the
# cloud sign-in flow is turned back on.
"connectors": {
"outlook": False,
"teams": False,
"onedrive": True,
"sharepoint": True,
"meeting_transcript": False,
},
"tenant_id": "",
"client_id": "",
# "Paste a Teams link" convenience (Settings): a channel/chat link the
# user connected once, so the agent can post to it without ever
# needing a team_id/channel_id/chat_id — see ms365_graph.parse_teams_link.
"teams_link": "",
"teams_target": None, # {"kind": "channel", "team_id", "channel_id"} | {"kind": "chat", "chat_id"}
"teams_introduced": False, # has the "Hi, I'm Co4E" self-intro already been sent for this target?
},
"last_session": { # restored on next launch (crash-resilient)
"cowork": "",
"code": "",
},
"tray": {
"minimize_on_close": True, # closing the window keeps running in the tray
"notify_on_done": True, # tray notification when a task finishes/fails
},
# Which Monitoring tabs a Sub-admin may see (Admin always sees every tab;
# "user" never sees Monitoring at all — unaffected by this). All default
# True so behavior is unchanged until an Admin explicitly restricts one.
"monitoring_visibility": {
"security_events": True,
"mcp_history": True,
"action_logs": True,
"agent_status": True,
},
# Agent tool governance (Monitoring → Tools). Built-in agent tools whose
# NAME is listed here are withheld from the agent (filtered out of the tool
# list at run time). Empty = every built-in tool available (default).
"tools": {
"disabled": [],
},
# Auto Model Assessment & Routing (core/routing/). The app periodically
# assesses each configured model (static metadata + dynamic probes graded
# by a fixed judge), scores them per task type, and can route each chat/
# agent turn to the best-fit model. Assessment RESULTS live in their own
# file (~/.cowork_local/assessments.json + assessments_history/), not here —
# this section is only the behaviour config the user edits.
"routing": {
"switch_mode": "off", # global default: "off" | "auto" | "manual"
"policy": "balanced", # "quality" | "cost" | "latency" | "balanced"
"min_score_gain": 0.05, # only switch if the new model beats current by ≥ this
"confirm_timeout_sec": 60, # (manual) auto-keep current if the user doesn't confirm in time
"reassess_interval_hours": 24, # periodic reassess cadence; 0 disables the schedule
"per_provider_concurrency": 2, # max concurrent probe calls per provider (rate-limit safety)
"judge_provider": "", # judge model's provider ("" → the active provider)
"judge_model": "", # fixed cheap judge model ("" → a per-provider default)
"candidates": [], # explicit [{provider, model_id, tier}]; empty → discover from providers
"auto_reassess_on_add": True, # reassess a newly-added model as soon as it's added
# Per-surface Off/Auto/Manual toggle state (the chat-screen toggle). An
# empty string means "follow the global switch_mode above".
"surface_modes": {
"cowork": "",
"co4e": "",
"ai_edit": "",
},
},
}
# Friendly labels used across the UI.
PROVIDER_LABELS = {
"openai_compat": "OpenAI-compatible (Internal Gateway)",
"anthropic": "Anthropic Claude",
"ollama": "Ollama (local models)",
"github_copilot": "GitHub Copilot",
"codex": "OpenAI (Codex / GPT)",
}
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
"""Recursively merge ``override`` into a copy of ``base``."""
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
def _apply_env_overrides(data: Dict[str, Any]) -> Dict[str, Any]:
"""Cho phép biến môi trường ghi đè cấu hình.
Dùng khi chạy trong container/CI: đặt endpoint và khoá qua biến môi trường mà
không phải sửa file cấu hình.
"""
data = copy.deepcopy(data)
oc = data["providers"]["openai_compat"]
if os.getenv("OPENAI_API_KEY"):
oc["api_key"] = os.environ["OPENAI_API_KEY"]
if os.getenv("OPENAI_BASE_URL"):
oc["base_url"] = os.environ["OPENAI_BASE_URL"]
if os.getenv("OPENAI_MODEL"):
oc["model"] = os.environ["OPENAI_MODEL"]
an = data["providers"]["anthropic"]
if os.getenv("ANTHROPIC_API_KEY"):
an["api_key"] = os.environ["ANTHROPIC_API_KEY"]
if os.getenv("ANTHROPIC_MODEL"):
an["model"] = os.environ["ANTHROPIC_MODEL"]
if os.getenv("COWORK_TEAMS_WEBHOOK"):
data["teams"]["webhook_url"] = os.environ["COWORK_TEAMS_WEBHOOK"]
if os.getenv("COWORK_ACTIVE_PROVIDER"):
data["active_provider"] = os.environ["COWORK_ACTIVE_PROVIDER"]
if os.getenv("COWORK_CA_BUNDLE"):
data["tls_ca_bundle"] = os.environ["COWORK_CA_BUNDLE"]
if os.getenv("COWORK_SANDBOX_PASSWORD"):
data["agent_security"]["sandbox_pw"] = os.environ["COWORK_SANDBOX_PASSWORD"]
if os.getenv("COWORK_MS365_UNLOCK_CODE"):
data["ms365"]["unlock_code"] = os.environ["COWORK_MS365_UNLOCK_CODE"]
return data
def _migrate_connectors(data: Dict[str, Any]) -> None:
"""One-way migration into the unified Connectors (MCP) model, in place:
* ext_connectors["office"] → ext_connectors["ms365"] (renamed category)
* legacy top-level mcp_servers → ext_connectors["other"] as mcp_stdio
connectors (the old standalone "MCP Servers" section was merged in).
Idempotent: re-running does nothing once migrated. Never raises."""
import uuid
ext = data.setdefault("ext_connectors", {})
for cat in ("cad", "cae", "ms365", "other"):
ext.setdefault(cat, [])
# office → ms365 (only migrate non-empty legacy bucket; then drop it)
legacy_office = ext.pop("office", None)
if legacy_office:
seen = {c.get("id") for c in ext["ms365"]}
for c in legacy_office:
c["category"] = "ms365"
if c.get("id") not in seen:
ext["ms365"].append(c)
# legacy generic mcp_servers → ext_connectors["other"] (mcp_stdio)
servers = data.get("mcp_servers") or []
if servers:
existing = {c.get("name") for c in ext["other"]}
for s in servers:
name = s.get("name", "")
if not name or name in existing:
continue
ext["other"].append({
"id": f"other-{uuid.uuid4().hex[:6]}",
"name": name,
"category": "other",
"enabled": bool(s.get("enabled", True)),
"mode": "mcp_stdio",
"command": s.get("command", ""),
"args": s.get("args") or [],
"env": s.get("env") or {},
})
data["mcp_servers"] = [] # migrated — the UI no longer manages this
# Deferred: JsonConfigRepository's own import chain (infrastructure.persistence
# .json -> task_repository_impl -> core.tasks) reads CONFIG_DIR back from this
# module, so importing it before CONFIG_DIR exists here is a circular import.
from .infrastructure.config.json_config_repository import JsonConfigRepository
class AppConfig(JsonConfigRepository):
"""Vỏ tương thích — R02 đã thay lớp này bằng :class:`JsonConfigRepository`.
Ngày 25/08 app chuyển hẳn sang repository (ghi nguyên tử, khoá nằm trong
kho bí mật của hệ điều hành). Nhưng cái tên ``AppConfig`` còn nằm ở 41 file
— 23 checker trong ``tools/`` và 18 file test, trong đó có test của cả ba
người. Sửa hết 41 chỗ trong một commit là đổi thứ không cần đổi và làm
review không đọc nổi.
Nên giữ tên, đổi ruột: mọi lối vào đều dẫn tới repository.
Bỏ hẳn được khi ``tools/`` và ``tests/`` chuyển sang gọi
``presentation.shell.bootstrap.build_context()``.
"""
def __init__(self, data=None, path: Path = CONFIG_PATH, **kw):
"""Mở cấu hình từ đĩa, hoặc dựng thẳng từ dict khi truyền ``data``.
Dạng ``AppConfig(data=..., path=...)`` là để 13 file test dựng cấu hình mà
không chạm đĩa; giữ nguyên vì bỏ đi là phải sửa cả 13 file.
"""
if data is None:
super().__init__(Path(path), **kw)
return
# Dạng AppConfig(data=..., path=...) mà 13 file test đang dùng: dựng
# thẳng từ dict, không đụng đĩa.
built = JsonConfigRepository.from_data(data, Path(path))
self.__dict__.update(built.__dict__)
@classmethod
def load(cls, path: Path = CONFIG_PATH) -> "JsonConfigRepository":
"""Điểm vào cũ. Giờ đi qua Composition Root nên checker và app dùng
chung một đường dựng — kể cả phần ráp kho bí mật."""
from .presentation.shell.bootstrap import build_config
return build_config(Path(path))