Pārlūkot izejas kodu

fix(ssl): 集中式修复企业代理自签 CA 致 CERTIFICATE_VERIFY_FAILED

在线 provider(dashscope/deepseek 走 OpenAICompatProvider 的 urllib)在 TLS 拦截
代理(注入自签根 CA)下报 SSL 验证失败;PYTHONHTTPSVERIFY=0 对底层 urllib 不可靠。
全仓 30+ 处 urlopen 逐个传 context= 不现实。

- 新增 lambdagent/_ssl_bootstrap.py::configure_ssl():覆盖
  ssl._create_default_https_context → 进程内所有 urllib HTTPS 调用统一生效。
  默认 no-op,env 驱动,优先级 CA bundle > insecure:
    AGENTPAAS_CA_BUNDLE / SSL_CERT_FILE / REQUESTS_CA_BUNDLE → 信任公司 CA(验证仍开,安全)
    AGENTPAAS_INSECURE_SSL=1 → 关闭验证(不安全,带警告)
- 接线两处(幂等):providers/__init__.py 导入即调(覆盖 bench/CLI/直连内核);
  app.py 在 config 加载 .env 之后调(server)
- providers_api 可用性探测:原对 SSL 错误误报"服务可达"(可达但不可用的根源),
  改为识别 SSL 错误并返回可执行修复指引
- 手册 §5/§14 Q9/§15:写入推荐做法(导公司 CA→AGENTPAAS_CA_BUNDLE)+ 临时方案

回归:test_ssl_bootstrap(7)+ lambdagent 626 + agentpaas 351 全绿;fresh-process
验证默认不动 context / INSECURE 关验证 / CA bundle 保持 CERT_REQUIRED。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kenny67nju 2 mēneši atpakaļ
vecāks
revīzija
75df7b13fa

+ 4 - 0
agentpaas/src/agentpaas/api/app.py

@@ -13,6 +13,10 @@ from fastapi.middleware.cors import CORSMiddleware
 from fastapi.staticfiles import StaticFiles
 from fastapi.responses import FileResponse
 from agentpaas.config import settings
+# 企业代理注入自签 CA 时的 SSL 修复:server 启动尽早配置进程级默认 HTTPS context
+# (默认 no-op)。providers 导入也会调一次,二者幂等。
+from lambdagent._ssl_bootstrap import configure_ssl as _configure_ssl
+_configure_ssl()
 from agentpaas.api.v1 import agents, auth, admin, billing, traces, jobs, discovery, metrics, status, analyze, setup as setup_router, providers_api, templates_api, knowledge as knowledge_router, agentpacks, assistant, mcp as mcp_router, skills as skills_router
 from agentpaas.observability.logging import logger
 

+ 8 - 0
agentpaas/src/agentpaas/api/v1/providers_api.py

