dsh-cloudq 0.1.0

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,554 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CloudQ 凭证统一管理模块
4
+
5
+ 凭证获取优先级:
6
+ 1. OAuth 凭证文件(~/.tencent-cloudq/credential.json,type="oauth")
7
+ 2. Connector 凭证文件(~/.tencent-cloudq/credential.json,type="connector")
8
+ 3. 环境变量 AK/SK(TENCENTCLOUD_SECRET_ID / TENCENTCLOUD_SECRET_KEY)
9
+ 4. 抛出 CredentialNotFoundError
10
+
11
+ 支持三种鉴权方式:
12
+ - OAuth 授权码模式 → source="oauth" (credential.json type="oauth")
13
+ - CloudQ Connector 临时密钥 → source="connector" (credential.json type="connector")
14
+ - AK/SK 环境变量 → source="env"
15
+
16
+ Connector 凭证由 Agent/SKILL.md 侧通过 MCP Tool 获取并写入 credential.json,
17
+ 本模块仅负责读取和校验,不负责获取和刷新 Connector 凭证。
18
+
19
+ 核心功能:
20
+ - get_credential() 获取当前可用凭证
21
+ - maybe_refresh_credential() 自动刷新快过期的 OAuth 凭证(Connector 跳过)
22
+ - get_authorize_url() 从服务端获取授权 URL
23
+ - exchange_token() 用 authorization_code 换 token
24
+ - get_tmp_cred() 用 accessToken 换临时密钥(OAuth)
25
+ - refresh_access_token() 用 refreshToken 刷新 accessToken(OAuth)
26
+ - save_credential() 保存 OAuth 凭证(原子写入)
27
+ - clear_credential() 清除凭证文件
28
+ """
29
+
30
+ import json
31
+ import os
32
+ import platform
33
+ import ssl
34
+ import stat
35
+ import time
36
+ import uuid
37
+ from datetime import datetime, timezone
38
+ from pathlib import Path
39
+ from typing import Optional
40
+ from urllib.request import Request, urlopen
41
+ from urllib.error import URLError, HTTPError
42
+ from urllib.parse import quote
43
+
44
+ # ============== 配置 ==============
45
+
46
+ CONFIG_DIR = Path.home() / ".tencent-cloudq"
47
+ CREDENTIAL_FILE = CONFIG_DIR / "credential.json"
48
+
49
+ # OAuth 服务端地址
50
+ OAUTH_ENDPOINT = os.environ.get(
51
+ "CLOUDQ_OAUTH_ENDPOINT", "https://cloudq.cloud.tencent.com"
52
+ )
53
+
54
+ # 刷新安全窗口
55
+ _CRED_REFRESH_SAFE_DUR = 60 * 5 # 临时密钥过期前 5 分钟触发刷新
56
+ _ACCESS_REFRESH_SAFE_DUR = 60 * 5 # accessToken 过期前 5 分钟触发刷新
57
+
58
+
59
+ # ============== 异常 ==============
60
+
61
+ class CredentialNotFoundError(Exception):
62
+ """未找到任何可用凭证"""
63
+ pass
64
+
65
+
66
+ class CredentialExpiredError(Exception):
67
+ """OAuth 凭证已过期(refreshToken 失效),需要重新登录"""
68
+ pass
69
+
70
+
71
+ # ============== SSL ==============
72
+
73
+ def _get_ssl_context():
74
+ """获取 SSL 上下文,强制验证证书"""
75
+ try:
76
+ import certifi
77
+ return ssl.create_default_context(cafile=certifi.where())
78
+ except ImportError:
79
+ return ssl.create_default_context()
80
+
81
+
82
+ # ============== 文件操作(安全) ==============
83
+
84
+ def _ensure_config_dir():
85
+ """确保配置目录存在且权限正确"""
86
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
87
+ if platform.system() != "Windows":
88
+ try:
89
+ os.chmod(str(CONFIG_DIR), stat.S_IRWXU) # 700
90
+ except OSError:
91
+ pass
92
+
93
+
94
+ def _set_file_permission(filepath: Path):
95
+ """设置文件权限为 600(仅所有者可读写)"""
96
+ if platform.system() != "Windows":
97
+ try:
98
+ os.chmod(str(filepath), stat.S_IRUSR | stat.S_IWUSR) # 600
99
+ except OSError:
100
+ pass
101
+
102
+
103
+ def _atomic_write_json(filepath: Path, data: dict):
104
+ """原子写入 JSON 文件(临时文件 + replace,防崩溃损坏)"""
105
+ _ensure_config_dir()
106
+
107
+ temp_file = filepath.parent / f".{filepath.name}.{uuid.uuid4().hex[:8]}.tmp"
108
+ try:
109
+ temp_file.write_text(
110
+ json.dumps(data, indent=2, ensure_ascii=False),
111
+ encoding="utf-8",
112
+ )
113
+ _set_file_permission(temp_file)
114
+ # replace() 在所有平台上都会原子覆盖目标文件
115
+ # rename() 在 Windows 上目标已存在时会抛 FileExistsError
116
+ temp_file.replace(filepath)
117
+ except Exception:
118
+ if temp_file.exists():
119
+ temp_file.unlink()
120
+ raise
121
+
122
+
123
+ # ============== HTTP 工具 ==============
124
+
125
+ class OAuthServerError(Exception):
126
+ """OAuth 服务端返回错误(HTTP 非 200 或业务错误)"""
127
+ pass
128
+
129
+
130
+ def _extract_error(resp_headers: dict, body: str) -> str:
131
+ """从 tRPC 错误响应中提取错误信息。
132
+
133
+ 支持两种错误响应格式:
134
+ 1. 自定义 ErrHandler(推荐):HTTP body 中包含 JSON {"error": {"code": ..., "message": ...}}
135
+ 2. 默认 tRPC 行为:错误信息在 Trpc-Error-Msg 响应头中,body 为空
136
+ """
137
+ # 优先从 body 中的 JSON 解析(自定义 ErrHandler 格式)
138
+ if body:
139
+ try:
140
+ data = json.loads(body)
141
+ if isinstance(data, dict):
142
+ err = data.get("error")
143
+ if isinstance(err, dict):
144
+ code = err.get("code", "")
145
+ msg = err.get("message", "")
146
+ if msg:
147
+ return f"[{code}] {msg}" if code else msg
148
+ if isinstance(err, str):
149
+ return err
150
+ except (json.JSONDecodeError, ValueError):
151
+ pass
152
+ # body 非 JSON,截断返回
153
+ return body[:200]
154
+ # 回退:从 Trpc-Error-Msg 头读取(默认 tRPC 行为)
155
+ trpc_msg = resp_headers.get("Trpc-Error-Msg", "")
156
+ if trpc_msg:
157
+ return trpc_msg
158
+ return "未知服务端错误"
159
+
160
+
161
+ def _http_post(url: str, body: dict, timeout: int = 30) -> dict:
162
+ """发送 POST 请求到 OAuth 服务端"""
163
+ data = json.dumps(body).encode("utf-8")
164
+ req = Request(
165
+ url,
166
+ data=data,
167
+ headers={"Content-Type": "application/json"},
168
+ method="POST",
169
+ )
170
+ ctx = _get_ssl_context() if url.startswith("https://") else None
171
+ try:
172
+ with urlopen(req, context=ctx, timeout=timeout) as resp:
173
+ result = json.loads(resp.read().decode("utf-8"))
174
+ return _check_response(result)
175
+ except HTTPError as e:
176
+ resp_headers = {k: v for k, v in e.headers.items()}
177
+ err_body = e.read().decode("utf-8", errors="replace") if e.fp else ""
178
+ msg = _extract_error(resp_headers, err_body)
179
+ raise OAuthServerError(msg) from e
180
+ except URLError as e:
181
+ raise OAuthServerError(f"网络连接失败: {e.reason}") from e
182
+
183
+
184
+ def _http_get(url: str, timeout: int = 30) -> dict:
185
+ """发送 GET 请求到 OAuth 服务端"""
186
+ req = Request(url, headers={"Accept": "application/json"}, method="GET")
187
+ ctx = _get_ssl_context() if url.startswith("https://") else None
188
+ try:
189
+ with urlopen(req, context=ctx, timeout=timeout) as resp:
190
+ result = json.loads(resp.read().decode("utf-8"))
191
+ return _check_response(result)
192
+ except HTTPError as e:
193
+ resp_headers = {k: v for k, v in e.headers.items()}
194
+ err_body = e.read().decode("utf-8", errors="replace") if e.fp else ""
195
+ msg = _extract_error(resp_headers, err_body)
196
+ raise OAuthServerError(msg) from e
197
+ except URLError as e:
198
+ raise OAuthServerError(f"网络连接失败: {e.reason}") from e
199
+
200
+
201
+ def _check_response(result: dict) -> dict:
202
+ """检查 tRPC HTTP 响应是否包含错误(自定义 ErrHandler 返回的错误)。
203
+
204
+ 自定义 ErrHandler 会在 HTTP 200 body 中返回 {"error": {"code": ..., "message": ...}},
205
+ 需要将其转换为 OAuthServerError 异常。
206
+ """
207
+ if isinstance(result, dict) and "error" in result:
208
+ err = result["error"]
209
+ if isinstance(err, dict) and err.get("message"):
210
+ code = err.get("code", "")
211
+ msg = err.get("message", "")
212
+ raise OAuthServerError(f"[{code}] {msg}" if code else msg)
213
+ return result
214
+
215
+
216
+ # ============== OAuth API 调用 ==============
217
+
218
+ def get_authorize_url(local_redirect_url: str, site: str = "cn") -> dict:
219
+ """
220
+ 步骤 1:从服务端获取授权 URL 和 state
221
+
222
+ 服务端会:
223
+ 1. 用自己的白名单回调地址构造腾讯云授权 URL
224
+ 2. 记录 local_redirect_url,授权完成后 302 回转到本地
225
+
226
+ Args:
227
+ local_redirect_url: 本地回调地址(如 http://localhost:9201/callback)
228
+ site: 站点标识
229
+
230
+ Returns:
231
+ {"authorize_url": "https://cloud.tencent.com/open/authorize?...", "state": "..."}
232
+ """
233
+ url = (
234
+ f"{OAUTH_ENDPOINT}/oauth/cq/authorize"
235
+ f"?local_redirect_url={quote(local_redirect_url, safe='')}&site={quote(site, safe='')}"
236
+ )
237
+ try:
238
+ resp = _http_get(url)
239
+ except OAuthServerError as e:
240
+ raise RuntimeError(f"获取授权 URL 失败: {e}") from e
241
+ # tRPC 使用 proto snake_case 字段名序列化
242
+ return {
243
+ "authorize_url": resp.get("authorize_url", ""),
244
+ "state": resp.get("state", ""),
245
+ }
246
+
247
+
248
+ def exchange_token(code: str) -> dict:
249
+ """
250
+ 步骤 3:用 authorization_code 换取 token
251
+
252
+ Args:
253
+ code: 授权回调返回的 authorization_code
254
+
255
+ Returns:
256
+ {
257
+ "user_access_token": "...",
258
+ "refresh_token": "...",
259
+ "user_open_id": "...",
260
+ "expires_at": 1712345678
261
+ }
262
+ """
263
+ url = f"{OAUTH_ENDPOINT}/oauth/cq/exchange_token"
264
+ body = {"code": code}
265
+ try:
266
+ resp = _http_post(url, body)
267
+ except OAuthServerError as e:
268
+ raise RuntimeError(f"exchange_token 失败: {e}") from e
269
+ # tRPC 使用 proto snake_case 字段名;int64 可能序列化为字符串
270
+ return {
271
+ "user_access_token": resp.get("user_access_token", ""),
272
+ "refresh_token": resp.get("refresh_token", ""),
273
+ "user_open_id": resp.get("user_open_id", ""),
274
+ "expires_at": int(resp.get("expires_at", 0)),
275
+ }
276
+
277
+
278
+ def get_tmp_cred(access_token: str, site: str = "cn") -> dict:
279
+ """
280
+ 步骤 4:用 accessToken 换取腾讯云临时密钥
281
+
282
+ Returns:
283
+ {"secretId": "...", "secretKey": "...", "token": "...", "expiresAt": 1712345678}
284
+ """
285
+ url = f"{OAUTH_ENDPOINT}/oauth/cq/get_tmp_cred"
286
+ body = {"access_token": access_token}
287
+ try:
288
+ resp = _http_post(url, body)
289
+ except OAuthServerError as e:
290
+ raise RuntimeError(f"获取临时密钥失败: {e}") from e
291
+ # tRPC 使用 proto snake_case 字段名;uint64 可能序列化为字符串
292
+ return {
293
+ "secretId": resp.get("tmp_secret_id", ""),
294
+ "secretKey": resp.get("tmp_secret_key", ""),
295
+ "token": resp.get("token", ""),
296
+ "expiresAt": int(resp.get("expired_time", 0)),
297
+ }
298
+
299
+
300
+ def refresh_access_token(refresh_token: str, user_open_id: str, site: str = "cn") -> dict:
301
+ """
302
+ 步骤 5:用 refreshToken + userOpenId 刷新 accessToken
303
+
304
+ Returns:
305
+ {"user_access_token": "...", "expires_at": 1712345678}
306
+ """
307
+ url = f"{OAUTH_ENDPOINT}/oauth/cq/refresh_token"
308
+ body = {
309
+ "refresh_token": refresh_token,
310
+ "user_open_id": user_open_id,
311
+ }
312
+ try:
313
+ resp = _http_post(url, body)
314
+ except OAuthServerError as e:
315
+ raise CredentialExpiredError(
316
+ f"refreshToken 已过期或无效: {e}。"
317
+ f"请重新授权登录。"
318
+ ) from e
319
+ # tRPC 使用 proto snake_case 字段名;int64 可能序列化为字符串
320
+ return {
321
+ "user_access_token": resp.get("user_access_token", ""),
322
+ "expires_at": int(resp.get("expires_at", 0)),
323
+ }
324
+
325
+
326
+ # ============== 凭证存储 ==============
327
+
328
+ def _save_oauth_info_only(cred_data: dict, oauth_info: dict):
329
+ """仅更新凭证文件中的 oauth 部分(accessToken/expiresAt),保留原有临时密钥。
330
+
331
+ 用于 accessToken 刷新成功但 get_tmp_cred 尚未执行时的中间持久化,
332
+ 防止 get_tmp_cred 失败导致新 accessToken 丢失。
333
+ """
334
+ data = {
335
+ "type": "oauth",
336
+ "secretId": cred_data.get("secretId", ""),
337
+ "secretKey": cred_data.get("secretKey", ""),
338
+ "token": cred_data.get("token", ""),
339
+ "expiresAt": cred_data.get("expiresAt", 0),
340
+ "oauth": {
341
+ "accessToken": oauth_info.get("accessToken", ""),
342
+ "refreshToken": oauth_info.get("refreshToken", ""),
343
+ "userOpenId": oauth_info.get("userOpenId", ""),
344
+ "expiresAt": oauth_info.get("expiresAt", 0),
345
+ "site": oauth_info.get("site", "cn"),
346
+ },
347
+ "createdAt": cred_data.get("createdAt", datetime.now(timezone.utc).isoformat()),
348
+ }
349
+ _atomic_write_json(CREDENTIAL_FILE, data)
350
+
351
+
352
+ def save_credential(cred: dict, oauth_info: dict):
353
+ """
354
+ 保存 OAuth 凭证到本地文件(原子写入 + 权限保护)
355
+
356
+ Args:
357
+ cred: {"secretId", "secretKey", "token", "expiresAt"}
358
+ oauth_info: {"accessToken", "refreshToken", "userOpenId", "expiresAt", "site"}
359
+ """
360
+ data = {
361
+ "type": "oauth",
362
+ "secretId": cred["secretId"],
363
+ "secretKey": cred["secretKey"],
364
+ "token": cred["token"],
365
+ "expiresAt": cred["expiresAt"],
366
+ "oauth": {
367
+ "accessToken": oauth_info["accessToken"],
368
+ "refreshToken": oauth_info["refreshToken"],
369
+ "userOpenId": oauth_info.get("userOpenId", ""),
370
+ "expiresAt": oauth_info["expiresAt"],
371
+ "site": oauth_info.get("site", "cn"),
372
+ },
373
+ "createdAt": datetime.now(timezone.utc).isoformat(),
374
+ }
375
+ _atomic_write_json(CREDENTIAL_FILE, data)
376
+
377
+
378
+ def load_credential() -> Optional[dict]:
379
+ """读取本地凭证文件,支持 oauth / connector / ak 三种类型,不存在或格式错误返回 None"""
380
+ if not CREDENTIAL_FILE.exists():
381
+ return None
382
+ try:
383
+ data = json.loads(CREDENTIAL_FILE.read_text(encoding="utf-8"))
384
+ if data.get("type") not in ("oauth", "connector", "ak"):
385
+ return None
386
+ return data
387
+ except (json.JSONDecodeError, IOError):
388
+ return None
389
+
390
+
391
+ def clear_credential():
392
+ """清除凭证文件(OAuth 和 Connector 均适用)"""
393
+ if CREDENTIAL_FILE.exists():
394
+ CREDENTIAL_FILE.unlink()
395
+
396
+
397
+ # ============== 核心:自动刷新 ==============
398
+
399
+ def maybe_refresh_credential(force: bool = False):
400
+ """
401
+ 检查凭证是否快过期,自动刷新。
402
+
403
+ OAuth 凭证:accessToken 快过期时用 refreshToken 刷新,再用新 accessToken 换临时密钥。
404
+ Connector 凭证:**不刷新**,凭证由 Agent 侧通过 MCP Tool 获取后重新写入 credential.json。
405
+
406
+ Args:
407
+ force: 强制刷新,忽略有效期检查(用户主动刷新时传 True)
408
+
409
+ 刷新逻辑(仅 OAuth):
410
+ 1. 临时密钥剩余 > 5 分钟且非强制 → 不刷新
411
+ 2. accessToken 剩余 < 5 分钟 → 用 refreshToken + userOpenId 刷新
412
+ 3. 用 accessToken 换取新的临时密钥
413
+ 4. 写回文件
414
+ """
415
+ cred_data = load_credential()
416
+ if cred_data is None:
417
+ return
418
+
419
+ # Connector 凭证由 Agent 侧管理,程序不负责刷新
420
+ # AK 长期密钥没有有效期,同样无需刷新
421
+ if cred_data.get("type") in ("connector", "ak"):
422
+ return
423
+
424
+ now = time.time()
425
+ expires_at = cred_data.get("expiresAt", 0)
426
+
427
+ # 临时密钥还有效且非强制刷新,不刷新
428
+ if not force and expires_at - now > _CRED_REFRESH_SAFE_DUR:
429
+ return
430
+
431
+ oauth_info = cred_data.get("oauth", {})
432
+ access_token = oauth_info.get("accessToken", "")
433
+ refresh_token = oauth_info.get("refreshToken", "")
434
+ user_open_id = oauth_info.get("userOpenId", "")
435
+ access_expires = oauth_info.get("expiresAt", 0)
436
+ site = oauth_info.get("site", "cn")
437
+
438
+ if not access_token or not refresh_token:
439
+ return
440
+
441
+ # accessToken 快过期 → 用 refreshToken + userOpenId 刷新
442
+ if access_expires - now < _ACCESS_REFRESH_SAFE_DUR:
443
+ new_token_info = refresh_access_token(refresh_token, user_open_id, site)
444
+ oauth_info["accessToken"] = new_token_info["user_access_token"]
445
+ oauth_info["expiresAt"] = new_token_info["expires_at"]
446
+ access_token = new_token_info["user_access_token"]
447
+ # 先持久化新的 oauth_info,防止下一步 get_tmp_cred 失败导致新 accessToken 丢失
448
+ _save_oauth_info_only(cred_data, oauth_info)
449
+
450
+ # 用 accessToken 换取新的临时密钥
451
+ new_cred = get_tmp_cred(access_token, site)
452
+ save_credential(new_cred, oauth_info)
453
+
454
+
455
+ # ============== 核心:获取凭证 ==============
456
+
457
+ def get_credential() -> dict:
458
+ """
459
+ 获取可用凭证,支持 OAuth、Connector 和 AK/SK 三种模式。
460
+
461
+ 优先级:
462
+ 1. credential.json(type="oauth")
463
+ 2. credential.json(type="connector")
464
+ 3. 环境变量 AK/SK
465
+
466
+ Returns:
467
+ {
468
+ "secretId": "...",
469
+ "secretKey": "...",
470
+ "token": "...",
471
+ "source": "oauth" | "connector" | "env",
472
+ "auditJwt": "..." # 仅 Connector 模式返回
473
+ }
474
+
475
+ Raises:
476
+ CredentialNotFoundError: 无任何可用凭证
477
+ CredentialExpiredError: OAuth/Connector 凭证已过期且无法刷新
478
+ """
479
+ # 1. credential.json(优先 OAuth,其次 Connector)
480
+ cred_data = load_credential()
481
+ if cred_data is not None:
482
+ cred_type = cred_data.get("type", "")
483
+
484
+ # 1.1 OAuth 凭证(自动刷新)
485
+ if cred_type == "oauth":
486
+ try:
487
+ maybe_refresh_credential()
488
+ cred_data = load_credential()
489
+ except CredentialExpiredError:
490
+ raise
491
+ except Exception:
492
+ pass # 刷新失败,下面会检查凭证是否仍然有效
493
+
494
+ if cred_data:
495
+ now = time.time()
496
+ if cred_data.get("expiresAt", 0) < now:
497
+ raise CredentialExpiredError(
498
+ "OAuth 临时密钥已过期且自动刷新失败,请重新授权登录。"
499
+ )
500
+ return {
501
+ "secretId": cred_data["secretId"],
502
+ "secretKey": cred_data["secretKey"],
503
+ "token": cred_data.get("token", ""),
504
+ "source": "oauth",
505
+ }
506
+
507
+ # 1.2 Connector 凭证(由 Agent 侧通过 MCP Tool 写入)
508
+ if cred_type == "connector":
509
+ now = time.time()
510
+ if cred_data.get("expiresAt", 0) < now:
511
+ raise CredentialExpiredError(
512
+ "Connector 临时密钥(OneId 方案)已过期,请通过 "
513
+ "CloudQConnector_get_available_tmp_secret 重新获取。"
514
+ )
515
+ result = {
516
+ "secretId": cred_data["secretId"],
517
+ "secretKey": cred_data["secretKey"],
518
+ "token": cred_data.get("token", ""),
519
+ "source": "connector",
520
+ }
521
+ # Connector 模式下附带 auditJwt
522
+ audit_jwt = cred_data.get("auditJwt", "")
523
+ if audit_jwt:
524
+ result["auditJwt"] = audit_jwt
525
+ return result
526
+
527
+ # 1.3 AK 长期密钥(用户在插件设置页手工填写并校验后写入)
528
+ # 长期密钥没有有效期,也不需要刷新;source="ak" 走 AK/SK 服务白名单。
529
+ if cred_type == "ak":
530
+ secret_id = cred_data.get("secretId", "")
531
+ secret_key = cred_data.get("secretKey", "")
532
+ if secret_id and secret_key:
533
+ return {
534
+ "secretId": secret_id,
535
+ "secretKey": secret_key,
536
+ "token": cred_data.get("token", ""),
537
+ "source": "ak",
538
+ }
539
+
540
+ # 2. 环境变量 AK/SK
541
+ env_id = os.environ.get("TENCENTCLOUD_SECRET_ID", "")
542
+ env_key = os.environ.get("TENCENTCLOUD_SECRET_KEY", "")
543
+ if env_id and env_key:
544
+ return {
545
+ "secretId": env_id,
546
+ "secretKey": env_key,
547
+ "token": os.environ.get("TENCENTCLOUD_TOKEN", ""),
548
+ "source": "env",
549
+ }
550
+
551
+ # 无凭证
552
+ raise CredentialNotFoundError(
553
+ "NEED_AUTH"
554
+ )