sycommon-python-lib 0.2.7a4__py3-none-any.whl → 0.2.7a5__py3-none-any.whl

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.
@@ -50,6 +50,7 @@ from langchain_core.tools import BaseTool, tool
50
50
 
51
51
  from sycommon.logging.kafka_log import SYLogger
52
52
  from sycommon.tools.snowflake import Snowflake
53
+ from sycommon.tools.env import check_env_flag
53
54
  from sycommon.llm.get_llm import get_llm
54
55
  from sycommon.agent.sandbox.http_sandbox_backend import HTTPSandboxBackend
55
56
  from sycommon.agent.sandbox.sandbox_recovery import SandboxRecoveryManager
@@ -59,6 +60,11 @@ from sycommon.middleware.tool_result_truncation import ToolResultTruncationMiddl
59
60
  from deepagents.middleware.summarization import create_summarization_tool_middleware # noqa: F401 保留 re-export
60
61
  from sycommon.agent.summarization_utils import build_summarization_middleware
61
62
 
63
+ # 流式逐 token 诊断日志开关(默认关)。开启后每个 chunk 都打印
64
+ # chunk_type/content 等(仅供排障)。LLM 最终结果已由 LLMLogger.on_llm_end
65
+ # 统一记录,正常无需打开此开关,避免流式日志刷屏。
66
+ _STREAM_DEBUG = check_env_flag(['DEEP_AGENT_STREAM_DEBUG'], default='false')
67
+
62
68
  if TYPE_CHECKING:
63
69
  from deepagents.graph import CompiledStateGraph
64
70
  from langchain.agents.middleware.types import AgentState, ContextT, ModelRequest, ModelResponse, ResponseT
@@ -465,38 +471,27 @@ class DeepAgent:
465
471
  input_state = {"messages": [HumanMessage(content=message)]}
466
472
  if rubric:
467
473
  input_state["rubric"] = rubric
468
- print(
469
- f"[DeepAgent] chat() starting stream, message={repr(message[:80])}", flush=True)
474
+ if _STREAM_DEBUG:
475
+ print(
476
+ f"[DeepAgent] chat() starting stream, message={repr(message[:80])}", flush=True)
470
477
  async for chunk in self._astream_with_retry(
471
478
  input_state,
472
479
  max_retries=3,
473
480
  base_delay=1.0,
474
481
  ):
475
- # 🔑 详细打印 chunk 内容
476
- _chunk_len = len(chunk) if isinstance(chunk, tuple) else '?'
477
- if isinstance(chunk, tuple) and _chunk_len == 2:
478
- # 2-tuple: either (namespace, (msg, meta)) or (msg, meta)
479
- if isinstance(chunk[1], tuple) and len(chunk[1]) == 2:
480
- _ns = repr(chunk[0])[:30]
481
- _msg = chunk[1][0]
482
- _meta = chunk[1][1]
483
- else:
484
- _ns = '()'
485
- _msg = chunk[0]
486
- _meta = chunk[1]
487
- _msg_type = type(_msg).__name__
488
- _content = repr(str(getattr(_msg, 'content', ''))[:80])
489
- _node = _meta.get('langgraph_node', '?') if isinstance(
490
- _meta, dict) else '?'
491
- _step = _meta.get('langgraph_step', '?') if isinstance(
492
- _meta, dict) else '?'
493
- print(
494
- f"[DeepAgent] 2tuple: ns={_ns} msg={_msg_type} node={_node} step={_step} content={_content}", flush=True)
495
- elif isinstance(chunk, tuple) and _chunk_len == 3:
496
- namespace_p, _tag_p, payload_p = chunk
497
- if isinstance(payload_p, tuple) and len(payload_p) == 2:
498
- _msg = payload_p[0]
499
- _meta = payload_p[1]
482
+ # 🔑 token 诊断日志:仅在 DEEP_AGENT_STREAM_DEBUG=true 时打印
483
+ if _STREAM_DEBUG:
484
+ _chunk_len = len(chunk) if isinstance(chunk, tuple) else '?'
485
+ if isinstance(chunk, tuple) and _chunk_len == 2:
486
+ # 2-tuple: either (namespace, (msg, meta)) or (msg, meta)
487
+ if isinstance(chunk[1], tuple) and len(chunk[1]) == 2:
488
+ _ns = repr(chunk[0])[:30]
489
+ _msg = chunk[1][0]
490
+ _meta = chunk[1][1]
491
+ else:
492
+ _ns = '()'
493
+ _msg = chunk[0]
494
+ _meta = chunk[1]
500
495
  _msg_type = type(_msg).__name__
501
496
  _content = repr(str(getattr(_msg, 'content', ''))[:80])
502
497
  _node = _meta.get('langgraph_node', '?') if isinstance(
@@ -504,13 +499,26 @@ class DeepAgent:
504
499
  _step = _meta.get('langgraph_step', '?') if isinstance(
505
500
  _meta, dict) else '?'
506
501
  print(
507
- f"[DeepAgent] 3tuple: ns={repr(namespace_p)[:30]} msg={_msg_type} node={_node} step={_step} content={_content}", flush=True)
502
+ f"[DeepAgent] 2tuple: ns={_ns} msg={_msg_type} node={_node} step={_step} content={_content}", flush=True)
503
+ elif isinstance(chunk, tuple) and _chunk_len == 3:
504
+ namespace_p, _tag_p, payload_p = chunk
505
+ if isinstance(payload_p, tuple) and len(payload_p) == 2:
506
+ _msg = payload_p[0]
507
+ _meta = payload_p[1]
508
+ _msg_type = type(_msg).__name__
509
+ _content = repr(str(getattr(_msg, 'content', ''))[:80])
510
+ _node = _meta.get('langgraph_node', '?') if isinstance(
511
+ _meta, dict) else '?'
512
+ _step = _meta.get('langgraph_step', '?') if isinstance(
513
+ _meta, dict) else '?'
514
+ print(
515
+ f"[DeepAgent] 3tuple: ns={repr(namespace_p)[:30]} msg={_msg_type} node={_node} step={_step} content={_content}", flush=True)
516
+ else:
517
+ print(
518
+ f"[DeepAgent] 3tuple: payload_type={type(payload_p).__name__}", flush=True)
508
519
  else:
509
520
  print(
510
- f"[DeepAgent] 3tuple: payload_type={type(payload_p).__name__}", flush=True)
511
- else:
512
- print(
513
- f"[DeepAgent] chunk: type={type(chunk).__name__}", flush=True)
521
+ f"[DeepAgent] chunk: type={type(chunk).__name__}", flush=True)
514
522
  # 检查取消
515
523
  if cancel_event and cancel_event.is_set():
516
524
  event = ChatEventBuilder.cancelled("任务已取消")
@@ -541,22 +549,23 @@ class DeepAgent:
541
549
 
542
550
  stream_step += 1
543
551
 
544
- # ===== 🔑 诊断日志:详细记录每个 chunk =====
545
- _chunk_type = type(chunk).__name__
546
- _msg_type = getattr(
547
- msg, '__class__.__name__', '?') if msg else 'None'
548
- _ns = namespace if namespace else '()'
549
- _node = metadata.get(
550
- "langgraph_node", "?") if metadata else "?"
551
- _step = metadata.get(
552
- "langgraph_step", "?") if metadata else "?"
553
- _content_preview = ""
554
- if isinstance(msg, BaseMessage):
555
- _c = msg.content or ""
556
- _content_preview = repr(_c[:80]) if _c else "(empty)"
557
- SYLogger.info(
558
- f"[DeepAgent #{stream_step}] chunk_type={_chunk_type} msg_type={_msg_type} "
559
- f"ns={_ns} node={_node} step={_step} content={_content_preview}")
552
+ # ===== chunk 诊断日志:默认关闭,DEEP_AGENT_STREAM_DEBUG=true 时开启 =====
553
+ if _STREAM_DEBUG:
554
+ _chunk_type = type(chunk).__name__
555
+ _msg_type = getattr(
556
+ msg, '__class__.__name__', '?') if msg else 'None'
557
+ _ns = namespace if namespace else '()'
558
+ _node = metadata.get(
559
+ "langgraph_node", "?") if metadata else "?"
560
+ _step = metadata.get(
561
+ "langgraph_step", "?") if metadata else "?"
562
+ _content_preview = ""
563
+ if isinstance(msg, BaseMessage):
564
+ _c = msg.content or ""
565
+ _content_preview = repr(_c[:80]) if _c else "(empty)"
566
+ SYLogger.info(
567
+ f"[DeepAgent #{stream_step}] chunk_type={_chunk_type} msg_type={_msg_type} "
568
+ f"ns={_ns} node={_node} step={_step} content={_content_preview}")
560
569
 
