sycommon-python-lib 0.2.7a22__py3-none-any.whl → 0.2.7a23__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/middleware/skill_api_whitelist.py +149 -23
- sycommon/agent/sandbox/file_ops.py +61 -27
- sycommon/agent/sandbox/http_sandbox_backend.py +11 -6
- sycommon/middleware/sandbox.py +166 -59
- {sycommon_python_lib-0.2.7a22.dist-info → sycommon_python_lib-0.2.7a23.dist-info}/METADATA +1 -1
- {sycommon_python_lib-0.2.7a22.dist-info → sycommon_python_lib-0.2.7a23.dist-info}/RECORD +9 -9
- {sycommon_python_lib-0.2.7a22.dist-info → sycommon_python_lib-0.2.7a23.dist-info}/WHEEL +0 -0
- {sycommon_python_lib-0.2.7a22.dist-info → sycommon_python_lib-0.2.7a23.dist-info}/entry_points.txt +0 -0
- {sycommon_python_lib-0.2.7a22.dist-info → sycommon_python_lib-0.2.7a23.dist-info}/top_level.txt +0 -0
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
allowlist:[] 空数组 → 注入空白名单(全拦截)。
|
|
20
20
|
"""
|
|
21
21
|
|
|
22
|
+
import base64
|
|
23
|
+
import hashlib
|
|
22
24
|
import json
|
|
23
25
|
import os
|
|
24
26
|
import re
|
|
@@ -26,6 +28,7 @@ import shlex
|
|
|
26
28
|
from typing import Optional
|
|
27
29
|
|
|
28
30
|
from langchain.agents.middleware.types import AgentMiddleware
|
|
31
|
+
from langchain_core.messages import ToolMessage
|
|
29
32
|
from langgraph.prebuilt.tool_node import ToolCallRequest
|
|
30
33
|
|
|
31
34
|
from sycommon.logging.kafka_log import SYLogger
|
|
@@ -41,14 +44,94 @@ _SKILL_TOP_RE = re.compile(
|
|
|
41
44
|
# BASE_URL 占位符 ${VAR}
|
|
42
45
|
_PLACEHOLDER_RE = re.compile(r'\$\{([A-Z_][A-Z0-9_]*)\}')
|
|
43
46
|
|
|
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
47
|
# workspace 内白名单目录的 shell 形式(init_script 已 export $SANDBOX_ROOT,运行期展开)
|
|
51
48
|
_WL_DIR = "$SANDBOX_ROOT/.wl"
|
|
49
|
+
# .wl 目录在 workspace 内的相对路径段(用于 hash 校验命令里的文件名)
|
|
50
|
+
_WL_FILES = [
|
|
51
|
+
".wl/sitecustomize.py", # 极小 loader(明文,无业务逻辑)
|
|
52
|
+
".wl/sitecustomize.payload", # sitecustomize.py 源码的 base64
|
|
53
|
+
".wl/skill_wl_check.payload", # skill_wl_check.py 源码的 base64
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
# ---- 极小 loader(落盘为 .wl/sitecustomize.py)----
|
|
57
|
+
# 不含任何白名单/拦截业务逻辑,只做"解码同目录 payload + exec"。
|
|
58
|
+
# 关键:把 skill_wl_check 注册成真模块对象进 sys.modules,使 sitecustomize.payload
|
|
59
|
+
# 里的 `import skill_wl_check; skill_wl_check.match(...)` 按原逻辑工作(逻辑不变)。
|
|
60
|
+
_LOADER_SRC = b'''# sycommon injected loader: decode payloads, exec them. No business logic.
|
|
61
|
+
import os, sys, base64, types
|
|
62
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
63
|
+
if _HERE not in sys.path:
|
|
64
|
+
sys.path.insert(0, _HERE)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _run(name, ns):
|
|
68
|
+
p = os.path.join(_HERE, name)
|
|
69
|
+
try:
|
|
70
|
+
with open(p, "rb") as f:
|
|
71
|
+
exec(compile(base64.b64decode(f.read()), name, "exec"), ns)
|
|
72
|
+
except Exception as _e:
|
|
73
|
+
sys.stderr.write("[.wl loader] load %s failed: %s\\n" % (name, _e))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_wl = types.ModuleType("skill_wl_check")
|
|
77
|
+
sys.modules["skill_wl_check"] = _wl
|
|
78
|
+
_run("skill_wl_check.payload", _wl.__dict__)
|
|
79
|
+
_run("sitecustomize.payload", globals())
|
|
80
|
+
'''
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _build_payload(src_name: str) -> bytes:
|
|
84
|
+
"""读包内源码并 base64 编码,作为 .payload 落盘内容(防明文窥探)。"""
|
|
85
|
+
raw = _load_resource_bytes(src_name)
|
|
86
|
+
return base64.b64encode(raw)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _expected_hashes() -> dict:
|
|
90
|
+
"""运行时从主进程源码算各 .wl 文件的预期 sha256(hex)。
|
|
91
|
+
|
|
92
|
+
payload 的预期 hash 算的是【编码后的 base64 字节】——即沙箱里实际落盘内容,
|
|
93
|
+
这样 sha256 比对的就是真实文件内容。loader 的预期 hash 算 _LOADER_SRC(固定常量)。
|
|
94
|
+
"""
|
|
95
|
+
loader_bytes = _LOADER_SRC
|
|
96
|
+
sc_bytes = _build_payload("sitecustomize.py")
|
|
97
|
+
wl_bytes = _build_payload("skill_wl_check.py")
|
|
98
|
+
return {
|
|
99
|
+
".wl/sitecustomize.py": hashlib.sha256(loader_bytes).hexdigest(),
|
|
100
|
+
".wl/sitecustomize.payload": hashlib.sha256(sc_bytes).hexdigest(),
|
|
101
|
+
".wl/skill_wl_check.payload": hashlib.sha256(wl_bytes).hexdigest(),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---- .wl 写拦截(复刻 skill_write_guard 的正则集合)----
|
|
106
|
+
# .wl 目录保护:精确匹配 .wl 或 .wl/...(带边界,防误伤 myapp.wl 之类)
|
|
107
|
+
_WL_PROTECTED_RE = re.compile(r'\.wl(?:/|\s|"|\'|$)')
|
|
108
|
+
# 写动作动词(命令首或分号/管道/空格后)
|
|
109
|
+
_WRITE_VERB_RE = re.compile(
|
|
110
|
+
r'(^|[\s;&|])(rm|rmdir|mv|cp|chmod|chown|tee|truncate|install|rsync|scp|dd|>\.wl)\b')
|
|
111
|
+
# 输出重定向 > 或 >>
|
|
112
|
+
_REDIRECT_RE = re.compile(r'>>?')
|
|
113
|
+
_WL_DENIED_MSG = "Error: .wl 为系统注入目录,禁止写入/修改/删除。"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _command_writes_wl(cmd: str) -> bool:
|
|
117
|
+
"""execute 命令是否在写 .wl:命中 .wl 路径 且 含写动词或重定向。"""
|
|
118
|
+
if not _WL_PROTECTED_RE.search(cmd or ""):
|
|
119
|
+
return False
|
|
120
|
+
return bool(_WRITE_VERB_RE.search(cmd) or _REDIRECT_RE.search(cmd))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _is_wl_path(p: str) -> bool:
|
|
124
|
+
"""file_path 是否落在 .wl 目录内(write_file/edit_file 拦截用)。
|
|
125
|
+
|
|
126
|
+
注意不能用 lstrip("./")——它是按字符集 strip,会把 ".wl" 的前导 "." 也吃成 "wl"。
|
|
127
|
+
改为逐前缀剥离再判。
|
|
128
|
+
"""
|
|
129
|
+
s = (p or "").strip().replace("\\", "/")
|
|
130
|
+
# 剥前导 ./ 和 /
|
|
131
|
+
while s.startswith("./") or s.startswith("/"):
|
|
132
|
+
s = s[1:] if s.startswith("/") else s[2:]
|
|
133
|
+
return s == ".wl" or s.startswith(".wl/")
|
|
134
|
+
|
|
52
135
|
|
|
53
136
|
|
|
54
137
|
def _resolve_placeholders(pattern: str, env_lookup) -> Optional[str]:
|
|
@@ -120,7 +203,8 @@ class SkillApiWhitelistMiddleware(AgentMiddleware):
|
|
|
120
203
|
|
|
121
204
|
def __init__(self, sandbox_backend):
|
|
122
205
|
self._backend = sandbox_backend
|
|
123
|
-
|
|
206
|
+
# 注:不再缓存"已推送"标志。用户可在会话中删除 .wl/ 绕过白名单,
|
|
207
|
+
# 故改为每次 execute 前校验文件完整性、缺则重推(见 _ensure_sitecustomize)。
|
|
124
208
|
|
|
125
209
|
def _env_lookup(self):
|
|
126
210
|
"""构造占位符查找器:先查主进程 os.environ,再查 sandbox env 字典。
|
|
@@ -155,27 +239,54 @@ class SkillApiWhitelistMiddleware(AgentMiddleware):
|
|
|
155
239
|
|
|
156
240
|
# ---- 懒加载:把注入脚本推进沙箱 workspace/.wl ----
|
|
157
241
|
async def _ensure_sitecustomize(self) -> bool:
|
|
158
|
-
"""把
|
|
242
|
+
"""把 loader + 2 个 base64 payload 推到沙箱 workspace 内的 .wl 目录。
|
|
159
243
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
幂等:每个 backend 实例只推一次(_pushed 标记)。失败 fail-open(不阻塞业务)。
|
|
244
|
+
防篡改 + 自愈:每次 execute 前对 3 个文件算 sha256,与主进程源码算出的
|
|
245
|
+
预期 hash 比对。任一文件缺失/被改 → 全量重推 + chmod 555。
|
|
246
|
+
旧的 test -f 存在性校验挡不住「用户把 sitecustomize 改成空文件」,
|
|
247
|
+
sha256 内容校验才能挡。失败 fail-open(不阻塞业务)。
|
|
165
248
|
"""
|
|
166
|
-
if self._pushed:
|
|
167
|
-
return True
|
|
168
249
|
backend = self._backend
|
|
169
250
|
if backend is None:
|
|
170
251
|
return False
|
|
171
252
|
try:
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
253
|
+
expected = _expected_hashes()
|
|
254
|
+
# 一条命令取三个文件的 sha256(缺失则该行输出 MISSING),换行拼接返回。
|
|
255
|
+
files_quoted = " ".join(
|
|
256
|
+
shlex.quote(f) for f in _WL_FILES)
|
|
257
|
+
check_cmd = (
|
|
258
|
+
f'for f in {files_quoted}; do '
|
|
259
|
+
f'if [ -f "$SANDBOX_ROOT/$f" ]; then '
|
|
260
|
+
f'sha256sum "$SANDBOX_ROOT/$f" 2>/dev/null | cut -d" " -f1; '
|
|
261
|
+
f'else echo MISSING; fi; done'
|
|
262
|
+
)
|
|
263
|
+
try:
|
|
264
|
+
resp = await backend.aexecute(check_cmd)
|
|
265
|
+
actual_lines = (getattr(resp, "output", "") or "").strip().splitlines()
|
|
266
|
+
except Exception:
|
|
267
|
+
actual_lines = []
|
|
268
|
+
|
|
269
|
+
# 逐文件比对:长度/顺序与 _WL_FILES 对应
|
|
270
|
+
intact = (len(actual_lines) == len(_WL_FILES)
|
|
271
|
+
and all(actual_lines[i] == expected[_WL_FILES[i]]
|
|
272
|
+
for i in range(len(_WL_FILES))))
|
|
273
|
+
|
|
274
|
+
if intact:
|
|
275
|
+
return True # 内容完整,无需重推
|
|
276
|
+
|
|
277
|
+
# 重推:loader 明文 + 2 个 payload
|
|
278
|
+
files = [
|
|
279
|
+
(".wl/sitecustomize.py", _LOADER_SRC),
|
|
280
|
+
(".wl/sitecustomize.payload", _build_payload("sitecustomize.py")),
|
|
281
|
+
(".wl/skill_wl_check.payload", _build_payload("skill_wl_check.py")),
|
|
282
|
+
]
|
|
176
283
|
await backend.aupload_files(files)
|
|
177
|
-
|
|
178
|
-
|
|
284
|
+
# chmod -R 555:挡普通用户改/删(root 仍可绕,靠 sha256 自愈兜底)
|
|
285
|
+
try:
|
|
286
|
+
await backend.aexecute(f'chmod -R 555 "{_WL_DIR}"')
|
|
287
|
+
except Exception as _ce:
|
|
288
|
+
SYLogger.warning("[Whitelist] chmod .wl 555 失败(非致命): %s", _ce)
|
|
289
|
+
SYLogger.info("[Whitelist] 注入脚本已(重新)推送至沙箱 %s (sha256 校验触发)", _WL_DIR)
|
|
179
290
|
return True
|
|
180
291
|
except Exception as e:
|
|
181
292
|
SYLogger.error("[Whitelist] 推送 sitecustomize 失败(fail-open): %s", e)
|
|
@@ -195,10 +306,25 @@ class SkillApiWhitelistMiddleware(AgentMiddleware):
|
|
|
195
306
|
handler,
|
|
196
307
|
):
|
|
197
308
|
tc = request.tool_call
|
|
198
|
-
|
|
309
|
+
name = tc.get("name")
|
|
310
|
+
args = tc.get("args", {}) or {}
|
|
311
|
+
|
|
312
|
+
# ---- .wl 写拦截(短路 fail-closed,在任何业务逻辑之前)----
|
|
313
|
+
# 防 agent 经 write_file/edit_file/execute 篡改注入目录。参考 SkillWriteGuard。
|
|
314
|
+
if name in ("write_file", "edit_file"):
|
|
315
|
+
fp = args.get("file_path") if isinstance(args, dict) else ""
|
|
316
|
+
if _is_wl_path(fp):
|
|
317
|
+
return ToolMessage(content=_WL_DENIED_MSG, name=name,
|
|
318
|
+
tool_call_id=tc.get("id", ""), status="error")
|
|
319
|
+
elif name == "execute":
|
|
320
|
+
command = args.get("command", "") if isinstance(args, dict) else ""
|
|
321
|
+
if _command_writes_wl(command):
|
|
322
|
+
return ToolMessage(content=_WL_DENIED_MSG, name="execute",
|
|
323
|
+
tool_call_id=tc.get("id", ""), status="error")
|
|
324
|
+
|
|
325
|
+
if name != "execute":
|
|
199
326
|
return await handler(request)
|
|
200
327
|
|
|
201
|
-
args = tc.get("args", {}) or {}
|
|
202
328
|
command = args.get("command", "") if isinstance(args, dict) else ""
|
|
203
329
|
if not command:
|
|
204
330
|
return await handler(request)
|
|
@@ -54,6 +54,25 @@ _EDIT_ERROR_CODE_MAP = {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
# 服务端 /ls 错误码 -> 人类可读文案(对齐 BaseSandbox._parse_ls_output 的
|
|
58
|
+
# path_not_found / not_a_directory 语义)。未在表内的码原样透传。
|
|
59
|
+
_LS_ERROR_MAP = {
|
|
60
|
+
"path_not_found": "path not found",
|
|
61
|
+
"not_a_directory": "not a directory",
|
|
62
|
+
"permission_denied": "permission denied",
|
|
63
|
+
"list_failed": "listing failed",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# 服务端 /delete 错误码 -> 人类可读文案(统一前缀 "Failed to delete '<path>': ...",
|
|
68
|
+
# 与 write 的 "Failed to write file '<path>': ..." 风格一致)。
|
|
69
|
+
_DELETE_ERROR_MAP = {
|
|
70
|
+
"file_not_found": "file not found",
|
|
71
|
+
"is_directory": "is a directory, set recursive=true",
|
|
72
|
+
"permission_denied": "permission denied",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
57
76
|
def _normalize_edit_error(error: str, file_path: str, old_string: str) -> str:
|
|
58
77
|
"""把服务端的自由格式错误串映射成与 BaseSandbox 一致的可读消息。
|
|
59
78
|
|
|
@@ -282,7 +301,14 @@ class FileOperationsMixin:
|
|
|
282
301
|
"path": path,
|
|
283
302
|
"user_id": self.user_id
|
|
284
303
|
})
|
|
285
|
-
#
|
|
304
|
+
# 服务端路径不存在/非目录时返回 {"error": "path_not_found"/"not_a_directory"/...}
|
|
305
|
+
# 对齐 BaseSandbox._parse_ls_output,映射成 "Path '<path>': <reason>",
|
|
306
|
+
# 而非当成空目录成功(否则 agent 会误判路径存在但无文件)。
|
|
307
|
+
if isinstance(result, dict) and result.get("error"):
|
|
308
|
+
err = result["error"]
|
|
309
|
+
# 归一化错误码为人类可读文案(对齐 BaseSandbox 的 path_not_found/not_a_directory 语义)
|
|
310
|
+
err_msg = _LS_ERROR_MAP.get(err, err)
|
|
311
|
+
return LsResult(entries=None, error=f"Path '{path}': {err_msg}")
|
|
286
312
|
entries = [FileInfo(**item) for item in (result or [])]
|
|
287
313
|
SYLogger.info(f"[Sandbox] 目录内容: {len(entries)} 项")
|
|
288
314
|
return LsResult(entries=entries)
|
|
@@ -300,6 +326,10 @@ class FileOperationsMixin:
|
|
|
300
326
|
"path": path,
|
|
301
327
|
"user_id": self.user_id
|
|
302
328
|
}, timeout=timeout)
|
|
329
|
+
if isinstance(result, dict) and result.get("error"):
|
|
330
|
+
err = result["error"]
|
|
331
|
+
err_msg = _LS_ERROR_MAP.get(err, err)
|
|
332
|
+
return LsResult(entries=None, error=f"Path '{path}': {err_msg}")
|
|
303
333
|
entries = [FileInfo(**item) for item in (result or [])]
|
|
304
334
|
SYLogger.info(f"[Sandbox] 目录内容: {len(entries)} 项")
|
|
305
335
|
return LsResult(entries=entries)
|
|
@@ -606,7 +636,10 @@ class FileOperationsMixin:
|
|
|
606
636
|
# ============== 树形目录 ==============
|
|
607
637
|
|
|
608
638
|
def tree(self: "HTTPSandboxBackend", path: str = "/", max_depth: int = 30, max_files: int = 0) -> TreeResult:
|
|
609
|
-
"""以树形结构展示目录
|
|
639
|
+
"""以树形结构展示目录
|
|
640
|
+
|
|
641
|
+
错误前缀对齐 read/ls/grep/glob:统一包装为 "Path '<path>': <reason>"。
|
|
642
|
+
"""
|
|
610
643
|
try:
|
|
611
644
|
self._lazy_sync_skill_from_path_sync(path)
|
|
612
645
|
self._ensure_synced_sync()
|
|
@@ -618,13 +651,13 @@ class FileOperationsMixin:
|
|
|
618
651
|
"max_files": max_files,
|
|
619
652
|
})
|
|
620
653
|
if result.get("error"):
|
|
621
|
-
return TreeResult(error=result[
|
|
654
|
+
return TreeResult(error=f"Path '{path}': {result['error']}")
|
|
622
655
|
tree_node = TreeNode(
|
|
623
656
|
**result["tree"]) if result.get("tree") else None
|
|
624
657
|
return TreeResult(tree=tree_node)
|
|
625
658
|
except Exception as e:
|
|
626
659
|
SYLogger.error(f"[Sandbox] tree 失败: {e}")
|
|
627
|
-
return TreeResult(error=
|
|
660
|
+
return TreeResult(error=f"Path '{path}': {e}")
|
|
628
661
|
|
|
629
662
|
async def atree(self: "HTTPSandboxBackend", path: str = "/", max_depth: int = 30, max_files: int = 0, *, timeout: int = None) -> TreeResult:
|
|
630
663
|
"""异步以树形结构展示目录"""
|
|
@@ -639,13 +672,13 @@ class FileOperationsMixin:
|
|
|
639
672
|
"max_files": max_files,
|
|
640
673
|
}, timeout=timeout)
|
|
641
674
|
if result.get("error"):
|
|
642
|
-
return TreeResult(error=result[
|
|
675
|
+
return TreeResult(error=f"Path '{path}': {result['error']}")
|
|
643
676
|
tree_node = TreeNode(
|
|
644
677
|
**result["tree"]) if result.get("tree") else None
|
|
645
678
|
return TreeResult(tree=tree_node)
|
|
646
679
|
except Exception as e:
|
|
647
680
|
SYLogger.error(f"[Sandbox] 异步 tree 失败: {e}")
|
|
648
|
-
return TreeResult(error=
|
|
681
|
+
return TreeResult(error=f"Path '{path}': {e}")
|
|
649
682
|
|
|
650
683
|
def grep(
|
|
651
684
|
self: "HTTPSandboxBackend",
|
|
@@ -745,7 +778,12 @@ class FileOperationsMixin:
|
|
|
745
778
|
path: str,
|
|
746
779
|
recursive: bool = False,
|
|
747
780
|
) -> DeleteResult:
|
|
748
|
-
"""删除文件或文件夹
|
|
781
|
+
"""删除文件或文件夹
|
|
782
|
+
|
|
783
|
+
错误文案对齐 write 的 "Failed to write file '<path>': ..." 风格,
|
|
784
|
+
统一为 "Failed to delete '<path>': <reason>",并把服务端错误码
|
|
785
|
+
归一化(file_not_found / is_directory / permission_denied)。
|
|
786
|
+
"""
|
|
749
787
|
try:
|
|
750
788
|
self._lazy_sync_skill_from_path_sync(path)
|
|
751
789
|
self._ensure_synced_sync()
|
|
@@ -755,18 +793,15 @@ class FileOperationsMixin:
|
|
|
755
793
|
"user_id": self.user_id,
|
|
756
794
|
"recursive": recursive,
|
|
757
795
|
})
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
else:
|
|
765
|
-
SYLogger.info(f"[Sandbox] 删除成功: {delete_result.path}")
|
|
766
|
-
return delete_result
|
|
796
|
+
err = result.get("error")
|
|
797
|
+
if err:
|
|
798
|
+
err_msg = _DELETE_ERROR_MAP.get(err, err)
|
|
799
|
+
return DeleteResult(path=path, error=f"Failed to delete '{path}': {err_msg}")
|
|
800
|
+
SYLogger.info(f"[Sandbox] 删除成功: {result.get('path', path)}")
|
|
801
|
+
return DeleteResult(path=result.get("path", path), error=None)
|
|
767
802
|
except Exception as e:
|
|
768
803
|
SYLogger.error(f"[Sandbox] 删除异常: {e}")
|
|
769
|
-
return DeleteResult(path=path, error=
|
|
804
|
+
return DeleteResult(path=path, error=f"Failed to delete '{path}': {e}")
|
|
770
805
|
|
|
771
806
|
async def adelete(
|
|
772
807
|
self: "HTTPSandboxBackend",
|
|
@@ -780,6 +815,8 @@ class FileOperationsMixin:
|
|
|
780
815
|
|
|
781
816
|
_skip_lazy_sync=True 时跳过懒同步(供内部技能覆盖删除用,
|
|
782
817
|
避免「删之前先把要删的技能上传一遍」的浪费)。
|
|
818
|
+
|
|
819
|
+
错误文案对齐 write/delete:统一 "Failed to delete '<path>': <reason>"。
|
|
783
820
|
"""
|
|
784
821
|
try:
|
|
785
822
|
if not _skip_lazy_sync:
|
|
@@ -791,18 +828,15 @@ class FileOperationsMixin:
|
|
|
791
828
|
"user_id": self.user_id,
|
|
792
829
|
"recursive": recursive,
|
|
793
830
|
}, timeout=timeout)
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
else:
|
|
801
|
-
SYLogger.info(f"[Sandbox] 异步删除成功: {delete_result.path}")
|
|
802
|
-
return delete_result
|
|
831
|
+
err = result.get("error")
|
|
832
|
+
if err:
|
|
833
|
+
err_msg = _DELETE_ERROR_MAP.get(err, err)
|
|
834
|
+
return DeleteResult(path=path, error=f"Failed to delete '{path}': {err_msg}")
|
|
835
|
+
SYLogger.info(f"[Sandbox] 异步删除成功: {result.get('path', path)}")
|
|
836
|
+
return DeleteResult(path=result.get("path", path), error=None)
|
|
803
837
|
except Exception as e:
|
|
804
838
|
SYLogger.error(f"[Sandbox] 异步删除异常: {e}")
|
|
805
|
-
return DeleteResult(path=path, error=
|
|
839
|
+
return DeleteResult(path=path, error=f"Failed to delete '{path}': {e}")
|
|
806
840
|
|
|
807
841
|
# ============== 文件存在检查 ==============
|
|
808
842
|
|
|
@@ -1020,10 +1020,13 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
|
|
|
1020
1020
|
"path": remote_path,
|
|
1021
1021
|
"user_id": self._user_id
|
|
1022
1022
|
}, timeout=timeout)
|
|
1023
|
-
|
|
1024
|
-
|
|
1023
|
+
# /ls 返回可能是 list[FileInfo](成功)或 {"error": ...}(路径不存在)。
|
|
1024
|
+
# 路径不存在时不视为错误——相当于无已有子目录可跳过,走全量同步。
|
|
1025
|
+
items = ls_result if isinstance(ls_result, list) else (ls_result or {}).get("files", [])
|
|
1026
|
+
if ls_result and not (isinstance(ls_result, dict) and ls_result.get("error")):
|
|
1027
|
+
for item in items:
|
|
1025
1028
|
if item.get("is_dir", False):
|
|
1026
|
-
existing_subdirs.add(item.get("name", ""))
|
|
1029
|
+
existing_subdirs.add(item.get("name", item.get("path", "").rstrip("/").split("/")[-1]))
|
|
1027
1030
|
if existing_subdirs:
|
|
1028
1031
|
SYLogger.info(
|
|
1029
1032
|
f"[Sandbox] partial 模式,跳过已有子目录: {existing_subdirs}")
|
|
@@ -1398,10 +1401,12 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
|
|
|
1398
1401
|
"path": remote_path,
|
|
1399
1402
|
"user_id": self._user_id
|
|
1400
1403
|
})
|
|
1401
|
-
|
|
1402
|
-
|
|
1404
|
+
# /ls 成功返回 list[FileInfo],路径不存在返回 {"error":...}(不视为错误,走全量)。
|
|
1405
|
+
items = ls_result if isinstance(ls_result, list) else (ls_result or {}).get("files", [])
|
|
1406
|
+
if ls_result and not (isinstance(ls_result, dict) and ls_result.get("error")):
|
|
1407
|
+
for item in items:
|
|
1403
1408
|
if item.get("is_dir", False):
|
|
1404
|
-
existing_subdirs.add(item.get("name", ""))
|
|
1409
|
+
existing_subdirs.add(item.get("name", item.get("path", "").rstrip("/").split("/")[-1]))
|
|
1405
1410
|
if existing_subdirs:
|
|
1406
1411
|
SYLogger.info(
|
|
1407
1412
|
f"[Sandbox] partial 模式,跳过已有子目录: {existing_subdirs}")
|
sycommon/middleware/sandbox.py
CHANGED
|
@@ -103,6 +103,14 @@ async def _cleanup_finished_processes():
|
|
|
103
103
|
# ============== 常量配置 ==============
|
|
104
104
|
|
|
105
105
|
MAX_OUTPUT_BYTES = 100_000
|
|
106
|
+
|
|
107
|
+
# grep 墙钟超时(秒)。对齐 deepagents DEFAULT_GREP_TIMEOUT 的思路:
|
|
108
|
+
# 大目录/死循环 symlink 会让 grep 无限挂住,占满 FastAPI worker。
|
|
109
|
+
_GREP_TIMEOUT = 60
|
|
110
|
+
|
|
111
|
+
# read 文本路径服务端字节上限(对齐 deepagents _READ_COMMAND_TEMPLATE 的
|
|
112
|
+
# MAX_OUTPUT_BYTES=500KiB):超大文本文件不会把整包塞进 HTTP 响应/内存。
|
|
113
|
+
_READ_MAX_OUTPUT_BYTES = 500 * 1024
|
|
106
114
|
# 默认工作目录配置
|
|
107
115
|
# - Linux: /data/sycommon_sandbox (固定路径,自动创建)
|
|
108
116
|
# - macOS/Windows: 使用系统临时目录
|
|
@@ -399,15 +407,26 @@ def _build_execute_command(init_script: str, user_command: str) -> tuple[str, di
|
|
|
399
407
|
"""
|
|
400
408
|
if platform.system() == "Windows":
|
|
401
409
|
# Windows: PowerShell 执行
|
|
410
|
+
# 注: Windows 仅本地调试用,未做 stdin 重定向(沙箱生产跑 Linux)。
|
|
402
411
|
full_command = f'''{init_script}
|
|
403
412
|
{user_command}
|
|
404
413
|
'''
|
|
405
414
|
return full_command, {"shell": True}
|
|
406
415
|
else:
|
|
407
416
|
# Linux/macOS: bash heredoc
|
|
417
|
+
# 用户命令包进 `( ... ) </dev/null`: 对整个子 shell 的 stdin 重定向到
|
|
418
|
+
# /dev/null,等价于 subprocess 的 stdin=DEVNULL(对齐 deepagents
|
|
419
|
+
# LocalShellBackend)。作用: 防止读 stdin 的命令(裸 cat / python REPL /
|
|
420
|
+
# ssh / 脚本里的 input())在沙箱非交互环境下挂死等待输入——否则会一直
|
|
421
|
+
# 占用 worker 直到 req.timeout(可达 180s)才被超时杀掉。
|
|
422
|
+
# 之所以要 `( ... )` 包一层: 用户命令可能是复合命令/管道/heredoc,直接
|
|
423
|
+
# `cmd </dev/null` 只对最后一条生效;包进子 shell 后重定向对该子 shell 内
|
|
424
|
+
# 所有命令统一生效。经核查全部技能脚本无正常依赖 stdin 的(手动验证码/
|
|
425
|
+
# 本地报表工具在沙箱里要么有 isatty 守卫走文件方案、要么本就不会触发),
|
|
426
|
+
# DEVNULL 不影响现有功能。
|
|
408
427
|
full_command = f'''bash <<'COMMAND_EOF'
|
|
409
428
|
{init_script}
|
|
410
|
-
{user_command}
|
|
429
|
+
( {user_command} ) </dev/null
|
|
411
430
|
COMMAND_EOF
|
|
412
431
|
'''
|
|
413
432
|
return full_command, {}
|
|
@@ -517,8 +536,12 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
517
536
|
|
|
518
537
|
try:
|
|
519
538
|
# 使用 asyncio.create_subprocess_shell 异步执行命令,避免阻塞事件循环
|
|
539
|
+
# stdin=DEVNULL: 双重保险。命令内部已用 `( ... ) </dev/null` 把子 shell
|
|
540
|
+
# stdin 重定向(见 _build_execute_command),这里再在 subprocess 层 DEVNULL,
|
|
541
|
+
# 防止任何遗漏的读 stdin 命令挂住 worker。对齐 deepagents LocalShellBackend。
|
|
520
542
|
process = await asyncio.create_subprocess_shell(
|
|
521
543
|
full_command,
|
|
544
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
522
545
|
stdout=asyncio.subprocess.PIPE,
|
|
523
546
|
stderr=asyncio.subprocess.PIPE,
|
|
524
547
|
cwd=workspace,
|
|
@@ -584,7 +607,7 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
584
607
|
truncated=False
|
|
585
608
|
)
|
|
586
609
|
|
|
587
|
-
@sandbox_router.post("/ls"
|
|
610
|
+
@sandbox_router.post("/ls") # response_model 不固定:dict(error)/list[FileInfo]
|
|
588
611
|
async def ls_info(req: LsRequest):
|
|
589
612
|
"""列出目录内容
|
|
590
613
|
|
|
@@ -600,8 +623,10 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
600
623
|
|
|
601
624
|
def _ls():
|
|
602
625
|
workspace_resolved = os.path.realpath(workspace)
|
|
626
|
+
if not os.path.exists(target_path):
|
|
627
|
+
return None, "path_not_found"
|
|
603
628
|
if not os.path.isdir(target_path):
|
|
604
|
-
return None
|
|
629
|
+
return None, "not_a_directory"
|
|
605
630
|
results = []
|
|
606
631
|
try:
|
|
607
632
|
with os.scandir(target_path) as it:
|
|
@@ -626,12 +651,13 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
626
651
|
results.append(info)
|
|
627
652
|
except Exception as e:
|
|
628
653
|
SYLogger.error(f"[Sandbox Server] 列目录异常: {e}")
|
|
629
|
-
|
|
654
|
+
return None, f"list_failed: {e}"
|
|
655
|
+
return results, None
|
|
630
656
|
|
|
631
|
-
result = await asyncio.to_thread(_ls)
|
|
632
|
-
if
|
|
633
|
-
SYLogger.
|
|
634
|
-
return
|
|
657
|
+
result, err = await asyncio.to_thread(_ls)
|
|
658
|
+
if err:
|
|
659
|
+
SYLogger.info(f"[Sandbox Server] 列目录未命中: {target_path} ({err})")
|
|
660
|
+
return {"error": err}
|
|
635
661
|
SYLogger.info(f"[Sandbox Server] 目录内容: {len(result)} 项")
|
|
636
662
|
return result
|
|
637
663
|
|
|
@@ -704,14 +730,25 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
704
730
|
if is_text:
|
|
705
731
|
lines = []
|
|
706
732
|
lines_read = 0
|
|
733
|
+
# 服务端字节上限(对齐 deepagents _READ_COMMAND_TEMPLATE 的
|
|
734
|
+
# MAX_OUTPUT_BYTES):避免单响应塞入 GB 级文本(超长行/巨大文件),
|
|
735
|
+
# 触顶即截断并附提示,让客户端走 offset/limit 分页续读。
|
|
736
|
+
current_bytes = 0
|
|
737
|
+
truncated = False
|
|
707
738
|
with open(file_path, 'r', encoding='utf-8', newline=None) as f:
|
|
708
739
|
for i, line in enumerate(f):
|
|
709
740
|
if i < req.offset:
|
|
710
741
|
continue
|
|
711
742
|
if lines_read >= req.limit:
|
|
712
743
|
break
|
|
713
|
-
|
|
744
|
+
line_clean = line.rstrip('\n').rstrip('\r')
|
|
745
|
+
line_bytes = len(line_clean.encode('utf-8')) + 1 # +1 for \n
|
|
746
|
+
if current_bytes + line_bytes > _READ_MAX_OUTPUT_BYTES:
|
|
747
|
+
truncated = True
|
|
748
|
+
break
|
|
749
|
+
lines.append(line_clean)
|
|
714
750
|
lines_read += 1
|
|
751
|
+
current_bytes += line_bytes
|
|
715
752
|
|
|
716
753
|
if not lines:
|
|
717
754
|
if req.offset > 0:
|
|
@@ -721,6 +758,11 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
721
758
|
return ReadResponse(content="System reminder: File exists but has empty contents", error=None)
|
|
722
759
|
|
|
723
760
|
content = "\n".join(lines)
|
|
761
|
+
if truncated:
|
|
762
|
+
content += (
|
|
763
|
+
"\n\n[Output was truncated due to size limits. "
|
|
764
|
+
"Continue reading with a larger offset or smaller limit to inspect the rest of the file.]"
|
|
765
|
+
)
|
|
724
766
|
return ReadResponse(content=content, error=None, encoding="utf-8")
|
|
725
767
|
else:
|
|
726
768
|
MAX_BINARY_BYTES = 500 * 1024
|
|
@@ -772,8 +814,21 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
772
814
|
if parent:
|
|
773
815
|
os.makedirs(parent, exist_ok=True)
|
|
774
816
|
|
|
775
|
-
|
|
776
|
-
|
|
817
|
+
# O_NOFOLLOW 防 symlink 写劫持(对齐 deepagents filesystem.py:473-475):
|
|
818
|
+
# target 本身若是 symlink(即使 resolve 后仍在 workspace 内,跨用户
|
|
819
|
+
# 也可被植入),拒绝穿过它写。无 O_NOFOLLOW 平台退化为 open()。
|
|
820
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
821
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
822
|
+
flags |= os.O_NOFOLLOW
|
|
823
|
+
if overwrite:
|
|
824
|
+
flags |= getattr(os, "O_TRUNC", 0)
|
|
825
|
+
fd = os.open(file_path, flags, 0o644)
|
|
826
|
+
try:
|
|
827
|
+
with os.fdopen(fd, 'wb') as f:
|
|
828
|
+
f.write(content)
|
|
829
|
+
except Exception:
|
|
830
|
+
fd = -1 # fdopen 已接管 fd;这里仅防误关
|
|
831
|
+
raise
|
|
777
832
|
|
|
778
833
|
action = "覆盖" if (os.path.exists(file_path) and overwrite) else "新建"
|
|
779
834
|
SYLogger.info(
|
|
@@ -827,8 +882,16 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
827
882
|
SYLogger.error(f"[Sandbox Server] 编辑失败: 权限不足 {file_path}")
|
|
828
883
|
return EditResponse(error="permission_denied")
|
|
829
884
|
|
|
830
|
-
|
|
831
|
-
|
|
885
|
+
# 读取用 O_NOFOLLOW 防 symlink(对齐 deepagents filesystem.py:412):
|
|
886
|
+
# 若 file_path 是 symlink,O_NOFOLLOW 会直接报错,不穿过链接读目标。
|
|
887
|
+
read_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
888
|
+
fd = os.open(file_path, read_flags)
|
|
889
|
+
try:
|
|
890
|
+
with os.fdopen(fd, 'rb') as f:
|
|
891
|
+
raw = f.read()
|
|
892
|
+
except Exception:
|
|
893
|
+
fd = -1
|
|
894
|
+
raise
|
|
832
895
|
|
|
833
896
|
try:
|
|
834
897
|
content = raw.decode('utf-8')
|
|
@@ -868,8 +931,15 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
868
931
|
else:
|
|
869
932
|
new_content = content.replace(matched_old, matched_new, 1)
|
|
870
933
|
|
|
871
|
-
|
|
872
|
-
|
|
934
|
+
# O_NOFOLLOW 防 symlink 写劫持(对齐 deepagents filesystem.py:537-539)。
|
|
935
|
+
write_flags = os.O_WRONLY | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
|
|
936
|
+
fd = os.open(file_path, write_flags)
|
|
937
|
+
try:
|
|
938
|
+
with os.fdopen(fd, 'wb') as f:
|
|
939
|
+
f.write(new_content.encode('utf-8'))
|
|
940
|
+
except Exception:
|
|
941
|
+
fd = -1
|
|
942
|
+
raise
|
|
873
943
|
|
|
874
944
|
SYLogger.info(f"[Sandbox Server] 编辑成功: {file_path} (替换 {count} 处)")
|
|
875
945
|
virtual_path = _to_virtual_path(file_path, workspace)
|
|
@@ -895,8 +965,15 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
895
965
|
parent = os.path.dirname(file_path)
|
|
896
966
|
if parent:
|
|
897
967
|
os.makedirs(parent, exist_ok=True)
|
|
898
|
-
|
|
899
|
-
|
|
968
|
+
# O_NOFOLLOW 防 symlink 写劫持(对齐 deepagents filesystem.py:913-916)。
|
|
969
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
|
|
970
|
+
fd = os.open(file_path, flags, 0o644)
|
|
971
|
+
try:
|
|
972
|
+
with os.fdopen(fd, 'wb') as f:
|
|
973
|
+
f.write(content)
|
|
974
|
+
except Exception:
|
|
975
|
+
fd = -1
|
|
976
|
+
raise
|
|
900
977
|
return UploadResponse(path=file_path, error=None)
|
|
901
978
|
|
|
902
979
|
try:
|
|
@@ -956,8 +1033,15 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
956
1033
|
parent = os.path.dirname(file_path)
|
|
957
1034
|
if parent:
|
|
958
1035
|
os.makedirs(parent, exist_ok=True)
|
|
959
|
-
|
|
960
|
-
|
|
1036
|
+
# O_NOFOLLOW 防 symlink 写劫持(对齐 deepagents filesystem.py)。
|
|
1037
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
|
|
1038
|
+
fd = os.open(file_path, flags, 0o644)
|
|
1039
|
+
try:
|
|
1040
|
+
with os.fdopen(fd, 'wb') as f:
|
|
1041
|
+
f.write(content)
|
|
1042
|
+
except Exception:
|
|
1043
|
+
fd = -1
|
|
1044
|
+
raise
|
|
961
1045
|
results.append(BatchUploadResult(path=file_entry.path))
|
|
962
1046
|
success_count += 1
|
|
963
1047
|
except Exception as e:
|
|
@@ -1137,7 +1221,9 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
1137
1221
|
return GrepResponse(error=str(e))
|
|
1138
1222
|
|
|
1139
1223
|
quoted_path = shlex.quote(search_path)
|
|
1140
|
-
|
|
1224
|
+
# -Z:文件名与行数据之间用 NUL 分隔(对齐 deepagents sandbox.py:783 的 grep -rHnFZ),
|
|
1225
|
+
# 文件名含 ":" 时也能无歧义解析;旧实现按冒号切,遇到含冒号文件名会静默错路径/错行号。
|
|
1226
|
+
grep_opts = "-rHnFZ"
|
|
1141
1227
|
|
|
1142
1228
|
# 安全处理 glob_pattern,使用 shlex.quote 防止命令注入
|
|
1143
1229
|
glob_pattern = f"--include={shlex.quote(req.glob)}" if req.glob else ""
|
|
@@ -1149,11 +1235,23 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
1149
1235
|
# 使用 asyncio.create_subprocess_shell 异步执行
|
|
1150
1236
|
proc = await asyncio.create_subprocess_shell(
|
|
1151
1237
|
cmd,
|
|
1238
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
1152
1239
|
stdout=asyncio.subprocess.PIPE,
|
|
1153
1240
|
stderr=asyncio.subprocess.PIPE,
|
|
1154
1241
|
start_new_session=_SUBPROCESS_NEW_SESSION,
|
|
1155
1242
|
)
|
|
1156
|
-
|
|
1243
|
+
# 墙钟超时(对齐 deepagents DEFAULT_GREP_TIMEOUT 思路):大目录/死循环 symlink
|
|
1244
|
+
# 会让 grep 无限挂住,进而占满 FastAPI worker。超时即杀进程组,返回 partial_error。
|
|
1245
|
+
try:
|
|
1246
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=_GREP_TIMEOUT)
|
|
1247
|
+
except asyncio.TimeoutError:
|
|
1248
|
+
await _kill_process_tree(proc)
|
|
1249
|
+
SYLogger.warning(
|
|
1250
|
+
f"[Sandbox Server] grep 超时({ _GREP_TIMEOUT}s),已终止: pattern={req.pattern!r}, path={req.path!r}")
|
|
1251
|
+
return GrepResponse(
|
|
1252
|
+
matches=[],
|
|
1253
|
+
partial_error=f"grep timed out after {_GREP_TIMEOUT}s. Try a more specific pattern or a narrower path.",
|
|
1254
|
+
)
|
|
1157
1255
|
result_text = stdout.decode('utf-8') if stdout else ''
|
|
1158
1256
|
stderr_text = stderr.decode('utf-8', errors='replace') if stderr else ''
|
|
1159
1257
|
|
|
@@ -1169,44 +1267,53 @@ def setup_sandbox_handler(app: FastAPI, config: dict = None):
|
|
|
1169
1267
|
def _parse_grep_results():
|
|
1170
1268
|
workspace_resolved = os.path.realpath(workspace)
|
|
1171
1269
|
matches = []
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1270
|
+
# grep -Z 输出每条匹配:<path>\0<lineno>:<text>\n,连续拼接。
|
|
1271
|
+
# 按 NUL split 后:
|
|
1272
|
+
# seg[0] = 第一个 path
|
|
1273
|
+
# seg[i>0] = "<lineno>:<text>\n<next_path>"(数据 + 紧跟的下一个 path)
|
|
1274
|
+
# 最后一段 = "<lineno>:<text>\n"(无后续 path)
|
|
1275
|
+
# 用「每段按第一个 \n 切:前半是 lineno:text,后半是 next_path」解析,
|
|
1276
|
+
# 这样文件名含 ":"、文本含 ":" 都不会错位(path 整段不按冒号切)。
|
|
1277
|
+
segments = result_text.split("\0")
|
|
1278
|
+
if not segments:
|
|
1279
|
+
return matches
|
|
1280
|
+
current_path = segments[0].rstrip("\n")
|
|
1281
|
+
for seg in segments[1:]:
|
|
1282
|
+
nl = seg.find("\n")
|
|
1283
|
+
if nl == -1:
|
|
1284
|
+
data_part, next_path = seg, None
|
|
1285
|
+
else:
|
|
1286
|
+
data_part = seg[:nl]
|
|
1287
|
+
rest = seg[nl + 1:].rstrip("\n")
|
|
1288
|
+
next_path = rest or None
|
|
1289
|
+
# data_part = "lineno:text",按第一个冒号切(文本可含冒号)
|
|
1290
|
+
colon = data_part.find(":")
|
|
1291
|
+
if colon > 0 and data_part[:colon].isdigit():
|
|
1292
|
+
line_num = int(data_part[:colon])
|
|
1293
|
+
text_part = data_part[colon + 1:]
|
|
1294
|
+
if current_path:
|
|
1295
|
+
path_part = current_path
|
|
1296
|
+
# 路径安全检查(对齐 deepagents 0.6.4):
|
|
1297
|
+
# resolve 后必须在 workspace 内,跳过 symlink 逃逸的结果
|
|
1298
|
+
try:
|
|
1299
|
+
resolved = os.path.realpath(path_part)
|
|
1300
|
+
if not _is_path_within(resolved, workspace_resolved):
|
|
1301
|
+
SYLogger.warning(f"[Sandbox Server] grep 跳过逃逸路径: {path_part}")
|
|
1302
|
+
continue
|
|
1303
|
+
except OSError:
|
|
1304
|
+
continue
|
|
1305
|
+
# 将绝对路径转换为虚拟路径
|
|
1306
|
+
try:
|
|
1307
|
+
virtual_path_part = _to_virtual_path(path_part, workspace)
|
|
1308
|
+
except ValueError:
|
|
1309
|
+
virtual_path_part = path_part
|
|
1310
|
+
matches.append(GrepMatch(
|
|
1311
|
+
path=virtual_path_part,
|
|
1312
|
+
line=line_num,
|
|
1313
|
+
text=text_part
|
|
1314
|
+
))
|
|
1315
|
+
if next_path:
|
|
1316
|
+
current_path = next_path
|
|
1210
1317
|
return matches
|
|
1211
1318
|
|
|
1212
1319
|
matches = await asyncio.to_thread(_parse_grep_results)
|
|
@@ -150,13 +150,13 @@ sycommon/agent/mcp/tool_loader.py,sha256=OQa-lE7RL2s3Lqh-kNOKaUrb130hvorB_HjyFBq
|
|
|
150
150
|
sycommon/agent/middleware/sandbox_path_guard.py,sha256=K1SnxA8pDxkCSucOkranwUvC9_8rd3l_-TIR8gMMTK8,8102
|
|
151
151
|
sycommon/agent/middleware/sensitive_guard.py,sha256=ApmC094ogG4jIwEWRaUawTpQSDs722k0OTjdeAEQ7Qc,22159
|
|
152
152
|
sycommon/agent/middleware/sitecustomize.py,sha256=Bt7XFnWV_LJo89l858AGp5VUkXwefpLGmjkCtLe-CMw,4800
|
|
153
|
-
sycommon/agent/middleware/skill_api_whitelist.py,sha256=
|
|
153
|
+
sycommon/agent/middleware/skill_api_whitelist.py,sha256=2qVsrwS6VkkPKHrvjJMNUfBDokdMJkP-mn-V-ZCn7X8,16643
|
|
154
154
|
sycommon/agent/middleware/skill_wl_check.py,sha256=rCJ9F6aWPh8tVQBIPmcq2lKDsfwiJQduwSUku_8kXfs,3952
|
|
155
155
|
sycommon/agent/middleware/skill_write_guard.py,sha256=ZbeRysGBqn0knjmDWRgPu7c8Pr5lGnjFgeEpAukSSWQ,2702
|
|
156
156
|
sycommon/agent/sandbox/__init__.py,sha256=jR7LlkD4J4Y6QYyRXQClkwmqDBCCPmycV_hQV9p9YHw,4621
|
|
157
157
|
sycommon/agent/sandbox/consistent_hash.py,sha256=8Jgk-W4NAD2-u5_vKRVlPmql_e0Vy4aNORiXOuzvIrs,7277
|
|
158
|
-
sycommon/agent/sandbox/file_ops.py,sha256=
|
|
159
|
-
sycommon/agent/sandbox/http_sandbox_backend.py,sha256=
|
|
158
|
+
sycommon/agent/sandbox/file_ops.py,sha256=VTK7xduMMWLVTr9JViMnmPeh7s8Ojr8YwYA4ovMZZg0,37138
|
|
159
|
+
sycommon/agent/sandbox/http_sandbox_backend.py,sha256=1Cj_JYdvwP3PFPa_4xFPnrGdt67Wp5iQdoziSzjeqoE,70481
|
|
160
160
|
sycommon/agent/sandbox/minio_sync.py,sha256=NuvgI2SQb6pdegei_a4pCqrwTjC8DTxkCvM3ABadljs,29326
|
|
161
161
|
sycommon/agent/sandbox/sandbox_pool.py,sha256=eMn8sLakCWf90l6ni2-333QM8oBdX1CflV-WzneFp_k,9133
|
|
162
162
|
sycommon/agent/sandbox/sandbox_recovery.py,sha256=X-eDODx1tmGMh_iTngV6e1ppfDBHpTdkPreJusN5MHY,7358
|
|
@@ -234,7 +234,7 @@ sycommon/middleware/exception.py,sha256=UAy0tKijI_2JoKjwT3h62aL-tybftP3IETvcr26N
|
|
|
234
234
|
sycommon/middleware/middleware.py,sha256=1qA_0rT7pv1xTfuvA0kdYkxVOHYT6roSSNO7Cmf2wYg,1778
|
|
235
235
|
sycommon/middleware/monitor_memory.py,sha256=pYRK-wRuDd6enSg9Pf8tQxPdYQS6S0AyjyXeKFRLKEs,628
|
|
236
236
|
sycommon/middleware/mq.py,sha256=9X6KKtadFjBXKS5L3kEKujYio9wwGfWgXwWOAHO-HDg,254
|
|
237
|
-
sycommon/middleware/sandbox.py,sha256=
|
|
237
|
+
sycommon/middleware/sandbox.py,sha256=CFisRjXs_hqktuxElDmZB-xKX2vb-Rk2xHE7pc0wf_w,61417
|
|
238
238
|
sycommon/middleware/timeout.py,sha256=KlxOPa8xl2dg6yuRi_EzkVJG8bX4stb5ueYxctzzGM8,1433
|
|
239
239
|
sycommon/middleware/token_tracking.py,sha256=rEbgV1bgWMdzAERx4aq5XAvOIT6jTY_tK1P0xHJnL3o,6609
|
|
240
240
|
sycommon/middleware/tool_result_truncation.py,sha256=SREo6tb_RDHxqiQdO0N82pio-Cpo0YSrHNa1sASUafM,12429
|
|
@@ -308,8 +308,8 @@ sycommon/tools/syemail.py,sha256=BDFhgf7WDOQeTcjxJEQdu0dQhnHFPO_p3eI0-Ni3LhQ,561
|
|
|
308
308
|
sycommon/tools/timing.py,sha256=OiiE7P07lRoMzX9kzb8sZU9cDb0zNnqIlY5pWqHcnkY,2064
|
|
309
309
|
sycommon/xxljob/__init__.py,sha256=7eoBlQxv-B39IfRSCY2bkqdGYs1QRe1umAWd88VMEEM,86
|
|
310
310
|
sycommon/xxljob/xxljob_service.py,sha256=1yifwIBNGsCIxLnQjHKiBlbsigc_zvPH-dMTZcNxe-Q,7649
|
|
311
|
-
sycommon_python_lib-0.2.
|
|
312
|
-
sycommon_python_lib-0.2.
|
|
313
|
-
sycommon_python_lib-0.2.
|
|
314
|
-
sycommon_python_lib-0.2.
|
|
315
|
-
sycommon_python_lib-0.2.
|
|
311
|
+
sycommon_python_lib-0.2.7a23.dist-info/METADATA,sha256=-nxp2cHfOFVzCFhRco15gVP5z2m1FX-offmy-DKZDs4,7962
|
|
312
|
+
sycommon_python_lib-0.2.7a23.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
313
|
+
sycommon_python_lib-0.2.7a23.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
|
|
314
|
+
sycommon_python_lib-0.2.7a23.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
|
|
315
|
+
sycommon_python_lib-0.2.7a23.dist-info/RECORD,,
|
|
File without changes
|
{sycommon_python_lib-0.2.7a22.dist-info → sycommon_python_lib-0.2.7a23.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{sycommon_python_lib-0.2.7a22.dist-info → sycommon_python_lib-0.2.7a23.dist-info}/top_level.txt
RENAMED
|
File without changes
|