@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.
@@ -25,6 +25,8 @@ from .json_types import JSONObject
25
25
  AUTH_CONTEXT_KEY_PREFIX = "mcp:auth:context"
26
26
  AUTH_CONTEXT_LOCK_KEY_PREFIX = "mcp:auth:context:lock"
27
27
  AUTH_CONTEXT_NEGATIVE_KEY_PREFIX = "mcp:auth:context:negative"
28
+ ACCESS_PLATFORM_KEY_PREFIX = "oauth2:access-platform:"
29
+ ACCESS_CONTEXT_KEY_PREFIX = "auth:access-context:"
28
30
 
29
31
 
30
32
  @dataclass(slots=True)
@@ -103,6 +105,35 @@ class CloudAuthBroker:
103
105
  def invalidate(self, credential_hash: str) -> None:
104
106
  self.cache.delete(self._auth_key(credential_hash))
105
107
 
108
+ def resolve_call_platform(self, access_token: str) -> str:
109
+ normalized_token = self._normalize_text(access_token)
110
+ if normalized_token is None:
111
+ return ""
112
+ access_context = self.resolve_access_context(normalized_token)
113
+ if access_context is not None:
114
+ source_code = self._normalize_text(access_context.get("source_code"))
115
+ if source_code is not None:
116
+ return source_code
117
+ try:
118
+ value = self.cache.get_text(f"{ACCESS_PLATFORM_KEY_PREFIX}{normalized_token}")
119
+ except QingflowApiError:
120
+ return ""
121
+ return self._normalize_text(value) or ""
122
+
123
+ def resolve_access_context(self, access_token: str) -> JSONObject | None:
124
+ normalized_token = self._normalize_text(access_token)
125
+ if normalized_token is None:
126
+ return None
127
+ try:
128
+ value = self.cache.get_json(
129
+ f"{ACCESS_CONTEXT_KEY_PREFIX}{sha256(normalized_token.encode('utf-8')).hexdigest()}"
130
+ )
131
+ except QingflowApiError:
132
+ return None
133
+ if not isinstance(value, dict):
134
+ return None
135
+ return value
136
+
106
137
  @staticmethod
107
138
  def credential_hash(credential: str) -> str:
108
139
  return sha256(credential.encode("utf-8")).hexdigest()
@@ -68,3 +68,8 @@ current_cloud_context: ContextVar[CloudMcpContext | None] = ContextVar(
68
68
  "qingflow_mcp_current_cloud_context",
69
69
  default=None,
70
70
  )
