@qingflow-tech/qingflow-cli 1.1.41 → 1.1.43

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.
@@ -10,7 +10,13 @@ from mcp.server.fastmcp import FastMCP
10
10
 
11
11
  from .backend_client import BackendClient
12
12
  from .cloud_auth import CloudAuthBroker
13
- from .cloud_context import current_cloud_context
13
+ from .cloud_context import CloudMcpContext, current_call_platform, current_cloud_context
14
+ from .cloud_oauth_proxy import (
15
+ CloudOAuthProxyConfig,
16
+ OAuthAccessTokenResolver,
17
+ OAuthProxyResponse,
18
+ OAuthServiceProxy,
19
+ )
14
20
  from .config import get_cloud_http_allowed_hosts, get_cloud_http_host, get_cloud_http_port
15
21
  from .errors import QingflowApiError, is_auth_like_error
16
22
  from .server_app_builder import build_builder_server
@@ -18,6 +24,7 @@ from .server_app_user import build_user_server
18
24
 
19
25
 
20
26
  BUILDER_TOOL_PREFIX = "builder_"
27
+ MAX_OAUTH_PROXY_BODY_BYTES = 64 * 1024
21
28
 
22
29
 
23
30
  ASGIApp = Callable[
@@ -36,11 +43,15 @@ class CloudMcpAuthApp:
36
43
  app: ASGIApp,
37
44
  broker: CloudAuthBroker,
38
45
  *,
46
+ oauth_proxy: OAuthServiceProxy | None = None,
47
+ oauth_token_resolver: OAuthAccessTokenResolver | None = None,
39
48
  allowed_hosts: list[str] | None = None,
40
49
  internal_host: str | None = None,
41
50
  ) -> None:
42
51
  self._app = app
43
52
  self._broker = broker
