@josephyan/qingflow-cli 0.2.0-beta.988 → 0.2.0-beta.990

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.
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from Crypto.Cipher import PKCS1_v1_5
8
+ from Crypto.PublicKey import RSA
9
+
10
+ from ..backend_client import BackendClient
11
+ from ..config import get_default_base_url, get_timeout_seconds, normalize_base_url
12
+ from ..errors import QingflowApiError
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class QingflowNativeLoginResult:
17
+ token: str
18
+ user_info: dict[str, Any]
19
+ login_token: str | None = None
20
+ flow: str = "qingflow_password"
21
+
22
+
23
+ class QingflowNativeLoginHelper:
24
+ def __init__(self, *, backend: BackendClient | None = None) -> None:
25
+ self._owns_backend = backend is None
26
+ self._backend = backend or BackendClient(timeout=get_timeout_seconds())
27
+
28
+ def close(self) -> None:
29
+ if self._owns_backend:
30
+ self._backend.close()
31
+
32
+ def login_with_password(
33
+ self,
34
+ *,
35
+ base_url: str | None,
36
+ email: str,
37
+ password: str,
38
+ ) -> QingflowNativeLoginResult:
39
+ normalized_base_url = normalize_base_url(base_url) or get_default_base_url()
40
+ normalized_email = str(email or "").strip()
41
+ normalized_password = str(password or "")
42
+ if not normalized_base_url:
43
+ raise QingflowApiError.config_error("base_url is required or configure default_base_url")
44
+ if not normalized_email:
45
+ raise QingflowApiError.config_error("email is required for Qingflow account login")
46
+ if not normalized_password:
47
+ raise QingflowApiError.config_error("password is required for Qingflow account login")
48
+
49
+ pubkey_payload = self._backend.public_request("GET", normalized_base_url, "/user/pubkey", qf_version=None)
50
+ pubkey = self._extract_pubkey(pubkey_payload)
51
+ encrypted_password = _encrypt_password(normalized_password, pubkey)
52
+ login_payload = self._backend.public_request(
53
+ "POST",
54
+ normalized_base_url,
55
+ "/user/login",
56
+ json_body={"email": normalized_email, "password": encrypted_password},
57
+ qf_version=None,
58
+ )
59
+ if not isinstance(login_payload, dict):
60
+ raise QingflowApiError(category="auth", message="Qingflow login did not return a valid response")
61
+
62
+ token = str(login_payload.get("token") or "").strip()
63
+ login_token = str(login_payload.get("loginToken") or "").strip() or None
64
+ if not token:
65
+ if login_token:
66
+ raise QingflowApiError(
67
+ category="auth",
68
+ message=(
69
+ "Qingflow account login requires additional security verification. "
70
+ "CLI password login currently does not complete the loginToken verification step."
71
+ ),
72
+ details={"login_token_present": True},
73
+ )
74
+ raise QingflowApiError(
75
+ category="auth",
76
+ message="Qingflow login succeeded but did not return a token",
77
+ )
78
+
79
+ user_info = login_payload.get("userInfo")
80
+ if not isinstance(user_info, dict):
81
+ user_info = {}
82
+ return QingflowNativeLoginResult(
83
+ token=token,
84
+ login_token=login_token,
85
+ user_info=user_info,
86
+ )
87
+
88
+ def _extract_pubkey(self, payload: Any) -> str:
89
+ if not isinstance(payload, dict):
90
+ raise QingflowApiError(category="auth", message="Qingflow pubkey response is invalid")
91
+ pubkey = str(payload.get("pubkey") or "").strip()
92
+ if not pubkey:
93
+ raise QingflowApiError(category="auth", message="Qingflow pubkey response did not include pubkey")
94
+ return pubkey
95
+
96
+
97
+ def login_with_qingflow_password(
98
+ *,
99
+ base_url: str | None,
100
+ email: str,
101
+ password: str,
102
+ ) -> QingflowNativeLoginResult:
103
+ helper = QingflowNativeLoginHelper()
104
+ try:
105
+ return helper.login_with_password(base_url=base_url, email=email, password=password)
106
+ finally:
107
+ helper.close()
108
+
109
+
110
+ def _encrypt_password(password: str, pubkey: str) -> str:
111
+ public_key = RSA.import_key(
112
+ "-----BEGIN PUBLIC KEY-----\n" + pubkey.strip() + "\n-----END PUBLIC KEY-----\n"
113
+ )
114
+ cipher = PKCS1_v1_5.new(public_key)
115
+ encrypted = cipher.encrypt(password.encode("utf-8"))
116
+ return base64.b64encode(encrypted).decode("ascii")
@@ -43,14 +43,14 @@ class QingflowApiError(Exception):
43
43
  def auth_required(cls, profile: str) -> "QingflowApiError":
