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.
@@ -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
- self._pushed: bool = False # 注入脚本是否已推送(每 backend 实例一次)
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
- """把 sitecustomize.py + skill_wl_check.py 推到沙箱 workspace 内的 .wl 目录。
242
+ """把 loader + 2 个 base64 payload 推到沙箱 workspace 内的 .wl 目录。
159
243
 
160
- 沙箱强制路径隔离:任何绝对路径都会被 resolve_sandbox_path 映射进用户 workspace,
161
- 真实 site-packages 无法写入。故改用「推到 workspace/.wl/ + export PYTHONPATH」
162
- 的方案:Python 启动时若 .wl PYTHONPATH 上,会自动 import sitecustomize。
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
- files = []
173
- for src, dst in _PUSH_FILES:
174
- content = _load_resource_bytes(src)
175
- files.append((dst, content))
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
- self._pushed = True
178
- SYLogger.info("[Whitelist] 注入脚本已推送至沙箱 %s", _WL_DIR)
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
- if tc.get("name") != "execute":
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
- # 服务端 /ls 失败时返回 [](无 error 字段),HTTP 层失败已进 except
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["error"])
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=str(e))
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["error"])
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=str(e))
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
- delete_result = DeleteResult(
759
- path=result.get("path", path),
760
- error=result.get("error"),
761
- )
762
- if delete_result.error:
763
- SYLogger.error(f"[Sandbox] 删除失败: {delete_result.error}")
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=str(e))
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
- delete_result = DeleteResult(
795
- path=result.get("path", path),
796
- error=result.get("error"),
797
- )
798
- if delete_result.error:
799
- SYLogger.error(f"[Sandbox] 异步删除失败: {delete_result.error}")
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=str(e))
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
- if ls_result and not ls_result.get("error"):
1024
- for item in ls_result.get("files", ls_result.get("children", [])):
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
- if ls_result and not ls_result.get("error"):
1402
- for item in ls_result.get("files", ls_result.get("children", [])):
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}")
@@ -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", response_model=list[FileInfo])
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
- return results
654
+ return None, f"list_failed: {e}"
655
+ return results, None
630
656
 
631
- result = await asyncio.to_thread(_ls)
632
- if result is None:
633
- SYLogger.error(f"[Sandbox Server] 列目录失败: 目录不存在 {target_path}")
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
- lines.append(line.rstrip('\n').rstrip('\r'))
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
- with open(file_path, 'wb') as f:
776
- f.write(content)
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
- with open(file_path, 'rb') as f:
831
- raw = f.read()
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
- with open(file_path, 'wb') as f:
872
- f.write(new_content.encode('utf-8'))
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
- with open(file_path, 'wb') as f:
899
- f.write(content)
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
- with open(file_path, 'wb') as f:
960
- f.write(content)
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
- grep_opts = "-rHnF"
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
- stdout, stderr = await proc.communicate()
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
- for line in result_text.strip().split("\n"):
1173
- if not line:
1174
- continue
1175
- first_colon = line.find(":")
1176
- if first_colon == -1:
1177
- continue
1178
- second_colon = line.find(":", first_colon + 1)
1179
- if second_colon == -1:
1180
- continue
1181
-
1182
- path_part = line[:first_colon]
1183
-
1184
- # 路径安全检查(对齐 deepagents 0.6.4):
1185
- # resolve 后必须在 workspace 内,跳过 symlink 逃逸的结果
1186
- try:
1187
- resolved = os.path.realpath(path_part)
1188
- if not _is_path_within(resolved, workspace_resolved):
1189
- SYLogger.warning(f"[Sandbox Server] grep 跳过逃逸路径: {path_part}")
1190
- continue
1191
- except OSError:
1192
- continue
1193
-
1194
- # 将绝对路径转换为虚拟路径
1195
- try:
1196
- virtual_path_part = _to_virtual_path(path_part, workspace)
1197
- except ValueError:
1198
- virtual_path_part = path_part
1199
- try:
1200
- line_num = int(line[first_colon + 1:second_colon])
1201
- except ValueError:
1202
- continue
1203
- text_part = line[second_colon + 1:]
1204
-
1205
- matches.append(GrepMatch(
1206
- path=virtual_path_part,
1207
- line=line_num,
1208
- text=text_part
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sycommon-python-lib
3
- Version: 0.2.7a22
3
+ Version: 0.2.7a23
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -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=h3CntW-FIdAfrKMo4QoC6lA35f8Y1iH7hcHldYMi4lw,10946
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=e9OwBZZfOtFvVhJ1Lixggi4eD2Djn56naZLiF_YIpa0,35128
159
- sycommon/agent/sandbox/http_sandbox_backend.py,sha256=2rF33c1x1n2c1H6P_H-AzS7_VoLbDD1xk3l7ZSwJSj8,69863
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=BfbVYeYgzbndOc60Cu0ZJbUby1YFOT-q0G_h5AhxVjw,54340
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.7a22.dist-info/METADATA,sha256=O2pYEf0WwGJmrL74b_5LZ9TWxd1JBt5xsQo0yQZ3SV4,7962
312
- sycommon_python_lib-0.2.7a22.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
313
- sycommon_python_lib-0.2.7a22.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
314
- sycommon_python_lib-0.2.7a22.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
315
- sycommon_python_lib-0.2.7a22.dist-info/RECORD,,
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,,