solace-agent-mesh 1.3.3__py3-none-any.whl → 1.4.1__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.
Potentially problematic release.
This version of solace-agent-mesh might be problematic. Click here for more details.
- solace_agent_mesh/agent/adk/setup.py +183 -8
- solace_agent_mesh/agent/sac/app.py +337 -622
- solace_agent_mesh/agent/sac/component.py +47 -1
- solace_agent_mesh/agent/tools/dynamic_tool.py +36 -5
- solace_agent_mesh/agent/tools/tool_config_types.py +58 -0
- solace_agent_mesh/assets/docs/404.html +3 -3
- solace_agent_mesh/assets/docs/assets/js/42b3f8d8.508ae8db.js +1 -0
- solace_agent_mesh/assets/docs/assets/js/9a09e75d.92de8cf5.js +1 -0
- solace_agent_mesh/assets/docs/assets/js/ae4415af.16cc58d3.js +1 -0
- solace_agent_mesh/assets/docs/assets/js/{main.e82b32e6.js → main.9bc1a102.js} +2 -2
- solace_agent_mesh/assets/docs/assets/js/{runtime~main.aad1f874.js → runtime~main.f2b4ea70.js} +1 -1
- solace_agent_mesh/assets/docs/docs/documentation/Enterprise/installation/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/Enterprise/single-sign-on/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/Migrations/A2A Upgrade To 0.3.0/a2a-gateway-upgrade-to-0.3.0/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/Migrations/A2A Upgrade To 0.3.0/a2a-technical-migration-map/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/concepts/agents/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/concepts/architecture/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/concepts/cli/index.html +20 -5
- solace_agent_mesh/assets/docs/docs/documentation/concepts/gateways/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/concepts/orchestrator/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/concepts/plugins/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/deployment/debugging/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/deployment/deploy/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/deployment/observability/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/getting-started/component-overview/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/getting-started/configurations/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/getting-started/installation/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/getting-started/introduction/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/getting-started/quick-start/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/tutorials/bedrock-agents/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/tutorials/custom-agent/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/tutorials/event-mesh-gateway/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/tutorials/mcp-integration/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/tutorials/mongodb-integration/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/tutorials/rag-integration/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/tutorials/rest-gateway/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/tutorials/slack-integration/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/tutorials/sql-database/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/user-guide/builtin-tools/artifact-management/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/user-guide/builtin-tools/audio-tools/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/user-guide/builtin-tools/data-analysis-tools/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/user-guide/builtin-tools/embeds/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/user-guide/builtin-tools/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/user-guide/create-agents/index.html +5 -5
- solace_agent_mesh/assets/docs/docs/documentation/user-guide/create-gateways/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/user-guide/creating-python-tools/index.html +68 -3
- solace_agent_mesh/assets/docs/docs/documentation/user-guide/creating-service-providers/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/user-guide/solace-ai-connector/index.html +3 -3
- solace_agent_mesh/assets/docs/docs/documentation/user-guide/structure/index.html +3 -3
- solace_agent_mesh/assets/docs/lunr-index-1758036158289.json +1 -0
- solace_agent_mesh/assets/docs/lunr-index.json +1 -1
- solace_agent_mesh/assets/docs/search-doc-1758036158289.json +1 -0
- solace_agent_mesh/assets/docs/search-doc.json +1 -1
- solace_agent_mesh/cli/__init__.py +1 -1
- solace_agent_mesh/cli/commands/plugin_cmd/__init__.py +2 -0
- solace_agent_mesh/cli/commands/plugin_cmd/add_cmd.py +10 -245
- solace_agent_mesh/cli/commands/plugin_cmd/install_cmd.py +283 -0
- solace_agent_mesh/cli/commands/run_cmd.py +4 -7
- solace_agent_mesh/client/webui/frontend/static/assets/{authCallback-CAX9u8a7.js → authCallback-j1LW-wlq.js} +1 -1
- solace_agent_mesh/client/webui/frontend/static/assets/{client-DXU9SPI5.js → client-B9p_nFNA.js} +1 -1
- solace_agent_mesh/client/webui/frontend/static/assets/main-B6BpuH9K.js +339 -0
- solace_agent_mesh/client/webui/frontend/static/assets/main-B9s_V9tJ.css +1 -0
- solace_agent_mesh/client/webui/frontend/static/assets/{vendor-B0BEKoAR.js → vendor-CS5YMf8a.js} +74 -69
- solace_agent_mesh/client/webui/frontend/static/auth-callback.html +3 -3
- solace_agent_mesh/client/webui/frontend/static/index.html +4 -4
- solace_agent_mesh/common/services/identity_service.py +2 -1
- solace_agent_mesh/common/services/providers/local_file_identity_service.py +1 -1
- solace_agent_mesh/common/utils/pydantic_utils.py +60 -0
- solace_agent_mesh/config_portal/backend/plugin_catalog/registry_manager.py +6 -4
- solace_agent_mesh/gateway/base/app.py +69 -120
- solace_agent_mesh/gateway/http_sse/app.py +99 -150
- solace_agent_mesh/gateway/http_sse/component.py +57 -30
- solace_agent_mesh/gateway/http_sse/main.py +337 -375
- solace_agent_mesh/gateway/http_sse/sse_event_buffer.py +87 -0
- solace_agent_mesh/gateway/http_sse/sse_manager.py +44 -23
- solace_agent_mesh/templates/webui.yaml +1 -1
- {solace_agent_mesh-1.3.3.dist-info → solace_agent_mesh-1.4.1.dist-info}/METADATA +7 -1
- {solace_agent_mesh-1.3.3.dist-info → solace_agent_mesh-1.4.1.dist-info}/RECORD +82 -78
- solace_agent_mesh/assets/docs/assets/js/42b3f8d8.3f34bf76.js +0 -1
- solace_agent_mesh/assets/docs/assets/js/9a09e75d.5a319fd4.js +0 -1
- solace_agent_mesh/assets/docs/assets/js/ae4415af.24cdc514.js +0 -1
- solace_agent_mesh/assets/docs/lunr-index-1757873594308.json +0 -1
- solace_agent_mesh/assets/docs/search-doc-1757873594308.json +0 -1
- solace_agent_mesh/client/webui/frontend/static/assets/main-C03yrETa.css +0 -1
- solace_agent_mesh/client/webui/frontend/static/assets/main-DjoMeldu.js +0 -339
- /solace_agent_mesh/assets/docs/assets/js/{main.e82b32e6.js.LICENSE.txt → main.9bc1a102.js.LICENSE.txt} +0 -0
- {solace_agent_mesh-1.3.3.dist-info → solace_agent_mesh-1.4.1.dist-info}/WHEEL +0 -0
- {solace_agent_mesh-1.3.3.dist-info → solace_agent_mesh-1.4.1.dist-info}/entry_points.txt +0 -0
- {solace_agent_mesh-1.3.3.dist-info → solace_agent_mesh-1.4.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -30,8 +30,6 @@ from ...gateway.http_sse.routers import (
|
|
|
30
30
|
tasks,
|
|
31
31
|
visualization,
|
|
32
32
|
)
|
|
33
|
-
|
|
34
|
-
# Import persistence-aware controllers
|
|
35
33
|
from .routers.sessions import router as session_router
|
|
36
34
|
from .routers.tasks import router as task_router
|
|
37
35
|
from .routers.users import router as user_router
|
|
@@ -46,90 +44,132 @@ app = FastAPI(
|
|
|
46
44
|
)
|
|
47
45
|
|
|
48
46
|
|
|
49
|
-
def
|
|
50
|
-
""
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
def _extract_access_token(request: FastAPIRequest) -> str:
|
|
48
|
+
auth_header = request.headers.get("Authorization")
|
|
49
|
+
if auth_header and auth_header.startswith("Bearer "):
|
|
50
|
+
return auth_header[7:]
|
|
53
51
|
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
try:
|
|
53
|
+
if "access_token" in request.session:
|
|
54
|
+
log.debug("AuthMiddleware: Found token in session.")
|
|
55
|
+
return request.session["access_token"]
|
|
56
|
+
except AssertionError:
|
|
57
|
+
log.debug("AuthMiddleware: Could not access request.session.")
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
if "token" in request.query_params:
|
|
60
|
+
return request.query_params["token"]
|
|
58
61
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
existing_tables = inspector.get_table_names()
|
|
69
|
-
|
|
70
|
-
if not existing_tables or "sessions" not in existing_tables:
|
|
71
|
-
log.info("Running database migrations...")
|
|
72
|
-
alembic_cfg = Config()
|
|
73
|
-
alembic_cfg.set_main_option(
|
|
74
|
-
"script_location",
|
|
75
|
-
os.path.join(os.path.dirname(__file__), "alembic"),
|
|
76
|
-
)
|
|
77
|
-
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
|
|
78
|
-
command.upgrade(alembic_cfg, "head")
|
|
79
|
-
log.info("Database migrations complete.")
|
|
80
|
-
else:
|
|
81
|
-
log.info("Database tables already exist, skipping migrations.")
|
|
82
|
-
except Exception as e:
|
|
83
|
-
log.warning(
|
|
84
|
-
f"Migration check failed, attempting to run migrations anyway: {e}"
|
|
85
|
-
)
|
|
86
|
-
try:
|
|
87
|
-
alembic_cfg = Config()
|
|
88
|
-
alembic_cfg.set_main_option(
|
|
89
|
-
"script_location",
|
|
90
|
-
os.path.join(os.path.dirname(__file__), "alembic"),
|
|
91
|
-
)
|
|
92
|
-
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
|
|
93
|
-
command.upgrade(alembic_cfg, "head")
|
|
94
|
-
log.info("Database migrations complete.")
|
|
95
|
-
except Exception as migration_error:
|
|
96
|
-
log.warning(f"Migration failed but continuing: {migration_error}")
|
|
97
|
-
else:
|
|
98
|
-
log.warning(
|
|
99
|
-
"No database URL provided - using in-memory session storage (data not persisted across restarts)"
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def _validate_token(auth_service_url: str, auth_provider: str, access_token: str) -> bool:
|
|
66
|
+
async with httpx.AsyncClient() as client:
|
|
67
|
+
validation_response = await client.post(
|
|
68
|
+
f"{auth_service_url}/is_token_valid",
|
|
69
|
+
json={"provider": auth_provider},
|
|
70
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
100
71
|
)
|
|
101
|
-
|
|
72
|
+
return validation_response.status_code == 200
|
|
102
73
|
|
|
103
|
-
webui_app = component.get_app()
|
|
104
|
-
app_config = {}
|
|
105
|
-
if webui_app:
|
|
106
|
-
app_config = getattr(webui_app, "app_config", {})
|
|
107
|
-
if app_config is None:
|
|
108
|
-
log.warning("webui_app.app_config is None, using empty dict.")
|
|
109
|
-
app_config = {}
|
|
110
|
-
else:
|
|
111
|
-
log.warning("Could not get webui_app from component. Using empty app_config.")
|
|
112
74
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
75
|
+
async def _get_user_info(auth_service_url: str, auth_provider: str, access_token: str) -> dict:
|
|
76
|
+
async with httpx.AsyncClient() as client:
|
|
77
|
+
userinfo_response = await client.get(
|
|
78
|
+
f"{auth_service_url}/user_info?provider={auth_provider}",
|
|
79
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if userinfo_response.status_code != 200:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
return userinfo_response.json()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _extract_user_identifier(user_info: dict) -> str:
|
|
89
|
+
user_identifier = (
|
|
90
|
+
user_info.get("sub")
|
|
91
|
+
or user_info.get("client_id")
|
|
92
|
+
or user_info.get("username")
|
|
93
|
+
or user_info.get("oid")
|
|
94
|
+
or user_info.get("preferred_username")
|
|
95
|
+
or user_info.get("upn")
|
|
96
|
+
or user_info.get("unique_name")
|
|
97
|
+
or user_info.get("email")
|
|
98
|
+
or user_info.get("name")
|
|
99
|
+
or user_info.get("azp")
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if user_identifier and user_identifier.lower() == "unknown":
|
|
103
|
+
log.warning("AuthMiddleware: IDP returned 'Unknown' as user identifier. Using fallback.")
|
|
104
|
+
return "sam_dev_user"
|
|
105
|
+
|
|
106
|
+
return user_identifier
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _extract_user_details(user_info: dict, user_identifier: str) -> tuple:
|
|
110
|
+
email_from_auth = (
|
|
111
|
+
user_info.get("email")
|
|
112
|
+
or user_info.get("preferred_username")
|
|
113
|
+
or user_info.get("upn")
|
|
114
|
+
or user_identifier
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
display_name = (
|
|
118
|
+
user_info.get("name")
|
|
119
|
+
or user_info.get("given_name", "") + " " + user_info.get("family_name", "")
|
|
120
|
+
or user_info.get("preferred_username")
|
|
121
|
+
or user_identifier
|
|
122
|
+
).strip()
|
|
123
|
+
|
|
124
|
+
return email_from_auth, display_name
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def _create_user_state_without_identity_service(user_identifier: str, email_from_auth: str, display_name: str) -> dict:
|
|
128
|
+
final_user_id = user_identifier or email_from_auth or "sam_dev_user"
|
|
129
|
+
if not final_user_id or final_user_id.lower() in ["unknown", "null", "none", ""]:
|
|
130
|
+
final_user_id = "sam_dev_user"
|
|
131
|
+
log.warning(
|
|
132
|
+
"AuthMiddleware: Had to use fallback user ID due to invalid identifier: %s",
|
|
133
|
+
user_identifier,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
log.error(
|
|
137
|
+
"AuthMiddleware: Internal IdentityService not configured on component. Using user ID: %s",
|
|
138
|
+
final_user_id,
|
|
139
|
+
)
|
|
140
|
+
return {
|
|
141
|
+
"id": final_user_id,
|
|
142
|
+
"email": email_from_auth or final_user_id,
|
|
143
|
+
"name": display_name or final_user_id,
|
|
144
|
+
"authenticated": True,
|
|
145
|
+
"auth_method": "oidc",
|
|
128
146
|
}
|
|
129
147
|
|
|
130
|
-
dependencies.set_api_config(api_config_dict)
|
|
131
|
-
log.info("API configuration extracted and stored.")
|
|
132
148
|
|
|
149
|
+
async def _create_user_state_with_identity_service(identity_service, user_identifier: str, email_from_auth: str, display_name: str, user_info: dict) -> dict:
|
|
150
|
+
lookup_value = email_from_auth if "@" in email_from_auth else user_identifier
|
|
151
|
+
user_profile = await identity_service.get_user_profile(
|
|
152
|
+
{identity_service.lookup_key: lookup_value},
|
|
153
|
+
user_info=user_info
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if not user_profile:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
user_state = user_profile.copy()
|
|
160
|
+
if not user_state.get("id"):
|
|
161
|
+
user_state["id"] = user_identifier
|
|
162
|
+
if not user_state.get("email"):
|
|
163
|
+
user_state["email"] = email_from_auth
|
|
164
|
+
if not user_state.get("name"):
|
|
165
|
+
user_state["name"] = display_name
|
|
166
|
+
user_state["authenticated"] = True
|
|
167
|
+
user_state["auth_method"] = "oidc"
|
|
168
|
+
|
|
169
|
+
return user_state
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _create_auth_middleware(component):
|
|
133
173
|
class AuthMiddleware:
|
|
134
174
|
def __init__(self, app, component):
|
|
135
175
|
self.app = app
|
|
@@ -147,304 +187,247 @@ def setup_dependencies(component: "WebUIBackendComponent", database_url: str = N
|
|
|
147
187
|
return
|
|
148
188
|
|
|
149
189
|
skip_paths = [
|
|
150
|
-
"/api/v1/config",
|
|
151
|
-
"/api/v1/auth/
|
|
152
|
-
"/api/v1/auth/login",
|
|
153
|
-
"/api/v1/auth/refresh",
|
|
154
|
-
"/api/v1/csrf-token",
|
|
155
|
-
"/health",
|
|
190
|
+
"/api/v1/config", "/api/v1/auth/callback", "/api/v1/auth/login",
|
|
191
|
+
"/api/v1/auth/refresh", "/api/v1/csrf-token", "/health",
|
|
156
192
|
]
|
|
157
193
|
|
|
158
194
|
if any(request.url.path.startswith(path) for path in skip_paths):
|
|
159
195
|
await self.app(scope, receive, send)
|
|
160
196
|
return
|
|
161
197
|
|
|
162
|
-
use_auth = dependencies.api_config and dependencies.api_config.get(
|
|
163
|
-
"frontend_use_authorization"
|
|
164
|
-
)
|
|
198
|
+
use_auth = dependencies.api_config and dependencies.api_config.get("frontend_use_authorization")
|
|
165
199
|
|
|
166
200
|
if use_auth:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
201
|
+
await self._handle_authenticated_request(request, scope, receive, send)
|
|
202
|
+
else:
|
|
203
|
+
request.state.user = {
|
|
204
|
+
"id": "sam_dev_user", "name": "Sam Dev User", "email": "sam@dev.local",
|
|
205
|
+
"authenticated": True, "auth_method": "development",
|
|
206
|
+
}
|
|
207
|
+
log.debug("AuthMiddleware: Set development user state with id: sam_dev_user")
|
|
208
|
+
|
|
209
|
+
await self.app(scope, receive, send)
|
|
210
|
+
|
|
211
|
+
async def _handle_authenticated_request(self, request, scope, receive, send):
|
|
212
|
+
access_token = _extract_access_token(request)
|
|
213
|
+
|
|
214
|
+
if not access_token:
|
|
215
|
+
log.warning("AuthMiddleware: No access token found. Returning 401.")
|
|
216
|
+
response = JSONResponse(
|
|
217
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
218
|
+
content={"detail": "Not authenticated", "error_type": "authentication_required"},
|
|
219
|
+
)
|
|
220
|
+
await response(scope, receive, send)
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
auth_service_url = dependencies.api_config.get("external_auth_service_url")
|
|
225
|
+
auth_provider = dependencies.api_config.get("external_auth_provider")
|
|
226
|
+
|
|
227
|
+
if not auth_service_url:
|
|
228
|
+
log.error("Auth service URL not configured.")
|
|
229
|
+
response = JSONResponse(
|
|
230
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
231
|
+
content={"detail": "Auth service not configured"},
|
|
232
|
+
)
|
|
233
|
+
await response(scope, receive, send)
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
if not await _validate_token(auth_service_url, auth_provider, access_token):
|
|
237
|
+
log.warning("AuthMiddleware: Token validation failed")
|
|
187
238
|
response = JSONResponse(
|
|
188
239
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
189
|
-
content={
|
|
190
|
-
"detail": "Not authenticated",
|
|
191
|
-
"error_type": "authentication_required",
|
|
192
|
-
},
|
|
240
|
+
content={"detail": "Invalid token", "error_type": "invalid_token"},
|
|
193
241
|
)
|
|
194
242
|
await response(scope, receive, send)
|
|
195
243
|
return
|
|
196
244
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
245
|
+
user_info = await _get_user_info(auth_service_url, auth_provider, access_token)
|
|
246
|
+
if not user_info:
|
|
247
|
+
log.warning("AuthMiddleware: Failed to get user info from external auth service")
|
|
248
|
+
response = JSONResponse(
|
|
249
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
250
|
+
content={"detail": "Could not retrieve user info from auth provider", "error_type": "user_info_failed"},
|
|
200
251
|
)
|
|
201
|
-
|
|
202
|
-
|
|
252
|
+
await response(scope, receive, send)
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
user_identifier = _extract_user_identifier(user_info)
|
|
256
|
+
if not user_identifier or user_identifier.lower() in ["null", "none", ""]:
|
|
257
|
+
log.error("AuthMiddleware: No valid user identifier from OAuth provider")
|
|
258
|
+
response = JSONResponse(
|
|
259
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
260
|
+
content={"detail": "OAuth provider returned no valid user identifier", "error_type": "invalid_user_identifier_from_provider"},
|
|
203
261
|
)
|
|
262
|
+
await response(scope, receive, send)
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
email_from_auth, display_name = _extract_user_details(user_info, user_identifier)
|
|
204
266
|
|
|
205
|
-
|
|
206
|
-
|
|
267
|
+
identity_service = self.component.identity_service
|
|
268
|
+
if not identity_service:
|
|
269
|
+
request.state.user = await _create_user_state_without_identity_service(user_identifier, email_from_auth, display_name)
|
|
270
|
+
else:
|
|
271
|
+
user_state = await _create_user_state_with_identity_service(identity_service, user_identifier, email_from_auth, display_name, user_info)
|
|
272
|
+
if not user_state:
|
|
273
|
+
log.error("AuthMiddleware: User authenticated but not found in internal IdentityService")
|
|
207
274
|
response = JSONResponse(
|
|
208
|
-
status_code=status.
|
|
209
|
-
content={"detail": "
|
|
275
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
276
|
+
content={"detail": "User not authorized for this application", "error_type": "not_authorized"},
|
|
210
277
|
)
|
|
211
278
|
await response(scope, receive, send)
|
|
212
279
|
return
|
|
280
|
+
request.state.user = user_state
|
|
213
281
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
282
|
+
except httpx.RequestError as exc:
|
|
283
|
+
log.error("Error calling auth service: %s", exc)
|
|
284
|
+
response = JSONResponse(
|
|
285
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
286
|
+
content={"detail": "Auth service is unavailable"},
|
|
287
|
+
)
|
|
288
|
+
await response(scope, receive, send)
|
|
289
|
+
return
|
|
290
|
+
except Exception as exc:
|
|
291
|
+
log.error("An unexpected error occurred during token validation: %s", exc)
|
|
292
|
+
response = JSONResponse(
|
|
293
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
294
|
+
content={"detail": "An internal error occurred during authentication"},
|
|
295
|
+
)
|
|
296
|
+
await response(scope, receive, send)
|
|
297
|
+
return
|
|
220
298
|
|
|
221
|
-
|
|
222
|
-
log.warning(
|
|
223
|
-
"AuthMiddleware: Token validation failed with status %s: %s",
|
|
224
|
-
validation_response.status_code,
|
|
225
|
-
validation_response.text,
|
|
226
|
-
)
|
|
227
|
-
response = JSONResponse(
|
|
228
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
229
|
-
content={
|
|
230
|
-
"detail": "Invalid token",
|
|
231
|
-
"error_type": "invalid_token",
|
|
232
|
-
},
|
|
233
|
-
)
|
|
234
|
-
await response(scope, receive, send)
|
|
235
|
-
return
|
|
299
|
+
return AuthMiddleware
|
|
236
300
|
|
|
237
|
-
async with httpx.AsyncClient() as client:
|
|
238
|
-
userinfo_response = await client.get(
|
|
239
|
-
f"{auth_service_url}/user_info?provider={auth_provider}",
|
|
240
|
-
headers={"Authorization": f"Bearer {access_token}"},
|
|
241
|
-
)
|
|
242
301
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
"detail": "Could not retrieve user info from auth provider",
|
|
252
|
-
"error_type": "user_info_failed",
|
|
253
|
-
},
|
|
254
|
-
)
|
|
255
|
-
await response(scope, receive, send)
|
|
256
|
-
return
|
|
302
|
+
def _setup_alembic_config(database_url: str) -> Config:
|
|
303
|
+
alembic_cfg = Config()
|
|
304
|
+
alembic_cfg.set_main_option(
|
|
305
|
+
"script_location",
|
|
306
|
+
os.path.join(os.path.dirname(__file__), "alembic"),
|
|
307
|
+
)
|
|
308
|
+
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
|
|
309
|
+
return alembic_cfg
|
|
257
310
|
|
|
258
|
-
user_info = userinfo_response.json()
|
|
259
|
-
log.info(
|
|
260
|
-
"AuthMiddleware: Raw user info from OAuth provider: %s",
|
|
261
|
-
user_info,
|
|
262
|
-
)
|
|
263
311
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
or user_info.get("client_id") # Mini IDP and some custom IDPs
|
|
268
|
-
or user_info.get("username") # Mini IDP returns username field
|
|
269
|
-
or user_info.get("oid") # Azure AD object ID
|
|
270
|
-
or user_info.get(
|
|
271
|
-
"preferred_username"
|
|
272
|
-
) # Common in enterprise IDPs
|
|
273
|
-
or user_info.get("upn") # Azure AD User Principal Name
|
|
274
|
-
or user_info.get("unique_name") # Some Azure configurations
|
|
275
|
-
or user_info.get("email") # Fallback to email
|
|
276
|
-
or user_info.get("name") # Last resort
|
|
277
|
-
or user_info.get("azp") # Authorized party (rare but possible)
|
|
278
|
-
)
|
|
312
|
+
def _run_community_migrations(database_url: str) -> None:
|
|
313
|
+
try:
|
|
314
|
+
from sqlalchemy import create_engine
|
|
279
315
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
log.warning(
|
|
284
|
-
"AuthMiddleware: IDP returned 'Unknown' as user identifier. This indicates misconfiguration. Using fallback."
|
|
285
|
-
)
|
|
286
|
-
# In development mode with mini IDP, default to sam_dev_user
|
|
287
|
-
# This is a workaround for the OAuth2 proxy service returning "Unknown"
|
|
288
|
-
user_identifier = "sam_dev_user" # Fallback for development
|
|
289
|
-
log.info(
|
|
290
|
-
"AuthMiddleware: Using development fallback user: sam_dev_user"
|
|
291
|
-
)
|
|
316
|
+
engine = create_engine(database_url)
|
|
317
|
+
inspector = sa.inspect(engine)
|
|
318
|
+
existing_tables = inspector.get_table_names()
|
|
292
319
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
320
|
+
if not existing_tables or "sessions" not in existing_tables:
|
|
321
|
+
log.info("Running community database migrations...")
|
|
322
|
+
alembic_cfg = _setup_alembic_config(database_url)
|
|
323
|
+
command.upgrade(alembic_cfg, "head")
|
|
324
|
+
log.info("Community database migrations complete.")
|
|
325
|
+
else:
|
|
326
|
+
log.info(
|
|
327
|
+
"Community database tables already exist, skipping community migrations."
|
|
328
|
+
)
|
|
329
|
+
except Exception as e:
|
|
330
|
+
log.warning(
|
|
331
|
+
"Community migration check failed, attempting to run migrations anyway: %s",
|
|
332
|
+
e,
|
|
333
|
+
)
|
|
334
|
+
try:
|
|
335
|
+
alembic_cfg = _setup_alembic_config(database_url)
|
|
336
|
+
command.upgrade(alembic_cfg, "head")
|
|
337
|
+
log.info("Community database migrations complete.")
|
|
338
|
+
except Exception as migration_error:
|
|
339
|
+
log.warning(
|
|
340
|
+
"Community migration failed but continuing: %s", migration_error
|
|
341
|
+
)
|
|
300
342
|
|
|
301
|
-
# Extract display name
|
|
302
|
-
display_name = (
|
|
303
|
-
user_info.get("name")
|
|
304
|
-
or user_info.get("given_name", "")
|
|
305
|
-
+ " "
|
|
306
|
-
+ user_info.get("family_name", "")
|
|
307
|
-
or user_info.get("preferred_username")
|
|
308
|
-
or user_identifier
|
|
309
|
-
).strip()
|
|
310
|
-
|
|
311
|
-
log.info(
|
|
312
|
-
"AuthMiddleware: Extracted user identifier: %s, email: %s, name: %s",
|
|
313
|
-
user_identifier,
|
|
314
|
-
email_from_auth,
|
|
315
|
-
display_name,
|
|
316
|
-
)
|
|
317
343
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
log.error(
|
|
324
|
-
"AuthMiddleware: No valid user identifier from OAuth provider. Full user info: %s. Expected valid user identifier.",
|
|
325
|
-
user_info,
|
|
326
|
-
)
|
|
327
|
-
response = JSONResponse(
|
|
328
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
329
|
-
content={
|
|
330
|
-
"detail": "OAuth provider returned no valid user identifier. Provider must return at least one of: sub, username, client_id, preferred_username, email, or name field.",
|
|
331
|
-
"error_type": "invalid_user_identifier_from_provider",
|
|
332
|
-
"received_user_info": user_info,
|
|
333
|
-
},
|
|
334
|
-
)
|
|
335
|
-
await response(scope, receive, send)
|
|
336
|
-
return
|
|
344
|
+
def _run_enterprise_migrations(component: "WebUIBackendComponent", database_url: str) -> None:
|
|
345
|
+
try:
|
|
346
|
+
from solace_agent_mesh_enterprise.webui_backend.migration_runner import (
|
|
347
|
+
run_migrations,
|
|
348
|
+
)
|
|
337
349
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
"none",
|
|
348
|
-
"",
|
|
349
|
-
]:
|
|
350
|
-
final_user_id = "sam_dev_user"
|
|
351
|
-
log.warning(
|
|
352
|
-
"AuthMiddleware: Had to use fallback user ID due to invalid identifier: %s",
|
|
353
|
-
user_identifier,
|
|
354
|
-
)
|
|
355
|
-
|
|
356
|
-
log.error(
|
|
357
|
-
"AuthMiddleware: Internal IdentityService not configured on component. Using user ID: %s",
|
|
358
|
-
final_user_id,
|
|
359
|
-
)
|
|
360
|
-
request.state.user = {
|
|
361
|
-
"id": final_user_id,
|
|
362
|
-
"email": email_from_auth or final_user_id,
|
|
363
|
-
"name": display_name or final_user_id,
|
|
364
|
-
"authenticated": True,
|
|
365
|
-
"auth_method": "oidc",
|
|
366
|
-
}
|
|
367
|
-
log.info(
|
|
368
|
-
"AuthMiddleware: Set fallback user state with id: %s",
|
|
369
|
-
final_user_id,
|
|
370
|
-
)
|
|
371
|
-
else:
|
|
372
|
-
# Try to look up user profile using the email or user identifier
|
|
373
|
-
lookup_value = (
|
|
374
|
-
email_from_auth
|
|
375
|
-
if "@" in email_from_auth
|
|
376
|
-
else user_identifier
|
|
377
|
-
)
|
|
378
|
-
user_profile = await identity_service.get_user_profile(
|
|
379
|
-
{identity_service.lookup_key: lookup_value}
|
|
380
|
-
)
|
|
381
|
-
if not user_profile:
|
|
382
|
-
log.error(
|
|
383
|
-
"AuthMiddleware: User '%s' authenticated but not found in internal IdentityService.",
|
|
384
|
-
lookup_value,
|
|
385
|
-
)
|
|
386
|
-
response = JSONResponse(
|
|
387
|
-
status_code=status.HTTP_403_FORBIDDEN,
|
|
388
|
-
content={
|
|
389
|
-
"detail": "User not authorized for this application",
|
|
390
|
-
"error_type": "not_authorized",
|
|
391
|
-
},
|
|
392
|
-
)
|
|
393
|
-
await response(scope, receive, send)
|
|
394
|
-
return
|
|
395
|
-
|
|
396
|
-
request.state.user = user_profile.copy()
|
|
397
|
-
# Ensure the ID is set from the OAuth provider if not present in the profile
|
|
398
|
-
if not request.state.user.get("id"):
|
|
399
|
-
request.state.user["id"] = user_identifier
|
|
400
|
-
# Also ensure email and name are set if not in profile
|
|
401
|
-
if not request.state.user.get("email"):
|
|
402
|
-
request.state.user["email"] = email_from_auth
|
|
403
|
-
if not request.state.user.get("name"):
|
|
404
|
-
request.state.user["name"] = display_name
|
|
405
|
-
request.state.user["authenticated"] = True
|
|
406
|
-
request.state.user["auth_method"] = "oidc"
|
|
407
|
-
log.info(
|
|
408
|
-
"AuthMiddleware: Set enriched user profile with id: %s",
|
|
409
|
-
request.state.user.get("id"),
|
|
410
|
-
)
|
|
350
|
+
webui_app = component.get_app()
|
|
351
|
+
app_config = getattr(webui_app, "app_config", {}) if webui_app else {}
|
|
352
|
+
log.info("Running enterprise migrations...")
|
|
353
|
+
run_migrations(database_url, app_config)
|
|
354
|
+
log.info("Enterprise migrations completed")
|
|
355
|
+
except (ImportError, ModuleNotFoundError):
|
|
356
|
+
log.debug("Enterprise module not found - skipping enterprise migrations")
|
|
357
|
+
except Exception as e:
|
|
358
|
+
log.warning("Enterprise migration failed but continuing: %s", e)
|
|
411
359
|
|
|
412
|
-
except httpx.RequestError as exc:
|
|
413
|
-
log.error("Error calling auth service: %s", exc)
|
|
414
|
-
response = JSONResponse(
|
|
415
|
-
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
416
|
-
content={"detail": "Auth service is unavailable"},
|
|
417
|
-
)
|
|
418
|
-
await response(scope, receive, send)
|
|
419
|
-
return
|
|
420
|
-
except Exception as exc:
|
|
421
|
-
log.error(
|
|
422
|
-
"An unexpected error occurred during token validation: %s", exc
|
|
423
|
-
)
|
|
424
|
-
response = JSONResponse(
|
|
425
|
-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
426
|
-
content={
|
|
427
|
-
"detail": "An internal error occurred during authentication"
|
|
428
|
-
},
|
|
429
|
-
)
|
|
430
|
-
await response(scope, receive, send)
|
|
431
|
-
return
|
|
432
|
-
else:
|
|
433
|
-
# If auth is not used, set a default user
|
|
434
|
-
request.state.user = {
|
|
435
|
-
"id": "sam_dev_user",
|
|
436
|
-
"name": "Sam Dev User",
|
|
437
|
-
"email": "sam@dev.local",
|
|
438
|
-
"authenticated": True,
|
|
439
|
-
"auth_method": "development",
|
|
440
|
-
}
|
|
441
|
-
log.debug(
|
|
442
|
-
"AuthMiddleware: Set development user state with id: sam_dev_user"
|
|
443
|
-
)
|
|
444
360
|
|
|
445
|
-
|
|
361
|
+
def _setup_database(component: "WebUIBackendComponent", database_url: str) -> None:
|
|
362
|
+
dependencies.init_database(database_url)
|
|
363
|
+
log.info("Persistence enabled - sessions will be stored in database")
|
|
364
|
+
log.info("Checking database migrations...")
|
|
365
|
+
|
|
366
|
+
_run_community_migrations(database_url)
|
|
367
|
+
_run_enterprise_migrations(component, database_url)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _get_app_config(component: "WebUIBackendComponent") -> dict:
|
|
371
|
+
webui_app = component.get_app()
|
|
372
|
+
app_config = {}
|
|
373
|
+
if webui_app:
|
|
374
|
+
app_config = getattr(webui_app, "app_config", {})
|
|
375
|
+
if app_config is None:
|
|
376
|
+
log.warning("webui_app.app_config is None, using empty dict.")
|
|
377
|
+
app_config = {}
|
|
378
|
+
else:
|
|
379
|
+
log.warning("Could not get webui_app from component. Using empty app_config.")
|
|
380
|
+
return app_config
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _create_api_config(app_config: dict, database_url: str) -> dict:
|
|
384
|
+
return {
|
|
385
|
+
"external_auth_service_url": app_config.get(
|
|
386
|
+
"external_auth_service_url", "http://localhost:8080"
|
|
387
|
+
),
|
|
388
|
+
"external_auth_callback_uri": app_config.get(
|
|
389
|
+
"external_auth_callback_uri", "http://localhost:8000/api/v1/auth/callback"
|
|
390
|
+
),
|
|
391
|
+
"external_auth_provider": app_config.get("external_auth_provider", "azure"),
|
|
392
|
+
"frontend_use_authorization": app_config.get(
|
|
393
|
+
"frontend_use_authorization", False
|
|
394
|
+
),
|
|
395
|
+
"frontend_redirect_url": app_config.get(
|
|
396
|
+
"frontend_redirect_url", "http://localhost:3000"
|
|
397
|
+
),
|
|
398
|
+
"persistence_enabled": database_url is not None,
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def setup_dependencies(component: "WebUIBackendComponent", database_url: str = None):
|
|
403
|
+
"""
|
|
404
|
+
This function initializes the simplified architecture while maintaining full
|
|
405
|
+
backward compatibility with existing API contracts.
|
|
406
|
+
|
|
407
|
+
If database_url is None, runs in compatibility mode with in-memory sessions.
|
|
408
|
+
"""
|
|
409
|
+
dependencies.set_component_instance(component)
|
|
446
410
|
|
|
447
|
-
|
|
411
|
+
if database_url:
|
|
412
|
+
_setup_database(component, database_url)
|
|
413
|
+
else:
|
|
414
|
+
log.warning(
|
|
415
|
+
"No database URL provided - using in-memory session storage (data not persisted across restarts)"
|
|
416
|
+
)
|
|
417
|
+
log.info("This maintains backward compatibility for existing SAM installations")
|
|
418
|
+
|
|
419
|
+
app_config = _get_app_config(component)
|
|
420
|
+
api_config_dict = _create_api_config(app_config, database_url)
|
|
421
|
+
|
|
422
|
+
dependencies.set_api_config(api_config_dict)
|
|
423
|
+
log.info("API configuration extracted and stored.")
|
|
424
|
+
|
|
425
|
+
_setup_middleware(component)
|
|
426
|
+
_setup_routers()
|
|
427
|
+
_setup_static_files()
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _setup_middleware(component: "WebUIBackendComponent") -> None:
|
|
448
431
|
allowed_origins = component.get_cors_origins()
|
|
449
432
|
app.add_middleware(
|
|
450
433
|
CORSMiddleware,
|
|
@@ -459,54 +442,33 @@ def setup_dependencies(component: "WebUIBackendComponent", database_url: str = N
|
|
|
459
442
|
app.add_middleware(SessionMiddleware, secret_key=session_manager.secret_key)
|
|
460
443
|
log.info("SessionMiddleware added.")
|
|
461
444
|
|
|
462
|
-
|
|
445
|
+
auth_middleware_class = _create_auth_middleware(component)
|
|
446
|
+
app.add_middleware(auth_middleware_class, component=component)
|
|
463
447
|
log.info("AuthMiddleware added.")
|
|
464
448
|
|
|
465
|
-
|
|
449
|
+
|
|
450
|
+
def _setup_routers() -> None:
|
|
466
451
|
api_prefix = "/api/v1"
|
|
467
452
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
app.include_router(
|
|
471
|
-
session_router, prefix=api_prefix, tags=["Sessions"]
|
|
472
|
-
) # Provides /api/v1/sessions/* endpoints
|
|
473
|
-
app.include_router(
|
|
474
|
-
user_router, prefix=f"{api_prefix}/users", tags=["Users"]
|
|
475
|
-
) # Provides /api/v1/users/me
|
|
476
|
-
app.include_router(
|
|
477
|
-
task_router, prefix=f"{api_prefix}/tasks", tags=["Tasks"]
|
|
478
|
-
) # Provides /api/v1/tasks/send, /subscribe, /cancel
|
|
479
|
-
|
|
480
|
-
# Mount new A2A SDK routers with different paths to avoid conflicts
|
|
453
|
+
app.include_router(session_router, prefix=api_prefix, tags=["Sessions"])
|
|
454
|
+
app.include_router(user_router, prefix=f"{api_prefix}/users", tags=["Users"])
|
|
455
|
+
app.include_router(task_router, prefix=f"{api_prefix}/tasks", tags=["Tasks"])
|
|
481
456
|
app.include_router(config.router, prefix=api_prefix, tags=["Config"])
|
|
482
457
|
app.include_router(agent_cards.router, prefix=api_prefix, tags=["Agent Cards"])
|
|
483
|
-
|
|
484
|
-
app.include_router(
|
|
485
|
-
tasks.router, prefix=api_prefix, tags=["A2A Messages"]
|
|
486
|
-
) # Provides /api/v1/message:send, /message:stream
|
|
487
|
-
# Note: We only use the full-featured session_router (from api/controllers/session_controller.py)
|
|
488
|
-
# which provides complete session management with database persistence
|
|
458
|
+
app.include_router(tasks.router, prefix=api_prefix, tags=["A2A Messages"])
|
|
489
459
|
app.include_router(sse.router, prefix=f"{api_prefix}/sse", tags=["SSE"])
|
|
490
|
-
app.include_router(
|
|
491
|
-
|
|
492
|
-
)
|
|
493
|
-
app.include_router(
|
|
494
|
-
visualization.router,
|
|
495
|
-
prefix=f"{api_prefix}/visualization",
|
|
496
|
-
tags=["Visualization"],
|
|
497
|
-
)
|
|
498
|
-
app.include_router(
|
|
499
|
-
people.router,
|
|
500
|
-
prefix=api_prefix,
|
|
501
|
-
tags=["People"],
|
|
502
|
-
)
|
|
460
|
+
app.include_router(artifacts.router, prefix=f"{api_prefix}/artifacts", tags=["Artifacts"])
|
|
461
|
+
app.include_router(visualization.router, prefix=f"{api_prefix}/visualization", tags=["Visualization"])
|
|
462
|
+
app.include_router(people.router, prefix=api_prefix, tags=["People"])
|
|
503
463
|
app.include_router(auth.router, prefix=api_prefix, tags=["Auth"])
|
|
504
464
|
log.info("Legacy routers mounted for endpoints not yet migrated")
|
|
505
465
|
|
|
506
|
-
|
|
466
|
+
|
|
467
|
+
def _setup_static_files() -> None:
|
|
507
468
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
508
469
|
root_dir = Path(os.path.normpath(os.path.join(current_dir, "..", "..")))
|
|
509
470
|
static_files_dir = Path.joinpath(root_dir, "client", "webui", "frontend", "static")
|
|
471
|
+
|
|
510
472
|
if not os.path.isdir(static_files_dir):
|
|
511
473
|
log.warning(
|
|
512
474
|
"Static files directory '%s' not found. Frontend may not be served.",
|