atlas-chat 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- atlas/__init__.py +40 -0
- atlas/application/__init__.py +7 -0
- atlas/application/chat/__init__.py +7 -0
- atlas/application/chat/agent/__init__.py +10 -0
- atlas/application/chat/agent/act_loop.py +179 -0
- atlas/application/chat/agent/factory.py +142 -0
- atlas/application/chat/agent/protocols.py +46 -0
- atlas/application/chat/agent/react_loop.py +338 -0
- atlas/application/chat/agent/think_act_loop.py +171 -0
- atlas/application/chat/approval_manager.py +151 -0
- atlas/application/chat/elicitation_manager.py +191 -0
- atlas/application/chat/events/__init__.py +1 -0
- atlas/application/chat/events/agent_event_relay.py +112 -0
- atlas/application/chat/modes/__init__.py +1 -0
- atlas/application/chat/modes/agent.py +125 -0
- atlas/application/chat/modes/plain.py +74 -0
- atlas/application/chat/modes/rag.py +81 -0
- atlas/application/chat/modes/tools.py +179 -0
- atlas/application/chat/orchestrator.py +213 -0
- atlas/application/chat/policies/__init__.py +1 -0
- atlas/application/chat/policies/tool_authorization.py +99 -0
- atlas/application/chat/preprocessors/__init__.py +1 -0
- atlas/application/chat/preprocessors/message_builder.py +92 -0
- atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
- atlas/application/chat/service.py +454 -0
- atlas/application/chat/utilities/__init__.py +6 -0
- atlas/application/chat/utilities/error_handler.py +367 -0
- atlas/application/chat/utilities/event_notifier.py +546 -0
- atlas/application/chat/utilities/file_processor.py +613 -0
- atlas/application/chat/utilities/tool_executor.py +789 -0
- atlas/atlas_chat_cli.py +347 -0
- atlas/atlas_client.py +238 -0
- atlas/core/__init__.py +0 -0
- atlas/core/auth.py +205 -0
- atlas/core/authorization_manager.py +27 -0
- atlas/core/capabilities.py +123 -0
- atlas/core/compliance.py +215 -0
- atlas/core/domain_whitelist.py +147 -0
- atlas/core/domain_whitelist_middleware.py +82 -0
- atlas/core/http_client.py +28 -0
- atlas/core/log_sanitizer.py +102 -0
- atlas/core/metrics_logger.py +59 -0
- atlas/core/middleware.py +131 -0
- atlas/core/otel_config.py +242 -0
- atlas/core/prompt_risk.py +200 -0
- atlas/core/rate_limit.py +0 -0
- atlas/core/rate_limit_middleware.py +64 -0
- atlas/core/security_headers_middleware.py +51 -0
- atlas/domain/__init__.py +37 -0
- atlas/domain/chat/__init__.py +1 -0
- atlas/domain/chat/dtos.py +85 -0
- atlas/domain/errors.py +96 -0
- atlas/domain/messages/__init__.py +12 -0
- atlas/domain/messages/models.py +160 -0
- atlas/domain/rag_mcp_service.py +664 -0
- atlas/domain/sessions/__init__.py +7 -0
- atlas/domain/sessions/models.py +36 -0
- atlas/domain/unified_rag_service.py +371 -0
- atlas/infrastructure/__init__.py +10 -0
- atlas/infrastructure/app_factory.py +135 -0
- atlas/infrastructure/events/__init__.py +1 -0
- atlas/infrastructure/events/cli_event_publisher.py +140 -0
- atlas/infrastructure/events/websocket_publisher.py +140 -0
- atlas/infrastructure/sessions/in_memory_repository.py +56 -0
- atlas/infrastructure/transport/__init__.py +7 -0
- atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
- atlas/init_cli.py +226 -0
- atlas/interfaces/__init__.py +15 -0
- atlas/interfaces/events.py +134 -0
- atlas/interfaces/llm.py +54 -0
- atlas/interfaces/rag.py +40 -0
- atlas/interfaces/sessions.py +75 -0
- atlas/interfaces/tools.py +57 -0
- atlas/interfaces/transport.py +24 -0
- atlas/main.py +564 -0
- atlas/mcp/api_key_demo/README.md +76 -0
- atlas/mcp/api_key_demo/main.py +172 -0
- atlas/mcp/api_key_demo/run.sh +56 -0
- atlas/mcp/basictable/main.py +147 -0
- atlas/mcp/calculator/main.py +149 -0
- atlas/mcp/code-executor/execution_engine.py +98 -0
- atlas/mcp/code-executor/execution_environment.py +95 -0
- atlas/mcp/code-executor/main.py +528 -0
- atlas/mcp/code-executor/result_processing.py +276 -0
- atlas/mcp/code-executor/script_generation.py +195 -0
- atlas/mcp/code-executor/security_checker.py +140 -0
- atlas/mcp/corporate_cars/main.py +437 -0
- atlas/mcp/csv_reporter/main.py +545 -0
- atlas/mcp/duckduckgo/main.py +182 -0
- atlas/mcp/elicitation_demo/README.md +171 -0
- atlas/mcp/elicitation_demo/main.py +262 -0
- atlas/mcp/env-demo/README.md +158 -0
- atlas/mcp/env-demo/main.py +199 -0
- atlas/mcp/file_size_test/main.py +284 -0
- atlas/mcp/filesystem/main.py +348 -0
- atlas/mcp/image_demo/main.py +113 -0
- atlas/mcp/image_demo/requirements.txt +4 -0
- atlas/mcp/logging_demo/README.md +72 -0
- atlas/mcp/logging_demo/main.py +103 -0
- atlas/mcp/many_tools_demo/main.py +50 -0
- atlas/mcp/order_database/__init__.py +0 -0
- atlas/mcp/order_database/main.py +369 -0
- atlas/mcp/order_database/signal_data.csv +1001 -0
- atlas/mcp/pdfbasic/main.py +394 -0
- atlas/mcp/pptx_generator/main.py +760 -0
- atlas/mcp/pptx_generator/requirements.txt +13 -0
- atlas/mcp/pptx_generator/run_test.sh +1 -0
- atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
- atlas/mcp/progress_demo/main.py +167 -0
- atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
- atlas/mcp/progress_updates_demo/README.md +120 -0
- atlas/mcp/progress_updates_demo/main.py +497 -0
- atlas/mcp/prompts/main.py +222 -0
- atlas/mcp/public_demo/main.py +189 -0
- atlas/mcp/sampling_demo/README.md +169 -0
- atlas/mcp/sampling_demo/main.py +234 -0
- atlas/mcp/thinking/main.py +77 -0
- atlas/mcp/tool_planner/main.py +240 -0
- atlas/mcp/ui-demo/badmesh.png +0 -0
- atlas/mcp/ui-demo/main.py +383 -0
- atlas/mcp/ui-demo/templates/button_demo.html +32 -0
- atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
- atlas/mcp/ui-demo/templates/form_demo.html +28 -0
- atlas/mcp/username-override-demo/README.md +320 -0
- atlas/mcp/username-override-demo/main.py +308 -0
- atlas/modules/__init__.py +0 -0
- atlas/modules/config/__init__.py +34 -0
- atlas/modules/config/cli.py +231 -0
- atlas/modules/config/config_manager.py +1096 -0
- atlas/modules/file_storage/__init__.py +22 -0
- atlas/modules/file_storage/cli.py +330 -0
- atlas/modules/file_storage/content_extractor.py +290 -0
- atlas/modules/file_storage/manager.py +295 -0
- atlas/modules/file_storage/mock_s3_client.py +402 -0
- atlas/modules/file_storage/s3_client.py +417 -0
- atlas/modules/llm/__init__.py +19 -0
- atlas/modules/llm/caller.py +287 -0
- atlas/modules/llm/litellm_caller.py +675 -0
- atlas/modules/llm/models.py +19 -0
- atlas/modules/mcp_tools/__init__.py +17 -0
- atlas/modules/mcp_tools/client.py +2123 -0
- atlas/modules/mcp_tools/token_storage.py +556 -0
- atlas/modules/prompts/prompt_provider.py +130 -0
- atlas/modules/rag/__init__.py +24 -0
- atlas/modules/rag/atlas_rag_client.py +336 -0
- atlas/modules/rag/client.py +129 -0
- atlas/routes/admin_routes.py +865 -0
- atlas/routes/config_routes.py +484 -0
- atlas/routes/feedback_routes.py +361 -0
- atlas/routes/files_routes.py +274 -0
- atlas/routes/health_routes.py +40 -0
- atlas/routes/mcp_auth_routes.py +223 -0
- atlas/server_cli.py +164 -0
- atlas/tests/conftest.py +20 -0
- atlas/tests/integration/test_mcp_auth_integration.py +152 -0
- atlas/tests/manual_test_sampling.py +87 -0
- atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
- atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
- atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
- atlas/tests/test_agent_roa.py +135 -0
- atlas/tests/test_app_factory_smoke.py +47 -0
- atlas/tests/test_approval_manager.py +439 -0
- atlas/tests/test_atlas_client.py +188 -0
- atlas/tests/test_atlas_rag_client.py +447 -0
- atlas/tests/test_atlas_rag_integration.py +224 -0
- atlas/tests/test_attach_file_flow.py +287 -0
- atlas/tests/test_auth_utils.py +165 -0
- atlas/tests/test_backend_public_url.py +185 -0
- atlas/tests/test_banner_logging.py +287 -0
- atlas/tests/test_capability_tokens_and_injection.py +203 -0
- atlas/tests/test_compliance_level.py +54 -0
- atlas/tests/test_compliance_manager.py +253 -0
- atlas/tests/test_config_manager.py +617 -0
- atlas/tests/test_config_manager_paths.py +12 -0
- atlas/tests/test_core_auth.py +18 -0
- atlas/tests/test_core_utils.py +190 -0
- atlas/tests/test_docker_env_sync.py +202 -0
- atlas/tests/test_domain_errors.py +329 -0
- atlas/tests/test_domain_whitelist.py +359 -0
- atlas/tests/test_elicitation_manager.py +408 -0
- atlas/tests/test_elicitation_routing.py +296 -0
- atlas/tests/test_env_demo_server.py +88 -0
- atlas/tests/test_error_classification.py +113 -0
- atlas/tests/test_error_flow_integration.py +116 -0
- atlas/tests/test_feedback_routes.py +333 -0
- atlas/tests/test_file_content_extraction.py +1134 -0
- atlas/tests/test_file_extraction_routes.py +158 -0
- atlas/tests/test_file_library.py +107 -0
- atlas/tests/test_file_manager_unit.py +18 -0
- atlas/tests/test_health_route.py +49 -0
- atlas/tests/test_http_client_stub.py +8 -0
- atlas/tests/test_imports_smoke.py +30 -0
- atlas/tests/test_interfaces_llm_response.py +9 -0
- atlas/tests/test_issue_access_denied_fix.py +136 -0
- atlas/tests/test_llm_env_expansion.py +836 -0
- atlas/tests/test_log_level_sensitive_data.py +285 -0
- atlas/tests/test_mcp_auth_routes.py +341 -0
- atlas/tests/test_mcp_client_auth.py +331 -0
- atlas/tests/test_mcp_data_injection.py +270 -0
- atlas/tests/test_mcp_get_authorized_servers.py +95 -0
- atlas/tests/test_mcp_hot_reload.py +512 -0
- atlas/tests/test_mcp_image_content.py +424 -0
- atlas/tests/test_mcp_logging.py +172 -0
- atlas/tests/test_mcp_progress_updates.py +313 -0
- atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
- atlas/tests/test_mcp_prompts_server.py +39 -0
- atlas/tests/test_mcp_tool_result_parsing.py +296 -0
- atlas/tests/test_metrics_logger.py +56 -0
- atlas/tests/test_middleware_auth.py +379 -0
- atlas/tests/test_prompt_risk_and_acl.py +141 -0
- atlas/tests/test_rag_mcp_aggregator.py +204 -0
- atlas/tests/test_rag_mcp_service.py +224 -0
- atlas/tests/test_rate_limit_middleware.py +45 -0
- atlas/tests/test_routes_config_smoke.py +60 -0
- atlas/tests/test_routes_files_download_token.py +41 -0
- atlas/tests/test_routes_files_health.py +18 -0
- atlas/tests/test_runtime_imports.py +53 -0
- atlas/tests/test_sampling_integration.py +482 -0
- atlas/tests/test_security_admin_routes.py +61 -0
- atlas/tests/test_security_capability_tokens.py +65 -0
- atlas/tests/test_security_file_stats_scope.py +21 -0
- atlas/tests/test_security_header_injection.py +191 -0
- atlas/tests/test_security_headers_and_filename.py +63 -0
- atlas/tests/test_shared_session_repository.py +101 -0
- atlas/tests/test_system_prompt_loading.py +181 -0
- atlas/tests/test_token_storage.py +505 -0
- atlas/tests/test_tool_approval_config.py +93 -0
- atlas/tests/test_tool_approval_utils.py +356 -0
- atlas/tests/test_tool_authorization_group_filtering.py +223 -0
- atlas/tests/test_tool_details_in_config.py +108 -0
- atlas/tests/test_tool_planner.py +300 -0
- atlas/tests/test_unified_rag_service.py +398 -0
- atlas/tests/test_username_override_in_approval.py +258 -0
- atlas/tests/test_websocket_auth_header.py +168 -0
- atlas/version.py +6 -0
- atlas_chat-0.1.0.data/data/.env.example +253 -0
- atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
- atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
- atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
- atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
- atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
- atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
- atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
- atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
- atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
- atlas_chat-0.1.0.dist-info/METADATA +236 -0
- atlas_chat-0.1.0.dist-info/RECORD +250 -0
- atlas_chat-0.1.0.dist-info/WHEEL +5 -0
- atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
- atlas_chat-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
"""Secure per-user token storage for MCP server authentication.
|
|
2
|
+
|
|
3
|
+
This module provides encrypted storage for authentication tokens associated
|
|
4
|
+
with MCP servers on a per-user basis. Supports multiple token types:
|
|
5
|
+
- API keys
|
|
6
|
+
- JWT tokens
|
|
7
|
+
- Bearer tokens
|
|
8
|
+
- OAuth access tokens
|
|
9
|
+
|
|
10
|
+
Each user's tokens are isolated and encrypted using Fernet (AES-128-CBC).
|
|
11
|
+
|
|
12
|
+
Key format: "{user_email}:{server_name}"
|
|
13
|
+
|
|
14
|
+
Security considerations:
|
|
15
|
+
- Tokens are encrypted at rest using a key derived from environment variable
|
|
16
|
+
- Each user's tokens are stored separately (isolation by key)
|
|
17
|
+
- Token expiration is tracked and validated
|
|
18
|
+
- No plaintext tokens are logged
|
|
19
|
+
|
|
20
|
+
Updated: 2025-01-21
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from dataclasses import asdict, dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
31
|
+
|
|
32
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
33
|
+
from cryptography.hazmat.primitives import hashes
|
|
34
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AuthenticationRequiredException(Exception):
|
|
40
|
+
"""Exception raised when a user needs to authenticate with an MCP server.
|
|
41
|
+
|
|
42
|
+
This exception carries information needed to initiate the OAuth flow
|
|
43
|
+
so the frontend can automatically redirect the user to authenticate.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
server_name: str,
|
|
49
|
+
auth_type: str,
|
|
50
|
+
message: str = "Authentication required",
|
|
51
|
+
oauth_start_url: Optional[str] = None,
|
|
52
|
+
):
|
|
53
|
+
super().__init__(message)
|
|
54
|
+
self.server_name = server_name
|
|
55
|
+
self.auth_type = auth_type # "oauth", "jwt", "bearer", or "api_key"
|
|
56
|
+
self.oauth_start_url = oauth_start_url # URL to start OAuth flow (if oauth)
|
|
57
|
+
self.message = message
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
60
|
+
"""Convert exception info to a dict for frontend consumption."""
|
|
61
|
+
return {
|
|
62
|
+
"server_name": self.server_name,
|
|
63
|
+
"auth_type": self.auth_type,
|
|
64
|
+
"message": self.message,
|
|
65
|
+
"oauth_start_url": self.oauth_start_url,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _make_token_key(user_email: str, server_name: str) -> str:
|
|
70
|
+
"""Create a storage key from user email and server name.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
user_email: User's email address
|
|
74
|
+
server_name: Name of the MCP server
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Combined key in format "user_email:server_name"
|
|
78
|
+
"""
|
|
79
|
+
# Normalize to lowercase for consistent lookups
|
|
80
|
+
return f"{user_email.lower()}:{server_name}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse_token_key(key: str) -> Tuple[str, str]:
|
|
84
|
+
"""Parse a storage key into user email and server name.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
key: Combined key in format "user_email:server_name"
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Tuple of (user_email, server_name)
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
ValueError: If key format is invalid
|
|
94
|
+
"""
|
|
95
|
+
parts = key.split(":", 1)
|
|
96
|
+
if len(parts) != 2:
|
|
97
|
+
raise ValueError(f"Invalid token key format: {key}")
|
|
98
|
+
return parts[0], parts[1]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class StoredToken:
|
|
103
|
+
"""Represents a stored authentication token."""
|
|
104
|
+
|
|
105
|
+
token_type: str # "api_key", "bearer", "jwt", "oauth_access", "oauth_refresh"
|
|
106
|
+
token_value: str # The actual token (will be encrypted at rest)
|
|
107
|
+
user_email: str # User who owns this token
|
|
108
|
+
server_name: str # MCP server this token is associated with
|
|
109
|
+
created_at: float # Unix timestamp when token was stored
|
|
110
|
+
expires_at: Optional[float] = None # Unix timestamp when token expires (if known)
|
|
111
|
+
scopes: Optional[str] = None # OAuth scopes (space-separated)
|
|
112
|
+
refresh_token: Optional[str] = None # OAuth refresh token (if available)
|
|
113
|
+
metadata: Optional[Dict[str, Any]] = None # Additional metadata
|
|
114
|
+
|
|
115
|
+
def is_expired(self, buffer_seconds: int = 60) -> bool:
|
|
116
|
+
"""Check if token is expired or will expire within buffer period."""
|
|
117
|
+
if self.expires_at is None:
|
|
118
|
+
return False # No expiration set, assume valid
|
|
119
|
+
return time.time() >= (self.expires_at - buffer_seconds)
|
|
120
|
+
|
|
121
|
+
def time_until_expiry(self) -> Optional[float]:
|
|
122
|
+
"""Get seconds until token expires, or None if no expiration."""
|
|
123
|
+
if self.expires_at is None:
|
|
124
|
+
return None
|
|
125
|
+
return max(0, self.expires_at - time.time())
|
|
126
|
+
|
|
127
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
128
|
+
"""Convert to dictionary for serialization."""
|
|
129
|
+
return asdict(self)
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def from_dict(cls, data: Dict[str, Any]) -> "StoredToken":
|
|
133
|
+
"""Create from dictionary."""
|
|
134
|
+
return cls(**data)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class MCPTokenStorage:
|
|
138
|
+
"""Secure encrypted storage for per-user MCP authentication tokens.
|
|
139
|
+
|
|
140
|
+
Tokens are stored in an encrypted JSON file on disk, keyed by the
|
|
141
|
+
combination of user email and server name. The encryption key is
|
|
142
|
+
derived from the MCP_TOKEN_ENCRYPTION_KEY environment variable using
|
|
143
|
+
PBKDF2. If no key is set, a random key is generated (tokens will not
|
|
144
|
+
persist across restarts in this case).
|
|
145
|
+
|
|
146
|
+
Storage location: {storage_dir}/mcp_tokens.enc
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
# Salt for key derivation (constant, not secret)
|
|
150
|
+
_SALT = b"atlas-mcp-token-storage-v1"
|
|
151
|
+
|
|
152
|
+
def __init__(
|
|
153
|
+
self,
|
|
154
|
+
storage_dir: Optional[Path] = None,
|
|
155
|
+
encryption_key: Optional[str] = None,
|
|
156
|
+
):
|
|
157
|
+
"""Initialize token storage.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
storage_dir: Directory to store encrypted tokens. Defaults to
|
|
161
|
+
MCP_TOKEN_STORAGE_DIR or config/secure
|
|
162
|
+
encryption_key: Base64-encoded encryption key or passphrase.
|
|
163
|
+
Defaults to MCP_TOKEN_ENCRYPTION_KEY setting.
|
|
164
|
+
"""
|
|
165
|
+
# Import here to avoid circular imports
|
|
166
|
+
from atlas.modules.config.config_manager import get_app_settings
|
|
167
|
+
app_settings = get_app_settings()
|
|
168
|
+
|
|
169
|
+
self._storage_dir = storage_dir or self._get_storage_dir(app_settings)
|
|
170
|
+
self._storage_dir.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
self._storage_file = self._storage_dir / "mcp_tokens.enc"
|
|
172
|
+
|
|
173
|
+
# Get or generate encryption key (prefer passed arg, then settings)
|
|
174
|
+
key_source = encryption_key or app_settings.mcp_token_encryption_key
|
|
175
|
+
if key_source:
|
|
176
|
+
self._fernet = self._derive_fernet(key_source)
|
|
177
|
+
logger.info("Token storage initialized with configured encryption key")
|
|
178
|
+
else:
|
|
179
|
+
# Generate ephemeral key (tokens won't persist across restarts)
|
|
180
|
+
ephemeral_key = Fernet.generate_key()
|
|
181
|
+
self._fernet = Fernet(ephemeral_key)
|
|
182
|
+
logger.warning(
|
|
183
|
+
"No MCP_TOKEN_ENCRYPTION_KEY set. Using ephemeral key - "
|
|
184
|
+
"tokens will not persist across application restarts."
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Thread lock for concurrent access
|
|
188
|
+
self._lock = threading.Lock()
|
|
189
|
+
|
|
190
|
+
# In-memory cache of decrypted tokens
|
|
191
|
+
# Key format: "user_email:server_name"
|
|
192
|
+
self._tokens: Dict[str, StoredToken] = {}
|
|
193
|
+
self._load_tokens()
|
|
194
|
+
|
|
195
|
+
def _get_storage_dir(self, app_settings) -> Path:
|
|
196
|
+
"""Get storage directory from settings or default locations.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
app_settings: AppSettings instance with mcp_token_storage_dir
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Path to storage directory
|
|
203
|
+
"""
|
|
204
|
+
# Check configured directory first
|
|
205
|
+
if app_settings.mcp_token_storage_dir:
|
|
206
|
+
configured_path = Path(app_settings.mcp_token_storage_dir)
|
|
207
|
+
try:
|
|
208
|
+
configured_path.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
logger.info(f"Using token storage directory: {configured_path}")
|
|
210
|
+
return configured_path
|
|
211
|
+
except (PermissionError, OSError) as e:
|
|
212
|
+
logger.warning(f"Cannot use MCP_TOKEN_STORAGE_DIR={app_settings.mcp_token_storage_dir}: {e}")
|
|
213
|
+
|
|
214
|
+
# Try project root locations
|
|
215
|
+
candidates = [
|
|
216
|
+
Path(__file__).parent.parent.parent.parent / "config" / "secure",
|
|
217
|
+
Path(__file__).parent.parent.parent.parent / "runtime" / "tokens",
|
|
218
|
+
Path.home() / ".atlas-ui" / "tokens",
|
|
219
|
+
]
|
|
220
|
+
for candidate in candidates:
|
|
221
|
+
try:
|
|
222
|
+
candidate.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
# Test write access
|
|
224
|
+
test_file = candidate / ".write_test"
|
|
225
|
+
test_file.write_text("test")
|
|
226
|
+
test_file.unlink()
|
|
227
|
+
return candidate
|
|
228
|
+
except (PermissionError, OSError):
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
# Fallback to temp directory
|
|
232
|
+
import tempfile
|
|
233
|
+
return Path(tempfile.gettempdir()) / "atlas-mcp-tokens"
|
|
234
|
+
|
|
235
|
+
def _derive_fernet(self, key_source: str) -> Fernet:
|
|
236
|
+
"""Derive Fernet key from passphrase or base64 key."""
|
|
237
|
+
try:
|
|
238
|
+
# Try to use as direct Fernet key (base64-encoded 32 bytes)
|
|
239
|
+
return Fernet(key_source.encode())
|
|
240
|
+
except (ValueError, Exception):
|
|
241
|
+
# Derive key from passphrase using PBKDF2
|
|
242
|
+
kdf = PBKDF2HMAC(
|
|
243
|
+
algorithm=hashes.SHA256(),
|
|
244
|
+
length=32,
|
|
245
|
+
salt=self._SALT,
|
|
246
|
+
iterations=480000, # OWASP recommended minimum
|
|
247
|
+
)
|
|
248
|
+
derived_key = base64.urlsafe_b64encode(
|
|
249
|
+
kdf.derive(key_source.encode())
|
|
250
|
+
)
|
|
251
|
+
return Fernet(derived_key)
|
|
252
|
+
|
|
253
|
+
def _load_tokens(self) -> None:
|
|
254
|
+
"""Load and decrypt tokens from storage file."""
|
|
255
|
+
if not self._storage_file.exists():
|
|
256
|
+
self._tokens = {}
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
encrypted_data = self._storage_file.read_bytes()
|
|
261
|
+
decrypted_data = self._fernet.decrypt(encrypted_data)
|
|
262
|
+
tokens_dict = json.loads(decrypted_data.decode())
|
|
263
|
+
|
|
264
|
+
self._tokens = {
|
|
265
|
+
key: StoredToken.from_dict(token_data)
|
|
266
|
+
for key, token_data in tokens_dict.items()
|
|
267
|
+
}
|
|
268
|
+
logger.info(f"Loaded {len(self._tokens)} encrypted tokens from storage")
|
|
269
|
+
|
|
270
|
+
except InvalidToken:
|
|
271
|
+
logger.error(
|
|
272
|
+
"Failed to decrypt token storage - encryption key may have changed. "
|
|
273
|
+
"Tokens will be reset."
|
|
274
|
+
)
|
|
275
|
+
self._tokens = {}
|
|
276
|
+
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
|
277
|
+
logger.error(f"Corrupted token storage: {e}. Tokens will be reset.")
|
|
278
|
+
self._tokens = {}
|
|
279
|
+
|
|
280
|
+
def _save_tokens(self) -> None:
|
|
281
|
+
"""Encrypt and save tokens to storage file."""
|
|
282
|
+
try:
|
|
283
|
+
tokens_dict = {
|
|
284
|
+
key: token.to_dict()
|
|
285
|
+
for key, token in self._tokens.items()
|
|
286
|
+
}
|
|
287
|
+
json_data = json.dumps(tokens_dict, indent=2)
|
|
288
|
+
encrypted_data = self._fernet.encrypt(json_data.encode())
|
|
289
|
+
|
|
290
|
+
# Atomic write: write to temp file then rename
|
|
291
|
+
temp_file = self._storage_file.with_suffix(".tmp")
|
|
292
|
+
temp_file.write_bytes(encrypted_data)
|
|
293
|
+
temp_file.rename(self._storage_file)
|
|
294
|
+
|
|
295
|
+
logger.debug(f"Saved {len(self._tokens)} encrypted tokens to storage")
|
|
296
|
+
|
|
297
|
+
except Exception as e:
|
|
298
|
+
logger.error(f"Failed to save tokens: {e}")
|
|
299
|
+
raise
|
|
300
|
+
|
|
301
|
+
def store_token(
|
|
302
|
+
self,
|
|
303
|
+
user_email: str,
|
|
304
|
+
server_name: str,
|
|
305
|
+
token_value: str,
|
|
306
|
+
token_type: str = "bearer",
|
|
307
|
+
expires_at: Optional[float] = None,
|
|
308
|
+
scopes: Optional[str] = None,
|
|
309
|
+
refresh_token: Optional[str] = None,
|
|
310
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
311
|
+
) -> StoredToken:
|
|
312
|
+
"""Store an authentication token for a user and MCP server.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
user_email: User's email address
|
|
316
|
+
server_name: Name of the MCP server
|
|
317
|
+
token_value: The token value (JWT, access token, etc.)
|
|
318
|
+
token_type: Type of token ("bearer", "oauth_access", "jwt")
|
|
319
|
+
expires_at: Unix timestamp when token expires
|
|
320
|
+
scopes: OAuth scopes (space-separated string)
|
|
321
|
+
refresh_token: OAuth refresh token if available
|
|
322
|
+
metadata: Additional metadata to store
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
The stored token object
|
|
326
|
+
"""
|
|
327
|
+
token = StoredToken(
|
|
328
|
+
token_type=token_type,
|
|
329
|
+
token_value=token_value,
|
|
330
|
+
user_email=user_email.lower(),
|
|
331
|
+
server_name=server_name,
|
|
332
|
+
created_at=time.time(),
|
|
333
|
+
expires_at=expires_at,
|
|
334
|
+
scopes=scopes,
|
|
335
|
+
refresh_token=refresh_token,
|
|
336
|
+
metadata=metadata,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
key = _make_token_key(user_email, server_name)
|
|
340
|
+
with self._lock:
|
|
341
|
+
self._tokens[key] = token
|
|
342
|
+
self._save_tokens()
|
|
343
|
+
|
|
344
|
+
from atlas.core.log_sanitizer import sanitize_for_logging
|
|
345
|
+
logger.info(
|
|
346
|
+
f"Stored {token_type} token for user and server '{sanitize_for_logging(server_name)}' "
|
|
347
|
+
f"(expires: {'never' if expires_at is None else time.ctime(expires_at)})"
|
|
348
|
+
)
|
|
349
|
+
return token
|
|
350
|
+
|
|
351
|
+
def get_token(self, user_email: str, server_name: str) -> Optional[StoredToken]:
|
|
352
|
+
"""Get stored token for a user and MCP server.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
user_email: User's email address
|
|
356
|
+
server_name: Name of the MCP server
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
StoredToken if found, None otherwise
|
|
360
|
+
"""
|
|
361
|
+
key = _make_token_key(user_email, server_name)
|
|
362
|
+
token = self._tokens.get(key)
|
|
363
|
+
if token is None:
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
# Log warning if expired (but still return it - caller may want to refresh)
|
|
367
|
+
if token.is_expired():
|
|
368
|
+
logger.debug(f"Token for server '{server_name}' has expired")
|
|
369
|
+
|
|
370
|
+
return token
|
|
371
|
+
|
|
372
|
+
def get_valid_token(self, user_email: str, server_name: str) -> Optional[StoredToken]:
|
|
373
|
+
"""Get stored token only if not expired.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
user_email: User's email address
|
|
377
|
+
server_name: Name of the MCP server
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
StoredToken if found and not expired, None otherwise
|
|
381
|
+
"""
|
|
382
|
+
token = self.get_token(user_email, server_name)
|
|
383
|
+
if token is None or token.is_expired():
|
|
384
|
+
return None
|
|
385
|
+
return token
|
|
386
|
+
|
|
387
|
+
def remove_token(self, user_email: str, server_name: str) -> bool:
|
|
388
|
+
"""Remove stored token for a user and MCP server.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
user_email: User's email address
|
|
392
|
+
server_name: Name of the MCP server
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
True if token was removed, False if not found
|
|
396
|
+
"""
|
|
397
|
+
key = _make_token_key(user_email, server_name)
|
|
398
|
+
with self._lock:
|
|
399
|
+
if key in self._tokens:
|
|
400
|
+
del self._tokens[key]
|
|
401
|
+
self._save_tokens()
|
|
402
|
+
from atlas.core.log_sanitizer import sanitize_for_logging
|
|
403
|
+
logger.info(f"Removed token for server '{sanitize_for_logging(server_name)}'")
|
|
404
|
+
return True
|
|
405
|
+
return False
|
|
406
|
+
|
|
407
|
+
def get_user_tokens(self, user_email: str) -> Dict[str, StoredToken]:
|
|
408
|
+
"""Get all tokens for a specific user.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
user_email: User's email address
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
Dictionary mapping server names to tokens
|
|
415
|
+
"""
|
|
416
|
+
user_email_lower = user_email.lower()
|
|
417
|
+
return {
|
|
418
|
+
_parse_token_key(key)[1]: token # Extract server_name from key
|
|
419
|
+
for key, token in self._tokens.items()
|
|
420
|
+
if token.user_email == user_email_lower
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
def get_user_auth_status(self, user_email: str) -> Dict[str, Dict[str, Any]]:
|
|
424
|
+
"""Get authentication status for all servers for a user.
|
|
425
|
+
|
|
426
|
+
Returns metadata about tokens without revealing token values.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
user_email: User's email address
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
Dictionary mapping server names to auth status info
|
|
433
|
+
"""
|
|
434
|
+
user_tokens = self.get_user_tokens(user_email)
|
|
435
|
+
return {
|
|
436
|
+
server_name: {
|
|
437
|
+
"authenticated": True,
|
|
438
|
+
"token_type": token.token_type,
|
|
439
|
+
"created_at": token.created_at,
|
|
440
|
+
"expires_at": token.expires_at,
|
|
441
|
+
"is_expired": token.is_expired(),
|
|
442
|
+
"time_until_expiry": token.time_until_expiry(),
|
|
443
|
+
"has_refresh_token": token.refresh_token is not None,
|
|
444
|
+
"scopes": token.scopes,
|
|
445
|
+
}
|
|
446
|
+
for server_name, token in user_tokens.items()
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
def list_all_tokens_metadata(self) -> List[Dict[str, Any]]:
|
|
450
|
+
"""List metadata for all stored tokens (admin use).
|
|
451
|
+
|
|
452
|
+
Returns token metadata without revealing token values.
|
|
453
|
+
|
|
454
|
+
Returns:
|
|
455
|
+
List of token metadata dictionaries
|
|
456
|
+
"""
|
|
457
|
+
return [
|
|
458
|
+
{
|
|
459
|
+
"user_email": token.user_email,
|
|
460
|
+
"server_name": token.server_name,
|
|
461
|
+
"token_type": token.token_type,
|
|
462
|
+
"created_at": token.created_at,
|
|
463
|
+
"expires_at": token.expires_at,
|
|
464
|
+
"is_expired": token.is_expired(),
|
|
465
|
+
"has_refresh_token": token.refresh_token is not None,
|
|
466
|
+
"scopes": token.scopes,
|
|
467
|
+
}
|
|
468
|
+
for token in self._tokens.values()
|
|
469
|
+
]
|
|
470
|
+
|
|
471
|
+
def update_oauth_tokens(
|
|
472
|
+
self,
|
|
473
|
+
user_email: str,
|
|
474
|
+
server_name: str,
|
|
475
|
+
access_token: str,
|
|
476
|
+
expires_at: Optional[float] = None,
|
|
477
|
+
refresh_token: Optional[str] = None,
|
|
478
|
+
scopes: Optional[str] = None,
|
|
479
|
+
) -> StoredToken:
|
|
480
|
+
"""Update OAuth tokens after a refresh or new authorization.
|
|
481
|
+
|
|
482
|
+
Preserves existing metadata and refresh token if new one not provided.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
user_email: User's email address
|
|
486
|
+
server_name: Name of the MCP server
|
|
487
|
+
access_token: New access token
|
|
488
|
+
expires_at: Unix timestamp when token expires
|
|
489
|
+
refresh_token: New refresh token (or None to keep existing)
|
|
490
|
+
scopes: OAuth scopes
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
Updated StoredToken
|
|
494
|
+
"""
|
|
495
|
+
existing = self.get_token(user_email, server_name)
|
|
496
|
+
|
|
497
|
+
return self.store_token(
|
|
498
|
+
user_email=user_email,
|
|
499
|
+
server_name=server_name,
|
|
500
|
+
token_value=access_token,
|
|
501
|
+
token_type="oauth_access",
|
|
502
|
+
expires_at=expires_at,
|
|
503
|
+
scopes=scopes or (existing.scopes if existing else None),
|
|
504
|
+
refresh_token=refresh_token or (existing.refresh_token if existing else None),
|
|
505
|
+
metadata=existing.metadata if existing else None,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
def clear_user_tokens(self, user_email: str) -> int:
|
|
509
|
+
"""Remove all tokens for a specific user.
|
|
510
|
+
|
|
511
|
+
Args:
|
|
512
|
+
user_email: User's email address
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
Number of tokens removed
|
|
516
|
+
"""
|
|
517
|
+
user_email_lower = user_email.lower()
|
|
518
|
+
with self._lock:
|
|
519
|
+
keys_to_remove = [
|
|
520
|
+
key for key, token in self._tokens.items()
|
|
521
|
+
if token.user_email == user_email_lower
|
|
522
|
+
]
|
|
523
|
+
|
|
524
|
+
for key in keys_to_remove:
|
|
525
|
+
del self._tokens[key]
|
|
526
|
+
|
|
527
|
+
if keys_to_remove:
|
|
528
|
+
self._save_tokens()
|
|
529
|
+
logger.info(f"Cleared {len(keys_to_remove)} tokens for user")
|
|
530
|
+
|
|
531
|
+
return len(keys_to_remove)
|
|
532
|
+
|
|
533
|
+
def clear_all(self) -> int:
|
|
534
|
+
"""Remove all stored tokens (admin use).
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
Number of tokens removed
|
|
538
|
+
"""
|
|
539
|
+
with self._lock:
|
|
540
|
+
count = len(self._tokens)
|
|
541
|
+
self._tokens.clear()
|
|
542
|
+
self._save_tokens()
|
|
543
|
+
logger.info(f"Cleared all {count} stored tokens")
|
|
544
|
+
return count
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
# Global token storage instance (lazy initialization)
|
|
548
|
+
_token_storage: Optional[MCPTokenStorage] = None
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def get_token_storage() -> MCPTokenStorage:
|
|
552
|
+
"""Get the global token storage instance."""
|
|
553
|
+
global _token_storage
|
|
554
|
+
if _token_storage is None:
|
|
555
|
+
_token_storage = MCPTokenStorage()
|
|
556
|
+
return _token_storage
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Prompt provider module for loading and caching prompt templates.
|
|
2
|
+
|
|
3
|
+
Centralizes prompt path resolution & template retrieval so core services stay
|
|
4
|
+
focused on orchestration/business logic.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from typing import Dict, Optional
|
|
11
|
+
|
|
12
|
+
from atlas.modules.config import ConfigManager
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PromptProvider:
|
|
18
|
+
"""Loads and caches prompt templates based on application configuration."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, config_manager: ConfigManager):
|
|
21
|
+
self.config_manager = config_manager
|
|
22
|
+
self._cache: Dict[str, str] = {}
|
|
23
|
+
# Resolve base path (relative paths resolved against repo root)
|
|
24
|
+
app_settings = self.config_manager.app_settings
|
|
25
|
+
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
|
|
26
|
+
base_candidate = app_settings.prompt_base_path
|
|
27
|
+
if not os.path.isabs(base_candidate):
|
|
28
|
+
self.base_path = os.path.join(repo_root, base_candidate)
|
|
29
|
+
else:
|
|
30
|
+
self.base_path = base_candidate
|
|
31
|
+
|
|
32
|
+
def _load_template(self, filename: str) -> Optional[str]:
|
|
33
|
+
cache_key = filename
|
|
34
|
+
if cache_key in self._cache:
|
|
35
|
+
return self._cache[cache_key]
|
|
36
|
+
path = os.path.join(self.base_path, filename)
|
|
37
|
+
if not os.path.exists(path):
|
|
38
|
+
logger.warning("Prompt template not found: %s", path)
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
42
|
+
content = f.read()
|
|
43
|
+
self._cache[cache_key] = content
|
|
44
|
+
return content
|
|
45
|
+
except Exception as e: # pragma: no cover
|
|
46
|
+
logger.error("Failed reading prompt template %s: %s", path, e)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def get_tool_synthesis_prompt(self, user_question: str) -> Optional[str]:
|
|
50
|
+
"""Return formatted tool synthesis prompt or None if unavailable."""
|
|
51
|
+
filename = self.config_manager.app_settings.tool_synthesis_prompt_filename
|
|
52
|
+
template = self._load_template(filename)
|
|
53
|
+
if not template:
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
return template.format(user_question=user_question.strip())
|
|
57
|
+
except Exception as e: # pragma: no cover - safeguard
|
|
58
|
+
logger.warning("Formatting tool synthesis prompt failed: %s", e)
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def get_agent_reason_prompt(
|
|
62
|
+
self,
|
|
63
|
+
user_question: str,
|
|
64
|
+
files_manifest: Optional[str] = None,
|
|
65
|
+
last_observation: Optional[str] = None,
|
|
66
|
+
) -> Optional[str]:
|
|
67
|
+
"""Return formatted agent reason prompt text or None if unavailable.
|
|
68
|
+
|
|
69
|
+
Expects template placeholders: {user_question}, {files_manifest}, {last_observation}
|
|
70
|
+
Missing values are rendered as empty strings.
|
|
71
|
+
"""
|
|
72
|
+
filename = self.config_manager.app_settings.agent_reason_prompt_filename
|
|
73
|
+
template = self._load_template(filename)
|
|
74
|
+
if not template:
|
|
75
|
+
return None
|
|
76
|
+
try:
|
|
77
|
+
return template.format(
|
|
78
|
+
user_question=(user_question or "").strip(),
|
|
79
|
+
files_manifest=(files_manifest or ""),
|
|
80
|
+
last_observation=(last_observation or ""),
|
|
81
|
+
)
|
|
82
|
+
except Exception as e: # pragma: no cover
|
|
83
|
+
logger.warning("Formatting agent reason prompt failed: %s", e)
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def get_agent_observe_prompt(
|
|
87
|
+
self,
|
|
88
|
+
user_question: str,
|
|
89
|
+
tool_summaries: str,
|
|
90
|
+
step: int,
|
|
91
|
+
) -> Optional[str]:
|
|
92
|
+
"""Return formatted agent observe prompt text or None if unavailable.
|
|
93
|
+
|
|
94
|
+
Expects template placeholders: {user_question}, {tool_summaries}, {step}
|
|
95
|
+
"""
|
|
96
|
+
filename = self.config_manager.app_settings.agent_observe_prompt_filename
|
|
97
|
+
template = self._load_template(filename)
|
|
98
|
+
if not template:
|
|
99
|
+
return None
|
|
100
|
+
try:
|
|
101
|
+
return template.format(
|
|
102
|
+
user_question=(user_question or "").strip(),
|
|
103
|
+
tool_summaries=(tool_summaries or ""),
|
|
104
|
+
step=step,
|
|
105
|
+
)
|
|
106
|
+
except Exception as e: # pragma: no cover
|
|
107
|
+
logger.warning("Formatting agent observe prompt failed: %s", e)
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def get_system_prompt(self, user_email: Optional[str] = None) -> Optional[str]:
|
|
111
|
+
"""Return formatted system prompt text or None if unavailable.
|
|
112
|
+
|
|
113
|
+
Expects template placeholder: {user_email}
|
|
114
|
+
Missing values are rendered as empty strings.
|
|
115
|
+
"""
|
|
116
|
+
filename = self.config_manager.app_settings.system_prompt_filename
|
|
117
|
+
template = self._load_template(filename)
|
|
118
|
+
if not template:
|
|
119
|
+
return None
|
|
120
|
+
try:
|
|
121
|
+
return template.format(
|
|
122
|
+
user_email=(user_email or ""),
|
|
123
|
+
)
|
|
124
|
+
except Exception as e: # pragma: no cover
|
|
125
|
+
logger.warning("Formatting system prompt failed: %s", e)
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
def clear_cache(self) -> None:
|
|
129
|
+
"""Clear in-memory prompt cache (e.g., after config reload)."""
|
|
130
|
+
self._cache.clear()
|