53
+ self._oauth_proxy = oauth_proxy
54
+ self._oauth_token_resolver = oauth_token_resolver
44
55
  self._allowed_hosts = [
45
56
  host.strip().lower()
46
57
  for host in (allowed_hosts if allowed_hosts is not None else get_cloud_http_allowed_hosts())
@@ -69,22 +80,65 @@ class CloudMcpAuthApp:
69
80
  await self._json_response(send, 200, {"status": "ok"})
70
81
  return
71
82
 
72
- credential = self._extract_credential(scope)
73
- if credential is None:
74
- await self._json_response(
75
- send,
76
- 401,
77
- {
78
- "error": "missing_mcp_credential",
79
- "message": "Authorization: Bearer <mcp_credential> is required",
80
- },
81
- )
83
+ if self._oauth_proxy is not None:
84
+ backend_metadata_path = self._oauth_proxy.config.backend_metadata_path(path)
85
+ if backend_metadata_path is not None:
86
+ await self._proxy_oauth_metadata(
87
+ scope,
88
+ send,
89
+ backend_metadata_path,
90
+ )
91
+ return
92
+ backend_oauth_path = self._oauth_proxy.config.backend_oauth_endpoint_path(path)
93
+ if backend_oauth_path is not None:
94
+ await self._proxy_oauth_endpoint(
95
+ scope,
96
+ receive,
97
+ send,
98
+ backend_oauth_path,
99
+ )
100
+ return
101
+
102
+ auth_value, explicit_credential, call_platform = self._extract_auth_value(scope)
103
+ if auth_value is None:
104
+ await self._authentication_required(send)
82
105
  return
83
106
 
107
+ use_oauth = (
108
+ not explicit_credential
109
+ and self._oauth_token_resolver is not None
110
+ and not auth_value.startswith("mcp_")
111
+ )
112
+ access_context: dict[str, Any] | None = None
84
113
  try:
85
- cloud_context = await anyio.to_thread.run_sync(self._broker.resolve, credential)
114
+ if use_oauth:
115
+ assert self._oauth_token_resolver is not None
116
+ access_context = await anyio.to_thread.run_sync(
117
+ self._broker.resolve_access_context,
118
+ auth_value,
119
+ )
120
+ if access_context is not None:
121
+ assert self._oauth_proxy is not None
122
+ token_resource = str(access_context.get("resource") or "").strip()
123
+ if token_resource != self._oauth_proxy.config.resource_url:
124
+ raise QingflowApiError(
125
+ category="auth",
126
+ message="OAuth access token was issued for a different protected resource",
127
+ )
128
+ cloud_context = await anyio.to_thread.run_sync(
129
+ self._resolve_oauth_or_legacy_credential,
130
+ auth_value,
131
+ )
132
+ else:
133
+ cloud_context = await anyio.to_thread.run_sync(
134
+ self._broker.resolve,
135
+ auth_value,
136
+ )
86
137
  except QingflowApiError as error:
87
138
  status = 401 if is_auth_like_error(error) else 500
139
+ if status == 401 and use_oauth:
140
+ await self._oauth_authentication_required(send, "invalid_token", error.message)
141
+ return
88
142
  await self._json_response(
89
143
  send,
90
144
  status,
@@ -97,30 +151,165 @@ class CloudMcpAuthApp:
97
151
  )
98
152
  return
99
153
 
100
- token = current_cloud_context.set(cloud_context)
154
+ trusted_source = (
155
+ str(access_context.get("source_code") or "").strip()
156
+ if access_context is not None
157
+ else ""
158
+ )
159
+ if trusted_source:
160
+ call_platform = trusted_source
161
+ elif not call_platform:
162
+ call_platform = await anyio.to_thread.run_sync(
163
+ self._broker.resolve_call_platform,
164
+ cloud_context.token,
165
+ )
166
+
167
+ context_token = current_cloud_context.set(cloud_context)
168
+ call_platform_token = current_call_platform.set(call_platform)
101
169
  try:
102
170
  scope = _ensure_streamable_http_accept(scope)
103
171
  await self._app(scope, receive, send)
104
172
  finally:
105
- current_cloud_context.reset(token)
173
+ current_call_platform.reset(call_platform_token)
174
+ current_cloud_context.reset(context_token)
106
175
 
107
- def _extract_credential(self, scope: dict[str, Any]) -> str | None:
176
+ def _resolve_oauth_or_legacy_credential(self, auth_value: str) -> CloudMcpContext:
177
+ assert self._oauth_token_resolver is not None
178
+ try:
179
+ return self._oauth_token_resolver.resolve(auth_value)
180
+ except QingflowApiError as oauth_error:
181
+ if not is_auth_like_error(oauth_error):
182
+ raise
183
+ try:
184
+ return self._broker.resolve(auth_value)
185
+ except QingflowApiError:
186
+ raise oauth_error
187
+
188
+ def _extract_auth_value(self, scope: dict[str, Any]) -> tuple[str | None, bool, str]:
108
189
  headers = {
109
190
  key.decode("latin1").lower(): value.decode("latin1")
110
191
  for key, value in scope.get("headers", [])
111
192
  }
193
+ call_platform = headers.get("x-qf-callplatform", "").strip()
112
194
  authorization = headers.get("authorization", "")
113
195
  if authorization.lower().startswith("bearer "):
114
- credential = authorization[7:].strip()
115
- return credential or None
196
+ bearer_token = authorization[7:].strip()
197
+ return bearer_token or None, False, call_platform
116
198
  credential = headers.get("x-qingflow-client-id", "").strip()
117
- return credential or None
199
+ return credential or None, True, call_platform
200
+
201
+ async def _authentication_required(
202
+ self,
203
+ send: Callable[[dict[str, Any]], Awaitable[None]],
204
+ ) -> None:
205
+ if self._oauth_proxy is not None:
206
+ await self._oauth_authentication_required(
207
+ send,
208
+ "invalid_token",
209
+ "Authentication required",
210
+ )
211
+ return
212
+ await self._json_response(
213
+ send,
214
+ 401,
215
+ {
216
+ "error": "missing_mcp_credential",
217
+ "message": "Authorization: Bearer <mcp_credential> is required",
218
+ },
219
+ )
220
+
221
+ async def _oauth_authentication_required(
222
+ self,
223
+ send: Callable[[dict[str, Any]], Awaitable[None]],
224
+ error: str,
225
+ description: str,
226
+ ) -> None:
227
+ assert self._oauth_proxy is not None
228
+ challenge = (
229
+ 'Bearer realm="OAuth", '
230
+ f'error="{_escape_auth_param(error)}", '
231
+ f'error_description="{_escape_auth_param(description)}", '
232
+ f'resource_metadata="{self._oauth_proxy.config.resource_metadata_url}"'
233
+ )
234
+ await self._json_response(
235
+ send,
236
+ 401,
237
+ {"error": error, "error_description": description},
238
+ extra_headers=[
239
+ (b"www-authenticate", challenge.encode("latin1", errors="replace")),
240
+ (b"cache-control", b"no-store"),
241
+ (b"pragma", b"no-cache"),
242
+ ],
243
+ )
244
+
245
+ async def _proxy_oauth_metadata(
246
+ self,
247
+ scope: dict[str, Any],
248
+ send: Callable[[dict[str, Any]], Awaitable[None]],
249
+ backend_path: str,
250
+ ) -> None:
251
+ assert self._oauth_proxy is not None
252
+ try:
253
+ response = await anyio.to_thread.run_sync(
254
+ self._oauth_proxy.request_metadata,
255
+ str(scope.get("method") or "GET"),
256
+ backend_path,
257
+ bytes(scope.get("query_string") or b""),
258
+ )
259
+ except QingflowApiError as error:
260
+ await self._json_response(
261
+ send,
262
+ 502,
263
+ {"error": "oauth_service_unavailable", "message": error.message},
264
+ )
265
+ return
266
+ await self._raw_response(send, response)
267
+
268
+ async def _proxy_oauth_endpoint(
269
+ self,
270
+ scope: dict[str, Any],
271
+ receive: Callable[[], Awaitable[dict[str, Any]]],
272
+ send: Callable[[dict[str, Any]], Awaitable[None]],
273
+ backend_path: str,
274
+ ) -> None:
275
+ assert self._oauth_proxy is not None
276
+ try:
277
+ body = await _read_request_body(receive, MAX_OAUTH_PROXY_BODY_BYTES)
278
+ except ValueError:
279
+ await self._json_response(
280
+ send,
281
+ 413,
282
+ {
283
+ "error": "invalid_request",
284
+ "error_description": "OAuth request body is too large",
285
+ },
286
+ )
287
+ return
288
+ try:
289
+ response = await anyio.to_thread.run_sync(
290
+ self._oauth_proxy.request_oauth_endpoint,
291
+ str(scope.get("method") or "GET"),
292
+ backend_path,
293
+ bytes(scope.get("query_string") or b""),
294
+ body,
295
+ tuple(scope.get("headers") or ()),
296
+ )
297
+ except QingflowApiError as error:
298
+ await self._json_response(
299
+ send,
300
+ 502,
301
+ {"error": "oauth_service_unavailable", "message": error.message},
302
+ )
303
+ return
304
+ await self._raw_response(send, response)
118
305
 
119
306
  async def _json_response(
120
307
  self,
121
308
  send: Callable[[dict[str, Any]], Awaitable[None]],
122
309
  status: int,
123
310
  payload: dict[str, Any],
311
+ *,
312
+ extra_headers: list[tuple[bytes, bytes]] | None = None,
124
313
  ) -> None:
125
314
  body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
126
315
  await send(
@@ -130,11 +319,26 @@ class CloudMcpAuthApp:
130
319
  "headers": [
131
320
  (b"content-type", b"application/json; charset=utf-8"),
132
321
  (b"content-length", str(len(body)).encode("ascii")),
133
- ],
322
+ ] + list(extra_headers or []),
134
323
  }
