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.
- sycommon/agent/deep_agent.py +87 -61
- sycommon/agent/middleware/sitecustomize.py +146 -0
- sycommon/agent/middleware/skill_api_whitelist.py +253 -0
- sycommon/agent/middleware/skill_wl_check.py +123 -0
- sycommon/agent/sandbox/http_sandbox_backend.py +77 -17
- sycommon/config/Config.py +2 -1
- sycommon/config/SkillApiWhitelistConfig.py +82 -0
- sycommon/middleware/sandbox.py +31 -5
- {sycommon_python_lib-0.2.7a4.dist-info → sycommon_python_lib-0.2.7a5.dist-info}/METADATA +1 -1
- {sycommon_python_lib-0.2.7a4.dist-info → sycommon_python_lib-0.2.7a5.dist-info}/RECORD +13 -9
- {sycommon_python_lib-0.2.7a4.dist-info → sycommon_python_lib-0.2.7a5.dist-info}/WHEEL +0 -0
- {sycommon_python_lib-0.2.7a4.dist-info → sycommon_python_lib-0.2.7a5.dist-info}/entry_points.txt +0 -0
- {sycommon_python_lib-0.2.7a4.dist-info → sycommon_python_lib-0.2.7a5.dist-info}/top_level.txt +0 -0
sycommon/agent/deep_agent.py
CHANGED
|
@@ -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
|
-
|
|
469
|
-
|
|
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
|
-
# 🔑
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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]
|
|
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]
|
|
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
|
-
# =====
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
657
|
-
|
|
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
|
-
|
|
661
|
-
|
|
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
|
-
|
|
903
|
-
|
|
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
|
-
|
|
1004
|
-
|
|
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
|
-
|
|
1014
|
-
|
|
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]
|
|
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] 请求失败 (
|
|
522
|
-
|
|
555
|
+
f"[Sandbox] 请求失败 (原地重试 {attempt + 1}/{max_retries}): {self._base_url} - {e}")
|
|
523
556
|
if attempt < max_retries - 1:
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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 /
|
|
628
|
+
策略(修复「两实例无限横跳」+「服务端 500 白等 3 次」):
|
|
629
|
+
- 瞬时错误(连接 reset / timeout / ClientConnectionError)→ 原地指数退避重试,不切实例。
|
|
577
630
|
切实例会触发 MinIO 恢复(耗时且会污染新沙箱),瞬时抖动不该付出这个代价。
|
|
578
|
-
-
|
|
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]
|
|
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()
|
sycommon/middleware/sandbox.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
305
|
-
sycommon_python_lib-0.2.
|
|
306
|
-
sycommon_python_lib-0.2.
|
|
307
|
-
sycommon_python_lib-0.2.
|
|
308
|
-
sycommon_python_lib-0.2.
|
|
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,,
|
|
File without changes
|
{sycommon_python_lib-0.2.7a4.dist-info → sycommon_python_lib-0.2.7a5.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{sycommon_python_lib-0.2.7a4.dist-info → sycommon_python_lib-0.2.7a5.dist-info}/top_level.txt
RENAMED
|
File without changes
|