@josephyan/qingflow-cli 0.2.0-beta.55

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.
Files changed (79) hide show
  1. package/README.md +30 -0
  2. package/docs/local-agent-install.md +235 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +204 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow +15 -0
  10. package/src/qingflow_mcp/__init__.py +5 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +547 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +985 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +8243 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +15 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +78 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +184 -0
  21. package/src/qingflow_mcp/cli/commands/common.py +47 -0
  22. package/src/qingflow_mcp/cli/commands/imports.py +86 -0
  23. package/src/qingflow_mcp/cli/commands/record.py +202 -0
  24. package/src/qingflow_mcp/cli/commands/task.py +87 -0
  25. package/src/qingflow_mcp/cli/commands/workspace.py +33 -0
  26. package/src/qingflow_mcp/cli/context.py +48 -0
  27. package/src/qingflow_mcp/cli/formatters.py +269 -0
  28. package/src/qingflow_mcp/cli/json_io.py +50 -0
  29. package/src/qingflow_mcp/cli/main.py +147 -0
  30. package/src/qingflow_mcp/config.py +221 -0
  31. package/src/qingflow_mcp/errors.py +66 -0
  32. package/src/qingflow_mcp/import_store.py +121 -0
  33. package/src/qingflow_mcp/json_types.py +18 -0
  34. package/src/qingflow_mcp/list_type_labels.py +76 -0
  35. package/src/qingflow_mcp/server.py +211 -0
  36. package/src/qingflow_mcp/server_app_builder.py +387 -0
  37. package/src/qingflow_mcp/server_app_user.py +317 -0
  38. package/src/qingflow_mcp/session_store.py +289 -0
  39. package/src/qingflow_mcp/solution/__init__.py +6 -0
  40. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  41. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  42. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  43. package/src/qingflow_mcp/solution/compiler/form_compiler.py +466 -0
  44. package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
  45. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  46. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  47. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  48. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  49. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  50. package/src/qingflow_mcp/solution/design_session.py +222 -0
  51. package/src/qingflow_mcp/solution/design_store.py +100 -0
  52. package/src/qingflow_mcp/solution/executor.py +2339 -0
  53. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  54. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  55. package/src/qingflow_mcp/solution/run_store.py +244 -0
  56. package/src/qingflow_mcp/solution/spec_models.py +853 -0
  57. package/src/qingflow_mcp/tools/__init__.py +1 -0
  58. package/src/qingflow_mcp/tools/ai_builder_tools.py +2063 -0
  59. package/src/qingflow_mcp/tools/app_tools.py +850 -0
  60. package/src/qingflow_mcp/tools/approval_tools.py +833 -0
  61. package/src/qingflow_mcp/tools/auth_tools.py +697 -0
  62. package/src/qingflow_mcp/tools/base.py +81 -0
  63. package/src/qingflow_mcp/tools/code_block_tools.py +679 -0
  64. package/src/qingflow_mcp/tools/directory_tools.py +648 -0
  65. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  66. package/src/qingflow_mcp/tools/file_tools.py +385 -0
  67. package/src/qingflow_mcp/tools/import_tools.py +1971 -0
  68. package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
  69. package/src/qingflow_mcp/tools/package_tools.py +240 -0
  70. package/src/qingflow_mcp/tools/portal_tools.py +131 -0
  71. package/src/qingflow_mcp/tools/qingbi_report_tools.py +269 -0
  72. package/src/qingflow_mcp/tools/record_tools.py +12739 -0
  73. package/src/qingflow_mcp/tools/role_tools.py +94 -0
  74. package/src/qingflow_mcp/tools/solution_tools.py +3887 -0
  75. package/src/qingflow_mcp/tools/task_context_tools.py +1423 -0
  76. package/src/qingflow_mcp/tools/task_tools.py +843 -0
  77. package/src/qingflow_mcp/tools/view_tools.py +280 -0
  78. package/src/qingflow_mcp/tools/workflow_tools.py +312 -0
  79. package/src/qingflow_mcp/tools/workspace_tools.py +219 -0