44
44
  return cls(
45
45
  category="auth",
46
- message=f"Profile '{profile}' is not logged in. Run auth_use_credential first.",
46
+ message=f"Profile '{profile}' is not logged in. Run auth login or auth_use_credential first.",
47
47
  )
48
48
 
49
49
  @classmethod
50
50
  def workspace_not_selected(cls, profile: str) -> "QingflowApiError":
51
51
  return cls(
52
52
  category="workspace",
53
- message=f"WORKSPACE_NOT_SELECTED: profile '{profile}' has no workspace from auth context. Re-run auth_use_credential.",
53
+ message=f"WORKSPACE_NOT_SELECTED: profile '{profile}' has no workspace from auth context. Re-run auth login or auth_use_credential.",
54
54
  )
55
55
 
56
56
  @classmethod
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .errors import QingflowApiError
6
+
7
+
8
+ JS_MAX_SAFE_INTEGER = 9_007_199_254_740_991
9
+
10
+
11
+ def stringify_backend_id(value: Any) -> str | None:
12
+ """Return an exact public id string for backend-originated identifiers."""
13
+ if value in (None, ""):
14
+ return None
15
+ if isinstance(value, bool):
16
+ return None
17
+ text = str(value).strip()
18
+ return text or None
19
+
20
+
21
+ def normalize_positive_id_text(value: Any, *, field_name: str) -> str:
22
+ """Normalize a user-supplied id while rejecting JS-unsafe numeric input."""
23
+ if value in (None, "") or isinstance(value, bool):
24
+ raise QingflowApiError.config_error(f"{field_name} must be positive")
25
+ if isinstance(value, int):
26
+ if value <= 0:
27
+ raise QingflowApiError.config_error(f"{field_name} must be positive")
28
+ if value > JS_MAX_SAFE_INTEGER:
29
+ raise QingflowApiError.config_error(
30
+ f"{field_name} exceeds JavaScript's safe integer range; pass it as a string to avoid precision loss"
31
+ )
32
+ return str(value)
33
+ if isinstance(value, str):
34
+ text = value.strip()
35
+ if not text.isdecimal() or int(text) <= 0:
36
+ raise QingflowApiError.config_error(f"{field_name} must be a positive integer string")
37
+ return text
38
+ raise QingflowApiError.config_error(f"{field_name} must be a positive integer string")
39
+
40
+
41
+ def normalize_positive_id_int(value: Any, *, field_name: str) -> int:
42
+ """Normalize an id to Python int after the public boundary preserves it as text."""
43
+ return int(normalize_positive_id_text(value, field_name=field_name))
44
+
45
+
46
+ def ids_equal(left: Any, right: Any) -> bool:
47
+ left_text = stringify_backend_id(left)
48
+ right_text = stringify_backend_id(right)
49
+ return left_text is not None and right_text is not None and left_text == right_text
@@ -30,6 +30,7 @@ def tool_key(domain: str, tool_name: str) -> str:
30
30
 
31
31
 
32
32
  USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
33
+ PublicToolSpec(USER_DOMAIN, "auth_login", cli_route=("auth", "login"), mcp_public=False),
33
34
  PublicToolSpec(USER_DOMAIN, "auth_use_credential", ("auth_use_credential",), ("auth", "use-credential")),
34
35
  PublicToolSpec(USER_DOMAIN, "auth_whoami", ("auth_whoami",), ("auth", "whoami")),
35
36
  PublicToolSpec(USER_DOMAIN, "auth_logout", ("auth_logout",), ("auth", "logout")),
@@ -263,6 +263,15 @@ def _trim_workspace_list(payload: JSONObject) -> None:
263
263
  _trim_item_list(page, "list", allowed=("wsId", "workspaceName", "remark"))
264
264
 
265
265
 
266
+ def _trim_workspace_get(payload: JSONObject) -> None:
267
+ workspace = payload.get("workspace")
268
+ if isinstance(workspace, dict):
269
+ payload["workspace"] = _pick(
270
+ workspace,
271
+ allowed=("wsId", "workspaceName", "remark", "systemVersion", "auth"),
272
+ )
273
+
274
+
266
275
  def _trim_app_search_like(payload: JSONObject) -> None:
267
276
  payload.pop("apps", None)
268
277
  _trim_item_list(payload, "items", allowed=("app_key", "app_name", "package_name"))
@@ -728,6 +737,7 @@ def _register_policy(domains: tuple[str, ...], names: tuple[str, ...], transform
728
737
  _register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("auth_use_credential", "auth_whoami"), _trim_auth_payload)
729
738
  _register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("auth_logout",), _trim_auth_logout)
730
739
  _register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("workspace_list",), _trim_workspace_list)
740
+ _register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("workspace_get",), _trim_workspace_get)
731
741
  _register_policy((USER_DOMAIN,), ("app_list", "app_search"), _trim_app_search_like)
732
742
  _register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("app_get",), _trim_app_get)
733
743
  _register_policy((BUILDER_DOMAIN,), ("app_repair_code_blocks",), _trim_builder_list_like)
@@ -164,6 +164,136 @@ class AuthTools(ToolBase):
164
164
  ),
165
165
  }
166
166
 
167
+ def auth_use_token(
168
+ self,
169
+ *,
170
+ profile: str = DEFAULT_PROFILE,
171
+ base_url: str | None = None,
172
+ qf_version: str | None = None,
173
+ token: str | None = None,
174
+ login_token: str | None = None,
175
+ persist: bool = False,
176
+ user_info: dict[str, Any] | None = None,
177
+ ) -> dict[str, Any]:
178
+ """使用已获得的 Qingflow token 建立本地会话。"""
179
+ normalized_base_url = self._normalize_base_url(base_url)
180
+ normalized_qf_version, qf_version_source = self._resolve_qf_version_input(qf_version)
181
+ normalized_token = self._normalize_text(token)
182
+ normalized_login_token = self._normalize_text(login_token)
183
+ if not normalized_token:
184
+ raise_tool_error(QingflowApiError.config_error("token is required"))
185
+
186
+ resolved_user_info = user_info if isinstance(user_info, dict) else None
187
+ response_qf_version: str | None = None
188
+ if resolved_user_info is None:
189
+ resolved_user_info, response_qf_version = self._fetch_user_info(
190
+ normalized_base_url,
191
+ normalized_token,
192
+ None,
193
+ qf_version=normalized_qf_version,
194
+ qf_version_source=qf_version_source,
195
+ )
196
+
197
+ last_workspace = resolved_user_info.get("lastWsInfo")
198
+ selected_ws_id = self._coerce_positive_int(
199
+ last_workspace.get("wsId") if isinstance(last_workspace, dict) else None
200
+ )
201
+ selected_ws_name = self._normalize_text(
202
+ (last_workspace.get("wsName") if isinstance(last_workspace, dict) else None)
203
+ or (last_workspace.get("workspaceName") if isinstance(last_workspace, dict) else None)
204
+ or (last_workspace.get("remark") if isinstance(last_workspace, dict) else None)
205
+ )
206
+ workspace_qf_version = (
207
+ self._workspace_system_version(last_workspace) if isinstance(last_workspace, dict) else None
208
+ )
209
+ if selected_ws_id is None:
210
+ fallback_workspace, fallback_qf_version = self._fetch_first_workspace(
211
+ normalized_base_url,
212
+ normalized_token,
213
+ qf_version=normalized_qf_version,
214
+ qf_version_source=qf_version_source,
215
+ )
216
+ if isinstance(fallback_workspace, dict):
217
+ selected_ws_id = self._coerce_positive_int(fallback_workspace.get("wsId"))
218
+ selected_ws_name = self._normalize_text(
219
+ fallback_workspace.get("workspaceName")
220
+ or fallback_workspace.get("wsName")
221
+ or fallback_workspace.get("remark")
222
+ ) or selected_ws_name
223
+ workspace_qf_version = self._workspace_system_version(fallback_workspace) or fallback_qf_version
224
+ elif selected_ws_name is None or workspace_qf_version is None:
225
+ workspace = self._fetch_workspace_with_name_fallback(
226
+ normalized_base_url,
227
+ normalized_token,
228
+ selected_ws_id,
229
+ qf_version=normalized_qf_version,
230
+ qf_version_source=qf_version_source,
231
+ )
232
+ if isinstance(workspace, dict):
233
+ selected_ws_name = self._normalize_text(
234
+ workspace.get("workspaceName")
235
+ or workspace.get("wsName")
236
+ or workspace.get("remark")
237
+ ) or selected_ws_name
238
+ workspace_qf_version = self._workspace_system_version(workspace) or workspace_qf_version
239
+
240
+ if workspace_qf_version is not None:
241
+ resolved_qf_version, resolved_qf_version_source = workspace_qf_version, "workspace_system_version"
242
+ else:
243
+ resolved_qf_version, resolved_qf_version_source = self._resolve_backend_qf_version(
244
+ response_qf_version,
245
+ fallback_qf_version=normalized_qf_version,
246
+ fallback_source=qf_version_source,
247
+ )
248
+
249
+ uid = self._coerce_positive_int(resolved_user_info.get("uid"))
250
+ if uid is None:
251
+ raise_tool_error(QingflowApiError(category="auth", message="Token validation did not return valid user info"))
252
+
253
+ session_profile = self.sessions.save_session(
254
+ profile=profile,
255
+ base_url=normalized_base_url,
256
+ qf_version=resolved_qf_version,
257
+ qf_version_source=resolved_qf_version_source,
258
+ token=normalized_token,
259
+ login_token=normalized_login_token,
260
+ credential=None,
261
+ uid=uid,
262
+ email=self._normalize_text(resolved_user_info.get("email")),
263
+ nick_name=self._normalize_text(
264
+ resolved_user_info.get("nickName")
265
+ or resolved_user_info.get("displayName")
266
+ or resolved_user_info.get("name")
267
+ ),
268
+ persist=persist,
269
+ )
270
+ if selected_ws_id is not None:
271
+ session_profile = self.sessions.select_workspace(profile, ws_id=selected_ws_id, ws_name=selected_ws_name)
272
+
273
+ return {
274
+ "profile": session_profile.profile,
275
+ "base_url": session_profile.base_url,
276
+ "qf_version": session_profile.qf_version,
277
+ "qf_version_source": session_profile.qf_version_source,
278
+ "uid": session_profile.uid,
279
+ "email": session_profile.email,
280
+ "nick_name": session_profile.nick_name,
281
+ "selected_ws_id": session_profile.selected_ws_id,
282
+ "selected_ws_name": session_profile.selected_ws_name,
283
+ "suggested_ws_id": session_profile.selected_ws_id,
284
+ "suggested_ws_name": session_profile.selected_ws_name,
285
+ "persisted": session_profile.persisted,
286
+ "request_route": self._request_route_payload(
287
+ BackendRequestContext(
288
+ base_url=session_profile.base_url,
289
+ token=normalized_token,
290
+ ws_id=session_profile.selected_ws_id,
291
+ qf_version=session_profile.qf_version,
292
+ qf_version_source=session_profile.qf_version_source,
293
+ )
294
+ ),
295
+ }
296
+
167
297
  def _resolve_mcporter_auth_inputs(self, *, base_url: str | None, credential: str | None) -> tuple[str | None, str]:
168
298
  """从参数或 mcporter 配置解析登录所需 base_url 与 credential。"""
169
299
  normalized_base_url = self._normalize_text(base_url)
@@ -100,7 +100,7 @@ class ToolBase:
100
100
  self.sessions.invalidate(profile)
101
101
  error = QingflowApiError(
102
102
  category="auth",
103
- message=f"Qingflow session for profile '{profile}' has expired. Run auth_use_credential again.",
103
+ message=f"Qingflow session for profile '{profile}' has expired. Run auth login or auth_use_credential again.",
104
104
  backend_code=error.backend_code,
105
105
  request_id=error.request_id,
106
106
  http_status=error.http_status,
@@ -91,7 +91,7 @@ class CodeBlockTools(RecordTools):
91
91
  def record_code_block_run(
92
92
  profile: str = DEFAULT_PROFILE,
93
93
  app_key: str = "",
94
- record_id: int = 0,
94
+ record_id: str = "",
95
95
  code_block_field: str = "",
96
96
  role: int = 1,
97
97
  workflow_node_id: int | None = None,
@@ -197,7 +197,7 @@ class CodeBlockTools(RecordTools):
197
197
  *,
198
198
  profile: str,
199
199
  app_key: str,
200
- record_id: int,
200
+ record_id: int | str,
201
201
  code_block_field: str,
202
202
  role: int = 1,
203
203
  workflow_node_id: int | None = None,