561
570
  # ===== 日志(仅关键节点) =====
562
571
  if isinstance(msg, BaseMessage):
@@ -653,12 +662,14 @@ class DeepAgent:
653
662
  await on_event(event)
654
663
  yield event
655
664
 
656
- print(
657
- f"[DeepAgent] post-yield: msg_type={type(msg).__name__}, is_BaseMessage={isinstance(msg, BaseMessage)}", flush=True)
665
+ if _STREAM_DEBUG:
666
+ print(
667
+ f"[DeepAgent] post-yield: msg_type={type(msg).__name__}, is_BaseMessage={isinstance(msg, BaseMessage)}", flush=True)
658
668
 
659
669
  if not isinstance(msg, BaseMessage):
660
- print(
661
- f"[DeepAgent] SKIPPING non-BaseMessage: {type(msg).__name__}", flush=True)
670
+ if _STREAM_DEBUG:
671
+ print(
672
+ f"[DeepAgent] SKIPPING non-BaseMessage: {type(msg).__name__}", flush=True)
662
673
  continue
663
674
 
664
675
  msg_type = msg.__class__.__name__
@@ -899,8 +910,9 @@ class DeepAgent:
899
910
  SYLogger.debug(
900
911
  f"[DeepAgent] AI chunk done | {repr(ai_chunk_buffer[:100])}...")
901
912
 
902
- print(
903
- f"[DeepAgent] FOR LOOP ENDED. stream_step={stream_step}, ai_text_content={repr(ai_text_content[:100])}, tool_calls={len(current_tool_calls)}", flush=True)
913
+ if _STREAM_DEBUG:
914
+ print(
915
+ f"[DeepAgent] FOR LOOP ENDED. stream_step={stream_step}, ai_text_content={repr(ai_text_content[:100])}, tool_calls={len(current_tool_calls)}", flush=True)
904
916
 
905
917
  # 空响应检测:模型被调用但没有产出任何文本
906
918
  if not ai_text_content and not ai_chunk_buffer:
@@ -1000,8 +1012,9 @@ class DeepAgent:
1000
1012
  while True:
1001
1013
  for attempt in range(max_retries):
1002
1014
  try:
1003
- print(
1004
- f"[DeepAgent] _astream_with_retry: attempt={attempt}, starting astream...", flush=True)
1015
+ if _STREAM_DEBUG:
1016
+ print(
1017
+ f"[DeepAgent] _astream_with_retry: attempt={attempt}, starting astream...", flush=True)
1005
1018
  chunk_count = 0
1006
1019
  async for chunk in self.agent.astream(
1007
1020
  input_data,
@@ -1010,13 +1023,16 @@ class DeepAgent:
1010
1023
  subgraphs=True
1011
1024
  ):
1012
1025
  chunk_count += 1
1013
- print(
1014
- f"[DeepAgent] _astream yielding chunk #{chunk_count}", flush=True)
1026
+ if _STREAM_DEBUG:
1027
+ print(
1028
+ f"[DeepAgent] _astream yielding chunk #{chunk_count}", flush=True)
1015
1029
  yield chunk
1030
+ if _STREAM_DEBUG:
1031
+ print(
1032
+ f"[DeepAgent] _astream yield returned, consumer processed chunk #{chunk_count}", flush=True)
1033
+ if _STREAM_DEBUG:
1016
1034
  print(
1017
- f"[DeepAgent] _astream yield returned, consumer processed chunk #{chunk_count}", flush=True)
1018
- print(
1019
- f"[DeepAgent] _astream_with_retry: stream ended normally, total_chunks={chunk_count}", flush=True)
1035
+ f"[DeepAgent] _astream_with_retry: stream ended normally, total_chunks={chunk_count}", flush=True)
1020
1036
  return
1021
1037
  except BadRequestError as e:
1022
1038
  # BadRequestError(含 ContextOverflowError)不重试:
@@ -1191,6 +1207,16 @@ async def create_deep_agent(
1191
1207
  on_evaluation=rubric_eval_collector.on_evaluation,
1192
1208
  ))
1193
1209
 