71
+
72
+ current_call_platform: ContextVar[str] = ContextVar(
73
+ "qingflow_mcp_current_call_platform",
74
+ default="",
75
+ )
@@ -0,0 +1,454 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+ from urllib.parse import urlsplit, urlunsplit
7
+
8
+ import httpx
9
+
10
+ from .backend_client import BackendClient, BackendRequestContext
11
+ from .cloud_context import CloudMcpContext
12
+ from .config import (
13
+ get_cloud_oauth_resource_url,
14
+ get_cloud_oauth_service_base_url,
15
+ get_default_base_url,
16
+ get_default_qf_version,
17
+ get_timeout_seconds,
18
+ normalize_base_url,
19
+ )
20
+ from .errors import QingflowApiError, is_auth_like_error
21
+ from .json_types import JSONObject
22
+
23
+
24
+ PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"
25
+ AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"
26
+ OAUTH_ENDPOINT_PATHS = {
27
+ "authorization_endpoint": "/oauth/authorize",
28
+ "token_endpoint": "/oauth/token",
29
+ "revocation_endpoint": "/oauth/revoke",
30
+ "registration_endpoint": "/oauth/register",
31
+ }
32
+ FORWARDED_REQUEST_HEADERS = {"accept", "content-type"}
33
+ HOP_BY_HOP_RESPONSE_HEADERS = {
34
+ "connection",
35
+ # httpx decodes compressed response bodies before exposing response.content.
36
+ "content-encoding",
37
+ "keep-alive",
38
+ "proxy-authenticate",
39
+ "proxy-authorization",
40
+ "te",
41
+ "trailer",
42
+ "transfer-encoding",
43
+ "upgrade",
44
+ "date",
45
+ "server",
46
+ }
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class CloudOAuthProxyConfig:
51
+ service_base_url: str
52
+ resource_url: str
53
+
54
+ @classmethod
55
+ def from_config(cls) -> "CloudOAuthProxyConfig | None":
56
+ resource_url = get_cloud_oauth_resource_url()
57
+ if resource_url is None:
58
+ return None
59
+ service_base_url = get_cloud_oauth_service_base_url()
60
+ if service_base_url is None:
61
+ raise QingflowApiError.config_error(
62
+ "cloud.oauth.service_base_url or default_base_url is required when OAuth is enabled"
63
+ )
64
+ return cls(
65
+ service_base_url=service_base_url,
66
+ resource_url=_validate_resource_url(resource_url),
67
+ )
68
+
69
+ @property
70
+ def resource_metadata_url(self) -> str:
71
+ return f"{self.metadata_issuer_url}{PROTECTED_RESOURCE_METADATA_PATH}"
72
+
73
+ @property
74
+ def metadata_issuer_url(self) -> str:
75
+ parsed = urlsplit(self.resource_url)
76
+ return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
77
+
78
+ def backend_metadata_path(self, request_path: str) -> str | None:
79
+ if request_path == PROTECTED_RESOURCE_METADATA_PATH or request_path.startswith(
80
+ f"{PROTECTED_RESOURCE_METADATA_PATH}/"
81
+ ):
82
+ return PROTECTED_RESOURCE_METADATA_PATH
83
+ if request_path == AUTHORIZATION_SERVER_METADATA_PATH or request_path.startswith(
84
+ f"{AUTHORIZATION_SERVER_METADATA_PATH}/"
85
+ ):
86
+ return AUTHORIZATION_SERVER_METADATA_PATH
87
+ return None
88
+
89
+ def backend_oauth_endpoint_path(self, request_path: str) -> str | None:
90
+ if request_path in OAUTH_ENDPOINT_PATHS.values():
91
+ return request_path
92
+ return None
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class OAuthProxyResponse:
97
+ status_code: int
98
+ headers: tuple[tuple[bytes, bytes], ...]
99
+ body: bytes
100
+
101
+
102
+ class OAuthServiceProxy:
103
+ def __init__(
104
+ self,
105
+ config: CloudOAuthProxyConfig,
106
+ *,
107
+ client: httpx.Client | None = None,
108
+ ) -> None:
109
+ self.config = config
110
+ self._owns_client = client is None
111
+ self._client = client or httpx.Client(
112
+ timeout=get_timeout_seconds(),
113
+ follow_redirects=False,
114
+ trust_env=False,
115
+ )
116
+
117
+ def close(self) -> None:
118
+ if self._owns_client:
119
+ self._client.close()
120
+
121
+ def request_metadata(
122
+ self,
123
+ method: str,
124
+ backend_path: str,
125
+ query_string: bytes = b"",
126
+ ) -> OAuthProxyResponse:
127
+ url = f"{self.config.service_base_url}/{backend_path.lstrip('/')}"
128
+ if query_string:
129
+ url = f"{url}?{query_string.decode('latin1')}"
130
+ try:
131
+ response = self._client.request(
132
+ method.upper(),
133
+ url,
134
+ headers={"Accept": "application/json"},
135
+ )
136
+ except httpx.RequestError as error:
137
+ raise QingflowApiError(
138
+ category="network",
139
+ message=f"OAuth service request failed: {error}",
140
+ ) from error
141
+
142
+ headers = tuple(
143
+ (name.encode("latin1"), value.encode("latin1"))
144
+ for name, value in response.headers.multi_items()
145
+ if name.lower() not in HOP_BY_HOP_RESPONSE_HEADERS
146
+ and name.lower() != "content-length"
147
+ )
148
+ return OAuthProxyResponse(
149
+ status_code=response.status_code,
150
+ headers=headers,
151
+ body=self._rewrite_metadata_body(backend_path, response),
152
+ )
153
+
154
+ def request_oauth_endpoint(
155
+ self,
156
+ method: str,
157
+ backend_path: str,
158
+ query_string: bytes = b"",
159
+ body: bytes = b"",
160
+ headers: tuple[tuple[bytes, bytes], ...] = (),
161
+ ) -> OAuthProxyResponse:
162
+ url = f"{self.config.service_base_url}/{backend_path.lstrip('/')}"
163
+ if query_string:
164
+ url = f"{url}?{query_string.decode('latin1')}"
165
+ forwarded_headers = {
166
+ name.decode("latin1"): value.decode("latin1")
167
+ for name, value in headers
168
+ if name.decode("latin1").lower() in FORWARDED_REQUEST_HEADERS
169
+ }
170
+ try:
171
+ response = self._client.request(
172
+ method.upper(),
173
+ url,
174
+ content=body,
175
+ headers=forwarded_headers,
176
+ )
177
+ except httpx.RequestError as error:
178
+ raise QingflowApiError(
179
+ category="network",
180
+ message=f"OAuth service request failed: {error}",
181
+ ) from error
182
+
183
+ response_headers = tuple(
184
+ (name.encode("latin1"), value.encode("latin1"))
185
+ for name, value in response.headers.multi_items()
186
+ if name.lower() not in HOP_BY_HOP_RESPONSE_HEADERS
187
+ and name.lower() != "content-length"
188
+ )
189
+ return OAuthProxyResponse(
190
+ status_code=response.status_code,
191
+ headers=response_headers,
192
+ body=response.content,
193
+ )
194
+
195
+ def _rewrite_metadata_body(
196
+ self,
197
+ backend_path: str,
198
+ response: httpx.Response,
199
+ ) -> bytes:
200
+ if not 200 <= response.status_code < 300 or not response.content:
201
+ return response.content
202
+ try:
203
+ payload = json.loads(response.content)
204
+ except (UnicodeDecodeError, json.JSONDecodeError):
205
+ return response.content
206
+ if not isinstance(payload, dict):
207
+ return response.content
208
+
209
+ changed = False
210
+ issuer = self.config.metadata_issuer_url
211
+ if backend_path == PROTECTED_RESOURCE_METADATA_PATH:
212
+ for field_name in ("authorization_servers", "authorizationServers"):
213
+ if field_name in payload:
214
+ payload[field_name] = [issuer]
215
+ changed = True
216
+ elif backend_path == AUTHORIZATION_SERVER_METADATA_PATH:
217
+ if "issuer" in payload:
218
+ payload["issuer"] = issuer
219
+ changed = True
220
+ for field_name, endpoint_path in OAUTH_ENDPOINT_PATHS.items():
221
+ if field_name in payload:
222
+ payload[field_name] = f"{issuer}{endpoint_path}"
223
+ changed = True
224
+
225
+ if not changed:
226
+ return response.content
227
+ return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode(
228
+ "utf-8"
229
+ )
230
+
231
+
232
+ @dataclass(slots=True)
233
+ class OAuthAccessTokenResolver:
234
+ backend: BackendClient
235
+ base_url: str
236
+ qf_version: str | None = None
237
+
238
+ @classmethod
239
+ def from_config(cls, backend: BackendClient) -> "OAuthAccessTokenResolver":
240
+ base_url = get_default_base_url()
241
+ if base_url is None:
242
+ raise QingflowApiError.config_error(
243
+ "default_base_url is required for OAuth access token requests"
244
+ )
245
+ return cls(
246
+ backend=backend,
247
+ base_url=base_url,
248
+ qf_version=get_default_qf_version(),
249
+ )
250
+
251
+ def resolve(self, access_token: str) -> CloudMcpContext:
252
+ normalized_token = str(access_token or "").strip()
253
+ if not normalized_token:
254
+ raise QingflowApiError(category="auth", message="OAuth access token is required")
255
+
256
+ user_info, workspace, response_qf_version = self._load_backend_context(
257
+ normalized_token
258
+ )
259
+ uid = self._coerce_positive_int(user_info.get("uid"))
260
+ ws_id = self._coerce_positive_int(workspace.get("wsId"))
261
+ if uid is None or ws_id is None:
262
+ raise QingflowApiError(
263
+ category="auth",
264
+ message="OAuth access token did not resolve a valid user and workspace",
265
+ )
266
+
267
+ workspace_qf_version, workspace_qf_version_source = (
268
+ self._resolve_workspace_qf_version(workspace)
269
+ )
270
+ qf_version = workspace_qf_version or response_qf_version or self.qf_version
271
+ qf_version_source = (
272
+ workspace_qf_version_source
273
+ if workspace_qf_version
274
+ else "backend_response"
275
+ if response_qf_version
276
+ else "default_config"
277
+ if self.qf_version
278
+ else None
279
+ )
280
+ return CloudMcpContext(
281
+ credential_hash="",
282
+ credential="",
283
+ token=normalized_token,
284
+ uid=uid,
285
+ ws_id=ws_id,
286
+ base_url=self.base_url,
287
+ qf_version=qf_version,
288
+ qf_version_source=qf_version_source,
289
+ email=self._normalize_text(user_info.get("email")),
290
+ nick_name=self._normalize_text(
291
+ user_info.get("nickName")
292
+ or user_info.get("displayName")
293
+ or user_info.get("name")
294
+ ),
295
+ ws_name=self._normalize_text(
296
+ workspace.get("workspaceName")
297
+ or workspace.get("wsName")
298
+ or workspace.get("remark")
299
+ ),
300
+ )
301
+
302
+ def _load_backend_context(
303
+ self, access_token: str
304
+ ) -> tuple[JSONObject, JSONObject, str | None]:
305
+ first_workspace: JSONObject | None = None
306
+ response_qf_version: str | None = None
307
+ try:
308
+ user_response = self.backend.request_with_meta(
309
+ "GET",
310
+ self._request_context(access_token, None),
311
+ "/user",
312
+ )
313
+ except QingflowApiError as original_error:
314
+ first_workspace, response_qf_version = self._fetch_first_workspace(
315
+ access_token
316
+ )
317
+ if first_workspace is None:
318
+ raise original_error
319
+ first_ws_id = self._coerce_positive_int(first_workspace.get("wsId"))
320
+ if first_ws_id is None:
321
+ raise original_error
322
+ user_response = self.backend.request_with_meta(
323
+ "GET",
324
+ self._request_context(
325
+ access_token, first_ws_id, response_qf_version
326
+ ),
327
+ "/user",
328
+ )
329
+
330
+ user_info = self._as_object(user_response.data)
331
+ if user_info is None:
332
+ raise QingflowApiError(
333
+ category="auth", message="OAuth access token did not resolve user info"
334
+ )
335
+ response_qf_version = user_response.qf_response_version or response_qf_version
336
+
337
+ workspace = self._as_object(user_info.get("lastWsInfo")) or first_workspace
338
+ if workspace is None:
339
+ workspace, workspace_response_version = self._fetch_first_workspace(
340
+ access_token
341
+ )
342
+ response_qf_version = workspace_response_version or response_qf_version
343
+ if workspace is None:
344
+ raise QingflowApiError(
345
+ category="auth",
346
+ message="OAuth access token user has no accessible workspace",
347
+ )
348
+
349
+ ws_id = self._coerce_positive_int(workspace.get("wsId"))
350
+ if ws_id is None:
351
+ raise QingflowApiError(
352
+ category="auth",
353
+ message="OAuth access token user has no valid workspace",
354
+ )
355
+ if self._resolve_workspace_qf_version(workspace)[0] is None:
356
+ try:
357
+ workspace_response = self.backend.request_with_meta(
358
+ "GET",
359
+ self._request_context(access_token, ws_id, response_qf_version),
360
+ f"/user/workspace/{ws_id}",
361
+ )
362
+ workspace = self._as_object(workspace_response.data) or workspace
363
+ response_qf_version = (
364
+ workspace_response.qf_response_version or response_qf_version
365
+ )
366
+ except QingflowApiError as error:
367
+ if is_auth_like_error(error):
368
+ raise
369
+ return user_info, workspace, response_qf_version
370
+
371
+ def _fetch_first_workspace(
372
+ self, access_token: str
373
+ ) -> tuple[JSONObject | None, str | None]:
374
+ response = self.backend.request_with_meta(
375
+ "POST",
376
+ self._request_context(access_token, None),
377
+ "/user/workspaceList/pageQuery",
378
+ json_body={"pageNum": 1, "pageSize": 1},
379
+ )
380
+ page = self._as_object(response.data)
381
+ workspaces = page.get("list") if page else None
382
+ first = workspaces[0] if isinstance(workspaces, list) and workspaces else None
383
+ return self._as_object(first), response.qf_response_version
384
+
385
+ def _request_context(
386
+ self,
387
+ access_token: str,
388
+ ws_id: int | None,
389
+ qf_version: str | None = None,
390
+ ) -> BackendRequestContext:
391
+ return BackendRequestContext(
392
+ base_url=self.base_url,
393
+ token=access_token,
394
+ ws_id=ws_id,
395
+ qf_version=qf_version or self.qf_version,
396
+ qf_version_source=(
397
+ "backend_response"
398
+ if qf_version
399
+ else "default_config"
400
+ if self.qf_version
401
+ else None
402
+ ),
403
+ )
404
+
405
+ @staticmethod
406
+ def _as_object(value: Any) -> JSONObject | None:
407
+ return value if isinstance(value, dict) else None
408
+
409
+ @classmethod
410
+ def _resolve_workspace_qf_version(
411
+ cls, workspace: JSONObject
412
+ ) -> tuple[str | None, str | None]:
413
+ qf_version = cls._normalize_text(workspace.get("qfVersion"))
414
+ if qf_version is not None:
415
+ return qf_version, "workspace_qf_version"
416
+ system_version = cls._normalize_text(workspace.get("systemVersion"))
417
+ if system_version is not None:
418
+ return system_version, "workspace_system_version"
419
+ return None, None
420
+
421
+ @staticmethod
422
+ def _normalize_text(value: Any) -> str | None:
423
+ if value is None:
424
+ return None
425
+ text = str(value).strip()
426
+ return text or None
427
+
428
+ @staticmethod
429
+ def _coerce_positive_int(value: Any) -> int | None:
430
+ if isinstance(value, bool) or value is None:
431
+ return None
432
+ try:
433
+ parsed = int(value)
434
+ except (TypeError, ValueError):
435
+ return None
436
+ return parsed if parsed > 0 else None
437
+
438
+
439
+ def _validate_resource_url(value: str) -> str:
440
+ parsed = urlsplit(value)
441
+ is_loopback_http = parsed.scheme == "http" and parsed.hostname in {
442
+ "localhost",
443
+ "127.0.0.1",
444
+ "::1",
445
+ }
446
+ if not parsed.netloc or (parsed.scheme != "https" and not is_loopback_http):
447
+ raise QingflowApiError.config_error(
448
+ "cloud.oauth.resource_url must use HTTPS (HTTP is allowed for loopback)"
449
+ )
450
+ if parsed.query or parsed.fragment or parsed.username or parsed.password:
451
+ raise QingflowApiError.config_error(
452
+ "cloud.oauth.resource_url must not contain user info, query parameters, or a fragment"
453
+ )
454
+ return normalize_base_url(value) or value.rstrip("/")
@@ -20,6 +20,9 @@ class CloudAuthCache(Protocol):
20
20
  def get_json(self, key: str) -> JSONObject | None:
21
21
  ...
22
22
 
23
+ def get_text(self, key: str) -> str | None:
24
+ ...
25
+
23
26
  def set_json(self, key: str, value: JSONObject, ttl_seconds: int) -> None:
24
27
  ...
25
28
 
@@ -72,6 +75,28 @@ class RedisCloudAuthCache:
72
75
  raise QingflowApiError(category="cache", message=f"invalid Redis JSON for key '{key}': {exc}") from exc
73
76
  return value if isinstance(value, dict) else None
74
77
 
78
+ def get_text(self, key: str) -> str | None:
79
+ try:
80
+ raw_value = self._client.get(key)
81
+ except Exception as exc:
82
+ raise QingflowApiError(
83
+ category="cache",
84
+ message="failed to read text value from Redis",
85
+ ) from exc
86
+ if raw_value is None:
87
+ return None
88
+ if isinstance(raw_value, bytes):
89
+ try:
90
+ raw_value = raw_value.decode("utf-8")
91
+ except UnicodeDecodeError:
92
+ return None
93
+ text = str(raw_value)
94
+ try:
95
+ value = json.loads(text)
96
+ except json.JSONDecodeError:
97
+ value = text
98
+ return value if isinstance(value, str) else None
99
+
75
100
  def set_json(self, key: str, value: JSONObject, ttl_seconds: int) -> None:
76
101
  self._client.set(key, json.dumps(value, ensure_ascii=False), ex=max(1, int(ttl_seconds)))
77
102
 
@@ -12,6 +12,7 @@ DEFAULT_USER_AGENT = "qingflow-mcp/1.0"
12
12
  DEFAULT_RECORD_LIST_TYPE = 8
13
13
  ATTACHMENT_QUESTION_TYPE = 13
14
14
  DEFAULT_BASE_URL = "https://qingflow.com/api"
15
+ DEFAULT_CLI_OAUTH_RESOURCE_URL = "https://mcp.qingflow.com/mcp"
15
16
  DEFAULT_FEEDBACK_APP_KEY = "e0d017kju002"
16
17
  DEFAULT_FEEDBACK_QSOURCE_TOKEN = "mcp-feedback-7755d14748fc"
17
18
  DEFAULT_REPOSITORY_GIT_REMOTE_TEMPLATE = "git@hackers.oalite.com:{group}/{repo}.git"
@@ -165,6 +166,17 @@ def get_default_qf_version() -> str | None:
165
166
  return normalized or None
166
167
 
167
168
 
169
+ def get_cli_oauth_resource_url() -> str:
170
+ """获取 CLI OAuth 受保护资源地址。"""
171
+ value = get_config_value(
172
+ "cli.oauth.resource_url",
173
+ env_var="QINGFLOW_MCP_OAUTH_RESOURCE_URL",
174
+ default=DEFAULT_CLI_OAUTH_RESOURCE_URL,
175
+ )
176
+ normalized = normalize_base_url(str(value or "").strip())
177
+ return normalized or DEFAULT_CLI_OAUTH_RESOURCE_URL
178
+
179
+
168
180
  def get_feedback_qsource_token() -> str | None:
169
181
  """获取反馈 q-source 被动入口 token"""
170
182
  value = get_config_value(
@@ -301,6 +313,50 @@ def get_cloud_auth_context_lock_wait_seconds() -> float:
301
313
  return parsed if parsed > 0 else DEFAULT_CLOUD_AUTH_CONTEXT_LOCK_WAIT_SECONDS
302
314
 
303
315
 
316
+ def get_cloud_oauth_service_base_url() -> str | None:
317
+ value = get_config_value(
318
+ "cloud.oauth.service_base_url",
319
+ env_var="QINGFLOW_MCP_OAUTH_SERVICE_BASE_URL",
320
+ default=None,
321
+ )
322
+ normalized = normalize_base_url(str(value).strip()) if value is not None else None
323
+ if normalized:
324
+ return normalized
325
+ _, service_base_url = _derive_cloud_oauth_urls()
326
+ return service_base_url
327
+
328
+
329
+ def get_cloud_oauth_resource_url() -> str | None:
330
+ value = get_config_value(
331
+ "cloud.oauth.resource_url",
332
+ env_var="QINGFLOW_MCP_OAUTH_RESOURCE_URL",
333
+ default=None,
334
+ )
335
+ normalized = str(value or "").strip().rstrip("/")
336
+ if normalized:
337
+ return normalized
338
+ resource_url, _ = _derive_cloud_oauth_urls()
339
+ return resource_url
340
+
341
+
342
+ def _derive_cloud_oauth_urls() -> tuple[str | None, str | None]:
343
+ default_base_url = get_default_base_url()
344
+ if default_base_url is None:
345
+ return None, None
346
+
347
+ parsed = urlsplit(default_base_url)
348
+ path = parsed.path.rstrip("/")
349
+ if path.endswith("/api"):
350
+ resource_path = path[:-4]
351
+ else:
352
+ resource_path = path
353
+
354
+ resource_url = urlunsplit(
355
+ (parsed.scheme, parsed.netloc, resource_path, parsed.query, parsed.fragment)
356
+ )
357
+ return resource_url or None, default_base_url
358
+
359
+
304
360
  def get_cloud_http_host() -> str:
305
361
  value = get_config_value(
306
362
  "cloud.http.host",