@qingflow-tech/qingflow-cli 1.1.43-beta.5 → 1.1.44-beta.1

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.
@@ -6,6 +6,7 @@ from typing import Any
6
6
 
7
7
  from mcp.server.fastmcp import FastMCP
8
8
 
9
+ from ..auth_provider import CliSessionFacade
9
10
  from ..backend_client import BackendRequestContext, BackendResponse
10
11
  from ..cloud_context import current_cloud_context
11
12
  from ..config import (
@@ -16,6 +17,7 @@ from ..config import (
16
17
  normalize_base_url,
17
18
  )
18
19
  from ..errors import QingflowApiError, backend_code_int, is_auth_like_error, raise_tool_error
20
+ from ..oauth_token_store import OAuthTokenBundle
19
21
  from ..session_store import SessionStore
20
22
  from .base import ToolBase
21
23
 
@@ -29,7 +31,7 @@ class AuthTools(ToolBase):
29
31
  3. 退出并清理当前 profile 会话。
30
32
  """
31
33
 
32
- def __init__(self, sessions: SessionStore, backend) -> None:
34
+ def __init__(self, sessions: SessionStore | CliSessionFacade, backend) -> None:
33
35
  """执行内部辅助逻辑。"""
34
36
  super().__init__(sessions, backend)
35
37
  self.sessions.set_profile_refresher(self._refresh_profile_via_credential)
@@ -307,6 +309,130 @@ class AuthTools(ToolBase):
307
309
  ),
308
310
  }
309
311
 
312
+ def auth_use_oauth_token(
313
+ self,
314
+ *,
315
+ profile: str,
316
+ base_url: str,
317
+ qf_version: str | None,
318
+ issuer: str,
319
+ resource: str,
320
+ bundle: OAuthTokenBundle,
321
+ ) -> dict[str, Any]:
322
+ """使用 OAuth Token Bundle 建立 CLI 专用会话。"""
323
+ if not isinstance(self.sessions, CliSessionFacade):
324
+ raise_tool_error(QingflowApiError.config_error("OAuth login is only available in the Qingflow CLI."))
325
+ normalized_base_url = self._normalize_base_url(base_url)
326
+ normalized_qf_version, qf_version_source = self._resolve_qf_version_input(qf_version)
327
+ resolved_user_info, response_qf_version = self._fetch_user_info(
328
+ normalized_base_url,
329
+ bundle.access_token,
330
+ None,
331
+ qf_version=normalized_qf_version,
332
+ qf_version_source=qf_version_source,
333
+ )
334
+
335
+ last_workspace = resolved_user_info.get("lastWsInfo")
336
+ selected_ws_id = self._coerce_positive_int(
337
+ last_workspace.get("wsId") if isinstance(last_workspace, dict) else None
338
+ )
339
+ selected_ws_name = self._normalize_text(
340
+ (last_workspace.get("wsName") if isinstance(last_workspace, dict) else None)
341
+ or (last_workspace.get("workspaceName") if isinstance(last_workspace, dict) else None)
342
+ or (last_workspace.get("remark") if isinstance(last_workspace, dict) else None)
343
+ )
344
+ workspace_qf_version = (
345
+ self._workspace_system_version(last_workspace) if isinstance(last_workspace, dict) else None
346
+ )
347
+ if selected_ws_id is None:
348
+ fallback_workspace, fallback_qf_version = self._fetch_first_workspace(
349
+ normalized_base_url,
350
+ bundle.access_token,
351
+ qf_version=normalized_qf_version,
352
+ qf_version_source=qf_version_source,
353
+ )
354
+ if isinstance(fallback_workspace, dict):
355
+ selected_ws_id = self._coerce_positive_int(fallback_workspace.get("wsId"))
356
+ selected_ws_name = self._normalize_text(
357
+ fallback_workspace.get("workspaceName")
358
+ or fallback_workspace.get("wsName")
359
+ or fallback_workspace.get("remark")
360
+ ) or selected_ws_name
361
+ workspace_qf_version = self._workspace_system_version(fallback_workspace) or fallback_qf_version
362
+ elif selected_ws_name is None or workspace_qf_version is None:
363
+ workspace = self._fetch_workspace_with_name_fallback(
364
+ normalized_base_url,
365
+ bundle.access_token,
366
+ selected_ws_id,
367
+ qf_version=normalized_qf_version,
368
+ qf_version_source=qf_version_source,
369
+ )
370
+ if isinstance(workspace, dict):
371
+ selected_ws_name = self._normalize_text(
372
+ workspace.get("workspaceName")
373
+ or workspace.get("wsName")
374
+ or workspace.get("remark")
375
+ ) or selected_ws_name
376
+ workspace_qf_version = self._workspace_system_version(workspace) or workspace_qf_version
377
+
378
+ if workspace_qf_version is not None:
379
+ resolved_qf_version, resolved_qf_version_source = workspace_qf_version, "workspace_system_version"
380
+ else:
381
+ resolved_qf_version, resolved_qf_version_source = self._resolve_backend_qf_version(
382
+ response_qf_version,
383
+ fallback_qf_version=normalized_qf_version,
384
+ fallback_source=qf_version_source,
385
+ )
386
+ uid = self._coerce_positive_int(resolved_user_info.get("uid"))
387
+ if uid is None:
388
+ raise_tool_error(QingflowApiError(category="auth", message="Token validation did not return valid user info"))
389
+
390
+ state = self.sessions.activate_oauth(
391
+ profile=profile,
392
+ base_url=normalized_base_url,
393
+ qf_version=resolved_qf_version,
394
+ qf_version_source=resolved_qf_version_source,
395
+ uid=uid,
396
+ email=self._normalize_text(resolved_user_info.get("email")),
397
+ nick_name=self._normalize_text(
398
+ resolved_user_info.get("nickName")
399
+ or resolved_user_info.get("displayName")
400
+ or resolved_user_info.get("name")
401
+ ),
402
+ selected_ws_id=selected_ws_id,
403
+ selected_ws_name=selected_ws_name,
404
+ issuer=issuer,
405
+ resource=resource,
406
+ bundle=bundle,
407
+ )
408
+ return {
409
+ "profile": state.profile,
410
+ "base_url": state.base_url,
411
+ "qf_version": state.qf_version,
412
+ "qf_version_source": state.qf_version_source,
413
+ "uid": state.uid,
414
+ "email": state.email,
415
+ "nick_name": state.nick_name,
416
+ "selected_ws_id": state.selected_ws_id,
417
+ "selected_ws_name": state.selected_ws_name,
418
+ "suggested_ws_id": state.selected_ws_id,
419
+ "suggested_ws_name": state.selected_ws_name,
420
+ "persisted": True,
421
+ "active_auth_type": "oauth",
422
+ "auth_source": "oauth_pkce",
423
+ "source_code": state.source_code,
424
+ "access_expires_at": state.access_expires_at,
425
+ "request_route": self._request_route_payload(
426
+ BackendRequestContext(
427
+ base_url=state.base_url or normalized_base_url,
428
+ token=bundle.access_token,
429
+ ws_id=state.selected_ws_id,
430
+ qf_version=state.qf_version,
431
+ qf_version_source=state.qf_version_source,
432
+ )
433
+ ),
434
+ }
435
+
310
436
  def _resolve_auth_inputs(
311
437
  self,
312
438
  *,
@@ -380,6 +506,14 @@ class AuthTools(ToolBase):
380
506
 
381
507
  def auth_whoami(self, *, profile: str = DEFAULT_PROFILE) -> dict[str, Any]:
382
508
  """执行认证与会话相关逻辑。"""
509
+ if isinstance(self.sessions, CliSessionFacade) and self.sessions.oauth_state(profile) == "none":
510
+ return {
511
+ "profile": profile,
512
+ "active_auth_type": "none",
513
+ "auth_source": "none",
514
+ "persisted": False,
515
+ }
516
+
383
517
  def build_response(
384
518
  session_profile, # type: ignore[no-untyped-def]
385
519
  backend_session, # type: ignore[no-untyped-def]
@@ -439,6 +573,13 @@ class AuthTools(ToolBase):
439
573
  "persisted": session_profile.persisted,
440
574
  "request_route": self._request_route_payload(context),
441
575
  }
576
+ if isinstance(self.sessions, CliSessionFacade):
577
+ active_auth_type = self.sessions.active_auth_type(profile)
578
+ response["active_auth_type"] = active_auth_type
579
+ if active_auth_type == "oauth":
580
+ response.update(self.sessions.oauth_details(profile))
581
+ else:
582
+ response["auth_source"] = active_auth_type
442
583
  member_info, member_warnings = self._workspace_member_info(
443
584
  session_profile=session_profile,
444
585
  backend_session=backend_session,
@@ -468,8 +609,18 @@ class AuthTools(ToolBase):
468
609
  return build_response(refreshed_profile, refreshed_backend_session, refreshed_context)
469
610
  except QingflowApiError as refreshed_error:
470
611
  self._handle_error(profile, refreshed_error)
612
+ if error.looks_like_invalid_token() and self._refresh_oauth_session(profile):
613
+ try:
614
+ refreshed_profile, refreshed_backend_session, refreshed_context = self._require_context(
615
+ profile,
616
+ require_workspace=False,
617
+ )
618
+ return build_response(refreshed_profile, refreshed_backend_session, refreshed_context)
619
+ except QingflowApiError as refreshed_error:
620
+ self._handle_error(profile, refreshed_error)
471
621
  if (
472
622
  error.looks_like_invalid_token()
623
+ and not self._uses_oauth(profile)
473
624
  and session_profile is not None
474
625
  and backend_session is not None
475
626
  and self._refresh_session_from_credential(
@@ -511,6 +662,17 @@ class AuthTools(ToolBase):
511
662
 
512
663
  def auth_logout(self, *, profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict[str, Any]:
513
664
  """执行认证与会话相关逻辑。"""
665
+ if isinstance(self.sessions, CliSessionFacade) and self.sessions.uses_oauth(profile):
666
+ warnings = self.sessions.oauth_logout(profile)
667
+ result: dict[str, Any] = {
668
+ "profile": profile,
669
+ "logged_out": True,
670
+ "forgot_persisted": True,
671
+ "active_auth_type": "none",
672
+ }
673
+ if warnings:
674
+ result["warnings"] = warnings
675
+ return result
514
676
  if not self.sessions.has_profile(profile):
515
677
  raise_tool_error(QingflowApiError.auth_required(profile))
516
678
  self.sessions.logout(profile, forget_persisted=forget_persisted)
@@ -5,6 +5,7 @@ from contextvars import ContextVar
5
5
  from typing import Any, Callable, TypeVar
6
6
 
7
7
  from ..backend_client import BackendRequestContext, BackendClient
8
+ from ..auth_provider import CliSessionFacade
8
9
  from ..cloud_context import current_call_platform, current_cloud_context
9
10
  from ..config import (
10
11
  get_credit_meter_enabled,
@@ -21,7 +22,7 @@ _RUN_DEPTH: ContextVar[int] = ContextVar("qingflow_mcp_tool_run_depth", default=
21
22
 
22
23
 
23
24
  class ToolBase:
24
- def __init__(self, sessions: SessionStore, backend: BackendClient) -> None:
25
+ def __init__(self, sessions: SessionStore | CliSessionFacade, backend: BackendClient) -> None:
25
26
  self.sessions = sessions
26
27
  self.backend = backend
27
28
 
@@ -30,7 +31,8 @@ class ToolBase:
30
31
  if cloud_context is not None:
31
32
  return cloud_context.as_tool_context(profile)
32
33
 
33
- self._ensure_environment_credential_matches(profile)
34
+ if not self._uses_oauth(profile):
35
+ self._ensure_environment_credential_matches(profile)
34
36
  session_profile = self.sessions.get_profile(profile)
35
37
  if session_profile is None:
36
38
  raise QingflowApiError.auth_required(profile)
@@ -83,8 +85,18 @@ class ToolBase:
83
85
  return func(refreshed_profile, refreshed_context)
84
86
  except QingflowApiError as refreshed_error:
85
87
  self._handle_error(profile, refreshed_error)
88
+ if error.looks_like_invalid_token() and self._refresh_oauth_session(profile):
89
+ try:
90
+ refreshed_profile, _, refreshed_context = self._require_context(
91
+ profile,
92
+ require_workspace=require_workspace,
93
+ )
94
+ return func(refreshed_profile, refreshed_context)
95
+ except QingflowApiError as refreshed_error:
96
+ self._handle_error(profile, refreshed_error)
86
97
  if (
87
98
  error.looks_like_invalid_token()
99
+ and not self._uses_oauth(profile)
88
100
  and session_profile is not None
89
101
  and backend_session is not None
90
102
  and self._refresh_session_from_credential(
@@ -121,6 +133,19 @@ class ToolBase:
121
133
  http_status=error.http_status,
122
134
  )
123
135
  raise_tool_error(refreshed_error)
136
+ if self._uses_oauth(profile):
137
+ oauth_error = QingflowApiError(
138
+ category="auth",
139
+ message=(
140
+ f"OAuth session for profile '{profile}' has expired and could not be refreshed. "
141
+ "Run auth login --method oauth again."
142
+ ),
143
+ backend_code=error.backend_code,
144
+ request_id=error.request_id,
145
+ http_status=error.http_status,
146
+ details={"reason": "oauth_relogin_required"},
147
+ )
148
+ raise_tool_error(oauth_error)
124
149
  self.sessions.invalidate(profile)
125
150
  error = QingflowApiError(
126
151
  category="auth",
@@ -142,6 +167,20 @@ class ToolBase:
142
167
  current_cloud_context.set(refreshed_context)
143
168
  return True
144
169
 
170
+ def _refresh_oauth_session(self, profile: str) -> bool:
171
+ if not self._uses_oauth(profile):
172
+ return False
173
+ assert isinstance(self.sessions, CliSessionFacade)
174
+ try:
175
+ return self.sessions.refresh_oauth_session(profile, force=True)
176
+ except QingflowApiError as error:
177
+ if (error.details or {}).get("reason") == "oauth_relogin_required":
178
+ raise
179
+ return False
180
+
181
+ def _uses_oauth(self, profile: str) -> bool:
182
+ return isinstance(self.sessions, CliSessionFacade) and self.sessions.uses_oauth(profile)
183
+
145
184
  def _refresh_session_from_credential(
146
185
  self,
147
186
  profile: str,