@@ -268,4 +268,12 @@ async def test_provider(
         err = str(e)
         if "401" in err or "403" in err:
             return {"ok": False, "message": "API Key 无效或权限不足"}
+        # SSL 证书链问题(常见于公司代理/防火墙注入自签根 CA):此时端点"可达"但
+        # 智能体真正调用会失败,绝不能误报"服务可达"。给出可执行修复提示。
+        if "CERTIFICATE_VERIFY_FAILED" in err or "SSL" in err.upper() or "self-signed" in err.lower():
+            return {"ok": False, "message": (
+                "SSL 证书验证失败(疑似公司代理/防火墙注入自签 CA)。修复:把公司根 "
+                "CA 导出为 PEM 后设 AGENTPAAS_CA_BUNDLE=/path/ca.pem(推荐,验证仍开启);"
+                "或本地开发设 AGENTPAAS_INSECURE_SSL=1 关闭验证(不安全),重启服务后生效。"
+            )}
         return {"ok": True, "message": f"服务可达({err[:60]})"}

+ 24 - 0
docs/DESKTOP_MANUAL_zh.md

@@ -216,6 +216,8 @@ LambdAgent 支持 8 类 Provider,可同时配置多个,不同智能体可使
 
 进入左侧菜单「**环境配置**」(Providers)页面 → 选择 Provider → 粘贴 API Key → 点击「**测试连接**」确认可用。Key 保存在本机 `~/.agentpaas/providers.json`,服务重启后自动加载。
 
+> 在公司/校园网络下接入在线模型若报 `SSL: CERTIFICATE_VERIFY_FAILED`(多为代理注入自签 CA),见 [§14 Q9](#14-常见问题与故障排查)。
+
 ### 5.2 通过命令行配置
 
 ```bash
@@ -512,6 +514,26 @@ Provider Key 无效、欠费或超时。到「环境配置」页点「测试连
 **Q8:如何彻底卸载?**
 删除仓库目录、`~/.agentpaas/`、数据目录(默认 `~/LambdAgentDesktop`)即可,无其他系统残留。
 
+**Q9:在线模型报 `SSL: CERTIFICATE_VERIFY_FAILED ... self-signed certificate in certificate chain`?**
+通常是公司/校园网络的代理或防火墙做了 TLS 拦截,往证书链里注入了自签根 CA,而 Python 默认信任库不认它(「测试连接」可能显示可达,但智能体实际调用会失败)。注意 `PYTHONHTTPSVERIFY=0` 对底层 urllib 不可靠,不要依赖它。
+
+**推荐做法(安全,验证仍开启)** —— 导出公司根 CA 为 PEM,让程序信任它:
+
+```bash
+# macOS:把系统钥匙串里的根证书导出为一个 PEM 包
+security find-certificate -a -p /Library/Keychains/System.keychain > ~/corp-ca.pem
+# 写入环境变量(指向上一步的 PEM)
+echo 'AGENTPAAS_CA_BUNDLE=/Users/<你的用户名>/corp-ca.pem' >> ~/.agentpaas/.env
+```
+
+**临时做法(不安全,仅限本地开发或你完全信任所处网络)** —— 关闭证书验证:
+
+```bash
+echo 'AGENTPAAS_INSECURE_SSL=1' >> ~/.agentpaas/.env   # 会在日志打印醒目警告
+```
+
+两者都写进 `~/.agentpaas/.env` 后**重启服务生效**(`launchctl kickstart -k gui/$(id -u)/com.agentpaas.serve`,或重启桌面应用)。优先级:`AGENTPAAS_CA_BUNDLE` > `AGENTPAAS_INSECURE_SSL`;都不设则保持系统默认。该配置进程级生效,覆盖所有在线 Provider 与工具的 HTTPS 调用。
+
 ---
 
 ## 15. 进阶配置
@@ -525,6 +547,8 @@ AGENTPAAS_DEPLOYMENT_MODE=desktop          # desktop / lab / paas
 AGENTPAAS_LOG_LEVEL=INFO
 AGENTPAAS_CORS_ORIGINS=http://my-host:5173 # 自定义前端来源
 AGENTPAAS_WORKSPACE_RETENTION_DAYS=30      # 工作目录保留策略
+AGENTPAAS_CA_BUNDLE=/path/to/corp-ca.pem   # 信任公司根 CA(应对 TLS 拦截代理,安全;见 Q9)
+AGENTPAAS_INSECURE_SSL=1                    # 关闭 HTTPS 证书验证(不安全,临时;见 Q9)
 ```
 
 **三种部署模式**:

+ 84 - 0
lambdagent/src/lambdagent/_ssl_bootstrap.py

@@ -0,0 +1,84 @@
+"""集中式 SSL 配置 —— 应对公司代理/防火墙往证书链注入自签根 CA 的场景。
+
+症状:`[SSL: CERTIFICATE_VERIFY_FAILED] ... self-signed certificate in certificate
+chain`。根因通常是企业网络的 TLS 拦截代理(Zscaler/防火墙等)用自签根 CA 重签了
+出站 HTTPS。`PYTHONHTTPSVERIFY=0` 对底层 urllib 不可靠(不同 Python 版本行为不一)。
+
+本模块提供 **进程级、一次性** 的修复:覆盖 `ssl._create_default_https_context`——
+这是 `urllib.request.urlopen` 在未显式传 `context=` 时构造默认 context 的工厂。覆盖
+它即可让进程内 **所有** urllib HTTPS 调用(provider chat、可用性探测、MCP、web 工具)
+统一生效,无需在每个 urlopen 站点改代码。
+
+**默认零行为变化**:只有设置了下列环境变量之一才生效。优先级 CA bundle > insecure。
+
+  - AGENTPAAS_CA_BUNDLE / SSL_CERT_FILE / REQUESTS_CA_BUNDLE
+      指向公司根 CA 的 PEM 文件。**推荐**:验证仍开启,只是额外信任该 CA → 安全。
+  - AGENTPAAS_INSECURE_SSL=1 (或 true/yes/on)
+      彻底关闭证书验证。**不安全**,仅限本地开发或完全信任所处网络时用;会打印
+      醒目警告。等价于此前手动 monkey-patch,但集中、可控、有日志。
+"""
+from __future__ import annotations
+
+import logging
+import os
+import ssl
+import warnings
+
+_TRUTHY = {"1", "true", "yes", "on"}
+_configured = False  # 幂等:每进程只配一次
+_log = logging.getLogger(__name__)
+
+
+def _ca_bundle_path() -> str:
+    """返回首个存在的 CA bundle 文件路径(按优先级),否则空串。"""
+    for key in ("AGENTPAAS_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"):
+        val = os.environ.get(key)
+        if val and os.path.isfile(val):
+            return val
+    return ""
+
+
+def ssl_status() -> str:
+    """返回当前 SSL 模式的可读描述(供 /health、日志、错误提示复用)。"""
+    ca = _ca_bundle_path()
+    if ca:
+        return f"custom-ca:{ca}"
+    if os.environ.get("AGENTPAAS_INSECURE_SSL", "").lower() in _TRUTHY:
+        return "insecure"
+    return "default"
+
+
+def configure_ssl(force: bool = False) -> str:
+    """按环境变量配置进程级默认 HTTPS context。幂等。返回所选模式。
+
+    在 server 启动、以及 lambdagent.providers 导入时各调一次(都幂等)→ server 与
+    bench/CLI/直连内核脚本都覆盖到。
+    """
+    global _configured
+    if _configured and not force:
+        return ssl_status()
+    _configured = True
+
+    ca = _ca_bundle_path()
+    insecure = os.environ.get("AGENTPAAS_INSECURE_SSL", "").lower() in _TRUTHY
+
+    if ca:
+        def _factory(*args, **kwargs):
+            ctx = ssl.create_default_context(*args, **kwargs)
+            try:
+                ctx.load_verify_locations(ca)
+            except Exception as e:  # 文件坏/格式错 → 不致命,退回默认信任库
+                _log.warning("加载 CA bundle 失败 %s: %s(退回系统默认信任库)", ca, e)
+            return ctx
+        ssl._create_default_https_context = _factory  # type: ignore[attr-defined]
+        _log.info("SSL: 已额外信任自定义 CA bundle %s(证书验证仍开启)", ca)
+    elif insecure:
+        ssl._create_default_https_context = ssl._create_unverified_context  # type: ignore[attr-defined]
+        warnings.warn(
+            "AGENTPAAS_INSECURE_SSL 已全局关闭 HTTPS 证书验证(不安全,仅限本地开发)",
+            stacklevel=2,
+        )
+        _log.warning("SSL: 证书验证已全局关闭(AGENTPAAS_INSECURE_SSL=1)")
+    # 否则保持 Python 默认 → no-op,零行为变化
+
+    return ssl_status()

+ 5 - 0
lambdagent/src/lambdagent/providers/__init__.py

@@ -6,6 +6,11 @@ All providers implement LLMProvider.chat(messages) -> str.
 L02: Added ChatMessage, ChatResponse exports and create_provider() factory
 for unified multi-provider instantiation.
 """
+# 企业代理/防火墙注入自签 CA 时的 SSL 修复:导入即按环境变量配置进程级默认 context
+# (默认 no-op,零行为变化)。放在 provider 导入处 → server/bench/CLI/直连内核全覆盖。
+from .._ssl_bootstrap import configure_ssl as _configure_ssl
+_configure_ssl()
+
 from .base import LLMProvider, ProviderConfig, ProviderError, Message, ChatMessage, ChatResponse
 from .claude_code import ClaudeLam  # deprecated, removed in 0.3.0 — use ClaudeCodeProvider
 from .claude_code_provider import ClaudeCodeProvider

+ 83 - 0
lambdagent/tests/test_ssl_bootstrap.py

@@ -0,0 +1,83 @@
+"""SSL bootstrap:企业自签 CA 场景的进程级修复。默认 no-op,env 驱动。"""
+import os
+import ssl
+import unittest
+
+from lambdagent import _ssl_bootstrap as sb
+
+
+class TestSSLBootstrap(unittest.TestCase):
+    def setUp(self):
+        # 隔离:存档并清理相关 env + 默认 context 工厂 + 幂等标志
+        self._env = {k: os.environ.get(k) for k in
+                     ("AGENTPAAS_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE",
+                      "AGENTPAAS_INSECURE_SSL")}
+        for k in self._env:
+            os.environ.pop(k, None)
+        self._orig_factory = ssl._create_default_https_context
+        sb._configured = False
+
+    def tearDown(self):
+        for k, v in self._env.items():
+            if v is None:
+                os.environ.pop(k, None)
+            else:
+                os.environ[k] = v
+        ssl._create_default_https_context = self._orig_factory
+        sb._configured = False
+
+    def test_default_is_noop(self):
+        mode = sb.configure_ssl(force=True)
+        self.assertEqual(mode, "default")
+        self.assertIs(ssl._create_default_https_context, self._orig_factory)  # 未改默认
+
+    def test_insecure_disables_verification(self):
+        os.environ["AGENTPAAS_INSECURE_SSL"] = "1"
+        import warnings
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore")
+            mode = sb.configure_ssl(force=True)
+        self.assertEqual(mode, "insecure")
+        ctx = ssl._create_default_https_context()
+        self.assertEqual(ctx.verify_mode, ssl.CERT_NONE)   # 验证已关
+        self.assertFalse(ctx.check_hostname)
+
+    def test_insecure_truthy_variants(self):
+        for val in ("true", "YES", "on", "1"):
+            sb._configured = False
+            os.environ["AGENTPAAS_INSECURE_SSL"] = val
+            self.assertEqual(sb.configure_ssl(force=True), "insecure")
+
+    def test_ca_bundle_trusted_and_verification_on(self):
+        # 用 certifi 的真实 CA 文件当"公司 CA"占位,验证仍开启
+        import certifi
+        os.environ["AGENTPAAS_CA_BUNDLE"] = certifi.where()
+        mode = sb.configure_ssl(force=True)
+        self.assertEqual(mode, "custom-ca:" + certifi.where())
+        ctx = ssl._create_default_https_context()
+        self.assertEqual(ctx.verify_mode, ssl.CERT_REQUIRED)  # 验证保持开启 → 安全
+
+    def test_ca_bundle_takes_precedence_over_insecure(self):
+        import certifi
+        os.environ["AGENTPAAS_CA_BUNDLE"] = certifi.where()
+        os.environ["AGENTPAAS_INSECURE_SSL"] = "1"
+        self.assertEqual(sb.configure_ssl(force=True), "custom-ca:" + certifi.where())
+
+    def test_idempotent(self):
+        os.environ["AGENTPAAS_INSECURE_SSL"] = "1"
+        import warnings
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore")
+            sb.configure_ssl(force=True)
+            ctx_factory = ssl._create_default_https_context
+            sb.configure_ssl()  # 第二次无 force → 不再改动
+        self.assertIs(ssl._create_default_https_context, ctx_factory)
+
+    def test_nonexistent_ca_file_ignored(self):
+        os.environ["AGENTPAAS_CA_BUNDLE"] = "/no/such/ca.pem"
+        # 文件不存在 → 当作未配置 → 回退默认(不崩)
+        self.assertEqual(sb.configure_ssl(force=True), "default")
+
+
+if __name__ == "__main__":
+    unittest.main()