CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
394 lines
19 KiB
Python
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))
|
|
|