1210
+ # SkillApiWhitelistMiddleware:按子技能注入接口白名单(拦 execute 工具,
1211
+ # export SKILL_API_WHITELIST + 推送 sitecustomize 到沙箱做 path 级校验)
1212
+ if sandbox_backend:
1213
+ try:
1214
+ from sycommon.agent.middleware.skill_api_whitelist import (
1215
+ SkillApiWhitelistMiddleware)
1216
+ middleware_list.append(SkillApiWhitelistMiddleware(sandbox_backend))
1217
+ except Exception as _wl_err:
1218
+ SYLogger.warning(f"[DeepAgent] 技能接口白名单中间件加载失败(忽略): {_wl_err}")
1219
+
1194
1220
  agent_kwargs = {
1195
1221
  "model": raw_model,
1196
1222
  "tools": config.tools or [get_current_date],
@@ -0,0 +1,146 @@
1
+ """技能接口白名单客户端注入脚本(部署到沙箱 site-packages)。
2
+
3
+ Python 解释器启动时(未传 -S)自动 import 本模块。它负责 Python HTTP 库的
4
+ 请求拦截:
5
+
6
+ 1. 读环境变量 SKILL_API_WHITELIST(JSON)。无此 env → 不管控(全放行,fail-open)。
7
+ 2. monkeypatch urllib / httpx / requests / aiohttp,发请求前做 path 级前缀匹配,
8
+ 未命中抛 PermissionError(LLM 能看到清晰错误)。
9
+
10
+ ⚠️ shell 命令(curl/wget 等)的拦截【不】在本文件处理——sitecustomize 只在
11
+ `python` 启动时执行,纯 shell 命令不会触发它。shell 拦截由主进程中间件把
12
+ curl/wget 包装函数【内联】到每条 execute 命令前缀实现(见 skill_api_whitelist.py)。
13
+
14
+ URL 匹配核心逻辑复用同目录 skill_wl_check.py。
15
+
16
+ 注意:本模块在沙箱子进程里运行,不依赖主进程内存。白名单数据全部来自环境变量。
17
+ """
18
+
19
+ import json
20
+ import os
21
+ import sys
22
+
23
+ # SKILL_API_WHITELIST 存在即「启用管控」;allowlist 为空时「全拦截」。
24
+ _ENABLED = "SKILL_API_WHITELIST" in os.environ
25
+
26
+ # 把沙箱 site-packages 里存放本模块的目录加入 sys.path,以便 import skill_wl_check。
27
+ _HERE = os.path.dirname(os.path.abspath(__file__))
28
+ if _HERE not in sys.path:
29
+ sys.path.insert(0, _HERE)
30
+
31
+
32
+ def _load_check():
33
+ """加载同目录 skill_wl_check,返回其 match 函数;失败返回 None。"""
34
+ try:
35
+ import skill_wl_check # type: ignore
36
+ return skill_wl_check
37
+ except Exception as e: # pragma: no cover
38
+ sys.stderr.write("[sitecustomize] 加载 skill_wl_check 失败,白名单禁用: %s\n" % e)
39
+ return None
40
+
41
+
42
+ def _patch_urllib(match):
43
+ import urllib.request
44
+
45
+ _orig = urllib.request.urlopen
46
+
47
+ def _urlopen(url, *args, **kwargs):
48
+ actual = url.full_url if isinstance(url, urllib.request.Request) else url
49
+ if actual and not match(str(actual)):
50
+ raise PermissionError(
51
+ "[SkillApiWhitelist] 拦截: URL 不在白名单内 (urlopen, %s)" % actual)
52
+ return _orig(url, *args, **kwargs)
53
+
54
+ urllib.request.urlopen = _urlopen
55
+
56
+
57
+ def _patch_httpx(match):
58
+ try:
59
+ import httpx
60
+ except ImportError:
61
+ return
62
+
63
+ _orig_sync = httpx.Client.send
64
+
65
+ def _send(self, request, **kwargs):
66
+ if not match(str(request.url)):
67
+ raise PermissionError(
68
+ "[SkillApiWhitelist] 拦截: URL 不在白名单内 (httpx, %s)" % request.url)
69
+ return _orig_sync(self, request, **kwargs)
70
+
71
+ httpx.Client.send = _send
72
+
73
+ if hasattr(httpx, "AsyncClient"):
74
+ _orig_async = httpx.AsyncClient.send
75
+
76
+ async def _asend(self, request, **kwargs):
77
+ if not match(str(request.url)):
78
+ raise PermissionError(
79
+ "[SkillApiWhitelist] 拦截: URL 不在白名单内 (httpx.async, %s)" % request.url)
80
+ return await _orig_async(self, request, **kwargs)
81
+
82
+ httpx.AsyncClient.send = _asend
83
+
84
+
85
+ def _patch_requests(match):
86
+ try:
87
+ import requests
88
+ except ImportError:
89
+ return
90
+
91
+ _orig = requests.Session.request
92
+
93
+ def _request(self, method, url, *args, **kwargs):
94
+ if not match(str(url)):
95
+ raise PermissionError(
96
+ "[SkillApiWhitelist] 拦截: URL 不在白名单内 (requests %s, %s)"
97
+ % (method.upper(), url))
98
+ return _orig(self, method, url, *args, **kwargs)
99
+
100
+ requests.Session.request = _request
101
+
102
+
103
+ def _patch_aiohttp(match):
104
+ try:
105
+ import aiohttp
106
+ except ImportError:
107
+ return
108
+ if not hasattr(aiohttp.ClientSession, "_request"):
109
+ return
110
+
111
+ _orig = aiohttp.ClientSession._request
112
+
113
+ async def _request(self, method, str_or_url, **kwargs):
114
+ if not match(str(str_or_url)):
115
+ raise PermissionError(
116
+ "[SkillApiWhitelist] 拦截: URL 不在白名单内 (aiohttp %s, %s)"
117
+ % (method, str_or_url))
118
+ return await _orig(self, method, str_or_url, **kwargs)
119
+
120
+ aiohttp.ClientSession._request = _request
121
+
122
+
123
+ def _install():
124
+ if not _ENABLED:
125
+ return
126
+ check = _load_check()
127
+ if check is None:
128
+ return
129
+ # allowlist 为空(明确禁止)时也走校验——match 必然 False,从而全拦截。
130
+ match = check.match
131
+ _patch_urllib(match)
132
+ _patch_httpx(match)
133
+ _patch_requests(match)
134
+ _patch_aiohttp(match)
135
+
136
+
137
+ # 保留解析结果(供排查)
138
+ try:
139
+ _WHITELIST_RAW = json.loads(os.environ.get("SKILL_API_WHITELIST", "{}"))
140
+ except Exception:
141
+ _WHITELIST_RAW = {}
142
+
143
+ try:
144
+ _install()
145
+ except Exception as _e: # 任何 patch 失败都不能阻断进程启动
146
+ sys.stderr.write("[sitecustomize] 白名单安装失败(fail-open): %s\n" % _e)
@@ -0,0 +1,253 @@
1
+ """技能接口白名单中间件。
2
+
3
+ 拦截 `execute` 工具(FilesystemMiddleware 注册的 shell 执行工具),做两件事:
4
+
5
+ 1. 从命令路径推断子技能名(skills/<域>/<子技能>/scripts/ → <子技能>),
6
+ 查 SkillApiWhitelist 配置得到该子技能的白名单,把占位符 ${VAR} 在主进程
7
+ 解析成完整 URL,序列化成 JSON 后以 `export SKILL_API_WHITELIST='<json>'`
8
+ 前缀注入命令——供沙箱内 sitecustomize.py 读取并 monkeypatch urllib/
9
+ httpx/requests/aiohttp。
10
+
11
+ 2. 命令前缀内联 curl/wget 的 shell 函数包装:纯 shell 命令(不走 Python)
12
+ 不会触发 sitecustomize,故用 shell 函数在执行 curl/wget 前调
13
+ `python -m skill_wl_check <url>` 做校验,未命中则 return 1 拦截。
14
+
15
+ 3. 首次惰性把 sitecustomize.py + skill_wl_check.py 推进沙箱 site-packages,
16
+ 使 Python 启动时自动 import sitecustomize。
17
+
18
+ 语义:子技能未配置 / enabled:false → 不注入(全放行,向后兼容);
19
+ allowlist:[] 空数组 → 注入空白名单(全拦截)。
20
+ """
21
+
22
+ import json
23
+ import os
24
+ import re
25
+ import shlex
26
+ from typing import Optional
27
+
28
+ from langchain.agents.middleware.types import AgentMiddleware
29
+ from langgraph.prebuilt.tool_node import ToolCallRequest
30
+
31
+ from sycommon.logging.kafka_log import SYLogger
32
+
33
+ # 命令路径形如 skills/sy-oa-skill/workflow-todo/scripts/x.py
34
+ # skills/sy-syt-skill/shengye-platform-zhongdeng/SKILL.md
35
+ # skills/sy-syt-skill/generate-plan/... (有/无 scripts/ 都识别)
36
+ # 取「skills/<域>/<子技能>/」的第二段作子技能名,不限 scripts/,覆盖文档目录型子技能。
37
+ # 用 (?:/|$) 锚定第二段结尾,避免误吞后续路径段。
38
+ _SKILL_NESTED_RE = re.compile(r'skills/[A-Za-z0-9_\-]+/([A-Za-z0-9_\-]+)(?:/|$)')
39
+ _SKILL_FLAT_RE = re.compile(r'/skills/([A-Za-z0-9_\-]+)(?:/|$)')
40
+
41
+ # BASE_URL 占位符 ${VAR}
42
+ _PLACEHOLDER_RE = re.compile(r'\$\{([A-Z_][A-Z0-9_]*)\}')
43
+
44
+ # 推送到沙箱 workspace 内的 .wl 目录(沙箱强制路径隔离,绝对路径会被映射进 workspace,
45
+ # 故用相对 POSIX 路径,落地为 {workspace}/.wl/,再 export PYTHONPATH 让 Python 自动 import)
46
+ _PUSH_FILES = [
47
+ ("sitecustomize.py", ".wl/sitecustomize.py"),
48
+ ("skill_wl_check.py", ".wl/skill_wl_check.py"),
49
+ ]
50
+ # workspace 内白名单目录的 shell 形式(init_script 已 export $_SANDBOX_WORKSPACE,运行期展开)
51
+ _WL_DIR = "$_SANDBOX_WORKSPACE/.wl"
52
+
53
+
54
+ def _resolve_placeholders(pattern: str, env_lookup) -> Optional[str]:
55
+ """把 ${VAR} 替换为解析后的值。任一 VAR 不存在返回 None(配置错误)。
56
+
57
+ env_lookup: callable(var_name) -> str|None。中间件传入「先查主进程 os.environ,
58
+ 再查 sandbox env 字典(含 SYT_BASE_URL 等运行时算出/从 Nacos 读出的值)」的查找器。
59
+ """
60
+ missing = []
61
+
62
+ def _sub(m):
63
+ val = env_lookup(m.group(1))
64
+ if val is None:
65
+ missing.append(m.group(1))
66
+ return ""
67
+ return str(val).rstrip("/")
68
+
69
+ expanded = _PLACEHOLDER_RE.sub(_sub, pattern)
70
+ if missing:
71
+ SYLogger.warning(
72
+ "[Whitelist] 占位符未定义 %s,丢弃规则 %s", missing, pattern)
73
+ return None
74
+ return expanded
75
+
76
+
77
+ def _load_resource_bytes(filename: str) -> bytes:
78
+ """从本包读取注入脚本源码(package_data)。"""
79
+ try:
80
+ from importlib.resources import files
81
+ return (files("sycommon.agent.middleware").joinpath(filename).read_bytes())
82
+ except Exception as e:
83
+ raise RuntimeError(
84
+ f"读取注入脚本 {filename} 失败,请确认已随包安装(package_data): {e}") from e
85
+
86
+
87
+ def _build_shell_guard() -> str:
88
+ """生成内联到命令前缀的 curl/wget 包装函数(shell)。
89
+
90
+ 在 SKILL_API_WHITELIST 已 export 的命令里生效:调用 curl/wget 前先抽取
91
+ 参数里首个 http(s) URL,用沙箱 python3 调 skill_wl_check.match 校验,
92
+ 未命中则 return 1 拦截。skill_wl_check 与 sitecustomize 同处 site-packages,
93
+ python3 启动时 sitecustomize 会 import 它并把该目录加入 sys.path。
94
+
95
+ 覆盖 curl / wget;其它 shell 出网方式(nc/裸 /dev/tcp 等)不覆盖。
96
+ """
97
+ check_cmd = (
98
+ 'python3 -c \'import skill_wl_check,sys; '
99
+ 'sys.exit(0 if skill_wl_check.match(sys.argv[1]) else 1)\' "$_u"'
100
+ )
101
+ # 单个命令的函数体模板:抽取 URL → 校验 → 通过则 command <CMD> "$@"
102
+ body = (
103
+ '() { local _u; for _a in "$@"; do '
104
+ 'case "$_a" in http://*|https://*) _u="$_a";; esac; done; '
105
+ f'if [ -n "$_u" ] && ! {check_cmd}; then '
106
+ 'echo "[SkillApiWhitelist] 拦截 shell 请求: $_u" 1>&2; return 1; fi; '
107
+ 'command {CMD} "$@"; };'
108
+ )
109
+ guard = "curl" + body.replace("{CMD}", "curl") + " wget" + body.replace("{CMD}", "wget")
110
+ return guard.rstrip(";") # 末尾不带分号:由调用方按需拼接分隔符
111
+
112
+
113
+ class SkillApiWhitelistMiddleware(AgentMiddleware):
114
+ """按子技能动态注入接口白名单环境变量 + shell 包装。
115
+
116
+ AgentMiddleware 协议钩子:实现 `awrap_tool_call`(异步版本),在 execute
117
+ 工具真正执行前注入白名单。框架对工具调用走 astream/ainvoke 异步链路,故
118
+ 只需实现异步钩子(与现有 TokenTrackingMiddleware/ToolResultTruncationMiddleware 一致)。
119
+ """
120
+
121
+ def __init__(self, sandbox_backend):
122
+ self._backend = sandbox_backend
123
+ self._pushed: bool = False # 注入脚本是否已推送(每 backend 实例一次)
124
+
125
+ def _env_lookup(self):
126
+ """构造占位符查找器:先查主进程 os.environ,再查 sandbox env 字典。
127
+
128
+ SYT_BASE_URL / XINGHAN_BASE_URL / YEALINK_BASE_URL 等不在主进程 os.environ 里,
129
+ 而是放在传给 sandbox backend 的 env 字典中(tools/config.py.build_sandbox_env 构造)。
130
+ MultiSandboxRouter/HTTPSandboxBackend 都暴露 `_env`,这里统一兜底取值。
131
+ """
132
+ sandbox_env = {}
133
+ b = self._backend
134
+ for attr in ("_env",):
135
+ v = getattr(b, attr, None)
136
+ if isinstance(v, dict):
137
+ sandbox_env.update(v)
138
+ else:
139
+ # RoutingBackend 等代理对象:尝试 default/local/remote 子 backend
140
+ pass
141
+ # 再尝试 default backend(MultiSandboxRouter._default._env)
142
+ default = getattr(b, "_default", None)
143
+ if default is not None:
144
+ v = getattr(default, "_env", None)
145
+ if isinstance(v, dict):
146
+ sandbox_env.update(v)
147
+
148
+ def lookup(var):
149
+ val = os.environ.get(var)
150
+ if val is None:
151
+ val = sandbox_env.get(var)
152
+ return val
153
+
154
+ return lookup
155
+
156
+ # ---- 懒加载:把注入脚本推进沙箱 workspace/.wl ----
157
+ async def _ensure_sitecustomize(self) -> bool:
158
+ """把 sitecustomize.py + skill_wl_check.py 推到沙箱 workspace 内的 .wl 目录。
159
+
160
+ 沙箱强制路径隔离:任何绝对路径都会被 resolve_sandbox_path 映射进用户 workspace,
161
+ 真实 site-packages 无法写入。故改用「推到 workspace/.wl/ + export PYTHONPATH」
162
+ 的方案:Python 启动时若 .wl 在 PYTHONPATH 上,会自动 import sitecustomize。
163
+
164
+ 幂等:每个 backend 实例只推一次(_pushed 标记)。失败 fail-open(不阻塞业务)。
165
+ """
166
+ if self._pushed:
167
+ return True
168
+ backend = self._backend
169
+ if backend is None:
170
+ return False
171
+ try:
172
+ files = []
173
+ for src, dst in _PUSH_FILES:
174
+ content = _load_resource_bytes(src)
175
+ files.append((dst, content))
176
+ await backend.aupload_files(files)
177
+ self._pushed = True
178
+ SYLogger.info("[Whitelist] 注入脚本已推送至沙箱 %s", _WL_DIR)
179
+ return True
180
+ except Exception as e:
181
+ SYLogger.error("[Whitelist] 推送 sitecustomize 失败(fail-open): %s", e)
182
+ return False
183
+
184
+ # ---- 子技能名提取 ----
185
+ def _extract_skill_name(self, command: str) -> Optional[str]:
186
+ m = _SKILL_NESTED_RE.search(command)
187
+ if m:
188
+ return m.group(1)
189
+ m = _SKILL_FLAT_RE.search(command)
190
+ if m:
191
+ return m.group(1)
192
+ return None
193
+
194
+ # ---- 主入口(AgentMiddleware 异步钩子)----
195
+ async def awrap_tool_call(
196
+ self,
197
+ request: ToolCallRequest,
198
+ handler,
199
+ ):
200
+ tc = request.tool_call
201
+ if tc.get("name") != "execute":
202
+ return await handler(request)
203
+
204
+ args = tc.get("args", {}) or {}
205
+ command = args.get("command", "") if isinstance(args, dict) else ""
206
+ if not command:
207
+ return await handler(request)
208
+
209
+ skill_name = self._extract_skill_name(command)
210
+ if skill_name is None:
211
+ # 非技能命令(无 skills/.../scripts/)→ 不管控
212
+ return await handler(request)
213
+
214
+ # 延迟导入避免模块加载期循环依赖
215
+ from sycommon.config.SkillApiWhitelistConfig import SkillApiWhitelistConfig
216
+ cfg = SkillApiWhitelistConfig.from_config(skill_name)
217
+ if cfg is None or not cfg.enabled:
218
+ # 未配置 / 禁用 → 全放行(向后兼容)
219
+ return await handler(request)
220
+
221
+ # 主进程解析占位符 ${VAR}(数据源:os.environ + sandbox env 字典)
222
+ env_lookup = self._env_lookup()
223
+ resolved = []
224
+ for pat in cfg.allowlist:
225
+ if "${" in pat:
226
+ expanded = _resolve_placeholders(pat, env_lookup)
227
+ if expanded:
228
+ resolved.append(expanded)
229
+ else:
230
+ resolved.append(pat)
231
+
232
+ # 推送注入脚本(惰性,每后端实例一次)
233
+ await self._ensure_sitecustomize()
234
+
235
+ # 序列化白名单 + PYTHONPATH(shell 启动 python 时自动 import sitecustomize)
236
+ # + shell guard(curl/wget 拦截) + export SKILL_API_WHITELIST
237
+ payload = json.dumps({"skill": skill_name, "allowlist": resolved},
238
+ ensure_ascii=False)
239
+ guard = _build_shell_guard()
240
+ prefix_parts = [
241
+ f"export SKILL_API_WHITELIST={shlex.quote(payload)}",
242
+ f'export PYTHONPATH="{_WL_DIR}:$PYTHONPATH"',
243
+ guard,
244
+ ]
245
+ # 每个 part 之间用 '; ' 分隔(而非空格)—— export 语句之间、函数定义与后续命令
246
+ # 之间都需要语句分隔符;末尾留一个 ';' 与原命令隔开。
247
+ prefix = "; ".join(prefix_parts) + "; "
248
+
249
+ new_command = prefix + command
250
+ new_args = {**args, "command": new_command} if isinstance(args, dict) else args
251
+ new_tc = {**tc, "args": new_args}
252
+ SYLogger.info("[Whitelist] 注入子技能 %s 白名单(%d 条)", skill_name, len(resolved))
253
+ return await handler(request.override(tool_call=new_tc))
@@ -0,0 +1,123 @@
1
+ """技能接口白名单:URL 匹配核心逻辑(被 sitecustomize 与 shell 包装层共用)。
2
+
3
+ 沙箱内运行。从环境变量 `SKILL_API_WHITELIST` 读取白名单 JSON:
4
+
5
+ {"skill": "<子技能名>", "allowlist": ["<完整URL或path前缀>", ...]}
6
+
7
+ 提供:
8
+ - match(url) -> bool 供 sitecustomize(Python 库 monkeypatch)内存调用
9
+ - main() CLI:`python3 -m skill_wl_check <url>`,exit 0=放行 / 1=拦截
10
+
11
+ 匹配规则(path 级前缀,带斜杠边界):
12
+ - 完整 URL 条目:scheme/host 小写比较,port 规则未写则任意,path 前缀匹配
13
+ - 纯 path 前缀条目(无 host):只比 path(匹配任意 host,谨慎用)
14
+ - /wf 匹配 /wf 与 /wf/...,不匹配 /wfcount(斜杠边界)
15
+ - query string 不参与匹配
16
+ """
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ from urllib.parse import urlparse
22
+
23
+ _ENV = "SKILL_API_WHITELIST"
24
+
25
+
26
+ def _load_allowlist() -> list[str]:
27
+ """从环境变量读白名单。无 env / 解析失败 → 空列表(sitecustomize 据此决定是否启用)。"""
28
+ raw = os.environ.get(_ENV, "")
29
+ if not raw:
30
+ return []
31
+ try:
32
+ data = json.loads(raw)
33
+ if isinstance(data, dict):
34
+ return [str(x) for x in data.get("allowlist", [])]
35
+ if isinstance(data, list):
36
+ return [str(x) for x in data]
37
+ except Exception:
38
+ pass
39
+ return []
40
+
41
+
42
+ def _normalize(entry: str) -> tuple:
43
+ """把白名单条目规范化成 (scheme, host, port, path_prefix)。
44
+
45
+ - 'https://oa.syholdings.com/services/WorkflowService'
46
+ → ('https', 'oa.syholdings.com', None, '/services/WorkflowService')
47
+ - 'http://10.100.8.72:8888'
48
+ → ('http', '10.100.8.72', 8888, '')
49
+ - '/wf' (纯 path 前缀,无 host)
50
+ → (None, None, None, '/wf')
51
+ """
52
+ entry = entry.strip().rstrip("/")
53
+ if entry.startswith(("http://", "https://")):
54
+ p = urlparse(entry)
55
+ path = (p.path or "").rstrip("/")
56
+ return (
57
+ (p.scheme or "").lower(),
58
+ (p.hostname or "").lower(),
59
+ p.port,
60
+ path,
61
+ )
62
+ # 纯 path 前缀(无 host)
63
+ if not entry.startswith("/"):
64
+ entry = "/" + entry
65
+ return (None, None, None, entry)
66
+
67
+
68
+ def _compiled():
69
+ return [_normalize(e) for e in _load_allowlist()]
70
+
71
+
72
+ def _is_allowed(url: str, rules=None) -> bool:
73
+ if rules is None:
74
+ rules = _compiled()
75
+ try:
76
+ p = urlparse(url if "://" in url else "http://" + str(url))
77
+ except Exception:
78
+ return False
79
+ req_scheme = (p.scheme or "").lower()
80
+ req_host = (p.hostname or "").lower()
81
+ req_port = p.port
82
+ req_path = (p.path or "").rstrip("/")
83
+ for (sch, host, port, pfx) in rules:
84
+ if pfx == "":
85
+ pfx_match = True # 规则没带 path → 该 host 下任意 path 放行
86
+ elif req_path == pfx or req_path.startswith(pfx + "/"):
87
+ pfx_match = True
88
+ else:
89
+ pfx_match = False
90
+ if not pfx_match:
91
+ continue
92
+ if sch is None:
93
+ # 纯 path 前缀规则:只比 path(host 任意)
94
+ return True
95
+ if req_scheme != sch or req_host != host:
96
+ continue
97
+ if port is not None and req_port not in (None, port):
98
+ continue
99
+ return True
100
+ return False
101
+
102
+
103
+ def match(url: str) -> bool:
104
+ """供 Python 库内存调用:URL 是否在白名单内。"""
105
+ return _is_allowed(url)
106
+
107
+
108
+ def main(argv=None) -> int:
109
+ argv = list(sys.argv[1:] if argv is None else argv)
110
+ if not argv:
111
+ sys.stderr.write("[skill_wl_check] 用法: python3 -m skill_wl_check <url>\n")
112
+ return 2
113
+ url = argv[0]
114
+ if match(url):
115
+ return 0
116
+ sys.stderr.write(
117
+ "[SkillApiWhitelist] 拦截: URL 不在白名单内 (url=%s)。"
118
+ "请联系管理员修改 SkillApiWhitelist 配置。\n" % url)
119
+ return 1
120
+
121
+
122
+ if __name__ == "__main__":
123
+ sys.exit(main())
@@ -47,6 +47,25 @@ def _shell_quote(value: str) -> str:
47
47
  return "'" + value.replace("'", "'\\''") + "'"
48
48
 
49
49
 
50
+ def _is_response_error(exc: Exception) -> bool:
51
+ """判断异常是否属于「已收到 HTTP 响应」的错误(非瞬时网络抖动)。
52
+
53
+ 含义:请求已成功抵达服务端并拿到了响应,只是状态码或响应体不符合预期。
54
+ 这类错误(典型:服务端 500)通常对同一实例是确定性的,原地重试只会白等,
55
+ 应直接跳到故障转移(切实例)一次,再不行就快速失败。
56
+ 与之相对,「瞬时错误」(连接 reset / timeout / DNS / ClientConnectionError)
57
+ 建议原地指数退避重试。
58
+
59
+ - 异步:aiohttp.ClientResponseError(raise_for_status 抛出)
60
+ - 同步:requests.exceptions.HTTPError(raise_for_status 抛出)
61
+ """
62
+ if isinstance(exc, aiohttp.ClientResponseError):
63
+ return True
64
+ if isinstance(exc, requests.exceptions.HTTPError):
65
+ return True
66
+ return False
67
+
68
+
50
69
  def filter_instances_by_owner(instances: list, user_id: str, timeout: float = 3.0) -> list:
51
70
  """过滤掉不属于当前用户的本地沙箱实例。
52
71
 
@@ -481,7 +500,12 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
481
500
  return headers
482
501
 
483
502
  def _post_sync_with_failover(self, endpoint: str, data: dict, timeout: int = None) -> dict:
484
- """带故障转移的同步 POST 请求"""
503
+ """带故障转移的同步 POST 请求(策略对齐异步版本)
504
+
505
+ - 瞬时错误(连接 reset / timeout / ConnectionError)→ 原地指数退避重试,不切实例。
506
+ - 响应级错误(已拿到 HTTP 响应:HTTPError,典型为服务端 5xx)→ 跳过原地重试,
507
+ 直接切一次实例(避免对确定性 500 白等 3 次)。
508
+ """
485
509
  actual_timeout = timeout if timeout is not None else self._timeout
486
510
  # 执行命令的 HTTP 超时需要更长,给沙箱足够时间执行
487
511
  # 只有在非 execute 端点时才加 30 秒缓冲
@@ -492,11 +516,13 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
492
516
  max_retries = 3
493
517
 
494
518
  headers = self._build_headers()
519
+ last_exc: Optional[Exception] = None
495
520
 
496
521
  url = f"{self._base_url}{endpoint}"
497
522
  SYLogger.info(
498
523
  f"[Sandbox] POST 请求开始: {url}, timeout={http_timeout}s, command_timeout={actual_timeout}s")
499
524
 
525
+ skip_in_place = False
500
526
  for attempt in range(max_retries):
501
527
  try:
502
528
  SYLogger.info(
@@ -517,19 +543,46 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
517
543
  return result
518
544
 
519
545
  except (requests.exceptions.RequestException, requests.exceptions.HTTPError) as e:
546
+ last_exc = e
547
+ if _is_response_error(e):
548
+ # 已拿到服务端响应(如 500),重发同一实例是确定性的 → 跳过原地重试
549
+ SYLogger.warning(
550
+ f"[Sandbox] 服务端响应错误,跳过原地重试直接故障转移: "
551
+ f"{self._base_url} - HTTP {getattr(e.response, 'status_code', None) or '?'}")
552
+ skip_in_place = True
553
+ break
520
554
  SYLogger.warning(
521
- f"[Sandbox] 请求失败 (尝试 {attempt + 1}/{max_retries}): {self._base_url} - {e}")
522
-
555
+ f"[Sandbox] 请求失败 (原地重试 {attempt + 1}/{max_retries}): {self._base_url} - {e}")
523
556
  if attempt < max_retries - 1:
524
- backoff = 2 ** attempt
525
- switched = self._refresh_from_nacos_and_switch_sync()
526
- if not switched:
527
- import time
528
- time.sleep(backoff)
529
- else:
530
- raise RuntimeError(f"沙箱服务不可用: {e}")
557
+ import time
558
+ time.sleep(2 ** attempt) # 1s, 2s
559
+
560
+ # 原地重试耗尽 / 响应级错误 → 切一次实例
561
+ SYLogger.warning(
562
+ f"[Sandbox] 原地重试不可用{'(响应级错误)' if skip_in_place else ''},尝试切换实例: {self._base_url}")
563
+ switched = self._refresh_from_nacos_and_switch_sync()
564
+ if switched:
565
+ try:
566
+ url = f"{self._base_url}{endpoint}"
567
+ SYLogger.info(f"[Sandbox] 切换后单次重试: {url}")
568
+ resp = requests.post(
569
+ url,
570
+ json=data,
571
+ headers=headers if headers else None,
572
+ timeout=http_timeout
573
+ )
574
+ if resp.status_code >= 400:
575
+ SYLogger.error(
576
+ f"[Sandbox] 切换后仍 HTTP 错误 {resp.status_code}: {resp.text}")
577
+ resp.raise_for_status()
578
+ result = resp.json()
579
+ SYLogger.info(f"[Sandbox] 切换实例后成功: {url}")
580
+ return result
581
+ except (requests.exceptions.RequestException, requests.exceptions.HTTPError) as e:
582
+ last_exc = e
583
+ SYLogger.warning(f"[Sandbox] 切换实例后仍失败: {self._base_url} - {e}")
531
584
 
532
- raise RuntimeError("沙箱服务不可用")
585
+ raise RuntimeError(f"沙箱服务不可用: {last_exc}")
533
586
 
534
587
  def _post_sync(self, endpoint: str, data: dict, timeout: int = None) -> dict:
535
588
  """发送同步 POST 请求(带故障转移)"""
@@ -572,30 +625,37 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
572
625
  async def _post_async_with_failover(self, endpoint: str, data: dict, timeout: int = None) -> dict:
573
626
  """带故障转移和指数退避的异步 POST 请求。
574
627
 
575
- 策略(修复「两实例无限横跳」):
576
- - 瞬时错误(连接 reset / timeout / ClientError)→ 原地指数退避重试,不切实例。
628
+ 策略(修复「两实例无限横跳」+「服务端 500 白等 3 次」):
629
+ - 瞬时错误(连接 reset / timeout / ClientConnectionError)→ 原地指数退避重试,不切实例。
577
630
  切实例会触发 MinIO 恢复(耗时且会污染新沙箱),瞬时抖动不该付出这个代价。
578
- - 原地重试全部失败后 才切一次实例(带失败 URL 排除 + 冷却,避免切回刚失败的)。
631
+ - 响应级错误(已拿到 HTTP 响应:ClientResponseError,典型为服务端 5xx)→ 跳过原地重试。
632
+ 服务端逻辑性错误对同一实例是确定性的,原地重发只会白等,直接切实例一次。
579
633
  - 切换后再试一次;仍失败才抛错。
580
634
  """
581
635
  max_retries = 3
582
636
  last_exc: Optional[Exception] = None
583
637
 
584
- # 阶段 1:原地重试(不切实例),应对瞬时网络抖动
638
+ # 阶段 1:原地重试(不切实例),仅对瞬时网络抖动退避;响应级错误直接跳过
585
639
  for attempt in range(max_retries):
586
640
  try:
587
641
  return await self._post_async(endpoint, data, timeout)
588
642
  except (aiohttp.ClientError, asyncio.TimeoutError) as e:
589
643
  last_exc = e
644
+ if _is_response_error(e):
645
+ # 已拿到服务端响应(如 500),重发同一实例是确定性的 → 跳过原地重试
646
+ SYLogger.warning(
647
+ f"[Sandbox] 服务端响应错误,跳过原地重试直接故障转移: "
648
+ f"{self._base_url} - HTTP {getattr(e, 'status', None) or '?'}")
649
+ break
590
650
  SYLogger.warning(
591
651
  f"[Sandbox] Async 请求失败 (原地重试 {attempt + 1}/{max_retries}): "
592
652
  f"{self._base_url} - {e}")
593
653
  if attempt < max_retries - 1:
594
654
  await asyncio.sleep(2 ** attempt) # 1s, 2s
595
655
 
596
- # 阶段 2:原地重试耗尽 → 尝试切换实例一次
656
+ # 阶段 2:原地重试耗尽 / 响应级错误 → 尝试切换实例一次
597
657
  SYLogger.warning(
598
- f"[Sandbox] 原地重试 {max_retries} 次仍失败,尝试切换实例: {self._base_url}")
658
+ f"[Sandbox] 原地重试不可用,尝试切换实例: {self._base_url}")
599
659
  switched = await asyncio.to_thread(self._refresh_from_nacos_and_switch_sync)
600
660
  if switched:
601
661
  try:
sycommon/config/Config.py CHANGED
@@ -185,7 +185,8 @@ class Config(metaclass=SingletonMeta):
185
185
  # 这些 key 写在「与 app 同名的 dataId」里(如 shengye-platform-digital-work),
186
186
  # 但消费方按顶层 key 读取(LLMConfig 等)——提升到顶层,免得每个调用方都得知道嵌套路径。
187
187
  override_keys = ['LLMConfig', 'EmbeddingConfig', 'RerankerConfig',
188
- 'ACPAgentConfig']
188
+ 'ACPAgentConfig',
189
+ 'SkillApiWhitelist']
189
190
  for k in override_keys:
190
191
  if k in project_cfg:
191
192
  self.config[k] = project_cfg[k]
@@ -0,0 +1,82 @@
1
+ """技能接口白名单配置模型。
2
+
3
+ 定义「单个子技能被允许访问的外部接口白名单」,配置来源是 Nacos 的
4
+ `shengye-platform-digital-work` dataId 下的 `SkillApiWhitelist` 列表:
5
+
6
+ SkillApiWhitelist:
7
+ - skill: workflow-todo # 子技能名(skills/<域>/<子技能>/scripts/ 里的 <子技能>)
8
+ enabled: true
9
+ allowlist:
10
+ - "${OA_WORKFLOW_URL}" # BASE_URL 占位符,主进程解析成完整 URL
11
+ - "${SYT_BASE_URL}/wf" # 占位符 + path 后缀
12
+ - "https://oa.syholdings.com" # 完整 URL
13
+ - "/wf" # 纯 path 前缀(匹配任意 host,谨慎用)
14
+
15
+ 语义约定(务必在 Nacos 配置注释里写明):
16
+ - 子技能【未出现】在配置里 → 不管控,全放行(向后兼容)
17
+ - enabled: false → 全放行
18
+ - allowlist: [](空数组) → 明确禁止该子技能任何外网请求(全拦截)
19
+
20
+ 代码侧用 `SkillApiWhitelistConfig.from_config(skill)` 按 skill 取单条,
21
+ 或 `list_from_config()` 取全部。
22
+ """
23
+
24
+ from typing import Optional
25
+
26
+ from pydantic import BaseModel, Field
27
+
28
+
29
+ class SkillApiWhitelistConfig(BaseModel):
30
+ """单个子技能的接口白名单配置。"""
31
+
32
+ skill: str = Field(
33
+ description="子技能名,对应命令路径 skills/<域>/<子技能>/scripts/x.py 里的 <子技能>,"
34
+ "如 'workflow-todo'、'oa-workorder'、'wecomcli-wrapper'")
35
+ enabled: bool = Field(default=True, description="是否启用白名单管控;False 则全放行")
36
+ allowlist: list[str] = Field(
37
+ default_factory=list,
38
+ description=(
39
+ "白名单条目(字符串数组)。每条可以是:完整 URL(https://...)、"
40
+ "BASE_URL 占位符(${ENV_VAR} 或 ${ENV_VAR}/path)、纯 path 前缀(/wf)。"
41
+ "主进程加载时把 ${ENV_VAR} 解析成完整 URL,运行期做 path 级前缀匹配。"
42
+ "空数组 [] = 明确禁止该子技能任何外网请求。"
43
+ ))
44
+
45
+ @classmethod
46
+ def from_config(cls, skill: str) -> Optional["SkillApiWhitelistConfig"]:
47
+ """按 skill 名从 Nacos 配置取单个白名单配置。不存在返回 None。"""
48
+ for cfg in list_from_config():
49
+ if cfg.skill == skill:
50
+ return cfg
51
+ return None
52
+
53
+ @classmethod
54
+ def list_from_config(cls) -> list["SkillApiWhitelistConfig"]:
55
+ """从 Nacos 配置取全部已配置的子技能白名单。
56
+
57
+ 配置读 `Config().config.get("SkillApiWhitelist", [])`——即
58
+ `shengye-platform-digital-work` dataId 下的 SkillApiWhitelist 列表
59
+ (需在 Config.set_attr 的 override_keys 里登记,否则读不到)。
60
+ """
61
+ from sycommon.config.Config import Config
62
+
63
+ raw_list = Config().config.get("SkillApiWhitelist", []) or []
64
+ result: list[SkillApiWhitelistConfig] = []
65
+ for item in raw_list:
66
+ try:
67
+ result.append(cls(**item))
68
+ except Exception as e: # 单条配置错误不影响其它
69
+ import logging
70
+ logging.getLogger(__name__).warning(
71
+ "Invalid SkillApiWhitelistConfig entry %s: %s", item, e)
72
+ return result
73
+
74
+ @classmethod
75
+ def list_enabled_from_config(cls) -> list["SkillApiWhitelistConfig"]:
76
+ """取全部 enabled=True 的配置。"""
77
+ return [c for c in cls.list_from_config() if c.enabled]
78
+
79
+
80
+ def list_from_config() -> list[SkillApiWhitelistConfig]:
81
+ """模块级便捷函数,等价于 SkillApiWhitelistConfig.list_from_config()。"""
82
+ return SkillApiWhitelistConfig.list_from_config()
@@ -214,20 +214,38 @@ def _sanitize_user_id(user_id: str) -> str:
214
214
 
215
215
 
216
216
  def get_user_workspace(user_id: str) -> str:
217
- """获取用户工作目录"""
217
+ """获取用户工作目录
218
+
219
+ 创建失败(磁盘满/只读/权限不足)时不抛异常,记录后返回路径。
220
+ 由后续具体文件操作(tree/read/write)返回各自的结构化错误,
221
+ 避免被全局异常处理器吞成无信息 500;日志带真实原因便于排查。
222
+ """
218
223
  workspace = os.path.join(WORKSPACE_BASE, _sanitize_user_id(user_id))
219
224
  if not os.path.exists(workspace):
220
- os.makedirs(workspace, exist_ok=True)
225
+ try:
226
+ os.makedirs(workspace, exist_ok=True)
227
+ except OSError as e:
228
+ SYLogger.error(
229
+ f"[Sandbox Server] 创建用户工作目录失败 (可能磁盘满/只读/权限不足): "
230
+ f"{workspace} - {type(e).__name__}: {e}")
221
231
  return workspace
222
232
 
223
233
 
224
234
  async def aget_user_workspace(user_id: str) -> str:
225
- """获取用户工作目录(异步版本,避免阻塞事件循环)"""
235
+ """获取用户工作目录(异步版本,避免阻塞事件循环)
236
+
237
+ 创建失败处理同 get_user_workspace:记录后返回路径,不抛异常。
238
+ """
226
239
  workspace = os.path.join(WORKSPACE_BASE, _sanitize_user_id(user_id))
227
240
 
228
241
  def _ensure():
229
242
  if not os.path.exists(workspace):
230
- os.makedirs(workspace, exist_ok=True)
243
+ try:
244
+ os.makedirs(workspace, exist_ok=True)
245
+ except OSError as e:
246
+ SYLogger.error(
247
+ f"[Sandbox Server] 创建用户工作目录失败 (可能磁盘满/只读/权限不足): "
248
+ f"{workspace} - {type(e).__name__}: {e}")
231
249
 
232
250
  await asyncio.to_thread(_ensure)
233
251
  return workspace
@@ -394,7 +412,15 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
394
412
  if not initialized:
395
413
  def _init():
396
414
  if not os.path.exists(WORKSPACE_BASE):
397
- os.makedirs(WORKSPACE_BASE, exist_ok=True)
415
+ try:
416
+ os.makedirs(WORKSPACE_BASE, exist_ok=True)
417
+ except OSError as e:
418
+ # 不抛异常:建失败时具体文件操作会返回结构化错误,
419
+ # 避免全局异常处理器吞成无信息 500;日志带真实原因。
420
+ SYLogger.error(
421
+ f"[Sandbox Server] 初始化工作根目录失败 "
422
+ f"(可能磁盘满/只读/权限不足): {WORKSPACE_BASE} - "
423
+ f"{type(e).__name__}: {e}")
398
424
  await asyncio.to_thread(_init)
399
425
  initialized = True
400
426
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sycommon-python-lib
3
- Version: 0.2.7a4
3
+ Version: 0.2.7a5
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -136,7 +136,7 @@ sycommon/services.py,sha256=CwC_gESafo05Qn41KLQgtulx6PfX7s2FkxlBI-hXve4,24861
136
136
  sycommon/agent/__init__.py,sha256=mxceAeUifQ-DKvWp7ZEJIFlmOCb5wpYHPGQw3rwEN8I,4378
137
137
  sycommon/agent/agent_manager.py,sha256=UhhaekEumT7g4v_Z1UB4jTp13X0n8M8erYaQdkGGWkA,13620
138
138
  sycommon/agent/chat_events.py,sha256=t7qWa6OrIWLfqtd1AnqaVP67QWkn1JxpCBTZYQpoLuM,14226
139
- sycommon/agent/deep_agent.py,sha256=MPGy7WR3SZ2Hs--g5VFqXE9DGNRNCsNHYY65SlEoTbU,65612
139
+ sycommon/agent/deep_agent.py,sha256=ZcqQ8aVo6PYx3YWzV8aEV9HRtuXuoVUyXWGwXUJ_VTw,67278
140
140
  sycommon/agent/multi_agent_team.py,sha256=mskS-FJbAB_n5kghjHhiWxpA3L9cD1lObeW_wjrLI6s,27079
141
141
  sycommon/agent/summarization_utils.py,sha256=zAnNtqzZ6mKmLVpLQLgcnbdV0D5ohxpAjPkg9Uz2V7s,17934
142
142
  sycommon/agent/acp/__init__.py,sha256=vQGMQYAA5-ZaejYmDZ4r4Fklqs17qH3cDXQ5pZlwj-0,1844
@@ -147,10 +147,13 @@ sycommon/agent/acp/tool.py,sha256=yt6ohKL2y7wIZMNiLv-m5oCg5u1p5GAbqgjzQVt285Y,10
147
147
  sycommon/agent/mcp/__init__.py,sha256=iKrdDhIrFsNIkqG_kgcwNe-nOiM6uVfolKv44LfQ-FQ,636
148
148
  sycommon/agent/mcp/models.py,sha256=zda4ho-UTYOJ5oPAZPZ-vxhfDRLghUCsMroHnA2A2QQ,1490
149
149
  sycommon/agent/mcp/tool_loader.py,sha256=OQa-lE7RL2s3Lqh-kNOKaUrb130hvorB_HjyFBqZBZs,10960
150
+ sycommon/agent/middleware/sitecustomize.py,sha256=Bt7XFnWV_LJo89l858AGp5VUkXwefpLGmjkCtLe-CMw,4800
151
+ sycommon/agent/middleware/skill_api_whitelist.py,sha256=vAAUGIYYKVYAzJVYB0tTObLweQ1OBTnCfPPbc37yTfo,11029
152
+ sycommon/agent/middleware/skill_wl_check.py,sha256=rCJ9F6aWPh8tVQBIPmcq2lKDsfwiJQduwSUku_8kXfs,3952
150
153
  sycommon/agent/sandbox/__init__.py,sha256=jR7LlkD4J4Y6QYyRXQClkwmqDBCCPmycV_hQV9p9YHw,4621
151
154
  sycommon/agent/sandbox/consistent_hash.py,sha256=8Jgk-W4NAD2-u5_vKRVlPmql_e0Vy4aNORiXOuzvIrs,7277
152
155
  sycommon/agent/sandbox/file_ops.py,sha256=7A-T5dV2o5OzPT2kaNrlOTAqmyQ8HGiHV7ifqqMivZA,32988
153
- sycommon/agent/sandbox/http_sandbox_backend.py,sha256=EGRxHojLXGRrkjAqYmjbQnMvq1sr0CBKvCmVjUQUkKU,64774
156
+ sycommon/agent/sandbox/http_sandbox_backend.py,sha256=0uQD4juPqUGJ_lQf0aTCgBn81GtansiuaCwGgsLZ7zc,68230
154
157
  sycommon/agent/sandbox/minio_sync.py,sha256=vGpc2rwfG86q8D_1eAxg_0X1n_6IfYxwQF3waQPgopY,22413
155
158
  sycommon/agent/sandbox/sandbox_pool.py,sha256=eMn8sLakCWf90l6ni2-333QM8oBdX1CflV-WzneFp_k,9133
156
159
  sycommon/agent/sandbox/sandbox_recovery.py,sha256=X-eDODx1tmGMh_iTngV6e1ppfDBHpTdkPreJusN5MHY,7358
@@ -162,7 +165,7 @@ sycommon/auth/oa_crypto.py,sha256=xpY1R1Bj3KLENXB0TuThB6Eku1E9PYjcoSpOdgDmCgc,18
162
165
  sycommon/auth/oa_service.py,sha256=kLepV9zgqpZoaB73DRPpMA5tJJQjoaDtQPdzBcGXeak,6235
163
166
  sycommon/auth/wecom_ldap_service.py,sha256=5DWXu-mR3ckZm2lkCJu7gLi_3GCiLNZNpK3bFCPcQAc,31122
164
167
  sycommon/config/ACPAgentConfig.py,sha256=paAa0c0gy1RnDDum-Q6m7QLTe7lhz2dsFt4r72QtmWE,3202
165
- sycommon/config/Config.py,sha256=KsXl6BrZ3piIcxu7IVQWnrT91xEhducnGwk2eh0Uav4,7839
168
+ sycommon/config/Config.py,sha256=MP92XxB7hsQiOnyjMkZskbxuMn_OUAZ4RTbC74ZATl4,7885
166
169
  sycommon/config/DatabaseConfig.py,sha256=ILiUuYT9_xJZE2W-RYuC3JCt_YLKc1sbH13-MHIOPhg,804
167
170
  sycommon/config/ElasticsearchConfig.py,sha256=fO9ZPMgJxSg1-UyDJ90wO6UvYy-jscwPJsSkXgx9qTU,2308
168
171
  sycommon/config/EmbeddingConfig.py,sha256=gPKwiDYbeu1GpdIZXMmgqM7JqBIzCXi0yYuGRLZooMI,362
@@ -173,6 +176,7 @@ sycommon/config/PgConfig.py,sha256=Hs9LwgIxSBxcFP16oq18N6Gq9hU2qVl4-7bPfd-ON_s,2
173
176
  sycommon/config/RedisConfig.py,sha256=gIa4BS8L_HdmBg9Dkv3cuIK6CU9zt9RodZOJUuUlh5Y,5235
174
177
  sycommon/config/RerankerConfig.py,sha256=35sVwzus2IscvTHnCG63Orl2pC-pMsrVi6wAGDmOH3U,341
175
178
  sycommon/config/SentryConfig.py,sha256=OsLb3G9lTsCSZ7tWkcXWJHmvfILQopBxje5pjnkFJfo,320
179
+ sycommon/config/SkillApiWhitelistConfig.py,sha256=ovxpBIY5rlafniydQ1UR4xYdaS3wfkk5ju_mVJAj4Y4,3764
176
180
  sycommon/config/XxlJobConfig.py,sha256=VSG6dn9ysfUVunOs7PqugyZUGJWmX_cEePz2ZCfqHtU,392
177
181
  sycommon/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
178
182
  sycommon/config/config_change_notifier.py,sha256=7qQe7vdBS1zohEfVq0d5IaDqYsLdgd2ckF4mKKgh0gE,6848
@@ -227,7 +231,7 @@ sycommon/middleware/exception.py,sha256=UAy0tKijI_2JoKjwT3h62aL-tybftP3IETvcr26N
227
231
  sycommon/middleware/middleware.py,sha256=1qA_0rT7pv1xTfuvA0kdYkxVOHYT6roSSNO7Cmf2wYg,1778
228
232
  sycommon/middleware/monitor_memory.py,sha256=pYRK-wRuDd6enSg9Pf8tQxPdYQS6S0AyjyXeKFRLKEs,628
229
233
  sycommon/middleware/mq.py,sha256=9X6KKtadFjBXKS5L3kEKujYio9wwGfWgXwWOAHO-HDg,254
230
- sycommon/middleware/sandbox.py,sha256=me-gbt9T2-RC-UPlEmCd84W-BbEZpGvqbs-iHcdw5JI,48193
234
+ sycommon/middleware/sandbox.py,sha256=_BjRqJgJDula_Vyg-ooifrqG9L3CaS7_KPLzvyW0JrY,49598
231
235
  sycommon/middleware/timeout.py,sha256=KlxOPa8xl2dg6yuRi_EzkVJG8bX4stb5ueYxctzzGM8,1433
232
236
  sycommon/middleware/token_tracking.py,sha256=rEbgV1bgWMdzAERx4aq5XAvOIT6jTY_tK1P0xHJnL3o,6609
233
237
  sycommon/middleware/tool_result_truncation.py,sha256=SREo6tb_RDHxqiQdO0N82pio-Cpo0YSrHNa1sASUafM,12429
@@ -301,8 +305,8 @@ sycommon/tools/syemail.py,sha256=BDFhgf7WDOQeTcjxJEQdu0dQhnHFPO_p3eI0-Ni3LhQ,561
301
305
  sycommon/tools/timing.py,sha256=OiiE7P07lRoMzX9kzb8sZU9cDb0zNnqIlY5pWqHcnkY,2064
302
306
  sycommon/xxljob/__init__.py,sha256=7eoBlQxv-B39IfRSCY2bkqdGYs1QRe1umAWd88VMEEM,86
303
307
  sycommon/xxljob/xxljob_service.py,sha256=1yifwIBNGsCIxLnQjHKiBlbsigc_zvPH-dMTZcNxe-Q,7649
304
- sycommon_python_lib-0.2.7a4.dist-info/METADATA,sha256=uZ0e_w_up1416YQu001gfJPQu6CJMVseAeBRddj8Yck,7960
305
- sycommon_python_lib-0.2.7a4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
306
- sycommon_python_lib-0.2.7a4.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
307
- sycommon_python_lib-0.2.7a4.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
308
- sycommon_python_lib-0.2.7a4.dist-info/RECORD,,
308
+ sycommon_python_lib-0.2.7a5.dist-info/METADATA,sha256=dC6l-dxbcElpxqKBFZVmPTNYxSDYWmoAJFaVFCS2m74,7960
309
+ sycommon_python_lib-0.2.7a5.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
310
+ sycommon_python_lib-0.2.7a5.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
311
+ sycommon_python_lib-0.2.7a5.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
312
+ sycommon_python_lib-0.2.7a5.dist-info/RECORD,,