135
324
  )
136
325
  await send({"type": "http.response.body", "body": body})
137
326
 
327
+ async def _raw_response(
328
+ self,
329
+ send: Callable[[dict[str, Any]], Awaitable[None]],
330
+ response: OAuthProxyResponse,
331
+ ) -> None:
332
+ await send(
333
+ {
334
+ "type": "http.response.start",
335
+ "status": response.status_code,
336
+ "headers": list(response.headers)
337
+ + [(b"content-length", str(len(response.body)).encode("ascii"))],
338
+ }
339
+ )
340
+ await send({"type": "http.response.body", "body": response.body})
341
+
138
342
  async def _plain_response(
139
343
  self,
140
344
  send: Callable[[dict[str, Any]], Awaitable[None]],
@@ -176,12 +380,25 @@ def build_cloud_user_app() -> CloudMcpAuthApp:
176
380
 
177
381
  def build_cloud_app() -> CloudMcpAuthApp:
178
382
  server = build_combined_server()
179
- broker = CloudAuthBroker.from_config(backend=BackendClient())
180
- return CloudMcpAuthApp(server.streamable_http_app(), broker)
383
+ backend = BackendClient()
384
+ broker = CloudAuthBroker.from_config(backend=backend)
385
+ oauth_config = CloudOAuthProxyConfig.from_config()
386
+ oauth_proxy = OAuthServiceProxy(oauth_config) if oauth_config is not None else None
387
+ oauth_token_resolver = (
388
+ OAuthAccessTokenResolver.from_config(backend)
389
+ if oauth_config is not None
390
+ else None
391
+ )
392
+ return CloudMcpAuthApp(
393
+ server.streamable_http_app(),
394
+ broker,
395
+ oauth_proxy=oauth_proxy,
396
+ oauth_token_resolver=oauth_token_resolver,
397
+ )
181
398
 
182
399
 
183
400
  def build_combined_server() -> FastMCP:
184
- server = build_user_server()
401
+ server = build_user_server(stateless_http=True)
185
402
  builder_server = build_builder_server()
186
403
  server._mcp_server.name = "Qingflow Combined MCP"
187
404
  _register_prefixed_builder_tools(server, builder_server)
@@ -237,6 +454,22 @@ def _extract_host(scope: dict[str, Any]) -> str:
237
454
  return ""
238
455
 
239
456
 
457
+ async def _read_request_body(
458
+ receive: Callable[[], Awaitable[dict[str, Any]]],
459
+ limit: int,
460
+ ) -> bytes:
461
+ body = bytearray()
462
+ while True:
463
+ message = await receive()
464
+ if message.get("type") != "http.request":
465
+ return bytes(body)
466
+ body.extend(bytes(message.get("body") or b""))
467
+ if len(body) > limit:
468
+ raise ValueError("request body is too large")
469
+ if not message.get("more_body", False):
470
+ return bytes(body)
471
+
472
+
240
473
  def _is_allowed_host(host: str, allowed_hosts: list[str]) -> bool:
241
474
  normalized_host = host.strip().lower().rstrip(".")
242
475
  host_without_port = _strip_port(normalized_host)
@@ -308,6 +541,16 @@ def _ensure_streamable_http_accept(scope: dict[str, Any]) -> dict[str, Any]:
308
541
  return rewritten
309
542
 
310
543
 
544
+ def _escape_auth_param(value: str) -> str:
545
+ return (
546
+ str(value or "")
547
+ .replace("\\", "\\\\")
548
+ .replace('"', '\\"')
549
+ .replace("\r", " ")
550
+ .replace("\n", " ")
551
+ )
552
+
553
+
311
554
  app = LazyCloudApp()
312
555
 
313
556
 
@@ -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)