@@ -0,0 +1,697 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from typing import Any
5
+
6
+ from mcp.server.fastmcp import FastMCP
7
+ from Crypto.PublicKey import RSA
8
+ from Crypto.Cipher import PKCS1_v1_5
9
+
10
+ from ..backend_client import BackendRequestContext, BackendResponse
11
+ from ..config import (
12
+ DEFAULT_PROFILE,
13
+ get_default_base_url,
14
+ get_default_qf_version,
15
+ normalize_base_url,
16
+ )
17
+ from ..errors import QingflowApiError, raise_tool_error
18
+ from ..session_store import SessionStore
19
+ from .base import ToolBase
20
+
21
+
22
+ class AuthTools(ToolBase):
23
+ def __init__(self, sessions: SessionStore, backend) -> None:
24
+ super().__init__(sessions, backend)
25
+
26
+ def register(self, mcp: FastMCP) -> None:
27
+ @mcp.tool()
28
+ def auth_login(
29
+ profile: str = DEFAULT_PROFILE,
30
+ base_url: str | None = None,
31
+ qf_version: str | None = None,
32
+ email: str = "",
33
+ password: str = "",
34
+ persist: bool = True,
35
+ ) -> dict[str, Any]:
36
+ return self.auth_login(profile=profile, base_url=base_url, qf_version=qf_version, email=email, password=password, persist=persist)
37
+
38
+ @mcp.tool()
39
+ def auth_use_token(
40
+ profile: str = DEFAULT_PROFILE,
41
+ base_url: str | None = None,
42
+ qf_version: str | None = None,
43
+ token: str = "",
44
+ ws_id: int | None = None,
45
+ persist: bool = False,
46
+ ) -> dict[str, Any]:
47
+ return self.auth_use_token(profile=profile, base_url=base_url, qf_version=qf_version, token=token, ws_id=ws_id, persist=persist)
48
+
49
+ @mcp.tool()
50
+ def auth_whoami(profile: str = DEFAULT_PROFILE) -> dict[str, Any]:
51
+ return self.auth_whoami(profile=profile)
52
+
53
+ @mcp.tool()
54
+ def auth_logout(profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict[str, Any]:
55
+ return self.auth_logout(profile=profile, forget_persisted=forget_persisted)
56
+
57
+ def auth_login(
58
+ self,
59
+ *,
60
+ profile: str = DEFAULT_PROFILE,
61
+ base_url: str | None = None,
62
+ qf_version: str | None = None,
63
+ email: str,
64
+ password: str,
65
+ persist: bool,
66
+ ) -> dict[str, Any]:
67
+ normalized_base_url = self._normalize_base_url(base_url)
68
+ normalized_qf_version, qf_version_source = self._resolve_qf_version_input(qf_version)
69
+ if not email or not password:
70
+ raise_tool_error(QingflowApiError.config_error("email and password are required"))
71
+
72
+ # Try to fetch public key and encrypt password
73
+ public_key_str = self._fetch_public_key(normalized_base_url, qf_version=normalized_qf_version)
74
+ encrypted_password = self._encrypt_password(password, public_key_str) if public_key_str else password
75
+
76
+ try:
77
+ # Try 'email' first (aPaas/Public Cloud style)
78
+ login_response = self.backend.public_request_with_meta(
79
+ "POST",
80
+ normalized_base_url,
81
+ "/user/login",
82
+ json_body={"email": email, "password": encrypted_password},
83
+ qf_version=normalized_qf_version,
84
+ )
85
+ except QingflowApiError as error:
86
+ # If failed, try 'account' (QMC/Private Cloud style)
87
+ try:
88
+ login_response = self.backend.public_request_with_meta(
89
+ "POST",
90
+ normalized_base_url,
91
+ "/user/login",
92
+ json_body={"account": email, "password": encrypted_password},
93
+ qf_version=normalized_qf_version,
94
+ )
95
+ except QingflowApiError:
96
+ # If both failed, raise the original error
97
+ self._handle_error(profile, error)
98
+ raise AssertionError("unreachable")
99
+ login_result = login_response.data
100
+ if not isinstance(login_result, dict):
101
+ raise_tool_error(QingflowApiError(category="auth", message="Login did not return a valid result"))
102
+
103
+ token = login_result.get("token")
104
+ login_token = login_result.get("loginToken")
105
+
106
+ if not token and login_token:
107
+ raise_tool_error(
108
+ QingflowApiError.not_supported(
109
+ "Current environment requires an additional login challenge. Qingflow MCP v1 only supports direct token login."
110
+ )
111
+ )
112
+
113
+ if not token:
114
+ raise_tool_error(QingflowApiError(category="auth", message="Login did not return a valid Qingflow token"))
115
+ detected_qf_version = login_response.qf_response_version
116
+ resolved_qf_version, resolved_qf_version_source = self._resolve_backend_qf_version(
117
+ detected_qf_version,
118
+ fallback_qf_version=normalized_qf_version,
119
+ fallback_source=qf_version_source,
120
+ )
121
+
122
+ user_info = login_result.get("userVO") or login_result.get("userInfo") or {}
123
+ if not isinstance(user_info, dict):
124
+ user_info = {}
125
+ verified_user_info, verified_qf_version = self._try_fetch_user_info(
126
+ normalized_base_url,
127
+ token,
128
+ qf_version=resolved_qf_version,
129
+ qf_version_source=resolved_qf_version_source,
130
+ )
131
+ if verified_qf_version:
132
+ resolved_qf_version, resolved_qf_version_source = self._resolve_backend_qf_version(
133
+ verified_qf_version,
134
+ fallback_qf_version=resolved_qf_version,
135
+ fallback_source=resolved_qf_version_source,
136
+ )
137
+ if isinstance(verified_user_info, dict):
138
+ user_info = verified_user_info
139
+ last_ws_info = user_info.get("lastWsInfo") or {}
140
+ session_profile = self.sessions.save_session(
141
+ profile=profile,
142
+ base_url=normalized_base_url,
143
+ qf_version=resolved_qf_version,
144
+ qf_version_source=resolved_qf_version_source,
145
+ token=token,
146
+ login_token=login_token,
147
+ uid=int(user_info.get("uid")),
148
+ email=user_info.get("email"),
149
+ nick_name=user_info.get("nickName"),
150
+ persist=persist,
151
+ )
152
+ return {
153
+ "profile": session_profile.profile,
154
+ "base_url": session_profile.base_url,
155
+ "qf_version": session_profile.qf_version,
156
+ "qf_version_source": session_profile.qf_version_source,
157
+ "uid": session_profile.uid,
158
+ "email": session_profile.email,
159
+ "nick_name": session_profile.nick_name,
160
+ "selected_ws_id": session_profile.selected_ws_id,
161
+ "selected_ws_name": session_profile.selected_ws_name,
162
+ "suggested_ws_id": last_ws_info.get("wsId"),
163
+ "suggested_ws_name": last_ws_info.get("wsName") or last_ws_info.get("workspaceName"),
164
+ "persisted": session_profile.persisted,
165
+ "request_route": self._request_route_payload(
166
+ BackendRequestContext(
167
+ base_url=session_profile.base_url,
168
+ token=token,
169
+ ws_id=session_profile.selected_ws_id,
170
+ qf_version=session_profile.qf_version,
171
+ qf_version_source=session_profile.qf_version_source,
172
+ )
173
+ ),
174
+ }
175
+
176
+ def auth_use_token(
177
+ self,
178
+ *,
179
+ profile: str = DEFAULT_PROFILE,
180
+ base_url: str | None = None,
181
+ qf_version: str | None = None,
182
+ token: str,
183
+ ws_id: int | None = None,
184
+ persist: bool = False,
185
+ ) -> dict[str, Any]:
186
+ normalized_base_url = self._normalize_base_url(base_url)
187
+ normalized_qf_version, qf_version_source = self._resolve_qf_version_input(qf_version)
188
+ qf_version_explicit = qf_version is not None and bool(str(qf_version).strip())
189
+ if not token:
190
+ raise_tool_error(QingflowApiError.config_error("token is required"))
191
+ if ws_id is not None and ws_id <= 0:
192
+ raise_tool_error(QingflowApiError.config_error("ws_id must be positive"))
193
+ try:
194
+ user_info, detected_qf_version = self._fetch_user_info(
195
+ normalized_base_url,
196
+ token,
197
+ ws_id,
198
+ qf_version=normalized_qf_version,
199
+ qf_version_source=qf_version_source,
200
+ )
201
+ resolved_qf_version, resolved_qf_version_source = self._resolve_backend_qf_version(
202
+ detected_qf_version,
203
+ fallback_qf_version=normalized_qf_version,
204
+ fallback_source=qf_version_source,
205
+ )
206
+ uid = user_info.get("uid")
207
+ if uid is None:
208
+ raise_tool_error(QingflowApiError(category="auth", message="Token validation did not return valid user info"))
209
+ last_ws_info = user_info.get("lastWsInfo") or {}
210
+ session_profile = self.sessions.save_session(
211
+ profile=profile,
212
+ base_url=normalized_base_url,
213
+ qf_version=resolved_qf_version,
214
+ qf_version_source=resolved_qf_version_source,
215
+ token=token,
216
+ login_token=None,
217
+ uid=int(uid),
218
+ email=user_info.get("email"),
219
+ nick_name=user_info.get("nickName") or user_info.get("displayName") or user_info.get("name"),
220
+ persist=persist,
221
+ )
222
+ selected_ws_name = None
223
+ if ws_id is not None:
224
+ workspace = self._fetch_workspace(
225
+ normalized_base_url,
226
+ token,
227
+ ws_id,
228
+ qf_version=resolved_qf_version,
229
+ qf_version_source=resolved_qf_version_source,
230
+ )
231
+ workspace_qf_version = self._workspace_system_version(workspace)
232
+ if not qf_version_explicit and workspace_qf_version is not None and workspace_qf_version != resolved_qf_version:
233
+ resolved_qf_version = workspace_qf_version
234
+ resolved_qf_version_source = "workspace_system_version"
235
+ session_profile = self.sessions.update_route(
236
+ profile,
237
+ qf_version=resolved_qf_version,
238
+ qf_version_source=resolved_qf_version_source,
239
+ )
240
+ selected_ws_name = workspace.get("workspaceName") or workspace.get("wsName") or workspace.get("remark")
241
+ session_profile = self.sessions.select_workspace(profile, ws_id=ws_id, ws_name=selected_ws_name)
242
+ return {
243
+ "profile": session_profile.profile,
244
+ "base_url": session_profile.base_url,
245
+ "qf_version": session_profile.qf_version,
246
+ "qf_version_source": session_profile.qf_version_source,
247
+ "uid": session_profile.uid,
248
+ "email": session_profile.email,
249
+ "nick_name": session_profile.nick_name,
250
+ "selected_ws_id": session_profile.selected_ws_id,
251
+ "selected_ws_name": session_profile.selected_ws_name,
252
+ "suggested_ws_id": last_ws_info.get("wsId"),
253
+ "suggested_ws_name": last_ws_info.get("wsName") or last_ws_info.get("workspaceName"),
254
+ "persisted": session_profile.persisted,
255
+ "request_route": self._request_route_payload(
256
+ BackendRequestContext(
257
+ base_url=session_profile.base_url,
258
+ token=token,
259
+ ws_id=session_profile.selected_ws_id,
260
+ qf_version=session_profile.qf_version,
261
+ qf_version_source=session_profile.qf_version_source,
262
+ )
263
+ ),
264
+ }
265
+ except QingflowApiError as error:
266
+ if self._should_allow_provisional_token_session(error):
267
+ session_profile = self.sessions.save_session(
268
+ profile=profile,
269
+ base_url=normalized_base_url,
270
+ qf_version=normalized_qf_version,
271
+ qf_version_source=qf_version_source,
272
+ token=token,
273
+ login_token=None,
274
+ uid=0,
275
+ email=None,
276
+ nick_name=None,
277
+ persist=persist,
278
+ )
279
+ if ws_id is not None:
280
+ session_profile = self.sessions.select_workspace(profile, ws_id=ws_id, ws_name=None)
281
+ return {
282
+ "profile": session_profile.profile,
283
+ "base_url": session_profile.base_url,
284
+ "qf_version": session_profile.qf_version,
285
+ "qf_version_source": session_profile.qf_version_source,
286
+ "uid": session_profile.uid,
287
+ "email": session_profile.email,
288
+ "nick_name": session_profile.nick_name,
289
+ "selected_ws_id": session_profile.selected_ws_id,
290
+ "selected_ws_name": session_profile.selected_ws_name,
291
+ "suggested_ws_id": ws_id,
292
+ "suggested_ws_name": None,
293
+ "persisted": session_profile.persisted,
294
+ "provisional": True,
295
+ "request_route": self._request_route_payload(
296
+ BackendRequestContext(
297
+ base_url=session_profile.base_url,
298
+ token=token,
299
+ ws_id=session_profile.selected_ws_id,
300
+ qf_version=session_profile.qf_version,
301
+ qf_version_source=session_profile.qf_version_source,
302
+ )
303
+ ),
304
+ }
305
+ self._handle_error(profile, error)
306
+ raise AssertionError("unreachable")
307
+
308
+ def auth_whoami(self, *, profile: str = DEFAULT_PROFILE) -> dict[str, Any]:
309
+ try:
310
+ session_profile, backend_session, context = self._require_context(profile, require_workspace=False)
311
+ except QingflowApiError as error:
312
+ self._handle_error(profile, error)
313
+ raise AssertionError("unreachable")
314
+ if self._should_refresh_identity_metadata(session_profile):
315
+ refreshed_profile = self._refresh_identity_metadata(
316
+ profile=profile,
317
+ session_profile=session_profile,
318
+ backend_session=backend_session,
319
+ context=context,
320
+ )
321
+ if refreshed_profile is not None:
322
+ session_profile = refreshed_profile
323
+ return {
324
+ "profile": session_profile.profile,
325
+ "base_url": session_profile.base_url,
326
+ "qf_version": session_profile.qf_version,
327
+ "qf_version_source": session_profile.qf_version_source,
328
+ "uid": session_profile.uid,
329
+ "email": session_profile.email,
330
+ "nick_name": session_profile.nick_name,
331
+ "selected_ws_id": session_profile.selected_ws_id,
332
+ "selected_ws_name": session_profile.selected_ws_name,
333
+ "persisted": session_profile.persisted,
334
+ "request_route": self._request_route_payload(context),
335
+ }
336
+
337
+ def auth_logout(self, *, profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict[str, Any]:
338
+ if not self.sessions.has_profile(profile):
339
+ raise_tool_error(QingflowApiError.auth_required(profile))
340
+ self.sessions.logout(profile, forget_persisted=forget_persisted)
341
+ return {
342
+ "profile": profile,
343
+ "logged_out": True,
344
+ "forgot_persisted": forget_persisted,
345
+ }
346
+
347
+ def _normalize_base_url(self, base_url: str | None) -> str:
348
+ normalized_base_url = normalize_base_url(base_url) or get_default_base_url()
349
+ if not normalized_base_url:
350
+ raise_tool_error(
351
+ QingflowApiError.config_error(
352
+ "base_url is required or configure default_base_url / QINGFLOW_MCP_DEFAULT_BASE_URL"
353
+ )
354
+ )
355
+ return normalized_base_url
356
+
357
+ def _normalize_qf_version(self, qf_version: str | None) -> str | None:
358
+ if qf_version is not None:
359
+ normalized = str(qf_version).strip()
360
+ return normalized or None
361
+ return get_default_qf_version()
362
+
363
+ def _resolve_qf_version_input(self, qf_version: str | None) -> tuple[str | None, str]:
364
+ if qf_version is not None:
365
+ normalized = self._normalize_qf_version(qf_version)
366
+ return normalized, "explicit" if normalized else "unset"
367
+ normalized = self._normalize_qf_version(None)
368
+ if normalized:
369
+ return normalized, "default_config"
370
+ return None, "unset"
371
+
372
+ def _should_allow_provisional_token_session(self, error: QingflowApiError) -> bool:
373
+ if error.looks_like_invalid_token():
374
+ return False
375
+ return error.http_status == 404 and error.category in {"http", "auth", "workspace"}
376
+
377
+ def _resolve_backend_qf_version(
378
+ self,
379
+ backend_qf_version: str | None,
380
+ *,
381
+ fallback_qf_version: str | None,
382
+ fallback_source: str,
383
+ ) -> tuple[str | None, str]:
384
+ if backend_qf_version:
385
+ return backend_qf_version, "backend_response"
386
+ return fallback_qf_version, fallback_source
387
+
388
+ def _workspace_system_version(self, workspace: Any) -> str | None:
389
+ if not isinstance(workspace, dict):
390
+ return None
391
+ value = workspace.get("systemVersion")
392
+ if value is None:
393
+ return None
394
+ normalized = str(value).strip()
395
+ return normalized or None
396
+
397
+ def _fetch_user_info(
398
+ self,
399
+ base_url: str,
400
+ token: str,
401
+ ws_id: int | None,
402
+ *,
403
+ qf_version: str | None,
404
+ qf_version_source: str | None,
405
+ ) -> tuple[dict[str, Any], str | None]:
406
+ request_context = BackendRequestContext(
407
+ base_url=base_url,
408
+ token=token,
409
+ ws_id=ws_id,
410
+ qf_version=qf_version,
411
+ qf_version_source=qf_version_source,
412
+ )
413
+ try:
414
+ user_response = self.backend.request_with_meta("GET", request_context, "/user")
415
+ user_info = user_response.data
416
+ if isinstance(user_info, dict):
417
+ return user_info, user_response.qf_response_version
418
+ except QingflowApiError as original_error:
419
+ if ws_id is not None:
420
+ raise original_error
421
+ first_workspace, workspace_qf_version = self._fetch_first_workspace(
422
+ base_url,
423
+ token,
424
+ qf_version=qf_version,
425
+ qf_version_source=qf_version_source,
426
+ )
427
+ if not first_workspace:
428
+ raise original_error
429
+ first_ws_id = first_workspace.get("wsId")
430
+ if not first_ws_id:
431
+ raise original_error
432
+ effective_qf_version = workspace_qf_version or qf_version
433
+ effective_qf_version_source = "backend_response" if workspace_qf_version else qf_version_source
434
+ fallback_context = BackendRequestContext(
435
+ base_url=base_url,
436
+ token=token,
437
+ ws_id=int(first_ws_id),
438
+ qf_version=effective_qf_version,
439
+ qf_version_source=effective_qf_version_source,
440
+ )
441
+ user_response = self.backend.request_with_meta("GET", fallback_context, "/user")
442
+ user_info = user_response.data
443
+ if isinstance(user_info, dict):
444
+ return user_info, user_response.qf_response_version or effective_qf_version
445
+ raise original_error
446
+ raise_tool_error(QingflowApiError(category="auth", message="Token validation did not return valid user info"))
447
+
448
+ def _try_fetch_user_info(
449
+ self,
450
+ base_url: str,
451
+ token: str,
452
+ *,
453
+ qf_version: str | None,
454
+ qf_version_source: str | None,
455
+ ) -> tuple[dict[str, Any] | None, str | None]:
456
+ try:
457
+ return self._fetch_user_info(
458
+ base_url,
459
+ token,
460
+ None,
461
+ qf_version=qf_version,
462
+ qf_version_source=qf_version_source,
463
+ )
464
+ except QingflowApiError:
465
+ return None, None
466
+
467
+ def _fetch_first_workspace(
468
+ self,
469
+ base_url: str,
470
+ token: str,
471
+ *,
472
+ qf_version: str | None,
473
+ qf_version_source: str | None,
474
+ ) -> tuple[dict[str, Any] | None, str | None]:
475
+ page_response = self.backend.request_with_meta(
476
+ "POST",
477
+ BackendRequestContext(
478
+ base_url=base_url,
479
+ token=token,
480
+ ws_id=None,
481
+ qf_version=qf_version,
482
+ qf_version_source=qf_version_source,
483
+ ),
484
+ "/user/workspaceList/pageQuery",
485
+ json_body={"pageNum": 1, "pageSize": 1},
486
+ )
487
+ page = page_response.data
488
+ if not isinstance(page, dict):
489
+ return None, page_response.qf_response_version
490
+ workspaces = page.get("list") or []
491
+ if not workspaces:
492
+ return None, page_response.qf_response_version
493
+ first_workspace = workspaces[0]
494
+ return (first_workspace if isinstance(first_workspace, dict) else None), page_response.qf_response_version
495
+
496
+ def _fetch_workspace(
497
+ self,
498
+ base_url: str,
499
+ token: str,
500
+ ws_id: int,
501
+ *,
502
+ qf_version: str | None,
503
+ qf_version_source: str | None,
504
+ ) -> dict[str, Any]:
505
+ workspace = self.backend.request(
506
+ "GET",
507
+ BackendRequestContext(
508
+ base_url=base_url,
509
+ token=token,
510
+ ws_id=None,
511
+ qf_version=qf_version,
512
+ qf_version_source=qf_version_source,
513
+ ),
514
+ f"/user/workspace/{ws_id}",
515
+ )
516
+ if not isinstance(workspace, dict):
517
+ raise_tool_error(QingflowApiError(category="workspace", message=f"Workspace {ws_id} is not accessible"))
518
+ return workspace
519
+
520
+ def _request_route_payload(self, context: BackendRequestContext) -> dict[str, Any]:
521
+ describe_route = getattr(self.backend, "describe_route", None)
522
+ if callable(describe_route):
523
+ payload = describe_route(context)
524
+ if isinstance(payload, dict):
525
+ return payload
526
+ return {
527
+ "base_url": context.base_url,
528
+ "qf_version": context.qf_version,
529
+ "qf_version_source": context.qf_version_source or ("context" if context.qf_version else "unknown"),
530
+ }
531
+
532
+ def _should_refresh_identity_metadata(self, session_profile) -> bool: # type: ignore[no-untyped-def]
533
+ return (
534
+ session_profile.uid == 0
535
+ or session_profile.email is None
536
+ or session_profile.nick_name is None
537
+ or session_profile.selected_ws_name is None
538
+ )
539
+
540
+ def _refresh_identity_metadata(
541
+ self,
542
+ *,
543
+ profile: str,
544
+ session_profile, # type: ignore[no-untyped-def]
545
+ backend_session, # type: ignore[no-untyped-def]
546
+ context: BackendRequestContext,
547
+ ):
548
+ try:
549
+ user_info, _ = self._fetch_user_info(
550
+ session_profile.base_url,
551
+ backend_session.token,
552
+ session_profile.selected_ws_id,
553
+ qf_version=session_profile.qf_version,
554
+ qf_version_source=session_profile.qf_version_source,
555
+ )
556
+ except QingflowApiError:
557
+ return None
558
+
559
+ ws_name = session_profile.selected_ws_name
560
+ if session_profile.selected_ws_id is not None:
561
+ workspace = self._fetch_workspace_with_name_fallback(
562
+ session_profile.base_url,
563
+ backend_session.token,
564
+ session_profile.selected_ws_id,
565
+ qf_version=session_profile.qf_version,
566
+ qf_version_source=session_profile.qf_version_source,
567
+ )
568
+ if isinstance(workspace, dict):
569
+ ws_name = (
570
+ str(workspace.get("workspaceName") or workspace.get("wsName") or workspace.get("remark") or "").strip()
571
+ or ws_name
572
+ )
573
+ email = user_info["email"] if "email" in user_info else session_profile.email
574
+ nick_name = (
575
+ user_info.get("nickName")
576
+ or user_info.get("displayName")
577
+ or user_info.get("name")
578
+ or session_profile.nick_name
579
+ )
580
+
581
+ uid = user_info.get("uid")
582
+ refreshed = self.sessions.update_profile_metadata(
583
+ profile,
584
+ uid=int(uid) if uid is not None else session_profile.uid,
585
+ email=email,
586
+ nick_name=nick_name,
587
+ selected_ws_id=session_profile.selected_ws_id,
588
+ selected_ws_name=ws_name,
589
+ )
590
+ return refreshed
591
+
592
+ def _fetch_workspace_with_name_fallback(
593
+ self,
594
+ base_url: str,
595
+ token: str,
596
+ ws_id: int,
597
+ *,
598
+ qf_version: str | None,
599
+ qf_version_source: str | None,
600
+ ) -> dict[str, Any] | None:
601
+ try:
602
+ workspace = self._fetch_workspace(
603
+ base_url,
604
+ token,
605
+ ws_id,
606
+ qf_version=qf_version,
607
+ qf_version_source=qf_version_source,
608
+ )
609
+ except QingflowApiError:
610
+ workspace = None
611
+ if isinstance(workspace, dict):
612
+ workspace_name = str(workspace.get("workspaceName") or workspace.get("wsName") or "").strip()
613
+ if workspace_name:
614
+ return workspace
615
+ try:
616
+ fallback = self._fetch_workspace_from_list(
617
+ base_url,
618
+ token,
619
+ ws_id,
620
+ qf_version=qf_version,
621
+ qf_version_source=qf_version_source,
622
+ )
623
+ except QingflowApiError:
624
+ fallback = None
625
+ return fallback or workspace
626
+
627
+ def _fetch_workspace_from_list(
628
+ self,
629
+ base_url: str,
630
+ token: str,
631
+ ws_id: int,
632
+ *,
633
+ qf_version: str | None,
634
+ qf_version_source: str | None,
635
+ ) -> dict[str, Any] | None:
636
+ payload = self.backend.request(
637
+ "POST",
638
+ BackendRequestContext(
639
+ base_url=base_url,
640
+ token=token,
641
+ ws_id=ws_id,
642
+ qf_version=qf_version,
643
+ qf_version_source=qf_version_source,
644
+ ),
645
+ "/user/workspaceList/pageQuery",
646
+ json_body={"pageNum": 1, "pageSize": 100, "authList": [0, 1, 2]},
647
+ )
648
+ workspaces = payload.get("list") if isinstance(payload, dict) else []
649
+ if not isinstance(workspaces, list):
650
+ return None
651
+ found = next(
652
+ (
653
+ item
654
+ for item in workspaces
655
+ if isinstance(item, dict) and item.get("wsId") == ws_id
656
+ ),
657
+ None,
658
+ )
659
+ return found if isinstance(found, dict) else None
660
+
661
+ def _fetch_public_key(self, base_url: str, *, qf_version: str | None) -> str | None:
662
+ # Endpoints to try (order matters, lowercase 'pubkey' is for Public Cloud)
663
+ endpoints = ["/user/pubkey", "/api/user/pubkey", "/user/publicKey", "/api/user/publicKey"]
664
+ for endpoint in endpoints:
665
+ try:
666
+ # We use unwrap=False to handle various response formats
667
+ result = self.backend.public_request("GET", base_url, endpoint, unwrap=False, qf_version=qf_version)
668
+ if isinstance(result, dict):
669
+ # Try various common response structures
670
+ data = result.get("data") or result.get("result") or result
671
+ if isinstance(data, dict):
672
+ # Try case-insensitive keys
673
+ for key in ["pubkey", "publicKey", "pubKey"]:
674
+ if key in data:
675
+ return str(data[key])
676
+ if isinstance(data, str) and not data.startswith("{"):
677
+ return data
678
+ except Exception:
679
+ continue
680
+
681
+ return None
682
+
683
+ def _encrypt_password(self, password: str, public_key_str: str) -> str:
684
+ try:
685
+ if not public_key_str.startswith("-----BEGIN"):
686
+ key_content = public_key_str.strip()
687
+ formatted_key = f"-----BEGIN PUBLIC KEY-----\n{key_content}\n-----END PUBLIC KEY-----"
688
+ else:
689
+ formatted_key = public_key_str
690
+
691
+ key = RSA.import_key(formatted_key)
692
+ cipher = PKCS1_v1_5.new(key)
693
+ encrypted = cipher.encrypt(password.encode("utf-8"))
694
+ return base64.b64encode(encrypted).decode("utf-8")
695
+ except Exception as e:
696
+ # If encryption fails, fallback to plain text (might be a legacy or custom environment)
697
+ return password